@gblikas/querykit 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.husky/pre-commit +3 -3
- package/README.md +539 -0
- package/dist/index.d.ts +36 -3
- package/dist/index.js +20 -3
- package/dist/parser/types.d.ts +17 -1
- package/dist/security/validator.js +14 -5
- package/dist/translators/drizzle/index.js +11 -0
- package/dist/virtual-fields/helpers.d.ts +32 -0
- package/dist/virtual-fields/helpers.js +74 -0
- package/dist/virtual-fields/index.d.ts +6 -0
- package/dist/virtual-fields/index.js +22 -0
- package/dist/virtual-fields/resolver.d.ts +17 -0
- package/dist/virtual-fields/resolver.js +111 -0
- package/dist/virtual-fields/types.d.ts +177 -0
- package/dist/virtual-fields/types.js +5 -0
- package/examples/qk-next/app/page.tsx +184 -85
- package/examples/qk-next/package.json +1 -1
- package/package.json +2 -2
- package/src/adapters/drizzle/index.ts +3 -3
- package/src/index.ts +77 -8
- package/src/parser/types.ts +21 -1
- package/src/security/validator.ts +15 -5
- package/src/translators/drizzle/index.ts +18 -0
- package/src/virtual-fields/helpers.ts +81 -0
- package/src/virtual-fields/index.ts +7 -0
- package/src/virtual-fields/integration.test.ts +338 -0
- package/src/virtual-fields/raw-sql.test.ts +978 -0
- package/src/virtual-fields/resolver.ts +170 -0
- package/src/virtual-fields/types.ts +223 -0
- package/src/virtual-fields/user-example-integration.test.ts +182 -0
- package/src/virtual-fields/virtual-fields.test.ts +831 -0
|
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState, useCallback, useRef, JSX } from 'react';
|
|
|
4
4
|
import { drizzle } from 'drizzle-orm/pglite';
|
|
5
5
|
import { usePGlite } from '@electric-sql/pglite-react';
|
|
6
6
|
import { pgTable, serial, text, integer, boolean } from 'drizzle-orm/pg-core';
|
|
7
|
-
import { InferSelectModel, sql
|
|
7
|
+
import { InferSelectModel, sql } from 'drizzle-orm';
|
|
8
8
|
import { Card, CardContent } from '@/components/ui/card';
|
|
9
9
|
import {
|
|
10
10
|
Table,
|
|
@@ -17,9 +17,9 @@ import {
|
|
|
17
17
|
import {
|
|
18
18
|
QueryParser,
|
|
19
19
|
SqlTranslator,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
createDrizzleQueryKit,
|
|
21
|
+
ISecurityOptions,
|
|
22
|
+
IQueryStructure
|
|
23
23
|
} from '@gblikas/querykit';
|
|
24
24
|
import { Copy, Check, Search, ChevronUp, FileCode, X } from 'lucide-react';
|
|
25
25
|
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
|
|
@@ -75,7 +75,7 @@ const highlightQueryHtml = (input: string): string => {
|
|
|
75
75
|
.join('');
|
|
76
76
|
};
|
|
77
77
|
|
|
78
|
-
const INSTALL_SNIPPET = `pnpm i @gblikas/querykit drizzle-orm`;
|
|
78
|
+
const INSTALL_SNIPPET = `pnpm i @gblikas/querykit@0.3.0 drizzle-orm`;
|
|
79
79
|
|
|
80
80
|
const SCHEMA_SNIPPET = `// schema.ts
|
|
81
81
|
import { serial, text, pgTable } from 'drizzle-orm/pg-core';
|
|
@@ -91,25 +91,30 @@ export type SelectUser = InferSelectModel<typeof users>;
|
|
|
91
91
|
`;
|
|
92
92
|
|
|
93
93
|
const QUERYKIT_SNIPPET = `// querykit.ts
|
|
94
|
-
import {
|
|
95
|
-
import {
|
|
94
|
+
import { createDrizzleQueryKit } from '@gblikas/querykit';
|
|
95
|
+
import { db } from './db';
|
|
96
96
|
import { users } from './schema';
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
// New 0.3.0: createDrizzleQueryKit combines adapter & security config
|
|
99
|
+
export const qk = createDrizzleQueryKit({
|
|
100
|
+
db,
|
|
100
101
|
schema: { users },
|
|
102
|
+
security: {
|
|
103
|
+
maxQueryDepth: 5,
|
|
104
|
+
maxClauseCount: 20,
|
|
105
|
+
sanitizeWildcards: true,
|
|
106
|
+
},
|
|
101
107
|
});
|
|
102
108
|
|
|
103
109
|
// example.ts
|
|
104
110
|
import { qk } from './querykit';
|
|
105
111
|
|
|
106
|
-
const
|
|
112
|
+
const results = await qk
|
|
107
113
|
.query('users')
|
|
108
114
|
.where('status:done AND name:"John *"')
|
|
109
115
|
.orderBy('name', 'asc')
|
|
110
|
-
.limit(10)
|
|
111
|
-
|
|
112
|
-
const results = await query.execute();
|
|
116
|
+
.limit(10)
|
|
117
|
+
.execute();
|
|
113
118
|
`;
|
|
114
119
|
|
|
115
120
|
const tasks = pgTable('tasks', {
|
|
@@ -137,6 +142,9 @@ export default function Home(): JSX.Element {
|
|
|
137
142
|
const [, setLastExecutionMs] = useState<number | null>(null);
|
|
138
143
|
const [rowsScanned, setRowsScanned] = useState<number | null>(null);
|
|
139
144
|
const [operatorsUsed, setOperatorsUsed] = useState<string[]>([]);
|
|
145
|
+
const [queryStructure, setQueryStructure] = useState<IQueryStructure | null>(
|
|
146
|
+
null
|
|
147
|
+
);
|
|
140
148
|
const [usedQueryKit, setUsedQueryKit] = useState<boolean>(false);
|
|
141
149
|
const [, setExplainJson] = useState<string | null>(null);
|
|
142
150
|
const [, setPlanningTimeMs] = useState<number | null>(null);
|
|
@@ -314,23 +322,33 @@ export default function Home(): JSX.Element {
|
|
|
314
322
|
void seed();
|
|
315
323
|
}, [db]);
|
|
316
324
|
|
|
325
|
+
// Security configuration for QueryKit 0.3.0
|
|
326
|
+
const securityOptions: ISecurityOptions = useMemo(
|
|
327
|
+
() => ({
|
|
328
|
+
maxQueryDepth: 5,
|
|
329
|
+
maxClauseCount: 20,
|
|
330
|
+
allowDotNotation: false, // Disable dot notation for simple flat schema
|
|
331
|
+
sanitizeWildcards: true
|
|
332
|
+
}),
|
|
333
|
+
[]
|
|
334
|
+
);
|
|
335
|
+
|
|
317
336
|
const parser = useMemo(() => new QueryParser(), []);
|
|
318
337
|
const sqlTranslator = useMemo(
|
|
319
338
|
() => new SqlTranslator({ useParameters: false }),
|
|
320
339
|
[]
|
|
321
340
|
);
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
}, [db]);
|
|
341
|
+
|
|
342
|
+
// Use the new createDrizzleQueryKit factory (0.3.0 feature)
|
|
343
|
+
const qk = useMemo(
|
|
344
|
+
() =>
|
|
345
|
+
createDrizzleQueryKit({
|
|
346
|
+
db,
|
|
347
|
+
schema: { tasks },
|
|
348
|
+
security: securityOptions
|
|
349
|
+
}),
|
|
350
|
+
[db, securityOptions]
|
|
351
|
+
);
|
|
334
352
|
|
|
335
353
|
// Note: Execute via QueryKit fluent API (Drizzle adapter under the hood)
|
|
336
354
|
|
|
@@ -346,6 +364,7 @@ export default function Home(): JSX.Element {
|
|
|
346
364
|
setIsInputFocused(false);
|
|
347
365
|
inputRef.current?.blur();
|
|
348
366
|
setOperatorsUsed([]);
|
|
367
|
+
setQueryStructure(null);
|
|
349
368
|
setExplainJson(null);
|
|
350
369
|
setPlanningTimeMs(null);
|
|
351
370
|
setExecutionTimeMs(null);
|
|
@@ -407,49 +426,77 @@ export default function Home(): JSX.Element {
|
|
|
407
426
|
if (searchQuery.trim()) {
|
|
408
427
|
try {
|
|
409
428
|
const parseStart = performance.now();
|
|
410
|
-
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
[
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
429
|
+
// Use parseWithContext for enhanced query analysis (0.3.0 feature)
|
|
430
|
+
const parseResult = parser.parseWithContext(searchQuery, {
|
|
431
|
+
schema: {
|
|
432
|
+
title: { type: 'string', description: 'Task title' },
|
|
433
|
+
status: {
|
|
434
|
+
type: 'string',
|
|
435
|
+
description: 'Task status',
|
|
436
|
+
allowedValues: ['todo', 'doing', 'done']
|
|
437
|
+
},
|
|
438
|
+
priority: { type: 'number', description: 'Priority level' },
|
|
439
|
+
completed: { type: 'boolean', description: 'Is completed' }
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// Set query structure for UI display
|
|
444
|
+
setQueryStructure(parseResult.structure);
|
|
445
|
+
|
|
446
|
+
if (parseResult.success && parseResult.ast) {
|
|
447
|
+
const translated = sqlTranslator.translate(parseResult.ast) as
|
|
448
|
+
| string
|
|
449
|
+
| { sql: string; params: unknown[] };
|
|
450
|
+
localParseTranslateMs = performance.now() - parseStart;
|
|
451
|
+
setParseTranslateMs(localParseTranslateMs);
|
|
452
|
+
whereSql =
|
|
453
|
+
typeof translated === 'string' ? translated : translated.sql;
|
|
454
|
+
mockSQL += ` WHERE ${whereSql}`;
|
|
455
|
+
|
|
456
|
+
// Use referenced fields from structure (0.3.0 feature)
|
|
457
|
+
detectedOperators = [];
|
|
458
|
+
if (parseResult.structure.operatorCount > 0) {
|
|
459
|
+
// Extract operators from the SQL for display
|
|
460
|
+
const extractOperators = (sqlText: string): string[] => {
|
|
461
|
+
const found = new Set<string>();
|
|
462
|
+
const keywordOps: Array<[string, RegExp]> = [
|
|
463
|
+
['ILIKE', /\bILIKE\b/i],
|
|
464
|
+
['LIKE', /\bLIKE\b/i],
|
|
465
|
+
['AND', /\bAND\b/i],
|
|
466
|
+
['OR', /\bOR\b/i],
|
|
467
|
+
['NOT', /\bNOT\b/i],
|
|
468
|
+
['IN', /\bIN\b/i],
|
|
469
|
+
['BETWEEN', /\bBETWEEN\b/i]
|
|
470
|
+
];
|
|
471
|
+
for (const [name, re] of keywordOps) {
|
|
472
|
+
if (re.test(sqlText)) found.add(name);
|
|
473
|
+
}
|
|
474
|
+
let temp = sqlText.toUpperCase();
|
|
475
|
+
const consume = (re: RegExp, label: string): void => {
|
|
476
|
+
if (re.test(temp)) {
|
|
477
|
+
found.add(label);
|
|
478
|
+
temp = temp.replace(re, ' ');
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
consume(/>=/g, '>=');
|
|
482
|
+
consume(/<=/g, '<=');
|
|
483
|
+
consume(/!=/g, '!=');
|
|
484
|
+
consume(/=/g, '=');
|
|
485
|
+
consume(/>/g, '>');
|
|
486
|
+
consume(/</g, '<');
|
|
487
|
+
return Array.from(found);
|
|
488
|
+
};
|
|
489
|
+
detectedOperators = extractOperators(whereSql);
|
|
490
|
+
} else {
|
|
491
|
+
// Simple query - just detect from SQL
|
|
492
|
+
if (/ILIKE/i.test(whereSql)) detectedOperators.push('ILIKE');
|
|
493
|
+
if (/=/i.test(whereSql) && !/!=/i.test(whereSql))
|
|
494
|
+
detectedOperators.push('=');
|
|
435
495
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
found.add(label);
|
|
441
|
-
temp = temp.replace(re, ' ');
|
|
442
|
-
}
|
|
443
|
-
};
|
|
444
|
-
consume(/>=/g, '>=');
|
|
445
|
-
consume(/<=/g, '<=');
|
|
446
|
-
consume(/!=/g, '!=');
|
|
447
|
-
consume(/=/g, '=');
|
|
448
|
-
consume(/>/g, '>');
|
|
449
|
-
consume(/</g, '<');
|
|
450
|
-
return Array.from(found);
|
|
451
|
-
};
|
|
452
|
-
detectedOperators = extractOperators(whereSql);
|
|
496
|
+
} else {
|
|
497
|
+
// Parse failed - fall back to ILIKE search
|
|
498
|
+
throw new Error(parseResult.error?.message || 'Parse failed');
|
|
499
|
+
}
|
|
453
500
|
} catch (error) {
|
|
454
501
|
void trackQueryKitIssue({
|
|
455
502
|
errorName: (error as Error)?.name ?? 'UnknownError',
|
|
@@ -1013,25 +1060,77 @@ export default function Home(): JSX.Element {
|
|
|
1013
1060
|
)}
|
|
1014
1061
|
</div>
|
|
1015
1062
|
{!isShortViewport ? (
|
|
1016
|
-
|
|
1017
|
-
<div className="
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
{operatorsUsed.
|
|
1023
|
-
<
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1063
|
+
<>
|
|
1064
|
+
<div className="mt-3 grid grid-cols-2 gap-3">
|
|
1065
|
+
<div>
|
|
1066
|
+
<div className="text-xs text-muted-foreground mb-1">
|
|
1067
|
+
Detected operators
|
|
1068
|
+
</div>
|
|
1069
|
+
{operatorsUsed.length ? (
|
|
1070
|
+
<div className="flex flex-wrap gap-2">
|
|
1071
|
+
{operatorsUsed.map(op => (
|
|
1072
|
+
<span
|
|
1073
|
+
key={op}
|
|
1074
|
+
className="inline-flex items-center rounded-full border bg-muted px-2 py-0.5 text-xs font-medium"
|
|
1075
|
+
>
|
|
1076
|
+
{op}
|
|
1077
|
+
</span>
|
|
1078
|
+
))}
|
|
1079
|
+
</div>
|
|
1080
|
+
) : (
|
|
1081
|
+
<div className="text-xs text-muted-foreground">
|
|
1082
|
+
-
|
|
1083
|
+
</div>
|
|
1084
|
+
)}
|
|
1030
1085
|
</div>
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1086
|
+
{queryStructure && (
|
|
1087
|
+
<div>
|
|
1088
|
+
<div className="text-xs text-muted-foreground mb-1">
|
|
1089
|
+
Query complexity (0.3.0)
|
|
1090
|
+
</div>
|
|
1091
|
+
<div className="flex flex-wrap gap-2">
|
|
1092
|
+
<span
|
|
1093
|
+
className={cn(
|
|
1094
|
+
'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium',
|
|
1095
|
+
queryStructure.complexity === 'simple' &&
|
|
1096
|
+
'bg-green-500/10 text-green-600 border-green-500/30',
|
|
1097
|
+
queryStructure.complexity === 'moderate' &&
|
|
1098
|
+
'bg-yellow-500/10 text-yellow-600 border-yellow-500/30',
|
|
1099
|
+
queryStructure.complexity === 'complex' &&
|
|
1100
|
+
'bg-red-500/10 text-red-600 border-red-500/30'
|
|
1101
|
+
)}
|
|
1102
|
+
>
|
|
1103
|
+
{queryStructure.complexity}
|
|
1104
|
+
</span>
|
|
1105
|
+
<span className="inline-flex items-center rounded-full border bg-muted px-2 py-0.5 text-xs font-medium">
|
|
1106
|
+
depth: {queryStructure.depth}
|
|
1107
|
+
</span>
|
|
1108
|
+
<span className="inline-flex items-center rounded-full border bg-muted px-2 py-0.5 text-xs font-medium">
|
|
1109
|
+
clauses: {queryStructure.clauseCount}
|
|
1110
|
+
</span>
|
|
1111
|
+
</div>
|
|
1112
|
+
</div>
|
|
1113
|
+
)}
|
|
1114
|
+
</div>
|
|
1115
|
+
{queryStructure &&
|
|
1116
|
+
queryStructure.referencedFields.length > 0 && (
|
|
1117
|
+
<div className="mt-3">
|
|
1118
|
+
<div className="text-xs text-muted-foreground mb-1">
|
|
1119
|
+
Referenced fields
|
|
1120
|
+
</div>
|
|
1121
|
+
<div className="flex flex-wrap gap-2">
|
|
1122
|
+
{queryStructure.referencedFields.map(field => (
|
|
1123
|
+
<span
|
|
1124
|
+
key={field}
|
|
1125
|
+
className="inline-flex items-center rounded-full border bg-blue-500/10 text-blue-600 border-blue-500/30 px-2 py-0.5 text-xs font-medium"
|
|
1126
|
+
>
|
|
1127
|
+
{field}
|
|
1128
|
+
</span>
|
|
1129
|
+
))}
|
|
1130
|
+
</div>
|
|
1131
|
+
</div>
|
|
1132
|
+
)}
|
|
1133
|
+
</>
|
|
1035
1134
|
) : (
|
|
1036
1135
|
<div className="mt-3 text-xs text-muted-foreground">
|
|
1037
1136
|
View on larger screen for more details
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@electric-sql/pglite": "^0.3.7",
|
|
13
13
|
"@electric-sql/pglite-react": "^0.2.25",
|
|
14
|
-
"@gblikas/querykit": "^0.
|
|
14
|
+
"@gblikas/querykit": "^0.3.0",
|
|
15
15
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
16
16
|
"@radix-ui/react-hover-card": "^1.1.15",
|
|
17
17
|
"@vercel/analytics": "^1.5.0",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gblikas/querykit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "A comprehensive query toolkit for TypeScript that simplifies building and executing data queries across different environments",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"lint-staged": "^15.5.1",
|
|
44
44
|
"prettier": "^3.2.5",
|
|
45
45
|
"ts-jest": "^29.1.2",
|
|
46
|
-
"typescript": "^5.
|
|
46
|
+
"typescript": "^5.9.3"
|
|
47
47
|
},
|
|
48
48
|
"scripts": {
|
|
49
49
|
"build": "tsc --outDir dist",
|
|
@@ -9,6 +9,7 @@ import { IAdapter, IAdapterOptions, IQueryExecutionOptions } from '../types';
|
|
|
9
9
|
import { QueryExpression } from '../../parser/types';
|
|
10
10
|
import { SQL, SQLWrapper, asc, desc, sql } from 'drizzle-orm';
|
|
11
11
|
import { createQueryKit, QueryKit } from '../../index';
|
|
12
|
+
import { IQueryContext } from '../../virtual-fields';
|
|
12
13
|
/**
|
|
13
14
|
* Type for Drizzle ORM database instance
|
|
14
15
|
*/
|
|
@@ -91,8 +92,7 @@ export class DrizzleAdapterError extends Error {
|
|
|
91
92
|
*/
|
|
92
93
|
export class DrizzleAdapter<
|
|
93
94
|
TSchema extends Record<string, unknown> = Record<string, unknown>
|
|
94
|
-
> implements IAdapter<IDrizzleAdapterOptions<TSchema>>
|
|
95
|
-
{
|
|
95
|
+
> implements IAdapter<IDrizzleAdapterOptions<TSchema>> {
|
|
96
96
|
private db!: unknown;
|
|
97
97
|
private schema!: TSchema;
|
|
98
98
|
private translator!: DrizzleTranslator;
|
|
@@ -291,7 +291,7 @@ export function createDrizzleQueryKit<
|
|
|
291
291
|
|
|
292
292
|
type RowMap = RowMapFromDrizzleSchema<TSchema>;
|
|
293
293
|
|
|
294
|
-
return createQueryKit<TSchema, RowMap>({
|
|
294
|
+
return createQueryKit<TSchema, IQueryContext, RowMap>({
|
|
295
295
|
adapter,
|
|
296
296
|
schema: args.schema as unknown as TSchema,
|
|
297
297
|
security: args.security
|
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
|
package/src/parser/types.ts
CHANGED
|
@@ -51,10 +51,30 @@ export interface ILogicalExpression {
|
|
|
51
51
|
right?: QueryExpression;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Represents a raw SQL expression node in the AST.
|
|
56
|
+
* Used by virtual fields to inject database-specific SQL operations.
|
|
57
|
+
*/
|
|
58
|
+
export interface IRawSqlExpression {
|
|
59
|
+
type: 'raw';
|
|
60
|
+
/**
|
|
61
|
+
* Function that generates the raw SQL for the adapter.
|
|
62
|
+
* For Drizzle, this should return a SQL template result.
|
|
63
|
+
*/
|
|
64
|
+
toSql: (context: {
|
|
65
|
+
adapter: string;
|
|
66
|
+
tableName: string;
|
|
67
|
+
schema: Record<string, unknown>;
|
|
68
|
+
}) => unknown;
|
|
69
|
+
}
|
|
70
|
+
|
|
54
71
|
/**
|
|
55
72
|
* Represents any valid query expression node
|
|
56
73
|
*/
|
|
57
|
-
export type QueryExpression =
|
|
74
|
+
export type QueryExpression =
|
|
75
|
+
| IComparisonExpression
|
|
76
|
+
| ILogicalExpression
|
|
77
|
+
| IRawSqlExpression;
|
|
58
78
|
|
|
59
79
|
/**
|
|
60
80
|
* Configuration options for the parser
|
|
@@ -240,13 +240,14 @@ export class QuerySecurityValidator {
|
|
|
240
240
|
`Found "${field}" - use a simple field name without dots instead.`
|
|
241
241
|
);
|
|
242
242
|
}
|
|
243
|
-
} else {
|
|
243
|
+
} else if (expression.type === 'logical') {
|
|
244
244
|
// Recursively validate logical expressions
|
|
245
245
|
this.validateNoDotNotation(expression.left);
|
|
246
246
|
if (expression.right) {
|
|
247
247
|
this.validateNoDotNotation(expression.right);
|
|
248
248
|
}
|
|
249
249
|
}
|
|
250
|
+
// Raw expressions are skipped - they handle their own field access
|
|
250
251
|
}
|
|
251
252
|
|
|
252
253
|
/**
|
|
@@ -285,13 +286,14 @@ export class QuerySecurityValidator {
|
|
|
285
286
|
}
|
|
286
287
|
}
|
|
287
288
|
}
|
|
288
|
-
} else {
|
|
289
|
+
} else if (expression.type === 'logical') {
|
|
289
290
|
// Recursively validate logical expressions
|
|
290
291
|
this.validateDenyValues(expression.left);
|
|
291
292
|
if (expression.right) {
|
|
292
293
|
this.validateDenyValues(expression.right);
|
|
293
294
|
}
|
|
294
295
|
}
|
|
296
|
+
// Raw expressions are skipped - they handle their own values
|
|
295
297
|
}
|
|
296
298
|
|
|
297
299
|
/**
|
|
@@ -350,6 +352,7 @@ export class QuerySecurityValidator {
|
|
|
350
352
|
this.validateQueryDepth(expression.right, currentDepth + 1);
|
|
351
353
|
}
|
|
352
354
|
}
|
|
355
|
+
// Raw and comparison expressions don't add depth
|
|
353
356
|
}
|
|
354
357
|
|
|
355
358
|
/**
|
|
@@ -379,6 +382,10 @@ export class QuerySecurityValidator {
|
|
|
379
382
|
return 1;
|
|
380
383
|
}
|
|
381
384
|
|
|
385
|
+
if (expression.type === 'raw') {
|
|
386
|
+
return 1; // Raw expressions count as one clause
|
|
387
|
+
}
|
|
388
|
+
|
|
382
389
|
let count = 0;
|
|
383
390
|
count += this.countClauses(expression.left);
|
|
384
391
|
if (expression.right) {
|
|
@@ -442,12 +449,13 @@ export class QuerySecurityValidator {
|
|
|
442
449
|
) {
|
|
443
450
|
throw new QuerySecurityError('Object values are not allowed');
|
|
444
451
|
}
|
|
445
|
-
} else {
|
|
452
|
+
} else if (expression.type === 'logical') {
|
|
446
453
|
this.validateValueLengths(expression.left);
|
|
447
454
|
if (expression.right) {
|
|
448
455
|
this.validateValueLengths(expression.right);
|
|
449
456
|
}
|
|
450
457
|
}
|
|
458
|
+
// Raw expressions are skipped - they handle their own values
|
|
451
459
|
}
|
|
452
460
|
|
|
453
461
|
/**
|
|
@@ -481,12 +489,13 @@ export class QuerySecurityValidator {
|
|
|
481
489
|
.replace(/\?{2,}/g, '?'); // Limit consecutive question marks
|
|
482
490
|
(expression as IComparisonExpression).value = sanitized;
|
|
483
491
|
}
|
|
484
|
-
} else {
|
|
492
|
+
} else if (expression.type === 'logical') {
|
|
485
493
|
this.sanitizeWildcards(expression.left);
|
|
486
494
|
if (expression.right) {
|
|
487
495
|
this.sanitizeWildcards(expression.right);
|
|
488
496
|
}
|
|
489
497
|
}
|
|
498
|
+
// Raw expressions are skipped - they handle their own wildcards
|
|
490
499
|
}
|
|
491
500
|
|
|
492
501
|
/**
|
|
@@ -502,11 +511,12 @@ export class QuerySecurityValidator {
|
|
|
502
511
|
): void {
|
|
503
512
|
if (expression.type === 'comparison') {
|
|
504
513
|
fieldSet.add(expression.field);
|
|
505
|
-
} else {
|
|
514
|
+
} else if (expression.type === 'logical') {
|
|
506
515
|
this.collectFields(expression.left, fieldSet);
|
|
507
516
|
if (expression.right) {
|
|
508
517
|
this.collectFields(expression.right, fieldSet);
|
|
509
518
|
}
|
|
510
519
|
}
|
|
520
|
+
// Raw expressions don't expose field names for collection
|
|
511
521
|
}
|
|
512
522
|
}
|