@memberjunction/core 6.2.0-edge.3 → 6.2.0-edge.5
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/BaseEntitySaveQueue.d.ts +7 -0
- package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
- package/dist/generic/BaseEntitySaveQueue.js +22 -4
- package/dist/generic/BaseEntitySaveQueue.js.map +1 -1
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +33 -0
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
- package/dist/generic/RegisterForStartup.d.ts +44 -0
- package/dist/generic/RegisterForStartup.d.ts.map +1 -1
- package/dist/generic/RegisterForStartup.js +124 -48
- package/dist/generic/RegisterForStartup.js.map +1 -1
- package/dist/generic/baseEngine.d.ts +222 -7
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +555 -37
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEngineSweeper.d.ts +35 -0
- package/dist/generic/baseEngineSweeper.d.ts.map +1 -0
- package/dist/generic/baseEngineSweeper.js +97 -0
- package/dist/generic/baseEngineSweeper.js.map +1 -0
- package/dist/generic/baseEntity.d.ts +45 -1
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +308 -187
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/baseInfo.d.ts +31 -3
- package/dist/generic/baseInfo.d.ts.map +1 -1
- package/dist/generic/baseInfo.js +138 -40
- package/dist/generic/baseInfo.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts +57 -19
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +203 -80
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/entityConfiguration.d.ts +8 -2
- package/dist/generic/entityConfiguration.d.ts.map +1 -1
- package/dist/generic/entityConfiguration.js +9 -0
- package/dist/generic/entityConfiguration.js.map +1 -1
- package/dist/generic/entityEventBatch.d.ts +124 -0
- package/dist/generic/entityEventBatch.d.ts.map +1 -0
- package/dist/generic/entityEventBatch.js +194 -0
- package/dist/generic/entityEventBatch.js.map +1 -0
- package/dist/generic/entityInfo.d.ts +103 -8
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +376 -60
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/infoProviderResolution.d.ts +16 -0
- package/dist/generic/infoProviderResolution.d.ts.map +1 -0
- package/dist/generic/infoProviderResolution.js +17 -0
- package/dist/generic/infoProviderResolution.js.map +1 -0
- package/dist/generic/interfaces.d.ts +143 -3
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +505 -56
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +1185 -175
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/logging.d.ts +15 -0
- package/dist/generic/logging.d.ts.map +1 -1
- package/dist/generic/logging.js +17 -0
- package/dist/generic/logging.js.map +1 -1
- package/dist/generic/metadata.d.ts +1 -1
- package/dist/generic/metadata.d.ts.map +1 -1
- package/dist/generic/metadata.js.map +1 -1
- package/dist/generic/mjapiUrl.d.ts +21 -0
- package/dist/generic/mjapiUrl.d.ts.map +1 -0
- package/dist/generic/mjapiUrl.js +25 -0
- package/dist/generic/mjapiUrl.js.map +1 -0
- package/dist/generic/providerBase.d.ts +301 -5
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +750 -168
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/queryInfo.d.ts +10 -9
- package/dist/generic/queryInfo.d.ts.map +1 -1
- package/dist/generic/queryInfo.js +60 -33
- package/dist/generic/queryInfo.js.map +1 -1
- package/dist/generic/scoring/ReciprocalRankFusion.d.ts.map +1 -1
- package/dist/generic/scoring/ReciprocalRankFusion.js +10 -3
- package/dist/generic/scoring/ReciprocalRankFusion.js.map +1 -1
- package/dist/generic/securityInfo.d.ts +11 -6
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +15 -10
- package/dist/generic/securityInfo.js.map +1 -1
- package/dist/generic/telemetryManager.d.ts.map +1 -1
- package/dist/generic/telemetryManager.js +4 -1
- package/dist/generic/telemetryManager.js.map +1 -1
- package/dist/generic/transactionGroup.d.ts +27 -0
- package/dist/generic/transactionGroup.d.ts.map +1 -1
- package/dist/generic/transactionGroup.js +58 -5
- package/dist/generic/transactionGroup.js.map +1 -1
- package/dist/generic/util.d.ts.map +1 -1
- package/dist/generic/util.js +3 -0
- package/dist/generic/util.js.map +1 -1
- package/dist/generic/wellKnownUserSource.d.ts +3 -2
- package/dist/generic/wellKnownUserSource.d.ts.map +1 -1
- package/dist/generic/wellKnownUserSource.js +18 -9
- package/dist/generic/wellKnownUserSource.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/package.json +3 -3
- package/readme.md +29 -4
|
@@ -6,7 +6,8 @@ import { DispatchRemoteOperationInProcess } from "./remoteOperationDispatch.js";
|
|
|
6
6
|
import { TransactionItem } from "./transactionGroup.js";
|
|
7
7
|
import { CompositeKey } from "./compositeKey.js";
|
|
8
8
|
import { LogError } from "./logging.js";
|
|
9
|
-
import {
|
|
9
|
+
import { LocalCacheManager } from "./localCacheManager.js";
|
|
10
|
+
import { FormatBinaryChangeValue, ScanSQLForScreening, SQLExpressionValidator, uuidv4 } from "@memberjunction/global";
|
|
10
11
|
import { GetDialect } from "@memberjunction/sql-dialect";
|
|
11
12
|
// Re-export PlatformSQL types from their canonical location for backward compatibility
|
|
12
13
|
export { IsPlatformSQL } from "./platformSQL.js";
|
|
@@ -82,10 +83,13 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
82
83
|
return 0;
|
|
83
84
|
}
|
|
84
85
|
/**
|
|
85
|
-
* Public nesting depth
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
86
|
+
* Public nesting depth of the transaction open on this instance; 0 = none. Read this, not
|
|
87
|
+
* `IsInTransaction` (SQL Server leaves that false), when you need to know whether a transaction
|
|
88
|
+
* is open at all. The depth is instance-wide: on a shared provider `TransactionDepth > 0` does
|
|
89
|
+
* NOT mean the caller's statements run in that transaction — only the async scope that began it
|
|
90
|
+
* is part of it (#4786). Code that needs to be inside a transaction should simply call
|
|
91
|
+
* {@link BeginTransaction} / {@link BeginEntityTransaction}: joining one the caller is already in
|
|
92
|
+
* is automatic. Deprecated camelCase `transactionDepth` alias ships for one release.
|
|
89
93
|
*/
|
|
90
94
|
get TransactionDepth() {
|
|
91
95
|
return this.CurrentTransactionDepth;
|
|
@@ -114,6 +118,11 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
114
118
|
await this.ResetTransactionState();
|
|
115
119
|
}
|
|
116
120
|
}
|
|
121
|
+
// Belt and braces: whatever route the rollback took, this instance is going away, so it
|
|
122
|
+
// must not leave an entity-event batch behind. A batch whose owner is collected is
|
|
123
|
+
// unreachable while its entities keep counting as pending, which makes every cached read
|
|
124
|
+
// of them miss for the life of the process.
|
|
125
|
+
await LocalCacheManager.Instance.AbandonEntityEventBatch(this);
|
|
117
126
|
}
|
|
118
127
|
/** @deprecated Use {@link TransactionDepth}. */
|
|
119
128
|
get transactionDepth() {
|
|
@@ -128,15 +137,15 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
128
137
|
/* no-op */
|
|
129
138
|
}
|
|
130
139
|
/**
|
|
131
|
-
* Run `task` once the
|
|
132
|
-
* no
|
|
140
|
+
* Run `task` once the caller's transaction on this provider (the one its async scope belongs
|
|
141
|
+
* to) has committed, or now if the caller is in no transaction on this provider.
|
|
133
142
|
*
|
|
134
143
|
* Use it for side effects that must only happen for work that is actually durable — queueing
|
|
135
144
|
* background jobs, firing deferred entity actions — and must not run *inside* the caller's
|
|
136
145
|
* transaction. Providers that track transactions (see `GenericDatabaseProvider`) queue the task
|
|
137
|
-
* while
|
|
138
|
-
* commit succeeds (once the transaction lock is released, so a task may open its
|
|
139
|
-
* transaction), and discard them — without running them — when the transaction rolls back,
|
|
146
|
+
* while the caller's transaction is open, run queued tasks in registration order after the
|
|
147
|
+
* **outermost** commit succeeds (once the transaction lock is released, so a task may open its
|
|
148
|
+
* own transaction), and discard them — without running them — when the transaction rolls back,
|
|
140
149
|
* fails to commit, or is abandoned.
|
|
141
150
|
*
|
|
142
151
|
* This default is for providers that do not track transactions: the task starts immediately.
|
|
@@ -163,8 +172,9 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
163
172
|
void this.RunPostCommitTaskSafely(task, description);
|
|
164
173
|
}
|
|
165
174
|
/**
|
|
166
|
-
* Snapshot of the transaction frames
|
|
167
|
-
* Synchronous by contract — call it before the
|
|
175
|
+
* Snapshot of the caller's transaction frames on this provider (the transaction its async scope
|
|
176
|
+
* belongs to), for a later {@link RunAfterCommit}. Synchronous by contract — call it before the
|
|
177
|
+
* first `await` of the code that causes the work.
|
|
168
178
|
*
|
|
169
179
|
* @returns `undefined` when no transaction is open. This default never tracks transactions, so
|
|
170
180
|
* it always returns `undefined`.
|
|
@@ -207,6 +217,11 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
207
217
|
* physical transaction on the same pool, blind to any transaction the caller had already
|
|
208
218
|
* started.
|
|
209
219
|
*
|
|
220
|
+
* The transaction belongs to the async scope that calls this: that code and everything it starts
|
|
221
|
+
* run on the transaction; unrelated callers on the same provider instance run on the pool
|
|
222
|
+
* (GenericDatabaseProvider, #4786). Call it from the function that performs the writes — a begin
|
|
223
|
+
* reached only after an `await` inside a helper does not include the helper's caller.
|
|
224
|
+
*
|
|
210
225
|
* The returned scope is **settle-once**: the first `Commit()` or `Rollback()` wins and later
|
|
211
226
|
* calls are no-ops, so `try { ...; Commit() } catch { Rollback() }` is safe even when the work
|
|
212
227
|
* already unwound its own scope.
|
|
@@ -222,6 +237,10 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
222
237
|
const isNested = this.IsInTransaction || this.CurrentTransactionDepth > 0;
|
|
223
238
|
await this.BeginTransaction();
|
|
224
239
|
const depthAtBegin = this.CurrentTransactionDepth;
|
|
240
|
+
// Cache maintenance for the saves in this scope waits for the outermost settle: one rewrite
|
|
241
|
+
// per cached slot on commit instead of one per save, and nothing written for work that is
|
|
242
|
+
// rolled back. Nested scopes join the same batch.
|
|
243
|
+
LocalCacheManager.Instance.BeginEntityEventBatch(this);
|
|
225
244
|
let settled = false;
|
|
226
245
|
const settle = async (commit) => {
|
|
227
246
|
if (settled) {
|
|
@@ -244,11 +263,18 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
244
263
|
`work. Do not run concurrent transactional saves on a shared provider instance — use ` +
|
|
245
264
|
`per-request providers (as MJServer does) or serialize the units of work.`);
|
|
246
265
|
}
|
|
247
|
-
|
|
248
|
-
|
|
266
|
+
let settledAsCommit = false;
|
|
267
|
+
try {
|
|
268
|
+
if (commit) {
|
|
269
|
+
await this.CommitTransaction();
|
|
270
|
+
settledAsCommit = true;
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
await this.RollbackTransaction();
|
|
274
|
+
}
|
|
249
275
|
}
|
|
250
|
-
|
|
251
|
-
await
|
|
276
|
+
finally {
|
|
277
|
+
await LocalCacheManager.Instance.EndEntityEventBatch(this, settledAsCommit);
|
|
252
278
|
}
|
|
253
279
|
};
|
|
254
280
|
return {
|
|
@@ -907,8 +933,23 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
907
933
|
// Always offer the record-change-free form to the SQL logger, not only when the
|
|
908
934
|
// entity tracks record changes: it is also the replay-safe form of a create for
|
|
909
935
|
// migration recordings (MemberJunction/MJ#4503). Execution still uses fullSQL.
|
|
910
|
-
if (sqlDetails
|
|
911
|
-
opts
|
|
936
|
+
if ('simpleSQL' in sqlDetails) {
|
|
937
|
+
Object.defineProperty(opts, 'simpleSQLFallback', {
|
|
938
|
+
enumerable: true,
|
|
939
|
+
configurable: true,
|
|
940
|
+
get() {
|
|
941
|
+
return sqlDetails.simpleSQL || undefined;
|
|
942
|
+
},
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
if ('literalSQL' in sqlDetails) {
|
|
946
|
+
Object.defineProperty(opts, 'literalSQL', {
|
|
947
|
+
enumerable: true,
|
|
948
|
+
configurable: true,
|
|
949
|
+
get() {
|
|
950
|
+
return sqlDetails.literalSQL || undefined;
|
|
951
|
+
},
|
|
952
|
+
});
|
|
912
953
|
}
|
|
913
954
|
return opts;
|
|
914
955
|
}
|
|
@@ -920,8 +961,23 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
920
961
|
isMutation: true,
|
|
921
962
|
description: `Delete ${entity.EntityInfo.Name}`,
|
|
922
963
|
};
|
|
923
|
-
if (entity.EntityInfo.TrackRecordChanges && sqlDetails
|
|
924
|
-
opts
|
|
964
|
+
if (entity.EntityInfo.TrackRecordChanges && 'simpleSQL' in sqlDetails) {
|
|
965
|
+
Object.defineProperty(opts, 'simpleSQLFallback', {
|
|
966
|
+
enumerable: true,
|
|
967
|
+
configurable: true,
|
|
968
|
+
get() {
|
|
969
|
+
return sqlDetails.simpleSQL || undefined;
|
|
970
|
+
},
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
if ('literalSQL' in sqlDetails) {
|
|
974
|
+
Object.defineProperty(opts, 'literalSQL', {
|
|
975
|
+
enumerable: true,
|
|
976
|
+
configurable: true,
|
|
977
|
+
get() {
|
|
978
|
+
return sqlDetails.literalSQL || undefined;
|
|
979
|
+
},
|
|
980
|
+
});
|
|
925
981
|
}
|
|
926
982
|
return opts;
|
|
927
983
|
}
|
|
@@ -950,26 +1006,27 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
950
1006
|
/**************************************************************************/
|
|
951
1007
|
/**
|
|
952
1008
|
* Validates a user-provided SQL clause (WHERE, ORDER BY, etc.) to prevent SQL injection.
|
|
953
|
-
*
|
|
954
|
-
*
|
|
955
|
-
*
|
|
1009
|
+
* The clause is read with this provider's SQL dialect. It is rejected when it contains a
|
|
1010
|
+
* comment, text that cannot be read the way the database reads it, a forbidden keyword
|
|
1011
|
+
* (INSERT, UPDATE, DELETE, EXEC, DROP, UNION, etc.), a semicolon, or an `xp_` name.
|
|
1012
|
+
* String literals and quoted identifiers are not checked for keywords.
|
|
956
1013
|
*
|
|
957
1014
|
* @param clause The SQL clause to validate
|
|
958
1015
|
* @returns true if the clause is safe, false if it contains forbidden patterns
|
|
959
1016
|
*/
|
|
960
1017
|
ValidateUserProvidedSQLClause(clause) {
|
|
961
|
-
//
|
|
962
|
-
//
|
|
963
|
-
//
|
|
964
|
-
//
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
const lowerClause =
|
|
1018
|
+
// 🚨 SECURITY: this uses the SHARED scanner in @memberjunction/global, which reads quotes,
|
|
1019
|
+
// [bracket] and "double-quoted" identifiers, comments, and PostgreSQL E'…' and $$…$$ strings
|
|
1020
|
+
// the way this provider's database does, and refuses text it cannot classify. Do NOT inline
|
|
1021
|
+
// a regex here: this screen and SQLExpressionValidator must read SQL the same way.
|
|
1022
|
+
const scan = ScanSQLForScreening(clause, this.PlatformKey);
|
|
1023
|
+
if (!scan.Classified || scan.CommentCount > 0)
|
|
1024
|
+
return false;
|
|
1025
|
+
const lowerClause = scan.Code.toLowerCase();
|
|
969
1026
|
const forbiddenPatterns = [
|
|
970
1027
|
/\binsert\b/, /\bupdate\b/, /\bdelete\b/,
|
|
971
1028
|
/\bexec\b/, /\bexecute\b/, /\bdrop\b/,
|
|
972
|
-
/--/, /\/\*/, /\*\//, /\bunion\b/,
|
|
1029
|
+
/--/, /\/\*/, /\*\//, /\bunion\b/, /;/,
|
|
973
1030
|
// Time-based blind injection vector — no legitimate filter/order-by clause uses WAITFOR.
|
|
974
1031
|
/\bwaitfor\b/,
|
|
975
1032
|
];
|
|
@@ -977,7 +1034,8 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
977
1034
|
if (pattern.test(lowerClause))
|
|
978
1035
|
return false;
|
|
979
1036
|
}
|
|
980
|
-
|
|
1037
|
+
// Extended stored procedures are names, so a quoted name such as [xp_cmdshell] is checked too.
|
|
1038
|
+
return !/\bxp_/.test(scan.CodeWithIdentifiers.toLowerCase());
|
|
981
1039
|
}
|
|
982
1040
|
/**
|
|
983
1041
|
* Checks that the given user has read permissions on the specified entity.
|
|
@@ -1026,6 +1084,7 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
1026
1084
|
const result = validator.validate(agg.expression, {
|
|
1027
1085
|
context: 'aggregate',
|
|
1028
1086
|
entityFields: fieldNames,
|
|
1087
|
+
Dialect: this.PlatformKey,
|
|
1029
1088
|
});
|
|
1030
1089
|
if (!result.valid) {
|
|
1031
1090
|
validationErrors.push({
|
|
@@ -1112,14 +1171,20 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
1112
1171
|
}
|
|
1113
1172
|
}
|
|
1114
1173
|
/**
|
|
1115
|
-
* Builds the
|
|
1116
|
-
*
|
|
1174
|
+
* Builds the query that reads a record's name fields by its primary key.
|
|
1175
|
+
*
|
|
1176
|
+
* Key columns are written from the entity's metadata and key values are bound as parameters,
|
|
1177
|
+
* so nothing the caller supplies reaches the SQL text. When a user is given, their effective
|
|
1178
|
+
* read row filter (role RLS and API-key row filters) is ANDed in. A caller that gives no user
|
|
1179
|
+
* is trusted server code, as for a single-record Load.
|
|
1117
1180
|
*
|
|
1118
1181
|
* @param entityName The entity name
|
|
1119
1182
|
* @param compositeKey The record's primary key
|
|
1120
|
-
* @
|
|
1183
|
+
* @param contextUser The acting user, whose read row filter applies
|
|
1184
|
+
* @returns The SQL and its parameter values, or null if the entity has no name field or the
|
|
1185
|
+
* key does not name the entity's primary key fields
|
|
1121
1186
|
*/
|
|
1122
|
-
BuildEntityRecordNameSQL(entityName, compositeKey) {
|
|
1187
|
+
BuildEntityRecordNameSQL(entityName, compositeKey, contextUser) {
|
|
1123
1188
|
const e = this.EntityByName(entityName);
|
|
1124
1189
|
if (!e)
|
|
1125
1190
|
throw new Error('Entity ' + entityName + ' not found');
|
|
@@ -1128,30 +1193,44 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
1128
1193
|
LogError('Entity ' + entityName + ' does not have a NameField, returning null');
|
|
1129
1194
|
return null;
|
|
1130
1195
|
}
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
where += this.QuoteIdentifier(pkv.FieldName) + "='" + EscapeSQLString(String(pkv.Value)) + "'";
|
|
1140
|
-
}
|
|
1141
|
-
else {
|
|
1142
|
-
// Unquoted (numeric) key column: the value is spliced in bare, so refuse anything
|
|
1143
|
-
// that is not a plain number rather than letting it reach the SQL text.
|
|
1144
|
-
const raw = String(pkv.Value);
|
|
1145
|
-
if (!/^-?\d+(\.\d+)?$/.test(raw)) {
|
|
1146
|
-
LogError(`BuildEntityRecordNameSQL: non-numeric value provided for numeric key field ${pkv.FieldName} on entity ${entityName}`);
|
|
1147
|
-
return null;
|
|
1148
|
-
}
|
|
1149
|
-
where += this.QuoteIdentifier(pkv.FieldName) + '=' + raw;
|
|
1150
|
-
}
|
|
1196
|
+
const keyFields = this.RecordNameKeyFieldsOf(e, compositeKey);
|
|
1197
|
+
if (!keyFields) {
|
|
1198
|
+
return null;
|
|
1199
|
+
}
|
|
1200
|
+
const conditions = keyFields.map((f, i) => this.QuoteIdentifier(f.Name) + '=' + this.BuildParameterPlaceholder(i));
|
|
1201
|
+
const rowFilter = contextUser ? e.GetEffectiveRowFilterWhereClause(contextUser, EntityPermissionType.Read, '') : '';
|
|
1202
|
+
if (rowFilter) {
|
|
1203
|
+
conditions.push('(' + rowFilter + ')');
|
|
1151
1204
|
}
|
|
1152
1205
|
// SELECT all name fields so InternalGetEntityRecordName can concatenate them
|
|
1153
1206
|
const selectFields = nameFields.map(f => this.QuoteIdentifier(f.Name)).join(', ');
|
|
1154
|
-
return
|
|
1207
|
+
return {
|
|
1208
|
+
SQL: 'SELECT ' + selectFields + ' FROM ' + this.QuoteSchemaAndView(e.SchemaName, e.BaseView) + ' WHERE ' + conditions.join(' AND '),
|
|
1209
|
+
Parameters: compositeKey.KeyValuePairs.map(kv => kv.Value),
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* The primary key field each pair of a record-name key names, in key order. Names match
|
|
1214
|
+
* case-insensitively. Null, after logging, when the key is empty or names a field that is not
|
|
1215
|
+
* one of the entity's primary keys.
|
|
1216
|
+
*/
|
|
1217
|
+
RecordNameKeyFieldsOf(entity, compositeKey) {
|
|
1218
|
+
const pairs = compositeKey?.KeyValuePairs ?? [];
|
|
1219
|
+
if (pairs.length === 0) {
|
|
1220
|
+
LogError(`Record name lookup on ${entity.Name}: the key names no fields`);
|
|
1221
|
+
return null;
|
|
1222
|
+
}
|
|
1223
|
+
const keyFields = [];
|
|
1224
|
+
for (const pair of pairs) {
|
|
1225
|
+
const name = String(pair.FieldName ?? '').trim().toLowerCase();
|
|
1226
|
+
const field = entity.PrimaryKeys.find(pk => pk.Name.trim().toLowerCase() === name);
|
|
1227
|
+
if (!field) {
|
|
1228
|
+
LogError(`Record name lookup on ${entity.Name}: ${JSON.stringify(pair.FieldName)} is not a primary key field`);
|
|
1229
|
+
return null;
|
|
1230
|
+
}
|
|
1231
|
+
keyFields.push(field);
|
|
1232
|
+
}
|
|
1233
|
+
return keyFields;
|
|
1155
1234
|
}
|
|
1156
1235
|
/**
|
|
1157
1236
|
* The fields a record's display name is built from, in order: every `IsNameField` field by
|
|
@@ -1184,7 +1263,8 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
1184
1263
|
}
|
|
1185
1264
|
/**
|
|
1186
1265
|
* Retrieves the display name for a single entity record.
|
|
1187
|
-
* Uses BuildEntityRecordNameSQL for dialect-neutral SQL generation
|
|
1266
|
+
* Uses BuildEntityRecordNameSQL for dialect-neutral SQL generation, which limits the lookup to
|
|
1267
|
+
* rows the acting user's read row filter allows.
|
|
1188
1268
|
*
|
|
1189
1269
|
* Answers with an empty string, without querying, when field-level security withholds any of
|
|
1190
1270
|
* the name fields from the acting user — the same answer as a record that does not exist, so
|
|
@@ -1196,9 +1276,9 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
1196
1276
|
if (entity && !this.CanUserReadRecordName(entity, contextUser)) {
|
|
1197
1277
|
return '';
|
|
1198
1278
|
}
|
|
1199
|
-
const
|
|
1200
|
-
if (
|
|
1201
|
-
const data = await this.ExecuteSQL(
|
|
1279
|
+
const query = this.BuildEntityRecordNameSQL(entityName, compositeKey, contextUser);
|
|
1280
|
+
if (query) {
|
|
1281
|
+
const data = await this.ExecuteSQL(query.SQL, query.Parameters, undefined, contextUser);
|
|
1202
1282
|
if (data && data.length === 1) {
|
|
1203
1283
|
// Concatenate all returned fields with spaces (supports multi-name entities)
|
|
1204
1284
|
const values = Object.values(data[0])
|
|
@@ -1381,22 +1461,45 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
1381
1461
|
}
|
|
1382
1462
|
// Step 4: Generate provider-specific SQL
|
|
1383
1463
|
const sqlDetails = await this.GenerateSaveSQL(entity, bNewRecord, user, options);
|
|
1464
|
+
const effectiveReturnMode = options?.SaveReturnMode ?? entity.EntityInfo.SaveReturnMode;
|
|
1465
|
+
// Snapshot the entity's in-memory field values at the exact point SQL parameters were bound
|
|
1466
|
+
// (before awaiting ExecuteSQL). If the caller mutates the entity in memory while an async
|
|
1467
|
+
// save is in flight (e.g. BaseEntitySaveQueue pipelining an INSERT + UPDATE), merging
|
|
1468
|
+
// post-await entity.GetAll() would falsely mark those in-flight mutations as clean/saved.
|
|
1469
|
+
const preSaveValues = effectiveReturnMode === 'datestamps_only'
|
|
1470
|
+
? entity.GetAll()
|
|
1471
|
+
: undefined;
|
|
1472
|
+
if (preSaveValues && bNewRecord) {
|
|
1473
|
+
// On a new record saved with 'datestamps_only', the SP does not query BaseView,
|
|
1474
|
+
// so virtual non-PK fields (e.g. joined name columns) that were never set stay
|
|
1475
|
+
// null. Omit them from preSaveValues so finalizeSave marks them NotLoaded=true.
|
|
1476
|
+
for (const f of entity.EntityInfo.Fields) {
|
|
1477
|
+
if (f.IsVirtual && !f.IsPrimaryKey && (preSaveValues[f.Name] === null || preSaveValues[f.Name] === undefined)) {
|
|
1478
|
+
delete preSaveValues[f.Name];
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1384
1482
|
if (entity.TransactionGroup && !bReplay) {
|
|
1385
1483
|
// ---- Transaction Group path ----
|
|
1386
1484
|
entity.RaiseReadyForTransaction();
|
|
1387
1485
|
suspendRefresh();
|
|
1388
1486
|
const extraData = this.GetTransactionExtraData(entity);
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1487
|
+
Object.defineProperty(extraData, 'simpleSQLFallback', {
|
|
1488
|
+
get: () => sqlDetails.simpleSQL,
|
|
1489
|
+
enumerable: true,
|
|
1490
|
+
configurable: true,
|
|
1491
|
+
});
|
|
1392
1492
|
extraData.entityName = entity.EntityInfo.Name;
|
|
1393
|
-
entity.TransactionGroup.AddTransaction(new TransactionItem(entity, entityResult.Type === 'create' ? 'Create' : 'Update', sqlDetails.fullSQL, sqlDetails.parameters ?? null, extraData, (transactionResult, success) => {
|
|
1493
|
+
entity.TransactionGroup.AddTransaction(new TransactionItem(entity, entityResult.Type === 'create' ? 'Create' : 'Update', sqlDetails.literalSQL ?? sqlDetails.fullSQL, sqlDetails.literalSQL ? null : (sqlDetails.parameters ?? null), extraData, (transactionResult, success) => {
|
|
1394
1494
|
resumeRefresh();
|
|
1395
1495
|
entityResult.EndedAt = new Date();
|
|
1396
1496
|
if (success && transactionResult) {
|
|
1397
1497
|
this.OnAfterSaveExecute(entity, user, options, saveContext);
|
|
1398
1498
|
entityResult.Success = true;
|
|
1399
|
-
|
|
1499
|
+
const mergedResult = preSaveValues
|
|
1500
|
+
? { ...preSaveValues, ...transactionResult }
|
|
1501
|
+
: transactionResult;
|
|
1502
|
+
entityResult.NewValues = this.MapTransactionResultToNewValues(mergedResult);
|
|
1400
1503
|
}
|
|
1401
1504
|
else {
|
|
1402
1505
|
entityResult.Success = false;
|
|
@@ -1422,20 +1525,36 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
1422
1525
|
if (result && result.length > 0) {
|
|
1423
1526
|
this.OnAfterSaveExecute(entity, user, options, saveContext);
|
|
1424
1527
|
entityResult.Success = true;
|
|
1528
|
+
// In 'datestamps_only' mode, the SP/query returned only the PK and
|
|
1529
|
+
// __mj_CreatedAt/__mj_UpdatedAt columns. Merge those returned values
|
|
1530
|
+
// onto the pre-save snapshot so caller-set fields remain intact without
|
|
1531
|
+
// falsely marking concurrent in-flight mutations as clean.
|
|
1532
|
+
let mergedRow;
|
|
1533
|
+
if (preSaveValues) {
|
|
1534
|
+
mergedRow = { ...preSaveValues, ...result[0] };
|
|
1535
|
+
Object.defineProperty(mergedRow, '__mj_preMerged', {
|
|
1536
|
+
value: true,
|
|
1537
|
+
enumerable: false,
|
|
1538
|
+
configurable: true,
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1541
|
+
else {
|
|
1542
|
+
mergedRow = result[0];
|
|
1543
|
+
}
|
|
1425
1544
|
// OnSaveCompleted may return patch fields (e.g., updated lat/lng
|
|
1426
1545
|
// from geocoding) that need to be merged onto the SP result before
|
|
1427
1546
|
// the entity loads the data via finalizeSave().
|
|
1428
1547
|
const patches = await this.OnSaveCompleted(entity, sqlDetails, user, options, saveContext);
|
|
1429
1548
|
if (patches) {
|
|
1430
|
-
Object.assign(
|
|
1549
|
+
Object.assign(mergedRow, patches);
|
|
1431
1550
|
}
|
|
1432
|
-
return
|
|
1551
|
+
return mergedRow;
|
|
1433
1552
|
}
|
|
1434
1553
|
else {
|
|
1435
1554
|
if (bNewRecord)
|
|
1436
|
-
throw new Error(`SQL Error: Error creating new record, no rows returned from SQL: ${sqlDetails.fullSQL}`);
|
|
1555
|
+
throw new Error(`SQL Error: Error creating new record, no rows returned from SQL: ${sqlDetails.literalSQL ?? sqlDetails.fullSQL}`);
|
|
1437
1556
|
else
|
|
1438
|
-
throw new Error(`SQL Error: Error updating record, no MATCHING rows found within the database: ${sqlDetails.fullSQL}`);
|
|
1557
|
+
throw new Error(`SQL Error: Error updating record, no MATCHING rows found within the database: ${sqlDetails.literalSQL ?? sqlDetails.fullSQL}`);
|
|
1439
1558
|
}
|
|
1440
1559
|
}
|
|
1441
1560
|
}
|
|
@@ -1505,20 +1624,24 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
1505
1624
|
// ---- Transaction Group path ----
|
|
1506
1625
|
entity.RaiseReadyForTransaction();
|
|
1507
1626
|
const extraData = this.GetTransactionExtraData(entity);
|
|
1508
|
-
if (entity.EntityInfo.TrackRecordChanges
|
|
1509
|
-
extraData
|
|
1627
|
+
if (entity.EntityInfo.TrackRecordChanges) {
|
|
1628
|
+
Object.defineProperty(extraData, 'simpleSQLFallback', {
|
|
1629
|
+
get: () => sqlDetails.simpleSQL,
|
|
1630
|
+
enumerable: true,
|
|
1631
|
+
configurable: true,
|
|
1632
|
+
});
|
|
1510
1633
|
}
|
|
1511
1634
|
extraData.entityName = entity.EntityInfo.Name;
|
|
1512
|
-
entity.TransactionGroup.AddTransaction(new TransactionItem(entity, 'Delete', sqlDetails.fullSQL, sqlDetails.parameters ?? null, extraData, (transactionResult, success) => {
|
|
1635
|
+
entity.TransactionGroup.AddTransaction(new TransactionItem(entity, 'Delete', sqlDetails.literalSQL ?? sqlDetails.fullSQL, sqlDetails.literalSQL ? null : (sqlDetails.parameters ?? null), extraData, (transactionResult, success) => {
|
|
1513
1636
|
entityResult.EndedAt = new Date();
|
|
1514
1637
|
if (success && transactionResult) {
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
}
|
|
1638
|
+
// Same key check as the direct path, through the provider's own override
|
|
1639
|
+
// (PostgreSQL accepts its legacy `_result_id` shape there).
|
|
1640
|
+
if (!this.ValidateDeleteResult(entity, [transactionResult], entityResult)) {
|
|
1641
|
+
entityResult.Success = false;
|
|
1642
|
+
return;
|
|
1521
1643
|
}
|
|
1644
|
+
this.OnAfterDeleteExecute(entity, user, options);
|
|
1522
1645
|
entityResult.NewValues = this.MapTransactionResultToNewValues(transactionResult);
|
|
1523
1646
|
entityResult.Success = true;
|
|
1524
1647
|
}
|