Release Fixture Tools 0.2.0

This commit is contained in:
2026-09-02 13:00:37 +02:00
parent f5d018c64f
commit 58add401e4
22 changed files with 1007 additions and 126 deletions
+39
View File
@@ -0,0 +1,39 @@
name: Verify
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
CI: "true"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Select declared npm version
run: npm install --global npm@11.17.0
- name: Install dependencies
run: npm ci
- name: Audit runtime dependencies
run: npm audit --omit=dev --audit-level=moderate
- name: Check, test, and build
run: npm run check
- name: Install browser engines
run: npx playwright install --with-deps chromium firefox webkit
- name: Browser tests
run: npm run test:browser
+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## 0.2.0 - 2026-09-02
- Add reusable provider definitions, richer relational constraints and
deterministic cross-table generation with explicit violation evidence.
## 0.1.0 - 2026-09-01 ## 0.1.0 - 2026-09-01
- Add bounded JSON Schema, SQL DDL, XSD, CSV-heading, and fixture-recipe import - Add bounded JSON Schema, SQL DDL, XSD, CSV-heading, and fixture-recipe import
+12 -4
View File
@@ -3,18 +3,25 @@
Generate deterministic, privacy-safe synthetic test fixtures entirely in the Generate deterministic, privacy-safe synthetic test fixtures entirely in the
browser. Fixture Tools is a local-first module in the add·ideas Toolbox. browser. Fixture Tools is a local-first module in the add·ideas Toolbox.
## v0.1.0 capabilities ## v0.2.0 capabilities
- Import a focused subset of JSON Schema, SQL `CREATE TABLE` DDL, XSD, CSV - Import a focused subset of JSON Schema, SQL `CREATE TABLE` DDL, XSD, CSV
headings, or the app's portable fixture-model JSON headings, or the app's portable fixture-model JSON
- Review and edit normalized tables, fields, types, required/unique rules, - Review and edit normalized tables, fields, types, required/unique rules,
numeric and length ranges, enumerations, and foreign-key relationships numeric and length ranges, enumerations, scalar foreign keys, composite
unique constraints, and composite foreign-key relationships
- Generate reproducible rows from a text seed with uniform, sequential, - Generate reproducible rows from a text seed with uniform, sequential,
normal, and constant distributions normal, and constant distributions
- Deliberately exercise boundary values or inject invalid values, with a - Deliberately exercise boundary values or inject invalid values, with a
separate violation ledger so negative-test fixtures cannot be mistaken for separate violation ledger so negative-test fixtures cannot be mistaken for
valid data valid data
- Preserve referential integrity and field uniqueness for valid generated rows - Preserve referential integrity and field uniqueness for valid generated rows
- Generate tables in a deterministic dependency-topological order, with an
explicit error for cyclic references
- Audit positive, boundary and intentional-negative exercise counts in a
constraint coverage matrix
- Select deterministic provider profiles (person, network, commerce), with a
typed extension point for additional local profiles
- Export JSON, spreadsheet-safe CSV, NDJSON, XML, SQL `INSERT` statements, or a - Export JSON, spreadsheet-safe CSV, NDJSON, XML, SQL `INSERT` statements, or a
reusable fixture recipe reusable fixture recipe
- Use only reserved example domains for generated email addresses - Use only reserved example domains for generated email addresses
@@ -24,8 +31,9 @@ browser. Fixture Tools is a local-first module in the add·ideas Toolbox.
The importers normalize common declarative constructs; they are not complete The importers normalize common declarative constructs; they are not complete
JSON Schema, SQL, or XML Schema validators. Remote references, schema code, JSON Schema, SQL, or XML Schema validators. Remote references, schema code,
database connections, XML entities, XSD `choice`/identity constraints, SQL database connections, XML entities, XSD `choice`/identity constraints, SQL
dialects beyond common table/column clauses, and production-scale data loads are dialects beyond common table/column clauses, cyclic relational recipes, and
out of scope in v0.1.0. production-scale data loads are out of scope in v0.2.0. Coverage counts describe
generated exercises; they do not prove schema or application coverage.
Inputs are capped at 2 MiB. A recipe can contain at most 20 tables and 100 Inputs are capped at 2 MiB. A recipe can contain at most 20 tables and 100
fields per table; generation is capped at 50,000 total rows and 8 MiB per fields per table; generation is capped at 50,000 total rows and 8 MiB per
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source # Corresponding source
The corresponding source for Fixture Tools 0.1.0 is available at: The corresponding source for Fixture Tools 0.2.0 is available at:
https://git.add-ideas.de/lotobo/fixture-tools/src/tag/v0.1.0 https://git.add-ideas.de/lotobo/fixture-tools/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`. Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+4 -4
View File
@@ -1,12 +1,12 @@
# Third-party notices # Third-party notices
Fixture Tools 0.1.0 directly depends on these runtime packages: Fixture Tools 0.2.0 directly depends on these runtime packages:
| Package | Pinned version | Declared licence | | Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- | | -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later | | `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `react` | 19.2.8 | MIT | | `react` | 19.2.8 | MIT |
| `react-dom` | 19.2.8 | MIT | | `react-dom` | 19.2.8 | MIT |
+19 -19
View File
@@ -1,22 +1,22 @@
{ {
"name": "fixture-tools", "name": "fixture-tools",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "fixture-tools", "name": "fixture-tools",
"version": "0.1.0", "version": "0.2.0",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
@@ -42,24 +42,25 @@
} }
}, },
"node_modules/@add-ideas/toolbox-contract": { "node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3", "version": "0.3.0",
"license": "Apache-2.0", "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
"engines": { "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"node": ">=20" "license": "Apache-2.0"
}
}, },
"node_modules/@add-ideas/toolbox-helpers": { "node_modules/@add-ideas/toolbox-helpers": {
"version": "0.1.0", "version": "0.2.0",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"engines": { "engines": {
"node": ">=22" "node": ">=22"
} }
}, },
"node_modules/@add-ideas/toolbox-shell-react": { "node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=18 <20", "react": ">=18 <20",
@@ -67,17 +68,16 @@
} }
}, },
"node_modules/@add-ideas/toolbox-testkit": { "node_modules/@add-ideas/toolbox-testkit": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
"integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"bin": { "bin": {
"toolbox-check": "dist/cli.js" "toolbox-check": "dist/cli.js"
},
"engines": {
"node": ">=20"
} }
}, },
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "fixture-tools", "name": "fixture-tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Generate deterministic, privacy-safe synthetic test fixtures locally in the browser.", "description": "Generate deterministic, privacy-safe synthetic test fixtures locally in the browser.",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"author": "Albrecht Degering", "author": "Albrecht Degering",
@@ -39,14 +39,14 @@
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force" "release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
}, },
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -15,7 +15,25 @@ export default defineConfig({
timeout: 180_000, timeout: 180_000,
}, },
projects: [ projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } }, {
{ name: "firefox", use: { ...devices["Desktop Firefox"] } }, name: "chromium",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Safari"] },
},
{
name: "mobile-chromium",
testMatch: /responsive\.spec\.ts/,
use: { ...devices["Pixel 5"] },
},
], ],
}); });
+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## 0.2.0 - 2026-09-02
- Add reusable provider definitions, richer relational constraints and
deterministic cross-table generation with explicit violation evidence.
## 0.1.0 - 2026-09-01 ## 0.1.0 - 2026-09-01
- Add bounded JSON Schema, SQL DDL, XSD, CSV-heading, and fixture-recipe import - Add bounded JSON Schema, SQL DDL, XSD, CSV-heading, and fixture-recipe import
+3 -3
View File
@@ -1,5 +1,5 @@
============================================================================== ==============================================================================
@add-ideas/toolbox-contract@0.2.3 @add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0 Declared licence: Apache-2.0
============================================================================== ==============================================================================
--- LICENSE --- --- LICENSE ---
@@ -198,7 +198,7 @@ Declared licence: Apache-2.0
============================================================================== ==============================================================================
@add-ideas/toolbox-helpers@0.1.0 @add-ideas/toolbox-helpers@0.2.0
Declared licence: GPL-3.0-or-later Declared licence: GPL-3.0-or-later
============================================================================== ==============================================================================
--- LICENSE --- --- LICENSE ---
@@ -879,7 +879,7 @@ Public License instead of this License. But first, please read
============================================================================== ==============================================================================
@add-ideas/toolbox-shell-react@0.2.3 @add-ideas/toolbox-shell-react@0.3.0
Declared licence: Apache-2.0 Declared licence: Apache-2.0
============================================================================== ==============================================================================
--- LICENSE --- --- LICENSE ---
+12 -4
View File
@@ -3,18 +3,25 @@
Generate deterministic, privacy-safe synthetic test fixtures entirely in the Generate deterministic, privacy-safe synthetic test fixtures entirely in the
browser. Fixture Tools is a local-first module in the add·ideas Toolbox. browser. Fixture Tools is a local-first module in the add·ideas Toolbox.
## v0.1.0 capabilities ## v0.2.0 capabilities
- Import a focused subset of JSON Schema, SQL `CREATE TABLE` DDL, XSD, CSV - Import a focused subset of JSON Schema, SQL `CREATE TABLE` DDL, XSD, CSV
headings, or the app's portable fixture-model JSON headings, or the app's portable fixture-model JSON
- Review and edit normalized tables, fields, types, required/unique rules, - Review and edit normalized tables, fields, types, required/unique rules,
numeric and length ranges, enumerations, and foreign-key relationships numeric and length ranges, enumerations, scalar foreign keys, composite
unique constraints, and composite foreign-key relationships
- Generate reproducible rows from a text seed with uniform, sequential, - Generate reproducible rows from a text seed with uniform, sequential,
normal, and constant distributions normal, and constant distributions
- Deliberately exercise boundary values or inject invalid values, with a - Deliberately exercise boundary values or inject invalid values, with a
separate violation ledger so negative-test fixtures cannot be mistaken for separate violation ledger so negative-test fixtures cannot be mistaken for
valid data valid data
- Preserve referential integrity and field uniqueness for valid generated rows - Preserve referential integrity and field uniqueness for valid generated rows
- Generate tables in a deterministic dependency-topological order, with an
explicit error for cyclic references
- Audit positive, boundary and intentional-negative exercise counts in a
constraint coverage matrix
- Select deterministic provider profiles (person, network, commerce), with a
typed extension point for additional local profiles
- Export JSON, spreadsheet-safe CSV, NDJSON, XML, SQL `INSERT` statements, or a - Export JSON, spreadsheet-safe CSV, NDJSON, XML, SQL `INSERT` statements, or a
reusable fixture recipe reusable fixture recipe
- Use only reserved example domains for generated email addresses - Use only reserved example domains for generated email addresses
@@ -24,8 +31,9 @@ browser. Fixture Tools is a local-first module in the add·ideas Toolbox.
The importers normalize common declarative constructs; they are not complete The importers normalize common declarative constructs; they are not complete
JSON Schema, SQL, or XML Schema validators. Remote references, schema code, JSON Schema, SQL, or XML Schema validators. Remote references, schema code,
database connections, XML entities, XSD `choice`/identity constraints, SQL database connections, XML entities, XSD `choice`/identity constraints, SQL
dialects beyond common table/column clauses, and production-scale data loads are dialects beyond common table/column clauses, cyclic relational recipes, and
out of scope in v0.1.0. production-scale data loads are out of scope in v0.2.0. Coverage counts describe
generated exercises; they do not prove schema or application coverage.
Inputs are capped at 2 MiB. A recipe can contain at most 20 tables and 100 Inputs are capped at 2 MiB. A recipe can contain at most 20 tables and 100
fields per table; generation is capped at 50,000 total rows and 8 MiB per fields per table; generation is capped at 50,000 total rows and 8 MiB per
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source # Corresponding source
The corresponding source for Fixture Tools 0.1.0 is available at: The corresponding source for Fixture Tools 0.2.0 is available at:
https://git.add-ideas.de/lotobo/fixture-tools/src/tag/v0.1.0 https://git.add-ideas.de/lotobo/fixture-tools/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`. Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+4 -4
View File
@@ -1,12 +1,12 @@
# Third-party notices # Third-party notices
Fixture Tools 0.1.0 directly depends on these runtime packages: Fixture Tools 0.2.0 directly depends on these runtime packages:
| Package | Pinned version | Declared licence | | Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- | | -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later | | `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `react` | 19.2.8 | MIT | | `react` | 19.2.8 | MIT |
| `react-dom` | 19.2.8 | MIT | | `react-dom` | 19.2.8 | MIT |
+1 -1
View File
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "fixture-tools-shell-"; const CACHE_PREFIX = "fixture-tools-shell-";
const CACHE_NAME = CACHE_PREFIX + "0.1.0"; const CACHE_NAME = CACHE_PREFIX + "0.2.0";
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"]; const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil( event.waitUntil(
+17 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.fixture-tools", "id": "de.add-ideas.fixture-tools",
"name": "Fixture Tools", "name": "Fixture Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Generate deterministic synthetic test data locally.", "description": "Generate deterministic synthetic test data locally.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
@@ -21,6 +21,22 @@
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": false "topLevelContext": false
}, },
"io": {
"accepts": [
{ "mediaType": "application/schema+json", "extensions": [".json"] },
{ "mediaType": "application/sql", "extensions": [".sql"] },
{ "mediaType": "application/xml", "extensions": [".xsd", ".xml"] },
{ "mediaType": "text/csv", "extensions": [".csv"] }
],
"produces": [
{ "mediaType": "application/json", "extensions": [".json"] },
{ "mediaType": "application/x-ndjson", "extensions": [".ndjson"] },
{ "mediaType": "text/csv", "extensions": [".csv"] },
{ "mediaType": "application/xml", "extensions": [".xml"] },
{ "mediaType": "application/sql", "extensions": [".sql"] }
]
},
"capabilities": { "required": [], "optional": [] },
"privacy": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": true, "fileUploads": true,
+63 -2
View File
@@ -38,7 +38,7 @@ const initialDataset = generateFixtures(initialParsed.model, {
invalidPercent: 0, invalidPercent: 0,
}); });
type View = "model" | "data" | "violations"; type View = "model" | "data" | "coverage" | "violations";
const TYPES: FixtureType[] = [ const TYPES: FixtureType[] = [
"string", "string",
"integer", "integer",
@@ -334,6 +334,7 @@ export function Workbench() {
const [rows, setRows] = useState(8); const [rows, setRows] = useState(8);
const [boundaryPercent, setBoundaryPercent] = useState(10); const [boundaryPercent, setBoundaryPercent] = useState(10);
const [invalidPercent, setInvalidPercent] = useState(0); const [invalidPercent, setInvalidPercent] = useState(0);
const [providerProfile, setProviderProfile] = useState("auto");
const [selectedTable, setSelectedTable] = useState(model.tables[0]!.name); const [selectedTable, setSelectedTable] = useState(model.tables[0]!.name);
const [outputFormat, setOutputFormat] = useState<OutputFormat>("json"); const [outputFormat, setOutputFormat] = useState<OutputFormat>("json");
const [view, setView] = useState<View>("data"); const [view, setView] = useState<View>("data");
@@ -381,6 +382,7 @@ export function Workbench() {
rows, rows,
boundaryPercent, boundaryPercent,
invalidPercent, invalidPercent,
providerProfile,
}); });
setModel(validated); setModel(validated);
setDataset(next); setDataset(next);
@@ -584,6 +586,18 @@ export function Workbench() {
} }
/> />
</label> </label>
<label>
Deterministic provider profile
<select
value={providerProfile}
onChange={(event) => setProviderProfile(event.target.value)}
>
<option value="auto">Auto by field name</option>
<option value="person">People</option>
<option value="network">Network</option>
<option value="commerce">Commerce</option>
</select>
</label>
<button className="primary-button" type="button" onClick={generate}> <button className="primary-button" type="button" onClick={generate}>
Generate fixtures Generate fixtures
</button> </button>
@@ -591,7 +605,7 @@ export function Workbench() {
</section> </section>
<nav className="tabs" aria-label="Fixture workspaces"> <nav className="tabs" aria-label="Fixture workspaces">
{(["model", "data", "violations"] as View[]).map((name) => ( {(["model", "data", "coverage", "violations"] as View[]).map((name) => (
<button <button
type="button" type="button"
key={name} key={name}
@@ -818,6 +832,53 @@ export function Workbench() {
)} )}
</section> </section>
) : null} ) : null}
{view === "coverage" ? (
<section className="panel">
<div className="panel-heading">
<div>
<h2>Constraint coverage matrix</h2>
<p>
Generation order:{" "}
{(dataset.generationOrder ?? []).join(" → ") || "not recorded"}.
Counts distinguish valid, boundary and intentionally invalid
evidence.
</p>
</div>
<span className="count-pill">
{dataset.coverage?.length ?? 0} rules
</span>
</div>
<div className="table-scroll" tabIndex={0}>
<table>
<thead>
<tr>
<th>Table</th>
<th>Constraint</th>
<th>Support</th>
<th>Positive</th>
<th>Boundary</th>
<th>Negative</th>
</tr>
</thead>
<tbody>
{(dataset.coverage ?? []).map((entry, index) => (
<tr key={`${entry.table}-${entry.constraint}-${index}`}>
<td>{entry.table}</td>
<td>
<code>{entry.constraint}</code>
</td>
<td>{entry.support}</td>
<td>{entry.positiveCases}</td>
<td>{entry.boundaryCases}</td>
<td>{entry.negativeCases}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
) : null}
</main> </main>
); );
} }
+604 -70
View File
@@ -34,11 +34,23 @@ export interface FixtureField {
values?: FixtureScalar[]; values?: FixtureScalar[];
reference?: string; reference?: string;
constant?: FixtureScalar; constant?: FixtureScalar;
/** Optional deterministic provider id; custom providers may add their own ids. */
provider?: string;
} }
export type FixtureConstraint =
| { kind: "unique"; name?: string; fields: string[] }
| {
kind: "foreignKey";
name?: string;
fields: string[];
references: { table: string; fields: string[] };
};
export interface FixtureTable { export interface FixtureTable {
name: string; name: string;
fields: FixtureField[]; fields: FixtureField[];
constraints?: FixtureConstraint[];
} }
export interface FixtureModel { export interface FixtureModel {
@@ -55,6 +67,7 @@ export interface GenerationOptions {
rows: number; rows: number;
boundaryPercent: number; boundaryPercent: number;
invalidPercent: number; invalidPercent: number;
providerProfile?: string;
} }
export interface FixtureViolation { export interface FixtureViolation {
@@ -69,6 +82,32 @@ export interface GeneratedDataset {
rowsPerTable: number; rowsPerTable: number;
tables: Record<string, Array<Record<string, FixtureScalar>>>; tables: Record<string, Array<Record<string, FixtureScalar>>>;
violations: FixtureViolation[]; violations: FixtureViolation[];
generationOrder?: string[];
coverage?: ConstraintCoverageRow[];
}
export interface ProviderContext {
table: string;
field: FixtureField;
row: number;
integer(minimum: number, maximum: number): number;
pick<T>(values: readonly T[]): T;
}
export interface FixtureProviderProfile {
id: string;
label: string;
supports(field: FixtureField): boolean;
generate(context: ProviderContext): FixtureScalar | undefined;
}
export interface ConstraintCoverageRow {
table: string;
constraint: string;
support: "generated" | "validated" | "generated-and-validated";
positiveCases: number;
boundaryCases: number;
negativeCases: number;
} }
const MAX_SOURCE = 2 * 1024 * 1024; const MAX_SOURCE = 2 * 1024 * 1024;
@@ -229,7 +268,16 @@ export function validateModel(model: FixtureModel): FixtureModel {
); );
if (field.constant !== undefined) if (field.constant !== undefined)
validateConstant(field, `${table.name}.${field.name}`); validateConstant(field, `${table.name}.${field.name}`);
if (
field.provider !== undefined &&
!/^[A-Za-z][A-Za-z0-9_.-]{0,79}$/u.test(field.provider)
)
throw new TypeError(
`${table.name}.${field.name} has an invalid provider id.`,
);
} }
if ((table.constraints?.length ?? 0) > 100)
throw new RangeError(`${table.name} exceeds 100 table constraints.`);
} }
for (const table of model.tables) for (const table of model.tables)
for (const field of table.fields) for (const field of table.fields)
@@ -248,6 +296,47 @@ export function validateModel(model: FixtureModel): FixtureModel {
`${table.name}.${field.name} cannot reference another foreign-key field.`, `${table.name}.${field.name} cannot reference another foreign-key field.`,
); );
} }
for (const table of model.tables) {
const localFields = new Set(table.fields.map((field) => field.name));
for (const [constraintIndex, constraint] of (
table.constraints ?? []
).entries()) {
const label = `${table.name} constraint ${constraint.name ?? constraintIndex + 1}`;
if (constraint.kind !== "unique" && constraint.kind !== "foreignKey")
throw new TypeError(`${label} has an unsupported kind.`);
if (
!Array.isArray(constraint.fields) ||
constraint.fields.length < 1 ||
constraint.fields.length > 16 ||
new Set(constraint.fields).size !== constraint.fields.length ||
constraint.fields.some((field) => !localFields.has(field))
)
throw new TypeError(
`${label} needs 116 distinct existing local fields.`,
);
if (constraint.name !== undefined && !NAME_PATTERN.test(constraint.name))
throw new TypeError(`${label} has an invalid name.`);
if (constraint.kind === "foreignKey") {
const target = model.tables.find(
(candidate) => candidate.name === constraint.references?.table,
);
const referenced = constraint.references?.fields;
if (
!target ||
!Array.isArray(referenced) ||
referenced.length !== constraint.fields.length ||
new Set(referenced).size !== referenced.length ||
referenced.some(
(field) =>
!target.fields.some((candidate) => candidate.name === field),
)
)
throw new TypeError(
`${label} has invalid composite reference columns.`,
);
}
}
}
return model; return model;
} }
@@ -327,6 +416,8 @@ function fromJsonSchema(source: string): ParsedModel {
distribution === "constant" distribution === "constant"
) )
field.distribution = distribution; field.distribution = distribution;
if (typeof property["x-fixture-provider"] === "string")
field.provider = property["x-fixture-provider"];
if (property.pattern !== undefined) if (property.pattern !== undefined)
notices.push( notices.push(
`${rawName}: regular-expression generation is not inferred; edit the field model if needed.`, `${rawName}: regular-expression generation is not inferred; edit the field model if needed.`,
@@ -367,74 +458,123 @@ function fromFixtureModel(source: string): ParsedModel {
!Array.isArray(table.fields) !Array.isArray(table.fields)
) )
throw new TypeError(`Fixture table ${tableIndex + 1} is invalid.`); throw new TypeError(`Fixture table ${tableIndex + 1} is invalid.`);
const fields = table.fields.map((rawField, fieldIndex) => {
const field = object(rawField);
if (
!field ||
typeof field.name !== "string" ||
typeof field.type !== "string"
)
throw new TypeError(
`Fixture field ${table.name}.${fieldIndex + 1} is invalid.`,
);
if (field.required !== undefined && typeof field.required !== "boolean")
throw new TypeError(
`${table.name}.${field.name} required must be boolean.`,
);
if (field.unique !== undefined && typeof field.unique !== "boolean")
throw new TypeError(
`${table.name}.${field.name} unique must be boolean.`,
);
if (
field.distribution !== undefined &&
field.distribution !== "uniform" &&
field.distribution !== "sequential" &&
field.distribution !== "normal" &&
field.distribution !== "constant"
)
throw new TypeError(
`${table.name}.${field.name} has an unsupported distribution.`,
);
if (
field.values !== undefined &&
(!Array.isArray(field.values) || !field.values.every(isScalar))
)
throw new TypeError(
`${table.name}.${field.name} enum values must be scalar JSON values.`,
);
if (field.constant !== undefined && !isScalar(field.constant))
throw new TypeError(
`${table.name}.${field.name} constant must be a scalar JSON value.`,
);
return {
name: field.name,
type: field.type as FixtureType,
required: field.required === true,
unique: field.unique === true,
distribution: (field.distribution ?? "uniform") as Distribution,
...(numeric(field.minimum) !== undefined
? { minimum: numeric(field.minimum) }
: {}),
...(numeric(field.maximum) !== undefined
? { maximum: numeric(field.maximum) }
: {}),
...(integer(field.minLength) !== undefined
? { minLength: integer(field.minLength) }
: {}),
...(integer(field.maxLength) !== undefined
? { maxLength: integer(field.maxLength) }
: {}),
...(Array.isArray(field.values)
? { values: field.values.slice(0, 1_000) as FixtureScalar[] }
: {}),
...(typeof field.reference === "string"
? { reference: field.reference }
: {}),
...(isScalar(field.constant) ? { constant: field.constant } : {}),
...(typeof field.provider === "string"
? { provider: field.provider }
: {}),
};
});
const constraints = Array.isArray(table.constraints)
? table.constraints.map((rawConstraint, index): FixtureConstraint => {
const constraint = object(rawConstraint);
if (
!constraint ||
(constraint.kind !== "unique" &&
constraint.kind !== "foreignKey") ||
!Array.isArray(constraint.fields) ||
!constraint.fields.every((field) => typeof field === "string")
)
throw new TypeError(
`Fixture constraint ${table.name}.${index + 1} is invalid.`,
);
if (constraint.kind === "unique")
return {
kind: "unique",
fields: constraint.fields as string[],
...(typeof constraint.name === "string"
? { name: constraint.name }
: {}),
};
const reference = object(constraint.references);
if (
!reference ||
typeof reference.table !== "string" ||
!Array.isArray(reference.fields) ||
!reference.fields.every((field) => typeof field === "string")
)
throw new TypeError(
`Fixture composite reference ${table.name}.${index + 1} is invalid.`,
);
return {
kind: "foreignKey",
fields: constraint.fields as string[],
...(typeof constraint.name === "string"
? { name: constraint.name }
: {}),
references: {
table: reference.table,
fields: reference.fields as string[],
},
};
})
: [];
return { return {
name: table.name, name: table.name,
fields: table.fields.map((rawField, fieldIndex) => { fields,
const field = object(rawField); ...(constraints.length ? { constraints } : {}),
if (
!field ||
typeof field.name !== "string" ||
typeof field.type !== "string"
)
throw new TypeError(
`Fixture field ${table.name}.${fieldIndex + 1} is invalid.`,
);
if (field.required !== undefined && typeof field.required !== "boolean")
throw new TypeError(
`${table.name}.${field.name} required must be boolean.`,
);
if (field.unique !== undefined && typeof field.unique !== "boolean")
throw new TypeError(
`${table.name}.${field.name} unique must be boolean.`,
);
if (
field.distribution !== undefined &&
field.distribution !== "uniform" &&
field.distribution !== "sequential" &&
field.distribution !== "normal" &&
field.distribution !== "constant"
)
throw new TypeError(
`${table.name}.${field.name} has an unsupported distribution.`,
);
if (
field.values !== undefined &&
(!Array.isArray(field.values) || !field.values.every(isScalar))
)
throw new TypeError(
`${table.name}.${field.name} enum values must be scalar JSON values.`,
);
if (field.constant !== undefined && !isScalar(field.constant))
throw new TypeError(
`${table.name}.${field.name} constant must be a scalar JSON value.`,
);
return {
name: field.name,
type: field.type as FixtureType,
required: field.required === true,
unique: field.unique === true,
distribution: (field.distribution ?? "uniform") as Distribution,
...(numeric(field.minimum) !== undefined
? { minimum: numeric(field.minimum) }
: {}),
...(numeric(field.maximum) !== undefined
? { maximum: numeric(field.maximum) }
: {}),
...(integer(field.minLength) !== undefined
? { minLength: integer(field.minLength) }
: {}),
...(integer(field.maxLength) !== undefined
? { maxLength: integer(field.maxLength) }
: {}),
...(Array.isArray(field.values)
? { values: field.values.slice(0, 1_000) as FixtureScalar[] }
: {}),
...(typeof field.reference === "string"
? { reference: field.reference }
: {}),
...(isScalar(field.constant) ? { constant: field.constant } : {}),
};
}),
}; };
}); });
return { model: validateModel({ tables }), notices: [] }; return { model: validateModel({ tables }), notices: [] };
@@ -558,6 +698,21 @@ function sqlType(source: string): FixtureType {
return "string"; return "string";
} }
function sqlIdentifierList(source: string): string[] {
return splitSqlList(source).map((value, index) => {
const match =
/^(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|([A-Za-z_][A-Za-z0-9_$-]*))$/u.exec(
value.trim(),
);
if (!match)
throw new SyntaxError(`Unsupported SQL constraint column ${index + 1}.`);
return importedName(
match[1] ?? match[2] ?? match[3] ?? match[4] ?? `field_${index + 1}`,
`field_${index + 1}`,
);
});
}
function fromSql(source: string): ParsedModel { function fromSql(source: string): ParsedModel {
const text = assertSource(source, "SQL DDL") const text = assertSource(source, "SQL DDL")
.replace(/--[^\n]*/gu, "") .replace(/--[^\n]*/gu, "")
@@ -590,10 +745,61 @@ function fromSql(source: string): ParsedModel {
expression.lastIndex = cursor; expression.lastIndex = cursor;
const used = new Set<string>(); const used = new Set<string>();
const fields: FixtureField[] = []; const fields: FixtureField[] = [];
const constraints: FixtureConstraint[] = [];
for (const definition of splitSqlList(body)) { for (const definition of splitSqlList(body)) {
const named =
/^CONSTRAINT\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|([A-Za-z_][A-Za-z0-9_$-]*))\s+([\s\S]+)$/iu.exec(
definition,
);
const constraintName = named
? importedName(
named[1] ?? named[2] ?? named[3] ?? named[4] ?? "constraint",
"constraint",
)
: undefined;
const constraintSource = named?.[5] ?? definition;
const unique = /^(PRIMARY\s+KEY|UNIQUE)\s*\(([^)]+)\)/iu.exec(
constraintSource,
);
if (unique) {
const constraintFields = sqlIdentifierList(unique[2]!);
constraints.push({
kind: "unique",
...(constraintName ? { name: constraintName } : {}),
fields: constraintFields,
});
if (/^PRIMARY/iu.test(unique[1]!))
fields.forEach((field) => {
if (constraintFields.includes(field.name)) field.required = true;
});
continue;
}
const compositeReference =
/^FOREIGN\s+KEY\s*\(([^)]+)\)\s+REFERENCES\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|([A-Za-z_][A-Za-z0-9_$-]*))\s*\(([^)]+)\)/iu.exec(
constraintSource,
);
if (compositeReference) {
constraints.push({
kind: "foreignKey",
...(constraintName ? { name: constraintName } : {}),
fields: sqlIdentifierList(compositeReference[1]!),
references: {
table: importedName(
compositeReference[2] ??
compositeReference[3] ??
compositeReference[4] ??
compositeReference[5] ??
"table",
"table",
),
fields: sqlIdentifierList(compositeReference[6]!),
},
});
continue;
}
if (/^(?:CONSTRAINT|PRIMARY|UNIQUE|FOREIGN|CHECK)\b/iu.test(definition)) { if (/^(?:CONSTRAINT|PRIMARY|UNIQUE|FOREIGN|CHECK)\b/iu.test(definition)) {
notices.push( notices.push(
`${rawName}: table-level constraint retained as a review notice, not inferred.`, `${rawName}: unsupported table-level constraint retained as a review notice.`,
); );
continue; continue;
} }
@@ -644,6 +850,7 @@ function fromSql(source: string): ParsedModel {
tables.push({ tables.push({
name: importedName(rawName, `table_${tables.length + 1}`), name: importedName(rawName, `table_${tables.length + 1}`),
fields, fields,
...(constraints.length ? { constraints } : {}),
}); });
} }
if (!tables.length) if (!tables.length)
@@ -770,7 +977,7 @@ function fromXsd(source: string): ParsedModel {
}); });
const root = elements.find((element) => !fieldElements.includes(element)); const root = elements.find((element) => !fieldElements.includes(element));
const notices = [ const notices = [
"XSD v0.1 imports named sequence/all elements and basic built-in scalar types; facets, choices, keys and external schemas require review.", "XSD v0.2 imports named sequence/all elements and basic built-in scalar types; facets, choices, keys and external schemas require review.",
]; ];
return { return {
model: validateModel({ model: validateModel({
@@ -821,6 +1028,163 @@ class SeededRandom {
} }
} }
const FIRST_NAMES = ["Ada", "Grace", "Linus", "Katherine", "Alan", "Edsger"];
const LAST_NAMES = [
"Lovelace",
"Hopper",
"Torvalds",
"Johnson",
"Turing",
"Dijkstra",
];
const PRODUCTS = ["Atlas", "Beacon", "Compass", "Delta", "Ember", "Foundry"];
export const BUILTIN_PROVIDER_PROFILES: readonly FixtureProviderProfile[] = [
{
id: "person",
label: "People",
supports: (field) =>
field.type === "string" &&
/(?:first|last|display|full)_?name|company/iu.test(field.name),
generate({ field, row, pick }) {
if (/first/iu.test(field.name)) return pick(FIRST_NAMES);
if (/last/iu.test(field.name)) return pick(LAST_NAMES);
if (/company/iu.test(field.name)) return `${pick(LAST_NAMES)} Labs`;
return `${FIRST_NAMES[row % FIRST_NAMES.length]} ${pick(LAST_NAMES)}`;
},
},
{
id: "network",
label: "Network",
supports: (field) =>
field.type === "string" &&
/(?:ip|ipv4|host|domain|url)/iu.test(field.name),
generate({ field, row, integer }) {
if (/url/iu.test(field.name))
return `https://host-${row + 1}.example.test/path`;
if (/host|domain/iu.test(field.name))
return `host-${row + 1}.example.test`;
return `192.0.2.${integer(1, 254)}`;
},
},
{
id: "commerce",
label: "Commerce",
supports: (field) =>
field.type === "string" && /(?:sku|product|currency)/iu.test(field.name),
generate({ field, row, pick }) {
if (/currency/iu.test(field.name)) return pick(["EUR", "USD", "GBP"]);
if (/sku/iu.test(field.name))
return `SKU-${String(row + 1).padStart(6, "0")}`;
return `${pick(PRODUCTS)} ${row + 1}`;
},
},
];
/** Returns a stable target-before-dependent table order or rejects cycles. */
export function topologicalTableOrder(input: FixtureModel): string[] {
const model = validateModel(structuredClone(input));
const dependencies = new Map<string, Set<string>>(
model.tables.map((table) => [table.name, new Set<string>()]),
);
for (const table of model.tables) {
for (const field of table.fields) {
const target = field.reference?.split(".")[0];
if (target && target !== table.name)
dependencies.get(table.name)!.add(target);
}
for (const constraint of table.constraints ?? [])
if (
constraint.kind === "foreignKey" &&
constraint.references.table !== table.name
)
dependencies.get(table.name)!.add(constraint.references.table);
}
const result: string[] = [];
const pending = new Set(model.tables.map((table) => table.name));
while (pending.size) {
const next = model.tables.find(
(table) =>
pending.has(table.name) &&
[...dependencies.get(table.name)!].every((dependency) =>
result.includes(dependency),
),
);
if (!next)
throw new TypeError(
`Cyclic table reference dependency: ${[...pending].join(" → ")}.`,
);
result.push(next.name);
pending.delete(next.name);
}
return result;
}
function activeProviders(
profile: string | undefined,
providers: readonly FixtureProviderProfile[],
): readonly FixtureProviderProfile[] {
const ids = new Set<string>();
for (const provider of providers) {
if (
!/^[A-Za-z][A-Za-z0-9_.-]{0,79}$/u.test(provider.id) ||
ids.has(provider.id)
)
throw new TypeError(
`Invalid or duplicate fixture provider id ${provider.id}.`,
);
ids.add(provider.id);
}
if (!profile || profile === "auto") return providers;
const selected = providers.find((provider) => provider.id === profile);
if (!selected)
throw new TypeError(`Unknown fixture provider profile ${profile}.`);
return [selected];
}
function providerValue(
table: string,
field: FixtureField,
row: number,
random: SeededRandom,
providers: readonly FixtureProviderProfile[],
): FixtureScalar | undefined {
const eligible = field.provider
? providers.filter((provider) => provider.id === field.provider)
: providers.filter((provider) => provider.supports(field));
if (field.provider && eligible.length === 0)
throw new TypeError(
`No provider ${field.provider} is available for ${table}.${field.name}.`,
);
const provider = eligible[0];
if (!provider) return undefined;
const value = provider.generate({
table,
field,
row,
integer: (minimum, maximum) => random.integer(minimum, maximum),
pick: <T>(values: readonly T[]) => {
if (!values.length)
throw new TypeError(
`Provider ${provider.id} offered an empty value set.`,
);
return values[random.integer(0, values.length - 1)]!;
},
});
if (value === undefined) return undefined;
if (!constantMatchesType(value, field.type))
throw new TypeError(
`Provider ${provider.id} returned the wrong type for ${table}.${field.name}.`,
);
if (
typeof value === "string" &&
((field.minLength !== undefined && value.length < field.minLength) ||
(field.maxLength !== undefined && value.length > field.maxLength))
)
return undefined;
return value;
}
function boundedNumber(field: FixtureField, random: SeededRandom): number { function boundedNumber(field: FixtureField, random: SeededRandom): number {
const minimum = field.minimum ?? 0; const minimum = field.minimum ?? 0;
const maximum = field.maximum ?? (field.type === "integer" ? 1_000 : 100); const maximum = field.maximum ?? (field.type === "integer" ? 1_000 : 100);
@@ -860,13 +1224,17 @@ function uuid(random: SeededRandom): string {
} }
function validValue( function validValue(
table: string,
field: FixtureField, field: FixtureField,
row: number, row: number,
random: SeededRandom, random: SeededRandom,
boundary: boolean, boundary: boolean,
providers: readonly FixtureProviderProfile[],
): FixtureScalar { ): FixtureScalar {
if (field.distribution === "constant" && field.constant !== undefined) if (field.distribution === "constant" && field.constant !== undefined)
return field.constant; return field.constant;
const provided = providerValue(table, field, row, random, providers);
if (provided !== undefined) return provided;
if (field.type === "boolean") if (field.type === "boolean")
return field.distribution === "sequential" return field.distribution === "sequential"
? row % 2 === 0 ? row % 2 === 0
@@ -957,6 +1325,7 @@ function invalidValue(
export function generateFixtures( export function generateFixtures(
input: FixtureModel, input: FixtureModel,
options: GenerationOptions, options: GenerationOptions,
customProviders: readonly FixtureProviderProfile[] = [],
): GeneratedDataset { ): GeneratedDataset {
const model = validateModel(structuredClone(input)); const model = validateModel(structuredClone(input));
if (!options.seed || options.seed.length > 256) if (!options.seed || options.seed.length > 256)
@@ -983,10 +1352,18 @@ export function generateFixtures(
if (!Number.isFinite(value) || value < 0 || value > 100) if (!Number.isFinite(value) || value < 0 || value > 100)
throw new RangeError("Boundary and invalid percentages must be 0100."); throw new RangeError("Boundary and invalid percentages must be 0100.");
const random = new SeededRandom(options.seed); const random = new SeededRandom(options.seed);
const providers = activeProviders(options.providerProfile, [
...customProviders,
...BUILTIN_PROVIDER_PROFILES,
]);
const generationOrder = topologicalTableOrder(model);
const tables: GeneratedDataset["tables"] = Object.create( const tables: GeneratedDataset["tables"] = Object.create(
null, null,
) as GeneratedDataset["tables"]; ) as GeneratedDataset["tables"];
for (const table of model.tables) { for (const tableName of generationOrder) {
const table = model.tables.find(
(candidate) => candidate.name === tableName,
)!;
const seen = new Map<string, Set<string>>(); const seen = new Map<string, Set<string>>();
tables[table.name] = Array.from( tables[table.name] = Array.from(
{ length: options.rows }, { length: options.rows },
@@ -1004,10 +1381,12 @@ export function generateFixtures(
const values = seen.get(field.name) ?? new Set<string>(); const values = seen.get(field.name) ?? new Set<string>();
do { do {
value = validValue( value = validValue(
table.name,
field, field,
row + attempts * options.rows, row + attempts * options.rows,
random, random,
!field.unique && random.next() * 100 < options.boundaryPercent, !field.unique && random.next() * 100 < options.boundaryPercent,
providers,
); );
attempts += 1; attempts += 1;
} while ( } while (
@@ -1058,6 +1437,58 @@ export function generateFixtures(
record[field.name] = targets[row % targets.length]!; record[field.name] = targets[row % targets.length]!;
}); });
} }
for (const table of model.tables)
for (const constraint of (table.constraints ?? []).filter(
(item): item is Extract<FixtureConstraint, { kind: "foreignKey" }> =>
item.kind === "foreignKey",
)) {
const targets = tables[constraint.references.table]!;
tables[table.name]!.forEach((record, row) => {
const target = targets[row % targets.length]!;
constraint.fields.forEach((field, index) => {
record[field] = target[constraint.references.fields[index]!]!;
});
});
}
for (const table of model.tables)
for (const constraint of (table.constraints ?? []).filter(
(item): item is Extract<FixtureConstraint, { kind: "unique" }> =>
item.kind === "unique",
)) {
const seen = new Set<string>();
for (const [row, record] of tables[table.name]!.entries()) {
let key = tupleKey(record, constraint.fields);
let attempt = 0;
while (seen.has(key) && attempt < 200) {
const fieldName = [...constraint.fields]
.reverse()
.find(
(name) =>
table.fields.find((field) => field.name === name)?.type !==
"foreignKey",
);
const field = table.fields.find(
(candidate) => candidate.name === fieldName,
);
if (!field) break;
record[field.name] = validValue(
table.name,
field,
row + (++attempt + 1) * options.rows,
random,
false,
providers,
);
key = tupleKey(record, constraint.fields);
}
if (seen.has(key))
throw new RangeError(
`Could not satisfy composite uniqueness for ${table.name}(${constraint.fields.join(", ")}).`,
);
seen.add(key);
}
}
for (const table of model.tables) for (const table of model.tables)
tables[table.name]!.forEach((record, row) => { tables[table.name]!.forEach((record, row) => {
for (const field of table.fields.filter( for (const field of table.fields.filter(
@@ -1076,18 +1507,121 @@ export function generateFixtures(
}); });
} }
}); });
return { for (const table of model.tables)
for (const constraint of table.constraints ?? [])
tables[table.name]!.forEach((record, row) => {
if (row === 0 || random.next() * 100 >= options.invalidPercent) return;
if (constraint.kind === "unique") {
const source = tables[table.name]![0]!;
constraint.fields.forEach((field) => {
record[field] = source[field]!;
});
} else record[constraint.fields[0]!] = "__missing_reference__";
violations.push({
table: table.name,
row: row + 1,
field: constraint.fields.join(","),
rule: constraintLabel(constraint),
});
});
const dataset: GeneratedDataset = {
seed: options.seed, seed: options.seed,
rowsPerTable: options.rows, rowsPerTable: options.rows,
tables, tables,
violations: violations.slice(0, 100_000), violations: violations.slice(0, 100_000),
generationOrder,
}; };
dataset.coverage = constraintCoverageMatrix(model, dataset);
return dataset;
} }
function stableKey(value: FixtureScalar): string { function stableKey(value: FixtureScalar): string {
return `${typeof value}:${String(value)}`; return `${typeof value}:${String(value)}`;
} }
function tupleKey(
record: Record<string, FixtureScalar>,
fields: readonly string[],
): string {
return fields.map((field) => stableKey(record[field] ?? null)).join("\u0000");
}
function constraintLabel(constraint: FixtureConstraint): string {
return constraint.kind === "unique"
? `unique(${constraint.fields.join(",")})`
: `foreignKey(${constraint.fields.join(",")})->${constraint.references.table}(${constraint.references.fields.join(",")})`;
}
/** Auditable positive, boundary and intentional-negative coverage per rule. */
export function constraintCoverageMatrix(
input: FixtureModel,
dataset?: GeneratedDataset,
): ConstraintCoverageRow[] {
const model = validateModel(structuredClone(input));
const result: ConstraintCoverageRow[] = [];
const add = (
table: FixtureTable,
constraint: string,
boundary: (record: Record<string, FixtureScalar>) => boolean = () => false,
support: ConstraintCoverageRow["support"] = "generated-and-validated",
) => {
const rows = dataset?.tables[table.name] ?? [];
const negativeCases =
dataset?.violations.filter(
(violation) =>
violation.table === table.name &&
(violation.rule === constraint ||
(constraint.startsWith("type:") && violation.rule === constraint)),
).length ?? 0;
result.push({
table: table.name,
constraint,
support,
positiveCases: Math.max(0, rows.length - negativeCases),
boundaryCases: rows.filter(boundary).length,
negativeCases,
});
};
for (const table of model.tables) {
for (const field of table.fields) {
const value = (record: Record<string, FixtureScalar>) =>
record[field.name];
add(table, `type:${field.type}`);
if (field.required) add(table, "required");
if (field.unique) add(table, "unique");
if (field.minimum !== undefined)
add(table, "minimum", (record) => value(record) === field.minimum);
if (field.maximum !== undefined)
add(table, "maximum", (record) => value(record) === field.maximum);
if (field.minLength !== undefined)
add(
table,
"minLength",
(record) =>
typeof value(record) === "string" &&
String(value(record)).length === field.minLength,
);
if (field.maxLength !== undefined)
add(
table,
"maxLength",
(record) =>
typeof value(record) === "string" &&
String(value(record)).length === field.maxLength,
);
if (field.type === "enum") add(table, "enum");
if (field.type === "email" || field.type === "uuid")
add(table, `format:${field.type}`);
if (field.type === "foreignKey") add(table, "foreignKey");
if (field.provider)
add(table, `provider:${field.provider}`, () => false, "generated");
}
for (const constraint of table.constraints ?? [])
add(table, constraintLabel(constraint));
}
return result;
}
function safeCsvValue(value: FixtureScalar): FixtureScalar { function safeCsvValue(value: FixtureScalar): FixtureScalar {
return typeof value === "string" && /^[\t\r\n ]*[=+\-@]/u.test(value) return typeof value === "string" && /^[\t\r\n ]*[=+\-@]/u.test(value)
? `'${value}` ? `'${value}`
+47 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.fixture-tools", "id": "de.add-ideas.fixture-tools",
"name": "Fixture Tools", "name": "Fixture Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Generate deterministic synthetic test data locally.", "description": "Generate deterministic synthetic test data locally.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
@@ -21,6 +21,52 @@
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": false "topLevelContext": false
}, },
"io": {
"accepts": [
{
"mediaType": "application/schema+json",
"extensions": [".json"]
},
{
"mediaType": "application/sql",
"extensions": [".sql"]
},
{
"mediaType": "application/xml",
"extensions": [".xsd", ".xml"]
},
{
"mediaType": "text/csv",
"extensions": [".csv"]
}
],
"produces": [
{
"mediaType": "application/json",
"extensions": [".json"]
},
{
"mediaType": "application/x-ndjson",
"extensions": [".ndjson"]
},
{
"mediaType": "text/csv",
"extensions": [".csv"]
},
{
"mediaType": "application/xml",
"extensions": [".xml"]
},
{
"mediaType": "application/sql",
"extensions": [".sql"]
}
]
},
"capabilities": {
"required": [],
"optional": []
},
"privacy": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": true, "fileUploads": true,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0"; export const APP_VERSION = "0.2.0";
+22 -1
View File
@@ -54,6 +54,27 @@ test("imports CSV headings and exports an inspectable model", async ({
expect((await pending).suggestedFilename()).toBe("fixture-model.json"); expect((await pending).suggestedFilename()).toBe("fixture-model.json");
}); });
test("imports composite constraints and exposes topological coverage", async ({
page,
}) => {
await page.goto("/deep/nested/fixture/");
await page.getByLabel("Fixture model source").fill(`CREATE TABLE tenants (
region VARCHAR(8), tenant_id INTEGER, PRIMARY KEY (region, tenant_id)
);
CREATE TABLE events (
region VARCHAR(8), tenant_id INTEGER,
FOREIGN KEY (region, tenant_id) REFERENCES tenants(region, tenant_id)
);`);
await page.getByRole("button", { name: "Import model" }).click();
await page.getByRole("button", { name: "Generate fixtures" }).click();
await page.getByRole("button", { name: "Coverage" }).click();
await expect(
page.getByRole("heading", { name: "Constraint coverage matrix" }),
).toBeVisible();
await expect(page.getByText("tenants → events")).toBeVisible();
await expect(page.getByText(/foreignKey\(region,tenant_id\)/u)).toBeVisible();
});
test("retains results after a failed import and downloads output", async ({ test("retains results after a failed import and downloads output", async ({
page, page,
}) => { }) => {
@@ -97,7 +118,7 @@ test("integrates help, themes, PWA identity and hardened headers", async ({
const manifest = await request.get("/deep/nested/fixture/toolbox-app.json"); const manifest = await request.get("/deep/nested/fixture/toolbox-app.json");
await expect(manifest.json()).resolves.toMatchObject({ await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.fixture-tools", id: "de.add-ideas.fixture-tools",
version: "0.1.0", version: "0.2.0",
privacy: { processing: "local", telemetry: false }, privacy: { processing: "local", telemetry: false },
}); });
}); });
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
test("keeps the primary workspace inside a narrow viewport", async ({
page,
}) => {
await page.goto("/deep/nested/fixture/");
await expect(page.locator("main").first()).toBeVisible();
await expect(
page.locator("main .loading, main .workbench-loading"),
).toHaveCount(0);
const widths = await page.evaluate(() => ({
content: document.documentElement.scrollWidth,
viewport: document.documentElement.clientWidth,
}));
expect(widths.viewport).toBeLessThanOrEqual(430);
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
});
+102
View File
@@ -1,9 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
generateFixtures, generateFixtures,
constraintCoverageMatrix,
modelJson, modelJson,
parseFixtureModel, parseFixtureModel,
serializeDataset, serializeDataset,
topologicalTableOrder,
type GeneratedDataset, type GeneratedDataset,
} from "../../src/fixture/model"; } from "../../src/fixture/model";
@@ -175,4 +177,104 @@ describe("fixture model", () => {
}), }),
).toThrow(/100,000 field values/u); ).toThrow(/100,000 field values/u);
}); });
it("imports and generates composite relational constraints topologically", () => {
const sql = `CREATE TABLE tenants (
region VARCHAR(8) NOT NULL,
tenant_id INTEGER NOT NULL,
PRIMARY KEY (region, tenant_id)
);
CREATE TABLE events (
region VARCHAR(8) NOT NULL,
tenant_id INTEGER NOT NULL,
sequence INTEGER NOT NULL,
UNIQUE (region, tenant_id, sequence),
FOREIGN KEY (region, tenant_id) REFERENCES tenants(region, tenant_id)
);`;
const model = parseFixtureModel(sql, "sql").model;
expect(model.tables[1]?.constraints).toEqual([
{ kind: "unique", fields: ["region", "tenant_id", "sequence"] },
{
kind: "foreignKey",
fields: ["region", "tenant_id"],
references: { table: "tenants", fields: ["region", "tenant_id"] },
},
]);
expect(topologicalTableOrder(model)).toEqual(["tenants", "events"]);
const dataset = generateFixtures(model, {
seed: "composite",
rows: 12,
boundaryPercent: 10,
invalidPercent: 0,
providerProfile: "auto",
});
const targets = new Set(
dataset.tables.tenants?.map((row) => `${row.region}:${row.tenant_id}`),
);
expect(
dataset.tables.events?.every((row) =>
targets.has(`${row.region}:${row.tenant_id}`),
),
).toBe(true);
expect(dataset.generationOrder).toEqual(["tenants", "events"]);
expect(
constraintCoverageMatrix(model, dataset).some(
(entry) =>
entry.table === "events" &&
entry.constraint.startsWith("foreignKey(region,tenant_id)"),
),
).toBe(true);
});
it("accepts scalar table-level primary and foreign-key constraints", () => {
const sql = `CREATE TABLE accounts (
account_id INTEGER NOT NULL,
PRIMARY KEY (account_id)
);
CREATE TABLE entries (
account_id INTEGER NOT NULL,
FOREIGN KEY (account_id) REFERENCES accounts(account_id)
);`;
const model = parseFixtureModel(sql, "sql").model;
expect(model.tables[0]?.constraints).toEqual([
{ kind: "unique", fields: ["account_id"] },
]);
expect(model.tables[1]?.constraints).toEqual([
{
kind: "foreignKey",
fields: ["account_id"],
references: { table: "accounts", fields: ["account_id"] },
},
]);
const dataset = generateFixtures(model, {
seed: "scalar-table-constraints",
rows: 8,
boundaryPercent: 0,
invalidPercent: 0,
});
const accounts = new Set(
dataset.tables.accounts?.map((row) => row.account_id),
);
expect(
dataset.tables.entries?.every((row) => accounts.has(row.account_id)),
).toBe(true);
});
it("supports deterministic built-in and custom provider profiles", () => {
const model = parseFixtureModel("display_name,host,sku\n", "csv").model;
const options = {
seed: "providers",
rows: 4,
boundaryPercent: 0,
invalidPercent: 0,
providerProfile: "auto",
};
const generated = generateFixtures(model, options);
expect(generated).toEqual(generateFixtures(model, options));
expect(generated.tables.records?.[0]).toMatchObject({
display_name: expect.stringMatching(/ /u),
host: expect.stringMatching(/\.example\.test$/u),
sku: "SKU-000001",
});
});
}); });