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"); function readFixture(name: string): string { return fs.readFileSync(path.join(fixturesDir, name), "utf-8"); } describe("parseCsv - basic parsing", () => { it("should parse a simple CSV with primitive types", () => { const csv = [ "name,age,active", "string,number,boolean", "Alice,30,true", "Bob,25,false", ].join("\n"); const result = parseCsv(csv, { emitTypes: false }); expect(result.data).toHaveLength(2); expect(result.data[0]).toEqual({ name: "Alice", age: 30, active: true }); expect(result.data[1]).toEqual({ name: "Bob", age: 25, active: false }); }); it("should parse CSV with int and float columns", () => { const csv = [ "id,count,price", "int,int,float", "1,5,9.99", "2,3,4.50", ].join("\n"); const result = parseCsv(csv, { emitTypes: false }); expect(result.data).toHaveLength(2); expect(result.data[0]).toEqual({ id: 1, count: 5, price: 9.99 }); expect(result.data[1]).toEqual({ id: 2, count: 3, price: 4.5 }); }); it("should parse CSV with string literal columns (unquoted in CSV)", () => { const csv = [ "name,status", "string,'on' | 'off'", "Alice,on", "Bob,off", ].join("\n"); const result = parseCsv(csv, { emitTypes: false }); expect(result.data).toHaveLength(2); expect(result.data[0]).toEqual({ name: "Alice", status: "on" }); expect(result.data[1]).toEqual({ name: "Bob", status: "off" }); }); it("should parse CSV with array columns", () => { const csv = [ "name,tags", "string,string[]", "Alice,[dev; admin]", "Bob,[user]", ].join("\n"); const result = parseCsv(csv, { emitTypes: false }); expect(result.data).toHaveLength(2); expect(result.data[0]).toEqual({ name: "Alice", tags: ["dev", "admin"] }); expect(result.data[1]).toEqual({ name: "Bob", tags: ["user"] }); }); it("should parse CSV with tuple columns", () => { const csv = [ "name,coords", "string,[number; number]", "Alice,[1; 2]", "Bob,[3; 4]", ].join("\n"); const result = parseCsv(csv, { emitTypes: false }); expect(result.data).toHaveLength(2); expect(result.data[0]).toEqual({ name: "Alice", coords: [1, 2] }); expect(result.data[1]).toEqual({ name: "Bob", coords: [3, 4] }); }); it("should require at least 2 rows (header + schema)", () => { const csv = "name,age\nstring,number"; expect(() => parseCsv(csv, { emitTypes: false })).not.toThrow(); const csv1Row = "name,age"; expect(() => parseCsv(csv1Row, { emitTypes: false })).toThrow( "at least 2 rows", ); }); it("should throw if header and schema count mismatch", () => { const csv = "name,age\nstring"; expect(() => parseCsv(csv, { emitTypes: false })).toThrow("does not match"); }); }); describe("parseCsv - reference resolution", () => { it("should resolve single reference to another CSV table", () => { const usersCsv = readFixture("users.csv"); const result = parseCsv(usersCsv, { emitTypes: false }); expect(result.data).toHaveLength(3); expect(result.data[0]).toEqual({ id: "1", name: "Alice", email: "alice@example.com", }); }); it("should resolve reference values using parseCsv with referenced tables", () => { const ordersCsv = [ "id,customer,total", "string,@users,number", "1,1,100", ].join("\n"); const result = parseCsv(ordersCsv, { emitTypes: false, currentFilePath: path.join(fixturesDir, "orders.csv"), }); expect(result.data).toHaveLength(1); expect(result.data[0].customer).toEqual({ id: "1", name: "Alice", email: "alice@example.com", }); expect(result.data[0].total).toBe(100); }); it("should resolve array reference values", () => { const ordersCsv = [ "id,items,total", "string,@parts[],number", "1,[1; 2],35.5", ].join("\n"); const result = parseCsv(ordersCsv, { emitTypes: false, currentFilePath: path.join(fixturesDir, "orders.csv"), }); expect(result.data).toHaveLength(1); const items = result.data[0].items as Record[]; expect(items).toHaveLength(2); expect(items[0]).toEqual({ id: "1", name: "Widget", price: 10.5 }); expect(items[1]).toEqual({ id: "2", name: "Gadget", price: 25 }); expect(result.data[0].total).toBe(35.5); }); it("should resolve mixed single and array references", () => { const ordersCsv = [ "id,customer,items,total", "string,@users,@parts[],number", "1,1,[1; 2],35.5", ].join("\n"); const result = parseCsv(ordersCsv, { emitTypes: false, currentFilePath: path.join(fixturesDir, "orders.csv"), }); expect(result.data).toHaveLength(1); expect(result.data[0].customer).toEqual({ id: "1", name: "Alice", email: "alice@example.com", }); const items = result.data[0].items as Record[]; expect(items).toHaveLength(2); expect(items[0]).toEqual({ id: "1", name: "Widget", price: 10.5 }); }); it("should throw error for reference to non-existent ID", () => { const ordersCsv = ["id,customer", "string,@users", "1,999"].join("\n"); expect(() => parseCsv(ordersCsv, { emitTypes: false, currentFilePath: path.join(fixturesDir, "orders.csv"), }), ).toThrow(/not found/); }); it("should throw error for reference to non-existent table", () => { const csv = ["id,ref", "string,@nonexistent", "1,someid"].join("\n"); expect(() => parseCsv(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, "test.csv"), }), ).toThrow(/Failed to load referenced table/); }); it("should collect reference table names", () => { const csv = ["id,customer,items", "string,@users,@parts[]", "1,1,[1]"].join( "\n", ); const result = parseCsv(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, "test.csv"), }); expect(result.references.has("users")).toBe(true); expect(result.references.has("parts")).toBe(true); }); it("should use custom primary key", () => { const nameCsv = [ "code,name", "string,string", "US,United States", "UK,United Kingdom", ].join("\n"); const nameCsvPath = path.join(fixturesDir, "countries.csv"); fs.writeFileSync(nameCsvPath, nameCsv); try { const refCsv = ["id,country", "string,@countries", "1,US"].join("\n"); const result = parseCsv(refCsv, { emitTypes: false, currentFilePath: path.join(fixturesDir, "ref.csv"), defaultPrimaryKey: "code", }); expect(result.data[0].country).toEqual({ code: "US", name: "United States", }); } finally { fs.unlinkSync(nameCsvPath); } }); }); describe("parseCsv - circular reference detection", () => { it("should detect self-referencing circular reference", () => { const csv = ["id,name,parent", "string,string,@self_ref", "1,Root,2"].join( "\n", ); expect(() => parseCsv(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, "self_ref.csv"), }), ).toThrow(/Circular reference detected/); }); it("should detect mutual circular reference (A -> B -> A)", () => { const csv = readFixture("circular_a.csv"); expect(() => parseCsv(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, "circular_a.csv"), }), ).toThrow(/Circular reference detected/); }); it("should allow same table referenced from multiple columns without circular reference", () => { const usersCsv = readFixture("users.csv"); const csv = ["id,creator,reviewer", "string,@users,@users", "1,1,2"].join( "\n", ); const result = parseCsv(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, "test.csv"), }); expect(result.data[0].creator).toEqual({ id: "1", name: "Alice", email: "alice@example.com", }); expect(result.data[0].reviewer).toEqual({ id: "2", name: "Bob", email: "bob@example.com", }); }); }); describe("parseCsv - references in combinatory schemas", () => { it("should resolve reference inside a tuple", () => { const csv = [ "id,info", "string,[ref: @users; note: string]", "1,[ref: 1; note: urgent]", ].join("\n"); const result = parseCsv(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, "test.csv"), }); expect(result.data).toHaveLength(1); const info = result.data[0].info as unknown[]; expect(info).toHaveLength(2); expect(info[0]).toEqual({ id: "1", name: "Alice", email: "alice@example.com", }); expect(info[1]).toBe("urgent"); }); it("should resolve reference array inside a tuple", () => { const csv = [ "id,info", "string,[refs: @users[]; note: string]", "1,[refs: [1; 2]; note: test]", ].join("\n"); const result = parseCsv(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, "test.csv"), }); expect(result.data).toHaveLength(1); const info = result.data[0].info as unknown[]; expect(info).toHaveLength(2); const refs = info[0] as Record[]; expect(refs).toHaveLength(2); expect(refs[0]).toEqual({ id: "1", name: "Alice", email: "alice@example.com", }); expect(refs[1]).toEqual({ id: "2", name: "Bob", email: "bob@example.com" }); expect(info[1]).toBe("test"); }); it("should resolve array of tuples containing references", () => { const csv = [ "id,pairs", "string,[@users; number][]", "1,[[1; 10]; [2; 20]]", ].join("\n"); const result = parseCsv(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, "test.csv"), }); expect(result.data).toHaveLength(1); const pairs = result.data[0].pairs as unknown[][]; expect(pairs).toHaveLength(2); expect(pairs[0]).toHaveLength(2); expect(pairs[0][0]).toEqual({ id: "1", name: "Alice", email: "alice@example.com", }); expect(pairs[0][1]).toBe(10); expect(pairs[1][0]).toEqual({ id: "2", name: "Bob", email: "bob@example.com", }); expect(pairs[1][1]).toBe(20); }); it("should resolve reference in union (@users | string)", () => { const csv = ["id,value", "string,@users | string", "1,1", "2,unknown"].join( "\n", ); const result = parseCsv(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, "test.csv"), }); expect(result.data).toHaveLength(2); expect(result.data[0].value).toEqual({ id: "1", name: "Alice", email: "alice@example.com", }); expect(result.data[1].value).toBe("unknown"); }); it("should resolve reference in union (@users[] | string)", () => { const csv = [ "id,value", "string,@users[] | string", "1,[1; 2]", "2,none", ].join("\n"); const result = parseCsv(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, "test.csv"), }); expect(result.data).toHaveLength(2); const arr = result.data[0].value as Record[]; expect(arr).toHaveLength(2); expect(arr[0]).toEqual({ id: "1", name: "Alice", email: "alice@example.com", }); expect(result.data[1].value).toBe("none"); }); it("should resolve array of reference unions (@users | @parts)[]", () => { const csv = ["id,items", "string,(@users | @parts)[]", "1,[1; 2]"].join( "\n", ); const result = parseCsv(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, "test.csv"), }); expect(result.data).toHaveLength(1); }); it("should resolve named tuple with reference and other fields", () => { const csv = [ "id,details", "string,[owner: @users; count: number]", "1,[owner: 1; count: 5]", ].join("\n"); const result = parseCsv(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, "test.csv"), }); expect(result.data).toHaveLength(1); const details = result.data[0].details as unknown[]; expect(details).toHaveLength(2); expect(details[0]).toEqual({ id: "1", name: "Alice", email: "alice@example.com", }); expect(details[1]).toBe(5); }); }); describe("parseCsv - type generation", () => { it("should generate type definition with emitTypes enabled", () => { const csv = ["name,age", "string,number", "Alice,30"].join("\n"); const result = parseCsv(csv, { emitTypes: true, resourceName: "people" }); expect(result.typeDefinition).toBeDefined(); expect(result.typeDefinition).toContain("peopleTable"); expect(result.typeDefinition).toContain("readonly name: string"); expect(result.typeDefinition).toContain("readonly age: number"); }); it("should include reference imports in type definition", () => { const csv = ["id,customer", "string,@users", "1,1"].join("\n"); const result = parseCsv(csv, { emitTypes: true, resourceName: "orders", currentFilePath: path.join(fixturesDir, "test.csv"), }); expect(result.typeDefinition).toBeDefined(); expect(result.typeDefinition).toContain("Users"); expect(result.typeDefinition).toContain("users.csv"); }); it("should not generate type definition when emitTypes is false", () => { const csv = ["name,age", "string,number", "Alice,30"].join("\n"); const result = parseCsv(csv, { emitTypes: false }); expect(result.typeDefinition).toBeUndefined(); }); it("should generate correct type for reference column", () => { const csv = ["id,customer", "string,@users", "1,1"].join("\n"); const result = parseCsv(csv, { emitTypes: true, resourceName: "orders", currentFilePath: path.join(fixturesDir, "test.csv"), }); expect(result.typeDefinition).toContain("readonly customer: Users"); }); it("should generate correct type for array reference column", () => { const csv = ["id,items", "string,@parts[]", "1,[1]"].join("\n"); const result = parseCsv(csv, { emitTypes: true, resourceName: "orders", currentFilePath: path.join(fixturesDir, "test.csv"), }); expect(result.typeDefinition).toContain("readonly items: Parts[]"); }); it("should generate correct type for reference in tuple", () => { const csv = [ "id,info", "string,[ref: @users; note: string]", "1,[ref: 1; note: test]", ].join("\n"); const result = parseCsv(csv, { emitTypes: true, resourceName: "data", currentFilePath: path.join(fixturesDir, "test.csv"), }); expect(result.typeDefinition).toContain("Users"); }); }); describe("parseCsv - caching", () => { it("should cache referenced table and not re-read on subsequent references", () => { const usersCsv = readFixture("users.csv"); const csv = ["id,creator,reviewer", "string,@users,@users", "1,1,2"].join( "\n", ); const result = parseCsv(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, "test.csv"), }); const creator = result.data[0].creator as Record; const reviewer = result.data[0].reviewer as Record; expect(creator).not.toEqual(reviewer); expect(creator.id).toBe("1"); expect(reviewer.id).toBe("2"); }); }); describe("parseCsv - refBaseDir option", () => { it("should use refBaseDir to resolve reference paths", () => { const csv = ["id,customer", "string,@users", "1,1"].join("\n"); const result = parseCsv(csv, { emitTypes: false, refBaseDir: fixturesDir, }); expect(result.data[0].customer).toEqual({ id: "1", name: "Alice", email: "alice@example.com", }); }); }); describe("parseCsv - resolveReferences: false", () => { it("should store IDs instead of resolved objects for reference fields", () => { const csv = [ "id,customer,items", "string,@users,@parts[]", "1,1,[1; 2]", ].join("\n"); const result = parseCsv(csv, { emitTypes: false, resolveReferences: false, }); expect(result.data[0].customer).toBe("1"); expect(result.data[0].items).toEqual(["1", "2"]); }); it("should populate referenceFields with metadata", () => { const csv = [ "id,customer,items", "string,@users,@parts[]", "1,1,[1; 2]", ].join("\n"); const result = parseCsv(csv, { emitTypes: false, resolveReferences: false, }); expect(result.referenceFields).toHaveLength(2); expect(result.referenceFields[0]).toEqual({ name: "customer", tableName: "users", isArray: false, schema: expect.objectContaining({ type: "reference", tableName: "users", isArray: false, }), }); expect(result.referenceFields[1]).toEqual({ name: "items", tableName: "parts", isArray: true, schema: expect.objectContaining({ type: "reference", tableName: "parts", isArray: true, }), }); }); it("should not load referenced CSV files", () => { const csv = ["id,customer", "string,@nonexistent", "1,someid"].join("\n"); expect(() => parseCsv(csv, { emitTypes: false, resolveReferences: false, }), ).not.toThrow(); }); it("should store IDs for nested references in tuples", () => { const csv = [ "id,info", "string,[ref: @users; note: string]", "1,[ref: 1; note: urgent]", ].join("\n"); const result = parseCsv(csv, { emitTypes: false, resolveReferences: false, }); expect((result.data[0].info as unknown[])[0]).toBe("1"); expect((result.data[0].info as unknown[])[1]).toBe("urgent"); expect(result.referenceFields).toHaveLength(1); expect(result.referenceFields[0].tableName).toBe("users"); }); it("should store IDs for references in unions", () => { const csv = ["id,value", "string,@users | string", "1,1", "2,unknown"].join( "\n", ); const result = parseCsv(csv, { emitTypes: false, resolveReferences: false, }); expect(result.data[0].value).toBe("1"); expect(result.data[1].value).toBe("unknown"); }); it("should not throw for self-referencing table when resolveReferences is false", () => { const csv = readFixture("self_ref.csv"); const result = parseCsv(csv, { emitTypes: false, resolveReferences: false, currentFilePath: path.join(fixturesDir, "self_ref.csv"), }); expect(result.data).toHaveLength(2); expect(result.data[0].parent).toBe("2"); expect(result.data[1].parent).toBe("1"); expect(result.referenceFields).toHaveLength(1); expect(result.referenceFields[0].tableName).toBe("self_ref"); }); it("should not throw for cross-referencing tables when resolveReferences is false", () => { const csv = readFixture("circular_a.csv"); const result = parseCsv(csv, { emitTypes: false, resolveReferences: false, currentFilePath: path.join(fixturesDir, "circular_a.csv"), }); expect(result.data).toHaveLength(1); expect(result.data[0].related).toEqual(["1"]); expect(result.referenceFields).toHaveLength(1); expect(result.referenceFields[0].tableName).toBe("circular_b"); }); it("should store IDs for nested self-reference in tuple", () => { const csv = [ "id,name,parent_info", "string,string,[parent: @self_ref; role: string]", "1,Root,[parent: 2; role: admin]", ].join("\n"); const result = parseCsv(csv, { emitTypes: false, resolveReferences: false, currentFilePath: path.join(fixturesDir, "self_ref.csv"), }); const parentInfo = result.data[0].parent_info as unknown[]; expect(parentInfo[0]).toBe("2"); expect(parentInfo[1]).toBe("admin"); expect(result.referenceFields).toHaveLength(1); expect(result.referenceFields[0].tableName).toBe("self_ref"); }); it("should store IDs for self-reference in union", () => { const csv = [ "id,name,ref_or_val", "string,string,@self_ref | string", "1,Root,2", "2,Child,none", ].join("\n"); const result = parseCsv(csv, { emitTypes: false, resolveReferences: false, currentFilePath: path.join(fixturesDir, "self_ref.csv"), }); expect(result.data[0].ref_or_val).toBe("2"); expect(result.data[1].ref_or_val).toBe("none"); expect(result.referenceFields).toHaveLength(1); expect(result.referenceFields[0].tableName).toBe("self_ref"); }); it("should store IDs for self-reference array in tuple", () => { const csv = [ "id,name,children", "string,string,[@self_ref[]]", "1,Root,[[2]]", ].join("\n"); const result = parseCsv(csv, { emitTypes: false, resolveReferences: false, currentFilePath: path.join(fixturesDir, "self_ref.csv"), }); const children = result.data[0].children as unknown[]; expect(children[0]).toEqual(["2"]); expect(result.referenceFields).toHaveLength(1); expect(result.referenceFields[0].tableName).toBe("self_ref"); }); }); describe("csvToModule - accessor-based output", () => { it("should emit accessor function for tables without references", () => { const csv = ["name,age", "string,number", "Alice,30"].join("\n"); const result = csvToModule(csv, { emitTypes: false }); expect(result.js).toContain("export default function getData()"); expect(result.js).not.toContain("import "); expect(result.js).not.toContain("Lookup"); }); it("should emit accessor function for tables with references", () => { const csv = ["id,customer", "string,@users", "1,1"].join("\n"); const result = csvToModule(csv, { emitTypes: false }); expect(result.js).toContain("import _users from './users.csv'"); expect(result.js).toContain("export default function getData()"); expect(result.js).toContain("_usersLookup"); expect(result.js).toContain("_resolved = _raw;"); }); it("should emit accessor function for tables with array references", () => { const csv = ["id,items", "string,@parts[]", "1,[1; 2]"].join("\n"); const result = csvToModule(csv, { emitTypes: false }); expect(result.js).toContain("import _parts from './parts.csv'"); expect(result.js).toContain("_partsLookup"); expect(result.js).toContain(".map(id =>"); }); it("should emit multiple imports for multiple reference tables", () => { const csv = [ "id,customer,items", "string,@users,@parts[]", "1,1,[1; 2]", ].join("\n"); const result = csvToModule(csv, { emitTypes: false }); expect(result.js).toContain("import _users from './users.csv'"); expect(result.js).toContain("import _parts from './parts.csv'"); expect(result.js).toContain("_usersLookup"); expect(result.js).toContain("_partsLookup"); }); it("should generate function type in dts for tables with references", () => { const csv = ["id,customer", "string,@users", "1,1"].join("\n"); const result = csvToModule(csv, { emitTypes: true, resourceName: "orders", }); expect(result.dts).toContain("declare function getData(): ordersTable"); expect(result.dts).toContain("export default getData"); expect(result.dts).not.toContain("declare const data"); }); it("should generate function type in dts for tables without references", () => { const csv = ["name,age", "string,number", "Alice,30"].join("\n"); const result = csvToModule(csv, { emitTypes: true, resourceName: "people", }); expect(result.dts).toContain("declare function getData(): peopleTable"); expect(result.dts).toContain("export default getData"); expect(result.dts).not.toContain("declare const data"); }); it("should handle nested references in tuples", () => { const csv = [ "id,info", "string,[ref: @users; note: string]", "1,[ref: 1; note: urgent]", ].join("\n"); const result = csvToModule(csv, { emitTypes: false }); expect(result.js).toContain("import _users from './users.csv'"); expect(result.js).toContain("_usersLookup"); }); }); describe("csvToModule - circular reference support", () => { it("should emit accessor for self-referencing table without self-import", () => { const csv = readFixture("self_ref.csv"); const result = csvToModule(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, "self_ref.csv"), }); expect(result.js).not.toContain("import _self_ref from './self_ref.csv'"); expect(result.js).toContain("export default function getData()"); expect(result.js).toContain("_self_refLookup"); expect(result.js).toContain("_self_refLookup = new Map(_raw.map"); expect(result.js).toContain( "parent: _self_refLookup.get(String(row.parent))", ); }); it("should emit accessor for cross-referencing tables", () => { const csv = readFixture("circular_a.csv"); const result = csvToModule(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, "circular_a.csv"), }); expect(result.js).toContain("import _circular_b from './circular_b.csv'"); expect(result.js).toContain("export default function getData()"); expect(result.js).toContain("_circular_bLookup"); expect(result.js).toContain("related:"); }); it("should emit accessor for self-referencing table with nested reference in tuple", () => { const csv = [ "id,name,parent_info", "string,string,[parent: @self_ref; role: string]", "1,Root,[parent: 2; role: admin]", ].join("\n"); const result = csvToModule(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, "self_ref.csv"), }); expect(result.js).not.toContain("import _self_ref from './self_ref.csv'"); expect(result.js).toContain("_self_refLookup"); expect(result.js).toContain("export default function getData()"); expect(result.js).toContain("parent_info:"); expect(result.js).toContain( "_self_refLookup.get(String(row.parent_info[0]))", ); }); it("should emit accessor for self-referencing table with nested reference in union with fallback", () => { const csv = [ "id,name,ref_or_val", "string,string,@self_ref | string", "1,Root,2", "2,Child,none", ].join("\n"); const result = csvToModule(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, "self_ref.csv"), }); expect(result.js).not.toContain("import _self_ref from './self_ref.csv'"); expect(result.js).toContain("_self_refLookup"); expect(result.js).toContain("export default function getData()"); expect(result.js).toContain("ref_or_val:"); expect(result.js).toContain( "_self_refLookup.get(String(row.ref_or_val)) ?? row.ref_or_val", ); }); it("should emit accessor for self-referencing table with nested reference array in tuple", () => { const csv = [ "id,name,children", "string,string,[kids: @self_ref[]]", "1,Root,[[2]]", ].join("\n"); const result = csvToModule(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, "self_ref.csv"), }); expect(result.js).not.toContain("import _self_ref from './self_ref.csv'"); expect(result.js).toContain("_self_refLookup"); expect(result.js).toContain("export default function getData()"); expect(result.js).toContain("children:"); }); it("should generate correct type definition for self-referencing table using local singular type", () => { const csv = readFixture("self_ref.csv"); const result = csvToModule(csv, { emitTypes: true, resourceName: "nodes", currentFilePath: path.join(fixturesDir, "self_ref.csv"), }); expect(result.dts).toContain("declare function getData(): nodesTable"); expect(result.dts).toContain("readonly parent: Nodes"); expect(result.dts).not.toContain( "import type { Self_ref } from './self_ref.csv'", ); expect(result.dts).not.toContain("Self_ref"); }); it("should emit accessor for cross-referencing tables with array references", () => { const csv = readFixture("circular_a.csv"); const result = csvToModule(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, "circular_a.csv"), }); expect(result.js).toContain("import _circular_b from './circular_b.csv'"); expect(result.js).toContain("export default function getData()"); expect(result.js).toContain("_circular_bLookup"); expect(result.js).toContain(".map(id =>"); }); it("should emit accessor for nested cross-reference in tuple", () => { const csv = [ "id,name,info", "string,string,[ref: @circular_b; note: string]", "1,A,[ref: 1; note: linked]", ].join("\n"); const result = csvToModule(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, "circular_a.csv"), }); expect(result.js).toContain("import _circular_b from './circular_b.csv'"); expect(result.js).toContain("_circular_bLookup"); expect(result.js).toContain("export default function getData()"); }); it("should generate union fallback with ?? for non-reference union members", () => { const csv = ["id,value", "string,@users | string", "1,1", "2,unknown"].join( "\n", ); const result = csvToModule(csv, { emitTypes: false }); expect(result.js).toContain("?? row.value"); }); }); 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"); }); it("should handle multiple comment lines including plain comments and reverse references", () => { const csv = [ "# id: id of user", "# orders: list of related orders", "# orders := ~order(user)", "id,name", "string,string", "u01,Alice", "u02,Bob", ].join("\n"); const result = parseCsv(csv, { emitTypes: false, currentFilePath: path.join(fixturesDir, "test.csv"), }); expect(result.reverseReferences).toHaveLength(1); expect(result.reverseReferences[0].fieldName).toBe("orders"); expect(result.data).toHaveLength(2); expect(result.data[0].id).toBe("u01"); expect(result.data[1].id).toBe("u02"); }); }); 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'"); }); });