@memberjunction/generic-database-provider 5.43.0 → 5.45.0

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.
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * @module @memberjunction/generic-database-provider
17
17
  */
18
- import { DatabaseProviderBase, EntityFieldTSType, ProjectRowsToFields, ProviderBase, EntityPermissionType, InMemoryLocalStorageProvider, Metadata, RunView, LocalCacheManager, LogError, LogStatus, LogStatusEx, StripStopWords, AfterKeyNotSupportedError, IsKeysetPaginationOrderableType, } from '@memberjunction/core';
18
+ import { DatabaseProviderBase, EntityFieldTSType, ProjectRowsToFields, ProviderBase, EntityPermissionType, InMemoryLocalStorageProvider, Metadata, RunView, LocalCacheManager, LogError, LogStatus, LogStatusEx, StripStopWords, AfterKeyNotSupportedError, IsKeysetPaginationOrderableType, ExternalDataSourceReadRouter, resolveQueryResultEnricher, } from '@memberjunction/core';
19
19
  import { MJGlobal, SQLExpressionValidator, UUIDsEqual } from '@memberjunction/global';
20
20
  import { QueryPagingEngine } from './queryPagingEngine.js';
21
21
  // QueryParameterProcessor is now called internally by RenderPipeline
@@ -508,10 +508,14 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
508
508
  async PostProcessRows(rows, entityInfo, user) {
509
509
  if (!rows || rows.length === 0)
510
510
  return rows;
511
- // Step 1: Platform-specific datetime adjustment (virtual hook)
511
+ // Step 1: Platform-specific datetime adjustment (virtual hook).
512
+ // SKIP for external entities: AdjustDatetimeFields applies THIS provider's platform correction
513
+ // (e.g. SQL Server appends 'Z' to compensate for how tedious marshals datetimes from the LOCAL
514
+ // MJ database). External rows come from a different engine whose own driver already normalized
515
+ // datetimes to proper Date objects — re-applying the local correction would double-shift them.
512
516
  const datetimeFields = entityInfo.DatetimeFields; // memoized on EntityInfo (was Fields.filter per query)
513
517
  let processedRows = rows;
514
- if (datetimeFields.length > 0) {
518
+ if (datetimeFields.length > 0 && !entityInfo.ExternalDataSourceID) {
515
519
  processedRows = await this.AdjustDatetimeFields(processedRows, datetimeFields, entityInfo);
516
520
  }
517
521
  // Step 2: Encryption decryption
@@ -754,7 +758,7 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
754
758
  // framework-internal read for SQL parameter building, so it deliberately bypasses
755
759
  // entity.Get(), which would assert active status (deprecation warning / disabled throw)
756
760
  // for what is NOT user use of the field. (EntityField.Value itself never asserts.)
757
- const theField = entity.Fields.find((field) => field.Name.trim().toLowerCase() === f.Name.trim().toLowerCase());
761
+ const theField = entity.GetFieldByName(f.Name);
758
762
  const rawValue = theField?.Value;
759
763
  // PK-on-CREATE with no explicit value: omit so the SP default fires.
760
764
  const isPKOnCreate = !isUpdate && f.IsPrimaryKey && !f.AutoIncrement;
@@ -1040,6 +1044,33 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1040
1044
  throw new Error(`Entity ID: ${viewEntity.EntityID} not found in metadata`);
1041
1045
  }
1042
1046
  this.CheckUserReadPermissions(entityInfo.Name, user);
1047
+ // ── External data source dispatch ──
1048
+ // Entities backed by an external data source are proxied live through a
1049
+ // driver and have no view/sproc in the MJ DB, so delegate before any SQL
1050
+ // generation. This is a no-op for every MJ-DB entity (ExternalDataSourceID null).
1051
+ if (entityInfo.ExternalDataSourceID) {
1052
+ // Refuse rather than silently bypass Row-Level Security — a remote system can't
1053
+ // enforce MJ's RLS WHERE clauses, so returning unfiltered rows would be a data leak.
1054
+ this.assertExternalReadAllowedUnderRLS(entityInfo, user);
1055
+ // Refuse params we can't honor remotely rather than silently dropping them — most
1056
+ // importantly AfterKey, which would otherwise return the same page on every call.
1057
+ this.assertExternalRunViewParamsSupported(params, entityInfo.Name);
1058
+ // A saved view's stored WhereClause/OrderBy live on viewEntity, not params, and the
1059
+ // normal SQL path that applies them runs below this early return — so fold them in
1060
+ // here, else a UserView over an external entity silently returns unfiltered rows.
1061
+ const externalParams = await this.mergeExternalViewParams(params, viewEntity, user);
1062
+ const externalRouter = this.resolveExternalReadRouterOrThrow(`Entity '${entityInfo.Name}'`);
1063
+ const externalResult = await externalRouter.RunViewExternal(entityInfo, externalParams, user, this);
1064
+ // Apply the same row post-processing MJ-DB reads get (field decryption + datetime
1065
+ // normalization). Without this, an Encrypt-flagged external field surfaces as ciphertext.
1066
+ if (externalResult.Success && externalResult.Results && externalResult.Results.length > 0) {
1067
+ // Generic-erasure boundary: PostProcessRows operates on Record<string,unknown>[]
1068
+ // but Results is typed T[]; the double-cast bridges that erasure (not a lazy `any`).
1069
+ const rows = externalResult.Results;
1070
+ externalResult.Results = (await this.PostProcessRows(rows, entityInfo, user));
1071
+ }
1072
+ return externalResult;
1073
+ }
1043
1074
  // ── Parameters (transform user-provided SQL clauses for platform compatibility) ──
1044
1075
  const extraFilter = this.TransformExternalSQLClause(params.ExtraFilter || '', entityInfo);
1045
1076
  const userSearchString = params.UserSearchString ?? '';
@@ -1658,13 +1689,26 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1658
1689
  errorResults.push({ viewIndex: i, status: 'error', errorMessage: `Entity ${item.params.EntityName} not found in metadata` });
1659
1690
  continue;
1660
1691
  }
