@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.
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Virtual field resolution logic
3
+ */
4
+
5
+ import {
6
+ QueryExpression,
7
+ IComparisonExpression,
8
+ ILogicalExpression
9
+ } from '../parser/types';
10
+ import { QueryParseError } from '../parser/parser';
11
+ import {
12
+ IQueryContext,
13
+ IVirtualFieldInput,
14
+ VirtualFieldsConfig,
15
+ IResolverHelpers,
16
+ SchemaFieldMap
17
+ } from './types';
18
+
19
+ /**
20
+ * Resolve virtual fields in a query expression.
21
+ * Recursively walks the AST and replaces virtual field references with
22
+ * their resolved expressions based on the provided context.
23
+ *
24
+ * @param expr - The query expression to resolve
25
+ * @param virtualFields - Virtual field configuration
26
+ * @param context - Runtime context for resolution
27
+ * @returns The resolved query expression
28
+ * @throws {QueryParseError} If a virtual field value is invalid or operator is not allowed
29
+ */
30
+ export function resolveVirtualFields<
31
+ TSchema extends Record<string, object>,
32
+ TContext extends IQueryContext
33
+ >(
34
+ expr: QueryExpression,
35
+ virtualFields: VirtualFieldsConfig<TSchema, TContext>,
36
+ context: TContext
37
+ ): QueryExpression {
38
+ // Base case: comparison expression
39
+ if (expr.type === 'comparison') {
40
+ return resolveComparisonExpression(expr, virtualFields, context);
41
+ }
42
+
43
+ // Recursive case: logical expression
44
+ if (expr.type === 'logical') {
45
+ return resolveLogicalExpression(expr, virtualFields, context);
46
+ }
47
+
48
+ // Pass through raw expressions
49
+ if (expr.type === 'raw') {
50
+ return expr;
51
+ }
52
+
53
+ // Unknown expression type, return as-is
54
+ return expr;
55
+ }
56
+
57
+ /**
58
+ * Resolve a comparison expression.
59
+ * If the field is a virtual field, resolve it using the configuration.
60
+ * Otherwise, return the expression unchanged.
61
+ */
62
+ function resolveComparisonExpression<
63
+ TSchema extends Record<string, object>,
64
+ TContext extends IQueryContext
65
+ >(
66
+ expr: IComparisonExpression,
67
+ virtualFields: VirtualFieldsConfig<TSchema, TContext>,
68
+ context: TContext
69
+ ): QueryExpression {
70
+ const fieldName = expr.field;
71
+ const virtualFieldDef = virtualFields[fieldName];
72
+
73
+ // Not a virtual field, return as-is
74
+ if (!virtualFieldDef) {
75
+ return expr;
76
+ }
77
+
78
+ // Validate the value is a string (virtual fields require string values)
79
+ if (typeof expr.value !== 'string') {
80
+ const valueType = Array.isArray(expr.value)
81
+ ? `array (${JSON.stringify(expr.value)})`
82
+ : typeof expr.value === 'object'
83
+ ? `object (${JSON.stringify(expr.value)})`
84
+ : typeof expr.value;
85
+
86
+ throw new QueryParseError(
87
+ `Virtual field "${fieldName}" requires a string value, got ${valueType}`
88
+ );
89
+ }
90
+
91
+ const value = expr.value;
92
+
93
+ // Validate the value is in allowedValues
94
+ if (!virtualFieldDef.allowedValues.includes(value)) {
95
+ const allowedValuesStr = virtualFieldDef.allowedValues
96
+ .map(v => `"${v}"`)
97
+ .join(', ');
98
+ throw new QueryParseError(
99
+ `Invalid value "${value}" for virtual field "${fieldName}". Allowed values: ${allowedValuesStr}`
100
+ );
101
+ }
102
+
103
+ // Validate operator usage
104
+ const allowOperators = virtualFieldDef.allowOperators ?? false;
105
+ if (!allowOperators && expr.operator !== '==') {
106
+ throw new QueryParseError(
107
+ `Virtual field "${fieldName}" does not allow comparison operators. Only equality (":") is permitted.`
108
+ );
109
+ }
110
+
111
+ // Create the input for the resolver
112
+ const input: IVirtualFieldInput & { value: string } = {
113
+ field: fieldName,
114
+ operator: expr.operator,
115
+ value: value
116
+ };
117
+
118
+ // Create the helpers object with type-safe fields() helper
119
+ // The fields() method is generic at the method level, allowing TypeScript to
120
+ // infer TValues from the mapping object at call-time without needing type assertions
121
+ const helpers: IResolverHelpers<TSchema> = {
122
+ fields: <TValues extends string>(
123
+ mapping: SchemaFieldMap<TValues, TSchema>
124
+ ): SchemaFieldMap<TValues, TSchema> => {
125
+ // Validate that all keys in the mapping are in the virtual field's allowed values
126
+ const mappingKeys = Object.keys(mapping);
127
+ const allowedValues = virtualFieldDef.allowedValues as readonly string[];
128
+
129
+ for (const key of mappingKeys) {
130
+ if (!allowedValues.includes(key)) {
131
+ throw new QueryParseError(
132
+ `Invalid key "${key}" in field mapping for virtual field "${fieldName}". ` +
133
+ `Allowed keys are: ${allowedValues.map(v => `"${v}"`).join(', ')}`
134
+ );
135
+ }
136
+ }
137
+
138
+ // Runtime: this is just an identity function
139
+ // Compile-time: TypeScript validates the mapping structure
140
+ return mapping;
141
+ }
142
+ };
143
+
144
+ // Resolve the virtual field - no type assertions needed!
145
+ const resolved = virtualFieldDef.resolve(input, context, helpers);
146
+
147
+ return resolved as QueryExpression;
148
+ }
149
+
150
+ /**
151
+ * Resolve a logical expression.
152
+ * Recursively resolve both left and right sides.
153
+ */
154
+ function resolveLogicalExpression<
155
+ TSchema extends Record<string, object>,
156
+ TContext extends IQueryContext
157
+ >(
158
+ expr: ILogicalExpression,
159
+ virtualFields: VirtualFieldsConfig<TSchema, TContext>,
160
+ context: TContext
161
+ ): ILogicalExpression {
162
+ return {
163
+ type: 'logical',
164
+ operator: expr.operator,
165
+ left: resolveVirtualFields(expr.left, virtualFields, context),
166
+ right: expr.right
167
+ ? resolveVirtualFields(expr.right, virtualFields, context)
168
+ : undefined
169
+ };
170
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Type definitions for Virtual Fields support
3
+ */
4
+
5
+ import {
6
+ QueryExpression,
7
+ IComparisonExpression,
8
+ ComparisonOperator,
9
+ IRawSqlExpression
10
+ } from '../parser/types';
11
+
12
+ /**
13
+ * Context provided to raw SQL generators for adapter-specific SQL generation.
14
+ */
15
+ export interface IRawSqlContext {
16
+ /**
17
+ * The database adapter identifier (e.g., 'drizzle')
18
+ */
19
+ adapter: string;
20
+ /**
21
+ * The table name being queried
22
+ */
23
+ tableName: string;
24
+ /**
25
+ * Access to the schema for field references
26
+ */
27
+ schema: Record<string, unknown>;
28
+ }
29
+
30
+ /**
31
+ * Base interface for query context.
32
+ * Users can extend this interface with their own context properties.
33
+ */
34
+ export interface IQueryContext {
35
+ [key: string]: unknown;
36
+ }
37
+
38
+ /**
39
+ * Input provided to a virtual field resolver.
40
+ * Contains the parsed field, operator, and value from the query.
41
+ */
42
+ export interface IVirtualFieldInput {
43
+ /**
44
+ * The virtual field name (e.g., "my")
45
+ */
46
+ field: string;
47
+
48
+ /**
49
+ * The comparison operator used (e.g., ":", ">", "<", etc.)
50
+ * Maps to ComparisonOperator type
51
+ */
52
+ operator: string;
53
+
54
+ /**
55
+ * The value provided in the query
56
+ */
57
+ value: string;
58
+ }
59
+
60
+ /**
61
+ * Helper type to filter out index signatures from a type
62
+ */
63
+ type KnownKeys<T> = {
64
+ [K in keyof T]: string extends K ? never : number extends K ? never : K;
65
+ } extends { [_ in keyof T]: infer U }
66
+ ? U
67
+ : never;
68
+
69
+ /**
70
+ * Utility type to extract all field names from a schema.
71
+ * Recursively extracts field names from nested tables, excluding index signatures.
72
+ */
73
+ export type AllSchemaFields<TSchema extends Record<string, object>> = {
74
+ [K in KnownKeys<TSchema>]: TSchema[K] extends { [key: string]: unknown }
75
+ ? keyof TSchema[K] & string
76
+ : never;
77
+ }[KnownKeys<TSchema>];
78
+
79
+ /**
80
+ * Type-safe mapping from allowed values to schema fields.
81
+ * Ensures all keys in TKeys map to valid fields in the schema.
82
+ */
83
+ export type SchemaFieldMap<
84
+ TKeys extends string,
85
+ TSchema extends Record<string, object>
86
+ > = Record<TKeys, AllSchemaFields<TSchema>>;
87
+
88
+ /**
89
+ * Helper functions provided to virtual field resolvers.
90
+ *
91
+ * Note: The fields() method is generic at the method level, not the interface level.
92
+ * This allows TypeScript to infer TValues from the mapping object passed at call-time,
93
+ * eliminating the need for type assertions while maintaining full type safety.
94
+ */
95
+ export interface IResolverHelpers<TSchema extends Record<string, object>> {
96
+ /**
97
+ * Type-safe field mapping helper.
98
+ * Ensures all allowedValues are mapped to valid schema fields.
99
+ *
100
+ * The generic TValues parameter is inferred from the keys in the mapping object,
101
+ * providing full type safety without requiring explicit type annotations.
102
+ *
103
+ * @example
104
+ * const fieldMap = fields({
105
+ * assigned: 'assignee_id',
106
+ * created: 'creator_id'
107
+ * });
108
+ * // TypeScript infers TValues as 'assigned' | 'created'
109
+ */
110
+ fields: <TValues extends string>(
111
+ mapping: SchemaFieldMap<TValues, TSchema>
112
+ ) => SchemaFieldMap<TValues, TSchema>;
113
+ }
114
+
115
+ /**
116
+ * Schema-constrained comparison expression.
117
+ * Ensures field names are valid schema fields.
118
+ */
119
+ export interface ITypedComparisonExpression<
120
+ TFields extends string = string
121
+ > extends Omit<IComparisonExpression, 'field'> {
122
+ type: 'comparison';
123
+ field: TFields;
124
+ operator: ComparisonOperator;
125
+ value:
126
+ | string
127
+ | number
128
+ | boolean
129
+ | null
130
+ | Array<string | number | boolean | null>;
131
+ }
132
+
133
+ /**
134
+ * Schema-constrained query expression.
135
+ * Can be a comparison, logical expression with typed fields, or a raw SQL expression.
136
+ */
137
+ export type ITypedQueryExpression<TFields extends string = string> =
138
+ | ITypedComparisonExpression<TFields>
139
+ | IRawSqlExpression
140
+ | QueryExpression;
141
+
142
+ /**
143
+ * Definition for a virtual field.
144
+ * Configures how a virtual field should be resolved at query execution time.
145
+ */
146
+ export interface IVirtualFieldDefinition<
147
+ TSchema extends Record<string, object>,
148
+ TContext extends IQueryContext = IQueryContext,
149
+ TValues extends string = string
150
+ > {
151
+ /**
152
+ * Allowed values for this virtual field.
153
+ * Use `as const` for type inference.
154
+ *
155
+ * @example
156
+ * allowedValues: ['assigned', 'created', 'watching'] as const
157
+ */
158
+ allowedValues: readonly TValues[];
159
+
160
+ /**
161
+ * Whether to allow comparison operators beyond `:` (equality).
162
+ * If false, only `:` is allowed. If true, `:>`, `:<`, etc. are permitted.
163
+ *
164
+ * @default false
165
+ */
166
+ allowOperators?: boolean;
167
+
168
+ /**
169
+ * Resolve the virtual field to a real query expression.
170
+ * The `fields` helper ensures type-safe field references.
171
+ *
172
+ * @param input - The parsed virtual field input (field, operator, value)
173
+ * @param context - Runtime context provided by createContext()
174
+ * @param helpers - Helper functions including type-safe fields() helper
175
+ * @returns A query expression that replaces the virtual field
176
+ *
177
+ * @example
178
+ * resolve: (input, ctx, { fields }) => {
179
+ * const fieldMap = fields({
180
+ * assigned: 'assignee_id',
181
+ * created: 'creator_id'
182
+ * });
183
+ * return {
184
+ * type: 'comparison',
185
+ * field: fieldMap[input.value],
186
+ * operator: '==',
187
+ * value: ctx.currentUserId
188
+ * };
189
+ * }
190
+ */
191
+ resolve: (
192
+ input: IVirtualFieldInput & { value: TValues },
193
+ context: TContext,
194
+ helpers: IResolverHelpers<TSchema>
195
+ ) => ITypedQueryExpression<AllSchemaFields<TSchema>>;
196
+
197
+ /**
198
+ * Human-readable description (for autocomplete UI).
199
+ * Optional metadata for documentation and tooling.
200
+ */
201
+ description?: string;
202
+
203
+ /**
204
+ * Descriptions for each allowed value (for autocomplete UI).
205
+ * Optional metadata for documentation and tooling.
206
+ */
207
+ valueDescriptions?: Partial<Record<TValues, string>>;
208
+ }
209
+
210
+ /**
211
+ * Configuration for all virtual fields in a QueryKit instance.
212
+ *
213
+ * Note: Uses a flexible type for the values to allow each virtual field definition
214
+ * to have its own specific TValues type (e.g., 'assigned' | 'created' for one field,
215
+ * 'high' | 'low' for another). The IResolverHelpers.fields() method infers these
216
+ * types at call-time, maintaining type safety without needing explicit annotations.
217
+ */
218
+ export type VirtualFieldsConfig<
219
+ TSchema extends Record<string, object> = Record<string, object>,
220
+ TContext extends IQueryContext = IQueryContext
221
+ > = {
222
+ [fieldName: string]: IVirtualFieldDefinition<TSchema, TContext, string>;
223
+ };
@@ -0,0 +1,182 @@
1
+ /**
2
+ * End-to-end integration test demonstrating the user's example from the issue
3
+ */
4
+
5
+ import { sql } from 'drizzle-orm';
6
+ import type { SQL } from 'drizzle-orm';
7
+ import { jsonbContains, dateWithinDays } from '../virtual-fields';
8
+ import { QueryParser } from '../parser';
9
+ import { resolveVirtualFields } from '../virtual-fields/resolver';
10
+ import { IQueryContext, VirtualFieldsConfig } from '../virtual-fields/types';
11
+ import { DrizzleTranslator } from '../translators/drizzle';
12
+
13
+ // Mock schema similar to the user's example
14
+ type UserSchema = {
15
+ my_table: {
16
+ id: number;
17
+ title: string;
18
+ description: string;
19
+ created_at: Date;
20
+ assigned_to: string[];
21
+ status: string;
22
+ };
23
+ };
24
+
25
+ interface IMyContext extends IQueryContext {
26
+ currentUserId: string;
27
+ }
28
+
29
+ describe('Raw SQL Expression - User Example Integration', () => {
30
+ it('should parse and resolve my:assigned JSONB query from user example', () => {
31
+ const parser = new QueryParser();
32
+ const translator = new DrizzleTranslator();
33
+
34
+ const virtualFields: VirtualFieldsConfig<UserSchema, IMyContext> = {
35
+ my: {
36
+ allowedValues: ['assigned'] as const,
37
+ description: 'Filter by your relationship to items',
38
+ resolve: (input, ctx) => {
39
+ if (input.value === 'assigned') {
40
+ return jsonbContains('assigned_to', ctx.currentUserId);
41
+ }
42
+ throw new Error(`Unknown value: ${input.value}`);
43
+ }
44
+ }
45
+ };
46
+
47
+ const context: IMyContext = { currentUserId: 'user123' };
48
+
49
+ // Parse the query
50
+ const expr = parser.parse('my:assigned');
51
+ expect(expr.type).toBe('comparison');
52
+
53
+ // Resolve virtual fields
54
+ const resolved = resolveVirtualFields(expr, virtualFields, context);
55
+ expect(resolved.type).toBe('raw');
56
+
57
+ // Translate to SQL
58
+ const result = translator.translate(resolved);
59
+ expect(result).toBeDefined();
60
+
61
+ // Verify SQL contains expected elements
62
+ const sqlString = JSON.stringify(result);
63
+ expect(sqlString).toContain('assigned_to');
64
+ expect(sqlString).toContain('user123');
65
+ });
66
+
67
+ it('should parse and resolve priority:high computed field from user example', () => {
68
+ const parser = new QueryParser();
69
+ const translator = new DrizzleTranslator();
70
+
71
+ const virtualFields: VirtualFieldsConfig<UserSchema, IMyContext> = {
72
+ priority: {
73
+ allowedValues: ['high', 'medium', 'low'] as const,
74
+ description: 'Filter by computed priority (based on age)',
75
+ resolve: input => {
76
+ const thresholds = { high: 1, medium: 7, low: 30 };
77
+ const days = thresholds[input.value as keyof typeof thresholds];
78
+ return dateWithinDays('created_at', days);
79
+ }
80
+ }
81
+ };
82
+
83
+ const context: IMyContext = { currentUserId: 'user123' };
84
+
85
+ // Parse the query
86
+ const expr = parser.parse('priority:high');
87
+ expect(expr.type).toBe('comparison');
88
+
89
+ // Resolve virtual fields
90
+ const resolved = resolveVirtualFields(expr, virtualFields, context);
91
+ expect(resolved.type).toBe('raw');
92
+
93
+ // Translate to SQL
94
+ const result = translator.translate(resolved);
95
+ expect(result).toBeDefined();
96
+
97
+ // Verify SQL contains expected elements
98
+ const sqlString = JSON.stringify(result);
99
+ expect(sqlString).toContain('created_at');
100
+ expect(sqlString).toContain('1');
101
+ });
102
+
103
+ it('should handle combined query: my:assigned AND priority:high AND status:active', () => {
104
+ const parser = new QueryParser();
105
+ const translator = new DrizzleTranslator();
106
+
107
+ const virtualFields: VirtualFieldsConfig<UserSchema, IMyContext> = {
108
+ my: {
109
+ allowedValues: ['assigned'] as const,
110
+ resolve: (input, ctx) => jsonbContains('assigned_to', ctx.currentUserId)
111
+ },
112
+ priority: {
113
+ allowedValues: ['high', 'medium', 'low'] as const,
114
+ resolve: input => {
115
+ const days = { high: 1, medium: 7, low: 30 }[
116
+ input.value as 'high' | 'medium' | 'low'
117
+ ];
118
+ return dateWithinDays('created_at', days);
119
+ }
120
+ }
121
+ };
122
+
123
+ const context: IMyContext = { currentUserId: 'user123' };
124
+
125
+ // Parse the combined query
126
+ const expr = parser.parse(
127
+ 'my:assigned AND priority:high AND status:active'
128
+ );
129
+ expect(expr.type).toBe('logical');
130
+
131
+ // Resolve virtual fields
132
+ const resolved = resolveVirtualFields(expr, virtualFields, context);
133
+ expect(resolved.type).toBe('logical');
134
+
135
+ // Translate to SQL
136
+ const result = translator.translate(resolved);
137
+ expect(result).toBeDefined();
138
+
139
+ // Verify SQL contains all expected elements
140
+ const sqlString = JSON.stringify(result);
141
+ expect(sqlString).toContain('assigned_to');
142
+ expect(sqlString).toContain('created_at');
143
+ expect(sqlString).toContain('status');
144
+ expect(sqlString).toContain('AND');
145
+ });
146
+
147
+ it('should support custom raw SQL as shown in user example', () => {
148
+ const parser = new QueryParser();
149
+ const translator = new DrizzleTranslator();
150
+
151
+ const virtualFields: VirtualFieldsConfig<UserSchema, IMyContext> = {
152
+ custom: {
153
+ allowedValues: ['active'] as const,
154
+ resolve: (_input, ctx) => ({
155
+ type: 'raw',
156
+ toSql: (): SQL =>
157
+ sql`status = 'active' AND owner_id = ${ctx.currentUserId}`
158
+ })
159
+ }
160
+ };
161
+
162
+ const context: IMyContext = { currentUserId: 'user123' };
163
+
164
+ // Parse the query
165
+ const expr = parser.parse('custom:active');
166
+ expect(expr.type).toBe('comparison');
167
+
168
+ // Resolve virtual fields
169
+ const resolved = resolveVirtualFields(expr, virtualFields, context);
170
+ expect(resolved.type).toBe('raw');
171
+
172
+ // Translate to SQL
173
+ const result = translator.translate(resolved);
174
+ expect(result).toBeDefined();
175
+
176
+ // Verify SQL contains expected elements
177
+ const sqlString = JSON.stringify(result);
178
+ expect(sqlString).toContain('status');
179
+ expect(sqlString).toContain('active');
180
+ expect(sqlString).toContain('user123');
181
+ });
182
+ });