Compare commits

..

No commits in common. "3e768f5c833a138ca4a83b5b737d49b028dcb25b" and "f66f60aa0e5c25d8783126c477f4da2d7c8197ed" have entirely different histories.

11 changed files with 1144 additions and 1070 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,16 @@
import * as fs from "fs"; import * as fs from "fs";
import * as path from "path"; import * as path from "path";
import { parseValue } from "../index.js"; import {
parseValue,
} from "../index.js";
import type { import type {
Schema, Schema,
ReferenceSchema, ReferenceSchema,
ReverseReferenceSchema, ReverseReferenceSchema,
} from "../types.js"; } from "../types.js";
import type { ReferenceFieldInfo } from "./types.js"; import type {
ReferenceFieldInfo,
} from "./types.js";
import { parseCsv } from "./loader.js"; import { parseCsv } from "./loader.js";
/** Cache for loaded referenced tables */ /** Cache for loaded referenced tables */
@ -106,7 +110,12 @@ export function parseReferenceIds(
if (schema.isOptional && trimmed === "") { if (schema.isOptional && trimmed === "") {
return null; return null;
} }
return parseValue(schema, trimmed); const valueParser = new ReferenceValueParser(trimmed);
const ids = valueParser.parseIds(schema.isArray);
if (schema.isArray) {
return ids;
}
return ids[0];
} }
export function parseValueWithReferenceIds( export function parseValueWithReferenceIds(
@ -155,10 +164,7 @@ export function parseValueWithReferenceIds(
} }
} }
export function extractNestedReferenceIds( export function extractNestedReferenceIds(value: unknown, schema: Schema): unknown {
value: unknown,
schema: Schema,
): unknown {
switch (schema.type) { switch (schema.type) {
case "reference": case "reference":
if (value === null || value === undefined) return value; if (value === null || value === undefined) return value;
@ -471,12 +477,101 @@ export function parseReferenceValue(
currentFilePath, currentFilePath,
); );
const ids = parseValue(schema, trimmed) as string | string[] | null; const valueParser = new ReferenceValueParser(trimmed);
if (ids === null) return null; const ids = valueParser.parseIds(schema.isArray);
if (schema.isArray && Array.isArray(ids)) { if (schema.isArray) {
return ids.map((id) => resolveReferenceId(id, lookup, schema.tableName)); return ids.map((id) => resolveReferenceId(id, lookup, schema.tableName));
} }
return resolveReferenceId(ids as string, lookup, schema.tableName); return resolveReferenceId(ids[0], lookup, schema.tableName);
}
class ReferenceValueParser {
private input: string;
private pos: number = 0;
constructor(input: string) {
this.input = input;
}
private peek(): string {
return this.input[this.pos] || "";
}
private consume(): string {
return this.input[this.pos++] || "";
}
private skipWhitespace(): void {
while (this.pos < this.input.length && /\s/.test(this.input[this.pos])) {
this.pos++;
}
}
private consumeStr(str: string): boolean {
if (this.input.slice(this.pos, this.pos + str.length) === str) {
this.pos += str.length;
return true;
}
return false;
}
parseIds(isArray: boolean): string[] {
this.skipWhitespace();
if (isArray) {
// Parse array format: [id1; id2; id3]
if (this.peek() === "[") {
this.consume();
}
this.skipWhitespace();
if (this.peek() === "]") {
this.consume();
return [];
}
const ids: string[] = [];
while (true) {
this.skipWhitespace();
let id = "";
while (
this.pos < this.input.length &&
this.peek() !== ";" &&
this.peek() !== "]"
) {
id += this.consume();
}
const trimmedId = id.trim();
if (trimmedId) {
ids.push(trimmedId);
}
this.skipWhitespace();
if (!this.consumeStr(";")) {
break;
}
}
this.skipWhitespace();
if (this.peek() === "]") {
this.consume();
}
return ids;
} else {
// Parse single ID
let id = "";
while (this.pos < this.input.length) {
const char = this.peek();
if (char === ";" || char === "]" || char === ",") {
break;
}
id += this.consume();
}
return [id.trim()];
}
}
} }

View File

@ -1,112 +0,0 @@
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");
});
});

View File

@ -1,25 +0,0 @@
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<string, unknown>;
const reviewer = result.data[0].reviewer as Record<string, unknown>;
expect(creator).not.toEqual(reviewer);
expect(creator.id).toBe("1");
expect(reviewer.id).toBe("2");
});
});

View File

@ -1,53 +0,0 @@
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",
});
});
});

View File

