Add reverse reference schema (~tablename(foreignKey))

Support reverse references via ~tablename(foreignKey) syntax,
complementing forward @tablename references. Includes parser,
validator, and CSV loader integration with the new
ReverseReferenceSchema type.
This commit is contained in:
2026-04-18 22:47:58 +08:00
parent 0954dcf594
commit e76ae79b2d
7 changed files with 2489 additions and 1249 deletions
File diff suppressed because it is too large Load Diff
+595 -186
View File
File diff suppressed because it is too large Load Diff
+568 -321
View File
File diff suppressed because it is too large Load Diff
+32 -6
View File
@@ -1,7 +1,17 @@
import { parseSchema } from './parser'; import { parseSchema } from "./parser";
import { parseValue, createValidator, schemaToTypeString } from './validator'; import { parseValue, createValidator, schemaToTypeString } from "./validator";
import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, ReferenceSchema, StringLiteralSchema, UnionSchema, ParsedSchema } from './types'; import type {
import { ParseError } from './parser'; Schema,
PrimitiveSchema,
TupleSchema,
ArraySchema,
ReferenceSchema,
ReverseReferenceSchema,
StringLiteralSchema,
UnionSchema,
ParsedSchema,
} from "./types";
import { ParseError } from "./parser";
export function defineSchema(schemaString: string): ParsedSchema { export function defineSchema(schemaString: string): ParsedSchema {
const schema = parseSchema(schemaString); const schema = parseSchema(schemaString);
@@ -14,5 +24,21 @@ export function defineSchema(schemaString: string): ParsedSchema {
}; };
} }
export { parseSchema, parseValue, createValidator, ParseError, schemaToTypeString }; export {
export type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, ReferenceSchema, StringLiteralSchema, UnionSchema, ParsedSchema }; parseSchema,
parseValue,
createValidator,
ParseError,
schemaToTypeString,
};
export type {
Schema,
PrimitiveSchema,
TupleSchema,
ArraySchema,
ReferenceSchema,
ReverseReferenceSchema,
StringLiteralSchema,
UnionSchema,
ParsedSchema,
};
+168 -87
View File
@@ -1,9 +1,24 @@
import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, NamedSchema, ReferenceSchema, StringLiteralSchema, UnionSchema } from './types'; import type {
Schema,
PrimitiveSchema,
TupleSchema,
ArraySchema,
NamedSchema,
ReferenceSchema,
ReverseReferenceSchema,
StringLiteralSchema,
UnionSchema,
} from "./types";
export class ParseError extends Error { export class ParseError extends Error {
constructor(message: string, public position?: number) { constructor(
super(position !== undefined ? `${message} at position ${position}` : message); message: string,
this.name = 'ParseError'; public position?: number,
) {
super(
position !== undefined ? `${message} at position ${position}` : message,
);
this.name = "ParseError";
} }
} }
@@ -23,11 +38,11 @@ class Parser {
} }
private peek(): string { private peek(): string {
return this.input[this.pos] || ''; return this.input[this.pos] || "";
} }
private consume(): string { private consume(): string {
return this.input[this.pos++] || ''; return this.input[this.pos++] || "";
} }
private skipWhitespace(): void { private skipWhitespace(): void {
@@ -64,17 +79,17 @@ class Parser {
this.skipWhitespace(); this.skipWhitespace();
// Check for array suffix: type[] // Check for array suffix: type[]
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);
} }
schema = { type: 'array', element: schema }; schema = { type: "array", element: schema };
this.skipWhitespace(); this.skipWhitespace();
} }
// Check for union type (| symbol) // Check for union type (| symbol)
if (this.consumeStr('|')) { if (this.consumeStr("|")) {
const members: Schema[] = [schema]; const members: Schema[] = [schema];
while (true) { while (true) {
@@ -83,14 +98,14 @@ class Parser {
members.push(member); members.push(member);
this.skipWhitespace(); this.skipWhitespace();
if (!this.consumeStr('|')) { if (!this.consumeStr("|")) {
break; break;
} }
} }
return { return {
type: 'union', type: "union",
members members,
}; };
} }
@@ -101,23 +116,23 @@ class Parser {
this.skipWhitespace(); this.skipWhitespace();
// Check for parentheses (grouping) // Check for parentheses (grouping)
if (this.consumeStr('(')) { if (this.consumeStr("(")) {
this.skipWhitespace(); this.skipWhitespace();
const schema = this.parseSchema(); // Recursive call for nested unions const schema = this.parseSchema(); // Recursive call for nested unions
this.skipWhitespace(); this.skipWhitespace();
if (!this.consumeStr(')')) { if (!this.consumeStr(")")) {
throw new ParseError('Expected )', this.pos); throw new ParseError("Expected )", this.pos);
} }
// Check if there's an array suffix: (union)[] // Check if there's an array suffix: (union)[]
this.skipWhitespace(); this.skipWhitespace();
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);
} }
return { type: 'array', element: schema }; return { type: "array", element: schema };
} }
return schema; return schema;
@@ -128,86 +143,91 @@ class Parser {
return this.parseStringLiteralSchema(); return this.parseStringLiteralSchema();
} }
// Check for reverse reference syntax: ~tablename(foreignKey)
if (this.consumeStr("~")) {
return this.parseReverseReferenceSchema();
}
// Check for reference syntax: @tablename[] // Check for reference syntax: @tablename[]
if (this.consumeStr('@')) { if (this.consumeStr("@")) {
return this.parseReferenceSchema(); return this.parseReferenceSchema();
} }
if (this.consumeStr('string')) { if (this.consumeStr("string")) {
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);
} }
return { type: 'array', element: { type: 'string' } }; return { type: "array", element: { type: "string" } };
} }
return { type: 'string' }; return { type: "string" };
} }
if (this.consumeStr('number')) { if (this.consumeStr("number")) {
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);
} }
return { type: 'array', element: { type: 'number' } }; return { type: "array", element: { type: "number" } };
} }
return { type: 'number' }; return { type: "number" };
} }
if (this.consumeStr('int')) { if (this.consumeStr("int")) {
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);
} }
return { type: 'array', element: { type: 'int' } }; return { type: "array", element: { type: "int" } };
} }
return { type: 'int' }; return { type: "int" };
} }
if (this.consumeStr('float')) { if (this.consumeStr("float")) {
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);
} }
return { type: 'array', element: { type: 'float' } }; return { type: "array", element: { type: "float" } };
} }
return { type: 'float' }; return { type: "float" };
} }
if (this.consumeStr('boolean')) { if (this.consumeStr("boolean")) {
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);
} }
return { type: 'array', element: { type: 'boolean' } }; return { type: "array", element: { type: "boolean" } };
} }
return { type: 'boolean' }; return { type: "boolean" };
} }
if (this.consumeStr('[')) { if (this.consumeStr("[")) {
const elements: NamedSchema[] = []; 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.parseNamedSchema()); elements.push(this.parseNamedSchema());
this.skipWhitespace(); this.skipWhitespace();
if (this.consumeStr(';')) { if (this.consumeStr(";")) {
const remainingElements: NamedSchema[] = []; const remainingElements: NamedSchema[] = [];
while (true) { while (true) {
this.skipWhitespace(); this.skipWhitespace();
remainingElements.push(this.parseNamedSchema()); remainingElements.push(this.parseNamedSchema());
this.skipWhitespace(); this.skipWhitespace();
if (!this.consumeStr(';')) { if (!this.consumeStr(";")) {
break; break;
} }
} }
@@ -216,57 +236,67 @@ class Parser {
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 && !elements[0].name) { if (elements.length === 1 && !elements[0].name) {
return { type: 'array', element: elements[0].schema }; 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 && !elements[0].name) { if (elements.length === 1 && !elements[0].name) {
return { type: 'array', element: elements[0].schema }; return { type: "array", element: elements[0].schema };
} }
return { type: 'tuple', elements }; return { type: "tuple", elements };
} }
throw new ParseError(`Unknown type: ${this.peek() || 'end of input'}`, this.pos); throw new ParseError(
`Unknown type: ${this.peek() || "end of input"}`,
this.pos,
);
} }
private parseStringLiteralSchema(): Schema { private parseStringLiteralSchema(): Schema {
const value = this.parseStringLiteral(); const value = this.parseStringLiteral();
return { return {
type: 'stringLiteral', type: "stringLiteral",
value value,
}; };
} }
private parseStringLiteral(): string { private parseStringLiteral(): string {
const quote = this.peek(); const quote = this.peek();
if (quote !== '"' && quote !== "'") { if (quote !== '"' && quote !== "'") {
throw new ParseError('Expected string literal with quotes', this.pos); throw new ParseError("Expected string literal with quotes", this.pos);
} }
this.consume(); // Consume opening quote this.consume(); // Consume opening quote
let value = ''; let value = "";
while (this.pos < this.input.length) { while (this.pos < this.input.length) {
const char = this.peek(); const char = this.peek();
if (char === '\\') { if (char === "\\") {
this.consume(); this.consume();
const nextChar = this.consume(); const nextChar = this.consume();
if (nextChar === '"' || nextChar === "'" || nextChar === '\\' || if (
nextChar === '|' || nextChar === ';' || nextChar === '(' || nextChar === ')') { nextChar === '"' ||
nextChar === "'" ||
nextChar === "\\" ||
nextChar === "|" ||
nextChar === ";" ||
nextChar === "(" ||
nextChar === ")"
) {
value += nextChar; value += nextChar;
} else { } else {
value += '\\' + nextChar; value += "\\" + nextChar;
} }
} else if (char === quote) { } else if (char === quote) {
this.consume(); // Consume closing quote this.consume(); // Consume closing quote
@@ -276,14 +306,14 @@ class Parser {
} }
} }
throw new ParseError('Unterminated string literal', this.pos); throw new ParseError("Unterminated string literal", this.pos);
} }
private parseNamedSchema(): NamedSchema { private parseNamedSchema(): NamedSchema {
this.skipWhitespace(); this.skipWhitespace();
const startpos = this.pos; const startpos = this.pos;
let identifier = ''; let identifier = "";
while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) { while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) {
identifier += this.consume(); identifier += this.consume();
@@ -296,7 +326,7 @@ class Parser {
this.skipWhitespace(); this.skipWhitespace();
if (this.consumeStr(':')) { if (this.consumeStr(":")) {
this.skipWhitespace(); this.skipWhitespace();
const name = identifier; const name = identifier;
const schema = this.parseSchema(); const schema = this.parseSchema();
@@ -310,23 +340,23 @@ class Parser {
private parseReferenceSchema(): Schema { private parseReferenceSchema(): Schema {
// Parse table name // Parse table name
let tableName = ''; let tableName = "";
while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) { while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) {
tableName += this.consume(); tableName += this.consume();
} }
if (tableName.length === 0) { if (tableName.length === 0) {
throw new ParseError('Expected table name after @', this.pos); throw new ParseError("Expected table name after @", this.pos);
} }
this.skipWhitespace(); this.skipWhitespace();
// Check for array syntax // Check for array syntax
if (this.consumeStr('[]')) { if (this.consumeStr("[]")) {
this.skipWhitespace(); this.skipWhitespace();
const isOptional = this.consumeStr('?'); const isOptional = this.consumeStr("?");
return { return {
type: 'reference', type: "reference",
tableName, tableName,
isArray: true, isArray: true,
isOptional, isOptional,
@@ -334,16 +364,67 @@ class Parser {
} }
// Check for optional suffix // Check for optional suffix
const isOptional = this.consumeStr('?'); const isOptional = this.consumeStr("?");
// Single reference (non-array) // Single reference (non-array)
return { return {
type: 'reference', type: "reference",
tableName, tableName,
isArray: false, isArray: false,
isOptional, isOptional,
}; };
} }
private parseReverseReferenceSchema(): Schema {
// Parse table name
let tableName = "";
while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) {
tableName += this.consume();
}
if (tableName.length === 0) {
throw new ParseError("Expected table name after ~", this.pos);
}
this.skipWhitespace();
// Parse (foreignKey)
if (!this.consumeStr("(")) {
throw new ParseError(
"Expected ( after reverse reference table name",
this.pos,
);
}
this.skipWhitespace();
let foreignKey = "";
while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) {
foreignKey += this.consume();
}
if (foreignKey.length === 0) {
throw new ParseError("Expected foreign key name inside ()", this.pos);
}
this.skipWhitespace();
if (!this.consumeStr(")")) {
throw new ParseError("Expected ) after foreign key name", this.pos);
}
this.skipWhitespace();
// Check for optional suffix
const isOptional = this.consumeStr("?");
return {
type: "reverseReference",
tableName,
foreignKey,
isOptional,
};
}
} }
export function parseSchema(schemaString: string): Schema { export function parseSchema(schemaString: string): Schema {
@@ -351,7 +432,7 @@ export function parseSchema(schemaString: string): Schema {
const schema = parser.parseSchema(); const schema = parser.parseSchema();
if (parser.getPosition() < parser.getInputLength()) { if (parser.getPosition() < parser.getInputLength()) {
throw new ParseError('Unexpected input after schema', parser.getPosition()); throw new ParseError("Unexpected input after schema", parser.getPosition());
} }
return schema; return schema;
+26 -9
View File
@@ -1,4 +1,4 @@
export type SchemaType = 'string' | 'number' | 'int' | 'float' | 'boolean'; export type SchemaType = "string" | "number" | "int" | "float" | "boolean";
export interface PrimitiveSchema { export interface PrimitiveSchema {
type: SchemaType; type: SchemaType;
@@ -10,17 +10,17 @@ export interface NamedSchema {
} }
export interface TupleSchema { export interface TupleSchema {
type: 'tuple'; type: "tuple";
elements: NamedSchema[]; elements: NamedSchema[];
} }
export interface ArraySchema { export interface ArraySchema {
type: 'array'; type: "array";
element: Schema; element: Schema;
} }
export interface ReferenceSchema { export interface ReferenceSchema {
type: 'reference'; type: "reference";
/** Referenced table name (e.g., 'parts' from '@parts[]') */ /** Referenced table name (e.g., 'parts' from '@parts[]') */
tableName: string; tableName: string;
/** Whether it's an array reference */ /** Whether it's an array reference */
@@ -29,17 +29,34 @@ export interface ReferenceSchema {
isOptional?: boolean; isOptional?: boolean;
} }
export interface ReverseReferenceSchema {
type: "reverseReference";
/** Referenced table name (e.g., 'orders' from '~orders(user)') */
tableName: string;
/** The foreign key field name in the referenced table (e.g., 'user' from '~orders(user)') */
foreignKey: string;
/** Whether it's optional (null if no matches, instead of empty array) */
isOptional?: boolean;
}
export interface StringLiteralSchema { export interface StringLiteralSchema {
type: 'stringLiteral'; type: "stringLiteral";
value: string; // The literal string value (without quotes) value: string; // The literal string value (without quotes)
} }
export interface UnionSchema { export interface UnionSchema {
type: 'union'; type: "union";
members: Schema[]; // Union members members: Schema[]; // Union members
} }
export type Schema = PrimitiveSchema | TupleSchema | ArraySchema | ReferenceSchema | StringLiteralSchema | UnionSchema; export type Schema =
| PrimitiveSchema
| TupleSchema
| ArraySchema
| ReferenceSchema
| ReverseReferenceSchema
| StringLiteralSchema
| UnionSchema;
export interface ParsedSchema { export interface ParsedSchema {
schema: Schema; schema: Schema;
+171 -107
View File
@@ -1,5 +1,15 @@
import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, NamedSchema, ReferenceSchema, StringLiteralSchema, UnionSchema } from './types'; import type {
import { ParseError } from './parser'; Schema,
PrimitiveSchema,
TupleSchema,
ArraySchema,
NamedSchema,
ReferenceSchema,
ReverseReferenceSchema,
StringLiteralSchema,
UnionSchema,
} from "./types";
import { ParseError } from "./parser";
class ValueParser { class ValueParser {
private input: string; private input: string;
@@ -10,11 +20,11 @@ class ValueParser {
} }
private peek(): string { private peek(): string {
return this.input[this.pos] || ''; return this.input[this.pos] || "";
} }
private consume(): string { private consume(): string {
return this.input[this.pos++] || ''; return this.input[this.pos++] || "";
} }
private skipWhitespace(): void { private skipWhitespace(): void {
@@ -35,46 +45,58 @@ class ValueParser {
this.skipWhitespace(); this.skipWhitespace();
switch (schema.type) { switch (schema.type) {
case 'string': case "string":
return this.parseStringValue(); return this.parseStringValue();
case 'number': case "number":
return this.parseNumberValue(); return this.parseNumberValue();
case 'int': case "int":
return this.parseIntValue(); return this.parseIntValue();
case 'float': case "float":
return this.parseFloatValue(); return this.parseFloatValue();
case 'boolean': case "boolean":
return this.parseBooleanValue(); return this.parseBooleanValue();
case 'stringLiteral': case "stringLiteral":
return this.parseStringLiteralValue(schema); return this.parseStringLiteralValue(schema);
case 'union': case "union":
return this.parseUnionValue(schema); return this.parseUnionValue(schema);
case 'tuple': case "tuple":
return this.parseTupleValue(schema, allowOmitBrackets); return this.parseTupleValue(schema, allowOmitBrackets);
case 'array': case "array":
return this.parseArrayValue(schema, allowOmitBrackets); return this.parseArrayValue(schema, allowOmitBrackets);
case 'reference': case "reference":
// Reference values are parsed as strings (IDs) initially, resolved later // Reference values are parsed as strings (IDs) initially, resolved later
return this.parseReferenceValue(schema); return this.parseReferenceValue(schema);
case "reverseReference":
// Reverse references are derived fields, not stored in CSV cells
// They resolve to null at parse time; actual resolution happens in the loader
return null;
default: default:
throw new ParseError(`Unknown schema type: ${(schema as { type: string }).type}`, this.pos); throw new ParseError(
`Unknown schema type: ${(schema as { type: string }).type}`,
this.pos,
);
} }
} }
private parseStringValue(): string { private parseStringValue(): string {
let result = ''; let result = "";
while (this.pos < this.input.length) { while (this.pos < this.input.length) {
const char = this.peek(); const char = this.peek();
if (char === '\\') { if (char === "\\") {
this.consume(); this.consume();
const nextChar = this.consume(); const nextChar = this.consume();
if (nextChar === ';' || nextChar === '[' || nextChar === ']' || nextChar === '\\') { if (
nextChar === ";" ||
nextChar === "[" ||
nextChar === "]" ||
nextChar === "\\"
) {
result += nextChar; result += nextChar;
} else { } else {
result += '\\' + nextChar; result += "\\" + nextChar;
} }
} else if (char === ';' || char === ']') { } else if (char === ";" || char === "]") {
break; break;
} else { } else {
result += this.consume(); result += this.consume();
@@ -84,28 +106,28 @@ class ValueParser {
} }
private parseNumberValue(): number { private parseNumberValue(): number {
let numStr = ''; let numStr = "";
while (this.pos < this.input.length && /[\d.\-+eE]/.test(this.peek())) { while (this.pos < this.input.length && /[\d.\-+eE]/.test(this.peek())) {
numStr += this.consume(); numStr += this.consume();
} }
const num = parseFloat(numStr); const num = parseFloat(numStr);
if (isNaN(num)) { if (isNaN(num)) {
throw new ParseError('Invalid number', this.pos - numStr.length); throw new ParseError("Invalid number", this.pos - numStr.length);
} }
return num; return num;
} }
private parseIntValue(): number { private parseIntValue(): number {
let numStr = ''; let numStr = "";
while (this.pos < this.input.length && /[\d.\-+eE]/.test(this.peek())) { while (this.pos < this.input.length && /[\d.\-+eE]/.test(this.peek())) {
numStr += this.consume(); numStr += this.consume();
} }
const num = parseFloat(numStr); const num = parseFloat(numStr);
if (isNaN(num)) { if (isNaN(num)) {
throw new ParseError('Invalid number', this.pos - numStr.length); throw new ParseError("Invalid number", this.pos - numStr.length);
} }
if (!Number.isInteger(num)) { if (!Number.isInteger(num)) {
throw new ParseError('Expected integer value', this.pos - numStr.length); throw new ParseError("Expected integer value", this.pos - numStr.length);
} }
return num; return num;
} }
@@ -115,13 +137,13 @@ class ValueParser {
} }
private parseBooleanValue(): boolean { private parseBooleanValue(): boolean {
if (this.consumeStr('true')) { if (this.consumeStr("true")) {
return true; return true;
} }
if (this.consumeStr('false')) { if (this.consumeStr("false")) {
return false; return false;
} }
throw new ParseError('Expected true or false', this.pos); throw new ParseError("Expected true or false", this.pos);
} }
private parseStringLiteralValue(schema: StringLiteralSchema): string { private parseStringLiteralValue(schema: StringLiteralSchema): string {
@@ -131,17 +153,22 @@ class ValueParser {
if (quote === '"' || quote === "'") { if (quote === '"' || quote === "'") {
this.consume(); // Consume opening quote this.consume(); // Consume opening quote
let value = ''; let value = "";
while (this.pos < this.input.length) { while (this.pos < this.input.length) {
const char = this.peek(); const char = this.peek();
if (char === '\\') { if (char === "\\") {
this.consume(); this.consume();
const nextChar = this.consume(); const nextChar = this.consume();
if (nextChar === '"' || nextChar === "'" || nextChar === '\\' || nextChar === ';') { if (
nextChar === '"' ||
nextChar === "'" ||
nextChar === "\\" ||
nextChar === ";"
) {
value += nextChar; value += nextChar;
} else { } else {
value += '\\' + nextChar; value += "\\" + nextChar;
} }
} else if (char === quote) { } else if (char === quote) {
this.consume(); // Consume closing quote this.consume(); // Consume closing quote
@@ -149,7 +176,7 @@ class ValueParser {
if (value !== schema.value) { if (value !== schema.value) {
throw new ParseError( throw new ParseError(
`Invalid value '"${value}"'. Expected '"${schema.value}"'`, `Invalid value '"${value}"'. Expected '"${schema.value}"'`,
this.pos this.pos,
); );
} }
@@ -159,13 +186,13 @@ class ValueParser {
} }
} }
throw new ParseError('Unterminated string literal', this.pos); throw new ParseError("Unterminated string literal", this.pos);
} else { } else {
// 不带引号的字符串,像普通字符串一样解析 // 不带引号的字符串,像普通字符串一样解析
let value = ''; let value = "";
while (this.pos < this.input.length) { while (this.pos < this.input.length) {
const char = this.peek(); const char = this.peek();
if (char === ';' || char === ']' || char === ')') { if (char === ";" || char === "]" || char === ")") {
break; break;
} }
value += this.consume(); value += this.consume();
@@ -176,7 +203,7 @@ class ValueParser {
if (value !== schema.value) { if (value !== schema.value) {
throw new ParseError( throw new ParseError(
`Invalid value '${value}'. Expected '${schema.value}'`, `Invalid value '${value}'. Expected '${schema.value}'`,
this.pos - value.length this.pos - value.length,
); );
} }
@@ -202,23 +229,26 @@ class ValueParser {
// If all members fail, throw a descriptive error // If all members fail, throw a descriptive error
throw new ParseError( throw new ParseError(
`Value does not match any union member. Tried ${schema.members.length} alternatives.`, `Value does not match any union member. Tried ${schema.members.length} alternatives.`,
this.pos this.pos,
); );
} }
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;
} else if (!allowOmitBrackets) { } else if (!allowOmitBrackets) {
throw new ParseError('Expected [', this.pos); throw new ParseError("Expected [", this.pos);
} }
this.skipWhitespace(); this.skipWhitespace();
if (this.peek() === ']' && hasOpenBracket) { if (this.peek() === "]" && hasOpenBracket) {
this.consume(); this.consume();
return []; return [];
} }
@@ -244,8 +274,8 @@ class ValueParser {
this.skipWhitespace(); this.skipWhitespace();
if (i < schema.elements.length - 1) { if (i < schema.elements.length - 1) {
if (!this.consumeStr(';')) { if (!this.consumeStr(";")) {
throw new ParseError('Expected ;', this.pos); throw new ParseError("Expected ;", this.pos);
} }
} }
} }
@@ -253,39 +283,43 @@ 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);
} }
} }
return result; return result;
} }
private parseArrayValue(schema: ArraySchema, allowOmitBrackets: boolean): unknown[] { private parseArrayValue(
schema: ArraySchema,
allowOmitBrackets: boolean,
): unknown[] {
let hasOpenBracket = false; let hasOpenBracket = false;
const elementIsTupleOrArray = schema.element.type === 'tuple' || schema.element.type === 'array'; const elementIsTupleOrArray =
schema.element.type === "tuple" || schema.element.type === "array";
if (this.pos >= this.input.length || !this.input.trim()) { if (this.pos >= this.input.length || !this.input.trim()) {
return []; return [];
} }
if (this.peek() === '[') { if (this.peek() === "[") {
if (!elementIsTupleOrArray) { if (!elementIsTupleOrArray) {
this.consume(); this.consume();
hasOpenBracket = true; hasOpenBracket = true;
} else if (this.input[this.pos + 1] === '[') { } else if (this.input[this.pos + 1] === "[") {
this.consume(); this.consume();
hasOpenBracket = true; hasOpenBracket = true;
} }
} }
if (!hasOpenBracket && !allowOmitBrackets && !elementIsTupleOrArray) { if (!hasOpenBracket && !allowOmitBrackets && !elementIsTupleOrArray) {
throw new ParseError('Expected [', this.pos); throw new ParseError("Expected [", this.pos);
} }
this.skipWhitespace(); this.skipWhitespace();
if (this.peek() === ']' && hasOpenBracket) { if (this.peek() === "]" && hasOpenBracket) {
this.consume(); this.consume();
return []; return [];
} }
@@ -296,7 +330,7 @@ class ValueParser {
result.push(this.parseValue(schema.element, false)); result.push(this.parseValue(schema.element, false));
this.skipWhitespace(); this.skipWhitespace();
if (!this.consumeStr(';')) { if (!this.consumeStr(";")) {
break; break;
} }
} }
@@ -304,15 +338,17 @@ 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);
} }
} }
return result; return result;
} }
private parseReferenceValue(schema: ReferenceSchema): string | string[] | null { private parseReferenceValue(
schema: ReferenceSchema,
): string | string[] | null {
if (schema.isOptional) { if (schema.isOptional) {
this.skipWhitespace(); this.skipWhitespace();
if (this.pos >= this.input.length) { if (this.pos >= this.input.length) {
@@ -323,14 +359,14 @@ class ValueParser {
if (schema.isArray) { if (schema.isArray) {
// Parse array of IDs: [id1; id2; id3] // Parse array of IDs: [id1; id2; id3]
let hasOpenBracket = false; let hasOpenBracket = false;
if (this.peek() === '[') { if (this.peek() === "[") {
this.consume(); this.consume();
hasOpenBracket = true; hasOpenBracket = true;
} }
this.skipWhitespace(); this.skipWhitespace();
if (this.peek() === ']' && hasOpenBracket) { if (this.peek() === "]" && hasOpenBracket) {
this.consume(); this.consume();
return []; return [];
} }
@@ -339,31 +375,35 @@ class ValueParser {
while (true) { while (true) {
this.skipWhitespace(); this.skipWhitespace();
// Parse each ID as a string // Parse each ID as a string
let id = ''; let id = "";
while (this.pos < this.input.length && this.peek() !== ';' && this.peek() !== ']') { while (
this.pos < this.input.length &&
this.peek() !== ";" &&
this.peek() !== "]"
) {
id += this.consume(); id += this.consume();
} }
ids.push(id.trim()); ids.push(id.trim());
this.skipWhitespace(); this.skipWhitespace();
if (!this.consumeStr(';')) { if (!this.consumeStr(";")) {
break; break;
} }
} }
if (hasOpenBracket) { if (hasOpenBracket) {
if (!this.consumeStr(']')) { if (!this.consumeStr("]")) {
throw new ParseError('Expected ]', this.pos); throw new ParseError("Expected ]", this.pos);
} }
} }
return ids; return ids;
} else { } else {
// Parse single ID as string // Parse single ID as string
let id = ''; let id = "";
while (this.pos < this.input.length) { while (this.pos < this.input.length) {
const char = this.peek(); const char = this.peek();
if (char === ';' || char === ']' || char === ',') { if (char === ";" || char === "]" || char === ",") {
break; break;
} }
id += this.consume(); id += this.consume();
@@ -383,92 +423,116 @@ class ValueParser {
export function parseValue(schema: Schema, valueString: string): unknown { export function parseValue(schema: Schema, valueString: string): unknown {
const parser = new ValueParser(valueString.trim()); const parser = new ValueParser(valueString.trim());
const allowOmitBrackets = schema.type === 'tuple' || schema.type === 'array'; const allowOmitBrackets = schema.type === "tuple" || schema.type === "array";
const value = parser.parseValue(schema, allowOmitBrackets); const value = parser.parseValue(schema, allowOmitBrackets);
if (parser.getPosition() < parser.getInputLength()) { if (parser.getPosition() < parser.getInputLength()) {
throw new ParseError('Unexpected input after value', parser.getPosition()); throw new ParseError("Unexpected input after value", parser.getPosition());
} }
return value; return value;
} }
export function schemaToTypeString(schema: Schema, resourceNames?: Map<string, string>): string { export function schemaToTypeString(
schema: Schema,
resourceNames?: Map<string, string>,
): string {
switch (schema.type) { switch (schema.type) {
case 'string': case "string":
return 'string'; return "string";
case 'number': case "number":
case 'int': case "int":
case 'float': case "float":
return 'number'; return "number";
case 'boolean': case "boolean":
return 'boolean'; return "boolean";
case 'stringLiteral': case "stringLiteral":
return `"${schema.value}"`; return `"${schema.value}"`;
case 'union': case "union":
return schema.members.map(m => schemaToTypeString(m, resourceNames)).join(' | '); return schema.members
case 'reference': { .map((m) => schemaToTypeString(m, resourceNames))
const typeName = resourceNames?.get(schema.tableName) || .join(" | ");
case "reference": {
const typeName =
resourceNames?.get(schema.tableName) ||
schema.tableName.charAt(0).toUpperCase() + schema.tableName.slice(1); schema.tableName.charAt(0).toUpperCase() + schema.tableName.slice(1);
const baseType = schema.isArray ? `${typeName}[]` : typeName; const baseType = schema.isArray ? `${typeName}[]` : typeName;
return schema.isOptional ? `${baseType} | null` : baseType; return schema.isOptional ? `${baseType} | null` : baseType;
} }
case 'array': case "reverseReference": {
if (schema.element.type === 'tuple') { const typeName =
resourceNames?.get(schema.tableName) ||
schema.tableName.charAt(0).toUpperCase() + schema.tableName.slice(1);
// Reverse references always resolve to an array (one-to-many)
const baseType = `${typeName}[]`;
return schema.isOptional ? `${baseType} | null` : baseType;
}
case "array":
if (schema.element.type === "tuple") {
const tupleElements = schema.element.elements.map((el) => { const tupleElements = schema.element.elements.map((el) => {
const typeStr = schemaToTypeString(el.schema, resourceNames); const typeStr = schemaToTypeString(el.schema, resourceNames);
return el.name ? `${el.name}: ${typeStr}` : typeStr; return el.name ? `${el.name}: ${typeStr}` : typeStr;
}); });
return `[${tupleElements.join(', ')}][]`; return `[${tupleElements.join(", ")}][]`;
} }
const elementType = schemaToTypeString(schema.element, resourceNames); const elementType = schemaToTypeString(schema.element, resourceNames);
if (schema.element.type === 'union') { if (schema.element.type === "union") {
return `(${elementType})[]`; return `(${elementType})[]`;
} }
return `${elementType}[]`; return `${elementType}[]`;
case 'tuple': case "tuple":
const tupleElements = schema.elements.map((el) => { const tupleElements = schema.elements.map((el) => {
const typeStr = schemaToTypeString(el.schema, resourceNames); const typeStr = schemaToTypeString(el.schema, resourceNames);
return el.name ? `${el.name}: ${typeStr}` : typeStr; return el.name ? `${el.name}: ${typeStr}` : typeStr;
}); });
return `[${tupleElements.join(', ')}]`; return `[${tupleElements.join(", ")}]`;
default: default:
return 'unknown'; return "unknown";
} }
} }
export function createValidator(schema: Schema): (value: unknown) => boolean { export function createValidator(schema: Schema): (value: unknown) => boolean {
return function validate(value: unknown): boolean { return function validate(value: unknown): boolean {
switch (schema.type) { switch (schema.type) {
case 'string': case "string":
return typeof value === 'string'; return typeof value === "string";
case 'number': case "number":
return typeof value === 'number' && !isNaN(value); return typeof value === "number" && !isNaN(value);
case 'int': case "int":
return typeof value === 'number' && !isNaN(value) && Number.isInteger(value); return (
case 'float': typeof value === "number" && !isNaN(value) && Number.isInteger(value)
return typeof value === 'number' && !isNaN(value); );
case 'boolean': case "float":
return typeof value === 'boolean'; return typeof value === "number" && !isNaN(value);
case 'stringLiteral': case "boolean":
return typeof value === 'string' && value === schema.value; return typeof value === "boolean";
case 'union': case "stringLiteral":
return typeof value === "string" && value === schema.value;
case "union":
return schema.members.some((member) => createValidator(member)(value)); return schema.members.some((member) => createValidator(member)(value));
case 'tuple': case "tuple":
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.schema)(value[index]) createValidator(elementSchema.schema)(value[index]),
); );
case 'array': case "array":
if (!Array.isArray(value)) return false; if (!Array.isArray(value)) return false;
return value.every((item) => createValidator(schema.element)(item)); return value.every((item) => createValidator(schema.element)(item));
case 'reference': case "reference":
if (schema.isOptional && value === null) return true; if (schema.isOptional && value === null) return true;
if (schema.isArray) { if (schema.isArray) {
return Array.isArray(value) && value.every((id) => typeof id === 'string'); return (
Array.isArray(value) && value.every((id) => typeof id === "string")
);
} }
return typeof value === 'string' || (Array.isArray(value) && value.every((id) => typeof id === 'string')); return (
typeof value === "string" ||
(Array.isArray(value) && value.every((id) => typeof id === "string"))
);
case "reverseReference":
if (schema.isOptional && value === null) return true;
return Array.isArray(value);
default: default:
return false; return false;
} }