@gblikas/querykit 0.1.0 → 0.3.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,18 +7,36 @@ import type {
7
7
  LiqeQuery,
8
8
  LogicalExpressionToken,
9
9
  ParenthesizedExpressionToken,
10
+ RangeExpressionToken,
10
11
  TagToken,
11
12
  UnaryOperatorToken
12
13
  } from 'liqe';
13
14
  import {
14
15
  ComparisonOperator,
16
+ IAutocompleteSuggestions,
15
17
  IComparisonExpression,
18
+ IErrorRecovery,
19
+ IFieldSchema,
20
+ IFieldSuggestion,
21
+ IFieldValidationDetail,
22
+ IFieldValidationResult,
16
23
  ILogicalExpression,
24
+ IOperatorSuggestion,
17
25
  IParserOptions,
26
+ IParseWithContextOptions,
18
27
  IQueryParser,
28
+ IQueryParseResult,
29
+ IQueryStructure,
30
+ IQueryToken,
31
+ ISecurityCheckResult,
32
+ ISecurityOptionsForContext,
33
+ ISecurityViolation,
34
+ ISecurityWarning,
35
+ IValueSuggestion,
19
36
  QueryExpression,
20
37
  QueryValue
21
38
  } from './types';
39
+ import { parseQueryTokens, isInputComplete, QueryToken } from './input-parser';
22
40
 
23
41
  /**
24
42
  * Error thrown when query parsing fails
@@ -48,7 +66,9 @@ export class QueryParser implements IQueryParser {
48
66
  */
