@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.
- package/.cursor/BUGBOT.md +65 -2
- package/README.md +163 -1
- package/dist/parser/index.d.ts +1 -0
- package/dist/parser/index.js +1 -0
- package/dist/parser/input-parser.d.ts +215 -0
- package/dist/parser/input-parser.js +493 -0
- package/dist/parser/parser.d.ts +114 -1
- package/dist/parser/parser.js +716 -0
- package/dist/parser/types.d.ts +432 -0
- package/examples/qk-next/app/page.tsx +6 -1
- package/package.json +1 -1
- package/src/parser/divergence.test.ts +357 -0
- package/src/parser/index.ts +2 -1
- package/src/parser/input-parser.test.ts +770 -0
- package/src/parser/input-parser.ts +697 -0
- package/src/parser/parse-with-context-suggestions.test.ts +360 -0
- package/src/parser/parse-with-context-validation.test.ts +447 -0
- package/src/parser/parse-with-context.test.ts +325 -0
- package/src/parser/parser.ts +872 -0
- package/src/parser/token-consistency.test.ts +341 -0
- package/src/parser/types.ts +545 -23
- package/examples/qk-next/pnpm-lock.yaml +0 -5623
package/src/parser/parser.ts
CHANGED
|
@@ -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
|
}
|