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:
parent
0954dcf594
commit
e76ae79b2d
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
38
src/index.ts
38
src/index.ts
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
255
src/parser.ts
255
src/parser.ts
|
|
@ -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
|
||||
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;
|
||||
|
|
|
|||
35
src/types.ts
35
src/types.ts
|
|
@ -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;
|
||||
|
|
|
|||
278
src/validator.ts
278
src/validator.ts
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue