@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.
- 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 +148 -1
- package/dist/parser/parser.js +880 -6
- 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.test.ts +209 -1
- package/src/parser/parser.ts +1106 -25
- 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
|
@@ -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
|
-
|
|
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
|
|
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 = (
|
|
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 & {
|
|
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 (
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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(
|
|
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(':')
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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
|
+
}
|