@gblikas/querykit 0.1.0 → 0.2.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/dist/parser/parser.d.ts +34 -0
- package/dist/parser/parser.js +164 -6
- package/package.json +1 -1
- package/src/parser/parser.test.ts +209 -1
- package/src/parser/parser.ts +234 -25
package/dist/parser/parser.d.ts
CHANGED
|
@@ -15,6 +15,35 @@ export declare class QueryParser implements IQueryParser {
|
|
|
15
15
|
* Parse a query string into a QueryKit AST
|
|
16
16
|
*/
|
|
17
17
|
parse(query: string): QueryExpression;
|
|
18
|
+
/**
|
|
19
|
+
* Pre-process a query string to convert non-standard syntax to Liqe-compatible syntax.
|
|
20
|
+
* Supports:
|
|
21
|
+
* - `field:[val1, val2, val3]` → `(field:val1 OR field:val2 OR field:val3)`
|
|
22
|
+
*
|
|
23
|
+
* This keeps the syntax consistent with the `key:value` pattern used throughout QueryKit:
|
|
24
|
+
* - `priority:>2` (comparison)
|
|
25
|
+
* - `status:active` (equality)
|
|
26
|
+
* - `status:[todo, doing, done]` (IN / multiple values)
|
|
27
|
+
*/
|
|
28
|
+
private preprocessQuery;
|
|
29
|
+
/**
|
|
30
|
+
* Convert a field and comma-separated values to an OR expression string
|
|
31
|
+
*/
|
|
32
|
+
private convertToOrExpression;
|
|
33
|
+
/**
|
|
34
|
+
* Parse a comma-separated string into values, respecting quoted strings.
|
|
35
|
+
* Commas inside quoted strings are preserved as part of the value.
|
|
36
|
+
*
|
|
37
|
+
* Examples:
|
|
38
|
+
* - `a, b, c` → ['a', 'b', 'c']
|
|
39
|
+
* - `"John, Jr.", Jane` → ['"John, Jr."', 'Jane']
|
|
40
|
+
* - `'hello, world', test` → ["'hello, world'", 'test']
|
|
41
|
+
*/
|
|
42
|
+
private parseCommaSeparatedValues;
|
|
43
|
+
/**
|
|
44
|
+
* Format a field:value pair, quoting the value if necessary
|
|
45
|
+
*/
|
|
46
|
+
private formatFieldValue;
|
|
18
47
|
/**
|
|
19
48
|
* Validate a query string
|
|
20
49
|
*/
|
|
@@ -35,6 +64,11 @@ export declare class QueryParser implements IQueryParser {
|
|
|
35
64
|
* Create a comparison expression
|
|
36
65
|
*/
|
|
37
66
|
private createComparisonExpression;
|
|
67
|
+
/**
|
|
68
|
+
* Convert a Liqe RangeExpression to a QueryKit logical AND expression
|
|
69
|
+
* E.g., `field:[2 TO 5]` becomes `(field >= 2 AND field <= 5)`
|
|
70
|
+
*/
|
|
71
|
+
private convertRangeExpression;
|
|
38
72
|
/**
|
|
39
73
|
* Convert a Liqe operator to a QueryKit operator
|
|
40
74
|
*/
|
package/dist/parser/parser.js
CHANGED
|
@@ -27,19 +27,142 @@ class QueryParser {
|
|
|
27
27
|
*/
|
|
28
28
|
parse(query) {
|
|
29
29
|
try {
|
|
30
|
-
|
|
30
|
+
// Pre-process the query to handle IN operator syntax
|
|
31
|
+
const preprocessedQuery = this.preprocessQuery(query);
|
|
32
|
+
const liqeAst = (0, liqe_1.parse)(preprocessedQuery);
|
|
31
33
|
return this.convertLiqeAst(liqeAst);
|
|
32
34
|
}
|
|
33
35
|
catch (error) {
|
|
34
36
|
throw new QueryParseError(`Failed to parse query: ${error instanceof Error ? error.message : String(error)}`);
|
|
35
37
|
}
|
|
36
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Pre-process a query string to convert non-standard syntax to Liqe-compatible syntax.
|
|
41
|
+
* Supports:
|
|
42
|
+
* - `field:[val1, val2, val3]` → `(field:val1 OR field:val2 OR field:val3)`
|
|
43
|
+
*
|
|
44
|
+
* This keeps the syntax consistent with the `key:value` pattern used throughout QueryKit:
|
|
45
|
+
* - `priority:>2` (comparison)
|
|
46
|
+
* - `status:active` (equality)
|
|
47
|
+
* - `status:[todo, doing, done]` (IN / multiple values)
|
|
48
|
+
*/
|
|
49
|
+
preprocessQuery(query) {
|
|
50
|
+
let result = query;
|
|
51
|
+
// Handle `field:[val1, val2, ...]` syntax (array-like, not range)
|
|
52
|
+
// Pattern: fieldName:[value1, value2, ...]
|
|
53
|
+
// We distinguish from range by checking for commas without "TO"
|
|
54
|
+
const bracketArrayPattern = /(\w+):\[([^\]]+)\]/g;
|
|
55
|
+
result = result.replace(bracketArrayPattern, (fullMatch, field, values) => {
|
|
56
|
+
// Check if this looks like a range expression (contains " TO ")
|
|
57
|
+
if (/\s+TO\s+/i.test(values)) {
|
|
58
|
+
// This is a range expression, keep as-is
|
|
59
|
+
return fullMatch;
|
|
60
|
+
}
|
|
61
|
+
// This is an array-like expression, convert to OR
|
|
62
|
+
return this.convertToOrExpression(field, values);
|
|
63
|
+
});
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Convert a field and comma-separated values to an OR expression string
|
|
68
|
+
*/
|
|
69
|
+
convertToOrExpression(field, valuesStr) {
|
|
70
|
+
// Parse values respecting quoted strings (commas inside quotes are preserved)
|
|
71
|
+
const values = this.parseCommaSeparatedValues(valuesStr);
|
|
72
|
+
if (values.length === 0) {
|
|
73
|
+
return `${field}:""`;
|
|
74
|
+
}
|
|
75
|
+
if (values.length === 1) {
|
|
76
|
+
return this.formatFieldValue(field, values[0]);
|
|
77
|
+
}
|
|
78
|
+
// Build OR expression: (field:val1 OR field:val2 OR ...)
|
|
79
|
+
const orClauses = values.map((v) => this.formatFieldValue(field, v));
|
|
80
|
+
return `(${orClauses.join(' OR ')})`;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Parse a comma-separated string into values, respecting quoted strings.
|
|
84
|
+
* Commas inside quoted strings are preserved as part of the value.
|
|
85
|
+
*
|
|
86
|
+
* Examples:
|
|
87
|
+
* - `a, b, c` → ['a', 'b', 'c']
|
|
88
|
+
* - `"John, Jr.", Jane` → ['"John, Jr."', 'Jane']
|
|
89
|
+
* - `'hello, world', test` → ["'hello, world'", 'test']
|
|
90
|
+
*/
|
|
91
|
+
parseCommaSeparatedValues(input) {
|
|
92
|
+
const values = [];
|
|
93
|
+
let current = '';
|
|
94
|
+
let inDoubleQuotes = false;
|
|
95
|
+
let inSingleQuotes = false;
|
|
96
|
+
let i = 0;
|
|
97
|
+
while (i < input.length) {
|
|
98
|
+
const char = input[i];
|
|
99
|
+
const nextChar = input[i + 1];
|
|
100
|
+
// Handle escape sequences inside quotes
|
|
101
|
+
if ((inDoubleQuotes || inSingleQuotes) && char === '\\' && nextChar) {
|
|
102
|
+
// Include both the backslash and the escaped character
|
|
103
|
+
current += char + nextChar;
|
|
104
|
+
i += 2;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
// Toggle double quote state
|
|
108
|
+
if (char === '"' && !inSingleQuotes) {
|
|
109
|
+
inDoubleQuotes = !inDoubleQuotes;
|
|
110
|
+
current += char;
|
|
111
|
+
i++;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
// Toggle single quote state
|
|
115
|
+
if (char === "'" && !inDoubleQuotes) {
|
|
116
|
+
inSingleQuotes = !inSingleQuotes;
|
|
117
|
+
current += char;
|
|
118
|
+
i++;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
// Handle comma as separator (only when not inside quotes)
|
|
122
|
+
if (char === ',' && !inDoubleQuotes && !inSingleQuotes) {
|
|
123
|
+
const trimmed = current.trim();
|
|
124
|
+
if (trimmed.length > 0) {
|
|
125
|
+
values.push(trimmed);
|
|
126
|
+
}
|
|
127
|
+
current = '';
|
|
128
|
+
i++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
// Regular character
|
|
132
|
+
current += char;
|
|
133
|
+
i++;
|
|
134
|
+
}
|
|
135
|
+
// Don't forget the last value
|
|
136
|
+
const trimmed = current.trim();
|
|
137
|
+
if (trimmed.length > 0) {
|
|
138
|
+
values.push(trimmed);
|
|
139
|
+
}
|
|
140
|
+
return values;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Format a field:value pair, quoting the value if necessary
|
|
144
|
+
*/
|
|
145
|
+
formatFieldValue(field, value) {
|
|
146
|
+
// If the value is already quoted, use it as-is
|
|
147
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
148
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
149
|
+
return `${field}:${value}`;
|
|
150
|
+
}
|
|
151
|
+
// If value contains spaces or special characters, quote it
|
|
152
|
+
if (/\s|[():]/.test(value)) {
|
|
153
|
+
// Escape quotes within the value
|
|
154
|
+
const escapedValue = value.replace(/"/g, '\\"');
|
|
155
|
+
return `${field}:"${escapedValue}"`;
|
|
156
|
+
}
|
|
157
|
+
return `${field}:${value}`;
|
|
158
|
+
}
|
|
37
159
|
/**
|
|
38
160
|
* Validate a query string
|
|
39
161
|
*/
|
|
40
162
|
validate(query) {
|
|
41
163
|
try {
|
|
42
|
-
const
|
|
164
|
+
const preprocessedQuery = this.preprocessQuery(query);
|
|
165
|
+
const ast = (0, liqe_1.parse)(preprocessedQuery);
|
|
43
166
|
this.convertLiqeAst(ast);
|
|
44
167
|
return true;
|
|
45
168
|
}
|
|
@@ -72,10 +195,16 @@ class QueryParser {
|
|
|
72
195
|
throw new QueryParseError('Invalid field or expression in Tag node');
|
|
73
196
|
}
|
|
74
197
|
const fieldName = this.normalizeFieldName(field.name);
|
|
198
|
+
// Handle RangeExpression (e.g., field:[min TO max])
|
|
199
|
+
if (expression.type === 'RangeExpression') {
|
|
200
|
+
return this.convertRangeExpression(fieldName, expression);
|
|
201
|
+
}
|
|
75
202
|
const operator = this.convertLiqeOperator(tagNode.operator.operator);
|
|
76
203
|
const value = this.convertLiqeValue(expression.value);
|
|
77
204
|
// Check for wildcard patterns in string values
|
|
78
|
-
if (operator === '==' &&
|
|
205
|
+
if (operator === '==' &&
|
|
206
|
+
typeof value === 'string' &&
|
|
207
|
+
(value.includes('*') || value.includes('?'))) {
|
|
79
208
|
return this.createComparisonExpression(fieldName, 'LIKE', value);
|
|
80
209
|
}
|
|
81
210
|
return this.createComparisonExpression(fieldName, operator, value);
|
|
@@ -133,6 +262,31 @@ class QueryParser {
|
|
|
133
262
|
value
|
|
134
263
|
};
|
|
135
264
|
}
|
|
265
|
+
/**
|
|
266
|
+
* Convert a Liqe RangeExpression to a QueryKit logical AND expression
|
|
267
|
+
* E.g., `field:[2 TO 5]` becomes `(field >= 2 AND field <= 5)`
|
|
268
|
+
*/
|
|
269
|
+
convertRangeExpression(fieldName, expression) {
|
|
270
|
+
const range = expression.range;
|
|
271
|
+
// Handle null/undefined range values
|
|
272
|
+
if (range === null || range === undefined) {
|
|
273
|
+
throw new QueryParseError('Invalid range expression: missing range data');
|
|
274
|
+
}
|
|
275
|
+
const { min, max, minInclusive, maxInclusive } = range;
|
|
276
|
+
// Determine the operators based on inclusivity
|
|
277
|
+
const minOperator = minInclusive ? '>=' : '>';
|
|
278
|
+
const maxOperator = maxInclusive ? '<=' : '<';
|
|
279
|
+
// Create comparison expressions for min and max
|
|
280
|
+
const minComparison = this.createComparisonExpression(fieldName, minOperator, min);
|
|
281
|
+
const maxComparison = this.createComparisonExpression(fieldName, maxOperator, max);
|
|
282
|
+
// Combine with AND
|
|
283
|
+
return {
|
|
284
|
+
type: 'logical',
|
|
285
|
+
operator: 'AND',
|
|
286
|
+
left: minComparison,
|
|
287
|
+
right: maxComparison
|
|
288
|
+
};
|
|
289
|
+
}
|
|
136
290
|
/**
|
|
137
291
|
* Convert a Liqe operator to a QueryKit operator
|
|
138
292
|
*/
|
|
@@ -142,7 +296,9 @@ class QueryParser {
|
|
|
142
296
|
return '==';
|
|
143
297
|
}
|
|
144
298
|
// Check if the operator is prefixed with a colon
|
|
145
|
-
const actualOperator = operator.startsWith(':')
|
|
299
|
+
const actualOperator = operator.startsWith(':')
|
|
300
|
+
? operator.substring(1)
|
|
301
|
+
: operator;
|
|
146
302
|
// Map Liqe operators to QueryKit operators
|
|
147
303
|
const operatorMap = {
|
|
148
304
|
'=': '==',
|
|
@@ -151,7 +307,7 @@ class QueryParser {
|
|
|
151
307
|
'>=': '>=',
|
|
152
308
|
'<': '<',
|
|
153
309
|
'<=': '<=',
|
|
154
|
-
|
|
310
|
+
in: 'IN',
|
|
155
311
|
'not in': 'NOT IN'
|
|
156
312
|
};
|
|
157
313
|
const queryKitOperator = operatorMap[actualOperator.toLowerCase()];
|
|
@@ -169,7 +325,9 @@ class QueryParser {
|
|
|
169
325
|
if (value === null) {
|
|
170
326
|
return null;
|
|
171
327
|
}
|
|
172
|
-
if (typeof value === 'string' ||
|
|
328
|
+
if (typeof value === 'string' ||
|
|
329
|
+
typeof value === 'number' ||
|
|
330
|
+
typeof value === 'boolean') {
|
|
173
331
|
return value;
|
|
174
332
|
}
|
|
175
333
|
if (Array.isArray(value)) {
|
package/package.json
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { QueryParser, QueryParseError } from './parser';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
IComparisonExpression,
|
|
4
|
+
ILogicalExpression,
|
|
5
|
+
QueryExpression
|
|
6
|
+
} from './types';
|
|
3
7
|
|
|
4
8
|
// Replace the type definition with this approach
|
|
5
9
|
type QueryParserPrivate = {
|
|
@@ -863,6 +867,210 @@ describe('QueryParser', () => {
|
|
|
863
867
|
|
|
864
868
|
expect(parser.parse(query)).toEqual(expected);
|
|
865
869
|
});
|
|
870
|
+
|
|
871
|
+
// IN operator syntax tests using consistent key:[values] pattern
|
|
872
|
+
describe('IN operator syntax (key:[values])', () => {
|
|
873
|
+
it('should parse "field:[val1, val2, val3]" bracket array syntax', () => {
|
|
874
|
+
const query = 'status:[todo, doing, done]';
|
|
875
|
+
const expected: QueryExpression = {
|
|
876
|
+
type: 'logical',
|
|
877
|
+
operator: 'OR',
|
|
878
|
+
left: {
|
|
879
|
+
type: 'logical',
|
|
880
|
+
operator: 'OR',
|
|
881
|
+
left: {
|
|
882
|
+
type: 'comparison',
|
|
883
|
+
field: 'status',
|
|
884
|
+
operator: '==',
|
|
885
|
+
value: 'todo'
|
|
886
|
+
},
|
|
887
|
+
right: {
|
|
888
|
+
type: 'comparison',
|
|
889
|
+
field: 'status',
|
|
890
|
+
operator: '==',
|
|
891
|
+
value: 'doing'
|
|
892
|
+
}
|
|
893
|
+
},
|
|
894
|
+
right: {
|
|
895
|
+
type: 'comparison',
|
|
896
|
+
field: 'status',
|
|
897
|
+
operator: '==',
|
|
898
|
+
value: 'done'
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
expect(parser.parse(query)).toEqual(expected);
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it('should parse numeric values in bracket syntax', () => {
|
|
906
|
+
const query = 'id:[2, 3]';
|
|
907
|
+
const expected: QueryExpression = {
|
|
908
|
+
type: 'logical',
|
|
909
|
+
operator: 'OR',
|
|
910
|
+
left: {
|
|
911
|
+
type: 'comparison',
|
|
912
|
+
field: 'id',
|
|
913
|
+
operator: '==',
|
|
914
|
+
value: 2
|
|
915
|
+
},
|
|
916
|
+
right: {
|
|
917
|
+
type: 'comparison',
|
|
918
|
+
field: 'id',
|
|
919
|
+
operator: '==',
|
|
920
|
+
value: 3
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
|
|
924
|
+
expect(parser.parse(query)).toEqual(expected);
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
it('should parse single value in bracket syntax', () => {
|
|
928
|
+
const query = 'status:[active]';
|
|
929
|
+
const expected: QueryExpression = {
|
|
930
|
+
type: 'comparison',
|
|
931
|
+
field: 'status',
|
|
932
|
+
operator: '==',
|
|
933
|
+
value: 'active'
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
expect(parser.parse(query)).toEqual(expected);
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
it('should preserve range syntax "field:[min TO max]"', () => {
|
|
940
|
+
const query = 'id:[2 TO 5]';
|
|
941
|
+
const expected: QueryExpression = {
|
|
942
|
+
type: 'logical',
|
|
943
|
+
operator: 'AND',
|
|
944
|
+
left: {
|
|
945
|
+
type: 'comparison',
|
|
946
|
+
field: 'id',
|
|
947
|
+
operator: '>=',
|
|
948
|
+
value: 2
|
|
949
|
+
},
|
|
950
|
+
right: {
|
|
951
|
+
type: 'comparison',
|
|
952
|
+
field: 'id',
|
|
953
|
+
operator: '<=',
|
|
954
|
+
value: 5
|
|
955
|
+
}
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
expect(parser.parse(query)).toEqual(expected);
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
it('should parse exclusive range syntax "field:{min TO max}"', () => {
|
|
962
|
+
const query = 'id:{2 TO 5}';
|
|
963
|
+
const expected: QueryExpression = {
|
|
964
|
+
type: 'logical',
|
|
965
|
+
operator: 'AND',
|
|
966
|
+
left: {
|
|
967
|
+
type: 'comparison',
|
|
968
|
+
field: 'id',
|
|
969
|
+
operator: '>',
|
|
970
|
+
value: 2
|
|
971
|
+
},
|
|
972
|
+
right: {
|
|
973
|
+
type: 'comparison',
|
|
974
|
+
field: 'id',
|
|
975
|
+
operator: '<',
|
|
976
|
+
value: 5
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
|
|
980
|
+
expect(parser.parse(query)).toEqual(expected);
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
it('should parse bracket syntax combined with other expressions', () => {
|
|
984
|
+
const query = 'status:[todo, doing] AND priority:>2';
|
|
985
|
+
const parsed = parser.parse(query);
|
|
986
|
+
|
|
987
|
+
// Verify it's a logical AND at the top level
|
|
988
|
+
expect(parsed.type).toBe('logical');
|
|
989
|
+
expect((parsed as ILogicalExpression).operator).toBe('AND');
|
|
990
|
+
|
|
991
|
+
// Left side should be OR of status values
|
|
992
|
+
const left = (parsed as ILogicalExpression).left as ILogicalExpression;
|
|
993
|
+
expect(left.type).toBe('logical');
|
|
994
|
+
expect(left.operator).toBe('OR');
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
it('should handle values with spaces using quotes', () => {
|
|
998
|
+
const query = 'name:[John, "Jane Doe"]';
|
|
999
|
+
const parsed = parser.parse(query);
|
|
1000
|
+
|
|
1001
|
+
expect(parsed.type).toBe('logical');
|
|
1002
|
+
expect((parsed as ILogicalExpression).operator).toBe('OR');
|
|
1003
|
+
|
|
1004
|
+
const right = (parsed as ILogicalExpression)
|
|
1005
|
+
.right as IComparisonExpression;
|
|
1006
|
+
expect(right.value).toBe('Jane Doe');
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
it('should validate bracket syntax queries', () => {
|
|
1010
|
+
expect(parser.validate('status:[todo, doing, done]')).toBe(true);
|
|
1011
|
+
expect(parser.validate('id:[1, 2, 3]')).toBe(true);
|
|
1012
|
+
expect(parser.validate('id:[1 TO 10]')).toBe(true);
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
it('should handle mixed types in bracket syntax', () => {
|
|
1016
|
+
const query = 'priority:[1, 2, 3]';
|
|
1017
|
+
const parsed = parser.parse(query);
|
|
1018
|
+
|
|
1019
|
+
expect(parsed.type).toBe('logical');
|
|
1020
|
+
// All values should be numbers
|
|
1021
|
+
const getLeftmost = (expr: QueryExpression): IComparisonExpression => {
|
|
1022
|
+
if (expr.type === 'comparison') return expr;
|
|
1023
|
+
return getLeftmost((expr as ILogicalExpression).left);
|
|
1024
|
+
};
|
|
1025
|
+
expect(typeof getLeftmost(parsed).value).toBe('number');
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
it('should handle quoted values with commas inside', () => {
|
|
1029
|
+
const query = 'name:["John, Jr.", "Jane"]';
|
|
1030
|
+
const parsed = parser.parse(query);
|
|
1031
|
+
|
|
1032
|
+
expect(parsed.type).toBe('logical');
|
|
1033
|
+
expect((parsed as ILogicalExpression).operator).toBe('OR');
|
|
1034
|
+
|
|
1035
|
+
const left = (parsed as ILogicalExpression)
|
|
1036
|
+
.left as IComparisonExpression;
|
|
1037
|
+
const right = (parsed as ILogicalExpression)
|
|
1038
|
+
.right as IComparisonExpression;
|
|
1039
|
+
|
|
1040
|
+
expect(left.value).toBe('John, Jr.');
|
|
1041
|
+
expect(right.value).toBe('Jane');
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
it('should handle single-quoted values with commas inside', () => {
|
|
1045
|
+
const query = "name:['Hello, World', 'test']";
|
|
1046
|
+
const parsed = parser.parse(query);
|
|
1047
|
+
|
|
1048
|
+
expect(parsed.type).toBe('logical');
|
|
1049
|
+
const left = (parsed as ILogicalExpression)
|
|
1050
|
+
.left as IComparisonExpression;
|
|
1051
|
+
expect(left.value).toBe('Hello, World');
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
it('should handle mixed quoted and unquoted values with commas', () => {
|
|
1055
|
+
const query = 'tags:["a,b,c", simple, "x,y"]';
|
|
1056
|
+
const parsed = parser.parse(query);
|
|
1057
|
+
|
|
1058
|
+
expect(parsed.type).toBe('logical');
|
|
1059
|
+
// Should have 3 values: "a,b,c", "simple", "x,y"
|
|
1060
|
+
const getValues = (expr: QueryExpression): string[] => {
|
|
1061
|
+
if (expr.type === 'comparison') {
|
|
1062
|
+
return [String(expr.value)];
|
|
1063
|
+
}
|
|
1064
|
+
const logical = expr as ILogicalExpression;
|
|
1065
|
+
return [...getValues(logical.left), ...getValues(logical.right!)];
|
|
1066
|
+
};
|
|
1067
|
+
|
|
1068
|
+
const values = getValues(parsed);
|
|
1069
|
+
expect(values).toContain('a,b,c');
|
|
1070
|
+
expect(values).toContain('simple');
|
|
1071
|
+
expect(values).toContain('x,y');
|
|
1072
|
+
});
|
|
1073
|
+
});
|
|
866
1074
|
});
|
|
867
1075
|
|
|
868
1076
|
describe('validate', () => {
|
package/src/parser/parser.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
LiqeQuery,
|
|
8
8
|
LogicalExpressionToken,
|
|
9
9
|
ParenthesizedExpressionToken,
|
|
10
|
+
RangeExpressionToken,
|
|
10
11
|
TagToken,
|
|
11
12
|
UnaryOperatorToken
|
|
12
13
|
} from 'liqe';
|
|
@@ -48,7 +49,9 @@ export class QueryParser implements IQueryParser {
|
|
|
48
49
|
*/
|
|
49
50
|
public parse(query: string): QueryExpression {
|
|
50
51
|
try {
|
|
51
|
-
|
|
52
|
+
// Pre-process the query to handle IN operator syntax
|
|
53
|
+
const preprocessedQuery = this.preprocessQuery(query);
|
|
54
|
+
const liqeAst = liqeParse(preprocessedQuery);
|
|
52
55
|
return this.convertLiqeAst(liqeAst);
|
|
53
56
|
} catch (error) {
|
|
54
57
|
throw new QueryParseError(
|
|
@@ -57,12 +60,155 @@ export class QueryParser implements IQueryParser {
|
|
|
57
60
|
}
|
|
58
61
|
}
|
|
59
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Pre-process a query string to convert non-standard syntax to Liqe-compatible syntax.
|
|
65
|
+
* Supports:
|
|
66
|
+
* - `field:[val1, val2, val3]` → `(field:val1 OR field:val2 OR field:val3)`
|
|
67
|
+
*
|
|
68
|
+
* This keeps the syntax consistent with the `key:value` pattern used throughout QueryKit:
|
|
69
|
+
* - `priority:>2` (comparison)
|
|
70
|
+
* - `status:active` (equality)
|
|
71
|
+
* - `status:[todo, doing, done]` (IN / multiple values)
|
|
72
|
+
*/
|
|
73
|
+
private preprocessQuery(query: string): string {
|
|
74
|
+
let result = query;
|
|
75
|
+
|
|
76
|
+
// Handle `field:[val1, val2, ...]` syntax (array-like, not range)
|
|
77
|
+
// Pattern: fieldName:[value1, value2, ...]
|
|
78
|
+
// We distinguish from range by checking for commas without "TO"
|
|
79
|
+
const bracketArrayPattern = /(\w+):\[([^\]]+)\]/g;
|
|
80
|
+
result = result.replace(bracketArrayPattern, (fullMatch, field, values) => {
|
|
81
|
+
// Check if this looks like a range expression (contains " TO ")
|
|
82
|
+
if (/\s+TO\s+/i.test(values)) {
|
|
83
|
+
// This is a range expression, keep as-is
|
|
84
|
+
return fullMatch;
|
|
85
|
+
}
|
|
86
|
+
// This is an array-like expression, convert to OR
|
|
87
|
+
return this.convertToOrExpression(field, values);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Convert a field and comma-separated values to an OR expression string
|
|
95
|
+
*/
|
|
96
|
+
private convertToOrExpression(field: string, valuesStr: string): string {
|
|
97
|
+
// Parse values respecting quoted strings (commas inside quotes are preserved)
|
|
98
|
+
const values = this.parseCommaSeparatedValues(valuesStr);
|
|
99
|
+
|
|
100
|
+
if (values.length === 0) {
|
|
101
|
+
return `${field}:""`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (values.length === 1) {
|
|
105
|
+
return this.formatFieldValue(field, values[0]);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Build OR expression: (field:val1 OR field:val2 OR ...)
|
|
109
|
+
const orClauses = values.map((v: string) =>
|
|
110
|
+
this.formatFieldValue(field, v)
|
|
111
|
+
);
|
|
112
|
+
return `(${orClauses.join(' OR ')})`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Parse a comma-separated string into values, respecting quoted strings.
|
|
117
|
+
* Commas inside quoted strings are preserved as part of the value.
|
|
118
|
+
*
|
|
119
|
+
* Examples:
|
|
120
|
+
* - `a, b, c` → ['a', 'b', 'c']
|
|
121
|
+
* - `"John, Jr.", Jane` → ['"John, Jr."', 'Jane']
|
|
122
|
+
* - `'hello, world', test` → ["'hello, world'", 'test']
|
|
123
|
+
*/
|
|
124
|
+
private parseCommaSeparatedValues(input: string): string[] {
|
|
125
|
+
const values: string[] = [];
|
|
126
|
+
let current = '';
|
|
127
|
+
let inDoubleQuotes = false;
|
|
128
|
+
let inSingleQuotes = false;
|
|
129
|
+
let i = 0;
|
|
130
|
+
|
|
131
|
+
while (i < input.length) {
|
|
132
|
+
const char = input[i];
|
|
133
|
+
const nextChar = input[i + 1];
|
|
134
|
+
|
|
135
|
+
// Handle escape sequences inside quotes
|
|
136
|
+
if ((inDoubleQuotes || inSingleQuotes) && char === '\\' && nextChar) {
|
|
137
|
+
// Include both the backslash and the escaped character
|
|
138
|
+
current += char + nextChar;
|
|
139
|
+
i += 2;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Toggle double quote state
|
|
144
|
+
if (char === '"' && !inSingleQuotes) {
|
|
145
|
+
inDoubleQuotes = !inDoubleQuotes;
|
|
146
|
+
current += char;
|
|
147
|
+
i++;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Toggle single quote state
|
|
152
|
+
if (char === "'" && !inDoubleQuotes) {
|
|
153
|
+
inSingleQuotes = !inSingleQuotes;
|
|
154
|
+
current += char;
|
|
155
|
+
i++;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Handle comma as separator (only when not inside quotes)
|
|
160
|
+
if (char === ',' && !inDoubleQuotes && !inSingleQuotes) {
|
|
161
|
+
const trimmed = current.trim();
|
|
162
|
+
if (trimmed.length > 0) {
|
|
163
|
+
values.push(trimmed);
|
|
164
|
+
}
|
|
165
|
+
current = '';
|
|
166
|
+
i++;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Regular character
|
|
171
|
+
current += char;
|
|
172
|
+
i++;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Don't forget the last value
|
|
176
|
+
const trimmed = current.trim();
|
|
177
|
+
if (trimmed.length > 0) {
|
|
178
|
+
values.push(trimmed);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return values;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Format a field:value pair, quoting the value if necessary
|
|
186
|
+
*/
|
|
187
|
+
private formatFieldValue(field: string, value: string): string {
|
|
188
|
+
// If the value is already quoted, use it as-is
|
|
189
|
+
if (
|
|
190
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
191
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
192
|
+
) {
|
|
193
|
+
return `${field}:${value}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// If value contains spaces or special characters, quote it
|
|
197
|
+
if (/\s|[():]/.test(value)) {
|
|
198
|
+
// Escape quotes within the value
|
|
199
|
+
const escapedValue = value.replace(/"/g, '\\"');
|
|
200
|
+
return `${field}:"${escapedValue}"`;
|
|
201
|
+
}
|
|
202
|
+
return `${field}:${value}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
60
205
|
/**
|
|
61
206
|
* Validate a query string
|
|
62
207
|
*/
|
|
63
208
|
public validate(query: string): boolean {
|
|
64
209
|
try {
|
|
65
|
-
const
|
|
210
|
+
const preprocessedQuery = this.preprocessQuery(query);
|
|
211
|
+
const ast = liqeParse(preprocessedQuery);
|
|
66
212
|
this.convertLiqeAst(ast);
|
|
67
213
|
return true;
|
|
68
214
|
} catch {
|
|
@@ -81,7 +227,11 @@ export class QueryParser implements IQueryParser {
|
|
|
81
227
|
switch (node.type) {
|
|
82
228
|
case 'LogicalExpression': {
|
|
83
229
|
const logicalNode = node as LogicalExpressionToken;
|
|
84
|
-
const operator = (
|
|
230
|
+
const operator = (
|
|
231
|
+
logicalNode.operator as
|
|
232
|
+
| BooleanOperatorToken
|
|
233
|
+
| ImplicitBooleanOperatorToken
|
|
234
|
+
).operator;
|
|
85
235
|
return this.createLogicalExpression(
|
|
86
236
|
this.convertLogicalOperator(operator),
|
|
87
237
|
logicalNode.left,
|
|
@@ -97,30 +247,37 @@ export class QueryParser implements IQueryParser {
|
|
|
97
247
|
case 'Tag': {
|
|
98
248
|
const tagNode = node as TagToken;
|
|
99
249
|
const field = tagNode.field as FieldToken;
|
|
100
|
-
const expression = tagNode.expression as ExpressionToken & {
|
|
101
|
-
|
|
250
|
+
const expression = tagNode.expression as ExpressionToken & {
|
|
251
|
+
value: QueryValue;
|
|
252
|
+
};
|
|
253
|
+
|
|
102
254
|
if (!field || !expression) {
|
|
103
255
|
throw new QueryParseError('Invalid field or expression in Tag node');
|
|
104
256
|
}
|
|
105
257
|
|
|
106
258
|
const fieldName = this.normalizeFieldName(field.name);
|
|
259
|
+
|
|
260
|
+
// Handle RangeExpression (e.g., field:[min TO max])
|
|
261
|
+
if (expression.type === 'RangeExpression') {
|
|
262
|
+
return this.convertRangeExpression(
|
|
263
|
+
fieldName,
|
|
264
|
+
expression as unknown as RangeExpressionToken
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
107
268
|
const operator = this.convertLiqeOperator(tagNode.operator.operator);
|
|
108
269
|
const value = this.convertLiqeValue(expression.value);
|
|
109
270
|
|
|
110
271
|
// Check for wildcard patterns in string values
|
|
111
|
-
if (
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
);
|
|
272
|
+
if (
|
|
273
|
+
operator === '==' &&
|
|
274
|
+
typeof value === 'string' &&
|
|
275
|
+
(value.includes('*') || value.includes('?'))
|
|
276
|
+
) {
|
|
277
|
+
return this.createComparisonExpression(fieldName, 'LIKE', value);
|
|
117
278
|
}
|
|
118
279
|
|
|
119
|
-
return this.createComparisonExpression(
|
|
120
|
-
fieldName,
|
|
121
|
-
operator,
|
|
122
|
-
value
|
|
123
|
-
);
|
|
280
|
+
return this.createComparisonExpression(fieldName, operator, value);
|
|
124
281
|
}
|
|
125
282
|
|
|
126
283
|
case 'EmptyExpression':
|
|
@@ -138,7 +295,9 @@ export class QueryParser implements IQueryParser {
|
|
|
138
295
|
}
|
|
139
296
|
|
|
140
297
|
default:
|
|
141
|
-
throw new QueryParseError(
|
|
298
|
+
throw new QueryParseError(
|
|
299
|
+
`Unsupported node type: ${(node as { type: string }).type}`
|
|
300
|
+
);
|
|
142
301
|
}
|
|
143
302
|
}
|
|
144
303
|
|
|
@@ -190,6 +349,48 @@ export class QueryParser implements IQueryParser {
|
|
|
190
349
|
};
|
|
191
350
|
}
|
|
192
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Convert a Liqe RangeExpression to a QueryKit logical AND expression
|
|
354
|
+
* E.g., `field:[2 TO 5]` becomes `(field >= 2 AND field <= 5)`
|
|
355
|
+
*/
|
|
356
|
+
private convertRangeExpression(
|
|
357
|
+
fieldName: string,
|
|
358
|
+
expression: RangeExpressionToken
|
|
359
|
+
): QueryExpression {
|
|
360
|
+
const range = expression.range;
|
|
361
|
+
|
|
362
|
+
// Handle null/undefined range values
|
|
363
|
+
if (range === null || range === undefined) {
|
|
364
|
+
throw new QueryParseError('Invalid range expression: missing range data');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const { min, max, minInclusive, maxInclusive } = range;
|
|
368
|
+
|
|
369
|
+
// Determine the operators based on inclusivity
|
|
370
|
+
const minOperator: ComparisonOperator = minInclusive ? '>=' : '>';
|
|
371
|
+
const maxOperator: ComparisonOperator = maxInclusive ? '<=' : '<';
|
|
372
|
+
|
|
373
|
+
// Create comparison expressions for min and max
|
|
374
|
+
const minComparison = this.createComparisonExpression(
|
|
375
|
+
fieldName,
|
|
376
|
+
minOperator,
|
|
377
|
+
min
|
|
378
|
+
);
|
|
379
|
+
const maxComparison = this.createComparisonExpression(
|
|
380
|
+
fieldName,
|
|
381
|
+
maxOperator,
|
|
382
|
+
max
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
// Combine with AND
|
|
386
|
+
return {
|
|
387
|
+
type: 'logical',
|
|
388
|
+
operator: 'AND',
|
|
389
|
+
left: minComparison,
|
|
390
|
+
right: maxComparison
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
193
394
|
/**
|
|
194
395
|
* Convert a Liqe operator to a QueryKit operator
|
|
195
396
|
*/
|
|
@@ -200,7 +401,9 @@ export class QueryParser implements IQueryParser {
|
|
|
200
401
|
}
|
|
201
402
|
|
|
202
403
|
// Check if the operator is prefixed with a colon
|
|
203
|
-
const actualOperator = operator.startsWith(':')
|
|
404
|
+
const actualOperator = operator.startsWith(':')
|
|
405
|
+
? operator.substring(1)
|
|
406
|
+
: operator;
|
|
204
407
|
|
|
205
408
|
// Map Liqe operators to QueryKit operators
|
|
206
409
|
const operatorMap: Record<string, ComparisonOperator> = {
|
|
@@ -210,7 +413,7 @@ export class QueryParser implements IQueryParser {
|
|
|
210
413
|
'>=': '>=',
|
|
211
414
|
'<': '<',
|
|
212
415
|
'<=': '<=',
|
|
213
|
-
|
|
416
|
+
in: 'IN',
|
|
214
417
|
'not in': 'NOT IN'
|
|
215
418
|
};
|
|
216
419
|
|
|
@@ -231,11 +434,15 @@ export class QueryParser implements IQueryParser {
|
|
|
231
434
|
if (value === null) {
|
|
232
435
|
return null;
|
|
233
436
|
}
|
|
234
|
-
|
|
235
|
-
if (
|
|
437
|
+
|
|
438
|
+
if (
|
|
439
|
+
typeof value === 'string' ||
|
|
440
|
+
typeof value === 'number' ||
|
|
441
|
+
typeof value === 'boolean'
|
|
442
|
+
) {
|
|
236
443
|
return value as QueryValue;
|
|
237
444
|
}
|
|
238
|
-
|
|
445
|
+
|
|
239
446
|
if (Array.isArray(value)) {
|
|
240
447
|
// Security fix: Recursively validate array elements
|
|
241
448
|
const validatedArray = value.map(item => {
|
|
@@ -246,10 +453,12 @@ export class QueryParser implements IQueryParser {
|
|
|
246
453
|
});
|
|
247
454
|
return validatedArray as QueryValue;
|
|
248
455
|
}
|
|
249
|
-
|
|
456
|
+
|
|
250
457
|
// Security fix: Reject all object types to prevent NoSQL injection
|
|
251
458
|
if (typeof value === 'object') {
|
|
252
|
-
throw new QueryParseError(
|
|
459
|
+
throw new QueryParseError(
|
|
460
|
+
'Object values are not supported for security reasons'
|
|
461
|
+
);
|
|
253
462
|
}
|
|
254
463
|
|
|
255
464
|
throw new QueryParseError(`Unsupported value type: ${typeof value}`);
|
|
@@ -265,4 +474,4 @@ export class QueryParser implements IQueryParser {
|
|
|
265
474
|
|
|
266
475
|
return this.options.fieldMappings[normalizedField] ?? normalizedField;
|
|
267
476
|
}
|
|
268
|
-
}
|
|
477
|
+
}
|