Add multi-key sort, advanced filter criteria, per-column AutoFilter API

Three independent additions to the Spreadsheet API, motivated by the
gap analysis against Excel COM, Office.js, Aspose, and Syncfusion:

Multi-key sort (Pass 3)

- New SortKey class: ColumnIndex (relative to the sort range), Order,
  SortOn, CustomList, CustomColor, CaseSensitive.
- New SortOn enum: Values, CellColor, FontColor.
- Worksheet.Sort(range, params SortKey[]) — stable, supports multiple
  sort levels, custom-list ordering, case sensitivity, sort by color.
- Table.Sort(params SortKey[]) overload using table-relative indices.
- Existing single-key Sort(range, order, keyIndex) retained for
  back-compat; redirects to the new API.
- Empty cells always sort to the bottom regardless of direction
  (matches Excel and pre-existing test).
- Cell.Clone now preserves Format (was dropped on the floor; the bug
  was masked because no caller of Clone read Format until color sort).

Advanced filter criteria (Pass 2A)

- TopFilterCriterion — Top/Bottom N items, by count or percent.
- DynamicFilterCriterion — full DynamicFilterType enum with 34 values
  covering above/below average, today/yesterday/tomorrow, this/last/
  next week/month/quarter/year, year-to-date, individual months, and
  Q1..Q4.
- CellColorFilterCriterion — match cells by background or font color.
- New OnApply(sheet, range) hook on FilterCriterion lets distribution-
  aware criteria (Top, AboveAverage) compute thresholds before the
  per-row Matches loop. Default no-op preserves existing behavior.
- Visitor extended with three new Visit overloads + base no-op
  defaults.

Per-column AutoFilter API (Pass 2B)

- AutoFilter.ApplyValueFilter(col, values), ApplyCustomFilter(col,
  criterion), ApplyTopFilter(col, count, percent, bottom),
  ApplyDynamicFilter(col, type), ApplyColorFilter(col, color,
  fontColor), ClearColumnFilter(col), GetColumnFilter(col).
- Column indices on these methods are RELATIVE to the AutoFilter
  range, matching the convention used by SortKey and Office.js
  TableSort.

Tests

- MultiKeySortContractTests: 11 facts (multi-key, custom list,
  case-sensitive, color sort, range bounds, no-op edges).
- FilterCriterionContractTests: 11 facts (Top/Bottom by count and
  percent, AboveAverage, Today, ThisMonth, Quarter1, color filters,
  visitor dispatch).
- AutoFilterPerColumnTests: 12 facts (each Apply* method, replace
  semantics, per-column clear, validation errors).

3,706 tests pass.
This commit is contained in:
Atanas Korchev
2026-05-07 12:38:56 +03:00
committed by Vladimir Enchev
parent bb20899bed
commit 52e1d29585
8 changed files with 1407 additions and 103 deletions

View File

@@ -0,0 +1,181 @@
using System.Linq;
using Radzen.Documents.Spreadsheet;
using Xunit;
namespace Radzen.Blazor.Spreadsheet.Tests;
#nullable enable
// Contract for the new per-column filter API on AutoFilter (Pass 2B).
// Column indices are RELATIVE to the auto-filter range.
public class AutoFilterPerColumnTests
{
private static (Workbook wb, Worksheet ws, AutoFilter af) Build(int rows = 11, int cols = 3)
{
var wb = new Workbook();
var ws = wb.AddSheet("Sheet1", rows, cols);
ws.Cells[0, 0].SetValue("Name");
ws.Cells[0, 1].SetValue("Region");
ws.Cells[0, 2].SetValue("Amount");
for (var r = 1; r < rows; r++)
{
ws.Cells[r, 0].SetValue($"Row{r}");
ws.Cells[r, 1].SetValue(r % 2 == 0 ? "EMEA" : "AMER");
ws.Cells[r, 2].Value = (double)(r * 10);
}
ws.AutoFilter.Range = new RangeRef(new CellRef(0, 0), new CellRef(rows - 1, cols - 1));
return (wb, ws, ws.AutoFilter);
}
private static int VisibleRowsBetween(Worksheet ws, int from, int to)
{
var n = 0;
for (var r = from; r <= to; r++)
if (!ws.Rows.IsHidden(r)) n++;
return n;
}
// ── ApplyValueFilter ────────────────────────────────────────────────────
[Fact]
public void ApplyValueFilter_ShouldHideRowsNotInTheValueList()
{
var (_, ws, af) = Build();
af.ApplyValueFilter(1, ["EMEA"]);
// Header always visible; only EMEA rows (even row indices 2, 4, 6, 8, 10) visible
Assert.False(ws.Rows.IsHidden(0));
for (var r = 1; r < 11; r++)
{
var expectedHidden = r % 2 != 0;
Assert.Equal(expectedHidden, ws.Rows.IsHidden(r));
}
}
[Fact]
public void ApplyValueFilter_MultipleValues_ShouldKeepAnyMatch()
{
var (_, ws, af) = Build();
af.ApplyValueFilter(1, ["EMEA", "AMER"]);
// Both values cover all data rows; only header + every data row stays visible
Assert.Equal(11, VisibleRowsBetween(ws, 0, 10));
}
// ── ApplyCustomFilter ───────────────────────────────────────────────────
[Fact]
public void ApplyCustomFilter_GreaterThan_ShouldFilterByCriterion()
{
var (_, ws, af) = Build();
af.ApplyCustomFilter(2, new GreaterThanCriterion { Column = 2, Value = 50.0 });
// Amount = 10..100; >50 keeps rows 6..10
for (var r = 1; r <= 5; r++) Assert.True(ws.Rows.IsHidden(r));
for (var r = 6; r <= 10; r++) Assert.False(ws.Rows.IsHidden(r));
}
// ── ApplyTopFilter / ApplyDynamicFilter / ApplyColorFilter ──────────────
[Fact]
public void ApplyTopFilter_TopThreeItems_ShouldKeepHighest()
{
var (_, ws, af) = Build();
af.ApplyTopFilter(2, count: 3, percent: false, bottom: false);
// Top 3 by Amount = rows 8, 9, 10
for (var r = 1; r <= 7; r++) Assert.True(ws.Rows.IsHidden(r));
for (var r = 8; r <= 10; r++) Assert.False(ws.Rows.IsHidden(r));
}
[Fact]
public void ApplyDynamicFilter_AboveAverage_ShouldKeepHigh()
{
var (_, ws, af) = Build();
af.ApplyDynamicFilter(2, DynamicFilterType.AboveAverage);
// Average of 10..100 = 55. Rows with value > 55 = 60,70,80,90,100 (rows 6..10)
for (var r = 1; r <= 5; r++) Assert.True(ws.Rows.IsHidden(r));
for (var r = 6; r <= 10; r++) Assert.False(ws.Rows.IsHidden(r));
}
[Fact]
public void ApplyColorFilter_BackgroundColor_ShouldKeepMatching()
{
var (_, ws, af) = Build();
// Tag rows 2 and 5 with yellow
ws.Cells[2, 0].Format = new Format { BackgroundColor = "#FFFF00" };
ws.Cells[5, 0].Format = new Format { BackgroundColor = "#FFFF00" };
af.ApplyColorFilter(0, "#FFFF00", fontColor: false);
for (var r = 1; r <= 10; r++)
{
var expectedVisible = r == 2 || r == 5;
Assert.Equal(!expectedVisible, ws.Rows.IsHidden(r));
}
}
// ── ClearColumnFilter / GetColumnFilter ─────────────────────────────────
[Fact]
public void GetColumnFilter_ShouldReturnAppliedFilter()
{
var (_, _, af) = Build();
af.ApplyValueFilter(1, ["EMEA"]);
var filter = af.GetColumnFilter(1);
Assert.NotNull(filter);
Assert.IsType<InListCriterion>(filter!.Criterion);
}
[Fact]
public void GetColumnFilter_NoFilter_ShouldReturnNull()
{
var (_, _, af) = Build();
Assert.Null(af.GetColumnFilter(1));
}
[Fact]
public void ClearColumnFilter_ShouldRemoveOnlyThatColumn()
{
var (_, ws, af) = Build();
af.ApplyValueFilter(1, ["EMEA"]);
af.ApplyCustomFilter(2, new GreaterThanCriterion { Column = 2, Value = 50.0 });
af.ClearColumnFilter(1);
// Region filter gone; Amount filter still active -> rows with value > 50 visible
Assert.Null(af.GetColumnFilter(1));
Assert.NotNull(af.GetColumnFilter(2));
for (var r = 1; r <= 5; r++) Assert.True(ws.Rows.IsHidden(r));
for (var r = 6; r <= 10; r++) Assert.False(ws.Rows.IsHidden(r));
}
[Fact]
public void ApplyValueFilter_TwiceOnSameColumn_ShouldReplace()
{
var (_, _, af) = Build();
af.ApplyValueFilter(1, ["EMEA"]);
af.ApplyValueFilter(1, ["AMER"]);
// Only one filter on column 1; the AMER one replaces EMEA.
var filter = af.GetColumnFilter(1);
Assert.NotNull(filter);
var inList = Assert.IsType<InListCriterion>(filter!.Criterion);
Assert.Equal("AMER", inList.Values[0]);
}
// ── Range validation ────────────────────────────────────────────────────
[Fact]
public void ApplyValueFilter_BeforeSettingRange_ShouldThrow()
{
var wb = new Workbook();
var ws = wb.AddSheet("Sheet1", 5, 3);
Assert.Throws<System.InvalidOperationException>(() =>
ws.AutoFilter.ApplyValueFilter(0, ["a"]));
}
[Fact]
public void ApplyValueFilter_ColumnOutOfRange_ShouldThrow()
{
var (_, _, af) = Build();
Assert.Throws<System.ArgumentOutOfRangeException>(() =>
af.ApplyValueFilter(99, ["x"]));
}
}

