Skip to content

Commit f0834db

Browse files
authored
Support PICT-compatible inline schema definitions (#187)
1 parent 88489fb commit f0834db

9 files changed

Lines changed: 260 additions & 8 deletions

File tree

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ The spec is a paragraph of text where each line is either a 'name' or a 'rule':
8888

8989
- **Comments**: lines starting with `#` (optionally prefixed by whitespace) are treated as comments.
9090
- **Blank lines**: blank lines are allowed and ignored, so you can separate column groups for readability.
91-
- **Column definitions**: each column is defined as `name` followed by `rule` on the next logical content line.
91+
- **Column definitions**: each column can be defined either as `name` followed by `rule` on the next logical content line, or inline as `name: rule`.
9292
- **Constraints**: optional `IF ... THEN ...` statements may appear in text mode after the field definitions, terminated by either `;` or `ENDIF`.
9393

9494
```
@@ -101,6 +101,9 @@ rule
101101
name
102102
rule
103103
104+
# compact pict-style alternative
105+
status: enum(active,inactive)
106+
104107
IF [name] = "Bob" THEN [status] = "active" ENDIF
105108
```
106109

docs-src/docs/040-test-data/018-Schema-Definition.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ This page explains:
1616

1717
## Basic schema format
1818

19-
A schema is written as repeating two-line field definitions:
19+
A schema is usually written as repeating two-line field definitions:
2020

2121
```text
2222
Column Name
@@ -32,6 +32,14 @@ enum("Open","In Progress","Closed")
3232

3333
This creates one output column called `Status`.
3434

35+
You can also use a compact inline form when you prefer a PICT-style layout:
36+
37+
```text
38+
Status: enum("Open","In Progress","Closed")
39+
```
40+
41+
Both formats are supported, and you can mix them in the same schema.
42+
3543
## Field rule examples
3644

3745
### Literal values

packages/core/js/data_generation/rulesParser.js

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,57 @@ function startsConstraint(trimmedLine) {
66
return /^IF\s+(?:\[|\(|NOT\b)/i.test(trimmedLine);
77
}
88

9+
function looksLikeInlineRuleSpec(ruleText) {
10+
const trimmed = String(ruleText ?? '').trim();
11+
if (trimmed.length === 0 || startsConstraint(trimmed)) {
12+
return false;
13+
}
14+
15+
if (
16+
/^(?:enum|literal|regex|datatype\.(?:enum|literal|regex)|awd\.datatype\.(?:enum|literal|regex))\s*\(/i.test(trimmed)
17+
) {
18+
return true;
19+
}
20+
21+
if (/^(?:faker\.)?[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+(?:\s*\(.*\)\s*|\s*)$/i.test(trimmed)) {
22+
return true;
23+
}
24+
25+
if (!trimmed.includes(',')) {
26+
return false;
27+
}
28+
29+
const values = trimmed.split(',').map((value) => value.trim());
30+
if (values.length < 2 || values.some((value) => value.length === 0 || value.length > 50)) {
31+
return false;
32+
}
33+
34+
return !values.some((value) => /[[\]{}()^$*+?|\\]/.test(value) || (value.includes('.') && /[A-Z]/.test(value)));
35+
}
36+
37+
function parseInlineRuleDefinition(line) {
38+
const source = String(line ?? '');
39+
for (let index = 0; index < source.length; index += 1) {
40+
if (source[index] !== ':') {
41+
continue;
42+
}
43+
44+
const name = source.slice(0, index).trim();
45+
const rule = source.slice(index + 1).trim();
46+
if (name.length === 0 || !looksLikeInlineRuleSpec(rule)) {
47+
continue;
48+
}
49+
50+
return {
51+
name,
52+
rule,
53+
separator: ': ',
54+
};
55+
}
56+
57+
return null;
58+
}
59+
960
function isEscapedQuote(text, index) {
1061
let backslashCount = 0;
1162
let back = index - 1;
@@ -144,6 +195,22 @@ export class RulesParser {
144195
pendingLeadingTextLines = [];
145196
continue;
146197
}
198+
const inlineRule = parseInlineRuleDefinition(line);
199+
if (inlineRule) {
200+
this.testDataRules.addRule(inlineRule.name, inlineRule.rule, {
201+
comments: pendingLeadingTextLines.join('\n'),
202+
});
203+
this.schemaTokens.push({
204+
kind: 'rule',
205+
name: inlineRule.name,
206+
rule: inlineRule.rule,
207+
line: index + 1,
208+
inline: true,
209+
separator: inlineRule.separator,
210+
});
211+
pendingLeadingTextLines = [];
212+
continue;
213+
}
147214
pendingName = trimmed;
148215
pendingNameLine = index + 1;
149216
continue;
@@ -216,8 +283,12 @@ export class RulesParser {
216283
}
217284
if (token.kind === 'rule') {
218285
if (rowIndex < rows.length) {
219-
outputLines.push(rows[rowIndex].name);
220-
outputLines.push(rows[rowIndex].rule);
286+
if (token.inline) {
287+
outputLines.push(`${rows[rowIndex].name}${token.separator || ': '}${rows[rowIndex].rule}`);
288+
} else {
289+
outputLines.push(rows[rowIndex].name);
290+
outputLines.push(rows[rowIndex].rule);
291+
}
221292
rowIndex += 1;
222293
}
223294
}

packages/core/js/data_generation/schema-conversion.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,12 @@ function renderSpecFromRulesWithTokens(rules, constraints, schemaTokens) {
100100
return;
101101
}
102102
if (token?.kind === 'rule' && rowIndex < rows.length) {
103-
outputLines.push(rows[rowIndex].name);
104-
outputLines.push(rows[rowIndex].rule);
103+
if (token.inline) {
104+
outputLines.push(`${rows[rowIndex].name}${token.separator || ': '}${rows[rowIndex].rule}`);
105+
} else {
106+
outputLines.push(rows[rowIndex].name);
107+
outputLines.push(rows[rowIndex].rule);
108+
}
105109
rowIndex += 1;
106110
}
107111
});

packages/core/src/index.js

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,73 @@ const SUPPORTED_FORMATS = [
7878
'asciitable',
7979
];
8080

81+
function looksLikeInlineSchemaRule(ruleText) {
82+
const trimmed = String(ruleText ?? '').trim();
83+
if (trimmed.length === 0 || /^IF\s+(?:\[|\(|NOT\b)/i.test(trimmed)) {
84+
return false;
85+
}
86+
87+
if (
88+
/^(?:enum|literal|regex|datatype\.(?:enum|literal|regex)|awd\.datatype\.(?:enum|literal|regex))\s*\(/i.test(trimmed)
89+
) {
90+
return true;
91+
}
92+
93+
if (/^(?:faker\.)?[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+(?:\s*\(.*\)\s*|\s*)$/i.test(trimmed)) {
94+
return true;
95+
}
96+
97+
if (!trimmed.includes(',')) {
98+
return false;
99+
}
100+
101+
const values = trimmed.split(',').map((value) => value.trim());
102+
if (values.length < 2 || values.some((value) => value.length === 0 || value.length > 50)) {
103+
return false;
104+
}
105+
106+
return !values.some((value) => /[[\]{}()^$*+?|\\]/.test(value) || (value.includes('.') && /[A-Z]/.test(value)));
107+
}
108+
81109
function extractRuleLines(textSpec) {
82110
if (typeof textSpec !== 'string') {
83111
return [];
84112
}
85113
const lines = textSpec.split(/\r?\n/);
86114
const ruleLines = [];
87-
for (let i = 1; i < lines.length; i += 2) {
88-
ruleLines.push(lines[i].trim());
115+
let pendingName = null;
116+
for (const line of lines) {
117+
const trimmed = line.trim();
118+
if (trimmed.length === 0 || /^\s*#/.test(line) || /^IF\s+(?:\[|\(|NOT\b)/i.test(trimmed)) {
119+
pendingName = null;
120+
continue;
121+
}
122+
123+
let matchedInlineRule = false;
124+
for (let separatorIndex = 0; separatorIndex < line.length; separatorIndex += 1) {
125+
if (line[separatorIndex] !== ':') {
126+
continue;
127+
}
128+
const rule = line.slice(separatorIndex + 1).trim();
129+
if (looksLikeInlineSchemaRule(rule)) {
130+
ruleLines.push(rule);
131+
pendingName = null;
132+
matchedInlineRule = true;
133+
break;
134+
}
135+
}
136+
137+
if (matchedInlineRule) {
138+
continue;
139+
}
140+
141+
if (pendingName === null) {
142+
pendingName = trimmed;
143+
continue;
144+
}
145+
146+
ruleLines.push(trimmed);
147+
pendingName = null;
89148
}
90149
return ruleLines;
91150
}

packages/core/src/tests/core-api/amendFromTextSpecAndData.test.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,24 @@ test('defaults rowCount to imported row count', () => {
5757
expect(result.diagnostics.importedRowCount).toBe(2);
5858
});
5959

60+
test('supports pict-style inline schema definitions for amend flows', () => {
61+
const result = amendFromTextSpecAndData({
62+
textSpec: 'Status: literal(Active)\nRole: enum(Admin,User)',
63+
inputData: '"Name"\n"Alice"\n"Eve"',
64+
inputFormat: 'csv',
65+
outputFormat: 'json',
66+
});
67+
68+
expect(result.ok).toBe(true);
69+
expect(result.headers).toEqual(['Name', 'Status', 'Role']);
70+
expect(result.rows).toHaveLength(2);
71+
result.rows.forEach((row) => {
72+
expect(['Alice', 'Eve']).toContain(row[0]);
73+
expect(row[1]).toBe('Active');
74+
expect(['Admin', 'User']).toContain(row[2]);
75+
});
76+
});
77+
6078
test('amends only first N rows when rowCount is smaller', () => {
6179
const result = amendFromTextSpecAndData({
6280
textSpec: 'Name\nBob',

packages/core/src/tests/core-api/generateFromTextSpec.test.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,24 @@ test('generateFromTextSpec generates rows for valid spec', () => {
4040
assertNoCommonErrorPatternsInRows(result.rows);
4141
});
4242

43+
test('generateFromTextSpec supports pict-style inline schema definitions', () => {
44+
const result = generateFromTextSpec({
45+
textSpec: 'Browser: Chrome,Firefox,Safari\nStatus: enum("Open","Closed")\nName: person.fullName',
46+
rowCount: 3,
47+
outputFormat: 'json',
48+
});
49+
50+
expect(result.ok).toBe(true);
51+
expect(result.headers).toEqual(['Browser', 'Status', 'Name']);
52+
expect(result.rows).toHaveLength(3);
53+
result.rows.forEach((row) => {
54+
expect(['Chrome', 'Firefox', 'Safari']).toContain(row[0]);
55+
expect(['Open', 'Closed']).toContain(row[1]);
56+
expect(String(row[2]).length).toBeGreaterThan(0);
57+
});
58+
assertNoCommonErrorPatternsInRows(result.rows);
59+
});
60+
4361
test('generateFromTextSpec serializes object return values as JSON strings', () => {
4462
const result = generateFromTextSpec({
4563
textSpec: 'Currency\nfinance.currency',
@@ -255,6 +273,11 @@ test('validateSafeFakerRules accepts known faker commands with literal args', ()
255273
expect(result.ok).toBe(true);
256274
});
257275

276+
test('validateSafeFakerRules accepts pict-style inline faker commands', () => {
277+
const result = validateSafeFakerRules('Name: person.firstName("female")\nStatus: enum(active,inactive)');
278+
expect(result.ok).toBe(true);
279+
});
280+
258281
test('validateSafeFakerRules accepts js-style object literal faker args', () => {
259282
const result = validateSafeFakerRules('Template\nhelpers.mustache("{{name}}", { name: "Ada" })');
260283
expect(result.ok).toBe(true);

packages/core/src/tests/data_generation/schema-rules-adapter.test.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,30 @@ describe('schema rules adapter', () => {
8484
expect(rendered.text).toBe('t1\nliteral("")\nt2\nliteral( 123)');
8585
});
8686

87+
test('round-trips pict-style inline schema tokens', () => {
88+
const schemaText = `Priority: enum(high,medium,low)
89+
Status: person.jobTitle`;
90+
91+
const parsed = schemaTextToDataRules({
92+
schemaText,
93+
faker,
94+
RandExp,
95+
});
96+
97+
expect(parsed.errors).toEqual([]);
98+
expect(parsed.schemaTokens).toEqual([
99+
expect.objectContaining({ kind: 'rule', inline: true }),
100+
expect.objectContaining({ kind: 'rule', inline: true }),
101+
]);
102+
103+
const rendered = dataRulesToSchemaText({
104+
dataRules: parsed.dataRules,
105+
schemaTokens: parsed.schemaTokens,
106+
});
107+
108+
expect(rendered.text).toBe(schemaText);
109+
});
110+
87111
test('prefers schema tokens when rendering so blank lines are preserved', () => {
88112
const rendered = dataRulesToSchemaText({
89113
dataRules: [

packages/core/src/tests/data_generation/unit/rulesParser.test.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,22 @@ person.fullName`;
1818
expect(parser.testDataRules.rules[0].ruleSpec).toBe('person.fullName');
1919
});
2020

