@smartive/graphql-magic 23.3.0 → 23.4.0-next.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.github/workflows/release.yml +13 -2
  2. package/CHANGELOG.md +4 -2
  3. package/dist/bin/gqm.cjs +642 -63
  4. package/dist/cjs/index.cjs +2767 -2107
  5. package/dist/esm/migrations/generate-functions.d.ts +2 -0
  6. package/dist/esm/migrations/generate-functions.js +59 -0
  7. package/dist/esm/migrations/generate-functions.js.map +1 -0
  8. package/dist/esm/migrations/generate.d.ts +8 -1
  9. package/dist/esm/migrations/generate.js +273 -33
  10. package/dist/esm/migrations/generate.js.map +1 -1
  11. package/dist/esm/migrations/index.d.ts +2 -0
  12. package/dist/esm/migrations/index.js +2 -0
  13. package/dist/esm/migrations/index.js.map +1 -1
  14. package/dist/esm/migrations/parse-functions.d.ts +8 -0
  15. package/dist/esm/migrations/parse-functions.js +105 -0
  16. package/dist/esm/migrations/parse-functions.js.map +1 -0
  17. package/dist/esm/migrations/update-functions.d.ts +2 -0
  18. package/dist/esm/migrations/update-functions.js +174 -0
  19. package/dist/esm/migrations/update-functions.js.map +1 -0
  20. package/dist/esm/models/model-definitions.d.ts +4 -1
  21. package/dist/esm/resolvers/filters.js +73 -14
  22. package/dist/esm/resolvers/filters.js.map +1 -1
  23. package/dist/esm/resolvers/selects.js +33 -2
  24. package/dist/esm/resolvers/selects.js.map +1 -1
  25. package/dist/esm/resolvers/utils.d.ts +1 -0
  26. package/dist/esm/resolvers/utils.js +22 -0
  27. package/dist/esm/resolvers/utils.js.map +1 -1
  28. package/docs/docs/3-fields.md +149 -0
  29. package/docs/docs/5-migrations.md +9 -1
  30. package/package.json +5 -1
  31. package/src/bin/gqm/gqm.ts +40 -5
  32. package/src/bin/gqm/settings.ts +7 -0
  33. package/src/migrations/generate-functions.ts +72 -0
  34. package/src/migrations/generate.ts +338 -41
  35. package/src/migrations/index.ts +2 -0
  36. package/src/migrations/parse-functions.ts +140 -0
  37. package/src/migrations/update-functions.ts +216 -0
  38. package/src/models/model-definitions.ts +4 -1
  39. package/src/resolvers/filters.ts +81 -25
  40. package/src/resolvers/selects.ts +38 -5
  41. package/src/resolvers/utils.ts +32 -0