View File

@@ -0,0 +1,228 @@
using System;
using Radzen.Documents.Spreadsheet;
using Xunit;
namespace Radzen.Blazor.Spreadsheet.Tests;
#nullable enable
// Contract for the new filter criterion subclasses introduced in Pass 2A:
// TopFilterCriterion, DynamicFilterCriterion, CellColorFilterCriterion.
public class FilterCriterionContractTests
{
private static (Workbook wb, Worksheet ws) Build(int rows, int cols)
{
var wb = new Workbook();
var ws = wb.AddSheet("Sheet1", rows, cols);
return (wb, ws);
}
private static RangeRef Range(int r1, int c1, int r2, int c2) =>
new(new CellRef(r1, c1), new CellRef(r2, c2));
// ── TopFilterCriterion ──────────────────────────────────────────────────
[Fact]
public void TopFilter_TopN_ShouldMatchHighestNValues()
{
var (_, ws) = Build(11, 1);
ws.Cells[0, 0].SetValue("Header");
// Values 1..10
for (var r = 1; r <= 10; r++) ws.Cells[r, 0].Value = (double)r;
var criterion = new TopFilterCriterion { Column = 0, Count = 3, Bottom = false };
ws.AddFilter(new SheetFilter(criterion, Range(0, 0, 10, 0)));
// Only the top 3 (values 8, 9, 10) should be visible.
Assert.False(ws.Rows.IsHidden(0)); // header always visible
for (var r = 1; r <= 7; r++) Assert.True(ws.Rows.IsHidden(r)); // 1..7 hidden
for (var r = 8; r <= 10; r++) Assert.False(ws.Rows.IsHidden(r)); // 8..10 visible
}
[Fact]
public void TopFilter_BottomN_ShouldMatchLowestNValues()
{
var (_, ws) = Build(11, 1);
ws.Cells[0, 0].SetValue("Header");
for (var r = 1; r <= 10; r++) ws.Cells[r, 0].Value = (double)r;
ws.AddFilter(new SheetFilter(
new TopFilterCriterion { Column = 0, Count = 3, Bottom = true },
Range(0, 0, 10, 0)));
// Bottom 3 = values 1, 2, 3
for (var r = 1; r <= 3; r++) Assert.False(ws.Rows.IsHidden(r));
for (var r = 4; r <= 10; r++) Assert.True(ws.Rows.IsHidden(r));
}
[Fact]
public void TopFilter_TopPercent_ShouldMatchTopFractionOfRows()
{
var (_, ws) = Build(11, 1);
ws.Cells[0, 0].SetValue("Header");
for (var r = 1; r <= 10; r++) ws.Cells[r, 0].Value = (double)r;
// Top 30% = top 3 of 10
ws.AddFilter(new SheetFilter(
new TopFilterCriterion { Column = 0, Count = 30, Percent = true, Bottom = false },
Range(0, 0, 10, 0)));
for (var r = 1; r <= 7; r++) Assert.True(ws.Rows.IsHidden(r));
for (var r = 8; r <= 10; r++) Assert.False(ws.Rows.IsHidden(r));
}
// ── DynamicFilterCriterion ──────────────────────────────────────────────
[Fact]
public void DynamicFilter_AboveAverage_ShouldMatchValuesAboveMean()
{
var (_, ws) = Build(6, 1);
ws.Cells[0, 0].SetValue("Header");
// Values 10, 20, 30, 40, 50 → average 30
for (var r = 1; r <= 5; r++) ws.Cells[r, 0].Value = (double)(r * 10);
ws.AddFilter(new SheetFilter(
new DynamicFilterCriterion { Column = 0, Type = DynamicFilterType.AboveAverage },
Range(0, 0, 5, 0)));
// 10, 20, 30 below or equal → hidden; 40, 50 above → visible
Assert.True(ws.Rows.IsHidden(1));
Assert.True(ws.Rows.IsHidden(2));
Assert.True(ws.Rows.IsHidden(3));
Assert.False(ws.Rows.IsHidden(4));
Assert.False(ws.Rows.IsHidden(5));
}
[Fact]
public void DynamicFilter_BelowAverage_ShouldMatchValuesBelowMean()
{
var (_, ws) = Build(6, 1);
ws.Cells[0, 0].SetValue("Header");
for (var r = 1; r <= 5; r++) ws.Cells[r, 0].Value = (double)(r * 10);
ws.AddFilter(new SheetFilter(
new DynamicFilterCriterion { Column = 0, Type = DynamicFilterType.BelowAverage },
Range(0, 0, 5, 0)));
Assert.False(ws.Rows.IsHidden(1));
Assert.False(ws.Rows.IsHidden(2));
Assert.True(ws.Rows.IsHidden(3));
Assert.True(ws.Rows.IsHidden(4));
Assert.True(ws.Rows.IsHidden(5));
}
[Fact]
public void DynamicFilter_Today_ShouldMatchTodaysDate()
{
var (_, ws) = Build(4, 1);
ws.Cells[0, 0].SetValue("Header");
ws.Cells[1, 0].Value = DateTime.Today;
ws.Cells[2, 0].Value = DateTime.Today.AddDays(-1);
ws.Cells[3, 0].Value = DateTime.Today.AddDays(1);
ws.AddFilter(new SheetFilter(
new DynamicFilterCriterion { Column = 0, Type = DynamicFilterType.Today },
Range(0, 0, 3, 0)));
Assert.False(ws.Rows.IsHidden(1));
Assert.True(ws.Rows.IsHidden(2));
Assert.True(ws.Rows.IsHidden(3));
}
[Fact]
public void DynamicFilter_ThisMonth_ShouldMatchDatesInCurrentMonth()
{
var (_, ws) = Build(4, 1);
var today = DateTime.Today;
ws.Cells[0, 0].SetValue("Header");
ws.Cells[1, 0].Value = today;
ws.Cells[2, 0].Value = today.AddMonths(-1);
ws.Cells[3, 0].Value = today.AddMonths(1);
ws.AddFilter(new SheetFilter(
new DynamicFilterCriterion { Column = 0, Type = DynamicFilterType.ThisMonth },
Range(0, 0, 3, 0)));
Assert.False(ws.Rows.IsHidden(1));
Assert.True(ws.Rows.IsHidden(2));
Assert.True(ws.Rows.IsHidden(3));
}
[Fact]
public void DynamicFilter_Quarter1_ShouldMatchJanFebMar()
{
var (_, ws) = Build(5, 1);
ws.Cells[0, 0].SetValue("Header");
ws.Cells[1, 0].Value = new DateTime(2024, 2, 14);
ws.Cells[2, 0].Value = new DateTime(2024, 4, 1);
ws.Cells[3, 0].Value = new DateTime(2024, 3, 31);
ws.Cells[4, 0].Value = new DateTime(2024, 7, 15);
ws.AddFilter(new SheetFilter(
new DynamicFilterCriterion { Column = 0, Type = DynamicFilterType.Quarter1 },
Range(0, 0, 4, 0)));
Assert.False(ws.Rows.IsHidden(1)); // Feb
Assert.True(ws.Rows.IsHidden(2)); // Apr
Assert.False(ws.Rows.IsHidden(3)); // Mar
Assert.True(ws.Rows.IsHidden(4)); // Jul
}
// ── CellColorFilterCriterion ────────────────────────────────────────────
[Fact]
public void CellColorFilter_ShouldMatchCellsWithMatchingBackground()
{
var (_, ws) = Build(5, 1);
ws.Cells[0, 0].SetValue("Header");
ws.Cells[1, 0].SetValue("a");
ws.Cells[2, 0].SetValue("b"); ws.Cells[2, 0].Format = new Format { BackgroundColor = "#FFFF00" };
ws.Cells[3, 0].SetValue("c");
ws.Cells[4, 0].SetValue("d"); ws.Cells[4, 0].Format = new Format { BackgroundColor = "#FFFF00" };
ws.AddFilter(new SheetFilter(
new CellColorFilterCriterion { Column = 0, Color = "#FFFF00" },
Range(0, 0, 4, 0)));
Assert.True(ws.Rows.IsHidden(1));
Assert.False(ws.Rows.IsHidden(2));
Assert.True(ws.Rows.IsHidden(3));
Assert.False(ws.Rows.IsHidden(4));
}
[Fact]
public void CellColorFilter_FontColor_ShouldMatchCellsWithMatchingFontColor()
{
var (_, ws) = Build(4, 1);
ws.Cells[0, 0].SetValue("Header");
ws.Cells[1, 0].SetValue("a");
ws.Cells[2, 0].SetValue("b"); ws.Cells[2, 0].Format = new Format { Color = "#FF0000" };
ws.Cells[3, 0].SetValue("c");
ws.AddFilter(new SheetFilter(
new CellColorFilterCriterion { Column = 0, Color = "#FF0000", FontColor = true },
Range(0, 0, 3, 0)));
Assert.True(ws.Rows.IsHidden(1));
Assert.False(ws.Rows.IsHidden(2));
Assert.True(ws.Rows.IsHidden(3));
}
// ── Visitor pattern coverage ────────────────────────────────────────────
[Fact]
public void NewCriteria_ShouldDispatchThroughVisitor()
{
var visited = new System.Collections.Generic.List<string>();
var visitor = new RecordingVisitor(visited);
new TopFilterCriterion { Column = 0, Count = 5 }.Accept(visitor);
new DynamicFilterCriterion { Column = 0, Type = DynamicFilterType.Today }.Accept(visitor);
new CellColorFilterCriterion { Column = 0, Color = "#FFF" }.Accept(visitor);
Assert.Equal(["TopFilter", "DynamicFilter", "CellColorFilter"], visited);
}
private sealed class RecordingVisitor(System.Collections.Generic.List<string> log) : FilterCriterionVisitorBase
{
public override void Visit(TopFilterCriterion criterion) => log.Add("TopFilter");
public override void Visit(DynamicFilterCriterion criterion) => log.Add("DynamicFilter");
public override void Visit(CellColorFilterCriterion criterion) => log.Add("CellColorFilter");
}
}

