From 8343df231671d8b38d53c7178e08b93a40fa51fd Mon Sep 17 00:00:00 2001 From: hypercross Date: Wed, 15 Apr 2026 13:58:14 +0800 Subject: [PATCH] fix: bug fixes and new tests --- src/csv-loader/fixtures/circular_a.csv | 3 + src/csv-loader/fixtures/circular_b.csv | 3 + src/csv-loader/fixtures/complex.csv | 5 + src/csv-loader/fixtures/orders.csv | 5 + src/csv-loader/fixtures/parts.csv | 5 + src/csv-loader/fixtures/self_ref.csv | 4 + src/csv-loader/fixtures/users.csv | 5 + src/csv-loader/loader.test.ts | 562 +++++++++++++++++++++++++ src/csv-loader/loader.ts | 254 ++++++++--- src/index.test.ts | 183 ++++++++ 10 files changed, 968 insertions(+), 61 deletions(-) create mode 100644 src/csv-loader/fixtures/circular_a.csv create mode 100644 src/csv-loader/fixtures/circular_b.csv create mode 100644 src/csv-loader/fixtures/complex.csv create mode 100644 src/csv-loader/fixtures/orders.csv create mode 100644 src/csv-loader/fixtures/parts.csv create mode 100644 src/csv-loader/fixtures/self_ref.csv create mode 100644 src/csv-loader/fixtures/users.csv create mode 100644 src/csv-loader/loader.test.ts diff --git a/src/csv-loader/fixtures/circular_a.csv b/src/csv-loader/fixtures/circular_a.csv new file mode 100644 index 0000000..e0a1468 --- /dev/null +++ b/src/csv-loader/fixtures/circular_a.csv @@ -0,0 +1,3 @@ +id,name,related +string,string,@circular_b[] +1,A,[1] \ No newline at end of file diff --git a/src/csv-loader/fixtures/circular_b.csv b/src/csv-loader/fixtures/circular_b.csv new file mode 100644 index 0000000..dd8ef99 --- /dev/null +++ b/src/csv-loader/fixtures/circular_b.csv @@ -0,0 +1,3 @@ +id,name,related +string,string,@circular_a[] +1,B,[1] \ No newline at end of file diff --git a/src/csv-loader/fixtures/complex.csv b/src/csv-loader/fixtures/complex.csv new file mode 100644 index 0000000..db5d3ea --- /dev/null +++ b/src/csv-loader/fixtures/complex.csv @@ -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 \ No newline at end of file diff --git a/src/csv-loader/fixtures/orders.csv b/src/csv-loader/fixtures/orders.csv new file mode 100644 index 0000000..aef9839 --- /dev/null +++ b/src/csv-loader/fixtures/orders.csv @@ -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 \ No newline at end of file diff --git a/src/csv-loader/fixtures/parts.csv b/src/csv-loader/fixtures/parts.csv new file mode 100644 index 0000000..c3b2ec1 --- /dev/null +++ b/src/csv-loader/fixtures/parts.csv @@ -0,0 +1,5 @@ +id,name,price +string,string,number +1,Widget,10.5 +2,Gadget,25.0 +3,Doohickey,7.99 \ No newline at end of file diff --git a/src/csv-loader/fixtures/self_ref.csv b/src/csv-loader/fixtures/self_ref.csv new file mode 100644 index 0000000..dcd7d33 --- /dev/null +++ b/src/csv-loader/fixtures/self_ref.csv @@ -0,0 +1,4 @@ +id,name,parent +string,string,@self_ref +1,Root,2 +2,Child,1 \ No newline at end of file diff --git a/src/csv-loader/fixtures/users.csv b/src/csv-loader/fixtures/users.csv new file mode 100644 index 0000000..21f90b5 --- /dev/null +++ b/src/csv-loader/fixtures/users.csv @@ -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 \ No newline at end of file diff --git a/src/csv-loader/loader.test.ts b/src/csv-loader/loader.test.ts new file mode 100644 index 0000000..2e9c758 --- /dev/null +++ b/src/csv-loader/loader.test.ts @@ -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[]; + 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[]; + 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[]; + 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[]; + 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; + const reviewer = result.data[0].reviewer as Record; + 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', + }); + }); +}); \ No newline at end of file diff --git a/src/csv-loader/loader.ts b/src/csv-loader/loader.ts index 584d9d3..523cb78 100644 --- a/src/csv-loader/loader.ts +++ b/src/csv-loader/loader.ts @@ -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>; refTable: Record[] } { + 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[]; + 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>(); + refTable.forEach(row => { + const pkValue = row[defaultPrimaryKey]; + if (pkValue !== undefined) { + lookup.set(String(pkValue), row); + } + }); + + return { lookup, refTable }; +} + +function resolveReferenceId( + id: string, + lookup: Map>, + tableName: string +): Record { + 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[]>(); +const referenceTableCache = new Map[]>(); + +/** Set of file paths currently being loaded (to detect circular references) */ +const loadingFiles = new Set(); /** * 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[]; - 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>(); - 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; diff --git a/src/index.test.ts b/src/index.test.ts index 53b1a45..0acba8c 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -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);