@ -1,164 +0,0 @@
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<string, unknown>[];
expect(refs).toHaveLength(2);
expect(refs[0]).toEqual({
id: "1",
name: "Alice",
email: "alice@example.com",
});
expect(refs[1]).toEqual({ id: "2", name: "Bob", email: "bob@example.com" });
expect(info[1]).toBe("test");
});
it("should resolve array of tuples containing references", () => {
const csv = [
"id,pairs",
"string,[@users; number][]",
"1,[[1; 10]; [2; 20]]",
].join("\n");
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.data).toHaveLength(1);
const pairs = result.data[0].pairs as unknown[][];
expect(pairs).toHaveLength(2);
expect(pairs[0]).toHaveLength(2);
expect(pairs[0][0]).toEqual({
id: "1",
name: "Alice",
email: "alice@example.com",
});
expect(pairs[0][1]).toBe(10);
expect(pairs[1][0]).toEqual({
id: "2",
name: "Bob",
email: "bob@example.com",
});
expect(pairs[1][1]).toBe(20);
});
it("should resolve reference in union (@users | string)", () => {
const csv = ["id,value", "string,@users | string", "1,1", "2,unknown"].join(
"\n",
);
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.data).toHaveLength(2);
expect(result.data[0].value).toEqual({
id: "1",
name: "Alice",
email: "alice@example.com",
});
expect(result.data[1].value).toBe("unknown");
});
it("should resolve reference in union (@users[] | string)", () => {
const csv = [
"id,value",
"string,@users[] | string",
"1,[1; 2]",
"2,none",
].join("\n");
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.data).toHaveLength(2);
const arr = result.data[0].value as Record<string, unknown>[];
expect(arr).toHaveLength(2);
expect(arr[0]).toEqual({
id: "1",
name: "Alice",
email: "alice@example.com",
});
expect(result.data[1].value).toBe("none");
});
it("should resolve array of reference unions (@users | @parts)[]", () => {
const csv = ["id,items", "string,(@users | @parts)[]", "1,[1; 2]"].join(
"\n",
);
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.data).toHaveLength(1);
});
it("should resolve named tuple with reference and other fields", () => {
const csv = [
"id,details",
"string,[owner: @users; count: number]",
"1,[owner: 1; count: 5]",
].join("\n");
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.data).toHaveLength(1);
const details = result.data[0].details as unknown[];
expect(details).toHaveLength(2);
expect(details[0]).toEqual({
id: "1",
name: "Alice",
email: "alice@example.com",
});
expect(details[1]).toBe(5);
});
});

View File

@ -1,190 +0,0 @@
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");
});
});

View File

@ -1,20 +0,0 @@
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",
});
});
});

View File

@ -1,148 +0,0 @@
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<string, unknown>[];
expect(items).toHaveLength(2);
expect(items[0]).toEqual({ id: "1", name: "Widget", price: 10.5 });
expect(items[1]).toEqual({ id: "2", name: "Gadget", price: 25 });
expect(result.data[0].total).toBe(35.5);
});
it("should resolve mixed single and array references", () => {
const ordersCsv = [
"id,customer,items,total",
"string,@users,@parts[],number",
"1,1,[1; 2],35.5",
].join("\n");
const result = parseCsv(ordersCsv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "orders.csv"),
});
expect(result.data).toHaveLength(1);
expect(result.data[0].customer).toEqual({
id: "1",
name: "Alice",
email: "alice@example.com",
});
const items = result.data[0].items as Record<string, unknown>[];
expect(items).toHaveLength(2);
expect(items[0]).toEqual({ id: "1", name: "Widget", price: 10.5 });
});
it("should throw error for reference to non-existent ID", () => {
const ordersCsv = ["id,customer", "string,@users", "1,999"].join("\n");
expect(() =>
parseCsv(ordersCsv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "orders.csv"),
}),
).toThrow(/not found/);
});
it("should throw error for reference to non-existent table", () => {
const csv = ["id,ref", "string,@nonexistent", "1,someid"].join("\n");
expect(() =>
parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
}),
).toThrow(/Failed to load referenced table/);
});
it("should collect reference table names", () => {
const csv = ["id,customer,items", "string,@users,@parts[]", "1,1,[1]"].join(
"\n",
);
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.references.has("users")).toBe(true);
expect(result.references.has("parts")).toBe(true);
});
it("should use custom primary key", () => {
const nameCsv = [
"code,name",
"string,string",
"US,United States",
"UK,United Kingdom",
].join("\n");
const nameCsvPath = path.join(fixturesDir, "countries.csv");
fs.writeFileSync(nameCsvPath, nameCsv);
try {
const refCsv = ["id,country", "string,@countries", "1,US"].join("\n");
const result = parseCsv(refCsv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "ref.csv"),
defaultPrimaryKey: "code",
});
expect(result.data[0].country).toEqual({
code: "US",
name: "United States",
});
} finally {
fs.unlinkSync(nameCsvPath);
}
});
});

View File

@ -1,269 +0,0 @@
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<string, unknown>[];
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<string, unknown>[];
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<string, unknown>[];
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<string, unknown>[];
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();
});
});

View File

@ -1,78 +0,0 @@
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");
});
});