@smartive/graphql-magic 23.4.1 → 23.5.0-next.2

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 (42) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/dist/bin/gqm.cjs +568 -59
  3. package/dist/cjs/index.cjs +2635 -2148
  4. package/dist/esm/migrations/generate-functions.d.ts +2 -0
  5. package/dist/esm/migrations/generate-functions.js +60 -0
  6. package/dist/esm/migrations/generate-functions.js.map +1 -0
  7. package/dist/esm/migrations/generate.d.ts +5 -1
  8. package/dist/esm/migrations/generate.js +151 -33
  9. package/dist/esm/migrations/generate.js.map +1 -1
  10. package/dist/esm/migrations/index.d.ts +2 -0
  11. package/dist/esm/migrations/index.js +2 -0
  12. package/dist/esm/migrations/index.js.map +1 -1
  13. package/dist/esm/migrations/types.d.ts +7 -0
  14. package/dist/esm/migrations/types.js +2 -0
  15. package/dist/esm/migrations/types.js.map +1 -0
  16. package/dist/esm/migrations/update-functions.d.ts +14 -0
  17. package/dist/esm/migrations/update-functions.js +201 -0
  18. package/dist/esm/migrations/update-functions.js.map +1 -0
  19. package/dist/esm/models/model-definitions.d.ts +4 -1
  20. package/dist/esm/resolvers/filters.js +76 -14
  21. package/dist/esm/resolvers/filters.js.map +1 -1
  22. package/dist/esm/resolvers/selects.js +20 -2
  23. package/dist/esm/resolvers/selects.js.map +1 -1
  24. package/dist/esm/resolvers/utils.d.ts +1 -0
  25. package/dist/esm/resolvers/utils.js +29 -0
  26. package/dist/esm/resolvers/utils.js.map +1 -1
  27. package/docs/docs/3-fields.md +149 -0
  28. package/docs/docs/5-migrations.md +9 -1
  29. package/package.json +2 -2
  30. package/src/bin/gqm/gqm.ts +44 -5
  31. package/src/bin/gqm/parse-functions.ts +141 -0
  32. package/src/bin/gqm/settings.ts +7 -0
  33. package/src/bin/gqm/utils.ts +1 -0
  34. package/src/migrations/generate-functions.ts +74 -0
  35. package/src/migrations/generate.ts +192 -41
  36. package/src/migrations/index.ts +2 -0
  37. package/src/migrations/types.ts +7 -0
  38. package/src/migrations/update-functions.ts +247 -0
  39. package/src/models/model-definitions.ts +4 -1
  40. package/src/resolvers/filters.ts +88 -25
  41. package/src/resolvers/selects.ts +22 -5
  42. package/src/resolvers/utils.ts +44 -0
@@ -341,3 +341,152 @@ the reverse relation will automatically be `Post.comments`. With `reverse` this
341
341
  ### `onDelete`
342
342
 
343
343
  Only available on relation fields. Can be `"cascade"` (default), `"restrict"` or `"set-null"`.
