fix: bug fixes and new tests
This commit is contained in:
parent
d5fdb69ad7
commit
8343df2316
|
|
@ -0,0 +1,3 @@
|
|||
id,name,related
|
||||
string,string,@circular_b[]
|
||||
1,A,[1]
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
id,name,related
|
||||
string,string,@circular_a[]
|
||||
1,B,[1]
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
id,name,details,alternatives,status
|
||||
string,string,[ref: @users; note: string],@users[] | string,"active" | "inactive"
|
||||
1,Order1,[ref: 1; note: urgent],1,active
|
||||
2,Order2,[ref: 2; note: normal],normal,inactive
|
||||
3,Order3,[ref: 3; note: low],"active",active
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 68.
|
|
|
@ -0,0 +1,5 @@
|
|||
id,customer,items,total
|
||||
string,@users,@parts[],number
|
||||
1,1,[1; 2],35.5
|
||||
2,2,[3],7.99
|
||||
3,1,[1; 2; 3],43.49
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
id,name,price
|
||||
string,string,number
|
||||
1,Widget,10.5
|
||||
2,Gadget,25.0
|
||||
3,Doohickey,7.99
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
id,name,parent
|
||||
string,string,@self_ref
|
||||
1,Root,2
|
||||
2,Child,1
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
id,name,email
|
||||
string,string,string
|
||||
1,Alice,alice@example.com
|
||||
2,Bob,bob@example.com
|
||||
3,Charlie,charlie@example.com
|
||||
|
|
|
@ -0,0 +1,562 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { parseCsv } from './loader';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const fixturesDir = path.join(__dirname, 'fixtures');
|
||||
|
||||
function readFixture(name: string): string {
|
||||
return fs.readFileSync(path.join(fixturesDir, name), 'utf-8');
|
||||
}
|
||||
|
||||
describe('parseCsv - basic parsing', () => {
|
||||
it('should parse a simple CSV with primitive types', () => {
|
||||
const csv = [
|
||||
'name,age,active',
|
||||
'string,number,boolean',
|
||||
'Alice,30,true',
|
||||
'Bob,25,false',
|
||||
].join('\n');
|
||||
|
||||
const result = parseCsv(csv, { emitTypes: false });
|
||||
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0]).toEqual({ name: 'Alice', age: 30, active: true });
|
||||
expect(result.data[1]).toEqual({ name: 'Bob', age: 25, active: false });
|
||||
});
|
||||
|
||||
it('should parse CSV with int and float columns', () => {
|
||||
const csv = [
|
||||
'id,count,price',
|
||||
'int,int,float',
|
||||
'1,5,9.99',
|
||||
'2,3,4.50',
|
||||
].join('\n');
|
||||
|
||||
const result = parseCsv(csv, { emitTypes: false });
|
||||
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0]).toEqual({ id: 1, count: 5, price: 9.99 });
|
||||
expect(result.data[1]).toEqual({ id: 2, count: 3, price: 4.5 });
|
||||
});
|
||||
|
||||
it('should parse CSV with string literal columns (unquoted in CSV)', () => {
|
||||
const csv = [
|
||||
'name,status',
|
||||
'string,on | off',
|
||||
'Alice,on',
|
||||
'Bob,off',
|
||||
].join('\n');
|
||||
|
||||
const result = parseCsv(csv, { emitTypes: false });
|
||||
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0]).toEqual({ name: 'Alice', status: 'on' });
|
||||
expect(result.data[1]).toEqual({ name: 'Bob', status: 'off' });
|
||||
});
|
||||
|
||||
it('should parse CSV with array columns', () => {
|
||||
const csv = [
|
||||
'name,tags',
|
||||
'string,string[]',
|
||||
'Alice,[dev; admin]',
|
||||
'Bob,[user]',
|
||||
].join('\n');
|
||||
|
||||
const result = parseCsv(csv, { emitTypes: false });
|
||||
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0]).toEqual({ name: 'Alice', tags: ['dev', 'admin'] });
|
||||
expect(result.data[1]).toEqual({ name: 'Bob', tags: ['user'] });
|
||||
});
|
||||
|
||||
it('should parse CSV with tuple columns', () => {
|
||||
const csv = [
|
||||
'name,coords',
|
||||
'string,[number; number]',
|
||||
'Alice,[1; 2]',
|
||||
'Bob,[3; 4]',
|
||||
].join('\n');
|
||||
|
||||
const result = parseCsv(csv, { emitTypes: false });
|
||||
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0]).toEqual({ name: 'Alice', coords: [1, 2] });
|
||||
expect(result.data[1]).toEqual({ name: 'Bob', coords: [3, 4] });
|
||||
});
|
||||
|
||||
it('should require at least 2 rows (header + schema)', () => {
|
||||
const csv = 'name,age\nstring,number';
|
||||
expect(() => parseCsv(csv, { emitTypes: false })).not.toThrow();
|
||||
|
||||
const csv1Row = 'name,age';
|
||||
expect(() => parseCsv(csv1Row, { emitTypes: false })).toThrow('at least 2 rows');
|
||||
});
|
||||
|
||||
it('should throw if header and schema count mismatch', () => {
|
||||
const csv = 'name,age\nstring';
|
||||
expect(() => parseCsv(csv, { emitTypes: false })).toThrow('does not match');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCsv - reference resolution', () => {
|
||||
it('should resolve single reference to another CSV table', () => {
|
||||
const usersCsv = readFixture('users.csv');
|
||||
const result = parseCsv(usersCsv, { emitTypes: false });
|
||||
|
||||
expect(result.data).toHaveLength(3);
|
||||
expect(result.data[0]).toEqual({ id: '1', name: 'Alice', email: 'alice@example.com' });
|
||||
});
|
||||
|
||||
it('should resolve reference values using parseCsv with referenced tables', () => {
|
||||
const ordersCsv = [
|
||||
'id,customer,total',
|
||||
'string,@users,number',
|
||||
'1,1,100',
|
||||
].join('\n');
|
||||
|
||||
const result = parseCsv(ordersCsv, {
|
||||
emitTypes: false,
|
||||
currentFilePath: path.join(fixturesDir, 'orders.csv'),
|
||||
});
|
||||
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].customer).toEqual({
|
||||
id: '1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
});
|
||||
expect(result.data[0].total).toBe(100);
|
||||
});
|
||||
|
||||
it('should resolve array reference values', () => {
|
||||
const ordersCsv = [
|
||||
'id,items,total',
|
||||
'string,@parts[],number',
|
||||
'1,[1; 2],35.5',
|
||||
].join('\n');
|
||||
|
||||
const result = parseCsv(ordersCsv, {
|
||||
emitTypes: false,
|
||||
currentFilePath: path.join(fixturesDir, 'orders.csv'),
|
||||
});
|
||||
|
||||
expect(result.data).toHaveLength(1);
|
||||
const items = result.data[0].items as Record<string, unknown>[];
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toEqual({ id: '1', name: 'Widget', price: 10.5 });
|
||||
expect(items[1]).toEqual({ id: '2', name: 'Gadget', price: 25 });
|
||||
expect(result.data[0].total).toBe(35.5);
|
||||
});
|
||||
|
||||
it('should resolve mixed single and array references', () => {
|
||||
const ordersCsv = [
|
||||
'id,customer,items,total',
|
||||
'string,@users,@parts[],number',
|
||||
'1,1,[1; 2],35.5',
|
||||
].join('\n');
|
||||
|
||||
const result = parseCsv(ordersCsv, {
|
||||
emitTypes: false,
|
||||
currentFilePath: path.join(fixturesDir, 'orders.csv'),
|
||||
});
|
||||
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].customer).toEqual({
|
||||
id: '1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
});
|
||||
const items = result.data[0].items as Record<string, unknown>[];
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toEqual({ id: '1', name: 'Widget', price: 10.5 });
|
||||
});
|
||||
|
||||
it('should throw error for reference to non-existent ID', () => {
|
||||
const ordersCsv = [
|
||||
'id,customer',
|
||||
'string,@users',
|
||||
'1,999',
|
||||
].join('\n');
|
||||
|
||||
expect(() => parseCsv(ordersCsv, {
|
||||
emitTypes: false,
|
||||
currentFilePath: path.join(fixturesDir, 'orders.csv'),
|
||||
})).toThrow(/not found/);
|
||||
});
|
||||
|
||||
it('should throw error for reference to non-existent table', () => {
|
||||
const csv = [
|
||||
'id,ref',
|
||||
'string,@nonexistent',
|
||||
'1,someid',
|
||||
].join('\n');
|
||||
|
||||
expect(() => parseCsv(csv, {
|
||||
emitTypes: false,
|
||||
currentFilePath: path.join(fixturesDir, 'test.csv'),
|
||||
})).toThrow(/Failed to load referenced table/);
|
||||
});
|
||||
|
||||
it('should collect reference table names', () => {
|
||||
const csv = [
|
||||
'id,customer,items',
|
||||
'string,@users,@parts[]',
|
||||
'1,1,[1]',
|
||||
].join('\n');
|
||||
|
||||
const result = parseCsv(csv, {
|
||||
emitTypes: false,
|
||||
currentFilePath: path.join(fixturesDir, 'test.csv'),
|
||||
});
|
||||
|
||||
expect(result.references.has('users')).toBe(true);
|
||||
expect(result.references.has('parts')).toBe(true);
|
||||
});
|
||||
|
||||
it('should use custom primary key', () => {
|
||||
const nameCsv = [
|
||||
'code,name',
|
||||
'string,string',
|
||||
'US,United States',
|
||||
'UK,United Kingdom',
|
||||
].join('\n');
|
||||
|
||||
const nameCsvPath = path.join(fixturesDir, 'countries.csv');
|
||||
fs.writeFileSync(nameCsvPath, nameCsv);
|
||||
|
||||
try {
|
||||
const refCsv = [
|
||||
'id,country',
|
||||
'string,@countries',
|
||||
'1,US',
|
||||
].join('\n');
|
||||
|
||||
const result = parseCsv(refCsv, {
|
||||
emitTypes: false,
|
||||
currentFilePath: path.join(fixturesDir, 'ref.csv'),
|
||||
defaultPrimaryKey: 'code',
|
||||
});
|
||||
|
||||
expect(result.data[0].country).toEqual({ code: 'US', name: 'United States' });
|
||||
} finally {
|
||||
fs.unlinkSync(nameCsvPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCsv - circular reference detection', () => {
|
||||
it('should detect self-referencing circular reference', () => {
|
||||
const csv = [
|
||||
'id,name,parent',
|
||||
'string,string,@self_ref',
|
||||
'1,Root,2',
|
||||
].join('\n');
|
||||
|
||||
expect(() => parseCsv(csv, {
|
||||
emitTypes: false,
|
||||
currentFilePath: path.join(fixturesDir, 'self_ref.csv'),
|
||||
})).toThrow(/Circular reference detected/);
|
||||
});
|
||||
|
||||
it('should detect mutual circular reference (A -> B -> A)', () => {
|
||||
const csv = readFixture('circular_a.csv');
|
||||
|
||||
expect(() => parseCsv(csv, {
|
||||
emitTypes: false,
|
||||
currentFilePath: path.join(fixturesDir, 'circular_a.csv'),
|
||||
})).toThrow(/Circular reference detected/);
|
||||
});
|
||||
|
||||
it('should allow same table referenced from multiple columns without circular reference', () => {
|
||||
const usersCsv = readFixture('users.csv');
|
||||
const csv = [
|
||||
'id,creator,reviewer',
|
||||
'string,@users,@users',
|
||||
'1,1,2',
|
||||
].join('\n');
|
||||
|
||||
const result = parseCsv(csv, {
|
||||
emitTypes: false,
|
||||
currentFilePath: path.join(fixturesDir, 'test.csv'),
|
||||
});
|
||||
|
||||
expect(result.data[0].creator).toEqual({ id: '1', name: 'Alice', email: 'alice@example.com' });
|
||||
expect(result.data[0].reviewer).toEqual({ id: '2', name: 'Bob', email: 'bob@example.com' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCsv - references in combinatory schemas', () => {
|
||||
it('should resolve reference inside a tuple', () => {
|
||||
const csv = [
|
||||
'id,info',
|
||||
'string,[ref: @users; note: string]',
|
||||
'1,[ref: 1; note: urgent]',
|
||||
].join('\n');
|
||||
|
||||
const result = parseCsv(csv, {
|
||||
emitTypes: false,
|
||||
currentFilePath: path.join(fixturesDir, 'test.csv'),
|
||||
});
|
||||
|
||||
expect(result.data).toHaveLength(1);
|
||||
const info = result.data[0].info as unknown[];
|
||||
expect(info).toHaveLength(2);
|
||||
expect(info[0]).toEqual({ id: '1', name: 'Alice', email: 'alice@example.com' });
|
||||
expect(info[1]).toBe('urgent');
|
||||
});
|
||||
|
||||
it('should resolve reference array inside a tuple', () => {
|
||||
const csv = [
|
||||
'id,info',
|
||||
'string,[refs: @users[]; note: string]',
|
||||
'1,[refs: [1; 2]; note: test]',
|
||||
].join('\n');
|
||||
|
||||
const result = parseCsv(csv, {
|
||||
emitTypes: false,
|
||||
currentFilePath: path.join(fixturesDir, 'test.csv'),
|
||||
});
|
||||
|
||||
expect(result.data).toHaveLength(1);
|
||||
const info = result.data[0].info as unknown[];
|
||||
expect(info).toHaveLength(2);
|
||||
const refs = info[0] as Record<string, unknown>[];
|
||||
expect(refs).toHaveLength(2);
|
||||
expect(refs[0]).toEqual({ id: '1', name: 'Alice', email: 'alice@example.com' });
|
||||
expect(refs[1]).toEqual({ id: '2', name: 'Bob', email: 'bob@example.com' });
|
||||
expect(info[1]).toBe('test');
|
||||
});
|
||||
|
||||
it('should resolve array of tuples containing references', () => {
|
||||
const csv = [
|
||||
'id,pairs',
|
||||
'string,[@users; number][]',
|
||||
'1,[[1; 10]; [2; 20]]',
|
||||
].join('\n');
|
||||
|
||||
const result = parseCsv(csv, {
|
||||
emitTypes: false,
|
||||
currentFilePath: path.join(fixturesDir, 'test.csv'),
|
||||
});
|
||||
|
||||
expect(result.data).toHaveLength(1);
|
||||
const pairs = result.data[0].pairs as unknown[][];
|
||||
expect(pairs).toHaveLength(2);
|
||||
expect(pairs[0]).toHaveLength(2);
|
||||
expect(pairs[0][0]).toEqual({ id: '1', name: 'Alice', email: 'alice@example.com' });
|
||||
expect(pairs[0][1]).toBe(10);
|
||||
expect(pairs[1][0]).toEqual({ id: '2', name: 'Bob', email: 'bob@example.com' });
|
||||
expect(pairs[1][1]).toBe(20);
|
||||
});
|
||||
|
||||
it('should resolve reference in union (@users | string)', () => {
|
||||
const csv = [
|
||||
'id,value',
|
||||
'string,@users | string',
|
||||
'1,1',
|
||||
'2,unknown',
|
||||
].join('\n');
|
||||
|
||||
const result = parseCsv(csv, {
|
||||
emitTypes: false,
|
||||
currentFilePath: path.join(fixturesDir, 'test.csv'),
|
||||
});
|
||||
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].value).toEqual({ id: '1', name: 'Alice', email: 'alice@example.com' });
|
||||
expect(result.data[1].value).toBe('unknown');
|
||||
});
|
||||
|
||||
it('should resolve reference in union (@users[] | string)', () => {
|
||||
const csv = [
|
||||
'id,value',
|
||||
'string,@users[] | string',
|
||||
'1,[1; 2]',
|
||||
'2,none',
|
||||
].join('\n');
|
||||
|
||||
const result = parseCsv(csv, {
|
||||
emitTypes: false,
|
||||
currentFilePath: path.join(fixturesDir, 'test.csv'),
|
||||
});
|
||||
|
||||
expect(result.data).toHaveLength(2);
|
||||
const arr = result.data[0].value as Record<string, unknown>[];
|
||||
expect(arr).toHaveLength(2);
|
||||
expect(arr[0]).toEqual({ id: '1', name: 'Alice', email: 'alice@example.com' });
|
||||
expect(result.data[1].value).toBe('none');
|
||||
});
|
||||
|
||||
it('should resolve array of reference unions (@users | @parts)[]', () => {
|
||||
const csv = [
|
||||
'id,items',
|
||||
'string,(@users | @parts)[]',
|
||||
'1,[1; 2]',
|
||||
].join('\n');
|
||||
|
||||
const result = parseCsv(csv, {
|
||||
emitTypes: false,
|
||||
currentFilePath: path.join(fixturesDir, 'test.csv'),
|
||||
});
|
||||
|
||||
expect(result.data).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should resolve named tuple with reference and other fields', () => {
|
||||
const csv = [
|
||||
'id,details',
|
||||
'string,[owner: @users; count: number]',
|
||||
'1,[owner: 1; count: 5]',
|
||||
].join('\n');
|
||||
|
||||
const result = parseCsv(csv, {
|
||||
emitTypes: false,
|
||||
currentFilePath: path.join(fixturesDir, 'test.csv'),
|
||||
});
|
||||
|
||||
expect(result.data).toHaveLength(1);
|
||||
const details = result.data[0].details as unknown[];
|
||||
expect(details).toHaveLength(2);
|
||||
expect(details[0]).toEqual({ id: '1', name: 'Alice', email: 'alice@example.com' });
|
||||
expect(details[1]).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCsv - type generation', () => {
|
||||
it('should generate type definition with emitTypes enabled', () => {
|
||||
const csv = [
|
||||
'name,age',
|
||||
'string,number',
|
||||
'Alice,30',
|
||||
].join('\n');
|
||||
|
||||
const result = parseCsv(csv, { emitTypes: true, resourceName: 'people' });
|
||||
|
||||
expect(result.typeDefinition).toBeDefined();
|
||||
expect(result.typeDefinition).toContain('peopleTable');
|
||||
expect(result.typeDefinition).toContain('readonly name: string');
|
||||
expect(result.typeDefinition).toContain('readonly age: number');
|
||||
});
|
||||
|
||||
it('should include reference imports in type definition', () => {
|
||||
const csv = [
|
||||
'id,customer',
|
||||
'string,@users',
|
||||
'1,1',
|
||||
].join('\n');
|
||||
|
||||
const result = parseCsv(csv, {
|
||||
emitTypes: true,
|
||||
resourceName: 'orders',
|
||||
currentFilePath: path.join(fixturesDir, 'test.csv'),
|
||||
});
|
||||
|
||||
expect(result.typeDefinition).toBeDefined();
|
||||
expect(result.typeDefinition).toContain('Users');
|
||||
expect(result.typeDefinition).toContain('users.csv');
|
||||
});
|
||||
|
||||
it('should not generate type definition when emitTypes is false', () => {
|
||||
const csv = [
|
||||
'name,age',
|
||||
'string,number',
|
||||
'Alice,30',
|
||||
].join('\n');
|
||||
|
||||
const result = parseCsv(csv, { emitTypes: false });
|
||||
expect(result.typeDefinition).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should generate correct type for reference column', () => {
|
||||
const csv = [
|
||||
'id,customer',
|
||||
'string,@users',
|
||||
'1,1',
|
||||
].join('\n');
|
||||
|
||||
const result = parseCsv(csv, {
|
||||
emitTypes: true,
|
||||
resourceName: 'orders',
|
||||
currentFilePath: path.join(fixturesDir, 'test.csv'),
|
||||
});
|
||||
|
||||
expect(result.typeDefinition).toContain('readonly customer: Users');
|
||||
});
|
||||
|
||||
it('should generate correct type for array reference column', () => {
|
||||
const csv = [
|
||||
'id,items',
|
||||
'string,@parts[]',
|
||||
'1,[1]',
|
||||
].join('\n');
|
||||
|
||||
const result = parseCsv(csv, {
|
||||
emitTypes: true,
|
||||
resourceName: 'orders',
|
||||
currentFilePath: path.join(fixturesDir, 'test.csv'),
|
||||
});
|
||||
|
||||
expect(result.typeDefinition).toContain('readonly items: readonly Parts[]');
|
||||
});
|
||||
|
||||
it('should generate correct type for reference in tuple', () => {
|
||||
const csv = [
|
||||
'id,info',
|
||||
'string,[ref: @users; note: string]',
|
||||
'1,[ref: 1; note: test]',
|
||||
].join('\n');
|
||||
|
||||
const result = parseCsv(csv, {
|
||||
emitTypes: true,
|
||||
resourceName: 'data',
|
||||
currentFilePath: path.join(fixturesDir, 'test.csv'),
|
||||
});
|
||||
|
||||
expect(result.typeDefinition).toContain('Users');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCsv - caching', () => {
|
||||
it('should cache referenced table and not re-read on subsequent references', () => {
|
||||
const usersCsv = readFixture('users.csv');
|
||||
|
||||
const csv = [
|
||||
'id,creator,reviewer',
|
||||
'string,@users,@users',
|
||||
'1,1,2',
|
||||
].join('\n');
|
||||
|
||||
const result = parseCsv(csv, {
|
||||
emitTypes: false,
|
||||
currentFilePath: path.join(fixturesDir, 'test.csv'),
|
||||
});
|
||||
|
||||
const creator = result.data[0].creator as Record<string, unknown>;
|
||||
const reviewer = result.data[0].reviewer as Record<string, unknown>;
|
||||
expect(creator).not.toEqual(reviewer);
|
||||
expect(creator.id).toBe('1');
|
||||
expect(reviewer.id).toBe('2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCsv - refBaseDir option', () => {
|
||||
it('should use refBaseDir to resolve reference paths', () => {
|
||||
const csv = [
|
||||
'id,customer',
|
||||
'string,@users',
|
||||
'1,1',
|
||||
].join('\n');
|
||||
|
||||
const result = parseCsv(csv, {
|
||||
emitTypes: false,
|
||||
refBaseDir: fixturesDir,
|
||||
});
|
||||
|
||||
expect(result.data[0].customer).toEqual({
|
||||
id: '1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -4,6 +4,186 @@ import type { Schema, ReferenceSchema } from '../types.js';
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
function hasNestedReferences(schema: Schema): boolean {
|
||||
switch (schema.type) {
|
||||
case 'reference':
|
||||
return true;
|
||||
case 'tuple':
|
||||
return schema.elements.some(el => hasNestedReferences(el.schema));
|
||||
case 'array':
|
||||
return hasNestedReferences(schema.element);
|
||||
case 'union':
|
||||
return schema.members.some(m => hasNestedReferences(m));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadReferenceTable(
|
||||
schema: ReferenceSchema,
|
||||
refBaseDir: string | undefined,
|
||||
defaultPrimaryKey: string,
|
||||
currentFilePath: string | undefined
|
||||
): { lookup: Map<string, Record<string, unknown>>; refTable: Record<string, unknown>[] } {
|
||||
const baseDir = refBaseDir || (currentFilePath ? path.dirname(currentFilePath) : process.cwd());
|
||||
const fileName = `${schema.tableName}.csv`;
|
||||
const refFilePath = path.isAbsolute(fileName)
|
||||
? fileName
|
||||
: path.join(baseDir, fileName);
|
||||
|
||||
let refTable: Record<string, unknown>[];
|
||||
if (referenceTableCache.has(refFilePath)) {
|
||||
refTable = referenceTableCache.get(refFilePath)!;
|
||||
} else {
|
||||
if (loadingFiles.has(refFilePath)) {
|
||||
throw new Error(
|
||||
`Circular reference detected: table "${schema.tableName}" (${refFilePath}) is already being loaded`
|
||||
);
|
||||
}
|
||||
loadingFiles.add(refFilePath);
|
||||
try {
|
||||
const refContent = fs.readFileSync(refFilePath, 'utf-8');
|
||||
const refResult = parseCsv(refContent, {
|
||||
currentFilePath: refFilePath,
|
||||
emitTypes: false,
|
||||
});
|
||||
refTable = refResult.data;
|
||||
referenceTableCache.set(refFilePath, refTable);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to load referenced table "${schema.tableName}" from ${refFilePath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
} finally {
|
||||
loadingFiles.delete(refFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
const lookup = new Map<string, Record<string, unknown>>();
|
||||
refTable.forEach(row => {
|
||||
const pkValue = row[defaultPrimaryKey];
|
||||
if (pkValue !== undefined) {
|
||||
lookup.set(String(pkValue), row);
|
||||
}
|
||||
});
|
||||
|
||||
return { lookup, refTable };
|
||||
}
|
||||
|
||||
function resolveReferenceId(
|
||||
id: string,
|
||||
lookup: Map<string, Record<string, unknown>>,
|
||||
tableName: string
|
||||
): Record<string, unknown> {
|
||||
const obj = lookup.get(id);
|
||||
if (!obj) {
|
||||
throw new Error(`Reference to "${tableName}" with id="${id}" not found`);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
function parseValueWithReferences(
|
||||
valueString: string,
|
||||
schema: Schema,
|
||||
refBaseDir: string | undefined,
|
||||
defaultPrimaryKey: string,
|
||||
currentFilePath: string | undefined
|
||||
): unknown {
|
||||
if (!hasNestedReferences(schema)) {
|
||||
return parseValue(schema, valueString);
|
||||
}
|
||||
|
||||
switch (schema.type) {
|
||||
case 'reference':
|
||||
return parseReferenceValue(schema, valueString, refBaseDir, defaultPrimaryKey, currentFilePath);
|
||||
case 'tuple': {
|
||||
const parsed = parseValue(schema, valueString) as unknown[];
|
||||
return schema.elements.map((el, i) =>
|
||||
resolveNestedReferences(parsed[i], el.schema, refBaseDir, defaultPrimaryKey, currentFilePath)
|
||||
);
|
||||
}
|
||||
case 'array': {
|
||||
const parsed = parseValue(schema, valueString) as unknown[];
|
||||
return parsed.map(item =>
|
||||
resolveNestedReferences(item, schema.element, refBaseDir, defaultPrimaryKey, currentFilePath)
|
||||
);
|
||||
}
|
||||
case 'union': {
|
||||
const errors: Error[] = [];
|
||||
for (const member of schema.members) {
|
||||
if (hasNestedReferences(member)) {
|
||||
try {
|
||||
const parsed = parseValue(member, valueString);
|
||||
return resolveNestedReferences(parsed, member, refBaseDir, defaultPrimaryKey, currentFilePath);
|
||||
} catch (e) {
|
||||
errors.push(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (errors.length > 0 && errors.every(e => /not found|Circular reference|Failed to load/.test(e.message))) {
|
||||
for (const member of schema.members) {
|
||||
if (!hasNestedReferences(member)) {
|
||||
try {
|
||||
return parseValue(member, valueString);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
return parseValue(schema, valueString);
|
||||
}
|
||||
default:
|
||||
return parseValue(schema, valueString);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNestedReferences(
|
||||
value: unknown,
|
||||
schema: Schema,
|
||||
refBaseDir: string | undefined,
|
||||
defaultPrimaryKey: string,
|
||||
currentFilePath: string | undefined
|
||||
): unknown {
|
||||
switch (schema.type) {
|
||||
case 'reference': {
|
||||
const { lookup } = loadReferenceTable(schema, refBaseDir, defaultPrimaryKey, currentFilePath);
|
||||
if (schema.isArray) {
|
||||
const ids = Array.isArray(value) ? value : [value];
|
||||
return ids.map(id => resolveReferenceId(String(id), lookup, schema.tableName));
|
||||
}
|
||||
return resolveReferenceId(String(value), lookup, schema.tableName);
|
||||
}
|
||||
case 'tuple': {
|
||||
if (!Array.isArray(value)) return value;
|
||||
return schema.elements.map((el, i) =>
|
||||
resolveNestedReferences(value[i], el.schema, refBaseDir, defaultPrimaryKey, currentFilePath)
|
||||
);
|
||||
}
|
||||
case 'array': {
|
||||
if (!Array.isArray(value)) return value;
|
||||
return value.map(item =>
|
||||
resolveNestedReferences(item, schema.element, refBaseDir, defaultPrimaryKey, currentFilePath)
|
||||
);
|
||||
}
|
||||
case 'union': {
|
||||
const errors: Error[] = [];
|
||||
for (const member of schema.members) {
|
||||
if (hasNestedReferences(member)) {
|
||||
try {
|
||||
return resolveNestedReferences(value, member, refBaseDir, defaultPrimaryKey, currentFilePath);
|
||||
} catch (e) {
|
||||
errors.push(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw errors[0];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CsvLoaderOptions {
|
||||
delimiter?: string;
|
||||
quote?: string;
|
||||
|
|
@ -50,7 +230,10 @@ interface PropertyConfig {
|
|||
}
|
||||
|
||||
/** Cache for loaded referenced tables */
|
||||
const referenceTableCache = new Map<string, Record<string, unknown>[]>();
|
||||
const referenceTableCache = new Map<string, Record<string,unknown>[]>();
|
||||
|
||||
/** Set of file paths currently being loaded (to detect circular references) */
|
||||
const loadingFiles = new Set<string>();
|
||||
|
||||
/**
|
||||
* Parse and resolve a reference value.
|
||||
|
|
@ -63,70 +246,16 @@ function parseReferenceValue(
|
|||
defaultPrimaryKey: string,
|
||||
currentFilePath: string | undefined
|
||||
): unknown {
|
||||
// Determine the directory to search for referenced files
|
||||
const baseDir = refBaseDir || (currentFilePath ? path.dirname(currentFilePath) : process.cwd());
|
||||
const { lookup } = loadReferenceTable(schema, refBaseDir, defaultPrimaryKey, currentFilePath);
|
||||
|
||||
// Build the referenced file path
|
||||
const fileName = `${schema.tableName}.csv`;
|
||||
const refFilePath = path.isAbsolute(fileName)
|
||||
? fileName
|
||||
: path.join(baseDir, fileName);
|
||||
|
||||
// Load the referenced table (use cache if already loaded)
|
||||
let refTable: Record<string, unknown>[];
|
||||
if (referenceTableCache.has(refFilePath)) {
|
||||
refTable = referenceTableCache.get(refFilePath)!;
|
||||
} else {
|
||||
try {
|
||||
const refContent = fs.readFileSync(refFilePath, 'utf-8');
|
||||
const refResult = parseCsv(refContent, {
|
||||
currentFilePath: refFilePath,
|
||||
emitTypes: false,
|
||||
});
|
||||
refTable = refResult.data;
|
||||
referenceTableCache.set(refFilePath, refTable);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to load referenced table "${schema.tableName}" from ${refFilePath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build a lookup map by primary key
|
||||
const primaryKeyMap = new Map<string, Record<string, unknown>>();
|
||||
refTable.forEach(row => {
|
||||
const pkValue = row[defaultPrimaryKey];
|
||||
if (pkValue !== undefined) {
|
||||
primaryKeyMap.set(String(pkValue), row);
|
||||
}
|
||||
});
|
||||
|
||||
// Parse the value string to extract IDs
|
||||
const valueParser = new ReferenceValueParser(valueString.trim());
|
||||
const ids = valueParser.parseIds(schema.isArray);
|
||||
|
||||
// Resolve IDs to actual objects
|
||||
if (schema.isArray) {
|
||||
return ids.map(id => {
|
||||
const obj = primaryKeyMap.get(id);
|
||||
if (!obj) {
|
||||
throw new Error(
|
||||
`Reference to "${schema.tableName}" with ${defaultPrimaryKey}="${id}" not found`
|
||||
);
|
||||
}
|
||||
return obj;
|
||||
});
|
||||
} else {
|
||||
// Single reference (first ID if array provided)
|
||||
const id = ids[0];
|
||||
const obj = primaryKeyMap.get(id);
|
||||
if (!obj) {
|
||||
throw new Error(
|
||||
`Reference to "${schema.tableName}" with ${defaultPrimaryKey}="${id}" not found`
|
||||
);
|
||||
}
|
||||
return obj;
|
||||
return ids.map(id => resolveReferenceId(id, lookup, schema.tableName));
|
||||
}
|
||||
|
||||
return resolveReferenceId(ids[0], lookup, schema.tableName);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -381,15 +510,18 @@ export function parseCsv(
|
|||
parser: (valueString: string) => parseValue(schema, valueString),
|
||||
};
|
||||
|
||||
// Check if it's a reference type
|
||||
if (schema.type === 'reference') {
|
||||
config.isReference = true;
|
||||
config.referenceTableName = schema.tableName;
|
||||
config.referenceIsArray = schema.isArray;
|
||||
// Override parser for reference fields
|
||||
config.parser = (valueString: string) => {
|
||||
return parseReferenceValue(schema, valueString, refBaseDir, defaultPrimaryKey, options.currentFilePath);
|
||||
};
|
||||
} else if (hasNestedReferences(schema)) {
|
||||
config.isReference = true;
|
||||
config.parser = (valueString: string) => {
|
||||
return parseValueWithReferences(valueString, schema, refBaseDir, defaultPrimaryKey, options.currentFilePath);
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
|
|
|
|||
|
|
@ -300,6 +300,189 @@ describe('parseSchema', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Reference schemas (parseSchema)', () => {
|
||||
it('should parse single reference schema @tablename', () => {
|
||||
const schema = parseSchema('@users');
|
||||
expect(schema).toEqual({ type: 'reference', tableName: 'users', isArray: false });
|
||||
});
|
||||
|
||||
it('should parse array reference schema @tablename[]', () => {
|
||||
const schema = parseSchema('@users[]');
|
||||
expect(schema).toEqual({ type: 'reference', tableName: 'users', isArray: true });
|
||||
});
|
||||
|
||||
it('should parse reference with hyphens in table name', () => {
|
||||
const schema = parseSchema('@my-table');
|
||||
expect(schema).toEqual({ type: 'reference', tableName: 'my-table', isArray: false });
|
||||
});
|
||||
|
||||
it('should parse reference with underscores in table name', () => {
|
||||
const schema = parseSchema('@my_table');
|
||||
expect(schema).toEqual({ type: 'reference', tableName: 'my_table', isArray: false });
|
||||
});
|
||||
|
||||
it('should throw ParseError for @ without table name', () => {
|
||||
expect(() => parseSchema('@')).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('should throw ParseError for @ followed by non-identifier', () => {
|
||||
expect(() => parseSchema('@ ')).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('should parse reference inside tuple [string; @users]', () => {
|
||||
const schema = parseSchema('[string; @users]');
|
||||
expect(schema.type).toBe('tuple');
|
||||
if (schema.type === 'tuple') {
|
||||
expect(schema.elements).toHaveLength(2);
|
||||
expect(schema.elements[0].schema).toEqual({ type: 'string' });
|
||||
expect(schema.elements[1].schema).toEqual({ type: 'reference', tableName: 'users', isArray: false });
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse reference array inside tuple [string; @users[]]', () => {
|
||||
const schema = parseSchema('[string; @users[]]');
|
||||
expect(schema.type).toBe('tuple');
|
||||
if (schema.type === 'tuple') {
|
||||
expect(schema.elements).toHaveLength(2);
|
||||
expect(schema.elements[0].schema).toEqual({ type: 'string' });
|
||||
expect(schema.elements[1].schema).toEqual({ type: 'reference', tableName: 'users', isArray: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse named reference in tuple [name: string; author: @users]', () => {
|
||||
const schema = parseSchema('[name: string; author: @users]');
|
||||
expect(schema.type).toBe('tuple');
|
||||
if (schema.type === 'tuple') {
|
||||
expect(schema.elements[1]).toEqual({
|
||||
name: 'author',
|
||||
schema: { type: 'reference', tableName: 'users', isArray: false },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse array of tuples containing reference [@users; number][]', () => {
|
||||
const schema = parseSchema('[@users; number][]');
|
||||
expect(schema.type).toBe('array');
|
||||
if (schema.type === 'array') {
|
||||
expect(schema.element.type).toBe('tuple');
|
||||
if (schema.element.type === 'tuple') {
|
||||
expect(schema.element.elements[0].schema).toEqual({ type: 'reference', tableName: 'users', isArray: false });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse reference in union @users | string', () => {
|
||||
const schema = parseSchema('@users | string');
|
||||
expect(schema.type).toBe('union');
|
||||
if (schema.type === 'union') {
|
||||
expect(schema.members).toHaveLength(2);
|
||||
expect(schema.members[0]).toEqual({ type: 'reference', tableName: 'users', isArray: false });
|
||||
expect(schema.members[1]).toEqual({ type: 'string' });
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse array reference in union @users[] | string', () => {
|
||||
const schema = parseSchema('@users[] | string');
|
||||
expect(schema.type).toBe('union');
|
||||
if (schema.type === 'union') {
|
||||
expect(schema.members[0]).toEqual({ type: 'reference', tableName: 'users', isArray: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse reference inside parenthesized union (@users | @parts)', () => {
|
||||
const schema = parseSchema('(@users | @parts)');
|
||||
expect(schema.type).toBe('union');
|
||||
if (schema.type === 'union') {
|
||||
expect(schema.members).toHaveLength(2);
|
||||
expect(schema.members[0]).toEqual({ type: 'reference', tableName: 'users', isArray: false });
|
||||
expect(schema.members[1]).toEqual({ type: 'reference', tableName: 'parts', isArray: false });
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse array of reference unions (@users | @parts)[]', () => {
|
||||
const schema = parseSchema('(@users | @parts)[]');
|
||||
expect(schema.type).toBe('array');
|
||||
if (schema.type === 'array') {
|
||||
expect(schema.element.type).toBe('union');
|
||||
if (schema.element.type === 'union') {
|
||||
expect(schema.element.members[0]).toEqual({ type: 'reference', tableName: 'users', isArray: false });
|
||||
expect(schema.element.members[1]).toEqual({ type: 'reference', tableName: 'parts', isArray: false });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reference value parsing (parseValue)', () => {
|
||||
it('should parse single reference ID', () => {
|
||||
const schema: import('./types').ReferenceSchema = { type: 'reference', tableName: 'users', isArray: false };
|
||||
const result = parseValue(schema, '42');
|
||||
expect(result).toBe('42');
|
||||
});
|
||||
|
||||
it('should parse array reference IDs with brackets', () => {
|
||||
const schema: import('./types').ReferenceSchema = { type: 'reference', tableName: 'users', isArray: true };
|
||||
const result = parseValue(schema, '[1; 2; 3]');
|
||||
expect(result).toEqual(['1', '2', '3']);
|
||||
});
|
||||
|
||||
it('should parse array reference IDs without brackets', () => {
|
||||
const schema: import('./types').ReferenceSchema = { type: 'reference', tableName: 'users', isArray: true };
|
||||
const result = parseValue(schema, '1; 2; 3');
|
||||
expect(result).toEqual(['1', '2', '3']);
|
||||
});
|
||||
|
||||
it('should parse empty array reference', () => {
|
||||
const schema: import('./types').ReferenceSchema = { type: 'reference', tableName: 'users', isArray: true };
|
||||
const result = parseValue(schema, '[]');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should parse single reference ID with spaces', () => {
|
||||
const schema: import('./types').ReferenceSchema = { type: 'reference', tableName: 'users', isArray: false };
|
||||
const result = parseValue(schema, ' 42 ');
|
||||
expect(result).toBe('42');
|
||||
});
|
||||
|
||||
it('should parse array reference IDs with spaces', () => {
|
||||
const schema: import('./types').ReferenceSchema = { type: 'reference', tableName: 'users', isArray: true };
|
||||
const result = parseValue(schema, '[ 1 ; 2 ; 3 ]');
|
||||
expect(result).toEqual(['1', '2', '3']);
|
||||
});
|
||||
|
||||
it('should parse string IDs in reference', () => {
|
||||
const schema: import('./types').ReferenceSchema = { type: 'reference', tableName: 'users', isArray: false };
|
||||
const result = parseValue(schema, 'abc-123');
|
||||
expect(result).toBe('abc-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reference validation (createValidator)', () => {
|
||||
it('should validate single reference (string ID)', () => {
|
||||
const schema: import('./types').ReferenceSchema = { type: 'reference', tableName: 'users', isArray: false };
|
||||
const validator = createValidator(schema);
|
||||
expect(validator('1')).toBe(true);
|
||||
expect(validator('abc')).toBe(true);
|
||||
expect(validator(42)).toBe(false);
|
||||
expect(validator(true)).toBe(false);
|
||||
});
|
||||
|
||||
it('should validate array reference (array of string IDs)', () => {
|
||||
const schema: import('./types').ReferenceSchema = { type: 'reference', tableName: 'users', isArray: true };
|
||||
const validator = createValidator(schema);
|
||||
expect(validator(['1', '2'])).toBe(true);
|
||||
expect(validator([])).toBe(true);
|
||||
expect(validator(['1'])).toBe(true);
|
||||
expect(validator([1, 2])).toBe(false);
|
||||
expect(validator('1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow string array for single reference (backward compat)', () => {
|
||||
const schema: import('./types').ReferenceSchema = { type: 'reference', tableName: 'users', isArray: false };
|
||||
const validator = createValidator(schema);
|
||||
expect(validator(['1', '2'])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should throw ParseError for invalid schema', () => {
|
||||
expect(() => parseSchema('')).toThrow(ParseError);
|
||||
|
|
|
|||
Loading…
Reference in New Issue