@memberjunction/generic-database-provider 0.0.1 → 5.6.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.
@@ -0,0 +1,1743 @@
1
+ /**
2
+ * @fileoverview Generic Database Provider for MemberJunction
3
+ *
4
+ * This module provides an intermediate abstract base class between DatabaseProviderBase (MJCore)
5
+ * and platform-specific providers (SQLServerDataProvider, PostgreSQLDataProvider).
6
+ *
7
+ * It contains shared logic that requires heavy dependencies (ActionEngine, AIEngine,
8
+ * EncryptionEngine, MJCoreEntities) which cannot live in MJCore's lightweight base class.
9
+ *
10
+ * Inheritance chain:
11
+ * DatabaseProviderBase (MJCore — no heavy deps)
12
+ * └── GenericDatabaseProvider (this package — has ActionEngine, AIEngine, EncryptionEngine)
13
+ * ├── SQLServerDataProvider (adds datetime handling, SQL logging, mssql-specific)
14
+ * └── PostgreSQLDataProvider (adds pg-specific connection management)
15
+ *
16
+ * @module @memberjunction/generic-database-provider
17
+ */
18
+ import { DatabaseProviderBase, EntityFieldTSType, EntityPermissionType, Metadata, QueryInfo, LogError, LogStatus, StripStopWords, } from '@memberjunction/core';
19
+ import { MJGlobal, UUIDsEqual } from '@memberjunction/global';
20
+ import { v4 as uuidv4 } from 'uuid';
21
+ import { SqlLoggingSessionImpl } from './SqlLogger.js';
22
+ import { QueryEngine, ViewInfo, } from '@memberjunction/core-entities';
23
+ import { AIEngine } from '@memberjunction/aiengine';
24
+ import { QueueManager } from '@memberjunction/queue';
25
+ import { EntityActionEngineServer } from '@memberjunction/actions';
26
+ import { EncryptionEngine } from '@memberjunction/encryption';
27
+ /**
28
+ * GenericDatabaseProvider is an intermediate abstract class that implements shared
29
+ * entity action, AI action, encryption, and view WHERE clause rendering logic.
30
+ *
31
+ * Platform-specific providers should extend this class instead of DatabaseProviderBase
32
+ * to inherit these shared behaviors.
33
+ */
34
+ export class GenericDatabaseProvider extends DatabaseProviderBase {
35
+ /**************************************************************************/
36
+ // SQL Logging — Session Management & Statement Logging
37
+ /**************************************************************************/
38
+ static { this._sqlLoggingSessionsKey = 'MJ_GenericDatabaseProvider_SqlLoggingSessions'; }
39
+ get _sqlLoggingSessions() {
40
+ const g = MJGlobal.Instance.GetGlobalObjectStore();
41
+ if (g) {
42
+ if (!g[GenericDatabaseProvider._sqlLoggingSessionsKey]) {
43
+ g[GenericDatabaseProvider._sqlLoggingSessionsKey] = new Map();
44
+ }
45
+ return g[GenericDatabaseProvider._sqlLoggingSessionsKey];
46
+ }
47
+ else {
48
+ throw new Error('No global object store available for SQL logging session');
49
+ }
50
+ }
51
+ /**
52
+ * Creates a new SQL logging session that will capture all SQL operations to a file.
53
+ * Returns a disposable session object that must be disposed to stop logging.
54
+ *
55
+ * @param filePath - Full path to the file where SQL statements will be logged
56
+ * @param options - Optional configuration for the logging session
57
+ * @returns Promise<SqlLoggingSession> - Disposable session object
58
+ *
59
+ * @example
60
+ * ```typescript
61
+ * // Basic usage
62
+ * const session = await provider.CreateSqlLogger('./logs/metadata-sync.sql');
63
+ * try {
64
+ * // Perform operations that will be logged
65
+ * await provider.ExecuteSQL('INSERT INTO ...');
66
+ * } finally {
67
+ * await session.dispose(); // Stop logging
68
+ * }
69
+ *
70
+ * // With migration formatting
71
+ * const session = await provider.CreateSqlLogger('./migrations/changes.sql', {
72
+ * formatAsMigration: true,
73
+ * description: 'MetadataSync push operation'
74
+ * });
75
+ * ```
76
+ */
77
+ async CreateSqlLogger(filePath, options) {
78
+ const sessionId = uuidv4();
79
+ const mjCoreSchema = this.ConfigData.MJCoreSchemaName;
80
+ const session = new SqlLoggingSessionImpl(sessionId, filePath, {
81
+ defaultSchemaName: mjCoreSchema,
82
+ ...options // if defaultSchemaName is not provided, it will use the MJCoreSchemaName, otherwise
83
+ // the caller's defaultSchemaName will be used
84
+ });
85
+ // Initialize the session (create file, write header)
86
+ await session.initialize();
87
+ // Store in active sessions map
88
+ this._sqlLoggingSessions.set(sessionId, session);
89
+ // Return a proxy that handles cleanup on dispose
90
+ return {
91
+ id: session.id,
92
+ filePath: session.filePath,
93
+ startTime: session.startTime,
94
+ get statementCount() {
95
+ return session.statementCount;
96
+ },
97
+ options: session.options,
98
+ dispose: async () => {
99
+ await session.dispose();
100
+ this._sqlLoggingSessions.delete(sessionId);
101
+ },
102
+ };
103
+ }
104
+ /**
105
+ * Gets information about all active SQL logging sessions.
106
+ * Useful for monitoring and debugging.
107
+ *
108
+ * @returns Array of session information objects
109
+ */
110
+ GetActiveSqlLoggingSessions() {
111
+ return Array.from(this._sqlLoggingSessions.values()).map((session) => ({
112
+ id: session.id,
113
+ filePath: session.filePath,
114
+ startTime: session.startTime,
115
+ statementCount: session.statementCount,
116
+ options: session.options,
117
+ }));
118
+ }
119
+ /**
120
+ * Gets a specific SQL logging session by its ID.
121
+ * Returns the session if found, or undefined if not found.
122
+ *
123
+ * @param sessionId - The unique identifier of the session to retrieve
124
+ * @returns The SqlLoggingSession if found, undefined otherwise
125
+ */
126
+ GetSqlLoggingSessionById(sessionId) {
127
+ return this._sqlLoggingSessions.get(sessionId);
128
+ }
129
+ /**
130
+ * Disposes all active SQL logging sessions.
131
+ * Useful for cleanup on provider shutdown.
132
+ */
133
+ async DisposeAllSqlLoggingSessions() {
134
+ const disposePromises = Array.from(this._sqlLoggingSessions.values()).map((session) => session.dispose());
135
+ await Promise.all(disposePromises);
136
+ this._sqlLoggingSessions.clear();
137
+ }
138
+ /**
139
+ * Internal method to log SQL statement to all active logging sessions.
140
+ * This is called automatically by ExecuteSQL methods.
141
+ * Protected so platform-specific providers can reference it (e.g., to bind as a callback).
142
+ *
143
+ * @param query - The SQL query being executed
144
+ * @param parameters - Parameters for the query
145
+ * @param description - Optional description for this operation
146
+ * @param ignoreLogging - If true, this statement will not be logged
147
+ * @param isMutation - Whether this is a data mutation operation
148
+ * @param simpleSQLFallback - Optional simple SQL to use for loggers with logRecordChangeMetadata=false
149
+ * @param contextUser - Optional user context for session filtering
150
+ */
151
+ async _logSqlStatement(query, parameters, description, ignoreLogging = false, isMutation = false, simpleSQLFallback, contextUser) {
152
+ if (ignoreLogging || this._sqlLoggingSessions.size === 0) {
153
+ return;
154
+ }
155
+ // Check if any session has verbose output enabled for debug logging
156
+ const allSessions = Array.from(this._sqlLoggingSessions.values());
157
+ const hasVerboseSession = allSessions.some(s => s.options.verboseOutput === true);
158
+ if (hasVerboseSession) {
159
+ console.log('=== SQL LOGGING DEBUG ===');
160
+ console.log(`Query to log: ${query.substring(0, 100)}...`);
161
+ console.log(`Context user email: ${contextUser?.Email || 'NOT_PROVIDED'}`);
162
+ console.log(`Active sessions count: ${this._sqlLoggingSessions.size}`);
163
+ console.log(`All sessions:`, allSessions.map(s => ({
164
+ id: s.id,
165
+ filterByUserId: s.options.filterByUserId,
166
+ sessionName: s.options.sessionName
167
+ })));
168
+ }
169
+ const filteredSessions = allSessions.filter((session) => {
170
+ // If session has user filter, only log if contextUser matches AND contextUser is provided
171
+ if (session.options.filterByUserId) {
172
+ if (!contextUser?.Email) {
173
+ if (hasVerboseSession) {
174
+ console.log(`Session ${session.id}: Has user filter but no contextUser provided - SKIPPING`);
175
+ }
176
+ return false; // Don't log if filtering requested but no user context provided
177
+ }
178
+ const matches = UUIDsEqual(session.options.filterByUserId, contextUser.ID);
179
+ if (hasVerboseSession) {
180
+ console.log(`Session ${session.id} filter check:`, {
181
+ filterByUserId: session.options.filterByUserId,
182
+ contextUserEmail: contextUser.Email,
183
+ matches: matches
184
+ });
185
+ }
186
+ return matches;
187
+ }
188
+ // No filter means log for all users (regardless of contextUser)
189
+ if (hasVerboseSession) {
190
+ console.log(`Session ${session.id} has no filter - including`);
191
+ }
192
+ return true;
193
+ });
194
+ if (hasVerboseSession) {
195
+ console.log(`Sessions after filtering: ${filteredSessions.length}`);
196
+ }
197
+ const logPromises = filteredSessions.map((session) => session.logSqlStatement(query, parameters, description, isMutation, simpleSQLFallback));
198
+ await Promise.all(logPromises);
199
+ if (hasVerboseSession) {
200
+ console.log('=== SQL LOGGING DEBUG END ===');
201
+ }
202
+ }
203
+ /**
204
+ * Static method to log SQL statements from external sources like transaction groups.
205
+ * Gets the current provider instance from Metadata.Provider and delegates to the
206
+ * instance _logSqlStatement method.
207
+ *
208
+ * @param query - The SQL query being executed
209
+ * @param parameters - Parameters for the query
210
+ * @param description - Optional description for this operation
211
+ * @param isMutation - Whether this is a data mutation operation
212
+ * @param simpleSQLFallback - Optional simple SQL to use for loggers with logRecordChangeMetadata=false
213
+ * @param contextUser - Optional user context for session filtering
214
+ */
215
+ static async LogSQLStatement(query, parameters, description, isMutation = false, simpleSQLFallback, contextUser) {
216
+ // Get the current provider instance
217
+ const provider = Metadata.Provider;
218
+ if (provider && provider._sqlLoggingSessions.size > 0) {
219
+ await provider._logSqlStatement(query, parameters, description, false, isMutation, simpleSQLFallback, contextUser);
220
+ }
221
+ }
222
+ /**************************************************************************/
223
+ // Entity Actions & AI Actions (Concrete Implementations)
224
+ /**************************************************************************/
225
+ /**
226
+ * Returns AI actions configured for the given entity and timing.
227
+ * Uses AIEngine metadata to find matching EntityAIAction records.
228
+ */
229
+ GetEntityAIActions(entityInfo, before) {
230
+ return AIEngine.Instance.EntityAIActions.filter((a) => UUIDsEqual(a.EntityID, entityInfo.ID) && a.TriggerEvent.toLowerCase().trim() === (before ? 'before save' : 'after save'));
231
+ }
232
+ /**
233
+ * Handles entity actions (non-AI) for save, delete, or validate operations.
234
+ * Uses EntityActionEngineServer to discover and run active actions.
235
+ */
236
+ async HandleEntityActions(entity, baseType, before, user) {
237
+ try {
238
+ const engine = EntityActionEngineServer.Instance;
239
+ await engine.Config(false, user);
240
+ const newRecord = entity.IsSaved ? false : true;
241
+ const baseTypeType = baseType === 'save' ? (newRecord ? 'Create' : 'Update') : 'Delete';
242
+ const invocationType = baseType === 'validate' ? 'Validate' : before ? 'Before' + baseTypeType : 'After' + baseTypeType;
243
+ const invocationTypeEntity = engine.InvocationTypes.find((i) => i.Name === invocationType);
244
+ if (!invocationTypeEntity) {
245
+ LogError(`Invocation Type ${invocationType} not found in metadata`);
246
+ return [];
247
+ }
248
+ const activeActions = engine.GetActionsByEntityNameAndInvocationType(entity.EntityInfo.Name, invocationType, 'Active');
249
+ const results = [];
250
+ for (const a of activeActions) {
251
+ const result = await engine.RunEntityAction({
252
+ EntityAction: a,
253
+ EntityObject: entity,
254
+ InvocationType: invocationTypeEntity,
255
+ ContextUser: user,
256
+ });
257
+ results.push(result);
258
+ }
259
+ return results;
260
+ }
261
+ catch (e) {
262
+ LogError(e);
263
+ return [];
264
+ }
265
+ }
266
+ /**
267
+ * Handles Entity AI Actions for save or delete operations.
268
+ *
269
+ * For "before save" actions: blocks (awaits) until complete.
270
+ * For "after save" actions: fires and forgets via QueueManager.
271
+ *
272
+ * Subclasses that manage transactions can override to defer after-save tasks
273
+ * until after transaction commit (see SQLServerDataProvider).
274
+ */
275
+ async HandleEntityAIActions(entity, baseType, before, user) {
276
+ try {
277
+ if (baseType === 'delete')
278
+ return; // delete not yet supported for AI actions
279
+ await AIEngine.Instance.Config(false, user);
280
+ const actions = this.GetEntityAIActions(entity.EntityInfo, before);
281
+ if (actions && actions.length > 0) {
282
+ const ai = AIEngine.Instance;
283
+ for (let i = 0; i < actions.length; i++) {
284
+ const a = actions[i];
285
+ if ((a.TriggerEvent === 'before save' && before) || (a.TriggerEvent === 'after save' && !before)) {
286
+ const p = {
287
+ entityAIActionId: a.ID,
288
+ entityRecord: entity,
289
+ actionId: a.AIActionID,
290
+ modelId: a.AIModelID,
291
+ };
292
+ if (before) {
293
+ await ai.ExecuteEntityAIAction(p);
294
+ }
295
+ else {
296
+ try {
297
+ this.EnqueueAfterSaveAIAction(p, user);
298
+ }
299
+ catch (e) {
300
+ LogError(e instanceof Error ? e.message : String(e));
301
+ }
302
+ }
303
+ }
304
+ }
305
+ }
306
+ }
307
+ catch (e) {
308
+ LogError(e);
309
+ }
310
+ }
311
+ /**
312
+ * Enqueues an after-save AI action for execution. By default, immediately adds
313
+ * to QueueManager. Subclasses with transaction support can override to defer
314
+ * until after transaction commit.
315
+ */
316
+ EnqueueAfterSaveAIAction(params, user) {
317
+ QueueManager.AddTask('Entity AI Action', params, null, user);
318
+ }
319
+ /**************************************************************************/
320
+ // Save/Delete Lifecycle Hooks (Concrete Implementations)
321
+ /**************************************************************************/
322
+ async OnValidateBeforeSave(entity, user) {
323
+ const validationResult = await this.HandleEntityActions(entity, 'validate', false, user);
324
+ if (validationResult && validationResult.length > 0) {
325
+ const message = validationResult
326
+ .filter((v) => !v.Success)
327
+ .map((v) => v.Message)
328
+ .join('\n\n');
329
+ if (message)
330
+ return message;
331
+ }
332
+ return null;
333
+ }
334
+ async OnBeforeSaveExecute(entity, user, options) {
335
+ if (options.SkipEntityActions !== true)
336
+ await this.HandleEntityActions(entity, 'save', true, user);
337
+ if (options.SkipEntityAIActions !== true)
338
+ await this.HandleEntityAIActions(entity, 'save', true, user);
339
+ }
340
+ OnAfterSaveExecute(entity, user, options) {
341
+ if (options.SkipEntityAIActions !== true)
342
+ this.HandleEntityAIActions(entity, 'save', false, user); // NO AWAIT INTENTIONALLY
343
+ if (options.SkipEntityActions !== true)
344
+ this.HandleEntityActions(entity, 'save', false, user); // NO AWAIT INTENTIONALLY
345
+ }
346
+ async OnBeforeDeleteExecute(entity, user, options) {
347
+ if (false === options?.SkipEntityActions)
348
+ await this.HandleEntityActions(entity, 'delete', true, user);
349
+ if (false === options?.SkipEntityAIActions)
350
+ await this.HandleEntityAIActions(entity, 'delete', true, user);
351
+ }
352
+ OnAfterDeleteExecute(entity, user, options) {
353
+ if (false === options?.SkipEntityActions)
354
+ this.HandleEntityActions(entity, 'delete', false, user);
355
+ if (false === options?.SkipEntityAIActions)
356
+ this.HandleEntityAIActions(entity, 'delete', false, user);
357
+ }
358
+ /**************************************************************************/
359
+ // PostProcessRows — Datetime Adjustment + Encryption Decryption
360
+ /**************************************************************************/
361
+ /**
362
+ * Post-processes rows: first applies platform-specific datetime adjustments
363
+ * via the virtual `AdjustDatetimeFields` hook, then handles field-level
364
+ * decryption for encrypted fields.
365
+ *
366
+ * Subclasses should NOT override this method. Instead, override
367
+ * `AdjustDatetimeFields` for platform-specific datetime corrections.
368
+ */
369
+ async PostProcessRows(rows, entityInfo, user) {
370
+ if (!rows || rows.length === 0)
371
+ return rows;
372
+ // Step 1: Platform-specific datetime adjustment (virtual hook)
373
+ const datetimeFields = entityInfo.Fields.filter((field) => field.TSType === EntityFieldTSType.Date);
374
+ let processedRows = rows;
375
+ if (datetimeFields.length > 0) {
376
+ processedRows = await this.AdjustDatetimeFields(processedRows, datetimeFields, entityInfo);
377
+ }
378
+ // Step 2: Encryption decryption
379
+ const encryptedFields = entityInfo.Fields.filter((field) => field.Encrypt && field.EncryptionKeyID);
380
+ if (encryptedFields.length === 0)
381
+ return processedRows;
382
+ const encryptionEngine = EncryptionEngine.Instance;
383
+ await encryptionEngine.Config(false, user);
384
+ return Promise.all(processedRows.map(async (row) => {
385
+ const processedRow = { ...row };
386
+ for (const field of encryptedFields) {
387
+ const fieldValue = processedRow[field.Name];
388
+ if (fieldValue === null || fieldValue === undefined || fieldValue === '')
389
+ continue;
390
+ const keyMarker = field.EncryptionKeyID ? encryptionEngine.GetKeyByID(field.EncryptionKeyID)?.Marker : undefined;
391
+ if (typeof fieldValue === 'string' && encryptionEngine.IsEncrypted(fieldValue, keyMarker)) {
392
+ try {
393
+ const decryptedValue = await encryptionEngine.Decrypt(fieldValue, user);
394
+ processedRow[field.Name] = decryptedValue;
395
+ }
396
+ catch (decryptError) {
397
+ const message = decryptError instanceof Error ? decryptError.message : String(decryptError);
398
+ LogError(`Failed to decrypt field "${field.Name}" on entity "${entityInfo.Name}": ${message}. ` +
399
+ 'The encrypted value will be returned unchanged.');
400
+ }
401
+ }
402
+ }
403
+ return processedRow;
404
+ }));
405
+ }
406
+ /**
407
+ * Virtual hook for platform-specific datetime field adjustments.
408
+ * Default implementation is a no-op (returns rows unchanged).
409
+ *
410
+ * SQL Server overrides this to correct datetime2/datetimeoffset/datetime
411
+ * timezone interpretation issues in the mssql driver.
412
+ * PostgreSQL does NOT need to override — PG timestamp types are timezone-aware natively.
413
+ *
414
+ * @param rows The data rows to process
415
+ * @param datetimeFields Entity fields with TSType === Date
416
+ * @param entityInfo The entity metadata
417
+ * @returns The rows with datetime fields adjusted (or unchanged for default)
418
+ */
419
+ async AdjustDatetimeFields(rows, datetimeFields, entityInfo) {
420
+ return rows; // No-op by default — PG timestamps don't need adjustment
421
+ }
422
+ /**************************************************************************/
423
+ // ExecuteSQLBatch — Default Batch Execution (Parallel Individual Queries)
424
+ /**************************************************************************/
425
+ /**
426
+ * Executes multiple SQL queries and returns an array of result arrays, one per query.
427
+ *
428
+ * The default implementation runs queries in parallel using `Promise.all(ExecuteSQL(...))`.
429
+ * Platform-specific providers can override for true multi-result-set batching:
430
+ * - SQL Server: concatenates queries and uses a single mssql request with multiple recordsets
431
+ * - PostgreSQL: could use pg pipeline or simple parallel execution
432
+ *
433
+ * @param queries Array of SQL query strings to execute
434
+ * @param parameters Optional array of parameter arrays, one per query
435
+ * @param options Optional batch execution options
436
+ * @param contextUser Optional user context for logging/filtering
437
+ * @returns Array of result arrays, one for each query
438
+ */
439
+ async ExecuteSQLBatch(queries, parameters, options, contextUser) {
440
+ const execOptions = options ? {
441
+ description: options.description,
442
+ ignoreLogging: options.ignoreLogging,
443
+ isMutation: options.isMutation,
444
+ } : undefined;
445
+ const promises = queries.map((query, index) => {
446
+ const queryParams = parameters?.[index];
447
+ return this.ExecuteSQL(query, queryParams, execOptions, contextUser);
448
+ });
449
+ return Promise.all(promises);
450
+ }
451
+ /**
452
+ * Builds a platform-specific TOP/LIMIT clause for non-paginated row limits.
453
+ * SQL Server: `TOP N`; PostgreSQL returns empty (uses LIMIT via BuildPaginationSQL).
454
+ * Default: returns empty string. SQL Server overrides.
455
+ */
456
+ BuildTopClause(_maxRows) {
457
+ return '';
458
+ }
459
+ /**
460
+ * Builds a platform-specific non-paginated row limit clause appended at end of query.
461
+ * SQL Server: returns '' (already handled by TOP in SELECT clause).
462
+ * PostgreSQL: returns `LIMIT N`.
463
+ * Default: returns empty string. PG overrides.
464
+ */
465
+ BuildNonPaginatedLimitSQL(_maxRows) {
466
+ return '';
467
+ }
468
+ /**
469
+ * Transforms a user-provided SQL clause (ExtraFilter, OrderBy, etc.) for platform compatibility.
470
+ * PostgreSQL overrides to quote mixed-case identifiers and convert bracket notation.
471
+ * Default: returns the clause unchanged.
472
+ */
473
+ TransformExternalSQLClause(clause, _entityInfo) {
474
+ return clause;
475
+ }
476
+ /**
477
+ * Optionally wraps a view query with user view run logging.
478
+ * SQL Server overrides to use spCreateUserViewRunWithDetail.
479
+ * Default: returns null (no view run logging).
480
+ */
481
+ async executeSQLForUserViewRunLogging(_viewId, _entityBaseView, _whereSQL, _orderBySQL, _user) {
482
+ return null;
483
+ }
484
+ /**
485
+ * Shared InternalRunView implementation.
486
+ * Handles: view resolution, permissions, field selection, WHERE clause building
487
+ * (view + extra filter + user search + exclude + RLS), ORDER BY, pagination,
488
+ * aggregates, parallel query execution, post-processing, and audit logging.
489
+ */
490
+ async InternalRunView(params, contextUser) {
491
+ if (params?.Aggregates?.length) {
492
+ LogStatus(`[GenericDatabaseProvider] InternalRunView received aggregates: entityName=${params.EntityName}, viewID=${params.ViewID}, viewName=${params.ViewName}, aggregateCount=${params.Aggregates.length}`);
493
+ }
494
+ const startTime = new Date();
495
+ try {
496
+ if (!params)
497
+ return null;
498
+ const user = contextUser ?? this.CurrentUser;
499
+ if (!user)
500
+ throw new Error('User not found in metadata and no contextUser provided to RunView()');
501
+ // ── View / Entity resolution ──
502
+ let viewEntity = null;
503
+ let entityInfo = null;
504
+ if (params.ViewEntity)
505
+ viewEntity = params.ViewEntity;
506
+ else if (params.ViewID && params.ViewID.length > 0)
507
+ viewEntity = await ViewInfo.GetViewEntity(params.ViewID, contextUser) ?? null;
508
+ else if (params.ViewName && params.ViewName.length > 0)
509
+ viewEntity = await ViewInfo.GetViewEntityByName(params.ViewName, contextUser) ?? null;
510
+ if (!viewEntity) {
511
+ if (!params.EntityName || params.EntityName.length === 0)
512
+ throw new Error('EntityName is required when ViewID or ViewName is not provided');
513
+ entityInfo = this.Entities.find((e) => e.Name.trim().toLowerCase() === params.EntityName.trim().toLowerCase()) ?? null;
514
+ if (!entityInfo)
515
+ throw new Error(`Entity ${params.EntityName} not found in metadata`);
516
+ }
517
+ else {
518
+ entityInfo = this.Entities.find((e) => UUIDsEqual(e.ID, viewEntity.EntityID)) ?? null;
519
+ if (!entityInfo)
520
+ throw new Error(`Entity ID: ${viewEntity.EntityID} not found in metadata`);
521
+ }
522
+ this.CheckUserReadPermissions(entityInfo.Name, user);
523
+ // ── Parameters (transform user-provided SQL clauses for platform compatibility) ──
524
+ const extraFilter = this.TransformExternalSQLClause(params.ExtraFilter || '', entityInfo);
525
+ const userSearchString = params.UserSearchString ?? '';
526
+ const excludeUserViewRunID = params.ExcludeUserViewRunID ?? '';
527
+ const overrideExcludeFilter = params.OverrideExcludeFilter ?? '';
528
+ const saveViewResults = params.SaveViewResults ?? false;
529
+ // ── TOP / pagination mode ──
530
+ const usingPagination = !!(params.MaxRows && params.MaxRows > 0 && params.StartRow !== undefined && params.StartRow >= 0);
531
+ let topSQL = '';
532
+ let maxRowsForQuery = 0;
533
+ if (params.IgnoreMaxRows === true) {
534
+ // no limit
535
+ }
536
+ else if (usingPagination) {
537
+ // pagination — no TOP, will add OFFSET/FETCH or LIMIT/OFFSET later
538
+ maxRowsForQuery = params.MaxRows;
539
+ }
540
+ else if (params.MaxRows && params.MaxRows > 0) {
541
+ topSQL = this.BuildTopClause(params.MaxRows);
542
+ maxRowsForQuery = params.MaxRows;
543
+ }
544
+ else if (entityInfo.UserViewMaxRows && entityInfo.UserViewMaxRows > 0) {
545
+ topSQL = this.BuildTopClause(entityInfo.UserViewMaxRows);
546
+ maxRowsForQuery = entityInfo.UserViewMaxRows;
547
+ }
548
+ // ── Field selection ──
549
+ const fields = this.getRunTimeViewFieldString(params, viewEntity);
550
+ // ── Build SELECT and COUNT SQL ──
551
+ const topFragment = topSQL ? topSQL + ' ' : '';
552
+ let viewSQL = `SELECT ${topFragment}${fields} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.BaseView)}`;
553
+ let countSQL = (usingPagination || (topSQL && topSQL.length > 0))
554
+ ? `SELECT COUNT(*) AS TotalRowCount FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.BaseView)}`
555
+ : null;
556
+ // ── WHERE clause assembly ──
557
+ let whereSQL = '';
558
+ let bHasWhere = false;
559
+ // 1. View where clause
560
+ if (viewEntity?.WhereClause && viewEntity.WhereClause.length > 0) {
561
+ const renderedWhere = await this.RenderViewWhereClause(viewEntity, user);
562
+ whereSQL = `(${renderedWhere})`;
563
+ bHasWhere = true;
564
+ }
565
+ // 2. ExtraFilter
566
+ if (extraFilter.length > 0) {
567
+ if (!this.ValidateUserProvidedSQLClause(extraFilter))
568
+ throw new Error(`Invalid Extra Filter: ${extraFilter}, contains one more for forbidden keywords`);
569
+ whereSQL = bHasWhere ? `${whereSQL} AND (${extraFilter})` : `(${extraFilter})`;
570
+ bHasWhere = true;
571
+ }
572
+ // 3. User search string
573
+ if (userSearchString.length > 0) {
574
+ if (!this.ValidateUserProvidedSQLClause(userSearchString))
575
+ throw new Error(`Invalid User Search SQL clause: ${userSearchString}, contains one more for forbidden keywords`);
576
+ const sUserSearchSQL = this.createViewUserSearchSQL(entityInfo, userSearchString);
577
+ if (sUserSearchSQL.length > 0) {
578
+ whereSQL = bHasWhere ? `${whereSQL} AND (${sUserSearchSQL})` : `(${sUserSearchSQL})`;
579
+ bHasWhere = true;
580
+ }
581
+ }
582
+ // 4. Exclude UserViewRunID
583
+ if ((excludeUserViewRunID.length > 0) || params.ExcludeDataFromAllPriorViewRuns === true) {
584
+ let sExcludeSQL = `${this.QuoteIdentifier(entityInfo.FirstPrimaryKey?.Name ?? 'ID')} NOT IN (SELECT RecordID FROM ${this.QuoteSchemaAndView(this.MJCoreSchemaName, 'vwUserViewRunDetails')} WHERE EntityID='${viewEntity?.EntityID}' AND`;
585
+ if (params.ExcludeDataFromAllPriorViewRuns === true)
586
+ sExcludeSQL += ` UserViewID=${viewEntity?.ID})`;
587
+ else
588
+ sExcludeSQL += ` UserViewRunID=${excludeUserViewRunID})`;
589
+ if (overrideExcludeFilter.length > 0) {
590
+ if (!this.ValidateUserProvidedSQLClause(overrideExcludeFilter))
591
+ throw new Error(`Invalid OverrideExcludeFilter: ${overrideExcludeFilter}, contains one more for forbidden keywords`);
592
+ sExcludeSQL += ' OR (' + overrideExcludeFilter + ')';
593
+ }
594
+ whereSQL = bHasWhere ? `${whereSQL} AND (${sExcludeSQL})` : `(${sExcludeSQL})`;
595
+ bHasWhere = true;
596
+ }
597
+ // 5. Row-Level Security
598
+ if (!entityInfo.UserExemptFromRowLevelSecurity(user, EntityPermissionType.Read)) {
599
+ const rlsWhereClause = entityInfo.GetUserRowLevelSecurityWhereClause(user, EntityPermissionType.Read, '');
600
+ if (rlsWhereClause && rlsWhereClause.length > 0) {
601
+ whereSQL = bHasWhere ? `${whereSQL} AND (${rlsWhereClause})` : `(${rlsWhereClause})`;
602
+ bHasWhere = true;
603
+ }
604
+ }
605
+ if (bHasWhere) {
606
+ viewSQL += ` WHERE ${whereSQL}`;
607
+ if (countSQL)
608
+ countSQL += ` WHERE ${whereSQL}`;
609
+ }
610
+ // ── ORDER BY (transform user-provided clause for platform compatibility) ──
611
+ const rawOrderBy = params.OrderBy ? params.OrderBy : (viewEntity ? viewEntity.OrderByClause ?? '' : '');
612
+ const orderBy = rawOrderBy.length > 0 ? this.TransformExternalSQLClause(rawOrderBy, entityInfo) : '';
613
+ // View run logging (SQL Server-specific, others return null)
614
+ let userViewRunID = '';
615
+ if (viewEntity?.ID && String(viewEntity.ID).length > 0 && saveViewResults && user) {
616
+ const logResult = await this.executeSQLForUserViewRunLogging(Number(viewEntity.ID), viewEntity.EntityBaseView, whereSQL, orderBy, user);
617
+ if (logResult) {
618
+ viewSQL = logResult.executeViewSQL;
619
+ userViewRunID = logResult.runID;
620
+ }
621
+ else if (orderBy.length > 0) {
622
+ if (!this.ValidateUserProvidedSQLClause(orderBy))
623
+ throw new Error(`Invalid Order By clause: ${orderBy}, contains one more for forbidden keywords`);
624
+ viewSQL += ` ORDER BY ${orderBy}`;
625
+ }
626
+ }
627
+ else if (orderBy.length > 0) {
628
+ if (!this.ValidateUserProvidedSQLClause(orderBy))
629
+ throw new Error(`Invalid Order By clause: ${orderBy}, contains one more for forbidden keywords`);
630
+ viewSQL += ` ORDER BY ${orderBy}`;
631
+ }
632
+ // ── Pagination / Non-paginated limit ──
633
+ if (usingPagination && entityInfo.FirstPrimaryKey) {
634
+ if (!orderBy) {
635
+ viewSQL += ` ORDER BY ${this.QuoteIdentifier(entityInfo.FirstPrimaryKey.Name)}`;
636
+ }
637
+ viewSQL += ' ' + this.BuildPaginationSQL(params.MaxRows, params.StartRow);
638
+ }
639
+ else if (!topSQL && maxRowsForQuery > 0) {
640
+ // Platform doesn't use TOP (e.g., PG uses LIMIT at end of query)
641
+ const limitSQL = this.BuildNonPaginatedLimitSQL(maxRowsForQuery);
642
+ if (limitSQL)
643
+ viewSQL += ' ' + limitSQL;
644
+ }
645
+ // ── Aggregates ──
646
+ let aggregateSQL = null;
647
+ let aggregateValidationErrors = [];
648
+ if (params.Aggregates && params.Aggregates.length > 0) {
649
+ const aggregateBuild = this.BuildAggregateSQL(params.Aggregates, entityInfo, entityInfo.SchemaName, entityInfo.BaseView, whereSQL);
650
+ aggregateSQL = aggregateBuild.aggregateSQL;
651
+ aggregateValidationErrors = aggregateBuild.validationErrors;
652
+ }
653
+ // ── Execute queries in parallel ──
654
+ const queries = [];
655
+ const queryKeys = [];
656
+ if (params.ResultType !== 'count_only') {
657
+ queries.push(this.ExecuteSQL(viewSQL, undefined, undefined, contextUser));
658
+ queryKeys.push('data');
659
+ }
660
+ const maxRowsUsed = params.MaxRows || entityInfo.UserViewMaxRows;
661
+ const willNeedCount = countSQL && (usingPagination || params.ResultType === 'count_only');
662
+ if (willNeedCount) {
663
+ queries.push(this.ExecuteSQL(countSQL, undefined, undefined, contextUser));
664
+ queryKeys.push('count');
665
+ }
666
+ const aggregateStartTime = Date.now();
667
+ if (aggregateSQL) {
668
+ queries.push(this.ExecuteSQL(aggregateSQL, undefined, undefined, contextUser));
669
+ queryKeys.push('aggregate');
670
+ }
671
+ const results = await Promise.all(queries);
672
+ const resultMap = {};
673
+ queryKeys.forEach((key, index) => { resultMap[key] = results[index]; });
674
+ // ── Process data rows ──
675
+ let retData = resultMap['data'] || [];
676
+ if (retData.length > 0 && params.ResultType !== 'count_only') {
677
+ retData = await this.PostProcessRows(retData, entityInfo, user);
678
+ }
679
+ // ── Process count ──
680
+ let rowCount = null;
681
+ if (willNeedCount && resultMap['count']) {
682
+ const countResult = resultMap['count'];
683
+ if (countResult && countResult.length > 0)
684
+ rowCount = countResult[0].TotalRowCount;
685
+ }
686
+ else if (countSQL && maxRowsUsed && retData.length === maxRowsUsed) {
687
+ const countResult = await this.ExecuteSQL(countSQL, undefined, undefined, contextUser);
688
+ if (countResult && countResult.length > 0)
689
+ rowCount = countResult[0].TotalRowCount;
690
+ }
691
+ // ── Process aggregates ──
692
+ let aggregateResults;
693
+ let aggregateExecutionTime;
694
+ if (params.Aggregates && params.Aggregates.length > 0) {
695
+ aggregateExecutionTime = Date.now() - aggregateStartTime;
696
+ if (resultMap['aggregate']) {
697
+ const rawAggregateResult = resultMap['aggregate'];
698
+ if (rawAggregateResult && rawAggregateResult.length > 0) {
699
+ const row = rawAggregateResult[0];
700
+ aggregateResults = [];
701
+ let validExprIndex = 0;
702
+ for (let i = 0; i < params.Aggregates.length; i++) {
703
+ const agg = params.Aggregates[i];
704
+ const alias = agg.alias || agg.expression;
705
+ const validationError = aggregateValidationErrors.find(e => e.expression === agg.expression);
706
+ if (validationError) {
707
+ aggregateResults.push(validationError);
708
+ }
709
+ else {
710
+ const rawValue = row[`Agg_${validExprIndex}`];
711
+ const value = rawValue === undefined ? null : rawValue;
712
+ aggregateResults.push({ expression: agg.expression, alias, value, error: undefined });
713
+ validExprIndex++;
714
+ }
715
+ }
716
+ }
717
+ }
718
+ else if (aggregateValidationErrors.length > 0) {
719
+ aggregateResults = aggregateValidationErrors;
720
+ }
721
+ }
722
+ // ── Audit log ──
723
+ const stopTime = new Date();
724
+ if (params.ForceAuditLog || (viewEntity?.ID && (!extraFilter || extraFilter.trim().length === 0) && entityInfo.AuditViewRuns)) {
725
+ this.CreateAuditLogRecord(user, 'Run View', 'Run View', 'Success', JSON.stringify({ ViewID: viewEntity?.ID, ViewName: viewEntity?.Name, Description: params.AuditLogDescription, RowCount: retData.length, SQL: viewSQL }), entityInfo.ID, null, params.AuditLogDescription ?? null, null);
726
+ }
727
+ return {
728
+ RowCount: params.ResultType === 'count_only' ? rowCount : retData.length,
729
+ TotalRowCount: rowCount ?? retData.length,
730
+ Results: retData,
731
+ UserViewRunID: userViewRunID,
732
+ ExecutionTime: stopTime.getTime() - startTime.getTime(),
733
+ Success: true,
734
+ ErrorMessage: '',
735
+ AggregateResults: aggregateResults,
736
+ AggregateExecutionTime: aggregateExecutionTime,
737
+ };
738
+ }
739
+ catch (e) {
740
+ const exceptionStopTime = new Date();
741
+ LogError(e);
742
+ return {
743
+ RowCount: 0,
744
+ TotalRowCount: 0,
745
+ Results: [],
746
+ UserViewRunID: '',
747
+ ExecutionTime: exceptionStopTime.getTime() - startTime.getTime(),
748
+ Success: false,
749
+ ErrorMessage: e instanceof Error ? e.message : String(e),
750
+ };
751
+ }
752
+ }
753
+ async InternalRunViews(params, contextUser) {
754
+ const promises = params.map((p) => this.InternalRunView(p, contextUser));
755
+ return Promise.all(promises);
756
+ }
757
+ /**************************************************************************/
758
+ // InternalRunView Helpers
759
+ /**************************************************************************/
760
+ /**
761
+ * Builds the SQL field list string for a view query, using dialect-neutral quoting.
762
+ * Returns '*' if no specific fields are resolved.
763
+ */
764
+ getRunTimeViewFieldString(params, viewEntity) {
765
+ const fieldList = this.getRunTimeViewFieldArray(params, viewEntity);
766
+ if (fieldList.length === 0)
767
+ return '*';
768
+ return fieldList
769
+ .map((f) => {
770
+ const asString = f.CodeName === f.Name ? '' : ` AS ${this.QuoteIdentifier(f.CodeName)}`;
771
+ return `${this.QuoteIdentifier(f.Name)}${asString}`;
772
+ })
773
+ .join(',');
774
+ }
775
+ /**
776
+ * Resolves the list of EntityFieldInfo objects for a view query.
777
+ * Priority: params.Fields > view columns > all entity fields (wildcard).
778
+ */
779
+ getRunTimeViewFieldArray(params, viewEntity) {
780
+ const fieldList = [];
781
+ try {
782
+ let entityInfo = null;
783
+ if (viewEntity) {
784
+ entityInfo = viewEntity.ViewEntityInfo;
785
+ }
786
+ else {
787
+ entityInfo = this.Entities.find((e) => e.Name === params.EntityName) ?? null;
788
+ if (!entityInfo)
789
+ throw new Error(`Entity ${params.EntityName} not found in metadata`);
790
+ }
791
+ if (params.Fields) {
792
+ for (const ef of entityInfo.PrimaryKeys) {
793
+ if (!params.Fields.find((f) => f.trim().toLowerCase() === ef.Name.toLowerCase()))
794
+ fieldList.push(ef);
795
+ }
796
+ params.Fields.forEach((f) => {
797
+ const field = entityInfo.Fields.find((field) => field.Name.trim().toLowerCase() === f.trim().toLowerCase());
798
+ if (field)
799
+ fieldList.push(field);
800
+ else
801
+ LogError(`Field ${f} not found in entity ${entityInfo.Name}`);
802
+ });
803
+ }
804
+ else if (viewEntity) {
805
+ viewEntity.Columns.forEach((c) => {
806
+ if (!c.hidden) {
807
+ if (c.EntityField) {
808
+ fieldList.push(c.EntityField);
809
+ }
810
+ else {
811
+ LogError(`View Field ${c.Name} doesn't match an Entity Field in entity ${entityInfo.Name}.`);
812
+ }
813
+ }
814
+ });
815
+ for (const ef of entityInfo.PrimaryKeys) {
816
+ if (!fieldList.find((f) => f.Name?.trim().toLowerCase() === ef.Name?.toLowerCase()))
817
+ fieldList.push(ef);
818
+ }
819
+ }
820
+ }
821
+ catch (e) {
822
+ LogError(e);
823
+ }
824
+ return fieldList;
825
+ }
826
+ /**
827
+ * Builds user search SQL for the given entity and search string.
828
+ * Supports full-text search (if enabled) and field-by-field LIKE searching.
829
+ */
830
+ createViewUserSearchSQL(entityInfo, userSearchString) {
831
+ let sUserSearchSQL = '';
832
+ if (entityInfo.FullTextSearchEnabled) {
833
+ let u = userSearchString;
834
+ const uUpper = u.toUpperCase();
835
+ if (uUpper.includes(' AND ') || uUpper.includes(' OR ') || uUpper.includes(' NOT ')) {
836
+ u = uUpper.replace(/ /g, '%').replace(/%AND%/g, ' AND ').replace(/%OR%/g, ' OR ').replace(/%NOT%/g, ' NOT ');
837
+ }
838
+ else if (uUpper.includes('AND') || uUpper.includes('OR') || uUpper.includes('NOT')) {
839
+ u = u.replace(/ /g, '%');
840
+ }
841
+ else if (u.includes(' ')) {
842
+ if (!(u.startsWith('"') && u.endsWith('"'))) {
843
+ u = StripStopWords(userSearchString);
844
+ u = u.replace(/ /g, ' AND ');
845
+ }
846
+ }
847
+ const pkName = this.QuoteIdentifier(entityInfo.FirstPrimaryKey?.Name ?? 'ID');
848
+ sUserSearchSQL = `${pkName} IN (SELECT ${pkName} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.FullTextSearchFunction ?? '')}('${u}'))`;
849
+ }
850
+ else {
851
+ for (const field of entityInfo.Fields) {
852
+ if (field.IncludeInUserSearchAPI) {
853
+ let sParam = '';
854
+ if (sUserSearchSQL.length > 0)
855
+ sUserSearchSQL += ' OR ';
856
+ if (field.UserSearchParamFormatAPI && field.UserSearchParamFormatAPI.length > 0)
857
+ sParam = field.UserSearchParamFormatAPI.replace('{0}', userSearchString);
858
+ else
859
+ sParam = ` LIKE '%${userSearchString}%'`;
860
+ sUserSearchSQL += `(${field.Name} ${sParam})`;
861
+ }
862
+ }
863
+ if (sUserSearchSQL.length > 0)
864
+ sUserSearchSQL = '(' + sUserSearchSQL + ')';
865
+ }
866
+ return sUserSearchSQL;
867
+ }
868
+ /**************************************************************************/
869
+ // RenderViewWhereClause — View Template Rendering
870
+ /**************************************************************************/
871
+ /**
872
+ * Renders the WHERE clause for a saved view, replacing template variables
873
+ * like {%UserView "viewId"%} with subquery SQL. Handles nested/recursive
874
+ * templates with circular reference detection.
875
+ *
876
+ * Uses QuoteIdentifier/QuoteSchemaAndView for dialect-neutral SQL generation.
877
+ */
878
+ async RenderViewWhereClause(viewEntity, user, stack = []) {
879
+ try {
880
+ let sWhere = viewEntity.WhereClause ?? '';
881
+ if (sWhere && sWhere.length > 0) {
882
+ const templateRegex = /{%([^%]+)%}/g;
883
+ const matches = sWhere.match(templateRegex);
884
+ if (matches) {
885
+ for (const match of matches) {
886
+ const variable = match.substring(2, match.length - 2);
887
+ const parts = variable.split(' ');
888
+ const variableName = parts[0];
889
+ if (variableName.trim().toLowerCase() === 'userview') {
890
+ let variableValue = parts.length > 1 ? parts[1] : null;
891
+ if (variableValue && variableValue.startsWith('"') && variableValue.endsWith('"'))
892
+ variableValue = variableValue.substring(1, variableValue.length - 1);
893
+ if (stack.includes(variable))
894
+ throw new Error(`Circular reference detected in view where clause for variable ${variable}`);
895
+ else
896
+ stack.push(variable);
897
+ const innerViewEntity = variableValue ? await ViewInfo.GetViewEntity(variableValue, user) : null;
898
+ if (innerViewEntity) {
899
+ const innerWhere = await this.RenderViewWhereClause(innerViewEntity, user, stack);
900
+ const innerSQL = `SELECT ${this.QuoteIdentifier(innerViewEntity.ViewEntityInfo.FirstPrimaryKey.Name)} FROM ${this.QuoteSchemaAndView(innerViewEntity.ViewEntityInfo.SchemaName, innerViewEntity.ViewEntityInfo.BaseView)} WHERE (${innerWhere})`;
901
+ sWhere = sWhere.replace(match, innerSQL);
902
+ }
903
+ else
904
+ throw new Error(`View ID ${variableValue} not found in metadata`);
905
+ }
906
+ else {
907
+ throw new Error(`Unknown variable ${variableName} as part of template match ${match} in view where clause`);
908
+ }
909
+ }
910
+ }
911
+ }
912
+ return sWhere;
913
+ }
914
+ catch (e) {
915
+ LogError(e);
916
+ throw e;
917
+ }
918
+ }
919
+ /**************************************************************************/
920
+ // Cache Check Utilities
921
+ /**************************************************************************/
922
+ /**
923
+ * Compares client cache status with server status to determine if cache is current.
924
+ * Checks both row count and maxUpdatedAt timestamp.
925
+ */
926
+ isCacheCurrent(clientStatus, serverStatus) {
927
+ if (clientStatus.rowCount !== serverStatus.rowCount)
928
+ return false;
929
+ const clientDate = new Date(clientStatus.maxUpdatedAt);
930
+ const serverDate = serverStatus.maxUpdatedAt ? new Date(serverStatus.maxUpdatedAt) : null;
931
+ if (!serverDate)
932
+ return clientStatus.rowCount === 0;
933
+ return clientDate.toISOString() === serverDate.toISOString();
934
+ }
935
+ /**************************************************************************/
936
+ // RunViewsWithCacheCheck — Shared Implementation
937
+ /**************************************************************************/
938
+ /**
939
+ * Smart cache validation for batch RunViews.
940
+ * For each view request, if cacheStatus is provided, checks if the cache is current
941
+ * by comparing MAX(__mj_UpdatedAt) and COUNT(*) with client's values.
942
+ * Returns 'current' if cache is valid (no data), 'stale' with fresh data, or 'differential'
943
+ * with only changed rows for entities that track record changes.
944
+ */
945
+ async RunViewsWithCacheCheck(params, contextUser) {
946
+ try {
947
+ const user = contextUser || this.CurrentUser;
948
+ if (!user) {
949
+ return { success: false, results: [], errorMessage: 'No user context available' };
950
+ }
951
+ // Separate items that need cache check from those that don't
952
+ const itemsNeedingCacheCheck = [];
953
+ const itemsWithoutCacheCheck = [];
954
+ const errorResults = [];
955
+ for (let i = 0; i < params.length; i++) {
956
+ const item = params[i];
957
+ if (!item.cacheStatus) {
958
+ itemsWithoutCacheCheck.push({ index: i, item });
959
+ continue;
960
+ }
961
+ const entityInfo = this.Entities.find((e) => e.Name.trim().toLowerCase() === item.params.EntityName?.trim().toLowerCase());
962
+ if (!entityInfo) {
963
+ errorResults.push({ viewIndex: i, status: 'error', errorMessage: `Entity ${item.params.EntityName} not found in metadata` });
964
+ continue;
965
+ }
966
+ try {
967
+ this.CheckUserReadPermissions(entityInfo.Name, user);
968
+ const whereSQL = await this.buildWhereClauseForCacheCheck(item.params, entityInfo, user);
969
+ itemsNeedingCacheCheck.push({ index: i, item, entityInfo, whereSQL });
970
+ }
971
+ catch (e) {
972
+ errorResults.push({ viewIndex: i, status: 'error', errorMessage: e instanceof Error ? e.message : String(e) });
973
+ }
974
+ }
975
+ // Execute batched cache status check
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 = [];
980
+ const currentResults = [];
981
+ for (const { index, item, entityInfo, whereSQL } of itemsNeedingCacheCheck) {
982
+ const serverStatus = cacheStatusResults.get(index);
983
+ if (!serverStatus || !serverStatus.success) {
984
+ errorResults.push({ viewIndex: index, status: 'error', errorMessage: serverStatus?.errorMessage || 'Failed to get cache status' });
985
+ continue;
986
+ }
987
+ if (this.isCacheCurrent(item.cacheStatus, serverStatus)) {
988
+ currentResults.push({ viewIndex: index, status: 'current' });
989
+ }
990
+ else if (entityInfo.TrackRecordChanges) {
991
+ differentialItems.push({
992
+ index, params: item.params, entityInfo, whereSQL,
993
+ clientMaxUpdatedAt: item.cacheStatus.maxUpdatedAt,
994
+ clientRowCount: item.cacheStatus.rowCount,
995
+ serverStatus,
996
+ });
997
+ }
998
+ else {
999
+ staleItemsNoTracking.push({ index, params: item.params });
1000
+ }
1001
+ }
1002
+ // Run queries in parallel
1003
+ const queryPromises = [
1004
+ ...itemsWithoutCacheCheck.map(({ index, item }) => this.runFullQueryAndReturn(item.params, index, contextUser)),
1005
+ ...staleItemsNoTracking.map(({ index, params: viewParams }) => this.runFullQueryAndReturn(viewParams, index, contextUser)),
1006
+ ...differentialItems.map(({ index, params: viewParams, entityInfo, whereSQL, clientMaxUpdatedAt, clientRowCount, serverStatus }) => this.runDifferentialQueryAndReturn(viewParams, entityInfo, clientMaxUpdatedAt, clientRowCount, serverStatus, whereSQL, index, contextUser)),
1007
+ ];
1008
+ const fullQueryResults = await Promise.all(queryPromises);
1009
+ const allResults = [...errorResults, ...currentResults, ...fullQueryResults];
1010
+ allResults.sort((a, b) => a.viewIndex - b.viewIndex);
1011
+ return { success: true, results: allResults };
1012
+ }
1013
+ catch (e) {
1014
+ LogError(e);
1015
+ return { success: false, results: [], errorMessage: e instanceof Error ? e.message : String(e) };
1016
+ }
1017
+ }
1018
+ /**
1019
+ * Builds the WHERE clause for cache status check, using same logic as InternalRunView.
1020
+ * Handles ExtraFilter, UserSearch, and Row-Level Security.
1021
+ * Subclasses can override to add platform-specific SQL transformations (e.g., identifier quoting).
1022
+ */
1023
+ async buildWhereClauseForCacheCheck(params, entityInfo, user) {
1024
+ let whereSQL = '';
1025
+ let bHasWhere = false;
1026
+ const extraFilter = this.TransformExternalSQLClause(params.ExtraFilter || '', entityInfo);
1027
+ if (extraFilter.length > 0) {
1028
+ if (!this.ValidateUserProvidedSQLClause(extraFilter))
1029
+ throw new Error(`Invalid Extra Filter: ${extraFilter}`);
1030
+ whereSQL = `(${extraFilter})`;
1031
+ bHasWhere = true;
1032
+ }
1033
+ if (params.UserSearchString && params.UserSearchString.length > 0) {
1034
+ if (!this.ValidateUserProvidedSQLClause(params.UserSearchString))
1035
+ throw new Error(`Invalid User Search SQL clause: ${params.UserSearchString}`);
1036
+ const sUserSearchSQL = this.createViewUserSearchSQL(entityInfo, params.UserSearchString);
1037
+ if (sUserSearchSQL.length > 0) {
1038
+ whereSQL = bHasWhere ? `${whereSQL} AND (${sUserSearchSQL})` : `(${sUserSearchSQL})`;
1039
+ bHasWhere = true;
1040
+ }
1041
+ }
1042
+ if (!entityInfo.UserExemptFromRowLevelSecurity(user, EntityPermissionType.Read)) {
1043
+ const rlsWhereClause = entityInfo.GetUserRowLevelSecurityWhereClause(user, EntityPermissionType.Read, '');
1044
+ if (rlsWhereClause && rlsWhereClause.length > 0) {
1045
+ whereSQL = bHasWhere ? `${whereSQL} AND (${rlsWhereClause})` : `(${rlsWhereClause})`;
1046
+ }
1047
+ }
1048
+ return whereSQL;
1049
+ }
1050
+ /**
1051
+ * Executes cache status checks for multiple views.
1052
+ * Default: parallel individual queries (works on all platforms).
1053
+ * SQL Server overrides to use ExecuteSQLBatch for multi-result-set efficiency.
1054
+ */
1055
+ async getBatchedServerCacheStatus(items, contextUser) {
1056
+ const results = new Map();
1057
+ if (items.length === 0)
1058
+ return results;
1059
+ const promises = items.map(async ({ index, entityInfo, whereSQL }) => {
1060
+ try {
1061
+ const statusSQL = `SELECT COUNT(*) AS ${this.QuoteIdentifier('TotalRows')}, MAX(${this.QuoteIdentifier('__mj_UpdatedAt')}) AS ${this.QuoteIdentifier('MaxUpdatedAt')} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.BaseView)}${whereSQL ? ' WHERE ' + whereSQL : ''}`;
1062
+ const rows = await this.ExecuteSQL(statusSQL, undefined, undefined, contextUser);
1063
+ if (rows && rows.length > 0) {
1064
+ const row = rows[0];
1065
+ results.set(index, {
1066
+ success: true,
1067
+ rowCount: Number(row['TotalRows']),
1068
+ maxUpdatedAt: row['MaxUpdatedAt'] ? new Date(String(row['MaxUpdatedAt'])).toISOString() : undefined,
1069
+ });
1070
+ }
1071
+ else {
1072
+ results.set(index, { success: true, rowCount: 0, maxUpdatedAt: undefined });
1073
+ }
1074
+ }
1075
+ catch (e) {
1076
+ results.set(index, { success: false, errorMessage: e instanceof Error ? e.message : String(e) });
1077
+ }
1078
+ });
1079
+ await Promise.all(promises);
1080
+ return results;
1081
+ }
1082
+ /**
1083
+ * Runs a full view query and returns results with cache metadata.
1084
+ */
1085
+ async runFullQueryAndReturn(params, viewIndex, contextUser) {
1086
+ const result = await this.InternalRunView(params, contextUser);
1087
+ if (!result.Success) {
1088
+ return { viewIndex, status: 'error', errorMessage: result.ErrorMessage || 'Unknown error executing view' };
1089
+ }
1090
+ const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
1091
+ return { viewIndex, status: 'stale', results: result.Results, maxUpdatedAt, rowCount: result.Results.length };
1092
+ }
1093
+ /**
1094
+ * Runs a differential query and returns only changes since the client's cached state.
1095
+ * Includes updated/created rows and deleted record IDs.
1096
+ * Falls back to full query if hidden deletes are detected.
1097
+ */
1098
+ async runDifferentialQueryAndReturn(params, entityInfo, clientMaxUpdatedAt, clientRowCount, serverStatus, whereSQL, viewIndex, contextUser) {
1099
+ try {
1100
+ const updatedRows = await this.getUpdatedRowsSince(params, entityInfo, clientMaxUpdatedAt, whereSQL, contextUser);
1101
+ const deletedRecordIDs = await this.getDeletedRecordIDsSince(entityInfo.ID, clientMaxUpdatedAt, contextUser);
1102
+ // Validation: detect hidden deletes not tracked in RecordChanges
1103
+ const clientMaxUpdatedDate = new Date(clientMaxUpdatedAt);
1104
+ const newInserts = updatedRows.filter(row => {
1105
+ const createdAt = row['__mj_CreatedAt'];
1106
+ if (!createdAt)
1107
+ return false;
1108
+ return new Date(String(createdAt)) > clientMaxUpdatedDate;
1109
+ }).length;
1110
+ const serverRowCount = serverStatus.rowCount ?? 0;
1111
+ const impliedDeletes = clientRowCount + newInserts - serverRowCount;
1112
+ const actualDeletes = deletedRecordIDs.length;
1113
+ if (impliedDeletes < 0) {
1114
+ LogStatus(`Differential validation failed for ${entityInfo.Name}: impliedDeletes=${impliedDeletes} (negative). Falling back to full refresh.`);
1115
+ return this.runFullQueryAndReturn(params, viewIndex, contextUser);
1116
+ }
1117
+ if (impliedDeletes > actualDeletes) {
1118
+ LogStatus(`Differential validation failed for ${entityInfo.Name}: hidden deletes detected (implied=${impliedDeletes}, actual=${actualDeletes}). Falling back to full refresh.`);
1119
+ return this.runFullQueryAndReturn(params, viewIndex, contextUser);
1120
+ }
1121
+ const newMaxUpdatedAt = updatedRows.length > 0
1122
+ ? this.extractMaxUpdatedAt(updatedRows)
1123
+ : serverStatus.maxUpdatedAt || new Date().toISOString();
1124
+ return {
1125
+ viewIndex,
1126
+ status: 'differential',
1127
+ differentialData: { updatedRows, deletedRecordIDs },
1128
+ maxUpdatedAt: newMaxUpdatedAt,
1129
+ rowCount: serverStatus.rowCount,
1130
+ };
1131
+ }
1132
+ catch (e) {
1133
+ LogError(e);
1134
+ return { viewIndex, status: 'error', errorMessage: e instanceof Error ? e.message : String(e) };
1135
+ }
1136
+ }
1137
+ /**
1138
+ * Gets IDs of records deleted since a given timestamp.
1139
+ * Uses dialect-neutral quoting. Subclasses can override for parameterized queries.
1140
+ */
1141
+ async getDeletedRecordIDsSince(entityID, sinceTimestamp, contextUser) {
1142
+ try {
1143
+ const sql = `SELECT DISTINCT ${this.QuoteIdentifier('RecordID')} FROM ${this.QuoteSchemaAndView(this.MJCoreSchemaName, 'vwRecordChanges')} WHERE ${this.QuoteIdentifier('EntityID')} = '${entityID}' AND ${this.QuoteIdentifier('Type')} = 'Delete' AND ${this.QuoteIdentifier('ChangedAt')} > '${sinceTimestamp}'`;
1144
+ const results = await this.ExecuteSQL(sql, undefined, undefined, contextUser);
1145
+ return results.map(r => String(r['RecordID']));
1146
+ }
1147
+ catch (e) {
1148
+ LogError(e);
1149
+ return [];
1150
+ }
1151
+ }
1152
+ /**
1153
+ * Gets rows updated/created since a given timestamp.
1154
+ * Uses dialect-neutral quoting and TransformExternalSQLClause for OrderBy.
1155
+ */
1156
+ async getUpdatedRowsSince(params, entityInfo, sinceTimestamp, whereSQL, contextUser) {
1157
+ try {
1158
+ const timestampFilter = `${this.QuoteIdentifier('__mj_UpdatedAt')} > '${sinceTimestamp}'`;
1159
+ const combinedWhere = whereSQL
1160
+ ? `(${whereSQL}) AND ${timestampFilter}`
1161
+ : timestampFilter;
1162
+ const fields = params.Fields && params.Fields.length > 0
1163
+ ? params.Fields.map(f => this.QuoteIdentifier(f)).join(', ')
1164
+ : '*';
1165
+ let sql = `SELECT ${fields} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.BaseView)} WHERE ${combinedWhere}`;
1166
+ const orderBy = this.TransformExternalSQLClause(params.OrderBy || '', entityInfo);
1167
+ if (orderBy.length > 0) {
1168
+ if (!this.ValidateUserProvidedSQLClause(orderBy))
1169
+ throw new Error(`Invalid OrderBy clause: ${orderBy}`);
1170
+ sql += ` ORDER BY ${orderBy}`;
1171
+ }
1172
+ return await this.ExecuteSQL(sql, undefined, undefined, contextUser);
1173
+ }
1174
+ catch (e) {
1175
+ LogError(e);
1176
+ return [];
1177
+ }
1178
+ }
1179
+ /**************************************************************************/
1180
+ // RunQueriesWithCacheCheck — Shared Implementation
1181
+ /**************************************************************************/
1182
+ /**
1183
+ * Smart cache validation for batch RunQueries.
1184
+ * For each query, if cacheStatus is provided, checks CacheValidationSQL to determine staleness.
1185
+ * Returns 'current' if cache is valid, 'stale' with fresh data, or 'no_validation' if
1186
+ * the query has no CacheValidationSQL configured.
1187
+ */
1188
+ async RunQueriesWithCacheCheck(params, contextUser) {
1189
+ try {
1190
+ const user = contextUser || this.CurrentUser;
1191
+ if (!user) {
1192
+ return { success: false, results: [], errorMessage: 'No user context available' };
1193
+ }
1194
+ const itemsNeedingCacheCheck = [];
1195
+ const itemsWithoutCacheCheck = [];
1196
+ const itemsWithoutValidationSQL = [];
1197
+ const errorResults = [];
1198
+ for (let i = 0; i < params.length; i++) {
1199
+ const item = params[i];
1200
+ const queryInfo = this.resolveQueryInfo(item.params);
1201
+ if (!queryInfo) {
1202
+ errorResults.push({ queryIndex: i, queryId: item.params.QueryID || '', status: 'error', errorMessage: `Query not found: ${item.params.QueryID || item.params.QueryName}` });
1203
+ continue;
1204
+ }
1205
+ if (!queryInfo.UserCanRun(user)) {
1206
+ errorResults.push({ queryIndex: i, queryId: queryInfo.ID, status: 'error', errorMessage: `User does not have permission to run query: ${queryInfo.Name}` });
1207
+ continue;
1208
+ }
1209
+ if (!item.cacheStatus) {
1210
+ itemsWithoutCacheCheck.push({ index: i, item });
1211
+ continue;
1212
+ }
1213
+ if (!queryInfo.CacheValidationSQL) {
1214
+ itemsWithoutValidationSQL.push({ index: i, item, queryInfo });
1215
+ continue;
1216
+ }
1217
+ itemsNeedingCacheCheck.push({ index: i, item, queryInfo });
1218
+ }
1219
+ const cacheStatusResults = await this.getBatchedQueryCacheStatus(itemsNeedingCacheCheck, contextUser);
1220
+ const staleItems = [];
1221
+ const currentResults = [];
1222
+ for (const { index, item, queryInfo } of itemsNeedingCacheCheck) {
1223
+ const serverStatus = cacheStatusResults.get(index);
1224
+ if (!serverStatus || !serverStatus.success) {
1225
+ errorResults.push({ queryIndex: index, queryId: queryInfo.ID, status: 'error', errorMessage: serverStatus?.errorMessage || 'Failed to get cache status' });
1226
+ continue;
1227
+ }
1228
+ if (this.isCacheCurrent(item.cacheStatus, serverStatus)) {
1229
+ currentResults.push({ queryIndex: index, queryId: queryInfo.ID, status: 'current' });
1230
+ }
1231
+ else {
1232
+ staleItems.push({ index, params: item.params, queryInfo });
1233
+ }
1234
+ }
1235
+ const fullQueryPromises = [
1236
+ ...itemsWithoutCacheCheck.map(({ index, item }) => this.runFullQueryAndReturnForQuery(item.params, index, 'stale', contextUser)),
1237
+ ...itemsWithoutValidationSQL.map(({ index, item, queryInfo }) => this.runFullQueryAndReturnForQuery(item.params, index, 'no_validation', contextUser, queryInfo.ID)),
1238
+ ...staleItems.map(({ index, params: queryParams, queryInfo }) => this.runFullQueryAndReturnForQuery(queryParams, index, 'stale', contextUser, queryInfo.ID)),
1239
+ ];
1240
+ const fullQueryResults = await Promise.all(fullQueryPromises);
1241
+ const allResults = [...errorResults, ...currentResults, ...fullQueryResults];
1242
+ allResults.sort((a, b) => a.queryIndex - b.queryIndex);
1243
+ return { success: true, results: allResults };
1244
+ }
1245
+ catch (e) {
1246
+ LogError(e);
1247
+ return { success: false, results: [], errorMessage: e instanceof Error ? e.message : String(e) };
1248
+ }
1249
+ }
1250
+ /**
1251
+ * Resolves QueryInfo from RunQueryParams (by ID or Name+CategoryPath).
1252
+ * Tries QueryEngine first for fresh data, falls back to ProviderBase cache.
1253
+ */
1254
+ resolveQueryInfo(params) {
1255
+ const freshEntity = this.findQueryInEngine(params.QueryID, params.QueryName, params.CategoryID, params.CategoryPath);
1256
+ if (freshEntity)
1257
+ return this.refreshQueryInfoFromEntity(freshEntity);
1258
+ if (params.QueryID)
1259
+ return this.Queries.find(q => UUIDsEqual(q.ID, params.QueryID));
1260
+ if (params.QueryName) {
1261
+ const matchingQueries = this.Queries.filter(q => q.Name.trim().toLowerCase() === params.QueryName?.trim().toLowerCase());
1262
+ if (matchingQueries.length === 0)
1263
+ return undefined;
1264
+ if (matchingQueries.length === 1)
1265
+ return matchingQueries[0];
1266
+ if (params.CategoryPath) {
1267
+ const byPath = matchingQueries.find(q => q.CategoryPath.toLowerCase() === params.CategoryPath?.toLowerCase());
1268
+ if (byPath)
1269
+ return byPath;
1270
+ }
1271
+ if (params.CategoryID) {
1272
+ const byId = matchingQueries.find(q => UUIDsEqual(q.CategoryID, params.CategoryID));
1273
+ if (byId)
1274
+ return byId;
1275
+ }
1276
+ return matchingQueries[0];
1277
+ }
1278
+ return undefined;
1279
+ }
1280
+ /**
1281
+ * Searches QueryEngine for a fresh query entity.
1282
+ */
1283
+ findQueryInEngine(QueryID, QueryName, CategoryID, CategoryPath) {
1284
+ const engineQueries = QueryEngine.Instance?.Queries;
1285
+ if (!engineQueries || engineQueries.length === 0)
1286
+ return null;
1287
+ if (QueryID) {
1288
+ const lower = QueryID.trim().toLowerCase();
1289
+ return engineQueries.find(q => q.ID.trim().toLowerCase() === lower) ?? null;
1290
+ }
1291
+ if (QueryName) {
1292
+ const lowerName = QueryName.trim().toLowerCase();
1293
+ const matches = engineQueries.filter(q => q.Name.trim().toLowerCase() === lowerName);
1294
+ if (matches.length === 0)
1295
+ return null;
1296
+ if (matches.length === 1)
1297
+ return matches[0];
1298
+ if (CategoryID) {
1299
+ const byId = matches.find(q => q.CategoryID?.trim().toLowerCase() === CategoryID.trim().toLowerCase());
1300
+ if (byId)
1301
+ return byId;
1302
+ }
1303
+ if (CategoryPath) {
1304
+ const resolvedCategoryId = this.resolveCategoryPath(CategoryPath);
1305
+ if (resolvedCategoryId) {
1306
+ const byPath = matches.find(q => UUIDsEqual(q.CategoryID, resolvedCategoryId));
1307
+ if (byPath)
1308
+ return byPath;
1309
+ }
1310
+ }
1311
+ return matches[0];
1312
+ }
1313
+ return null;
1314
+ }
1315
+ /**
1316
+ * Creates a fresh QueryInfo from a MJQueryEntity and patches the ProviderBase cache.
1317
+ */
1318
+ refreshQueryInfoFromEntity(entity) {
1319
+ const freshInfo = new QueryInfo(entity.GetAll());
1320
+ const existingIndex = this.Queries.findIndex(q => UUIDsEqual(q.ID, freshInfo.ID));
1321
+ if (existingIndex >= 0) {
1322
+ this.Queries[existingIndex] = freshInfo;
1323
+ }
1324
+ else {
1325
+ this.Queries.push(freshInfo);
1326
+ }
1327
+ return freshInfo;
1328
+ }
1329
+ /**
1330
+ * Resolves a category path string to a QueryCategoryInfo ID.
1331
+ */
1332
+ resolveCategoryPath(categoryPath) {
1333
+ if (!categoryPath)
1334
+ return null;
1335
+ const segments = categoryPath.split('/').map(s => s.trim()).filter(s => s.length > 0);
1336
+ if (segments.length === 0)
1337
+ return null;
1338
+ let currentCategory = null;
1339
+ for (const segment of segments) {
1340
+ const parentId = currentCategory !== null ? currentCategory.ID : null;
1341
+ currentCategory = this.QueryCategories.find(cat => cat.Name.trim().toLowerCase() === segment.toLowerCase() && UUIDsEqual(cat.ParentID, parentId)) ?? null;
1342
+ if (!currentCategory)
1343
+ return null;
1344
+ }
1345
+ return currentCategory?.ID || null;
1346
+ }
1347
+ /**
1348
+ * Executes cache status checks for multiple queries using their CacheValidationSQL.
1349
+ * Default: parallel individual queries. SQL Server overrides for batch execution.
1350
+ */
1351
+ async getBatchedQueryCacheStatus(items, contextUser) {
1352
+ const results = new Map();
1353
+ if (items.length === 0)
1354
+ return results;
1355
+ const promises = items.map(async ({ index, queryInfo }) => {
1356
+ try {
1357
+ const rows = await this.ExecuteSQL(queryInfo.CacheValidationSQL, undefined, undefined, contextUser);
1358
+ if (rows && rows.length > 0) {
1359
+ const row = rows[0];
1360
+ results.set(index, {
1361
+ success: true,
1362
+ rowCount: Number(row['RowCount']),
1363
+ maxUpdatedAt: row['MaxUpdatedAt'] ? new Date(String(row['MaxUpdatedAt'])).toISOString() : undefined,
1364
+ });
1365
+ }
1366
+ else {
1367
+ results.set(index, { success: true, rowCount: 0, maxUpdatedAt: undefined });
1368
+ }
1369
+ }
1370
+ catch (e) {
1371
+ results.set(index, { success: false, errorMessage: e instanceof Error ? e.message : String(e) });
1372
+ }
1373
+ });
1374
+ await Promise.all(promises);
1375
+ return results;
1376
+ }
1377
+ /**
1378
+ * Runs a full query and returns results with cache metadata.
1379
+ */
1380
+ async runFullQueryAndReturnForQuery(params, queryIndex, status, contextUser, queryId) {
1381
+ const result = await this.InternalRunQuery(params, contextUser);
1382
+ if (!result.Success) {
1383
+ return {
1384
+ queryIndex,
1385
+ queryId: queryId || result.QueryID || '',
1386
+ status: 'error',
1387
+ errorMessage: result.ErrorMessage || 'Unknown error executing query',
1388
+ };
1389
+ }
1390
+ const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
1391
+ return {
1392
+ queryIndex,
1393
+ queryId: result.QueryID,
1394
+ status,
1395
+ results: result.Results,
1396
+ maxUpdatedAt,
1397
+ rowCount: result.Results.length,
1398
+ };
1399
+ }
1400
+ /**************************************************************************/
1401
+ // Load — Shared Implementation
1402
+ /**************************************************************************/
1403
+ /**
1404
+ * Loads a single entity record by composite key, with optional relationship loading.
1405
+ * Uses dialect-neutral quoting for all SQL construction.
1406
+ */
1407
+ async Load(entity, compositeKey, entityRelationshipsToLoad = null, user) {
1408
+ const entityInfo = entity.EntityInfo;
1409
+ // Build WHERE from composite key
1410
+ const where = compositeKey.KeyValuePairs.map(val => {
1411
+ const pk = entityInfo.PrimaryKeys.find(p => p.Name.trim().toLowerCase() === val.FieldName.trim().toLowerCase());
1412
+ if (!pk)
1413
+ throw new Error(`Primary key ${val.FieldName} not found in entity ${entityInfo.Name}`);
1414
+ const quotes = pk.NeedsQuotes ? "'" : '';
1415
+ return `${this.QuoteIdentifier(pk.CodeName)}=${quotes}${val.Value}${quotes}`;
1416
+ }).join(' AND ');
1417
+ const sql = `SELECT * FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.BaseView)} WHERE ${where}`;
1418
+ const rawData = await this.ExecuteSQL(sql, undefined, undefined, user);
1419
+ const d = await this.PostProcessRows(rawData, entityInfo, user);
1420
+ if (d && d.length > 0) {
1421
+ const ret = d[0];
1422
+ // Trim trailing spaces for fixed-width char fields (char/nchar pad with spaces on both SQL Server and PG)
1423
+ for (const field of entityInfo.Fields) {
1424
+ if (field.TSType === EntityFieldTSType.String &&
1425
+ field.Type.toLowerCase().includes('char') &&
1426
+ !field.Type.toLowerCase().includes('varchar')) {
1427
+ const val = ret[field.Name];
1428
+ if (typeof val === 'string')
1429
+ ret[field.Name] = val.trimEnd();
1430
+ }
1431
+ }
1432
+ // Load entity relationships if requested
1433
+ if (entityRelationshipsToLoad && entityRelationshipsToLoad.length > 0) {
1434
+ for (const rel of entityRelationshipsToLoad) {
1435
+ const relInfo = entityInfo.RelatedEntities.find(r => r.RelatedEntity === rel);
1436
+ if (!relInfo)
1437
+ continue;
1438
+ const relEntityInfo = this.Entities.find(e => e.Name.trim().toLowerCase() === relInfo.RelatedEntity.trim().toLowerCase());
1439
+ if (!relEntityInfo)
1440
+ continue;
1441
+ const quotes = entity.FirstPrimaryKey.NeedsQuotes ? "'" : '';
1442
+ const pkValue = ret[entity.FirstPrimaryKey.Name];
1443
+ let relSql;
1444
+ if (relInfo.Type.trim().toLowerCase() === 'one to many') {
1445
+ relSql = `SELECT * FROM ${this.QuoteSchemaAndView(relEntityInfo.SchemaName, relInfo.RelatedEntityBaseView)} WHERE ${this.QuoteIdentifier(relInfo.RelatedEntityJoinField)} = ${quotes}${pkValue}${quotes}`;
1446
+ }
1447
+ else {
1448
+ // many to many — use join view
1449
+ relSql = `SELECT _theview.* FROM ${this.QuoteSchemaAndView(relEntityInfo.SchemaName, relInfo.RelatedEntityBaseView)} _theview INNER JOIN ${this.QuoteSchemaAndView(relEntityInfo.SchemaName, relInfo.JoinView)} _jv ON _theview.${this.QuoteIdentifier(relInfo.RelatedEntityJoinField)} = _jv.${this.QuoteIdentifier(relInfo.JoinEntityInverseJoinField)} WHERE _jv.${this.QuoteIdentifier(relInfo.JoinEntityJoinField)} = ${quotes}${pkValue}${quotes}`;
1450
+ }
1451
+ const rawRelData = await this.ExecuteSQL(relSql, undefined, undefined, user);
1452
+ if (rawRelData && rawRelData.length > 0) {
1453
+ ret[rel] = await this.PostProcessRows(rawRelData, relEntityInfo, user);
1454
+ }
1455
+ }
1456
+ }
1457
+ return ret;
1458
+ }
1459
+ return null;
1460
+ }
1461
+ /**************************************************************************/
1462
+ // GetDatasetByName — Shared Implementation
1463
+ /**************************************************************************/
1464
+ /**
1465
+ * Builds a parameter placeholder for parameterized queries.
1466
+ * Default: PG-style ($1, $2, ...). SQL Server overrides to @p0, @p1, etc.
1467
+ */
1468
+ BuildParameterPlaceholder(index) {
1469
+ return `$${index + 1}`;
1470
+ }
1471
+ /**
1472
+ * Retrieves a dataset by name, executing all item queries via ExecuteSQLBatch
1473
+ * and aggregating results. Uses dialect-neutral quoting for all SQL construction.
1474
+ *
1475
+ * ExecuteSQLBatch gives SQL Server true multi-result-set batching automatically,
1476
+ * while PG (and the default) use parallel individual queries.
1477
+ */
1478
+ async GetDatasetByName(datasetName, itemFilters, contextUser, providerToUse) {
1479
+ const provider = (providerToUse ?? this);
1480
+ const schema = provider.MJCoreSchemaName;
1481
+ // Build metadata SQL with dialect-neutral quoting
1482
+ const sSQL = `SELECT di.*, ` +
1483
+ `e.${provider.QuoteIdentifier('BaseView')} AS ${provider.QuoteIdentifier('EntityBaseView')}, ` +
1484
+ `e.${provider.QuoteIdentifier('SchemaName')} AS ${provider.QuoteIdentifier('EntitySchemaName')}, ` +
1485
+ `di.${provider.QuoteIdentifier('__mj_UpdatedAt')} AS ${provider.QuoteIdentifier('DatasetItemUpdatedAt')}, ` +
1486
+ `d.${provider.QuoteIdentifier('__mj_UpdatedAt')} AS ${provider.QuoteIdentifier('DatasetUpdatedAt')} ` +
1487
+ `FROM ${provider.QuoteSchemaAndView(schema, 'vwDatasets')} d ` +
1488
+ `INNER JOIN ${provider.QuoteSchemaAndView(schema, 'vwDatasetItems')} di ON d.${provider.QuoteIdentifier('ID')} = di.${provider.QuoteIdentifier('DatasetID')} ` +
1489
+ `INNER JOIN ${provider.QuoteSchemaAndView(schema, 'vwEntities')} e ON di.${provider.QuoteIdentifier('EntityID')} = e.${provider.QuoteIdentifier('ID')} ` +
1490
+ `WHERE d.${provider.QuoteIdentifier('Name')} = ${provider.BuildParameterPlaceholder(0)}`;
1491
+ const items = await provider.ExecuteSQL(sSQL, [datasetName], undefined, contextUser);
1492
+ if (!items || items.length === 0) {
1493
+ return {
1494
+ DatasetID: '',
1495
+ DatasetName: datasetName,
1496
+ Success: false,
1497
+ Status: 'No Dataset or Items found for DatasetName: ' + datasetName,
1498
+ LatestUpdateDate: new Date(0),
1499
+ Results: [],
1500
+ };
1501
+ }
1502
+ // Phase 1: Build all item SQL queries
1503
+ const queries = [];
1504
+ const validItems = [];
1505
+ const errorResults = [];
1506
+ for (const item of items) {
1507
+ const entitySchemaName = String(item['EntitySchemaName'] ?? schema);
1508
+ const entityBaseView = String(item['EntityBaseView']);
1509
+ const code = String(item['Code']);
1510
+ const entityName = String(item['Entity']);
1511
+ const entityID = String(item['EntityID']);
1512
+ const whereClause = item['WhereClause'] ? String(item['WhereClause']) : '';
1513
+ let filterSQL = '';
1514
+ if (itemFilters && itemFilters.length > 0) {
1515
+ const filter = itemFilters.find(f => f.ItemCode === code);
1516
+ if (filter)
1517
+ filterSQL = (whereClause ? ' AND ' : ' WHERE ') + '(' + filter.Filter + ')';
1518
+ }
1519
+ const columns = provider.getColumnsForDatasetItem(item, datasetName);
1520
+ if (!columns) {
1521
+ errorResults.push({
1522
+ EntityID: entityID,
1523
+ EntityName: entityName,
1524
+ Code: code,
1525
+ Results: [],
1526
+ LatestUpdateDate: undefined,
1527
+ Status: 'Invalid columns specified for dataset item',
1528
+ Success: false,
1529
+ });
1530
+ continue;
1531
+ }
1532
+ queries.push(`SELECT ${columns} FROM ${provider.QuoteSchemaAndView(entitySchemaName, entityBaseView)} ${whereClause ? 'WHERE ' + whereClause : ''}${filterSQL}`);
1533
+ validItems.push(item);
1534
+ }
1535
+ // Phase 2: Execute all queries via ExecuteSQLBatch (true batch on SQL Server, parallel on PG)
1536
+ let batchResults = [];
1537
+ try {
1538
+ batchResults = await provider.ExecuteSQLBatch(queries, undefined, undefined, contextUser);
1539
+ }
1540
+ catch (err) {
1541
+ LogError(`GetDatasetByName: Batch execution failed: ${err instanceof Error ? err.message : String(err)}`);
1542
+ // Fall through with empty results
1543
+ }
1544
+ // Phase 3: Process results per item
1545
+ const results = [...errorResults];
1546
+ for (let i = 0; i < validItems.length; i++) {
1547
+ const item = validItems[i];
1548
+ const entityName = String(item['Entity']);
1549
+ const entityID = String(item['EntityID']);
1550
+ const code = String(item['Code']);
1551
+ const dateFieldToCheck = String(item['DateFieldToCheck'] ?? '__mj_UpdatedAt');
1552
+ let itemData = batchResults[i] || [];
1553
+ // Post-process rows for encryption/datetime
1554
+ if (itemData.length > 0) {
1555
+ const entityInfo = provider.Entities.find(e => e.Name.trim().toLowerCase() === entityName.trim().toLowerCase());
1556
+ if (entityInfo && contextUser) {
1557
+ itemData = await provider.PostProcessRows(itemData, entityInfo, contextUser);
1558
+ }
1559
+ }
1560
+ const itemUpdatedAt = new Date(String(item['DatasetItemUpdatedAt']));
1561
+ const datasetUpdatedAt = new Date(String(item['DatasetUpdatedAt']));
1562
+ const datasetMaxUpdatedAt = new Date(Math.max(itemUpdatedAt.getTime(), datasetUpdatedAt.getTime()));
1563
+ let latestUpdateDate = new Date(1900, 1, 1);
1564
+ if (itemData && itemData.length > 0) {
1565
+ for (const data of itemData) {
1566
+ if (data[dateFieldToCheck] && new Date(String(data[dateFieldToCheck])) > latestUpdateDate) {
1567
+ latestUpdateDate = new Date(String(data[dateFieldToCheck]));
1568
+ }
1569
+ }
1570
+ }
1571
+ if (datasetMaxUpdatedAt > latestUpdateDate)
1572
+ latestUpdateDate = datasetMaxUpdatedAt;
1573
+ results.push({
1574
+ EntityID: entityID,
1575
+ EntityName: entityName,
1576
+ Code: code,
1577
+ Results: itemData,
1578
+ LatestUpdateDate: latestUpdateDate,
1579
+ Success: itemData !== null && itemData !== undefined,
1580
+ });
1581
+ }
1582
+ // Aggregate results
1583
+ const bSuccess = results.every(result => result.Success);
1584
+ const latestUpdateDate = results.reduce((acc, result) => {
1585
+ if (result?.LatestUpdateDate) {
1586
+ const theDate = new Date(result.LatestUpdateDate);
1587
+ if (theDate.getTime() > acc.getTime())
1588
+ return theDate;
1589
+ }
1590
+ return acc;
1591
+ }, new Date(0));
1592
+ return {
1593
+ DatasetID: String(items[0]['DatasetID']),
1594
+ DatasetName: datasetName,
1595
+ Success: bSuccess,
1596
+ Status: '',
1597
+ LatestUpdateDate: latestUpdateDate,
1598
+ Results: results,
1599
+ };
1600
+ }
1601
+ /**************************************************************************/
1602
+ // GetDatasetStatusByName — Shared Implementation
1603
+ /**************************************************************************/
1604
+ /**
1605
+ * Retrieves status information for a dataset by name: per-entity row count and
1606
+ * latest update date. Uses ExecuteSQLBatch for per-item status queries.
1607
+ */
1608
+ async GetDatasetStatusByName(datasetName, itemFilters, contextUser, providerToUse) {
1609
+ const provider = (providerToUse ?? this);
1610
+ const schema = provider.MJCoreSchemaName;
1611
+ // Fetch dataset items metadata
1612
+ const sSQL = `SELECT di.*, ` +
1613
+ `e.${provider.QuoteIdentifier('BaseView')} AS ${provider.QuoteIdentifier('EntityBaseView')}, ` +
1614
+ `e.${provider.QuoteIdentifier('SchemaName')} AS ${provider.QuoteIdentifier('EntitySchemaName')}, ` +
1615
+ `d.${provider.QuoteIdentifier('__mj_UpdatedAt')} AS ${provider.QuoteIdentifier('DatasetUpdatedAt')}, ` +
1616
+ `di.${provider.QuoteIdentifier('__mj_UpdatedAt')} AS ${provider.QuoteIdentifier('DatasetItemUpdatedAt')} ` +
1617
+ `FROM ${provider.QuoteSchemaAndView(schema, 'vwDatasets')} d ` +
1618
+ `INNER JOIN ${provider.QuoteSchemaAndView(schema, 'vwDatasetItems')} di ON d.${provider.QuoteIdentifier('ID')} = di.${provider.QuoteIdentifier('DatasetID')} ` +
1619
+ `INNER JOIN ${provider.QuoteSchemaAndView(schema, 'vwEntities')} e ON di.${provider.QuoteIdentifier('EntityID')} = e.${provider.QuoteIdentifier('ID')} ` +
1620
+ `WHERE d.${provider.QuoteIdentifier('Name')} = ${provider.BuildParameterPlaceholder(0)}`;
1621
+ const items = await provider.ExecuteSQL(sSQL, [datasetName], undefined, contextUser);
1622
+ if (!items || items.length === 0) {
1623
+ return {
1624
+ DatasetID: '',
1625
+ DatasetName: datasetName,
1626
+ Success: false,
1627
+ Status: 'No Dataset or Items found for DatasetName: ' + datasetName,
1628
+ LatestUpdateDate: new Date(0),
1629
+ EntityUpdateDates: [],
1630
+ };
1631
+ }
1632
+ // Build per-item status queries
1633
+ const queries = [];
1634
+ const itemMeta = [];
1635
+ for (const item of items) {
1636
+ const entitySchemaName = String(item['EntitySchemaName'] ?? schema);
1637
+ const entityBaseView = String(item['EntityBaseView']);
1638
+ const entityID = String(item['EntityID']);
1639
+ const entityName = String(item['Entity']);
1640
+ const dateFieldToCheck = String(item['DateFieldToCheck'] ?? '__mj_UpdatedAt');
1641
+ let filterSQL = '';
1642
+ if (itemFilters && itemFilters.length > 0) {
1643
+ const filter = itemFilters.find(f => f.ItemCode === String(item['Code']));
1644
+ if (filter)
1645
+ filterSQL = ' WHERE ' + filter.Filter;
1646
+ }
1647
+ const itemUpdatedAt = new Date(String(item['DatasetItemUpdatedAt']));
1648
+ const datasetUpdatedAt = new Date(String(item['DatasetUpdatedAt']));
1649
+ const datasetMaxUpdatedAt = new Date(Math.max(itemUpdatedAt.getTime(), datasetUpdatedAt.getTime())).toISOString();
1650
+ const statusSQL = `SELECT ` +
1651
+ `CASE ` +
1652
+ `WHEN MAX(${provider.QuoteIdentifier(dateFieldToCheck)}) > '${datasetMaxUpdatedAt}' THEN MAX(${provider.QuoteIdentifier(dateFieldToCheck)}) ` +
1653
+ `ELSE '${datasetMaxUpdatedAt}' ` +
1654
+ `END AS ${provider.QuoteIdentifier('UpdateDate')}, ` +
1655
+ `COUNT(*) AS ${provider.QuoteIdentifier('TheRowCount')} ` +
1656
+ `FROM ${provider.QuoteSchemaAndView(entitySchemaName, entityBaseView)}${filterSQL}`;
1657
+ queries.push(statusSQL);
1658
+ itemMeta.push({ entityID, entityName, datasetMaxUpdatedAt });
1659
+ }
1660
+ // Execute all status queries via ExecuteSQLBatch
1661
+ let batchResults = [];
1662
+ try {
1663
+ batchResults = await provider.ExecuteSQLBatch(queries, undefined, undefined, contextUser);
1664
+ }
1665
+ catch (err) {
1666
+ LogError(`GetDatasetStatusByName: Batch execution failed: ${err instanceof Error ? err.message : String(err)}`);
1667
+ }
1668
+ // Process results
1669
+ const updateDates = [];
1670
+ let overallLatestDate = new Date(1900, 1, 1);
1671
+ for (let i = 0; i < itemMeta.length; i++) {
1672
+ const meta = itemMeta[i];
1673
+ const statusRows = batchResults[i];
1674
+ if (statusRows && statusRows.length > 0) {
1675
+ const updateDate = new Date(String(statusRows[0]['UpdateDate']));
1676
+ updateDates.push({
1677
+ EntityID: meta.entityID,
1678
+ EntityName: meta.entityName,
1679
+ RowCount: Number(statusRows[0]['TheRowCount']),
1680
+ UpdateDate: updateDate,
1681
+ });
1682
+ if (updateDate > overallLatestDate) {
1683
+ overallLatestDate = updateDate;
1684
+ }
1685
+ }
1686
+ }
1687
+ if (updateDates.length === 0) {
1688
+ return {
1689
+ DatasetID: String(items[0]['DatasetID']),
1690
+ DatasetName: datasetName,
1691
+ Success: false,
1692
+ Status: 'No update dates found for DatasetName: ' + datasetName,
1693
+ LatestUpdateDate: new Date(0),
1694
+ EntityUpdateDates: [],
1695
+ };
1696
+ }
1697
+ return {
1698
+ DatasetID: String(items[0]['DatasetID']),
1699
+ DatasetName: datasetName,
1700
+ Success: true,
1701
+ Status: '',
1702
+ LatestUpdateDate: overallLatestDate,
1703
+ EntityUpdateDates: updateDates,
1704
+ };
1705
+ }
1706
+ /**************************************************************************/
1707
+ // getColumnsForDatasetItem — Column Validation
1708
+ /**************************************************************************/
1709
+ /**
1710
+ * Validates columns for a dataset item and returns the column list string.
1711
+ * Returns null if columns are invalid.
1712
+ */
1713
+ getColumnsForDatasetItem(item, datasetName) {
1714
+ const specifiedColumns = item['Columns'] ? String(item['Columns']).split(',').map(col => col.trim()) : [];
1715
+ if (specifiedColumns.length > 0) {
1716
+ const entity = this.Entities.find(e => UUIDsEqual(e.ID, item['EntityID']));
1717
+ if (!entity && this.Entities.length > 0) {
1718
+ LogError(`Entity not found for dataset item ${item['Code']} in dataset ${datasetName}`);
1719
+ return null;
1720
+ }
1721
+ if (entity) {
1722
+ const invalidColumns = [];
1723
+ specifiedColumns.forEach(col => {
1724
+ if (!entity.Fields.find(f => f.Name.trim().toLowerCase() === col.trim().toLowerCase())) {
1725
+ invalidColumns.push(col);
1726
+ }
1727
+ });
1728
+ if (invalidColumns.length > 0) {
1729
+ LogError(`Invalid columns specified for dataset item ${item['Code']} in dataset ${datasetName}: ${invalidColumns.join(', ')}`);
1730
+ return null;
1731
+ }
1732
+ }
1733
+ // Ensure DateFieldToCheck is included
1734
+ const dateField = item['DateFieldToCheck'] ? String(item['DateFieldToCheck']).trim() : '';
1735
+ if (dateField.length > 0 && specifiedColumns.indexOf(dateField) === -1) {
1736
+ if (!entity || entity.Fields.find(f => f.Name.trim().toLowerCase() === dateField.toLowerCase()))
1737
+ specifiedColumns.push(dateField);
1738
+ }
1739
+ }
1740
+ return specifiedColumns.length > 0 ? specifiedColumns.map(col => this.QuoteIdentifier(col.trim())).join(',') : '*';
1741
+ }
1742
+ }
1743
+ //# sourceMappingURL=GenericDatabaseProvider.js.map