344
+
345
+ ### `generateAs`
346
+
347
+ Only available on entity fields. Allows you to define fields that are computed from SQL expressions rather than stored directly. This option accepts an object with:
348
+
349
+ - `expression`: A SQL expression string that defines how the field is computed
350
+ - `type`: One of `'virtual'`, `'stored'`, or `'expression'`
351
+
352
+ #### `type: 'virtual'` and `type: 'stored'`
353
+
354
+ These types create SQL generated columns in the database. The difference is:
355
+
356
+ - **`'virtual'`**: The value is computed on-the-fly when queried (not stored in the database)
357
+ - **`'stored'`**: The value is computed and stored in the database (takes up space but faster queries)
358
+
359
+ Both types affect the SQL schema and create actual database columns.
360
+
361
+ Example:
362
+
363
+ ```ts
364
+ {
365
+ name: 'Product',
366
+ fields: [
367
+ {
368
+ name: 'price',
369
+ type: 'Float',
370
+ },
371
+ {
372
+ name: 'quantity',
373
+ type: 'Int',
374
+ },
375
+ {
376
+ name: 'totalPrice',
377
+ type: 'Float',
378
+ generateAs: {
379
+ expression: 'price * quantity',
380
+ type: 'stored'
381
+ }
382
+ }
383
+ ]
384
+ }
385
+ ```
386
+
387
+ This creates a `totalPrice` column in the database that is automatically computed as `price * quantity`. The expression can reference other columns in the same table.
388
+
389
+ #### `type: 'expression'`
390
+
391
+ This type creates a field that is **not** stored in the database schema. Instead, the expression is evaluated at query time during SELECT operations. This is useful for:
392
+
393
+ - Computed fields that don't need to be stored
394
+ - Fields that reference columns but shouldn't create database columns
395
+ - Dynamic calculations that should always use the latest data
396
+
397
+ Example:
398
+
399
+ ```ts
400
+ {
401
+ name: 'Product',
402
+ fields: [
403
+ {
404
+ name: 'price',
405
+ type: 'Float',
406
+ },
407
+ {
408
+ name: 'quantity',
409
+ type: 'Int',
410
+ },
411
+ {
412
+ name: 'totalPrice',
413
+ type: 'Float',
414
+ generateAs: {
415
+ expression: 'price * quantity',
416
+ type: 'expression'
417
+ },
418
+ filterable: true,
419
+ comparable: true,
420
+ orderable: true
421
+ }
422
+ ]
423
+ }
424
+ ```
425
+
426
+ This creates a `totalPrice` field that is computed at query time. The expression can reference other columns in the table, and column names in the expression are automatically resolved with proper table aliases.
427
+
428
+ **Important notes for `type: 'expression'`:**
429
+
430
+ - The field does **not** create a database column
431
+ - The expression is evaluated during SELECT queries
432
+ - Column references in the expression are automatically prefixed with the table alias
433
+ - Can be combined with `filterable`, `comparable`, `orderable`, and `searchable` options
434
+ - Works with all filter types (equality, arrays, null checks, comparisons)
435
+
436
+ **Expression examples:**
437
+
438
+ ```ts
439
+ // Simple calculation
440
+ {
441
+ name: 'totalPrice',
442
+ type: 'Float',
443
+ generateAs: {
444
+ expression: 'price * quantity',
445
+ type: 'expression'
446
+ }
447
+ }
448
+
449
+ // Using SQL functions
450
+ {
451
+ name: 'fullName',
452
+ type: 'String',
453
+ generateAs: {
454
+ expression: "COALESCE(firstName || ' ' || lastName, 'Unknown')",
455
+ type: 'expression'
456
+ }
457
+ }
458
+
459
+ // Conditional logic
460
+ {
461
+ name: 'discountedPrice',
462
+ type: 'Float',
463
+ generateAs: {
464
+ expression: 'CASE WHEN discount > 0 THEN price * (1 - discount) ELSE price END',
465
+ type: 'expression'
466
+ }
467
+ }
468
+ ```
469
+
470
+ **Filtering expression fields:**
471
+
472
+ When `filterable: true` is set on an expression field, you can filter by it:
473
+
474
+ ```graphql
475
+ query {
476
+ products(where: { totalPrice_GT: 100 }) {
477
+ totalPrice
478
+ }
479
+ }
480
+ ```
481
+
482
+ **Ordering expression fields:**
483
+
484
+ When `orderable: true` is set on an expression field, you can order by it:
485
+
486
+ ```graphql
487
+ query {
488
+ products(orderBy: [{ totalPrice: DESC }]) {
489
+ totalPrice
490
+ }
491
+ }
492
+ ```
@@ -20,9 +20,17 @@ Note: if you are in a `feat/<feature-name>` branch, the script will use that as
20
20
 
21
21
  This will generate a migration file in the `migrations` folder (without running the migration itself yet). Check whether it needs to be adapted.
22
22
 
23
+ ### Generated columns
24
+
25
+ Fields with `generateAs` are handled specially in migrations:
26
+
27
+ - **`type: 'virtual'` or `type: 'stored'`**: These create SQL `GENERATED ALWAYS AS` columns in the database. The migration generator will create these columns automatically.
28
+
29
+ - **`type: 'expression'`**: These fields do **not** create database columns. They are computed at query time and will not appear in migration files. This means you can add or remove expression fields without needing a migration.
30
+
23
31
  ## Running migrations
24
32
 
