@memberjunction/generic-database-provider 6.1.0-edge.2 → 6.1.0-edge.3

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,12 +15,14 @@
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, ExternalDataSourceReadRouter, resolveQueryResultEnricher, } from '@memberjunction/core';
18
+ import { DatabaseProviderBase, EntityFieldTSType, ProjectRowsToFields, ProviderBase, EntityPermissionType, InMemoryLocalStorageProvider, Metadata, RunView, IsMaterializedDataSource, 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
22
22
  import { v4 as uuidv4 } from 'uuid';
23
23
  import { SqlLoggingSessionImpl } from './SqlLogger.js';
24
+ import { GetDialect } from '@memberjunction/sql-dialect';
25
+ import { SQLParser } from '@memberjunction/sql-parser';
24
26
  // QueryCompositionEngine is now owned by RenderPipeline
25
27
  import { RenderPipeline } from './renderPipeline.js';
26
28
  import { useJsonArgShape } from './crudSprocFieldRules.js';
@@ -899,11 +901,109 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
899
901
  * `QuoteIdentifier` produces `"TotalRowCount"` on PG and `[TotalRowCount]` on SQL
900
902
  * Server — both preserve case.
901
903
  */
902
- BuildTotalRowCountSQL(entityInfo, usingPagination, maxRowsForQuery) {
904
+ BuildTotalRowCountSQL(entityInfo, usingPagination, maxRowsForQuery, baseViewOverride) {
903
905
  const rowsAreLimited = usingPagination || maxRowsForQuery > 0;
904
906
  if (!rowsAreLimited)
905
907
  return null;
906
- return `SELECT COUNT(*) AS ${this.QuoteIdentifier('TotalRowCount')} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.BaseView)}`;
908
+ return `SELECT COUNT(*) AS ${this.QuoteIdentifier('TotalRowCount')} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, baseViewOverride ?? entityInfo.BaseView)}`;
909
+ }
910
+ /**
911
+ * Resolves the view a RunView reads from: the entity's live base view by default, or its materialized
912
+ * wrapper view when the caller opts into the snapshot via `DataSource: 'Materialized'` (plan §7). The
913
+ * choice is explicit (never silent), so the same RLS/paging/field-selection apply against the identical shape.
914
+ *
915
+ * Two materialization shapes:
916
+ * - **Base-view materialization** reuses the SOURCE entity, whose `BaseView` stays the LIVE view; the
917
+ * snapshot lives beside it as `materialized_vw<CodeName>` (the name CodeGen's base-view path emits).
918
+ * `'Materialized'` swaps the live view for that snapshot.
919
+ * - **Query materialization** mints a NEW entity whose `BaseView` ALREADY IS the materialized wrapper
920
+ * view (`materialized_vw<...>`), so there is no separate live source to swap — `'Materialized'` is a
921
+ * no-op and we return the entity's own base view. (Deriving `materialized_vw<CodeName>` here would be
922
+ * wrong: the minted entity's CodeName need not match the query-derived view name.)
923
+ *
924
+ * Convention-based for the base-view case: if the entity has no such materialization the wrapper view
925
+ * won't exist and the read will error — opting into `'Materialized'` asserts the snapshot exists.
926
+ */
927
+ GetEffectiveBaseView(entityInfo, params) {
928
+ // Case-INSENSITIVE prefix test (matches the sibling guard in providerBase.IsServerCacheAllowedForEntity):
929
+ // a BaseView returned with non-lowercase casing (e.g. 'Materialized_vwFoo' from a case-insensitive SQL
930
+ // Server, or a hand-authored entity) is still an already-materialized view — a case-sensitive check
931
+ // would miss it and wrongly derive materialized_vw<CodeName>, targeting a non-existent object.
932
+ if (IsMaterializedDataSource(params.DataSource) && !entityInfo.BaseView?.toLowerCase().startsWith('materialized_vw')) {
933
+ return `materialized_vw${entityInfo.CodeName}`;
934
+ }
935
+ return entityInfo.BaseView;
936
+ }
937
+ /**
938
+ * Async status-aware wrapper around {@link GetEffectiveBaseView} for the BASE-VIEW materialization case.
939
+ * `GetEffectiveBaseView` name-swaps unconditionally, which (a) serves a `Building`/`DriftHold`/`Disabled`
940
+ * snapshot — defeating "flag and hold" (§13/§17.2), since a base-view materialization reuses the source
941
+ * entity and thus has no read-permission revoke to fall back on the way a minted query entity does — and
942
+ * (b) hard-errors on a `Materialized` read of a non-materialized entity (missing view). This gates the swap
943
+ * on an ACTIVE `MaterializedResult` and otherwise returns the LIVE base view (graceful fallback). The status
944
+ * read uses `BypassCache` because DriftHold/Disabled are written by CodeGen via direct SQL (no BaseEntity
945
+ * cache-invalidation event), so a cached status could otherwise be stale. Non-materialized reads and minted
946
+ * query virtual entities (BaseView already `materialized_vw…`) skip the lookup entirely (no extra query).
947
+ */
948
+ async resolveEffectiveBaseView(entityInfo, params, contextUser) {
949
+ if (!IsMaterializedDataSource(params.DataSource) || entityInfo.BaseView?.toLowerCase().startsWith('materialized_vw')) {
950
+ return this.GetEffectiveBaseView(entityInfo, params);
951
+ }
952
+ const rv = new RunView(this);
953
+ const res = await rv.RunView({
954
+ EntityName: 'MJ: Materialized Results',
955
+ ExtraFilter: `SourceType='EntityBaseView' AND SourceEntityID='${entityInfo.ID}'`, // entityInfo.ID: trusted metadata PK
956
+ Fields: ['Status', 'ViewName'],
957
+ ResultType: 'simple',
958
+ MaxRows: 1,
959
+ BypassCache: true,
960
+ }, contextUser);
961
+ // A FAILED lookup must not masquerade as "no materialization exists" — see MaterializationLookupFailed
962
+ // for why the two must stay distinguishable. Falling back to the LIVE base view is correct in BOTH
963
+ // cases; only the silence was the defect.
964
+ if (this.MaterializationLookupFailed(res, `entity "${entityInfo.Name}" (base-view materialization status)`)) {
965
+ return entityInfo.BaseView;
966
+ }
967
+ const row = res.Results?.length > 0 ? res.Results[0] : null;
968
+ if (row?.Status !== 'Active')
969
+ return entityInfo.BaseView;
970
+ // Use the AUTHORITATIVE wrapper-view name persisted on the row rather than re-deriving
971
+ // materialized_vw<CodeName>: the row's ViewName is what the mint/migration actually created, so it stays
972
+ // correct for a migration-provided (non-conventionally-named) view or an entity renamed since mint (whose
973
+ // current CodeName no longer matches the view). Matches how the query path reads ViewName from the row.
974
+ // Fall back to the convention only if the row somehow lacks a ViewName. (SchemaName stays the entity's —
975
+ // base-view materialization always mints into the source entity's own schema.)
976
+ return row.ViewName?.trim() || `materialized_vw${entityInfo.CodeName}`;
977
+ }
978
+ /**
979
+ * Reports whether a materialization-metadata `RunView` FAILED, logging the failure when it did.
980
+ *
981
+ * Every materialization read path falls back to LIVE data when it cannot confirm an Active snapshot, and
982
+ * that fallback is correct — live data is always right, materialization is a transparent optimization,
983
+ * never a correctness dependency. The defect this guards is the **silence**: `RunView` signals an
984
+ * authorization or query failure through `Success === false` rather than by throwing, so collapsing a
985
+ * failure into the same branch as the legitimate "no materialization row exists" case makes the two
986
+ * indistinguishable to operator and caller alike.
987
+ *
988
+ * That matters because read access to the materialization entities is role-gated (`CanRead` is granted
989
+ * only to the UI / Developer / Integration roles). A user on a restricted role — including MJ's
990
+ * magic-link / external-access pattern — therefore has every one of these lookups fail, and so
991
+ * permanently reads LIVE data for every `DataSource:'Materialized'` request, while an admin issuing the
992
+ * identical request is served the snapshot. Two users, different data, no error surfaced to either.
993
+ * Logging leaves the safe fallback intact but makes the divergence diagnosable.
994
+ *
995
+ * @param result the `RunView` result to inspect (structurally a `RunViewResult`).
996
+ * @param context human-readable description of what was being resolved, for the log message.
997
+ * @returns `true` if the lookup failed and the caller should take its live-data fallback; `false` otherwise.
998
+ */
999
+ MaterializationLookupFailed(result, context) {
1000
+ if (result.Success)
1001
+ return false;
1002
+ LogError(`GenericDatabaseProvider: materialization metadata lookup failed for ${context} — falling back to LIVE data. ` +
1003
+ `The fallback is safe (live data is always correct), but the snapshot will NEVER be served for this caller. ` +
1004
+ `The most likely cause is the current user's roles lacking CanRead on the materialization entities. ` +
1005
+ `Error: ${result.ErrorMessage || 'unknown error'}`);
1006
+ return true;
907
1007
  }
908
1008
  /**
909
1009
  * Validates that the entity and RunViewParams are compatible with keyset (AfterKey) pagination,
@@ -1050,7 +1150,7 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1050
1150
  * SQL Server overrides to use spCreateUserViewRunWithDetail.
1051
1151
  * Default: returns null (no view run logging).
1052
1152
  */
1053
- async executeSQLForUserViewRunLogging(_viewId, _entityBaseView, _whereSQL, _orderBySQL, _user) {
1153
+ async executeSQLForUserViewRunLogging(_viewId, _entityInfo, _effectiveBaseView, _whereSQL, _orderBySQL, _user) {
1054
1154
  return null;
1055
1155
  }
1056
1156
  /**
@@ -1168,14 +1268,17 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1168
1268
  // ── Field selection ──
1169
1269
  const fields = this.getRunTimeViewFieldString(params, viewEntity);
1170
1270
  // ── Build SELECT and COUNT SQL ──
1271
+ // DataSource:'Materialized' routes the read to the entity's materialized wrapper view
1272
+ // (same shape, so RLS/paging/fields all apply identically); default stays the live base view.
1273
+ const effectiveBaseView = await this.resolveEffectiveBaseView(entityInfo, params, contextUser);
1171
1274
  const topFragment = topSQL ? topSQL + ' ' : '';
1172
- let viewSQL = `SELECT ${topFragment}${fields} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.BaseView)}`;
1275
+ let viewSQL = `SELECT ${topFragment}${fields} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, effectiveBaseView)}`;
1173
1276
  // count_only ALWAYS needs the count query — BuildTotalRowCountSQL only emits
1174
1277
  // it when rows are limited (its pagination purpose), which previously left
1175
1278
  // count_only with no COUNT at all (silently returned TotalRowCount 0).
1176
1279
  let countSQL = params.ResultType === 'count_only'
1177
- ? `SELECT COUNT(*) AS ${this.QuoteIdentifier('TotalRowCount')} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.BaseView)}`
1178
- : this.BuildTotalRowCountSQL(entityInfo, usingPagination, maxRowsForQuery);
1280
+ ? `SELECT COUNT(*) AS ${this.QuoteIdentifier('TotalRowCount')} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, effectiveBaseView)}`
1281
+ : this.BuildTotalRowCountSQL(entityInfo, usingPagination, maxRowsForQuery, effectiveBaseView);
1179
1282
  // ── WHERE clause assembly ──
1180
1283
  let whereSQL = '';
1181
1284
  let bHasWhere = false;
@@ -1281,7 +1384,10 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1281
1384
  // View run logging (SQL Server-specific, others return null)
1282
1385
  let userViewRunID = '';
1283
1386
  if (viewEntity?.ID && String(viewEntity.ID).length > 0 && saveViewResults && user) {
1284
- const logResult = await this.executeSQLForUserViewRunLogging(Number(viewEntity.ID), viewEntity.EntityBaseView, whereSQL, orderBy, user);
1387
+ // Pass entityInfo + effectiveBaseView so the logged read honors DataSource:'Materialized'
1388
+ // (reads the snapshot). effectiveBaseView === entityInfo.BaseView on the default live path,
1389
+ // so non-materialized reads are unchanged.
1390
+ const logResult = await this.executeSQLForUserViewRunLogging(Number(viewEntity.ID), entityInfo, effectiveBaseView, whereSQL, orderBy, user);
1285
1391
  if (logResult) {
1286
1392
  viewSQL = logResult.executeViewSQL;
1287
1393
  userViewRunID = logResult.runID;
@@ -1320,7 +1426,9 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1320
1426
  let aggregateSQL = null;
1321
1427
  let aggregateValidationErrors = [];
1322
1428
  if (params.Aggregates && params.Aggregates.length > 0) {
1323
- const aggregateBuild = this.BuildAggregateSQL(params.Aggregates, entityInfo, entityInfo.SchemaName, entityInfo.BaseView, whereSQL);
1429
+ // Aggregate over the SAME source the rows/count came from — effectiveBaseView, so a caller
1430
+ // asking for DataSource:'Materialized' gets aggregates over the snapshot, not the live view.
1431
+ const aggregateBuild = this.BuildAggregateSQL(params.Aggregates, entityInfo, entityInfo.SchemaName, effectiveBaseView, whereSQL);
1324
1432
  aggregateSQL = aggregateBuild.aggregateSQL;
1325
1433
  aggregateValidationErrors = aggregateBuild.validationErrors;
1326
1434
  }
@@ -1569,7 +1677,9 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1569
1677
  */
1570
1678
  buildPerFieldSearchPredicate(field, escapedTerm, rawSafeTerm) {
1571
1679
  if (field.UserSearchParamFormatAPI && field.UserSearchParamFormatAPI.length > 0) {
1572
- return field.UserSearchParamFormatAPI.replace('{0}', rawSafeTerm);
1680
+ // Function replacement: the term is end-user input, so `$&`/`` $` ``/`$'`/`$$`
1681
+ // in it must be data, not splice directives. See issue #3171.
1682
+ return field.UserSearchParamFormatAPI.replace('{0}', () => rawSafeTerm);
1573
1683
  }
1574
1684
  if (!this.isTextSearchableType(field))
1575
1685
  return '';
@@ -1641,7 +1751,9 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1641
1751
  if (innerViewEntity) {
1642
1752
  const innerWhere = await this.RenderViewWhereClause(innerViewEntity, user, stack);
1643
1753
  const innerSQL = `SELECT ${this.QuoteIdentifier(innerViewEntity.ViewEntityInfo.FirstPrimaryKey.Name)} FROM ${this.QuoteSchemaAndView(innerViewEntity.ViewEntityInfo.SchemaName, innerViewEntity.ViewEntityInfo.BaseView)} WHERE (${innerWhere})`;
1644
- sWhere = sWhere.replace(match, innerSQL);
1754
+ // Function replacement — `innerSQL` is generated SQL that can
1755
+ // legitimately contain `$`. See issue #3171.
1756
+ sWhere = sWhere.replace(match, () => innerSQL);
1645
1757
  }
1646
1758
  else
1647
1759
  throw new Error(`View ID ${variableValue} not found in metadata`);
@@ -1984,9 +2096,16 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1984
2096
  const results = new Map();
1985
2097
  if (items.length === 0)
1986
2098
  return results;
1987
- const promises = items.map(async ({ index, entityInfo, whereSQL }) => {
2099
+ const promises = items.map(async ({ index, item, entityInfo, whereSQL }) => {
1988
2100
  try {
1989
- const statusSQL = `SELECT COUNT(*) AS ${this.QuoteIdentifier('TotalRows')}, MAX(${this.QuoteIdentifier('__mj_UpdatedAt')}) AS ${this.QuoteIdentifier('MaxUpdatedAt')} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.BaseView)}${whereSQL ? ' WHERE ' + whereSQL : ''}`;
2101
+ // Probe the SAME physical view the read targets for a DataSource:'Materialized' read that's
2102
+ // the materialized_vw<CodeName> snapshot (a full SELECT * of the base view, so it carries
2103
+ // __mj_UpdatedAt), NOT the live base view. Probing the live view would compare the client's
2104
+ // snapshot cache against an unrelated source, yielding a meaningless current/stale verdict.
2105
+ // (Materialized reads are normally kept out of the client cache by runViewCacheEligible; this
2106
+ // matches the SQL Server override and is defense-in-depth on the PG/default path.)
2107
+ const effectiveView = await this.resolveEffectiveBaseView(entityInfo, item.params, contextUser);
2108
+ const statusSQL = `SELECT COUNT(*) AS ${this.QuoteIdentifier('TotalRows')}, MAX(${this.QuoteIdentifier('__mj_UpdatedAt')}) AS ${this.QuoteIdentifier('MaxUpdatedAt')} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, effectiveView)}${whereSQL ? ' WHERE ' + whereSQL : ''}`;
1990
2109
  const rows = await this.ExecuteSQL(statusSQL, undefined, undefined, contextUser);
1991
2110
  if (rows && rows.length > 0) {
1992
2111
  const row = rows[0];
@@ -2567,7 +2686,12 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2567
2686
  const row = rows[0];
2568
2687
  results.set(index, {
2569
2688
  success: true,
2570
- rowCount: Number(row['RowCount']),
2689
+ // Both spellings accepted. The shipped `CacheValidationSQL` field description tells authors
2690
+ // to return `TotalRows`, while this has only ever read `RowCount` — so a query written
2691
+ // from the documentation yields `Number(undefined)` = NaN, `NaN !== NaN` makes the slot
2692
+ // never validate, and NaN is then written back into the client's cache entry where `??`
2693
+ // cannot clear it. Silent, permanent degradation to always-refetch, with no error.
2694
+ rowCount: Number(row['RowCount'] ?? row['TotalRows']),
2571
2695
  maxUpdatedAt: row['MaxUpdatedAt'] ? new Date(String(row['MaxUpdatedAt'])).toISOString() : undefined,
2572
2696
  });
2573
2697
  }
@@ -2621,6 +2745,285 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2621
2745
  * execute → paginate → audit → cache store. Platform providers inherit this; only
2622
2746
  * `ExecuteSQL()` is platform-specific.
2623
2747
  */
2748
+ /**
2749
+ * Phase 2 read-time filter predicate — the runtime mirror of CodeGen's persisted `ReadFilterSpec`
2750
+ * entry. Duplicated here (not imported) because the provider must not depend on the dev-time
2751
+ * CodeGenLib; the JSON shape is the contract (plan §4).
2752
+ */
2753
+ static { this.RUNTIME_SAFE_READ_FILTER_OPERATORS = new Set([
2754
+ '=', '!=', '<>', '<', '>', '<=', '>=', 'IN', 'NOT IN',
2755
+ ]); }
2756
+ /**
2757
+ * The stable surrogate row-id column every materialized snapshot table carries (CodeGenLib's
2758
+ * `MATERIALIZATION_SURROGATE_COLUMN`). Duplicated here (not imported) for the same reason as the operator
2759
+ * set above — the provider must not depend on dev-time CodeGenLib. The wrapper view exposes it (`SELECT *`),
2760
+ * so ordering by it gives paged materialized reads a deterministic, refresh-stable order (see
2761
+ * {@link buildMaterializedReadQuery}).
2762
+ */
2763
+ static { this.MATERIALIZED_SURROGATE_ORDER_COLUMN = '__mj_MaterializedRowID'; }
2764
+ /** Quotes a SQL identifier for the target engine (SQL Server `[x]`, PostgreSQL `"x"`), escaping the closer. */
2765
+ static quoteMaterializedIdentifier(name, isPostgres) {
2766
+ return isPostgres ? `"${name.replace(/"/g, '""')}"` : `[${name.replace(/]/g, ']]')}]`;
2767
+ }
2768
+ /**
2769
+ * PURE, dialect-aware builder for the Phase-2 materialized read query (plan §5). Given the query's
2770
+ * output columns, the materialized view (schema + name), the persisted read-filter spec, the caller's
2771
+ * parameter values, and the platform, returns `{ sql, parameters }` whose WHERE injects each spec
2772
+ * predicate as `column <op> <placeholder>` with the value **bound** (SQL Server `?`, PostgreSQL `$n`) —
2773
+ * never interpolating a caller value (SQL-injection-safe by construction).
2774
+ *
2775
+ * Returns null on ANY condition that would make the materialized read UNFAITHFUL to the live query, so
2776
+ * the caller falls back to running live (always correct): an operator outside the safe set, a spec
2777
+ * parameter the caller did not supply (the live query would apply the param's default), a null value,
2778
+ * or an empty/non-array value for a list (`IN`/`NOT IN`) predicate. No IO — fully unit-testable.
2779
+ */
2780
+ /**
2781
+ * True if `sql`'s top-level SELECT carries an ORDER BY. Used to refuse a materialized RowFilterBroad read:
2782
+ * {@link buildMaterializedReadQuery} emits no ORDER BY and the snapshot is built with the source's top-level
2783
+ * ORDER BY stripped, so an ordered query must be served LIVE (where its ordering — and therefore its
2784
+ * pagination under StartRow/MaxRows — is preserved) rather than from the unordered snapshot. Parse failure or
2785
+ * an un-reasoned statement shape returns `true` (refuse-to-live: treat unknown as ordered rather than risk
2786
+ * serving mis-ordered pages). Mirrors MaterializationRefresher.stripTopLevelOrderBy's AST detection.
2787
+ *
2788
+ * @internal Materialized-read ordering-fidelity gate — NOT part of this package's supported public API;
2789
+ * `static` only so it can be unit-tested. Do not call from outside `@memberjunction/generic-database-provider`.
2790
+ */
2791
+ static queryHasTopLevelOrderBy(sql, platformKey) {
2792
+ if (!sql || sql.trim().length === 0)
2793
+ return false;
2794
+ try {
2795
+ const parsed = SQLParser.Astify(sql, GetDialect(platformKey ?? 'sqlserver'));
2796
+ if (!parsed.astParsed || parsed.ast == null)
2797
+ return true; // unparseable → refuse to live
2798
+ const stmtNode = Array.isArray(parsed.ast) ? (parsed.ast.length === 1 ? parsed.ast[0] : null) : parsed.ast;
2799
+ if (stmtNode == null || typeof stmtNode !== 'object')
2800
+ return true;
2801
+ const s = stmtNode;
2802
+ if (s.type !== 'select')
2803
+ return true; // not a simple SELECT we can reason about → refuse to live
2804
+ return s.orderby != null;
2805
+ }
2806
+ catch {
2807
+ return true; // parser threw → refuse to live (safe: served correctly by the live path)
2808
+ }
2809
+ }
2810
+ static buildMaterializedReadQuery(opts) {
2811
+ const { outputColumns, schemaName, viewName, spec, paramValues, paramTypes, isPostgres } = opts;
2812
+ if (!outputColumns || outputColumns.length === 0)
2813
+ return null;
2814
+ if (!spec || spec.length === 0)
2815
+ return null;
2816
+ const q = (name) => GenericDatabaseProvider.quoteMaterializedIdentifier(name, isPostgres);
2817
+ const parameters = [];
2818
+ const predicates = [];
2819
+ // Placeholder for the NEXT bound value: SQL Server uses positional `?`; PostgreSQL uses `$n` (1-based,
2820
+ // computed BEFORE the value is pushed so the index aligns with the array position).
2821
+ const nextPlaceholder = () => (isPostgres ? `$${parameters.length + 1}` : '?');
2822
+ for (const e of spec) {
2823
+ // Defensive: the spec is parsed from a persisted JSON string, so validate each element's shape
2824
+ // before use — a malformed element (missing/non-string column/operator/paramName) returns null
2825
+ // (→ caller falls back to live), never throws mid-build (Phase 2 §2: any uncertainty → live).
2826
+ if (!e || typeof e.column !== 'string' || typeof e.operator !== 'string' || typeof e.paramName !== 'string')
2827
+ return null;
2828
+ if (!GenericDatabaseProvider.RUNTIME_SAFE_READ_FILTER_OPERATORS.has(e.operator))
2829
+ return null;
2830
+ const val = paramValues ? paramValues[e.paramName] : undefined;
2831
+ if (val === undefined || val === null)
2832
+ return null; // caller omitted it → live applies the default
2833
+ const col = q(e.column);
2834
+ const isListOp = e.operator === 'IN' || e.operator === 'NOT IN';
2835
+ if (isListOp) {
2836
+ if (!Array.isArray(val) || val.length === 0)
2837
+ return null; // empty/non-array IN → live
2838
+ const phs = val.map((item) => {
2839
+ const ph = nextPlaceholder();
2840
+ parameters.push(item); // list elements bound as-is (array element type is not declared)
2841
+ return ph;
2842
+ });
2843
+ predicates.push(`${col} ${e.operator} (${phs.join(', ')})`);
2844
+ }
2845
+ else {
2846
+ // Bind the value AS ITS DECLARED TYPE. The live path renders params as typed SQL literals via the
2847
+ // Nunjucks pipeline; binding the raw string here would instead make the DB implicitly coerce it,
2848
+ // which can (a) error on PostgreSQL (text vs numeric/date) and (b) silently match DIFFERENT rows
2849
+ // than live on SQL Server for format/whitespace-sensitive values. Coercing to the declared type
2850
+ // aligns the two; an unconvertible value fails closed → live (never a wrong-rows materialized read).
2851
+ const coerced = GenericDatabaseProvider.coerceMaterializedScalarValue(val, paramTypes?.[e.paramName], isPostgres);
2852
+ if (!coerced.ok)
2853
+ return null;
2854
+ const ph = nextPlaceholder();
2855
+ parameters.push(coerced.value);
2856
+ predicates.push(`${col} ${e.operator} ${ph}`);
2857
+ }
2858
+ }
2859
+ const cols = outputColumns.map((c) => q(c)).join(', ');
2860
+ // Deterministic page order. A materialized read carries no user ORDER BY (an ordered source query falls
2861
+ // back to live), so without an explicit order the paging engine would append `ORDER BY (SELECT NULL)` and
2862
+ // successive pages could skip or duplicate rows whenever the execution plan differs between calls. The
2863
+ // snapshot's stable surrogate row-id — exposed by the wrapper view and unchanged between refreshes — gives
2864
+ // a total, refresh-stable order; it need not appear in the SELECT list to be an ORDER BY target, and
2865
+ // buildDataSQL detects this ORDER BY and pages against it instead of the dialect default.
2866
+ const orderBy = q(GenericDatabaseProvider.MATERIALIZED_SURROGATE_ORDER_COLUMN);
2867
+ const sql = `SELECT ${cols} FROM ${q(schemaName)}.${q(viewName)} WHERE ${predicates.join(' AND ')} ORDER BY ${orderBy}`;
2868
+ return { sql, parameters };
2869
+ }
2870
+ /**
2871
+ * Coerces a scalar row-filter value to its declared `MJ: Query Parameters`.Type for type-faithful binding
2872
+ * (see {@link buildMaterializedReadQuery}). This mirrors the TYPE-CONVERSION SWITCH of the live path's
2873
+ * `@memberjunction/queryprocessor` `QueryParameterProcessor.validateParameters` (number/boolean/date/string),
2874
+ * so the materialized read binds the same value the live query renders. Keep in sync with that switch. It does
2875
+ * NOT replay the subsequent ValidationFilters chain: value-TRANSFORMING filters (trim/upper/lower/etc.) are
2876
+ * already excluded from RowFilterBroad materialization at classify time (materializationParamClassifier's
2877
+ * `isValuePassthrough`), so they never reach here. Pure VALIDATORS (min/max/email/...) are a documented low
2878
+ * residual — they reject invalid input at runtime on the live path only, so an invalid caller value can make
2879
+ * live error while the materialized read returns rows (divergence on the error path only; tracked as a
2880
+ * follow-up to refuse validator-bearing row-filter params at classify time). Returns `{ok:false}` only where
2881
+ * the live TYPE conversion would ALSO reject the value (→ caller falls back to live).
2882
+ * - `number` → `Number(value)` (JS trims); non-finite → refuse (live pushes a validation error → live).
2883
+ * - `boolean` → live truthiness: ONLY 'true' (case-insensitive) or a real boolean `true` is true; everything
2884
+ * else is false (live never refuses a boolean). SQL Server binds BIT 1/0; PostgreSQL binds bool.
2885
+ * - `date` → `new Date(value).toISOString()` — the UTC ISO string, exactly what live stores/renders. NOT
2886
+ * the naive input string: live applies the local→UTC shift, so binding the raw string would
2887
+ * diverge for any timed value. Invalid date → refuse.
2888
+ * - `string` → `String(value)` (matches live); `array`-element / unspecified → bound as-is.
2889
+ */
2890
+ static coerceMaterializedScalarValue(value, type, isPostgres) {
2891
+ switch (type) {
2892
+ case 'number': {
2893
+ const n = Number(typeof value === 'string' ? value.trim() : value);
2894
+ return Number.isFinite(n) ? { ok: true, value: n } : { ok: false };
2895
+ }
2896
+ case 'boolean': {
2897
+ const b = typeof value === 'boolean' ? value : String(value).toLowerCase() === 'true';
2898
+ return { ok: true, value: isPostgres ? b : b ? 1 : 0 };
2899
+ }
2900
+ case 'date': {
2901
+ const d = value instanceof Date ? value : new Date(String(value));
2902
+ if (Number.isNaN(d.getTime()))
2903
+ return { ok: false };
2904
+ const iso = d.toISOString();
2905
+ // SQL Server rejects the ISO 'Z' zone suffix for datetime2/datetime (error 241 — 'Z' is valid only
2906
+ // for datetimeoffset), so binding it would throw and force a fallback to live. Strip it for SS: the
2907
+ // value is already the UTC moment and a zone-less ISO string binds as that same datetime2 wall-clock,
2908
+ // matching the live path's rendered literal (no row divergence). PostgreSQL's timestamptz accepts
2909
+ // 'Z', so keep it there.
2910
+ return { ok: true, value: isPostgres ? iso : iso.replace(/Z$/, '') };
2911
+ }
2912
+ case 'string':
2913
+ return { ok: true, value: String(value) };
2914
+ default: // 'array' (element-wise, no declared element type) or unknown → bind verbatim
2915
+ return { ok: true, value };
2916
+ }
2917
+ }
2918
+ /**
2919
+ * Phase 2 (plan §5): resolve a materialized read plan for a query IF the caller opted into
2920
+ * `DataSource:'Materialized'` AND the query has a fresh, Active `RowFilterBroad` materialization whose
2921
+ * persisted spec fully covers the query's parameters. Returns null on ANY uncertainty → the caller runs
2922
+ * the live query (serving live is always correct — this is a transparent optimization, never a
2923
+ * correctness dependency).
2924
+ */
2925
+ async tryBuildMaterializedQueryPlan(query, params, contextUser) {
2926
+ if (!IsMaterializedDataSource(params.DataSource))
2927
+ return null; // not opted in → live
2928
+ if (query.ExternalDataSourceID)
2929
+ return null; // external source → materialized table is local; live
2930
+ // The query<->materialization link lives in the MaterializedResultQuery join table — there is no
2931
+ // Query.MaterializedResultID column (that direct FK, paired with MaterializedResult.SourceQueryID,
2932
+ // formed a circular dependency). Resolve this query's materialization via the join; absent → live.
2933
+ // query.ID is our own metadata UUID (never caller input), so it is safe to interpolate.
2934
+ const linkRv = new RunView(this);
2935
+ const linkRes = await linkRv.RunView({
2936
+ EntityName: 'MJ: Materialized Result Queries',
2937
+ ExtraFilter: `QueryID='${query.ID}'`,
2938
+ Fields: ['MaterializedResultID'],
2939
+ ResultType: 'simple',
2940
+ MaxRows: 1,
2941
+ }, contextUser);
2942
+ // Same failure-vs-absence distinction as the base-view path: a permission/query failure here would
2943
+ // otherwise be indistinguishable from "this query is not materialized", silently pinning the caller to
2944
+ // live data forever. Still fall back to live (correct either way) — just not silently.
2945
+ if (this.MaterializationLookupFailed(linkRes, `query "${query.Name}" (materialization join lookup)`))
2946
+ return null;
2947
+ if (!linkRes.Results || linkRes.Results.length === 0)
2948
+ return null; // query not materialized → live
2949
+ const matId = linkRes.Results[0].MaterializedResultID;
2950
+ if (!matId)
2951
+ return null; // query not materialized → live
2952
+ // Ordering fidelity: buildMaterializedReadQuery emits no ORDER BY, and the snapshot was built with the
2953
+ // source's top-level ORDER BY stripped (it has no inherent order). A query that carries a top-level ORDER
2954
+ // BY would therefore page differently from the live query. Refuse → live (which preserves the ordering)
2955
+ // rather than serve a divergent page order — consistent with this method's "any uncertainty → live".
2956
+ // Check the PLATFORM-resolved SQL (the exact SQL the live path executes — GetPlatformSQL, line ~3642),
2957
+ // not the base query.SQL: a per-platform QuerySQL variant (e.g. a PostgreSQL variant) may add a top-level
2958
+ // ORDER BY the base SQL lacks, and parsing the base SQL would miss it and serve mis-ordered snapshot pages.
2959
+ if (GenericDatabaseProvider.queryHasTopLevelOrderBy(query.GetPlatformSQL(this.PlatformKey) ?? '', this.PlatformKey))
2960
+ return null;
2961
+ // Load the materialization metadata. matId is our own UUID (from committed metadata), so it is safe
2962
+ // to interpolate into ExtraFilter — it never carries caller input.
2963
+ const rv = new RunView(this);
2964
+ // BypassCache is REQUIRED here (H6): this read gates whether we route to the materialized table, and the
2965
+ // decisive column is Status. A refresher/CodeGen run can flip Status to 'DriftHold' or 'Disabled' out of
2966
+ // band, but a cached 'Active' row would let this plan keep serving the held/disabled snapshot — the exact
2967
+ // stale-serve the DriftHold mechanism exists to prevent. Reading straight from the DB guarantees we see
2968
+ // the current terminal status. This is a single-row point lookup, so the bypass cost is negligible.
2969
+ const res = await rv.RunView({
2970
+ EntityName: 'MJ: Materialized Results',
2971
+ ExtraFilter: `ID='${matId}'`,
2972
+ Fields: ['Status', 'ParamMode', 'ReadFilterSpec', 'SchemaName', 'ViewName'],
2973
+ ResultType: 'simple',
2974
+ MaxRows: 1,
2975
+ BypassCache: true,
2976
+ }, contextUser);
2977
+ if (this.MaterializationLookupFailed(res, `query "${query.Name}" (materialization metadata)`))
2978
+ return null;
2979
+ if (!res.Results || res.Results.length === 0)
2980
+ return null;
2981
+ const mat = res.Results[0];
2982
+ if (mat.Status !== 'Active')
2983
+ return null; // Building / DriftHold / stale → live
2984
+ if (mat.ParamMode !== 'RowFilterBroad')
2985
+ return null; // None / PerValueCache → live (this path only serves Bucket 1)
2986
+ if (!mat.ReadFilterSpec)
2987
+ return null;
2988
+ let spec;
2989
+ try {
2990
+ spec = JSON.parse(mat.ReadFilterSpec);
2991
+ }
2992
+ catch {
2993
+ return null; // malformed spec → live
2994
+ }
2995
+ if (!Array.isArray(spec) || spec.length === 0)
2996
+ return null;
2997
+ // Coverage invariant (BOTH directions): a RowFilterBroad query's parameters are ALL row-filters (a mix
2998
+ // refuses at classify time), so the query's parameter set and the persisted spec's parameter set MUST be
2999
+ // identical. A query param missing from the spec → we would UNDER-filter; a spec param the query no longer
3000
+ // has (stale metadata after an out-of-band edit — the same window H6 guards against) → we would OVER-filter
3001
+ // vs. live, silently returning fewer rows. Either mismatch means the spec is inconsistent → refuse to live.
3002
+ const specNames = new Set(spec.map((s) => s.paramName));
3003
+ const queryParamNames = (query.QueryParameters ?? []).map((p) => p.Name);
3004
+ const queryParamNameSet = new Set(queryParamNames);
3005
+ if (queryParamNames.some((n) => !specNames.has(n)))
3006
+ return null; // query param not in spec → under-filter
3007
+ if (spec.some((s) => !queryParamNameSet.has(s.paramName)))
3008
+ return null; // spec param not in query → over-filter
3009
+ const outputColumns = (query.QueryFields ?? []).map((f) => f.Name).filter((n) => !!n);
3010
+ if (outputColumns.length === 0)
3011
+ return null;
3012
+ // Declared parameter types (name → Type) so the row-filter values bind type-faithfully (see
3013
+ // buildMaterializedReadQuery / coerceMaterializedScalarValue), matching the live path's typed literals.
3014
+ const paramTypes = {};
3015
+ for (const p of query.QueryParameters ?? [])
3016
+ paramTypes[p.Name] = p.Type;
3017
+ return GenericDatabaseProvider.buildMaterializedReadQuery({
3018
+ outputColumns,
3019
+ schemaName: mat.SchemaName,
3020
+ viewName: mat.ViewName,
3021
+ spec,
3022
+ paramValues: params.Parameters,
3023
+ paramTypes,
3024
+ isPostgres: this.PlatformKey === 'postgresql',
3025
+ });
3026
+ }
2624
3027
  async InternalRunQuery(params, contextUser) {
2625
3028
  // Route ad-hoc SQL queries to dedicated handler
2626
3029
  if (params.SQL) {
@@ -2634,6 +3037,81 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2634
3037
  const resolved = this.processQueryParameters(query, params.Parameters, contextUser);
2635
3038
  finalSQL = resolved.finalSQL;
2636
3039
  const appliedParameters = resolved.appliedParameters;
3040
+ // ── Phase 2: materialized read redirect (plan §5) ──
3041
+ // If the caller opted into DataSource:'Materialized' and this query has a fresh, Active
3042
+ // RowFilterBroad materialization, serve from the materialized table with the row-filter params
3043
+ // injected as BOUND predicates. On ANY uncertainty tryBuildMaterializedQueryPlan returns null and
3044
+ // we fall through to the live execution below — a materialized read can never diverge from live.
3045
+ const matPlan = await this.tryBuildMaterializedQueryPlan(query, params, contextUser);
3046
+ if (matPlan) {
3047
+ // Use a LOCAL for the materialized SQL — never overwrite `finalSQL` (which stays the
3048
+ // live-rendered SQL), so a fallback below runs the live path unchanged.
3049
+ try {
3050
+ const materializedSQL = matPlan.sql;
3051
+ // SQL-level paging parity with the live path: when the caller requested a page, wrap the
3052
+ // materialized read with OFFSET/FETCH (SQL Server) / LIMIT-OFFSET (PostgreSQL) plus a parallel
3053
+ // COUNT — via the same QueryPagingEngine the live path uses — so only the requested page is
3054
+ // pulled from the snapshot. Without this the whole (potentially multi-million-row) filtered
3055
+ // snapshot was loaded into Node and sliced in memory, defeating the large-dataset case
3056
+ // materialization targets. The bound row-filter parameters (matPlan.parameters) are preserved
3057
+ // in both the data and count SQL; StartRow/MaxRows are inlined by WrapWithPaging. The materialized
3058
+ // read carries an explicit `ORDER BY <surrogate row-id>` (buildMaterializedReadQuery), so paging
3059
+ // is deterministic and refresh-stable across pages rather than relying on the dialect default
3060
+ // order. No cache layer — materialized reads bypass the query cache by design. When no page is
3061
+ // requested, keep the full-load path.
3062
+ const matUseSQLPaging = QueryPagingEngine.ShouldPage(params.StartRow, params.MaxRows);
3063
+ let rows;
3064
+ let matTotalRowCount;
3065
+ let matExecutionTime;
3066
+ if (matUseSQLPaging) {
3067
+ const paging = QueryPagingEngine.WrapWithPaging(materializedSQL, params.StartRow, params.MaxRows, this.PlatformKey);
3068
+ const start = Date.now();
3069
+ const [dataResult, countResult] = await Promise.all([
3070
+ this.ExecuteSQL(paging.DataSQL, matPlan.parameters, undefined, contextUser),
3071
+ this.ExecuteSQL(paging.CountSQL, matPlan.parameters, undefined, contextUser),
3072
+ ]);
3073
+ matExecutionTime = Date.now() - start;
3074
+ rows = dataResult ?? [];
3075
+ matTotalRowCount = countResult?.[0]?.TotalRowCount != null ? Number(countResult[0].TotalRowCount) : rows.length;
3076
+ }
3077
+ else {
3078
+ const timing = await this.executeQueryWithTiming(materializedSQL, contextUser, matPlan.parameters);
3079
+ matExecutionTime = timing.executionTime;
3080
+ const paginated = this.applyQueryPagination(timing.result, params);
3081
+ rows = paginated.paginatedResult;
3082
+ matTotalRowCount = paginated.totalRowCount;
3083
+ }
3084
+ if (params.Enrichment?.EnricherKey) {
3085
+ rows = await this.enrichQueryResults(rows, params, query, contextUser);
3086
+ }
3087
+ this.auditQueryExecution(query, params, materializedSQL, rows.length, matTotalRowCount, matExecutionTime, contextUser);
3088
+ return {
3089
+ Success: true,
3090
+ QueryID: query.ID,
3091
+ QueryName: query.Name,
3092
+ Results: rows,
3093
+ RowCount: rows.length,
3094
+ TotalRowCount: matTotalRowCount,
3095
+ PageNumber: matUseSQLPaging ? Math.floor(params.StartRow / params.MaxRows) + 1 : undefined,
3096
+ PageSize: matUseSQLPaging ? params.MaxRows : undefined,
3097
+ ExecutionTime: matExecutionTime,
3098
+ ErrorMessage: '',
3099
+ AppliedParameters: appliedParameters,
3100
+ RenderedSQL: materializedSQL,
3101
+ CacheHit: false,
3102
+ };
3103
+ }
3104
+ catch (matErr) {
3105
+ // A connection error is fatal for the live path too — let the outer handler surface it.
3106
+ if (this.isConnectionError(matErr))
3107
+ throw matErr;
3108
+ // Any other materialized-read failure (e.g. the wrapper view was rebuilt/dropped between the
3109
+ // freshness check and execution, or a column/grant mismatch) FALLS BACK to the live query.
3110
+ // Serving live is always correct, so a materialized-read failure must never fail a request
3111
+ // that would otherwise succeed (Phase 2 §2 safety model). `finalSQL` is still the live SQL.
3112
+ LogError(`Materialized read failed for query '${query.Name}' — falling back to live: ${matErr instanceof Error ? matErr.message : String(matErr)}`);
3113
+ }
3114
+ }
2637
3115
  // ── External data source dispatch ──
2638
3116
  // Queries bound to an external data source execute their (now fully-rendered)
2639
3117
  // native SQL via the driver, not the MJ DB. No-op for MJ-DB queries.
@@ -3033,9 +3511,9 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
3033
3511
  /**
3034
3512
  * Executes the query SQL and tracks execution time.
3035
3513
  */
3036
- async executeQueryWithTiming(sql, contextUser) {
3514
+ async executeQueryWithTiming(sql, contextUser, parameters) {
3037
3515
  const start = Date.now();
3038
- const result = await this.ExecuteSQL(sql, undefined, undefined, contextUser);
3516
+ const result = await this.ExecuteSQL(sql, parameters, undefined, contextUser);
3039
3517
  const executionTime = Date.now() - start;
3040
3518
  if (!result) {
3041
3519
  throw new Error('Error executing query SQL');
@@ -3383,11 +3861,40 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
3383
3861
  */
3384
3862
  renderRLSProjectionValue(field, val) {
3385
3863
  if (val == null) {
3386
- const sqlType = field.SQLFullType;
3387
- if (!sqlType || sqlType.trim().length === 0) {
3864
+ // Metadata records field types in SQL Server's vocabulary, so interpolating one raw
3865
+ // emits `CAST(NULL AS uniqueidentifier)` on PostgreSQL, where that type does not exist.
3866
+ // The statement throws — and because this is the RLS post-image gate, the caller gets a
3867
+ // type error in place of the row-scope rejection it was testing for: an authorization
3868
+ // decision surfacing as a SQL fault. Translate through the dialect, the same mapping
3869
+ // CodeGen and the converter use.
3870
+ //
3871
+ // Map from `field.Type` (the BASE type), NOT `field.SQLFullType`, which already has the
3872
+ // length formatted in as `nvarchar(50)`. `MapDataTypeToString` looks the base type up
3873
+ // and returns anything unrecognized UNCHANGED, so a full type string maps to itself and
3874
+ // the translation silently no-ops. `uniqueidentifier` carries no length and so maps
3875
+ // correctly either way — which is precisely how a half-fixed version of this passes a
3876
+ // spot check and still emits `CAST(NULL AS nvarchar(50))` on the very next field.
3877
+ const baseType = field.Type;
3878
+ if (!baseType || baseType.trim().length === 0) {
3388
3879
  throw new Error(`Cannot build RLS post-image projection: field ${field.Name} has no resolvable SQL type for a typed NULL`);
3389
3880
  }
3390
- return `CAST(NULL AS ${sqlType})`;
3881
+ // `field.Length` is the SYSTEM (byte) length from `sys.columns`, but
3882
+ // `MapDataTypeToString` takes a CHARACTER count — `RuntimeSchemaManager` calls it as
3883
+ // `('NVARCHAR', 200)` meaning 200 characters. `SQLFullType` knows the difference and
3884
+ // halves it for the n-types; passing the raw byte length instead DOUBLES every
3885
+ // `nvarchar`/`nchar` width. That is not cosmetic: SQL Server caps `NVARCHAR` at 4000,
3886
+ // and 119 fields in a stock database — including the shipped
3887
+ // `MJ: Conversation Detail Attachments.FileName` at 8000 — exceed it once doubled, so
3888
+ // the CAST becomes illegal and the RLS post-image gate throws on SQL SERVER: the exact
3889
+ // failure this change set out to remove from PostgreSQL, relocated to the primary
3890
+ // platform. `MaxLength` does the halving, but flattens the `-1` MAX sentinel to 0, so
3891
+ // the sentinel is passed through explicitly.
3892
+ const charLength = field.Length === -1 ? -1 : field.MaxLength;
3893
+ // `getDialect()` is nullable on this base. Falling back to `SQLFullType` — the exact
3894
+ // string this used to emit — keeps SQL Server, whose vocabulary that already is,
3895
+ // byte-identical to its previous behaviour.
3896
+ const mapped = this.getDialect()?.MapDataTypeToString(baseType, charLength, field.Precision, field.Scale);
3897
+ return `CAST(NULL AS ${mapped ?? field.SQLFullType})`;
3391
3898
  }
3392
3899
  if (typeof val === 'boolean') {
3393
3900
  return val ? '1' : '0';