feat: add query dsl
This commit is contained in:
317
packages/server/src/query-parser/query-parser.parser.ts
Normal file
317
packages/server/src/query-parser/query-parser.parser.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user