@travetto/model-query-language
Version:
Datastore query language.
199 lines (179 loc) • 5.49 kB
text/typescript
import { castTo } from '@travetto/runtime';
import { WhereClauseRaw } from '@travetto/model-query';
import { QueryLanguageTokenizer } from './tokenizer.ts';
import { Token, Literal, GroupNode, OP_TRANSLATION, ArrayNode, AllNode } from './types.ts';
/**
* Determine if a token is boolean
*/
function isBoolean(o: unknown): o is Token & { type: 'boolean' } {
return !!o && typeof o === 'object' && 'type' in o && o.type === 'boolean';
}
/**
* Language parser
*/
export class QueryLanguageParser {
/**
* Handle all clauses
*/
static handleClause(nodes: (AllNode | Token)[]): void {
const val: Token | ArrayNode = castTo(nodes.pop());
const op: Token & { value: string } = castTo(nodes.pop());
const ident: Token & { value: string } = castTo(nodes.pop());
// value isn't a literal or a list, bail
if (val.type !== 'literal' && val.type !== 'list') {
throw new Error(`Unexpected token: ${val.value}`);
}
// If operator is not an operator, bail
if (op.type !== 'operator') {
throw new Error(`Unexpected token: ${op.value}`);
}
// If operator is not known, bail
const finalOp = OP_TRANSLATION[op.value];
if (!finalOp) {
throw new Error(`Unexpected operator: ${op.value}`);
}
nodes.push({
type: 'clause',
field: ident.value,
op: finalOp,
value: val.value
});
// Handle unary support
this.unary(nodes);
// Simplify as we go along
this.condense(nodes, 'and');
}
/**
* Condense nodes to remove unnecessary groupings
* (a AND (b AND (c AND d))) => (a AND b AND c)
*/
static condense(nodes: (AllNode | Token)[], op: 'and' | 'or'): void {
let second = nodes[nodes.length - 2];
while (isBoolean(second) && second.value === op) {
const right: AllNode = castTo(nodes.pop());
nodes.pop()!;
const left: AllNode = castTo(nodes.pop());
const rg: GroupNode = castTo(right);
if (rg.type === 'group' && rg.op === op) {
rg.value.unshift(left);
nodes.push(rg);
} else {
nodes.push({
type: 'group',
op,
value: [left, right]
});
}
second = nodes[nodes.length - 2];
}
}
/**
* Remove unnecessary unary nodes
* (((5))) => 5
*/
static unary(nodes: (AllNode | Token)[]): void {
const second = nodes[nodes.length - 2];
if (second && second.type === 'unary' && second.value === 'not') {
const node = nodes.pop();
nodes.pop();
nodes.push({
type: 'unary',
op: 'not',
value: castTo(node)
});
}
}
/**
* Parse all tokens
*/
static parse(tokens: Token[], pos: number = 0): AllNode {
let top: (AllNode | Token)[] = [];
const stack: (typeof top)[] = [top];
let arr: Literal[] | undefined;
let token = tokens[pos];
while (token) {
switch (token.type) {
case 'grouping':
if (token.value === 'start') {
stack.push(top = []);
} else {
const group = stack.pop()!;
top = stack[stack.length - 1];
this.condense(group, 'or');
top.push(group[0]);
this.unary(top);
this.condense(top, 'and');
}
break;
case 'array':
if (token.value === 'start') {
arr = [];
} else {
const arrNode: ArrayNode = { type: 'list', value: arr! };
top.push(arrNode);
arr = undefined;
this.handleClause(top);
}
break;
case 'literal':
if (arr !== undefined) {
arr.push(token.value);
} else {
top.push(token);
this.handleClause(top);
}
break;
case 'punctuation':
if (!arr) {
throw new Error(`Invalid token: ${token.value}`);
}
break;
default:
top.push(token);
}
token = tokens[++pos];
}
this.condense(top, 'or');
return castTo(top[0]);
}
/**
* Convert Query AST to output
*/
static convert<T = unknown>(node: AllNode): WhereClauseRaw<T> {
switch (node.type) {
case 'unary': {
return castTo({ [`$${node.op!}`]: this.convert(node.value) });
}
case 'group': {
return castTo({ [`$${node.op!}`]: node.value.map(x => this.convert(x)) });
}
case 'clause': {
const parts = node.field!.split('.');
const top: WhereClauseRaw<T> = {};
let sub: Record<string, unknown> = top;
for (const p of parts) {
sub = sub[p] = {};
}
if (node.op === '$regex' && typeof node.value === 'string') {
sub[node.op!] = new RegExp(`^${node.value}`);
} else if ((node.op === '$eq' || node.op === '$ne') && node.value === null) {
sub.$exists = node.op !== '$eq';
} else if ((node.op === '$in' || node.op === '$nin') && !Array.isArray(node.value)) {
throw new Error(`Expected array literal for ${node.op}`);
} else {
sub[node.op!] = node.value;
}
return top;
}
default: throw new Error(`Unexpected node type: ${node.type}`);
}
}
/**
* Tokenize and parse text
*/
static parseToQuery<T = unknown>(text: string): WhereClauseRaw<T> {
const tokens = QueryLanguageTokenizer.tokenize(text);
const node = this.parse(tokens);
return this.convert(node);
}
}