@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 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
@@ -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
+ }
@@ -3,3 +3,4 @@
3
3
  */
4
4
  export * from './types';
5
5
  export * from './resolver';
6
+ export * from './helpers';
@@ -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 or logical expression with typed fields.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gblikas/querykit",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "A comprehensive query toolkit for TypeScript that simplifies building and executing data queries across different environments",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 = IComparisonExpression | ILogicalExpression;
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}`