@memberjunction/sqlserver-dataprovider 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/README.md +7 -0
- package/dist/SQLServerDataProvider.d.ts +102 -10
- package/dist/SQLServerDataProvider.d.ts.map +1 -1
- package/dist/SQLServerDataProvider.js +411 -151
- 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) {
|
|
@@ -669,8 +687,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
669
687
|
// unconditional GetEffectiveBaseView) so a Building/DriftHold/Disabled/never-minted snapshot probes the
|
|
670
688
|
// LIVE base view — mirroring the read path — instead of a held or missing materialized_vw wrapper.
|
|
671
689
|
const effectiveView = await this.resolveEffectiveBaseView(entityInfo, item.params, contextUser);
|
|
672
|
-
|
|
673
|
-
sqlStatements.push(statusSQL);
|
|
690
|
+
sqlStatements.push(this.BuildCacheStatusSQL(entityInfo, effectiveView, whereSQL));
|
|
674
691
|
}
|
|
675
692
|
try {
|
|
676
693
|
// Execute the batched SQL using existing ExecuteSQLBatch method
|
|
@@ -693,11 +710,15 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
693
710
|
}
|
|
694
711
|
}
|
|
695
712
|
catch (e) {
|
|
696
|
-
//
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
713
|
+
// One bad statement fails the WHOLE batch on SQL Server, which used to mark every item
|
|
714
|
+
// failed — so a single problematic entity blinded the sweep for its entire engine. Fall back
|
|
715
|
+
// to the per-item path, which runs each probe on its own and fails only what deserves it
|
|
716
|
+
//.
|
|
717
|
+
LogStatusEx({
|
|
718
|
+
message: `SQLServerDataProvider.getBatchedServerCacheStatus: batched probe failed (${e instanceof Error ? e.message : String(e)}) — retrying ${items.length} probe(s) individually`,
|
|
719
|
+
verboseOnly: true,
|
|
720
|
+
});
|
|
721
|
+
return await super.getBatchedServerCacheStatus(items, contextUser);
|
|
701
722
|
}
|
|
702
723
|
return results;
|
|
703
724
|
}
|
|
@@ -888,9 +909,11 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
888
909
|
// SQLServerTransactionGroup-batched saves regenerate per-item SQL when
|
|
889
910
|
// transaction variables get rebound. `_spName` is preserved for back-compat
|
|
890
911
|
// with that caller; the dialect-driven pipeline derives it from entity
|
|
891
|
-
// metadata via `GetCreateUpdateSPName`.
|
|
912
|
+
// metadata via `GetCreateUpdateSPName`. Outside a TransactionGroup,
|
|
913
|
+
// `fullSQL` holds `@p$0` parameter placeholders, so return `literalSQL`
|
|
914
|
+
// for standalone callers.
|
|
892
915
|
const result = await this.GenerateSaveSQL(entity, bNewRecord, user);
|
|
893
|
-
return result.fullSQL;
|
|
916
|
+
return result.literalSQL ?? result.fullSQL;
|
|
894
917
|
}
|
|
895
918
|
/**
|
|
896
919
|
* Queue the AI action task only once the save is durable: through {@link RunAfterCommit}, so it
|
|
@@ -903,6 +926,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
903
926
|
await QueueManager.AddTask('Entity AI Action', params, null, user);
|
|
904
927
|
}, 'Entity AI Action', postCommitToken);
|
|
905
928
|
}
|
|
929
|
+
static { this._entityColumnsSQLCache = new WeakMap(); }
|
|
906
930
|
getAllEntityColumnsSQL(entityInfo) {
|
|
907
931
|
// This builds the column list for the @ResultTable/@ResultChangesTable that captures
|
|
908
932
|
// `SELECT * FROM <BaseView>` via a POSITIONAL `INSERT INTO @table EXEC ...`. SQL Server maps the
|
|
@@ -927,10 +951,17 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
927
951
|
// — the only case where a base-column-after-a-join could mis-route by position — we WARN loudly
|
|
928
952
|
// (warnOnViewOrderFallback), because the metadata order can no longer be checked against the view and
|
|
929
953
|
// a silent fallback there would let the very bug this fix prevents persist undetected.
|
|
930
|
-
let orderedFields;
|
|
931
|
-
let fallbackReason;
|
|
932
954
|
const viewKey = `${entityInfo.SchemaName}.${entityInfo.BaseView}`.toLowerCase();
|
|
933
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;
|
|
934
965
|
if (!viewOrder) {
|
|
935
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)`;
|
|
936
967
|
}
|
|
@@ -938,16 +969,16 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
938
969
|
fallbackReason = `base view exposes ${viewOrder.length} column(s) but the entity declares ${entityInfo.Fields.length} field(s) — cannot align 1:1 by position`;
|
|
939
970
|
}
|
|
940
971
|
else {
|
|
941
|
-
const
|
|
942
|
-
|
|
943
|
-
if (mapped.every((f) => f !== undefined)) {
|
|
972
|
+
const mapped = viewOrder.map((colName) => entityInfo.FieldByName(colName));
|
|
973
|
+
if (mapped.every((f) => f !== null && f !== undefined)) {
|
|
944
974
|
orderedFields = mapped; // every view column resolved 1:1 to a field
|
|
945
975
|
}
|
|
946
976
|
else {
|
|
947
|
-
const unmatched = viewOrder.filter((colName) => !
|
|
977
|
+
const unmatched = viewOrder.filter((colName) => !entityInfo.FieldByName(colName));
|
|
948
978
|
fallbackReason = `view column(s) without a matching EntityField: ${unmatched.join(', ')}`;
|
|
949
979
|
}
|
|
950
980
|
}
|
|
981
|
+
const usedFallback = !orderedFields;
|
|
951
982
|
if (!orderedFields) {
|
|
952
983
|
this.warnIfBaseFieldSequencedAfterVirtual(entityInfo);
|
|
953
984
|
this.warnOnViewOrderFallback(entityInfo, fallbackReason);
|
|
@@ -960,8 +991,35 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
960
991
|
sRet += '[' + f.Name + '] ' + f.SQLFullType + ' ' + (f.AllowsNull || f.IsVirtual ? 'NULL' : 'NOT NULL');
|
|
961
992
|
outputCount++;
|
|
962
993
|
}
|
|
994
|
+
SQLServerDataProvider._entityColumnsSQLCache.set(entityInfo, {
|
|
995
|
+
viewOrderRef: viewOrder,
|
|
996
|
+
fieldCount: entityInfo.Fields.length,
|
|
997
|
+
usedFallback,
|
|
998
|
+
sql: sRet,
|
|
999
|
+
});
|
|
963
1000
|
return sRet;
|
|
964
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
|
+
}
|
|
965
1023
|
/** Entities already warned about base-after-virtual field-order skew — so we warn once, not every save. */
|
|
966
1024
|
static { this._fieldOrderWarnedEntities = new Set(); }
|
|
967
1025
|
/**
|
|
@@ -1060,7 +1118,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1060
1118
|
if (value && field.Type.trim().toLowerCase() === 'datetimeoffset') {
|
|
1061
1119
|
return { kind: 'use', value: new Date(value).toISOString() };
|
|
1062
1120
|
}
|
|
1063
|
-
if (!isUpdate && field.
|
|
1121
|
+
if (!isUpdate && field.IsUniqueIdentifier && !field.IsPrimaryKey) {
|
|
1064
1122
|
if (typeof value === 'string' && value.includes('()')) {
|
|
1065
1123
|
return { kind: 'skip' };
|
|
1066
1124
|
}
|
|
@@ -1080,7 +1138,53 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1080
1138
|
* `EntityFieldInfo.NeedsClearCompanion`.
|
|
1081
1139
|
*/
|
|
1082
1140
|
RenderSaveCallBinding(entity, fieldValues, isUpdate, _spName) {
|
|
1083
|
-
|
|
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;
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
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.
|
|
1162
|
+
*/
|
|
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) {
|
|
1084
1188
|
const declarations = [];
|
|
1085
1189
|
const setStatements = [];
|
|
1086
1190
|
const execParams = [];
|
|
@@ -1094,7 +1198,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1094
1198
|
const hasValue = value !== null && value !== undefined;
|
|
1095
1199
|
const binaryLiteral = hasValue && f.IsBinaryFieldType ? this.FormatBinaryLiteral(f, value) : undefined;
|
|
1096
1200
|
if (hasValue) {
|
|
1097
|
-
setStatements.push(`SET ${varName} = ${binaryLiteral ?? this.generateSetStatementValue(f, value)}`);
|
|
1201
|
+
setStatements.push(`SET ${varName} = ${params.AddField(f, binaryLiteral ?? this.generateSetStatementValue(f, value))}`);
|
|
1098
1202
|
}
|
|
1099
1203
|
execParams.push(`@${f.CodeName}=${varName}`);
|
|
1100
1204
|
simpleParams += this.generateSingleSPParam(f, value, bFirst, binaryLiteral);
|
|
@@ -1107,12 +1211,12 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1107
1211
|
// UPDATE: tail-append PK declarations from the loaded entity's PrimaryKey.
|
|
1108
1212
|
if (isUpdate && execParams.length > 0) {
|
|
1109
1213
|
for (const pkv of entity.PrimaryKey.KeyValuePairs) {
|
|
1110
|
-
const f = entity.EntityInfo.
|
|
1214
|
+
const f = entity.EntityInfo.FieldByName(pkv.FieldName);
|
|
1111
1215
|
if (!f)
|
|
1112
1216
|
continue;
|
|
1113
1217
|
const varName = `@${f.CodeName}${uniqueSuffix}`;
|
|
1114
1218
|
declarations.push(`${varName} ${f.SQLFullType.toUpperCase()}`);
|
|
1115
|
-
setStatements.push(`SET ${varName} = ${this.generateSetStatementValue(f, pkv.Value)}`);
|
|
1219
|
+
setStatements.push(`SET ${varName} = ${params.AddField(f, this.generateSetStatementValue(f, pkv.Value))}`);
|
|
1116
1220
|
execParams.push(`@${f.CodeName}=${varName}`);
|
|
1117
1221
|
const pkeyQuotes = f.NeedsQuotes ? "'" : '';
|
|
1118
1222
|
simpleParams += `, @${f.CodeName} = ${pkeyQuotes}${pkv.Value}${pkeyQuotes}`;
|
|
@@ -1155,23 +1259,46 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1155
1259
|
* there is nothing to look up).
|
|
1156
1260
|
*/
|
|
1157
1261
|
RenderReplaySaveSQL(binding, entity, fieldValues) {
|
|
1158
|
-
|
|
1159
|
-
throw new Error(`SQLServerDataProvider.RenderReplaySaveSQL: unexpected binding kind '${binding.kind}'`);
|
|
1160
|
-
}
|
|
1262
|
+
const saveBinding = this.asSaveBinding(binding, 'RenderReplaySaveSQL');
|
|
1161
1263
|
const info = entity.EntityInfo;
|
|
1162
1264
|
const pks = info.PrimaryKeys;
|
|
1163
1265
|
if (pks.length === 0 || !pks.every((pk) => fieldValues.has(pk))) {
|
|
1164
1266
|
return undefined;
|
|
1165
1267
|
}
|
|
1166
1268
|
const schema = info.SchemaName;
|
|
1167
|
-
const where = pks.map((pk) => `[${pk.Name}] = @${pk.CodeName}${
|
|
1269
|
+
const where = pks.map((pk) => `[${pk.Name}] = @${pk.CodeName}${saveBinding.suffix}`).join(' AND ');
|
|
1168
1270
|
const createSpName = this.GetCreateUpdateSPName(entity, true);
|
|
1169
|
-
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`;
|
|
1170
1272
|
const hasUpdateProc = info.AllowUpdateAPI && info.spUpdateGenerated && !info.VirtualEntity;
|
|
1171
1273
|
const updateBranch = hasUpdateProc
|
|
1172
|
-
? `\nELSE\nBEGIN\n EXEC [${schema}].${this.GetCreateUpdateSPName(entity, false)} ${
|
|
1274
|
+
? `\nELSE\nBEGIN\n EXEC [${schema}].${this.GetCreateUpdateSPName(entity, false)} ${saveBinding.callArgsSQL}\nEND`
|
|
1173
1275
|
: '';
|
|
1174
|
-
return `${this.renderDeclareSetHead(
|
|
1276
|
+
return RenderSaveSQL(`${this.renderDeclareSetHead(saveBinding)}${createBranch}${updateBranch}`, saveBinding.parameters, saveBinding.recordSuffix);
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Replay form of an UPDATE for the SQL log (never executed): `EXEC spUpdate` with only the
|
|
1280
|
+
* fields this save changed, plus the primary key. The update procs keep every column whose
|
|
1281
|
+
* parameter is not passed (`ISNULL(@p, [Col])`), so replaying the recording applies exactly
|
|
1282
|
+
* the change that was made and leaves the target's other columns — including settings a
|
|
1283
|
+
* consumer tuned on an MJ-owned row — alone. A field changed to NULL still carries its
|
|
1284
|
+
* `_Clear` companion (derived from the same map). A save whose changes touch no proc
|
|
1285
|
+
* parameter has nothing to replay, so it is logged as a comment. A hand-written update proc
|
|
1286
|
+
* (spUpdateGenerated off) makes no ISNULL promise, so it keeps the full-row form.
|
|
1287
|
+
*/
|
|
1288
|
+
RenderReplayUpdateSQL(binding, entity, changedFieldValues) {
|
|
1289
|
+
const saveBinding = this.asSaveBinding(binding, 'RenderReplayUpdateSQL');
|
|
1290
|
+
const info = entity.EntityInfo;
|
|
1291
|
+
if (!info.spUpdateGenerated) {
|
|
1292
|
+
return undefined;
|
|
1293
|
+
}
|
|
1294
|
+
if (changedFieldValues.size === 0) {
|
|
1295
|
+
const key = entity.PrimaryKey.ToString().replace(/[\r\n]+/g, ' ');
|
|
1296
|
+
return `-- ${info.Name} (${key}): saved with no field changes; nothing to replay`;
|
|
1297
|
+
}
|
|
1298
|
+
const params = new SaveParameterCollector();
|
|
1299
|
+
const changed = this.renderSaveCallBindingWithSuffix(entity, changedFieldValues, true, saveBinding.suffix, params);
|
|
1300
|
+
const spName = this.GetCreateUpdateSPName(entity, false);
|
|
1301
|
+
return RenderSaveSQL(`${this.renderDeclareSetHead(changed)}EXEC [${info.SchemaName}].${spName} ${changed.callArgsSQL}`, params.Parameters, saveBinding.recordSuffix);
|
|
1175
1302
|
}
|
|
1176
1303
|
/** `DECLARE ...\n\nSET ...\n\n` when the binding declares variables, else empty. */
|
|
1177
1304
|
renderDeclareSetHead(binding) {
|
|
@@ -1182,58 +1309,66 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1182
1309
|
* shape: `DECLARE ... ; SET ... ; EXEC [schema].sp callArgs`. When
|
|
1183
1310
|
* record-change tracking is on, this output is further wrapped by
|
|
1184
1311
|
* `WrapSaveCallWithRecordChange`.
|
|
1312
|
+
*
|
|
1313
|
+
* When `returnContext.needsDatestampProjection` is true (either passed by
|
|
1314
|
+
* `GenericDatabaseProvider.GenerateSaveSQL` or resolved from `options`),
|
|
1315
|
+
* delegates to {@link ProjectSaveCallToDatestamps}.
|
|
1185
1316
|
*/
|
|
1186
|
-
WrapSaveCallForResult(binding, entity, spName) {
|
|
1187
|
-
|
|
1188
|
-
|
|
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);
|
|
1189
1321
|
}
|
|
1190
|
-
const
|
|
1191
|
-
|
|
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);
|
|
1192
1338
|
}
|
|
1193
1339
|
/**
|
|
1194
1340
|
* Wraps the save SQL with SQL Server's @ResultTable + inline
|
|
1195
1341
|
* `spCreateRecordChange_Internal` EXEC pattern. The CONCAT-built `@ID`
|
|
1196
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`.
|
|
1197
1348
|
*/
|
|
1198
|
-
WrapSaveCallWithRecordChange(_saveSQL, binding, payload, entity) {
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
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');
|
|
1203
1353
|
const spName = this.GetCreateUpdateSPName(entity, payload.type === 'Create');
|
|
1204
1354
|
const concatPKIDString = `CONCAT(${entity.EntityInfo.PrimaryKeys
|
|
1205
1355
|
.map((pk) => `'${pk.CodeName}','${CompositeKey.DefaultValueDelimiter}',[${pk.Name}]`)
|
|
1206
1356
|
.join(`,'${CompositeKey.DefaultFieldDelimiter}',`)})`;
|
|
1207
1357
|
// Build the inline `EXEC spCreateRecordChange_Internal` from the payload,
|
|
1208
1358
|
// referencing `@ID` (set below from the result table).
|
|
1209
|
-
const
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
: payload.source === 'Clone'
|
|
1215
|
-
? `,
|
|
1216
|
-
@Source='Clone',
|
|
1217
|
-
@ChangeContext=N'${EscapeSQLString(payload.changeContext)}'`
|
|
1218
|
-
: '';
|
|
1219
|
-
const recordChangeEXEC = `EXEC [${this.MJCoreSchemaName}].spCreateRecordChange_Internal @EntityName='${entity.EntityInfo.Name}',
|
|
1220
|
-
@RecordID=@ID,
|
|
1221
|
-
@UserID='${payload.userID}',
|
|
1222
|
-
@Type='${payload.type}',
|
|
1223
|
-
@ChangesJSON='${payload.changesJSON}',
|
|
1224
|
-
@ChangesDescription='${payload.changesDescription}',
|
|
1225
|
-
@FullRecordJSON='${payload.fullRecordJSON}',
|
|
1226
|
-
@Status='Complete',
|
|
1227
|
-
@Comments=null${lineageClause}`;
|
|
1228
|
-
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;`;
|
|
1229
1364
|
const sql = `
|
|
1230
|
-
${
|
|
1365
|
+
${saveBinding.preambleSQL}
|
|
1231
1366
|
|
|
1232
1367
|
DECLARE @ResultTable TABLE (
|
|
1233
|
-
${
|
|
1368
|
+
${resultTableColumnsSQL}
|
|
1234
1369
|
);
|
|
1235
1370
|
|
|
1236
|
-
${
|
|
1371
|
+
${saveBinding.setSQL}
|
|
1237
1372
|
|
|
1238
1373
|
INSERT INTO @ResultTable
|
|
1239
1374
|
${execSQL};
|
|
@@ -1252,8 +1387,44 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1252
1387
|
${recordChangeEXEC};
|
|
1253
1388
|
END;
|
|
1254
1389
|
|
|
1255
|
-
|
|
1256
|
-
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;
|
|
1257
1428
|
}
|
|
1258
1429
|
/**
|
|
1259
1430
|
* Generates the value portion of a SET statement for a field
|
|
@@ -1424,32 +1595,22 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1424
1595
|
return unicodePrefix + quoteString + pVal + quoteString;
|
|
1425
1596
|
}
|
|
1426
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) {
|
|
1427
1605
|
// Dialect-agnostic payload assembly is hoisted into DatabaseProviderBase
|
|
1428
1606
|
// so SQL Server and PostgreSQL share one implementation. We only render
|
|
1429
1607
|
// the T-SQL EXEC wrapper here.
|
|
1430
1608
|
const payload = this.BuildRecordChangePayload(newData, oldData, String(recordID), entityInfo, type, user, restoreContext, "'", cloneContext);
|
|
1431
1609
|
if (!payload)
|
|
1432
1610
|
return null;
|
|
1433
|
-
const
|
|
1434
|
-
const
|
|
1435
|
-
|
|
1436
|
-
@Source='Restore',
|
|
1437
|
-
@RestoredFromID='${payload.restoredFromID}',
|
|
1438
|
-
@RestoreReason=${payload.restoreReason ? `N'${payload.restoreReason.replace(/'/g, "''")}'` : 'NULL'}`
|
|
1439
|
-
: payload.source === 'Clone'
|
|
1440
|
-
? `,
|
|
1441
|
-
@Source='Clone',
|
|
1442
|
-
@ChangeContext=N'${EscapeSQLString(payload.changeContext)}'`
|
|
1443
|
-
: '';
|
|
1444
|
-
return `EXEC [${this.MJCoreSchemaName}].spCreateRecordChange_Internal @EntityName='${entityName}',
|
|
1445
|
-
@RecordID=${quotes}${recordID}${quotes},
|
|
1446
|
-
@UserID='${payload.userID}',
|
|
1447
|
-
@Type='${payload.type}',
|
|
1448
|
-
@ChangesJSON='${payload.changesJSON}',
|
|
1449
|
-
@ChangesDescription='${payload.changesDescription}',
|
|
1450
|
-
@FullRecordJSON='${payload.fullRecordJSON}',
|
|
1451
|
-
@Status='Complete',
|
|
1452
|
-
@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);
|
|
1453
1614
|
}
|
|
1454
1615
|
/**
|
|
1455
1616
|
* Implements the abstract BuildRecordChangeSQL from DatabaseProviderBase.
|
|
@@ -1474,19 +1635,30 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1474
1635
|
return result.fullSQL;
|
|
1475
1636
|
}
|
|
1476
1637
|
/**
|
|
1477
|
-
*
|
|
1478
|
-
*
|
|
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).
|
|
1479
1643
|
*/
|
|
1480
|
-
getDeleteSQLWithDetails(entity, user, skipRecordChanges = false) {
|
|
1481
|
-
let sSQL = '';
|
|
1644
|
+
getDeleteSQLWithDetails(entity, user, skipRecordChanges = false, params) {
|
|
1482
1645
|
const spName = entity.EntityInfo.spDelete ? entity.EntityInfo.spDelete : `spDelete${entity.EntityInfo.BaseTableCodeName}`;
|
|
1483
|
-
const
|
|
1484
|
-
const f = entity.EntityInfo.
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
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(', ');
|
|
1488
1659
|
const sSimpleSQL = `EXEC [${entity.EntityInfo.SchemaName}].[${spName}] ${sParams}`;
|
|
1489
|
-
const recordChangesEntityInfo = this.
|
|
1660
|
+
const recordChangesEntityInfo = this.EntityByName('MJ: Record Changes');
|
|
1661
|
+
let sSQL;
|
|
1490
1662
|
if (entity.EntityInfo.TrackRecordChanges && !skipRecordChanges && entity.EntityInfo.Name.trim().toLowerCase() !== 'record changes') {
|
|
1491
1663
|
// don't track changes for the record changes entity
|
|
1492
1664
|
const oldData = entity.GetAll(true); // get all the OLD values
|
|
@@ -1506,6 +1678,40 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1506
1678
|
const sReturnList = entity.PrimaryKeys.map((pk) => {
|
|
1507
1679
|
return `@${pk.CodeName} AS [${pk.Name}]`;
|
|
1508
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
|
+
}
|
|
1509
1715
|
sSQL = `
|
|
1510
1716
|
IF OBJECT_ID('tempdb..#ResultTable') IS NOT NULL
|
|
1511
1717
|
DROP TABLE #ResultTable
|
|
@@ -1526,7 +1732,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1526
1732
|
)
|
|
1527
1733
|
|
|
1528
1734
|
INSERT INTO @ResultChangesTable
|
|
1529
|
-
${
|
|
1735
|
+
${recordChangeSQL}
|
|
1530
1736
|
END
|
|
1531
1737
|
|
|
1532
1738
|
SELECT ${sReturnList}`;
|
|
@@ -1549,10 +1755,22 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1549
1755
|
// WrapSaveCallWithRecordChange (see this file's "Save Grammar" section
|
|
1550
1756
|
// above). See plans/sp-save-builder-generic-layer-refactor.md (rev 4).
|
|
1551
1757
|
GenerateDeleteSQL(entity, user, options) {
|
|
1552
|
-
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;
|
|
1553
1762
|
return {
|
|
1554
|
-
fullSQL:
|
|
1555
|
-
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
|
+
},
|
|
1556
1774
|
};
|
|
1557
1775
|
}
|
|
1558
1776
|
OnSuspendRefresh() {
|
|
@@ -1677,6 +1895,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1677
1895
|
}
|
|
1678
1896
|
// For transactional queries, use the instance queue to ensure serialization
|
|
1679
1897
|
// This prevents EREQINPROG errors when multiple queries try to use the same transaction
|
|
1898
|
+
this.initializeQueueProcessor();
|
|
1680
1899
|
return new Promise((resolve, reject) => {
|
|
1681
1900
|
this._sqlQueue$.next({
|
|
1682
1901
|
kind: 'query',
|
|
@@ -1719,7 +1938,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1719
1938
|
* @remarks
|
|
1720
1939
|
* - Always creates a new Request object for each query to avoid "EREQINPROG" errors
|
|
1721
1940
|
* - Handles both positional (?) and named (@param) parameter styles
|
|
1722
|
-
* -
|
|
1941
|
+
* - Uses the ambient transaction when the caller is part of it (#4786), otherwise uses connection pool
|
|
1723
1942
|
* - Handles SQL logging in parallel with query execution
|
|
1724
1943
|
* - Provides automatic retry with pool connection if transaction fails
|
|
1725
1944
|
*
|
|
@@ -1733,25 +1952,40 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1733
1952
|
if (connectionSource instanceof sql.Transaction) {
|
|
1734
1953
|
transaction = connectionSource;
|
|
1735
1954
|
}
|
|
1736
|
-
else if (!connectionSource && !loggingOptions?.ignoreAmbientTransaction) {
|
|
1955
|
+
else if (!connectionSource && !loggingOptions?.ignoreAmbientTransaction && this.IsCallerInAmbientTransaction()) {
|
|
1956
|
+
// A caller outside the open transaction's scope runs on the pool as if none were open (#4786).
|
|
1737
1957
|
this.AssertAmbientTransactionUsable();
|
|
1738
1958
|
transaction = this._transaction;
|
|
1739
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
|
+
}
|
|
1740
1971
|
// Create the execution context
|
|
1741
1972
|
const context = {
|
|
1742
1973
|
pool: this._pool,
|
|
1743
1974
|
transaction: transaction,
|
|
1744
|
-
logSqlStatement: this.
|
|
1745
|
-
clearTransaction:
|
|
1746
|
-
this._transaction = null;
|
|
1747
|
-
}
|
|
1975
|
+
logSqlStatement: shouldLogSql ? this._boundLogSqlStatement : undefined,
|
|
1976
|
+
clearTransaction: this._boundClearTransaction
|
|
1748
1977
|
};
|
|
1749
1978
|
// Convert logging options to internal format
|
|
1750
1979
|
const options = loggingOptions ? {
|
|
1751
1980
|
description: loggingOptions.description,
|
|
1752
1981
|
ignoreLogging: loggingOptions.ignoreLogging,
|
|
1753
1982
|
isMutation: loggingOptions.isMutation,
|
|
1754
|
-
simpleSQLFallback
|
|
1983
|
+
get simpleSQLFallback() {
|
|
1984
|
+
return loggingOptions.simpleSQLFallback;
|
|
1985
|
+
},
|
|
1986
|
+
get literalSQL() {
|
|
1987
|
+
return loggingOptions.literalSQL;
|
|
1988
|
+
},
|
|
1755
1989
|
contextUser: loggingOptions.contextUser,
|
|
1756
1990
|
// A statement inside a transaction follows the transaction's limits (see ExecuteSQLOptions).
|
|
1757
1991
|
timeoutMs: transaction ? undefined : loggingOptions.timeoutMs
|
|
@@ -1774,7 +2008,12 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1774
2008
|
description: options?.description,
|
|
1775
2009
|
ignoreLogging: options?.ignoreLogging,
|
|
1776
2010
|
isMutation: options?.isMutation,
|
|
1777
|
-
simpleSQLFallback
|
|
2011
|
+
get simpleSQLFallback() {
|
|
2012
|
+
return options?.simpleSQLFallback;
|
|
2013
|
+
},
|
|
2014
|
+
get literalSQL() {
|
|
2015
|
+
return options?.literalSQL;
|
|
2016
|
+
},
|
|
1778
2017
|
contextUser: contextUser,
|
|
1779
2018
|
ignoreAmbientTransaction: options?.ignoreAmbientTransaction,
|
|
1780
2019
|
timeoutMs: options?.timeoutMs,
|
|
@@ -1970,9 +2209,12 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1970
2209
|
*/
|
|
1971
2210
|
async ExecuteSQLBatch(queries, parameters, options, contextUser) {
|
|
1972
2211
|
try {
|
|
1973
|
-
// A
|
|
1974
|
-
//
|
|
1975
|
-
|
|
2212
|
+
// A batch joins the ambient transaction only when the caller opted in (default) AND is in
|
|
2213
|
+
// its scope (#4786) — a caller outside that scope runs on the pool as if none were open. A
|
|
2214
|
+
// read that does not join it cannot autocommit anything on the pool, which is what the
|
|
2215
|
+
// doomed-transaction assert protects; skip the assert for that case too (#4514).
|
|
2216
|
+
const joinsAmbient = !options?.ignoreAmbientTransaction && this.IsCallerInAmbientTransaction();
|
|
2217
|
+
if (joinsAmbient) {
|
|
1976
2218
|
this.AssertAmbientTransactionUsable();
|
|
1977
2219
|
}
|
|
1978
2220
|
// Build combined batch SQL and parameters (same as static method)
|
|
@@ -2022,7 +2264,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
2022
2264
|
// Create execution context
|
|
2023
2265
|
const context = {
|
|
2024
2266
|
pool: this._pool,
|
|
2025
|
-
transaction:
|
|
2267
|
+
transaction: joinsAmbient ? this._transaction : null,
|
|
2026
2268
|
logSqlStatement: this._logSqlStatement.bind(this),
|
|
2027
2269
|
clearTransaction: () => {
|
|
2028
2270
|
this._transaction = null;
|
|
@@ -2078,8 +2320,19 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
2078
2320
|
if (this._datetimeOffsetTestComplete) {
|
|
2079
2321
|
return this._needsDatetimeOffsetAdjustment;
|
|
2080
2322
|
}
|
|
2081
|
-
//
|
|
2082
|
-
|
|
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;
|
|
2083
2336
|
return this._needsDatetimeOffsetAdjustment;
|
|
2084
2337
|
}
|
|
2085
2338
|
/**
|
|
@@ -2124,13 +2377,15 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
2124
2377
|
}
|
|
2125
2378
|
// Mark the test as complete
|
|
2126
2379
|
this._datetimeOffsetTestComplete = true;
|
|
2380
|
+
SQLServerDataProvider._datetimeOffsetTestFailedAt = null;
|
|
2127
2381
|
}
|
|
2128
2382
|
catch (e) {
|
|
2129
2383
|
// If the test fails, log the error but don't prevent initialization
|
|
2130
2384
|
console.error('SQLServerDataProvider: Failed to test datetimeoffset handling:', e);
|
|
2131
|
-
// 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.
|
|
2132
2387
|
this._needsDatetimeOffsetAdjustment = true;
|
|
2133
|
-
|
|
2388
|
+
SQLServerDataProvider._datetimeOffsetTestFailedAt = Date.now();
|
|
2134
2389
|
}
|
|
2135
2390
|
}
|
|
2136
2391
|
/**
|
|
@@ -2207,12 +2462,12 @@ DECLARE ${varName} NVARCHAR(MAX) = (
|
|
|
2207
2462
|
);
|
|
2208
2463
|
IF ${varName} IS NOT NULL
|
|
2209
2464
|
EXEC [${this.MJCoreSchemaName}].spCreateRecordChange_Internal
|
|
2210
|
-
@EntityName='${safeEntityName}',
|
|
2211
|
-
@RecordID='${recordID}',
|
|
2465
|
+
@EntityName=N'${safeEntityName}',
|
|
2466
|
+
@RecordID=N'${recordID}',
|
|
2212
2467
|
@UserID='${safeUserId}',
|
|
2213
2468
|
@Type='Update',
|
|
2214
|
-
@ChangesJSON='${safeChangesJSON}',
|
|
2215
|
-
@ChangesDescription='${safeChangesDesc}',
|
|
2469
|
+
@ChangesJSON=N'${safeChangesJSON}',
|
|
2470
|
+
@ChangesDescription=N'${safeChangesDesc}',
|
|
2216
2471
|
@FullRecordJSON=${varName},
|
|
2217
2472
|
@Status='Complete',
|
|
2218
2473
|
@Comments=NULL${lineageClause};`;
|
|
@@ -2265,6 +2520,7 @@ IF ${varName} IS NOT NULL
|
|
|
2265
2520
|
* that queued ambient queries behind it are rejected rather than run on the doomed handle.
|
|
2266
2521
|
*/
|
|
2267
2522
|
enqueueTransactionAction(description, handle, run) {
|
|
2523
|
+
this.initializeQueueProcessor();
|
|
2268
2524
|
return new Promise((resolve, reject) => {
|
|
2269
2525
|
this._sqlQueue$.next({ kind: 'action', description, handle, run, resolve, reject });
|
|
2270
2526
|
});
|
|
@@ -2340,16 +2596,20 @@ IF ${varName} IS NOT NULL
|
|
|
2340
2596
|
/**
|
|
2341
2597
|
* Override RefreshIfNeeded to skip refresh when a transaction is active
|
|
2342
2598
|
* This prevents conflicts between metadata refresh operations and active transactions
|
|
2599
|
+
* @param providerToUse - passed through to the base implementation
|
|
2600
|
+
* @param bypassMinCheckInterval - passed through; event-driven callers (a metadata change notice
|
|
2601
|
+
* from another server) set it so the check throttle cannot drop their check. Dropping the
|
|
2602
|
+
* arguments here used to discard it.
|
|
2343
2603
|
* @returns Promise<boolean> - true if refresh was performed, false if skipped or no refresh needed
|
|
2344
2604
|
*/
|
|
2345
|
-
async RefreshIfNeeded() {
|
|
2605
|
+
async RefreshIfNeeded(providerToUse, bypassMinCheckInterval) {
|
|
2346
2606
|
// Skip refresh if a transaction is active
|
|
2347
2607
|
if (this.IsTransactionActive) {
|
|
2348
2608
|
LogStatus('Skipping metadata refresh - transaction is active');
|
|
2349
2609
|
return false;
|
|
2350
2610
|
}
|
|
2351
2611
|
// Call parent implementation if no transaction
|
|
2352
|
-
return super.RefreshIfNeeded();
|
|
2612
|
+
return super.RefreshIfNeeded(providerToUse, bypassMinCheckInterval);
|
|
2353
2613
|
}
|
|
2354
2614
|
get FileSystemProvider() {
|
|
2355
2615
|
if (!this._fileSystemProvider)
|