search-input-query-parser 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "search-input-query-parser",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "A parser for advanced search query syntax with field:value support",
5
5
  "keywords": [
6
6
  "search",
@@ -22,6 +22,16 @@
22
22
  "types": "./dist/types/parser.d.ts",
23
23
  "import": "./dist/esm/parser.js",
24
24
  "require": "./dist/cjs/parser.js"
25
+ },
26
+ "./validator": {
27
+ "types": "./dist/types/validator.d.ts",
28
+ "import": "./dist/esm/validator.js",
29
+ "require": "./dist/cjs/validator.js"
30
+ },
31
+ "./search-query-to-sql": {
32
+ "types": "./dist/types/search-query-to-sql.d.ts",
33
+ "import": "./dist/esm/search-query-to-sql.js",
34
+ "require": "./dist/cjs/search-query-to-sql.js"
25
35
  }
26
36
  },
27
37
  "scripts": {
@@ -0,0 +1,332 @@
1
+ import {
2
+ Expression,
3
+ SearchQuery,
4
+ parseSearchInputQuery,
5
+ FieldSchema,
6
+ WildcardPattern,
7
+ } from "./parser";
8
+
9
+ export interface SqlQueryResult {
10
+ text: string;
11
+ values: any[];
12
+ }
13
+
14
+ interface SqlState {
15
+ paramCounter: number;
16
+ values: any[];
17
+ searchableColumns: string[];
18
+ schemas: Map<string, FieldSchema>;
19
+ language?: string; // For tsvector configuration
20
+ }
21
+
22
+ export interface SearchQueryOptions {
23
+ language?: string; // PostgreSQL language configuration for tsvector
24
+ }
25
+
26
+ // Constants
27
+ const SPECIAL_CHARS = ["%", "_"] as const;
28
+ const ESCAPE_CHAR = "\\";
29
+
30
+ // Helper Functions
31
+ const escapeRegExp = (str: string): string =>
32
+ str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
33
+
34
+ const escapeSpecialChars = (value: string): string =>
35
+ SPECIAL_CHARS.reduce(
36
+ (escaped, char) =>
37
+ escaped.replace(new RegExp(escapeRegExp(char), "g"), ESCAPE_CHAR + char),
38
+ value
39
+ );
40
+
41
+ const stripQuotes = (value: string): string => {
42
+ if (value.startsWith('"') && value.endsWith('"')) {
43
+ return value.slice(1, -1);
44
+ }
45
+ return value;
46
+ };
47
+
48
+ const cleanQuotedString = (
49
+ value: string,
50
+ stripOuterQuotes: boolean = true
51
+ ): string => {
52
+ // First strip the outer quotes if requested
53
+ let cleaned = stripOuterQuotes ? stripQuotes(value) : value;
54
+
55
+ // Replace escaped quotes with regular quotes
56
+ cleaned = cleaned.replace(/\\"/g, '"');
57
+
58
+ // Clean up any remaining escape characters
59
+ cleaned = cleaned.replace(/\\\\/g, "\\");
60
+
61
+ return cleaned;
62
+ };
63
+
64
+ // Create a new parameter placeholder and update state
65
+ const nextParam = (state: SqlState): [string, SqlState] => {
66
+ const paramName = `$${state.paramCounter}`;
67
+ const newState = {
68
+ ...state,
69
+ paramCounter: state.paramCounter + 1,
70
+ };
71
+ return [paramName, newState];
72
+ };
73
+
74
+ // Add a value to the state and return updated state
75
+ const addValue = (state: SqlState, value: any): SqlState => ({
76
+ ...state,
77
+ values: [...state.values, value],
78
+ });
79
+
80
+ /**
81
+ * Convert a wildcard pattern to SQL
82
+ */
83
+ const wildcardPatternToSql = (
84
+ expr: WildcardPattern,
85
+ state: SqlState
86
+ ): [string, SqlState] => {
87
+ if (expr.prefix === "") {
88
+ return ["1=1", state];
89
+ }
90
+
91
+ const [paramName, newState] = nextParam(state);
92
+ const cleanedPrefix = cleanQuotedString(expr.prefix);
93
+
94
+ const escapedPrefix = escapeSpecialChars(cleanedPrefix);
95
+ const conditions = state.searchableColumns.map(
96
+ (column) => `lower(${column}) LIKE lower(${paramName})`
97
+ );
98
+ const sql =
99
+ conditions.length === 1
100
+ ? conditions[0]
101
+ : `(${conditions.join(" OR ")})`;
102
+ return [sql, addValue(newState, `${escapedPrefix}%`)];
103
+ };
104
+
105
+ /**
106
+ * Convert a search term to SQL conditions based on search type
107
+ */
108
+ const searchTermToSql = (
109
+ value: string,
110
+ state: SqlState
111
+ ): [string, SqlState] => {
112
+ const [paramName, newState] = nextParam(state);
113
+ const hasWildcard = value.endsWith("*");
114
+ const cleanedValue = cleanQuotedString(value);
115
+ const baseValue = hasWildcard ? cleanedValue.slice(0, -1) : cleanedValue;
116
+
117
+ // Use lower() for case-insensitive search in SQLite
118
+ const escapedTerm = escapeSpecialChars(baseValue);
119
+ const conditions = state.searchableColumns.map(
120
+ (column) => `lower(${column}) LIKE lower(${paramName})`
121
+ );
122
+ const sql =
123
+ conditions.length === 1
124
+ ? conditions[0]
125
+ : `(${conditions.join(" OR ")})`;
126
+
127
+ if (hasWildcard) {
128
+ return [sql, addValue(newState, `${escapedTerm}%`)];
129
+ } else {
130
+ return [sql, addValue(newState, `%${escapedTerm}%`)];
131
+ }
132
+ };
133
+
134
+ /**
135
+ * Convert a field:value pair to SQL based on search type
136
+ */
137
+ const fieldValueToSql = (
138
+ field: string,
139
+ value: string,
140
+ state: SqlState
141
+ ): [string, SqlState] => {
142
+ const [paramName, newState] = nextParam(state);
143
+ const schema = state.schemas.get(field.toLowerCase());
144
+ const hasWildcard = value.endsWith("*");
145
+ const cleanedValue = cleanQuotedString(value);
146
+ const baseValue = hasWildcard ? cleanedValue.slice(0, -1) : cleanedValue;
147
+
148
+ // Rest of the function remains the same...
149
+ switch (schema?.type) {
150
+ case "date":
151
+ return [
152
+ `${field} = ${paramName}`,
153
+ addValue(newState, cleanedValue),
154
+ ];
155
+ case "number":
156
+ return [
157
+ `${field} = ${paramName}`,
158
+ addValue(newState, Number(cleanedValue)),
159
+ ];
160
+ default:
161
+ const escapedValue = escapeSpecialChars(baseValue);
162
+ return [
163
+ `lower(${field}) LIKE lower(${paramName})`,
164
+ addValue(
165
+ newState,
166
+ hasWildcard ? `${escapedValue}%` : `%${escapedValue}%`
167
+ ),
168
+ ];
169
+ }
170
+ };
171
+
172
+ /**
173
+ * Convert a range expression to SQL
174
+ */
175
+ const rangeToSql = (
176
+ field: string,
177
+ operator: string,
178
+ value: string,
179
+ value2: string | undefined,
180
+ state: SqlState
181
+ ): [string, SqlState] => {
182
+ const schema = state.schemas.get(field.toLowerCase());
183
+ const isDateField = schema?.type === "date";
184
+
185
+ if (operator === "BETWEEN" && value2) {
186
+ const [param1, state1] = nextParam(state);
187
+ const [param2, state2] = nextParam(state1);
188
+ let val1 = isDateField ? value : Number(value);
189
+ let val2 = isDateField ? value2 : Number(value2);
190
+ return [
191
+ `${field} BETWEEN ${param1} AND ${param2}`,
192
+ addValue(addValue(state2, val1), val2),
193
+ ];
194
+ }
195
+
196
+ const [paramName, newState] = nextParam(state);
197
+ const val = isDateField ? value : Number(value);
198
+ return [
199
+ `${field} ${operator} ${paramName}`,
200
+ addValue(newState, val),
201
+ ];
202
+ };
203
+
204
+ const inExpressionToSql = (
205
+ field: string,
206
+ values: string[],
207
+ state: SqlState
208
+ ): [string, SqlState] => {
209
+ let currentState = state;
210
+ const cleanedValues = values.map((v) => cleanQuotedString(v));
211
+
212
+ const paramNames: string[] = [];
213
+ const schema = state.schemas.get(field.toLowerCase());
214
+
215
+ for (const value of cleanedValues) {
216
+ const [paramName, newState] = nextParam(currentState);
217
+ paramNames.push(paramName);
218
+ currentState = addValue(
219
+ newState,
220
+ schema?.type === "number" ? Number(value) : value
221
+ );
222
+ }
223
+
224
+ return [
225
+ `${field} IN (${paramNames
226
+ .map((p) => p)
227
+ .join(", ")})`,
228
+ currentState,
229
+ ];
230
+ };
231
+
232
+ /**
233
+ * Convert a binary operation (AND/OR) to SQL
234
+ */
235
+ const binaryOpToSql = (
236
+ operator: string,
237
+ left: Expression,
238
+ right: Expression,
239
+ state: SqlState
240
+ ): [string, SqlState] => {
241
+ const [leftText, leftState] = expressionToSql(left, state);
242
+ const [rightText, rightState] = expressionToSql(right, leftState);
243
+ return [`(${leftText} ${operator} ${rightText})`, rightState];
244
+ };
245
+
246
+ /**
247
+ * Convert a single expression to SQL
248
+ */
249
+ const expressionToSql = (
250
+ expr: Expression,
251
+ state: SqlState
252
+ ): [string, SqlState] => {
253
+ switch (expr.type) {
254
+ case "SEARCH_TERM":
255
+ return searchTermToSql(expr.value, state);
256
+
257
+ case "WILDCARD":
258
+ return wildcardPatternToSql(expr, state);
259
+
260
+ case "IN":
261
+ return inExpressionToSql(
262
+ expr.field.value,
263
+ expr.values.map((v) => v.value),
264
+ state
265
+ );
266
+
267
+ case "FIELD_VALUE":
268
+ return fieldValueToSql(expr.field.value, expr.value.value, state);
269
+
270
+ case "RANGE":
271
+ return rangeToSql(
272
+ expr.field.value,
273
+ expr.operator,
274
+ expr.value.value,
275
+ expr.value2?.value,
276
+ state
277
+ );
278
+
279
+ case "AND":
280
+ return binaryOpToSql("AND", expr.left, expr.right, state);
281
+
282
+ case "OR":
283
+ return binaryOpToSql("OR", expr.left, expr.right, state);
284
+
285
+ case "NOT": {
286
+ const [sqlText, newState] = expressionToSql(expr.expression, state);
287
+ return [`NOT ${sqlText}`, newState];
288
+ }
289
+ }
290
+ };
291
+
292
+ /**
293
+ * Convert a SearchQuery to a SQL WHERE clause with specified search type
294
+ */
295
+ export const searchQueryToIlikeSql = (
296
+ query: SearchQuery,
297
+ searchableColumns: string[],
298
+ schemas: FieldSchema[] = [],
299
+ options: SearchQueryOptions = {}
300
+ ): SqlQueryResult => {
301
+ const initialState: SqlState = {
302
+ paramCounter: 1,
303
+ values: [],
304
+ searchableColumns,
305
+ schemas: new Map(schemas.map((s) => [s.name.toLowerCase(), s])),
306
+ language: options.language,
307
+ };
308
+
309
+ if (!query.expression) {
310
+ return { text: "1=1", values: [] };
311
+ }
312
+
313
+ const [text, finalState] = expressionToSql(query.expression, initialState);
314
+ return { text, values: finalState.values };
315
+ };
316
+
317
+ /**
318
+ * Convert a search string directly to SQL
319
+ */
320
+ export const searchStringToIlikeSql = (
321
+ searchString: string,
322
+ searchableColumns: string[],
323
+ schemas: FieldSchema[] = [],
324
+ options: SearchQueryOptions = {}
325
+ ): SqlQueryResult => {
326
+ const query = parseSearchInputQuery(searchString, schemas);
327
+ if (query.type === "SEARCH_QUERY_ERROR") {
328
+ throw new Error(`Parse error: ${query.errors[0].message}`);
329
+ }
330
+
331
+ return searchQueryToIlikeSql(query, searchableColumns, schemas, options);
332
+ };
@@ -0,0 +1,346 @@
1
+ import {
2
+ Expression,
3
+ SearchQuery,
4
+ parseSearchInputQuery,
5
+ FieldSchema,
6
+ WildcardPattern,
7
+ } from "./parser";
8
+
9
+ export interface SqlQueryResult {
10
+ text: string;
11
+ values: any[];
12
+ }
13
+
14
+ interface SqlState {
15
+ paramCounter: number;
16
+ values: any[];
17
+ searchableColumns: string[];
18
+ schemas: Map<string, FieldSchema>;
19
+ language?: string; // For tsvector configuration
20
+ }
21
+
22
+ export interface SearchQueryOptions {
23
+ language?: string; // PostgreSQL language configuration for tsvector
24
+ }
25
+
26
+ // Helper Functions
27
+ const escapeRegExp = (str: string): string =>
28
+ str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
29
+
30
+ // Helper to escape special characters for ParadeDB query syntax
31
+ const escapeParadeDBChars = (value: string): string => {
32
+ const specialChars = [
33
+ "+",
34
+ "^",
35
+ "`",
36
+ ":",
37
+ "{",
38
+ "}",
39
+ '"',
40
+ "[",
41
+ "]",
42
+ "(",
43
+ ")",
44
+ "<",
45
+ ">",
46
+ "~",
47
+ "!",
48
+ "\\",
49
+ "*",
50
+ ];
51
+ return specialChars.reduce(
52
+ (escaped, char) =>
53
+ escaped.replace(new RegExp(escapeRegExp(char), "g"), `\\${char}`),
54
+ value
55
+ );
56
+ };
57
+
58
+ const stripQuotes = (value: string): string => {
59
+ if (value.startsWith('"') && value.endsWith('"')) {
60
+ return value.slice(1, -1);
61
+ }
62
+ return value;
63
+ };
64
+
65
+ const cleanQuotedString = (
66
+ value: string,
67
+ stripOuterQuotes: boolean = true
68
+ ): string => {
69
+ // First strip the outer quotes if requested
70
+ let cleaned = stripOuterQuotes ? stripQuotes(value) : value;
71
+
72
+ // Replace escaped quotes with regular quotes
73
+ cleaned = cleaned.replace(/\\"/g, '"');
74
+
75
+ // Clean up any remaining escape characters
76
+ cleaned = cleaned.replace(/\\\\/g, "\\");
77
+
78
+ return cleaned;
79
+ };
80
+
81
+ const prepareParadeDBString = (
82
+ value: string,
83
+ includeWildcard: boolean = false
84
+ ): string => {
85
+ // First clean up the string
86
+ const cleaned = cleanQuotedString(value);
87
+
88
+ // For ParadeDB, we need to:
89
+ // 1. Escape special characters (except wildcards)
90
+ // 2. Wrap in quotes
91
+ // 3. Add wildcard if needed
92
+ const escaped = escapeParadeDBChars(cleaned);
93
+ const result = `"${escaped}"`;
94
+ return includeWildcard ? `${result}*` : result;
95
+ };
96
+
97
+ // Create a new parameter placeholder and update state
98
+ const nextParam = (state: SqlState): [string, SqlState] => {
99
+ const paramName = `$${state.paramCounter}`;
100
+ const newState = {
101
+ ...state,
102
+ paramCounter: state.paramCounter + 1,
103
+ };
104
+ return [paramName, newState];
105
+ };
106
+
107
+ // Add a value to the state and return updated state
108
+ const addValue = (state: SqlState, value: any): SqlState => ({
109
+ ...state,
110
+ values: [...state.values, value],
111
+ });
112
+
113
+ /**
114
+ * Convert a wildcard pattern to SQL
115
+ */
116
+ const wildcardPatternToSql = (
117
+ expr: WildcardPattern,
118
+ state: SqlState
119
+ ): [string, SqlState] => {
120
+ if (expr.prefix === "") {
121
+ return ["1=1", state];
122
+ }
123
+
124
+ const [paramName, newState] = nextParam(state);
125
+ const cleanedPrefix = cleanQuotedString(expr.prefix);
126
+
127
+ const queryValue = prepareParadeDBString(cleanedPrefix, true);
128
+ const conditions = state.searchableColumns.map(
129
+ (column) => `${column} @@@ ${paramName}`
130
+ );
131
+ const sql =
132
+ conditions.length === 1
133
+ ? conditions[0]
134
+ : `(${conditions.join(" OR ")})`;
135
+ return [sql, addValue(newState, queryValue)];
136
+ };
137
+
138
+ /**
139
+ * Convert a search term to SQL conditions based on search type
140
+ */
141
+ const searchTermToSql = (
142
+ value: string,
143
+ state: SqlState
144
+ ): [string, SqlState] => {
145
+ const [paramName, newState] = nextParam(state);
146
+ const hasWildcard = value.endsWith("*");
147
+ const cleanedValue = cleanQuotedString(value);
148
+ const baseValue = hasWildcard ? cleanedValue.slice(0, -1) : cleanedValue;
149
+
150
+ const queryValue = prepareParadeDBString(baseValue, hasWildcard);
151
+ const conditions = state.searchableColumns.map(
152
+ (column) => `${column} @@@ ${paramName}`
153
+ );
154
+ const sql =
155
+ conditions.length === 1
156
+ ? conditions[0]
157
+ : `(${conditions.join(" OR ")})`;
158
+ return [sql, addValue(newState, queryValue)];
159
+ };
160
+
161
+ /**
162
+ * Convert a field:value pair to SQL based on search type
163
+ */
164
+ const fieldValueToSql = (
165
+ field: string,
166
+ value: string,
167
+ state: SqlState
168
+ ): [string, SqlState] => {
169
+ const [paramName, newState] = nextParam(state);
170
+ const schema = state.schemas.get(field.toLowerCase());
171
+ const hasWildcard = value.endsWith("*");
172
+ const cleanedValue = cleanQuotedString(value);
173
+ const baseValue = hasWildcard ? cleanedValue.slice(0, -1) : cleanedValue;
174
+
175
+ switch (schema?.type) {
176
+ case "date": {
177
+ // Use parameter binding for dates
178
+ const [dateParam, dateState] = nextParam(state);
179
+ return [
180
+ `${field} @@@ '"' || ${dateParam} || '"'`,
181
+ addValue(dateState, baseValue),
182
+ ];
183
+ }
184
+ case "number":
185
+ return [`${field} @@@ '${baseValue}'`, newState];
186
+ default: {
187
+ const queryValue = prepareParadeDBString(baseValue, hasWildcard);
188
+ return [`${field} @@@ ${paramName}`, addValue(newState, queryValue)];
189
+ }
190
+ }
191
+ };
192
+
193
+ /**
194
+ * Convert a range expression to SQL
195
+ */
196
+ const rangeToSql = (
197
+ field: string,
198
+ operator: string,
199
+ value: string,
200
+ value2: string | undefined,
201
+ state: SqlState
202
+ ): [string, SqlState] => {
203
+ const schema = state.schemas.get(field.toLowerCase());
204
+ const isDateField = schema?.type === "date";
205
+
206
+ if (operator === "BETWEEN" && value2) {
207
+ const [param1, state1] = nextParam(state);
208
+ const [param2, state2] = nextParam(state1);
209
+ let val1 = isDateField ? value : Number(value);
210
+ let val2 = isDateField ? value2 : Number(value2);
211
+ return [
212
+ `${field} @@@ '[' || ${param1} || ' TO ' || ${param2} || ']'`,
213
+ addValue(addValue(state2, val1), val2),
214
+ ];
215
+ } else {
216
+ const [paramName, newState] = nextParam(state);
217
+ const rangeOp = operator.replace(">=", ">=").replace("<=", "<=");
218
+ const val = isDateField ? value : Number(value);
219
+ return [
220
+ `${field} @@@ '${rangeOp}' || ${paramName}`,
221
+ addValue(newState, val),
222
+ ];
223
+ }
224
+ };
225
+
226
+ const inExpressionToSql = (
227
+ field: string,
228
+ values: string[],
229
+ state: SqlState
230
+ ): [string, SqlState] => {
231
+ let currentState = state;
232
+ const cleanedValues = values.map((v) => cleanQuotedString(v));
233
+
234
+ const paramNames: string[] = [];
235
+
236
+ for (const value of cleanedValues) {
237
+ const [paramName, newState] = nextParam(currentState);
238
+ paramNames.push(paramName);
239
+ currentState = addValue(newState, value);
240
+ }
241
+
242
+ const concatExpr = paramNames.join(" || ' ' || ");
243
+ return [`${field} @@@ 'IN[' || ${concatExpr} || ']'`, currentState];
244
+ };
245
+
246
+ /**
247
+ * Convert a binary operation (AND/OR) to SQL
248
+ */
249
+ const binaryOpToSql = (
250
+ operator: string,
251
+ left: Expression,
252
+ right: Expression,
253
+ state: SqlState
254
+ ): [string, SqlState] => {
255
+ const [leftText, leftState] = expressionToSql(left, state);
256
+ const [rightText, rightState] = expressionToSql(right, leftState);
257
+ return [`(${leftText} ${operator} ${rightText})`, rightState];
258
+ };
259
+
260
+ /**
261
+ * Convert a single expression to SQL
262
+ */
263
+ const expressionToSql = (
264
+ expr: Expression,
265
+ state: SqlState
266
+ ): [string, SqlState] => {
267
+ switch (expr.type) {
268
+ case "SEARCH_TERM":
269
+ return searchTermToSql(expr.value, state);
270
+
271
+ case "WILDCARD":
272
+ return wildcardPatternToSql(expr, state);
273
+
274
+ case "IN":
275
+ return inExpressionToSql(
276
+ expr.field.value,
277
+ expr.values.map((v) => v.value),
278
+ state
279
+ );
280
+
281
+ case "FIELD_VALUE":
282
+ return fieldValueToSql(expr.field.value, expr.value.value, state);
283
+
284
+ case "RANGE":
285
+ return rangeToSql(
286
+ expr.field.value,
287
+ expr.operator,
288
+ expr.value.value,
289
+ expr.value2?.value,
290
+ state
291
+ );
292
+
293
+ case "AND":
294
+ return binaryOpToSql("AND", expr.left, expr.right, state);
295
+
296
+ case "OR":
297
+ return binaryOpToSql("OR", expr.left, expr.right, state);
298
+
299
+ case "NOT": {
300
+ const [sqlText, newState] = expressionToSql(expr.expression, state);
301
+ return [`NOT ${sqlText}`, newState];
302
+ }
303
+ }
304
+ };
305
+
306
+ /**
307
+ * Convert a SearchQuery to a SQL WHERE clause with specified search type
308
+ */
309
+ export const searchQueryToParadeDbSql = (
310
+ query: SearchQuery,
311
+ searchableColumns: string[],
312
+ schemas: FieldSchema[] = [],
313
+ options: SearchQueryOptions = {}
314
+ ): SqlQueryResult => {
315
+ const initialState: SqlState = {
316
+ paramCounter: 1,
317
+ values: [],
318
+ searchableColumns,
319
+ schemas: new Map(schemas.map((s) => [s.name.toLowerCase(), s])),
320
+ language: options.language,
321
+ };
322
+
323
+ if (!query.expression) {
324
+ return { text: "1=1", values: [] };
325
+ }
326
+
327
+ const [text, finalState] = expressionToSql(query.expression, initialState);
328
+ return { text, values: finalState.values };
329
+ };
330
+
331
+ /**
332
+ * Convert a search string directly to SQL
333
+ */
334
+ export const searchStringToParadeDbSql = (
335
+ searchString: string,
336
+ searchableColumns: string[],
337
+ schemas: FieldSchema[] = [],
338
+ options: SearchQueryOptions = {}
339
+ ): SqlQueryResult => {
340
+ const query = parseSearchInputQuery(searchString, schemas);
341
+ if (query.type === "SEARCH_QUERY_ERROR") {
342
+ throw new Error(`Parse error: ${query.errors[0].message}`);
343
+ }
344
+
345
+ return searchQueryToParadeDbSql(query, searchableColumns, schemas, options);
346
+ };