feat: support named members in tuples

- Add NamedSchema interface with optional name property
- Update TupleSchema.elements to use NamedSchema[]
- Add parseNamedSchema() to support [x: number; y: number] syntax
- Update validator to parse named members in tuple values
- Fix schemaToTypeString in csv-loader for NamedSchema
- Fix createValidator to handle NamedSchema.schema
- Ensure single named element stays as tuple (not array)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
hyper 2026-03-31 16:36:32 +08:00
parent d056145462
commit 23ee60bc20
5 changed files with 77 additions and 29 deletions

View File

@ -40,12 +40,12 @@ function schemaToTypeString(schema: Schema): string {
return 'boolean'; return 'boolean';
case 'array': case 'array':
if (schema.element.type === 'tuple') { if (schema.element.type === 'tuple') {
const tupleElements = schema.element.elements.map(schemaToTypeString); const tupleElements = schema.element.elements.map((el) => schemaToTypeString(el.schema));
return `[${tupleElements.join(', ')}]`; return `[${tupleElements.join(', ')}]`;
} }
return `${schemaToTypeString(schema.element)}[]`; return `${schemaToTypeString(schema.element)}[]`;
case 'tuple': case 'tuple':
const tupleElements = schema.elements.map(schemaToTypeString); const tupleElements = schema.elements.map((el) => schemaToTypeString(el.schema));
return `[${tupleElements.join(', ')}]`; return `[${tupleElements.join(', ')}]`;
default: default:
return 'unknown'; return 'unknown';

View File

@ -1,4 +1,4 @@
import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema } from './types'; import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, NamedSchema } from './types';
export class ParseError extends Error { export class ParseError extends Error {
constructor(message: string, public position?: number) { constructor(message: string, public position?: number) {
@ -51,7 +51,7 @@ class Parser {
parseSchema(): Schema { parseSchema(): Schema {
this.skipWhitespace(); this.skipWhitespace();
if (this.consumeStr('string')) { if (this.consumeStr('string')) {
if (this.consumeStr('[')) { if (this.consumeStr('[')) {
this.skipWhitespace(); this.skipWhitespace();
@ -62,7 +62,7 @@ class Parser {
} }
return { type: 'string' }; return { type: 'string' };
} }
if (this.consumeStr('number')) { if (this.consumeStr('number')) {
if (this.consumeStr('[')) { if (this.consumeStr('[')) {
this.skipWhitespace(); this.skipWhitespace();
@ -73,7 +73,7 @@ class Parser {
} }
return { type: 'number' }; return { type: 'number' };
} }
if (this.consumeStr('boolean')) { if (this.consumeStr('boolean')) {
if (this.consumeStr('[')) { if (this.consumeStr('[')) {
this.skipWhitespace(); this.skipWhitespace();
@ -86,50 +86,50 @@ class Parser {
} }
if (this.consumeStr('[')) { if (this.consumeStr('[')) {
const elements: Schema[] = []; const elements: NamedSchema[] = [];
this.skipWhitespace(); this.skipWhitespace();
if (this.peek() === ']') { if (this.peek() === ']') {
this.consume(); this.consume();
throw new ParseError('Empty array/tuple not allowed', this.pos); throw new ParseError('Empty array/tuple not allowed', this.pos);
} }
elements.push(this.parseSchema()); elements.push(this.parseNamedSchema());
this.skipWhitespace(); this.skipWhitespace();
if (this.consumeStr(';')) { if (this.consumeStr(';')) {
const remainingElements: Schema[] = []; const remainingElements: NamedSchema[] = [];
while (true) { while (true) {
this.skipWhitespace(); this.skipWhitespace();
remainingElements.push(this.parseSchema()); remainingElements.push(this.parseNamedSchema());
this.skipWhitespace(); this.skipWhitespace();
if (!this.consumeStr(';')) { if (!this.consumeStr(';')) {
break; break;
} }
} }
elements.push(...remainingElements); elements.push(...remainingElements);
} }
this.skipWhitespace(); this.skipWhitespace();
if (!this.consumeStr(']')) { if (!this.consumeStr(']')) {
throw new ParseError('Expected ]', this.pos); throw new ParseError('Expected ]', this.pos);
} }
if (this.consumeStr('[')) { if (this.consumeStr('[')) {
this.skipWhitespace(); this.skipWhitespace();
if (!this.consumeStr(']')) { if (!this.consumeStr(']')) {
throw new ParseError('Expected ]', this.pos); throw new ParseError('Expected ]', this.pos);
} }
if (elements.length === 1) { if (elements.length === 1 && !elements[0].name) {
return { type: 'array', element: elements[0] }; return { type: 'array', element: elements[0].schema };
} }
return { type: 'array', element: { type: 'tuple', elements } }; return { type: 'array', element: { type: 'tuple', elements } };
} }
if (elements.length === 1) { if (elements.length === 1 && !elements[0].name) {
return { type: 'array', element: elements[0] }; return { type: 'array', element: elements[0].schema };
} }
return { type: 'tuple', elements }; return { type: 'tuple', elements };
} }
@ -152,6 +152,34 @@ class Parser {
throw new ParseError(`Unexpected character: ${this.peek()}`, this.pos); throw new ParseError(`Unexpected character: ${this.peek()}`, this.pos);
} }
private parseNamedSchema(): NamedSchema {
this.skipWhitespace();
const startpos = this.pos;
let identifier = '';
while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) {
identifier += this.consume();
}
if (identifier.length === 0) {
throw new ParseError('Expected schema or named schema', this.pos);
}
this.skipWhitespace();
if (this.consumeStr(':')) {
this.skipWhitespace();
const name = identifier;
const schema = this.parseSchema();
return { name, schema };
} else {
this.pos = startpos;
const schema = this.parseSchema();
return { schema };
}
}
} }
export function parseSchema(schemaString: string): Schema { export function parseSchema(schemaString: string): Schema {

View File

@ -18,6 +18,11 @@ const testCases = [
{ schema: 'string', value: 'hello\\[world', description: 'Escaped bracket' }, { schema: 'string', value: 'hello\\[world', description: 'Escaped bracket' },
{ schema: 'string', value: 'hello\\\\world', description: 'Escaped backslash' }, { schema: 'string', value: 'hello\\\\world', description: 'Escaped backslash' },
{ schema: '[string; string]', value: 'hello\\;world; test', description: 'Tuple with escaped semicolon' }, { schema: '[string; string]', value: 'hello\\;world; test', description: 'Tuple with escaped semicolon' },
{ schema: '[x: number; y: number]', value: '[x: 10; y: 20]', description: 'Named tuple' },
{ schema: '[x: number; y: number]', value: 'x: 10; y: 20', description: 'Named tuple without brackets' },
{ schema: '[name: string; age: number; active: boolean]', value: '[name: Alice; age: 30; active: true]', description: 'Named tuple with mixed types' },
{ schema: '[name: string; age: number]', value: 'name: Bob; age: 25', description: 'Named tuple without brackets' },
{ schema: '[point: [x: number; y: number]]', value: '[point: [x: 5; y: 10]]', description: 'Nested named tuple' },
]; ];
testCases.forEach(({ schema, value, description }) => { testCases.forEach(({ schema, value, description }) => {

View File

@ -4,9 +4,14 @@ export interface PrimitiveSchema {
type: SchemaType; type: SchemaType;
} }
export interface NamedSchema {
name?: string;
schema: Schema;
}
export interface TupleSchema { export interface TupleSchema {
type: 'tuple'; type: 'tuple';
elements: Schema[]; elements: NamedSchema[];
} }
export interface ArraySchema { export interface ArraySchema {

View File

@ -1,4 +1,4 @@
import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema } from './types'; import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, NamedSchema } from './types';
import { ParseError } from './parser'; import { ParseError } from './parser';
class ValueParser { class ValueParser {
@ -96,7 +96,7 @@ class ValueParser {
private parseTupleValue(schema: TupleSchema, allowOmitBrackets: boolean): unknown[] { private parseTupleValue(schema: TupleSchema, allowOmitBrackets: boolean): unknown[] {
let hasOpenBracket = false; let hasOpenBracket = false;
if (this.peek() === '[') { if (this.peek() === '[') {
this.consume(); this.consume();
hasOpenBracket = true; hasOpenBracket = true;
@ -105,7 +105,7 @@ class ValueParser {
} }
this.skipWhitespace(); this.skipWhitespace();
if (this.peek() === ']' && hasOpenBracket) { if (this.peek() === ']' && hasOpenBracket) {
this.consume(); this.consume();
return []; return [];
@ -114,7 +114,17 @@ class ValueParser {
const result: unknown[] = []; const result: unknown[] = [];
for (let i = 0; i < schema.elements.length; i++) { for (let i = 0; i < schema.elements.length; i++) {
this.skipWhitespace(); this.skipWhitespace();
result.push(this.parseValue(schema.elements[i], false)); const elementSchema = schema.elements[i];
if (elementSchema.name) {
this.skipWhitespace();
if (!this.consumeStr(`${elementSchema.name}:`)) {
throw new ParseError(`Expected ${elementSchema.name}:`, this.pos);
}
this.skipWhitespace();
}
result.push(this.parseValue(elementSchema.schema, false));
this.skipWhitespace(); this.skipWhitespace();
if (i < schema.elements.length - 1) { if (i < schema.elements.length - 1) {
@ -125,7 +135,7 @@ class ValueParser {
} }
this.skipWhitespace(); this.skipWhitespace();
if (hasOpenBracket) { if (hasOpenBracket) {
if (!this.consumeStr(']')) { if (!this.consumeStr(']')) {
throw new ParseError('Expected ]', this.pos); throw new ParseError('Expected ]', this.pos);
@ -216,7 +226,7 @@ export function createValidator(schema: Schema): (value: unknown) => boolean {
if (!Array.isArray(value)) return false; if (!Array.isArray(value)) return false;
if (value.length !== schema.elements.length) return false; if (value.length !== schema.elements.length) return false;
return schema.elements.every((elementSchema, index) => return schema.elements.every((elementSchema, index) =>
createValidator(elementSchema)(value[index]) createValidator(elementSchema.schema)(value[index])
); );
case 'array': case 'array':
if (!Array.isArray(value)) return false; if (!Array.isArray(value)) return false;