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:
hypercross 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,17 @@
import { parseSchema } from './parser';
import { parseValue, createValidator, schemaToTypeString } from './validator';
import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, ReferenceSchema, StringLiteralSchema, UnionSchema, ParsedSchema } from './types';
import { ParseError } from './parser';
import { parseSchema } from "./parser";
import { parseValue, createValidator, schemaToTypeString } from "./validator";
import type {
Schema,
PrimitiveSchema,
TupleSchema,
ArraySchema,
ReferenceSchema,
ReverseReferenceSchema,
StringLiteralSchema,
UnionSchema,
ParsedSchema,
} from "./types";
import { ParseError } from "./parser";
export function defineSchema(schemaString: string): ParsedSchema {
const schema = parseSchema(schemaString);
@ -14,5 +24,21 @@ export function defineSchema(schemaString: string): ParsedSchema {
};
}
export { parseSchema, parseValue, createValidator, ParseError, schemaToTypeString };
export type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, ReferenceSchema, StringLiteralSchema, UnionSchema, ParsedSchema };
export {
parseSchema,
parseValue,
createValidator,
ParseError,
schemaToTypeString,
};
export type {
Schema,
PrimitiveSchema,
TupleSchema,
ArraySchema,
ReferenceSchema,
ReverseReferenceSchema,
StringLiteralSchema,
UnionSchema,
ParsedSchema,
};

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 {
constructor(message: string, public position?: number) {
super(position !== undefined ? `${message} at position ${position}` : message);
this.name = 'ParseError';
constructor(
message: string,
public position?: number,
) {
super(
position !== undefined ? `${message} at position ${position}` : message,
);
this.name = "ParseError";
}
}
@ -23,11 +38,11 @@ class Parser {
}
private peek(): string {
return this.input[this.pos] || '';
return this.input[this.pos] || "";
}
private consume(): string {
return this.input[this.pos++] || '';
return this.input[this.pos++] || "";
}
private skipWhitespace(): void {
@ -64,33 +79,33 @@ class Parser {
this.skipWhitespace();
// Check for array suffix: type[]
if (this.consumeStr('[')) {
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr(']')) {
throw new ParseError('Expected ]', this.pos);
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
schema = { type: 'array', element: schema };
schema = { type: "array", element: schema };
this.skipWhitespace();
}
// Check for union type (| symbol)
if (this.consumeStr('|')) {
if (this.consumeStr("|")) {
const members: Schema[] = [schema];
while (true) {
this.skipWhitespace();
const member = this.parseSchemaInternal();
members.push(member);
this.skipWhitespace();
if (!this.consumeStr('|')) {
if (!this.consumeStr("|")) {
break;
}
}
return {
type: 'union',
members
type: "union",
members,
};
}
@ -101,25 +116,25 @@ class Parser {
this.skipWhitespace();
// Check for parentheses (grouping)
if (this.consumeStr('(')) {
if (this.consumeStr("(")) {
this.skipWhitespace();
const schema = this.parseSchema(); // Recursive call for nested unions
const schema = this.parseSchema(); // Recursive call for nested unions
this.skipWhitespace();
if (!this.consumeStr(')')) {
throw new ParseError('Expected )', this.pos);
if (!this.consumeStr(")")) {
throw new ParseError("Expected )", this.pos);
}
// Check if there's an array suffix: (union)[]
this.skipWhitespace();
if (this.consumeStr('[')) {
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr(']')) {
throw new ParseError('Expected ]', this.pos);
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
return { type: 'array', element: schema };
return { type: "array", element: schema };
}
return schema;
}
@ -128,86 +143,91 @@ class Parser {
return this.parseStringLiteralSchema();
}
// Check for reverse reference syntax: ~tablename(foreignKey)
if (this.consumeStr("~")) {
return this.parseReverseReferenceSchema();
}
// Check for reference syntax: @tablename[]
if (this.consumeStr('@')) {
if (this.consumeStr("@")) {
return this.parseReferenceSchema();
}
if (this.consumeStr('string')) {
if (this.consumeStr('[')) {
if (this.consumeStr("string")) {
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr(']')) {
throw new ParseError('Expected ]', this.pos);
if (!this.consumeStr("]")) {
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('[')) {
if (this.consumeStr("number")) {
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr(']')) {
throw new ParseError('Expected ]', this.pos);
if (!this.consumeStr("]")) {
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('[')) {
if (this.consumeStr("int")) {
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr(']')) {
throw new ParseError('Expected ]', this.pos);
if (!this.consumeStr("]")) {
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('[')) {
if (this.consumeStr("float")) {
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr(']')) {
throw new ParseError('Expected ]', this.pos);
if (!this.consumeStr("]")) {
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('[')) {
if (this.consumeStr("boolean")) {
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr(']')) {
throw new ParseError('Expected ]', this.pos);
if (!this.consumeStr("]")) {
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[] = [];
this.skipWhitespace();
if (this.peek() === ']') {
if (this.peek() === "]") {
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());
this.skipWhitespace();
if (this.consumeStr(';')) {
if (this.consumeStr(";")) {
const remainingElements: NamedSchema[] = [];
while (true) {
this.skipWhitespace();
remainingElements.push(this.parseNamedSchema());
this.skipWhitespace();
if (!this.consumeStr(';')) {
if (!this.consumeStr(";")) {
break;
}
}
@ -216,57 +236,67 @@ class Parser {
this.skipWhitespace();
if (!this.consumeStr(']')) {
throw new ParseError('Expected ]', this.pos);
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
if (this.consumeStr('[')) {
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr(']')) {
throw new ParseError('Expected ]', this.pos);
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
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) {
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 {
const value = this.parseStringLiteral();
return {
type: 'stringLiteral',
value
type: "stringLiteral",
value,
};
}
private parseStringLiteral(): string {
const quote = this.peek();
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
let value = '';
let value = "";
while (this.pos < this.input.length) {
const char = this.peek();
if (char === '\\') {
if (char === "\\") {
this.consume();
const nextChar = this.consume();
if (nextChar === '"' || nextChar === "'" || nextChar === '\\' ||
nextChar === '|' || nextChar === ';' || nextChar === '(' || nextChar === ')') {
if (
nextChar === '"' ||
nextChar === "'" ||
nextChar === "\\" ||
nextChar === "|" ||
nextChar === ";" ||
nextChar === "(" ||
nextChar === ")"
) {
value += nextChar;
} else {
value += '\\' + nextChar;
value += "\\" + nextChar;
}
} else if (char === quote) {
this.consume(); // Consume closing quote
@ -275,15 +305,15 @@ class Parser {
value += this.consume();
}
}
throw new ParseError('Unterminated string literal', this.pos);
throw new ParseError("Unterminated string literal", this.pos);
}
private parseNamedSchema(): NamedSchema {
this.skipWhitespace();
const startpos = this.pos;
let identifier = '';
let identifier = "";
while (this.pos < this.input.length && /[a-zA-Z0-9\-_]/.test(this.peek())) {
identifier += this.consume();
@ -296,7 +326,7 @@ class Parser {
this.skipWhitespace();
if (this.consumeStr(':')) {
if (this.consumeStr(":")) {
this.skipWhitespace();
const name = identifier;
const schema = this.parseSchema();
@ -310,23 +340,23 @@ class Parser {
private parseReferenceSchema(): Schema {
// Parse table name
let tableName = '';
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);
throw new ParseError("Expected table name after @", this.pos);
}
this.skipWhitespace();
// Check for array syntax
if (this.consumeStr('[]')) {
if (this.consumeStr("[]")) {
this.skipWhitespace();
const isOptional = this.consumeStr('?');
const isOptional = this.consumeStr("?");
return {
type: 'reference',
type: "reference",
tableName,
isArray: true,
isOptional,
@ -334,25 +364,76 @@ class Parser {
}
// Check for optional suffix
const isOptional = this.consumeStr('?');
const isOptional = this.consumeStr("?");
// Single reference (non-array)
return {
type: 'reference',
type: "reference",
tableName,
isArray: false,
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 {
const parser = new Parser(schemaString.trim());
const schema = parser.parseSchema();
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;
}

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 {
type: SchemaType;
@ -10,17 +10,17 @@ export interface NamedSchema {
}
export interface TupleSchema {
type: 'tuple';
type: "tuple";
elements: NamedSchema[];
}
export interface ArraySchema {
type: 'array';
type: "array";
element: Schema;
}
export interface ReferenceSchema {
type: 'reference';
type: "reference";
/** Referenced table name (e.g., 'parts' from '@parts[]') */
tableName: string;
/** Whether it's an array reference */
@ -29,17 +29,34 @@ export interface ReferenceSchema {
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 {
type: 'stringLiteral';
value: string; // The literal string value (without quotes)
type: "stringLiteral";
value: string; // The literal string value (without quotes)
}
export interface UnionSchema {
type: 'union';
members: Schema[]; // Union members
type: "union";
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 {
schema: Schema;

View File

@ -1,5 +1,15 @@
import type { Schema, PrimitiveSchema, TupleSchema, ArraySchema, NamedSchema, ReferenceSchema, StringLiteralSchema, UnionSchema } from './types';
import { ParseError } from './parser';
import type {
Schema,
PrimitiveSchema,
TupleSchema,
ArraySchema,
NamedSchema,
ReferenceSchema,
ReverseReferenceSchema,
StringLiteralSchema,
UnionSchema,
} from "./types";
import { ParseError } from "./parser";
class ValueParser {
private input: string;
@ -10,11 +20,11 @@ class ValueParser {
}
private peek(): string {
return this.input[this.pos] || '';
return this.input[this.pos] || "";
}
private consume(): string {
return this.input[this.pos++] || '';
return this.input[this.pos++] || "";
}
private skipWhitespace(): void {
@ -35,46 +45,58 @@ class ValueParser {
this.skipWhitespace();
switch (schema.type) {
case 'string':
case "string":
return this.parseStringValue();
case 'number':
case "number":
return this.parseNumberValue();
case 'int':
case "int":
return this.parseIntValue();
case 'float':
case "float":
return this.parseFloatValue();
case 'boolean':
case "boolean":
return this.parseBooleanValue();
case 'stringLiteral':
case "stringLiteral":
return this.parseStringLiteralValue(schema);
case 'union':
case "union":
return this.parseUnionValue(schema);
case 'tuple':
case "tuple":
return this.parseTupleValue(schema, allowOmitBrackets);
case 'array':
case "array":
return this.parseArrayValue(schema, allowOmitBrackets);
case 'reference':
case "reference":
// Reference values are parsed as strings (IDs) initially, resolved later
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:
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 {
let result = '';
let result = "";
while (this.pos < this.input.length) {
const char = this.peek();
if (char === '\\') {
if (char === "\\") {
this.consume();
const nextChar = this.consume();
if (nextChar === ';' || nextChar === '[' || nextChar === ']' || nextChar === '\\') {
if (
nextChar === ";" ||
nextChar === "[" ||
nextChar === "]" ||
nextChar === "\\"
) {
result += nextChar;
} else {
result += '\\' + nextChar;
result += "\\" + nextChar;
}
} else if (char === ';' || char === ']') {
} else if (char === ";" || char === "]") {
break;
} else {
result += this.consume();
@ -84,28 +106,28 @@ class ValueParser {
}
private parseNumberValue(): number {
let numStr = '';
let numStr = "";
while (this.pos < this.input.length && /[\d.\-+eE]/.test(this.peek())) {
numStr += this.consume();
}
const num = parseFloat(numStr);
if (isNaN(num)) {
throw new ParseError('Invalid number', this.pos - numStr.length);
throw new ParseError("Invalid number", this.pos - numStr.length);
}
return num;
}
private parseIntValue(): number {
let numStr = '';
let numStr = "";
while (this.pos < this.input.length && /[\d.\-+eE]/.test(this.peek())) {
numStr += this.consume();
}
const num = parseFloat(numStr);
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)) {
throw new ParseError('Expected integer value', this.pos - numStr.length);
throw new ParseError("Expected integer value", this.pos - numStr.length);
}
return num;
}
@ -115,33 +137,38 @@ class ValueParser {
}
private parseBooleanValue(): boolean {
if (this.consumeStr('true')) {
if (this.consumeStr("true")) {
return true;
}
if (this.consumeStr('false')) {
if (this.consumeStr("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 {
const quote = this.peek();
// 支持带引号或不带引号的字符串值
if (quote === '"' || quote === "'") {
this.consume(); // Consume opening quote
let value = '';
let value = "";
while (this.pos < this.input.length) {
const char = this.peek();
if (char === '\\') {
if (char === "\\") {
this.consume();
const nextChar = this.consume();
if (nextChar === '"' || nextChar === "'" || nextChar === '\\' || nextChar === ';') {
if (
nextChar === '"' ||
nextChar === "'" ||
nextChar === "\\" ||
nextChar === ";"
) {
value += nextChar;
} else {
value += '\\' + nextChar;
value += "\\" + nextChar;
}
} else if (char === quote) {
this.consume(); // Consume closing quote
@ -149,7 +176,7 @@ class ValueParser {
if (value !== schema.value) {
throw new ParseError(
`Invalid value '"${value}"'. Expected '"${schema.value}"'`,
this.pos
this.pos,
);
}
@ -159,24 +186,24 @@ class ValueParser {
}
}
throw new ParseError('Unterminated string literal', this.pos);
throw new ParseError("Unterminated string literal", this.pos);
} else {
// 不带引号的字符串,像普通字符串一样解析
let value = '';
let value = "";
while (this.pos < this.input.length) {
const char = this.peek();
if (char === ';' || char === ']' || char === ')') {
if (char === ";" || char === "]" || char === ")") {
break;
}
value += this.consume();
}
value = value.trim();
if (value !== schema.value) {
throw new ParseError(
`Invalid value '${value}'. Expected '${schema.value}'`,
this.pos - value.length
this.pos - value.length,
);
}
@ -187,7 +214,7 @@ class ValueParser {
private parseUnionValue(schema: UnionSchema): unknown {
const savedPos = this.pos;
const errors: Error[] = [];
// Try each union member until one succeeds
for (let i = 0; i < schema.members.length; i++) {
this.pos = savedPos;
@ -198,27 +225,30 @@ class ValueParser {
// Continue to next member
}
}
// If all members fail, throw a descriptive error
throw new ParseError(
`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;
if (this.peek() === '[') {
if (this.peek() === "[") {
this.consume();
hasOpenBracket = true;
} else if (!allowOmitBrackets) {
throw new ParseError('Expected [', this.pos);
throw new ParseError("Expected [", this.pos);
}
this.skipWhitespace();
if (this.peek() === ']' && hasOpenBracket) {
if (this.peek() === "]" && hasOpenBracket) {
this.consume();
return [];
}
@ -227,7 +257,7 @@ class ValueParser {
for (let i = 0; i < schema.elements.length; i++) {
this.skipWhitespace();
const elementSchema = schema.elements[i];
// Try to consume optional name prefix (e.g., "current:")
if (elementSchema.name) {
this.skipWhitespace();
@ -239,13 +269,13 @@ class ValueParser {
this.pos = savedPos;
}
}
result.push(this.parseValue(elementSchema.schema, false));
this.skipWhitespace();
if (i < schema.elements.length - 1) {
if (!this.consumeStr(';')) {
throw new ParseError('Expected ;', this.pos);
if (!this.consumeStr(";")) {
throw new ParseError("Expected ;", this.pos);
}
}
}
@ -253,39 +283,43 @@ class ValueParser {
this.skipWhitespace();
if (hasOpenBracket) {
if (!this.consumeStr(']')) {
throw new ParseError('Expected ]', this.pos);
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
}
return result;
}
private parseArrayValue(schema: ArraySchema, allowOmitBrackets: boolean): unknown[] {
private parseArrayValue(
schema: ArraySchema,
allowOmitBrackets: boolean,
): unknown[] {
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()) {
return [];
}
if (this.peek() === '[') {
if (this.peek() === "[") {
if (!elementIsTupleOrArray) {
this.consume();
hasOpenBracket = true;
} else if (this.input[this.pos + 1] === '[') {
} else if (this.input[this.pos + 1] === "[") {
this.consume();
hasOpenBracket = true;
}
}
if (!hasOpenBracket && !allowOmitBrackets && !elementIsTupleOrArray) {
throw new ParseError('Expected [', this.pos);
throw new ParseError("Expected [", this.pos);
}
this.skipWhitespace();
if (this.peek() === ']' && hasOpenBracket) {
if (this.peek() === "]" && hasOpenBracket) {
this.consume();
return [];
}
@ -296,7 +330,7 @@ class ValueParser {
result.push(this.parseValue(schema.element, false));
this.skipWhitespace();
if (!this.consumeStr(';')) {
if (!this.consumeStr(";")) {
break;
}
}
@ -304,15 +338,17 @@ class ValueParser {
this.skipWhitespace();
if (hasOpenBracket) {
if (!this.consumeStr(']')) {
throw new ParseError('Expected ]', this.pos);
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
}
return result;
}
private parseReferenceValue(schema: ReferenceSchema): string | string[] | null {
private parseReferenceValue(
schema: ReferenceSchema,
): string | string[] | null {
if (schema.isOptional) {
this.skipWhitespace();
if (this.pos >= this.input.length) {
@ -323,14 +359,14 @@ class ValueParser {
if (schema.isArray) {
// Parse array of IDs: [id1; id2; id3]
let hasOpenBracket = false;
if (this.peek() === '[') {
if (this.peek() === "[") {
this.consume();
hasOpenBracket = true;
}
this.skipWhitespace();
if (this.peek() === ']' && hasOpenBracket) {
if (this.peek() === "]" && hasOpenBracket) {
this.consume();
return [];
}
@ -339,31 +375,35 @@ class ValueParser {
while (true) {
this.skipWhitespace();
// Parse each ID as a string
let id = '';
while (this.pos < this.input.length && this.peek() !== ';' && this.peek() !== ']') {
let id = "";
while (
this.pos < this.input.length &&
this.peek() !== ";" &&
this.peek() !== "]"
) {
id += this.consume();
}
ids.push(id.trim());
this.skipWhitespace();
if (!this.consumeStr(';')) {
if (!this.consumeStr(";")) {
break;
}
}
if (hasOpenBracket) {
if (!this.consumeStr(']')) {
throw new ParseError('Expected ]', this.pos);
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
}
return ids;
} else {
// Parse single ID as string
let id = '';
let id = "";
while (this.pos < this.input.length) {
const char = this.peek();
if (char === ';' || char === ']' || char === ',') {
if (char === ";" || char === "]" || char === ",") {
break;
}
id += this.consume();
@ -383,92 +423,116 @@ class ValueParser {
export function parseValue(schema: Schema, valueString: string): unknown {
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);
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;
}
export function schemaToTypeString(schema: Schema, resourceNames?: Map<string, string>): string {
export function schemaToTypeString(
schema: Schema,
resourceNames?: Map<string, string>,
): string {
switch (schema.type) {
case 'string':
return 'string';
case 'number':
case 'int':
case 'float':
return 'number';
case 'boolean':
return 'boolean';
case 'stringLiteral':
case "string":
return "string";
case "number":
case "int":
case "float":
return "number";
case "boolean":
return "boolean";
case "stringLiteral":
return `"${schema.value}"`;
case 'union':
return schema.members.map(m => schemaToTypeString(m, resourceNames)).join(' | ');
case 'reference': {
const typeName = resourceNames?.get(schema.tableName) ||
case "union":
return schema.members
.map((m) => schemaToTypeString(m, resourceNames))
.join(" | ");
case "reference": {
const typeName =
resourceNames?.get(schema.tableName) ||
schema.tableName.charAt(0).toUpperCase() + schema.tableName.slice(1);
const baseType = schema.isArray ? `${typeName}[]` : typeName;
return schema.isOptional ? `${baseType} | null` : baseType;
}
case 'array':
if (schema.element.type === 'tuple') {
case "reverseReference": {
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 typeStr = schemaToTypeString(el.schema, resourceNames);
return el.name ? `${el.name}: ${typeStr}` : typeStr;
});
return `[${tupleElements.join(', ')}][]`;
return `[${tupleElements.join(", ")}][]`;
}
const elementType = schemaToTypeString(schema.element, resourceNames);
if (schema.element.type === 'union') {
if (schema.element.type === "union") {
return `(${elementType})[]`;
}
return `${elementType}[]`;
case 'tuple':
case "tuple":
const tupleElements = schema.elements.map((el) => {
const typeStr = schemaToTypeString(el.schema, resourceNames);
return el.name ? `${el.name}: ${typeStr}` : typeStr;
});
return `[${tupleElements.join(', ')}]`;
return `[${tupleElements.join(", ")}]`;
default:
return 'unknown';
return "unknown";
}
}
export function createValidator(schema: Schema): (value: unknown) => boolean {
return function validate(value: unknown): boolean {
switch (schema.type) {
case 'string':
return typeof value === 'string';
case 'number':
return typeof value === 'number' && !isNaN(value);
case 'int':
return typeof value === 'number' && !isNaN(value) && Number.isInteger(value);
case 'float':
return typeof value === 'number' && !isNaN(value);
case 'boolean':
return typeof value === 'boolean';
case 'stringLiteral':
return typeof value === 'string' && value === schema.value;
case 'union':
case "string":
return typeof value === "string";
case "number":
return typeof value === "number" && !isNaN(value);
case "int":
return (
typeof value === "number" && !isNaN(value) && Number.isInteger(value)
);
case "float":
return typeof value === "number" && !isNaN(value);
case "boolean":
return typeof value === "boolean";
case "stringLiteral":
return typeof value === "string" && value === schema.value;
case "union":
return schema.members.some((member) => createValidator(member)(value));
case 'tuple':
case "tuple":
if (!Array.isArray(value)) return false;
if (value.length !== schema.elements.length) return false;
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;
return value.every((item) => createValidator(schema.element)(item));
case 'reference':
case "reference":
if (schema.isOptional && value === null) return true;
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:
return false;
}