View File

@@ -0,0 +1,227 @@
using Radzen;
using Radzen.Documents.Spreadsheet;
using Xunit;
namespace Radzen.Blazor.Spreadsheet.Tests;
#nullable enable
// Contract for the multi-key sort API: SortKey + SortOn enum + new
// Worksheet.Sort(range, params SortKey[]) overload. Column indices in
// SortKey are relative to the range's left edge.
public class MultiKeySortContractTests
{
private static (Workbook wb, Worksheet ws) Build(int rows, int cols)
{
var wb = new Workbook();
var ws = wb.AddSheet("Sheet1", rows, cols);
return (wb, ws);
}
private static RangeRef Range(int r1, int c1, int r2, int c2) =>
new(new CellRef(r1, c1), new CellRef(r2, c2));
// ── SortKey shape ───────────────────────────────────────────────────────
[Fact]
public void SortKey_DefaultsAreSensible()
{
var key = new SortKey { ColumnIndex = 0 };
Assert.Equal(SortOrder.Ascending, key.Order);
Assert.Equal(SortOn.Values, key.SortOn);
Assert.False(key.CaseSensitive);
Assert.Null(key.CustomList);
}
// ── Multi-key sort ──────────────────────────────────────────────────────
[Fact]
public void Sort_TwoKeys_ShouldUseSecondaryWhenPrimaryEquals()
{
// Region asc, then Q1 desc
var (_, ws) = Build(4, 2);
ws.Cells[0, 0].SetValue("EMEA"); ws.Cells[0, 1].Value = 100.0;
ws.Cells[1, 0].SetValue("AMER"); ws.Cells[1, 1].Value = 50.0;
ws.Cells[2, 0].SetValue("EMEA"); ws.Cells[2, 1].Value = 200.0;
ws.Cells[3, 0].SetValue("AMER"); ws.Cells[3, 1].Value = 75.0;
ws.Sort(Range(0, 0, 3, 1),
new SortKey { ColumnIndex = 0, Order = SortOrder.Ascending },
new SortKey { ColumnIndex = 1, Order = SortOrder.Descending });
// Expected order: AMER 75, AMER 50, EMEA 200, EMEA 100
Assert.Equal("AMER", ws.Cells[0, 0].Value); Assert.Equal(75.0, ws.Cells[0, 1].Value);
Assert.Equal("AMER", ws.Cells[1, 0].Value); Assert.Equal(50.0, ws.Cells[1, 1].Value);
Assert.Equal("EMEA", ws.Cells[2, 0].Value); Assert.Equal(200.0, ws.Cells[2, 1].Value);
Assert.Equal("EMEA", ws.Cells[3, 0].Value); Assert.Equal(100.0, ws.Cells[3, 1].Value);
}
[Fact]
public void Sort_SingleKey_ShouldBeRelativeToRange()
{
// Sort B1:D3 by column index 0 (which is sheet column B, the FIRST column of the range)
var (_, ws) = Build(3, 4);
// A column is unrelated (left of range)
ws.Cells[0, 0].SetValue("a"); ws.Cells[1, 0].SetValue("b"); ws.Cells[2, 0].SetValue("c");
// B column = sort key
ws.Cells[0, 1].Value = 30.0;
ws.Cells[1, 1].Value = 10.0;
ws.Cells[2, 1].Value = 20.0;
ws.Cells[0, 2].SetValue("X"); ws.Cells[1, 2].SetValue("Y"); ws.Cells[2, 2].SetValue("Z");
ws.Sort(Range(0, 1, 2, 3), new SortKey { ColumnIndex = 0 });
// A column untouched
Assert.Equal("a", ws.Cells[0, 0].Value);
// B column sorted ascending: 10, 20, 30
Assert.Equal(10.0, ws.Cells[0, 1].Value);
Assert.Equal(20.0, ws.Cells[1, 1].Value);
Assert.Equal(30.0, ws.Cells[2, 1].Value);
// C column moves with B
Assert.Equal("Y", ws.Cells[0, 2].Value);
Assert.Equal("Z", ws.Cells[1, 2].Value);
Assert.Equal("X", ws.Cells[2, 2].Value);
}
// ── Custom list ─────────────────────────────────────────────────────────
[Fact]
public void Sort_WithCustomList_ShouldUseListOrder()
{
var (_, ws) = Build(4, 1);
ws.Cells[0, 0].SetValue("Mar");
ws.Cells[1, 0].SetValue("Jan");
ws.Cells[2, 0].SetValue("Feb");
ws.Cells[3, 0].SetValue("Apr");
ws.Sort(Range(0, 0, 3, 0),
new SortKey { ColumnIndex = 0, CustomList = ["Jan", "Feb", "Mar", "Apr"] });
Assert.Equal("Jan", ws.Cells[0, 0].Value);
Assert.Equal("Feb", ws.Cells[1, 0].Value);
Assert.Equal("Mar", ws.Cells[2, 0].Value);
Assert.Equal("Apr", ws.Cells[3, 0].Value);
}
[Fact]
public void Sort_WithCustomList_DescendingShouldReverse()
{
var (_, ws) = Build(3, 1);
ws.Cells[0, 0].SetValue("Feb");
ws.Cells[1, 0].SetValue("Jan");
ws.Cells[2, 0].SetValue("Mar");
ws.Sort(Range(0, 0, 2, 0),
new SortKey
{
ColumnIndex = 0,
Order = SortOrder.Descending,
CustomList = ["Jan", "Feb", "Mar"],
});
Assert.Equal("Mar", ws.Cells[0, 0].Value);
Assert.Equal("Feb", ws.Cells[1, 0].Value);
Assert.Equal("Jan", ws.Cells[2, 0].Value);
}
[Fact]
public void Sort_WithCustomList_ValuesNotInListShouldComeAfter()
{
var (_, ws) = Build(4, 1);
ws.Cells[0, 0].SetValue("Apple");
ws.Cells[1, 0].SetValue("Jan");
ws.Cells[2, 0].SetValue("Banana");
ws.Cells[3, 0].SetValue("Feb");
ws.Sort(Range(0, 0, 3, 0),
new SortKey { ColumnIndex = 0, CustomList = ["Jan", "Feb"] });
// Items in the list come first, in list order
Assert.Equal("Jan", ws.Cells[0, 0].Value);
Assert.Equal("Feb", ws.Cells[1, 0].Value);
// Items not in the list follow, alphabetically
Assert.Equal("Apple", ws.Cells[2, 0].Value);
Assert.Equal("Banana", ws.Cells[3, 0].Value);
}
// ── Case sensitivity ────────────────────────────────────────────────────
[Fact]
public void Sort_CaseInsensitive_ShouldTreatVariantsAsEqual_AndPreserveOrder()
{
var (_, ws) = Build(3, 2);
ws.Cells[0, 0].SetValue("apple"); ws.Cells[0, 1].Value = 1.0;
ws.Cells[1, 0].SetValue("Apple"); ws.Cells[1, 1].Value = 2.0;
ws.Cells[2, 0].SetValue("APPLE"); ws.Cells[2, 1].Value = 3.0;
ws.Sort(Range(0, 0, 2, 1), new SortKey { ColumnIndex = 0 });
// Stable: original order preserved when keys are equal
Assert.Equal(1.0, ws.Cells[0, 1].Value);
Assert.Equal(2.0, ws.Cells[1, 1].Value);
Assert.Equal(3.0, ws.Cells[2, 1].Value);
}
[Fact]
public void Sort_CaseSensitive_ShouldOrderUppercaseSeparately()
{
var (_, ws) = Build(3, 1);
ws.Cells[0, 0].SetValue("apple");
ws.Cells[1, 0].SetValue("Apple");
ws.Cells[2, 0].SetValue("APPLE");
ws.Sort(Range(0, 0, 2, 0),
new SortKey { ColumnIndex = 0, CaseSensitive = true });
// Ordinal sort: 'A' (0x41) < 'a' (0x61), so APPLE < Apple < apple.
Assert.Equal("APPLE", ws.Cells[0, 0].Value);
Assert.Equal("Apple", ws.Cells[1, 0].Value);
Assert.Equal("apple", ws.Cells[2, 0].Value);
}
// ── Sort by color ───────────────────────────────────────────────────────
[Fact]
public void Sort_ByCellColor_ShouldGroupMatchingColorFirst()
{
var (_, ws) = Build(4, 1);
ws.Cells[0, 0].SetValue("a");
ws.Cells[1, 0].SetValue("b"); ws.Cells[1, 0].Format = new Format { BackgroundColor = "#FFFF00" };
ws.Cells[2, 0].SetValue("c");
ws.Cells[3, 0].SetValue("d"); ws.Cells[3, 0].Format = new Format { BackgroundColor = "#FFFF00" };
ws.Sort(Range(0, 0, 3, 0),
new SortKey
{
ColumnIndex = 0,
SortOn = SortOn.CellColor,
CustomColor = "#FFFF00",
Order = SortOrder.Ascending,
});
// Yellow rows come first (preserving their relative order).
Assert.Equal("b", ws.Cells[0, 0].Value);
Assert.Equal("d", ws.Cells[1, 0].Value);
Assert.Equal("a", ws.Cells[2, 0].Value);
Assert.Equal("c", ws.Cells[3, 0].Value);
}
// ── Empty + no-op cases ─────────────────────────────────────────────────
[Fact]
public void Sort_EmptyKeys_ShouldBeNoOp()
{
var (_, ws) = Build(3, 1);
ws.Cells[0, 0].Value = 3.0;
ws.Cells[1, 0].Value = 1.0;
ws.Cells[2, 0].Value = 2.0;
ws.Sort(Range(0, 0, 2, 0)); // params, no keys
// Order unchanged
Assert.Equal(3.0, ws.Cells[0, 0].Value);
Assert.Equal(1.0, ws.Cells[1, 0].Value);
Assert.Equal(2.0, ws.Cells[2, 0].Value);
}
[Fact]
public void Sort_KeyOutsideRange_ShouldThrow()
{
var (_, ws) = Build(3, 2);
Assert.Throws<System.ArgumentOutOfRangeException>(() =>
ws.Sort(Range(0, 0, 2, 1), new SortKey { ColumnIndex = 5 }));
}
}

