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/EPPlus/FormulaParsing/DependencyChain/RpnFormulaExecution.cs b/src/EPPlus/FormulaParsing/DependencyChain/RpnFormulaExecution.cs index 42e29e4c8..3bc997da6 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) { @@ -1140,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(); @@ -1155,10 +1163,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()); @@ -1735,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/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs b/src/EPPlus/FormulaParsing/Excel/Functions/BuiltInFunctions.cs index fc6fa2dbb..3b956d0db 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/ExcelFunction.cs b/src/EPPlus/FormulaParsing/Excel/Functions/ExcelFunction.cs index e6de1e09d..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. /// @@ -895,10 +903,21 @@ public virtual bool IsAllowedInCalculatedPivotTableField return true; } } - /// - /// Provides information about the functions parameters. - /// - public virtual ExcelFunctionParametersInfo ParametersInfo + /// + /// The function is allowed... + /// + 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/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 new file mode 100644 index 000000000..8785bcd6e --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/Groupby.cs @@ -0,0 +1,400 @@ +/************************************************************************************************* + 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.GroupingFunctions; +using OfficeOpenXml.FormulaParsing.FormulaExpressions; +using OfficeOpenXml.FormulaParsing.Ranges; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup +{ + [FunctionMetadata( + Category = ExcelFunctionCategory.LookupAndReference, + EPPlusVersion = "8.6", + Description = "Allows you to create a summary of your data via a formula. Supports grouping along one axis and aggregating the associated values.")] + + internal class GroupBy : GroupByFunctionBase + { + public override string NamespacePrefix => "_xlfn."; + public override bool ExecutesLambda => true; + public override int ArgumentMinLength => 3; + + public override CompileResult Execute(IList arguments, ParsingContext context) + { + if (!TryParseBaseArgs(arguments, out var args, out var error)) + return error; + var groups = BuildGroups(args, context); + groups = ApplySort(groups, args); + var result = BuildResult(groups, args, context); + + return CreateDynamicArrayResult(result, DataType.ExcelRange); + } + + // ------------------------------------------------------- + // Sorting + // ------------------------------------------------------- + private List ApplySort(List levels, GroupByBaseArgs args, int depth = 1) + { + if (args.SortOrders == null || args.SortOrders.All(s => s == 0)) return levels; + + if (args.FieldRelationship == FieldRelationship.Table) + { + var allRows = levels.SelectMany(l => CollectLeafRows(l)).ToList(); + allRows = SortRowsMulti(allRows, args); + + var newLevelDict = new Dictionary(); + var newLevelOrder = new List(); + foreach (var row in allRows) + { + var topKey = (row.KeyParts[0]?.ToString() ?? string.Empty).ToLowerInvariant(); + if (!newLevelDict.TryGetValue(topKey, out var level)) + { + level = new GroupLevel { Key = row.KeyParts[0] }; + newLevelDict[topKey] = level; + newLevelOrder.Add(topKey); + } + level.Rows.Add(row); + } + return newLevelOrder.Select(k => newLevelDict[k]).ToList(); + } + else + { + var sortForThisLevel = args.SortOrders + .FirstOrDefault(s => Math.Abs(s) == depth); + + bool hasSortForThisLevel = sortForThisLevel != 0; + bool desc = sortForThisLevel < 0; + bool sortOnAggregated = hasSortForThisLevel && Math.Abs(sortForThisLevel) > args.RowFields.Size.NumberOfCols; + + if (hasSortForThisLevel) + { + levels = sortOnAggregated + ? (desc ? levels.OrderByDescending(l => l.SubtotalValue as IComparable, _comparer).ToList() + : levels.OrderBy(l => l.SubtotalValue as IComparable, _comparer).ToList()) + : (desc ? levels.OrderByDescending(l => l.Key as IComparable, _comparer).ToList() + : levels.OrderBy(l => l.Key as IComparable, _comparer).ToList()); + } + + foreach (var level in levels) + { + if (!level.IsLeaf) + level.Children = ApplySort(level.Children, args, depth + 1); + else + level.Rows = SortRowsMulti(level.Rows, args); + } + + return levels; + } + } + + private List SortRowsMulti(List rows, GroupByBaseArgs args) + { + if (rows == null || rows.Count == 0) return rows; + + int nKeyCols = args.RowFields.Size.NumberOfCols; + IOrderedEnumerable ordered = null; + + foreach (var sortOrder in args.SortOrders) + { + if (sortOrder == 0) continue; + bool desc = sortOrder < 0; + int col = Math.Abs(sortOrder); + bool sortOnAggregated = col > nKeyCols; + + // Capture loop variables + var capturedCol = col; + var capturedSortOnAggregated = sortOnAggregated; + + Func keySelector = capturedSortOnAggregated + ? (Func)(r => r.AggregatedValue) + : (r => r.KeyParts[Math.Min(capturedCol - 1, r.KeyParts.Length - 1)]); + + if (ordered == null) + ordered = desc + ? rows.OrderByDescending(keySelector, _comparer) + : rows.OrderBy(keySelector, _comparer); + else + ordered = desc + ? ordered.ThenByDescending(keySelector, _comparer) + : ordered.ThenBy(keySelector, _comparer); + } + + return ordered?.ToList() ?? rows; + } + + private IEnumerable CollectLeafRows(GroupLevel level) + { + if (level.IsLeaf) + return level.Rows; + return level.Children.SelectMany(c => CollectLeafRows(c)); + } + + // ------------------------------------------------------- + // Build result + // ------------------------------------------------------- + + private InMemoryRange BuildResult(List levels, GroupByBaseArgs args, ParsingContext context) + { + 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; + 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 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) * (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 + + (addFunctionHeaders && args.FunctionLayout == FunctionLayout.Horizontal ? 1 : 0); + + var result = new InMemoryRange(totalRows, (short)nCols); + int r = 0; + + 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()); + + 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++; + } + + string grandTotalStr = grandAndSub ? "Grand Total" : "Total"; + + if (totalsAtTop && showTotals) + r = WriteGrandTotal(result, r, levels, grandTotalStr, nKeyCols, nValCols, args, context); + + r = WriteRows(result, r, levels, subtotalDepth, totalsAtTop, nKeyCols, nValCols, args, depth: 1); + + if (totalsAtEnd && showTotals) + WriteGrandTotal(result, r, levels, grandTotalStr, nKeyCols, nValCols, args, context); + + return result; + } + + private int WriteRows( + 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; + + if (writeSubtotal && subtotalsAtTop) + r = WriteSubtotal(result, r, level, nKeyCols, nValCols, args); + + if (level.IsLeaf) + { + foreach (var row in level.Rows) + { + 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][c]); + 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][c]); + r++; + } + } + } + else + { + r = WriteRows(result, r, level.Children, subtotalDepth, subtotalsAtTop, nKeyCols, nValCols, args, depth + 1); + } + + if (writeSubtotal && !subtotalsAtTop) + r = WriteSubtotal(result, r, level, nKeyCols, nValCols, args); + } + return r; + } + + + 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, GroupByBaseArgs args) + { + 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][c]); + 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][c]); + r++; + } + return r; + } + + 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++) + { + result.SetValue(r, 0, label); + 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++) + { + 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++; + } + } + 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++) + 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; + } + + /// Recursively collects all AggregatedValues from leaf GroupRows. + private IEnumerable CollectAggregatedValues(GroupLevel level) + { + if (level.IsLeaf) + return level.Rows.Select(r => r.AggregatedValue); + + return level.Children.SelectMany(c => CollectAggregatedValues(c)); + } + } +} 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/GroupingFunctions/GroupByFunctionBase.cs b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupByFunctionBase.cs new file mode 100644 index 000000000..770283a1b --- /dev/null +++ b/src/EPPlus/FormulaParsing/Excel/Functions/RefAndLookup/GroupingFunctions/GroupByFunctionBase.cs @@ -0,0 +1,385 @@ +/************************************************************************************************* + 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; +using OfficeOpenXml.FormulaParsing.Ranges; +using System; +using System.Collections.Generic; +using System.Linq; + + +namespace OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup.GroupingFunctions +{ + internal abstract class GroupByFunctionBase : ExcelFunction + { + protected readonly LookupComparerBase _comparer = new SortByComparer(); + + protected const int TotalDepthNoTotals = 0; + protected const int TotalDepthGrandOnly = 1; + + protected List ResolveFunctionHeaders(GroupByBaseArgs args) + { + var names = args.Functions + .Select(f => f.EtaFunction != null ? f.EtaFunction.Name : "CUSTOM") + .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 + // ------------------------------------------------------- + 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) + { + // 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); + } + + if (args.Functions.Count == 0) + return Fail(eErrorType.Value, out error); + + // field_headers (optional) + if (arguments.Count > 3 && arguments[3].Value != null) + { + var v = Convert.ToInt32(arguments[3].Value); + if (!Enum.IsDefined(typeof(FieldHeaders), v)) + return Fail(eErrorType.Value, out error); + args.Headers = (FieldHeaders)v; + } + else if(args.Functions.Count > 1) // In excel, if multiple functions are included, headers are by default displayed. + { + args.Headers = FieldHeaders.YesAndShow; + } + + // 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) + { + 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) + 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; + bool multipleFunctions = args.Functions.Count > 1; + 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 there are more cols after the current, add child. + { + 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, 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, GroupByBaseArgs args, ParsingContext context) + { + foreach (var level in levels) + { + if (level.IsLeaf) + { + foreach (var row in level.Rows) + { + 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 => + { + 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 => + { + 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]; + } + } + } + + 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, List allValues = null) + { + 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); + + if(calculator.NumberOfVariables > 1 && allValues != null) + { + 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/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] + } +} 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/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/EPPlus/FormulaParsing/FormulaExpressions/LambdaEtaExpression.cs b/src/EPPlus/FormulaParsing/FormulaExpressions/LambdaEtaExpression.cs index 990ad0392..9c15d96b1 100644 --- a/src/EPPlus/FormulaParsing/FormulaExpressions/LambdaEtaExpression.cs +++ b/src/EPPlus/FormulaParsing/FormulaExpressions/LambdaEtaExpression.cs @@ -54,12 +54,26 @@ 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); } - 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 new file mode 100644 index 000000000..f270fd0fb --- /dev/null +++ b/src/EPPlusTest/FormulaParsing/Excel/Functions/RefAndLookup/GroupByTests.cs @@ -0,0 +1,537 @@ +using FakeItEasy.Configuration; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml; +using OfficeOpenXml.Drawing.Chart; +using OfficeOpenXml.Drawing.Chart.ChartEx; +using OfficeOpenXml.FormulaParsing.Excel.Functions.Database; +using OfficeOpenXml.FormulaParsing.Excel.Functions.Information; +using OfficeOpenXml.FormulaParsing.Excel.Functions.RefAndLookup; +using OfficeOpenXml.FormulaParsing.Excel.Functions.Statistical; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using static OfficeOpenXml.FormulaParsing.Excel.Functions.Engineering.Conversions; + + +namespace EPPlusTest.FormulaParsing.Excel.Functions.RefAndLookup +{ + [TestClass] + public class GroupByTests : TestBase + { + + [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 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(2d, s.Cells["D2"].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); + } + } + + [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 = "=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); + } + } + + [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); + 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); + } + } + [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; + + 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); + + Assert.AreNotEqual(s.Cells["C4"].Value, "Total"); + Assert.AreNotEqual(s.Cells["D4"].Value, 0d); + } + } + + [TestMethod] + public void GroupBySortingMultipleCols() + { + 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,0,2,-1,,0)"; + s.Calculate(); + + Assert.AreEqual("Stockholm", s.Cells["E1"].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()) + { + SwitchToCulture(); + 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); + SwitchBackToCurrentCulture(); + } + } + + [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 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))"; + 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 GroupByMultipleFunctions2() + { + 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, _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)), 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["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(_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("COUNT", s.Cells["D1"].Value); + Assert.AreEqual("CUSTOM1", s.Cells["E1"].Value); + Assert.AreEqual("PERCENTOF", s.Cells["F1"].Value); + Assert.AreEqual("CUSTOM2", s.Cells["G1"].Value); + } + } + + [TestMethod] + public void GroupBySortByArrayInput() + { + using (var package = new ExcelPackage()) + { + var s = package.Workbook.Worksheets.Add("test"); + 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); + } + } + + // 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 + } +}