@memberjunction/sqlserver-dataprovider 6.2.0-edge.4 → 6.2.0-edge.6
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 +93 -15
- package/dist/SQLServerDataProvider.d.ts.map +1 -1
- package/dist/SQLServerDataProvider.js +458 -182
- package/dist/SQLServerDataProvider.js.map +1 -1
- package/dist/SQLServerTransactionGroup.js +1 -1
- package/dist/SQLServerTransactionGroup.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
|
|
@@ -239,7 +244,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
239
244
|
* ENOTBEGUN on a finished handle. A query on an explicit handle a caller passed in — an IS-A chain
|
|
240
245
|
* sharing its own transaction — is never subject to that check.
|
|
241
246
|
*/
|
|
242
|
-
this._sqlQueue$ =
|
|
247
|
+
this._sqlQueue$ = null;
|
|
243
248
|
/**
|
|
244
249
|
* Handles whose commit or rollback has run — or whose commit FAILED, which dooms them just the same.
|
|
245
250
|
* The ambient check in runQueueItem consults this as well as `_transaction`, because a failed
|
|
@@ -247,16 +252,19 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
247
252
|
* already being dequeued; without the marker that item would run on the doomed handle.
|
|
248
253
|
*/
|
|
249
254
|
this._endedHandles = new WeakSet();
|
|
250
|
-
// Transaction state management
|
|
251
|
-
|
|
255
|
+
// Transaction state management — lazy-allocated so read-only per-request providers
|
|
256
|
+
// avoid allocating RxJS Subject/BehaviorSubject instances when no transaction is opened.
|
|
257
|
+
this._isTransactionActive = false;
|
|
258
|
+
this._transactionState$ = null;
|
|
252
259
|
}
|
|
253
260
|
/**
|
|
254
261
|
* Returns the SQL Server dialect instance so that platform-specific tokens
|
|
255
262
|
* (batch separator, quoted identifiers, etc.) are sourced from
|
|
256
263
|
* `@memberjunction/sql-dialect` rather than hardcoded strings.
|
|
257
264
|
*/
|
|
265
|
+
static { this._dialectInstance = new SQLServerDialect(); }
|
|
258
266
|
getDialect() {
|
|
259
|
-
return
|
|
267
|
+
return SQLServerDataProvider._dialectInstance;
|
|
260
268
|
}
|
|
261
269
|
QuoteIdentifier(name) {
|
|
262
270
|
// Double embedded closing brackets so a name containing `]` cannot terminate the
|
|
@@ -276,6 +284,29 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
276
284
|
}
|
|
277
285
|
/** Longest an explicit Refresh() waits for in-flight saves before giving up (returning false). */
|
|
278
286
|
static { this.REFRESH_WAIT_TIMEOUT_MS = 30_000; }
|
|
287
|
+
static { this._needsDatetimeOffsetAdjustment = false; }
|
|
288
|
+
static { this._datetimeOffsetTestComplete = false; }
|
|
289
|
+
static { this._datetimeOffsetTestPromise = null; }
|
|
290
|
+
/** When the last datetimeoffset probe failed (ms since epoch), or null if it hasn't failed since the last success. */
|
|
291
|
+
static { this._datetimeOffsetTestFailedAt = null; }
|
|
292
|
+
/** After a failed probe, callers use the safe default for this long before probing again. */
|
|
293
|
+
static { this.DATETIMEOFFSET_PROBE_RETRY_MS = 30_000; }
|
|
294
|
+
get _needsDatetimeOffsetAdjustment() {
|
|
295
|
+
return SQLServerDataProvider._needsDatetimeOffsetAdjustment;
|
|
296
|
+
}
|
|
297
|
+
set _needsDatetimeOffsetAdjustment(value) {
|
|
298
|
+
SQLServerDataProvider._needsDatetimeOffsetAdjustment = value;
|
|
299
|
+
}
|
|
300
|
+
get _datetimeOffsetTestComplete() {
|
|
301
|
+
return SQLServerDataProvider._datetimeOffsetTestComplete;
|
|
302
|
+
}
|
|
303
|
+
set _datetimeOffsetTestComplete(value) {
|
|
304
|
+
SQLServerDataProvider._datetimeOffsetTestComplete = value;
|
|
305
|
+
if (!value) {
|
|
306
|
+
SQLServerDataProvider._datetimeOffsetTestPromise = null;
|
|
307
|
+
SQLServerDataProvider._datetimeOffsetTestFailedAt = null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
279
310
|
/**
|
|
280
311
|
* Process-wide view-column-order cache, scoped PER CONNECTION POOL and shared across provider
|
|
281
312
|
* instances. MJServer creates 1–2 fresh, fully-Config()'d provider instances per GraphQL request
|
|
@@ -296,6 +327,24 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
296
327
|
* migrations/CodeGen are picked up without a server restart.
|
|
297
328
|
*/
|
|
298
329
|
static { this._viewColumnOrderCacheByPool = new WeakMap(); }
|
|
330
|
+
ensureSqlQueue() {
|
|
331
|
+
if (!this._sqlQueue$) {
|
|
332
|
+
this._sqlQueue$ = new Subject();
|
|
333
|
+
}
|
|
334
|
+
return this._sqlQueue$;
|
|
335
|
+
}
|
|
336
|
+
ensureTransactionStateSubject() {
|
|
337
|
+
if (!this._transactionState$) {
|
|
338
|
+
this._transactionState$ = new BehaviorSubject(this._isTransactionActive);
|
|
339
|
+
}
|
|
340
|
+
return this._transactionState$;
|
|
341
|
+
}
|
|
342
|
+
setTransactionActiveState(active) {
|
|
343
|
+
this._isTransactionActive = active;
|
|
344
|
+
if (this._transactionState$) {
|
|
345
|
+
this._transactionState$.next(active);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
299
348
|
/**
|
|
300
349
|
* Observable that emits the current transaction state (true when active, false when not)
|
|
301
350
|
* External code can subscribe to this to know when transactions start and end
|
|
@@ -306,7 +355,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
306
355
|
* });
|
|
307
356
|
*/
|
|
308
357
|
get TransactionState$() {
|
|
309
|
-
return this.
|
|
358
|
+
return this.ensureTransactionStateSubject().asObservable();
|
|
310
359
|
}
|
|
311
360
|
/** @deprecated Use {@link TransactionState$}. */
|
|
312
361
|
get transactionState$() {
|
|
@@ -338,7 +387,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
338
387
|
get IsTransactionActive() {
|
|
339
388
|
// Always return instance-level state
|
|
340
389
|
// Request-specific state should be accessed via getTransactionContext
|
|
341
|
-
return this.
|
|
390
|
+
return this._isTransactionActive;
|
|
342
391
|
}
|
|
343
392
|
/** @deprecated Use {@link IsTransactionActive}. */
|
|
344
393
|
get isTransactionActive() {
|
|
@@ -370,8 +419,6 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
370
419
|
async Config(configData, providerToUse) {
|
|
371
420
|
try {
|
|
372
421
|
this._pool = configData.ConnectionPool; // Now expects a ConnectionPool instead of DataSource
|
|
373
|
-
// Initialize the instance queue processor
|
|
374
|
-
this.initializeQueueProcessor();
|
|
375
422
|
const configured = await super.Config(configData, providerToUse); // now parent class can do it's config
|
|
376
423
|
// After metadata is loaded, capture each base view's REAL column order so the save-capture
|
|
377
424
|
// @ResultTable is declared to match it (see _viewColumnOrderCache + getAllEntityColumnsSQL).
|
|
@@ -500,7 +547,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
500
547
|
// Each instance gets its own queue processor, but only do this ONCE if we get this method called more than once we don't need to reinit
|
|
501
548
|
// the sub, taht would cause duplicate rprocessing.
|
|
502
549
|
if (!this._queueSubscription) {
|
|
503
|
-
this._queueSubscription = this.
|
|
550
|
+
this._queueSubscription = this.ensureSqlQueue().pipe(concatMap(item => from(this.runQueueItem(item)).pipe(
|
|
504
551
|
// runQueueItem settles the item's own promise on success; only failure is handled here
|
|
505
552
|
catchError(error => {
|
|
506
553
|
item.reject(error);
|
|
@@ -600,7 +647,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
600
647
|
// Build array of SQL statements for batch execution
|
|
601
648
|
const sqlStatements = [];
|
|
602
649
|
for (const { query } of items) {
|
|
603
|
-
// CacheValidationSQL
|
|
650
|
+
// CacheValidationSQL returns MaxUpdatedAt and a row count (TotalRows or RowCount)
|
|
604
651
|
sqlStatements.push(query.CacheValidationSQL);
|
|
605
652
|
}
|
|
606
653
|
try {
|
|
@@ -610,17 +657,8 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
610
657
|
for (let i = 0; i < items.length; i++) {
|
|
611
658
|
const { index } = items[i];
|
|
612
659
|
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
|
-
}
|
|
660
|
+
// Shared reader: accepts the documented `TotalRows` as well as `RowCount`.
|
|
661
|
+
results.set(index, this.QueryCacheStatusFromRow(resultSet?.[0]));
|
|
624
662
|
}
|
|
625
663
|
}
|
|
626
664
|
catch (e) {
|
|
@@ -656,9 +694,11 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
656
694
|
if (items.length === 0) {
|
|
657
695
|
return results;
|
|
658
696
|
}
|
|
659
|
-
// Build array of SQL statements for batch execution
|
|
697
|
+
// Build array of SQL statements for batch execution (recording per-item resolution errors
|
|
698
|
+
// in `results` without aborting valid sibling items in the batch)
|
|
660
699
|
const sqlStatements = [];
|
|
661
|
-
|
|
700
|
+
const validItems = [];
|
|
701
|
+
for (const { index, entityInfo, whereSQL, item } of items) {
|
|
662
702
|
// The freshness probe MUST target the same physical view the read targets. For a
|
|
663
703
|
// DataSource:'Materialized' read that's the materialized_vw<CodeName> wrapper (a full SELECT *
|
|
664
704
|
// snapshot of the base view, so it carries __mj_UpdatedAt) — NOT the live base view. Probing the
|
|
@@ -668,15 +708,27 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
668
708
|
// still supplies a materialized cacheStatus.) Use the status-gated resolveEffectiveBaseView (not the
|
|
669
709
|
// unconditional GetEffectiveBaseView) so a Building/DriftHold/Disabled/never-minted snapshot probes the
|
|
670
710
|
// LIVE base view — mirroring the read path — instead of a held or missing materialized_vw wrapper.
|
|
671
|
-
|
|
672
|
-
|
|
711
|
+
try {
|
|
712
|
+
const effectiveTarget = await this.resolveEffectiveBaseViewTarget(entityInfo, item.params, contextUser);
|
|
713
|
+
sqlStatements.push(this.BuildCacheStatusSQL(entityInfo, effectiveTarget.viewName, whereSQL, effectiveTarget.schemaName));
|
|
714
|
+
validItems.push({ index });
|
|
715
|
+
}
|
|
716
|
+
catch (err) {
|
|
717
|
+
results.set(index, {
|
|
718
|
+
success: false,
|
|
719
|
+
errorMessage: err instanceof Error ? err.message : String(err),
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
if (sqlStatements.length === 0) {
|
|
724
|
+
return results;
|
|
673
725
|
}
|
|
674
726
|
try {
|
|
675
727
|
// Execute the batched SQL using existing ExecuteSQLBatch method
|
|
676
728
|
const resultSets = await this.ExecuteSQLBatch(sqlStatements, undefined, undefined, contextUser);
|
|
677
729
|
// Process each result set and map to the corresponding item index
|
|
678
|
-
for (let i = 0; i <
|
|
679
|
-
const { index } =
|
|
730
|
+
for (let i = 0; i < validItems.length; i++) {
|
|
731
|
+
const { index } = validItems[i];
|
|
680
732
|
const resultSet = resultSets[i];
|
|
681
733
|
if (resultSet && resultSet.length > 0) {
|
|
682
734
|
const row = resultSet[0];
|
|
@@ -704,24 +756,25 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
704
756
|
}
|
|
705
757
|
return results;
|
|
706
758
|
}
|
|
707
|
-
async executeSQLForUserViewRunLogging(viewId, entityInfo, effectiveBaseView, whereSQL, orderBySQL, user) {
|
|
759
|
+
async executeSQLForUserViewRunLogging(viewId, entityInfo, effectiveBaseView, whereSQL, orderBySQL, user, schemaNameOverride) {
|
|
708
760
|
// entityInfo + effectiveBaseView are passed in (no reverse-lookup by base-view name) so the logged
|
|
709
|
-
// read honors DataSource:'Materialized' — effectiveBaseView is the materialized
|
|
710
|
-
// and the entity's live base view otherwise.
|
|
761
|
+
// read honors DataSource:'Materialized' and AlternateBaseView — effectiveBaseView is the materialized
|
|
762
|
+
// or alternate view then, and the entity's live base view otherwise.
|
|
711
763
|
//
|
|
712
764
|
// UserViewRunDetail.RecordID is a single NVARCHAR(255) holding ONE bare key value per row, and the
|
|
713
765
|
// read-back below is `<pk> IN (SELECT RecordID ...)` — a one-column predicate. A composite key has
|
|
714
766
|
// no single column to log or match on, so refuse rather than record (and re-read) a truncated key.
|
|
715
767
|
this.assertSingleColumnPrimaryKey(entityInfo, 'SaveViewResults (user view run logging)');
|
|
716
768
|
const pkName = entityInfo.FirstPrimaryKey.Name; // first-pk-ok: guarded above — view-run RecordID holds one bare key value, PrimaryKeys.length === 1 enforced
|
|
769
|
+
const targetSchema = schemaNameOverride ?? entityInfo.SchemaName;
|
|
717
770
|
const sSQL = `
|
|
718
771
|
DECLARE @ViewIDList TABLE ( ID NVARCHAR(255) );
|
|
719
|
-
INSERT INTO @ViewIDList (ID) (SELECT ${pkName} FROM [${
|
|
772
|
+
INSERT INTO @ViewIDList (ID) (SELECT ${pkName} FROM [${targetSchema}].${effectiveBaseView} WHERE (${whereSQL}))
|
|
720
773
|
EXEC [${this.MJCoreSchemaName}].spCreateUserViewRunWithDetail(${viewId},${user.Email}, @ViewIDLIst)
|
|
721
774
|
`;
|
|
722
775
|
const runIDResult = await this.ExecuteSQL(sSQL, undefined, undefined, user);
|
|
723
776
|
const runID = runIDResult[0].UserViewRunID;
|
|
724
|
-
const sRetSQL = `SELECT * FROM [${
|
|
777
|
+
const sRetSQL = `SELECT * FROM [${targetSchema}].${effectiveBaseView} WHERE ${pkName} IN
|
|
725
778
|
(SELECT RecordID FROM [${this.MJCoreSchemaName}].vwUserViewRunDetails WHERE UserViewRunID=${runID})
|
|
726
779
|
${orderBySQL && orderBySQL.length > 0 ? ` ORDER BY ${orderBySQL}` : ''}`;
|
|
727
780
|
return { executeViewSQL: sRetSQL, runID };
|
|
@@ -742,7 +795,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
742
795
|
* @param entityName
|
|
743
796
|
* @param compositeKey
|
|
744
797
|
*/
|
|
745
|
-
BuildSoftLinkDependencySQL(entityName, compositeKey) {
|
|
798
|
+
BuildSoftLinkDependencySQL(entityName, compositeKey, options, contextUser) {
|
|
746
799
|
// we need to go through ALL of the entities in the system and find all of the EntityFields that have a non-null EntityIDFieldName
|
|
747
800
|
// for each of these, we generate a SQL Statement that will return the EntityName, RelatedEntityName, FieldName, and the primary key values of the related entity
|
|
748
801
|
//
|
|
@@ -753,11 +806,17 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
753
806
|
if (!targetEntity) {
|
|
754
807
|
throw new Error(`Entity ${entityName} not found in metadata`);
|
|
755
808
|
}
|
|
756
|
-
//
|
|
757
|
-
//
|
|
758
|
-
const targetRecordID = compositeKey.ToRecordID();
|
|
809
|
+
// Single-primary-key records referenced by polymorphic tables may store either the canonical
|
|
810
|
+
// `ID|<guid>` encoding (`CompositeKey.ToRecordID()`) OR the bare primary key value (`<guid>`).
|
|
811
|
+
const targetRecordID = this.escapeSQLLiteral(compositeKey.ToRecordID());
|
|
812
|
+
const bareValue = compositeKey.KeyValuePairs.length === 1
|
|
813
|
+
? this.escapeSQLLiteral(String(compositeKey.KeyValuePairs[0].Value ?? ''))
|
|
814
|
+
: '';
|
|
759
815
|
let sSQL = '';
|
|
760
816
|
this.Entities.forEach((entity) => {
|
|
817
|
+
if (!this.shouldIncludeDependencyEntity(entity, options, contextUser)) {
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
761
820
|
// we build a string that will concatenate all of the primary key values into a single string, this is because the primary key could be a composite key
|
|
762
821
|
// we do this in SQL by combining the pirmary key name and value for each row using the default separator defined by the CompositeKey class
|
|
763
822
|
// the output of this should be like the following 'Field1|Value1||Field2|Value2||Field3|Value3' where the || is the CompositeKey.DefaultFieldDelimiter and the | is the CompositeKey.DefaultValueDelimiter
|
|
@@ -767,6 +826,10 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
767
826
|
// each field in f must be processed
|
|
768
827
|
if (sSQL.length > 0)
|
|
769
828
|
sSQL += ' UNION ALL ';
|
|
829
|
+
const sourceObject = this.resolveDependencySourceObject(entity, [f.Name, f.EntityIDFieldName]);
|
|
830
|
+
const recordIDPredicate = bareValue && bareValue !== targetRecordID
|
|
831
|
+
? `[${f.Name}] IN ('${targetRecordID}', '${bareValue}')`
|
|
832
|
+
: `[${f.Name}] = '${targetRecordID}'`;
|
|
770
833
|
// there is a layer of indirection here because each ROW in each of the entity records for this entity/field combination could point to a DIFFERENT
|
|
771
834
|
// entity. We find out which entity it is pointed to via the EntityIDFieldName in the field definition, so we have to filter the rows in the entity
|
|
772
835
|
// based on that.
|
|
@@ -782,10 +845,10 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
782
845
|
1 AS IsSoftLink,
|
|
783
846
|
'${f.EntityIDFieldName}' AS EntityIDFieldName
|
|
784
847
|
FROM
|
|
785
|
-
[${entity.SchemaName}].[${
|
|
848
|
+
[${entity.SchemaName}].[${sourceObject}]
|
|
786
849
|
WHERE
|
|
787
850
|
[${f.EntityIDFieldName}] = '${targetEntity.ID}' AND
|
|
788
|
-
|
|
851
|
+
${recordIDPredicate}`;
|
|
789
852
|
});
|
|
790
853
|
});
|
|
791
854
|
return sSQL;
|
|
@@ -798,13 +861,22 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
798
861
|
escapeSQLLiteral(value) {
|
|
799
862
|
return value.replace(/'/g, "''");
|
|
800
863
|
}
|
|
801
|
-
BuildHardLinkDependencySQL(
|
|
864
|
+
BuildHardLinkDependencySQL(entityDependenciesOrName, compositeKey, options, contextUser) {
|
|
865
|
+
const entityDependencies = this.resolveEntityDependencies(entityDependenciesOrName);
|
|
802
866
|
let sSQL = '';
|
|
803
867
|
for (const entityDependency of entityDependencies) {
|
|
804
868
|
const entityInfo = this.Entities.find((e) => e.Name.trim().toLowerCase() === entityDependency.EntityName?.trim().toLowerCase());
|
|
805
|
-
const quotes = entityInfo.FirstPrimaryKey.NeedsQuotes ? "'" : ''; // first-pk-ok: a foreign key targets a single column; quoting follows that FK target's type
|
|
806
869
|
const relatedEntityInfo = this.Entities.find((e) => e.Name.trim().toLowerCase() === entityDependency.RelatedEntityName?.trim().toLowerCase());
|
|
807
|
-
|
|
870
|
+
if (!entityInfo || !relatedEntityInfo)
|
|
871
|
+
continue;
|
|
872
|
+
if (!this.shouldIncludeDependencyEntity(relatedEntityInfo, options, contextUser))
|
|
873
|
+
continue;
|
|
874
|
+
const fkField = relatedEntityInfo.Fields.find((f) => f.Name.trim().toLowerCase() === entityDependency.FieldName?.trim().toLowerCase());
|
|
875
|
+
if (fkField?.EntityIDFieldName && fkField.EntityIDFieldName.trim().length > 0)
|
|
876
|
+
continue;
|
|
877
|
+
const pksToProject = relatedEntityInfo.PrimaryKeys?.length ? relatedEntityInfo.PrimaryKeys : entityInfo.PrimaryKeys;
|
|
878
|
+
const primaryKeySelectString = `CONCAT(${pksToProject.map((pk) => `'${pk.Name}|', CAST([${pk.Name}] AS NVARCHAR(MAX))`).join(`,'${CompositeKey.DefaultFieldDelimiter}',`)})`;
|
|
879
|
+
const sourceObject = this.resolveDependencySourceObject(relatedEntityInfo, [entityDependency.FieldName]);
|
|
808
880
|
if (sSQL.length > 0)
|
|
809
881
|
sSQL += ' UNION ALL ';
|
|
810
882
|
sSQL += `SELECT
|
|
@@ -815,7 +887,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
815
887
|
0 AS IsSoftLink,
|
|
816
888
|
NULL AS EntityIDFieldName
|
|
817
889
|
FROM
|
|
818
|
-
[${relatedEntityInfo.SchemaName}].[${
|
|
890
|
+
[${relatedEntityInfo.SchemaName}].[${sourceObject}]
|
|
819
891
|
WHERE
|
|
820
892
|
[${entityDependency.FieldName}] = ${this.GetRecordDependencyLinkSQL(entityDependency, entityInfo, relatedEntityInfo, compositeKey)}`;
|
|
821
893
|
}
|
|
@@ -826,7 +898,8 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
826
898
|
if (!f) {
|
|
827
899
|
throw new Error(`Field ${dep.FieldName} not found in Entity ${relatedEntity.Name}`);
|
|
828
900
|
}
|
|
829
|
-
|
|
901
|
+
const relFieldName = f.RelatedEntityFieldName?.trim().toLowerCase();
|
|
902
|
+
if (!relFieldName || relFieldName === 'id' || relFieldName === entity.FirstPrimaryKey?.Name?.trim().toLowerCase()) { // first-pk-ok: a foreign key targets a single column; default FK target is the first primary key
|
|
830
903
|
// simple link to first primary key, most common scenario for linkages
|
|
831
904
|
// Key values arrive from remote callers — render through the shared sanitizer.
|
|
832
905
|
return SQLServerDataProvider.RenderKeyValueLiteral(CompositeKey.GetValueByIndex(0), entity.FirstPrimaryKey.NeedsQuotes, entity.FirstPrimaryKey.Name, entity.Name); // first-pk-ok: a foreign key targets a single column; quoting follows that FK target's type
|
|
@@ -881,19 +954,24 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
881
954
|
* @param bNewRecord - Whether this is a new record (create) or existing record (update)
|
|
882
955
|
* @param spName - The stored procedure name to call
|
|
883
956
|
* @param user - The user context for the operation
|
|
957
|
+
* @param transaction - The transaction the save will run in, when one is already open. A read
|
|
958
|
+
* the save's Record Change snapshot needs runs in it, so it sees that transaction's earlier
|
|
959
|
+
* writes and never waits on the locks they hold.
|
|
884
960
|
* @returns The full SQL statement for the save operation
|
|
885
961
|
*
|
|
886
962
|
* @security This method handles field-level encryption transparently.
|
|
887
963
|
* Fields marked with Encrypt=true will have their values encrypted
|
|
888
964
|
* before being included in the SQL statement.
|
|
889
965
|
*/
|
|
890
|
-
async GetSaveSQL(entity, bNewRecord, _spName, user) {
|
|
966
|
+
async GetSaveSQL(entity, bNewRecord, _spName, user, transaction) {
|
|
891
967
|
// SQLServerTransactionGroup-batched saves regenerate per-item SQL when
|
|
892
968
|
// transaction variables get rebound. `_spName` is preserved for back-compat
|
|
893
969
|
// with that caller; the dialect-driven pipeline derives it from entity
|
|
894
|
-
// metadata via `GetCreateUpdateSPName`.
|
|
895
|
-
|
|
896
|
-
|
|
970
|
+
// metadata via `GetCreateUpdateSPName`. Outside a TransactionGroup,
|
|
971
|
+
// `fullSQL` holds `@p$0` parameter placeholders, so return `literalSQL`
|
|
972
|
+
// for standalone callers.
|
|
973
|
+
const result = await this.GenerateSaveSQLReadingOn(entity, bNewRecord, user, transaction);
|
|
974
|
+
return result.literalSQL ?? result.fullSQL;
|
|
897
975
|
}
|
|
898
976
|
/**
|
|
899
977
|
* Queue the AI action task only once the save is durable: through {@link RunAfterCommit}, so it
|
|
@@ -906,6 +984,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
906
984
|
await QueueManager.AddTask('Entity AI Action', params, null, user);
|
|
907
985
|
}, 'Entity AI Action', postCommitToken);
|
|
908
986
|
}
|
|
987
|
+
static { this._entityColumnsSQLCache = new WeakMap(); }
|
|
909
988
|
getAllEntityColumnsSQL(entityInfo) {
|
|
910
989
|
// This builds the column list for the @ResultTable/@ResultChangesTable that captures
|
|
911
990
|
// `SELECT * FROM <BaseView>` via a POSITIONAL `INSERT INTO @table EXEC ...`. SQL Server maps the
|
|
@@ -930,10 +1009,17 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
930
1009
|
// — the only case where a base-column-after-a-join could mis-route by position — we WARN loudly
|
|
931
1010
|
// (warnOnViewOrderFallback), because the metadata order can no longer be checked against the view and
|
|
932
1011
|
// a silent fallback there would let the very bug this fix prevents persist undetected.
|
|
933
|
-
let orderedFields;
|
|
934
|
-
let fallbackReason;
|
|
935
1012
|
const viewKey = `${entityInfo.SchemaName}.${entityInfo.BaseView}`.toLowerCase();
|
|
936
1013
|
const viewOrder = this._viewColumnOrderCache.get(viewKey);
|
|
1014
|
+
const cached = SQLServerDataProvider._entityColumnsSQLCache.get(entityInfo);
|
|
1015
|
+
if (cached &&
|
|
1016
|
+
cached.viewOrderRef === viewOrder &&
|
|
1017
|
+
cached.fieldCount === entityInfo.Fields.length &&
|
|
1018
|
+
!cached.usedFallback) {
|
|
1019
|
+
return cached.sql;
|
|
1020
|
+
}
|
|
1021
|
+
let orderedFields;
|
|
1022
|
+
let fallbackReason;
|
|
937
1023
|
if (!viewOrder) {
|
|
938
1024
|
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
1025
|
}
|
|
@@ -941,16 +1027,16 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
941
1027
|
fallbackReason = `base view exposes ${viewOrder.length} column(s) but the entity declares ${entityInfo.Fields.length} field(s) — cannot align 1:1 by position`;
|
|
942
1028
|
}
|
|
943
1029
|
else {
|
|
944
|
-
const
|
|
945
|
-
|
|
946
|
-
if (mapped.every((f) => f !== undefined)) {
|
|
1030
|
+
const mapped = viewOrder.map((colName) => entityInfo.FieldByName(colName));
|
|
1031
|
+
if (mapped.every((f) => f !== null && f !== undefined)) {
|
|
947
1032
|
orderedFields = mapped; // every view column resolved 1:1 to a field
|
|
948
1033
|
}
|
|
949
1034
|
else {
|
|
950
|
-
const unmatched = viewOrder.filter((colName) => !
|
|
1035
|
+
const unmatched = viewOrder.filter((colName) => !entityInfo.FieldByName(colName));
|
|
951
1036
|
fallbackReason = `view column(s) without a matching EntityField: ${unmatched.join(', ')}`;
|
|
952
1037
|
}
|
|
953
1038
|
}
|
|
1039
|
+
const usedFallback = !orderedFields;
|
|
954
1040
|
if (!orderedFields) {
|
|
955
1041
|
this.warnIfBaseFieldSequencedAfterVirtual(entityInfo);
|
|
956
1042
|
this.warnOnViewOrderFallback(entityInfo, fallbackReason);
|
|
@@ -963,8 +1049,35 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
963
1049
|
sRet += '[' + f.Name + '] ' + f.SQLFullType + ' ' + (f.AllowsNull || f.IsVirtual ? 'NULL' : 'NOT NULL');
|
|
964
1050
|
outputCount++;
|
|
965
1051
|
}
|
|
1052
|
+
SQLServerDataProvider._entityColumnsSQLCache.set(entityInfo, {
|
|
1053
|
+
viewOrderRef: viewOrder,
|
|
1054
|
+
fieldCount: entityInfo.Fields.length,
|
|
1055
|
+
usedFallback,
|
|
1056
|
+
sql: sRet,
|
|
1057
|
+
});
|
|
966
1058
|
return sRet;
|
|
967
1059
|
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Builds the column declarations for `@ResultTable` when `entityInfo.SaveReturnMode === 'datestamps_only'`.
|
|
1062
|
+
*
|
|
1063
|
+
* Matches the exact column order emitted by CodeGen's `buildCRUDReturnSelect` via
|
|
1064
|
+
* {@link EntityInfo.DatestampReturnFields} (primary key(s), followed by `__mj_CreatedAt` and `__mj_UpdatedAt`),
|
|
1065
|
+
* ensuring positional `INSERT INTO @ResultTable EXEC spCreate/spUpdate` alignment.
|
|
1066
|
+
*/
|
|
1067
|
+
getDatestampEntityColumnsSQL(entityInfo) {
|
|
1068
|
+
return entityInfo.DatestampReturnFields
|
|
1069
|
+
.map((f) => `${this.QuoteIdentifier(f.Name)} ${f.SQLFullType} ${f.AllowsNull || f.IsVirtual ? 'NULL' : 'NOT NULL'}`)
|
|
1070
|
+
.join(',\n');
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* Selects the `@ResultTable` column declarations matching the compiled stored procedure's
|
|
1074
|
+
* return shape (`returnContext.spReturnsDatestampsOnly`).
|
|
1075
|
+
*/
|
|
1076
|
+
getSaveResultTableColumnsSQL(entityInfo, returnContext) {
|
|
1077
|
+
return returnContext.spReturnsDatestampsOnly
|
|
1078
|
+
? this.getDatestampEntityColumnsSQL(entityInfo)
|
|
1079
|
+
: this.getAllEntityColumnsSQL(entityInfo);
|
|
1080
|
+
}
|
|
968
1081
|
/** Entities already warned about base-after-virtual field-order skew — so we warn once, not every save. */
|
|
969
1082
|
static { this._fieldOrderWarnedEntities = new Set(); }
|
|
970
1083
|
/**
|
|
@@ -1063,7 +1176,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1063
1176
|
if (value && field.Type.trim().toLowerCase() === 'datetimeoffset') {
|
|
1064
1177
|
return { kind: 'use', value: new Date(value).toISOString() };
|
|
1065
1178
|
}
|
|
1066
|
-
if (!isUpdate && field.
|
|
1179
|
+
if (!isUpdate && field.IsUniqueIdentifier && !field.IsPrimaryKey) {
|
|
1067
1180
|
if (typeof value === 'string' && value.includes('()')) {
|
|
1068
1181
|
return { kind: 'skip' };
|
|
1069
1182
|
}
|
|
@@ -1083,14 +1196,53 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1083
1196
|
* `EntityFieldInfo.NeedsClearCompanion`.
|
|
1084
1197
|
*/
|
|
1085
1198
|
RenderSaveCallBinding(entity, fieldValues, isUpdate, _spName) {
|
|
1086
|
-
|
|
1199
|
+
// The statement that runs uses one constant variable suffix and a parameter per value, so its
|
|
1200
|
+
// text repeats across records. The per-record suffix is kept for the literal form.
|
|
1201
|
+
const params = new SaveParameterCollector();
|
|
1202
|
+
const binding = this.renderSaveCallBindingWithSuffix(entity, fieldValues, isUpdate, EXECUTED_VARIABLE_SUFFIX, params);
|
|
1203
|
+
const saveBinding = { ...binding, parameters: params.Parameters, recordSuffix: this.allocateSaveCallSuffix(entity) };
|
|
1204
|
+
return saveBinding;
|
|
1205
|
+
}
|
|
1206
|
+
/** The binding as RenderSaveCallBinding built it; throws for any other shape. */
|
|
1207
|
+
asSaveBinding(binding, caller) {
|
|
1208
|
+
if (binding.kind !== 'mssql-declare-exec' || !('recordSuffix' in binding)) {
|
|
1209
|
+
throw new Error(`SQLServerDataProvider.${caller}: unexpected binding kind '${binding.kind}'`);
|
|
1210
|
+
}
|
|
1211
|
+
return binding;
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* The fragment for a statement built with typed parameters. A transaction-group item gets the
|
|
1215
|
+
* literal form only, as before: the group combines and regenerates its items' SQL as text. A
|
|
1216
|
+
* statement with no values to bind runs as built, which is already valid SQL.
|
|
1217
|
+
*
|
|
1218
|
+
* `literalSQL` is exposed as a lazy memoized getter so saves with SQL logging off avoid running
|
|
1219
|
+
* the `RenderSaveSQL` regex substitution pass on every write.
|
|
1220
|
+
*/
|
|
1221
|
+
toSaveFragment(entity, executedSQL, parameters, recordSuffix) {
|
|
1222
|
+
if (entity.TransactionGroup) {
|
|
1223
|
+
return { sql: RenderSaveSQL(executedSQL, parameters, recordSuffix) };
|
|
1224
|
+
}
|
|
1225
|
+
let cachedLiteralSQL;
|
|
1226
|
+
const fragment = parameters.length > 0 ? { sql: executedSQL, parameters: [...parameters] } : { sql: executedSQL };
|
|
1227
|
+
Object.defineProperty(fragment, 'literalSQL', {
|
|
1228
|
+
enumerable: true,
|
|
1229
|
+
configurable: true,
|
|
1230
|
+
get() {
|
|
1231
|
+
if (cachedLiteralSQL === undefined) {
|
|
1232
|
+
cachedLiteralSQL = RenderSaveSQL(executedSQL, parameters, recordSuffix);
|
|
1233
|
+
}
|
|
1234
|
+
return cachedLiteralSQL;
|
|
1235
|
+
},
|
|
1236
|
+
});
|
|
1237
|
+
return fragment;
|
|
1087
1238
|
}
|
|
1088
1239
|
/**
|
|
1089
|
-
* The binding for a given variable suffix
|
|
1090
|
-
* replay form can re-render a subset of the fields under the
|
|
1091
|
-
* another one (inside a TransactionGroup that would consume a `_n`
|
|
1240
|
+
* The binding for a given variable suffix, with each value bound through `params`. Split out of
|
|
1241
|
+
* RenderSaveCallBinding so the update replay form can re-render a subset of the fields under the
|
|
1242
|
+
* SAME suffix without allocating another one (inside a TransactionGroup that would consume a `_n`
|
|
1243
|
+
* ordinal).
|
|
1092
1244
|
*/
|
|
1093
|
-
renderSaveCallBindingWithSuffix(entity, fieldValues, isUpdate, uniqueSuffix) {
|
|
1245
|
+
renderSaveCallBindingWithSuffix(entity, fieldValues, isUpdate, uniqueSuffix, params) {
|
|
1094
1246
|
const declarations = [];
|
|
1095
1247
|
const setStatements = [];
|
|
1096
1248
|
const execParams = [];
|
|
@@ -1104,7 +1256,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1104
1256
|
const hasValue = value !== null && value !== undefined;
|
|
1105
1257
|
const binaryLiteral = hasValue && f.IsBinaryFieldType ? this.FormatBinaryLiteral(f, value) : undefined;
|
|
1106
1258
|
if (hasValue) {
|
|
1107
|
-
setStatements.push(`SET ${varName} = ${binaryLiteral ?? this.generateSetStatementValue(f, value)}`);
|
|
1259
|
+
setStatements.push(`SET ${varName} = ${params.AddField(f, binaryLiteral ?? this.generateSetStatementValue(f, value))}`);
|
|
1108
1260
|
}
|
|
1109
1261
|
execParams.push(`@${f.CodeName}=${varName}`);
|
|
1110
1262
|
simpleParams += this.generateSingleSPParam(f, value, bFirst, binaryLiteral);
|
|
@@ -1117,12 +1269,12 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1117
1269
|
// UPDATE: tail-append PK declarations from the loaded entity's PrimaryKey.
|
|
1118
1270
|
if (isUpdate && execParams.length > 0) {
|
|
1119
1271
|
for (const pkv of entity.PrimaryKey.KeyValuePairs) {
|
|
1120
|
-
const f = entity.EntityInfo.
|
|
1272
|
+
const f = entity.EntityInfo.FieldByName(pkv.FieldName);
|
|
1121
1273
|
if (!f)
|
|
1122
1274
|
continue;
|
|
1123
1275
|
const varName = `@${f.CodeName}${uniqueSuffix}`;
|
|
1124
1276
|
declarations.push(`${varName} ${f.SQLFullType.toUpperCase()}`);
|
|
1125
|
-
setStatements.push(`SET ${varName} = ${this.generateSetStatementValue(f, pkv.Value)}`);
|
|
1277
|
+
setStatements.push(`SET ${varName} = ${params.AddField(f, this.generateSetStatementValue(f, pkv.Value))}`);
|
|
1126
1278
|
execParams.push(`@${f.CodeName}=${varName}`);
|
|
1127
1279
|
const pkeyQuotes = f.NeedsQuotes ? "'" : '';
|
|
1128
1280
|
simpleParams += `, @${f.CodeName} = ${pkeyQuotes}${pkv.Value}${pkeyQuotes}`;
|
|
@@ -1165,23 +1317,21 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1165
1317
|
* there is nothing to look up).
|
|
1166
1318
|
*/
|
|
1167
1319
|
RenderReplaySaveSQL(binding, entity, fieldValues) {
|
|
1168
|
-
|
|
1169
|
-
throw new Error(`SQLServerDataProvider.RenderReplaySaveSQL: unexpected binding kind '${binding.kind}'`);
|
|
1170
|
-
}
|
|
1320
|
+
const saveBinding = this.asSaveBinding(binding, 'RenderReplaySaveSQL');
|
|
1171
1321
|
const info = entity.EntityInfo;
|
|
1172
1322
|
const pks = info.PrimaryKeys;
|
|
1173
1323
|
if (pks.length === 0 || !pks.every((pk) => fieldValues.has(pk))) {
|
|
1174
1324
|
return undefined;
|
|
1175
1325
|
}
|
|
1176
1326
|
const schema = info.SchemaName;
|
|
1177
|
-
const where = pks.map((pk) => `[${pk.Name}] = @${pk.CodeName}${
|
|
1327
|
+
const where = pks.map((pk) => `[${pk.Name}] = @${pk.CodeName}${saveBinding.suffix}`).join(' AND ');
|
|
1178
1328
|
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} ${
|
|
1329
|
+
const createBranch = `IF NOT EXISTS (SELECT 1 FROM [${schema}].[${info.BaseTable}] WHERE ${where})\nBEGIN\n EXEC [${schema}].${createSpName} ${saveBinding.callArgsSQL}\nEND`;
|
|
1180
1330
|
const hasUpdateProc = info.AllowUpdateAPI && info.spUpdateGenerated && !info.VirtualEntity;
|
|
1181
1331
|
const updateBranch = hasUpdateProc
|
|
1182
|
-
? `\nELSE\nBEGIN\n EXEC [${schema}].${this.GetCreateUpdateSPName(entity, false)} ${
|
|
1332
|
+
? `\nELSE\nBEGIN\n EXEC [${schema}].${this.GetCreateUpdateSPName(entity, false)} ${saveBinding.callArgsSQL}\nEND`
|
|
1183
1333
|
: '';
|
|
1184
|
-
return `${this.renderDeclareSetHead(
|
|
1334
|
+
return RenderSaveSQL(`${this.renderDeclareSetHead(saveBinding)}${createBranch}${updateBranch}`, saveBinding.parameters, saveBinding.recordSuffix);
|
|
1185
1335
|
}
|
|
1186
1336
|
/**
|
|
1187
1337
|
* Replay form of an UPDATE for the SQL log (never executed): `EXEC spUpdate` with only the
|
|
@@ -1194,9 +1344,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1194
1344
|
* (spUpdateGenerated off) makes no ISNULL promise, so it keeps the full-row form.
|
|
1195
1345
|
*/
|
|
1196
1346
|
RenderReplayUpdateSQL(binding, entity, changedFieldValues) {
|
|
1197
|
-
|
|
1198
|
-
throw new Error(`SQLServerDataProvider.RenderReplayUpdateSQL: unexpected binding kind '${binding.kind}'`);
|
|
1199
|
-
}
|
|
1347
|
+
const saveBinding = this.asSaveBinding(binding, 'RenderReplayUpdateSQL');
|
|
1200
1348
|
const info = entity.EntityInfo;
|
|
1201
1349
|
if (!info.spUpdateGenerated) {
|
|
1202
1350
|
return undefined;
|
|
@@ -1205,9 +1353,10 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1205
1353
|
const key = entity.PrimaryKey.ToString().replace(/[\r\n]+/g, ' ');
|
|
1206
1354
|
return `-- ${info.Name} (${key}): saved with no field changes; nothing to replay`;
|
|
1207
1355
|
}
|
|
1208
|
-
const
|
|
1356
|
+
const params = new SaveParameterCollector();
|
|
1357
|
+
const changed = this.renderSaveCallBindingWithSuffix(entity, changedFieldValues, true, saveBinding.suffix, params);
|
|
1209
1358
|
const spName = this.GetCreateUpdateSPName(entity, false);
|
|
1210
|
-
return `${this.renderDeclareSetHead(changed)}EXEC [${info.SchemaName}].${spName} ${changed.callArgsSQL}
|
|
1359
|
+
return RenderSaveSQL(`${this.renderDeclareSetHead(changed)}EXEC [${info.SchemaName}].${spName} ${changed.callArgsSQL}`, params.Parameters, saveBinding.recordSuffix);
|
|
1211
1360
|
}
|
|
1212
1361
|
/** `DECLARE ...\n\nSET ...\n\n` when the binding declares variables, else empty. */
|
|
1213
1362
|
renderDeclareSetHead(binding) {
|
|
@@ -1218,58 +1367,66 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1218
1367
|
* shape: `DECLARE ... ; SET ... ; EXEC [schema].sp callArgs`. When
|
|
1219
1368
|
* record-change tracking is on, this output is further wrapped by
|
|
1220
1369
|
* `WrapSaveCallWithRecordChange`.
|
|
1370
|
+
*
|
|
1371
|
+
* When `returnContext.needsDatestampProjection` is true (either passed by
|
|
1372
|
+
* `GenericDatabaseProvider.GenerateSaveSQL` or resolved from `options`),
|
|
1373
|
+
* delegates to {@link ProjectSaveCallToDatestamps}.
|
|
1221
1374
|
*/
|
|
1222
|
-
WrapSaveCallForResult(binding, entity, spName) {
|
|
1223
|
-
|
|
1224
|
-
|
|
1375
|
+
WrapSaveCallForResult(binding, entity, spName, options, returnContext) {
|
|
1376
|
+
const resolvedReturn = returnContext ?? this.ResolveSaveReturnContext(entity, options);
|
|
1377
|
+
if (resolvedReturn.needsDatestampProjection) {
|
|
1378
|
+
return this.ProjectSaveCallToDatestamps(binding, entity, spName, resolvedReturn);
|
|
1225
1379
|
}
|
|
1226
|
-
const
|
|
1227
|
-
|
|
1380
|
+
const saveBinding = this.asSaveBinding(binding, 'WrapSaveCallForResult');
|
|
1381
|
+
const execSQL = `EXEC [${entity.EntityInfo.SchemaName}].${spName} ${saveBinding.callArgsSQL}`;
|
|
1382
|
+
return this.toSaveFragment(entity, `${this.renderDeclareSetHead(saveBinding)}${execSQL}`, saveBinding.parameters, saveBinding.recordSuffix);
|
|
1383
|
+
}
|
|
1384
|
+
/**
|
|
1385
|
+
* Captures the stored procedure's output into `@ResultTable` and projects only
|
|
1386
|
+
* `returnContext.datestampSelectColumnsSQL` (`SELECT [ID], [__mj_CreatedAt], [__mj_UpdatedAt] FROM @ResultTable;`)
|
|
1387
|
+
* when a caller requests `SaveReturnMode: 'datestamps_only'` at runtime on an entity whose
|
|
1388
|
+
* compiled stored procedure returns the full row.
|
|
1389
|
+
*/
|
|
1390
|
+
ProjectSaveCallToDatestamps(binding, entity, spName, returnContext) {
|
|
1391
|
+
const saveBinding = this.asSaveBinding(binding, 'ProjectSaveCallToDatestamps');
|
|
1392
|
+
const execSQL = `EXEC [${entity.EntityInfo.SchemaName}].${spName} ${saveBinding.callArgsSQL}`;
|
|
1393
|
+
const resultTableColumnsSQL = this.getSaveResultTableColumnsSQL(entity.EntityInfo, returnContext);
|
|
1394
|
+
const sql = `${this.renderDeclareSetHead(saveBinding)}DECLARE @ResultTable TABLE (\n ${resultTableColumnsSQL}\n);\n\nINSERT INTO @ResultTable\n${execSQL};\n\nSELECT ${returnContext.datestampSelectColumnsSQL} FROM @ResultTable;`;
|
|
1395
|
+
return this.toSaveFragment(entity, sql, saveBinding.parameters, saveBinding.recordSuffix);
|
|
1228
1396
|
}
|
|
1229
1397
|
/**
|
|
1230
1398
|
* Wraps the save SQL with SQL Server's @ResultTable + inline
|
|
1231
1399
|
* `spCreateRecordChange_Internal` EXEC pattern. The CONCAT-built `@ID`
|
|
1232
1400
|
* inline supports composite-key entities.
|
|
1401
|
+
*
|
|
1402
|
+
* Consumes {@link SaveReturnContext} resolved by `GenericDatabaseProvider`:
|
|
1403
|
+
* - `@ResultTable` column declarations match the compiled stored procedure's return mode
|
|
1404
|
+
* (`returnContext.spReturnsDatestampsOnly`).
|
|
1405
|
+
* - The final `SELECT` from `@ResultTable` uses `returnContext.finalSelectColumnsSQL`.
|
|
1233
1406
|
*/
|
|
1234
|
-
WrapSaveCallWithRecordChange(_saveSQL, binding, payload, entity) {
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
const recordChangesEntityInfo = this.Entities.find((e) => e.Name === 'MJ: Record Changes');
|
|
1407
|
+
WrapSaveCallWithRecordChange(_saveSQL, binding, payload, entity, options, returnContext) {
|
|
1408
|
+
const saveBinding = this.asSaveBinding(binding, 'WrapSaveCallWithRecordChange');
|
|
1409
|
+
const params = new SaveParameterCollector(saveBinding.parameters);
|
|
1410
|
+
const recordChangesEntityInfo = this.EntityByName('MJ: Record Changes');
|
|
1239
1411
|
const spName = this.GetCreateUpdateSPName(entity, payload.type === 'Create');
|
|
1240
1412
|
const concatPKIDString = `CONCAT(${entity.EntityInfo.PrimaryKeys
|
|
1241
1413
|
.map((pk) => `'${pk.CodeName}','${CompositeKey.DefaultValueDelimiter}',[${pk.Name}]`)
|
|
1242
1414
|
.join(`,'${CompositeKey.DefaultFieldDelimiter}',`)})`;
|
|
1243
1415
|
// Build the inline `EXEC spCreateRecordChange_Internal` from the payload,
|
|
1244
1416
|
// 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}`;
|
|
1417
|
+
const recordChangeEXEC = this.renderRecordChangeEXEC(entity.EntityInfo.Name, '@ID', payload, params);
|
|
1418
|
+
const execSQL = `EXEC [${entity.EntityInfo.SchemaName}].${spName} ${saveBinding.callArgsSQL}`;
|
|
1419
|
+
const resolvedReturn = returnContext ?? this.ResolveSaveReturnContext(entity, options);
|
|
1420
|
+
const resultTableColumnsSQL = this.getSaveResultTableColumnsSQL(entity.EntityInfo, resolvedReturn);
|
|
1421
|
+
const finalSelectSQL = `SELECT ${resolvedReturn.finalSelectColumnsSQL} FROM @ResultTable;`;
|
|
1265
1422
|
const sql = `
|
|
1266
|
-
${
|
|
1423
|
+
${saveBinding.preambleSQL}
|
|
1267
1424
|
|
|
1268
1425
|
DECLARE @ResultTable TABLE (
|
|
1269
|
-
${
|
|
1426
|
+
${resultTableColumnsSQL}
|
|
1270
1427
|
);
|
|
1271
1428
|
|
|
1272
|
-
${
|
|
1429
|
+
${saveBinding.setSQL}
|
|
1273
1430
|
|
|
1274
1431
|
INSERT INTO @ResultTable
|
|
1275
1432
|
${execSQL};
|
|
@@ -1288,8 +1445,44 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1288
1445
|
${recordChangeEXEC};
|
|
1289
1446
|
END;
|
|
1290
1447
|
|
|
1291
|
-
|
|
1292
|
-
return
|
|
1448
|
+
${finalSelectSQL}`;
|
|
1449
|
+
return this.toSaveFragment(entity, sql, params.Parameters, saveBinding.recordSuffix);
|
|
1450
|
+
}
|
|
1451
|
+
/**
|
|
1452
|
+
* `EXEC spCreateRecordChange_Internal …` for a payload. With `params`, each per-change value is
|
|
1453
|
+
* bound as a parameter typed from its Record Changes column, and the literal it replaces is kept
|
|
1454
|
+
* for the rendered form; without, the literals are written in directly. The entity name and change
|
|
1455
|
+
* type stay literal: they are constant for an entity and operation.
|
|
1456
|
+
*/
|
|
1457
|
+
renderRecordChangeEXEC(entityName, recordIDSQL, payload, params) {
|
|
1458
|
+
const bind = (fieldName, literal) => (params ? params.AddField(this.recordChangeField(fieldName), literal) : literal);
|
|
1459
|
+
const lineageClause = payload.source === 'Restore'
|
|
1460
|
+
? `,
|
|
1461
|
+
@Source='Restore',
|
|
1462
|
+
@RestoredFromID=${bind('RestoredFromID', `'${payload.restoredFromID}'`)},
|
|
1463
|
+
@RestoreReason=${payload.restoreReason ? bind('RestoreReason', `N'${payload.restoreReason.replace(/'/g, "''")}'`) : 'NULL'}`
|
|
1464
|
+
: payload.source === 'Clone'
|
|
1465
|
+
? `,
|
|
1466
|
+
@Source='Clone',
|
|
1467
|
+
@ChangeContext=${bind('ChangeContext', `N'${EscapeSQLString(payload.changeContext)}'`)}`
|
|
1468
|
+
: '';
|
|
1469
|
+
return `EXEC [${this.MJCoreSchemaName}].spCreateRecordChange_Internal @EntityName=N'${entityName}',
|
|
1470
|
+
@RecordID=${recordIDSQL},
|
|
1471
|
+
@UserID=${bind('UserID', `'${payload.userID}'`)},
|
|
1472
|
+
@Type='${payload.type}',
|
|
1473
|
+
@ChangesJSON=${bind('ChangesJSON', `N'${payload.changesJSON}'`)},
|
|
1474
|
+
@ChangesDescription=${bind('ChangesDescription', `N'${payload.changesDescription}'`)},
|
|
1475
|
+
@FullRecordJSON=${bind('FullRecordJSON', `N'${payload.fullRecordJSON}'`)},
|
|
1476
|
+
@Status='Complete',
|
|
1477
|
+
@Comments=null${lineageClause}`;
|
|
1478
|
+
}
|
|
1479
|
+
/** A field of `MJ: Record Changes`, whose column types type the Record Change parameters. */
|
|
1480
|
+
recordChangeField(fieldName) {
|
|
1481
|
+
const field = this.EntityByName('MJ: Record Changes')?.FieldByName(fieldName);
|
|
1482
|
+
if (!field) {
|
|
1483
|
+
throw new Error(`SQLServerDataProvider: field "${fieldName}" of "MJ: Record Changes" is not in the metadata`);
|
|
1484
|
+
}
|
|
1485
|
+
return field;
|
|
1293
1486
|
}
|
|
1294
1487
|
/**
|
|
1295
1488
|
* Generates the value portion of a SET statement for a field
|
|
@@ -1460,32 +1653,22 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1460
1653
|
return unicodePrefix + quoteString + pVal + quoteString;
|
|
1461
1654
|
}
|
|
1462
1655
|
GetLogRecordChangeSQL(newData, oldData, entityName, recordID, entityInfo, type, user, wrapRecordIdInQuotes, restoreContext, cloneContext) {
|
|
1656
|
+
return this.renderLogRecordChangeSQL(newData, oldData, entityName, recordID, entityInfo, type, user, wrapRecordIdInQuotes, restoreContext, cloneContext);
|
|
1657
|
+
}
|
|
1658
|
+
/**
|
|
1659
|
+
* Renders the `spCreateRecordChange_Internal` call for `GetLogRecordChangeSQL` (literal form) and
|
|
1660
|
+
* for the delete batch (`params` supplied, so the payload values bind as typed inputs).
|
|
1661
|
+
*/
|
|
1662
|
+
renderLogRecordChangeSQL(newData, oldData, entityName, recordID, entityInfo, type, user, wrapRecordIdInQuotes, restoreContext, cloneContext, params) {
|
|
1463
1663
|
// Dialect-agnostic payload assembly is hoisted into DatabaseProviderBase
|
|
1464
1664
|
// so SQL Server and PostgreSQL share one implementation. We only render
|
|
1465
1665
|
// the T-SQL EXEC wrapper here.
|
|
1466
1666
|
const payload = this.BuildRecordChangePayload(newData, oldData, String(recordID), entityInfo, type, user, restoreContext, "'", cloneContext);
|
|
1467
1667
|
if (!payload)
|
|
1468
1668
|
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}`;
|
|
1669
|
+
const recordIDSQL = wrapRecordIdInQuotes ? `N'${EscapeSQLString(String(recordID))}'` : String(recordID);
|
|
1670
|
+
const boundRecordID = params && wrapRecordIdInQuotes ? params.AddField(this.recordChangeField('RecordID'), recordIDSQL) : recordIDSQL;
|
|
1671
|
+
return this.renderRecordChangeEXEC(entityName, boundRecordID, payload, params);
|
|
1489
1672
|
}
|
|
1490
1673
|
/**
|
|
1491
1674
|
* Implements the abstract BuildRecordChangeSQL from DatabaseProviderBase.
|
|
@@ -1510,22 +1693,33 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1510
1693
|
return result.fullSQL;
|
|
1511
1694
|
}
|
|
1512
1695
|
/**
|
|
1513
|
-
*
|
|
1514
|
-
*
|
|
1696
|
+
* Generates the delete statement with both full SQL (including record changes) and simple SQL.
|
|
1697
|
+
* With `params`, primary-key and Record Change values are bound as `@p0, @p1, …` and their
|
|
1698
|
+
* literals are kept on the parameters for `RenderSaveSQL`.
|
|
1699
|
+
* For a TrackRecordChanges entity with CascadeDeletes, the full SQL runs the proc uncaptured and selects the key twice:
|
|
1700
|
+
* first whether the row existed (read by transaction groups), last whether it was deleted (read by direct execution).
|
|
1515
1701
|
*/
|
|
1516
|
-
getDeleteSQLWithDetails(entity, user, skipRecordChanges = false) {
|
|
1517
|
-
let sSQL = '';
|
|
1702
|
+
getDeleteSQLWithDetails(entity, user, skipRecordChanges = false, params) {
|
|
1518
1703
|
const spName = entity.EntityInfo.spDelete ? entity.EntityInfo.spDelete : `spDelete${entity.EntityInfo.BaseTableCodeName}`;
|
|
1519
|
-
const
|
|
1520
|
-
const f = entity.EntityInfo.
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1704
|
+
const pkRefs = entity.PrimaryKey.KeyValuePairs.map((kv) => {
|
|
1705
|
+
const f = entity.EntityInfo.FieldByName(kv.FieldName);
|
|
1706
|
+
if (!f) {
|
|
1707
|
+
throw new Error(`SQLServerDataProvider.getDeleteSQLWithDetails: primary key field "${kv.FieldName}" not found on "${entity.EntityInfo.Name}"`);
|
|
1708
|
+
}
|
|
1709
|
+
// Quoted keys are escaped by generateSetStatementValue; an unquoted (numeric) key must be a plain number.
|
|
1710
|
+
const literal = f.NeedsQuotes
|
|
1711
|
+
? this.generateSetStatementValue(f, kv.Value)
|
|
1712
|
+
: SQLServerDataProvider.RenderKeyValueLiteral(kv.Value, false, f.Name, entity.EntityInfo.Name);
|
|
1713
|
+
// One reference per key, reused by every statement below that names the key.
|
|
1714
|
+
return { Field: f, Ref: params ? params.AddField(f, literal) : literal };
|
|
1715
|
+
});
|
|
1716
|
+
const sParams = pkRefs.map((pk) => `@${pk.Field.CodeName}=${pk.Ref}`).join(', ');
|
|
1524
1717
|
const sSimpleSQL = `EXEC [${entity.EntityInfo.SchemaName}].[${spName}] ${sParams}`;
|
|
1525
|
-
const recordChangesEntityInfo = this.
|
|
1718
|
+
const recordChangesEntityInfo = this.EntityByName('MJ: Record Changes');
|
|
1719
|
+
let sSQL;
|
|
1526
1720
|
if (entity.EntityInfo.TrackRecordChanges && !skipRecordChanges && entity.EntityInfo.Name.trim().toLowerCase() !== 'record changes') {
|
|
1527
1721
|
// don't track changes for the record changes entity
|
|
1528
|
-
const oldData = entity.GetAll(true); //
|
|
1722
|
+
const oldData = this.RecordChangeDeleteSnapshot(entity, entity.GetAll(true)); // the OLD values, full width
|
|
1529
1723
|
const sTableDeclare = entity.PrimaryKeys.map((pk) => {
|
|
1530
1724
|
return `${pk.CodeName} ${pk.EntityFieldInfo.SQLFullType}`;
|
|
1531
1725
|
}).join(', ');
|
|
@@ -1542,6 +1736,40 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1542
1736
|
const sReturnList = entity.PrimaryKeys.map((pk) => {
|
|
1543
1737
|
return `@${pk.CodeName} AS [${pk.Name}]`;
|
|
1544
1738
|
}).join(', ');
|
|
1739
|
+
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);
|
|
1740
|
+
if (entity.EntityInfo.CascadeDeletes) {
|
|
1741
|
+
// A cascading spDelete EXECs other procs (spUpdate* to null references, spDelete* for
|
|
1742
|
+
// children) and each emits its own result set, spUpdate*'s being the full row. Capturing the
|
|
1743
|
+
// call with INSERT INTO @ResultTable EXEC takes every one of them, so the first full row fails
|
|
1744
|
+
// with error 213. Run the proc uncaptured instead and judge success by whether the row is
|
|
1745
|
+
// gone. The key is selected first (transaction groups read the first result set) and last
|
|
1746
|
+
// (direct execution reads the last, see ValidateDeleteResult); NULL when nothing was deleted.
|
|
1747
|
+
// The checks read the base view, not the table: runtime logins hold SELECT on base views
|
|
1748
|
+
// only, and for a soft-delete entity the view hides the row spDelete just soft-deleted.
|
|
1749
|
+
const sView = `[${entity.EntityInfo.SchemaName}].[${entity.EntityInfo.BaseView}]`;
|
|
1750
|
+
const sWhere = pkRefs.map((pk) => `[${pk.Field.Name}]=${pk.Ref}`).join(' AND ');
|
|
1751
|
+
const sKeyWhen = (flag) => pkRefs.map((pk) => `CASE WHEN ${flag} = 1 THEN ${pk.Ref} END AS [${pk.Field.Name}]`).join(', ');
|
|
1752
|
+
sSQL = `
|
|
1753
|
+
DECLARE @MJDeleteRowExisted BIT = CASE WHEN EXISTS (SELECT 1 FROM ${sView} WHERE ${sWhere}) THEN 1 ELSE 0 END
|
|
1754
|
+
SELECT ${sKeyWhen('@MJDeleteRowExisted')}
|
|
1755
|
+
|
|
1756
|
+
IF @MJDeleteRowExisted = 1
|
|
1757
|
+
${sSimpleSQL}
|
|
1758
|
+
|
|
1759
|
+
DECLARE @MJDeleteRowDeleted BIT = CASE WHEN @MJDeleteRowExisted = 1 AND NOT EXISTS (SELECT 1 FROM ${sView} WHERE ${sWhere}) THEN 1 ELSE 0 END
|
|
1760
|
+
IF @MJDeleteRowDeleted = 1
|
|
1761
|
+
BEGIN
|
|
1762
|
+
DECLARE @ResultChangesTable TABLE (
|
|
1763
|
+
${this.getAllEntityColumnsSQL(recordChangesEntityInfo)}
|
|
1764
|
+
)
|
|
1765
|
+
|
|
1766
|
+
INSERT INTO @ResultChangesTable
|
|
1767
|
+
${recordChangeSQL}
|
|
1768
|
+
END
|
|
1769
|
+
|
|
1770
|
+
SELECT ${sKeyWhen('@MJDeleteRowDeleted')}`;
|
|
1771
|
+
return { fullSQL: sSQL, simpleSQL: sSimpleSQL };
|
|
1772
|
+
}
|
|
1545
1773
|
sSQL = `
|
|
1546
1774
|
IF OBJECT_ID('tempdb..#ResultTable') IS NOT NULL
|
|
1547
1775
|
DROP TABLE #ResultTable
|
|
@@ -1562,7 +1790,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1562
1790
|
)
|
|
1563
1791
|
|
|
1564
1792
|
INSERT INTO @ResultChangesTable
|
|
1565
|
-
${
|
|
1793
|
+
${recordChangeSQL}
|
|
1566
1794
|
END
|
|
1567
1795
|
|
|
1568
1796
|
SELECT ${sReturnList}`;
|
|
@@ -1585,10 +1813,22 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1585
1813
|
// WrapSaveCallWithRecordChange (see this file's "Save Grammar" section
|
|
1586
1814
|
// above). See plans/sp-save-builder-generic-layer-refactor.md (rev 4).
|
|
1587
1815
|
GenerateDeleteSQL(entity, user, options) {
|
|
1588
|
-
const
|
|
1816
|
+
const params = new SaveParameterCollector();
|
|
1817
|
+
const sqlDetails = this.getDeleteSQLWithDetails(entity, user, options?.SkipRecordChanges === true, params);
|
|
1818
|
+
const fragment = this.toSaveFragment(entity, sqlDetails.fullSQL, params.Parameters, '');
|
|
1819
|
+
let cachedSimpleSQL;
|
|
1589
1820
|
return {
|
|
1590
|
-
fullSQL:
|
|
1591
|
-
simpleSQL
|
|
1821
|
+
fullSQL: fragment.sql,
|
|
1822
|
+
get simpleSQL() {
|
|
1823
|
+
if (cachedSimpleSQL === undefined) {
|
|
1824
|
+
cachedSimpleSQL = RenderSaveSQL(sqlDetails.simpleSQL, params.Parameters, '');
|
|
1825
|
+
}
|
|
1826
|
+
return cachedSimpleSQL;
|
|
1827
|
+
},
|
|
1828
|
+
parameters: fragment.parameters ?? null,
|
|
1829
|
+
get literalSQL() {
|
|
1830
|
+
return fragment.literalSQL;
|
|
1831
|
+
},
|
|
1592
1832
|
};
|
|
1593
1833
|
}
|
|
1594
1834
|
OnSuspendRefresh() {
|
|
@@ -1713,8 +1953,10 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1713
1953
|
}
|
|
1714
1954
|
// For transactional queries, use the instance queue to ensure serialization
|
|
1715
1955
|
// This prevents EREQINPROG errors when multiple queries try to use the same transaction
|
|
1956
|
+
this.initializeQueueProcessor();
|
|
1957
|
+
const queue = this.ensureSqlQueue();
|
|
1716
1958
|
return new Promise((resolve, reject) => {
|
|
1717
|
-
|
|
1959
|
+
queue.next({
|
|
1718
1960
|
kind: 'query',
|
|
1719
1961
|
query,
|
|
1720
1962
|
parameters,
|
|
@@ -1774,21 +2016,35 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1774
2016
|
this.AssertAmbientTransactionUsable();
|
|
1775
2017
|
transaction = this._transaction;
|
|
1776
2018
|
}
|
|
2019
|
+
const shouldLogSql = !loggingOptions?.ignoreLogging && (this.HasActiveSqlLoggingSessions ||
|
|
2020
|
+
Object.prototype.hasOwnProperty.call(this, '_logSqlStatement') ||
|
|
2021
|
+
Object.getPrototypeOf(this) !== SQLServerDataProvider.prototype);
|
|
2022
|
+
if (shouldLogSql && !this._boundLogSqlStatement) {
|
|
2023
|
+
this._boundLogSqlStatement = this._logSqlStatement.bind(this);
|
|
2024
|
+
}
|
|
2025
|
+
if (!this._boundClearTransaction) {
|
|
2026
|
+
this._boundClearTransaction = () => {
|
|
2027
|
+
this._transaction = null;
|
|
2028
|
+
};
|
|
2029
|
+
}
|
|
1777
2030
|
// Create the execution context
|
|
1778
2031
|
const context = {
|
|
1779
2032
|
pool: this._pool,
|
|
1780
2033
|
transaction: transaction,
|
|
1781
|
-
logSqlStatement: this.
|
|
1782
|
-
clearTransaction:
|
|
1783
|
-
this._transaction = null;
|
|
1784
|
-
}
|
|
2034
|
+
logSqlStatement: shouldLogSql ? this._boundLogSqlStatement : undefined,
|
|
2035
|
+
clearTransaction: this._boundClearTransaction
|
|
1785
2036
|
};
|
|
1786
2037
|
// Convert logging options to internal format
|
|
1787
2038
|
const options = loggingOptions ? {
|
|
1788
2039
|
description: loggingOptions.description,
|
|
1789
2040
|
ignoreLogging: loggingOptions.ignoreLogging,
|
|
1790
2041
|
isMutation: loggingOptions.isMutation,
|
|
1791
|
-
simpleSQLFallback
|
|
2042
|
+
get simpleSQLFallback() {
|
|
2043
|
+
return loggingOptions.simpleSQLFallback;
|
|
2044
|
+
},
|
|
2045
|
+
get literalSQL() {
|
|
2046
|
+
return loggingOptions.literalSQL;
|
|
2047
|
+
},
|
|
1792
2048
|
contextUser: loggingOptions.contextUser,
|
|
1793
2049
|
// A statement inside a transaction follows the transaction's limits (see ExecuteSQLOptions).
|
|
1794
2050
|
timeoutMs: transaction ? undefined : loggingOptions.timeoutMs
|
|
@@ -1811,7 +2067,12 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1811
2067
|
description: options?.description,
|
|
1812
2068
|
ignoreLogging: options?.ignoreLogging,
|
|
1813
2069
|
isMutation: options?.isMutation,
|
|
1814
|
-
simpleSQLFallback
|
|
2070
|
+
get simpleSQLFallback() {
|
|
2071
|
+
return options?.simpleSQLFallback;
|
|
2072
|
+
},
|
|
2073
|
+
get literalSQL() {
|
|
2074
|
+
return options?.literalSQL;
|
|
2075
|
+
},
|
|
1815
2076
|
contextUser: contextUser,
|
|
1816
2077
|
ignoreAmbientTransaction: options?.ignoreAmbientTransaction,
|
|
1817
2078
|
timeoutMs: options?.timeoutMs,
|
|
@@ -2118,8 +2379,19 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
2118
2379
|
if (this._datetimeOffsetTestComplete) {
|
|
2119
2380
|
return this._needsDatetimeOffsetAdjustment;
|
|
2120
2381
|
}
|
|
2121
|
-
//
|
|
2122
|
-
|
|
2382
|
+
// A recent probe failed: keep its safe default until the backoff passes, rather than re-probing
|
|
2383
|
+
// (and logging the failure again) on every call while the database is unreachable.
|
|
2384
|
+
const failedAt = SQLServerDataProvider._datetimeOffsetTestFailedAt;
|
|
2385
|
+
if (failedAt !== null && Date.now() - failedAt < SQLServerDataProvider.DATETIMEOFFSET_PROBE_RETRY_MS) {
|
|
2386
|
+
return this._needsDatetimeOffsetAdjustment;
|
|
2387
|
+
}
|
|
2388
|
+
// Deduplicate concurrent tests across per-request provider instances
|
|
2389
|
+
if (!SQLServerDataProvider._datetimeOffsetTestPromise) {
|
|
2390
|
+
SQLServerDataProvider._datetimeOffsetTestPromise = this.testDatetimeOffsetHandling().finally(() => {
|
|
2391
|
+
SQLServerDataProvider._datetimeOffsetTestPromise = null;
|
|
2392
|
+
});
|
|
2393
|
+
}
|
|
2394
|
+
await SQLServerDataProvider._datetimeOffsetTestPromise;
|
|
2123
2395
|
return this._needsDatetimeOffsetAdjustment;
|
|
2124
2396
|
}
|
|
2125
2397
|
/**
|
|
@@ -2164,13 +2436,15 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
2164
2436
|
}
|
|
2165
2437
|
// Mark the test as complete
|
|
2166
2438
|
this._datetimeOffsetTestComplete = true;
|
|
2439
|
+
SQLServerDataProvider._datetimeOffsetTestFailedAt = null;
|
|
2167
2440
|
}
|
|
2168
2441
|
catch (e) {
|
|
2169
2442
|
// If the test fails, log the error but don't prevent initialization
|
|
2170
2443
|
console.error('SQLServerDataProvider: Failed to test datetimeoffset handling:', e);
|
|
2171
|
-
// Default to assuming adjustment is needed for safety
|
|
2444
|
+
// Default to assuming adjustment is needed for safety on this provider,
|
|
2445
|
+
// but leave _datetimeOffsetTestComplete false so a call after the backoff retries.
|
|
2172
2446
|
this._needsDatetimeOffsetAdjustment = true;
|
|
2173
|
-
|
|
2447
|
+
SQLServerDataProvider._datetimeOffsetTestFailedAt = Date.now();
|
|
2174
2448
|
}
|
|
2175
2449
|
}
|
|
2176
2450
|
/**
|
|
@@ -2247,12 +2521,12 @@ DECLARE ${varName} NVARCHAR(MAX) = (
|
|
|
2247
2521
|
);
|
|
2248
2522
|
IF ${varName} IS NOT NULL
|
|
2249
2523
|
EXEC [${this.MJCoreSchemaName}].spCreateRecordChange_Internal
|
|
2250
|
-
@EntityName='${safeEntityName}',
|
|
2251
|
-
@RecordID='${recordID}',
|
|
2524
|
+
@EntityName=N'${safeEntityName}',
|
|
2525
|
+
@RecordID=N'${recordID}',
|
|
2252
2526
|
@UserID='${safeUserId}',
|
|
2253
2527
|
@Type='Update',
|
|
2254
|
-
@ChangesJSON='${safeChangesJSON}',
|
|
2255
|
-
@ChangesDescription='${safeChangesDesc}',
|
|
2528
|
+
@ChangesJSON=N'${safeChangesJSON}',
|
|
2529
|
+
@ChangesDescription=N'${safeChangesDesc}',
|
|
2256
2530
|
@FullRecordJSON=${varName},
|
|
2257
2531
|
@Status='Complete',
|
|
2258
2532
|
@Comments=NULL${lineageClause};`;
|
|
@@ -2270,7 +2544,7 @@ IF ${varName} IS NOT NULL
|
|
|
2270
2544
|
const transaction = new sql.Transaction(this._pool);
|
|
2271
2545
|
await transaction.begin();
|
|
2272
2546
|
this._transaction = transaction;
|
|
2273
|
-
this.
|
|
2547
|
+
this.setTransactionActiveState(true);
|
|
2274
2548
|
}
|
|
2275
2549
|
/**
|
|
2276
2550
|
* Internal mssql transaction interface to safely inspect `_activeRequest` without `any`.
|
|
@@ -2305,8 +2579,10 @@ IF ${varName} IS NOT NULL
|
|
|
2305
2579
|
* that queued ambient queries behind it are rejected rather than run on the doomed handle.
|
|
2306
2580
|
*/
|
|
2307
2581
|
enqueueTransactionAction(description, handle, run) {
|
|
2582
|
+
this.initializeQueueProcessor();
|
|
2583
|
+
const queue = this.ensureSqlQueue();
|
|
2308
2584
|
return new Promise((resolve, reject) => {
|
|
2309
|
-
|
|
2585
|
+
queue.next({ kind: 'action', description, handle, run, resolve, reject });
|
|
2310
2586
|
});
|
|
2311
2587
|
}
|
|
2312
2588
|
async CommitPhysicalTransaction() {
|
|
@@ -2331,13 +2607,13 @@ IF ${varName} IS NOT NULL
|
|
|
2331
2607
|
// AbandonPhysicalTransaction can roll the doomed handle back — nulling it first, as the old
|
|
2332
2608
|
// `finally` did, made that abandon a no-op and leaked the server-side transaction (#4447).
|
|
2333
2609
|
this._transaction = null;
|
|
2334
|
-
this.
|
|
2610
|
+
this.setTransactionActiveState(false);
|
|
2335
2611
|
});
|
|
2336
2612
|
}
|
|
2337
2613
|
async AbandonPhysicalTransaction() {
|
|
2338
2614
|
const stale = this._transaction;
|
|
2339
2615
|
this._transaction = null;
|
|
2340
|
-
this.
|
|
2616
|
+
this.setTransactionActiveState(false);
|
|
2341
2617
|
if (stale) {
|
|
2342
2618
|
try {
|
|
2343
2619
|
// Through the queue, like commit and rollback: the handle is already nulled above, so any
|
|
@@ -2371,7 +2647,7 @@ IF ${varName} IS NOT NULL
|
|
|
2371
2647
|
}
|
|
2372
2648
|
finally {
|
|
2373
2649
|
this._transaction = null;
|
|
2374
|
-
this.
|
|
2650
|
+
this.setTransactionActiveState(false);
|
|
2375
2651
|
}
|
|
2376
2652
|
}
|
|
2377
2653
|
async OnBeginFailedAtDepthZero() {
|