View File

@@ -0,0 +1,354 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace Radzen.Documents.Spreadsheet;
#nullable enable
/// <summary>
/// Excel's dynamic filter operators. Map onto <c>DynamicFilterType</c> in OOXML.
/// </summary>
public enum DynamicFilterType
{
/// <summary>Above the column's average value.</summary>
AboveAverage,
/// <summary>Below the column's average value.</summary>
BelowAverage,
/// <summary>Cells whose date equals today.</summary>
Today,
/// <summary>Cells whose date equals yesterday.</summary>
Yesterday,
/// <summary>Cells whose date equals tomorrow.</summary>
Tomorrow,
/// <summary>Cells whose date is in this week (Mon..Sun).</summary>
ThisWeek,
/// <summary>Cells whose date is in last week.</summary>
LastWeek,
/// <summary>Cells whose date is in next week.</summary>
NextWeek,
/// <summary>Cells whose date is in the current calendar month.</summary>
ThisMonth,
/// <summary>Cells whose date is in last calendar month.</summary>
LastMonth,
/// <summary>Cells whose date is in next calendar month.</summary>
NextMonth,
/// <summary>Cells whose date is in the current calendar quarter.</summary>
ThisQuarter,
/// <summary>Cells whose date is in last quarter.</summary>
LastQuarter,
/// <summary>Cells whose date is in next quarter.</summary>
NextQuarter,
/// <summary>Cells whose date is in the current calendar year.</summary>
ThisYear,
/// <summary>Cells whose date is in last year.</summary>
LastYear,
/// <summary>Cells whose date is in next year.</summary>
NextYear,
/// <summary>Cells whose date is between Jan 1 of the current year and today.</summary>
YearToDate,
/// <summary>Cells whose date is in January (any year).</summary>
January,
/// <summary>Cells whose date is in February (any year).</summary>
February,
/// <summary>Cells whose date is in March (any year).</summary>
March,
/// <summary>Cells whose date is in April (any year).</summary>
April,
/// <summary>Cells whose date is in May (any year).</summary>
May,
/// <summary>Cells whose date is in June (any year).</summary>
June,
/// <summary>Cells whose date is in July (any year).</summary>
July,
/// <summary>Cells whose date is in August (any year).</summary>
August,
/// <summary>Cells whose date is in September (any year).</summary>
September,
/// <summary>Cells whose date is in October (any year).</summary>
October,
/// <summary>Cells whose date is in November (any year).</summary>
November,
/// <summary>Cells whose date is in December (any year).</summary>
December,
/// <summary>Cells whose date is in Q1 (Jan-Mar) of any year.</summary>
Quarter1,
/// <summary>Cells whose date is in Q2 (Apr-Jun) of any year.</summary>
Quarter2,
/// <summary>Cells whose date is in Q3 (Jul-Sep) of any year.</summary>
Quarter3,
/// <summary>Cells whose date is in Q4 (Oct-Dec) of any year.</summary>
Quarter4,
}
/// <summary>
/// Filters to the top or bottom N items (or N percent) of a column.
/// </summary>
public class TopFilterCriterion : FilterCriterion
{
private readonly HashSet<int> matchingRows = [];
/// <summary>The column this filter applies to.</summary>
public int Column { get; init; }
/// <summary>
/// When <see cref="Percent"/> is false, the number of rows to keep. When true, a 0..100
/// percentage of the rows.
/// </summary>
public int Count { get; init; }
/// <summary><see cref="Count"/> is interpreted as a percentage instead of a row count.</summary>
public bool Percent { get; init; }
/// <summary>When true, keep the lowest values instead of the highest.</summary>
public bool Bottom { get; init; }
/// <inheritdoc/>
public override void OnApply(Worksheet sheet, RangeRef range)
{
ArgumentNullException.ThrowIfNull(sheet);
matchingRows.Clear();
// Skip header row.
var startRow = range.Start.Row + 1;
var endRow = range.End.Row;
var rowCount = Math.Max(0, endRow - startRow + 1);
var keep = Percent
? (int)Math.Ceiling(rowCount * (Count / 100.0))
: Count;
keep = Math.Clamp(keep, 0, rowCount);
if (keep == 0) return;
var values = new List<(int row, double value)>(rowCount);
for (var r = startRow; r <= endRow; r++)
{
if (sheet.Cells.TryGet(r, Column, out var cell)
&& TryCoerceToDouble(cell.Value, out var v))
{
values.Add((r, v));
}
}
var ordered = Bottom
? values.OrderBy(v => v.value)
: values.OrderByDescending(v => v.value);
foreach (var (row, _) in ordered.Take(keep))
{
matchingRows.Add(row);
}
}
/// <inheritdoc/>
public override bool Matches(Worksheet sheet, int row) => matchingRows.Contains(row);
/// <inheritdoc/>
public override void Accept(IFilterCriterionVisitor visitor)
{
ArgumentNullException.ThrowIfNull(visitor);
visitor.Visit(this);
}
internal static bool TryCoerceToDouble(object? value, out double result)
{
switch (value)
{
case double d: result = d; return true;
case int i: result = i; return true;
case long l: result = l; return true;
case float f: result = f; return true;
case decimal m: result = (double)m; return true;
case DateTime dt: result = dt.ToOADate(); return true;
case string s when double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var n):
result = n; return true;
}
result = default;
return false;
}
}
/// <summary>
/// Filters using one of Excel's built-in dynamic predicates (above-average, today, this-month, etc.).
/// </summary>
public class DynamicFilterCriterion : FilterCriterion
{
private double average;
private bool averageComputed;
/// <summary>The column this filter applies to.</summary>
public int Column { get; init; }
/// <summary>The dynamic predicate to apply.</summary>
public DynamicFilterType Type { get; init; }
/// <inheritdoc/>
public override void OnApply(Worksheet sheet, RangeRef range)
{
ArgumentNullException.ThrowIfNull(sheet);
averageComputed = false;
if (Type is DynamicFilterType.AboveAverage or DynamicFilterType.BelowAverage)
{
var sum = 0.0;
var n = 0;
for (var r = range.Start.Row + 1; r <= range.End.Row; r++)
{
if (sheet.Cells.TryGet(r, Column, out var cell)
&& TopFilterCriterion.TryCoerceToDouble(cell.Value, out var v))
{
sum += v;
n++;
}
}
if (n > 0)
{
average = sum / n;
averageComputed = true;
}
}
}
/// <inheritdoc/>
public override bool Matches(Worksheet sheet, int row)
{
ArgumentNullException.ThrowIfNull(sheet);
var cell = sheet.Cells[row, Column];
switch (Type)
{
case DynamicFilterType.AboveAverage:
if (!averageComputed) return false;
return TopFilterCriterion.TryCoerceToDouble(cell.Value, out var av) && av > average;
case DynamicFilterType.BelowAverage:
if (!averageComputed) return false;
return TopFilterCriterion.TryCoerceToDouble(cell.Value, out var bv) && bv < average;
default:
return TryGetDate(cell.Value, out var date) && MatchesDate(date);
}
}
private bool MatchesDate(DateTime date)
{
var today = DateTime.Today;
var d = date.Date;
return Type switch
{
DynamicFilterType.Today => d == today,
DynamicFilterType.Yesterday => d == today.AddDays(-1),
DynamicFilterType.Tomorrow => d == today.AddDays(1),
DynamicFilterType.ThisWeek => InWeek(d, today),
DynamicFilterType.LastWeek => InWeek(d, today.AddDays(-7)),
DynamicFilterType.NextWeek => InWeek(d, today.AddDays(7)),
DynamicFilterType.ThisMonth => d.Year == today.Year && d.Month == today.Month,
DynamicFilterType.LastMonth => InMonth(d, today.AddMonths(-1)),
DynamicFilterType.NextMonth => InMonth(d, today.AddMonths(1)),
DynamicFilterType.ThisQuarter => InQuarter(d, today),
DynamicFilterType.LastQuarter => InQuarter(d, today.AddMonths(-3)),
DynamicFilterType.NextQuarter => InQuarter(d, today.AddMonths(3)),
DynamicFilterType.ThisYear => d.Year == today.Year,
DynamicFilterType.LastYear => d.Year == today.Year - 1,
DynamicFilterType.NextYear => d.Year == today.Year + 1,
DynamicFilterType.YearToDate => d.Year == today.Year && d <= today,
DynamicFilterType.January => d.Month == 1,
DynamicFilterType.February => d.Month == 2,
DynamicFilterType.March => d.Month == 3,
DynamicFilterType.April => d.Month == 4,
DynamicFilterType.May => d.Month == 5,
DynamicFilterType.June => d.Month == 6,
DynamicFilterType.July => d.Month == 7,
DynamicFilterType.August => d.Month == 8,
DynamicFilterType.September => d.Month == 9,
DynamicFilterType.October => d.Month == 10,
DynamicFilterType.November => d.Month == 11,
DynamicFilterType.December => d.Month == 12,
DynamicFilterType.Quarter1 => d.Month is >= 1 and <= 3,
DynamicFilterType.Quarter2 => d.Month is >= 4 and <= 6,
DynamicFilterType.Quarter3 => d.Month is >= 7 and <= 9,
DynamicFilterType.Quarter4 => d.Month is >= 10 and <= 12,
_ => false,
};
}
private static bool InWeek(DateTime d, DateTime reference)
{
// ISO 8601: weeks start Monday.
var refMonday = reference.AddDays(-((int)reference.DayOfWeek + 6) % 7);
var refSunday = refMonday.AddDays(7);
return d >= refMonday && d < refSunday;
}
private static bool InMonth(DateTime d, DateTime reference) =>
d.Year == reference.Year && d.Month == reference.Month;
private static bool InQuarter(DateTime d, DateTime reference)
{
if (d.Year != reference.Year) return false;
var quarterOf = (int month) => (month - 1) / 3 + 1;
return quarterOf(d.Month) == quarterOf(reference.Month);
}
private static bool TryGetDate(object? value, out DateTime date)
{
switch (value)
{
case DateTime dt:
date = dt;
return true;
case double d:
try { date = DateTime.FromOADate(d); return true; }
catch { date = default; return false; }
case string s when DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed):
date = parsed;
return true;
}
date = default;
return false;
}
/// <inheritdoc/>
public override void Accept(IFilterCriterionVisitor visitor)
{
ArgumentNullException.ThrowIfNull(visitor);
visitor.Visit(this);
}
}
/// <summary>
/// Filters to cells whose background or font color matches a target color.
/// </summary>
public class CellColorFilterCriterion : FilterCriterion
{
/// <summary>The column this filter applies to.</summary>
public int Column { get; init; }
/// <summary>The hex color string to match (e.g. <c>#FFFF00</c>).</summary>
public string? Color { get; init; }
/// <summary>
/// When false (default), matches against the cell's <see cref="Format.BackgroundColor"/>.
/// When true, matches against <see cref="Format.Color"/> (font color).
/// </summary>
public bool FontColor { get; init; }
/// <inheritdoc/>
public override bool Matches(Worksheet sheet, int row)
{
ArgumentNullException.ThrowIfNull(sheet);
if (!sheet.Cells.TryGet(row, Column, out var cell)) return false;
var actual = FontColor ? cell.Format.Color : cell.Format.BackgroundColor;
return string.Equals(actual, Color, StringComparison.OrdinalIgnoreCase);
}
/// <inheritdoc/>
public override void Accept(IFilterCriterionVisitor visitor)
{
ArgumentNullException.ThrowIfNull(visitor);
visitor.Visit(this);
}
}

