@memberjunction/generic-database-provider 6.1.0-edge.2 → 6.1.0-edge.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +180 -4
- package/dist/GenericDatabaseProvider.d.ts +149 -3
- package/dist/GenericDatabaseProvider.d.ts.map +1 -1
- package/dist/GenericDatabaseProvider.js +526 -19
- package/dist/GenericDatabaseProvider.js.map +1 -1
- package/dist/UserCache.d.ts +19 -0
- package/dist/UserCache.d.ts.map +1 -1
- package/dist/UserCache.js +21 -0
- package/dist/UserCache.js.map +1 -1
- package/dist/queryCompositionEngine.d.ts.map +1 -1
- package/dist/queryCompositionEngine.js +13 -1
- package/dist/queryCompositionEngine.js.map +1 -1
- package/package.json +15 -15
|
@@ -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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
3387
|
-
|
|
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
|
-
|
|
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';
|