@memberjunction/codegen-lib 5.4.1 → 5.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.
Files changed (75) hide show
  1. package/README.md +65 -2
  2. package/dist/Angular/angular-codegen.d.ts.map +1 -1
  3. package/dist/Angular/angular-codegen.js +26 -12
  4. package/dist/Angular/angular-codegen.js.map +1 -1
  5. package/dist/Angular/related-entity-components.js +2 -2
  6. package/dist/Angular/related-entity-components.js.map +1 -1
  7. package/dist/Config/config.d.ts +10 -0
  8. package/dist/Config/config.d.ts.map +1 -1
  9. package/dist/Config/config.js +10 -0
  10. package/dist/Config/config.js.map +1 -1
  11. package/dist/Database/codeGenDatabaseProvider.d.ts +544 -0
  12. package/dist/Database/codeGenDatabaseProvider.d.ts.map +1 -0
  13. package/dist/Database/codeGenDatabaseProvider.js +29 -0
  14. package/dist/Database/codeGenDatabaseProvider.js.map +1 -0
  15. package/dist/Database/manage-metadata.d.ts +165 -60
  16. package/dist/Database/manage-metadata.d.ts.map +1 -1
  17. package/dist/Database/manage-metadata.js +592 -483
  18. package/dist/Database/manage-metadata.js.map +1 -1
  19. package/dist/Database/providers/postgresql/PostgreSQLCodeGenConnection.d.ts +53 -0
  20. package/dist/Database/providers/postgresql/PostgreSQLCodeGenConnection.d.ts.map +1 -0
  21. package/dist/Database/providers/postgresql/PostgreSQLCodeGenConnection.js +112 -0
  22. package/dist/Database/providers/postgresql/PostgreSQLCodeGenConnection.js.map +1 -0
  23. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts +344 -0
  24. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts.map +1 -0
  25. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js +1567 -0
  26. package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js.map +1 -0
  27. package/dist/Database/providers/sqlserver/SQLServerCodeGenConnection.d.ts +42 -0
  28. package/dist/Database/providers/sqlserver/SQLServerCodeGenConnection.d.ts.map +1 -0
  29. package/dist/Database/providers/sqlserver/SQLServerCodeGenConnection.js +84 -0
  30. package/dist/Database/providers/sqlserver/SQLServerCodeGenConnection.js.map +1 -0
  31. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts +372 -0
  32. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts.map +1 -0
  33. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js +1483 -0
  34. package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js.map +1 -0
  35. package/dist/Database/reorder-columns.d.ts +2 -2
  36. package/dist/Database/reorder-columns.d.ts.map +1 -1
  37. package/dist/Database/reorder-columns.js +9 -9
  38. package/dist/Database/reorder-columns.js.map +1 -1
  39. package/dist/Database/sql.d.ts +10 -5
  40. package/dist/Database/sql.d.ts.map +1 -1
  41. package/dist/Database/sql.js +44 -228
  42. package/dist/Database/sql.js.map +1 -1
  43. package/dist/Database/sql_codegen.d.ts +31 -29
  44. package/dist/Database/sql_codegen.d.ts.map +1 -1
  45. package/dist/Database/sql_codegen.js +209 -842
  46. package/dist/Database/sql_codegen.js.map +1 -1
  47. package/dist/Misc/action_subclasses_codegen.js +3 -2
  48. package/dist/Misc/action_subclasses_codegen.js.map +1 -1
  49. package/dist/Misc/entity_subclasses_codegen.d.ts +4 -4
  50. package/dist/Misc/entity_subclasses_codegen.d.ts.map +1 -1
  51. package/dist/Misc/entity_subclasses_codegen.js.map +1 -1
  52. package/dist/Misc/graphql_server_codegen.d.ts +6 -1
  53. package/dist/Misc/graphql_server_codegen.d.ts.map +1 -1
  54. package/dist/Misc/graphql_server_codegen.js +33 -35
  55. package/dist/Misc/graphql_server_codegen.js.map +1 -1
  56. package/dist/Misc/sql_logging.d.ts +2 -2
  57. package/dist/Misc/sql_logging.d.ts.map +1 -1
  58. package/dist/Misc/sql_logging.js +1 -1
  59. package/dist/Misc/sql_logging.js.map +1 -1
  60. package/dist/Misc/system_integrity.d.ts +6 -6
  61. package/dist/Misc/system_integrity.d.ts.map +1 -1
  62. package/dist/Misc/system_integrity.js +33 -8
  63. package/dist/Misc/system_integrity.js.map +1 -1
  64. package/dist/Misc/temp_batch_file.d.ts.map +1 -1
  65. package/dist/Misc/temp_batch_file.js +4 -1
  66. package/dist/Misc/temp_batch_file.js.map +1 -1
  67. package/dist/index.d.ts +5 -0
  68. package/dist/index.d.ts.map +1 -1
  69. package/dist/index.js +5 -0
  70. package/dist/index.js.map +1 -1
  71. package/dist/runCodeGen.d.ts +30 -75
  72. package/dist/runCodeGen.d.ts.map +1 -1
  73. package/dist/runCodeGen.js +123 -215
  74. package/dist/runCodeGen.js.map +1 -1
  75. package/package.json +18 -15