25
- Migrations themselves are managed with `knex` (see the [knex migration docs](https://knexjs.org/guide/migrations.html)).
33
+ Migrations themselves are managed with `knex` (see the [knex migration docs](https://knexjs.org/guide/migrations.html)).
26
34
 
27
35
  For example, to migrate to the latest version (using `env-cmd` to add db connection variables in `.env`):
28
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smartive/graphql-magic",
3
- "version": "23.4.1",
3
+ "version": "23.5.0-next.2",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -68,7 +68,7 @@
68
68
  "knex": "^3.0.1"
69
69
  },
70
70
  "devDependencies": {
71
- "@smartive/eslint-config": "7.0.0",
71
+ "@smartive/eslint-config": "7.0.1",
72
72
  "@smartive/prettier-config": "3.1.2",
73
73
  "@types/jest": "30.0.0",
74
74
  "@types/lodash": "4.17.23",
@@ -12,8 +12,11 @@ import {
12
12
  getMigrationDate,
13
13
  printSchemaFromModels,
14
14
  } from '../..';
15
+ import { generateFunctionsFromDatabase } from '../../migrations/generate-functions';
16
+ import { updateFunctions } from '../../migrations/update-functions';
15
17
  import { DateLibrary } from '../../utils/dates';
16
18
  import { generateGraphqlApiTypes, generateGraphqlClientTypes } from './codegen';
19
+ import { parseFunctionsFile } from './parse-functions';
17
20
  import { parseKnexfile } from './parse-knexfile';
18
21
  import { parseModels } from './parse-models';
19
22
  import { generatePermissionTypes } from './permissions';
@@ -76,7 +79,9 @@ program
76
79
 
77
80
  try {
78
81
  const models = await parseModels();
79
- const migrations = await new MigrationGenerator(db, models).generate();
82
+ const functionsPath = await getSetting('functionsPath');
83
+ const parsedFunctions = parseFunctionsFile(functionsPath);
84
+ const migrations = await new MigrationGenerator(db, models, parsedFunctions).generate();
80
85
 
81
86
  writeToFile(`migrations/${date || getMigrationDate()}_${name}.ts`, migrations);
82
87
  } finally {
@@ -93,7 +98,9 @@ program
93
98
 
94
99
  try {
95
100
  const models = await parseModels();
96
- const mg = new MigrationGenerator(db, models);
101
+ const functionsPath = await getSetting('functionsPath');
102
+ const parsedFunctions = parseFunctionsFile(functionsPath);
103
+ const mg = new MigrationGenerator(db, models, parsedFunctions);
97
104
  await mg.generate();
98
105
 
99
106
  if (mg.needsMigration) {
@@ -106,10 +113,42 @@ program
106
113
  });
107
114
 
108
115
  program
109
- .command('*', { noHelp: true })
116
+ .command('generate-functions')
117
+ .description('Generate functions.ts file from database')
118
+ .action(async () => {
119
+ const knexfile = await parseKnexfile();
120
+ const db = knex(knexfile);
121
+
122
+ try {
123
+ const functionsPath = await getSetting('functionsPath');
124
+ const functions = await generateFunctionsFromDatabase(db);
125
+ writeToFile(functionsPath, functions);
126
+ } finally {
127
+ await db.destroy();
128
+ }
129
+ });
130
+
131
+ program
132
+ .command('update-functions')
133
+ .description('Update database functions from functions.ts file')
134
+ .action(async () => {
135
+ const knexfile = await parseKnexfile();
136
+ const db = knex(knexfile);
137
+
138
+ try {
139
+ const functionsPath = await getSetting('functionsPath');
140
+ const parsedFunctions = parseFunctionsFile(functionsPath);
141
+ await updateFunctions(db, parsedFunctions);
142
+ } finally {
143
+ await db.destroy();
144
+ }
145
+ });
146
+
147
+ program
148
+ .command('*')
110
149
  .description('Invalid command')
111
- .action(() => {
112
- console.error('Invalid command: %s\nSee --help for a list of available commands.', program.args.join(' '));
150
+ .action((command) => {
151
+ console.error(`Invalid command: ${command}\nSee --help for a list of available commands.`);
113
152
  process.exit(1);
114
153
  });
115
154
 
@@ -0,0 +1,141 @@
1
+ import { existsSync } from 'fs';
2
+ import { IndentationText, Project } from 'ts-morph';
3
+ import { ParsedFunction } from '../../migrations/types';
4
+ import { staticEval } from './static-eval';
5
+ import { findDeclarationInFile } from './utils';
6
+
7
+ const normalizeWhitespace = (str: string): string => {
8
+ return str
9
+ .replace(/\s+/g, ' ')
10
+ .replace(/\s*\(\s*/g, '(')
11
+ .replace(/\s*\)\s*/g, ')')
12
+ .replace(/\s*,\s*/g, ',')
13
+ .replace(/\s*;\s*/g, ';')
14
+ .trim();
15
+ };
16
+
17
+ const normalizeFunctionBody = (body: string): string => {
18
+ return normalizeWhitespace(body);
19
+ };
20
+
21
+ const normalizeAggregateDefinition = (definition: string): string => {
22
+ let normalized = normalizeWhitespace(definition);
23
+
24
+ const initCondMatch = normalized.match(/INITCOND\s*=\s*([^,)]+)/i);
25
+ if (initCondMatch) {
26
+ const initCondValue = initCondMatch[1].trim();
27
+ const unquoted = initCondValue.replace(/^['"]|['"]$/g, '');
28
+ if (/^\d+$/.test(unquoted)) {
29
+ normalized = normalized.replace(/INITCOND\s*=\s*[^,)]+/i, `INITCOND = '${unquoted}'`);
30
+ }
31
+ }
32
+
33
+ return normalized;
34
+ };
35
+
36
+ const extractFunctionSignature = (definition: string, isAggregate: boolean): string | null => {
37
+ if (isAggregate) {
38
+ const createMatch = definition.match(/CREATE\s+(OR\s+REPLACE\s+)?AGGREGATE\s+([^(]+)\(/i);
39
+ if (!createMatch) {
40
+ return null;
41
+ }
42
+
43
+ const functionNamePart = createMatch[2].trim().replace(/^[^.]+\./, '');
44
+ const argsMatch = definition.match(/CREATE\s+(OR\s+REPLACE\s+)?AGGREGATE\s+[^(]+\(([^)]*)\)/i);
45
+ const args = argsMatch ? argsMatch[2].trim() : '';
46
+
47
+ return `${functionNamePart}(${args})`;
48
+ }
49
+
50
+ const createMatch = definition.match(/CREATE\s+(OR\s+REPLACE\s+)?FUNCTION\s+([^(]+)\(/i);
51
+ if (!createMatch) {
52
+ return null;
53
+ }
54
+
55
+ const functionNamePart = createMatch[2].trim().replace(/^[^.]+\./, '');
56
+ const fullArgsMatch = definition.match(
57
+ /CREATE\s+(OR\s+REPLACE\s+)?FUNCTION\s+[^(]+\(([\s\S]*?)\)\s*(RETURNS|LANGUAGE|AS|STRICT|IMMUTABLE|STABLE|VOLATILE|SECURITY)/i,
58
+ );
59
+
60
+ if (!fullArgsMatch) {
61
+ return null;
62
+ }
63
+
64
+ const argsSection = fullArgsMatch[2].trim();
65
+ const args = argsSection
66
+ .split(/\s*,\s*/)
67
+ .map((arg) => {
68
+ return arg.trim().replace(/\s+/g, ' ');
69
+ })
70
+ .join(', ');
71
+
72
+ return `${functionNamePart}(${args})`;
73
+ };
74
+
75
+ const extractFunctionBody = (definition: string): string => {
76
+ const dollarQuoteMatch = definition.match(/AS\s+\$([^$]*)\$([\s\S]*?)\$\1\$/i);
77
+ if (dollarQuoteMatch) {
78
+ return dollarQuoteMatch[2].trim();
79
+ }
80
+
81
+ const bodyMatch = definition.match(/AS\s+\$\$([\s\S]*?)\$\$/i) || definition.match(/AS\s+['"]([\s\S]*?)['"]/i);
82
+ if (bodyMatch) {
83
+ return bodyMatch[1].trim();
84
+ }
85
+
86
+ return definition;
87
+ };
88
+
89
+ export const parseFunctionsFile = (filePath: string): ParsedFunction[] => {
90
+ if (!existsSync(filePath)) {
91
+ return [];
92
+ }
93
+
94
+ const project = new Project({
95
+ manipulationSettings: {
96
+ indentationText: IndentationText.TwoSpaces,
97
+ },
98
+ });
99
+ const sourceFile = project.addSourceFileAtPath(filePath);
100
+
101
+ try {
102
+ const functionsDeclaration = findDeclarationInFile(sourceFile, 'functions');
103
+ const functionsArray = staticEval(functionsDeclaration, {}) as string[];
104
+
105
+ if (!Array.isArray(functionsArray)) {
106
+ return [];
107
+ }
108
+
109
+ const parsedFunctions: ParsedFunction[] = [];
110
+
111
+ for (const definition of functionsArray) {
112
+ if (!definition || typeof definition !== 'string') {
113
+ continue;
114
+ }
115
+
116
+ const trimmedDefinition = definition.trim();
117
+ const isAggregate = /CREATE\s+(OR\s+REPLACE\s+)?AGGREGATE/i.test(trimmedDefinition);
118
+ const signature = extractFunctionSignature(trimmedDefinition, isAggregate);
119
+
120
+ if (!signature) {
121
+ continue;
122
+ }
123
+
124
+ const nameMatch = signature.match(/^([^(]+)\(/);
125
+ const name = nameMatch ? nameMatch[1].trim().split('.').pop() || '' : '';
126
+ const body = isAggregate ? trimmedDefinition : extractFunctionBody(trimmedDefinition);
127
+
128
+ parsedFunctions.push({
129
+ name,
130
+ signature,
131
+ body: isAggregate ? normalizeAggregateDefinition(body) : normalizeFunctionBody(body),
132
+ fullDefinition: trimmedDefinition,
133
+ isAggregate,
134
+ });
135
+ }
136
+
137
+ return parsedFunctions;
138
+ } catch (error) {
139
+ return [];
140
+ }
141
+ };
@@ -39,6 +39,13 @@ const DEFAULTS = {
39
39
  ensureFileExists(path, EMPTY_MODELS);
40
40
  },
41
41
  },
42
+ functionsPath: {
43
+ question: 'What is the PostgreSQL functions file path?',
44
+ defaultValue: 'src/config/functions.ts',
45
+ init: (path: string) => {
46
+ ensureFileExists(path, `export const functions: string[] = [];\n`);
47
+ },
48
+ },
42
49
  generatedFolderPath: {
43
50
  question: 'What is the path for generated stuff?',
44
51
  defaultValue: 'src/generated',
@@ -9,6 +9,7 @@ export const findDeclarationInFile = (sourceFile: SourceFile, name: string) => {
9
9
  if (!declaration) {
10
10
  throw new Error(`No ${name} declaration`);
11
11
  }
12
+
12
13
  return declaration;
13
14
  };
14
15
 
@@ -0,0 +1,74 @@
1
+ import { Knex } from 'knex';
2
+
3
+ export const generateFunctionsFromDatabase = async (knex: Knex): Promise<string> => {
4
+ const regularFunctions = await knex.raw(`
5
+ SELECT
6
+ pg_get_functiondef(p.oid) as definition
7
+ FROM pg_proc p
8
+ JOIN pg_namespace n ON p.pronamespace = n.oid
9
+ WHERE n.nspname = 'public'
10
+ AND NOT EXISTS (SELECT 1 FROM pg_aggregate a WHERE a.aggfnoid = p.oid)
11
+ ORDER BY p.proname, pg_get_function_identity_arguments(p.oid)
12
+ `);
13
+
14
+ const aggregateFunctions = await knex.raw(`
15
+ SELECT
16
+ p.proname as name,
17
+ pg_get_function_identity_arguments(p.oid) as arguments,
18
+ a.aggtransfn::regproc::text as trans_func,
19
+ a.aggfinalfn::regproc::text as final_func,
20
+ a.agginitval as init_val,
21
+ pg_catalog.format_type(a.aggtranstype, NULL) as state_type
22
+ FROM pg_proc p
23
+ JOIN pg_aggregate a ON p.oid = a.aggfnoid
24
+ JOIN pg_namespace n ON p.pronamespace = n.oid
25
+ WHERE n.nspname = 'public'
26
+ ORDER BY p.proname, pg_get_function_identity_arguments(p.oid)
27
+ `);
28
+
29
+ const functions: string[] = [];
30
+
31
+ for (const row of regularFunctions.rows || []) {
32
+ if (row.definition) {
33
+ functions.push(row.definition.trim());
34
+ }
35
+ }
36
+
37
+ for (const row of aggregateFunctions.rows || []) {
38
+ const name = row.name || '';
39
+ const argumentsStr = row.arguments || '';
40
+ const transFunc = row.trans_func || '';
41
+ const finalFunc = row.final_func || '';
42
+ const initVal = row.init_val;
43
+ const stateType = row.state_type || '';
44
+
45
+ if (!name || !transFunc || !stateType) {
46
+ continue;
47
+ }
48
+
49
+ let aggregateDef = `CREATE AGGREGATE ${name}(${argumentsStr}) (\n`;
50
+ aggregateDef += ` SFUNC = ${transFunc},\n`;
51
+ aggregateDef += ` STYPE = ${stateType}`;
52
+
53
+ if (finalFunc) {
54
+ aggregateDef += `,\n FINALFUNC = ${finalFunc}`;
55
+ }
56
+
57
+ if (initVal !== null && initVal !== undefined) {
58
+ const initValStr = typeof initVal === 'string' ? `'${initVal}'` : String(initVal);
59
+ aggregateDef += `,\n INITCOND = ${initValStr}`;
60
+ }
61
+
62
+ aggregateDef += '\n);';
63
+
64
+ functions.push(aggregateDef);
65
+ }
66
+
67
+ if (functions.length === 0) {
68
+ return `export const functions: string[] = [];\n`;
69
+ }
70
+
71
+ const functionsArrayString = functions.map((func) => ` ${JSON.stringify(func)}`).join(',\n');
72
+
73
+ return `export const functions: string[] = [\n${functionsArrayString},\n];\n`;
74
+ };