@memberjunction/core 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 (67) hide show
  1. package/dist/generic/applicationInfo.d.ts.map +1 -1
  2. package/dist/generic/applicationInfo.js +2 -1
  3. package/dist/generic/applicationInfo.js.map +1 -1
  4. package/dist/generic/baseEngine.d.ts.map +1 -1
  5. package/dist/generic/baseEngine.js +0 -13
  6. package/dist/generic/baseEngine.js.map +1 -1
  7. package/dist/generic/baseEntity.js +2 -2
  8. package/dist/generic/baseEntity.js.map +1 -1
  9. package/dist/generic/compositeKey.d.ts.map +1 -1
  10. package/dist/generic/compositeKey.js +12 -1
  11. package/dist/generic/compositeKey.js.map +1 -1
  12. package/dist/generic/databaseProviderBase.d.ts +653 -1
  13. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  14. package/dist/generic/databaseProviderBase.js +1463 -0
  15. package/dist/generic/databaseProviderBase.js.map +1 -1
  16. package/dist/generic/entityInfo.d.ts +2 -1
  17. package/dist/generic/entityInfo.d.ts.map +1 -1
  18. package/dist/generic/entityInfo.js +15 -10
  19. package/dist/generic/entityInfo.js.map +1 -1
  20. package/dist/generic/interfaces.d.ts +5 -1
  21. package/dist/generic/interfaces.d.ts.map +1 -1
  22. package/dist/generic/interfaces.js +2 -0
  23. package/dist/generic/interfaces.js.map +1 -1
  24. package/dist/generic/localCacheManager.d.ts.map +1 -1
  25. package/dist/generic/localCacheManager.js +4 -2
  26. package/dist/generic/localCacheManager.js.map +1 -1
  27. package/dist/generic/metadata.js +5 -5
  28. package/dist/generic/metadata.js.map +1 -1
  29. package/dist/generic/platformSQL.d.ts +25 -0
  30. package/dist/generic/platformSQL.d.ts.map +1 -0
  31. package/dist/generic/platformSQL.js +7 -0
  32. package/dist/generic/platformSQL.js.map +1 -0
  33. package/dist/generic/platformVariants.d.ts +68 -0
  34. package/dist/generic/platformVariants.d.ts.map +1 -0
  35. package/dist/generic/platformVariants.js +34 -0
  36. package/dist/generic/platformVariants.js.map +1 -0
  37. package/dist/generic/providerBase.d.ts +34 -1
  38. package/dist/generic/providerBase.d.ts.map +1 -1
  39. package/dist/generic/providerBase.js +76 -10
  40. package/dist/generic/providerBase.js.map +1 -1
  41. package/dist/generic/queryInfo.d.ts +97 -0
  42. package/dist/generic/queryInfo.d.ts.map +1 -1
  43. package/dist/generic/queryInfo.js +154 -13
  44. package/dist/generic/queryInfo.js.map +1 -1
  45. package/dist/generic/runQuerySQLFilterImplementations.d.ts +22 -2
  46. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  47. package/dist/generic/runQuerySQLFilterImplementations.js +74 -3
  48. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  49. package/dist/generic/securityInfo.d.ts +18 -0
  50. package/dist/generic/securityInfo.d.ts.map +1 -1
  51. package/dist/generic/securityInfo.js +30 -3
  52. package/dist/generic/securityInfo.js.map +1 -1
  53. package/dist/generic/util.d.ts.map +1 -1
  54. package/dist/generic/util.js +42 -3
  55. package/dist/generic/util.js.map +1 -1
  56. package/dist/index.d.ts +2 -0
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +2 -0
  59. package/dist/index.js.map +1 -1
  60. package/dist/views/runView.d.ts +16 -2
  61. package/dist/views/runView.d.ts.map +1 -1
  62. package/dist/views/runView.js +21 -4
  63. package/dist/views/runView.js.map +1 -1
  64. package/dist/views/viewInfo.d.ts.map +1 -1
  65. package/dist/views/viewInfo.js +2 -1
  66. package/dist/views/viewInfo.js.map +1 -1
  67. package/package.json +2 -2
@@ -1,10 +1,1473 @@
1
1
  import { ProviderBase } from "./providerBase.js";
2
+ import { EntityFieldTSType } from "./entityInfo.js";
3
+ import { BaseEntityResult } from "./baseEntity.js";
4
+ import { EntitySaveOptions, EntityDeleteOptions } from "./interfaces.js";
5
+ import { TransactionItem } from "./transactionGroup.js";
6
+ import { CompositeKey } from "./compositeKey.js";
7
+ import { LogError } from "./logging.js";
8
+ import { SQLExpressionValidator, uuidv4 } from "@memberjunction/global";
9
+ // Re-export PlatformSQL types from their canonical location for backward compatibility
10
+ export { IsPlatformSQL } from "./platformSQL.js";
2
11
  /**
3
12
  * This class is a generic server-side provider class to abstract database operations
4
13
  * on any database system and therefore be usable by server-side components that need to
5
14
  * do database operations but do not want close coupling with a specific database provider
6
15
  * like @see @memberjunction/sqlserver-dataprovider
16
+ *
17
+ * It contains DB-agnostic business logic (record change tracking, favorites, ISA hierarchy,
18
+ * record dependencies, diffing, etc.) that is shared across all database providers.
19
+ * Subclasses implement abstract methods for DB-specific SQL dialect generation.
7
20
  */
