@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.
@@ -0,0 +1,325 @@
1
+ import { QueryParser } from './parser';
2
+
3
+ describe('QueryParser.parseWithContext', () => {
4
+ let parser: QueryParser;
5
+
6
+ beforeEach(() => {
7
+ parser = new QueryParser();
8
+ });
9
+
10
+ describe('successful parsing', () => {
11
+ it('should return success=true for valid query', () => {
12
+ const result = parser.parseWithContext('status:done');
13
+
14
+ expect(result.success).toBe(true);
15
+ expect(result.ast).toBeDefined();
16
+ expect(result.error).toBeUndefined();
17
+ });
18
+
19
+ it('should include AST for valid query', () => {
20
+ const result = parser.parseWithContext('status:done');
21
+
22
+ expect(result.ast).toMatchObject({
23
+ type: 'comparison',
24
+ field: 'status',
25
+ value: 'done'
26
+ });
27
+ });
28
+
29
+ it('should parse complex valid query', () => {
30
+ const result = parser.parseWithContext('status:done AND priority:high');
31
+
32
+ expect(result.success).toBe(true);
33
+ expect(result.ast?.type).toBe('logical');
34
+ });
35
+ });
36
+
37
+ describe('failed parsing', () => {
38
+ it('should return success=false for invalid query', () => {
39
+ const result = parser.parseWithContext('status:');
40
+
41
+ expect(result.success).toBe(false);
42
+ expect(result.ast).toBeUndefined();
43
+ expect(result.error).toBeDefined();
44
+ });
45
+
46
+ it('should include error message', () => {
47
+ const result = parser.parseWithContext('status:');
48
+
49
+ expect(result.error?.message).toBeDefined();
50
+ expect(result.error?.message.length).toBeGreaterThan(0);
51
+ });
52
+
53
+ it('should still return tokens for invalid query', () => {
54
+ const result = parser.parseWithContext('status:');
55
+
56
+ expect(result.tokens).toHaveLength(1);
57
+ expect(result.tokens[0]).toMatchObject({
58
+ type: 'term',
59
+ key: 'status',
60
+ value: null
61
+ });
62
+ });
63
+
64
+ it('should handle trailing operator', () => {
65
+ const result = parser.parseWithContext('status:done AND');
66
+
67
+ expect(result.success).toBe(false);
68
+ expect(result.tokens.length).toBeGreaterThanOrEqual(2);
69
+ });
70
+ });
71
+
72
+ describe('tokens', () => {
73
+ it('should always return tokens array', () => {
74
+ const validResult = parser.parseWithContext('status:done');
75
+ const invalidResult = parser.parseWithContext('status:');
76
+
77
+ expect(Array.isArray(validResult.tokens)).toBe(true);
78
+ expect(Array.isArray(invalidResult.tokens)).toBe(true);
79
+ });
80
+
81
+ it('should return interleaved tokens for compound query', () => {
82
+ const result = parser.parseWithContext('a:1 AND b:2 OR c:3');
83
+
84
+ expect(result.tokens).toHaveLength(5);
85
+ expect(result.tokens.map(t => t.type)).toEqual([
86
+ 'term',
87
+ 'operator',
88
+ 'term',
89
+ 'operator',
90
+ 'term'
91
+ ]);
92
+ });
93
+
94
+ it('should include position information', () => {
95
+ const result = parser.parseWithContext('status:done');
96
+
97
+ expect(result.tokens[0].startPosition).toBe(0);
98
+ expect(result.tokens[0].endPosition).toBe(11);
99
+ expect(result.tokens[0].raw).toBe('status:done');
100
+ });
101
+ });
102
+
103
+ describe('active token (cursor awareness)', () => {
104
+ it('should identify active token when cursor position provided', () => {
105
+ const result = parser.parseWithContext('status:done AND priority:high', {
106
+ cursorPosition: 5
107
+ });
108
+
109
+ expect(result.activeToken).toBeDefined();
110
+ expect(result.activeToken?.type).toBe('term');
111
+ expect(result.activeTokenIndex).toBe(0);
112
+ });
113
+
114
+ it('should identify cursor in operator', () => {
115
+ const result = parser.parseWithContext('status:done AND priority:high', {
116
+ cursorPosition: 13 // in "AND"
117
+ });
118
+
119
+ expect(result.activeToken?.type).toBe('operator');
120
+ expect(result.activeTokenIndex).toBe(1);
121
+ });
122
+
123
+ it('should return activeTokenIndex=-1 when no cursor provided', () => {
124
+ const result = parser.parseWithContext('status:done');
125
+
126
+ expect(result.activeTokenIndex).toBe(-1);
127
+ });
128
+ });
129
+
130
+ describe('structure analysis', () => {
131
+ it('should analyze simple query structure', () => {
132
+ const result = parser.parseWithContext('status:done');
133
+
134
+ expect(result.structure).toMatchObject({
135
+ depth: 1,
136
+ clauseCount: 1,
137
+ operatorCount: 0,
138
+ hasBalancedParentheses: true,
139
+ hasBalancedQuotes: true,
140
+ isComplete: true,
141
+ complexity: 'simple'
142
+ });
143
+ });
144
+
145
+ it('should count clauses correctly', () => {
146
+ const result = parser.parseWithContext('a:1 AND b:2 AND c:3');
147
+
148
+ expect(result.structure.clauseCount).toBe(3);
149
+ expect(result.structure.operatorCount).toBe(2);
150
+ });
151
+
152
+ it('should detect unbalanced parentheses', () => {
153
+ const result = parser.parseWithContext('(status:done');
154
+
155
+ expect(result.structure.hasBalancedParentheses).toBe(false);
156
+ expect(result.structure.isComplete).toBe(false);
157
+ });
158
+
159
+ it('should detect unbalanced quotes', () => {
160
+ const result = parser.parseWithContext('name:"John');
161
+
162
+ expect(result.structure.hasBalancedQuotes).toBe(false);
163
+ expect(result.structure.isComplete).toBe(false);
164
+ });
165
+
166
+ it('should calculate depth for nested query', () => {
167
+ const result = parser.parseWithContext('(a:1 AND (b:2 OR c:3))');
168
+
169
+ expect(result.structure.depth).toBe(2);
170
+ });
171
+
172
+ it('should extract referenced fields', () => {
173
+ const result = parser.parseWithContext('status:done AND priority:high');
174
+
175
+ expect(result.structure.referencedFields).toContain('status');
176
+ expect(result.structure.referencedFields).toContain('priority');
177
+ expect(result.structure.referencedFields).toHaveLength(2);
178
+ });
179
+
180
+ it('should not duplicate fields in referencedFields', () => {
181
+ const result = parser.parseWithContext('status:done OR status:pending');
182
+
183
+ expect(result.structure.referencedFields).toEqual(['status']);
184
+ });
185
+
186
+ it('should classify simple queries', () => {
187
+ expect(parser.parseWithContext('a:1').structure.complexity).toBe(
188
+ 'simple'
189
+ );
190
+ expect(parser.parseWithContext('a:1 AND b:2').structure.complexity).toBe(
191
+ 'simple'
192
+ );
193
+ });
194
+
195
+ it('should classify moderate queries', () => {
196
+ const result = parser.parseWithContext('a:1 AND b:2 AND c:3');
197
+
198
+ expect(result.structure.complexity).toBe('moderate');
199
+ });
200
+
201
+ it('should classify complex queries', () => {
202
+ const result = parser.parseWithContext(
203
+ 'a:1 AND b:2 AND c:3 AND d:4 AND e:5 AND f:6'
204
+ );
205
+
206
+ expect(result.structure.complexity).toBe('complex');
207
+ });
208
+
209
+ it('should classify deeply nested as complex', () => {
210
+ const result = parser.parseWithContext(
211
+ '(((a:1 AND b:2) OR c:3) AND d:4)'
212
+ );
213
+
214
+ // Depth > 3 should be complex
215
+ expect(result.structure.depth).toBeGreaterThanOrEqual(3);
216
+ });
217
+ });
218
+
219
+ describe('input preservation', () => {
220
+ it('should preserve original input', () => {
221
+ const input = 'status:done AND priority:high';
222
+ const result = parser.parseWithContext(input);
223
+
224
+ expect(result.input).toBe(input);
225
+ });
226
+ });
227
+
228
+ describe('edge cases', () => {
229
+ it('should handle empty input', () => {
230
+ const result = parser.parseWithContext('');
231
+
232
+ expect(result.success).toBe(false);
233
+ expect(result.tokens).toHaveLength(0);
234
+ expect(result.structure.clauseCount).toBe(0);
235
+ expect(result.structure.depth).toBe(0);
236
+ });
237
+
238
+ it('should handle whitespace-only input', () => {
239
+ const result = parser.parseWithContext(' ');
240
+
241
+ expect(result.success).toBe(false);
242
+ expect(result.tokens).toHaveLength(0);
243
+ });
244
+
245
+ it('should handle query with only logical operators', () => {
246
+ const result = parser.parseWithContext('AND OR');
247
+
248
+ expect(result.success).toBe(false);
249
+ // Should still tokenize the operators
250
+ expect(result.tokens.length).toBeGreaterThanOrEqual(1);
251
+ });
252
+
253
+ it('should handle very long query', () => {
254
+ const clauses = Array.from(
255
+ { length: 20 },
256
+ (_, i) => `field${i}:value${i}`
257
+ );
258
+ const query = clauses.join(' AND ');
259
+ const result = parser.parseWithContext(query);
260
+
261
+ expect(result.success).toBe(true);
262
+ expect(result.structure.clauseCount).toBe(20);
263
+ expect(result.structure.complexity).toBe('complex');
264
+ });
265
+ });
266
+
267
+ describe('integration with parser options', () => {
268
+ it('should respect caseInsensitiveFields option', () => {
269
+ const caseInsensitiveParser = new QueryParser({
270
+ caseInsensitiveFields: true
271
+ });
272
+
273
+ const result = caseInsensitiveParser.parseWithContext('STATUS:done');
274
+
275
+ expect(result.success).toBe(true);
276
+ expect(result.ast).toMatchObject({
277
+ field: 'status' // lowercased
278
+ });
279
+ });
280
+
281
+ it('should respect fieldMappings option', () => {
282
+ const mappedParser = new QueryParser({
283
+ fieldMappings: { s: 'status' }
284
+ });
285
+
286
+ const result = mappedParser.parseWithContext('s:done');
287
+
288
+ expect(result.success).toBe(true);
289
+ expect(result.ast).toMatchObject({
290
+ field: 'status' // mapped
291
+ });
292
+ });
293
+ });
294
+
295
+ describe('never throws', () => {
296
+ it('should never throw, even for malformed input', () => {
297
+ const badInputs = [
298
+ '',
299
+ ' ',
300
+ ':::',
301
+ '(((',
302
+ '))))',
303
+ 'AND AND AND',
304
+ '""""""',
305
+ '\n\t\r',
306
+ 'field:>>>>>',
307
+ 'a'.repeat(10000)
308
+ ];
309
+
310
+ for (const input of badInputs) {
311
+ expect(() => parser.parseWithContext(input)).not.toThrow();
312
+ }
313
+ });
314
+
315
+ it('should return a valid result object for any input', () => {
316
+ const result = parser.parseWithContext('totally {{invalid}} query!!!');
317
+
318
+ expect(result).toHaveProperty('success');
319
+ expect(result).toHaveProperty('input');
320
+ expect(result).toHaveProperty('tokens');
321
+ expect(result).toHaveProperty('structure');
322
+ expect(result).toHaveProperty('activeTokenIndex');
323
+ });
324
+ });
325
+ });
@@ -1,5 +1,9 @@
1
1
  import { QueryParser, QueryParseError } from './parser';
2
- import { QueryExpression } from './types';
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', () => {