diff --git a/src/csv-loader/loader.ts b/src/csv-loader/loader.ts index a0bb8f9..0d80348 100644 --- a/src/csv-loader/loader.ts +++ b/src/csv-loader/loader.ts @@ -82,7 +82,11 @@ function resolveReferenceId( } function parseReferenceIds(schema: ReferenceSchema, valueString: string): unknown { - const valueParser = new ReferenceValueParser(valueString.trim()); + const trimmed = valueString.trim(); + if (schema.isOptional && trimmed === '') { + return null; + } + const valueParser = new ReferenceValueParser(trimmed); const ids = valueParser.parseIds(schema.isArray); if (schema.isArray) { return ids; @@ -136,6 +140,7 @@ function parseValueWithReferenceIds( function extractNestedReferenceIds(value: unknown, schema: Schema): unknown { switch (schema.type) { case 'reference': + if (value === null || value === undefined) return value; if (schema.isArray) { const ids = Array.isArray(value) ? value : [value]; return ids.map(id => String(id)); @@ -258,6 +263,7 @@ function resolveNestedReferences( ): unknown { switch (schema.type) { case 'reference': { + if (value === null || value === undefined) return value; const { lookup } = loadReferenceTable(schema, refBaseDir, defaultPrimaryKey, currentFilePath); if (schema.isArray) { const ids = Array.isArray(value) ? value : [value]; @@ -379,9 +385,14 @@ function parseReferenceValue( defaultPrimaryKey: string, currentFilePath: string | undefined ): unknown { + const trimmed = valueString.trim(); + if (schema.isOptional && trimmed === '') { + return null; + } + const { lookup } = loadReferenceTable(schema, refBaseDir, defaultPrimaryKey, currentFilePath); - const valueParser = new ReferenceValueParser(valueString.trim()); + const valueParser = new ReferenceValueParser(trimmed); const ids = valueParser.parseIds(schema.isArray); if (schema.isArray) { @@ -497,10 +508,10 @@ function schemaToTypeString(schema: Schema, resourceNames?: Map) case 'union': return schema.members.map(m => schemaToTypeString(m, resourceNames)).join(' | '); case 'reference': { - // Use the resource name mapping if provided, otherwise capitalize table name const typeName = resourceNames?.get(schema.tableName) || schema.tableName.charAt(0).toUpperCase() + schema.tableName.slice(1); - return schema.isArray ? `readonly ${typeName}[]` : typeName; + const baseType = schema.isArray ? `readonly ${typeName}[]` : typeName; + return schema.isOptional ? `${baseType} | null` : baseType; } case 'array': if (schema.element.type === 'tuple') { @@ -764,6 +775,12 @@ function generateSchemaResolutionCode( switch (schema.type) { case 'reference': { const lookup = lookupVar(schema.tableName); + if (schema.isOptional) { + if (schema.isArray) { + return `(${valueExpr} === null || ${valueExpr} === undefined ? ${valueExpr} : (Array.isArray(${valueExpr}) ? ${valueExpr}.map(id => ${lookup}.get(String(id))) : ${lookup}.get(String(${valueExpr}))))`; + } + return `(${valueExpr} === null || ${valueExpr} === undefined ? ${valueExpr} : ${lookup}.get(String(${valueExpr})))`; + } if (schema.isArray) { return `(Array.isArray(${valueExpr}) ? ${valueExpr}.map(id => ${lookup}.get(String(id))) : ${valueExpr})`; } diff --git a/src/index.test.ts b/src/index.test.ts index a16c651..1ea5c00 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -302,22 +302,37 @@ 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 }); + expect(schema).toEqual({ type: 'reference', tableName: 'users', isArray: false, isOptional: false }); }); it('should parse array reference schema @tablename[]', () => { const schema = parseSchema('@users[]'); - expect(schema).toEqual({ type: 'reference', tableName: 'users', isArray: true }); + expect(schema).toEqual({ type: 'reference', tableName: 'users', isArray: true, isOptional: false }); }); it('should parse reference with hyphens in table name', () => { const schema = parseSchema('@my-table'); - expect(schema).toEqual({ type: 'reference', tableName: 'my-table', isArray: false }); + expect(schema).toEqual({ type: 'reference', tableName: 'my-table', isArray: false, isOptional: false }); }); it('should parse reference with underscores in table name', () => { const schema = parseSchema('@my_table'); - expect(schema).toEqual({ type: 'reference', tableName: 'my_table', isArray: false }); + expect(schema).toEqual({ type: 'reference', tableName: 'my_table', isArray: false, isOptional: false }); + }); + + it('should parse array reference schema @tablename[]', () => { + const schema = parseSchema('@users[]'); + expect(schema).toEqual({ type: 'reference', tableName: 'users', isArray: true, isOptional: false }); + }); + + it('should parse reference with hyphens in table name', () => { + const schema = parseSchema('@my-table'); + expect(schema).toEqual({ type: 'reference', tableName: 'my-table', isArray: false, isOptional: false }); + }); + + it('should parse reference with underscores in table name', () => { + const schema = parseSchema('@my_table'); + expect(schema).toEqual({ type: 'reference', tableName: 'my_table', isArray: false, isOptional: false }); }); it('should throw ParseError for @ without table name', () => { @@ -334,7 +349,7 @@ describe('Reference schemas (parseSchema)', () => { 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 }); + expect(schema.elements[1].schema).toEqual({ type: 'reference', tableName: 'users', isArray: false, isOptional: false }); } }); @@ -344,7 +359,7 @@ describe('Reference schemas (parseSchema)', () => { 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 }); + expect(schema.elements[1].schema).toEqual({ type: 'reference', tableName: 'users', isArray: true, isOptional: false }); } }); @@ -354,7 +369,7 @@ describe('Reference schemas (parseSchema)', () => { if (schema.type === 'tuple') { expect(schema.elements[1]).toEqual({ name: 'author', - schema: { type: 'reference', tableName: 'users', isArray: false }, + schema: { type: 'reference', tableName: 'users', isArray: false, isOptional: false }, }); } }); @@ -365,7 +380,7 @@ describe('Reference schemas (parseSchema)', () => { 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 }); + expect(schema.element.elements[0].schema).toEqual({ type: 'reference', tableName: 'users', isArray: false, isOptional: false }); } } }); @@ -375,7 +390,7 @@ describe('Reference schemas (parseSchema)', () => { 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[0]).toEqual({ type: 'reference', tableName: 'users', isArray: false, isOptional: false }); expect(schema.members[1]).toEqual({ type: 'string' }); } }); @@ -384,7 +399,7 @@ describe('Reference schemas (parseSchema)', () => { 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 }); + expect(schema.members[0]).toEqual({ type: 'reference', tableName: 'users', isArray: true, isOptional: false }); } }); @@ -393,8 +408,8 @@ describe('Reference schemas (parseSchema)', () => { 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 }); + expect(schema.members[0]).toEqual({ type: 'reference', tableName: 'users', isArray: false, isOptional: false }); + expect(schema.members[1]).toEqual({ type: 'reference', tableName: 'parts', isArray: false, isOptional: false }); } }); @@ -404,11 +419,39 @@ describe('Reference schemas (parseSchema)', () => { 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 }); + expect(schema.element.members[0]).toEqual({ type: 'reference', tableName: 'users', isArray: false, isOptional: false }); + expect(schema.element.members[1]).toEqual({ type: 'reference', tableName: 'parts', isArray: false, isOptional: false }); } } }); + + it('should parse optional reference schema @tablename?', () => { + const schema = parseSchema('@users?'); + expect(schema).toEqual({ type: 'reference', tableName: 'users', isArray: false, isOptional: true }); + }); + + it('should parse optional array reference schema @tablename[]?', () => { + const schema = parseSchema('@users[]?'); + expect(schema).toEqual({ type: 'reference', tableName: 'users', isArray: true, isOptional: true }); + }); + + it('should parse optional reference schema @tablename?', () => { + const schema = parseSchema('@users?'); + expect(schema).toEqual({ type: 'reference', tableName: 'users', isArray: false, isOptional: true }); + }); + + it('should parse optional array reference schema @tablename[]?', () => { + const schema = parseSchema('@users[]?'); + expect(schema).toEqual({ type: 'reference', tableName: 'users', isArray: true, isOptional: true }); + }); + + it('should parse optional reference in union @users? | string', () => { + const schema = parseSchema('@users? | string'); + expect(schema.type).toBe('union'); + if (schema.type === 'union') { + expect(schema.members[0]).toEqual({ type: 'reference', tableName: 'users', isArray: false, isOptional: true }); + } + }); }); describe('Reference value parsing (parseValue)', () => { diff --git a/src/parser.ts b/src/parser.ts index 6519a2e..e14d4f9 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -323,18 +323,25 @@ class Parser { // Check for array syntax if (this.consumeStr('[]')) { + this.skipWhitespace(); + const isOptional = this.consumeStr('?'); return { type: 'reference', tableName, isArray: true, + isOptional, }; } + // Check for optional suffix + const isOptional = this.consumeStr('?'); + // Single reference (non-array) return { type: 'reference', tableName, isArray: false, + isOptional, }; } } diff --git a/src/types.ts b/src/types.ts index 7bcb530..e2f8742 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,6 +25,8 @@ export interface ReferenceSchema { tableName: string; /** Whether it's an array reference */ isArray: boolean; + /** Whether it's optional (allows empty input resolving to null) */ + isOptional?: boolean; } export interface StringLiteralSchema { diff --git a/src/validator.ts b/src/validator.ts index 2c67c7c..1380f60 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -312,7 +312,14 @@ class ValueParser { return result; } - private parseReferenceValue(schema: ReferenceSchema): string | string[] { + private parseReferenceValue(schema: ReferenceSchema): string | string[] | null { + if (schema.isOptional) { + this.skipWhitespace(); + if (this.pos >= this.input.length) { + return null; + } + } + if (schema.isArray) { // Parse array of IDs: [id1; id2; id3] let hasOpenBracket = false; @@ -413,7 +420,7 @@ export function createValidator(schema: Schema): (value: unknown) => boolean { if (!Array.isArray(value)) return false; return value.every((item) => createValidator(schema.element)(item)); case 'reference': - // Reference can be a string (single ID) or array of strings (IDs) + if (schema.isOptional && value === null) return true; if (schema.isArray) { return Array.isArray(value) && value.every((id) => typeof id === 'string'); }