Compare commits
No commits in common. "1f3a81272852405552b907319538b133874886ba" and "8343df231671d8b38d53c7178e08b93a40fa51fd" have entirely different histories.
1f3a812728
...
8343df2316
42
AGENTS.md
42
AGENTS.md
|
|
@ -1,42 +0,0 @@
|
||||||
# AGENTS.md
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
- **Build:** `npm run build` (tsup, CJS + ESM + d.ts for all entry points)
|
|
||||||
- **Test:** `npm run test` (vitest run) | `npm run test:watch` (vitest watch)
|
|
||||||
- **Type check:** `npm run typecheck`
|
|
||||||
- **Run a single test:** `npx vitest run -t "test name pattern"`
|
|
||||||
|
|
||||||
No linter or formatter is configured. No CI pipeline exists.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Single-package TypeScript library with two runtime entry points:
|
|
||||||
|
|
||||||
- **`inline-schema`** (`src/index.ts`) — core schema parser, value parser, and validator
|
|
||||||
- `src/parser.ts` — `parseSchema()` turns schema strings into AST (`Schema` type)
|
|
||||||
- `src/validator.ts` — `parseValue()` and `createValidator()` operate on the AST
|
|
||||||
- `src/types.ts` — union type `Schema = Primitive | Tuple | Array | Reference | StringLiteral | Union`
|
|
||||||
|
|
||||||
- **`inline-schema/csv-loader`** (`src/csv-loader/loader.ts`) — CSV loader with `@table` reference resolution
|
|
||||||
- `loader.ts` — `parseCsv()` (eager resolution) and `csvToModule()` (accessor-based output with lazy resolution); `resolveReferences: false` mode stores IDs instead of resolved objects
|
|
||||||
- `webpack.ts`, `rollup.ts`, `esbuild.ts` — bundler plugin wrappers around `csvToModule`
|
|
||||||
|
|
||||||
Build produces separate bundles per entry point (see `tsup.config.ts`). The csv-loader entries externalize `csv-parse`, `@rspack/core`, `esbuild`, and `rollup`.
|
|
||||||
|
|
||||||
## Key conventions
|
|
||||||
|
|
||||||
- Schema syntax uses **semicolons** (`;`) as separators, not commas
|
|
||||||
- Unknown identifiers throw a `ParseError` — only recognized keywords (`string`, `number`, `int`, `float`, `boolean`) and string literals (`"on"`, `'off'`) are valid types
|
|
||||||
- `@tablename` / `@tablename[]` are reference schemas resolved at CSV load time
|
|
||||||
- `csvToModule()` emits accessor functions (`getData()`) for tables with references, and static JSON for tables without; bundler loaders all use `csvToModule`
|
|
||||||
- `parseCsv({ resolveReferences: false })` stores reference IDs instead of resolved objects — used by `csvToModule` to emit import-based lazy resolution
|
|
||||||
- References can appear nested inside tuples, arrays, and unions; the loader resolves them recursively
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **Circular references** between CSV tables are supported in `csvToModule` output via accessor-based lazy resolution. `parseCsv()` with `resolveReferences: true` (default) still detects and throws on circular references via an in-progress loading set.
|
|
||||||
- **Run `npm run typecheck` before committing** to catch type errors.
|
|
||||||
- **Union member ordering matters** — `parseValue` tries union members in order; the first one that parses wins. This affects references in unions (e.g., `@users[] | string` will try `@users[]` first).
|
|
||||||
- **csv-parse quote handling** — Double-quoted schema values like `"active" | "inactive"` in CSV rows confuse the csv-parse library. Use single-quoted string literals (`'on' | 'off'`) or unquoted identifiers in the schema row of CSV data when possible.
|
|
||||||
- **Module imports use `.js` extension** — source files import from `../index.js` etc. (ESM convention), not `../index.ts`.
|
|
||||||
|
|
@ -35,7 +35,6 @@
|
||||||
"build": "tsup",
|
"build": "tsup",
|
||||||
"dev": "tsup --watch",
|
"dev": "tsup --watch",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import { parseCsv, csvToModule } from './loader';
|
import { parseCsv } from './loader';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
|
@ -43,7 +43,7 @@ describe('parseCsv - basic parsing', () => {
|
||||||
it('should parse CSV with string literal columns (unquoted in CSV)', () => {
|
it('should parse CSV with string literal columns (unquoted in CSV)', () => {
|
||||||
const csv = [
|
const csv = [
|
||||||
'name,status',
|
'name,status',
|
||||||
"string,'on' | 'off'",
|
'string,on | off',
|
||||||
'Alice,on',
|
'Alice,on',
|
||||||
'Bob,off',
|
'Bob,off',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
@ -560,409 +560,3 @@ describe('parseCsv - refBaseDir option', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('parseCsv - resolveReferences: false', () => {
|
|
||||||
it('should store IDs instead of resolved objects for reference fields', () => {
|
|
||||||
const csv = [
|
|
||||||
'id,customer,items',
|
|
||||||
'string,@users,@parts[]',
|
|
||||||
'1,1,[1; 2]',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const result = parseCsv(csv, {
|
|
||||||
emitTypes: false,
|
|
||||||
resolveReferences: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.data[0].customer).toBe('1');
|
|
||||||
expect(result.data[0].items).toEqual(['1', '2']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should populate referenceFields with metadata', () => {
|
|
||||||
const csv = [
|
|
||||||
'id,customer,items',
|
|
||||||
'string,@users,@parts[]',
|
|
||||||
'1,1,[1; 2]',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const result = parseCsv(csv, {
|
|
||||||
emitTypes: false,
|
|
||||||
resolveReferences: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.referenceFields).toHaveLength(2);
|
|
||||||
expect(result.referenceFields[0]).toEqual({
|
|
||||||
name: 'customer',
|
|
||||||
tableName: 'users',
|
|
||||||
isArray: false,
|
|
||||||
schema: expect.objectContaining({ type: 'reference', tableName: 'users', isArray: false }),
|
|
||||||
});
|
|
||||||
expect(result.referenceFields[1]).toEqual({
|
|
||||||
name: 'items',
|
|
||||||
tableName: 'parts',
|
|
||||||
isArray: true,
|
|
||||||
schema: expect.objectContaining({ type: 'reference', tableName: 'parts', isArray: true }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not load referenced CSV files', () => {
|
|
||||||
const csv = [
|
|
||||||
'id,customer',
|
|
||||||
'string,@nonexistent',
|
|
||||||
'1,someid',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
expect(() => parseCsv(csv, {
|
|
||||||
emitTypes: false,
|
|
||||||
resolveReferences: false,
|
|
||||||
})).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should store IDs for nested references in tuples', () => {
|
|
||||||
const csv = [
|
|
||||||
'id,info',
|
|
||||||
'string,[ref: @users; note: string]',
|
|
||||||
'1,[ref: 1; note: urgent]',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const result = parseCsv(csv, {
|
|
||||||
emitTypes: false,
|
|
||||||
resolveReferences: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect((result.data[0].info as unknown[])[0]).toBe('1');
|
|
||||||
expect((result.data[0].info as unknown[])[1]).toBe('urgent');
|
|
||||||
expect(result.referenceFields).toHaveLength(1);
|
|
||||||
expect(result.referenceFields[0].tableName).toBe('users');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should store IDs for references in unions', () => {
|
|
||||||
const csv = [
|
|
||||||
'id,value',
|
|
||||||
'string,@users | string',
|
|
||||||
'1,1',
|
|
||||||
'2,unknown',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const result = parseCsv(csv, {
|
|
||||||
emitTypes: false,
|
|
||||||
resolveReferences: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.data[0].value).toBe('1');
|
|
||||||
expect(result.data[1].value).toBe('unknown');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not throw for self-referencing table when resolveReferences is false', () => {
|
|
||||||
const csv = readFixture('self_ref.csv');
|
|
||||||
|
|
||||||
const result = parseCsv(csv, {
|
|
||||||
emitTypes: false,
|
|
||||||
resolveReferences: false,
|
|
||||||
currentFilePath: path.join(fixturesDir, 'self_ref.csv'),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.data).toHaveLength(2);
|
|
||||||
expect(result.data[0].parent).toBe('2');
|
|
||||||
expect(result.data[1].parent).toBe('1');
|
|
||||||
expect(result.referenceFields).toHaveLength(1);
|
|
||||||
expect(result.referenceFields[0].tableName).toBe('self_ref');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not throw for cross-referencing tables when resolveReferences is false', () => {
|
|
||||||
const csv = readFixture('circular_a.csv');
|
|
||||||
|
|
||||||
const result = parseCsv(csv, {
|
|
||||||
emitTypes: false,
|
|
||||||
resolveReferences: false,
|
|
||||||
currentFilePath: path.join(fixturesDir, 'circular_a.csv'),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.data).toHaveLength(1);
|
|
||||||
expect(result.data[0].related).toEqual(['1']);
|
|
||||||
expect(result.referenceFields).toHaveLength(1);
|
|
||||||
expect(result.referenceFields[0].tableName).toBe('circular_b');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should store IDs for nested self-reference in tuple', () => {
|
|
||||||
const csv = [
|
|
||||||
'id,name,parent_info',
|
|
||||||
'string,string,[parent: @self_ref; role: string]',
|
|
||||||
'1,Root,[parent: 2; role: admin]',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const result = parseCsv(csv, {
|
|
||||||
emitTypes: false,
|
|
||||||
resolveReferences: false,
|
|
||||||
currentFilePath: path.join(fixturesDir, 'self_ref.csv'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const parentInfo = result.data[0].parent_info as unknown[];
|
|
||||||
expect(parentInfo[0]).toBe('2');
|
|
||||||
expect(parentInfo[1]).toBe('admin');
|
|
||||||
expect(result.referenceFields).toHaveLength(1);
|
|
||||||
expect(result.referenceFields[0].tableName).toBe('self_ref');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should store IDs for self-reference in union', () => {
|
|
||||||
const csv = [
|
|
||||||
'id,name,ref_or_val',
|
|
||||||
'string,string,@self_ref | string',
|
|
||||||
'1,Root,2',
|
|
||||||
'2,Child,none',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const result = parseCsv(csv, {
|
|
||||||
emitTypes: false,
|
|
||||||
resolveReferences: false,
|
|
||||||
currentFilePath: path.join(fixturesDir, 'self_ref.csv'),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.data[0].ref_or_val).toBe('2');
|
|
||||||
expect(result.data[1].ref_or_val).toBe('none');
|
|
||||||
expect(result.referenceFields).toHaveLength(1);
|
|
||||||
expect(result.referenceFields[0].tableName).toBe('self_ref');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should store IDs for self-reference array in tuple', () => {
|
|
||||||
const csv = [
|
|
||||||
'id,name,children',
|
|
||||||
'string,string,[@self_ref[]]',
|
|
||||||
'1,Root,[[2]]',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const result = parseCsv(csv, {
|
|
||||||
emitTypes: false,
|
|
||||||
resolveReferences: false,
|
|
||||||
currentFilePath: path.join(fixturesDir, 'self_ref.csv'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const children = result.data[0].children as unknown[];
|
|
||||||
expect(children[0]).toEqual(['2']);
|
|
||||||
expect(result.referenceFields).toHaveLength(1);
|
|
||||||
expect(result.referenceFields[0].tableName).toBe('self_ref');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('csvToModule - accessor-based output', () => {
|
|
||||||
it('should emit accessor function for tables without references', () => {
|
|
||||||
const csv = [
|
|
||||||
'name,age',
|
|
||||||
'string,number',
|
|
||||||
'Alice,30',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const result = csvToModule(csv, { emitTypes: false });
|
|
||||||
|
|
||||||
expect(result.js).toContain('export default function getData()');
|
|
||||||
expect(result.js).not.toContain('import ');
|
|
||||||
expect(result.js).not.toContain('Lookup');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit accessor function for tables with references', () => {
|
|
||||||
const csv = [
|
|
||||||
'id,customer',
|
|
||||||
'string,@users',
|
|
||||||
'1,1',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const result = csvToModule(csv, { emitTypes: false });
|
|
||||||
|
|
||||||
expect(result.js).toContain("import _users from './users.csv'");
|
|
||||||
expect(result.js).toContain('export default function getData()');
|
|
||||||
expect(result.js).toContain('_usersLookup');
|
|
||||||
expect(result.js).toContain('_resolved = _raw;');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit accessor function for tables with array references', () => {
|
|
||||||
const csv = [
|
|
||||||
'id,items',
|
|
||||||
'string,@parts[]',
|
|
||||||
'1,[1; 2]',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const result = csvToModule(csv, { emitTypes: false });
|
|
||||||
|
|
||||||
expect(result.js).toContain("import _parts from './parts.csv'");
|
|
||||||
expect(result.js).toContain('_partsLookup');
|
|
||||||
expect(result.js).toContain('.map(id =>');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit multiple imports for multiple reference tables', () => {
|
|
||||||
const csv = [
|
|
||||||
'id,customer,items',
|
|
||||||
'string,@users,@parts[]',
|
|
||||||
'1,1,[1; 2]',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const result = csvToModule(csv, { emitTypes: false });
|
|
||||||
|
|
||||||
expect(result.js).toContain("import _users from './users.csv'");
|
|
||||||
expect(result.js).toContain("import _parts from './parts.csv'");
|
|
||||||
expect(result.js).toContain('_usersLookup');
|
|
||||||
expect(result.js).toContain('_partsLookup');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should generate function type in dts for tables with references', () => {
|
|
||||||
const csv = [
|
|
||||||
'id,customer',
|
|
||||||
'string,@users',
|
|
||||||
'1,1',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const result = csvToModule(csv, { emitTypes: true, resourceName: 'orders' });
|
|
||||||
|
|
||||||
expect(result.dts).toContain('declare function getData(): ordersTable');
|
|
||||||
expect(result.dts).toContain('export default getData');
|
|
||||||
expect(result.dts).not.toContain('declare const data');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should generate function type in dts for tables without references', () => {
|
|
||||||
const csv = [
|
|
||||||
'name,age',
|
|
||||||
'string,number',
|
|
||||||
'Alice,30',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const result = csvToModule(csv, { emitTypes: true, resourceName: 'people' });
|
|
||||||
|
|
||||||
expect(result.dts).toContain('declare function getData(): peopleTable');
|
|
||||||
expect(result.dts).toContain('export default getData');
|
|
||||||
expect(result.dts).not.toContain('declare const data');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle nested references in tuples', () => {
|
|
||||||
const csv = [
|
|
||||||
'id,info',
|
|
||||||
'string,[ref: @users; note: string]',
|
|
||||||
'1,[ref: 1; note: urgent]',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const result = csvToModule(csv, { emitTypes: false });
|
|
||||||
|
|
||||||
expect(result.js).toContain("import _users from './users.csv'");
|
|
||||||
expect(result.js).toContain('_usersLookup');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('csvToModule - circular reference support', () => {
|
|
||||||
it('should emit accessor for self-referencing table without self-import', () => {
|
|
||||||
const csv = readFixture('self_ref.csv');
|
|
||||||
|
|
||||||
const result = csvToModule(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, 'self_ref.csv') });
|
|
||||||
|
|
||||||
expect(result.js).not.toContain("import _self_ref from './self_ref.csv'");
|
|
||||||
expect(result.js).toContain('export default function getData()');
|
|
||||||
expect(result.js).toContain('_self_refLookup');
|
|
||||||
expect(result.js).toContain('_self_refLookup = new Map(_raw.map');
|
|
||||||
expect(result.js).toContain('parent: _self_refLookup.get(String(row.parent))');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit accessor for cross-referencing tables', () => {
|
|
||||||
const csv = readFixture('circular_a.csv');
|
|
||||||
|
|
||||||
const result = csvToModule(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, 'circular_a.csv') });
|
|
||||||
|
|
||||||
expect(result.js).toContain("import _circular_b from './circular_b.csv'");
|
|
||||||
expect(result.js).toContain('export default function getData()');
|
|
||||||
expect(result.js).toContain('_circular_bLookup');
|
|
||||||
expect(result.js).toContain('related:');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit accessor for self-referencing table with nested reference in tuple', () => {
|
|
||||||
const csv = [
|
|
||||||
'id,name,parent_info',
|
|
||||||
'string,string,[parent: @self_ref; role: string]',
|
|
||||||
'1,Root,[parent: 2; role: admin]',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const result = csvToModule(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, 'self_ref.csv') });
|
|
||||||
|
|
||||||
expect(result.js).not.toContain("import _self_ref from './self_ref.csv'");
|
|
||||||
expect(result.js).toContain('_self_refLookup');
|
|
||||||
expect(result.js).toContain('export default function getData()');
|
|
||||||
expect(result.js).toContain('parent_info:');
|
|
||||||
expect(result.js).toContain('_self_refLookup.get(String(row.parent_info[0]))');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit accessor for self-referencing table with nested reference in union with fallback', () => {
|
|
||||||
const csv = [
|
|
||||||
'id,name,ref_or_val',
|
|
||||||
'string,string,@self_ref | string',
|
|
||||||
'1,Root,2',
|
|
||||||
'2,Child,none',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const result = csvToModule(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, 'self_ref.csv') });
|
|
||||||
|
|
||||||
expect(result.js).not.toContain("import _self_ref from './self_ref.csv'");
|
|
||||||
expect(result.js).toContain('_self_refLookup');
|
|
||||||
expect(result.js).toContain('export default function getData()');
|
|
||||||
expect(result.js).toContain('ref_or_val:');
|
|
||||||
expect(result.js).toContain('_self_refLookup.get(String(row.ref_or_val)) ?? row.ref_or_val');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit accessor for self-referencing table with nested reference array in tuple', () => {
|
|
||||||
const csv = [
|
|
||||||
'id,name,children',
|
|
||||||
'string,string,[kids: @self_ref[]]',
|
|
||||||
'1,Root,[[2]]',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const result = csvToModule(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, 'self_ref.csv') });
|
|
||||||
|
|
||||||
expect(result.js).not.toContain("import _self_ref from './self_ref.csv'");
|
|
||||||
expect(result.js).toContain('_self_refLookup');
|
|
||||||
expect(result.js).toContain('export default function getData()');
|
|
||||||
expect(result.js).toContain('children:');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should generate correct type definition for self-referencing table using local singular type', () => {
|
|
||||||
const csv = readFixture('self_ref.csv');
|
|
||||||
|
|
||||||
const result = csvToModule(csv, { emitTypes: true, resourceName: 'nodes', currentFilePath: path.join(fixturesDir, 'self_ref.csv') });
|
|
||||||
|
|
||||||
expect(result.dts).toContain('declare function getData(): nodesTable');
|
|
||||||
expect(result.dts).toContain('readonly parent: Nodes');
|
|
||||||
expect(result.dts).not.toContain("import type { Self_ref } from './self_ref.csv'");
|
|
||||||
expect(result.dts).not.toContain('Self_ref');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit accessor for cross-referencing tables with array references', () => {
|
|
||||||
const csv = readFixture('circular_a.csv');
|
|
||||||
|
|
||||||
const result = csvToModule(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, 'circular_a.csv') });
|
|
||||||
|
|
||||||
expect(result.js).toContain("import _circular_b from './circular_b.csv'");
|
|
||||||
expect(result.js).toContain('export default function getData()');
|
|
||||||
expect(result.js).toContain('_circular_bLookup');
|
|
||||||
expect(result.js).toContain('.map(id =>');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit accessor for nested cross-reference in tuple', () => {
|
|
||||||
const csv = [
|
|
||||||
'id,name,info',
|
|
||||||
'string,string,[ref: @circular_b; note: string]',
|
|
||||||
'1,A,[ref: 1; note: linked]',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const result = csvToModule(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, 'circular_a.csv') });
|
|
||||||
|
|
||||||
expect(result.js).toContain("import _circular_b from './circular_b.csv'");
|
|
||||||
expect(result.js).toContain('_circular_bLookup');
|
|
||||||
expect(result.js).toContain('export default function getData()');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should generate union fallback with ?? for non-reference union members', () => {
|
|
||||||
const csv = [
|
|
||||||
'id,value',
|
|
||||||
'string,@users | string',
|
|
||||||
'1,1',
|
|
||||||
'2,unknown',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const result = csvToModule(csv, { emitTypes: false });
|
|
||||||
|
|
||||||
expect(result.js).toContain('?? row.value');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -81,120 +81,6 @@ function resolveReferenceId(
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseReferenceIds(schema: ReferenceSchema, valueString: string): unknown {
|
|
||||||
const valueParser = new ReferenceValueParser(valueString.trim());
|
|
||||||
const ids = valueParser.parseIds(schema.isArray);
|
|
||||||
if (schema.isArray) {
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
return ids[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseValueWithReferenceIds(
|
|
||||||
valueString: string,
|
|
||||||
schema: Schema
|
|
||||||
): unknown {
|
|
||||||
if (!hasNestedReferences(schema)) {
|
|
||||||
return parseValue(schema, valueString);
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (schema.type) {
|
|
||||||
case 'reference':
|
|
||||||
return parseReferenceIds(schema, valueString);
|
|
||||||
case 'tuple': {
|
|
||||||
const parsed = parseValue(schema, valueString) as unknown[];
|
|
||||||
return schema.elements.map((el, i) =>
|
|
||||||
hasNestedReferences(el.schema)
|
|
||||||
? extractNestedReferenceIds(parsed[i], el.schema)
|
|
||||||
: parsed[i]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case 'array': {
|
|
||||||
const parsed = parseValue(schema, valueString) as unknown[];
|
|
||||||
return parsed.map(item =>
|
|
||||||
hasNestedReferences(schema.element)
|
|
||||||
? extractNestedReferenceIds(item, schema.element)
|
|
||||||
: item
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case 'union': {
|
|
||||||
for (const member of schema.members) {
|
|
||||||
if (hasNestedReferences(member)) {
|
|
||||||
try {
|
|
||||||
const parsed = parseValue(member, valueString);
|
|
||||||
return extractNestedReferenceIds(parsed, member);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parseValue(schema, valueString);
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return parseValue(schema, valueString);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractNestedReferenceIds(value: unknown, schema: Schema): unknown {
|
|
||||||
switch (schema.type) {
|
|
||||||
case 'reference':
|
|
||||||
if (schema.isArray) {
|
|
||||||
const ids = Array.isArray(value) ? value : [value];
|
|
||||||
return ids.map(id => String(id));
|
|
||||||
}
|
|
||||||
return String(value);
|
|
||||||
case 'tuple': {
|
|
||||||
if (!Array.isArray(value)) return value;
|
|
||||||
return schema.elements.map((el, i) =>
|
|
||||||
hasNestedReferences(el.schema)
|
|
||||||
? extractNestedReferenceIds(value[i], el.schema)
|
|
||||||
: value[i]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case 'array': {
|
|
||||||
if (!Array.isArray(value)) return value;
|
|
||||||
return value.map(item =>
|
|
||||||
hasNestedReferences(schema.element)
|
|
||||||
? extractNestedReferenceIds(item, schema.element)
|
|
||||||
: item
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case 'union': {
|
|
||||||
for (const member of schema.members) {
|
|
||||||
if (hasNestedReferences(member)) {
|
|
||||||
try {
|
|
||||||
return extractNestedReferenceIds(value, member);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectReferenceFields(schema: Schema, name: string): ReferenceFieldInfo[] {
|
|
||||||
const fields: ReferenceFieldInfo[] = [];
|
|
||||||
switch (schema.type) {
|
|
||||||
case 'reference':
|
|
||||||
fields.push({ name, tableName: schema.tableName, isArray: schema.isArray, schema });
|
|
||||||
break;
|
|
||||||
case 'tuple':
|
|
||||||
for (const el of schema.elements) {
|
|
||||||
fields.push(...collectReferenceFields(el.schema, name));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'array':
|
|
||||||
fields.push(...collectReferenceFields(schema.element, name));
|
|
||||||
break;
|
|
||||||
case 'union':
|
|
||||||
for (const member of schema.members) {
|
|
||||||
fields.push(...collectReferenceFields(member, name));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return fields;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseValueWithReferences(
|
function parseValueWithReferences(
|
||||||
valueString: string,
|
valueString: string,
|
||||||
schema: Schema,
|
schema: Schema,
|
||||||
|
|
@ -317,23 +203,6 @@ export interface CsvLoaderOptions {
|
||||||
defaultPrimaryKey?: string;
|
defaultPrimaryKey?: string;
|
||||||
/** Current file path (used to resolve relative references) */
|
/** Current file path (used to resolve relative references) */
|
||||||
currentFilePath?: string;
|
currentFilePath?: string;
|
||||||
/**
|
|
||||||
* When false, reference fields store parsed IDs instead of resolved objects.
|
|
||||||
* Used by csvToModule to emit accessor-based code with lazy resolution.
|
|
||||||
* Default: true (resolves references eagerly by loading referenced CSV files).
|
|
||||||
*/
|
|
||||||
resolveReferences?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReferenceFieldInfo {
|
|
||||||
/** Column name in the CSV */
|
|
||||||
name: string;
|
|
||||||
/** Referenced table name */
|
|
||||||
tableName: string;
|
|
||||||
/** Whether it's an array reference */
|
|
||||||
isArray: boolean;
|
|
||||||
/** The schema of this field (for nested references) */
|
|
||||||
schema: Schema;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CsvParseResult {
|
export interface CsvParseResult {
|
||||||
|
|
@ -345,8 +214,6 @@ export interface CsvParseResult {
|
||||||
propertyConfigs: PropertyConfig[];
|
propertyConfigs: PropertyConfig[];
|
||||||
/** Referenced table names */
|
/** Referenced table names */
|
||||||
references: Set<string>;
|
references: Set<string>;
|
||||||
/** Reference field metadata (populated when resolveReferences is false) */
|
|
||||||
referenceFields: ReferenceFieldInfo[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PropertyConfig {
|
interface PropertyConfig {
|
||||||
|
|
@ -537,22 +404,12 @@ function generateTypeDefinition(
|
||||||
currentFilePath?: string
|
currentFilePath?: string
|
||||||
): string {
|
): string {
|
||||||
const typeName = resourceName ? `${resourceName}Table` : 'Table';
|
const typeName = resourceName ? `${resourceName}Table` : 'Table';
|
||||||
const currentTableName = currentFilePath
|
|
||||||
? path.basename(currentFilePath, path.extname(currentFilePath))
|
|
||||||
: undefined;
|
|
||||||
const singularType = resourceName
|
|
||||||
? resourceName.charAt(0).toUpperCase() + resourceName.slice(1)
|
|
||||||
: `${typeName}[number]`;
|
|
||||||
|
|
||||||
// Generate import statements for referenced tables
|
// Generate import statements for referenced tables
|
||||||
const imports: string[] = [];
|
const imports: string[] = [];
|
||||||
const resourceNames = new Map<string, string>();
|
const resourceNames = new Map<string, string>();
|
||||||
|
|
||||||
references.forEach(tableName => {
|
references.forEach(tableName => {
|
||||||
if (tableName === currentTableName) {
|
|
||||||
resourceNames.set(tableName, singularType);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Convert table name to type name by capitalizing
|
// Convert table name to type name by capitalizing
|
||||||
const typeBase = tableName.charAt(0).toUpperCase() + tableName.slice(1);
|
const typeBase = tableName.charAt(0).toUpperCase() + tableName.slice(1);
|
||||||
resourceNames.set(tableName, typeBase);
|
resourceNames.set(tableName, typeBase);
|
||||||
|
|
@ -560,8 +417,10 @@ function generateTypeDefinition(
|
||||||
// Generate import path based on current file path
|
// Generate import path based on current file path
|
||||||
let importPath: string;
|
let importPath: string;
|
||||||
if (currentFilePath) {
|
if (currentFilePath) {
|
||||||
|
// Both files are in the same directory, use relative path
|
||||||
importPath = `./${tableName}.csv`;
|
importPath = `./${tableName}.csv`;
|
||||||
} else {
|
} else {
|
||||||
|
// Fallback for unknown path
|
||||||
importPath = `../${tableName}.csv`;
|
importPath = `../${tableName}.csv`;
|
||||||
}
|
}
|
||||||
imports.push(`import type { ${typeBase} } from '${importPath}';`);
|
imports.push(`import type { ${typeBase} } from '${importPath}';`);
|
||||||
|
|
@ -573,9 +432,14 @@ function generateTypeDefinition(
|
||||||
.map((config) => ` readonly ${config.name}: ${schemaToTypeString(config.schema, resourceNames)};`)
|
.map((config) => ` readonly ${config.name}: ${schemaToTypeString(config.schema, resourceNames)};`)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
|
// Generate both the table type and export a singular type alias for references
|
||||||
|
// e.g., for "parts" table, export both "partsTable" and "Parts" (as alias)
|
||||||
let exportAlias = '';
|
let exportAlias = '';
|
||||||
if (resourceName) {
|
if (resourceName) {
|
||||||
|
// Capitalize resource name for the singular type
|
||||||
const singularType = resourceName.charAt(0).toUpperCase() + resourceName.slice(1);
|
const singularType = resourceName.charAt(0).toUpperCase() + resourceName.slice(1);
|
||||||
|
// Remove trailing 's' if it looks like a plural (simple heuristic)
|
||||||
|
// Actually, let's just use the table name capitalized - users can adjust if needed
|
||||||
exportAlias = `\nexport type ${singularType} = ${typeName}[number];`;
|
exportAlias = `\nexport type ${singularType} = ${typeName}[number];`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -584,8 +448,8 @@ ${properties}
|
||||||
}[];
|
}[];
|
||||||
${exportAlias}
|
${exportAlias}
|
||||||
|
|
||||||
declare function getData(): ${typeName};
|
declare const data: ${typeName};
|
||||||
export default getData;
|
export default data;
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -635,8 +499,6 @@ export function parseCsv(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveReferences = options.resolveReferences ?? true;
|
|
||||||
|
|
||||||
const propertyConfigs: PropertyConfig[] = headers.map((header: string, index: number) => {
|
const propertyConfigs: PropertyConfig[] = headers.map((header: string, index: number) => {
|
||||||
const schemaString = schemas[index];
|
const schemaString = schemas[index];
|
||||||
const schema = parseSchema(schemaString);
|
const schema = parseSchema(schemaString);
|
||||||
|
|
@ -652,26 +514,14 @@ export function parseCsv(
|
||||||
config.isReference = true;
|
config.isReference = true;
|
||||||
config.referenceTableName = schema.tableName;
|
config.referenceTableName = schema.tableName;
|
||||||
config.referenceIsArray = schema.isArray;
|
config.referenceIsArray = schema.isArray;
|
||||||
if (resolveReferences) {
|
|
||||||
config.parser = (valueString: string) => {
|
config.parser = (valueString: string) => {
|
||||||
return parseReferenceValue(schema, valueString, refBaseDir, defaultPrimaryKey, options.currentFilePath);
|
return parseReferenceValue(schema, valueString, refBaseDir, defaultPrimaryKey, options.currentFilePath);
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
config.parser = (valueString: string) => {
|
|
||||||
return parseReferenceIds(schema, valueString);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else if (hasNestedReferences(schema)) {
|
} else if (hasNestedReferences(schema)) {
|
||||||
config.isReference = true;
|
config.isReference = true;
|
||||||
if (resolveReferences) {
|
|
||||||
config.parser = (valueString: string) => {
|
config.parser = (valueString: string) => {
|
||||||
return parseValueWithReferences(valueString, schema, refBaseDir, defaultPrimaryKey, options.currentFilePath);
|
return parseValueWithReferences(valueString, schema, refBaseDir, defaultPrimaryKey, options.currentFilePath);
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
config.parser = (valueString: string) => {
|
|
||||||
return parseValueWithReferenceIds(valueString, schema);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
|
|
@ -723,20 +573,10 @@ export function parseCsv(
|
||||||
return obj;
|
return obj;
|
||||||
});
|
});
|
||||||
|
|
||||||
const referenceFields: ReferenceFieldInfo[] = [];
|
|
||||||
if (!resolveReferences) {
|
|
||||||
for (const config of propertyConfigs) {
|
|
||||||
if (hasNestedReferences(config.schema)) {
|
|
||||||
referenceFields.push(...collectReferenceFields(config.schema, config.name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: CsvParseResult = {
|
const result: CsvParseResult = {
|
||||||
data: objects,
|
data: objects,
|
||||||
propertyConfigs,
|
propertyConfigs,
|
||||||
references,
|
references,
|
||||||
referenceFields,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (emitTypes) {
|
if (emitTypes) {
|
||||||
|
|
@ -751,137 +591,22 @@ export function parseCsv(
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate runtime reference resolution code for a schema.
|
|
||||||
* Returns a JS expression string that resolves references using lookup maps.
|
|
||||||
*/
|
|
||||||
function generateSchemaResolutionCode(
|
|
||||||
schema: Schema,
|
|
||||||
valueExpr: string,
|
|
||||||
lookupVar: (tableName: string) => string,
|
|
||||||
pkField: string
|
|
||||||
): string {
|
|
||||||
switch (schema.type) {
|
|
||||||
case 'reference': {
|
|
||||||
const lookup = lookupVar(schema.tableName);
|
|
||||||
if (schema.isArray) {
|
|
||||||
return `(Array.isArray(${valueExpr}) ? ${valueExpr}.map(id => ${lookup}.get(String(id))) : ${valueExpr})`;
|
|
||||||
}
|
|
||||||
return `${lookup}.get(String(${valueExpr}))`;
|
|
||||||
}
|
|
||||||
case 'tuple': {
|
|
||||||
const elementResolvers = schema.elements.map((el, i) => {
|
|
||||||
if (hasNestedReferences(el.schema)) {
|
|
||||||
return generateSchemaResolutionCode(el.schema, `${valueExpr}[${i}]`, lookupVar, pkField);
|
|
||||||
}
|
|
||||||
return `${valueExpr}[${i}]`;
|
|
||||||
});
|
|
||||||
return `[${elementResolvers.join(', ')}]`;
|
|
||||||
}
|
|
||||||
case 'array': {
|
|
||||||
if (hasNestedReferences(schema.element)) {
|
|
||||||
const itemResolve = generateSchemaResolutionCode(schema.element, 'item', lookupVar, pkField);
|
|
||||||
return `(${valueExpr}).map(item => ${itemResolve})`;
|
|
||||||
}
|
|
||||||
return valueExpr;
|
|
||||||
}
|
|
||||||
case 'union': {
|
|
||||||
const refMembers = schema.members.filter(m => hasNestedReferences(m));
|
|
||||||
const nonRefMembers = schema.members.filter(m => !hasNestedReferences(m));
|
|
||||||
const resolveParts: string[] = [];
|
|
||||||
for (const member of refMembers) {
|
|
||||||
const resolveCode = generateSchemaResolutionCode(member, valueExpr, lookupVar, pkField);
|
|
||||||
resolveParts.push(resolveCode);
|
|
||||||
}
|
|
||||||
if (nonRefMembers.length > 0) {
|
|
||||||
resolveParts.push(valueExpr);
|
|
||||||
}
|
|
||||||
if (resolveParts.length === 0) return valueExpr;
|
|
||||||
if (resolveParts.length === 1) return resolveParts[0];
|
|
||||||
return `(${resolveParts.join(' ?? ')})`;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return valueExpr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate JavaScript module code from CSV content.
|
* Generate JavaScript module code from CSV content.
|
||||||
* Emits an accessor function for tables with references (lazy resolution),
|
* Returns a string that can be used as a module export.
|
||||||
* or static JSON for tables without references.
|
*
|
||||||
|
* @param content - CSV content string
|
||||||
|
* @param options - Parsing options
|
||||||
|
* @returns JavaScript module code string
|
||||||
*/
|
*/
|
||||||
export function csvToModule(
|
export function csvToModule(
|
||||||
content: string,
|
content: string,
|
||||||
options: CsvLoaderOptions & { resourceName?: string } = {}
|
options: CsvLoaderOptions & { resourceName?: string } = {}
|
||||||
): { js: string; dts?: string } {
|
): { js: string; dts?: string } {
|
||||||
const result = parseCsv(content, { ...options, resolveReferences: false });
|
const result = parseCsv(content, options);
|
||||||
|
|
||||||
const hasRefs = result.referenceFields.length > 0;
|
const json = JSON.stringify(result.data, null, 2);
|
||||||
const defaultPrimaryKey = options.defaultPrimaryKey ?? 'id';
|
const js = `export default ${json};`;
|
||||||
|
|
||||||
const imports: string[] = [];
|
|
||||||
const lookupInits: string[] = [];
|
|
||||||
const lookupVarMap = new Map<string, string>();
|
|
||||||
|
|
||||||
const currentTableName = options.currentFilePath
|
|
||||||
? path.basename(options.currentFilePath, path.extname(options.currentFilePath))
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const uniqueTables = new Set(result.referenceFields.map(f => f.tableName));
|
|
||||||
uniqueTables.forEach(tableName => {
|
|
||||||
const lookupVar = `_${tableName}Lookup`;
|
|
||||||
lookupVarMap.set(tableName, lookupVar);
|
|
||||||
|
|
||||||
if (tableName === currentTableName) {
|
|
||||||
lookupInits.push(
|
|
||||||
`const ${lookupVar} = new Map(_raw.map(p => [String(p.${defaultPrimaryKey}), p]));`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const varName = `_${tableName}`;
|
|
||||||
imports.push(`import ${varName} from './${tableName}.csv';`);
|
|
||||||
lookupInits.push(
|
|
||||||
`const ${lookupVar} = new Map(${varName}().map(p => [String(p.${defaultPrimaryKey}), p]));`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const lookupVar = (tableName: string) => lookupVarMap.get(tableName)!;
|
|
||||||
|
|
||||||
const rowResolvers: string[] = [];
|
|
||||||
for (const config of result.propertyConfigs) {
|
|
||||||
if (hasNestedReferences(config.schema)) {
|
|
||||||
const resolveCode = generateSchemaResolutionCode(
|
|
||||||
config.schema,
|
|
||||||
`row.${config.name}`,
|
|
||||||
lookupVar,
|
|
||||||
defaultPrimaryKey
|
|
||||||
);
|
|
||||||
rowResolvers.push(` ${config.name}: ${resolveCode},`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawJson = JSON.stringify(result.data, null, 2);
|
|
||||||
|
|
||||||
const js = [
|
|
||||||
...imports,
|
|
||||||
'',
|
|
||||||
`const _raw = ${rawJson};`,
|
|
||||||
'',
|
|
||||||
'let _resolved = null;',
|
|
||||||
'',
|
|
||||||
'export default function getData() {',
|
|
||||||
' if (_resolved) return _resolved;',
|
|
||||||
' _resolved = _raw;',
|
|
||||||
...lookupInits.map(l => ` ${l}`),
|
|
||||||
...rowResolvers.length > 0 ? [
|
|
||||||
' _resolved = _raw.map(row => ({',
|
|
||||||
' ...row,',
|
|
||||||
...rowResolvers,
|
|
||||||
' }));',
|
|
||||||
] : [],
|
|
||||||
' return _resolved;',
|
|
||||||
'}',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
js,
|
js,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { LoaderContext } from '@rspack/core';
|
import type { LoaderContext } from '@rspack/core';
|
||||||
import type { CsvLoaderOptions } from './loader.js';
|
import type { CsvLoaderOptions } from './loader.js';
|
||||||
import { csvToModule } from './loader.js';
|
import { parseCsv } from './loader.js';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
|
@ -30,14 +30,14 @@ export default function csvLoader(
|
||||||
.replace(/[-_\s]+(.)?/g, (_, char) => char ? char.toUpperCase() : '')
|
.replace(/[-_\s]+(.)?/g, (_, char) => char ? char.toUpperCase() : '')
|
||||||
.replace(/^(.)/, (_, char) => char.toUpperCase());
|
.replace(/^(.)/, (_, char) => char.toUpperCase());
|
||||||
|
|
||||||
const result = csvToModule(content, {
|
const result = parseCsv(content, {
|
||||||
...options,
|
...options,
|
||||||
resourceName,
|
resourceName,
|
||||||
currentFilePath: this.resourcePath,
|
currentFilePath: this.resourcePath,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Emit type definition file if enabled
|
// Emit type definition file if enabled
|
||||||
if (emitTypes && result.dts) {
|
if (emitTypes && result.typeDefinition) {
|
||||||
const context = this.context || '';
|
const context = this.context || '';
|
||||||
// Get relative path from context, normalize to forward slashes
|
// Get relative path from context, normalize to forward slashes
|
||||||
let relativePath = this.resourcePath.replace(context, '');
|
let relativePath = this.resourcePath.replace(context, '');
|
||||||
|
|
@ -56,12 +56,12 @@ export default function csvLoader(
|
||||||
// Write directly to disk (useful for dev server)
|
// Write directly to disk (useful for dev server)
|
||||||
const absolutePath = path.join(this.context || process.cwd(), typesOutputDir || '', dtsFileName);
|
const absolutePath = path.join(this.context || process.cwd(), typesOutputDir || '', dtsFileName);
|
||||||
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
||||||
fs.writeFileSync(absolutePath, result.dts);
|
fs.writeFileSync(absolutePath, result.typeDefinition);
|
||||||
} else {
|
} else {
|
||||||
// Emit to in-memory filesystem (for production build)
|
// Emit to in-memory filesystem (for production build)
|
||||||
this.emitFile?.(outputPath, result.dts);
|
this.emitFile?.(outputPath, result.typeDefinition);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.js;
|
return `export default ${JSON.stringify(result.data, null, 2)};`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,10 @@ describe('Primitive types', () => {
|
||||||
expect(schema.validator(42)).toBe(false);
|
expect(schema.validator(42)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject unknown identifiers', () => {
|
it('should handle identifiers with hyphens', () => {
|
||||||
expect(() => defineSchema('word-smith')).toThrow(ParseError);
|
const schema = defineSchema('word-smith');
|
||||||
expect(() => defineSchema('strign')).toThrow(ParseError);
|
expect(schema.parse('word-smith')).toBe('word-smith');
|
||||||
|
expect(schema.validator('word-smith')).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -237,7 +237,23 @@ class Parser {
|
||||||
return { type: 'tuple', elements };
|
return { type: 'tuple', elements };
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new ParseError(`Unknown type: ${this.peek() || 'end of input'}`, this.pos);
|
let identifier = '';
|
||||||
|
while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) {
|
||||||
|
identifier += this.consume();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (identifier.length > 0) {
|
||||||
|
if (this.consumeStr('[')) {
|
||||||
|
this.skipWhitespace();
|
||||||
|
if (!this.consumeStr(']')) {
|
||||||
|
throw new ParseError('Expected ]', this.pos);
|
||||||
|
}
|
||||||
|
return { type: 'array', element: { type: 'string' } };
|
||||||
|
}
|
||||||
|
return { type: 'string' };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ParseError(`Unexpected character: ${this.peek()}`, this.pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseStringLiteralSchema(): Schema {
|
private parseStringLiteralSchema(): Schema {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { defineSchema, parseSchema, parseValue, createValidator } from './index';
|
||||||
|
|
||||||
|
console.log('=== Testing Schema Parser ===\n');
|
||||||
|
|
||||||
|
const testCases = [
|
||||||
|
{ schema: 'string', value: 'hello', description: 'Simple string' },
|
||||||
|
{ schema: 'number', value: '42', description: 'Simple number' },
|
||||||
|
{ schema: 'int', value: '42', description: 'Simple int' },
|
||||||
|
{ schema: 'float', value: '3.14', description: 'Simple float' },
|
||||||
|
{ schema: 'float', value: '42', description: 'Float with integer value' },
|
||||||
|
{ schema: 'boolean', value: 'true', description: 'Simple boolean' },
|
||||||
|
{ schema: '[string; number]', value: '[hello; 42]', description: 'Tuple' },
|
||||||
|
{ schema: '[string; number]', value: 'hello; 42', description: 'Tuple without brackets' },
|
||||||
|
{ schema: 'string[]', value: '[hello; world; test]', description: 'Array of strings' },
|
||||||
|
{ schema: 'string[]', value: 'hello; world; test', description: 'Array without brackets' },
|
||||||
|
{ schema: 'number[]', value: '[1; 2; 3; 4]', description: 'Array of numbers' },
|
||||||
|
{ schema: 'int[]', value: '[1; 2; 3; 4]', description: 'Array of ints' },
|
||||||
|
{ schema: 'float[]', value: '[1.5; 2.5; 3.5]', description: 'Array of floats' },
|
||||||
|
{ schema: '[string; number][]', value: '[[a; 1]; [b; 2]; [c; 3]]', description: 'Array of tuples' },
|
||||||
|
{ schema: '[string; number][]', value: '[a; 1]; [b; 2]; [c; 3]', description: 'Array of tuples without outer brackets' },
|
||||||
|
{ schema: 'word-smith', value: 'word-smith', description: 'String with hyphen' },
|
||||||
|
{ schema: 'string', value: 'hello\\;world', description: 'Escaped semicolon' },
|
||||||
|
{ schema: 'string', value: 'hello\\[world', description: 'Escaped bracket' },
|
||||||
|
{ schema: 'string', value: 'hello\\\\world', description: 'Escaped backslash' },
|
||||||
|
{ schema: '[string; string]', value: 'hello\\;world; test', description: 'Tuple with escaped semicolon' },
|
||||||
|
{ schema: '[x: number; y: number]', value: '[x: 10; y: 20]', description: 'Named tuple' },
|
||||||
|
{ schema: '[x: number; y: number]', value: 'x: 10; y: 20', description: 'Named tuple without brackets' },
|
||||||
|
{ schema: '[name: string; age: number; active: boolean]', value: '[name: Alice; age: 30; active: true]', description: 'Named tuple with mixed types' },
|
||||||
|
{ schema: '[name: string; age: number]', value: 'name: Bob; age: 25', description: 'Named tuple without brackets' },
|
||||||
|
{ schema: '[point: [x: number; y: number]]', value: '[point: [x: 5; y: 10]]', description: 'Nested named tuple' },
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('=== Testing String Literals ===\n');
|
||||||
|
|
||||||
|
const stringLiteralCases = [
|
||||||
|
{ schema: '"hello"', value: '"hello"', description: 'Simple string literal' },
|
||||||
|
{ schema: "'world'", value: "'world'", description: 'Single quoted string literal' },
|
||||||
|
{ schema: '"on"', value: '"on"', description: 'String literal "on"' },
|
||||||
|
{ schema: '"off"', value: '"off"', description: 'String literal "off"' },
|
||||||
|
{ schema: '"hello;world"', value: '"hello;world"', description: 'String literal with semicolon' },
|
||||||
|
{ schema: '"hello\\"world"', value: '"hello\\"world"', description: 'String literal with escaped quote' },
|
||||||
|
];
|
||||||
|
|
||||||
|
testCases.push(...stringLiteralCases);
|
||||||
|
|
||||||
|
console.log('=== Testing Union Types (Enums) ===\n');
|
||||||
|
|
||||||
|
const unionCases = [
|
||||||
|
{ schema: '"on" | "off"', value: '"on"', description: 'Union: on' },
|
||||||
|
{ schema: '"on" | "off"', value: '"off"', description: 'Union: off' },
|
||||||
|
{ schema: '"pending" | "approved" | "rejected"', value: '"approved"', description: 'Union: approved' },
|
||||||
|
{ schema: '( "active" | "inactive" )', value: '"active"', description: 'Union with parentheses' },
|
||||||
|
{ schema: '[name: string; status: "active" | "inactive"]', value: '[myName; "active"]', description: 'Tuple with union field' },
|
||||||
|
{ schema: '("pending" | "approved" | "rejected")[]', value: '["pending"; "approved"; "rejected"]', description: 'Array of unions' },
|
||||||
|
{ schema: 'string | number', value: 'hello', description: 'Union: string' },
|
||||||
|
{ schema: 'string | number', value: '42', description: 'Union: number' },
|
||||||
|
{ schema: 'string | "special"', value: 'normal', description: 'Union: string type' },
|
||||||
|
{ schema: 'string | "special"', value: '"special"', description: 'Union: string literal' },
|
||||||
|
];
|
||||||
|
|
||||||
|
testCases.push(...unionCases);
|
||||||
|
|
||||||
|
testCases.forEach(({ schema, value, description }) => {
|
||||||
|
try {
|
||||||
|
console.log(`Test: ${description}`);
|
||||||
|
console.log(` Schema: ${schema}`);
|
||||||
|
console.log(` Value: "${value}"`);
|
||||||
|
|
||||||
|
const parsed = defineSchema(schema);
|
||||||
|
const parsedValue = parsed.parse(value);
|
||||||
|
const isValid = parsed.validator(parsedValue);
|
||||||
|
|
||||||
|
console.log(` Parsed: ${JSON.stringify(parsedValue)}`);
|
||||||
|
console.log(` Valid: ${isValid}`);
|
||||||
|
console.log(' ✓ Passed\n');
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` ✗ Failed: ${(error as Error).message}\n`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('=== Testing Validation ===\n');
|
||||||
|
|
||||||
|
const stringSchema = defineSchema('string');
|
||||||
|
console.log('String schema validation:');
|
||||||
|
console.log(` "hello" is valid: ${stringSchema.validator('hello')}`);
|
||||||
|
console.log(` 42 is valid: ${stringSchema.validator(42)}\n`);
|
||||||
|
|
||||||
|
const numberSchema = defineSchema('number');
|
||||||
|
console.log('Number schema validation:');
|
||||||
|
console.log(` 42 is valid: ${numberSchema.validator(42)}`);
|
||||||
|
console.log(` "42" is valid: ${numberSchema.validator('42')}\n`);
|
||||||
|
|
||||||
|
const tupleSchema = defineSchema('[string; number; boolean]');
|
||||||
|
console.log('Tuple [string; number; boolean] validation:');
|
||||||
|
console.log(` ["hello", 42, true] is valid: ${tupleSchema.validator(['hello', 42, true])}`);
|
||||||
|
console.log(` ["hello", "42", true] is valid: ${tupleSchema.validator(['hello', '42', true])}\n`);
|
||||||
|
|
||||||
|
const arraySchema = defineSchema('number[]');
|
||||||
|
console.log('Array number[] validation:');
|
||||||
|
console.log(` [1, 2, 3] is valid: ${arraySchema.validator([1, 2, 3])}`);
|
||||||
|
console.log(` [1, "2", 3] is valid: ${arraySchema.validator([1, '2', 3])}\n`);
|
||||||
|
|
||||||
|
const arrayOfTuplesSchema = defineSchema('[string; number][]');
|
||||||
|
console.log('Array of tuples [string; number][] validation:');
|
||||||
|
console.log(` [["a", 1], ["b", 2]] is valid: ${arrayOfTuplesSchema.validator([['a', 1], ['b', 2]])}`);
|
||||||
|
console.log(` [["a", "1"], ["b", 2]] is valid: ${arrayOfTuplesSchema.validator([['a', '1'], ['b', 2]])}\n`);
|
||||||
|
|
||||||
|
console.log('=== Testing Int and Float Types ===\n');
|
||||||
|
|
||||||
|
const intSchema = defineSchema('int');
|
||||||
|
console.log('Int schema validation:');
|
||||||
|
console.log(` 42 is valid: ${intSchema.validator(42)}`);
|
||||||
|
console.log(` 3.14 is valid: ${intSchema.validator(3.14)}\n`);
|
||||||
|
|
||||||
|
const floatSchema = defineSchema('float');
|
||||||
|
console.log('Float schema validation:');
|
||||||
|
console.log(` 3.14 is valid: ${floatSchema.validator(3.14)}`);
|
||||||
|
console.log(` 42 is valid: ${floatSchema.validator(42)}`);
|
||||||
|
console.log(` "3.14" is valid: ${floatSchema.validator('3.14')}\n`);
|
||||||
|
|
||||||
|
const intArraySchema = defineSchema('int[]');
|
||||||
|
console.log('Int array validation:');
|
||||||
|
console.log(` [1, 2, 3] is valid: ${intArraySchema.validator([1, 2, 3])}`);
|
||||||
|
console.log(` [1, 2.5, 3] is valid: ${intArraySchema.validator([1, 2.5, 3])}\n`);
|
||||||
|
|
||||||
|
const floatArraySchema = defineSchema('float[]');
|
||||||
|
console.log('Float array validation:');
|
||||||
|
console.log(` [1.5, 2.5, 3.5] is valid: ${floatArraySchema.validator([1.5, 2.5, 3.5])}`);
|
||||||
|
console.log(` [1, 2, 3] is valid: ${floatArraySchema.validator([1, 2, 3])}\n`);
|
||||||
|
|
||||||
|
console.log('=== All tests completed ===');
|
||||||
Loading…
Reference in New Issue