From 33d1ca5d76f380db23c554bfb2d714fc6e6e8202 Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Mon, 13 Apr 2026 16:27:05 +0200 Subject: [PATCH 01/16] WIP --- .../Excel/Functions/BuiltInFunctions.cs | 1 + .../Excel/Functions/RefAndLookup/Groupby.cs | 146 +++++----- .../GroupingFunctions/GroupByFunctionBase.cs | 249 +++++++++++------- .../GroupingFunctions/PivotByArgs.cs | 18 ++ .../GroupingFunctions/RelativeTo.cs | 16 ++ .../Excel/Functions/RefAndLookup/PivotBy.cs | 240 +++++++++++++++++ .../Functions/RefAndLookup/GroupByTests.cs | 14 +- .../Functions/RefAndLookup/PivotByTests.cs | 31 +++ 8 files changed, 519 insertions(+), 196 deletions(-) create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/PivotByArgs.cs create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/RelativeTo.cs create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs create mode 100644 src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs b/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs index 3b956d0db..28120ffec 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs @@ -337,6 +337,7 @@ public BuiltInFunctions() Functions["address"] = new Address(); Functions["areas"] = new Areas(); Functions["groupby"] = new GroupBy(); + Functions["pivotby"] = new PivotBy(); Functions["hlookup"] = new HLookup(); Functions["vlookup"] = new VLookup(); Functions["xlookup"] = new Xlookup(); diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs index 8785bcd6e..a171327df 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs @@ -34,7 +34,7 @@ internal class GroupBy : GroupByFunctionBase public override CompileResult Execute(IList arguments, ParsingContext context) { - if (!TryParseBaseArgs(arguments, out var args, out var error)) + if (!TryParseGroupByArgs(arguments, out var args, out var error)) return error; var groups = BuildGroups(args, context); groups = ApplySort(groups, args); @@ -43,104 +43,82 @@ public override CompileResult Execute(IList arguments, Parsing return CreateDynamicArrayResult(result, DataType.ExcelRange); } - // ------------------------------------------------------- - // Sorting - // ------------------------------------------------------- - private List ApplySort(List levels, GroupByBaseArgs args, int depth = 1) + private bool TryParseGroupByArgs(IList arguments, + out GroupByBaseArgs args, + out CompileResult error) { - if (args.SortOrders == null || args.SortOrders.All(s => s == 0)) return levels; + args = new GroupByBaseArgs(); + error = null; - if (args.FieldRelationship == FieldRelationship.Table) - { - var allRows = levels.SelectMany(l => CollectLeafRows(l)).ToList(); - allRows = SortRowsMulti(allRows, args); + if (!arguments[0].IsExcelRange) // TODO. Man kan skicka in enskilda celler i rowfields och values, så detta är fel. + return Fail(eErrorType.Value, out error); + args.RowFields = arguments[0].ValueAsRangeInfo; - var newLevelDict = new Dictionary(); - var newLevelOrder = new List(); - foreach (var row in allRows) - { - var topKey = (row.KeyParts[0]?.ToString() ?? string.Empty).ToLowerInvariant(); - if (!newLevelDict.TryGetValue(topKey, out var level)) - { - level = new GroupLevel { Key = row.KeyParts[0] }; - newLevelDict[topKey] = level; - newLevelOrder.Add(topKey); - } - level.Rows.Add(row); - } - return newLevelOrder.Select(k => newLevelDict[k]).ToList(); - } - else - { - var sortForThisLevel = args.SortOrders - .FirstOrDefault(s => Math.Abs(s) == depth); + if (!arguments[1].IsExcelRange) + return Fail(eErrorType.Value, out error); + args.Values = arguments[1].ValueAsRangeInfo; - bool hasSortForThisLevel = sortForThisLevel != 0; - bool desc = sortForThisLevel < 0; - bool sortOnAggregated = hasSortForThisLevel && Math.Abs(sortForThisLevel) > args.RowFields.Size.NumberOfCols; + if (args.RowFields.Size.NumberOfRows != args.Values.Size.NumberOfRows) + return Fail(eErrorType.Value, out error); - if (hasSortForThisLevel) - { - levels = sortOnAggregated - ? (desc ? levels.OrderByDescending(l => l.SubtotalValue as IComparable, _comparer).ToList() - : levels.OrderBy(l => l.SubtotalValue as IComparable, _comparer).ToList()) - : (desc ? levels.OrderByDescending(l => l.Key as IComparable, _comparer).ToList() - : levels.OrderBy(l => l.Key as IComparable, _comparer).ToList()); - } + if (!TryParseFunctionArg(arguments[2], args.Functions, out LambdaCalculator function, out FunctionLayout layout)) + return Fail(eErrorType.Value, out error); - foreach (var level in levels) - { - if (!level.IsLeaf) - level.Children = ApplySort(level.Children, args, depth + 1); - else - level.Rows = SortRowsMulti(level.Rows, args); - } + args.Function = function; + args.FunctionLayout = layout; + + if (args.Functions.Count == 0) + return Fail(eErrorType.Value, out error); + + // field_headers (optional) + if (arguments.Count > 3 && arguments[3].Value != null) + { + var v = Convert.ToInt32(arguments[3].Value); + if (!Enum.IsDefined(typeof(FieldHeaders), v)) + return Fail(eErrorType.Value, out error); + args.Headers = (FieldHeaders)v; + } + else if (args.Functions.Count > 1) // In excel, if multiple functions are included, headers are by default displayed. + { + args.Headers = FieldHeaders.YesAndShow; + } - return levels; + // total_depth (optional) + if (arguments.Count > 4 && arguments[4].Value != null) + { + if (!TryParseTotalDepthArg(arguments[4], args.RowFields.Size.NumberOfCols, out int totalDepth)) + return Fail(eErrorType.Value, out error); + args.TotalDepth = totalDepth; } - } - private List SortRowsMulti(List rows, GroupByBaseArgs args) - { - if (rows == null || rows.Count == 0) return rows; + // sort_order (optional) + if (arguments.Count > 5 && arguments[5].Value != null) + { + args.SortOrders = ParseSortOrderArg(arguments[5]); + } - int nKeyCols = args.RowFields.Size.NumberOfCols; - IOrderedEnumerable ordered = null; + // filter_array (optional) + if (arguments.Count > 6 && arguments[6].IsExcelRange) + args.FilterArray = arguments[6].ValueAsRangeInfo; - foreach (var sortOrder in args.SortOrders) + // field_relationship (optional) + if (arguments.Count > 7 && arguments[7].Value != null) { - if (sortOrder == 0) continue; - bool desc = sortOrder < 0; - int col = Math.Abs(sortOrder); - bool sortOnAggregated = col > nKeyCols; - - // Capture loop variables - var capturedCol = col; - var capturedSortOnAggregated = sortOnAggregated; - - Func keySelector = capturedSortOnAggregated - ? (Func)(r => r.AggregatedValue) - : (r => r.KeyParts[Math.Min(capturedCol - 1, r.KeyParts.Length - 1)]); - - if (ordered == null) - ordered = desc - ? rows.OrderByDescending(keySelector, _comparer) - : rows.OrderBy(keySelector, _comparer); - else - ordered = desc - ? ordered.ThenByDescending(keySelector, _comparer) - : ordered.ThenBy(keySelector, _comparer); + var v = Convert.ToInt32(arguments[7].Value); + if (!Enum.IsDefined(typeof(FieldRelationship), v)) + return Fail(eErrorType.Value, out error); + if (v == (int)FieldRelationship.Table && Math.Abs(args.TotalDepth) > 1) + return Fail(eErrorType.Value, out error); + args.FieldRelationship = (FieldRelationship)v; } - return ordered?.ToList() ?? rows; + return true; } - private IEnumerable CollectLeafRows(GroupLevel level) - { - if (level.IsLeaf) - return level.Rows; - return level.Children.SelectMany(c => CollectLeafRows(c)); - } + // ------------------------------------------------------- + // Sorting + // ------------------------------------------------------- + // ------------------------------------------------------- // Build result @@ -148,7 +126,7 @@ private IEnumerable CollectLeafRows(GroupLevel level) private InMemoryRange BuildResult(List levels, GroupByBaseArgs args, ParsingContext context) { - var resolvedHeaders = ResolveHeaders(args); + var resolvedHeaders = ResolveHeaders(args.Headers, args.Values); bool showHeaders = resolvedHeaders == FieldHeaders.YesAndShow || resolvedHeaders == FieldHeaders.NoButGenerate; bool addFunctionHeaders = args.Functions.Count > 1; diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupByFunctionBase.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupByFunctionBase.cs index 770283a1b..4830eb8d1 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupByFunctionBase.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupByFunctionBase.cs @@ -19,7 +19,6 @@ Date Author Change using System.Collections.Generic; using System.Linq; - namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions { internal abstract class GroupByFunctionBase : ExcelFunction @@ -51,38 +50,30 @@ protected List ResolveFunctionHeaders(GroupByBaseArgs args) // ------------------------------------------------------- // Argument parsing // ------------------------------------------------------- - protected bool TryParseBaseArgs( - IList arguments, - out GroupByBaseArgs args, - out CompileResult error) - { - args = new GroupByBaseArgs(); - error = null; - - if (!arguments[0].IsExcelRange) - return Fail(eErrorType.Value, out error); - args.RowFields = arguments[0].ValueAsRangeInfo; - if (!arguments[1].IsExcelRange) - return Fail(eErrorType.Value, out error); - args.Values = arguments[1].ValueAsRangeInfo; - - if (args.RowFields.Size.NumberOfRows != args.Values.Size.NumberOfRows) - return Fail(eErrorType.Value, out error); + protected bool Fail(eErrorType err, out CompileResult error) + { + error = CompileResult.GetErrorResult(err); + return false; + } - if (arguments[2].DataType == DataType.LambdaCalculation) + protected bool TryParseFunctionArg(FunctionArgument funtionArgument, List functions, + out LambdaCalculator function, out FunctionLayout layout) + { + function = null; + layout = FunctionLayout.Single; + if (funtionArgument.DataType == DataType.LambdaCalculation) { // Single function - args.Function = arguments[2].Value as LambdaCalculator; - args.Functions.Add(args.Function); - args.FunctionLayout = FunctionLayout.Single; + function = funtionArgument.Value as LambdaCalculator; + functions.Add(function); } - else if (arguments[2].IsExcelRange) + else if (funtionArgument.IsExcelRange) { // Multiple functions via HSTACK or VSTACK - var range = arguments[2].ValueAsRangeInfo; + var range = funtionArgument.ValueAsRangeInfo; bool isHorizontal = range.Size.NumberOfRows == 1; - args.FunctionLayout = isHorizontal ? FunctionLayout.Horizontal : FunctionLayout.Vertical; + layout = isHorizontal ? FunctionLayout.Horizontal : FunctionLayout.Vertical; int count = isHorizontal ? range.Size.NumberOfCols : range.Size.NumberOfRows; for (int i = 0; i < count; i++) @@ -92,99 +83,61 @@ protected bool TryParseBaseArgs( : range.GetOffset(i, 0); if (cellVal is LambdaCalculator lc) - args.Functions.Add(lc); + functions.Add(lc); else - return Fail(eErrorType.Value, out error); + return false; } - args.Function = args.Functions[0]; + function = functions[0]; } else { - return Fail(eErrorType.Value, out error); - } - - if (args.Functions.Count == 0) - return Fail(eErrorType.Value, out error); - - // field_headers (optional) - if (arguments.Count > 3 && arguments[3].Value != null) - { - var v = Convert.ToInt32(arguments[3].Value); - if (!Enum.IsDefined(typeof(FieldHeaders), v)) - return Fail(eErrorType.Value, out error); - args.Headers = (FieldHeaders)v; - } - else if(args.Functions.Count > 1) // In excel, if multiple functions are included, headers are by default displayed. - { - args.Headers = FieldHeaders.YesAndShow; + return false; } + return true; + } - // total_depth (optional) - if (arguments.Count > 4 && arguments[4].Value != null) - { - var totalDepth = Convert.ToInt32(arguments[4].Value); - if (Math.Abs(totalDepth) > args.RowFields.Size.NumberOfCols) - return Fail(eErrorType.Value, out error); - args.TotalDepth = totalDepth; - } + protected bool TryParseTotalDepthArg(FunctionArgument arg, int numberOfCols, + out int totalDepth) + { + totalDepth = Convert.ToInt32(arg.Value); + if (Math.Abs(totalDepth) > numberOfCols) + return false; + return true; + } - // sort_order (optional) - if (arguments.Count > 5 && arguments[5].Value != null) + protected int[] ParseSortOrderArg(FunctionArgument arg) + { + if (arg.IsExcelRange) { - if (arguments[5].IsExcelRange) - { - var range = arguments[5].ValueAsRangeInfo; - bool isHorizontal = range.Size.NumberOfRows == 1; - int count = isHorizontal ? range.Size.NumberOfCols : range.Size.NumberOfRows; - args.SortOrders = new int[count]; - for (int i = 0; i < count; i++) - args.SortOrders[i] = Convert.ToInt32(isHorizontal - ? range.GetOffset(0, i) - : range.GetOffset(i, 0)); - } - else - { - args.SortOrders = new[] { Convert.ToInt32(arguments[5].Value) }; - } + var range = arg.ValueAsRangeInfo; + bool isHorizontal = range.Size.NumberOfRows == 1; + int count = isHorizontal ? range.Size.NumberOfCols : range.Size.NumberOfRows; + var result = new int[count]; + for (int i = 0; i < count; i++) + result[i] = Convert.ToInt32(isHorizontal + ? range.GetOffset(0, i) + : range.GetOffset(i, 0)); + return result; } - - // filter_array (optional) - if (arguments.Count > 6 && arguments[6].IsExcelRange) - args.FilterArray = arguments[6].ValueAsRangeInfo; - - // field_relationship (optional) - if (arguments.Count > 7 && arguments[7].Value != null) + else { - var v = Convert.ToInt32(arguments[7].Value); - if (!Enum.IsDefined(typeof(FieldRelationship), v)) - return Fail(eErrorType.Value, out error); - if (v == (int)FieldRelationship.Table && Math.Abs(args.TotalDepth) > 1) - return Fail(eErrorType.Value, out error); - args.FieldRelationship = (FieldRelationship)v; + return new[] { Convert.ToInt32(arg.Value) }; } - - return true; - } - - protected bool Fail(eErrorType err, out CompileResult error) - { - error = CompileResult.GetErrorResult(err); - return false; } // ------------------------------------------------------- // Header resolution // ------------------------------------------------------- - protected FieldHeaders ResolveHeaders(GroupByBaseArgs args) + protected FieldHeaders ResolveHeaders(FieldHeaders headers, IRangeInfo values) { - if (args.Headers != FieldHeaders.Missing) - return args.Headers; + if (headers != FieldHeaders.Missing) + return headers; - if (args.Values.Size.NumberOfRows < 2) + if (values.Size.NumberOfRows < 2) return FieldHeaders.No; - var first = args.Values.GetValue(0, 0); - var second = args.Values.GetValue(1, 0); + var first = values.GetValue(0, 0); + var second = values.GetValue(1, 0); bool firstIsText = first is string; bool secondIsNumber = second is double || second is int || second is long || second is float; @@ -199,7 +152,7 @@ protected FieldHeaders ResolveHeaders(GroupByBaseArgs args) // ------------------------------------------------------- protected List BuildGroups(GroupByBaseArgs args, ParsingContext context) { - var resolvedHeaders = ResolveHeaders(args); + var resolvedHeaders = ResolveHeaders(args.Headers, args.Values); bool hasHeaders = resolvedHeaders == FieldHeaders.YesAndShow || resolvedHeaders == FieldHeaders.YesAndDontShow; bool multipleFunctions = args.Functions.Count > 1; @@ -272,6 +225,8 @@ protected List BuildGroups(GroupByBaseArgs args, ParsingContext cont return levels; } + + protected List BuildOrderedTree( Dictionary dict, List order) @@ -322,7 +277,7 @@ protected void AggregateTree(List levels, GroupByBaseArgs args, Pars f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null); } return result; - }).ToList(); + }).ToList(); // Krash. Level.Row är null av någon anledning level.SubtotalValue = level.SubtotalValues[0][0]; } else @@ -381,5 +336,101 @@ protected object Aggregate(LambdaCalculator calculator, List values, P } return calculator.Execute(context).ResultValue; } + + protected List ApplySort(List levels, GroupByBaseArgs args, int depth = 1) + { + if (args.SortOrders == null || args.SortOrders.All(s => s == 0)) return levels; + + if (args.FieldRelationship == FieldRelationship.Table) + { + var allRows = levels.SelectMany(l => CollectLeafRows(l)).ToList(); + allRows = SortRowsMulti(allRows, args); + + var newLevelDict = new Dictionary(); + var newLevelOrder = new List(); + foreach (var row in allRows) + { + var topKey = (row.KeyParts[0]?.ToString() ?? string.Empty).ToLowerInvariant(); + if (!newLevelDict.TryGetValue(topKey, out var level)) + { + level = new GroupLevel { Key = row.KeyParts[0] }; + newLevelDict[topKey] = level; + newLevelOrder.Add(topKey); + } + level.Rows.Add(row); + } + return newLevelOrder.Select(k => newLevelDict[k]).ToList(); + } + else + { + var sortForThisLevel = args.SortOrders + .FirstOrDefault(s => Math.Abs(s) == depth); + + bool hasSortForThisLevel = sortForThisLevel != 0; + bool desc = sortForThisLevel < 0; + bool sortOnAggregated = hasSortForThisLevel && Math.Abs(sortForThisLevel) > args.RowFields.Size.NumberOfCols; + + if (hasSortForThisLevel) + { + levels = sortOnAggregated + ? (desc ? levels.OrderByDescending(l => l.SubtotalValue as IComparable, _comparer).ToList() + : levels.OrderBy(l => l.SubtotalValue as IComparable, _comparer).ToList()) + : (desc ? levels.OrderByDescending(l => l.Key as IComparable, _comparer).ToList() + : levels.OrderBy(l => l.Key as IComparable, _comparer).ToList()); + } + + foreach (var level in levels) + { + if (!level.IsLeaf) + level.Children = ApplySort(level.Children, args, depth + 1); + else + level.Rows = SortRowsMulti(level.Rows, args); + } + + return levels; + } + } + + private List SortRowsMulti(List rows, GroupByBaseArgs args) + { + if (rows == null || rows.Count == 0) return rows; + + int nKeyCols = args.RowFields.Size.NumberOfCols; + IOrderedEnumerable ordered = null; + + foreach (var sortOrder in args.SortOrders) + { + if (sortOrder == 0) continue; + bool desc = sortOrder < 0; + int col = Math.Abs(sortOrder); + bool sortOnAggregated = col > nKeyCols; + + // Capture loop variables + var capturedCol = col; + var capturedSortOnAggregated = sortOnAggregated; + + Func keySelector = capturedSortOnAggregated + ? (Func)(r => r.AggregatedValue) + : (r => r.KeyParts[Math.Min(capturedCol - 1, r.KeyParts.Length - 1)]); + + if (ordered == null) + ordered = desc + ? rows.OrderByDescending(keySelector, _comparer) + : rows.OrderBy(keySelector, _comparer); + else + ordered = desc + ? ordered.ThenByDescending(keySelector, _comparer) + : ordered.ThenBy(keySelector, _comparer); + } + + return ordered?.ToList() ?? rows; + } + + private IEnumerable CollectLeafRows(GroupLevel level) + { + if (level.IsLeaf) + return level.Rows; + return level.Children.SelectMany(c => CollectLeafRows(c)); + } } } diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/PivotByArgs.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/PivotByArgs.cs new file mode 100644 index 000000000..b922c632f --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/PivotByArgs.cs @@ -0,0 +1,18 @@ +using OfficeOpenXml.FormulaParsing.FormulaExpressions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions +{ + internal class PivotByArgs : GroupByBaseArgs + { + public IRangeInfo ColFields { get; set; } + public int RowTotalDepth { get; set; } = 1; + public int[] RowSortOrders { get; set; } = new[] { 1 }; + public int ColTotalDepth { get; set; } = 1; + public int[] ColSortOrders { get; set; } = new[] { 1 }; + public RelativeTo RelativeTo { get; set; } = RelativeTo.ColumnTotals; // Default + } +} diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/RelativeTo.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/RelativeTo.cs new file mode 100644 index 000000000..5a75f14d7 --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/RelativeTo.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions +{ + internal enum RelativeTo + { + ColumnTotals = 0, + RowTotals = 1, + GrandTotals = 2, + ParentColTotal = 3, + ParentRowTotal = 4 + } +} diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs new file mode 100644 index 000000000..a4aa8ec30 --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs @@ -0,0 +1,240 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 13/4/2026 EPPlus Software AB EPPlus v8.6 + *************************************************************************************************/ + +using OfficeOpenXml.FormulaParsing.Excel.Functions.Metadata; +using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions; +using OfficeOpenXml.FormulaParsing.FormulaExpressions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup +{ + [FunctionMetadata( + Category = ExcelFunctionCategory.LookupAndReference, + EPPlusVersion = "8.6", + Description = "Allows you to create a summary of your data via a formula. It supports grouping along two axis and aggregating the associated values.")] + internal class PivotBy : GroupByFunctionBase + { + public override int ArgumentMinLength => 3; // kanske ska vara 4 + public override string NamespacePrefix => "_xlfn."; + public override bool ExecutesLambda => true; + + public override CompileResult Execute(IList arguments, ParsingContext context) + { + if (!TryParsePivotByArgs(arguments, out var args, out var error)) + return error; + BuildPivotData(args, context, out var rowLevels, out var colLevels, out var pivotMap); + rowLevels = ApplySort(rowLevels, args); + colLevels = ApplySort(colLevels, args); + + throw new NotImplementedException(); + } + + // 2. Bygg radgrupperingar → List rowLevels (återanvänd BuildGroups) + // 3. Bygg kolumngrupperingar → List colLevels (återanvänd BuildGroups på col_fields) + // 4. Bygg aggregeringskartan: Dictionary<(rowKey, colKey), object> pivotMap + // 5. Applicera sortering på båda axlarna (återanvänd ApplySort) + // 6. Räkna ut dimensioner: nRows, nCols + // 7. Skriv kolumnhuvuden (ett pass) + // 8. Skriv datarader rad för rad, slå upp pivotMap[(rowKey, colKey)] per kolumn + // 9. Skriv grand total-rad/-kolumn + + protected bool TryParsePivotByArgs(IList arguments, + out PivotByArgs args, + out CompileResult error) + { + args = new PivotByArgs(); + error = null; + + args.RowFields = arguments[0].ValueAsRangeInfo; + args.ColFields = arguments[1].ValueAsRangeInfo; + args.Values = arguments[2].ValueAsRangeInfo; + + if (args.RowFields.Size.NumberOfRows != args.Values.Size.NumberOfRows) + return Fail(eErrorType.Value, out error); + + if (!TryParseFunctionArg(arguments[3], args.Functions, out LambdaCalculator function, out FunctionLayout layout)) + return Fail(eErrorType.Value, out error); + + args.Function = function; + args.FunctionLayout = layout; + + if (arguments.Count > 4 && arguments[4].Value != null) + { + var v = Convert.ToInt32(arguments[3].Value); + if (!Enum.IsDefined(typeof(FieldHeaders), v)) + return Fail(eErrorType.Value, out error); + args.Headers = (FieldHeaders)v; + } + + // Total depth for rows (optional) + if (arguments.Count > 5 && arguments[5].Value != null) + { + if (!TryParseTotalDepthArg(arguments[5], args.RowFields.Size.NumberOfCols, out int rowTotalDepth)) + return Fail(eErrorType.Value, out error); + args.RowTotalDepth = rowTotalDepth; + } + + // SortOrder for RowFields (optional) + if (arguments.Count > 6 && arguments[6].Value != null) + args.RowSortOrders = ParseSortOrderArg(arguments[6]); + + // TotalDepth for columns (optional) + if (arguments.Count > 7 && arguments[7].Value != null) + { + if (!TryParseTotalDepthArg(arguments[7], args.ColFields.Size.NumberOfCols, out int colTotalDepth)) + return Fail(eErrorType.Value, out error); + args.ColTotalDepth = colTotalDepth; + } + + // SortOrder for ColFields (optional) + if (arguments.Count > 8 && arguments[8].Value != null) + args.RowSortOrders = ParseSortOrderArg(arguments[8]); + + if (arguments.Count > 9 && arguments[9].IsExcelRange) + args.FilterArray = arguments[9].ValueAsRangeInfo; + + // RelativeTo (optional) + if (arguments.Count > 10 && arguments[10].Value != null) + { + var v = Convert.ToInt32(arguments[10].Value); + if (!Enum.IsDefined(typeof(RelativeTo), v)) + return Fail(eErrorType.Value, out error); + args.RelativeTo = (RelativeTo)v; + } + + return true; + } + + + private void BuildPivotData( + PivotByArgs args, + ParsingContext context, + out List rowLevels, + out List colLevels, + out Dictionary>> pivotMap) + { + var resolvedHeaders = ResolveHeaders(args.Headers, args.Values); + bool hasHeaders = resolvedHeaders == FieldHeaders.YesAndShow + || resolvedHeaders == FieldHeaders.YesAndDontShow; + int startRow = hasHeaders ? 1 : 0; + + int nRowKeyCols = args.RowFields.Size.NumberOfCols; + int nColKeyCols = args.ColFields.Size.NumberOfCols; + int nValCols = args.Values.Size.NumberOfCols; + + var rowDict = new Dictionary(StringComparer.OrdinalIgnoreCase); + var rowOrder = new List(); + var colDict = new Dictionary(StringComparer.OrdinalIgnoreCase); + var colOrder = new List(); + pivotMap = new Dictionary>>(StringComparer.OrdinalIgnoreCase); + + int nRows = args.RowFields.Size.NumberOfRows; + + for (int r = startRow; r < nRows; r++) + { + // Filter + if (args.FilterArray != null) + { + var fv = args.FilterArray.GetOffset(r, 0); + if (fv is bool b && !b) continue; + if (fv is int i && i == 0) continue; + } + + // Läs radnycklar och kolumnnycklar + var rowKeyParts = new object[nRowKeyCols]; + for (int c = 0; c < nRowKeyCols; c++) + rowKeyParts[c] = args.RowFields.GetOffset(r, c); + + var colKeyParts = new object[nColKeyCols]; + for (int c = 0; c < nColKeyCols; c++) + colKeyParts[c] = args.ColFields.GetOffset(r, c); + + var vals = new object[nValCols]; + for (int c = 0; c < nValCols; c++) + vals[c] = args.Values.GetOffset(r, c); + + // Bygg radträd + InsertIntoTree(rowDict, rowOrder, rowKeyParts); + + // Bygg kolumnträd + InsertIntoTree(colDict, colOrder, colKeyParts); + + // Bygg pivotkartan – sammansatt nyckel per axel för snabb uppslagning + string rowKey = MakePivotKey(rowKeyParts); + string colKey = MakePivotKey(colKeyParts); + + if (!pivotMap.TryGetValue(rowKey, out var colMap)) + { + colMap = new Dictionary>(StringComparer.OrdinalIgnoreCase); + pivotMap[rowKey] = colMap; + } + if (!colMap.TryGetValue(colKey, out var cellVals)) + { + cellVals = new List(); + colMap[colKey] = cellVals; + } + cellVals.Add(vals); + args.AllValuesInOrder.Add(vals); + } + + rowLevels = BuildOrderedTree(rowDict, rowOrder); + colLevels = BuildOrderedTree(colDict, colOrder); + + // Aggregera subtotaler i radträdet (återanvänder AggregateTree från basklassen) + AggregateTree(rowLevels, args, context); + } + + + /// + /// Infogar en rad med sammansatt nyckel i ett träd. + /// Extraherad hjälpmetod – identisk logik som i BuildGroups, delad av GROUPBY och PIVOTBY. + /// + protected void InsertIntoTree( + Dictionary rootDict, + List rootOrder, + object[] keyParts) + { + var currentDict = rootDict; + var currentOrder = rootOrder; + GroupLevel currentLevel = null; + + for (int depth = 0; depth < keyParts.Length; depth++) + { + var keyStr = (keyParts[depth]?.ToString() ?? string.Empty).ToLowerInvariant(); + if (!currentDict.TryGetValue(keyStr, out currentLevel)) + { + currentLevel = new GroupLevel { Key = keyParts[depth] }; + currentDict[keyStr] = currentLevel; + currentOrder.Add(keyStr); + } + + if (depth < keyParts.Length - 1) + { + if (currentLevel.ChildDict == null) + { + currentLevel.ChildDict = new Dictionary(StringComparer.OrdinalIgnoreCase); + currentLevel.ChildOrder = new List(); + } + currentDict = currentLevel.ChildDict; + currentOrder = currentLevel.ChildOrder; + } + } + } + + private static string MakePivotKey(object[] parts) => + string.Join("\u001F", parts.Select(p => p?.ToString()?.ToLowerInvariant() ?? string.Empty).ToArray()); + } +} diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs index f270fd0fb..c1086bc12 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs @@ -1,18 +1,6 @@ -using FakeItEasy.Configuration; -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting; using OfficeOpenXml; -using OfficeOpenXml.Drawing.Chart; -using OfficeOpenXml.Drawing.Chart.ChartEx; -using OfficeOpenXml.FormulaParsing.Excel.Functions.Database; -using OfficeOpenXml.FormulaParsing.Excel.Functions.Information; -using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup; -using OfficeOpenXml.FormulaParsing.Excel.Functions.Statistical; using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using static OfficeOpenXml.FormulaParsing.Excel.Functions.Engineering.Conversions; namespace EPPlusTest.FormulaParsing.Excel.Functions.RefAndLookup diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs new file mode 100644 index 000000000..c11159a5a --- /dev/null +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs @@ -0,0 +1,31 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace EPPlusTest.FormulaParsing.Excel.Functions.RefAndLookup +{ + [TestClass] + public class PivotByTests : TestBase + { + [TestMethod] + public void PivotBy() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "Joe"; + s.Cells["A2"].Value = "Anna"; + s.Cells["C1"].Value = "Bertil"; + s.Cells["C2"].Value = "Joe"; + s.Cells["B1"].Value = 1; + s.Cells["B2"].Value = 2; + s.Cells["D1"].Formula = "PIVOTBY(A1:A2,C1:C2, B1:B2, _xleta.SUM)"; + s.Calculate(); + } + } + } +} From c8c1018a721c8ecf77c3531866547cff1f628387 Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Wed, 15 Apr 2026 16:29:06 +0200 Subject: [PATCH 02/16] WIP --- .../Excel/Functions/RefAndLookup/Groupby.cs | 5 - .../Excel/Functions/RefAndLookup/PivotBy.cs | 189 +++++++++++++++++- .../Functions/RefAndLookup/PivotByTests.cs | 47 ++++- 3 files changed, 224 insertions(+), 17 deletions(-) diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs index a171327df..5bd2cf2eb 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs @@ -114,12 +114,7 @@ private bool TryParseGroupByArgs(IList arguments, return true; } - - // ------------------------------------------------------- - // Sorting - // ------------------------------------------------------- - // ------------------------------------------------------- // Build result // ------------------------------------------------------- diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs index a4aa8ec30..4cca8d838 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs @@ -14,6 +14,7 @@ Date Author Change using OfficeOpenXml.FormulaParsing.Excel.Functions.Metadata; using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions; using OfficeOpenXml.FormulaParsing.FormulaExpressions; +using OfficeOpenXml.FormulaParsing.Ranges; using System; using System.Collections.Generic; using System.Linq; @@ -38,14 +39,11 @@ public override CompileResult Execute(IList arguments, Parsing BuildPivotData(args, context, out var rowLevels, out var colLevels, out var pivotMap); rowLevels = ApplySort(rowLevels, args); colLevels = ApplySort(colLevels, args); + var result = RenderPivot(rowLevels, colLevels, pivotMap, args, context); - throw new NotImplementedException(); + return CreateDynamicArrayResult(result, DataType.ExcelRange); } - // 2. Bygg radgrupperingar → List rowLevels (återanvänd BuildGroups) - // 3. Bygg kolumngrupperingar → List colLevels (återanvänd BuildGroups på col_fields) - // 4. Bygg aggregeringskartan: Dictionary<(rowKey, colKey), object> pivotMap - // 5. Applicera sortering på båda axlarna (återanvänd ApplySort) // 6. Räkna ut dimensioner: nRows, nCols // 7. Skriv kolumnhuvuden (ett pass) // 8. Skriv datarader rad för rad, slå upp pivotMap[(rowKey, colKey)] per kolumn @@ -166,15 +164,36 @@ private void BuildPivotData( for (int c = 0; c < nValCols; c++) vals[c] = args.Values.GetOffset(r, c); - // Bygg radträd - InsertIntoTree(rowDict, rowOrder, rowKeyParts); + // Bygg radträd och hämta lövet + var rowLeaf = InsertIntoTree(rowDict, rowOrder, rowKeyParts); + + // Lägg till värden på lövet (precis som BuildGroups gör) + var leafKey = MakePivotKey(rowKeyParts); + var row = rowLeaf.Rows.FirstOrDefault(rw => MakePivotKey(rw.KeyParts) == leafKey); + if (row == null) + { + row = new GroupRow { KeyParts = rowKeyParts }; + rowLeaf.Rows.Add(row); + } + row.Values.Add(vals); // Bygg kolumnträd InsertIntoTree(colDict, colOrder, colKeyParts); // Bygg pivotkartan – sammansatt nyckel per axel för snabb uppslagning string rowKey = MakePivotKey(rowKeyParts); - string colKey = MakePivotKey(colKeyParts); + + var colLeaf = InsertIntoTree(colDict, colOrder, colKeyParts); + + // Lägg till värden på kolumnlövet + var colKey = MakePivotKey(colKeyParts); + var colRow = colLeaf.Rows.FirstOrDefault(rw => MakePivotKey(rw.KeyParts) == colKey); + if (colRow == null) + { + colRow = new GroupRow { KeyParts = colKeyParts }; + colLeaf.Rows.Add(colRow); + } + colRow.Values.Add(vals); if (!pivotMap.TryGetValue(rowKey, out var colMap)) { @@ -193,8 +212,9 @@ private void BuildPivotData( rowLevels = BuildOrderedTree(rowDict, rowOrder); colLevels = BuildOrderedTree(colDict, colOrder); - // Aggregera subtotaler i radträdet (återanvänder AggregateTree från basklassen) + // Aggregera subtotaler i radträdet och colträdet. AggregateTree(rowLevels, args, context); + AggregateTree(colLevels, args, context); } @@ -202,7 +222,7 @@ private void BuildPivotData( /// Infogar en rad med sammansatt nyckel i ett träd. /// Extraherad hjälpmetod – identisk logik som i BuildGroups, delad av GROUPBY och PIVOTBY. /// - protected void InsertIntoTree( + protected GroupLevel InsertIntoTree( Dictionary rootDict, List rootOrder, object[] keyParts) @@ -232,9 +252,156 @@ protected void InsertIntoTree( currentOrder = currentLevel.ChildOrder; } } + return currentLevel; // <-- returnera lövet } private static string MakePivotKey(object[] parts) => string.Join("\u001F", parts.Select(p => p?.ToString()?.ToLowerInvariant() ?? string.Empty).ToArray()); + + private InMemoryRange RenderPivot( + List rowLevels, + List colLevels, + Dictionary>> pivotMap, + PivotByArgs args, + ParsingContext context) + { + var rowLeaves = CollectLeavesWithPath(rowLevels, new object[0]); + var colLeaves = CollectLeavesWithPath(colLevels, new object[0]); + + int nRowKeyCols = args.RowFields.Size.NumberOfCols; + int nColLeaves = colLeaves.Count; + int nRowLeaves = rowLeaves.Count; + int nValCols = args.Values.Size.NumberOfCols; + + bool showRowTotal = args.RowTotalDepth != TotalDepthNoTotals; + bool showColTotal = args.ColTotalDepth != TotalDepthNoTotals; + bool rowTotalAtTop = args.RowTotalDepth < 0; + bool colTotalAtLeft = args.ColTotalDepth < 0; + + // Dimensioner + int dataRows = nRowLeaves; + int dataCols = nColLeaves; + int totalRows = 1 + dataRows + (showRowTotal ? 1 : 0); // rubrik + data + ev. grand total-rad + int totalCols = nRowKeyCols + dataCols + (showColTotal ? 1 : 0); // radnycklar + data + ev. grand total-kolumn + + var result = new InMemoryRange(totalRows, (short)totalCols); + + // Kolumnindex för grand total-kolumnen + int grandTotalCol = colTotalAtLeft ? nRowKeyCols : nRowKeyCols + dataCols; + // Radindex för grand total-raden + int grandTotalRow = rowTotalAtTop ? 1 : 1 + dataRows; + + // --- Rubrikrad (rad 0) --- + int colOffset = colTotalAtLeft ? 1 : 0; + for (int ci = 0; ci < nColLeaves; ci++) + { + var colPath = colLeaves[ci].Path; + result.SetValue(0, nRowKeyCols + colOffset + ci, colPath[colPath.Length - 1]); + } + if (showColTotal) + result.SetValue(0, grandTotalCol, "Total"); + + // --- Datarader --- + int dataRowStart = rowTotalAtTop ? 2 : 1; // hoppa över grand total-raden om den är överst + for (int ri = 0; ri < nRowLeaves; ri++) + { + int outputRow = dataRowStart + ri; + var rowPath = rowLeaves[ri].Path; + var rowLeaf = rowLeaves[ri].Leaf; + + // Radnycklar + for (int k = 0; k < rowPath.Length; k++) + result.SetValue(outputRow, k, rowPath[k]); + + string rowKey = MakePivotKey(rowPath); + + // Datavärden per kolumnlöv + int colIdx = colTotalAtLeft ? nRowKeyCols + 1 : nRowKeyCols; + for (int ci = 0; ci < nColLeaves; ci++) + { + string colKey = MakePivotKey(colLeaves[ci].Path); + + object aggregated = null; + if (pivotMap.TryGetValue(rowKey, out var colMap) && + colMap.TryGetValue(colKey, out var cellVals)) + { + aggregated = Aggregate(args.Function, cellVals, context, + args.Function.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null); + } + result.SetValue(outputRow, colIdx + ci, aggregated); + } + + // Grand total-kolumn: SubtotalValues för detta radlöv + if (showColTotal) + { + var grandTotalVal = rowLeaf.SubtotalValues != null && rowLeaf.SubtotalValues.Count > 0 + ? rowLeaf.SubtotalValues[0][0] + : null; + result.SetValue(outputRow, grandTotalCol, grandTotalVal); + } + } + + // --- Grand total-rad --- + if (showRowTotal) + { + result.SetValue(grandTotalRow, 0, "Total"); + for (int c = 1; c < nRowKeyCols; c++) + result.SetValue(grandTotalRow, c, string.Empty); + + int colIdx = colTotalAtLeft ? nRowKeyCols + 1 : nRowKeyCols; + for (int ci = 0; ci < nColLeaves; ci++) + { + var colLeaf = colLeaves[ci].Leaf; + var grandTotalVal = colLeaf.SubtotalValues != null && colLeaf.SubtotalValues.Count > 0 + ? colLeaf.SubtotalValues[0][0] + : null; + result.SetValue(grandTotalRow, colIdx + ci, grandTotalVal); + } + + // Hörncellen: aggregera hela AllValuesInOrder + if (showColTotal) + { + var colValues = args.AllValuesInOrder + .Select(v => new object[] { v[0] }) + .ToList(); + var cornerVal = Aggregate(args.Function, colValues, context, + args.Function.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null); + result.SetValue(grandTotalRow, grandTotalCol, cornerVal); + } + } + + return result; + } + + private List CollectLeavesWithPath(List levels, object[] parentPath) + { + var result = new List(); + foreach (var level in levels) + { + var path = new object[parentPath.Length + 1]; + for (int i = 0; i < parentPath.Length; i++) + path[i] = parentPath[i]; + path[parentPath.Length] = level.Key; + + if (level.IsLeaf) + result.Add(new LeafWithPath(level, path)); + else + result.AddRange(CollectLeavesWithPath(level.Children, path)); + } + return result; + } + + private class LeafWithPath + { + public GroupLevel Leaf { get; private set; } + public object[] Path { get; private set; } + + public LeafWithPath(GroupLevel leaf, object[] path) + { + Leaf = leaf; + Path = path; + } + } + } -} +} \ No newline at end of file diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs index c11159a5a..7b1c9cd68 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs @@ -12,7 +12,7 @@ namespace EPPlusTest.FormulaParsing.Excel.Functions.RefAndLookup public class PivotByTests : TestBase { [TestMethod] - public void PivotBy() + public void BasicPivotBy() { using (var package = new ExcelPackage()) { @@ -25,7 +25,52 @@ public void PivotBy() s.Cells["B2"].Value = 2; s.Cells["D1"].Formula = "PIVOTBY(A1:A2,C1:C2, B1:B2, _xleta.SUM)"; s.Calculate(); + + // Rubrikrad + Assert.AreEqual("Bertil", s.Cells["E1"].Value); + Assert.AreEqual("Joe", s.Cells["F1"].Value); + Assert.AreEqual("Total", s.Cells["G1"].Value); + + // Anna-rad + Assert.AreEqual("Anna", s.Cells["D2"].Value); + Assert.AreEqual(2d, s.Cells["F2"].Value); + Assert.AreEqual(2d, s.Cells["G2"].Value); + + // Joe-rad + Assert.AreEqual("Joe", s.Cells["D3"].Value); + Assert.AreEqual(1d, s.Cells["E3"].Value); + Assert.AreEqual(1d, s.Cells["G3"].Value); + + // Total-rad + Assert.AreEqual("Total", s.Cells["D4"].Value); + Assert.AreEqual(1d, s.Cells["E4"].Value); + Assert.AreEqual(2d, s.Cells["F4"].Value); + Assert.AreEqual(3d, s.Cells["G4"].Value); + } + } + + [TestMethod] + public void PivotBy() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "Stockholm"; + s.Cells["A2"].Value = "Linköping"; + s.Cells["A3"].Value = "Örebro"; + s.Cells["B1"].Value = 2026; + s.Cells["B2"].Value = 2026; + s.Cells["B3"].Value = 2025; + s.Cells["C1"].Value = "Q2"; + s.Cells["C2"].Value = "Q1"; + s.Cells["C3"].Value = "Q2"; + s.Cells["D1"].Value = 34543; + s.Cells["D2"].Value = 43265; + s.Cells["D3"].Value = 75461; + s.Cells["E1"].Formula = "PIVOTBY(A1:A3,B1:C3,D1:D3, _xleta.SUM,,,,2,-1)"; + s.Calculate(); } } + } } From 2b1ff0d22c5a7206dd70a228dd45202b9a4d3475 Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Thu, 16 Apr 2026 16:26:09 +0200 Subject: [PATCH 03/16] WIP --- .../Excel/Functions/RefAndLookup/PivotBy.cs | 120 ++++++++++++------ .../Functions/RefAndLookup/PivotByTests.cs | 2 +- 2 files changed, 82 insertions(+), 40 deletions(-) diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs index 4cca8d838..01a7de096 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs @@ -17,6 +17,7 @@ Date Author Change using OfficeOpenXml.FormulaParsing.Ranges; using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Text; @@ -207,6 +208,7 @@ private void BuildPivotData( } cellVals.Add(vals); args.AllValuesInOrder.Add(vals); + } rowLevels = BuildOrderedTree(rowDict, rowOrder); @@ -269,6 +271,7 @@ private InMemoryRange RenderPivot( var colLeaves = CollectLeavesWithPath(colLevels, new object[0]); int nRowKeyCols = args.RowFields.Size.NumberOfCols; + int nColKeyRows = args.ColFields.Size.NumberOfCols; // antal rubrikrader = kolumndjup int nColLeaves = colLeaves.Count; int nRowLeaves = rowLeaves.Count; int nValCols = args.Values.Size.NumberOfCols; @@ -281,28 +284,42 @@ private InMemoryRange RenderPivot( // Dimensioner int dataRows = nRowLeaves; int dataCols = nColLeaves; - int totalRows = 1 + dataRows + (showRowTotal ? 1 : 0); // rubrik + data + ev. grand total-rad - int totalCols = nRowKeyCols + dataCols + (showColTotal ? 1 : 0); // radnycklar + data + ev. grand total-kolumn + int totalRows = nColKeyRows + dataRows + (showRowTotal ? 1 : 0); + int totalCols = nRowKeyCols + dataCols + (showColTotal ? 1 : 0); var result = new InMemoryRange(totalRows, (short)totalCols); - // Kolumnindex för grand total-kolumnen int grandTotalCol = colTotalAtLeft ? nRowKeyCols : nRowKeyCols + dataCols; - // Radindex för grand total-raden - int grandTotalRow = rowTotalAtTop ? 1 : 1 + dataRows; - - // --- Rubrikrad (rad 0) --- + int grandTotalRow = rowTotalAtTop ? nColKeyRows : nColKeyRows + dataRows; + int dataRowStart = nColKeyRows + (rowTotalAtTop ? 1 : 0); int colOffset = colTotalAtLeft ? 1 : 0; - for (int ci = 0; ci < nColLeaves; ci++) + + // --- Rubrikrader (en per kolumnnivå) --- + for (int level = 0; level < nColKeyRows; level++) { - var colPath = colLeaves[ci].Path; - result.SetValue(0, nRowKeyCols + colOffset + ci, colPath[colPath.Length - 1]); + for (int ci = 0; ci < nColLeaves; ci++) + { + var colPath = colLeaves[ci].Path; + var val = level < colPath.Length ? colPath[level] : null; + + // Skriv bara ut värdet om det skiljer sig från föregående kolumn på denna nivå + var prevVal = ci > 0 && colLeaves[ci - 1].Path.Length > level + ? colLeaves[ci - 1].Path[level] + : null; + if (ci == 0 || !Equals(val, prevVal)) + result.SetValue(level, nRowKeyCols + colOffset + ci, val); + } + + if (showColTotal) + result.SetValue(level, grandTotalCol, level == 0 ? (object)"Grand Total" : string.Empty); } - if (showColTotal) - result.SetValue(0, grandTotalCol, "Total"); + + // --- Grand total-rad överst --- + if (rowTotalAtTop && showRowTotal) + WriteGrandTotalRow(result, nColKeyRows, colLeaves, pivotMap, args, context, + nRowKeyCols, nColLeaves, colOffset, grandTotalCol, showColTotal); // --- Datarader --- - int dataRowStart = rowTotalAtTop ? 2 : 1; // hoppa över grand total-raden om den är överst for (int ri = 0; ri < nRowLeaves; ri++) { int outputRow = dataRowStart + ri; @@ -316,7 +333,6 @@ private InMemoryRange RenderPivot( string rowKey = MakePivotKey(rowPath); // Datavärden per kolumnlöv - int colIdx = colTotalAtLeft ? nRowKeyCols + 1 : nRowKeyCols; for (int ci = 0; ci < nColLeaves; ci++) { string colKey = MakePivotKey(colLeaves[ci].Path); @@ -328,10 +344,10 @@ private InMemoryRange RenderPivot( aggregated = Aggregate(args.Function, cellVals, context, args.Function.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null); } - result.SetValue(outputRow, colIdx + ci, aggregated); + result.SetValue(outputRow, nRowKeyCols + colOffset + ci, aggregated); } - // Grand total-kolumn: SubtotalValues för detta radlöv + // Grand total-kolumn för denna rad if (showColTotal) { var grandTotalVal = rowLeaf.SubtotalValues != null && rowLeaf.SubtotalValues.Count > 0 @@ -341,36 +357,60 @@ private InMemoryRange RenderPivot( } } - // --- Grand total-rad --- - if (showRowTotal) + // --- Grand total-rad nederst --- + if (!rowTotalAtTop && showRowTotal) + WriteGrandTotalRow(result, grandTotalRow, colLeaves, pivotMap, args, context, + nRowKeyCols, nColLeaves, colOffset, grandTotalCol, showColTotal); + + return result; + } + + private void WriteGrandTotalRow( + InMemoryRange result, + int r, + List colLeaves, + Dictionary>> pivotMap, + PivotByArgs args, + ParsingContext context, + int nRowKeyCols, + int nColLeaves, + int colOffset, + int grandTotalCol, + bool showColTotal) + { + result.SetValue(r, 0, "Total"); + for (int c = 1; c < nRowKeyCols; c++) + result.SetValue(r, c, string.Empty); + + for (int ci = 0; ci < nColLeaves; ci++) { - result.SetValue(grandTotalRow, 0, "Total"); - for (int c = 1; c < nRowKeyCols; c++) - result.SetValue(grandTotalRow, c, string.Empty); + string colKey = MakePivotKey(colLeaves[ci].Path); - int colIdx = colTotalAtLeft ? nRowKeyCols + 1 : nRowKeyCols; - for (int ci = 0; ci < nColLeaves; ci++) - { - var colLeaf = colLeaves[ci].Leaf; - var grandTotalVal = colLeaf.SubtotalValues != null && colLeaf.SubtotalValues.Count > 0 - ? colLeaf.SubtotalValues[0][0] - : null; - result.SetValue(grandTotalRow, colIdx + ci, grandTotalVal); - } + var allValsForCol = pivotMap.Values + .SelectMany(cm => cm.TryGetValue(colKey, out var cv) + ? cv + : Enumerable.Empty()) + .ToList(); - // Hörncellen: aggregera hela AllValuesInOrder - if (showColTotal) + object grandVal = null; + if (allValsForCol.Count > 0) { - var colValues = args.AllValuesInOrder - .Select(v => new object[] { v[0] }) - .ToList(); - var cornerVal = Aggregate(args.Function, colValues, context, + grandVal = Aggregate(args.Function, allValsForCol, context, args.Function.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null); - result.SetValue(grandTotalRow, grandTotalCol, cornerVal); } + result.SetValue(r, nRowKeyCols + colOffset + ci, grandVal); } - return result; + // Hörncellen: aggregera hela AllValuesInOrder + if (showColTotal) + { + var colValues = args.AllValuesInOrder + .Select(v => new object[] { v[0] }) + .ToList(); + var cornerVal = Aggregate(args.Function, colValues, context, + args.Function.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null); + result.SetValue(r, grandTotalCol, cornerVal); + } } private List CollectLeavesWithPath(List levels, object[] parentPath) @@ -383,10 +423,12 @@ private List CollectLeavesWithPath(List levels, object path[i] = parentPath[i]; path[parentPath.Length] = level.Key; + Debug.WriteLine($"Level key={level.Key}, IsLeaf={level.IsLeaf}, path.Length={path.Length}, path={string.Join("|", path.Select(p => p?.ToString()).ToArray())}"); + if (level.IsLeaf) result.Add(new LeafWithPath(level, path)); else - result.AddRange(CollectLeavesWithPath(level.Children, path)); + result.AddRange(CollectLeavesWithPath(level.Children, path)); } return result; } diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs index 7b1c9cd68..fe54e1974 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs @@ -67,7 +67,7 @@ public void PivotBy() s.Cells["D1"].Value = 34543; s.Cells["D2"].Value = 43265; s.Cells["D3"].Value = 75461; - s.Cells["E1"].Formula = "PIVOTBY(A1:A3,B1:C3,D1:D3, _xleta.SUM,,,,2,-1)"; + s.Cells["E1"].Formula = "PIVOTBY(A1:A3,B1:C3,D1:D3, _xleta.SUM)"; s.Calculate(); } } From da7d23b8d129f39658883d3e9010ca7fae482e85 Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Mon, 20 Apr 2026 15:28:00 +0200 Subject: [PATCH 04/16] WIP --- .../Excel/Functions/RefAndLookup/PivotBy.cs | 174 +++++++++++------- .../Functions/RefAndLookup/PivotByTests.cs | 92 +++++++++ 2 files changed, 201 insertions(+), 65 deletions(-) diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs index 01a7de096..a9e034b92 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs @@ -11,6 +11,7 @@ Date Author Change 13/4/2026 EPPlus Software AB EPPlus v8.6 *************************************************************************************************/ +using OfficeOpenXml.FormulaParsing.Excel.Functions.DateAndTime; using OfficeOpenXml.FormulaParsing.Excel.Functions.Metadata; using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions; using OfficeOpenXml.FormulaParsing.FormulaExpressions; @@ -37,10 +38,10 @@ public override CompileResult Execute(IList arguments, Parsing { if (!TryParsePivotByArgs(arguments, out var args, out var error)) return error; - BuildPivotData(args, context, out var rowLevels, out var colLevels, out var pivotMap); - rowLevels = ApplySort(rowLevels, args); - colLevels = ApplySort(colLevels, args); - var result = RenderPivot(rowLevels, colLevels, pivotMap, args, context); + BuildPivotData(args, context, out var rowLeaves, out var colLeaves, out var pivotMap); + rowLeaves = ApplyRowSort(rowLeaves, args); + colLeaves = ApplyColSort(colLeaves, args); + var result = RenderPivot(rowLeaves, colLeaves, pivotMap, args, context); return CreateDynamicArrayResult(result, DataType.ExcelRange); } @@ -100,7 +101,7 @@ protected bool TryParsePivotByArgs(IList arguments, // SortOrder for ColFields (optional) if (arguments.Count > 8 && arguments[8].Value != null) - args.RowSortOrders = ParseSortOrderArg(arguments[8]); + args.ColSortOrders = ParseSortOrderArg(arguments[8]); if (arguments.Count > 9 && arguments[9].IsExcelRange) args.FilterArray = arguments[9].ValueAsRangeInfo; @@ -119,11 +120,11 @@ protected bool TryParsePivotByArgs(IList arguments, private void BuildPivotData( - PivotByArgs args, - ParsingContext context, - out List rowLevels, - out List colLevels, - out Dictionary>> pivotMap) + PivotByArgs args, + ParsingContext context, + out List rowLeaves, + out List colLeaves, + out Dictionary>> pivotMap) { var resolvedHeaders = ResolveHeaders(args.Headers, args.Values); bool hasHeaders = resolvedHeaders == FieldHeaders.YesAndShow @@ -134,17 +135,16 @@ private void BuildPivotData( int nColKeyCols = args.ColFields.Size.NumberOfCols; int nValCols = args.Values.Size.NumberOfCols; - var rowDict = new Dictionary(StringComparer.OrdinalIgnoreCase); - var rowOrder = new List(); - var colDict = new Dictionary(StringComparer.OrdinalIgnoreCase); - var colOrder = new List(); + var rowLeafDict = new Dictionary(StringComparer.OrdinalIgnoreCase); + var rowLeafOrder = new List(); + var colLeafDict = new Dictionary(StringComparer.OrdinalIgnoreCase); + var colLeafOrder = new List(); pivotMap = new Dictionary>>(StringComparer.OrdinalIgnoreCase); int nRows = args.RowFields.Size.NumberOfRows; for (int r = startRow; r < nRows; r++) { - // Filter if (args.FilterArray != null) { var fv = args.FilterArray.GetOffset(r, 0); @@ -152,7 +152,6 @@ private void BuildPivotData( if (fv is int i && i == 0) continue; } - // Läs radnycklar och kolumnnycklar var rowKeyParts = new object[nRowKeyCols]; for (int c = 0; c < nRowKeyCols; c++) rowKeyParts[c] = args.RowFields.GetOffset(r, c); @@ -165,37 +164,33 @@ private void BuildPivotData( for (int c = 0; c < nValCols; c++) vals[c] = args.Values.GetOffset(r, c); - // Bygg radträd och hämta lövet - var rowLeaf = InsertIntoTree(rowDict, rowOrder, rowKeyParts); - - // Lägg till värden på lövet (precis som BuildGroups gör) - var leafKey = MakePivotKey(rowKeyParts); - var row = rowLeaf.Rows.FirstOrDefault(rw => MakePivotKey(rw.KeyParts) == leafKey); - if (row == null) + // Radlöv + string rowKey = MakePivotKey(rowKeyParts); + if (!rowLeafDict.ContainsKey(rowKey)) { - row = new GroupRow { KeyParts = rowKeyParts }; - rowLeaf.Rows.Add(row); + var leaf = new GroupLevel { Key = rowKeyParts[rowKeyParts.Length - 1] }; + rowLeafDict[rowKey] = new LeafWithPath(leaf, rowKeyParts); + rowLeafOrder.Add(rowKey); } - row.Values.Add(vals); - - // Bygg kolumnträd - InsertIntoTree(colDict, colOrder, colKeyParts); - - // Bygg pivotkartan – sammansatt nyckel per axel för snabb uppslagning - string rowKey = MakePivotKey(rowKeyParts); - - var colLeaf = InsertIntoTree(colDict, colOrder, colKeyParts); + var rowLeafEntry = rowLeafDict[rowKey]; + var existingRow = rowLeafEntry.Leaf.Rows.FirstOrDefault(rw => MakePivotKey(rw.KeyParts) == rowKey); + if (existingRow == null) + { + existingRow = new GroupRow { KeyParts = rowKeyParts }; + rowLeafEntry.Leaf.Rows.Add(existingRow); + } + existingRow.Values.Add(vals); - // Lägg till värden på kolumnlövet - var colKey = MakePivotKey(colKeyParts); - var colRow = colLeaf.Rows.FirstOrDefault(rw => MakePivotKey(rw.KeyParts) == colKey); - if (colRow == null) + // Kolumnlöv + string colKey = MakePivotKey(colKeyParts); + if (!colLeafDict.ContainsKey(colKey)) { - colRow = new GroupRow { KeyParts = colKeyParts }; - colLeaf.Rows.Add(colRow); + var leaf = new GroupLevel { Key = colKeyParts[colKeyParts.Length - 1] }; + colLeafDict[colKey] = new LeafWithPath(leaf, colKeyParts); + colLeafOrder.Add(colKey); } - colRow.Values.Add(vals); + // Pivotkartan if (!pivotMap.TryGetValue(rowKey, out var colMap)) { colMap = new Dictionary>(StringComparer.OrdinalIgnoreCase); @@ -208,17 +203,79 @@ private void BuildPivotData( } cellVals.Add(vals); args.AllValuesInOrder.Add(vals); - } - rowLevels = BuildOrderedTree(rowDict, rowOrder); - colLevels = BuildOrderedTree(colDict, colOrder); + rowLeaves = rowLeafOrder.Select(k => rowLeafDict[k]).ToList(); + colLeaves = colLeafOrder.Select(k => colLeafDict[k]).ToList(); + + // Aggregera SubtotalValues per radlöv (används för rad-totals) + foreach (var rl in rowLeaves) + AggregateLeaf(rl.Leaf, args, context); + } - // Aggregera subtotaler i radträdet och colträdet. - AggregateTree(rowLevels, args, context); - AggregateTree(colLevels, args, context); + private void AggregateLeaf(GroupLevel leaf, PivotByArgs args, ParsingContext context) + { + var allVals = leaf.Rows.SelectMany(r => r.Values).ToList(); + leaf.SubtotalValues = args.Functions.Select(f => + { + int nValCols = allVals[0].Length; + var result = new object[nValCols]; + for (int col = 0; col < nValCols; col++) + { + var colValues = allVals.Select(v => new object[] { v[col] }).ToList(); + result[col] = Aggregate(f, colValues, context, + f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null); + } + return result; + }).ToList(); + leaf.SubtotalValue = leaf.SubtotalValues[0][0]; } + + + private List ApplyRowSort(List rowLeaves, PivotByArgs args) => + ApplyLeafSort(rowLeaves, args.RowSortOrders); + + private List ApplyColSort(List colLeaves, PivotByArgs args) => + ApplyLeafSort(colLeaves, args.ColSortOrders); + + private List ApplyLeafSort(List leaves, int[] sortOrders) + { + if (sortOrders == null || sortOrders.All(s => s == 0)) return leaves; + + IOrderedEnumerable ordered = null; + foreach (var sortOrder in sortOrders) + { + if (sortOrder == 0) continue; + bool desc = sortOrder < 0; + int col = Math.Abs(sortOrder) - 1; + var capturedCol = col; + + Func keySelector = lp => + capturedCol < lp.Path.Length ? lp.Path[capturedCol] : null; + + if (ordered == null) + ordered = desc + ? leaves.OrderByDescending(keySelector, _comparer) + : leaves.OrderBy(keySelector, _comparer); + else + ordered = desc + ? ordered.ThenByDescending(keySelector, _comparer) + : ordered.ThenBy(keySelector, _comparer); + } + + // Bryt oavgjort med efterföljande nyckeldelar + int maxDepth = leaves.Max(l => l.Path.Length); + int sortedDepth = sortOrders.Max(s => Math.Abs(s)); + for (int col = sortedDepth; col < maxDepth; col++) + { + var capturedCol = col; + Func keySelector = lp => + capturedCol < lp.Path.Length ? lp.Path[capturedCol] : null; + ordered = ordered.ThenBy(keySelector, _comparer); + } + return ordered?.ToList() ?? leaves; + } /// /// Infogar en rad med sammansatt nyckel i ett träd. @@ -261,17 +318,14 @@ private static string MakePivotKey(object[] parts) => string.Join("\u001F", parts.Select(p => p?.ToString()?.ToLowerInvariant() ?? string.Empty).ToArray()); private InMemoryRange RenderPivot( - List rowLevels, - List colLevels, + List rowLeaves, + List colLeaves, Dictionary>> pivotMap, PivotByArgs args, ParsingContext context) { - var rowLeaves = CollectLeavesWithPath(rowLevels, new object[0]); - var colLeaves = CollectLeavesWithPath(colLevels, new object[0]); - int nRowKeyCols = args.RowFields.Size.NumberOfCols; - int nColKeyRows = args.ColFields.Size.NumberOfCols; // antal rubrikrader = kolumndjup + int nColKeyRows = args.ColFields.Size.NumberOfCols; int nColLeaves = colLeaves.Count; int nRowLeaves = rowLeaves.Count; int nValCols = args.Values.Size.NumberOfCols; @@ -281,7 +335,6 @@ private InMemoryRange RenderPivot( bool rowTotalAtTop = args.RowTotalDepth < 0; bool colTotalAtLeft = args.ColTotalDepth < 0; - // Dimensioner int dataRows = nRowLeaves; int dataCols = nColLeaves; int totalRows = nColKeyRows + dataRows + (showRowTotal ? 1 : 0); @@ -301,17 +354,11 @@ private InMemoryRange RenderPivot( { var colPath = colLeaves[ci].Path; var val = level < colPath.Length ? colPath[level] : null; - - // Skriv bara ut värdet om det skiljer sig från föregående kolumn på denna nivå - var prevVal = ci > 0 && colLeaves[ci - 1].Path.Length > level - ? colLeaves[ci - 1].Path[level] - : null; - if (ci == 0 || !Equals(val, prevVal)) - result.SetValue(level, nRowKeyCols + colOffset + ci, val); + result.SetValue(level, nRowKeyCols + colOffset + ci, val); } if (showColTotal) - result.SetValue(level, grandTotalCol, level == 0 ? (object)"Grand Total" : string.Empty); + result.SetValue(level, grandTotalCol, level == 0 ? (object)"Total" : string.Empty); } // --- Grand total-rad överst --- @@ -326,13 +373,11 @@ private InMemoryRange RenderPivot( var rowPath = rowLeaves[ri].Path; var rowLeaf = rowLeaves[ri].Leaf; - // Radnycklar for (int k = 0; k < rowPath.Length; k++) result.SetValue(outputRow, k, rowPath[k]); string rowKey = MakePivotKey(rowPath); - // Datavärden per kolumnlöv for (int ci = 0; ci < nColLeaves; ci++) { string colKey = MakePivotKey(colLeaves[ci].Path); @@ -347,7 +392,6 @@ private InMemoryRange RenderPivot( result.SetValue(outputRow, nRowKeyCols + colOffset + ci, aggregated); } - // Grand total-kolumn för denna rad if (showColTotal) { var grandTotalVal = rowLeaf.SubtotalValues != null && rowLeaf.SubtotalValues.Count > 0 diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs index fe54e1974..64f6615d4 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs @@ -69,8 +69,100 @@ public void PivotBy() s.Cells["D3"].Value = 75461; s.Cells["E1"].Formula = "PIVOTBY(A1:A3,B1:C3,D1:D3, _xleta.SUM)"; s.Calculate(); + + // Rubrikrad 1 (år) + Assert.AreEqual(2025, s.Cells["F1"].Value); + Assert.AreEqual(2026, s.Cells["G1"].Value); + Assert.AreEqual(2026, s.Cells["H1"].Value); + Assert.AreEqual("Total", s.Cells["I1"].Value); + + // Rubrikrad 2 (kvartal) + Assert.AreEqual("Q2", s.Cells["F2"].Value); + Assert.AreEqual("Q1", s.Cells["G2"].Value); + Assert.AreEqual("Q2", s.Cells["H2"].Value); + + // Linköping + Assert.AreEqual("Linköping", s.Cells["E3"].Value); + Assert.AreEqual(43265d, s.Cells["G3"].Value); + Assert.AreEqual(43265d, s.Cells["I3"].Value); + + // Örebro + Assert.AreEqual("Örebro", s.Cells["E4"].Value); + Assert.AreEqual(75461d, s.Cells["F4"].Value); + Assert.AreEqual(75461d, s.Cells["I4"].Value); + + // Stockholm + Assert.AreEqual("Stockholm", s.Cells["E5"].Value); + Assert.AreEqual(34543d, s.Cells["H5"].Value); + Assert.AreEqual(34543d, s.Cells["I5"].Value); + + // Total-rad + Assert.AreEqual("Total", s.Cells["E6"].Value); + Assert.AreEqual(75461d, s.Cells["F6"].Value); + Assert.AreEqual(43265d, s.Cells["G6"].Value); + Assert.AreEqual(34543d, s.Cells["H6"].Value); + Assert.AreEqual(153269d, s.Cells["I6"].Value); } } + [TestMethod] + public void PivotBySortOrder() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; + s.Cells["A2"].Value = "A"; + s.Cells["A3"].Value = "B"; + s.Cells["A4"].Value = "B"; + s.Cells["A5"].Value = "C"; + s.Cells["A6"].Value = "C"; + s.Cells["B1"].Value = "X"; + s.Cells["B2"].Value = "Y"; + s.Cells["B3"].Value = "X"; + s.Cells["B4"].Value = "Y"; + s.Cells["B5"].Value = "X"; + s.Cells["B6"].Value = "Y"; + s.Cells["C1"].Value = 2; + s.Cells["C2"].Value = 4; + s.Cells["C3"].Value = 1; + s.Cells["C4"].Value = 5; + s.Cells["C5"].Value = 7; + s.Cells["C6"].Value = 4; + s.Cells["D1"].Formula = "PIVOTBY(A1:A6,B1:B6,C1:C6,_xleta.SUM,,,-1,,-1)"; + s.Calculate(); + + Assert.AreEqual("Y", s.Cells["E1"].Value); + Assert.AreEqual("X", s.Cells["F1"].Value); + Assert.AreEqual("Total", s.Cells["G1"].Value); + + // C + Assert.AreEqual("C", s.Cells["D2"].Value); + Assert.AreEqual(4d, s.Cells["E2"].Value); + Assert.AreEqual(7d, s.Cells["F2"].Value); + Assert.AreEqual(11d, s.Cells["G2"].Value); + + // B + Assert.AreEqual("B", s.Cells["D3"].Value); + Assert.AreEqual(5d, s.Cells["E3"].Value); + Assert.AreEqual(1d, s.Cells["F3"].Value); + Assert.AreEqual(6d, s.Cells["G3"].Value); + + // A + Assert.AreEqual("A", s.Cells["D4"].Value); + Assert.AreEqual(4d, s.Cells["E4"].Value); + Assert.AreEqual(2d, s.Cells["F4"].Value); + Assert.AreEqual(6d, s.Cells["G4"].Value); + + // Total + Assert.AreEqual("Total", s.Cells["D5"].Value); + Assert.AreEqual(13d, s.Cells["E5"].Value); + Assert.AreEqual(10d, s.Cells["F5"].Value); + Assert.AreEqual(23d, s.Cells["G5"].Value); + } + } + + [TestMethod] + } } From 2a4ae53a6bf0da2efd040a58fffe07adb2e44186 Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Wed, 22 Apr 2026 16:26:32 +0200 Subject: [PATCH 05/16] WIP --- .../Excel/Functions/RefAndLookup/PivotBy.cs | 169 +++++++++++++----- .../Functions/RefAndLookup/PivotByTests.cs | 56 ++++++ 2 files changed, 177 insertions(+), 48 deletions(-) diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs index a9e034b92..3d31fbaaa 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs @@ -30,7 +30,8 @@ namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup Description = "Allows you to create a summary of your data via a formula. It supports grouping along two axis and aggregating the associated values.")] internal class PivotBy : GroupByFunctionBase { - public override int ArgumentMinLength => 3; // kanske ska vara 4 + public override int ArgumentMinLength => 3; // kan + // ske ska vara 4 public override string NamespacePrefix => "_xlfn."; public override bool ExecutesLambda => true; @@ -318,11 +319,11 @@ private static string MakePivotKey(object[] parts) => string.Join("\u001F", parts.Select(p => p?.ToString()?.ToLowerInvariant() ?? string.Empty).ToArray()); private InMemoryRange RenderPivot( - List rowLeaves, - List colLeaves, - Dictionary>> pivotMap, - PivotByArgs args, - ParsingContext context) + List rowLeaves, + List colLeaves, + Dictionary>> pivotMap, + PivotByArgs args, + ParsingContext context) { int nRowKeyCols = args.RowFields.Size.NumberOfCols; int nColKeyRows = args.ColFields.Size.NumberOfCols; @@ -334,37 +335,60 @@ private InMemoryRange RenderPivot( bool showColTotal = args.ColTotalDepth != TotalDepthNoTotals; bool rowTotalAtTop = args.RowTotalDepth < 0; bool colTotalAtLeft = args.ColTotalDepth < 0; + int colSubtotalDepth = Math.Abs(args.ColTotalDepth); + bool showColSubtotals = colSubtotalDepth > 1; - int dataRows = nRowLeaves; - int dataCols = nColLeaves; - int totalRows = nColKeyRows + dataRows + (showRowTotal ? 1 : 0); - int totalCols = nRowKeyCols + dataCols + (showColTotal ? 1 : 0); + // Gruppera kolumnlöv per översta nyckelgrupp (nivå 0) + var colGroups = colLeaves + .GroupBy(l => l.Path[0]?.ToString()?.ToLowerInvariant() ?? string.Empty) + .ToList(); + + // Bygg ordnad lista av kolumner – löv först, subtotal sist i varje grupp + var colEntries = new List(); + foreach (var group in colGroups) + { + var groupLeaves = group.ToList(); + foreach (var leaf in groupLeaves) + colEntries.Add(new ColEntry { IsSubtotal = false, Leaf = leaf }); + if (showColSubtotals) + colEntries.Add(new ColEntry { IsSubtotal = true, GroupKey = group.Key, GroupLeaves = groupLeaves }); + } + + int nDataCols = colEntries.Count; + int totalRows = nColKeyRows + nRowLeaves + (showRowTotal ? 1 : 0); + int totalCols = nRowKeyCols + nDataCols + (showColTotal ? 1 : 0); var result = new InMemoryRange(totalRows, (short)totalCols); - int grandTotalCol = colTotalAtLeft ? nRowKeyCols : nRowKeyCols + dataCols; - int grandTotalRow = rowTotalAtTop ? nColKeyRows : nColKeyRows + dataRows; + int grandTotalCol = colTotalAtLeft ? nRowKeyCols : nRowKeyCols + nDataCols; + int grandTotalRow = rowTotalAtTop ? nColKeyRows : nColKeyRows + nRowLeaves; int dataRowStart = nColKeyRows + (rowTotalAtTop ? 1 : 0); int colOffset = colTotalAtLeft ? 1 : 0; - // --- Rubrikrader (en per kolumnnivå) --- + // --- Rubrikrader --- for (int level = 0; level < nColKeyRows; level++) { - for (int ci = 0; ci < nColLeaves; ci++) + int col = nRowKeyCols + colOffset; + foreach (var entry in colEntries) { - var colPath = colLeaves[ci].Path; - var val = level < colPath.Length ? colPath[level] : null; - result.SetValue(level, nRowKeyCols + colOffset + ci, val); + if (entry.IsSubtotal) + result.SetValue(level, col, level == 0 ? entry.GroupLeaves[0].Path[0] : (object)string.Empty); + else + { + var val = level < entry.Leaf.Path.Length ? entry.Leaf.Path[level] : null; + result.SetValue(level, col, val); + } + col++; } if (showColTotal) - result.SetValue(level, grandTotalCol, level == 0 ? (object)"Total" : string.Empty); + result.SetValue(level, grandTotalCol, level == 0 ? (object)"Grand Total" : string.Empty); } // --- Grand total-rad överst --- if (rowTotalAtTop && showRowTotal) - WriteGrandTotalRow(result, nColKeyRows, colLeaves, pivotMap, args, context, - nRowKeyCols, nColLeaves, colOffset, grandTotalCol, showColTotal); + WriteGrandTotalRow(result, nColKeyRows, colEntries, pivotMap, args, context, + nRowKeyCols, colOffset, grandTotalCol, showColTotal); // --- Datarader --- for (int ri = 0; ri < nRowLeaves; ri++) @@ -378,18 +402,40 @@ private InMemoryRange RenderPivot( string rowKey = MakePivotKey(rowPath); - for (int ci = 0; ci < nColLeaves; ci++) + int col = nRowKeyCols + colOffset; + foreach (var entry in colEntries) { - string colKey = MakePivotKey(colLeaves[ci].Path); - - object aggregated = null; - if (pivotMap.TryGetValue(rowKey, out var colMap) && - colMap.TryGetValue(colKey, out var cellVals)) + if (entry.IsSubtotal) { - aggregated = Aggregate(args.Function, cellVals, context, - args.Function.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null); + var groupVals = entry.GroupLeaves + .SelectMany(l => + { + var ck = MakePivotKey(l.Path); + if (pivotMap.TryGetValue(rowKey, out var cm) && cm.TryGetValue(ck, out var cv)) + return cv; + return Enumerable.Empty(); + }) + .ToList(); + + object subtotalVal = null; + if (groupVals.Count > 0) + subtotalVal = Aggregate(args.Function, groupVals, context, + args.Function.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null); + result.SetValue(outputRow, col, subtotalVal); } - result.SetValue(outputRow, nRowKeyCols + colOffset + ci, aggregated); + else + { + string colKey = MakePivotKey(entry.Leaf.Path); + object aggregated = null; + if (pivotMap.TryGetValue(rowKey, out var colMap) && + colMap.TryGetValue(colKey, out var cellVals)) + { + aggregated = Aggregate(args.Function, cellVals, context, + args.Function.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null); + } + result.SetValue(outputRow, col, aggregated); + } + col++; } if (showColTotal) @@ -403,8 +449,8 @@ private InMemoryRange RenderPivot( // --- Grand total-rad nederst --- if (!rowTotalAtTop && showRowTotal) - WriteGrandTotalRow(result, grandTotalRow, colLeaves, pivotMap, args, context, - nRowKeyCols, nColLeaves, colOffset, grandTotalCol, showColTotal); + WriteGrandTotalRow(result, grandTotalRow, colEntries, pivotMap, args, context, + nRowKeyCols, colOffset, grandTotalCol, showColTotal); return result; } @@ -412,12 +458,11 @@ private InMemoryRange RenderPivot( private void WriteGrandTotalRow( InMemoryRange result, int r, - List colLeaves, + List colEntries, Dictionary>> pivotMap, PivotByArgs args, ParsingContext context, int nRowKeyCols, - int nColLeaves, int colOffset, int grandTotalCol, bool showColTotal) @@ -426,26 +471,46 @@ private void WriteGrandTotalRow( for (int c = 1; c < nRowKeyCols; c++) result.SetValue(r, c, string.Empty); - for (int ci = 0; ci < nColLeaves; ci++) + int col = nRowKeyCols + colOffset; + foreach (var entry in colEntries) { - string colKey = MakePivotKey(colLeaves[ci].Path); - - var allValsForCol = pivotMap.Values - .SelectMany(cm => cm.TryGetValue(colKey, out var cv) - ? cv - : Enumerable.Empty()) - .ToList(); - - object grandVal = null; - if (allValsForCol.Count > 0) + if (entry.IsSubtotal) + { + var groupVals = entry.GroupLeaves + .SelectMany(l => + { + var ck = MakePivotKey(l.Path); + return pivotMap.Values + .SelectMany(cm => cm.TryGetValue(ck, out var cv) + ? cv + : Enumerable.Empty()); + }) + .ToList(); + + object subtotalVal = null; + if (groupVals.Count > 0) + subtotalVal = Aggregate(args.Function, groupVals, context, + args.Function.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null); + result.SetValue(r, col, subtotalVal); + } + else { - grandVal = Aggregate(args.Function, allValsForCol, context, - args.Function.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null); + string colKey = MakePivotKey(entry.Leaf.Path); + var allValsForCol = pivotMap.Values + .SelectMany(cm => cm.TryGetValue(colKey, out var cv) + ? cv + : Enumerable.Empty()) + .ToList(); + + object grandVal = null; + if (allValsForCol.Count > 0) + grandVal = Aggregate(args.Function, allValsForCol, context, + args.Function.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null); + result.SetValue(r, col, grandVal); } - result.SetValue(r, nRowKeyCols + colOffset + ci, grandVal); + col++; } - // Hörncellen: aggregera hela AllValuesInOrder if (showColTotal) { var colValues = args.AllValuesInOrder @@ -457,6 +522,14 @@ private void WriteGrandTotalRow( } } + private class ColEntry + { + public bool IsSubtotal { get; set; } + public string GroupKey { get; set; } + public List GroupLeaves { get; set; } + public LeafWithPath Leaf { get; set; } + } + private List CollectLeavesWithPath(List levels, object[] parentPath) { var result = new List(); diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs index 64f6615d4..e2475c727 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs @@ -163,6 +163,62 @@ public void PivotBySortOrder() } [TestMethod] + public void PivotBySubTotalsIncluded() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; + s.Cells["A2"].Value = "A"; + s.Cells["A3"].Value = "B"; + s.Cells["B1"].Value = "X"; + s.Cells["B2"].Value = "Y"; + s.Cells["B3"].Value = "X"; + s.Cells["C1"].Value = "O"; + s.Cells["C2"].Value = "I"; + s.Cells["C3"].Value = "I"; + s.Cells["D1"].Value = 2; + s.Cells["D2"].Value = 4; + s.Cells["D3"].Value = 1; + s.Cells["E1"].Formula = "PIVOTBY(A1:A3,B1:C3,D1:D3,_xleta.SUM,,,,2)"; + s.Calculate(); + // Rubrikrad 1 + Assert.AreEqual("X", s.Cells["F1"].Value); + Assert.AreEqual("X", s.Cells["G1"].Value); + Assert.AreEqual("X", s.Cells["H1"].Value); + Assert.AreEqual("Y", s.Cells["I1"].Value); + Assert.AreEqual("Y", s.Cells["J1"].Value); + Assert.AreEqual("Grand Total", s.Cells["K1"].Value); + + // Rubrikrad 2 + Assert.AreEqual("I", s.Cells["H2"].Value); + Assert.AreEqual("O", s.Cells["I2"].Value); + Assert.AreEqual("I", s.Cells["K2"].Value); + + // A + Assert.AreEqual("A", s.Cells["E3"].Value); + Assert.AreEqual(2d, s.Cells["G3"].Value); + Assert.AreEqual(2d, s.Cells["H3"].Value); + Assert.AreEqual(4d, s.Cells["I3"].Value); + Assert.AreEqual(4d, s.Cells["J3"].Value); + Assert.AreEqual(6d, s.Cells["K3"].Value); + + // B + Assert.AreEqual("B", s.Cells["E4"].Value); + Assert.AreEqual(1d, s.Cells["F4"].Value); + Assert.AreEqual(1d, s.Cells["H4"].Value); + Assert.AreEqual(1d, s.Cells["K4"].Value); + + // Total + Assert.AreEqual("Total", s.Cells["E5"].Value); + Assert.AreEqual(1d, s.Cells["F5"].Value); + Assert.AreEqual(2d, s.Cells["G5"].Value); + Assert.AreEqual(3d, s.Cells["H5"].Value); + Assert.AreEqual(4d, s.Cells["I5"].Value); + Assert.AreEqual(4d, s.Cells["J5"].Value); + Assert.AreEqual(7d, s.Cells["K5"].Value); + } + } } } From cef4fad2be43a9123723f05d3a4624510291a3cf Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Thu, 23 Apr 2026 16:22:30 +0200 Subject: [PATCH 06/16] WIP --- .../GroupingFunctions/GroupLevel.cs | 2 + .../Excel/Functions/RefAndLookup/PivotBy.cs | 95 +++++++++++++++---- .../Functions/RefAndLookup/PivotByTests.cs | 23 +++++ 3 files changed, 100 insertions(+), 20 deletions(-) diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupLevel.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupLevel.cs index a9fc7b952..9d0684bf3 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupLevel.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupLevel.cs @@ -10,6 +10,7 @@ Date Author Change ************************************************************************************************* 19/3/2026 EPPlus Software AB EPPlus v8.6 *************************************************************************************************/ +using System; using System.Collections.Generic; @@ -18,6 +19,7 @@ namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunc internal class GroupLevel { public object Key { get; set; } + public object[] KeyParts { get; set; } public List Children { get; set; } = new List(); public Dictionary ChildDict { get; set; } = null; public List ChildOrder { get; set; } = null; diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs index 3d31fbaaa..ef96ed7fa 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs @@ -47,7 +47,7 @@ public override CompileResult Execute(IList arguments, Parsing return CreateDynamicArrayResult(result, DataType.ExcelRange); } - // 6. Räkna ut dimensioner: nRows, nCols + // 6. Räkna t dimensioner: nRows, nCols // 7. Skriv kolumnhuvuden (ett pass) // 8. Skriv datarader rad för rad, slå upp pivotMap[(rowKey, colKey)] per kolumn // 9. Skriv grand total-rad/-kolumn @@ -296,7 +296,11 @@ protected GroupLevel InsertIntoTree( var keyStr = (keyParts[depth]?.ToString() ?? string.Empty).ToLowerInvariant(); if (!currentDict.TryGetValue(keyStr, out currentLevel)) { - currentLevel = new GroupLevel { Key = keyParts[depth] }; + currentLevel = new GroupLevel + { + Key = keyParts[depth], + KeyParts = keyParts.Take(depth + 1).ToArray() + }; currentDict[keyStr] = currentLevel; currentOrder.Add(keyStr); } @@ -312,7 +316,46 @@ protected GroupLevel InsertIntoTree( currentOrder = currentLevel.ChildOrder; } } - return currentLevel; // <-- returnera lövet + return currentLevel; + } + + private GroupLevel FindParentInTree(GroupLevel target, List levels) + { + foreach (var level in levels) + { + if (!level.IsLeaf && level.Children.Contains(target)) + return level; + + if (!level.IsLeaf) + { + var found = FindParentInTree(target, level.Children); + if (found != null) return found; + } + } + return null; // toppnivå, ingen förälder + } + + private List ResolveRelativeToValues( + RelativeTo relativeTo, + LeafWithPath colLeaf, + List colLeaves, + Dictionary>> pivotMap) + { + switch (relativeTo) + { + case RelativeTo.ParentColTotal: + { + // Dela med totalvärdet för just denna kolumn, inte hela förälderns grupp + string colKey = MakePivotKey(colLeaf.Path); + return pivotMap.Values + .SelectMany(cm => cm.TryGetValue(colKey, out var cv) + ? cv + : Enumerable.Empty()) + .ToList(); + } + default: + return null; + } } private static string MakePivotKey(object[] parts) => @@ -387,7 +430,7 @@ private InMemoryRange RenderPivot( // --- Grand total-rad överst --- if (rowTotalAtTop && showRowTotal) - WriteGrandTotalRow(result, nColKeyRows, colEntries, pivotMap, args, context, + WriteGrandTotalRow(result, nColKeyRows, colEntries, colLeaves, pivotMap, args, context, nRowKeyCols, colOffset, grandTotalCol, showColTotal); // --- Datarader --- @@ -430,8 +473,11 @@ private InMemoryRange RenderPivot( if (pivotMap.TryGetValue(rowKey, out var colMap) && colMap.TryGetValue(colKey, out var cellVals)) { - aggregated = Aggregate(args.Function, cellVals, context, - args.Function.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null); + var relativeToVals = args.Function.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals + ? ResolveRelativeToValues(args.RelativeTo, entry.Leaf, colLeaves, pivotMap) + : args.AllValuesInOrder; + + aggregated = Aggregate(args.Function, cellVals, context, relativeToVals); } result.SetValue(outputRow, col, aggregated); } @@ -449,23 +495,24 @@ private InMemoryRange RenderPivot( // --- Grand total-rad nederst --- if (!rowTotalAtTop && showRowTotal) - WriteGrandTotalRow(result, grandTotalRow, colEntries, pivotMap, args, context, + WriteGrandTotalRow(result, grandTotalRow, colEntries, colLeaves, pivotMap, args, context, nRowKeyCols, colOffset, grandTotalCol, showColTotal); return result; } private void WriteGrandTotalRow( - InMemoryRange result, - int r, - List colEntries, - Dictionary>> pivotMap, - PivotByArgs args, - ParsingContext context, - int nRowKeyCols, - int colOffset, - int grandTotalCol, - bool showColTotal) + InMemoryRange result, + int r, + List colEntries, + List colLeaves, + Dictionary>> pivotMap, + PivotByArgs args, + ParsingContext context, + int nRowKeyCols, + int colOffset, + int grandTotalCol, + bool showColTotal) { result.SetValue(r, 0, "Total"); for (int c = 1; c < nRowKeyCols; c++) @@ -504,8 +551,13 @@ private void WriteGrandTotalRow( object grandVal = null; if (allValsForCol.Count > 0) - grandVal = Aggregate(args.Function, allValsForCol, context, - args.Function.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null); + { + var relativeToVals = args.Function.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals + ? ResolveRelativeToValues(args.RelativeTo, entry.Leaf, colLeaves, pivotMap) + : args.AllValuesInOrder; + + grandVal = Aggregate(args.Function, allValsForCol, context, relativeToVals); + } result.SetValue(r, col, grandVal); } col++; @@ -549,7 +601,10 @@ private List CollectLeavesWithPath(List levels, object } return result; } - + private List CollectLeaves(List leaves) + { + return leaves; // redan platta löv, returnera direkt + } private class LeafWithPath { public GroupLevel Leaf { get; private set; } diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs index e2475c727..b397c17c2 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs @@ -218,6 +218,29 @@ public void PivotBySubTotalsIncluded() Assert.AreEqual(4d, s.Cells["I5"].Value); Assert.AreEqual(4d, s.Cells["J5"].Value); Assert.AreEqual(7d, s.Cells["K5"].Value); + //Fixa detta test det är förskjutet en rad fel + } + } + [TestMethod] + public void PivotByRelativeTo() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; + s.Cells["A2"].Value = "A"; + s.Cells["A3"].Value = "B"; + s.Cells["B1"].Value = "X"; + s.Cells["B2"].Value = "Y"; + s.Cells["B3"].Value = "X"; + s.Cells["C1"].Value = "O"; + s.Cells["C2"].Value = "I"; + s.Cells["C3"].Value = "I"; + s.Cells["D1"].Value = 2; + s.Cells["D2"].Value = 4; + s.Cells["D3"].Value = 1; + s.Cells["E1"].Formula = "PIVOTBY(A1:A3,B1:C3,D1:D3,_xleta.PERCENTOF,,,,,,,3)"; + s.Calculate(); } } } From 01a6ed0de59e3dc07f2f73b71bbb9e56db404053 Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Tue, 28 Apr 2026 16:27:33 +0200 Subject: [PATCH 07/16] WIP --- .../GroupingFunctions/PivotByArgs.cs | 4 +- .../Excel/Functions/RefAndLookup/PivotBy.cs | 129 +++++++++++++++--- .../Functions/RefAndLookup/PivotByTests.cs | 62 +++++++++ 3 files changed, 176 insertions(+), 19 deletions(-) diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/PivotByArgs.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/PivotByArgs.cs index b922c632f..8af5a229b 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/PivotByArgs.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/PivotByArgs.cs @@ -9,9 +9,9 @@ namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunc internal class PivotByArgs : GroupByBaseArgs { public IRangeInfo ColFields { get; set; } - public int RowTotalDepth { get; set; } = 1; + public int RowTotalDepth { get; set; } = 0; public int[] RowSortOrders { get; set; } = new[] { 1 }; - public int ColTotalDepth { get; set; } = 1; + public int ColTotalDepth { get; set; } = 0; public int[] ColSortOrders { get; set; } = new[] { 1 }; public RelativeTo RelativeTo { get; set; } = RelativeTo.ColumnTotals; // Default } diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs index ef96ed7fa..937ed6984 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs @@ -87,18 +87,26 @@ protected bool TryParsePivotByArgs(IList arguments, return Fail(eErrorType.Value, out error); args.RowTotalDepth = rowTotalDepth; } + else + { + args.RowTotalDepth = TotalDepthGrandOnly; // default = 1 + } // SortOrder for RowFields (optional) if (arguments.Count > 6 && arguments[6].Value != null) args.RowSortOrders = ParseSortOrderArg(arguments[6]); - // TotalDepth for columns (optional) + // TotalDepth for columns (optional, default = 1) if (arguments.Count > 7 && arguments[7].Value != null) { if (!TryParseTotalDepthArg(arguments[7], args.ColFields.Size.NumberOfCols, out int colTotalDepth)) return Fail(eErrorType.Value, out error); args.ColTotalDepth = colTotalDepth; } + else + { + args.ColTotalDepth = TotalDepthGrandOnly; // default = 1 + } // SortOrder for ColFields (optional) if (arguments.Count > 8 && arguments[8].Value != null) @@ -336,16 +344,48 @@ private GroupLevel FindParentInTree(GroupLevel target, List levels) } private List ResolveRelativeToValues( - RelativeTo relativeTo, - LeafWithPath colLeaf, - List colLeaves, - Dictionary>> pivotMap) + RelativeTo relativeTo, + LeafWithPath colLeaf, + List colLeaves, + string rowKey, + Dictionary>> pivotMap, + PivotByArgs args) { switch (relativeTo) { + case RelativeTo.RowTotals: // 1 + { + if (!pivotMap.TryGetValue(rowKey, out var colMap)) + return null; + + return colMap.Values + .SelectMany(vals => vals) + .ToList(); + } + case RelativeTo.GrandTotals: // 2 + { + // Nämnare = alla värden i hela datasetet + return args.AllValuesInOrder; + } case RelativeTo.ParentColTotal: { - // Dela med totalvärdet för just denna kolumn, inte hela förälderns grupp + var parentKey = colLeaf.Path[0]?.ToString()?.ToLowerInvariant() ?? string.Empty; + var siblingLeaves = colLeaves + .Where(l => (l.Path[0]?.ToString()?.ToLowerInvariant() ?? string.Empty) == parentKey) + .ToList(); + + // Hämta radens egna värden under alla syskonkolumner + if (!pivotMap.TryGetValue(rowKey, out var colMap)) + return null; + + return siblingLeaves + .Where(leaf => colMap.ContainsKey(MakePivotKey(leaf.Path))) + .SelectMany(leaf => colMap[MakePivotKey(leaf.Path)]) + .ToList(); + } + case RelativeTo.ParentRowTotal: // 4 + { + // Nämnare = alla raders värden för denna kolumn (kolumnens totalsumma) string colKey = MakePivotKey(colLeaf.Path); return pivotMap.Values .SelectMany(cm => cm.TryGetValue(colKey, out var cv) @@ -357,6 +397,54 @@ private List ResolveRelativeToValues( return null; } } + private List ResolveRelativeToValuesForTotal( + RelativeTo relativeTo, + LeafWithPath colLeaf, + List colLeaves, + Dictionary>> pivotMap, + PivotByArgs args) + { + switch (relativeTo) + { + case RelativeTo.RowTotals: // 1 + { + return pivotMap.Values + .SelectMany(colMap => colMap.Values.SelectMany(v => v)) + .ToList(); + } + case RelativeTo.GrandTotals: // 2 + { + return args.AllValuesInOrder; + } + case RelativeTo.ParentColTotal: + { + // Nämnaren = alla värden under förälderns grupp (alla syskonkolumner) + var parentKey = colLeaf.Path[0]?.ToString()?.ToLowerInvariant() ?? string.Empty; + var siblingLeaves = colLeaves + .Where(l => (l.Path[0]?.ToString()?.ToLowerInvariant() ?? string.Empty) == parentKey) + .ToList(); + + return pivotMap.Values + .SelectMany(colMap => siblingLeaves + .Where(leaf => colMap.ContainsKey(MakePivotKey(leaf.Path))) + .SelectMany(leaf => colMap[MakePivotKey(leaf.Path)])) + .ToList(); + } + case RelativeTo.ParentRowTotal: // 4 + { + // Total-raden: kolumn / kolumn = 1 + string colKey = MakePivotKey(colLeaf.Path); + var colVals = pivotMap.Values + .SelectMany(cm => cm.TryGetValue(colKey, out var cv) + ? cv + : Enumerable.Empty()) + .ToList(); + return colVals; + } + default: + return null; + } + } private static string MakePivotKey(object[] parts) => string.Join("\u001F", parts.Select(p => p?.ToString()?.ToLowerInvariant() ?? string.Empty).ToArray()); @@ -424,8 +512,9 @@ private InMemoryRange RenderPivot( col++; } + string colTotalLabel = args.ColTotalDepth < 0 ? "Grand Total" : "Total"; if (showColTotal) - result.SetValue(level, grandTotalCol, level == 0 ? (object)"Grand Total" : string.Empty); + result.SetValue(level, grandTotalCol, level == 0 ? colTotalLabel : string.Empty); } // --- Grand total-rad överst --- @@ -474,7 +563,7 @@ private InMemoryRange RenderPivot( colMap.TryGetValue(colKey, out var cellVals)) { var relativeToVals = args.Function.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals - ? ResolveRelativeToValues(args.RelativeTo, entry.Leaf, colLeaves, pivotMap) + ? ResolveRelativeToValues(args.RelativeTo, entry.Leaf, colLeaves, rowKey, pivotMap, args) : args.AllValuesInOrder; aggregated = Aggregate(args.Function, cellVals, context, relativeToVals); @@ -486,9 +575,13 @@ private InMemoryRange RenderPivot( if (showColTotal) { - var grandTotalVal = rowLeaf.SubtotalValues != null && rowLeaf.SubtotalValues.Count > 0 - ? rowLeaf.SubtotalValues[0][0] - : null; + var rowAllVals = rowLeaf.Rows.SelectMany(r => r.Values).ToList(); + var relVals = args.Function.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals + ? (args.RelativeTo == RelativeTo.GrandTotals || args.RelativeTo == RelativeTo.ParentRowTotal + ? args.AllValuesInOrder + : rowAllVals) + : args.AllValuesInOrder; + var grandTotalVal = Aggregate(args.Function, rowAllVals, context, relVals); result.SetValue(outputRow, grandTotalCol, grandTotalVal); } } @@ -525,6 +618,7 @@ private void WriteGrandTotalRow( { var groupVals = entry.GroupLeaves .SelectMany(l => + { var ck = MakePivotKey(l.Path); return pivotMap.Values @@ -553,7 +647,7 @@ private void WriteGrandTotalRow( if (allValsForCol.Count > 0) { var relativeToVals = args.Function.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals - ? ResolveRelativeToValues(args.RelativeTo, entry.Leaf, colLeaves, pivotMap) + ? ResolveRelativeToValuesForTotal(args.RelativeTo, entry.Leaf, colLeaves, pivotMap, args) : args.AllValuesInOrder; grandVal = Aggregate(args.Function, allValsForCol, context, relativeToVals); @@ -565,13 +659,14 @@ private void WriteGrandTotalRow( if (showColTotal) { - var colValues = args.AllValuesInOrder - .Select(v => new object[] { v[0] }) - .ToList(); - var cornerVal = Aggregate(args.Function, colValues, context, - args.Function.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null); + var allVals = args.AllValuesInOrder.Select(v => new object[] { v[0] }).ToList(); + var cornerVal = Aggregate(args.Function, allVals, context, + args.Function.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals + ? allVals // dela med sig själv → 1 + : args.AllValuesInOrder); result.SetValue(r, grandTotalCol, cornerVal); } + } private class ColEntry diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs index b397c17c2..9d814825e 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs @@ -243,5 +243,67 @@ public void PivotByRelativeTo() s.Calculate(); } } + + [TestMethod] + public void PivotByRelativeTo2() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "Stockholm"; + s.Cells["A2"].Value = "Linköping"; + s.Cells["A3"].Value = "Örebro"; + s.Cells["A4"].Value = "Stockholm"; + s.Cells["A5"].Value = "Örebro"; + s.Cells["A6"].Value = "Linköping"; + + s.Cells["B1"].Value = "2026"; + s.Cells["B2"].Value = "2026"; + s.Cells["B3"].Value = "2025"; + s.Cells["B4"].Value = "2025"; + s.Cells["B5"].Value = "2025"; + s.Cells["B6"].Value = "2024"; + + s.Cells["C1"].Value = "Q2"; + s.Cells["C2"].Value = "Q1"; + s.Cells["C3"].Value = "Q2"; + s.Cells["C4"].Value = "Q3"; + s.Cells["C5"].Value = "Q4"; + s.Cells["C6"].Value = "Q2"; + + s.Cells["D1"].Value = 34543; + s.Cells["D2"].Value = 43265; + s.Cells["D3"].Value = 75461; + s.Cells["D4"].Value = 4536; + s.Cells["D5"].Value = 64312; + s.Cells["D6"].Value = 64531; + + s.Cells["E1"].Formula = "PIVOTBY(A1:A6,B1:C6,D1:D6,_xleta.PERCENTOF,,,,,,,3)"; + s.Calculate(); + } + } + + [TestMethod] + public void PivotByRelativeTo3() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; + s.Cells["A2"].Value = "A"; + s.Cells["A3"].Value = "B"; + s.Cells["B1"].Value = "X"; + s.Cells["B2"].Value = "Y"; + s.Cells["B3"].Value = "X"; + s.Cells["C1"].Value = "O"; + s.Cells["C2"].Value = "I"; + s.Cells["C3"].Value = "I"; + s.Cells["D1"].Value = 2; + s.Cells["D2"].Value = 4; + s.Cells["D3"].Value = 1; + s.Cells["E1"].Formula = "PIVOTBY(A1:A3,B1:C3,D1:D3,_xleta.PERCENTOF,,,,,,,4)"; + s.Calculate(); + } + } } } From 0f12b7995fb1872cc4d165727011126183fe88c1 Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Wed, 29 Apr 2026 16:30:40 +0200 Subject: [PATCH 08/16] WIP --- .../Excel/Functions/RefAndLookup/PivotBy.cs | 27 +++++++++- .../Functions/RefAndLookup/PivotByTests.cs | 52 +++++++++++++++++-- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs index 937ed6984..db096beac 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs @@ -74,7 +74,7 @@ protected bool TryParsePivotByArgs(IList arguments, if (arguments.Count > 4 && arguments[4].Value != null) { - var v = Convert.ToInt32(arguments[3].Value); + var v = Convert.ToInt32(arguments[4].Value); if (!Enum.IsDefined(typeof(FieldHeaders), v)) return Fail(eErrorType.Value, out error); args.Headers = (FieldHeaders)v; @@ -467,6 +467,13 @@ private InMemoryRange RenderPivot( bool rowTotalAtTop = args.RowTotalDepth < 0; bool colTotalAtLeft = args.ColTotalDepth < 0; int colSubtotalDepth = Math.Abs(args.ColTotalDepth); + + var resolvedHeaders = ResolveHeaders(args.Headers, args.Values); + bool showHeaders = resolvedHeaders == FieldHeaders.YesAndShow + || resolvedHeaders == FieldHeaders.NoButGenerate; + bool addFunctionHeaders = args.Functions.Count > 1; + int nFunctions = args.Functions.Count; + bool showColSubtotals = colSubtotalDepth > 1; // Gruppera kolumnlöv per översta nyckelgrupp (nivå 0) @@ -496,9 +503,24 @@ private InMemoryRange RenderPivot( int dataRowStart = nColKeyRows + (rowTotalAtTop ? 1 : 0); int colOffset = colTotalAtLeft ? 1 : 0; + + + // --- Rubrikrader --- for (int level = 0; level < nColKeyRows; level++) { + if (addFunctionHeaders) + { + var functionHeaders = ResolveFunctionHeaders(args); + if (args.FunctionLayout == FunctionLayout.Horizontal) + { + + for (int c = 0; c < nFunctions; c++) + result.SetValue(level, c + 1, functionHeaders[c]); + //r++; + } + } + int col = nRowKeyCols + colOffset; foreach (var entry in colEntries) { @@ -512,7 +534,7 @@ private InMemoryRange RenderPivot( col++; } - string colTotalLabel = args.ColTotalDepth < 0 ? "Grand Total" : "Total"; + string colTotalLabel = Math.Abs(args.ColTotalDepth) > 1 ? "Grand Total" : "Total"; if (showColTotal) result.SetValue(level, grandTotalCol, level == 0 ? colTotalLabel : string.Empty); } @@ -607,6 +629,7 @@ private void WriteGrandTotalRow( int grandTotalCol, bool showColTotal) { + var rowTotalLabel = Math.Abs(args.RowTotalDepth) > 1 ? "Grand Total" : "Total"; result.SetValue(r, 0, "Total"); for (int c = 1; c < nRowKeyCols; c++) result.SetValue(r, c, string.Empty); diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs index 9d814825e..3f0d9a557 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs @@ -192,9 +192,9 @@ public void PivotBySubTotalsIncluded() Assert.AreEqual("Grand Total", s.Cells["K1"].Value); // Rubrikrad 2 - Assert.AreEqual("I", s.Cells["H2"].Value); - Assert.AreEqual("O", s.Cells["I2"].Value); - Assert.AreEqual("I", s.Cells["K2"].Value); + Assert.AreEqual("I", s.Cells["F2"].Value); + Assert.AreEqual("O", s.Cells["G2"].Value); + Assert.AreEqual("I", s.Cells["I2"].Value); // A Assert.AreEqual("A", s.Cells["E3"].Value); @@ -305,5 +305,51 @@ public void PivotByRelativeTo3() s.Calculate(); } } + + [TestMethod] + public void PivotByHeaders() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; + s.Cells["A2"].Value = "A"; + s.Cells["A3"].Value = "B"; + s.Cells["B1"].Value = "X"; + s.Cells["B2"].Value = "Y"; + s.Cells["B3"].Value = "X"; + s.Cells["D1"].Value = 2; + s.Cells["D2"].Value = 4; + s.Cells["D3"].Value = 1; + s.Cells["E1"].Formula = "PIVOTBY(A1:A3,B1:B3,D1:D3, _xleta.SUM, 3)"; + s.Calculate(); + + Assert.AreEqual("X", s.Cells["F1"].Value); + Assert.AreEqual("A", s.Cells["E3"].Value); + } + } + [TestMethod] + public void PivotByCustomLambdaWithHstack() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; + s.Cells["A2"].Value = "A"; + s.Cells["A3"].Value = "B"; + s.Cells["B1"].Value = "X"; + s.Cells["B2"].Value = "Y"; + s.Cells["B3"].Value = "X"; + s.Cells["C1"].Value = "O"; + s.Cells["C2"].Value = "I"; + s.Cells["C3"].Value = "I"; + s.Cells["D1"].Value = 2; + s.Cells["D2"].Value = 4; + s.Cells["D3"].Value = 1; + s.Cells["E1"].Formula = "PIVOTBY(A1:A3,B1:B3,D1:D3, HSTACK(_xleta.COUNT, LAMBDA(x, SUM(x *2/3)), LAMBDA(x, SUM(x *2)) ),3)"; + s.Calculate(); + + } + } } } From 661ae076d4b767dc531acdc043da8a395ca4532e Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Mon, 4 May 2026 16:28:31 +0200 Subject: [PATCH 09/16] WIP --- .../Excel/Functions/RefAndLookup/PivotBy.cs | 75 ++++++++++--------- .../Functions/RefAndLookup/PivotByTests.cs | 6 +- 2 files changed, 44 insertions(+), 37 deletions(-) diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs index db096beac..567592b39 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs @@ -450,11 +450,11 @@ private static string MakePivotKey(object[] parts) => string.Join("\u001F", parts.Select(p => p?.ToString()?.ToLowerInvariant() ?? string.Empty).ToArray()); private InMemoryRange RenderPivot( - List rowLeaves, - List colLeaves, - Dictionary>> pivotMap, - PivotByArgs args, - ParsingContext context) + List rowLeaves, + List colLeaves, + Dictionary>> pivotMap, + PivotByArgs args, + ParsingContext context) { int nRowKeyCols = args.RowFields.Size.NumberOfCols; int nColKeyRows = args.ColFields.Size.NumberOfCols; @@ -467,21 +467,18 @@ private InMemoryRange RenderPivot( bool rowTotalAtTop = args.RowTotalDepth < 0; bool colTotalAtLeft = args.ColTotalDepth < 0; int colSubtotalDepth = Math.Abs(args.ColTotalDepth); + bool showColSubtotals = colSubtotalDepth > 1; var resolvedHeaders = ResolveHeaders(args.Headers, args.Values); - bool showHeaders = resolvedHeaders == FieldHeaders.YesAndShow - || resolvedHeaders == FieldHeaders.NoButGenerate; - bool addFunctionHeaders = args.Functions.Count > 1; - int nFunctions = args.Functions.Count; - - bool showColSubtotals = colSubtotalDepth > 1; + bool showFieldHeaders = resolvedHeaders == FieldHeaders.YesAndShow; + int fieldHeaderRows = showFieldHeaders ? 1 : 0; // Gruppera kolumnlöv per översta nyckelgrupp (nivå 0) var colGroups = colLeaves .GroupBy(l => l.Path[0]?.ToString()?.ToLowerInvariant() ?? string.Empty) .ToList(); - // Bygg ordnad lista av kolumner – löv först, subtotal sist i varje grupp + // Bygg ordnad lista av kolumner var colEntries = new List(); foreach (var group in colGroups) { @@ -493,64 +490,72 @@ private InMemoryRange RenderPivot( } int nDataCols = colEntries.Count; - int totalRows = nColKeyRows + nRowLeaves + (showRowTotal ? 1 : 0); + int totalRows = fieldHeaderRows + nColKeyRows + nRowLeaves + (showRowTotal ? 1 : 0); int totalCols = nRowKeyCols + nDataCols + (showColTotal ? 1 : 0); var result = new InMemoryRange(totalRows, (short)totalCols); int grandTotalCol = colTotalAtLeft ? nRowKeyCols : nRowKeyCols + nDataCols; - int grandTotalRow = rowTotalAtTop ? nColKeyRows : nColKeyRows + nRowLeaves; - int dataRowStart = nColKeyRows + (rowTotalAtTop ? 1 : 0); + int grandTotalRow = fieldHeaderRows + (rowTotalAtTop ? nColKeyRows : nColKeyRows + nRowLeaves); + int dataRowStart = fieldHeaderRows + nColKeyRows + (rowTotalAtTop ? 1 : 0); int colOffset = colTotalAtLeft ? 1 : 0; - - - // --- Rubrikrader --- - for (int level = 0; level < nColKeyRows; level++) + if (showFieldHeaders) { - if (addFunctionHeaders) + var colFieldCols = args.RowFields.Size.NumberOfCols; + for (int i = 0; i < colFieldCols; i++) { - var functionHeaders = ResolveFunctionHeaders(args); - if (args.FunctionLayout == FunctionLayout.Horizontal) - { - - for (int c = 0; c < nFunctions; c++) - result.SetValue(level, c + 1, functionHeaders[c]); - //r++; - } - } - + var rowFieldName = args.ColFields.GetOffset(0, i); + result.SetValue(0, nRowKeyCols + colOffset + i, rowFieldName); + } + } + for (int level = 0; level < nColKeyRows; level++) + { + int outputLevel = fieldHeaderRows + level; int col = nRowKeyCols + colOffset; foreach (var entry in colEntries) { if (entry.IsSubtotal) - result.SetValue(level, col, level == 0 ? entry.GroupLeaves[0].Path[0] : (object)string.Empty); + result.SetValue(outputLevel, col, level == 0 ? entry.GroupLeaves[0].Path[0] : (object)string.Empty); else { var val = level < entry.Leaf.Path.Length ? entry.Leaf.Path[level] : null; - result.SetValue(level, col, val); + result.SetValue(outputLevel, col, val); } col++; } - string colTotalLabel = Math.Abs(args.ColTotalDepth) > 1 ? "Grand Total" : "Total"; + string colTotalLabel = Math.Abs(args.ColTotalDepth) > 1 ? "Grand Total" : "Total"; if (showColTotal) - result.SetValue(level, grandTotalCol, level == 0 ? colTotalLabel : string.Empty); + result.SetValue(outputLevel, grandTotalCol, level == 0 ? colTotalLabel : string.Empty); } // --- Grand total-rad överst --- if (rowTotalAtTop && showRowTotal) - WriteGrandTotalRow(result, nColKeyRows, colEntries, colLeaves, pivotMap, args, context, + WriteGrandTotalRow(result, fieldHeaderRows + nColKeyRows, colEntries, colLeaves, pivotMap, args, context, nRowKeyCols, colOffset, grandTotalCol, showColTotal); // --- Datarader --- for (int ri = 0; ri < nRowLeaves; ri++) { + int outputRow = dataRowStart + ri; var rowPath = rowLeaves[ri].Path; var rowLeaf = rowLeaves[ri].Leaf; + if (showFieldHeaders) + { + var rowFieldCols = args.RowFields.Size.NumberOfCols; + for (int i = 0; i < rowFieldCols; i++) + { + var rowFieldName = args.RowFields.GetOffset(0, i); + result.SetValue(outputRow, i, rowFieldName); + } + outputRow++; + // Sätt values på alla + } + for (int k = 0; k < rowPath.Length; k++) result.SetValue(outputRow, k, rowPath[k]); diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs index 3f0d9a557..18f5ff74e 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs @@ -324,11 +324,13 @@ public void PivotByHeaders() s.Cells["E1"].Formula = "PIVOTBY(A1:A3,B1:B3,D1:D3, _xleta.SUM, 3)"; s.Calculate(); - Assert.AreEqual("X", s.Cells["F1"].Value); + Assert.AreEqual("X", s.Cells["F2"].Value); Assert.AreEqual("A", s.Cells["E3"].Value); + Assert.AreEqual("A", s.Cells["E4"].Value); } } - [TestMethod] + + [TestMethod] public void PivotByCustomLambdaWithHstack() { using (var package = new ExcelPackage()) From 86e6a1f441481168eed3df1c8f12b4d5504d4bc9 Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Tue, 5 May 2026 16:29:59 +0200 Subject: [PATCH 10/16] WIP --- .../Excel/Functions/RefAndLookup/Groupby.cs | 8 +- .../GroupingFunctions/GroupByFunctionBase.cs | 4 +- .../Excel/Functions/RefAndLookup/PivotBy.cs | 542 +++++++++++++----- .../Functions/RefAndLookup/PivotByTests.cs | 23 + 4 files changed, 427 insertions(+), 150 deletions(-) diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs index 5bd2cf2eb..7a13f2431 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs @@ -165,7 +165,7 @@ private InMemoryRange BuildResult(List levels, GroupByBaseArgs args, if(addFunctionHeaders) { - var functionHeaders = ResolveFunctionHeaders(args); + var functionHeaders = ResolveFunctionHeaders(args.Functions); if(args.FunctionLayout == FunctionLayout.Horizontal) { for (int c = 0; c < nFunctions; c++) @@ -220,7 +220,7 @@ private int WriteRows( int depth) { var functionHeaders = args.FunctionLayout == FunctionLayout.Vertical - ? ResolveFunctionHeaders(args) + ? ResolveFunctionHeaders(args.Functions) : null; foreach (var level in levels) @@ -291,7 +291,7 @@ private int CountSubtotalRows(List levels, int subtotalDepth, int de private int WriteSubtotal(InMemoryRange result, int r, GroupLevel level, int nKeyCols, int nValCols, GroupByBaseArgs args) { - var functionHeaders = ResolveFunctionHeaders(args); + var functionHeaders = ResolveFunctionHeaders(args.Functions); if (args.FunctionLayout == FunctionLayout.Vertical) { for (int f = 0; f < args.Functions.Count; f++) @@ -320,7 +320,7 @@ private int WriteSubtotal(InMemoryRange result, int r, GroupLevel level, int nKe private int WriteGrandTotal(InMemoryRange result, int r, List levels, string label, int nKeyCols, int nValCols, GroupByBaseArgs args, ParsingContext context) { - var functionHeaders = ResolveFunctionHeaders(args); + var functionHeaders = ResolveFunctionHeaders(args.Functions); int nAllValCols = args.AllValuesInOrder.Count > 0 ? args.AllValuesInOrder[0].Length : 1; if (args.FunctionLayout == FunctionLayout.Vertical) diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupByFunctionBase.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupByFunctionBase.cs index 4830eb8d1..66dc78160 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupByFunctionBase.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupByFunctionBase.cs @@ -28,9 +28,9 @@ internal abstract class GroupByFunctionBase : ExcelFunction protected const int TotalDepthNoTotals = 0; protected const int TotalDepthGrandOnly = 1; - protected List ResolveFunctionHeaders(GroupByBaseArgs args) + protected List ResolveFunctionHeaders(List functions) { - var names = args.Functions + var names = functions .Select(f => f.EtaFunction != null ? f.EtaFunction.Name : "CUSTOM") .ToList(); diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs index 567592b39..dc9c912d7 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs @@ -458,9 +458,11 @@ private InMemoryRange RenderPivot( { int nRowKeyCols = args.RowFields.Size.NumberOfCols; int nColKeyRows = args.ColFields.Size.NumberOfCols; - int nColLeaves = colLeaves.Count; int nRowLeaves = rowLeaves.Count; - int nValCols = args.Values.Size.NumberOfCols; + int nFunctions = args.Functions.Count; + bool isVStack = args.FunctionLayout == FunctionLayout.Vertical; + bool isHStack = args.FunctionLayout == FunctionLayout.Horizontal; + bool multipleFunctions = isVStack || isHStack; bool showRowTotal = args.RowTotalDepth != TotalDepthNoTotals; bool showColTotal = args.ColTotalDepth != TotalDepthNoTotals; @@ -472,13 +474,15 @@ private InMemoryRange RenderPivot( var resolvedHeaders = ResolveHeaders(args.Headers, args.Values); bool showFieldHeaders = resolvedHeaders == FieldHeaders.YesAndShow; int fieldHeaderRows = showFieldHeaders ? 1 : 0; + int headerDataRows = showFieldHeaders ? 1 : 0; + int functionHeaderRows = isHStack ? 1 : 0; + int functionNameCol = isVStack ? nRowKeyCols : -1; // extra kolumn för VSTACK + int functionColOffset = isVStack ? 1 : 0; - // Gruppera kolumnlöv per översta nyckelgrupp (nivå 0) var colGroups = colLeaves .GroupBy(l => l.Path[0]?.ToString()?.ToLowerInvariant() ?? string.Empty) .ToList(); - // Bygg ordnad lista av kolumner var colEntries = new List(); foreach (var group in colGroups) { @@ -489,214 +493,464 @@ private InMemoryRange RenderPivot( colEntries.Add(new ColEntry { IsSubtotal = true, GroupKey = group.Key, GroupLeaves = groupLeaves }); } - int nDataCols = colEntries.Count; - int totalRows = fieldHeaderRows + nColKeyRows + nRowLeaves + (showRowTotal ? 1 : 0); - int totalCols = nRowKeyCols + nDataCols + (showColTotal ? 1 : 0); + // Antal datakolumner per entry + int colsPerEntry = isHStack ? nFunctions : 1; + int nDataCols = colEntries.Count * colsPerEntry; + int nTotalCols = showColTotal ? colsPerEntry : 0; + + // Totalt antal kolumner + int totalCols = nRowKeyCols + functionColOffset + nDataCols + nTotalCols; + + // Totalt antal rader + int rowsPerLeaf = isVStack ? nFunctions : 1; + int rowsPerTotal = isVStack ? nFunctions : 1; + int totalRows = fieldHeaderRows + nColKeyRows + functionHeaderRows + headerDataRows + + nRowLeaves * rowsPerLeaf + + (showRowTotal ? rowsPerTotal : 0); + + int dataRowStart = fieldHeaderRows + nColKeyRows + functionHeaderRows + headerDataRows + + (rowTotalAtTop ? rowsPerTotal : 0); + int grandTotalRow = fieldHeaderRows + nColKeyRows + functionHeaderRows + headerDataRows + + (rowTotalAtTop ? 0 : nRowLeaves * rowsPerLeaf); var result = new InMemoryRange(totalRows, (short)totalCols); - int grandTotalCol = colTotalAtLeft ? nRowKeyCols : nRowKeyCols + nDataCols; - int grandTotalRow = fieldHeaderRows + (rowTotalAtTop ? nColKeyRows : nColKeyRows + nRowLeaves); - int dataRowStart = fieldHeaderRows + nColKeyRows + (rowTotalAtTop ? 1 : 0); - int colOffset = colTotalAtLeft ? 1 : 0; + int dataColStart = nRowKeyCols + functionColOffset; + int grandTotalCol = colTotalAtLeft ? dataColStart : dataColStart + nDataCols; + int colOffset = colTotalAtLeft ? colsPerEntry : 0; - // --- Rubrikrader --- + // --- Fältnamnrad --- if (showFieldHeaders) { - var colFieldCols = args.RowFields.Size.NumberOfCols; - for (int i = 0; i < colFieldCols; i++) - { - var rowFieldName = args.ColFields.GetOffset(0, i); - result.SetValue(0, nRowKeyCols + colOffset + i, rowFieldName); - } + for (int i = 0; i < args.ColFields.Size.NumberOfCols; i++) + result.SetValue(0, dataColStart + colOffset + i, args.ColFields.GetOffset(0, i)); } + + // --- Rubrikrader --- for (int level = 0; level < nColKeyRows; level++) { int outputLevel = fieldHeaderRows + level; - int col = nRowKeyCols + colOffset; + int col = dataColStart + colOffset; foreach (var entry in colEntries) { - if (entry.IsSubtotal) - result.SetValue(outputLevel, col, level == 0 ? entry.GroupLeaves[0].Path[0] : (object)string.Empty); - else + var val = entry.IsSubtotal + ? (level == 0 ? entry.GroupLeaves[0].Path[0] : (object)string.Empty) + : (level < entry.Leaf.Path.Length ? entry.Leaf.Path[level] : null); + + for (int f = 0; f < colsPerEntry; f++) { - var val = level < entry.Leaf.Path.Length ? entry.Leaf.Path[level] : null; - result.SetValue(outputLevel, col, val); + result.SetValue(outputLevel, col, f == 0 ? val : string.Empty); + col++; } - col++; } - string colTotalLabel = Math.Abs(args.ColTotalDepth) > 1 ? "Grand Total" : "Total"; if (showColTotal) - result.SetValue(outputLevel, grandTotalCol, level == 0 ? colTotalLabel : string.Empty); + { + string colTotalLabel = Math.Abs(args.ColTotalDepth) > 1 ? "Grand Total" : "Total"; + for (int f = 0; f < colsPerEntry; f++) + { + result.SetValue(outputLevel, col, f == 0 && level == 0 ? colTotalLabel : string.Empty); + col++; + } + } } - // --- Grand total-rad överst --- - if (rowTotalAtTop && showRowTotal) - WriteGrandTotalRow(result, fieldHeaderRows + nColKeyRows, colEntries, colLeaves, pivotMap, args, context, - nRowKeyCols, colOffset, grandTotalCol, showColTotal); + // --- HSTACK: Funktionsnamnsrad --- + if (isHStack) + { + var functionNames = ResolveFunctionHeaders(args.Functions); + int functionHeaderRow = fieldHeaderRows + nColKeyRows; + int col = dataColStart + colOffset; + foreach (var entry in colEntries) + { + foreach (var name in functionNames) + { + result.SetValue(functionHeaderRow, col, name); + col++; + } + } + if (showColTotal) + { + foreach (var name in functionNames) + { + result.SetValue(functionHeaderRow, col, name); + col++; + } + } + } - // --- Datarader --- - for (int ri = 0; ri < nRowLeaves; ri++) + // --- Header-datarad --- + if (showFieldHeaders) { + int headerDataRow = fieldHeaderRows + nColKeyRows + functionHeaderRows; - int outputRow = dataRowStart + ri; - var rowPath = rowLeaves[ri].Path; - var rowLeaf = rowLeaves[ri].Leaf; + for (int i = 0; i < nRowKeyCols; i++) + result.SetValue(headerDataRow, i, args.RowFields.GetOffset(0, i)); - if (showFieldHeaders) + var headerValue = args.Values.GetOffset(0, 0); + int col = dataColStart + colOffset; + foreach (var entry in colEntries) { - var rowFieldCols = args.RowFields.Size.NumberOfCols; - for (int i = 0; i < rowFieldCols; i++) + for (int fc = 0; fc < colsPerEntry; fc++) { - var rowFieldName = args.RowFields.GetOffset(0, i); - result.SetValue(outputRow, i, rowFieldName); + if (!entry.IsSubtotal) + result.SetValue(headerDataRow, col, headerValue); + col++; } - outputRow++; - // Sätt values på alla } - for (int k = 0; k < rowPath.Length; k++) - result.SetValue(outputRow, k, rowPath[k]); + if (showColTotal) + { + for (int fc = 0; fc < colsPerEntry; fc++) + { + result.SetValue(headerDataRow, col, headerValue); + col++; + } + } + } + // --- Grand total-rad överst --- + if (rowTotalAtTop && showRowTotal) + WriteGrandTotalRow(result, fieldHeaderRows + nColKeyRows + functionHeaderRows + headerDataRows, + colEntries, colLeaves, pivotMap, args, context, + nRowKeyCols, functionNameCol, dataColStart, colOffset, + grandTotalCol, showColTotal, colsPerEntry, isVStack); + + // --- Datarader --- + for (int ri = 0; ri < nRowLeaves; ri++) + { + var rowPath = rowLeaves[ri].Path; + var rowLeaf = rowLeaves[ri].Leaf; string rowKey = MakePivotKey(rowPath); + var functionNames = ResolveFunctionHeaders(args.Functions); - int col = nRowKeyCols + colOffset; - foreach (var entry in colEntries) + for (int fi = 0; fi < nFunctions; fi++) { - if (entry.IsSubtotal) + int outputRow = dataRowStart + ri * rowsPerLeaf + fi; + var f = args.Functions[fi]; + + for (int k = 0; k < rowPath.Length; k++) + result.SetValue(outputRow, k, rowPath[k]); + + if (isVStack) + result.SetValue(outputRow, functionNameCol, functionNames[fi]); + + int col = dataColStart + colOffset; + foreach (var entry in colEntries) { - var groupVals = entry.GroupLeaves - .SelectMany(l => + if (entry.IsSubtotal) + { + var groupVals = entry.GroupLeaves + .SelectMany(l => + { + var ck = MakePivotKey(l.Path); + if (pivotMap.TryGetValue(rowKey, out var cm) && cm.TryGetValue(ck, out var cv)) + return cv; + return Enumerable.Empty(); + }).ToList(); + + if (isHStack) { - var ck = MakePivotKey(l.Path); - if (pivotMap.TryGetValue(rowKey, out var cm) && cm.TryGetValue(ck, out var cv)) - return cv; - return Enumerable.Empty(); - }) - .ToList(); + foreach (var func in args.Functions) + { + object val = groupVals.Count > 0 + ? Aggregate(func, groupVals, context, + func.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null) + : null; + result.SetValue(outputRow, col++, val); + } + } + else + { + object val = groupVals.Count > 0 + ? Aggregate(f, groupVals, context, + f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null) + : null; + result.SetValue(outputRow, col++, val); + } + } + else + { + string colKey = MakePivotKey(entry.Leaf.Path); - object subtotalVal = null; - if (groupVals.Count > 0) - subtotalVal = Aggregate(args.Function, groupVals, context, - args.Function.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null); - result.SetValue(outputRow, col, subtotalVal); + if (isHStack) + { + foreach (var func in args.Functions) + { + object aggregated = null; + if (pivotMap.TryGetValue(rowKey, out var colMap) && + colMap.TryGetValue(colKey, out var cellVals)) + { + var relativeToVals = func.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals + ? ResolveRelativeToValues(args.RelativeTo, entry.Leaf, colLeaves, rowKey, pivotMap, args) + : args.AllValuesInOrder; + aggregated = Aggregate(func, cellVals, context, relativeToVals); + } + result.SetValue(outputRow, col++, aggregated); + } + } + else + { + object aggregated = null; + if (pivotMap.TryGetValue(rowKey, out var colMap) && + colMap.TryGetValue(colKey, out var cellVals)) + { + var relativeToVals = f.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals + ? ResolveRelativeToValues(args.RelativeTo, entry.Leaf, colLeaves, rowKey, pivotMap, args) + : args.AllValuesInOrder; + aggregated = Aggregate(f, cellVals, context, relativeToVals); + } + result.SetValue(outputRow, col++, aggregated); + } + } } - else + + if (showColTotal) { - string colKey = MakePivotKey(entry.Leaf.Path); - object aggregated = null; - if (pivotMap.TryGetValue(rowKey, out var colMap) && - colMap.TryGetValue(colKey, out var cellVals)) + var rowAllVals = rowLeaf.Rows.SelectMany(r => r.Values).ToList(); + if (isHStack) { - var relativeToVals = args.Function.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals - ? ResolveRelativeToValues(args.RelativeTo, entry.Leaf, colLeaves, rowKey, pivotMap, args) + foreach (var func in args.Functions) + { + var relVals = func.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals + ? (args.RelativeTo == RelativeTo.GrandTotals || args.RelativeTo == RelativeTo.ParentRowTotal + ? args.AllValuesInOrder : rowAllVals) + : args.AllValuesInOrder; + result.SetValue(outputRow, col++, Aggregate(func, rowAllVals, context, relVals)); + } + } + else + { + var relVals = f.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals + ? (args.RelativeTo == RelativeTo.GrandTotals || args.RelativeTo == RelativeTo.ParentRowTotal + ? args.AllValuesInOrder : rowAllVals) : args.AllValuesInOrder; - - aggregated = Aggregate(args.Function, cellVals, context, relativeToVals); + result.SetValue(outputRow, col++, Aggregate(f, rowAllVals, context, relVals)); } - result.SetValue(outputRow, col, aggregated); } - col++; - } - if (showColTotal) - { - var rowAllVals = rowLeaf.Rows.SelectMany(r => r.Values).ToList(); - var relVals = args.Function.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals - ? (args.RelativeTo == RelativeTo.GrandTotals || args.RelativeTo == RelativeTo.ParentRowTotal - ? args.AllValuesInOrder - : rowAllVals) - : args.AllValuesInOrder; - var grandTotalVal = Aggregate(args.Function, rowAllVals, context, relVals); - result.SetValue(outputRow, grandTotalCol, grandTotalVal); + // För HSTACK — bryt efter första iterationen + if (isHStack) break; } } // --- Grand total-rad nederst --- if (!rowTotalAtTop && showRowTotal) WriteGrandTotalRow(result, grandTotalRow, colEntries, colLeaves, pivotMap, args, context, - nRowKeyCols, colOffset, grandTotalCol, showColTotal); + nRowKeyCols, functionNameCol, dataColStart, colOffset, + grandTotalCol, showColTotal, colsPerEntry, isVStack); return result; } private void WriteGrandTotalRow( - InMemoryRange result, - int r, - List colEntries, - List colLeaves, - Dictionary>> pivotMap, - PivotByArgs args, - ParsingContext context, - int nRowKeyCols, - int colOffset, - int grandTotalCol, - bool showColTotal) + InMemoryRange result, + int startRow, + List colEntries, + List colLeaves, + Dictionary>> pivotMap, + PivotByArgs args, + ParsingContext context, + int nRowKeyCols, + int functionNameCol, + int dataColStart, + int colOffset, + int grandTotalCol, + bool showColTotal, + int colsPerEntry, + bool isVStack) { - var rowTotalLabel = Math.Abs(args.RowTotalDepth) > 1 ? "Grand Total" : "Total"; - result.SetValue(r, 0, "Total"); - for (int c = 1; c < nRowKeyCols; c++) - result.SetValue(r, c, string.Empty); + var functionNames = ResolveFunctionHeaders(args.Functions); + int nFunctions = args.Functions.Count; + int rowsPerTotal = isVStack ? nFunctions : 1; - int col = nRowKeyCols + colOffset; - foreach (var entry in colEntries) + for (int fi = 0; fi < rowsPerTotal; fi++) { - if (entry.IsSubtotal) + int r = startRow + fi; + var f = args.Functions[fi]; + + result.SetValue(r, 0, "Total"); + for (int c = 1; c < nRowKeyCols; c++) + result.SetValue(r, c, string.Empty); + + if (isVStack) + result.SetValue(r, functionNameCol, functionNames[fi]); + + int col = dataColStart + colOffset; + foreach (var entry in colEntries) { - var groupVals = entry.GroupLeaves - .SelectMany(l => + if (entry.IsSubtotal) + { + var groupVals = entry.GroupLeaves + .SelectMany(l => + { + var ck = MakePivotKey(l.Path); + return pivotMap.Values + .SelectMany(cm => cm.TryGetValue(ck, out var cv) + ? cv : Enumerable.Empty()); + }).ToList(); + if (isVStack) + { + object val = groupVals.Count > 0 + ? Aggregate(f, groupVals, context, + f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null) + : null; + result.SetValue(r, col++, val); + } + else { - var ck = MakePivotKey(l.Path); - return pivotMap.Values - .SelectMany(cm => cm.TryGetValue(ck, out var cv) - ? cv - : Enumerable.Empty()); - }) - .ToList(); - - object subtotalVal = null; - if (groupVals.Count > 0) - subtotalVal = Aggregate(args.Function, groupVals, context, - args.Function.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null); - result.SetValue(r, col, subtotalVal); + foreach (var func in args.Functions) + { + object val = groupVals.Count > 0 + ? Aggregate(func, groupVals, context, + func.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null) + : null; + result.SetValue(r, col++, val); + } + } + } + else + { + string colKey = MakePivotKey(entry.Leaf.Path); + var allValsForCol = pivotMap.Values + .SelectMany(cm => cm.TryGetValue(colKey, out var cv) + ? cv : Enumerable.Empty()) + .ToList(); + + if (isVStack) + { + object grandVal = null; + if (allValsForCol.Count > 0) + { + var relativeToVals = f.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals + ? ResolveRelativeToValuesForTotal(args.RelativeTo, entry.Leaf, colLeaves, pivotMap, args) + : args.AllValuesInOrder; + grandVal = Aggregate(f, allValsForCol, context, relativeToVals); + } + result.SetValue(r, col++, grandVal); + } + else + { + foreach (var func in args.Functions) + { + object grandVal = null; + if (allValsForCol.Count > 0) + { + var relativeToVals = func.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals + ? ResolveRelativeToValuesForTotal(args.RelativeTo, entry.Leaf, colLeaves, pivotMap, args) + : args.AllValuesInOrder; + grandVal = Aggregate(func, allValsForCol, context, relativeToVals); + } + result.SetValue(r, col++, grandVal); + } + } + } } - else + + if (showColTotal) { - string colKey = MakePivotKey(entry.Leaf.Path); - var allValsForCol = pivotMap.Values - .SelectMany(cm => cm.TryGetValue(colKey, out var cv) - ? cv - : Enumerable.Empty()) - .ToList(); - - object grandVal = null; - if (allValsForCol.Count > 0) + var allVals = args.AllValuesInOrder.Select(v => new object[] { v[0] }).ToList(); + if (isVStack) { - var relativeToVals = args.Function.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals - ? ResolveRelativeToValuesForTotal(args.RelativeTo, entry.Leaf, colLeaves, pivotMap, args) - : args.AllValuesInOrder; - - grandVal = Aggregate(args.Function, allValsForCol, context, relativeToVals); + var cornerVal = Aggregate(f, allVals, context, + f.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals + ? allVals : args.AllValuesInOrder); + result.SetValue(r, col++, cornerVal); + } + else + { + foreach (var func in args.Functions) + { + var cornerVal = Aggregate(func, allVals, context, + func.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals + ? allVals : args.AllValuesInOrder); + result.SetValue(r, col++, cornerVal); + } } - result.SetValue(r, col, grandVal); } - col++; - } - if (showColTotal) - { - var allVals = args.AllValuesInOrder.Select(v => new object[] { v[0] }).ToList(); - var cornerVal = Aggregate(args.Function, allVals, context, - args.Function.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals - ? allVals // dela med sig själv → 1 - : args.AllValuesInOrder); - result.SetValue(r, grandTotalCol, cornerVal); + // För HSTACK — bryt efter första iterationen + if (!isVStack) break; } - } + // private void WriteGrandTotalRow( + //InMemoryRange result, + //int r, + //List colEntries, + //List colLeaves, + //Dictionary>> pivotMap, + //PivotByArgs args, + //ParsingContext context, + //int nRowKeyCols, + //int colOffset, + //int grandTotalCol, + //bool showColTotal) + // { + // var rowTotalLabel = Math.Abs(args.RowTotalDepth) > 1 ? "Grand Total" : "Total"; + // result.SetValue(r, 0, "Total"); + // for (int c = 1; c < nRowKeyCols; c++) + // result.SetValue(r, c, string.Empty); + + // int col = nRowKeyCols + colOffset; + // foreach (var entry in colEntries) + // { + // if (entry.IsSubtotal) + // { + // var groupVals = entry.GroupLeaves + // .SelectMany(l => + + // { + // var ck = MakePivotKey(l.Path); + // return pivotMap.Values + // .SelectMany(cm => cm.TryGetValue(ck, out var cv) + // ? cv + // : Enumerable.Empty()); + // }) + // .ToList(); + + // object subtotalVal = null; + // if (groupVals.Count > 0) + // subtotalVal = Aggregate(args.Function, groupVals, context, + // args.Function.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null); + // result.SetValue(r, col, subtotalVal); + // } + // else + // { + // string colKey = MakePivotKey(entry.Leaf.Path); + // var allValsForCol = pivotMap.Values + // .SelectMany(cm => cm.TryGetValue(colKey, out var cv) + // ? cv + // : Enumerable.Empty()) + // .ToList(); + + // object grandVal = null; + // if (allValsForCol.Count > 0) + // { + // var relativeToVals = args.Function.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals + // ? ResolveRelativeToValuesForTotal(args.RelativeTo, entry.Leaf, colLeaves, pivotMap, args) + // : args.AllValuesInOrder; + + // grandVal = Aggregate(args.Function, allValsForCol, context, relativeToVals); + // } + // result.SetValue(r, col, grandVal); + // } + // col++; + // } + + // if (showColTotal) + // { + // var allVals = args.AllValuesInOrder.Select(v => new object[] { v[0] }).ToList(); + // var cornerVal = Aggregate(args.Function, allVals, context, + // args.Function.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals + // ? allVals // dela med sig själv → 1 + // : args.AllValuesInOrder); + // result.SetValue(r, grandTotalCol, cornerVal); + // } + + // } + private class ColEntry { public bool IsSubtotal { get; set; } diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs index 18f5ff74e..f0d67490c 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs @@ -353,5 +353,28 @@ public void PivotByCustomLambdaWithHstack() } } + + [TestMethod] + public void PivotByCustomLambdaWithVstack() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; + s.Cells["A2"].Value = "A"; + s.Cells["A3"].Value = "B"; + s.Cells["B1"].Value = "X"; + s.Cells["B2"].Value = "Y"; + s.Cells["B3"].Value = "X"; + s.Cells["C1"].Value = "O"; + s.Cells["C2"].Value = "I"; + s.Cells["C3"].Value = "I"; + s.Cells["D1"].Value = 2; + s.Cells["D2"].Value = 4; + s.Cells["D3"].Value = 1; + s.Cells["E1"].Formula = "PIVOTBY(A1:A3,B1:B3,D1:D3, VSTACK(_xleta.COUNT, LAMBDA(x, SUM(x *2/3)), LAMBDA(x, SUM(x *2)) ),3)"; + s.Calculate(); + } + } } } From ea5fd54ed7506a27fdd5e4784f7d5c55995f0476 Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Wed, 6 May 2026 16:34:16 +0200 Subject: [PATCH 11/16] WIP --- .../Functions/RefAndLookup/GroupByTests.cs | 4 +- .../Functions/RefAndLookup/PivotByTests.cs | 100 ++++++++++++++++++ 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs index c1086bc12..6f86b8294 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs @@ -518,8 +518,6 @@ public void GroupBySortByArrayInput() Assert.AreEqual(s.Cells["K8"].Value, 11d); } } - - // TESTA SKICKA IN LAMBDA SÅ ATT VI KAN SE ATT CUSTOM funktionerna FÅR RÄTT HEADERS "CUSTOM1, CUSTOM2..." - // NOTE: Verkar vara nåt som blir knasigt med headers. Dem skrivs ut på fel ställe i rangen, ex: rubrik på fel ställe ovan + } } diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs index f0d67490c..10a9025a1 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs @@ -376,5 +376,105 @@ public void PivotByCustomLambdaWithVstack() s.Calculate(); } } + + [TestMethod] + public void PivotBySortOrderArray() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; + s.Cells["A2"].Value = "A"; + s.Cells["A3"].Value = "B"; + s.Cells["B1"].Value = "X"; + s.Cells["B2"].Value = "Y"; + s.Cells["B3"].Value = "X"; + s.Cells["C1"].Value = "O"; + s.Cells["C2"].Value = "I"; + s.Cells["C3"].Value = "I"; + s.Cells["D1"].Value = 2; + s.Cells["D2"].Value = 4; + s.Cells["D3"].Value = 1; + s.Cells["E1"].Formula = "PIVOTBY(A1:B3,C1:C3,D1:D3, _xleta.SUM,,, {-1,-2})"; + s.Calculate(); + + // Rubrikrad + Assert.AreEqual("I", s.Cells["G1"].Value); + Assert.AreEqual("O", s.Cells["H1"].Value); + Assert.AreEqual("Total", s.Cells["I1"].Value); + + // B X + Assert.AreEqual("B", s.Cells["E2"].Value); + Assert.AreEqual("X", s.Cells["F2"].Value); + Assert.AreEqual(1d, s.Cells["G2"].Value); + Assert.AreEqual(1d, s.Cells["I2"].Value); + + // A Y + Assert.AreEqual("A", s.Cells["E3"].Value); + Assert.AreEqual("Y", s.Cells["F3"].Value); + Assert.AreEqual(4d, s.Cells["G3"].Value); + Assert.AreEqual(4d, s.Cells["I3"].Value); + + // A X + Assert.AreEqual("A", s.Cells["E4"].Value); + Assert.AreEqual("X", s.Cells["F4"].Value); + Assert.AreEqual(2d, s.Cells["H4"].Value); + Assert.AreEqual(2d, s.Cells["I4"].Value); + + // Total + Assert.AreEqual("Total", s.Cells["E5"].Value); + Assert.AreEqual(5d, s.Cells["G5"].Value); + Assert.AreEqual(2d, s.Cells["H5"].Value); + Assert.AreEqual(7d, s.Cells["I5"].Value); + } + } + + [TestMethod] + + public void PivotByTemplateTest() + { + using (var package = OpenTemplatePackage("PivotByTest1.xlsx")) + { + var sheet = package.Workbook.Worksheets[1]; + + sheet.Cells["B15"].Formula = "PIVOTBY('FCL V'!C6:C2055,'FCL V'!Y6:Y2055,'FCL V'!DH6:DH2055, _xleta.SUM)"; + sheet.Calculate(); + + Assert.AreEqual("Albania", sheet.Cells["C15"].Value); + SaveAndCleanup(package); + } + } + + [TestMethod] + public void PivotByTemplateTest2() + { + using (var package = OpenTemplatePackage("PivotByTest1.xlsx")) + { + var sheet = package.Workbook.Worksheets[2]; + + sheet.Cells["B17"].Formula = "PIVOTBY('FCL V'!C6:D2055,'FCL V'!Y6:Y2055,'FCL V'!DH6:DH2055, _xleta.SUM)"; + sheet.Calculate(); + + Assert.AreEqual("Albania", sheet.Cells["D17"].Value); + SaveAndCleanup(package); + } + } + + [TestMethod] + public void PivotByTemplateTest3() + { + using (var package = OpenTemplatePackage("PivotByTest1.xlsx")) + { + var sheet = package.Workbook.Worksheets[3]; + + sheet.Cells["B26"].Formula = "PIVOTBY('FCL V'!C6:D2055,'FCL V'!Y6:Y2055,'FCL V'!DH6:DH2055, _xleta.PERCENTOF,,2,,,,,3)"; + sheet.Calculate(); + + Assert.AreEqual("Albania", sheet.Cells["D26"].Value); + Assert.AreEqual(0.021928991, sheet.Cells["G27"].Value); + + //SaveAndCleanup(package); + } + } } } From a2e43b14991731456bd851fd9b1b7126741e17bc Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Thu, 7 May 2026 16:30:47 +0200 Subject: [PATCH 12/16] WIP --- .../Excel/Functions/RefAndLookup/PivotBy.cs | 78 ------------------- .../Functions/RefAndLookup/PivotByTests.cs | 59 ++++++++++++-- 2 files changed, 53 insertions(+), 84 deletions(-) diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs index dc9c912d7..c9eacf90f 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs @@ -875,82 +875,6 @@ private void WriteGrandTotalRow( } } - // private void WriteGrandTotalRow( - //InMemoryRange result, - //int r, - //List colEntries, - //List colLeaves, - //Dictionary>> pivotMap, - //PivotByArgs args, - //ParsingContext context, - //int nRowKeyCols, - //int colOffset, - //int grandTotalCol, - //bool showColTotal) - // { - // var rowTotalLabel = Math.Abs(args.RowTotalDepth) > 1 ? "Grand Total" : "Total"; - // result.SetValue(r, 0, "Total"); - // for (int c = 1; c < nRowKeyCols; c++) - // result.SetValue(r, c, string.Empty); - - // int col = nRowKeyCols + colOffset; - // foreach (var entry in colEntries) - // { - // if (entry.IsSubtotal) - // { - // var groupVals = entry.GroupLeaves - // .SelectMany(l => - - // { - // var ck = MakePivotKey(l.Path); - // return pivotMap.Values - // .SelectMany(cm => cm.TryGetValue(ck, out var cv) - // ? cv - // : Enumerable.Empty()); - // }) - // .ToList(); - - // object subtotalVal = null; - // if (groupVals.Count > 0) - // subtotalVal = Aggregate(args.Function, groupVals, context, - // args.Function.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null); - // result.SetValue(r, col, subtotalVal); - // } - // else - // { - // string colKey = MakePivotKey(entry.Leaf.Path); - // var allValsForCol = pivotMap.Values - // .SelectMany(cm => cm.TryGetValue(colKey, out var cv) - // ? cv - // : Enumerable.Empty()) - // .ToList(); - - // object grandVal = null; - // if (allValsForCol.Count > 0) - // { - // var relativeToVals = args.Function.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals - // ? ResolveRelativeToValuesForTotal(args.RelativeTo, entry.Leaf, colLeaves, pivotMap, args) - // : args.AllValuesInOrder; - - // grandVal = Aggregate(args.Function, allValsForCol, context, relativeToVals); - // } - // result.SetValue(r, col, grandVal); - // } - // col++; - // } - - // if (showColTotal) - // { - // var allVals = args.AllValuesInOrder.Select(v => new object[] { v[0] }).ToList(); - // var cornerVal = Aggregate(args.Function, allVals, context, - // args.Function.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals - // ? allVals // dela med sig själv → 1 - // : args.AllValuesInOrder); - // result.SetValue(r, grandTotalCol, cornerVal); - // } - - // } - private class ColEntry { public bool IsSubtotal { get; set; } @@ -969,8 +893,6 @@ private List CollectLeavesWithPath(List levels, object path[i] = parentPath[i]; path[parentPath.Length] = level.Key; - Debug.WriteLine($"Level key={level.Key}, IsLeaf={level.IsLeaf}, path.Length={path.Length}, path={string.Join("|", path.Select(p => p?.ToString()).ToArray())}"); - if (level.IsLeaf) result.Add(new LeafWithPath(level, path)); else diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs index 10a9025a1..26c80a06b 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs @@ -32,13 +32,13 @@ public void BasicPivotBy() Assert.AreEqual("Total", s.Cells["G1"].Value); // Anna-rad - Assert.AreEqual("Anna", s.Cells["D2"].Value); + Assert.AreEqual("Anna", s.Cells["D2"].Value); Assert.AreEqual(2d, s.Cells["F2"].Value); Assert.AreEqual(2d, s.Cells["G2"].Value); // Joe-rad Assert.AreEqual("Joe", s.Cells["D3"].Value); - Assert.AreEqual(1d, s.Cells["E3"].Value); + Assert.AreEqual(1d, s.Cells["E3"].Value); Assert.AreEqual(1d, s.Cells["G3"].Value); // Total-rad @@ -226,7 +226,7 @@ public void PivotByRelativeTo() { using (var package = new ExcelPackage()) { - var s = package.Workbook.Worksheets.Add("test"); + var s = package.Workbook.Worksheets.Add("test"); s.Cells["A1"].Value = "A"; s.Cells["A2"].Value = "A"; s.Cells["A3"].Value = "B"; @@ -329,7 +329,7 @@ public void PivotByHeaders() Assert.AreEqual("A", s.Cells["E4"].Value); } } - + [TestMethod] public void PivotByCustomLambdaWithHstack() { @@ -430,7 +430,7 @@ public void PivotBySortOrderArray() } [TestMethod] - + public void PivotByTemplateTest() { using (var package = OpenTemplatePackage("PivotByTest1.xlsx")) @@ -468,7 +468,7 @@ public void PivotByTemplateTest3() var sheet = package.Workbook.Worksheets[3]; sheet.Cells["B26"].Formula = "PIVOTBY('FCL V'!C6:D2055,'FCL V'!Y6:Y2055,'FCL V'!DH6:DH2055, _xleta.PERCENTOF,,2,,,,,3)"; - sheet.Calculate(); + sheet.Cells["B26"].Calculate(); Assert.AreEqual("Albania", sheet.Cells["D26"].Value); Assert.AreEqual(0.021928991, sheet.Cells["G27"].Value); @@ -476,5 +476,52 @@ public void PivotByTemplateTest3() //SaveAndCleanup(package); } } + + [TestMethod] + public void PivotByTemplateTest4() + { + using (var package = OpenTemplatePackage("PivotByTest1.xlsx")) + { + var sheet = package.Workbook.Worksheets[3]; + + sheet.Cells["B26"].Formula = "PIVOTBY('FCL V'!C6:D2055,'FCL V'!Y6:Y2055,'FCL V'!DH6:DH2055, _xleta.PERCENTOF,,2,,,,,)"; + sheet.Cells["B26"].Calculate(); + + Assert.AreEqual("Albania", sheet.Cells["D26"].Value); + Assert.AreEqual(0.99237652, sheet.Cells["G27"].Value); + + //SaveAndCleanup(package); + } + } + + [TestMethod] + public void PivotBySortOrderPercentOf() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; + s.Cells["A2"].Value = "A"; + s.Cells["A3"].Value = "B"; + s.Cells["A4"].Value = "B"; + s.Cells["A5"].Value = "C"; + s.Cells["A6"].Value = "C"; + s.Cells["B1"].Value = "X"; + s.Cells["B2"].Value = "Y"; + s.Cells["B3"].Value = "X"; + s.Cells["B4"].Value = "Y"; + s.Cells["B5"].Value = "X"; + s.Cells["B6"].Value = "Y"; + s.Cells["C1"].Value = 2; + s.Cells["C2"].Value = 4; + s.Cells["C3"].Value = 1; + s.Cells["C4"].Value = 5; + s.Cells["C5"].Value = 7; + s.Cells["C6"].Value = 4; + s.Cells["D1"].Formula = "PIVOTBY(A1:A6,B1:B6,C1:C6,_xleta.PERCENTOF)"; + s.Calculate(); + Assert.AreEqual(0.2, s.Cells["E2"].Value); + } + } } } From 757f7dd46483d863423ff49a88e8440cd5bd669f Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Mon, 11 May 2026 16:33:44 +0200 Subject: [PATCH 13/16] WIP --- src/EPPlus/ExcelWorkbook.cs | 14 +- .../Excel/Functions/RefAndLookup/ColEntry.cs | 26 +++ .../GroupingFunctions/LeafWithPath.cs | 27 +++ .../Excel/Functions/RefAndLookup/PivotBy.cs | 209 +++++++++++------- .../Functions/RefAndLookup/PivotByTests.cs | 19 +- 5 files changed, 207 insertions(+), 88 deletions(-) create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/ColEntry.cs create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/LeafWithPath.cs diff --git a/src/EPPlus/ExcelWorkbook.cs b/src/EPPlus/ExcelWorkbook.cs index b285effb1..c9873ec96 100644 --- a/src/EPPlus/ExcelWorkbook.cs +++ b/src/EPPlus/ExcelWorkbook.cs @@ -1151,6 +1151,11 @@ private void CreateWorkbookXml(XmlNamespaceManager namespaceManager) wbElem.SetAttribute("xmlns:r", ExcelPackage.schemaRelationships); _workbookXml.AppendChild(wbElem); + XmlElement fileVersion = _workbookXml.CreateElement("fileVersion", ExcelPackage.schemaMain); + fileVersion.SetAttribute("appName", "xl"); + fileVersion.SetAttribute("lastEdited", "7"); + fileVersion.SetAttribute("lowestEdited", "7"); + wbElem.AppendChild(fileVersion); // create the bookViews and workbooks element XmlElement bookViews = _workbookXml.CreateElement("bookViews", ExcelPackage.schemaMain); @@ -1158,6 +1163,12 @@ private void CreateWorkbookXml(XmlNamespaceManager namespaceManager) XmlElement workbookView = _workbookXml.CreateElement("workbookView", ExcelPackage.schemaMain); bookViews.AppendChild(workbookView); + + XmlElement extLst = _workbookXml.CreateElement("extLst", ExcelPackage.schemaMain); + extLst.InnerXml = ""; + wbElem.AppendChild(extLst); + + // save it to the package StreamWriter stream = new StreamWriter(partWorkbook.GetStream(FileMode.Create, FileAccess.Write)); _workbookXml.Save(stream); @@ -1282,7 +1293,8 @@ public ExcelCalcMode CalcMode SetXmlNodeString(CALC_MODE_PATH, "autoNoTable"); break; case ExcelCalcMode.Manual: - SetXmlNodeString(CALC_MODE_PATH, "manual"); + SetXmlNodeString(CALC_MODE_PATH, "manual"); + SetXmlNodeString("d:calcPr/@calcId", "191029"); break; default: SetXmlNodeString(CALC_MODE_PATH, "auto"); diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/ColEntry.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/ColEntry.cs new file mode 100644 index 000000000..2d582ae60 --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/ColEntry.cs @@ -0,0 +1,26 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 13/4/2026 EPPlus Software AB EPPlus v8.6 + *************************************************************************************************/ + +using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions; +using System.Collections.Generic; + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup +{ + internal class ColEntry + { + public bool IsSubtotal { get; set; } + public string GroupKey { get; set; } + public List GroupLeaves { get; set; } + public LeafWithPath Leaf { get; set; } + } +} \ No newline at end of file diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/LeafWithPath.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/LeafWithPath.cs new file mode 100644 index 000000000..86b0bf567 --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/LeafWithPath.cs @@ -0,0 +1,27 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 13/4/2026 EPPlus Software AB EPPlus v8.6 + *************************************************************************************************/ + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions +{ + internal class LeafWithPath + { + public GroupLevel Leaf { get; private set; } + public object[] Path { get; private set; } + + public LeafWithPath(GroupLevel leaf, object[] path) + { + Leaf = leaf; + Path = path; + } + } +} \ No newline at end of file diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs index c9eacf90f..6dcb6e446 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs @@ -28,7 +28,7 @@ namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup Category = ExcelFunctionCategory.LookupAndReference, EPPlusVersion = "8.6", Description = "Allows you to create a summary of your data via a formula. It supports grouping along two axis and aggregating the associated values.")] - internal class PivotBy : GroupByFunctionBase + internal partial class PivotBy : GroupByFunctionBase { public override int ArgumentMinLength => 3; // kan // ske ska vara 4 @@ -369,15 +369,24 @@ private List ResolveRelativeToValues( } case RelativeTo.ParentColTotal: { + if (!pivotMap.TryGetValue(rowKey, out var colMap)) + return null; + + // En kolumnnivå = ingen riktig föräldragrupp finns. + // Faller tillbaka på RowTotals: nämnare = radens totalsumma över ALLA kolumner. + if (colLeaf.Path.Length <= 1) + { + return colMap.Values + .SelectMany(vals => vals) + .ToList(); + } + + // Flernivå: nämnare = radens värden inom förälderns kolumngrupp var parentKey = colLeaf.Path[0]?.ToString()?.ToLowerInvariant() ?? string.Empty; var siblingLeaves = colLeaves .Where(l => (l.Path[0]?.ToString()?.ToLowerInvariant() ?? string.Empty) == parentKey) .ToList(); - // Hämta radens egna värden under alla syskonkolumner - if (!pivotMap.TryGetValue(rowKey, out var colMap)) - return null; - return siblingLeaves .Where(leaf => colMap.ContainsKey(MakePivotKey(leaf.Path))) .SelectMany(leaf => colMap[MakePivotKey(leaf.Path)]) @@ -418,16 +427,23 @@ private List ResolveRelativeToValuesForTotal( } case RelativeTo.ParentColTotal: { - // Nämnaren = alla värden under förälderns grupp (alla syskonkolumner) + // En kolumnnivå = faller tillbaka på GrandTotals som nämnare + if (colLeaf.Path.Length <= 1) + { + return pivotMap.Values + .SelectMany(colMap => colMap.Values.SelectMany(v => v)) + .ToList(); + } + var parentKey = colLeaf.Path[0]?.ToString()?.ToLowerInvariant() ?? string.Empty; var siblingLeaves = colLeaves .Where(l => (l.Path[0]?.ToString()?.ToLowerInvariant() ?? string.Empty) == parentKey) .ToList(); return pivotMap.Values - .SelectMany(colMap => siblingLeaves - .Where(leaf => colMap.ContainsKey(MakePivotKey(leaf.Path))) - .SelectMany(leaf => colMap[MakePivotKey(leaf.Path)])) + .SelectMany(cm => siblingLeaves + .Where(leaf => cm.ContainsKey(MakePivotKey(leaf.Path))) + .SelectMany(leaf => cm[MakePivotKey(leaf.Path)])) .ToList(); } case RelativeTo.ParentRowTotal: // 4 @@ -449,12 +465,13 @@ private List ResolveRelativeToValuesForTotal( private static string MakePivotKey(object[] parts) => string.Join("\u001F", parts.Select(p => p?.ToString()?.ToLowerInvariant() ?? string.Empty).ToArray()); + private InMemoryRange RenderPivot( - List rowLeaves, - List colLeaves, - Dictionary>> pivotMap, - PivotByArgs args, - ParsingContext context) + List rowLeaves, + List colLeaves, + Dictionary>> pivotMap, + PivotByArgs args, + ParsingContext context) { int nRowKeyCols = args.RowFields.Size.NumberOfCols; int nColKeyRows = args.ColFields.Size.NumberOfCols; @@ -474,9 +491,9 @@ private InMemoryRange RenderPivot( var resolvedHeaders = ResolveHeaders(args.Headers, args.Values); bool showFieldHeaders = resolvedHeaders == FieldHeaders.YesAndShow; int fieldHeaderRows = showFieldHeaders ? 1 : 0; - int headerDataRows = showFieldHeaders ? 1 : 0; + int headerDataRows = showFieldHeaders ? 1 : 0; int functionHeaderRows = isHStack ? 1 : 0; - int functionNameCol = isVStack ? nRowKeyCols : -1; // extra kolumn för VSTACK + int functionNameCol = isVStack ? nRowKeyCols : -1; int functionColOffset = isVStack ? 1 : 0; var colGroups = colLeaves @@ -493,15 +510,11 @@ private InMemoryRange RenderPivot( colEntries.Add(new ColEntry { IsSubtotal = true, GroupKey = group.Key, GroupLeaves = groupLeaves }); } - // Antal datakolumner per entry int colsPerEntry = isHStack ? nFunctions : 1; int nDataCols = colEntries.Count * colsPerEntry; int nTotalCols = showColTotal ? colsPerEntry : 0; - - // Totalt antal kolumner int totalCols = nRowKeyCols + functionColOffset + nDataCols + nTotalCols; - // Totalt antal rader int rowsPerLeaf = isVStack ? nFunctions : 1; int rowsPerTotal = isVStack ? nFunctions : 1; int totalRows = fieldHeaderRows + nColKeyRows + functionHeaderRows + headerDataRows @@ -657,7 +670,9 @@ private InMemoryRange RenderPivot( ? Aggregate(func, groupVals, context, func.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null) : null; - result.SetValue(outputRow, col++, val); + if (val != null) + result.SetValue(outputRow, col, val); + col++; } } else @@ -666,7 +681,9 @@ private InMemoryRange RenderPivot( ? Aggregate(f, groupVals, context, f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null) : null; - result.SetValue(outputRow, col++, val); + if (val != null) + result.SetValue(outputRow, col, val); + col++; } } else @@ -681,12 +698,17 @@ private InMemoryRange RenderPivot( if (pivotMap.TryGetValue(rowKey, out var colMap) && colMap.TryGetValue(colKey, out var cellVals)) { - var relativeToVals = func.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals - ? ResolveRelativeToValues(args.RelativeTo, entry.Leaf, colLeaves, rowKey, pivotMap, args) + var effectiveRelativeTo = func.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo == RelativeTo.ColumnTotals + ? RelativeTo.ParentRowTotal + : args.RelativeTo; + var relativeToVals = func.EtaFunction?.Name == "PERCENTOF" + ? ResolveRelativeToValues(effectiveRelativeTo, entry.Leaf, colLeaves, rowKey, pivotMap, args) ?? args.AllValuesInOrder : args.AllValuesInOrder; aggregated = Aggregate(func, cellVals, context, relativeToVals); } - result.SetValue(outputRow, col++, aggregated); + if (aggregated != null) + result.SetValue(outputRow, col, aggregated); + col++; } } else @@ -695,12 +717,17 @@ private InMemoryRange RenderPivot( if (pivotMap.TryGetValue(rowKey, out var colMap) && colMap.TryGetValue(colKey, out var cellVals)) { - var relativeToVals = f.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals - ? ResolveRelativeToValues(args.RelativeTo, entry.Leaf, colLeaves, rowKey, pivotMap, args) + var effectiveRelativeTo = f.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo == RelativeTo.ColumnTotals + ? RelativeTo.ParentRowTotal + : args.RelativeTo; + var relativeToVals = f.EtaFunction?.Name == "PERCENTOF" + ? ResolveRelativeToValues(effectiveRelativeTo, entry.Leaf, colLeaves, rowKey, pivotMap, args) ?? args.AllValuesInOrder : args.AllValuesInOrder; aggregated = Aggregate(f, cellVals, context, relativeToVals); } - result.SetValue(outputRow, col++, aggregated); + if (aggregated != null) + result.SetValue(outputRow, col, aggregated); + col++; } } } @@ -712,24 +739,41 @@ private InMemoryRange RenderPivot( { foreach (var func in args.Functions) { - var relVals = func.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals - ? (args.RelativeTo == RelativeTo.GrandTotals || args.RelativeTo == RelativeTo.ParentRowTotal - ? args.AllValuesInOrder : rowAllVals) - : args.AllValuesInOrder; - result.SetValue(outputRow, col++, Aggregate(func, rowAllVals, context, relVals)); + var relVals = args.AllValuesInOrder; + if (func.EtaFunction?.Name == "PERCENTOF") + { + var effectiveRelativeTo = args.RelativeTo == RelativeTo.ColumnTotals + ? RelativeTo.ParentRowTotal + : args.RelativeTo; + relVals = effectiveRelativeTo == RelativeTo.GrandTotals || effectiveRelativeTo == RelativeTo.ParentRowTotal + ? args.AllValuesInOrder + : rowAllVals; + } + var totalVal = Aggregate(func, rowAllVals, context, relVals); + if (totalVal != null) + result.SetValue(outputRow, col, totalVal); + col++; } } else { - var relVals = f.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals - ? (args.RelativeTo == RelativeTo.GrandTotals || args.RelativeTo == RelativeTo.ParentRowTotal - ? args.AllValuesInOrder : rowAllVals) - : args.AllValuesInOrder; - result.SetValue(outputRow, col++, Aggregate(f, rowAllVals, context, relVals)); + var relVals = args.AllValuesInOrder; + if (f.EtaFunction?.Name == "PERCENTOF") + { + var effectiveRelativeTo = args.RelativeTo == RelativeTo.ColumnTotals + ? RelativeTo.ParentRowTotal + : args.RelativeTo; + relVals = effectiveRelativeTo == RelativeTo.GrandTotals || effectiveRelativeTo == RelativeTo.ParentRowTotal + ? args.AllValuesInOrder + : rowAllVals; + } + var totalVal = Aggregate(f, rowAllVals, context, relVals); + if (totalVal != null) + result.SetValue(outputRow, col, totalVal); + col++; } } - // För HSTACK — bryt efter första iterationen if (isHStack) break; } } @@ -796,7 +840,9 @@ private void WriteGrandTotalRow( ? Aggregate(f, groupVals, context, f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null) : null; - result.SetValue(r, col++, val); + if (val != null) + result.SetValue(r, col, val); + col++; } else { @@ -806,7 +852,9 @@ private void WriteGrandTotalRow( ? Aggregate(func, groupVals, context, func.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null) : null; - result.SetValue(r, col++, val); + if (val != null) + result.SetValue(r, col, val); + col++; } } } @@ -823,12 +871,17 @@ private void WriteGrandTotalRow( object grandVal = null; if (allValsForCol.Count > 0) { - var relativeToVals = f.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals - ? ResolveRelativeToValuesForTotal(args.RelativeTo, entry.Leaf, colLeaves, pivotMap, args) + var effectiveRelativeTo = f.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo == RelativeTo.ColumnTotals + ? RelativeTo.ParentRowTotal + : args.RelativeTo; + var relativeToVals = f.EtaFunction?.Name == "PERCENTOF" + ? ResolveRelativeToValuesForTotal(effectiveRelativeTo, entry.Leaf, colLeaves, pivotMap, args) ?? args.AllValuesInOrder : args.AllValuesInOrder; grandVal = Aggregate(f, allValsForCol, context, relativeToVals); } - result.SetValue(r, col++, grandVal); + if (grandVal != null) + result.SetValue(r, col, grandVal); + col++; } else { @@ -837,12 +890,17 @@ private void WriteGrandTotalRow( object grandVal = null; if (allValsForCol.Count > 0) { - var relativeToVals = func.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals - ? ResolveRelativeToValuesForTotal(args.RelativeTo, entry.Leaf, colLeaves, pivotMap, args) + var effectiveRelativeTo = func.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo == RelativeTo.ColumnTotals + ? RelativeTo.ParentRowTotal + : args.RelativeTo; + var relativeToVals = func.EtaFunction?.Name == "PERCENTOF" + ? ResolveRelativeToValuesForTotal(effectiveRelativeTo, entry.Leaf, colLeaves, pivotMap, args) ?? args.AllValuesInOrder : args.AllValuesInOrder; grandVal = Aggregate(func, allValsForCol, context, relativeToVals); } - result.SetValue(r, col++, grandVal); + if (grandVal != null) + result.SetValue(r, col, grandVal); + col++; } } } @@ -853,36 +911,41 @@ private void WriteGrandTotalRow( var allVals = args.AllValuesInOrder.Select(v => new object[] { v[0] }).ToList(); if (isVStack) { - var cornerVal = Aggregate(f, allVals, context, - f.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals - ? allVals : args.AllValuesInOrder); - result.SetValue(r, col++, cornerVal); + var effectiveRelativeTo = f.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo == RelativeTo.ColumnTotals + ? RelativeTo.ParentRowTotal + : args.RelativeTo; + var cornerRelVals = f.EtaFunction?.Name == "PERCENTOF" + ? (effectiveRelativeTo == RelativeTo.ParentRowTotal || effectiveRelativeTo == RelativeTo.GrandTotals + ? allVals : args.AllValuesInOrder) + : args.AllValuesInOrder; + var cornerVal = Aggregate(f, allVals, context, cornerRelVals); + if (cornerVal != null) + result.SetValue(r, col, cornerVal); + col++; } else { foreach (var func in args.Functions) { - var cornerVal = Aggregate(func, allVals, context, - func.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo != RelativeTo.ColumnTotals - ? allVals : args.AllValuesInOrder); - result.SetValue(r, col++, cornerVal); + var effectiveRelativeTo = func.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo == RelativeTo.ColumnTotals + ? RelativeTo.ParentRowTotal + : args.RelativeTo; + var cornerRelVals = func.EtaFunction?.Name == "PERCENTOF" + ? (effectiveRelativeTo == RelativeTo.ParentRowTotal || effectiveRelativeTo == RelativeTo.GrandTotals + ? allVals : args.AllValuesInOrder) + : args.AllValuesInOrder; + var cornerVal = Aggregate(func, allVals, context, cornerRelVals); + if (cornerVal != null) + result.SetValue(r, col, cornerVal); + col++; } } } - // För HSTACK — bryt efter första iterationen if (!isVStack) break; } } - private class ColEntry - { - public bool IsSubtotal { get; set; } - public string GroupKey { get; set; } - public List GroupLeaves { get; set; } - public LeafWithPath Leaf { get; set; } - } - private List CollectLeavesWithPath(List levels, object[] parentPath) { var result = new List(); @@ -899,22 +962,6 @@ private List CollectLeavesWithPath(List levels, object result.AddRange(CollectLeavesWithPath(level.Children, path)); } return result; - } - private List CollectLeaves(List leaves) - { - return leaves; // redan platta löv, returnera direkt - } - private class LeafWithPath - { - public GroupLevel Leaf { get; private set; } - public object[] Path { get; private set; } - - public LeafWithPath(GroupLevel leaf, object[] path) - { - Leaf = leaf; - Path = path; - } - } - + } } } \ No newline at end of file diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs index 26c80a06b..cb209b2ee 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs @@ -25,6 +25,8 @@ public void BasicPivotBy() s.Cells["B2"].Value = 2; s.Cells["D1"].Formula = "PIVOTBY(A1:A2,C1:C2, B1:B2, _xleta.SUM)"; s.Calculate(); + s.Workbook.FullCalcOnLoad = false; + s.Workbook.CalcMode = ExcelCalcMode.Manual; // Rubrikrad Assert.AreEqual("Bertil", s.Cells["E1"].Value); @@ -46,6 +48,8 @@ public void BasicPivotBy() Assert.AreEqual(1d, s.Cells["E4"].Value); Assert.AreEqual(2d, s.Cells["F4"].Value); Assert.AreEqual(3d, s.Cells["G4"].Value); + + SaveWorkbook("BasicPivotBy.xlsx", package); } } @@ -451,9 +455,11 @@ public void PivotByTemplateTest2() using (var package = OpenTemplatePackage("PivotByTest1.xlsx")) { var sheet = package.Workbook.Worksheets[2]; + package.Workbook.CalcMode = ExcelCalcMode.Manual; sheet.Cells["B17"].Formula = "PIVOTBY('FCL V'!C6:D2055,'FCL V'!Y6:Y2055,'FCL V'!DH6:DH2055, _xleta.SUM)"; - sheet.Calculate(); + //sheet.Calculate(); + sheet.Cells["B17"].Calculate(); Assert.AreEqual("Albania", sheet.Cells["D17"].Value); SaveAndCleanup(package); @@ -466,14 +472,15 @@ public void PivotByTemplateTest3() using (var package = OpenTemplatePackage("PivotByTest1.xlsx")) { var sheet = package.Workbook.Worksheets[3]; + package.Workbook.CalcMode = ExcelCalcMode.Manual; sheet.Cells["B26"].Formula = "PIVOTBY('FCL V'!C6:D2055,'FCL V'!Y6:Y2055,'FCL V'!DH6:DH2055, _xleta.PERCENTOF,,2,,,,,3)"; sheet.Cells["B26"].Calculate(); - + Assert.AreEqual("Albania", sheet.Cells["D26"].Value); - Assert.AreEqual(0.021928991, sheet.Cells["G27"].Value); + Assert.AreEqual(0.021928991, System.Math.Round((double)sheet.Cells["G27"].Value), 8); - //SaveAndCleanup(package); + SaveAndCleanup(package); } } @@ -483,13 +490,13 @@ public void PivotByTemplateTest4() using (var package = OpenTemplatePackage("PivotByTest1.xlsx")) { var sheet = package.Workbook.Worksheets[3]; + package.Workbook.CalcMode = ExcelCalcMode.Manual; sheet.Cells["B26"].Formula = "PIVOTBY('FCL V'!C6:D2055,'FCL V'!Y6:Y2055,'FCL V'!DH6:DH2055, _xleta.PERCENTOF,,2,,,,,)"; sheet.Cells["B26"].Calculate(); Assert.AreEqual("Albania", sheet.Cells["D26"].Value); - Assert.AreEqual(0.99237652, sheet.Cells["G27"].Value); - + Assert.AreEqual(0.99237652, System.Math.Round((double)sheet.Cells["G27"].Value), 8d); //SaveAndCleanup(package); } } From a0c41eaa483f770bbdc41de6b29a04294937e0df Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Tue, 12 May 2026 16:29:06 +0200 Subject: [PATCH 14/16] WIP --- .../Excel/Functions/BuiltInFunctions.cs | 1 + .../RefAndLookup/PivotBy/DataBuilding.cs | 169 ++++ .../Functions/RefAndLookup/PivotBy/PivotBy.cs | 142 ++++ .../{PivotBy.cs => PivotBy/Rendering.cs} | 775 +++++++----------- .../Functions/RefAndLookup/PivotByTests.cs | 68 +- 5 files changed, 689 insertions(+), 466 deletions(-) create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/DataBuilding.cs create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/PivotBy.cs rename src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/{PivotBy.cs => PivotBy/Rendering.cs} (53%) diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs b/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs index 28120ffec..e75d975e3 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs @@ -17,6 +17,7 @@ Date Author Change using OfficeOpenXml.FormulaParsing.Excel.Functions.DateAndTime; using OfficeOpenXml.FormulaParsing.Excel.Functions.Numeric; using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup; +using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.PivotBy; using OfficeOpenXml.FormulaParsing.Excel.Functions.Information; using OfficeOpenXml.FormulaParsing.Excel.Functions.Finance; using OfficeOpenXml.FormulaParsing.Excel.Functions.Engineering; diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/DataBuilding.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/DataBuilding.cs new file mode 100644 index 000000000..dfa6dcd81 --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/DataBuilding.cs @@ -0,0 +1,169 @@ +using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.PivotBy +{ + internal partial class PivotBy + { + private void BuildPivotData( + PivotByArgs args, + ParsingContext context, + out List rowLeaves, + out List colLeaves, + out Dictionary>> pivotMap) + { + var resolvedHeaders = ResolveHeaders(args.Headers, args.Values); + bool hasHeaders = resolvedHeaders == FieldHeaders.YesAndShow + || resolvedHeaders == FieldHeaders.YesAndDontShow; + int startRow = hasHeaders ? 1 : 0; + + int nRowKeyCols = args.RowFields.Size.NumberOfCols; + int nColKeyCols = args.ColFields.Size.NumberOfCols; + int nValCols = args.Values.Size.NumberOfCols; + + var rowLeafDict = new Dictionary(StringComparer.OrdinalIgnoreCase); + var rowLeafOrder = new List(); + var colLeafDict = new Dictionary(StringComparer.OrdinalIgnoreCase); + var colLeafOrder = new List(); + pivotMap = new Dictionary>>(StringComparer.OrdinalIgnoreCase); + + int nRows = args.RowFields.Size.NumberOfRows; + + for (int r = startRow; r < nRows; r++) + { + if (args.FilterArray != null) + { + var fv = args.FilterArray.GetOffset(r, 0); + if (fv is bool b && !b) continue; + if (fv is int i && i == 0) continue; + } + + var rowKeyParts = new object[nRowKeyCols]; + for (int c = 0; c < nRowKeyCols; c++) + rowKeyParts[c] = args.RowFields.GetOffset(r, c); + + var colKeyParts = new object[nColKeyCols]; + for (int c = 0; c < nColKeyCols; c++) + colKeyParts[c] = args.ColFields.GetOffset(r, c); + + var vals = new object[nValCols]; + for (int c = 0; c < nValCols; c++) + vals[c] = args.Values.GetOffset(r, c); + + // Radlöv + string rowKey = MakePivotKey(rowKeyParts); + if (!rowLeafDict.ContainsKey(rowKey)) + { + var leaf = new GroupLevel { Key = rowKeyParts[rowKeyParts.Length - 1] }; + rowLeafDict[rowKey] = new LeafWithPath(leaf, rowKeyParts); + rowLeafOrder.Add(rowKey); + } + var rowLeafEntry = rowLeafDict[rowKey]; + var existingRow = rowLeafEntry.Leaf.Rows.FirstOrDefault(rw => MakePivotKey(rw.KeyParts) == rowKey); + if (existingRow == null) + { + existingRow = new GroupRow { KeyParts = rowKeyParts }; + rowLeafEntry.Leaf.Rows.Add(existingRow); + } + existingRow.Values.Add(vals); + + // Kolumnlöv + string colKey = MakePivotKey(colKeyParts); + if (!colLeafDict.ContainsKey(colKey)) + { + var leaf = new GroupLevel { Key = colKeyParts[colKeyParts.Length - 1] }; + colLeafDict[colKey] = new LeafWithPath(leaf, colKeyParts); + colLeafOrder.Add(colKey); + } + + // Pivotkartan + if (!pivotMap.TryGetValue(rowKey, out var colMap)) + { + colMap = new Dictionary>(StringComparer.OrdinalIgnoreCase); + pivotMap[rowKey] = colMap; + } + if (!colMap.TryGetValue(colKey, out var cellVals)) + { + cellVals = new List(); + colMap[colKey] = cellVals; + } + cellVals.Add(vals); + args.AllValuesInOrder.Add(vals); + } + + rowLeaves = rowLeafOrder.Select(k => rowLeafDict[k]).ToList(); + colLeaves = colLeafOrder.Select(k => colLeafDict[k]).ToList(); + + // Aggregera SubtotalValues per radlöv (används för rad-totals) + foreach (var rl in rowLeaves) + AggregateLeaf(rl.Leaf, args, context); + } + + private void AggregateLeaf(GroupLevel leaf, PivotByArgs args, ParsingContext context) + { + var allVals = leaf.Rows.SelectMany(r => r.Values).ToList(); + leaf.SubtotalValues = args.Functions.Select(f => + { + int nValCols = allVals[0].Length; + var result = new object[nValCols]; + for (int col = 0; col < nValCols; col++) + { + var colValues = allVals.Select(v => new object[] { v[col] }).ToList(); + result[col] = Aggregate(f, colValues, context, + f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null); + } + return result; + }).ToList(); + leaf.SubtotalValue = leaf.SubtotalValues[0][0]; + } + + + private List ApplyRowSort(List rowLeaves, PivotByArgs args) => + ApplyLeafSort(rowLeaves, args.RowSortOrders); + + private List ApplyColSort(List colLeaves, PivotByArgs args) => + ApplyLeafSort(colLeaves, args.ColSortOrders); + + private List ApplyLeafSort(List leaves, int[] sortOrders) + { + if (sortOrders == null || sortOrders.All(s => s == 0)) return leaves; + + IOrderedEnumerable ordered = null; + foreach (var sortOrder in sortOrders) + { + if (sortOrder == 0) continue; + bool desc = sortOrder < 0; + int col = Math.Abs(sortOrder) - 1; + var capturedCol = col; + + Func keySelector = lp => + capturedCol < lp.Path.Length ? lp.Path[capturedCol] : null; + + if (ordered == null) + ordered = desc + ? leaves.OrderByDescending(keySelector, _comparer) + : leaves.OrderBy(keySelector, _comparer); + else + ordered = desc + ? ordered.ThenByDescending(keySelector, _comparer) + : ordered.ThenBy(keySelector, _comparer); + } + + // Bryt oavgjort med efterföljande nyckeldelar + int maxDepth = leaves.Max(l => l.Path.Length); + int sortedDepth = sortOrders.Max(s => Math.Abs(s)); + for (int col = sortedDepth; col < maxDepth; col++) + { + var capturedCol = col; + Func keySelector = lp => + capturedCol < lp.Path.Length ? lp.Path[capturedCol] : null; + ordered = ordered.ThenBy(keySelector, _comparer); + } + + return ordered?.ToList() ?? leaves; + } + } +} diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/PivotBy.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/PivotBy.cs new file mode 100644 index 000000000..8ca817a3e --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/PivotBy.cs @@ -0,0 +1,142 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 13/4/2026 EPPlus Software AB EPPlus v8.6 + *************************************************************************************************/ + +using OfficeOpenXml.FormulaParsing.Excel.Functions.DateAndTime; +using OfficeOpenXml.FormulaParsing.Excel.Functions.Metadata; +using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions; +using OfficeOpenXml.FormulaParsing.FormulaExpressions; +using OfficeOpenXml.FormulaParsing.Ranges; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.PivotBy +{ + [FunctionMetadata( + Category = ExcelFunctionCategory.LookupAndReference, + EPPlusVersion = "8.6", + Description = "Allows you to create a summary of your data via a formula. It supports grouping along two axis and aggregating the associated values.")] + internal partial class PivotBy : GroupByFunctionBase + { + public override int ArgumentMinLength => 3; // kan + // ske ska vara 4 + public override string NamespacePrefix => "_xlfn."; + public override bool ExecutesLambda => true; + + public override CompileResult Execute(IList arguments, ParsingContext context) + { + if (!TryParsePivotByArgs(arguments, out var args, out var error)) + return error; + BuildPivotData(args, context, out var rowLeaves, out var colLeaves, out var pivotMap); + rowLeaves = ApplyRowSort(rowLeaves, args); + colLeaves = ApplyColSort(colLeaves, args); + var result = RenderPivot(rowLeaves, colLeaves, pivotMap, args, context); + + return CreateDynamicArrayResult(result, DataType.ExcelRange); + } + + protected bool TryParsePivotByArgs(IList arguments, + out PivotByArgs args, + out CompileResult error) + { + args = new PivotByArgs(); + error = null; + + args.RowFields = arguments[0].ValueAsRangeInfo; + args.ColFields = arguments[1].ValueAsRangeInfo; + args.Values = arguments[2].ValueAsRangeInfo; + + if (args.RowFields.Size.NumberOfRows != args.Values.Size.NumberOfRows) + return Fail(eErrorType.Value, out error); + + if (!TryParseFunctionArg(arguments[3], args.Functions, out LambdaCalculator function, out FunctionLayout layout)) + return Fail(eErrorType.Value, out error); + + args.Function = function; + args.FunctionLayout = layout; + + if (arguments.Count > 4 && arguments[4].Value != null) + { + var v = Convert.ToInt32(arguments[4].Value); + if (!Enum.IsDefined(typeof(FieldHeaders), v)) + return Fail(eErrorType.Value, out error); + args.Headers = (FieldHeaders)v; + } + + // Total depth for rows (optional) + if (arguments.Count > 5 && arguments[5].Value != null) + { + if (!TryParseTotalDepthArg(arguments[5], args.RowFields.Size.NumberOfCols, out int rowTotalDepth)) + return Fail(eErrorType.Value, out error); + args.RowTotalDepth = rowTotalDepth; + } + else + { + args.RowTotalDepth = TotalDepthGrandOnly; // default = 1 + } + + // SortOrder for RowFields (optional) + if (arguments.Count > 6 && arguments[6].Value != null) + args.RowSortOrders = ParseSortOrderArg(arguments[6]); + + // TotalDepth for columns (optional, default = 1) + if (arguments.Count > 7 && arguments[7].Value != null) + { + if (!TryParseTotalDepthArg(arguments[7], args.ColFields.Size.NumberOfCols, out int colTotalDepth)) + return Fail(eErrorType.Value, out error); + args.ColTotalDepth = colTotalDepth; + } + else + { + args.ColTotalDepth = TotalDepthGrandOnly; // default = 1 + } + + // SortOrder for ColFields (optional) + if (arguments.Count > 8 && arguments[8].Value != null) + args.ColSortOrders = ParseSortOrderArg(arguments[8]); + + if (arguments.Count > 9 && arguments[9].IsExcelRange) + args.FilterArray = arguments[9].ValueAsRangeInfo; + + // RelativeTo (optional) + if (arguments.Count > 10 && arguments[10].Value != null) + { + var v = Convert.ToInt32(arguments[10].Value); + if (!Enum.IsDefined(typeof(RelativeTo), v)) + return Fail(eErrorType.Value, out error); + args.RelativeTo = (RelativeTo)v; + } + + return true; + } + + //private GroupLevel FindParentInTree(GroupLevel target, List levels) + //{ + // foreach (var level in levels) + // { + // if (!level.IsLeaf && level.Children.Contains(target)) + // return level; + + // if (!level.IsLeaf) + // { + // //var found = FindParentInTree(target, level.Children); + // if (found != null) return found; + // } + // } + // return null; // toppnivå, ingen förälder + //} + + } +} \ No newline at end of file diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/Rendering.cs similarity index 53% rename from src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs rename to src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/Rendering.cs index 6dcb6e446..e6e9ccb22 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/Rendering.cs @@ -1,355 +1,22 @@ -/************************************************************************************************* - Required Notice: Copyright (C) EPPlus Software AB. - This software is licensed under PolyForm Noncommercial License 1.0.0 - and may only be used for noncommercial purposes - https://polyformproject.org/licenses/noncommercial/1.0.0/ - - A commercial license to use this software can be purchased at https://epplussoftware.com - ************************************************************************************************* - Date Author Change - ************************************************************************************************* - 13/4/2026 EPPlus Software AB EPPlus v8.6 - *************************************************************************************************/ - -using OfficeOpenXml.FormulaParsing.Excel.Functions.DateAndTime; -using OfficeOpenXml.FormulaParsing.Excel.Functions.Metadata; -using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions; +using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions; using OfficeOpenXml.FormulaParsing.FormulaExpressions; using OfficeOpenXml.FormulaParsing.Ranges; using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Text; -namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.PivotBy { - [FunctionMetadata( - Category = ExcelFunctionCategory.LookupAndReference, - EPPlusVersion = "8.6", - Description = "Allows you to create a summary of your data via a formula. It supports grouping along two axis and aggregating the associated values.")] - internal partial class PivotBy : GroupByFunctionBase + internal partial class PivotBy { - public override int ArgumentMinLength => 3; // kan - // ske ska vara 4 - public override string NamespacePrefix => "_xlfn."; - public override bool ExecutesLambda => true; - - public override CompileResult Execute(IList arguments, ParsingContext context) - { - if (!TryParsePivotByArgs(arguments, out var args, out var error)) - return error; - BuildPivotData(args, context, out var rowLeaves, out var colLeaves, out var pivotMap); - rowLeaves = ApplyRowSort(rowLeaves, args); - colLeaves = ApplyColSort(colLeaves, args); - var result = RenderPivot(rowLeaves, colLeaves, pivotMap, args, context); - - return CreateDynamicArrayResult(result, DataType.ExcelRange); - } - - // 6. Räkna t dimensioner: nRows, nCols - // 7. Skriv kolumnhuvuden (ett pass) - // 8. Skriv datarader rad för rad, slå upp pivotMap[(rowKey, colKey)] per kolumn - // 9. Skriv grand total-rad/-kolumn - - protected bool TryParsePivotByArgs(IList arguments, - out PivotByArgs args, - out CompileResult error) - { - args = new PivotByArgs(); - error = null; - - args.RowFields = arguments[0].ValueAsRangeInfo; - args.ColFields = arguments[1].ValueAsRangeInfo; - args.Values = arguments[2].ValueAsRangeInfo; - - if (args.RowFields.Size.NumberOfRows != args.Values.Size.NumberOfRows) - return Fail(eErrorType.Value, out error); - - if (!TryParseFunctionArg(arguments[3], args.Functions, out LambdaCalculator function, out FunctionLayout layout)) - return Fail(eErrorType.Value, out error); - - args.Function = function; - args.FunctionLayout = layout; - - if (arguments.Count > 4 && arguments[4].Value != null) - { - var v = Convert.ToInt32(arguments[4].Value); - if (!Enum.IsDefined(typeof(FieldHeaders), v)) - return Fail(eErrorType.Value, out error); - args.Headers = (FieldHeaders)v; - } - - // Total depth for rows (optional) - if (arguments.Count > 5 && arguments[5].Value != null) - { - if (!TryParseTotalDepthArg(arguments[5], args.RowFields.Size.NumberOfCols, out int rowTotalDepth)) - return Fail(eErrorType.Value, out error); - args.RowTotalDepth = rowTotalDepth; - } - else - { - args.RowTotalDepth = TotalDepthGrandOnly; // default = 1 - } - - // SortOrder for RowFields (optional) - if (arguments.Count > 6 && arguments[6].Value != null) - args.RowSortOrders = ParseSortOrderArg(arguments[6]); - - // TotalDepth for columns (optional, default = 1) - if (arguments.Count > 7 && arguments[7].Value != null) - { - if (!TryParseTotalDepthArg(arguments[7], args.ColFields.Size.NumberOfCols, out int colTotalDepth)) - return Fail(eErrorType.Value, out error); - args.ColTotalDepth = colTotalDepth; - } - else - { - args.ColTotalDepth = TotalDepthGrandOnly; // default = 1 - } - - // SortOrder for ColFields (optional) - if (arguments.Count > 8 && arguments[8].Value != null) - args.ColSortOrders = ParseSortOrderArg(arguments[8]); - - if (arguments.Count > 9 && arguments[9].IsExcelRange) - args.FilterArray = arguments[9].ValueAsRangeInfo; - - // RelativeTo (optional) - if (arguments.Count > 10 && arguments[10].Value != null) - { - var v = Convert.ToInt32(arguments[10].Value); - if (!Enum.IsDefined(typeof(RelativeTo), v)) - return Fail(eErrorType.Value, out error); - args.RelativeTo = (RelativeTo)v; - } - - return true; - } - - - private void BuildPivotData( - PivotByArgs args, - ParsingContext context, - out List rowLeaves, - out List colLeaves, - out Dictionary>> pivotMap) - { - var resolvedHeaders = ResolveHeaders(args.Headers, args.Values); - bool hasHeaders = resolvedHeaders == FieldHeaders.YesAndShow - || resolvedHeaders == FieldHeaders.YesAndDontShow; - int startRow = hasHeaders ? 1 : 0; - - int nRowKeyCols = args.RowFields.Size.NumberOfCols; - int nColKeyCols = args.ColFields.Size.NumberOfCols; - int nValCols = args.Values.Size.NumberOfCols; - - var rowLeafDict = new Dictionary(StringComparer.OrdinalIgnoreCase); - var rowLeafOrder = new List(); - var colLeafDict = new Dictionary(StringComparer.OrdinalIgnoreCase); - var colLeafOrder = new List(); - pivotMap = new Dictionary>>(StringComparer.OrdinalIgnoreCase); - - int nRows = args.RowFields.Size.NumberOfRows; - - for (int r = startRow; r < nRows; r++) - { - if (args.FilterArray != null) - { - var fv = args.FilterArray.GetOffset(r, 0); - if (fv is bool b && !b) continue; - if (fv is int i && i == 0) continue; - } - - var rowKeyParts = new object[nRowKeyCols]; - for (int c = 0; c < nRowKeyCols; c++) - rowKeyParts[c] = args.RowFields.GetOffset(r, c); - - var colKeyParts = new object[nColKeyCols]; - for (int c = 0; c < nColKeyCols; c++) - colKeyParts[c] = args.ColFields.GetOffset(r, c); - - var vals = new object[nValCols]; - for (int c = 0; c < nValCols; c++) - vals[c] = args.Values.GetOffset(r, c); - - // Radlöv - string rowKey = MakePivotKey(rowKeyParts); - if (!rowLeafDict.ContainsKey(rowKey)) - { - var leaf = new GroupLevel { Key = rowKeyParts[rowKeyParts.Length - 1] }; - rowLeafDict[rowKey] = new LeafWithPath(leaf, rowKeyParts); - rowLeafOrder.Add(rowKey); - } - var rowLeafEntry = rowLeafDict[rowKey]; - var existingRow = rowLeafEntry.Leaf.Rows.FirstOrDefault(rw => MakePivotKey(rw.KeyParts) == rowKey); - if (existingRow == null) - { - existingRow = new GroupRow { KeyParts = rowKeyParts }; - rowLeafEntry.Leaf.Rows.Add(existingRow); - } - existingRow.Values.Add(vals); - - // Kolumnlöv - string colKey = MakePivotKey(colKeyParts); - if (!colLeafDict.ContainsKey(colKey)) - { - var leaf = new GroupLevel { Key = colKeyParts[colKeyParts.Length - 1] }; - colLeafDict[colKey] = new LeafWithPath(leaf, colKeyParts); - colLeafOrder.Add(colKey); - } - - // Pivotkartan - if (!pivotMap.TryGetValue(rowKey, out var colMap)) - { - colMap = new Dictionary>(StringComparer.OrdinalIgnoreCase); - pivotMap[rowKey] = colMap; - } - if (!colMap.TryGetValue(colKey, out var cellVals)) - { - cellVals = new List(); - colMap[colKey] = cellVals; - } - cellVals.Add(vals); - args.AllValuesInOrder.Add(vals); - } - - rowLeaves = rowLeafOrder.Select(k => rowLeafDict[k]).ToList(); - colLeaves = colLeafOrder.Select(k => colLeafDict[k]).ToList(); - - // Aggregera SubtotalValues per radlöv (används för rad-totals) - foreach (var rl in rowLeaves) - AggregateLeaf(rl.Leaf, args, context); - } - - private void AggregateLeaf(GroupLevel leaf, PivotByArgs args, ParsingContext context) - { - var allVals = leaf.Rows.SelectMany(r => r.Values).ToList(); - leaf.SubtotalValues = args.Functions.Select(f => - { - int nValCols = allVals[0].Length; - var result = new object[nValCols]; - for (int col = 0; col < nValCols; col++) - { - var colValues = allVals.Select(v => new object[] { v[col] }).ToList(); - result[col] = Aggregate(f, colValues, context, - f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null); - } - return result; - }).ToList(); - leaf.SubtotalValue = leaf.SubtotalValues[0][0]; - } - - - private List ApplyRowSort(List rowLeaves, PivotByArgs args) => - ApplyLeafSort(rowLeaves, args.RowSortOrders); - - private List ApplyColSort(List colLeaves, PivotByArgs args) => - ApplyLeafSort(colLeaves, args.ColSortOrders); - - private List ApplyLeafSort(List leaves, int[] sortOrders) - { - if (sortOrders == null || sortOrders.All(s => s == 0)) return leaves; - - IOrderedEnumerable ordered = null; - foreach (var sortOrder in sortOrders) - { - if (sortOrder == 0) continue; - bool desc = sortOrder < 0; - int col = Math.Abs(sortOrder) - 1; - var capturedCol = col; - - Func keySelector = lp => - capturedCol < lp.Path.Length ? lp.Path[capturedCol] : null; - - if (ordered == null) - ordered = desc - ? leaves.OrderByDescending(keySelector, _comparer) - : leaves.OrderBy(keySelector, _comparer); - else - ordered = desc - ? ordered.ThenByDescending(keySelector, _comparer) - : ordered.ThenBy(keySelector, _comparer); - } - - // Bryt oavgjort med efterföljande nyckeldelar - int maxDepth = leaves.Max(l => l.Path.Length); - int sortedDepth = sortOrders.Max(s => Math.Abs(s)); - for (int col = sortedDepth; col < maxDepth; col++) - { - var capturedCol = col; - Func keySelector = lp => - capturedCol < lp.Path.Length ? lp.Path[capturedCol] : null; - ordered = ordered.ThenBy(keySelector, _comparer); - } - - return ordered?.ToList() ?? leaves; - } - - /// - /// Infogar en rad med sammansatt nyckel i ett träd. - /// Extraherad hjälpmetod – identisk logik som i BuildGroups, delad av GROUPBY och PIVOTBY. - /// - protected GroupLevel InsertIntoTree( - Dictionary rootDict, - List rootOrder, - object[] keyParts) - { - var currentDict = rootDict; - var currentOrder = rootOrder; - GroupLevel currentLevel = null; - - for (int depth = 0; depth < keyParts.Length; depth++) - { - var keyStr = (keyParts[depth]?.ToString() ?? string.Empty).ToLowerInvariant(); - if (!currentDict.TryGetValue(keyStr, out currentLevel)) - { - currentLevel = new GroupLevel - { - Key = keyParts[depth], - KeyParts = keyParts.Take(depth + 1).ToArray() - }; - currentDict[keyStr] = currentLevel; - currentOrder.Add(keyStr); - } - - if (depth < keyParts.Length - 1) - { - if (currentLevel.ChildDict == null) - { - currentLevel.ChildDict = new Dictionary(StringComparer.OrdinalIgnoreCase); - currentLevel.ChildOrder = new List(); - } - currentDict = currentLevel.ChildDict; - currentOrder = currentLevel.ChildOrder; - } - } - return currentLevel; - } - - private GroupLevel FindParentInTree(GroupLevel target, List levels) - { - foreach (var level in levels) - { - if (!level.IsLeaf && level.Children.Contains(target)) - return level; - - if (!level.IsLeaf) - { - var found = FindParentInTree(target, level.Children); - if (found != null) return found; - } - } - return null; // toppnivå, ingen förälder - } - private List ResolveRelativeToValues( - RelativeTo relativeTo, - LeafWithPath colLeaf, - List colLeaves, - string rowKey, - Dictionary>> pivotMap, - PivotByArgs args) +RelativeTo relativeTo, +LeafWithPath colLeaf, +List colLeaves, +string rowKey, +Dictionary>> pivotMap, +PivotByArgs args) { switch (relativeTo) { @@ -467,11 +134,11 @@ private static string MakePivotKey(object[] parts) => private InMemoryRange RenderPivot( - List rowLeaves, - List colLeaves, - Dictionary>> pivotMap, - PivotByArgs args, - ParsingContext context) + List rowLeaves, + List colLeaves, + Dictionary>> pivotMap, + PivotByArgs args, + ParsingContext context) { int nRowKeyCols = args.RowFields.Size.NumberOfCols; int nColKeyRows = args.ColFields.Size.NumberOfCols; @@ -479,7 +146,6 @@ private InMemoryRange RenderPivot( int nFunctions = args.Functions.Count; bool isVStack = args.FunctionLayout == FunctionLayout.Vertical; bool isHStack = args.FunctionLayout == FunctionLayout.Horizontal; - bool multipleFunctions = isVStack || isHStack; bool showRowTotal = args.RowTotalDepth != TotalDepthNoTotals; bool showColTotal = args.ColTotalDepth != TotalDepthNoTotals; @@ -487,6 +153,8 @@ private InMemoryRange RenderPivot( bool colTotalAtLeft = args.ColTotalDepth < 0; int colSubtotalDepth = Math.Abs(args.ColTotalDepth); bool showColSubtotals = colSubtotalDepth > 1; + int rowSubtotalDepth = Math.Abs(args.RowTotalDepth); + bool showRowSubtotals = rowSubtotalDepth > 1; var resolvedHeaders = ResolveHeaders(args.Headers, args.Values); bool showFieldHeaders = resolvedHeaders == FieldHeaders.YesAndShow; @@ -509,6 +177,12 @@ private InMemoryRange RenderPivot( if (showColSubtotals) colEntries.Add(new ColEntry { IsSubtotal = true, GroupKey = group.Key, GroupLeaves = groupLeaves }); } + var rowGroups = rowLeaves + .GroupBy(l => string.Join("\u001F", + l.Path.Take(rowSubtotalDepth - 1) + .Select(p => p?.ToString()?.ToLowerInvariant() ?? string.Empty) + .ToArray())) + .ToList(); int colsPerEntry = isHStack ? nFunctions : 1; int nDataCols = colEntries.Count * colsPerEntry; @@ -517,14 +191,17 @@ private InMemoryRange RenderPivot( int rowsPerLeaf = isVStack ? nFunctions : 1; int rowsPerTotal = isVStack ? nFunctions : 1; + int subtotalRowCount = showRowSubtotals ? rowGroups.Count * rowsPerLeaf : 0; + int totalRows = fieldHeaderRows + nColKeyRows + functionHeaderRows + headerDataRows + nRowLeaves * rowsPerLeaf + + subtotalRowCount + (showRowTotal ? rowsPerTotal : 0); int dataRowStart = fieldHeaderRows + nColKeyRows + functionHeaderRows + headerDataRows + (rowTotalAtTop ? rowsPerTotal : 0); int grandTotalRow = fieldHeaderRows + nColKeyRows + functionHeaderRows + headerDataRows - + (rowTotalAtTop ? 0 : nRowLeaves * rowsPerLeaf); + + (rowTotalAtTop ? 0 : nRowLeaves * rowsPerLeaf + subtotalRowCount); var result = new InMemoryRange(totalRows, (short)totalCols); @@ -535,6 +212,10 @@ private InMemoryRange RenderPivot( // --- Fältnamnrad --- if (showFieldHeaders) { + // Fyll radnyckelkolumnerna med string.Empty + for (int i = 0; i < nRowKeyCols; i++) + result.SetValue(0, i, string.Empty); + for (int i = 0; i < args.ColFields.Size.NumberOfCols; i++) result.SetValue(0, dataColStart + colOffset + i, args.ColFields.GetOffset(0, i)); } @@ -543,12 +224,17 @@ private InMemoryRange RenderPivot( for (int level = 0; level < nColKeyRows; level++) { int outputLevel = fieldHeaderRows + level; + + // Fyll radnyckelkolumnerna med string.Empty + for (int i = 0; i < nRowKeyCols; i++) + result.SetValue(outputLevel, i, string.Empty); + int col = dataColStart + colOffset; foreach (var entry in colEntries) { var val = entry.IsSubtotal ? (level == 0 ? entry.GroupLeaves[0].Path[0] : (object)string.Empty) - : (level < entry.Leaf.Path.Length ? entry.Leaf.Path[level] : null); + : (level < entry.Leaf.Path.Length ? entry.Leaf.Path[level] : (object)string.Empty); for (int f = 0; f < colsPerEntry; f++) { @@ -573,6 +259,11 @@ private InMemoryRange RenderPivot( { var functionNames = ResolveFunctionHeaders(args.Functions); int functionHeaderRow = fieldHeaderRows + nColKeyRows; + + // Fyll radnyckelkolumnerna med string.Empty + for (int i = 0; i < nRowKeyCols; i++) + result.SetValue(functionHeaderRow, i, string.Empty); + int col = dataColStart + colOffset; foreach (var entry in colEntries) { @@ -606,8 +297,7 @@ private InMemoryRange RenderPivot( { for (int fc = 0; fc < colsPerEntry; fc++) { - if (!entry.IsSubtotal) - result.SetValue(headerDataRow, col, headerValue); + result.SetValue(headerDataRow, col, entry.IsSubtotal ? string.Empty : headerValue); col++; } } @@ -630,117 +320,141 @@ private InMemoryRange RenderPivot( grandTotalCol, showColTotal, colsPerEntry, isVStack); // --- Datarader --- - for (int ri = 0; ri < nRowLeaves; ri++) + var functionNames2 = ResolveFunctionHeaders(args.Functions); + int currentOutputRow = dataRowStart; + + foreach (var rowGroup in rowGroups) { - var rowPath = rowLeaves[ri].Path; - var rowLeaf = rowLeaves[ri].Leaf; - string rowKey = MakePivotKey(rowPath); - var functionNames = ResolveFunctionHeaders(args.Functions); + var groupLeaves = rowGroup.ToList(); - for (int fi = 0; fi < nFunctions; fi++) + foreach (var rowLeafEntry in groupLeaves) { - int outputRow = dataRowStart + ri * rowsPerLeaf + fi; - var f = args.Functions[fi]; + var rowPath = rowLeafEntry.Path; + var rowLeaf = rowLeafEntry.Leaf; + string rowKey = MakePivotKey(rowPath); - for (int k = 0; k < rowPath.Length; k++) - result.SetValue(outputRow, k, rowPath[k]); + for (int fi = 0; fi < nFunctions; fi++) + { + int outputRow = currentOutputRow + fi; + var f = args.Functions[fi]; - if (isVStack) - result.SetValue(outputRow, functionNameCol, functionNames[fi]); + for (int k = 0; k < rowPath.Length; k++) + result.SetValue(outputRow, k, rowPath[k]); - int col = dataColStart + colOffset; - foreach (var entry in colEntries) - { - if (entry.IsSubtotal) - { - var groupVals = entry.GroupLeaves - .SelectMany(l => - { - var ck = MakePivotKey(l.Path); - if (pivotMap.TryGetValue(rowKey, out var cm) && cm.TryGetValue(ck, out var cv)) - return cv; - return Enumerable.Empty(); - }).ToList(); + if (isVStack) + result.SetValue(outputRow, functionNameCol, functionNames2[fi]); - if (isHStack) + int col = dataColStart + colOffset; + foreach (var entry in colEntries) + { + if (entry.IsSubtotal) { - foreach (var func in args.Functions) + var groupVals = entry.GroupLeaves + .SelectMany(l => + { + var ck = MakePivotKey(l.Path); + if (pivotMap.TryGetValue(rowKey, out var cm) && cm.TryGetValue(ck, out var cv)) + return cv; + return Enumerable.Empty(); + }).ToList(); + + if (isHStack) + { + foreach (var func in args.Functions) + { + object val = groupVals.Count > 0 + ? Aggregate(func, groupVals, context, + func.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null) + : null; + //if (val != null) + result.SetValue(outputRow, col, val ?? string.Empty); + col++; + } + } + else { object val = groupVals.Count > 0 - ? Aggregate(func, groupVals, context, - func.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null) + ? Aggregate(f, groupVals, context, + f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null) : null; - if (val != null) - result.SetValue(outputRow, col, val); + //if (val != null) + result.SetValue(outputRow, col, val ?? string.Empty); col++; } } else { - object val = groupVals.Count > 0 - ? Aggregate(f, groupVals, context, - f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null) - : null; - if (val != null) - result.SetValue(outputRow, col, val); - col++; - } - } - else - { - string colKey = MakePivotKey(entry.Leaf.Path); + string colKey = MakePivotKey(entry.Leaf.Path); - if (isHStack) - { - foreach (var func in args.Functions) + if (isHStack) + { + foreach (var func in args.Functions) + { + object aggregated = null; + if (pivotMap.TryGetValue(rowKey, out var colMap) && + colMap.TryGetValue(colKey, out var cellVals)) + { + var effectiveRelativeTo = func.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo == RelativeTo.ColumnTotals + ? RelativeTo.ParentRowTotal + : args.RelativeTo; + var relativeToVals = func.EtaFunction?.Name == "PERCENTOF" + ? ResolveRelativeToValues(effectiveRelativeTo, entry.Leaf, colLeaves, rowKey, pivotMap, args) ?? args.AllValuesInOrder + : args.AllValuesInOrder; + aggregated = Aggregate(func, cellVals, context, relativeToVals); + } + //if (aggregated != null) + result.SetValue(outputRow, col, aggregated ?? string.Empty); + col++; + } + } + else { object aggregated = null; if (pivotMap.TryGetValue(rowKey, out var colMap) && colMap.TryGetValue(colKey, out var cellVals)) { - var effectiveRelativeTo = func.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo == RelativeTo.ColumnTotals + var effectiveRelativeTo = f.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo == RelativeTo.ColumnTotals ? RelativeTo.ParentRowTotal : args.RelativeTo; - var relativeToVals = func.EtaFunction?.Name == "PERCENTOF" + var relativeToVals = f.EtaFunction?.Name == "PERCENTOF" ? ResolveRelativeToValues(effectiveRelativeTo, entry.Leaf, colLeaves, rowKey, pivotMap, args) ?? args.AllValuesInOrder : args.AllValuesInOrder; - aggregated = Aggregate(func, cellVals, context, relativeToVals); + aggregated = Aggregate(f, cellVals, context, relativeToVals); } - if (aggregated != null) - result.SetValue(outputRow, col, aggregated); + //if (aggregated != null) + result.SetValue(outputRow, col, aggregated ?? string.Empty); col++; } } - else + } + + if (showColTotal) + { + var rowAllVals = rowLeaf.Rows.SelectMany(r => r.Values).ToList(); + if (isHStack) { - object aggregated = null; - if (pivotMap.TryGetValue(rowKey, out var colMap) && - colMap.TryGetValue(colKey, out var cellVals)) + foreach (var func in args.Functions) { - var effectiveRelativeTo = f.EtaFunction?.Name == "PERCENTOF" && args.RelativeTo == RelativeTo.ColumnTotals - ? RelativeTo.ParentRowTotal - : args.RelativeTo; - var relativeToVals = f.EtaFunction?.Name == "PERCENTOF" - ? ResolveRelativeToValues(effectiveRelativeTo, entry.Leaf, colLeaves, rowKey, pivotMap, args) ?? args.AllValuesInOrder - : args.AllValuesInOrder; - aggregated = Aggregate(f, cellVals, context, relativeToVals); + var relVals = args.AllValuesInOrder; + if (func.EtaFunction?.Name == "PERCENTOF") + { + var effectiveRelativeTo = args.RelativeTo == RelativeTo.ColumnTotals + ? RelativeTo.ParentRowTotal + : args.RelativeTo; + relVals = effectiveRelativeTo == RelativeTo.GrandTotals || effectiveRelativeTo == RelativeTo.ParentRowTotal + ? args.AllValuesInOrder + : rowAllVals; + } + var totalVal = Aggregate(func, rowAllVals, context, relVals); + if (totalVal != null) + result.SetValue(outputRow, col, totalVal ?? string.Empty); + col++; } - if (aggregated != null) - result.SetValue(outputRow, col, aggregated); - col++; } - } - } - - if (showColTotal) - { - var rowAllVals = rowLeaf.Rows.SelectMany(r => r.Values).ToList(); - if (isHStack) - { - foreach (var func in args.Functions) + else { var relVals = args.AllValuesInOrder; - if (func.EtaFunction?.Name == "PERCENTOF") + if (f.EtaFunction?.Name == "PERCENTOF") { var effectiveRelativeTo = args.RelativeTo == RelativeTo.ColumnTotals ? RelativeTo.ParentRowTotal @@ -749,32 +463,36 @@ private InMemoryRange RenderPivot( ? args.AllValuesInOrder : rowAllVals; } - var totalVal = Aggregate(func, rowAllVals, context, relVals); - if (totalVal != null) - result.SetValue(outputRow, col, totalVal); + var totalVal = Aggregate(f, rowAllVals, context, relVals); + //if (totalVal != null) + result.SetValue(outputRow, col, totalVal ?? string.Empty); col++; } } - else - { - var relVals = args.AllValuesInOrder; - if (f.EtaFunction?.Name == "PERCENTOF") - { - var effectiveRelativeTo = args.RelativeTo == RelativeTo.ColumnTotals - ? RelativeTo.ParentRowTotal - : args.RelativeTo; - relVals = effectiveRelativeTo == RelativeTo.GrandTotals || effectiveRelativeTo == RelativeTo.ParentRowTotal - ? args.AllValuesInOrder - : rowAllVals; - } - var totalVal = Aggregate(f, rowAllVals, context, relVals); - if (totalVal != null) - result.SetValue(outputRow, col, totalVal); - col++; - } + + if (isHStack) break; } - if (isHStack) break; + currentOutputRow += rowsPerLeaf; + } + + // --- Radsubtotalrad för gruppen --- + if (showRowSubtotals) + { + var groupKeyParts = groupLeaves[0].Path.Take(rowSubtotalDepth - 1).ToArray(); + for (int fi = 0; fi < rowsPerLeaf; fi++) + { + WriteRowSubtotalRow( + result, currentOutputRow + fi, + groupKeyParts, groupLeaves, + colEntries, colLeaves, + pivotMap, args, context, + nRowKeyCols, functionNameCol, + dataColStart, colOffset, + showColTotal, isVStack, + args.Functions[fi], functionNames2[fi]); + } + currentOutputRow += rowsPerLeaf; } } @@ -787,6 +505,134 @@ private InMemoryRange RenderPivot( return result; } + private void WriteRowSubtotalRow( + InMemoryRange result, + int outputRow, + object[] groupKeyParts, + List groupLeaves, + List colEntries, + List colLeaves, + Dictionary>> pivotMap, + PivotByArgs args, + ParsingContext context, + int nRowKeyCols, + int functionNameCol, + int dataColStart, + int colOffset, + bool showColTotal, + bool isVStack, + LambdaCalculator f, + string functionName) + { + // Radnycklar: känd prefix + tomt för resten + for (int k = 0; k < nRowKeyCols; k++) + result.SetValue(outputRow, k, k < groupKeyParts.Length ? groupKeyParts[k] : string.Empty); + + if (isVStack) + result.SetValue(outputRow, functionNameCol, functionName); + + var groupRowKeys = groupLeaves.Select(l => MakePivotKey(l.Path)).ToList(); + + int col = dataColStart + colOffset; + foreach (var entry in colEntries) + { + List cellVals; + if (entry.IsSubtotal) + { + cellVals = groupRowKeys + .SelectMany(rk => + { + if (!pivotMap.TryGetValue(rk, out var cm)) return Enumerable.Empty(); + return entry.GroupLeaves + .SelectMany(gl => cm.TryGetValue(MakePivotKey(gl.Path), out var cv) + ? cv : Enumerable.Empty()); + }).ToList(); + } + else + { + string colKey = MakePivotKey(entry.Leaf.Path); + cellVals = groupRowKeys + .SelectMany(rk => + { + if (!pivotMap.TryGetValue(rk, out var cm)) return Enumerable.Empty(); + return cm.TryGetValue(colKey, out var cv) ? cv : Enumerable.Empty(); + }).ToList(); + } + + if (cellVals.Count > 0) + { + List relativeToVals = null; + if (f.EtaFunction?.Name == "PERCENTOF") + { + var effectiveRelativeTo = args.RelativeTo == RelativeTo.ColumnTotals + ? RelativeTo.ParentRowTotal + : args.RelativeTo; + + // För subtotalraden: nämnare baseras på hela kolumnen (ParentRowTotal) + // eller grand total, inte bara gruppen + relativeToVals = effectiveRelativeTo switch + { + RelativeTo.RowTotals => + groupRowKeys + .SelectMany(rk => pivotMap.TryGetValue(rk, out var cm) + ? cm.Values.SelectMany(v => v) + : Enumerable.Empty()) + .ToList(), + RelativeTo.GrandTotals => args.AllValuesInOrder, + RelativeTo.ParentColTotal => + groupRowKeys + .SelectMany(rk => pivotMap.TryGetValue(rk, out var cm) + ? cm.Values.SelectMany(v => v) + : Enumerable.Empty()) + .ToList(), + RelativeTo.ParentRowTotal => + entry.IsSubtotal + ? entry.GroupLeaves + .SelectMany(gl => + pivotMap.Values.SelectMany(cm => + cm.TryGetValue(MakePivotKey(gl.Path), out var cv) + ? cv : Enumerable.Empty())) + .ToList() + : pivotMap.Values + .SelectMany(cm => cm.TryGetValue(MakePivotKey(entry.Leaf.Path), out var cv2) + ? cv2 : Enumerable.Empty()) + .ToList(), + _ => args.AllValuesInOrder + }; + } + + var val = Aggregate(f, cellVals, context, relativeToVals); + //if (val != null) + result.SetValue(outputRow, col, val ?? string.Empty); + } + col++; + } + + if (showColTotal) + { + var allGroupVals = groupRowKeys + .SelectMany(rk => pivotMap.TryGetValue(rk, out var cm) + ? cm.Values.SelectMany(v => v) + : Enumerable.Empty()) + .ToList(); + + List relVals = null; + if (f.EtaFunction?.Name == "PERCENTOF") + { + var effectiveRelativeTo = args.RelativeTo == RelativeTo.ColumnTotals + ? RelativeTo.ParentRowTotal + : args.RelativeTo; + relVals = effectiveRelativeTo == RelativeTo.GrandTotals || effectiveRelativeTo == RelativeTo.ParentRowTotal + ? args.AllValuesInOrder + : allGroupVals; + } + + var totalVal = allGroupVals.Count > 0 ? Aggregate(f, allGroupVals, context, relVals) : null; + //if (totalVal != null) + result.SetValue(outputRow, col, totalVal ?? string.Empty); + } + } + private void WriteGrandTotalRow( InMemoryRange result, int startRow, @@ -807,13 +653,14 @@ private void WriteGrandTotalRow( var functionNames = ResolveFunctionHeaders(args.Functions); int nFunctions = args.Functions.Count; int rowsPerTotal = isVStack ? nFunctions : 1; + string rowTotalLabel = Math.Abs(args.RowTotalDepth) > 1 ? "Grand Total" : "Total"; for (int fi = 0; fi < rowsPerTotal; fi++) { int r = startRow + fi; var f = args.Functions[fi]; - result.SetValue(r, 0, "Total"); + result.SetValue(r, 0, rowTotalLabel); for (int c = 1; c < nRowKeyCols; c++) result.SetValue(r, c, string.Empty); @@ -840,8 +687,8 @@ private void WriteGrandTotalRow( ? Aggregate(f, groupVals, context, f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null) : null; - if (val != null) - result.SetValue(r, col, val); + //if (val != null) + result.SetValue(r, col, val ?? string.Empty); col++; } else @@ -852,8 +699,8 @@ private void WriteGrandTotalRow( ? Aggregate(func, groupVals, context, func.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null) : null; - if (val != null) - result.SetValue(r, col, val); + //if (val != null) + result.SetValue(r, col, val ?? string.Empty); col++; } } @@ -879,8 +726,8 @@ private void WriteGrandTotalRow( : args.AllValuesInOrder; grandVal = Aggregate(f, allValsForCol, context, relativeToVals); } - if (grandVal != null) - result.SetValue(r, col, grandVal); + //if (grandVal != null) + result.SetValue(r, col, grandVal ?? string.Empty); col++; } else @@ -898,8 +745,8 @@ private void WriteGrandTotalRow( : args.AllValuesInOrder; grandVal = Aggregate(func, allValsForCol, context, relativeToVals); } - if (grandVal != null) - result.SetValue(r, col, grandVal); + //if (grandVal != null) + result.SetValue(r, col, grandVal ?? string.Empty); col++; } } @@ -919,8 +766,8 @@ private void WriteGrandTotalRow( ? allVals : args.AllValuesInOrder) : args.AllValuesInOrder; var cornerVal = Aggregate(f, allVals, context, cornerRelVals); - if (cornerVal != null) - result.SetValue(r, col, cornerVal); + //if (cornerVal != null) + result.SetValue(r, col, cornerVal ?? string.Empty); col++; } else @@ -935,8 +782,8 @@ private void WriteGrandTotalRow( ? allVals : args.AllValuesInOrder) : args.AllValuesInOrder; var cornerVal = Aggregate(func, allVals, context, cornerRelVals); - if (cornerVal != null) - result.SetValue(r, col, cornerVal); + //if (cornerVal != null) + result.SetValue(r, col, cornerVal ?? string.Empty); col++; } } @@ -959,9 +806,9 @@ private List CollectLeavesWithPath(List levels, object if (level.IsLeaf) result.Add(new LeafWithPath(level, path)); else - result.AddRange(CollectLeavesWithPath(level.Children, path)); + result.AddRange(CollectLeavesWithPath(level.Children, path)); } return result; - } + } } -} \ No newline at end of file +} diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs index cb209b2ee..a50e5d381 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs @@ -222,7 +222,68 @@ public void PivotBySubTotalsIncluded() Assert.AreEqual(4d, s.Cells["I5"].Value); Assert.AreEqual(4d, s.Cells["J5"].Value); Assert.AreEqual(7d, s.Cells["K5"].Value); - //Fixa detta test det är förskjutet en rad fel + } + } + [TestMethod] + public void PivotByGrandTotalsRows() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; + s.Cells["A2"].Value = "A"; + s.Cells["A3"].Value = "B"; + s.Cells["B1"].Value = "X"; + s.Cells["B2"].Value = "Y"; + s.Cells["B3"].Value = "X"; + s.Cells["C1"].Value = "O"; + s.Cells["C2"].Value = "I"; + s.Cells["C3"].Value = "I"; + s.Cells["D1"].Value = 2; + s.Cells["D2"].Value = 4; + s.Cells["D3"].Value = 1; + s.Cells["E1"].Formula = "PIVOTBY(A1:B3,C1:C3,D1:D3,_xleta.PERCENTOF,,2)"; + s.Calculate(); + + // Rubrikrad + Assert.AreEqual("I", s.Cells["G1"].Value); + Assert.AreEqual("O", s.Cells["H1"].Value); + Assert.AreEqual("Total", s.Cells["I1"].Value); + + // A | X + Assert.AreEqual("A", s.Cells["E2"].Value); + Assert.AreEqual("X", s.Cells["F2"].Value); + Assert.AreEqual(1d, s.Cells["H2"].Value); + Assert.AreEqual(0.28571429, System.Math.Round((double)s.Cells["I2"].Value, 8)); + + // A | Y + Assert.AreEqual("A", s.Cells["E3"].Value); + Assert.AreEqual("Y", s.Cells["F3"].Value); + Assert.AreEqual(0.8d, s.Cells["G3"].Value); + Assert.AreEqual(0.57142857, System.Math.Round((double)s.Cells["I3"].Value, 8)); + + // A subtotal + Assert.AreEqual("A", s.Cells["E4"].Value); + Assert.AreEqual(0.8d, s.Cells["G4"].Value); + Assert.AreEqual(1d, s.Cells["H4"].Value); + Assert.AreEqual(0.85714286, System.Math.Round((double)s.Cells["I4"].Value, 8)); + + // B | X + Assert.AreEqual("B", s.Cells["E5"].Value); + Assert.AreEqual("X", s.Cells["F5"].Value); + Assert.AreEqual(0.2d, s.Cells["G5"].Value); + Assert.AreEqual(0.14285714, System.Math.Round((double)s.Cells["I5"].Value, 8)); + + // B subtotal + Assert.AreEqual("B", s.Cells["E6"].Value); + Assert.AreEqual(0.2d, s.Cells["G6"].Value); + Assert.AreEqual(0.14285714, System.Math.Round((double)s.Cells["I6"].Value, 8)); + + // Grand Total + Assert.AreEqual("Grand Total", s.Cells["E7"].Value); + Assert.AreEqual(1d, s.Cells["G7"].Value); + Assert.AreEqual(1d, s.Cells["H7"].Value); + Assert.AreEqual(1d, s.Cells["I7"].Value); } } [TestMethod] @@ -243,8 +304,11 @@ public void PivotByRelativeTo() s.Cells["D1"].Value = 2; s.Cells["D2"].Value = 4; s.Cells["D3"].Value = 1; - s.Cells["E1"].Formula = "PIVOTBY(A1:A3,B1:C3,D1:D3,_xleta.PERCENTOF,,,,,,,3)"; + s.Cells["E1"].Formula = "PIVOTBY(A1:B3,C1:C3,D1:D3,_xleta.PERCENTOF,,,,,,,3)"; s.Calculate(); + + Assert.AreEqual(0.714285714d, System.Math.Round((double)s.Cells["G5"].Value, 9)); + Assert.AreEqual(0.285714286d, System.Math.Round((double)s.Cells["H5"].Value, 9)); } } From 6369cadbb3a1f8bc29ac20323490fb3e5eb5e23b Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Wed, 13 May 2026 12:57:40 +0200 Subject: [PATCH 15/16] WIP --- .../Functions/RefAndLookup/PivotBy/PivotBy.cs | 17 --- .../RefAndLookup/PivotBy/Rendering.cs | 120 ++++++++---------- .../Functions/RefAndLookup/PivotByTests.cs | 41 ++---- 3 files changed, 64 insertions(+), 114 deletions(-) diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/PivotBy.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/PivotBy.cs index 8ca817a3e..7e7a6f8cc 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/PivotBy.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/PivotBy.cs @@ -121,22 +121,5 @@ protected bool TryParsePivotByArgs(IList arguments, return true; } - - //private GroupLevel FindParentInTree(GroupLevel target, List levels) - //{ - // foreach (var level in levels) - // { - // if (!level.IsLeaf && level.Children.Contains(target)) - // return level; - - // if (!level.IsLeaf) - // { - // //var found = FindParentInTree(target, level.Children); - // if (found != null) return found; - // } - // } - // return null; // toppnivå, ingen förälder - //} - } } \ No newline at end of file diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/Rendering.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/Rendering.cs index e6e9ccb22..d19caa8a6 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/Rendering.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/Rendering.cs @@ -133,12 +133,13 @@ private static string MakePivotKey(object[] parts) => string.Join("\u001F", parts.Select(p => p?.ToString()?.ToLowerInvariant() ?? string.Empty).ToArray()); + private InMemoryRange RenderPivot( - List rowLeaves, - List colLeaves, - Dictionary>> pivotMap, - PivotByArgs args, - ParsingContext context) + List rowLeaves, + List colLeaves, + Dictionary>> pivotMap, + PivotByArgs args, + ParsingContext context) { int nRowKeyCols = args.RowFields.Size.NumberOfCols; int nColKeyRows = args.ColFields.Size.NumberOfCols; @@ -177,6 +178,7 @@ private InMemoryRange RenderPivot( if (showColSubtotals) colEntries.Add(new ColEntry { IsSubtotal = true, GroupKey = group.Key, GroupLeaves = groupLeaves }); } + var rowGroups = rowLeaves .GroupBy(l => string.Join("\u001F", l.Path.Take(rowSubtotalDepth - 1) @@ -212,10 +214,8 @@ private InMemoryRange RenderPivot( // --- Fältnamnrad --- if (showFieldHeaders) { - // Fyll radnyckelkolumnerna med string.Empty - for (int i = 0; i < nRowKeyCols; i++) + for (int i = 0; i < nRowKeyCols + functionColOffset; i++) result.SetValue(0, i, string.Empty); - for (int i = 0; i < args.ColFields.Size.NumberOfCols; i++) result.SetValue(0, dataColStart + colOffset + i, args.ColFields.GetOffset(0, i)); } @@ -225,8 +225,7 @@ private InMemoryRange RenderPivot( { int outputLevel = fieldHeaderRows + level; - // Fyll radnyckelkolumnerna med string.Empty - for (int i = 0; i < nRowKeyCols; i++) + for (int i = 0; i < nRowKeyCols + functionColOffset; i++) result.SetValue(outputLevel, i, string.Empty); int col = dataColStart + colOffset; @@ -260,7 +259,6 @@ private InMemoryRange RenderPivot( var functionNames = ResolveFunctionHeaders(args.Functions); int functionHeaderRow = fieldHeaderRows + nColKeyRows; - // Fyll radnyckelkolumnerna med string.Empty for (int i = 0; i < nRowKeyCols; i++) result.SetValue(functionHeaderRow, i, string.Empty); @@ -290,6 +288,8 @@ private InMemoryRange RenderPivot( for (int i = 0; i < nRowKeyCols; i++) result.SetValue(headerDataRow, i, args.RowFields.GetOffset(0, i)); + if (functionColOffset > 0) + result.SetValue(headerDataRow, nRowKeyCols, string.Empty); var headerValue = args.Values.GetOffset(0, 0); int col = dataColStart + colOffset; @@ -317,7 +317,7 @@ private InMemoryRange RenderPivot( WriteGrandTotalRow(result, fieldHeaderRows + nColKeyRows + functionHeaderRows + headerDataRows, colEntries, colLeaves, pivotMap, args, context, nRowKeyCols, functionNameCol, dataColStart, colOffset, - grandTotalCol, showColTotal, colsPerEntry, isVStack); + grandTotalCol, showColTotal, colsPerEntry, isVStack, functionColOffset); // --- Datarader --- var functionNames2 = ResolveFunctionHeaders(args.Functions); @@ -338,8 +338,9 @@ private InMemoryRange RenderPivot( int outputRow = currentOutputRow + fi; var f = args.Functions[fi]; - for (int k = 0; k < rowPath.Length; k++) - result.SetValue(outputRow, k, rowPath[k]); + // Fyll alla radnyckelkolumner – även de som saknas i rowPath + for (int k = 0; k < nRowKeyCols; k++) + result.SetValue(outputRow, k, k < rowPath.Length ? rowPath[k] : string.Empty); if (isVStack) result.SetValue(outputRow, functionNameCol, functionNames2[fi]); @@ -366,8 +367,7 @@ private InMemoryRange RenderPivot( ? Aggregate(func, groupVals, context, func.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null) : null; - //if (val != null) - result.SetValue(outputRow, col, val ?? string.Empty); + result.SetValue(outputRow, col, val ?? string.Empty); col++; } } @@ -377,8 +377,7 @@ private InMemoryRange RenderPivot( ? Aggregate(f, groupVals, context, f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null) : null; - //if (val != null) - result.SetValue(outputRow, col, val ?? string.Empty); + result.SetValue(outputRow, col, val ?? string.Empty); col++; } } @@ -402,8 +401,7 @@ private InMemoryRange RenderPivot( : args.AllValuesInOrder; aggregated = Aggregate(func, cellVals, context, relativeToVals); } - //if (aggregated != null) - result.SetValue(outputRow, col, aggregated ?? string.Empty); + result.SetValue(outputRow, col, aggregated ?? string.Empty); col++; } } @@ -421,8 +419,7 @@ private InMemoryRange RenderPivot( : args.AllValuesInOrder; aggregated = Aggregate(f, cellVals, context, relativeToVals); } - //if (aggregated != null) - result.SetValue(outputRow, col, aggregated ?? string.Empty); + result.SetValue(outputRow, col, aggregated ?? string.Empty); col++; } } @@ -446,8 +443,7 @@ private InMemoryRange RenderPivot( : rowAllVals; } var totalVal = Aggregate(func, rowAllVals, context, relVals); - if (totalVal != null) - result.SetValue(outputRow, col, totalVal ?? string.Empty); + result.SetValue(outputRow, col, totalVal ?? string.Empty); col++; } } @@ -464,8 +460,7 @@ private InMemoryRange RenderPivot( : rowAllVals; } var totalVal = Aggregate(f, rowAllVals, context, relVals); - //if (totalVal != null) - result.SetValue(outputRow, col, totalVal ?? string.Empty); + result.SetValue(outputRow, col, totalVal ?? string.Empty); col++; } } @@ -500,29 +495,30 @@ private InMemoryRange RenderPivot( if (!rowTotalAtTop && showRowTotal) WriteGrandTotalRow(result, grandTotalRow, colEntries, colLeaves, pivotMap, args, context, nRowKeyCols, functionNameCol, dataColStart, colOffset, - grandTotalCol, showColTotal, colsPerEntry, isVStack); + grandTotalCol, showColTotal, colsPerEntry, isVStack, functionColOffset); return result; } + private void WriteRowSubtotalRow( - InMemoryRange result, - int outputRow, - object[] groupKeyParts, - List groupLeaves, - List colEntries, - List colLeaves, - Dictionary>> pivotMap, - PivotByArgs args, - ParsingContext context, - int nRowKeyCols, - int functionNameCol, - int dataColStart, - int colOffset, - bool showColTotal, - bool isVStack, - LambdaCalculator f, - string functionName) + InMemoryRange result, + int outputRow, + object[] groupKeyParts, + List groupLeaves, + List colEntries, + List colLeaves, + Dictionary>> pivotMap, + PivotByArgs args, + ParsingContext context, + int nRowKeyCols, + int functionNameCol, + int dataColStart, + int colOffset, + bool showColTotal, + bool isVStack, + LambdaCalculator f, + string functionName) { // Radnycklar: känd prefix + tomt för resten for (int k = 0; k < nRowKeyCols; k++) @@ -568,8 +564,6 @@ private void WriteRowSubtotalRow( ? RelativeTo.ParentRowTotal : args.RelativeTo; - // För subtotalraden: nämnare baseras på hela kolumnen (ParentRowTotal) - // eller grand total, inte bara gruppen relativeToVals = effectiveRelativeTo switch { RelativeTo.RowTotals => @@ -602,8 +596,11 @@ private void WriteRowSubtotalRow( } var val = Aggregate(f, cellVals, context, relativeToVals); - //if (val != null) - result.SetValue(outputRow, col, val ?? string.Empty); + result.SetValue(outputRow, col, val ?? string.Empty); + } + else + { + result.SetValue(outputRow, col, string.Empty); } col++; } @@ -628,11 +625,11 @@ private void WriteRowSubtotalRow( } var totalVal = allGroupVals.Count > 0 ? Aggregate(f, allGroupVals, context, relVals) : null; - //if (totalVal != null) - result.SetValue(outputRow, col, totalVal ?? string.Empty); + result.SetValue(outputRow, col, totalVal ?? string.Empty); } } + private void WriteGrandTotalRow( InMemoryRange result, int startRow, @@ -648,7 +645,8 @@ private void WriteGrandTotalRow( int grandTotalCol, bool showColTotal, int colsPerEntry, - bool isVStack) + bool isVStack, + int functionColOffset) { var functionNames = ResolveFunctionHeaders(args.Functions); int nFunctions = args.Functions.Count; @@ -661,7 +659,7 @@ private void WriteGrandTotalRow( var f = args.Functions[fi]; result.SetValue(r, 0, rowTotalLabel); - for (int c = 1; c < nRowKeyCols; c++) + for (int c = 1; c < nRowKeyCols + functionColOffset; c++) result.SetValue(r, c, string.Empty); if (isVStack) @@ -687,8 +685,7 @@ private void WriteGrandTotalRow( ? Aggregate(f, groupVals, context, f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null) : null; - //if (val != null) - result.SetValue(r, col, val ?? string.Empty); + result.SetValue(r, col, val ?? string.Empty); col++; } else @@ -699,8 +696,7 @@ private void WriteGrandTotalRow( ? Aggregate(func, groupVals, context, func.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null) : null; - //if (val != null) - result.SetValue(r, col, val ?? string.Empty); + result.SetValue(r, col, val ?? string.Empty); col++; } } @@ -726,8 +722,7 @@ private void WriteGrandTotalRow( : args.AllValuesInOrder; grandVal = Aggregate(f, allValsForCol, context, relativeToVals); } - //if (grandVal != null) - result.SetValue(r, col, grandVal ?? string.Empty); + result.SetValue(r, col, grandVal ?? string.Empty); col++; } else @@ -745,8 +740,7 @@ private void WriteGrandTotalRow( : args.AllValuesInOrder; grandVal = Aggregate(func, allValsForCol, context, relativeToVals); } - //if (grandVal != null) - result.SetValue(r, col, grandVal ?? string.Empty); + result.SetValue(r, col, grandVal ?? string.Empty); col++; } } @@ -766,8 +760,7 @@ private void WriteGrandTotalRow( ? allVals : args.AllValuesInOrder) : args.AllValuesInOrder; var cornerVal = Aggregate(f, allVals, context, cornerRelVals); - //if (cornerVal != null) - result.SetValue(r, col, cornerVal ?? string.Empty); + result.SetValue(r, col, cornerVal ?? string.Empty); col++; } else @@ -782,8 +775,7 @@ private void WriteGrandTotalRow( ? allVals : args.AllValuesInOrder) : args.AllValuesInOrder; var cornerVal = Aggregate(func, allVals, context, cornerRelVals); - //if (cornerVal != null) - result.SetValue(r, col, cornerVal ?? string.Empty); + result.SetValue(r, col, cornerVal ?? string.Empty); col++; } } diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs index a50e5d381..8a6d06729 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs @@ -427,6 +427,7 @@ public void PivotByCustomLambdaWithVstack() { using (var package = new ExcelPackage()) { + package.Workbook.CalcMode = ExcelCalcMode.Manual; var s = package.Workbook.Worksheets.Add("test"); s.Cells["A1"].Value = "A"; s.Cells["A2"].Value = "A"; @@ -442,6 +443,12 @@ public void PivotByCustomLambdaWithVstack() s.Cells["D3"].Value = 1; s.Cells["E1"].Formula = "PIVOTBY(A1:A3,B1:B3,D1:D3, VSTACK(_xleta.COUNT, LAMBDA(x, SUM(x *2/3)), LAMBDA(x, SUM(x *2)) ),3)"; s.Calculate(); + Assert.AreNotEqual(0d, s.Cells["E1"].Value); + Assert.AreNotEqual(0d, s.Cells["F1"].Value); + Assert.AreNotEqual(0d, s.Cells["E2"].Value); + Assert.AreNotEqual(0d, s.Cells["F2"].Value); + + // SaveWorkbook("PivotByCustomLambda.xlsx", package); } } @@ -513,25 +520,9 @@ public void PivotByTemplateTest() } } - [TestMethod] - public void PivotByTemplateTest2() - { - using (var package = OpenTemplatePackage("PivotByTest1.xlsx")) - { - var sheet = package.Workbook.Worksheets[2]; - package.Workbook.CalcMode = ExcelCalcMode.Manual; - - sheet.Cells["B17"].Formula = "PIVOTBY('FCL V'!C6:D2055,'FCL V'!Y6:Y2055,'FCL V'!DH6:DH2055, _xleta.SUM)"; - //sheet.Calculate(); - sheet.Cells["B17"].Calculate(); - - Assert.AreEqual("Albania", sheet.Cells["D17"].Value); - SaveAndCleanup(package); - } - } [TestMethod] - public void PivotByTemplateTest3() + public void PivotByTemplateTest2() { using (var package = OpenTemplatePackage("PivotByTest1.xlsx")) { @@ -548,22 +539,6 @@ public void PivotByTemplateTest3() } } - [TestMethod] - public void PivotByTemplateTest4() - { - using (var package = OpenTemplatePackage("PivotByTest1.xlsx")) - { - var sheet = package.Workbook.Worksheets[3]; - package.Workbook.CalcMode = ExcelCalcMode.Manual; - - sheet.Cells["B26"].Formula = "PIVOTBY('FCL V'!C6:D2055,'FCL V'!Y6:Y2055,'FCL V'!DH6:DH2055, _xleta.PERCENTOF,,2,,,,,)"; - sheet.Cells["B26"].Calculate(); - - Assert.AreEqual("Albania", sheet.Cells["D26"].Value); - Assert.AreEqual(0.99237652, System.Math.Round((double)sheet.Cells["G27"].Value), 8d); - //SaveAndCleanup(package); - } - } [TestMethod] public void PivotBySortOrderPercentOf() From 0b7770ea0bb76d53e4098bee931334f8a2eb4574 Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Thu, 21 May 2026 14:43:14 +0200 Subject: [PATCH 16/16] Fixed failing test --- .../Excel/Functions/RefAndLookup/PivotByTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs index 8a6d06729..d2028b104 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs @@ -58,6 +58,7 @@ public void PivotBy() { using (var package = new ExcelPackage()) { + SwitchToCulture("en-US"); var s = package.Workbook.Worksheets.Add("test"); s.Cells["A1"].Value = "Stockholm"; s.Cells["A2"].Value = "Linköping"; @@ -106,6 +107,7 @@ public void PivotBy() Assert.AreEqual(43265d, s.Cells["G6"].Value); Assert.AreEqual(34543d, s.Cells["H6"].Value); Assert.AreEqual(153269d, s.Cells["I6"].Value); + SwitchBackToCurrentCulture(); } } @@ -317,6 +319,7 @@ public void PivotByRelativeTo2() { using (var package = new ExcelPackage()) { + SwitchToCulture("en-US"); var s = package.Workbook.Worksheets.Add("test"); s.Cells["A1"].Value = "Stockholm"; s.Cells["A2"].Value = "Linköping"; @@ -348,6 +351,7 @@ public void PivotByRelativeTo2() s.Cells["E1"].Formula = "PIVOTBY(A1:A6,B1:C6,D1:D6,_xleta.PERCENTOF,,,,,,,3)"; s.Calculate(); + SwitchBackToCurrentCulture(); } }