@gblikas/querykit 0.3.0 โ 0.5.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 +539 -0
- package/dist/index.d.ts +36 -3
- package/dist/index.js +20 -3
- package/dist/parser/types.d.ts +17 -1
- package/dist/security/validator.js +14 -5
- package/dist/translators/drizzle/index.js +11 -0
- package/dist/virtual-fields/helpers.d.ts +32 -0
- package/dist/virtual-fields/helpers.js +74 -0
- package/dist/virtual-fields/index.d.ts +6 -0
- package/dist/virtual-fields/index.js +22 -0
- package/dist/virtual-fields/resolver.d.ts +17 -0
- package/dist/virtual-fields/resolver.js +111 -0
- package/dist/virtual-fields/types.d.ts +177 -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/parser/types.ts +21 -1
- package/src/security/validator.ts +15 -5
- package/src/translators/drizzle/index.ts +18 -0
- package/src/virtual-fields/helpers.ts +81 -0
- package/src/virtual-fields/index.ts +7 -0
- package/src/virtual-fields/integration.test.ts +338 -0
- package/src/virtual-fields/raw-sql.test.ts +978 -0
- package/src/virtual-fields/resolver.ts +170 -0
- package/src/virtual-fields/types.ts +223 -0
- package/src/virtual-fields/user-example-integration.test.ts +182 -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,543 @@ 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
|
+
|
|
783
|
+
### Raw SQL Expressions
|
|
784
|
+
|
|
785
|
+
For advanced query patterns that can't be expressed as simple field comparisons, virtual fields can return **raw SQL expressions**. This enables database-specific operations like JSONB array membership checks, date calculations, and custom SQL logic.
|
|
786
|
+
|
|
787
|
+
#### Why Raw SQL Expressions?
|
|
788
|
+
|
|
789
|
+
Use raw SQL expressions when you need to:
|
|
790
|
+
- **Check JSONB array membership** - e.g., `my:assigned` where `assignedTo` is a JSONB array
|
|
791
|
+
- **Implement computed/derived fields** - e.g., `priority:high` based on date calculations
|
|
792
|
+
- **Use database-specific functions** - e.g., PostGIS functions, full-text search
|
|
793
|
+
- **Combine multiple conditions** - e.g., custom business logic that requires complex SQL
|
|
794
|
+
|
|
795
|
+
#### Basic Example
|
|
796
|
+
|
|
797
|
+
```typescript
|
|
798
|
+
import { createQueryKit } from '@gblikas/querykit';
|
|
799
|
+
import { drizzleAdapter } from '@gblikas/querykit/adapters/drizzle';
|
|
800
|
+
import { jsonbContains, dateWithinDays } from '@gblikas/querykit/virtual-fields';
|
|
801
|
+
import { sql } from 'drizzle-orm';
|
|
802
|
+
|
|
803
|
+
interface MyContext extends IQueryContext {
|
|
804
|
+
currentUserId: string;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const qk = createQueryKit<typeof schema, MyContext>({
|
|
808
|
+
adapter: drizzleAdapter({ db, schema }),
|
|
809
|
+
schema,
|
|
810
|
+
|
|
811
|
+
virtualFields: {
|
|
812
|
+
// JSONB array contains example
|
|
813
|
+
my: {
|
|
814
|
+
allowedValues: ['assigned'] as const,
|
|
815
|
+
description: 'Filter by your relationship to items',
|
|
816
|
+
resolve: (input, ctx) => {
|
|
817
|
+
if (input.value === 'assigned') {
|
|
818
|
+
return jsonbContains('assigned_to', ctx.currentUserId);
|
|
819
|
+
}
|
|
820
|
+
throw new Error(`Unknown value: ${input.value}`);
|
|
821
|
+
}
|
|
822
|
+
},
|
|
823
|
+
|
|
824
|
+
// Computed priority based on createdAt
|
|
825
|
+
priority: {
|
|
826
|
+
allowedValues: ['high', 'medium', 'low'] as const,
|
|
827
|
+
description: 'Filter by computed priority (based on age)',
|
|
828
|
+
resolve: (input) => {
|
|
829
|
+
const thresholds = { high: 1, medium: 7, low: 30 };
|
|
830
|
+
const days = thresholds[input.value as keyof typeof thresholds];
|
|
831
|
+
return dateWithinDays('created_at', days);
|
|
832
|
+
}
|
|
833
|
+
},
|
|
834
|
+
|
|
835
|
+
// Custom raw SQL example
|
|
836
|
+
custom: {
|
|
837
|
+
allowedValues: ['active'] as const,
|
|
838
|
+
resolve: (input, ctx) => ({
|
|
839
|
+
type: 'raw',
|
|
840
|
+
toSql: () => sql`status = 'active' AND ${sql.identifier('owner_id')} = ${ctx.currentUserId}`
|
|
841
|
+
})
|
|
842
|
+
}
|
|
843
|
+
},
|
|
844
|
+
|
|
845
|
+
createContext: async () => ({
|
|
846
|
+
currentUserId: await getCurrentUserId()
|
|
847
|
+
})
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
// Queries that now work:
|
|
851
|
+
await qk.query('my_table').where('my:assigned').execute();
|
|
852
|
+
await qk.query('my_table').where('priority:high AND status:active').execute();
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
#### Helper Functions
|
|
856
|
+
|
|
857
|
+
QueryKit provides helper functions for common raw SQL patterns:
|
|
858
|
+
|
|
859
|
+
##### `jsonbContains(field, value)` - JSONB Array Membership (PostgreSQL)
|
|
860
|
+
|
|
861
|
+
Checks if a JSONB array field contains the given value:
|
|
862
|
+
|
|
863
|
+
```typescript
|
|
864
|
+
import { jsonbContains } from '@gblikas/querykit/virtual-fields';
|
|
865
|
+
|
|
866
|
+
// Check if assignedTo contains the current user ID
|
|
867
|
+
jsonbContains('assigned_to', ctx.currentUserId)
|
|
868
|
+
// Generates: assigned_to @> '["user123"]'::jsonb
|
|
869
|
+
|
|
870
|
+
// Works with arrays too
|
|
871
|
+
jsonbContains('tags', ['urgent', 'review'])
|
|
872
|
+
// Generates: tags @> '["urgent","review"]'::jsonb
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
##### `dateWithinDays(field, days)` - Date Range Check
|
|
876
|
+
|
|
877
|
+
Checks if a timestamp field is within the specified number of days from now:
|
|
878
|
+
|
|
879
|
+
```typescript
|
|
880
|
+
import { dateWithinDays } from '@gblikas/querykit/virtual-fields';
|
|
881
|
+
|
|
882
|
+
// Check if created within last day
|
|
883
|
+
dateWithinDays('created_at', 1)
|
|
884
|
+
// Generates: created_at >= NOW() - INTERVAL '1 days'
|
|
885
|
+
|
|
886
|
+
// Check if created within last week
|
|
887
|
+
dateWithinDays('created_at', 7)
|
|
888
|
+
// Generates: created_at >= NOW() - INTERVAL '7 days'
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
#### Custom Raw SQL
|
|
892
|
+
|
|
893
|
+
For complete control, create your own raw SQL expressions:
|
|
894
|
+
|
|
895
|
+
```typescript
|
|
896
|
+
virtualFields: {
|
|
897
|
+
custom: {
|
|
898
|
+
allowedValues: ['special'] as const,
|
|
899
|
+
resolve: (input, ctx) => ({
|
|
900
|
+
type: 'raw',
|
|
901
|
+
toSql: (context) => {
|
|
902
|
+
// context provides: adapter, tableName, schema
|
|
903
|
+
// Return a Drizzle SQL template
|
|
904
|
+
return sql`
|
|
905
|
+
${sql.identifier('status')} = 'active'
|
|
906
|
+
AND ${sql.identifier('owner_id')} = ${ctx.currentUserId}
|
|
907
|
+
AND ${sql.identifier('created_at')} >= NOW() - INTERVAL '30 days'
|
|
908
|
+
`;
|
|
909
|
+
}
|
|
910
|
+
})
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
```
|
|
914
|
+
|
|
915
|
+
#### Schema Example with JSONB
|
|
916
|
+
|
|
917
|
+
Here's a complete example with a Drizzle schema that uses JSONB:
|
|
918
|
+
|
|
919
|
+
```typescript
|
|
920
|
+
import { pgTable, serial, varchar, timestamp, jsonb } from 'drizzle-orm/pg-core';
|
|
921
|
+
import { sql } from 'drizzle-orm';
|
|
922
|
+
|
|
923
|
+
export const tasks = pgTable('tasks', {
|
|
924
|
+
id: serial('id').primaryKey(),
|
|
925
|
+
title: varchar('title', { length: 256 }),
|
|
926
|
+
description: varchar('description', { length: 1024 }),
|
|
927
|
+
createdAt: timestamp('created_at').defaultNow(),
|
|
928
|
+
status: varchar('status', { length: 50 }).default('open'),
|
|
929
|
+
// JSONB array of user IDs
|
|
930
|
+
assignedTo: jsonb('assigned_to')
|
|
931
|
+
.$type<string[]>()
|
|
932
|
+
.default(sql`'[]'`)
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
// QueryKit configuration
|
|
936
|
+
const qk = createQueryKit({
|
|
937
|
+
adapter: drizzleAdapter({ db, schema: { tasks } }),
|
|
938
|
+
schema: { tasks },
|
|
939
|
+
|
|
940
|
+
virtualFields: {
|
|
941
|
+
my: {
|
|
942
|
+
allowedValues: ['assigned'] as const,
|
|
943
|
+
resolve: (input, ctx) => jsonbContains('assigned_to', ctx.currentUserId)
|
|
944
|
+
}
|
|
945
|
+
},
|
|
946
|
+
|
|
947
|
+
createContext: async () => ({
|
|
948
|
+
currentUserId: await getCurrentUserId()
|
|
949
|
+
})
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
// Query: find my assigned tasks that are high priority
|
|
953
|
+
await qk.query('tasks')
|
|
954
|
+
.where('my:assigned AND priority:high')
|
|
955
|
+
.execute();
|
|
956
|
+
```
|
|
957
|
+
|
|
958
|
+
#### Combining with Standard Fields
|
|
959
|
+
|
|
960
|
+
Raw SQL expressions work seamlessly with standard field queries:
|
|
961
|
+
|
|
962
|
+
```typescript
|
|
963
|
+
// Mix virtual fields (raw SQL) with standard field queries
|
|
964
|
+
await qk.query('tasks')
|
|
965
|
+
.where('my:assigned AND status:active AND priority:>5')
|
|
966
|
+
.execute();
|
|
967
|
+
|
|
968
|
+
// Complex nested queries
|
|
969
|
+
await qk.query('tasks')
|
|
970
|
+
.where('(my:assigned OR priority:high) AND NOT status:closed')
|
|
971
|
+
.execute();
|
|
972
|
+
```
|
|
973
|
+
|
|
437
974
|
## Roadmap
|
|
438
975
|
|
|
439
976
|
### Core Parsing Engine and DSL
|
|
@@ -457,8 +994,10 @@ function SearchBar({ value, onChange }) {
|
|
|
457
994
|
- [ ] CLI tools for testing and debugging
|
|
458
995
|
- [x] Performance optimizations for SQL generation
|
|
459
996
|
- [x] Support for complex nested expressions
|
|
997
|
+
- [x] Raw SQL expressions for virtual fields (JSONB, computed fields, custom SQL)
|
|
460
998
|
- [ ] Custom function support
|
|
461
999
|
- [ ] Pagination helpers
|
|
1000
|
+
- [x] Virtual fields for context-aware query expansion
|
|
462
1001
|
|
|
463
1002
|
### Ecosystem Expansion
|
|
464
1003
|
- [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,
|