@memberjunction/sqlserver-dataprovider 2.48.0 → 2.50.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.
@@ -1,25 +1,77 @@
1
1
  "use strict";
2
+ /**
3
+ * @fileoverview SQL Server Data Provider for MemberJunction
4
+ *
5
+ * This module provides a comprehensive SQL Server implementation of the MemberJunction data provider interfaces.
6
+ * It handles all database operations including CRUD operations, metadata management, view execution, and
7
+ * advanced features like SQL logging, transaction management, and record change tracking.
8
+ *
9
+ * @module @memberjunction/sqlserver-dataprovider
10
+ * @author MemberJunction Team
11
+ * @version 2.0
12
+ * @since 1.0
13
+ */
14
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
15
+ if (k2 === undefined) k2 = k;
16
+ var desc = Object.getOwnPropertyDescriptor(m, k);
17
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
18
+ desc = { enumerable: true, get: function() { return m[k]; } };
19
+ }
20
+ Object.defineProperty(o, k2, desc);
21
+ }) : (function(o, m, k, k2) {
22
+ if (k2 === undefined) k2 = k;
23
+ o[k2] = m[k];
24
+ }));
25
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
26
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
27
+ }) : function(o, v) {
28
+ o["default"] = v;
29
+ });
30
+ var __importStar = (this && this.__importStar) || function (mod) {
31
+ if (mod && mod.__esModule) return mod;
32
+ var result = {};
33
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
34
+ __setModuleDefault(result, mod);
35
+ return result;
36
+ };
37
+ Object.defineProperty(exports, "__esModule", { value: true });
38
+ exports.SQLServerDataProvider = exports.SQLServerProviderConfigData = void 0;
2
39
  /**************************************************************************************************************
3
40
  * The SQLServerDataProvider provides a data provider for the entities framework that uses SQL Server directly
4
41
  * In practice - this FILE will NOT exist in the entities library, we need to move to its own separate project
5
42
  * so it is only included by the consumer of the entities library if they want to use it.
6
43
  **************************************************************************************************************/
7
- Object.defineProperty(exports, "__esModule", { value: true });
8
- exports.SQLServerDataProvider = exports.SQLServerProviderConfigData = void 0;
9
44
  const core_1 = require("@memberjunction/core");
10
45
  const core_entities_1 = require("@memberjunction/core-entities");
11
46
  const aiengine_1 = require("@memberjunction/aiengine");
12
47
  const queue_1 = require("@memberjunction/queue");
48
+ const sql = __importStar(require("mssql"));
13
49
  const SQLServerTransactionGroup_1 = require("./SQLServerTransactionGroup");
14
50
  const UserCache_1 = require("./UserCache");
15
51
  const actions_1 = require("@memberjunction/actions");
52
+ const uuid_1 = require("uuid");
53
+ const sql_formatter_1 = require("sql-formatter");
54
+ const fs = __importStar(require("fs"));
55
+ const path = __importStar(require("path"));
56
+ /**
57
+ * Configuration data specific to SQL Server provider
58
+ */
16
59
  class SQLServerProviderConfigData extends core_1.ProviderConfigDataBase {
60
+ /**
61
+ * Gets the SQL Server data source configuration
62
+ */
17
63
  get DataSource() {
18
64
  return this.Data.DataSource;
19
65
  }
66
+ /**
67
+ * Gets the current user's email address
68
+ */
20
69
  get CurrentUserEmail() {
21
70
  return this.Data.CurrentUserEmail;
22
71
  }
72
+ /**
73
+ * Gets the interval in seconds for checking metadata refresh
74
+ */
23
75
  get CheckRefreshIntervalSeconds() {
24
76
  return this.Data.CheckRefreshIntervalSeconds;
25
77
  }
@@ -32,20 +84,255 @@ class SQLServerProviderConfigData extends core_1.ProviderConfigDataBase {
32
84
  }
33
85
  }
34
86
  exports.SQLServerProviderConfigData = SQLServerProviderConfigData;
