Skip to content

Commit f47796d

Browse files
committed
🎸 feat: allow recursive schemas
1 parent 19c8f1c commit f47796d

File tree

5 files changed

+134
-30
lines changed

5 files changed

+134
-30
lines changed

src/number.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const int = (min: number, max: number): number => {
2+
let int = Math.round(Math.random() * (max - min) + min);
3+
int = Math.max(min, Math.min(max, int));
4+
return int;
5+
};

src/structured/TemplateJson.ts

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1+
import {int} from "../number";
12
import {randomString} from "../string";
23
import {clone} from "../util";
34
import * as templates from "./templates";
4-
import type {ArrayTemplate, BooleanTemplate, FloatTemplate, IntegerTemplate, LiteralTemplate, NumberTemplate, ObjectTemplate, OrTemplate, StringTemplate, Template, TemplateNode} from "./types";
5+
import type {ArrayTemplate, BooleanTemplate, FloatTemplate, IntegerTemplate, LiteralTemplate, NumberTemplate, ObjectTemplate, OrTemplate, StringTemplate, Template, TemplateNode, TemplateShorthand} from "./types";
56

67
export interface TemplateJsonOpts {
7-
maxNodeCount?: number;
8+
/**
9+
* Sets the limit of maximum number of JSON nodes to generate. This is a soft
10+
* limit: once this limit is reached, no further optional values are generate
11+
* (optional object and map keys are not generated, arrays are generated with
12+
* their minimum required size).
13+
*/
14+
maxNodes?: number;
815
}
916

