@gblikas/querykit 0.2.0 → 0.4.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/.husky/pre-commit +3 -3
- package/README.md +510 -1
- package/dist/index.d.ts +36 -3
- package/dist/index.js +20 -3
- 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/dist/virtual-fields/index.d.ts +5 -0
- package/dist/virtual-fields/index.js +21 -0
- package/dist/virtual-fields/resolver.d.ts +17 -0
- package/dist/virtual-fields/resolver.js +107 -0
- package/dist/virtual-fields/types.d.ts +160 -0
- package/dist/virtual-fields/types.js +5 -0
- package/examples/qk-next/app/page.tsx +190 -86
- package/examples/qk-next/package.json +1 -1
- package/package.json +2 -2
- package/src/adapters/drizzle/index.ts +3 -3
- package/src/index.ts +77 -8
- 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/src/virtual-fields/index.ts +6 -0
- package/src/virtual-fields/integration.test.ts +338 -0
- package/src/virtual-fields/resolver.ts +165 -0
- package/src/virtual-fields/types.ts +203 -0
- package/src/virtual-fields/virtual-fields.test.ts +831 -0
- package/examples/qk-next/pnpm-lock.yaml +0 -5623
|
@@ -0,0 +1,831 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Virtual Fields functionality
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { QueryParseError } from '../parser/parser';
|
|
6
|
+
import { resolveVirtualFields } from './resolver';
|
|
7
|
+
import { IQueryContext, VirtualFieldsConfig, SchemaFieldMap } from './types';
|
|
8
|
+
import { IComparisonExpression, ILogicalExpression } from '../parser/types';
|
|
9
|
+
|
|
10
|
+
// Mock schema for testing
|
|
11
|
+
type MockSchema = {
|
|
12
|
+
tasks: {
|
|
13
|
+
id: number;
|
|
14
|
+
title: string;
|
|
15
|
+
assignee_id: number;
|
|
16
|
+
creator_id: number;
|
|
17
|
+
watcher_ids: number[];
|
|
18
|
+
status: string;
|
|
19
|
+
priority: number;
|
|
20
|
+
};
|
|
21
|
+
users: {
|
|
22
|
+
id: number;
|
|
23
|
+
name: string;
|
|
24
|
+
email: string;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Mock context for testing
|
|
29
|
+
interface IMockContext extends IQueryContext {
|
|
30
|
+
currentUserId: number;
|
|
31
|
+
currentUserTeamIds: number[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('Virtual Fields', () => {
|
|
35
|
+
describe('Basic Resolution', () => {
|
|
36
|
+
it('should resolve a simple virtual field to a comparison expression', () => {
|
|
37
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
38
|
+
my: {
|
|
39
|
+
allowedValues: ['assigned', 'created'] as const,
|
|
40
|
+
resolve: (input, ctx, { fields }) => {
|
|
41
|
+
const fieldMap = fields({
|
|
42
|
+
assigned: 'assignee_id',
|
|
43
|
+
created: 'creator_id'
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
type: 'comparison',
|
|
47
|
+
field: fieldMap[input.value as 'assigned' | 'created'],
|
|
48
|
+
operator: '==',
|
|
49
|
+
value: ctx.currentUserId
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const context: IMockContext = {
|
|
56
|
+
currentUserId: 123,
|
|
57
|
+
currentUserTeamIds: [1, 2, 3]
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const expr: IComparisonExpression = {
|
|
61
|
+
type: 'comparison',
|
|
62
|
+
field: 'my',
|
|
63
|
+
operator: '==',
|
|
64
|
+
value: 'assigned'
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const resolved = resolveVirtualFields(expr, virtualFields, context);
|
|
68
|
+
|
|
69
|
+
expect(resolved).toEqual({
|
|
70
|
+
type: 'comparison',
|
|
71
|
+
field: 'assignee_id',
|
|
72
|
+
operator: '==',
|
|
73
|
+
value: 123
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should resolve multiple different virtual fields in the same query', () => {
|
|
78
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
79
|
+
my: {
|
|
80
|
+
allowedValues: ['assigned'] as const,
|
|
81
|
+
resolve: (_input, ctx) => ({
|
|
82
|
+
type: 'comparison',
|
|
83
|
+
field: 'assignee_id',
|
|
84
|
+
operator: '==',
|
|
85
|
+
value: ctx.currentUserId
|
|
86
|
+
})
|
|
87
|
+
},
|
|
88
|
+
team: {
|
|
89
|
+
allowedValues: ['assigned'] as const,
|
|
90
|
+
resolve: (_input, ctx) => ({
|
|
91
|
+
type: 'comparison',
|
|
92
|
+
field: 'assignee_id',
|
|
93
|
+
operator: 'IN',
|
|
94
|
+
value: ctx.currentUserTeamIds
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const context: IMockContext = {
|
|
100
|
+
currentUserId: 123,
|
|
101
|
+
currentUserTeamIds: [1, 2, 3]
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const expr: ILogicalExpression = {
|
|
105
|
+
type: 'logical',
|
|
106
|
+
operator: 'OR',
|
|
107
|
+
left: {
|
|
108
|
+
type: 'comparison',
|
|
109
|
+
field: 'my',
|
|
110
|
+
operator: '==',
|
|
111
|
+
value: 'assigned'
|
|
112
|
+
},
|
|
113
|
+
right: {
|
|
114
|
+
type: 'comparison',
|
|
115
|
+
field: 'team',
|
|
116
|
+
operator: '==',
|
|
117
|
+
value: 'assigned'
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const resolved = resolveVirtualFields(
|
|
122
|
+
expr,
|
|
123
|
+
virtualFields,
|
|
124
|
+
context
|
|
125
|
+
) as ILogicalExpression;
|
|
126
|
+
|
|
127
|
+
expect(resolved.type).toBe('logical');
|
|
128
|
+
expect(resolved.operator).toBe('OR');
|
|
129
|
+
expect((resolved.left as IComparisonExpression).field).toBe(
|
|
130
|
+
'assignee_id'
|
|
131
|
+
);
|
|
132
|
+
expect((resolved.left as IComparisonExpression).value).toBe(123);
|
|
133
|
+
expect((resolved.right as IComparisonExpression).field).toBe(
|
|
134
|
+
'assignee_id'
|
|
135
|
+
);
|
|
136
|
+
expect((resolved.right as IComparisonExpression).value).toEqual([
|
|
137
|
+
1, 2, 3
|
|
138
|
+
]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should resolve virtual fields combined with regular fields', () => {
|
|
142
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
143
|
+
my: {
|
|
144
|
+
allowedValues: ['assigned'] as const,
|
|
145
|
+
resolve: (_input, ctx) => ({
|
|
146
|
+
type: 'comparison',
|
|
147
|
+
field: 'assignee_id',
|
|
148
|
+
operator: '==',
|
|
149
|
+
value: ctx.currentUserId
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const context: IMockContext = {
|
|
155
|
+
currentUserId: 123,
|
|
156
|
+
currentUserTeamIds: []
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const expr: ILogicalExpression = {
|
|
160
|
+
type: 'logical',
|
|
161
|
+
operator: 'AND',
|
|
162
|
+
left: {
|
|
163
|
+
type: 'comparison',
|
|
164
|
+
field: 'my',
|
|
165
|
+
operator: '==',
|
|
166
|
+
value: 'assigned'
|
|
167
|
+
},
|
|
168
|
+
right: {
|
|
169
|
+
type: 'comparison',
|
|
170
|
+
field: 'status',
|
|
171
|
+
operator: '==',
|
|
172
|
+
value: 'done'
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const resolved = resolveVirtualFields(
|
|
177
|
+
expr,
|
|
178
|
+
virtualFields,
|
|
179
|
+
context
|
|
180
|
+
) as ILogicalExpression;
|
|
181
|
+
|
|
182
|
+
expect(resolved.type).toBe('logical');
|
|
183
|
+
expect(resolved.operator).toBe('AND');
|
|
184
|
+
expect((resolved.left as IComparisonExpression).field).toBe(
|
|
185
|
+
'assignee_id'
|
|
186
|
+
);
|
|
187
|
+
expect((resolved.right as IComparisonExpression).field).toBe('status');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should handle logical expressions with virtual fields', () => {
|
|
191
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
192
|
+
my: {
|
|
193
|
+
allowedValues: ['assigned', 'created'] as const,
|
|
194
|
+
resolve: (input, ctx, { fields }) => {
|
|
195
|
+
const fieldMap = fields({
|
|
196
|
+
assigned: 'assignee_id',
|
|
197
|
+
created: 'creator_id'
|
|
198
|
+
});
|
|
199
|
+
return {
|
|
200
|
+
type: 'comparison',
|
|
201
|
+
field: fieldMap[input.value as 'assigned' | 'created'],
|
|
202
|
+
operator: '==',
|
|
203
|
+
value: ctx.currentUserId
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const context: IMockContext = {
|
|
210
|
+
currentUserId: 456,
|
|
211
|
+
currentUserTeamIds: []
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const expr: ILogicalExpression = {
|
|
215
|
+
type: 'logical',
|
|
216
|
+
operator: 'OR',
|
|
217
|
+
left: {
|
|
218
|
+
type: 'comparison',
|
|
219
|
+
field: 'my',
|
|
220
|
+
operator: '==',
|
|
221
|
+
value: 'assigned'
|
|
222
|
+
},
|
|
223
|
+
right: {
|
|
224
|
+
type: 'comparison',
|
|
225
|
+
field: 'my',
|
|
226
|
+
operator: '==',
|
|
227
|
+
value: 'created'
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const resolved = resolveVirtualFields(
|
|
232
|
+
expr,
|
|
233
|
+
virtualFields,
|
|
234
|
+
context
|
|
235
|
+
) as ILogicalExpression;
|
|
236
|
+
|
|
237
|
+
expect(resolved.type).toBe('logical');
|
|
238
|
+
expect(resolved.operator).toBe('OR');
|
|
239
|
+
expect((resolved.left as IComparisonExpression).field).toBe(
|
|
240
|
+
'assignee_id'
|
|
241
|
+
);
|
|
242
|
+
expect((resolved.left as IComparisonExpression).value).toBe(456);
|
|
243
|
+
expect((resolved.right as IComparisonExpression).field).toBe(
|
|
244
|
+
'creator_id'
|
|
245
|
+
);
|
|
246
|
+
expect((resolved.right as IComparisonExpression).value).toBe(456);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('Validation', () => {
|
|
251
|
+
it('should throw QueryParseError for invalid virtual field value', () => {
|
|
252
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
253
|
+
my: {
|
|
254
|
+
allowedValues: ['assigned', 'created'] as const,
|
|
255
|
+
resolve: (_input, ctx) => ({
|
|
256
|
+
type: 'comparison',
|
|
257
|
+
field: 'assignee_id',
|
|
258
|
+
operator: '==',
|
|
259
|
+
value: ctx.currentUserId
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const context: IMockContext = {
|
|
265
|
+
currentUserId: 123,
|
|
266
|
+
currentUserTeamIds: []
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const expr: IComparisonExpression = {
|
|
270
|
+
type: 'comparison',
|
|
271
|
+
field: 'my',
|
|
272
|
+
operator: '==',
|
|
273
|
+
value: 'invalid_value'
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
expect(() => {
|
|
277
|
+
resolveVirtualFields(expr, virtualFields, context);
|
|
278
|
+
}).toThrow(QueryParseError);
|
|
279
|
+
|
|
280
|
+
expect(() => {
|
|
281
|
+
resolveVirtualFields(expr, virtualFields, context);
|
|
282
|
+
}).toThrow(
|
|
283
|
+
'Invalid value "invalid_value" for virtual field "my". Allowed values: "assigned", "created"'
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('should throw QueryParseError when operators are not allowed', () => {
|
|
288
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
289
|
+
my: {
|
|
290
|
+
allowedValues: ['assigned'] as const,
|
|
291
|
+
allowOperators: false, // Explicitly disallow operators
|
|
292
|
+
resolve: (_input, ctx) => ({
|
|
293
|
+
type: 'comparison',
|
|
294
|
+
field: 'assignee_id',
|
|
295
|
+
operator: '==',
|
|
296
|
+
value: ctx.currentUserId
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const context: IMockContext = {
|
|
302
|
+
currentUserId: 123,
|
|
303
|
+
currentUserTeamIds: []
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const expr: IComparisonExpression = {
|
|
307
|
+
type: 'comparison',
|
|
308
|
+
field: 'my',
|
|
309
|
+
operator: '>',
|
|
310
|
+
value: 'assigned'
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
expect(() => {
|
|
314
|
+
resolveVirtualFields(expr, virtualFields, context);
|
|
315
|
+
}).toThrow(QueryParseError);
|
|
316
|
+
|
|
317
|
+
expect(() => {
|
|
318
|
+
resolveVirtualFields(expr, virtualFields, context);
|
|
319
|
+
}).toThrow('Virtual field "my" does not allow comparison operators');
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('should allow operators when allowOperators is true', () => {
|
|
323
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
324
|
+
priority: {
|
|
325
|
+
allowedValues: ['high'] as const,
|
|
326
|
+
allowOperators: true, // Allow operators
|
|
327
|
+
resolve: input => ({
|
|
328
|
+
type: 'comparison',
|
|
329
|
+
field: 'priority',
|
|
330
|
+
operator: input.operator as '>',
|
|
331
|
+
value: 5
|
|
332
|
+
})
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const context: IMockContext = {
|
|
337
|
+
currentUserId: 123,
|
|
338
|
+
currentUserTeamIds: []
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const expr: IComparisonExpression = {
|
|
342
|
+
type: 'comparison',
|
|
343
|
+
field: 'priority',
|
|
344
|
+
operator: '>',
|
|
345
|
+
value: 'high'
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const resolved = resolveVirtualFields(expr, virtualFields, context);
|
|
349
|
+
|
|
350
|
+
expect(resolved).toEqual({
|
|
351
|
+
type: 'comparison',
|
|
352
|
+
field: 'priority',
|
|
353
|
+
operator: '>',
|
|
354
|
+
value: 5
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('should pass through unknown fields unchanged', () => {
|
|
359
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
360
|
+
my: {
|
|
361
|
+
allowedValues: ['assigned'] as const,
|
|
362
|
+
resolve: (_input, ctx) => ({
|
|
363
|
+
type: 'comparison',
|
|
364
|
+
field: 'assignee_id',
|
|
365
|
+
operator: '==',
|
|
366
|
+
value: ctx.currentUserId
|
|
367
|
+
})
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const context: IMockContext = {
|
|
372
|
+
currentUserId: 123,
|
|
373
|
+
currentUserTeamIds: []
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const expr: IComparisonExpression = {
|
|
377
|
+
type: 'comparison',
|
|
378
|
+
field: 'status',
|
|
379
|
+
operator: '==',
|
|
380
|
+
value: 'done'
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const resolved = resolveVirtualFields(expr, virtualFields, context);
|
|
384
|
+
|
|
385
|
+
expect(resolved).toEqual(expr);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('should throw QueryParseError for non-string values in virtual fields', () => {
|
|
389
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
390
|
+
my: {
|
|
391
|
+
allowedValues: ['assigned'] as const,
|
|
392
|
+
resolve: (_input, ctx) => ({
|
|
393
|
+
type: 'comparison',
|
|
394
|
+
field: 'assignee_id',
|
|
395
|
+
operator: '==',
|
|
396
|
+
value: ctx.currentUserId
|
|
397
|
+
})
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const context: IMockContext = {
|
|
402
|
+
currentUserId: 123,
|
|
403
|
+
currentUserTeamIds: []
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const expr: IComparisonExpression = {
|
|
407
|
+
type: 'comparison',
|
|
408
|
+
field: 'my',
|
|
409
|
+
operator: '==',
|
|
410
|
+
value: 123 // Number instead of string
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
expect(() => {
|
|
414
|
+
resolveVirtualFields(expr, virtualFields, context);
|
|
415
|
+
}).toThrow(QueryParseError);
|
|
416
|
+
|
|
417
|
+
expect(() => {
|
|
418
|
+
resolveVirtualFields(expr, virtualFields, context);
|
|
419
|
+
}).toThrow('Virtual field "my" requires a string value');
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
describe('Context Usage', () => {
|
|
424
|
+
it('should correctly use context values in resolution', () => {
|
|
425
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
426
|
+
my: {
|
|
427
|
+
allowedValues: ['assigned'] as const,
|
|
428
|
+
resolve: (_input, ctx) => ({
|
|
429
|
+
type: 'comparison',
|
|
430
|
+
field: 'assignee_id',
|
|
431
|
+
operator: '==',
|
|
432
|
+
value: ctx.currentUserId
|
|
433
|
+
})
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const context: IMockContext = {
|
|
438
|
+
currentUserId: 999,
|
|
439
|
+
currentUserTeamIds: []
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
const expr: IComparisonExpression = {
|
|
443
|
+
type: 'comparison',
|
|
444
|
+
field: 'my',
|
|
445
|
+
operator: '==',
|
|
446
|
+
value: 'assigned'
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
const resolved = resolveVirtualFields(expr, virtualFields, context);
|
|
450
|
+
|
|
451
|
+
expect((resolved as IComparisonExpression).value).toBe(999);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('should use array values from context', () => {
|
|
455
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
456
|
+
team: {
|
|
457
|
+
allowedValues: ['members'] as const,
|
|
458
|
+
resolve: (_input, ctx) => ({
|
|
459
|
+
type: 'comparison',
|
|
460
|
+
field: 'assignee_id',
|
|
461
|
+
operator: 'IN',
|
|
462
|
+
value: ctx.currentUserTeamIds
|
|
463
|
+
})
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
const context: IMockContext = {
|
|
468
|
+
currentUserId: 1,
|
|
469
|
+
currentUserTeamIds: [10, 20, 30]
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const expr: IComparisonExpression = {
|
|
473
|
+
type: 'comparison',
|
|
474
|
+
field: 'team',
|
|
475
|
+
operator: '==',
|
|
476
|
+
value: 'members'
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const resolved = resolveVirtualFields(expr, virtualFields, context);
|
|
480
|
+
|
|
481
|
+
expect((resolved as IComparisonExpression).value).toEqual([10, 20, 30]);
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
describe('Complex Expressions', () => {
|
|
486
|
+
it('should handle nested logical expressions with virtual fields', () => {
|
|
487
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
488
|
+
my: {
|
|
489
|
+
allowedValues: ['assigned'] as const,
|
|
490
|
+
resolve: (_input, ctx) => ({
|
|
491
|
+
type: 'comparison',
|
|
492
|
+
field: 'assignee_id',
|
|
493
|
+
operator: '==',
|
|
494
|
+
value: ctx.currentUserId
|
|
495
|
+
})
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
const context: IMockContext = {
|
|
500
|
+
currentUserId: 123,
|
|
501
|
+
currentUserTeamIds: []
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
const expr: ILogicalExpression = {
|
|
505
|
+
type: 'logical',
|
|
506
|
+
operator: 'AND',
|
|
507
|
+
left: {
|
|
508
|
+
type: 'logical',
|
|
509
|
+
operator: 'OR',
|
|
510
|
+
left: {
|
|
511
|
+
type: 'comparison',
|
|
512
|
+
field: 'my',
|
|
513
|
+
operator: '==',
|
|
514
|
+
value: 'assigned'
|
|
515
|
+
},
|
|
516
|
+
right: {
|
|
517
|
+
type: 'comparison',
|
|
518
|
+
field: 'status',
|
|
519
|
+
operator: '==',
|
|
520
|
+
value: 'done'
|
|
521
|
+
}
|
|
522
|
+
},
|
|
523
|
+
right: {
|
|
524
|
+
type: 'comparison',
|
|
525
|
+
field: 'priority',
|
|
526
|
+
operator: '>',
|
|
527
|
+
value: 5
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
const resolved = resolveVirtualFields(
|
|
532
|
+
expr,
|
|
533
|
+
virtualFields,
|
|
534
|
+
context
|
|
535
|
+
) as ILogicalExpression;
|
|
536
|
+
|
|
537
|
+
expect(resolved.type).toBe('logical');
|
|
538
|
+
expect(resolved.operator).toBe('AND');
|
|
539
|
+
|
|
540
|
+
const leftLogical = resolved.left as ILogicalExpression;
|
|
541
|
+
expect(leftLogical.type).toBe('logical');
|
|
542
|
+
expect(leftLogical.operator).toBe('OR');
|
|
543
|
+
expect((leftLogical.left as IComparisonExpression).field).toBe(
|
|
544
|
+
'assignee_id'
|
|
545
|
+
);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('should handle NOT operator with virtual fields', () => {
|
|
549
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
550
|
+
my: {
|
|
551
|
+
allowedValues: ['assigned'] as const,
|
|
552
|
+
resolve: (_input, ctx) => ({
|
|
553
|
+
type: 'comparison',
|
|
554
|
+
field: 'assignee_id',
|
|
555
|
+
operator: '==',
|
|
556
|
+
value: ctx.currentUserId
|
|
557
|
+
})
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
const context: IMockContext = {
|
|
562
|
+
currentUserId: 123,
|
|
563
|
+
currentUserTeamIds: []
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
const expr: ILogicalExpression = {
|
|
567
|
+
type: 'logical',
|
|
568
|
+
operator: 'NOT',
|
|
569
|
+
left: {
|
|
570
|
+
type: 'comparison',
|
|
571
|
+
field: 'my',
|
|
572
|
+
operator: '==',
|
|
573
|
+
value: 'assigned'
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
const resolved = resolveVirtualFields(
|
|
578
|
+
expr,
|
|
579
|
+
virtualFields,
|
|
580
|
+
context
|
|
581
|
+
) as ILogicalExpression;
|
|
582
|
+
|
|
583
|
+
expect(resolved.type).toBe('logical');
|
|
584
|
+
expect(resolved.operator).toBe('NOT');
|
|
585
|
+
expect((resolved.left as IComparisonExpression).field).toBe(
|
|
586
|
+
'assignee_id'
|
|
587
|
+
);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it('should handle virtual field that returns a logical expression', () => {
|
|
591
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
592
|
+
myItems: {
|
|
593
|
+
allowedValues: ['all'] as const,
|
|
594
|
+
resolve: (_input, ctx) => ({
|
|
595
|
+
type: 'logical',
|
|
596
|
+
operator: 'OR',
|
|
597
|
+
left: {
|
|
598
|
+
type: 'comparison',
|
|
599
|
+
field: 'assignee_id',
|
|
600
|
+
operator: '==',
|
|
601
|
+
value: ctx.currentUserId
|
|
602
|
+
},
|
|
603
|
+
right: {
|
|
604
|
+
type: 'comparison',
|
|
605
|
+
field: 'creator_id',
|
|
606
|
+
operator: '==',
|
|
607
|
+
value: ctx.currentUserId
|
|
608
|
+
}
|
|
609
|
+
})
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
const context: IMockContext = {
|
|
614
|
+
currentUserId: 123,
|
|
615
|
+
currentUserTeamIds: []
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
const expr: IComparisonExpression = {
|
|
619
|
+
type: 'comparison',
|
|
620
|
+
field: 'myItems',
|
|
621
|
+
operator: '==',
|
|
622
|
+
value: 'all'
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
const resolved = resolveVirtualFields(
|
|
626
|
+
expr,
|
|
627
|
+
virtualFields,
|
|
628
|
+
context
|
|
629
|
+
) as ILogicalExpression;
|
|
630
|
+
|
|
631
|
+
expect(resolved.type).toBe('logical');
|
|
632
|
+
expect(resolved.operator).toBe('OR');
|
|
633
|
+
expect((resolved.left as IComparisonExpression).field).toBe(
|
|
634
|
+
'assignee_id'
|
|
635
|
+
);
|
|
636
|
+
expect((resolved.right as IComparisonExpression).field).toBe(
|
|
637
|
+
'creator_id'
|
|
638
|
+
);
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
describe('Edge Cases', () => {
|
|
643
|
+
it('should handle empty virtualFields config', () => {
|
|
644
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {};
|
|
645
|
+
|
|
646
|
+
const context: IMockContext = {
|
|
647
|
+
currentUserId: 123,
|
|
648
|
+
currentUserTeamIds: []
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
const expr: IComparisonExpression = {
|
|
652
|
+
type: 'comparison',
|
|
653
|
+
field: 'status',
|
|
654
|
+
operator: '==',
|
|
655
|
+
value: 'done'
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
const resolved = resolveVirtualFields(expr, virtualFields, context);
|
|
659
|
+
|
|
660
|
+
expect(resolved).toEqual(expr);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it('should handle null context values', () => {
|
|
664
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
665
|
+
my: {
|
|
666
|
+
allowedValues: ['assigned'] as const,
|
|
667
|
+
resolve: (_input, ctx) => ({
|
|
668
|
+
type: 'comparison',
|
|
669
|
+
field: 'assignee_id',
|
|
670
|
+
operator: '==',
|
|
671
|
+
value: ctx.currentUserId ?? null
|
|
672
|
+
})
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
const context: IMockContext = {
|
|
677
|
+
currentUserId: undefined as unknown as number,
|
|
678
|
+
currentUserTeamIds: []
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
const expr: IComparisonExpression = {
|
|
682
|
+
type: 'comparison',
|
|
683
|
+
field: 'my',
|
|
684
|
+
operator: '==',
|
|
685
|
+
value: 'assigned'
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
const resolved = resolveVirtualFields(expr, virtualFields, context);
|
|
689
|
+
|
|
690
|
+
// The resolver uses ?? null, so when currentUserId is undefined, it becomes null
|
|
691
|
+
expect((resolved as IComparisonExpression).value).toBeNull();
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it('should handle multiple values for the same virtual field', () => {
|
|
695
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
696
|
+
my: {
|
|
697
|
+
allowedValues: ['assigned', 'created', 'watching'] as const,
|
|
698
|
+
resolve: (input, ctx, { fields }) => {
|
|
699
|
+
const fieldMap = fields({
|
|
700
|
+
assigned: 'assignee_id',
|
|
701
|
+
created: 'creator_id',
|
|
702
|
+
watching: 'watcher_ids'
|
|
703
|
+
});
|
|
704
|
+
return {
|
|
705
|
+
type: 'comparison',
|
|
706
|
+
field:
|
|
707
|
+
fieldMap[input.value as 'assigned' | 'created' | 'watching'],
|
|
708
|
+
operator: '==',
|
|
709
|
+
value: ctx.currentUserId
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
const context: IMockContext = {
|
|
716
|
+
currentUserId: 123,
|
|
717
|
+
currentUserTeamIds: []
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
// Test each allowed value
|
|
721
|
+
const values = ['assigned', 'created', 'watching'] as const;
|
|
722
|
+
const expectedFields = ['assignee_id', 'creator_id', 'watcher_ids'];
|
|
723
|
+
|
|
724
|
+
values.forEach((value, index) => {
|
|
725
|
+
const expr: IComparisonExpression = {
|
|
726
|
+
type: 'comparison',
|
|
727
|
+
field: 'my',
|
|
728
|
+
operator: '==',
|
|
729
|
+
value: value
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
const resolved = resolveVirtualFields(expr, virtualFields, context);
|
|
733
|
+
|
|
734
|
+
expect((resolved as IComparisonExpression).field).toBe(
|
|
735
|
+
expectedFields[index]
|
|
736
|
+
);
|
|
737
|
+
});
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
describe('Type Safety (Documented)', () => {
|
|
742
|
+
it('documents that fields() helper provides compile-time validation', () => {
|
|
743
|
+
// This test documents the type-safety feature.
|
|
744
|
+
// The actual validation happens at compile-time via TypeScript.
|
|
745
|
+
|
|
746
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
747
|
+
my: {
|
|
748
|
+
allowedValues: ['assigned', 'created'] as const,
|
|
749
|
+
resolve: (input, ctx, { fields }) => {
|
|
750
|
+
// TypeScript validates that all keys in allowedValues are mapped
|
|
751
|
+
// and all values are valid schema fields
|
|
752
|
+
const fieldMap = fields({
|
|
753
|
+
assigned: 'assignee_id',
|
|
754
|
+
created: 'creator_id'
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
// This would cause a TypeScript error if uncommented:
|
|
758
|
+
// const badMap = fields({
|
|
759
|
+
// assigned: 'invalid_field' // Error: not a valid schema field
|
|
760
|
+
// });
|
|
761
|
+
|
|
762
|
+
// This would also cause a TypeScript error:
|
|
763
|
+
// const incompleteMap = fields({
|
|
764
|
+
// assigned: 'assignee_id'
|
|
765
|
+
// // Missing 'created' key
|
|
766
|
+
// });
|
|
767
|
+
|
|
768
|
+
return {
|
|
769
|
+
type: 'comparison',
|
|
770
|
+
field: fieldMap[input.value as 'assigned' | 'created'],
|
|
771
|
+
operator: '==',
|
|
772
|
+
value: ctx.currentUserId
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
// The fields() helper is an identity function at runtime
|
|
779
|
+
expect(typeof virtualFields.my.resolve).toBe('function');
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
it('should validate field mapping keys at runtime', () => {
|
|
783
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
784
|
+
my: {
|
|
785
|
+
allowedValues: ['assigned', 'created'] as const,
|
|
786
|
+
resolve: (input, ctx, { fields }) => {
|
|
787
|
+
// This invalid mapping should throw at runtime
|
|
788
|
+
// because 'invalid' is not in allowedValues
|
|
789
|
+
// We use Record<string, string> to bypass compile-time checks
|
|
790
|
+
// and test runtime validation
|
|
791
|
+
const fieldMap = fields({
|
|
792
|
+
assigned: 'assignee_id',
|
|
793
|
+
created: 'creator_id',
|
|
794
|
+
invalid: 'assignee_id'
|
|
795
|
+
} as Record<string, string> as SchemaFieldMap<
|
|
796
|
+
'assigned' | 'created',
|
|
797
|
+
MockSchema
|
|
798
|
+
>);
|
|
799
|
+
|
|
800
|
+
return {
|
|
801
|
+
type: 'comparison',
|
|
802
|
+
field: fieldMap[input.value as 'assigned' | 'created'],
|
|
803
|
+
operator: '==',
|
|
804
|
+
value: ctx.currentUserId
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
const context: IMockContext = {
|
|
811
|
+
currentUserId: 123,
|
|
812
|
+
currentUserTeamIds: []
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
const expr: IComparisonExpression = {
|
|
816
|
+
type: 'comparison',
|
|
817
|
+
field: 'my',
|
|
818
|
+
operator: '==',
|
|
819
|
+
value: 'assigned'
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
expect(() => {
|
|
823
|
+
resolveVirtualFields(expr, virtualFields, context);
|
|
824
|
+
}).toThrow(QueryParseError);
|
|
825
|
+
|
|
826
|
+
expect(() => {
|
|
827
|
+
resolveVirtualFields(expr, virtualFields, context);
|
|
828
|
+
}).toThrow('Invalid key "invalid" in field mapping');
|
|
829
|
+
});
|
|
830
|
+
});
|
|
831
|
+
});
|