@gblikas/querykit 0.2.0 → 0.4.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/.cursor/BUGBOT.md +65 -2
- package/.husky/pre-commit +3 -3
- package/README.md +510 -1
- package/dist/index.d.ts +36 -3
- package/dist/index.js +20 -3
- package/dist/parser/index.d.ts +1 -0
- package/dist/parser/index.js +1 -0
- package/dist/parser/input-parser.d.ts +215 -0
- package/dist/parser/input-parser.js +493 -0
- package/dist/parser/parser.d.ts +114 -1
- package/dist/parser/parser.js +716 -0
- package/dist/parser/types.d.ts +432 -0
- package/dist/virtual-fields/index.d.ts +5 -0
- package/dist/virtual-fields/index.js +21 -0
- package/dist/virtual-fields/resolver.d.ts +17 -0
- package/dist/virtual-fields/resolver.js +107 -0
- package/dist/virtual-fields/types.d.ts +160 -0
- package/dist/virtual-fields/types.js +5 -0
- package/examples/qk-next/app/page.tsx +190 -86
- 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/divergence.test.ts +357 -0
- package/src/parser/index.ts +2 -1
- package/src/parser/input-parser.test.ts +770 -0
- package/src/parser/input-parser.ts +697 -0
- package/src/parser/parse-with-context-suggestions.test.ts +360 -0
- package/src/parser/parse-with-context-validation.test.ts +447 -0
- package/src/parser/parse-with-context.test.ts +325 -0
- package/src/parser/parser.ts +872 -0
- package/src/parser/token-consistency.test.ts +341 -0
- package/src/parser/types.ts +545 -23
- package/src/virtual-fields/index.ts +6 -0
- package/src/virtual-fields/integration.test.ts +338 -0
- package/src/virtual-fields/resolver.ts +165 -0
- package/src/virtual-fields/types.ts +203 -0
- package/src/virtual-fields/virtual-fields.test.ts +831 -0
- package/examples/qk-next/pnpm-lock.yaml +0 -5623
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Virtual field resolution logic
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
QueryExpression,
|
|
7
|
+
IComparisonExpression,
|
|
8
|
+
ILogicalExpression
|
|
9
|
+
} from '../parser/types';
|
|
10
|
+
import { QueryParseError } from '../parser/parser';
|
|
11
|
+
import {
|
|
12
|
+
IQueryContext,
|
|
13
|
+
IVirtualFieldInput,
|
|
14
|
+
VirtualFieldsConfig,
|
|
15
|
+
IResolverHelpers,
|
|
16
|
+
SchemaFieldMap
|
|
17
|
+
} from './types';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve virtual fields in a query expression.
|
|
21
|
+
* Recursively walks the AST and replaces virtual field references with
|
|
22
|
+
* their resolved expressions based on the provided context.
|
|
23
|
+
*
|
|
24
|
+
* @param expr - The query expression to resolve
|
|
25
|
+
* @param virtualFields - Virtual field configuration
|
|
26
|
+
* @param context - Runtime context for resolution
|
|
27
|
+
* @returns The resolved query expression
|
|
28
|
+
* @throws {QueryParseError} If a virtual field value is invalid or operator is not allowed
|
|
29
|
+
*/
|
|
30
|
+
export function resolveVirtualFields<
|
|
31
|
+
TSchema extends Record<string, object>,
|
|
32
|
+
TContext extends IQueryContext
|
|
33
|
+
>(
|
|
34
|
+
expr: QueryExpression,
|
|
35
|
+
virtualFields: VirtualFieldsConfig<TSchema, TContext>,
|
|
36
|
+
context: TContext
|
|
37
|
+
): QueryExpression {
|
|
38
|
+
// Base case: comparison expression
|
|
39
|
+
if (expr.type === 'comparison') {
|
|
40
|
+
return resolveComparisonExpression(expr, virtualFields, context);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Recursive case: logical expression
|
|
44
|
+
if (expr.type === 'logical') {
|
|
45
|
+
return resolveLogicalExpression(expr, virtualFields, context);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Unknown expression type, return as-is
|
|
49
|
+
return expr;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve a comparison expression.
|
|
54
|
+
* If the field is a virtual field, resolve it using the configuration.
|
|
55
|
+
* Otherwise, return the expression unchanged.
|
|
56
|
+
*/
|
|
57
|
+
function resolveComparisonExpression<
|
|
58
|
+
TSchema extends Record<string, object>,
|
|
59
|
+
TContext extends IQueryContext
|
|
60
|
+
>(
|
|
61
|
+
expr: IComparisonExpression,
|
|
62
|
+
virtualFields: VirtualFieldsConfig<TSchema, TContext>,
|
|
63
|
+
context: TContext
|
|
64
|
+
): QueryExpression {
|
|
65
|
+
const fieldName = expr.field;
|
|
66
|
+
const virtualFieldDef = virtualFields[fieldName];
|
|
67
|
+
|
|
68
|
+
// Not a virtual field, return as-is
|
|
69
|
+
if (!virtualFieldDef) {
|
|
70
|
+
return expr;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Validate the value is a string (virtual fields require string values)
|
|
74
|
+
if (typeof expr.value !== 'string') {
|
|
75
|
+
const valueType = Array.isArray(expr.value)
|
|
76
|
+
? `array (${JSON.stringify(expr.value)})`
|
|
77
|
+
: typeof expr.value === 'object'
|
|
78
|
+
? `object (${JSON.stringify(expr.value)})`
|
|
79
|
+
: typeof expr.value;
|
|
80
|
+
|
|
81
|
+
throw new QueryParseError(
|
|
82
|
+
`Virtual field "${fieldName}" requires a string value, got ${valueType}`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const value = expr.value;
|
|
87
|
+
|
|
88
|
+
// Validate the value is in allowedValues
|
|
89
|
+
if (!virtualFieldDef.allowedValues.includes(value)) {
|
|
90
|
+
const allowedValuesStr = virtualFieldDef.allowedValues
|
|
91
|
+
.map(v => `"${v}"`)
|
|
92
|
+
.join(', ');
|
|
93
|
+
throw new QueryParseError(
|
|
94
|
+
`Invalid value "${value}" for virtual field "${fieldName}". Allowed values: ${allowedValuesStr}`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Validate operator usage
|
|
99
|
+
const allowOperators = virtualFieldDef.allowOperators ?? false;
|
|
100
|
+
if (!allowOperators && expr.operator !== '==') {
|
|
101
|
+
throw new QueryParseError(
|
|
102
|
+
`Virtual field "${fieldName}" does not allow comparison operators. Only equality (":") is permitted.`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Create the input for the resolver
|
|
107
|
+
const input: IVirtualFieldInput & { value: string } = {
|
|
108
|
+
field: fieldName,
|
|
109
|
+
operator: expr.operator,
|
|
110
|
+
value: value
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Create the helpers object with type-safe fields() helper
|
|
114
|
+
// The fields() method is generic at the method level, allowing TypeScript to
|
|
115
|
+
// infer TValues from the mapping object at call-time without needing type assertions
|
|
116
|
+
const helpers: IResolverHelpers<TSchema> = {
|
|
117
|
+
fields: <TValues extends string>(
|
|
118
|
+
mapping: SchemaFieldMap<TValues, TSchema>
|
|
119
|
+
): SchemaFieldMap<TValues, TSchema> => {
|
|
120
|
+
// Validate that all keys in the mapping are in the virtual field's allowed values
|
|
121
|
+
const mappingKeys = Object.keys(mapping);
|
|
122
|
+
const allowedValues = virtualFieldDef.allowedValues as readonly string[];
|
|
123
|
+
|
|
124
|
+
for (const key of mappingKeys) {
|
|
125
|
+
if (!allowedValues.includes(key)) {
|
|
126
|
+
throw new QueryParseError(
|
|
127
|
+
`Invalid key "${key}" in field mapping for virtual field "${fieldName}". ` +
|
|
128
|
+
`Allowed keys are: ${allowedValues.map(v => `"${v}"`).join(', ')}`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Runtime: this is just an identity function
|
|
134
|
+
// Compile-time: TypeScript validates the mapping structure
|
|
135
|
+
return mapping;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Resolve the virtual field - no type assertions needed!
|
|
140
|
+
const resolved = virtualFieldDef.resolve(input, context, helpers);
|
|
141
|
+
|
|
142
|
+
return resolved as QueryExpression;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Resolve a logical expression.
|
|
147
|
+
* Recursively resolve both left and right sides.
|
|
148
|
+
*/
|
|
149
|
+
function resolveLogicalExpression<
|
|
150
|
+
TSchema extends Record<string, object>,
|
|
151
|
+
TContext extends IQueryContext
|
|
152
|
+
>(
|
|
153
|
+
expr: ILogicalExpression,
|
|
154
|
+
virtualFields: VirtualFieldsConfig<TSchema, TContext>,
|
|
155
|
+
context: TContext
|
|
156
|
+
): ILogicalExpression {
|
|
157
|
+
return {
|
|
158
|
+
type: 'logical',
|
|
159
|
+
operator: expr.operator,
|
|
160
|
+
left: resolveVirtualFields(expr.left, virtualFields, context),
|
|
161
|
+
right: expr.right
|
|
162
|
+
? resolveVirtualFields(expr.right, virtualFields, context)
|
|
163
|
+
: undefined
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for Virtual Fields support
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
QueryExpression,
|
|
7
|
+
IComparisonExpression,
|
|
8
|
+
ComparisonOperator
|
|
9
|
+
} from '../parser/types';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Base interface for query context.
|
|
13
|
+
* Users can extend this interface with their own context properties.
|
|
14
|
+
*/
|
|
15
|
+
export interface IQueryContext {
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Input provided to a virtual field resolver.
|
|
21
|
+
* Contains the parsed field, operator, and value from the query.
|
|
22
|
+
*/
|
|
23
|
+
export interface IVirtualFieldInput {
|
|
24
|
+
/**
|
|
25
|
+
* The virtual field name (e.g., "my")
|
|
26
|
+
*/
|
|
27
|
+
field: string;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The comparison operator used (e.g., ":", ">", "<", etc.)
|
|
31
|
+
* Maps to ComparisonOperator type
|
|
32
|
+
*/
|
|
33
|
+
operator: string;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The value provided in the query
|
|
37
|
+
*/
|
|
38
|
+
value: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Helper type to filter out index signatures from a type
|
|
43
|
+
*/
|
|
44
|
+
type KnownKeys<T> = {
|
|
45
|
+
[K in keyof T]: string extends K ? never : number extends K ? never : K;
|
|
46
|
+
} extends { [_ in keyof T]: infer U }
|
|
47
|
+
? U
|
|
48
|
+
: never;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Utility type to extract all field names from a schema.
|
|
52
|
+
* Recursively extracts field names from nested tables, excluding index signatures.
|
|
53
|
+
*/
|
|
54
|
+
export type AllSchemaFields<TSchema extends Record<string, object>> = {
|
|
55
|
+
[K in KnownKeys<TSchema>]: TSchema[K] extends { [key: string]: unknown }
|
|
56
|
+
? keyof TSchema[K] & string
|
|
57
|
+
: never;
|
|
58
|
+
}[KnownKeys<TSchema>];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Type-safe mapping from allowed values to schema fields.
|
|
62
|
+
* Ensures all keys in TKeys map to valid fields in the schema.
|
|
63
|
+
*/
|
|
64
|
+
export type SchemaFieldMap<
|
|
65
|
+
TKeys extends string,
|
|
66
|
+
TSchema extends Record<string, object>
|
|
67
|
+
> = Record<TKeys, AllSchemaFields<TSchema>>;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Helper functions provided to virtual field resolvers.
|
|
71
|
+
*
|
|
72
|
+
* Note: The fields() method is generic at the method level, not the interface level.
|
|
73
|
+
* This allows TypeScript to infer TValues from the mapping object passed at call-time,
|
|
74
|
+
* eliminating the need for type assertions while maintaining full type safety.
|
|
75
|
+
*/
|
|
76
|
+
export interface IResolverHelpers<TSchema extends Record<string, object>> {
|
|
77
|
+
/**
|
|
78
|
+
* Type-safe field mapping helper.
|
|
79
|
+
* Ensures all allowedValues are mapped to valid schema fields.
|
|
80
|
+
*
|
|
81
|
+
* The generic TValues parameter is inferred from the keys in the mapping object,
|
|
82
|
+
* providing full type safety without requiring explicit type annotations.
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* const fieldMap = fields({
|
|
86
|
+
* assigned: 'assignee_id',
|
|
87
|
+
* created: 'creator_id'
|
|
88
|
+
* });
|
|
89
|
+
* // TypeScript infers TValues as 'assigned' | 'created'
|
|
90
|
+
*/
|
|
91
|
+
fields: <TValues extends string>(
|
|
92
|
+
mapping: SchemaFieldMap<TValues, TSchema>
|
|
93
|
+
) => SchemaFieldMap<TValues, TSchema>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Schema-constrained comparison expression.
|
|
98
|
+
* Ensures field names are valid schema fields.
|
|
99
|
+
*/
|
|
100
|
+
export interface ITypedComparisonExpression<
|
|
101
|
+
TFields extends string = string
|
|
102
|
+
> extends Omit<IComparisonExpression, 'field'> {
|
|
103
|
+
type: 'comparison';
|
|
104
|
+
field: TFields;
|
|
105
|
+
operator: ComparisonOperator;
|
|
106
|
+
value:
|
|
107
|
+
| string
|
|
108
|
+
| number
|
|
109
|
+
| boolean
|
|
110
|
+
| null
|
|
111
|
+
| Array<string | number | boolean | null>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Schema-constrained query expression.
|
|
116
|
+
* Can be a comparison or logical expression with typed fields.
|
|
117
|
+
*/
|
|
118
|
+
export type ITypedQueryExpression<TFields extends string = string> =
|
|
119
|
+
| ITypedComparisonExpression<TFields>
|
|
120
|
+
| QueryExpression;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Definition for a virtual field.
|
|
124
|
+
* Configures how a virtual field should be resolved at query execution time.
|
|
125
|
+
*/
|
|
126
|
+
export interface IVirtualFieldDefinition<
|
|
127
|
+
TSchema extends Record<string, object>,
|
|
128
|
+
TContext extends IQueryContext = IQueryContext,
|
|
129
|
+
TValues extends string = string
|
|
130
|
+
> {
|
|
131
|
+
/**
|
|
132
|
+
* Allowed values for this virtual field.
|
|
133
|
+
* Use `as const` for type inference.
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* allowedValues: ['assigned', 'created', 'watching'] as const
|
|
137
|
+
*/
|
|
138
|
+
allowedValues: readonly TValues[];
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Whether to allow comparison operators beyond `:` (equality).
|
|
142
|
+
* If false, only `:` is allowed. If true, `:>`, `:<`, etc. are permitted.
|
|
143
|
+
*
|
|
144
|
+
* @default false
|
|
145
|
+
*/
|
|
146
|
+
allowOperators?: boolean;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Resolve the virtual field to a real query expression.
|
|
150
|
+
* The `fields` helper ensures type-safe field references.
|
|
151
|
+
*
|
|
152
|
+
* @param input - The parsed virtual field input (field, operator, value)
|
|
153
|
+
* @param context - Runtime context provided by createContext()
|
|
154
|
+
* @param helpers - Helper functions including type-safe fields() helper
|
|
155
|
+
* @returns A query expression that replaces the virtual field
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* resolve: (input, ctx, { fields }) => {
|
|
159
|
+
* const fieldMap = fields({
|
|
160
|
+
* assigned: 'assignee_id',
|
|
161
|
+
* created: 'creator_id'
|
|
162
|
+
* });
|
|
163
|
+
* return {
|
|
164
|
+
* type: 'comparison',
|
|
165
|
+
* field: fieldMap[input.value],
|
|
166
|
+
* operator: '==',
|
|
167
|
+
* value: ctx.currentUserId
|
|
168
|
+
* };
|
|
169
|
+
* }
|
|
170
|
+
*/
|
|
171
|
+
resolve: (
|
|
172
|
+
input: IVirtualFieldInput & { value: TValues },
|
|
173
|
+
context: TContext,
|
|
174
|
+
helpers: IResolverHelpers<TSchema>
|
|
175
|
+
) => ITypedQueryExpression<AllSchemaFields<TSchema>>;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Human-readable description (for autocomplete UI).
|
|
179
|
+
* Optional metadata for documentation and tooling.
|
|
180
|
+
*/
|
|
181
|
+
description?: string;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Descriptions for each allowed value (for autocomplete UI).
|
|
185
|
+
* Optional metadata for documentation and tooling.
|
|
186
|
+
*/
|
|
187
|
+
valueDescriptions?: Partial<Record<TValues, string>>;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Configuration for all virtual fields in a QueryKit instance.
|
|
192
|
+
*
|
|
193
|
+
* Note: Uses a flexible type for the values to allow each virtual field definition
|
|
194
|
+
* to have its own specific TValues type (e.g., 'assigned' | 'created' for one field,
|
|
195
|
+
* 'high' | 'low' for another). The IResolverHelpers.fields() method infers these
|
|
196
|
+
* types at call-time, maintaining type safety without needing explicit annotations.
|
|
197
|
+
*/
|
|
198
|
+
export type VirtualFieldsConfig<
|
|
199
|
+
TSchema extends Record<string, object> = Record<string, object>,
|
|
200
|
+
TContext extends IQueryContext = IQueryContext
|
|
201
|
+
> = {
|
|
202
|
+
[fieldName: string]: IVirtualFieldDefinition<TSchema, TContext, string>;
|
|
203
|
+
};
|