@@ -0,0 +1,1567 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var PostgreSQLCodeGenProvider_1;
8
+ import { EntityInfo } from '@memberjunction/core';
9
+ import { RegisterClass } from '@memberjunction/global';
10
+ import { CodeGenDatabaseProvider, CRUDType, } from '../../codeGenDatabaseProvider.js';
11
+ import { configInfo } from '../../../Config/config.js';
12
+ import { logError, logWarning } from '../../../Misc/status_logging.js';
13
+ import { logIf } from '../../../Misc/util.js';
14
+ import { PostgreSQLDialect } from '@memberjunction/sql-dialect';
15
+ import { spawn } from 'child_process';
16
+ import path from 'path';
17
+ const pgDialect = new PostgreSQLDialect();
18
+ /**
19
+ * PostgreSQL implementation of the CodeGen database provider.
20
+ * Generates PostgreSQL-native DDL for views, CRUD functions, triggers, indexes,
21
+ * full-text search, permissions, and other database objects.
22
+ */
23
+ let PostgreSQLCodeGenProvider = class PostgreSQLCodeGenProvider extends CodeGenDatabaseProvider {
24
+ static { PostgreSQLCodeGenProvider_1 = this; }
25
+ /** @inheritdoc */
26
+ get Dialect() {
27
+ return pgDialect;
28
+ }
29
+ /** @inheritdoc */
30
+ get PlatformKey() {
31
+ return 'postgresql';
32
+ }
33
+ // ─── DROP GUARDS ─────────────────────────────────────────────────────
34
+ /**
35
+ * Generates a PostgreSQL `DROP ... IF EXISTS ... CASCADE` statement as a guard before
36
+ * creating or replacing a database object. For triggers, PostgreSQL relies on
37
+ * `CREATE OR REPLACE` on the trigger function, so a comment is emitted instead.
38
+ */
39
+ generateDropGuard(objectType, schema, name) {
40
+ // PostgreSQL uses CREATE OR REPLACE for views and functions, so we mostly
41
+ // just need DROP IF EXISTS for procedures and triggers
42
+ switch (objectType) {
43
+ case 'VIEW':
44
+ return `DROP VIEW IF EXISTS ${pgDialect.QuoteSchema(schema, name)} CASCADE;`;
45
+ case 'FUNCTION':
46
+ return `DROP FUNCTION IF EXISTS ${pgDialect.QuoteSchema(schema, name)} CASCADE;`;
47
+ case 'PROCEDURE':
48
+ return `DROP PROCEDURE IF EXISTS ${pgDialect.QuoteSchema(schema, name)} CASCADE;`;
49
+ case 'TRIGGER':
50
+ // Triggers need table context, but for the guard alone we use the name
51
+ return `-- Trigger ${name} will be dropped via CREATE OR REPLACE on the function`;
52
+ default:
53
+ return `-- Unknown object type: ${objectType}`;
54
+ }
55
+ }
56
+ // ─── BASE VIEWS ──────────────────────────────────────────────────────
57
+ /**
58
+ * Generates a PostgreSQL `CREATE OR REPLACE VIEW` statement for an entity's base view.
59
+ * Includes all base table columns, parent/related field joins, and root field lateral
60
+ * joins. Applies a soft-delete `WHERE` filter when the entity uses soft deletes.
61
+ */
62
+ generateBaseView(context) {
63
+ const { entity } = context;
64
+ const viewName = this.getBaseViewName(entity);
65
+ const alias = entity.BaseTableCodeName.charAt(0).toLowerCase();
66
+ const whereClause = this.buildSoftDeleteWhereClause(entity, alias);
67
+ const selectParts = this.buildBaseViewSelectParts(context, alias);
68
+ const fromParts = this.buildBaseViewFromParts(context, entity, alias);
69
+ // Permissions are handled separately by sql_codegen.ts via generateViewPermissions()
70
+ return `
71
+ ------------------------------------------------------------
72
+ ----- BASE VIEW FOR ENTITY: ${entity.Name}
73
+ ----- SCHEMA: ${entity.SchemaName}
74
+ ----- BASE TABLE: ${entity.BaseTable}
75
+ ----- PRIMARY KEY: ${entity.PrimaryKeys.map((pk) => pk.Name).join(', ')}
76
+ ------------------------------------------------------------
77
+ CREATE OR REPLACE VIEW ${pgDialect.QuoteSchema(entity.SchemaName, viewName)}
78
+ AS
79
+ SELECT
80
+ ${selectParts}
81
+ FROM
82
+ ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)} AS ${alias}${fromParts}
83
+ ${whereClause};
84
+ `;
85
+ }
86
+ // ─── CRUD CREATE ─────────────────────────────────────────────────────
87
+ /**
88
+ * Generates a PostgreSQL `CREATE OR REPLACE FUNCTION` for inserting a new record.
89
+ * The function accepts typed parameters for each writable field, performs an `INSERT`
90
+ * into the base table, and returns the newly created row from the base view via
91
+ * `RETURN QUERY SELECT`. Handles auto-increment PKs (using `RETURNING ... INTO`),
92
+ * UUID PKs (with `COALESCE` to gen_random_uuid()), and composite PKs. Also emits
93
+ * `GRANT EXECUTE` permissions for authorized roles.
94
+ */
95
+ generateCRUDCreate(entity) {
96
+ const fnName = this.getCRUDRoutineName(entity, CRUDType.Create);
97
+ const viewName = this.getBaseViewName(entity);
98
+ const paramString = this.generateCRUDParamString(entity.Fields, false);
99
+ const permissions = this.generateCRUDPermissions(entity, fnName, CRUDType.Create);
100
+ const insertColumns = this.generateInsertFieldString(entity, entity.Fields, '', false);
101
+ const insertValues = this.generateInsertFieldString(entity, entity.Fields, 'p_', false);
102
+ const firstKey = entity.FirstPrimaryKey;
103
+ const strategy = this.buildCreateInsertStrategy(entity, firstKey, insertColumns, insertValues);
104
+ return `
105
+ ------------------------------------------------------------
106
+ ----- CREATE FUNCTION FOR ${entity.BaseTable}
107
+ ------------------------------------------------------------
108
+ CREATE OR REPLACE FUNCTION ${pgDialect.QuoteSchema(entity.SchemaName, fnName)}(
109
+ ${paramString}
110
+ ) RETURNS SETOF ${pgDialect.QuoteSchema(entity.SchemaName, viewName)} AS $$
111
+ DECLARE
112
+ v_new_id ${firstKey.SQLFullType};
113
+ BEGIN
114
+ ${strategy.preInsert}INSERT INTO ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)}
115
+ (
116
+ ${strategy.finalColumns}
117
+ )
118
+ VALUES
119
+ (
120
+ ${strategy.finalValues}
121
+ )
122
+ ${strategy.returningClause};
123
+
124
+ RETURN QUERY
125
+ ${strategy.selectClause};
126
+ END;
127
+ $$ LANGUAGE plpgsql;
128
+ ${permissions}
129
+ `;
130
+ }
131
+ // ─── CRUD UPDATE ─────────────────────────────────────────────────────
132
+ /**
133
+ * Generates a PostgreSQL `CREATE OR REPLACE FUNCTION` for updating an existing record.
134
+ * The function accepts typed parameters for all updatable fields plus primary key(s),
135
+ * performs an `UPDATE ... SET ... WHERE PK = param`, checks `ROW_COUNT` to detect
136
+ * missing rows, and returns the updated record from the base view via `RETURN QUERY
137
+ * SELECT`. Also generates the `__mj_UpdatedAt` timestamp trigger for the entity
138
+ * and emits `GRANT EXECUTE` permissions.
139
+ */
140
+ generateCRUDUpdate(entity) {
141
+ const fnName = this.getCRUDRoutineName(entity, CRUDType.Update);
142
+ const viewName = this.getBaseViewName(entity);
143
+ const paramString = this.generateCRUDParamString(entity.Fields, true);
144
+ const permissions = this.generateCRUDPermissions(entity, fnName, CRUDType.Update);
145
+ const updateFields = this.generateUpdateFieldString(entity.Fields);
146
+ const whereClause = this.buildPrimaryKeyWhereClause(entity, 'p_');
147
+ const selectWhereClause = this.buildPrimaryKeyWhereClause(entity, 'p_');
148
+ const trigger = this.generateTimestampTrigger(entity);
149
+ return `
150
+ ------------------------------------------------------------
151
+ ----- UPDATE FUNCTION FOR ${entity.BaseTable}
152
+ ------------------------------------------------------------
153
+ CREATE OR REPLACE FUNCTION ${pgDialect.QuoteSchema(entity.SchemaName, fnName)}(
154
+ ${paramString}
155
+ ) RETURNS SETOF ${pgDialect.QuoteSchema(entity.SchemaName, viewName)} AS $$
156
+ DECLARE
157
+ v_updated_count INTEGER;
158
+ BEGIN
159
+ UPDATE ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)}
160
+ SET
161
+ ${updateFields}
162
+ WHERE
163
+ ${whereClause};
164
+
165
+ GET DIAGNOSTICS v_updated_count = ROW_COUNT;
166
+
167
+ IF v_updated_count = 0 THEN
168
+ -- Nothing was updated, return empty result set
169
+ RETURN;
170
+ END IF;
171
+
172
+ -- Return the updated record from the base view
173
+ RETURN QUERY
174
+ SELECT * FROM ${pgDialect.QuoteSchema(entity.SchemaName, viewName)}
175
+ WHERE ${selectWhereClause};
176
+ END;
177
+ $$ LANGUAGE plpgsql;
178
+ ${permissions}
179
+
180
+ ${trigger}
181
+ `;
182
+ }
183
+ // ─── CRUD DELETE ─────────────────────────────────────────────────────
184
+ /**
185
+ * Generates a PostgreSQL `CREATE OR REPLACE FUNCTION` for deleting a record.
186
+ * Supports both hard deletes (`DELETE FROM`) and soft deletes (`UPDATE ... SET
187
+ * __mj_DeletedAt`). Prepends any cascade SQL for dependent records, uses
188
+ * `#variable_conflict use_column` to avoid PL/pgSQL naming conflicts, and returns
189
+ * the affected primary key(s) or NULLs if no row was found. Emits `GRANT EXECUTE`
190
+ * permissions for authorized roles.
191
+ */
192
+ generateCRUDDelete(entity, cascadeSQL) {
193
+ const fnName = this.getCRUDRoutineName(entity, CRUDType.Delete);
194
+ const permissions = this.generateCRUDPermissions(entity, fnName, CRUDType.Delete);
195
+ const { paramDecl, deleteBody, returnType, returnStatement } = this.buildDeleteStrategy(entity, cascadeSQL);
196
+ return `
197
+ ------------------------------------------------------------
198
+ ----- DELETE FUNCTION FOR ${entity.BaseTable}
199
+ ------------------------------------------------------------
200
+ CREATE OR REPLACE FUNCTION ${pgDialect.QuoteSchema(entity.SchemaName, fnName)}(
201
+ ${paramDecl}
202
+ ) RETURNS ${returnType} AS $$
203
+ #variable_conflict use_column
204
+ DECLARE
205
+ v_affected_count INTEGER;
206
+ BEGIN
207
+ ${cascadeSQL}
208
+ ${deleteBody}
209
+
210
+ GET DIAGNOSTICS v_affected_count = ROW_COUNT;
211
+
212
+ ${returnStatement}
213
+ END;
214
+ $$ LANGUAGE plpgsql;
215
+ ${permissions}
216
+ `;
217
+ }
218
+ // ─── TIMESTAMP TRIGGER ───────────────────────────────────────────────
219
+ /**
220
+ * Generates a PL/pgSQL trigger function and a `BEFORE UPDATE` trigger that
221
+ * automatically sets the `__mj_UpdatedAt` column to the current UTC time on every
222
+ * row update. Uses `CREATE OR REPLACE FUNCTION` for the trigger function and
223
+ * `DROP TRIGGER IF EXISTS` + `CREATE TRIGGER` for idempotent trigger creation.
224
+ * Returns an empty string if the entity has no `__mj_UpdatedAt` field.
225
+ */
226
+ generateTimestampTrigger(entity) {
227
+ const updatedAtField = entity.Fields.find((f) => f.Name.toLowerCase().trim() === EntityInfo.UpdatedAtFieldName.toLowerCase().trim());
228
+ if (!updatedAtField)
229
+ return '';
230
+ const trigFnName = `fn_trg_update_${this.toSnakeCase(entity.BaseTableCodeName)}`;
231
+ const trigName = `trg_update_${this.toSnakeCase(entity.BaseTableCodeName)}`;
232
+ return `
233
+ ------------------------------------------------------------
234
+ ----- TRIGGER FOR ${EntityInfo.UpdatedAtFieldName} field for the ${entity.BaseTable} table
235
+ ------------------------------------------------------------
236
+ CREATE OR REPLACE FUNCTION ${pgDialect.QuoteSchema(entity.SchemaName, trigFnName)}()
237
+ RETURNS TRIGGER AS $$
238
+ BEGIN
239
+ NEW.${EntityInfo.UpdatedAtFieldName} := NOW() AT TIME ZONE 'UTC';
240
+ RETURN NEW;
241
+ END;
242
+ $$ LANGUAGE plpgsql;
243
+
244
+ DROP TRIGGER IF EXISTS ${pgDialect.QuoteIdentifier(trigName)} ON ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)};
245
+
246
+ CREATE TRIGGER ${pgDialect.QuoteIdentifier(trigName)}
247
+ BEFORE UPDATE ON ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)}
248
+ FOR EACH ROW
249
+ EXECUTE FUNCTION ${pgDialect.QuoteSchema(entity.SchemaName, trigFnName)}();
250
+ `;
251
+ }
252
+ // ─── INDEXES ─────────────────────────────────────────────────────────
253
+ /**
254
+ * Generates `CREATE INDEX IF NOT EXISTS` statements for each foreign key column
255
+ * on the entity's base table. Index names follow the `idx_auto_mj_fkey_<table>_<column>`
256
+ * convention and are truncated to 63 characters (PostgreSQL's maximum identifier length).
257
+ * Skips primary key columns and virtual fields.
258
+ */
259
+ generateForeignKeyIndexes(entity) {
260
+ const indexes = [];
261
+ for (const field of entity.Fields) {
262
+ if (field.RelatedEntityID && !field.IsPrimaryKey && !field.IsVirtual) {
263
+ const indexName = `idx_auto_mj_fkey_${this.toSnakeCase(entity.BaseTable)}_${this.toSnakeCase(field.Name)}`;
264
+ // Truncate to 63 chars (PG max identifier length)
265
+ const truncatedName = indexName.length > 63 ? indexName.substring(0, 63) : indexName;
266
+ indexes.push(`CREATE INDEX IF NOT EXISTS ${pgDialect.QuoteIdentifier(truncatedName)}\n` +
267
+ ` ON ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)} (${pgDialect.QuoteIdentifier(field.Name)});`);
268
+ }
269
+ }
270
+ return indexes;
271
+ }
272
+ // ─── FULL-TEXT SEARCH ────────────────────────────────────────────────
273
+ /**
274
+ * Generates a complete PostgreSQL full-text search infrastructure for an entity.
275
+ * This includes:
276
+ * 1. A `tsvector` column (`__mj_fts_vector`) added via conditional `ALTER TABLE`
277
+ * 2. A PL/pgSQL trigger function that concatenates search fields into a `tsvector`
278
+ * 3. A `BEFORE INSERT OR UPDATE` trigger to keep the vector column in sync
279
+ * 4. A GIN index on the `tsvector` column for fast lookups
280
+ * 5. A SQL `STABLE` search function that joins the base view with a `plainto_tsquery` match
281
+ * 6. A backfill `UPDATE` to populate existing rows where the vector is NULL
282
+ *
283
+ * @returns A {@link FullTextSearchResult} with the generated SQL and the search function name.
284
+ */
285
+ generateFullTextSearch(entity, searchFields, _primaryKeyIndexName) {
286
+ const ftsColName = '__mj_fts_vector';
287
+ const trigName = `trg_fts_${this.toSnakeCase(entity.BaseTable)}`;
288
+ const indexName = `idx_fts_${this.toSnakeCase(entity.BaseTable)}`;
289
+ const fnName = `fn_search_${this.toSnakeCase(entity.BaseTable)}`;
290
+ const viewName = this.getBaseViewName(entity);
291
+ const fieldNames = searchFields.map((f) => pgDialect.QuoteIdentifier(f.Name));
292
+ const fieldList = fieldNames.join(', ');
293
+ const sql = `
294
+ ------------------------------------------------------------
295
+ ----- FULL-TEXT SEARCH FOR ${entity.BaseTable}
296
+ ------------------------------------------------------------
297
+ -- Add tsvector column if it doesn't exist
298
+ DO $$
299
+ BEGIN
300
+ IF NOT EXISTS (
301
+ SELECT 1 FROM information_schema.columns
302
+ WHERE table_schema = '${entity.SchemaName}'
303
+ AND table_name = '${entity.BaseTable}'
304
+ AND column_name = '${ftsColName}'
305
+ ) THEN
306
+ ALTER TABLE ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)}
307
+ ADD COLUMN ${ftsColName} TSVECTOR;
308
+ END IF;
309
+ END $$;
310
+
311
+ -- Create trigger to keep tsvector updated
312
+ CREATE OR REPLACE FUNCTION ${pgDialect.QuoteSchema(entity.SchemaName, `fn_${trigName}`)}()
313
+ RETURNS TRIGGER AS $$
314
+ BEGIN
315
+ NEW.${ftsColName} := to_tsvector('english', ${fieldNames.map((n) => `COALESCE(NEW.${n}::TEXT, '')`).join(" || ' ' || ")});
316
+ RETURN NEW;
317
+ END;
318
+ $$ LANGUAGE plpgsql;
319
+
320
+ DROP TRIGGER IF EXISTS ${pgDialect.QuoteIdentifier(trigName)} ON ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)};
321
+
322
+ CREATE TRIGGER ${pgDialect.QuoteIdentifier(trigName)}
323
+ BEFORE INSERT OR UPDATE OF ${fieldList}
324
+ ON ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)}
325
+ FOR EACH ROW
326
+ EXECUTE FUNCTION ${pgDialect.QuoteSchema(entity.SchemaName, `fn_${trigName}`)}();
327
+
328
+ -- Create GIN index for fast full-text search
329
+ CREATE INDEX IF NOT EXISTS ${pgDialect.QuoteIdentifier(indexName)}
330
+ ON ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)} USING GIN(${ftsColName});
331
+
332
+ -- Create search function
333
+ CREATE OR REPLACE FUNCTION ${pgDialect.QuoteSchema(entity.SchemaName, fnName)}(
334
+ p_search_term TEXT
335
+ ) RETURNS SETOF ${pgDialect.QuoteSchema(entity.SchemaName, viewName)} AS $$
336
+ SELECT v.*
337
+ FROM ${pgDialect.QuoteSchema(entity.SchemaName, viewName)} v
338
+ JOIN ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)} t
339
+ ON ${entity.PrimaryKeys.map((pk) => `v.${pgDialect.QuoteIdentifier(pk.Name)} = t.${pgDialect.QuoteIdentifier(pk.Name)}`).join(' AND ')}
340
+ WHERE t.${ftsColName} @@ plainto_tsquery('english', p_search_term);
341
+ $$ LANGUAGE sql STABLE;
342
+
343
+ -- Backfill existing rows
344
+ UPDATE ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)}
345
+ SET ${ftsColName} = to_tsvector('english', ${fieldNames.map((n) => `COALESCE(${n}::TEXT, '')`).join(" || ' ' || ")})
346
+ WHERE ${ftsColName} IS NULL;
347
+ `;
348
+ return { sql, functionName: fnName };
349
+ }
350
+ // ─── RECURSIVE ROOT ID FUNCTIONS ─────────────────────────────────────
351
+ /**
352
+ * Generates a PostgreSQL SQL `STABLE` function that walks a self-referencing hierarchy
353
+ * (e.g., ParentCategoryID) using a recursive CTE to find the root ancestor record.
354
+ * The CTE starts from `COALESCE(p_parent_id, p_record_id)` as the anchor and follows
355
+ * the parent FK upward, capped at 100 levels to prevent infinite loops. Returns the
356
+ * root record's primary key value.
357
+ */
358
+ generateRootIDFunction(entity, field) {
359
+ const primaryKey = entity.FirstPrimaryKey.Name;
360
+ const primaryKeyType = this.mapSQLType(entity.FirstPrimaryKey.SQLFullType);
361
+ const fieldName = field.Name;
362
+ const fnName = `fn_${this.toSnakeCase(entity.BaseTable)}_${this.toSnakeCase(fieldName)}_get_root_id`;
363
+ return `
364
+ ------------------------------------------------------------
365
+ ----- ROOT ID FUNCTION FOR: ${entity.BaseTable}.${fieldName}
366
+ ------------------------------------------------------------
367
+ CREATE OR REPLACE FUNCTION ${pgDialect.QuoteSchema(entity.SchemaName, fnName)}(
368
+ p_record_id ${primaryKeyType},
369
+ p_parent_id ${primaryKeyType}
370
+ ) RETURNS ${primaryKeyType} AS $$
371
+ WITH RECURSIVE cte_root_parent AS (
372
+ -- Anchor: Start from p_parent_id if not null, otherwise start from p_record_id
373
+ SELECT
374
+ ${pgDialect.QuoteIdentifier(primaryKey)},
375
+ ${pgDialect.QuoteIdentifier(fieldName)},
376
+ ${pgDialect.QuoteIdentifier(primaryKey)} AS root_parent_id,
377
+ 0 AS depth
378
+ FROM
379
+ ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)}
380
+ WHERE
381
+ ${pgDialect.QuoteIdentifier(primaryKey)} = COALESCE(p_parent_id, p_record_id)
382
+
383
+ UNION ALL
384
+
385
+ -- Recursive: Keep going up the hierarchy
386
+ SELECT
387
+ c.${pgDialect.QuoteIdentifier(primaryKey)},
388
+ c.${pgDialect.QuoteIdentifier(fieldName)},
389
+ c.${pgDialect.QuoteIdentifier(primaryKey)} AS root_parent_id,
390
+ p.depth + 1 AS depth
391
+ FROM
392
+ ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)} c
393
+ INNER JOIN
394
+ cte_root_parent p ON c.${pgDialect.QuoteIdentifier(primaryKey)} = p.${pgDialect.QuoteIdentifier(fieldName)}
395
+ WHERE
396
+ p.depth < 100 -- Prevent infinite loops
397
+ )
398
+ SELECT root_parent_id
399
+ FROM cte_root_parent
400
+ WHERE ${pgDialect.QuoteIdentifier(fieldName)} IS NULL
401
+ ORDER BY root_parent_id
402
+ LIMIT 1;
403
+ $$ LANGUAGE sql STABLE;
404
+ `;
405
+ }
406
+ /** @inheritdoc */
407
+ generateRootFieldSelect(_entity, field, alias) {
408
+ const rootFieldName = `Root${field.Name}`;
409
+ return `${alias}.root_id AS ${pgDialect.QuoteIdentifier(rootFieldName)}`;
410
+ }
411
+ /**
412
+ * Generates a `LEFT JOIN LATERAL` clause that invokes the root ID function for a
413
+ * self-referencing field. PostgreSQL uses `LATERAL` joins (rather than SQL Server's
414
+ * `OUTER APPLY`) to call scalar functions inline within a view definition.
415
+ */
416
+ generateRootFieldJoin(entity, field, alias) {
417
+ const fnName = `fn_${this.toSnakeCase(entity.BaseTable)}_${this.toSnakeCase(field.Name)}_get_root_id`;
418
+ const tableAlias = entity.BaseTableCodeName.charAt(0).toLowerCase();
419
+ return `LEFT JOIN LATERAL (
420
+ SELECT ${pgDialect.QuoteSchema(entity.SchemaName, fnName)}(${tableAlias}.${pgDialect.QuoteIdentifier(entity.FirstPrimaryKey.Name)}, ${tableAlias}.${pgDialect.QuoteIdentifier(field.Name)}) AS root_id
421
+ ) AS ${alias} ON true`;
422
+ }
423
+ // ─── PERMISSIONS ─────────────────────────────────────────────────────
424
+ /** @inheritdoc */
425
+ generateViewPermissions(entity) {
426
+ const viewName = this.getBaseViewName(entity);
427
+ const roles = this.collectPermissionRoles(entity.Permissions);
428
+ if (roles.length === 0)
429
+ return '';
430
+ return roles.map((role) => `GRANT SELECT ON ${pgDialect.QuoteSchema(entity.SchemaName, viewName)} TO ${pgDialect.QuoteIdentifier(role)};`).join('\n');
431
+ }
432
+ /**
433
+ * Generates `GRANT EXECUTE ON FUNCTION` statements for the given CRUD function,
434
+ * granting access to each role that has the corresponding permission (Create, Update,
435
+ * or Delete) on the entity.
436
+ */
437
+ generateCRUDPermissions(entity, routineName, type) {
438
+ const roles = [];
439
+ for (const ep of entity.Permissions) {
440
+ if (!ep.RoleSQLName || ep.RoleSQLName.length === 0)
441
+ continue;
442
+ if ((type === CRUDType.Create && ep.CanCreate) ||
443
+ (type === CRUDType.Update && ep.CanUpdate) ||
444
+ (type === CRUDType.Delete && ep.CanDelete)) {
445
+ roles.push(ep.RoleSQLName);
446
+ }
447
+ }
448
+ if (roles.length === 0)
449
+ return '';
450
+ return roles.map((role) => `GRANT EXECUTE ON FUNCTION ${pgDialect.QuoteSchema(entity.SchemaName, routineName)} TO ${pgDialect.QuoteIdentifier(role)};`).join('\n');
451
+ }
452
+ /** @inheritdoc */
453
+ generateFullTextSearchPermissions(entity, functionName) {
454
+ const roles = this.collectPermissionRoles(entity.Permissions);
455
+ if (roles.length === 0)
456
+ return '';
457
+ return roles.map((role) => `GRANT EXECUTE ON FUNCTION ${pgDialect.QuoteSchema(entity.SchemaName, functionName)} TO ${pgDialect.QuoteIdentifier(role)};`).join('\n');
458
+ }
459
+ // ─── CASCADE DELETES ─────────────────────────────────────────────────
460
+ /** @inheritdoc */
461
+ generateSingleCascadeOperation(context) {
462
+ const { parentEntity, relatedEntity, fkField, operation } = context;
463
+ // Use the operation type from the orchestrator's decision
464
+ if (operation === 'update') {
465
+ return this.generateCascadeUpdateToNull(parentEntity, relatedEntity, fkField);
466
+ }
467
+ return this.generateCascadeCursorDelete(parentEntity, relatedEntity, fkField);
468
+ }
469
+ // ─── TIMESTAMP COLUMNS ───────────────────────────────────────────────
470
+ /**
471
+ * Generates a PL/pgSQL `DO $$` block that conditionally adds `__mj_CreatedAt` and
472
+ * `__mj_UpdatedAt` columns to a table using `TIMESTAMPTZ` type with a UTC default.
473
+ * Uses `information_schema` checks to skip columns that already exist.
474
+ */
475
+ generateTimestampColumns(schema, tableName) {
476
+ return `
477
+ -- Add timestamp columns to ${tableName}
478
+ DO $$
479
+ BEGIN
480
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = '${schema}' AND table_name = '${tableName}' AND column_name = '__mj_CreatedAt') THEN
481
+ ALTER TABLE ${pgDialect.QuoteSchema(schema, tableName)}
482
+ ADD COLUMN __mj_CreatedAt TIMESTAMPTZ NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC');
483
+ END IF;
484
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = '${schema}' AND table_name = '${tableName}' AND column_name = '__mj_UpdatedAt') THEN
485
+ ALTER TABLE ${pgDialect.QuoteSchema(schema, tableName)}
486
+ ADD COLUMN __mj_UpdatedAt TIMESTAMPTZ NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC');
487
+ END IF;
488
+ END $$;
489
+ `;
490
+ }
491
+ // ─── PARAMETER / FIELD HELPERS ───────────────────────────────────────
492
+ /**
493
+ * Builds the parameter declaration list for a PostgreSQL CRUD function signature.
494
+ * Each parameter is prefixed with `p_` and uses the PostgreSQL-mapped type. For
495
+ * CREATE functions, parameters with default values or primary keys get `DEFAULT NULL`
496
+ * to allow optional arguments, and PostgreSQL's requirement that all subsequent
497
+ * parameters also have defaults once the first default appears is respected.
498
+ */
499
+ generateCRUDParamString(entityFields, isUpdate) {
500
+ const parts = [];
501
+ let foundDefault = false;
502
+ for (const ef of entityFields) {
503
+ if (!this.shouldIncludeFieldInParams(ef, isUpdate))
504
+ continue;
505
+ const paramName = `p_${this.toSnakeCase(ef.CodeName)}`;
506
+ const sqlType = this.mapSQLType(ef.SQLFullType);
507
+ let defaultVal = '';
508
+ if (!isUpdate && ef.IsPrimaryKey && !ef.AutoIncrement) {
509
+ defaultVal = ' DEFAULT NULL';
510
+ foundDefault = true;
511
+ }
512
+ else if (!isUpdate && ef.HasDefaultValue && !ef.AllowsNull) {
513
+ defaultVal = ' DEFAULT NULL';
514
+ foundDefault = true;
515
+ }
516
+ else if (!isUpdate && foundDefault) {
517
+ // PG requires all params after the first DEFAULT to also have DEFAULTs
518
+ defaultVal = ' DEFAULT NULL';
519
+ }
520
+ parts.push(`${paramName} ${sqlType}${defaultVal}`);
521
+ }
522
+ return parts.join(',\n ');
523
+ }
524
+ /**
525
+ * Builds either the column name list or the value expression list for an INSERT
526
+ * statement, depending on whether {@link prefix} is empty (column names) or set
527
+ * (parameter values with `p_` prefix). Handles special date fields
528
+ * (`__mj_CreatedAt`, `__mj_UpdatedAt`) by substituting `NOW() AT TIME ZONE 'UTC'`
529
+ * and applies default-value COALESCE wrappers for fields with non-null defaults.
530
+ */
531
+ generateInsertFieldString(entity, entityFields, prefix, excludePrimaryKey = false) {
532
+ const autoGeneratedPrimaryKey = entity.FirstPrimaryKey.AutoIncrement;
533
+ const parts = [];
534
+ for (const ef of entityFields) {
535
+ if (this.shouldSkipFieldForInsert(ef, autoGeneratedPrimaryKey, excludePrimaryKey))
536
+ continue;
537
+ if (prefix !== '' && ef.IsSpecialDateField) {
538
+ parts.push(this.getSpecialDateInsertValue(ef));
539
+ }
540
+ else if (prefix === '') {
541
+ // Column name side
542
+ parts.push(pgDialect.QuoteIdentifier(ef.Name));
543
+ }
544
+ else {
545
+ // Parameter value side
546
+ parts.push(this.getParameterInsertValue(ef, prefix));
547
+ }
548
+ }
549
+ return parts.join(',\n ');
550
+ }
551
+ /** @inheritdoc */
552
+ generateUpdateFieldString(entityFields) {
553
+ const parts = [];
554
+ for (const ef of entityFields) {
555
+ if (ef.IsPrimaryKey || ef.IsVirtual || !ef.AllowUpdateAPI || ef.AutoIncrement || ef.IsSpecialDateField)
556
+ continue;
557
+ parts.push(`${pgDialect.QuoteIdentifier(ef.Name)} = p_${this.toSnakeCase(ef.CodeName)}`);
558
+ }
559
+ return parts.join(',\n ');
560
+ }
561
+ // ─── ROUTINE NAMING ──────────────────────────────────────────────────
562
+ /** @inheritdoc */
563
+ getCRUDRoutineName(entity, type) {
564
+ const snakeTable = this.toSnakeCase(entity.BaseTableCodeName);
565
+ switch (type) {
566
+ case CRUDType.Create:
567
+ return entity.spCreate || `fn_create_${snakeTable}`;
568
+ case CRUDType.Update:
569
+ return entity.spUpdate || `fn_update_${snakeTable}`;
570
+ case CRUDType.Delete:
571
+ return entity.spDelete || `fn_delete_${snakeTable}`;
572
+ }
573
+ }
574
+ // ─── SQL HEADERS ─────────────────────────────────────────────────────
575
+ /** @inheritdoc */
576
+ generateSQLFileHeader(entity, itemName) {
577
+ return `-- ============================================================
578
+ -- PostgreSQL Generated SQL for Entity: ${entity.Name}
579
+ -- Item: ${itemName}
580
+ -- Generated at: ${new Date().toISOString()}
581
+ -- ============================================================
582
+ `;
583
+ }
584
+ /** @inheritdoc */
585
+ generateAllEntitiesSQLFileHeader() {
586
+ return `-- ============================================================
587
+ -- PostgreSQL Generated SQL for All Entities
588
+ -- Generated at: ${new Date().toISOString()}
589
+ -- ============================================================
590
+ `;
591
+ }
592
+ // ─── UTILITY ─────────────────────────────────────────────────────────
593
+ /**
594
+ * Maps a SQL default value expression to its PostgreSQL equivalent. Translates
595
+ * SQL Server built-in functions (e.g., `NEWID()` to `gen_random_uuid()`,
596
+ * `GETUTCDATE()` to `NOW() AT TIME ZONE 'UTC'`), strips outer parentheses and
597
+ * surrounding single quotes, and re-applies quoting based on the {@link needsQuotes}
598
+ * flag. Returns `'NULL'` for empty or whitespace-only input.
599
+ */
600
+ formatDefaultValue(defaultValue, needsQuotes) {
601
+ if (!defaultValue || defaultValue.trim().length === 0)
602
+ return 'NULL';
603
+ let trimmedValue = defaultValue.trim();
604
+ const lowerValue = trimmedValue.toLowerCase();
605
+ // Map SQL Server and PostgreSQL functions to canonical PostgreSQL equivalents
606
+ const functionMap = {
607
+ 'newid()': 'gen_random_uuid()',
608
+ 'newsequentialid()': 'gen_random_uuid()',
609
+ 'gen_random_uuid()': 'gen_random_uuid()',
610
+ 'getdate()': "NOW() AT TIME ZONE 'UTC'",
611
+ 'getutcdate()': "NOW() AT TIME ZONE 'UTC'",
612
+ 'sysdatetime()': "NOW() AT TIME ZONE 'UTC'",
613
+ 'sysdatetimeoffset()': "NOW() AT TIME ZONE 'UTC'",
614
+ 'now()': 'NOW()',
615
+ 'current_timestamp': 'CURRENT_TIMESTAMP',
616
+ 'user_name()': 'CURRENT_USER',
617
+ 'suser_name()': 'SESSION_USER',
618
+ 'system_user': 'CURRENT_USER',
619
+ };
620
+ for (const [sqlFunc, pgFunc] of Object.entries(functionMap)) {
621
+ if (lowerValue.includes(sqlFunc)) {
622
+ return pgFunc;
623
+ }
624
+ }
625
+ // Remove outer parentheses if present
626
+ if (trimmedValue.startsWith('(') && trimmedValue.endsWith(')')) {
627
+ trimmedValue = trimmedValue.substring(1, trimmedValue.length - 1);
628
+ }
629
+ // Remove surrounding quotes for clean value
630
+ let cleanValue = trimmedValue;
631
+ if (cleanValue.startsWith("'") && cleanValue.endsWith("'")) {
632
+ cleanValue = cleanValue.substring(1, cleanValue.length - 1);
633
+ }
634
+ if (needsQuotes)
635
+ return `'${cleanValue}'`;
636
+ return cleanValue;
637
+ }
638
+ /**
639
+ * Builds a set of PL/pgSQL components for working with an entity's primary key(s)
640
+ * in cascade operations: variable declarations, SELECT field list, FETCH INTO
641
+ * variable list, and named routine parameter assignments. Used by cascade delete
642
+ * and update-to-NULL generators to construct cursor-based loops.
643
+ */
644
+ buildPrimaryKeyComponents(entity, prefix) {
645
+ const varPrefix = prefix || 'v_related_';
646
+ const varDecls = [];
647
+ const selectFlds = [];
648
+ const fetchVars = [];
649
+ const routineParamParts = [];
650
+ for (const pk of entity.PrimaryKeys) {
651
+ const varName = `${varPrefix}${this.toSnakeCase(pk.CodeName)}`;
652
+ const sqlType = this.mapSQLType(pk.SQLFullType);
653
+ varDecls.push(`${varName} ${sqlType}`);
654
+ selectFlds.push(pgDialect.QuoteIdentifier(pk.Name));
655
+ fetchVars.push(varName);
656
+ routineParamParts.push(`p_${this.toSnakeCase(pk.CodeName)} := ${varName}`);
657
+ }
658
+ return {
659
+ varDeclarations: varDecls.join(', '),
660
+ selectFields: selectFlds.join(', '),
661
+ fetchInto: fetchVars.join(', '),
662
+ routineParams: routineParamParts.join(', '),
663
+ };
664
+ }
665
+ // ─── METADATA MANAGEMENT: STORED PROCEDURE CALLS ─────────────────
666
+ /** @inheritdoc */
667
+ callRoutineSQL(schema, routineName, params, _paramNames) {
668
+ const qualifiedName = pgDialect.QuoteSchema(schema, routineName);
669
+ const paramList = params.join(', ');
670
+ return `SELECT * FROM ${qualifiedName}(${paramList})`;
671
+ }
672
+ // ─── METADATA MANAGEMENT: CONDITIONAL INSERT ─────────────────────
673
+ /** @inheritdoc */
674
+ conditionalInsertSQL(checkQuery, insertSQL) {
675
+ return `DO $$ BEGIN\n IF NOT EXISTS (${checkQuery}) THEN\n ${insertSQL};\n END IF;\nEND $$`;
676
+ }
677
+ /** @inheritdoc */
678
+ wrapInsertWithConflictGuard(_conflictCheckSQL) {
679
+ return { prefix: '', suffix: 'ON CONFLICT DO NOTHING' };
680
+ }
681
+ // ─── METADATA MANAGEMENT: DDL OPERATIONS ─────────────────────────
682
+ /** @inheritdoc */
683
+ addColumnSQL(schema, tableName, columnName, dataType, nullable, defaultExpression) {
684
+ const table = pgDialect.QuoteSchema(schema, tableName);
685
+ const col = pgDialect.QuoteIdentifier(columnName);
686
+ const nullClause = nullable ? 'NULL' : 'NOT NULL';
687
+ const defaultClause = defaultExpression ? ` DEFAULT ${defaultExpression}` : '';
688
+ return `ALTER TABLE ${table} ADD COLUMN ${col} ${dataType} ${nullClause}${defaultClause}`;
689
+ }
690
+ /** @inheritdoc */
691
+ alterColumnTypeAndNullabilitySQL(schema, tableName, columnName, dataType, nullable) {
692
+ const table = pgDialect.QuoteSchema(schema, tableName);
693
+ const col = pgDialect.QuoteIdentifier(columnName);
694
+ const nullAction = nullable ? 'DROP NOT NULL' : 'SET NOT NULL';
695
+ return `ALTER TABLE ${table} ALTER COLUMN ${col} TYPE ${dataType}, ALTER COLUMN ${col} ${nullAction}`;
696
+ }
697
+ /** @inheritdoc */
698
+ addDefaultConstraintSQL(schema, tableName, columnName, defaultExpression) {
699
+ const table = pgDialect.QuoteSchema(schema, tableName);
700
+ const col = pgDialect.QuoteIdentifier(columnName);
701
+ return `ALTER TABLE ${table} ALTER COLUMN ${col} SET DEFAULT ${defaultExpression}`;
702
+ }
703
+ /**
704
+ * Generates a PL/pgSQL `DO $$` block that drops both a named CHECK constraint (if one
705
+ * exists on the column, found via `pg_catalog.pg_constraint`) and the column's default
706
+ * value. Uses dynamic SQL (`EXECUTE format(...)`) to drop the constraint by name,
707
+ * then unconditionally runs `ALTER COLUMN ... DROP DEFAULT`.
708
+ */
709
+ dropDefaultConstraintSQL(schema, tableName, columnName) {
710
+ const table = pgDialect.QuoteSchema(schema, tableName);
711
+ const col = pgDialect.QuoteIdentifier(columnName);
712
+ return `
713
+ DO $$
714
+ DECLARE
715
+ v_constraint_name TEXT;
716
+ BEGIN
717
+ SELECT con.conname INTO v_constraint_name
718
+ FROM pg_catalog.pg_constraint con
719
+ JOIN pg_catalog.pg_class rel ON rel.oid = con.conrelid
720
+ JOIN pg_catalog.pg_namespace nsp ON nsp.oid = rel.relnamespace
721
+ JOIN pg_catalog.pg_attribute att ON att.attrelid = rel.oid AND att.attnum = ANY(con.conkey)
722
+ WHERE nsp.nspname = '${schema}'
723
+ AND rel.relname = '${tableName}'
724
+ AND att.attname = '${columnName}'
725
+ AND con.contype = 'c';
726
+
727
+ IF v_constraint_name IS NOT NULL THEN
728
+ EXECUTE format('ALTER TABLE %I.%I DROP CONSTRAINT %I', '${schema}', '${tableName}', v_constraint_name);
729
+ END IF;
730
+
731
+ -- Also drop any column default
732
+ ALTER TABLE ${table} ALTER COLUMN ${col} DROP DEFAULT;
733
+ END $$`;
734
+ }
735
+ /** @inheritdoc */
736
+ dropObjectSQL(objectType, schema, name) {
737
+ // PostgreSQL uses FUNCTION for both procedures and functions in DROP statements
738
+ const typeStr = objectType === 'PROCEDURE' ? 'FUNCTION' : objectType;
739
+ const qualifiedName = pgDialect.QuoteSchema(schema, name);
740
+ const cascade = (objectType === 'PROCEDURE' || objectType === 'FUNCTION') ? ' CASCADE' : '';
741
+ return `DROP ${typeStr} IF EXISTS ${qualifiedName}${cascade}`;
742
+ }
743
+ // ─── METADATA MANAGEMENT: VIEW INTROSPECTION ─────────────────────
744
+ /** @inheritdoc */
745
+ getViewExistsSQL() {
746
+ return `SELECT 1 FROM information_schema.views WHERE table_name = @ViewName AND table_schema = @SchemaName`;
747
+ }
748
+ /** @inheritdoc */
749
+ getViewColumnsSQL(schema, viewName) {
750
+ return `SELECT
751
+ column_name AS "FieldName",
752
+ data_type AS "Type",
753
+ COALESCE(character_maximum_length, 0) AS "Length",
754
+ COALESCE(numeric_precision, 0) AS "Precision",
755
+ COALESCE(numeric_scale, 0) AS "Scale",
756
+ CASE WHEN is_nullable = 'YES' THEN 1 ELSE 0 END AS "AllowsNull"
757
+ FROM information_schema.columns
758
+ WHERE table_schema = '${schema}'
759
+ AND table_name = '${viewName}'
760
+ ORDER BY ordinal_position`;
761
+ }
762
+ // ─── METADATA MANAGEMENT: TYPE SYSTEM ────────────────────────────
763
+ /** @inheritdoc */
764
+ get TimestampType() {
765
+ return 'TIMESTAMPTZ';
766
+ }
767
+ /**
768
+ * Compares two PostgreSQL data type strings for equivalence, accounting for common
769
+ * aliases. For example, `'timestamptz'` and `'timestamp with time zone'` are
770
+ * considered equal. Returns `true` if the types match directly or via alias lookup.
771
+ */
772
+ compareDataTypes(reported, expected) {
773
+ if (reported === expected)
774
+ return true;
775
+ const aliases = {
776
+ 'timestamptz': 'timestamp with time zone',
777
+ 'timestamp with time zone': 'timestamptz',
778
+ };
779
+ return aliases[reported] === expected;
780
+ }
781
+ // ─── METADATA MANAGEMENT: PLATFORM CONFIGURATION ─────────────────
782
+ /** @inheritdoc */
783
+ getSystemSchemasToExclude() {
784
+ return ['information_schema', 'pg_catalog', 'pg_toast', 'pg_temp_1', 'pg_toast_temp_1'];
785
+ }
786
+ /**
787
+ * PostgreSQL does not require view refresh after creation. Unlike SQL Server's
788
+ * `sp_refreshview`, PostgreSQL views automatically reflect column changes, so
789
+ * this always returns `false`.
790
+ */
791
+ get NeedsViewRefresh() {
792
+ return false;
793
+ }
794
+ /** @inheritdoc */
795
+ generateViewRefreshSQL(_schema, _viewName) {
796
+ return '';
797
+ }
798
+ /** @inheritdoc */
799
+ generateViewTestQuerySQL(schema, viewName) {
800
+ return `SELECT * FROM ${pgDialect.QuoteSchema(schema, viewName)} LIMIT 1`;
801
+ }
802
+ /**
803
+ * PostgreSQL requires a nullability fix for virtual (computed) fields in views.
804
+ * View columns derived from expressions may report incorrect nullability in
805
+ * `information_schema.columns`, so CodeGen must correct these after view creation.
806
+ */
807
+ get NeedsVirtualFieldNullabilityFix() {
808
+ return true;
809
+ }
810
+ // ─── METADATA MANAGEMENT: SQL QUOTING ────────────────────────────
811
+ /**
812
+ * SQL keywords that should NOT be quoted even when they match PascalCase patterns.
813
+ */
814
+ static { this._SQL_KEYWORDS = new Set([
815
+ // DML/DDL keywords
816
+ 'SELECT', 'INSERT', 'INTO', 'UPDATE', 'DELETE', 'FROM', 'WHERE', 'AND', 'OR', 'NOT',
817
+ 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'CROSS', 'FULL', 'ON', 'AS', 'SET',
818
+ 'VALUES', 'NULL', 'LIKE', 'IN', 'EXISTS', 'BETWEEN', 'CASE', 'WHEN', 'THEN',
819
+ 'ELSE', 'END', 'ORDER', 'BY', 'GROUP', 'HAVING', 'LIMIT', 'OFFSET', 'UNION',
820
+ 'ALL', 'CREATE', 'ALTER', 'DROP', 'TABLE', 'INDEX', 'VIEW', 'EXEC', 'DECLARE',
821
+ 'BEGIN', 'COMMIT', 'ROLLBACK', 'TRANSACTION', 'TRUE', 'FALSE', 'IS', 'ASC', 'DESC',
822
+ 'DISTINCT', 'PRIMARY', 'KEY', 'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT',
823
+ 'IF', 'OBJECT', 'TOP', 'WITH', 'OVER', 'PARTITION', 'ROW_NUMBER', 'RANK',
824
+ 'DENSE_RANK', 'LAG', 'LEAD', 'FIRST_VALUE', 'LAST_VALUE', 'ROWS', 'RANGE',
825
+ 'PRECEDING', 'FOLLOWING', 'UNBOUNDED', 'CURRENT', 'ROW', 'FETCH', 'NEXT', 'ONLY',
826
+ 'SCHEMA', 'CASCADE', 'RESTRICT', 'NO', 'ACTION', 'TRIGGER', 'FUNCTION', 'PROCEDURE',
827
+ 'RETURNS', 'RETURN', 'EXECUTE', 'CALL', 'RAISE', 'NOTICE', 'EXCEPTION', 'PERFORM',
828
+ 'GRANT', 'REVOKE', 'TO', 'USAGE', 'PRIVILEGES', 'OWNER',
829
+ // DDL sub-keywords
830
+ 'ADD', 'COLUMN', 'DO', 'RENAME', 'COMMENT', 'UNIQUE', 'CHECK',
831
+ 'CONFLICT', 'NOTHING', 'EXCLUDED', 'ZONE', 'AT', 'FOR', 'EACH', 'OF',
832
+ 'BEFORE', 'AFTER', 'INSTEAD', 'USING', 'ANY', 'SOME',
833
+ 'ENABLE', 'DISABLE', 'GENERATED', 'ALWAYS', 'IDENTITY',
834
+ 'SECURITY', 'DEFINER', 'INVOKER', 'FORCE', 'COPY',
835
+ 'TEMPORARY', 'TEMP', 'RECURSIVE', 'MATERIALIZED', 'CONCURRENTLY',
836
+ // PL/pgSQL control flow
837
+ 'NEW', 'OLD', 'FOUND', 'LOOP', 'WHILE', 'EXIT', 'CONTINUE',
838
+ 'ELSIF', 'ELSEIF', 'STRICT',
839
+ // SQL Server types
840
+ 'NVARCHAR', 'VARCHAR', 'UNIQUEIDENTIFIER', 'DATETIMEOFFSET', 'DATETIME', 'DATETIME2',
841
+ 'BIGINT', 'SMALLINT', 'TINYINT', 'FLOAT', 'REAL', 'DECIMAL', 'NUMERIC', 'MONEY',
842
+ 'BIT', 'INT', 'TEXT', 'NTEXT', 'IMAGE', 'BINARY', 'VARBINARY', 'CHAR', 'NCHAR',
843
+ 'XML', 'GEOGRAPHY', 'GEOMETRY', 'HIERARCHYID', 'SQL_VARIANT', 'SYSNAME',
844
+ 'NEWSEQUENTIALID', 'NEWID', 'GETUTCDATE', 'GETDATE', 'SYSDATETIMEOFFSET',
845
+ 'OBJECT_ID', 'SCOPE_IDENTITY',
846
+ // Aggregate / scalar functions
847
+ 'COUNT', 'MAX', 'MIN', 'SUM', 'AVG', 'COALESCE', 'CAST', 'CONVERT', 'ISNULL',
848
+ 'LEN', 'DATALENGTH', 'LOWER', 'UPPER', 'LTRIM', 'RTRIM', 'TRIM', 'REPLACE',
849
+ 'SUBSTRING', 'CHARINDEX', 'PATINDEX', 'STUFF', 'CONCAT', 'FORMAT',
850
+ 'DATEADD', 'DATEDIFF', 'DATEPART', 'YEAR', 'MONTH', 'DAY', 'HOUR', 'MINUTE',
851
+ 'SECOND', 'NOW', 'CURRENT_TIMESTAMP',
852
+ // PostgreSQL specific
853
+ 'BOOLEAN', 'SERIAL', 'BIGSERIAL', 'UUID', 'JSONB', 'JSON', 'ARRAY', 'TIMESTAMPTZ',
854
+ 'TIMESTAMP', 'DATE', 'TIME', 'INTERVAL', 'CITEXT', 'INET', 'MACADDR',
855
+ 'GEN_RANDOM_UUID', 'TO_CHAR', 'TO_DATE', 'TO_TIMESTAMP', 'TO_NUMBER',
856
+ 'STRING_AGG', 'ARRAY_AGG', 'UNNEST', 'LATERAL', 'ILIKE',
857
+ 'LANGUAGE', 'PLPGSQL', 'VOLATILE', 'STABLE', 'IMMUTABLE', 'SETOF', 'RECORD',
858
+ 'INOUT', 'OUT', 'VARIADIC', 'PARALLEL', 'SAFE', 'UNSAFE',
859
+ // information_schema column names
860
+ 'TABLE_SCHEMA', 'TABLE_NAME', 'TABLE_CATALOG', 'COLUMN_NAME', 'DATA_TYPE',
861
+ 'IS_NULLABLE', 'COLUMN_DEFAULT', 'CHARACTER_MAXIMUM_LENGTH', 'NUMERIC_PRECISION',
862
+ 'NUMERIC_SCALE', 'ORDINAL_POSITION', 'COLUMN_COMMENT',
863
+ // MJ SQL constructs
864
+ 'INFORMATION_SCHEMA', 'COLUMNS', 'TABLES', 'ROUTINES',
865
+ ]); }
866
+ /**
867
+ * Quotes mixed-case identifiers in a SQL string for PostgreSQL compatibility.
868
+ * Uses a tokenizer approach to skip string literals, already-quoted identifiers,
869
+ * dollar-quoted blocks, and SQL keywords. Any remaining PascalCase word gets
870
+ * double-quoted to preserve case.
871
+ */
872
+ quoteSQLForExecution(sql) {
873
+ const result = [];
874
+ let i = 0;
875
+ const len = sql.length;
876
+ while (i < len) {
877
+ const ch = sql[i];
878
+ if (ch === "'") {
879
+ i = this.skipSingleQuotedString(sql, i, len, result);
880
+ continue;
881
+ }
882
+ if (ch === '$') {
883
+ i = this.skipDollarQuotedBlock(sql, i, len, result);
884
+ continue;
885
+ }
886
+ if (ch === '"') {
887
+ i = this.skipDoubleQuotedIdentifier(sql, i, len, result);
888
+ continue;
889
+ }
890
+ if (ch === '[') {
891
+ i = this.skipBracketedIdentifier(sql, i, len, result);
892
+ continue;
893
+ }
894
+ if (ch === '@') {
895
+ i = this.skipAtParameter(sql, i, len, result);
896
+ continue;
897
+ }
898
+ if (/[a-zA-Z_]/.test(ch)) {
899
+ i = this.processWord(sql, i, len, result);
900
+ continue;
901
+ }
902
+ result.push(ch);
903
+ i++;
904
+ }
905
+ return result.join('');
906
+ }
907
+ // ─── METADATA MANAGEMENT: DEFAULT VALUE PARSING ──────────────────
908
+ /**
909
+ * Parses a PostgreSQL column default value by stripping PG-specific type cast syntax
910
+ * (e.g., `'2024-01-01'::timestamp` becomes `'2024-01-01'`). Returns `null` for
911
+ * auto-increment sequences (`nextval(...)`) and for null/undefined input, indicating
912
+ * no meaningful default.
913
+ */
914
+ parseColumnDefaultValue(sqlDefaultValue) {
915
+ if (sqlDefaultValue === null || sqlDefaultValue === undefined) {
916
+ return null;
917
+ }
918
+ let sResult = sqlDefaultValue;
919
+ // Strip type casts like '2024-01-01'::timestamp, 'value'::character varying
920
+ const castMatch = sResult.match(/^'(.*)'::.*$/);
921
+ if (castMatch) {
922
+ sResult = castMatch[1];
923
+ }
924
+ // Strip nextval('...') for auto-increment sequences - treated as no default
925
+ if (sResult.match(/^nextval\(/i)) {
926
+ return null;
927
+ }
928
+ return sResult;
929
+ }
930
+ // ─── METADATA MANAGEMENT: COMPLEX SQL GENERATION ─────────────────
931
+ /** @inheritdoc */
932
+ getPendingEntityFieldsSQL(mjCoreSchema) {
933
+ const qs = pgDialect.QuoteSchema.bind(pgDialect);
934
+ return this.buildPendingEntityFieldsQuery(mjCoreSchema, qs);
935
+ }
936
+ /** @inheritdoc */
937
+ getCheckConstraintsSchemaFilter(_excludeSchemas) {
938
+ // PostgreSQL view already handles schema filtering
939
+ return '';
940
+ }
941
+ /** @inheritdoc */
942
+ getEntitiesWithMissingBaseTablesFilter() {
943
+ // PostgreSQL query doesn't need this filter
944
+ return '';
945
+ }
946
+ /** @inheritdoc */
947
+ getFixVirtualFieldNullabilitySQL(mjCoreSchema) {
948
+ const qs = pgDialect.QuoteSchema.bind(pgDialect);
949
+ return this.buildFixVirtualFieldNullabilityUpdateSQL(mjCoreSchema, qs);
950
+ }
951
+ // ─── METADATA MANAGEMENT: SQL FILE EXECUTION ─────────────────────
952
+ /**
953
+ * Executes a SQL file against the PostgreSQL database using the `psql` CLI tool.
954
+ * Reads connection parameters from environment variables (`PG_HOST`, `PG_PORT`,
955
+ * `PG_DATABASE`, `PG_USERNAME`, `PG_PASSWORD`) with fallback to `configInfo` values.
956
+ * Resolves the file path to an absolute path before passing it to psql.
957
+ */
958
+ async executeSQLFileViaShell(filePath) {
959
+ const pgHost = process.env.PG_HOST ?? configInfo.dbHost;
960
+ const pgPort = process.env.PG_PORT ?? String(configInfo.dbPort ?? 5432);
961
+ const pgDatabase = process.env.PG_DATABASE ?? configInfo.dbDatabase;
962
+ const pgUser = process.env.PG_USERNAME ?? configInfo.codeGenLogin;
963
+ const pgPassword = process.env.PG_PASSWORD ?? configInfo.codeGenPassword;
964
+ if (!pgUser || !pgPassword || !pgDatabase) {
965
+ throw new Error('PostgreSQL user, password, and database must be provided in the configuration or environment variables');
966
+ }
967
+ const absoluteFilePath = path.resolve(process.cwd(), filePath);
968
+ return this.executePsqlCommand(absoluteFilePath, pgHost, pgPort, pgUser, pgDatabase, pgPassword);
969
+ }
970
+ // ═══════════════════════════════════════════════════════════════════════
971
+ // PRIVATE HELPERS
972
+ // ═══════════════════════════════════════════════════════════════════════
973
+ /**
974
+ * Converts a PascalCase or camelCase string to snake_case.
975
+ * Handles consecutive uppercase letters (e.g., "ID" → "id", "HTMLParser" → "html_parser").
976
+ */
977
+ toSnakeCase(name) {
978
+ return name
979
+ // Insert underscore between a lowercase letter and an uppercase letter
980
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
981
+ // Insert underscore between consecutive uppercase letters followed by a lowercase letter
982
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
983
+ .toLowerCase()
984
+ .replace(/__+/g, '_');
985
+ }
986
+ /**
987
+ * Maps a SQL Server type string to its PostgreSQL equivalent.
988
+ */
989
+ mapSQLType(sqlType) {
990
+ const lower = sqlType.toLowerCase().trim();
991
+ if (lower.startsWith('uniqueidentifier'))
992
+ return 'UUID';
993
+ if (lower.startsWith('nvarchar(max)') || lower.startsWith('varchar(max)'))
994
+ return 'TEXT';
995
+ if (lower.startsWith('nvarchar') || lower.startsWith('nchar'))
996
+ return sqlType.replace(/^n/i, '');
997
+ if (lower === 'bit')
998
+ return 'BOOLEAN';
999
+ if (lower === 'datetime' || lower === 'datetime2')
1000
+ return 'TIMESTAMP';
1001
+ if (lower === 'datetimeoffset')
1002
+ return 'TIMESTAMPTZ';
1003
+ if (lower === 'money' || lower === 'smallmoney')
1004
+ return 'NUMERIC(19,4)';
1005
+ if (lower === 'tinyint')
1006
+ return 'SMALLINT';
1007
+ if (lower.startsWith('image') || lower.startsWith('varbinary'))
1008
+ return 'BYTEA';
1009
+ if (lower === 'xml')
1010
+ return 'XML';
1011
+ if (lower === 'sql_variant')
1012
+ return 'TEXT';
1013
+ return sqlType; // Pass through INT, BIGINT, etc.
1014
+ }
1015
+ /** Gets the base view name for an entity */
1016
+ getBaseViewName(entity) {
1017
+ return entity.BaseView || `vw_${this.toSnakeCase(entity.CodeName)}`;
1018
+ }
1019
+ /** Builds the WHERE clause for soft-delete filtering */
1020
+ buildSoftDeleteWhereClause(entity, alias) {
1021
+ if (entity.DeleteType === 'Soft') {
1022
+ return `WHERE\n ${alias}.${pgDialect.QuoteIdentifier(EntityInfo.DeletedAtFieldName)} IS NULL\n`;
1023
+ }
1024
+ return '';
1025
+ }
1026
+ /** Assembles the SELECT parts for a base view */
1027
+ buildBaseViewSelectParts(context, alias) {
1028
+ // parentFieldsSelect and rootFieldsSelect have leading commas (e.g. ",\n Field AS Alias").
1029
+ // relatedFieldsSelect does NOT have a leading comma for the first field (starts with "\n Field...").
1030
+ let select = `${alias}.*`;
1031
+ if (context.parentFieldsSelect)
1032
+ select += context.parentFieldsSelect;
1033
+ if (context.relatedFieldsSelect)
1034
+ select += `,${context.relatedFieldsSelect}`;
1035
+ if (context.rootFieldsSelect)
1036
+ select += context.rootFieldsSelect;
1037
+ return select;
1038
+ }
1039
+ /** Assembles the FROM/JOIN parts for a base view */
1040
+ buildBaseViewFromParts(context, entity, _alias) {
1041
+ const joins = [];
1042
+ if (context.parentJoins)
1043
+ joins.push(context.parentJoins);
1044
+ if (context.relatedFieldsJoins)
1045
+ joins.push(context.relatedFieldsJoins);
1046
+ if (context.rootJoins)
1047
+ joins.push(context.rootJoins);
1048
+ return joins.length > 0 ? '\n' + joins.join('\n') : '';
1049
+ }
1050
+ /** Determines whether a field should be included in CRUD parameters */
1051
+ shouldIncludeFieldInParams(ef, isUpdate) {
1052
+ const autoGeneratedPrimaryKey = ef.AutoIncrement;
1053
+ return ((ef.AllowUpdateAPI || (ef.IsPrimaryKey && isUpdate) || (ef.IsPrimaryKey && !autoGeneratedPrimaryKey && !isUpdate)) &&
1054
+ !ef.IsVirtual &&
1055
+ (!ef.IsPrimaryKey || !autoGeneratedPrimaryKey || isUpdate) &&
1056
+ !ef.IsSpecialDateField);
1057
+ }
1058
+ /** Determines whether a field should be skipped for INSERT */
1059
+ shouldSkipFieldForInsert(ef, autoGeneratedPrimaryKey, excludePrimaryKey) {
1060
+ return ((excludePrimaryKey && ef.IsPrimaryKey) ||
1061
+ (ef.IsPrimaryKey && autoGeneratedPrimaryKey) ||
1062
+ ef.IsVirtual ||
1063
+ !ef.AllowUpdateAPI ||
1064
+ ef.AutoIncrement);
1065
+ }
1066
+ /** Gets the INSERT value for a special date field */
1067
+ getSpecialDateInsertValue(ef) {
1068
+ if (ef.IsCreatedAtField || ef.IsUpdatedAtField)
1069
+ return "NOW() AT TIME ZONE 'UTC'";
1070
+ return 'NULL'; // DeletedAt
1071
+ }
1072
+ /** Gets the parameter insert value, handling defaults */
1073
+ getParameterInsertValue(ef, prefix) {
1074
+ const paramName = `${prefix}${this.toSnakeCase(ef.CodeName)}`;
1075
+ if (ef.HasDefaultValue && !ef.AllowsNull) {
1076
+ const formattedDefault = this.formatDefaultValue(ef.DefaultValue, ef.NeedsQuotes);
1077
+ if (ef.IsUniqueIdentifier) {
1078
+ return `CASE WHEN ${paramName} = '00000000-0000-0000-0000-000000000000'::UUID THEN ${formattedDefault} ELSE COALESCE(${paramName}, ${formattedDefault}) END`;
1079
+ }
1080
+ return `COALESCE(${paramName}, ${formattedDefault})`;
1081
+ }
1082
+ return paramName;
1083
+ }
1084
+ /** Builds a WHERE clause using primary key fields with a parameter prefix */
1085
+ buildPrimaryKeyWhereClause(entity, prefix) {
1086
+ return entity.PrimaryKeys.map((k) => `${pgDialect.QuoteIdentifier(k.Name)} = ${prefix}${this.toSnakeCase(k.CodeName)}`).join(' AND ');
1087
+ }
1088
+ /** Builds the INSERT strategy for CREATE function based on PK type */
1089
+ buildCreateInsertStrategy(entity, firstKey, insertColumns, insertValues) {
1090
+ const viewName = this.getBaseViewName(entity);
1091
+ const pkCol = pgDialect.QuoteIdentifier(firstKey.Name);
1092
+ if (firstKey.AutoIncrement) {
1093
+ return {
1094
+ preInsert: '',
1095
+ returningClause: `RETURNING ${pkCol} INTO v_new_id`,
1096
+ selectClause: `SELECT * FROM ${pgDialect.QuoteSchema(entity.SchemaName, viewName)}\n WHERE ${pkCol} = v_new_id`,
1097
+ finalColumns: insertColumns,
1098
+ finalValues: insertValues,
1099
+ };
1100
+ }
1101
+ if ((firstKey.Type.toLowerCase().trim() === 'uniqueidentifier' || firstKey.Type.toLowerCase().trim() === 'uuid') && entity.PrimaryKeys.length === 1) {
1102
+ const paramName = `p_${this.toSnakeCase(firstKey.CodeName)}`;
1103
+ return {
1104
+ preInsert: `v_new_id := COALESCE(${paramName}, gen_random_uuid());\n `,
1105
+ returningClause: '',
1106
+ selectClause: `SELECT * FROM ${pgDialect.QuoteSchema(entity.SchemaName, viewName)}\n WHERE ${pkCol} = v_new_id`,
1107
+ // Include the PK column in the INSERT so caller-provided IDs are respected
1108
+ finalColumns: `${pkCol},\n ${insertColumns}`,
1109
+ finalValues: `v_new_id,\n ${insertValues}`,
1110
+ };
1111
+ }
1112
+ // Composite keys or non-auto, non-UUID PKs
1113
+ const selectWhere = entity.PrimaryKeys.map((k) => `${pgDialect.QuoteIdentifier(k.Name)} = p_${this.toSnakeCase(k.CodeName)}`).join(' AND ');
1114
+ return {
1115
+ preInsert: '',
1116
+ returningClause: '',
1117
+ selectClause: `SELECT * FROM ${pgDialect.QuoteSchema(entity.SchemaName, viewName)}\n WHERE ${selectWhere}`,
1118
+ finalColumns: insertColumns,
1119
+ finalValues: insertValues,
1120
+ };
1121
+ }
1122
+ /** Builds the DELETE body and return type based on entity delete type */
1123
+ buildDeleteStrategy(entity, cascadeSQL) {
1124
+ const paramParts = [];
1125
+ const selectParts = [];
1126
+ const nullParts = [];
1127
+ for (const k of entity.PrimaryKeys) {
1128
+ const paramName = `p_${this.toSnakeCase(k.CodeName)}`;
1129
+ paramParts.push(`${paramName} ${this.mapSQLType(k.SQLFullType)}`);
1130
+ selectParts.push(`${paramName} AS ${pgDialect.QuoteIdentifier(k.Name)}`);
1131
+ nullParts.push(`NULL::${this.mapSQLType(k.SQLFullType)} AS ${pgDialect.QuoteIdentifier(k.Name)}`);
1132
+ }
1133
+ const whereClause = entity.PrimaryKeys.map((k) => `${pgDialect.QuoteIdentifier(k.Name)} = p_${this.toSnakeCase(k.CodeName)}`).join(' AND ');
1134
+ let deleteBody;
1135
+ if (entity.DeleteType === 'Hard') {
1136
+ deleteBody = ` DELETE FROM ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)}\n WHERE ${whereClause};`;
1137
+ }
1138
+ else {
1139
+ deleteBody = ` UPDATE ${pgDialect.QuoteSchema(entity.SchemaName, entity.BaseTable)}
1140
+ SET ${pgDialect.QuoteIdentifier(EntityInfo.DeletedAtFieldName)} = NOW() AT TIME ZONE 'UTC'
1141
+ WHERE ${whereClause}
1142
+ AND ${pgDialect.QuoteIdentifier(EntityInfo.DeletedAtFieldName)} IS NULL;`;
1143
+ }
1144
+ // Return type is a TABLE with PK columns
1145
+ const returnCols = entity.PrimaryKeys.map((k) => `${pgDialect.QuoteIdentifier(k.Name)} ${this.mapSQLType(k.SQLFullType)}`).join(', ');
1146
+ const returnStatement = ` IF v_affected_count = 0 THEN
1147
+ RETURN QUERY SELECT ${nullParts.join(', ')};
1148
+ ELSE
1149
+ RETURN QUERY SELECT ${selectParts.join(', ')};
1150
+ END IF;`;
1151
+ return {
1152
+ paramDecl: paramParts.join(',\n '),
1153
+ deleteBody,
1154
+ returnType: `TABLE(${returnCols})`,
1155
+ returnStatement,
1156
+ };
1157
+ }
1158
+ /** Generates cascade update-to-NULL SQL for nullable FK */
1159
+ generateCascadeUpdateToNull(parentEntity, relatedEntity, fkField) {
1160
+ if (!relatedEntity.AllowUpdateAPI) {
1161
+ return ` -- WARNING: Cannot cascade update ${relatedEntity.Name}.${fkField.Name} to NULL - entity does not allow updates`;
1162
+ }
1163
+ const updateFnName = this.getCRUDRoutineName(relatedEntity, CRUDType.Update);
1164
+ const whereClause = `${pgDialect.QuoteIdentifier(fkField.Name)} = p_${this.toSnakeCase(parentEntity.FirstPrimaryKey.CodeName)}`;
1165
+ return ` -- Cascade: Set ${relatedEntity.Name}.${fkField.Name} to NULL
1166
+ FOR v_rec IN
1167
+ SELECT ${relatedEntity.PrimaryKeys.map((pk) => pgDialect.QuoteIdentifier(pk.Name)).join(', ')}
1168
+ FROM ${pgDialect.QuoteSchema(relatedEntity.SchemaName, relatedEntity.BaseTable)}
1169
+ WHERE ${whereClause}
1170
+ LOOP
1171
+ -- Update related record to set FK to NULL
1172
+ UPDATE ${pgDialect.QuoteSchema(relatedEntity.SchemaName, relatedEntity.BaseTable)}
1173
+ SET ${pgDialect.QuoteIdentifier(fkField.Name)} = NULL
1174
+ WHERE ${relatedEntity.PrimaryKeys.map((pk) => `${pgDialect.QuoteIdentifier(pk.Name)} = v_rec.${pgDialect.QuoteIdentifier(pk.Name)}`).join(' AND ')};
1175
+ END LOOP;
1176
+ `;
1177
+ }
1178
+ /** Generates cascade cursor-based DELETE SQL for non-nullable FK */
1179
+ generateCascadeCursorDelete(parentEntity, relatedEntity, fkField) {
1180
+ if (!relatedEntity.AllowDeleteAPI) {
1181
+ return ` -- WARNING: Cannot cascade delete ${relatedEntity.Name} records - entity does not allow deletes`;
1182
+ }
1183
+ const deleteFnName = this.getCRUDRoutineName(relatedEntity, CRUDType.Delete);
1184
+ const whereClause = `${pgDialect.QuoteIdentifier(fkField.Name)} = p_${this.toSnakeCase(parentEntity.FirstPrimaryKey.CodeName)}`;
1185
+ return ` -- Cascade: Delete ${relatedEntity.Name} records via ${fkField.Name}
1186
+ FOR v_rec IN
1187
+ SELECT ${relatedEntity.PrimaryKeys.map((pk) => pgDialect.QuoteIdentifier(pk.Name)).join(', ')}
1188
+ FROM ${pgDialect.QuoteSchema(relatedEntity.SchemaName, relatedEntity.BaseTable)}
1189
+ WHERE ${whereClause}
1190
+ LOOP
1191
+ PERFORM ${pgDialect.QuoteSchema(relatedEntity.SchemaName, deleteFnName)}(${relatedEntity.PrimaryKeys.map((pk) => `v_rec.${pgDialect.QuoteIdentifier(pk.Name)}`).join(', ')});
1192
+ END LOOP;
1193
+ `;
1194
+ }
1195
+ /** Collects unique role SQL names from permissions that have a role name */
1196
+ collectPermissionRoles(permissions) {
1197
+ const roles = [];
1198
+ for (const ep of permissions) {
1199
+ if (ep.RoleSQLName && ep.RoleSQLName.length > 0 && !roles.includes(ep.RoleSQLName)) {
1200
+ roles.push(ep.RoleSQLName);
1201
+ }
1202
+ }
1203
+ return roles;
1204
+ }
1205
+ // ─── DATABASE INTROSPECTION ──────────────────────────────────────────
1206
+ /** @inheritdoc */
1207
+ getViewDefinitionSQL(schema, viewName) {
1208
+ return `SELECT pg_get_viewdef('"${schema}"."${viewName}"'::regclass, true) AS "ViewDefinition"`;
1209
+ }
1210
+ /**
1211
+ * Generates a query against `pg_index`, `pg_class`, and `pg_namespace` to retrieve
1212
+ * the index name for a table's primary key constraint. Used by CodeGen to reference
1213
+ * the PK index in full-text search and other operations.
1214
+ */
1215
+ getPrimaryKeyIndexNameSQL(schema, tableName) {
1216
+ return `SELECT
1217
+ i.relname AS "IndexName"
1218
+ FROM
1219
+ pg_index ix
1220
+ INNER JOIN
1221
+ pg_class t ON t.oid = ix.indrelid
1222
+ INNER JOIN
1223
+ pg_class i ON i.oid = ix.indexrelid
1224
+ INNER JOIN
1225
+ pg_namespace n ON n.oid = t.relnamespace
1226
+ WHERE
1227
+ ix.indisprimary = true
1228
+ AND t.relname = '${tableName}'
1229
+ AND n.nspname = '${schema}'`;
1230
+ }
1231
+ /**
1232
+ * Generates a query against `pg_index`, `pg_class`, `pg_namespace`, and `pg_attribute`
1233
+ * to check whether a column participates in a multi-column unique constraint. Returns
1234
+ * rows only when the unique index contains more than one column and includes the
1235
+ * specified column.
1236
+ */
1237
+ getCompositeUniqueConstraintCheckSQL(schema, tableName, columnName) {
1238
+ return `SELECT ix.indexrelid AS index_id
1239
+ FROM pg_index ix
1240
+ INNER JOIN pg_class t ON t.oid = ix.indrelid
1241
+ INNER JOIN pg_namespace n ON n.oid = t.relnamespace
1242
+ INNER JOIN pg_class i ON i.oid = ix.indexrelid
1243
+ WHERE ix.indisunique = true
1244
+ AND ix.indisprimary = false
1245
+ AND n.nspname = '${schema}'
1246
+ AND t.relname = '${tableName}'
1247
+ AND EXISTS (
1248
+ SELECT 1
1249
+ FROM pg_attribute a
1250
+ WHERE a.attrelid = t.oid
1251
+ AND a.attnum = ANY(ix.indkey)
1252
+ AND a.attname = '${columnName}'
1253
+ )
1254
+ AND array_length(ix.indkey, 1) > 1`;
1255
+ }
1256
+ /** @inheritdoc */
1257
+ getForeignKeyIndexExistsSQL(schema, tableName, indexName) {
1258
+ return `SELECT 1
1259
+ FROM pg_indexes
1260
+ WHERE schemaname = '${schema}'
1261
+ AND tablename = '${tableName}'
1262
+ AND indexname = '${indexName}'`;
1263
+ }
1264
+ // ─── TOKENIZER HELPERS (for quoteSQLForExecution) ────────────────
1265
+ /** Skips a single-quoted string literal, handling escaped quotes ('') */
1266
+ skipSingleQuotedString(sql, start, len, result) {
1267
+ let j = start + 1;
1268
+ while (j < len) {
1269
+ if (sql[j] === "'" && j + 1 < len && sql[j + 1] === "'") {
1270
+ j += 2;
1271
+ }
1272
+ else if (sql[j] === "'") {
1273
+ j++;
1274
+ break;
1275
+ }
1276
+ else {
1277
+ j++;
1278
+ }
1279
+ }
1280
+ result.push(sql.substring(start, j));
1281
+ return j;
1282
+ }
1283
+ /** Skips a dollar-quoted block ($$ ... $$ or $tag$ ... $tag$) */
1284
+ skipDollarQuotedBlock(sql, start, len, result) {
1285
+ let tagEnd = start + 1;
1286
+ if (tagEnd < len && sql[tagEnd] === '$') {
1287
+ // Simple $$ tag
1288
+ tagEnd = start + 2;
1289
+ }
1290
+ else {
1291
+ // Look for $identifier$ pattern
1292
+ while (tagEnd < len && /[a-zA-Z0-9_]/.test(sql[tagEnd]))
1293
+ tagEnd++;
1294
+ if (tagEnd < len && sql[tagEnd] === '$') {
1295
+ tagEnd++;
1296
+ }
1297
+ else {
1298
+ // Not a dollar-quote, just a $ character
1299
+ result.push(sql[start]);
1300
+ return start + 1;
1301
+ }
1302
+ }
1303
+ const tag = sql.substring(start, tagEnd);
1304
+ const closePos = sql.indexOf(tag, tagEnd);
1305
+ if (closePos !== -1) {
1306
+ const blockEnd = closePos + tag.length;
1307
+ result.push(sql.substring(start, blockEnd));
1308
+ return blockEnd;
1309
+ }
1310
+ // No closing tag found, pass through rest of string
1311
+ result.push(sql.substring(start));
1312
+ return len;
1313
+ }
1314
+ /** Skips an already double-quoted identifier */
1315
+ skipDoubleQuotedIdentifier(sql, start, len, result) {
1316
+ let j = start + 1;
1317
+ while (j < len && sql[j] !== '"')
1318
+ j++;
1319
+ if (j < len)
1320
+ j++;
1321
+ result.push(sql.substring(start, j));
1322
+ return j;
1323
+ }
1324
+ /** Skips a square-bracketed identifier (SQL Server style) */
1325
+ skipBracketedIdentifier(sql, start, len, result) {
1326
+ let j = start + 1;
1327
+ while (j < len && sql[j] !== ']')
1328
+ j++;
1329
+ if (j < len)
1330
+ j++;
1331
+ result.push(sql.substring(start, j));
1332
+ return j;
1333
+ }
1334
+ /** Skips an @-prefixed parameter */
1335
+ skipAtParameter(sql, start, len, result) {
1336
+ let j = start + 1;
1337
+ while (j < len && /[a-zA-Z0-9_]/.test(sql[j]))
1338
+ j++;
1339
+ result.push(sql.substring(start, j));
1340
+ return j;
1341
+ }
1342
+ /** Processes a word token - quotes it if it's a PascalCase identifier, not a keyword */
1343
+ processWord(sql, start, len, result) {
1344
+ let j = start + 1;
1345
+ while (j < len && /[a-zA-Z0-9_]/.test(sql[j]))
1346
+ j++;
1347
+ const word = sql.substring(start, j);
1348
+ const isKeyword = PostgreSQLCodeGenProvider_1._SQL_KEYWORDS.has(word.toUpperCase());
1349
+ const startsUpper = /^[A-Z]/.test(word);
1350
+ const isAllLower = word === word.toLowerCase();
1351
+ const isMJInternal = word.startsWith('__mj_');
1352
+ if (!isKeyword && !isAllLower && !isMJInternal && startsUpper) {
1353
+ result.push(pgDialect.QuoteIdentifier(word));
1354
+ }
1355
+ else {
1356
+ result.push(word);
1357
+ }
1358
+ return j;
1359
+ }
1360
+ // ─── COMPLEX SQL GENERATION HELPERS ──────────────────────────────
1361
+ /**
1362
+ * Builds the full pending entity fields query for PostgreSQL.
1363
+ * Uses CTEs for FK, PK, and UK caches, then joins against entity metadata
1364
+ * to find fields that exist in the database but not in MJ metadata.
1365
+ */
1366
+ buildPendingEntityFieldsQuery(schema, qs) {
1367
+ return `
1368
+ WITH fk_cache AS (
1369
+ SELECT "column", "table", "schema_name", "referenced_table", "referenced_column", "referenced_schema"
1370
+ FROM ${qs(schema, 'vwForeignKeys')}
1371
+ ),
1372
+ pk_cache AS (
1373
+ SELECT "TableName", "ColumnName", "SchemaName"
1374
+ FROM ${qs(schema, 'vwTablePrimaryKeys')}
1375
+ ),
1376
+ uk_cache AS (
1377
+ SELECT "TableName", "ColumnName", "SchemaName"
1378
+ FROM ${qs(schema, 'vwTableUniqueKeys')}
1379
+ ),
1380
+ max_sequences AS (
1381
+ SELECT
1382
+ "EntityID",
1383
+ COALESCE(MAX("Sequence"), 0) AS "MaxSequence"
1384
+ FROM
1385
+ ${qs(schema, 'EntityField')}
1386
+ GROUP BY
1387
+ "EntityID"
1388
+ ),
1389
+ numbered_rows AS (
1390
+ SELECT
1391
+ sf."EntityID",
1392
+ COALESCE(ms."MaxSequence", 0) + 100000 + sf."Sequence" AS "Sequence",
1393
+ sf."FieldName",
1394
+ sf."Description",
1395
+ sf."Type",
1396
+ sf."Length",
1397
+ sf."Precision",
1398
+ sf."Scale",
1399
+ sf."AllowsNull",
1400
+ sf."DefaultValue",
1401
+ sf."AutoIncrement",
1402
+ ${this.buildAllowUpdateAPICase()},
1403
+ sf."IsVirtual",
1404
+ e."RelationshipDefaultDisplayType",
1405
+ e."Name" AS "EntityName",
1406
+ re."ID" AS "RelatedEntityID",
1407
+ fk."referenced_column" AS "RelatedEntityFieldName",
1408
+ CASE WHEN sf."FieldName" = 'Name' THEN 1 ELSE 0 END AS "IsNameField",
1409
+ CASE WHEN pk."ColumnName" IS NOT NULL THEN 1 ELSE 0 END AS "IsPrimaryKey",
1410
+ CASE
1411
+ WHEN pk."ColumnName" IS NOT NULL THEN 1
1412
+ WHEN uk."ColumnName" IS NOT NULL THEN 1
1413
+ ELSE 0
1414
+ END AS "IsUnique",
1415
+ ROW_NUMBER() OVER (PARTITION BY sf."EntityID", sf."FieldName" ORDER BY (SELECT NULL)) AS rn
1416
+ FROM
1417
+ ${qs(schema, 'vwSQLColumnsAndEntityFields')} sf
1418
+ LEFT OUTER JOIN
1419
+ max_sequences ms ON sf."EntityID" = ms."EntityID"
1420
+ LEFT OUTER JOIN
1421
+ ${qs(schema, 'Entity')} e ON sf."EntityID" = e."ID"
1422
+ LEFT OUTER JOIN
1423
+ fk_cache fk ON sf."FieldName" = fk."column" AND e."BaseTable" = fk."table" AND e."SchemaName" = fk."schema_name"
1424
+ LEFT OUTER JOIN
1425
+ ${qs(schema, 'Entity')} re ON re."BaseTable" = fk."referenced_table" AND re."SchemaName" = fk."referenced_schema"
1426
+ LEFT OUTER JOIN
1427
+ pk_cache pk ON e."BaseTable" = pk."TableName" AND sf."FieldName" = pk."ColumnName" AND e."SchemaName" = pk."SchemaName"
1428
+ LEFT OUTER JOIN
1429
+ uk_cache uk ON e."BaseTable" = uk."TableName" AND sf."FieldName" = uk."ColumnName" AND e."SchemaName" = uk."SchemaName"
1430
+ WHERE
1431
+ "EntityFieldID" IS NULL
1432
+ )
1433
+ SELECT *
1434
+ FROM numbered_rows
1435
+ WHERE rn = 1
1436
+ ORDER BY "EntityID", "Sequence";
1437
+ `;
1438
+ }
1439
+ /**
1440
+ * Builds the CASE expression for AllowUpdateAPI in the pending entity fields query.
1441
+ */
1442
+ buildAllowUpdateAPICase() {
1443
+ return `CASE WHEN sf."IsVirtual" = true THEN 0
1444
+ WHEN sf."FieldName" = '${EntityInfo.CreatedAtFieldName}' THEN 0
1445
+ WHEN sf."FieldName" = '${EntityInfo.UpdatedAtFieldName}' THEN 0
1446
+ WHEN sf."FieldName" = '${EntityInfo.DeletedAtFieldName}' THEN 0
1447
+ WHEN pk."ColumnName" IS NOT NULL THEN 0
1448
+ ELSE 1
1449
+ END AS "AllowUpdateAPI"`;
1450
+ }
1451
+ /**
1452
+ * Builds the UPDATE SQL to fix virtual field nullability.
1453
+ * Updates AllowsNull for virtual fields based on the FK column's nullability.
1454
+ */
1455
+ buildFixVirtualFieldNullabilityUpdateSQL(mjCoreSchema, qs) {
1456
+ return `
1457
+ UPDATE ${qs(mjCoreSchema, 'EntityField')} vf
1458
+ SET "AllowsNull" = fk."AllowsNull"
1459
+ FROM ${qs(mjCoreSchema, 'EntityField')} fk
1460
+ WHERE vf."IsVirtual" = true
1461
+ AND fk."IsVirtual" = false
1462
+ AND vf."EntityID" = fk."EntityID"
1463
+ AND fk."RelatedEntityID" IS NOT NULL
1464
+ AND (
1465
+ (LENGTH(fk."Name") > 2
1466
+ AND LOWER(vf."Name") = LOWER(LEFT(fk."Name", LENGTH(fk."Name") - 2)))
1467
+ OR
1468
+ (LENGTH(fk."Name") > 2
1469
+ AND LOWER(vf."Name") = LOWER(LEFT(fk."Name", LENGTH(fk."Name") - 2) || '_Virtual'))
1470
+ OR
1471
+ (fk."RelatedEntityNameFieldMap" IS NOT NULL
1472
+ AND fk."RelatedEntityNameFieldMap" != ''
1473
+ AND LOWER(vf."Name") = LOWER(fk."RelatedEntityNameFieldMap"))
1474
+ )
1475
+ AND vf."AllowsNull" != fk."AllowsNull"`;
1476
+ }
1477
+ // ─── SHELL EXECUTION HELPERS ─────────────────────────────────────
1478
+ /**
1479
+ * Executes a SQL file using the psql CLI.
1480
+ */
1481
+ async executePsqlCommand(absoluteFilePath, pgHost, pgPort, pgUser, pgDatabase, pgPassword) {
1482
+ const args = [
1483
+ '-h', pgHost,
1484
+ '-p', pgPort,
1485
+ '-U', pgUser,
1486
+ '-d', pgDatabase,
1487
+ '-v', 'ON_ERROR_STOP=1',
1488
+ '-f', absoluteFilePath,
1489
+ ];
1490
+ logIf(configInfo.verboseOutput, `Executing SQL file (psql): ${absoluteFilePath} as ${pgUser}@${pgHost}:${pgPort}/${pgDatabase}`);
1491
+ try {
1492
+ const result = await this.spawnPsql(args, pgPassword);
1493
+ this.logPsqlOutput(result.stdout, result.stderr);
1494
+ return true;
1495
+ }
1496
+ catch (e) {
1497
+ this.logPsqlError(e, pgPassword);
1498
+ return false;
1499
+ }
1500
+ }
1501
+ /**
1502
+ * Spawns a psql child process and returns its output.
1503
+ */
1504
+ spawnPsql(args, pgPassword) {
1505
+ return new Promise((resolve, reject) => {
1506
+ const child = spawn('psql', args, {
1507
+ shell: false,
1508
+ env: { ...process.env, PGPASSWORD: pgPassword },
1509
+ });
1510
+ let stdout = '';
1511
+ let stderr = '';
1512
+ child.stdout?.on('data', (data) => {
1513
+ stdout += data.toString();
1514
+ });
1515
+ child.stderr?.on('data', (data) => {
1516
+ stderr += data.toString();
1517
+ });
1518
+ child.on('error', (error) => {
1519
+ reject(error);
1520
+ });
1521
+ child.on('close', (code) => {
1522
+ if (code === 0) {
1523
+ resolve({ stdout, stderr });
1524
+ }
1525
+ else {
1526
+ const error = new Error(`psql exited with code ${code}`);
1527
+ Object.assign(error, { stdout, stderr, code });
1528
+ reject(error);
1529
+ }
1530
+ });
1531
+ });
1532
+ }
1533
+ /**
1534
+ * Logs psql stdout/stderr output, filtering out informational NOTICE messages.
1535
+ */
1536
+ logPsqlOutput(stdout, stderr) {
1537
+ if (stdout && stdout.trim().length > 0) {
1538
+ logIf(configInfo.verboseOutput, `PostgreSQL output: ${stdout.trim()}`);
1539
+ }
1540
+ if (stderr && stderr.trim().length > 0) {
1541
+ const nonNoticeLines = stderr.split('\n').filter((l) => !l.trim().startsWith('NOTICE:') && !l.trim().startsWith('psql:') && l.trim().length > 0);
1542
+ if (nonNoticeLines.length > 0) {
1543
+ logWarning(`PostgreSQL stderr: ${nonNoticeLines.join('\n')}`);
1544
+ }
1545
+ }
1546
+ }
1547
+ /**
1548
+ * Logs psql execution errors with password masking.
1549
+ */
1550
+ logPsqlError(e, pgPassword) {
1551
+ let message = (e instanceof Error) ? e.message : String(e);
1552
+ const errRecord = e;
1553
+ if (errRecord.stdout) {
1554
+ message += `\n PostgreSQL output: ${errRecord.stdout}`;
1555
+ }
1556
+ if (errRecord.stderr) {
1557
+ message += `\n PostgreSQL error: ${errRecord.stderr}`;
1558
+ }
1559
+ const errorMessage = pgPassword ? message.replace(pgPassword, 'XXXXX') : message;
1560
+ logError('Error executing PostgreSQL SQL file: ' + errorMessage);
1561
+ }
1562
+ };
1563
+ PostgreSQLCodeGenProvider = PostgreSQLCodeGenProvider_1 = __decorate([
1564
+ RegisterClass(CodeGenDatabaseProvider, 'PostgreSQLCodeGenProvider')
1565
+ ], PostgreSQLCodeGenProvider);
1566
+ export { PostgreSQLCodeGenProvider };
1567
+ //# sourceMappingURL=PostgreSQLCodeGenProvider.js.map