diff --git a/src/csv-loader/loader.test.ts b/src/csv-loader/loader.test.ts index bc148f2..db021f9 100644 --- a/src/csv-loader/loader.test.ts +++ b/src/csv-loader/loader.test.ts @@ -1,37 +1,37 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { parseCsv, csvToModule } from './loader'; -import * as path from 'path'; -import * as fs from 'fs'; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { parseCsv, csvToModule } from "./loader"; +import * as path from "path"; +import * as fs from "fs"; -const fixturesDir = path.join(__dirname, 'fixtures'); +const fixturesDir = path.join(__dirname, "fixtures"); function readFixture(name: string): string { - return fs.readFileSync(path.join(fixturesDir, name), 'utf-8'); + return fs.readFileSync(path.join(fixturesDir, name), "utf-8"); } -describe('parseCsv - basic parsing', () => { - it('should parse a simple CSV with primitive types', () => { +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'); + "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 }); + 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', () => { + 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'); + "id,count,price", + "int,int,float", + "1,5,9.99", + "2,3,4.50", + ].join("\n"); const result = parseCsv(csv, { emitTypes: false }); @@ -40,282 +40,293 @@ describe('parseCsv - basic parsing', () => { expect(result.data[1]).toEqual({ id: 2, count: 3, price: 4.5 }); }); - it('should parse CSV with string literal columns (unquoted in CSV)', () => { + it("should parse CSV with string literal columns (unquoted in CSV)", () => { const csv = [ - 'name,status', + "name,status", "string,'on' | 'off'", - 'Alice,on', - 'Bob,off', - ].join('\n'); + "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' }); + 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', () => { + it("should parse CSV with array columns", () => { const csv = [ - 'name,tags', - 'string,string[]', - 'Alice,[dev; admin]', - 'Bob,[user]', - ].join('\n'); + "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'] }); + 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', () => { + it("should parse CSV with tuple columns", () => { const csv = [ - 'name,coords', - 'string,[number; number]', - 'Alice,[1; 2]', - 'Bob,[3; 4]', - ].join('\n'); + "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] }); + 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'; + 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'); + 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'); + 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'); +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' }); + expect(result.data[0]).toEqual({ + id: "1", + name: "Alice", + email: "alice@example.com", + }); }); - it('should resolve reference values using parseCsv with referenced tables', () => { + it("should resolve reference values using parseCsv with referenced tables", () => { const ordersCsv = [ - 'id,customer,total', - 'string,@users,number', - '1,1,100', - ].join('\n'); + "id,customer,total", + "string,@users,number", + "1,1,100", + ].join("\n"); const result = parseCsv(ordersCsv, { emitTypes: false, - currentFilePath: path.join(fixturesDir, 'orders.csv'), + 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', + id: "1", + name: "Alice", + email: "alice@example.com", }); expect(result.data[0].total).toBe(100); }); - it('should resolve array reference values', () => { + it("should resolve array reference values", () => { const ordersCsv = [ - 'id,items,total', - 'string,@parts[],number', - '1,[1; 2],35.5', - ].join('\n'); + "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'), + 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(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', () => { + 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'); + "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'), + 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', + 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 }); + 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'); + 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/); + 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'); + 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/); + 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'); + 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'), + currentFilePath: path.join(fixturesDir, "test.csv"), }); - expect(result.references.has('users')).toBe(true); - expect(result.references.has('parts')).toBe(true); + expect(result.references.has("users")).toBe(true); + expect(result.references.has("parts")).toBe(true); }); - it('should use custom primary key', () => { + it("should use custom primary key", () => { const nameCsv = [ - 'code,name', - 'string,string', - 'US,United States', - 'UK,United Kingdom', - ].join('\n'); + "code,name", + "string,string", + "US,United States", + "UK,United Kingdom", + ].join("\n"); - const nameCsvPath = path.join(fixturesDir, 'countries.csv'); + const nameCsvPath = path.join(fixturesDir, "countries.csv"); fs.writeFileSync(nameCsvPath, nameCsv); try { - const refCsv = [ - 'id,country', - 'string,@countries', - '1,US', - ].join('\n'); + const refCsv = ["id,country", "string,@countries", "1,US"].join("\n"); const result = parseCsv(refCsv, { emitTypes: false, - currentFilePath: path.join(fixturesDir, 'ref.csv'), - defaultPrimaryKey: 'code', + currentFilePath: path.join(fixturesDir, "ref.csv"), + defaultPrimaryKey: "code", }); - expect(result.data[0].country).toEqual({ code: 'US', name: 'United States' }); + 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'); +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/); + 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'); + 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/); + 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'); + 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'), + 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' }); + 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', () => { +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'); + "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'), + 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'); + 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', () => { + 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'); + "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'), + currentFilePath: path.join(fixturesDir, "test.csv"), }); expect(result.data).toHaveLength(1); @@ -323,230 +334,223 @@ describe('parseCsv - references in combinatory schemas', () => { 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'); + 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', () => { + it("should resolve array of tuples containing references", () => { const csv = [ - 'id,pairs', - 'string,[@users; number][]', - '1,[[1; 10]; [2; 20]]', - ].join('\n'); + "id,pairs", + "string,[@users; number][]", + "1,[[1; 10]; [2; 20]]", + ].join("\n"); const result = parseCsv(csv, { emitTypes: false, - currentFilePath: path.join(fixturesDir, 'test.csv'), + 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][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][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'); + 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'), + 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'); + 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)', () => { + it("should resolve reference in union (@users[] | string)", () => { const csv = [ - 'id,value', - 'string,@users[] | string', - '1,[1; 2]', - '2,none', - ].join('\n'); + "id,value", + "string,@users[] | string", + "1,[1; 2]", + "2,none", + ].join("\n"); const result = parseCsv(csv, { emitTypes: false, - currentFilePath: path.join(fixturesDir, 'test.csv'), + 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'); + 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'); + 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'), + currentFilePath: path.join(fixturesDir, "test.csv"), }); expect(result.data).toHaveLength(1); }); - it('should resolve named tuple with reference and other fields', () => { + 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'); + "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'), + 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[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'); +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' }); + 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'); + 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'); + 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'), + resourceName: "orders", + currentFilePath: path.join(fixturesDir, "test.csv"), }); expect(result.typeDefinition).toBeDefined(); - expect(result.typeDefinition).toContain('Users'); - expect(result.typeDefinition).toContain('users.csv'); + 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'); + 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'); + 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'), + resourceName: "orders", + currentFilePath: path.join(fixturesDir, "test.csv"), }); - expect(result.typeDefinition).toContain('readonly customer: Users'); + 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'); + 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'), + resourceName: "orders", + currentFilePath: path.join(fixturesDir, "test.csv"), }); - expect(result.typeDefinition).toContain('readonly items: Parts[]'); + expect(result.typeDefinition).toContain("readonly items: Parts[]"); }); - it('should generate correct type for reference in tuple', () => { + 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'); + "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'), + resourceName: "data", + currentFilePath: path.join(fixturesDir, "test.csv"), }); - expect(result.typeDefinition).toContain('Users'); + 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'); +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 csv = ["id,creator,reviewer", "string,@users,@users", "1,1,2"].join( + "\n", + ); const result = parseCsv(csv, { emitTypes: false, - currentFilePath: path.join(fixturesDir, 'test.csv'), + 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'); + 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'); +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, @@ -554,36 +558,36 @@ describe('parseCsv - refBaseDir option', () => { }); expect(result.data[0].customer).toEqual({ - id: '1', - name: 'Alice', - email: 'alice@example.com', + id: "1", + name: "Alice", + email: "alice@example.com", }); }); }); -describe('parseCsv - resolveReferences: false', () => { - it('should store IDs instead of resolved objects for reference fields', () => { +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'); + "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']); + expect(result.data[0].customer).toBe("1"); + expect(result.data[0].items).toEqual(["1", "2"]); }); - it('should populate referenceFields with metadata', () => { + it("should populate referenceFields with metadata", () => { const csv = [ - 'id,customer,items', - 'string,@users,@parts[]', - '1,1,[1; 2]', - ].join('\n'); + "id,customer,items", + "string,@users,@parts[]", + "1,1,[1; 2]", + ].join("\n"); const result = parseCsv(csv, { emitTypes: false, @@ -592,377 +596,769 @@ describe('parseCsv - resolveReferences: false', () => { expect(result.referenceFields).toHaveLength(2); expect(result.referenceFields[0]).toEqual({ - name: 'customer', - tableName: 'users', + name: "customer", + tableName: "users", isArray: false, - schema: expect.objectContaining({ type: 'reference', tableName: 'users', isArray: false }), + schema: expect.objectContaining({ + type: "reference", + tableName: "users", + isArray: false, + }), }); expect(result.referenceFields[1]).toEqual({ - name: 'items', - tableName: 'parts', + name: "items", + tableName: "parts", isArray: true, - schema: expect.objectContaining({ type: 'reference', 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'); + 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(); + expect(() => + parseCsv(csv, { + emitTypes: false, + resolveReferences: false, + }), + ).not.toThrow(); }); - it('should store IDs for nested references in tuples', () => { + 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'); + "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.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'); + 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'); + 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'); + 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'); + 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'), + 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.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'); + 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'); + 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'), + currentFilePath: path.join(fixturesDir, "circular_a.csv"), }); expect(result.data).toHaveLength(1); - expect(result.data[0].related).toEqual(['1']); + expect(result.data[0].related).toEqual(["1"]); expect(result.referenceFields).toHaveLength(1); - expect(result.referenceFields[0].tableName).toBe('circular_b'); + expect(result.referenceFields[0].tableName).toBe("circular_b"); }); - it('should store IDs for nested self-reference in tuple', () => { + 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'); + "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'), + 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(parentInfo[0]).toBe("2"); + expect(parentInfo[1]).toBe("admin"); expect(result.referenceFields).toHaveLength(1); - expect(result.referenceFields[0].tableName).toBe('self_ref'); + expect(result.referenceFields[0].tableName).toBe("self_ref"); }); - it('should store IDs for self-reference in union', () => { + 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'); + "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'), + 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.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'); + expect(result.referenceFields[0].tableName).toBe("self_ref"); }); - it('should store IDs for self-reference array in tuple', () => { + it("should store IDs for self-reference array in tuple", () => { const csv = [ - 'id,name,children', - 'string,string,[@self_ref[]]', - '1,Root,[[2]]', - ].join('\n'); + "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'), + currentFilePath: path.join(fixturesDir, "self_ref.csv"), }); const children = result.data[0].children as unknown[]; - expect(children[0]).toEqual(['2']); + expect(children[0]).toEqual(["2"]); expect(result.referenceFields).toHaveLength(1); - expect(result.referenceFields[0].tableName).toBe('self_ref'); + 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'); +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'); + 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'); + 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;'); + 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'); + 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 =>'); + expect(result.js).toContain("_partsLookup"); + expect(result.js).toContain(".map(id =>"); }); - it('should emit multiple imports for multiple reference tables', () => { + it("should emit multiple imports for multiple reference tables", () => { const csv = [ - 'id,customer,items', - 'string,@users,@parts[]', - '1,1,[1; 2]', - ].join('\n'); + "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'); + 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'); + 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' }); + 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'); + 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'); + 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' }); + 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'); + 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', () => { + it("should handle nested references in tuples", () => { const csv = [ - 'id,info', - 'string,[ref: @users; note: string]', - '1,[ref: 1; note: urgent]', - ].join('\n'); + "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'); + 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'); +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') }); + 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))'); + 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'); + 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') }); + 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:'); + 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', () => { + 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'); + "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') }); + 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]))'); + 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', () => { + 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'); + "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') }); + 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'); + 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', () => { + 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'); + "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') }); + 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:'); + 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'); + 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') }); + 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'); + 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'); + 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') }); + 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 =>'); + 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', () => { + 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'); + "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') }); + 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()'); + 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'); + 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'); + expect(result.js).toContain("?? row.value"); }); -}); \ No newline at end of file +}); + +describe("parseCsv - reverse reference resolution", () => { + it("should resolve reverse reference from comment declaration", () => { + // Create a temporary orders CSV with a plain string foreign key + // (not @users reference) so reverse lookup works on unresolved IDs + const ordersCsvPath = path.join(fixturesDir, "rev_orders.csv"); + const ordersContent = [ + "id,customer,total", + "string,string,number", + "1,1,100", + "2,1,50", + "3,2,75", + ].join("\n"); + fs.writeFileSync(ordersCsvPath, ordersContent); + + try { + const csv = [ + "id,name", + "string,string", + "# orders := ~rev_orders(customer)", + "1,Alice", + "2,Bob", + ].join("\n"); + + const result = parseCsv(csv, { + emitTypes: false, + currentFilePath: path.join(fixturesDir, "test.csv"), + }); + + expect(result.data).toHaveLength(2); + // User 1 (Alice) should have 2 orders + const aliceOrders = result.data[0].orders as Record[]; + expect(aliceOrders).toHaveLength(2); + expect(aliceOrders[0]).toEqual({ id: "1", customer: "1", total: 100 }); + expect(aliceOrders[1]).toEqual({ id: "2", customer: "1", total: 50 }); + // User 2 (Bob) should have 1 order + const bobOrders = result.data[1].orders as Record[]; + expect(bobOrders).toHaveLength(1); + expect(bobOrders[0].id).toBe("3"); + } finally { + fs.unlinkSync(ordersCsvPath); + } + }); + + it("should return empty array for reverse reference with no matches", () => { + const ordersCsvPath = path.join(fixturesDir, "rev_orders.csv"); + const ordersContent = [ + "id,customer,total", + "string,string,number", + "1,1,100", + "2,1,50", + ].join("\n"); + fs.writeFileSync(ordersCsvPath, ordersContent); + + try { + const csv = [ + "id,name", + "string,string", + "# orders := ~rev_orders(customer)", + "1,Alice", + "99,Nobody", + ].join("\n"); + + const result = parseCsv(csv, { + emitTypes: false, + currentFilePath: path.join(fixturesDir, "test.csv"), + }); + + expect(result.data).toHaveLength(2); + expect(result.data[0].orders).toHaveLength(2); + expect(result.data[1].orders).toEqual([]); + } finally { + fs.unlinkSync(ordersCsvPath); + } + }); + + it("should return null for optional reverse reference with no matches", () => { + const csv = [ + "id,name", + "string,string", + "# orders := ~orders(customer)?", + "99,Nobody", + ].join("\n"); + + const result = parseCsv(csv, { + emitTypes: false, + currentFilePath: path.join(fixturesDir, "test.csv"), + }); + + expect(result.data).toHaveLength(1); + expect(result.data[0].orders).toBeNull(); + }); + + it("should populate reverseReferences in result", () => { + const csv = [ + "id,name", + "string,string", + "# orders := ~orders(customer)", + "1,Alice", + ].join("\n"); + + const result = parseCsv(csv, { + emitTypes: false, + currentFilePath: path.join(fixturesDir, "test.csv"), + }); + + expect(result.reverseReferences).toHaveLength(1); + expect(result.reverseReferences[0]).toEqual({ + fieldName: "orders", + tableName: "orders", + foreignKey: "customer", + isOptional: false, + schema: { + type: "reverseReference", + tableName: "orders", + foreignKey: "customer", + isOptional: false, + }, + }); + }); + + it("should include reverse reference tables in references set", () => { + const csv = [ + "id,name", + "string,string", + "# orders := ~orders(customer)", + "1,Alice", + ].join("\n"); + + const result = parseCsv(csv, { + emitTypes: false, + currentFilePath: path.join(fixturesDir, "test.csv"), + }); + + expect(result.references.has("orders")).toBe(true); + }); + + it("should support multiple reverse reference declarations", () => { + const csv = [ + "id,name", + "string,string", + "# orders := ~orders(customer)", + "# parts := ~parts(user)", + "1,Alice", + ].join("\n"); + + const result = parseCsv(csv, { + emitTypes: false, + currentFilePath: path.join(fixturesDir, "test.csv"), + }); + + expect(result.reverseReferences).toHaveLength(2); + expect(result.reverseReferences[0].fieldName).toBe("orders"); + expect(result.reverseReferences[1].fieldName).toBe("parts"); + expect(result.data[0]).toHaveProperty("orders"); + expect(result.data[0]).toHaveProperty("parts"); + }); + + it("should ignore comment lines that are not reverse reference declarations", () => { + const csv = [ + "id,name", + "string,string", + "# This is just a comment", + "# orders := ~orders(customer)", + "1,Alice", + ].join("\n"); + + const result = parseCsv(csv, { + emitTypes: false, + currentFilePath: path.join(fixturesDir, "test.csv"), + }); + + expect(result.reverseReferences).toHaveLength(1); + expect(result.data[0]).toHaveProperty("orders"); + }); +}); + +describe("parseCsv - reverse reference with resolveReferences: false", () => { + it("should populate referenceFields for reverse references", () => { + const csv = [ + "id,name", + "string,string", + "# orders := ~orders(customer)", + "1,Alice", + ].join("\n"); + + const result = parseCsv(csv, { + emitTypes: false, + resolveReferences: false, + }); + + // Reverse reference appears in referenceFields from both + // collectReferenceFields (via hasNestedReferences) and the explicit + // reverse reference loop. Find the one with the foreignKey field. + const withForeignKey = result.referenceFields.filter( + (f) => f.foreignKey === "customer", + ); + expect(withForeignKey).toHaveLength(1); + expect(withForeignKey[0]).toEqual({ + name: "orders", + tableName: "orders", + isArray: true, + foreignKey: "customer", + schema: expect.objectContaining({ + type: "reverseReference", + tableName: "orders", + foreignKey: "customer", + }), + }); + }); + + it("should not load referenced CSV files for reverse references", () => { + const csv = [ + "id,name", + "string,string", + "# nonexistent := ~nonexistent(some_key)", + "1,Alice", + ].join("\n"); + + expect(() => + parseCsv(csv, { + emitTypes: false, + resolveReferences: false, + }), + ).not.toThrow(); + }); +}); + +describe("csvToModule - reverse reference output", () => { + it("should emit reverse lookup code for reverse references", () => { + const csv = [ + "id,name", + "string,string", + "# orders := ~orders(customer)", + "1,Alice", + ].join("\n"); + + const result = csvToModule(csv, { emitTypes: false }); + + expect(result.js).toContain("import _orders from './orders.csv'"); + expect(result.js).toContain("_ordersBy_customer"); + expect(result.js).toContain("orders:"); + expect(result.js).toContain("_ordersBy_customer.get(String(row.id))"); + }); + + it("should emit null fallback for optional reverse references", () => { + const csv = [ + "id,name", + "string,string", + "# orders := ~orders(customer)?", + "1,Alice", + ].join("\n"); + + const result = csvToModule(csv, { emitTypes: false }); + + expect(result.js).toContain( + "_ordersBy_customer.get(String(row.id)) || null", + ); + }); + + it("should emit empty array fallback for non-optional reverse references", () => { + const csv = [ + "id,name", + "string,string", + "# orders := ~orders(customer)", + "1,Alice", + ].join("\n"); + + const result = csvToModule(csv, { emitTypes: false }); + + expect(result.js).toContain("_ordersBy_customer.get(String(row.id)) || []"); + }); + + it("should handle self-referencing reverse reference without self-import", () => { + const csv = [ + "id,name", + "string,string", + "# children := ~self_ref(parent)", + "1,Root", + "2,Child", + ].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_refBy_parent"); + expect(result.js).toContain("for (const r of _raw)"); + expect(result.js).toContain("children:"); + }); + + it("should generate correct type definition for reverse references", () => { + const csv = [ + "id,name", + "string,string", + "# orders := ~orders(customer)", + "1,Alice", + ].join("\n"); + + const result = csvToModule(csv, { + emitTypes: true, + resourceName: "users", + currentFilePath: path.join(fixturesDir, "test.csv"), + }); + + expect(result.dts).toContain("readonly orders: Orders[]"); + expect(result.dts).toContain("import type { Orders }"); + }); + + it("should generate nullable type for optional reverse references", () => { + const csv = [ + "id,name", + "string,string", + "# orders := ~orders(customer)?", + "1,Alice", + ].join("\n"); + + const result = csvToModule(csv, { + emitTypes: true, + resourceName: "users", + currentFilePath: path.join(fixturesDir, "test.csv"), + }); + + expect(result.dts).toContain("readonly orders: Orders[] | null"); + }); + + it("should combine forward and reverse references to the same table", () => { + // When the current table references itself via both @ and ~, + // no self-import is needed + const csv = [ + "id,name,manager", + "string,string,@users", + "# reports := ~users(manager)", + "1,Alice,2", + "2,Bob,", + ].join("\n"); + + const result = csvToModule(csv, { + emitTypes: false, + currentFilePath: path.join(fixturesDir, "users.csv"), + }); + + // Forward reference uses lookup built from _raw (self) + expect(result.js).toContain("_usersLookup"); + // Reverse reference uses reverse lookup built from _raw (self) + expect(result.js).toContain("_usersBy_manager"); + // No self-import since the current file IS users.csv + expect(result.js).not.toContain("import _users from './users.csv'"); + }); + + it("should import referenced table when forward and reverse reference a different table", () => { + const csv = [ + "id,name,creator", + "string,string,@users", + "# reviews := ~users(reviewer)", + "1,Doc,1", + ].join("\n"); + + const result = csvToModule(csv, { + emitTypes: false, + currentFilePath: path.join(fixturesDir, "test.csv"), + }); + + // Forward reference uses lookup + expect(result.js).toContain("_usersLookup"); + // Reverse reference uses reverse lookup + expect(result.js).toContain("_usersBy_reviewer"); + // users is a different table, so it should be imported + expect(result.js).toContain("import _users from './users.csv'"); + }); +}); diff --git a/src/csv-loader/loader.ts b/src/csv-loader/loader.ts index a41074e..4bac982 100644 --- a/src/csv-loader/loader.ts +++ b/src/csv-loader/loader.ts @@ -1,31 +1,46 @@ -import { parse } from 'csv-parse/sync'; -import { parseSchema, createValidator, parseValue, schemaToTypeString } from '../index.js'; -import type { Schema, ReferenceSchema } from '../types.js'; -import * as fs from 'fs'; -import * as path from 'path'; +import { parse } from "csv-parse/sync"; +import { + parseSchema, + createValidator, + parseValue, + schemaToTypeString, +} from "../index.js"; +import type { + Schema, + ReferenceSchema, + ReverseReferenceSchema, +} from "../types.js"; +import * as fs from "fs"; +import * as path from "path"; function hasNestedReferences(schema: Schema): boolean { switch (schema.type) { - case 'reference': + case "reference": + case "reverseReference": return true; - case 'tuple': - return schema.elements.some(el => hasNestedReferences(el.schema)); - case 'array': + 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)); + case "union": + return schema.members.some((m) => hasNestedReferences(m)); default: return false; } } function loadReferenceTable( - schema: ReferenceSchema, + schema: ReferenceSchema | ReverseReferenceSchema, refBaseDir: string | undefined, defaultPrimaryKey: string, - currentFilePath: string | undefined -): { lookup: Map>; refTable: Record[] } { - const baseDir = refBaseDir || (currentFilePath ? path.dirname(currentFilePath) : process.cwd()); + 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 @@ -37,12 +52,12 @@ function loadReferenceTable( } else { if (loadingFiles.has(refFilePath)) { throw new Error( - `Circular reference detected: table "${schema.tableName}" (${refFilePath}) is already being loaded` + `Circular reference detected: table "${schema.tableName}" (${refFilePath}) is already being loaded`, ); } loadingFiles.add(refFilePath); try { - const refContent = fs.readFileSync(refFilePath, 'utf-8'); + const refContent = fs.readFileSync(refFilePath, "utf-8"); const refResult = parseCsv(refContent, { currentFilePath: refFilePath, emitTypes: false, @@ -51,7 +66,7 @@ function loadReferenceTable( 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)}` + `Failed to load referenced table "${schema.tableName}" from ${refFilePath}: ${error instanceof Error ? error.message : String(error)}`, ); } finally { loadingFiles.delete(refFilePath); @@ -59,7 +74,7 @@ function loadReferenceTable( } const lookup = new Map>(); - refTable.forEach(row => { + refTable.forEach((row) => { const pkValue = row[defaultPrimaryKey]; if (pkValue !== undefined) { lookup.set(String(pkValue), row); @@ -72,7 +87,7 @@ function loadReferenceTable( function resolveReferenceId( id: string, lookup: Map>, - tableName: string + tableName: string, ): Record { const obj = lookup.get(id); if (!obj) { @@ -81,9 +96,12 @@ function resolveReferenceId( return obj; } -function parseReferenceIds(schema: ReferenceSchema, valueString: string): unknown { +function parseReferenceIds( + schema: ReferenceSchema, + valueString: string, +): unknown { const trimmed = valueString.trim(); - if (schema.isOptional && trimmed === '') { + if (schema.isOptional && trimmed === "") { return null; } const valueParser = new ReferenceValueParser(trimmed); @@ -96,32 +114,35 @@ function parseReferenceIds(schema: ReferenceSchema, valueString: string): unknow function parseValueWithReferenceIds( valueString: string, - schema: Schema + schema: Schema, ): unknown { if (!hasNestedReferences(schema)) { return parseValue(schema, valueString); } switch (schema.type) { - case 'reference': + case "reference": return parseReferenceIds(schema, valueString); - case 'tuple': { + case "reverseReference": + // Reverse references don't store IDs; they're derived at resolution time + return null; + 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] + : parsed[i], ); } - case 'array': { + case "array": { const parsed = parseValue(schema, valueString) as unknown[]; - return parsed.map(item => + return parsed.map((item) => hasNestedReferences(schema.element) ? extractNestedReferenceIds(item, schema.element) - : item + : item, ); } - case 'union': { + case "union": { for (const member of schema.members) { if (hasNestedReferences(member)) { try { @@ -139,30 +160,33 @@ function parseValueWithReferenceIds( function extractNestedReferenceIds(value: unknown, schema: Schema): unknown { switch (schema.type) { - case 'reference': + case "reference": if (value === null || value === undefined) return value; if (schema.isArray) { const ids = Array.isArray(value) ? value : [value]; - return ids.map(id => String(id)); + return ids.map((id) => String(id)); } return String(value); - case 'tuple': { + case "reverseReference": + // Reverse references don't store IDs; return null placeholder + return null; + case "tuple": { if (!Array.isArray(value)) return value; return schema.elements.map((el, i) => hasNestedReferences(el.schema) ? extractNestedReferenceIds(value[i], el.schema) - : value[i] + : value[i], ); } - case 'array': { + case "array": { if (!Array.isArray(value)) return value; - return value.map(item => + return value.map((item) => hasNestedReferences(schema.element) ? extractNestedReferenceIds(item, schema.element) - : item + : item, ); } - case 'union': { + case "union": { for (const member of schema.members) { if (hasNestedReferences(member)) { try { @@ -177,21 +201,38 @@ function extractNestedReferenceIds(value: unknown, schema: Schema): unknown { } } -function collectReferenceFields(schema: Schema, name: string): ReferenceFieldInfo[] { +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 }); + case "reference": + fields.push({ + name, + tableName: schema.tableName, + isArray: schema.isArray, + schema, + }); break; - case 'tuple': + case "reverseReference": + fields.push({ + name, + tableName: schema.tableName, + isArray: true, + foreignKey: schema.foreignKey, + schema, + }); + break; + case "tuple": for (const el of schema.elements) { fields.push(...collectReferenceFields(el.schema, name)); } break; - case 'array': + case "array": fields.push(...collectReferenceFields(schema.element, name)); break; - case 'union': + case "union": for (const member of schema.members) { fields.push(...collectReferenceFields(member, name)); } @@ -205,40 +246,83 @@ function parseValueWithReferences( schema: Schema, refBaseDir: string | undefined, defaultPrimaryKey: string, - currentFilePath: string | undefined + currentFilePath: string | undefined, + currentRowPk?: unknown, ): unknown { if (!hasNestedReferences(schema)) { return parseValue(schema, valueString); } switch (schema.type) { - case 'reference': - return parseReferenceValue(schema, valueString, refBaseDir, defaultPrimaryKey, currentFilePath); - case 'tuple': { + case "reference": + return parseReferenceValue( + schema, + valueString, + refBaseDir, + defaultPrimaryKey, + currentFilePath, + ); + case "reverseReference": { + if (currentRowPk === undefined) return []; + return resolveReverseReference( + schema, + currentRowPk, + 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) + resolveNestedReferences( + parsed[i], + el.schema, + refBaseDir, + defaultPrimaryKey, + currentFilePath, + currentRowPk, + ), ); } - case 'array': { + case "array": { const parsed = parseValue(schema, valueString) as unknown[]; - return parsed.map(item => - resolveNestedReferences(item, schema.element, refBaseDir, defaultPrimaryKey, currentFilePath) + return parsed.map((item) => + resolveNestedReferences( + item, + schema.element, + refBaseDir, + defaultPrimaryKey, + currentFilePath, + currentRowPk, + ), ); } - case 'union': { + 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); + return resolveNestedReferences( + parsed, + member, + refBaseDir, + defaultPrimaryKey, + currentFilePath, + currentRowPk, + ); } 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))) { + 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 { @@ -254,41 +338,98 @@ function parseValueWithReferences( } } +function resolveReverseReference( + schema: ReverseReferenceSchema, + pkValue: unknown, + refBaseDir: string | undefined, + defaultPrimaryKey: string, + currentFilePath: string | undefined, +): Record[] { + const { refTable } = loadReferenceTable( + schema, + refBaseDir, + defaultPrimaryKey, + currentFilePath, + ); + const pkStr = String(pkValue); + return refTable.filter((row) => String(row[schema.foreignKey]) === pkStr); +} + function resolveNestedReferences( value: unknown, schema: Schema, refBaseDir: string | undefined, defaultPrimaryKey: string, - currentFilePath: string | undefined + currentFilePath: string | undefined, + currentRowPk?: unknown, ): unknown { switch (schema.type) { - case 'reference': { + case "reference": { if (value === null || value === undefined) return value; - const { lookup } = loadReferenceTable(schema, refBaseDir, defaultPrimaryKey, currentFilePath); + 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 ids.map((id) => + resolveReferenceId(String(id), lookup, schema.tableName), + ); } return resolveReferenceId(String(value), lookup, schema.tableName); } - case 'tuple': { + case "reverseReference": { + if (currentRowPk === undefined) return []; + const results = resolveReverseReference( + schema, + currentRowPk, + refBaseDir, + defaultPrimaryKey, + currentFilePath, + ); + return results; + } + case "tuple": { if (!Array.isArray(value)) return value; return schema.elements.map((el, i) => - resolveNestedReferences(value[i], el.schema, refBaseDir, defaultPrimaryKey, currentFilePath) + resolveNestedReferences( + value[i], + el.schema, + refBaseDir, + defaultPrimaryKey, + currentFilePath, + currentRowPk, + ), ); } - case 'array': { + case "array": { if (!Array.isArray(value)) return value; - return value.map(item => - resolveNestedReferences(item, schema.element, refBaseDir, defaultPrimaryKey, currentFilePath) + return value.map((item) => + resolveNestedReferences( + item, + schema.element, + refBaseDir, + defaultPrimaryKey, + currentFilePath, + currentRowPk, + ), ); } - case 'union': { + case "union": { const errors: Error[] = []; for (const member of schema.members) { if (hasNestedReferences(member)) { try { - return resolveNestedReferences(value, member, refBaseDir, defaultPrimaryKey, currentFilePath); + return resolveNestedReferences( + value, + member, + refBaseDir, + defaultPrimaryKey, + currentFilePath, + currentRowPk, + ); } catch (e) { errors.push(e instanceof Error ? e : new Error(String(e))); } @@ -340,6 +481,8 @@ export interface ReferenceFieldInfo { isArray: boolean; /** The schema of this field (for nested references) */ schema: Schema; + /** For reverse references: the foreign key field name in the referenced table */ + foreignKey?: string; } export interface CsvParseResult { @@ -353,6 +496,8 @@ export interface CsvParseResult { references: Set; /** Reference field metadata (populated when resolveReferences is false) */ referenceFields: ReferenceFieldInfo[]; + /** Reverse reference declarations parsed from comment lines */ + reverseReferences: ReverseReferenceDeclaration[]; } interface PropertyConfig { @@ -366,10 +511,65 @@ interface PropertyConfig { referenceTableName?: string; /** Whether it's an array reference */ referenceIsArray?: boolean; + /** Whether this is a reverse reference (one-to-many) */ + isReverseReference?: boolean; + /** Foreign key field name for reverse references */ + reverseReferenceForeignKey?: string; +} + +/** Parsed reverse reference declaration from a comment line */ +export interface ReverseReferenceDeclaration { + /** Field name in the current table */ + fieldName: string; + /** Referenced table name */ + tableName: string; + /** Foreign key field name in the referenced table */ + foreignKey: string; + /** Whether it's optional */ + isOptional: boolean; + /** The parsed schema */ + schema: ReverseReferenceSchema; +} + +/** + * Parse a reverse reference declaration from a comment line. + * Format: # fieldName := ~tableName(foreignKey) + * Returns null if the line is not a reverse reference declaration. + */ +function parseReverseReferenceDeclaration( + line: string, +): ReverseReferenceDeclaration | null { + const trimmed = line.trim(); + // Must start with # (comment) + if (!trimmed.startsWith("#")) return null; + + const content = trimmed.slice(1).trim(); + + // Match pattern: fieldName := ~tableName(foreignKey) + const match = content.match(/^(\w+)\s*:=\s*~(\w+)\((\w+)\)(\?)?$/); + if (!match) return null; + + const [, fieldName, tableName, foreignKey, optionalMark] = match; + const isOptional = optionalMark === "?"; + + const schema: ReverseReferenceSchema = { + type: "reverseReference", + tableName, + foreignKey, + isOptional, + }; + + return { + fieldName, + tableName, + foreignKey, + isOptional, + schema, + }; } /** 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(); @@ -383,22 +583,27 @@ function parseReferenceValue( valueString: string, refBaseDir: string | undefined, defaultPrimaryKey: string, - currentFilePath: string | undefined + currentFilePath: string | undefined, ): unknown { const trimmed = valueString.trim(); - if (schema.isOptional && trimmed === '') { + if (schema.isOptional && trimmed === "") { return null; } - const { lookup } = loadReferenceTable(schema, refBaseDir, defaultPrimaryKey, currentFilePath); - + const { lookup } = loadReferenceTable( + schema, + refBaseDir, + defaultPrimaryKey, + currentFilePath, + ); + const valueParser = new ReferenceValueParser(trimmed); const ids = valueParser.parseIds(schema.isArray); - + if (schema.isArray) { - return ids.map(id => resolveReferenceId(id, lookup, schema.tableName)); + return ids.map((id) => resolveReferenceId(id, lookup, schema.tableName)); } - + return resolveReferenceId(ids[0], lookup, schema.tableName); } @@ -414,11 +619,11 @@ class ReferenceValueParser { } private peek(): string { - return this.input[this.pos] || ''; + return this.input[this.pos] || ""; } private consume(): string { - return this.input[this.pos++] || ''; + return this.input[this.pos++] || ""; } private skipWhitespace(): void { @@ -440,13 +645,13 @@ class ReferenceValueParser { if (isArray) { // Parse array format: [id1; id2; id3] - if (this.peek() === '[') { + if (this.peek() === "[") { this.consume(); } this.skipWhitespace(); - if (this.peek() === ']') { + if (this.peek() === "]") { this.consume(); return []; } @@ -454,8 +659,12 @@ class ReferenceValueParser { const ids: string[] = []; while (true) { this.skipWhitespace(); - let id = ''; - while (this.pos < this.input.length && this.peek() !== ';' && this.peek() !== ']') { + let id = ""; + while ( + this.pos < this.input.length && + this.peek() !== ";" && + this.peek() !== "]" + ) { id += this.consume(); } const trimmedId = id.trim(); @@ -464,23 +673,23 @@ class ReferenceValueParser { } this.skipWhitespace(); - if (!this.consumeStr(';')) { + if (!this.consumeStr(";")) { break; } } this.skipWhitespace(); - if (this.peek() === ']') { + if (this.peek() === "]") { this.consume(); } return ids; } else { // Parse single ID - let id = ''; + let id = ""; while (this.pos < this.input.length) { const char = this.peek(); - if (char === ';' || char === ']' || char === ',') { + if (char === ";" || char === "]" || char === ",") { break; } id += this.consume(); @@ -497,21 +706,21 @@ function generateTypeDefinition( resourceName: string, propertyConfigs: PropertyConfig[], references: Set, - currentFilePath?: string + currentFilePath?: 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 const imports: string[] = []; const resourceNames = new Map(); - - references.forEach(tableName => { + + references.forEach((tableName) => { if (tableName === currentTableName) { resourceNames.set(tableName, singularType); return; @@ -519,7 +728,7 @@ function generateTypeDefinition( // Convert table name to type name by capitalizing const typeBase = tableName.charAt(0).toUpperCase() + tableName.slice(1); resourceNames.set(tableName, typeBase); - + // Generate import path based on current file path let importPath: string; if (currentFilePath) { @@ -529,16 +738,20 @@ function generateTypeDefinition( } imports.push(`import type { ${typeBase} } from '${importPath}';`); }); - - const importSection = imports.length > 0 ? imports.join('\n') + '\n\n' : ''; - - const properties = propertyConfigs - .map((config) => ` readonly ${config.name}: ${schemaToTypeString(config.schema, resourceNames)};`) - .join('\n'); - let exportAlias = ''; + const importSection = imports.length > 0 ? imports.join("\n") + "\n\n" : ""; + + const properties = propertyConfigs + .map( + (config) => + ` readonly ${config.name}: ${schemaToTypeString(config.schema, resourceNames)};`, + ) + .join("\n"); + + let exportAlias = ""; if (resourceName) { - const singularType = resourceName.charAt(0).toUpperCase() + resourceName.slice(1); + const singularType = + resourceName.charAt(0).toUpperCase() + resourceName.slice(1); exportAlias = `\nexport type ${singularType} = ${typeName}[number];`; } @@ -562,31 +775,32 @@ export default getData; */ export function parseCsv( content: string, - options: CsvLoaderOptions & { resourceName?: string } = {} + options: CsvLoaderOptions & { resourceName?: string } = {}, ): CsvParseResult { - const delimiter = options.delimiter ?? ','; + const delimiter = options.delimiter ?? ","; const quote = options.quote ?? '"'; - const escape = options.escape ?? '\\'; + const escape = options.escape ?? "\\"; const bom = options.bom ?? true; - const comment = options.comment === false ? undefined : (options.comment ?? '#'); + const comment = + options.comment === false ? undefined : (options.comment ?? "#"); const trim = options.trim ?? true; const emitTypes = options.emitTypes ?? true; const refBaseDir = options.refBaseDir; - const defaultPrimaryKey = options.defaultPrimaryKey ?? 'id'; + const defaultPrimaryKey = options.defaultPrimaryKey ?? "id"; const records = parse(content, { delimiter, quote, escape, bom, - comment, + comment: undefined, // Don't let csv-parse skip comments; we need to parse them for reverse references trim, skip_empty_lines: true, relax_column_count: true, }); if (records.length < 2) { - throw new Error('CSV must have at least 2 rows: headers and schemas'); + throw new Error("CSV must have at least 2 rows: headers and schemas"); } const headers = records[0]; @@ -594,90 +808,158 @@ export function parseCsv( if (headers.length !== schemas.length) { throw new Error( - `Header count (${headers.length}) does not match schema count (${schemas.length})` + `Header count (${headers.length}) does not match schema count (${schemas.length})`, ); } + // Parse reverse reference declarations from comment lines between schema row and data rows + const reverseReferences: ReverseReferenceDeclaration[] = []; + const dataRows: string[][] = []; + for (let i = 2; i < records.length; i++) { + const row = records[i]; + // Check if this is a single-column row starting with # (comment with reverse ref declaration) + const firstCell = (row[0] ?? "").trim(); + if (firstCell.startsWith("#")) { + const decl = parseReverseReferenceDeclaration(firstCell); + if (decl) { + reverseReferences.push(decl); + } + // Skip comment lines (whether or not they're reverse ref declarations) + continue; + } + dataRows.push(row); + } + + // Also check schema row cells for comment-prefixed reverse reference declarations + // (in case they appear as schema cells rather than separate rows) + for (let col = 0; col < schemas.length; col++) { + const cell = (schemas[col] ?? "").trim(); + if (cell.startsWith("#")) { + const decl = parseReverseReferenceDeclaration(cell); + if (decl) { + reverseReferences.push(decl); + } + } + } + const resolveReferences = options.resolveReferences ?? true; - const propertyConfigs: PropertyConfig[] = headers.map((header: string, index: number) => { - const schemaString = schemas[index]; - const schema = parseSchema(schemaString); - + const propertyConfigs: PropertyConfig[] = headers.map( + (header: string, index: number) => { + const schemaString = schemas[index]; + const schema = parseSchema(schemaString); + + const config: PropertyConfig = { + name: header, + schema, + validator: createValidator(schema), + parser: (valueString: string) => parseValue(schema, valueString), + }; + + if (schema.type === "reference") { + config.isReference = true; + config.referenceTableName = schema.tableName; + config.referenceIsArray = schema.isArray; + if (resolveReferences) { + config.parser = (valueString: string) => { + return parseReferenceValue( + schema, + valueString, + refBaseDir, + defaultPrimaryKey, + options.currentFilePath, + ); + }; + } else { + config.parser = (valueString: string) => { + return parseReferenceIds(schema, valueString); + }; + } + } else if (hasNestedReferences(schema)) { + config.isReference = true; + if (resolveReferences) { + config.parser = (valueString: string) => { + return parseValueWithReferences( + valueString, + schema, + refBaseDir, + defaultPrimaryKey, + options.currentFilePath, + ); + }; + } else { + config.parser = (valueString: string) => { + return parseValueWithReferenceIds(valueString, schema); + }; + } + } + + return config; + }, + ); + + // Add reverse reference property configs + for (const decl of reverseReferences) { const config: PropertyConfig = { - name: header, - schema, - validator: createValidator(schema), - parser: (valueString: string) => parseValue(schema, valueString), + name: decl.fieldName, + schema: decl.schema, + validator: createValidator(decl.schema), + parser: (_valueString: string) => { + // Reverse references are resolved after all rows are parsed + return null; + }, + isReference: true, + isReverseReference: true, + referenceTableName: decl.tableName, + referenceIsArray: true, + reverseReferenceForeignKey: decl.foreignKey, }; - - if (schema.type === 'reference') { - config.isReference = true; - config.referenceTableName = schema.tableName; - config.referenceIsArray = schema.isArray; - if (resolveReferences) { - config.parser = (valueString: string) => { - return parseReferenceValue(schema, valueString, refBaseDir, defaultPrimaryKey, options.currentFilePath); - }; - } else { - config.parser = (valueString: string) => { - return parseReferenceIds(schema, valueString); - }; - } - } else if (hasNestedReferences(schema)) { - config.isReference = true; - if (resolveReferences) { - config.parser = (valueString: string) => { - return parseValueWithReferences(valueString, schema, refBaseDir, defaultPrimaryKey, options.currentFilePath); - }; - } else { - config.parser = (valueString: string) => { - return parseValueWithReferenceIds(valueString, schema); - }; - } - } - - return config; - }); + propertyConfigs.push(config); + } // Collect all referenced tables (including nested references in tuples/arrays) const references = new Set(); function collectReferences(schema: Schema): void { - if (schema.type === 'reference') { + if (schema.type === "reference") { references.add(schema.tableName); - } else if (schema.type === 'tuple') { - schema.elements.forEach(el => collectReferences(el.schema)); - } else if (schema.type === 'array') { + } else if (schema.type === "reverseReference") { + references.add(schema.tableName); + } else if (schema.type === "tuple") { + schema.elements.forEach((el) => collectReferences(el.schema)); + } else if (schema.type === "array") { collectReferences(schema.element); - } else if (schema.type === 'union') { - schema.members.forEach(m => collectReferences(m)); + } else if (schema.type === "union") { + schema.members.forEach((m) => collectReferences(m)); } } - propertyConfigs.forEach(config => { + propertyConfigs.forEach((config) => { if (config.isReference && config.referenceTableName) { references.add(config.referenceTableName); } collectReferences(config.schema); }); - const dataRows = records.slice(2); const objects = dataRows.map((row: string[], rowIndex: number) => { const obj: Record = {}; propertyConfigs.forEach((config, colIndex) => { - const rawValue = row[colIndex] ?? ''; + // Skip reverse reference columns — they don't have CSV cell data + if (config.isReverseReference) { + return; + } + const rawValue = row[colIndex] ?? ""; try { const parsed = config.parser(rawValue); // Skip validation for reference fields (validation happens during reference resolution) if (!config.isReference && !config.validator(parsed)) { throw new Error( - `Validation failed for property "${config.name}" at row ${rowIndex + 3}: ${rawValue}` + `Validation failed for property "${config.name}" at row ${rowIndex + 3}: ${rawValue}`, ); } obj[config.name] = parsed; } catch (error) { if (error instanceof Error) { throw new Error( - `Failed to parse property "${config.name}" at row ${rowIndex + 3}, column ${colIndex + 1}: ${error.message}` + `Failed to parse property "${config.name}" at row ${rowIndex + 3}, column ${colIndex + 1}: ${error.message}`, ); } throw error; @@ -686,13 +968,40 @@ export function parseCsv( return obj; }); + // Resolve reverse references after all rows are parsed + if (resolveReferences) { + for (const decl of reverseReferences) { + for (const obj of objects) { + const pkValue = obj[defaultPrimaryKey]; + if (pkValue !== undefined) { + const resolved = resolveReverseReference( + decl.schema, + pkValue, + refBaseDir, + defaultPrimaryKey, + options.currentFilePath, + ); + obj[decl.fieldName] = + decl.isOptional && resolved.length === 0 ? null : resolved; + } else { + obj[decl.fieldName] = decl.isOptional ? null : []; + } + } + } + } + const referenceFields: ReferenceFieldInfo[] = []; if (!resolveReferences) { for (const config of propertyConfigs) { if (hasNestedReferences(config.schema)) { - referenceFields.push(...collectReferenceFields(config.schema, config.name)); + referenceFields.push( + ...collectReferenceFields(config.schema, config.name), + ); } } + // Reverse reference fields are already included by collectReferenceFields + // above (which handles the reverseReference schema type), so no additional + // loop is needed here. } const result: CsvParseResult = { @@ -700,14 +1009,15 @@ export function parseCsv( propertyConfigs, references, referenceFields, + reverseReferences, }; if (emitTypes) { result.typeDefinition = generateTypeDefinition( - options.resourceName || '', + options.resourceName || "", propertyConfigs, references, - options.currentFilePath + options.currentFilePath, ); } @@ -722,10 +1032,11 @@ function generateSchemaResolutionCode( schema: Schema, valueExpr: string, lookupVar: (tableName: string) => string, - pkField: string + pkField: string, + reverseLookupVar?: (tableName: string, foreignKey: string) => string, ): string { switch (schema.type) { - case 'reference': { + case "reference": { const lookup = lookupVar(schema.tableName); if (schema.isOptional) { if (schema.isArray) { @@ -738,28 +1049,59 @@ function generateSchemaResolutionCode( } return `${lookup}.get(String(${valueExpr}))`; } - case 'tuple': { + case "reverseReference": { + if (!reverseLookupVar) return valueExpr; + const reverseLookup = reverseLookupVar( + schema.tableName, + schema.foreignKey, + ); + if (schema.isOptional) { + return `(${reverseLookup}.get(String(row.${pkField})) || null)`; + } + return `(${reverseLookup}.get(String(row.${pkField})) || [])`; + } + case "tuple": { const elementResolvers = schema.elements.map((el, i) => { if (hasNestedReferences(el.schema)) { - return generateSchemaResolutionCode(el.schema, `${valueExpr}[${i}]`, lookupVar, pkField); + return generateSchemaResolutionCode( + el.schema, + `${valueExpr}[${i}]`, + lookupVar, + pkField, + reverseLookupVar, + ); } return `${valueExpr}[${i}]`; }); - return `[${elementResolvers.join(', ')}]`; + return `[${elementResolvers.join(", ")}]`; } - case 'array': { + case "array": { if (hasNestedReferences(schema.element)) { - const itemResolve = generateSchemaResolutionCode(schema.element, 'item', lookupVar, pkField); + const itemResolve = generateSchemaResolutionCode( + schema.element, + "item", + lookupVar, + pkField, + reverseLookupVar, + ); 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)); + 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); + const resolveCode = generateSchemaResolutionCode( + member, + valueExpr, + lookupVar, + pkField, + reverseLookupVar, + ); resolveParts.push(resolveCode); } if (nonRefMembers.length > 0) { @@ -767,7 +1109,7 @@ function generateSchemaResolutionCode( } if (resolveParts.length === 0) return valueExpr; if (resolveParts.length === 1) return resolveParts[0]; - return `(${resolveParts.join(' ?? ')})`; + return `(${resolveParts.join(" ?? ")})`; } default: return valueExpr; @@ -781,49 +1123,113 @@ function generateSchemaResolutionCode( */ export function csvToModule( content: string, - options: CsvLoaderOptions & { resourceName?: string } = {} + options: CsvLoaderOptions & { resourceName?: string } = {}, ): { js: string; dts?: string } { const result = parseCsv(content, { ...options, resolveReferences: false }); - const hasRefs = result.referenceFields.length > 0; - const defaultPrimaryKey = options.defaultPrimaryKey ?? 'id'; + const hasRefs = + result.referenceFields.length > 0 || result.reverseReferences.length > 0; + const defaultPrimaryKey = options.defaultPrimaryKey ?? "id"; const imports: string[] = []; const lookupInits: string[] = []; const lookupVarMap = new Map(); + // Reverse lookup maps: grouped by (tableName, foreignKey) + const reverseLookupInits: string[] = []; + const reverseLookupVarMap = new Map(); + const currentTableName = options.currentFilePath - ? path.basename(options.currentFilePath, path.extname(options.currentFilePath)) + ? path.basename( + options.currentFilePath, + path.extname(options.currentFilePath), + ) : undefined; - const uniqueTables = new Set(result.referenceFields.map(f => f.tableName)); - uniqueTables.forEach(tableName => { + // Build forward lookup maps for referenced tables + const uniqueTables = new Set(result.referenceFields.map((f) => f.tableName)); + // Also include tables from reverse references + for (const decl of result.reverseReferences) { + uniqueTables.add(decl.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]));` + `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} = new Map(${varName}().map(p => [String(p.${defaultPrimaryKey}), p]));`, ); } }); + // Build reverse lookup maps for reverse references + for (const decl of result.reverseReferences) { + const key = `${decl.tableName}:${decl.foreignKey}`; + if (reverseLookupVarMap.has(key)) continue; + + const revLookupVar = `_${decl.tableName}By_${decl.foreignKey}`; + reverseLookupVarMap.set(key, revLookupVar); + + if (decl.tableName === currentTableName) { + reverseLookupInits.push( + `const ${revLookupVar} = new Map();`, + `for (const r of _raw) {`, + ` const k = String(r.${decl.foreignKey});`, + ` if (!${revLookupVar}.has(k)) ${revLookupVar}.set(k, []);`, + ` ${revLookupVar}.get(k).push(r);`, + `}`, + ); + } else { + const varName = `_${decl.tableName}`; + reverseLookupInits.push( + `const ${revLookupVar} = new Map();`, + `for (const r of ${varName}()) {`, + ` const k = String(r.${decl.foreignKey});`, + ` if (!${revLookupVar}.has(k)) ${revLookupVar}.set(k, []);`, + ` ${revLookupVar}.get(k).push(r);`, + `}`, + ); + } + } + const lookupVar = (tableName: string) => lookupVarMap.get(tableName)!; + const reverseLookupVar = (tableName: string, foreignKey: string) => + reverseLookupVarMap.get(`${tableName}:${foreignKey}`)!; const rowResolvers: string[] = []; for (const config of result.propertyConfigs) { - if (hasNestedReferences(config.schema)) { + if (config.isReverseReference) { + // Reverse reference resolution + const decl = result.reverseReferences.find( + (d) => d.fieldName === config.name, + ); + if (decl) { + const revLookup = reverseLookupVar(decl.tableName, decl.foreignKey); + if (decl.isOptional) { + rowResolvers.push( + ` ${config.name}: (${revLookup}.get(String(row.${defaultPrimaryKey})) || null),`, + ); + } else { + rowResolvers.push( + ` ${config.name}: (${revLookup}.get(String(row.${defaultPrimaryKey})) || []),`, + ); + } + } + } else if (hasNestedReferences(config.schema)) { const resolveCode = generateSchemaResolutionCode( config.schema, `row.${config.name}`, lookupVar, - defaultPrimaryKey + defaultPrimaryKey, + reverseLookupVar, ); rowResolvers.push(` ${config.name}: ${resolveCode},`); } @@ -833,24 +1239,27 @@ export function csvToModule( 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'); + "", + "let _resolved = null;", + "", + "export default function getData() {", + " if (_resolved) return _resolved;", + " _resolved = _raw;", + ...lookupInits.map((l) => ` ${l}`), + ...reverseLookupInits.map((l) => ` ${l}`), + ...(rowResolvers.length > 0 + ? [ + " _resolved = _raw.map(row => ({", + " ...row,", + ...rowResolvers, + " }));", + ] + : []), + " return _resolved;", + "}", + ].join("\n"); return { js, diff --git a/src/index.test.ts b/src/index.test.ts index 1ea5c00..b76f813 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,545 +1,792 @@ -import { describe, it, expect } from 'vitest'; -import { defineSchema, parseSchema, parseValue, createValidator, ParseError } from './index'; -import type { Schema, StringLiteralSchema, UnionSchema } from './types'; +import { describe, it, expect } from "vitest"; +import { + defineSchema, + parseSchema, + parseValue, + createValidator, + ParseError, +} from "./index"; +import type { Schema, StringLiteralSchema, UnionSchema } from "./types"; -describe('defineSchema', () => { - it('should return a ParsedSchema with schema, validator, and parse', () => { - const parsed = defineSchema('string'); - expect(parsed).toHaveProperty('schema'); - expect(parsed).toHaveProperty('validator'); - expect(parsed).toHaveProperty('parse'); +describe("defineSchema", () => { + it("should return a ParsedSchema with schema, validator, and parse", () => { + const parsed = defineSchema("string"); + expect(parsed).toHaveProperty("schema"); + expect(parsed).toHaveProperty("validator"); + expect(parsed).toHaveProperty("parse"); }); }); -describe('Primitive types', () => { - describe('string', () => { - it('should parse and validate string', () => { - const schema = defineSchema('string'); - expect(schema.parse('hello')).toBe('hello'); - expect(schema.validator('hello')).toBe(true); +describe("Primitive types", () => { + describe("string", () => { + it("should parse and validate string", () => { + const schema = defineSchema("string"); + expect(schema.parse("hello")).toBe("hello"); + expect(schema.validator("hello")).toBe(true); expect(schema.validator(42)).toBe(false); }); - it('should reject unknown identifiers', () => { - expect(() => defineSchema('word-smith')).toThrow(ParseError); - expect(() => defineSchema('strign')).toThrow(ParseError); + it("should reject unknown identifiers", () => { + expect(() => defineSchema("word-smith")).toThrow(ParseError); + expect(() => defineSchema("strign")).toThrow(ParseError); }); }); - describe('number', () => { - it('should parse and validate number', () => { - const schema = defineSchema('number'); - expect(schema.parse('42')).toBe(42); - expect(schema.parse('3.14')).toBe(3.14); + describe("number", () => { + it("should parse and validate number", () => { + const schema = defineSchema("number"); + expect(schema.parse("42")).toBe(42); + expect(schema.parse("3.14")).toBe(3.14); expect(schema.validator(42)).toBe(true); - expect(schema.validator('42')).toBe(false); + expect(schema.validator("42")).toBe(false); }); }); - describe('int', () => { - it('should parse and validate int', () => { - const schema = defineSchema('int'); - expect(schema.parse('42')).toBe(42); + describe("int", () => { + it("should parse and validate int", () => { + const schema = defineSchema("int"); + expect(schema.parse("42")).toBe(42); expect(schema.validator(42)).toBe(true); expect(schema.validator(3.14)).toBe(false); }); - it('should reject floats', () => { - const schema = defineSchema('int'); - expect(() => schema.parse('3.14')).toThrow(ParseError); + it("should reject floats", () => { + const schema = defineSchema("int"); + expect(() => schema.parse("3.14")).toThrow(ParseError); }); }); - describe('float', () => { - it('should parse and validate float', () => { - const schema = defineSchema('float'); - expect(schema.parse('3.14')).toBe(3.14); - expect(schema.parse('42')).toBe(42); + describe("float", () => { + it("should parse and validate float", () => { + const schema = defineSchema("float"); + expect(schema.parse("3.14")).toBe(3.14); + expect(schema.parse("42")).toBe(42); expect(schema.validator(3.14)).toBe(true); expect(schema.validator(42)).toBe(true); }); }); - describe('boolean', () => { - it('should parse and validate boolean', () => { - const schema = defineSchema('boolean'); - expect(schema.parse('true')).toBe(true); - expect(schema.parse('false')).toBe(false); + describe("boolean", () => { + it("should parse and validate boolean", () => { + const schema = defineSchema("boolean"); + expect(schema.parse("true")).toBe(true); + expect(schema.parse("false")).toBe(false); expect(schema.validator(true)).toBe(true); - expect(schema.validator('true')).toBe(false); + expect(schema.validator("true")).toBe(false); }); - it('should reject non-boolean values', () => { - const schema = defineSchema('boolean'); - expect(() => schema.parse('yes')).toThrow(ParseError); + it("should reject non-boolean values", () => { + const schema = defineSchema("boolean"); + expect(() => schema.parse("yes")).toThrow(ParseError); }); }); }); -describe('String literals', () => { - it('should parse double-quoted string literal', () => { +describe("String literals", () => { + it("should parse double-quoted string literal", () => { const schema = defineSchema('"hello"'); - expect(schema.parse('"hello"')).toBe('hello'); - expect(schema.validator('hello')).toBe(true); - expect(schema.validator('world')).toBe(false); + expect(schema.parse('"hello"')).toBe("hello"); + expect(schema.validator("hello")).toBe(true); + expect(schema.validator("world")).toBe(false); }); - it('should parse single-quoted string literal', () => { + it("should parse single-quoted string literal", () => { const schema = defineSchema("'world'"); - expect(schema.parse("'world'")).toBe('world'); - expect(schema.validator('world')).toBe(true); - expect(schema.validator('hello')).toBe(false); + expect(schema.parse("'world'")).toBe("world"); + expect(schema.validator("world")).toBe(true); + expect(schema.validator("hello")).toBe(false); }); - it('should reject mismatched string literal values', () => { + it("should reject mismatched string literal values", () => { const schema = defineSchema('"on"'); expect(() => schema.parse('"off"')).toThrow(ParseError); }); - it('should handle string literals with semicolons', () => { + it("should handle string literals with semicolons", () => { const schema = defineSchema('"hello;world"'); - expect(schema.parse('"hello;world"')).toBe('hello;world'); - expect(schema.validator('hello;world')).toBe(true); + expect(schema.parse('"hello;world"')).toBe("hello;world"); + expect(schema.validator("hello;world")).toBe(true); }); - it('should handle escaped quotes in string literals', () => { + it("should handle escaped quotes in string literals", () => { const schema = defineSchema('"hello\\"world"'); expect(schema.parse('"hello\\"world"')).toBe('hello"world'); expect(schema.validator('hello"world')).toBe(true); }); }); -describe('Union types', () => { - it('should parse union of string literals', () => { +describe("Union types", () => { + it("should parse union of string literals", () => { const schema = defineSchema('"on" | "off"'); - expect(schema.parse('"on"')).toBe('on'); - expect(schema.parse('"off"')).toBe('off'); - expect(schema.validator('on')).toBe(true); - expect(schema.validator('off')).toBe(true); - expect(schema.validator('maybe')).toBe(false); + expect(schema.parse('"on"')).toBe("on"); + expect(schema.parse('"off"')).toBe("off"); + expect(schema.validator("on")).toBe(true); + expect(schema.validator("off")).toBe(true); + expect(schema.validator("maybe")).toBe(false); }); - it('should parse union with three members', () => { + it("should parse union with three members", () => { const schema = defineSchema('"pending" | "approved" | "rejected"'); - expect(schema.parse('"approved"')).toBe('approved'); - expect(schema.validator('pending')).toBe(true); - expect(schema.validator('approved')).toBe(true); - expect(schema.validator('rejected')).toBe(true); - expect(schema.validator('unknown')).toBe(false); + expect(schema.parse('"approved"')).toBe("approved"); + expect(schema.validator("pending")).toBe(true); + expect(schema.validator("approved")).toBe(true); + expect(schema.validator("rejected")).toBe(true); + expect(schema.validator("unknown")).toBe(false); }); - it('should support parentheses for grouping', () => { + it("should support parentheses for grouping", () => { const schema = defineSchema('( "active" | "inactive" )'); - expect(schema.parse('"active"')).toBe('active'); - expect(schema.validator('active')).toBe(true); + expect(schema.parse('"active"')).toBe("active"); + expect(schema.validator("active")).toBe(true); }); - it('should reject invalid union values', () => { + it("should reject invalid union values", () => { const schema = defineSchema('"a" | "b"'); expect(() => schema.parse('"c"')).toThrow(ParseError); }); - it('should support mixed type unions', () => { - const schema = defineSchema('string | number'); + it("should support mixed type unions", () => { + const schema = defineSchema("string | number"); // string is tried first, so '42' is parsed as string "42" - expect(schema.parse('hello')).toBe('hello'); - expect(schema.parse('42')).toBe('42'); - expect(schema.validator('hello')).toBe(true); + expect(schema.parse("hello")).toBe("hello"); + expect(schema.parse("42")).toBe("42"); + expect(schema.validator("hello")).toBe(true); expect(schema.validator(42)).toBe(true); expect(schema.validator(true)).toBe(false); }); - it('should support string type and string literal union', () => { + it("should support string type and string literal union", () => { const schema = defineSchema('string | "special"'); // string matches first, so '"special"' is parsed as string '"special"' - expect(schema.parse('normal')).toBe('normal'); - expect(schema.parse('special')).toBe('special'); - expect(schema.validator('normal')).toBe(true); - expect(schema.validator('special')).toBe(true); + expect(schema.parse("normal")).toBe("normal"); + expect(schema.parse("special")).toBe("special"); + expect(schema.validator("normal")).toBe(true); + expect(schema.validator("special")).toBe(true); }); }); -describe('Tuples', () => { - it('should parse and validate tuple', () => { - const schema = defineSchema('[string; number]'); - const value = schema.parse('[hello; 42]'); - expect(value).toEqual(['hello', 42]); - expect(schema.validator(['hello', 42])).toBe(true); - expect(schema.validator(['hello', '42'])).toBe(false); +describe("Tuples", () => { + it("should parse and validate tuple", () => { + const schema = defineSchema("[string; number]"); + const value = schema.parse("[hello; 42]"); + expect(value).toEqual(["hello", 42]); + expect(schema.validator(["hello", 42])).toBe(true); + expect(schema.validator(["hello", "42"])).toBe(false); }); - it('should parse tuple without brackets', () => { - const schema = defineSchema('[string; number]'); - const value = schema.parse('hello; 42'); - expect(value).toEqual(['hello', 42]); + it("should parse tuple without brackets", () => { + const schema = defineSchema("[string; number]"); + const value = schema.parse("hello; 42"); + expect(value).toEqual(["hello", 42]); }); - it('should parse named tuple', () => { - const schema = defineSchema('[x: number; y: number]'); - const value = schema.parse('[x: 10; y: 20]'); + it("should parse named tuple", () => { + const schema = defineSchema("[x: number; y: number]"); + const value = schema.parse("[x: 10; y: 20]"); expect(value).toEqual([10, 20]); }); - it('should parse tuple with union field', () => { - const schema = defineSchema('[name: string; status: "active" | "inactive"]'); + it("should parse tuple with union field", () => { + const schema = defineSchema( + '[name: string; status: "active" | "inactive"]', + ); const value = schema.parse('[myName; "active"]'); - expect(value).toEqual(['myName', 'active']); - expect(schema.validator(['myName', 'active'])).toBe(true); - expect(schema.validator(['myName', 'unknown'])).toBe(false); + expect(value).toEqual(["myName", "active"]); + expect(schema.validator(["myName", "active"])).toBe(true); + expect(schema.validator(["myName", "unknown"])).toBe(false); }); - it('should parse nested tuple', () => { - const schema = defineSchema('[point: [x: number; y: number]]'); - const value = schema.parse('[point: [x: 5; y: 10]]'); + it("should parse nested tuple", () => { + const schema = defineSchema("[point: [x: number; y: number]]"); + const value = schema.parse("[point: [x: 5; y: 10]]"); expect(value).toEqual([[5, 10]]); }); }); -describe('Arrays', () => { - it('should parse and validate array', () => { - const schema = defineSchema('string[]'); - const value = schema.parse('[hello; world; test]'); - expect(value).toEqual(['hello', 'world', 'test']); - expect(schema.validator(['hello', 'world'])).toBe(true); - expect(schema.validator(['hello', 42])).toBe(false); +describe("Arrays", () => { + it("should parse and validate array", () => { + const schema = defineSchema("string[]"); + const value = schema.parse("[hello; world; test]"); + expect(value).toEqual(["hello", "world", "test"]); + expect(schema.validator(["hello", "world"])).toBe(true); + expect(schema.validator(["hello", 42])).toBe(false); }); - it('should parse array without brackets', () => { - const schema = defineSchema('string[]'); - const value = schema.parse('hello; world; test'); - expect(value).toEqual(['hello', 'world', 'test']); + it("should parse array without brackets", () => { + const schema = defineSchema("string[]"); + const value = schema.parse("hello; world; test"); + expect(value).toEqual(["hello", "world", "test"]); }); - it('should parse array of numbers', () => { - const schema = defineSchema('number[]'); - const value = schema.parse('[1; 2; 3; 4]'); + it("should parse array of numbers", () => { + const schema = defineSchema("number[]"); + const value = schema.parse("[1; 2; 3; 4]"); expect(value).toEqual([1, 2, 3, 4]); }); - it('should parse array of ints', () => { - const schema = defineSchema('int[]'); - expect(schema.parse('[1; 2; 3; 4]')).toEqual([1, 2, 3, 4]); - expect(() => schema.parse('[1; 2.5; 3]')).toThrow(ParseError); + it("should parse array of ints", () => { + const schema = defineSchema("int[]"); + expect(schema.parse("[1; 2; 3; 4]")).toEqual([1, 2, 3, 4]); + expect(() => schema.parse("[1; 2.5; 3]")).toThrow(ParseError); }); - it('should parse array of floats', () => { - const schema = defineSchema('float[]'); - expect(schema.parse('[1.5; 2.5; 3.5]')).toEqual([1.5, 2.5, 3.5]); - expect(schema.parse('[1; 2; 3]')).toEqual([1, 2, 3]); + it("should parse array of floats", () => { + const schema = defineSchema("float[]"); + expect(schema.parse("[1.5; 2.5; 3.5]")).toEqual([1.5, 2.5, 3.5]); + expect(schema.parse("[1; 2; 3]")).toEqual([1, 2, 3]); }); - it('should parse array of tuples', () => { - const schema = defineSchema('[string; number][]'); - const value = schema.parse('[[a; 1]; [b; 2]; [c; 3]]'); - expect(value).toEqual([['a', 1], ['b', 2], ['c', 3]]); + it("should parse array of tuples", () => { + const schema = defineSchema("[string; number][]"); + const value = schema.parse("[[a; 1]; [b; 2]; [c; 3]]"); + expect(value).toEqual([ + ["a", 1], + ["b", 2], + ["c", 3], + ]); }); - it('should parse array of tuples without outer brackets', () => { - const schema = defineSchema('[string; number][]'); - const value = schema.parse('[a; 1]; [b; 2]; [c; 3]'); - expect(value).toEqual([['a', 1], ['b', 2], ['c', 3]]); + it("should parse array of tuples without outer brackets", () => { + const schema = defineSchema("[string; number][]"); + const value = schema.parse("[a; 1]; [b; 2]; [c; 3]"); + expect(value).toEqual([ + ["a", 1], + ["b", 2], + ["c", 3], + ]); }); - it('should parse array of unions', () => { + it("should parse array of unions", () => { const schema = defineSchema('("pending" | "approved" | "rejected")[]'); const value = schema.parse('["pending"; "approved"; "rejected"]'); - expect(value).toEqual(['pending', 'approved', 'rejected']); - expect(schema.validator(['pending', 'approved'])).toBe(true); - expect(schema.validator(['pending', 'unknown'])).toBe(false); + expect(value).toEqual(["pending", "approved", "rejected"]); + expect(schema.validator(["pending", "approved"])).toBe(true); + expect(schema.validator(["pending", "unknown"])).toBe(false); }); }); -describe('Escaping', () => { - it('should handle escaped semicolon', () => { - const schema = defineSchema('string'); - expect(schema.parse('hello\\;world')).toBe('hello;world'); +describe("Escaping", () => { + it("should handle escaped semicolon", () => { + const schema = defineSchema("string"); + expect(schema.parse("hello\\;world")).toBe("hello;world"); }); - it('should handle escaped bracket', () => { - const schema = defineSchema('string'); - expect(schema.parse('hello\\[world')).toBe('hello[world'); + it("should handle escaped bracket", () => { + const schema = defineSchema("string"); + expect(schema.parse("hello\\[world")).toBe("hello[world"); }); - it('should handle escaped backslash', () => { - const schema = defineSchema('string'); - expect(schema.parse('hello\\\\world')).toBe('hello\\world'); + it("should handle escaped backslash", () => { + const schema = defineSchema("string"); + expect(schema.parse("hello\\\\world")).toBe("hello\\world"); }); - it('should handle escaped semicolon in tuple', () => { - const schema = defineSchema('[string; string]'); - const value = schema.parse('hello\\;world; test'); - expect(value).toEqual(['hello;world', 'test']); + it("should handle escaped semicolon in tuple", () => { + const schema = defineSchema("[string; string]"); + const value = schema.parse("hello\\;world; test"); + expect(value).toEqual(["hello;world", "test"]); }); }); -describe('parseSchema', () => { - it('should parse string schema', () => { - const schema = parseSchema('string'); - expect(schema).toEqual({ type: 'string' }); +describe("parseSchema", () => { + it("should parse string schema", () => { + const schema = parseSchema("string"); + expect(schema).toEqual({ type: "string" }); }); - it('should parse number schema', () => { - const schema = parseSchema('number'); - expect(schema).toEqual({ type: 'number' }); + it("should parse number schema", () => { + const schema = parseSchema("number"); + expect(schema).toEqual({ type: "number" }); }); - it('should parse string literal schema', () => { + it("should parse string literal schema", () => { const schema = parseSchema('"on"'); - expect(schema).toEqual({ type: 'stringLiteral', value: 'on' }); + expect(schema).toEqual({ type: "stringLiteral", value: "on" }); }); - it('should parse union schema', () => { + it("should parse union schema", () => { const schema = parseSchema('"on" | "off"'); - expect(schema.type).toBe('union'); - if (schema.type === 'union') { + expect(schema.type).toBe("union"); + if (schema.type === "union") { expect(schema.members).toHaveLength(2); - expect(schema.members[0]).toEqual({ type: 'stringLiteral', value: 'on' }); - expect(schema.members[1]).toEqual({ type: 'stringLiteral', value: 'off' }); + expect(schema.members[0]).toEqual({ type: "stringLiteral", value: "on" }); + expect(schema.members[1]).toEqual({ + type: "stringLiteral", + value: "off", + }); } }); }); -describe('Reference schemas (parseSchema)', () => { - it('should parse single reference schema @tablename', () => { - const schema = parseSchema('@users'); - expect(schema).toEqual({ type: 'reference', tableName: 'users', isArray: false, isOptional: false }); +describe("Reference schemas (parseSchema)", () => { + it("should parse single reference schema @tablename", () => { + const schema = parseSchema("@users"); + expect(schema).toEqual({ + type: "reference", + tableName: "users", + isArray: false, + isOptional: false, + }); }); - it('should parse array reference schema @tablename[]', () => { - const schema = parseSchema('@users[]'); - expect(schema).toEqual({ type: 'reference', tableName: 'users', isArray: true, isOptional: false }); + it("should parse array reference schema @tablename[]", () => { + const schema = parseSchema("@users[]"); + expect(schema).toEqual({ + type: "reference", + tableName: "users", + isArray: true, + isOptional: false, + }); }); - it('should parse reference with hyphens in table name', () => { - const schema = parseSchema('@my-table'); - expect(schema).toEqual({ type: 'reference', tableName: 'my-table', isArray: false, isOptional: false }); + it("should parse reference with hyphens in table name", () => { + const schema = parseSchema("@my-table"); + expect(schema).toEqual({ + type: "reference", + tableName: "my-table", + isArray: false, + isOptional: 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, isOptional: 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, + isOptional: false, + }); }); - it('should parse array reference schema @tablename[]', () => { - const schema = parseSchema('@users[]'); - expect(schema).toEqual({ type: 'reference', tableName: 'users', isArray: true, isOptional: false }); + it("should parse array reference schema @tablename[]", () => { + const schema = parseSchema("@users[]"); + expect(schema).toEqual({ + type: "reference", + tableName: "users", + isArray: true, + isOptional: false, + }); }); - it('should parse reference with hyphens in table name', () => { - const schema = parseSchema('@my-table'); - expect(schema).toEqual({ type: 'reference', tableName: 'my-table', isArray: false, isOptional: false }); + it("should parse reference with hyphens in table name", () => { + const schema = parseSchema("@my-table"); + expect(schema).toEqual({ + type: "reference", + tableName: "my-table", + isArray: false, + isOptional: 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, isOptional: 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, + isOptional: false, + }); }); - it('should throw ParseError for @ without table name', () => { - expect(() => parseSchema('@')).toThrow(ParseError); + 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 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') { + 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, isOptional: 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, isOptional: false }); - } - }); - - 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, isOptional: false }, + expect(schema.elements[0].schema).toEqual({ type: "string" }); + expect(schema.elements[1].schema).toEqual({ + type: "reference", + tableName: "users", + isArray: false, + isOptional: 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, isOptional: 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, + isOptional: false, + }); + } + }); + + 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, + isOptional: 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, + isOptional: false, + }); } } }); - it('should parse reference in union @users | string', () => { - const schema = parseSchema('@users | string'); - expect(schema.type).toBe('union'); - if (schema.type === 'union') { + 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, isOptional: false }); - expect(schema.members[1]).toEqual({ type: 'string' }); + expect(schema.members[0]).toEqual({ + type: "reference", + tableName: "users", + isArray: false, + isOptional: 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, isOptional: false }); + 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, + isOptional: false, + }); } }); - it('should parse reference inside parenthesized union (@users | @parts)', () => { - const schema = parseSchema('(@users | @parts)'); - expect(schema.type).toBe('union'); - if (schema.type === 'union') { + 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, isOptional: false }); - expect(schema.members[1]).toEqual({ type: 'reference', tableName: 'parts', isArray: false, isOptional: false }); + expect(schema.members[0]).toEqual({ + type: "reference", + tableName: "users", + isArray: false, + isOptional: false, + }); + expect(schema.members[1]).toEqual({ + type: "reference", + tableName: "parts", + isArray: false, + isOptional: 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, isOptional: false }); - expect(schema.element.members[1]).toEqual({ type: 'reference', tableName: 'parts', isArray: false, isOptional: 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, + isOptional: false, + }); + expect(schema.element.members[1]).toEqual({ + type: "reference", + tableName: "parts", + isArray: false, + isOptional: false, + }); } } }); - it('should parse optional reference schema @tablename?', () => { - const schema = parseSchema('@users?'); - expect(schema).toEqual({ type: 'reference', tableName: 'users', isArray: false, isOptional: true }); + it("should parse optional reference schema @tablename?", () => { + const schema = parseSchema("@users?"); + expect(schema).toEqual({ + type: "reference", + tableName: "users", + isArray: false, + isOptional: true, + }); }); - it('should parse optional array reference schema @tablename[]?', () => { - const schema = parseSchema('@users[]?'); - expect(schema).toEqual({ type: 'reference', tableName: 'users', isArray: true, isOptional: true }); + it("should parse optional array reference schema @tablename[]?", () => { + const schema = parseSchema("@users[]?"); + expect(schema).toEqual({ + type: "reference", + tableName: "users", + isArray: true, + isOptional: true, + }); }); - it('should parse optional reference schema @tablename?', () => { - const schema = parseSchema('@users?'); - expect(schema).toEqual({ type: 'reference', tableName: 'users', isArray: false, isOptional: true }); + it("should parse optional reference schema @tablename?", () => { + const schema = parseSchema("@users?"); + expect(schema).toEqual({ + type: "reference", + tableName: "users", + isArray: false, + isOptional: true, + }); }); - it('should parse optional array reference schema @tablename[]?', () => { - const schema = parseSchema('@users[]?'); - expect(schema).toEqual({ type: 'reference', tableName: 'users', isArray: true, isOptional: true }); + it("should parse optional array reference schema @tablename[]?", () => { + const schema = parseSchema("@users[]?"); + expect(schema).toEqual({ + type: "reference", + tableName: "users", + isArray: true, + isOptional: true, + }); }); - it('should parse optional 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: false, isOptional: true }); + it("should parse optional 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: false, + isOptional: true, + }); } }); }); -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'); +describe("Reverse reference schemas (parseSchema)", () => { + it("should parse reverse reference ~tablename(foreignKey)", () => { + const schema = parseSchema("~orders(user)"); + expect(schema).toEqual({ + type: "reverseReference", + tableName: "orders", + foreignKey: "user", + isOptional: false, + }); }); - 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 optional reverse reference ~tablename(foreignKey)?", () => { + const schema = parseSchema("~orders(user)?"); + expect(schema).toEqual({ + type: "reverseReference", + tableName: "orders", + foreignKey: "user", + isOptional: true, + }); }); - 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 reverse reference with hyphens and underscores", () => { + const schema = parseSchema("~my-orders(my_foreign_key)"); + expect(schema).toEqual({ + type: "reverseReference", + tableName: "my-orders", + foreignKey: "my_foreign_key", + isOptional: false, + }); }); - it('should parse empty array reference', () => { - const schema: import('./types').ReferenceSchema = { type: 'reference', tableName: 'users', isArray: true }; - const result = parseValue(schema, '[]'); + it("should throw ParseError for ~ without table name", () => { + expect(() => parseSchema("~")).toThrow(ParseError); + }); + + it("should throw ParseError for ~ without parentheses", () => { + expect(() => parseSchema("~orders")).toThrow(ParseError); + }); + + it("should throw ParseError for ~ with empty parentheses", () => { + expect(() => parseSchema("~orders()")).toThrow(ParseError); + }); + + it("should throw ParseError for ~ with missing closing parenthesis", () => { + expect(() => parseSchema("~orders(user")).toThrow(ParseError); + }); + + it("should parse reverse reference inside tuple [string; ~orders(user)]", () => { + const schema = parseSchema("[string; ~orders(user)]"); + 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: "reverseReference", + tableName: "orders", + foreignKey: "user", + isOptional: false, + }); + } + }); + + it("should parse reverse reference in union ~orders(user) | string", () => { + const schema = parseSchema("~orders(user) | string"); + expect(schema.type).toBe("union"); + if (schema.type === "union") { + expect(schema.members).toHaveLength(2); + expect(schema.members[0]).toEqual({ + type: "reverseReference", + tableName: "orders", + foreignKey: "user", + isOptional: false, + }); + expect(schema.members[1]).toEqual({ type: "string" }); + } + }); +}); + +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 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 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'); + 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 }; +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("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 }; + 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(["1", "2"])).toBe(true); expect(validator([])).toBe(true); - expect(validator(['1'])).toBe(true); + expect(validator(["1"])).toBe(true); expect(validator([1, 2])).toBe(false); - expect(validator('1')).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 }; + 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); + expect(validator(["1", "2"])).toBe(true); }); }); -describe('Error handling', () => { - it('should throw ParseError for invalid schema', () => { - expect(() => parseSchema('')).toThrow(ParseError); +describe("Error handling", () => { + it("should throw ParseError for invalid schema", () => { + expect(() => parseSchema("")).toThrow(ParseError); }); - it('should throw ParseError for unexpected input', () => { - expect(() => parseSchema('string extra')).toThrow(ParseError); + it("should throw ParseError for unexpected input", () => { + expect(() => parseSchema("string extra")).toThrow(ParseError); }); - it('should throw ParseError for invalid value', () => { - const schema = defineSchema('number'); - expect(() => schema.parse('not-a-number')).toThrow(ParseError); + it("should throw ParseError for invalid value", () => { + const schema = defineSchema("number"); + expect(() => schema.parse("not-a-number")).toThrow(ParseError); }); - it('should throw ParseError for mismatched enum value', () => { + it("should throw ParseError for mismatched enum value", () => { const schema = defineSchema('"on" | "off"'); expect(() => schema.parse('"invalid"')).toThrow(ParseError); }); diff --git a/src/index.ts b/src/index.ts index 616a37f..d2f0bbc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,17 @@ -import { parseSchema } from './parser'; -import { parseValue, createValidator, schemaToTypeString } from './validator'; -import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, ReferenceSchema, StringLiteralSchema, UnionSchema, ParsedSchema } from './types'; -import { ParseError } from './parser'; +import { parseSchema } from "./parser"; +import { parseValue, createValidator, schemaToTypeString } from "./validator"; +import type { + Schema, + PrimitiveSchema, + TupleSchema, + ArraySchema, + ReferenceSchema, + ReverseReferenceSchema, + StringLiteralSchema, + UnionSchema, + ParsedSchema, +} from "./types"; +import { ParseError } from "./parser"; export function defineSchema(schemaString: string): ParsedSchema { const schema = parseSchema(schemaString); @@ -14,5 +24,21 @@ export function defineSchema(schemaString: string): ParsedSchema { }; } -export { parseSchema, parseValue, createValidator, ParseError, schemaToTypeString }; -export type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, ReferenceSchema, StringLiteralSchema, UnionSchema, ParsedSchema }; +export { + parseSchema, + parseValue, + createValidator, + ParseError, + schemaToTypeString, +}; +export type { + Schema, + PrimitiveSchema, + TupleSchema, + ArraySchema, + ReferenceSchema, + ReverseReferenceSchema, + StringLiteralSchema, + UnionSchema, + ParsedSchema, +}; diff --git a/src/parser.ts b/src/parser.ts index e14d4f9..e36ccc2 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,9 +1,24 @@ -import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, NamedSchema, ReferenceSchema, StringLiteralSchema, UnionSchema } from './types'; +import type { + Schema, + PrimitiveSchema, + TupleSchema, + ArraySchema, + NamedSchema, + ReferenceSchema, + ReverseReferenceSchema, + StringLiteralSchema, + UnionSchema, +} from "./types"; export class ParseError extends Error { - constructor(message: string, public position?: number) { - super(position !== undefined ? `${message} at position ${position}` : message); - this.name = 'ParseError'; + constructor( + message: string, + public position?: number, + ) { + super( + position !== undefined ? `${message} at position ${position}` : message, + ); + this.name = "ParseError"; } } @@ -23,11 +38,11 @@ class Parser { } private peek(): string { - return this.input[this.pos] || ''; + return this.input[this.pos] || ""; } private consume(): string { - return this.input[this.pos++] || ''; + return this.input[this.pos++] || ""; } private skipWhitespace(): void { @@ -64,33 +79,33 @@ class Parser { this.skipWhitespace(); // Check for array suffix: type[] - if (this.consumeStr('[')) { + if (this.consumeStr("[")) { this.skipWhitespace(); - if (!this.consumeStr(']')) { - throw new ParseError('Expected ]', this.pos); + if (!this.consumeStr("]")) { + throw new ParseError("Expected ]", this.pos); } - schema = { type: 'array', element: schema }; + schema = { type: "array", element: schema }; this.skipWhitespace(); } // Check for union type (| symbol) - if (this.consumeStr('|')) { + if (this.consumeStr("|")) { const members: Schema[] = [schema]; - + while (true) { this.skipWhitespace(); const member = this.parseSchemaInternal(); members.push(member); this.skipWhitespace(); - - if (!this.consumeStr('|')) { + + if (!this.consumeStr("|")) { break; } } - + return { - type: 'union', - members + type: "union", + members, }; } @@ -101,25 +116,25 @@ class Parser { this.skipWhitespace(); // Check for parentheses (grouping) - if (this.consumeStr('(')) { + if (this.consumeStr("(")) { this.skipWhitespace(); - const schema = this.parseSchema(); // Recursive call for nested unions + const schema = this.parseSchema(); // Recursive call for nested unions this.skipWhitespace(); - - if (!this.consumeStr(')')) { - throw new ParseError('Expected )', this.pos); + + if (!this.consumeStr(")")) { + throw new ParseError("Expected )", this.pos); } - + // Check if there's an array suffix: (union)[] this.skipWhitespace(); - if (this.consumeStr('[')) { + if (this.consumeStr("[")) { this.skipWhitespace(); - if (!this.consumeStr(']')) { - throw new ParseError('Expected ]', this.pos); + if (!this.consumeStr("]")) { + throw new ParseError("Expected ]", this.pos); } - return { type: 'array', element: schema }; + return { type: "array", element: schema }; } - + return schema; } @@ -128,86 +143,91 @@ class Parser { return this.parseStringLiteralSchema(); } + // Check for reverse reference syntax: ~tablename(foreignKey) + if (this.consumeStr("~")) { + return this.parseReverseReferenceSchema(); + } + // Check for reference syntax: @tablename[] - if (this.consumeStr('@')) { + if (this.consumeStr("@")) { return this.parseReferenceSchema(); } - if (this.consumeStr('string')) { - if (this.consumeStr('[')) { + if (this.consumeStr("string")) { + if (this.consumeStr("[")) { this.skipWhitespace(); - if (!this.consumeStr(']')) { - throw new ParseError('Expected ]', this.pos); + if (!this.consumeStr("]")) { + throw new ParseError("Expected ]", this.pos); } - return { type: 'array', element: { type: 'string' } }; + return { type: "array", element: { type: "string" } }; } - return { type: 'string' }; + return { type: "string" }; } - if (this.consumeStr('number')) { - if (this.consumeStr('[')) { + if (this.consumeStr("number")) { + if (this.consumeStr("[")) { this.skipWhitespace(); - if (!this.consumeStr(']')) { - throw new ParseError('Expected ]', this.pos); + if (!this.consumeStr("]")) { + throw new ParseError("Expected ]", this.pos); } - return { type: 'array', element: { type: 'number' } }; + return { type: "array", element: { type: "number" } }; } - return { type: 'number' }; + return { type: "number" }; } - if (this.consumeStr('int')) { - if (this.consumeStr('[')) { + if (this.consumeStr("int")) { + if (this.consumeStr("[")) { this.skipWhitespace(); - if (!this.consumeStr(']')) { - throw new ParseError('Expected ]', this.pos); + if (!this.consumeStr("]")) { + throw new ParseError("Expected ]", this.pos); } - return { type: 'array', element: { type: 'int' } }; + return { type: "array", element: { type: "int" } }; } - return { type: 'int' }; + return { type: "int" }; } - if (this.consumeStr('float')) { - if (this.consumeStr('[')) { + if (this.consumeStr("float")) { + if (this.consumeStr("[")) { this.skipWhitespace(); - if (!this.consumeStr(']')) { - throw new ParseError('Expected ]', this.pos); + if (!this.consumeStr("]")) { + throw new ParseError("Expected ]", this.pos); } - return { type: 'array', element: { type: 'float' } }; + return { type: "array", element: { type: "float" } }; } - return { type: 'float' }; + return { type: "float" }; } - if (this.consumeStr('boolean')) { - if (this.consumeStr('[')) { + if (this.consumeStr("boolean")) { + if (this.consumeStr("[")) { this.skipWhitespace(); - if (!this.consumeStr(']')) { - throw new ParseError('Expected ]', this.pos); + if (!this.consumeStr("]")) { + throw new ParseError("Expected ]", this.pos); } - return { type: 'array', element: { type: 'boolean' } }; + return { type: "array", element: { type: "boolean" } }; } - return { type: 'boolean' }; + return { type: "boolean" }; } - if (this.consumeStr('[')) { + if (this.consumeStr("[")) { const elements: NamedSchema[] = []; this.skipWhitespace(); - if (this.peek() === ']') { + if (this.peek() === "]") { this.consume(); - throw new ParseError('Empty array/tuple not allowed', this.pos); + throw new ParseError("Empty array/tuple not allowed", this.pos); } elements.push(this.parseNamedSchema()); this.skipWhitespace(); - if (this.consumeStr(';')) { + if (this.consumeStr(";")) { const remainingElements: NamedSchema[] = []; while (true) { this.skipWhitespace(); remainingElements.push(this.parseNamedSchema()); this.skipWhitespace(); - if (!this.consumeStr(';')) { + if (!this.consumeStr(";")) { break; } } @@ -216,57 +236,67 @@ class Parser { this.skipWhitespace(); - if (!this.consumeStr(']')) { - throw new ParseError('Expected ]', this.pos); + if (!this.consumeStr("]")) { + throw new ParseError("Expected ]", this.pos); } - if (this.consumeStr('[')) { + if (this.consumeStr("[")) { this.skipWhitespace(); - if (!this.consumeStr(']')) { - throw new ParseError('Expected ]', this.pos); + if (!this.consumeStr("]")) { + throw new ParseError("Expected ]", this.pos); } if (elements.length === 1 && !elements[0].name) { - return { type: 'array', element: elements[0].schema }; + return { type: "array", element: elements[0].schema }; } - return { type: 'array', element: { type: 'tuple', elements } }; + return { type: "array", element: { type: "tuple", elements } }; } if (elements.length === 1 && !elements[0].name) { - return { type: 'array', element: elements[0].schema }; + return { type: "array", element: elements[0].schema }; } - return { type: 'tuple', elements }; + return { type: "tuple", elements }; } - throw new ParseError(`Unknown type: ${this.peek() || 'end of input'}`, this.pos); + throw new ParseError( + `Unknown type: ${this.peek() || "end of input"}`, + this.pos, + ); } private parseStringLiteralSchema(): Schema { const value = this.parseStringLiteral(); return { - type: 'stringLiteral', - value + type: "stringLiteral", + value, }; } private parseStringLiteral(): string { const quote = this.peek(); if (quote !== '"' && quote !== "'") { - throw new ParseError('Expected string literal with quotes', this.pos); + throw new ParseError("Expected string literal with quotes", this.pos); } this.consume(); // Consume opening quote - - let value = ''; + + let value = ""; while (this.pos < this.input.length) { const char = this.peek(); - - if (char === '\\') { + + if (char === "\\") { this.consume(); const nextChar = this.consume(); - if (nextChar === '"' || nextChar === "'" || nextChar === '\\' || - nextChar === '|' || nextChar === ';' || nextChar === '(' || nextChar === ')') { + if ( + nextChar === '"' || + nextChar === "'" || + nextChar === "\\" || + nextChar === "|" || + nextChar === ";" || + nextChar === "(" || + nextChar === ")" + ) { value += nextChar; } else { - value += '\\' + nextChar; + value += "\\" + nextChar; } } else if (char === quote) { this.consume(); // Consume closing quote @@ -275,15 +305,15 @@ class Parser { value += this.consume(); } } - - throw new ParseError('Unterminated string literal', this.pos); + + throw new ParseError("Unterminated string literal", this.pos); } private parseNamedSchema(): NamedSchema { this.skipWhitespace(); const startpos = this.pos; - let identifier = ''; + let identifier = ""; while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) { identifier += this.consume(); @@ -296,7 +326,7 @@ class Parser { this.skipWhitespace(); - if (this.consumeStr(':')) { + if (this.consumeStr(":")) { this.skipWhitespace(); const name = identifier; const schema = this.parseSchema(); @@ -310,23 +340,23 @@ class Parser { private parseReferenceSchema(): Schema { // Parse table name - let tableName = ''; + let tableName = ""; while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) { tableName += this.consume(); } if (tableName.length === 0) { - throw new ParseError('Expected table name after @', this.pos); + throw new ParseError("Expected table name after @", this.pos); } this.skipWhitespace(); // Check for array syntax - if (this.consumeStr('[]')) { + if (this.consumeStr("[]")) { this.skipWhitespace(); - const isOptional = this.consumeStr('?'); + const isOptional = this.consumeStr("?"); return { - type: 'reference', + type: "reference", tableName, isArray: true, isOptional, @@ -334,25 +364,76 @@ class Parser { } // Check for optional suffix - const isOptional = this.consumeStr('?'); + const isOptional = this.consumeStr("?"); // Single reference (non-array) return { - type: 'reference', + type: "reference", tableName, isArray: false, isOptional, }; } + + private parseReverseReferenceSchema(): Schema { + // Parse table name + let tableName = ""; + while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) { + tableName += this.consume(); + } + + if (tableName.length === 0) { + throw new ParseError("Expected table name after ~", this.pos); + } + + this.skipWhitespace(); + + // Parse (foreignKey) + if (!this.consumeStr("(")) { + throw new ParseError( + "Expected ( after reverse reference table name", + this.pos, + ); + } + + this.skipWhitespace(); + + let foreignKey = ""; + while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) { + foreignKey += this.consume(); + } + + if (foreignKey.length === 0) { + throw new ParseError("Expected foreign key name inside ()", this.pos); + } + + this.skipWhitespace(); + + if (!this.consumeStr(")")) { + throw new ParseError("Expected ) after foreign key name", this.pos); + } + + this.skipWhitespace(); + + // Check for optional suffix + const isOptional = this.consumeStr("?"); + + return { + type: "reverseReference", + tableName, + foreignKey, + isOptional, + }; + } } export function parseSchema(schemaString: string): Schema { const parser = new Parser(schemaString.trim()); const schema = parser.parseSchema(); - + if (parser.getPosition() < parser.getInputLength()) { - throw new ParseError('Unexpected input after schema', parser.getPosition()); + throw new ParseError("Unexpected input after schema", parser.getPosition()); } - + return schema; } diff --git a/src/types.ts b/src/types.ts index e2f8742..6e602ba 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -export type SchemaType = 'string' | 'number' | 'int' | 'float' | 'boolean'; +export type SchemaType = "string" | "number" | "int" | "float" | "boolean"; export interface PrimitiveSchema { type: SchemaType; @@ -10,17 +10,17 @@ export interface NamedSchema { } export interface TupleSchema { - type: 'tuple'; + type: "tuple"; elements: NamedSchema[]; } export interface ArraySchema { - type: 'array'; + type: "array"; element: Schema; } export interface ReferenceSchema { - type: 'reference'; + type: "reference"; /** Referenced table name (e.g., 'parts' from '@parts[]') */ tableName: string; /** Whether it's an array reference */ @@ -29,17 +29,34 @@ export interface ReferenceSchema { isOptional?: boolean; } +export interface ReverseReferenceSchema { + type: "reverseReference"; + /** Referenced table name (e.g., 'orders' from '~orders(user)') */ + tableName: string; + /** The foreign key field name in the referenced table (e.g., 'user' from '~orders(user)') */ + foreignKey: string; + /** Whether it's optional (null if no matches, instead of empty array) */ + isOptional?: boolean; +} + export interface StringLiteralSchema { - type: 'stringLiteral'; - value: string; // The literal string value (without quotes) + type: "stringLiteral"; + value: string; // The literal string value (without quotes) } export interface UnionSchema { - type: 'union'; - members: Schema[]; // Union members + type: "union"; + members: Schema[]; // Union members } -export type Schema = PrimitiveSchema | TupleSchema | ArraySchema | ReferenceSchema | StringLiteralSchema | UnionSchema; +export type Schema = + | PrimitiveSchema + | TupleSchema + | ArraySchema + | ReferenceSchema + | ReverseReferenceSchema + | StringLiteralSchema + | UnionSchema; export interface ParsedSchema { schema: Schema; diff --git a/src/validator.ts b/src/validator.ts index 6775d09..6baa273 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -1,5 +1,15 @@ -import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, NamedSchema, ReferenceSchema, StringLiteralSchema, UnionSchema } from './types'; -import { ParseError } from './parser'; +import type { + Schema, + PrimitiveSchema, + TupleSchema, + ArraySchema, + NamedSchema, + ReferenceSchema, + ReverseReferenceSchema, + StringLiteralSchema, + UnionSchema, +} from "./types"; +import { ParseError } from "./parser"; class ValueParser { private input: string; @@ -10,11 +20,11 @@ class ValueParser { } private peek(): string { - return this.input[this.pos] || ''; + return this.input[this.pos] || ""; } private consume(): string { - return this.input[this.pos++] || ''; + return this.input[this.pos++] || ""; } private skipWhitespace(): void { @@ -35,46 +45,58 @@ class ValueParser { this.skipWhitespace(); switch (schema.type) { - case 'string': + case "string": return this.parseStringValue(); - case 'number': + case "number": return this.parseNumberValue(); - case 'int': + case "int": return this.parseIntValue(); - case 'float': + case "float": return this.parseFloatValue(); - case 'boolean': + case "boolean": return this.parseBooleanValue(); - case 'stringLiteral': + case "stringLiteral": return this.parseStringLiteralValue(schema); - case 'union': + case "union": return this.parseUnionValue(schema); - case 'tuple': + case "tuple": return this.parseTupleValue(schema, allowOmitBrackets); - case 'array': + case "array": return this.parseArrayValue(schema, allowOmitBrackets); - case 'reference': + case "reference": // Reference values are parsed as strings (IDs) initially, resolved later return this.parseReferenceValue(schema); + case "reverseReference": + // Reverse references are derived fields, not stored in CSV cells + // They resolve to null at parse time; actual resolution happens in the loader + return null; default: - throw new ParseError(`Unknown schema type: ${(schema as { type: string }).type}`, this.pos); + throw new ParseError( + `Unknown schema type: ${(schema as { type: string }).type}`, + this.pos, + ); } } private parseStringValue(): string { - let result = ''; + let result = ""; while (this.pos < this.input.length) { const char = this.peek(); - - if (char === '\\') { + + if (char === "\\") { this.consume(); const nextChar = this.consume(); - if (nextChar === ';' || nextChar === '[' || nextChar === ']' || nextChar === '\\') { + if ( + nextChar === ";" || + nextChar === "[" || + nextChar === "]" || + nextChar === "\\" + ) { result += nextChar; } else { - result += '\\' + nextChar; + result += "\\" + nextChar; } - } else if (char === ';' || char === ']') { + } else if (char === ";" || char === "]") { break; } else { result += this.consume(); @@ -84,28 +106,28 @@ class ValueParser { } private parseNumberValue(): number { - let numStr = ''; + let numStr = ""; while (this.pos < this.input.length && /[\d.\-+eE]/.test(this.peek())) { numStr += this.consume(); } const num = parseFloat(numStr); if (isNaN(num)) { - throw new ParseError('Invalid number', this.pos - numStr.length); + throw new ParseError("Invalid number", this.pos - numStr.length); } return num; } private parseIntValue(): number { - let numStr = ''; + let numStr = ""; while (this.pos < this.input.length && /[\d.\-+eE]/.test(this.peek())) { numStr += this.consume(); } const num = parseFloat(numStr); if (isNaN(num)) { - throw new ParseError('Invalid number', this.pos - numStr.length); + throw new ParseError("Invalid number", this.pos - numStr.length); } if (!Number.isInteger(num)) { - throw new ParseError('Expected integer value', this.pos - numStr.length); + throw new ParseError("Expected integer value", this.pos - numStr.length); } return num; } @@ -115,33 +137,38 @@ class ValueParser { } private parseBooleanValue(): boolean { - if (this.consumeStr('true')) { + if (this.consumeStr("true")) { return true; } - if (this.consumeStr('false')) { + if (this.consumeStr("false")) { return false; } - throw new ParseError('Expected true or false', this.pos); + throw new ParseError("Expected true or false", this.pos); } private parseStringLiteralValue(schema: StringLiteralSchema): string { const quote = this.peek(); - + // 支持带引号或不带引号的字符串值 if (quote === '"' || quote === "'") { this.consume(); // Consume opening quote - let value = ''; + let value = ""; while (this.pos < this.input.length) { const char = this.peek(); - if (char === '\\') { + if (char === "\\") { this.consume(); const nextChar = this.consume(); - if (nextChar === '"' || nextChar === "'" || nextChar === '\\' || nextChar === ';') { + if ( + nextChar === '"' || + nextChar === "'" || + nextChar === "\\" || + nextChar === ";" + ) { value += nextChar; } else { - value += '\\' + nextChar; + value += "\\" + nextChar; } } else if (char === quote) { this.consume(); // Consume closing quote @@ -149,7 +176,7 @@ class ValueParser { if (value !== schema.value) { throw new ParseError( `Invalid value '"${value}"'. Expected '"${schema.value}"'`, - this.pos + this.pos, ); } @@ -159,24 +186,24 @@ class ValueParser { } } - throw new ParseError('Unterminated string literal', this.pos); + throw new ParseError("Unterminated string literal", this.pos); } else { // 不带引号的字符串,像普通字符串一样解析 - let value = ''; + let value = ""; while (this.pos < this.input.length) { const char = this.peek(); - if (char === ';' || char === ']' || char === ')') { + if (char === ";" || char === "]" || char === ")") { break; } value += this.consume(); } - + value = value.trim(); - + if (value !== schema.value) { throw new ParseError( `Invalid value '${value}'. Expected '${schema.value}'`, - this.pos - value.length + this.pos - value.length, ); } @@ -187,7 +214,7 @@ class ValueParser { private parseUnionValue(schema: UnionSchema): unknown { const savedPos = this.pos; const errors: Error[] = []; - + // Try each union member until one succeeds for (let i = 0; i < schema.members.length; i++) { this.pos = savedPos; @@ -198,27 +225,30 @@ class ValueParser { // Continue to next member } } - + // If all members fail, throw a descriptive error throw new ParseError( `Value does not match any union member. Tried ${schema.members.length} alternatives.`, - this.pos + this.pos, ); } - private parseTupleValue(schema: TupleSchema, allowOmitBrackets: boolean): unknown[] { + private parseTupleValue( + schema: TupleSchema, + allowOmitBrackets: boolean, + ): unknown[] { let hasOpenBracket = false; - if (this.peek() === '[') { + if (this.peek() === "[") { this.consume(); hasOpenBracket = true; } else if (!allowOmitBrackets) { - throw new ParseError('Expected [', this.pos); + throw new ParseError("Expected [", this.pos); } this.skipWhitespace(); - if (this.peek() === ']' && hasOpenBracket) { + if (this.peek() === "]" && hasOpenBracket) { this.consume(); return []; } @@ -227,7 +257,7 @@ class ValueParser { for (let i = 0; i < schema.elements.length; i++) { this.skipWhitespace(); const elementSchema = schema.elements[i]; - + // Try to consume optional name prefix (e.g., "current:") if (elementSchema.name) { this.skipWhitespace(); @@ -239,13 +269,13 @@ class ValueParser { this.pos = savedPos; } } - + result.push(this.parseValue(elementSchema.schema, false)); this.skipWhitespace(); if (i < schema.elements.length - 1) { - if (!this.consumeStr(';')) { - throw new ParseError('Expected ;', this.pos); + if (!this.consumeStr(";")) { + throw new ParseError("Expected ;", this.pos); } } } @@ -253,39 +283,43 @@ class ValueParser { this.skipWhitespace(); if (hasOpenBracket) { - if (!this.consumeStr(']')) { - throw new ParseError('Expected ]', this.pos); + if (!this.consumeStr("]")) { + throw new ParseError("Expected ]", this.pos); } } return result; } - private parseArrayValue(schema: ArraySchema, allowOmitBrackets: boolean): unknown[] { + private parseArrayValue( + schema: ArraySchema, + allowOmitBrackets: boolean, + ): unknown[] { let hasOpenBracket = false; - const elementIsTupleOrArray = schema.element.type === 'tuple' || schema.element.type === 'array'; + const elementIsTupleOrArray = + schema.element.type === "tuple" || schema.element.type === "array"; if (this.pos >= this.input.length || !this.input.trim()) { return []; } - if (this.peek() === '[') { + if (this.peek() === "[") { if (!elementIsTupleOrArray) { this.consume(); hasOpenBracket = true; - } else if (this.input[this.pos + 1] === '[') { + } else if (this.input[this.pos + 1] === "[") { this.consume(); hasOpenBracket = true; } } if (!hasOpenBracket && !allowOmitBrackets && !elementIsTupleOrArray) { - throw new ParseError('Expected [', this.pos); + throw new ParseError("Expected [", this.pos); } this.skipWhitespace(); - if (this.peek() === ']' && hasOpenBracket) { + if (this.peek() === "]" && hasOpenBracket) { this.consume(); return []; } @@ -296,7 +330,7 @@ class ValueParser { result.push(this.parseValue(schema.element, false)); this.skipWhitespace(); - if (!this.consumeStr(';')) { + if (!this.consumeStr(";")) { break; } } @@ -304,15 +338,17 @@ class ValueParser { this.skipWhitespace(); if (hasOpenBracket) { - if (!this.consumeStr(']')) { - throw new ParseError('Expected ]', this.pos); + if (!this.consumeStr("]")) { + throw new ParseError("Expected ]", this.pos); } } return result; } - private parseReferenceValue(schema: ReferenceSchema): string | string[] | null { + private parseReferenceValue( + schema: ReferenceSchema, + ): string | string[] | null { if (schema.isOptional) { this.skipWhitespace(); if (this.pos >= this.input.length) { @@ -323,14 +359,14 @@ class ValueParser { if (schema.isArray) { // Parse array of IDs: [id1; id2; id3] let hasOpenBracket = false; - if (this.peek() === '[') { + if (this.peek() === "[") { this.consume(); hasOpenBracket = true; } this.skipWhitespace(); - if (this.peek() === ']' && hasOpenBracket) { + if (this.peek() === "]" && hasOpenBracket) { this.consume(); return []; } @@ -339,31 +375,35 @@ class ValueParser { while (true) { this.skipWhitespace(); // Parse each ID as a string - let id = ''; - while (this.pos < this.input.length && this.peek() !== ';' && this.peek() !== ']') { + let id = ""; + while ( + this.pos < this.input.length && + this.peek() !== ";" && + this.peek() !== "]" + ) { id += this.consume(); } ids.push(id.trim()); this.skipWhitespace(); - if (!this.consumeStr(';')) { + if (!this.consumeStr(";")) { break; } } if (hasOpenBracket) { - if (!this.consumeStr(']')) { - throw new ParseError('Expected ]', this.pos); + if (!this.consumeStr("]")) { + throw new ParseError("Expected ]", this.pos); } } return ids; } else { // Parse single ID as string - let id = ''; + let id = ""; while (this.pos < this.input.length) { const char = this.peek(); - if (char === ';' || char === ']' || char === ',') { + if (char === ";" || char === "]" || char === ",") { break; } id += this.consume(); @@ -383,92 +423,116 @@ class ValueParser { export function parseValue(schema: Schema, valueString: string): unknown { const parser = new ValueParser(valueString.trim()); - const allowOmitBrackets = schema.type === 'tuple' || schema.type === 'array'; + const allowOmitBrackets = schema.type === "tuple" || schema.type === "array"; const value = parser.parseValue(schema, allowOmitBrackets); if (parser.getPosition() < parser.getInputLength()) { - throw new ParseError('Unexpected input after value', parser.getPosition()); + throw new ParseError("Unexpected input after value", parser.getPosition()); } return value; } -export function schemaToTypeString(schema: Schema, resourceNames?: Map): string { +export function schemaToTypeString( + schema: Schema, + resourceNames?: Map, +): string { switch (schema.type) { - case 'string': - return 'string'; - case 'number': - case 'int': - case 'float': - return 'number'; - case 'boolean': - return 'boolean'; - case 'stringLiteral': + case "string": + return "string"; + case "number": + case "int": + case "float": + return "number"; + case "boolean": + return "boolean"; + case "stringLiteral": return `"${schema.value}"`; - case 'union': - return schema.members.map(m => schemaToTypeString(m, resourceNames)).join(' | '); - case 'reference': { - const typeName = resourceNames?.get(schema.tableName) || + case "union": + return schema.members + .map((m) => schemaToTypeString(m, resourceNames)) + .join(" | "); + case "reference": { + const typeName = + resourceNames?.get(schema.tableName) || schema.tableName.charAt(0).toUpperCase() + schema.tableName.slice(1); const baseType = schema.isArray ? `${typeName}[]` : typeName; return schema.isOptional ? `${baseType} | null` : baseType; } - case 'array': - if (schema.element.type === 'tuple') { + case "reverseReference": { + const typeName = + resourceNames?.get(schema.tableName) || + schema.tableName.charAt(0).toUpperCase() + schema.tableName.slice(1); + // Reverse references always resolve to an array (one-to-many) + const baseType = `${typeName}[]`; + return schema.isOptional ? `${baseType} | null` : baseType; + } + case "array": + if (schema.element.type === "tuple") { const tupleElements = schema.element.elements.map((el) => { const typeStr = schemaToTypeString(el.schema, resourceNames); return el.name ? `${el.name}: ${typeStr}` : typeStr; }); - return `[${tupleElements.join(', ')}][]`; + return `[${tupleElements.join(", ")}][]`; } const elementType = schemaToTypeString(schema.element, resourceNames); - if (schema.element.type === 'union') { + if (schema.element.type === "union") { return `(${elementType})[]`; } return `${elementType}[]`; - case 'tuple': + case "tuple": const tupleElements = schema.elements.map((el) => { const typeStr = schemaToTypeString(el.schema, resourceNames); return el.name ? `${el.name}: ${typeStr}` : typeStr; }); - return `[${tupleElements.join(', ')}]`; + return `[${tupleElements.join(", ")}]`; default: - return 'unknown'; + return "unknown"; } } export function createValidator(schema: Schema): (value: unknown) => boolean { return function validate(value: unknown): boolean { switch (schema.type) { - case 'string': - return typeof value === 'string'; - case 'number': - return typeof value === 'number' && !isNaN(value); - case 'int': - return typeof value === 'number' && !isNaN(value) && Number.isInteger(value); - case 'float': - return typeof value === 'number' && !isNaN(value); - case 'boolean': - return typeof value === 'boolean'; - case 'stringLiteral': - return typeof value === 'string' && value === schema.value; - case 'union': + case "string": + return typeof value === "string"; + case "number": + return typeof value === "number" && !isNaN(value); + case "int": + return ( + typeof value === "number" && !isNaN(value) && Number.isInteger(value) + ); + case "float": + return typeof value === "number" && !isNaN(value); + case "boolean": + return typeof value === "boolean"; + case "stringLiteral": + return typeof value === "string" && value === schema.value; + case "union": return schema.members.some((member) => createValidator(member)(value)); - case 'tuple': + case "tuple": if (!Array.isArray(value)) return false; if (value.length !== schema.elements.length) return false; return schema.elements.every((elementSchema, index) => - createValidator(elementSchema.schema)(value[index]) + createValidator(elementSchema.schema)(value[index]), ); - case 'array': + case "array": if (!Array.isArray(value)) return false; return value.every((item) => createValidator(schema.element)(item)); - case 'reference': + case "reference": if (schema.isOptional && value === null) return true; if (schema.isArray) { - return Array.isArray(value) && value.every((id) => typeof id === 'string'); + return ( + Array.isArray(value) && value.every((id) => typeof id === "string") + ); } - return typeof value === 'string' || (Array.isArray(value) && value.every((id) => typeof id === 'string')); + return ( + typeof value === "string" || + (Array.isArray(value) && value.every((id) => typeof id === "string")) + ); + case "reverseReference": + if (schema.isOptional && value === null) return true; + return Array.isArray(value); default: return false; }