UNPKG

@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
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