@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.
Files changed (39) hide show
  1. package/.cursor/BUGBOT.md +65 -2
  2. package/.husky/pre-commit +3 -3
  3. package/README.md +510 -1
  4. package/dist/index.d.ts +36 -3
  5. package/dist/index.js +20 -3
  6. package/dist/parser/index.d.ts +1 -0
  7. package/dist/parser/index.js +1 -0
  8. package/dist/parser/input-parser.d.ts +215 -0
  9. package/dist/parser/input-parser.js +493 -0
  10. package/dist/parser/parser.d.ts +114 -1
  11. package/dist/parser/parser.js +716 -0
  12. package/dist/parser/types.d.ts +432 -0
  13. package/dist/virtual-fields/index.d.ts +5 -0
  14. package/dist/virtual-fields/index.js +21 -0
  15. package/dist/virtual-fields/resolver.d.ts +17 -0
  16. package/dist/virtual-fields/resolver.js +107 -0
  17. package/dist/virtual-fields/types.d.ts +160 -0
  18. package/dist/virtual-fields/types.js +5 -0
  19. package/examples/qk-next/app/page.tsx +190 -86
  20. package/examples/qk-next/package.json +1 -1
  21. package/package.json +2 -2
  22. package/src/adapters/drizzle/index.ts +3 -3
  23. package/src/index.ts +77 -8
  24. package/src/parser/divergence.test.ts +357 -0
  25. package/src/parser/index.ts +2 -1
  26. package/src/parser/input-parser.test.ts +770 -0
  27. package/src/parser/input-parser.ts +697 -0
  28. package/src/parser/parse-with-context-suggestions.test.ts +360 -0
  29. package/src/parser/parse-with-context-validation.test.ts +447 -0
  30. package/src/parser/parse-with-context.test.ts +325 -0
  31. package/src/parser/parser.ts +872 -0
  32. package/src/parser/token-consistency.test.ts +341 -0
  33. package/src/parser/types.ts +545 -23
  34. package/src/virtual-fields/index.ts +6 -0
  35. package/src/virtual-fields/integration.test.ts +338 -0
  36. package/src/virtual-fields/resolver.ts +165 -0
  37. package/src/virtual-fields/types.ts +203 -0
  38. package/src/virtual-fields/virtual-fields.test.ts +831 -0
  39. package/examples/qk-next/pnpm-lock.yaml +0 -5623
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 and validate the query
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
- expressionAst,
240
+ resolvedExpression,
172
241
  {
173
242
  orderBy:
174
243
  Object.keys(orderByState).length > 0
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Tests documenting the differences between the input parser and main parser.
3
+ *
4
+ * IMPORTANT: The input parser is designed for UI/UX purposes (autocomplete, highlighting)
5
+ * and uses simplified regex-based tokenization. The main parser uses Liqe's full grammar.
6
+ *
7
+ * These tests document known divergences so developers understand the differences.
8
+ */
9
+
10
+ import { QueryParser } from './parser';
11
+ import {
12
+ parseQueryInput,
13
+ extractKeyValue,
14
+ isInputComplete
15
+ } from './input-parser';
16
+
17
+ describe('Parser Divergence Documentation', () => {
18
+ const mainParser = new QueryParser();
19
+
20
+ /**
21
+ * Helper to check if main parser accepts input
22
+ */
23
+ function mainParserAccepts(input: string): boolean {
24
+ try {
25
+ mainParser.parse(input);
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ describe('Known Divergences - Logical operators as values', () => {
33
+ /**
34
+ * DIVERGENCE: Liqe treats AND/OR/NOT as reserved keywords.
35
+ * The main parser REJECTS these as values.
36
+ * The input parser correctly handles them as literal values.
37
+ */
38
+ it('DIVERGENCE: "status:AND" - main parser rejects, input parser accepts', () => {
39
+ const input = 'status:AND';
40
+
41
+ // Main parser (Liqe) treats AND as a keyword and fails
42
+ expect(mainParserAccepts(input)).toBe(false);
43
+
44
+ // Input parser correctly handles it as a value
45
+ const inputResult = parseQueryInput(input);
46
+ expect(inputResult.terms[0]).toMatchObject({
47
+ key: 'status',
48
+ operator: ':',
49
+ value: 'AND'
50
+ });
51
+
52
+ // extractKeyValue also works
53
+ expect(extractKeyValue(input)).toEqual({ key: 'status', value: 'AND' });
54
+ });
55
+
56
+ it('DIVERGENCE: "status:OR" - main parser rejects, input parser accepts', () => {
57
+ const input = 'status:OR';
58
+
59
+ // Main parser rejects
60
+ expect(mainParserAccepts(input)).toBe(false);
61
+
62
+ // Input parser handles it
63
+ const inputResult = parseQueryInput(input);
64
+ expect(inputResult.terms[0]).toMatchObject({
65
+ key: 'status',
66
+ value: 'OR'
67
+ });
68
+ });
69
+
70
+ it('DIVERGENCE: "status:NOT" - main parser rejects, input parser accepts', () => {
71
+ const input = 'status:NOT';
72
+
73
+ expect(mainParserAccepts(input)).toBe(false);
74
+
75
+ const inputResult = parseQueryInput(input);
76
+ expect(inputResult.terms[0]).toMatchObject({
77
+ key: 'status',
78
+ value: 'NOT'
79
+ });
80
+ });
81
+
82
+ it('WORKAROUND: Quote the value to make main parser accept it', () => {
83
+ const input = 'status:"AND"';
84
+
85
+ // Both parsers handle quoted values
86
+ expect(mainParserAccepts(input)).toBe(true);
87
+
88
+ const inputResult = parseQueryInput(input);
89
+ expect(inputResult.terms[0]).toMatchObject({
90
+ key: 'status',
91
+ value: '"AND"'
92
+ });
93
+ });
94
+ });
95
+
96
+ describe('Known Divergences - Incomplete input', () => {
97
+ /**
98
+ * The main advantage of the input parser: handling incomplete input
99
+ * that users are still typing.
100
+ */
101
+ it('DIVERGENCE: "status:" - main parser rejects, input parser returns partial', () => {
102
+ const input = 'status:';
103
+
104
+ // Main parser requires a value
105
+ expect(mainParserAccepts(input)).toBe(false);
106
+
107
+ // Input parser returns what it can
108
+ const inputResult = parseQueryInput(input);
109
+ expect(inputResult.terms[0]).toMatchObject({
110
+ key: 'status',
111
+ operator: ':',
112
+ value: null
113
+ });
114
+ expect(inputResult.cursorContext).toBe('value');
115
+ });
116
+
117
+ it('DIVERGENCE: "status:done AND" - main parser rejects trailing operator', () => {
118
+ const input = 'status:done AND';
119
+
120
+ // Main parser rejects incomplete expression
121
+ expect(mainParserAccepts(input)).toBe(false);
122
+
123
+ // Input parser parses what it can
124
+ const inputResult = parseQueryInput(input);
125
+ expect(inputResult.terms[0]).toMatchObject({
126
+ key: 'status',
127
+ value: 'done'
128
+ });
129
+ expect(inputResult.logicalOperators).toContainEqual(
130
+ expect.objectContaining({ operator: 'AND' })
131
+ );
132
+
133
+ // isInputComplete correctly identifies this as incomplete
134
+ expect(isInputComplete(input)).toBe(false);
135
+ });
136
+
137
+ it('DIVERGENCE: unclosed quote - main parser rejects, input parser handles', () => {
138
+ const input = 'name:"John';
139
+
140
+ expect(mainParserAccepts(input)).toBe(false);
141
+
142
+ const inputResult = parseQueryInput(input);
143
+ expect(inputResult.terms[0]).toMatchObject({
144
+ key: 'name',
145
+ value: '"John' // Partial quoted value
146
+ });
147
+
148
+ expect(isInputComplete(input)).toBe(false);
149
+ });
150
+ });
151
+
152
+ describe('Known Divergences - Range and Array syntax', () => {
153
+ /**
154
+ * Complex syntax like ranges and arrays are preprocessed by the main parser
155
+ * but not fully understood by the input parser.
156
+ */
157
+ it('DIVERGENCE: "field:[1 TO 10]" - different handling', () => {
158
+ const input = 'field:[1 TO 10]';
159
+
160
+ // Main parser handles range syntax
161
+ expect(mainParserAccepts(input)).toBe(true);
162
+ const mainAst = mainParser.parse(input);
163
+ // Main parser converts to logical AND of comparisons
164
+ expect(mainAst.type).toBe('logical');
165
+
166
+ // Input parser sees partial bracket content
167
+ const inputResult = parseQueryInput(input);
168
+ // It doesn't fully understand range syntax
169
+ expect(inputResult.terms.length).toBeGreaterThanOrEqual(1);
170
+ expect(inputResult.terms[0].key).toBe('field');
171
+ });
172
+
173
+ it('DIVERGENCE: "field:[a, b, c]" - main parser expands, input parser partial', () => {
174
+ const input = 'field:[a, b, c]';
175
+
176
+ // Main parser expands to OR expression
177
+ expect(mainParserAccepts(input)).toBe(true);
178
+ const mainAst = mainParser.parse(input);
179
+ expect(mainAst.type).toBe('logical');
180
+
181
+ // Input parser handles it partially
182
+ const inputResult = parseQueryInput(input);
183
+ expect(inputResult.terms[0].key).toBe('field');
184
+ // The value parsing for array syntax is limited
185
+ });
186
+ });
187
+
188
+ describe('Consistent Behavior - Simple cases', () => {
189
+ /**
190
+ * For typical key:value patterns, both parsers agree.
191
+ */
192
+ it('CONSISTENT: "status:done" - both parsers agree', () => {
193
+ const input = 'status:done';
194
+
195
+ expect(mainParserAccepts(input)).toBe(true);
196
+ const mainAst = mainParser.parse(input);
197
+ expect(mainAst).toMatchObject({
198
+ type: 'comparison',
199
+ field: 'status',
200
+ value: 'done'
201
+ });
202
+
203
+ const inputResult = parseQueryInput(input);
204
+ expect(inputResult.terms[0]).toMatchObject({
205
+ key: 'status',
206
+ value: 'done'
207
+ });
208
+
209
+ expect(extractKeyValue(input)).toEqual({ key: 'status', value: 'done' });
210
+ });
211
+
212
+ it('CONSISTENT: "status:d" - partial value works in both', () => {
213
+ const input = 'status:d';
214
+
215
+ // Main parser accepts partial values
216
+ expect(mainParserAccepts(input)).toBe(true);
217
+ const mainAst = mainParser.parse(input);
218
+ expect(mainAst).toMatchObject({
219
+ type: 'comparison',
220
+ field: 'status',
221
+ value: 'd'
222
+ });
223
+
224
+ // Input parser matches
225
+ const inputResult = parseQueryInput(input);
226
+ expect(inputResult.terms[0]).toMatchObject({
227
+ key: 'status',
228
+ value: 'd'
229
+ });
230
+ });
231
+
232
+ it('CONSISTENT: "priority:>5" - comparison operators work in both', () => {
233
+ const input = 'priority:>5';
234
+
235
+ expect(mainParserAccepts(input)).toBe(true);
236
+ const mainAst = mainParser.parse(input);
237
+ expect(mainAst).toMatchObject({
238
+ type: 'comparison',
239
+ field: 'priority',
240
+ operator: '>',
241
+ value: 5
242
+ });
243
+
244
+ const inputResult = parseQueryInput(input);
245
+ expect(inputResult.terms[0]).toMatchObject({
246
+ key: 'priority',
247
+ operator: ':>',
248
+ value: '5' // Note: input parser keeps as string (for display)
249
+ });
250
+ });
251
+
252
+ it('CONSISTENT: quoted values work in both (with quote handling difference)', () => {
253
+ const input = 'name:"John Doe"';
254
+
255
+ expect(mainParserAccepts(input)).toBe(true);
256
+ const mainAst = mainParser.parse(input);
257
+ expect(mainAst).toMatchObject({
258
+ type: 'comparison',
259
+ field: 'name',
260
+ value: 'John Doe' // Main parser STRIPS quotes
261
+ });
262
+
263
+ const inputResult = parseQueryInput(input);
264
+ expect(inputResult.terms[0]).toMatchObject({
265
+ key: 'name',
266
+ value: '"John Doe"' // Input parser PRESERVES quotes (for highlighting)
267
+ });
268
+ });
269
+
270
+ it('CONSISTENT: multiple terms with AND', () => {
271
+ const input = 'status:done AND priority:high';
272
+
273
+ expect(mainParserAccepts(input)).toBe(true);
274
+ const mainAst = mainParser.parse(input);
275
+ expect(mainAst.type).toBe('logical');
276
+
277
+ const inputResult = parseQueryInput(input);
278
+ expect(inputResult.terms).toHaveLength(2);
279
+ expect(inputResult.logicalOperators).toHaveLength(1);
280
+ });
281
+ });
282
+
283
+ describe('Type Differences', () => {
284
+ /**
285
+ * The main parser converts values to their proper types.
286
+ * The input parser keeps everything as strings (for display purposes).
287
+ */
288
+ it('TYPE DIFFERENCE: numeric values', () => {
289
+ const input = 'count:42';
290
+
291
+ const mainAst = mainParser.parse(input);
292
+ expect(mainAst).toMatchObject({
293
+ value: 42 // Number type
294
+ });
295
+
296
+ const inputResult = parseQueryInput(input);
297
+ expect(inputResult.terms[0].value).toBe('42'); // String type
298
+ });
299
+
300
+ it('TYPE DIFFERENCE: boolean values', () => {
301
+ const input = 'active:true';
302
+
303
+ const mainAst = mainParser.parse(input);
304
+ expect(mainAst).toMatchObject({
305
+ value: true // Boolean type
306
+ });
307
+
308
+ const inputResult = parseQueryInput(input);
309
+ expect(inputResult.terms[0].value).toBe('true'); // String type
310
+ });
311
+ });
312
+
313
+ describe('Developer Guidance', () => {
314
+ /**
315
+ * Summary of when to use each parser and known limitations.
316
+ */
317
+ it('documents usage guidance', () => {
318
+ /**
319
+ * USE THE MAIN PARSER (QueryParser) when:
320
+ * - Executing queries against a database
321
+ * - Validating complete query syntax
322
+ * - Need proper type conversion (string -> number/boolean)
323
+ * - Need the full AST for SQL/Drizzle translation
324
+ *
325
+ * USE THE INPUT PARSER (parseQueryInput) when:
326
+ * - Building autocomplete/suggestions UI
327
+ * - Highlighting key/value in search input as user types
328
+ * - Need position information for cursor-aware features
329
+ * - Handling incomplete input gracefully
330
+ * - Display purposes (values stay as strings)
331
+ *
332
+ * KNOWN DIVERGENCES:
333
+ * 1. Logical keywords as values:
334
+ * - Main parser REJECTS: status:AND, status:OR, status:NOT
335
+ * - Input parser ACCEPTS these as valid key:value pairs
336
+ * - Workaround: Quote the value: status:"AND"
337
+ *
338
+ * 2. Incomplete input:
339
+ * - Main parser REJECTS: status:, status:done AND
340
+ * - Input parser returns partial results
341
+ *
342
+ * 3. Quote handling:
343
+ * - Main parser STRIPS quotes from values
344
+ * - Input parser PRESERVES quotes (for display)
345
+ *
346
+ * 4. Type conversion:
347
+ * - Main parser converts to proper types (42 -> number)
348
+ * - Input parser keeps as strings ("42")
349
+ *
350
+ * 5. Complex syntax (ranges, arrays):
351
+ * - Main parser fully understands [1 TO 10], [a, b, c]
352
+ * - Input parser has limited support
353
+ */
354
+ expect(true).toBe(true);
355
+ });
356
+ });
357
+ });
@@ -1,2 +1,3 @@
1
1
  export * from './types';
2
- export * from './parser';
2
+ export * from './parser';
3
+ export * from './input-parser';