1692
+ // Read-permission gate for EVERY entity (external + local) up front. This must run before
1693
+ // the external routing below: external entities are served from the server cache on a hit
1694
+ // WITHOUT re-entering InternalRunView, so if the CanRead check only lived on the cache-miss
1695
+ // path a user lacking permission could receive rows another user warmed into the cache.
1661
1696
  try {
1662
1697
  this.CheckUserReadPermissions(entityInfo.Name, user);
1663
- itemsNeedingValidation.push({ index: i, item, entityInfo });
1664
1698
  }
1665
1699
  catch (e) {
1666
1700
  errorResults.push({ viewIndex: i, status: 'error', errorMessage: e instanceof Error ? e.message : String(e) });
1701
+ continue;
1702
+ }
1703
+ // External-data-source entities can't participate in DB-side cache validation
1704
+ // (no MJ base view / __mj_UpdatedAt to COUNT/MAX against). Route them to the
1705
+ // standard execution path, which dispatches to the external driver and TTL-caches
1706
+ // correctly via runFullQueryAndCacheResult. Mirrors the AfterKey bypass above.
1707
+ if (entityInfo.ExternalDataSourceID) {
1708
+ itemsWithoutCacheCheck.push({ index: i, item });
1709
+ continue;
1667
1710
  }
1711
+ itemsNeedingValidation.push({ index: i, item, entityInfo });
1668
1712
  }
1669
1713
  // Phase 1: Check server's LocalCacheManager first (zero DB hits)
1670
1714
  const currentResults = [];
@@ -1859,10 +1903,16 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1859
1903
  // eligibility gate matters: BypassCache/AfterKey/count_only results were
1860
1904
  // never widened and must NOT be written under the superset fingerprint.
1861
1905
  if (this.runViewCacheEligible(params) && LocalCacheManager.Instance.IsInitialized) {
1862
- const rlsWhereClause = this.ComputeRunViewRLSWhereClause(params, contextUser);
1863
- const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, rlsWhereClause);
1864
- const maxUpdatedAt = result.maxUpdatedAt || new Date().toISOString();
1865
- await LocalCacheManager.Instance.SetRunViewResult(fingerprint, params, result.results, maxUpdatedAt, undefined, result.rowCount, this);
1906
+ // External entities cache with a TTL (no BaseEntity events to invalidate them);
1907
+ // MJ-DB entities get undefined (event-invalidated as before). A 0 means the
1908
+ // external source has caching disabled — skip the write to avoid stale data.
1909
+ const ttlMs = await this.resolveExternalCacheTTLMs(params, contextUser);
1910
+ if (ttlMs !== 0) {
1911
+ const rlsWhereClause = this.ComputeRunViewRLSWhereClause(params, contextUser);
1912
+ const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, rlsWhereClause);
1913
+ const maxUpdatedAt = result.maxUpdatedAt || new Date().toISOString();
1914
+ await LocalCacheManager.Instance.SetRunViewResult(fingerprint, params, result.results, maxUpdatedAt, undefined, result.rowCount, this, ttlMs);
1915
+ }
1866
1916
  }
1867
1917
  // Project the response down to the caller's requested shape AFTER the
1868
1918
  // cache write — the cache keeps the superset, the caller gets their fields.
@@ -1872,6 +1922,124 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1872
1922
  }
1873
1923
  return result;
1874
1924
  }
