fix: bug fixes and new tests

This commit is contained in:
hypercross 2026-04-15 13:58:14 +08:00
parent d5fdb69ad7
commit 8343df2316
10 changed files with 968 additions and 61 deletions

View File

@ -0,0 +1,3 @@
id,name,related
string,string,@circular_b[]
1,A,[1]
1 id name related
2 string string @circular_b[]
3 1 A [1]

View File

@ -0,0 +1,3 @@
id,name,related
string,string,@circular_a[]
1,B,[1]
1 id name related
2 string string @circular_a[]
3 1 B [1]

View File

@ -0,0 +1,5 @@
id,name,details,alternatives,status
string,string,[ref: @users; note: string],@users[] | string,"active" | "inactive"
1,Order1,[ref: 1; note: urgent],1,active
2,Order2,[ref: 2; note: normal],normal,inactive
3,Order3,[ref: 3; note: low],"active",active
Can't render this file because it contains an unexpected character in line 2 and column 68.

View File

@ -0,0 +1,5 @@
id,customer,items,total
string,@users,@parts[],number
1,1,[1; 2],35.5
2,2,[3],7.99
3,1,[1; 2; 3],43.49
1 id customer items total
2 string @users @parts[] number
3 1 1 [1; 2] 35.5
4 2 2 [3] 7.99
5 3 1 [1; 2; 3] 43.49

View File

@ -0,0 +1,5 @@
id,name,price
string,string,number
1,Widget,10.5
2,Gadget,25.0
3,Doohickey,7.99
1 id name price
2 string string number
3 1 Widget 10.5
4 2 Gadget 25.0
5 3 Doohickey 7.99

View File

@ -0,0 +1,4 @@
id,name,parent
string,string,@self_ref
1,Root,2
2,Child,1
1 id name parent
2 string string @self_ref
3 1 Root 2
4 2 Child 1

View File

@ -0,0 +1,5 @@
id,name,email
string,string,string
1,Alice,alice@example.com
2,Bob,bob@example.com
3,Charlie,charlie@example.com
1 id name email
2 string string string
3 1 Alice alice@example.com
4 2 Bob bob@example.com
5 3 Charlie charlie@example.com

View File

