@gblikas/querykit 0.3.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/.husky/pre-commit +3 -3
- package/README.md +347 -0
- package/dist/index.d.ts +36 -3
- package/dist/index.js +20 -3
- 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 +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/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/src/index.ts
CHANGED
|
@@ -12,6 +12,11 @@ import { QueryParser, IParserOptions } from './parser';
|
|
|
12
12
|
import { SqlTranslator } from './translators/sql';
|
|
13
13
|
import { ISecurityOptions, QuerySecurityValidator } from './security';
|
|
14
14
|
import { IAdapter, IAdapterOptions } from './adapters';
|
|
15
|
+
import {
|
|
16
|
+
IQueryContext,
|
|
17
|
+
VirtualFieldsConfig,
|
|
18
|
+
resolveVirtualFields
|
|
19
|
+
} from './virtual-fields';
|
|
15
20
|
|
|
16
21
|
export {
|
|
17
22
|
// Parser exports
|
|
@@ -29,6 +34,7 @@ export {
|
|
|
29
34
|
// Re-export from modules
|
|
30
35
|
export * from './translators';
|
|
31
36
|
export * from './adapters';
|
|
37
|
+
export * from './virtual-fields';
|
|
32
38
|
|
|
33
39
|
/**
|
|
34
40
|
* Create a new QueryBuilder instance
|
|
@@ -53,7 +59,8 @@ export interface IQueryKitOptions<
|
|
|
53
59
|
TSchema extends Record<string, object> = Record<
|
|
54
60
|
string,
|
|
55
61
|
Record<string, unknown>
|
|
56
|
-
|
|
62
|
+
>,
|
|
63
|
+
TContext extends IQueryContext = IQueryContext
|
|
57
64
|
> {
|
|
58
65
|
/**
|
|
59
66
|
* The adapter to use for database connections
|
|
@@ -74,6 +81,39 @@ export interface IQueryKitOptions<
|
|
|
74
81
|
* Options to initialize the provided adapter
|
|
75
82
|
*/
|
|
76
83
|
adapterOptions?: IAdapterOptions & { [key: string]: unknown };
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Virtual field definitions for context-aware query expansion.
|
|
87
|
+
* Virtual fields allow shortcuts like `my:assigned` that expand to
|
|
88
|
+
* real schema fields at query execution time.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* virtualFields: {
|
|
92
|
+
* my: {
|
|
93
|
+
* allowedValues: ['assigned', 'created'] as const,
|
|
94
|
+
* resolve: (input, ctx, { fields }) => ({
|
|
95
|
+
* type: 'comparison',
|
|
96
|
+
* field: fields({ assigned: 'assignee_id', created: 'creator_id' })[input.value],
|
|
97
|
+
* operator: '==',
|
|
98
|
+
* value: ctx.currentUserId
|
|
99
|
+
* })
|
|
100
|
+
* }
|
|
101
|
+
* }
|
|
102
|
+
*/
|
|
103
|
+
virtualFields?: VirtualFieldsConfig<TSchema, TContext>;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Factory function to create query execution context.
|
|
107
|
+
* Called once per query execution to provide runtime values
|
|
108
|
+
* for virtual field resolution.
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* createContext: async () => ({
|
|
112
|
+
* currentUserId: await getCurrentUserId(),
|
|
113
|
+
* currentUserTeamIds: await getUserTeamIds()
|
|
114
|
+
* })
|
|
115
|
+
*/
|
|
116
|
+
createContext?: () => TContext | Promise<TContext>;
|
|
77
117
|
}
|
|
78
118
|
|
|
79
119
|
// Define interfaces for return types
|
|
@@ -105,10 +145,11 @@ export type QueryKit<
|
|
|
105
145
|
*/
|
|
106
146
|
export function createQueryKit<
|
|
107
147
|
TSchema extends Record<string, object>,
|
|
148
|
+
TContext extends IQueryContext = IQueryContext,
|
|
108
149
|
TRows extends { [K in keyof TSchema & string]: unknown } = {
|
|
109
150
|
[K in keyof TSchema & string]: unknown;
|
|
110
151
|
}
|
|
111
|
-
>(options: IQueryKitOptions<TSchema>): QueryKit<TSchema, TRows> {
|
|
152
|
+
>(options: IQueryKitOptions<TSchema, TContext>): QueryKit<TSchema, TRows> {
|
|
112
153
|
const parser = new QueryParser();
|
|
113
154
|
const securityValidator = new QuerySecurityValidator(options.security);
|
|
114
155
|
|
|
@@ -136,12 +177,8 @@ export function createQueryKit<
|
|
|
136
177
|
): IWhereClause<TRows[K]> => {
|
|
137
178
|
return {
|
|
138
179
|
where: (queryString: string): IQueryExecutor<TRows[K]> => {
|
|
139
|
-
// Parse
|
|
180
|
+
// Parse the query
|
|
140
181
|
const expressionAst = parser.parse(queryString);
|
|
141
|
-
securityValidator.validate(
|
|
142
|
-
expressionAst,
|
|
143
|
-
options.schema as unknown as Record<string, Record<string, unknown>>
|
|
144
|
-
);
|
|
145
182
|
|
|
146
183
|
// Execution state accumulated via fluent calls
|
|
147
184
|
let orderByState: Record<string, 'asc' | 'desc'> = {};
|
|
@@ -165,10 +202,42 @@ export function createQueryKit<
|
|
|
165
202
|
return executor;
|
|
166
203
|
},
|
|
167
204
|
execute: async (): Promise<TRows[K][]> => {
|
|
205
|
+
// Validate that if virtual fields are configured, createContext must also be provided
|
|
206
|
+
if (options.virtualFields && !options.createContext) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
'createContext must be provided when virtualFields is configured'
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Get context if virtual fields are configured
|
|
213
|
+
let context: TContext | undefined;
|
|
214
|
+
if (options.virtualFields && options.createContext) {
|
|
215
|
+
context = await options.createContext();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Resolve virtual fields if configured and context is available
|
|
219
|
+
let resolvedExpression = expressionAst;
|
|
220
|
+
if (options.virtualFields && context) {
|
|
221
|
+
resolvedExpression = resolveVirtualFields(
|
|
222
|
+
expressionAst,
|
|
223
|
+
options.virtualFields,
|
|
224
|
+
context
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Validate the resolved query
|
|
229
|
+
securityValidator.validate(
|
|
230
|
+
resolvedExpression,
|
|
231
|
+
options.schema as unknown as Record<
|
|
232
|
+
string,
|
|
233
|
+
Record<string, unknown>
|
|
234
|
+
>
|
|
235
|
+
);
|
|
236
|
+
|
|
168
237
|
// Delegate to adapter
|
|
169
238
|
const results = await options.adapter.execute(
|
|
170
239
|
table,
|
|
171
|
-
|
|
240
|
+
resolvedExpression,
|
|
172
241
|
{
|
|
173
242
|
orderBy:
|
|
174
243
|
Object.keys(orderByState).length > 0
|
|
@@ -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
|
+
}
|