1925
+ /**
1926
+ * Resolves the cache TTL (in ms) for a RunView's entity, or `undefined` for MJ-DB entities
1927
+ * (which use event-based cache invalidation, not TTL). External-data-source entities MUST be
1928
+ * time-bounded — their remote data changes never emit BaseEntity events — so this returns the
1929
+ * data source's DefaultCacheTTLSeconds (via the external router) in ms, or `0` to signal "do
1930
+ * not cache" (TTL disabled on the source, or no router available to resolve one).
1931
+ */
1932
+ async resolveExternalCacheTTLMs(params, contextUser) {
1933
+ if (!params.EntityName)
1934
+ return undefined;
1935
+ const entityInfo = this.EntityByName(params.EntityName);
1936
+ if (!entityInfo?.ExternalDataSourceID)
1937
+ return undefined; // MJ-DB entity — event-invalidated, no TTL
1938
+ // Must check GetRegistration, not CreateInstance's return: CreateInstance returns an instance of
1939
+ // the ABSTRACT base (never null) when unregistered, so a `!router` check would be dead code.
1940
+ const cf = MJGlobal.Instance.ClassFactory;
1941
+ if (!cf.GetRegistration(ExternalDataSourceReadRouter))
1942
+ return 0; // no router — don't cache (avoid stale-forever)
1943
+ const router = cf.CreateInstance(ExternalDataSourceReadRouter);
1944
+ const ttlSeconds = await router.GetCacheTTLSeconds(entityInfo.ExternalDataSourceID, contextUser, this);
1945
+ return ttlSeconds > 0 ? ttlSeconds * 1000 : 0;
1946
+ }
1947
+ /**
1948
+ * Resolve the External Data Sources read router, or throw a CLEAR error when the EDS engine isn't
1949
+ * loaded. IMPORTANT: `ClassFactory.CreateInstance` returns an instance of the ABSTRACT base class
1950
+ * (not null) when no concrete class is registered, so a `if (!router)` guard is dead code and the
1951
+ * caller would instead hit a cryptic "router.X is not a function" TypeError. Gate on GetRegistration.
1952
+ */
1953
+ resolveExternalReadRouterOrThrow(context) {
1954
+ const cf = MJGlobal.Instance.ClassFactory;
1955
+ if (!cf.GetRegistration(ExternalDataSourceReadRouter)) {
1956
+ throw new Error(`${context} is backed by an external data source but no ExternalDataSourceReadRouter is registered. Ensure @memberjunction/external-data-sources is loaded.`);
1957
+ }
1958
+ return cf.CreateInstance(ExternalDataSourceReadRouter);
1959
+ }
1960
+ /**
1961
+ * Guards external-data-source reads against silently bypassing Row-Level Security. A remote
1962
+ * system can't enforce MJ's RLS WHERE clauses, so if RLS would filter this user's rows we
1963
+ * refuse the read with a clear error rather than returning unfiltered data. Users exempt from
1964
+ * RLS (e.g. admins) get an empty clause and pass through — RLS wouldn't restrict them on an
1965
+ * MJ-DB entity either. Called from the external RunView and Load dispatch points.
1966
+ */
1967
+ assertExternalReadAllowedUnderRLS(entityInfo, user) {
1968
+ const rlsWhereClause = entityInfo.GetUserRowLevelSecurityWhereClause(user, EntityPermissionType.Read, '');
1969
+ if (rlsWhereClause && rlsWhereClause.length > 0) {
1970
+ throw new Error(`Entity '${entityInfo.Name}' is backed by an external data source and has Row-Level Security that applies ` +
1971
+ `to this user. RLS cannot be enforced on a remote system, so external reads are refused for RLS-protected ` +
1972
+ `entities to avoid returning unfiltered data. Remove the RLS filter, or do not back this entity with an external data source.`);
1973
+ }
1974
+ }
1975
+ /**
1976
+ * Rejects RunView params an external data source can't honor, rather than silently dropping
1977
+ * them. Most important is AfterKey (keyset pagination): the external read path only supports
1978
+ * offset paging, so a silently-dropped AfterKey would return the same page on every call — an
1979
+ * infinite loop / duplicate processing in deep-pagination jobs. Aggregates and a non-empty
1980
+ * UserSearchString likewise can't be evaluated remotely. Throws a clear error naming the param.
1981
+ */
1982
+ assertExternalRunViewParamsSupported(params, entityName) {
1983
+ if (params.AfterKey) {
1984
+ throw new Error(`Keyset pagination (AfterKey) is not supported for external-data-source entity '${entityName}'. Use StartRow/MaxRows offset paging instead.`);
1985
+ }
1986
+ if (params.Aggregates && params.Aggregates.length > 0) {
1987
+ throw new Error(`Aggregates are not supported for external-data-source entity '${entityName}'. Author an external MJ Query for aggregate results instead.`);
1988
+ }
1989
+ if (typeof params.UserSearchString === 'string' && params.UserSearchString.trim().length > 0) {
1990
+ throw new Error(`UserSearchString is not supported for external-data-source entity '${entityName}'. Use ExtraFilter instead.`);
1991
+ }
1992
+ // Apply the same forbidden-keyword screen the MJ-DB path runs on caller-supplied SQL
1993
+ // clauses before they're passed to the remote driver. The driver still parameterizes
1994
+ // values, but this blocks obvious injection of statement-terminating / DDL keywords.
1995
+ // NOTE: this is a deliberately conservative cross-dialect pre-filter applied to EVERY
1996
+ // external source, including MongoDB (whose real parser is MongoFilterTranslator) — a Mongo
1997
+ // filter containing a SQL-ish token is screened here even though Mongo never builds SQL.
1998
+ // Acceptable belt-and-suspenders; scope to SQL drivers if it ever proves noisy.
1999
+ const extraFilter = params.ExtraFilter || '';
2000
+ if (extraFilter && !this.ValidateUserProvidedSQLClause(extraFilter)) {
2001
+ throw new Error(`Invalid ExtraFilter clause for external-data-source entity '${entityName}': contains one or more forbidden keywords.`);
2002
+ }
2003
+ const orderBy = params.OrderBy || '';
2004
+ if (orderBy && !this.ValidateUserProvidedSQLClause(orderBy)) {
2005
+ throw new Error(`Invalid OrderBy clause for external-data-source entity '${entityName}': contains one or more forbidden keywords.`);
2006
+ }
2007
+ }
2008
+ /**
2009
+ * Folds a saved view's stored WhereClause and OrderByClause into the RunView params before
2010
+ * external dispatch. The external branch returns before the normal SQL path that applies
2011
+ * them, so without this a UserView over an external entity would silently return unfiltered,
2012
+ * unordered rows. The view's WhereClause is ANDed with any caller ExtraFilter; the view's
2013
+ * OrderByClause is used only when the caller supplied no OrderBy. Returns params unchanged
2014
+ * when there is no saved view.
2015
+ */
2016
+ async mergeExternalViewParams(params, viewEntity, user) {
2017
+ if (!viewEntity)
2018
+ return params;
2019
+ const merged = { ...params };
2020
+ const callerFilter = params.ExtraFilter || '';
2021
+ if (viewEntity.WhereClause && viewEntity.WhereClause.length > 0) {
2022
+ const renderedWhere = (await this.RenderViewWhereClause(viewEntity, user))?.trim();
2023
+ if (renderedWhere) {
2024
+ merged.ExtraFilter = callerFilter ? `(${callerFilter}) AND (${renderedWhere})` : renderedWhere;
2025
+ }
2026
+ }
2027
+ if ((!params.OrderBy || params.OrderBy.length === 0) && viewEntity.OrderByClause) {
2028
+ merged.OrderBy = viewEntity.OrderByClause;
2029
+ }
2030
+ // The view's WhereClause/OrderByClause weren't covered by the pre-merge screen in
2031
+ // assertExternalRunViewParamsSupported — validate the MERGED clauses before they reach the
2032
+ // remote driver (mirrors the MJ-DB path, which validates the post-merge OrderBy).
2033
+ const mergedFilter = merged.ExtraFilter || '';
2034
+ if (mergedFilter && !this.ValidateUserProvidedSQLClause(mergedFilter)) {
2035
+ throw new Error(`Invalid effective filter for external-data-source view '${viewEntity.Name}': the saved view's WhereClause contains one or more forbidden keywords.`);
2036
+ }
2037
+ const mergedOrderBy = merged.OrderBy || '';
2038
+ if (mergedOrderBy && !this.ValidateUserProvidedSQLClause(mergedOrderBy)) {
2039
+ throw new Error(`Invalid effective OrderBy for external-data-source view '${viewEntity.Name}': the saved view's OrderByClause contains one or more forbidden keywords.`);
2040
+ }
2041
+ return merged;
2042
+ }
1875
2043
  /**
1876
2044
  * Checks the server's LocalCacheManager for cached data matching the client's request.
1877
2045
  * Returns the resolution if found (either 'current' or server-cached data to serve),
@@ -2262,6 +2430,21 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2262
2430
  const query = this.findAndValidateQuery(params, contextUser);
2263
2431
  // Process parameters (composition + Nunjucks templates)
2264
2432
  const { finalSQL, appliedParameters } = this.processQueryParameters(query, params.Parameters, contextUser);
2433
+ // ── External data source dispatch ──
2434
+ // Queries bound to an external data source execute their (now fully-rendered)
2435
+ // native SQL via the driver, not the MJ DB. No-op for MJ-DB queries.
2436
+ //
2437
+ // NOTE (RLS asymmetry — intentional): unlike the external RunView/Load paths, which REFUSE
2438
+ // to run when an entity has a Row-Level-Security clause (assertExternalReadAllowedUnderRLS,
2439
+ // since RLS can't be enforced on the remote system), the Query path does NOT apply that
2440
+ // refusal. This matches MJ's general model that a saved Query is trusted, admin-authored
2441
+ // raw SQL gated by query-level permissions — not by per-entity RLS. If a source backs an
2442
+ // RLS-protected entity, the query author is responsible for scoping the SQL (and the source
2443
+ // credential should be least-privilege). Do not assume EDS RLS covers the Query path.
2444
+ if (query.ExternalDataSourceID) {
2445
+ const externalRouter = this.resolveExternalReadRouterOrThrow(`Query '${query.Name}'`);
2446
+ return await this.runExternalQueryWithCache(query, finalSQL, params, externalRouter, contextUser);
2447
+ }
2265
2448
  // Execute query — use SQL-level paging when requested, else fetch all rows
2266
2449
  const useSQLPaging = QueryPagingEngine.ShouldPage(params.StartRow, params.MaxRows);
2267
2450
  let paginatedResult;
@@ -2305,6 +2488,15 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2305
2488
  // Cache full (unpaginated) results if enabled (fire-and-forget)
2306
2489
  void this.cacheQueryResults(query, params.Parameters || {}, fullResult);
2307
2490
  }
2491
+ // Optional, additive post-query enrichment (e.g. append an ML prediction
2492
+ // column). Awaited so the appended columns are present in the response.
2493
+ // Fully decoupled + resilient: resolves the enricher via the ClassFactory
2494
+ // (no-op when none is registered — i.e. the providing package isn't loaded),
2495
+ // and on ANY failure logs and leaves the rows untouched so a scoring problem
2496
+ // never breaks the query. Runs after paging so only the returned page is scored.
2497
+ if (params.Enrichment?.EnricherKey) {
2498
+ paginatedResult = await this.enrichQueryResults(paginatedResult, params, query, contextUser);
2499
+ }
2308
2500
  // Handle audit logging (fire-and-forget)
2309
2501
  this.auditQueryExecution(query, params, finalSQL, paginatedResult.length, totalRowCount, executionTime, contextUser);
2310
2502
  return {
@@ -2398,6 +2590,16 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2398
2590
  * Finds a query from RunQueryParams and validates user permissions.
2399
2591
  * Uses `resolveQuery()` for lookup and `ValidateQueryForExecution()` for permissions.
2400
2592
  */
