@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.
- package/dist/generic/applicationInfo.d.ts.map +1 -1
- package/dist/generic/applicationInfo.js +2 -1
- package/dist/generic/applicationInfo.js.map +1 -1
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +0 -13
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEntity.js +2 -2
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/compositeKey.d.ts.map +1 -1
- package/dist/generic/compositeKey.js +12 -1
- package/dist/generic/compositeKey.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts +653 -1
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +1463 -0
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +2 -1
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +15 -10
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/interfaces.d.ts +5 -1
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js +2 -0
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +4 -2
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/metadata.js +5 -5
- package/dist/generic/metadata.js.map +1 -1
- package/dist/generic/platformSQL.d.ts +25 -0
- package/dist/generic/platformSQL.d.ts.map +1 -0
- package/dist/generic/platformSQL.js +7 -0
- package/dist/generic/platformSQL.js.map +1 -0
- package/dist/generic/platformVariants.d.ts +68 -0
- package/dist/generic/platformVariants.d.ts.map +1 -0
- package/dist/generic/platformVariants.js +34 -0
- package/dist/generic/platformVariants.js.map +1 -0
- package/dist/generic/providerBase.d.ts +34 -1
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +76 -10
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/queryInfo.d.ts +97 -0
- package/dist/generic/queryInfo.d.ts.map +1 -1
- package/dist/generic/queryInfo.js +154 -13
- package/dist/generic/queryInfo.js.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.d.ts +22 -2
- package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.js +74 -3
- package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
- package/dist/generic/securityInfo.d.ts +18 -0
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +30 -3
- package/dist/generic/securityInfo.js.map +1 -1
- package/dist/generic/util.d.ts.map +1 -1
- package/dist/generic/util.js +42 -3
- package/dist/generic/util.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +16 -2
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js +21 -4
- package/dist/views/runView.js.map +1 -1
- package/dist/views/viewInfo.d.ts.map +1 -1
- package/dist/views/viewInfo.js +2 -1
- package/dist/views/viewInfo.js.map +1 -1
- 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
|