diff --git a/src/csv-loader/loader.test.ts b/src/csv-loader/loader.test.ts deleted file mode 100644 index d6398e5..0000000 --- a/src/csv-loader/loader.test.ts +++ /dev/null @@ -1,1038 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { parseCsv } from "./loader"; -import * as path from "path"; -import { fixturesDir, readFixture } from "./test-utils"; -import fs from "fs"; - -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 non-ASCII characters and comments", () => { - const csv = [ - '# id: unique intent state ID (e.g. "仙人掌怪-boost")', - "id", - "string", - "仙人掌怪-boost", - "仙人掌怪-defend", - "仙人掌怪-attack", - ].join("\n"); - - const result = parseCsv(csv, { emitTypes: false }); - - expect(result.data).toHaveLength(3); - expect(result.data[0]).toEqual({ id: "仙人掌怪-boost" }); - expect(result.data[1]).toEqual({ id: "仙人掌怪-defend" }); - expect(result.data[2]).toEqual({ id: "仙人掌怪-attack" }); - }); - - 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("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 orderCsvPath = path.join(fixturesDir, "order.csv"); - const orderContent = [ - "id,user,total", - "string,string,number", - "o01,u01,100", - "o02,u01,50", - "o03,u02,75", - ].join("\n"); - fs.writeFileSync(orderCsvPath, orderContent); - - try { - 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"); - // User u01 (Alice) should have 2 orders - const aliceOrders = result.data[0].orders as Record[]; - expect(aliceOrders).toHaveLength(2); - expect(aliceOrders[0]).toEqual({ id: "o01", user: "u01", total: 100 }); - expect(aliceOrders[1]).toEqual({ id: "o02", user: "u01", total: 50 }); - // User u02 (Bob) should have 1 order - const bobOrders = result.data[1].orders as Record[]; - expect(bobOrders).toHaveLength(1); - expect(bobOrders[0].id).toBe("o03"); - } finally { - fs.unlinkSync(orderCsvPath); - } - }); -}); - -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(); - }); -}); diff --git a/src/csv-loader/tests/parseCsv-basic.test.ts b/src/csv-loader/tests/parseCsv-basic.test.ts new file mode 100644 index 0000000..7002c67 --- /dev/null +++ b/src/csv-loader/tests/parseCsv-basic.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from "vitest"; +import { parseCsv } from "../loader"; + +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 non-ASCII characters and comments", () => { + const csv = [ + '# id: unique intent state ID (e.g. "仙人掌怪-boost")', + "id", + "string", + "仙人掌怪-boost", + "仙人掌怪-defend", + "仙人掌怪-attack", + ].join("\n"); + + const result = parseCsv(csv, { emitTypes: false }); + + expect(result.data).toHaveLength(3); + expect(result.data[0]).toEqual({ id: "仙人掌怪-boost" }); + expect(result.data[1]).toEqual({ id: "仙人掌怪-defend" }); + expect(result.data[2]).toEqual({ id: "仙人掌怪-attack" }); + }); + + 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"); + }); +}); diff --git a/src/csv-loader/tests/parseCsv-caching.test.ts b/src/csv-loader/tests/parseCsv-caching.test.ts new file mode 100644 index 0000000..b296409 --- /dev/null +++ b/src/csv-loader/tests/parseCsv-caching.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { parseCsv } from "../loader"; +import * as path from "path"; +import { fixturesDir, readFixture } from "../test-utils"; + +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"); + }); +}); diff --git a/src/csv-loader/tests/parseCsv-circular.test.ts b/src/csv-loader/tests/parseCsv-circular.test.ts new file mode 100644 index 0000000..74f0f37 --- /dev/null +++ b/src/csv-loader/tests/parseCsv-circular.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import { parseCsv } from "../loader"; +import * as path from "path"; +import { fixturesDir, readFixture } from "../test-utils"; + +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", + }); + }); +}); diff --git a/src/csv-loader/tests/parseCsv-combinators.test.ts b/src/csv-loader/tests/parseCsv-combinators.test.ts new file mode 100644 index 0000000..4b10875 --- /dev/null +++ b/src/csv-loader/tests/parseCsv-combinators.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect } from "vitest"; +import { parseCsv } from "../loader"; +import * as path from "path"; +import { fixturesDir } from "../test-utils"; + +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); + }); +}); diff --git a/src/csv-loader/tests/parseCsv-noResolveRefs.test.ts b/src/csv-loader/tests/parseCsv-noResolveRefs.test.ts new file mode 100644 index 0000000..4ec16aa --- /dev/null +++ b/src/csv-loader/tests/parseCsv-noResolveRefs.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect } from "vitest"; +import { parseCsv } from "../loader"; +import * as path from "path"; +import { fixturesDir, readFixture } from "../test-utils"; + +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"); + }); +}); diff --git a/src/csv-loader/tests/parseCsv-refBaseDir.test.ts b/src/csv-loader/tests/parseCsv-refBaseDir.test.ts new file mode 100644 index 0000000..a48bc67 --- /dev/null +++ b/src/csv-loader/tests/parseCsv-refBaseDir.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { parseCsv } from "../loader"; +import { fixturesDir } from "../test-utils"; + +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", + }); + }); +}); diff --git a/src/csv-loader/tests/parseCsv-references.test.ts b/src/csv-loader/tests/parseCsv-references.test.ts new file mode 100644 index 0000000..f013db3 --- /dev/null +++ b/src/csv-loader/tests/parseCsv-references.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from "vitest"; +import { parseCsv } from "../loader"; +import * as path from "path"; +import { fixturesDir, readFixture } from "../test-utils"; +import fs from "fs"; + +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); + } + }); +}); diff --git a/src/csv-loader/tests/parseCsv-reverseRefs.test.ts b/src/csv-loader/tests/parseCsv-reverseRefs.test.ts new file mode 100644 index 0000000..38b5382 --- /dev/null +++ b/src/csv-loader/tests/parseCsv-reverseRefs.test.ts @@ -0,0 +1,269 @@ +import { describe, it, expect } from "vitest"; +import { parseCsv } from "../loader"; +import * as path from "path"; +import { fixturesDir } from "../test-utils"; +import fs from "fs"; + +describe("parseCsv - reverse reference resolution", () => { + it("should resolve reverse reference from comment declaration", () => { + 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); + 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 }); + 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 orderCsvPath = path.join(fixturesDir, "order.csv"); + const orderContent = [ + "id,user,total", + "string,string,number", + "o01,u01,100", + "o02,u01,50", + "o03,u02,75", + ].join("\n"); + fs.writeFileSync(orderCsvPath, orderContent); + + try { + 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"); + const aliceOrders = result.data[0].orders as Record[]; + expect(aliceOrders).toHaveLength(2); + expect(aliceOrders[0]).toEqual({ id: "o01", user: "u01", total: 100 }); + expect(aliceOrders[1]).toEqual({ id: "o02", user: "u01", total: 50 }); + const bobOrders = result.data[1].orders as Record[]; + expect(bobOrders).toHaveLength(1); + expect(bobOrders[0].id).toBe("o03"); + } finally { + fs.unlinkSync(orderCsvPath); + } + }); +}); + +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, + }); + + 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(); + }); +}); diff --git a/src/csv-loader/tests/parseCsv-types.test.ts b/src/csv-loader/tests/parseCsv-types.test.ts new file mode 100644 index 0000000..6e92a18 --- /dev/null +++ b/src/csv-loader/tests/parseCsv-types.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; +import { parseCsv } from "../loader"; +import * as path from "path"; +import { fixturesDir } from "../test-utils"; + +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"); + }); +});