1017
export class TemplateJson {
@@ -17,16 +24,16 @@ export class TemplateJson {
1724
protected maxNodes: number;
1825

1926
constructor(public readonly template: Template = templates.nil, public readonly opts: TemplateJsonOpts = {}) {
20-
this.maxNodes = opts.maxNodeCount ?? 100;
27+
this.maxNodes = opts.maxNodes ?? 100;
2128
}
2229

2330
public gen(): unknown {
2431
return this.generate(this.template);
2532
}
2633

2734
protected generate(tpl: Template): unknown {
28-
if (this.nodes >= this.maxNodes) return null;
2935
this.nodes++;
36+
while (typeof tpl === 'function') tpl = tpl();
3037
const template: TemplateNode = typeof tpl === 'string' ? [tpl] : tpl;
3138
const type = template[0];
3239
switch (type) {
@@ -55,13 +62,19 @@ export class TemplateJson {
5562
}
5663
}
5764

65+
protected minmax(min: number, max: number): number {
66+
if (this.nodes > this.maxNodes) return min;
67+
if (this.nodes + max > this.maxNodes) max = this.maxNodes - this.nodes;
68+
if (max < min) max = min;
69+
return int(min, max);
70+
}
71+
5872
protected generateArray(template: ArrayTemplate): unknown[] {
5973
const [, min = 0, max = 5, itemTemplate = 'nil', head = [], tail = []] = template;
60-
const length = Math.floor(Math.random() * (max - min + 1)) + min;
74+
const length = this.minmax(min, max);
6175
const result: unknown[] = [];
6276
for (const tpl of head) result.push(this.generate(tpl));
63-
const mainCount = Math.max(0, length - head.length - tail.length);
64-
for (let i = 0; i < mainCount; i++) result.push(this.generate(itemTemplate));
77+
for (let i = 0; i < length; i++) result.push(this.generate(itemTemplate));
6578
for (const tpl of tail) result.push(this.generate(tpl));
6679
return result;
6780
}
@@ -71,8 +84,11 @@ export class TemplateJson {
7184
const result: Record<string, unknown> = {};
7285
for (const field of fields) {
7386
const [keyToken, valueTemplate = 'nil', optionality = 0] = field;
74-
if (optionality && Math.random() < optionality) continue;
75-
const key = randomString(keyToken ?? templates.tokensHelloWorld);
87+
if (optionality) {
88+
if (this.nodes > this.maxNodes) continue;
89+
if (Math.random() < optionality) continue;
90+
}
91+
const key = randomString(keyToken ?? templates.tokensObjectKey);
7692
const value = this.generate(valueTemplate);
7793
result[key] = value;
7894
}
@@ -90,31 +106,28 @@ export class TemplateJson {
90106

91107
protected generateInteger(template: IntegerTemplate): number {
92108
const [, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER] = template;
93-
let int = Math.round(Math.random() * (max - min) + min);
94-
int = Math.max(Number.MIN_SAFE_INTEGER, Math.min(Number.MAX_SAFE_INTEGER, int));
95-
return int;
109+
return int(min, max);
96110
}
97111

98112
protected generateFloat(template: FloatTemplate): number {
99113
const [, min = -Number.MAX_VALUE, max = Number.MAX_VALUE] = template;
100114
let float = Math.random() * (max - min) + min;
101-
float = Math.max(-Number.MAX_VALUE, Math.min(Number.MAX_VALUE, float));
115+
float = Math.max(min, Math.min(max, float));
102116
return float;
103117
}
104118

105119
protected generateBoolean(template: BooleanTemplate): boolean {
106-
const [, value] = template;
120+
const value = template[1];
107121
return value !== undefined ? value : Math.random() < 0.5;
108122
}
109123

110124
protected generateLiteral(template: LiteralTemplate): unknown {
111-
const [, value] = template;
112-
return clone(value);
125+
return clone(template[1]);
113126
}
114127

115128
protected generateOr(template: OrTemplate): unknown {
116129
const [, ...options] = template;
117-
const randomIndex = Math.floor(Math.random() * options.length);
118-
return this.generate(options[randomIndex]);
130+
const index = int(0, options.length - 1);
131+
return this.generate(options[index]);
119132
}
120133
}

src/structured/__tests__/TemplateJson.spec.ts

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {resetMathRandom} from "../../__tests__/setup";
22
import {deterministic} from "../../util";
33
import {TemplateJson} from "../TemplateJson";
4+
import {Template} from "../types";
45

56
describe('TemplateJson', () => {
67
describe('str', () => {
@@ -14,13 +15,13 @@ describe('TemplateJson', () => {
1415

1516
test('generates string according to schema', () => {
1617
resetMathRandom();
17-
const str = TemplateJson.gen(['str', ['pick', ['foo', 'bar', 'baz']]]);
18+
const str = TemplateJson.gen(['str', ['pick', 'foo', 'bar', 'baz']]);
1819
expect(str).toBe('foo');
1920
});
2021

2122
test('handles complex string tokens', () => {
2223
resetMathRandom();
23-
const str = TemplateJson.gen(['str', ['list', 'prefix-', ['pick', ['a', 'b']], '-suffix']]);
24+
const str = TemplateJson.gen(['str', ['list', 'prefix-', ['pick', 'a', 'b'], '-suffix']]);
2425
expect(str).toBe('prefix-a-suffix');
2526
});
2627
});
@@ -225,7 +226,7 @@ describe('TemplateJson', () => {
225226
test('can use token for key generation', () => {
226227
resetMathRandom();
227228
const obj = TemplateJson.gen(['obj', [
228-
[['pick', ['key1', 'key2']], 'str']
229+
[['pick', 'key1', 'key2'], 'str']
229230
]]);
230231
expect(typeof obj).toBe('object');
231232
const keys = Object.keys(obj as any);
@@ -265,15 +266,15 @@ describe('TemplateJson', () => {
265266

266267
describe('maxNodeCount', () => {
267268
test('respects node count limit', () => {
268-
const result = TemplateJson.gen(['arr', 100, 100, 'str'], { maxNodeCount: 5 });
269+
const result = TemplateJson.gen(['arr', 1, 100, 'str'], { maxNodes: 5 }) as any[];
269270
expect(Array.isArray(result)).toBe(true);
270-
const arr = result as any[];
271-
expect(arr.some(item => item === null)).toBe(true);
271+
expect(result.length > 2).toBe(true);
272+
expect(result.length < 10).toBe(true);
272273
});
273274

274275
test('works with nested structures', () => {
275276
const template: any = ['arr', 3, 3, ['obj', [['key', 'str']]]];
276-
const result = TemplateJson.gen(template, { maxNodeCount: 10 });
277+
const result = TemplateJson.gen(template, { maxNodes: 10 });
277278
expect(Array.isArray(result)).toBe(true);
278279
});
279280
});
@@ -317,3 +318,57 @@ describe('TemplateJson', () => {
317318
});
318319
});
319320
});
321+
322+
describe('recursive templates', () => {
323+
test('handles recursive structures', () => {
324+
const user = (): Template => ['obj', [
325+
['id', ['str', ['repeat', 4, 8, ['pick', ...'0123456789'.split('')]]]],
326+
['friend', user, .2]
327+
]];
328+
const result = deterministic(123, () => TemplateJson.gen(user));
329+
expect(result).toEqual({
330+
"id": "4960",
331+
"friend": {
332+
"id": "93409",
333+
"friend": {
334+
"id": "898338",
335+
"friend": {
336+
"id": "638225",
337+
"friend": {
338+
"id": "1093",
339+
"friend": {
340+
"id": "7985",
341+
"friend": {
342+
"id": "7950",
343+
"friend": {
344+
"id": "593382",
345+
"friend": {
346+
"id": "9670919"
347+
}
348+
}
349+
}
350+
}
351+
}
352+
}
353+
}
354+
}
355+
});
356+
});
357+
358+
test('can limit number of nodes', () => {
359+
const user = (): Template => ['obj', [
360+
['id', ['str', ['repeat', 4, 8, ['pick', ...'0123456789'.split('')]]]],
361+
['friend', user, .2]
362+
]];
363+
const result = deterministic(123, () => TemplateJson.gen(user, {maxNodes: 5}));
364+
expect(result).toEqual({
365+
"id": "4960",
366+
"friend": {
367+
"id": "93409",
368+
"friend": {
369+
"id": "898338"
370+
}
371+
}
372+
});
373+
});
374+
});

src/structured/templates.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,20 @@ import type {StringTemplate, Template} from './types';
44
export const nil: Template = 'nil';
55

66
export const tokensHelloWorld: Token = ['list',
7-
['pick', ['hello', 'Hello', 'Halo', 'Hi', 'Hey', 'Greetings', 'Salutations']],
8-
['pick', ['', ',']],
7+
['pick', 'hello', 'Hello', 'Halo', 'Hi', 'Hey', 'Greetings', 'Salutations'],
8+
['pick', '', ','],
99
' ',
10-
['pick', ['world', 'World', 'Earth', 'Globe', 'Planet']],
11-
['pick', ['', '!']],
10+
['pick', 'world', 'World', 'Earth', 'Globe', 'Planet'],
11+
['pick', '', '!'],
12+
];
13+
14+
export const tokensObjectKey: Token = ['pick',
15+
['pick', 'id', 'name', 'type', 'tags', '_id', '.git', '__proto__', ''],
16+
['list',
17+
['pick', 'user', 'group', '__system__'],
18+
['pick', '.', ':', '_', '$'],
19+
['pick', 'id', '$namespace', '$']
20+
]
1221
];
1322

1423
export const str: StringTemplate = ['str', tokensHelloWorld];

src/structured/types.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import {Token} from "../string";
55
*/
66
export type Template =
77
| TemplateShorthand
8-
| TemplateNode;
8+
| TemplateNode
9+
| TemplateRecursiveReference;
910

1011
export type TemplateNode =
1112
| LiteralTemplate
@@ -21,6 +22,27 @@ export type TemplateNode =
2122

2223
export type TemplateShorthand = 'num' | 'int' | 'float' | 'str' | 'bool' | 'nil' | 'arr' | 'obj';
2324

25+
/**
26+
* Recursive reference allows for recursive template construction, for example:
27+
*
28+
* ```ts
29+
* const user = (): Template => ['obj', [
30+
* ['id', ['str', ['repeat', 4, 8, ['pick', ...'0123456789'.split('')]]]],
31+
* ['friend', user, .2] // <--- Probability 20%
32+
* ]];
33+
* ```
34+
*
35+
* The above corresponds to:
36+
*
37+
* ```ts
38+
* interface User {
39+
* id: string;
40+
* friend?: User; // <--- Recursive
41+
* }
42+
* ```
43+
*/
44+
export type TemplateRecursiveReference = () => Template;
45+
2446
/**
2547
* Literal value template. The literal value is deeply cloned when generating
2648
* the random JSON and inserted as-is.

0 commit comments

Comments
 (0)