@abaktiar/ql-parser
Version:
Framework-agnostic QL (Query Language) parser and builder for creating complex queries with support for logical operators, parameterized functions, and ORDER BY clauses
662 lines (661 loc) • 22.1 kB
JavaScript
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
class QLExpressionParser {
constructor() {
__publicField(this, "tokens", []);
__publicField(this, "position", 0);
}
parse(tokens) {
this.tokens = tokens.filter((t) => t.type !== "whitespace");
this.position = 0;
if (this.tokens.length === 0) {
return null;
}
const hasNonKeywordTokens = this.tokens.some((token) => token.type !== "keyword" && token.type !== "whitespace");
if (!hasNonKeywordTokens) {
return null;
}
return this.parseExpression();
}
parseExpression() {
return this.parseOrExpression();
}
parseOrExpression() {
var _a, _b;
let left = this.parseAndExpression();
while (((_a = this.current()) == null ? void 0 : _a.type) === "logical" && ((_b = this.current()) == null ? void 0 : _b.value) === "OR") {
this.advance();
const right = this.parseAndExpression();
if (this.isLogicalGroup(left) && left.operator === "OR") {
left.conditions.push(right);
} else {
left = {
operator: "OR",
conditions: [left, right]
};
}
}
return left;
}
parseAndExpression() {
var _a, _b;
let left = this.parsePrimaryExpression();
while (((_a = this.current()) == null ? void 0 : _a.type) === "logical" && ((_b = this.current()) == null ? void 0 : _b.value) === "AND") {
this.advance();
const right = this.parsePrimaryExpression();
if (this.isLogicalGroup(left) && left.operator === "AND") {
left.conditions.push(right);
} else {
left = {
operator: "AND",
conditions: [left, right]
};
}
}
return left;
}
parsePrimaryExpression() {
var _a, _b;
const token = this.current();
if (!token) {
throw new Error("Unexpected end of input");
}
if (token.type === "logical" && token.value === "NOT") {
this.advance();
const expr = this.parsePrimaryExpression();
if ("operator" in expr && "conditions" in expr) {
return { ...expr, not: true };
} else {
return { ...expr, not: true };
}
}
if (token.type === "parenthesis" && token.value === "(") {
this.advance();
const expr = this.parseExpression();
if (((_a = this.current()) == null ? void 0 : _a.type) !== "parenthesis" || ((_b = this.current()) == null ? void 0 : _b.value) !== ")") {
throw new Error("Expected closing parenthesis");
}
this.advance();
return expr;
}
if (token.type === "field") {
return this.parseCondition();
}
throw new Error(`Unexpected token: ${token.type} "${token.value}"`);
}
parseCondition() {
const fieldToken = this.current();
if (!fieldToken || fieldToken.type !== "field") {
throw new Error("Expected field name");
}
const condition = {
field: fieldToken.value
};
this.advance();
const operatorToken = this.current();
if (!operatorToken || operatorToken.type !== "operator") {
throw new Error("Expected operator");
}
condition.operator = operatorToken.value;
this.advance();
if (this.operatorRequiresValue(condition.operator)) {
condition.value = this.parseValue(condition.operator);
}
return condition;
}
parseValue(operator) {
if (operator === "IN" || operator === "NOT IN") {
return this.parseInList();
}
const token = this.current();
if (!token) {
throw new Error("Expected value");
}
if (token.type === "value" || token.type === "field" || token.type === "function" || token.type === "unknown") {
let value = token.value;
const nextToken = this.tokens[this.position + 1];
if (nextToken && nextToken.type === "parenthesis" && nextToken.value === "(") {
return this.parseFunctionCall(value);
}
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
value = value.slice(1, -1);
}
this.advance();
return value;
}
throw new Error(`Expected value, got ${token.type} "${token.value}"`);
}
parseFunctionCall(functionName) {
var _a, _b, _c, _d, _e;
this.advance();
this.advance();
const parameters = [];
while (this.current() && !(((_a = this.current()) == null ? void 0 : _a.type) === "parenthesis" && ((_b = this.current()) == null ? void 0 : _b.value) === ")")) {
const token = this.current();
if ((token == null ? void 0 : token.type) === "value" || (token == null ? void 0 : token.type) === "field" || (token == null ? void 0 : token.type) === "function" || (token == null ? void 0 : token.type) === "unknown") {
let paramValue = token.value;
const nextToken = this.tokens[this.position + 1];
if (nextToken && nextToken.type === "parenthesis" && nextToken.value === "(") {
paramValue = this.parseFunctionCall(paramValue);
} else {
if (paramValue.startsWith('"') && paramValue.endsWith('"') || paramValue.startsWith("'") && paramValue.endsWith("'")) {
paramValue = paramValue.slice(1, -1);
}
this.advance();
}
parameters.push(paramValue);
if (((_c = this.current()) == null ? void 0 : _c.type) === "comma") {
this.advance();
}
} else {
throw new Error(`Unexpected token in function parameters: ${token == null ? void 0 : token.type} "${token == null ? void 0 : token.value}"`);
}
}
if (((_d = this.current()) == null ? void 0 : _d.type) !== "parenthesis" || ((_e = this.current()) == null ? void 0 : _e.value) !== ")") {
throw new Error("Expected closing parenthesis for function call");
}
this.advance();
if (parameters.length === 0) {
return `${functionName}()`;
} else {
return `${functionName}(${parameters.join(", ")})`;
}
}
parseInList() {
var _a, _b, _c, _d, _e, _f, _g;
if (((_a = this.current()) == null ? void 0 : _a.type) !== "parenthesis" || ((_b = this.current()) == null ? void 0 : _b.value) !== "(") {
throw new Error("Expected opening parenthesis for IN list");
}
this.advance();
const values = [];
while (this.current() && !(((_c = this.current()) == null ? void 0 : _c.type) === "parenthesis" && ((_d = this.current()) == null ? void 0 : _d.value) === ")")) {
const token = this.current();
if ((token == null ? void 0 : token.type) === "value" || (token == null ? void 0 : token.type) === "field" || (token == null ? void 0 : token.type) === "function" || (token == null ? void 0 : token.type) === "unknown") {
let value = token.value;
const nextToken = this.tokens[this.position + 1];
if (nextToken && nextToken.type === "parenthesis" && nextToken.value === "(") {
value = this.parseFunctionCall(value);
} else {
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
value = value.slice(1, -1);
}
this.advance();
}
values.push(value);
if (((_e = this.current()) == null ? void 0 : _e.type) === "comma") {
this.advance();
}
} else {
throw new Error(`Unexpected token in IN list: ${token == null ? void 0 : token.type} "${token == null ? void 0 : token.value}"`);
}
}
if (((_f = this.current()) == null ? void 0 : _f.type) !== "parenthesis" || ((_g = this.current()) == null ? void 0 : _g.value) !== ")") {
throw new Error("Expected closing parenthesis for IN list");
}
this.advance();
return values;
}
operatorRequiresValue(operator) {
return !["IS EMPTY", "IS NOT EMPTY"].includes(operator);
}
current() {
return this.tokens[this.position];
}
advance() {
this.position++;
}
isLogicalGroup(expr) {
return "operator" in expr && "conditions" in expr;
}
}
const LOGICAL_OPERATORS = ["AND", "OR", "NOT"];
const COMPARISON_OPERATORS = ["=", "!=", ">", "<", ">=", "<="];
const TEXT_OPERATORS = ["~", "!~"];
const LIST_OPERATORS = ["IN", "NOT IN"];
const NULL_OPERATORS = ["IS EMPTY", "IS NOT EMPTY"];
const ALL_OPERATORS = [
...COMPARISON_OPERATORS,
...TEXT_OPERATORS,
...LIST_OPERATORS,
...NULL_OPERATORS
];
const KEYWORDS = ["ORDER", "BY", "ASC", "DESC"];
class QLParser {
constructor(_config) {
}
/**
* Tokenize the input string into QL tokens
*/
tokenize(input) {
const tokens = [];
let position = 0;
while (position < input.length) {
const char = input[position];
const remaining = input.slice(position);
if (/\s/.test(char)) {
const start2 = position;
while (position < input.length && /\s/.test(input[position])) {
position++;
}
tokens.push({
type: "whitespace",
value: input.slice(start2, position),
start: start2,
end: position
});
continue;
}
let matched = false;
if (char === '"' || char === "'") {
const quote = char;
const start2 = position;
position++;
while (position < input.length && input[position] !== quote) {
if (input[position] === "\\") {
position += 2;
} else {
position++;
}
}
if (position < input.length) {
position++;
}
tokens.push({
type: "value",
value: input.slice(start2, position),
start: start2,
end: position
});
continue;
}
if (char === "(" || char === ")") {
tokens.push({
type: "parenthesis",
value: char,
start: position,
end: position + 1
});
position++;
continue;
}
if (char === ",") {
tokens.push({
type: "comma",
value: char,
start: position,
end: position + 1
});
position++;
continue;
}
if (["=", "!", ">", "<", "~"].includes(char)) {
const start2 = position;
let operator = char;
if (position + 1 < input.length) {
const nextChar = input[position + 1];
if (char === "!" && nextChar === "=" || char === ">" && nextChar === "=" || char === "<" && nextChar === "=" || char === "!" && nextChar === "~") {
operator += nextChar;
position++;
}
}
tokens.push({
type: "operator",
value: operator,
start: start2,
end: position + 1
});
position++;
continue;
}
for (const op of ALL_OPERATORS.sort((a, b) => b.length - a.length)) {
if (remaining.toUpperCase().startsWith(op)) {
const nextChar = remaining[op.length];
if (!nextChar || /\s/.test(nextChar) || nextChar === "(" || nextChar === ")") {
tokens.push({
type: "operator",
value: op,
// Always store in uppercase for consistency
start: position,
end: position + op.length
});
position += op.length;
matched = true;
break;
}
}
}
if (matched) continue;
for (const op of LOGICAL_OPERATORS) {
if (remaining.toUpperCase().startsWith(op)) {
const nextChar = remaining[op.length];
if (!nextChar || /\s/.test(nextChar) || nextChar === "(" || nextChar === ")") {
tokens.push({
type: "logical",
value: op,
// Always store in uppercase for consistency
start: position,
end: position + op.length
});
position += op.length;
matched = true;
break;
}
}
}
if (matched) continue;
for (const keyword of KEYWORDS) {
if (remaining.toUpperCase().startsWith(keyword)) {
const nextChar = remaining[keyword.length];
if (!nextChar || /\s/.test(nextChar) || nextChar === "(" || nextChar === ")") {
tokens.push({
type: "keyword",
value: keyword,
// Always store in uppercase for consistency
start: position,
end: position + keyword.length
});
position += keyword.length;
matched = true;
break;
}
}
}
if (matched) continue;
const start = position;
while (position < input.length && !/\s/.test(input[position]) && input[position] !== "(" && input[position] !== ")" && input[position] !== "," && input[position] !== '"' && input[position] !== "'") {
position++;
}
if (position > start) {
const value = input.slice(start, position);
tokens.push({
type: "unknown",
// Will be classified later
value,
start,
end: position
});
} else {
position++;
}
}
return this.classifyTokens(tokens);
}
/**
* Classify unknown tokens based on context
*/
classifyTokens(tokens) {
const context = {
expectingField: true,
expectingOperator: false,
expectingValue: false,
expectingLogical: false,
parenthesesLevel: 0,
inOrderBy: false
};
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token.type === "whitespace") continue;
if (token.type === "unknown") {
if (context.expectingField) {
token.type = "field";
context.expectingField = false;
context.expectingOperator = true;
} else if (context.expectingValue) {
token.type = "value";
context.expectingValue = false;
context.expectingLogical = true;
} else {
token.type = "field";
}
}
if (token.type === "field") {
context.expectingOperator = true;
context.expectingField = false;
context.expectingValue = false;
context.expectingLogical = false;
} else if (token.type === "operator") {
context.expectingValue = true;
context.expectingField = false;
context.expectingOperator = false;
context.expectingLogical = false;
} else if (token.type === "value") {
context.expectingLogical = true;
context.expectingField = false;
context.expectingOperator = false;
context.expectingValue = false;
} else if (token.type === "logical") {
context.expectingField = true;
context.expectingOperator = false;
context.expectingValue = false;
context.expectingLogical = false;
} else if (token.type === "parenthesis") {
if (token.value === "(") {
context.parenthesesLevel++;
context.expectingField = true;
context.expectingOperator = false;
context.expectingValue = false;
context.expectingLogical = false;
} else {
context.parenthesesLevel--;
context.expectingLogical = true;
context.expectingField = false;
context.expectingOperator = false;
context.expectingValue = false;
}
} else if (token.type === "keyword") {
if (token.value.toUpperCase() === "ORDER") {
context.inOrderBy = true;
context.expectingField = false;
context.expectingOperator = false;
context.expectingValue = false;
context.expectingLogical = false;
} else if (token.value.toUpperCase() === "BY") {
context.expectingField = true;
context.expectingOperator = false;
context.expectingValue = false;
context.expectingLogical = false;
}
}
}
return tokens;
}
/**
* Parse tokens into a QL query object
*/
parse(input) {
const errors = [];
let whereExpression = void 0;
let orderBy = [];
try {
const { whereClause, orderByClause } = this.splitQuery(input);
if (whereClause.trim()) {
const tokens = this.tokenize(whereClause);
const expressionParser = new QLExpressionParser();
const result = expressionParser.parse(tokens);
whereExpression = result || void 0;
}
if (orderByClause.trim()) {
orderBy = this.parseOrderBy(orderByClause);
}
} catch (error) {
errors.push(error instanceof Error ? error.message : "Parse error");
}
return {
where: whereExpression || void 0,
orderBy,
raw: input,
valid: errors.length === 0,
errors
};
}
splitQuery(input) {
const orderByMatch = input.match(/\s*ORDER\s+BY\s+/i);
if (!orderByMatch) {
return { whereClause: input, orderByClause: "" };
}
const orderByIndex = orderByMatch.index + orderByMatch[0].length;
const whereClause = input.substring(0, orderByMatch.index);
const orderByClause = input.substring(orderByIndex);
return { whereClause, orderByClause };
}
parseOrderBy(orderByClause) {
const orderByItems = [];
const items = orderByClause.split(",").map((item) => item.trim());
for (const item of items) {
const parts = item.trim().split(/\s+/);
if (parts.length === 0 || !parts[0]) {
continue;
}
const field = parts[0];
const direction = parts.length > 1 && parts[1].toUpperCase() === "DESC" ? "DESC" : "ASC";
orderByItems.push({
field,
direction
});
}
return orderByItems;
}
}
function isCondition(expr) {
return "field" in expr && "operator" in expr;
}
function isLogicalGroup(expr) {
return "operator" in expr && "conditions" in expr;
}
function toMongooseQuery(expr) {
if (isCondition(expr)) {
return conditionToMongoDB(expr);
} else if (isLogicalGroup(expr)) {
return logicalGroupToMongoDB(expr);
}
return {};
}
function conditionToMongoDB(condition) {
const { field, operator, value } = condition;
switch (operator) {
case "=":
return { [field]: value };
case "!=":
return { [field]: { $ne: value } };
case ">":
return { [field]: { $gt: value } };
case "<":
return { [field]: { $lt: value } };
case ">=":
return { [field]: { $gte: value } };
case "<=":
return { [field]: { $lte: value } };
case "IN":
return { [field]: { $in: Array.isArray(value) ? value : [value] } };
case "NOT IN":
return { [field]: { $nin: Array.isArray(value) ? value : [value] } };
case "~":
return { [field]: { $regex: value, $options: "i" } };
case "!~":
return { [field]: { $not: { $regex: value, $options: "i" } } };
case "IS EMPTY":
return { $or: [{ [field]: null }, { [field]: { $exists: false } }] };
case "IS NOT EMPTY":
return { [field]: { $ne: null, $exists: true } };
default:
return { [field]: value };
}
}
function logicalGroupToMongoDB(group) {
const conditions = group.conditions.map(toMongooseQuery);
if (group.operator === "AND") {
return { $and: conditions };
} else if (group.operator === "OR") {
return { $or: conditions };
}
return {};
}
function toSQLQuery(expr) {
if (isCondition(expr)) {
return conditionToSQL(expr);
} else if (isLogicalGroup(expr)) {
return logicalGroupToSQL(expr);
}
return "";
}
function conditionToSQL(condition) {
const { field, operator, value } = condition;
switch (operator) {
case "=":
return `${field} = '${value}'`;
case "!=":
return `${field} != '${value}'`;
case ">":
return `${field} > '${value}'`;
case "<":
return `${field} < '${value}'`;
case ">=":
return `${field} >= '${value}'`;
case "<=":
return `${field} <= '${value}'`;
case "IN":
const inValues = Array.isArray(value) ? value : [value];
return `${field} IN (${inValues.map((v) => `'${v}'`).join(", ")})`;
case "NOT IN":
const notInValues = Array.isArray(value) ? value : [value];
return `${field} NOT IN (${notInValues.map((v) => `'${v}'`).join(", ")})`;
case "~":
return `${field} LIKE '%${value}%'`;
case "!~":
return `${field} NOT LIKE '%${value}%'`;
case "IS EMPTY":
return `${field} IS NULL`;
case "IS NOT EMPTY":
return `${field} IS NOT NULL`;
default:
return `${field} = '${value}'`;
}
}
function logicalGroupToSQL(group) {
const conditions = group.conditions.map(toSQLQuery);
const operator = group.operator;
if (conditions.length === 1) {
return conditions[0];
}
return `(${conditions.join(` ${operator} `)})`;
}
function countConditions(expr) {
if (isCondition(expr)) {
return 1;
} else if (isLogicalGroup(expr)) {
return expr.conditions.reduce((count, condition) => count + countConditions(condition), 0);
}
return 0;
}
function getUsedFields(expr) {
if (isCondition(expr)) {
return [expr.field];
} else if (isLogicalGroup(expr)) {
return expr.conditions.flatMap(getUsedFields);
}
return [];
}
function printExpression(expr, indent = 0) {
const spaces = " ".repeat(indent);
if (isCondition(expr)) {
return `${spaces}${expr.field} ${expr.operator} ${Array.isArray(expr.value) ? `[${expr.value.join(", ")}]` : expr.value}`;
} else if (isLogicalGroup(expr)) {
const conditions = expr.conditions.map((c) => printExpression(c, indent + 1)).join("\n");
return `${spaces}${expr.operator}:
${conditions}`;
}
return "";
}
export {
QLExpressionParser,
QLParser,
countConditions,
getUsedFields,
isCondition,
isLogicalGroup,
printExpression,
toMongooseQuery,
toSQLQuery
};
//# sourceMappingURL=index.mjs.map