@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
|
@@ -0,0 +1,978 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Raw SQL expressions in Virtual Fields
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { sql } from 'drizzle-orm';
|
|
6
|
+
import type { SQL } from 'drizzle-orm';
|
|
7
|
+
import { IRawSqlExpression } from '../parser/types';
|
|
8
|
+
import { resolveVirtualFields } from './resolver';
|
|
9
|
+
import { jsonbContains, dateWithinDays } from './helpers';
|
|
10
|
+
import { IQueryContext, VirtualFieldsConfig } from './types';
|
|
11
|
+
import { IComparisonExpression, ILogicalExpression } from '../parser/types';
|
|
12
|
+
import { DrizzleTranslator } from '../translators/drizzle';
|
|
13
|
+
|
|
14
|
+
// Mock schema for testing
|
|
15
|
+
type MockSchema = {
|
|
16
|
+
tasks: {
|
|
17
|
+
id: number;
|
|
18
|
+
title: string;
|
|
19
|
+
assignee_id: number;
|
|
20
|
+
assigned_to: string[];
|
|
21
|
+
created_at: Date;
|
|
22
|
+
status: string;
|
|
23
|
+
priority: number;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Mock context for testing
|
|
28
|
+
interface IMockContext extends IQueryContext {
|
|
29
|
+
currentUserId: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Helper to extract SQL string for testing
|
|
33
|
+
function getSqlString(sqlObj: SQL): string {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.stringify(sqlObj);
|
|
36
|
+
} catch (e) {
|
|
37
|
+
return String(sqlObj);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('Raw SQL Expressions', () => {
|
|
42
|
+
describe('Unit Tests - IRawSqlExpression', () => {
|
|
43
|
+
it('should create a raw SQL expression with type "raw"', () => {
|
|
44
|
+
const rawExpr: IRawSqlExpression = {
|
|
45
|
+
type: 'raw',
|
|
46
|
+
toSql: () => sql`status = 'active'`
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
expect(rawExpr.type).toBe('raw');
|
|
50
|
+
expect(typeof rawExpr.toSql).toBe('function');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should invoke toSql method with context', () => {
|
|
54
|
+
const mockContext = {
|
|
55
|
+
adapter: 'drizzle',
|
|
56
|
+
tableName: 'tasks',
|
|
57
|
+
schema: {}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const rawExpr: IRawSqlExpression = {
|
|
61
|
+
type: 'raw',
|
|
62
|
+
toSql: ctx => {
|
|
63
|
+
expect(ctx.adapter).toBe('drizzle');
|
|
64
|
+
expect(ctx.tableName).toBe('tasks');
|
|
65
|
+
return sql`test`;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
rawExpr.toSql(mockContext);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should support custom SQL generation logic', () => {
|
|
73
|
+
const rawExpr: IRawSqlExpression = {
|
|
74
|
+
type: 'raw',
|
|
75
|
+
toSql: () => sql`${sql.identifier('custom_field')} = 'custom_value'`
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const result = rawExpr.toSql({
|
|
79
|
+
adapter: 'drizzle',
|
|
80
|
+
tableName: 'test',
|
|
81
|
+
schema: {}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(result).toBeDefined();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('Helper Functions', () => {
|
|
89
|
+
describe('jsonbContains', () => {
|
|
90
|
+
it('should create JSONB contains expression for single value', () => {
|
|
91
|
+
const expr = jsonbContains('assigned_to', 'user123');
|
|
92
|
+
|
|
93
|
+
expect(expr.type).toBe('raw');
|
|
94
|
+
expect(typeof expr.toSql).toBe('function');
|
|
95
|
+
|
|
96
|
+
const result = expr.toSql({
|
|
97
|
+
adapter: 'drizzle',
|
|
98
|
+
tableName: 'tasks',
|
|
99
|
+
schema: {}
|
|
100
|
+
});
|
|
101
|
+
const sqlString = getSqlString(result as SQL);
|
|
102
|
+
|
|
103
|
+
expect(sqlString).toContain('assigned_to');
|
|
104
|
+
expect(sqlString).toContain('@>');
|
|
105
|
+
expect(sqlString).toContain('user123');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should wrap single value in array', () => {
|
|
109
|
+
const expr = jsonbContains('assigned_to', 'user123');
|
|
110
|
+
const result = expr.toSql({
|
|
111
|
+
adapter: 'drizzle',
|
|
112
|
+
tableName: 'tasks',
|
|
113
|
+
schema: {}
|
|
114
|
+
});
|
|
115
|
+
const sqlString = getSqlString(result as SQL);
|
|
116
|
+
|
|
117
|
+
// Check for the escaped JSON format in the serialized SQL
|
|
118
|
+
expect(sqlString).toContain('user123');
|
|
119
|
+
expect(sqlString).toContain('assigned_to');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should handle array values', () => {
|
|
123
|
+
const expr = jsonbContains('assigned_to', ['user123', 'user456']);
|
|
124
|
+
const result = expr.toSql({
|
|
125
|
+
adapter: 'drizzle',
|
|
126
|
+
tableName: 'tasks',
|
|
127
|
+
schema: {}
|
|
128
|
+
});
|
|
129
|
+
const sqlString = getSqlString(result as SQL);
|
|
130
|
+
|
|
131
|
+
expect(sqlString).toContain('assigned_to');
|
|
132
|
+
expect(sqlString).toContain('user123');
|
|
133
|
+
expect(sqlString).toContain('user456');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should validate field name to prevent SQL injection', () => {
|
|
137
|
+
expect(() => jsonbContains('invalid;field', 'value')).toThrow(
|
|
138
|
+
'Invalid field name'
|
|
139
|
+
);
|
|
140
|
+
expect(() => jsonbContains('1invalid', 'value')).toThrow(
|
|
141
|
+
'Invalid field name'
|
|
142
|
+
);
|
|
143
|
+
expect(() => jsonbContains('a'.repeat(65), 'value')).toThrow(
|
|
144
|
+
'Field name too long'
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should allow valid field names with dots and underscores', () => {
|
|
149
|
+
expect(() => jsonbContains('valid_field', 'value')).not.toThrow();
|
|
150
|
+
expect(() => jsonbContains('table.field', 'value')).not.toThrow();
|
|
151
|
+
expect(() => jsonbContains('my_table.my_field', 'value')).not.toThrow();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('dateWithinDays', () => {
|
|
156
|
+
it('should create date range expression', () => {
|
|
157
|
+
const expr = dateWithinDays('created_at', 1);
|
|
158
|
+
|
|
159
|
+
expect(expr.type).toBe('raw');
|
|
160
|
+
expect(typeof expr.toSql).toBe('function');
|
|
161
|
+
|
|
162
|
+
const result = expr.toSql({
|
|
163
|
+
adapter: 'drizzle',
|
|
164
|
+
tableName: 'tasks',
|
|
165
|
+
schema: {}
|
|
166
|
+
});
|
|
167
|
+
const sqlString = getSqlString(result as SQL);
|
|
168
|
+
|
|
169
|
+
expect(sqlString).toContain('created_at');
|
|
170
|
+
expect(sqlString).toContain('>=');
|
|
171
|
+
expect(sqlString).toContain('NOW()');
|
|
172
|
+
expect(sqlString).toContain('INTERVAL');
|
|
173
|
+
expect(sqlString).toContain('1');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should support different day values', () => {
|
|
177
|
+
const expr7 = dateWithinDays('created_at', 7);
|
|
178
|
+
const result7 = expr7.toSql({
|
|
179
|
+
adapter: 'drizzle',
|
|
180
|
+
tableName: 'tasks',
|
|
181
|
+
schema: {}
|
|
182
|
+
});
|
|
183
|
+
const sqlString7 = getSqlString(result7 as SQL);
|
|
184
|
+
|
|
185
|
+
expect(sqlString7).toContain('7');
|
|
186
|
+
|
|
187
|
+
const expr30 = dateWithinDays('created_at', 30);
|
|
188
|
+
const result30 = expr30.toSql({
|
|
189
|
+
adapter: 'drizzle',
|
|
190
|
+
tableName: 'tasks',
|
|
191
|
+
schema: {}
|
|
192
|
+
});
|
|
193
|
+
const sqlString30 = getSqlString(result30 as SQL);
|
|
194
|
+
|
|
195
|
+
expect(sqlString30).toContain('30');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('should validate field name to prevent SQL injection', () => {
|
|
199
|
+
expect(() => dateWithinDays('invalid;field', 7)).toThrow(
|
|
200
|
+
'Invalid field name'
|
|
201
|
+
);
|
|
202
|
+
expect(() => dateWithinDays('1invalid', 7)).toThrow(
|
|
203
|
+
'Invalid field name'
|
|
204
|
+
);
|
|
205
|
+
expect(() => dateWithinDays('a'.repeat(65), 7)).toThrow(
|
|
206
|
+
'Field name too long'
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('should allow valid field names with dots and underscores', () => {
|
|
211
|
+
expect(() => dateWithinDays('valid_field', 7)).not.toThrow();
|
|
212
|
+
expect(() => dateWithinDays('table.field', 7)).not.toThrow();
|
|
213
|
+
expect(() => dateWithinDays('my_table.my_field', 7)).not.toThrow();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should validate days parameter to ensure it is a positive finite number', () => {
|
|
217
|
+
expect(() => dateWithinDays('created_at', -1)).toThrow(
|
|
218
|
+
'Must be a positive number'
|
|
219
|
+
);
|
|
220
|
+
expect(() => dateWithinDays('created_at', 0)).toThrow(
|
|
221
|
+
'Must be a positive number'
|
|
222
|
+
);
|
|
223
|
+
expect(() => dateWithinDays('created_at', Infinity)).toThrow(
|
|
224
|
+
'Must be a finite number'
|
|
225
|
+
);
|
|
226
|
+
expect(() => dateWithinDays('created_at', -Infinity)).toThrow(
|
|
227
|
+
'Must be a finite number'
|
|
228
|
+
);
|
|
229
|
+
expect(() => dateWithinDays('created_at', NaN)).toThrow(
|
|
230
|
+
'Must be a finite number'
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('should accept valid positive finite number values for days', () => {
|
|
235
|
+
expect(() => dateWithinDays('created_at', 1)).not.toThrow();
|
|
236
|
+
expect(() => dateWithinDays('created_at', 0.5)).not.toThrow();
|
|
237
|
+
expect(() => dateWithinDays('created_at', 1000)).not.toThrow();
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe('Integration with Resolver', () => {
|
|
243
|
+
it('should pass through raw expressions unchanged', () => {
|
|
244
|
+
const context: IMockContext = {
|
|
245
|
+
currentUserId: 'user123'
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const rawExpr: IRawSqlExpression = {
|
|
249
|
+
type: 'raw',
|
|
250
|
+
toSql: () => sql`test = 'value'`
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const result = resolveVirtualFields(rawExpr, {}, context);
|
|
254
|
+
|
|
255
|
+
expect(result).toBe(rawExpr); // Should be the exact same object
|
|
256
|
+
expect(result.type).toBe('raw');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('should resolve virtual fields to raw expressions', () => {
|
|
260
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
261
|
+
my: {
|
|
262
|
+
allowedValues: ['assigned'] as const,
|
|
263
|
+
resolve: (input, ctx) => {
|
|
264
|
+
if (input.value === 'assigned') {
|
|
265
|
+
return jsonbContains('assigned_to', ctx.currentUserId);
|
|
266
|
+
}
|
|
267
|
+
throw new Error('Unknown value');
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const context: IMockContext = {
|
|
273
|
+
currentUserId: 'user123'
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const expr: IComparisonExpression = {
|
|
277
|
+
type: 'comparison',
|
|
278
|
+
field: 'my',
|
|
279
|
+
operator: '==',
|
|
280
|
+
value: 'assigned'
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const resolved = resolveVirtualFields(expr, virtualFields, context);
|
|
284
|
+
|
|
285
|
+
expect(resolved.type).toBe('raw');
|
|
286
|
+
expect((resolved as IRawSqlExpression).toSql).toBeDefined();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('should support raw expressions in logical AND operations', () => {
|
|
290
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
291
|
+
my: {
|
|
292
|
+
allowedValues: ['assigned'] as const,
|
|
293
|
+
resolve: (input, ctx) =>
|
|
294
|
+
jsonbContains('assigned_to', ctx.currentUserId)
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const context: IMockContext = {
|
|
299
|
+
currentUserId: 'user123'
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const expr: ILogicalExpression = {
|
|
303
|
+
type: 'logical',
|
|
304
|
+
operator: 'AND',
|
|
305
|
+
left: {
|
|
306
|
+
type: 'comparison',
|
|
307
|
+
field: 'my',
|
|
308
|
+
operator: '==',
|
|
309
|
+
value: 'assigned'
|
|
310
|
+
},
|
|
311
|
+
right: {
|
|
312
|
+
type: 'comparison',
|
|
313
|
+
field: 'status',
|
|
314
|
+
operator: '==',
|
|
315
|
+
value: 'active'
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const resolved = resolveVirtualFields(
|
|
320
|
+
expr,
|
|
321
|
+
virtualFields,
|
|
322
|
+
context
|
|
323
|
+
) as ILogicalExpression;
|
|
324
|
+
|
|
325
|
+
expect(resolved.type).toBe('logical');
|
|
326
|
+
expect(resolved.operator).toBe('AND');
|
|
327
|
+
expect(resolved.left.type).toBe('raw');
|
|
328
|
+
expect(resolved.right?.type).toBe('comparison');
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('should support raw expressions in logical OR operations', () => {
|
|
332
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
333
|
+
priority: {
|
|
334
|
+
allowedValues: ['high'] as const,
|
|
335
|
+
resolve: () => dateWithinDays('created_at', 1)
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const context: IMockContext = {
|
|
340
|
+
currentUserId: 'user123'
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const expr: ILogicalExpression = {
|
|
344
|
+
type: 'logical',
|
|
345
|
+
operator: 'OR',
|
|
346
|
+
left: {
|
|
347
|
+
type: 'comparison',
|
|
348
|
+
field: 'priority',
|
|
349
|
+
operator: '==',
|
|
350
|
+
value: 'high'
|
|
351
|
+
},
|
|
352
|
+
right: {
|
|
353
|
+
type: 'comparison',
|
|
354
|
+
field: 'status',
|
|
355
|
+
operator: '==',
|
|
356
|
+
value: 'urgent'
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const resolved = resolveVirtualFields(
|
|
361
|
+
expr,
|
|
362
|
+
virtualFields,
|
|
363
|
+
context
|
|
364
|
+
) as ILogicalExpression;
|
|
365
|
+
|
|
366
|
+
expect(resolved.type).toBe('logical');
|
|
367
|
+
expect(resolved.operator).toBe('OR');
|
|
368
|
+
expect(resolved.left.type).toBe('raw');
|
|
369
|
+
expect(resolved.right?.type).toBe('comparison');
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('should support raw expressions in NOT operations', () => {
|
|
373
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
374
|
+
my: {
|
|
375
|
+
allowedValues: ['assigned'] as const,
|
|
376
|
+
resolve: (input, ctx) =>
|
|
377
|
+
jsonbContains('assigned_to', ctx.currentUserId)
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const context: IMockContext = {
|
|
382
|
+
currentUserId: 'user123'
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const expr: ILogicalExpression = {
|
|
386
|
+
type: 'logical',
|
|
387
|
+
operator: 'NOT',
|
|
388
|
+
left: {
|
|
389
|
+
type: 'comparison',
|
|
390
|
+
field: 'my',
|
|
391
|
+
operator: '==',
|
|
392
|
+
value: 'assigned'
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const resolved = resolveVirtualFields(
|
|
397
|
+
expr,
|
|
398
|
+
virtualFields,
|
|
399
|
+
context
|
|
400
|
+
) as ILogicalExpression;
|
|
401
|
+
|
|
402
|
+
expect(resolved.type).toBe('logical');
|
|
403
|
+
expect(resolved.operator).toBe('NOT');
|
|
404
|
+
expect(resolved.left.type).toBe('raw');
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('should mix raw and standard comparison expressions', () => {
|
|
408
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
409
|
+
my: {
|
|
410
|
+
allowedValues: ['assigned'] as const,
|
|
411
|
+
resolve: (input, ctx) =>
|
|
412
|
+
jsonbContains('assigned_to', ctx.currentUserId)
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const context: IMockContext = {
|
|
417
|
+
currentUserId: 'user123'
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
const expr: ILogicalExpression = {
|
|
421
|
+
type: 'logical',
|
|
422
|
+
operator: 'AND',
|
|
423
|
+
left: {
|
|
424
|
+
type: 'comparison',
|
|
425
|
+
field: 'my',
|
|
426
|
+
operator: '==',
|
|
427
|
+
value: 'assigned'
|
|
428
|
+
},
|
|
429
|
+
right: {
|
|
430
|
+
type: 'logical',
|
|
431
|
+
operator: 'AND',
|
|
432
|
+
left: {
|
|
433
|
+
type: 'comparison',
|
|
434
|
+
field: 'status',
|
|
435
|
+
operator: '==',
|
|
436
|
+
value: 'active'
|
|
437
|
+
},
|
|
438
|
+
right: {
|
|
439
|
+
type: 'comparison',
|
|
440
|
+
field: 'priority',
|
|
441
|
+
operator: '>',
|
|
442
|
+
value: 2
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
const resolved = resolveVirtualFields(
|
|
448
|
+
expr,
|
|
449
|
+
virtualFields,
|
|
450
|
+
context
|
|
451
|
+
) as ILogicalExpression;
|
|
452
|
+
|
|
453
|
+
expect(resolved.type).toBe('logical');
|
|
454
|
+
expect(resolved.left.type).toBe('raw');
|
|
455
|
+
|
|
456
|
+
const right = resolved.right as ILogicalExpression;
|
|
457
|
+
expect(right.type).toBe('logical');
|
|
458
|
+
expect(right.left.type).toBe('comparison');
|
|
459
|
+
expect(right.right?.type).toBe('comparison');
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
describe('Integration with Drizzle Translator', () => {
|
|
464
|
+
const translator = new DrizzleTranslator();
|
|
465
|
+
|
|
466
|
+
it('should translate raw SQL expression', () => {
|
|
467
|
+
const rawExpr: IRawSqlExpression = {
|
|
468
|
+
type: 'raw',
|
|
469
|
+
toSql: () => sql`status = 'active'`
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const result = translator.translate(rawExpr);
|
|
473
|
+
const sqlString = getSqlString(result);
|
|
474
|
+
|
|
475
|
+
expect(sqlString).toContain('status');
|
|
476
|
+
expect(sqlString).toContain('active');
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('should translate JSONB contains expression', () => {
|
|
480
|
+
const expr = jsonbContains('assigned_to', 'user123');
|
|
481
|
+
const result = translator.translate(expr);
|
|
482
|
+
const sqlString = getSqlString(result);
|
|
483
|
+
|
|
484
|
+
expect(sqlString).toContain('assigned_to');
|
|
485
|
+
expect(sqlString).toContain('@>');
|
|
486
|
+
expect(sqlString).toContain('user123');
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('should translate date range expression', () => {
|
|
490
|
+
const expr = dateWithinDays('created_at', 7);
|
|
491
|
+
const result = translator.translate(expr);
|
|
492
|
+
const sqlString = getSqlString(result);
|
|
493
|
+
|
|
494
|
+
expect(sqlString).toContain('created_at');
|
|
495
|
+
expect(sqlString).toContain('>=');
|
|
496
|
+
expect(sqlString).toContain('NOW()');
|
|
497
|
+
expect(sqlString).toContain('7');
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('should translate raw expressions in AND operations', () => {
|
|
501
|
+
const expr: ILogicalExpression = {
|
|
502
|
+
type: 'logical',
|
|
503
|
+
operator: 'AND',
|
|
504
|
+
left: jsonbContains('assigned_to', 'user123'),
|
|
505
|
+
right: {
|
|
506
|
+
type: 'comparison',
|
|
507
|
+
field: 'status',
|
|
508
|
+
operator: '==',
|
|
509
|
+
value: 'active'
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
const result = translator.translate(expr);
|
|
514
|
+
const sqlString = getSqlString(result);
|
|
515
|
+
|
|
516
|
+
expect(sqlString).toContain('AND');
|
|
517
|
+
expect(sqlString).toContain('assigned_to');
|
|
518
|
+
expect(sqlString).toContain('status');
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('should translate raw expressions in OR operations', () => {
|
|
522
|
+
const expr: ILogicalExpression = {
|
|
523
|
+
type: 'logical',
|
|
524
|
+
operator: 'OR',
|
|
525
|
+
left: dateWithinDays('created_at', 1),
|
|
526
|
+
right: {
|
|
527
|
+
type: 'comparison',
|
|
528
|
+
field: 'priority',
|
|
529
|
+
operator: '>',
|
|
530
|
+
value: 5
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const result = translator.translate(expr);
|
|
535
|
+
const sqlString = getSqlString(result);
|
|
536
|
+
|
|
537
|
+
expect(sqlString).toContain('OR');
|
|
538
|
+
expect(sqlString).toContain('created_at');
|
|
539
|
+
expect(sqlString).toContain('priority');
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('should translate raw expressions in NOT operations', () => {
|
|
543
|
+
const expr: ILogicalExpression = {
|
|
544
|
+
type: 'logical',
|
|
545
|
+
operator: 'NOT',
|
|
546
|
+
left: jsonbContains('assigned_to', 'user123')
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
const result = translator.translate(expr);
|
|
550
|
+
const sqlString = getSqlString(result);
|
|
551
|
+
|
|
552
|
+
expect(sqlString).toContain('NOT');
|
|
553
|
+
expect(sqlString).toContain('assigned_to');
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it('should translate complex nested expressions with raw SQL', () => {
|
|
557
|
+
const expr: ILogicalExpression = {
|
|
558
|
+
type: 'logical',
|
|
559
|
+
operator: 'AND',
|
|
560
|
+
left: {
|
|
561
|
+
type: 'logical',
|
|
562
|
+
operator: 'OR',
|
|
563
|
+
left: jsonbContains('assigned_to', 'user123'),
|
|
564
|
+
right: dateWithinDays('created_at', 7)
|
|
565
|
+
},
|
|
566
|
+
right: {
|
|
567
|
+
type: 'comparison',
|
|
568
|
+
field: 'status',
|
|
569
|
+
operator: '==',
|
|
570
|
+
value: 'active'
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
const result = translator.translate(expr);
|
|
575
|
+
const sqlString = getSqlString(result);
|
|
576
|
+
|
|
577
|
+
expect(sqlString).toContain('AND');
|
|
578
|
+
expect(sqlString).toContain('OR');
|
|
579
|
+
expect(sqlString).toContain('assigned_to');
|
|
580
|
+
expect(sqlString).toContain('created_at');
|
|
581
|
+
expect(sqlString).toContain('status');
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it('should handle multiple raw expressions', () => {
|
|
585
|
+
const expr: ILogicalExpression = {
|
|
586
|
+
type: 'logical',
|
|
587
|
+
operator: 'AND',
|
|
588
|
+
left: jsonbContains('assigned_to', 'user123'),
|
|
589
|
+
right: dateWithinDays('created_at', 7)
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
const result = translator.translate(expr);
|
|
593
|
+
const sqlString = getSqlString(result);
|
|
594
|
+
|
|
595
|
+
expect(sqlString).toContain('AND');
|
|
596
|
+
expect(sqlString).toContain('assigned_to');
|
|
597
|
+
expect(sqlString).toContain('created_at');
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
describe('End-to-End Tests', () => {
|
|
602
|
+
it('should resolve and translate JSONB contains scenario', () => {
|
|
603
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
604
|
+
my: {
|
|
605
|
+
allowedValues: ['assigned'] as const,
|
|
606
|
+
description: 'Filter by your relationship to items',
|
|
607
|
+
resolve: (input, ctx) => {
|
|
608
|
+
if (input.value === 'assigned') {
|
|
609
|
+
return jsonbContains('assigned_to', ctx.currentUserId);
|
|
610
|
+
}
|
|
611
|
+
throw new Error(`Unknown value: ${input.value}`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
const context: IMockContext = {
|
|
617
|
+
currentUserId: 'user123'
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
const expr: IComparisonExpression = {
|
|
621
|
+
type: 'comparison',
|
|
622
|
+
field: 'my',
|
|
623
|
+
operator: '==',
|
|
624
|
+
value: 'assigned'
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
// Step 1: Resolve virtual fields
|
|
628
|
+
const resolved = resolveVirtualFields(expr, virtualFields, context);
|
|
629
|
+
expect(resolved.type).toBe('raw');
|
|
630
|
+
|
|
631
|
+
// Step 2: Translate to SQL
|
|
632
|
+
const translator = new DrizzleTranslator();
|
|
633
|
+
const result = translator.translate(resolved);
|
|
634
|
+
const sqlString = getSqlString(result);
|
|
635
|
+
|
|
636
|
+
expect(sqlString).toContain('assigned_to');
|
|
637
|
+
expect(sqlString).toContain('@>');
|
|
638
|
+
expect(sqlString).toContain('user123');
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it('should resolve and translate date-based computed field', () => {
|
|
642
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
643
|
+
priority: {
|
|
644
|
+
allowedValues: ['high', 'medium', 'low'] as const,
|
|
645
|
+
description: 'Filter by computed priority (based on age)',
|
|
646
|
+
resolve: input => {
|
|
647
|
+
const thresholds = { high: 1, medium: 7, low: 30 };
|
|
648
|
+
const days = thresholds[input.value as keyof typeof thresholds];
|
|
649
|
+
return dateWithinDays('created_at', days);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
const context: IMockContext = {
|
|
655
|
+
currentUserId: 'user123'
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
const expr: IComparisonExpression = {
|
|
659
|
+
type: 'comparison',
|
|
660
|
+
field: 'priority',
|
|
661
|
+
operator: '==',
|
|
662
|
+
value: 'high'
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
// Step 1: Resolve virtual fields
|
|
666
|
+
const resolved = resolveVirtualFields(expr, virtualFields, context);
|
|
667
|
+
expect(resolved.type).toBe('raw');
|
|
668
|
+
|
|
669
|
+
// Step 2: Translate to SQL
|
|
670
|
+
const translator = new DrizzleTranslator();
|
|
671
|
+
const result = translator.translate(resolved);
|
|
672
|
+
const sqlString = getSqlString(result);
|
|
673
|
+
|
|
674
|
+
expect(sqlString).toContain('created_at');
|
|
675
|
+
expect(sqlString).toContain('1');
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it('should handle custom raw SQL example', () => {
|
|
679
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
680
|
+
custom: {
|
|
681
|
+
allowedValues: ['active'] as const,
|
|
682
|
+
resolve: (_input, ctx) => ({
|
|
683
|
+
type: 'raw',
|
|
684
|
+
toSql: (): SQL => {
|
|
685
|
+
// Build SQL using the same pattern as the translators
|
|
686
|
+
const userId = ctx.currentUserId;
|
|
687
|
+
return sql`status = 'active' AND assignee_id = ${userId}`;
|
|
688
|
+
}
|
|
689
|
+
})
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
const context: IMockContext = {
|
|
694
|
+
currentUserId: 'user123'
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
const expr: IComparisonExpression = {
|
|
698
|
+
type: 'comparison',
|
|
699
|
+
field: 'custom',
|
|
700
|
+
operator: '==',
|
|
701
|
+
value: 'active'
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
// Step 1: Resolve virtual fields
|
|
705
|
+
const resolved = resolveVirtualFields(expr, virtualFields, context);
|
|
706
|
+
expect(resolved.type).toBe('raw');
|
|
707
|
+
|
|
708
|
+
// Step 2: Translate to SQL
|
|
709
|
+
const translator = new DrizzleTranslator();
|
|
710
|
+
const result = translator.translate(resolved);
|
|
711
|
+
const sqlString = getSqlString(result);
|
|
712
|
+
|
|
713
|
+
expect(sqlString).toContain('status');
|
|
714
|
+
expect(sqlString).toContain('active');
|
|
715
|
+
expect(sqlString).toContain('assignee_id');
|
|
716
|
+
expect(sqlString).toContain('user123');
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it('should handle complex query with multiple virtual fields', () => {
|
|
720
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
721
|
+
my: {
|
|
722
|
+
allowedValues: ['assigned'] as const,
|
|
723
|
+
resolve: (input, ctx) =>
|
|
724
|
+
jsonbContains('assigned_to', ctx.currentUserId)
|
|
725
|
+
},
|
|
726
|
+
priority: {
|
|
727
|
+
allowedValues: ['high'] as const,
|
|
728
|
+
resolve: () => dateWithinDays('created_at', 1)
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
const context: IMockContext = {
|
|
733
|
+
currentUserId: 'user123'
|
|
734
|
+
};
|
|
735
|
+
|
|
736
|
+
const expr: ILogicalExpression = {
|
|
737
|
+
type: 'logical',
|
|
738
|
+
operator: 'AND',
|
|
739
|
+
left: {
|
|
740
|
+
type: 'comparison',
|
|
741
|
+
field: 'my',
|
|
742
|
+
operator: '==',
|
|
743
|
+
value: 'assigned'
|
|
744
|
+
},
|
|
745
|
+
right: {
|
|
746
|
+
type: 'logical',
|
|
747
|
+
operator: 'AND',
|
|
748
|
+
left: {
|
|
749
|
+
type: 'comparison',
|
|
750
|
+
field: 'priority',
|
|
751
|
+
operator: '==',
|
|
752
|
+
value: 'high'
|
|
753
|
+
},
|
|
754
|
+
right: {
|
|
755
|
+
type: 'comparison',
|
|
756
|
+
field: 'status',
|
|
757
|
+
operator: '==',
|
|
758
|
+
value: 'active'
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
// Step 1: Resolve virtual fields
|
|
764
|
+
const resolved = resolveVirtualFields(
|
|
765
|
+
expr,
|
|
766
|
+
virtualFields,
|
|
767
|
+
context
|
|
768
|
+
) as ILogicalExpression;
|
|
769
|
+
|
|
770
|
+
expect(resolved.type).toBe('logical');
|
|
771
|
+
expect(resolved.left.type).toBe('raw');
|
|
772
|
+
|
|
773
|
+
// Step 2: Translate to SQL
|
|
774
|
+
const translator = new DrizzleTranslator();
|
|
775
|
+
const result = translator.translate(resolved);
|
|
776
|
+
const sqlString = getSqlString(result);
|
|
777
|
+
|
|
778
|
+
expect(sqlString).toContain('AND');
|
|
779
|
+
expect(sqlString).toContain('assigned_to');
|
|
780
|
+
expect(sqlString).toContain('created_at');
|
|
781
|
+
expect(sqlString).toContain('status');
|
|
782
|
+
});
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
describe('Error Handling', () => {
|
|
786
|
+
it('should handle malformed raw expression gracefully', () => {
|
|
787
|
+
const rawExpr: IRawSqlExpression = {
|
|
788
|
+
type: 'raw',
|
|
789
|
+
toSql: () => {
|
|
790
|
+
throw new Error('SQL generation failed');
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
const translator = new DrizzleTranslator();
|
|
795
|
+
|
|
796
|
+
expect(() => translator.translate(rawExpr)).toThrow();
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it('should validate that toSql returns valid SQL object', () => {
|
|
800
|
+
const rawExpr: IRawSqlExpression = {
|
|
801
|
+
type: 'raw',
|
|
802
|
+
toSql: () => sql`valid = 'sql'`
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
const translator = new DrizzleTranslator();
|
|
806
|
+
const result = translator.translate(rawExpr);
|
|
807
|
+
|
|
808
|
+
expect(result).toBeDefined();
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
describe('SQL Output Verification', () => {
|
|
813
|
+
it('should generate correct JSONB contains SQL', () => {
|
|
814
|
+
const expr = jsonbContains('assigned_to', 'user123');
|
|
815
|
+
const result = expr.toSql({
|
|
816
|
+
adapter: 'drizzle',
|
|
817
|
+
tableName: 'tasks',
|
|
818
|
+
schema: {}
|
|
819
|
+
}) as SQL;
|
|
820
|
+
|
|
821
|
+
const sqlString = getSqlString(result);
|
|
822
|
+
|
|
823
|
+
// Verify the SQL structure contains the expected elements
|
|
824
|
+
expect(sqlString).toContain('assigned_to');
|
|
825
|
+
expect(sqlString).toContain('@>');
|
|
826
|
+
expect(sqlString).toContain('user123');
|
|
827
|
+
expect(sqlString).toContain('::jsonb');
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it('should generate correct date range SQL', () => {
|
|
831
|
+
const expr = dateWithinDays('created_at', 7);
|
|
832
|
+
const result = expr.toSql({
|
|
833
|
+
adapter: 'drizzle',
|
|
834
|
+
tableName: 'tasks',
|
|
835
|
+
schema: {}
|
|
836
|
+
}) as SQL;
|
|
837
|
+
|
|
838
|
+
const sqlString = getSqlString(result);
|
|
839
|
+
|
|
840
|
+
// Verify the SQL structure contains the expected elements
|
|
841
|
+
expect(sqlString).toContain('created_at');
|
|
842
|
+
expect(sqlString).toContain('>=');
|
|
843
|
+
expect(sqlString).toContain('NOW()');
|
|
844
|
+
expect(sqlString).toContain('INTERVAL');
|
|
845
|
+
expect(sqlString).toContain('7');
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
it('should use sql.identifier for safe field references', () => {
|
|
849
|
+
const expr = jsonbContains('assigned_to', 'user123');
|
|
850
|
+
const result = expr.toSql({
|
|
851
|
+
adapter: 'drizzle',
|
|
852
|
+
tableName: 'tasks',
|
|
853
|
+
schema: {}
|
|
854
|
+
}) as SQL;
|
|
855
|
+
|
|
856
|
+
// sql.identifier should properly escape field names
|
|
857
|
+
expect(result).toBeDefined();
|
|
858
|
+
});
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
describe('Real-World Scenarios', () => {
|
|
862
|
+
it('should support JSONB array membership check', () => {
|
|
863
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
864
|
+
my: {
|
|
865
|
+
allowedValues: ['assigned'] as const,
|
|
866
|
+
resolve: (input, ctx) =>
|
|
867
|
+
jsonbContains('assigned_to', ctx.currentUserId)
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
const context: IMockContext = {
|
|
872
|
+
currentUserId: 'user123'
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
const expr: IComparisonExpression = {
|
|
876
|
+
type: 'comparison',
|
|
877
|
+
field: 'my',
|
|
878
|
+
operator: '==',
|
|
879
|
+
value: 'assigned'
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
const resolved = resolveVirtualFields(expr, virtualFields, context);
|
|
883
|
+
const translator = new DrizzleTranslator();
|
|
884
|
+
const result = translator.translate(resolved);
|
|
885
|
+
|
|
886
|
+
expect(result).toBeDefined();
|
|
887
|
+
const sqlString = getSqlString(result);
|
|
888
|
+
expect(sqlString).toContain('assigned_to');
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
it('should support computed priority based on createdAt', () => {
|
|
892
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
893
|
+
priority: {
|
|
894
|
+
allowedValues: ['high', 'medium', 'low'] as const,
|
|
895
|
+
resolve: input => {
|
|
896
|
+
const days = { high: 1, medium: 7, low: 30 }[
|
|
897
|
+
input.value as 'high' | 'medium' | 'low'
|
|
898
|
+
];
|
|
899
|
+
return dateWithinDays('created_at', days);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
const context: IMockContext = {
|
|
905
|
+
currentUserId: 'user123'
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
['high', 'medium', 'low'].forEach((priority, idx) => {
|
|
909
|
+
const expr: IComparisonExpression = {
|
|
910
|
+
type: 'comparison',
|
|
911
|
+
field: 'priority',
|
|
912
|
+
operator: '==',
|
|
913
|
+
value: priority
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
const resolved = resolveVirtualFields(expr, virtualFields, context);
|
|
917
|
+
const translator = new DrizzleTranslator();
|
|
918
|
+
const result = translator.translate(resolved);
|
|
919
|
+
const sqlString = getSqlString(result);
|
|
920
|
+
|
|
921
|
+
expect(sqlString).toContain('created_at');
|
|
922
|
+
expect(sqlString).toContain([1, 7, 30][idx].toString());
|
|
923
|
+
});
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
it('should support combined JSONB and date filters', () => {
|
|
927
|
+
const virtualFields: VirtualFieldsConfig<MockSchema, IMockContext> = {
|
|
928
|
+
my: {
|
|
929
|
+
allowedValues: ['assigned'] as const,
|
|
930
|
+
resolve: (input, ctx) =>
|
|
931
|
+
jsonbContains('assigned_to', ctx.currentUserId)
|
|
932
|
+
},
|
|
933
|
+
priority: {
|
|
934
|
+
allowedValues: ['high'] as const,
|
|
935
|
+
resolve: () => dateWithinDays('created_at', 1)
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
const context: IMockContext = {
|
|
940
|
+
currentUserId: 'user123'
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
const expr: ILogicalExpression = {
|
|
944
|
+
type: 'logical',
|
|
945
|
+
operator: 'AND',
|
|
946
|
+
left: {
|
|
947
|
+
type: 'comparison',
|
|
948
|
+
field: 'my',
|
|
949
|
+
operator: '==',
|
|
950
|
+
value: 'assigned'
|
|
951
|
+
},
|
|
952
|
+
right: {
|
|
953
|
+
type: 'comparison',
|
|
954
|
+
field: 'priority',
|
|
955
|
+
operator: '==',
|
|
956
|
+
value: 'high'
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
|
|
960
|
+
const resolved = resolveVirtualFields(
|
|
961
|
+
expr,
|
|
962
|
+
virtualFields,
|
|
963
|
+
context
|
|
964
|
+
) as ILogicalExpression;
|
|
965
|
+
|
|
966
|
+
expect(resolved.left.type).toBe('raw');
|
|
967
|
+
expect(resolved.right?.type).toBe('raw');
|
|
968
|
+
|
|
969
|
+
const translator = new DrizzleTranslator();
|
|
970
|
+
const result = translator.translate(resolved);
|
|
971
|
+
const sqlString = getSqlString(result);
|
|
972
|
+
|
|
973
|
+
expect(sqlString).toContain('AND');
|
|
974
|
+
expect(sqlString).toContain('assigned_to');
|
|
975
|
+
expect(sqlString).toContain('created_at');
|
|
976
|
+
});
|
|
977
|
+
});
|
|
978
|
+
});
|