using System; using System.Collections.Generic; using System.Linq; using AnalyticsDAL.DTO.AnalysisAggregation.V1; using AnalyticsDAL.DTO.BIFieldMapping; using AnalyticsDAL.DTO.Warehouse; using GB5Shared.Export; namespace AnalyticsBLL.AnalysisAggregation { /// BI-policy layer for computed/derived fields (MBIFIELDMAPPING.MAPPEDAS=2) — /// selection against a request's AnalysisQueryDefinition.ComputedFields, reference validation /// (a computed field may reference only fields already present in the resolved result rows — /// never auto-injected, see the "sources must be selected" note below), row-locality /// enforcement, and dependency ordering. The actual per-row evaluation is delegated to the /// shared GB5Shared.Export.ComputedFieldEvaluator — everything in this class is BI-specific /// policy the fixed-report catalog has no equivalent of. /// /// Additivity, worked through: re-running AnalysisAggregationService.ValidateAdditivity on a /// computed field would be a category error — that check classifies an AGGREGATION (e.g. /// SUM(CASHBALANCE) without DATEID adds point-in-time balances from different days); the /// computed layer performs NO aggregation, it evaluates a row-local expression over values /// ALREADY aggregated and already validated. The real safeguard is narrower and structural: /// a computed expression may reference only (a) a key already present in the resolved result /// row (meaning it passed through Dimensions/Measures and, if a measure, already passed /// ValidateAdditivity), or (b) another computed field in the same request. There is /// deliberately NO auto-injection of a computed field's missing source measure — doing so /// would require choosing an aggregation AFTER ValidateAdditivity already ran, which could let /// an unsafe SUM over a SemiAdditive measure slip through a path the guard cannot see. public static class ComputedFieldPlanner { private static readonly HashSet AggregateFunctionNames = new(StringComparer.OrdinalIgnoreCase) { "SUM", "AVG", "COUNT", "MIN", "MAX", "STDEV", "VAR", }; public static bool IsRequested(AnalysisQueryDefinition definition) => definition.ComputedFields.Count > 0; /// Resolves the requested field names against the catalog's computed-field rows, /// validates row-locality and references, and returns them in dependency order (so a /// computed field may reference an earlier one in the same request). Throws /// ArgumentException with an actionable message on any failure — computed-field /// configuration errors are definition-level bugs, not per-row failures, so unlike a /// per-row divide-by-zero these are NOT swallowed. public static List SelectAndOrder( IReadOnlyList available, IReadOnlyList requestedNames, IReadOnlyCollection availableResultKeys) { var byName = available.ToDictionary(f => f.SourceFieldName, StringComparer.OrdinalIgnoreCase); var selected = new List(); foreach (var name in requestedNames) { if (!byName.TryGetValue(name, out var field)) { var offered = available.Count > 0 ? string.Join(", ", available.Select(f => f.SourceFieldName)) : "(none defined for this dataset)"; throw new ArgumentException($"Computed field '{name}' is not defined for this dataset. Available: {offered}."); } ValidateRowLocal(field); selected.Add(field); } var selectedNames = new HashSet(selected.Select(f => f.SourceFieldName), StringComparer.OrdinalIgnoreCase); foreach (var field in selected) { foreach (var token in ComputedFieldEvaluator.ExtractReferencedFields(field.ComputeExpression ?? string.Empty)) { if (selectedNames.Contains(token)) continue; // resolves as a reference to another computed field in this request if (availableResultKeys.Contains(token)) continue; // a real Dimension/Measure the caller already selected throw new ArgumentException( $"Computed field '{field.SourceFieldName}' references '{token}', which is not among the requested " + "Dimensions/Measures (or another requested computed field). Add it to Dimensions/Measures, or " + "request the computed field that defines it — a computed field can never silently pull in an " + "unselected/unaggregated source."); } } return TopologicalOrder(selected); } /// Save-time validation for BIFieldMappingBLL.SaveMapping: row-locality plus an /// actual DataColumn.Expression compile attempt (catches a syntax error, e.g. a stray /// paren or SQL CASE syntax, at save rather than at first dashboard render). Every /// identifier the expression references is added as a source column of a synthetic type /// (decimal — good enough to catch a syntax error; the real per-row type resolution /// happens against actual data at query time). Throws ArgumentException with the /// DataColumn.Expression parser's own message on failure. public static void ValidateForSave(BIFieldMappingDTO field) { ValidateRowLocal(field); using var dt = new System.Data.DataTable(); foreach (var token in ComputedFieldEvaluator.ExtractReferencedFields(field.ComputeExpression ?? string.Empty).Distinct(StringComparer.OrdinalIgnoreCase)) dt.Columns.Add(token, typeof(decimal)); try { dt.Columns.Add(new System.Data.DataColumn("__PROBE__", typeof(object)) { Expression = field.ComputeExpression }); } catch (Exception ex) { throw new ArgumentException( $"Computed field '{field.SourceFieldName}' expression '{field.ComputeExpression}' could not be compiled: {ex.Message}", ex); } } private static void ValidateRowLocal(BIFieldMappingDTO field) { var expr = field.ComputeExpression ?? string.Empty; if (expr.Contains("Parent.", StringComparison.OrdinalIgnoreCase) || expr.Contains("Child.", StringComparison.OrdinalIgnoreCase) || expr.Contains("Child(", StringComparison.OrdinalIgnoreCase)) { throw new ArgumentException( $"Computed field '{field.SourceFieldName}' expression uses Parent/Child DataRelation syntax, which this " + "engine does not support (no relation is set up on the evaluator's DataTable)."); } foreach (var token in ComputedFieldEvaluator.ExtractReferencedFields(expr)) { if (AggregateFunctionNames.Contains(token)) throw new ArgumentException( $"Computed field '{field.SourceFieldName}' expression uses an aggregate function ('{token}'), which is " + "not supported here — a computed field is evaluated per row over already-aggregated values. Request " + "the underlying measure with the aggregation you need instead."); } } // Iterative topological sort: repeatedly emit any not-yet-emitted field whose // computed-field dependencies are all already emitted. A pass that emits nothing while // fields remain means a cycle — named explicitly in the error. private static List TopologicalOrder(List selected) { var byName = selected.ToDictionary(f => f.SourceFieldName, StringComparer.OrdinalIgnoreCase); var remaining = new List(selected); var emitted = new HashSet(StringComparer.OrdinalIgnoreCase); var ordered = new List(); while (remaining.Count > 0) { // A field is ready once every OTHER computed field it depends on (an identifier // that resolves to a sibling in this same selected set) has already been emitted. var next = remaining.Where(f => { var deps = ComputedFieldEvaluator.ExtractReferencedFields(f.ComputeExpression ?? string.Empty) .Where(t => byName.ContainsKey(t) && !string.Equals(t, f.SourceFieldName, StringComparison.OrdinalIgnoreCase)); return deps.All(emitted.Contains); }).ToList(); if (next.Count == 0) { var stuck = string.Join(", ", remaining.Select(f => f.SourceFieldName)); throw new ArgumentException($"Computed field(s) [{stuck}] form a reference cycle and cannot be evaluated."); } foreach (var f in next) { ordered.Add(f); emitted.Add(f.SourceFieldName); remaining.Remove(f); } } return ordered; } public static List BuildMeta( List ordered, IReadOnlyDictionary resultKeyAdditivity) { var byName = ordered.ToDictionary(f => f.SourceFieldName, StringComparer.OrdinalIgnoreCase); var meta = new List(); foreach (var field in ordered) { // Real source fields only -- excludes identifiers that resolve to another // computed field in this same selected set (those are dependencies, not sources). var sourceFields = ComputedFieldEvaluator.ExtractReferencedFields(field.ComputeExpression ?? string.Empty) .Where(t => !byName.ContainsKey(t)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); var inherited = AdditivityType.Additive; foreach (var src in sourceFields) if (resultKeyAdditivity.TryGetValue(src, out var a) && a > inherited) inherited = a; meta.Add(new AnalysisComputedFieldDTO { Field = field.SourceFieldName, Label = field.Label, DataType = field.DataType, SourceFields = sourceFields, Additivity = AdditivityType.NonAdditive, InheritedAdditivity = inherited, NullRowCount = 0, // filled in by the caller after evaluation }); } return meta; } } }