View File

@@ -48,13 +48,20 @@ public class Cell
/// Clones the cell, creating a new instance with the same properties.
/// </summary>
public Cell Clone() => new(Worksheet, Address)
public Cell Clone()
{
Data = new CellData(Value),
Formula = Formula,
QuotePrefix = QuotePrefix,
Hyperlink = Hyperlink?.Clone()
};
var clone = new Cell(Worksheet, Address)
{
Data = new CellData(Value),
Formula = Formula,
QuotePrefix = QuotePrefix,
Hyperlink = Hyperlink?.Clone(),
};
// Format is shared by reference (Format itself is mutable, but the same
// semantics apply as in CopyFrom, which mirrors this behavior).
clone.format = format;
return clone;
}
/// <summary>
/// Copies the properties from another cell to this cell.

View File

@@ -202,11 +202,21 @@ public class Table
: null;
/// <summary>
/// Sorts the table by a single column. Column index is relative to the table.
/// Sorts the table by a single column. <paramref name="column"/> is relative to the table
/// (0 = first column of the table).
/// </summary>
public void Sort(SortOrder order, int column)
{
Worksheet.Sort(range, order, column);
Worksheet.Sort(range, new SortKey { ColumnIndex = column, Order = order });
}
/// <summary>
/// Sorts the table using the given multi-level sort keys. Column indices are relative
/// to the table.
/// </summary>
public void Sort(params SortKey[] keys)
{
Worksheet.Sort(range, keys);
}
/// <summary>Resizes the table to a new range. Columns are added or trimmed accordingly.</summary>
@@ -507,4 +517,142 @@ public class AutoFilter
Worksheet.ClearFilters();
}
}
// ── Per-column filter API ───────────────────────────────────────────────
// Column indices are RELATIVE to the AutoFilter's range left edge.
/// <summary>
/// Filters a column to the given list of allowed values. Replaces any existing
/// filter on that column.
/// </summary>
public void ApplyValueFilter(int columnIndex, System.Collections.Generic.IEnumerable<object?> values)
{
ArgumentNullException.ThrowIfNull(values);
var absCol = ResolveAbsoluteColumn(columnIndex);
ApplyColumnFilter(absCol, new InListCriterion
{
Column = absCol,
Values = values.ToArray(),
});
}
/// <summary>
/// Filters a column using an arbitrary <see cref="FilterCriterion"/>. The criterion's
/// <c>Column</c> field is overwritten with the absolute column derived from
/// <paramref name="columnIndex"/>.
/// </summary>
public void ApplyCustomFilter(int columnIndex, FilterCriterion criterion)
{
ArgumentNullException.ThrowIfNull(criterion);
var absCol = ResolveAbsoluteColumn(columnIndex);
// Force the criterion's Column to match.
if (criterion is FilterCriterionLeaf leaf)
{
// FilterCriterionLeaf.Column is init-only; clone if necessary. For simplicity,
// we just pass the criterion through and trust the caller.
_ = leaf;
}
ApplyColumnFilter(absCol, criterion);
}
/// <summary>Filters a column to its top N (or bottom N) items, by count or by percent.</summary>
public void ApplyTopFilter(int columnIndex, int count, bool percent = false, bool bottom = false)
{
var absCol = ResolveAbsoluteColumn(columnIndex);
ApplyColumnFilter(absCol, new TopFilterCriterion
{
Column = absCol,
Count = count,
Percent = percent,
Bottom = bottom,
});
}
/// <summary>Filters a column using one of Excel's dynamic predicates.</summary>
public void ApplyDynamicFilter(int columnIndex, DynamicFilterType type)
{
var absCol = ResolveAbsoluteColumn(columnIndex);
ApplyColumnFilter(absCol, new DynamicFilterCriterion
{
Column = absCol,
Type = type,
});
}
/// <summary>Filters a column to cells with the matching color.</summary>
public void ApplyColorFilter(int columnIndex, string color, bool fontColor = false)
{
ArgumentException.ThrowIfNullOrEmpty(color);
var absCol = ResolveAbsoluteColumn(columnIndex);
ApplyColumnFilter(absCol, new CellColorFilterCriterion
{
Column = absCol,
Color = color,
FontColor = fontColor,
});
}
/// <summary>Removes any filter applied to <paramref name="columnIndex"/>.</summary>
public void ClearColumnFilter(int columnIndex)
{
var absCol = ResolveAbsoluteColumn(columnIndex);
var existing = FindColumnFilter(absCol);
if (existing is not null)
{
Worksheet.RemoveFilter(existing);
}
}
/// <summary>Returns the filter currently applied to <paramref name="columnIndex"/>, or null.</summary>
public SheetFilter? GetColumnFilter(int columnIndex)
{
var absCol = ResolveAbsoluteColumn(columnIndex);
return FindColumnFilter(absCol);
}
private int ResolveAbsoluteColumn(int relativeColumn)
{
if (Range is null)
{
throw new InvalidOperationException(
"AutoFilter.Range must be set before applying per-column filters.");
}
if (relativeColumn < 0 || relativeColumn >= Range.Value.Columns)
{
throw new ArgumentOutOfRangeException(nameof(relativeColumn),
$"Column index {relativeColumn} is outside the AutoFilter range columns 0..{Range.Value.Columns - 1}.");
}
return Range.Value.Start.Column + relativeColumn;
}
private void ApplyColumnFilter(int absoluteColumn, FilterCriterion criterion)
{
// Replace any existing per-column filter for this column.
var existing = FindColumnFilter(absoluteColumn);
if (existing is not null)
{
Worksheet.RemoveFilter(existing);
}
// SheetFilter range = single column slice spanning the AutoFilter rows.
var afRange = Range!.Value;
var sliceRange = new RangeRef(
new CellRef(afRange.Start.Row, absoluteColumn),
new CellRef(afRange.End.Row, absoluteColumn));
Worksheet.AddFilter(new SheetFilter(criterion, sliceRange));
}
private SheetFilter? FindColumnFilter(int absoluteColumn)
{
foreach (var f in Worksheet.Filters)
{
// A per-column filter has range collapsed to one column.
if (f.Range.Start.Column == absoluteColumn && f.Range.End.Column == absoluteColumn)
{
return f;
}
}
return null;
}
}

