@gblikas/querykit 0.4.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/README.md +192 -0
- 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 +1 -0
- package/dist/virtual-fields/index.js +1 -0
- package/dist/virtual-fields/resolver.js +4 -0
- package/dist/virtual-fields/types.d.ts +20 -3
- package/package.json +1 -1
- 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 +1 -0
- package/src/virtual-fields/raw-sql.test.ts +978 -0
- package/src/virtual-fields/resolver.ts +5 -0
- package/src/virtual-fields/types.ts +22 -2
- package/src/virtual-fields/user-example-integration.test.ts +182 -0
package/README.md
CHANGED
|
@@ -780,6 +780,197 @@ const results = await qk
|
|
|
780
780
|
.execute();
|
|
781
781
|
```
|
|
782
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
|
+
|
|
783
974
|
## Roadmap
|
|
784
975
|
|
|
785
976
|
### Core Parsing Engine and DSL
|
|
@@ -803,6 +994,7 @@ const results = await qk
|
|
|
803
994
|
- [ ] CLI tools for testing and debugging
|
|
804
995
|
- [x] Performance optimizations for SQL generation
|
|
805
996
|
- [x] Support for complex nested expressions
|
|
997
|
+
- [x] Raw SQL expressions for virtual fields (JSONB, computed fields, custom SQL)
|
|
806
998
|
- [ ] Custom function support
|
|
807
999
|
- [ ] Pagination helpers
|
|
808
1000
|
- [x] Virtual fields for context-aware query expansion
|
package/dist/parser/types.d.ts
CHANGED
|
@@ -31,10 +31,26 @@ export interface ILogicalExpression {
|
|
|
31
31
|
left: QueryExpression;
|
|
32
32
|
right?: QueryExpression;
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Represents a raw SQL expression node in the AST.
|
|
36
|
+
* Used by virtual fields to inject database-specific SQL operations.
|
|
37
|
+
*/
|
|
38
|
+
export interface IRawSqlExpression {
|
|
39
|
+
type: 'raw';
|
|
40
|
+
/**
|
|
41
|
+
* Function that generates the raw SQL for the adapter.
|
|
42
|
+
* For Drizzle, this should return a SQL template result.
|
|
43
|
+
*/
|
|
44
|
+
toSql: (context: {
|
|
45
|
+
adapter: string;
|
|
46
|
+
tableName: string;
|
|
47
|
+
schema: Record<string, unknown>;
|
|
48
|
+
}) => unknown;
|
|
49
|
+
}
|
|
34
50
|
/**
|
|
35
51
|
* Represents any valid query expression node
|
|
36
52
|
*/
|
|
37
|
-
export type QueryExpression = IComparisonExpression | ILogicalExpression;
|
|
53
|
+
export type QueryExpression = IComparisonExpression | ILogicalExpression | IRawSqlExpression;
|
|
38
54
|
/**
|
|
39
55
|
* Configuration options for the parser
|
|
40
56
|
*/
|
|
@@ -219,13 +219,14 @@ class QuerySecurityValidator {
|
|
|
219
219
|
`Found "${field}" - use a simple field name without dots instead.`);
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
|
-
else {
|
|
222
|
+
else if (expression.type === 'logical') {
|
|
223
223
|
// Recursively validate logical expressions
|
|
224
224
|
this.validateNoDotNotation(expression.left);
|
|
225
225
|
if (expression.right) {
|
|
226
226
|
this.validateNoDotNotation(expression.right);
|
|
227
227
|
}
|
|
228
228
|
}
|
|
229
|
+
// Raw expressions are skipped - they handle their own field access
|
|
229
230
|
}
|
|
230
231
|
/**
|
|
231
232
|
* Validate that query values are not in the denied values list for their field
|
|
@@ -263,13 +264,14 @@ class QuerySecurityValidator {
|
|
|
263
264
|
}
|
|
264
265
|
}
|
|
265
266
|
}
|
|
266
|
-
else {
|
|
267
|
+
else if (expression.type === 'logical') {
|
|
267
268
|
// Recursively validate logical expressions
|
|
268
269
|
this.validateDenyValues(expression.left);
|
|
269
270
|
if (expression.right) {
|
|
270
271
|
this.validateDenyValues(expression.right);
|
|
271
272
|
}
|
|
272
273
|
}
|
|
274
|
+
// Raw expressions are skipped - they handle their own values
|
|
273
275
|
}
|
|
274
276
|
/**
|
|
275
277
|
* Check if a value is in the denied values list
|
|
@@ -317,6 +319,7 @@ class QuerySecurityValidator {
|
|
|
317
319
|
this.validateQueryDepth(expression.right, currentDepth + 1);
|
|
318
320
|
}
|
|
319
321
|
}
|
|
322
|
+
// Raw and comparison expressions don't add depth
|
|
320
323
|
}
|
|
321
324
|
/**
|
|
322
325
|
* Validate that the number of clauses does not exceed the maximum
|
|
@@ -341,6 +344,9 @@ class QuerySecurityValidator {
|
|
|
341
344
|
if (expression.type === 'comparison') {
|
|
342
345
|
return 1;
|
|
343
346
|
}
|
|
347
|
+
if (expression.type === 'raw') {
|
|
348
|
+
return 1; // Raw expressions count as one clause
|
|
349
|
+
}
|
|
344
350
|
let count = 0;
|
|
345
351
|
count += this.countClauses(expression.left);
|
|
346
352
|
if (expression.right) {
|
|
@@ -387,12 +393,13 @@ class QuerySecurityValidator {
|
|
|
387
393
|
throw new QuerySecurityError('Object values are not allowed');
|
|
388
394
|
}
|
|
389
395
|
}
|
|
390
|
-
else {
|
|
396
|
+
else if (expression.type === 'logical') {
|
|
391
397
|
this.validateValueLengths(expression.left);
|
|
392
398
|
if (expression.right) {
|
|
393
399
|
this.validateValueLengths(expression.right);
|
|
394
400
|
}
|
|
395
401
|
}
|
|
402
|
+
// Raw expressions are skipped - they handle their own values
|
|
396
403
|
}
|
|
397
404
|
/**
|
|
398
405
|
* Sanitize wildcard patterns in LIKE queries to prevent regex DoS
|
|
@@ -423,12 +430,13 @@ class QuerySecurityValidator {
|
|
|
423
430
|
expression.value = sanitized;
|
|
424
431
|
}
|
|
425
432
|
}
|
|
426
|
-
else {
|
|
433
|
+
else if (expression.type === 'logical') {
|
|
427
434
|
this.sanitizeWildcards(expression.left);
|
|
428
435
|
if (expression.right) {
|
|
429
436
|
this.sanitizeWildcards(expression.right);
|
|
430
437
|
}
|
|
431
438
|
}
|
|
439
|
+
// Raw expressions are skipped - they handle their own wildcards
|
|
432
440
|
}
|
|
433
441
|
/**
|
|
434
442
|
* Collect all field names used in the query
|
|
@@ -441,12 +449,13 @@ class QuerySecurityValidator {
|
|
|
441
449
|
if (expression.type === 'comparison') {
|
|
442
450
|
fieldSet.add(expression.field);
|
|
443
451
|
}
|
|
444
|
-
else {
|
|
452
|
+
else if (expression.type === 'logical') {
|
|
445
453
|
this.collectFields(expression.left, fieldSet);
|
|
446
454
|
if (expression.right) {
|
|
447
455
|
this.collectFields(expression.right, fieldSet);
|
|
448
456
|
}
|
|
449
457
|
}
|
|
458
|
+
// Raw expressions don't expose field names for collection
|
|
450
459
|
}
|
|
451
460
|
}
|
|
452
461
|
exports.QuerySecurityValidator = QuerySecurityValidator;
|
|
@@ -64,6 +64,17 @@ class DrizzleTranslator {
|
|
|
64
64
|
return this.translateComparisonExpression(expression);
|
|
65
65
|
case 'logical':
|
|
66
66
|
return this.translateLogicalExpression(expression);
|
|
67
|
+
case 'raw': {
|
|
68
|
+
const rawExpr = expression;
|
|
69
|
+
return rawExpr.toSql({
|
|
70
|
+
adapter: 'drizzle',
|
|
71
|
+
// tableName is empty because raw SQL expressions in virtual fields
|
|
72
|
+
// are resolved before translation and don't need table context at this stage.
|
|
73
|
+
// The schema is provided for field lookups if needed.
|
|
74
|
+
tableName: '',
|
|
75
|
+
schema: this.options.schema
|
|
76
|
+
});
|
|
77
|
+
}
|
|
67
78
|
default:
|
|
68
79
|
throw new DrizzleTranslationError(`Unsupported expression type: ${expression.type}`);
|
|
69
80
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper utilities for creating raw SQL expressions in virtual fields
|
|
3
|
+
*/
|
|
4
|
+
import { IRawSqlExpression } from '../parser/types';
|
|
5
|
+
/**
|
|
6
|
+
* Create a JSONB array contains expression (PostgreSQL).
|
|
7
|
+
* Checks if the JSONB array field contains the given value.
|
|
8
|
+
*
|
|
9
|
+
* @param field - The JSONB field name (e.g., 'assigned_to')
|
|
10
|
+
* @param value - The value to check for in the array
|
|
11
|
+
* @returns A raw SQL expression for JSONB contains check
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* // Check if assignedTo contains the current user ID
|
|
15
|
+
* jsonbContains('assigned_to', ctx.currentUserId)
|
|
16
|
+
* // Generates: assigned_to @> '["user123"]'::jsonb
|
|
17
|
+
*/
|
|
18
|
+
export declare function jsonbContains(field: string, value: unknown): IRawSqlExpression;
|
|
19
|
+
/**
|
|
20
|
+
* Create a date range expression.
|
|
21
|
+
* Checks if a timestamp field is within the specified number of days from now.
|
|
22
|
+
*
|
|
23
|
+
* @param field - The timestamp field name (e.g., 'created_at')
|
|
24
|
+
* @param days - Number of days from now (must be a positive finite number)
|
|
25
|
+
* @returns A raw SQL expression for date range check
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* // Check if created within last day
|
|
29
|
+
* dateWithinDays('created_at', 1)
|
|
30
|
+
* // Generates: created_at >= NOW() - INTERVAL '1 days'
|
|
31
|
+
*/
|
|
32
|
+
export declare function dateWithinDays(field: string, days: number): IRawSqlExpression;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Helper utilities for creating raw SQL expressions in virtual fields
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.jsonbContains = jsonbContains;
|
|
7
|
+
exports.dateWithinDays = dateWithinDays;
|
|
8
|
+
const drizzle_orm_1 = require("drizzle-orm");
|
|
9
|
+
/**
|
|
10
|
+
* Validates field name to prevent SQL injection.
|
|
11
|
+
* Only allows alphanumeric characters, dots, and underscores.
|
|
12
|
+
* @private
|
|
13
|
+
*/
|
|
14
|
+
function validateFieldName(field) {
|
|
15
|
+
if (!/^[a-zA-Z][a-zA-Z0-9._]*$/.test(field)) {
|
|
16
|
+
throw new Error(`Invalid field name: ${field}`);
|
|
17
|
+
}
|
|
18
|
+
if (field.length > 64) {
|
|
19
|
+
throw new Error(`Field name too long: ${field}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Create a JSONB array contains expression (PostgreSQL).
|
|
24
|
+
* Checks if the JSONB array field contains the given value.
|
|
25
|
+
*
|
|
26
|
+
* @param field - The JSONB field name (e.g., 'assigned_to')
|
|
27
|
+
* @param value - The value to check for in the array
|
|
28
|
+
* @returns A raw SQL expression for JSONB contains check
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* // Check if assignedTo contains the current user ID
|
|
32
|
+
* jsonbContains('assigned_to', ctx.currentUserId)
|
|
33
|
+
* // Generates: assigned_to @> '["user123"]'::jsonb
|
|
34
|
+
*/
|
|
35
|
+
function jsonbContains(field, value) {
|
|
36
|
+
validateFieldName(field);
|
|
37
|
+
return {
|
|
38
|
+
type: 'raw',
|
|
39
|
+
toSql: () => (0, drizzle_orm_1.sql) `${drizzle_orm_1.sql.identifier(field)} @> ${drizzle_orm_1.sql.raw("'" + JSON.stringify(Array.isArray(value) ? value : [value]).replace(/'/g, "''") + "'::jsonb")}`
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Validates days parameter to ensure it's a positive finite number.
|
|
44
|
+
* @private
|
|
45
|
+
*/
|
|
46
|
+
function validateDaysParameter(days) {
|
|
47
|
+
if (!Number.isFinite(days)) {
|
|
48
|
+
throw new Error(`Invalid days parameter: ${days}. Must be a finite number.`);
|
|
49
|
+
}
|
|
50
|
+
if (days <= 0) {
|
|
51
|
+
throw new Error(`Invalid days parameter: ${days}. Must be a positive number.`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Create a date range expression.
|
|
56
|
+
* Checks if a timestamp field is within the specified number of days from now.
|
|
57
|
+
*
|
|
58
|
+
* @param field - The timestamp field name (e.g., 'created_at')
|
|
59
|
+
* @param days - Number of days from now (must be a positive finite number)
|
|
60
|
+
* @returns A raw SQL expression for date range check
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* // Check if created within last day
|
|
64
|
+
* dateWithinDays('created_at', 1)
|
|
65
|
+
* // Generates: created_at >= NOW() - INTERVAL '1 days'
|
|
66
|
+
*/
|
|
67
|
+
function dateWithinDays(field, days) {
|
|
68
|
+
validateFieldName(field);
|
|
69
|
+
validateDaysParameter(days);
|
|
70
|
+
return {
|
|
71
|
+
type: 'raw',
|
|
72
|
+
toSql: () => (0, drizzle_orm_1.sql) `${drizzle_orm_1.sql.identifier(field)} >= NOW() - INTERVAL '${drizzle_orm_1.sql.raw(days.toString())} days'`
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -19,3 +19,4 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
19
19
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
20
|
__exportStar(require("./types"), exports);
|
|
21
21
|
__exportStar(require("./resolver"), exports);
|
|
22
|
+
__exportStar(require("./helpers"), exports);
|
|
@@ -25,6 +25,10 @@ function resolveVirtualFields(expr, virtualFields, context) {
|
|
|
25
25
|
if (expr.type === 'logical') {
|
|
26
26
|
return resolveLogicalExpression(expr, virtualFields, context);
|
|
27
27
|
}
|
|
28
|
+
// Pass through raw expressions
|
|
29
|
+
if (expr.type === 'raw') {
|
|
30
|
+
return expr;
|
|
31
|
+
}
|
|
28
32
|
// Unknown expression type, return as-is
|
|
29
33
|
return expr;
|
|
30
34
|
}
|
|
@@ -1,7 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Type definitions for Virtual Fields support
|
|
3
3
|
*/
|
|
4
|
-
import { QueryExpression, IComparisonExpression, ComparisonOperator } from '../parser/types';
|
|
4
|
+
import { QueryExpression, IComparisonExpression, ComparisonOperator, IRawSqlExpression } from '../parser/types';
|
|
5
|
+
/**
|
|
6
|
+
* Context provided to raw SQL generators for adapter-specific SQL generation.
|
|
7
|
+
*/
|
|
8
|
+
export interface IRawSqlContext {
|
|
9
|
+
/**
|
|
10
|
+
* The database adapter identifier (e.g., 'drizzle')
|
|
11
|
+
*/
|
|
12
|
+
adapter: string;
|
|
13
|
+
/**
|
|
14
|
+
* The table name being queried
|
|
15
|
+
*/
|
|
16
|
+
tableName: string;
|
|
17
|
+
/**
|
|
18
|
+
* Access to the schema for field references
|
|
19
|
+
*/
|
|
20
|
+
schema: Record<string, unknown>;
|
|
21
|
+
}
|
|
5
22
|
/**
|
|
6
23
|
* Base interface for query context.
|
|
7
24
|
* Users can extend this interface with their own context properties.
|
|
@@ -86,9 +103,9 @@ export interface ITypedComparisonExpression<TFields extends string = string> ext
|
|
|
86
103
|
}
|
|
87
104
|
/**
|
|
88
105
|
* Schema-constrained query expression.
|
|
89
|
-
* Can be a comparison
|
|
106
|
+
* Can be a comparison, logical expression with typed fields, or a raw SQL expression.
|
|
90
107
|
*/
|
|
91
|
-
export type ITypedQueryExpression<TFields extends string = string> = ITypedComparisonExpression<TFields> | QueryExpression;
|
|
108
|
+
export type ITypedQueryExpression<TFields extends string = string> = ITypedComparisonExpression<TFields> | IRawSqlExpression | QueryExpression;
|
|
92
109
|
/**
|
|
93
110
|
* Definition for a virtual field.
|
|
94
111
|
* Configures how a virtual field should be resolved at query execution time.
|
package/package.json
CHANGED
package/src/parser/types.ts
CHANGED
|
@@ -51,10 +51,30 @@ export interface ILogicalExpression {
|
|
|
51
51
|
right?: QueryExpression;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Represents a raw SQL expression node in the AST.
|
|
56
|
+
* Used by virtual fields to inject database-specific SQL operations.
|
|
57
|
+
*/
|
|
58
|
+
export interface IRawSqlExpression {
|
|
59
|
+
type: 'raw';
|
|
60
|
+
/**
|
|
61
|
+
* Function that generates the raw SQL for the adapter.
|
|
62
|
+
* For Drizzle, this should return a SQL template result.
|
|
63
|
+
*/
|
|
64
|
+
toSql: (context: {
|
|
65
|
+
adapter: string;
|
|
66
|
+
tableName: string;
|
|
67
|
+
schema: Record<string, unknown>;
|
|
68
|
+
}) => unknown;
|
|
69
|
+
}
|
|
70
|
+
|
|
54
71
|
/**
|
|
55
72
|
* Represents any valid query expression node
|
|
56
73
|
*/
|
|
57
|
-
export type QueryExpression =
|
|
74
|
+
export type QueryExpression =
|
|
75
|
+
| IComparisonExpression
|
|
76
|
+
| ILogicalExpression
|
|
77
|
+
| IRawSqlExpression;
|
|
58
78
|
|
|
59
79
|
/**
|
|
60
80
|
* Configuration options for the parser
|
|
@@ -240,13 +240,14 @@ export class QuerySecurityValidator {
|
|
|
240
240
|
`Found "${field}" - use a simple field name without dots instead.`
|
|
241
241
|
);
|
|
242
242
|
}
|
|
243
|
-
} else {
|
|
243
|
+
} else if (expression.type === 'logical') {
|
|
244
244
|
// Recursively validate logical expressions
|
|
245
245
|
this.validateNoDotNotation(expression.left);
|
|
246
246
|
if (expression.right) {
|
|
247
247
|
this.validateNoDotNotation(expression.right);
|
|
248
248
|
}
|
|
249
249
|
}
|
|
250
|
+
// Raw expressions are skipped - they handle their own field access
|
|
250
251
|
}
|
|
251
252
|
|
|
252
253
|
/**
|
|
@@ -285,13 +286,14 @@ export class QuerySecurityValidator {
|
|
|
285
286
|
}
|
|
286
287
|
}
|
|
287
288
|
}
|
|
288
|
-
} else {
|
|
289
|
+
} else if (expression.type === 'logical') {
|
|
289
290
|
// Recursively validate logical expressions
|
|
290
291
|
this.validateDenyValues(expression.left);
|
|
291
292
|
if (expression.right) {
|
|
292
293
|
this.validateDenyValues(expression.right);
|
|
293
294
|
}
|
|
294
295
|
}
|
|
296
|
+
// Raw expressions are skipped - they handle their own values
|
|
295
297
|
}
|
|
296
298
|
|
|
297
299
|
/**
|
|
@@ -350,6 +352,7 @@ export class QuerySecurityValidator {
|
|
|
350
352
|
this.validateQueryDepth(expression.right, currentDepth + 1);
|
|
351
353
|
}
|
|
352
354
|
}
|
|
355
|
+
// Raw and comparison expressions don't add depth
|
|
353
356
|
}
|
|
354
357
|
|
|
355
358
|
/**
|
|
@@ -379,6 +382,10 @@ export class QuerySecurityValidator {
|
|
|
379
382
|
return 1;
|
|
380
383
|
}
|
|
381
384
|
|
|
385
|
+
if (expression.type === 'raw') {
|
|
386
|
+
return 1; // Raw expressions count as one clause
|
|
387
|
+
}
|
|
388
|
+
|
|
382
389
|
let count = 0;
|
|
383
390
|
count += this.countClauses(expression.left);
|
|
384
391
|
if (expression.right) {
|
|
@@ -442,12 +449,13 @@ export class QuerySecurityValidator {
|
|
|
442
449
|
) {
|
|
443
450
|
throw new QuerySecurityError('Object values are not allowed');
|
|
444
451
|
}
|
|
445
|
-
} else {
|
|
452
|
+
} else if (expression.type === 'logical') {
|
|
446
453
|
this.validateValueLengths(expression.left);
|
|
447
454
|
if (expression.right) {
|
|
448
455
|
this.validateValueLengths(expression.right);
|
|
449
456
|
}
|
|
450
457
|
}
|
|
458
|
+
// Raw expressions are skipped - they handle their own values
|
|
451
459
|
}
|
|
452
460
|
|
|
453
461
|
/**
|
|
@@ -481,12 +489,13 @@ export class QuerySecurityValidator {
|
|
|
481
489
|
.replace(/\?{2,}/g, '?'); // Limit consecutive question marks
|
|
482
490
|
(expression as IComparisonExpression).value = sanitized;
|
|
483
491
|
}
|
|
484
|
-
} else {
|
|
492
|
+
} else if (expression.type === 'logical') {
|
|
485
493
|
this.sanitizeWildcards(expression.left);
|
|
486
494
|
if (expression.right) {
|
|
487
495
|
this.sanitizeWildcards(expression.right);
|
|
488
496
|
}
|
|
489
497
|
}
|
|
498
|
+
// Raw expressions are skipped - they handle their own wildcards
|
|
490
499
|
}
|
|
491
500
|
|
|
492
501
|
/**
|
|
@@ -502,11 +511,12 @@ export class QuerySecurityValidator {
|
|
|
502
511
|
): void {
|
|
503
512
|
if (expression.type === 'comparison') {
|
|
504
513
|
fieldSet.add(expression.field);
|
|
505
|
-
} else {
|
|
514
|
+
} else if (expression.type === 'logical') {
|
|
506
515
|
this.collectFields(expression.left, fieldSet);
|
|
507
516
|
if (expression.right) {
|
|
508
517
|
this.collectFields(expression.right, fieldSet);
|
|
509
518
|
}
|
|
510
519
|
}
|
|
520
|
+
// Raw expressions don't expose field names for collection
|
|
511
521
|
}
|
|
512
522
|
}
|
|
@@ -97,6 +97,24 @@ export class DrizzleTranslator implements ITranslator<SQL> {
|
|
|
97
97
|
return this.translateComparisonExpression(expression);
|
|
98
98
|
case 'logical':
|
|
99
99
|
return this.translateLogicalExpression(expression);
|
|
100
|
+
case 'raw': {
|
|
101
|
+
const rawExpr = expression as {
|
|
102
|
+
type: 'raw';
|
|
103
|
+
toSql: (context: {
|
|
104
|
+
adapter: string;
|
|
105
|
+
tableName: string;
|
|
106
|
+
schema: Record<string, unknown>;
|
|
107
|
+
}) => unknown;
|
|
108
|
+
};
|
|
109
|
+
return rawExpr.toSql({
|
|
110
|
+
adapter: 'drizzle',
|
|
111
|
+
// tableName is empty because raw SQL expressions in virtual fields
|
|
112
|
+
// are resolved before translation and don't need table context at this stage.
|
|
113
|
+
// The schema is provided for field lookups if needed.
|
|
114
|
+
tableName: '',
|
|
115
|
+
schema: this.options.schema
|
|
116
|
+
}) as SQL;
|
|
117
|
+
}
|
|
100
118
|
default:
|
|
101
119
|
throw new DrizzleTranslationError(
|
|
102
120
|
`Unsupported expression type: ${(expression as { type: string }).type}`
|