@gblikas/querykit 0.0.0 → 0.2.0

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.
@@ -7,6 +7,7 @@ import type {
7
7
  LiqeQuery,
8
8
  LogicalExpressionToken,
9
9
  ParenthesizedExpressionToken,
10
+ RangeExpressionToken,
10
11
  TagToken,
11
12
  UnaryOperatorToken
12
13
  } from 'liqe';
@@ -48,7 +49,9 @@ export class QueryParser implements IQueryParser {
48
49
  */
49
50
  public parse(query: string): QueryExpression {
50
51
  try {
51
- const liqeAst = liqeParse(query);
52
+ // Pre-process the query to handle IN operator syntax
53
+ const preprocessedQuery = this.preprocessQuery(query);
54
+ const liqeAst = liqeParse(preprocessedQuery);
52
55
  return this.convertLiqeAst(liqeAst);
53
56
  } catch (error) {
54
57
  throw new QueryParseError(
@@ -57,12 +60,155 @@ export class QueryParser implements IQueryParser {
57
60
  }
58
61
  }
59
62
 
63
+ /**
64
+ * Pre-process a query string to convert non-standard syntax to Liqe-compatible syntax.
65
+ * Supports:
66
+ * - `field:[val1, val2, val3]` → `(field:val1 OR field:val2 OR field:val3)`
67
+ *
68
+ * This keeps the syntax consistent with the `key:value` pattern used throughout QueryKit:
69
+ * - `priority:>2` (comparison)
70
+ * - `status:active` (equality)
71
+ * - `status:[todo, doing, done]` (IN / multiple values)
72
+ */
73
+ private preprocessQuery(query: string): string {
74
+ let result = query;
75
+
76
+ // Handle `field:[val1, val2, ...]` syntax (array-like, not range)
77
+ // Pattern: fieldName:[value1, value2, ...]
78
+ // We distinguish from range by checking for commas without "TO"
79
+ const bracketArrayPattern = /(\w+):\[([^\]]+)\]/g;
80
+ result = result.replace(bracketArrayPattern, (fullMatch, field, values) => {
81
+ // Check if this looks like a range expression (contains " TO ")
82
+ if (/\s+TO\s+/i.test(values)) {
83
+ // This is a range expression, keep as-is
84
+ return fullMatch;
85
+ }
86
+ // This is an array-like expression, convert to OR
87
+ return this.convertToOrExpression(field, values);
88
+ });
89
+
90
+ return result;
91
+ }
92
+
93
+ /**
94
+ * Convert a field and comma-separated values to an OR expression string
95
+ */
96
+ private convertToOrExpression(field: string, valuesStr: string): string {
97
+ // Parse values respecting quoted strings (commas inside quotes are preserved)
98
+ const values = this.parseCommaSeparatedValues(valuesStr);
99
+
100
+ if (values.length === 0) {
101
+ return `${field}:""`;
102
+ }
103
+
104
+ if (values.length === 1) {
105
+ return this.formatFieldValue(field, values[0]);
106
+ }
107
+
108
+ // Build OR expression: (field:val1 OR field:val2 OR ...)
109
+ const orClauses = values.map((v: string) =>
110
+ this.formatFieldValue(field, v)
111
+ );
112
+ return `(${orClauses.join(' OR ')})`;
113
+ }
114
+
115
+ /**
116
+ * Parse a comma-separated string into values, respecting quoted strings.
117
+ * Commas inside quoted strings are preserved as part of the value.
118
+ *
119
+ * Examples:
120
+ * - `a, b, c` → ['a', 'b', 'c']
121
+ * - `"John, Jr.", Jane` → ['"John, Jr."', 'Jane']
122
+ * - `'hello, world', test` → ["'hello, world'", 'test']
123
+ */
124
+ private parseCommaSeparatedValues(input: string): string[] {
125
+ const values: string[] = [];
126
+ let current = '';
127
+ let inDoubleQuotes = false;
128
+ let inSingleQuotes = false;
129
+ let i = 0;
130
+
131
+ while (i < input.length) {
132
+ const char = input[i];
133
+ const nextChar = input[i + 1];
134
+
135
+ // Handle escape sequences inside quotes
136
+ if ((inDoubleQuotes || inSingleQuotes) && char === '\\' && nextChar) {
137
+ // Include both the backslash and the escaped character
138
+ current += char + nextChar;
139
+ i += 2;
140
+ continue;
141
+ }
142
+
143
+ // Toggle double quote state
144
+ if (char === '"' && !inSingleQuotes) {
145
+ inDoubleQuotes = !inDoubleQuotes;
146
+ current += char;
147
+ i++;
148
+ continue;
149
+ }
150
+
151
+ // Toggle single quote state
152
+ if (char === "'" && !inDoubleQuotes) {
153
+ inSingleQuotes = !inSingleQuotes;
154
+ current += char;
155
+ i++;
156
+ continue;
157
+ }
158
+
159
+ // Handle comma as separator (only when not inside quotes)
160
+ if (char === ',' && !inDoubleQuotes && !inSingleQuotes) {
161
+ const trimmed = current.trim();
162
+ if (trimmed.length > 0) {
163
+ values.push(trimmed);
164
+ }
165
+ current = '';
166
+ i++;
167
+ continue;
168
+ }
169
+
170
+ // Regular character
171
+ current += char;
172
+ i++;
173
+ }
174
+
175
+ // Don't forget the last value
176
+ const trimmed = current.trim();
177
+ if (trimmed.length > 0) {
178
+ values.push(trimmed);
179
+ }
180
+
181
+ return values;
182
+ }
183
+
184
+ /**
185
+ * Format a field:value pair, quoting the value if necessary
186
+ */
187
+ private formatFieldValue(field: string, value: string): string {
188
+ // If the value is already quoted, use it as-is
189
+ if (
190
+ (value.startsWith('"') && value.endsWith('"')) ||
191
+ (value.startsWith("'") && value.endsWith("'"))
192
+ ) {
193
+ return `${field}:${value}`;
194
+ }
195
+
196
+ // If value contains spaces or special characters, quote it
197
+ if (/\s|[():]/.test(value)) {
198
+ // Escape quotes within the value
199
+ const escapedValue = value.replace(/"/g, '\\"');
200
+ return `${field}:"${escapedValue}"`;
201
+ }
202
+ return `${field}:${value}`;
203
+ }
204
+
60
205
  /**
61
206
  * Validate a query string
62
207
  */
63
208
  public validate(query: string): boolean {
64
209
  try {
65
- const ast = liqeParse(query);
210
+ const preprocessedQuery = this.preprocessQuery(query);
211
+ const ast = liqeParse(preprocessedQuery);
66
212
  this.convertLiqeAst(ast);
67
213
  return true;
68
214
  } catch {
@@ -81,7 +227,11 @@ export class QueryParser implements IQueryParser {
81
227
  switch (node.type) {
82
228
  case 'LogicalExpression': {
83
229
  const logicalNode = node as LogicalExpressionToken;
84
- const operator = (logicalNode.operator as BooleanOperatorToken | ImplicitBooleanOperatorToken).operator;
230
+ const operator = (
231
+ logicalNode.operator as
232
+ | BooleanOperatorToken
233
+ | ImplicitBooleanOperatorToken
234
+ ).operator;
85
235
  return this.createLogicalExpression(
86
236
  this.convertLogicalOperator(operator),
87
237
  logicalNode.left,
@@ -97,30 +247,37 @@ export class QueryParser implements IQueryParser {
97
247
  case 'Tag': {
98
248
  const tagNode = node as TagToken;
99
249
  const field = tagNode.field as FieldToken;
100
- const expression = tagNode.expression as ExpressionToken & { value: QueryValue };
101
-
250
+ const expression = tagNode.expression as ExpressionToken & {
251
+ value: QueryValue;
252
+ };
253
+
102
254
  if (!field || !expression) {
103
255
  throw new QueryParseError('Invalid field or expression in Tag node');
104
256
  }
105
257
 
106
258
  const fieldName = this.normalizeFieldName(field.name);
259
+
260
+ // Handle RangeExpression (e.g., field:[min TO max])
261
+ if (expression.type === 'RangeExpression') {
262
+ return this.convertRangeExpression(
263
+ fieldName,
264
+ expression as unknown as RangeExpressionToken
265
+ );
266
+ }
267
+
107
268
  const operator = this.convertLiqeOperator(tagNode.operator.operator);
108
269
  const value = this.convertLiqeValue(expression.value);
109
270
 
110
271
  // Check for wildcard patterns in string values
111
- if (operator === '==' && typeof value === 'string' && (value.includes('*') || value.includes('?'))) {
112
- return this.createComparisonExpression(
113
- fieldName,
114
- 'LIKE',
115
- value
116
- );
272
+ if (
273
+ operator === '==' &&
274
+ typeof value === 'string' &&
275
+ (value.includes('*') || value.includes('?'))
276
+ ) {
277
+ return this.createComparisonExpression(fieldName, 'LIKE', value);
117
278
  }
118
279
 
119
- return this.createComparisonExpression(
120
- fieldName,
121
- operator,
122
- value
123
- );
280
+ return this.createComparisonExpression(fieldName, operator, value);
124
281
  }
125
282
 
126
283
  case 'EmptyExpression':
@@ -138,7 +295,9 @@ export class QueryParser implements IQueryParser {
138
295
  }
139
296
 
140
297
  default:
141
- throw new QueryParseError(`Unsupported node type: ${(node as { type: string }).type}`);
298
+ throw new QueryParseError(
299
+ `Unsupported node type: ${(node as { type: string }).type}`
300
+ );
142
301
  }
143
302
  }
144
303
 
@@ -190,6 +349,48 @@ export class QueryParser implements IQueryParser {
190
349
  };
191
350
  }
192
351
 
352
+ /**
353
+ * Convert a Liqe RangeExpression to a QueryKit logical AND expression
354
+ * E.g., `field:[2 TO 5]` becomes `(field >= 2 AND field <= 5)`
355
+ */
356
+ private convertRangeExpression(
357
+ fieldName: string,
358
+ expression: RangeExpressionToken
359
+ ): QueryExpression {
360
+ const range = expression.range;
361
+
362
+ // Handle null/undefined range values
363
+ if (range === null || range === undefined) {
364
+ throw new QueryParseError('Invalid range expression: missing range data');
365
+ }
366
+
367
+ const { min, max, minInclusive, maxInclusive } = range;
368
+
369
+ // Determine the operators based on inclusivity
370
+ const minOperator: ComparisonOperator = minInclusive ? '>=' : '>';
371
+ const maxOperator: ComparisonOperator = maxInclusive ? '<=' : '<';
372
+
373
+ // Create comparison expressions for min and max
374
+ const minComparison = this.createComparisonExpression(
375
+ fieldName,
376
+ minOperator,
377
+ min
378
+ );
379
+ const maxComparison = this.createComparisonExpression(
380
+ fieldName,
381
+ maxOperator,
382
+ max
383
+ );
384
+
385
+ // Combine with AND
386
+ return {
387
+ type: 'logical',
388
+ operator: 'AND',
389
+ left: minComparison,
390
+ right: maxComparison
391
+ };
392
+ }
393
+
193
394
  /**
194
395
  * Convert a Liqe operator to a QueryKit operator
195
396
  */
@@ -200,7 +401,9 @@ export class QueryParser implements IQueryParser {
200
401
  }
201
402
 
202
403
  // Check if the operator is prefixed with a colon
203
- const actualOperator = operator.startsWith(':') ? operator.substring(1) : operator;
404
+ const actualOperator = operator.startsWith(':')
405
+ ? operator.substring(1)
406
+ : operator;
204
407
 
205
408
  // Map Liqe operators to QueryKit operators
206
409
  const operatorMap: Record<string, ComparisonOperator> = {
@@ -210,7 +413,7 @@ export class QueryParser implements IQueryParser {
210
413
  '>=': '>=',
211
414
  '<': '<',
212
415
  '<=': '<=',
213
- 'in': 'IN',
416
+ in: 'IN',
214
417
  'not in': 'NOT IN'
215
418
  };
216
419
 
@@ -231,11 +434,15 @@ export class QueryParser implements IQueryParser {
231
434
  if (value === null) {
232
435
  return null;
233
436
  }
234
-
235
- if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
437
+
438
+ if (
439
+ typeof value === 'string' ||
440
+ typeof value === 'number' ||
441
+ typeof value === 'boolean'
442
+ ) {
236
443
  return value as QueryValue;
237
444
  }
238
-
445
+
239
446
  if (Array.isArray(value)) {
240
447
  // Security fix: Recursively validate array elements
241
448
  const validatedArray = value.map(item => {
@@ -246,10 +453,12 @@ export class QueryParser implements IQueryParser {
246
453
  });
247
454
  return validatedArray as QueryValue;
248
455
  }
249
-
456
+
250
457
  // Security fix: Reject all object types to prevent NoSQL injection
251
458
  if (typeof value === 'object') {
252
- throw new QueryParseError('Object values are not supported for security reasons');
459
+ throw new QueryParseError(
460
+ 'Object values are not supported for security reasons'
461
+ );
253
462
  }
254
463
 
255
464
  throw new QueryParseError(`Unsupported value type: ${typeof value}`);
@@ -265,4 +474,4 @@ export class QueryParser implements IQueryParser {
265
474
 
266
475
  return this.options.fieldMappings[normalizedField] ?? normalizedField;
267
476
  }
268
- }
477
+ }
@@ -57,6 +57,56 @@ export interface ISecurityOptions {
57
57
  */
58
58
  denyFields?: string[];
59
59
 
60
+ /**
61
+ * Map of field names to arrays of values that are denied for that field.
62
+ * This provides granular control over what values can be used in queries.
63
+ * Use this to protect against queries targeting specific sensitive values.
64
+ *
65
+ * The keys are field names (can include table prefixes like "user.role")
66
+ * and the values are arrays of denied values for that field.
67
+ *
68
+ * @example
69
+ * ```typescript
70
+ * // Prevent certain values from being queried
71
+ * denyValues: {
72
+ * 'status': ['deleted', 'banned'],
73
+ * 'role': ['superadmin', 'system'],
74
+ * 'user.type': ['internal', 'bot']
75
+ * }
76
+ *
77
+ * // This would block queries like:
78
+ * // status == "deleted"
79
+ * // role IN ["superadmin", "admin"]
80
+ * // user.type == "internal"
81
+ * ```
82
+ */
83
+ denyValues?: Record<string, Array<string | number | boolean | null>>;
84
+
85
+ /**
86
+ * Whether to allow dot notation in field names (e.g., "user.name", "metadata.tags").
87
+ * When disabled, queries with dots in field names will be rejected.
88
+ *
89
+ * Use cases for DISABLING dot notation:
90
+ * - Public-facing search APIs where users should only query flat, top-level fields
91
+ * - Preventing access to table-qualified columns in SQL joins (e.g., "users.password")
92
+ * - Simpler security model when your schema doesn't have nested/JSON data
93
+ * - Preventing users from probing internal table structures
94
+ *
95
+ * @default true
96
+ *
97
+ * @example
98
+ * ```typescript
99
+ * // Disable dot notation for a public search API
100
+ * allowDotNotation: false
101
+ *
102
+ * // This would block queries like:
103
+ * // user.email == "test@example.com" // Rejected
104
+ * // metadata.tags == "sale" // Rejected
105
+ * // email == "test@example.com" // Allowed
106
+ * ```
107
+ */
108
+ allowDotNotation?: boolean;
109
+
60
110
  /**
61
111
  * Maximum nesting depth of query expressions.
62
112
  * Prevents deeply nested queries that could impact performance.
@@ -192,6 +242,8 @@ export const DEFAULT_SECURITY_OPTIONS: Required<ISecurityOptions> = {
192
242
  // Field restrictions - by default, all schema fields are allowed
193
243
  allowedFields: [], // Empty means "use schema fields"
194
244
  denyFields: [], // Empty means no denied fields
245
+ denyValues: {}, // Empty means no denied values for any field
246
+ allowDotNotation: true, // Allow dot notation by default for backward compatibility
195
247
 
196
248
  // Query complexity limits
197
249
  maxQueryDepth: 10, // Maximum nesting level of expressions