using System.Collections.Concurrent; using System.Data; using System.Reflection; using System.Text.RegularExpressions; using GB5Shared.DTO.Report; using Microsoft.Extensions.Logging; namespace GB5Shared.Export { // Evaluates MREPORTVSFIELDS.COMPUTEEXPRESSION formulas per row -- a report field whose value is // NOT returned by the DAL's SQL, but computed from other already-returned fields (e.g. // "IIf(Status=0,'Pending',IIf(Status=1,'Active','Cancelled'))"). Structural sibling of // ReportSubRowResolver.cs: one opt-in catalog column (COMPUTEEXPRESSION), one reusable // resolver, callable from both the grid path (BaseReportEndpoint.cs) and the export path // (ReportExport.cs). // // Mechanism: System.Data.DataTable + a computed DataColumn (DataColumn.Expression) -- the same // proven-safe mechanism already used for single-aggregate-value formulas in // RatioAnalysisReportDAL.cs and KPIEvaluationBLL.cs (both documented there: DataTable.Compute // only accepts AGGREGATE expressions and throws on plain per-row arithmetic; a computed // DataColumn is the correct API for row-level expressions). Generalized here to a rolling // single-row table -- one DataTable built once per report response, its single DataRow replaced // for every row -- which keeps the export path's IAsyncEnumerable streaming contract intact // (a 200k-row register export never materializes all rows at once) while producing identical // values to a genuinely multi-row table, since row-level expressions have no cross-row context. // // Officially documented DataColumn.Expression functions are exactly: Len, IsNull, IIf, Convert, // Trim, Substring (https://learn.microsoft.com/dotnet/api/system.data.datacolumn.expression). // Author computed expressions using only these -- e.g. ABS(x) is written as // "IIf(x < 0, -x, x)", not "Abs(x)". SQL-style "CASE WHEN...END" is NOT DataColumn.Expression // syntax and is rejected at build time with an actionable message pointing at nested IIf(...). // // Fail-soft, per the same precedent as RatioAnalysisReportDAL/KPIEvaluationBLL: a report never // fails because of a computed field. A field that won't compile (bad syntax, unknown identifier, // a FieldName collision with a real DAL-returned column) is dropped with one warning log line; // every other field, and the report itself, keeps working. A per-row evaluation failure // (divide-by-zero, bad cast) yields null for that cell only. public sealed class ComputedFieldEvaluator { private static readonly Regex TokenPattern = new(@"'(?:[^']|'')*'|\[[^\]]+\]|[A-Za-z_][A-Za-z0-9_]*", RegexOptions.Compiled); private static readonly HashSet ReservedTokens = new(StringComparer.OrdinalIgnoreCase) { "IIF", "ISNULL", "LEN", "TRIM", "SUBSTRING", "CONVERT", "AND", "OR", "NOT", "LIKE", "IN", "IS", "NULL", "TRUE", "FALSE", }; private static readonly ConcurrentDictionary _propCache = new(); private readonly IReadOnlyList _defs; private readonly ILogger? _logger; private bool _built; private DataTable? _table; private List _sourceColumns = new(); private List _validFields = new(); private ComputedFieldEvaluator(IReadOnlyList defs, ILogger? logger) { _defs = defs; _logger = logger; } // Returns null when there is nothing to evaluate -- callers should treat null as "skip // everything, return the input unchanged" (the no-op guarantee for unconfigured reports). public static ComputedFieldEvaluator? Create(IReadOnlyList? defs, ILogger? logger = null) => defs is { Count: > 0 } ? new ComputedFieldEvaluator(defs, logger) : null; // Identifier tokenization shared with the BI/Analysis platform's own computed-field // reference validation (AnalyticsBLL/AnalysisAggregation/ComputedFieldPlanner.cs) -- one // implementation, since both halves need "which bare identifiers does this expression // reference" and the tokenization rules (skip string literals, function names, keywords) // are table-agnostic. public static IEnumerable ExtractReferencedFields(string expression) { if (string.IsNullOrWhiteSpace(expression)) yield break; foreach (Match m in TokenPattern.Matches(expression)) { var token = m.Value; if (token.Length >= 2 && token[0] == '\'') continue; // string literal if (token.Length >= 2 && token[0] == '[' && token[^1] == ']') { yield return token[1..^1]; continue; } if (ReservedTokens.Contains(token)) continue; yield return token; } } // Mutates `row` in place: adds one key per successfully-evaluated computed field. Never // overwrites an existing key (see the collision guard in EnsureBuilt). public void Apply(IDictionary row) { EnsureBuilt(row); if (_table == null || _validFields.Count == 0) return; _table.Rows.Clear(); var dr = _table.NewRow(); foreach (var colName in _sourceColumns) { var value = row.TryGetValue(colName, out var v) ? v : null; dr[colName] = CoerceForColumn(value, _table.Columns[colName]!.DataType); } _table.Rows.Add(dr); foreach (var field in _validFields) { try { var value = dr[field.FieldName]; row[field.FieldName] = value is DBNull ? null : value; } catch (Exception ex) { _logger?.LogDebug(ex, "[ComputedField] Row-level evaluation failed for {FieldName}", field.FieldName); row[field.FieldName] = null; } } } private void EnsureBuilt(IDictionary firstRow) { if (_built) return; _built = true; var table = new DataTable(); var knownComputedNames = new HashSet(_defs.Select(d => d.FieldName), StringComparer.OrdinalIgnoreCase); var candidates = new List(); foreach (var def in _defs.OrderBy(d => d.DisplaySlNo)) { if (string.IsNullOrWhiteSpace(def.Expression)) continue; if (firstRow.ContainsKey(def.FieldName)) { _logger?.LogWarning( "[ComputedField] Report field {FieldName} collides with an existing returned column; skipping its COMPUTEEXPRESSION so the real value is never overwritten.", def.FieldName); continue; } if (def.Expression.TrimStart().StartsWith("CASE", StringComparison.OrdinalIgnoreCase)) { _logger?.LogWarning( "[ComputedField] Report field {FieldName} expression '{Expression}' uses SQL CASE syntax, which DataColumn.Expression does not support. Use nested IIf(...) instead -- e.g. IIf(Status=0,'Pending',IIf(Status=1,'Active','Cancelled')).", def.FieldName, def.Expression); continue; } candidates.Add(def); } // Source columns: every identifier any candidate expression references, minus the // names of other computed fields in this same set (those resolve as a DataColumn // referencing an earlier DataColumn, not as a row-supplied value). var sourceNames = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var def in candidates) foreach (var token in ExtractReferencedFields(def.Expression)) if (!knownComputedNames.Contains(token)) sourceNames.Add(token); foreach (var name in sourceNames) { var type = InferColumnType(firstRow.TryGetValue(name, out var v) ? v : null); table.Columns.Add(new DataColumn(name, type)); } _sourceColumns = sourceNames.ToList(); // Computed columns, in DisplaySlNo order so one computed field may reference an // earlier one -- ADO.NET resolves DataColumn-to-DataColumn references within the same // table and throws (cycle/unknown reference) at Columns.Add time, caught per-field below. var valid = new List(); foreach (var def in candidates) { try { var col = new DataColumn(def.FieldName, MapClrType(def.FieldType)) { Expression = def.Expression, }; table.Columns.Add(col); valid.Add(def); } catch (Exception ex) { _logger?.LogWarning( ex, "[ComputedField] Report field {FieldName} expression '{Expression}' could not be compiled: {Message}", def.FieldName, def.Expression, ex.Message); } } _validFields = valid; _table = table; } private static Type InferColumnType(object? sampleValue) => sampleValue is null or DBNull ? typeof(object) : sampleValue.GetType(); private static object CoerceForColumn(object? value, Type columnType) { if (value is null or DBNull) return DBNull.Value; if (columnType == typeof(object) || columnType.IsInstanceOfType(value)) return value; try { return Convert.ChangeType(value, columnType); } catch { return DBNull.Value; } } // MREPORTVSFIELDS.FIELDTYPE: 0 INT,1 STRING,2 LONG,3 DATE,4 BOOLEAN,5 OBJECT,6 DOUBLE, // 7 BYTE[],8 ENUM,9 INT16,10 BYTE,11 INT64. FIELDTYPE=6 ("DOUBLE") maps to decimal, not // double -- every money figure in this codebase (CALFORMAT 'N2' style) is decimal, and // DataColumn.Expression handles decimal natively. private static Type MapClrType(byte fieldType) => fieldType switch { 0 => typeof(int), 1 => typeof(string), 2 => typeof(long), 3 => typeof(DateTime), 4 => typeof(bool), 6 => typeof(decimal), 9 => typeof(short), 10 => typeof(byte), 11 => typeof(long), _ => typeof(string), }; // ── Grid path (BaseReportEndpoint.ExecutePagedAsync / envelope helpers) ───────────────── // Projects a strongly-typed List into computed-field-augmented rows. Returns the SAME // `rows` reference (boxed as object), unchanged, when there is nothing to compute -- the // no-op guarantee: CreateSuccessResponse then serializes the identical List it always // did, byte-for-byte, for every report/view with zero computed fields configured. public static object Project(List rows, IReadOnlyList defs, ILogger? logger = null) { if (defs.Count == 0 || rows.Count == 0) return rows; var evaluator = Create(defs, logger); if (evaluator == null) return rows; var props = _propCache.GetOrAdd(typeof(T), t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance)); var projected = new List>(rows.Count); foreach (var item in rows) { if (item is null) { projected.Add(new Dictionary(StringComparer.Ordinal)); continue; } var dict = new Dictionary(StringComparer.Ordinal); foreach (var p in props) dict[p.Name] = p.GetValue(item); evaluator.Apply(dict); projected.Add(dict); } return projected; } // Non-generic sibling of Project<T>, for callers that only know the row type at runtime // (e.g. BaseReportEndpoint's envelope-shaped helper, which extracts a rows property via // reflection from an arbitrary "{ Items, TotalCount, ... }" response DTO). Groups items by // their actual runtime type (cheap: report result sets are always homogeneous) so the // reflection property list is still cached and read once per type, not once per item. public static object Project(System.Collections.IEnumerable rows, IReadOnlyList defs, ILogger? logger = null) { if (defs.Count == 0) return rows; var evaluator = Create(defs, logger); if (evaluator == null) return rows; var projected = new List>(); foreach (var item in rows) { var dict = new Dictionary(StringComparer.Ordinal); if (item != null) { var props = _propCache.GetOrAdd(item.GetType(), t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance)); foreach (var p in props) dict[p.Name] = p.GetValue(item); } evaluator.Apply(dict); projected.Add(dict); } return projected.Count == 0 ? rows : projected; } // ── Export path (ReportExport.GetDataSource) ──────────────────────────────────────────── // Streaming decorator: yields `source`'s rows unchanged when there are no computed fields // (no-op guarantee for exports), otherwise mutates and yields each row in place. public static async IAsyncEnumerable> Decorate( IAsyncEnumerable> source, IReadOnlyList defs, ILogger? logger, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) { var evaluator = Create(defs, logger); await foreach (var row in source.WithCancellation(ct).ConfigureAwait(false)) { evaluator?.Apply(row); yield return row; } } } }