@ -0,0 +1,562 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { parseCsv } from './loader';
import * as path from 'path';
import * as fs from 'fs';
const fixturesDir = path.join(__dirname, 'fixtures');
function readFixture(name: string): string {
return fs.readFileSync(path.join(fixturesDir, name), 'utf-8');
}
describe('parseCsv - basic parsing', () => {
it('should parse a simple CSV with primitive types', () => {
const csv = [
'name,age,active',
'string,number,boolean',
'Alice,30,true',
'Bob,25,false',
].join('\n');
const result = parseCsv(csv, { emitTypes: false });
expect(result.data).toHaveLength(2);
expect(result.data[0]).toEqual({ name: 'Alice', age: 30, active: true });
expect(result.data[1]).toEqual({ name: 'Bob', age: 25, active: false });
});
it('should parse CSV with int and float columns', () => {
const csv = [
'id,count,price',
'int,int,float',
'1,5,9.99',
'2,3,4.50',
].join('\n');
const result = parseCsv(csv, { emitTypes: false });
expect(result.data).toHaveLength(2);
expect(result.data[0]).toEqual({ id: 1, count: 5, price: 9.99 });
expect(result.data[1]).toEqual({ id: 2, count: 3, price: 4.5 });
});
it('should parse CSV with string literal columns (unquoted in CSV)', () => {
const csv = [
'name,status',
'string,on | off',
'Alice,on',
'Bob,off',
].join('\n');
const result = parseCsv(csv, { emitTypes: false });
expect(result.data).toHaveLength(2);
expect(result.data[0]).toEqual({ name: 'Alice', status: 'on' });
expect(result.data[1]).toEqual({ name: 'Bob', status: 'off' });
});
it('should parse CSV with array columns', () => {
const csv = [
'name,tags',
'string,string[]',
'Alice,[dev; admin]',
'Bob,[user]',
].join('\n');
const result = parseCsv(csv, { emitTypes: false });
expect(result.data).toHaveLength(2);
expect(result.data[0]).toEqual({ name: 'Alice', tags: ['dev', 'admin'] });
expect(result.data[1]).toEqual({ name: 'Bob', tags: ['user'] });
});
it('should parse CSV with tuple columns', () => {
const csv = [
'name,coords',
'string,[number; number]',
'Alice,[1; 2]',
'Bob,[3; 4]',
].join('\n');
const result = parseCsv(csv, { emitTypes: false });
expect(result.data).toHaveLength(2);
expect(result.data[0]).toEqual({ name: 'Alice', coords: [1, 2] });
expect(result.data[1]).toEqual({ name: 'Bob', coords: [3, 4] });
});
it('should require at least 2 rows (header + schema)', () => {
const csv = 'name,age\nstring,number';
expect(() => parseCsv(csv, { emitTypes: false })).not.toThrow();
const csv1Row = 'name,age';
expect(() => parseCsv(csv1Row, { emitTypes: false })).toThrow('at least 2 rows');
});
it('should throw if header and schema count mismatch', () => {
const csv = 'name,age\nstring';
expect(() => parseCsv(csv, { emitTypes: false })).toThrow('does not match');
});
});
describe('parseCsv - reference resolution', () => {
it('should resolve single reference to another CSV table', () => {
const usersCsv = readFixture('users.csv');
const result = parseCsv(usersCsv, { emitTypes: false });
expect(result.data).toHaveLength(3);
expect(result.data[0]).toEqual({ id: '1', name: 'Alice', email: 'alice@example.com' });
});
it('should resolve reference values using parseCsv with referenced tables', () => {
const ordersCsv = [
'id,customer,total',
'string,@users,number',
'1,1,100',
].join('\n');
const result = parseCsv(ordersCsv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, 'orders.csv'),
});
expect(result.data).toHaveLength(1);
expect(result.data[0].customer).toEqual({
id: '1',
name: 'Alice',
email: 'alice@example.com',
});
expect(result.data[0].total).toBe(100);
});
it('should resolve array reference values', () => {
const ordersCsv = [
'id,items,total',
'string,@parts[],number',
'1,[1; 2],35.5',
].join('\n');
const result = parseCsv(ordersCsv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, 'orders.csv'),
});
expect(result.data).toHaveLength(1);
const items = result.data[0].items as Record<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);
}
});
});
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<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);
});
});
describe('parseCsv - type generation', () => {
it('should generate type definition with emitTypes enabled', () => {
const csv = [
'name,age',
'string,number',
'Alice,30',
].join('\n');
const result = parseCsv(csv, { emitTypes: true, resourceName: 'people' });
expect(result.typeDefinition).toBeDefined();
expect(result.typeDefinition).toContain('peopleTable');
expect(result.typeDefinition).toContain('readonly name: string');
expect(result.typeDefinition).toContain('readonly age: number');
});
it('should include reference imports in type definition', () => {
const csv = [
'id,customer',
'string,@users',
'1,1',
].join('\n');
const result = parseCsv(csv, {
emitTypes: true,
resourceName: 'orders',
currentFilePath: path.join(fixturesDir, 'test.csv'),
});
expect(result.typeDefinition).toBeDefined();
expect(result.typeDefinition).toContain('Users');
expect(result.typeDefinition).toContain('users.csv');
});
it('should not generate type definition when emitTypes is false', () => {
const csv = [
'name,age',
'string,number',
'Alice,30',
].join('\n');
const result = parseCsv(csv, { emitTypes: false });
expect(result.typeDefinition).toBeUndefined();
});
it('should generate correct type for reference column', () => {
const csv = [
'id,customer',
'string,@users',
'1,1',
].join('\n');
const result = parseCsv(csv, {
emitTypes: true,
resourceName: 'orders',
currentFilePath: path.join(fixturesDir, 'test.csv'),
});
expect(result.typeDefinition).toContain('readonly customer: Users');
});
it('should generate correct type for array reference column', () => {
const csv = [
'id,items',
'string,@parts[]',
'1,[1]',
].join('\n');
const result = parseCsv(csv, {
emitTypes: true,
resourceName: 'orders',
currentFilePath: path.join(fixturesDir, 'test.csv'),
});
expect(result.typeDefinition).toContain('readonly items: readonly Parts[]');
});
it('should generate correct type for reference in tuple', () => {
const csv = [
'id,info',
'string,[ref: @users; note: string]',
'1,[ref: 1; note: test]',
].join('\n');
const result = parseCsv(csv, {
emitTypes: true,
resourceName: 'data',
currentFilePath: path.join(fixturesDir, 'test.csv'),
});
expect(result.typeDefinition).toContain('Users');
});
});
describe('parseCsv - caching', () => {
it('should cache referenced table and not re-read on subsequent references', () => {
const usersCsv = readFixture('users.csv');
const csv = [
'id,creator,reviewer',
'string,@users,@users',
'1,1,2',
].join('\n');
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, 'test.csv'),
});
const creator = result.data[0].creator as Record<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');
});
});
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

