@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
package/.cursor/BUGBOT.md
CHANGED
|
@@ -1,21 +1,84 @@
|
|
|
1
1
|
# Project review guidelines
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Below is a list of generally accepted best-practices to prevent bugs in QueryKit. Not all guidelines may apply to every component; please make sure to read the README.md for context on the project's goals.
|
|
4
4
|
|
|
5
5
|
## Security focus areas
|
|
6
6
|
|
|
7
7
|
- Validate user input in API endpoints
|
|
8
8
|
- Check for SQL injection vulnerabilities in database queries
|
|
9
9
|
- Ensure proper authentication on protected routes
|
|
10
|
+
- Validate query inputs using `parseWithContext` with security options
|
|
11
|
+
- Use `allowedFields` and `denyFields` to restrict queryable fields
|
|
12
|
+
- Set `maxQueryDepth` and `maxClauseCount` to prevent DoS attacks
|
|
13
|
+
|
|
14
|
+
### Query parsing security
|
|
15
|
+
|
|
16
|
+
When using the input parser or `parseWithContext`:
|
|
17
|
+
|
|
18
|
+
1. **Never trust user-provided queries** - Always validate with security options:
|
|
19
|
+
```typescript
|
|
20
|
+
const result = parser.parseWithContext(userQuery, {
|
|
21
|
+
securityOptions: {
|
|
22
|
+
allowedFields: ['name', 'status', 'priority'],
|
|
23
|
+
denyFields: ['password', 'secret'],
|
|
24
|
+
maxQueryDepth: 5,
|
|
25
|
+
maxClauseCount: 20
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (!result.security?.passed) {
|
|
30
|
+
// Reject query - contains violations
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
2. **Schema validation** - Use schema to detect typos and invalid fields early:
|
|
35
|
+
```typescript
|
|
36
|
+
const result = parser.parseWithContext(userQuery, { schema });
|
|
37
|
+
if (!result.fieldValidation?.valid) {
|
|
38
|
+
// Show user-friendly error with suggestions
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
3. **Input parser limitations** - The input parser (`parseQueryInput`, `parseQueryTokens`) is regex-based for performance. It may accept inputs that the main parser rejects. Always validate with `parseWithContext` or `parser.parse()` before executing queries.
|
|
10
43
|
|
|
11
44
|
## Architecture patterns
|
|
12
45
|
|
|
13
46
|
- Use dependency injection for services
|
|
14
47
|
- Follow the repository pattern for data access
|
|
15
48
|
- Implement proper error handling with custom error classes
|
|
49
|
+
- Parser components follow Single Responsibility Principle:
|
|
50
|
+
- `input-parser.ts` - Fast, regex-based tokenization for UI feedback
|
|
51
|
+
- `parser.ts` - Full Liqe-based parsing with AST generation
|
|
52
|
+
- `parseWithContext` - Orchestrates both for rich context
|
|
53
|
+
|
|
54
|
+
### Parser architecture
|
|
55
|
+
|
|
56
|
+
The parsing system has two tiers:
|
|
57
|
+
|
|
58
|
+
1. **Input Parser** (`parseQueryInput`, `parseQueryTokens`)
|
|
59
|
+
- Purpose: Real-time UI feedback (highlighting, cursor context)
|
|
60
|
+
- Performance: O(n) regex-based, no AST generation
|
|
61
|
+
- Error handling: Best-effort, never throws
|
|
62
|
+
- Use for: Search bar highlighting, autocomplete triggering
|
|
63
|
+
|
|
64
|
+
2. **Query Parser** (`parser.parse`, `parseWithContext`)
|
|
65
|
+
- Purpose: Query validation and execution
|
|
66
|
+
- Performance: Full Liqe grammar parsing
|
|
67
|
+
- Error handling: Strict validation, detailed error messages
|
|
68
|
+
- Use for: Query execution, security validation
|
|
16
69
|
|
|
17
70
|
## Common issues
|
|
18
71
|
|
|
19
72
|
- Memory leaks in React components (check useEffect cleanup)
|
|
20
73
|
- Missing error boundaries in UI components
|
|
21
|
-
- Inconsistent naming conventions (use camelCase for functions)
|
|
74
|
+
- Inconsistent naming conventions (use camelCase for functions)
|
|
75
|
+
- Not checking `result.success` before accessing `result.ast`
|
|
76
|
+
- Using input parser for security validation (use `parseWithContext` instead)
|
|
77
|
+
- Forgetting to provide `cursorPosition` when autocomplete is needed
|
|
78
|
+
|
|
79
|
+
## Testing guidelines
|
|
80
|
+
|
|
81
|
+
- All parser features require co-located tests
|
|
82
|
+
- Use divergence tests to document differences between input parser and main parser
|
|
83
|
+
- Token consistency tests verify `parseWithContext` tokens match `parseQueryTokens`
|
|
84
|
+
- Security tests should cover field restrictions, depth limits, and value sanitization
|
package/.husky/pre-commit
CHANGED
|
@@ -14,16 +14,16 @@ echo "Running pre-commit checks..."
|
|
|
14
14
|
|
|
15
15
|
# Run lint-staged to process only changed files
|
|
16
16
|
echo "🔍 Running lint-staged..."
|
|
17
|
-
|
|
17
|
+
npx lint-staged || { echo "❌ Linting failed"; exit 1; }
|
|
18
18
|
|
|
19
19
|
# Check TypeScript compilation
|
|
20
20
|
echo "🔍 Checking TypeScript compilation..."
|
|
21
|
-
|
|
21
|
+
npx tsc --noEmit || { echo "❌ TypeScript check failed"; exit 1; }
|
|
22
22
|
|
|
23
23
|
# Run tests
|
|
24
24
|
if [ "$RUN_TESTS" = "true" ]; then
|
|
25
25
|
echo "🧪 Running tests..."
|
|
26
|
-
|
|
26
|
+
npm test || { echo "❌ Tests failed"; exit 1; }
|
|
27
27
|
fi
|
|
28
28
|
|
|
29
29
|
# If everything passes, allow the commit
|
package/README.md
CHANGED
|
@@ -275,6 +275,511 @@ const publicSearchKit = createQueryKit({
|
|
|
275
275
|
});
|
|
276
276
|
```
|
|
277
277
|
|
|
278
|
+
## Input Parsing for Search UIs
|
|
279
|
+
|
|
280
|
+
QueryKit provides utilities for building rich search bar experiences with real-time feedback, including key:value highlighting, autocomplete suggestions, and error recovery hints.
|
|
281
|
+
|
|
282
|
+
### Real-Time Token Parsing
|
|
283
|
+
|
|
284
|
+
Use `parseQueryInput` and `parseQueryTokens` for lightweight, real-time parsing as users type:
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
import { parseQueryInput, parseQueryTokens } from '@gblikas/querykit';
|
|
288
|
+
|
|
289
|
+
// Parse input to get terms and cursor context
|
|
290
|
+
const input = 'status:done AND priority:';
|
|
291
|
+
const result = parseQueryInput(input, { cursorPosition: 25 });
|
|
292
|
+
|
|
293
|
+
// result.terms contains parsed terms:
|
|
294
|
+
// [{ key: 'status', value: 'done', ... }, { key: 'priority', value: null, ... }]
|
|
295
|
+
|
|
296
|
+
// result.cursorContext tells you where the cursor is: 'key', 'value', or 'operator'
|
|
297
|
+
console.log(result.cursorContext); // 'value' (cursor is after 'priority:')
|
|
298
|
+
|
|
299
|
+
// Get interleaved tokens (terms + operators) for highlighting
|
|
300
|
+
const tokens = parseQueryTokens(input);
|
|
301
|
+
// [
|
|
302
|
+
// { type: 'term', key: 'status', value: 'done', startPosition: 0, endPosition: 11 },
|
|
303
|
+
// { type: 'operator', operator: 'AND', startPosition: 12, endPosition: 15 },
|
|
304
|
+
// { type: 'term', key: 'priority', value: null, startPosition: 16, endPosition: 25 }
|
|
305
|
+
// ]
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
### Rich Context with parseWithContext
|
|
309
|
+
|
|
310
|
+
For comprehensive parsing with schema validation, autocomplete, and error recovery:
|
|
311
|
+
|
|
312
|
+
```typescript
|
|
313
|
+
import { QueryParser } from '@gblikas/querykit';
|
|
314
|
+
|
|
315
|
+
const parser = new QueryParser();
|
|
316
|
+
|
|
317
|
+
// Define your schema for validation and autocomplete
|
|
318
|
+
const schema = {
|
|
319
|
+
status: {
|
|
320
|
+
type: 'string',
|
|
321
|
+
allowedValues: ['todo', 'doing', 'done'],
|
|
322
|
+
description: 'Task status'
|
|
323
|
+
},
|
|
324
|
+
priority: { type: 'number', description: 'Priority level (1-5)' },
|
|
325
|
+
assignee: { type: 'string', description: 'Assigned user' }
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const result = parser.parseWithContext('status:do', {
|
|
329
|
+
cursorPosition: 9,
|
|
330
|
+
schema,
|
|
331
|
+
securityOptions: { maxClauseCount: 10 }
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// Always returns a result object (never throws)
|
|
335
|
+
console.log(result.success); // true/false - whether parsing succeeded
|
|
336
|
+
console.log(result.tokens); // Tokenized input (always available)
|
|
337
|
+
console.log(result.structure); // Query structure analysis
|
|
338
|
+
console.log(result.ast); // AST (if successful)
|
|
339
|
+
console.log(result.error); // Error details (if failed)
|
|
340
|
+
|
|
341
|
+
// Autocomplete suggestions based on cursor position
|
|
342
|
+
console.log(result.suggestions);
|
|
343
|
+
// {
|
|
344
|
+
// context: 'value',
|
|
345
|
+
// currentField: 'status',
|
|
346
|
+
// values: [
|
|
347
|
+
// { value: 'doing', score: 80 },
|
|
348
|
+
// { value: 'done', score: 80 }
|
|
349
|
+
// ]
|
|
350
|
+
// }
|
|
351
|
+
|
|
352
|
+
// Schema validation results
|
|
353
|
+
console.log(result.fieldValidation);
|
|
354
|
+
// { valid: true, fields: [...], unknownFields: [] }
|
|
355
|
+
|
|
356
|
+
// Security pre-check
|
|
357
|
+
console.log(result.security);
|
|
358
|
+
// { passed: true, violations: [], warnings: [] }
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### Error Recovery
|
|
362
|
+
|
|
363
|
+
When parsing fails, `parseWithContext` provides helpful recovery hints:
|
|
364
|
+
|
|
365
|
+
```typescript
|
|
366
|
+
const result = parser.parseWithContext('status:"incomplete');
|
|
367
|
+
|
|
368
|
+
console.log(result.recovery);
|
|
369
|
+
// {
|
|
370
|
+
// issue: 'unclosed_quote',
|
|
371
|
+
// message: 'Unclosed double quote detected',
|
|
372
|
+
// suggestion: 'Add a closing " to complete the quoted value',
|
|
373
|
+
// autofix: 'status:"incomplete"',
|
|
374
|
+
// position: 7
|
|
375
|
+
// }
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Error types detected:
|
|
379
|
+
- `unclosed_quote` - Missing closing quote (with autofix)
|
|
380
|
+
- `unclosed_parenthesis` - Unbalanced parentheses (with autofix)
|
|
381
|
+
- `trailing_operator` - Query ends with AND/OR/NOT (with autofix)
|
|
382
|
+
- `missing_value` - Field has colon but no value
|
|
383
|
+
- `syntax_error` - Generic syntax issue
|
|
384
|
+
|
|
385
|
+
### Building a Search Bar with Highlighting
|
|
386
|
+
|
|
387
|
+
Here's a React example using the input parser for highlighting:
|
|
388
|
+
|
|
389
|
+
```tsx
|
|
390
|
+
import { parseQueryTokens } from '@gblikas/querykit';
|
|
391
|
+
|
|
392
|
+
function SearchBar({ value, onChange }) {
|
|
393
|
+
const tokens = parseQueryTokens(value);
|
|
394
|
+
|
|
395
|
+
const renderHighlightedQuery = () => {
|
|
396
|
+
if (!value) return null;
|
|
397
|
+
|
|
398
|
+
return tokens.map((token, idx) => {
|
|
399
|
+
const text = value.slice(token.startPosition, token.endPosition);
|
|
400
|
+
|
|
401
|
+
if (token.type === 'operator') {
|
|
402
|
+
return <span key={idx} className="text-purple-500">{text}</span>;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Term token - highlight key and value differently
|
|
406
|
+
if (token.key && token.operator) {
|
|
407
|
+
const keyEnd = token.startPosition + token.key.length;
|
|
408
|
+
const opEnd = keyEnd + token.operator.length;
|
|
409
|
+
return (
|
|
410
|
+
<span key={idx}>
|
|
411
|
+
<span className="text-orange-400">{token.key}</span>
|
|
412
|
+
<span className="text-gray-500">{token.operator}</span>
|
|
413
|
+
<span className="text-blue-400">{value.slice(opEnd, token.endPosition)}</span>
|
|
414
|
+
</span>
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return <span key={idx}>{text}</span>;
|
|
419
|
+
});
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
return (
|
|
423
|
+
<div className="relative">
|
|
424
|
+
<div className="absolute inset-0 pointer-events-none">
|
|
425
|
+
{renderHighlightedQuery()}
|
|
426
|
+
</div>
|
|
427
|
+
<input
|
|
428
|
+
value={value}
|
|
429
|
+
onChange={(e) => onChange(e.target.value)}
|
|
430
|
+
className="bg-transparent text-transparent caret-black"
|
|
431
|
+
/>
|
|
432
|
+
</div>
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
## Virtual Fields
|
|
438
|
+
|
|
439
|
+
Virtual fields enable powerful shortcuts in your queries that expand to real schema fields at query execution time based on runtime context. This allows you to support queries like `my:assigned` which expands to `assignee_id == <current_user_id>` using the currently logged-in user's ID.
|
|
440
|
+
|
|
441
|
+
### Why Virtual Fields?
|
|
442
|
+
|
|
443
|
+
Virtual fields are useful when:
|
|
444
|
+
- You want to provide user-friendly shortcuts (e.g., `my:assigned` instead of `assignee_id:123`)
|
|
445
|
+
- The query depends on runtime context (current user, permissions, tenant, etc.)
|
|
446
|
+
- You want to abstract complex field mappings from end users
|
|
447
|
+
- You need consistent query shortcuts across your application
|
|
448
|
+
|
|
449
|
+
### Basic Usage
|
|
450
|
+
|
|
451
|
+
Define virtual fields when creating your QueryKit instance:
|
|
452
|
+
|
|
453
|
+
```typescript
|
|
454
|
+
import { createQueryKit } from '@gblikas/querykit';
|
|
455
|
+
import { drizzleAdapter } from '@gblikas/querykit/adapters/drizzle';
|
|
456
|
+
|
|
457
|
+
const qk = createQueryKit({
|
|
458
|
+
adapter: drizzleAdapter,
|
|
459
|
+
schema: { tasks, users },
|
|
460
|
+
|
|
461
|
+
// Define virtual fields
|
|
462
|
+
virtualFields: {
|
|
463
|
+
my: {
|
|
464
|
+
allowedValues: ['assigned', 'created', 'watching'] as const,
|
|
465
|
+
description: 'Filter by your relationship to items',
|
|
466
|
+
|
|
467
|
+
resolve: (input, ctx, { fields }) => {
|
|
468
|
+
// Map virtual values to real schema fields
|
|
469
|
+
const fieldMap = fields({
|
|
470
|
+
assigned: 'assignee_id',
|
|
471
|
+
created: 'creator_id',
|
|
472
|
+
watching: 'watcher_ids'
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
return {
|
|
476
|
+
type: 'comparison',
|
|
477
|
+
field: fieldMap[input.value],
|
|
478
|
+
operator: '==',
|
|
479
|
+
value: ctx.currentUserId
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
|
|
485
|
+
// Provide runtime context
|
|
486
|
+
createContext: async () => ({
|
|
487
|
+
currentUserId: await getCurrentUserId(),
|
|
488
|
+
currentUserTeamIds: await getUserTeamIds()
|
|
489
|
+
})
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// Use virtual fields in queries
|
|
493
|
+
const myTasks = await qk
|
|
494
|
+
.query('tasks')
|
|
495
|
+
.where('my:assigned AND status:active')
|
|
496
|
+
.execute();
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
### Configuration Options
|
|
500
|
+
|
|
501
|
+
Each virtual field definition supports:
|
|
502
|
+
|
|
503
|
+
```typescript
|
|
504
|
+
{
|
|
505
|
+
// Required: allowed values for this virtual field
|
|
506
|
+
allowedValues: ['value1', 'value2'] as const,
|
|
507
|
+
|
|
508
|
+
// Optional: allow comparison operators (>, <, >=, <=)
|
|
509
|
+
// Default: false (only equality ":" is allowed)
|
|
510
|
+
allowOperators?: boolean,
|
|
511
|
+
|
|
512
|
+
// Required: resolver function
|
|
513
|
+
resolve: (input, context, helpers) => {
|
|
514
|
+
// Return a query expression that replaces the virtual field
|
|
515
|
+
return {
|
|
516
|
+
type: 'comparison',
|
|
517
|
+
field: 'real_field',
|
|
518
|
+
operator: '==',
|
|
519
|
+
value: context.someValue
|
|
520
|
+
};
|
|
521
|
+
},
|
|
522
|
+
|
|
523
|
+
// Optional: human-readable description
|
|
524
|
+
description?: string,
|
|
525
|
+
|
|
526
|
+
// Optional: descriptions for each value
|
|
527
|
+
valueDescriptions?: {
|
|
528
|
+
value1: 'Description of value1',
|
|
529
|
+
value2: 'Description of value2'
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
### Type-Safe Field Mapping
|
|
535
|
+
|
|
536
|
+
The `fields()` helper provides compile-time validation that all mapped fields exist in your schema:
|
|
537
|
+
|
|
538
|
+
```typescript
|
|
539
|
+
virtualFields: {
|
|
540
|
+
my: {
|
|
541
|
+
allowedValues: ['assigned', 'created'] as const,
|
|
542
|
+
resolve: (input, ctx, { fields }) => {
|
|
543
|
+
// TypeScript validates:
|
|
544
|
+
// 1. All allowedValues keys are mapped
|
|
545
|
+
// 2. All field values exist in the schema
|
|
546
|
+
const fieldMap = fields({
|
|
547
|
+
assigned: 'assignee_id', // ✓ Valid schema field
|
|
548
|
+
created: 'creator_id' // ✓ Valid schema field
|
|
549
|
+
// Missing 'watching' → TypeScript error!
|
|
550
|
+
// assigned: 'invalid_field' → TypeScript error!
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
return {
|
|
554
|
+
type: 'comparison',
|
|
555
|
+
field: fieldMap[input.value],
|
|
556
|
+
operator: '==',
|
|
557
|
+
value: ctx.currentUserId
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
### Context Factory
|
|
565
|
+
|
|
566
|
+
The `createContext` function is called once per query execution to provide runtime values:
|
|
567
|
+
|
|
568
|
+
```typescript
|
|
569
|
+
createContext: async () => {
|
|
570
|
+
const user = await getCurrentUser();
|
|
571
|
+
const permissions = await getUserPermissions(user.id);
|
|
572
|
+
|
|
573
|
+
return {
|
|
574
|
+
currentUserId: user.id,
|
|
575
|
+
currentUserTeamIds: user.teamIds,
|
|
576
|
+
canSeeArchived: permissions.includes('view:archived')
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
Context is type-safe and can include any data your resolvers need:
|
|
582
|
+
|
|
583
|
+
```typescript
|
|
584
|
+
interface MyQueryContext extends IQueryContext {
|
|
585
|
+
currentUserId: number;
|
|
586
|
+
currentUserTeamIds: number[];
|
|
587
|
+
canSeeArchived: boolean;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const qk = createQueryKit<typeof schema, MyQueryContext>({
|
|
591
|
+
// ... configuration
|
|
592
|
+
});
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
### Complex Resolvers
|
|
596
|
+
|
|
597
|
+
Virtual fields can return logical expressions for more complex scenarios:
|
|
598
|
+
|
|
599
|
+
```typescript
|
|
600
|
+
virtualFields: {
|
|
601
|
+
myItems: {
|
|
602
|
+
allowedValues: ['all'] as const,
|
|
603
|
+
resolve: (input, ctx) => ({
|
|
604
|
+
// Return a logical OR expression
|
|
605
|
+
type: 'logical',
|
|
606
|
+
operator: 'OR',
|
|
607
|
+
left: {
|
|
608
|
+
type: 'comparison',
|
|
609
|
+
field: 'assignee_id',
|
|
610
|
+
operator: '==',
|
|
611
|
+
value: ctx.currentUserId
|
|
612
|
+
},
|
|
613
|
+
right: {
|
|
614
|
+
type: 'comparison',
|
|
615
|
+
field: 'creator_id',
|
|
616
|
+
operator: '==',
|
|
617
|
+
value: ctx.currentUserId
|
|
618
|
+
}
|
|
619
|
+
})
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Expands to: (assignee_id == currentUserId OR creator_id == currentUserId)
|
|
624
|
+
await qk.query('tasks').where('myItems:all').execute();
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
### Allowing Comparison Operators
|
|
628
|
+
|
|
629
|
+
By default, only equality (`:`) is allowed. Enable other operators with `allowOperators: true`:
|
|
630
|
+
|
|
631
|
+
```typescript
|
|
632
|
+
virtualFields: {
|
|
633
|
+
priority: {
|
|
634
|
+
allowedValues: ['high', 'low'] as const,
|
|
635
|
+
allowOperators: true, // Enable >, <, etc.
|
|
636
|
+
|
|
637
|
+
resolve: (input, ctx) => {
|
|
638
|
+
const threshold = input.value === 'high' ? 7 : 3;
|
|
639
|
+
|
|
640
|
+
return {
|
|
641
|
+
type: 'comparison',
|
|
642
|
+
field: 'priority',
|
|
643
|
+
operator: input.operator, // Use the operator from the query
|
|
644
|
+
value: threshold
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Both work:
|
|
651
|
+
qk.query('tasks').where('priority:high') // priority == 7
|
|
652
|
+
qk.query('tasks').where('priority:>high') // priority > 7
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
### Error Handling
|
|
656
|
+
|
|
657
|
+
QueryKit throws `QueryParseError` for invalid virtual field usage:
|
|
658
|
+
|
|
659
|
+
```typescript
|
|
660
|
+
// Invalid value
|
|
661
|
+
qk.query('tasks').where('my:invalid')
|
|
662
|
+
// Error: Invalid value "invalid" for virtual field "my".
|
|
663
|
+
// Allowed values: "assigned", "created", "watching"
|
|
664
|
+
|
|
665
|
+
// Operator not allowed (when allowOperators: false)
|
|
666
|
+
qk.query('tasks').where('my:>assigned')
|
|
667
|
+
// Error: Virtual field "my" does not allow comparison operators.
|
|
668
|
+
// Only equality (":") is permitted.
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
### Complete Example
|
|
672
|
+
|
|
673
|
+
Here's a full example with multiple virtual fields:
|
|
674
|
+
|
|
675
|
+
```typescript
|
|
676
|
+
import { createQueryKit, IQueryContext, ComparisonOperator } from '@gblikas/querykit';
|
|
677
|
+
import { drizzleAdapter } from '@gblikas/querykit/adapters/drizzle';
|
|
678
|
+
|
|
679
|
+
// Define your context type
|
|
680
|
+
interface TaskQueryContext extends IQueryContext {
|
|
681
|
+
currentUserId: number;
|
|
682
|
+
currentUserTeamIds: number[];
|
|
683
|
+
currentTenantId: string;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Create QueryKit with virtual fields
|
|
687
|
+
const qk = createQueryKit<typeof schema, TaskQueryContext>({
|
|
688
|
+
adapter: drizzleAdapter,
|
|
689
|
+
schema: { tasks, users },
|
|
690
|
+
|
|
691
|
+
virtualFields: {
|
|
692
|
+
// User relationship shortcuts
|
|
693
|
+
my: {
|
|
694
|
+
allowedValues: ['assigned', 'created', 'watching'] as const,
|
|
695
|
+
description: 'Filter by your relationship to tasks',
|
|
696
|
+
valueDescriptions: {
|
|
697
|
+
assigned: 'Tasks assigned to you',
|
|
698
|
+
created: 'Tasks you created',
|
|
699
|
+
watching: 'Tasks you are watching'
|
|
700
|
+
},
|
|
701
|
+
resolve: (input, ctx, { fields }) => {
|
|
702
|
+
const fieldMap = fields({
|
|
703
|
+
assigned: 'assignee_id',
|
|
704
|
+
created: 'creator_id',
|
|
705
|
+
watching: 'watcher_ids'
|
|
706
|
+
});
|
|
707
|
+
return {
|
|
708
|
+
type: 'comparison',
|
|
709
|
+
field: fieldMap[input.value],
|
|
710
|
+
operator: '==',
|
|
711
|
+
value: ctx.currentUserId
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
},
|
|
715
|
+
|
|
716
|
+
// Team shortcuts
|
|
717
|
+
team: {
|
|
718
|
+
allowedValues: ['assigned', 'owned'] as const,
|
|
719
|
+
description: 'Filter by team relationship',
|
|
720
|
+
resolve: (input, ctx, { fields }) => {
|
|
721
|
+
const fieldMap = fields({
|
|
722
|
+
assigned: 'assignee_id',
|
|
723
|
+
owned: 'owner_id'
|
|
724
|
+
});
|
|
725
|
+
return {
|
|
726
|
+
type: 'comparison',
|
|
727
|
+
field: fieldMap[input.value],
|
|
728
|
+
operator: 'IN',
|
|
729
|
+
value: ctx.currentUserTeamIds
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
},
|
|
733
|
+
|
|
734
|
+
// Priority shortcuts with operators
|
|
735
|
+
priority: {
|
|
736
|
+
allowedValues: ['critical', 'high', 'normal', 'low'] as const,
|
|
737
|
+
allowOperators: true,
|
|
738
|
+
description: 'Filter by priority level',
|
|
739
|
+
resolve: (input) => {
|
|
740
|
+
const priorityMap = {
|
|
741
|
+
critical: 10,
|
|
742
|
+
high: 7,
|
|
743
|
+
normal: 5,
|
|
744
|
+
low: 3
|
|
745
|
+
};
|
|
746
|
+
return {
|
|
747
|
+
type: 'comparison',
|
|
748
|
+
field: 'priority',
|
|
749
|
+
operator: input.operator as ComparisonOperator,
|
|
750
|
+
value: priorityMap[input.value as keyof typeof priorityMap]
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
},
|
|
755
|
+
|
|
756
|
+
// Context factory
|
|
757
|
+
createContext: async () => {
|
|
758
|
+
const user = await getCurrentUser();
|
|
759
|
+
const teams = await getUserTeams(user.id);
|
|
760
|
+
|
|
761
|
+
return {
|
|
762
|
+
currentUserId: user.id,
|
|
763
|
+
currentUserTeamIds: teams.map(t => t.id),
|
|
764
|
+
currentTenantId: user.tenantId
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
// Example queries using virtual fields
|
|
770
|
+
// "my:assigned AND status:active"
|
|
771
|
+
// "team:assigned OR my:created"
|
|
772
|
+
// "priority:>high AND my:watching"
|
|
773
|
+
// "(my:assigned OR team:assigned) AND status:active"
|
|
774
|
+
|
|
775
|
+
const results = await qk
|
|
776
|
+
.query('tasks')
|
|
777
|
+
.where('my:assigned AND priority:>high')
|
|
778
|
+
.orderBy('created_at', 'desc')
|
|
779
|
+
.limit(10)
|
|
780
|
+
.execute();
|
|
781
|
+
```
|
|
782
|
+
|
|
278
783
|
## Roadmap
|
|
279
784
|
|
|
280
785
|
### Core Parsing Engine and DSL
|
|
@@ -283,6 +788,9 @@ const publicSearchKit = createQueryKit({
|
|
|
283
788
|
- [x] Develop internal AST representation
|
|
284
789
|
- [x] Implement consistent syntax for logical operators (AND, OR, NOT)
|
|
285
790
|
- [x] Support standard comparison operators (==, !=, >, >=, <, <=)
|
|
791
|
+
- [x] Real-time input parsing for search UIs
|
|
792
|
+
- [x] Autocomplete suggestions with schema awareness
|
|
793
|
+
- [x] Error recovery hints with autofix
|
|
286
794
|
|
|
287
795
|
### First Adapters
|
|
288
796
|
- [x] Drizzle ORM integration
|
|
@@ -297,9 +805,10 @@ const publicSearchKit = createQueryKit({
|
|
|
297
805
|
- [x] Support for complex nested expressions
|
|
298
806
|
- [ ] Custom function support
|
|
299
807
|
- [ ] Pagination helpers
|
|
808
|
+
- [x] Virtual fields for context-aware query expansion
|
|
300
809
|
|
|
301
810
|
### Ecosystem Expansion
|
|
302
|
-
- [
|
|
811
|
+
- [x] Frontend query builder components (input parser)
|
|
303
812
|
- [ ] Additional ORM adapters
|
|
304
813
|
- [ ] Server middleware for Express/Fastify
|
|
305
814
|
- [ ] TypeScript SDK generation
|