@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
|
@@ -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,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
|
+
});
|