feat: add @type? optional notation

This commit is contained in:
hypercross 2026-04-17 11:41:06 +08:00
parent 1f3a812728
commit 075045223f
5 changed files with 96 additions and 20 deletions

View File

@ -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<string, string>)
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})`;
}

View File

@ -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)', () => {

View File

@ -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,
};
}
}

View File

@ -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 {

View File

@ -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');
}