@memberjunction/codegen-lib 5.4.1 → 5.6.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
@@ -1,11 +1,12 @@
1
- import sql from 'mssql';
2
- import { configInfo, currentWorkingDirectory, getSettingValue, mj_core_schema, outputDir } from '../Config/config.js';
3
- import { CodeNameFromString, EntityInfo, ExtractActualDefaultValue, LogError, LogStatus, Metadata, SeverityType } from "@memberjunction/core";
1
+ import { CodeGenDatabaseProvider } from './codeGenDatabaseProvider.js';
2
+ import { SQLServerCodeGenProvider } from './providers/sqlserver/SQLServerCodeGenProvider.js';
3
+ import { configInfo, currentWorkingDirectory, dbType, getSettingValue, mj_core_schema, outputDir } from '../Config/config.js';
4
+ import { EntityInfo, ExtractActualDefaultValue, LogError, LogStatus, Metadata, SeverityType } from "@memberjunction/core";
4
5
  import { logError, logMessage, logStatus } from "../Misc/status_logging.js";
5
6
  import { SQLUtilityBase } from "./sql.js";
6
7
  import { AdvancedGeneration } from "../Misc/advanced_generation.js";
7
8
  import { SQLParser } from "@memberjunction/core-entities-server";
8
- import { convertCamelCaseToHaveSpaces, generatePluralName, MJGlobal, stripTrailingChars } from "@memberjunction/global";
9
+ import { convertCamelCaseToHaveSpaces, generatePluralName, MJGlobal, stripTrailingChars, UUIDsEqual } from "@memberjunction/global";
9
10
  import { v4 as uuidv4 } from 'uuid';
10
11
  import * as fs from 'fs';
11
12
  import path from 'path';
@@ -31,8 +32,138 @@ export class ValidatorResult {
31
32
  }
