From 57d71a6b230b9d51c58138782625ac6e9d6be576 Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Tue, 10 Mar 2026 16:30:20 +0100 Subject: [PATCH 01/14] WIP --- .../Excel/Functions/BuiltInFunctions.cs | 1 + .../Excel/Functions/RefAndLookup/Groupby.cs | 317 ++++++++++++++++++ .../Functions/RefAndLookup/GroupByTests.cs | 74 ++++ 3 files changed, 392 insertions(+) create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs create mode 100644 src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs b/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs index fc6fa2dbb..15b52c42d 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs @@ -336,6 +336,7 @@ public BuiltInFunctions() // Reference and lookup Functions["address"] = new Address(); Functions["areas"] = new Areas(); + Functions["groupby"] = new Groupby(); 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 new file mode 100644 index 000000000..2027f8f79 --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs @@ -0,0 +1,317 @@ +using OfficeOpenXml.FormulaParsing.Excel.Functions.Metadata; +using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.LookupUtils; +using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.Sorting; +using OfficeOpenXml.FormulaParsing.FormulaExpressions; +using OfficeOpenXml.FormulaParsing.Ranges; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Runtime.CompilerServices; +using System.Text; + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup +{ + [FunctionMetadata( + Category = ExcelFunctionCategory.LookupAndReference, + EPPlusVersion = "", + Description = "Allows you to create a summary of your data via a formula. Supports grouping along one axis and aggregating the associated values.")] + + internal class Groupby : ExcelFunction + { + public override string NamespacePrefix => "_xlfn."; + public override bool ExecutesLambda => true; + public override int ArgumentMinLength => 3; + private readonly LookupComparerBase _comparer = new SortByComparer(); + private enum FieldHeaders + { + Missing = -1, // Default + No = 0, + YesAndDontShow = 1, + NoButGenerate = 2, + YesAndShow = 3 + } + private enum TotalDepth + { + Missing = -3, + GrandAndSubtotalsAtTop = -2, + GrandTotalsAtTop = -1, + NoTotals = 0, + GrandTotals = 1, + GrandAndSubtotals = 2, + } + private class GroupbyArgs + { + public IRangeInfo RowFields { get; set; } + public IRangeInfo Values { get; set; } + public LambdaCalculator Function { get; set; } + public FieldHeaders Headers { get; set; } = FieldHeaders.Missing; // Default + public TotalDepth TotalDepth { get; set; } = TotalDepth.GrandTotals; // Default + public int SortOrder { get; set; } = 1; // Default is first column ascending. + public IRangeInfo FilterArray { get; set; } = null; + } + + public override CompileResult Execute(IList arguments, ParsingContext context) + { + if (!TryParseArgs(arguments, context, out var args, out var error)) + return error; + var groups = BuildGroups(args, context); + groups = ApplySort(groups, args.SortOrder); + var result = BuildResult(groups, args); + + return CreateDynamicArrayResult(result, DataType.ExcelRange); + } + + + + private bool TryParseArgs( + IList arguments, + ParsingContext context, + out GroupbyArgs args, + out CompileResult error) + { + args = new GroupbyArgs(); + error = null; + + if (!arguments[0].IsExcelRange) + { + error = CompileResult.GetErrorResult(eErrorType.Value); + return false; + } + args.RowFields = arguments[0].ValueAsRangeInfo; + + if (!arguments[1].IsExcelRange) + { + error = CompileResult.GetErrorResult(eErrorType.Value); + return false; + } + args.Values = arguments[1].ValueAsRangeInfo; + + // Validate that row_fields and values have the same number of rows + if (args.RowFields.Size.NumberOfRows != args.Values.Size.NumberOfRows) + { + error = CompileResult.GetErrorResult(eErrorType.Value); + return false; + } + + // function (required) – resolve the aggregation function by name + if (arguments[2].DataType != DataType.LambdaCalculation) + { + error = CompileResult.GetErrorResult(eErrorType.Value); + return false; + } + var calculator = arguments[2].Value as LambdaCalculator; + args.Function = calculator; if (args.Function == null) + { + error = CompileResult.GetErrorResult(eErrorType.Value); + return false; + } + + // field_headers (optional, default = HasHeadersAndShow) + if (arguments.Count > 3 && arguments[3].Value != null) + { + if (!Enum.IsDefined(typeof(FieldHeaders), Convert.ToInt32(arguments[3].Value))) + { + error = CompileResult.GetErrorResult(eErrorType.Value); + return false; + } + args.Headers = (FieldHeaders)Convert.ToInt32(arguments[3].Value); + } + + // total_depth (optional, default = GrandTotals) + if (arguments.Count > 4 && arguments[4].Value != null) + { + if (!Enum.IsDefined(typeof(TotalDepth), Convert.ToInt32(arguments[4].Value))) + { + error = CompileResult.GetErrorResult(eErrorType.Value); + return false; + } + args.TotalDepth = (TotalDepth)Convert.ToInt32(arguments[4].Value); + } + + // sort_order (optional, default = 0 / no sort) + if (arguments.Count > 5 && arguments[5].Value != null) + args.SortOrder = Convert.ToInt32(arguments[5].Value); + + // filter_array (optional) + if (arguments.Count > 6 && arguments[6].IsExcelRange) + args.FilterArray = arguments[6].ValueAsRangeInfo; + + return true; + } + /// Represents one group key with its collected values. + private class GroupRow + { + public object Key { get; set; } + public List Values { get; set; } = new List(); + public object AggregatedValue { get; set; } + } + + private FieldHeaders ResolveHeaders (GroupbyArgs args) + { + if (args.Headers != FieldHeaders.Missing) + return args.Headers; + + if (args.Values.Size.NumberOfRows < 2) + return FieldHeaders.No; + + var first = args.Values.GetValue(0, 0); + var second = args.Values.GetValue(1, 0); + + bool firstIsText = first is string; + bool secondIsNumber = second is double || second is int || second is long || second is float; + + return firstIsText && secondIsNumber + ? FieldHeaders.YesAndDontShow + : FieldHeaders.No; + } + + private List BuildGroups(GroupbyArgs args, ParsingContext context) + { + var resolvedHeaders = ResolveHeaders(args); + bool hasHeaders = resolvedHeaders == FieldHeaders.YesAndShow + || resolvedHeaders == FieldHeaders.YesAndDontShow; + int startRow = hasHeaders ? 1 : 0; + + var dict = new Dictionary(); + var order = new List(); + + for (int r = startRow; r < args.RowFields.Size.NumberOfRows; r++) + { + // Apply filter_array if present + if (args.FilterArray != null) + { + var filterVal = args.FilterArray.GetValue(r, 0); + if (filterVal is bool b && !b) continue; + if (filterVal is int i && i == 0) continue; + } + + //var key = args.RowFields.GetValue(r, 0)?.ToString() ?? string.Empty; + //var val = args.Values.GetValue(r, 0); + + var key = args.RowFields.GetOffset(r, 0); + //?.ToString() ?? string.Empty; + var val = args.Values.GetOffset(r, 0); + + if (!dict.TryGetValue(key, out var group)) + { + group = new GroupRow { Key = key }; + dict[key] = group; + order.Add(key); + } + group.Values.Add(val); + } + + // Aggregate each group using the lambda + var groups = order.Select(k => dict[k]).ToList(); + foreach (var g in groups) + g.AggregatedValue = Aggregate(args.Function, g.Values, context); + + return groups; + } + + /// + /// Aggregates a group's values by passing them as an in-memory range + /// to the LambdaCalculator, mirroring the pattern used in Map. + /// + private object Aggregate(LambdaCalculator calculator, List values, ParsingContext context) + { + // Build a single-column in-memory range from the group's values + var range = new InMemoryRange(values.Count, 1); + for (int i = 0; i < values.Count; i++) + range.SetValue(i, 0, values[i]); + + calculator.BeginCalculation(); + // Pass the range as the single variable (e.g. the x in LAMBDA(x, SUM(x))) + calculator.SetVariableValue(0, range, DataType.ExcelRange, context); + var result = calculator.Execute(context); + return result.ResultValue; + } + + // ------------------------------------------------------- + // Sorting + // ------------------------------------------------------- + private List ApplySort(List groups, int sortOrder) + { + //if (sortOrder == 0) sortOrder = 1; + + bool desc = sortOrder < 0; + int col = Math.Abs(sortOrder); + + if (col == 1) + { + return desc ? groups.OrderByDescending(g => g.Key, _comparer).ToList() + : groups.OrderBy(g => g.Key, _comparer).ToList(); + } + + // Column 2+ sorts on aggregated value - handle both numeric and text + return desc + ? groups.OrderByDescending(g => g.AggregatedValue as IComparable, _comparer).ToList() + : groups.OrderBy(g => g.AggregatedValue as IComparable, _comparer).ToList(); + } + + // ------------------------------------------------------- + // Build result + // ------------------------------------------------------- + private InMemoryRange BuildResult(List groups, GroupbyArgs args) + { + bool showHeaders = args.Headers == FieldHeaders.YesAndShow + || args.Headers == FieldHeaders.NoButGenerate; + bool totalsAtEnd = args.TotalDepth == TotalDepth.GrandTotals + || args.TotalDepth == TotalDepth.GrandAndSubtotals; + bool totalsAtTop = args.TotalDepth == TotalDepth.GrandTotalsAtTop + || args.TotalDepth == TotalDepth.GrandAndSubtotalsAtTop; + bool showTotals = args.TotalDepth != TotalDepth.NoTotals; + + int rowCount = groups.Count + + (showHeaders ? 1 : 0) + + (showTotals ? 1 : 0); + + var result = new InMemoryRange(rowCount, 2); + int r = 0; + + if (showHeaders) + { + result.SetValue(r, 0, args.Headers == FieldHeaders.NoButGenerate + ? "Field 1" : args.RowFields.GetValue(0, 0)?.ToString()); + result.SetValue(r, 1, args.Headers == FieldHeaders.NoButGenerate + ? "Field 2" : args.Values.GetValue(0, 0)?.ToString()); + r++; + } + + if (totalsAtTop && showTotals) + { + result.SetValue(r, 0, "Total"); + result.SetValue(r, 1, SumAggregated(groups)); + r++; + } + + foreach (var g in groups) + { + result.SetValue(r, 0, g.Key); + result.SetValue(r, 1, g.AggregatedValue); + r++; + } + + if (totalsAtEnd && showTotals) + { + result.SetValue(r, 0, "Total"); + result.SetValue(r, 1, SumAggregated(groups)); + } + + return result; + } + + /// Sums aggregated values where they are numeric. + private object SumAggregated(List groups) + { + var nums = groups + .Select(g => g.AggregatedValue) + .Where(v => v is IConvertible && !(v is string)) + .Select(v => Convert.ToDouble(v)) + .ToList(); + + return nums.Any() ? (object)nums.Sum() : string.Empty; + } + } +} diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs new file mode 100644 index 000000000..8db4c7c80 --- /dev/null +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs @@ -0,0 +1,74 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml; +using OfficeOpenXml.FormulaParsing.Excel.Functions.Information; +using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup; +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 +{ + [TestClass] + public class GroupByTests + { + + [TestMethod] + public void GroupBy() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "Joe"; + s.Cells["A2"].Value = "Anna"; + s.Cells["A3"].Value = "Bertil"; + s.Cells["A4"].Value = "Joe"; + s.Cells["B1"].Value = 1; + s.Cells["B2"].Value = 2; + s.Cells["B3"].Value = 3; + s.Cells["B4"].Value = 0; + s.Cells["C1"].Formula = "GROUPBY(A1:A4, B1:B4, _xleta.SUM)"; + s.Calculate(); + Assert.AreEqual("Anna", s.Cells["C1"].Value); + Assert.AreEqual("Bertil", s.Cells["C2"].Value); + Assert.AreEqual("Joe", s.Cells["C3"].Value); + Assert.AreEqual(2d, s.Cells["D1"].Value); + Assert.AreEqual(3d, s.Cells["D2"].Value); + Assert.AreEqual(1d, s.Cells["D3"].Value); + Assert.AreEqual("Total", s.Cells["C4"].Value); + Assert.AreEqual(6d, s.Cells["D4"].Value); + } + } + + [TestMethod] + public void GroupByMixedInput() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "Joe"; + s.Cells["A2"].Value = "Anna"; + s.Cells["A3"].Value = "NA()"; + s.Cells["A4"].Value = false; + s.Cells["B1"].Value = 1; + s.Cells["B2"].Value = 2; + s.Cells["B3"].Value = 4; + s.Cells["C1"].Formula = "GROUPBY(A1:A4, B1:B4, _xleta.SUM)"; + s.Calculate(); + Assert.AreEqual("Anna", s.Cells["C1"].Value); + Assert.AreEqual("Joe", s.Cells["C2"].Value); + Assert.AreEqual(ErrorValues.NAError, s.Cells["C3"].Value); + Assert.AreEqual(false, s.Cells["C4"].Value); + Assert.AreEqual(2d, s.Cells["D1"].Value); + Assert.AreEqual(1d, s.Cells["D2"].Value); + Assert.AreEqual(4d, s.Cells["D3"].Value); + Assert.AreEqual(0d, s.Cells["D4"].Value); + + Assert.AreEqual("Total", s.Cells["C5"].Value); + Assert.AreEqual(7d, s.Cells["D5"].Value); + } + } + } +} From 80811dc190e789a9dd430a5f0169a2e8f19d1dd9 Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Mon, 16 Mar 2026 16:26:50 +0100 Subject: [PATCH 02/14] WIP --- .../Excel/Functions/RefAndLookup/Groupby.cs | 289 ++++++++++++++---- .../Functions/RefAndLookup/GroupByTests.cs | 144 ++++++++- 2 files changed, 364 insertions(+), 69 deletions(-) diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs index 2027f8f79..96c3b4c0e 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs @@ -40,6 +40,11 @@ private enum TotalDepth GrandTotals = 1, GrandAndSubtotals = 2, } + private enum FieldRelationship + { + Hierarchy = 0, + Table = 1 + } private class GroupbyArgs { public IRangeInfo RowFields { get; set; } @@ -49,6 +54,24 @@ private class GroupbyArgs public TotalDepth TotalDepth { get; set; } = TotalDepth.GrandTotals; // Default public int SortOrder { get; set; } = 1; // Default is first column ascending. public IRangeInfo FilterArray { get; set; } = null; + public FieldRelationship FieldRelationship { get; set; } = FieldRelationship.Hierarchy; + } + + /// Represents one level, with the topkey that represents the key in the first column. + private class GroupLevel + { + public object TopKey { get; set; } // First column value + public List Rows { get; set; } = new List(); + public object SubtotalValue { get; set; } + } + + /// Represents one group key with its collected values. + private class GroupRow + { + public string Key { get; set; } + public object[] KeyParts { get; set; } + public List Values { get; set; } = new List(); + public object AggregatedValue { get; set; } } public override CompileResult Execute(IList arguments, ParsingContext context) @@ -56,14 +79,12 @@ public override CompileResult Execute(IList arguments, Parsing if (!TryParseArgs(arguments, context, out var args, out var error)) return error; var groups = BuildGroups(args, context); - groups = ApplySort(groups, args.SortOrder); + groups = ApplySort(groups, args, args.SortOrder); var result = BuildResult(groups, args); return CreateDynamicArrayResult(result, DataType.ExcelRange); } - - private bool TryParseArgs( IList arguments, ParsingContext context, @@ -137,15 +158,19 @@ private bool TryParseArgs( if (arguments.Count > 6 && arguments[6].IsExcelRange) args.FilterArray = arguments[6].ValueAsRangeInfo; + // field_relationship (optional) + if (arguments.Count > 7 && arguments[7].Value != null) + { + if (!Enum.IsDefined(typeof(FieldRelationship), Convert.ToInt32(arguments[7].Value))) + { + error = CompileResult.GetErrorResult(eErrorType.Value); + return false; + } + args.FieldRelationship = (FieldRelationship)Convert.ToInt32(arguments[7].Value); + } + return true; - } - /// Represents one group key with its collected values. - private class GroupRow - { - public object Key { get; set; } - public List Values { get; set; } = new List(); - public object AggregatedValue { get; set; } - } + } private FieldHeaders ResolveHeaders (GroupbyArgs args) { @@ -166,63 +191,95 @@ private FieldHeaders ResolveHeaders (GroupbyArgs args) : FieldHeaders.No; } - private List BuildGroups(GroupbyArgs args, ParsingContext context) + private List BuildGroups(GroupbyArgs args, ParsingContext context) { var resolvedHeaders = ResolveHeaders(args); bool hasHeaders = resolvedHeaders == FieldHeaders.YesAndShow || resolvedHeaders == FieldHeaders.YesAndDontShow; int startRow = hasHeaders ? 1 : 0; - var dict = new Dictionary(); - var order = new List(); + var levelDict = new Dictionary(StringComparer.OrdinalIgnoreCase); + var levelOrder = new List(); + var rowDict = new Dictionary(StringComparer.OrdinalIgnoreCase); for (int r = startRow; r < args.RowFields.Size.NumberOfRows; r++) { // Apply filter_array if present if (args.FilterArray != null) { - var filterVal = args.FilterArray.GetValue(r, 0); + var filterVal = args.FilterArray.GetOffset(r, 0); if (filterVal is bool b && !b) continue; if (filterVal is int i && i == 0) continue; } - //var key = args.RowFields.GetValue(r, 0)?.ToString() ?? string.Empty; - //var val = args.Values.GetValue(r, 0); + // Build composite key from all columns in RowFields + int nKeyCols = args.RowFields.Size.NumberOfCols; + var keyParts = new object[nKeyCols]; + for (int c = 0; c < nKeyCols; c++) + keyParts[c] = args.RowFields.GetOffset(r, c); + + var keyStrings = new string[keyParts.Length]; + for (int c = 0; c < keyParts.Length; c++) + keyStrings[c] = keyParts[c]?.ToString() ?? string.Empty; + var key = string.Join("|", keyStrings); + + // Top-level key is always the first column + var topKeyStr = keyStrings[0]; + + // Collect all columns from values range for this row + int nCols = args.Values.Size.NumberOfCols; + var rowVals = new object[nCols]; + for (int c = 0; c < nCols; c++) + rowVals[c] = args.Values.GetOffset(r, c); - var key = args.RowFields.GetOffset(r, 0); - //?.ToString() ?? string.Empty; - var val = args.Values.GetOffset(r, 0); + // Create GroupLevel if needed + if (!levelDict.TryGetValue(topKeyStr, out var level)) + { + level = new GroupLevel { TopKey = keyParts[0] }; + levelDict[topKeyStr] = level; + levelOrder.Add(topKeyStr); + } - if (!dict.TryGetValue(key, out var group)) + // Create GroupRow if needed + if (!rowDict.TryGetValue(key, out var group)) { - group = new GroupRow { Key = key }; - dict[key] = group; - order.Add(key); + group = new GroupRow { Key = key, KeyParts = keyParts }; + rowDict[key] = group; + level.Rows.Add(group); } - group.Values.Add(val); + group.Values.Add(rowVals); } - // Aggregate each group using the lambda - var groups = order.Select(k => dict[k]).ToList(); - foreach (var g in groups) - g.AggregatedValue = Aggregate(args.Function, g.Values, context); + // Aggregate each GroupRow and each GroupLevel's subtotal + var levels = levelOrder.Select(k => levelDict[k]).ToList(); + foreach (var level in levels) + { + foreach (var row in level.Rows) + row.AggregatedValue = Aggregate(args.Function, row.Values, context); + + // Subtotal = aggregate of all values in this level + var allLevelValues = level.Rows.SelectMany(r => r.Values).ToList(); + level.SubtotalValue = Aggregate(args.Function, allLevelValues, context); + } - return groups; + return levels; } /// /// Aggregates a group's values by passing them as an in-memory range /// to the LambdaCalculator, mirroring the pattern used in Map. /// - private object Aggregate(LambdaCalculator calculator, List values, ParsingContext context) + private object Aggregate(LambdaCalculator calculator, List values, ParsingContext context) { - // Build a single-column in-memory range from the group's values - var range = new InMemoryRange(values.Count, 1); - for (int i = 0; i < values.Count; i++) - range.SetValue(i, 0, values[i]); + int nRows = values.Count; + int nCols = values.Count > 0 ? values[0].Length : 1; + + var range = new InMemoryRange(nRows, (short)nCols); + for (int row = 0; row < nRows; row++) + for (int col = 0; col < nCols; col++) + range.SetValue(row, col, values[row][col]); calculator.BeginCalculation(); - // Pass the range as the single variable (e.g. the x in LAMBDA(x, SUM(x))) calculator.SetVariableValue(0, range, DataType.ExcelRange, context); var result = calculator.Execute(context); return result.ResultValue; @@ -231,72 +288,170 @@ private object Aggregate(LambdaCalculator calculator, List values, Parsi // ------------------------------------------------------- // Sorting // ------------------------------------------------------- - private List ApplySort(List groups, int sortOrder) + private List ApplySort(List levels, GroupbyArgs args, int sortOrder) { - //if (sortOrder == 0) sortOrder = 1; + if (sortOrder == 0) return levels; bool desc = sortOrder < 0; int col = Math.Abs(sortOrder); + bool sortOnAggregated = col > args.RowFields.Size.NumberOfRows; + + //// Sort levels by TopKey or subtotal + //var sortedLevels = col == 1 + // ? (desc ? levels.OrderByDescending(l => l.TopKey as IComparable, _comparer).ToList() + // : levels.OrderBy(l => l.TopKey as IComparable, _comparer).ToList()) + // : (desc ? levels.OrderByDescending(l => l.SubtotalValue as IComparable, _comparer).ToList() + // : levels.OrderBy(l => l.SubtotalValue as IComparable, _comparer).ToList()); + + //// Sort rows within each level by their KeyParts or aggregated value + //foreach (var level in sortedLevels) + //{ + // level.Rows = col == 1 + // ? (desc ? level.Rows.OrderByDescending(r => r.KeyParts[0] as IComparable, _comparer).ToList() + // : level.Rows.OrderBy(r => r.KeyParts[0] as IComparable, _comparer).ToList()) + // : (desc ? level.Rows.OrderByDescending(r => r.AggregatedValue as IComparable, _comparer).ToList() + // : level.Rows.OrderBy(r => r.AggregatedValue as IComparable, _comparer).ToList()); + //} + + //return sortedLevels; + if (args.FieldRelationship == FieldRelationship.Table) + { + // Table: sort all GroupRows globally and independently, rebuild levels + var allRows = levels.SelectMany(l => l.Rows).ToList(); + allRows = SortRows(allRows, col, desc, sortOnAggregated); + + // Rebuild levels in the new row order + var newLevelDict = new Dictionary(); + var newLevelOrder = new List(); + foreach (var row in allRows) + { + var topKey = row.KeyParts[0]?.ToString() ?? string.Empty; + if (!newLevelDict.TryGetValue(topKey, out var level)) + { + level = new GroupLevel { TopKey = row.KeyParts[0] }; + newLevelDict[topKey] = level; + newLevelOrder.Add(topKey); + } + level.Rows.Add(row); + } + return newLevelOrder.Select(k => newLevelDict[k]).ToList(); + } + else + { + // Hierarchy: sort levels on TopKey or aggregated, then sort rows within each level + levels = sortOnAggregated + ? (desc ? levels.OrderByDescending(l => l.SubtotalValue as IComparable).ToList() + : levels.OrderBy(l => l.SubtotalValue as IComparable).ToList()) + : (desc ? levels.OrderByDescending(l => l.TopKey as IComparable).ToList() + : levels.OrderBy(l => l.TopKey as IComparable).ToList()); + + // Sort rows within each level + foreach (var level in levels) + level.Rows = SortRows(level.Rows, col, desc, sortOnAggregated); + + return levels; + } + } - if (col == 1) + private List SortRows(List rows, int col, bool desc, bool sortOnAggregated) + { + if (sortOnAggregated) { - return desc ? groups.OrderByDescending(g => g.Key, _comparer).ToList() - : groups.OrderBy(g => g.Key, _comparer).ToList(); + return desc ? rows.OrderByDescending(r => r.AggregatedValue as IComparable).ToList() + : rows.OrderBy(r => r.AggregatedValue as IComparable).ToList(); } - // Column 2+ sorts on aggregated value - handle both numeric and text - return desc - ? groups.OrderByDescending(g => g.AggregatedValue as IComparable, _comparer).ToList() - : groups.OrderBy(g => g.AggregatedValue as IComparable, _comparer).ToList(); + // col is 1-based, KeyParts is 0-based + int keyIndex = Math.Min(col - 1, rows[0].KeyParts.Length - 1); + return desc ? rows.OrderByDescending(r => r.KeyParts[keyIndex] as IComparable).ToList() + : rows.OrderBy(r => r.KeyParts[keyIndex] as IComparable).ToList(); } - // ------------------------------------------------------- // Build result - // ------------------------------------------------------- - private InMemoryRange BuildResult(List groups, GroupbyArgs args) + // ------------------------------------------------------- + + private InMemoryRange BuildResult(List levels, GroupbyArgs args) { + var resolvedHeaders = ResolveHeaders(args); bool showHeaders = args.Headers == FieldHeaders.YesAndShow - || args.Headers == FieldHeaders.NoButGenerate; + || args.Headers == FieldHeaders.NoButGenerate; bool totalsAtEnd = args.TotalDepth == TotalDepth.GrandTotals - || args.TotalDepth == TotalDepth.GrandAndSubtotals; + || args.TotalDepth == TotalDepth.GrandAndSubtotals; bool totalsAtTop = args.TotalDepth == TotalDepth.GrandTotalsAtTop - || args.TotalDepth == TotalDepth.GrandAndSubtotalsAtTop; + || args.TotalDepth == TotalDepth.GrandAndSubtotalsAtTop; bool showTotals = args.TotalDepth != TotalDepth.NoTotals; + bool showSubtotals = args.TotalDepth == TotalDepth.GrandAndSubtotals + || args.TotalDepth == TotalDepth.GrandAndSubtotalsAtTop; + + int nKeyCols = args.RowFields.Size.NumberOfCols; + int nValCols = args.Values.Size.NumberOfCols; + int nCols = nKeyCols + nValCols; - int rowCount = groups.Count - + (showHeaders ? 1 : 0) - + (showTotals ? 1 : 0); + // Calculate total number of rows needed + int dataRows = levels.Sum(l => l.Rows.Count); + int subtotalRows = showSubtotals ? levels.Count : 0; + int totalRows = dataRows + subtotalRows + + (showHeaders ? 1 : 0) + + (showTotals ? 1 : 0); - var result = new InMemoryRange(rowCount, 2); + var result = new InMemoryRange(totalRows, (short)nCols); int r = 0; + // Header row if (showHeaders) { - result.SetValue(r, 0, args.Headers == FieldHeaders.NoButGenerate - ? "Field 1" : args.RowFields.GetValue(0, 0)?.ToString()); - result.SetValue(r, 1, args.Headers == FieldHeaders.NoButGenerate - ? "Field 2" : args.Values.GetValue(0, 0)?.ToString()); + for (int c = 0; c < nKeyCols; c++) + result.SetValue(r, c, resolvedHeaders == FieldHeaders.NoButGenerate + ? $"Field {c + 1}" + : args.RowFields.GetOffset(0, c)?.ToString()); + for (int c = 0; c < nValCols; c++) + result.SetValue(r, nKeyCols + c, resolvedHeaders == FieldHeaders.NoButGenerate + ? $"Field {nKeyCols + c + 1}" + : args.Values.GetOffset(0, c)?.ToString()); r++; } + string totalString; + if (args.TotalDepth == TotalDepth.GrandAndSubtotalsAtTop || args.TotalDepth == TotalDepth.GrandAndSubtotals) + { + totalString = "Grand Total"; + } + else + { + totalString = "Total"; + } + // Grand total at top if (totalsAtTop && showTotals) { - result.SetValue(r, 0, "Total"); - result.SetValue(r, 1, SumAggregated(groups)); + result.SetValue(r, 0, totalString); + result.SetValue(r, nKeyCols, levels.SelectMany(l => l.Rows).Sum(row => Convert.ToDouble(row.AggregatedValue))); r++; } - foreach (var g in groups) + // Data rows and subtotals + foreach (var level in levels) { - result.SetValue(r, 0, g.Key); - result.SetValue(r, 1, g.AggregatedValue); - r++; + foreach (var row in level.Rows) + { + for (int c = 0; c < nKeyCols; c++) + result.SetValue(r, c, row.KeyParts[c]); + result.SetValue(r, nKeyCols, row.AggregatedValue); + r++; + } + + if (showSubtotals) + { + result.SetValue(r, 0, level.TopKey); + result.SetValue(r, nKeyCols, level.SubtotalValue); + r++; + } } + // Grand total at bottom if (totalsAtEnd && showTotals) { - result.SetValue(r, 0, "Total"); - result.SetValue(r, 1, SumAggregated(groups)); + result.SetValue(r, 0, totalString); + result.SetValue(r, nKeyCols, levels.SelectMany(l => l.Rows).Sum(row => Convert.ToDouble(row.AggregatedValue))); } return result; diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs index 8db4c7c80..7aa336b3c 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs @@ -1,7 +1,9 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using FakeItEasy.Configuration; +using Microsoft.VisualStudio.TestTools.UnitTesting; using OfficeOpenXml; using OfficeOpenXml.FormulaParsing.Excel.Functions.Information; using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup; +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -42,6 +44,33 @@ public void GroupBy() } } + [TestMethod] + public void GroupByLambda() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "Joe"; + s.Cells["A2"].Value = "Anna"; + s.Cells["A3"].Value = "Bertil"; + s.Cells["A4"].Value = "Joe"; + s.Cells["B1"].Value = 1; + s.Cells["B2"].Value = 2; + s.Cells["B3"].Value = 3; + s.Cells["B4"].Value = 0; + s.Cells["C1"].Formula = "GROUPBY(A1:A4, B1:B4, LAMBDA(x,SUM(x *2/3)))"; + s.Calculate(); + Assert.AreEqual("Anna", s.Cells["C1"].Value); + Assert.AreEqual("Bertil", s.Cells["C2"].Value); + Assert.AreEqual("Joe", s.Cells["C3"].Value); + Assert.AreEqual(1.3333d, s.Cells["D1"].Value); + Assert.AreEqual(2d, s.Cells["D2"].Value); + Assert.AreEqual(0.6667d, s.Cells["D3"].Value); + Assert.AreEqual("Total", s.Cells["C4"].Value); + Assert.AreEqual(4d, s.Cells["D4"].Value); + } + } + [TestMethod] public void GroupByMixedInput() { @@ -50,7 +79,7 @@ public void GroupByMixedInput() var s = package.Workbook.Worksheets.Add("test"); s.Cells["A1"].Value = "Joe"; s.Cells["A2"].Value = "Anna"; - s.Cells["A3"].Value = "NA()"; + s.Cells["A3"].Value = ErrorValues.NAError; s.Cells["A4"].Value = false; s.Cells["B1"].Value = 1; s.Cells["B2"].Value = 2; @@ -70,5 +99,116 @@ public void GroupByMixedInput() Assert.AreEqual(7d, s.Cells["D5"].Value); } } + + [TestMethod] + public void GroupByFieldHeaders() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "A"; + s.Cells["A2"].Value = "B"; + s.Cells["A3"].Value = "A"; + s.Cells["A4"].Value = "A"; + s.Cells["A5"].Value = "B"; + s.Cells["B1"].Value = "X"; + s.Cells["B2"].Value = "X"; + s.Cells["B3"].Value = "Y"; + s.Cells["B4"].Value = "Y"; + s.Cells["B5"].Value = "Y"; + + s.Cells["C1"].Value = 1; + s.Cells["C2"].Value = 1; + s.Cells["C3"].Value = 2; + s.Cells["C4"].Value = 1; + s.Cells["C5"].Value = 1; + + s.Cells["D1"].Formula = "GROUPBY(A1:B5, C1:C5, _xleta.SUM,,2)"; + s.Calculate(); + + Assert.AreEqual("A", s.Cells["D1"].Value); + Assert.AreEqual("X", s.Cells["E1"].Value); + Assert.AreEqual(1d, s.Cells["F1"].Value); + + Assert.AreEqual("A", s.Cells["D2"].Value); + Assert.AreEqual("Y", s.Cells["E2"].Value); + Assert.AreEqual(3d, s.Cells["F2"].Value); + // Subtotal row + Assert.AreEqual("A", s.Cells["D3"].Value); + Assert.AreEqual(4d, s.Cells["F3"].Value); + + Assert.AreEqual("B", s.Cells["D4"].Value); + Assert.AreEqual("X", s.Cells["E4"].Value); + Assert.AreEqual(1d, s.Cells["F4"].Value); + Assert.AreEqual("B", s.Cells["D5"].Value); + Assert.AreEqual("Y", s.Cells["E5"].Value); + Assert.AreEqual(1d, s.Cells["F5"].Value); + + Assert.AreEqual("B", s.Cells["D6"].Value); + Assert.AreEqual(2d, s.Cells["F6"].Value); + + Assert.AreEqual("Grand Total", s.Cells["D7"].Value); + Assert.AreEqual(6d, s.Cells["F7"].Value); + } + } + + [TestMethod] + public void GroupByFilteredArray() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "Anna"; + s.Cells["A2"].Value = "Joe"; + s.Cells["A3"].Value = "Bertil"; + s.Cells["A4"].Value = "ANNA"; + s.Cells["A5"].Value = "Anna"; + s.Cells["A6"].Value = "Bertil"; + s.Cells["A7"].Value = "Anna"; + s.Cells["A8"].Value = "Joe"; + + s.Cells["B1"].Value = 1; + s.Cells["B2"].Value = 1; + s.Cells["B3"].Value = 1; + + s.Cells["B4"].Value = 2; + s.Cells["B5"].Value = 2; + s.Cells["B7"].Value = 3; + + s.Cells["C1"].Formula = "GROUPBY(A1:A8, B1:B8, _xleta.SUM,,,,A1:A8 =\"ANNA\")"; + s.Calculate(); + Assert.AreEqual(s.Cells["C1"].Value, "Anna"); + Assert.AreEqual(8d, s.Cells["D1"].Value); + } + } + [TestMethod] + public void GroupByFieldRelationship() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = new DateTime(2025, 01, 01); + s.Cells["A2"].Value = new DateTime(2025, 02, 01); + s.Cells["A3"].Value = new DateTime(2025, 03, 01); + s.Cells["A4"].Value = new DateTime(2025, 03, 01); + s.Cells["A5"].Value = new DateTime(2025, 01, 01); + + s.Cells["B1"].Value = 30; + s.Cells["B2"].Value = 20; + s.Cells["B3"].Value = 54; + s.Cells["B4"].Value = 54; + s.Cells["B5"].Value = 23; + + s.Cells["C1"].Formula = "=CHOOSECOLS(GROUPBY(HSTACK(CHOOSE(MONTH(A1:A5),\"Jan\",\"Feb\",\"Mar\",\"Apr\",\"Maj\",\"Jun\",\"Jul\",\"Aug\",\"Sep\",\"Okt\",\"Nov\",\"Dec\"), MONTH(A1:A5) ), B1:B5, _xleta.SUM,,,2,,1 ); {1;3} )"; + s.Calculate(); + Assert.AreEqual("Jan", s.Cells["C1"].Value); + Assert.AreEqual(53d, s.Cells["D1"].Value); + Assert.AreEqual("Feb", s.Cells["C2"].Value); + Assert.AreEqual(20d, s.Cells["D2"].Value); + Assert.AreEqual("Mar", s.Cells["C3"].Value); + Assert.AreEqual(108d, s.Cells["D3"].Value); + // Det ska inte gå att ha med subtotaler, subtotals are not supported + } + } } } From c0e30454b325b095901d4275983ca28897513477 Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Wed, 18 Mar 2026 15:14:41 +0100 Subject: [PATCH 03/14] WIP --- .../Excel/Functions/RefAndLookup/Groupby.cs | 7 +- .../Functions/RefAndLookup/GroupByTests.cs | 114 +++++++++++++++++- 2 files changed, 117 insertions(+), 4 deletions(-) diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs index 96c3b4c0e..5950a359e 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs @@ -142,7 +142,9 @@ private bool TryParseArgs( // total_depth (optional, default = GrandTotals) if (arguments.Count > 4 && arguments[4].Value != null) { - if (!Enum.IsDefined(typeof(TotalDepth), Convert.ToInt32(arguments[4].Value))) + if (!Enum.IsDefined(typeof(TotalDepth), Convert.ToInt32(arguments[4].Value)) + || args.RowFields.Size.NumberOfCols > Convert.ToInt32(arguments[4].Value) + || args.RowFields.Size.NumberOfCols * -1 < Convert.ToInt32(arguments[4].Value) * -1 ) { error = CompileResult.GetErrorResult(eErrorType.Value); return false; @@ -161,7 +163,8 @@ private bool TryParseArgs( // field_relationship (optional) if (arguments.Count > 7 && arguments[7].Value != null) { - if (!Enum.IsDefined(typeof(FieldRelationship), Convert.ToInt32(arguments[7].Value))) + if (!Enum.IsDefined(typeof(FieldRelationship), Convert.ToInt32(arguments[7].Value)) + || args.TotalDepth == TotalDepth.GrandAndSubtotals || args.TotalDepth == TotalDepth.GrandAndSubtotalsAtTop) { error = CompileResult.GetErrorResult(eErrorType.Value); return false; diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs index 7aa336b3c..df98d8066 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs @@ -1,6 +1,8 @@ using FakeItEasy.Configuration; using Microsoft.VisualStudio.TestTools.UnitTesting; using OfficeOpenXml; +using OfficeOpenXml.Drawing.Chart; +using OfficeOpenXml.Drawing.Chart.ChartEx; using OfficeOpenXml.FormulaParsing.Excel.Functions.Information; using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup; using System; @@ -181,6 +183,7 @@ public void GroupByFilteredArray() Assert.AreEqual(8d, s.Cells["D1"].Value); } } + [TestMethod] public void GroupByFieldRelationship() { @@ -199,7 +202,37 @@ public void GroupByFieldRelationship() s.Cells["B4"].Value = 54; s.Cells["B5"].Value = 23; - s.Cells["C1"].Formula = "=CHOOSECOLS(GROUPBY(HSTACK(CHOOSE(MONTH(A1:A5),\"Jan\",\"Feb\",\"Mar\",\"Apr\",\"Maj\",\"Jun\",\"Jul\",\"Aug\",\"Sep\",\"Okt\",\"Nov\",\"Dec\"), MONTH(A1:A5) ), B1:B5, _xleta.SUM,,,2,,1 ); {1;3} )"; + s.Cells["C1"].Formula = "=GROUPBY(HSTACK(CHOOSE(MONTH(A1:A5),\"Jan\",\"Feb\",\"Mar\",\"Apr\",\"Maj\",\"Jun\",\"Jul\",\"Aug\",\"Sep\",\"Okt\",\"Nov\",\"Dec\"), MONTH(A1:A5) ), B1:B5, _xleta.SUM,,,2,,1 )"; + s.Calculate(); + Assert.AreEqual("Jan", s.Cells["C1"].Value); + Assert.AreEqual(53d, s.Cells["E1"].Value); + Assert.AreEqual("Feb", s.Cells["C2"].Value); + Assert.AreEqual(20d, s.Cells["E2"].Value); + Assert.AreEqual("Mar", s.Cells["C3"].Value); + Assert.AreEqual(108d, s.Cells["E3"].Value); + // Det ska inte gå att ha med subtotaler, subtotals are not supported + } + } + + [TestMethod] + public void GroupByFieldRelationship2() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = new DateTime(2025, 01, 01); + s.Cells["A2"].Value = new DateTime(2025, 02, 01); + s.Cells["A3"].Value = new DateTime(2025, 03, 01); + s.Cells["A4"].Value = new DateTime(2025, 03, 01); + s.Cells["A5"].Value = new DateTime(2025, 01, 01); + + s.Cells["B1"].Value = 30; + s.Cells["B2"].Value = 20; + s.Cells["B3"].Value = 54; + s.Cells["B4"].Value = 54; + s.Cells["B5"].Value = 23; + + s.Cells["C1"].Formula = "=CHOOSECOLS(GROUPBY(HSTACK(CHOOSE(MONTH(A1:A5),\"Jan\",\"Feb\",\"Mar\",\"Apr\",\"Maj\",\"Jun\",\"Jul\",\"Aug\",\"Sep\",\"Okt\",\"Nov\",\"Dec\"), MONTH(A1:A5) ), B1:B5, _xleta.SUM,,,2,,1) ,{1,3})"; s.Calculate(); Assert.AreEqual("Jan", s.Cells["C1"].Value); Assert.AreEqual(53d, s.Cells["D1"].Value); @@ -207,7 +240,84 @@ public void GroupByFieldRelationship() Assert.AreEqual(20d, s.Cells["D2"].Value); Assert.AreEqual("Mar", s.Cells["C3"].Value); Assert.AreEqual(108d, s.Cells["D3"].Value); - // Det ska inte gå att ha med subtotaler, subtotals are not supported + } + // Det ska inte gå att ha med subtotaler, subtotals are not supported + } + [TestMethod] + public void GroupBy_NoTotals_ShouldNotIncludeTotalRow() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "Joe"; + s.Cells["A2"].Value = "Anna"; + s.Cells["A3"].Value = "Bertil"; + s.Cells["A4"].Value = "Joe"; + s.Cells["B1"].Value = 1; + s.Cells["B2"].Value = 2; + s.Cells["B3"].Value = 3; + s.Cells["B4"].Value = 0; + + // fieldSettings = 0 stänger av totalsraden + s.Cells["C1"].Formula = "GROUPBY(A1:A4, B1:B4, _xleta.SUM,, 0)"; + s.Calculate(); + + Assert.AreEqual("Anna", s.Cells["C1"].Value); + Assert.AreEqual("Bertil", s.Cells["C2"].Value); + Assert.AreEqual("Joe", s.Cells["C3"].Value); + Assert.AreEqual(2d, s.Cells["D1"].Value); + Assert.AreEqual(3d, s.Cells["D2"].Value); + Assert.AreEqual(1d, s.Cells["D3"].Value); + + // C4 ska vara tom – ingen totalsrad när fieldSettings = 0 + Assert.AreNotEqual(s.Cells["C4"].Value, "Total"); + Assert.AreNotEqual(s.Cells["D4"].Value, 0d); + } + } + + [TestMethod] + public void GroupByMEGATESTWOWOWOWOOWOW() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "Stockholm"; + s.Cells["A2"].Value = "Stockholm"; + s.Cells["A3"].Value = "Göteborg"; + s.Cells["A4"].Value = "Linköping"; + s.Cells["A5"].Value = "Linköping"; + s.Cells["A6"].Value = "Göteborg"; + s.Cells["A7"].Value = "Stockholm"; + + s.Cells["B1"].Value = "Cykel"; + s.Cells["B2"].Value = "Boll"; + s.Cells["B3"].Value = "Fisk"; + s.Cells["B4"].Value = "Bomb"; + s.Cells["B5"].Value = "Bok"; + s.Cells["B6"].Value = "Boll"; + s.Cells["B7"].Value = "Fisk"; + + s.Cells["C1"].Value = "Vällingby"; + s.Cells["C2"].Value = "Vällingby"; + s.Cells["C3"].Value = "Majorna"; + s.Cells["C4"].Value = "Skäggetorp"; + s.Cells["C5"].Value = "Tornby"; + s.Cells["C6"].Value = "Majorna"; + s.Cells["C7"].Value = "Vällingby"; + + s.Cells["D1"].Value = 1000; + s.Cells["D2"].Value = 300; + s.Cells["D3"].Value = 200; + s.Cells["D4"].Value = 3000; + s.Cells["D5"].Value = 700; + s.Cells["D6"].Value = 300; + s.Cells["D7"].Value = 300; + + s.Cells["E1"].Formula = "GROUPBY(A1:C7, D1:D7, _xleta.SUM,3,2,-1,,,0)"; + s.Calculate(); + + Assert.AreEqual("Stockholm", s.Cells["E1"].Value); + Assert.AreEqual("Grand Total", s.Cells["E17"].Value); } } } From e5740a5bd3aa06aa8f5bb691ea910a251ac5d6bb Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Thu, 26 Mar 2026 10:01:05 +0100 Subject: [PATCH 04/14] WIP --- .../Excel/Functions/RefAndLookup/Groupby.cs | 514 ++++++------------ .../RefAndLookup/GroupbyFunctionBase.cs | 303 +++++++++++ .../Excel/Functions/RefAndLookup/Hstack.cs | 2 + .../Functions/RefAndLookup/GroupByTests.cs | 174 ++++-- 4 files changed, 605 insertions(+), 388 deletions(-) create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupbyFunctionBase.cs diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs index 5950a359e..bb489b60e 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs @@ -1,4 +1,17 @@ -using OfficeOpenXml.FormulaParsing.Excel.Functions.Metadata; +/************************************************************************************************* + 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 + ************************************************************************************************* + 19/3/2026 EPPlus Software AB EPPlus v8.6 + *************************************************************************************************/ + +using OfficeOpenXml.FormulaParsing.Excel.Functions.Metadata; using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.LookupUtils; using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.Sorting; using OfficeOpenXml.FormulaParsing.FormulaExpressions; @@ -17,321 +30,51 @@ namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup EPPlusVersion = "", Description = "Allows you to create a summary of your data via a formula. Supports grouping along one axis and aggregating the associated values.")] - internal class Groupby : ExcelFunction + internal class Groupby : GroupbyFunctionBase { public override string NamespacePrefix => "_xlfn."; public override bool ExecutesLambda => true; public override int ArgumentMinLength => 3; - private readonly LookupComparerBase _comparer = new SortByComparer(); - private enum FieldHeaders - { - Missing = -1, // Default - No = 0, - YesAndDontShow = 1, - NoButGenerate = 2, - YesAndShow = 3 - } - private enum TotalDepth - { - Missing = -3, - GrandAndSubtotalsAtTop = -2, - GrandTotalsAtTop = -1, - NoTotals = 0, - GrandTotals = 1, - GrandAndSubtotals = 2, - } - private enum FieldRelationship - { - Hierarchy = 0, - Table = 1 - } - private class GroupbyArgs - { - public IRangeInfo RowFields { get; set; } - public IRangeInfo Values { get; set; } - public LambdaCalculator Function { get; set; } - public FieldHeaders Headers { get; set; } = FieldHeaders.Missing; // Default - public TotalDepth TotalDepth { get; set; } = TotalDepth.GrandTotals; // Default - public int SortOrder { get; set; } = 1; // Default is first column ascending. - public IRangeInfo FilterArray { get; set; } = null; - public FieldRelationship FieldRelationship { get; set; } = FieldRelationship.Hierarchy; - } - - /// Represents one level, with the topkey that represents the key in the first column. - private class GroupLevel - { - public object TopKey { get; set; } // First column value - public List Rows { get; set; } = new List(); - public object SubtotalValue { get; set; } - } - - /// Represents one group key with its collected values. - private class GroupRow - { - public string Key { get; set; } - public object[] KeyParts { get; set; } - public List Values { get; set; } = new List(); - public object AggregatedValue { get; set; } - } public override CompileResult Execute(IList arguments, ParsingContext context) { - if (!TryParseArgs(arguments, context, out var args, out var error)) + if (!TryParseBaseArgs(arguments, out var args, out var error)) return error; var groups = BuildGroups(args, context); - groups = ApplySort(groups, args, args.SortOrder); - var result = BuildResult(groups, args); + groups = ApplySort(groups, args); + var result = BuildResult(groups, args, context); return CreateDynamicArrayResult(result, DataType.ExcelRange); - } - - private bool TryParseArgs( - IList arguments, - ParsingContext context, - out GroupbyArgs args, - out CompileResult error) - { - args = new GroupbyArgs(); - error = null; - - if (!arguments[0].IsExcelRange) - { - error = CompileResult.GetErrorResult(eErrorType.Value); - return false; - } - args.RowFields = arguments[0].ValueAsRangeInfo; - - if (!arguments[1].IsExcelRange) - { - error = CompileResult.GetErrorResult(eErrorType.Value); - return false; - } - args.Values = arguments[1].ValueAsRangeInfo; - - // Validate that row_fields and values have the same number of rows - if (args.RowFields.Size.NumberOfRows != args.Values.Size.NumberOfRows) - { - error = CompileResult.GetErrorResult(eErrorType.Value); - return false; - } - - // function (required) – resolve the aggregation function by name - if (arguments[2].DataType != DataType.LambdaCalculation) - { - error = CompileResult.GetErrorResult(eErrorType.Value); - return false; - } - var calculator = arguments[2].Value as LambdaCalculator; - args.Function = calculator; if (args.Function == null) - { - error = CompileResult.GetErrorResult(eErrorType.Value); - return false; - } - - // field_headers (optional, default = HasHeadersAndShow) - if (arguments.Count > 3 && arguments[3].Value != null) - { - if (!Enum.IsDefined(typeof(FieldHeaders), Convert.ToInt32(arguments[3].Value))) - { - error = CompileResult.GetErrorResult(eErrorType.Value); - return false; - } - args.Headers = (FieldHeaders)Convert.ToInt32(arguments[3].Value); - } - - // total_depth (optional, default = GrandTotals) - if (arguments.Count > 4 && arguments[4].Value != null) - { - if (!Enum.IsDefined(typeof(TotalDepth), Convert.ToInt32(arguments[4].Value)) - || args.RowFields.Size.NumberOfCols > Convert.ToInt32(arguments[4].Value) - || args.RowFields.Size.NumberOfCols * -1 < Convert.ToInt32(arguments[4].Value) * -1 ) - { - error = CompileResult.GetErrorResult(eErrorType.Value); - return false; - } - args.TotalDepth = (TotalDepth)Convert.ToInt32(arguments[4].Value); - } - - // sort_order (optional, default = 0 / no sort) - if (arguments.Count > 5 && arguments[5].Value != null) - args.SortOrder = Convert.ToInt32(arguments[5].Value); - - // 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) - { - if (!Enum.IsDefined(typeof(FieldRelationship), Convert.ToInt32(arguments[7].Value)) - || args.TotalDepth == TotalDepth.GrandAndSubtotals || args.TotalDepth == TotalDepth.GrandAndSubtotalsAtTop) - { - error = CompileResult.GetErrorResult(eErrorType.Value); - return false; - } - args.FieldRelationship = (FieldRelationship)Convert.ToInt32(arguments[7].Value); - } - - return true; - } - - private FieldHeaders ResolveHeaders (GroupbyArgs args) - { - if (args.Headers != FieldHeaders.Missing) - return args.Headers; - - if (args.Values.Size.NumberOfRows < 2) - return FieldHeaders.No; - - var first = args.Values.GetValue(0, 0); - var second = args.Values.GetValue(1, 0); - - bool firstIsText = first is string; - bool secondIsNumber = second is double || second is int || second is long || second is float; - - return firstIsText && secondIsNumber - ? FieldHeaders.YesAndDontShow - : FieldHeaders.No; - } - - private List BuildGroups(GroupbyArgs args, ParsingContext context) - { - var resolvedHeaders = ResolveHeaders(args); - bool hasHeaders = resolvedHeaders == FieldHeaders.YesAndShow - || resolvedHeaders == FieldHeaders.YesAndDontShow; - int startRow = hasHeaders ? 1 : 0; - - var levelDict = new Dictionary(StringComparer.OrdinalIgnoreCase); - var levelOrder = new List(); - var rowDict = new Dictionary(StringComparer.OrdinalIgnoreCase); - - for (int r = startRow; r < args.RowFields.Size.NumberOfRows; r++) - { - // Apply filter_array if present - if (args.FilterArray != null) - { - var filterVal = args.FilterArray.GetOffset(r, 0); - if (filterVal is bool b && !b) continue; - if (filterVal is int i && i == 0) continue; - } - - // Build composite key from all columns in RowFields - int nKeyCols = args.RowFields.Size.NumberOfCols; - var keyParts = new object[nKeyCols]; - for (int c = 0; c < nKeyCols; c++) - keyParts[c] = args.RowFields.GetOffset(r, c); - - var keyStrings = new string[keyParts.Length]; - for (int c = 0; c < keyParts.Length; c++) - keyStrings[c] = keyParts[c]?.ToString() ?? string.Empty; - var key = string.Join("|", keyStrings); - - // Top-level key is always the first column - var topKeyStr = keyStrings[0]; - - // Collect all columns from values range for this row - int nCols = args.Values.Size.NumberOfCols; - var rowVals = new object[nCols]; - for (int c = 0; c < nCols; c++) - rowVals[c] = args.Values.GetOffset(r, c); - - // Create GroupLevel if needed - if (!levelDict.TryGetValue(topKeyStr, out var level)) - { - level = new GroupLevel { TopKey = keyParts[0] }; - levelDict[topKeyStr] = level; - levelOrder.Add(topKeyStr); - } - - // Create GroupRow if needed - if (!rowDict.TryGetValue(key, out var group)) - { - group = new GroupRow { Key = key, KeyParts = keyParts }; - rowDict[key] = group; - level.Rows.Add(group); - } - group.Values.Add(rowVals); - } - - // Aggregate each GroupRow and each GroupLevel's subtotal - var levels = levelOrder.Select(k => levelDict[k]).ToList(); - foreach (var level in levels) - { - foreach (var row in level.Rows) - row.AggregatedValue = Aggregate(args.Function, row.Values, context); - - // Subtotal = aggregate of all values in this level - var allLevelValues = level.Rows.SelectMany(r => r.Values).ToList(); - level.SubtotalValue = Aggregate(args.Function, allLevelValues, context); - } - - return levels; - } - - /// - /// Aggregates a group's values by passing them as an in-memory range - /// to the LambdaCalculator, mirroring the pattern used in Map. - /// - private object Aggregate(LambdaCalculator calculator, List values, ParsingContext context) - { - int nRows = values.Count; - int nCols = values.Count > 0 ? values[0].Length : 1; - - var range = new InMemoryRange(nRows, (short)nCols); - for (int row = 0; row < nRows; row++) - for (int col = 0; col < nCols; col++) - range.SetValue(row, col, values[row][col]); - - calculator.BeginCalculation(); - calculator.SetVariableValue(0, range, DataType.ExcelRange, context); - var result = calculator.Execute(context); - return result.ResultValue; - } + } // ------------------------------------------------------- // Sorting // ------------------------------------------------------- - private List ApplySort(List levels, GroupbyArgs args, int sortOrder) + private List ApplySort(List levels, GroupByBaseArgs args, int depth = 1) { - if (sortOrder == 0) return levels; - - bool desc = sortOrder < 0; - int col = Math.Abs(sortOrder); - bool sortOnAggregated = col > args.RowFields.Size.NumberOfRows; - - //// Sort levels by TopKey or subtotal - //var sortedLevels = col == 1 - // ? (desc ? levels.OrderByDescending(l => l.TopKey as IComparable, _comparer).ToList() - // : levels.OrderBy(l => l.TopKey as IComparable, _comparer).ToList()) - // : (desc ? levels.OrderByDescending(l => l.SubtotalValue as IComparable, _comparer).ToList() - // : levels.OrderBy(l => l.SubtotalValue as IComparable, _comparer).ToList()); - - //// Sort rows within each level by their KeyParts or aggregated value - //foreach (var level in sortedLevels) - //{ - // level.Rows = col == 1 - // ? (desc ? level.Rows.OrderByDescending(r => r.KeyParts[0] as IComparable, _comparer).ToList() - // : level.Rows.OrderBy(r => r.KeyParts[0] as IComparable, _comparer).ToList()) - // : (desc ? level.Rows.OrderByDescending(r => r.AggregatedValue as IComparable, _comparer).ToList() - // : level.Rows.OrderBy(r => r.AggregatedValue as IComparable, _comparer).ToList()); - //} - - //return sortedLevels; + if (args.SortOrder == 0) return levels; + + bool desc = args.SortOrder < 0; + int col = Math.Abs(args.SortOrder); + bool sortOnAggregated = col > args.RowFields.Size.NumberOfCols; + + // desc only applies at the depth that matches the sort column + bool descThisLevel = desc && depth == col; + if (args.FieldRelationship == FieldRelationship.Table) { - // Table: sort all GroupRows globally and independently, rebuild levels - var allRows = levels.SelectMany(l => l.Rows).ToList(); + // Collect all leaf rows recursively + var allRows = levels.SelectMany(l => CollectLeafRows(l)).ToList(); allRows = SortRows(allRows, col, desc, sortOnAggregated); - // Rebuild levels in the new row order var newLevelDict = new Dictionary(); var newLevelOrder = new List(); foreach (var row in allRows) { - var topKey = row.KeyParts[0]?.ToString() ?? string.Empty; + var topKey = (row.KeyParts[0]?.ToString() ?? string.Empty).ToLowerInvariant(); if (!newLevelDict.TryGetValue(topKey, out var level)) { - level = new GroupLevel { TopKey = row.KeyParts[0] }; + level = new GroupLevel { Key = row.KeyParts[0] }; newLevelDict[topKey] = level; newLevelOrder.Add(topKey); } @@ -341,58 +84,69 @@ private List ApplySort(List levels, GroupbyArgs args, in } else { - // Hierarchy: sort levels on TopKey or aggregated, then sort rows within each level levels = sortOnAggregated - ? (desc ? levels.OrderByDescending(l => l.SubtotalValue as IComparable).ToList() - : levels.OrderBy(l => l.SubtotalValue as IComparable).ToList()) - : (desc ? levels.OrderByDescending(l => l.TopKey as IComparable).ToList() - : levels.OrderBy(l => l.TopKey as IComparable).ToList()); + ? (descThisLevel ? levels.OrderByDescending(l => l.SubtotalValue as IComparable, _comparer).ToList() + : levels.OrderBy(l => l.SubtotalValue as IComparable, _comparer).ToList()) + : (descThisLevel ? levels.OrderByDescending(l => l.Key as IComparable, _comparer).ToList() + : levels.OrderBy(l => l.Key as IComparable, _comparer).ToList()); - // Sort rows within each level foreach (var level in levels) - level.Rows = SortRows(level.Rows, col, desc, sortOnAggregated); + { + if (!level.IsLeaf) + level.Children = ApplySort(level.Children, args, depth + 1); + else + level.Rows = SortRows(level.Rows, depth, depth == col ? desc : false, sortOnAggregated); + } return levels; } } + private IEnumerable CollectLeafRows(GroupLevel level) + { + if (level.IsLeaf) + return level.Rows; + return level.Children.SelectMany(c => CollectLeafRows(c)); + } + private List SortRows(List rows, int col, bool desc, bool sortOnAggregated) { + if (rows == null || rows.Count == 0) return rows; + if (sortOnAggregated) { - return desc ? rows.OrderByDescending(r => r.AggregatedValue as IComparable).ToList() - : rows.OrderBy(r => r.AggregatedValue as IComparable).ToList(); + return desc ? rows.OrderByDescending(r => r.AggregatedValue as IComparable, _comparer).ToList() + : rows.OrderBy(r => r.AggregatedValue as IComparable, _comparer).ToList(); } // col is 1-based, KeyParts is 0-based int keyIndex = Math.Min(col - 1, rows[0].KeyParts.Length - 1); - return desc ? rows.OrderByDescending(r => r.KeyParts[keyIndex] as IComparable).ToList() - : rows.OrderBy(r => r.KeyParts[keyIndex] as IComparable).ToList(); + return desc ? rows.OrderByDescending(r => r.KeyParts[keyIndex] as IComparable, _comparer).ToList() + : rows.OrderBy(r => r.KeyParts[keyIndex] as IComparable, _comparer).ToList(); } + // ------------------------------------------------------- // Build result // ------------------------------------------------------- - private InMemoryRange BuildResult(List levels, GroupbyArgs args) + private InMemoryRange BuildResult(List levels, GroupByBaseArgs args, ParsingContext context) { var resolvedHeaders = ResolveHeaders(args); - bool showHeaders = args.Headers == FieldHeaders.YesAndShow - || args.Headers == FieldHeaders.NoButGenerate; - bool totalsAtEnd = args.TotalDepth == TotalDepth.GrandTotals - || args.TotalDepth == TotalDepth.GrandAndSubtotals; - bool totalsAtTop = args.TotalDepth == TotalDepth.GrandTotalsAtTop - || args.TotalDepth == TotalDepth.GrandAndSubtotalsAtTop; - bool showTotals = args.TotalDepth != TotalDepth.NoTotals; - bool showSubtotals = args.TotalDepth == TotalDepth.GrandAndSubtotals - || args.TotalDepth == TotalDepth.GrandAndSubtotalsAtTop; + bool showHeaders = resolvedHeaders == FieldHeaders.YesAndShow + || resolvedHeaders == FieldHeaders.NoButGenerate; + bool showTotals = args.TotalDepth != TotalDepthNoTotals; + bool totalsAtTop = args.TotalDepth < 0; + bool totalsAtEnd = args.TotalDepth > 0; + int subtotalDepth = Math.Abs(args.TotalDepth); + bool showSubtotals = subtotalDepth > 1; + bool grandAndSub = subtotalDepth > 1; int nKeyCols = args.RowFields.Size.NumberOfCols; int nValCols = args.Values.Size.NumberOfCols; int nCols = nKeyCols + nValCols; - // Calculate total number of rows needed - int dataRows = levels.Sum(l => l.Rows.Count); - int subtotalRows = showSubtotals ? levels.Count : 0; + int dataRows = CountDataRows(levels); + int subtotalRows = showSubtotals ? CountSubtotalRows(levels, subtotalDepth, 1) : 0; int totalRows = dataRows + subtotalRows + (showHeaders ? 1 : 0) + (showTotals ? 1 : 0); @@ -400,7 +154,6 @@ private InMemoryRange BuildResult(List levels, GroupbyArgs args) var result = new InMemoryRange(totalRows, (short)nCols); int r = 0; - // Header row if (showHeaders) { for (int c = 0; c < nKeyCols; c++) @@ -414,62 +167,111 @@ private InMemoryRange BuildResult(List levels, GroupbyArgs args) r++; } - string totalString; - if (args.TotalDepth == TotalDepth.GrandAndSubtotalsAtTop || args.TotalDepth == TotalDepth.GrandAndSubtotals) - { - totalString = "Grand Total"; - } - else - { - totalString = "Total"; - } - // Grand total at top + string grandTotalStr = grandAndSub ? "Grand Total" : "Total"; + if (totalsAtTop && showTotals) - { - result.SetValue(r, 0, totalString); - result.SetValue(r, nKeyCols, levels.SelectMany(l => l.Rows).Sum(row => Convert.ToDouble(row.AggregatedValue))); - r++; - } + r = WriteGrandTotal(result, r, levels, grandTotalStr, nKeyCols, nValCols, args, context); + + r = WriteRows(result, r, levels, subtotalDepth, totalsAtTop, nKeyCols, nValCols, depth: 1); + + if (totalsAtEnd && showTotals) + WriteGrandTotal(result, r, levels, grandTotalStr, nKeyCols, nValCols, args, context); + + return result; + } - // Data rows and subtotals + private int WriteRows( + InMemoryRange result, int r, + List levels, + int subtotalDepth, bool subtotalsAtTop, + int nKeyCols, int nValCols, + int depth) + { foreach (var level in levels) { - foreach (var row in level.Rows) + bool writeSubtotal = subtotalDepth > 1 && depth <= subtotalDepth - 1; + + // Subtotal at top of this level + if (writeSubtotal && subtotalsAtTop) + r = WriteSubtotal(result, r, level, nKeyCols, nValCols); + + if (level.IsLeaf) { - for (int c = 0; c < nKeyCols; c++) - result.SetValue(r, c, row.KeyParts[c]); - result.SetValue(r, nKeyCols, row.AggregatedValue); - r++; + // Write leaf GroupRows + foreach (var row in level.Rows) + { + for (int c = 0; c < nKeyCols; c++) + result.SetValue(r, c, row.KeyParts[c]); + for (int c = 0; c < nValCols; c++) + result.SetValue(r, nKeyCols + c, row.AggregatedValue); + r++; + } } - - if (showSubtotals) + else { - result.SetValue(r, 0, level.TopKey); - result.SetValue(r, nKeyCols, level.SubtotalValue); - r++; + // Recurse into children + r = WriteRows(result, r, level.Children, subtotalDepth, subtotalsAtTop, nKeyCols, nValCols, depth + 1); } - } - // Grand total at bottom - if (totalsAtEnd && showTotals) - { - result.SetValue(r, 0, totalString); - result.SetValue(r, nKeyCols, levels.SelectMany(l => l.Rows).Sum(row => Convert.ToDouble(row.AggregatedValue))); + // Subtotal at bottom of this level + if (writeSubtotal && !subtotalsAtTop) + r = WriteSubtotal(result, r, level, nKeyCols, nValCols); } + return r; + } - return result; + private int CountDataRows(List levels) + { + int count = 0; + foreach (var level in levels) + count += level.IsLeaf + ? level.Rows.Count + : CountDataRows(level.Children); + return count; + } + + private int CountSubtotalRows(List levels, int subtotalDepth, int depth) + { + if (depth >= subtotalDepth) return 0; + int count = levels.Count; + foreach (var level in levels) + if (!level.IsLeaf) + count += CountSubtotalRows(level.Children, subtotalDepth, depth + 1); + return count; + } + + private int WriteSubtotal(InMemoryRange result, int r, GroupLevel level, int nKeyCols, int nValCols) + { + result.SetValue(r, 0, level.Key); + for (int c = 1; c < nKeyCols; c++) + result.SetValue(r, c, string.Empty); + result.SetValue(r, nKeyCols, level.SubtotalValue); + for (int c = 1; c < nValCols; c++) + result.SetValue(r, nKeyCols + c, string.Empty); + return r + 1; + } + + private int WriteGrandTotal(InMemoryRange result, int r, List levels, string label, int nKeyCols, int nValCols, GroupByBaseArgs args, ParsingContext context) + { + result.SetValue(r, 0, label); + for (int c = 1; c < nKeyCols; c++) + result.SetValue(r, c, string.Empty); + + var allValues = levels.SelectMany(l => GetAllValues(l)).ToList(); + result.SetValue(r, nKeyCols, Aggregate(args.Function, args.AllValuesInOrder, context)); + + for (int c = 1; c < nValCols; c++) + result.SetValue(r, nKeyCols + c, string.Empty); + return r + 1; } - /// Sums aggregated values where they are numeric. - private object SumAggregated(List groups) + /// Recursively collects all AggregatedValues from leaf GroupRows. + private IEnumerable CollectAggregatedValues(GroupLevel level) { - var nums = groups - .Select(g => g.AggregatedValue) - .Where(v => v is IConvertible && !(v is string)) - .Select(v => Convert.ToDouble(v)) - .ToList(); + if (level.IsLeaf) + return level.Rows.Select(r => r.AggregatedValue); - return nums.Any() ? (object)nums.Sum() : string.Empty; + return level.Children.SelectMany(c => CollectAggregatedValues(c)); } } } diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupbyFunctionBase.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupbyFunctionBase.cs new file mode 100644 index 000000000..aab617e9c --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupbyFunctionBase.cs @@ -0,0 +1,303 @@ +using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.LookupUtils; +using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.Sorting; +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 +{ + internal abstract class GroupbyFunctionBase : ExcelFunction + { + protected readonly LookupComparerBase _comparer = new SortByComparer(); + + // ------------------------------------------------------- + // Enums & Constants + // ------------------------------------------------------- + protected enum FieldHeaders + { + Missing = -1, + No = 0, + YesAndDontShow = 1, + NoButGenerate = 2, + YesAndShow = 3 + } + + protected const int TotalDepthNoTotals = 0; + protected const int TotalDepthGrandOnly = 1; + + protected enum FieldRelationship + { + Hierarchy = 0, + Table = 1 + } + + // ------------------------------------------------------- + // Shared argument container + // ------------------------------------------------------- + protected class GroupByBaseArgs + { + public IRangeInfo RowFields { get; set; } + public IRangeInfo Values { get; set; } + public LambdaCalculator Function { get; set; } + public FieldHeaders Headers { get; set; } = FieldHeaders.Missing; + public int TotalDepth { get; set; } = 1; + public int SortOrder { get; set; } = 1; + public IRangeInfo FilterArray { get; set; } = null; + public FieldRelationship FieldRelationship { get; set; } = FieldRelationship.Hierarchy; + public List AllValuesInOrder { get; set; } = new List(); + } + + // ------------------------------------------------------- + // Shared data structures + // ------------------------------------------------------- + protected class GroupLevel + { + public object Key { get; set; } + public List Children { get; set; } = new List(); + public Dictionary ChildDict { get; set; } = null; + public List ChildOrder { get; set; } = null; + public List Rows { get; set; } = new List(); + public object SubtotalValue { get; set; } + public bool IsLeaf => Children.Count == 0; + } + + protected class GroupRow + { + public object[] KeyParts { get; set; } + public List Values { get; set; } = new List(); + public object AggregatedValue { get; set; } + } + + // ------------------------------------------------------- + // Argument parsing (shared arguments 1-8) + // ------------------------------------------------------- + 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); + + if (arguments[2].DataType != DataType.LambdaCalculation) + return Fail(eErrorType.Value, out error); + args.Function = arguments[2].Value as LambdaCalculator; + if (args.Function == null) + 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; + } + + // 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; + } + + // sort_order (optional) + if (arguments.Count > 5 && arguments[5].Value != null) + args.SortOrder = Convert.ToInt32(arguments[5].Value); + + // 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) + { + 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 true; + } + + protected bool Fail(eErrorType err, out CompileResult error) + { + error = CompileResult.GetErrorResult(err); + return false; + } + + // ------------------------------------------------------- + // Header resolution + // ------------------------------------------------------- + protected FieldHeaders ResolveHeaders(GroupByBaseArgs args) + { + if (args.Headers != FieldHeaders.Missing) + return args.Headers; + + if (args.Values.Size.NumberOfRows < 2) + return FieldHeaders.No; + + var first = args.Values.GetValue(0, 0); + var second = args.Values.GetValue(1, 0); + + bool firstIsText = first is string; + bool secondIsNumber = second is double || second is int || second is long || second is float; + + return firstIsText && secondIsNumber + ? FieldHeaders.YesAndDontShow + : FieldHeaders.No; + } + + // ------------------------------------------------------- + // Grouping + // ------------------------------------------------------- + protected List BuildGroups(GroupByBaseArgs args, ParsingContext context) + { + var resolvedHeaders = ResolveHeaders(args); + bool hasHeaders = resolvedHeaders == FieldHeaders.YesAndShow + || resolvedHeaders == FieldHeaders.YesAndDontShow; + int startRow = hasHeaders ? 1 : 0; + int nKeyCols = args.RowFields.Size.NumberOfCols; + int nValCols = args.Values.Size.NumberOfCols; + + var rootDict = new Dictionary(StringComparer.OrdinalIgnoreCase); + var rootOrder = new List(); + + for (int r = startRow; r < args.RowFields.Size.NumberOfRows; r++) + { + if (args.FilterArray != null) + { + var filterVal = args.FilterArray.GetOffset(r, 0); + if (filterVal is bool b && !b) continue; + if (filterVal is int i && i == 0) continue; + } + + var keyParts = new object[nKeyCols]; + for (int c = 0; c < nKeyCols; c++) + keyParts[c] = args.RowFields.GetOffset(r, c); + + var rowVals = new object[nValCols]; + for (int c = 0; c < nValCols; c++) + rowVals[c] = args.Values.GetOffset(r, c); + + var currentDict = rootDict; + var currentOrder = rootOrder; + GroupLevel currentLevel = null; + + for (int depth = 0; depth < nKeyCols; 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 < nKeyCols - 1) + { + if (currentLevel.ChildDict == null) + { + currentLevel.ChildDict = new Dictionary(StringComparer.OrdinalIgnoreCase); + currentLevel.ChildOrder = new List(); + } + currentDict = currentLevel.ChildDict; + currentOrder = currentLevel.ChildOrder; + } + } + + var leafKey = string.Join("|", keyParts.Select(k => k?.ToString().ToLowerInvariant() ?? string.Empty).ToArray()); + var row = currentLevel.Rows.FirstOrDefault(rw => + string.Join("|", rw.KeyParts.Select(k => k?.ToString().ToLowerInvariant() ?? string.Empty).ToArray()) == leafKey); + + if (row == null) + { + row = new GroupRow { KeyParts = keyParts }; + currentLevel.Rows.Add(row); + } + row.Values.Add(rowVals); + args.AllValuesInOrder.Add(rowVals); + } + + var levels = BuildOrderedTree(rootDict, rootOrder); + AggregateTree(levels, args.Function, context); + return levels; + } + + protected List BuildOrderedTree( + Dictionary dict, + List order) + { + var levels = order.Select(k => dict[k]).ToList(); + foreach (var level in levels) + if (level.ChildDict != null) + level.Children = BuildOrderedTree(level.ChildDict, level.ChildOrder); + return levels; + } + + protected void AggregateTree(List levels, LambdaCalculator function, ParsingContext context) + { + foreach (var level in levels) + { + if (level.IsLeaf) + { + foreach (var row in level.Rows) + row.AggregatedValue = Aggregate(function, row.Values, context); + + var allVals = level.Rows.SelectMany(r => r.Values).ToList(); + level.SubtotalValue = Aggregate(function, allVals, context); + } + else + { + AggregateTree(level.Children, function, context); + + var allVals = level.Children.SelectMany(c => GetAllValues(c)).ToList(); + level.SubtotalValue = Aggregate(function, allVals, context); + } + } + } + + protected List GetAllValues(GroupLevel level) + { + if (level.IsLeaf) + return level.Rows.SelectMany(r => r.Values).ToList(); + return level.Children.SelectMany(c => GetAllValues(c)).ToList(); + } + + protected object Aggregate(LambdaCalculator calculator, List values, ParsingContext context) + { + int nRows = values.Count; + int nCols = values.Count > 0 ? values[0].Length : 1; + + var range = new InMemoryRange(nRows, (short)nCols); + for (int row = 0; row < nRows; row++) + for (int col = 0; col < nCols; col++) + range.SetValue(row, col, values[row][col]); + + calculator.BeginCalculation(); + calculator.SetVariableValue(0, range, DataType.ExcelRange, context); + return calculator.Execute(context).ResultValue; + } + + } +} diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Hstack.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Hstack.cs index a6d31fe4b..8d40e5995 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Hstack.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Hstack.cs @@ -30,6 +30,8 @@ internal class Hstack : ExcelFunction public override string NamespacePrefix => "_xlfn."; public override int ArgumentMinLength => 1; + public override bool ExecutesLambda => true; + public override CompileResult Execute(IList arguments, ParsingContext context) { var ranges = new List(); diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs index df98d8066..75afb32ad 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs @@ -73,35 +73,6 @@ public void GroupByLambda() } } - [TestMethod] - public void GroupByMixedInput() - { - using (var package = new ExcelPackage()) - { - var s = package.Workbook.Worksheets.Add("test"); - s.Cells["A1"].Value = "Joe"; - s.Cells["A2"].Value = "Anna"; - s.Cells["A3"].Value = ErrorValues.NAError; - s.Cells["A4"].Value = false; - s.Cells["B1"].Value = 1; - s.Cells["B2"].Value = 2; - s.Cells["B3"].Value = 4; - s.Cells["C1"].Formula = "GROUPBY(A1:A4, B1:B4, _xleta.SUM)"; - s.Calculate(); - Assert.AreEqual("Anna", s.Cells["C1"].Value); - Assert.AreEqual("Joe", s.Cells["C2"].Value); - Assert.AreEqual(ErrorValues.NAError, s.Cells["C3"].Value); - Assert.AreEqual(false, s.Cells["C4"].Value); - Assert.AreEqual(2d, s.Cells["D1"].Value); - Assert.AreEqual(1d, s.Cells["D2"].Value); - Assert.AreEqual(4d, s.Cells["D3"].Value); - Assert.AreEqual(0d, s.Cells["D4"].Value); - - Assert.AreEqual("Total", s.Cells["C5"].Value); - Assert.AreEqual(7d, s.Cells["D5"].Value); - } - } - [TestMethod] public void GroupByFieldHeaders() { @@ -276,7 +247,7 @@ public void GroupBy_NoTotals_ShouldNotIncludeTotalRow() } [TestMethod] - public void GroupByMEGATESTWOWOWOWOOWOW() + public void GroupBySortingMultipleCols() { using (var package = new ExcelPackage()) { @@ -313,12 +284,151 @@ public void GroupByMEGATESTWOWOWOWOOWOW() s.Cells["D6"].Value = 300; s.Cells["D7"].Value = 300; - s.Cells["E1"].Formula = "GROUPBY(A1:C7, D1:D7, _xleta.SUM,3,2,-1,,,0)"; + s.Cells["E1"].Formula = "GROUPBY(A1:C7, D1:D7, _xleta.SUM,0,2,-1,,0)"; s.Calculate(); Assert.AreEqual("Stockholm", s.Cells["E1"].Value); - Assert.AreEqual("Grand Total", s.Cells["E17"].Value); + Assert.AreEqual("Grand Total", s.Cells["E11"].Value); + Assert.AreEqual(5800d, s.Cells["H11"].Value); + } + } + + [TestMethod] + public void GroupByTextFunction() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "Kalle"; + s.Cells["A2"].Value = "Alice"; + s.Cells["A3"].Value = "Kalle"; + s.Cells["A4"].Value = "Alva"; + + s.Cells["B1"].Value = "Hoppade"; + s.Cells["B2"].Value = "Sprang"; + s.Cells["B3"].Value = "Hoppade"; + s.Cells["B4"].Value = "Gick"; + + s.Cells["C1"].Formula = "GROUPBY(A1:A4, B1:B4, _xleta.ARRAYTOTEXT)"; + s.Calculate(); + + Assert.AreEqual("Hoppade; Sprang; Hoppade; Gick", s.Cells["D4"].Value); + } + } + + [TestMethod] + public void GroupByAVERAGE() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["A1"].Value = "Kalle"; + s.Cells["A2"].Value = "Alice"; + s.Cells["A3"].Value = "Kalle"; + s.Cells["A4"].Value = "Alva"; + + s.Cells["B1"].Value = 1; + s.Cells["B2"].Value = 2; + s.Cells["B3"].Value = 3; + s.Cells["B4"].Value = 4; + + s.Cells["C1"].Formula = "GROUPBY(A1:A4, B1:B4, _xleta.AVERAGE)"; + s.Calculate(); + + Assert.AreEqual("Total", s.Cells["C4"].Value); + Assert.AreEqual(2.5d, s.Cells["D4"].Value); + } + } + + [TestMethod] + public void GroupByTextFunction2() + { + // REMINDER: ARRAYTOTEXT verkar inte fungera som den ska. Kan inte hantera singel cell adress till funktionen, vilket den kan i excel. + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + s.Cells["B4"].Value = "Gick"; + + s.Cells["C1"].Formula = "ARRAYTOTEXT(B4)"; + s.Calculate(); + + //Assert.AreEqual("Gick", s.Cells["C1"].Value); + } + } + + [TestMethod] + public void GroupByShouldInsertZeroWhenEmptyAndNumericFunction() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + + s.Cells["A1"].Value = "B"; + s.Cells["A2"].Value = "A"; + s.Cells["A3"].Value = "B"; + s.Cells["A4"].Value = "A"; + s.Cells["A5"].Value = "C"; + + s.Cells["B1"].Value = 1; + s.Cells["B3"].Value = 3; + s.Cells["B5"].Value = 4; + + s.Cells["C1"].Formula = "GROUPBY(A1:A5, B1:B5, _xleta.SUM)"; + s.Calculate(); + Assert.AreEqual(0d, s.Cells["D1"].Value); + } + } + + [TestMethod] + public void GroupByMultipleFunctions() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + + s.Cells["A1"].Value = "B"; + s.Cells["A2"].Value = "A"; + s.Cells["A3"].Value = "B"; + s.Cells["A4"].Value = "A"; + s.Cells["A5"].Value = "C"; + + s.Cells["B1"].Value = 1; + s.Cells["B3"].Value = 3; + s.Cells["B5"].Value = 4; + + s.Cells["C1"].Formula = "=GROUPBY(A1:A5, B1:B5,HSTACK(_xleta.COUNT, _xleta.SUM, _xleta.PERCENTOF),1)"; + s.Calculate(); + Assert.AreEqual(null, s.Cells["C1"].Value); + Assert.AreEqual("COUNT", s.Cells["D1"].Value); + Assert.AreEqual("SUM", s.Cells["E1"].Value); + Assert.AreEqual("PERCENTOF", s.Cells["F1"].Value); } } + + [TestMethod] + public void GroupByMultipleFunctions1() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + + s.Cells["A1"].Value = "B"; + s.Cells["A2"].Value = "A"; + s.Cells["A3"].Value = "B"; + s.Cells["A4"].Value = "A"; + s.Cells["A5"].Value = "C"; + + s.Cells["B1"].Value = 1; + s.Cells["B3"].Value = 3; + s.Cells["B5"].Value = 4; + + s.Cells["C1"].Formula = "=HSTACK(COUNT;SUM;PERCENTOF)"; + s.Calculate(); + Assert.AreEqual("COUNT", s.Cells["D1"].Value); + Assert.AreEqual("SUM", s.Cells["E1"].Value); + Assert.AreEqual("PERCENTOF", s.Cells["F1"].Value); + } + } + } } From 03edb6e4e329d8cc14fdb6c7b2342e4de4c2bf07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20K=C3=A4llman?= Date: Thu, 26 Mar 2026 10:36:17 +0100 Subject: [PATCH 05/14] Added support for multiple lambda functions to handle PercentOf in the GroupBy function with HStack --- .../Excel/Functions/ExcelFunction.cs | 19 +++++++++++++++---- .../Functions/MathFunctions/PercentOf.cs | 2 +- .../FormulaExpressions/LambdaEtaExpression.cs | 2 +- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/ExcelFunction.cs b/src/EPPlus/FormulaParsing/Excel/Functions/ExcelFunction.cs index e6de1e09d..fb5a1860f 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/ExcelFunction.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/ExcelFunction.cs @@ -895,10 +895,21 @@ public virtual bool IsAllowedInCalculatedPivotTableField return true; } } - /// - /// Provides information about the functions parameters. - /// - public virtual ExcelFunctionParametersInfo ParametersInfo + /// + /// If the function is allowed in a pivot table calculated field. Default is true, if not overridden. + /// + public virtual bool IsAllowedAsLambdaWithMultipleArguments + { + get + { + return false; + } + } + + /// + /// Provides information about the functions parameters. + /// + public virtual ExcelFunctionParametersInfo ParametersInfo { get; } = ExcelFunctionParametersInfo.Default; diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/MathFunctions/PercentOf.cs b/src/EPPlus/FormulaParsing/Excel/Functions/MathFunctions/PercentOf.cs index 83ebcae62..207b1f885 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/MathFunctions/PercentOf.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/MathFunctions/PercentOf.cs @@ -26,7 +26,7 @@ internal class PercentOf : ExcelFunction { public override int ArgumentMinLength => 2; public override string NamespacePrefix => "_xlfn."; - + public override bool IsAllowedAsLambdaWithMultipleArguments => true; public override CompileResult Execute(IList arguments, ParsingContext context) { if (arguments.Count > 2) diff --git a/src/EPPlus/FormulaParsing/FormulaExpressions/LambdaEtaExpression.cs b/src/EPPlus/FormulaParsing/FormulaExpressions/LambdaEtaExpression.cs index 990ad0392..977455e0a 100644 --- a/src/EPPlus/FormulaParsing/FormulaExpressions/LambdaEtaExpression.cs +++ b/src/EPPlus/FormulaParsing/FormulaExpressions/LambdaEtaExpression.cs @@ -54,7 +54,7 @@ public override CompileResult Compile() } var functionName = _tokenValue.Replace("_xleta.", string.Empty); var func = Context.Configuration.FunctionRepository.GetFunction(functionName); - if(func == null || func.ArgumentMinLength > 1) + if(func == null || (func.ArgumentMinLength > 1 && func.IsAllowedAsLambdaWithMultipleArguments==false)) { return CompileResult.GetErrorResult(eErrorType.Value); } From 6287831fa068300bfbeb37ac9b031c50dd2554ac Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Thu, 26 Mar 2026 16:30:32 +0100 Subject: [PATCH 06/14] WIP --- .../RefAndLookup/GroupbyFunctionBase.cs | 75 ++++++++++++++++--- 1 file changed, 63 insertions(+), 12 deletions(-) diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupbyFunctionBase.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupbyFunctionBase.cs index aab617e9c..935009efb 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupbyFunctionBase.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupbyFunctionBase.cs @@ -1,4 +1,5 @@ -using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.LookupUtils; +using OfficeOpenXml.FormulaParsing.Excel.Functions.DateAndTime; +using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.LookupUtils; using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.Sorting; using OfficeOpenXml.FormulaParsing.FormulaExpressions; using OfficeOpenXml.FormulaParsing.Ranges; @@ -34,7 +35,12 @@ protected enum FieldRelationship Hierarchy = 0, Table = 1 } - + protected enum FunctionLayout + { + Single, + Horizontal, // HSTACK - results are added as columns + Vertical // VSTACK - results are added as rows + } // ------------------------------------------------------- // Shared argument container // ------------------------------------------------------- @@ -43,6 +49,8 @@ protected class GroupByBaseArgs public IRangeInfo RowFields { get; set; } public IRangeInfo Values { get; set; } public LambdaCalculator Function { get; set; } + public List Functions { get; set; } = new List(); + public FunctionLayout FunctionLayout { get; set; } = FunctionLayout.Single; public FieldHeaders Headers { get; set; } = FieldHeaders.Missing; public int TotalDepth { get; set; } = 1; public int SortOrder { get; set; } = 1; @@ -62,6 +70,7 @@ protected class GroupLevel public List ChildOrder { get; set; } = null; public List Rows { get; set; } = new List(); public object SubtotalValue { get; set; } + public List SubtotalValues { get; set; } public bool IsLeaf => Children.Count == 0; } @@ -70,6 +79,7 @@ protected class GroupRow public object[] KeyParts { get; set; } public List Values { get; set; } = new List(); public object AggregatedValue { get; set; } + public List AggregatedValues { get; set; } = new List(); // All function results } // ------------------------------------------------------- @@ -94,10 +104,40 @@ protected bool TryParseBaseArgs( if (args.RowFields.Size.NumberOfRows != args.Values.Size.NumberOfRows) return Fail(eErrorType.Value, out error); - if (arguments[2].DataType != DataType.LambdaCalculation) + if (arguments[2].DataType == DataType.LambdaCalculation) + { + // Single function + args.Function = arguments[2].Value as LambdaCalculator; + args.Functions.Add(args.Function); + args.FunctionLayout = FunctionLayout.Single; + } + else if (arguments[2].IsExcelRange) + { + // Multiple functions via HSTACK or VSTACK + var range = arguments[2].ValueAsRangeInfo; + bool isHorizontal = range.Size.NumberOfRows == 1; + args.FunctionLayout = isHorizontal ? FunctionLayout.Horizontal : FunctionLayout.Vertical; + + int count = isHorizontal ? range.Size.NumberOfCols : range.Size.NumberOfRows; + for (int i = 0; i < count; i++) + { + var cellVal = isHorizontal + ? range.GetOffset(0, i) + : range.GetOffset(i, 0); + + if (cellVal is LambdaCalculator lc) + args.Functions.Add(lc); + else + return Fail(eErrorType.Value, out error); + } + args.Function = args.Functions[0]; + } + else + { return Fail(eErrorType.Value, out error); - args.Function = arguments[2].Value as LambdaCalculator; - if (args.Function == null) + } + + if (args.Functions.Count == 0) return Fail(eErrorType.Value, out error); // field_headers (optional) @@ -240,7 +280,7 @@ protected List BuildGroups(GroupByBaseArgs args, ParsingContext cont } var levels = BuildOrderedTree(rootDict, rootOrder); - AggregateTree(levels, args.Function, context); + AggregateTree(levels, args, context); return levels; } @@ -255,24 +295,35 @@ protected List BuildOrderedTree( return levels; } - protected void AggregateTree(List levels, LambdaCalculator function, ParsingContext context) + protected void AggregateTree(List levels, GroupByBaseArgs args, ParsingContext context) { foreach (var level in levels) { if (level.IsLeaf) { - foreach (var row in level.Rows) - row.AggregatedValue = Aggregate(function, row.Values, context); + foreach(var row in level.Rows) + { + row.AggregatedValues = args.Functions + .Select(f => Aggregate(f, row.Values, context)) + .ToList(); + row.AggregatedValue = row.AggregatedValues[0]; + } var allVals = level.Rows.SelectMany(r => r.Values).ToList(); - level.SubtotalValue = Aggregate(function, allVals, context); + level.SubtotalValues = args.Functions + .Select(f => Aggregate(f, allVals, context)) + .ToList(); + level.SubtotalValue = level.SubtotalValues[0]; } else { - AggregateTree(level.Children, function, context); + AggregateTree(level.Children, args, context); var allVals = level.Children.SelectMany(c => GetAllValues(c)).ToList(); - level.SubtotalValue = Aggregate(function, allVals, context); + level.SubtotalValues = args.Functions + .Select(f => Aggregate(f, allVals, context)) + .ToList(); + level.SubtotalValue = level.SubtotalValues[0]; } } } From f8e4907549537b7ab8facf096c85792d1318b262 Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Wed, 1 Apr 2026 12:53:01 +0200 Subject: [PATCH 07/14] WIP --- .../DependencyChain/RpnFormulaExecution.cs | 11 +- .../Excel/Functions/ExcelFunction.cs | 10 +- .../Excel/Functions/MathFunctions/Sum.cs | 1 + .../Excel/Functions/RefAndLookup/Groupby.cs | 187 +++++++++++++----- .../RefAndLookup/GroupbyFunctionBase.cs | 51 ++++- .../FormulaExpressions/LambdaEtaExpression.cs | 18 +- .../Functions/RefAndLookup/GroupByTests.cs | 97 +++++++-- 7 files changed, 296 insertions(+), 79 deletions(-) diff --git a/src/EPPlus/FormulaParsing/DependencyChain/RpnFormulaExecution.cs b/src/EPPlus/FormulaParsing/DependencyChain/RpnFormulaExecution.cs index 42e29e4c8..efca23e68 100644 --- a/src/EPPlus/FormulaParsing/DependencyChain/RpnFormulaExecution.cs +++ b/src/EPPlus/FormulaParsing/DependencyChain/RpnFormulaExecution.cs @@ -1130,7 +1130,11 @@ private static FormulaRangeAddress[] ExecuteNextToken(RpnOptimizedDependencyChai { exp.Status |= ExpressionStatus.IsLambdaVariableDeclaration; } - + if (t.TokenType == TokenType.EtaReducedLambda) + { + ((FunctionExpression)f._expressions[f._tokenIndex]).SetRpnFormula(f); + } + var cr = exp.Compile(); if (cr.DataType == DataType.LambdaTokens) { @@ -1155,10 +1159,7 @@ private static FormulaRangeAddress[] ExecuteNextToken(RpnOptimizedDependencyChai { s.Push(f._expressions[f._tokenIndex]); } - if(t.TokenType == TokenType.EtaReducedLambda) - { - ((FunctionExpression)f._expressions[f._tokenIndex]).SetRpnFormula(f); - } + break; case TokenType.Negator: s.Push(s.Pop().Negate()); diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/ExcelFunction.cs b/src/EPPlus/FormulaParsing/Excel/Functions/ExcelFunction.cs index fb5a1860f..0a98779c1 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/ExcelFunction.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/ExcelFunction.cs @@ -128,6 +128,14 @@ public virtual void GetNewParameterAddress(IList args, int index, { } + + public virtual string Name + { + get + { + return GetType().Name.ToUpper(); + } + } /// /// Indicates that the function is an ErrorHandlingFunction. /// @@ -896,7 +904,7 @@ public virtual bool IsAllowedInCalculatedPivotTableField } } /// - /// If the function is allowed in a pivot table calculated field. Default is true, if not overridden. + /// The function is allowed... /// public virtual bool IsAllowedAsLambdaWithMultipleArguments { diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/MathFunctions/Sum.cs b/src/EPPlus/FormulaParsing/Excel/Functions/MathFunctions/Sum.cs index eb528f711..89508df5f 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/MathFunctions/Sum.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/MathFunctions/Sum.cs @@ -24,6 +24,7 @@ namespace OfficeOpenXml.FormulaParsing.Excel.Functions.MathFunctions internal class SumV2 : ExcelFunction { public override int ArgumentMinLength => 1; + public override string Name => "SUM"; public override CompileResult Execute(IList arguments, ParsingContext context) { diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs index bb489b60e..2f713bf53 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs @@ -45,8 +45,8 @@ public override CompileResult Execute(IList arguments, Parsing var result = BuildResult(groups, args, context); return CreateDynamicArrayResult(result, DataType.ExcelRange); - } - + } + // ------------------------------------------------------- // Sorting // ------------------------------------------------------- @@ -134,6 +134,7 @@ private InMemoryRange BuildResult(List levels, GroupByBaseArgs args, var resolvedHeaders = ResolveHeaders(args); bool showHeaders = resolvedHeaders == FieldHeaders.YesAndShow || resolvedHeaders == FieldHeaders.NoButGenerate; + bool addFunctionHeaders = args.Functions.Count > 1; bool showTotals = args.TotalDepth != TotalDepthNoTotals; bool totalsAtTop = args.TotalDepth < 0; bool totalsAtEnd = args.TotalDepth > 0; @@ -143,27 +144,66 @@ private InMemoryRange BuildResult(List levels, GroupByBaseArgs args, int nKeyCols = args.RowFields.Size.NumberOfCols; int nValCols = args.Values.Size.NumberOfCols; - int nCols = nKeyCols + nValCols; + int nFunctions = args.Functions.Count; + + int valColsPerRow = args.FunctionLayout == FunctionLayout.Horizontal + ? nValCols * nFunctions + : args.FunctionLayout == FunctionLayout.Vertical + ? nValCols + 1 + : nValCols; + int nCols = nKeyCols + valColsPerRow; int dataRows = CountDataRows(levels); - int subtotalRows = showSubtotals ? CountSubtotalRows(levels, subtotalDepth, 1) : 0; - int totalRows = dataRows + subtotalRows - + (showHeaders ? 1 : 0) - + (showTotals ? 1 : 0); + int subtotalRows = showSubtotals + ? CountSubtotalRows(levels, subtotalDepth, 1) * (args.FunctionLayout == FunctionLayout.Vertical ? nFunctions : 1) + : 0; + int resultDataRows = args.FunctionLayout == FunctionLayout.Vertical + ? dataRows * nFunctions + : dataRows; + + int grandTotalRows = showTotals + ? (args.FunctionLayout == FunctionLayout.Vertical ? nFunctions : 1) + : 0; + + int totalRows = resultDataRows + subtotalRows + + (showHeaders ? 1 : 0) + + grandTotalRows; var result = new InMemoryRange(totalRows, (short)nCols); int r = 0; - if (showHeaders) + if(addFunctionHeaders) { + var functionHeaders = ResolveFunctionHeaders(args); + if(args.FunctionLayout == FunctionLayout.Horizontal) + { + for (int c = 0; c < nFunctions; c++) + result.SetValue(r, c + 1, functionHeaders[c]); + r++; + } + } + if (showHeaders) + { for (int c = 0; c < nKeyCols; c++) result.SetValue(r, c, resolvedHeaders == FieldHeaders.NoButGenerate ? $"Field {c + 1}" : args.RowFields.GetOffset(0, c)?.ToString()); - for (int c = 0; c < nValCols; c++) - result.SetValue(r, nKeyCols + c, resolvedHeaders == FieldHeaders.NoButGenerate - ? $"Field {nKeyCols + c + 1}" - : args.Values.GetOffset(0, c)?.ToString()); + + if (args.FunctionLayout == FunctionLayout.Horizontal) + { + for (int f = 0; f < nFunctions; f++) + for (int c = 0; c < nValCols; c++) + result.SetValue(r, nKeyCols + f * nValCols + c, resolvedHeaders == FieldHeaders.NoButGenerate + ? $"Field {nKeyCols + f * nValCols + c + 1}" + : args.Values.GetOffset(0, c)?.ToString()); + } + else + { + for (int c = 0; c < nValCols; c++) + result.SetValue(r, nKeyCols + c + (addFunctionHeaders ? 1: 0), resolvedHeaders == FieldHeaders.NoButGenerate + ? $"Field {nKeyCols + c + 1}" + : args.Values.GetOffset(0, c)?.ToString()); + } r++; } @@ -172,7 +212,7 @@ private InMemoryRange BuildResult(List levels, GroupByBaseArgs args, if (totalsAtTop && showTotals) r = WriteGrandTotal(result, r, levels, grandTotalStr, nKeyCols, nValCols, args, context); - r = WriteRows(result, r, levels, subtotalDepth, totalsAtTop, nKeyCols, nValCols, depth: 1); + r = WriteRows(result, r, levels, subtotalDepth, totalsAtTop, nKeyCols, nValCols, args, depth: 1); if (totalsAtEnd && showTotals) WriteGrandTotal(result, r, levels, grandTotalStr, nKeyCols, nValCols, args, context); @@ -181,45 +221,63 @@ private InMemoryRange BuildResult(List levels, GroupByBaseArgs args, } private int WriteRows( - InMemoryRange result, int r, - List levels, - int subtotalDepth, bool subtotalsAtTop, - int nKeyCols, int nValCols, - int depth) + InMemoryRange result, int r, + List levels, + int subtotalDepth, bool subtotalsAtTop, + int nKeyCols, int nValCols, + GroupByBaseArgs args, + int depth) { + var functionHeaders = args.FunctionLayout == FunctionLayout.Vertical + ? ResolveFunctionHeaders(args) + : null; + foreach (var level in levels) { bool writeSubtotal = subtotalDepth > 1 && depth <= subtotalDepth - 1; - // Subtotal at top of this level if (writeSubtotal && subtotalsAtTop) - r = WriteSubtotal(result, r, level, nKeyCols, nValCols); + r = WriteSubtotal(result, r, level, nKeyCols, nValCols, args); if (level.IsLeaf) { - // Write leaf GroupRows foreach (var row in level.Rows) { - for (int c = 0; c < nKeyCols; c++) - result.SetValue(r, c, row.KeyParts[c]); - for (int c = 0; c < nValCols; c++) - result.SetValue(r, nKeyCols + c, row.AggregatedValue); - r++; + if (args.FunctionLayout == FunctionLayout.Vertical) + { + for (int f = 0; f < args.Functions.Count; f++) + { + for (int c = 0; c < nKeyCols; c++) + result.SetValue(r, c, row.KeyParts[c]); + result.SetValue(r, nKeyCols, functionHeaders[f]); + for (int c = 0; c < nValCols; c++) + result.SetValue(r, nKeyCols + 1 + c, row.AggregatedValues[f]); + r++; + } + } + else + { + for (int c = 0; c < nKeyCols; c++) + result.SetValue(r, c, row.KeyParts[c]); + for (int f = 0; f < args.Functions.Count; f++) + for (int c = 0; c < nValCols; c++) + result.SetValue(r, nKeyCols + f * nValCols + c, row.AggregatedValues[f]); + r++; + } } } else { - // Recurse into children - r = WriteRows(result, r, level.Children, subtotalDepth, subtotalsAtTop, nKeyCols, nValCols, depth + 1); + r = WriteRows(result, r, level.Children, subtotalDepth, subtotalsAtTop, nKeyCols, nValCols, args, depth + 1); } - // Subtotal at bottom of this level if (writeSubtotal && !subtotalsAtTop) - r = WriteSubtotal(result, r, level, nKeyCols, nValCols); + r = WriteSubtotal(result, r, level, nKeyCols, nValCols, args); } return r; } + private int CountDataRows(List levels) { int count = 0; @@ -240,29 +298,62 @@ private int CountSubtotalRows(List levels, int subtotalDepth, int de return count; } - private int WriteSubtotal(InMemoryRange result, int r, GroupLevel level, int nKeyCols, int nValCols) + private int WriteSubtotal(InMemoryRange result, int r, GroupLevel level, int nKeyCols, int nValCols, GroupByBaseArgs args) { - result.SetValue(r, 0, level.Key); - for (int c = 1; c < nKeyCols; c++) - result.SetValue(r, c, string.Empty); - result.SetValue(r, nKeyCols, level.SubtotalValue); - for (int c = 1; c < nValCols; c++) - result.SetValue(r, nKeyCols + c, string.Empty); - return r + 1; + var functionHeaders = ResolveFunctionHeaders(args); + if (args.FunctionLayout == FunctionLayout.Vertical) + { + for (int f = 0; f < args.Functions.Count; f++) + { + result.SetValue(r, 0, level.Key); + for (int c = 1; c < nKeyCols; c++) + result.SetValue(r, c, string.Empty); + result.SetValue(r, nKeyCols, functionHeaders[f]); + for (int c = 0; c < nValCols; c++) + result.SetValue(r, nKeyCols + 1 + c, level.SubtotalValues[f]); + r++; + } + } + else + { + result.SetValue(r, 0, level.Key); + for (int c = 1; c < nKeyCols; c++) + result.SetValue(r, c, string.Empty); + for (int f = 0; f < args.Functions.Count; f++) + for (int c = 0; c < nValCols; c++) + result.SetValue(r, nKeyCols + f * nValCols + c, level.SubtotalValues[f]); + r++; + } + return r; } private int WriteGrandTotal(InMemoryRange result, int r, List levels, string label, int nKeyCols, int nValCols, GroupByBaseArgs args, ParsingContext context) { - result.SetValue(r, 0, label); - for (int c = 1; c < nKeyCols; c++) - result.SetValue(r, c, string.Empty); - - var allValues = levels.SelectMany(l => GetAllValues(l)).ToList(); - result.SetValue(r, nKeyCols, Aggregate(args.Function, args.AllValuesInOrder, context)); - - for (int c = 1; c < nValCols; c++) - result.SetValue(r, nKeyCols + c, string.Empty); - return r + 1; + var functionHeaders = ResolveFunctionHeaders(args); + if (args.FunctionLayout == FunctionLayout.Vertical) + { + for (int f = 0; f < args.Functions.Count; f++) + { + result.SetValue(r, 0, label); + for (int c = 1; c < nKeyCols; c++) + result.SetValue(r, c, string.Empty); + result.SetValue(r, nKeyCols, functionHeaders[f]); + result.SetValue(r, nKeyCols + 1, Aggregate(args.Functions[f], args.AllValuesInOrder, context, + args.Functions[f].EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null)); + r++; + } + } + else + { + result.SetValue(r, 0, label); + for (int c = 1; c < nKeyCols; c++) + result.SetValue(r, c, string.Empty); + for (int f = 0; f < args.Functions.Count; f++) + result.SetValue(r, nKeyCols + f * nValCols, Aggregate(args.Functions[f], args.AllValuesInOrder, context, + args.Functions[f].EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null)); + r++; + } + return r; } /// Recursively collects all AggregatedValues from leaf GroupRows. diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupbyFunctionBase.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupbyFunctionBase.cs index 935009efb..26540320d 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupbyFunctionBase.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupbyFunctionBase.cs @@ -1,4 +1,5 @@ using OfficeOpenXml.FormulaParsing.Excel.Functions.DateAndTime; +using OfficeOpenXml.FormulaParsing.Excel.Functions.MathFunctions; using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.LookupUtils; using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.Sorting; using OfficeOpenXml.FormulaParsing.FormulaExpressions; @@ -82,6 +83,25 @@ protected class GroupRow public List AggregatedValues { get; set; } = new List(); // All function results } + protected List ResolveFunctionHeaders(GroupByBaseArgs args) + { + var names = args.Functions + .Select(f => f.EtaFunction.Name) + .ToList(); + + int customCount = names.Count(n => n == "CUSTOM"); + + if (customCount > 1) + { + int counter = 1; + for (int i = 0; i < names.Count; i++) + if (names[i] == "CUSTOM") + names[i] = $"CUSTOM{counter++}"; + } + + return names; + } + // ------------------------------------------------------- // Argument parsing (shared arguments 1-8) // ------------------------------------------------------- @@ -216,7 +236,11 @@ protected List BuildGroups(GroupByBaseArgs args, ParsingContext cont var resolvedHeaders = ResolveHeaders(args); bool hasHeaders = resolvedHeaders == FieldHeaders.YesAndShow || resolvedHeaders == FieldHeaders.YesAndDontShow; + bool multipleFunctions = args.Functions.Count > 1; int startRow = hasHeaders ? 1 : 0; + //int startRow = 0; + //if (hasHeaders) startRow++; + //if (multipleFunctions) startRow++; int nKeyCols = args.RowFields.Size.NumberOfCols; int nValCols = args.Values.Size.NumberOfCols; @@ -301,17 +325,19 @@ protected void AggregateTree(List levels, GroupByBaseArgs args, Pars { if (level.IsLeaf) { - foreach(var row in level.Rows) + foreach (var row in level.Rows) { row.AggregatedValues = args.Functions - .Select(f => Aggregate(f, row.Values, context)) + .Select(f => Aggregate(f, row.Values, context, + f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null)) .ToList(); row.AggregatedValue = row.AggregatedValues[0]; } var allVals = level.Rows.SelectMany(r => r.Values).ToList(); level.SubtotalValues = args.Functions - .Select(f => Aggregate(f, allVals, context)) + .Select(f => Aggregate(f, allVals, context, + f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null)) .ToList(); level.SubtotalValue = level.SubtotalValues[0]; } @@ -321,8 +347,9 @@ protected void AggregateTree(List levels, GroupByBaseArgs args, Pars var allVals = level.Children.SelectMany(c => GetAllValues(c)).ToList(); level.SubtotalValues = args.Functions - .Select(f => Aggregate(f, allVals, context)) - .ToList(); + .Select(f => Aggregate(f, allVals, context, + f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null)) + .ToList(); level.SubtotalValue = level.SubtotalValues[0]; } } @@ -335,7 +362,7 @@ protected List GetAllValues(GroupLevel level) return level.Children.SelectMany(c => GetAllValues(c)).ToList(); } - protected object Aggregate(LambdaCalculator calculator, List values, ParsingContext context) + protected object Aggregate(LambdaCalculator calculator, List values, ParsingContext context, List allValues = null) { int nRows = values.Count; int nCols = values.Count > 0 ? values[0].Length : 1; @@ -347,6 +374,18 @@ protected object Aggregate(LambdaCalculator calculator, List values, P calculator.BeginCalculation(); calculator.SetVariableValue(0, range, DataType.ExcelRange, context); + + if(calculator.NumberOfVariables > 1 && allValues != null) + { + // Special case with PERCENTOF where we have to handle two arguments with no input. + int allRows = allValues.Count; + int allCols = allValues.Count > 0 ? allValues[0].Length : 1; + var allRange = new InMemoryRange(allRows, (short)allCols); + for (int row = 0; row < allRows; row++) + for (int col = 0; col < allCols; col++) + allRange.SetValue(row, col, allValues[row][col]); + calculator.SetVariableValue(1, allRange, DataType.ExcelRange, context); + } return calculator.Execute(context).ResultValue; } diff --git a/src/EPPlus/FormulaParsing/FormulaExpressions/LambdaEtaExpression.cs b/src/EPPlus/FormulaParsing/FormulaExpressions/LambdaEtaExpression.cs index 977455e0a..9c15d96b1 100644 --- a/src/EPPlus/FormulaParsing/FormulaExpressions/LambdaEtaExpression.cs +++ b/src/EPPlus/FormulaParsing/FormulaExpressions/LambdaEtaExpression.cs @@ -58,8 +58,22 @@ public override CompileResult Compile() { return CompileResult.GetErrorResult(eErrorType.Value); } - var paramName = $"p{Guid.NewGuid().ToString("N")}"; - var formula = $"LAMBDA({paramName}, {functionName}({paramName}))"; + string paramNames; + if (func.IsAllowedAsLambdaWithMultipleArguments) + { + var argList = new StringBuilder(); + for (int i = 0; i < func.ArgumentMinLength; i++) + { + var paramName = $"p{Guid.NewGuid().ToString("N")},"; + argList.Append(paramName); + } + paramNames = argList.ToString(0, argList.Length - 1); + } + else + { + paramNames = $"p{Guid.NewGuid().ToString("N")}"; + } + var formula = $"LAMBDA({paramNames}, {functionName}({paramNames}))"; var lambdaTokens = SourceCodeTokenizer.Default.Tokenize(formula); var rpnTokens = FormulaExecutor.CreateRPNTokens(lambdaTokens); diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs index 75afb32ad..46580e907 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs @@ -3,8 +3,10 @@ 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; @@ -60,12 +62,12 @@ public void GroupByLambda() s.Cells["B2"].Value = 2; s.Cells["B3"].Value = 3; s.Cells["B4"].Value = 0; - s.Cells["C1"].Formula = "GROUPBY(A1:A4, B1:B4, LAMBDA(x,SUM(x *2/3)))"; + s.Cells["C1"].Formula = "GROUPBY(A1:A4, B1:B4, LAMBDA(x,SUM(x *2/3)) )"; s.Calculate(); Assert.AreEqual("Anna", s.Cells["C1"].Value); Assert.AreEqual("Bertil", s.Cells["C2"].Value); Assert.AreEqual("Joe", s.Cells["C3"].Value); - Assert.AreEqual(1.3333d, s.Cells["D1"].Value); + //Assert.AreEqual(1.33333333333333d, s.Cells["D1"].Value); Assert.AreEqual(2d, s.Cells["D2"].Value); Assert.AreEqual(0.6667d, s.Cells["D3"].Value); Assert.AreEqual("Total", s.Cells["C4"].Value); @@ -107,7 +109,7 @@ public void GroupByFieldHeaders() Assert.AreEqual("Y", s.Cells["E2"].Value); Assert.AreEqual(3d, s.Cells["F2"].Value); // Subtotal row - Assert.AreEqual("A", s.Cells["D3"].Value); + Assert.AreEqual("A", s.Cells["D3"].Value); Assert.AreEqual(4d, s.Cells["F3"].Value); Assert.AreEqual("B", s.Cells["D4"].Value); @@ -356,13 +358,13 @@ public void GroupByTextFunction2() } } - [TestMethod] + [TestMethod] public void GroupByShouldInsertZeroWhenEmptyAndNumericFunction() { using (var package = new ExcelPackage()) { var s = package.Workbook.Worksheets.Add("test"); - + s.Cells["A1"].Value = "B"; s.Cells["A2"].Value = "A"; s.Cells["A3"].Value = "B"; @@ -396,9 +398,9 @@ public void GroupByMultipleFunctions() s.Cells["B3"].Value = 3; s.Cells["B5"].Value = 4; - s.Cells["C1"].Formula = "=GROUPBY(A1:A5, B1:B5,HSTACK(_xleta.COUNT, _xleta.SUM, _xleta.PERCENTOF),1)"; + s.Cells["C1"].Formula = "=GROUPBY(A1:A5, B1:B5,HSTACK(_xleta.COUNT, _xleta.SUM, _xleta.PERCENTOF))"; s.Calculate(); - Assert.AreEqual(null, s.Cells["C1"].Value); + //Assert.AreEqual(null, s.Cells["C1"].Value); Assert.AreEqual("COUNT", s.Cells["D1"].Value); Assert.AreEqual("SUM", s.Cells["E1"].Value); Assert.AreEqual("PERCENTOF", s.Cells["F1"].Value); @@ -406,29 +408,90 @@ public void GroupByMultipleFunctions() } [TestMethod] - public void GroupByMultipleFunctions1() + public void GroupByMultipleFunctions2() { using (var package = new ExcelPackage()) { var s = package.Workbook.Worksheets.Add("test"); - s.Cells["A1"].Value = "B"; - s.Cells["A2"].Value = "A"; - s.Cells["A3"].Value = "B"; - s.Cells["A4"].Value = "A"; - s.Cells["A5"].Value = "C"; + s.Cells["A1"].Value = "Rubrik"; + s.Cells["A2"].Value = "B"; + s.Cells["A3"].Value = "A"; + s.Cells["A4"].Value = "B"; + s.Cells["A5"].Value = "A"; + s.Cells["A6"].Value = "C"; - s.Cells["B1"].Value = 1; - s.Cells["B3"].Value = 3; - s.Cells["B5"].Value = 4; + s.Cells["B1"].Value = "Siffor"; + s.Cells["B2"].Value = 1; + s.Cells["B4"].Value = 3; + s.Cells["B6"].Value = 4; - s.Cells["C1"].Formula = "=HSTACK(COUNT;SUM;PERCENTOF)"; + s.Cells["C1"].Formula = "=GROUPBY(A1:A6, B1:B6,HSTACK(_xleta.COUNT, _xleta.SUM, _xleta.PERCENTOF),3)"; s.Calculate(); + //Assert.AreEqual(null, s.Cells["C1"].Value); Assert.AreEqual("COUNT", s.Cells["D1"].Value); Assert.AreEqual("SUM", s.Cells["E1"].Value); Assert.AreEqual("PERCENTOF", s.Cells["F1"].Value); } } + [TestMethod] + public void GroupByMultipleFunctions3() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + + s.Cells["A1"].Value = "Rubrik"; + s.Cells["A2"].Value = "B"; + s.Cells["A3"].Value = "A"; + s.Cells["A4"].Value = "B"; + s.Cells["A5"].Value = "A"; + s.Cells["A6"].Value = "C"; + + s.Cells["B1"].Value = "Siffor"; + s.Cells["B2"].Value = 1; + s.Cells["B4"].Value = 3; + s.Cells["B6"].Value = 4; + + s.Cells["C1"].Formula = "=GROUPBY(A1:A6, B1:B6,VSTACK(_xleta.COUNT, _xleta.SUM, _xleta.PERCENTOF),3)"; + s.Calculate(); + + Assert.AreEqual("COUNT", s.Cells["D2"].Value); + Assert.AreEqual("SUM", s.Cells["D3"].Value); + Assert.AreEqual("PERCENTOF", s.Cells["D4"].Value); + } + } + + [TestMethod] + public void GroupByMultipleFunctionsCustomLambda() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + + s.Cells["A1"].Value = "Rubrik"; + s.Cells["A2"].Value = "B"; + s.Cells["A3"].Value = "A"; + s.Cells["A4"].Value = "B"; + s.Cells["A5"].Value = "A"; + s.Cells["A6"].Value = "C"; + + s.Cells["B1"].Value = "Siffor"; + s.Cells["B2"].Value = 1; + s.Cells["B4"].Value = 3; + s.Cells["B6"].Value = 4; + + s.Cells["C1"].Formula = "=GROUPBY(A1:A6, B1:B6,HSTACK(_xleta.COUNT, LAMBDA(x,SUM(x *2/3)) , _xleta.PERCENTOF),3)"; + // LAMBDA(x, SUM(x*4/2)) LAMBDA(x,SUM(x *2/3)) + s.Calculate(); + + Assert.AreEqual("COUNT", s.Cells["D2"].Value); + Assert.AreEqual("CUSTOM", s.Cells["D3"].Value); + Assert.AreEqual("PERCENTOF", s.Cells["D4"].Value); + } + } + + // TESTA SKICKA IN LAMBDA SÅ ATT VI KAN SE ATT CUSTOM funktionerna FÅR RÄTT HEADERS "CUSTOM1, CUSTOM2..." } } From 6dce1b33eb1e61aba7046aadb4a1f6686ff0240c Mon Sep 17 00:00:00 2001 From: swmal <897655+swmal@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:58:39 +0200 Subject: [PATCH 08/14] Lambda expression fix --- .../DependencyChain/RpnFormulaExecution.cs | 10 +++++++++- .../Excel/Functions/RefAndLookup/GroupByTests.cs | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/EPPlus/FormulaParsing/DependencyChain/RpnFormulaExecution.cs b/src/EPPlus/FormulaParsing/DependencyChain/RpnFormulaExecution.cs index efca23e68..3bc997da6 100644 --- a/src/EPPlus/FormulaParsing/DependencyChain/RpnFormulaExecution.cs +++ b/src/EPPlus/FormulaParsing/DependencyChain/RpnFormulaExecution.cs @@ -1144,7 +1144,11 @@ private static FormulaRangeAddress[] ExecuteNextToken(RpnOptimizedDependencyChai { s.Push(f._expressions[f._tokenIndex]); } - else if (cr.DataType != DataType.LambdaVariableDeclaration && f.LambdaSettings.LambdaArgsAdded.Count > 0 && f.LambdaSettings.LambdaArgsAdded.Peek() < f.GetNumberOfLambdaVariables()) + else if( + f._expressionStack.Peek() is LambdaCalculationExpression lce2 && lce2.ArgumentCollectionStarted && + cr.DataType != DataType.LambdaVariableDeclaration + && f.LambdaSettings.LambdaArgsAdded.Count > 0 + && f.LambdaSettings.LambdaArgsAdded.Peek() < f.GetNumberOfLambdaVariables()) { leStackPos.Expression.SetVariable(f.LambdaSettings.LambdaArgsAdded.Peek(), cr.Result, cr.DataType, cr.Address); var nLambdaArgsAdded = f.LambdaSettings.LambdaArgsAdded.Pop(); @@ -1736,6 +1740,10 @@ private static IList CompileFunctionArguments(RpnFormula f, Funct { si.Status |= ExpressionStatus.FunctionArgument; } + if(si is FunctionExpression fe1) + { + fe1.SetRpnFormula(f); + } var cr = si.Compile(); list.Insert(0, cr); } diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs index 46580e907..b2e928276 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs @@ -482,7 +482,7 @@ public void GroupByMultipleFunctionsCustomLambda() s.Cells["B4"].Value = 3; s.Cells["B6"].Value = 4; - s.Cells["C1"].Formula = "=GROUPBY(A1:A6, B1:B6,HSTACK(_xleta.COUNT, LAMBDA(x,SUM(x *2/3)) , _xleta.PERCENTOF),3)"; + s.Cells["C1"].Formula = "GROUPBY(A1:A6, B1:B6,HSTACK(_xleta.COUNT, LAMBDA(x,SUM(x *2/3)) , _xleta.PERCENTOF),3)"; // LAMBDA(x, SUM(x*4/2)) LAMBDA(x,SUM(x *2/3)) s.Calculate(); From a65daef735d404dd2f2de517ca42d214f7599b9b Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Thu, 2 Apr 2026 13:04:11 +0200 Subject: [PATCH 09/14] WIP --- .../Excel/Functions/RefAndLookup/Groupby.cs | 5 ++- .../RefAndLookup/GroupbyFunctionBase.cs | 4 +- .../Functions/RefAndLookup/GroupByTests.cs | 40 +++++++++++++++++-- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs index 2f713bf53..a4177f72b 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs @@ -167,9 +167,10 @@ private InMemoryRange BuildResult(List levels, GroupByBaseArgs args, int totalRows = resultDataRows + subtotalRows + (showHeaders ? 1 : 0) - + grandTotalRows; + + grandTotalRows + + (addFunctionHeaders && args.FunctionLayout == FunctionLayout.Horizontal ? 1 : 0); - var result = new InMemoryRange(totalRows, (short)nCols); + var result = new InMemoryRange(totalRows, (short)nCols); // denna är ett för lite. TODO int r = 0; if(addFunctionHeaders) diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupbyFunctionBase.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupbyFunctionBase.cs index 26540320d..70cd2daba 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupbyFunctionBase.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupbyFunctionBase.cs @@ -86,7 +86,7 @@ protected class GroupRow protected List ResolveFunctionHeaders(GroupByBaseArgs args) { var names = args.Functions - .Select(f => f.EtaFunction.Name) + .Select(f => f.EtaFunction != null ? f.EtaFunction.Name : "CUSTOM") .ToList(); int customCount = names.Count(n => n == "CUSTOM"); @@ -107,7 +107,7 @@ protected List ResolveFunctionHeaders(GroupByBaseArgs args) // ------------------------------------------------------- protected bool TryParseBaseArgs( IList arguments, - out GroupByBaseArgs args, + out GroupByBaseArgs args, out CompileResult error) { args = new GroupByBaseArgs(); diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs index b2e928276..0ca6a4353 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs @@ -482,16 +482,48 @@ public void GroupByMultipleFunctionsCustomLambda() s.Cells["B4"].Value = 3; s.Cells["B6"].Value = 4; - s.Cells["C1"].Formula = "GROUPBY(A1:A6, B1:B6,HSTACK(_xleta.COUNT, LAMBDA(x,SUM(x *2/3)) , _xleta.PERCENTOF),3)"; + s.Cells["C1"].Formula = "GROUPBY(A1:A6, B1:B6,HSTACK(_xleta.COUNT, LAMBDA(x,SUM(x *2/3)), LAMBDA(x,SUM(x *2)) ),3)"; // LAMBDA(x, SUM(x*4/2)) LAMBDA(x,SUM(x *2/3)) s.Calculate(); - Assert.AreEqual("COUNT", s.Cells["D2"].Value); - Assert.AreEqual("CUSTOM", s.Cells["D3"].Value); - Assert.AreEqual("PERCENTOF", s.Cells["D4"].Value); + Assert.AreEqual("COUNT", s.Cells["D1"].Value); + Assert.AreEqual("CUSTOM1", s.Cells["E1"].Value); + Assert.AreEqual("CUSTOM2", s.Cells["F1"].Value); } } + + [TestMethod] + public void GroupByMultipleFunctionsCustomLambda2() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + + s.Cells["A1"].Value = "Rubrik"; + s.Cells["A2"].Value = "B"; + s.Cells["A3"].Value = "A"; + s.Cells["A4"].Value = "B"; + s.Cells["A5"].Value = "A"; + s.Cells["A6"].Value = "C"; + + s.Cells["B1"].Value = "Siffor"; + s.Cells["B2"].Value = 1; + s.Cells["B4"].Value = 3; + s.Cells["B6"].Value = 4; + + s.Cells["C1"].Formula = "GROUPBY(A1:A6, B1:B6,HSTACK(LAMBDA(x,SUM(x *2/3)), _xleta.COUNT, LAMBDA(x,SUM(x *2)) ) )"; + // LAMBDA(x, SUM(x*4/2)) LAMBDA(x,SUM(x *2/3)) + s.Calculate(); + + Assert.AreEqual(null, s.Cells["C1"].Value); + Assert.AreEqual("COUNT", s.Cells["D1"].Value); + Assert.AreEqual("CUSTOM1", s.Cells["E1"].Value); + Assert.AreEqual("PERCENTOF", s.Cells["F1"].Value); + Assert.AreEqual("CUSTOM2", s.Cells["E1"].Value); + } + } // 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 } } From 070c51f6cb9b79287e38ab08b2bb0bd941cb1d5c Mon Sep 17 00:00:00 2001 From: swmal <897655+swmal@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:43:51 +0200 Subject: [PATCH 10/14] Fixed issue with lambda invokation --- .../DependencyChain/RpnFormula.cs | 1 + .../Functions/RefAndLookup/GroupByTests.cs | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/EPPlus/FormulaParsing/DependencyChain/RpnFormula.cs b/src/EPPlus/FormulaParsing/DependencyChain/RpnFormula.cs index 9539a3584..47bd17ceb 100644 --- a/src/EPPlus/FormulaParsing/DependencyChain/RpnFormula.cs +++ b/src/EPPlus/FormulaParsing/DependencyChain/RpnFormula.cs @@ -130,6 +130,7 @@ internal LambdaExpressionStackPosition GetCurrentLambdaExpressionStackPosition() internal int GetNumberOfLambdaVariables() { if (_lambdaSettings == null || _lambdaSettings.NumberOfLambdaVariables == null || _lambdaSettings.NumberOfLambdaVariables.Count == 0) return 0; + if (_lambdaSettings.CurrentLambdaExpressions.Peek().Expression.ArgumentCollectionStarted == false) return 0; return _lambdaSettings.NumberOfLambdaVariables.Peek(); } diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs index 0ca6a4353..9b3894d0b 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs @@ -523,6 +523,33 @@ public void GroupByMultipleFunctionsCustomLambda2() Assert.AreEqual("CUSTOM2", s.Cells["E1"].Value); } } + + + [TestMethod] + public void GroupByMultipleFunctionsCustomLambda3() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + + s.Cells["A1"].Value = "Rubrik"; + s.Cells["A2"].Value = "B"; + s.Cells["A3"].Value = "A"; + s.Cells["A4"].Value = "B"; + s.Cells["A5"].Value = "A"; + s.Cells["A6"].Value = "C"; + + s.Cells["B1"].Value = "Siffor"; + s.Cells["B2"].Value = 1; + s.Cells["B4"].Value = 3; + s.Cells["B6"].Value = 4; + + s.Cells["C1"].Formula = "HSTACK(LAMBDA(x,x), _xleta.COUNT, LAMBDA(x,x))"; + // LAMBDA(x, SUM(x*4/2)) LAMBDA(x,SUM(x *2/3)) + s.Calculate(); + } + } + // 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 } From 95ef482dbc53383595f64bc775f812c53384221f Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Thu, 9 Apr 2026 09:41:31 +0200 Subject: [PATCH 11/14] Added GROUPBY function with base class and tests --- .../Excel/Functions/RefAndLookup/Groupby.cs | 117 ++++++++++++------ .../RefAndLookup/GroupbyFunctionBase.cs | 104 +++++++++++----- .../Excel/Functions/Text/ArrayToText.cs | 8 +- .../Functions/RefAndLookup/GroupByTests.cs | 70 ++++------- 4 files changed, 180 insertions(+), 119 deletions(-) diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs index a4177f72b..c93d81fbd 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs @@ -46,26 +46,18 @@ public override CompileResult Execute(IList arguments, Parsing return CreateDynamicArrayResult(result, DataType.ExcelRange); } - + // ------------------------------------------------------- // Sorting // ------------------------------------------------------- private List ApplySort(List levels, GroupByBaseArgs args, int depth = 1) { - if (args.SortOrder == 0) return levels; - - bool desc = args.SortOrder < 0; - int col = Math.Abs(args.SortOrder); - bool sortOnAggregated = col > args.RowFields.Size.NumberOfCols; - - // desc only applies at the depth that matches the sort column - bool descThisLevel = desc && depth == col; + if (args.SortOrders == null || args.SortOrders.All(s => s == 0)) return levels; if (args.FieldRelationship == FieldRelationship.Table) { - // Collect all leaf rows recursively var allRows = levels.SelectMany(l => CollectLeafRows(l)).ToList(); - allRows = SortRows(allRows, col, desc, sortOnAggregated); + allRows = SortRowsMulti(allRows, args); var newLevelDict = new Dictionary(); var newLevelOrder = new List(); @@ -84,45 +76,74 @@ private List ApplySort(List levels, GroupByBaseArgs args } else { - levels = sortOnAggregated - ? (descThisLevel ? levels.OrderByDescending(l => l.SubtotalValue as IComparable, _comparer).ToList() - : levels.OrderBy(l => l.SubtotalValue as IComparable, _comparer).ToList()) - : (descThisLevel ? levels.OrderByDescending(l => l.Key as IComparable, _comparer).ToList() - : levels.OrderBy(l => l.Key as IComparable, _comparer).ToList()); + 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 = SortRows(level.Rows, depth, depth == col ? desc : false, sortOnAggregated); + level.Rows = SortRowsMulti(level.Rows, args); } return levels; } } - private IEnumerable CollectLeafRows(GroupLevel level) - { - if (level.IsLeaf) - return level.Rows; - return level.Children.SelectMany(c => CollectLeafRows(c)); - } - - private List SortRows(List rows, int col, bool desc, bool sortOnAggregated) + private List SortRowsMulti(List rows, GroupByBaseArgs args) { if (rows == null || rows.Count == 0) return rows; - if (sortOnAggregated) + int nKeyCols = args.RowFields.Size.NumberOfCols; + IOrderedEnumerable ordered = null; + + foreach (var sortOrder in args.SortOrders) { - return desc ? rows.OrderByDescending(r => r.AggregatedValue as IComparable, _comparer).ToList() - : rows.OrderBy(r => r.AggregatedValue as IComparable, _comparer).ToList(); + 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); } - // col is 1-based, KeyParts is 0-based - int keyIndex = Math.Min(col - 1, rows[0].KeyParts.Length - 1); - return desc ? rows.OrderByDescending(r => r.KeyParts[keyIndex] as IComparable, _comparer).ToList() - : rows.OrderBy(r => r.KeyParts[keyIndex] as IComparable, _comparer).ToList(); + return ordered?.ToList() ?? rows; + } + + private IEnumerable CollectLeafRows(GroupLevel level) + { + if (level.IsLeaf) + return level.Rows; + return level.Children.SelectMany(c => CollectLeafRows(c)); } // ------------------------------------------------------- @@ -170,7 +191,7 @@ private InMemoryRange BuildResult(List levels, GroupByBaseArgs args, + grandTotalRows + (addFunctionHeaders && args.FunctionLayout == FunctionLayout.Horizontal ? 1 : 0); - var result = new InMemoryRange(totalRows, (short)nCols); // denna är ett för lite. TODO + var result = new InMemoryRange(totalRows, (short)nCols); int r = 0; if(addFunctionHeaders) @@ -252,7 +273,7 @@ private int WriteRows( result.SetValue(r, c, row.KeyParts[c]); result.SetValue(r, nKeyCols, functionHeaders[f]); for (int c = 0; c < nValCols; c++) - result.SetValue(r, nKeyCols + 1 + c, row.AggregatedValues[f]); + result.SetValue(r, nKeyCols + 1 + c, row.AggregatedValues[f][c]); r++; } } @@ -262,7 +283,7 @@ private int WriteRows( result.SetValue(r, c, row.KeyParts[c]); for (int f = 0; f < args.Functions.Count; f++) for (int c = 0; c < nValCols; c++) - result.SetValue(r, nKeyCols + f * nValCols + c, row.AggregatedValues[f]); + result.SetValue(r, nKeyCols + f * nValCols + c, row.AggregatedValues[f][c]); r++; } } @@ -311,7 +332,7 @@ private int WriteSubtotal(InMemoryRange result, int r, GroupLevel level, int nKe result.SetValue(r, c, string.Empty); result.SetValue(r, nKeyCols, functionHeaders[f]); for (int c = 0; c < nValCols; c++) - result.SetValue(r, nKeyCols + 1 + c, level.SubtotalValues[f]); + result.SetValue(r, nKeyCols + 1 + c, level.SubtotalValues[f][c]); r++; } } @@ -322,7 +343,7 @@ private int WriteSubtotal(InMemoryRange result, int r, GroupLevel level, int nKe result.SetValue(r, c, string.Empty); for (int f = 0; f < args.Functions.Count; f++) for (int c = 0; c < nValCols; c++) - result.SetValue(r, nKeyCols + f * nValCols + c, level.SubtotalValues[f]); + result.SetValue(r, nKeyCols + f * nValCols + c, level.SubtotalValues[f][c]); r++; } return r; @@ -331,6 +352,8 @@ 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); + int nAllValCols = args.AllValuesInOrder.Count > 0 ? args.AllValuesInOrder[0].Length : 1; + if (args.FunctionLayout == FunctionLayout.Vertical) { for (int f = 0; f < args.Functions.Count; f++) @@ -339,8 +362,14 @@ private int WriteGrandTotal(InMemoryRange result, int r, List levels for (int c = 1; c < nKeyCols; c++) result.SetValue(r, c, string.Empty); result.SetValue(r, nKeyCols, functionHeaders[f]); - result.SetValue(r, nKeyCols + 1, Aggregate(args.Functions[f], args.AllValuesInOrder, context, - args.Functions[f].EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null)); + for (int c = 0; c < nValCols; c++) + { + var colValues = args.AllValuesInOrder + .Select(v => new object[] { v[c] }) + .ToList(); + result.SetValue(r, nKeyCols + 1 + c, Aggregate(args.Functions[f], colValues, context, + args.Functions[f].EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null)); + } r++; } } @@ -350,8 +379,14 @@ private int WriteGrandTotal(InMemoryRange result, int r, List levels for (int c = 1; c < nKeyCols; c++) result.SetValue(r, c, string.Empty); for (int f = 0; f < args.Functions.Count; f++) - result.SetValue(r, nKeyCols + f * nValCols, Aggregate(args.Functions[f], args.AllValuesInOrder, context, - args.Functions[f].EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null)); + for (int c = 0; c < nValCols; c++) + { + var colValues = args.AllValuesInOrder + .Select(v => new object[] { v[c] }) + .ToList(); + result.SetValue(r, nKeyCols + f * nValCols + c, Aggregate(args.Functions[f], colValues, context, + args.Functions[f].EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null)); + } r++; } return r; diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupbyFunctionBase.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupbyFunctionBase.cs index 70cd2daba..87da31185 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupbyFunctionBase.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupbyFunctionBase.cs @@ -54,10 +54,10 @@ protected class GroupByBaseArgs public FunctionLayout FunctionLayout { get; set; } = FunctionLayout.Single; public FieldHeaders Headers { get; set; } = FieldHeaders.Missing; public int TotalDepth { get; set; } = 1; - public int SortOrder { get; set; } = 1; + public int[] SortOrders { get; set; } = new [] { 1 }; public IRangeInfo FilterArray { get; set; } = null; public FieldRelationship FieldRelationship { get; set; } = FieldRelationship.Hierarchy; - public List AllValuesInOrder { get; set; } = new List(); + public List AllValuesInOrder { get; set; } = new List(); } // ------------------------------------------------------- @@ -71,7 +71,7 @@ protected class GroupLevel public List ChildOrder { get; set; } = null; public List Rows { get; set; } = new List(); public object SubtotalValue { get; set; } - public List SubtotalValues { get; set; } + public List SubtotalValues { get; set; } = new List(); // [function][valueCol] public bool IsLeaf => Children.Count == 0; } @@ -80,7 +80,7 @@ protected class GroupRow public object[] KeyParts { get; set; } public List Values { get; set; } = new List(); public object AggregatedValue { get; set; } - public List AggregatedValues { get; set; } = new List(); // All function results + public List AggregatedValues { get; set; } = new List(); // [function][valueCol] } protected List ResolveFunctionHeaders(GroupByBaseArgs args) @@ -103,7 +103,7 @@ protected List ResolveFunctionHeaders(GroupByBaseArgs args) } // ------------------------------------------------------- - // Argument parsing (shared arguments 1-8) + // Argument parsing // ------------------------------------------------------- protected bool TryParseBaseArgs( IList arguments, @@ -150,7 +150,7 @@ protected bool TryParseBaseArgs( else return Fail(eErrorType.Value, out error); } - args.Function = args.Functions[0]; + args.Function = args.Functions[0]; } else { @@ -166,7 +166,11 @@ protected bool TryParseBaseArgs( var v = Convert.ToInt32(arguments[3].Value); if (!Enum.IsDefined(typeof(FieldHeaders), v)) return Fail(eErrorType.Value, out error); - args.Headers = (FieldHeaders)v; + 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; } // total_depth (optional) @@ -180,7 +184,23 @@ protected bool TryParseBaseArgs( // sort_order (optional) if (arguments.Count > 5 && arguments[5].Value != null) - args.SortOrder = Convert.ToInt32(arguments[5].Value); + { + 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) }; + } + } // filter_array (optional) if (arguments.Count > 6 && arguments[6].IsExcelRange) @@ -238,9 +258,7 @@ protected List BuildGroups(GroupByBaseArgs args, ParsingContext cont || resolvedHeaders == FieldHeaders.YesAndDontShow; bool multipleFunctions = args.Functions.Count > 1; int startRow = hasHeaders ? 1 : 0; - //int startRow = 0; - //if (hasHeaders) startRow++; - //if (multipleFunctions) startRow++; + int nKeyCols = args.RowFields.Size.NumberOfCols; int nValCols = args.Values.Size.NumberOfCols; @@ -278,7 +296,7 @@ protected List BuildGroups(GroupByBaseArgs args, ParsingContext cont currentOrder.Add(keyStr); } - if (depth < nKeyCols - 1) + if (depth < nKeyCols - 1) // If there are more cols after the current, add child. { if (currentLevel.ChildDict == null) { @@ -327,30 +345,60 @@ protected void AggregateTree(List levels, GroupByBaseArgs args, Pars { foreach (var row in level.Rows) { - row.AggregatedValues = args.Functions - .Select(f => Aggregate(f, row.Values, context, - f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null)) - .ToList(); - row.AggregatedValue = row.AggregatedValues[0]; + row.AggregatedValues = args.Functions.Select(f => + { + int nValCols = row.Values[0].Length; + var result = new object[nValCols]; + for (int col = 0; col < nValCols; col++) + { + var colValues = row.Values + .Select(v => new object[] { v[col] }) + .ToList(); + result[col] = Aggregate(f, colValues, context, + f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null); + } + return result; + }).ToList(); + row.AggregatedValue = row.AggregatedValues[0][0]; } var allVals = level.Rows.SelectMany(r => r.Values).ToList(); - level.SubtotalValues = args.Functions - .Select(f => Aggregate(f, allVals, context, - f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null)) - .ToList(); - level.SubtotalValue = level.SubtotalValues[0]; + level.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(); + level.SubtotalValue = level.SubtotalValues[0][0]; } else { AggregateTree(level.Children, args, context); var allVals = level.Children.SelectMany(c => GetAllValues(c)).ToList(); - level.SubtotalValues = args.Functions - .Select(f => Aggregate(f, allVals, context, - f.EtaFunction?.Name == "PERCENTOF" ? args.AllValuesInOrder : null)) - .ToList(); - level.SubtotalValue = level.SubtotalValues[0]; + level.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(); + level.SubtotalValue = level.SubtotalValues[0][0]; } } } @@ -377,7 +425,6 @@ protected object Aggregate(LambdaCalculator calculator, List values, P if(calculator.NumberOfVariables > 1 && allValues != null) { - // Special case with PERCENTOF where we have to handle two arguments with no input. int allRows = allValues.Count; int allCols = allValues.Count > 0 ? allValues[0].Length : 1; var allRange = new InMemoryRange(allRows, (short)allCols); @@ -388,6 +435,5 @@ protected object Aggregate(LambdaCalculator calculator, List values, P } return calculator.Execute(context).ResultValue; } - } } diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/Text/ArrayToText.cs b/src/EPPlus/FormulaParsing/Excel/Functions/Text/ArrayToText.cs index 7febf38a5..242f4dd59 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/Text/ArrayToText.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/Text/ArrayToText.cs @@ -35,12 +35,12 @@ internal class ArrayToText : ExcelFunction public override CompileResult Execute(IList arguments, ParsingContext context) { + var format = ConciceFormat; if (!arguments.First().IsExcelRange) { - return CompileResult.GetErrorResult(eErrorType.Value); - } - var range = arguments.First().ValueAsRangeInfo; - var format = ConciceFormat; + return CreateResult(GetStringVal(arguments.First().Value, format), DataType.String); + } + var range = arguments.First().ValueAsRangeInfo; if (arguments.Count > 1) { format = ArgToInt(arguments, 1, out ExcelErrorValue e1); diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs index 9b3894d0b..ac28c8f5c 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs @@ -66,10 +66,9 @@ public void GroupByLambda() s.Calculate(); Assert.AreEqual("Anna", s.Cells["C1"].Value); Assert.AreEqual("Bertil", s.Cells["C2"].Value); - Assert.AreEqual("Joe", s.Cells["C3"].Value); - //Assert.AreEqual(1.33333333333333d, s.Cells["D1"].Value); + Assert.AreEqual("Joe", s.Cells["C3"].Value); Assert.AreEqual(2d, s.Cells["D2"].Value); - Assert.AreEqual(0.6667d, s.Cells["D3"].Value); + Assert.AreEqual(0.6667d, System.Math.Round((double)s.Cells["D3"].Value, 4)); Assert.AreEqual("Total", s.Cells["C4"].Value); Assert.AreEqual(4d, s.Cells["D4"].Value); } @@ -82,10 +81,11 @@ public void GroupByFieldHeaders() { var s = package.Workbook.Worksheets.Add("test"); s.Cells["A1"].Value = "A"; - s.Cells["A2"].Value = "B"; + s.Cells["A2"].Value = "B"; s.Cells["A3"].Value = "A"; s.Cells["A4"].Value = "A"; s.Cells["A5"].Value = "B"; + s.Cells["B1"].Value = "X"; s.Cells["B2"].Value = "X"; s.Cells["B3"].Value = "Y"; @@ -183,7 +183,6 @@ public void GroupByFieldRelationship() Assert.AreEqual(20d, s.Cells["E2"].Value); Assert.AreEqual("Mar", s.Cells["C3"].Value); Assert.AreEqual(108d, s.Cells["E3"].Value); - // Det ska inte gå att ha med subtotaler, subtotals are not supported } } @@ -214,7 +213,6 @@ public void GroupByFieldRelationship2() Assert.AreEqual("Mar", s.Cells["C3"].Value); Assert.AreEqual(108d, s.Cells["D3"].Value); } - // Det ska inte gå att ha med subtotaler, subtotals are not supported } [TestMethod] public void GroupBy_NoTotals_ShouldNotIncludeTotalRow() @@ -231,7 +229,6 @@ public void GroupBy_NoTotals_ShouldNotIncludeTotalRow() s.Cells["B3"].Value = 3; s.Cells["B4"].Value = 0; - // fieldSettings = 0 stänger av totalsraden s.Cells["C1"].Formula = "GROUPBY(A1:A4, B1:B4, _xleta.SUM,, 0)"; s.Calculate(); @@ -242,7 +239,6 @@ public void GroupBy_NoTotals_ShouldNotIncludeTotalRow() Assert.AreEqual(3d, s.Cells["D2"].Value); Assert.AreEqual(1d, s.Cells["D3"].Value); - // C4 ska vara tom – ingen totalsrad när fieldSettings = 0 Assert.AreNotEqual(s.Cells["C4"].Value, "Total"); Assert.AreNotEqual(s.Cells["D4"].Value, 0d); } @@ -342,22 +338,6 @@ public void GroupByAVERAGE() } } - [TestMethod] - public void GroupByTextFunction2() - { - // REMINDER: ARRAYTOTEXT verkar inte fungera som den ska. Kan inte hantera singel cell adress till funktionen, vilket den kan i excel. - using (var package = new ExcelPackage()) - { - var s = package.Workbook.Worksheets.Add("test"); - s.Cells["B4"].Value = "Gick"; - - s.Cells["C1"].Formula = "ARRAYTOTEXT(B4)"; - s.Calculate(); - - //Assert.AreEqual("Gick", s.Cells["C1"].Value); - } - } - [TestMethod] public void GroupByShouldInsertZeroWhenEmptyAndNumericFunction() { @@ -492,7 +472,6 @@ public void GroupByMultipleFunctionsCustomLambda() } } - [TestMethod] public void GroupByMultipleFunctionsCustomLambda2() { @@ -512,41 +491,42 @@ public void GroupByMultipleFunctionsCustomLambda2() s.Cells["B4"].Value = 3; s.Cells["B6"].Value = 4; - s.Cells["C1"].Formula = "GROUPBY(A1:A6, B1:B6,HSTACK(LAMBDA(x,SUM(x *2/3)), _xleta.COUNT, LAMBDA(x,SUM(x *2)) ) )"; + s.Cells["C1"].Formula = "GROUPBY(A1:A6, B1:B6,HSTACK(_xleta.COUNT, LAMBDA(x,SUM(x *2/3)), _xleta.PERCENTOF, LAMBDA(x,SUM(x *2)) ) )"; // LAMBDA(x, SUM(x*4/2)) LAMBDA(x,SUM(x *2/3)) s.Calculate(); - Assert.AreEqual(null, s.Cells["C1"].Value); Assert.AreEqual("COUNT", s.Cells["D1"].Value); Assert.AreEqual("CUSTOM1", s.Cells["E1"].Value); Assert.AreEqual("PERCENTOF", s.Cells["F1"].Value); - Assert.AreEqual("CUSTOM2", s.Cells["E1"].Value); + Assert.AreEqual("CUSTOM2", s.Cells["G1"].Value); } } - [TestMethod] - public void GroupByMultipleFunctionsCustomLambda3() + public void GroupBySortByArrayInput() { using (var package = new ExcelPackage()) { var s = package.Workbook.Worksheets.Add("test"); - - s.Cells["A1"].Value = "Rubrik"; - s.Cells["A2"].Value = "B"; - s.Cells["A3"].Value = "A"; - s.Cells["A4"].Value = "B"; - s.Cells["A5"].Value = "A"; - s.Cells["A6"].Value = "C"; - - s.Cells["B1"].Value = "Siffor"; - s.Cells["B2"].Value = 1; - s.Cells["B4"].Value = 3; - s.Cells["B6"].Value = 4; - - s.Cells["C1"].Formula = "HSTACK(LAMBDA(x,x), _xleta.COUNT, LAMBDA(x,x))"; - // LAMBDA(x, SUM(x*4/2)) LAMBDA(x,SUM(x *2/3)) + s.Cells["A2"].Value = "A"; + s.Cells["A3"].Value = "B"; + s.Cells["B2"].Value = "C"; + s.Cells["B3"].Value = "A"; + s.Cells["C2"].Value = "A"; + s.Cells["C3"].Value = "B"; + s.Cells["D2"].Value = "C"; + s.Cells["D3"].Value = "A"; + + s.Cells["E2"].Value = 4; + s.Cells["E3"].Value = 2; + s.Cells["F2"].Value = 6; + s.Cells["F3"].Value = 5; + s.Cells["F6"].Formula = "GROUPBY(A2:D3,E2:F3,_xleta.SUM,,,{-1,2,3})"; s.Calculate(); + + Assert.AreEqual(s.Cells["F6"].Value, "B"); + Assert.AreEqual(s.Cells["J8"].Value, 6d); + Assert.AreEqual(s.Cells["K8"].Value, 11d); } } From 3b15b673f06468f0b953ae99b24c890256a07e60 Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Thu, 9 Apr 2026 12:54:51 +0200 Subject: [PATCH 12/14] Fixed failing test in AppVeyor --- .../Excel/Functions/RefAndLookup/GroupByTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs index ac28c8f5c..171b7bb56 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs @@ -18,7 +18,7 @@ namespace EPPlusTest.FormulaParsing.Excel.Functions.RefAndLookup { [TestClass] - public class GroupByTests + public class GroupByTests : TestBase { [TestMethod] @@ -296,6 +296,7 @@ public void GroupByTextFunction() { using (var package = new ExcelPackage()) { + SwitchToCulture(); var s = package.Workbook.Worksheets.Add("test"); s.Cells["A1"].Value = "Kalle"; s.Cells["A2"].Value = "Alice"; @@ -309,8 +310,8 @@ public void GroupByTextFunction() s.Cells["C1"].Formula = "GROUPBY(A1:A4, B1:B4, _xleta.ARRAYTOTEXT)"; s.Calculate(); - Assert.AreEqual("Hoppade; Sprang; Hoppade; Gick", s.Cells["D4"].Value); + SwitchBackToCurrentCulture(); } } From f81449fc2adc1af2f73aae355512a99eb0c049fe Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Thu, 9 Apr 2026 13:24:29 +0200 Subject: [PATCH 13/14] Fix for AppVeyor test --- .../FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs index 171b7bb56..f270fd0fb 100644 --- a/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs @@ -310,7 +310,7 @@ public void GroupByTextFunction() s.Cells["C1"].Formula = "GROUPBY(A1:A4, B1:B4, _xleta.ARRAYTOTEXT)"; s.Calculate(); - Assert.AreEqual("Hoppade; Sprang; Hoppade; Gick", s.Cells["D4"].Value); + Assert.AreEqual("Hoppade, Sprang, Hoppade, Gick", s.Cells["D4"].Value); SwitchBackToCurrentCulture(); } } From ac8529ca1151714795761811fa09179373f91399 Mon Sep 17 00:00:00 2001 From: KarlKallman Date: Thu, 9 Apr 2026 15:05:41 +0200 Subject: [PATCH 14/14] Broke out protected classes from GroupByFunctionBase --- .../Excel/Functions/BuiltInFunctions.cs | 2 +- .../Excel/Functions/RefAndLookup/Groupby.cs | 10 +-- .../GroupingFunctions/FieldHeaders.cs | 24 ++++++ .../GroupingFunctions/FieldRelationship.cs | 21 +++++ .../GroupingFunctions/FunctionLayout.cs | 22 +++++ .../GroupingFunctions/GroupByBaseArgs.cs | 33 ++++++++ .../GroupByFunctionBase.cs} | 84 ++++--------------- .../GroupingFunctions/GroupLevel.cs | 29 +++++++ .../GroupingFunctions/GroupRow.cs | 24 ++++++ 9 files changed, 172 insertions(+), 77 deletions(-) create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/FieldHeaders.cs create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/FieldRelationship.cs create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/FunctionLayout.cs create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupByBaseArgs.cs rename src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/{GroupbyFunctionBase.cs => GroupingFunctions/GroupByFunctionBase.cs} (83%) create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupLevel.cs create mode 100644 src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupRow.cs diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs b/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs index 15b52c42d..3b956d0db 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs @@ -336,7 +336,7 @@ public BuiltInFunctions() // Reference and lookup Functions["address"] = new Address(); Functions["areas"] = new Areas(); - Functions["groupby"] = new Groupby(); + Functions["groupby"] = new GroupBy(); 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 c93d81fbd..8785bcd6e 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs @@ -12,25 +12,21 @@ Date Author Change *************************************************************************************************/ using OfficeOpenXml.FormulaParsing.Excel.Functions.Metadata; -using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.LookupUtils; -using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.Sorting; +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.Net; -using System.Runtime.CompilerServices; -using System.Text; namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup { [FunctionMetadata( Category = ExcelFunctionCategory.LookupAndReference, - EPPlusVersion = "", + EPPlusVersion = "8.6", Description = "Allows you to create a summary of your data via a formula. Supports grouping along one axis and aggregating the associated values.")] - internal class Groupby : GroupbyFunctionBase + internal class GroupBy : GroupByFunctionBase { public override string NamespacePrefix => "_xlfn."; public override bool ExecutesLambda => true; diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/FieldHeaders.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/FieldHeaders.cs new file mode 100644 index 000000000..80b61f6e3 --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/FieldHeaders.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 + ************************************************************************************************* + 19/3/2026 EPPlus Software AB EPPlus v8.6 + *************************************************************************************************/ + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions +{ + internal enum FieldHeaders + { + Missing = -1, + No = 0, + YesAndDontShow = 1, + NoButGenerate = 2, + YesAndShow = 3 + } +} diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/FieldRelationship.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/FieldRelationship.cs new file mode 100644 index 000000000..aee5b1a49 --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/FieldRelationship.cs @@ -0,0 +1,21 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + 19/3/2026 EPPlus Software AB EPPlus v8.6 + *************************************************************************************************/ + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions +{ + internal enum FieldRelationship + { + Hierarchy = 0, + Table = 1 + } +} diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/FunctionLayout.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/FunctionLayout.cs new file mode 100644 index 000000000..d201a05f6 --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/FunctionLayout.cs @@ -0,0 +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 + ************************************************************************************************* + 19/3/2026 EPPlus Software AB EPPlus v8.6 + *************************************************************************************************/ + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions +{ + internal enum FunctionLayout + { + Single, + Horizontal, // HSTACK - results are added as columns + Vertical // VSTACK - results are added as rows + } +} diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupByBaseArgs.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupByBaseArgs.cs new file mode 100644 index 000000000..88a04bb85 --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupByBaseArgs.cs @@ -0,0 +1,33 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + 19/3/2026 EPPlus Software AB EPPlus v8.6 + *************************************************************************************************/ +using OfficeOpenXml.FormulaParsing.FormulaExpressions; +using System.Collections.Generic; + + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions +{ + internal class GroupByBaseArgs + { + public IRangeInfo RowFields { get; set; } + public IRangeInfo Values { get; set; } + public LambdaCalculator Function { get; set; } + public List Functions { get; set; } = new List(); + public FunctionLayout FunctionLayout { get; set; } = FunctionLayout.Single; + public FieldHeaders Headers { get; set; } = FieldHeaders.Missing; + public int TotalDepth { get; set; } = 1; + public int[] SortOrders { get; set; } = new[] { 1 }; + public IRangeInfo FilterArray { get; set; } = null; + public FieldRelationship FieldRelationship { get; set; } = FieldRelationship.Hierarchy; + public List AllValuesInOrder { get; set; } = new List(); + } +} diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupbyFunctionBase.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupByFunctionBase.cs similarity index 83% rename from src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupbyFunctionBase.cs rename to src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupByFunctionBase.cs index 87da31185..770283a1b 100644 --- a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupbyFunctionBase.cs +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupByFunctionBase.cs @@ -1,5 +1,16 @@ -using OfficeOpenXml.FormulaParsing.Excel.Functions.DateAndTime; -using OfficeOpenXml.FormulaParsing.Excel.Functions.MathFunctions; +/************************************************************************************************* + 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 + ************************************************************************************************* + 19/3/2026 EPPlus Software AB EPPlus v8.6 + *************************************************************************************************/ + using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.LookupUtils; using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.Sorting; using OfficeOpenXml.FormulaParsing.FormulaExpressions; @@ -7,82 +18,17 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text; -namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions { - internal abstract class GroupbyFunctionBase : ExcelFunction + internal abstract class GroupByFunctionBase : ExcelFunction { protected readonly LookupComparerBase _comparer = new SortByComparer(); - // ------------------------------------------------------- - // Enums & Constants - // ------------------------------------------------------- - protected enum FieldHeaders - { - Missing = -1, - No = 0, - YesAndDontShow = 1, - NoButGenerate = 2, - YesAndShow = 3 - } - protected const int TotalDepthNoTotals = 0; protected const int TotalDepthGrandOnly = 1; - protected enum FieldRelationship - { - Hierarchy = 0, - Table = 1 - } - protected enum FunctionLayout - { - Single, - Horizontal, // HSTACK - results are added as columns - Vertical // VSTACK - results are added as rows - } - // ------------------------------------------------------- - // Shared argument container - // ------------------------------------------------------- - protected class GroupByBaseArgs - { - public IRangeInfo RowFields { get; set; } - public IRangeInfo Values { get; set; } - public LambdaCalculator Function { get; set; } - public List Functions { get; set; } = new List(); - public FunctionLayout FunctionLayout { get; set; } = FunctionLayout.Single; - public FieldHeaders Headers { get; set; } = FieldHeaders.Missing; - public int TotalDepth { get; set; } = 1; - public int[] SortOrders { get; set; } = new [] { 1 }; - public IRangeInfo FilterArray { get; set; } = null; - public FieldRelationship FieldRelationship { get; set; } = FieldRelationship.Hierarchy; - public List AllValuesInOrder { get; set; } = new List(); - } - - // ------------------------------------------------------- - // Shared data structures - // ------------------------------------------------------- - protected class GroupLevel - { - public object Key { get; set; } - public List Children { get; set; } = new List(); - public Dictionary ChildDict { get; set; } = null; - public List ChildOrder { get; set; } = null; - public List Rows { get; set; } = new List(); - public object SubtotalValue { get; set; } - public List SubtotalValues { get; set; } = new List(); // [function][valueCol] - public bool IsLeaf => Children.Count == 0; - } - - protected class GroupRow - { - public object[] KeyParts { get; set; } - public List Values { get; set; } = new List(); - public object AggregatedValue { get; set; } - public List AggregatedValues { get; set; } = new List(); // [function][valueCol] - } - protected List ResolveFunctionHeaders(GroupByBaseArgs args) { var names = args.Functions diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupLevel.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupLevel.cs new file mode 100644 index 000000000..a9fc7b952 --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupLevel.cs @@ -0,0 +1,29 @@ +/************************************************************************************************* + 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 + ************************************************************************************************* + 19/3/2026 EPPlus Software AB EPPlus v8.6 + *************************************************************************************************/ +using System.Collections.Generic; + + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions +{ + internal class GroupLevel + { + public object Key { get; set; } + public List Children { get; set; } = new List(); + public Dictionary ChildDict { get; set; } = null; + public List ChildOrder { get; set; } = null; + public List Rows { get; set; } = new List(); + public object SubtotalValue { get; set; } + public List SubtotalValues { get; set; } = new List(); // [function][valueCol] + public bool IsLeaf => Children.Count == 0; + } +} diff --git a/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupRow.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupRow.cs new file mode 100644 index 000000000..f93f40bf1 --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupRow.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 + ************************************************************************************************* + 19/3/2026 EPPlus Software AB EPPlus v8.6 + *************************************************************************************************/ +using System.Collections.Generic; + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions +{ + internal class GroupRow + { + public object[] KeyParts { get; set; } + public List Values { get; set; } = new List(); + public object AggregatedValue { get; set; } + public List AggregatedValues { get; set; } = new List(); // [function][valueCol] + } +}