35
- // Implements both the IEntityDataProvider and IMetadataProvider interfaces.
87
+ /**
88
+ * Internal implementation of SqlLoggingSession that handles SQL statement logging to files.
89
+ * This class manages file I/O, SQL formatting, and filtering based on session options.
90
+ *
91
+ * @internal
92
+ */
93
+ class SqlLoggingSessionImpl {
94
+ constructor(id, filePath, options = {}) {
95
+ this._statementCount = 0;
96
+ this._emittedStatementCount = 0; // Track actually emitted statements
97
+ this._fileHandle = null;
98
+ this._disposed = false;
99
+ this.id = id;
100
+ this.filePath = filePath;
101
+ this.startTime = new Date();
102
+ this.options = options;
103
+ }
104
+ /**
105
+ * Gets the count of SQL statements actually written to the log file
106
+ * @returns The number of emitted statements (after filtering)
107
+ */
108
+ get statementCount() {
109
+ return this._emittedStatementCount; // Return actually emitted statements
110
+ }
111
+ /**
112
+ * Initializes the logging session by creating the log file and writing the header
113
+ * @throws Error if file creation fails
114
+ */
115
+ async initialize() {
116
+ // Ensure directory exists
117
+ const dir = path.dirname(this.filePath);
118
+ await fs.promises.mkdir(dir, { recursive: true });
119
+ // Open file for writing
120
+ this._fileHandle = await fs.promises.open(this.filePath, 'w');
121
+ // Write header comment
122
+ const header = this._generateHeader();
123
+ await this._fileHandle.writeFile(header);
124
+ }
125
+ /**
126
+ * Logs a SQL statement to the file, applying filtering and formatting based on session options
127
+ *
128
+ * @param query - The SQL query to log
129
+ * @param parameters - Optional parameters for the query
130
+ * @param description - Optional description for this operation
131
+ * @param isMutation - Whether this is a data mutation operation
132
+ * @param simpleSQLFallback - Optional simple SQL to use if logRecordChangeMetadata=false
133
+ */
134
+ async logSqlStatement(query, parameters, description, isMutation = false, simpleSQLFallback) {
135
+ if (this._disposed || !this._fileHandle) {
136
+ return;
137
+ }
138
+ // Filter statements based on statementTypes option
139
+ const statementTypes = this.options.statementTypes || 'both';
140
+ if (statementTypes === 'mutations' && !isMutation) {
141
+ return; // Skip logging non-mutation statements
142
+ }
143
+ if (statementTypes === 'queries' && isMutation) {
144
+ return; // Skip logging mutation statements
145
+ }
146
+ let logEntry = '';
147
+ // Add description comment if provided
148
+ if (description) {
149
+ logEntry += `-- ${description}\n`;
150
+ }
151
+ // Process the SQL statement
152
+ let processedQuery = query;
153
+ // Use simple SQL fallback if this session has logRecordChangeMetadata=false (default) and fallback is provided
154
+ if (this.options.logRecordChangeMetadata !== true && simpleSQLFallback) {
155
+ processedQuery = simpleSQLFallback;
156
+ // Update description to indicate we're using the simplified version
157
+ if (description && !description.includes('(core SP call only)')) {
158
+ logEntry = logEntry.replace(`-- ${description}\n`, `-- ${description} (core SP call only)\n`);
159
+ }
160
+ }
161
+ // Replace schema names with Flyway placeholders if migration format
162
+ if (this.options.formatAsMigration) {
163
+ processedQuery = processedQuery.replace(/\[(\w+)\]\./g, '[${flyway:defaultSchema}].');
164
+ }
165
+ // Apply pretty printing if enabled
166
+ if (this.options.prettyPrint) {
167
+ processedQuery = this._prettyPrintSql(processedQuery);
168
+ }
169
+ // Add the SQL statement
170
+ logEntry += `${processedQuery};\n`;
171
+ // Add parameter comment if parameters exist
172
+ if (parameters) {
173
+ if (Array.isArray(parameters)) {
174
+ if (parameters.length > 0) {
175
+ logEntry += `-- Parameters: ${parameters.map((p, i) => `@p${i}='${p}'`).join(', ')}\n`;
176
+ }
177
+ }
178
+ else if (typeof parameters === 'object') {
179
+ const paramStr = Object.entries(parameters)
180
+ .map(([key, value]) => `@${key}='${value}'`)
181
+ .join(', ');
182
+ if (paramStr) {
183
+ logEntry += `-- Parameters: ${paramStr}\n`;
184
+ }
185
+ }
186
+ }
187
+ // Add batch separator if specified
188
+ if (this.options.batchSeparator) {
189
+ logEntry += `\n${this.options.batchSeparator}\n`;
190
+ }
191
+ logEntry += '\n'; // Add blank line between statements
192
+ await this._fileHandle.writeFile(logEntry);
193
+ this._statementCount++;
194
+ this._emittedStatementCount++; // Track actually emitted statements
195
+ }
196
+ /**
197
+ * Disposes of the logging session, writes the footer, closes the file, and optionally deletes empty files
198
+ */
199
+ async dispose() {
200
+ if (this._disposed) {
201
+ return;
202
+ }
203
+ this._disposed = true;
204
+ if (this._fileHandle) {
205
+ // Write footer comment
206
+ const footer = this._generateFooter();
207
+ await this._fileHandle.writeFile(footer);
208
+ await this._fileHandle.close();
209
+ this._fileHandle = null;
210
+ // Check if we should delete empty log files
211
+ if (this._emittedStatementCount === 0 && !this.options.retainEmptyLogFiles) {
212
+ try {
213
+ await fs.promises.unlink(this.filePath);
214
+ // Log that we deleted the empty file (optional)
215
+ console.log(`Deleted empty SQL log file: ${this.filePath}`);
216
+ }
217
+ catch (error) {
218
+ // Ignore errors during deletion (file might already be deleted, etc.)
219
+ console.error(`Failed to delete empty SQL log file: ${this.filePath}`, error);
220
+ }
221
+ }
222
+ }
223
+ }
224
+ _generateHeader() {
225
+ let header = `-- SQL Logging Session\n`;
226
+ header += `-- Session ID: ${this.id}\n`;
227
+ header += `-- Started: ${this.startTime.toISOString()}\n`;
228
+ if (this.options.description) {
229
+ header += `-- Description: ${this.options.description}\n`;
230
+ }
231
+ if (this.options.formatAsMigration) {
232
+ header += `-- Format: Migration-ready with Flyway schema placeholders\n`;
233
+ }
234
+ header += `-- Generated by MemberJunction SQLServerDataProvider\n`;
235
+ header += `\n`;
236
+ return header;
237
+ }
238
+ _generateFooter() {
239
+ const endTime = new Date();
240
+ const duration = endTime.getTime() - this.startTime.getTime();
241
+ let footer = `\n-- End of SQL Logging Session\n`;
242
+ footer += `-- Session ID: ${this.id}\n`;
243
+ footer += `-- Completed: ${endTime.toISOString()}\n`;
244
+ footer += `-- Duration: ${duration}ms\n`;
245
+ footer += `-- Total Statements: ${this._emittedStatementCount}\n`;
246
+ return footer;
247
+ }
248
+ /**
249
+ * Format SQL using sql-formatter library with SQL Server dialect
250
+ */
251
+ _prettyPrintSql(sql) {
252
+ if (!sql)
253
+ return sql;
254
+ try {
255
+ let formatted = (0, sql_formatter_1.format)(sql, {
256
+ language: 'tsql', // SQL Server Transact-SQL dialect
257
+ tabWidth: 2,
258
+ keywordCase: 'upper',
259
+ functionCase: 'upper',
260
+ dataTypeCase: 'upper',
261
+ linesBetweenQueries: 1,
262
+ });
263
+ // Post-process to fix BEGIN/END formatting
264
+ formatted = this._postProcessBeginEnd(formatted);
265
+ return formatted;
266
+ }
267
+ catch (error) {
268
+ // If formatting fails, return original SQL
269
+ console.warn('SQL formatting failed, returning original:', error);
270
+ return sql;
271
+ }
272
+ }
273
+ /**
274
+ * Post-process SQL to ensure BEGIN, END, and EXEC keywords are on their own lines
275
+ */
276
+ _postProcessBeginEnd(sql) {
277
+ if (!sql)
278
+ return sql;
279
+ // Fix BEGIN keyword - ensure it's on its own line
280
+ // Match: any non-whitespace followed by space(s) followed by BEGIN (word boundary)
281
+ sql = sql.replace(/(\S)\s+(BEGIN\b)/g, '$1\n$2');
282
+ // Fix BEGIN followed by other keywords - ensure what follows BEGIN is on a new line
283
+ // Match: BEGIN followed by space(s) followed by non-whitespace
284
+ sql = sql.replace(/(BEGIN\b)\s+(\S)/g, '$1\n$2');
285
+ // Fix END keyword - ensure it's on its own line
286
+ // Match: any non-whitespace followed by space(s) followed by END (word boundary)
287
+ sql = sql.replace(/(\S)\s+(END\b)/g, '$1\n$2');
288
+ // Fix EXEC keyword - ensure it's on its own line
289
+ // Match: any non-whitespace followed by space(s) followed by EXEC (word boundary)
290
+ sql = sql.replace(/(\S)\s+(EXEC\b)/g, '$1\n$2');
291
+ return sql;
292
+ }
293
+ }
294
+ /**
295
+ * SQL Server implementation of the MemberJunction data provider interfaces.
296
+ *
297
+ * This class provides comprehensive database functionality including:
298
+ * - CRUD operations for entities
299
+ * - Metadata management and caching
300
+ * - View and query execution
301
+ * - Transaction support
302
+ * - SQL logging capabilities
303
+ * - Record change tracking
304
+ * - AI integration hooks
305
+ *
306
+ * @example
307
+ * ```typescript
308
+ * const config = new SQLServerProviderConfigData(dataSource, userEmail);
309
+ * const provider = new SQLServerDataProvider(config);
310
+ * await provider.Config();
311
+ * ```
312
+ */
36
313
  class SQLServerDataProvider extends core_1.ProviderBase {
37
314
  constructor() {
38
315
  super(...arguments);
39
316
  this._bAllowRefresh = true;
40
317
  this._needsDatetimeOffsetAdjustment = false;
41
318
  this._datetimeOffsetTestComplete = false;
319
+ this._sqlLoggingSessions = new Map();
42
320
  }
321
+ /**
322
+ * Gets the current configuration data for this provider instance
323
+ */
43
324
  get ConfigData() {
44
325
  return super.ConfigData;
45
326
  }
327
+ /**
328
+ * Configures the SQL Server data provider with connection settings and initializes the connection pool
329
+ *
330
+ * @param configData - Configuration data including connection string and options
331
+ * @returns Promise<boolean> - True if configuration succeeded
332
+ */
46
333
  async Config(configData) {
47
334
  try {
48
- this._dataSource = configData.DataSource;
335
+ this._pool = configData.DataSource; // Now expects a ConnectionPool instead of DataSource
49
336
  this._currentUserEmail = configData.CurrentUserEmail;
50
337
  return super.Config(configData); // now parent class can do it's config
51
338
  }
@@ -55,34 +342,157 @@ class SQLServerDataProvider extends core_1.ProviderBase {
55
342
  }
56
343
  }
57
344
  /**
58
- * SQL Server Data Provider implementation of this getter returns a TypeORM DataSource object
345
+ * Gets the underlying SQL Server connection pool
346
+ * @returns The mssql ConnectionPool object
59
347
  */
60
348
  get DatabaseConnection() {
61
- return this._dataSource;
349
+ return this._pool;
62
350
  }
63
351
  /**
64
352
  * For the SQLServerDataProvider the unique instance connection string which is used to identify, uniquely, a given connection is the following format:
65
- * type://host:port/instanceName?/database
353
+ * mssql://host:port/instanceName?/database
66
354
  * instanceName is only inserted if it is provided in the options
67
355
  */
68
356
  get InstanceConnectionString() {
69
- const dbOptions = this._dataSource.options;
357
+ // For mssql, we need to access the pool's internal connection options
358
+ // Since mssql v11 doesn't expose config directly, we'll construct from what we know
359
+ const pool = this._pool;
70
360
  const options = {
71
- type: dbOptions.type || '',
72
- host: dbOptions.host || '',
73
- port: dbOptions.port || '',
74
- instanceName: dbOptions.instanceName ? '/' + dbOptions.instanceName : '',
75
- database: dbOptions.database || '',
361
+ type: 'mssql',
362
+ host: pool._config?.server || 'localhost',
363
+ port: pool._config?.port || 1433,
364
+ instanceName: pool._config?.options?.instanceName ? '/' + pool._config.options.instanceName : '',
365
+ database: pool._config?.database || '',
76
366
  };
77
367
  return options.type + '://' + options.host + ':' + options.port + options.instanceName + '/' + options.database;
78
368
  }
369
+ /**
370
+ * Gets whether metadata refresh is currently allowed
371
+ * @internal
372
+ */
79
373
  get AllowRefresh() {
80
374
  return this._bAllowRefresh;
81
375
  }
376
+ /**
377
+ * Gets the MemberJunction core schema name (defaults to __mj if not configured)
378
+ */
82
379
  get MJCoreSchemaName() {
83
380
  return this.ConfigData.MJCoreSchemaName;
84
381
  }
85
382
  /**************************************************************************/
383
+ // START ---- SQL Logging Methods
384
+ /**************************************************************************/
385
+ /**
386
+ * Creates a new SQL logging session that will capture all SQL operations to a file.
387
+ * Returns a disposable session object that must be disposed to stop logging.
388
+ *
389
+ * @param filePath - Full path to the file where SQL statements will be logged
390
+ * @param options - Optional configuration for the logging session
391
+ * @returns Promise<SqlLoggingSession> - Disposable session object
392
+ *
393
+ * @example
394
+ * ```typescript
395
+ * // Basic usage
396
+ * const session = await provider.createSqlLogger('./logs/metadata-sync.sql');
397
+ * try {
398
+ * // Perform operations that will be logged
399
+ * await provider.ExecuteSQL('INSERT INTO ...');
400
+ * } finally {
401
+ * await session.dispose(); // Stop logging
402
+ * }
403
+ *
404
+ * // With migration formatting
405
+ * const session = await provider.createSqlLogger('./migrations/changes.sql', {
406
+ * formatAsMigration: true,
407
+ * description: 'MetadataSync push operation'
408
+ * });
409
+ * ```
410
+ */
411
+ async createSqlLogger(filePath, options) {
412
+ const sessionId = (0, uuid_1.v4)();
413
+ const session = new SqlLoggingSessionImpl(sessionId, filePath, options);
414
+ // Initialize the session (create file, write header)
415
+ await session.initialize();
416
+ // Store in active sessions map
417
+ this._sqlLoggingSessions.set(sessionId, session);
418
+ // Return a proxy that handles cleanup on dispose
419
+ return {
420
+ id: session.id,
421
+ filePath: session.filePath,
422
+ startTime: session.startTime,
423
+ get statementCount() {
424
+ return session.statementCount;
425
+ },
426
+ options: session.options,
427
+ dispose: async () => {
428
+ await session.dispose();
429
+ this._sqlLoggingSessions.delete(sessionId);
430
+ },
431
+ };
432
+ }
433
+ /**
434
+ * Gets information about all active SQL logging sessions.
435
+ * Useful for monitoring and debugging.
436
+ *
437
+ * @returns Array of session information objects
438
+ */
439
+ getActiveSqlLoggingSessions() {
440
+ return Array.from(this._sqlLoggingSessions.values()).map((session) => ({
441
+ id: session.id,
442
+ filePath: session.filePath,
443
+ startTime: session.startTime,
444
+ statementCount: session.statementCount,
445
+ options: session.options,
446
+ }));
447
+ }
448
+ /**
449
+ * Disposes all active SQL logging sessions.
450
+ * Useful for cleanup on provider shutdown.
451
+ */
452
+ async disposeAllSqlLoggingSessions() {
453
+ const disposePromises = Array.from(this._sqlLoggingSessions.values()).map((session) => session.dispose());
454
+ await Promise.all(disposePromises);
455
+ this._sqlLoggingSessions.clear();
456
+ }
457
+ /**
458
+ * Internal method to log SQL statement to all active logging sessions.
459
+ * This is called automatically by ExecuteSQL methods.
460
+ *
461
+ * @param query - The SQL query being executed
462
+ * @param parameters - Parameters for the query
463
+ * @param description - Optional description for this operation
464
+ * @param ignoreLogging - If true, this statement will not be logged
465
+ * @param isMutation - Whether this is a data mutation operation
466
+ * @param simpleSQLFallback - Optional simple SQL to use for loggers with logRecordChangeMetadata=false
467
+ */
468
+ async _logSqlStatement(query, parameters, description, ignoreLogging = false, isMutation = false, simpleSQLFallback) {
469
+ if (ignoreLogging || this._sqlLoggingSessions.size === 0) {
470
+ return;
471
+ }
472
+ // Log to all active sessions in parallel
473
+ const logPromises = Array.from(this._sqlLoggingSessions.values()).map((session) => session.logSqlStatement(query, parameters, description, isMutation, simpleSQLFallback));
474
+ await Promise.all(logPromises);
475
+ }
476
+ /**
477
+ * Static method to log SQL statements from external sources like transaction groups
478
+ *
479
+ * @param query - The SQL query being executed
480
+ * @param parameters - Parameters for the query
481
+ * @param description - Optional description for this operation
482
+ * @param isMutation - Whether this is a data mutation operation
483
+ * @param simpleSQLFallback - Optional simple SQL to use for loggers with logRecordChangeMetadata=false
484
+ */
485
+ static async LogSQLStatement(query, parameters, description, isMutation = false, simpleSQLFallback) {
486
+ // Get the current provider instance
487
+ const provider = core_1.Metadata.Provider;
488
+ if (provider && provider._sqlLoggingSessions.size > 0) {
489
+ await provider._logSqlStatement(query, parameters, description, false, isMutation, simpleSQLFallback);
490
+ }
491
+ }
492
+ /**************************************************************************/
493
+ // END ---- SQL Logging Methods
494
+ /**************************************************************************/
495
+ /**************************************************************************/
86
496
  // START ---- IRunReportProvider
87
497
  /**************************************************************************/
88
498
  async RunReport(params, contextUser) {
@@ -98,7 +508,7 @@ class SQLServerDataProvider extends core_1.ProviderBase {
98
508
  if (result)
99
509
  return {
100
510
  Success: true,
101
- ReportID: ReportID,
511
+ ReportID,
102
512
  Results: result,
103
513
  RowCount: result.length,
104
514
  ExecutionTime: end - start,
@@ -107,7 +517,7 @@ class SQLServerDataProvider extends core_1.ProviderBase {
107
517
  else
108
518
  return {
109
519
  Success: false,
110
- ReportID: ReportID,
520
+ ReportID,
111
521
  Results: [],
112
522
  RowCount: 0,
113
523
  ExecutionTime: end - start,
@@ -115,7 +525,7 @@ class SQLServerDataProvider extends core_1.ProviderBase {
115
525
  };
116
526
  }
117
527
  else
118
- return { Success: false, ReportID: ReportID, Results: [], RowCount: 0, ExecutionTime: 0, ErrorMessage: 'Report not found' };
528
+ return { Success: false, ReportID, Results: [], RowCount: 0, ExecutionTime: 0, ErrorMessage: 'Report not found' };
119
529
  }
120
530
  /**************************************************************************/
121
531
  // END ---- IRunReportProvider
@@ -167,7 +577,7 @@ class SQLServerDataProvider extends core_1.ProviderBase {
167
577
  Results: [],
168
578
  RowCount: 0,
169
579
  ExecutionTime: 0,
170
- ErrorMessage: e.message
580
+ ErrorMessage: e.message,
171
581
  };
172
582
  }
173
583
  }
@@ -393,22 +803,20 @@ class SQLServerDataProvider extends core_1.ProviderBase {
393
803
  viewSQL += ` OFFSET ${params.StartRow} ROWS FETCH NEXT ${params.MaxRows} ROWS ONLY`;
394
804
  }
395
805
  // now we can run the viewSQL, but only do this if the ResultType !== 'count_only', otherwise we don't need to run the viewSQL
396
- const retData = params.ResultType === 'count_only' ? [] : await this._dataSource.query(viewSQL);
806
+ const retData = params.ResultType === 'count_only' ? [] : await this.ExecuteSQL(viewSQL);
397
807
  // finally, if we have a countSQL, we need to run that to get the row count
398
808
  // but only do that if the # of rows returned is equal to the max rows, otherwise we know we have all the rows
399
809
  // OR do that if we are doing a count_only
400
810
  let rowCount = null;
401
811
  if (countSQL && (params.ResultType === 'count_only' || retData.length === entityInfo.UserViewMaxRows)) {
402
- const countResult = await this._dataSource.query(countSQL);
812
+ const countResult = await this.ExecuteSQL(countSQL);
403
813
  if (countResult && countResult.length > 0) {
404
814
  rowCount = countResult[0].TotalRowCount;
405
815
  }
406
816
  }
407
817
  const stopTime = new Date();
408
818
  if (params.ForceAuditLog ||
409
- (viewEntity?.ID &&
410
- (extraFilter === undefined || extraFilter === null || extraFilter?.trim().length === 0) &&
411
- entityInfo.AuditViewRuns)) {
819
+ (viewEntity?.ID && (extraFilter === undefined || extraFilter === null || extraFilter?.trim().length === 0) && entityInfo.AuditViewRuns)) {
412
820
  // ONLY LOG TOP LEVEL VIEW EXECUTION - this would be for views with an ID, and don't have ExtraFilter as ExtraFilter
413
821
  // is only used in the system on a tab or just for ad hoc view execution
414
822
  // we do NOT want to wait for this, so no await,
@@ -450,7 +858,7 @@ class SQLServerDataProvider extends core_1.ProviderBase {
450
858
  }
451
859
  }
452
860
  async RunViews(params, contextUser) {
453
- const promises = params.map(p => this.RunView(p, contextUser));
861
+ const promises = params.map((p) => this.RunView(p, contextUser));
454
862
  return Promise.all(promises);
455
863
  }
456
864
  validateUserProvidedSQLClause(clause) {
@@ -559,12 +967,12 @@ class SQLServerDataProvider extends core_1.ProviderBase {
559
967
  INSERT INTO @ViewIDList (ID) (SELECT ${entityInfo.FirstPrimaryKey.Name} FROM [${entityInfo.SchemaName}].${entityBaseView} WHERE (${whereSQL}))
560
968
  EXEC [${this.MJCoreSchemaName}].spCreateUserViewRunWithDetail(${viewId},${user.Email}, @ViewIDLIst)
561
969
  `;
562
- const runIDResult = await this._dataSource.query(sSQL);
970
+ const runIDResult = await this.ExecuteSQL(sSQL);
563
971
  const runID = runIDResult[0].UserViewRunID;
564
972
  const sRetSQL = `SELECT * FROM [${entityInfo.SchemaName}].${entityBaseView} WHERE ${entityInfo.FirstPrimaryKey.Name} IN
565
973
  (SELECT RecordID FROM [${this.MJCoreSchemaName}].vwUserViewRunDetails WHERE UserViewRunID=${runID})
566
974
  ${orderBySQL && orderBySQL.length > 0 ? ' ORDER BY ' + orderBySQL : ''}`;
567
- return { executeViewSQL: sRetSQL, runID: runID };
975
+ return { executeViewSQL: sRetSQL, runID };
568
976
  }
569
977
  createViewUserSearchSQL(entityInfo, userSearchString) {
570
978
  // we have a user search string.
@@ -629,9 +1037,7 @@ class SQLServerDataProvider extends core_1.ProviderBase {
629
1037
  const authorization = authorizationName
630
1038
  ? this.Authorizations.find((a) => a?.Name?.trim().toLowerCase() === authorizationName.trim().toLowerCase())
631
1039
  : null;
632
- const auditLogType = auditLogTypeName
633
- ? this.AuditLogTypes.find((a) => a?.Name?.trim().toLowerCase() === auditLogTypeName.trim().toLowerCase())
634
- : null;
1040
+ const auditLogType = auditLogTypeName ? this.AuditLogTypes.find((a) => a?.Name?.trim().toLowerCase() === auditLogTypeName.trim().toLowerCase()) : null;
635
1041
  if (!user)
636
1042
  throw new Error(`User is a required parameter`);
637
1043
  if (!auditLogType)
@@ -840,7 +1246,7 @@ class SQLServerDataProvider extends core_1.ProviderBase {
840
1246
  // entityInfo.PrimaryKeys.forEach((pk) => {
841
1247
  // pkeyValues.push({FieldName: pk.Name, Value: r[pk.Name]}) // add all of the primary keys, which often is as simple as just "ID", but this is generic way to do it
842
1248
  // })
843
- let compositeKey = new core_1.CompositeKey();
1249
+ const compositeKey = new core_1.CompositeKey();
844
1250
  // the row r will have a PrimaryKeyValue field that is a string that is a concatenation of the primary key field names and values
845
1251
  // we need to parse that out so that we can then pass it to the CompositeKey object
846
1252
  const pkeys = {};
@@ -889,11 +1295,11 @@ class SQLServerDataProvider extends core_1.ProviderBase {
889
1295
  }
890
1296
  const listEntity = await this.GetEntityObject('Lists');
891
1297
  listEntity.ContextCurrentUser = contextUser;
892
- let success = await listEntity.Load(params.ListID);
1298
+ const success = await listEntity.Load(params.ListID);
893
1299
  if (!success) {
894
1300
  throw new Error(`List with ID ${params.ListID} not found.`);
895
1301
  }
896
- let duplicateRun = await this.GetEntityObject('Duplicate Runs');
1302
+ const duplicateRun = await this.GetEntityObject('Duplicate Runs');
897
1303
  duplicateRun.NewRecord();
898
1304
  duplicateRun.EntityID = params.EntityID;
899
1305
  duplicateRun.StartedByUserID = contextUser.ID;
@@ -906,7 +1312,7 @@ class SQLServerDataProvider extends core_1.ProviderBase {
906
1312
  if (!saveResult) {
907
1313
  throw new Error(`Failed to save Duplicate Run Entity`);
908
1314
  }
909
- let response = {
1315
+ const response = {
910
1316
  Status: 'Inprogress',
911
1317
  PotentialDuplicateResult: [],
912
1318
  };
@@ -1043,7 +1449,7 @@ class SQLServerDataProvider extends core_1.ProviderBase {
1043
1449
  if (await recordMergeLog.Save()) {
1044
1450
  // top level saved, now let's create the deletion detail records for each of the records that were merged
1045
1451
  for (const d of result.RecordStatus) {
1046
- const recordMergeDeletionLog = (await this.GetEntityObject('Record Merge Deletion Logs', contextUser));
1452
+ const recordMergeDeletionLog = await this.GetEntityObject('Record Merge Deletion Logs', contextUser);
1047
1453
  recordMergeDeletionLog.NewRecord();
1048
1454
  recordMergeDeletionLog.RecordMergeLogID = recordMergeLog.ID;
1049
1455
  recordMergeDeletionLog.DeletedRecordID = d.CompositeKey.Values(); // this would join together all of the primary key values, which is fine as the primary key is a string
@@ -1067,6 +1473,14 @@ class SQLServerDataProvider extends core_1.ProviderBase {
1067
1473
  * it is also used by the SQLServerTransactionGroup to regenerate Save SQL if any values were changed by the transaction group due to transaction variables being set into the object.
1068
1474
  */
1069
1475
  GetSaveSQL(entity, bNewRecord, spName, user) {
1476
+ const result = this.GetSaveSQLWithDetails(entity, bNewRecord, spName, user);
1477
+ return result.fullSQL;
1478
+ }
1479
+ /**
1480
+ * This function generates both the full SQL (with record change metadata) and the simple stored procedure call
1481
+ * @returns Object with fullSQL and simpleSQL properties
1482
+ */
1483
+ GetSaveSQLWithDetails(entity, bNewRecord, spName, user) {
1070
1484
  const sSimpleSQL = `EXEC [${entity.EntityInfo.SchemaName}].${spName} ${this.generateSPParams(entity, !bNewRecord)}`;
1071
1485
  const recordChangesEntityInfo = this.Entities.find((e) => e.Name === 'Record Changes');
1072
1486
  let sSQL = '';
@@ -1081,34 +1495,54 @@ class SQLServerDataProvider extends core_1.ProviderBase {
1081
1495
  sSQL = `
1082
1496
  DECLARE @ResultTable TABLE (
1083
1497
  ${this.getAllEntityColumnsSQL(entity.EntityInfo)}
1084
- )
1498
+ );
1085
1499
 
1086
1500
  INSERT INTO @ResultTable
1087
- ${sSimpleSQL}
1501
+ ${sSimpleSQL};
1088
1502
 
1089
- DECLARE @ID NVARCHAR(MAX)
1090
- SELECT @ID = ${concatPKIDString} FROM @ResultTable
1503
+ DECLARE @ID NVARCHAR(MAX);
1504
+
1505
+ SELECT @ID = ${concatPKIDString} FROM @ResultTable;
1506
+
1091
1507
  IF @ID IS NOT NULL
1092
1508
  BEGIN
1093
1509
  DECLARE @ResultChangesTable TABLE (
1094
1510
  ${this.getAllEntityColumnsSQL(recordChangesEntityInfo)}
1095
- )
1511
+ );
1096
1512
 
1097
1513
  INSERT INTO @ResultChangesTable
1098
- ${this.GetLogRecordChangeSQL(entity.GetAll(false), oldData, entity.EntityInfo.Name, '@ID', entity.EntityInfo, bNewRecord ? 'Create' : 'Update', user, false)}
1099
- END
1514
+ ${this.GetLogRecordChangeSQL(entity.GetAll(false), oldData, entity.EntityInfo.Name, '@ID', entity.EntityInfo, bNewRecord ? 'Create' : 'Update', user, false)};
1515
+ END;
1100
1516
 
1101
- SELECT * FROM @ResultTable`; // NOTE - in the above, we call the T-SQL variable @ID for simplicity just as a variable name, even though for each entity the pkey could be something else. Entity pkeys are not always a field called ID could be something else including composite keys.
1517
+ SELECT * FROM @ResultTable;`; // NOTE - in the above, we call the T-SQL variable @ID for simplicity just as a variable name, even though for each entity the pkey could be something else. Entity pkeys are not always a field called ID could be something else including composite keys.
1102
1518
  }
1103
1519
  else {
1104
1520
  // not doing track changes for this entity, keep it simple
1105
1521
  sSQL = sSimpleSQL;
1106
1522
  }
1107
- return sSQL;
1523
+ return { fullSQL: sSQL, simpleSQL: sSimpleSQL };
1108
1524
  }
