@memberjunction/generic-database-provider 6.1.0-edge.1 → 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.
- package/LICENSE +180 -4
- package/dist/GenericDatabaseProvider.d.ts +173 -4
- package/dist/GenericDatabaseProvider.d.ts.map +1 -1
- package/dist/GenericDatabaseProvider.js +614 -30
- package/dist/GenericDatabaseProvider.js.map +1 -1
- package/dist/UserCache.d.ts +78 -0
- package/dist/UserCache.d.ts.map +1 -0
- package/dist/UserCache.js +124 -0
- package/dist/UserCache.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.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';
|
|
@@ -29,6 +31,7 @@ import { AIEngine } from '@memberjunction/aiengine';
|
|
|
29
31
|
import { SimpleVectorServiceProvider } from '@memberjunction/ai-vectors-memory';
|
|
30
32
|
import { QueueManager } from '@memberjunction/queue';
|
|
31
33
|
import { BuildEntityActionDispatchKey, EntityActionDispatchGuard, EntityActionEngineServer } from '@memberjunction/actions';
|
|
34
|
+
import { BuildEntityChangeContext } from '@memberjunction/actions-base';
|
|
32
35
|
import { EncryptionEngine } from '@memberjunction/encryption';
|
|
33
36
|
import { GeoCodeSyncService } from '@memberjunction/geo-core';
|
|
34
37
|
/**
|
|
@@ -296,12 +299,24 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
296
299
|
* synchronous half of the pipeline: `Validate` and `Before*` participate in the save and can
|
|
297
300
|
* abort it, so skipping one would let a record through that should have been refused, and
|
|
298
301
|
* deferring one would decide the save's outcome after it had already happened.
|
|
302
|
+
*
|
|
303
|
+
* **An After-hook binding may also ask to run durably** (`EntityAction.RunMode = 'Durable'`).
|
|
304
|
+
* After-hooks are dispatched fire-and-forget, so a process that dies mid-flight loses the work
|
|
305
|
+
* with nothing to retry it; durable dispatch hands it to the task-graph substrate instead (D14).
|
|
306
|
+
* `Validate` and `Before*` ignore RunMode entirely — deferring work that decides whether the
|
|
307
|
+
* save succeeds is not a durability improvement, it is a different feature.
|
|
299
308
|
*/
|
|
300
309
|
async HandleEntityActions(entity, baseType, before, user, originatingEntityActionIDs) {
|
|
310
|
+
// FIRST STATEMENT, and deliberately before the first `await`. After-hooks are dispatched
|
|
311
|
+
// fire-and-forget, and the moment this method yields, the save completes and `finalizeSave()`
|
|
312
|
+
// reloads the entity — resetting every field's OldValue to its new value. A change context
|
|
313
|
+
// built any later would report that nothing changed. Everything below this line may yield;
|
|
314
|
+
// nothing above it does.
|
|
315
|
+
const entityChange = BuildEntityChangeContext(entity);
|
|
301
316
|
try {
|
|
302
317
|
const engine = EntityActionEngineServer.Instance;
|
|
303
318
|
await engine.Config(false, user);
|
|
304
|
-
const newRecord =
|
|
319
|
+
const newRecord = entityChange.IsCreate;
|
|
305
320
|
const baseTypeType = baseType === 'save' ? (newRecord ? 'Create' : 'Update') : 'Delete';
|
|
306
321
|
const invocationType = baseType === 'validate' ? 'Validate' : before ? 'Before' + baseTypeType : 'After' + baseTypeType;
|
|
307
322
|
const invocationTypeEntity = engine.InvocationTypes.find((i) => i.Name === invocationType);
|
|
@@ -322,11 +337,15 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
322
337
|
continue;
|
|
323
338
|
}
|
|
324
339
|
const runOnce = async () => {
|
|
340
|
+
// Durability is NOT decided here. A binding's RunMode is honoured inside the
|
|
341
|
+
// invocation path, after its scope check and its filters — deciding it at this
|
|
342
|
+
// level would hand the work over before either gate ran.
|
|
325
343
|
const result = await engine.RunEntityAction({
|
|
326
344
|
EntityAction: a,
|
|
327
345
|
EntityObject: entity,
|
|
328
346
|
InvocationType: invocationTypeEntity,
|
|
329
347
|
ContextUser: user,
|
|
348
|
+
EntityChange: entityChange,
|
|
330
349
|
});
|
|
331
350
|
// null means the binding is scoped (ScopeEntityID/ScopeRecordID) and this record falls
|
|
332
351
|
// outside it — the action never ran, so there is no result to report.
|
|
@@ -882,11 +901,109 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
882
901
|
* `QuoteIdentifier` produces `"TotalRowCount"` on PG and `[TotalRowCount]` on SQL
|
|
883
902
|
* Server — both preserve case.
|
|
884
903
|
*/
|
|
885
|
-
BuildTotalRowCountSQL(entityInfo, usingPagination, maxRowsForQuery) {
|
|
904
|
+
BuildTotalRowCountSQL(entityInfo, usingPagination, maxRowsForQuery, baseViewOverride) {
|
|
886
905
|
const rowsAreLimited = usingPagination || maxRowsForQuery > 0;
|
|
887
906
|
if (!rowsAreLimited)
|
|
888
907
|
return null;
|
|
889
|
-
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;
|
|
890
1007
|
}
|
|
891
1008
|
/**
|
|
892
1009
|
* Validates that the entity and RunViewParams are compatible with keyset (AfterKey) pagination,
|
|
@@ -1033,7 +1150,7 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1033
1150
|
* SQL Server overrides to use spCreateUserViewRunWithDetail.
|
|
1034
1151
|
* Default: returns null (no view run logging).
|
|
1035
1152
|
*/
|
|
1036
|
-
async executeSQLForUserViewRunLogging(_viewId,
|
|
1153
|
+
async executeSQLForUserViewRunLogging(_viewId, _entityInfo, _effectiveBaseView, _whereSQL, _orderBySQL, _user) {
|
|
1037
1154
|
return null;
|
|
1038
1155
|
}
|
|
1039
1156
|
/**
|
|
@@ -1151,14 +1268,17 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1151
1268
|
// ── Field selection ──
|
|
1152
1269
|
const fields = this.getRunTimeViewFieldString(params, viewEntity);
|
|
1153
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);
|
|
1154
1274
|
const topFragment = topSQL ? topSQL + ' ' : '';
|
|
1155
|
-
let viewSQL = `SELECT ${topFragment}${fields} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName,
|
|
1275
|
+
let viewSQL = `SELECT ${topFragment}${fields} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, effectiveBaseView)}`;
|
|
1156
1276
|
// count_only ALWAYS needs the count query — BuildTotalRowCountSQL only emits
|
|
1157
1277
|
// it when rows are limited (its pagination purpose), which previously left
|
|
1158
1278
|
// count_only with no COUNT at all (silently returned TotalRowCount 0).
|
|
1159
1279
|
let countSQL = params.ResultType === 'count_only'
|
|
1160
|
-
? `SELECT COUNT(*) AS ${this.QuoteIdentifier('TotalRowCount')} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName,
|
|
1161
|
-
: 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);
|
|
1162
1282
|
// ── WHERE clause assembly ──
|
|
1163
1283
|
let whereSQL = '';
|
|
1164
1284
|
let bHasWhere = false;
|
|
@@ -1232,36 +1352,63 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1232
1352
|
// caller's OrderBy — BuildKeysetSeekClause already validated that any caller-provided
|
|
1233
1353
|
// OrderBy referenced the PK and we use the resolved direction.
|
|
1234
1354
|
let rawOrderBy;
|
|
1355
|
+
let orderByIsPkFallback = false;
|
|
1235
1356
|
if (usingKeyset) {
|
|
1236
1357
|
rawOrderBy = `${keysetPkColumnName} ${keysetDirection}`;
|
|
1237
1358
|
}
|
|
1238
1359
|
else {
|
|
1239
1360
|
rawOrderBy = params.OrderBy ? params.OrderBy : (viewEntity ? viewEntity.OrderByClause ?? '' : '');
|
|
1361
|
+
if (rawOrderBy.trim().length === 0 && maxRowsForQuery > 0 && entityInfo.FirstPrimaryKey) {
|
|
1362
|
+
// ── DETERMINISM FALLBACK ──
|
|
1363
|
+
// A row-LIMITED query with no ORDER BY returns an ARBITRARY subset: `TOP N` /
|
|
1364
|
+
// `LIMIT N` without an ordering is undefined by definition, and the engine is
|
|
1365
|
+
// free to return different rows run to run (or between two runs of the *same*
|
|
1366
|
+
// walk). Order by the PK so "the first N rows" means something.
|
|
1367
|
+
//
|
|
1368
|
+
// This is what broke keyset (AfterKey) pagination end to end. Page 1 of a walk
|
|
1369
|
+
// has no cursor yet, so `usingKeyset` is false and it landed here unordered,
|
|
1370
|
+
// while page 2+ force `ORDER BY <pk>` + `<pk> > @afterKey`. The two pages were
|
|
1371
|
+
// ordered differently, so a walk both RE-RETURNED page-1 rows and could silently
|
|
1372
|
+
// MISS others. Reproduced as 4 duplicates in an 8-row page (IT25 V10).
|
|
1373
|
+
//
|
|
1374
|
+
// OFFSET pagination already had exactly this fallback (see the pagination block
|
|
1375
|
+
// below); it was simply never applied to the TOP/LIMIT path. Same PK, so a
|
|
1376
|
+
// keyset walk's page 1 now agrees with every later page.
|
|
1377
|
+
rawOrderBy = this.QuoteIdentifier(entityInfo.FirstPrimaryKey.Name);
|
|
1378
|
+
orderByIsPkFallback = true;
|
|
1379
|
+
}
|
|
1240
1380
|
}
|
|
1241
1381
|
const orderBy = rawOrderBy.length > 0
|
|
1242
|
-
? (usingKeyset ? rawOrderBy : this.TransformExternalSQLClause(rawOrderBy, entityInfo))
|
|
1382
|
+
? (usingKeyset || orderByIsPkFallback ? rawOrderBy : this.TransformExternalSQLClause(rawOrderBy, entityInfo))
|
|
1243
1383
|
: '';
|
|
1244
1384
|
// View run logging (SQL Server-specific, others return null)
|
|
1245
1385
|
let userViewRunID = '';
|
|
1246
1386
|
if (viewEntity?.ID && String(viewEntity.ID).length > 0 && saveViewResults && user) {
|
|
1247
|
-
|
|
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);
|
|
1248
1391
|
if (logResult) {
|
|
1249
1392
|
viewSQL = logResult.executeViewSQL;
|
|
1250
1393
|
userViewRunID = logResult.runID;
|
|
1251
1394
|
}
|
|
1252
1395
|
else if (orderBy.length > 0) {
|
|
1253
|
-
if (!usingKeyset && !this.ValidateUserProvidedSQLClause(orderBy))
|
|
1396
|
+
if (!usingKeyset && !orderByIsPkFallback && !this.ValidateUserProvidedSQLClause(orderBy))
|
|
1254
1397
|
throw new Error(`Invalid Order By clause: ${orderBy}, contains one more for forbidden keywords`);
|
|
1255
1398
|
viewSQL += ` ORDER BY ${orderBy}`;
|
|
1256
1399
|
}
|
|
1257
1400
|
}
|
|
1258
1401
|
else if (orderBy.length > 0) {
|
|
1259
|
-
if (!usingKeyset && !this.ValidateUserProvidedSQLClause(orderBy))
|
|
1402
|
+
if (!usingKeyset && !orderByIsPkFallback && !this.ValidateUserProvidedSQLClause(orderBy))
|
|
1260
1403
|
throw new Error(`Invalid Order By clause: ${orderBy}, contains one more for forbidden keywords`);
|
|
1261
1404
|
viewSQL += ` ORDER BY ${orderBy}`;
|
|
1262
1405
|
}
|
|
1263
1406
|
// ── Pagination / Non-paginated limit ──
|
|
1264
1407
|
if (usingPagination && entityInfo.FirstPrimaryKey) {
|
|
1408
|
+
// Belt-and-braces: the determinism fallback above already supplies ORDER BY <PK>
|
|
1409
|
+
// for every row-limited query (pagination included), so `orderBy` is normally
|
|
1410
|
+
// non-empty here. Kept because OFFSET/FETCH is a hard SYNTAX error without an
|
|
1411
|
+
// ORDER BY — if the fallback above is ever narrowed, this must still hold.
|
|
1265
1412
|
if (!orderBy) {
|
|
1266
1413
|
viewSQL += ` ORDER BY ${this.QuoteIdentifier(entityInfo.FirstPrimaryKey.Name)}`;
|
|
1267
1414
|
}
|
|
@@ -1279,7 +1426,9 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1279
1426
|
let aggregateSQL = null;
|
|
1280
1427
|
let aggregateValidationErrors = [];
|
|
1281
1428
|
if (params.Aggregates && params.Aggregates.length > 0) {
|
|
1282
|
-
|
|
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);
|
|
1283
1432
|
aggregateSQL = aggregateBuild.aggregateSQL;
|
|
1284
1433
|
aggregateValidationErrors = aggregateBuild.validationErrors;
|
|
1285
1434
|
}
|
|
@@ -1528,7 +1677,9 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1528
1677
|
*/
|
|
1529
1678
|
buildPerFieldSearchPredicate(field, escapedTerm, rawSafeTerm) {
|
|
1530
1679
|
if (field.UserSearchParamFormatAPI && field.UserSearchParamFormatAPI.length > 0) {
|
|
1531
|
-
|
|
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);
|
|
1532
1683
|
}
|
|
1533
1684
|
if (!this.isTextSearchableType(field))
|
|
1534
1685
|
return '';
|
|
@@ -1600,7 +1751,9 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1600
1751
|
if (innerViewEntity) {
|
|
1601
1752
|
const innerWhere = await this.RenderViewWhereClause(innerViewEntity, user, stack);
|
|
1602
1753
|
const innerSQL = `SELECT ${this.QuoteIdentifier(innerViewEntity.ViewEntityInfo.FirstPrimaryKey.Name)} FROM ${this.QuoteSchemaAndView(innerViewEntity.ViewEntityInfo.SchemaName, innerViewEntity.ViewEntityInfo.BaseView)} WHERE (${innerWhere})`;
|
|
1603
|
-
|
|
1754
|
+
// Function replacement — `innerSQL` is generated SQL that can
|
|
1755
|
+
// legitimately contain `$`. See issue #3171.
|
|
1756
|
+
sWhere = sWhere.replace(match, () => innerSQL);
|
|
1604
1757
|
}
|
|
1605
1758
|
else
|
|
1606
1759
|
throw new Error(`View ID ${variableValue} not found in metadata`);
|
|
@@ -1943,9 +2096,16 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1943
2096
|
const results = new Map();
|
|
1944
2097
|
if (items.length === 0)
|
|
1945
2098
|
return results;
|
|
1946
|
-
const promises = items.map(async ({ index, entityInfo, whereSQL }) => {
|
|
2099
|
+
const promises = items.map(async ({ index, item, entityInfo, whereSQL }) => {
|
|
1947
2100
|
try {
|
|
1948
|
-
|
|
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 : ''}`;
|
|
1949
2109
|
const rows = await this.ExecuteSQL(statusSQL, undefined, undefined, contextUser);
|
|
1950
2110
|
if (rows && rows.length > 0) {
|
|
1951
2111
|
const row = rows[0];
|
|
@@ -2167,6 +2327,9 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
2167
2327
|
// caller's requested fields (∪ PK) before returning, exactly like the
|
|
2168
2328
|
// ProviderBase hit path. Serving unprojected rows here previously leaked
|
|
2169
2329
|
// whatever shape happened to be cached to every subsequent caller.
|
|
2330
|
+
//
|
|
2331
|
+
// `serverCached.results` are the cache's shared, deep-frozen rows — the runtime freeze
|
|
2332
|
+
// is what actually protects the cache. See ProviderBase's hit path.
|
|
2170
2333
|
const results = callerFields
|
|
2171
2334
|
? ProjectRowsToFields(serverCached.results, callerFields)
|
|
2172
2335
|
: serverCached.results;
|
|
@@ -2178,8 +2341,8 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
2178
2341
|
rowCount: serverCached.totalRowCount ?? serverCached.rowCount,
|
|
2179
2342
|
// Carry aggregates through (B40-family, 4th drop). The server slot stores them and
|
|
2180
2343
|
// GetRunViewResult returns them, but this serve-leg's inline serverCached type omitted
|
|
2181
|
-
// the field, so TypeScript could not flag the drop. Both
|
|
2182
|
-
//
|
|
2344
|
+
// the field, so TypeScript could not flag the drop. Both legs now share the canonical
|
|
2345
|
+
// CachedRunViewResult type, so a future field can no longer be silently dropped here.
|
|
2183
2346
|
aggregateResults: serverCached.aggregateResults,
|
|
2184
2347
|
};
|
|
2185
2348
|
}
|
|
@@ -2523,7 +2686,12 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
2523
2686
|
const row = rows[0];
|
|
2524
2687
|
results.set(index, {
|
|
2525
2688
|
success: true,
|
|
2526
|
-
|
|
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']),
|
|
2527
2695
|
maxUpdatedAt: row['MaxUpdatedAt'] ? new Date(String(row['MaxUpdatedAt'])).toISOString() : undefined,
|
|
2528
2696
|
});
|
|
2529
2697
|
}
|
|
@@ -2577,6 +2745,285 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
2577
2745
|
* execute → paginate → audit → cache store. Platform providers inherit this; only
|
|
2578
2746
|
* `ExecuteSQL()` is platform-specific.
|
|
2579
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
|
+
}
|
|
2580
3027
|
async InternalRunQuery(params, contextUser) {
|
|
2581
3028
|
// Route ad-hoc SQL queries to dedicated handler
|
|
2582
3029
|
if (params.SQL) {
|
|
@@ -2590,6 +3037,81 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
2590
3037
|
const resolved = this.processQueryParameters(query, params.Parameters, contextUser);
|
|
2591
3038
|
finalSQL = resolved.finalSQL;
|
|
2592
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
|
+
}
|
|
2593
3115
|
// ── External data source dispatch ──
|
|
2594
3116
|
// Queries bound to an external data source execute their (now fully-rendered)
|
|
2595
3117
|
// native SQL via the driver, not the MJ DB. No-op for MJ-DB queries.
|
|
@@ -2849,6 +3371,9 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
2849
3371
|
QueryID: cached.queryId ?? query.ID,
|
|
2850
3372
|
QueryName: query.Name,
|
|
2851
3373
|
Success: true,
|
|
3374
|
+
// Transport boundary: `cached.results` is readonly (shared, deep-frozen cache
|
|
3375
|
+
// rows) while the outbound Results is mutable — the runtime freeze is the
|
|
3376
|
+
// enforcement. Same cast as the RunView hit paths in ProviderBase.
|
|
2852
3377
|
Results: cached.results,
|
|
2853
3378
|
RowCount: cached.results.length,
|
|
2854
3379
|
TotalRowCount: cached.rowCount ?? cached.results.length,
|
|
@@ -2986,9 +3511,9 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
2986
3511
|
/**
|
|
2987
3512
|
* Executes the query SQL and tracks execution time.
|
|
2988
3513
|
*/
|
|
2989
|
-
async executeQueryWithTiming(sql, contextUser) {
|
|
3514
|
+
async executeQueryWithTiming(sql, contextUser, parameters) {
|
|
2990
3515
|
const start = Date.now();
|
|
2991
|
-
const result = await this.ExecuteSQL(sql,
|
|
3516
|
+
const result = await this.ExecuteSQL(sql, parameters, undefined, contextUser);
|
|
2992
3517
|
const executionTime = Date.now() - start;
|
|
2993
3518
|
if (!result) {
|
|
2994
3519
|
throw new Error('Error executing query SQL');
|
|
@@ -3336,11 +3861,40 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
3336
3861
|
*/
|
|
3337
3862
|
renderRLSProjectionValue(field, val) {
|
|
3338
3863
|
if (val == null) {
|
|
3339
|
-
|
|
3340
|
-
|
|
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) {
|
|
3341
3879
|
throw new Error(`Cannot build RLS post-image projection: field ${field.Name} has no resolvable SQL type for a typed NULL`);
|
|
3342
3880
|
}
|
|
3343
|
-
|
|
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})`;
|
|
3344
3898
|
}
|
|
3345
3899
|
if (typeof val === 'boolean') {
|
|
3346
3900
|
return val ? '1' : '0';
|
|
@@ -3447,7 +4001,7 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
3447
4001
|
}
|
|
3448
4002
|
// Try cache first
|
|
3449
4003
|
if (cacheAvailable) {
|
|
3450
|
-
const fingerprint = cache.GenerateRunViewFingerprint({ EntityName: entityName, ExtraFilter: effectiveFilter }, this.InstanceConnectionString);
|
|
4004
|
+
const fingerprint = cache.GenerateRunViewFingerprint({ EntityName: entityName, ExtraFilter: effectiveFilter }, this.InstanceConnectionString, undefined, this.datasetCacheSegment(datasetName, code));
|
|
3451
4005
|
const cached = await cache.GetRunViewResult(fingerprint);
|
|
3452
4006
|
if (cached) {
|
|
3453
4007
|
cacheHitCount++;
|
|
@@ -3484,7 +4038,7 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
3484
4038
|
uncachedItems.push(item);
|
|
3485
4039
|
// Store fingerprint for write-through caching after SQL
|
|
3486
4040
|
const fp = cacheAvailable
|
|
3487
|
-
? cache.GenerateRunViewFingerprint({ EntityName: entityName, ExtraFilter: effectiveFilter }, this.InstanceConnectionString)
|
|
4041
|
+
? cache.GenerateRunViewFingerprint({ EntityName: entityName, ExtraFilter: effectiveFilter }, this.InstanceConnectionString, undefined, this.datasetCacheSegment(datasetName, code))
|
|
3488
4042
|
: '';
|
|
3489
4043
|
uncachedFingerprints.push(fp);
|
|
3490
4044
|
}
|
|
@@ -3522,7 +4076,18 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
3522
4076
|
? this.extractMaxUpdatedAtFromRows(itemData, dateFieldToCheck)
|
|
3523
4077
|
: new Date(0).toISOString();
|
|
3524
4078
|
const syntheticParams = { EntityName: entityName };
|
|
3525
|
-
|
|
4079
|
+
// ProviderInternalScaffolding — for the MJ_Metadata dataset ONLY. Those rows are
|
|
4080
|
+
// consumed by this provider's own assembly steps, which mutate them in place by
|
|
4081
|
+
// design: PostProcessEntityMetadata sorts the entity array and attaches child
|
|
4082
|
+
// collections onto each entity/field row, and GetAllMetadata's Applications
|
|
4083
|
+
// assembly writes ApplicationEntities/ApplicationSettings onto Application rows.
|
|
4084
|
+
// Freezing them makes metadata bootstrap throw and the process starts blind.
|
|
4085
|
+
//
|
|
4086
|
+
// Every OTHER dataset is served to arbitrary consumers (BaseEngine.Load hands
|
|
4087
|
+
// item.Results — these very arrays — to every engine subclass), so those slots
|
|
4088
|
+
// must stay under the defensive deep-freeze like any RunView result.
|
|
4089
|
+
const isMetadataScaffolding = datasetName === ProviderBase._mjMetadataDatasetName;
|
|
4090
|
+
await cache.SetRunViewResult(uncachedFingerprints[i], syntheticParams, itemData, maxUpdatedAt, undefined, undefined, this, undefined, isMetadataScaffolding ? { ProviderInternalScaffolding: true } : undefined);
|
|
3526
4091
|
}
|
|
3527
4092
|
sqlResults.push({
|
|
3528
4093
|
EntityID: entityID,
|
|
@@ -3630,7 +4195,7 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
3630
4195
|
const datasetMaxUpdatedAt = new Date(Math.max(itemUpdatedAt.getTime(), datasetUpdatedAt.getTime()));
|
|
3631
4196
|
// Try to derive status from cached data
|
|
3632
4197
|
if (cacheAvailable) {
|
|
3633
|
-
const fingerprint = cache.GenerateRunViewFingerprint({ EntityName: entityName, ExtraFilter: effectiveFilter }, this.InstanceConnectionString);
|
|
4198
|
+
const fingerprint = cache.GenerateRunViewFingerprint({ EntityName: entityName, ExtraFilter: effectiveFilter }, this.InstanceConnectionString, undefined, this.datasetCacheSegment(datasetName, code));
|
|
3634
4199
|
const cached = await cache.GetRunViewResult(fingerprint);
|
|
3635
4200
|
if (cached) {
|
|
3636
4201
|
cacheHitCount++;
|
|
@@ -3770,10 +4335,29 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
3770
4335
|
/**************************************************************************/
|
|
3771
4336
|
// Dataset Cache Helpers
|
|
3772
4337
|
/**************************************************************************/
|
|
4338
|
+
/**
|
|
4339
|
+
* Key namespace for a dataset item's cached rows.
|
|
4340
|
+
*
|
|
4341
|
+
* Dataset items are cached through the same fingerprint builder ordinary RunViews use, with
|
|
4342
|
+
* only `{ EntityName, ExtraFilter }` — and every shipped item has a NULL `WhereClause`, so
|
|
4343
|
+
* without this segment a dataset item and a plain unfiltered read of the same entity produce
|
|
4344
|
+
* an IDENTICAL key and share one slot. That leaks the `MJ_Metadata` scaffolding exemption
|
|
4345
|
+
* (deliberately unfrozen rows) to ordinary callers of `MJ: Entities` / `MJ: Entity Fields`,
|
|
4346
|
+
* and lets an ordinary read repopulate an evicted slot FROZEN, which then breaks the next
|
|
4347
|
+
* metadata refresh.
|
|
4348
|
+
*
|
|
4349
|
+
* Keyed by dataset + item code so two items over the same entity also stay distinct.
|
|
4350
|
+
* Callers must use this on the read, the write-through, and the status paths alike — the
|
|
4351
|
+
* three must agree or dataset reads stop finding dataset writes.
|
|
4352
|
+
*/
|
|
4353
|
+
datasetCacheSegment(datasetName, itemCode) {
|
|
4354
|
+
return `${datasetName}/${itemCode}`;
|
|
4355
|
+
}
|
|
3773
4356
|
/**
|
|
3774
4357
|
* Computes the latest update date for a dataset item from its result rows and dataset metadata.
|
|
3775
4358
|
* Used by both the cache-hit and cache-miss paths in GetDatasetByName.
|
|
3776
|
-
* @param rows - The result rows (from cache or SQL)
|
|
4359
|
+
* @param rows - The result rows (from cache or SQL). `readonly` because cache-hit callers
|
|
4360
|
+
* pass the cache's shared, frozen rows; this method only scans them.
|
|
3777
4361
|
* @param dateFieldToCheck - The field name to scan for latest date
|
|
3778
4362
|
* @param item - The dataset item metadata row (contains DatasetItemUpdatedAt, DatasetUpdatedAt)
|
|
3779
4363
|
* @returns The latest date across all rows and dataset metadata
|