318 lines
7.8 KiB
TypeScript
318 lines
7.8 KiB
TypeScript
import { Lexer } from './query-parser.lexer.ts';
|
|
import type { Token, TokenType } from './query-parser.types.ts';
|
|
|
|
import type { QueryConditionText, QueryConditionNumber, QueryFilter, QueryCondition } from '#root/utils/utils.query.ts';
|
|
|
|
class Parser {
|
|
#tokens: Token[] = [];
|
|
#position = 0;
|
|
|
|
#current = (): Token => {
|
|
return this.#tokens[this.#position];
|
|
};
|
|
|
|
#advance = (): Token => {
|
|
const token = this.#current();
|
|
this.#position++;
|
|
return token;
|
|
};
|
|
|
|
#expect = (type: TokenType): Token => {
|
|
const token = this.#current();
|
|
if (token.type !== type) {
|
|
throw new Error(`Expected ${type} but got ${token.type} at position ${token.position}`);
|
|
}
|
|
return this.#advance();
|
|
};
|
|
|
|
#parseExpression = (): QueryFilter => {
|
|
return this.#parseOr();
|
|
};
|
|
|
|
#parseOr = (): QueryFilter => {
|
|
let left = this.#parseAnd();
|
|
|
|
while (this.#current().type === 'OR') {
|
|
this.#advance();
|
|
const right = this.#parseAnd();
|
|
left = this.#combineWithOperator(left, right, 'or');
|
|
}
|
|
|
|
return left;
|
|
};
|
|
|
|
#parseAnd = (): QueryFilter => {
|
|
let left = this.#parsePrimary();
|
|
|
|
while (this.#current().type === 'AND') {
|
|
this.#advance();
|
|
const right = this.#parsePrimary();
|
|
left = this.#combineWithOperator(left, right, 'and');
|
|
}
|
|
|
|
return left;
|
|
};
|
|
|
|
#combineWithOperator = (left: QueryFilter, right: QueryFilter, operator: 'and' | 'or'): QueryFilter => {
|
|
// If left is already an operator of the same type, add to its conditions
|
|
if (left.type === 'operator' && left.operator === operator) {
|
|
return {
|
|
type: 'operator',
|
|
operator,
|
|
conditions: [...left.conditions, right],
|
|
};
|
|
}
|
|
|
|
return {
|
|
type: 'operator',
|
|
operator,
|
|
conditions: [left, right],
|
|
};
|
|
};
|
|
|
|
#parsePrimary = (): QueryFilter => {
|
|
// Handle parenthesized expressions
|
|
if (this.#current().type === 'LPAREN') {
|
|
this.#advance();
|
|
const expr = this.#parseExpression();
|
|
this.#expect('RPAREN');
|
|
return expr;
|
|
}
|
|
|
|
// Must be a condition
|
|
return this.#parseCondition();
|
|
};
|
|
|
|
#parseCondition = (): QueryCondition => {
|
|
const field = this.#parseField();
|
|
|
|
const token = this.#current();
|
|
|
|
// IS NULL / IS NOT NULL
|
|
if (token.type === 'IS') {
|
|
this.#advance();
|
|
const isNot = this.#current().type === 'NOT';
|
|
if (isNot) {
|
|
this.#advance();
|
|
}
|
|
this.#expect('NULL');
|
|
|
|
// IS NULL / IS NOT NULL could be either text or number - default to text
|
|
return {
|
|
type: 'text',
|
|
field,
|
|
conditions: isNot ? { notEqual: undefined, equal: undefined } : { equal: null },
|
|
} satisfies QueryConditionText;
|
|
}
|
|
|
|
// NOT IN / NOT LIKE
|
|
if (token.type === 'NOT') {
|
|
this.#advance();
|
|
const nextToken = this.#current();
|
|
|
|
if (nextToken.type === 'IN') {
|
|
this.#advance();
|
|
return this.#parseInCondition(field, true);
|
|
}
|
|
|
|
if (nextToken.type === 'LIKE') {
|
|
this.#advance();
|
|
const pattern = this.#expect('STRING').value;
|
|
return {
|
|
type: 'text',
|
|
field,
|
|
conditions: { notLike: pattern },
|
|
};
|
|
}
|
|
|
|
throw new Error(`Expected IN or LIKE after NOT at position ${nextToken.position}`);
|
|
}
|
|
|
|
// IN
|
|
if (token.type === 'IN') {
|
|
this.#advance();
|
|
return this.#parseInCondition(field, false);
|
|
}
|
|
|
|
// LIKE
|
|
if (token.type === 'LIKE') {
|
|
this.#advance();
|
|
const pattern = this.#expect('STRING').value;
|
|
return {
|
|
type: 'text',
|
|
field,
|
|
conditions: { like: pattern },
|
|
};
|
|
}
|
|
|
|
// Comparison operators
|
|
if (token.type === 'EQUALS') {
|
|
this.#advance();
|
|
return this.#parseValueCondition(field, 'equals');
|
|
}
|
|
|
|
if (token.type === 'NOT_EQUALS') {
|
|
this.#advance();
|
|
return this.#parseValueCondition(field, 'notEquals');
|
|
}
|
|
|
|
if (token.type === 'GREATER_THAN') {
|
|
this.#advance();
|
|
const value = this.#parseNumber();
|
|
return {
|
|
type: 'number',
|
|
field,
|
|
conditions: { greaterThan: value },
|
|
};
|
|
}
|
|
|
|
if (token.type === 'GREATER_THAN_OR_EQUAL') {
|
|
this.#advance();
|
|
const value = this.#parseNumber();
|
|
return {
|
|
type: 'number',
|
|
field,
|
|
conditions: { greaterThanOrEqual: value },
|
|
};
|
|
}
|
|
|
|
if (token.type === 'LESS_THAN') {
|
|
this.#advance();
|
|
const value = this.#parseNumber();
|
|
return {
|
|
type: 'number',
|
|
field,
|
|
conditions: { lessThan: value },
|
|
};
|
|
}
|
|
|
|
if (token.type === 'LESS_THAN_OR_EQUAL') {
|
|
this.#advance();
|
|
const value = this.#parseNumber();
|
|
return {
|
|
type: 'number',
|
|
field,
|
|
conditions: { lessThanOrEqual: value },
|
|
};
|
|
}
|
|
|
|
throw new Error(`Unexpected token '${token.value}' at position ${token.position}`);
|
|
};
|
|
|
|
#parseField = (): string[] => {
|
|
const parts: string[] = [];
|
|
parts.push(this.#expect('IDENTIFIER').value);
|
|
|
|
while (this.#current().type === 'DOT') {
|
|
this.#advance();
|
|
parts.push(this.#expect('IDENTIFIER').value);
|
|
}
|
|
|
|
return parts;
|
|
};
|
|
|
|
#parseValueCondition = (field: string[], operator: 'equals' | 'notEquals'): QueryCondition => {
|
|
const token = this.#current();
|
|
|
|
if (token.type === 'STRING') {
|
|
this.#advance();
|
|
const textCondition: QueryConditionText = {
|
|
type: 'text',
|
|
field,
|
|
conditions: operator === 'equals' ? { equal: token.value } : { notEqual: token.value },
|
|
};
|
|
return textCondition;
|
|
}
|
|
|
|
if (token.type === 'NUMBER') {
|
|
this.#advance();
|
|
const value = parseFloat(token.value);
|
|
const numCondition: QueryConditionNumber = {
|
|
type: 'number',
|
|
field,
|
|
conditions: operator === 'equals' ? { equals: value } : { notEquals: value },
|
|
};
|
|
return numCondition;
|
|
}
|
|
|
|
if (token.type === 'NULL') {
|
|
this.#advance();
|
|
// NULL equality - default to text type
|
|
return {
|
|
type: 'text',
|
|
field,
|
|
conditions: operator === 'equals' ? { equal: null } : {},
|
|
} as QueryConditionText;
|
|
}
|
|
|
|
throw new Error(`Expected value but got ${token.type} at position ${token.position}`);
|
|
};
|
|
|
|
#parseNumber = (): number => {
|
|
const token = this.#expect('NUMBER');
|
|
return parseFloat(token.value);
|
|
};
|
|
|
|
#parseInCondition = (field: string[], isNot: boolean): QueryCondition => {
|
|
this.#expect('LPAREN');
|
|
|
|
const firstToken = this.#current();
|
|
|
|
if (firstToken.type === 'STRING') {
|
|
// Text IN
|
|
const values: string[] = [];
|
|
values.push(this.#advance().value);
|
|
|
|
while (this.#current().type === 'COMMA') {
|
|
this.#advance();
|
|
values.push(this.#expect('STRING').value);
|
|
}
|
|
|
|
this.#expect('RPAREN');
|
|
|
|
return {
|
|
type: 'text',
|
|
field,
|
|
conditions: isNot ? { notIn: values } : { in: values },
|
|
};
|
|
}
|
|
|
|
if (firstToken.type === 'NUMBER') {
|
|
// Numeric IN
|
|
const values: number[] = [];
|
|
values.push(parseFloat(this.#advance().value));
|
|
|
|
while (this.#current().type === 'COMMA') {
|
|
this.#advance();
|
|
values.push(parseFloat(this.#expect('NUMBER').value));
|
|
}
|
|
|
|
this.#expect('RPAREN');
|
|
|
|
return {
|
|
type: 'number',
|
|
field,
|
|
conditions: isNot ? { notIn: values } : { in: values },
|
|
};
|
|
}
|
|
|
|
throw new Error(`Expected STRING or NUMBER in IN list at position ${firstToken.position}`);
|
|
};
|
|
|
|
public parse(input: string): QueryFilter {
|
|
const lexer = new Lexer(input);
|
|
this.#tokens = lexer.tokenize();
|
|
this.#position = 0;
|
|
|
|
const result = this.#parseExpression();
|
|
|
|
if (this.#current().type !== 'EOF') {
|
|
throw new Error(`Unexpected token '${this.#current().value}' at position ${this.#current().position}`);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|
|
|
|
export { Parser };
|