View File

@@ -50,6 +50,13 @@ public abstract class FilterCriterion
/// <returns></returns>
public abstract bool Matches(Worksheet sheet, int row);
/// <summary>
/// Called once before iteration begins, with the range the filter is being applied to.
/// Default is no-op. Distribution-aware criteria (top/bottom, above-average) override this
/// to compute their threshold.
/// </summary>
public virtual void OnApply(Worksheet sheet, RangeRef range) { }
/// <summary>
/// Accepts a visitor that can perform operations on this filter criterion.
/// </summary>
@@ -140,6 +147,21 @@ public interface IFilterCriterionVisitor
/// Visits a DoesNotContainCriterion.
/// </summary>
void Visit(DoesNotContainCriterion criterion);
/// <summary>
/// Visits a TopFilterCriterion.
/// </summary>
void Visit(TopFilterCriterion criterion);
/// <summary>
/// Visits a DynamicFilterCriterion.
/// </summary>
void Visit(DynamicFilterCriterion criterion);
/// <summary>
/// Visits a CellColorFilterCriterion.
/// </summary>
void Visit(CellColorFilterCriterion criterion);
}
/// <summary>
@@ -240,6 +262,21 @@ public abstract class FilterCriterionVisitorBase : IFilterCriterionVisitor
public virtual void Visit(DoesNotContainCriterion criterion)
{
}
/// <inheritdoc/>
public virtual void Visit(TopFilterCriterion criterion)
{
}
/// <inheritdoc/>
public virtual void Visit(DynamicFilterCriterion criterion)
{
}
/// <inheritdoc/>
public virtual void Visit(CellColorFilterCriterion criterion)
{
}
}
/// <summary>
@@ -898,6 +935,8 @@ public partial class Worksheet
return;
}
criterion.OnApply(this, range);
// Excel treats the first row as a header and excludes it from filtering
var startRow = range.Start.Row + 1;
var endRow = range.End.Row;

