@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.
Files changed (39) hide show
  1. package/.cursor/BUGBOT.md +65 -2
  2. package/.husky/pre-commit +3 -3
  3. package/README.md +510 -1
  4. package/dist/index.d.ts +36 -3
  5. package/dist/index.js +20 -3
  6. package/dist/parser/index.d.ts +1 -0
  7. package/dist/parser/index.js +1 -0
  8. package/dist/parser/input-parser.d.ts +215 -0
  9. package/dist/parser/input-parser.js +493 -0
  10. package/dist/parser/parser.d.ts +114 -1
  11. package/dist/parser/parser.js +716 -0
  12. package/dist/parser/types.d.ts +432 -0
  13. package/dist/virtual-fields/index.d.ts +5 -0
  14. package/dist/virtual-fields/index.js +21 -0
  15. package/dist/virtual-fields/resolver.d.ts +17 -0
  16. package/dist/virtual-fields/resolver.js +107 -0
  17. package/dist/virtual-fields/types.d.ts +160 -0
  18. package/dist/virtual-fields/types.js +5 -0
  19. package/examples/qk-next/app/page.tsx +190 -86
  20. package/examples/qk-next/package.json +1 -1
  21. package/package.json +2 -2
  22. package/src/adapters/drizzle/index.ts +3 -3
  23. package/src/index.ts +77 -8
  24. package/src/parser/divergence.test.ts +357 -0
  25. package/src/parser/index.ts +2 -1
  26. package/src/parser/input-parser.test.ts +770 -0
  27. package/src/parser/input-parser.ts +697 -0
  28. package/src/parser/parse-with-context-suggestions.test.ts +360 -0
  29. package/src/parser/parse-with-context-validation.test.ts +447 -0
  30. package/src/parser/parse-with-context.test.ts +325 -0
  31. package/src/parser/parser.ts +872 -0
  32. package/src/parser/token-consistency.test.ts +341 -0
  33. package/src/parser/types.ts +545 -23
  34. package/src/virtual-fields/index.ts +6 -0
  35. package/src/virtual-fields/integration.test.ts +338 -0
  36. package/src/virtual-fields/resolver.ts +165 -0
  37. package/src/virtual-fields/types.ts +203 -0
  38. package/src/virtual-fields/virtual-fields.test.ts +831 -0
  39. 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
+ });