@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/dist/parser/parser.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.QueryParser = exports.QueryParseError = void 0;
|
|
4
4
|
const liqe_1 = require("liqe");
|
|
5
|
+
const input_parser_1 = require("./input-parser");
|
|
5
6
|
/**
|
|
6
7
|
* Error thrown when query parsing fails
|
|
7
8
|
*/
|
|
@@ -27,19 +28,142 @@ class QueryParser {
|
|
|
27
28
|
*/
|
|
28
29
|
parse(query) {
|
|
29
30
|
try {
|
|
30
|
-
|
|
31
|
+
// Pre-process the query to handle IN operator syntax
|
|
32
|
+
const preprocessedQuery = this.preprocessQuery(query);
|
|
33
|
+
const liqeAst = (0, liqe_1.parse)(preprocessedQuery);
|
|
31
34
|
return this.convertLiqeAst(liqeAst);
|
|
32
35
|
}
|
|
33
36
|
catch (error) {
|
|
34
37
|
throw new QueryParseError(`Failed to parse query: ${error instanceof Error ? error.message : String(error)}`);
|
|
35
38
|
}
|
|
36
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Pre-process a query string to convert non-standard syntax to Liqe-compatible syntax.
|
|
42
|
+
* Supports:
|
|
43
|
+
* - `field:[val1, val2, val3]` → `(field:val1 OR field:val2 OR field:val3)`
|
|
44
|
+
*
|
|
45
|
+
* This keeps the syntax consistent with the `key:value` pattern used throughout QueryKit:
|
|
46
|
+
* - `priority:>2` (comparison)
|
|
47
|
+
* - `status:active` (equality)
|
|
48
|
+
* - `status:[todo, doing, done]` (IN / multiple values)
|
|
49
|
+
*/
|
|
50
|
+
preprocessQuery(query) {
|
|
51
|
+
let result = query;
|
|
52
|
+
// Handle `field:[val1, val2, ...]` syntax (array-like, not range)
|
|
53
|
+
// Pattern: fieldName:[value1, value2, ...]
|
|
54
|
+
// We distinguish from range by checking for commas without "TO"
|
|
55
|
+
const bracketArrayPattern = /(\w+):\[([^\]]+)\]/g;
|
|
56
|
+
result = result.replace(bracketArrayPattern, (fullMatch, field, values) => {
|
|
57
|
+
// Check if this looks like a range expression (contains " TO ")
|
|
58
|
+
if (/\s+TO\s+/i.test(values)) {
|
|
59
|
+
// This is a range expression, keep as-is
|
|
60
|
+
return fullMatch;
|
|
61
|
+
}
|
|
62
|
+
// This is an array-like expression, convert to OR
|
|
63
|
+
return this.convertToOrExpression(field, values);
|
|
64
|
+
});
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Convert a field and comma-separated values to an OR expression string
|
|
69
|
+
*/
|
|
70
|
+
convertToOrExpression(field, valuesStr) {
|
|
71
|
+
// Parse values respecting quoted strings (commas inside quotes are preserved)
|
|
72
|
+
const values = this.parseCommaSeparatedValues(valuesStr);
|
|
73
|
+
if (values.length === 0) {
|
|
74
|
+
return `${field}:""`;
|
|
75
|
+
}
|
|
76
|
+
if (values.length === 1) {
|
|
77
|
+
return this.formatFieldValue(field, values[0]);
|
|
78
|
+
}
|
|
79
|
+
// Build OR expression: (field:val1 OR field:val2 OR ...)
|
|
80
|
+
const orClauses = values.map((v) => this.formatFieldValue(field, v));
|
|
81
|
+
return `(${orClauses.join(' OR ')})`;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Parse a comma-separated string into values, respecting quoted strings.
|
|
85
|
+
* Commas inside quoted strings are preserved as part of the value.
|
|
86
|
+
*
|
|
87
|
+
* Examples:
|
|
88
|
+
* - `a, b, c` → ['a', 'b', 'c']
|
|
89
|
+
* - `"John, Jr.", Jane` → ['"John, Jr."', 'Jane']
|
|
90
|
+
* - `'hello, world', test` → ["'hello, world'", 'test']
|
|
91
|
+
*/
|
|
92
|
+
parseCommaSeparatedValues(input) {
|
|
93
|
+
const values = [];
|
|
94
|
+
let current = '';
|
|
95
|
+
let inDoubleQuotes = false;
|
|
96
|
+
let inSingleQuotes = false;
|
|
97
|
+
let i = 0;
|
|
98
|
+
while (i < input.length) {
|
|
99
|
+
const char = input[i];
|
|
100
|
+
const nextChar = input[i + 1];
|
|
101
|
+
// Handle escape sequences inside quotes
|
|
102
|
+
if ((inDoubleQuotes || inSingleQuotes) && char === '\\' && nextChar) {
|
|
103
|
+
// Include both the backslash and the escaped character
|
|
104
|
+
current += char + nextChar;
|
|
105
|
+
i += 2;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
// Toggle double quote state
|
|
109
|
+
if (char === '"' && !inSingleQuotes) {
|
|
110
|
+
inDoubleQuotes = !inDoubleQuotes;
|
|
111
|
+
current += char;
|
|
112
|
+
i++;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
// Toggle single quote state
|
|
116
|
+
if (char === "'" && !inDoubleQuotes) {
|
|
117
|
+
inSingleQuotes = !inSingleQuotes;
|
|
118
|
+
current += char;
|
|
119
|
+
i++;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
// Handle comma as separator (only when not inside quotes)
|
|
123
|
+
if (char === ',' && !inDoubleQuotes && !inSingleQuotes) {
|
|
124
|
+
const trimmed = current.trim();
|
|
125
|
+
if (trimmed.length > 0) {
|
|
126
|
+
values.push(trimmed);
|
|
127
|
+
}
|
|
128
|
+
current = '';
|
|
129
|
+
i++;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
// Regular character
|
|
133
|
+
current += char;
|
|
134
|
+
i++;
|
|
135
|
+
}
|
|
136
|
+
// Don't forget the last value
|
|
137
|
+
const trimmed = current.trim();
|
|
138
|
+
if (trimmed.length > 0) {
|
|
139
|
+
values.push(trimmed);
|
|
140
|
+
}
|
|
141
|
+
return values;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Format a field:value pair, quoting the value if necessary
|
|
145
|
+
*/
|
|
146
|
+
formatFieldValue(field, value) {
|
|
147
|
+
// If the value is already quoted, use it as-is
|
|
148
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
149
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
150
|
+
return `${field}:${value}`;
|
|
151
|
+
}
|
|
152
|
+
// If value contains spaces or special characters, quote it
|
|
153
|
+
if (/\s|[():]/.test(value)) {
|
|
154
|
+
// Escape quotes within the value
|
|
155
|
+
const escapedValue = value.replace(/"/g, '\\"');
|
|
156
|
+
return `${field}:"${escapedValue}"`;
|
|
157
|
+
}
|
|
158
|
+
return `${field}:${value}`;
|
|
159
|
+
}
|
|
37
160
|
/**
|
|
38
161
|
* Validate a query string
|
|
39
162
|
*/
|
|
40
163
|
validate(query) {
|
|
41
164
|
try {
|
|
42
|
-
const
|
|
165
|
+
const preprocessedQuery = this.preprocessQuery(query);
|
|
166
|
+
const ast = (0, liqe_1.parse)(preprocessedQuery);
|
|
43
167
|
this.convertLiqeAst(ast);
|
|
44
168
|
return true;
|
|
45
169
|
}
|
|
@@ -72,10 +196,16 @@ class QueryParser {
|
|
|
72
196
|
throw new QueryParseError('Invalid field or expression in Tag node');
|
|
73
197
|
}
|
|
74
198
|
const fieldName = this.normalizeFieldName(field.name);
|
|
199
|
+
// Handle RangeExpression (e.g., field:[min TO max])
|
|
200
|
+
if (expression.type === 'RangeExpression') {
|
|
201
|
+
return this.convertRangeExpression(fieldName, expression);
|
|
202
|
+
}
|
|
75
203
|
const operator = this.convertLiqeOperator(tagNode.operator.operator);
|
|
76
204
|
const value = this.convertLiqeValue(expression.value);
|
|
77
205
|
// Check for wildcard patterns in string values
|
|
78
|
-
if (operator === '==' &&
|
|
206
|
+
if (operator === '==' &&
|
|
207
|
+
typeof value === 'string' &&
|
|
208
|
+
(value.includes('*') || value.includes('?'))) {
|
|
79
209
|
return this.createComparisonExpression(fieldName, 'LIKE', value);
|
|
80
210
|
}
|
|
81
211
|
return this.createComparisonExpression(fieldName, operator, value);
|
|
@@ -133,6 +263,31 @@ class QueryParser {
|
|
|
133
263
|
value
|
|
134
264
|
};
|
|
135
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* Convert a Liqe RangeExpression to a QueryKit logical AND expression
|
|
268
|
+
* E.g., `field:[2 TO 5]` becomes `(field >= 2 AND field <= 5)`
|
|
269
|
+
*/
|
|
270
|
+
convertRangeExpression(fieldName, expression) {
|
|
271
|
+
const range = expression.range;
|
|
272
|
+
// Handle null/undefined range values
|
|
273
|
+
if (range === null || range === undefined) {
|
|
274
|
+
throw new QueryParseError('Invalid range expression: missing range data');
|
|
275
|
+
}
|
|
276
|
+
const { min, max, minInclusive, maxInclusive } = range;
|
|
277
|
+
// Determine the operators based on inclusivity
|
|
278
|
+
const minOperator = minInclusive ? '>=' : '>';
|
|
279
|
+
const maxOperator = maxInclusive ? '<=' : '<';
|
|
280
|
+
// Create comparison expressions for min and max
|
|
281
|
+
const minComparison = this.createComparisonExpression(fieldName, minOperator, min);
|
|
282
|
+
const maxComparison = this.createComparisonExpression(fieldName, maxOperator, max);
|
|
283
|
+
// Combine with AND
|
|
284
|
+
return {
|
|
285
|
+
type: 'logical',
|
|
286
|
+
operator: 'AND',
|
|
287
|
+
left: minComparison,
|
|
288
|
+
right: maxComparison
|
|
289
|
+
};
|
|
290
|
+
}
|
|
136
291
|
/**
|
|
137
292
|
* Convert a Liqe operator to a QueryKit operator
|
|
138
293
|
*/
|
|
@@ -142,7 +297,9 @@ class QueryParser {
|
|
|
142
297
|
return '==';
|
|
143
298
|
}
|
|
144
299
|
// Check if the operator is prefixed with a colon
|
|
145
|
-
const actualOperator = operator.startsWith(':')
|
|
300
|
+
const actualOperator = operator.startsWith(':')
|
|
301
|
+
? operator.substring(1)
|
|
302
|
+
: operator;
|
|
146
303
|
// Map Liqe operators to QueryKit operators
|
|
147
304
|
const operatorMap = {
|
|
148
305
|
'=': '==',
|
|
@@ -151,7 +308,7 @@ class QueryParser {
|
|
|
151
308
|
'>=': '>=',
|
|
152
309
|
'<': '<',
|
|
153
310
|
'<=': '<=',
|
|
154
|
-
|
|
311
|
+
in: 'IN',
|
|
155
312
|
'not in': 'NOT IN'
|
|
156
313
|
};
|
|
157
314
|
const queryKitOperator = operatorMap[actualOperator.toLowerCase()];
|
|
@@ -169,7 +326,9 @@ class QueryParser {
|
|
|
169
326
|
if (value === null) {
|
|
170
327
|
return null;
|
|
171
328
|
}
|
|
172
|
-
if (typeof value === 'string' ||
|
|
329
|
+
if (typeof value === 'string' ||
|
|
330
|
+
typeof value === 'number' ||
|
|
331
|
+
typeof value === 'boolean') {
|
|
173
332
|
return value;
|
|
174
333
|
}
|
|
175
334
|
if (Array.isArray(value)) {
|
|
@@ -197,5 +356,720 @@ class QueryParser {
|
|
|
197
356
|
: field;
|
|
198
357
|
return this.options.fieldMappings[normalizedField] ?? normalizedField;
|
|
199
358
|
}
|
|
359
|
+
/**
|
|
360
|
+
* Parse a query string with full context information.
|
|
361
|
+
*
|
|
362
|
+
* Unlike `parse()`, this method never throws. Instead, it returns a result object
|
|
363
|
+
* that indicates success or failure along with rich contextual information useful
|
|
364
|
+
* for building search UIs.
|
|
365
|
+
*
|
|
366
|
+
* @param query The query string to parse
|
|
367
|
+
* @param options Optional configuration (cursor position, etc.)
|
|
368
|
+
* @returns Rich parse result with tokens, AST/error, and structural analysis
|
|
369
|
+
*
|
|
370
|
+
* @example
|
|
371
|
+
* ```typescript
|
|
372
|
+
* const result = parser.parseWithContext('status:done AND priority:high');
|
|
373
|
+
*
|
|
374
|
+
* if (result.success) {
|
|
375
|
+
* // Use result.ast for query execution
|
|
376
|
+
* console.log('Valid query:', result.ast);
|
|
377
|
+
* } else {
|
|
378
|
+
* // Show error to user
|
|
379
|
+
* console.log('Error:', result.error?.message);
|
|
380
|
+
* }
|
|
381
|
+
*
|
|
382
|
+
* // Always available for UI rendering
|
|
383
|
+
* console.log('Tokens:', result.tokens);
|
|
384
|
+
* console.log('Structure:', result.structure);
|
|
385
|
+
* ```
|
|
386
|
+
*/
|
|
387
|
+
parseWithContext(query, options = {}) {
|
|
388
|
+
// Get tokens from input parser (always works, even for invalid input)
|
|
389
|
+
const tokenResult = (0, input_parser_1.parseQueryTokens)(query, options.cursorPosition);
|
|
390
|
+
const tokens = this.convertTokens(tokenResult.tokens);
|
|
391
|
+
// Analyze structure
|
|
392
|
+
const structure = this.analyzeStructure(query, tokens);
|
|
393
|
+
// Attempt full parse
|
|
394
|
+
let ast;
|
|
395
|
+
let error;
|
|
396
|
+
let success = false;
|
|
397
|
+
try {
|
|
398
|
+
ast = this.parse(query);
|
|
399
|
+
success = true;
|
|
400
|
+
}
|
|
401
|
+
catch (e) {
|
|
402
|
+
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
403
|
+
error = {
|
|
404
|
+
message: errorMessage,
|
|
405
|
+
// Try to extract position from error message if available
|
|
406
|
+
position: this.extractErrorPosition(errorMessage),
|
|
407
|
+
problematicText: this.extractProblematicText(query, errorMessage)
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
// Determine active token
|
|
411
|
+
const activeToken = tokenResult.activeToken
|
|
412
|
+
? this.convertSingleToken(tokenResult.activeToken)
|
|
413
|
+
: undefined;
|
|
414
|
+
// Build base result
|
|
415
|
+
const result = {
|
|
416
|
+
success,
|
|
417
|
+
input: query,
|
|
418
|
+
ast,
|
|
419
|
+
error,
|
|
420
|
+
tokens,
|
|
421
|
+
activeToken,
|
|
422
|
+
activeTokenIndex: tokenResult.activeTokenIndex,
|
|
423
|
+
structure
|
|
424
|
+
};
|
|
425
|
+
// Perform field validation if schema provided
|
|
426
|
+
if (options.schema) {
|
|
427
|
+
result.fieldValidation = this.validateFields(structure.referencedFields, options.schema);
|
|
428
|
+
}
|
|
429
|
+
// Perform security pre-check if security options provided
|
|
430
|
+
if (options.securityOptions) {
|
|
431
|
+
result.security = this.performSecurityCheck(structure, options.securityOptions);
|
|
432
|
+
}
|
|
433
|
+
// Generate autocomplete suggestions if cursor position provided
|
|
434
|
+
if (options.cursorPosition !== undefined) {
|
|
435
|
+
result.suggestions = this.generateAutocompleteSuggestions(query, options.cursorPosition, activeToken, options.schema);
|
|
436
|
+
}
|
|
437
|
+
// Generate error recovery suggestions if parsing failed
|
|
438
|
+
if (!success) {
|
|
439
|
+
result.recovery = this.generateErrorRecovery(query, structure);
|
|
440
|
+
}
|
|
441
|
+
return result;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Convert tokens from input parser format to IQueryToken format
|
|
445
|
+
*/
|
|
446
|
+
convertTokens(tokens) {
|
|
447
|
+
return tokens.map(token => this.convertSingleToken(token));
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Convert a single token from input parser format
|
|
451
|
+
*/
|
|
452
|
+
convertSingleToken(token) {
|
|
453
|
+
if (token.type === 'term') {
|
|
454
|
+
return {
|
|
455
|
+
type: 'term',
|
|
456
|
+
key: token.key,
|
|
457
|
+
operator: token.operator,
|
|
458
|
+
value: token.value,
|
|
459
|
+
startPosition: token.startPosition,
|
|
460
|
+
endPosition: token.endPosition,
|
|
461
|
+
raw: token.raw
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
return {
|
|
466
|
+
type: 'operator',
|
|
467
|
+
operator: token.operator,
|
|
468
|
+
startPosition: token.startPosition,
|
|
469
|
+
endPosition: token.endPosition,
|
|
470
|
+
raw: token.raw
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Analyze the structure of a query
|
|
476
|
+
*/
|
|
477
|
+
analyzeStructure(query, tokens) {
|
|
478
|
+
// Count parentheses
|
|
479
|
+
const openParens = (query.match(/\(/g) || []).length;
|
|
480
|
+
const closeParens = (query.match(/\)/g) || []).length;
|
|
481
|
+
const hasBalancedParentheses = openParens === closeParens;
|
|
482
|
+
// Count quotes
|
|
483
|
+
const singleQuotes = (query.match(/'/g) || []).length;
|
|
484
|
+
const doubleQuotes = (query.match(/"/g) || []).length;
|
|
485
|
+
const hasBalancedQuotes = singleQuotes % 2 === 0 && doubleQuotes % 2 === 0;
|
|
486
|
+
// Count terms and operators
|
|
487
|
+
const termTokens = tokens.filter(t => t.type === 'term');
|
|
488
|
+
const operatorTokens = tokens.filter(t => t.type === 'operator');
|
|
489
|
+
const clauseCount = termTokens.length;
|
|
490
|
+
const operatorCount = operatorTokens.length;
|
|
491
|
+
// Extract referenced fields
|
|
492
|
+
const referencedFields = [];
|
|
493
|
+
for (const token of termTokens) {
|
|
494
|
+
if (token.type === 'term' && token.key !== null) {
|
|
495
|
+
if (!referencedFields.includes(token.key)) {
|
|
496
|
+
referencedFields.push(token.key);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
// Calculate depth (by counting max nesting in parentheses)
|
|
501
|
+
const depth = this.calculateDepth(query);
|
|
502
|
+
// Check if complete
|
|
503
|
+
const isComplete = (0, input_parser_1.isInputComplete)(query);
|
|
504
|
+
// Determine complexity
|
|
505
|
+
const complexity = this.determineComplexity(clauseCount, operatorCount, depth);
|
|
506
|
+
return {
|
|
507
|
+
depth,
|
|
508
|
+
clauseCount,
|
|
509
|
+
operatorCount,
|
|
510
|
+
hasBalancedParentheses,
|
|
511
|
+
hasBalancedQuotes,
|
|
512
|
+
isComplete,
|
|
513
|
+
referencedFields,
|
|
514
|
+
complexity
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Calculate the maximum nesting depth of parentheses
|
|
519
|
+
*/
|
|
520
|
+
calculateDepth(query) {
|
|
521
|
+
let maxDepth = 0;
|
|
522
|
+
let currentDepth = 0;
|
|
523
|
+
for (const char of query) {
|
|
524
|
+
if (char === '(') {
|
|
525
|
+
currentDepth++;
|
|
526
|
+
maxDepth = Math.max(maxDepth, currentDepth);
|
|
527
|
+
}
|
|
528
|
+
else if (char === ')') {
|
|
529
|
+
currentDepth = Math.max(0, currentDepth - 1);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
// Base depth is 1 if there's any content
|
|
533
|
+
return query.trim().length > 0 ? Math.max(1, maxDepth) : 0;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Determine query complexity classification
|
|
537
|
+
*/
|
|
538
|
+
determineComplexity(clauseCount, operatorCount, depth) {
|
|
539
|
+
// Simple: 1-2 clauses, no nesting
|
|
540
|
+
if (clauseCount <= 2 && depth <= 1) {
|
|
541
|
+
return 'simple';
|
|
542
|
+
}
|
|
543
|
+
// Complex: many clauses, deep nesting, or many operators
|
|
544
|
+
if (clauseCount > 5 || depth > 3 || operatorCount > 4) {
|
|
545
|
+
return 'complex';
|
|
546
|
+
}
|
|
547
|
+
return 'moderate';
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Try to extract error position from error message
|
|
551
|
+
*/
|
|
552
|
+
extractErrorPosition(errorMessage) {
|
|
553
|
+
// Try to find position indicators in error messages
|
|
554
|
+
// e.g., "at position 15" or "column 15"
|
|
555
|
+
const posMatch = errorMessage.match(/(?:position|column|offset)\s*(\d+)/i);
|
|
556
|
+
if (posMatch) {
|
|
557
|
+
return parseInt(posMatch[1], 10);
|
|
558
|
+
}
|
|
559
|
+
return undefined;
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Try to extract the problematic text from the query based on error
|
|
563
|
+
*/
|
|
564
|
+
extractProblematicText(query, errorMessage) {
|
|
565
|
+
// If we found a position, extract surrounding text
|
|
566
|
+
const position = this.extractErrorPosition(errorMessage);
|
|
567
|
+
if (position !== undefined && position < query.length) {
|
|
568
|
+
const start = Math.max(0, position - 10);
|
|
569
|
+
const end = Math.min(query.length, position + 10);
|
|
570
|
+
return query.substring(start, end);
|
|
571
|
+
}
|
|
572
|
+
// Try to find quoted text in error message
|
|
573
|
+
const quotedMatch = errorMessage.match(/"([^"]+)"/);
|
|
574
|
+
if (quotedMatch) {
|
|
575
|
+
return quotedMatch[1];
|
|
576
|
+
}
|
|
577
|
+
return undefined;
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Validate fields against the provided schema
|
|
581
|
+
*/
|
|
582
|
+
validateFields(referencedFields, schema) {
|
|
583
|
+
const schemaFields = Object.keys(schema);
|
|
584
|
+
const fields = [];
|
|
585
|
+
const unknownFields = [];
|
|
586
|
+
let allValid = true;
|
|
587
|
+
for (const field of referencedFields) {
|
|
588
|
+
if (field in schema) {
|
|
589
|
+
// Field exists in schema
|
|
590
|
+
fields.push({
|
|
591
|
+
field,
|
|
592
|
+
valid: true,
|
|
593
|
+
expectedType: schema[field].type,
|
|
594
|
+
allowedValues: schema[field].allowedValues
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
else {
|
|
598
|
+
// Field not in schema - try to find a suggestion
|
|
599
|
+
const suggestion = this.findSimilarField(field, schemaFields);
|
|
600
|
+
fields.push({
|
|
601
|
+
field,
|
|
602
|
+
valid: false,
|
|
603
|
+
reason: 'unknown_field',
|
|
604
|
+
suggestion
|
|
605
|
+
});
|
|
606
|
+
unknownFields.push(field);
|
|
607
|
+
allValid = false;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return {
|
|
611
|
+
valid: allValid,
|
|
612
|
+
fields,
|
|
613
|
+
unknownFields
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Find a similar field name (for typo suggestions)
|
|
618
|
+
*/
|
|
619
|
+
findSimilarField(field, schemaFields) {
|
|
620
|
+
const fieldLower = field.toLowerCase();
|
|
621
|
+
// First, try exact case-insensitive match
|
|
622
|
+
for (const schemaField of schemaFields) {
|
|
623
|
+
if (schemaField.toLowerCase() === fieldLower) {
|
|
624
|
+
return schemaField;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
// Then, try to find fields that start with the same prefix
|
|
628
|
+
const prefix = fieldLower.substring(0, Math.min(3, fieldLower.length));
|
|
629
|
+
for (const schemaField of schemaFields) {
|
|
630
|
+
if (schemaField.toLowerCase().startsWith(prefix)) {
|
|
631
|
+
return schemaField;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
// Try Levenshtein distance for short fields
|
|
635
|
+
if (field.length <= 10) {
|
|
636
|
+
let bestMatch;
|
|
637
|
+
let bestDistance = Infinity;
|
|
638
|
+
for (const schemaField of schemaFields) {
|
|
639
|
+
const distance = this.levenshteinDistance(fieldLower, schemaField.toLowerCase());
|
|
640
|
+
if (distance <= 2 && distance < bestDistance) {
|
|
641
|
+
bestDistance = distance;
|
|
642
|
+
bestMatch = schemaField;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (bestMatch) {
|
|
646
|
+
return bestMatch;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return undefined;
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Calculate Levenshtein distance between two strings
|
|
653
|
+
*/
|
|
654
|
+
levenshteinDistance(a, b) {
|
|
655
|
+
const matrix = [];
|
|
656
|
+
for (let i = 0; i <= b.length; i++) {
|
|
657
|
+
matrix[i] = [i];
|
|
658
|
+
}
|
|
659
|
+
for (let j = 0; j <= a.length; j++) {
|
|
660
|
+
matrix[0][j] = j;
|
|
661
|
+
}
|
|
662
|
+
for (let i = 1; i <= b.length; i++) {
|
|
663
|
+
for (let j = 1; j <= a.length; j++) {
|
|
664
|
+
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
|
665
|
+
matrix[i][j] = matrix[i - 1][j - 1];
|
|
666
|
+
}
|
|
667
|
+
else {
|
|
668
|
+
matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, // substitution
|
|
669
|
+
matrix[i][j - 1] + 1, // insertion
|
|
670
|
+
matrix[i - 1][j] + 1 // deletion
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return matrix[b.length][a.length];
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Perform security pre-check against the provided options
|
|
679
|
+
*/
|
|
680
|
+
performSecurityCheck(structure, options) {
|
|
681
|
+
const violations = [];
|
|
682
|
+
const warnings = [];
|
|
683
|
+
// Check denied fields
|
|
684
|
+
if (options.denyFields && options.denyFields.length > 0) {
|
|
685
|
+
for (const field of structure.referencedFields) {
|
|
686
|
+
if (options.denyFields.includes(field)) {
|
|
687
|
+
violations.push({
|
|
688
|
+
type: 'denied_field',
|
|
689
|
+
message: `Field "${field}" is not allowed in queries`,
|
|
690
|
+
field
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
// Check allowed fields (if specified, only these fields are allowed)
|
|
696
|
+
if (options.allowedFields && options.allowedFields.length > 0) {
|
|
697
|
+
for (const field of structure.referencedFields) {
|
|
698
|
+
if (!options.allowedFields.includes(field)) {
|
|
699
|
+
violations.push({
|
|
700
|
+
type: 'field_not_allowed',
|
|
701
|
+
message: `Field "${field}" is not in the list of allowed fields`,
|
|
702
|
+
field
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
// Check dot notation
|
|
708
|
+
if (options.allowDotNotation === false) {
|
|
709
|
+
for (const field of structure.referencedFields) {
|
|
710
|
+
if (field.includes('.')) {
|
|
711
|
+
violations.push({
|
|
712
|
+
type: 'dot_notation',
|
|
713
|
+
message: `Dot notation is not allowed in field names: "${field}"`,
|
|
714
|
+
field
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
// Check query depth
|
|
720
|
+
if (options.maxQueryDepth !== undefined) {
|
|
721
|
+
if (structure.depth > options.maxQueryDepth) {
|
|
722
|
+
violations.push({
|
|
723
|
+
type: 'depth_exceeded',
|
|
724
|
+
message: `Query depth (${structure.depth}) exceeds maximum allowed (${options.maxQueryDepth})`
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
else if (structure.depth >= options.maxQueryDepth * 0.8) {
|
|
728
|
+
warnings.push({
|
|
729
|
+
type: 'approaching_depth_limit',
|
|
730
|
+
message: `Query depth (${structure.depth}) is approaching the limit (${options.maxQueryDepth})`,
|
|
731
|
+
current: structure.depth,
|
|
732
|
+
limit: options.maxQueryDepth
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
// Check clause count
|
|
737
|
+
if (options.maxClauseCount !== undefined) {
|
|
738
|
+
if (structure.clauseCount > options.maxClauseCount) {
|
|
739
|
+
violations.push({
|
|
740
|
+
type: 'clause_limit',
|
|
741
|
+
message: `Clause count (${structure.clauseCount}) exceeds maximum allowed (${options.maxClauseCount})`
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
else if (structure.clauseCount >= options.maxClauseCount * 0.8) {
|
|
745
|
+
warnings.push({
|
|
746
|
+
type: 'approaching_clause_limit',
|
|
747
|
+
message: `Clause count (${structure.clauseCount}) is approaching the limit (${options.maxClauseCount})`,
|
|
748
|
+
current: structure.clauseCount,
|
|
749
|
+
limit: options.maxClauseCount
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
// Add complexity warning
|
|
754
|
+
if (structure.complexity === 'complex') {
|
|
755
|
+
warnings.push({
|
|
756
|
+
type: 'complex_query',
|
|
757
|
+
message: 'This query is complex and may impact performance'
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
return {
|
|
761
|
+
passed: violations.length === 0,
|
|
762
|
+
violations,
|
|
763
|
+
warnings
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Generate autocomplete suggestions based on cursor position
|
|
768
|
+
*/
|
|
769
|
+
generateAutocompleteSuggestions(query, cursorPosition, activeToken, schema) {
|
|
770
|
+
// Determine context based on active token and position
|
|
771
|
+
if (!activeToken) {
|
|
772
|
+
// Cursor is not in a token - check if we're at the start or between tokens
|
|
773
|
+
if (query.trim().length === 0 || cursorPosition === 0) {
|
|
774
|
+
return this.suggestForEmptyContext(schema);
|
|
775
|
+
}
|
|
776
|
+
// Between tokens - suggest logical operators or new field
|
|
777
|
+
return this.suggestBetweenTokens(schema);
|
|
778
|
+
}
|
|
779
|
+
if (activeToken.type === 'operator') {
|
|
780
|
+
// Cursor is in a logical operator
|
|
781
|
+
return {
|
|
782
|
+
context: 'logical_operator',
|
|
783
|
+
logicalOperators: ['AND', 'OR', 'NOT'],
|
|
784
|
+
replaceText: activeToken.raw,
|
|
785
|
+
replaceRange: {
|
|
786
|
+
start: activeToken.startPosition,
|
|
787
|
+
end: activeToken.endPosition
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
// Active token is a term
|
|
792
|
+
const term = activeToken;
|
|
793
|
+
const relativePos = cursorPosition - term.startPosition;
|
|
794
|
+
// Determine if cursor is in key, operator, or value part
|
|
795
|
+
if (term.key !== null && term.operator !== null) {
|
|
796
|
+
const keyLength = term.key.length;
|
|
797
|
+
const operatorLength = term.operator.length;
|
|
798
|
+
if (relativePos < keyLength) {
|
|
799
|
+
// Cursor is in the field name
|
|
800
|
+
return this.suggestFields(term.key, schema);
|
|
801
|
+
}
|
|
802
|
+
else if (relativePos < keyLength + operatorLength) {
|
|
803
|
+
// Cursor is in the operator
|
|
804
|
+
return this.suggestOperators(term.key, schema);
|
|
805
|
+
}
|
|
806
|
+
else {
|
|
807
|
+
// Cursor is in the value
|
|
808
|
+
return this.suggestValues(term.key, term.value, schema);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
else if (term.key !== null) {
|
|
812
|
+
// Only key present (incomplete)
|
|
813
|
+
return this.suggestFields(term.key, schema);
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
// Bare value - could be a field name
|
|
817
|
+
return this.suggestFields(term.value || '', schema);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Suggest for empty/start context
|
|
822
|
+
*/
|
|
823
|
+
suggestForEmptyContext(schema) {
|
|
824
|
+
return {
|
|
825
|
+
context: 'empty',
|
|
826
|
+
fields: this.getFieldSuggestions('', schema),
|
|
827
|
+
logicalOperators: ['NOT']
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Suggest between tokens (after a complete term)
|
|
832
|
+
*/
|
|
833
|
+
suggestBetweenTokens(schema) {
|
|
834
|
+
return {
|
|
835
|
+
context: 'logical_operator',
|
|
836
|
+
fields: this.getFieldSuggestions('', schema),
|
|
837
|
+
logicalOperators: ['AND', 'OR', 'NOT']
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Suggest field names
|
|
842
|
+
*/
|
|
843
|
+
suggestFields(partial, schema) {
|
|
844
|
+
return {
|
|
845
|
+
context: 'field',
|
|
846
|
+
fields: this.getFieldSuggestions(partial, schema),
|
|
847
|
+
replaceText: partial,
|
|
848
|
+
replaceRange: partial.length > 0 ? { start: 0, end: partial.length } : undefined
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Get field suggestions based on partial input
|
|
853
|
+
*/
|
|
854
|
+
getFieldSuggestions(partial, schema) {
|
|
855
|
+
if (!schema) {
|
|
856
|
+
return [];
|
|
857
|
+
}
|
|
858
|
+
const partialLower = partial.toLowerCase();
|
|
859
|
+
const suggestions = [];
|
|
860
|
+
for (const [field, fieldSchema] of Object.entries(schema)) {
|
|
861
|
+
const fieldLower = field.toLowerCase();
|
|
862
|
+
let score = 0;
|
|
863
|
+
if (partial.length === 0) {
|
|
864
|
+
// No partial - suggest all fields with base score
|
|
865
|
+
score = 50;
|
|
866
|
+
}
|
|
867
|
+
else if (fieldLower === partialLower) {
|
|
868
|
+
// Exact match
|
|
869
|
+
score = 100;
|
|
870
|
+
}
|
|
871
|
+
else if (fieldLower.startsWith(partialLower)) {
|
|
872
|
+
// Prefix match
|
|
873
|
+
score = 80 + (partial.length / field.length) * 20;
|
|
874
|
+
}
|
|
875
|
+
else if (fieldLower.includes(partialLower)) {
|
|
876
|
+
// Contains match
|
|
877
|
+
score = 60;
|
|
878
|
+
}
|
|
879
|
+
else {
|
|
880
|
+
// Check Levenshtein distance for typos
|
|
881
|
+
const distance = this.levenshteinDistance(partialLower, fieldLower);
|
|
882
|
+
if (distance <= 2) {
|
|
883
|
+
score = 40 - distance * 10;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
if (score > 0) {
|
|
887
|
+
suggestions.push({
|
|
888
|
+
field,
|
|
889
|
+
type: fieldSchema.type,
|
|
890
|
+
description: fieldSchema.description,
|
|
891
|
+
score
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
// Sort by score descending
|
|
896
|
+
return suggestions.sort((a, b) => b.score - a.score);
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Suggest operators
|
|
900
|
+
*/
|
|
901
|
+
suggestOperators(field, schema) {
|
|
902
|
+
const fieldType = schema?.[field]?.type;
|
|
903
|
+
const operators = this.getOperatorSuggestions(fieldType);
|
|
904
|
+
return {
|
|
905
|
+
context: 'operator',
|
|
906
|
+
currentField: field,
|
|
907
|
+
operators
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Get operator suggestions based on field type
|
|
912
|
+
*/
|
|
913
|
+
getOperatorSuggestions(fieldType) {
|
|
914
|
+
const allOperators = [
|
|
915
|
+
{ operator: ':', description: 'equals', applicable: true },
|
|
916
|
+
{ operator: ':!=', description: 'not equals', applicable: true },
|
|
917
|
+
{
|
|
918
|
+
operator: ':>',
|
|
919
|
+
description: 'greater than',
|
|
920
|
+
applicable: fieldType === 'number' || fieldType === 'date'
|
|
921
|
+
},
|
|
922
|
+
{
|
|
923
|
+
operator: ':>=',
|
|
924
|
+
description: 'greater than or equal',
|
|
925
|
+
applicable: fieldType === 'number' || fieldType === 'date'
|
|
926
|
+
},
|
|
927
|
+
{
|
|
928
|
+
operator: ':<',
|
|
929
|
+
description: 'less than',
|
|
930
|
+
applicable: fieldType === 'number' || fieldType === 'date'
|
|
931
|
+
},
|
|
932
|
+
{
|
|
933
|
+
operator: ':<=',
|
|
934
|
+
description: 'less than or equal',
|
|
935
|
+
applicable: fieldType === 'number' || fieldType === 'date'
|
|
936
|
+
}
|
|
937
|
+
];
|
|
938
|
+
// Sort applicable operators first
|
|
939
|
+
return allOperators.sort((a, b) => {
|
|
940
|
+
if (a.applicable && !b.applicable)
|
|
941
|
+
return -1;
|
|
942
|
+
if (!a.applicable && b.applicable)
|
|
943
|
+
return 1;
|
|
944
|
+
return 0;
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* Suggest values
|
|
949
|
+
*/
|
|
950
|
+
suggestValues(field, partialValue, schema) {
|
|
951
|
+
const fieldSchema = schema?.[field];
|
|
952
|
+
const values = this.getValueSuggestions(partialValue || '', fieldSchema);
|
|
953
|
+
return {
|
|
954
|
+
context: 'value',
|
|
955
|
+
currentField: field,
|
|
956
|
+
values,
|
|
957
|
+
replaceText: partialValue || undefined
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Get value suggestions based on schema
|
|
962
|
+
*/
|
|
963
|
+
getValueSuggestions(partial, fieldSchema) {
|
|
964
|
+
if (!fieldSchema?.allowedValues) {
|
|
965
|
+
// Suggest based on type
|
|
966
|
+
if (fieldSchema?.type === 'boolean') {
|
|
967
|
+
return [
|
|
968
|
+
{ value: true, label: 'true', score: 100 },
|
|
969
|
+
{ value: false, label: 'false', score: 100 }
|
|
970
|
+
];
|
|
971
|
+
}
|
|
972
|
+
return [];
|
|
973
|
+
}
|
|
974
|
+
const partialLower = partial.toLowerCase();
|
|
975
|
+
const suggestions = [];
|
|
976
|
+
for (const value of fieldSchema.allowedValues) {
|
|
977
|
+
const valueStr = String(value).toLowerCase();
|
|
978
|
+
let score = 0;
|
|
979
|
+
if (partial.length === 0) {
|
|
980
|
+
score = 50;
|
|
981
|
+
}
|
|
982
|
+
else if (valueStr === partialLower) {
|
|
983
|
+
score = 100;
|
|
984
|
+
}
|
|
985
|
+
else if (valueStr.startsWith(partialLower)) {
|
|
986
|
+
score = 80;
|
|
987
|
+
}
|
|
988
|
+
else if (valueStr.includes(partialLower)) {
|
|
989
|
+
score = 60;
|
|
990
|
+
}
|
|
991
|
+
if (score > 0) {
|
|
992
|
+
suggestions.push({
|
|
993
|
+
value,
|
|
994
|
+
label: String(value),
|
|
995
|
+
score
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
return suggestions.sort((a, b) => b.score - a.score);
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Generate error recovery suggestions
|
|
1003
|
+
*/
|
|
1004
|
+
generateErrorRecovery(query, structure) {
|
|
1005
|
+
// Check for unclosed quotes
|
|
1006
|
+
const singleQuotes = (query.match(/'/g) || []).length;
|
|
1007
|
+
const doubleQuotes = (query.match(/"/g) || []).length;
|
|
1008
|
+
if (singleQuotes % 2 !== 0) {
|
|
1009
|
+
const lastQuotePos = query.lastIndexOf("'");
|
|
1010
|
+
return {
|
|
1011
|
+
issue: 'unclosed_quote',
|
|
1012
|
+
message: 'Unclosed single quote detected',
|
|
1013
|
+
suggestion: "Add a closing ' to complete the quoted value",
|
|
1014
|
+
autofix: query + "'",
|
|
1015
|
+
position: lastQuotePos
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
if (doubleQuotes % 2 !== 0) {
|
|
1019
|
+
const lastQuotePos = query.lastIndexOf('"');
|
|
1020
|
+
return {
|
|
1021
|
+
issue: 'unclosed_quote',
|
|
1022
|
+
message: 'Unclosed double quote detected',
|
|
1023
|
+
suggestion: 'Add a closing " to complete the quoted value',
|
|
1024
|
+
autofix: query + '"',
|
|
1025
|
+
position: lastQuotePos
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
// Check for unbalanced parentheses
|
|
1029
|
+
if (!structure.hasBalancedParentheses) {
|
|
1030
|
+
const openCount = (query.match(/\(/g) || []).length;
|
|
1031
|
+
const closeCount = (query.match(/\)/g) || []).length;
|
|
1032
|
+
if (openCount > closeCount) {
|
|
1033
|
+
return {
|
|
1034
|
+
issue: 'unclosed_parenthesis',
|
|
1035
|
+
message: `Missing ${openCount - closeCount} closing parenthesis`,
|
|
1036
|
+
suggestion: 'Add closing parenthesis to balance the expression',
|
|
1037
|
+
autofix: query + ')'.repeat(openCount - closeCount)
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
else {
|
|
1041
|
+
return {
|
|
1042
|
+
issue: 'unclosed_parenthesis',
|
|
1043
|
+
message: `Extra ${closeCount - openCount} closing parenthesis`,
|
|
1044
|
+
suggestion: 'Remove extra closing parenthesis'
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
// Check for trailing operator
|
|
1049
|
+
const trimmed = query.trim();
|
|
1050
|
+
if (/\b(AND|OR|NOT)\s*$/i.test(trimmed)) {
|
|
1051
|
+
const match = trimmed.match(/\b(AND|OR|NOT)\s*$/i);
|
|
1052
|
+
return {
|
|
1053
|
+
issue: 'trailing_operator',
|
|
1054
|
+
message: `Query ends with incomplete "${match?.[1]}" operator`,
|
|
1055
|
+
suggestion: 'Add a condition after the operator or remove it',
|
|
1056
|
+
autofix: trimmed.replace(/\s*(AND|OR|NOT)\s*$/i, '').trim()
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
// Check for missing value (field:)
|
|
1060
|
+
if (/:$/.test(trimmed) || /:\s*$/.test(trimmed)) {
|
|
1061
|
+
return {
|
|
1062
|
+
issue: 'missing_value',
|
|
1063
|
+
message: 'Field is missing a value',
|
|
1064
|
+
suggestion: 'Add a value after the colon'
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
// Generic syntax error
|
|
1068
|
+
return {
|
|
1069
|
+
issue: 'syntax_error',
|
|
1070
|
+
message: 'Query contains a syntax error',
|
|
1071
|
+
suggestion: 'Check the query syntax and try again'
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
200
1074
|
}
|
|
201
1075
|
exports.QueryParser = QueryParser;
|