diff --git a/package.json b/package.json index f6742aa..18726dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sqlparser-devexpress", - "version": "2.3.10", + "version": "2.3.16", "main": "src/index.js", "type": "module", "scripts": { diff --git a/src/@types/core/converter.d.ts b/src/@types/core/converter.d.ts new file mode 100644 index 0000000..a19ce0e --- /dev/null +++ b/src/@types/core/converter.d.ts @@ -0,0 +1,22 @@ +import { ASTNode } from "./parser.js"; + +export interface ResultObject { + [key: string]: any; +} + +export type DevExpressFilter = any[] | null; + +export interface ConvertOptions { + ast: ASTNode; + resultObject?: ResultObject; + enableShortCircuit?: boolean; +} + +/** + * Converts an abstract syntax tree (AST) to DevExpress filter format. + * This function uses short-circuit evaluation for optimization. + * + * @param options - The conversion options containing AST, result object, and short-circuit flag. + * @returns DevExpressFilter - The DevExpress compatible filter array or null. + */ +export function convertToDevExpressFormat(options: ConvertOptions): DevExpressFilter; \ No newline at end of file diff --git a/src/@types/core/parser.d.ts b/src/@types/core/parser.d.ts new file mode 100644 index 0000000..fea2d7d --- /dev/null +++ b/src/@types/core/parser.d.ts @@ -0,0 +1,27 @@ +export interface ASTNode { + type: string; + operator?: string; + field?: string; + value?: any; + left?: ASTNode; + right?: ASTNode; + args?: ASTNode[]; + name?: string; +} + +/** + * Represents the result of the parse function. + */ +export interface ParseResult { + ast: ASTNode; + variables: string[]; +} + +/** + * The main parse function that converts SQL-like queries into AST. + * + * @param input - The SQL-like string to be parsed. + * @param variables - The list of extracted variables during parsing. + * @returns ParseResult - The resulting AST and extracted variables. + */ +export function parse(input: string, variables?: string[]): ParseResult; \ No newline at end of file diff --git a/src/@types/core/sanitizer.d.ts b/src/@types/core/sanitizer.d.ts new file mode 100644 index 0000000..2116a88 --- /dev/null +++ b/src/@types/core/sanitizer.d.ts @@ -0,0 +1,28 @@ +/** + * Represents the result of sanitizing the SQL query. + */ +export interface SanitizeResult { + sanitizedSQL: string; + variables: string[]; +} + +/** + * Sanitizes the SQL query by replacing placeholders or pipe-separated variables + * with standardized `{placeholder}` format and extracts all variable names. + * + * Example Input: + * ``` + * SELECT * FROM Orders WHERE CustomerID = {0} | [CustomerID] + * ``` + * + * Output: + * ``` + * { + * sanitizedSQL: "SELECT * FROM Orders WHERE CustomerID = {CustomerID}", + * variables: ["CustomerID"] + * } + * + * @param sql - The raw SQL query containing placeholders or pipes. + * @returns SanitizeResult - The cleaned SQL query and extracted variables. + */ +export function sanitizeQuery(sql: string): SanitizeResult; \ No newline at end of file diff --git a/src/@types/core/tokenizer.d.ts b/src/@types/core/tokenizer.d.ts new file mode 100644 index 0000000..95cfb87 --- /dev/null +++ b/src/@types/core/tokenizer.d.ts @@ -0,0 +1,8 @@ +/** + * Represents a single token from the tokenizer. + */ +export interface Token { + type: string; + value: string; + dataType?: string; +} \ No newline at end of file diff --git a/src/@types/default.d.ts b/src/@types/default.d.ts index a0cb113..c8a58a1 100644 --- a/src/@types/default.d.ts +++ b/src/@types/default.d.ts @@ -1,35 +1,41 @@ -export type StateDataObject = Record; - -export interface SanitizedQuery { - sanitizedSQL: string; - extractedVariables: string[]; -} - -export interface ParsedResult { - ast: any; // Define a more specific type if possible - variables: string[]; -} - -export interface ConvertToDevExpressFormatParams { - ast: any; // Define a more specific type if possible - resultObject?: StateDataObject | null; - enableShortCircuit?: boolean; -} - -export function sanitizeQuery(filterString: string): SanitizedQuery; - -export function parse(query: string, variables: string[]): ParsedResult; - -export function convertToDevExpressFormat(params: ConvertToDevExpressFormatParams): any; - -export function convertSQLToAst( - filterString: string, - SampleData?: StateDataObject | null, - enableConsoleLogs?: boolean -): ParsedResult; - +import { DevExpressFilter, ResultObject } from "./core/converter"; +import { ASTNode, ParseResult } from "./core/parser"; + +/** + * Converts an SQL-like filter string into an Abstract Syntax Tree (AST). + * It also extracts variables from placeholders like `{CustomerID}` or pipe-separated sections. + * Optionally logs the conversion process if `enableConsoleLogs` is `true`. + * + * Example: + * ``` + * const { ast, variables } = convertSQLToAst("ID = {CustomerID} AND Status = {OrderStatus}"); + * console.log(ast); + * console.log(variables); + * ``` + * + * @param filterString - The raw SQL-like filter string. + * @param enableConsoleLogs - Whether to log the parsing and sanitization process. + * @returns ParseResult - The AST and extracted variables. + */ +export function convertSQLToAst(filterString: string, enableConsoleLogs?: boolean): ParseResult; + +/** + * Converts an Abstract Syntax Tree (AST) into a DevExpress-compatible filter format. + * Optionally supports a result object for dynamic value resolution and short-circuit evaluation. + * + * Example: + * ``` + * const filter = convertAstToDevextreme(ast, state, true); + * console.log(filter); + * ``` + * + * @param ast - The parsed AST from `convertSQLToAst`. + * @param state - An optional result object to resolve placeholders to actual values. + * @param enableShortCircuit - Whether to apply short-circuit evaluation. + * @returns DevExpressFilter - The DevExpress-compatible filter array or null. + */ export function convertAstToDevextreme( - ast: any, // Define a more specific type if possible - state?: StateDataObject | null, + ast: ASTNode, + state?: ResultObject | null, enableShortCircuit?: boolean, -): any; +): DevExpressFilter; \ No newline at end of file diff --git a/src/constants.js b/src/constants.js index 0aaefbb..b85ac8e 100644 --- a/src/constants.js +++ b/src/constants.js @@ -10,4 +10,6 @@ export const OPERATOR_PRECEDENCE = { // Regular expression to check for unsupported SQL patterns (like SELECT-FROM or JOIN statements) export const UNSUPPORTED_PATTERN = /\bSELECT\b.*\bFROM\b|\bINNER\s+JOIN\b/i; -export const LOGICAL_OPERATORS = ['and', 'or']; \ No newline at end of file +export const LOGICAL_OPERATORS = ['and', 'or']; + +export const LITERAL_TYPES = ["value", "placeholder"]; \ No newline at end of file diff --git a/src/core/converter.js b/src/core/converter.js index 8724780..0bddabe 100644 --- a/src/core/converter.js +++ b/src/core/converter.js @@ -1,4 +1,4 @@ -import { LOGICAL_OPERATORS } from "../constants.js"; +import { LITERAL_TYPES, LOGICAL_OPERATORS } from "../constants.js"; /** * Main conversion function that sets up the global context @@ -48,7 +48,7 @@ function DevExpressConverter() { return handleFunction(ast); case "field": case "value": - return convertValue(ast.value); + return convertValue(ast.value, parentOperator); default: return null; } @@ -113,6 +113,7 @@ function DevExpressConverter() { if (shouldFlattenLogicalTree(parentOperator, operator, ast)) { return flattenLogicalTree(left, operator, right); } + return [left, operator, right]; } @@ -123,10 +124,11 @@ function DevExpressConverter() { */ function handleComparisonOperator(ast) { const operator = ast.operator.toUpperCase(); + const originalOperator = ast.originalOperator?.toUpperCase(); // Handle "IS NULL" condition - if (operator === "IS" && ast.value === null) { - return [ast.field, "=", null]; + if ((operator === "IS" || originalOperator === "IS") && ast.value === null) { + return [ast.field, "=", null, { type: originalOperator }, null]; } // Handle "IN" condition, including comma-separated values @@ -139,14 +141,21 @@ function DevExpressConverter() { const right = ast.right !== undefined ? processAstNode(ast.right) : convertValue(ast.value); const rightDefault = ast.right?.args[1]?.value; let operatorToken = ast.operator.toLowerCase(); + let includeExtradata = false; if (operatorToken === "like") { operatorToken = "contains"; } else if (operatorToken === "not like") { operatorToken = "notcontains"; + } else if (operatorToken === "=" && originalOperator === "IS") { + includeExtradata = true + } else if (operatorToken == "!=" && originalOperator === "IS NOT") { + operatorToken = "!="; + includeExtradata = true; } - let comparison = [left, operatorToken, right]; + if (includeExtradata) + comparison = [left, operatorToken, right, { type: originalOperator }, right]; // Last null because of special case when using dropdown it https://github.com/DevExpress/DevExtreme/blob/25_1/packages/devextreme/js/__internal/data/m_utils.ts#L18 it takes last value as null if ((ast.left && isFunctionNullCheck(ast.left, true)) || (ast.value && isFunctionNullCheck(ast.value, false))) { @@ -205,22 +214,51 @@ function DevExpressConverter() { resolvedValue = resolvedValue.split(',').map(v => v.trim()); } + // handle short circuit evaluation for IN operator + if (EnableShortCircuit && (LITERAL_TYPES.includes(ast.field?.type) && LITERAL_TYPES.includes(ast.value?.type))) { + const fieldVal = convertValue(ast.field); + if (Array.isArray(resolvedValue)) { + // normalize numeric strings if LHS is number + const list = resolvedValue.map(x => + (typeof x === "string" && !isNaN(x) && typeof fieldVal === "number") + ? Number(x) + : x + ); + + if (operator === "IN") + return list.includes(fieldVal); + else if (operator === "NOT IN") + return !list.includes(fieldVal); + } else if (!Array.isArray(resolvedValue)) { + // normalize numeric strings if LHS is number + const value = (typeof resolvedValue === "string" && !isNaN(resolvedValue) && typeof fieldVal === "number") + ? Number(resolvedValue) + : resolvedValue; + + if (operator === "IN") + return fieldVal == value; + else if (operator === "NOT IN") + return fieldVal != value; + } + } + let operatorToken = operator === "IN" ? '=' : operator === "NOT IN" ? '!=' : operator; let joinOperatorToken = operator === "IN" ? 'or' : operator === "NOT IN" ? 'and' : operator; - + let field = convertValue(ast.field); if (Array.isArray(resolvedValue) && resolvedValue.length) { - return resolvedValue.flatMap(i => [[ast.field, operatorToken, i], joinOperatorToken]).slice(0, -1); + return resolvedValue.flatMap(i => [[field, operatorToken, i], joinOperatorToken]).slice(0, -1); } - return [ast.field, operatorToken, resolvedValue]; + return [field, operatorToken, resolvedValue]; } /** * Converts a single value, resolving placeholders and handling special cases. * @param {*} val - The value to convert. + * @param {string} parentOperator - The operator of the parent logical node (if any). * @returns {*} Converted value. */ - function convertValue(val) { + function convertValue(val, parentOperator = null) { if (val === null) return null; // Handle array values @@ -251,6 +289,10 @@ function DevExpressConverter() { } } + if (parentOperator && parentOperator.toUpperCase() === "IN" && typeof val === "string") { + return val.split(',').map(v => v.trim()); + } + return val; } @@ -368,6 +410,10 @@ function DevExpressConverter() { if ((left !== null && isNaN(left)) || (right !== null && isNaN(right))) return null; + // Handle NULL == 0 OR NULL == "" cases + if (left === null && (right == 0 || right == "")) return true; + if (right === null && (left == 0 || left == "")) return true; + if (left === null || right === null) { if (operator === '=' || operator === '==') return left === right; if (operator === '<>' || operator === '!=') return left !== right; diff --git a/src/core/parser.js b/src/core/parser.js index 88bcc68..9bbc6f7 100644 --- a/src/core/parser.js +++ b/src/core/parser.js @@ -102,15 +102,15 @@ export function parse(input, variables = []) { const rightOperand = parseValue(); // Parse the value after the operator const nodeType = LOGICAL_OPERATORS.includes(operator.toLowerCase()) ? "logical" : "comparison"; - if(nodeType === "logical") { - return { type: "logical", operator, left: { type: "function", name: functionName, args: functionArgs }, right: rightOperand }; + if (nodeType === "logical") { + return { type: "logical", operator, left: { type: "function", name: functionName, args: functionArgs }, right: rightOperand }; } return { type: "comparison", left: { type: "function", name: functionName, args: functionArgs }, operator, - value: rightOperand + value: rightOperand }; } @@ -126,9 +126,21 @@ export function parse(input, variables = []) { const operator = currentToken.value.toUpperCase(); next(); // Move to the next token - // Recursively parse the right-hand expression with adjusted precedence - const right = parseExpression(OPERATOR_PRECEDENCE[operator]); - left = { type: "logical", operator, left, right }; + if (operator === "IN" || operator === "NOT IN") { + const rightList = parseValue(operator); + left = { + type: "comparison", + field: left, + operator: operator, + value: rightList + }; + } + + if (LOGICAL_OPERATORS.includes(operator.toLowerCase())) { + // Recursively parse the right-hand expression with adjusted precedence + const right = parseExpression(OPERATOR_PRECEDENCE[operator]); + left = { type: "logical", operator, left, right }; + } } return left; @@ -166,6 +178,7 @@ export function parse(input, variables = []) { // Check if it's part of a comparison expression if (currentToken && currentToken.type === "operator") { const operator = currentToken.value.toLowerCase(); + const originalOperator = currentToken.originalValue; next(); if (operator === "between") return parseBetweenComparison(field, operator); @@ -173,12 +186,13 @@ export function parse(input, variables = []) { if (currentToken.type === "function") { const functionNode = parseFunction(); - if(fieldType === "identifier" && functionNode.type === "function") { + if (fieldType === "identifier" && functionNode.type === "function") { return { type: "comparison", field, operator, - value: functionNode + value: functionNode, + originalOperator } } @@ -187,9 +201,10 @@ export function parse(input, variables = []) { type: "comparison", field, operator, - value: functionNode.left + value: functionNode.left, + originalOperator }; - + functionNode.left = leftComparison; return functionNode; } @@ -203,7 +218,7 @@ export function parse(input, variables = []) { throw new Error(`Invalid comparison: ${field} ${operator} ${value}`); } - return { type: "comparison", field, operator, value }; + return { type: "comparison", field, operator, value, originalOperator }; } return { type: "field", value: field }; @@ -237,7 +252,7 @@ export function parse(input, variables = []) { case "placeholder": { const val = token.value.slice(1, -1); if (!variables.includes(val)) variables.push(val); - return { ...token ,type: "placeholder", value: val }; + return { ...token, type: "placeholder", value: val }; } case "paren": { diff --git a/src/core/tokenizer.js b/src/core/tokenizer.js index 6cf3a8f..bd6976d 100644 --- a/src/core/tokenizer.js +++ b/src/core/tokenizer.js @@ -45,12 +45,13 @@ class Tokenizer { if (!type || type === "whitespace") return this.nextToken(); let value = match.groups[type]; + const originalValue = value; // Store the original value for debugging let dataType = null; // Remove surrounding single quotes from placeholders - if(type === "placeholder"){ + if (type === "placeholder") { - if(value.startsWith("'") && value.endsWith("'")){ + if (value.startsWith("'") && value.endsWith("'")) { dataType = "string"; } @@ -77,7 +78,7 @@ class Tokenizer { } - return { type, value, ...(dataType !== null && { dataType }) }; + return { type, value, originalValue, ...(dataType !== null && { dataType }) }; } // If no valid token is found, throw an error with the remaining input for debugging diff --git a/tests/parser.test.js b/tests/parser.test.js index a4cb4f3..de3b053 100644 --- a/tests/parser.test.js +++ b/tests/parser.test.js @@ -76,13 +76,13 @@ describe("Parser SQL to dx Filter Builder", () => { ["ToDate", ">=", "2022-01-01"] ], 'or', - ["ToDate", "=", null] + ["ToDate", "=", null, { "type": "IS" }, null] ], "and", [ ["BranchID", "=", 42], "or", - ["RefBranchID", "=", null] + ["RefBranchID", "=", null, { "type": "IS" }, null] ], "and", [ @@ -90,7 +90,7 @@ describe("Parser SQL to dx Filter Builder", () => { // "or", // [7,"=",0], "or", - ["CompanyID", "=", null] + ["CompanyID", "=", null, { "type": "IS" }, null] ] ] }, @@ -112,7 +112,7 @@ describe("Parser SQL to dx Filter Builder", () => { ["CompanyID", "=", 7], // ], "or", - ["CompanyID", "=", null], + ["CompanyID", "=", null, { "type": "IS" }, null], // ], 'or', ["BranchID", "=", 42] @@ -127,9 +127,9 @@ describe("Parser SQL to dx Filter Builder", () => { { input: "BranchID is Null OR BranchID is not 12", expected: [ - ["BranchID", "=", null], + ["BranchID", "=", null, { "type": "IS" }, null], "or", - ["BranchID", "!=", 12] + ["BranchID", "!=", 12, { "type": "IS NOT" }, 12] ] }, { @@ -214,6 +214,52 @@ describe("Parser SQL to dx Filter Builder", () => { { input: "(RS2ID in ({SaleOrderStatusStmtGlobalRpt.StateID}) Or (ISNULL({SaleOrderStatusStmtGlobalRpt.StateID},0) =0)) And (RS3ID in (0,{SaleOrderStatusStmtGlobalRpt.RegionID}) Or ISNULL({SaleOrderStatusStmtGlobalRpt.RegionID},0) =0 )", expected: [] + }, + { + input: "ID IN ({WorkOrderLine.CompanyIDs}) AND 0 IN ({WorkOrderLine.CompanyIDs})", + expected: [ + ["ID", "=", "0"], + "or", + ["ID", "=", "1"] + ] + }, + { + input: "CompanyID is null OR CompanyID is 7 OR CompanyID is not 8", + expected: [ + ["CompanyID", "=", null, { "type": "IS" }, null], + "or", + ["CompanyID", "=", 7, { "type": "IS" }, 7], + "or", + ["CompanyID", "!=", 8, { "type": "IS NOT" }, 8] + ] + }, + { + input: "null = 0", + expected: [] + }, + { + input: "null = null", + expected: [] + }, + { + input: "null = {SaleOrderStatusStmtGlobalRpt.StateID}", + expected: [] + }, + { + input: "null = {SaleOrderStatusStmtGlobalRpt.RegionID}", + expected: [] + }, + { + input: "null = {LeadDocument.CompanyID}", + expected: [] + }, + { + input: "{LeadDocument.BranchID} = null", + expected: [] + }, + { + input: "{LeadDocument.AllowSubDealer} != null", + expected: [] } ]; @@ -277,4 +323,5 @@ const sampleData = { "SupportResolution.TicketID": 123, "SaleOrderStatusStmtGlobalRpt.StateID": null, "SaleOrderStatusStmtGlobalRpt.RegionID": null, + "WorkOrderLine.CompanyIDs": ["0,1"], }; \ No newline at end of file