@gblikas/querykit 0.2.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.
@@ -13,13 +13,30 @@ import type {
13
13
  } from 'liqe';
14
14
  import {
15
15
  ComparisonOperator,
16
+ IAutocompleteSuggestions,
16
17
  IComparisonExpression,
18
+ IErrorRecovery,
19
+ IFieldSchema,
20
+ IFieldSuggestion,
21
+ IFieldValidationDetail,
22
+ IFieldValidationResult,
17
23
  ILogicalExpression,
24
+ IOperatorSuggestion,
18
25
  IParserOptions,
26
+ IParseWithContextOptions,
19
27
  IQueryParser,
28
+ IQueryParseResult,
29
+ IQueryStructure,
30
+ IQueryToken,
31
+ ISecurityCheckResult,
32
+ ISecurityOptionsForContext,
33
+ ISecurityViolation,
34
+ ISecurityWarning,
35
+ IValueSuggestion,
20
36
  QueryExpression,
21
37
  QueryValue
22
38
  } from './types';
39
+ import { parseQueryTokens, isInputComplete, QueryToken } from './input-parser';
23
40
 
24
41
  /**
25
42
  * Error thrown when query parsing fails
@@ -474,4 +491,859 @@ export class QueryParser implements IQueryParser {
474
491
 
475
492
  return this.options.fieldMappings[normalizedField] ?? normalizedField;
476
493
  }
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
+ }
477
1349
  }