@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.
@@ -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, binds parameters, and processes positional placeholders.
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
- // Handle positional parameters (legacy TypeORM style)
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$ = new Subject();
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
- this._transactionState$ = new BehaviorSubject(false);
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 the `ConnectionPool` object (NOT by `schema.view` alone) because one process can hold
316
- * providers to different databases (RW + RO datasources, multi-DB tooling) where the same
317
- * `schema.view` has different physical column orders — a process-global map would serve one
318
- * database's column order to another's provider and silently reintroduce the positional
319
- * `@ResultTable` mis-routing this cache exists to prevent. A WeakMap lets pools (and their cache)
320
- * be collected if a pool is discarded.
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._transactionState$.asObservable();
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._transactionState$.value;
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
- let scanPromise = SQLServerDataProvider._viewColumnOrderCacheByPool.get(pool);
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
- SQLServerDataProvider._viewColumnOrderCacheByPool.set(pool, scanPromise);
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 (SQLServerDataProvider._viewColumnOrderCacheByPool.get(pool) === scanPromise) {
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._sqlQueue$.pipe(concatMap(item => from(this.runQueueItem(item)).pipe(
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
- for (const { entityInfo, whereSQL, item } of items) {
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
- const effectiveView = await this.resolveEffectiveBaseView(entityInfo, item.params, contextUser);
690
- sqlStatements.push(this.BuildCacheStatusSQL(entityInfo, effectiveView, whereSQL));
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 < items.length; i++) {
697
- const { index } = items[i];
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 wrapper view then,
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 [${entityInfo.SchemaName}].${effectiveBaseView} WHERE (${whereSQL}))
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 [${entityInfo.SchemaName}].${effectiveBaseView} WHERE ${pkName} IN
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
- // The canonical stored encoding of the target record's key - `ID|<guid>` (see CompositeKey.ToRecordID).
775
- // A RecordID column holds this, not the bare primary key value.
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}].[${entity.BaseView}]
915
+ [${entity.SchemaName}].[${sourceObject}]
804
916
  WHERE
805
917
  [${f.EntityIDFieldName}] = '${targetEntity.ID}' AND
806
- [${f.Name}] = '${this.escapeSQLLiteral(targetRecordID)}'`;
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(entityDependencies, compositeKey) {
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
- const primaryKeySelectString = `CONCAT(${entityInfo.PrimaryKeys.map((pk) => `'${pk.Name}|', CAST([${pk.Name}] AS NVARCHAR(MAX))`).join(`,'${CompositeKey.DefaultFieldDelimiter}',`)})`;
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}].[${relatedEntityInfo.BaseView}]
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
- if (f.RelatedEntityFieldName?.trim().toLowerCase() === 'id') {
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.GenerateSaveSQL(entity, bNewRecord, user);
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); // get all the OLD values
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
- this._sqlQueue$.next({
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._transactionState$.next(true);
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
- this._sqlQueue$.next({ kind: 'action', description, handle, run, resolve, reject });
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._transactionState$.next(false);
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._transactionState$.next(false);
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._transactionState$.next(false);
2756
+ this.setTransactionActiveState(false);
2591
2757
  }
2592
2758
  }
2593
2759
  async OnBeginFailedAtDepthZero() {