@filtron/sql
Advanced tools
| { | ||
| "version": 3, | ||
| "sources": ["../src/converter.ts"], | ||
| "sourcesContent": [ | ||
| "/**\n * SQL converter for Filtron AST\n * Converts Filtron AST nodes to parameterized SQL WHERE clauses\n */\n\nimport type {\n\tASTNode,\n\tValue,\n\tComparisonOperator,\n\tOrExpression,\n\tAndExpression,\n\tNotExpression,\n\tComparisonExpression,\n\tOneOfExpression,\n\tNotOneOfExpression,\n\tExistsExpression,\n\tBooleanFieldExpression,\n\tRangeExpression,\n} from \"@filtron/core\";\n\n/**\n * SQL generation result\n */\nexport interface SQLResult {\n\t/** SQL WHERE clause (without the WHERE keyword) */\n\tsql: string;\n\t/** Array of parameter values in order */\n\tparams: unknown[];\n}\n\n/**\n * SQL generation options\n */\nexport interface SQLOptions {\n\t/**\n\t * Parameter placeholder style\n\t * - 'numbered': PostgreSQL/DuckDB style ($1, $2, $3)\n\t * - 'question': MySQL/SQLite/DuckDB style (?, ?, ?)\n\t * @default 'numbered'\n\t */\n\tparameterStyle?: \"numbered\" | \"question\";\n\n\t/**\n\t * Custom field name mapper\n\t * Useful for escaping field names or mapping to table columns\n\t * @default (field) => field\n\t */\n\tfieldMapper?: (field: string) => string;\n\n\t/**\n\t * Value mapper for LIKE operator (~)\n\t * Allows adding wildcards or escaping special characters\n\t * Applied to the value before parameterization\n\t * @default (value) => value\n\t *\n\t * @example\n\t * ```typescript\n\t * // Auto-wrap LIKE values with wildcards for \"contains\" search\n\t * toSQL(ast, {\n\t * valueMapper: (value) => `%${escapeLike(String(value))}%`\n\t * })\n\t * ```\n\t */\n\tvalueMapper?: (value: string | number | boolean) => string | number | boolean;\n\n\t/**\n\t * Starting parameter index (for numbered parameters)\n\t * @default 1\n\t */\n\tstartIndex?: number;\n}\n\n/**\n * Internal state for SQL generation\n */\ninterface GeneratorState {\n\tparams: unknown[];\n\tparameterStyle: \"numbered\" | \"question\";\n\tfieldMapper: (field: string) => string;\n\tvalueMapper: (value: string | number | boolean) => string | number | boolean;\n\tparamIndex: number;\n}\n\n/**\n * Converts a Filtron AST to a parameterized SQL WHERE clause\n *\n * @param ast - The Filtron AST node to convert\n * @param options - SQL generation options\n * @returns SQL result with WHERE clause and parameters\n *\n * @example\n * ```typescript\n * import { parse } from 'filtron';\n * import { toSQL } from '@filtron/sql';\n *\n * const ast = parse('age > 18 AND status = \"active\"');\n * if (ast.success) {\n * const { sql, params } = toSQL(ast.ast);\n * console.log(sql); // \"age > $1 AND status = $2\"\n * console.log(params); // [18, \"active\"]\n * }\n * ```\n */\nexport function toSQL(ast: ASTNode, options: SQLOptions = {}): SQLResult {\n\tconst state: GeneratorState = {\n\t\tparams: [],\n\t\tparameterStyle: options.parameterStyle ?? \"numbered\",\n\t\tfieldMapper: options.fieldMapper ?? ((field) => field),\n\t\tvalueMapper: options.valueMapper ?? ((value) => value),\n\t\tparamIndex: options.startIndex ?? 1,\n\t};\n\n\tconst sql = generateSQL(ast, state);\n\n\treturn {\n\t\tsql,\n\t\tparams: state.params,\n\t};\n}\n\n/**\n * Recursively generates SQL from AST nodes\n */\nfunction generateSQL(node: ASTNode, state: GeneratorState): string {\n\tswitch (node.type) {\n\t\tcase \"or\":\n\t\t\treturn generateOr(node, state);\n\t\tcase \"and\":\n\t\t\treturn generateAnd(node, state);\n\t\tcase \"not\":\n\t\t\treturn generateNot(node, state);\n\t\tcase \"comparison\":\n\t\t\treturn generateComparison(node, state);\n\t\tcase \"oneOf\":\n\t\t\treturn generateOneOf(node, state);\n\t\tcase \"notOneOf\":\n\t\t\treturn generateNotOneOf(node, state);\n\t\tcase \"exists\":\n\t\t\treturn generateExists(node, state);\n\t\tcase \"booleanField\":\n\t\t\treturn generateBooleanField(node, state);\n\t\tcase \"range\":\n\t\t\treturn generateRange(node, state);\n\t\tdefault:\n\t\t\t// TypeScript exhaustiveness check\n\t\t\tconst _exhaustive: never = node;\n\t\t\tthrow new Error(`Unknown node type: ${(node as ASTNode).type}`);\n\t}\n}\n\n/**\n * Generates SQL for OR expression\n */\nfunction generateOr(node: OrExpression, state: GeneratorState): string {\n\tconst left = generateSQL(node.left, state);\n\tconst right = generateSQL(node.right, state);\n\treturn `(${left} OR ${right})`;\n}\n\n/**\n * Generates SQL for AND expression\n */\nfunction generateAnd(node: AndExpression, state: GeneratorState): string {\n\tconst left = generateSQL(node.left, state);\n\tconst right = generateSQL(node.right, state);\n\treturn `(${left} AND ${right})`;\n}\n\n/**\n * Generates SQL for NOT expression\n */\nfunction generateNot(node: NotExpression, state: GeneratorState): string {\n\tconst expr = generateSQL(node.expression, state);\n\treturn `NOT (${expr})`;\n}\n\n/**\n * Generates SQL for comparison expression\n */\nfunction generateComparison(node: ComparisonExpression, state: GeneratorState): string {\n\tconst field = state.fieldMapper(node.field);\n\tconst operator = mapComparisonOperator(node.operator);\n\n\t// Apply valueMapper for LIKE operator\n\tlet value = extractValue(node.value);\n\tif (node.operator === \"~\") {\n\t\tvalue = state.valueMapper(value);\n\t}\n\n\tconst param = addParameter(value, state);\n\n\treturn `${field} ${operator} ${param}`;\n}\n\n/**\n * Generates SQL for one-of expression (IN clause)\n */\nfunction generateOneOf(node: OneOfExpression, state: GeneratorState): string {\n\tconst field = state.fieldMapper(node.field);\n\tconst values = node.values.map((v) => extractValue(v));\n\n\tif (values.length === 0) {\n\t\t// Empty IN clause - always false\n\t\treturn \"1 = 0\";\n\t}\n\n\tconst placeholders = values.map((value) => addParameter(value, state));\n\treturn `${field} IN (${placeholders.join(\", \")})`;\n}\n\n/**\n * Generates SQL for not-one-of expression (NOT IN clause)\n */\nfunction generateNotOneOf(node: NotOneOfExpression, state: GeneratorState): string {\n\tconst field = state.fieldMapper(node.field);\n\tconst values = node.values.map((v) => extractValue(v));\n\n\tif (values.length === 0) {\n\t\t// Empty NOT IN clause - always true\n\t\treturn \"1 = 1\";\n\t}\n\n\tconst placeholders = values.map((value) => addParameter(value, state));\n\treturn `${field} NOT IN (${placeholders.join(\", \")})`;\n}\n\n/**\n * Generates SQL for exists expression\n */\nfunction generateExists(node: ExistsExpression, state: GeneratorState): string {\n\tconst field = state.fieldMapper(node.field);\n\treturn `${field} IS NOT NULL`;\n}\n\n/**\n * Generates SQL for boolean field expression\n */\nfunction generateBooleanField(node: BooleanFieldExpression, state: GeneratorState): string {\n\tconst field = state.fieldMapper(node.field);\n\tconst param = addParameter(true, state);\n\treturn `${field} = ${param}`;\n}\n\n/**\n * Generates SQL for range expression (BETWEEN)\n */\nfunction generateRange(node: RangeExpression, state: GeneratorState): string {\n\tconst field = state.fieldMapper(node.field);\n\tconst minParam = addParameter(node.min, state);\n\tconst maxParam = addParameter(node.max, state);\n\treturn `${field} BETWEEN ${minParam} AND ${maxParam}`;\n}\n\n/**\n * Maps Filtron comparison operators to SQL operators\n */\nfunction mapComparisonOperator(operator: ComparisonOperator): string {\n\tswitch (operator) {\n\t\tcase \"=\":\n\t\tcase \":\":\n\t\t\treturn \"=\";\n\t\tcase \"!=\":\n\t\t\treturn \"!=\";\n\t\tcase \"~\":\n\t\t\treturn \"LIKE\";\n\t\tcase \">\":\n\t\t\treturn \">\";\n\t\tcase \">=\":\n\t\t\treturn \">=\";\n\t\tcase \"<\":\n\t\t\treturn \"<\";\n\t\tcase \"<=\":\n\t\t\treturn \"<=\";\n\t\tdefault:\n\t\t\tconst _exhaustive: never = operator;\n\t\t\tthrow new Error(`Unknown operator: ${operator as string}`);\n\t}\n}\n\n/**\n * Extracts the primitive value from a Filtron Value node\n */\nfunction extractValue(value: Value): string | number | boolean {\n\tswitch (value.type) {\n\t\tcase \"string\":\n\t\t\treturn value.value;\n\t\tcase \"number\":\n\t\t\treturn value.value;\n\t\tcase \"boolean\":\n\t\t\treturn value.value;\n\t\tcase \"identifier\":\n\t\t\t// Identifiers are treated as strings in SQL context\n\t\t\treturn value.value;\n\t\tdefault:\n\t\t\tconst _exhaustive: never = value;\n\t\t\tthrow new Error(`Unknown value type: ${(value as Value).type}`);\n\t}\n}\n\n/**\n * Adds a parameter to the state and returns the placeholder\n */\nfunction addParameter(value: unknown, state: GeneratorState): string {\n\tstate.params.push(value);\n\n\tif (state.parameterStyle === \"numbered\") {\n\t\tconst placeholder = `$${state.paramIndex}`;\n\t\tstate.paramIndex++;\n\t\treturn placeholder;\n\t}\n\n\treturn \"?\";\n}\n\n/**\n * Escapes special LIKE characters (%, _, \\) in a string value\n * Use this to prevent LIKE injection when user input is used in LIKE patterns\n *\n * @param value - The value to escape\n * @returns Escaped string safe for use in LIKE patterns\n *\n * @example\n * ```typescript\n * escapeLike(\"admin%\") // \"admin\\\\%\"\n * escapeLike(\"test_user\") // \"test\\\\_user\"\n * ```\n */\nexport function escapeLike(value: string): string {\n\treturn value\n\t\t.replace(/\\\\/g, \"\\\\\\\\\") // Escape backslashes first\n\t\t.replace(/%/g, \"\\\\%\") // Escape % wildcard\n\t\t.replace(/_/g, \"\\\\_\"); // Escape _ single-char wildcard\n}\n\n/**\n * Wraps a value with wildcards for \"contains\" matching\n * Automatically escapes special LIKE characters\n *\n * @param value - The value to wrap\n * @returns Value wrapped with % wildcards\n *\n * @example\n * ```typescript\n * toSQL(ast, { valueMapper: contains })\n * // \"foo\" becomes \"%foo%\"\n * ```\n */\nexport function contains(value: string | number | boolean): string {\n\treturn `%${escapeLike(String(value))}%`;\n}\n\n/**\n * Adds trailing wildcard for \"starts with\" matching\n * Automatically escapes special LIKE characters\n *\n * @param value - The value to wrap\n * @returns Value with % wildcard at the end\n *\n * @example\n * ```typescript\n * toSQL(ast, { valueMapper: prefix })\n * // \"admin\" becomes \"admin%\"\n * ```\n */\nexport function prefix(value: string | number | boolean): string {\n\treturn `${escapeLike(String(value))}%`;\n}\n\n/**\n * Adds leading wildcard for \"ends with\" matching\n * Automatically escapes special LIKE characters\n *\n * @param value - The value to wrap\n * @returns Value with % wildcard at the beginning\n *\n * @example\n * ```typescript\n * toSQL(ast, { valueMapper: suffix })\n * // \".pdf\" becomes \"%.pdf\"\n * ```\n */\nexport function suffix(value: string | number | boolean): string {\n\treturn `%${escapeLike(String(value))}`;\n}\n" | ||
| ], | ||
| "mappings": "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", | ||
| "debugId": "7C5E6C130D5FBAFC64756E2164756E21", | ||
| "names": [] | ||
| } |
+1
-1
@@ -23,3 +23,3 @@ /** | ||
| */ | ||
| export { toSQL } from "./src/converter.js"; | ||
| export { toSQL, escapeLike, contains, prefix, suffix } from "./src/converter.js"; | ||
| export type { SQLResult, SQLOptions } from "./src/converter.js"; |
+4
-1
@@ -1,1 +0,4 @@ | ||
| function L(c,E={}){let q={params:[],parameterStyle:E.parameterStyle??"numbered",fieldMapper:E.fieldMapper??((S)=>S),paramIndex:E.startIndex??1};return{sql:b(c,q),params:q.params}}function b(c,E){switch(c.type){case"or":return T(c,E);case"and":return W(c,E);case"not":return $(c,E);case"comparison":return i(c,E);case"oneOf":return j(c,E);case"notOneOf":return m(c,E);case"exists":return A(c,E);case"booleanField":return D(c,E);default:let q=c;throw Error(`Unknown node type: ${c.type}`)}}function T(c,E){let q=b(c.left,E),R=b(c.right,E);return`(${q} OR ${R})`}function W(c,E){let q=b(c.left,E),R=b(c.right,E);return`(${q} AND ${R})`}function $(c,E){return`NOT (${b(c.expression,E)})`}function i(c,E){let q=E.fieldMapper(c.field),R=F(c.operator),S=w(H(c.value),E);return`${q} ${R} ${S}`}function j(c,E){let q=E.fieldMapper(c.field),R=c.values.map((y)=>H(y));if(R.length===0)return"1 = 0";let S=R.map((y)=>w(y,E));return`${q} IN (${S.join(", ")})`}function m(c,E){let q=E.fieldMapper(c.field),R=c.values.map((y)=>H(y));if(R.length===0)return"1 = 1";let S=R.map((y)=>w(y,E));return`${q} NOT IN (${S.join(", ")})`}function A(c,E){return`${E.fieldMapper(c.field)} IS NOT NULL`}function D(c,E){let q=E.fieldMapper(c.field),R=w(!0,E);return`${q} = ${R}`}function F(c){switch(c){case"=":case":":return"=";case"!=":return"!=";case"~":return"LIKE";case">":return">";case">=":return">=";case"<":return"<";case"<=":return"<=";default:let E=c;throw Error(`Unknown operator: ${c}`)}}function H(c){switch(c.type){case"string":return c.value;case"number":return c.value;case"boolean":return c.value;case"identifier":return c.value;default:let E=c;throw Error(`Unknown value type: ${c.type}`)}}function w(c,E){if(E.params.push(c),E.parameterStyle==="numbered"){let q=`$${E.paramIndex}`;return E.paramIndex++,q}return"?"}export{L as toSQL}; | ||
| function F(q,E={}){let R={params:[],parameterStyle:E.parameterStyle??"numbered",fieldMapper:E.fieldMapper??((H)=>H),valueMapper:E.valueMapper??((H)=>H),paramIndex:E.startIndex??1};return{sql:$(q,R),params:R.params}}function $(q,E){switch(q.type){case"or":return T(q,E);case"and":return b(q,E);case"not":return z(q,E);case"comparison":return A(q,E);case"oneOf":return Q(q,E);case"notOneOf":return U(q,E);case"exists":return C(q,E);case"booleanField":return G(q,E);case"range":return J(q,E);default:let R=q;throw Error(`Unknown node type: ${q.type}`)}}function T(q,E){let R=$(q.left,E),S=$(q.right,E);return`(${R} OR ${S})`}function b(q,E){let R=$(q.left,E),S=$(q.right,E);return`(${R} AND ${S})`}function z(q,E){return`NOT (${$(q.expression,E)})`}function A(q,E){let R=E.fieldMapper(q.field),S=M(q.operator),H=D(q.value);if(q.operator==="~")H=E.valueMapper(H);let W=j(H,E);return`${R} ${S} ${W}`}function Q(q,E){let R=E.fieldMapper(q.field),S=q.values.map((W)=>D(W));if(S.length===0)return"1 = 0";let H=S.map((W)=>j(W,E));return`${R} IN (${H.join(", ")})`}function U(q,E){let R=E.fieldMapper(q.field),S=q.values.map((W)=>D(W));if(S.length===0)return"1 = 1";let H=S.map((W)=>j(W,E));return`${R} NOT IN (${H.join(", ")})`}function C(q,E){return`${E.fieldMapper(q.field)} IS NOT NULL`}function G(q,E){let R=E.fieldMapper(q.field),S=j(!0,E);return`${R} = ${S}`}function J(q,E){let R=E.fieldMapper(q.field),S=j(q.min,E),H=j(q.max,E);return`${R} BETWEEN ${S} AND ${H}`}function M(q){switch(q){case"=":case":":return"=";case"!=":return"!=";case"~":return"LIKE";case">":return">";case">=":return">=";case"<":return"<";case"<=":return"<=";default:let E=q;throw Error(`Unknown operator: ${q}`)}}function D(q){switch(q.type){case"string":return q.value;case"number":return q.value;case"boolean":return q.value;case"identifier":return q.value;default:let E=q;throw Error(`Unknown value type: ${q.type}`)}}function j(q,E){if(E.params.push(q),E.parameterStyle==="numbered"){let R=`$${E.paramIndex}`;return E.paramIndex++,R}return"?"}function y(q){return q.replace(/\\/g,"\\\\").replace(/%/g,"\\%").replace(/_/g,"\\_")}function N(q){return`%${y(String(q))}%`}function X(q){return`${y(String(q))}%`}function Y(q){return`%${y(String(q))}`}export{F as toSQL,Y as suffix,X as prefix,y as escapeLike,N as contains}; | ||
| //# debugId=7C5E6C130D5FBAFC64756E2164756E21 | ||
| //# sourceMappingURL=index.js.map |
@@ -33,2 +33,17 @@ /** | ||
| /** | ||
| * Value mapper for LIKE operator (~) | ||
| * Allows adding wildcards or escaping special characters | ||
| * Applied to the value before parameterization | ||
| * @default (value) => value | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * // Auto-wrap LIKE values with wildcards for "contains" search | ||
| * toSQL(ast, { | ||
| * valueMapper: (value) => `%${escapeLike(String(value))}%` | ||
| * }) | ||
| * ``` | ||
| */ | ||
| valueMapper?: (value: string | number | boolean) => string | number | boolean; | ||
| /** | ||
| * Starting parameter index (for numbered parameters) | ||
@@ -60,1 +75,57 @@ * @default 1 | ||
| export declare function toSQL(ast: ASTNode, options?: SQLOptions): SQLResult; | ||
| /** | ||
| * Escapes special LIKE characters (%, _, \) in a string value | ||
| * Use this to prevent LIKE injection when user input is used in LIKE patterns | ||
| * | ||
| * @param value - The value to escape | ||
| * @returns Escaped string safe for use in LIKE patterns | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * escapeLike("admin%") // "admin\\%" | ||
| * escapeLike("test_user") // "test\\_user" | ||
| * ``` | ||
| */ | ||
| export declare function escapeLike(value: string): string; | ||
| /** | ||
| * Wraps a value with wildcards for "contains" matching | ||
| * Automatically escapes special LIKE characters | ||
| * | ||
| * @param value - The value to wrap | ||
| * @returns Value wrapped with % wildcards | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * toSQL(ast, { valueMapper: contains }) | ||
| * // "foo" becomes "%foo%" | ||
| * ``` | ||
| */ | ||
| export declare function contains(value: string | number | boolean): string; | ||
| /** | ||
| * Adds trailing wildcard for "starts with" matching | ||
| * Automatically escapes special LIKE characters | ||
| * | ||
| * @param value - The value to wrap | ||
| * @returns Value with % wildcard at the end | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * toSQL(ast, { valueMapper: prefix }) | ||
| * // "admin" becomes "admin%" | ||
| * ``` | ||
| */ | ||
| export declare function prefix(value: string | number | boolean): string; | ||
| /** | ||
| * Adds leading wildcard for "ends with" matching | ||
| * Automatically escapes special LIKE characters | ||
| * | ||
| * @param value - The value to wrap | ||
| * @returns Value with % wildcard at the beginning | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * toSQL(ast, { valueMapper: suffix }) | ||
| * // ".pdf" becomes "%.pdf" | ||
| * ``` | ||
| */ | ||
| export declare function suffix(value: string | number | boolean): string; |
+10
-9
| { | ||
| "$schema": "https://json.schemastore.org/package.json", | ||
| "name": "@filtron/sql", | ||
| "version": "1.0.0", | ||
| "version": "1.1.0", | ||
| "description": "SQL WHERE clause generator for Filtron AST with parameterized queries", | ||
@@ -47,10 +47,12 @@ "keywords": [ | ||
| "bench": "bun --expose-gc run benchmark.ts", | ||
| "build": "rm -rf dist && bun build --minify --splitting --outdir=dist --external @filtron/core index.ts && tsc -p tsconfig.build.json", | ||
| "lint": "oxlint --type-aware && oxfmt", | ||
| "build": "bun build --minify --splitting --sourcemap=linked --outdir=dist --external @filtron/core index.ts && tsc", | ||
| "prepublishOnly": "bun run build && bun test", | ||
| "test": "bun test" | ||
| "test": "bun test", | ||
| "typecheck": "tsc --noemit" | ||
| }, | ||
| "dependencies": { | ||
| "@filtron/core": "1.0.0" | ||
| }, | ||
| "peerDependencies": { | ||
| "@filtron/core": "1.0.0", | ||
| "typescript": ">=5.0.0" | ||
| "typescript": "5.9.3" | ||
| }, | ||
@@ -63,4 +65,3 @@ "peerDependenciesMeta": { | ||
| "devDependencies": { | ||
| "@filtron/core": "1.0.0", | ||
| "@types/bun": "1.3.2", | ||
| "@types/bun": "1.3.3", | ||
| "mitata": "1.0.34", | ||
@@ -73,3 +74,3 @@ "typescript": "5.9.3" | ||
| }, | ||
| "packageManager": "bun@1.3.2" | ||
| "packageManager": "bun@1.3.3" | ||
| } |
+22
-358
@@ -5,18 +5,6 @@ # @filtron/sql | ||
| Convert Filtron query language AST to safe, parameterized SQL WHERE clauses that prevent SQL injection. | ||
| ## Features | ||
| - **Safe**: Parameterized queries prevent SQL injection | ||
| - **Flexible**: Support for PostgreSQL/DuckDB ($1), MySQL/SQLite/DuckDB (?) parameter styles | ||
| - **Type-safe**: Full TypeScript support | ||
| - **Customizable**: Map field names to table columns | ||
| - **Zero dependencies**: Only requires `filtron` peer dependency | ||
| ## Installation | ||
| ```bash | ||
| bun add @filtron/core @filtron/sql | ||
| # or | ||
| npm install @filtron/core @filtron/sql | ||
| bun add @filtron/sql | ||
| ``` | ||
@@ -30,13 +18,9 @@ | ||
| // Parse a query | ||
| const result = parse('age > 18 AND status = "active"'); | ||
| if (result.success) { | ||
| // Convert to SQL | ||
| const { sql, params } = toSQL(result.ast); | ||
| // sql: "(age > $1 AND status = $2)" | ||
| // params: [18, "active"] | ||
| console.log(sql); // "(age > $1 AND status = $2)" | ||
| console.log(params); // [18, "active"] | ||
| // Use with your database | ||
| const users = await db.query(`SELECT * FROM users WHERE ${sql}`, params); | ||
@@ -50,363 +34,43 @@ } | ||
| Converts a Filtron AST node to a parameterized SQL WHERE clause. | ||
| Converts a Filtron AST to a parameterized SQL WHERE clause. | ||
| **Parameters:** | ||
| **Options:** | ||
| - `ast` (ASTNode): The Filtron AST to convert | ||
| - `options` (SQLOptions, optional): Generation options | ||
| | Option | Type | Description | | ||
| |--------|------|-------------| | ||
| | `parameterStyle` | `'numbered'` \| `'question'` | Placeholder style: `$1` (default) or `?` | | ||
| | `fieldMapper` | `(field) => string` | Transform field names to column names | | ||
| | `valueMapper` | `(value) => value` | Transform values (useful for LIKE wildcards) | | ||
| | `startIndex` | `number` | Starting parameter index (default: `1`) | | ||
| **Returns:** `SQLResult` | ||
| - `sql` (string): SQL WHERE clause (without the WHERE keyword) | ||
| - `params` (unknown[]): Array of parameter values in order | ||
| ## Parameter Styles | ||
| ### PostgreSQL/DuckDB Style (Default) | ||
| Uses numbered placeholders: `$1`, `$2`, `$3`, etc. | ||
| ```typescript | ||
| const { sql, params } = toSQL(ast); | ||
| // sql: "age > $1 AND status = $2" | ||
| // params: [18, "active"] | ||
| // PostgreSQL | ||
| await client.query(`SELECT * FROM users WHERE ${sql}`, params); | ||
| // DuckDB | ||
| await db.all(`SELECT * FROM users WHERE ${sql}`, ...params); | ||
| ``` | ||
| ### MySQL/SQLite/DuckDB Style | ||
| Uses question mark placeholders: `?`, `?`, `?`, etc. | ||
| ```typescript | ||
| const { sql, params } = toSQL(ast, { | ||
| parameterStyle: "question", | ||
| }); | ||
| // sql: "age > ? AND status = ?" | ||
| // params: [18, "active"] | ||
| // MySQL | ||
| await connection.query(`SELECT * FROM users WHERE ${sql}`, params); | ||
| // SQLite | ||
| db.all(`SELECT * FROM users WHERE ${sql}`, params, callback); | ||
| // DuckDB (also supports this style) | ||
| await db.all(`SELECT * FROM users WHERE ${sql}`, ...params); | ||
| ``` | ||
| ## Field Mapping | ||
| Map Filtron field names to database column names: | ||
| ```typescript | ||
| const { sql, params } = toSQL(ast, { | ||
| parameterStyle: "question", // for MySQL/SQLite | ||
| fieldMapper: (field) => `users.${field}`, | ||
| }); | ||
| // Input: age > 18 | ||
| // Output: users.age > $1 | ||
| ``` | ||
| ### Escaping Field Names | ||
| ### Helper Functions | ||
| ```typescript | ||
| const { sql, params } = toSQL(ast, { | ||
| fieldMapper: (field) => `"${field}"`, | ||
| }); | ||
| // Input: user-name = "john" | ||
| // Output: "user-name" = $1 | ||
| ``` | ||
| Helpers for use with `valueMapper` when using the LIKE operator (`~`): | ||
| ### Table Aliases | ||
| ```typescript | ||
| const { sql, params } = toSQL(ast, { | ||
| fieldMapper: (field) => `u.${field}`, | ||
| }); | ||
| // Input: status = "active" AND verified | ||
| // Output: (u.status = $1 AND u.verified = $2) | ||
| ``` | ||
| import { toSQL, contains, prefix, suffix, escapeLike } from "@filtron/sql"; | ||
| ## Custom Start Index | ||
| // contains("foo") → "%foo%" | ||
| // prefix("foo") → "foo%" | ||
| // suffix("foo") → "%foo" | ||
| // escapeLike("foo%bar") → "foo\\%bar" | ||
| Useful when combining multiple queries: | ||
| ```typescript | ||
| // First query uses $1, $2 | ||
| const query1 = toSQL(ast1); | ||
| // Second query starts at $3 | ||
| const query2 = toSQL(ast2, { | ||
| startIndex: query1.params.length + 1, | ||
| }); | ||
| const sql = `${query1.sql} OR ${query2.sql}`; | ||
| const params = [...query1.params, ...query2.params]; | ||
| const { sql, params } = toSQL(ast, { valueMapper: contains }); | ||
| ``` | ||
| ## Operator Mapping | ||
| | Filtron Operator | SQL Operator | | ||
| | ---------------- | ------------ | | ||
| | `=`, `:` | `=` | | ||
| | `!=` | `!=` | | ||
| | `>` | `>` | | ||
| | `>=` | `>=` | | ||
| | `<` | `<` | | ||
| | `<=` | `<=` | | ||
| | `~` | `LIKE` | | ||
| ## Expression Types | ||
| ### Comparison | ||
| ```typescript | ||
| // Filtron: age > 18 | ||
| // SQL: age > $1 | ||
| // Params: [18] | ||
| ``` | ||
| ### Boolean Logic | ||
| ```typescript | ||
| // Filtron: age > 18 AND status = "active" | ||
| // SQL: (age > $1 AND status = $2) | ||
| // Params: [18, "active"] | ||
| // Filtron: role = "admin" OR role = "mod" | ||
| // SQL: (role = $1 OR role = $2) | ||
| // Params: ["admin", "mod"] | ||
| // Filtron: NOT suspended | ||
| // SQL: NOT (suspended = $1) | ||
| // Params: [true] | ||
| ``` | ||
| ### One-of (IN clause) | ||
| ```typescript | ||
| // Filtron: status : ["pending", "approved", "active"] | ||
| // SQL: status IN ($1, $2, $3) | ||
| // Params: ["pending", "approved", "active"] | ||
| // Filtron: role !: ["guest", "banned"] | ||
| // SQL: role NOT IN ($1, $2) | ||
| // Params: ["guest", "banned"] | ||
| ``` | ||
| ### Field Exists | ||
| ```typescript | ||
| // Filtron: email? | ||
| // SQL: email IS NOT NULL | ||
| // Params: [] | ||
| ``` | ||
| ### Boolean Field | ||
| ```typescript | ||
| // Filtron: verified | ||
| // SQL: verified = $1 | ||
| // Params: [true] | ||
| ``` | ||
| ## Real-world Examples | ||
| ### User Filtering | ||
| ```typescript | ||
| import { parseOrThrow } from "@filtron/core"; | ||
| import { toSQL } from "@filtron/sql"; | ||
| // Parse user input | ||
| const query = 'age >= 18 AND verified AND role : ["user", "premium"]'; | ||
| const ast = parseOrThrow(query); | ||
| // Generate SQL | ||
| const { sql, params } = toSQL(ast, { | ||
| fieldMapper: (field) => `users.${field}`, | ||
| }); | ||
| // Execute query | ||
| const users = await db.query(`SELECT * FROM users WHERE ${sql}`, params); | ||
| ``` | ||
| ### Product Search | ||
| ```typescript | ||
| const query = | ||
| 'price <= 100 AND name ~ "laptop" AND category : ["electronics", "computers"]'; | ||
| const ast = parseOrThrow(query); | ||
| const { sql, params } = toSQL(ast, { | ||
| parameterStyle: "question", | ||
| fieldMapper: (field) => `products.${field}`, | ||
| }); | ||
| const products = await db.all( | ||
| `SELECT * FROM products WHERE ${sql} ORDER BY price`, | ||
| params, | ||
| ); | ||
| ``` | ||
| ### Access Control | ||
| ```typescript | ||
| const query = '(role = "admin" OR role = "moderator") AND NOT suspended'; | ||
| const ast = parseOrThrow(query); | ||
| const { sql, params } = toSQL(ast); | ||
| const hasAccess = await db.get( | ||
| `SELECT COUNT(*) as count FROM users WHERE id = ? AND ${sql}`, | ||
| [userId, ...params], | ||
| ); | ||
| return hasAccess.count > 0; | ||
| ``` | ||
| ### DuckDB - Analytical Queries | ||
| ```typescript | ||
| import { parseOrThrow } from "@filtron/core"; | ||
| import { toSQL } from "@filtron/sql"; | ||
| // Query Parquet files directly | ||
| const query = 'region : ["US", "EU"] AND revenue > 10000'; | ||
| const ast = parseOrThrow(query); | ||
| const { sql, params } = toSQL(ast, { | ||
| parameterStyle: "numbered", // DuckDB supports both $1 and ? | ||
| }); | ||
| // DuckDB excels at analytical queries | ||
| const results = await db.all( | ||
| ` | ||
| SELECT | ||
| region, | ||
| SUM(revenue) as total_revenue, | ||
| COUNT(*) as transaction_count | ||
| FROM read_parquet('sales_*.parquet') | ||
| WHERE ${sql} | ||
| GROUP BY region | ||
| ORDER BY total_revenue DESC | ||
| `, | ||
| ...params, | ||
| ); | ||
| ``` | ||
| ### DuckDB - Time Series Filtering | ||
| ```typescript | ||
| const query = 'sensor_type = "temperature" AND value > 25'; | ||
| const ast = parseOrThrow(query); | ||
| const { sql, params } = toSQL(ast, { | ||
| parameterStyle: "numbered", | ||
| fieldMapper: (field) => `"${field}"`, // Quote field names | ||
| }); | ||
| // Time-series aggregation | ||
| const timeSeries = await db.all( | ||
| ` | ||
| SELECT | ||
| time_bucket('1 hour', timestamp) as hour, | ||
| AVG(value) as avg_value, | ||
| MAX(value) as max_value | ||
| FROM sensor_data | ||
| WHERE ${sql} | ||
| GROUP BY hour | ||
| ORDER BY hour | ||
| `, | ||
| ...params, | ||
| ); | ||
| ``` | ||
| ## Performance | ||
| **SQL conversion adds minimal overhead to query parsing.** | ||
| ### Running Benchmarks | ||
| Comprehensive benchmark suite with detailed metrics: | ||
| ```bash | ||
| bun run bench | ||
| ``` | ||
| Quick summary showing overhead analysis: | ||
| ```bash | ||
| bun run overhead.ts | ||
| ``` | ||
| ### Key Metrics | ||
| - **SQL Conversion Overhead**: ~0.1-0.2μs per query | ||
| - **Impact**: Less than 1% of parse time | ||
| - **Throughput**: 11,000+ queries/sec (parse + SQL generation) | ||
| - **Memory**: Minimal allocations, efficient GC pressure | ||
| ### Benchmark Results | ||
| ``` | ||
| Simple queries: 26-33 μs (parse + SQL) | ||
| Complex queries: 60-130 μs (parse + SQL) | ||
| SQL conversion only: 30-200 ns (isolated) | ||
| ``` | ||
| The overhead of SQL conversion is negligible compared to parsing, making it suitable for real-time API usage where every request may involve parsing and converting user filters. | ||
| ## Security | ||
| **This library generates parameterized queries to prevent SQL injection.** | ||
| This library generates parameterized queries to prevent SQL injection: | ||
| ✅ **Safe** - Values are passed as parameters: | ||
| ```typescript | ||
| const { sql, params } = toSQL(ast); | ||
| db.query(`SELECT * FROM users WHERE ${sql}`, params); | ||
| // SQL: SELECT * FROM users WHERE name = $1 | ||
| // Params: ["admin' OR '1'='1"] | ||
| // Params: ["admin' OR '1'='1"] - safely escaped | ||
| ``` | ||
| ❌ **Unsafe** - Never concatenate values directly: | ||
| ```typescript | ||
| // DON'T DO THIS! | ||
| const unsafeSQL = `SELECT * FROM users WHERE name = '${userInput}'`; | ||
| ``` | ||
| **Note:** Field names from the `fieldMapper` are **not** parameterized. Ensure field names come from trusted sources or validate them before use. | ||
| ## TypeScript | ||
| Full type definitions included: | ||
| ```typescript | ||
| import type { SQLResult, SQLOptions } from "@filtron/sql"; | ||
| const options: SQLOptions = { | ||
| parameterStyle: "numbered", | ||
| fieldMapper: (field) => `t.${field}`, | ||
| startIndex: 1, | ||
| }; | ||
| const result: SQLResult = toSQL(ast, options); | ||
| // result.sql: string | ||
| // result.params: unknown[] | ||
| ``` | ||
| ## License | ||
| MIT - See [LICENSE](../../LICENSE) | ||
| ## Links | ||
| - **Filtron**: https://github.com/jbergstroem/filtron | ||
| - **GitHub**: https://github.com/jbergstroem/filtron/tree/main/packages/sql | ||
| - **npm**: https://www.npmjs.com/package/@filtron/sql |
24990
55.81%3
-25%7
16.67%164
84.27%74
-81.95%+ Added