32
33
  export class ManageMetadataBase {
33
34
  constructor() {
35
+ // ─── Database Provider Infrastructure ─────────────────────────────
36
+ // All platform-specific SQL generation is delegated to the CodeGenDatabaseProvider.
37
+ // The provider is lazily initialized from the dbType() config on first access.
38
+ this._dbProvider = null;
39
+ // ─── End Dialect Infrastructure ───────────────────────────────────
34
40
  this._sqlUtilityObject = MJGlobal.Instance.ClassFactory.CreateInstance(SQLUtilityBase);
35
41
  }
42
+ /**
43
+ * Returns the CodeGenDatabaseProvider for the current database platform.
44
+ * Lazily initialized from dbType() configuration.
45
+ */
46
+ get dbProvider() {
47
+ if (!this._dbProvider) {
48
+ const platform = dbType();
49
+ if (platform === 'postgresql') {
50
+ const pgProvider = MJGlobal.Instance.ClassFactory.CreateInstance(CodeGenDatabaseProvider, 'PostgreSQLCodeGenProvider');
51
+ if (pgProvider) {
52
+ this._dbProvider = pgProvider;
53
+ }
54
+ else {
55
+ throw new Error('PostgreSQL CodeGen provider not found. Ensure @memberjunction/postgresql-dataprovider ' +
56
+ 'is installed and its CodeGen provider is registered before running CodeGen.');
57
+ }
58
+ }
59
+ else {
60
+ this._dbProvider = new SQLServerCodeGenProvider();
61
+ }
62
+ }
63
+ return this._dbProvider;
64
+ }
65
+ /**
66
+ * Returns the SQLDialect for the current database platform, derived from the provider.
67
+ */
68
+ get dialect() {
69
+ return this.dbProvider.Dialect;
70
+ }
71
+ /**
72
+ * Quotes a database identifier (column, table, etc.).
73
+ * SQL Server: [name], PostgreSQL: "name"
74
+ */
75
+ qi(name) {
76
+ return this.dialect.QuoteIdentifier(name);
77
+ }
78
+ /**
79
+ * Produces a schema-qualified object reference.
80
+ * SQL Server: [schema].[object], PostgreSQL: schema."object"
81
+ */
82
+ qs(schema, object) {
83
+ return this.dialect.QuoteSchema(schema, object);
84
+ }
85
+ /**
86
+ * Returns the current UTC timestamp expression.
87
+ * SQL Server: GETUTCDATE(), PostgreSQL: NOW() AT TIME ZONE 'UTC'
88
+ */
89
+ utcNow() {
90
+ return this.dialect.CurrentTimestampUTC();
91
+ }
92
+ /**
93
+ * Returns a boolean literal for the platform.
94
+ * SQL Server: 1/0, PostgreSQL: true/false
95
+ */
96
+ boolLit(value) {
97
+ return this.dialect.BooleanLiteral(value);
98
+ }
99
+ /**
100
+ * Quotes mixed-case identifiers in a SQL string for the current platform.
101
+ * Delegates to the database provider's quoteSQLForExecution method.
102
+ */
103
+ qsql(sql) {
104
+ return this.dbProvider.quoteSQLForExecution(sql);
105
+ }
106
+ /**
107
+ * Executes a SQL query with automatic identifier quoting for the current platform.
108
+ */
109
+ async runQuery(pool, sql) {
110
+ return pool.query(this.qsql(sql));
111
+ }
112
+ /**
113
+ * Executes a parameterized SQL query with automatic identifier quoting for the current platform.
114
+ */
115
+ async runQueryWithParams(pool, sql, params) {
116
+ return pool.queryWithParams(this.qsql(sql), params);
117
+ }
118
+ /**
119
+ * Wraps a SELECT query with a row limit.
120
+ * SQL Server: SELECT TOP N ... , PostgreSQL: SELECT ... LIMIT N
121
+ */
122
+ selectTop(n, selectBody, fromAndWhere, orderBy) {
123
+ const limit = this.dialect.LimitClause(n);
124
+ const orderClause = orderBy ? ` ORDER BY ${orderBy}` : '';
125
+ if (limit.prefix) {
126
+ // SQL Server: SELECT TOP N columns FROM ...
127
+ return `SELECT ${limit.prefix} ${selectBody} ${fromAndWhere}${orderClause}`;
128
+ }
129
+ // PostgreSQL: SELECT columns FROM ... LIMIT N
130
+ return `SELECT ${selectBody} ${fromAndWhere}${orderClause} ${limit.suffix}`;
131
+ }
132
+ /**
133
+ * Returns ISNULL/COALESCE expression.
134
+ * Both platforms support COALESCE, but SQL Server also has ISNULL.
135
+ */
136
+ coalesce(expr, fallback) {
137
+ return this.dialect.IsNull(expr, fallback);
138
+ }
139
+ /**
140
+ * Returns an IIF/CASE expression.
141
+ * SQL Server: IIF(cond, t, f), PostgreSQL: CASE WHEN cond THEN t ELSE f END
142
+ */
143
+ iif(condition, trueVal, falseVal) {
144
+ return this.dialect.IIF(condition, trueVal, falseVal);
145
+ }
146
+ /**
147
+ * Returns the timestamp column type name for this platform.
148
+ * Delegates to the database provider.
149
+ */
150
+ get timestampType() {
151
+ return this.dbProvider.TimestampType;
152
+ }
153
+ /**
154
+ * Generates a conditional existence check + DROP statement.
155
+ * Delegates to the database provider.
156
+ */
157
+ dropIfExists(objectType, schema, name) {
158
+ return this.dbProvider.dropObjectSQL(objectType, schema, name);
159
+ }
160
+ /**
161
+ * Generates SQL for conditional INSERT (IF NOT EXISTS pattern).
162
+ * Delegates to the database provider.
163
+ */
164
+ conditionalInsert(checkQuery, insertSQL) {
165
+ return this.dbProvider.conditionalInsertSQL(checkQuery, insertSQL);
166
+ }
36
167
  get SQLUtilityObject() {
37
168
  return this._sqlUtilityObject;
38
169
  }
@@ -200,16 +331,11 @@ export class ManageMetadataBase {
200
331
  for (const rel of relationships) {
201
332
  try {
202
333
  // Look up the parent entity — try by Name first, then by BaseTable within the given schema
203
- const parentResult = await pool.request()
204
- .input('ParentName', rel.ParentEntity)
205
- .input('SchemaName', rel.SchemaName || null)
206
- .query(`
207
- SELECT TOP 1 ID, Name
208
- FROM [${schema}].vwEntities
334
+ const parentResult = await this.runQueryWithParams(pool, `
335
+ ${this.selectTop(1, 'ID, Name', `FROM ${this.qs(schema, 'vwEntities')}
209
336
  WHERE Name = @ParentName
210
- OR (BaseTable = @ParentName AND (@SchemaName IS NULL OR SchemaName = @SchemaName))
211
- ORDER BY CASE WHEN Name = @ParentName THEN 0 ELSE 1 END
212
- `);
337
+ OR (BaseTable = @ParentName AND (@SchemaName IS NULL OR SchemaName = @SchemaName))`, 'CASE WHEN Name = @ParentName THEN 0 ELSE 1 END')}
338
+ `, { 'ParentName': rel.ParentEntity, 'SchemaName': rel.SchemaName || null });
213
339
  if (parentResult.recordset.length === 0) {
214
340
  logError(` > IS-A config: parent entity "${rel.ParentEntity}" not found — skipping`);
215
341
  continue;
@@ -217,16 +343,11 @@ export class ManageMetadataBase {
217
343
  const parentId = parentResult.recordset[0].ID;
218
344
  const parentName = parentResult.recordset[0].Name;
219
345
  // Look up the child entity — same strategy
220
- const childResult = await pool.request()
221
- .input('ChildName', rel.ChildEntity)
222
- .input('SchemaName', rel.SchemaName || null)
223
- .query(`
224
- SELECT TOP 1 ID, Name, ParentID
225
- FROM [${schema}].vwEntities
346
+ const childResult = await this.runQueryWithParams(pool, `
347
+ ${this.selectTop(1, 'ID, Name, ParentID', `FROM ${this.qs(schema, 'vwEntities')}
226
348
  WHERE Name = @ChildName
227
- OR (BaseTable = @ChildName AND (@SchemaName IS NULL OR SchemaName = @SchemaName))
228
- ORDER BY CASE WHEN Name = @ChildName THEN 0 ELSE 1 END
229
- `);
349
+ OR (BaseTable = @ChildName AND (@SchemaName IS NULL OR SchemaName = @SchemaName))`, 'CASE WHEN Name = @ChildName THEN 0 ELSE 1 END')}
350
+ `, { 'ChildName': rel.ChildEntity, 'SchemaName': rel.SchemaName || null });
230
351
  if (childResult.recordset.length === 0) {
231
352
  logError(` > IS-A config: child entity "${rel.ChildEntity}" not found — skipping`);
232
353
  continue;
@@ -235,15 +356,12 @@ export class ManageMetadataBase {
235
356
  const childName = childResult.recordset[0].Name;
236
357
  const existingParentId = childResult.recordset[0].ParentID;
237
358
  // Skip if already set correctly
238
- if (existingParentId === parentId) {
359
+ if (UUIDsEqual(existingParentId, parentId)) {
239
360
  logStatus(` > IS-A: "${childName}" already has ParentID set to "${parentName}", skipping`);
240
361
  }
241
362
  else {
242
363
  // Set ParentID on the child entity
243
- await pool.request()
244
- .input('ParentID', parentId)
245
- .input('ChildID', childId)
246
- .query(`UPDATE [${schema}].Entity SET ParentID = @ParentID WHERE ID = @ChildID`);
364
+ await this.runQueryWithParams(pool, `UPDATE ${this.qs(schema, 'Entity')} SET ParentID = @ParentID WHERE ID = @ChildID`, { 'ParentID': parentId, 'ChildID': childId });
247
365
  if (existingParentId) {
248
366
  logStatus(` > IS-A: Updated "${childName}" ParentID from previous value to "${parentName}"`);
249
367
  }
@@ -286,14 +404,10 @@ export class ManageMetadataBase {
286
404
  continue;
287
405
  }
288
406
  // Look up the entity by BaseTable + SchemaName
289
- const entityResult = await pool.request()
290
- .input('BaseTable', ec.BaseTable)
291
- .input('SchemaName', ec.SchemaName)
292
- .query(`
293
- SELECT TOP 1 ID, Name
294
- FROM [${schema}].vwEntities
295
- WHERE BaseTable = @BaseTable AND SchemaName = @SchemaName
296
- `);
407
+ const entityResult = await this.runQueryWithParams(pool, `
408
+ ${this.selectTop(1, 'ID, Name', `FROM ${this.qs(schema, 'vwEntities')}
409
+ WHERE BaseTable = @BaseTable AND SchemaName = @SchemaName`)}
410
+ `, { 'BaseTable': ec.BaseTable, 'SchemaName': ec.SchemaName });
297
411
  if (entityResult.recordset.length === 0) {
298
412
  logError(` > Entities config: entity for "${ec.SchemaName}.${ec.BaseTable}" not found — skipping`);
299
413
  continue;
@@ -301,16 +415,16 @@ export class ManageMetadataBase {
301
415
  const entityId = entityResult.recordset[0].ID;
302
416
  const entityName = entityResult.recordset[0].Name;
303
417
  // Build a parameterized UPDATE with one SET clause per attribute
304
- const request = pool.request().input('EntityID', entityId);
418
+ const queryParams = { 'EntityID': entityId };
305
419
  const setClauses = [];
306
420
  for (const [key, value] of attrs) {
307
421
  const paramName = `attr_${key}`;
308
422
  // Convert boolean values to SQL BIT (1/0)
309
423
  const sqlValue = typeof value === 'boolean' ? (value ? 1 : 0) : value;
310
- request.input(paramName, sqlValue);
311
- setClauses.push(`[${key}] = @${paramName}`);
424
+ queryParams[paramName] = sqlValue;
425
+ setClauses.push(`${this.qi(key)} = @${paramName}`);
312
426
  }
313
- await request.query(`UPDATE [${schema}].Entity SET ${setClauses.join(', ')} WHERE ID = @EntityID`);
427
+ await this.runQueryWithParams(pool, `UPDATE ${this.qs(schema, 'Entity')} SET ${setClauses.join(', ')} WHERE ID = @EntityID`, queryParams);
314
428
  const attrSummary = attrs.map(([k, v]) => `${k}=${v}`).join(', ');
315
429
  logStatus(` > Entities config: Set ${attrSummary} on "${entityName}"`);
316
430
  updatedCount++;
@@ -343,32 +457,20 @@ export class ManageMetadataBase {
343
457
  const entityName = ve.EntityName || this.deriveEntityNameFromView(viewName);
344
458
  const pkField = ve.PrimaryKey?.[0] || 'ID';
345
459
  // Check if entity already exists for this view
346
- const existsResult = await pool.request()
347
- .input('ViewName', viewName)
348
- .input('SchemaName', viewSchema)
349
- .query(`SELECT ID FROM [${schema}].vwEntities WHERE BaseView = @ViewName AND SchemaName = @SchemaName`);
460
+ const existsResult = await this.runQueryWithParams(pool, `SELECT ID FROM ${this.qs(schema, 'vwEntities')} WHERE BaseView = @ViewName AND SchemaName = @SchemaName`, { 'ViewName': viewName, 'SchemaName': viewSchema });
350
461
  if (existsResult.recordset.length > 0) {
351
462
  logStatus(` > Virtual entity "${entityName}" already exists for view [${viewSchema}].[${viewName}], skipping creation`);
352
463
  continue;
353
464
  }
354
465
  // Verify the view actually exists in the database
355
- const viewExistsResult = await pool.request()
356
- .input('ViewName', viewName)
357
- .input('SchemaName', viewSchema)
358
- .query(`SELECT 1 FROM INFORMATION_SCHEMA.VIEWS WHERE TABLE_NAME = @ViewName AND TABLE_SCHEMA = @SchemaName`);
466
+ const viewExistsResult = await this.runQueryWithParams(pool, this.dbProvider.getViewExistsSQL(), { 'ViewName': viewName, 'SchemaName': viewSchema });
359
467
  if (viewExistsResult.recordset.length === 0) {
360
468
  logError(` > View [${viewSchema}].[${viewName}] does not exist — skipping virtual entity creation for "${entityName}"`);
361
469
  continue;
362
470
  }
363
471
  // Create the virtual entity via the stored procedure
364
472
  try {
365
- const createResult = await pool.request()
366
- .input('Name', entityName)
367
- .input('BaseView', viewName)
368
- .input('SchemaName', viewSchema)
369
- .input('PrimaryKeyFieldName', pkField)
370
- .input('Description', ve.Description || null)
371
- .execute(`[${schema}].spCreateVirtualEntity`);
473
+ const createResult = await pool.executeStoredProcedure(`${this.qs(schema, 'spCreateVirtualEntity')}`, { 'Name': entityName, 'BaseView': viewName, 'SchemaName': viewSchema, 'PrimaryKeyFieldName': pkField, 'Description': ve.Description || null });
372
474
  const newEntityId = createResult.recordset?.[0]?.['']
373
475
  || createResult.recordset?.[0]?.ID
374
476
  || createResult.recordset?.[0]?.Column0;
@@ -410,9 +512,24 @@ export class ManageMetadataBase {
410
512
  */
411
513
  async manageMetadata(pool, currentUser) {
412
514
  const md = new Metadata();
413
- const excludeSchemas = configInfo.excludeSchemas ? configInfo.excludeSchemas : [];
515
+ // Auto-exclude platform-specific system schemas.
516
+ // We mutate configInfo.excludeSchemas directly so that all downstream code
517
+ // (including createExcludeTablesAndSchemasFilter) picks up the exclusions.
518
+ const systemSchemas = this.dbProvider.getSystemSchemasToExclude();
519
+ if (systemSchemas.length > 0) {
520
+ if (!configInfo.excludeSchemas) {
521
+ configInfo.excludeSchemas = [];
522
+ }
523
+ for (const s of systemSchemas) {
524
+ if (!configInfo.excludeSchemas.includes(s)) {
525
+ configInfo.excludeSchemas.push(s);
526
+ }
527
+ }
528
+ }
529
+ const excludeSchemas = configInfo.excludeSchemas ? [...configInfo.excludeSchemas] : [];
414
530
  let bSuccess = true;
415
531
  let start = new Date();
532
+ // Very early FK check - before any operations
416
533
  // Load SchemaInfo records early so that EntityNamePrefix/Suffix rules from the
417
534
  // database are available when createNewEntities() names new entities.
418
535
  logStatus(' Loading SchemaInfo records for entity name rules...');
@@ -519,8 +636,8 @@ export class ManageMetadataBase {
519
636
  // virtual entities are records defined in the entity metadata and do NOT define a distinct base table
520
637
  // but they do specify a base view. We DO NOT generate a base view for a virtual entity, we simply use it to figure
521
638
  // out the fields that should be in the entity definition and add/update/delete the entity definition to match what's in the view when this runs
522
- const sql = `SELECT * FROM [${mj_core_schema()}].vwEntities WHERE VirtualEntity = 1`;
523
- const virtualEntitiesResult = await pool.request().query(sql);
639
+ const sql = `SELECT * FROM ${this.qs(mj_core_schema(), 'vwEntities')} WHERE VirtualEntity = 1`;
640
+ const virtualEntitiesResult = await this.runQuery(pool, sql);
524
641
  const virtualEntities = virtualEntitiesResult.recordset;
525
642
  let anyUpdates = false;
526
643
  if (virtualEntities && virtualEntities.length > 0) {
@@ -542,20 +659,8 @@ export class ManageMetadataBase {
542
659
  try {
543
660
  // for a given virtual entity, we need to loop through the fields that exist in the current SQL definition for the view
544
661
  // and add/update/delete the entity fields to match what's in the view
545
- const sql = ` SELECT
546
- c.name AS FieldName, t.name AS Type, c.max_length AS Length, c.precision Precision, c.scale Scale, c.is_nullable AllowsNull
547
- FROM
548
- sys.columns c
549
- INNER JOIN
550
- sys.types t ON c.user_type_id = t.user_type_id
551
- INNER JOIN
552
- sys.views v ON c.object_id = v.object_id
553
- WHERE
554
- v.name = '${virtualEntity.BaseView}' AND
555
- SCHEMA_NAME(v.schema_id) = '${virtualEntity.SchemaName}'
556
- ORDER BY
557
- c.column_id`;
558
- const veFieldsResult = await pool.request().query(sql);
662
+ const sql = this.dbProvider.getViewColumnsSQL(virtualEntity.SchemaName, virtualEntity.BaseView);
663
+ const veFieldsResult = await this.runQuery(pool, sql);
559
664
  const veFields = veFieldsResult.recordset;
560
665
  if (veFields && veFields.length > 0) {
561
666
  // we have 1+ fields, now loop through them and process each one
@@ -569,7 +674,7 @@ export class ManageMetadataBase {
569
674
  removeList.push(f.ID);
570
675
  }
571
676
  if (removeList.length > 0) {
572
- const sqlRemove = `DELETE FROM [${mj_core_schema()}].EntityField WHERE ID IN (${removeList.map(removeId => `'${removeId}'`).join(',')})`;
677
+ const sqlRemove = `DELETE FROM ${this.qs(mj_core_schema(), 'EntityField')} WHERE ID IN (${removeList.map(removeId => `'${removeId}'`).join(',')})`;
573
678
  // this removes the fields that shouldn't be there anymore
574
679
  await this.LogSQLAndExecute(pool, sqlRemove, `SQL text to remove fields from entity ${virtualEntity.Name}`);
575
680
  bUpdated = true;
@@ -591,7 +696,7 @@ export class ManageMetadataBase {
591
696
  }
592
697
  if (bUpdated) {
593
698
  // finally make sure we update the UpdatedAt field for the entity if we made changes to its fields
594
- const sqlUpdate = `UPDATE [${mj_core_schema()}].Entity SET [${EntityInfo.UpdatedAtFieldName}]=GETUTCDATE() WHERE ID='${virtualEntity.ID}'`;
699
+ const sqlUpdate = `UPDATE ${this.qs(mj_core_schema(), 'Entity')} SET ${this.qi(EntityInfo.UpdatedAtFieldName)}=${this.utcNow()} WHERE ID='${virtualEntity.ID}'`;
595
700
  await this.LogSQLAndExecute(pool, sqlUpdate, `SQL text to update virtual entity updated date for ${virtualEntity.Name}`);
596
701
  }
597
702
  return { success: bSuccess, updatedEntity: bUpdated };
@@ -622,7 +727,7 @@ export class ManageMetadataBase {
622
727
  field.Sequence !== fieldSequence) {
623
728
  // the field needs to be updated, so update it
624
729
  const sqlUpdate = `UPDATE
625
- [${mj_core_schema()}].EntityField
730
+ ${this.qs(mj_core_schema(), 'EntityField')}
626
731
  SET
627
732
  Sequence=${fieldSequence},
628
733
  Type='${veField.Type}',
@@ -640,13 +745,16 @@ export class ManageMetadataBase {
640
745
  else {
641
746
  // this means that we do NOT have a match so the field does not exist in the entity definition, so we need to add it
642
747
  newEntityFieldUUID = this.createNewUUID();
643
- const sqlAdd = `INSERT INTO [${mj_core_schema()}].EntityField (
644
- ID, EntityID, Name, Type, AllowsNull,
645
- Length, Precision, Scale,
646
- Sequence, IsPrimaryKey, IsUnique )
748
+ const q = (n) => this.qi(n);
749
+ const sqlAdd = `INSERT INTO ${this.qs(mj_core_schema(), 'EntityField')} (
750
+ ${q('ID')}, ${q('EntityID')}, ${q('Name')}, ${q('Type')}, ${q('AllowsNull')},
751
+ ${q('Length')}, ${q('Precision')}, ${q('Scale')},
752
+ ${q('Sequence')}, ${q('IsPrimaryKey')}, ${q('IsUnique')},
753
+ ${q('__mj_CreatedAt')}, ${q('__mj_UpdatedAt')} )
647
754
  VALUES ( '${newEntityFieldUUID}', '${entity.ID}', '${veField.FieldName}', '${veField.Type}', ${veField.AllowsNull ? 1 : 0},
648
755
  ${veField.Length}, ${veField.Precision}, ${veField.Scale},
649
- ${fieldSequence}, ${makePrimaryKey ? 1 : 0}, ${makePrimaryKey ? 1 : 0}
756
+ ${fieldSequence}, ${makePrimaryKey ? 1 : 0}, ${makePrimaryKey ? 1 : 0},
757
+ ${this.utcNow()}, ${this.utcNow()}
650
758
  )`;
651
759
  await this.LogSQLAndExecute(pool, sqlAdd, `SQL text to add virtual entity field ${veField.FieldName} for entity ${virtualEntity.Name}`);
652
760
  didUpdate = true;
@@ -717,9 +825,9 @@ export class ManageMetadataBase {
717
825
  return { decorated: false, skipped: true };
718
826
  }
719
827
  // Get view definition from SQL Server
720
- const viewDefSQL = `SELECT OBJECT_DEFINITION(OBJECT_ID('[${entity.SchemaName}].[${entity.BaseView}]')) AS ViewDef`;
721
- const viewDefResult = await pool.request().query(viewDefSQL);
722
- const viewDefinition = viewDefResult.recordset[0]?.ViewDef;
828
+ const viewDefSQL = this.dbProvider.getViewDefinitionSQL(entity.SchemaName, entity.BaseView);
829
+ const viewDefResult = await this.runQuery(pool, viewDefSQL);
830
+ const viewDefinition = viewDefResult.recordset[0]?.ViewDefinition;
723
831
  if (!viewDefinition) {
724
832
  logStatus(` Could not get view definition for ${entity.SchemaName}.${entity.BaseView} — skipping LLM decoration`);
725
833
  return { decorated: false, skipped: false };
@@ -752,7 +860,7 @@ export class ManageMetadataBase {
752
860
  // Apply categories using the shared methods (same stability rules as regular entities)
753
861
  anyUpdated = await this.applyVEFieldCategories(pool, entity, result) || anyUpdated;
754
862
  if (anyUpdated) {
755
- const sqlUpdate = `UPDATE [${schema}].Entity SET [${EntityInfo.UpdatedAtFieldName}]=GETUTCDATE() WHERE ID='${entity.ID}'`;
863
+ const sqlUpdate = `UPDATE ${this.qs(schema, 'Entity')} SET ${this.qi(EntityInfo.UpdatedAtFieldName)}=${this.utcNow()} WHERE ID='${entity.ID}'`;
756
864
  await this.LogSQLAndExecute(pool, sqlUpdate, `Update entity timestamp for ${entity.Name} after LLM decoration`);
757
865
  }
758
866
  return { decorated: anyUpdated, skipped: false };
@@ -810,10 +918,10 @@ export class ManageMetadataBase {
810
918
  const schema = mj_core_schema();
811
919
  const fieldsSQL = `
812
920
  SELECT ID, Name, Category, AutoUpdateCategory, AutoUpdateDisplayName, GeneratedFormSection, DisplayName, ExtendedType, CodeType
813
- FROM [${schema}].EntityField
921
+ FROM ${this.qs(schema, 'EntityField')}
814
922
  WHERE EntityID = '${entity.ID}'
815
923
  `;
816
- const fieldsResult = await pool.request().query(fieldsSQL);
924
+ const fieldsResult = await this.runQuery(pool, fieldsSQL);
817
925
  const dbFields = fieldsResult.recordset;
818
926
  if (dbFields.length === 0)
819
927
  return false;
@@ -860,12 +968,12 @@ export class ManageMetadataBase {
860
968
  // Build batched SQL: clear default PK + set all LLM-identified PKs
861
969
  const sqlStatements = [];
862
970
  // Clear existing default PK (field #1 fallback) before applying LLM-identified PKs
863
- sqlStatements.push(`UPDATE [${schema}].[EntityField]
971
+ sqlStatements.push(`UPDATE ${this.qs(schema, 'EntityField')}
864
972
  SET IsPrimaryKey=0, IsUnique=0
865
973
  WHERE EntityID='${entity.ID}' AND IsPrimaryKey=1 AND IsSoftPrimaryKey=0`);
866
974
  // Set LLM-identified PKs
867
975
  for (const pk of validPKs) {
868
- sqlStatements.push(`UPDATE [${schema}].[EntityField]
976
+ sqlStatements.push(`UPDATE ${this.qs(schema, 'EntityField')}
869
977
  SET IsPrimaryKey=1, IsUnique=1, IsSoftPrimaryKey=1
870
978
  WHERE EntityID='${entity.ID}' AND Name='${pk}'`);
871
979
  logStatus(` ✓ Set PK for ${entity.Name}.${pk} (LLM-identified)`);
@@ -905,7 +1013,7 @@ export class ManageMetadataBase {
905
1013
  logStatus(` ⚠️ LLM FK: related entity '${fk.relatedEntityName}' not found for ${entity.Name}.${fk.fieldName}`);
906
1014
  continue;
907
1015
  }
908
- sqlStatements.push(`UPDATE [${schema}].[EntityField]
1016
+ sqlStatements.push(`UPDATE ${this.qs(schema, 'EntityField')}
909
1017
  SET RelatedEntityID='${relatedEntity.ID}',
910
1018
  RelatedEntityFieldName='${fk.relatedFieldName}',
911
1019
  IsSoftForeignKey=1
@@ -945,7 +1053,7 @@ export class ManageMetadataBase {
945
1053
  setClauses += `, ExtendedType='${validExtendedType}'`;
946
1054
  }
947
1055
  }
948
- sqlStatements.push(`UPDATE [${schema}].[EntityField]
1056
+ sqlStatements.push(`UPDATE ${this.qs(schema, 'EntityField')}
949
1057
  SET ${setClauses}
950
1058
  WHERE EntityID='${entity.ID}' AND Name='${field.Name}'`);
951
1059
  }
@@ -1051,12 +1159,9 @@ export class ManageMetadataBase {
1051
1159
  }
1052
1160
  // Check the DATABASE for existing field record — in-memory metadata may be stale
1053
1161
  // (e.g. createNewEntityFieldsFromSchema may have already added this field from the view)
1054
- const existsResult = await pool.request()
1055
- .input('EntityID', childEntity.ID)
1056
- .input('FieldName', parentField.Name)
1057
- .query(`SELECT ID, IsVirtual, Type, Length, Precision, Scale, AllowsNull, AllowUpdateAPI
1058
- FROM [${mj_core_schema()}].EntityField
1059
- WHERE EntityID = @EntityID AND Name = @FieldName`);
1162
+ const existsResult = await this.runQueryWithParams(pool, `SELECT ID, IsVirtual, Type, Length, Precision, Scale, AllowsNull, AllowUpdateAPI
1163
+ FROM ${this.qs(mj_core_schema(), 'EntityField')}
1164
+ WHERE EntityID = @EntityID AND Name = @FieldName`, { 'EntityID': childEntity.ID, 'FieldName': parentField.Name });
1060
1165
  if (existsResult.recordset.length > 0) {
1061
1166
  // Field already exists — update it to ensure it's marked as a virtual IS-A field
1062
1167
  const existingRow = existsResult.recordset[0];
@@ -1068,7 +1173,7 @@ export class ManageMetadataBase {
1068
1173
  existingRow.AllowsNull !== parentField.AllowsNull ||
1069
1174
  !existingRow.AllowUpdateAPI;
1070
1175
  if (needsUpdate) {
1071
- const sqlUpdate = `UPDATE [${mj_core_schema()}].EntityField
1176
+ const sqlUpdate = `UPDATE ${this.qs(mj_core_schema(), 'EntityField')}
1072
1177
  SET IsVirtual=1,
1073
1178
  Type='${parentField.Type}',
1074
1179
  Length=${parentField.Length},
@@ -1086,16 +1191,19 @@ export class ManageMetadataBase {
1086
1191
  const newFieldID = this.createNewUUID();
1087
1192
  // Use high sequence — will be reordered by updateExistingEntityFieldsFromSchema
1088
1193
  const sequence = 100000 + parentFields.indexOf(parentField);
1089
- const sqlInsert = `INSERT INTO [${mj_core_schema()}].EntityField (
1090
- ID, EntityID, Name, Type, AllowsNull,
1091
- Length, Precision, Scale,
1092
- Sequence, IsVirtual, AllowUpdateAPI,
1093
- IsPrimaryKey, IsUnique)
1194
+ const q = (n) => this.qi(n);
1195
+ const sqlInsert = `INSERT INTO ${this.qs(mj_core_schema(), 'EntityField')} (
1196
+ ${q('ID')}, ${q('EntityID')}, ${q('Name')}, ${q('Type')}, ${q('AllowsNull')},
1197
+ ${q('Length')}, ${q('Precision')}, ${q('Scale')},
1198
+ ${q('Sequence')}, ${q('IsVirtual')}, ${q('AllowUpdateAPI')},
1199
+ ${q('IsPrimaryKey')}, ${q('IsUnique')},
1200
+ ${q('__mj_CreatedAt')}, ${q('__mj_UpdatedAt')})
1094
1201
  VALUES (
1095
1202
  '${newFieldID}', '${childEntity.ID}', '${parentField.Name}',
1096
1203
  '${parentField.Type}', ${parentField.AllowsNull ? 1 : 0},
1097
1204
  ${parentField.Length}, ${parentField.Precision}, ${parentField.Scale},
1098
- ${sequence}, 1, 1, 0, 0)`;
1205
+ ${sequence}, 1, 1, 0, 0,
1206
+ ${this.utcNow()}, ${this.utcNow()})`;
1099
1207
  await this.LogSQLAndExecute(pool, sqlInsert, `Create IS-A parent field ${parentField.Name} on ${childEntity.Name}`);
1100
1208
  bUpdated = true;
1101
1209
  }
@@ -1107,12 +1215,12 @@ export class ManageMetadataBase {
1107
1215
  !f.IsPrimaryKey && !f.Name.startsWith('__mj_') &&
1108
1216
  !currentParentFieldNames.has(f.Name.toLowerCase()));
1109
1217
  for (const staleField of staleFields) {
1110
- const sqlDelete = `DELETE FROM [${mj_core_schema()}].EntityField WHERE ID='${staleField.ID}'`;
1218
+ const sqlDelete = `DELETE FROM ${this.qs(mj_core_schema(), 'EntityField')} WHERE ID='${staleField.ID}'`;
1111
1219
  await this.LogSQLAndExecute(pool, sqlDelete, `Remove stale IS-A parent field ${staleField.Name} from ${childEntity.Name}`);
1112
1220
  bUpdated = true;
1113
1221
  }
1114
1222
  if (bUpdated) {
1115
- const sqlUpdate = `UPDATE [${mj_core_schema()}].Entity SET [${EntityInfo.UpdatedAtFieldName}]=GETUTCDATE() WHERE ID='${childEntity.ID}'`;
1223
+ const sqlUpdate = `UPDATE ${this.qs(mj_core_schema(), 'Entity')} SET ${this.qi(EntityInfo.UpdatedAtFieldName)}=${this.utcNow()} WHERE ID='${childEntity.ID}'`;
1116
1224
  await this.LogSQLAndExecute(pool, sqlUpdate, `Update entity timestamp for ${childEntity.Name} after IS-A field sync`);
1117
1225
  }
1118
1226
  return { success: true, updated: bUpdated };
@@ -1164,55 +1272,40 @@ export class ManageMetadataBase {
1164
1272
  try {
1165
1273
  // STEP 1 - search for all foreign keys in the vwEntityFields view, we use the RelatedEntityID field to determine our FKs
1166
1274
  const sSQL = `SELECT *
1167
- FROM ${mj_core_schema()}.vwEntityFields
1275
+ FROM ${this.qs(mj_core_schema(), 'vwEntityFields')}
1168
1276
  WHERE
1169
1277
  RelatedEntityID IS NOT NULL AND
1170
1278
  IsVirtual = 0 AND
1171
- EntityID NOT IN (SELECT ID FROM ${mj_core_schema()}.Entity WHERE SchemaName IN (${excludeSchemas.map(s => `'${s}'`).join(',')}))
1279
+ EntityID NOT IN (SELECT ID FROM ${this.qs(mj_core_schema(), 'Entity')} WHERE SchemaName IN (${excludeSchemas.map(s => `'${s}'`).join(',')}))
1172
1280
  ORDER BY RelatedEntityID`;
1173
- const entityFieldsResult = await pool.request().query(sSQL);
1281
+ const entityFieldsResult = await this.runQuery(pool, sSQL);
1174
1282
  const entityFields = entityFieldsResult.recordset;
1175
1283
  // Get the relationship counts for each entity
1176
- const sSQLRelationshipCount = `SELECT EntityID, COUNT(*) AS Count FROM ${mj_core_schema()}.EntityRelationship GROUP BY EntityID`;
1177
- const relationshipCountsResult = await pool.request().query(sSQLRelationshipCount);
1284
+ const sSQLRelationshipCount = `SELECT ${this.qi('EntityID')}, COUNT(*) AS ${this.qi('Count')} FROM ${this.qs(mj_core_schema(), 'EntityRelationship')} GROUP BY ${this.qi('EntityID')}`;
1285
+ const relationshipCountsResult = await this.runQuery(pool, sSQLRelationshipCount);
1178
1286
  const relationshipCounts = relationshipCountsResult.recordset;
1179
1287
  const relationshipCountMap = new Map();
1180
1288
  for (const rc of relationshipCounts) {
1181
- relationshipCountMap.set(rc.EntityID, rc.Count);
1289
+ // Use Number() because PG returns COUNT(*) as bigint, which the pg driver delivers as a string
1290
+ relationshipCountMap.set(rc.EntityID, Number(rc.Count));
1182
1291
  }
1183
1292
  // get all relationships in one query for performance improvement
1184
- const sSQLRelationship = `SELECT * FROM ${mj_core_schema()}.EntityRelationship`;
1185
- const allRelationshipsResult = await pool.request().query(sSQLRelationship);
1293
+ const sSQLRelationship = `SELECT * FROM ${this.qs(mj_core_schema(), 'EntityRelationship')}`;
1294
+ const allRelationshipsResult = await this.runQuery(pool, sSQLRelationship);
1186
1295
  const allRelationships = allRelationshipsResult.recordset;
1187
1296
  // Function to process a batch of entity fields
1188
1297
  const processBatch = async (batch) => {
1189
1298
  let batchSQL = '';
1190
1299
  batch.forEach((f) => {
1191
1300
  // for each field determine if an existing relationship exists, if not, create it
1192
- const relationships = allRelationships.filter((r) => r.EntityID === f.RelatedEntityID && r.RelatedEntityID === f.EntityID);
1301
+ const relationships = allRelationships.filter((r) => UUIDsEqual(r.EntityID, f.RelatedEntityID) && UUIDsEqual(r.RelatedEntityID, f.EntityID));
1193
1302
  if (relationships && relationships.length === 0) {
1194
1303
  // no relationship exists, so create it
1195
- const e = md.Entities.find(e => e.ID === f.EntityID);
1196
- const parentEntity = md.Entities.find(e => e.ID === f.RelatedEntityID);
1197
- const parentEntityName = parentEntity ? parentEntity.Name : f.RelatedEntityID;
1198
- // calculate the sequence by getting the count of existing relationships for the entity and adding 1 and then increment the count for future inserts in this loop
1199
- const relCount = relationshipCountMap.get(f.EntityID) || 0;
1200
- const sequence = relCount + 1;
1201
- const newEntityRelationshipUUID = this.createNewUUID();
1202
- batchSQL += `
1203
- /* Create Entity Relationship: ${parentEntityName} -> ${e.Name} (One To Many via ${f.Name}) */
1204
- IF NOT EXISTS (
1205
- SELECT 1
1206
- FROM [${mj_core_schema()}].EntityRelationship
1207
- WHERE ID = '${newEntityRelationshipUUID}'
1208
- )
1209
- BEGIN
1210
- INSERT INTO ${mj_core_schema()}.EntityRelationship (ID, EntityID, RelatedEntityID, RelatedEntityJoinField, Type, BundleInAPI, DisplayInForm, DisplayName, Sequence)
1211
- VALUES ('${newEntityRelationshipUUID}', '${f.RelatedEntityID}', '${f.EntityID}', '${f.Name}', 'One To Many', 1, 1, '${e.Name}', ${sequence});
1212
- END
1213
- `;
1214
- // now update the map for the relationship count
1215
- relationshipCountMap.set(f.EntityID, sequence);
1304
+ batchSQL += this.buildInsertRelationshipSQL(f, md, relationshipCountMap);
1305
+ }
1306
+ else {
1307
+ // relationship(s) exist - check if any need their join field updated
1308
+ batchSQL += this.buildUpdateRelationshipJoinFieldSQL(relationships, f);
1216
1309
  }
1217
1310
  });
1218
1311
  if (batchSQL.length > 0) {
@@ -1224,6 +1317,11 @@ export class ManageMetadataBase {
1224
1317
  const batch = entityFields.slice(i, i + batchItems);
1225
1318
  await processBatch(batch);
1226
1319
  }
1320
+ // NOTE: Stale relationship cleanup is intentionally NOT done here because this
1321
+ // method runs BEFORE deleteUnneededEntityFields(). Stale EntityField records
1322
+ // (for dropped columns) still exist at this point and would prevent detection
1323
+ // of stale relationships. The cleanup is handled by cleanupStaleEntityRelationships()
1324
+ // which is called from sql_codegen.ts AFTER entity fields are fully synced.
1227
1325
  return true;
1228
1326
  }
1229
1327
  catch (e) {
@@ -1231,6 +1329,103 @@ export class ManageMetadataBase {
1231
1329
  return false;
1232
1330
  }
1233
1331
  }
1332
+ /**
1333
+ * Builds SQL to INSERT a new EntityRelationship record for a discovered FK field.
1334
+ */
1335
+ buildInsertRelationshipSQL(f, md, relationshipCountMap) {
1336
+ const e = md.Entities.find(e => UUIDsEqual(e.ID, f.EntityID));
1337
+ const parentEntity = md.Entities.find(e => UUIDsEqual(e.ID, f.RelatedEntityID));
1338
+ const parentEntityName = parentEntity ? parentEntity.Name : String(f.RelatedEntityID);
1339
+ const relCount = relationshipCountMap.get(f.EntityID) || 0;
1340
+ const sequence = relCount + 1;
1341
+ const newEntityRelationshipUUID = this.createNewUUID();
1342
+ const checkQuery = `SELECT 1 FROM ${this.qs(mj_core_schema(), 'EntityRelationship')} WHERE ${this.qi('ID')} = '${newEntityRelationshipUUID}'`;
1343
+ const insertSQL = `INSERT INTO ${this.qs(mj_core_schema(), 'EntityRelationship')} (${this.qi('ID')}, ${this.qi('EntityID')}, ${this.qi('RelatedEntityID')}, ${this.qi('RelatedEntityJoinField')}, ${this.qi('Type')}, ${this.qi('BundleInAPI')}, ${this.qi('DisplayInForm')}, ${this.qi('Sequence')}, ${this.qi('__mj_CreatedAt')}, ${this.qi('__mj_UpdatedAt')})
1344
+ VALUES ('${newEntityRelationshipUUID}', '${f.RelatedEntityID}', '${f.EntityID}', '${f.Name}', 'One To Many', ${this.boolLit(true)}, ${this.boolLit(true)}, ${sequence}, ${this.utcNow()}, ${this.utcNow()})`;
1345
+ relationshipCountMap.set(f.EntityID, sequence);
1346
+ return `
1347
+ /* Create Entity Relationship: ${parentEntityName} -> ${e.Name} (One To Many via ${f.Name}) */
1348
+ ${this.dbProvider.conditionalInsertSQL(checkQuery, insertSQL)};
1349
+ `;
1350
+ }
1351
+ /**
1352
+ * Builds SQL to UPDATE an existing EntityRelationship's RelatedEntityJoinField if it no longer
1353
+ * matches the current FK field name. Only updates relationships where AutoUpdateFromSchema = true.
1354
+ */
1355
+ buildUpdateRelationshipJoinFieldSQL(relationships, f) {
1356
+ let sql = '';
1357
+ for (const r of relationships) {
1358
+ if (r.AutoUpdateFromSchema && String(r.RelatedEntityJoinField).trim() !== String(f.Name).trim()) {
1359
+ logStatus(` > Updating EntityRelationship join field: ${r.RelatedEntityJoinField} -> ${f.Name} (ID: ${r.ID})`);
1360
+ sql += `
1361
+ /* Update EntityRelationship join field from '${r.RelatedEntityJoinField}' to '${f.Name}' */
1362
+ UPDATE ${this.qs(mj_core_schema(), 'EntityRelationship')}
1363
+ SET ${this.qi('RelatedEntityJoinField')} = '${f.Name}',
1364
+ ${this.qi('__mj_UpdatedAt')} = ${this.utcNow()}
1365
+ WHERE ${this.qi('ID')} = '${r.ID}';
1366
+ `;
1367
+ }
1368
+ }
1369
+ return sql;
1370
+ }
1371
+ /**
1372
+ * Public entry point for cleaning up stale EntityRelationship records. This must be called
1373
+ * AFTER deleteUnneededEntityFields() has run, so that stale EntityField records (for dropped
1374
+ * columns) are removed before we check which relationships are still valid.
1375
+ * Called from sql_codegen.ts after the second manageEntityFields() pass.
1376
+ * @param pool - database connection
1377
+ * @param excludeSchemas - schemas to exclude from FK field lookup
1378
+ */
1379
+ async cleanupStaleEntityRelationships(pool, excludeSchemas) {
1380
+ try {
1381
+ const sSQL = `SELECT *
1382
+ FROM ${this.qs(mj_core_schema(), 'vwEntityFields')}
1383
+ WHERE
1384
+ RelatedEntityID IS NOT NULL AND
1385
+ IsVirtual = 0 AND
1386
+ EntityID NOT IN (SELECT ID FROM ${this.qs(mj_core_schema(), 'Entity')} WHERE SchemaName IN (${excludeSchemas.map(s => `'${s}'`).join(',')}))
1387
+ ORDER BY RelatedEntityID`;
1388
+ const entityFieldsResult = await this.runQuery(pool, sSQL);
1389
+ const entityFields = entityFieldsResult.recordset;
1390
+ const sSQLRelationship = `SELECT * FROM ${this.qs(mj_core_schema(), 'EntityRelationship')}`;
1391
+ const allRelationshipsResult = await this.runQuery(pool, sSQLRelationship);
1392
+ const allRelationships = allRelationshipsResult.recordset;
1393
+ await this.removeStaleOneToManyRelationships(pool, allRelationships, entityFields);
1394
+ return true;
1395
+ }
1396
+ catch (e) {
1397
+ logError(e);
1398
+ return false;
1399
+ }
1400
+ }
1401
+ /**
1402
+ * Removes stale One-To-Many EntityRelationship records whose RelatedEntityJoinField no longer
1403
+ * corresponds to a valid FK field in the database. Only removes relationships where
1404
+ * AutoUpdateFromSchema = true and Type = 'One To Many'.
1405
+ */
1406
+ async removeStaleOneToManyRelationships(pool, allRelationships, entityFields) {
1407
+ // Build a set of valid (ParentEntityID, ChildEntityID, JoinFieldName) tuples from current FK fields.
1408
+ // In EntityRelationship terms: EntityID = ParentEntityID (the "one" side), RelatedEntityID = ChildEntityID (the "many" side)
1409
+ // In entityFields: f.RelatedEntityID = ParentEntityID, f.EntityID = ChildEntityID, f.Name = FK field name
1410
+ const validRelationshipKeys = new Set();
1411
+ for (const f of entityFields) {
1412
+ validRelationshipKeys.add(`${f.RelatedEntityID}|${f.EntityID}|${f.Name}`);
1413
+ }
1414
+ const staleRelationships = allRelationships.filter(r => String(r.Type).trim() === 'One To Many' &&
1415
+ r.AutoUpdateFromSchema === true &&
1416
+ !validRelationshipKeys.has(`${r.EntityID}|${r.RelatedEntityID}|${r.RelatedEntityJoinField}`));
1417
+ if (staleRelationships.length > 0) {
1418
+ let deleteSQL = '';
1419
+ for (const r of staleRelationships) {
1420
+ logStatus(` > Removing stale EntityRelationship: ${r.Entity} -> ${r.RelatedEntity} via '${r.RelatedEntityJoinField}' (ID: ${r.ID})`);
1421
+ deleteSQL += `
1422
+ /* Remove stale EntityRelationship: ${r.Entity} -> ${r.RelatedEntity} (FK field '${r.RelatedEntityJoinField}' no longer exists) */
1423
+ DELETE FROM ${this.qs(mj_core_schema(), 'EntityRelationship')} WHERE ${this.qi('ID')} = '${r.ID}';
1424
+ `;
1425
+ }
1426
+ await this.LogSQLAndExecute(pool, deleteSQL, 'Remove stale One-To-Many EntityRelationships');
1427
+ }
1428
+ }
1234
1429
  /**
1235
1430
  * This method will look for situations where entity metadata exist in the entities metadata table but the underlying table has been deleted. In this case, the metadata for the entity
1236
1431
  * should be removed. This method is called as part of the manageMetadata method and is not intended to be called directly.
@@ -1239,8 +1434,8 @@ export class ManageMetadataBase {
1239
1434
  */
1240
1435
  async checkAndRemoveMetadataForDeletedTables(pool, excludeSchemas) {
1241
1436
  try {
1242
- const sql = `SELECT * FROM ${mj_core_schema()}.vwEntitiesWithMissingBaseTables WHERE VirtualEntity=0`;
1243
- const entitiesResult = await pool.request().query(sql);
1437
+ const sql = `SELECT * FROM ${this.qs(mj_core_schema(), 'vwEntitiesWithMissingBaseTables')}${this.dbProvider.getEntitiesWithMissingBaseTablesFilter()}`;
1438
+ const entitiesResult = await this.runQuery(pool, sql);
1244
1439
  const entities = entitiesResult.recordset;
1245
1440
  if (entities && entities.length > 0) {
1246
1441
  for (const e of entities) {
@@ -1248,7 +1443,7 @@ export class ManageMetadataBase {
1248
1443
  // the below could fail if there are non-core dependencies on the entity, but that's ok, we will flag that in the console
1249
1444
  // for the admin to handle manually
1250
1445
  try {
1251
- const sqlDelete = `__mj.spDeleteEntityWithCoreDependencies @EntityID='${e.ID}'`;
1446
+ const sqlDelete = this.dbProvider.callRoutineSQL(mj_core_schema(), 'spDeleteEntityWithCoreDependencies', [`'${e.ID}'`], ['EntityID']);
1252
1447
  await this.LogSQLAndExecute(pool, sqlDelete, `SQL text to remove entity ${e.Name}`);
1253
1448
  logStatus(` > Removed metadata for table ${e.SchemaName}.${e.BaseTable}`);
1254
1449
  // next up we need to remove the spCreate, spDelete, spUpdate, BaseView, and FullTextSearchFunction, if provided.
@@ -1279,9 +1474,8 @@ export class ManageMetadataBase {
1279
1474
  if (proceed && schemaName && name && schemaName.trim().length > 0 && name.trim().length > 0) {
1280
1475
  // Use IF OBJECT_ID pattern for Flyway compatibility
1281
1476
  // Object type codes: P = Stored Procedure, V = View, FN = Scalar Function, IF/TF = Table-Valued Function
1282
- const objectTypeCode = type === 'procedure' ? 'P' : type === 'view' ? 'V' : 'FN';
1283
1477
  const upperType = type.toUpperCase();
1284
- const sqlDelete = `IF OBJECT_ID('[${schemaName}].[${name}]', '${objectTypeCode}') IS NOT NULL\n DROP ${upperType} [${schemaName}].[${name}]`;
1478
+ const sqlDelete = this.dbProvider.dropObjectSQL(upperType, schemaName, name);
1285
1479
  await this.LogSQLAndExecute(pool, sqlDelete, `SQL text to remove ${type} ${schemaName}.${name}`);
1286
1480
  // next up, we need to clean up the cache of saved DB objects that may exist for this entity in the appropriate sub-directory.
1287
1481
  const sqlOutputDir = outputDir('SQL', true);
@@ -1350,6 +1544,16 @@ export class ManageMetadataBase {
1350
1544
  bSuccess = false;
1351
1545
  }
1352
1546
  logStatus(` Created new entity fields from schema in ${(new Date().getTime() - step2StartTime.getTime()) / 1000} seconds`);
1547
+ // PostgreSQL view columns always report attnotnull=false, so virtual fields
1548
+ // (FK join columns like "Location", "Class") incorrectly get AllowsNull=true.
1549
+ // Fix: derive virtual field AllowsNull from the FK column that drives the JOIN.
1550
+ // The virtual field name = FK field name minus trailing "ID" (e.g. ClassID → Class).
1551
+ if (this.dbProvider.NeedsVirtualFieldNullabilityFix) {
1552
+ const fixSQL = this.dbProvider.getFixVirtualFieldNullabilitySQL(mj_core_schema());
1553
+ if (fixSQL) {
1554
+ await this.LogSQLAndExecute(pool, fixSQL, 'SQL to fix virtual field nullability');
1555
+ }
1556
+ }
1353
1557
  // AN: 14-June-2025 - we are now running this AFTER we create new entity fields from schema
1354
1558
  // which results in the same pattern of behavior as migrations where we first create new fields
1355
1559
  // with VERY HIGH sequence numbers (e.g. 100,000 above what they will be approx) and then
@@ -1425,23 +1629,26 @@ export class ManageMetadataBase {
1425
1629
  const sqlEntities = `SELECT
1426
1630
  *
1427
1631
  FROM
1428
- [${mj_core_schema()}].vwEntities
1632
+ ${this.qs(mj_core_schema(), 'vwEntities')}
1429
1633
  WHERE
1430
1634
  VirtualEntity=0 AND
1431
1635
  DeleteType='Soft' AND
1432
1636
  SchemaName NOT IN (${excludeSchemas.map(s => `'${s}'`).join(',')})`;
1433
- const entitiesResult = await pool.request().query(sqlEntities);
1637
+ const entitiesResult = await this.runQuery(pool, sqlEntities);
1434
1638
  const entities = entitiesResult.recordset;
1435
1639
  let overallResult = true;
1436
1640
  if (entities.length > 0) {
1437
1641
  // we have 1+ entities that need the special fields, so loop through them and ensure the fields exist
1438
1642
  // validate that each entity has the __mj_DeletedAt field, and it is a DATETIMEOFFSET fields, NOT NULL and both are fields that have a DEFAULT value of GETUTCDATE().
1439
- const sql = `SELECT *
1643
+ const sql = `SELECT
1644
+ TABLE_SCHEMA AS "TABLE_SCHEMA", TABLE_NAME AS "TABLE_NAME",
1645
+ COLUMN_NAME AS "COLUMN_NAME", DATA_TYPE AS "DATA_TYPE",
1646
+ IS_NULLABLE AS "IS_NULLABLE", COLUMN_DEFAULT AS "COLUMN_DEFAULT"
1440
1647
  FROM INFORMATION_SCHEMA.COLUMNS
1441
1648
  WHERE
1442
- ${entities.map((e) => `(TABLE_SCHEMA='${e.SchemaName}' AND TABLE_NAME='${e.BaseTable}')`).join(' OR ')}
1649
+ (${entities.map((e) => `(TABLE_SCHEMA='${e.SchemaName}' AND TABLE_NAME='${e.BaseTable}')`).join(' OR ')})
1443
1650
  AND COLUMN_NAME='${EntityInfo.DeletedAtFieldName}'`;
1444
- const resultResult = await pool.request().query(sql);
1651
+ const resultResult = await this.runQuery(pool, sql);
1445
1652
  const result = resultResult.recordset;
1446
1653
  for (const e of entities) {
1447
1654
  const eResult = result.filter((r) => r.TABLE_NAME === e.BaseTable && r.TABLE_SCHEMA === e.SchemaName); // get just the fields for this entity
@@ -1490,8 +1697,8 @@ export class ManageMetadataBase {
1490
1697
  const tableSchema = table.SchemaName;
1491
1698
  const tableName = table.TableName;
1492
1699
  // Look up entity ID (SELECT query - no need to log to migration file)
1493
- const entityLookupSQL = `SELECT ID FROM [${schema}].[Entity] WHERE SchemaName = '${tableSchema}' AND BaseTable = '${tableName}'`;
1494
- const entityResult = await pool.request().query(entityLookupSQL);
1700
+ const entityLookupSQL = `SELECT ID FROM ${this.qs(schema, 'Entity')} WHERE SchemaName = '${tableSchema}' AND BaseTable = '${tableName}'`;
1701
+ const entityResult = await this.runQuery(pool, entityLookupSQL);
1495
1702
  if (entityResult.recordset.length === 0) {
1496
1703
  logStatus(` ⚠️ Entity not found for ${tableSchema}.${tableName} - skipping`);
1497
1704
  continue;
@@ -1502,11 +1709,11 @@ export class ManageMetadataBase {
1502
1709
  const primaryKeys = table.PrimaryKey || [];
1503
1710
  if (primaryKeys.length > 0) {
1504
1711
  for (const pk of primaryKeys) {
1505
- const sSQL = `UPDATE [${schema}].[EntityField]
1506
- SET ${EntityInfo.UpdatedAtFieldName}=GETUTCDATE(),
1507
- [IsPrimaryKey] = 1,
1508
- [IsSoftPrimaryKey] = 1
1509
- WHERE [EntityID] = '${entityId}' AND [Name] = '${pk.FieldName}'`;
1712
+ const sSQL = `UPDATE ${this.qs(schema, 'EntityField')}
1713
+ SET ${EntityInfo.UpdatedAtFieldName}=${this.utcNow()},
1714
+ ${this.qi('IsPrimaryKey')} = 1,
1715
+ ${this.qi('IsSoftPrimaryKey')} = 1
1716
+ WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = '${pk.FieldName}'`;
1510
1717
  const result = await this.LogSQLAndExecute(pool, sSQL, `Set soft PK for ${tableSchema}.${tableName}.${pk.FieldName}`);
1511
1718
  if (result !== null) {
1512
1719
  logStatus(` ✓ Set IsPrimaryKey=1, IsSoftPrimaryKey=1 for ${tableName}.${pk.FieldName}`);
@@ -1520,19 +1727,19 @@ export class ManageMetadataBase {
1520
1727
  for (const fk of foreignKeys) {
1521
1728
  const fkSchema = fk.SchemaName || tableSchema;
1522
1729
  // Look up related entity ID (SELECT query - no need to log to migration file)
1523
- const relatedLookupSQL = `SELECT ID FROM [${schema}].[Entity] WHERE SchemaName = '${fkSchema}' AND BaseTable = '${fk.RelatedTable}'`;
1524
- const relatedEntityResult = await pool.request().query(relatedLookupSQL);
1730
+ const relatedLookupSQL = `SELECT ID FROM ${this.qs(schema, 'Entity')} WHERE SchemaName = '${fkSchema}' AND BaseTable = '${fk.RelatedTable}'`;
1731
+ const relatedEntityResult = await this.runQuery(pool, relatedLookupSQL);
1525
1732
  if (relatedEntityResult.recordset.length === 0) {
1526
1733
  logStatus(` ⚠️ Related entity not found for ${fkSchema}.${fk.RelatedTable} - skipping FK ${fk.FieldName}`);
1527
1734
  continue;
1528
1735
  }
1529
1736
  const relatedEntityId = relatedEntityResult.recordset[0].ID;
1530
- const sSQL = `UPDATE [${schema}].[EntityField]
1531
- SET ${EntityInfo.UpdatedAtFieldName}=GETUTCDATE(),
1532
- [RelatedEntityID] = '${relatedEntityId}',
1533
- [RelatedEntityFieldName] = '${fk.RelatedField}',
1534
- [IsSoftForeignKey] = 1
1535
- WHERE [EntityID] = '${entityId}' AND [Name] = '${fk.FieldName}'`;
1737
+ const sSQL = `UPDATE ${this.qs(schema, 'EntityField')}
1738
+ SET ${EntityInfo.UpdatedAtFieldName}=${this.utcNow()},
1739
+ ${this.qi('RelatedEntityID')} = '${relatedEntityId}',
1740
+ ${this.qi('RelatedEntityFieldName')} = '${fk.RelatedField}',
1741
+ ${this.qi('IsSoftForeignKey')} = 1
1742
+ WHERE ${this.qi('EntityID')} = '${entityId}' AND ${this.qi('Name')} = '${fk.FieldName}'`;
1536
1743
  const result = await this.LogSQLAndExecute(pool, sSQL, `Set soft FK for ${tableSchema}.${tableName}.${fk.FieldName} → ${fk.RelatedTable}.${fk.RelatedField}`);
1537
1744
  if (result !== null) {
1538
1745
  logStatus(` ✓ Set soft FK for ${tableName}.${fk.FieldName} → ${fk.RelatedTable}.${fk.RelatedField}`);
@@ -1560,24 +1767,27 @@ export class ManageMetadataBase {
1560
1767
  const sqlEntities = `SELECT
1561
1768
  *
1562
1769
  FROM
1563
- [${mj_core_schema()}].vwEntities
1770
+ ${this.qs(mj_core_schema(), 'vwEntities')}
1564
1771
  WHERE
1565
1772
  VirtualEntity = 0 AND
1566
1773
  TrackRecordChanges = 1 AND
1567
1774
  SchemaName NOT IN (${excludeSchemas.map(s => `'${s}'`).join(',')})`;
1568
- const entitiesResult = await pool.request().query(sqlEntities);
1775
+ const entitiesResult = await this.runQuery(pool, sqlEntities);
1569
1776
  const entities = entitiesResult.recordset;
1570
1777
  let overallResult = true;
1571
1778
  if (entities.length > 0) {
1572
1779
  // we have 1+ entities that need the special fields, so loop through them and ensure the fields exist
1573
1780
  // validate that each entity has two specific fields, the first one is __mj_CreatedAt and the second one is __mj_UpdatedAt
1574
1781
  // both are DATETIME fields, NOT NULL and both are fields that have a DEFAULT value of GETUTCDATE().
1575
- const sqlCreatedUpdated = `SELECT *
1782
+ const sqlCreatedUpdated = `SELECT
1783
+ TABLE_SCHEMA AS "TABLE_SCHEMA", TABLE_NAME AS "TABLE_NAME",
1784
+ COLUMN_NAME AS "COLUMN_NAME", DATA_TYPE AS "DATA_TYPE",
1785
+ IS_NULLABLE AS "IS_NULLABLE", COLUMN_DEFAULT AS "COLUMN_DEFAULT"
1576
1786
  FROM INFORMATION_SCHEMA.COLUMNS
1577
1787
  WHERE
1578
- ${entities.map((e) => `(TABLE_SCHEMA='${e.SchemaName}' AND TABLE_NAME='${e.BaseTable}')`).join(' OR ')}
1788
+ (${entities.map((e) => `(TABLE_SCHEMA='${e.SchemaName}' AND TABLE_NAME='${e.BaseTable}')`).join(' OR ')})
1579
1789
  AND COLUMN_NAME IN ('${EntityInfo.CreatedAtFieldName}','${EntityInfo.UpdatedAtFieldName}')`;
1580
- const resultResult = await pool.request().query(sqlCreatedUpdated);
1790
+ const resultResult = await this.runQuery(pool, sqlCreatedUpdated);
1581
1791
  const result = resultResult.recordset;
1582
1792
  for (const e of entities) {
1583
1793
  // result has both created at and updated at fields, so filter on the result for each and do what we need to based on that
@@ -1608,18 +1818,19 @@ export class ManageMetadataBase {
1608
1818
  try {
1609
1819
  if (!currentFieldData) {
1610
1820
  // field doesn't exist, let's create it
1611
- const sql = `ALTER TABLE [${entity.SchemaName}].[${entity.BaseTable}] ADD ${fieldName} DATETIMEOFFSET ${allowNull ? 'NULL' : 'NOT NULL DEFAULT GETUTCDATE()'}`;
1821
+ const sql = this.dbProvider.addColumnSQL(entity.SchemaName, entity.BaseTable, fieldName, this.timestampType, allowNull, allowNull ? undefined : this.utcNow());
1612
1822
  await this.LogSQLAndExecute(pool, sql, `SQL text to add special date field ${fieldName} to entity ${entity.SchemaName}.${entity.BaseTable}`);
1613
1823
  }
1614
1824
  else {
1615
1825
  // field does exist, let's first check the data type/nullability
1616
- if (currentFieldData.DATA_TYPE.trim().toLowerCase() !== 'datetimeoffset' ||
1826
+ const dataTypeMatches = this.dbProvider.compareDataTypes(currentFieldData.DATA_TYPE.trim().toLowerCase(), this.timestampType.toLowerCase());
1827
+ if (!dataTypeMatches ||
1617
1828
  (currentFieldData.IS_NULLABLE.trim().toLowerCase() !== 'no' && !allowNull) ||
1618
1829
  (currentFieldData.IS_NULLABLE.trim().toLowerCase() === 'no' && allowNull)) {
1619
1830
  // the column is the wrong type, or has wrong nullability attribute, so let's update it, first removing the default constraint, then
1620
1831
  // modifying the column, and finally adding the default constraint back in.
1621
1832
  await this.dropExistingDefaultConstraint(pool, entity, fieldName);
1622
- const sql = `ALTER TABLE [${entity.SchemaName}].[${entity.BaseTable}] ALTER COLUMN ${fieldName} DATETIMEOFFSET ${allowNull ? 'NULL' : 'NOT NULL'}`;
1833
+ const sql = this.dbProvider.alterColumnTypeAndNullabilitySQL(entity.SchemaName, entity.BaseTable, fieldName, this.timestampType, allowNull);
1623
1834
  await this.LogSQLAndExecute(pool, sql, `SQL text to update special date field ${fieldName} in entity ${entity.SchemaName}.${entity.BaseTable}`);
1624
1835
  if (!allowNull)
1625
1836
  await this.createDefaultConstraintForSpecialDateField(pool, entity, fieldName);
@@ -1630,7 +1841,7 @@ export class ManageMetadataBase {
1630
1841
  if (!allowNull) {
1631
1842
  const defaultValue = currentFieldData.COLUMN_DEFAULT;
1632
1843
  const realDefaultValue = ExtractActualDefaultValue(defaultValue);
1633
- if (!realDefaultValue || realDefaultValue.trim().toLowerCase() !== 'getutcdate()') {
1844
+ if (!realDefaultValue || realDefaultValue.trim().toLowerCase() !== this.utcNow().toLowerCase()) {
1634
1845
  await this.dropAndCreateDefaultConstraintForSpecialDateField(pool, entity, fieldName);
1635
1846
  }
1636
1847
  }
@@ -1649,7 +1860,7 @@ export class ManageMetadataBase {
1649
1860
  */
1650
1861
  async createDefaultConstraintForSpecialDateField(pool, entity, fieldName) {
1651
1862
  try {
1652
- const sqlAddDefaultConstraint = `ALTER TABLE [${entity.SchemaName}].[${entity.BaseTable}] ADD CONSTRAINT DF_${entity.SchemaName}_${CodeNameFromString(entity.BaseTable)}_${fieldName} DEFAULT GETUTCDATE() FOR [${fieldName}]`;
1863
+ const sqlAddDefaultConstraint = this.dbProvider.addDefaultConstraintSQL(entity.SchemaName, entity.BaseTable, fieldName, this.utcNow());
1653
1864
  await this.LogSQLAndExecute(pool, sqlAddDefaultConstraint, `SQL text to add default constraint for special date field ${fieldName} in entity ${entity.SchemaName}.${entity.BaseTable}`);
1654
1865
  }
1655
1866
  catch (e) {
@@ -1675,25 +1886,7 @@ export class ManageMetadataBase {
1675
1886
  */
1676
1887
  async dropExistingDefaultConstraint(pool, entity, fieldName) {
1677
1888
  try {
1678
- const sqlDropDefaultConstraint = `
1679
- DECLARE @constraintName NVARCHAR(255);
1680
-
1681
- -- Get the default constraint name
1682
- SELECT @constraintName = d.name
1683
- FROM sys.tables t
1684
- JOIN sys.schemas s ON t.schema_id = s.schema_id
1685
- JOIN sys.columns c ON t.object_id = c.object_id
1686
- JOIN sys.default_constraints d ON c.default_object_id = d.object_id
1687
- WHERE s.name = '${entity.SchemaName}'
1688
- AND t.name = '${entity.BaseTable}'
1689
- AND c.name = '${fieldName}';
1690
-
1691
- -- Drop the default constraint if it exists
1692
- IF @constraintName IS NOT NULL
1693
- BEGIN
1694
- EXEC('ALTER TABLE [${entity.SchemaName}].[${entity.BaseTable}] DROP CONSTRAINT ' + @constraintName);
1695
- END
1696
- `;
1889
+ const sqlDropDefaultConstraint = this.dbProvider.dropDefaultConstraintSQL(entity.SchemaName, entity.BaseTable, fieldName);
1697
1890
  await this.LogSQLAndExecute(pool, sqlDropDefaultConstraint, `SQL text to drop default existing default constraints in entity ${entity.SchemaName}.${entity.BaseTable}`);
1698
1891
  }
1699
1892
  catch (e) {
@@ -1713,14 +1906,14 @@ export class ManageMetadataBase {
1713
1906
  if (ag.featureEnabled('EntityDescriptions')) {
1714
1907
  // we have the feature enabled, so let's loop through the new entities and generate descriptions for them
1715
1908
  for (let e of ManageMetadataBase.newEntityList) {
1716
- const dataResult = await pool.request().query(`SELECT * FROM [${mj_core_schema()}].vwEntities WHERE Name = '${e}'`);
1909
+ const dataResult = await this.runQuery(pool, `SELECT * FROM ${this.qs(mj_core_schema(), 'vwEntities')} WHERE Name = '${e}'`);
1717
1910
  const data = dataResult.recordset;
1718
- const fieldsResult = await pool.request().query(`SELECT * FROM [${mj_core_schema()}].vwEntityFields WHERE EntityID='${data[0].ID}'`);
1911
+ const fieldsResult = await this.runQuery(pool, `SELECT * FROM ${this.qs(mj_core_schema(), 'vwEntityFields')} WHERE EntityID='${data[0].ID}'`);
1719
1912
  const fields = fieldsResult.recordset;
1720
1913
  // Use new API to generate entity description
1721
1914
  const result = await ag.generateEntityDescription(e, data[0].BaseTable, fields.map((f) => ({ Name: f.Name, Type: f.Type, IsNullable: f.AllowsNull, Description: f.Description })), currentUser);
1722
1915
  if (result?.entityDescription && result.entityDescription.length > 0) {
1723
- const sSQL = `UPDATE [${mj_core_schema()}].Entity SET Description = '${result.entityDescription}' WHERE Name = '${e}'`;
1916
+ const sSQL = `UPDATE ${this.qs(mj_core_schema(), 'Entity')} SET Description = '${result.entityDescription}' WHERE Name = '${e}'`;
1724
1917
  await this.LogSQLAndExecute(pool, sSQL, `SQL text to update entity description for entity ${e}`);
1725
1918
  }
1726
1919
  else {
@@ -1742,9 +1935,9 @@ export class ManageMetadataBase {
1742
1935
  const sql = `SELECT
1743
1936
  ef.ID, ef.Name
1744
1937
  FROM
1745
- [${mj_core_schema()}].vwEntityFields ef
1938
+ ${this.qs(mj_core_schema(), 'vwEntityFields')} ef
1746
1939
  INNER JOIN
1747
- [${mj_core_schema()}].vwEntities e
1940
+ ${this.qs(mj_core_schema(), 'vwEntities')} e
1748
1941
  ON
1749
1942
  ef.EntityID = e.ID
1750
1943
  WHERE
@@ -1752,13 +1945,13 @@ export class ManageMetadataBase {
1752
1945
  ef.Name <> \'ID\' AND
1753
1946
  e.SchemaName NOT IN (${excludeSchemas.map(s => `'${s}'`).join(',')})
1754
1947
  `;
1755
- const fieldsResult = await pool.request().query(sql);
1948
+ const fieldsResult = await this.runQuery(pool, sql);
1756
1949
  const fields = fieldsResult.recordset;
1757
1950
  if (fields && fields.length > 0)
1758
1951
  for (const field of fields) {
1759
1952
  const sDisplayName = stripTrailingChars(convertCamelCaseToHaveSpaces(field.Name), 'ID', true).trim();
1760
1953
  if (sDisplayName.length > 0 && sDisplayName.toLowerCase().trim() !== field.Name.toLowerCase().trim()) {
1761
- const sSQL = `UPDATE [${mj_core_schema()}].EntityField SET ${EntityInfo.UpdatedAtFieldName}=GETUTCDATE(), DisplayName = '${sDisplayName}' WHERE ID = '${field.ID}'`;
1954
+ const sSQL = `UPDATE ${this.qs(mj_core_schema(), 'EntityField')} SET ${EntityInfo.UpdatedAtFieldName}=${this.utcNow()}, DisplayName = '${sDisplayName}' WHERE ID = '${field.ID}'`;
1762
1955
  await this.LogSQLAndExecute(pool, sSQL, `SQL text to update display name for field ${field.Name}`);
1763
1956
  }
1764
1957
  }
@@ -1779,7 +1972,7 @@ export class ManageMetadataBase {
1779
1972
  */
1780
1973
  async setDefaultColumnWidthWhereNeeded(pool, excludeSchemas) {
1781
1974
  try {
1782
- const sSQL = `EXEC ${mj_core_schema()}.spSetDefaultColumnWidthWhereNeeded @ExcludedSchemaNames='${excludeSchemas.join(',')}'`;
1975
+ const sSQL = this.dbProvider.callRoutineSQL(mj_core_schema(), 'spSetDefaultColumnWidthWhereNeeded', [`'${excludeSchemas.join(',')}'`], ['ExcludedSchemaNames']);
1783
1976
  await this.LogSQLAndExecute(pool, sSQL, `SQL text to set default column width where needed`, true);
1784
1977
  return true;
1785
1978
  }
@@ -1801,120 +1994,7 @@ export class ManageMetadataBase {
1801
1994
  */
1802
1995
  getPendingEntityFieldsSELECTSQL() {
1803
1996
  const schema = mj_core_schema();
1804
- const sSQL = `
1805
- -- Materialize system DMV views into temp tables so SQL Server gets real statistics
1806
- -- instead of expanding nested view-on-view joins with bad cardinality estimates
1807
- -- Drop first in case a prior run on this connection left them behind
1808
- IF OBJECT_ID('tempdb..#__mj__CodeGen__vwForeignKeys') IS NOT NULL DROP TABLE #__mj__CodeGen__vwForeignKeys;
1809
- IF OBJECT_ID('tempdb..#__mj__CodeGen__vwTablePrimaryKeys') IS NOT NULL DROP TABLE #__mj__CodeGen__vwTablePrimaryKeys;
1810
- IF OBJECT_ID('tempdb..#__mj__CodeGen__vwTableUniqueKeys') IS NOT NULL DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
1811
-
1812
- SELECT [column], [table], [schema_name], referenced_table, referenced_column, [referenced_schema]
1813
- INTO #__mj__CodeGen__vwForeignKeys
1814
- FROM [${schema}].vwForeignKeys;
1815
-
1816
- SELECT TableName, ColumnName, SchemaName
1817
- INTO #__mj__CodeGen__vwTablePrimaryKeys
1818
- FROM [${schema}].vwTablePrimaryKeys;
1819
-
1820
- SELECT TableName, ColumnName, SchemaName
1821
- INTO #__mj__CodeGen__vwTableUniqueKeys
1822
- FROM [${schema}].vwTableUniqueKeys;
1823
-
1824
- WITH MaxSequences AS (
1825
- -- Calculate the maximum existing sequence for each entity to avoid collisions
1826
- SELECT
1827
- EntityID,
1828
- ISNULL(MAX(Sequence), 0) AS MaxSequence
1829
- FROM
1830
- [${schema}].EntityField
1831
- GROUP BY
1832
- EntityID
1833
- ),
1834
- NumberedRows AS (
1835
- SELECT
1836
- sf.EntityID,
1837
- -- Use dynamic offset based on max existing sequence for this entity to prevent collisions
1838
- -- Add 100000 to ensure we're well above any existing sequences, then add the column sequence
1839
- ISNULL(ms.MaxSequence, 0) + 100000 + sf.Sequence AS Sequence,
1840
- sf.FieldName,
1841
- sf.Description,
1842
- sf.Type,
1843
- sf.Length,
1844
- sf.Precision,
1845
- sf.Scale,
1846
- sf.AllowsNull,
1847
- sf.DefaultValue,
1848
- sf.AutoIncrement,
1849
- IIF(sf.IsVirtual = 1, 0, IIF(sf.FieldName = '${EntityInfo.CreatedAtFieldName}' OR
1850
- sf.FieldName = '${EntityInfo.UpdatedAtFieldName}' OR
1851
- sf.FieldName = '${EntityInfo.DeletedAtFieldName}' OR
1852
- pk.ColumnName IS NOT NULL, 0, 1)) AllowUpdateAPI,
1853
- sf.IsVirtual,
1854
- e.RelationshipDefaultDisplayType,
1855
- e.Name EntityName,
1856
- re.ID RelatedEntityID,
1857
- fk.referenced_column RelatedEntityFieldName,
1858
- IIF(sf.FieldName = 'Name', 1, 0) IsNameField,
1859
- IsPrimaryKey = CASE
1860
- WHEN pk.ColumnName IS NOT NULL THEN 1
1861
- ELSE 0
1862
- END,
1863
- IsUnique = CASE
1864
- WHEN pk.ColumnName IS NOT NULL THEN 1
1865
- ELSE
1866
- CASE
1867
- WHEN uk.ColumnName IS NOT NULL THEN 1
1868
- ELSE 0
1869
- END
1870
- END,
1871
- ROW_NUMBER() OVER (PARTITION BY sf.EntityID, sf.FieldName ORDER BY (SELECT NULL)) AS rn
1872
- FROM
1873
- [${schema}].vwSQLColumnsAndEntityFields sf
1874
- LEFT OUTER JOIN
1875
- MaxSequences ms
1876
- ON
1877
- sf.EntityID = ms.EntityID
1878
- LEFT OUTER JOIN
1879
- [${schema}].Entity e
1880
- ON
1881
- sf.EntityID = e.ID
1882
- LEFT OUTER JOIN
1883
- #__mj__CodeGen__vwForeignKeys fk
1884
- ON
1885
- sf.FieldName = fk.[column] AND
1886
- e.BaseTable = fk.[table] AND
1887
- e.SchemaName = fk.[schema_name]
1888
- LEFT OUTER JOIN
1889
- [${schema}].Entity re -- Related Entity
1890
- ON
1891
- re.BaseTable = fk.referenced_table AND
1892
- re.SchemaName = fk.[referenced_schema]
1893
- LEFT OUTER JOIN
1894
- #__mj__CodeGen__vwTablePrimaryKeys pk
1895
- ON
1896
- e.BaseTable = pk.TableName AND
1897
- sf.FieldName = pk.ColumnName AND
1898
- e.SchemaName = pk.SchemaName
1899
- LEFT OUTER JOIN
1900
- #__mj__CodeGen__vwTableUniqueKeys uk
1901
- ON
1902
- e.BaseTable = uk.TableName AND
1903
- sf.FieldName = uk.ColumnName AND
1904
- e.SchemaName = uk.SchemaName
1905
- WHERE
1906
- EntityFieldID IS NULL -- only where we have NOT YET CREATED EntityField records\n${this.createExcludeTablesAndSchemasFilter('sf.')}
1907
- )
1908
- SELECT *
1909
- FROM NumberedRows
1910
- WHERE rn = 1
1911
- ORDER BY EntityID, Sequence;
1912
-
1913
- DROP TABLE #__mj__CodeGen__vwForeignKeys;
1914
- DROP TABLE #__mj__CodeGen__vwTablePrimaryKeys;
1915
- DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
1916
- `;
1917
- return sSQL;
1997
+ return this.dbProvider.getPendingEntityFieldsSQL(schema);
1918
1998
  }
1919
1999
  /**
1920
2000
  * This method builds a SQL Statement that will insert a row into the EntityField table with information about a new field.
@@ -1946,43 +2026,44 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
1946
2026
  break;
1947
2027
  }
1948
2028
  const parsedDefaultValue = this.parseDefaultValue(n.DefaultValue);
1949
- const quotedDefaultValue = parsedDefaultValue?.trim().length === 0 ? 'NULL' :
1950
- (parsedDefaultValue?.trim().toLowerCase() === 'null' ? 'NULL' : `'${parsedDefaultValue}'`);
1951
- // in the above we are setting quotedDefaultValue to NULL if the parsed default value is an empty string or the string 'NULL' (case insensitive)
2029
+ // Escape single quotes inside the default value so they don't break the SQL string literal
2030
+ const escapedParsedDefault = parsedDefaultValue?.replace(/'/g, "''");
2031
+ // quotedDefaultValue is NULL when parsedDefaultValue is null/undefined, empty, or the literal string 'NULL' (case insensitive)
2032
+ const quotedDefaultValue = (parsedDefaultValue == null || parsedDefaultValue.trim().length === 0)
2033
+ ? 'NULL'
2034
+ : (parsedDefaultValue.trim().toLowerCase() === 'null' ? 'NULL' : `'${escapedParsedDefault}'`);
2035
+ const conflictCheck = `SELECT 1 FROM ${this.qs(mj_core_schema(), 'EntityField')} WHERE ID = '${newEntityFieldUUID}' OR (EntityID = '${n.EntityID}' AND Name = '${n.FieldName}')`;
2036
+ const guard = this.dbProvider.wrapInsertWithConflictGuard(conflictCheck);
1952
2037
  return `
1953
- IF NOT EXISTS (
1954
- SELECT 1 FROM [${mj_core_schema()}].EntityField
1955
- WHERE ID = '${newEntityFieldUUID}' OR
1956
- (EntityID = '${n.EntityID}' AND Name = '${n.FieldName}')
1957
- -- check to make sure we're not inserting a duplicate entity field metadata record
1958
- )
1959
- BEGIN
1960
- INSERT INTO [${mj_core_schema()}].EntityField
2038
+ ${guard.prefix}
2039
+ INSERT INTO ${this.qs(mj_core_schema(), 'EntityField')}
1961
2040
  (
1962
- ID,
1963
- EntityID,
1964
- Sequence,
1965
- Name,
1966
- DisplayName,
1967
- Description,
1968
- Type,
1969
- Length,
1970
- Precision,
1971
- Scale,
1972
- AllowsNull,
1973
- DefaultValue,
1974
- AutoIncrement,
1975
- AllowUpdateAPI,
1976
- IsVirtual,
1977
- RelatedEntityID,
1978
- RelatedEntityFieldName,
1979
- IsNameField,
1980
- IncludeInUserSearchAPI,
1981
- IncludeRelatedEntityNameFieldInBaseView,
1982
- DefaultInView,
1983
- IsPrimaryKey,
1984
- IsUnique,
1985
- RelatedEntityDisplayType
2041
+ ${this.qi('ID')},
2042
+ ${this.qi('EntityID')},
2043
+ ${this.qi('Sequence')},
2044
+ ${this.qi('Name')},
2045
+ ${this.qi('DisplayName')},
2046
+ ${this.qi('Description')},
2047
+ ${this.qi('Type')},
2048
+ ${this.qi('Length')},
2049
+ ${this.qi('Precision')},
2050
+ ${this.qi('Scale')},
2051
+ ${this.qi('AllowsNull')},
2052
+ ${this.qi('DefaultValue')},
2053
+ ${this.qi('AutoIncrement')},
2054
+ ${this.qi('AllowUpdateAPI')},
2055
+ ${this.qi('IsVirtual')},
2056
+ ${this.qi('RelatedEntityID')},
2057
+ ${this.qi('RelatedEntityFieldName')},
2058
+ ${this.qi('IsNameField')},
2059
+ ${this.qi('IncludeInUserSearchAPI')},
2060
+ ${this.qi('IncludeRelatedEntityNameFieldInBaseView')},
2061
+ ${this.qi('DefaultInView')},
2062
+ ${this.qi('IsPrimaryKey')},
2063
+ ${this.qi('IsUnique')},
2064
+ ${this.qi('RelatedEntityDisplayType')},
2065
+ ${this.qi('__mj_CreatedAt')},
2066
+ ${this.qi('__mj_UpdatedAt')}
1986
2067
  )
1987
2068
  VALUES
1988
2069
  (
@@ -2009,9 +2090,11 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
2009
2090
  ${bDefaultInView ? 1 : 0},
2010
2091
  ${n.IsPrimaryKey},
2011
2092
  ${n.IsUnique},
2012
- '${n.RelationshipDefaultDisplayType}'
2093
+ '${n.RelationshipDefaultDisplayType}',
2094
+ ${this.utcNow()},
2095
+ ${this.utcNow()}
2013
2096
  )
2014
- END`;
2097
+ ${guard.suffix}`;
2015
2098
  }
2016
2099
  /**
2017
2100
  * This method takes the stored DEFAULT CONSTRAINT value from the database and parses it to retrieve the actual default value. This is necessary because the default value is
@@ -2023,27 +2106,18 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
2023
2106
  * @returns
2024
2107
  */
2025
2108
  parseDefaultValue(sqlDefaultValue) {
2026
- let sResult = null;
2027
- if (sqlDefaultValue !== null && sqlDefaultValue !== undefined) {
2028
- if (sqlDefaultValue.startsWith('(') && sqlDefaultValue.endsWith(')'))
2029
- sResult = sqlDefaultValue.substring(1, sqlDefaultValue.length - 1);
2030
- else
2031
- sResult = sqlDefaultValue;
2032
- if (sResult.toUpperCase().startsWith('N\'') && sResult.endsWith('\''))
2033
- sResult = sResult.substring(2, sResult.length - 1);
2034
- if (sResult.startsWith('\'') && sResult.endsWith('\''))
2035
- sResult = sResult.substring(1, sResult.length - 1);
2036
- }
2037
- return sResult;
2109
+ if (sqlDefaultValue === null || sqlDefaultValue === undefined) {
2110
+ return null;
2111
+ }
2112
+ return this.dbProvider.parseColumnDefaultValue(sqlDefaultValue) ?? null;
2038
2113
  }
2039
2114
  async createNewEntityFieldsFromSchema(pool) {
2040
2115
  try {
2041
2116
  const sSQL = this.getPendingEntityFieldsSELECTSQL();
2042
- const newEntityFieldsResult = await pool.request().query(sSQL);
2117
+ const newEntityFieldsResult = await this.runQuery(pool, sSQL);
2043
2118
  const newEntityFields = newEntityFieldsResult.recordset;
2044
2119
  if (newEntityFields.length > 0) {
2045
- const transaction = new sql.Transaction(pool);
2046
- await transaction.begin();
2120
+ const transaction = await pool.beginTransaction();
2047
2121
  try {
2048
2122
  // wrap in a transaction so we get all of it or none of it
2049
2123
  for (let i = 0; i < newEntityFields.length; ++i) {
@@ -2091,9 +2165,7 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
2091
2165
  */
2092
2166
  async updateEntityFieldRelatedEntityNameFieldMap(pool, entityFieldID, relatedEntityNameFieldMap) {
2093
2167
  try {
2094
- const sSQL = `EXEC [${mj_core_schema()}].spUpdateEntityFieldRelatedEntityNameFieldMap
2095
- @EntityFieldID='${entityFieldID}',
2096
- @RelatedEntityNameFieldMap='${relatedEntityNameFieldMap}'`;
2168
+ const sSQL = this.dbProvider.callRoutineSQL(mj_core_schema(), 'spUpdateEntityFieldRelatedEntityNameFieldMap', [`'${entityFieldID}'`, `'${relatedEntityNameFieldMap}'`], ['EntityFieldID', 'RelatedEntityNameFieldMap']);
2097
2169
  await this.LogSQLAndExecute(pool, sSQL, `SQL text to update entity field related entity name field map for entity field ID ${entityFieldID}`);
2098
2170
  return true;
2099
2171
  }
@@ -2104,7 +2176,7 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
2104
2176
  }
2105
2177
  async updateExistingEntitiesFromSchema(pool, excludeSchemas) {
2106
2178
  try {
2107
- const sSQL = `EXEC [${mj_core_schema()}].spUpdateExistingEntitiesFromSchema @ExcludedSchemaNames='${excludeSchemas.join(',')}'`;
2179
+ const sSQL = this.dbProvider.callRoutineSQL(mj_core_schema(), 'spUpdateExistingEntitiesFromSchema', [`'${excludeSchemas.join(',')}'`], ['ExcludedSchemaNames']);
2108
2180
  const result = await this.LogSQLAndExecute(pool, sSQL, `SQL text to update existing entities from schema`, true);
2109
2181
  // result contains the updated entities, and there is a property of each row called Name which has the entity name that was modified
2110
2182
  // add these to the modified entity list if they're not already in there
@@ -2129,7 +2201,7 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
2129
2201
  }
2130
2202
  async updateExistingEntityFieldsFromSchema(pool, excludeSchemas) {
2131
2203
  try {
2132
- const sSQL = `EXEC [${mj_core_schema()}].spUpdateExistingEntityFieldsFromSchema @ExcludedSchemaNames='${excludeSchemas.join(',')}'`;
2204
+ const sSQL = this.dbProvider.callRoutineSQL(mj_core_schema(), 'spUpdateExistingEntityFieldsFromSchema', [`'${excludeSchemas.join(',')}'`], ['ExcludedSchemaNames']);
2133
2205
  const result = await this.LogSQLAndExecute(pool, sSQL, `SQL text to update existing entity fields from schema`, true);
2134
2206
  // result contains the updated entity fields
2135
2207
  // there is a field in there called EntityName. Get a distinct list of entity names from this and add them
@@ -2154,7 +2226,7 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
2154
2226
  */
2155
2227
  async updateSchemaInfoFromDatabase(pool, excludeSchemas) {
2156
2228
  try {
2157
- const sSQL = `EXEC [${mj_core_schema()}].spUpdateSchemaInfoFromDatabase @ExcludedSchemaNames='${excludeSchemas.join(',')}'`;
2229
+ const sSQL = this.dbProvider.callRoutineSQL(mj_core_schema(), 'spUpdateSchemaInfoFromDatabase', [`'${excludeSchemas.join(',')}'`], ['ExcludedSchemaNames']);
2158
2230
  const result = await this.LogSQLAndExecute(pool, sSQL, `SQL text to sync schema info from database schemas`, true);
2159
2231
  if (result && result.length > 0) {
2160
2232
  logStatus(` > Updated/created ${result.length} SchemaInfo records`);
@@ -2185,8 +2257,8 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
2185
2257
  */
2186
2258
  async loadSchemaInfoRecords(pool) {
2187
2259
  try {
2188
- const sSQL = `SELECT * FROM [${mj_core_schema()}].SchemaInfo`;
2189
- const result = await pool.request().query(sSQL);
2260
+ const sSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'SchemaInfo')}`;
2261
+ const result = await this.runQuery(pool, sSQL);
2190
2262
  if (result?.recordset?.length > 0) {
2191
2263
  this.cacheSchemaInfoRecords(result.recordset);
2192
2264
  }
@@ -2199,7 +2271,7 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
2199
2271
  }
2200
2272
  async deleteUnneededEntityFields(pool, excludeSchemas) {
2201
2273
  try {
2202
- const sSQL = `EXEC [${mj_core_schema()}].spDeleteUnneededEntityFields @ExcludedSchemaNames='${excludeSchemas.join(',')}'`;
2274
+ const sSQL = this.dbProvider.callRoutineSQL(mj_core_schema(), 'spDeleteUnneededEntityFields', [`'${excludeSchemas.join(',')}'`], ['ExcludedSchemaNames']);
2203
2275
  const result = await this.LogSQLAndExecute(pool, sSQL, `SQL text to delete unneeded entity fields`, true);
2204
2276
  // result contains the DELETED entity fields
2205
2277
  // there is a field in there called Entity. Get a distinct list of entity names from this and add them
@@ -2220,15 +2292,15 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
2220
2292
  // evaluate it to see if it is a simple series of OR statements or not, if it is a simple series of OR statements, we can parse the possible values
2221
2293
  // for the field and sync that up with the EntityFieldValue table. If it is not a simple series of OR statements, we will not be able to parse it and we'll
2222
2294
  // just ignore it.
2223
- const filter = excludeSchemas && excludeSchemas.length > 0 ? ` WHERE SchemaName NOT IN (${excludeSchemas.map(s => `'${s}'`).join(',')})` : '';
2224
- const sSQL = `SELECT * FROM [${mj_core_schema()}].vwEntityFieldsWithCheckConstraints${filter}`;
2225
- const resultResult = await pool.request().query(sSQL);
2295
+ const filter = this.dbProvider.getCheckConstraintsSchemaFilter(excludeSchemas);
2296
+ const sSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'vwEntityFieldsWithCheckConstraints')}${filter}`;
2297
+ const resultResult = await this.runQuery(pool, sSQL);
2226
2298
  const result = resultResult.recordset;
2227
- const efvSQL = `SELECT * FROM [${mj_core_schema()}].EntityFieldValue`;
2228
- const allEntityFieldValuesResult = await pool.request().query(efvSQL);
2299
+ const efvSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'EntityFieldValue')}`;
2300
+ const allEntityFieldValuesResult = await this.runQuery(pool, efvSQL);
2229
2301
  const allEntityFieldValues = allEntityFieldValuesResult.recordset;
2230
- const efSQL = `SELECT * FROM [${mj_core_schema()}].vwEntityFields ORDER BY EntityID, Sequence`;
2231
- const allEntityFieldsResult = await pool.request().query(efSQL);
2302
+ const efSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'vwEntityFields')} ORDER BY EntityID, Sequence`;
2303
+ const allEntityFieldsResult = await this.runQuery(pool, efSQL);
2232
2304
  const allEntityFields = allEntityFieldsResult.recordset;
2233
2305
  const generationPromises = [];
2234
2306
  const columnLevelResults = result.filter((r) => r.EntityFieldID); // get the column level constraints
@@ -2249,11 +2321,11 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
2249
2321
  await this.syncEntityFieldValues(pool, r.EntityFieldID, parsedValues, allEntityFieldValues);
2250
2322
  // finally, make sure the ValueListType column within the EntityField table is set to "List" because for check constraints we only allow the values specified in the list.
2251
2323
  // check to see if the ValueListType is already set to "List", if not, update it
2252
- const sSQLCheck = `SELECT ValueListType FROM [${mj_core_schema()}].EntityField WHERE ID='${r.EntityFieldID}'`;
2253
- const checkResultResult = await pool.request().query(sSQLCheck);
2324
+ const sSQLCheck = `SELECT ValueListType FROM ${this.qs(mj_core_schema(), 'EntityField')} WHERE ID='${r.EntityFieldID}'`;
2325
+ const checkResultResult = await this.runQuery(pool, sSQLCheck);
2254
2326
  const checkResult = checkResultResult.recordset;
2255
2327
  if (checkResult && checkResult.length > 0 && checkResult[0].ValueListType.trim().toLowerCase() !== 'list') {
2256
- const sSQL = `UPDATE [${mj_core_schema()}].EntityField SET ValueListType='List' WHERE ID='${r.EntityFieldID}'`;
2328
+ const sSQL = `UPDATE ${this.qs(mj_core_schema(), 'EntityField')} SET ValueListType='List' WHERE ID='${r.EntityFieldID}'`;
2257
2329
  await this.LogSQLAndExecute(pool, sSQL, `SQL text to update ValueListType for entity field ID ${r.EntityFieldID}`);
2258
2330
  }
2259
2331
  }
@@ -2381,13 +2453,12 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
2381
2453
  // now, loop through the possible values and add any that are not already in the database
2382
2454
  // Step 1: for any existing value that is NOT in the list of possible Values, delete it
2383
2455
  let numRemoved = 0;
2384
- const transaction = new sql.Transaction(ds);
2385
- await transaction.begin();
2456
+ const transaction = await ds.beginTransaction();
2386
2457
  try {
2387
2458
  for (const ev of existingValues) {
2388
2459
  if (!possibleValues.find(v => v === ev.Value)) {
2389
2460
  // delete the value from the database
2390
- const sSQLDelete = `DELETE FROM [${mj_core_schema()}].EntityFieldValue WHERE ID='${ev.ID}'`;
2461
+ const sSQLDelete = `DELETE FROM ${this.qs(mj_core_schema(), 'EntityFieldValue')} WHERE ID='${ev.ID}'`;
2391
2462
  await this.LogSQLAndExecute(ds, sSQLDelete, `SQL text to delete entity field value ID ${ev.ID}`);
2392
2463
  numRemoved++;
2393
2464
  }
@@ -2399,10 +2470,10 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
2399
2470
  // Generate a UUID for this new EntityFieldValue record
2400
2471
  const newId = uuidv4();
2401
2472
  // add the value to the database with explicit ID
2402
- const sSQLInsert = `INSERT INTO [${mj_core_schema()}].EntityFieldValue
2403
- (ID, EntityFieldID, Sequence, Value, Code)
2473
+ const sSQLInsert = `INSERT INTO ${this.qs(mj_core_schema(), 'EntityFieldValue')}
2474
+ (${this.qi('ID')}, ${this.qi('EntityFieldID')}, ${this.qi('Sequence')}, ${this.qi('Value')}, ${this.qi('Code')}, ${this.qi('__mj_CreatedAt')}, ${this.qi('__mj_UpdatedAt')})
2404
2475
  VALUES
2405
- ('${newId}', '${entityFieldID}', ${1 + possibleValues.indexOf(v)}, '${v}', '${v}')`;
2476
+ ('${newId}', '${entityFieldID}', ${1 + possibleValues.indexOf(v)}, '${v}', '${v}', ${this.utcNow()}, ${this.utcNow()})`;
2406
2477
  await this.LogSQLAndExecute(ds, sSQLInsert, `SQL text to insert entity field value with ID ${newId}`);
2407
2478
  numAdded++;
2408
2479
  }
@@ -2413,7 +2484,7 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
2413
2484
  const ev = existingValues.find((ev) => ev.Value === v);
2414
2485
  if (ev && ev.Sequence !== 1 + possibleValues.indexOf(v)) {
2415
2486
  // update the sequence to match the order in the possible values list, if it doesn't already match
2416
- const sSQLUpdate = `UPDATE [${mj_core_schema()}].EntityFieldValue SET Sequence=${1 + possibleValues.indexOf(v)} WHERE ID='${ev.ID}'`;
2487
+ const sSQLUpdate = `UPDATE ${this.qs(mj_core_schema(), 'EntityFieldValue')} SET Sequence=${1 + possibleValues.indexOf(v)} WHERE ID='${ev.ID}'`;
2417
2488
  await this.LogSQLAndExecute(ds, sSQLUpdate, `SQL text to update entity field value sequence`);
2418
2489
  numUpdated++;
2419
2490
  }
@@ -2433,17 +2504,25 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
2433
2504
  }
2434
2505
  parseCheckConstraintValues(constraintDefinition, fieldName, entityName) {
2435
2506
  // This regex checks for the overall structure including field name and 'OR' sequences
2436
- // an example of a valid constraint definition would be: ([FieldName]='Value1' OR [FieldName]='Value2' OR [FieldName]='Value3')
2437
- // like: ([AutoRunIntervalUnits]='Years' OR [AutoRunIntervalUnits]='Months' OR [AutoRunIntervalUnits]='Weeks' OR [AutoRunIntervalUnits]='Days' OR [AutoRunIntervalUnits]='Hours' OR [AutoRunIntervalUnits]='Minutes')
2438
- // Also handles constraints with optional NULL: ([FieldName]='Value1' OR [FieldName]='Value2' OR [FieldName] IS NULL)
2439
- // Also handles nested NULL pattern: ([FieldName] IS NULL OR ([FieldName]='Value1' OR [FieldName]='Value2'))
2507
+ // SQL Server uses [FieldName]='Value' quoting, PostgreSQL uses "FieldName" or unquoted FieldName
2508
+ // We handle both: [FieldName], "FieldName", or bare FieldName
2440
2509
  // Note: Assuming fieldName does not contain regex special characters; otherwise, it needs to be escaped as well.
2441
2510
  const processedConstraint = constraintDefinition.replace(/(^|[=(\s])N'([^']*)'/g, "$1'$2'");
2442
- // Check for nested pattern: ([Field] IS NULL OR ([Field]='Value1' OR ...))
2443
- const nestedNullRegex = new RegExp(`^\\(\\[${fieldName}\\] IS NULL OR \\(\\[${fieldName}\\]='[^']+'(?: OR \\[${fieldName}\\]='[^']+?')+\\)\\)$`);
2511
+ // PostgreSQL ANY (ARRAY[...]) pattern:
2512
+ // CHECK ((("FieldName")::text = ANY ((ARRAY['Val1'::character varying, 'Val2'::character varying])::text[])))
2513
+ // Also handles simpler forms: ("FieldName" = ANY (ARRAY['Val1', 'Val2']))
2514
+ const pgArrayValues = this.parsePgArrayConstraint(processedConstraint);
2515
+ if (pgArrayValues) {
2516
+ return pgArrayValues;
2517
+ }
2518
+ // Build a regex fragment that matches the field name in any quoting style:
2519
+ // [FieldName] (SQL Server) or "FieldName" (PostgreSQL) or bare FieldName
2520
+ const quotedField = `(?:\\[${fieldName}\\]|"${fieldName}"|${fieldName})`;
2521
+ // Check for nested pattern: (Field IS NULL OR (Field='Value1' OR ...))
2522
+ const nestedNullRegex = new RegExp(`^\\(${quotedField} IS NULL OR \\(${quotedField}='[^']+'(?: OR ${quotedField}='[^']+?')+\\)\\)$`);
2444
2523
  if (nestedNullRegex.test(processedConstraint)) {
2445
2524
  // Extract values from nested pattern - same extraction logic works
2446
- const valueRegex = new RegExp(`\\[${fieldName}\\]='([^']+)\'`, 'g');
2525
+ const valueRegex = new RegExp(`${quotedField}='([^']+)\'`, 'g');
2447
2526
  let match;
2448
2527
  const possibleValues = [];
2449
2528
  while ((match = valueRegex.exec(processedConstraint)) !== null) {
@@ -2457,16 +2536,13 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
2457
2536
  return possibleValues.length > 0 ? possibleValues : null;
2458
2537
  }
2459
2538
  // Check for standard pattern with optional trailing IS NULL
2460
- const structureRegex = new RegExp(`^\\(\\[${fieldName}\\]='[^']+'(?: OR \\[${fieldName}\\]='[^']+?')+(?: OR \\[${fieldName}\\] IS NULL)?\\)$`);
2539
+ const structureRegex = new RegExp(`^\\(${quotedField}='[^']+'(?: OR ${quotedField}='[^']+?')+(?: OR ${quotedField} IS NULL)?\\)$`);
2461
2540
  if (!structureRegex.test(processedConstraint)) {
2462
- // decided to NOT log these warnings anymore becuase they make it appear to the user that there is a problem but there is NOT, this is normal behvario for all othe types of
2463
- // check constraints that are not simple OR conditions
2464
- //logWarning(` Can't extract value list from [${entityName}].[${fieldName}]. The check constraint does not match the simple OR condition pattern or field name does not match: ${constraintDefinition}`);
2465
2541
  return null;
2466
2542
  }
2467
2543
  else {
2468
2544
  // Regular expression to match the values within the single quotes specifically for the field
2469
- const valueRegex = new RegExp(`\\[${fieldName}\\]='([^']+)\'`, 'g');
2545
+ const valueRegex = new RegExp(`${quotedField}='([^']+)\'`, 'g');
2470
2546
  let match;
2471
2547
  const possibleValues = [];
2472
2548
  // Use regex to find matches and extract the values
@@ -2483,24 +2559,51 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
2483
2559
  return possibleValues;
2484
2560
  }
2485
2561
  }
2562
+ /**
2563
+ * Parses PostgreSQL ANY (ARRAY[...]) CHECK constraint syntax.
2564
+ * PG's pg_get_constraintdef() returns constraints like:
2565
+ * CHECK ((("Status")::text = ANY ((ARRAY['Confirmed'::character varying, 'Cancelled'::character varying])::text[])))
2566
+ * This method extracts the string values from the ARRAY literal.
2567
+ */
2568
+ parsePgArrayConstraint(constraintDefinition) {
2569
+ // Match the ARRAY[...] portion, handling optional type casts
2570
+ const arrayMatch = constraintDefinition.match(/\bARRAY\s*\[([^\]]+)\]/i);
2571
+ if (!arrayMatch) {
2572
+ return null;
2573
+ }
2574
+ // Extract individual values from the array content
2575
+ // Each element looks like: 'Value'::character varying or just 'Value'
2576
+ const arrayContent = arrayMatch[1];
2577
+ const valueRegex = /'([^']+)'(?:::[^,\]]*)?/g;
2578
+ const possibleValues = [];
2579
+ let match;
2580
+ while ((match = valueRegex.exec(arrayContent)) !== null) {
2581
+ if (match[1]) {
2582
+ possibleValues.push(match[1]);
2583
+ }
2584
+ }
2585
+ return possibleValues.length > 0 ? possibleValues : null;
2586
+ }
2486
2587
  createExcludeTablesAndSchemasFilter(fieldPrefix) {
2487
2588
  let sExcludeTables = '';
2488
2589
  let sExcludeSchemas = '';
2590
+ const schemaCol = `${fieldPrefix}${this.qi('SchemaName')}`;
2591
+ const tableCol = `${fieldPrefix}${this.qi('TableName')}`;
2489
2592
  if (configInfo.excludeTables) {
2490
2593
  for (let i = 0; i < configInfo.excludeTables.length; ++i) {
2491
2594
  const t = configInfo.excludeTables[i];
2492
2595
  sExcludeTables += (sExcludeTables.length > 0 ? ' AND ' : '') +
2493
- (t.schema.indexOf('%') > -1 ? ` NOT ( ${fieldPrefix}SchemaName LIKE '${t.schema}'` :
2494
- ` NOT ( ${fieldPrefix}SchemaName = '${t.schema}'`);
2495
- sExcludeTables += (t.table.indexOf('%') > -1 ? ` AND ${fieldPrefix}TableName LIKE '${t.table}') ` :
2496
- ` AND ${fieldPrefix}TableName = '${t.table}') `);
2596
+ (t.schema.indexOf('%') > -1 ? ` NOT ( ${schemaCol} LIKE '${t.schema}'` :
2597
+ ` NOT ( ${schemaCol} = '${t.schema}'`);
2598
+ sExcludeTables += (t.table.indexOf('%') > -1 ? ` AND ${tableCol} LIKE '${t.table}') ` :
2599
+ ` AND ${tableCol} = '${t.table}') `);
2497
2600
  }
2498
2601
  }
2499
2602
  if (configInfo.excludeSchemas) {
2500
2603
  for (let i = 0; i < configInfo.excludeSchemas.length; ++i) {
2501
2604
  const s = configInfo.excludeSchemas[i];
2502
2605
  sExcludeSchemas += (sExcludeSchemas.length > 0 ? ' AND ' : '') +
2503
- (s.indexOf('%') > -1 ? `${fieldPrefix}SchemaName NOT LIKE '${s}'` : `${fieldPrefix}SchemaName <> '${s}'`);
2606
+ (s.indexOf('%') > -1 ? `${schemaCol} NOT LIKE '${s}'` : `${schemaCol} <> '${s}'`);
2504
2607
  }
2505
2608
  }
2506
2609
  const sWhere = (sExcludeTables.length > 0 || sExcludeSchemas.length > 0 ? ` AND ` : '') +
@@ -2510,13 +2613,12 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
2510
2613
  }
2511
2614
  async createNewEntities(pool, currentUser) {
2512
2615
  try {
2513
- const sSQL = `SELECT * FROM [${mj_core_schema()}].vwSQLTablesAndEntities WHERE EntityID IS NULL ` + this.createExcludeTablesAndSchemasFilter('');
2514
- const newEntitiesResult = await pool.request().query(sSQL);
2616
+ const sSQL = `SELECT * FROM ${this.qs(mj_core_schema(), 'vwSQLTablesAndEntities')} WHERE ${this.qi('EntityID')} IS NULL ` + this.createExcludeTablesAndSchemasFilter('');
2617
+ const newEntitiesResult = await this.runQuery(pool, sSQL);
2515
2618
  const newEntities = newEntitiesResult.recordset;
2516
2619
  if (newEntities && newEntities.length > 0) {
2517
2620
  const md = new Metadata();
2518
- const transaction = new sql.Transaction(pool);
2519
- await transaction.begin();
2621
+ const transaction = await pool.beginTransaction();
2520
2622
  try {
2521
2623
  // wrap in a transaction so we get all of it or none of it
2522
2624
  for (let i = 0; i < newEntities.length; ++i) {
@@ -2547,9 +2649,9 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
2547
2649
  // criteria:
2548
2650
  // 1) entity has a field that is a primary key
2549
2651
  // validate all of these factors by getting the sql from SQL Server and check the result, if failure, shouldCreate=false and generate validation message, otherwise return empty validation message and true for shouldCreate.
2550
- const query = `EXEC ${Metadata.Provider.ConfigData.MJCoreSchemaName}.spGetPrimaryKeyForTable @TableName='${newEntity.TableName}', @SchemaName='${newEntity.SchemaName}'`;
2652
+ const query = this.dbProvider.callRoutineSQL(Metadata.Provider.ConfigData.MJCoreSchemaName, 'spGetPrimaryKeyForTable', [`'${newEntity.TableName}'`, `'${newEntity.SchemaName}'`], ['TableName', 'SchemaName']);
2551
2653
  try {
2552
- const resultResult = await ds.request().query(query);
2654
+ const resultResult = await ds.query(query);
2553
2655
  const result = resultResult.recordset;
2554
2656
  if (result.length === 0) {
2555
2657
  // No database PK constraint found - check if there's a soft PK defined in config
@@ -2784,9 +2886,9 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
2784
2886
  if (configInfo.newEntityDefaults.AddToApplicationWithSchemaName) {
2785
2887
  // only do this if the configuration setting is set to add new entities to applications for schema names
2786
2888
  for (const appUUID of apps) {
2787
- const sSQLInsertApplicationEntity = `INSERT INTO ${mj_core_schema()}.ApplicationEntity
2788
- (ApplicationID, EntityID, Sequence) VALUES
2789
- ('${appUUID}', '${newEntityID}', (SELECT ISNULL(MAX(Sequence),0)+1 FROM ${mj_core_schema()}.ApplicationEntity WHERE ApplicationID = '${appUUID}'))`;
2889
+ const sSQLInsertApplicationEntity = `INSERT INTO ${this.qs(mj_core_schema(), 'ApplicationEntity')}
2890
+ (${this.qi('ApplicationID')}, ${this.qi('EntityID')}, ${this.qi('Sequence')}, ${this.qi('__mj_CreatedAt')}, ${this.qi('__mj_UpdatedAt')}) VALUES
2891
+ ('${appUUID}', '${newEntityID}', (SELECT COALESCE(MAX(${this.qi('Sequence')}),0)+1 FROM ${this.qs(mj_core_schema(), 'ApplicationEntity')} WHERE ${this.qi('ApplicationID')} = '${appUUID}'), ${this.utcNow()}, ${this.utcNow()})`;
2790
2892
  await this.LogSQLAndExecute(pool, sSQLInsertApplicationEntity, `SQL generated to add new entity ${newEntityName} to application ID: '${appUUID}'`);
2791
2893
  }
2792
2894
  }
@@ -2805,9 +2907,9 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
2805
2907
  for (const p of permissions) {
2806
2908
  const RoleID = md.Roles.find(r => r.Name.trim().toLowerCase() === p.RoleName.trim().toLowerCase())?.ID;
2807
2909
  if (RoleID) {
2808
- const sSQLInsertPermission = `INSERT INTO ${mj_core_schema()}.EntityPermission
2809
- (EntityID, RoleID, CanRead, CanCreate, CanUpdate, CanDelete) VALUES
2810
- ('${newEntityID}', '${RoleID}', ${p.CanRead ? 1 : 0}, ${p.CanCreate ? 1 : 0}, ${p.CanUpdate ? 1 : 0}, ${p.CanDelete ? 1 : 0})`;
2910
+ const sSQLInsertPermission = `INSERT INTO ${this.qs(mj_core_schema(), 'EntityPermission')}
2911
+ (${this.qi('EntityID')}, ${this.qi('RoleID')}, ${this.qi('CanRead')}, ${this.qi('CanCreate')}, ${this.qi('CanUpdate')}, ${this.qi('CanDelete')}, ${this.qi('__mj_CreatedAt')}, ${this.qi('__mj_UpdatedAt')}) VALUES
2912
+ ('${newEntityID}', '${RoleID}', ${p.CanRead ? 1 : 0}, ${p.CanCreate ? 1 : 0}, ${p.CanUpdate ? 1 : 0}, ${p.CanDelete ? 1 : 0}, ${this.utcNow()}, ${this.utcNow()})`;
2811
2913
  await this.LogSQLAndExecute(pool, sSQLInsertPermission, `SQL generated to add new permission for entity ${newEntityName} for role ${p.RoleName}`);
2812
2914
  }
2813
2915
  else
@@ -2832,10 +2934,12 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
2832
2934
  }
2833
2935
  async isSchemaNew(pool, schemaName) {
2834
2936
  // check to see if there are any entities in the db with this schema name
2835
- const sSQL = `SELECT COUNT(*) AS Count FROM [${mj_core_schema()}].Entity WHERE SchemaName = '${schemaName}'`;
2836
- const resultResult = await pool.request().query(sSQL);
2937
+ // Quote the alias so PostgreSQL preserves PascalCase (unquoted aliases are lowercased in PG)
2938
+ const sSQL = `SELECT COUNT(*) AS ${this.qi('Count')} FROM ${this.qs(mj_core_schema(), 'Entity')} WHERE ${this.qi('SchemaName')} = '${schemaName}'`;
2939
+ const resultResult = await this.runQuery(pool, sSQL);
2837
2940
  const result = resultResult.recordset;
2838
- return result && result.length > 0 ? result[0].Count === 0 : true;
2941
+ // Use Number() because PG returns COUNT(*) as bigint, which the pg driver delivers as a string
2942
+ return result && result.length > 0 ? Number(result[0].Count) === 0 : true;
2839
2943
  }
2840
2944
  /**
2841
2945
  * Creates a new application using direct SQL INSERT to ensure it's captured in SQL logging.
@@ -2860,7 +2964,7 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
2860
2964
  .replace(/[^a-z0-9-]/g, '') // remove special chars
2861
2965
  .replace(/-+/g, '-') // collapse multiple hyphens
2862
2966
  .replace(/^-|-$/g, ''); // trim hyphens from start/end
2863
- const sSQL = `INSERT INTO [${mj_core_schema()}].Application (ID, Name, Description, SchemaAutoAddNewEntities, Path, AutoUpdatePath)
2967
+ const sSQL = `INSERT INTO ${this.qs(mj_core_schema(), 'Application')} (ID, Name, Description, SchemaAutoAddNewEntities, Path, AutoUpdatePath)
2864
2968
  VALUES ('${appID}', '${appName}', 'Generated for schema', '${schemaName}', '${path}', 1)`;
2865
2969
  await this.LogSQLAndExecute(pool, sSQL, `SQL generated to create new application ${appName}`);
2866
2970
  LogStatus(`Created new application ${appName} with Path: ${path}`);
@@ -2872,15 +2976,15 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
2872
2976
  }
2873
2977
  }
2874
2978
  async applicationExists(pool, applicationName) {
2875
- const sSQL = `SELECT ID FROM [${mj_core_schema()}].Application WHERE Name = '${applicationName}'`;
2876
- const resultResult = await pool.request().query(sSQL);
2979
+ const sSQL = `SELECT ID FROM ${this.qs(mj_core_schema(), 'Application')} WHERE Name = '${applicationName}'`;
2980
+ const resultResult = await this.runQuery(pool, sSQL);
2877
2981
  const result = resultResult.recordset;
2878
2982
  return result && result.length > 0 ? result[0].ID.length > 0 : false;
2879
2983
  }
2880
2984
  async getApplicationIDForSchema(pool, schemaName) {
2881
2985
  // get all the apps each time from DB as we might be adding, don't use Metadata here for that reason
2882
- const sSQL = `SELECT ID, Name, SchemaAutoAddNewEntities FROM [${mj_core_schema()}].vwApplications`;
2883
- const resultResult = await pool.request().query(sSQL);
2986
+ const sSQL = `SELECT ID, Name, SchemaAutoAddNewEntities FROM ${this.qs(mj_core_schema(), 'vwApplications')}`;
2987
+ const resultResult = await this.runQuery(pool, sSQL);
2884
2988
  const result = resultResult.recordset;
2885
2989
  if (!result || result.length === 0) {
2886
2990
  // no applications found, return null
@@ -2921,9 +3025,9 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
2921
3025
  if (apps && apps.length > 0) {
2922
3026
  if (configInfo.newEntityDefaults.AddToApplicationWithSchemaName) {
2923
3027
  for (const appUUID of apps) {
2924
- const sSQLInsert = `INSERT INTO ${mj_core_schema()}.ApplicationEntity
2925
- (ApplicationID, EntityID, Sequence) VALUES
2926
- ('${appUUID}', '${entityId}', (SELECT ISNULL(MAX(Sequence),0)+1 FROM ${mj_core_schema()}.ApplicationEntity WHERE ApplicationID = '${appUUID}'))`;
3028
+ const sSQLInsert = `INSERT INTO ${this.qs(mj_core_schema(), 'ApplicationEntity')}
3029
+ (${this.qi('ApplicationID')}, ${this.qi('EntityID')}, ${this.qi('Sequence')}, ${this.qi('__mj_CreatedAt')}, ${this.qi('__mj_UpdatedAt')}) VALUES
3030
+ ('${appUUID}', '${entityId}', (SELECT COALESCE(MAX(${this.qi('Sequence')}),0)+1 FROM ${this.qs(mj_core_schema(), 'ApplicationEntity')} WHERE ${this.qi('ApplicationID')} = '${appUUID}'), ${this.utcNow()}, ${this.utcNow()})`;
2927
3031
  await this.LogSQLAndExecute(pool, sSQLInsert, `SQL generated to add entity ${entityName} to application ID: '${appUUID}'`);
2928
3032
  }
2929
3033
  }
@@ -2945,9 +3049,9 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
2945
3049
  for (const p of permissions) {
2946
3050
  const RoleID = md.Roles.find(r => r.Name.trim().toLowerCase() === p.RoleName.trim().toLowerCase())?.ID;
2947
3051
  if (RoleID) {
2948
- const sSQLInsert = `INSERT INTO ${mj_core_schema()}.EntityPermission
2949
- (EntityID, RoleID, CanRead, CanCreate, CanUpdate, CanDelete) VALUES
2950
- ('${entityId}', '${RoleID}', ${p.CanRead ? 1 : 0}, ${p.CanCreate ? 1 : 0}, ${p.CanUpdate ? 1 : 0}, ${p.CanDelete ? 1 : 0})`;
3052
+ const sSQLInsert = `INSERT INTO ${this.qs(mj_core_schema(), 'EntityPermission')}
3053
+ (${this.qi('EntityID')}, ${this.qi('RoleID')}, ${this.qi('CanRead')}, ${this.qi('CanCreate')}, ${this.qi('CanUpdate')}, ${this.qi('CanDelete')}, ${this.qi('__mj_CreatedAt')}, ${this.qi('__mj_UpdatedAt')}) VALUES
3054
+ ('${entityId}', '${RoleID}', ${p.CanRead ? 1 : 0}, ${p.CanCreate ? 1 : 0}, ${p.CanUpdate ? 1 : 0}, ${p.CanDelete ? 1 : 0}, ${this.utcNow()}, ${this.utcNow()})`;
2951
3055
  await this.LogSQLAndExecute(pool, sSQLInsert, `SQL generated to add permission for entity ${entityName} for role ${p.RoleName}`);
2952
3056
  }
2953
3057
  else {
@@ -2957,27 +3061,30 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
2957
3061
  }
2958
3062
  createNewEntityInsertSQL(newEntityUUID, newEntityName, newEntity, newEntitySuffix, newEntityDisplayName) {
2959
3063
  const newEntityDefaults = configInfo.newEntityDefaults;
2960
- const newEntityDescriptionEscaped = newEntity.Description ? `'${newEntity.Description.replace(/'/g, "''")}` : null;
3064
+ const newEntityDescriptionEscaped = newEntity.EntityDescription ? `'${newEntity.EntityDescription.replace(/'/g, "''")}'` : null;
3065
+ const q = (name) => this.qi(name);
2961
3066
  const sSQLInsert = `
2962
- INSERT INTO [${mj_core_schema()}].Entity (
2963
- ID,
2964
- Name,
2965
- DisplayName,
2966
- Description,
2967
- NameSuffix,
2968
- BaseTable,
2969
- BaseView,
2970
- SchemaName,
2971
- IncludeInAPI,
2972
- AllowUserSearchAPI
2973
- ${newEntityDefaults.TrackRecordChanges === undefined ? '' : ', TrackRecordChanges'}
2974
- ${newEntityDefaults.AuditRecordAccess === undefined ? '' : ', AuditRecordAccess'}
2975
- ${newEntityDefaults.AuditViewRuns === undefined ? '' : ', AuditViewRuns'}
2976
- ${newEntityDefaults.AllowAllRowsAPI === undefined ? '' : ', AllowAllRowsAPI'}
2977
- ${newEntityDefaults.AllowCreateAPI === undefined ? '' : ', AllowCreateAPI'}
2978
- ${newEntityDefaults.AllowUpdateAPI === undefined ? '' : ', AllowUpdateAPI'}
2979
- ${newEntityDefaults.AllowDeleteAPI === undefined ? '' : ', AllowDeleteAPI'}
2980
- ${newEntityDefaults.UserViewMaxRows === undefined ? '' : ', UserViewMaxRows'}
3067
+ INSERT INTO ${this.qs(mj_core_schema(), 'Entity')} (
3068
+ ${q('ID')},
3069
+ ${q('Name')},
3070
+ ${q('DisplayName')},
3071
+ ${q('Description')},
3072
+ ${q('NameSuffix')},
3073
+ ${q('BaseTable')},
3074
+ ${q('BaseView')},
3075
+ ${q('SchemaName')},
3076
+ ${q('IncludeInAPI')},
3077
+ ${q('AllowUserSearchAPI')}
3078
+ ${newEntityDefaults.TrackRecordChanges === undefined ? '' : ', ' + q('TrackRecordChanges')}
3079
+ ${newEntityDefaults.AuditRecordAccess === undefined ? '' : ', ' + q('AuditRecordAccess')}
3080
+ ${newEntityDefaults.AuditViewRuns === undefined ? '' : ', ' + q('AuditViewRuns')}
3081
+ ${newEntityDefaults.AllowAllRowsAPI === undefined ? '' : ', ' + q('AllowAllRowsAPI')}
3082
+ ${newEntityDefaults.AllowCreateAPI === undefined ? '' : ', ' + q('AllowCreateAPI')}
3083
+ ${newEntityDefaults.AllowUpdateAPI === undefined ? '' : ', ' + q('AllowUpdateAPI')}
3084
+ ${newEntityDefaults.AllowDeleteAPI === undefined ? '' : ', ' + q('AllowDeleteAPI')}
3085
+ ${newEntityDefaults.UserViewMaxRows === undefined ? '' : ', ' + q('UserViewMaxRows')}
3086
+ , ${q('__mj_CreatedAt')}
3087
+ , ${q('__mj_UpdatedAt')}
2981
3088
  )
2982
3089
  VALUES (
2983
3090
  '${newEntityUUID}',
@@ -2998,6 +3105,8 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
2998
3105
  ${newEntityDefaults.AllowUpdateAPI === undefined ? '' : ', ' + (newEntityDefaults.AllowUpdateAPI ? '1' : '0')}
2999
3106
  ${newEntityDefaults.AllowDeleteAPI === undefined ? '' : ', ' + (newEntityDefaults.AllowDeleteAPI ? '1' : '0')}
3000
3107
  ${newEntityDefaults.UserViewMaxRows === undefined ? '' : ', ' + (newEntityDefaults.UserViewMaxRows)}
3108
+ , ${this.utcNow()}
3109
+ , ${this.utcNow()}
3001
3110
  )
3002
3111
  `;
3003
3112
  return sSQLInsert;
@@ -3051,13 +3160,13 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
3051
3160
  e.BaseTable,
3052
3161
  e.ParentID
3053
3162
  FROM
3054
- [${mj_core_schema()}].vwEntities e
3163
+ ${this.qs(mj_core_schema(), 'vwEntities')} e
3055
3164
  WHERE
3056
3165
  ${whereClause}
3057
3166
  ORDER BY
3058
3167
  e.Name
3059
3168
  `;
3060
- const entitiesResult = await pool.request().query(entitiesSQL);
3169
+ const entitiesResult = await this.runQuery(pool, entitiesSQL);
3061
3170
  const entities = entitiesResult.recordset;
3062
3171
  if (entities.length === 0) {
3063
3172
  return true;
@@ -3089,14 +3198,14 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
3089
3198
  ef.DefaultInView,
3090
3199
  ef.IncludeInUserSearchAPI
3091
3200
  FROM
3092
- [${mj_core_schema()}].vwEntityFields ef
3201
+ ${this.qs(mj_core_schema(), 'vwEntityFields')} ef
3093
3202
  WHERE
3094
3203
  ef.EntityID IN (${entityIds})
3095
3204
  ORDER BY
3096
3205
  ef.EntityID,
3097
3206
  ef.Sequence
3098
3207
  `;
3099
- const fieldsResult = await pool.request().query(fieldsSQL);
3208
+ const fieldsResult = await this.runQuery(pool, fieldsSQL);
3100
3209
  const allFields = fieldsResult.recordset;
3101
3210
  // Get EntitySettings for all entities (for existing category icons/info)
3102
3211
  const settingsSQL = `
@@ -3105,12 +3214,12 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
3105
3214
  es.Name,
3106
3215
  es.Value
3107
3216
  FROM
3108
- [${mj_core_schema()}].EntitySetting es
3217
+ ${this.qs(mj_core_schema(), 'EntitySetting')} es
3109
3218
  WHERE
3110
3219
  es.EntityID IN (${entityIds})
3111
3220
  AND es.Name = 'FieldCategoryInfo'
3112
3221
  `;
3113
- const settingsResult = await pool.request().query(settingsSQL);
3222
+ const settingsResult = await this.runQuery(pool, settingsSQL);
3114
3223
  const allSettings = settingsResult.recordset;
3115
3224
  // Group settings by entity
3116
3225
  const settingsByEntity = {};
@@ -3175,7 +3284,7 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
3175
3284
  async processEntityAdvancedGeneration(pool, entity, allFields, ag, currentUser) {
3176
3285
  try {
3177
3286
  // Filter fields for this entity (client-side filtering)
3178
- const fields = allFields.filter((f) => f.EntityID === entity.ID);
3287
+ const fields = allFields.filter((f) => UUIDsEqual(f.EntityID, entity.ID));
3179
3288
  // Determine if this is a new entity (for DefaultForNewUser decision)
3180
3289
  const isNewEntity = ManageMetadataBase.newEntityList.includes(entity.Name);
3181
3290
  // Smart Field Identification
@@ -3236,7 +3345,7 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
3236
3345
  if (visited.has(currentParentID))
3237
3346
  break; // circular reference guard
3238
3347
  visited.add(currentParentID);
3239
- const parentEntity = allEntities.find(e => e.ID === currentParentID);
3348
+ const parentEntity = allEntities.find(e => UUIDsEqual(e.ID, currentParentID));
3240
3349
  if (!parentEntity)
3241
3350
  break;
3242
3351
  parentChain.push({ entityID: parentEntity.ID, entityName: parentEntity.Name });
@@ -3264,7 +3373,7 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
3264
3373
  */
3265
3374
  findFieldSourceParent(fieldName, parentChain, allEntities) {
3266
3375
  for (const parent of parentChain) {
3267
- const parentEntity = allEntities.find(e => e.ID === parent.entityID);
3376
+ const parentEntity = allEntities.find(e => UUIDsEqual(e.ID, parent.entityID));
3268
3377
  if (!parentEntity)
3269
3378
  continue;
3270
3379
  // Check if this parent has a non-virtual field with this name
@@ -3284,7 +3393,7 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
3284
3393
  const nameField = fields.find(f => f.Name === result.nameField);
3285
3394
  if (nameField && nameField.AutoUpdateIsNameField && nameField.ID && !nameField.IsNameField /*don't waste SQL to set the value if IsNameField already set */) {
3286
3395
  sqlStatements.push(`
3287
- UPDATE [${mj_core_schema()}].EntityField
3396
+ UPDATE ${this.qs(mj_core_schema(), 'EntityField')}
3288
3397
  SET IsNameField = 1
3289
3398
  WHERE ID = '${nameField.ID}'
3290
3399
  AND AutoUpdateIsNameField = 1
@@ -3305,7 +3414,7 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
3305
3414
  if (!field.DefaultInView) {
3306
3415
  // only set these when DefaultInView not already on, otherwise wasteful
3307
3416
  sqlStatements.push(`
3308
- UPDATE [${mj_core_schema()}].EntityField
3417
+ UPDATE ${this.qs(mj_core_schema(), 'EntityField')}
3309
3418
  SET DefaultInView = 1
3310
3419
  WHERE ID = '${field.ID}'
3311
3420
  AND AutoUpdateDefaultInView = 1
@@ -3325,7 +3434,7 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
3325
3434
  if (!field.IncludeInUserSearchAPI) {
3326
3435
  // only set this if IncludeInUserSearchAPI isn't already set
3327
3436
  sqlStatements.push(`
3328
- UPDATE [${mj_core_schema()}].EntityField
3437
+ UPDATE ${this.qs(mj_core_schema(), 'EntityField')}
3329
3438
  SET IncludeInUserSearchAPI = 1
3330
3439
  WHERE ID = '${field.ID}'
3331
3440
  AND AutoUpdateIncludeInUserSearchAPI = 1
@@ -3429,7 +3538,7 @@ DROP TABLE #__mj__CodeGen__vwTableUniqueKeys;
3429
3538
  }
3430
3539
  if (setClauses.length > 0) {
3431
3540
  // only generate an UPDATE if we have 1+ set clause
3432
- sqlStatements.push(`\n-- UPDATE Entity Field Category Info ${entity.Name}.${field.Name} \nUPDATE [${mj_core_schema()}].EntityField
3541
+ sqlStatements.push(`\n-- UPDATE Entity Field Category Info ${entity.Name}.${field.Name} \nUPDATE ${this.qs(mj_core_schema(), 'EntityField')}
3433
3542
  SET
3434
3543
  ${setClauses.join(',\n ')}
3435
3544
  WHERE
@@ -3455,15 +3564,15 @@ WHERE
3455
3564
  async applyEntityIcon(pool, entityId, entityIcon) {
3456
3565
  if (!entityIcon || entityIcon.trim().length === 0)
3457
3566
  return;
3458
- const checkSQL = `SELECT Icon FROM [${mj_core_schema()}].Entity WHERE ID = '${entityId}'`;
3459
- const entityCheck = await pool.request().query(checkSQL);
3567
+ const checkSQL = `SELECT Icon FROM ${this.qs(mj_core_schema(), 'Entity')} WHERE ID = '${entityId}'`;
3568
+ const entityCheck = await this.runQuery(pool, checkSQL);
3460
3569
  if (entityCheck.recordset.length > 0) {
3461
3570
  const currentIcon = entityCheck.recordset[0].Icon;
3462
3571
  if (!currentIcon || currentIcon.trim().length === 0) {
3463
3572
  const escapedIcon = entityIcon.replace(/'/g, "''");
3464
3573
  const updateSQL = `
3465
- UPDATE [${mj_core_schema()}].Entity
3466
- SET Icon = '${escapedIcon}', __mj_UpdatedAt = GETUTCDATE()
3574
+ UPDATE ${this.qs(mj_core_schema(), 'Entity')}
3575
+ SET Icon = '${escapedIcon}', __mj_UpdatedAt = ${this.utcNow()}
3467
3576
  WHERE ID = '${entityId}'
3468
3577
  `;
3469
3578
  try {
@@ -3484,13 +3593,13 @@ WHERE
3484
3593
  return;
3485
3594
  const infoJSON = JSON.stringify(categoryInfo).replace(/'/g, "''");
3486
3595
  // Upsert FieldCategoryInfo (new format)
3487
- const checkNewSQL = `SELECT ID FROM [${mj_core_schema()}].EntitySetting WHERE EntityID = '${entityId}' AND Name = 'FieldCategoryInfo'`;
3488
- const existingNew = await pool.request().query(checkNewSQL);
3596
+ const checkNewSQL = `SELECT ID FROM ${this.qs(mj_core_schema(), 'EntitySetting')} WHERE EntityID = '${entityId}' AND Name = 'FieldCategoryInfo'`;
3597
+ const existingNew = await this.runQuery(pool, checkNewSQL);
3489
3598
  if (existingNew.recordset.length > 0) {
3490
3599
  try {
3491
3600
  await this.LogSQLAndExecute(pool, `
3492
- UPDATE [${mj_core_schema()}].EntitySetting
3493
- SET Value = '${infoJSON}', __mj_UpdatedAt = GETUTCDATE()
3601
+ UPDATE ${this.qs(mj_core_schema(), 'EntitySetting')}
3602
+ SET Value = '${infoJSON}', __mj_UpdatedAt = ${this.utcNow()}
3494
3603
  WHERE EntityID = '${entityId}' AND Name = 'FieldCategoryInfo'
3495
3604
  `, `Update FieldCategoryInfo setting for entity`, false);
3496
3605
  }
@@ -3502,8 +3611,8 @@ WHERE
3502
3611
  const newId = uuidv4();
3503
3612
  try {
3504
3613
  await this.LogSQLAndExecute(pool, `
3505
- INSERT INTO [${mj_core_schema()}].EntitySetting (ID, EntityID, Name, Value, __mj_CreatedAt, __mj_UpdatedAt)
3506
- VALUES ('${newId}', '${entityId}', 'FieldCategoryInfo', '${infoJSON}', GETUTCDATE(), GETUTCDATE())
3614
+ INSERT INTO ${this.qs(mj_core_schema(), 'EntitySetting')} (ID, EntityID, Name, Value, __mj_CreatedAt, __mj_UpdatedAt)
3615
+ VALUES ('${newId}', '${entityId}', 'FieldCategoryInfo', '${infoJSON}', ${this.utcNow()}, ${this.utcNow()})
3507
3616
  `, `Insert FieldCategoryInfo setting for entity`, false);
3508
3617
  }
3509
3618
  catch (ex) {
@@ -3518,13 +3627,13 @@ WHERE
3518
3627
  }
3519
3628
  }
3520
3629
  const iconsJSON = JSON.stringify(iconsOnly).replace(/'/g, "''");
3521
- const checkLegacySQL = `SELECT ID FROM [${mj_core_schema()}].EntitySetting WHERE EntityID = '${entityId}' AND Name = 'FieldCategoryIcons'`;
3522
- const existingLegacy = await pool.request().query(checkLegacySQL);
3630
+ const checkLegacySQL = `SELECT ID FROM ${this.qs(mj_core_schema(), 'EntitySetting')} WHERE EntityID = '${entityId}' AND Name = 'FieldCategoryIcons'`;
3631
+ const existingLegacy = await this.runQuery(pool, checkLegacySQL);
3523
3632
  if (existingLegacy.recordset.length > 0) {
3524
3633
  try {
3525
3634
  await this.LogSQLAndExecute(pool, `
3526
- UPDATE [${mj_core_schema()}].EntitySetting
3527
- SET Value = '${iconsJSON}', __mj_UpdatedAt = GETUTCDATE()
3635
+ UPDATE ${this.qs(mj_core_schema(), 'EntitySetting')}
3636
+ SET Value = '${iconsJSON}', __mj_UpdatedAt = ${this.utcNow()}
3528
3637
  WHERE EntityID = '${entityId}' AND Name = 'FieldCategoryIcons'
3529
3638
  `, `Update FieldCategoryIcons setting (legacy)`, false);
3530
3639
  }
@@ -3536,8 +3645,8 @@ WHERE
3536
3645
  const newId = uuidv4();
3537
3646
  try {
3538
3647
  await this.LogSQLAndExecute(pool, `
3539
- INSERT INTO [${mj_core_schema()}].EntitySetting (ID, EntityID, Name, Value, __mj_CreatedAt, __mj_UpdatedAt)
3540
- VALUES ('${newId}', '${entityId}', 'FieldCategoryIcons', '${iconsJSON}', GETUTCDATE(), GETUTCDATE())
3648
+ INSERT INTO ${this.qs(mj_core_schema(), 'EntitySetting')} (ID, EntityID, Name, Value, __mj_CreatedAt, __mj_UpdatedAt)
3649
+ VALUES ('${newId}', '${entityId}', 'FieldCategoryIcons', '${iconsJSON}', ${this.utcNow()}, ${this.utcNow()})
3541
3650
  `, `Insert FieldCategoryIcons setting (legacy)`, false);
3542
3651
  }
3543
3652
  catch (ex) {
@@ -3552,8 +3661,8 @@ WHERE
3552
3661
  async applyEntityImportance(pool, entityId, importance) {
3553
3662
  const defaultForNewUser = importance.defaultForNewUser ? 1 : 0;
3554
3663
  const updateSQL = `
3555
- UPDATE [${mj_core_schema()}].ApplicationEntity
3556
- SET DefaultForNewUser = ${defaultForNewUser}, __mj_UpdatedAt = GETUTCDATE()
3664
+ UPDATE ${this.qs(mj_core_schema(), 'ApplicationEntity')}
3665
+ SET DefaultForNewUser = ${defaultForNewUser}, __mj_UpdatedAt = ${this.utcNow()}
3557
3666
  WHERE EntityID = '${entityId}'
3558
3667
  `;
3559
3668
  try {
@@ -3576,7 +3685,7 @@ WHERE
3576
3685
  * @returns - The result of the query execution.
3577
3686
  */
3578
3687
  async LogSQLAndExecute(pool, query, description, isRecurringScript = false) {
3579
- return await SQLLogging.LogSQLAndExecute(pool, query, description, isRecurringScript);
3688
+ return await SQLLogging.LogSQLAndExecute(pool, this.qsql(query), description, isRecurringScript);
3580
3689
  }
3581
3690
  }
3582
3691
  //# sourceMappingURL=manage-metadata.js.map