Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ The spec is a paragraph of text where each line is either a 'name' or a 'rule':

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

```
Expand All @@ -101,6 +101,9 @@ rule
name
rule

# compact pict-style alternative
status: enum(active,inactive)

IF [name] = "Bob" THEN [status] = "active" ENDIF
```

Expand Down
10 changes: 9 additions & 1 deletion docs-src/docs/040-test-data/018-Schema-Definition.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ This page explains:

## Basic schema format

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

```text
Column Name
Expand All @@ -32,6 +32,14 @@ enum("Open","In Progress","Closed")

This creates one output column called `Status`.

You can also use a compact inline form when you prefer a PICT-style layout:

```text
Status: enum("Open","In Progress","Closed")
```

Both formats are supported, and you can mix them in the same schema.

## Field rule examples

### Literal values
Expand Down
75 changes: 73 additions & 2 deletions packages/core/js/data_generation/rulesParser.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,57 @@ function startsConstraint(trimmedLine) {
return /^IF\s+(?:\[|\(|NOT\b)/i.test(trimmedLine);
}

function looksLikeInlineRuleSpec(ruleText) {
const trimmed = String(ruleText ?? '').trim();
if (trimmed.length === 0 || startsConstraint(trimmed)) {
return false;
}

if (
/^(?:enum|literal|regex|datatype\.(?:enum|literal|regex)|awd\.datatype\.(?:enum|literal|regex))\s*\(/i.test(trimmed)
) {
return true;
}

if (/^(?:faker\.)?[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+(?:\s*\(.*\)\s*|\s*)$/i.test(trimmed)) {
return true;
}

if (!trimmed.includes(',')) {
return false;
}

const values = trimmed.split(',').map((value) => value.trim());
if (values.length < 2 || values.some((value) => value.length === 0 || value.length > 50)) {
return false;
}

return !values.some((value) => /[[\]{}()^$*+?|\\]/.test(value) || (value.includes('.') && /[A-Z]/.test(value)));
}

function parseInlineRuleDefinition(line) {
const source = String(line ?? '');
for (let index = 0; index < source.length; index += 1) {
if (source[index] !== ':') {
continue;
}

const name = source.slice(0, index).trim();
const rule = source.slice(index + 1).trim();
if (name.length === 0 || !looksLikeInlineRuleSpec(rule)) {
continue;
}

return {
name,
rule,
separator: ': ',
};
}

return null;
}
Comment thread
eviltester marked this conversation as resolved.

function isEscapedQuote(text, index) {
let backslashCount = 0;
let back = index - 1;
Expand Down Expand Up @@ -144,6 +195,22 @@ export class RulesParser {
pendingLeadingTextLines = [];
continue;
}
const inlineRule = parseInlineRuleDefinition(line);
if (inlineRule) {
this.testDataRules.addRule(inlineRule.name, inlineRule.rule, {
comments: pendingLeadingTextLines.join('\n'),
});
this.schemaTokens.push({
kind: 'rule',
name: inlineRule.name,
rule: inlineRule.rule,
line: index + 1,
inline: true,
separator: inlineRule.separator,
});
pendingLeadingTextLines = [];
continue;
}
pendingName = trimmed;
pendingNameLine = index + 1;
continue;
Expand Down Expand Up @@ -216,8 +283,12 @@ export class RulesParser {
}
if (token.kind === 'rule') {
if (rowIndex < rows.length) {
outputLines.push(rows[rowIndex].name);
outputLines.push(rows[rowIndex].rule);
if (token.inline) {
outputLines.push(`${rows[rowIndex].name}${token.separator || ': '}${rows[rowIndex].rule}`);
} else {
outputLines.push(rows[rowIndex].name);
outputLines.push(rows[rowIndex].rule);
}
rowIndex += 1;
}
}
Expand Down
8 changes: 6 additions & 2 deletions packages/core/js/data_generation/schema-conversion.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,12 @@ function renderSpecFromRulesWithTokens(rules, constraints, schemaTokens) {
return;
}
if (token?.kind === 'rule' && rowIndex < rows.length) {
outputLines.push(rows[rowIndex].name);
outputLines.push(rows[rowIndex].rule);
if (token.inline) {
outputLines.push(`${rows[rowIndex].name}${token.separator || ': '}${rows[rowIndex].rule}`);
} else {
outputLines.push(rows[rowIndex].name);
outputLines.push(rows[rowIndex].rule);
}
rowIndex += 1;
}
});
Expand Down
63 changes: 61 additions & 2 deletions packages/core/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,73 @@ const SUPPORTED_FORMATS = [
'asciitable',
];

function looksLikeInlineSchemaRule(ruleText) {
const trimmed = String(ruleText ?? '').trim();
if (trimmed.length === 0 || /^IF\s+(?:\[|\(|NOT\b)/i.test(trimmed)) {
return false;
}

if (
/^(?:enum|literal|regex|datatype\.(?:enum|literal|regex)|awd\.datatype\.(?:enum|literal|regex))\s*\(/i.test(trimmed)
) {
Comment thread
eviltester marked this conversation as resolved.
return true;
}

if (/^(?:faker\.)?[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+(?:\s*\(.*\)\s*|\s*)$/i.test(trimmed)) {
return true;
}

if (!trimmed.includes(',')) {
return false;
}

const values = trimmed.split(',').map((value) => value.trim());
if (values.length < 2 || values.some((value) => value.length === 0 || value.length > 50)) {
return false;
}

return !values.some((value) => /[[\]{}()^$*+?|\\]/.test(value) || (value.includes('.') && /[A-Z]/.test(value)));
}
Comment thread
eviltester marked this conversation as resolved.

function extractRuleLines(textSpec) {
if (typeof textSpec !== 'string') {
return [];
}
const lines = textSpec.split(/\r?\n/);
const ruleLines = [];
for (let i = 1; i < lines.length; i += 2) {
ruleLines.push(lines[i].trim());
let pendingName = null;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.length === 0 || /^\s*#/.test(line) || /^IF\s+(?:\[|\(|NOT\b)/i.test(trimmed)) {
pendingName = null;
continue;
}

let matchedInlineRule = false;
for (let separatorIndex = 0; separatorIndex < line.length; separatorIndex += 1) {
if (line[separatorIndex] !== ':') {
continue;
}
const rule = line.slice(separatorIndex + 1).trim();
if (looksLikeInlineSchemaRule(rule)) {
ruleLines.push(rule);
pendingName = null;
matchedInlineRule = true;
break;
}
}

if (matchedInlineRule) {
continue;
}

if (pendingName === null) {
pendingName = trimmed;
continue;
}

ruleLines.push(trimmed);
pendingName = null;
}
return ruleLines;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,24 @@ test('defaults rowCount to imported row count', () => {
expect(result.diagnostics.importedRowCount).toBe(2);
});

test('supports pict-style inline schema definitions for amend flows', () => {
const result = amendFromTextSpecAndData({
textSpec: 'Status: literal(Active)\nRole: enum(Admin,User)',
inputData: '"Name"\n"Alice"\n"Eve"',
inputFormat: 'csv',
outputFormat: 'json',
});

expect(result.ok).toBe(true);
expect(result.headers).toEqual(['Name', 'Status', 'Role']);
expect(result.rows).toHaveLength(2);
result.rows.forEach((row) => {
expect(['Alice', 'Eve']).toContain(row[0]);
expect(row[1]).toBe('Active');
expect(['Admin', 'User']).toContain(row[2]);
});
});

test('amends only first N rows when rowCount is smaller', () => {
const result = amendFromTextSpecAndData({
textSpec: 'Name\nBob',
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/tests/core-api/generateFromTextSpec.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,24 @@ test('generateFromTextSpec generates rows for valid spec', () => {
assertNoCommonErrorPatternsInRows(result.rows);
});

test('generateFromTextSpec supports pict-style inline schema definitions', () => {
const result = generateFromTextSpec({
textSpec: 'Browser: Chrome,Firefox,Safari\nStatus: enum("Open","Closed")\nName: person.fullName',
rowCount: 3,
outputFormat: 'json',
});

expect(result.ok).toBe(true);
expect(result.headers).toEqual(['Browser', 'Status', 'Name']);
expect(result.rows).toHaveLength(3);
result.rows.forEach((row) => {
expect(['Chrome', 'Firefox', 'Safari']).toContain(row[0]);
expect(['Open', 'Closed']).toContain(row[1]);
expect(String(row[2]).length).toBeGreaterThan(0);
});
assertNoCommonErrorPatternsInRows(result.rows);
});

test('generateFromTextSpec serializes object return values as JSON strings', () => {
const result = generateFromTextSpec({
textSpec: 'Currency\nfinance.currency',
Expand Down Expand Up @@ -255,6 +273,11 @@ test('validateSafeFakerRules accepts known faker commands with literal args', ()
expect(result.ok).toBe(true);
});

test('validateSafeFakerRules accepts pict-style inline faker commands', () => {
const result = validateSafeFakerRules('Name: person.firstName("female")\nStatus: enum(active,inactive)');
expect(result.ok).toBe(true);
});

test('validateSafeFakerRules accepts js-style object literal faker args', () => {
const result = validateSafeFakerRules('Template\nhelpers.mustache("{{name}}", { name: "Ada" })');
expect(result.ok).toBe(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,30 @@ describe('schema rules adapter', () => {
expect(rendered.text).toBe('t1\nliteral("")\nt2\nliteral( 123)');
});

test('round-trips pict-style inline schema tokens', () => {
const schemaText = `Priority: enum(high,medium,low)
Status: person.jobTitle`;

const parsed = schemaTextToDataRules({
schemaText,
faker,
RandExp,
});

expect(parsed.errors).toEqual([]);
expect(parsed.schemaTokens).toEqual([
expect.objectContaining({ kind: 'rule', inline: true }),
expect.objectContaining({ kind: 'rule', inline: true }),
]);

const rendered = dataRulesToSchemaText({
dataRules: parsed.dataRules,
schemaTokens: parsed.schemaTokens,
});

expect(rendered.text).toBe(schemaText);
});

test('prefers schema tokens when rendering so blank lines are preserved', () => {
const rendered = dataRulesToSchemaText({
dataRules: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@ person.fullName`;
expect(parser.testDataRules.rules[0].ruleSpec).toBe('person.fullName');
});

