@gblikas/querykit 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cursor/BUGBOT.md +65 -2
- package/README.md +163 -1
- package/dist/parser/index.d.ts +1 -0
- package/dist/parser/index.js +1 -0
- package/dist/parser/input-parser.d.ts +215 -0
- package/dist/parser/input-parser.js +493 -0
- package/dist/parser/parser.d.ts +114 -1
- package/dist/parser/parser.js +716 -0
- package/dist/parser/types.d.ts +432 -0
- package/examples/qk-next/app/page.tsx +6 -1
- package/package.json +1 -1
- package/src/parser/divergence.test.ts +357 -0
- package/src/parser/index.ts +2 -1
- package/src/parser/input-parser.test.ts +770 -0
- package/src/parser/input-parser.ts +697 -0
- package/src/parser/parse-with-context-suggestions.test.ts +360 -0
- package/src/parser/parse-with-context-validation.test.ts +447 -0
- package/src/parser/parse-with-context.test.ts +325 -0
- package/src/parser/parser.ts +872 -0
- package/src/parser/token-consistency.test.ts +341 -0
- package/src/parser/types.ts +545 -23
- package/examples/qk-next/pnpm-lock.yaml +0 -5623
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Validation & Security features of parseWithContext:
|
|
3
|
+
* - Schema-aware field validation
|
|
4
|
+
* - Security pre-check
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { QueryParser } from './parser';
|
|
8
|
+
import { IFieldSchema } from './types';
|
|
9
|
+
|
|
10
|
+
describe('QueryParser.parseWithContext - Validation & Security', () => {
|
|
11
|
+
let parser: QueryParser;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
parser = new QueryParser();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('Field Validation (with schema)', () => {
|
|
18
|
+
const testSchema: Record<string, IFieldSchema> = {
|
|
19
|
+
status: { type: 'string', allowedValues: ['todo', 'doing', 'done'] },
|
|
20
|
+
priority: { type: 'number' },
|
|
21
|
+
name: { type: 'string' },
|
|
22
|
+
createdAt: { type: 'date' },
|
|
23
|
+
isActive: { type: 'boolean' },
|
|
24
|
+
tags: { type: 'array' }
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe('valid fields', () => {
|
|
28
|
+
it('should validate fields that exist in schema', () => {
|
|
29
|
+
const result = parser.parseWithContext('status:done', {
|
|
30
|
+
schema: testSchema
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(result.fieldValidation).toBeDefined();
|
|
34
|
+
expect(result.fieldValidation?.valid).toBe(true);
|
|
35
|
+
expect(result.fieldValidation?.unknownFields).toHaveLength(0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should include field details for valid fields', () => {
|
|
39
|
+
const result = parser.parseWithContext(
|
|
40
|
+
'status:done AND priority:high',
|
|
41
|
+
{
|
|
42
|
+
schema: testSchema
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
expect(result.fieldValidation?.fields).toHaveLength(2);
|
|
47
|
+
|
|
48
|
+
const statusField = result.fieldValidation?.fields.find(
|
|
49
|
+
f => f.field === 'status'
|
|
50
|
+
);
|
|
51
|
+
expect(statusField).toMatchObject({
|
|
52
|
+
field: 'status',
|
|
53
|
+
valid: true,
|
|
54
|
+
expectedType: 'string',
|
|
55
|
+
allowedValues: ['todo', 'doing', 'done']
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const priorityField = result.fieldValidation?.fields.find(
|
|
59
|
+
f => f.field === 'priority'
|
|
60
|
+
);
|
|
61
|
+
expect(priorityField).toMatchObject({
|
|
62
|
+
field: 'priority',
|
|
63
|
+
valid: true,
|
|
64
|
+
expectedType: 'number'
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should handle multiple references to same field', () => {
|
|
69
|
+
const result = parser.parseWithContext('status:todo OR status:done', {
|
|
70
|
+
schema: testSchema
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(result.fieldValidation?.valid).toBe(true);
|
|
74
|
+
// referencedFields should deduplicate
|
|
75
|
+
expect(result.structure.referencedFields).toEqual(['status']);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('invalid fields', () => {
|
|
80
|
+
it('should detect unknown fields', () => {
|
|
81
|
+
const result = parser.parseWithContext('unknownField:value', {
|
|
82
|
+
schema: testSchema
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(result.fieldValidation?.valid).toBe(false);
|
|
86
|
+
expect(result.fieldValidation?.unknownFields).toContain('unknownField');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should provide reason for invalid fields', () => {
|
|
90
|
+
const result = parser.parseWithContext('badField:value', {
|
|
91
|
+
schema: testSchema
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const badField = result.fieldValidation?.fields.find(
|
|
95
|
+
f => f.field === 'badField'
|
|
96
|
+
);
|
|
97
|
+
expect(badField).toMatchObject({
|
|
98
|
+
field: 'badField',
|
|
99
|
+
valid: false,
|
|
100
|
+
reason: 'unknown_field'
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should suggest similar field for typos', () => {
|
|
105
|
+
const result = parser.parseWithContext('statis:done', {
|
|
106
|
+
schema: testSchema
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const field = result.fieldValidation?.fields.find(
|
|
110
|
+
f => f.field === 'statis'
|
|
111
|
+
);
|
|
112
|
+
expect(field?.suggestion).toBe('status');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should suggest case-corrected field', () => {
|
|
116
|
+
const result = parser.parseWithContext('STATUS:done', {
|
|
117
|
+
schema: testSchema
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const field = result.fieldValidation?.fields.find(
|
|
121
|
+
f => f.field === 'STATUS'
|
|
122
|
+
);
|
|
123
|
+
expect(field?.suggestion).toBe('status');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should suggest field with similar prefix', () => {
|
|
127
|
+
const result = parser.parseWithContext('creat:value', {
|
|
128
|
+
schema: testSchema
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const field = result.fieldValidation?.fields.find(
|
|
132
|
+
f => f.field === 'creat'
|
|
133
|
+
);
|
|
134
|
+
expect(field?.suggestion).toBe('createdAt');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('mixed valid and invalid fields', () => {
|
|
139
|
+
it('should report all fields with mixed validity', () => {
|
|
140
|
+
const result = parser.parseWithContext(
|
|
141
|
+
'status:done AND badField:value',
|
|
142
|
+
{ schema: testSchema }
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
expect(result.fieldValidation?.valid).toBe(false);
|
|
146
|
+
expect(result.fieldValidation?.fields).toHaveLength(2);
|
|
147
|
+
|
|
148
|
+
const validField = result.fieldValidation?.fields.find(
|
|
149
|
+
f => f.field === 'status'
|
|
150
|
+
);
|
|
151
|
+
expect(validField?.valid).toBe(true);
|
|
152
|
+
|
|
153
|
+
const invalidField = result.fieldValidation?.fields.find(
|
|
154
|
+
f => f.field === 'badField'
|
|
155
|
+
);
|
|
156
|
+
expect(invalidField?.valid).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('no schema provided', () => {
|
|
161
|
+
it('should not include fieldValidation when no schema', () => {
|
|
162
|
+
const result = parser.parseWithContext('status:done');
|
|
163
|
+
|
|
164
|
+
expect(result.fieldValidation).toBeUndefined();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('Security Pre-check', () => {
|
|
170
|
+
describe('denied fields', () => {
|
|
171
|
+
it('should detect denied fields', () => {
|
|
172
|
+
const result = parser.parseWithContext('password:secret', {
|
|
173
|
+
securityOptions: {
|
|
174
|
+
denyFields: ['password', 'secret_key']
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(result.security?.passed).toBe(false);
|
|
179
|
+
expect(result.security?.violations).toContainEqual(
|
|
180
|
+
expect.objectContaining({
|
|
181
|
+
type: 'denied_field',
|
|
182
|
+
field: 'password'
|
|
183
|
+
})
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should pass when field is not denied', () => {
|
|
188
|
+
const result = parser.parseWithContext('status:done', {
|
|
189
|
+
securityOptions: {
|
|
190
|
+
denyFields: ['password', 'secret_key']
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(result.security?.passed).toBe(true);
|
|
195
|
+
expect(result.security?.violations).toHaveLength(0);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('allowed fields', () => {
|
|
200
|
+
it('should detect fields not in allowed list', () => {
|
|
201
|
+
const result = parser.parseWithContext('status:done AND secret:value', {
|
|
202
|
+
securityOptions: {
|
|
203
|
+
allowedFields: ['status', 'priority', 'name']
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(result.security?.passed).toBe(false);
|
|
208
|
+
expect(result.security?.violations).toContainEqual(
|
|
209
|
+
expect.objectContaining({
|
|
210
|
+
type: 'field_not_allowed',
|
|
211
|
+
field: 'secret'
|
|
212
|
+
})
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should pass when all fields are allowed', () => {
|
|
217
|
+
const result = parser.parseWithContext(
|
|
218
|
+
'status:done AND priority:high',
|
|
219
|
+
{
|
|
220
|
+
securityOptions: {
|
|
221
|
+
allowedFields: ['status', 'priority', 'name']
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
expect(result.security?.passed).toBe(true);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe('dot notation', () => {
|
|
231
|
+
it('should detect dot notation when disabled', () => {
|
|
232
|
+
const result = parser.parseWithContext('user.email:test@example.com', {
|
|
233
|
+
securityOptions: {
|
|
234
|
+
allowDotNotation: false
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
expect(result.security?.passed).toBe(false);
|
|
239
|
+
expect(result.security?.violations).toContainEqual(
|
|
240
|
+
expect.objectContaining({
|
|
241
|
+
type: 'dot_notation',
|
|
242
|
+
field: 'user.email'
|
|
243
|
+
})
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should allow dot notation by default', () => {
|
|
248
|
+
const result = parser.parseWithContext('user.email:test@example.com', {
|
|
249
|
+
securityOptions: {}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
expect(result.security?.passed).toBe(true);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe('query depth', () => {
|
|
257
|
+
it('should detect exceeded depth', () => {
|
|
258
|
+
const result = parser.parseWithContext('((a:1 AND b:2) OR c:3)', {
|
|
259
|
+
securityOptions: {
|
|
260
|
+
maxQueryDepth: 1
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
expect(result.security?.passed).toBe(false);
|
|
265
|
+
expect(result.security?.violations).toContainEqual(
|
|
266
|
+
expect.objectContaining({
|
|
267
|
+
type: 'depth_exceeded'
|
|
268
|
+
})
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('should warn when approaching depth limit', () => {
|
|
273
|
+
const result = parser.parseWithContext('(a:1 AND b:2)', {
|
|
274
|
+
securityOptions: {
|
|
275
|
+
maxQueryDepth: 2
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// Depth is 1, limit is 2, 80% of 2 is 1.6
|
|
280
|
+
// So depth 1 doesn't trigger warning, but depth 2 would
|
|
281
|
+
expect(result.security?.passed).toBe(true);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('should pass when within depth limit', () => {
|
|
285
|
+
const result = parser.parseWithContext('a:1 AND b:2', {
|
|
286
|
+
securityOptions: {
|
|
287
|
+
maxQueryDepth: 5
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
expect(result.security?.passed).toBe(true);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe('clause count', () => {
|
|
296
|
+
it('should detect exceeded clause count', () => {
|
|
297
|
+
const result = parser.parseWithContext('a:1 AND b:2 AND c:3 AND d:4', {
|
|
298
|
+
securityOptions: {
|
|
299
|
+
maxClauseCount: 3
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
expect(result.security?.passed).toBe(false);
|
|
304
|
+
expect(result.security?.violations).toContainEqual(
|
|
305
|
+
expect.objectContaining({
|
|
306
|
+
type: 'clause_limit'
|
|
307
|
+
})
|
|
308
|
+
);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('should warn when approaching clause limit', () => {
|
|
312
|
+
const result = parser.parseWithContext('a:1 AND b:2 AND c:3 AND d:4', {
|
|
313
|
+
securityOptions: {
|
|
314
|
+
maxClauseCount: 5
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
expect(result.security?.passed).toBe(true);
|
|
319
|
+
expect(result.security?.warnings).toContainEqual(
|
|
320
|
+
expect.objectContaining({
|
|
321
|
+
type: 'approaching_clause_limit',
|
|
322
|
+
current: 4,
|
|
323
|
+
limit: 5
|
|
324
|
+
})
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe('complexity warnings', () => {
|
|
330
|
+
it('should warn about complex queries', () => {
|
|
331
|
+
const clauses = Array.from(
|
|
332
|
+
{ length: 10 },
|
|
333
|
+
(_, i) => `field${i}:value${i}`
|
|
334
|
+
);
|
|
335
|
+
const query = clauses.join(' AND ');
|
|
336
|
+
|
|
337
|
+
const result = parser.parseWithContext(query, {
|
|
338
|
+
securityOptions: {}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
expect(result.security?.warnings).toContainEqual(
|
|
342
|
+
expect.objectContaining({
|
|
343
|
+
type: 'complex_query'
|
|
344
|
+
})
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
describe('multiple violations', () => {
|
|
350
|
+
it('should report all violations', () => {
|
|
351
|
+
const result = parser.parseWithContext(
|
|
352
|
+
'password:secret AND user.role:admin',
|
|
353
|
+
{
|
|
354
|
+
securityOptions: {
|
|
355
|
+
denyFields: ['password'],
|
|
356
|
+
allowDotNotation: false
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
expect(result.security?.passed).toBe(false);
|
|
362
|
+
expect(result.security?.violations.length).toBeGreaterThanOrEqual(2);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
describe('no security options provided', () => {
|
|
367
|
+
it('should not include security when no options', () => {
|
|
368
|
+
const result = parser.parseWithContext('status:done');
|
|
369
|
+
|
|
370
|
+
expect(result.security).toBeUndefined();
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
describe('Combined schema and security', () => {
|
|
376
|
+
it('should include both fieldValidation and security when both provided', () => {
|
|
377
|
+
const result = parser.parseWithContext('status:done', {
|
|
378
|
+
schema: {
|
|
379
|
+
status: { type: 'string' }
|
|
380
|
+
},
|
|
381
|
+
securityOptions: {
|
|
382
|
+
denyFields: ['password']
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
expect(result.fieldValidation).toBeDefined();
|
|
387
|
+
expect(result.security).toBeDefined();
|
|
388
|
+
expect(result.fieldValidation?.valid).toBe(true);
|
|
389
|
+
expect(result.security?.passed).toBe(true);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('should report issues from both validations', () => {
|
|
393
|
+
const result = parser.parseWithContext(
|
|
394
|
+
'unknownField:value AND password:secret',
|
|
395
|
+
{
|
|
396
|
+
schema: {
|
|
397
|
+
status: { type: 'string' }
|
|
398
|
+
},
|
|
399
|
+
securityOptions: {
|
|
400
|
+
denyFields: ['password']
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
expect(result.fieldValidation?.valid).toBe(false);
|
|
406
|
+
expect(result.security?.passed).toBe(false);
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
describe('Integration with Core Parsing', () => {
|
|
411
|
+
it('should include all core parsing features', () => {
|
|
412
|
+
const result = parser.parseWithContext('status:done AND priority:high', {
|
|
413
|
+
cursorPosition: 5,
|
|
414
|
+
schema: {
|
|
415
|
+
status: { type: 'string' },
|
|
416
|
+
priority: { type: 'number' }
|
|
417
|
+
},
|
|
418
|
+
securityOptions: {
|
|
419
|
+
maxClauseCount: 10
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// Core parsing features
|
|
424
|
+
expect(result.success).toBe(true);
|
|
425
|
+
expect(result.tokens).toHaveLength(3);
|
|
426
|
+
expect(result.activeToken).toBeDefined();
|
|
427
|
+
expect(result.structure).toBeDefined();
|
|
428
|
+
|
|
429
|
+
// Validation & Security features
|
|
430
|
+
expect(result.fieldValidation).toBeDefined();
|
|
431
|
+
expect(result.security).toBeDefined();
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('should work with failed parsing', () => {
|
|
435
|
+
const result = parser.parseWithContext('status:', {
|
|
436
|
+
schema: {
|
|
437
|
+
status: { type: 'string' }
|
|
438
|
+
},
|
|
439
|
+
securityOptions: {}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
expect(result.success).toBe(false);
|
|
443
|
+
expect(result.fieldValidation?.valid).toBe(true); // Field is valid
|
|
444
|
+
expect(result.security?.passed).toBe(true); // No security issues
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
});
|