@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.
@@ -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}`
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Helper utilities for creating raw SQL expressions in virtual fields
3
+ */
4
+
5
+ import { sql } from 'drizzle-orm';
6
+ import { IRawSqlExpression } from '../parser/types';
7
+
8
+ /**
9
+ * Validates field name to prevent SQL injection.
10
+ * Only allows alphanumeric characters, dots, and underscores.
11
+ * @private
12
+ */
13
+ function validateFieldName(field: string): void {
14
+ if (!/^[a-zA-Z][a-zA-Z0-9._]*$/.test(field)) {
15
+ throw new Error(`Invalid field name: ${field}`);
16
+ }
17
+ if (field.length > 64) {
18
+ throw new Error(`Field name too long: ${field}`);
19
+ }
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
+ export function jsonbContains(
36
+ field: string,
37
+ value: unknown
38
+ ): IRawSqlExpression {
39
+ validateFieldName(field);
40
+ return {
41
+ type: 'raw',
42
+ toSql: () =>
43
+ sql`${sql.identifier(field)} @> ${sql.raw("'" + JSON.stringify(Array.isArray(value) ? value : [value]).replace(/'/g, "''") + "'::jsonb")}`
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Validates days parameter to ensure it's a positive finite number.
49
+ * @private
50
+ */
51
+ function validateDaysParameter(days: number): void {
52
+ if (!Number.isFinite(days)) {
53
+ throw new Error(`Invalid days parameter: ${days}. Must be a finite number.`);
54
+ }
55
+ if (days <= 0) {
56
+ throw new Error(`Invalid days parameter: ${days}. Must be a positive number.`);
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Create a date range expression.
62
+ * Checks if a timestamp field is within the specified number of days from now.
63
+ *
64
+ * @param field - The timestamp field name (e.g., 'created_at')
65
+ * @param days - Number of days from now (must be a positive finite number)
66
+ * @returns A raw SQL expression for date range check
67
+ *
68
+ * @example
69
+ * // Check if created within last day
70
+ * dateWithinDays('created_at', 1)
71
+ * // Generates: created_at >= NOW() - INTERVAL '1 days'
72
+ */
73
+ export function dateWithinDays(field: string, days: number): IRawSqlExpression {
74
+ validateFieldName(field);
75
+ validateDaysParameter(days);
76
+ return {
77
+ type: 'raw',
78
+ toSql: () =>
79
+ sql`${sql.identifier(field)} >= NOW() - INTERVAL '${sql.raw(days.toString())} days'`
80
+ };
81
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Virtual Fields module exports
3
+ */
4
+
5
+ export * from './types';
6
+ export * from './resolver';
7
+ export * from './helpers';
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Integration test for Virtual Fields functionality
3
+ * Demonstrates end-to-end usage with QueryKit
4
+ */
5
+
6
+ import { createQueryKit } from '../index';
7
+ import { IQueryContext } from '../virtual-fields';
8
+ import { IAdapter, IAdapterOptions } from '../adapters/types';
9
+ import { QueryExpression } from '../parser/types';
10
+
11
+ // Mock schema for testing
12
+ type MockSchema = {
13
+ tasks: {
14
+ id: number;
15
+ title: string;
16
+ assignee_id: number;
17
+ creator_id: number;
18
+ status: string;
19
+ priority: number;
20
+ };
21
+ };
22
+
23
+ // Mock context for testing
24
+ interface ITaskContext extends IQueryContext {
25
+ currentUserId: number;
26
+ currentUserTeamIds: number[];
27
+ }
28
+
29
+ // Mock adapter for testing
30
+ class MockAdapter implements IAdapter {
31
+ name = 'mock';
32
+ private lastExpression?: QueryExpression;
33
+ private lastTable?: string;
34
+
35
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
36
+ initialize(_options: IAdapterOptions): void {
37
+ // Mock initialization
38
+ }
39
+
40
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
41
+ canExecute(_expression: QueryExpression): boolean {
42
+ return true;
43
+ }
44
+
45
+ async execute<T = unknown>(
46
+ table: string,
47
+ expression: QueryExpression
48
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
49
+ ): Promise<T[]> {
50
+ // Store for verification
51
+ this.lastExpression = expression;
52
+ this.lastTable = table;
53
+
54
+ // Return mock data
55
+ return [
56
+ { id: 1, title: 'Task 1', assignee_id: 123 },
57
+ { id: 2, title: 'Task 2', assignee_id: 123 }
58
+ ] as T[];
59
+ }
60
+
61
+ getLastExpression(): QueryExpression | undefined {
62
+ return this.lastExpression;
63
+ }
64
+
65
+ getLastTable(): string | undefined {
66
+ return this.lastTable;
67
+ }
68
+ }
69
+
70
+ describe('Virtual Fields Integration', () => {
71
+ it('should resolve virtual fields in a complete QueryKit flow', async () => {
72
+ const mockAdapter = new MockAdapter();
73
+
74
+ const qk = createQueryKit<MockSchema, ITaskContext>({
75
+ adapter: mockAdapter,
76
+ schema: {
77
+ tasks: {
78
+ id: 0,
79
+ title: '',
80
+ assignee_id: 0,
81
+ creator_id: 0,
82
+ status: '',
83
+ priority: 0
84
+ }
85
+ },
86
+
87
+ virtualFields: {
88
+ my: {
89
+ allowedValues: ['assigned', 'created'] as const,
90
+ resolve: (input, ctx, { fields }) => {
91
+ const fieldMap = fields({
92
+ assigned: 'assignee_id',
93
+ created: 'creator_id'
94
+ });
95
+ return {
96
+ type: 'comparison',
97
+ field: fieldMap[input.value as 'assigned' | 'created'],
98
+ operator: '==',
99
+ value: ctx.currentUserId
100
+ };
101
+ }
102
+ }
103
+ },
104
+
105
+ createContext: async () => ({
106
+ currentUserId: 123,
107
+ currentUserTeamIds: [1, 2, 3]
108
+ })
109
+ });
110
+
111
+ // Execute a query with a virtual field
112
+ const results = await qk.query('tasks').where('my:assigned').execute();
113
+
114
+ // Verify the results
115
+ expect(results).toHaveLength(2);
116
+ expect(results[0]).toHaveProperty('id', 1);
117
+
118
+ // Verify the expression was resolved correctly
119
+ const lastExpression = mockAdapter.getLastExpression();
120
+ expect(lastExpression).toBeDefined();
121
+ expect(lastExpression?.type).toBe('comparison');
122
+
123
+ if (lastExpression?.type === 'comparison') {
124
+ expect(lastExpression.field).toBe('assignee_id');
125
+ expect(lastExpression.operator).toBe('==');
126
+ expect(lastExpression.value).toBe(123);
127
+ }
128
+ });
129
+
130
+ it('should resolve virtual fields combined with regular fields', async () => {
131
+ const mockAdapter = new MockAdapter();
132
+
133
+ const qk = createQueryKit<MockSchema, ITaskContext>({
134
+ adapter: mockAdapter,
135
+ schema: {
136
+ tasks: {
137
+ id: 0,
138
+ title: '',
139
+ assignee_id: 0,
140
+ creator_id: 0,
141
+ status: '',
142
+ priority: 0
143
+ }
144
+ },
145
+
146
+ virtualFields: {
147
+ my: {
148
+ allowedValues: ['assigned'] as const,
149
+ resolve: (_input, ctx) => ({
150
+ type: 'comparison',
151
+ field: 'assignee_id',
152
+ operator: '==',
153
+ value: ctx.currentUserId
154
+ })
155
+ }
156
+ },
157
+
158
+ createContext: async () => ({
159
+ currentUserId: 456,
160
+ currentUserTeamIds: []
161
+ })
162
+ });
163
+
164
+ // Execute a query with virtual field and regular field
165
+ await qk.query('tasks').where('my:assigned AND status:active').execute();
166
+
167
+ // Verify the expression was resolved correctly
168
+ const lastExpression = mockAdapter.getLastExpression();
169
+ expect(lastExpression).toBeDefined();
170
+ expect(lastExpression?.type).toBe('logical');
171
+
172
+ if (lastExpression?.type === 'logical') {
173
+ expect(lastExpression.operator).toBe('AND');
174
+
175
+ // Check left side (virtual field resolved)
176
+ const left = lastExpression.left;
177
+ expect(left.type).toBe('comparison');
178
+ if (left.type === 'comparison') {
179
+ expect(left.field).toBe('assignee_id');
180
+ expect(left.value).toBe(456);
181
+ }
182
+
183
+ // Check right side (regular field unchanged)
184
+ const right = lastExpression.right;
185
+ expect(right?.type).toBe('comparison');
186
+ if (right?.type === 'comparison') {
187
+ expect(right.field).toBe('status');
188
+ expect(right.value).toBe('active');
189
+ }
190
+ }
191
+ });
192
+
193
+ it('should work without virtual fields configured', async () => {
194
+ const mockAdapter = new MockAdapter();
195
+
196
+ const qk = createQueryKit<MockSchema>({
197
+ adapter: mockAdapter,
198
+ schema: {
199
+ tasks: {
200
+ id: 0,
201
+ title: '',
202
+ assignee_id: 0,
203
+ creator_id: 0,
204
+ status: '',
205
+ priority: 0
206
+ }
207
+ }
208
+ // No virtualFields or createContext
209
+ });
210
+
211
+ // Execute a regular query
212
+ const results = await qk.query('tasks').where('status:active').execute();
213
+
214
+ expect(results).toHaveLength(2);
215
+
216
+ // Verify the expression was not modified
217
+ const lastExpression = mockAdapter.getLastExpression();
218
+ expect(lastExpression?.type).toBe('comparison');
219
+ if (lastExpression?.type === 'comparison') {
220
+ expect(lastExpression.field).toBe('status');
221
+ expect(lastExpression.value).toBe('active');
222
+ }
223
+ });
224
+
225
+ it('should support multiple virtual fields in the same query', async () => {
226
+ const mockAdapter = new MockAdapter();
227
+
228
+ const qk = createQueryKit<MockSchema, ITaskContext>({
229
+ adapter: mockAdapter,
230
+ schema: {
231
+ tasks: {
232
+ id: 0,
233
+ title: '',
234
+ assignee_id: 0,
235
+ creator_id: 0,
236
+ status: '',
237
+ priority: 0
238
+ }
239
+ },
240
+
241
+ virtualFields: {
242
+ my: {
243
+ allowedValues: ['assigned', 'created'] as const,
244
+ resolve: (input, ctx, { fields }) => {
245
+ const fieldMap = fields({
246
+ assigned: 'assignee_id',
247
+ created: 'creator_id'
248
+ });
249
+ return {
250
+ type: 'comparison',
251
+ field: fieldMap[input.value as 'assigned' | 'created'],
252
+ operator: '==',
253
+ value: ctx.currentUserId
254
+ };
255
+ }
256
+ }
257
+ },
258
+
259
+ createContext: async () => ({
260
+ currentUserId: 789,
261
+ currentUserTeamIds: []
262
+ })
263
+ });
264
+
265
+ // Execute a query with multiple uses of the same virtual field
266
+ await qk.query('tasks').where('my:assigned OR my:created').execute();
267
+
268
+ // Verify both were resolved
269
+ const lastExpression = mockAdapter.getLastExpression();
270
+ expect(lastExpression?.type).toBe('logical');
271
+
272
+ if (lastExpression?.type === 'logical') {
273
+ expect(lastExpression.operator).toBe('OR');
274
+
275
+ // Check left side (my:assigned)
276
+ const left = lastExpression.left;
277
+ expect(left.type).toBe('comparison');
278
+ if (left.type === 'comparison') {
279
+ expect(left.field).toBe('assignee_id');
280
+ expect(left.value).toBe(789);
281
+ }
282
+
283
+ // Check right side (my:created)
284
+ const right = lastExpression.right;
285
+ expect(right?.type).toBe('comparison');
286
+ if (right?.type === 'comparison') {
287
+ expect(right.field).toBe('creator_id');
288
+ expect(right.value).toBe(789);
289
+ }
290
+ }
291
+ });
292
+
293
+ it('should use fluent API methods after virtual field resolution', async () => {
294
+ const mockAdapter = new MockAdapter();
295
+
296
+ const qk = createQueryKit<MockSchema, ITaskContext>({
297
+ adapter: mockAdapter,
298
+ schema: {
299
+ tasks: {
300
+ id: 0,
301
+ title: '',
302
+ assignee_id: 0,
303
+ creator_id: 0,
304
+ status: '',
305
+ priority: 0
306
+ }
307
+ },
308
+
309
+ virtualFields: {
310
+ my: {
311
+ allowedValues: ['assigned'] as const,
312
+ resolve: (_input, ctx) => ({
313
+ type: 'comparison',
314
+ field: 'assignee_id',
315
+ operator: '==',
316
+ value: ctx.currentUserId
317
+ })
318
+ }
319
+ },
320
+
321
+ createContext: async () => ({
322
+ currentUserId: 111,
323
+ currentUserTeamIds: []
324
+ })
325
+ });
326
+
327
+ // Execute a query with virtual field and fluent API methods
328
+ const results = await qk
329
+ .query('tasks')
330
+ .where('my:assigned')
331
+ .orderBy('priority', 'desc')
332
+ .limit(5)
333
+ .execute();
334
+
335
+ expect(results).toBeDefined();
336
+ expect(mockAdapter.getLastTable()).toBe('tasks');
337
+ });
338
+ });