@memberjunction/sqlserver-dataprovider 6.2.0-edge.4 → 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/README.md +7 -0
- package/dist/SQLServerDataProvider.d.ts +82 -11
- package/dist/SQLServerDataProvider.d.ts.map +1 -1
- package/dist/SQLServerDataProvider.js +361 -145
- package/dist/SQLServerDataProvider.js.map +1 -1
- package/dist/saveParameters.d.ts +80 -0
- package/dist/saveParameters.d.ts.map +1 -0
- package/dist/saveParameters.js +229 -0
- package/dist/saveParameters.js.map +1 -0
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +16 -16
|
@@ -22,6 +22,7 @@ import { GenericDatabaseProvider } from '@memberjunction/generic-database-provid
|
|
|
22
22
|
import sql from 'mssql';
|
|
23
23
|
import { BehaviorSubject, Subject, concatMap, from, catchError, of } from 'rxjs';
|
|
24
24
|
import { SQLServerTransactionGroup } from './SQLServerTransactionGroup.js';
|
|
25
|
+
import { EXECUTED_VARIABLE_SUFFIX, IsSaveParameterList, RenderSaveSQL, SaveParameterCollector, } from './saveParameters.js';
|
|
25
26
|
import { SQLServerProviderConfigData } from './types.js';
|
|
26
27
|
import { EscapeSQLString, IsByteArray, TryBase64ToBytes } from '@memberjunction/global';
|
|
27
28
|
import { SQLServerDialect } from '@memberjunction/sql-dialect';
|
|
@@ -72,6 +73,13 @@ function buildRequest(connectionSource, query, parameters) {
|
|
|
72
73
|
else {
|
|
73
74
|
request = new sql.Request(connectionSource);
|
|
74
75
|
}
|
|
76
|
+
if (IsSaveParameterList(parameters)) {
|
|
77
|
+
// A save or delete statement (saveParameters.ts): named inputs typed from the columns.
|
|
78
|
+
for (const p of parameters) {
|
|
79
|
+
request.input(p.Name, p.Type, p.Value);
|
|
80
|
+
}
|
|
81
|
+
return { request, processedQuery: query };
|
|
82
|
+
}
|
|
75
83
|
let processedQuery = query;
|
|
76
84
|
if (parameters) {
|
|
77
85
|
if (Array.isArray(parameters)) {
|
|
@@ -150,22 +158,20 @@ async function executeSQLCore(query, parameters, context, options, isRetry = fal
|
|
|
150
158
|
error.code = 'POOL_CLOSED';
|
|
151
159
|
throw error;
|
|
152
160
|
}
|
|
153
|
-
// Handle logging
|
|
154
|
-
let logPromise;
|
|
155
|
-
if (options && !options.ignoreLogging && context.logSqlStatement) {
|
|
156
|
-
logPromise = context.logSqlStatement(query, parameters, options.description, options.ignoreLogging, options.isMutation, options.simpleSQLFallback, options.contextUser);
|
|
157
|
-
}
|
|
158
|
-
else {
|
|
159
|
-
logPromise = Promise.resolve();
|
|
160
|
-
}
|
|
161
161
|
try {
|
|
162
162
|
const { request, processedQuery } = buildRequest(connectionSource, query, parameters);
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
163
|
+
const queryPromise = options?.timeoutMs
|
|
164
|
+
? QueryWithTimeout(request, processedQuery, options.timeoutMs)
|
|
165
|
+
: request.query(processedQuery);
|
|
166
|
+
if (options && !options.ignoreLogging && context.logSqlStatement) {
|
|
167
|
+
const literalSQL = options.literalSQL;
|
|
168
|
+
const [result] = await Promise.all([
|
|
169
|
+
queryPromise,
|
|
170
|
+
context.logSqlStatement(literalSQL ?? query, literalSQL ? undefined : parameters, options.description, options.ignoreLogging, options.isMutation, options.simpleSQLFallback, options.contextUser)
|
|
171
|
+
]);
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
return await queryPromise;
|
|
169
175
|
}
|
|
170
176
|
catch (error) {
|
|
171
177
|
// Retry once for stale connection errors on non-transactional queries.
|
|
@@ -185,10 +191,11 @@ async function executeSQLCore(query, parameters, context, options, isRetry = fal
|
|
|
185
191
|
.map((e) => e?.message ?? String(e))
|
|
186
192
|
.join(' | ')}`
|
|
187
193
|
: '';
|
|
194
|
+
const literalSQL = options?.literalSQL;
|
|
188
195
|
const errorMessage = `Error executing SQL
|
|
189
196
|
Error: ${error?.message ? error.message : error}${precedingMsgs}
|
|
190
|
-
Query: ${query}
|
|
191
|
-
Parameters: ${parameters ? JSON.stringify(parameters)
|
|
197
|
+
Query: ${literalSQL ?? query}
|
|
198
|
+
Parameters: ${literalSQL || !parameters ? 'None' : JSON.stringify(parameters)}`;
|
|
192
199
|
// Preserve the original error type (ConnectionError vs RequestError) so callers
|
|
193
200
|
// can structurally distinguish infrastructure failures from query-level errors.
|
|
194
201
|
// Attach the detailed message while keeping the error's class identity and code.
|
|
@@ -206,8 +213,6 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
206
213
|
this._refreshSuspendCount = 0;
|
|
207
214
|
/** Refresh() calls waiting for `_refreshSuspendCount` to reach zero. */
|
|
208
215
|
this._refreshResumeWaiters = [];
|
|
209
|
-
this._needsDatetimeOffsetAdjustment = false;
|
|
210
|
-
this._datetimeOffsetTestComplete = false;
|
|
211
216
|
/**
|
|
212
217
|
* Per-entity base-view column order — the view's column NAMES in physical (`column_id`) order,
|
|
213
218
|
* keyed by `'<schema>.<baseview>'` lowercased. Populated in {@link Config} (after metadata
|
|
@@ -255,8 +260,9 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
255
260
|
* (batch separator, quoted identifiers, etc.) are sourced from
|
|
256
261
|
* `@memberjunction/sql-dialect` rather than hardcoded strings.
|
|
257
262
|
*/
|
|
263
|
+
static { this._dialectInstance = new SQLServerDialect(); }
|
|
258
264
|
getDialect() {
|
|
259
|
-
return
|
|
265
|
+
return SQLServerDataProvider._dialectInstance;
|
|
260
266
|
}
|
|
261
267
|
QuoteIdentifier(name) {
|
|
262
268
|
// Double embedded closing brackets so a name containing `]` cannot terminate the
|
|
@@ -276,6 +282,29 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
276
282
|
}
|
|
277
283
|
/** Longest an explicit Refresh() waits for in-flight saves before giving up (returning false). */
|
|
278
284
|
static { this.REFRESH_WAIT_TIMEOUT_MS = 30_000; }
|
|
285
|
+
static { this._needsDatetimeOffsetAdjustment = false; }
|
|
286
|
+
static { this._datetimeOffsetTestComplete = false; }
|
|
287
|
+
static { this._datetimeOffsetTestPromise = null; }
|
|
288
|
+
/** When the last datetimeoffset probe failed (ms since epoch), or null if it hasn't failed since the last success. */
|
|
289
|
+
static { this._datetimeOffsetTestFailedAt = null; }
|
|
290
|
+
/** After a failed probe, callers use the safe default for this long before probing again. */
|
|
291
|
+
static { this.DATETIMEOFFSET_PROBE_RETRY_MS = 30_000; }
|
|
292
|
+
get _needsDatetimeOffsetAdjustment() {
|
|
293
|
+
return SQLServerDataProvider._needsDatetimeOffsetAdjustment;
|
|
294
|
+
}
|
|
295
|
+
set _needsDatetimeOffsetAdjustment(value) {
|
|
296
|
+
SQLServerDataProvider._needsDatetimeOffsetAdjustment = value;
|
|
297
|
+
}
|
|
298
|
+
get _datetimeOffsetTestComplete() {
|
|
299
|
+
return SQLServerDataProvider._datetimeOffsetTestComplete;
|
|
300
|
+
}
|
|
301
|
+
set _datetimeOffsetTestComplete(value) {
|
|
302
|
+
SQLServerDataProvider._datetimeOffsetTestComplete = value;
|
|
303
|
+
if (!value) {
|
|
304
|
+
SQLServerDataProvider._datetimeOffsetTestPromise = null;
|
|
305
|
+
SQLServerDataProvider._datetimeOffsetTestFailedAt = null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
279
308
|
/**
|
|
280
309
|
* Process-wide view-column-order cache, scoped PER CONNECTION POOL and shared across provider
|
|
281
310
|
* instances. MJServer creates 1–2 fresh, fully-Config()'d provider instances per GraphQL request
|
|
@@ -370,8 +399,6 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
370
399
|
async Config(configData, providerToUse) {
|
|
371
400
|
try {
|
|
372
401
|
this._pool = configData.ConnectionPool; // Now expects a ConnectionPool instead of DataSource
|
|
373
|
-
// Initialize the instance queue processor
|
|
374
|
-
this.initializeQueueProcessor();
|
|
375
402
|
const configured = await super.Config(configData, providerToUse); // now parent class can do it's config
|
|
376
403
|
// After metadata is loaded, capture each base view's REAL column order so the save-capture
|
|
377
404
|
// @ResultTable is declared to match it (see _viewColumnOrderCache + getAllEntityColumnsSQL).
|
|
@@ -600,7 +627,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
600
627
|
// Build array of SQL statements for batch execution
|
|
601
628
|
const sqlStatements = [];
|
|
602
629
|
for (const { query } of items) {
|
|
603
|
-
// CacheValidationSQL
|
|
630
|
+
// CacheValidationSQL returns MaxUpdatedAt and a row count (TotalRows or RowCount)
|
|
604
631
|
sqlStatements.push(query.CacheValidationSQL);
|
|
605
632
|
}
|
|
606
633
|
try {
|
|
@@ -610,17 +637,8 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
610
637
|
for (let i = 0; i < items.length; i++) {
|
|
611
638
|
const { index } = items[i];
|
|
612
639
|
const resultSet = resultSets[i];
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
results.set(index, {
|
|
616
|
-
success: true,
|
|
617
|
-
rowCount: row.RowCount,
|
|
618
|
-
maxUpdatedAt: row.MaxUpdatedAt ? new Date(row.MaxUpdatedAt).toISOString() : undefined,
|
|
619
|
-
});
|
|
620
|
-
}
|
|
621
|
-
else {
|
|
622
|
-
results.set(index, { success: true, rowCount: 0, maxUpdatedAt: undefined });
|
|
623
|
-
}
|
|
640
|
+
// Shared reader: accepts the documented `TotalRows` as well as `RowCount`.
|
|
641
|
+
results.set(index, this.QueryCacheStatusFromRow(resultSet?.[0]));
|
|
624
642
|
}
|
|
625
643
|
}
|
|
626
644
|
catch (e) {
|
|
@@ -891,9 +909,11 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
891
909
|
// SQLServerTransactionGroup-batched saves regenerate per-item SQL when
|
|
892
910
|
// transaction variables get rebound. `_spName` is preserved for back-compat
|
|
893
911
|
// with that caller; the dialect-driven pipeline derives it from entity
|
|
894
|
-
// metadata via `GetCreateUpdateSPName`.
|
|
912
|
+
// metadata via `GetCreateUpdateSPName`. Outside a TransactionGroup,
|
|
913
|
+
// `fullSQL` holds `@p$0` parameter placeholders, so return `literalSQL`
|
|
914
|
+
// for standalone callers.
|
|
895
915
|
const result = await this.GenerateSaveSQL(entity, bNewRecord, user);
|
|
896
|
-
return result.fullSQL;
|
|
916
|
+
return result.literalSQL ?? result.fullSQL;
|
|
897
917
|
}
|
|
898
918
|
/**
|
|
899
919
|
* Queue the AI action task only once the save is durable: through {@link RunAfterCommit}, so it
|
|
@@ -906,6 +926,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
906
926
|
await QueueManager.AddTask('Entity AI Action', params, null, user);
|
|
907
927
|
}, 'Entity AI Action', postCommitToken);
|
|
908
928
|
}
|
|
929
|
+
static { this._entityColumnsSQLCache = new WeakMap(); }
|
|
909
930
|
getAllEntityColumnsSQL(entityInfo) {
|
|
910
931
|
// This builds the column list for the @ResultTable/@ResultChangesTable that captures
|
|
911
932
|
// `SELECT * FROM <BaseView>` via a POSITIONAL `INSERT INTO @table EXEC ...`. SQL Server maps the
|
|
@@ -930,10 +951,17 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
930
951
|
// — the only case where a base-column-after-a-join could mis-route by position — we WARN loudly
|
|
931
952
|
// (warnOnViewOrderFallback), because the metadata order can no longer be checked against the view and
|
|
932
953
|
// a silent fallback there would let the very bug this fix prevents persist undetected.
|
|
933
|
-
let orderedFields;
|
|
934
|
-
let fallbackReason;
|
|
935
954
|
const viewKey = `${entityInfo.SchemaName}.${entityInfo.BaseView}`.toLowerCase();
|
|
936
955
|
const viewOrder = this._viewColumnOrderCache.get(viewKey);
|
|
956
|
+
const cached = SQLServerDataProvider._entityColumnsSQLCache.get(entityInfo);
|
|
957
|
+
if (cached &&
|
|
958
|
+
cached.viewOrderRef === viewOrder &&
|
|
959
|
+
cached.fieldCount === entityInfo.Fields.length &&
|
|
960
|
+
!cached.usedFallback) {
|
|
961
|
+
return cached.sql;
|
|
962
|
+
}
|
|
963
|
+
let orderedFields;
|
|
964
|
+
let fallbackReason;
|
|
937
965
|
if (!viewOrder) {
|
|
938
966
|
fallbackReason = `base view not captured in the column-order cache (cache load failed at Config, or a custom/overridden sproc whose view was not read)`;
|
|
939
967
|
}
|
|
@@ -941,16 +969,16 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
941
969
|
fallbackReason = `base view exposes ${viewOrder.length} column(s) but the entity declares ${entityInfo.Fields.length} field(s) — cannot align 1:1 by position`;
|
|
942
970
|
}
|
|
943
971
|
else {
|
|
944
|
-
const
|
|
945
|
-
|
|
946
|
-
if (mapped.every((f) => f !== undefined)) {
|
|
972
|
+
const mapped = viewOrder.map((colName) => entityInfo.FieldByName(colName));
|
|
973
|
+
if (mapped.every((f) => f !== null && f !== undefined)) {
|
|
947
974
|
orderedFields = mapped; // every view column resolved 1:1 to a field
|
|
948
975
|
}
|
|
949
976
|
else {
|
|
950
|
-
const unmatched = viewOrder.filter((colName) => !
|
|
977
|
+
const unmatched = viewOrder.filter((colName) => !entityInfo.FieldByName(colName));
|
|
951
978
|
fallbackReason = `view column(s) without a matching EntityField: ${unmatched.join(', ')}`;
|
|
952
979
|
}
|
|
953
980
|
}
|
|
981
|
+
const usedFallback = !orderedFields;
|
|
954
982
|
if (!orderedFields) {
|
|
955
983
|
this.warnIfBaseFieldSequencedAfterVirtual(entityInfo);
|
|
956
984
|
this.warnOnViewOrderFallback(entityInfo, fallbackReason);
|
|
@@ -963,8 +991,35 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
963
991
|
sRet += '[' + f.Name + '] ' + f.SQLFullType + ' ' + (f.AllowsNull || f.IsVirtual ? 'NULL' : 'NOT NULL');
|
|
964
992
|
outputCount++;
|
|
965
993
|
}
|
|
994
|
+
SQLServerDataProvider._entityColumnsSQLCache.set(entityInfo, {
|
|
995
|
+
viewOrderRef: viewOrder,
|
|
996
|
+
fieldCount: entityInfo.Fields.length,
|
|
997
|
+
usedFallback,
|
|
998
|
+
sql: sRet,
|
|
999
|
+
});
|
|
966
1000
|
return sRet;
|
|
967
1001
|
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Builds the column declarations for `@ResultTable` when `entityInfo.SaveReturnMode === 'datestamps_only'`.
|
|
1004
|
+
*
|
|
1005
|
+
* Matches the exact column order emitted by CodeGen's `buildCRUDReturnSelect` via
|
|
1006
|
+
* {@link EntityInfo.DatestampReturnFields} (primary key(s), followed by `__mj_CreatedAt` and `__mj_UpdatedAt`),
|
|
1007
|
+
* ensuring positional `INSERT INTO @ResultTable EXEC spCreate/spUpdate` alignment.
|
|
1008
|
+
*/
|
|
1009
|
+
getDatestampEntityColumnsSQL(entityInfo) {
|
|
1010
|
+
return entityInfo.DatestampReturnFields
|
|
1011
|
+
.map((f) => `${this.QuoteIdentifier(f.Name)} ${f.SQLFullType} ${f.AllowsNull || f.IsVirtual ? 'NULL' : 'NOT NULL'}`)
|
|
1012
|
+
.join(',\n');
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Selects the `@ResultTable` column declarations matching the compiled stored procedure's
|
|
1016
|
+
* return shape (`returnContext.spReturnsDatestampsOnly`).
|
|
1017
|
+
*/
|
|
1018
|
+
getSaveResultTableColumnsSQL(entityInfo, returnContext) {
|
|
1019
|
+
return returnContext.spReturnsDatestampsOnly
|
|
1020
|
+
? this.getDatestampEntityColumnsSQL(entityInfo)
|
|
1021
|
+
: this.getAllEntityColumnsSQL(entityInfo);
|
|
1022
|
+
}
|
|
968
1023
|
/** Entities already warned about base-after-virtual field-order skew — so we warn once, not every save. */
|
|
969
1024
|
static { this._fieldOrderWarnedEntities = new Set(); }
|
|
970
1025
|
/**
|
|
@@ -1063,7 +1118,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1063
1118
|
if (value && field.Type.trim().toLowerCase() === 'datetimeoffset') {
|
|
1064
1119
|
return { kind: 'use', value: new Date(value).toISOString() };
|
|
1065
1120
|
}
|
|
1066
|
-
if (!isUpdate && field.
|
|
1121
|
+
if (!isUpdate && field.IsUniqueIdentifier && !field.IsPrimaryKey) {
|
|
1067
1122
|
if (typeof value === 'string' && value.includes('()')) {
|
|
1068
1123
|
return { kind: 'skip' };
|
|
1069
1124
|
}
|
|
@@ -1083,14 +1138,53 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1083
1138
|
* `EntityFieldInfo.NeedsClearCompanion`.
|
|
1084
1139
|
*/
|
|
1085
1140
|
RenderSaveCallBinding(entity, fieldValues, isUpdate, _spName) {
|
|
1086
|
-
|
|
1141
|
+
// The statement that runs uses one constant variable suffix and a parameter per value, so its
|
|
1142
|
+
// text repeats across records. The per-record suffix is kept for the literal form.
|
|
1143
|
+
const params = new SaveParameterCollector();
|
|
1144
|
+
const binding = this.renderSaveCallBindingWithSuffix(entity, fieldValues, isUpdate, EXECUTED_VARIABLE_SUFFIX, params);
|
|
1145
|
+
const saveBinding = { ...binding, parameters: params.Parameters, recordSuffix: this.allocateSaveCallSuffix(entity) };
|
|
1146
|
+
return saveBinding;
|
|
1147
|
+
}
|
|
1148
|
+
/** The binding as RenderSaveCallBinding built it; throws for any other shape. */
|
|
1149
|
+
asSaveBinding(binding, caller) {
|
|
1150
|
+
if (binding.kind !== 'mssql-declare-exec' || !('recordSuffix' in binding)) {
|
|
1151
|
+
throw new Error(`SQLServerDataProvider.${caller}: unexpected binding kind '${binding.kind}'`);
|
|
1152
|
+
}
|
|
1153
|
+
return binding;
|
|
1087
1154
|
}
|
|
1088
1155
|
/**
|
|
1089
|
-
* The
|
|
1090
|
-
*
|
|
1091
|
-
*
|
|
1156
|
+
* The fragment for a statement built with typed parameters. A transaction-group item gets the
|
|
1157
|
+
* literal form only, as before: the group combines and regenerates its items' SQL as text. A
|
|
1158
|
+
* statement with no values to bind runs as built, which is already valid SQL.
|
|
1159
|
+
*
|
|
1160
|
+
* `literalSQL` is exposed as a lazy memoized getter so saves with SQL logging off avoid running
|
|
1161
|
+
* the `RenderSaveSQL` regex substitution pass on every write.
|
|
1092
1162
|
*/
|
|
1093
|
-
|
|
1163
|
+
toSaveFragment(entity, executedSQL, parameters, recordSuffix) {
|
|
1164
|
+
if (entity.TransactionGroup) {
|
|
1165
|
+
return { sql: RenderSaveSQL(executedSQL, parameters, recordSuffix) };
|
|
1166
|
+
}
|
|
1167
|
+
let cachedLiteralSQL;
|
|
1168
|
+
const fragment = parameters.length > 0 ? { sql: executedSQL, parameters: [...parameters] } : { sql: executedSQL };
|
|
1169
|
+
Object.defineProperty(fragment, 'literalSQL', {
|
|
1170
|
+
enumerable: true,
|
|
1171
|
+
configurable: true,
|
|
1172
|
+
get() {
|
|
1173
|
+
if (cachedLiteralSQL === undefined) {
|
|
1174
|
+
cachedLiteralSQL = RenderSaveSQL(executedSQL, parameters, recordSuffix);
|
|
1175
|
+
}
|
|
1176
|
+
return cachedLiteralSQL;
|
|
1177
|
+
},
|
|
1178
|
+
});
|
|
1179
|
+
return fragment;
|
|
1180
|
+
}
|
|
1181
|
+
/**
|
|
1182
|
+
* The binding for a given variable suffix, with each value bound through `params`. Split out of
|
|
1183
|
+
* RenderSaveCallBinding so the update replay form can re-render a subset of the fields under the
|
|
1184
|
+
* SAME suffix without allocating another one (inside a TransactionGroup that would consume a `_n`
|
|
1185
|
+
* ordinal).
|
|
1186
|
+
*/
|
|
1187
|
+
renderSaveCallBindingWithSuffix(entity, fieldValues, isUpdate, uniqueSuffix, params) {
|
|
1094
1188
|
const declarations = [];
|
|
1095
1189
|
const setStatements = [];
|
|
1096
1190
|
const execParams = [];
|
|
@@ -1104,7 +1198,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1104
1198
|
const hasValue = value !== null && value !== undefined;
|
|
1105
1199
|
const binaryLiteral = hasValue && f.IsBinaryFieldType ? this.FormatBinaryLiteral(f, value) : undefined;
|
|
1106
1200
|
if (hasValue) {
|
|
1107
|
-
setStatements.push(`SET ${varName} = ${binaryLiteral ?? this.generateSetStatementValue(f, value)}`);
|
|
1201
|
+
setStatements.push(`SET ${varName} = ${params.AddField(f, binaryLiteral ?? this.generateSetStatementValue(f, value))}`);
|
|
1108
1202
|
}
|
|
1109
1203
|
execParams.push(`@${f.CodeName}=${varName}`);
|
|
1110
1204
|
simpleParams += this.generateSingleSPParam(f, value, bFirst, binaryLiteral);
|
|
@@ -1117,12 +1211,12 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1117
1211
|
// UPDATE: tail-append PK declarations from the loaded entity's PrimaryKey.
|
|
1118
1212
|
if (isUpdate && execParams.length > 0) {
|
|
1119
1213
|
for (const pkv of entity.PrimaryKey.KeyValuePairs) {
|
|
1120
|
-
const f = entity.EntityInfo.
|
|
1214
|
+
const f = entity.EntityInfo.FieldByName(pkv.FieldName);
|
|
1121
1215
|
if (!f)
|
|
1122
1216
|
continue;
|
|
1123
1217
|
const varName = `@${f.CodeName}${uniqueSuffix}`;
|
|
1124
1218
|
declarations.push(`${varName} ${f.SQLFullType.toUpperCase()}`);
|
|
1125
|
-
setStatements.push(`SET ${varName} = ${this.generateSetStatementValue(f, pkv.Value)}`);
|
|
1219
|
+
setStatements.push(`SET ${varName} = ${params.AddField(f, this.generateSetStatementValue(f, pkv.Value))}`);
|
|
1126
1220
|
execParams.push(`@${f.CodeName}=${varName}`);
|
|
1127
1221
|
const pkeyQuotes = f.NeedsQuotes ? "'" : '';
|
|
1128
1222
|
simpleParams += `, @${f.CodeName} = ${pkeyQuotes}${pkv.Value}${pkeyQuotes}`;
|
|
@@ -1165,23 +1259,21 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1165
1259
|
* there is nothing to look up).
|
|
1166
1260
|
*/
|
|
1167
1261
|
RenderReplaySaveSQL(binding, entity, fieldValues) {
|
|
1168
|
-
|
|
1169
|
-
throw new Error(`SQLServerDataProvider.RenderReplaySaveSQL: unexpected binding kind '${binding.kind}'`);
|
|
1170
|
-
}
|
|
1262
|
+
const saveBinding = this.asSaveBinding(binding, 'RenderReplaySaveSQL');
|
|
1171
1263
|
const info = entity.EntityInfo;
|
|
1172
1264
|
const pks = info.PrimaryKeys;
|
|
1173
1265
|
if (pks.length === 0 || !pks.every((pk) => fieldValues.has(pk))) {
|
|
1174
1266
|
return undefined;
|
|
1175
1267
|
}
|
|
1176
1268
|
const schema = info.SchemaName;
|
|
1177
|
-
const where = pks.map((pk) => `[${pk.Name}] = @${pk.CodeName}${
|
|
1269
|
+
const where = pks.map((pk) => `[${pk.Name}] = @${pk.CodeName}${saveBinding.suffix}`).join(' AND ');
|
|
1178
1270
|
const createSpName = this.GetCreateUpdateSPName(entity, true);
|
|
1179
|
-
const createBranch = `IF NOT EXISTS (SELECT 1 FROM [${schema}].[${info.BaseTable}] WHERE ${where})\nBEGIN\n EXEC [${schema}].${createSpName} ${
|
|
1271
|
+
const createBranch = `IF NOT EXISTS (SELECT 1 FROM [${schema}].[${info.BaseTable}] WHERE ${where})\nBEGIN\n EXEC [${schema}].${createSpName} ${saveBinding.callArgsSQL}\nEND`;
|
|
1180
1272
|
const hasUpdateProc = info.AllowUpdateAPI && info.spUpdateGenerated && !info.VirtualEntity;
|
|
1181
1273
|
const updateBranch = hasUpdateProc
|
|
1182
|
-
? `\nELSE\nBEGIN\n EXEC [${schema}].${this.GetCreateUpdateSPName(entity, false)} ${
|
|
1274
|
+
? `\nELSE\nBEGIN\n EXEC [${schema}].${this.GetCreateUpdateSPName(entity, false)} ${saveBinding.callArgsSQL}\nEND`
|
|
1183
1275
|
: '';
|
|
1184
|
-
return `${this.renderDeclareSetHead(
|
|
1276
|
+
return RenderSaveSQL(`${this.renderDeclareSetHead(saveBinding)}${createBranch}${updateBranch}`, saveBinding.parameters, saveBinding.recordSuffix);
|
|
1185
1277
|
}
|
|
1186
1278
|
/**
|
|
1187
1279
|
* Replay form of an UPDATE for the SQL log (never executed): `EXEC spUpdate` with only the
|
|
@@ -1194,9 +1286,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1194
1286
|
* (spUpdateGenerated off) makes no ISNULL promise, so it keeps the full-row form.
|
|
1195
1287
|
*/
|
|
1196
1288
|
RenderReplayUpdateSQL(binding, entity, changedFieldValues) {
|
|
1197
|
-
|
|
1198
|
-
throw new Error(`SQLServerDataProvider.RenderReplayUpdateSQL: unexpected binding kind '${binding.kind}'`);
|
|
1199
|
-
}
|
|
1289
|
+
const saveBinding = this.asSaveBinding(binding, 'RenderReplayUpdateSQL');
|
|
1200
1290
|
const info = entity.EntityInfo;
|
|
1201
1291
|
if (!info.spUpdateGenerated) {
|
|
1202
1292
|
return undefined;
|
|
@@ -1205,9 +1295,10 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1205
1295
|
const key = entity.PrimaryKey.ToString().replace(/[\r\n]+/g, ' ');
|
|
1206
1296
|
return `-- ${info.Name} (${key}): saved with no field changes; nothing to replay`;
|
|
1207
1297
|
}
|
|
1208
|
-
const
|
|
1298
|
+
const params = new SaveParameterCollector();
|
|
1299
|
+
const changed = this.renderSaveCallBindingWithSuffix(entity, changedFieldValues, true, saveBinding.suffix, params);
|
|
1209
1300
|
const spName = this.GetCreateUpdateSPName(entity, false);
|
|
1210
|
-
return `${this.renderDeclareSetHead(changed)}EXEC [${info.SchemaName}].${spName} ${changed.callArgsSQL}
|
|
1301
|
+
return RenderSaveSQL(`${this.renderDeclareSetHead(changed)}EXEC [${info.SchemaName}].${spName} ${changed.callArgsSQL}`, params.Parameters, saveBinding.recordSuffix);
|
|
1211
1302
|
}
|
|
1212
1303
|
/** `DECLARE ...\n\nSET ...\n\n` when the binding declares variables, else empty. */
|
|
1213
1304
|
renderDeclareSetHead(binding) {
|
|
@@ -1218,58 +1309,66 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1218
1309
|
* shape: `DECLARE ... ; SET ... ; EXEC [schema].sp callArgs`. When
|
|
1219
1310
|
* record-change tracking is on, this output is further wrapped by
|
|
1220
1311
|
* `WrapSaveCallWithRecordChange`.
|
|
1312
|
+
*
|
|
1313
|
+
* When `returnContext.needsDatestampProjection` is true (either passed by
|
|
1314
|
+
* `GenericDatabaseProvider.GenerateSaveSQL` or resolved from `options`),
|
|
1315
|
+
* delegates to {@link ProjectSaveCallToDatestamps}.
|
|
1221
1316
|
*/
|
|
1222
|
-
WrapSaveCallForResult(binding, entity, spName) {
|
|
1223
|
-
|
|
1224
|
-
|
|
1317
|
+
WrapSaveCallForResult(binding, entity, spName, options, returnContext) {
|
|
1318
|
+
const resolvedReturn = returnContext ?? this.ResolveSaveReturnContext(entity, options);
|
|
1319
|
+
if (resolvedReturn.needsDatestampProjection) {
|
|
1320
|
+
return this.ProjectSaveCallToDatestamps(binding, entity, spName, resolvedReturn);
|
|
1225
1321
|
}
|
|
1226
|
-
const
|
|
1227
|
-
|
|
1322
|
+
const saveBinding = this.asSaveBinding(binding, 'WrapSaveCallForResult');
|
|
1323
|
+
const execSQL = `EXEC [${entity.EntityInfo.SchemaName}].${spName} ${saveBinding.callArgsSQL}`;
|
|
1324
|
+
return this.toSaveFragment(entity, `${this.renderDeclareSetHead(saveBinding)}${execSQL}`, saveBinding.parameters, saveBinding.recordSuffix);
|
|
1325
|
+
}
|
|
1326
|
+
/**
|
|
1327
|
+
* Captures the stored procedure's output into `@ResultTable` and projects only
|
|
1328
|
+
* `returnContext.datestampSelectColumnsSQL` (`SELECT [ID], [__mj_CreatedAt], [__mj_UpdatedAt] FROM @ResultTable;`)
|
|
1329
|
+
* when a caller requests `SaveReturnMode: 'datestamps_only'` at runtime on an entity whose
|
|
1330
|
+
* compiled stored procedure returns the full row.
|
|
1331
|
+
*/
|
|
1332
|
+
ProjectSaveCallToDatestamps(binding, entity, spName, returnContext) {
|
|
1333
|
+
const saveBinding = this.asSaveBinding(binding, 'ProjectSaveCallToDatestamps');
|
|
1334
|
+
const execSQL = `EXEC [${entity.EntityInfo.SchemaName}].${spName} ${saveBinding.callArgsSQL}`;
|
|
1335
|
+
const resultTableColumnsSQL = this.getSaveResultTableColumnsSQL(entity.EntityInfo, returnContext);
|
|
1336
|
+
const sql = `${this.renderDeclareSetHead(saveBinding)}DECLARE @ResultTable TABLE (\n ${resultTableColumnsSQL}\n);\n\nINSERT INTO @ResultTable\n${execSQL};\n\nSELECT ${returnContext.datestampSelectColumnsSQL} FROM @ResultTable;`;
|
|
1337
|
+
return this.toSaveFragment(entity, sql, saveBinding.parameters, saveBinding.recordSuffix);
|
|
1228
1338
|
}
|
|
1229
1339
|
/**
|
|
1230
1340
|
* Wraps the save SQL with SQL Server's @ResultTable + inline
|
|
1231
1341
|
* `spCreateRecordChange_Internal` EXEC pattern. The CONCAT-built `@ID`
|
|
1232
1342
|
* inline supports composite-key entities.
|
|
1343
|
+
*
|
|
1344
|
+
* Consumes {@link SaveReturnContext} resolved by `GenericDatabaseProvider`:
|
|
1345
|
+
* - `@ResultTable` column declarations match the compiled stored procedure's return mode
|
|
1346
|
+
* (`returnContext.spReturnsDatestampsOnly`).
|
|
1347
|
+
* - The final `SELECT` from `@ResultTable` uses `returnContext.finalSelectColumnsSQL`.
|
|
1233
1348
|
*/
|
|
1234
|
-
WrapSaveCallWithRecordChange(_saveSQL, binding, payload, entity) {
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
const recordChangesEntityInfo = this.Entities.find((e) => e.Name === 'MJ: Record Changes');
|
|
1349
|
+
WrapSaveCallWithRecordChange(_saveSQL, binding, payload, entity, options, returnContext) {
|
|
1350
|
+
const saveBinding = this.asSaveBinding(binding, 'WrapSaveCallWithRecordChange');
|
|
1351
|
+
const params = new SaveParameterCollector(saveBinding.parameters);
|
|
1352
|
+
const recordChangesEntityInfo = this.EntityByName('MJ: Record Changes');
|
|
1239
1353
|
const spName = this.GetCreateUpdateSPName(entity, payload.type === 'Create');
|
|
1240
1354
|
const concatPKIDString = `CONCAT(${entity.EntityInfo.PrimaryKeys
|
|
1241
1355
|
.map((pk) => `'${pk.CodeName}','${CompositeKey.DefaultValueDelimiter}',[${pk.Name}]`)
|
|
1242
1356
|
.join(`,'${CompositeKey.DefaultFieldDelimiter}',`)})`;
|
|
1243
1357
|
// Build the inline `EXEC spCreateRecordChange_Internal` from the payload,
|
|
1244
1358
|
// referencing `@ID` (set below from the result table).
|
|
1245
|
-
const
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
: payload.source === 'Clone'
|
|
1251
|
-
? `,
|
|
1252
|
-
@Source='Clone',
|
|
1253
|
-
@ChangeContext=N'${EscapeSQLString(payload.changeContext)}'`
|
|
1254
|
-
: '';
|
|
1255
|
-
const recordChangeEXEC = `EXEC [${this.MJCoreSchemaName}].spCreateRecordChange_Internal @EntityName='${entity.EntityInfo.Name}',
|
|
1256
|
-
@RecordID=@ID,
|
|
1257
|
-
@UserID='${payload.userID}',
|
|
1258
|
-
@Type='${payload.type}',
|
|
1259
|
-
@ChangesJSON='${payload.changesJSON}',
|
|
1260
|
-
@ChangesDescription='${payload.changesDescription}',
|
|
1261
|
-
@FullRecordJSON='${payload.fullRecordJSON}',
|
|
1262
|
-
@Status='Complete',
|
|
1263
|
-
@Comments=null${lineageClause}`;
|
|
1264
|
-
const execSQL = `EXEC [${entity.EntityInfo.SchemaName}].${spName} ${binding.callArgsSQL}`;
|
|
1359
|
+
const recordChangeEXEC = this.renderRecordChangeEXEC(entity.EntityInfo.Name, '@ID', payload, params);
|
|
1360
|
+
const execSQL = `EXEC [${entity.EntityInfo.SchemaName}].${spName} ${saveBinding.callArgsSQL}`;
|
|
1361
|
+
const resolvedReturn = returnContext ?? this.ResolveSaveReturnContext(entity, options);
|
|
1362
|
+
const resultTableColumnsSQL = this.getSaveResultTableColumnsSQL(entity.EntityInfo, resolvedReturn);
|
|
1363
|
+
const finalSelectSQL = `SELECT ${resolvedReturn.finalSelectColumnsSQL} FROM @ResultTable;`;
|
|
1265
1364
|
const sql = `
|
|
1266
|
-
${
|
|
1365
|
+
${saveBinding.preambleSQL}
|
|
1267
1366
|
|
|
1268
1367
|
DECLARE @ResultTable TABLE (
|
|
1269
|
-
${
|
|
1368
|
+
${resultTableColumnsSQL}
|
|
1270
1369
|
);
|
|
1271
1370
|
|
|
1272
|
-
${
|
|
1371
|
+
${saveBinding.setSQL}
|
|
1273
1372
|
|
|
1274
1373
|
INSERT INTO @ResultTable
|
|
1275
1374
|
${execSQL};
|
|
@@ -1288,8 +1387,44 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1288
1387
|
${recordChangeEXEC};
|
|
1289
1388
|
END;
|
|
1290
1389
|
|
|
1291
|
-
|
|
1292
|
-
return
|
|
1390
|
+
${finalSelectSQL}`;
|
|
1391
|
+
return this.toSaveFragment(entity, sql, params.Parameters, saveBinding.recordSuffix);
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* `EXEC spCreateRecordChange_Internal …` for a payload. With `params`, each per-change value is
|
|
1395
|
+
* bound as a parameter typed from its Record Changes column, and the literal it replaces is kept
|
|
1396
|
+
* for the rendered form; without, the literals are written in directly. The entity name and change
|
|
1397
|
+
* type stay literal: they are constant for an entity and operation.
|
|
1398
|
+
*/
|
|
1399
|
+
renderRecordChangeEXEC(entityName, recordIDSQL, payload, params) {
|
|
1400
|
+
const bind = (fieldName, literal) => (params ? params.AddField(this.recordChangeField(fieldName), literal) : literal);
|
|
1401
|
+
const lineageClause = payload.source === 'Restore'
|
|
1402
|
+
? `,
|
|
1403
|
+
@Source='Restore',
|
|
1404
|
+
@RestoredFromID=${bind('RestoredFromID', `'${payload.restoredFromID}'`)},
|
|
1405
|
+
@RestoreReason=${payload.restoreReason ? bind('RestoreReason', `N'${payload.restoreReason.replace(/'/g, "''")}'`) : 'NULL'}`
|
|
1406
|
+
: payload.source === 'Clone'
|
|
1407
|
+
? `,
|
|
1408
|
+
@Source='Clone',
|
|
1409
|
+
@ChangeContext=${bind('ChangeContext', `N'${EscapeSQLString(payload.changeContext)}'`)}`
|
|
1410
|
+
: '';
|
|
1411
|
+
return `EXEC [${this.MJCoreSchemaName}].spCreateRecordChange_Internal @EntityName=N'${entityName}',
|
|
1412
|
+
@RecordID=${recordIDSQL},
|
|
1413
|
+
@UserID=${bind('UserID', `'${payload.userID}'`)},
|
|
1414
|
+
@Type='${payload.type}',
|
|
1415
|
+
@ChangesJSON=${bind('ChangesJSON', `N'${payload.changesJSON}'`)},
|
|
1416
|
+
@ChangesDescription=${bind('ChangesDescription', `N'${payload.changesDescription}'`)},
|
|
1417
|
+
@FullRecordJSON=${bind('FullRecordJSON', `N'${payload.fullRecordJSON}'`)},
|
|
1418
|
+
@Status='Complete',
|
|
1419
|
+
@Comments=null${lineageClause}`;
|
|
1420
|
+
}
|
|
1421
|
+
/** A field of `MJ: Record Changes`, whose column types type the Record Change parameters. */
|
|
1422
|
+
recordChangeField(fieldName) {
|
|
1423
|
+
const field = this.EntityByName('MJ: Record Changes')?.FieldByName(fieldName);
|
|
1424
|
+
if (!field) {
|
|
1425
|
+
throw new Error(`SQLServerDataProvider: field "${fieldName}" of "MJ: Record Changes" is not in the metadata`);
|
|
1426
|
+
}
|
|
1427
|
+
return field;
|
|
1293
1428
|
}
|
|
1294
1429
|
/**
|
|
1295
1430
|
* Generates the value portion of a SET statement for a field
|
|
@@ -1460,32 +1595,22 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1460
1595
|
return unicodePrefix + quoteString + pVal + quoteString;
|
|
1461
1596
|
}
|
|
1462
1597
|
GetLogRecordChangeSQL(newData, oldData, entityName, recordID, entityInfo, type, user, wrapRecordIdInQuotes, restoreContext, cloneContext) {
|
|
1598
|
+
return this.renderLogRecordChangeSQL(newData, oldData, entityName, recordID, entityInfo, type, user, wrapRecordIdInQuotes, restoreContext, cloneContext);
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* Renders the `spCreateRecordChange_Internal` call for `GetLogRecordChangeSQL` (literal form) and
|
|
1602
|
+
* for the delete batch (`params` supplied, so the payload values bind as typed inputs).
|
|
1603
|
+
*/
|
|
1604
|
+
renderLogRecordChangeSQL(newData, oldData, entityName, recordID, entityInfo, type, user, wrapRecordIdInQuotes, restoreContext, cloneContext, params) {
|
|
1463
1605
|
// Dialect-agnostic payload assembly is hoisted into DatabaseProviderBase
|
|
1464
1606
|
// so SQL Server and PostgreSQL share one implementation. We only render
|
|
1465
1607
|
// the T-SQL EXEC wrapper here.
|
|
1466
1608
|
const payload = this.BuildRecordChangePayload(newData, oldData, String(recordID), entityInfo, type, user, restoreContext, "'", cloneContext);
|
|
1467
1609
|
if (!payload)
|
|
1468
1610
|
return null;
|
|
1469
|
-
const
|
|
1470
|
-
const
|
|
1471
|
-
|
|
1472
|
-
@Source='Restore',
|
|
1473
|
-
@RestoredFromID='${payload.restoredFromID}',
|
|
1474
|
-
@RestoreReason=${payload.restoreReason ? `N'${payload.restoreReason.replace(/'/g, "''")}'` : 'NULL'}`
|
|
1475
|
-
: payload.source === 'Clone'
|
|
1476
|
-
? `,
|
|
1477
|
-
@Source='Clone',
|
|
1478
|
-
@ChangeContext=N'${EscapeSQLString(payload.changeContext)}'`
|
|
1479
|
-
: '';
|
|
1480
|
-
return `EXEC [${this.MJCoreSchemaName}].spCreateRecordChange_Internal @EntityName='${entityName}',
|
|
1481
|
-
@RecordID=${quotes}${recordID}${quotes},
|
|
1482
|
-
@UserID='${payload.userID}',
|
|
1483
|
-
@Type='${payload.type}',
|
|
1484
|
-
@ChangesJSON='${payload.changesJSON}',
|
|
1485
|
-
@ChangesDescription='${payload.changesDescription}',
|
|
1486
|
-
@FullRecordJSON='${payload.fullRecordJSON}',
|
|
1487
|
-
@Status='Complete',
|
|
1488
|
-
@Comments=null${lineageClause}`;
|
|
1611
|
+
const recordIDSQL = wrapRecordIdInQuotes ? `N'${EscapeSQLString(String(recordID))}'` : String(recordID);
|
|
1612
|
+
const boundRecordID = params && wrapRecordIdInQuotes ? params.AddField(this.recordChangeField('RecordID'), recordIDSQL) : recordIDSQL;
|
|
1613
|
+
return this.renderRecordChangeEXEC(entityName, boundRecordID, payload, params);
|
|
1489
1614
|
}
|
|
1490
1615
|
/**
|
|
1491
1616
|
* Implements the abstract BuildRecordChangeSQL from DatabaseProviderBase.
|
|
@@ -1510,19 +1635,30 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1510
1635
|
return result.fullSQL;
|
|
1511
1636
|
}
|
|
1512
1637
|
/**
|
|
1513
|
-
*
|
|
1514
|
-
*
|
|
1638
|
+
* Generates the delete statement with both full SQL (including record changes) and simple SQL.
|
|
1639
|
+
* With `params`, primary-key and Record Change values are bound as `@p0, @p1, …` and their
|
|
1640
|
+
* literals are kept on the parameters for `RenderSaveSQL`.
|
|
1641
|
+
* For a TrackRecordChanges entity with CascadeDeletes, the full SQL runs the proc uncaptured and selects the key twice:
|
|
1642
|
+
* first whether the row existed (read by transaction groups), last whether it was deleted (read by direct execution).
|
|
1515
1643
|
*/
|
|
1516
|
-
getDeleteSQLWithDetails(entity, user, skipRecordChanges = false) {
|
|
1517
|
-
let sSQL = '';
|
|
1644
|
+
getDeleteSQLWithDetails(entity, user, skipRecordChanges = false, params) {
|
|
1518
1645
|
const spName = entity.EntityInfo.spDelete ? entity.EntityInfo.spDelete : `spDelete${entity.EntityInfo.BaseTableCodeName}`;
|
|
1519
|
-
const
|
|
1520
|
-
const f = entity.EntityInfo.
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1646
|
+
const pkRefs = entity.PrimaryKey.KeyValuePairs.map((kv) => {
|
|
1647
|
+
const f = entity.EntityInfo.FieldByName(kv.FieldName);
|
|
1648
|
+
if (!f) {
|
|
1649
|
+
throw new Error(`SQLServerDataProvider.getDeleteSQLWithDetails: primary key field "${kv.FieldName}" not found on "${entity.EntityInfo.Name}"`);
|
|
1650
|
+
}
|
|
1651
|
+
// Quoted keys are escaped by generateSetStatementValue; an unquoted (numeric) key must be a plain number.
|
|
1652
|
+
const literal = f.NeedsQuotes
|
|
1653
|
+
? this.generateSetStatementValue(f, kv.Value)
|
|
1654
|
+
: SQLServerDataProvider.RenderKeyValueLiteral(kv.Value, false, f.Name, entity.EntityInfo.Name);
|
|
1655
|
+
// One reference per key, reused by every statement below that names the key.
|
|
1656
|
+
return { Field: f, Ref: params ? params.AddField(f, literal) : literal };
|
|
1657
|
+
});
|
|
1658
|
+
const sParams = pkRefs.map((pk) => `@${pk.Field.CodeName}=${pk.Ref}`).join(', ');
|
|
1524
1659
|
const sSimpleSQL = `EXEC [${entity.EntityInfo.SchemaName}].[${spName}] ${sParams}`;
|
|
1525
|
-
const recordChangesEntityInfo = this.
|
|
1660
|
+
const recordChangesEntityInfo = this.EntityByName('MJ: Record Changes');
|
|
1661
|
+
let sSQL;
|
|
1526
1662
|
if (entity.EntityInfo.TrackRecordChanges && !skipRecordChanges && entity.EntityInfo.Name.trim().toLowerCase() !== 'record changes') {
|
|
1527
1663
|
// don't track changes for the record changes entity
|
|
1528
1664
|
const oldData = entity.GetAll(true); // get all the OLD values
|
|
@@ -1542,6 +1678,40 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1542
1678
|
const sReturnList = entity.PrimaryKeys.map((pk) => {
|
|
1543
1679
|
return `@${pk.CodeName} AS [${pk.Name}]`;
|
|
1544
1680
|
}).join(', ');
|
|
1681
|
+
const recordChangeSQL = this.renderLogRecordChangeSQL(null /*pass in null for new data for deleted records*/, oldData, entity.EntityInfo.Name, sCombinedPrimaryKey, entity.EntityInfo, 'Delete', user, true, entity.RestoreContext, entity.CloneContext, params);
|
|
1682
|
+
if (entity.EntityInfo.CascadeDeletes) {
|
|
1683
|
+
// A cascading spDelete EXECs other procs (spUpdate* to null references, spDelete* for
|
|
1684
|
+
// children) and each emits its own result set, spUpdate*'s being the full row. Capturing the
|
|
1685
|
+
// call with INSERT INTO @ResultTable EXEC takes every one of them, so the first full row fails
|
|
1686
|
+
// with error 213. Run the proc uncaptured instead and judge success by whether the row is
|
|
1687
|
+
// gone. The key is selected first (transaction groups read the first result set) and last
|
|
1688
|
+
// (direct execution reads the last, see ValidateDeleteResult); NULL when nothing was deleted.
|
|
1689
|
+
// The checks read the base view, not the table: runtime logins hold SELECT on base views
|
|
1690
|
+
// only, and for a soft-delete entity the view hides the row spDelete just soft-deleted.
|
|
1691
|
+
const sView = `[${entity.EntityInfo.SchemaName}].[${entity.EntityInfo.BaseView}]`;
|
|
1692
|
+
const sWhere = pkRefs.map((pk) => `[${pk.Field.Name}]=${pk.Ref}`).join(' AND ');
|
|
1693
|
+
const sKeyWhen = (flag) => pkRefs.map((pk) => `CASE WHEN ${flag} = 1 THEN ${pk.Ref} END AS [${pk.Field.Name}]`).join(', ');
|
|
1694
|
+
sSQL = `
|
|
1695
|
+
DECLARE @MJDeleteRowExisted BIT = CASE WHEN EXISTS (SELECT 1 FROM ${sView} WHERE ${sWhere}) THEN 1 ELSE 0 END
|
|
1696
|
+
SELECT ${sKeyWhen('@MJDeleteRowExisted')}
|
|
1697
|
+
|
|
1698
|
+
IF @MJDeleteRowExisted = 1
|
|
1699
|
+
${sSimpleSQL}
|
|
1700
|
+
|
|
1701
|
+
DECLARE @MJDeleteRowDeleted BIT = CASE WHEN @MJDeleteRowExisted = 1 AND NOT EXISTS (SELECT 1 FROM ${sView} WHERE ${sWhere}) THEN 1 ELSE 0 END
|
|
1702
|
+
IF @MJDeleteRowDeleted = 1
|
|
1703
|
+
BEGIN
|
|
1704
|
+
DECLARE @ResultChangesTable TABLE (
|
|
1705
|
+
${this.getAllEntityColumnsSQL(recordChangesEntityInfo)}
|
|
1706
|
+
)
|
|
1707
|
+
|
|
1708
|
+
INSERT INTO @ResultChangesTable
|
|
1709
|
+
${recordChangeSQL}
|
|
1710
|
+
END
|
|
1711
|
+
|
|
1712
|
+
SELECT ${sKeyWhen('@MJDeleteRowDeleted')}`;
|
|
1713
|
+
return { fullSQL: sSQL, simpleSQL: sSimpleSQL };
|
|
1714
|
+
}
|
|
1545
1715
|
sSQL = `
|
|
1546
1716
|
IF OBJECT_ID('tempdb..#ResultTable') IS NOT NULL
|
|
1547
1717
|
DROP TABLE #ResultTable
|
|
@@ -1562,7 +1732,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1562
1732
|
)
|
|
1563
1733
|
|
|
1564
1734
|
INSERT INTO @ResultChangesTable
|
|
1565
|
-
${
|
|
1735
|
+
${recordChangeSQL}
|
|
1566
1736
|
END
|
|
1567
1737
|
|
|
1568
1738
|
SELECT ${sReturnList}`;
|
|
@@ -1585,10 +1755,22 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1585
1755
|
// WrapSaveCallWithRecordChange (see this file's "Save Grammar" section
|
|
1586
1756
|
// above). See plans/sp-save-builder-generic-layer-refactor.md (rev 4).
|
|
1587
1757
|
GenerateDeleteSQL(entity, user, options) {
|
|
1588
|
-
const
|
|
1758
|
+
const params = new SaveParameterCollector();
|
|
1759
|
+
const sqlDetails = this.getDeleteSQLWithDetails(entity, user, options?.SkipRecordChanges === true, params);
|
|
1760
|
+
const fragment = this.toSaveFragment(entity, sqlDetails.fullSQL, params.Parameters, '');
|
|
1761
|
+
let cachedSimpleSQL;
|
|
1589
1762
|
return {
|
|
1590
|
-
fullSQL:
|
|
1591
|
-
simpleSQL
|
|
1763
|
+
fullSQL: fragment.sql,
|
|
1764
|
+
get simpleSQL() {
|
|
1765
|
+
if (cachedSimpleSQL === undefined) {
|
|
1766
|
+
cachedSimpleSQL = RenderSaveSQL(sqlDetails.simpleSQL, params.Parameters, '');
|
|
1767
|
+
}
|
|
1768
|
+
return cachedSimpleSQL;
|
|
1769
|
+
},
|
|
1770
|
+
parameters: fragment.parameters ?? null,
|
|
1771
|
+
get literalSQL() {
|
|
1772
|
+
return fragment.literalSQL;
|
|
1773
|
+
},
|
|
1592
1774
|
};
|
|
1593
1775
|
}
|
|
1594
1776
|
OnSuspendRefresh() {
|
|
@@ -1713,6 +1895,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1713
1895
|
}
|
|
1714
1896
|
// For transactional queries, use the instance queue to ensure serialization
|
|
1715
1897
|
// This prevents EREQINPROG errors when multiple queries try to use the same transaction
|
|
1898
|
+
this.initializeQueueProcessor();
|
|
1716
1899
|
return new Promise((resolve, reject) => {
|
|
1717
1900
|
this._sqlQueue$.next({
|
|
1718
1901
|
kind: 'query',
|
|
@@ -1774,21 +1957,35 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1774
1957
|
this.AssertAmbientTransactionUsable();
|
|
1775
1958
|
transaction = this._transaction;
|
|
1776
1959
|
}
|
|
1960
|
+
const shouldLogSql = !loggingOptions?.ignoreLogging && (this.HasActiveSqlLoggingSessions ||
|
|
1961
|
+
Object.prototype.hasOwnProperty.call(this, '_logSqlStatement') ||
|
|
1962
|
+
Object.getPrototypeOf(this) !== SQLServerDataProvider.prototype);
|
|
1963
|
+
if (shouldLogSql && !this._boundLogSqlStatement) {
|
|
1964
|
+
this._boundLogSqlStatement = this._logSqlStatement.bind(this);
|
|
1965
|
+
}
|
|
1966
|
+
if (!this._boundClearTransaction) {
|
|
1967
|
+
this._boundClearTransaction = () => {
|
|
1968
|
+
this._transaction = null;
|
|
1969
|
+
};
|
|
1970
|
+
}
|
|
1777
1971
|
// Create the execution context
|
|
1778
1972
|
const context = {
|
|
1779
1973
|
pool: this._pool,
|
|
1780
1974
|
transaction: transaction,
|
|
1781
|
-
logSqlStatement: this.
|
|
1782
|
-
clearTransaction:
|
|
1783
|
-
this._transaction = null;
|
|
1784
|
-
}
|
|
1975
|
+
logSqlStatement: shouldLogSql ? this._boundLogSqlStatement : undefined,
|
|
1976
|
+
clearTransaction: this._boundClearTransaction
|
|
1785
1977
|
};
|
|
1786
1978
|
// Convert logging options to internal format
|
|
1787
1979
|
const options = loggingOptions ? {
|
|
1788
1980
|
description: loggingOptions.description,
|
|
1789
1981
|
ignoreLogging: loggingOptions.ignoreLogging,
|
|
1790
1982
|
isMutation: loggingOptions.isMutation,
|
|
1791
|
-
simpleSQLFallback
|
|
1983
|
+
get simpleSQLFallback() {
|
|
1984
|
+
return loggingOptions.simpleSQLFallback;
|
|
1985
|
+
},
|
|
1986
|
+
get literalSQL() {
|
|
1987
|
+
return loggingOptions.literalSQL;
|
|
1988
|
+
},
|
|
1792
1989
|
contextUser: loggingOptions.contextUser,
|
|
1793
1990
|
// A statement inside a transaction follows the transaction's limits (see ExecuteSQLOptions).
|
|
1794
1991
|
timeoutMs: transaction ? undefined : loggingOptions.timeoutMs
|
|
@@ -1811,7 +2008,12 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1811
2008
|
description: options?.description,
|
|
1812
2009
|
ignoreLogging: options?.ignoreLogging,
|
|
1813
2010
|
isMutation: options?.isMutation,
|
|
1814
|
-
simpleSQLFallback
|
|
2011
|
+
get simpleSQLFallback() {
|
|
2012
|
+
return options?.simpleSQLFallback;
|
|
2013
|
+
},
|
|
2014
|
+
get literalSQL() {
|
|
2015
|
+
return options?.literalSQL;
|
|
2016
|
+
},
|
|
1815
2017
|
contextUser: contextUser,
|
|
1816
2018
|
ignoreAmbientTransaction: options?.ignoreAmbientTransaction,
|
|
1817
2019
|
timeoutMs: options?.timeoutMs,
|
|
@@ -2118,8 +2320,19 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
2118
2320
|
if (this._datetimeOffsetTestComplete) {
|
|
2119
2321
|
return this._needsDatetimeOffsetAdjustment;
|
|
2120
2322
|
}
|
|
2121
|
-
//
|
|
2122
|
-
|
|
2323
|
+
// A recent probe failed: keep its safe default until the backoff passes, rather than re-probing
|
|
2324
|
+
// (and logging the failure again) on every call while the database is unreachable.
|
|
2325
|
+
const failedAt = SQLServerDataProvider._datetimeOffsetTestFailedAt;
|
|
2326
|
+
if (failedAt !== null && Date.now() - failedAt < SQLServerDataProvider.DATETIMEOFFSET_PROBE_RETRY_MS) {
|
|
2327
|
+
return this._needsDatetimeOffsetAdjustment;
|
|
2328
|
+
}
|
|
2329
|
+
// Deduplicate concurrent tests across per-request provider instances
|
|
2330
|
+
if (!SQLServerDataProvider._datetimeOffsetTestPromise) {
|
|
2331
|
+
SQLServerDataProvider._datetimeOffsetTestPromise = this.testDatetimeOffsetHandling().finally(() => {
|
|
2332
|
+
SQLServerDataProvider._datetimeOffsetTestPromise = null;
|
|
2333
|
+
});
|
|
2334
|
+
}
|
|
2335
|
+
await SQLServerDataProvider._datetimeOffsetTestPromise;
|
|
2123
2336
|
return this._needsDatetimeOffsetAdjustment;
|
|
2124
2337
|
}
|
|
2125
2338
|
/**
|
|
@@ -2164,13 +2377,15 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
2164
2377
|
}
|
|
2165
2378
|
// Mark the test as complete
|
|
2166
2379
|
this._datetimeOffsetTestComplete = true;
|
|
2380
|
+
SQLServerDataProvider._datetimeOffsetTestFailedAt = null;
|
|
2167
2381
|
}
|
|
2168
2382
|
catch (e) {
|
|
2169
2383
|
// If the test fails, log the error but don't prevent initialization
|
|
2170
2384
|
console.error('SQLServerDataProvider: Failed to test datetimeoffset handling:', e);
|
|
2171
|
-
// Default to assuming adjustment is needed for safety
|
|
2385
|
+
// Default to assuming adjustment is needed for safety on this provider,
|
|
2386
|
+
// but leave _datetimeOffsetTestComplete false so a call after the backoff retries.
|
|
2172
2387
|
this._needsDatetimeOffsetAdjustment = true;
|
|
2173
|
-
|
|
2388
|
+
SQLServerDataProvider._datetimeOffsetTestFailedAt = Date.now();
|
|
2174
2389
|
}
|
|
2175
2390
|
}
|
|
2176
2391
|
/**
|
|
@@ -2247,12 +2462,12 @@ DECLARE ${varName} NVARCHAR(MAX) = (
|
|
|
2247
2462
|
);
|
|
2248
2463
|
IF ${varName} IS NOT NULL
|
|
2249
2464
|
EXEC [${this.MJCoreSchemaName}].spCreateRecordChange_Internal
|
|
2250
|
-
@EntityName='${safeEntityName}',
|
|
2251
|
-
@RecordID='${recordID}',
|
|
2465
|
+
@EntityName=N'${safeEntityName}',
|
|
2466
|
+
@RecordID=N'${recordID}',
|
|
2252
2467
|
@UserID='${safeUserId}',
|
|
2253
2468
|
@Type='Update',
|
|
2254
|
-
@ChangesJSON='${safeChangesJSON}',
|
|
2255
|
-
@ChangesDescription='${safeChangesDesc}',
|
|
2469
|
+
@ChangesJSON=N'${safeChangesJSON}',
|
|
2470
|
+
@ChangesDescription=N'${safeChangesDesc}',
|
|
2256
2471
|
@FullRecordJSON=${varName},
|
|
2257
2472
|
@Status='Complete',
|
|
2258
2473
|
@Comments=NULL${lineageClause};`;
|
|
@@ -2305,6 +2520,7 @@ IF ${varName} IS NOT NULL
|
|
|
2305
2520
|
* that queued ambient queries behind it are rejected rather than run on the doomed handle.
|
|
2306
2521
|
*/
|
|
2307
2522
|
enqueueTransactionAction(description, handle, run) {
|
|
2523
|
+
this.initializeQueueProcessor();
|
|
2308
2524
|
return new Promise((resolve, reject) => {
|
|
2309
2525
|
this._sqlQueue$.next({ kind: 'action', description, handle, run, resolve, reject });
|
|
2310
2526
|
});
|