diff --git a/src/EPPlus/ExcelWorkbook.cs b/src/EPPlus/ExcelWorkbook.cs index 02b0c79d7..282f47ff6 100644 --- a/src/EPPlus/ExcelWorkbook.cs +++ b/src/EPPlus/ExcelWorkbook.cs @@ -439,7 +439,7 @@ internal void ThrowIfCalculationCanceled() /// /// Returns true if a calculation was canceled, leaving the workbook in an inconsistent state. - /// A workbook in this state must be disposed — saving or recalculating is not permitted. + /// A workbook in this state must be disposed ïżœ saving or recalculating is not permitted. /// public bool IsCalculationInconsistent => IsCalculationCanceled; #endif @@ -1196,14 +1196,6 @@ private void CreateWorkbookXml(XmlNamespaceManager namespaceManager) XmlElement workbookView = _workbookXml.CreateElement("workbookView", ExcelPackage.schemaMain); bookViews.AppendChild(workbookView); - XmlElement calcPr = _workbookXml.CreateElement("calcPr", ExcelPackage.schemaMain); - calcPr.SetAttribute("calcId", "191029"); //Set the version of the calc engine to the latest known version. This will make sure that Excel does not downgrade the calculation engine and that new functions are supported. - wbElem.AppendChild(calcPr); - - XmlElement extLst = _workbookXml.CreateElement("extLst", ExcelPackage.schemaMain); - AddCalculationFeatures(extLst); - wbElem.AppendChild(extLst); - // save it to the package StreamWriter stream = new StreamWriter(partWorkbook.GetStream(FileMode.Create, FileAccess.Write)); _workbookXml.Save(stream); @@ -1399,7 +1391,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/BuiltInFunctions.cs b/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs index 7108e5c99..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; @@ -336,7 +337,8 @@ public BuiltInFunctions() // Reference and lookup Functions["address"] = new Address(); Functions["areas"] = new Areas(); - //Functions["groupby"] = new GroupBy(); //Will be released in next minor release. + 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/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/Groupby.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs index 8785bcd6e..7a13f2431 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,112 +43,85 @@ 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; - } - - private IEnumerable CollectLeafRows(GroupLevel level) - { - if (level.IsLeaf) - return level.Rows; - return level.Children.SelectMany(c => CollectLeafRows(c)); + return true; } - + // ------------------------------------------------------- // Build result // ------------------------------------------------------- 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; @@ -192,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++) @@ -247,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) @@ -318,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++) @@ -347,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 770283a1b..2fdbc7ffc 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 @@ -29,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(); @@ -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(); 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/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/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/GroupingFunctions/PivotByArgs.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/PivotByArgs.cs new file mode 100644 index 000000000..b9c63d7ce --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/PivotByArgs.cs @@ -0,0 +1,25 @@ +ï»ż/************************************************************************************************* + 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 PivotByArgs : GroupByBaseArgs + { + public IRangeInfo ColFields { get; set; } + public int RowTotalDepth { get; set; } = 0; + public int[] RowSortOrders { get; set; } = new[] { 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/GroupingFunctions/RelativeTo.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/RelativeTo.cs new file mode 100644 index 000000000..6740eb576 --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/RelativeTo.cs @@ -0,0 +1,24 @@ +ï»ż/************************************************************************************************* + 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 enum RelativeTo + { + ColumnTotals = 0, + RowTotals = 1, + GrandTotals = 2, + ParentColTotal = 3, + ParentRowTotal = 4 + } +} 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..9ed5aa45f --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/DataBuilding.cs @@ -0,0 +1,178 @@ +ï»ż/************************************************************************************************* + 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; +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); + + 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); + } + + 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(); + + 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); + } + + 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..50b3cf627 --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/PivotBy.cs @@ -0,0 +1,124 @@ +ï»ż/************************************************************************************************* + 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; + 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; + } + } +} \ 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 new file mode 100644 index 000000000..1f227427c --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/PivotBy/Rendering.cs @@ -0,0 +1,818 @@ +ï»ż/************************************************************************************************* + 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 OfficeOpenXml.FormulaParsing.FormulaExpressions; +using OfficeOpenXml.FormulaParsing.Ranges; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.PivotBy +{ + internal partial class PivotBy + { + private List ResolveRelativeToValues( +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: + { + 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(); + + 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) + ? cv + : Enumerable.Empty()) + .ToList(); + } + default: + 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: + { + // 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(cm => siblingLeaves + .Where(leaf => cm.ContainsKey(MakePivotKey(leaf.Path))) + .SelectMany(leaf => cm[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()); + + + + private InMemoryRange RenderPivot( + List rowLeaves, + List colLeaves, + Dictionary>> pivotMap, + PivotByArgs args, + ParsingContext context) + { + int nRowKeyCols = args.RowFields.Size.NumberOfCols; + int nColKeyRows = args.ColFields.Size.NumberOfCols; + int nRowLeaves = rowLeaves.Count; + int nFunctions = args.Functions.Count; + bool isVStack = args.FunctionLayout == FunctionLayout.Vertical; + bool isHStack = args.FunctionLayout == FunctionLayout.Horizontal; + + bool showRowTotal = args.RowTotalDepth != TotalDepthNoTotals; + 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 rowSubtotalDepth = Math.Abs(args.RowTotalDepth); + bool showRowSubtotals = rowSubtotalDepth > 1; + + 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; + int functionColOffset = isVStack ? 1 : 0; + + var colGroups = colLeaves + .GroupBy(l => l.Path[0]?.ToString()?.ToLowerInvariant() ?? string.Empty) + .ToList(); + + 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 }); + } + + 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; + int nTotalCols = showColTotal ? colsPerEntry : 0; + int totalCols = nRowKeyCols + functionColOffset + nDataCols + nTotalCols; + + 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 + subtotalRowCount); + + var result = new InMemoryRange(totalRows, (short)totalCols); + + int dataColStart = nRowKeyCols + functionColOffset; + int grandTotalCol = colTotalAtLeft ? dataColStart : dataColStart + nDataCols; + int colOffset = colTotalAtLeft ? colsPerEntry : 0; + + // --- FĂ€ltnamnrad --- + if (showFieldHeaders) + { + 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)); + } + + // --- Rubrikrader --- + for (int level = 0; level < nColKeyRows; level++) + { + int outputLevel = fieldHeaderRows + level; + + for (int i = 0; i < nRowKeyCols + functionColOffset; 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] : (object)string.Empty); + + for (int f = 0; f < colsPerEntry; f++) + { + result.SetValue(outputLevel, col, f == 0 ? val : string.Empty); + col++; + } + } + + if (showColTotal) + { + 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++; + } + } + } + + // --- HSTACK: Funktionsnamnsrad --- + if (isHStack) + { + var functionNames = ResolveFunctionHeaders(args.Functions); + int functionHeaderRow = fieldHeaderRows + nColKeyRows; + + for (int i = 0; i < nRowKeyCols; i++) + result.SetValue(functionHeaderRow, i, string.Empty); + + 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++; + } + } + } + + // --- Header-datarad --- + if (showFieldHeaders) + { + int headerDataRow = fieldHeaderRows + nColKeyRows + functionHeaderRows; + + 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; + foreach (var entry in colEntries) + { + for (int fc = 0; fc < colsPerEntry; fc++) + { + result.SetValue(headerDataRow, col, entry.IsSubtotal ? string.Empty : headerValue); + col++; + } + } + + 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, functionColOffset); + + // --- Datarader --- + var functionNames2 = ResolveFunctionHeaders(args.Functions); + int currentOutputRow = dataRowStart; + + foreach (var rowGroup in rowGroups) + { + var groupLeaves = rowGroup.ToList(); + + foreach (var rowLeafEntry in groupLeaves) + { + var rowPath = rowLeafEntry.Path; + var rowLeaf = rowLeafEntry.Leaf; + string rowKey = MakePivotKey(rowPath); + + for (int fi = 0; fi < nFunctions; fi++) + { + int outputRow = currentOutputRow + fi; + var f = args.Functions[fi]; + + // 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]); + + 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 (isHStack) + { + 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 ?? string.Empty); + col++; + } + } + else + { + object val = groupVals.Count > 0 + ? Aggregate(f, groupVals, context, + f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null) + : null; + result.SetValue(outputRow, col, val ?? string.Empty); + col++; + } + } + else + { + string colKey = MakePivotKey(entry.Leaf.Path); + + 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); + } + 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 = 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 ?? string.Empty); + col++; + } + } + } + + if (showColTotal) + { + var rowAllVals = rowLeaf.Rows.SelectMany(r => r.Values).ToList(); + if (isHStack) + { + foreach (var func in args.Functions) + { + 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); + 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); + result.SetValue(outputRow, col, totalVal ?? string.Empty); + col++; + } + } + + 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; + } + } + + // --- Grand total-rad nederst --- + if (!rowTotalAtTop && showRowTotal) + WriteGrandTotalRow(result, grandTotalRow, colEntries, colLeaves, pivotMap, args, context, + nRowKeyCols, functionNameCol, dataColStart, colOffset, + 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) + { + // 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; + + 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); + result.SetValue(outputRow, col, val ?? string.Empty); + } + else + { + result.SetValue(outputRow, col, 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; + result.SetValue(outputRow, col, totalVal ?? string.Empty); + } + } + + + private void WriteGrandTotalRow( + 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, + int functionColOffset) + { + 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, rowTotalLabel); + for (int c = 1; c < nRowKeyCols + functionColOffset; c++) + result.SetValue(r, c, string.Empty); + + if (isVStack) + result.SetValue(r, functionNameCol, functionNames[fi]); + + int col = dataColStart + 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(); + + if (isVStack) + { + object val = groupVals.Count > 0 + ? Aggregate(f, groupVals, context, + f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null) + : null; + result.SetValue(r, col, val ?? string.Empty); + col++; + } + else + { + 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 ?? string.Empty); + col++; + } + } + } + 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 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 ?? string.Empty); + col++; + } + else + { + foreach (var func in args.Functions) + { + object grandVal = null; + if (allValsForCol.Count > 0) + { + 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 ?? string.Empty); + col++; + } + } + } + } + + if (showColTotal) + { + var allVals = args.AllValuesInOrder.Select(v => new object[] { v[0] }).ToList(); + if (isVStack) + { + 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); + result.SetValue(r, col, cornerVal ?? string.Empty); + col++; + } + else + { + foreach (var func in args.Functions) + { + 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); + result.SetValue(r, col, cornerVal ?? string.Empty); + col++; + } + } + } + + if (!isVStack) break; + } + } + + 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; + } + } +} diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs index 3129b62a8..75debbb56 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 @@ -530,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 new file mode 100644 index 000000000..d2028b104 --- /dev/null +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/PivotByTests.cs @@ -0,0 +1,577 @@ +ï»ż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 BasicPivotBy() + { + 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(); + s.Workbook.FullCalcOnLoad = false; + s.Workbook.CalcMode = ExcelCalcMode.Manual; + + // 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); + + SaveWorkbook("BasicPivotBy.xlsx", package); + } + } + + [TestMethod] + 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"; + 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)"; + 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); + SwitchBackToCurrentCulture(); + } + } + + [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] + 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["F2"].Value); + Assert.AreEqual("O", s.Cells["G2"].Value); + Assert.AreEqual("I", s.Cells["I2"].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); + } + } + [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] + 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: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)); + } + } + + [TestMethod] + 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"; + 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(); + SwitchBackToCurrentCulture(); + } + } + + [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(); + } + } + + [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["F2"].Value); + Assert.AreEqual("A", s.Cells["E3"].Value); + Assert.AreEqual("A", s.Cells["E4"].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(); + + } + } + + [TestMethod] + 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"; + 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(); + 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); + } + } + + [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[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, System.Math.Round((double)sheet.Cells["G27"].Value), 8); + + 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); + } + } + } +}