21+
test('can parse inline pict-style column definitions into rules', () => {
22+
const inputText = `Browser: Chrome,Firefox,Safari
23+
Status: enum("Open","Closed")
24+
Name: person.fullName`;
25+
26+
const parser = new RulesParser(faker, RandExp);
27+
parser.parseText(inputText);
28+
29+
expect(parser.isValid()).toBe(true);
30+
expect(parser.testDataRules.rules).toHaveLength(3);
31+
expect(parser.testDataRules.rules[0]).toMatchObject({ name: 'Browser', ruleSpec: 'Chrome,Firefox,Safari' });
32+
expect(parser.testDataRules.rules[1]).toMatchObject({ name: 'Status', ruleSpec: 'enum("Open","Closed")' });
33+
expect(parser.testDataRules.rules[2]).toMatchObject({ name: 'Name', ruleSpec: 'person.fullName' });
34+
expect(parser.getSchemaTokens().every((token) => token.kind !== 'rule' || token.inline === true)).toBe(true);
35+
});
36+
2137
test('flags an empty rule definition line', () => {
2238
const inputText = `Name
2339
`;
@@ -113,6 +129,17 @@ enum(active,inactive,pending)`;
113129
expect(output).toBe(inputText);
114130
});
115131

132+
test('preserves inline pict-style rules when rebuilding from parsed tokens', () => {
133+
const inputText = `# compact
134+
Priority: enum(high,medium,low)
135+
Status: person.jobTitle`;
136+
137+
const parser = new RulesParser(faker, RandExp);
138+
parser.parseText(inputText);
139+
140+
expect(parser.renderSpecFromRulesWithTokens(parser.testDataRules.rules)).toBe(inputText);
141+
});
142+
116143
test('preserves comments and blank lines when rebuilding from rule comments', () => {
117144
const inputText = `# one
118145
@@ -167,6 +194,21 @@ enum(open,closed)`;
167194
expect(parser.testDataRules.constraints).toHaveLength(0);
168195
});
169196

197+
test('does not treat non-rule colon lines as inline pict definitions', () => {
198+
const inputText = `Environment: Browser
199+
enum(chrome,firefox)`;
200+
201+
const parser = new RulesParser(faker, RandExp);
202+
parser.parseText(inputText);
203+
204+
expect(parser.isValid()).toBe(true);
205+
expect(parser.testDataRules.rules).toHaveLength(1);
206+
expect(parser.testDataRules.rules[0]).toMatchObject({
207+
name: 'Environment: Browser',
208+
ruleSpec: 'enum(chrome,firefox)',
209+
});
210+
});
211+
170212
test('does not treat ENDIF inside a parameter reference as the constraint terminator', () => {
171213
const inputText = `ENDIF
172214
enum(yes,no)

0 commit comments

Comments
 (0)