@@ -0,0 +1,140 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+
3
+ export type ParsedFunction = {
4
+ name: string;
5
+ signature: string;
6
+ body: string;
7
+ fullDefinition: string;
8
+ isAggregate: boolean;
9
+ };
10
+
11
+ const normalizeFunctionBody = (body: string): string => {
12
+ return body
13
+ .replace(/\s+/g, ' ')
14
+ .replace(/\s*\(\s*/g, '(')
15
+ .replace(/\s*\)\s*/g, ')')
16
+ .replace(/\s*,\s*/g, ',')
17
+ .trim();
18
+ };
19
+
20
+ const normalizeAggregateDefinition = (definition: string): string => {
21
+ let normalized = definition
22
+ .replace(/\s+/g, ' ')
23
+ .replace(/\s*\(\s*/g, '(')
24
+ .replace(/\s*\)\s*/g, ')')
25
+ .replace(/\s*,\s*/g, ',')
26
+ .trim();
27
+
28
+ const initCondMatch = normalized.match(/INITCOND\s*=\s*([^,)]+)/i);
29
+ if (initCondMatch) {
30
+ const initCondValue = initCondMatch[1].trim();
31
+ const unquoted = initCondValue.replace(/^['"]|['"]$/g, '');
32
+ if (/^\d+$/.test(unquoted)) {
33
+ normalized = normalized.replace(/INITCOND\s*=\s*[^,)]+/i, `INITCOND = '${unquoted}'`);
34
+ }
35
+ }
36
+
37
+ return normalized;
38
+ };
39
+
40
+ const extractFunctionSignature = (definition: string, isAggregate: boolean): string | null => {
41
+ if (isAggregate) {
42
+ const createMatch = definition.match(/CREATE\s+(OR\s+REPLACE\s+)?AGGREGATE\s+([^(]+)\(/i);
43
+ if (!createMatch) {
44
+ return null;
45
+ }
46
+
47
+ const functionNamePart = createMatch[2].trim().replace(/^[^.]+\./, '');
48
+ const argsMatch = definition.match(/CREATE\s+(OR\s+REPLACE\s+)?AGGREGATE\s+[^(]+\(([^)]*)\)/i);
49
+ const args = argsMatch ? argsMatch[2].trim() : '';
50
+
51
+ return `${functionNamePart}(${args})`;
52
+ }
53
+
54
+ const createMatch = definition.match(/CREATE\s+(OR\s+REPLACE\s+)?FUNCTION\s+([^(]+)\(/i);
55
+ if (!createMatch) {
56
+ return null;
57
+ }
58
+
59
+ const functionNamePart = createMatch[2].trim().replace(/^[^.]+\./, '');
60
+ const fullArgsMatch = definition.match(
61
+ /CREATE\s+(OR\s+REPLACE\s+)?FUNCTION\s+[^(]+\(([\s\S]*?)\)\s*(RETURNS|LANGUAGE|AS|STRICT|IMMUTABLE|STABLE|VOLATILE|SECURITY)/i,
62
+ );
63
+
64
+ if (!fullArgsMatch) {
65
+ return null;
66
+ }
67
+
68
+ const argsSection = fullArgsMatch[2].trim();
69
+ const args = argsSection
70
+ .split(',')
71
+ .map((arg) => {
72
+ const trimmed = arg.trim();
73
+ const typeMatch = trimmed.match(/(\w+)\s+(\w+(?:\s*\[\])?)/);
74
+ if (typeMatch) {
75
+ return `${typeMatch[1]} ${typeMatch[2]}`;
76
+ }
77
+
78
+ return trimmed;
79
+ })
80
+ .join(', ');
81
+
82
+ return `${functionNamePart}(${args})`;
83
+ };
84
+
85
+ const extractFunctionBody = (definition: string): string => {
86
+ const dollarQuoteMatch = definition.match(/AS\s+\$([^$]*)\$([\s\S]*?)\$\1\$/i);
87
+ if (dollarQuoteMatch) {
88
+ return dollarQuoteMatch[2].trim();
89
+ }
90
+
91
+ const bodyMatch = definition.match(/AS\s+\$\$([\s\S]*?)\$\$/i) || definition.match(/AS\s+['"]([\s\S]*?)['"]/i);
92
+ if (bodyMatch) {
93
+ return bodyMatch[1].trim();
94
+ }
95
+
96
+ return definition;
97
+ };
98
+
99
+ export const parseFunctionsFile = (filePath: string): ParsedFunction[] => {
100
+ if (!existsSync(filePath)) {
101
+ return [];
102
+ }
103
+
104
+ const content = readFileSync(filePath, 'utf-8');
105
+ const functions: ParsedFunction[] = [];
106
+
107
+ const functionRegex =
108
+ /CREATE\s+(OR\s+REPLACE\s+)?(FUNCTION|AGGREGATE)\s+[\s\S]*?(?=CREATE\s+(OR\s+REPLACE\s+)?(FUNCTION|AGGREGATE)|$)/gi;
109
+ let match;
110
+ let lastIndex = 0;
111
+
112
+ while ((match = functionRegex.exec(content)) !== null) {
113
+ if (match.index < lastIndex) {
114
+ continue;
115
+ }
116
+ lastIndex = match.index + match[0].length;
117
+
118
+ const definition = match[0].trim();
119
+ const isAggregate = /CREATE\s+(OR\s+REPLACE\s+)?AGGREGATE/i.test(definition);
120
+ const signature = extractFunctionSignature(definition, isAggregate);
121
+
122
+ if (!signature) {
123
+ continue;
124
+ }
125
+
126
+ const nameMatch = signature.match(/^([^(]+)\(/);
127
+ const name = nameMatch ? nameMatch[1].trim().split('.').pop() || '' : '';
128
+ const body = isAggregate ? definition : extractFunctionBody(definition);
129
+
130
+ functions.push({
131
+ name,
132
+ signature,
133
+ body: isAggregate ? normalizeAggregateDefinition(body) : normalizeFunctionBody(body),
134
+ fullDefinition: definition,
135
+ isAggregate,
136
+ });
137
+ }
138
+
139
+ return functions;
140
+ };
@@ -0,0 +1,216 @@
1
+ import { Knex } from 'knex';
2
+ import { ParsedFunction, parseFunctionsFile } from './parse-functions';
3
+
4
+ type DatabaseFunction = {
5
+ name: string;
6
+ signature: string;
7
+ body: string;
8
+ isAggregate: boolean;
9
+ definition?: string;
10
+ };
11
+
12
+ const normalizeFunctionBody = (body: string): string => {
13
+ return body
14
+ .replace(/\s+/g, ' ')
15
+ .replace(/\s*\(\s*/g, '(')
16
+ .replace(/\s*\)\s*/g, ')')
17
+ .replace(/\s*,\s*/g, ',')
18
+ .trim();
19
+ };
20
+
21
+ const extractFunctionBody = (definition: string): string => {
22
+ const dollarQuoteMatch = definition.match(/AS\s+\$([^$]*)\$([\s\S]*?)\$\1\$/i);
23
+ if (dollarQuoteMatch) {
24
+ return dollarQuoteMatch[2].trim();
25
+ }
26
+
27
+ const bodyMatch = definition.match(/AS\s+\$\$([\s\S]*?)\$\$/i) || definition.match(/AS\s+['"]([\s\S]*?)['"]/i);
28
+ if (bodyMatch) {
29
+ return bodyMatch[1].trim();
30
+ }
31
+
32
+ return definition;
33
+ };
34
+
35
+ const getDatabaseFunctions = async (knex: Knex): Promise<DatabaseFunction[]> => {
36
+ const regularFunctions = await knex.raw(`
37
+ SELECT
38
+ p.proname as name,
39
+ pg_get_function_identity_arguments(p.oid) as arguments,
40
+ pg_get_functiondef(p.oid) as definition,
41
+ false as is_aggregate
42
+ FROM pg_proc p
43
+ JOIN pg_namespace n ON p.pronamespace = n.oid
44
+ WHERE n.nspname = 'public'
45
+ AND NOT EXISTS (SELECT 1 FROM pg_aggregate a WHERE a.aggfnoid = p.oid)
46
+ ORDER BY p.proname, pg_get_function_identity_arguments(p.oid)
47
+ `);
48
+
49
+ const aggregateFunctions = await knex.raw(`
50
+ SELECT
51
+ p.proname as name,
52
+ pg_get_function_identity_arguments(p.oid) as arguments,
53
+ a.aggtransfn::regproc::text as trans_func,
54
+ a.aggfinalfn::regproc::text as final_func,
55
+ a.agginitval as init_val,
56
+ pg_catalog.format_type(a.aggtranstype, NULL) as state_type,
57
+ true as is_aggregate
58
+ FROM pg_proc p
59
+ JOIN pg_aggregate a ON p.oid = a.aggfnoid
60
+ JOIN pg_namespace n ON p.pronamespace = n.oid
61
+ WHERE n.nspname = 'public'
62
+ ORDER BY p.proname, pg_get_function_identity_arguments(p.oid)
63
+ `);
64
+
65
+ const result: DatabaseFunction[] = [];
66
+
67
+ for (const row of regularFunctions.rows || []) {
68
+ const definition = row.definition || '';
69
+ const name = row.name || '';
70
+ const argumentsStr = row.arguments || '';
71
+
72
+ if (!definition) {
73
+ continue;
74
+ }
75
+
76
+ const signature = `${name}(${argumentsStr})`;
77
+ const body = normalizeFunctionBody(extractFunctionBody(definition));
78
+
79
+ result.push({
80
+ name,
81
+ signature,
82
+ body,
83
+ isAggregate: false,
84
+ definition,
85
+ });
86
+ }
87
+
88
+ for (const row of aggregateFunctions.rows || []) {
89
+ const name = row.name || '';
90
+ const argumentsStr = row.arguments || '';
91
+ const transFunc = row.trans_func || '';
92
+ const finalFunc = row.final_func || '';
93
+ const initVal = row.init_val;
94
+ const stateType = row.state_type || '';
95
+
96
+ const signature = `${name}(${argumentsStr})`;
97
+
98
+ let aggregateDef = `CREATE AGGREGATE ${name}(${argumentsStr}) (`;
99
+ aggregateDef += `SFUNC = ${transFunc}, STYPE = ${stateType}`;
100
+
101
+ if (finalFunc) {
102
+ aggregateDef += `, FINALFUNC = ${finalFunc}`;
103
+ }
104
+
105
+ if (initVal !== null && initVal !== undefined) {
106
+ const initValStr = typeof initVal === 'string' ? `'${initVal}'` : String(initVal);
107
+ aggregateDef += `, INITCOND = ${initValStr}`;
108
+ }
109
+
110
+ aggregateDef += ');';
111
+
112
+ result.push({
113
+ name,
114
+ signature,
115
+ body: normalizeFunctionBody(aggregateDef),
116
+ isAggregate: true,
117
+ definition: aggregateDef,
118
+ });
119
+ }
120
+
121
+ return result;
122
+ };
123
+
124
+ const compareFunctions = (defined: ParsedFunction, db: DatabaseFunction): { changed: boolean; diff?: string } => {
125
+ const definedBody = normalizeFunctionBody(defined.body);
126
+ const dbBody = normalizeFunctionBody(db.body);
127
+
128
+ if (definedBody !== dbBody) {
129
+ const definedPreview = definedBody.length > 200 ? `${definedBody.substring(0, 200)}...` : definedBody;
130
+ const dbPreview = dbBody.length > 200 ? `${dbBody.substring(0, 200)}...` : dbBody;
131
+
132
+ return {
133
+ changed: true,
134
+ diff: `Definition changed:\n File: ${definedPreview}\n DB: ${dbPreview}`,
135
+ };
136
+ }
137
+
138
+ return { changed: false };
139
+ };
140
+
141
+ export const updateFunctions = async (knex: Knex, functionsFilePath: string): Promise<void> => {
142
+ const definedFunctions = parseFunctionsFile(functionsFilePath);
143
+
144
+ if (definedFunctions.length === 0) {
145
+ return;
146
+ }
147
+
148
+ const dbFunctions = await getDatabaseFunctions(knex);
149
+ const dbFunctionsBySignature = new Map<string, DatabaseFunction>();
150
+ for (const func of dbFunctions) {
151
+ dbFunctionsBySignature.set(func.signature, func);
152
+ }
153
+
154
+ console.info(`Found ${definedFunctions.length} function(s) in file, ${dbFunctions.length} function(s) in database.`);
155
+
156
+ let updatedCount = 0;
157
+ let skippedCount = 0;
158
+
159
+ for (const definedFunc of definedFunctions) {
160
+ const dbFunc = dbFunctionsBySignature.get(definedFunc.signature);
161
+
162
+ if (!dbFunc) {
163
+ try {
164
+ await knex.raw(definedFunc.fullDefinition);
165
+ console.info(`✓ Created ${definedFunc.isAggregate ? 'aggregate' : 'function'}: ${definedFunc.signature}`);
166
+ updatedCount++;
167
+ } catch (error: any) {
168
+ console.error(
169
+ `✗ Failed to create ${definedFunc.isAggregate ? 'aggregate' : 'function'} ${definedFunc.signature}:`,
170
+ error.message,
171
+ );
172
+ throw error;
173
+ }
174
+ } else {
175
+ const comparison = compareFunctions(definedFunc, dbFunc);
176
+ if (comparison.changed) {
177
+ console.info(`\n⚠ ${definedFunc.isAggregate ? 'Aggregate' : 'Function'} ${definedFunc.signature} has changes:`);
178
+ if (comparison.diff) {
179
+ console.info(comparison.diff);
180
+ }
181
+ try {
182
+ if (definedFunc.isAggregate) {
183
+ const dropMatch = definedFunc.fullDefinition.match(/CREATE\s+(OR\s+REPLACE\s+)?AGGREGATE\s+([^(]+)\(/i);
184
+ if (dropMatch) {
185
+ const functionName = dropMatch[2].trim();
186
+ const argsMatch = definedFunc.fullDefinition.match(/CREATE\s+(OR\s+REPLACE\s+)?AGGREGATE\s+[^(]+\(([^)]*)\)/i);
187
+ const args = argsMatch ? argsMatch[2].trim() : '';
188
+ await knex.raw(`DROP AGGREGATE IF EXISTS ${functionName}${args ? `(${args})` : ''}`);
189
+ }
190
+ }
191
+ await knex.raw(definedFunc.fullDefinition);
192
+ console.info(`✓ Updated ${definedFunc.isAggregate ? 'aggregate' : 'function'}: ${definedFunc.signature}\n`);
193
+ updatedCount++;
194
+ } catch (error: any) {
195
+ console.error(
196
+ `✗ Failed to update ${definedFunc.isAggregate ? 'aggregate' : 'function'} ${definedFunc.signature}:`,
197
+ error.message,
198
+ );
199
+ throw error;
200
+ }
201
+ } else {
202
+ console.info(
203
+ `○ Skipped ${definedFunc.isAggregate ? 'aggregate' : 'function'} (unchanged): ${definedFunc.signature}`,
204
+ );
205
+ skippedCount++;
206
+ }
207
+ }
208
+ }
209
+
210
+ console.info(`\nSummary: ${updatedCount} updated, ${skippedCount} skipped`);
211
+ if (updatedCount > 0) {
212
+ console.info('Functions updated successfully.');
213
+ } else {
214
+ console.info('All functions are up to date.');
215
+ }
216
+ };
@@ -90,7 +90,10 @@ export type EntityFieldDefinition = FieldDefinitionBase &
90
90
  indent?: boolean;
91
91
  // If true the field is hidden in the admin interface
92
92
  hidden?: boolean;
93
- generateAs?: string;
93
+ generateAs?: {
94
+ expression: string;
95
+ type: 'virtual' | 'stored' | 'expression';
96
+ };
94
97
 
95
98
  // Temporary fields for the generation of migrations
96
99
  deleted?: true;
@@ -3,7 +3,7 @@ import { EntityModel, FullContext, getPermissionStack } from '..';
3
3
  import { ForbiddenError, UserInputError } from '../errors';
4
4
  import { OrderBy, Where, normalizeArguments } from './arguments';
5
5
  import { FieldResolverNode } from './node';
6
- import { Joins, QueryBuilderOps, addJoin, apply, applyJoins, getColumn, ors } from './utils';
6
+ import { Joins, QueryBuilderOps, addJoin, apply, applyJoins, getColumn, getColumnExpression, ors } from './utils';
7
7
 
8
8
  export const SPECIAL_FILTERS: Record<string, string> = {
9
9
  GT: '?? > ?',
@@ -157,19 +157,32 @@ const applyWhere = (node: FilterNode, where: Where | undefined, ops: QueryBuilde
157
157
  // Should not happen
158
158
  throw new Error(`Invalid filter ${key}.`);
159
159
  }
160
- ops.push((query) => query.whereRaw(SPECIAL_FILTERS[filter], [getColumn(node, actualKey), value as string]));
160
+ const actualField = node.model.getField(actualKey);
161
+ const isExpressionField = actualField.generateAs?.type === 'expression';
162
+ const actualColumn = isExpressionField ? getColumnExpression(node, actualKey) : getColumn(node, actualKey);
163
+ if (isExpressionField) {
164
+ const operator = filter === 'GT' ? '>' : filter === 'GTE' ? '>=' : filter === 'LT' ? '<' : '<=';
165
+ ops.push((query) => query.whereRaw(`${actualColumn} ${operator} ?`, [value as string]));
166
+ } else {
167
+ ops.push((query) => query.whereRaw(SPECIAL_FILTERS[filter], [actualColumn, value as string]));
168
+ }
161
169
  continue;
162
170
  }
163
171
 
164
172
  const field = node.model.getField(key);
165
173
 
166
- const column = getColumn(node, key);
174
+ const isExpressionField = field.generateAs?.type === 'expression';
175
+ const column = isExpressionField ? getColumnExpression(node, key) : getColumn(node, key);
167
176
 
168
177
  if (field.kind === 'relation') {
169
178
  const relation = node.model.getRelation(field.name);
170
179
 
171
180
  if (value === null) {
172
- ops.push((query) => query.whereNull(column));
181
+ if (isExpressionField) {
182
+ ops.push((query) => query.whereRaw(`${column} IS NULL`));
183
+ } else {
184
+ ops.push((query) => query.whereNull(column));
185
+ }
173
186
  continue;
174
187
  }
175
188
 
@@ -189,35 +202,65 @@ const applyWhere = (node: FilterNode, where: Where | undefined, ops: QueryBuilde
189
202
 
190
203
  if (Array.isArray(value)) {
191
204
  if (field && field.list) {
192
- ops.push((query) =>
193
- ors(
194
- query,
195
- value.map((v) => (subQuery) => subQuery.whereRaw('? = ANY(??)', [v, column] as string[])),
196
- ),
197
- );
205
+ if (isExpressionField) {
206
+ ops.push((query) =>
207
+ ors(
208
+ query,
209
+ value.map((v) => (subQuery) => subQuery.whereRaw(`? = ANY(${column})`, [v])),
210
+ ),
211
+ );
212
+ } else {
213
+ ops.push((query) =>
214
+ ors(
215
+ query,
216
+ value.map((v) => (subQuery) => subQuery.whereRaw('? = ANY(??)', [v, column] as string[])),
217
+ ),
218
+ );
219
+ }
198
220
  continue;
199
221
  }
200
222
 
201
223
  if (value.some((v) => v === null)) {
202
224
  if (value.some((v) => v !== null)) {
203
- ops.push((query) =>
204
- ors(query, [
205
- (subQuery) => subQuery.whereIn(column, value.filter((v) => v !== null) as string[]),
206
- (subQuery) => subQuery.whereNull(column),
207
- ]),
208
- );
225
+ if (isExpressionField) {
226
+ ops.push((query) =>
227
+ ors(query, [
228
+ (subQuery) => subQuery.whereRaw(`${column} IN (?)`, [value.filter((v) => v !== null) as string[]]),
229
+ (subQuery) => subQuery.whereRaw(`${column} IS NULL`),
230
+ ]),
231
+ );
232
+ } else {
233
+ ops.push((query) =>
234
+ ors(query, [
235
+ (subQuery) => subQuery.whereIn(column, value.filter((v) => v !== null) as string[]),
236
+ (subQuery) => subQuery.whereNull(column),
237
+ ]),
238
+ );
239
+ }
209
240
  continue;
210
241
  }
211
242
 
212
- ops.push((query) => query.whereNull(column));
243
+ if (isExpressionField) {
244
+ ops.push((query) => query.whereRaw(`${column} IS NULL`));
245
+ } else {
246
+ ops.push((query) => query.whereNull(column));
247
+ }
213
248
  continue;
214
249
  }
215
250
 
216
- ops.push((query) => query.whereIn(column, value as string[]));
251
+ if (isExpressionField) {
252
+ ops.push((query) => query.whereRaw(`${column} IN (?)`, [value as string[]]));
253
+ } else {
254
+ ops.push((query) => query.whereIn(column, value as string[]));
255
+ }
217
256
  continue;
218
257
  }
219
258
 
220
- ops.push((query) => query.where({ [column]: value }));
259
+ if (isExpressionField) {
260
+ ops.push((query) => query.whereRaw(`${column} = ?`, [value]));
261
+ } else {
262
+ ops.push((query) => query.where({ [column]: value }));
263
+ }
221
264
  }
222
265
  };
223
266
 
@@ -226,11 +269,18 @@ const applySearch = (node: FieldResolverNode, search: string, query: Knex.QueryB
226
269
  query,
227
270
  node.model.fields
228
271
  .filter(({ searchable }) => searchable)
229
- .map(
230
- ({ name }) =>
231
- (query) =>
232
- query.whereRaw('??::text ILIKE ?', [getColumn(node, name), `%${search}%`]),
233
- ),
272
+ .map((field) => {
273
+ const isExpressionField = field.generateAs?.type === 'expression';
274
+ const column = isExpressionField ? getColumnExpression(node, field.name) : getColumn(node, field.name);
275
+
276
+ return (query: Knex.QueryBuilder) => {
277
+ if (isExpressionField) {
278
+ return query.whereRaw(`${column}::text ILIKE ?`, [`%${search}%`]);
279
+ }
280
+
281
+ return query.whereRaw('??::text ILIKE ?', [column, `%${search}%`]);
282
+ };
283
+ }),
234
284
  );
235
285
 
236
286
  const applyOrderBy = (node: FilterNode, orderBy: OrderBy | OrderBy[], query: Knex.QueryBuilder, joins: Joins) => {
@@ -261,6 +311,12 @@ const applyOrderBy = (node: FilterNode, orderBy: OrderBy | OrderBy[], query: Kne
261
311
  }
262
312
 
263
313
  // Simple field
264
- void query.orderBy(getColumn(node, key), value as 'ASC' | 'DESC');
314
+ const isExpressionField = field.generateAs?.type === 'expression';
315
+ const column = isExpressionField ? getColumnExpression(node, key) : getColumn(node, key);
316
+ if (isExpressionField) {
317
+ void query.orderByRaw(`${column} ${value}`);
318
+ } else {
319
+ void query.orderBy(column, value as 'ASC' | 'DESC');
320
+ }
265
321
  }
266
322
  };
@@ -13,6 +13,7 @@ import {
13
13
  isFieldNode,
14
14
  } from '.';
15
15
  import { PermissionError, UserInputError, getRole } from '..';
16
+ import { getColumnName } from './utils';
16
17
 
17
18
  export const applySelects = (node: ResolverNode, query: Knex.QueryBuilder, joins: Joins) => {
18
19
  if (node.isAggregate) {
@@ -32,7 +33,15 @@ export const applySelects = (node: ResolverNode, query: Knex.QueryBuilder, joins
32
33
  ...[
33
34
  { tableAlias: node.rootTableAlias, resultAlias: node.resultAlias, field: 'id', fieldAlias: ID_ALIAS },
34
35
  ...(node.model.root
35
- ? [{ tableAlias: node.rootTableAlias, resultAlias: node.resultAlias, field: 'type', fieldAlias: TYPE_ALIAS }]
36
+ ? [
37
+ {
38
+ tableAlias: node.rootTableAlias,
39
+ resultAlias: node.resultAlias,
40
+ field: 'type',
41
+ fieldAlias: TYPE_ALIAS,
42
+ generateAs: undefined,
43
+ },
44
+ ]
36
45
  : []),
37
46
  ...getSimpleFields(node)
38
47
  .filter((fieldNode) => {
@@ -69,12 +78,36 @@ export const applySelects = (node: ResolverNode, query: Knex.QueryBuilder, joins
69
78
  tableAlias: field.inherited ? node.rootTableAlias : node.tableAlias,
70
79
  resultAlias: node.resultAlias,
71
80
  fieldAlias,
81
+ generateAs: field.generateAs,
72
82
  };
73
83
  }),
74
- ].map(
75
- ({ tableAlias, resultAlias, field, fieldAlias }) =>
76
- `${node.ctx.aliases.getShort(tableAlias)}.${field} as ${node.ctx.aliases.getShort(resultAlias)}__${fieldAlias}`,
77
- ),
84
+ ].map(({ tableAlias, resultAlias, field, fieldAlias, generateAs }) => {
85
+ if (generateAs?.type === 'expression') {
86
+ const tableShortAlias = node.ctx.aliases.getShort(tableAlias);
87
+ const resultShortAlias = node.ctx.aliases.getShort(resultAlias);
88
+ const expression = generateAs.expression.replace(/\b(\w+)\b/g, (match, columnName) => {
89
+ const field = node.model.fields.find((f) => {
90
+ if (f.name === columnName) {
91
+ return true;
92
+ }
93
+ const actualColumnName = getColumnName(f);
94
+
95
+ return actualColumnName === columnName;
96
+ });
97
+ if (field) {
98
+ const actualColumnName = getColumnName(field);
99
+
100
+ return `${tableShortAlias}.${actualColumnName}`;
101
+ }
102
+
103
+ return match;
104
+ });
105
+
106
+ return node.ctx.knex.raw(`(${expression}) as ??`, [`${resultShortAlias}__${fieldAlias}`]);
107
+ }
108
+
109
+ return `${node.ctx.aliases.getShort(tableAlias)}.${field} as ${node.ctx.aliases.getShort(resultAlias)}__${fieldAlias}`;
110
+ }),
78
111
  );
79
112
 
80
113
  for (const subNode of getInlineFragments(node)) {
@@ -218,6 +218,38 @@ export const getColumn = (
218
218
  return `${node.ctx.aliases.getShort(field.inherited ? node.rootTableAlias : node.tableAlias)}.${getColumnName(field)}`;
219
219
  };
220
220
 
221
+ export const getColumnExpression = (
222
+ node: Pick<ResolverNode, 'model' | 'ctx' | 'rootTableAlias' | 'tableAlias'>,
223
+ fieldName: string,
224
+ ) => {
225
+ const field = node.model.fields.find((field) => field.name === fieldName)!;
226
+
227
+ if (field.generateAs?.type === 'expression') {
228
+ const expression = field.generateAs.expression.replace(/\b(\w+)\b/g, (match, columnName) => {
229
+ const referencedField = node.model.fields.find((f) => {
230
+ if (f.name === columnName) {
231
+ return true;
232
+ }
233
+ const actualColumnName = getColumnName(f);
234
+
235
+ return actualColumnName === columnName;
236
+ });
237
+ if (referencedField) {
238
+ const actualColumnName = getColumnName(referencedField);
239
+ const referencedTableAlias = referencedField.inherited ? node.rootTableAlias : node.tableAlias;
240
+
241
+ return `${node.ctx.aliases.getShort(referencedTableAlias)}.${actualColumnName}`;
242
+ }
243
+
244
+ return match;
245
+ });
246
+
247
+ return `(${expression})`;
248
+ }
249
+
250
+ return getColumn(node, fieldName);
251
+ };
252
+
221
253
  export const getTechnicalDisplay = (model: EntityModel, entity: Entity) =>
222
254
  model.displayField && entity[model.displayField]
223
255
  ? `${model.name} "${entity[model.displayField]}" (${entity.id})`