49
67
  public parse(query: string): QueryExpression {
50
68
  try {
51
- const liqeAst = liqeParse(query);
69
+ // Pre-process the query to handle IN operator syntax
70
+ const preprocessedQuery = this.preprocessQuery(query);
71
+ const liqeAst = liqeParse(preprocessedQuery);
52
72
  return this.convertLiqeAst(liqeAst);
53
73
  } catch (error) {
54
74
  throw new QueryParseError(
@@ -57,12 +77,155 @@ export class QueryParser implements IQueryParser {
57
77
  }
58
78
  }
59
79
 
80
+ /**
81
+ * Pre-process a query string to convert non-standard syntax to Liqe-compatible syntax.
82
+ * Supports:
83
+ * - `field:[val1, val2, val3]` → `(field:val1 OR field:val2 OR field:val3)`
84
+ *
85
+ * This keeps the syntax consistent with the `key:value` pattern used throughout QueryKit:
86
+ * - `priority:>2` (comparison)
87
+ * - `status:active` (equality)
88
+ * - `status:[todo, doing, done]` (IN / multiple values)
89
+ */
90
+ private preprocessQuery(query: string): string {
91
+ let result = query;
92
+
93
+ // Handle `field:[val1, val2, ...]` syntax (array-like, not range)
94
+ // Pattern: fieldName:[value1, value2, ...]
95
+ // We distinguish from range by checking for commas without "TO"
96
+ const bracketArrayPattern = /(\w+):\[([^\]]+)\]/g;
97
+ result = result.replace(bracketArrayPattern, (fullMatch, field, values) => {
98
+ // Check if this looks like a range expression (contains " TO ")
99
+ if (/\s+TO\s+/i.test(values)) {
100
+ // This is a range expression, keep as-is
101
+ return fullMatch;
102
+ }
103
+ // This is an array-like expression, convert to OR
104
+ return this.convertToOrExpression(field, values);
105
+ });
106
+
107
+ return result;
108
+ }
109
+
110
+ /**
111
+ * Convert a field and comma-separated values to an OR expression string
112
+ */
113
+ private convertToOrExpression(field: string, valuesStr: string): string {
114
+ // Parse values respecting quoted strings (commas inside quotes are preserved)
115
+ const values = this.parseCommaSeparatedValues(valuesStr);
116
+
117
+ if (values.length === 0) {
118
+ return `${field}:""`;
119
+ }
120
+
121
+ if (values.length === 1) {
122
+ return this.formatFieldValue(field, values[0]);
123
+ }
124
+
125
+ // Build OR expression: (field:val1 OR field:val2 OR ...)
126
+ const orClauses = values.map((v: string) =>
127
+ this.formatFieldValue(field, v)
128
+ );
129
+ return `(${orClauses.join(' OR ')})`;
130
+ }
131
+
132
+ /**
133
+ * Parse a comma-separated string into values, respecting quoted strings.
134
+ * Commas inside quoted strings are preserved as part of the value.
135
+ *
136
+ * Examples:
137
+ * - `a, b, c` → ['a', 'b', 'c']
138
+ * - `"John, Jr.", Jane` → ['"John, Jr."', 'Jane']
139
+ * - `'hello, world', test` → ["'hello, world'", 'test']
140
+ */
141
+ private parseCommaSeparatedValues(input: string): string[] {
142
+ const values: string[] = [];
143
+ let current = '';
144
+ let inDoubleQuotes = false;
145
+ let inSingleQuotes = false;
146
+ let i = 0;
147
+
148
+ while (i < input.length) {
149
+ const char = input[i];
150
+ const nextChar = input[i + 1];
151
+
152
+ // Handle escape sequences inside quotes
153
+ if ((inDoubleQuotes || inSingleQuotes) && char === '\\' && nextChar) {
154
+ // Include both the backslash and the escaped character
155
+ current += char + nextChar;
156
+ i += 2;
157
+ continue;
158
+ }
159
+
160
+ // Toggle double quote state
161
+ if (char === '"' && !inSingleQuotes) {
162
+ inDoubleQuotes = !inDoubleQuotes;
163
+ current += char;
164
+ i++;
165
+ continue;
166
+ }
167
+
168
+ // Toggle single quote state
169
+ if (char === "'" && !inDoubleQuotes) {
170
+ inSingleQuotes = !inSingleQuotes;
171
+ current += char;
172
+ i++;
173
+ continue;
174
+ }
175
+
176
+ // Handle comma as separator (only when not inside quotes)
177
+ if (char === ',' && !inDoubleQuotes && !inSingleQuotes) {
178
+ const trimmed = current.trim();
179
+ if (trimmed.length > 0) {
180
+ values.push(trimmed);
181
+ }
182
+ current = '';
183
+ i++;
184
+ continue;
185
+ }
186
+
187
+ // Regular character
188
+ current += char;
189
+ i++;
190
+ }
191
+
192
+ // Don't forget the last value
193
+ const trimmed = current.trim();
194
+ if (trimmed.length > 0) {
195
+ values.push(trimmed);
196
+ }
197
+
198
+ return values;
199
+ }
200
+
201
+ /**
202
+ * Format a field:value pair, quoting the value if necessary
203
+ */
204
+ private formatFieldValue(field: string, value: string): string {
205
+ // If the value is already quoted, use it as-is
206
+ if (
207
+ (value.startsWith('"') && value.endsWith('"')) ||
208
+ (value.startsWith("'") && value.endsWith("'"))
209
+ ) {
210
+ return `${field}:${value}`;
211
+ }
212
+
213
+ // If value contains spaces or special characters, quote it
214
+ if (/\s|[():]/.test(value)) {
215
+ // Escape quotes within the value
216
+ const escapedValue = value.replace(/"/g, '\\"');
217
+ return `${field}:"${escapedValue}"`;
218
+ }
219
+ return `${field}:${value}`;
220
+ }
221
+
60
222
  /**
61
223
  * Validate a query string
62
224
  */
63
225
  public validate(query: string): boolean {
64
226
  try {
65
- const ast = liqeParse(query);
227
+ const preprocessedQuery = this.preprocessQuery(query);
228
+ const ast = liqeParse(preprocessedQuery);
66
229
  this.convertLiqeAst(ast);
67
230
  return true;
68
231
  } catch {
@@ -81,7 +244,11 @@ export class QueryParser implements IQueryParser {
81
244
  switch (node.type) {
82
245
  case 'LogicalExpression': {
83
246
  const logicalNode = node as LogicalExpressionToken;
84
- const operator = (logicalNode.operator as BooleanOperatorToken | ImplicitBooleanOperatorToken).operator;
247
+ const operator = (
248
+ logicalNode.operator as
249
+ | BooleanOperatorToken
250
+ | ImplicitBooleanOperatorToken
251
+ ).operator;
85
252
  return this.createLogicalExpression(
86
253
  this.convertLogicalOperator(operator),
87
254
  logicalNode.left,
@@ -97,30 +264,37 @@ export class QueryParser implements IQueryParser {
97
264
  case 'Tag': {
98
265
  const tagNode = node as TagToken;
99
266
  const field = tagNode.field as FieldToken;
100
- const expression = tagNode.expression as ExpressionToken & { value: QueryValue };
101
-
267
+ const expression = tagNode.expression as ExpressionToken & {
268
+ value: QueryValue;
269
+ };
270
+
102
271
  if (!field || !expression) {
103
272
  throw new QueryParseError('Invalid field or expression in Tag node');
104
273
  }
105
274
 
106
275
  const fieldName = this.normalizeFieldName(field.name);
276
+
277
+ // Handle RangeExpression (e.g., field:[min TO max])
278
+ if (expression.type === 'RangeExpression') {
279
+ return this.convertRangeExpression(
280
+ fieldName,
281
+ expression as unknown as RangeExpressionToken
282
+ );
283
+ }
284
+
107
285
  const operator = this.convertLiqeOperator(tagNode.operator.operator);
108
286
  const value = this.convertLiqeValue(expression.value);
109
287
 
110
288
  // 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
- );
289
+ if (
290
+ operator === '==' &&
291
+ typeof value === 'string' &&
292
+ (value.includes('*') || value.includes('?'))
293
+ ) {
294
+ return this.createComparisonExpression(fieldName, 'LIKE', value);
117
295
  }
118
296
 
119
- return this.createComparisonExpression(
120
- fieldName,
121
- operator,
122
- value
123
- );
297
+ return this.createComparisonExpression(fieldName, operator, value);
124
298
  }
125
299
 
126
300
  case 'EmptyExpression':
@@ -138,7 +312,9 @@ export class QueryParser implements IQueryParser {
138
312
  }
139
313
 
140
314
  default:
141
- throw new QueryParseError(`Unsupported node type: ${(node as { type: string }).type}`);
315
+ throw new QueryParseError(
316
+ `Unsupported node type: ${(node as { type: string }).type}`
317
+ );
142
318
  }
143
319
  }
144
320
 
@@ -190,6 +366,48 @@ export class QueryParser implements IQueryParser {
190
366
  };
191
367
  }
192
368
 
369
+ /**
370
+ * Convert a Liqe RangeExpression to a QueryKit logical AND expression
371
+ * E.g., `field:[2 TO 5]` becomes `(field >= 2 AND field <= 5)`
372
+ */
373
+ private convertRangeExpression(
374
+ fieldName: string,
375
+ expression: RangeExpressionToken
376
+ ): QueryExpression {
377
+ const range = expression.range;
378
+
379
+ // Handle null/undefined range values
380
+ if (range === null || range === undefined) {
381
+ throw new QueryParseError('Invalid range expression: missing range data');
382
+ }
383
+
384
+ const { min, max, minInclusive, maxInclusive } = range;
385
+
386
+ // Determine the operators based on inclusivity
387
+ const minOperator: ComparisonOperator = minInclusive ? '>=' : '>';
388
+ const maxOperator: ComparisonOperator = maxInclusive ? '<=' : '<';
389
+
390
+ // Create comparison expressions for min and max
391
+ const minComparison = this.createComparisonExpression(
392
+ fieldName,
393
+ minOperator,
394
+ min
395
+ );
396
+ const maxComparison = this.createComparisonExpression(
397
+ fieldName,
398
+ maxOperator,
399
+ max
400
+ );
401
+
402
+ // Combine with AND
403
+ return {
404
+ type: 'logical',
405
+ operator: 'AND',
406
+ left: minComparison,
407
+ right: maxComparison
408
+ };
409
+ }
410
+
193
411
  /**
194
412
  * Convert a Liqe operator to a QueryKit operator
195
413
  */
@@ -200,7 +418,9 @@ export class QueryParser implements IQueryParser {
200
418
  }
201
419
 
202
420
  // Check if the operator is prefixed with a colon
203
- const actualOperator = operator.startsWith(':') ? operator.substring(1) : operator;
421
+ const actualOperator = operator.startsWith(':')
422
+ ? operator.substring(1)
423
+ : operator;
204
424
 
205
425
  // Map Liqe operators to QueryKit operators
206
426
  const operatorMap: Record<string, ComparisonOperator> = {
@@ -210,7 +430,7 @@ export class QueryParser implements IQueryParser {
210
430
  '>=': '>=',
211
431
  '<': '<',
212
432
  '<=': '<=',
213
- 'in': 'IN',
433
+ in: 'IN',
214
434
  'not in': 'NOT IN'
215
435
  };
216
436
 
@@ -231,11 +451,15 @@ export class QueryParser implements IQueryParser {
231
451
  if (value === null) {
232
452
  return null;
233
453
  }
234
-
235
- if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
454
+
455
+ if (
456
+ typeof value === 'string' ||
457
+ typeof value === 'number' ||
458
+ typeof value === 'boolean'
459
+ ) {
236
460
  return value as QueryValue;
237
461
  }
238
-
462
+
239
463
  if (Array.isArray(value)) {
240
464
  // Security fix: Recursively validate array elements
241
465
  const validatedArray = value.map(item => {
@@ -246,10 +470,12 @@ export class QueryParser implements IQueryParser {
246
470
  });
247
471
  return validatedArray as QueryValue;
248
472
  }
249
-
473
+
250
474
  // Security fix: Reject all object types to prevent NoSQL injection
251
475
  if (typeof value === 'object') {
252
- throw new QueryParseError('Object values are not supported for security reasons');
476
+ throw new QueryParseError(
477
+ 'Object values are not supported for security reasons'
478
+ );
253
479
  }
254
480
 
255
481
  throw new QueryParseError(`Unsupported value type: ${typeof value}`);
@@ -265,4 +491,859 @@ export class QueryParser implements IQueryParser {
265
491
 
266
492
  return this.options.fieldMappings[normalizedField] ?? normalizedField;
267
493
  }
268
- }
494
+
495
+ /**
496
+ * Parse a query string with full context information.
497
+ *
498
+ * Unlike `parse()`, this method never throws. Instead, it returns a result object
499
+ * that indicates success or failure along with rich contextual information useful
500
+ * for building search UIs.
501
+ *
502
+ * @param query The query string to parse
503
+ * @param options Optional configuration (cursor position, etc.)
504
+ * @returns Rich parse result with tokens, AST/error, and structural analysis
505
+ *
506
+ * @example
507
+ * ```typescript
508
+ * const result = parser.parseWithContext('status:done AND priority:high');
509
+ *
510
+ * if (result.success) {
511
+ * // Use result.ast for query execution
512
+ * console.log('Valid query:', result.ast);
513
+ * } else {
514
+ * // Show error to user
515
+ * console.log('Error:', result.error?.message);
516
+ * }
517
+ *
518
+ * // Always available for UI rendering
519
+ * console.log('Tokens:', result.tokens);
520
+ * console.log('Structure:', result.structure);
521
+ * ```
522
+ */
523
+ public parseWithContext(
524
+ query: string,
525
+ options: IParseWithContextOptions = {}
526
+ ): IQueryParseResult {
527
+ // Get tokens from input parser (always works, even for invalid input)
528
+ const tokenResult = parseQueryTokens(query, options.cursorPosition);
529
+ const tokens = this.convertTokens(tokenResult.tokens);
530
+
531
+ // Analyze structure
532
+ const structure = this.analyzeStructure(query, tokens);
533
+
534
+ // Attempt full parse
535
+ let ast: QueryExpression | undefined;
536
+ let error:
537
+ | { message: string; position?: number; problematicText?: string }
538
+ | undefined;
539
+ let success = false;
540
+
541
+ try {
542
+ ast = this.parse(query);
543
+ success = true;
544
+ } catch (e) {
545
+ const errorMessage = e instanceof Error ? e.message : String(e);
546
+ error = {
547
+ message: errorMessage,
548
+ // Try to extract position from error message if available
549
+ position: this.extractErrorPosition(errorMessage),
550
+ problematicText: this.extractProblematicText(query, errorMessage)
551
+ };
552
+ }
553
+
554
+ // Determine active token
555
+ const activeToken = tokenResult.activeToken
556
+ ? this.convertSingleToken(tokenResult.activeToken)
557
+ : undefined;
558
+
559
+ // Build base result
560
+ const result: IQueryParseResult = {
561
+ success,
562
+ input: query,
563
+ ast,
564
+ error,
565
+ tokens,
566
+ activeToken,
567
+ activeTokenIndex: tokenResult.activeTokenIndex,
568
+ structure
569
+ };
570
+
571
+ // Perform field validation if schema provided
572
+ if (options.schema) {
573
+ result.fieldValidation = this.validateFields(
574
+ structure.referencedFields,
575
+ options.schema
576
+ );
577
+ }
578
+
579
+ // Perform security pre-check if security options provided
580
+ if (options.securityOptions) {
581
+ result.security = this.performSecurityCheck(
582
+ structure,
583
+ options.securityOptions
584
+ );
585
+ }
586
+
587
+ // Generate autocomplete suggestions if cursor position provided
588
+ if (options.cursorPosition !== undefined) {
589
+ result.suggestions = this.generateAutocompleteSuggestions(
590
+ query,
591
+ options.cursorPosition,
592
+ activeToken,
593
+ options.schema
594
+ );
595
+ }
596
+
597
+ // Generate error recovery suggestions if parsing failed
598
+ if (!success) {
599
+ result.recovery = this.generateErrorRecovery(query, structure);
600
+ }
601
+
602
+ return result;
603
+ }
604
+
605
+ /**
606
+ * Convert tokens from input parser format to IQueryToken format
607
+ */
608
+ private convertTokens(tokens: QueryToken[]): IQueryToken[] {
609
+ return tokens.map(token => this.convertSingleToken(token));
610
+ }
611
+
612
+ /**
613
+ * Convert a single token from input parser format
614
+ */
615
+ private convertSingleToken(token: QueryToken): IQueryToken {
616
+ if (token.type === 'term') {
617
+ return {
618
+ type: 'term',
619
+ key: token.key,
620
+ operator: token.operator,
621
+ value: token.value,
622
+ startPosition: token.startPosition,
623
+ endPosition: token.endPosition,
624
+ raw: token.raw
625
+ };
626
+ } else {
627
+ return {
628
+ type: 'operator',
629
+ operator: token.operator,
630
+ startPosition: token.startPosition,
631
+ endPosition: token.endPosition,
632
+ raw: token.raw
633
+ };
634
+ }
635
+ }
636
+
637
+ /**
638
+ * Analyze the structure of a query
639
+ */
640
+ private analyzeStructure(
641
+ query: string,
642
+ tokens: IQueryToken[]
643
+ ): IQueryStructure {
644
+ // Count parentheses
645
+ const openParens = (query.match(/\(/g) || []).length;
646
+ const closeParens = (query.match(/\)/g) || []).length;
647
+ const hasBalancedParentheses = openParens === closeParens;
648
+
649
+ // Count quotes
650
+ const singleQuotes = (query.match(/'/g) || []).length;
651
+ const doubleQuotes = (query.match(/"/g) || []).length;
652
+ const hasBalancedQuotes = singleQuotes % 2 === 0 && doubleQuotes % 2 === 0;
653
+
654
+ // Count terms and operators
655
+ const termTokens = tokens.filter(t => t.type === 'term');
656
+ const operatorTokens = tokens.filter(t => t.type === 'operator');
657
+
658
+ const clauseCount = termTokens.length;
659
+ const operatorCount = operatorTokens.length;
660
+
661
+ // Extract referenced fields
662
+ const referencedFields: string[] = [];
663
+ for (const token of termTokens) {
664
+ if (token.type === 'term' && token.key !== null) {
665
+ if (!referencedFields.includes(token.key)) {
666
+ referencedFields.push(token.key);
667
+ }
668
+ }
669
+ }
670
+
671
+ // Calculate depth (by counting max nesting in parentheses)
672
+ const depth = this.calculateDepth(query);
673
+
674
+ // Check if complete
675
+ const isComplete = isInputComplete(query);
676
+
677
+ // Determine complexity
678
+ const complexity = this.determineComplexity(
679
+ clauseCount,
680
+ operatorCount,
681
+ depth
682
+ );
683
+
684
+ return {
685
+ depth,
686
+ clauseCount,
687
+ operatorCount,
688
+ hasBalancedParentheses,
689
+ hasBalancedQuotes,
690
+ isComplete,
691
+ referencedFields,
692
+ complexity
693
+ };
694
+ }
695
+
696
+ /**
697
+ * Calculate the maximum nesting depth of parentheses
698
+ */
699
+ private calculateDepth(query: string): number {
700
+ let maxDepth = 0;
701
+ let currentDepth = 0;
702
+
703
+ for (const char of query) {
704
+ if (char === '(') {
705
+ currentDepth++;
706
+ maxDepth = Math.max(maxDepth, currentDepth);
707
+ } else if (char === ')') {
708
+ currentDepth = Math.max(0, currentDepth - 1);
709
+ }
710
+ }
711
+
712
+ // Base depth is 1 if there's any content
713
+ return query.trim().length > 0 ? Math.max(1, maxDepth) : 0;
714
+ }
715
+
716
+ /**
717
+ * Determine query complexity classification
718
+ */
719
+ private determineComplexity(
720
+ clauseCount: number,
721
+ operatorCount: number,
722
+ depth: number
723
+ ): 'simple' | 'moderate' | 'complex' {
724
+ // Simple: 1-2 clauses, no nesting
725
+ if (clauseCount <= 2 && depth <= 1) {
726
+ return 'simple';
727
+ }
728
+
729
+ // Complex: many clauses, deep nesting, or many operators
730
+ if (clauseCount > 5 || depth > 3 || operatorCount > 4) {
731
+ return 'complex';
732
+ }
733
+
734
+ return 'moderate';
735
+ }
736
+
737
+ /**
738
+ * Try to extract error position from error message
739
+ */
740
+ private extractErrorPosition(errorMessage: string): number | undefined {
741
+ // Try to find position indicators in error messages
742
+ // e.g., "at position 15" or "column 15"
743
+ const posMatch = errorMessage.match(/(?:position|column|offset)\s*(\d+)/i);
744
+ if (posMatch) {
745
+ return parseInt(posMatch[1], 10);
746
+ }
747
+ return undefined;
748
+ }
749
+
750
+ /**
751
+ * Try to extract the problematic text from the query based on error
752
+ */
753
+ private extractProblematicText(
754
+ query: string,
755
+ errorMessage: string
756
+ ): string | undefined {
757
+ // If we found a position, extract surrounding text
758
+ const position = this.extractErrorPosition(errorMessage);
759
+ if (position !== undefined && position < query.length) {
760
+ const start = Math.max(0, position - 10);
761
+ const end = Math.min(query.length, position + 10);
762
+ return query.substring(start, end);
763
+ }
764
+
765
+ // Try to find quoted text in error message
766
+ const quotedMatch = errorMessage.match(/"([^"]+)"/);
767
+ if (quotedMatch) {
768
+ return quotedMatch[1];
769
+ }
770
+
771
+ return undefined;
772
+ }
773
+
774
+ /**
775
+ * Validate fields against the provided schema
776
+ */
777
+ private validateFields(
778
+ referencedFields: string[],
779
+ schema: Record<string, IFieldSchema>
780
+ ): IFieldValidationResult {
781
+ const schemaFields = Object.keys(schema);
782
+ const fields: IFieldValidationDetail[] = [];
783
+ const unknownFields: string[] = [];
784
+ let allValid = true;
785
+
786
+ for (const field of referencedFields) {
787
+ if (field in schema) {
788
+ // Field exists in schema
789
+ fields.push({
790
+ field,
791
+ valid: true,
792
+ expectedType: schema[field].type,
793
+ allowedValues: schema[field].allowedValues
794
+ });
795
+ } else {
796
+ // Field not in schema - try to find a suggestion
797
+ const suggestion = this.findSimilarField(field, schemaFields);
798
+ fields.push({
799
+ field,
800
+ valid: false,
801
+ reason: 'unknown_field',
802
+ suggestion
803
+ });
804
+ unknownFields.push(field);
805
+ allValid = false;
806
+ }
807
+ }
808
+
809
+ return {
810
+ valid: allValid,
811
+ fields,
812
+ unknownFields
813
+ };
814
+ }
815
+
816
+ /**
817
+ * Find a similar field name (for typo suggestions)
818
+ */
819
+ private findSimilarField(
820
+ field: string,
821
+ schemaFields: string[]
822
+ ): string | undefined {
823
+ const fieldLower = field.toLowerCase();
824
+
825
+ // First, try exact case-insensitive match
826
+ for (const schemaField of schemaFields) {
827
+ if (schemaField.toLowerCase() === fieldLower) {
828
+ return schemaField;
829
+ }
830
+ }
831
+
832
+ // Then, try to find fields that start with the same prefix
833
+ const prefix = fieldLower.substring(0, Math.min(3, fieldLower.length));
834
+ for (const schemaField of schemaFields) {
835
+ if (schemaField.toLowerCase().startsWith(prefix)) {
836
+ return schemaField;
837
+ }
838
+ }
839
+
840
+ // Try Levenshtein distance for short fields
841
+ if (field.length <= 10) {
842
+ let bestMatch: string | undefined;
843
+ let bestDistance = Infinity;
844
+
845
+ for (const schemaField of schemaFields) {
846
+ const distance = this.levenshteinDistance(
847
+ fieldLower,
848
+ schemaField.toLowerCase()
849
+ );
850
+ if (distance <= 2 && distance < bestDistance) {
851
+ bestDistance = distance;
852
+ bestMatch = schemaField;
853
+ }
854
+ }
855
+
856
+ if (bestMatch) {
857
+ return bestMatch;
858
+ }
859
+ }
860
+
861
+ return undefined;
862
+ }
863
+
864
+ /**
865
+ * Calculate Levenshtein distance between two strings
866
+ */
867
+ private levenshteinDistance(a: string, b: string): number {
868
+ const matrix: number[][] = [];
869
+
870
+ for (let i = 0; i <= b.length; i++) {
871
+ matrix[i] = [i];
872
+ }
873
+
874
+ for (let j = 0; j <= a.length; j++) {
875
+ matrix[0][j] = j;
876
+ }
877
+
878
+ for (let i = 1; i <= b.length; i++) {
879
+ for (let j = 1; j <= a.length; j++) {
880
+ if (b.charAt(i - 1) === a.charAt(j - 1)) {
881
+ matrix[i][j] = matrix[i - 1][j - 1];
882
+ } else {
883
+ matrix[i][j] = Math.min(
884
+ matrix[i - 1][j - 1] + 1, // substitution
885
+ matrix[i][j - 1] + 1, // insertion
886
+ matrix[i - 1][j] + 1 // deletion
887
+ );
888
+ }
889
+ }
890
+ }
891
+
892
+ return matrix[b.length][a.length];
893
+ }
894
+
895
+ /**
896
+ * Perform security pre-check against the provided options
897
+ */
898
+ private performSecurityCheck(
899
+ structure: IQueryStructure,
900
+ options: ISecurityOptionsForContext
901
+ ): ISecurityCheckResult {
902
+ const violations: ISecurityViolation[] = [];
903
+ const warnings: ISecurityWarning[] = [];
904
+
905
+ // Check denied fields
906
+ if (options.denyFields && options.denyFields.length > 0) {
907
+ for (const field of structure.referencedFields) {
908
+ if (options.denyFields.includes(field)) {
909
+ violations.push({
910
+ type: 'denied_field',
911
+ message: `Field "${field}" is not allowed in queries`,
912
+ field
913
+ });
914
+ }
915
+ }
916
+ }
917
+
918
+ // Check allowed fields (if specified, only these fields are allowed)
919
+ if (options.allowedFields && options.allowedFields.length > 0) {
920
+ for (const field of structure.referencedFields) {
921
+ if (!options.allowedFields.includes(field)) {
922
+ violations.push({
923
+ type: 'field_not_allowed',
924
+ message: `Field "${field}" is not in the list of allowed fields`,
925
+ field
926
+ });
927
+ }
928
+ }
929
+ }
930
+
931
+ // Check dot notation
932
+ if (options.allowDotNotation === false) {
933
+ for (const field of structure.referencedFields) {
934
+ if (field.includes('.')) {
935
+ violations.push({
936
+ type: 'dot_notation',
937
+ message: `Dot notation is not allowed in field names: "${field}"`,
938
+ field
939
+ });
940
+ }
941
+ }
942
+ }
943
+
944
+ // Check query depth
945
+ if (options.maxQueryDepth !== undefined) {
946
+ if (structure.depth > options.maxQueryDepth) {
947
+ violations.push({
948
+ type: 'depth_exceeded',
949
+ message: `Query depth (${structure.depth}) exceeds maximum allowed (${options.maxQueryDepth})`
950
+ });
951
+ } else if (structure.depth >= options.maxQueryDepth * 0.8) {
952
+ warnings.push({
953
+ type: 'approaching_depth_limit',
954
+ message: `Query depth (${structure.depth}) is approaching the limit (${options.maxQueryDepth})`,
955
+ current: structure.depth,
956
+ limit: options.maxQueryDepth
957
+ });
958
+ }
959
+ }
960
+
961
+ // Check clause count
962
+ if (options.maxClauseCount !== undefined) {
963
+ if (structure.clauseCount > options.maxClauseCount) {
964
+ violations.push({
965
+ type: 'clause_limit',
966
+ message: `Clause count (${structure.clauseCount}) exceeds maximum allowed (${options.maxClauseCount})`
967
+ });
968
+ } else if (structure.clauseCount >= options.maxClauseCount * 0.8) {
969
+ warnings.push({
970
+ type: 'approaching_clause_limit',
971
+ message: `Clause count (${structure.clauseCount}) is approaching the limit (${options.maxClauseCount})`,
972
+ current: structure.clauseCount,
973
+ limit: options.maxClauseCount
974
+ });
975
+ }
976
+ }
977
+
978
+ // Add complexity warning
979
+ if (structure.complexity === 'complex') {
980
+ warnings.push({
981
+ type: 'complex_query',
982
+ message: 'This query is complex and may impact performance'
983
+ });
984
+ }
985
+
986
+ return {
987
+ passed: violations.length === 0,
988
+ violations,
989
+ warnings
990
+ };
991
+ }
992
+
993
+ /**
994
+ * Generate autocomplete suggestions based on cursor position
995
+ */
996
+ private generateAutocompleteSuggestions(
997
+ query: string,
998
+ cursorPosition: number,
999
+ activeToken: IQueryToken | undefined,
1000
+ schema?: Record<string, IFieldSchema>
1001
+ ): IAutocompleteSuggestions {
1002
+ // Determine context based on active token and position
1003
+ if (!activeToken) {
1004
+ // Cursor is not in a token - check if we're at the start or between tokens
1005
+ if (query.trim().length === 0 || cursorPosition === 0) {
1006
+ return this.suggestForEmptyContext(schema);
1007
+ }
1008
+ // Between tokens - suggest logical operators or new field
1009
+ return this.suggestBetweenTokens(schema);
1010
+ }
1011
+
1012
+ if (activeToken.type === 'operator') {
1013
+ // Cursor is in a logical operator
1014
+ return {
1015
+ context: 'logical_operator',
1016
+ logicalOperators: ['AND', 'OR', 'NOT'],
1017
+ replaceText: activeToken.raw,
1018
+ replaceRange: {
1019
+ start: activeToken.startPosition,
1020
+ end: activeToken.endPosition
1021
+ }
1022
+ };
1023
+ }
1024
+
1025
+ // Active token is a term
1026
+ const term = activeToken;
1027
+ const relativePos = cursorPosition - term.startPosition;
1028
+
1029
+ // Determine if cursor is in key, operator, or value part
1030
+ if (term.key !== null && term.operator !== null) {
1031
+ const keyLength = term.key.length;
1032
+ const operatorLength = term.operator.length;
1033
+
1034
+ if (relativePos < keyLength) {
1035
+ // Cursor is in the field name
1036
+ return this.suggestFields(term.key, schema);
1037
+ } else if (relativePos < keyLength + operatorLength) {
1038
+ // Cursor is in the operator
1039
+ return this.suggestOperators(term.key, schema);
1040
+ } else {
1041
+ // Cursor is in the value
1042
+ return this.suggestValues(term.key, term.value, schema);
1043
+ }
1044
+ } else if (term.key !== null) {
1045
+ // Only key present (incomplete)
1046
+ return this.suggestFields(term.key, schema);
1047
+ } else {
1048
+ // Bare value - could be a field name
1049
+ return this.suggestFields(term.value || '', schema);
1050
+ }
1051
+ }
1052
+
1053
+ /**
1054
+ * Suggest for empty/start context
1055
+ */
1056
+ private suggestForEmptyContext(
1057
+ schema?: Record<string, IFieldSchema>
1058
+ ): IAutocompleteSuggestions {
1059
+ return {
1060
+ context: 'empty',
1061
+ fields: this.getFieldSuggestions('', schema),
1062
+ logicalOperators: ['NOT']
1063
+ };
1064
+ }
1065
+
1066
+ /**
1067
+ * Suggest between tokens (after a complete term)
1068
+ */
1069
+ private suggestBetweenTokens(
1070
+ schema?: Record<string, IFieldSchema>
1071
+ ): IAutocompleteSuggestions {
1072
+ return {
1073
+ context: 'logical_operator',
1074
+ fields: this.getFieldSuggestions('', schema),
1075
+ logicalOperators: ['AND', 'OR', 'NOT']
1076
+ };
1077
+ }
1078
+
1079
+ /**
1080
+ * Suggest field names
1081
+ */
1082
+ private suggestFields(
1083
+ partial: string,
1084
+ schema?: Record<string, IFieldSchema>
1085
+ ): IAutocompleteSuggestions {
1086
+ return {
1087
+ context: 'field',
1088
+ fields: this.getFieldSuggestions(partial, schema),
1089
+ replaceText: partial,
1090
+ replaceRange:
1091
+ partial.length > 0 ? { start: 0, end: partial.length } : undefined
1092
+ };
1093
+ }
1094
+
1095
+ /**
1096
+ * Get field suggestions based on partial input
1097
+ */
1098
+ private getFieldSuggestions(
1099
+ partial: string,
1100
+ schema?: Record<string, IFieldSchema>
1101
+ ): IFieldSuggestion[] {
1102
+ if (!schema) {
1103
+ return [];
1104
+ }
1105
+
1106
+ const partialLower = partial.toLowerCase();
1107
+ const suggestions: IFieldSuggestion[] = [];
1108
+
1109
+ for (const [field, fieldSchema] of Object.entries(schema)) {
1110
+ const fieldLower = field.toLowerCase();
1111
+ let score = 0;
1112
+
1113
+ if (partial.length === 0) {
1114
+ // No partial - suggest all fields with base score
1115
+ score = 50;
1116
+ } else if (fieldLower === partialLower) {
1117
+ // Exact match
1118
+ score = 100;
1119
+ } else if (fieldLower.startsWith(partialLower)) {
1120
+ // Prefix match
1121
+ score = 80 + (partial.length / field.length) * 20;
1122
+ } else if (fieldLower.includes(partialLower)) {
1123
+ // Contains match
1124
+ score = 60;
1125
+ } else {
1126
+ // Check Levenshtein distance for typos
1127
+ const distance = this.levenshteinDistance(partialLower, fieldLower);
1128
+ if (distance <= 2) {
1129
+ score = 40 - distance * 10;
1130
+ }
1131
+ }
1132
+
1133
+ if (score > 0) {
1134
+ suggestions.push({
1135
+ field,
1136
+ type: fieldSchema.type,
1137
+ description: fieldSchema.description,
1138
+ score
1139
+ });
1140
+ }
1141
+ }
1142
+
1143
+ // Sort by score descending
1144
+ return suggestions.sort((a, b) => b.score - a.score);
1145
+ }
1146
+
1147
+ /**
1148
+ * Suggest operators
1149
+ */
1150
+ private suggestOperators(
1151
+ field: string,
1152
+ schema?: Record<string, IFieldSchema>
1153
+ ): IAutocompleteSuggestions {
1154
+ const fieldType = schema?.[field]?.type;
1155
+ const operators = this.getOperatorSuggestions(fieldType);
1156
+
1157
+ return {
1158
+ context: 'operator',
1159
+ currentField: field,
1160
+ operators
1161
+ };
1162
+ }
1163
+
1164
+ /**
1165
+ * Get operator suggestions based on field type
1166
+ */
1167
+ private getOperatorSuggestions(fieldType?: string): IOperatorSuggestion[] {
1168
+ const allOperators: IOperatorSuggestion[] = [
1169
+ { operator: ':', description: 'equals', applicable: true },
1170
+ { operator: ':!=', description: 'not equals', applicable: true },
1171
+ {
1172
+ operator: ':>',
1173
+ description: 'greater than',
1174
+ applicable: fieldType === 'number' || fieldType === 'date'
1175
+ },
1176
+ {
1177
+ operator: ':>=',
1178
+ description: 'greater than or equal',
1179
+ applicable: fieldType === 'number' || fieldType === 'date'
1180
+ },
1181
+ {
1182
+ operator: ':<',
1183
+ description: 'less than',
1184
+ applicable: fieldType === 'number' || fieldType === 'date'
1185
+ },
1186
+ {
1187
+ operator: ':<=',
1188
+ description: 'less than or equal',
1189
+ applicable: fieldType === 'number' || fieldType === 'date'
1190
+ }
1191
+ ];
1192
+
1193
+ // Sort applicable operators first
1194
+ return allOperators.sort((a, b) => {
1195
+ if (a.applicable && !b.applicable) return -1;
1196
+ if (!a.applicable && b.applicable) return 1;
1197
+ return 0;
1198
+ });
1199
+ }
1200
+
1201
+ /**
1202
+ * Suggest values
1203
+ */
1204
+ private suggestValues(
1205
+ field: string,
1206
+ partialValue: string | null,
1207
+ schema?: Record<string, IFieldSchema>
1208
+ ): IAutocompleteSuggestions {
1209
+ const fieldSchema = schema?.[field];
1210
+ const values = this.getValueSuggestions(partialValue || '', fieldSchema);
1211
+
1212
+ return {
1213
+ context: 'value',
1214
+ currentField: field,
1215
+ values,
1216
+ replaceText: partialValue || undefined
1217
+ };
1218
+ }
1219
+
1220
+ /**
1221
+ * Get value suggestions based on schema
1222
+ */
1223
+ private getValueSuggestions(
1224
+ partial: string,
1225
+ fieldSchema?: IFieldSchema
1226
+ ): IValueSuggestion[] {
1227
+ if (!fieldSchema?.allowedValues) {
1228
+ // Suggest based on type
1229
+ if (fieldSchema?.type === 'boolean') {
1230
+ return [
1231
+ { value: true, label: 'true', score: 100 },
1232
+ { value: false, label: 'false', score: 100 }
1233
+ ];
1234
+ }
1235
+ return [];
1236
+ }
1237
+
1238
+ const partialLower = partial.toLowerCase();
1239
+ const suggestions: IValueSuggestion[] = [];
1240
+
1241
+ for (const value of fieldSchema.allowedValues) {
1242
+ const valueStr = String(value).toLowerCase();
1243
+ let score = 0;
1244
+
1245
+ if (partial.length === 0) {
1246
+ score = 50;
1247
+ } else if (valueStr === partialLower) {
1248
+ score = 100;
1249
+ } else if (valueStr.startsWith(partialLower)) {
1250
+ score = 80;
1251
+ } else if (valueStr.includes(partialLower)) {
1252
+ score = 60;
1253
+ }
1254
+
1255
+ if (score > 0) {
1256
+ suggestions.push({
1257
+ value,
1258
+ label: String(value),
1259
+ score
1260
+ });
1261
+ }
1262
+ }
1263
+
1264
+ return suggestions.sort((a, b) => b.score - a.score);
1265
+ }
1266
+
1267
+ /**
1268
+ * Generate error recovery suggestions
1269
+ */
1270
+ private generateErrorRecovery(
1271
+ query: string,
1272
+ structure: IQueryStructure
1273
+ ): IErrorRecovery {
1274
+ // Check for unclosed quotes
1275
+ const singleQuotes = (query.match(/'/g) || []).length;
1276
+ const doubleQuotes = (query.match(/"/g) || []).length;
1277
+
1278
+ if (singleQuotes % 2 !== 0) {
1279
+ const lastQuotePos = query.lastIndexOf("'");
1280
+ return {
1281
+ issue: 'unclosed_quote',
1282
+ message: 'Unclosed single quote detected',
1283
+ suggestion: "Add a closing ' to complete the quoted value",
1284
+ autofix: query + "'",
1285
+ position: lastQuotePos
1286
+ };
1287
+ }
1288
+
1289
+ if (doubleQuotes % 2 !== 0) {
1290
+ const lastQuotePos = query.lastIndexOf('"');
1291
+ return {
1292
+ issue: 'unclosed_quote',
1293
+ message: 'Unclosed double quote detected',
1294
+ suggestion: 'Add a closing " to complete the quoted value',
1295
+ autofix: query + '"',
1296
+ position: lastQuotePos
1297
+ };
1298
+ }
1299
+
1300
+ // Check for unbalanced parentheses
1301
+ if (!structure.hasBalancedParentheses) {
1302
+ const openCount = (query.match(/\(/g) || []).length;
1303
+ const closeCount = (query.match(/\)/g) || []).length;
1304
+
1305
+ if (openCount > closeCount) {
1306
+ return {
1307
+ issue: 'unclosed_parenthesis',
1308
+ message: `Missing ${openCount - closeCount} closing parenthesis`,
1309
+ suggestion: 'Add closing parenthesis to balance the expression',
1310
+ autofix: query + ')'.repeat(openCount - closeCount)
1311
+ };
1312
+ } else {
1313
+ return {
1314
+ issue: 'unclosed_parenthesis',
1315
+ message: `Extra ${closeCount - openCount} closing parenthesis`,
1316
+ suggestion: 'Remove extra closing parenthesis'
1317
+ };
1318
+ }
1319
+ }
1320
+
1321
+ // Check for trailing operator
1322
+ const trimmed = query.trim();
1323
+ if (/\b(AND|OR|NOT)\s*$/i.test(trimmed)) {
1324
+ const match = trimmed.match(/\b(AND|OR|NOT)\s*$/i);
1325
+ return {
1326
+ issue: 'trailing_operator',
1327
+ message: `Query ends with incomplete "${match?.[1]}" operator`,
1328
+ suggestion: 'Add a condition after the operator or remove it',
1329
+ autofix: trimmed.replace(/\s*(AND|OR|NOT)\s*$/i, '').trim()
1330
+ };
1331
+ }
1332
+
1333
+ // Check for missing value (field:)
1334
+ if (/:$/.test(trimmed) || /:\s*$/.test(trimmed)) {
1335
+ return {
1336
+ issue: 'missing_value',
1337
+ message: 'Field is missing a value',
1338
+ suggestion: 'Add a value after the colon'
1339
+ };
1340
+ }
1341
+
1342
+ // Generic syntax error
1343
+ return {
1344
+ issue: 'syntax_error',
1345
+ message: 'Query contains a syntax error',
1346
+ suggestion: 'Check the query syntax and try again'
1347
+ };
1348
+ }
1349
+ }