@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
package/dist/parser/types.d.ts
CHANGED
|
@@ -31,10 +31,26 @@ export interface ILogicalExpression {
|
|
|
31
31
|
left: QueryExpression;
|
|
32
32
|
right?: QueryExpression;
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Represents a raw SQL expression node in the AST.
|
|
36
|
+
* Used by virtual fields to inject database-specific SQL operations.
|
|
37
|
+
*/
|
|
38
|
+
export interface IRawSqlExpression {
|
|
39
|
+
type: 'raw';
|
|
40
|
+
/**
|
|
41
|
+
* Function that generates the raw SQL for the adapter.
|
|
42
|
+
* For Drizzle, this should return a SQL template result.
|
|
43
|
+
*/
|
|
44
|
+
toSql: (context: {
|
|
45
|
+
adapter: string;
|
|
46
|
+
tableName: string;
|
|
47
|
+
schema: Record<string, unknown>;
|
|
48
|
+
}) => unknown;
|
|
49
|
+
}
|
|
34
50
|
/**
|
|
35
51
|
* Represents any valid query expression node
|
|
36
52
|
*/
|
|
37
|
-
export type QueryExpression = IComparisonExpression | ILogicalExpression;
|
|
53
|
+
export type QueryExpression = IComparisonExpression | ILogicalExpression | IRawSqlExpression;
|
|
38
54
|
/**
|
|
39
55
|
* Configuration options for the parser
|
|
40
56
|
*/
|
|
@@ -219,13 +219,14 @@ class QuerySecurityValidator {
|
|
|
219
219
|
`Found "${field}" - use a simple field name without dots instead.`);
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
|
-
else {
|
|
222
|
+
else if (expression.type === 'logical') {
|
|
223
223
|
// Recursively validate logical expressions
|
|
224
224
|
this.validateNoDotNotation(expression.left);
|
|
225
225
|
if (expression.right) {
|
|
226
226
|
this.validateNoDotNotation(expression.right);
|
|
227
227
|
}
|
|
228
228
|
}
|
|
229
|
+
// Raw expressions are skipped - they handle their own field access
|
|
229
230
|
}
|
|
230
231
|
/**
|
|
231
232
|
* Validate that query values are not in the denied values list for their field
|
|
@@ -263,13 +264,14 @@ class QuerySecurityValidator {
|
|
|
263
264
|
}
|
|
264
265
|
}
|
|
265
266
|
}
|
|
266
|
-
else {
|
|
267
|
+
else if (expression.type === 'logical') {
|
|
267
268
|
// Recursively validate logical expressions
|
|
268
269
|
this.validateDenyValues(expression.left);
|
|
269
270
|
if (expression.right) {
|
|
270
271
|
this.validateDenyValues(expression.right);
|
|
271
272
|
}
|
|
272
273
|
}
|
|
274
|
+
// Raw expressions are skipped - they handle their own values
|
|
273
275
|
}
|
|
274
276
|
/**
|
|
275
277
|
* Check if a value is in the denied values list
|
|
@@ -317,6 +319,7 @@ class QuerySecurityValidator {
|
|
|
317
319
|
this.validateQueryDepth(expression.right, currentDepth + 1);
|
|
318
320
|
}
|
|
319
321
|
}
|
|
322
|
+
// Raw and comparison expressions don't add depth
|
|
320
323
|
}
|
|
321
324
|
/**
|
|
322
325
|
* Validate that the number of clauses does not exceed the maximum
|
|
@@ -341,6 +344,9 @@ class QuerySecurityValidator {
|
|
|
341
344
|
if (expression.type === 'comparison') {
|
|
342
345
|
return 1;
|
|
343
346
|
}
|
|
347
|
+
if (expression.type === 'raw') {
|
|
348
|
+
return 1; // Raw expressions count as one clause
|
|
349
|
+
}
|
|
344
350
|
let count = 0;
|
|
345
351
|
count += this.countClauses(expression.left);
|
|
346
352
|
if (expression.right) {
|
|
@@ -387,12 +393,13 @@ class QuerySecurityValidator {
|
|
|
387
393
|
throw new QuerySecurityError('Object values are not allowed');
|
|
388
394
|
}
|
|
389
395
|
}
|
|
390
|
-
else {
|
|
396
|
+
else if (expression.type === 'logical') {
|
|
391
397
|
this.validateValueLengths(expression.left);
|
|
392
398
|
if (expression.right) {
|
|
393
399
|
this.validateValueLengths(expression.right);
|
|
394
400
|
}
|
|
395
401
|
}
|
|
402
|
+
// Raw expressions are skipped - they handle their own values
|
|
396
403
|
}
|
|
397
404
|
/**
|
|
398
405
|
* Sanitize wildcard patterns in LIKE queries to prevent regex DoS
|
|
@@ -423,12 +430,13 @@ class QuerySecurityValidator {
|
|
|
423
430
|
expression.value = sanitized;
|
|
424
431
|
}
|
|
425
432
|
}
|
|
426
|
-
else {
|
|
433
|
+
else if (expression.type === 'logical') {
|
|
427
434
|
this.sanitizeWildcards(expression.left);
|
|
428
435
|
if (expression.right) {
|
|
429
436
|
this.sanitizeWildcards(expression.right);
|
|
430
437
|
}
|
|
431
438
|
}
|
|
439
|
+
// Raw expressions are skipped - they handle their own wildcards
|
|
432
440
|
}
|
|
433
441
|
/**
|
|
434
442
|
* Collect all field names used in the query
|
|
@@ -441,12 +449,13 @@ class QuerySecurityValidator {
|
|
|
441
449
|
if (expression.type === 'comparison') {
|
|
442
450
|
fieldSet.add(expression.field);
|
|
443
451
|
}
|
|
444
|
-
else {
|
|
452
|
+
else if (expression.type === 'logical') {
|
|
445
453
|
this.collectFields(expression.left, fieldSet);
|
|
446
454
|
if (expression.right) {
|
|
447
455
|
this.collectFields(expression.right, fieldSet);
|
|
448
456
|
}
|
|
449
457
|
}
|
|
458
|
+
// Raw expressions don't expose field names for collection
|
|
450
459
|
}
|
|
451
460
|
}
|
|
452
461
|
exports.QuerySecurityValidator = QuerySecurityValidator;
|
|
@@ -64,6 +64,17 @@ class DrizzleTranslator {
|
|
|
64
64
|
return this.translateComparisonExpression(expression);
|
|
65
65
|
case 'logical':
|
|
66
66
|
return this.translateLogicalExpression(expression);
|
|
67
|
+
case 'raw': {
|
|
68
|
+
const rawExpr = expression;
|
|
69
|
+
return rawExpr.toSql({
|
|
70
|
+
adapter: 'drizzle',
|
|
71
|
+
// tableName is empty because raw SQL expressions in virtual fields
|
|
72
|
+
// are resolved before translation and don't need table context at this stage.
|
|
73
|
+
// The schema is provided for field lookups if needed.
|
|
74
|
+
tableName: '',
|
|
75
|
+
schema: this.options.schema
|
|
76
|
+
});
|
|
77
|
+
}
|
|
67
78
|
default:
|
|
68
79
|
throw new DrizzleTranslationError(`Unsupported expression type: ${expression.type}`);
|
|
69
80
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper utilities for creating raw SQL expressions in virtual fields
|
|
3
|
+
*/
|
|
4
|
+
import { IRawSqlExpression } from '../parser/types';
|
|
5
|
+
/**
|
|
6
|
+
* Create a JSONB array contains expression (PostgreSQL).
|
|
7
|
+
* Checks if the JSONB array field contains the given value.
|
|
8
|
+
*
|
|
9
|
+
* @param field - The JSONB field name (e.g., 'assigned_to')
|
|
10
|
+
* @param value - The value to check for in the array
|
|
11
|
+
* @returns A raw SQL expression for JSONB contains check
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* // Check if assignedTo contains the current user ID
|
|
15
|
+
* jsonbContains('assigned_to', ctx.currentUserId)
|
|
16
|
+
* // Generates: assigned_to @> '["user123"]'::jsonb
|
|
17
|
+
*/
|
|
18
|
+
export declare function jsonbContains(field: string, value: unknown): IRawSqlExpression;
|
|
19
|
+
/**
|
|
20
|
+
* Create a date range expression.
|
|
21
|
+
* Checks if a timestamp field is within the specified number of days from now.
|
|
22
|
+
*
|
|
23
|
+
* @param field - The timestamp field name (e.g., 'created_at')
|
|
24
|
+
* @param days - Number of days from now (must be a positive finite number)
|
|
25
|
+
* @returns A raw SQL expression for date range check
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* // Check if created within last day
|
|
29
|
+
* dateWithinDays('created_at', 1)
|
|
30
|
+
* // Generates: created_at >= NOW() - INTERVAL '1 days'
|
|
31
|
+
*/
|
|
32
|
+
export declare function dateWithinDays(field: string, days: number): IRawSqlExpression;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Helper utilities for creating raw SQL expressions in virtual fields
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.jsonbContains = jsonbContains;
|
|
7
|
+
exports.dateWithinDays = dateWithinDays;
|
|
8
|
+
const drizzle_orm_1 = require("drizzle-orm");
|
|
9
|
+
/**
|
|
10
|
+
* Validates field name to prevent SQL injection.
|
|
11
|
+
* Only allows alphanumeric characters, dots, and underscores.
|
|
12
|
+
* @private
|
|
13
|
+
*/
|
|
14
|
+
function validateFieldName(field) {
|
|
15
|
+
if (!/^[a-zA-Z][a-zA-Z0-9._]*$/.test(field)) {
|
|
16
|
+
throw new Error(`Invalid field name: ${field}`);
|
|
17
|
+
}
|
|
18
|
+
if (field.length > 64) {
|
|
19
|
+
throw new Error(`Field name too long: ${field}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Create a JSONB array contains expression (PostgreSQL).
|
|
24
|
+
* Checks if the JSONB array field contains the given value.
|
|
25
|
+
*
|
|
26
|
+
* @param field - The JSONB field name (e.g., 'assigned_to')
|
|
27
|
+
* @param value - The value to check for in the array
|
|
28
|
+
* @returns A raw SQL expression for JSONB contains check
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* // Check if assignedTo contains the current user ID
|
|
32
|
+
* jsonbContains('assigned_to', ctx.currentUserId)
|
|
33
|
+
* // Generates: assigned_to @> '["user123"]'::jsonb
|
|
34
|
+
*/
|
|
35
|
+
function jsonbContains(field, value) {
|
|
36
|
+
validateFieldName(field);
|
|
37
|
+
return {
|
|
38
|
+
type: 'raw',
|
|
39
|
+
toSql: () => (0, drizzle_orm_1.sql) `${drizzle_orm_1.sql.identifier(field)} @> ${drizzle_orm_1.sql.raw("'" + JSON.stringify(Array.isArray(value) ? value : [value]).replace(/'/g, "''") + "'::jsonb")}`
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Validates days parameter to ensure it's a positive finite number.
|
|
44
|
+
* @private
|
|
45
|
+
*/
|
|
46
|
+
function validateDaysParameter(days) {
|
|
47
|
+
if (!Number.isFinite(days)) {
|
|
48
|
+
throw new Error(`Invalid days parameter: ${days}. Must be a finite number.`);
|
|
49
|
+
}
|
|
50
|
+
if (days <= 0) {
|
|
51
|
+
throw new Error(`Invalid days parameter: ${days}. Must be a positive number.`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Create a date range expression.
|
|
56
|
+
* Checks if a timestamp field is within the specified number of days from now.
|
|
57
|
+
*
|
|
58
|
+
* @param field - The timestamp field name (e.g., 'created_at')
|
|
59
|
+
* @param days - Number of days from now (must be a positive finite number)
|
|
60
|
+
* @returns A raw SQL expression for date range check
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* // Check if created within last day
|
|
64
|
+
* dateWithinDays('created_at', 1)
|
|
65
|
+
* // Generates: created_at >= NOW() - INTERVAL '1 days'
|
|
66
|
+
*/
|
|
67
|
+
function dateWithinDays(field, days) {
|
|
68
|
+
validateFieldName(field);
|
|
69
|
+
validateDaysParameter(days);
|
|
70
|
+
return {
|
|
71
|
+
type: 'raw',
|
|
72
|
+
toSql: () => (0, drizzle_orm_1.sql) `${drizzle_orm_1.sql.identifier(field)} >= NOW() - INTERVAL '${drizzle_orm_1.sql.raw(days.toString())} days'`
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Virtual Fields module exports
|
|
4
|
+
*/
|
|
5
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
6
|
+
if (k2 === undefined) k2 = k;
|
|
7
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
8
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
9
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
10
|
+
}
|
|
11
|
+
Object.defineProperty(o, k2, desc);
|
|
12
|
+
}) : (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
o[k2] = m[k];
|
|
15
|
+
}));
|
|
16
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
17
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
18
|
+
};
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
__exportStar(require("./types"), exports);
|
|
21
|
+
__exportStar(require("./resolver"), exports);
|
|
22
|
+
__exportStar(require("./helpers"), exports);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Virtual field resolution logic
|
|
3
|
+
*/
|
|
4
|
+
import { QueryExpression } from '../parser/types';
|
|
5
|
+
import { IQueryContext, VirtualFieldsConfig } from './types';
|
|
6
|
+
/**
|
|
7
|
+
* Resolve virtual fields in a query expression.
|
|
8
|
+
* Recursively walks the AST and replaces virtual field references with
|
|
9
|
+
* their resolved expressions based on the provided context.
|
|
10
|
+
*
|
|
11
|
+
* @param expr - The query expression to resolve
|
|
12
|
+
* @param virtualFields - Virtual field configuration
|
|
13
|
+
* @param context - Runtime context for resolution
|
|
14
|
+
* @returns The resolved query expression
|
|
15
|
+
* @throws {QueryParseError} If a virtual field value is invalid or operator is not allowed
|
|
16
|
+
*/
|
|
17
|
+
export declare function resolveVirtualFields<TSchema extends Record<string, object>, TContext extends IQueryContext>(expr: QueryExpression, virtualFields: VirtualFieldsConfig<TSchema, TContext>, context: TContext): QueryExpression;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Virtual field resolution logic
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.resolveVirtualFields = resolveVirtualFields;
|
|
7
|
+
const parser_1 = require("../parser/parser");
|
|
8
|
+
/**
|
|
9
|
+
* Resolve virtual fields in a query expression.
|
|
10
|
+
* Recursively walks the AST and replaces virtual field references with
|
|
11
|
+
* their resolved expressions based on the provided context.
|
|
12
|
+
*
|
|
13
|
+
* @param expr - The query expression to resolve
|
|
14
|
+
* @param virtualFields - Virtual field configuration
|
|
15
|
+
* @param context - Runtime context for resolution
|
|
16
|
+
* @returns The resolved query expression
|
|
17
|
+
* @throws {QueryParseError} If a virtual field value is invalid or operator is not allowed
|
|
18
|
+
*/
|
|
19
|
+
function resolveVirtualFields(expr, virtualFields, context) {
|
|
20
|
+
// Base case: comparison expression
|
|
21
|
+
if (expr.type === 'comparison') {
|
|
22
|
+
return resolveComparisonExpression(expr, virtualFields, context);
|
|
23
|
+
}
|
|
24
|
+
// Recursive case: logical expression
|
|
25
|
+
if (expr.type === 'logical') {
|
|
26
|
+
return resolveLogicalExpression(expr, virtualFields, context);
|
|
27
|
+
}
|
|
28
|
+
// Pass through raw expressions
|
|
29
|
+
if (expr.type === 'raw') {
|
|
30
|
+
return expr;
|
|
31
|
+
}
|
|
32
|
+
// Unknown expression type, return as-is
|
|
33
|
+
return expr;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Resolve a comparison expression.
|
|
37
|
+
* If the field is a virtual field, resolve it using the configuration.
|
|
38
|
+
* Otherwise, return the expression unchanged.
|
|
39
|
+
*/
|
|
40
|
+
function resolveComparisonExpression(expr, virtualFields, context) {
|
|
41
|
+
const fieldName = expr.field;
|
|
42
|
+
const virtualFieldDef = virtualFields[fieldName];
|
|
43
|
+
// Not a virtual field, return as-is
|
|
44
|
+
if (!virtualFieldDef) {
|
|
45
|
+
return expr;
|
|
46
|
+
}
|
|
47
|
+
// Validate the value is a string (virtual fields require string values)
|
|
48
|
+
if (typeof expr.value !== 'string') {
|
|
49
|
+
const valueType = Array.isArray(expr.value)
|
|
50
|
+
? `array (${JSON.stringify(expr.value)})`
|
|
51
|
+
: typeof expr.value === 'object'
|
|
52
|
+
? `object (${JSON.stringify(expr.value)})`
|
|
53
|
+
: typeof expr.value;
|
|
54
|
+
throw new parser_1.QueryParseError(`Virtual field "${fieldName}" requires a string value, got ${valueType}`);
|
|
55
|
+
}
|
|
56
|
+
const value = expr.value;
|
|
57
|
+
// Validate the value is in allowedValues
|
|
58
|
+
if (!virtualFieldDef.allowedValues.includes(value)) {
|
|
59
|
+
const allowedValuesStr = virtualFieldDef.allowedValues
|
|
60
|
+
.map(v => `"${v}"`)
|
|
61
|
+
.join(', ');
|
|
62
|
+
throw new parser_1.QueryParseError(`Invalid value "${value}" for virtual field "${fieldName}". Allowed values: ${allowedValuesStr}`);
|
|
63
|
+
}
|
|
64
|
+
// Validate operator usage
|
|
65
|
+
const allowOperators = virtualFieldDef.allowOperators ?? false;
|
|
66
|
+
if (!allowOperators && expr.operator !== '==') {
|
|
67
|
+
throw new parser_1.QueryParseError(`Virtual field "${fieldName}" does not allow comparison operators. Only equality (":") is permitted.`);
|
|
68
|
+
}
|
|
69
|
+
// Create the input for the resolver
|
|
70
|
+
const input = {
|
|
71
|
+
field: fieldName,
|
|
72
|
+
operator: expr.operator,
|
|
73
|
+
value: value
|
|
74
|
+
};
|
|
75
|
+
// Create the helpers object with type-safe fields() helper
|
|
76
|
+
// The fields() method is generic at the method level, allowing TypeScript to
|
|
77
|
+
// infer TValues from the mapping object at call-time without needing type assertions
|
|
78
|
+
const helpers = {
|
|
79
|
+
fields: (mapping) => {
|
|
80
|
+
// Validate that all keys in the mapping are in the virtual field's allowed values
|
|
81
|
+
const mappingKeys = Object.keys(mapping);
|
|
82
|
+
const allowedValues = virtualFieldDef.allowedValues;
|
|
83
|
+
for (const key of mappingKeys) {
|
|
84
|
+
if (!allowedValues.includes(key)) {
|
|
85
|
+
throw new parser_1.QueryParseError(`Invalid key "${key}" in field mapping for virtual field "${fieldName}". ` +
|
|
86
|
+
`Allowed keys are: ${allowedValues.map(v => `"${v}"`).join(', ')}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Runtime: this is just an identity function
|
|
90
|
+
// Compile-time: TypeScript validates the mapping structure
|
|
91
|
+
return mapping;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
// Resolve the virtual field - no type assertions needed!
|
|
95
|
+
const resolved = virtualFieldDef.resolve(input, context, helpers);
|
|
96
|
+
return resolved;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Resolve a logical expression.
|
|
100
|
+
* Recursively resolve both left and right sides.
|
|
101
|
+
*/
|
|
102
|
+
function resolveLogicalExpression(expr, virtualFields, context) {
|
|
103
|
+
return {
|
|
104
|
+
type: 'logical',
|
|
105
|
+
operator: expr.operator,
|
|
106
|
+
left: resolveVirtualFields(expr.left, virtualFields, context),
|
|
107
|
+
right: expr.right
|
|
108
|
+
? resolveVirtualFields(expr.right, virtualFields, context)
|
|
109
|
+
: undefined
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for Virtual Fields support
|
|
3
|
+
*/
|
|
4
|
+
import { QueryExpression, IComparisonExpression, ComparisonOperator, IRawSqlExpression } from '../parser/types';
|
|
5
|
+
/**
|
|
6
|
+
* Context provided to raw SQL generators for adapter-specific SQL generation.
|
|
7
|
+
*/
|
|
8
|
+
export interface IRawSqlContext {
|
|
9
|
+
/**
|
|
10
|
+
* The database adapter identifier (e.g., 'drizzle')
|
|
11
|
+
*/
|
|
12
|
+
adapter: string;
|
|
13
|
+
/**
|
|
14
|
+
* The table name being queried
|
|
15
|
+
*/
|
|
16
|
+
tableName: string;
|
|
17
|
+
/**
|
|
18
|
+
* Access to the schema for field references
|
|
19
|
+
*/
|
|
20
|
+
schema: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Base interface for query context.
|
|
24
|
+
* Users can extend this interface with their own context properties.
|
|
25
|
+
*/
|
|
26
|
+
export interface IQueryContext {
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Input provided to a virtual field resolver.
|
|
31
|
+
* Contains the parsed field, operator, and value from the query.
|
|
32
|
+
*/
|
|
33
|
+
export interface IVirtualFieldInput {
|
|
34
|
+
/**
|
|
35
|
+
* The virtual field name (e.g., "my")
|
|
36
|
+
*/
|
|
37
|
+
field: string;
|
|
38
|
+
/**
|
|
39
|
+
* The comparison operator used (e.g., ":", ">", "<", etc.)
|
|
40
|
+
* Maps to ComparisonOperator type
|
|
41
|
+
*/
|
|
42
|
+
operator: string;
|
|
43
|
+
/**
|
|
44
|
+
* The value provided in the query
|
|
45
|
+
*/
|
|
46
|
+
value: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Helper type to filter out index signatures from a type
|
|
50
|
+
*/
|
|
51
|
+
type KnownKeys<T> = {
|
|
52
|
+
[K in keyof T]: string extends K ? never : number extends K ? never : K;
|
|
53
|
+
} extends {
|
|
54
|
+
[_ in keyof T]: infer U;
|
|
55
|
+
} ? U : never;
|
|
56
|
+
/**
|
|
57
|
+
* Utility type to extract all field names from a schema.
|
|
58
|
+
* Recursively extracts field names from nested tables, excluding index signatures.
|
|
59
|
+
*/
|
|
60
|
+
export type AllSchemaFields<TSchema extends Record<string, object>> = {
|
|
61
|
+
[K in KnownKeys<TSchema>]: TSchema[K] extends {
|
|
62
|
+
[key: string]: unknown;
|
|
63
|
+
} ? keyof TSchema[K] & string : never;
|
|
64
|
+
}[KnownKeys<TSchema>];
|
|
65
|
+
/**
|
|
66
|
+
* Type-safe mapping from allowed values to schema fields.
|
|
67
|
+
* Ensures all keys in TKeys map to valid fields in the schema.
|
|
68
|
+
*/
|
|
69
|
+
export type SchemaFieldMap<TKeys extends string, TSchema extends Record<string, object>> = Record<TKeys, AllSchemaFields<TSchema>>;
|
|
70
|
+
/**
|
|
71
|
+
* Helper functions provided to virtual field resolvers.
|
|
72
|
+
*
|
|
73
|
+
* Note: The fields() method is generic at the method level, not the interface level.
|
|
74
|
+
* This allows TypeScript to infer TValues from the mapping object passed at call-time,
|
|
75
|
+
* eliminating the need for type assertions while maintaining full type safety.
|
|
76
|
+
*/
|
|
77
|
+
export interface IResolverHelpers<TSchema extends Record<string, object>> {
|
|
78
|
+
/**
|
|
79
|
+
* Type-safe field mapping helper.
|
|
80
|
+
* Ensures all allowedValues are mapped to valid schema fields.
|
|
81
|
+
*
|
|
82
|
+
* The generic TValues parameter is inferred from the keys in the mapping object,
|
|
83
|
+
* providing full type safety without requiring explicit type annotations.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* const fieldMap = fields({
|
|
87
|
+
* assigned: 'assignee_id',
|
|
88
|
+
* created: 'creator_id'
|
|
89
|
+
* });
|
|
90
|
+
* // TypeScript infers TValues as 'assigned' | 'created'
|
|
91
|
+
*/
|
|
92
|
+
fields: <TValues extends string>(mapping: SchemaFieldMap<TValues, TSchema>) => SchemaFieldMap<TValues, TSchema>;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Schema-constrained comparison expression.
|
|
96
|
+
* Ensures field names are valid schema fields.
|
|
97
|
+
*/
|
|
98
|
+
export interface ITypedComparisonExpression<TFields extends string = string> extends Omit<IComparisonExpression, 'field'> {
|
|
99
|
+
type: 'comparison';
|
|
100
|
+
field: TFields;
|
|
101
|
+
operator: ComparisonOperator;
|
|
102
|
+
value: string | number | boolean | null | Array<string | number | boolean | null>;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Schema-constrained query expression.
|
|
106
|
+
* Can be a comparison, logical expression with typed fields, or a raw SQL expression.
|
|
107
|
+
*/
|
|
108
|
+
export type ITypedQueryExpression<TFields extends string = string> = ITypedComparisonExpression<TFields> | IRawSqlExpression | QueryExpression;
|
|
109
|
+
/**
|
|
110
|
+
* Definition for a virtual field.
|
|
111
|
+
* Configures how a virtual field should be resolved at query execution time.
|
|
112
|
+
*/
|
|
113
|
+
export interface IVirtualFieldDefinition<TSchema extends Record<string, object>, TContext extends IQueryContext = IQueryContext, TValues extends string = string> {
|
|
114
|
+
/**
|
|
115
|
+
* Allowed values for this virtual field.
|
|
116
|
+
* Use `as const` for type inference.
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* allowedValues: ['assigned', 'created', 'watching'] as const
|
|
120
|
+
*/
|
|
121
|
+
allowedValues: readonly TValues[];
|
|
122
|
+
/**
|
|
123
|
+
* Whether to allow comparison operators beyond `:` (equality).
|
|
124
|
+
* If false, only `:` is allowed. If true, `:>`, `:<`, etc. are permitted.
|
|
125
|
+
*
|
|
126
|
+
* @default false
|
|
127
|
+
*/
|
|
128
|
+
allowOperators?: boolean;
|
|
129
|
+
/**
|
|
130
|
+
* Resolve the virtual field to a real query expression.
|
|
131
|
+
* The `fields` helper ensures type-safe field references.
|
|
132
|
+
*
|
|
133
|
+
* @param input - The parsed virtual field input (field, operator, value)
|
|
134
|
+
* @param context - Runtime context provided by createContext()
|
|
135
|
+
* @param helpers - Helper functions including type-safe fields() helper
|
|
136
|
+
* @returns A query expression that replaces the virtual field
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* resolve: (input, ctx, { fields }) => {
|
|
140
|
+
* const fieldMap = fields({
|
|
141
|
+
* assigned: 'assignee_id',
|
|
142
|
+
* created: 'creator_id'
|
|
143
|
+
* });
|
|
144
|
+
* return {
|
|
145
|
+
* type: 'comparison',
|
|
146
|
+
* field: fieldMap[input.value],
|
|
147
|
+
* operator: '==',
|
|
148
|
+
* value: ctx.currentUserId
|
|
149
|
+
* };
|
|
150
|
+
* }
|
|
151
|
+
*/
|
|
152
|
+
resolve: (input: IVirtualFieldInput & {
|
|
153
|
+
value: TValues;
|
|
154
|
+
}, context: TContext, helpers: IResolverHelpers<TSchema>) => ITypedQueryExpression<AllSchemaFields<TSchema>>;
|
|
155
|
+
/**
|
|
156
|
+
* Human-readable description (for autocomplete UI).
|
|
157
|
+
* Optional metadata for documentation and tooling.
|
|
158
|
+
*/
|
|
159
|
+
description?: string;
|
|
160
|
+
/**
|
|
161
|
+
* Descriptions for each allowed value (for autocomplete UI).
|
|
162
|
+
* Optional metadata for documentation and tooling.
|
|
163
|
+
*/
|
|
164
|
+
valueDescriptions?: Partial<Record<TValues, string>>;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Configuration for all virtual fields in a QueryKit instance.
|
|
168
|
+
*
|
|
169
|
+
* Note: Uses a flexible type for the values to allow each virtual field definition
|
|
170
|
+
* to have its own specific TValues type (e.g., 'assigned' | 'created' for one field,
|
|
171
|
+
* 'high' | 'low' for another). The IResolverHelpers.fields() method infers these
|
|
172
|
+
* types at call-time, maintaining type safety without needing explicit annotations.
|
|
173
|
+
*/
|
|
174
|
+
export type VirtualFieldsConfig<TSchema extends Record<string, object> = Record<string, object>, TContext extends IQueryContext = IQueryContext> = {
|
|
175
|
+
[fieldName: string]: IVirtualFieldDefinition<TSchema, TContext, string>;
|
|
176
|
+
};
|
|
177
|
+
export {};
|