@memberjunction/generic-database-provider 5.8.0 → 5.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* @module @memberjunction/generic-database-provider
|
|
17
17
|
*/
|
|
18
|
-
import { DatabaseProviderBase, EntityFieldTSType, EntityPermissionType, Metadata, QueryInfo, LogError, LogStatus, StripStopWords, } from '@memberjunction/core';
|
|
18
|
+
import { DatabaseProviderBase, EntityFieldTSType, EntityPermissionType, InMemoryLocalStorageProvider, Metadata, QueryInfo, LocalCacheManager, LogError, LogStatus, LogStatusEx, StripStopWords, } from '@memberjunction/core';
|
|
19
19
|
import { MJGlobal, UUIDsEqual } from '@memberjunction/global';
|
|
20
20
|
import { v4 as uuidv4 } from 'uuid';
|
|
21
21
|
import { SqlLoggingSessionImpl } from './SqlLogger.js';
|
|
@@ -32,6 +32,44 @@ import { EncryptionEngine } from '@memberjunction/encryption';
|
|
|
32
32
|
* to inherit these shared behaviors.
|
|
33
33
|
*/
|
|
34
34
|
export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
35
|
+
/**
|
|
36
|
+
* Returns the active local storage provider, lazily creating an
|
|
37
|
+
* {@link InMemoryLocalStorageProvider} if none has been set.
|
|
38
|
+
*
|
|
39
|
+
* This fulfills the abstract `LocalStorageProvider` requirement from
|
|
40
|
+
* {@link ProviderBase} and is shared by all database providers
|
|
41
|
+
* (SQL Server, PostgreSQL, and any future platforms).
|
|
42
|
+
*/
|
|
43
|
+
get LocalStorageProvider() {
|
|
44
|
+
if (!this._localStorageProvider) {
|
|
45
|
+
this._localStorageProvider = new InMemoryLocalStorageProvider();
|
|
46
|
+
}
|
|
47
|
+
return this._localStorageProvider;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Replaces the active local storage provider at runtime.
|
|
51
|
+
*
|
|
52
|
+
* Use this to swap from the default in-memory provider to a Redis-backed
|
|
53
|
+
* provider (or any other {@link ILocalStorageProvider}) after reading
|
|
54
|
+
* application configuration.
|
|
55
|
+
*
|
|
56
|
+
* @param provider - The new storage provider to use for all caching operations.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```typescript
|
|
60
|
+
* import { RedisLocalStorageProvider } from '@memberjunction/redis-provider';
|
|
61
|
+
*
|
|
62
|
+
* // During server startup, after config is loaded:
|
|
63
|
+
* const redis = new RedisLocalStorageProvider({
|
|
64
|
+
* url: process.env.REDIS_URL,
|
|
65
|
+
* defaultTTLSeconds: 300
|
|
66
|
+
* });
|
|
67
|
+
* (Metadata.Provider as GenericDatabaseProvider).SetLocalStorageProvider(redis);
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
SetLocalStorageProvider(provider) {
|
|
71
|
+
this._localStorageProvider = provider;
|
|
72
|
+
}
|
|
35
73
|
/**************************************************************************/
|
|
36
74
|
// SQL Logging — Session Management & Statement Logging
|
|
37
75
|
/**************************************************************************/
|
|
@@ -948,9 +986,9 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
948
986
|
if (!user) {
|
|
949
987
|
return { success: false, results: [], errorMessage: 'No user context available' };
|
|
950
988
|
}
|
|
951
|
-
// Separate items
|
|
952
|
-
const itemsNeedingCacheCheck = [];
|
|
989
|
+
// Separate items by type: no cache check, needs validation
|
|
953
990
|
const itemsWithoutCacheCheck = [];
|
|
991
|
+
const itemsNeedingValidation = [];
|
|
954
992
|
const errorResults = [];
|
|
955
993
|
for (let i = 0; i < params.length; i++) {
|
|
956
994
|
const item = params[i];
|
|
@@ -965,49 +1003,110 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
965
1003
|
}
|
|
966
1004
|
try {
|
|
967
1005
|
this.CheckUserReadPermissions(entityInfo.Name, user);
|
|
968
|
-
|
|
969
|
-
itemsNeedingCacheCheck.push({ index: i, item, entityInfo, whereSQL });
|
|
1006
|
+
itemsNeedingValidation.push({ index: i, item, entityInfo });
|
|
970
1007
|
}
|
|
971
1008
|
catch (e) {
|
|
972
1009
|
errorResults.push({ viewIndex: i, status: 'error', errorMessage: e instanceof Error ? e.message : String(e) });
|
|
973
1010
|
}
|
|
974
1011
|
}
|
|
975
|
-
//
|
|
976
|
-
const cacheStatusResults = await this.getBatchedServerCacheStatus(itemsNeedingCacheCheck, contextUser);
|
|
977
|
-
// Determine which items are current vs stale, and whether they support differential updates
|
|
978
|
-
const differentialItems = [];
|
|
979
|
-
const staleItemsNoTracking = [];
|
|
1012
|
+
// Phase 1: Check server's LocalCacheManager first (zero DB hits)
|
|
980
1013
|
const currentResults = [];
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
1014
|
+
const serverCacheStaleItems = [];
|
|
1015
|
+
const serverCacheMissItems = [];
|
|
1016
|
+
for (const { index, item, entityInfo } of itemsNeedingValidation) {
|
|
1017
|
+
const entityLabel = item.params.EntityName || 'unknown';
|
|
1018
|
+
const resolved = await this.resolveFromServerCache(item, index, entityLabel);
|
|
1019
|
+
if (resolved) {
|
|
1020
|
+
if (resolved.status === 'current') {
|
|
1021
|
+
currentResults.push(resolved.result);
|
|
1022
|
+
}
|
|
1023
|
+
else {
|
|
1024
|
+
serverCacheStaleItems.push({ index, item, entityInfo, serverCached: resolved.serverCached });
|
|
1025
|
+
}
|
|
986
1026
|
}
|
|
987
|
-
|
|
988
|
-
|
|
1027
|
+
else {
|
|
1028
|
+
serverCacheMissItems.push({ index, item, entityInfo });
|
|
989
1029
|
}
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1030
|
+
}
|
|
1031
|
+
// Phase 2: For server cache misses, fall back to DB validation
|
|
1032
|
+
const differentialItems = [];
|
|
1033
|
+
const staleItemsNoTracking = [];
|
|
1034
|
+
if (serverCacheMissItems.length > 0) {
|
|
1035
|
+
// Build WHERE clauses and run batched DB status check only for cache misses
|
|
1036
|
+
const itemsForDBCheck = [];
|
|
1037
|
+
for (const { index, item, entityInfo } of serverCacheMissItems) {
|
|
1038
|
+
try {
|
|
1039
|
+
const whereSQL = await this.buildWhereClauseForCacheCheck(item.params, entityInfo, user);
|
|
1040
|
+
itemsForDBCheck.push({ index, item, entityInfo, whereSQL });
|
|
1041
|
+
}
|
|
1042
|
+
catch (e) {
|
|
1043
|
+
errorResults.push({ viewIndex: index, status: 'error', errorMessage: e instanceof Error ? e.message : String(e) });
|
|
1044
|
+
}
|
|
997
1045
|
}
|
|
998
|
-
|
|
999
|
-
|
|
1046
|
+
const cacheStatusResults = await this.getBatchedServerCacheStatus(itemsForDBCheck, contextUser);
|
|
1047
|
+
for (const { index, item, entityInfo, whereSQL } of itemsForDBCheck) {
|
|
1048
|
+
const serverStatus = cacheStatusResults.get(index);
|
|
1049
|
+
if (!serverStatus || !serverStatus.success) {
|
|
1050
|
+
errorResults.push({ viewIndex: index, status: 'error', errorMessage: serverStatus?.errorMessage || 'Failed to get cache status' });
|
|
1051
|
+
continue;
|
|
1052
|
+
}
|
|
1053
|
+
const entityLabel = item.params.EntityName || 'unknown';
|
|
1054
|
+
if (this.isCacheCurrent(item.cacheStatus, serverStatus)) {
|
|
1055
|
+
LogStatusEx({ message: ` ✅ [SmartCache CURRENT] "${entityLabel}" — client cache matches DB (server cache miss)`, verboseOnly: true });
|
|
1056
|
+
currentResults.push({ viewIndex: index, status: 'current' });
|
|
1057
|
+
}
|
|
1058
|
+
else if (entityInfo.TrackRecordChanges) {
|
|
1059
|
+
LogStatusEx({ message: ` 🔄 [SmartCache DIFFERENTIAL] "${entityLabel}" — sending only changed rows (from DB)`, verboseOnly: true });
|
|
1060
|
+
differentialItems.push({
|
|
1061
|
+
index, params: item.params, entityInfo, whereSQL,
|
|
1062
|
+
clientMaxUpdatedAt: item.cacheStatus.maxUpdatedAt,
|
|
1063
|
+
clientRowCount: item.cacheStatus.rowCount,
|
|
1064
|
+
serverStatus,
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
else {
|
|
1068
|
+
LogStatusEx({ message: ` 🔍 [SmartCache STALE] "${entityLabel}" — full refresh from DB (no change tracking)`, verboseOnly: true });
|
|
1069
|
+
staleItemsNoTracking.push({ index, params: item.params });
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
// Phase 3: For items without cacheStatus (client has nothing), check server cache before hitting DB
|
|
1074
|
+
const noCacheStatusServedFromCache = [];
|
|
1075
|
+
const noCacheStatusNeedsDB = [];
|
|
1076
|
+
for (const entry of itemsWithoutCacheCheck) {
|
|
1077
|
+
if (LocalCacheManager.Instance.IsInitialized) {
|
|
1078
|
+
const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(entry.item.params, this.InstanceConnectionString);
|
|
1079
|
+
const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
|
|
1080
|
+
if (cached) {
|
|
1081
|
+
const entityLabel = entry.item.params.EntityName || 'unknown';
|
|
1082
|
+
LogStatusEx({ message: ` 📦 [SmartCache SERVE-FROM-CACHE] "${entityLabel}" — client has no cache, serving ${cached.rowCount} rows from server cache, no DB hit`, verboseOnly: true });
|
|
1083
|
+
noCacheStatusServedFromCache.push({ index: entry.index, serverCached: cached });
|
|
1084
|
+
continue;
|
|
1085
|
+
}
|
|
1000
1086
|
}
|
|
1087
|
+
noCacheStatusNeedsDB.push(entry);
|
|
1001
1088
|
}
|
|
1002
|
-
// Run queries in parallel
|
|
1089
|
+
// Phase 4: Run queries in parallel for items needing data
|
|
1003
1090
|
const queryPromises = [
|
|
1004
|
-
|
|
1005
|
-
...
|
|
1091
|
+
// Items without cache check — served from server cache
|
|
1092
|
+
...noCacheStatusServedFromCache.map(({ index, serverCached }) => this.serveFromServerCache(index, serverCached)),
|
|
1093
|
+
// Items without cache check — server cache miss, must hit DB
|
|
1094
|
+
...noCacheStatusNeedsDB.map(({ index, item }) => this.runFullQueryAndCacheResult(item.params, index, contextUser)),
|
|
1095
|
+
// Server cache stale — serve from server's cached data (zero DB)
|
|
1096
|
+
...serverCacheStaleItems.map(({ index, serverCached }) => this.serveFromServerCache(index, serverCached)),
|
|
1097
|
+
// DB-validated stale items (no change tracking)
|
|
1098
|
+
...staleItemsNoTracking.map(({ index, params: viewParams }) => this.runFullQueryAndCacheResult(viewParams, index, contextUser)),
|
|
1099
|
+
// DB-validated differential items
|
|
1006
1100
|
...differentialItems.map(({ index, params: viewParams, entityInfo, whereSQL, clientMaxUpdatedAt, clientRowCount, serverStatus }) => this.runDifferentialQueryAndReturn(viewParams, entityInfo, clientMaxUpdatedAt, clientRowCount, serverStatus, whereSQL, index, contextUser)),
|
|
1007
1101
|
];
|
|
1008
1102
|
const fullQueryResults = await Promise.all(queryPromises);
|
|
1009
1103
|
const allResults = [...errorResults, ...currentResults, ...fullQueryResults];
|
|
1010
1104
|
allResults.sort((a, b) => a.viewIndex - b.viewIndex);
|
|
1105
|
+
const entities = params.map(p => p.params.EntityName || 'unknown').join(', ');
|
|
1106
|
+
const totalServerCacheHits = (itemsNeedingValidation.length - serverCacheMissItems.length) + noCacheStatusServedFromCache.length;
|
|
1107
|
+
const totalChecked = itemsNeedingValidation.length + itemsWithoutCacheCheck.length;
|
|
1108
|
+
const totalDBQueries = noCacheStatusNeedsDB.length + staleItemsNoTracking.length + differentialItems.length;
|
|
1109
|
+
LogStatusEx({ message: ` 📊 [SmartCache] Batch [${entities}] — ${currentResults.length} current, ${serverCacheStaleItems.length + noCacheStatusServedFromCache.length} served-from-cache, ${differentialItems.length} differential, ${totalDBQueries} full-query, ${errorResults.length} errors (server cache: ${totalServerCacheHits}/${totalChecked} hits)`, verboseOnly: true });
|
|
1011
1110
|
return { success: true, results: allResults };
|
|
1012
1111
|
}
|
|
1013
1112
|
catch (e) {
|
|
@@ -1090,6 +1189,53 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1090
1189
|
const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
|
|
1091
1190
|
return { viewIndex, status: 'stale', results: result.Results, maxUpdatedAt, rowCount: result.Results.length };
|
|
1092
1191
|
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Runs a full query and stores the result in the server's LocalCacheManager.
|
|
1194
|
+
* Used by RunViewsWithCacheCheck to populate the server cache for future requests.
|
|
1195
|
+
*/
|
|
1196
|
+
async runFullQueryAndCacheResult(params, viewIndex, contextUser) {
|
|
1197
|
+
const result = await this.runFullQueryAndReturn(params, viewIndex, contextUser);
|
|
1198
|
+
// Cache the result so subsequent RunViewsWithCacheCheck calls can skip DB
|
|
1199
|
+
if (result.status !== 'error' && result.results && LocalCacheManager.Instance.IsInitialized) {
|
|
1200
|
+
const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString);
|
|
1201
|
+
const maxUpdatedAt = result.maxUpdatedAt || new Date().toISOString();
|
|
1202
|
+
await LocalCacheManager.Instance.SetRunViewResult(fingerprint, params, result.results, maxUpdatedAt);
|
|
1203
|
+
}
|
|
1204
|
+
return result;
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Checks the server's LocalCacheManager for cached data matching the client's request.
|
|
1208
|
+
* Returns the resolution if found (either 'current' or server-cached data to serve),
|
|
1209
|
+
* or null if the server cache doesn't have this data.
|
|
1210
|
+
*/
|
|
1211
|
+
async resolveFromServerCache(item, index, entityLabel) {
|
|
1212
|
+
if (!LocalCacheManager.Instance.IsInitialized)
|
|
1213
|
+
return null;
|
|
1214
|
+
const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(item.params, this.InstanceConnectionString);
|
|
1215
|
+
const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
|
|
1216
|
+
if (!cached)
|
|
1217
|
+
return null;
|
|
1218
|
+
const serverStatus = { maxUpdatedAt: cached.maxUpdatedAt, rowCount: cached.rowCount };
|
|
1219
|
+
if (this.isCacheCurrent(item.cacheStatus, serverStatus)) {
|
|
1220
|
+
LogStatusEx({ message: ` ✅ [SmartCache CURRENT] "${entityLabel}" — client cache matches server cache, no DB hit`, verboseOnly: true });
|
|
1221
|
+
return { status: 'current', result: { viewIndex: index, status: 'current' } };
|
|
1222
|
+
}
|
|
1223
|
+
// Server has newer data than client — we can serve it directly from cache
|
|
1224
|
+
LogStatusEx({ message: ` 📦 [SmartCache SERVE-FROM-CACHE] "${entityLabel}" — serving ${cached.rowCount} rows from server cache, no DB hit`, verboseOnly: true });
|
|
1225
|
+
return { status: 'stale', serverCached: cached };
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* Packages server-cached data as a RunViewWithCacheCheckResult for return to the client.
|
|
1229
|
+
*/
|
|
1230
|
+
async serveFromServerCache(viewIndex, serverCached) {
|
|
1231
|
+
return {
|
|
1232
|
+
viewIndex,
|
|
1233
|
+
status: 'stale',
|
|
1234
|
+
results: serverCached.results,
|
|
1235
|
+
maxUpdatedAt: serverCached.maxUpdatedAt,
|
|
1236
|
+
rowCount: serverCached.rowCount,
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1093
1239
|
/**
|
|
1094
1240
|
* Runs a differential query and returns only changes since the client's cached state.
|
|
1095
1241
|
* Includes updated/created rows and deleted record IDs.
|
|
@@ -1312,6 +1458,25 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1312
1458
|
}
|
|
1313
1459
|
return null;
|
|
1314
1460
|
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Validates that a query can be executed by the given user. Checks both permissions
|
|
1463
|
+
* and approval status. Permission failures throw an error. Non-approved status
|
|
1464
|
+
* emits a console warning but allows execution to proceed, enabling query testing
|
|
1465
|
+
* before formal approval.
|
|
1466
|
+
*
|
|
1467
|
+
* @param query - The resolved QueryInfo to validate
|
|
1468
|
+
* @param contextUser - The user attempting to execute the query
|
|
1469
|
+
* @throws Error if the user does not have permission to run the query
|
|
1470
|
+
*/
|
|
1471
|
+
ValidateQueryForExecution(query, contextUser) {
|
|
1472
|
+
const user = contextUser || this.CurrentUser;
|
|
1473
|
+
if (user && !query.UserHasRunPermissions(user)) {
|
|
1474
|
+
throw new Error(`User does not have permission to run query '${query.Name}' (ID: ${query.ID})`);
|
|
1475
|
+
}
|
|
1476
|
+
if (query.Status !== 'Approved') {
|
|
1477
|
+
LogStatus(`WARNING: Executing query '${query.Name}' (ID: ${query.ID}) with status '${query.Status}'. Query has not been approved.`);
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1315
1480
|
/**
|
|
1316
1481
|
* Creates a fresh QueryInfo from a MJQueryEntity and patches the ProviderBase cache.
|
|
1317
1482
|
*/
|
|
@@ -1414,7 +1579,15 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1414
1579
|
const quotes = pk.NeedsQuotes ? "'" : '';
|
|
1415
1580
|
return `${this.QuoteIdentifier(pk.CodeName)}=${quotes}${val.Value}${quotes}`;
|
|
1416
1581
|
}).join(' AND ');
|
|
1417
|
-
|
|
1582
|
+
// Append Read RLS filter if user is not exempt
|
|
1583
|
+
let fullWhere = where;
|
|
1584
|
+
if (user && !entityInfo.UserExemptFromRowLevelSecurity(user, EntityPermissionType.Read)) {
|
|
1585
|
+
const rlsWhereClause = entityInfo.GetUserRowLevelSecurityWhereClause(user, EntityPermissionType.Read, '');
|
|
1586
|
+
if (rlsWhereClause && rlsWhereClause.length > 0) {
|
|
1587
|
+
fullWhere = `${where} AND (${rlsWhereClause})`;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
const sql = `SELECT * FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.BaseView)} WHERE ${fullWhere}`;
|
|
1418
1591
|
const rawData = await this.ExecuteSQL(sql, undefined, undefined, user);
|
|
1419
1592
|
const d = await this.PostProcessRows(rawData, entityInfo, user);
|
|
1420
1593
|
if (d && d.length > 0) {
|
|
@@ -1459,6 +1632,76 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1459
1632
|
return null;
|
|
1460
1633
|
}
|
|
1461
1634
|
/**************************************************************************/
|
|
1635
|
+
// Row-Level Security Checks
|
|
1636
|
+
/**************************************************************************/
|
|
1637
|
+
/**
|
|
1638
|
+
* Checks whether an existing record passes the RLS filter for a given permission type.
|
|
1639
|
+
* Executes: SELECT COUNT(*) AS cnt FROM view WHERE PK=value AND (RLS filter)
|
|
1640
|
+
* Returns true if the record matches (cnt > 0), false otherwise.
|
|
1641
|
+
*/
|
|
1642
|
+
async CheckRecordRLS(entity, user, type) {
|
|
1643
|
+
const entityInfo = entity.EntityInfo;
|
|
1644
|
+
if (entityInfo.UserExemptFromRowLevelSecurity(user, type)) {
|
|
1645
|
+
return true;
|
|
1646
|
+
}
|
|
1647
|
+
const rlsWhereClause = entityInfo.GetUserRowLevelSecurityWhereClause(user, type, '');
|
|
1648
|
+
if (!rlsWhereClause || rlsWhereClause.length === 0) {
|
|
1649
|
+
return true;
|
|
1650
|
+
}
|
|
1651
|
+
const pkWhere = entity.PrimaryKeys.map(pk => {
|
|
1652
|
+
const fieldInfo = entityInfo.Fields.find(f => f.Name === pk.Name);
|
|
1653
|
+
const quotes = fieldInfo?.NeedsQuotes ? "'" : '';
|
|
1654
|
+
return `${this.QuoteIdentifier(pk.Name)}=${quotes}${pk.Value}${quotes}`;
|
|
1655
|
+
}).join(' AND ');
|
|
1656
|
+
const sql = `SELECT COUNT(*) AS cnt FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.BaseView)} WHERE ${pkWhere} AND (${rlsWhereClause})`;
|
|
1657
|
+
const result = await this.ExecuteSQL(sql, undefined, undefined, user);
|
|
1658
|
+
return result && result.length > 0 && Number(result[0]['cnt']) > 0;
|
|
1659
|
+
}
|
|
1660
|
+
/**
|
|
1661
|
+
* Checks whether a new record's field values pass the Create RLS filter.
|
|
1662
|
+
* Builds a synthetic single-row subquery from entity field values, then tests the RLS filter against it.
|
|
1663
|
+
*/
|
|
1664
|
+
async CheckCreateRLS(entity, user) {
|
|
1665
|
+
const entityInfo = entity.EntityInfo;
|
|
1666
|
+
if (entityInfo.UserExemptFromRowLevelSecurity(user, EntityPermissionType.Create)) {
|
|
1667
|
+
return true;
|
|
1668
|
+
}
|
|
1669
|
+
const rlsWhereClause = entityInfo.GetUserRowLevelSecurityWhereClause(user, EntityPermissionType.Create, '');
|
|
1670
|
+
if (!rlsWhereClause || rlsWhereClause.length === 0) {
|
|
1671
|
+
return true;
|
|
1672
|
+
}
|
|
1673
|
+
const projections = this.BuildCreateRLSProjections(entity, entityInfo);
|
|
1674
|
+
const sql = `SELECT CASE WHEN (${rlsWhereClause}) THEN 1 ELSE 0 END AS pass FROM (SELECT ${projections}) AS newrow`;
|
|
1675
|
+
const result = await this.ExecuteSQL(sql, undefined, undefined, user);
|
|
1676
|
+
return result && result.length > 0 && Number(result[0]['pass']) === 1;
|
|
1677
|
+
}
|
|
1678
|
+
/**
|
|
1679
|
+
* Builds field projections for the Create RLS synthetic row subquery.
|
|
1680
|
+
* Only includes non-virtual fields that have non-null values.
|
|
1681
|
+
*/
|
|
1682
|
+
BuildCreateRLSProjections(entity, entityInfo) {
|
|
1683
|
+
const parts = [];
|
|
1684
|
+
for (const field of entityInfo.Fields) {
|
|
1685
|
+
if (field.IsVirtual)
|
|
1686
|
+
continue;
|
|
1687
|
+
const val = entity.Get(field.Name);
|
|
1688
|
+
if (val == null)
|
|
1689
|
+
continue;
|
|
1690
|
+
let sqlVal;
|
|
1691
|
+
if (typeof val === 'boolean') {
|
|
1692
|
+
sqlVal = val ? '1' : '0';
|
|
1693
|
+
}
|
|
1694
|
+
else if (field.NeedsQuotes) {
|
|
1695
|
+
sqlVal = `'${String(val).replace(/'/g, "''")}'`;
|
|
1696
|
+
}
|
|
1697
|
+
else {
|
|
1698
|
+
sqlVal = String(val);
|
|
1699
|
+
}
|
|
1700
|
+
parts.push(`${sqlVal} AS ${this.QuoteIdentifier(field.Name)}`);
|
|
1701
|
+
}
|
|
1702
|
+
return parts.join(', ');
|
|
1703
|
+
}
|
|
1704
|
+
/**************************************************************************/
|
|
1462
1705
|
// GetDatasetByName — Shared Implementation
|
|
1463
1706
|
/**************************************************************************/
|
|
1464
1707
|
/**
|
|
@@ -1499,10 +1742,18 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1499
1742
|
Results: [],
|
|
1500
1743
|
};
|
|
1501
1744
|
}
|
|
1502
|
-
// Phase 1:
|
|
1503
|
-
const
|
|
1504
|
-
const
|
|
1745
|
+
// Phase 1: Check LocalCacheManager for each item, build SQL only for cache misses
|
|
1746
|
+
const overallStart = performance.now();
|
|
1747
|
+
const cache = LocalCacheManager.Instance;
|
|
1748
|
+
const cacheAvailable = cache.IsInitialized && this.TrustLocalCacheCompletely;
|
|
1505
1749
|
const errorResults = [];
|
|
1750
|
+
const cachedResults = [];
|
|
1751
|
+
const uncachedQueries = [];
|
|
1752
|
+
const uncachedItems = [];
|
|
1753
|
+
// Track fingerprints for uncached items so we can write-through after SQL
|
|
1754
|
+
const uncachedFingerprints = [];
|
|
1755
|
+
let cacheHitCount = 0;
|
|
1756
|
+
let cacheMissCount = 0;
|
|
1506
1757
|
for (const item of items) {
|
|
1507
1758
|
const entitySchemaName = String(item['EntitySchemaName'] ?? schema);
|
|
1508
1759
|
const entityBaseView = String(item['EntityBaseView']);
|
|
@@ -1510,12 +1761,37 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1510
1761
|
const entityName = String(item['Entity']);
|
|
1511
1762
|
const entityID = String(item['EntityID']);
|
|
1512
1763
|
const whereClause = item['WhereClause'] ? String(item['WhereClause']) : '';
|
|
1513
|
-
|
|
1764
|
+
// Build effective filter (WhereClause + optional runtime ItemFilter)
|
|
1765
|
+
let effectiveFilter = whereClause;
|
|
1514
1766
|
if (itemFilters && itemFilters.length > 0) {
|
|
1515
1767
|
const filter = itemFilters.find(f => f.ItemCode === code);
|
|
1516
|
-
if (filter)
|
|
1517
|
-
|
|
1768
|
+
if (filter) {
|
|
1769
|
+
effectiveFilter = whereClause
|
|
1770
|
+
? `${whereClause} AND (${filter.Filter})`
|
|
1771
|
+
: filter.Filter;
|
|
1772
|
+
}
|
|
1518
1773
|
}
|
|
1774
|
+
// Try cache first
|
|
1775
|
+
if (cacheAvailable) {
|
|
1776
|
+
const fingerprint = cache.GenerateRunViewFingerprint({ EntityName: entityName, ExtraFilter: effectiveFilter }, this.InstanceConnectionString);
|
|
1777
|
+
const cached = await cache.GetRunViewResult(fingerprint);
|
|
1778
|
+
if (cached) {
|
|
1779
|
+
cacheHitCount++;
|
|
1780
|
+
const dateFieldToCheck = String(item['DateFieldToCheck'] ?? '__mj_UpdatedAt');
|
|
1781
|
+
const latestUpdateDate = this.computeLatestUpdateDate(cached.results, dateFieldToCheck, item);
|
|
1782
|
+
cachedResults.push({
|
|
1783
|
+
EntityID: entityID,
|
|
1784
|
+
EntityName: entityName,
|
|
1785
|
+
Code: code,
|
|
1786
|
+
Results: cached.results,
|
|
1787
|
+
LatestUpdateDate: latestUpdateDate,
|
|
1788
|
+
Success: true,
|
|
1789
|
+
});
|
|
1790
|
+
continue; // Skip SQL for this item
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
// Cache miss — validate columns and build SQL
|
|
1794
|
+
cacheMissCount++;
|
|
1519
1795
|
const columns = provider.getColumnsForDatasetItem(item, datasetName);
|
|
1520
1796
|
if (!columns) {
|
|
1521
1797
|
errorResults.push({
|
|
@@ -1529,22 +1805,30 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1529
1805
|
});
|
|
1530
1806
|
continue;
|
|
1531
1807
|
}
|
|
1532
|
-
|
|
1533
|
-
|
|
1808
|
+
const filterSQL = effectiveFilter ? 'WHERE ' + effectiveFilter : '';
|
|
1809
|
+
uncachedQueries.push(`SELECT ${columns} FROM ${provider.QuoteSchemaAndView(entitySchemaName, entityBaseView)} ${filterSQL}`);
|
|
1810
|
+
uncachedItems.push(item);
|
|
1811
|
+
// Store fingerprint for write-through caching after SQL
|
|
1812
|
+
const fp = cacheAvailable
|
|
1813
|
+
? cache.GenerateRunViewFingerprint({ EntityName: entityName, ExtraFilter: effectiveFilter }, this.InstanceConnectionString)
|
|
1814
|
+
: '';
|
|
1815
|
+
uncachedFingerprints.push(fp);
|
|
1534
1816
|
}
|
|
1535
|
-
// Phase 2: Execute
|
|
1817
|
+
// Phase 2: Execute SQL only for cache misses
|
|
1536
1818
|
let batchResults = [];
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1819
|
+
if (uncachedQueries.length > 0) {
|
|
1820
|
+
try {
|
|
1821
|
+
batchResults = await provider.ExecuteSQLBatch(uncachedQueries, undefined, undefined, contextUser);
|
|
1822
|
+
}
|
|
1823
|
+
catch (err) {
|
|
1824
|
+
LogError(`GetDatasetByName: Batch execution failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1825
|
+
// Fall through with empty results
|
|
1826
|
+
}
|
|
1543
1827
|
}
|
|
1544
|
-
// Phase 3: Process results
|
|
1545
|
-
const
|
|
1546
|
-
for (let i = 0; i <
|
|
1547
|
-
const item =
|
|
1828
|
+
// Phase 3: Process SQL results and write-through to cache
|
|
1829
|
+
const sqlResults = [];
|
|
1830
|
+
for (let i = 0; i < uncachedItems.length; i++) {
|
|
1831
|
+
const item = uncachedItems[i];
|
|
1548
1832
|
const entityName = String(item['Entity']);
|
|
1549
1833
|
const entityID = String(item['EntityID']);
|
|
1550
1834
|
const code = String(item['Code']);
|
|
@@ -1557,20 +1841,16 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1557
1841
|
itemData = await provider.PostProcessRows(itemData, entityInfo, contextUser);
|
|
1558
1842
|
}
|
|
1559
1843
|
}
|
|
1560
|
-
const
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
}
|
|
1569
|
-
}
|
|
1844
|
+
const latestUpdateDate = this.computeLatestUpdateDate(itemData, dateFieldToCheck, item);
|
|
1845
|
+
// Write-through: cache this result for future requests (including empty results)
|
|
1846
|
+
if (cacheAvailable && uncachedFingerprints[i]) {
|
|
1847
|
+
const maxUpdatedAt = itemData.length > 0
|
|
1848
|
+
? this.extractMaxUpdatedAtFromRows(itemData, dateFieldToCheck)
|
|
1849
|
+
: new Date(0).toISOString();
|
|
1850
|
+
const syntheticParams = { EntityName: entityName };
|
|
1851
|
+
await cache.SetRunViewResult(uncachedFingerprints[i], syntheticParams, itemData, maxUpdatedAt);
|
|
1570
1852
|
}
|
|
1571
|
-
|
|
1572
|
-
latestUpdateDate = datasetMaxUpdatedAt;
|
|
1573
|
-
results.push({
|
|
1853
|
+
sqlResults.push({
|
|
1574
1854
|
EntityID: entityID,
|
|
1575
1855
|
EntityName: entityName,
|
|
1576
1856
|
Code: code,
|
|
@@ -1579,6 +1859,21 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1579
1859
|
Success: itemData !== null && itemData !== undefined,
|
|
1580
1860
|
});
|
|
1581
1861
|
}
|
|
1862
|
+
// Merge results: errors + cached + SQL (maintain original item order via code matching)
|
|
1863
|
+
const results = [];
|
|
1864
|
+
for (const item of items) {
|
|
1865
|
+
const code = String(item['Code']);
|
|
1866
|
+
const found = errorResults.find(r => r.Code === code)
|
|
1867
|
+
?? cachedResults.find(r => r.Code === code)
|
|
1868
|
+
?? sqlResults.find(r => r.Code === code);
|
|
1869
|
+
if (found)
|
|
1870
|
+
results.push(found);
|
|
1871
|
+
}
|
|
1872
|
+
const elapsedMs = (performance.now() - overallStart).toFixed(1);
|
|
1873
|
+
LogStatusEx({
|
|
1874
|
+
message: `📊 [Dataset] GetDatasetByName("${datasetName}"): ${cacheHitCount} cache hits, ${cacheMissCount} cache misses, ${errorResults.length} errors — ${elapsedMs}ms`,
|
|
1875
|
+
verboseOnly: true
|
|
1876
|
+
});
|
|
1582
1877
|
// Aggregate results
|
|
1583
1878
|
const bSuccess = results.every(result => result.Success);
|
|
1584
1879
|
const latestUpdateDate = results.reduce((acc, result) => {
|
|
@@ -1606,9 +1901,12 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1606
1901
|
* latest update date. Uses ExecuteSQLBatch for per-item status queries.
|
|
1607
1902
|
*/
|
|
1608
1903
|
async GetDatasetStatusByName(datasetName, itemFilters, contextUser, providerToUse) {
|
|
1904
|
+
const overallStart = performance.now();
|
|
1609
1905
|
const provider = (providerToUse ?? this);
|
|
1610
1906
|
const schema = provider.MJCoreSchemaName;
|
|
1611
|
-
|
|
1907
|
+
const cache = LocalCacheManager.Instance;
|
|
1908
|
+
const cacheAvailable = cache.IsInitialized && this.TrustLocalCacheCompletely;
|
|
1909
|
+
// Fetch dataset items metadata (lightweight — just the dataset definition, not entity data)
|
|
1612
1910
|
const sSQL = `SELECT di.*, ` +
|
|
1613
1911
|
`e.${provider.QuoteIdentifier('BaseView')} AS ${provider.QuoteIdentifier('EntityBaseView')}, ` +
|
|
1614
1912
|
`e.${provider.QuoteIdentifier('SchemaName')} AS ${provider.QuoteIdentifier('EntitySchemaName')}, ` +
|
|
@@ -1629,61 +1927,117 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1629
1927
|
EntityUpdateDates: [],
|
|
1630
1928
|
};
|
|
1631
1929
|
}
|
|
1632
|
-
//
|
|
1633
|
-
const
|
|
1634
|
-
|
|
1930
|
+
// Phase 1: Try to derive status from cached data for each item
|
|
1931
|
+
const updateDates = [];
|
|
1932
|
+
let overallLatestDate = new Date(1900, 1, 1);
|
|
1933
|
+
let cacheHitCount = 0;
|
|
1934
|
+
let cacheMissCount = 0;
|
|
1935
|
+
// Collect items that need SQL fallback
|
|
1936
|
+
const uncachedItems = [];
|
|
1937
|
+
const uncachedItemMeta = [];
|
|
1635
1938
|
for (const item of items) {
|
|
1636
|
-
const entitySchemaName = String(item['EntitySchemaName'] ?? schema);
|
|
1637
|
-
const entityBaseView = String(item['EntityBaseView']);
|
|
1638
1939
|
const entityID = String(item['EntityID']);
|
|
1639
1940
|
const entityName = String(item['Entity']);
|
|
1941
|
+
const code = String(item['Code']);
|
|
1640
1942
|
const dateFieldToCheck = String(item['DateFieldToCheck'] ?? '__mj_UpdatedAt');
|
|
1641
|
-
|
|
1943
|
+
const whereClause = item['WhereClause'] ? String(item['WhereClause']) : '';
|
|
1944
|
+
// Build effective filter for fingerprint
|
|
1945
|
+
let effectiveFilter = whereClause;
|
|
1642
1946
|
if (itemFilters && itemFilters.length > 0) {
|
|
1643
|
-
const filter = itemFilters.find(f => f.ItemCode ===
|
|
1644
|
-
if (filter)
|
|
1645
|
-
|
|
1947
|
+
const filter = itemFilters.find(f => f.ItemCode === code);
|
|
1948
|
+
if (filter) {
|
|
1949
|
+
effectiveFilter = whereClause
|
|
1950
|
+
? `${whereClause} AND (${filter.Filter})`
|
|
1951
|
+
: filter.Filter;
|
|
1952
|
+
}
|
|
1646
1953
|
}
|
|
1647
1954
|
const itemUpdatedAt = new Date(String(item['DatasetItemUpdatedAt']));
|
|
1648
1955
|
const datasetUpdatedAt = new Date(String(item['DatasetUpdatedAt']));
|
|
1649
|
-
const datasetMaxUpdatedAt = new Date(Math.max(itemUpdatedAt.getTime(), datasetUpdatedAt.getTime()))
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1956
|
+
const datasetMaxUpdatedAt = new Date(Math.max(itemUpdatedAt.getTime(), datasetUpdatedAt.getTime()));
|
|
1957
|
+
// Try to derive status from cached data
|
|
1958
|
+
if (cacheAvailable) {
|
|
1959
|
+
const fingerprint = cache.GenerateRunViewFingerprint({ EntityName: entityName, ExtraFilter: effectiveFilter }, this.InstanceConnectionString);
|
|
1960
|
+
const cached = await cache.GetRunViewResult(fingerprint);
|
|
1961
|
+
if (cached) {
|
|
1962
|
+
cacheHitCount++;
|
|
1963
|
+
// Derive MAX(dateField) and COUNT(*) directly from cached rows
|
|
1964
|
+
let maxDateFromRows = new Date(1900, 1, 1);
|
|
1965
|
+
for (const row of cached.results) {
|
|
1966
|
+
const record = row;
|
|
1967
|
+
if (record[dateFieldToCheck]) {
|
|
1968
|
+
const d = new Date(String(record[dateFieldToCheck]));
|
|
1969
|
+
if (d > maxDateFromRows)
|
|
1970
|
+
maxDateFromRows = d;
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
const updateDate = maxDateFromRows > datasetMaxUpdatedAt ? maxDateFromRows : datasetMaxUpdatedAt;
|
|
1974
|
+
updateDates.push({
|
|
1975
|
+
EntityID: entityID,
|
|
1976
|
+
EntityName: entityName,
|
|
1977
|
+
RowCount: cached.results.length,
|
|
1978
|
+
UpdateDate: updateDate,
|
|
1979
|
+
});
|
|
1980
|
+
if (updateDate > overallLatestDate)
|
|
1981
|
+
overallLatestDate = updateDate;
|
|
1982
|
+
continue; // No SQL needed for this item
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
// Cache miss — need SQL fallback
|
|
1986
|
+
cacheMissCount++;
|
|
1987
|
+
uncachedItems.push(item);
|
|
1988
|
+
uncachedItemMeta.push({ entityID, entityName, datasetMaxUpdatedAt: datasetMaxUpdatedAt.toISOString() });
|
|
1667
1989
|
}
|
|
1668
|
-
//
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
const
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
}
|
|
1682
|
-
|
|
1683
|
-
|
|
1990
|
+
// Phase 2: Execute SQL only for cache misses
|
|
1991
|
+
if (uncachedItems.length > 0) {
|
|
1992
|
+
const queries = uncachedItems.map((item, idx) => {
|
|
1993
|
+
const entitySchemaName = String(item['EntitySchemaName'] ?? schema);
|
|
1994
|
+
const entityBaseView = String(item['EntityBaseView']);
|
|
1995
|
+
const code = String(item['Code']);
|
|
1996
|
+
const dateFieldToCheck = String(item['DateFieldToCheck'] ?? '__mj_UpdatedAt');
|
|
1997
|
+
const meta = uncachedItemMeta[idx];
|
|
1998
|
+
let filterSQL = '';
|
|
1999
|
+
if (itemFilters && itemFilters.length > 0) {
|
|
2000
|
+
const filter = itemFilters.find(f => f.ItemCode === code);
|
|
2001
|
+
if (filter)
|
|
2002
|
+
filterSQL = ' WHERE ' + filter.Filter;
|
|
2003
|
+
}
|
|
2004
|
+
return `SELECT ` +
|
|
2005
|
+
`CASE ` +
|
|
2006
|
+
`WHEN MAX(${provider.QuoteIdentifier(dateFieldToCheck)}) > '${meta.datasetMaxUpdatedAt}' THEN MAX(${provider.QuoteIdentifier(dateFieldToCheck)}) ` +
|
|
2007
|
+
`ELSE '${meta.datasetMaxUpdatedAt}' ` +
|
|
2008
|
+
`END AS ${provider.QuoteIdentifier('UpdateDate')}, ` +
|
|
2009
|
+
`COUNT(*) AS ${provider.QuoteIdentifier('TheRowCount')} ` +
|
|
2010
|
+
`FROM ${provider.QuoteSchemaAndView(entitySchemaName, entityBaseView)}${filterSQL}`;
|
|
2011
|
+
});
|
|
2012
|
+
let batchResults = [];
|
|
2013
|
+
try {
|
|
2014
|
+
batchResults = await provider.ExecuteSQLBatch(queries, undefined, undefined, contextUser);
|
|
2015
|
+
}
|
|
2016
|
+
catch (err) {
|
|
2017
|
+
LogError(`GetDatasetStatusByName: Batch execution failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2018
|
+
}
|
|
2019
|
+
for (let i = 0; i < uncachedItemMeta.length; i++) {
|
|
2020
|
+
const meta = uncachedItemMeta[i];
|
|
2021
|
+
const statusRows = batchResults[i];
|
|
2022
|
+
if (statusRows && statusRows.length > 0) {
|
|
2023
|
+
const updateDate = new Date(String(statusRows[0]['UpdateDate']));
|
|
2024
|
+
updateDates.push({
|
|
2025
|
+
EntityID: meta.entityID,
|
|
2026
|
+
EntityName: meta.entityName,
|
|
2027
|
+
RowCount: Number(statusRows[0]['TheRowCount']),
|
|
2028
|
+
UpdateDate: updateDate,
|
|
2029
|
+
});
|
|
2030
|
+
if (updateDate > overallLatestDate) {
|
|
2031
|
+
overallLatestDate = updateDate;
|
|
2032
|
+
}
|
|
1684
2033
|
}
|
|
1685
2034
|
}
|
|
1686
2035
|
}
|
|
2036
|
+
const elapsedMs = (performance.now() - overallStart).toFixed(1);
|
|
2037
|
+
LogStatusEx({
|
|
2038
|
+
message: `📊 [Dataset Status] GetDatasetStatusByName("${datasetName}"): ${cacheHitCount} cache-derived, ${cacheMissCount} SQL queries — ${elapsedMs}ms`,
|
|
2039
|
+
verboseOnly: true
|
|
2040
|
+
});
|
|
1687
2041
|
if (updateDates.length === 0) {
|
|
1688
2042
|
return {
|
|
1689
2043
|
DatasetID: String(items[0]['DatasetID']),
|
|
@@ -1739,5 +2093,56 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1739
2093
|
}
|
|
1740
2094
|
return specifiedColumns.length > 0 ? specifiedColumns.map(col => this.QuoteIdentifier(col.trim())).join(',') : '*';
|
|
1741
2095
|
}
|
|
2096
|
+
/**************************************************************************/
|
|
2097
|
+
// Dataset Cache Helpers
|
|
2098
|
+
/**************************************************************************/
|
|
2099
|
+
/**
|
|
2100
|
+
* Computes the latest update date for a dataset item from its result rows and dataset metadata.
|
|
2101
|
+
* Used by both the cache-hit and cache-miss paths in GetDatasetByName.
|
|
2102
|
+
* @param rows - The result rows (from cache or SQL)
|
|
2103
|
+
* @param dateFieldToCheck - The field name to scan for latest date
|
|
2104
|
+
* @param item - The dataset item metadata row (contains DatasetItemUpdatedAt, DatasetUpdatedAt)
|
|
2105
|
+
* @returns The latest date across all rows and dataset metadata
|
|
2106
|
+
*/
|
|
2107
|
+
computeLatestUpdateDate(rows, dateFieldToCheck, item) {
|
|
2108
|
+
const itemUpdatedAt = new Date(String(item['DatasetItemUpdatedAt']));
|
|
2109
|
+
const datasetUpdatedAt = new Date(String(item['DatasetUpdatedAt']));
|
|
2110
|
+
const datasetMaxUpdatedAt = new Date(Math.max(itemUpdatedAt.getTime(), datasetUpdatedAt.getTime()));
|
|
2111
|
+
let latestUpdateDate = new Date(1900, 1, 1);
|
|
2112
|
+
if (rows && rows.length > 0) {
|
|
2113
|
+
for (const data of rows) {
|
|
2114
|
+
const record = data;
|
|
2115
|
+
if (record[dateFieldToCheck]) {
|
|
2116
|
+
const d = new Date(String(record[dateFieldToCheck]));
|
|
2117
|
+
if (d > latestUpdateDate)
|
|
2118
|
+
latestUpdateDate = d;
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
if (datasetMaxUpdatedAt > latestUpdateDate)
|
|
2123
|
+
latestUpdateDate = datasetMaxUpdatedAt;
|
|
2124
|
+
return latestUpdateDate;
|
|
2125
|
+
}
|
|
2126
|
+
/**
|
|
2127
|
+
* Extracts the MAX value of a specified date field from result rows as an ISO string.
|
|
2128
|
+
* Used for write-through caching of dataset item results.
|
|
2129
|
+
* @param rows - The result rows
|
|
2130
|
+
* @param dateFieldToCheck - The field name to scan
|
|
2131
|
+
* @returns ISO string of the max date, or current time if no dates found
|
|
2132
|
+
*/
|
|
2133
|
+
extractMaxUpdatedAtFromRows(rows, dateFieldToCheck) {
|
|
2134
|
+
let maxDate = null;
|
|
2135
|
+
for (const row of rows) {
|
|
2136
|
+
const record = row;
|
|
2137
|
+
const val = record[dateFieldToCheck];
|
|
2138
|
+
if (val) {
|
|
2139
|
+
const d = val instanceof Date ? val : new Date(val);
|
|
2140
|
+
if (!isNaN(d.getTime()) && (!maxDate || d > maxDate)) {
|
|
2141
|
+
maxDate = d;
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
return maxDate ? maxDate.toISOString() : new Date().toISOString();
|
|
2146
|
+
}
|
|
1742
2147
|
}
|
|
1743
2148
|
//# sourceMappingURL=GenericDatabaseProvider.js.map
|