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
+563 -154
View File
File diff suppressed because it is too large Load Diff
+560 -313
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 { 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,
};
+167 -86
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,17 +79,17 @@ 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) {
@@ -83,14 +98,14 @@ class Parser {
members.push(member);
this.skipWhitespace();
if (!this.consumeStr('|')) {
if (!this.consumeStr("|")) {
break;
}
}
return {
type: 'union',
members
type: "union",
members,
};
}
@@ -101,23 +116,23 @@ 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
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
@@ -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 {
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,16 +364,67 @@ 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 {
@@ -351,7 +432,7 @@ export function parseSchema(schemaString: string): Schema {
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;
+24 -7
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';
type: "stringLiteral";
value: string; // The literal string value (without quotes)
}
export interface UnionSchema {
type: 'union';
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;
+171 -107
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,13 +137,13 @@ 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 {
@@ -131,17 +153,22 @@ class ValueParser {
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,13 +186,13 @@ 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();
@@ -176,7 +203,7 @@ class ValueParser {
if (value !== schema.value) {
throw new ParseError(
`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
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 [];
}
@@ -244,8 +274,8 @@ class ValueParser {
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;
}