@ -4,6 +4,186 @@ import type { Schema, ReferenceSchema } from '../types.js';
import * as fs from 'fs';
import * as path from 'path';
function hasNestedReferences(schema: Schema): boolean {
switch (schema.type) {
case 'reference':
return true;
case 'tuple':
return schema.elements.some(el => hasNestedReferences(el.schema));
case 'array':
return hasNestedReferences(schema.element);
case 'union':
return schema.members.some(m => hasNestedReferences(m));
default:
return false;
}
}
function loadReferenceTable(
schema: ReferenceSchema,
refBaseDir: string | undefined,
defaultPrimaryKey: string,
currentFilePath: string | undefined
): { lookup: Map<string, Record<string, unknown>>; refTable: Record<string, unknown>[] } {
const baseDir = refBaseDir || (currentFilePath ? path.dirname(currentFilePath) : process.cwd());
const fileName = `${schema.tableName}.csv`;
const refFilePath = path.isAbsolute(fileName)
? fileName
: path.join(baseDir, fileName);
let refTable: Record<string, unknown>[];
if (referenceTableCache.has(refFilePath)) {
refTable = referenceTableCache.get(refFilePath)!;
} else {
if (loadingFiles.has(refFilePath)) {
throw new Error(
`Circular reference detected: table "${schema.tableName}" (${refFilePath}) is already being loaded`
);
}
loadingFiles.add(refFilePath);
try {
const refContent = fs.readFileSync(refFilePath, 'utf-8');
const refResult = parseCsv(refContent, {
currentFilePath: refFilePath,
emitTypes: false,
});
refTable = refResult.data;
referenceTableCache.set(refFilePath, refTable);
} catch (error) {
throw new Error(
`Failed to load referenced table "${schema.tableName}" from ${refFilePath}: ${error instanceof Error ? error.message : String(error)}`
);
} finally {
loadingFiles.delete(refFilePath);
}
}
const lookup = new Map<string, Record<string, unknown>>();
refTable.forEach(row => {
const pkValue = row[defaultPrimaryKey];
if (pkValue !== undefined) {
lookup.set(String(pkValue), row);
}
});
return { lookup, refTable };
}
function resolveReferenceId(
id: string,
lookup: Map<string, Record<string, unknown>>,
tableName: string
): Record<string, unknown> {
const obj = lookup.get(id);
if (!obj) {
throw new Error(`Reference to "${tableName}" with id="${id}" not found`);
}
return obj;
}
function parseValueWithReferences(
valueString: string,
schema: Schema,
refBaseDir: string | undefined,
defaultPrimaryKey: string,
currentFilePath: string | undefined
): unknown {
if (!hasNestedReferences(schema)) {
return parseValue(schema, valueString);
}
switch (schema.type) {
case 'reference':
return parseReferenceValue(schema, valueString, refBaseDir, defaultPrimaryKey, currentFilePath);
case 'tuple': {
const parsed = parseValue(schema, valueString) as unknown[];
return schema.elements.map((el, i) =>
resolveNestedReferences(parsed[i], el.schema, refBaseDir, defaultPrimaryKey, currentFilePath)
);
}
case 'array': {
const parsed = parseValue(schema, valueString) as unknown[];
return parsed.map(item =>
resolveNestedReferences(item, schema.element, refBaseDir, defaultPrimaryKey, currentFilePath)
);
}
case 'union': {
const errors: Error[] = [];
for (const member of schema.members) {
if (hasNestedReferences(member)) {
try {
const parsed = parseValue(member, valueString);
return resolveNestedReferences(parsed, member, refBaseDir, defaultPrimaryKey, currentFilePath);
} catch (e) {
errors.push(e instanceof Error ? e : new Error(String(e)));
}
}
}
if (errors.length > 0 && errors.every(e => /not found|Circular reference|Failed to load/.test(e.message))) {
for (const member of schema.members) {
if (!hasNestedReferences(member)) {
try {
return parseValue(member, valueString);
} catch {}
}
}
}
return parseValue(schema, valueString);
}
default:
return parseValue(schema, valueString);
}
}
function resolveNestedReferences(
value: unknown,
schema: Schema,
refBaseDir: string | undefined,
defaultPrimaryKey: string,
currentFilePath: string | undefined
): unknown {
switch (schema.type) {
case 'reference': {
const { lookup } = loadReferenceTable(schema, refBaseDir, defaultPrimaryKey, currentFilePath);
if (schema.isArray) {
const ids = Array.isArray(value) ? value : [value];
return ids.map(id => resolveReferenceId(String(id), lookup, schema.tableName));
}
return resolveReferenceId(String(value), lookup, schema.tableName);
}
case 'tuple': {
if (!Array.isArray(value)) return value;
return schema.elements.map((el, i) =>
resolveNestedReferences(value[i], el.schema, refBaseDir, defaultPrimaryKey, currentFilePath)
);
}
case 'array': {
if (!Array.isArray(value)) return value;
return value.map(item =>
resolveNestedReferences(item, schema.element, refBaseDir, defaultPrimaryKey, currentFilePath)
);
}
case 'union': {
const errors: Error[] = [];
for (const member of schema.members) {
if (hasNestedReferences(member)) {
try {
return resolveNestedReferences(value, member, refBaseDir, defaultPrimaryKey, currentFilePath);
} catch (e) {
errors.push(e instanceof Error ? e : new Error(String(e)));
}
}
}
if (errors.length > 0) {
throw errors[0];
}
return value;
}
default:
return value;
}
}
export interface CsvLoaderOptions {
delimiter?: string;
quote?: string;
@ -52,6 +232,9 @@ interface PropertyConfig {
/** Cache for loaded referenced tables */
const referenceTableCache = new Map<string, Record<string,unknown>[]>();
/** Set of file paths currently being loaded (to detect circular references) */
const loadingFiles = new Set<string>();
/**
* Parse and resolve a reference value.
* Loads the referenced table and replaces IDs with actual objects.
@ -63,70 +246,16 @@ function parseReferenceValue(
defaultPrimaryKey: string,
currentFilePath: string | undefined
): unknown {
// Determine the directory to search for referenced files
const baseDir = refBaseDir || (currentFilePath ? path.dirname(currentFilePath) : process.cwd());
const { lookup } = loadReferenceTable(schema, refBaseDir, defaultPrimaryKey, currentFilePath);
// Build the referenced file path
const fileName = `${schema.tableName}.csv`;
const refFilePath = path.isAbsolute(fileName)
? fileName
: path.join(baseDir, fileName);
// Load the referenced table (use cache if already loaded)
let refTable: Record<string, unknown>[];
if (referenceTableCache.has(refFilePath)) {
refTable = referenceTableCache.get(refFilePath)!;
} else {
try {
const refContent = fs.readFileSync(refFilePath, 'utf-8');
const refResult = parseCsv(refContent, {
currentFilePath: refFilePath,
emitTypes: false,
});
refTable = refResult.data;
referenceTableCache.set(refFilePath, refTable);
} catch (error) {
throw new Error(
`Failed to load referenced table "${schema.tableName}" from ${refFilePath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
// Build a lookup map by primary key
const primaryKeyMap = new Map<string, Record<string, unknown>>();
refTable.forEach(row => {
const pkValue = row[defaultPrimaryKey];
if (pkValue !== undefined) {
primaryKeyMap.set(String(pkValue), row);
}
});
// Parse the value string to extract IDs
const valueParser = new ReferenceValueParser(valueString.trim());
const ids = valueParser.parseIds(schema.isArray);
// Resolve IDs to actual objects
if (schema.isArray) {
return ids.map(id => {
const obj = primaryKeyMap.get(id);
if (!obj) {
throw new Error(
`Reference to "${schema.tableName}" with ${defaultPrimaryKey}="${id}" not found`
);
}
return obj;
});
} else {
// Single reference (first ID if array provided)
const id = ids[0];
const obj = primaryKeyMap.get(id);
if (!obj) {
throw new Error(
`Reference to "${schema.tableName}" with ${defaultPrimaryKey}="${id}" not found`
);
}
return obj;
return ids.map(id => resolveReferenceId(id, lookup, schema.tableName));
}
return resolveReferenceId(ids[0], lookup, schema.tableName);
}
/**
@ -381,15 +510,18 @@ export function parseCsv(
parser: (valueString: string) => parseValue(schema, valueString),
};
// Check if it's a reference type
if (schema.type === 'reference') {
config.isReference = true;
config.referenceTableName = schema.tableName;
config.referenceIsArray = schema.isArray;
// Override parser for reference fields
config.parser = (valueString: string) => {
return parseReferenceValue(schema, valueString, refBaseDir, defaultPrimaryKey, options.currentFilePath);
};
} else if (hasNestedReferences(schema)) {
config.isReference = true;
config.parser = (valueString: string) => {
return parseValueWithReferences(valueString, schema, refBaseDir, defaultPrimaryKey, options.currentFilePath);
};
}
return config;

View File

@ -300,6 +300,189 @@ describe('parseSchema', () => {
});
});
describe('Reference schemas (parseSchema)', () => {
it('should parse single reference schema @tablename', () => {
const schema = parseSchema('@users');
expect(schema).toEqual({ type: 'reference', tableName: 'users', isArray: false });
});
it('should parse array reference schema @tablename[]', () => {
const schema = parseSchema('@users[]');
expect(schema).toEqual({ type: 'reference', tableName: 'users', isArray: true });
});
it('should parse reference with hyphens in table name', () => {
const schema = parseSchema('@my-table');
expect(schema).toEqual({ type: 'reference', tableName: 'my-table', isArray: false });
});
it('should parse reference with underscores in table name', () => {
const schema = parseSchema('@my_table');
expect(schema).toEqual({ type: 'reference', tableName: 'my_table', isArray: false });
});
it('should throw ParseError for @ without table name', () => {
expect(() => parseSchema('@')).toThrow(ParseError);
});
it('should throw ParseError for @ followed by non-identifier', () => {
expect(() => parseSchema('@ ')).toThrow(ParseError);
});
it('should parse reference inside tuple [string; @users]', () => {
const schema = parseSchema('[string; @users]');
expect(schema.type).toBe('tuple');
if (schema.type === 'tuple') {
expect(schema.elements).toHaveLength(2);
expect(schema.elements[0].schema).toEqual({ type: 'string' });
expect(schema.elements[1].schema).toEqual({ type: 'reference', tableName: 'users', isArray: false });
}
});
it('should parse reference array inside tuple [string; @users[]]', () => {
const schema = parseSchema('[string; @users[]]');
expect(schema.type).toBe('tuple');
if (schema.type === 'tuple') {
expect(schema.elements).toHaveLength(2);
expect(schema.elements[0].schema).toEqual({ type: 'string' });
expect(schema.elements[1].schema).toEqual({ type: 'reference', tableName: 'users', isArray: true });
}
});
it('should parse named reference in tuple [name: string; author: @users]', () => {
const schema = parseSchema('[name: string; author: @users]');
expect(schema.type).toBe('tuple');
if (schema.type === 'tuple') {
expect(schema.elements[1]).toEqual({
name: 'author',
schema: { type: 'reference', tableName: 'users', isArray: false },
});
}
});
it('should parse array of tuples containing reference [@users; number][]', () => {
const schema = parseSchema('[@users; number][]');
expect(schema.type).toBe('array');
if (schema.type === 'array') {
expect(schema.element.type).toBe('tuple');
if (schema.element.type === 'tuple') {
expect(schema.element.elements[0].schema).toEqual({ type: 'reference', tableName: 'users', isArray: false });
}
}
});
it('should parse reference in union @users | string', () => {
const schema = parseSchema('@users | string');
expect(schema.type).toBe('union');
if (schema.type === 'union') {
expect(schema.members).toHaveLength(2);
expect(schema.members[0]).toEqual({ type: 'reference', tableName: 'users', isArray: false });
expect(schema.members[1]).toEqual({ type: 'string' });
}
});
it('should parse array reference in union @users[] | string', () => {
const schema = parseSchema('@users[] | string');
expect(schema.type).toBe('union');
if (schema.type === 'union') {
expect(schema.members[0]).toEqual({ type: 'reference', tableName: 'users', isArray: true });
}
});
it('should parse reference inside parenthesized union (@users | @parts)', () => {
const schema = parseSchema('(@users | @parts)');
expect(schema.type).toBe('union');
if (schema.type === 'union') {
expect(schema.members).toHaveLength(2);
expect(schema.members[0]).toEqual({ type: 'reference', tableName: 'users', isArray: false });
expect(schema.members[1]).toEqual({ type: 'reference', tableName: 'parts', isArray: false });
}
});
it('should parse array of reference unions (@users | @parts)[]', () => {
const schema = parseSchema('(@users | @parts)[]');
expect(schema.type).toBe('array');
if (schema.type === 'array') {
expect(schema.element.type).toBe('union');
if (schema.element.type === 'union') {
expect(schema.element.members[0]).toEqual({ type: 'reference', tableName: 'users', isArray: false });
expect(schema.element.members[1]).toEqual({ type: 'reference', tableName: 'parts', isArray: false });
}
}
});
});
describe('Reference value parsing (parseValue)', () => {
it('should parse single reference ID', () => {
const schema: import('./types').ReferenceSchema = { type: 'reference', tableName: 'users', isArray: false };
const result = parseValue(schema, '42');
expect(result).toBe('42');
});
it('should parse array reference IDs with brackets', () => {
const schema: import('./types').ReferenceSchema = { type: 'reference', tableName: 'users', isArray: true };
const result = parseValue(schema, '[1; 2; 3]');
expect(result).toEqual(['1', '2', '3']);
});
it('should parse array reference IDs without brackets', () => {
const schema: import('./types').ReferenceSchema = { type: 'reference', tableName: 'users', isArray: true };
const result = parseValue(schema, '1; 2; 3');
expect(result).toEqual(['1', '2', '3']);
});
it('should parse empty array reference', () => {
const schema: import('./types').ReferenceSchema = { type: 'reference', tableName: 'users', isArray: true };
const result = parseValue(schema, '[]');
expect(result).toEqual([]);
});
it('should parse single reference ID with spaces', () => {
const schema: import('./types').ReferenceSchema = { type: 'reference', tableName: 'users', isArray: false };
const result = parseValue(schema, ' 42 ');
expect(result).toBe('42');
});
it('should parse array reference IDs with spaces', () => {
const schema: import('./types').ReferenceSchema = { type: 'reference', tableName: 'users', isArray: true };
const result = parseValue(schema, '[ 1 ; 2 ; 3 ]');
expect(result).toEqual(['1', '2', '3']);
});
it('should parse string IDs in reference', () => {
const schema: import('./types').ReferenceSchema = { type: 'reference', tableName: 'users', isArray: false };
const result = parseValue(schema, 'abc-123');
expect(result).toBe('abc-123');
});
});
describe('Reference validation (createValidator)', () => {
it('should validate single reference (string ID)', () => {
const schema: import('./types').ReferenceSchema = { type: 'reference', tableName: 'users', isArray: false };
const validator = createValidator(schema);
expect(validator('1')).toBe(true);
expect(validator('abc')).toBe(true);
expect(validator(42)).toBe(false);
expect(validator(true)).toBe(false);
});
it('should validate array reference (array of string IDs)', () => {
const schema: import('./types').ReferenceSchema = { type: 'reference', tableName: 'users', isArray: true };
const validator = createValidator(schema);
expect(validator(['1', '2'])).toBe(true);
expect(validator([])).toBe(true);
expect(validator(['1'])).toBe(true);
expect(validator([1, 2])).toBe(false);
expect(validator('1')).toBe(false);
});
it('should allow string array for single reference (backward compat)', () => {
const schema: import('./types').ReferenceSchema = { type: 'reference', tableName: 'users', isArray: false };
const validator = createValidator(schema);
expect(validator(['1', '2'])).toBe(true);
});
});
describe('Error handling', () => {
it('should throw ParseError for invalid schema', () => {
expect(() => parseSchema('')).toThrow(ParseError);