2593
+ /**
2594
+ * A saved query is "external" when it resolves to a Query bound to an external data source.
2595
+ * Used by the base RunQuery CacheLocal layer to defer external-query caching to
2596
+ * InternalRunQuery's runExternalQueryWithCache. Non-throwing — resolves from cached metadata.
2597
+ */
2598
+ IsExternalQuery(params) {
2599
+ if (params.SQL || (!params.QueryID && !params.QueryName))
2600
+ return false; // ad-hoc SQL is never external-cached here
2601
+ return !!this.resolveQuery(params)?.ExternalDataSourceID;
2602
+ }
2401
2603
  findAndValidateQuery(params, contextUser) {
2402
2604
  const query = this.resolveQuery(params);
2403
2605
  if (!query) {
@@ -2443,6 +2645,92 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2443
2645
  }
2444
2646
  return { finalSQL: result.FinalSQL, appliedParameters: result.AppliedParameters };
2445
2647
  }
2648
+ /**
2649
+ * Checks the columns an external-data-source query returned against the fields the Query
2650
+ * *declares* (its QueryField metadata) and logs a warning naming any declared field that
2651
+ * is missing — the signature of a remote object whose columns have drifted (renamed or
2652
+ * dropped).
2653
+ *
2654
+ * Per the External Data Sources plan this is intentionally NON-FATAL: the rows are still
2655
+ * returned unchanged. Drift is surfaced for diagnosis, not treated as a hard failure —
2656
+ * the declared field metadata can legitimately lag the remote schema, and the plan calls
2657
+ * for "a warning logged, rows still returned." Matching is case-insensitive because remote
2658
+ * dialects differ in column casing (e.g. Snowflake uppercases unquoted identifiers). No-op
2659
+ * when the query declares no fields or returned no rows (columns can't be inspected without
2660
+ * a row, and an empty result is legitimate). Extra columns beyond the declared set are fine.
2661
+ */
2662
+ /**
2663
+ * Executes an external-data-source query with TTL-based result caching keyed off the data
2664
+ * source's DefaultCacheTTLSeconds. External queries can't be event-invalidated (their data
2665
+ * lives on a remote system), so a time-bounded cache is the read-cost mitigation the plan
2666
+ * calls for — notably for warehouses like Snowflake. The data source's TTL is the source of
2667
+ * truth (a TTL of <= 0 disables caching for the source); field-drift warnings still apply to
2668
+ * freshly-fetched rows. Ad-hoc SQL (params.SQL) is never cached.
2669
+ */
2670
+ async runExternalQueryWithCache(query, finalSQL, params, router, contextUser) {
2671
+ const externalDataSourceID = query.ExternalDataSourceID;
2672
+ const ttlSeconds = await router.GetCacheTTLSeconds(externalDataSourceID, contextUser, this);
2673
+ const cacheable = !params.SQL && ttlSeconds > 0 && LocalCacheManager.Instance.IsInitialized;
2674
+ let fingerprint;
2675
+ if (cacheable) {
2676
+ // MaxRows/StartRow shape the result set, and the data source ID disambiguates slots.
2677
+ const fingerprintParams = {
2678
+ ...(params.Parameters ?? {}),
2679
+ __maxRows: params.MaxRows ?? -1,
2680
+ __startRow: params.StartRow ?? 0,
2681
+ __eds: externalDataSourceID,
2682
+ };
2683
+ fingerprint = LocalCacheManager.Instance.GenerateRunQueryFingerprint(query.ID, query.Name, fingerprintParams, this.InstanceConnectionString);
2684
+ const cached = await LocalCacheManager.Instance.GetRunQueryResult(fingerprint); // TTL-enforced
2685
+ if (cached) {
2686
+ return {
2687
+ QueryID: cached.queryId ?? query.ID,
2688
+ QueryName: query.Name,
2689
+ Success: true,
2690
+ Results: cached.results,
2691
+ RowCount: cached.results.length,
2692
+ TotalRowCount: cached.rowCount ?? cached.results.length,
2693
+ ExecutionTime: 0,
2694
+ ErrorMessage: '',
2695
+ CacheHit: true,
2696
+ CacheKey: fingerprint,
2697
+ };
2698
+ }
2699
+ }
2700
+ const externalResult = await router.RunQueryExternal(externalDataSourceID, query.ID, query.Name, finalSQL, params, contextUser, this);
2701
+ const checked = this.warnIfExternalQueryFieldsMissing(query, externalResult);
2702
+ if (!checked.Success) {
2703
+ return checked;
2704
+ }
2705
+ // The external router returns the FULL result set (drivers don't apply Query paging), so honor
2706
+ // StartRow/MaxRows here in-memory — otherwise every "page" returned the entire set (StartRow was
2707
+ // silently ignored). The fingerprint already varies by __startRow/__maxRows, so we cache the
2708
+ // per-page slice; TotalRowCount carries the full count.
2709
+ const { paginatedResult, totalRowCount } = this.applyQueryPagination(checked.Results, params);
2710
+ if (cacheable && fingerprint) {
2711
+ // Fire-and-forget store with the data source's TTL (ms).
2712
+ LocalCacheManager.Instance.SetRunQueryResult(fingerprint, query.Name, paginatedResult, '', totalRowCount, query.ID, ttlSeconds * 1000).catch(e => LogError(`External RunQuery cache write failed: ${e}`));
2713
+ }
2714
+ return { ...checked, Results: paginatedResult, RowCount: paginatedResult.length, TotalRowCount: totalRowCount };
2715
+ }
2716
+ warnIfExternalQueryFieldsMissing(query, result) {
2717
+ if (!result.Success || !result.Results || result.Results.length === 0) {
2718
+ return result;
2719
+ }
2720
+ const declaredFields = query.QueryFields;
2721
+ if (!declaredFields || declaredFields.length === 0) {
2722
+ return result;
2723
+ }
2724
+ const presentKeys = new Set(Object.keys(result.Results[0]).map(k => k.trim().toLowerCase()));
2725
+ const missing = declaredFields
2726
+ .map(f => f.Name)
2727
+ .filter(name => name && !presentKeys.has(name.trim().toLowerCase()));
2728
+ if (missing.length > 0) {
2729
+ LogStatus(`Warning: external query '${query.Name}' returned rows missing declared field(s): ${missing.join(', ')}. ` +
2730
+ `The remote object's columns may have drifted from the query's field metadata. Rows are returned as-is.`);
2731
+ }
2732
+ return result;
2733
+ }
2446
2734
  /**
2447
2735
  * Lower-layer execution: resolves composition, processes templates, executes SQL.
2448
2736
  * This is the single execution pathway used by both saved queries (via RunQuery upper layer)
@@ -2556,6 +2844,58 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2556
2844
  }
2557
2845
  return { paginatedResult, totalRowCount };
2558
2846
  }
2847
+ /**
2848
+ * Optional, additive post-query enrichment step. Resolves the {@link QueryResultEnricherBase}
2849
+ * registered under `params.Enrichment.EnricherKey` via the MJGlobal ClassFactory and awaits
2850
+ * it on the result rows, returning whatever (column-appended) rows it produces.
2851
+ *
2852
+ * Fully decoupled + resilient by design:
2853
+ * - **Decoupled**: the enricher is resolved by string key through the ClassFactory, so this
2854
+ * provider takes no static dependency on any concrete enricher (e.g. Predictive Studio's
2855
+ * ML-scoring enricher). When no enricher is registered under the key — i.e. the providing
2856
+ * package isn't loaded — {@link resolveQueryResultEnricher} returns `null` and we no-op,
2857
+ * returning the original rows.
2858
+ * - **Resilient**: the whole call is wrapped in try/catch. On ANY failure (resolution,
2859
+ * scorer error, bad config) we LogError and return the ORIGINAL un-enriched rows. A
2860
+ * scoring problem must NEVER break the underlying query.
2861
+ *
2862
+ * The loaded {@link QueryInfo} (when resolvable from the executed query's id) is passed
2863
+ * through so an enricher can read the query's associated entity/fields.
2864
+ *
2865
+ * @param rows the assembled, paginated result rows to enrich
2866
+ * @param params the run params carrying the {@link RunQueryEnrichment} directive
2867
+ * @param query the executed query entity (used to resolve its {@link QueryInfo} metadata)
2868
+ * @param contextUser the request user, threaded through for isolation/audit
2869
+ * @returns the enriched rows on success, or the original rows on any failure / no-op
2870
+ */
2871
+ async enrichQueryResults(rows, params, query, contextUser) {
2872
+ const enrichment = params.Enrichment;
2873
+ if (!enrichment?.EnricherKey) {
2874
+ return rows;
2875
+ }
2876
+ try {
2877
+ const enricher = resolveQueryResultEnricher(enrichment.EnricherKey);
2878
+ if (!enricher) {
2879
+ // No enricher registered under this key (providing package not loaded) — no-op.
2880
+ return rows;
2881
+ }
2882
+ // Resolve the QueryInfo metadata for the executed query (best-effort; an enricher
2883
+ // can use it to find the query's associated entity). Undefined is acceptable.
2884
+ const queryInfo = this.Queries.find(q => UUIDsEqual(q.ID, query.ID));
2885
+ return await enricher.EnrichResults({
2886
+ rows,
2887
+ config: enrichment.Config ?? {},
2888
+ query: queryInfo,
2889
+ contextUser,
2890
+ provider: this,
2891
+ });
2892
+ }
2893
+ catch (e) {
2894
+ LogError(e);
2895
+ // A scoring/enrichment failure must never break the query — return the original rows.
2896
+ return rows;
2897
+ }
2898
+ }
2559
2899
  /**
2560
2900
  * Creates an audit log record for query execution (fire-and-forget).
2561
2901
  * Only logs if the query has `AuditQueryRuns` enabled or `ForceAuditLog` is set.
@@ -2597,6 +2937,29 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2597
2937
  */