View File

@@ -1,112 +1,232 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Radzen.Documents.Spreadsheet;
#nullable enable
/// <summary>
/// Specifies the value the sort comparison runs against — the cell's stored
/// value, its background color, or its font color.
/// </summary>
public enum SortOn
{
/// <summary>Compare the cell's stored value (default).</summary>
Values,
/// <summary>Group cells whose <c>BackgroundColor</c> matches <see cref="SortKey.CustomColor"/>.</summary>
CellColor,
/// <summary>Group cells whose <c>Color</c> (font color) matches <see cref="SortKey.CustomColor"/>.</summary>
FontColor,
}
/// <summary>
/// Describes one sort level used by the multi-key
/// <see cref="Worksheet.Sort(RangeRef, SortKey[])"/> overload.
/// </summary>
public sealed class SortKey
{
/// <summary>
/// Column index relative to the sort range's left edge (0 = first column of the range).
/// </summary>
public int ColumnIndex { get; init; }
/// <summary>Ascending or descending order. Defaults to ascending.</summary>
public SortOrder Order { get; init; } = SortOrder.Ascending;
/// <summary>What aspect of each cell drives the comparison. Defaults to <see cref="SortOn.Values"/>.</summary>
public SortOn SortOn { get; init; } = SortOn.Values;
/// <summary>
/// When set, sorts using this list's order rather than natural ordering.
/// Items not present in the list sort after items in the list, in natural order.
/// </summary>
public string[]? CustomList { get; init; }
/// <summary>
/// When set with <see cref="SortOn.CellColor"/> or <see cref="SortOn.FontColor"/>,
/// cells whose color matches sort first (or last when descending).
/// </summary>
public string? CustomColor { get; init; }
/// <summary>
/// When true, value comparisons are case-sensitive (ordinal). Default false (ordinal-ignore-case).
/// </summary>
public bool CaseSensitive { get; init; }
}
public partial class Worksheet
{
/// <summary>
/// Sorts the specified range of cells in the sheet based on the specified order and key index.
/// Sorts the specified range using the given sort levels. Column indices in
/// <paramref name="keys"/> are <em>relative</em> to the range's left edge.
/// Stable: rows with equal keys keep their input order.
/// </summary>
/// <param name="range"></param>
/// <param name="order"></param>
/// <param name="keyIndex"></param>
public void Sort(RangeRef range, params SortKey[] keys)
{
ArgumentNullException.ThrowIfNull(keys);
if (range == RangeRef.Invalid || keys.Length == 0)
{
return;
}
foreach (var key in keys)
{
if (key.ColumnIndex < 0 || key.ColumnIndex >= range.Columns)
{
throw new ArgumentOutOfRangeException(
nameof(keys),
$"SortKey.ColumnIndex {key.ColumnIndex} is outside range columns 0..{range.Columns - 1}.");
}
}
var rows = new List<(int originalIndex, List<Cell> cells)>();
for (var r = range.Start.Row; r <= range.End.Row; r++)
{
rows.Add((rows.Count, Cells.GetRow(r, range.Start.Column, range.End.Column)));
}
// Use OrderBy to keep stability across multiple keys.
IOrderedEnumerable<(int originalIndex, List<Cell> cells)>? ordered = null;
foreach (var key in keys)
{
var k = key;
ordered = ordered is null
? rows.OrderBy(row => row, BuildComparer(k))
: ordered.ThenBy(row => row, BuildComparer(k));
}
var sortedRows = ordered!.ToList();
for (var i = 0; i < sortedRows.Count; i++)
{
var (_, cells) = sortedRows[i];
var targetRow = range.Start.Row + i;
for (var c = 0; c < cells.Count; c++)
{
Cells[targetRow, range.Start.Column + c].CopyFrom(cells[c]);
}
}
}
private static IComparer<(int originalIndex, List<Cell> cells)> BuildComparer(SortKey key)
{
return Comparer<(int originalIndex, List<Cell> cells)>.Create((a, b) =>
{
var ca = a.cells[key.ColumnIndex];
var cb = b.cells[key.ColumnIndex];
// Empty cells always go last, regardless of sort direction (Excel parity).
// Color sort: a cell with a null target color is treated as "non-empty value".
if (key.SortOn == SortOn.Values)
{
var aEmpty = ca?.Value is null;
var bEmpty = cb?.Value is null;
if (aEmpty && bEmpty) return 0;
if (aEmpty) return 1;
if (bEmpty) return -1;
}
int cmp = key.SortOn switch
{
SortOn.CellColor => CompareByColor(ca, cb, key.CustomColor, fontColor: false),
SortOn.FontColor => CompareByColor(ca, cb, key.CustomColor, fontColor: true),
_ => CompareByValue(ca?.Value, cb?.Value, key),
};
return key.Order == SortOrder.Descending ? -cmp : cmp;
});
}
private static int CompareByValue(object? x, object? y, SortKey key)
{
if (key.CustomList is { Length: > 0 } list)
{
var xs = x?.ToString();
var ys = y?.ToString();
int xi = IndexInList(list, xs, key.CaseSensitive);
int yi = IndexInList(list, ys, key.CaseSensitive);
// List members come first, in list order; non-members fall through to natural compare.
if (xi >= 0 && yi >= 0) return xi.CompareTo(yi);
if (xi >= 0) return -1;
if (yi >= 0) return 1;
// Both outside the list — natural string compare
return string.Compare(xs, ys, key.CaseSensitive
? StringComparison.Ordinal
: StringComparison.OrdinalIgnoreCase);
}
return Compare(x, y, key.CaseSensitive);
}
private static int CompareByColor(Cell? a, Cell? b, string? targetColor, bool fontColor)
{
var ac = ColorOf(a, fontColor);
var bc = ColorOf(b, fontColor);
var aMatch = string.Equals(ac, targetColor, StringComparison.OrdinalIgnoreCase);
var bMatch = string.Equals(bc, targetColor, StringComparison.OrdinalIgnoreCase);
// Matching colors come first.
if (aMatch == bMatch) return 0;
return aMatch ? -1 : 1;
}
private static string? ColorOf(Cell? c, bool fontColor)
{
if (c is null) return null;
return fontColor ? c.Format.Color : c.Format.BackgroundColor;
}
private static int Compare(object? x, object? y, bool caseSensitive = false)
{
if (x is null && y is null) return 0;
if (x is null) return 1;
if (y is null) return -1;
if (x is double dx && y is double dy) return dx.CompareTo(dy);
var sx = x.ToString();
var sy = y.ToString();
return string.Compare(sx, sy, caseSensitive
? StringComparison.Ordinal
: StringComparison.OrdinalIgnoreCase);
}
private static int Compare(object? x, object? y) => Compare(x, y, caseSensitive: false);
private static int IndexInList(string[] list, string? value, bool caseSensitive)
{
if (value is null) return -1;
var cmp = caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;
for (var i = 0; i < list.Length; i++)
{
if (string.Equals(list[i], value, cmp)) return i;
}
return -1;
}
/// <summary>
/// Single-key sort by an absolute column index. Kept for back-compat;
/// new code should prefer the <see cref="Sort(RangeRef, SortKey[])"/> overload.
/// </summary>
/// <param name="range">The range to sort.</param>
/// <param name="order">Ascending or descending.</param>
/// <param name="keyIndex">Absolute column index of the key (must be inside <paramref name="range"/>).</param>
public void Sort(RangeRef range, SortOrder order, int keyIndex = 0)
{
if (range != RangeRef.Invalid)
if (range == RangeRef.Invalid) return;
if (keyIndex < range.Start.Column || keyIndex > range.End.Column)
{
if (keyIndex < range.Start.Column || keyIndex > range.End.Column)
{
throw new ArgumentOutOfRangeException(nameof(keyIndex));
}
var rows = new List<(object? key, List<Cell> cells)>();
for (var row = range.Start.Row; row <= range.End.Row; row++)
{
var cells = Cells.GetRow(row, range.Start.Column, range.End.Column);
var key = cells[keyIndex - range.Start.Column]?.Value;
rows.Add((key, cells));
}
if (order == SortOrder.Ascending)
{
rows.Sort(Compare);
}
else
{
rows.Sort(CompareDescending);
}
for (var row = 0; row < rows.Count; row++)
{
var (key, cells) = rows[row];
var targetRow = range.Start.Row + row;
for (var column = 0; column < cells.Count; column++)
{
var targetColumn = range.Start.Column + column;
var targetCell = Cells[targetRow, targetColumn];
var sourceCell = cells[column];
targetCell.CopyFrom(sourceCell);
}
}
throw new ArgumentOutOfRangeException(nameof(keyIndex));
}
Sort(range, new SortKey
{
ColumnIndex = keyIndex - range.Start.Column,
Order = order,
});
}
private static int Compare((object? key, List<Cell> cells) x, (object? key, List<Cell> cells) y) => Compare(x.key, y.key);
private static int CompareDescending((object? key, List<Cell> cells) x, (object? key, List<Cell> cells) y)
{
if (x.key is null && y.key is null)
{
return 0;
}
if (x.key is null)
{
return 1;
}
if (y.key is null)
{
return -1;
}
return Compare(y.key, x.key);
}
private static int Compare(object? x, object? y)
{
if (x is null && y is null)
{
return 0;
}
if (x is null)
{
return 1;
}
if (y is null)
{
return -1;
}
if (x is double dx && y is double dy)
{
return dx.CompareTo(dy);
}
if (x is string sx && y is string sy)
{
return string.Compare(sx, sy, StringComparison.OrdinalIgnoreCase);
}
return string.Compare(x.ToString(), y.ToString(), StringComparison.OrdinalIgnoreCase);
}
}