1525
+ /**
1526
+ * Gets AI actions configured for an entity based on trigger timing
1527
+ *
1528
+ * @param entityInfo - The entity to get AI actions for
1529
+ * @param before - True to get before-save actions, false for after-save
1530
+ * @returns Array of AI action entities
1531
+ * @internal
1532
+ */
1109
1533
  GetEntityAIActions(entityInfo, before) {
1110
1534
  return aiengine_1.AIEngine.Instance.EntityAIActions.filter((a) => a.EntityID === entityInfo.ID && a.TriggerEvent.toLowerCase().trim() === (before ? 'before save' : 'after save'));
1111
1535
  }
1536
+ /**
1537
+ * Handles entity actions (non-AI) for save, delete, or validate operations
1538
+ *
1539
+ * @param entity - The entity being operated on
1540
+ * @param baseType - The type of operation
1541
+ * @param before - Whether this is before or after the operation
1542
+ * @param user - The user performing the operation
1543
+ * @returns Array of action results
1544
+ * @internal
1545
+ */
1112
1546
  async HandleEntityActions(entity, baseType, before, user) {
1113
1547
  // use the EntityActionEngine for this
1114
1548
  try {
@@ -1247,14 +1681,19 @@ class SQLServerDataProvider extends core_1.ProviderBase {
1247
1681
  // but they are supported (for now)
1248
1682
  await this.HandleEntityAIActions(entity, 'save', true, user);
1249
1683
  }
1250
- const sSQL = this.GetSaveSQL(entity, bNewRecord, spName, user);
1684
+ const sqlDetails = this.GetSaveSQLWithDetails(entity, bNewRecord, spName, user);
1685
+ const sSQL = sqlDetails.fullSQL;
1251
1686
  if (entity.TransactionGroup && !bReplay /*we never participate in a transaction if we're in replay mode*/) {
1252
1687
  // we have a transaction group, need to play nice and be part of it
1253
1688
  entity.RaiseReadyForTransaction(); // let the entity know we're ready to be part of the transaction
1254
1689
  // we are part of a transaction group, so just add our query to the list
1255
1690
  // and when the transaction is committed, we will send all the queries at once
1256
1691
  this._bAllowRefresh = false; // stop refreshes of metadata while we're doing work
1257
- entity.TransactionGroup.AddTransaction(new core_1.TransactionItem(entity, entityResult.Type === 'create' ? 'Create' : 'Update', sSQL, null, { dataSource: this._dataSource }, (transactionResult, success) => {
1692
+ entity.TransactionGroup.AddTransaction(new core_1.TransactionItem(entity, entityResult.Type === 'create' ? 'Create' : 'Update', sSQL, null, {
1693
+ dataSource: this._pool,
1694
+ simpleSQLFallback: entity.EntityInfo.TrackRecordChanges ? sqlDetails.simpleSQL : undefined,
1695
+ entityName: entity.EntityInfo.Name
1696
+ }, (transactionResult, success) => {
1258
1697
  // we get here whenever the transaction group does gets around to committing
1259
1698
  // our query.
1260
1699
  this._bAllowRefresh = true; // allow refreshes again
@@ -1290,7 +1729,12 @@ class SQLServerDataProvider extends core_1.ProviderBase {
1290
1729
  result = [entity.GetAll()]; // just return the entity as it was before the save as we are NOT saving anything as we are in replay mode
1291
1730
  }
1292
1731
  else {
1293
- const rawResult = await this.ExecuteSQL(sSQL);
1732
+ // Execute SQL with optional simple SQL fallback for loggers
1733
+ const rawResult = await this.ExecuteSQL(sSQL, null, {
1734
+ isMutation: true,
1735
+ description: `Save ${entity.EntityInfo.Name}`,
1736
+ simpleSQLFallback: entity.EntityInfo.TrackRecordChanges ? sqlDetails.simpleSQL : undefined
1737
+ });
1294
1738
  result = await this.ProcessEntityRows(rawResult, entity.EntityInfo);
1295
1739
  }
1296
1740
  this._bAllowRefresh = true; // allow refreshes now
@@ -1324,10 +1768,10 @@ class SQLServerDataProvider extends core_1.ProviderBase {
1324
1768
  }
1325
1769
  }
1326
1770
  MapTransactionResultToNewValues(transactionResult) {
1327
- return Object.keys(transactionResult).map(k => {
1771
+ return Object.keys(transactionResult).map((k) => {
1328
1772
  return {
1329
1773
  FieldName: k,
1330
- Value: transactionResult[k]
1774
+ Value: transactionResult[k],
1331
1775
  };
1332
1776
  }); // transform the result into a list of field/value pairs
1333
1777
  }
@@ -1362,22 +1806,26 @@ class SQLServerDataProvider extends core_1.ProviderBase {
1362
1806
  let sRet = '', bFirst = true;
1363
1807
  for (let i = 0; i < entity.EntityInfo.Fields.length; i++) {
1364
1808
  const f = entity.EntityInfo.Fields[i];
1365
- if (f.AllowUpdateAPI) {
1366
- if (!f.SkipValidation) {
1809
+ // For CREATE operations, include primary keys that are not auto-increment and have actual values
1810
+ const includePrimaryKeyForCreate = !isUpdate && f.IsPrimaryKey && !f.AutoIncrement && entity.Get(f.Name) !== null && entity.Get(f.Name) !== undefined;
1811
+ if (f.AllowUpdateAPI || includePrimaryKeyForCreate) {
1812
+ if (!f.SkipValidation || includePrimaryKeyForCreate) {
1367
1813
  // DO NOT INCLUDE any fields where we skip validation, these are fields that are not editable by the user/object
1368
1814
  // model/api because they're special fields like ID, CreatedAt, etc. or they're virtual or auto-increment, etc.
1815
+ // EXCEPTION: Include primary keys for CREATE when they have values and are not auto-increment
1369
1816
  let value = entity.Get(f.Name);
1370
1817
  if (value && f.Type.trim().toLowerCase() === 'datetimeoffset') {
1371
1818
  // for non-null datetimeoffset fields, we need to convert the value to ISO format
1372
1819
  value = new Date(value).toISOString();
1373
1820
  }
1374
- else if (!isUpdate && f.Type.trim().toLowerCase() === 'uniqueidentifier') {
1821
+ else if (!isUpdate && f.Type.trim().toLowerCase() === 'uniqueidentifier' && !includePrimaryKeyForCreate) {
1375
1822
  // in the case of unique identifiers, for CREATE procs only,
1376
1823
  // we need to check to see if the value we have in the entity object is a function like newid() or newsquentialid()
1377
1824
  // in those cases we should just skip the parameter entirely because that means there is a default value that should be used
1378
1825
  // and that will be handled by the database not by us
1379
1826
  // instead of just checking for specific functions like newid(), we can instead check for any string that includes ()
1380
1827
  // this way we can handle any function that the database might support in the future
1828
+ // EXCEPTION: Don't skip if we're including a primary key for create
1381
1829
  if (typeof value === 'string' && value.includes('()')) {
1382
1830
  continue; // skip this field entirely by going to the next iteration of the loop
1383
1831
  }
@@ -1390,7 +1838,7 @@ class SQLServerDataProvider extends core_1.ProviderBase {
1390
1838
  }
1391
1839
  if (isUpdate && bFirst === false) {
1392
1840
  // this is an update and we have other fields, so we need to add all of the pkeys to the end of the SP call
1393
- for (let pkey of entity.PrimaryKey.KeyValuePairs) {
1841
+ for (const pkey of entity.PrimaryKey.KeyValuePairs) {
1394
1842
  const f = entity.EntityInfo.Fields.find((f) => f.Name.trim().toLowerCase() === pkey.FieldName.trim().toLowerCase());
1395
1843
  const pkeyQuotes = f.NeedsQuotes ? "'" : '';
1396
1844
  sRet += `, @${f.CodeName} = ` + pkeyQuotes + pkey.Value + pkeyQuotes; // add pkey to update SP at end, but only if other fields included
@@ -1490,7 +1938,7 @@ class SQLServerDataProvider extends core_1.ProviderBase {
1490
1938
  */
1491
1939
  CreateUserDescriptionOfChanges(changesObject, maxValueLength = 200, cutOffText = '...') {
1492
1940
  let sRet = '';
1493
- let keys = Object.keys(changesObject);
1941
+ const keys = Object.keys(changesObject);
1494
1942
  for (let i = 0; i < keys.length; i++) {
1495
1943
  const change = changesObject[keys[i]];
1496
1944
  if (sRet.length > 0) {
@@ -1592,6 +2040,15 @@ class SQLServerDataProvider extends core_1.ProviderBase {
1592
2040
  if (d && d.length > 0) {
1593
2041
  // got the record, now process the relationships if there are any
1594
2042
  const ret = d[0];
2043
+ // we need to post process the retrieval to see if we have any char or nchar fields and we need to remove their trailing spaces
2044
+ for (const field of entity.EntityInfo.Fields) {
2045
+ if (field.TSType === core_1.EntityFieldTSType.String &&
2046
+ field.Type.toLowerCase().includes('char') &&
2047
+ !field.Type.toLowerCase().includes('varchar')) {
2048
+ // trim trailing spaces for char and nchar fields
2049
+ ret[field.Name] = ret[field.Name] ? ret[field.Name].trimEnd() : ret[field.Name];
2050
+ }
2051
+ }
1595
2052
  if (EntityRelationshipsToLoad && EntityRelationshipsToLoad.length > 0) {
1596
2053
  for (let i = 0; i < EntityRelationshipsToLoad.length; i++) {
1597
2054
  const rel = EntityRelationshipsToLoad[i];
@@ -1622,7 +2079,7 @@ class SQLServerDataProvider extends core_1.ProviderBase {
1622
2079
  const rawRelData = await this.ExecuteSQL(relSql);
1623
2080
  if (rawRelData && rawRelData.length > 0) {
1624
2081
  // Find the related entity info to process datetime fields correctly
1625
- const relEntityInfo = this.Entities.find(e => e.Name.trim().toLowerCase() === relInfo.RelatedEntity.trim().toLowerCase());
2082
+ const relEntityInfo = this.Entities.find((e) => e.Name.trim().toLowerCase() === relInfo.RelatedEntity.trim().toLowerCase());
1626
2083
  if (relEntityInfo) {
1627
2084
  ret[rel] = await this.ProcessEntityRows(rawRelData, relEntityInfo);
1628
2085
  }
@@ -1639,7 +2096,23 @@ class SQLServerDataProvider extends core_1.ProviderBase {
1639
2096
  // if we get here, something didn't go right
1640
2097
  return null;
1641
2098
  }
2099
+ /**
2100
+ * Generates the SQL statement for deleting an entity record
2101
+ *
2102
+ * @param entity - The entity to delete
2103
+ * @param user - The user performing the delete
2104
+ * @returns The full SQL statement for deletion
2105
+ * @internal
2106
+ */
1642
2107
  GetDeleteSQL(entity, user) {
2108
+ const result = this.GetDeleteSQLWithDetails(entity, user);
2109
+ return result.fullSQL;
2110
+ }
2111
+ /**
2112
+ * This function generates both the full SQL (with record change metadata) and the simple stored procedure call for delete
2113
+ * @returns Object with fullSQL and simpleSQL properties
2114
+ */
2115
+ GetDeleteSQLWithDetails(entity, user) {
1643
2116
  let sSQL = '';
1644
2117
  const spName = entity.EntityInfo.spDelete ? entity.EntityInfo.spDelete : `spDelete${entity.EntityInfo.ClassName}`;
1645
2118
  const sParams = entity.PrimaryKey.KeyValuePairs.map((kv) => {
@@ -1698,7 +2171,7 @@ class SQLServerDataProvider extends core_1.ProviderBase {
1698
2171
  // just delete the record
1699
2172
  sSQL = sSimpleSQL;
1700
2173
  }
1701
- return sSQL;
2174
+ return { fullSQL: sSQL, simpleSQL: sSimpleSQL };
1702
2175
  }
1703
2176
  async Delete(entity, options, user) {
1704
2177
  const result = new core_1.BaseEntityResult();
@@ -1721,7 +2194,8 @@ class SQLServerDataProvider extends core_1.ProviderBase {
1721
2194
  entity.ResultHistory.push(result); // push the new result as we have started a process
1722
2195
  // REMEMBER - this is the provider and the BaseEntity/subclasses handle user-level permission checking already, we just make sure API was turned on for the operation
1723
2196
  // if we get here we can delete, so build the SQL and then handle appropriately either as part of TransGroup or directly...
1724
- const sSQL = this.GetDeleteSQL(entity, user);
2197
+ const sqlDetails = this.GetDeleteSQLWithDetails(entity, user);
2198
+ const sSQL = sqlDetails.fullSQL;
1725
2199
  // Handle Entity and Entity AI Actions here w/ before and after handling
1726
2200
  if (false === options?.SkipEntityActions)
1727
2201
  await this.HandleEntityActions(entity, 'delete', true, user);
@@ -1732,7 +2206,11 @@ class SQLServerDataProvider extends core_1.ProviderBase {
1732
2206
  entity.RaiseReadyForTransaction();
1733
2207
  // we are part of a transaction group, so just add our query to the list
1734
2208
  // and when the transaction is committed, we will send all the queries at once
1735
- entity.TransactionGroup.AddTransaction(new core_1.TransactionItem(entity, 'Delete', sSQL, null, { dataSource: this._dataSource }, (transactionResult, success) => {
2209
+ entity.TransactionGroup.AddTransaction(new core_1.TransactionItem(entity, 'Delete', sSQL, null, {
2210
+ dataSource: this._pool,
2211
+ simpleSQLFallback: entity.EntityInfo.TrackRecordChanges ? sqlDetails.simpleSQL : undefined,
2212
+ entityName: entity.EntityInfo.Name
2213
+ }, (transactionResult, success) => {
1736
2214
  // we get here whenever the transaction group does gets around to committing
1737
2215
  // our query.
1738
2216
  result.EndedAt = new Date();
@@ -1745,7 +2223,7 @@ class SQLServerDataProvider extends core_1.ProviderBase {
1745
2223
  this.HandleEntityAIActions(entity, 'delete', false, user);
1746
2224
  }
1747
2225
  // Make sure the return value matches up as that is how we know the SP was succesfully internally
1748
- for (let key of entity.PrimaryKeys) {
2226
+ for (const key of entity.PrimaryKeys) {
1749
2227
  if (key.Value !== transactionResult[key.Name]) {
1750
2228
  result.Success = false;
1751
2229
  result.Message = 'Transaction failed to commit';
@@ -1769,11 +2247,16 @@ class SQLServerDataProvider extends core_1.ProviderBase {
1769
2247
  d = [entity.GetAll()]; // just return the entity as it was before the save as we are NOT saving anything as we are in replay mode
1770
2248
  }
1771
2249
  else {
1772
- d = await this.ExecuteSQL(sSQL);
2250
+ // Execute SQL with optional simple SQL fallback for loggers
2251
+ d = await this.ExecuteSQL(sSQL, null, {
2252
+ isMutation: true,
2253
+ description: `Delete ${entity.EntityInfo.Name}`,
2254
+ simpleSQLFallback: entity.EntityInfo.TrackRecordChanges ? sqlDetails.simpleSQL : undefined
2255
+ });
1773
2256
  }
1774
2257
  if (d && d[0]) {
1775
2258
  // SP executed, now make sure the return value matches up as that is how we know the SP was succesfully internally
1776
- for (let key of entity.PrimaryKeys) {
2259
+ for (const key of entity.PrimaryKeys) {
1777
2260
  if (key.Value !== d[0][key.Name]) {
1778
2261
  return false;
1779
2262
  }
@@ -1823,16 +2306,73 @@ class SQLServerDataProvider extends core_1.ProviderBase {
1823
2306
  ON
1824
2307
  di.EntityID = e.ID
1825
2308
  WHERE
1826
- d.Name = @0`;
2309
+ d.Name = @p0`;
1827
2310
  const items = await this.ExecuteSQL(sSQL, [datasetName]);
1828
2311
  // now we have the dataset and the items, we need to get the update date from the items underlying entities
1829
2312
  if (items && items.length > 0) {
1830
- // fire off all of the item queries in parallel
1831
- const promises = items.map((item) => {
1832
- return this.GetDatasetItem(item, itemFilters, datasetName); // no await as Promise.All used below
1833
- });
1834
- // execute all promises in parallel
1835
- const results = await Promise.all(promises);
2313
+ // Optimization: Use batch SQL execution for multiple items
2314
+ // Build SQL queries for all items
2315
+ const queries = [];
2316
+ const itemsWithSQL = [];
2317
+ for (const item of items) {
2318
+ const itemSQL = this.GetDatasetItemSQL(item, itemFilters, datasetName);
2319
+ if (itemSQL) {
2320
+ queries.push(itemSQL);
2321
+ itemsWithSQL.push(item);
2322
+ }
2323
+ else {
2324
+ // Handle invalid SQL case - add to results with error
2325
+ itemsWithSQL.push({ ...item, hasError: true });
2326
+ }
2327
+ }
2328
+ // Execute all queries in a single batch
2329
+ const batchResults = await this.ExecuteSQLBatch(queries);
2330
+ // Process results for each item
2331
+ const results = [];
2332
+ let queryIndex = 0;
2333
+ for (const item of itemsWithSQL) {
2334
+ if (item.hasError) {
2335
+ // Handle error case for invalid columns
2336
+ results.push({
2337
+ EntityID: item.EntityID,
2338
+ EntityName: item.Entity,
2339
+ Code: item.Code,
2340
+ Results: null,
2341
+ LatestUpdateDate: null,
2342
+ Status: 'Invalid columns specified for dataset item',
2343
+ Success: false,
2344
+ });
2345
+ }
2346
+ else {
2347
+ // Process successful query result
2348
+ const itemData = batchResults[queryIndex] || [];
2349
+ const itemUpdatedAt = new Date(item.DatasetItemUpdatedAt);
2350
+ const datasetUpdatedAt = new Date(item.DatasetUpdatedAt);
2351
+ const datasetMaxUpdatedAt = new Date(Math.max(itemUpdatedAt.getTime(), datasetUpdatedAt.getTime()));
2352
+ // get the latest update date
2353
+ let latestUpdateDate = new Date(1900, 1, 1);
2354
+ if (itemData && itemData.length > 0) {
2355
+ itemData.forEach((data) => {
2356
+ if (data[item.DateFieldToCheck] && new Date(data[item.DateFieldToCheck]) > latestUpdateDate) {
2357
+ latestUpdateDate = new Date(data[item.DateFieldToCheck]);
2358
+ }
2359
+ });
2360
+ }
2361
+ // finally, compare the latestUpdatedDate to the dataset max date, and use the latter if it is more recent
2362
+ if (datasetMaxUpdatedAt > latestUpdateDate) {
2363
+ latestUpdateDate = datasetMaxUpdatedAt;
2364
+ }
2365
+ results.push({
2366
+ EntityID: item.EntityID,
2367
+ EntityName: item.Entity,
2368
+ Code: item.Code,
2369
+ Results: itemData,
2370
+ LatestUpdateDate: latestUpdateDate,
2371
+ Success: itemData !== null && itemData !== undefined,
2372
+ });
2373
+ queryIndex++;
2374
+ }
2375
+ }
1836
2376
  // determine overall success
1837
2377
  const bSuccess = results.every((result) => result.Success);
1838
2378
  // get the latest update date from all the results
@@ -1844,8 +2384,7 @@ class SQLServerDataProvider extends core_1.ProviderBase {
1844
2384
  }
1845
2385
  }
1846
2386
  return acc;
1847
- }, new Date(0) // Unix epoch - lowest possible date to start with
1848
- );
2387
+ }, new Date(0));
1849
2388
  return {
1850
2389
  DatasetID: items[0].DatasetID,
1851
2390
  DatasetName: datasetName,
@@ -1866,18 +2405,32 @@ class SQLServerDataProvider extends core_1.ProviderBase {
1866
2405
  };
1867
2406
  }
1868
2407
  }
1869
- async GetDatasetItem(item, itemFilters, datasetName) {
2408
+ /**
2409
+ * Constructs the SQL query for a dataset item.
2410
+ * @param item - The dataset item metadata
2411
+ * @param itemFilters - Optional filters to apply
2412
+ * @param datasetName - Name of the dataset (for error logging)
2413
+ * @returns The SQL query string, or null if columns are invalid
2414
+ */
2415
+ GetDatasetItemSQL(item, itemFilters, datasetName) {
1870
2416
  let filterSQL = '';
1871
2417
  if (itemFilters && itemFilters.length > 0) {
1872
2418
  const filter = itemFilters.find((f) => f.ItemCode === item.Code);
1873
2419
  if (filter)
1874
2420
  filterSQL = (item.WhereClause ? ' AND ' : ' WHERE ') + '(' + filter.Filter + ')';
1875
2421
  }
2422
+ const columns = this.GetColumnsForDatasetItem(item, datasetName);
2423
+ if (!columns) {
2424
+ return null; // Invalid columns
2425
+ }
2426
+ return `SELECT ${columns} FROM [${item.EntitySchemaName}].[${item.EntityBaseView}] ${item.WhereClause ? 'WHERE ' + item.WhereClause : ''}${filterSQL}`;
2427
+ }
2428
+ async GetDatasetItem(item, itemFilters, datasetName) {
1876
2429
  const itemUpdatedAt = new Date(item.DatasetItemUpdatedAt);
1877
2430
  const datasetUpdatedAt = new Date(item.DatasetUpdatedAt);
1878
2431
  const datasetMaxUpdatedAt = new Date(Math.max(itemUpdatedAt.getTime(), datasetUpdatedAt.getTime()));
1879
- const columns = this.GetColumnsForDatasetItem(item, datasetName);
1880
- if (!columns) {
2432
+ const itemSQL = this.GetDatasetItemSQL(item, itemFilters, datasetName);
2433
+ if (!itemSQL) {
1881
2434
  // failure condition within columns, return a failed result
1882
2435
  return {
1883
2436
  EntityID: item.EntityID,
@@ -1889,7 +2442,6 @@ class SQLServerDataProvider extends core_1.ProviderBase {
1889
2442
  Success: false,
1890
2443
  };
1891
2444
  }
1892
- const itemSQL = `SELECT ${columns} FROM [${item.EntitySchemaName}].[${item.EntityBaseView}] ${item.WhereClause ? 'WHERE ' + item.WhereClause : ''}${filterSQL}`;
1893
2445
  const itemData = await this.ExecuteSQL(itemSQL);
1894
2446
  // get the latest update date
1895
2447
  let latestUpdateDate = new Date(1900, 1, 1);
@@ -1978,7 +2530,7 @@ class SQLServerDataProvider extends core_1.ProviderBase {
1978
2530
  ON
1979
2531
  di.EntityID = e.ID
1980
2532
  WHERE
1981
- d.Name = @0`;
2533
+ d.Name = @p0`;
1982
2534
  const items = await this.ExecuteSQL(sSQL, [datasetName]);
1983
2535
  // now we have the dataset and the items, we need to get the update date from the items underlying entities
1984
2536
  if (items && items.length > 0) {
@@ -2165,7 +2717,7 @@ class SQLServerDataProvider extends core_1.ProviderBase {
2165
2717
  return rows;
2166
2718
  }
2167
2719
  // Find all datetime fields in the entity
2168
- const datetimeFields = entityInfo.Fields.filter(field => field.TSType === core_1.EntityFieldTSType.Date);
2720
+ const datetimeFields = entityInfo.Fields.filter((field) => field.TSType === core_1.EntityFieldTSType.Date);
2169
2721
  // If there are no datetime fields, return the rows as-is
2170
2722
  if (datetimeFields.length === 0) {
2171
2723
  return rows;
@@ -2173,7 +2725,7 @@ class SQLServerDataProvider extends core_1.ProviderBase {
2173
2725
  // Check if we need datetimeoffset adjustment (lazy loaded on first use)
2174
2726
  const needsAdjustment = await this.NeedsDatetimeOffsetAdjustment();
2175
2727
  // Process each row
2176
- return rows.map(row => {
2728
+ return rows.map((row) => {
2177
2729
  const processedRow = { ...row };
2178
2730
  for (const field of datetimeFields) {
2179
2731
  const fieldValue = processedRow[field.Name];
@@ -2194,9 +2746,9 @@ class SQLServerDataProvider extends core_1.ProviderBase {
2194
2746
  }
2195
2747
  }
2196
2748
  else if (fieldValue instanceof Date) {
2197
- // TypeORM has already converted to a Date object using local timezone
2749
+ // DB driver has already converted to a Date object using local timezone
2198
2750
  // We need to adjust it back to UTC
2199
- // SQL Server stores datetime2 as UTC, but TypeORM interprets it as local
2751
+ // SQL Server stores datetime2 as UTC, but DB Driver interprets it as local
2200
2752
  const localDate = fieldValue;
2201
2753
  const timezoneOffsetMs = localDate.getTimezoneOffset() * 60 * 1000;
2202
2754
  const utcDate = new Date(localDate.getTime() + timezoneOffsetMs);
@@ -2211,7 +2763,7 @@ class SQLServerDataProvider extends core_1.ProviderBase {
2211
2763
  }
2212
2764
  else if (fieldValue instanceof Date && needsAdjustment) {
2213
2765
  // The database driver has incorrectly converted to a Date object using local timezone
2214
- // For datetimeoffset, SQL Server provides the value with timezone info, but the driver
2766
+ // For datetimeoffset, SQL Server provides the value with timezone info, but the driver
2215
2767
  // creates the Date as if it were in local time, ignoring the offset
2216
2768
  // We need to adjust it back to the correct UTC time
2217
2769
  const localDate = fieldValue;
@@ -2242,25 +2794,212 @@ class SQLServerDataProvider extends core_1.ProviderBase {
2242
2794
  * @param parameters
2243
2795
  * @returns
2244
2796
  */
2245
- async ExecuteSQL(query, parameters = null) {
2797
+ async ExecuteSQL(query, parameters = null, options) {
2246
2798
  try {
2247
- if (this._queryRunner) {
2248
- const data = await this._queryRunner.query(query, parameters);
2249
- return data;
2799
+ let request;
2800
+ if (this._transaction && this._transactionRequest) {
2801
+ // Use transaction request if in a transaction
2802
+ request = this._transactionRequest;
2803
+ }
2804
+ else if (this._transaction) {
2805
+ // Create a new request for this transaction if we don't have one
2806
+ request = new sql.Request(this._transaction);
2250
2807
  }
2251
2808
  else {
2252
- const data = await this._dataSource.query(query, parameters);
2253
- return data;
2809
+ // Use pool request for non-transactional queries
2810
+ request = new sql.Request(this._pool);
2811
+ }
2812
+ // Add parameters if provided
2813
+ if (parameters) {
2814
+ if (Array.isArray(parameters)) {
2815
+ // Handle positional parameters (legacy TypeORM style)
2816
+ // Convert to named parameters for mssql
2817
+ parameters.forEach((value, index) => {
2818
+ request.input(`p${index}`, value);
2819
+ });
2820
+ // Replace ? with @p0, @p1, etc. in the query
2821
+ let paramIndex = 0;
2822
+ query = query.replace(/\?/g, () => `@p${paramIndex++}`);
2823
+ }
2824
+ else if (typeof parameters === 'object') {
2825
+ // Handle named parameters
2826
+ for (const [key, value] of Object.entries(parameters)) {
2827
+ request.input(key, value);
2828
+ }
2829
+ }
2254
2830
  }
2831
+ // Log SQL statement to all active logging sessions (runs in parallel with execution)
2832
+ const loggingPromise = this._logSqlStatement(query, parameters, options?.description, options?.ignoreLogging, options?.isMutation, options?.simpleSQLFallback);
2833
+ // Execute SQL and logging in parallel, but wait for both to complete
2834
+ const [result] = await Promise.all([request.query(query), loggingPromise]);
2835
+ // Return recordset for consistency with TypeORM behavior
2836
+ // If multiple recordsets, return recordsets array
2837
+ return result.recordsets && Array.isArray(result.recordsets) && result.recordsets.length > 1 ? result.recordsets : result.recordset;
2255
2838
  }
2256
2839
  catch (e) {
2257
2840
  (0, core_1.LogError)(e);
2258
2841
  throw e; // force caller to handle
2259
2842
  }
2260
2843
  }
2844
+ /**
2845
+ * Static helper method for executing SQL queries on an external connection pool.
2846
+ * This method is designed to be used by generated code where a connection pool
2847
+ * is passed in from the context. It returns results as arrays for consistency
2848
+ * with the expected behavior in generated resolvers.
2849
+ *
2850
+ * @param pool - The mssql ConnectionPool to execute the query on
2851
+ * @param query - The SQL query to execute
2852
+ * @param parameters - Optional parameters for the query
2853
+ * @returns Promise<any[]> - Array of results (empty array if no results)
2854
+ */
2855
+ static async ExecuteSQLWithPool(pool, query, parameters) {
2856
+ try {
2857
+ const request = new sql.Request(pool);
2858
+ // Add parameters if provided
2859
+ if (parameters) {
2860
+ if (Array.isArray(parameters)) {
2861
+ // Handle positional parameters
2862
+ parameters.forEach((value, index) => {
2863
+ request.input(`p${index}`, value);
2864
+ });
2865
+ // Replace ? with @p0, @p1, etc. in the query
2866
+ let paramIndex = 0;
2867
+ query = query.replace(/\?/g, () => `@p${paramIndex++}`);
2868
+ }
2869
+ else if (typeof parameters === 'object') {
2870
+ // Handle named parameters
2871
+ for (const [key, value] of Object.entries(parameters)) {
2872
+ request.input(key, value);
2873
+ }
2874
+ }
2875
+ }
2876
+ const result = await request.query(query);
2877
+ // Always return array for consistency
2878
+ return result.recordset || [];
2879
+ }
2880
+ catch (e) {
2881
+ (0, core_1.LogError)(e);
2882
+ throw e;
2883
+ }
2884
+ }
2885
+ /**
2886
+ * Static method to execute a batch of SQL queries using a provided connection source.
2887
+ * This allows the batch logic to be reused from external contexts like TransactionGroup.
2888
+ * All queries are combined into a single SQL statement and executed together within
2889
+ * the same connection/transaction context for optimal performance.
2890
+ *
2891
+ * @param connectionSource - Either a sql.ConnectionPool, sql.Transaction, or sql.Request to use for execution
2892
+ * @param queries - Array of SQL queries to execute
2893
+ * @param parameters - Optional array of parameter arrays, one for each query
2894
+ * @returns Promise<any[][]> - Array of result arrays, one for each query
2895
+ */
2896
+ static async ExecuteSQLBatchStatic(connectionSource, queries, parameters) {
2897
+ try {
2898
+ let request;
2899
+ // Determine the request to use based on connection source type
2900
+ if (connectionSource instanceof sql.Request) {
2901
+ request = connectionSource;
2902
+ }
2903
+ else if (connectionSource instanceof sql.Transaction) {
2904
+ request = new sql.Request(connectionSource);
2905
+ }
2906
+ else {
2907
+ // Assume it's a ConnectionPool
2908
+ request = new sql.Request(connectionSource);
2909
+ }
2910
+ // Build combined batch SQL
2911
+ let batchSQL = '';
2912
+ let paramIndex = 0;
2913
+ queries.forEach((query, queryIndex) => {
2914
+ // Add parameters for this query if provided
2915
+ if (parameters && parameters[queryIndex]) {
2916
+ const queryParams = parameters[queryIndex];
2917
+ if (Array.isArray(queryParams)) {
2918
+ // Handle positional parameters
2919
+ queryParams.forEach((value) => {
2920
+ request.input(`p${paramIndex}`, value);
2921
+ paramIndex++;
2922
+ });
2923
+ // Replace @p0, @p1, etc. with actual parameter names
2924
+ let localParamIndex = paramIndex - queryParams.length;
2925
+ query = query.replace(/@p(\d+)/g, () => `@p${localParamIndex++}`);
2926
+ }
2927
+ else if (typeof queryParams === 'object') {
2928
+ // Handle named parameters - prefix with query index to avoid conflicts
2929
+ for (const [key, value] of Object.entries(queryParams)) {
2930
+ const paramName = `q${queryIndex}_${key}`;
2931
+ request.input(paramName, value);
2932
+ // Replace parameter references in query
2933
+ query = query.replace(new RegExp(`@${key}\\b`, 'g'), `@${paramName}`);
2934
+ }
2935
+ }
2936
+ }
2937
+ batchSQL += query;
2938
+ if (queryIndex < queries.length - 1) {
2939
+ batchSQL += ';\n';
2940
+ }
2941
+ });
2942
+ // Execute the batch SQL
2943
+ const result = await request.query(batchSQL);
2944
+ // Return array of recordsets - one for each query
2945
+ // Handle both single and multiple recordsets
2946
+ if (result.recordsets && Array.isArray(result.recordsets)) {
2947
+ return result.recordsets;
2948
+ }
2949
+ else if (result.recordset) {
2950
+ return [result.recordset];
2951
+ }
2952
+ else {
2953
+ return [];
2954
+ }
2955
+ }
2956
+ catch (e) {
2957
+ (0, core_1.LogError)(e);
2958
+ throw e;
2959
+ }
2960
+ }
2961
+ /**
2962
+ * Executes multiple SQL queries in a single batch for optimal performance.
2963
+ * All queries are combined into a single SQL statement and executed together.
2964
+ * This is particularly useful for bulk operations where you need to execute
2965
+ * many similar queries and want to minimize round trips to the database.
2966
+ *
2967
+ * @param queries - Array of SQL queries to execute
2968
+ * @param parameters - Optional array of parameter arrays, one for each query
2969
+ * @param options - Optional execution options for logging and description
2970
+ * @returns Promise<any[][]> - Array of result arrays, one for each query
2971
+ */
2972
+ async ExecuteSQLBatch(queries, parameters, options) {
2973
+ try {
2974
+ let connectionSource;
2975
+ if (this._transaction && this._transactionRequest) {
2976
+ // Use transaction request if in a transaction
2977
+ connectionSource = this._transactionRequest;
2978
+ }
2979
+ else if (this._transaction) {
2980
+ // Use transaction if we have one
2981
+ connectionSource = this._transaction;
2982
+ }
2983
+ else {
2984
+ // Use pool request for non-transactional queries
2985
+ connectionSource = this._pool;
2986
+ }
2987
+ // Log batch SQL statement to all active logging sessions
2988
+ const description = options?.description ? `${options.description} (Batch: ${queries.length} queries)` : `Batch execution: ${queries.length} queries`;
2989
+ const batchSQL = queries.join(';\n');
2990
+ const loggingPromise = this._logSqlStatement(batchSQL, parameters, description, options?.ignoreLogging, options?.isMutation);
2991
+ // Execute SQL and logging in parallel, but wait for both to complete
2992
+ const [result] = await Promise.all([SQLServerDataProvider.ExecuteSQLBatchStatic(connectionSource, queries, parameters), loggingPromise]);
2993
+ return result;
2994
+ }
2995
+ catch (e) {
2996
+ (0, core_1.LogError)(e);
2997
+ throw e;
2998
+ }
2999
+ }
2261
3000
  /**
2262
3001
  * Determines whether the database driver requires adjustment for datetimeoffset fields.
2263
- * This method performs an empirical test on first use to detect if the driver (e.g., TypeORM)
3002
+ * This method performs an empirical test on first use to detect if the driver (e.g., mssql)
2264
3003
  * incorrectly handles timezone information in datetimeoffset columns.
2265
3004
  *
2266
3005
  * @returns {Promise<boolean>} True if datetimeoffset values need timezone adjustment, false otherwise
@@ -2306,7 +3045,7 @@ class SQLServerDataProvider extends core_1.ProviderBase {
2306
3045
  -- Select and return the row
2307
3046
  SELECT TestDateTime FROM @TestTable;
2308
3047
  `;
2309
- const result = await this.ExecuteSQL(testQuery);
3048
+ const result = await this.ExecuteSQL(testQuery, null, { description: 'DatetimeOffset handling test' });
2310
3049
  if (result && result.length > 0) {
2311
3050
  const testDate = result[0].TestDateTime;
2312
3051
  // Expected: January 1, 1900 at 11:00 AM UTC
@@ -2341,9 +3080,11 @@ class SQLServerDataProvider extends core_1.ProviderBase {
2341
3080
  }
2342
3081
  async BeginTransaction() {
2343
3082
  try {
2344
- if (!this._queryRunner)
2345
- this._queryRunner = this._dataSource.createQueryRunner();
2346
- await this._queryRunner.startTransaction();
3083
+ // Create a new transaction from the pool
3084
+ this._transaction = new sql.Transaction(this._pool);
3085
+ await this._transaction.begin();
3086
+ // Create a request for this transaction that can be reused
3087
+ this._transactionRequest = new sql.Request(this._transaction);
2347
3088
  }
2348
3089
  catch (e) {
2349
3090
  (0, core_1.LogError)(e);
@@ -2352,7 +3093,11 @@ class SQLServerDataProvider extends core_1.ProviderBase {
2352
3093
  }
2353
3094
  async CommitTransaction() {
2354
3095
  try {
2355
- await this._queryRunner.commitTransaction();
3096
+ if (this._transaction) {
3097
+ await this._transaction.commit();
3098
+ this._transaction = null;
3099
+ this._transactionRequest = null;
3100
+ }
2356
3101
  }
2357
3102
  catch (e) {
2358
3103
  (0, core_1.LogError)(e);
@@ -2361,7 +3106,11 @@ class SQLServerDataProvider extends core_1.ProviderBase {
2361
3106
  }
2362
3107
  async RollbackTransaction() {
2363
3108
  try {
2364
- await this._queryRunner.rollbackTransaction();
3109
+ if (this._transaction) {
3110
+ await this._transaction.rollback();
3111
+ this._transaction = null;
3112
+ this._transactionRequest = null;
3113
+ }
2365
3114
  }
2366
3115
  catch (e) {
2367
3116
  (0, core_1.LogError)(e);
@@ -2420,9 +3169,9 @@ class SQLServerDataProvider extends core_1.ProviderBase {
2420
3169
  }
2421
3170
  else {
2422
3171
  // got our field, create a SQL Query
2423
- let sql = `SELECT [${f.Name}] FROM [${e.SchemaName}].[${e.BaseView}] WHERE `;
3172
+ const sql = `SELECT [${f.Name}] FROM [${e.SchemaName}].[${e.BaseView}] WHERE `;
2424
3173
  let where = '';
2425
- for (let pkv of CompositeKey.KeyValuePairs) {
3174
+ for (const pkv of CompositeKey.KeyValuePairs) {
2426
3175
  const pk = e.PrimaryKeys.find((pk) => pk.Name === pkv.FieldName);
2427
3176
  const quotes = pk.NeedsQuotes ? "'" : '';
2428
3177
  if (where.length > 0)