@memberjunction/sqlserver-dataprovider 6.2.0-edge.5 → 6.2.0-edge.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/SQLServerDataProvider.d.ts +38 -13
- package/dist/SQLServerDataProvider.d.ts.map +1 -1
- package/dist/SQLServerDataProvider.js +218 -52
- package/dist/SQLServerDataProvider.js.map +1 -1
- package/dist/SQLServerTransactionGroup.js +1 -1
- package/dist/SQLServerTransactionGroup.js.map +1 -1
- package/dist/statementDescription.d.ts +31 -0
- package/dist/statementDescription.d.ts.map +1 -0
- package/dist/statementDescription.js +46 -0
- package/dist/statementDescription.js.map +1 -0
- package/dist/typedParameters.d.ts +15 -0
- package/dist/typedParameters.d.ts.map +1 -0
- package/dist/typedParameters.js +85 -0
- package/dist/typedParameters.js.map +1 -0
- package/package.json +16 -16
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
import { EntityFieldTSType, ProviderType, LogError, LogStatus, LogStatusEx, CompositeKey, } from '@memberjunction/core';
|
|
19
19
|
import { NodeFileSystemProvider } from './NodeFileSystemProvider.js';
|
|
20
20
|
import { QueueManager } from '@memberjunction/queue';
|
|
21
|
-
import { GenericDatabaseProvider } from '@memberjunction/generic-database-provider';
|
|
21
|
+
import { GenericDatabaseProvider, IsTypedQueryParameterList, IsNamedQueryParameterList, TypedQueryParameter, DescribeStatementFailure } from '@memberjunction/generic-database-provider';
|
|
22
|
+
import { BindTypedParameters } from './typedParameters.js';
|
|
23
|
+
import { MapSQLServerDescribeRows, SQLSERVER_DESCRIBE_SQL } from './statementDescription.js';
|
|
22
24
|
import sql from 'mssql';
|
|
23
25
|
import { BehaviorSubject, Subject, concatMap, from, catchError, of } from 'rxjs';
|
|
24
26
|
import { SQLServerTransactionGroup } from './SQLServerTransactionGroup.js';
|
|
@@ -60,7 +62,11 @@ function isStaleConnectionError(error) {
|
|
|
60
62
|
code === 'ECONNRESET');
|
|
61
63
|
}
|
|
62
64
|
/**
|
|
63
|
-
* Builds a sql.Request
|
|
65
|
+
* Builds a sql.Request and binds `parameters`:
|
|
66
|
+
* - a SaveParameter list, a TypedQueryParameter list or a NamedQueryParameter list binds each by name;
|
|
67
|
+
* - a plain object binds each key by name (`{ name: v }` for `@name`);
|
|
68
|
+
* - a plain array is the LEGACY positional form: `@p0..@pN`, with every `?` in the statement rewritten,
|
|
69
|
+
* including one inside a string literal, quoted identifier or comment. Prefer a named form.
|
|
64
70
|
*/
|
|
65
71
|
function buildRequest(connectionSource, query, parameters) {
|
|
66
72
|
// Note: The branch looks redundant but is required for TypeScript type narrowing.
|
|
@@ -80,10 +86,25 @@ function buildRequest(connectionSource, query, parameters) {
|
|
|
80
86
|
}
|
|
81
87
|
return { request, processedQuery: query };
|
|
82
88
|
}
|
|
89
|
+
if (IsTypedQueryParameterList(parameters)) {
|
|
90
|
+
// Named inputs declared with the types of the columns they are compared with (typedParameters.ts).
|
|
91
|
+
BindTypedParameters(request, parameters);
|
|
92
|
+
return { request, processedQuery: query };
|
|
93
|
+
}
|
|
94
|
+
if (IsNamedQueryParameterList(parameters)) {
|
|
95
|
+
// Named inputs whose type the driver infers; the statement already references them by name.
|
|
96
|
+
for (const p of parameters) {
|
|
97
|
+
request.input(p.Name, p.Value);
|
|
98
|
+
}
|
|
99
|
+
return { request, processedQuery: query };
|
|
100
|
+
}
|
|
83
101
|
let processedQuery = query;
|
|
84
102
|
if (parameters) {
|
|
85
103
|
if (Array.isArray(parameters)) {
|
|
86
|
-
//
|
|
104
|
+
// LEGACY positional form: values bind as @p0..@pN and EVERY `?` in the text is rewritten to the
|
|
105
|
+
// next @pN, a `?` inside a string literal, quoted identifier or comment included. Kept for
|
|
106
|
+
// existing callers; MJ's own SQL passes named parameters (an object, NamedQueryParameter or
|
|
107
|
+
// TypedQueryParameter lists), which leave the text alone.
|
|
87
108
|
parameters.forEach((value, index) => {
|
|
88
109
|
request.input(`p${index}`, value);
|
|
89
110
|
});
|
|
@@ -244,7 +265,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
244
265
|
* ENOTBEGUN on a finished handle. A query on an explicit handle a caller passed in — an IS-A chain
|
|
245
266
|
* sharing its own transaction — is never subject to that check.
|
|
246
267
|
*/
|
|
247
|
-
this._sqlQueue$ =
|
|
268
|
+
this._sqlQueue$ = null;
|
|
248
269
|
/**
|
|
249
270
|
* Handles whose commit or rollback has run — or whose commit FAILED, which dooms them just the same.
|
|
250
271
|
* The ambient check in runQueueItem consults this as well as `_transaction`, because a failed
|
|
@@ -252,8 +273,10 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
252
273
|
* already being dequeued; without the marker that item would run on the doomed handle.
|
|
253
274
|
*/
|
|
254
275
|
this._endedHandles = new WeakSet();
|
|
255
|
-
// Transaction state management
|
|
256
|
-
|
|
276
|
+
// Transaction state management — lazy-allocated so read-only per-request providers
|
|
277
|
+
// avoid allocating RxJS Subject/BehaviorSubject instances when no transaction is opened.
|
|
278
|
+
this._isTransactionActive = false;
|
|
279
|
+
this._transactionState$ = null;
|
|
257
280
|
}
|
|
258
281
|
/**
|
|
259
282
|
* Returns the SQL Server dialect instance so that platform-specific tokens
|
|
@@ -312,19 +335,66 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
312
335
|
* `sys.columns` scan in {@link loadViewColumnOrderCache} ran once or twice on EVERY request
|
|
313
336
|
* (issue #3102 — ~9.6k scans/day in production vs the ~2 needed).
|
|
314
337
|
*
|
|
315
|
-
* Keyed by
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
338
|
+
* Keyed by normalized database target AND login (`user@server:port[/instance]/database`) when
|
|
339
|
+
* `ConnectionPool.config` exposes them — so pools opened with the same login against the SAME
|
|
340
|
+
* database (per-request RW providers around the boot-time pool) share one `sys.columns` scan and
|
|
341
|
+
* one cross-pool invalidation domain, while pools pointing to DIFFERENT databases, or using a
|
|
342
|
+
* different login (read-only, CodeGen), stay isolated: `sys.columns` only lists objects the login
|
|
343
|
+
* can see, so one login's scan must never stand in for another's. Pools without `config.user`
|
|
344
|
+
* (token/integrated auth) share by target alone. Falls back to `_viewColumnOrderCacheByPool`
|
|
345
|
+
* (`WeakMap`) when a pool object does not expose `config.server` + `config.database` (e.g.
|
|
346
|
+
* lightweight test doubles).
|
|
321
347
|
*
|
|
322
348
|
* The value is the load PROMISE (not the resolved Map) so concurrent per-request Config() calls
|
|
323
349
|
* de-duplicate onto a single in-flight scan. Invalidated on metadata refresh (see
|
|
324
350
|
* {@link Refresh} / {@link InvalidateViewColumnOrderCache}) so view changes from
|
|
325
|
-
* migrations/CodeGen are picked up without a server restart.
|
|
351
|
+
* migrations/CodeGen are picked up across all pools targeting that database without a server restart.
|
|
326
352
|
*/
|
|
327
353
|
static { this._viewColumnOrderCacheByPool = new WeakMap(); }
|
|
354
|
+
static { this._viewColumnOrderCacheByDbKey = new Map(); }
|
|
355
|
+
static { this.DEFAULT_SQL_SERVER_PORT = 1433; }
|
|
356
|
+
static viewColumnOrderDbKey(pool) {
|
|
357
|
+
const cfg = pool?.config;
|
|
358
|
+
if (typeof cfg?.server === 'string' &&
|
|
359
|
+
cfg.server.trim().length > 0 &&
|
|
360
|
+
typeof cfg?.database === 'string' &&
|
|
361
|
+
cfg.database.trim().length > 0) {
|
|
362
|
+
const host = cfg.server.trim().toLowerCase();
|
|
363
|
+
const port = typeof cfg.port === 'number' && cfg.port > 0
|
|
364
|
+
? cfg.port
|
|
365
|
+
: SQLServerDataProvider.DEFAULT_SQL_SERVER_PORT;
|
|
366
|
+
const instance = typeof cfg.options?.instanceName === 'string' && cfg.options.instanceName.trim().length > 0
|
|
367
|
+
? `/${cfg.options.instanceName.trim().toLowerCase()}`
|
|
368
|
+
: '';
|
|
369
|
+
const db = cfg.database.trim().toLowerCase();
|
|
370
|
+
// The login is part of the identity: sys.columns only lists objects the login can see, so a
|
|
371
|
+
// scan made through the read-only or CodeGen login must never be served to the read-write
|
|
372
|
+
// pool (or vice versa) — a view missing from the map falls back to metadata order, the very
|
|
373
|
+
// misrouting this cache exists to prevent (#3102). Pools sharing server, database AND login
|
|
374
|
+
// still share one scan.
|
|
375
|
+
const user = typeof cfg.user === 'string' && cfg.user.trim().length > 0 ? cfg.user.trim().toLowerCase() : '';
|
|
376
|
+
return `mssql://${user}@${host}:${port}${instance}/${db}`;
|
|
377
|
+
}
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
ensureSqlQueue() {
|
|
381
|
+
if (!this._sqlQueue$) {
|
|
382
|
+
this._sqlQueue$ = new Subject();
|
|
383
|
+
}
|
|
384
|
+
return this._sqlQueue$;
|
|
385
|
+
}
|
|
386
|
+
ensureTransactionStateSubject() {
|
|
387
|
+
if (!this._transactionState$) {
|
|
388
|
+
this._transactionState$ = new BehaviorSubject(this._isTransactionActive);
|
|
389
|
+
}
|
|
390
|
+
return this._transactionState$;
|
|
391
|
+
}
|
|
392
|
+
setTransactionActiveState(active) {
|
|
393
|
+
this._isTransactionActive = active;
|
|
394
|
+
if (this._transactionState$) {
|
|
395
|
+
this._transactionState$.next(active);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
328
398
|
/**
|
|
329
399
|
* Observable that emits the current transaction state (true when active, false when not)
|
|
330
400
|
* External code can subscribe to this to know when transactions start and end
|
|
@@ -335,7 +405,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
335
405
|
* });
|
|
336
406
|
*/
|
|
337
407
|
get TransactionState$() {
|
|
338
|
-
return this.
|
|
408
|
+
return this.ensureTransactionStateSubject().asObservable();
|
|
339
409
|
}
|
|
340
410
|
/** @deprecated Use {@link TransactionState$}. */
|
|
341
411
|
get transactionState$() {
|
|
@@ -367,7 +437,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
367
437
|
get IsTransactionActive() {
|
|
368
438
|
// Always return instance-level state
|
|
369
439
|
// Request-specific state should be accessed via getTransactionContext
|
|
370
|
-
return this.
|
|
440
|
+
return this._isTransactionActive;
|
|
371
441
|
}
|
|
372
442
|
/** @deprecated Use {@link IsTransactionActive}. */
|
|
373
443
|
get isTransactionActive() {
|
|
@@ -426,10 +496,18 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
426
496
|
*/
|
|
427
497
|
async loadViewColumnOrderCache() {
|
|
428
498
|
const pool = this._pool;
|
|
429
|
-
|
|
499
|
+
const dbKey = SQLServerDataProvider.viewColumnOrderDbKey(pool);
|
|
500
|
+
let scanPromise = dbKey !== null
|
|
501
|
+
? SQLServerDataProvider._viewColumnOrderCacheByDbKey.get(dbKey)
|
|
502
|
+
: SQLServerDataProvider._viewColumnOrderCacheByPool.get(pool);
|
|
430
503
|
if (!scanPromise) {
|
|
431
504
|
scanPromise = this.scanViewColumnOrder();
|
|
432
|
-
|
|
505
|
+
if (dbKey !== null) {
|
|
506
|
+
SQLServerDataProvider._viewColumnOrderCacheByDbKey.set(dbKey, scanPromise);
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
SQLServerDataProvider._viewColumnOrderCacheByPool.set(pool, scanPromise);
|
|
510
|
+
}
|
|
433
511
|
}
|
|
434
512
|
try {
|
|
435
513
|
this._viewColumnOrderCache = await scanPromise;
|
|
@@ -437,7 +515,12 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
437
515
|
catch (e) {
|
|
438
516
|
// best-effort: an empty cache makes getAllEntityColumnsSQL fall back to the metadata-derived order.
|
|
439
517
|
// Drop the shared entry (only if it's still OUR failed promise) so a later Config() can retry.
|
|
440
|
-
if (
|
|
518
|
+
if (dbKey !== null) {
|
|
519
|
+
if (SQLServerDataProvider._viewColumnOrderCacheByDbKey.get(dbKey) === scanPromise) {
|
|
520
|
+
SQLServerDataProvider._viewColumnOrderCacheByDbKey.delete(dbKey);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
else if (SQLServerDataProvider._viewColumnOrderCacheByPool.get(pool) === scanPromise) {
|
|
441
524
|
SQLServerDataProvider._viewColumnOrderCacheByPool.delete(pool);
|
|
442
525
|
}
|
|
443
526
|
this._viewColumnOrderCache = new Map();
|
|
@@ -484,6 +567,10 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
484
567
|
*/
|
|
485
568
|
static InvalidateViewColumnOrderCache(pool) {
|
|
486
569
|
SQLServerDataProvider._viewColumnOrderCacheByPool.delete(pool);
|
|
570
|
+
const dbKey = SQLServerDataProvider.viewColumnOrderDbKey(pool);
|
|
571
|
+
if (dbKey !== null) {
|
|
572
|
+
SQLServerDataProvider._viewColumnOrderCacheByDbKey.delete(dbKey);
|
|
573
|
+
}
|
|
487
574
|
}
|
|
488
575
|
/**
|
|
489
576
|
* Metadata refresh invalidates this pool's shared view-column-order cache before re-running
|
|
@@ -527,7 +614,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
527
614
|
// 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
|
|
528
615
|
// the sub, taht would cause duplicate rprocessing.
|
|
529
616
|
if (!this._queueSubscription) {
|
|
530
|
-
this._queueSubscription = this.
|
|
617
|
+
this._queueSubscription = this.ensureSqlQueue().pipe(concatMap(item => from(this.runQueueItem(item)).pipe(
|
|
531
618
|
// runQueueItem settles the item's own promise on success; only failure is handled here
|
|
532
619
|
catchError(error => {
|
|
533
620
|
item.reject(error);
|
|
@@ -674,9 +761,11 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
674
761
|
if (items.length === 0) {
|
|
675
762
|
return results;
|
|
676
763
|
}
|
|
677
|
-
// Build array of SQL statements for batch execution
|
|
764
|
+
// Build array of SQL statements for batch execution (recording per-item resolution errors
|
|
765
|
+
// in `results` without aborting valid sibling items in the batch)
|
|
678
766
|
const sqlStatements = [];
|
|
679
|
-
|
|
767
|
+
const validItems = [];
|
|
768
|
+
for (const { index, entityInfo, whereSQL, item } of items) {
|
|
680
769
|
// The freshness probe MUST target the same physical view the read targets. For a
|
|
681
770
|
// DataSource:'Materialized' read that's the materialized_vw<CodeName> wrapper (a full SELECT *
|
|
682
771
|
// snapshot of the base view, so it carries __mj_UpdatedAt) — NOT the live base view. Probing the
|
|
@@ -686,15 +775,27 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
686
775
|
// still supplies a materialized cacheStatus.) Use the status-gated resolveEffectiveBaseView (not the
|
|
687
776
|
// unconditional GetEffectiveBaseView) so a Building/DriftHold/Disabled/never-minted snapshot probes the
|
|
688
777
|
// LIVE base view — mirroring the read path — instead of a held or missing materialized_vw wrapper.
|
|
689
|
-
|
|
690
|
-
|
|
778
|
+
try {
|
|
779
|
+
const effectiveTarget = await this.resolveEffectiveBaseViewTarget(entityInfo, item.params, contextUser);
|
|
780
|
+
sqlStatements.push(this.BuildCacheStatusSQL(entityInfo, effectiveTarget.viewName, whereSQL, effectiveTarget.schemaName));
|
|
781
|
+
validItems.push({ index });
|
|
782
|
+
}
|
|
783
|
+
catch (err) {
|
|
784
|
+
results.set(index, {
|
|
785
|
+
success: false,
|
|
786
|
+
errorMessage: err instanceof Error ? err.message : String(err),
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (sqlStatements.length === 0) {
|
|
791
|
+
return results;
|
|
691
792
|
}
|
|
692
793
|
try {
|
|
693
794
|
// Execute the batched SQL using existing ExecuteSQLBatch method
|
|
694
795
|
const resultSets = await this.ExecuteSQLBatch(sqlStatements, undefined, undefined, contextUser);
|
|
695
796
|
// Process each result set and map to the corresponding item index
|
|
696
|
-
for (let i = 0; i <
|
|
697
|
-
const { index } =
|
|
797
|
+
for (let i = 0; i < validItems.length; i++) {
|
|
798
|
+
const { index } = validItems[i];
|
|
698
799
|
const resultSet = resultSets[i];
|
|
699
800
|
if (resultSet && resultSet.length > 0) {
|
|
700
801
|
const row = resultSet[0];
|
|
@@ -722,24 +823,25 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
722
823
|
}
|
|
723
824
|
return results;
|
|
724
825
|
}
|
|
725
|
-
async executeSQLForUserViewRunLogging(viewId, entityInfo, effectiveBaseView, whereSQL, orderBySQL, user) {
|
|
826
|
+
async executeSQLForUserViewRunLogging(viewId, entityInfo, effectiveBaseView, whereSQL, orderBySQL, user, schemaNameOverride) {
|
|
726
827
|
// entityInfo + effectiveBaseView are passed in (no reverse-lookup by base-view name) so the logged
|
|
727
|
-
// read honors DataSource:'Materialized' — effectiveBaseView is the materialized
|
|
728
|
-
// and the entity's live base view otherwise.
|
|
828
|
+
// read honors DataSource:'Materialized' and AlternateBaseView — effectiveBaseView is the materialized
|
|
829
|
+
// or alternate view then, and the entity's live base view otherwise.
|
|
729
830
|
//
|
|
730
831
|
// UserViewRunDetail.RecordID is a single NVARCHAR(255) holding ONE bare key value per row, and the
|
|
731
832
|
// read-back below is `<pk> IN (SELECT RecordID ...)` — a one-column predicate. A composite key has
|
|
732
833
|
// no single column to log or match on, so refuse rather than record (and re-read) a truncated key.
|
|
733
834
|
this.assertSingleColumnPrimaryKey(entityInfo, 'SaveViewResults (user view run logging)');
|
|
734
835
|
const pkName = entityInfo.FirstPrimaryKey.Name; // first-pk-ok: guarded above — view-run RecordID holds one bare key value, PrimaryKeys.length === 1 enforced
|
|
836
|
+
const targetSchema = schemaNameOverride ?? entityInfo.SchemaName;
|
|
735
837
|
const sSQL = `
|
|
736
838
|
DECLARE @ViewIDList TABLE ( ID NVARCHAR(255) );
|
|
737
|
-
INSERT INTO @ViewIDList (ID) (SELECT ${pkName} FROM [${
|
|
839
|
+
INSERT INTO @ViewIDList (ID) (SELECT ${pkName} FROM [${targetSchema}].${effectiveBaseView} WHERE (${whereSQL}))
|
|
738
840
|
EXEC [${this.MJCoreSchemaName}].spCreateUserViewRunWithDetail(${viewId},${user.Email}, @ViewIDLIst)
|
|
739
841
|
`;
|
|
740
842
|
const runIDResult = await this.ExecuteSQL(sSQL, undefined, undefined, user);
|
|
741
843
|
const runID = runIDResult[0].UserViewRunID;
|
|
742
|
-
const sRetSQL = `SELECT * FROM [${
|
|
844
|
+
const sRetSQL = `SELECT * FROM [${targetSchema}].${effectiveBaseView} WHERE ${pkName} IN
|
|
743
845
|
(SELECT RecordID FROM [${this.MJCoreSchemaName}].vwUserViewRunDetails WHERE UserViewRunID=${runID})
|
|
744
846
|
${orderBySQL && orderBySQL.length > 0 ? ` ORDER BY ${orderBySQL}` : ''}`;
|
|
745
847
|
return { executeViewSQL: sRetSQL, runID };
|
|
@@ -760,7 +862,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
760
862
|
* @param entityName
|
|
761
863
|
* @param compositeKey
|
|
762
864
|
*/
|
|
763
|
-
BuildSoftLinkDependencySQL(entityName, compositeKey) {
|
|
865
|
+
BuildSoftLinkDependencySQL(entityName, compositeKey, options, contextUser) {
|
|
764
866
|
// we need to go through ALL of the entities in the system and find all of the EntityFields that have a non-null EntityIDFieldName
|
|
765
867
|
// 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
|
|
766
868
|
//
|
|
@@ -771,11 +873,17 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
771
873
|
if (!targetEntity) {
|
|
772
874
|
throw new Error(`Entity ${entityName} not found in metadata`);
|
|
773
875
|
}
|
|
774
|
-
//
|
|
775
|
-
//
|
|
776
|
-
const targetRecordID = compositeKey.ToRecordID();
|
|
876
|
+
// Single-primary-key records referenced by polymorphic tables may store either the canonical
|
|
877
|
+
// `ID|<guid>` encoding (`CompositeKey.ToRecordID()`) OR the bare primary key value (`<guid>`).
|
|
878
|
+
const targetRecordID = this.escapeSQLLiteral(compositeKey.ToRecordID());
|
|
879
|
+
const bareValue = compositeKey.KeyValuePairs.length === 1
|
|
880
|
+
? this.escapeSQLLiteral(String(compositeKey.KeyValuePairs[0].Value ?? ''))
|
|
881
|
+
: '';
|
|
777
882
|
let sSQL = '';
|
|
778
883
|
this.Entities.forEach((entity) => {
|
|
884
|
+
if (!this.shouldIncludeDependencyEntity(entity, options, contextUser)) {
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
779
887
|
// 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
|
|
780
888
|
// 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
|
|
781
889
|
// 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
|
|
@@ -785,6 +893,10 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
785
893
|
// each field in f must be processed
|
|
786
894
|
if (sSQL.length > 0)
|
|
787
895
|
sSQL += ' UNION ALL ';
|
|
896
|
+
const sourceObject = this.resolveDependencySourceObject(entity, [f.Name, f.EntityIDFieldName]);
|
|
897
|
+
const recordIDPredicate = bareValue && bareValue !== targetRecordID
|
|
898
|
+
? `[${f.Name}] IN ('${targetRecordID}', '${bareValue}')`
|
|
899
|
+
: `[${f.Name}] = '${targetRecordID}'`;
|
|
788
900
|
// 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
|
|
789
901
|
// 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
|
|
790
902
|
// based on that.
|
|
@@ -800,10 +912,10 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
800
912
|
1 AS IsSoftLink,
|
|
801
913
|
'${f.EntityIDFieldName}' AS EntityIDFieldName
|
|
802
914
|
FROM
|
|
803
|
-
[${entity.SchemaName}].[${
|
|
915
|
+
[${entity.SchemaName}].[${sourceObject}]
|
|
804
916
|
WHERE
|
|
805
917
|
[${f.EntityIDFieldName}] = '${targetEntity.ID}' AND
|
|
806
|
-
|
|
918
|
+
${recordIDPredicate}`;
|
|
807
919
|
});
|
|
808
920
|
});
|
|
809
921
|
return sSQL;
|
|
@@ -816,13 +928,22 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
816
928
|
escapeSQLLiteral(value) {
|
|
817
929
|
return value.replace(/'/g, "''");
|
|
818
930
|
}
|
|
819
|
-
BuildHardLinkDependencySQL(
|
|
931
|
+
BuildHardLinkDependencySQL(entityDependenciesOrName, compositeKey, options, contextUser) {
|
|
932
|
+
const entityDependencies = this.resolveEntityDependencies(entityDependenciesOrName);
|
|
820
933
|
let sSQL = '';
|
|
821
934
|
for (const entityDependency of entityDependencies) {
|
|
822
935
|
const entityInfo = this.Entities.find((e) => e.Name.trim().toLowerCase() === entityDependency.EntityName?.trim().toLowerCase());
|
|
823
|
-
const quotes = entityInfo.FirstPrimaryKey.NeedsQuotes ? "'" : ''; // first-pk-ok: a foreign key targets a single column; quoting follows that FK target's type
|
|
824
936
|
const relatedEntityInfo = this.Entities.find((e) => e.Name.trim().toLowerCase() === entityDependency.RelatedEntityName?.trim().toLowerCase());
|
|
825
|
-
|
|
937
|
+
if (!entityInfo || !relatedEntityInfo)
|
|
938
|
+
continue;
|
|
939
|
+
if (!this.shouldIncludeDependencyEntity(relatedEntityInfo, options, contextUser))
|
|
940
|
+
continue;
|
|
941
|
+
const fkField = relatedEntityInfo.Fields.find((f) => f.Name.trim().toLowerCase() === entityDependency.FieldName?.trim().toLowerCase());
|
|
942
|
+
if (fkField?.EntityIDFieldName && fkField.EntityIDFieldName.trim().length > 0)
|
|
943
|
+
continue;
|
|
944
|
+
const pksToProject = relatedEntityInfo.PrimaryKeys?.length ? relatedEntityInfo.PrimaryKeys : entityInfo.PrimaryKeys;
|
|
945
|
+
const primaryKeySelectString = `CONCAT(${pksToProject.map((pk) => `'${pk.Name}|', CAST([${pk.Name}] AS NVARCHAR(MAX))`).join(`,'${CompositeKey.DefaultFieldDelimiter}',`)})`;
|
|
946
|
+
const sourceObject = this.resolveDependencySourceObject(relatedEntityInfo, [entityDependency.FieldName]);
|
|
826
947
|
if (sSQL.length > 0)
|
|
827
948
|
sSQL += ' UNION ALL ';
|
|
828
949
|
sSQL += `SELECT
|
|
@@ -833,7 +954,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
833
954
|
0 AS IsSoftLink,
|
|
834
955
|
NULL AS EntityIDFieldName
|
|
835
956
|
FROM
|
|
836
|
-
[${relatedEntityInfo.SchemaName}].[${
|
|
957
|
+
[${relatedEntityInfo.SchemaName}].[${sourceObject}]
|
|
837
958
|
WHERE
|
|
838
959
|
[${entityDependency.FieldName}] = ${this.GetRecordDependencyLinkSQL(entityDependency, entityInfo, relatedEntityInfo, compositeKey)}`;
|
|
839
960
|
}
|
|
@@ -844,7 +965,8 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
844
965
|
if (!f) {
|
|
845
966
|
throw new Error(`Field ${dep.FieldName} not found in Entity ${relatedEntity.Name}`);
|
|
846
967
|
}
|
|
847
|
-
|
|
968
|
+
const relFieldName = f.RelatedEntityFieldName?.trim().toLowerCase();
|
|
969
|
+
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
|
|
848
970
|
// simple link to first primary key, most common scenario for linkages
|
|
849
971
|
// Key values arrive from remote callers — render through the shared sanitizer.
|
|
850
972
|
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
|
|
@@ -899,20 +1021,23 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
899
1021
|
* @param bNewRecord - Whether this is a new record (create) or existing record (update)
|
|
900
1022
|
* @param spName - The stored procedure name to call
|
|
901
1023
|
* @param user - The user context for the operation
|
|
1024
|
+
* @param transaction - The transaction the save will run in, when one is already open. A read
|
|
1025
|
+
* the save's Record Change snapshot needs runs in it, so it sees that transaction's earlier
|
|
1026
|
+
* writes and never waits on the locks they hold.
|
|
902
1027
|
* @returns The full SQL statement for the save operation
|
|
903
1028
|
*
|
|
904
1029
|
* @security This method handles field-level encryption transparently.
|
|
905
1030
|
* Fields marked with Encrypt=true will have their values encrypted
|
|
906
1031
|
* before being included in the SQL statement.
|
|
907
1032
|
*/
|
|
908
|
-
async GetSaveSQL(entity, bNewRecord, _spName, user) {
|
|
1033
|
+
async GetSaveSQL(entity, bNewRecord, _spName, user, transaction) {
|
|
909
1034
|
// SQLServerTransactionGroup-batched saves regenerate per-item SQL when
|
|
910
1035
|
// transaction variables get rebound. `_spName` is preserved for back-compat
|
|
911
1036
|
// with that caller; the dialect-driven pipeline derives it from entity
|
|
912
1037
|
// metadata via `GetCreateUpdateSPName`. Outside a TransactionGroup,
|
|
913
1038
|
// `fullSQL` holds `@p$0` parameter placeholders, so return `literalSQL`
|
|
914
1039
|
// for standalone callers.
|
|
915
|
-
const result = await this.
|
|
1040
|
+
const result = await this.GenerateSaveSQLReadingOn(entity, bNewRecord, user, transaction);
|
|
916
1041
|
return result.literalSQL ?? result.fullSQL;
|
|
917
1042
|
}
|
|
918
1043
|
/**
|
|
@@ -1661,7 +1786,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1661
1786
|
let sSQL;
|
|
1662
1787
|
if (entity.EntityInfo.TrackRecordChanges && !skipRecordChanges && entity.EntityInfo.Name.trim().toLowerCase() !== 'record changes') {
|
|
1663
1788
|
// don't track changes for the record changes entity
|
|
1664
|
-
const oldData = entity.GetAll(true); //
|
|
1789
|
+
const oldData = this.RecordChangeDeleteSnapshot(entity, entity.GetAll(true)); // the OLD values, full width
|
|
1665
1790
|
const sTableDeclare = entity.PrimaryKeys.map((pk) => {
|
|
1666
1791
|
return `${pk.CodeName} ${pk.EntityFieldInfo.SQLFullType}`;
|
|
1667
1792
|
}).join(', ');
|
|
@@ -1896,8 +2021,9 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1896
2021
|
// For transactional queries, use the instance queue to ensure serialization
|
|
1897
2022
|
// This prevents EREQINPROG errors when multiple queries try to use the same transaction
|
|
1898
2023
|
this.initializeQueueProcessor();
|
|
2024
|
+
const queue = this.ensureSqlQueue();
|
|
1899
2025
|
return new Promise((resolve, reject) => {
|
|
1900
|
-
|
|
2026
|
+
queue.next({
|
|
1901
2027
|
kind: 'query',
|
|
1902
2028
|
query,
|
|
1903
2029
|
parameters,
|
|
@@ -1997,7 +2123,10 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
1997
2123
|
* This method can be used to execute raw SQL statements outside of the MJ infrastructure.
|
|
1998
2124
|
* *CAUTION* - use this method with great care.
|
|
1999
2125
|
* @param query
|
|
2000
|
-
* @param parameters
|
|
2126
|
+
* @param parameters - Named parameters: an object (`{ name: value }`, referenced `@name`), or a list of
|
|
2127
|
+
* `NamedQueryParameter` / `TypedQueryParameter`. A plain array of values is the LEGACY positional form:
|
|
2128
|
+
* it binds `@p0..@pN` and rewrites every `?` in `query`, a `?` inside a string literal, quoted
|
|
2129
|
+
* identifier or comment included, so it is only safe for statement text with no other `?`.
|
|
2001
2130
|
* @returns
|
|
2002
2131
|
*/
|
|
2003
2132
|
async ExecuteSQL(query, parameters = null, options, contextUser) {
|
|
@@ -2027,6 +2156,21 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
2027
2156
|
throw e; // force caller to handle
|
|
2028
2157
|
}
|
|
2029
2158
|
}
|
|
2159
|
+
/**
|
|
2160
|
+
* Describes the first result set of `sqlText` with `sys.dm_exec_describe_first_result_set` (mode 2):
|
|
2161
|
+
* names, order, exact types, nullability and the view or table column each pass-through column
|
|
2162
|
+
* comes from. SQL Server compiles the statement without executing it. A statement the server
|
|
2163
|
+
* cannot compile gives a failure carrying the server's message.
|
|
2164
|
+
*/
|
|
2165
|
+
async DescribeStatement(sqlText, contextUser) {
|
|
2166
|
+
try {
|
|
2167
|
+
const rows = await this.ExecuteSQL(SQLSERVER_DESCRIBE_SQL, [new TypedQueryParameter('tsql', 'nvarchar(max)', sqlText)], { description: 'DescribeStatement', ignoreLogging: true }, contextUser);
|
|
2168
|
+
return MapSQLServerDescribeRows(rows);
|
|
2169
|
+
}
|
|
2170
|
+
catch (e) {
|
|
2171
|
+
return DescribeStatementFailure(e instanceof Error ? e.message : String(e));
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2030
2174
|
// ─── Colocated vector host (IColocatedVectorHost) ────────────────
|
|
2031
2175
|
// Lets a colocated vector provider (e.g. SQLServerVectorDatabase, SQL Server 2025 native
|
|
2032
2176
|
// vectors) store and query vectors in THIS database, reusing this connection — instead of
|
|
@@ -2406,7 +2550,7 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
2406
2550
|
const unionParts = childEntities
|
|
2407
2551
|
.filter(child => child.PrimaryKeys.length > 0)
|
|
2408
2552
|
.map(child => {
|
|
2409
|
-
const schema = child.SchemaName || '__mj';
|
|
2553
|
+
const schema = child.SchemaName || this.MJCoreSchemaName || '__mj';
|
|
2410
2554
|
const sourceRef = dialect.QuoteSchema(schema, child.BaseView);
|
|
2411
2555
|
const pkRef = dialect.QuoteIdentifier(child.FirstPrimaryKey.Name); // first-pk-ok: IS-A child shares its parent's single-column key by design
|
|
2412
2556
|
const nameLit = dialect.QuoteStringLiteral(child.Name);
|
|
@@ -2416,6 +2560,27 @@ export class SQLServerDataProvider extends GenericDatabaseProvider {
|
|
|
2416
2560
|
return '';
|
|
2417
2561
|
return unionParts.join(' UNION ALL ');
|
|
2418
2562
|
}
|
|
2563
|
+
BuildChildDiscoveryBatchSQL(childEntities, recordPKValues) {
|
|
2564
|
+
if (recordPKValues.length === 0)
|
|
2565
|
+
return '';
|
|
2566
|
+
const dialect = this.getDialect();
|
|
2567
|
+
const inList = recordPKValues.map(v => dialect.QuoteStringLiteral(v)).join(', ');
|
|
2568
|
+
const aliasEntityName = dialect.QuoteColumnAlias('EntityName');
|
|
2569
|
+
const aliasRecordPK = dialect.QuoteColumnAlias('RecordPK');
|
|
2570
|
+
const aliasPriority = dialect.QuoteColumnAlias('Priority');
|
|
2571
|
+
const unionParts = childEntities
|
|
2572
|
+
.filter(child => child.PrimaryKeys.length > 0)
|
|
2573
|
+
.map((child, idx) => {
|
|
2574
|
+
const schema = child.SchemaName || this.MJCoreSchemaName || '__mj';
|
|
2575
|
+
const sourceRef = dialect.QuoteSchema(schema, child.BaseView);
|
|
2576
|
+
const pkRef = dialect.QuoteIdentifier(child.FirstPrimaryKey.Name); // first-pk-ok: IS-A shared key
|
|
2577
|
+
const nameLit = dialect.QuoteStringLiteral(child.Name);
|
|
2578
|
+
return `SELECT ${nameLit} AS ${aliasEntityName}, CAST(${pkRef} AS NVARCHAR(255)) AS ${aliasRecordPK}, ${idx + 1} AS ${aliasPriority} FROM ${sourceRef} WHERE ${pkRef} IN (${inList})`;
|
|
2579
|
+
});
|
|
2580
|
+
if (unionParts.length === 0)
|
|
2581
|
+
return '';
|
|
2582
|
+
return unionParts.join(' UNION ALL ');
|
|
2583
|
+
}
|
|
2419
2584
|
/**************************************************************************
|
|
2420
2585
|
* IS-A Overlapping Subtype — Record Change Propagation
|
|
2421
2586
|
*
|
|
@@ -2485,7 +2650,7 @@ IF ${varName} IS NOT NULL
|
|
|
2485
2650
|
const transaction = new sql.Transaction(this._pool);
|
|
2486
2651
|
await transaction.begin();
|
|
2487
2652
|
this._transaction = transaction;
|
|
2488
|
-
this.
|
|
2653
|
+
this.setTransactionActiveState(true);
|
|
2489
2654
|
}
|
|
2490
2655
|
/**
|
|
2491
2656
|
* Internal mssql transaction interface to safely inspect `_activeRequest` without `any`.
|
|
@@ -2521,8 +2686,9 @@ IF ${varName} IS NOT NULL
|
|
|
2521
2686
|
*/
|
|
2522
2687
|
enqueueTransactionAction(description, handle, run) {
|
|
2523
2688
|
this.initializeQueueProcessor();
|
|
2689
|
+
const queue = this.ensureSqlQueue();
|
|
2524
2690
|
return new Promise((resolve, reject) => {
|
|
2525
|
-
|
|
2691
|
+
queue.next({ kind: 'action', description, handle, run, resolve, reject });
|
|
2526
2692
|
});
|
|
2527
2693
|
}
|
|
2528
2694
|
async CommitPhysicalTransaction() {
|
|
@@ -2547,13 +2713,13 @@ IF ${varName} IS NOT NULL
|
|
|
2547
2713
|
// AbandonPhysicalTransaction can roll the doomed handle back — nulling it first, as the old
|
|
2548
2714
|
// `finally` did, made that abandon a no-op and leaked the server-side transaction (#4447).
|
|
2549
2715
|
this._transaction = null;
|
|
2550
|
-
this.
|
|
2716
|
+
this.setTransactionActiveState(false);
|
|
2551
2717
|
});
|
|
2552
2718
|
}
|
|
2553
2719
|
async AbandonPhysicalTransaction() {
|
|
2554
2720
|
const stale = this._transaction;
|
|
2555
2721
|
this._transaction = null;
|
|
2556
|
-
this.
|
|
2722
|
+
this.setTransactionActiveState(false);
|
|
2557
2723
|
if (stale) {
|
|
2558
2724
|
try {
|
|
2559
2725
|
// Through the queue, like commit and rollback: the handle is already nulled above, so any
|
|
@@ -2587,7 +2753,7 @@ IF ${varName} IS NOT NULL
|
|
|
2587
2753
|
}
|
|
2588
2754
|
finally {
|
|
2589
2755
|
this._transaction = null;
|
|
2590
|
-
this.
|
|
2756
|
+
this.setTransactionActiveState(false);
|
|
2591
2757
|
}
|
|
2592
2758
|
}
|
|
2593
2759
|
async OnBeginFailedAtDepthZero() {
|