test('can parse inline pict-style column definitions into rules', () => {
const inputText = `Browser: Chrome,Firefox,Safari
Status: enum("Open","Closed")
Name: person.fullName`;

const parser = new RulesParser(faker, RandExp);
parser.parseText(inputText);

expect(parser.isValid()).toBe(true);
expect(parser.testDataRules.rules).toHaveLength(3);
expect(parser.testDataRules.rules[0]).toMatchObject({ name: 'Browser', ruleSpec: 'Chrome,Firefox,Safari' });
expect(parser.testDataRules.rules[1]).toMatchObject({ name: 'Status', ruleSpec: 'enum("Open","Closed")' });
expect(parser.testDataRules.rules[2]).toMatchObject({ name: 'Name', ruleSpec: 'person.fullName' });
expect(parser.getSchemaTokens().every((token) => token.kind !== 'rule' || token.inline === true)).toBe(true);
});

test('flags an empty rule definition line', () => {
const inputText = `Name
`;
Expand Down Expand Up @@ -113,6 +129,17 @@ enum(active,inactive,pending)`;
expect(output).toBe(inputText);
});

test('preserves inline pict-style rules when rebuilding from parsed tokens', () => {
const inputText = `# compact
Priority: enum(high,medium,low)
Status: person.jobTitle`;

const parser = new RulesParser(faker, RandExp);
parser.parseText(inputText);

expect(parser.renderSpecFromRulesWithTokens(parser.testDataRules.rules)).toBe(inputText);
});

test('preserves comments and blank lines when rebuilding from rule comments', () => {
const inputText = `# one

Expand Down Expand Up @@ -167,6 +194,21 @@ enum(open,closed)`;
expect(parser.testDataRules.constraints).toHaveLength(0);
});

test('does not treat non-rule colon lines as inline pict definitions', () => {
const inputText = `Environment: Browser
enum(chrome,firefox)`;

const parser = new RulesParser(faker, RandExp);
parser.parseText(inputText);

expect(parser.isValid()).toBe(true);
expect(parser.testDataRules.rules).toHaveLength(1);
expect(parser.testDataRules.rules[0]).toMatchObject({
name: 'Environment: Browser',
ruleSpec: 'enum(chrome,firefox)',
});
});

test('does not treat ENDIF inside a parameter reference as the constraint terminator', () => {
const inputText = `ENDIF
enum(yes,no)
Expand Down