@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,1483 @@
1
+ import { EntityInfo, CodeNameFromString } from '@memberjunction/core';
2
+ import { CodeGenDatabaseProvider, } from '../../codeGenDatabaseProvider.js';
3
+ import { SQLServerDialect } from '@memberjunction/sql-dialect';
4
+ import { logIf, sortBySequenceAndCreatedAt } from '../../../Misc/util.js';
5
+ import { configInfo, dbDatabase } from '../../../Config/config.js';
6
+ import { sqlConfig } from '../../../Config/db-connection.js';
7
+ import { logError, logWarning } from '../../../Misc/status_logging.js';
8
+ import * as path from 'path';
9
+ import { execSync, spawn } from 'child_process';
10
+ const ssDialect = new SQLServerDialect();
11
+ /**
12
+ * SQL Server implementation of the CodeGen database provider.
13
+ * Generates SQL Server-native DDL for views, stored procedures, triggers, indexes,
14
+ * full-text search, permissions, and other database objects.
15
+ *
16
+ * This provider extracts the SQL Server-specific generation logic that was previously
17
+ * hardcoded in SQLCodeGenBase, enabling the orchestrator to be database-agnostic.
18
+ */
19
+ export class SQLServerCodeGenProvider extends CodeGenDatabaseProvider {
20
+ /** @inheritdoc */
21
+ get Dialect() {
22
+ return ssDialect;
23
+ }
24
+ /** @inheritdoc */
25
+ get PlatformKey() {
26
+ return 'sqlserver';
27
+ }
28
+ // ─── DROP GUARDS ─────────────────────────────────────────────────────
29
+ /**
30
+ * Generates SQL Server-style conditional DROP guards using the `IF OBJECT_ID(...) IS NOT NULL`
31
+ * pattern. Maps each object type to its SQL Server type code: `'V'` for views, `'P'` for
32
+ * procedures, `'IF'` for inline table-valued functions, and `'TR'` for triggers.
33
+ * Each guard is terminated with a `GO` batch separator.
34
+ */
35
+ generateDropGuard(objectType, schema, name) {
36
+ switch (objectType) {
37
+ case 'VIEW':
38
+ return `IF OBJECT_ID('[${schema}].[${name}]', 'V') IS NOT NULL\n DROP VIEW [${schema}].[${name}];\nGO`;
39
+ case 'PROCEDURE':
40
+ return `IF OBJECT_ID('[${schema}].[${name}]', 'P') IS NOT NULL\n DROP PROCEDURE [${schema}].[${name}];\nGO`;
41
+ case 'FUNCTION':
42
+ return `IF OBJECT_ID('[${schema}].[${name}]', 'IF') IS NOT NULL\n DROP FUNCTION [${schema}].[${name}];\nGO`;
43
+ case 'TRIGGER':
44
+ return `IF OBJECT_ID('[${schema}].[${name}]', 'TR') IS NOT NULL\n DROP TRIGGER [${schema}].[${name}];\nGO`;
45
+ default:
46
+ return `-- Unknown object type: ${objectType}`;
47
+ }
48
+ }
49
+ // ─── BASE VIEWS ──────────────────────────────────────────────────────
50
+ /**
51
+ * Generates a SQL Server `CREATE VIEW` statement for an entity's base view. The view
52
+ * selects all columns from the base table plus any related, parent, and root ID fields
53
+ * supplied by the orchestrator context. For entities with soft-delete enabled, appends
54
+ * a `WHERE ... IS NULL` clause filtering out soft-deleted rows. Includes the conditional
55
+ * DROP guard and a descriptive comment header.
56
+ */
57
+ generateBaseView(context) {
58
+ const entity = context.entity;
59
+ const viewName = entity.BaseView ? entity.BaseView : `vw${entity.CodeName}`;
60
+ const alias = entity.BaseTableCodeName.charAt(0).toLowerCase();
61
+ const whereClause = entity.DeleteType === 'Soft'
62
+ ? `WHERE\n ${alias}.[${EntityInfo.DeletedAtFieldName}] IS NULL\n`
63
+ : '';
64
+ return `
65
+ ------------------------------------------------------------
66
+ ----- BASE VIEW FOR ENTITY: ${entity.Name}
67
+ ----- SCHEMA: ${entity.SchemaName}
68
+ ----- BASE TABLE: ${entity.BaseTable}
69
+ ----- PRIMARY KEY: ${entity.PrimaryKeys.map(pk => pk.Name).join(', ')}
70
+ ------------------------------------------------------------
71
+ IF OBJECT_ID('[${entity.SchemaName}].[${viewName}]', 'V') IS NOT NULL
72
+ DROP VIEW [${entity.SchemaName}].[${viewName}];
73
+ GO
74
+
75
+ CREATE VIEW [${entity.SchemaName}].[${viewName}]
76
+ AS
77
+ SELECT
78
+ ${alias}.*${context.parentFieldsSelect}${context.relatedFieldsSelect.length > 0 ? ',' : ''}${context.relatedFieldsSelect}${context.rootFieldsSelect}
79
+ FROM
80
+ [${entity.SchemaName}].[${entity.BaseTable}] AS ${alias}${context.parentJoins ? '\n' + context.parentJoins : ''}${context.relatedFieldsJoins ? '\n' + context.relatedFieldsJoins : ''}${context.rootJoins}
81
+ ${whereClause}GO`;
82
+ }
83
+ // ─── CRUD ROUTINES ───────────────────────────────────────────────────
84
+ /**
85
+ * Generates the `spCreate` stored procedure for a SQL Server entity. Handles three
86
+ * primary key strategies:
87
+ *
88
+ * 1. **Auto-increment**: Uses `SCOPE_IDENTITY()` to retrieve the generated key.
89
+ * 2. **UNIQUEIDENTIFIER with default** (e.g., `NEWSEQUENTIALID()`): Uses an `OUTPUT`
90
+ * clause into a table variable with a two-branch `IF @PK IS NOT NULL` pattern so
91
+ * callers can optionally supply their own GUID or let the database default apply.
92
+ * 3. **UNIQUEIDENTIFIER without default**: Falls back to `ISNULL(@PK, NEWID())`.
93
+ * 4. **Composite / other PKs**: Constructs a multi-column WHERE clause for retrieval.
94
+ *
95
+ * The procedure always returns the newly created record via the entity's base view.
96
+ * Includes GRANT EXECUTE permissions for authorized roles.
97
+ */
98
+ generateCRUDCreate(entity) {
99
+ const spName = this.getCRUDRoutineName(entity, 'Create');
100
+ const firstKey = entity.FirstPrimaryKey;
101
+ const efString = this.generateCRUDParamString(entity.Fields, false);
102
+ const permissions = this.generateCRUDPermissions(entity, spName, 'Create');
103
+ let preInsertCode = '';
104
+ let outputCode = '';
105
+ let selectInsertedRecord = '';
106
+ let additionalFieldList = '';
107
+ let additionalValueList = '';
108
+ if (firstKey.AutoIncrement) {
109
+ selectInsertedRecord = `SELECT * FROM [${entity.SchemaName}].[${entity.BaseView}] WHERE [${firstKey.Name}] = SCOPE_IDENTITY()`;
110
+ }
111
+ else if (firstKey.Type.toLowerCase().trim() === 'uniqueidentifier' && entity.PrimaryKeys.length === 1) {
112
+ const hasDefaultValue = firstKey.DefaultValue && firstKey.DefaultValue.trim().length > 0;
113
+ if (hasDefaultValue) {
114
+ preInsertCode = `DECLARE @InsertedRow TABLE ([${firstKey.Name}] UNIQUEIDENTIFIER)
115
+
116
+ IF @${firstKey.Name} IS NOT NULL
117
+ BEGIN
118
+ -- User provided a value, use it
119
+ INSERT INTO [${entity.SchemaName}].[${entity.BaseTable}]
120
+ (
121
+ [${firstKey.Name}],
122
+ ${this.generateInsertFieldString(entity, entity.Fields, '', true)}
123
+ )
124
+ OUTPUT INSERTED.[${firstKey.Name}] INTO @InsertedRow
125
+ VALUES
126
+ (
127
+ @${firstKey.Name},
128
+ ${this.generateInsertFieldString(entity, entity.Fields, '@', true)}
129
+ )
130
+ END
131
+ ELSE
132
+ BEGIN
133
+ -- No value provided, let database use its default (e.g., NEWSEQUENTIALID())
134
+ INSERT INTO [${entity.SchemaName}].[${entity.BaseTable}]
135
+ (
136
+ ${this.generateInsertFieldString(entity, entity.Fields, '', true)}
137
+ )
138
+ OUTPUT INSERTED.[${firstKey.Name}] INTO @InsertedRow
139
+ VALUES
140
+ (
141
+ ${this.generateInsertFieldString(entity, entity.Fields, '@', true)}
142
+ )
143
+ END`;
144
+ additionalFieldList = '';
145
+ additionalValueList = '';
146
+ outputCode = '';
147
+ selectInsertedRecord = `SELECT * FROM [${entity.SchemaName}].[${entity.BaseView}] WHERE [${firstKey.Name}] = (SELECT [${firstKey.Name}] FROM @InsertedRow)`;
148
+ }
149
+ else {
150
+ preInsertCode = `DECLARE @ActualID UNIQUEIDENTIFIER = ISNULL(@${firstKey.Name}, NEWID())`;
151
+ additionalFieldList = ',\n [' + firstKey.Name + ']';
152
+ additionalValueList = ',\n @ActualID';
153
+ outputCode = '';
154
+ selectInsertedRecord = `SELECT * FROM [${entity.SchemaName}].[${entity.BaseView}] WHERE [${firstKey.Name}] = @ActualID`;
155
+ }
156
+ }
157
+ else {
158
+ selectInsertedRecord = `SELECT * FROM [${entity.SchemaName}].[${entity.BaseView}] WHERE `;
159
+ let isFirst = true;
160
+ for (const k of entity.PrimaryKeys) {
161
+ if (!isFirst)
162
+ selectInsertedRecord += ' AND ';
163
+ selectInsertedRecord += `[${k.Name}] = @${k.CodeName}`;
164
+ isFirst = false;
165
+ }
166
+ }
167
+ return `
168
+ ------------------------------------------------------------
169
+ ----- CREATE PROCEDURE FOR ${entity.BaseTable}
170
+ ------------------------------------------------------------
171
+ IF OBJECT_ID('[${entity.SchemaName}].[${spName}]', 'P') IS NOT NULL
172
+ DROP PROCEDURE [${entity.SchemaName}].[${spName}];
173
+ GO
174
+
175
+ CREATE PROCEDURE [${entity.SchemaName}].[${spName}]
176
+ ${efString}
177
+ AS
178
+ BEGIN
179
+ SET NOCOUNT ON;
180
+ ${preInsertCode}${preInsertCode.includes('INSERT INTO') ? '' : `
181
+ INSERT INTO
182
+ [${entity.SchemaName}].[${entity.BaseTable}]
183
+ (
184
+ ${this.generateInsertFieldString(entity, entity.Fields, '')}${additionalFieldList}
185
+ )
186
+ ${outputCode}VALUES
187
+ (
188
+ ${this.generateInsertFieldString(entity, entity.Fields, '@')}${additionalValueList}
189
+ )`}
190
+ -- return the new record from the base view, which might have some calculated fields
191
+ ${selectInsertedRecord}
192
+ END
193
+ GO${permissions}
194
+ `;
195
+ }
196
+ /**
197
+ * Generates the `spUpdate` stored procedure for a SQL Server entity. Performs a standard
198
+ * `UPDATE ... SET ... WHERE PK = @PK` and uses `@@ROWCOUNT` to detect whether the row
199
+ * was found: if no rows were updated (e.g., stale PK or concurrent delete), returns an
200
+ * empty result set with the base view's column structure (`SELECT TOP 0 ... WHERE 1=0`);
201
+ * otherwise returns the updated record from the base view. Also generates the
202
+ * `__mj_UpdatedAt` timestamp trigger if the entity has that column.
203
+ */
204
+ generateCRUDUpdate(entity) {
205
+ const spName = this.getCRUDRoutineName(entity, 'Update');
206
+ const efParamString = this.generateCRUDParamString(entity.Fields, true);
207
+ const permissions = this.generateCRUDPermissions(entity, spName, 'Update');
208
+ const hasUpdatedAtField = entity.Fields.find(f => f.Name.toLowerCase().trim() === EntityInfo.UpdatedAtFieldName.trim().toLowerCase()) !== undefined;
209
+ const updatedAtTrigger = hasUpdatedAtField ? this.generateTimestampTrigger(entity) : '';
210
+ const selectUpdatedRecord = `SELECT
211
+ *
212
+ FROM
213
+ [${entity.SchemaName}].[${entity.BaseView}]
214
+ WHERE
215
+ ${entity.PrimaryKeys.map(k => `[${k.Name}] = @${k.CodeName}`).join(' AND ')}
216
+ `;
217
+ return `
218
+ ------------------------------------------------------------
219
+ ----- UPDATE PROCEDURE FOR ${entity.BaseTable}
220
+ ------------------------------------------------------------
221
+ IF OBJECT_ID('[${entity.SchemaName}].[${spName}]', 'P') IS NOT NULL
222
+ DROP PROCEDURE [${entity.SchemaName}].[${spName}];
223
+ GO
224
+
225
+ CREATE PROCEDURE [${entity.SchemaName}].[${spName}]
226
+ ${efParamString}
227
+ AS
228
+ BEGIN
229
+ SET NOCOUNT ON;
230
+ UPDATE
231
+ [${entity.SchemaName}].[${entity.BaseTable}]
232
+ SET
233
+ ${this.generateUpdateFieldString(entity.Fields)}
234
+ WHERE
235
+ ${entity.PrimaryKeys.map(k => `[${k.Name}] = @${k.CodeName}`).join(' AND ')}
236
+
237
+ -- Check if the update was successful
238
+ IF @@ROWCOUNT = 0
239
+ -- Nothing was updated, return no rows, but column structure from base view intact, semantically correct this way.
240
+ SELECT TOP 0 * FROM [${entity.SchemaName}].[${entity.BaseView}] WHERE 1=0
241
+ ELSE
242
+ -- Return the updated record so the caller can see the updated values and any calculated fields
243
+ ${selectUpdatedRecord}
244
+ END
245
+ GO
246
+ ${permissions}
247
+ GO
248
+ ${updatedAtTrigger}
249
+ `;
250
+ }
251
+ /**
252
+ * Generates the `spDelete` stored procedure for a SQL Server entity. Supports both
253
+ * hard delete (`DELETE FROM`) and soft delete (`UPDATE ... SET __mj_DeletedAt = GETUTCDATE()`
254
+ * with a guard against re-deleting already soft-deleted rows). Prepends any cascade
255
+ * delete/update SQL for related entities. Uses `@@ROWCOUNT` to determine success:
256
+ * returns the PK values on success or NULL PK values if no row was affected.
257
+ */
258
+ generateCRUDDelete(entity, cascadeSQL) {
259
+ const spName = this.getCRUDRoutineName(entity, 'Delete');
260
+ const permissions = this.generateCRUDPermissions(entity, spName, 'Delete');
261
+ let sVariables = '';
262
+ let sSelect = '';
263
+ for (const k of entity.PrimaryKeys) {
264
+ if (sVariables !== '')
265
+ sVariables += ', ';
266
+ sVariables += `@${k.CodeName} ${k.SQLFullType}`;
267
+ if (sSelect !== '')
268
+ sSelect += ', ';
269
+ sSelect += `@${k.CodeName} AS [${k.Name}]`;
270
+ }
271
+ let deleteCode = ` WHERE
272
+ ${entity.PrimaryKeys.map(k => `[${k.Name}] = @${k.CodeName}`).join(' AND ')}
273
+ `;
274
+ if (entity.DeleteType === 'Hard') {
275
+ deleteCode = ` DELETE FROM
276
+ [${entity.SchemaName}].[${entity.BaseTable}]
277
+ ${deleteCode}`;
278
+ }
279
+ else {
280
+ deleteCode = ` UPDATE
281
+ [${entity.SchemaName}].[${entity.BaseTable}]
282
+ SET
283
+ ${EntityInfo.DeletedAtFieldName} = GETUTCDATE()
284
+ ${deleteCode} AND ${EntityInfo.DeletedAtFieldName} IS NULL -- don't update the record if it's already been deleted via a soft delete`;
285
+ }
286
+ let sNullSelect = '';
287
+ for (const k of entity.PrimaryKeys) {
288
+ if (sNullSelect !== '')
289
+ sNullSelect += ', ';
290
+ sNullSelect += `NULL AS [${k.Name}]`;
291
+ }
292
+ return `
293
+ ------------------------------------------------------------
294
+ ----- DELETE PROCEDURE FOR ${entity.BaseTable}
295
+ ------------------------------------------------------------
296
+ IF OBJECT_ID('[${entity.SchemaName}].[${spName}]', 'P') IS NOT NULL
297
+ DROP PROCEDURE [${entity.SchemaName}].[${spName}];
298
+ GO
299
+
300
+ CREATE PROCEDURE [${entity.SchemaName}].[${spName}]
301
+ ${sVariables}
302
+ AS
303
+ BEGIN
304
+ SET NOCOUNT ON;${cascadeSQL}
305
+
306
+ ${deleteCode}
307
+
308
+ -- Check if the delete was successful
309
+ IF @@ROWCOUNT = 0
310
+ SELECT ${sNullSelect} -- Return NULL for all primary key fields to indicate no record was deleted
311
+ ELSE
312
+ SELECT ${sSelect} -- Return the primary key values to indicate we successfully deleted the record
313
+ END
314
+ GO${permissions}
315
+ `;
316
+ }
317
+ // ─── TRIGGERS ────────────────────────────────────────────────────────
318
+ /**
319
+ * Generates a SQL Server `AFTER UPDATE` trigger that automatically sets the
320
+ * `__mj_UpdatedAt` column to `GETUTCDATE()` on every update. The trigger joins
321
+ * the base table to the `INSERTED` pseudo-table on all primary key columns to
322
+ * target only the affected rows. Returns an empty string if the entity does not
323
+ * have an `__mj_UpdatedAt` field.
324
+ */
325
+ generateTimestampTrigger(entity) {
326
+ const updatedAtField = entity.Fields.find(f => f.Name.toLowerCase().trim() === EntityInfo.UpdatedAtFieldName.toLowerCase().trim());
327
+ if (!updatedAtField)
328
+ return '';
329
+ return `
330
+ ------------------------------------------------------------
331
+ ----- TRIGGER FOR ${EntityInfo.UpdatedAtFieldName} field for the ${entity.BaseTable} table
332
+ ------------------------------------------------------------
333
+ IF OBJECT_ID('[${entity.SchemaName}].[trgUpdate${entity.BaseTableCodeName}]', 'TR') IS NOT NULL
334
+ DROP TRIGGER [${entity.SchemaName}].[trgUpdate${entity.BaseTableCodeName}];
335
+ GO
336
+ CREATE TRIGGER [${entity.SchemaName}].trgUpdate${entity.BaseTableCodeName}
337
+ ON [${entity.SchemaName}].[${entity.BaseTable}]
338
+ AFTER UPDATE
339
+ AS
340
+ BEGIN
341
+ SET NOCOUNT ON;
342
+ UPDATE
343
+ [${entity.SchemaName}].[${entity.BaseTable}]
344
+ SET
345
+ ${EntityInfo.UpdatedAtFieldName} = GETUTCDATE()
346
+ FROM
347
+ [${entity.SchemaName}].[${entity.BaseTable}] AS _organicTable
348
+ INNER JOIN
349
+ INSERTED AS I ON
350
+ ${entity.PrimaryKeys.map(k => `_organicTable.[${k.Name}] = I.[${k.Name}]`).join(' AND ')};
351
+ END;
352
+ GO`;
353
+ }
354
+ // ─── INDEXES ─────────────────────────────────────────────────────────
355
+ /**
356
+ * Generates conditional `CREATE INDEX` statements for all foreign key columns on an entity.
357
+ * Each index uses the naming convention `IDX_AUTO_MJ_FKEY_{Table}_{Column}`, truncated
358
+ * to 128 characters (SQL Server's identifier length limit). Wraps each statement in an
359
+ * `IF NOT EXISTS` check against `sys.indexes` to avoid duplicate index creation.
360
+ */
361
+ generateForeignKeyIndexes(entity) {
362
+ const indexes = [];
363
+ for (const f of entity.Fields) {
364
+ if (f.RelatedEntity && f.RelatedEntity.length > 0) {
365
+ let indexName = `IDX_AUTO_MJ_FKEY_${entity.BaseTableCodeName}_${f.CodeName}`;
366
+ if (indexName.length > 128)
367
+ indexName = indexName.substring(0, 128);
368
+ indexes.push(`-- Index for foreign key ${f.Name} in table ${entity.BaseTable}
369
+ IF NOT EXISTS (
370
+ SELECT 1
371
+ FROM sys.indexes
372
+ WHERE name = '${indexName}'
373
+ AND object_id = OBJECT_ID('[${entity.SchemaName}].[${entity.BaseTable}]')
374
+ )
375
+ CREATE INDEX ${indexName} ON [${entity.SchemaName}].[${entity.BaseTable}] ([${f.Name}]);`);
376
+ }
377
+ }
378
+ return indexes;
379
+ }
380
+ // ─── FULL-TEXT SEARCH ────────────────────────────────────────────────
381
+ /**
382
+ * Generates SQL Server full-text search infrastructure for an entity, conditionally
383
+ * producing up to three components based on entity metadata flags:
384
+ *
385
+ * 1. **Full-text catalog** (`FullTextCatalogGenerated`): `CREATE FULLTEXT CATALOG` if
386
+ * one doesn't already exist, defaulting to `MJ_FullTextCatalog`.
387
+ * 2. **Full-text index** (`FullTextIndexGenerated`): Drops any existing FT index on the
388
+ * table, then creates a new one covering the specified search fields with English language.
389
+ * 3. **Search function** (`FullTextSearchFunctionGenerated`): An inline table-valued
390
+ * function that wraps `CONTAINS()` to return matching primary key values.
391
+ *
392
+ * @returns The generated SQL and the resolved function name for permission grants.
393
+ */
394
+ generateFullTextSearch(entity, searchFields, primaryKeyIndexName) {
395
+ let sql = '';
396
+ const catalogName = entity.FullTextCatalog && entity.FullTextCatalog.length > 0
397
+ ? entity.FullTextCatalog
398
+ : dbDatabase + '_FullTextCatalog';
399
+ if (entity.FullTextCatalogGenerated) {
400
+ sql += ` -- CREATE THE FULL TEXT CATALOG
401
+ IF NOT EXISTS (
402
+ SELECT * FROM sys.fulltext_catalogs WHERE name = '${catalogName}'
403
+ )
404
+ CREATE FULLTEXT CATALOG ${catalogName};
405
+ GO
406
+ `;
407
+ }
408
+ if (entity.FullTextIndexGenerated) {
409
+ const fullTextFields = searchFields.map(f => `${f.Name} LANGUAGE 'English'`).join(', ');
410
+ sql += ` -- DROP AND RECREATE THE FULL TEXT INDEX
411
+ IF EXISTS (
412
+ SELECT * FROM sys.fulltext_indexes
413
+ WHERE object_id = OBJECT_ID('${entity.SchemaName}.${entity.BaseTable}')
414
+ )
415
+ BEGIN
416
+ DROP FULLTEXT INDEX ON [${entity.SchemaName}].[${entity.BaseTable}];
417
+ END
418
+ GO
419
+
420
+ IF NOT EXISTS (
421
+ SELECT * FROM sys.fulltext_indexes
422
+ WHERE object_id = OBJECT_ID('${entity.SchemaName}.${entity.BaseTable}')
423
+ )
424
+ BEGIN
425
+ CREATE FULLTEXT INDEX ON [${entity.SchemaName}].[${entity.BaseTable}]
426
+ (${fullTextFields})
427
+ KEY INDEX ${primaryKeyIndexName}
428
+ ON ${catalogName};
429
+ END
430
+ GO
431
+ `;
432
+ }
433
+ const functionName = entity.FullTextSearchFunction && entity.FullTextSearchFunction.length > 0
434
+ ? entity.FullTextSearchFunction
435
+ : `fnSearch${entity.CodeName}`;
436
+ if (entity.FullTextSearchFunctionGenerated) {
437
+ const fullTextFieldsSimple = searchFields.map(f => '[' + f.Name + ']').join(', ');
438
+ const pkeyList = entity.PrimaryKeys.map(pk => '[' + pk.Name + ']').join(', ');
439
+ sql += ` -- DROP AND RECREATE THE FULL TEXT SEARCH FUNCTION
440
+ IF OBJECT_ID('${entity.SchemaName}.${functionName}', 'IF') IS NOT NULL
441
+ DROP FUNCTION ${entity.SchemaName}.${functionName};
442
+ GO
443
+ CREATE FUNCTION ${entity.SchemaName}.${functionName} (@searchTerm NVARCHAR(255))
444
+ RETURNS TABLE
445
+ AS
446
+ RETURN (
447
+ SELECT ${pkeyList}
448
+ FROM [${entity.SchemaName}].[${entity.BaseTable}]
449
+ WHERE CONTAINS((${fullTextFieldsSimple}), @searchTerm)
450
+ )
451
+ GO
452
+ `;
453
+ }
454
+ return { sql, functionName };
455
+ }
456
+ // ─── RECURSIVE FUNCTIONS (ROOT ID) ───────────────────────────────────
457
+ /**
458
+ * Generates a SQL Server inline table-valued function that resolves the root ancestor ID
459
+ * for a self-referencing foreign key hierarchy. Uses a recursive CTE starting from the
460
+ * given record (or its parent if provided), walking up the parent chain until it finds
461
+ * a record with a NULL parent pointer. Enforces a maximum recursion depth of 100 to
462
+ * prevent infinite loops from circular references. The function is named
463
+ * `fn{BaseTable}{FieldName}_GetRootID` and returns a single-column result (`RootID`),
464
+ * designed to be consumed via `OUTER APPLY` in the entity's base view.
465
+ */
466
+ generateRootIDFunction(entity, field) {
467
+ const primaryKey = entity.FirstPrimaryKey.Name;
468
+ const primaryKeyType = entity.FirstPrimaryKey.SQLFullType;
469
+ const schemaName = entity.SchemaName;
470
+ const tableName = entity.BaseTable;
471
+ const fieldName = field.Name;
472
+ const functionName = `fn${entity.BaseTable}${fieldName}_GetRootID`;
473
+ return `------------------------------------------------------------
474
+ ----- ROOT ID FUNCTION FOR: [${tableName}].[${fieldName}]
475
+ ------------------------------------------------------------
476
+ IF OBJECT_ID('[${schemaName}].[${functionName}]', 'IF') IS NOT NULL
477
+ DROP FUNCTION [${schemaName}].[${functionName}];
478
+ GO
479
+
480
+ CREATE FUNCTION [${schemaName}].[${functionName}]
481
+ (
482
+ @RecordID ${primaryKeyType},
483
+ @ParentID ${primaryKeyType}
484
+ )
485
+ RETURNS TABLE
486
+ AS
487
+ RETURN
488
+ (
489
+ WITH CTE_RootParent AS (
490
+ SELECT
491
+ [${primaryKey}],
492
+ [${fieldName}],
493
+ [${primaryKey}] AS [RootParentID],
494
+ 0 AS [Depth]
495
+ FROM
496
+ [${schemaName}].[${tableName}]
497
+ WHERE
498
+ [${primaryKey}] = COALESCE(@ParentID, @RecordID)
499
+
500
+ UNION ALL
501
+
502
+ SELECT
503
+ c.[${primaryKey}],
504
+ c.[${fieldName}],
505
+ c.[${primaryKey}] AS [RootParentID],
506
+ p.[Depth] + 1 AS [Depth]
507
+ FROM
508
+ [${schemaName}].[${tableName}] c
509
+ INNER JOIN
510
+ CTE_RootParent p ON c.[${primaryKey}] = p.[${fieldName}]
511
+ WHERE
512
+ p.[Depth] < 100
513
+ )
514
+ SELECT TOP 1
515
+ [RootParentID] AS RootID
516
+ FROM
517
+ CTE_RootParent
518
+ WHERE
519
+ [${fieldName}] IS NULL
520
+ ORDER BY
521
+ [RootParentID]
522
+ );
523
+ GO
524
+ `;
525
+ }
526
+ /** @inheritdoc */
527
+ generateRootFieldSelect(_entity, field, alias) {
528
+ const rootFieldName = `Root${field.Name}`;
529
+ return `${alias}.RootID AS [${rootFieldName}]`;
530
+ }
531
+ /**
532
+ * Generates a SQL Server `OUTER APPLY` clause to join the root ID inline table-valued
533
+ * function into the entity's base view. The function is invoked with the record's primary
534
+ * key and the self-referencing FK value as arguments.
535
+ */
536
+ generateRootFieldJoin(entity, field, alias) {
537
+ const classNameFirstChar = entity.BaseTableCodeName.charAt(0).toLowerCase();
538
+ const schemaName = entity.SchemaName;
539
+ const functionName = `fn${entity.BaseTable}${field.Name}_GetRootID`;
540
+ const primaryKey = entity.FirstPrimaryKey.Name;
541
+ return `OUTER APPLY\n [${schemaName}].[${functionName}]([${classNameFirstChar}].[${primaryKey}], [${classNameFirstChar}].[${field.Name}]) AS ${alias}`;
542
+ }
543
+ // ─── PERMISSIONS ─────────────────────────────────────────────────────
544
+ /** @inheritdoc */
545
+ generateViewPermissions(entity) {
546
+ let sOutput = '';
547
+ for (const ep of entity.Permissions) {
548
+ if (ep.RoleSQLName && ep.RoleSQLName.length > 0) {
549
+ sOutput += (sOutput === '' ? `GRANT SELECT ON [${entity.SchemaName}].[${entity.BaseView}] TO ` : ', ') + `[${ep.RoleSQLName}]`;
550
+ }
551
+ }
552
+ return (sOutput === '' ? '' : '\n') + sOutput;
553
+ }
554
+ /**
555
+ * Generates `GRANT EXECUTE` SQL for a CRUD stored procedure, granting permission only
556
+ * to roles whose `EntityPermission` record allows the specified CRUD operation type.
557
+ * Produces a single comma-separated `GRANT EXECUTE ON ... TO [role1], [role2]` statement.
558
+ */
559
+ generateCRUDPermissions(entity, routineName, type) {
560
+ let sOutput = '';
561
+ for (const ep of entity.Permissions) {
562
+ if (ep.RoleSQLName && ep.RoleSQLName.length > 0) {
563
+ const hasPermission = (type === 'Create' && ep.CanCreate) ||
564
+ (type === 'Update' && ep.CanUpdate) ||
565
+ (type === 'Delete' && ep.CanDelete);
566
+ if (hasPermission) {
567
+ sOutput += (sOutput === '' ? `GRANT EXECUTE ON [${entity.SchemaName}].[${routineName}] TO ` : ', ') + `[${ep.RoleSQLName}]`;
568
+ }
569
+ }
570
+ }
571
+ return (sOutput === '' ? '' : '\n') + sOutput;
572
+ }
573
+ /** @inheritdoc */
574
+ generateFullTextSearchPermissions(entity, functionName) {
575
+ let sOutput = '';
576
+ for (const ep of entity.Permissions) {
577
+ if (ep.CanRead) {
578
+ if (ep.RoleSQLName && ep.RoleSQLName.length > 0) {
579
+ sOutput += (sOutput === '' ? `GRANT SELECT ON [${entity.SchemaName}].[${functionName}] TO ` : ', ') + `[${ep.RoleSQLName}]`;
580
+ }
581
+ }
582
+ }
583
+ return (sOutput === '' ? '' : '\n') + sOutput;
584
+ }
585
+ // ─── CASCADE DELETES ─────────────────────────────────────────────────
586
+ /** @inheritdoc */
587
+ generateSingleCascadeOperation(context) {
588
+ const { parentEntity, relatedEntity, fkField, operation } = context;
589
+ if (operation === 'update') {
590
+ return this.generateCascadeCursorUpdate(parentEntity, relatedEntity, fkField);
591
+ }
592
+ return this.generateCascadeCursorDelete(parentEntity, relatedEntity, fkField);
593
+ }
594
+ /**
595
+ * Generates cursor-based cascade DELETE SQL for SQL Server.
596
+ * Uses a cursor to iterate related records and call the entity's spDelete for each.
597
+ */
598
+ generateCascadeCursorDelete(parentEntity, relatedEntity, fkField) {
599
+ const qi = this.Dialect.QuoteIdentifier.bind(this.Dialect);
600
+ const qs = this.Dialect.QuoteSchema.bind(this.Dialect);
601
+ const whereClause = `${qi(fkField.CodeName)} = @${parentEntity.FirstPrimaryKey.CodeName}`;
602
+ const spName = this.getCRUDRoutineName(relatedEntity, 'Delete');
603
+ const variablePrefix = `${relatedEntity.CodeName}_${fkField.CodeName}`;
604
+ const pkComponents = this.buildPrimaryKeyComponents(relatedEntity, variablePrefix);
605
+ const cursorName = `cascade_delete_${relatedEntity.CodeName}_${fkField.CodeName}_cursor`;
606
+ return `
607
+ -- Cascade delete from ${relatedEntity.BaseTable} using cursor to call ${spName}
608
+ DECLARE ${pkComponents.varDeclarations}
609
+ DECLARE ${cursorName} CURSOR FOR
610
+ SELECT ${pkComponents.selectFields}
611
+ FROM ${qs(relatedEntity.SchemaName, relatedEntity.BaseTable)}
612
+ WHERE ${whereClause}
613
+
614
+ OPEN ${cursorName}
615
+ FETCH NEXT FROM ${cursorName} INTO ${pkComponents.fetchInto}
616
+
617
+ WHILE @@FETCH_STATUS = 0
618
+ BEGIN
619
+ EXEC ${qs(relatedEntity.SchemaName, spName)} ${pkComponents.routineParams}
620
+
621
+ FETCH NEXT FROM ${cursorName} INTO ${pkComponents.fetchInto}
622
+ END
623
+
624
+ CLOSE ${cursorName}
625
+ DEALLOCATE ${cursorName}`;
626
+ }
627
+ /**
628
+ * Generates cursor-based cascade UPDATE SQL for SQL Server.
629
+ * Used for nullable FK fields: iterates related records and calls spUpdate
630
+ * to set the FK to NULL for each.
631
+ */
632
+ generateCascadeCursorUpdate(parentEntity, relatedEntity, fkField) {
633
+ const qi = this.Dialect.QuoteIdentifier.bind(this.Dialect);
634
+ const qs = this.Dialect.QuoteSchema.bind(this.Dialect);
635
+ const whereClause = `${qi(fkField.CodeName)} = @${parentEntity.FirstPrimaryKey.CodeName}`;
636
+ const variablePrefix = `${relatedEntity.CodeName}_${fkField.CodeName}`;
637
+ const spName = this.getCRUDRoutineName(relatedEntity, 'Update');
638
+ const updateParams = this.buildUpdateCursorParameters(relatedEntity, fkField, variablePrefix);
639
+ const cursorName = `cascade_update_${relatedEntity.CodeName}_${fkField.CodeName}_cursor`;
640
+ return `
641
+ -- Cascade update on ${relatedEntity.BaseTable} using cursor to call ${spName}
642
+ ${updateParams.declarations}
643
+ DECLARE ${cursorName} CURSOR FOR
644
+ SELECT ${updateParams.selectFields}
645
+ FROM ${qs(relatedEntity.SchemaName, relatedEntity.BaseTable)}
646
+ WHERE ${whereClause}
647
+
648
+ OPEN ${cursorName}
649
+ FETCH NEXT FROM ${cursorName} INTO ${updateParams.fetchInto}
650
+
651
+ WHILE @@FETCH_STATUS = 0
652
+ BEGIN
653
+ -- Set the FK field to NULL
654
+ SET @${variablePrefix}_${fkField.CodeName} = NULL
655
+
656
+ -- Call the update SP for the related entity
657
+ EXEC ${qs(relatedEntity.SchemaName, spName)} ${updateParams.allParams}
658
+
659
+ FETCH NEXT FROM ${cursorName} INTO ${updateParams.fetchInto}
660
+ END
661
+
662
+ CLOSE ${cursorName}
663
+ DEALLOCATE ${cursorName}`;
664
+ }
665
+ /**
666
+ * Builds all the cursor parameters needed for a cascade UPDATE operation.
667
+ * Includes primary key fields and all updateable fields, with proper
668
+ * DECLARE statements, SELECT fields, FETCH INTO variables, and SP parameters.
669
+ */
670
+ buildUpdateCursorParameters(entity, _fkField, prefix = '') {
671
+ const qi = this.Dialect.QuoteIdentifier.bind(this.Dialect);
672
+ let declarations = '';
673
+ let selectFields = '';
674
+ let fetchInto = '';
675
+ let allParams = '';
676
+ const varPrefix = prefix || entity.CodeName;
677
+ // First, handle primary keys with the entity-specific prefix
678
+ const pkComponents = this.buildPrimaryKeyComponents(entity, varPrefix);
679
+ // Add primary key declarations to the declarations string
680
+ // Need to add DECLARE keyword since buildPrimaryKeyComponents doesn't include it
681
+ declarations = pkComponents.varDeclarations.split(', ').map((decl) => `DECLARE ${decl}`).join('\n ');
682
+ selectFields = pkComponents.selectFields;
683
+ fetchInto = pkComponents.fetchInto;
684
+ allParams = pkComponents.routineParams;
685
+ // Then, add all updateable fields with the same prefix
686
+ const sortedFields = sortBySequenceAndCreatedAt(entity.Fields);
687
+ for (const ef of sortedFields) {
688
+ if (!ef.IsPrimaryKey && !ef.IsVirtual && ef.AllowUpdateAPI && !ef.AutoIncrement && !ef.IsSpecialDateField) {
689
+ if (declarations !== '')
690
+ declarations += '\n ';
691
+ declarations += `DECLARE @${varPrefix}_${ef.CodeName} ${ef.SQLFullType}`;
692
+ if (selectFields !== '')
693
+ selectFields += ', ';
694
+ selectFields += qi(ef.Name);
695
+ if (fetchInto !== '')
696
+ fetchInto += ', ';
697
+ fetchInto += `@${varPrefix}_${ef.CodeName}`;
698
+ if (allParams !== '')
699
+ allParams += ', ';
700
+ // Use named parameters: @ParamName = @VariableValue
701
+ allParams += `@${ef.CodeName} = @${varPrefix}_${ef.CodeName}`;
702
+ }
703
+ }
704
+ return { declarations, selectFields, fetchInto, allParams };
705
+ }
706
+ // ─── TIMESTAMP COLUMNS ───────────────────────────────────────────────
707
+ /** @inheritdoc */
708
+ generateTimestampColumns(schema, tableName) {
709
+ return `ALTER TABLE [${schema}].[${tableName}] ADD
710
+ [${EntityInfo.CreatedAtFieldName}] DATETIMEOFFSET NOT NULL DEFAULT GETUTCDATE(),
711
+ [${EntityInfo.UpdatedAtFieldName}] DATETIMEOFFSET NOT NULL DEFAULT GETUTCDATE();`;
712
+ }
713
+ // ─── PARAMETER / FIELD HELPERS ───────────────────────────────────────
714
+ /**
715
+ * Builds the SQL Server parameter declaration list for a CRUD stored procedure.
716
+ * Produces `@ParamName TYPE` entries separated by commas. Filters fields based on
717
+ * update/create context: includes primary keys only on updates (or non-auto-increment
718
+ * creates), excludes virtual and special date fields, and adds `= NULL` default values
719
+ * for optional primary keys on create and for non-nullable fields with database defaults.
720
+ */
721
+ generateCRUDParamString(entityFields, isUpdate) {
722
+ let sOutput = '';
723
+ let isFirst = true;
724
+ for (const ef of entityFields) {
725
+ const autoGeneratedPrimaryKey = ef.AutoIncrement;
726
+ if ((ef.AllowUpdateAPI || (ef.IsPrimaryKey && isUpdate) || (ef.IsPrimaryKey && !autoGeneratedPrimaryKey && !isUpdate)) &&
727
+ !ef.IsVirtual &&
728
+ (!ef.IsPrimaryKey || !autoGeneratedPrimaryKey || isUpdate) &&
729
+ !ef.IsSpecialDateField) {
730
+ if (!isFirst)
731
+ sOutput += ',\n ';
732
+ else
733
+ isFirst = false;
734
+ let defaultParamValue = '';
735
+ if (!isUpdate && ef.IsPrimaryKey && !ef.AutoIncrement) {
736
+ defaultParamValue = ' = NULL';
737
+ }
738
+ else if (!isUpdate && ef.HasDefaultValue && !ef.AllowsNull) {
739
+ defaultParamValue = ' = NULL';
740
+ }
741
+ sOutput += `@${ef.CodeName} ${ef.SQLFullType}${defaultParamValue}`;
742
+ }
743
+ }
744
+ return sOutput;
745
+ }
746
+ /**
747
+ * Generates either the column-name list or the value-expression list for an INSERT
748
+ * statement, depending on the `prefix` parameter:
749
+ *
750
+ * - **Empty prefix** (`''`): Produces bracketed column names (e.g., `[Name], [Email]`).
751
+ * - **`'@'` prefix**: Produces parameter references with smart default handling:
752
+ * - Special date fields emit `GETUTCDATE()` for created/updated-at, `NULL` for deleted-at.
753
+ * - UNIQUEIDENTIFIER fields with defaults use a `CASE` expression that detects the
754
+ * empty GUID sentinel (`00000000-...`) and falls back to the database default.
755
+ * - Other non-nullable fields with defaults are wrapped in `ISNULL(@Param, default)`.
756
+ *
757
+ * Skips auto-increment, virtual, and non-updatable fields. Optionally excludes the
758
+ * primary key column (used by the two-branch GUID insert pattern in `generateCRUDCreate`).
759
+ */
760
+ generateInsertFieldString(entity, entityFields, prefix, excludePrimaryKey = false) {
761
+ const autoGeneratedPrimaryKey = entity.FirstPrimaryKey.AutoIncrement;
762
+ let sOutput = '';
763
+ let isFirst = true;
764
+ for (const ef of entityFields) {
765
+ if ((excludePrimaryKey && ef.IsPrimaryKey) ||
766
+ (ef.IsPrimaryKey && autoGeneratedPrimaryKey) ||
767
+ ef.IsVirtual ||
768
+ !ef.AllowUpdateAPI ||
769
+ ef.AutoIncrement) {
770
+ continue;
771
+ }
772
+ if (!isFirst)
773
+ sOutput += ',\n ';
774
+ else
775
+ isFirst = false;
776
+ if (prefix !== '' && ef.IsSpecialDateField) {
777
+ if (ef.IsCreatedAtField || ef.IsUpdatedAtField)
778
+ sOutput += `GETUTCDATE()`;
779
+ else
780
+ sOutput += `NULL`;
781
+ }
782
+ else if (prefix && prefix !== '' && !ef.IsPrimaryKey && ef.IsUniqueIdentifier && ef.HasDefaultValue && !ef.AllowsNull) {
783
+ const formattedDefault = this.formatDefaultValue(ef.DefaultValue, ef.NeedsQuotes);
784
+ sOutput += `CASE @${ef.CodeName} WHEN '00000000-0000-0000-0000-000000000000' THEN ${formattedDefault} ELSE ISNULL(@${ef.CodeName}, ${formattedDefault}) END`;
785
+ }
786
+ else {
787
+ let sVal = '';
788
+ if (!prefix || prefix.length === 0) {
789
+ sVal = '[' + ef.Name + ']';
790
+ }
791
+ else {
792
+ sVal = prefix + ef.CodeName;
793
+ if (ef.HasDefaultValue && !ef.AllowsNull) {
794
+ const formattedDefault = this.formatDefaultValue(ef.DefaultValue, ef.NeedsQuotes);
795
+ if (ef.IsUniqueIdentifier) {
796
+ sVal = `CASE ${sVal} WHEN '00000000-0000-0000-0000-000000000000' THEN ${formattedDefault} ELSE ISNULL(${sVal}, ${formattedDefault}) END`;
797
+ }
798
+ else {
799
+ sVal = `ISNULL(${sVal}, ${formattedDefault})`;
800
+ }
801
+ }
802
+ }
803
+ sOutput += sVal;
804
+ }
805
+ }
806
+ return sOutput;
807
+ }
808
+ /** @inheritdoc */
809
+ generateUpdateFieldString(entityFields) {
810
+ let sOutput = '';
811
+ let isFirst = true;
812
+ for (const ef of entityFields) {
813
+ if (!ef.IsPrimaryKey &&
814
+ !ef.IsVirtual &&
815
+ ef.AllowUpdateAPI &&
816
+ !ef.AutoIncrement &&
817
+ !ef.IsSpecialDateField) {
818
+ if (!isFirst)
819
+ sOutput += ',\n ';
820
+ else
821
+ isFirst = false;
822
+ sOutput += `[${ef.Name}] = @${ef.CodeName}`;
823
+ }
824
+ }
825
+ return sOutput;
826
+ }
827
+ // ─── ROUTINE NAMING ──────────────────────────────────────────────────
828
+ /** @inheritdoc */
829
+ getCRUDRoutineName(entity, type) {
830
+ switch (type) {
831
+ case 'Create':
832
+ return entity.spCreate && entity.spCreate.length > 0 ? entity.spCreate : 'spCreate' + entity.BaseTableCodeName;
833
+ case 'Update':
834
+ return entity.spUpdate && entity.spUpdate.length > 0 ? entity.spUpdate : 'spUpdate' + entity.BaseTableCodeName;
835
+ case 'Delete':
836
+ return entity.spDelete && entity.spDelete.length > 0 ? entity.spDelete : 'spDelete' + entity.BaseTableCodeName;
837
+ }
838
+ }
839
+ // ─── SQL HEADERS ─────────────────────────────────────────────────────
840
+ /** @inheritdoc */
841
+ generateSQLFileHeader(entity, itemName) {
842
+ return `-----------------------------------------------------------------
843
+ -- SQL Code Generation
844
+ -- Entity: ${entity.Name}
845
+ -- Item: ${itemName}
846
+ --
847
+ -- This was generated by the MemberJunction CodeGen tool.
848
+ -- This file should NOT be edited by hand.
849
+ -----------------------------------------------------------------
850
+ `;
851
+ }
852
+ /** @inheritdoc */
853
+ generateAllEntitiesSQLFileHeader() {
854
+ return `-----------------------------------------------------------------
855
+ -- SQL Code Generation for Entities
856
+ --
857
+ -- This file contains the SQL code for the entities in the database
858
+ -- that are included in the API and have generated SQL elements like views and
859
+ -- stored procedures.
860
+ --
861
+ -- It is generated by the MemberJunction CodeGen tool.
862
+ -- It is not intended to be edited by hand.
863
+ -----------------------------------------------------------------
864
+ `;
865
+ }
866
+ // ─── UTILITY ─────────────────────────────────────────────────────────
867
+ /**
868
+ * Formats a raw default value string for embedding in generated SQL. Recognizes common
869
+ * SQL Server functions (`NEWID()`, `NEWSEQUENTIALID()`, `GETDATE()`, `GETUTCDATE()`,
870
+ * `SYSDATETIME()`, `SYSDATETIMEOFFSET()`, `CURRENT_TIMESTAMP`, `USER_NAME()`,
871
+ * `SUSER_NAME()`, `SYSTEM_USER`) and strips wrapping parentheses from them. For literal
872
+ * values, strips surrounding single quotes and re-applies them only if `needsQuotes`
873
+ * is true. Returns `'NULL'` for empty or whitespace-only inputs.
874
+ */
875
+ formatDefaultValue(defaultValue, needsQuotes) {
876
+ if (!defaultValue || defaultValue.trim().length === 0) {
877
+ return 'NULL';
878
+ }
879
+ let trimmedValue = defaultValue.trim();
880
+ const lowerValue = trimmedValue.toLowerCase();
881
+ const sqlFunctions = [
882
+ 'newid()', 'newsequentialid()', 'getdate()', 'getutcdate()',
883
+ 'sysdatetime()', 'sysdatetimeoffset()', 'current_timestamp',
884
+ 'user_name()', 'suser_name()', 'system_user'
885
+ ];
886
+ for (const func of sqlFunctions) {
887
+ if (lowerValue.includes(func)) {
888
+ if (trimmedValue.startsWith('(') && trimmedValue.endsWith(')')) {
889
+ trimmedValue = trimmedValue.substring(1, trimmedValue.length - 1);
890
+ }
891
+ return trimmedValue;
892
+ }
893
+ }
894
+ let cleanValue = trimmedValue;
895
+ if (cleanValue.startsWith("'") && cleanValue.endsWith("'")) {
896
+ cleanValue = cleanValue.substring(1, cleanValue.length - 1);
897
+ }
898
+ if (needsQuotes) {
899
+ return `'${cleanValue}'`;
900
+ }
901
+ return cleanValue;
902
+ }
903
+ /**
904
+ * Builds the four SQL fragments needed to work with an entity's primary key columns
905
+ * in cursor-based cascade operations:
906
+ *
907
+ * - `varDeclarations`: `@{prefix}{PK} TYPE` variable declarations for `DECLARE`.
908
+ * - `selectFields`: Bracketed column names for the cursor `SELECT`.
909
+ * - `fetchInto`: `@{prefix}{PK}` variable references for `FETCH INTO`.
910
+ * - `routineParams`: Named parameter assignments (`@PK = @{prefix}{PK}`) for `EXEC`.
911
+ *
912
+ * Supports composite primary keys by iterating all PK fields. The optional `prefix`
913
+ * defaults to `'Related'` to avoid variable name collisions in nested cursor blocks.
914
+ */
915
+ buildPrimaryKeyComponents(entity, prefix) {
916
+ let varDeclarations = '';
917
+ let selectFields = '';
918
+ let fetchInto = '';
919
+ let routineParams = '';
920
+ const varPrefix = prefix || 'Related';
921
+ for (const pk of entity.PrimaryKeys) {
922
+ if (varDeclarations !== '')
923
+ varDeclarations += ', ';
924
+ varDeclarations += `@${varPrefix}${pk.CodeName} ${pk.SQLFullType}`;
925
+ if (selectFields !== '')
926
+ selectFields += ', ';
927
+ selectFields += `[${pk.Name}]`;
928
+ if (fetchInto !== '')
929
+ fetchInto += ', ';
930
+ fetchInto += `@${varPrefix}${pk.CodeName}`;
931
+ if (routineParams !== '')
932
+ routineParams += ', ';
933
+ routineParams += `@${pk.CodeName} = @${varPrefix}${pk.CodeName}`;
934
+ }
935
+ return { varDeclarations, selectFields, fetchInto, routineParams };
936
+ }
937
+ // ─── DATABASE INTROSPECTION ──────────────────────────────────────────
938
+ /** @inheritdoc */
939
+ getViewDefinitionSQL(schema, viewName) {
940
+ return `SELECT OBJECT_DEFINITION(OBJECT_ID('[${schema}].[${viewName}]')) AS ViewDefinition`;
941
+ }
942
+ /**
943
+ * Returns a SQL query that retrieves the primary key index name for a table by joining
944
+ * `sys.indexes`, `sys.objects`, and `sys.key_constraints` and filtering on constraint
945
+ * type `'PK'`. The result column is named `IndexName` as expected by the orchestrator.
946
+ */
947
+ getPrimaryKeyIndexNameSQL(schema, tableName) {
948
+ return `SELECT
949
+ i.name AS IndexName
950
+ FROM
951
+ sys.indexes i
952
+ INNER JOIN
953
+ sys.objects o ON i.object_id = o.object_id
954
+ INNER JOIN
955
+ sys.key_constraints kc ON i.object_id = kc.parent_object_id AND
956
+ i.index_id = kc.unique_index_id
957
+ WHERE
958
+ o.name = '${tableName}' AND
959
+ o.schema_id = SCHEMA_ID('${schema}') AND
960
+ kc.type = 'PK'`;
961
+ }
962
+ /**
963
+ * Returns a SQL query that checks whether a column participates in a composite (multi-column)
964
+ * unique constraint. Queries `sys.indexes`, `sys.index_columns`, and `sys.columns` to find
965
+ * non-primary-key unique indexes that include the specified column AND have more than one
966
+ * column. Returns rows if the column is part of such a constraint; the orchestrator checks
967
+ * `recordset.length > 0`.
968
+ */
969
+ getCompositeUniqueConstraintCheckSQL(schema, tableName, columnName) {
970
+ return `SELECT i.index_id
971
+ FROM sys.indexes i
972
+ INNER JOIN sys.tables t ON i.object_id = t.object_id
973
+ INNER JOIN sys.schemas s ON t.schema_id = s.schema_id
974
+ WHERE i.is_unique = 1
975
+ AND i.is_primary_key = 0
976
+ AND s.name = '${schema}'
977
+ AND t.name = '${tableName}'
978
+ AND EXISTS (
979
+ SELECT 1
980
+ FROM sys.index_columns ic
981
+ INNER JOIN sys.columns c ON ic.object_id = c.object_id AND c.column_id = ic.column_id
982
+ WHERE ic.object_id = i.object_id
983
+ AND ic.index_id = i.index_id
984
+ AND c.name = '${columnName}'
985
+ )
986
+ AND (SELECT COUNT(*)
987
+ FROM sys.index_columns ic2
988
+ WHERE ic2.object_id = i.object_id
989
+ AND ic2.index_id = i.index_id) > 1`;
990
+ }
991
+ /** @inheritdoc */
992
+ getForeignKeyIndexExistsSQL(schema, tableName, indexName) {
993
+ return `SELECT 1
994
+ FROM sys.indexes
995
+ WHERE name = '${indexName}'
996
+ AND object_id = OBJECT_ID('[${schema}].[${tableName}]')`;
997
+ }
998
+ // ─── METADATA MANAGEMENT: STORED PROCEDURE CALLS ─────────────────
999
+ /**
1000
+ * Builds a SQL Server `EXEC` statement for invoking a stored procedure. When `paramNames`
1001
+ * are provided, generates named parameter syntax (`@ParamName=value`); otherwise uses
1002
+ * positional parameter values. Returns just `EXEC [schema].[routine]` if no params.
1003
+ */
1004
+ callRoutineSQL(schema, routineName, params, paramNames) {
1005
+ const qualifiedName = `[${schema}].[${routineName}]`;
1006
+ if (!params || params.length === 0) {
1007
+ return `EXEC ${qualifiedName}`;
1008
+ }
1009
+ const paramParts = params.map((val, i) => {
1010
+ if (paramNames && paramNames[i]) {
1011
+ return `@${paramNames[i]}=${val}`;
1012
+ }
1013
+ return val;
1014
+ });
1015
+ return `EXEC ${qualifiedName} ${paramParts.join(', ')}`;
1016
+ }
1017
+ // ─── METADATA MANAGEMENT: CONDITIONAL INSERT ─────────────────────
1018
+ /** @inheritdoc */
1019
+ conditionalInsertSQL(checkQuery, insertSQL) {
1020
+ return `IF NOT EXISTS (\n ${checkQuery}\n )\n BEGIN\n ${insertSQL}\n END`;
1021
+ }
1022
+ /** @inheritdoc */
1023
+ wrapInsertWithConflictGuard(conflictCheckSQL) {
1024
+ return {
1025
+ prefix: `IF NOT EXISTS (${conflictCheckSQL}) BEGIN`,
1026
+ suffix: `END`
1027
+ };
1028
+ }
1029
+ // ─── METADATA MANAGEMENT: DDL OPERATIONS ─────────────────────────
1030
+ /** @inheritdoc */
1031
+ addColumnSQL(schema, tableName, columnName, dataType, nullable, defaultExpression) {
1032
+ const nullClause = nullable ? 'NULL' : 'NOT NULL';
1033
+ const defaultClause = defaultExpression ? ` DEFAULT ${defaultExpression}` : '';
1034
+ return `ALTER TABLE [${schema}].[${tableName}] ADD ${columnName} ${dataType} ${nullClause}${defaultClause}`;
1035
+ }
1036
+ /** @inheritdoc */
1037
+ alterColumnTypeAndNullabilitySQL(schema, tableName, columnName, dataType, nullable) {
1038
+ const nullClause = nullable ? 'NULL' : 'NOT NULL';
1039
+ return `ALTER TABLE [${schema}].[${tableName}] ALTER COLUMN ${columnName} ${dataType} ${nullClause}`;
1040
+ }
1041
+ /** @inheritdoc */
1042
+ addDefaultConstraintSQL(schema, tableName, columnName, defaultExpression) {
1043
+ const constraintName = `DF_${schema}_${CodeNameFromString(tableName)}_${columnName}`;
1044
+ return `ALTER TABLE [${schema}].[${tableName}] ADD CONSTRAINT ${constraintName} DEFAULT ${defaultExpression} FOR [${columnName}]`;
1045
+ }
1046
+ /**
1047
+ * Generates SQL to dynamically find and drop the default constraint on a column.
1048
+ * Unlike PostgreSQL's simple `ALTER COLUMN DROP DEFAULT`, SQL Server requires looking
1049
+ * up the constraint name from `sys.default_constraints` joined through `sys.tables`,
1050
+ * `sys.schemas`, and `sys.columns`, then executing a dynamic `ALTER TABLE DROP CONSTRAINT`
1051
+ * via `EXEC()`. The generated SQL is safe to run even if no default constraint exists
1052
+ * (guarded by `IF @constraintName IS NOT NULL`).
1053
+ */
1054
+ dropDefaultConstraintSQL(schema, tableName, columnName) {
1055
+ return `DECLARE @constraintName NVARCHAR(255);
1056
+
1057
+ SELECT @constraintName = d.name
1058
+ FROM sys.tables t
1059
+ JOIN sys.schemas s ON t.schema_id = s.schema_id
1060
+ JOIN sys.columns c ON t.object_id = c.object_id
1061
+ JOIN sys.default_constraints d ON c.default_object_id = d.object_id
1062
+ WHERE s.name = '${schema}'
1063
+ AND t.name = '${tableName}'
1064
+ AND c.name = '${columnName}';
1065
+
1066
+ IF @constraintName IS NOT NULL
1067
+ BEGIN
1068
+ EXEC('ALTER TABLE [${schema}].[${tableName}] DROP CONSTRAINT ' + @constraintName);
1069
+ END`;
1070
+ }
1071
+ /** @inheritdoc */
1072
+ dropObjectSQL(objectType, schema, name) {
1073
+ const typeCode = objectType === 'PROCEDURE' ? 'P' : objectType === 'VIEW' ? 'V' : 'FN';
1074
+ return `IF OBJECT_ID('[${schema}].[${name}]', '${typeCode}') IS NOT NULL\n DROP ${objectType} [${schema}].[${name}]`;
1075
+ }
1076
+ // ─── METADATA MANAGEMENT: VIEW INTROSPECTION ─────────────────────
1077
+ /** @inheritdoc */
1078
+ getViewExistsSQL() {
1079
+ return `SELECT 1 FROM INFORMATION_SCHEMA.VIEWS WHERE TABLE_NAME = @ViewName AND TABLE_SCHEMA = @SchemaName`;
1080
+ }
1081
+ /**
1082
+ * Returns a SQL query that retrieves column metadata for a view by joining `sys.columns`,
1083
+ * `sys.types`, `sys.views`, and `sys.schemas`. Returns columns named `FieldName`, `Type`,
1084
+ * `Length`, `Precision`, `Scale`, and `AllowsNull`, ordered by `column_id` to preserve
1085
+ * the original column definition order.
1086
+ */
1087
+ getViewColumnsSQL(schema, viewName) {
1088
+ return `SELECT
1089
+ c.name AS FieldName,
1090
+ t.name AS Type,
1091
+ c.max_length AS Length,
1092
+ c.precision AS Precision,
1093
+ c.scale AS Scale,
1094
+ c.is_nullable AS AllowsNull
1095
+ FROM
1096
+ sys.columns c
1097
+ INNER JOIN
1098
+ sys.types t ON c.user_type_id = t.user_type_id
1099
+ INNER JOIN
1100
+ sys.views v ON c.object_id = v.object_id
1101
+ INNER JOIN
1102
+ sys.schemas s ON v.schema_id = s.schema_id
1103
+ WHERE
1104
+ s.name = '${schema}'
1105
+ AND v.name = '${viewName}'
1106
+ ORDER BY
1107
+ c.column_id`;
1108
+ }
1109
+ // ─── METADATA MANAGEMENT: TYPE SYSTEM ────────────────────────────
1110
+ /** @inheritdoc */
1111
+ get TimestampType() {
1112
+ return 'DATETIMEOFFSET';
1113
+ }
1114
+ /** @inheritdoc */
1115
+ compareDataTypes(reported, expected) {
1116
+ return reported.trim().toLowerCase() === expected.trim().toLowerCase();
1117
+ }
1118
+ // ─── METADATA MANAGEMENT: PLATFORM CONFIGURATION ─────────────────
1119
+ /** @inheritdoc */
1120
+ getSystemSchemasToExclude() {
1121
+ return [];
1122
+ }
1123
+ /** @inheritdoc */
1124
+ get NeedsViewRefresh() {
1125
+ return true;
1126
+ }
1127
+ /** @inheritdoc */
1128
+ generateViewRefreshSQL(schema, viewName) {
1129
+ return `EXEC sp_refreshview '${schema}.${viewName}';`;
1130
+ }
1131
+ /** @inheritdoc */
1132
+ generateViewTestQuerySQL(schema, viewName) {
1133
+ return `SELECT TOP 1 * FROM [${schema}].[${viewName}]`;
1134
+ }
1135
+ /** @inheritdoc */
1136
+ get NeedsVirtualFieldNullabilityFix() {
1137
+ return false;
1138
+ }
1139
+ // ─── METADATA MANAGEMENT: SQL QUOTING ────────────────────────────
1140
+ /** @inheritdoc */
1141
+ quoteSQLForExecution(sql) {
1142
+ return sql;
1143
+ }
1144
+ // ─── METADATA MANAGEMENT: DEFAULT VALUE PARSING ──────────────────
1145
+ /**
1146
+ * Parses a raw SQL Server column default value from the system catalog into a clean form.
1147
+ * SQL Server wraps defaults in parentheses (e.g., `(getdate())`, `((1))`, `(N'foo')`),
1148
+ * so this method applies three successive stripping passes:
1149
+ *
1150
+ * 1. Strips one layer of wrapping parentheses.
1151
+ * 2. Strips the `N'...'` Unicode string prefix.
1152
+ * 3. Strips surrounding single quotes from plain string literals.
1153
+ *
1154
+ * Returns `null` if the input is null or undefined.
1155
+ */
1156
+ parseColumnDefaultValue(sqlDefaultValue) {
1157
+ if (sqlDefaultValue === null || sqlDefaultValue === undefined) {
1158
+ return null;
1159
+ }
1160
+ let result = this.stripWrappingParentheses(sqlDefaultValue);
1161
+ result = this.stripNPrefixQuotes(result);
1162
+ result = this.stripSingleQuotes(result);
1163
+ return result;
1164
+ }
1165
+ /**
1166
+ * Strips wrapping parentheses from a SQL Server default value.
1167
+ * Example: `(getdate())` becomes `getdate()`, `((1))` becomes `(1)`.
1168
+ */
1169
+ stripWrappingParentheses(value) {
1170
+ if (value.startsWith('(') && value.endsWith(')')) {
1171
+ return value.substring(1, value.length - 1);
1172
+ }
1173
+ return value;
1174
+ }
1175
+ /**
1176
+ * Strips the N'' prefix from a SQL Server Unicode string literal.
1177
+ * Example: `N'SomeValue'` becomes `SomeValue`.
1178
+ */
1179
+ stripNPrefixQuotes(value) {
1180
+ if (value.toUpperCase().startsWith("N'") && value.endsWith("'")) {
1181
+ return value.substring(2, value.length - 1);
1182
+ }
1183
+ return value;
1184
+ }
1185
+ /**
1186
+ * Strips surrounding single quotes from a value.
1187
+ * Example: `'SomeValue'` becomes `SomeValue`.
1188
+ */
1189
+ stripSingleQuotes(value) {
1190
+ if (value.startsWith("'") && value.endsWith("'")) {
1191
+ return value.substring(1, value.length - 1);
1192
+ }
1193
+ return value;
1194
+ }
1195
+ // ─── METADATA MANAGEMENT: COMPLEX SQL GENERATION ─────────────────
1196
+ /**
1197
+ * Generates the full SQL query to retrieve entity fields that exist in the database schema
1198
+ * but are not yet registered in MJ metadata. The query is assembled from three parts:
1199
+ *
1200
+ * 1. **Temp table materialization**: Copies `vwForeignKeys`, `vwTablePrimaryKeys`, and
1201
+ * `vwTableUniqueKeys` into temp tables so SQL Server can build real statistics instead
1202
+ * of expanding nested view-on-view joins with bad cardinality estimates.
1203
+ * 2. **Main CTE query**: Uses `MaxSequences` CTE to calculate proper field ordering and
1204
+ * `NumberedRows` CTE to deduplicate results, joining against the temp tables for FK,
1205
+ * PK, and unique key detection.
1206
+ * 3. **Cleanup**: Drops the temp tables.
1207
+ */
1208
+ getPendingEntityFieldsSQL(mjCoreSchema) {
1209
+ return this.buildPendingFieldsTempTables(mjCoreSchema) +
1210
+ this.buildPendingFieldsMainQuery(mjCoreSchema) +
1211
+ this.buildPendingFieldsCleanup();
1212
+ }
1213
+ /**
1214
+ * Builds the temp table materialization statements for the pending entity fields query.
1215
+ * Materializes system DMV views into temp tables for optimal SQL Server query plan statistics.
1216
+ */
1217
+ buildPendingFieldsTempTables(schema) {
1218
+ return `
1219
+ -- Materialize system DMV views into temp tables so SQL Server gets real statistics
1220
+ -- instead of expanding nested view-on-view joins with bad cardinality estimates
1221
+ -- Drop first in case a prior run on this connection left them behind
1222
+ IF OBJECT_ID('tempdb..#__mj__CodeGen__vwForeignKeys') IS NOT NULL DROP TABLE #__mj__CodeGen__vwForeignKeys;
1223
+ IF OBJECT_ID('tempdb..#__mj__CodeGen__vwTablePrimaryKeys') IS NOT NULL DROP TABLE #__mj__CodeGen__vwTablePrimaryKeys;
1224
+ IF OBJECT_ID('tempdb..#__mj__CodeGen__vwTableUniqueKeys') IS NOT NULL DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
1225
+
1226
+ SELECT [column], [table], [schema_name], referenced_table, referenced_column, [referenced_schema]
1227
+ INTO #__mj__CodeGen__vwForeignKeys
1228
+ FROM [${schema}].[vwForeignKeys];
1229
+
1230
+ SELECT TableName, ColumnName, SchemaName
1231
+ INTO #__mj__CodeGen__vwTablePrimaryKeys
1232
+ FROM [${schema}].[vwTablePrimaryKeys];
1233
+
1234
+ SELECT TableName, ColumnName, SchemaName
1235
+ INTO #__mj__CodeGen__vwTableUniqueKeys
1236
+ FROM [${schema}].[vwTableUniqueKeys];
1237
+ `;
1238
+ }
1239
+ /**
1240
+ * Builds the main CTE query for finding pending entity fields.
1241
+ * Uses MaxSequences CTE to calculate proper field ordering and NumberedRows
1242
+ * CTE to deduplicate results.
1243
+ */
1244
+ buildPendingFieldsMainQuery(schema) {
1245
+ return `WITH MaxSequences AS (
1246
+ SELECT
1247
+ EntityID,
1248
+ ISNULL(MAX(Sequence), 0) AS MaxSequence
1249
+ FROM
1250
+ [${schema}].[EntityField]
1251
+ GROUP BY
1252
+ EntityID
1253
+ ),
1254
+ NumberedRows AS (
1255
+ SELECT
1256
+ sf.EntityID,
1257
+ ISNULL(ms.MaxSequence, 0) + 100000 + sf.Sequence AS Sequence,
1258
+ sf.FieldName,
1259
+ sf.Description,
1260
+ sf.Type,
1261
+ sf.Length,
1262
+ sf.Precision,
1263
+ sf.Scale,
1264
+ sf.AllowsNull,
1265
+ sf.DefaultValue,
1266
+ sf.AutoIncrement,
1267
+ IIF(sf.IsVirtual = 1, 0, IIF(sf.FieldName = '${EntityInfo.CreatedAtFieldName}' OR
1268
+ sf.FieldName = '${EntityInfo.UpdatedAtFieldName}' OR
1269
+ sf.FieldName = '${EntityInfo.DeletedAtFieldName}' OR
1270
+ pk.ColumnName IS NOT NULL, 0, 1)) AllowUpdateAPI,
1271
+ sf.IsVirtual,
1272
+ e.RelationshipDefaultDisplayType,
1273
+ e.Name EntityName,
1274
+ re.ID RelatedEntityID,
1275
+ fk.referenced_column RelatedEntityFieldName,
1276
+ IIF(sf.FieldName = 'Name', 1, 0) IsNameField,
1277
+ IsPrimaryKey = CASE WHEN pk.ColumnName IS NOT NULL THEN 1 ELSE 0 END,
1278
+ IsUnique = CASE
1279
+ WHEN pk.ColumnName IS NOT NULL THEN 1
1280
+ WHEN uk.ColumnName IS NOT NULL THEN 1
1281
+ ELSE 0
1282
+ END,
1283
+ ROW_NUMBER() OVER (PARTITION BY sf.EntityID, sf.FieldName ORDER BY (SELECT NULL)) AS rn
1284
+ FROM
1285
+ [${schema}].[vwSQLColumnsAndEntityFields] sf
1286
+ LEFT OUTER JOIN
1287
+ MaxSequences ms ON sf.EntityID = ms.EntityID
1288
+ LEFT OUTER JOIN
1289
+ [${schema}].[Entity] e ON sf.EntityID = e.ID
1290
+ LEFT OUTER JOIN
1291
+ #__mj__CodeGen__vwForeignKeys fk
1292
+ ON sf.FieldName = fk.[column] AND e.BaseTable = fk.[table] AND e.SchemaName = fk.[schema_name]
1293
+ LEFT OUTER JOIN
1294
+ [${schema}].[Entity] re ON re.BaseTable = fk.referenced_table AND re.SchemaName = fk.[referenced_schema]
1295
+ LEFT OUTER JOIN
1296
+ #__mj__CodeGen__vwTablePrimaryKeys pk
1297
+ ON e.BaseTable = pk.TableName AND sf.FieldName = pk.ColumnName AND e.SchemaName = pk.SchemaName
1298
+ LEFT OUTER JOIN
1299
+ #__mj__CodeGen__vwTableUniqueKeys uk
1300
+ ON e.BaseTable = uk.TableName AND sf.FieldName = uk.ColumnName AND e.SchemaName = uk.SchemaName
1301
+ WHERE
1302
+ EntityFieldID IS NULL
1303
+ )
1304
+ SELECT *
1305
+ FROM NumberedRows
1306
+ WHERE rn = 1
1307
+ ORDER BY EntityID, Sequence;
1308
+ `;
1309
+ }
1310
+ /**
1311
+ * Builds the cleanup statements to drop temp tables after the pending fields query.
1312
+ */
1313
+ buildPendingFieldsCleanup() {
1314
+ return `
1315
+ DROP TABLE #__mj__CodeGen__vwForeignKeys;
1316
+ DROP TABLE #__mj__CodeGen__vwTablePrimaryKeys;
1317
+ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
1318
+ `;
1319
+ }
1320
+ /** @inheritdoc */
1321
+ getCheckConstraintsSchemaFilter(excludeSchemas) {
1322
+ if (!excludeSchemas || excludeSchemas.length === 0) {
1323
+ return '';
1324
+ }
1325
+ const quotedSchemas = excludeSchemas.map(s => `'${s}'`).join(',');
1326
+ return ` WHERE SchemaName NOT IN (${quotedSchemas})`;
1327
+ }
1328
+ /** @inheritdoc */
1329
+ getEntitiesWithMissingBaseTablesFilter() {
1330
+ return ` WHERE VirtualEntity=0`;
1331
+ }
1332
+ /** @inheritdoc */
1333
+ getFixVirtualFieldNullabilitySQL(_mjCoreSchema) {
1334
+ return '';
1335
+ }
1336
+ // ─── METADATA MANAGEMENT: SQL FILE EXECUTION ─────────────────────
1337
+ /**
1338
+ * Executes a SQL file against SQL Server using the `sqlcmd` command-line utility. Reads
1339
+ * connection details (server, port, instance, user, password, database) from the shared
1340
+ * `sqlConfig` object. On Windows, converts file paths containing spaces to 8.3 short
1341
+ * format to avoid quoting issues with `sqlcmd`. Spawns the process with `QUOTED_IDENTIFIER`
1342
+ * enabled (`-I`) and severity threshold 17 (`-V 17`). Optionally adds `-C` for
1343
+ * `trustServerCertificate`. Returns `true` on successful execution, `false` on failure.
1344
+ */
1345
+ async executeSQLFileViaShell(filePath) {
1346
+ try {
1347
+ this.validateSqlConfig();
1348
+ const serverSpec = this.buildServerSpec();
1349
+ const absoluteFilePath = this.resolveAndShortPathConvert(filePath);
1350
+ const args = this.buildSqlcmdArgs(serverSpec, absoluteFilePath);
1351
+ return await this.spawnSqlcmd(args, filePath);
1352
+ }
1353
+ catch (e) {
1354
+ this.logSqlcmdError(e);
1355
+ return false;
1356
+ }
1357
+ }
1358
+ /**
1359
+ * Validates that required SQL Server connection configuration is present.
1360
+ */
1361
+ validateSqlConfig() {
1362
+ if (sqlConfig.user === undefined || sqlConfig.password === undefined || sqlConfig.database === undefined) {
1363
+ throw new Error('SQL Server user, password, and database must be provided in the configuration');
1364
+ }
1365
+ }
1366
+ /**
1367
+ * Builds the server specification string for sqlcmd (server[,port][\instance]).
1368
+ */
1369
+ buildServerSpec() {
1370
+ let serverSpec = sqlConfig.server;
1371
+ if (sqlConfig.port) {
1372
+ serverSpec += `,${sqlConfig.port}`;
1373
+ }
1374
+ if (sqlConfig.options?.instanceName) {
1375
+ serverSpec += `\\${sqlConfig.options.instanceName}`;
1376
+ }
1377
+ return serverSpec;
1378
+ }
1379
+ /**
1380
+ * Resolves the file path to absolute and converts to 8.3 short path on Windows if needed.
1381
+ */
1382
+ resolveAndShortPathConvert(filePath) {
1383
+ const cwd = path.resolve(process.cwd());
1384
+ let absoluteFilePath = path.resolve(cwd, filePath);
1385
+ const isWindows = process.platform === 'win32';
1386
+ if (isWindows && absoluteFilePath.includes(' ')) {
1387
+ absoluteFilePath = this.tryConvertToShortPath(absoluteFilePath);
1388
+ }
1389
+ return absoluteFilePath;
1390
+ }
1391
+ /**
1392
+ * Attempts to convert a Windows path to 8.3 short format to avoid quoting issues.
1393
+ */
1394
+ tryConvertToShortPath(absoluteFilePath) {
1395
+ try {
1396
+ const result = execSync(`for %I in ("${absoluteFilePath}") do @echo %~sI`, {
1397
+ encoding: 'utf8',
1398
+ shell: 'cmd.exe'
1399
+ }).trim();
1400
+ if (result && !result.includes('ERROR') && !result.includes('%~sI')) {
1401
+ logIf(configInfo.verboseOutput, `Converted path to short format: ${result}`);
1402
+ return result;
1403
+ }
1404
+ }
1405
+ catch (e) {
1406
+ logIf(configInfo.verboseOutput, `Could not convert to short path, using original: ${e}`);
1407
+ }
1408
+ return absoluteFilePath;
1409
+ }
1410
+ /**
1411
+ * Builds the argument array for the sqlcmd CLI tool.
1412
+ */
1413
+ buildSqlcmdArgs(serverSpec, absoluteFilePath) {
1414
+ const args = [
1415
+ '-S', serverSpec,
1416
+ '-U', sqlConfig.user,
1417
+ '-P', sqlConfig.password,
1418
+ '-d', sqlConfig.database,
1419
+ '-I', // Enable QUOTED_IDENTIFIER
1420
+ '-V', '17', // Only fail on severity >= 17
1421
+ '-i', absoluteFilePath
1422
+ ];
1423
+ if (sqlConfig.options?.trustServerCertificate) {
1424
+ args.push('-C');
1425
+ }
1426
+ return args;
1427
+ }
1428
+ /**
1429
+ * Spawns the sqlcmd process and waits for completion.
1430
+ */
1431
+ async spawnSqlcmd(args, filePath) {
1432
+ logIf(configInfo.verboseOutput, `Executing SQL file: ${filePath} as ${sqlConfig.user}@${sqlConfig.server}:${sqlConfig.port}/${sqlConfig.database}`);
1433
+ const isWindows = process.platform === 'win32';
1434
+ const sqlcmdCommand = isWindows ? 'sqlcmd.exe' : 'sqlcmd';
1435
+ const spawnOptions = { shell: false };
1436
+ if (isWindows) {
1437
+ spawnOptions['windowsVerbatimArguments'] = true;
1438
+ }
1439
+ const result = await new Promise((resolve, reject) => {
1440
+ const child = spawn(sqlcmdCommand, args, spawnOptions);
1441
+ let stdout = '';
1442
+ let stderr = '';
1443
+ child.stdout?.on('data', (data) => { stdout += data.toString(); });
1444
+ child.stderr?.on('data', (data) => { stderr += data.toString(); });
1445
+ child.on('error', (error) => { reject(error); });
1446
+ child.on('close', (code) => {
1447
+ if (code === 0) {
1448
+ resolve({ stdout, stderr });
1449
+ }
1450
+ else {
1451
+ const error = new Error(`sqlcmd exited with code ${code}`);
1452
+ Object.assign(error, { stdout, stderr, code });
1453
+ reject(error);
1454
+ }
1455
+ });
1456
+ });
1457
+ if (result.stdout && result.stdout.trim().length > 0) {
1458
+ logWarning(`SQL Server message: ${result.stdout.trim()}`);
1459
+ }
1460
+ if (result.stderr && result.stderr.trim().length > 0) {
1461
+ logWarning(`SQL Server stderr: ${result.stderr.trim()}`);
1462
+ }
1463
+ return true;
1464
+ }
1465
+ /**
1466
+ * Logs a sqlcmd execution error, masking the password in the output.
1467
+ */
1468
+ logSqlcmdError(e) {
1469
+ const errRecord = e;
1470
+ let message = (e instanceof Error) ? e.message : String(e);
1471
+ if (errRecord['stdout']) {
1472
+ message += `\n SQL Server message: ${errRecord['stdout']}`;
1473
+ }
1474
+ if (errRecord['stderr']) {
1475
+ message += `\n SQL Server error: ${errRecord['stderr']}`;
1476
+ }
1477
+ const errorMessage = sqlConfig.password
1478
+ ? message.replace(sqlConfig.password, 'XXXXX')
1479
+ : message;
1480
+ logError('Error executing batch SQL file: ' + errorMessage);
1481
+ }
1482
+ }
1483
+ //# sourceMappingURL=SQLServerCodeGenProvider.js.map