Files
stash/packages/server/src/query-parser/query-parser.parser.ts
2025-12-09 21:32:09 +01:00

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 };