8
21
  export class DatabaseProviderBase extends ProviderBase {
22
+ /**
23
+ * Returns the database platform key for this provider.
24
+ * Override in subclasses. Defaults to 'sqlserver' for backward compatibility.
25
+ * Inherited from ProviderBase; redeclared here for DatabaseProviderBase consumers.
26
+ */
27
+ get PlatformKey() {
28
+ return 'sqlserver';
29
+ }
30
+ /**
31
+ * Gets the MemberJunction core schema name (e.g. '__mj').
32
+ * Subclasses should override if they have a different way to resolve this.
33
+ * Defaults to the value from ConfigData.
34
+ */
35
+ get MJCoreSchemaName() {
36
+ return this.ConfigData?.MJCoreSchemaName ?? '__mj';
37
+ }
38
+ /**
39
+ * Combined regex covering UUID functions from ALL platforms.
40
+ * Used by the static convenience method for code that doesn't have a provider instance.
41
+ */
42
+ static { this._allPlatformUUIDPattern = /^\s*(newid|newsequentialid|gen_random_uuid|uuid_generate_v4)\s*\(\s*\)\s*$/i; }
43
+ /**
44
+ * Combined regex covering default-value functions from ALL platforms.
45
+ * Used by the static convenience method for code that doesn't have a provider instance.
46
+ */
47
+ static { this._allPlatformDefaultPattern = /^\s*(getdate|getutcdate|sysdatetime|sysdatetimeoffset|sysutcdatetime|current_timestamp|now|clock_timestamp|statement_timestamp|transaction_timestamp)\s*\(\s*\)\s*$/i; }
48
+ /**
49
+ * Checks whether a string value looks like a database UUID generation function
50
+ * for this provider's platform.
51
+ *
52
+ * @param value The string value to check
53
+ * @returns true if the value matches a known UUID generation function pattern
54
+ */
55
+ IsUUIDGenerationFunction(value) {
56
+ return this.UUIDFunctionPattern.test(value);
57
+ }
58
+ /**
59
+ * Checks whether a string value looks like a known database default-value function
60
+ * that is NOT a UUID generator for this provider's platform.
61
+ *
62
+ * @param value The string value to check
63
+ * @returns true if the value matches a known non-UUID database function pattern
64
+ */
65
+ IsNonUUIDDatabaseFunction(value) {
66
+ return this.DBDefaultFunctionPattern.test(value);
67
+ }
68
+ /**
69
+ * Static convenience: checks all platforms' UUID generation functions.
70
+ * Prefer the instance method when you have a provider reference.
71
+ */
72
+ static IsUUIDGenerationFunctionAllPlatforms(value) {
73
+ return DatabaseProviderBase._allPlatformUUIDPattern.test(value);
74
+ }
75
+ /**
76
+ * Static convenience: checks all platforms' default-value functions.
77
+ * Prefer the instance method when you have a provider reference.
78
+ */
79
+ static IsNonUUIDDatabaseFunctionAllPlatforms(value) {
80
+ return DatabaseProviderBase._allPlatformDefaultPattern.test(value);
81
+ }
82
+ /**
83
+ * Generates a new UUID suitable for use as a primary key or unique identifier.
84
+ * Uses uuidv4() from @memberjunction/global. Subclasses may override to provide
85
+ * platform-specific ID generation if needed.
86
+ *
87
+ * @returns A new UUID string
88
+ */
89
+ GenerateNewID() {
90
+ return uuidv4();
91
+ }
92
+ /**************************************************************************/
93
+ // END ---- ID Generation & DB Function Detection
94
+ /**************************************************************************/
95
+ /**************************************************************************/
96
+ // START ---- Pure Business Logic (no SQL, no external deps)
97
+ /**************************************************************************/
98
+ /**
99
+ * Creates a changes object by comparing two JavaScript objects, identifying fields that have different values.
100
+ * Each property in the returned object represents a changed field, with the field name as the key.
101
+ *
102
+ * @param oldData - The original data object to compare from
103
+ * @param newData - The new data object to compare to
104
+ * @param entityInfo - Entity metadata used to validate fields and determine comparison logic
105
+ * @param quoteToEscape - The quote character to escape in string values (typically "'")
106
+ * @returns A Record mapping field names to FieldChange objects, or null if either input is null/undefined.
107
+ * Only includes fields that have actually changed and are not read-only.
108
+ */
109
+ DiffObjects(oldData, newData, entityInfo, quoteToEscape) {
110
+ if (!oldData || !newData)
111
+ return null;
112
+ const changes = {};
113
+ for (const key in newData) {
114
+ const f = entityInfo.Fields.find((f) => f.Name.toLowerCase() === key.toLowerCase());
115
+ if (!f)
116
+ continue; // skip if field not found in entity info
117
+ const bDiff = this.isFieldDifferent(f, oldData[key], newData[key]);
118
+ if (bDiff) {
119
+ const o = this.escapeValueForDiff(oldData[key], quoteToEscape);
120
+ const n = this.escapeValueForDiff(newData[key], quoteToEscape);
121
+ changes[key] = { field: key, oldValue: o, newValue: n };
122
+ }
123
+ }
124
+ return changes;
125
+ }
126
+ /**
127
+ * Determines whether a specific field value has changed between old and new data.
128
+ */
129
+ isFieldDifferent(f, oldVal, newVal) {
130
+ if (f.ReadOnly)
131
+ return false;
132
+ if ((oldVal == undefined || oldVal == null) && (newVal == undefined || newVal == null))
133
+ return false;
134
+ switch (f.TSType) {
135
+ case EntityFieldTSType.String:
136
+ return oldVal !== newVal;
137
+ case EntityFieldTSType.Date:
138
+ return new Date(oldVal).getTime() !== new Date(newVal).getTime();
139
+ case EntityFieldTSType.Number:
140
+ case EntityFieldTSType.Boolean:
141
+ return oldVal !== newVal;
142
+ default:
143
+ return oldVal !== newVal;
144
+ }
145
+ }
146
+ /**
147
+ * Escapes a value for use in diff output, handling strings and nested objects.
148
+ */
149
+ escapeValueForDiff(value, quoteToEscape) {
150
+ if (typeof value === 'string') {
151
+ const r = new RegExp(quoteToEscape, 'g');
152
+ return value.replace(r, quoteToEscape + quoteToEscape);
153
+ }
154
+ else if (typeof value === 'object' && value !== null) {
155
+ return this.EscapeQuotesInProperties(value, quoteToEscape);
156
+ }
157
+ return value;
158
+ }
159
+ /**
160
+ * Converts a diff/changes object into a human-readable description of what changed.
161
+ * @param changesObject The output of DiffObjects()
162
+ * @param maxValueLength Maximum length for displayed values before truncation
163
+ * @param cutOffText Text to append when values are truncated
164
+ */
165
+ CreateUserDescriptionOfChanges(changesObject, maxValueLength = 200, cutOffText = '...') {
166
+ let sRet = '';
167
+ const keys = Object.keys(changesObject);
168
+ for (let i = 0; i < keys.length; i++) {
169
+ const change = changesObject[keys[i]];
170
+ if (sRet.length > 0)
171
+ sRet += '\n';
172
+ if (change.oldValue && change.newValue)
173
+ sRet += `${change.field} changed from ${this.TrimString(change.oldValue, maxValueLength, cutOffText)} to ${this.TrimString(change.newValue, maxValueLength, cutOffText)}`;
174
+ else if (change.newValue)
175
+ sRet += `${change.field} set to ${this.TrimString(change.newValue, maxValueLength, cutOffText)}`;
176
+ else if (change.oldValue)
177
+ sRet += `${change.field} cleared from ${this.TrimString(change.oldValue, maxValueLength, cutOffText)}`;
178
+ }
179
+ return sRet.replace(/'/g, "''");
180
+ }
181
+ /**
182
+ * Truncates a string value to a maximum length, appending trailing characters if truncated.
183
+ */
184
+ TrimString(value, maxLength, trailingChars) {
185
+ if (value && typeof value === 'string' && value.length > maxLength) {
186
+ return value.substring(0, maxLength) + trailingChars;
187
+ }
188
+ return value;
189
+ }
190
+ /**
191
+ * Recursively escapes the specified quote character in all string properties of an object or array.
192
+ * Essential for preparing data to be embedded in SQL strings.
193
+ *
194
+ * @param obj - The object, array, or primitive value to process
195
+ * @param quoteToEscape - The quote character to escape (typically single quote "'")
196
+ * @returns A new object/array with all string values having quotes properly escaped
197
+ */
198
+ EscapeQuotesInProperties(obj, quoteToEscape) {
199
+ if (obj === null || obj === undefined)
200
+ return obj;
201
+ if (Array.isArray(obj)) {
202
+ return obj.map(item => this.EscapeQuotesInProperties(item, quoteToEscape));
203
+ }
204
+ if (obj instanceof Date)
205
+ return obj.toISOString();
206
+ if (typeof obj === 'object') {
207
+ const sRet = {};
208
+ for (const key in obj) {
209
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
210
+ const element = obj[key];
211
+ if (typeof element === 'string') {
212
+ const reg = new RegExp(quoteToEscape, 'g');
213
+ sRet[key] = element.replace(reg, quoteToEscape + quoteToEscape);
214
+ }
215
+ else if (typeof element === 'object') {
216
+ sRet[key] = this.EscapeQuotesInProperties(element, quoteToEscape);
217
+ }
218
+ else {
219
+ sRet[key] = element;
220
+ }
221
+ }
222
+ }
223
+ return sRet;
224
+ }
225
+ return obj;
226
+ }
227
+ /**
228
+ * Transforms a transaction result row into a list of field/value pairs.
229
+ */
230
+ MapTransactionResultToNewValues(transactionResult) {
231
+ return Object.keys(transactionResult).map((k) => ({
232
+ FieldName: k,
233
+ Value: transactionResult[k],
234
+ }));
235
+ }
236
+ /**************************************************************************/
237
+ // END ---- Pure Business Logic
238
+ /**************************************************************************/
239
+ /**************************************************************************/
240
+ // START ---- IS-A Hierarchy Support
241
+ /**************************************************************************/
242
+ /**
243
+ * Discovers which IS-A child entity, if any, has a record with the given primary key.
244
+ * Executes a single UNION ALL query across all child entity tables for maximum efficiency.
245
+ *
246
+ * @param entityInfo The parent entity whose children to search
247
+ * @param recordPKValue The primary key value to find in child tables
248
+ * @param contextUser Optional context user for audit/permission purposes
249
+ * @returns The child entity name if found, or null if no child record exists
250
+ */
251
+ async FindISAChildEntity(entityInfo, recordPKValue, contextUser) {
252
+ const childEntities = entityInfo.ChildEntities;
253
+ if (childEntities.length === 0)
254
+ return null;
255
+ const unionSQL = this.BuildChildDiscoverySQL(childEntities, recordPKValue);
256
+ if (!unionSQL)
257
+ return null;
258
+ const results = await this.ExecuteSQL(unionSQL, undefined, undefined, contextUser);
259
+ if (results && results.length > 0 && results[0].EntityName) {
260
+ return { ChildEntityName: results[0].EntityName };
261
+ }
262
+ return null;
263
+ }
264
+ /**
265
+ * Discovers ALL IS-A child entities that have records with the given primary key.
266
+ * Used for overlapping subtype parents (AllowMultipleSubtypes = true) where multiple
267
+ * children can coexist.
268
+ *
269
+ * @param entityInfo The parent entity whose children to search
270
+ * @param recordPKValue The primary key value to find in child tables
271
+ * @param contextUser Optional context user for audit/permission purposes
272
+ * @returns Array of child entity names found (empty if none)
273
+ */
274
+ async FindISAChildEntities(entityInfo, recordPKValue, contextUser) {
275
+ const childEntities = entityInfo.ChildEntities;
276
+ if (childEntities.length === 0)
277
+ return [];
278
+ const unionSQL = this.BuildChildDiscoverySQL(childEntities, recordPKValue);
279
+ if (!unionSQL)
280
+ return [];
281
+ const results = await this.ExecuteSQL(unionSQL, undefined, undefined, contextUser);
282
+ if (results && results.length > 0) {
283
+ return results
284
+ .filter((r) => r.EntityName)
285
+ .map((r) => ({ ChildEntityName: r.EntityName }));
286
+ }
287
+ return [];
288
+ }
289
+ /**
290
+ * Checks whether a given entity matches the target name, or is an ancestor
291
+ * of the target (i.e., the target is somewhere in its descendant sub-tree).
292
+ * Used to identify and skip the active branch during sibling propagation.
293
+ */
294
+ IsEntityOrAncestorOf(entityInfo, targetName) {
295
+ if (entityInfo.Name === targetName)
296
+ return true;
297
+ for (const child of entityInfo.ChildEntities) {
298
+ if (this.IsEntityOrAncestorOf(child, targetName))
299
+ return true;
300
+ }
301
+ return false;
302
+ }
303
+ /**
304
+ * Recursively enumerates an entity's entire sub-tree from metadata.
305
+ * No DB queries — uses EntityInfo.ChildEntities which is populated from metadata.
306
+ */
307
+ GetFullSubTree(entityInfo) {
308
+ const result = [entityInfo];
309
+ for (const child of entityInfo.ChildEntities) {
310
+ result.push(...this.GetFullSubTree(child));
311
+ }
312
+ return result;
313
+ }
314
+ /**************************************************************************/
315
+ // END ---- IS-A Hierarchy Support
316
+ /**************************************************************************/
317
+ /**************************************************************************/
318
+ // START ---- Record Changes
319
+ /**************************************************************************/
320
+ /**
321
+ * Retrieves the change history for a specific record.
322
+ * Uses the vwRecordChanges view which exists in both SQL Server and PostgreSQL.
323
+ *
324
+ * @param entityName The entity name
325
+ * @param compositeKey The record's composite primary key
326
+ * @param contextUser Optional context user
327
+ */
328
+ async GetRecordChanges(entityName, compositeKey, contextUser) {
329
+ try {
330
+ const safeEntityName = entityName.replace(/'/g, "''");
331
+ const safeRecordID = compositeKey.ToConcatenatedString().replace(/'/g, "''");
332
+ const schema = this.MJCoreSchemaName;
333
+ const sSQL = `SELECT * FROM ${this.QuoteSchemaAndView(schema, 'vwRecordChanges')} WHERE ${this.QuoteIdentifier('Entity')}='${safeEntityName}' AND ${this.QuoteIdentifier('RecordID')}='${safeRecordID}' ORDER BY ${this.QuoteIdentifier('ChangedAt')} DESC`;
334
+ return this.ExecuteSQL(sSQL, undefined, undefined, contextUser);
335
+ }
336
+ catch (e) {
337
+ LogError(e);
338
+ throw e;
339
+ }
340
+ }
341
+ /**************************************************************************/
342
+ // END ---- Record Changes
343
+ /**************************************************************************/
344
+ /**************************************************************************/
345
+ // START ---- Record Favorites
346
+ /**************************************************************************/
347
+ /**
348
+ * Checks if a record is marked as a favorite for a given user.
349
+ */
350
+ async GetRecordFavoriteStatus(userId, entityName, compositeKey, contextUser) {
351
+ const id = await this.GetRecordFavoriteID(userId, entityName, compositeKey, contextUser);
352
+ return id !== null;
353
+ }
354
+ /**
355
+ * Gets the favorite record ID if the record is a favorite for the given user, null otherwise.
356
+ */
357
+ async GetRecordFavoriteID(userId, entityName, compositeKey, contextUser) {
358
+ try {
359
+ const schema = this.MJCoreSchemaName;
360
+ const safeUserId = userId.replace(/'/g, "''");
361
+ const safeEntityName = entityName.replace(/'/g, "''");
362
+ const safeRecordID = compositeKey.Values().replace(/'/g, "''");
363
+ const sSQL = `SELECT ${this.QuoteIdentifier('ID')} FROM ${this.QuoteSchemaAndView(schema, 'vwUserFavorites')} WHERE ${this.QuoteIdentifier('UserID')}='${safeUserId}' AND ${this.QuoteIdentifier('Entity')}='${safeEntityName}' AND ${this.QuoteIdentifier('RecordID')}='${safeRecordID}'`;
364
+ const result = await this.ExecuteSQL(sSQL, undefined, undefined, contextUser);
365
+ if (result && result.length > 0)
366
+ return result[0].ID;
367
+ return null;
368
+ }
369
+ catch (e) {
370
+ LogError(e);
371
+ throw e;
372
+ }
373
+ }
374
+ /**
375
+ * Creates or deletes a user favorite record for the specified entity record.
376
+ * Uses GetEntityObject and BaseEntity CRUD methods (no entity-specific type imports needed).
377
+ */
378
+ async SetRecordFavoriteStatus(userId, entityName, compositeKey, isFavorite, contextUser) {
379
+ try {
380
+ const currentFavoriteId = await this.GetRecordFavoriteID(userId, entityName, compositeKey);
381
+ if ((currentFavoriteId === null && !isFavorite) || (currentFavoriteId !== null && isFavorite))
382
+ return;
383
+ const e = this.Entities.find((e) => e.Name === entityName);
384
+ const ufEntity = await this.GetEntityObject('MJ: User Favorites', contextUser || this.CurrentUser);
385
+ if (currentFavoriteId !== null) {
386
+ // delete the record since we are setting isFavorite to FALSE
387
+ await ufEntity.InnerLoad(CompositeKey.FromKeyValuePair('ID', currentFavoriteId));
388
+ if (await ufEntity.Delete())
389
+ return;
390
+ else
391
+ throw new Error(`Error deleting user favorite`);
392
+ }
393
+ else {
394
+ // create the record since we are setting isFavorite to TRUE
395
+ ufEntity.NewRecord();
396
+ ufEntity.Set('EntityID', e.ID);
397
+ ufEntity.Set('RecordID', compositeKey.Values());
398
+ ufEntity.Set('UserID', userId);
399
+ if (await ufEntity.Save())
400
+ return;
401
+ else
402
+ throw new Error(`Error saving user favorite`);
403
+ }
404
+ }
405
+ catch (e) {
406
+ LogError(e);
407
+ throw e;
408
+ }
409
+ }
410
+ /**************************************************************************/
411
+ // END ---- Record Favorites
412
+ /**************************************************************************/
413
+ /**************************************************************************/
414
+ // START ---- Record Dependencies
415
+ /**************************************************************************/
416
+ /**
417
+ * Returns a list of record-level dependencies — records in other entities linked to
418
+ * the specified entity/record via foreign keys (hard links) or EntityIDFieldName
419
+ * soft links. Uses abstract SQL builders for dialect-specific query generation.
420
+ *
421
+ * @param entityName The entity name to check
422
+ * @param compositeKey The primary key(s) of the record
423
+ * @param contextUser Optional context user
424
+ */
425
+ async GetRecordDependencies(entityName, compositeKey, contextUser) {
426
+ try {
427
+ const recordDependencies = [];
428
+ const entityDependencies = await this.GetEntityDependencies(entityName);
429
+ if (entityDependencies.length === 0)
430
+ return recordDependencies;
431
+ const hardSQL = this.BuildHardLinkDependencySQL(entityDependencies, compositeKey);
432
+ const softSQL = this.BuildSoftLinkDependencySQL(entityName, compositeKey);
433
+ const sSQL = [hardSQL, softSQL].filter(s => s.length > 0).join(' UNION ALL ');
434
+ if (!sSQL)
435
+ return recordDependencies;
436
+ const result = await this.ExecuteSQL(sSQL, undefined, undefined, contextUser);
437
+ if (!result || result.length === 0)
438
+ return recordDependencies;
439
+ return this.parseRecordDependencyResults(result);
440
+ }
441
+ catch (e) {
442
+ LogError(e);
443
+ throw e;
444
+ }
445
+ }
446
+ /**
447
+ * Parses raw SQL results from dependency queries into RecordDependency objects.
448
+ */
449
+ parseRecordDependencyResults(result) {
450
+ const recordDependencies = [];
451
+ for (const r of result) {
452
+ const entityInfo = this.Entities.find((e) => e.Name.trim().toLowerCase() === r.EntityName?.trim().toLowerCase());
453
+ if (!entityInfo) {
454
+ throw new Error(`Entity ${r.EntityName} not found in metadata`);
455
+ }
456
+ const depCompositeKey = new CompositeKey();
457
+ const pkeys = {};
458
+ const keyValues = r.PrimaryKeyValue.split(CompositeKey.DefaultFieldDelimiter);
459
+ keyValues.forEach((kv) => {
460
+ const parts = kv.split(CompositeKey.DefaultValueDelimiter);
461
+ pkeys[parts[0]] = parts[1];
462
+ });
463
+ depCompositeKey.LoadFromEntityInfoAndRecord(entityInfo, pkeys);
464
+ recordDependencies.push({
465
+ EntityName: r.EntityName,
466
+ RelatedEntityName: r.RelatedEntityName,
467
+ FieldName: r.FieldName,
468
+ PrimaryKey: depCompositeKey,
469
+ });
470
+ }
471
+ return recordDependencies;
472
+ }
473
+ /**************************************************************************/
474
+ // END ---- Record Dependencies
475
+ /**************************************************************************/
476
+ /**************************************************************************/
477
+ // START ---- Save/Delete Virtual Hooks
478
+ // Subclasses override these to inject provider-specific behavior
479
+ // (entity actions, AI actions, encryption, ISA propagation, etc.)
480
+ // Default implementations are no-ops so lightweight providers work out of the box.
481
+ /**************************************************************************/
482
+ /**
483
+ * Called during Save before any SQL is executed to run validation-type entity actions.
484
+ * Return a non-empty string to abort the save with that message; return null to proceed.
485
+ * SQL Server overrides this to delegate to HandleEntityActions('validate', ...).
486
+ */
487
+ async OnValidateBeforeSave(_entity, _user) {
488
+ return null;
489
+ }
490
+ /**
491
+ * Called before the save SQL is executed.
492
+ * SQL Server overrides this to fire before-save entity actions and AI actions.
493
+ */
494
+ async OnBeforeSaveExecute(_entity, _user, _options) {
495
+ /* no-op by default */
496
+ }
497
+ /**
498
+ * Called after a successful save (both direct and transaction-callback paths).
499
+ * Intentionally synchronous (fire-and-forget) — SQL Server overrides to dispatch
500
+ * after-save entity actions and AI actions without awaiting.
501
+ */
502
+ OnAfterSaveExecute(_entity, _user, _options) {
503
+ /* no-op by default */
504
+ }
505
+ /**
506
+ * Called before the delete SQL is executed.
507
+ * SQL Server overrides to fire before-delete entity actions and AI actions.
508
+ */
509
+ async OnBeforeDeleteExecute(_entity, _user, _options) {
510
+ /* no-op by default */
511
+ }
512
+ /**
513
+ * Called after a successful delete.
514
+ * Intentionally synchronous — see OnAfterSaveExecute.
515
+ */
516
+ OnAfterDeleteExecute(_entity, _user, _options) {
517
+ /* no-op by default */
518
+ }
519
+ /**
520
+ * Post-processes rows returned by a save/load SQL operation.
521
+ * SQL Server overrides to handle datetimeoffset conversion and field decryption.
522
+ * Default: returns rows unchanged.
523
+ */
524
+ async PostProcessRows(rows, _entityInfo, _user) {
525
+ return rows;
526
+ }
527
+ /**
528
+ * Called after a direct (non-transaction) save succeeds, before returning.
529
+ * SQL Server overrides to propagate record-change entries to IS-A sibling branches.
530
+ */
531
+ async OnSaveCompleted(_entity, _saveSQLResult, _user, _options) {
532
+ /* no-op by default */
533
+ }
534
+ /**
535
+ * Called before starting a save/delete SQL operation to pause background metadata refresh.
536
+ * SQL Server overrides to set _bAllowRefresh = false.
537
+ */
538
+ OnSuspendRefresh() { }
539
+ /**
540
+ * Called after a save/delete SQL operation completes (success or failure) to resume refresh.
541
+ */
542
+ OnResumeRefresh() { }
543
+ /**
544
+ * Returns provider-specific extra data to attach to a TransactionItem.
545
+ * SQL Server overrides to include { dataSource: this._pool }.
546
+ */
547
+ GetTransactionExtraData(_entity) {
548
+ return {};
549
+ }
550
+ /**
551
+ * Builds the ExecuteSQLOptions for a Save operation.
552
+ * SQL Server overrides to add connectionSource for IS-A shared transactions.
553
+ */
554
+ BuildSaveExecuteOptions(entity, sqlDetails) {
555
+ const opts = {
556
+ isMutation: true,
557
+ description: `Save ${entity.EntityInfo.Name}`,
558
+ };
559
+ if (entity.EntityInfo.TrackRecordChanges && sqlDetails.simpleSQL) {
560
+ opts.simpleSQLFallback = sqlDetails.simpleSQL;
561
+ }
562
+ return opts;
563
+ }
564
+ /**
565
+ * Builds the ExecuteSQLOptions for a Delete operation.
566
+ */
567
+ BuildDeleteExecuteOptions(entity, sqlDetails) {
568
+ const opts = {
569
+ isMutation: true,
570
+ description: `Delete ${entity.EntityInfo.Name}`,
571
+ };
572
+ if (entity.EntityInfo.TrackRecordChanges && sqlDetails.simpleSQL) {
573
+ opts.simpleSQLFallback = sqlDetails.simpleSQL;
574
+ }
575
+ return opts;
576
+ }
577
+ /**
578
+ * Validates the result of a delete SQL execution by checking that the returned
579
+ * primary keys match the entity being deleted.
580
+ * SQL Server overrides to handle the multi-result-set case (CASCADE deletes).
581
+ */
582
+ ValidateDeleteResult(entity, rawResult, entityResult) {
583
+ if (!rawResult || rawResult.length === 0)
584
+ return false;
585
+ const deletedRecord = rawResult[0];
586
+ for (const key of entity.PrimaryKeys) {
587
+ if (key.Value !== deletedRecord[key.Name]) {
588
+ entityResult.Message = `Delete failed: record with primary key ${key.Name}=${key.Value} not found`;
589
+ return false;
590
+ }
591
+ }
592
+ return true;
593
+ }
594
+ /**************************************************************************/
595
+ // END ---- Save/Delete Virtual Hooks
596
+ /**************************************************************************/
597
+ /**************************************************************************/
598
+ // START ---- RunView/RunQuery Shared Helpers
599
+ /**************************************************************************/
600
+ /**
601
+ * Validates a user-provided SQL clause (WHERE, ORDER BY, etc.) to prevent SQL injection.
602
+ * Checks for forbidden keywords (INSERT, UPDATE, DELETE, EXEC, DROP, UNION, CAST, etc.)
603
+ * and dangerous patterns (comments, semicolons, xp_ prefix).
604
+ * String literals are stripped before validation to avoid false positives.
605
+ *
606
+ * @param clause The SQL clause to validate
607
+ * @returns true if the clause is safe, false if it contains forbidden patterns
608
+ */
609
+ ValidateUserProvidedSQLClause(clause) {
610
+ // Remove string literals to avoid false positives
611
+ const stringLiteralPattern = /(['"])(?:(?=(\\?))\2[\s\S])*?\1/g;
612
+ const clauseWithoutStrings = clause.replace(stringLiteralPattern, '');
613
+ const lowerClause = clauseWithoutStrings.toLowerCase();
614
+ const forbiddenPatterns = [
615
+ /\binsert\b/, /\bupdate\b/, /\bdelete\b/,
616
+ /\bexec\b/, /\bexecute\b/, /\bdrop\b/,
617
+ /--/, /\/\*/, /\*\//, /\bunion\b/, /\bcast\b/, /\bxp_/, /;/,
618
+ ];
619
+ for (const pattern of forbiddenPatterns) {
620
+ if (pattern.test(lowerClause))
621
+ return false;
622
+ }
623
+ return true;
624
+ }
625
+ /**
626
+ * Checks that the given user has read permissions on the specified entity.
627
+ * Throws if the user lacks CanRead permission.
628
+ *
629
+ * @param entityName The entity to check permissions for
630
+ * @param contextUser The user whose permissions to check
631
+ * @throws Error if contextUser is null, entity is not found, or user lacks read permission
632
+ */
633
+ CheckUserReadPermissions(entityName, contextUser) {
634
+ const entityInfo = this.Entities.find((e) => e.Name === entityName);
635
+ if (!contextUser)
636
+ throw new Error('contextUser is null');
637
+ if (entityInfo) {
638
+ const userPermissions = entityInfo.GetUserPermisions(contextUser);
639
+ if (!userPermissions.CanRead)
640
+ throw new Error('User ' + contextUser.Email + ' does not have read permissions on ' + entityInfo.Name);
641
+ }
642
+ else {
643
+ throw new Error('Entity not found in metadata');
644
+ }
645
+ }
646
+ /**
647
+ * Builds and validates an aggregate SQL query from the provided aggregate expressions.
648
+ * Uses SQLExpressionValidator from @memberjunction/global for injection prevention.
649
+ * Uses QuoteIdentifier/QuoteSchemaAndView for dialect-neutral SQL generation.
650
+ *
651
+ * @param aggregates Array of aggregate expressions to validate and build
652
+ * @param entityInfo Entity metadata for field reference validation
653
+ * @param schemaName Schema name for the entity
654
+ * @param baseView Base view name for the entity
655
+ * @param whereSQL WHERE clause to apply (without the WHERE keyword)
656
+ * @returns Object with aggregateSQL string and any validation errors
657
+ */
658
+ BuildAggregateSQL(aggregates, entityInfo, schemaName, baseView, whereSQL) {
659
+ if (!aggregates || aggregates.length === 0) {
660
+ return { aggregateSQL: null, validationErrors: [] };
661
+ }
662
+ const validator = SQLExpressionValidator.Instance;
663
+ const validationErrors = [];
664
+ const validExpressions = [];
665
+ const fieldNames = entityInfo.Fields.map((f) => f.Name);
666
+ for (let i = 0; i < aggregates.length; i++) {
667
+ const agg = aggregates[i];
668
+ const alias = agg.alias || agg.expression;
669
+ const result = validator.validate(agg.expression, {
670
+ context: 'aggregate',
671
+ entityFields: fieldNames,
672
+ });
673
+ if (!result.valid) {
674
+ validationErrors.push({
675
+ expression: agg.expression,
676
+ alias: alias,
677
+ value: null,
678
+ error: result.error || 'Validation failed',
679
+ });
680
+ }
681
+ else {
682
+ validExpressions.push(agg.expression + ' AS ' + this.QuoteIdentifier('Agg_' + i));
683
+ }
684
+ }
685
+ if (validExpressions.length === 0) {
686
+ return { aggregateSQL: null, validationErrors };
687
+ }
688
+ let aggregateSQL = 'SELECT ' + validExpressions.join(', ') + ' FROM ' + this.QuoteSchemaAndView(schemaName, baseView);
689
+ if (whereSQL && whereSQL.length > 0) {
690
+ aggregateSQL += ' WHERE ' + whereSQL;
691
+ }
692
+ return { aggregateSQL, validationErrors };
693
+ }
694
+ /**
695
+ * Executes an aggregate query and maps results back to the original expressions.
696
+ *
697
+ * @param aggregateSQL The SQL query to execute (from BuildAggregateSQL)
698
+ * @param aggregates Original aggregate expression definitions
699
+ * @param validationErrors Any validation errors from BuildAggregateSQL
700
+ * @param contextUser User context for query execution
701
+ * @returns Array of AggregateResult objects with execution time
702
+ */
703
+ async ExecuteAggregateQuery(aggregateSQL, aggregates, validationErrors, contextUser) {
704
+ const startTime = Date.now();
705
+ if (!aggregateSQL) {
706
+ return { results: validationErrors, executionTime: 0 };
707
+ }
708
+ try {
709
+ const queryResult = await this.ExecuteSQL(aggregateSQL, undefined, undefined, contextUser);
710
+ const executionTime = Date.now() - startTime;
711
+ if (!queryResult || queryResult.length === 0) {
712
+ const nullResults = aggregates
713
+ .filter((_, i) => !validationErrors.some((e) => e.expression === aggregates[i].expression))
714
+ .map((agg) => ({
715
+ expression: agg.expression,
716
+ alias: agg.alias || agg.expression,
717
+ value: null,
718
+ error: undefined,
719
+ }));
720
+ return { results: [...validationErrors, ...nullResults], executionTime };
721
+ }
722
+ const row = queryResult[0];
723
+ const results = [];
724
+ let validExprIndex = 0;
725
+ for (let i = 0; i < aggregates.length; i++) {
726
+ const agg = aggregates[i];
727
+ const alias = agg.alias || agg.expression;
728
+ const validationError = validationErrors.find((e) => e.expression === agg.expression);
729
+ if (validationError) {
730
+ results.push(validationError);
731
+ }
732
+ else {
733
+ const value = row['Agg_' + validExprIndex];
734
+ results.push({
735
+ expression: agg.expression,
736
+ alias: alias,
737
+ value: (value ?? null),
738
+ error: undefined,
739
+ });
740
+ validExprIndex++;
741
+ }
742
+ }
743
+ return { results, executionTime };
744
+ }
745
+ catch (error) {
746
+ const executionTime = Date.now() - startTime;
747
+ const errorMessage = error instanceof Error ? error.message : String(error);
748
+ const errorResults = aggregates.map((agg) => ({
749
+ expression: agg.expression,
750
+ alias: agg.alias || agg.expression,
751
+ value: null,
752
+ error: errorMessage,
753
+ }));
754
+ return { results: errorResults, executionTime };
755
+ }
756
+ }
757
+ /**
758
+ * Builds the SQL to retrieve the "name" field value for a specific entity record.
759
+ * Uses QuoteIdentifier/QuoteSchemaAndView for dialect-neutral SQL generation.
760
+ *
761
+ * @param entityName The entity name
762
+ * @param compositeKey The record's primary key
763
+ * @returns The SQL query string, or null if the entity has no name field
764
+ */
765
+ BuildEntityRecordNameSQL(entityName, compositeKey) {
766
+ const e = this.Entities.find((e) => e.Name === entityName);
767
+ if (!e)
768
+ throw new Error('Entity ' + entityName + ' not found');
769
+ const f = e.NameField;
770
+ if (!f) {
771
+ LogError('Entity ' + entityName + ' does not have a NameField, returning null');
772
+ return null;
773
+ }
774
+ let where = '';
775
+ for (const pkv of compositeKey.KeyValuePairs) {
776
+ const pk = e.PrimaryKeys.find((pk) => pk.Name === pkv.FieldName);
777
+ const quotes = pk && pk.NeedsQuotes ? "'" : '';
778
+ if (where.length > 0)
779
+ where += ' AND ';
780
+ where += this.QuoteIdentifier(pkv.FieldName) + '=' + quotes + pkv.Value + quotes;
781
+ }
782
+ return 'SELECT ' + this.QuoteIdentifier(f.Name) + ' FROM ' + this.QuoteSchemaAndView(e.SchemaName, e.BaseView) + ' WHERE ' + where;
783
+ }
784
+ /**
785
+ * Retrieves the display name for a single entity record.
786
+ * Uses BuildEntityRecordNameSQL for dialect-neutral SQL generation.
787
+ */
788
+ async InternalGetEntityRecordName(entityName, compositeKey, contextUser) {
789
+ try {
790
+ const sql = this.BuildEntityRecordNameSQL(entityName, compositeKey);
791
+ if (sql) {
792
+ const data = await this.ExecuteSQL(sql, undefined, undefined, contextUser);
793
+ if (data && data.length === 1) {
794
+ const fields = Object.keys(data[0]);
795
+ return String(data[0][fields[0]] ?? '');
796
+ }
797
+ else {
798
+ LogError('Entity ' + entityName + ' record ' + compositeKey.ToString() + ' not found');
799
+ return '';
800
+ }
801
+ }
802
+ return '';
803
+ }
804
+ catch (e) {
805
+ LogError(e);
806
+ return '';
807
+ }
808
+ }
809
+ /**
810
+ * Retrieves display names for multiple entity records.
811
+ */
812
+ async InternalGetEntityRecordNames(info, contextUser) {
813
+ const results = [];
814
+ for (const item of info) {
815
+ const name = await this.InternalGetEntityRecordName(item.EntityName, item.CompositeKey, contextUser);
816
+ results.push({
817
+ EntityName: item.EntityName,
818
+ CompositeKey: item.CompositeKey,
819
+ RecordName: name,
820
+ Success: name ? true : false,
821
+ Status: name ? 'Success' : 'Error',
822
+ });
823
+ }
824
+ return results;
825
+ }
826
+ /**************************************************************************/
827
+ // END ---- RunView/RunQuery Shared Helpers
828
+ /**************************************************************************/
829
+ /**************************************************************************/
830
+ // START ---- Save/Delete Orchestration
831
+ // DB-agnostic orchestration that calls abstract SQL generation + virtual hooks.
832
+ // Both SQL Server and PostgreSQL inherit this; they only override hooks/SQL gen.
833
+ /**************************************************************************/
834
+ /**
835
+ * Saves an entity record — the full orchestration flow shared by all DB providers.
836
+ *
837
+ * 1. Permission & dirty-state checks
838
+ * 2. Validation via OnValidateBeforeSave hook
839
+ * 3. Before-save actions via OnBeforeSaveExecute hook
840
+ * 4. SQL generation via GenerateSaveSQL (abstract, provider-specific)
841
+ * 5. Execute via TransactionGroup or directly
842
+ * 6. After-save actions via OnAfterSaveExecute hook
843
+ * 7. Post-save cleanup via OnSaveCompleted hook (ISA propagation, etc.)
844
+ */
845
+ async Save(entity, user, options) {
846
+ const entityResult = new BaseEntityResult();
847
+ try {
848
+ entity.RegisterTransactionPreprocessing();
849
+ const bNewRecord = !entity.IsSaved;
850
+ if (!options)
851
+ options = new EntitySaveOptions();
852
+ const bReplay = !!options.ReplayOnly;
853
+ if (!bReplay && !bNewRecord && !entity.EntityInfo.AllowUpdateAPI)
854
+ throw new Error(`UPDATE not allowed for entity ${entity.EntityInfo.Name}`);
855
+ if (!bReplay && bNewRecord && !entity.EntityInfo.AllowCreateAPI)
856
+ throw new Error(`CREATE not allowed for entity ${entity.EntityInfo.Name}`);
857
+ if (entity.Dirty || options.IgnoreDirtyState || options.ReplayOnly) {
858
+ entityResult.StartedAt = new Date();
859
+ entityResult.Type = bNewRecord ? 'create' : 'update';
860
+ entityResult.OriginalValues = entity.Fields.map((f) => {
861
+ const tempStatus = f.ActiveStatusAssertions;
862
+ f.ActiveStatusAssertions = false;
863
+ const ret = { FieldName: f.Name, Value: f.Value };
864
+ f.ActiveStatusAssertions = tempStatus;
865
+ return ret;
866
+ });
867
+ entity.ResultHistory.push(entityResult);
868
+ // Step 2: Validation hook
869
+ if (!bReplay) {
870
+ const validationMessage = await this.OnValidateBeforeSave(entity, user);
871
+ if (validationMessage) {
872
+ entityResult.Success = false;
873
+ entityResult.EndedAt = new Date();
874
+ entityResult.Message = validationMessage;
875
+ return false;
876
+ }
877
+ }
878
+ // Step 3: Before-save hook (entity actions, AI actions)
879
+ if (!bReplay) {
880
+ await this.OnBeforeSaveExecute(entity, user, options);
881
+ }
882
+ // Step 4: Generate provider-specific SQL
883
+ const sqlDetails = await this.GenerateSaveSQL(entity, bNewRecord, user);
884
+ if (entity.TransactionGroup && !bReplay) {
885
+ // ---- Transaction Group path ----
886
+ entity.RaiseReadyForTransaction();
887
+ this.OnSuspendRefresh();
888
+ const extraData = this.GetTransactionExtraData(entity);
889
+ if (entity.EntityInfo.TrackRecordChanges && sqlDetails.simpleSQL) {
890
+ extraData.simpleSQLFallback = sqlDetails.simpleSQL;
891
+ }
892
+ extraData.entityName = entity.EntityInfo.Name;
893
+ entity.TransactionGroup.AddTransaction(new TransactionItem(entity, entityResult.Type === 'create' ? 'Create' : 'Update', sqlDetails.fullSQL, sqlDetails.parameters ?? null, extraData, (transactionResult, success) => {
894
+ this.OnResumeRefresh();
895
+ entityResult.EndedAt = new Date();
896
+ if (success && transactionResult) {
897
+ this.OnAfterSaveExecute(entity, user, options);
898
+ entityResult.Success = true;
899
+ entityResult.NewValues = this.MapTransactionResultToNewValues(transactionResult);
900
+ }
901
+ else {
902
+ entityResult.Success = false;
903
+ entityResult.Message = 'Transaction Failed';
904
+ }
905
+ }));
906
+ return true;
907
+ }
908
+ else {
909
+ // ---- Direct execution path ----
910
+ this.OnSuspendRefresh();
911
+ let result;
912
+ if (bReplay) {
913
+ result = [entity.GetAll()];
914
+ }
915
+ else {
916
+ const execOptions = this.BuildSaveExecuteOptions(entity, sqlDetails);
917
+ const rawResult = await this.ExecuteSQL(sqlDetails.fullSQL, sqlDetails.parameters ?? undefined, execOptions, user);
918
+ result = await this.PostProcessRows(rawResult, entity.EntityInfo, user);
919
+ }
920
+ this.OnResumeRefresh();
921
+ entityResult.EndedAt = new Date();
922
+ if (result && result.length > 0) {
923
+ this.OnAfterSaveExecute(entity, user, options);
924
+ entityResult.Success = true;
925
+ await this.OnSaveCompleted(entity, sqlDetails, user, options);
926
+ return result[0];
927
+ }
928
+ else {
929
+ if (bNewRecord)
930
+ throw new Error(`SQL Error: Error creating new record, no rows returned from SQL: ${sqlDetails.fullSQL}`);
931
+ else
932
+ throw new Error(`SQL Error: Error updating record, no MATCHING rows found within the database: ${sqlDetails.fullSQL}`);
933
+ }
934
+ }
935
+ }
936
+ else {
937
+ return entity; // nothing to save
938
+ }
939
+ }
940
+ catch (e) {
941
+ this.OnResumeRefresh();
942
+ entityResult.EndedAt = new Date();
943
+ entityResult.Message = e.message;
944
+ LogError(e);
945
+ throw e;
946
+ }
947
+ }
948
+ /**
949
+ * Deletes an entity record — the full orchestration flow shared by all DB providers.
950
+ *
951
+ * 1. Permission checks & replay handling
952
+ * 2. SQL generation via GenerateDeleteSQL (abstract, provider-specific)
953
+ * 3. Before-delete actions via OnBeforeDeleteExecute hook
954
+ * 4. Execute via TransactionGroup or directly
955
+ * 5. Validate delete result (PK match check)
956
+ * 6. After-delete actions via OnAfterDeleteExecute hook
957
+ */
958
+ async Delete(entity, options, user) {
959
+ const entityResult = new BaseEntityResult();
960
+ try {
961
+ entity.RegisterTransactionPreprocessing();
962
+ if (!options)
963
+ options = new EntityDeleteOptions();
964
+ const bReplay = options.ReplayOnly;
965
+ if (!entity.IsSaved && !bReplay)
966
+ throw new Error(`Delete() isn't callable for records that haven't yet been saved - ${entity.EntityInfo.Name}`);
967
+ if (!entity.EntityInfo.AllowDeleteAPI && !bReplay)
968
+ throw new Error(`Delete() isn't callable for ${entity.EntityInfo.Name} as AllowDeleteAPI is false`);
969
+ entityResult.StartedAt = new Date();
970
+ entityResult.Type = 'delete';
971
+ entityResult.OriginalValues = entity.Fields.map((f) => ({
972
+ FieldName: f.Name,
973
+ Value: f.Value,
974
+ }));
975
+ entity.ResultHistory.push(entityResult);
976
+ // Generate provider-specific delete SQL
977
+ const sqlDetails = this.GenerateDeleteSQL(entity, user);
978
+ // Before-delete hooks
979
+ await this.OnBeforeDeleteExecute(entity, user, options);
980
+ if (entity.TransactionGroup && !bReplay) {
981
+ // ---- Transaction Group path ----
982
+ entity.RaiseReadyForTransaction();
983
+ const extraData = this.GetTransactionExtraData(entity);
984
+ if (entity.EntityInfo.TrackRecordChanges && sqlDetails.simpleSQL) {
985
+ extraData.simpleSQLFallback = sqlDetails.simpleSQL;
986
+ }
987
+ extraData.entityName = entity.EntityInfo.Name;
988
+ entity.TransactionGroup.AddTransaction(new TransactionItem(entity, 'Delete', sqlDetails.fullSQL, sqlDetails.parameters ?? null, extraData, (transactionResult, success) => {
989
+ entityResult.EndedAt = new Date();
990
+ if (success && transactionResult) {
991
+ this.OnAfterDeleteExecute(entity, user, options);
992
+ for (const key of entity.PrimaryKeys) {
993
+ if (key.Value !== transactionResult[key.Name]) {
994
+ entityResult.Success = false;
995
+ entityResult.Message = 'Transaction failed to commit';
996
+ }
997
+ }
998
+ entityResult.NewValues = this.MapTransactionResultToNewValues(transactionResult);
999
+ entityResult.Success = true;
1000
+ }
1001
+ else {
1002
+ entityResult.Success = false;
1003
+ entityResult.Message = 'Transaction failed to commit';
1004
+ }
1005
+ }));
1006
+ return true;
1007
+ }
1008
+ else {
1009
+ // ---- Direct execution path ----
1010
+ let d;
1011
+ if (bReplay) {
1012
+ d = [entity.GetAll()];
1013
+ }
1014
+ else {
1015
+ const execOptions = this.BuildDeleteExecuteOptions(entity, sqlDetails);
1016
+ d = await this.ExecuteSQL(sqlDetails.fullSQL, sqlDetails.parameters ?? undefined, execOptions, user);
1017
+ }
1018
+ if (d && d.length > 0) {
1019
+ if (!this.ValidateDeleteResult(entity, d, entityResult)) {
1020
+ entityResult.EndedAt = new Date();
1021
+ entityResult.Success = false;
1022
+ return false;
1023
+ }
1024
+ this.OnAfterDeleteExecute(entity, user, options);
1025
+ entityResult.EndedAt = new Date();
1026
+ return true;
1027
+ }
1028
+ else {
1029
+ entityResult.Message = 'No result returned from SQL';
1030
+ entityResult.EndedAt = new Date();
1031
+ return false;
1032
+ }
1033
+ }
1034
+ }
1035
+ catch (e) {
1036
+ LogError(e);
1037
+ entityResult.Message = e.message;
1038
+ entityResult.Success = false;
1039
+ entityResult.EndedAt = new Date();
1040
+ return false;
1041
+ }
1042
+ }
1043
+ /**************************************************************************/
1044
+ // END ---- Save/Delete Orchestration
1045
+ /**************************************************************************/
1046
+ /**************************************************************************/
1047
+ // START ---- Audit Logging
1048
+ /**************************************************************************/
1049
+ /**
1050
+ * Creates an audit log record in the MJ: Audit Logs entity.
1051
+ * Uses BaseEntity with .Set() calls (no typed entity subclass imports needed - can't use those from MJCore anyway).
1052
+ * Callers typically fire-and-forget.
1053
+ *
1054
+ * @param user The user performing the action
1055
+ * @param authorizationName Optional authorization name to look up
1056
+ * @param auditLogTypeName The audit log type name (must exist in metadata)
1057
+ * @param status 'Success' or 'Failed'
1058
+ * @param details Optional details (JSON string, description, etc.)
1059
+ * @param entityId The entity ID being audited
1060
+ * @param recordId Optional record ID being audited
1061
+ * @param auditLogDescription Optional description for the audit log
1062
+ * @param saveOptions Save options to pass to the entity Save() call
1063
+ * @returns The saved audit log BaseEntity, or null on error
1064
+ */
1065
+ async CreateAuditLogRecord(user, authorizationName, auditLogTypeName, status, details, entityId, recordId, auditLogDescription, saveOptions) {
1066
+ try {
1067
+ const authorization = authorizationName
1068
+ ? this.Authorizations.find((a) => a?.Name?.trim().toLowerCase() === authorizationName.trim().toLowerCase())
1069
+ : null;
1070
+ const auditLogType = auditLogTypeName
1071
+ ? this.AuditLogTypes.find((a) => a?.Name?.trim().toLowerCase() === auditLogTypeName.trim().toLowerCase())
1072
+ : null;
1073
+ if (!user)
1074
+ throw new Error('User is a required parameter');
1075
+ if (!auditLogType)
1076
+ throw new Error(`Audit Log Type ${auditLogTypeName} not found in metadata`);
1077
+ const auditLog = await this.GetEntityObject('MJ: Audit Logs', user);
1078
+ auditLog.NewRecord();
1079
+ auditLog.Set('UserID', user.ID);
1080
+ auditLog.Set('AuditLogTypeID', auditLogType.ID);
1081
+ auditLog.Set('Status', status?.trim().toLowerCase() === 'success' ? 'Success' : 'Failed');
1082
+ auditLog.Set('EntityID', entityId);
1083
+ if (recordId != null)
1084
+ auditLog.Set('RecordID', recordId);
1085
+ if (authorization)
1086
+ auditLog.Set('AuthorizationID', authorization.ID);
1087
+ if (details)
1088
+ auditLog.Set('Details', details);
1089
+ if (auditLogDescription)
1090
+ auditLog.Set('Description', auditLogDescription);
1091
+ if (await auditLog.Save(saveOptions ?? undefined)) {
1092
+ return auditLog;
1093
+ }
1094
+ else {
1095
+ throw new Error('Error saving audit log record');
1096
+ }
1097
+ }
1098
+ catch (err) {
1099
+ LogError(err);
1100
+ return null;
1101
+ }
1102
+ }
1103
+ /**************************************************************************/
1104
+ // END ---- Audit Logging
1105
+ /**************************************************************************/
1106
+ /**************************************************************************/
1107
+ // START ---- Entity Actions & AI Actions (Virtual Hooks)
1108
+ // Subclasses that have access to @memberjunction/actions and
1109
+ // @memberjunction/aiengine override these. Lightweight providers
1110
+ // (PostgreSQL during initial development) inherit the no-ops.
1111
+ /**************************************************************************/
1112
+ /**
1113
+ * Handles entity actions (non-AI) for save, delete, or validate operations.
1114
+ * Override in subclasses that have access to EntityActionEngineServer.
1115
+ * Default: no-op, returns empty array.
1116
+ *
1117
+ * @param entity The entity being saved/deleted/validated
1118
+ * @param baseType The operation type
1119
+ * @param before True for before-hooks, false for after-hooks
1120
+ * @param user The acting user
1121
+ * @returns Array of action results (empty by default)
1122
+ */
1123
+ async HandleEntityActions(_entity, _baseType, _before, _user) {
1124
+ return [];
1125
+ }
1126
+ /**
1127
+ * Handles AI-specific entity actions for save or delete operations.
1128
+ * Override in subclasses that have access to AIEngine.
1129
+ * Default: no-op.
1130
+ */
1131
+ async HandleEntityAIActions(_entity, _baseType, _before, _user) {
1132
+ /* no-op by default */
1133
+ }
1134
+ /**
1135
+ * Returns AI actions configured for the given entity and timing.
1136
+ * Override in subclasses that have access to AIEngine.
1137
+ * Default: returns empty array.
1138
+ */
1139
+ GetEntityAIActions(_entityInfo, _before) {
1140
+ return [];
1141
+ }
1142
+ /**************************************************************************/
1143
+ // END ---- Entity Actions & AI Actions (Virtual Hooks)
1144
+ /**************************************************************************/
1145
+ /**************************************************************************/
1146
+ // START ---- CRUD SP/Function Name Resolution
1147
+ /**************************************************************************/
1148
+ /**
1149
+ * Returns the stored procedure / function name for a Create or Update operation.
1150
+ * Pure metadata lookup — no SQL execution needed.
1151
+ * SQL Server uses spCreate/spUpdate naming, PostgreSQL uses the same pattern.
1152
+ *
1153
+ * @param entity The entity being saved
1154
+ * @param bNewRecord True for Create, false for Update
1155
+ * @returns The SP/function name
1156
+ */
1157
+ GetCreateUpdateSPName(entity, bNewRecord) {
1158
+ const spName = bNewRecord
1159
+ ? entity.EntityInfo.spCreate?.length > 0
1160
+ ? entity.EntityInfo.spCreate
1161
+ : 'spCreate' + entity.EntityInfo.BaseTableCodeName
1162
+ : entity.EntityInfo.spUpdate?.length > 0
1163
+ ? entity.EntityInfo.spUpdate
1164
+ : 'spUpdate' + entity.EntityInfo.BaseTableCodeName;
1165
+ return spName;
1166
+ }
1167
+ /**************************************************************************/
1168
+ // END ---- CRUD SP/Function Name Resolution
1169
+ /**************************************************************************/
1170
+ /**************************************************************************/
1171
+ // START ---- Record Change Logging
1172
+ /**************************************************************************/
1173
+ /**
1174
+ * Logs a record change entry by diffing old/new data and executing
1175
+ * provider-specific SQL to insert the record change.
1176
+ * Concrete orchestration; SQL generation is delegated to BuildRecordChangeSQL.
1177
+ *
1178
+ * @param newData The new record data (null for deletes)
1179
+ * @param oldData The old record data (null for creates)
1180
+ * @param entityName The entity name
1181
+ * @param recordID The record ID (CompositeKey string)
1182
+ * @param entityInfo The entity metadata
1183
+ * @param type The change type
1184
+ * @param user The acting user
1185
+ */
1186
+ async LogRecordChange(newData, oldData, entityName, recordID, entityInfo, type, user) {
1187
+ const sqlResult = this.BuildRecordChangeSQL(newData, oldData, entityName, recordID, entityInfo, type, user);
1188
+ if (sqlResult) {
1189
+ return await this.ExecuteSQL(sqlResult.sql, sqlResult.parameters ?? undefined, undefined, user);
1190
+ }
1191
+ return undefined;
1192
+ }
1193
+ /**
1194
+ * Propagates record change entries to sibling branches of an IS-A hierarchy.
1195
+ * Called after saving an entity with AllowMultipleSubtypes (overlapping subtypes).
1196
+ * Collects SQL from BuildSiblingRecordChangeSQL for each sibling and executes as a batch.
1197
+ *
1198
+ * @param parentInfo The parent entity info
1199
+ * @param changeData The changes JSON and description
1200
+ * @param pkValue The primary key value
1201
+ * @param userId The acting user ID
1202
+ * @param activeChildEntityName The child entity that initiated the save (to skip)
1203
+ * @param extraExecOptions Optional provider-specific execution options (e.g. connectionSource for SQL Server transactions)
1204
+ */
1205
+ async PropagateRecordChangesToSiblings(parentInfo, changeData, pkValue, userId, activeChildEntityName, extraExecOptions) {
1206
+ const sqlParts = [];
1207
+ const safePKValue = pkValue.replace(/'/g, "''");
1208
+ const safeUserId = userId.replace(/'/g, "''");
1209
+ const safeChangesJSON = changeData.changesJSON.replace(/'/g, "''");
1210
+ const safeChangesDesc = changeData.changesDescription.replace(/'/g, "''");
1211
+ let varIndex = 0;
1212
+ for (const childInfo of parentInfo.ChildEntities) {
1213
+ // Skip the active branch (the child that initiated the parent save).
1214
+ // When activeChildEntityName is undefined (direct save on parent), propagate to ALL children.
1215
+ if (activeChildEntityName && this.IsEntityOrAncestorOf(childInfo, activeChildEntityName))
1216
+ continue;
1217
+ // Recursively enumerate this child's entire sub-tree from metadata
1218
+ const subTree = this.GetFullSubTree(childInfo);
1219
+ for (const entityInTree of subTree) {
1220
+ if (!entityInTree.TrackRecordChanges)
1221
+ continue;
1222
+ const varName = `@_rc_prop_${varIndex++}`;
1223
+ sqlParts.push(this.BuildSiblingRecordChangeSQL(varName, entityInTree, safeChangesJSON, safeChangesDesc, safePKValue, safeUserId));
1224
+ }
1225
+ }
1226
+ // Execute as single batch
1227
+ if (sqlParts.length > 0) {
1228
+ const batch = sqlParts.join('\n');
1229
+ const execOptions = {
1230
+ description: 'IS-A overlapping subtype Record Change propagation',
1231
+ isMutation: true,
1232
+ ...(extraExecOptions ?? {}),
1233
+ };
1234
+ await this.ExecuteSQL(batch, undefined, execOptions);
1235
+ }
1236
+ }
1237
+ /**************************************************************************/
1238
+ // END ---- Record Change Logging
1239
+ /**************************************************************************/
1240
+ /**************************************************************************/
1241
+ // START ---- Record Duplicates & Merge
1242
+ /**************************************************************************/
1243
+ /**
1244
+ * Initiates duplicate detection for a list of records.
1245
+ * Uses BaseEntity to create a Duplicate Run record.
1246
+ * Subclasses may override to provide additional functionality.
1247
+ *
1248
+ * @param params The duplicate detection request parameters
1249
+ * @param contextUser The acting user
1250
+ * @returns A response indicating the duplicate detection status
1251
+ */
1252
+ async GetRecordDuplicates(params, contextUser) {
1253
+ if (!contextUser) {
1254
+ throw new Error('User context is required to get record duplicates.');
1255
+ }
1256
+ const listEntity = await this.GetEntityObject('MJ: Lists', contextUser);
1257
+ await listEntity.InnerLoad(CompositeKey.FromKeyValuePair('ID', params.ListID));
1258
+ const duplicateRun = await this.GetEntityObject('MJ: Duplicate Runs', contextUser);
1259
+ duplicateRun.NewRecord();
1260
+ duplicateRun.Set('EntityID', params.EntityID);
1261
+ duplicateRun.Set('StartedByUserID', contextUser.ID);
1262
+ duplicateRun.Set('StartedAt', new Date());
1263
+ duplicateRun.Set('ProcessingStatus', 'In Progress');
1264
+ duplicateRun.Set('ApprovalStatus', 'Pending');
1265
+ duplicateRun.Set('SourceListID', listEntity.Get('ID'));
1266
+ const saveResult = await duplicateRun.Save();
1267
+ if (!saveResult) {
1268
+ throw new Error('Failed to save Duplicate Run Entity');
1269
+ }
1270
+ const response = {
1271
+ Status: 'Inprogress',
1272
+ PotentialDuplicateResult: [],
1273
+ };
1274
+ return response;
1275
+ }
1276
+ /**
1277
+ * Merges multiple records into a single surviving record.
1278
+ * Full orchestration: transaction, field map update, dependency re-pointing,
1279
+ * deletion, and merge logging.
1280
+ *
1281
+ * @param request The merge request with surviving record and records to merge
1282
+ * @param contextUser The acting user
1283
+ * @param _options Optional merge options
1284
+ * @returns The merge result
1285
+ */
1286
+ async MergeRecords(request, contextUser, _options) {
1287
+ const e = this.Entities.find((e) => e.Name.trim().toLowerCase() === request.EntityName.trim().toLowerCase());
1288
+ if (!e || !e.AllowRecordMerge)
1289
+ throw new Error(`Entity ${request.EntityName} does not allow record merging, check the AllowRecordMerge property in the entity metadata`);
1290
+ const result = {
1291
+ Success: false,
1292
+ RecordMergeLogID: null,
1293
+ RecordStatus: [],
1294
+ Request: request,
1295
+ OverallStatus: null,
1296
+ };
1297
+ const mergeRecordLog = await this.StartMergeLogging(request, result, contextUser);
1298
+ try {
1299
+ // Step 1 - begin transaction
1300
+ await this.BeginTransaction();
1301
+ // Step 2 - update the surviving record if field map provided
1302
+ if (request.FieldMap && request.FieldMap.length > 0) {
1303
+ const survivor = await this.GetEntityObject(request.EntityName, contextUser);
1304
+ await survivor.InnerLoad(request.SurvivingRecordCompositeKey);
1305
+ for (const fieldMap of request.FieldMap) {
1306
+ survivor.Set(fieldMap.FieldName, fieldMap.Value);
1307
+ }
1308
+ if (!(await survivor.Save())) {
1309
+ result.OverallStatus = 'Error saving survivor record with values from provided field map.';
1310
+ throw new Error(result.OverallStatus);
1311
+ }
1312
+ }
1313
+ // Step 3 - update dependencies and delete each merged record
1314
+ for (const pksToDelete of request.RecordsToMerge) {
1315
+ const newRecStatus = {
1316
+ CompositeKey: pksToDelete,
1317
+ Success: false,
1318
+ RecordMergeDeletionLogID: null,
1319
+ Message: null,
1320
+ };
1321
+ result.RecordStatus.push(newRecStatus);
1322
+ const dependencies = await this.GetRecordDependencies(request.EntityName, pksToDelete);
1323
+ for (const dependency of dependencies) {
1324
+ const relatedEntity = await this.GetEntityObject(dependency.RelatedEntityName, contextUser);
1325
+ await relatedEntity.InnerLoad(dependency.PrimaryKey);
1326
+ relatedEntity.Set(dependency.FieldName, request.SurvivingRecordCompositeKey.GetValueByIndex(0));
1327
+ if (!(await relatedEntity.Save())) {
1328
+ newRecStatus.Success = false;
1329
+ newRecStatus.Message = `Error updating dependency record ${dependency.PrimaryKey.ToString()} for entity ${dependency.RelatedEntityName} to point to surviving record ${request.SurvivingRecordCompositeKey.ToString()}`;
1330
+ throw new Error(newRecStatus.Message);
1331
+ }
1332
+ }
1333
+ const recordToDelete = await this.GetEntityObject(request.EntityName, contextUser);
1334
+ await recordToDelete.InnerLoad(pksToDelete);
1335
+ if (!(await recordToDelete.Delete())) {
1336
+ newRecStatus.Message = `Error deleting record ${pksToDelete.ToString()} for entity ${request.EntityName}`;
1337
+ throw new Error(newRecStatus.Message);
1338
+ }
1339
+ else {
1340
+ newRecStatus.Success = true;
1341
+ }
1342
+ }
1343
+ result.Success = true;
1344
+ await this.CompleteMergeLogging(mergeRecordLog, result, contextUser);
1345
+ // Step 5 - commit transaction
1346
+ await this.CommitTransaction();
1347
+ result.Success = true;
1348
+ return result;
1349
+ }
1350
+ catch (err) {
1351
+ LogError(err);
1352
+ await this.RollbackTransaction();
1353
+ await this.CompleteMergeLogging(mergeRecordLog, result, contextUser);
1354
+ throw err;
1355
+ }
1356
+ }
1357
+ /**
1358
+ * Creates the initial merge log record at the start of a merge operation.
1359
+ * Uses BaseEntity with .Set() calls (no typed entity subclass imports available in MJCore).
1360
+ */
1361
+ async StartMergeLogging(request, result, contextUser) {
1362
+ try {
1363
+ const recordMergeLog = await this.GetEntityObject('MJ: Record Merge Logs', contextUser);
1364
+ const entity = this.Entities.find((e) => e.Name === request.EntityName);
1365
+ if (!entity)
1366
+ throw new Error(`Entity ${request.EntityName} not found in metadata`);
1367
+ if (!contextUser && !this.CurrentUser)
1368
+ throw new Error('contextUser is null and no CurrentUser is set');
1369
+ recordMergeLog.NewRecord();
1370
+ recordMergeLog.Set('EntityID', entity.ID);
1371
+ recordMergeLog.Set('SurvivingRecordID', request.SurvivingRecordCompositeKey.Values());
1372
+ recordMergeLog.Set('InitiatedByUserID', contextUser ? contextUser.ID : this.CurrentUser?.ID);
1373
+ recordMergeLog.Set('ApprovalStatus', 'Approved');
1374
+ recordMergeLog.Set('ApprovedByUserID', contextUser ? contextUser.ID : this.CurrentUser?.ID);
1375
+ recordMergeLog.Set('ProcessingStatus', 'Started');
1376
+ recordMergeLog.Set('ProcessingStartedAt', new Date());
1377
+ if (await recordMergeLog.Save()) {
1378
+ result.RecordMergeLogID = recordMergeLog.Get('ID');
1379
+ return recordMergeLog;
1380
+ }
1381
+ else {
1382
+ throw new Error('Error saving record merge log');
1383
+ }
1384
+ }
1385
+ catch (err) {
1386
+ LogError(err);
1387
+ throw err;
1388
+ }
1389
+ }
1390
+ /**
1391
+ * Finalizes merge logging by updating the log record with completion status
1392
+ * and creating deletion detail records.
1393
+ * Uses BaseEntity with .Set() calls (no typed entity subclass imports).
1394
+ */
1395
+ async CompleteMergeLogging(recordMergeLog, result, contextUser) {
1396
+ try {
1397
+ if (!contextUser && !this.CurrentUser)
1398
+ throw new Error('contextUser is null and no CurrentUser is set');
1399
+ recordMergeLog.Set('ProcessingStatus', result.Success ? 'Complete' : 'Error');
1400
+ recordMergeLog.Set('ProcessingEndedAt', new Date());
1401
+ if (!result.Success) {
1402
+ recordMergeLog.Set('ProcessingLog', result.OverallStatus);
1403
+ }
1404
+ if (await recordMergeLog.Save()) {
1405
+ for (const d of result.RecordStatus) {
1406
+ const deletionLog = await this.GetEntityObject('MJ: Record Merge Deletion Logs', contextUser);
1407
+ deletionLog.NewRecord();
1408
+ deletionLog.Set('RecordMergeLogID', recordMergeLog.Get('ID'));
1409
+ deletionLog.Set('DeletedRecordID', d.CompositeKey.Values());
1410
+ deletionLog.Set('Status', d.Success ? 'Complete' : 'Error');
1411
+ if (!d.Success)
1412
+ deletionLog.Set('ProcessingLog', d.Message);
1413
+ if (!(await deletionLog.Save()))
1414
+ throw new Error('Error saving record merge deletion log');
1415
+ }
1416
+ }
1417
+ else {
1418
+ throw new Error('Error saving record merge log');
1419
+ }
1420
+ }
1421
+ catch (err) {
1422
+ // do nothing here because we often will get here since some conditions lead to no DB updates possible
1423
+ LogError(err);
1424
+ }
1425
+ }
1426
+ /**************************************************************************/
1427
+ // END ---- Record Duplicates & Merge
1428
+ /**************************************************************************/
1429
+ /**************************************************************************/
1430
+ // START ---- RunReport
1431
+ /**************************************************************************/
1432
+ /**
1433
+ * Runs a report by looking up its SQL definition from vwReports and executing it.
1434
+ * Both SQL Server and PostgreSQL share this logic — the only dialect difference
1435
+ * is identifier quoting, handled by QuoteIdentifier/QuoteSchemaAndView.
1436
+ *
1437
+ * @param params Report parameters including ReportID
1438
+ * @param contextUser Optional context user for permission/audit purposes
1439
+ * @deprecated Reports are no longer supported and will eventually be removed. Interactive Components and Artifacts are replacements
1440
+ */
1441
+ async RunReport(params, contextUser) {
1442
+ const reportID = params.ReportID;
1443
+ const safeReportID = reportID.replace(/'/g, "''");
1444
+ const sqlReport = `SELECT ${this.QuoteIdentifier('ReportSQL')} FROM ${this.QuoteSchemaAndView(this.MJCoreSchemaName, 'vwReports')} WHERE ${this.QuoteIdentifier('ID')} = '${safeReportID}'`;
1445
+ const reportInfo = await this.ExecuteSQL(sqlReport, undefined, undefined, contextUser);
1446
+ if (reportInfo && reportInfo.length > 0) {
1447
+ const start = Date.now();
1448
+ const sql = String(reportInfo[0].ReportSQL);
1449
+ const result = await this.ExecuteSQL(sql, undefined, undefined, contextUser);
1450
+ const end = Date.now();
1451
+ if (result)
1452
+ return {
1453
+ Success: true,
1454
+ ReportID: reportID,
1455
+ Results: result,
1456
+ RowCount: result.length,
1457
+ ExecutionTime: end - start,
1458
+ ErrorMessage: '',
1459
+ };
1460
+ else
1461
+ return {
1462
+ Success: false,
1463
+ ReportID: reportID,
1464
+ Results: [],
1465
+ RowCount: 0,
1466
+ ExecutionTime: end - start,
1467
+ ErrorMessage: 'Error running report SQL',
1468
+ };
1469
+ }
1470
+ return { Success: false, ReportID: reportID, Results: [], RowCount: 0, ExecutionTime: 0, ErrorMessage: 'Report not found' };
1471
+ }
9
1472
  }
10
1473
  //# sourceMappingURL=databaseProviderBase.js.map