@gblikas/querykit 0.3.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/.husky/pre-commit +3 -3
- package/README.md +347 -0
- package/dist/index.d.ts +36 -3
- package/dist/index.js +20 -3
- 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 +184 -85
- 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/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/.husky/pre-commit
CHANGED
|
@@ -14,16 +14,16 @@ echo "Running pre-commit checks..."
|
|
|
14
14
|
|
|
15
15
|
# Run lint-staged to process only changed files
|
|
16
16
|
echo "๐ Running lint-staged..."
|
|
17
|
-
|
|
17
|
+
npx lint-staged || { echo "โ Linting failed"; exit 1; }
|
|
18
18
|
|
|
19
19
|
# Check TypeScript compilation
|
|
20
20
|
echo "๐ Checking TypeScript compilation..."
|
|
21
|
-
|
|
21
|
+
npx tsc --noEmit || { echo "โ TypeScript check failed"; exit 1; }
|
|
22
22
|
|
|
23
23
|
# Run tests
|
|
24
24
|
if [ "$RUN_TESTS" = "true" ]; then
|
|
25
25
|
echo "๐งช Running tests..."
|
|
26
|
-
|
|
26
|
+
npm test || { echo "โ Tests failed"; exit 1; }
|
|
27
27
|
fi
|
|
28
28
|
|
|
29
29
|
# If everything passes, allow the commit
|
package/README.md
CHANGED
|
@@ -434,6 +434,352 @@ function SearchBar({ value, onChange }) {
|
|
|
434
434
|
}
|
|
435
435
|
```
|
|
436
436
|
|
|
437
|
+
## Virtual Fields
|
|
438
|
+
|
|
439
|
+
Virtual fields enable powerful shortcuts in your queries that expand to real schema fields at query execution time based on runtime context. This allows you to support queries like `my:assigned` which expands to `assignee_id == <current_user_id>` using the currently logged-in user's ID.
|
|
440
|
+
|
|
441
|
+
### Why Virtual Fields?
|
|
442
|
+
|
|
443
|
+
Virtual fields are useful when:
|
|
444
|
+
- You want to provide user-friendly shortcuts (e.g., `my:assigned` instead of `assignee_id:123`)
|
|
445
|
+
- The query depends on runtime context (current user, permissions, tenant, etc.)
|
|
446
|
+
- You want to abstract complex field mappings from end users
|
|
447
|
+
- You need consistent query shortcuts across your application
|
|
448
|
+
|
|
449
|
+
### Basic Usage
|
|
450
|
+
|
|
451
|
+
Define virtual fields when creating your QueryKit instance:
|
|
452
|
+
|
|
453
|
+
```typescript
|
|
454
|
+
import { createQueryKit } from '@gblikas/querykit';
|
|
455
|
+
import { drizzleAdapter } from '@gblikas/querykit/adapters/drizzle';
|
|
456
|
+
|
|
457
|
+
const qk = createQueryKit({
|
|
458
|
+
adapter: drizzleAdapter,
|
|
459
|
+
schema: { tasks, users },
|
|
460
|
+
|
|
461
|
+
// Define virtual fields
|
|
462
|
+
virtualFields: {
|
|
463
|
+
my: {
|
|
464
|
+
allowedValues: ['assigned', 'created', 'watching'] as const,
|
|
465
|
+
description: 'Filter by your relationship to items',
|
|
466
|
+
|
|
467
|
+
resolve: (input, ctx, { fields }) => {
|
|
468
|
+
// Map virtual values to real schema fields
|
|
469
|
+
const fieldMap = fields({
|
|
470
|
+
assigned: 'assignee_id',
|
|
471
|
+
created: 'creator_id',
|
|
472
|
+
watching: 'watcher_ids'
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
return {
|
|
476
|
+
type: 'comparison',
|
|
477
|
+
field: fieldMap[input.value],
|
|
478
|
+
operator: '==',
|
|
479
|
+
value: ctx.currentUserId
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
|
|
485
|
+
// Provide runtime context
|
|
486
|
+
createContext: async () => ({
|
|
487
|
+
currentUserId: await getCurrentUserId(),
|
|
488
|
+
currentUserTeamIds: await getUserTeamIds()
|
|
489
|
+
})
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// Use virtual fields in queries
|
|
493
|
+
const myTasks = await qk
|
|
494
|
+
.query('tasks')
|
|
495
|
+
.where('my:assigned AND status:active')
|
|
496
|
+
.execute();
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
### Configuration Options
|
|
500
|
+
|
|
501
|
+
Each virtual field definition supports:
|
|
502
|
+
|
|
503
|
+
```typescript
|
|
504
|
+
{
|
|
505
|
+
// Required: allowed values for this virtual field
|
|
506
|
+
allowedValues: ['value1', 'value2'] as const,
|
|
507
|
+
|
|
508
|
+
// Optional: allow comparison operators (>, <, >=, <=)
|
|
509
|
+
// Default: false (only equality ":" is allowed)
|
|
510
|
+
allowOperators?: boolean,
|
|
511
|
+
|
|
512
|
+
// Required: resolver function
|
|
513
|
+
resolve: (input, context, helpers) => {
|
|
514
|
+
// Return a query expression that replaces the virtual field
|
|
515
|
+
return {
|
|
516
|
+
type: 'comparison',
|
|
517
|
+
field: 'real_field',
|
|
518
|
+
operator: '==',
|
|
519
|
+
value: context.someValue
|
|
520
|
+
};
|
|
521
|
+
},
|
|
522
|
+
|
|
523
|
+
// Optional: human-readable description
|
|
524
|
+
description?: string,
|
|
525
|
+
|
|
526
|
+
// Optional: descriptions for each value
|
|
527
|
+
valueDescriptions?: {
|
|
528
|
+
value1: 'Description of value1',
|
|
529
|
+
value2: 'Description of value2'
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
### Type-Safe Field Mapping
|
|
535
|
+
|
|
536
|
+
The `fields()` helper provides compile-time validation that all mapped fields exist in your schema:
|
|
537
|
+
|
|
538
|
+
```typescript
|
|
539
|
+
virtualFields: {
|
|
540
|
+
my: {
|
|
541
|
+
allowedValues: ['assigned', 'created'] as const,
|
|
542
|
+
resolve: (input, ctx, { fields }) => {
|
|
543
|
+
// TypeScript validates:
|
|
544
|
+
// 1. All allowedValues keys are mapped
|
|
545
|
+
// 2. All field values exist in the schema
|
|
546
|
+
const fieldMap = fields({
|
|
547
|
+
assigned: 'assignee_id', // โ Valid schema field
|
|
548
|
+
created: 'creator_id' // โ Valid schema field
|
|
549
|
+
// Missing 'watching' โ TypeScript error!
|
|
550
|
+
// assigned: 'invalid_field' โ TypeScript error!
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
return {
|
|
554
|
+
type: 'comparison',
|
|
555
|
+
field: fieldMap[input.value],
|
|
556
|
+
operator: '==',
|
|
557
|
+
value: ctx.currentUserId
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
### Context Factory
|
|
565
|
+
|
|
566
|
+
The `createContext` function is called once per query execution to provide runtime values:
|
|
567
|
+
|
|
568
|
+
```typescript
|
|
569
|
+
createContext: async () => {
|
|
570
|
+
const user = await getCurrentUser();
|
|
571
|
+
const permissions = await getUserPermissions(user.id);
|
|
572
|
+
|
|
573
|
+
return {
|
|
574
|
+
currentUserId: user.id,
|
|
575
|
+
currentUserTeamIds: user.teamIds,
|
|
576
|
+
canSeeArchived: permissions.includes('view:archived')
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
Context is type-safe and can include any data your resolvers need:
|
|
582
|
+
|
|
583
|
+
```typescript
|
|
584
|
+
interface MyQueryContext extends IQueryContext {
|
|
585
|
+
currentUserId: number;
|
|
586
|
+
currentUserTeamIds: number[];
|
|
587
|
+
canSeeArchived: boolean;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const qk = createQueryKit<typeof schema, MyQueryContext>({
|
|
591
|
+
// ... configuration
|
|
592
|
+
});
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
### Complex Resolvers
|
|
596
|
+
|
|
597
|
+
Virtual fields can return logical expressions for more complex scenarios:
|
|
598
|
+
|
|
599
|
+
```typescript
|
|
600
|
+
virtualFields: {
|
|
601
|
+
myItems: {
|
|
602
|
+
allowedValues: ['all'] as const,
|
|
603
|
+
resolve: (input, ctx) => ({
|
|
604
|
+
// Return a logical OR expression
|
|
605
|
+
type: 'logical',
|
|
606
|
+
operator: 'OR',
|
|
607
|
+
left: {
|
|
608
|
+
type: 'comparison',
|
|
609
|
+
field: 'assignee_id',
|
|
610
|
+
operator: '==',
|
|
611
|
+
value: ctx.currentUserId
|
|
612
|
+
},
|
|
613
|
+
right: {
|
|
614
|
+
type: 'comparison',
|
|
615
|
+
field: 'creator_id',
|
|
616
|
+
operator: '==',
|
|
617
|
+
value: ctx.currentUserId
|
|
618
|
+
}
|
|
619
|
+
})
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Expands to: (assignee_id == currentUserId OR creator_id == currentUserId)
|
|
624
|
+
await qk.query('tasks').where('myItems:all').execute();
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
### Allowing Comparison Operators
|
|
628
|
+
|
|
629
|
+
By default, only equality (`:`) is allowed. Enable other operators with `allowOperators: true`:
|
|
630
|
+
|
|
631
|
+
```typescript
|
|
632
|
+
virtualFields: {
|
|
633
|
+
priority: {
|
|
634
|
+
allowedValues: ['high', 'low'] as const,
|
|
635
|
+
allowOperators: true, // Enable >, <, etc.
|
|
636
|
+
|
|
637
|
+
resolve: (input, ctx) => {
|
|
638
|
+
const threshold = input.value === 'high' ? 7 : 3;
|
|
639
|
+
|
|
640
|
+
return {
|
|
641
|
+
type: 'comparison',
|
|
642
|
+
field: 'priority',
|
|
643
|
+
operator: input.operator, // Use the operator from the query
|
|
644
|
+
value: threshold
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Both work:
|
|
651
|
+
qk.query('tasks').where('priority:high') // priority == 7
|
|
652
|
+
qk.query('tasks').where('priority:>high') // priority > 7
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
### Error Handling
|
|
656
|
+
|
|
657
|
+
QueryKit throws `QueryParseError` for invalid virtual field usage:
|
|
658
|
+
|
|
659
|
+
```typescript
|
|
660
|
+
// Invalid value
|
|
661
|
+
qk.query('tasks').where('my:invalid')
|
|
662
|
+
// Error: Invalid value "invalid" for virtual field "my".
|
|
663
|
+
// Allowed values: "assigned", "created", "watching"
|
|
664
|
+
|
|
665
|
+
// Operator not allowed (when allowOperators: false)
|
|
666
|
+
qk.query('tasks').where('my:>assigned')
|
|
667
|
+
// Error: Virtual field "my" does not allow comparison operators.
|
|
668
|
+
// Only equality (":") is permitted.
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
### Complete Example
|
|
672
|
+
|
|
673
|
+
Here's a full example with multiple virtual fields:
|
|
674
|
+
|
|
675
|
+
```typescript
|
|
676
|
+
import { createQueryKit, IQueryContext, ComparisonOperator } from '@gblikas/querykit';
|
|
677
|
+
import { drizzleAdapter } from '@gblikas/querykit/adapters/drizzle';
|
|
678
|
+
|
|
679
|
+
// Define your context type
|
|
680
|
+
interface TaskQueryContext extends IQueryContext {
|
|
681
|
+
currentUserId: number;
|
|
682
|
+
currentUserTeamIds: number[];
|
|
683
|
+
currentTenantId: string;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Create QueryKit with virtual fields
|
|
687
|
+
const qk = createQueryKit<typeof schema, TaskQueryContext>({
|
|
688
|
+
adapter: drizzleAdapter,
|
|
689
|
+
schema: { tasks, users },
|
|
690
|
+
|
|
691
|
+
virtualFields: {
|
|
692
|
+
// User relationship shortcuts
|
|
693
|
+
my: {
|
|
694
|
+
allowedValues: ['assigned', 'created', 'watching'] as const,
|
|
695
|
+
description: 'Filter by your relationship to tasks',
|
|
696
|
+
valueDescriptions: {
|
|
697
|
+
assigned: 'Tasks assigned to you',
|
|
698
|
+
created: 'Tasks you created',
|
|
699
|
+
watching: 'Tasks you are watching'
|
|
700
|
+
},
|
|
701
|
+
resolve: (input, ctx, { fields }) => {
|
|
702
|
+
const fieldMap = fields({
|
|
703
|
+
assigned: 'assignee_id',
|
|
704
|
+
created: 'creator_id',
|
|
705
|
+
watching: 'watcher_ids'
|
|
706
|
+
});
|
|
707
|
+
return {
|
|
708
|
+
type: 'comparison',
|
|
709
|
+
field: fieldMap[input.value],
|
|
710
|
+
operator: '==',
|
|
711
|
+
value: ctx.currentUserId
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
},
|
|
715
|
+
|
|
716
|
+
// Team shortcuts
|
|
717
|
+
team: {
|
|
718
|
+
allowedValues: ['assigned', 'owned'] as const,
|
|
719
|
+
description: 'Filter by team relationship',
|
|
720
|
+
resolve: (input, ctx, { fields }) => {
|
|
721
|
+
const fieldMap = fields({
|
|
722
|
+
assigned: 'assignee_id',
|
|
723
|
+
owned: 'owner_id'
|
|
724
|
+
});
|
|
725
|
+
return {
|
|
726
|
+
type: 'comparison',
|
|
727
|
+
field: fieldMap[input.value],
|
|
728
|
+
operator: 'IN',
|
|
729
|
+
value: ctx.currentUserTeamIds
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
},
|
|
733
|
+
|
|
734
|
+
// Priority shortcuts with operators
|
|
735
|
+
priority: {
|
|
736
|
+
allowedValues: ['critical', 'high', 'normal', 'low'] as const,
|
|
737
|
+
allowOperators: true,
|
|
738
|
+
description: 'Filter by priority level',
|
|
739
|
+
resolve: (input) => {
|
|
740
|
+
const priorityMap = {
|
|
741
|
+
critical: 10,
|
|
742
|
+
high: 7,
|
|
743
|
+
normal: 5,
|
|
744
|
+
low: 3
|
|
745
|
+
};
|
|
746
|
+
return {
|
|
747
|
+
type: 'comparison',
|
|
748
|
+
field: 'priority',
|
|
749
|
+
operator: input.operator as ComparisonOperator,
|
|
750
|
+
value: priorityMap[input.value as keyof typeof priorityMap]
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
},
|
|
755
|
+
|
|
756
|
+
// Context factory
|
|
757
|
+
createContext: async () => {
|
|
758
|
+
const user = await getCurrentUser();
|
|
759
|
+
const teams = await getUserTeams(user.id);
|
|
760
|
+
|
|
761
|
+
return {
|
|
762
|
+
currentUserId: user.id,
|
|
763
|
+
currentUserTeamIds: teams.map(t => t.id),
|
|
764
|
+
currentTenantId: user.tenantId
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
// Example queries using virtual fields
|
|
770
|
+
// "my:assigned AND status:active"
|
|
771
|
+
// "team:assigned OR my:created"
|
|
772
|
+
// "priority:>high AND my:watching"
|
|
773
|
+
// "(my:assigned OR team:assigned) AND status:active"
|
|
774
|
+
|
|
775
|
+
const results = await qk
|
|
776
|
+
.query('tasks')
|
|
777
|
+
.where('my:assigned AND priority:>high')
|
|
778
|
+
.orderBy('created_at', 'desc')
|
|
779
|
+
.limit(10)
|
|
780
|
+
.execute();
|
|
781
|
+
```
|
|
782
|
+
|
|
437
783
|
## Roadmap
|
|
438
784
|
|
|
439
785
|
### Core Parsing Engine and DSL
|
|
@@ -459,6 +805,7 @@ function SearchBar({ value, onChange }) {
|
|
|
459
805
|
- [x] Support for complex nested expressions
|
|
460
806
|
- [ ] Custom function support
|
|
461
807
|
- [ ] Pagination helpers
|
|
808
|
+
- [x] Virtual fields for context-aware query expansion
|
|
462
809
|
|
|
463
810
|
### Ecosystem Expansion
|
|
464
811
|
- [x] Frontend query builder components (input parser)
|
package/dist/index.d.ts
CHANGED
|
@@ -10,9 +10,11 @@ import { QueryParser, IParserOptions } from './parser';
|
|
|
10
10
|
import { SqlTranslator } from './translators/sql';
|
|
11
11
|
import { ISecurityOptions } from './security';
|
|
12
12
|
import { IAdapter, IAdapterOptions } from './adapters';
|
|
13
|
+
import { IQueryContext, VirtualFieldsConfig } from './virtual-fields';
|
|
13
14
|
export { QueryParser, IParserOptions, QueryBuilder, IQueryBuilderOptions, SqlTranslator };
|
|
14
15
|
export * from './translators';
|
|
15
16
|
export * from './adapters';
|
|
17
|
+
export * from './virtual-fields';
|
|
16
18
|
/**
|
|
17
19
|
* Create a new QueryBuilder instance
|
|
18
20
|
*/
|
|
@@ -24,7 +26,7 @@ export declare function createQueryParser(options?: IParserOptions): QueryParser
|
|
|
24
26
|
/**
|
|
25
27
|
* Options for creating a new QueryKit instance
|
|
26
28
|
*/
|
|
27
|
-
export interface IQueryKitOptions<TSchema extends Record<string, object> = Record<string, Record<string, unknown
|
|
29
|
+
export interface IQueryKitOptions<TSchema extends Record<string, object> = Record<string, Record<string, unknown>>, TContext extends IQueryContext = IQueryContext> {
|
|
28
30
|
/**
|
|
29
31
|
* The adapter to use for database connections
|
|
30
32
|
*/
|
|
@@ -43,6 +45,37 @@ export interface IQueryKitOptions<TSchema extends Record<string, object> = Recor
|
|
|
43
45
|
adapterOptions?: IAdapterOptions & {
|
|
44
46
|
[key: string]: unknown;
|
|
45
47
|
};
|
|
48
|
+
/**
|
|
49
|
+
* Virtual field definitions for context-aware query expansion.
|
|
50
|
+
* Virtual fields allow shortcuts like `my:assigned` that expand to
|
|
51
|
+
* real schema fields at query execution time.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* virtualFields: {
|
|
55
|
+
* my: {
|
|
56
|
+
* allowedValues: ['assigned', 'created'] as const,
|
|
57
|
+
* resolve: (input, ctx, { fields }) => ({
|
|
58
|
+
* type: 'comparison',
|
|
59
|
+
* field: fields({ assigned: 'assignee_id', created: 'creator_id' })[input.value],
|
|
60
|
+
* operator: '==',
|
|
61
|
+
* value: ctx.currentUserId
|
|
62
|
+
* })
|
|
63
|
+
* }
|
|
64
|
+
* }
|
|
65
|
+
*/
|
|
66
|
+
virtualFields?: VirtualFieldsConfig<TSchema, TContext>;
|
|
67
|
+
/**
|
|
68
|
+
* Factory function to create query execution context.
|
|
69
|
+
* Called once per query execution to provide runtime values
|
|
70
|
+
* for virtual field resolution.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* createContext: async () => ({
|
|
74
|
+
* currentUserId: await getCurrentUserId(),
|
|
75
|
+
* currentUserTeamIds: await getUserTeamIds()
|
|
76
|
+
* })
|
|
77
|
+
*/
|
|
78
|
+
createContext?: () => TContext | Promise<TContext>;
|
|
46
79
|
}
|
|
47
80
|
export interface IQueryExecutor<TResult> {
|
|
48
81
|
execute(): Promise<TResult[]>;
|
|
@@ -66,10 +99,10 @@ export type QueryKit<TSchema extends Record<string, object>, TRows extends {
|
|
|
66
99
|
/**
|
|
67
100
|
* Create a new QueryKit instance
|
|
68
101
|
*/
|
|
69
|
-
export declare function createQueryKit<TSchema extends Record<string, object>, TRows extends {
|
|
102
|
+
export declare function createQueryKit<TSchema extends Record<string, object>, TContext extends IQueryContext = IQueryContext, TRows extends {
|
|
70
103
|
[K in keyof TSchema & string]: unknown;
|
|
71
104
|
} = {
|
|
72
105
|
[K in keyof TSchema & string]: unknown;
|
|
73
|
-
}>(options: IQueryKitOptions<TSchema>): QueryKit<TSchema, TRows>;
|
|
106
|
+
}>(options: IQueryKitOptions<TSchema, TContext>): QueryKit<TSchema, TRows>;
|
|
74
107
|
export * from './parser';
|
|
75
108
|
export * from './security';
|
package/dist/index.js
CHANGED
|
@@ -33,9 +33,11 @@ Object.defineProperty(exports, "QueryParser", { enumerable: true, get: function
|
|
|
33
33
|
const sql_1 = require("./translators/sql");
|
|
34
34
|
Object.defineProperty(exports, "SqlTranslator", { enumerable: true, get: function () { return sql_1.SqlTranslator; } });
|
|
35
35
|
const security_1 = require("./security");
|
|
36
|
+
const virtual_fields_1 = require("./virtual-fields");
|
|
36
37
|
// Re-export from modules
|
|
37
38
|
__exportStar(require("./translators"), exports);
|
|
38
39
|
__exportStar(require("./adapters"), exports);
|
|
40
|
+
__exportStar(require("./virtual-fields"), exports);
|
|
39
41
|
/**
|
|
40
42
|
* Create a new QueryBuilder instance
|
|
41
43
|
*/
|
|
@@ -75,9 +77,8 @@ function createQueryKit(options) {
|
|
|
75
77
|
query: (table) => {
|
|
76
78
|
return {
|
|
77
79
|
where: (queryString) => {
|
|
78
|
-
// Parse
|
|
80
|
+
// Parse the query
|
|
79
81
|
const expressionAst = parser.parse(queryString);
|
|
80
|
-
securityValidator.validate(expressionAst, options.schema);
|
|
81
82
|
// Execution state accumulated via fluent calls
|
|
82
83
|
let orderByState = {};
|
|
83
84
|
let limitState;
|
|
@@ -96,8 +97,24 @@ function createQueryKit(options) {
|
|
|
96
97
|
return executor;
|
|
97
98
|
},
|
|
98
99
|
execute: async () => {
|
|
100
|
+
// Validate that if virtual fields are configured, createContext must also be provided
|
|
101
|
+
if (options.virtualFields && !options.createContext) {
|
|
102
|
+
throw new Error('createContext must be provided when virtualFields is configured');
|
|
103
|
+
}
|
|
104
|
+
// Get context if virtual fields are configured
|
|
105
|
+
let context;
|
|
106
|
+
if (options.virtualFields && options.createContext) {
|
|
107
|
+
context = await options.createContext();
|
|
108
|
+
}
|
|
109
|
+
// Resolve virtual fields if configured and context is available
|
|
110
|
+
let resolvedExpression = expressionAst;
|
|
111
|
+
if (options.virtualFields && context) {
|
|
112
|
+
resolvedExpression = (0, virtual_fields_1.resolveVirtualFields)(expressionAst, options.virtualFields, context);
|
|
113
|
+
}
|
|
114
|
+
// Validate the resolved query
|
|
115
|
+
securityValidator.validate(resolvedExpression, options.schema);
|
|
99
116
|
// Delegate to adapter
|
|
100
|
-
const results = await options.adapter.execute(table,
|
|
117
|
+
const results = await options.adapter.execute(table, resolvedExpression, {
|
|
101
118
|
orderBy: Object.keys(orderByState).length > 0
|
|
102
119
|
? orderByState
|
|
103
120
|
: undefined,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Virtual Fields module exports
|
|
4
|
+
*/
|
|
5
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
6
|
+
if (k2 === undefined) k2 = k;
|
|
7
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
8
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
9
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
10
|
+
}
|
|
11
|
+
Object.defineProperty(o, k2, desc);
|
|
12
|
+
}) : (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
o[k2] = m[k];
|
|
15
|
+
}));
|
|
16
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
17
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
18
|
+
};
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
__exportStar(require("./types"), exports);
|
|
21
|
+
__exportStar(require("./resolver"), exports);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Virtual field resolution logic
|
|
3
|
+
*/
|
|
4
|
+
import { QueryExpression } from '../parser/types';
|
|
5
|
+
import { IQueryContext, VirtualFieldsConfig } from './types';
|
|
6
|
+
/**
|
|
7
|
+
* Resolve virtual fields in a query expression.
|
|
8
|
+
* Recursively walks the AST and replaces virtual field references with
|
|
9
|
+
* their resolved expressions based on the provided context.
|
|
10
|
+
*
|
|
11
|
+
* @param expr - The query expression to resolve
|
|
12
|
+
* @param virtualFields - Virtual field configuration
|
|
13
|
+
* @param context - Runtime context for resolution
|
|
14
|
+
* @returns The resolved query expression
|
|
15
|
+
* @throws {QueryParseError} If a virtual field value is invalid or operator is not allowed
|
|
16
|
+
*/
|
|
17
|
+
export declare function resolveVirtualFields<TSchema extends Record<string, object>, TContext extends IQueryContext>(expr: QueryExpression, virtualFields: VirtualFieldsConfig<TSchema, TContext>, context: TContext): QueryExpression;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Virtual field resolution logic
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.resolveVirtualFields = resolveVirtualFields;
|
|
7
|
+
const parser_1 = require("../parser/parser");
|
|
8
|
+
/**
|
|
9
|
+
* Resolve virtual fields in a query expression.
|
|
10
|
+
* Recursively walks the AST and replaces virtual field references with
|
|
11
|
+
* their resolved expressions based on the provided context.
|
|
12
|
+
*
|
|
13
|
+
* @param expr - The query expression to resolve
|
|
14
|
+
* @param virtualFields - Virtual field configuration
|
|
15
|
+
* @param context - Runtime context for resolution
|
|
16
|
+
* @returns The resolved query expression
|
|
17
|
+
* @throws {QueryParseError} If a virtual field value is invalid or operator is not allowed
|
|
18
|
+
*/
|
|
19
|
+
function resolveVirtualFields(expr, virtualFields, context) {
|
|
20
|
+
// Base case: comparison expression
|
|
21
|
+
if (expr.type === 'comparison') {
|
|
22
|
+
return resolveComparisonExpression(expr, virtualFields, context);
|
|
23
|
+
}
|
|
24
|
+
// Recursive case: logical expression
|
|
25
|
+
if (expr.type === 'logical') {
|
|
26
|
+
return resolveLogicalExpression(expr, virtualFields, context);
|
|
27
|
+
}
|
|
28
|
+
// Unknown expression type, return as-is
|
|
29
|
+
return expr;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Resolve a comparison expression.
|
|
33
|
+
* If the field is a virtual field, resolve it using the configuration.
|
|
34
|
+
* Otherwise, return the expression unchanged.
|
|
35
|
+
*/
|
|
36
|
+
function resolveComparisonExpression(expr, virtualFields, context) {
|
|
37
|
+
const fieldName = expr.field;
|
|
38
|
+
const virtualFieldDef = virtualFields[fieldName];
|
|
39
|
+
// Not a virtual field, return as-is
|
|
40
|
+
if (!virtualFieldDef) {
|
|
41
|
+
return expr;
|
|
42
|
+
}
|
|
43
|
+
// Validate the value is a string (virtual fields require string values)
|
|
44
|
+
if (typeof expr.value !== 'string') {
|
|
45
|
+
const valueType = Array.isArray(expr.value)
|
|
46
|
+
? `array (${JSON.stringify(expr.value)})`
|
|
47
|
+
: typeof expr.value === 'object'
|
|
48
|
+
? `object (${JSON.stringify(expr.value)})`
|
|
49
|
+
: typeof expr.value;
|
|
50
|
+
throw new parser_1.QueryParseError(`Virtual field "${fieldName}" requires a string value, got ${valueType}`);
|
|
51
|
+
}
|
|
52
|
+
const value = expr.value;
|
|
53
|
+
// Validate the value is in allowedValues
|
|
54
|
+
if (!virtualFieldDef.allowedValues.includes(value)) {
|
|
55
|
+
const allowedValuesStr = virtualFieldDef.allowedValues
|
|
56
|
+
.map(v => `"${v}"`)
|
|
57
|
+
.join(', ');
|
|
58
|
+
throw new parser_1.QueryParseError(`Invalid value "${value}" for virtual field "${fieldName}". Allowed values: ${allowedValuesStr}`);
|
|
59
|
+
}
|
|
60
|
+
// Validate operator usage
|
|
61
|
+
const allowOperators = virtualFieldDef.allowOperators ?? false;
|
|
62
|
+
if (!allowOperators && expr.operator !== '==') {
|
|
63
|
+
throw new parser_1.QueryParseError(`Virtual field "${fieldName}" does not allow comparison operators. Only equality (":") is permitted.`);
|
|
64
|
+
}
|
|
65
|
+
// Create the input for the resolver
|
|
66
|
+
const input = {
|
|
67
|
+
field: fieldName,
|
|
68
|
+
operator: expr.operator,
|
|
69
|
+
value: value
|
|
70
|
+
};
|
|
71
|
+
// Create the helpers object with type-safe fields() helper
|
|
72
|
+
// The fields() method is generic at the method level, allowing TypeScript to
|
|
73
|
+
// infer TValues from the mapping object at call-time without needing type assertions
|
|
74
|
+
const helpers = {
|
|
75
|
+
fields: (mapping) => {
|
|
76
|
+
// Validate that all keys in the mapping are in the virtual field's allowed values
|
|
77
|
+
const mappingKeys = Object.keys(mapping);
|
|
78
|
+
const allowedValues = virtualFieldDef.allowedValues;
|
|
79
|
+
for (const key of mappingKeys) {
|
|
80
|
+
if (!allowedValues.includes(key)) {
|
|
81
|
+
throw new parser_1.QueryParseError(`Invalid key "${key}" in field mapping for virtual field "${fieldName}". ` +
|
|
82
|
+
`Allowed keys are: ${allowedValues.map(v => `"${v}"`).join(', ')}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Runtime: this is just an identity function
|
|
86
|
+
// Compile-time: TypeScript validates the mapping structure
|
|
87
|
+
return mapping;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
// Resolve the virtual field - no type assertions needed!
|
|
91
|
+
const resolved = virtualFieldDef.resolve(input, context, helpers);
|
|
92
|
+
return resolved;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Resolve a logical expression.
|
|
96
|
+
* Recursively resolve both left and right sides.
|
|
97
|
+
*/
|
|
98
|
+
function resolveLogicalExpression(expr, virtualFields, context) {
|
|
99
|
+
return {
|
|
100
|
+
type: 'logical',
|
|
101
|
+
operator: expr.operator,
|
|
102
|
+
left: resolveVirtualFields(expr.left, virtualFields, context),
|
|
103
|
+
right: expr.right
|
|
104
|
+
? resolveVirtualFields(expr.right, virtualFields, context)
|
|
105
|
+
: undefined
|
|
106
|
+
};
|
|
107
|
+
}
|