2598
2938
  async Load(entity, compositeKey, entityRelationshipsToLoad = null, user) {
2599
2939
  const entityInfo = entity.EntityInfo;
2940
+ // ── External data source dispatch ──
2941
+ // Entities backed by an external data source have no MJ view/sproc, so proxy the
2942
+ // single-record load through the external router's LoadExternalRecord, which builds a
2943
+ // quoted, parameter-bound primary-key predicate at the driver boundary (composite-key
2944
+ // aware; mixed-case / reserved-word PK columns work on case-sensitive dialects). No-op
2945
+ // for every MJ-DB entity (ExternalDataSourceID null). Relationship loading is intentionally
2946
+ // not supported for external entities (they are read-only leaf records);
2947
+ // entityRelationshipsToLoad is ignored here.
2948
+ if (entityInfo.ExternalDataSourceID) {
2949
+ // Refuse rather than silently bypass Row-Level Security (a remote system can't enforce it).
2950
+ this.assertExternalReadAllowedUnderRLS(entityInfo, user);
2951
+ const externalRouter = this.resolveExternalReadRouterOrThrow(`Entity '${entityInfo.Name}'`);
2952
+ const externalResult = await externalRouter.LoadExternalRecord(entityInfo, compositeKey, user, this);
2953
+ if (!externalResult.Success) {
2954
+ throw new Error(`External Load failed for '${entityInfo.Name}': ${externalResult.ErrorMessage}`);
2955
+ }
2956
+ if (externalResult.Results && externalResult.Results.length > 0) {
2957
+ // Same post-processing MJ-DB Load applies (field decryption + datetime normalization).
2958
+ const processed = await this.PostProcessRows(externalResult.Results, entityInfo, user);
2959
+ return processed.length > 0 ? processed[0] : null;
2960
+ }
2961
+ return null;
2962
+ }
2600
2963
  // Build WHERE from composite key
2601
2964
  const where = compositeKey.KeyValuePairs.map(val => {
2602
2965
  const pk = entityInfo.PrimaryKeys.find(p => p.Name.trim().toLowerCase() === val.FieldName.trim().toLowerCase());
@@ -2641,6 +3004,15 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2641
3004
  const relEntityInfo = this.EntityByName(relInfo.RelatedEntity);
2642
3005
  if (!relEntityInfo)
2643
3006
  continue;
3007
+ // The related entity may itself be external-data-source-backed (no local base view to
3008
+ // query). Running the SELECT below against the local DB would fail (view doesn't exist)
3009
+ // and abort the whole parent-record load. Loading external relationships isn't supported
3010
+ // in this pass, so skip with a warning rather than throw. (Consistent with external
3011
+ // records not auto-loading their own relationships.)
3012
+ if (relEntityInfo.ExternalDataSourceID) {
3013
+ LogStatus(`[GenericDatabaseProvider] Skipping relationship '${rel}' on '${entityInfo.Name}': related entity '${relEntityInfo.Name}' is external-data-source-backed (relationship loading not supported for external entities).`);
3014
+ continue;
3015
+ }
2644
3016
  const quotes = entity.FirstPrimaryKey.NeedsQuotes ? "'" : '';
2645
3017
  const pkValue = ret[entity.FirstPrimaryKey.Name];
2646
3018
  let relSql;
@@ -2676,7 +3048,7 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2676
3048
  return true;
2677
3049
  }
2678
3050
  const pkWhere = entity.PrimaryKeys.map(pk => {
2679
- const fieldInfo = entityInfo.Fields.find(f => f.Name === pk.Name);
3051
+ const fieldInfo = entityInfo.FieldByName(pk.Name);
2680
3052
  const quotes = fieldInfo?.NeedsQuotes ? "'" : '';
2681
3053
  return `${this.QuoteIdentifier(pk.Name)}=${quotes}${pk.Value}${quotes}`;
2682
3054
  }).join(' AND ');
@@ -2787,6 +3159,22 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2787
3159
  const entityName = String(item['Entity']);
2788
3160
  const entityID = String(item['EntityID']);
2789
3161
  const whereClause = item['WhereClause'] ? String(item['WhereClause']) : '';
3162
+ // External-data-source entities have no MJ base view — their data is proxied live and
3163
+ // can't be served through the dataset's batched MJ-DB SQL path. Fail loud for that item
3164
+ // rather than silently querying a non-existent (or wrongly same-named) local view.
3165
+ const itemEntity = this.EntityByName(entityName);
3166
+ if (itemEntity?.ExternalDataSourceID) {
3167
+ errorResults.push({
3168
+ EntityID: entityID,
3169
+ EntityName: entityName,
3170
+ Code: code,
3171
+ Results: [],
3172
+ LatestUpdateDate: undefined,
3173
+ Status: `Dataset item '${code}' references external-data-source entity '${entityName}', which is not supported in datasets (its data is proxied live, not stored in MJ).`,
3174
+ Success: false,
3175
+ });
3176
+ continue;
3177
+ }
2790
3178
  // Build effective filter (WhereClause + optional runtime ItemFilter)
2791
3179
  let effectiveFilter = whereClause;
2792
3180
  if (itemFilters && itemFilters.length > 0) {
@@ -3101,7 +3489,7 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
3101
3489
  if (entity) {
3102
3490
  const invalidColumns = [];
3103
3491
  specifiedColumns.forEach(col => {
3104
- if (!entity.Fields.find(f => f.Name.trim().toLowerCase() === col.trim().toLowerCase())) {
3492
+ if (!entity.FieldByName(col)) {
3105
3493
  invalidColumns.push(col);
3106
3494
  }
3107
3495
  });
@@ -3113,7 +3501,7 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
3113
3501
  // Ensure DateFieldToCheck is included
3114
3502
  const dateField = item['DateFieldToCheck'] ? String(item['DateFieldToCheck']).trim() : '';
3115
3503
  if (dateField.length > 0 && specifiedColumns.indexOf(dateField) === -1) {
3116
- if (!entity || entity.Fields.find(f => f.Name.trim().toLowerCase() === dateField.toLowerCase()))
3504
+ if (!entity || entity.FieldByName(dateField))
3117
3505
  specifiedColumns.push(dateField);
3118
3506
  }
3119
3507
  }