From e0c011887ed4fd9b66834c4503fdbe296ef32173 Mon Sep 17 00:00:00 2001 From: Joost Meulenkamp Date: Fri, 11 Apr 2025 12:22:49 +0200 Subject: [PATCH 1/4] updated models and add targeting rules --- .../Configuration/ExporterOptions.cs | 19 +++++ SharpExcel.Models/Data/TargetingCollection.cs | 6 ++ SharpExcel.Models/Data/TargetingRule.cs | 74 +++++++++++++++++++ SharpExcel.TestApplication/Program.cs | 8 ++ .../Exporters/BaseSharpExcelSynchronizer.cs | 1 + 5 files changed, 108 insertions(+) create mode 100644 SharpExcel.Models/Data/TargetingCollection.cs create mode 100644 SharpExcel.Models/Data/TargetingRule.cs diff --git a/SharpExcel.Models/Configuration/ExporterOptions.cs b/SharpExcel.Models/Configuration/ExporterOptions.cs index ca1e5e7..7c3c7ec 100644 --- a/SharpExcel.Models/Configuration/ExporterOptions.cs +++ b/SharpExcel.Models/Configuration/ExporterOptions.cs @@ -1,3 +1,4 @@ +using SharpExcel.Models.Data; using SharpExcel.Models.Styling; using SharpExcel.Models.Styling.Rules; @@ -14,6 +15,11 @@ public class ExporterOptions /// Collection of styling rules /// public StylingCollection Styling { get; set; } = new(); + + /// + /// Targeting collection + /// + public TargetingCollection Targeting { get; set; } = new(); /// /// Fluent method to set default header style for this exporter @@ -60,4 +66,17 @@ public ExporterOptions WithStylingRule(Action + /// Fluent method to add a styling rule for this exporter + /// + /// constructs the styling rule + /// + public ExporterOptions WithTargetingRule(Action> targetingRuleOptions) + { + var stylingRule = new TargetingRule(); + targetingRuleOptions(stylingRule); + Targeting.Rules.Add(stylingRule); + return this; + } } \ No newline at end of file diff --git a/SharpExcel.Models/Data/TargetingCollection.cs b/SharpExcel.Models/Data/TargetingCollection.cs new file mode 100644 index 0000000..1e2acd5 --- /dev/null +++ b/SharpExcel.Models/Data/TargetingCollection.cs @@ -0,0 +1,6 @@ +namespace SharpExcel.Models.Data; + +public class TargetingCollection +{ + public List> Rules { get; set; } = new(); +} \ No newline at end of file diff --git a/SharpExcel.Models/Data/TargetingRule.cs b/SharpExcel.Models/Data/TargetingRule.cs new file mode 100644 index 0000000..8e9beab --- /dev/null +++ b/SharpExcel.Models/Data/TargetingRule.cs @@ -0,0 +1,74 @@ +namespace SharpExcel.Models.Data; + +public interface ITargetingRule +{ + +} +public record TargetingRule : ITargetingRule +{ + /// + /// name of the sheet in the excel file + /// When not specified, we will use the first sheet in the file + /// + public string? SheetName { get; set; } + + /// + /// Optional Row to start reading/writing from. + /// This is useful when you want to only affect part of a sheet. + /// Excel rows start as 1, so 1 is the first row + /// + public int? Row { get; set; } + + /// + /// Optional Column to start reading/writing from. + /// This is useful when you want to only affect part of a sheet. + /// Excel columns start as 1, so 1 is the first row + /// + public int? Column { get; set; } + + /// + /// Conditions to check if the rule should be applied. + /// + public Func? RulePredicate { get; set; } + + public TargetingRule WithCondition(Func condition) + { + RulePredicate = condition; + //return this object so we can chain calls + return this; + } + + /// + /// Sets the row to start reading/writing from. + /// + /// + /// + public TargetingRule WithStartRow(int rowId) + { + Row = rowId; + //return this object so we can chain calls + return this; + } + + /// + /// Sets the column to start reading/writing from. + /// + public TargetingRule WithStartColumn(int rowId) + { + Row = rowId; + //return this object so we can chain calls + return this; + } + + /// + /// Sets the sheet name to start reading/writing from. + /// + public TargetingRule WithSheetName(string sheetName) + { + SheetName = sheetName; + //return this object so we can chain calls + return this; + } +} + + diff --git a/SharpExcel.TestApplication/Program.cs b/SharpExcel.TestApplication/Program.cs index 8bacc53..93cfc18 100644 --- a/SharpExcel.TestApplication/Program.cs +++ b/SharpExcel.TestApplication/Program.cs @@ -48,6 +48,14 @@ //can be omitted to use default style rule.WhenFalse(ExcelCellStyleConstants.DefaultDataStyle.WithTextColor(new(80, 160, 80))); }); + + options.WithTargetingRule(rule => + { + rule.WithCondition(_ => true); + rule.WithSheetName("Budgets"); + rule.WithStartColumn(1); + rule.WithStartRow(1); + }); }); using IHost host = builder.Build(); diff --git a/SharpExcel/Exporters/BaseSharpExcelSynchronizer.cs b/SharpExcel/Exporters/BaseSharpExcelSynchronizer.cs index b2823d8..7c81905 100644 --- a/SharpExcel/Exporters/BaseSharpExcelSynchronizer.cs +++ b/SharpExcel/Exporters/BaseSharpExcelSynchronizer.cs @@ -22,6 +22,7 @@ public BaseSharpExcelSynchronizer(IOptions> options) { _options = options; } + /// public async Task ValidateAndAnnotateWorkbookAsync(ExcelArguments arguments, XLWorkbook workbook) { From e6b6f0350e35702dce0314ebe27cdb123bcdabdd Mon Sep 17 00:00:00 2001 From: Joost Meulenkamp Date: Sat, 12 Apr 2025 15:36:00 +0200 Subject: [PATCH 2/4] implemented targeting rules --- .../Configuration/ExporterOptions.cs | 2 +- SharpExcel.Models/Data/TargetingRule.cs | 14 +- SharpExcel.Models/Results/ExcelAddress.cs | 5 + SharpExcel.Models/Results/ExcelReadResult.cs | 21 +++ .../Constants/ExcelTargetingConstants.cs | 16 +++ SharpExcel.TestApplication/Program.cs | 27 ++-- SharpExcel.Tests/ExcelImportTests.cs | 14 +- .../Abstraction/ISharpExcelSynchronizer.cs | 14 +- .../Exporters/BaseSharpExcelSynchronizer.cs | 134 ++++++++++++------ SharpExcel/Exporters/EnumExporter.cs | 8 +- SharpExcel/Exporters/ExporterHelpers.cs | 35 +++-- .../Exporters/SharpExcelWriterInstanceData.cs | 15 +- 12 files changed, 210 insertions(+), 95 deletions(-) create mode 100644 SharpExcel.Models/Styling/Constants/ExcelTargetingConstants.cs diff --git a/SharpExcel.Models/Configuration/ExporterOptions.cs b/SharpExcel.Models/Configuration/ExporterOptions.cs index 7c3c7ec..f555387 100644 --- a/SharpExcel.Models/Configuration/ExporterOptions.cs +++ b/SharpExcel.Models/Configuration/ExporterOptions.cs @@ -15,7 +15,7 @@ public class ExporterOptions /// Collection of styling rules /// public StylingCollection Styling { get; set; } = new(); - + /// /// Targeting collection /// diff --git a/SharpExcel.Models/Data/TargetingRule.cs b/SharpExcel.Models/Data/TargetingRule.cs index 8e9beab..51bcf6f 100644 --- a/SharpExcel.Models/Data/TargetingRule.cs +++ b/SharpExcel.Models/Data/TargetingRule.cs @@ -1,3 +1,5 @@ +using System.ComponentModel.DataAnnotations; + namespace SharpExcel.Models.Data; public interface ITargetingRule @@ -7,11 +9,11 @@ public interface ITargetingRule public record TargetingRule : ITargetingRule { /// - /// name of the sheet in the excel file - /// When not specified, we will use the first sheet in the file + /// REQUIRED: name of the sheet in the excel file /// - public string? SheetName { get; set; } - + [MinLength(1)] + public string SheetName { get; set; } = null!; + /// /// Optional Row to start reading/writing from. /// This is useful when you want to only affect part of a sheet. @@ -53,9 +55,9 @@ public TargetingRule WithStartRow(int rowId) /// /// Sets the column to start reading/writing from. /// - public TargetingRule WithStartColumn(int rowId) + public TargetingRule WithStartColumn(int columnId) { - Row = rowId; + Column = columnId; //return this object so we can chain calls return this; } diff --git a/SharpExcel.Models/Results/ExcelAddress.cs b/SharpExcel.Models/Results/ExcelAddress.cs index ad24e2f..1507d85 100644 --- a/SharpExcel.Models/Results/ExcelAddress.cs +++ b/SharpExcel.Models/Results/ExcelAddress.cs @@ -24,4 +24,9 @@ public record struct ExcelAddress /// Header name /// public string? HeaderName { get; set; } + + /// + /// Sheet Name + /// + public string SheetName { get; set; } } \ No newline at end of file diff --git a/SharpExcel.Models/Results/ExcelReadResult.cs b/SharpExcel.Models/Results/ExcelReadResult.cs index 7f15502..aa77757 100644 --- a/SharpExcel.Models/Results/ExcelReadResult.cs +++ b/SharpExcel.Models/Results/ExcelReadResult.cs @@ -6,4 +6,25 @@ public class ExcelReadResult public List Records { get; set; } = new(); public Dictionary ValidationResults { get; set; } = new(); + + +} + +public static class ExcelReadResultExtensions +{ + public static void Append(this ExcelReadResult result, ExcelReadResult other) + where TModel : class + { + result.Records.AddRange(other.Records); + foreach (var kvp in other.ValidationResults) + { + if (!result.ValidationResults.ContainsKey(kvp.Key)) + { + result.ValidationResults.Add(kvp.Key, kvp.Value); + continue; + } + result.ValidationResults[kvp.Key] = kvp.Value; + } + + } } \ No newline at end of file diff --git a/SharpExcel.Models/Styling/Constants/ExcelTargetingConstants.cs b/SharpExcel.Models/Styling/Constants/ExcelTargetingConstants.cs new file mode 100644 index 0000000..4ba4c6f --- /dev/null +++ b/SharpExcel.Models/Styling/Constants/ExcelTargetingConstants.cs @@ -0,0 +1,16 @@ +using SharpExcel.Models.Data; + +namespace SharpExcel.Models.Styling.Constants; + +public class ExcelTargetingConstants + where TModel : class +{ + public static TargetingRule DefaultTargetingRule = new TargetingRule + { + SheetName = "Export", + Column = 1, + Row = 1, + RulePredicate = _ => true, + }; + +} \ No newline at end of file diff --git a/SharpExcel.TestApplication/Program.cs b/SharpExcel.TestApplication/Program.cs index 93cfc18..192596d 100644 --- a/SharpExcel.TestApplication/Program.cs +++ b/SharpExcel.TestApplication/Program.cs @@ -1,5 +1,4 @@ using System.Globalization; -using SharpExcel.Models.Arguments; using SharpExcel.Models.Results; using SharpExcel.Models.Styling.Colorization; using SharpExcel.TestApplication.TestData; @@ -51,10 +50,18 @@ options.WithTargetingRule(rule => { - rule.WithCondition(_ => true); - rule.WithSheetName("Budgets"); + rule.WithCondition(x => x.Status != TestStatus.Fired); + rule.WithSheetName("Employees"); rule.WithStartColumn(1); - rule.WithStartRow(1); + rule.WithStartRow(3); + }); + + options.WithTargetingRule(rule => + { + rule.WithCondition(x => x.Status == TestStatus.Fired); + rule.WithSheetName("Fired"); + rule.WithStartColumn(1); + rule.WithStartRow(3); }); }); @@ -68,20 +75,14 @@ async Task RunApp(IServiceProvider services) var exportPath = $"./OutputFolder/TestExport-{Guid.NewGuid()}.xlsx"; var validationExportPath = $"./OutputFolder/ErrorChecked-{Guid.NewGuid()}.xlsx"; var exportService = services.GetRequiredService>(); - - var excelArguments = new ExcelArguments() - { - SheetName = "Budgets", - CultureInfo = CultureInfo.CurrentCulture - }; - using var workbook = await exportService.GenerateWorkbookAsync(excelArguments, TestDataProvider.GetTestData()); + using var workbook = await exportService.GenerateWorkbookAsync(CultureInfo.CurrentCulture, TestDataProvider.GetTestData()); workbook.SaveAs(exportPath); - using var errorCheckedWorkbook = await exportService.ValidateAndAnnotateWorkbookAsync(excelArguments, workbook); + using var errorCheckedWorkbook = await exportService.ValidateAndAnnotateWorkbookAsync(CultureInfo.CurrentCulture, workbook); errorCheckedWorkbook.SaveAs(validationExportPath); - var importedWorkbook = await exportService.ReadWorkbookAsync(excelArguments, workbook); + var importedWorkbook = await exportService.ReadWorkbookAsync(CultureInfo.CurrentCulture, workbook); #region write_output foreach (var dataItem in importedWorkbook.Records) diff --git a/SharpExcel.Tests/ExcelImportTests.cs b/SharpExcel.Tests/ExcelImportTests.cs index d52ab9b..18d5597 100644 --- a/SharpExcel.Tests/ExcelImportTests.cs +++ b/SharpExcel.Tests/ExcelImportTests.cs @@ -1,8 +1,9 @@ +using System.Globalization; using ClosedXML.Excel; using Microsoft.Extensions.Options; using SharpExcel.Tests.Shared; -using SharpExcel.Models.Arguments; using SharpExcel.Models.Configuration.Constants; +using SharpExcel.Models.Styling.Constants; using Shouldly; using Xunit; @@ -23,15 +24,15 @@ public ExcelImportTests() [Fact] public async Task CreateWorkbookTest() { - var workbook = await _synchronizer.GenerateWorkbookAsync(new ExcelArguments(){ SheetName = "TestSheet"}, CreateTestData()); - workbook.Worksheets.FirstOrDefault(x => x.Name == "TestSheet").ShouldNotBeNull(); + var workbook = await _synchronizer.GenerateWorkbookAsync(CultureInfo.CurrentCulture, CreateTestData()); + workbook.Worksheets.FirstOrDefault(x => x.Name == ExcelTargetingConstants.DefaultTargetingRule.SheetName).ShouldNotBeNull(); workbook.ShouldNotBeNull(); //there should be 2 worksheets, a visible one for the data, and a hidden one to pull data from for the enum dropdowns workbook.Worksheets.Count.ShouldBe(2); //main data worksheet - workbook.Worksheet(1).Name.ShouldBe("TestSheet"); + workbook.Worksheet(1).Name.ShouldBe(ExcelTargetingConstants.DefaultTargetingRule.SheetName); workbook.Worksheet(1).Visibility.ShouldBe(XLWorksheetVisibility.Visible); //hidden worksheet for enum dropdowns @@ -41,12 +42,11 @@ public async Task CreateWorkbookTest() [Fact] public async Task ReadWorkbookTest() { - var args = new ExcelArguments() { SheetName = "TestSheet" }; //create test workbook - var workbook = await _synchronizer.GenerateWorkbookAsync( args, CreateTestData()); + var workbook = await _synchronizer.GenerateWorkbookAsync(CultureInfo.InvariantCulture, CreateTestData()); //read workbook - var output = await _synchronizer.ReadWorkbookAsync(args, workbook); + var output = await _synchronizer.ReadWorkbookAsync(CultureInfo.InvariantCulture, workbook); output.Records.Count.ShouldBe(2); diff --git a/SharpExcel/Abstraction/ISharpExcelSynchronizer.cs b/SharpExcel/Abstraction/ISharpExcelSynchronizer.cs index 50dadef..f979a32 100644 --- a/SharpExcel/Abstraction/ISharpExcelSynchronizer.cs +++ b/SharpExcel/Abstraction/ISharpExcelSynchronizer.cs @@ -1,5 +1,5 @@ -using ClosedXML.Excel; -using SharpExcel.Models.Arguments; +using System.Globalization; +using ClosedXML.Excel; using SharpExcel.Models.Results; namespace SharpExcel.Abstraction; @@ -14,10 +14,10 @@ public interface ISharpExcelSynchronizer /// /// Generates a workbook based on the provided data /// - /// Collection of arguments + /// /// The data to generate the workbook from /// - public Task GenerateWorkbookAsync(ExcelArguments arguments, IEnumerable data); + public Task GenerateWorkbookAsync(CultureInfo cultureInfo, ICollection data); /// /// Reads a workbook to convert it into the given model @@ -26,13 +26,13 @@ public interface ISharpExcelSynchronizer /// /// /// - public Task> ReadWorkbookAsync(ExcelArguments arguments, XLWorkbook workbook); + public Task> ReadWorkbookAsync(CultureInfo arguments, XLWorkbook workbook); /// /// Reads, then returns the supplied workbook, but highlights cells containing invalid data, using standard System.ComponentModel.DataAnnotations validation on the model /// - /// Collection of arguments + /// /// The workbook /// The highlighted workbook - public Task ValidateAndAnnotateWorkbookAsync(ExcelArguments arguments, XLWorkbook workbook); + public Task ValidateAndAnnotateWorkbookAsync(CultureInfo cultureInfo, XLWorkbook workbook); } \ No newline at end of file diff --git a/SharpExcel/Exporters/BaseSharpExcelSynchronizer.cs b/SharpExcel/Exporters/BaseSharpExcelSynchronizer.cs index 7c81905..98a25ed 100644 --- a/SharpExcel/Exporters/BaseSharpExcelSynchronizer.cs +++ b/SharpExcel/Exporters/BaseSharpExcelSynchronizer.cs @@ -4,9 +4,10 @@ using Microsoft.Extensions.Options; using SharpExcel.Abstraction; using SharpExcel.Extensions; -using SharpExcel.Models.Arguments; using SharpExcel.Models.Configuration; +using SharpExcel.Models.Data; using SharpExcel.Models.Results; +using SharpExcel.Models.Styling.Constants; namespace SharpExcel.Exporters; @@ -24,60 +25,110 @@ public BaseSharpExcelSynchronizer(IOptions> options) } /// - public async Task ValidateAndAnnotateWorkbookAsync(ExcelArguments arguments, XLWorkbook workbook) + public async Task ValidateAndAnnotateWorkbookAsync(CultureInfo cultureInfo, XLWorkbook workbook) { - var parsedWorkbook = await ReadWorkbookAsync(arguments, workbook); - ExporterHelpers.ApplyCellValidation(arguments.SheetName!, workbook, parsedWorkbook); + var parsedWorkbook = await ReadWorkbookAsync(cultureInfo, workbook); + ExporterHelpers.ApplyCellValidation(workbook, parsedWorkbook); return workbook; } + + public Task> ReadWorkbookAsync(CultureInfo cultureInfo, XLWorkbook workbook) + { + var output = new ExcelReadResult(); + var instanceData = CreateReadInstanceData(cultureInfo, workbook); + var rules = _options.Value.Targeting.Rules.GroupBy(rule => rule.SheetName); - /// - public virtual async Task GenerateWorkbookAsync(ExcelArguments arguments, IEnumerable data) + foreach (var ruleGroup in rules) + { + if (!instanceData.Workbook.Worksheets.TryGetWorksheet(ruleGroup.Key, out var worksheet)) + { + continue; + } + + foreach (var rule in ruleGroup) + { + ReadSheetAsync(output, instanceData, worksheet); + } + } + + return Task.FromResult(output); + } + + public virtual async Task GenerateWorkbookAsync(CultureInfo cultureInfo, ICollection data) { var workbook = new XLWorkbook(); + + if (!_options.Value.Targeting.Rules.Any()) + { + _options.Value.Targeting.Rules = [ExcelTargetingConstants.DefaultTargetingRule]; + } + + Dictionary, IEnumerable> dataGroupedByTargetingRule = new(); + + foreach (var targetingRule in _options.Value.Targeting.Rules) + { + dataGroupedByTargetingRule.Add(targetingRule, data.Where(x => targetingRule.RulePredicate != null && targetingRule.RulePredicate(x)).ToList()); + if (!workbook.Worksheets.TryGetWorksheet(targetingRule.SheetName, out var _)) + { + workbook.Worksheets.Add(targetingRule.SheetName); + } + } - var instanceData = CreateWriteInstanceData(arguments, workbook); + var instanceData = CreateWriteInstanceData(cultureInfo, workbook); + EnumExporter.AddEnumDropdownMappingsToSheet(instanceData); - if (instanceData.HeaderStyle.RowHeight.HasValue) + foreach (var targetingRuleData in dataGroupedByTargetingRule) { - instanceData.MainWorksheet.Rows().Height = instanceData.HeaderStyle.RowHeight.Value; + await GenerateSheetAsync(targetingRuleData.Key, instanceData, targetingRuleData.Value); } - //start at Row 1 because Excel starts at 1 - var rowIndex = 1; - var dropdownDataMappings = EnumExporter.AddEnumDropdownMappingsToSheet(instanceData); + return workbook; + } + + + /// + internal virtual Task GenerateSheetAsync(TargetingRule targetingRule, SharpExcelWriterInstanceData instanceData, IEnumerable data) + { + if (!instanceData.Workbook.Worksheets.TryGetWorksheet(targetingRule.SheetName, out var _)) + { + instanceData.Workbook.Worksheets.Add(targetingRule.SheetName); + } + + //start at Row 1 if not defined because Excel starts at 1 + var rowIndex = targetingRule.Row ?? 1; - ExporterHelpers.WriteHeaderRow(instanceData, rowIndex); + ExporterHelpers.WriteHeaderRow(targetingRule, instanceData, rowIndex, targetingRule.Column); //go to next row to start inserting data rowIndex++; foreach (var dataItem in data) { - ExporterHelpers.WriteDataRow(instanceData, dataItem, rowIndex, dropdownDataMappings); + ExporterHelpers.WriteDataRow(targetingRule, instanceData, dataItem, rowIndex, targetingRule.Column); rowIndex++; } - - return await Task.FromResult(workbook); + + return Task.CompletedTask; } /// - public Task> ReadWorkbookAsync(ExcelArguments arguments, XLWorkbook workbook) + internal void ReadSheetAsync( ExcelReadResult result, SharpExcelWriterInstanceData instanceData, IXLWorksheet worksheet) { - var instanceData = CreateReadInstanceData(arguments, workbook); + var usedArea = worksheet.RangeUsed(); + if (usedArea is null) + { + return; + } - var output = new ExcelReadResult(); - - var usedArea = instanceData.MainWorksheet.RangeUsed(); - var headerRowIndex = FindAndMapHeaderRow(instanceData, usedArea); - var remainingRows = usedArea.Rows(headerRowIndex + 1, usedArea.RowCount()).ToList(); + var headerRowIndex = FindAndMapHeaderRow(worksheet, instanceData, usedArea); + var remainingRows = usedArea.Rows(headerRowIndex, usedArea.RowCount()).ToList(); //parse remaining data rows foreach (var row in remainingRows) { - var data = ReadRow(instanceData, row, out var validationResults); + var data = ReadRow(worksheet, instanceData, row, out var validationResults); if (data == null) { @@ -85,14 +136,14 @@ public Task> ReadWorkbookAsync(ExcelArguments arguments, continue; } - output.Records.Add(data); + result.Records.Add(data); //add validation results if (validationResults.Any()) { foreach (var validationResult in validationResults) { - output.ValidationResults.Add(data, new ExcelCellValidationResult() + result.ValidationResults.Add(data, new ExcelCellValidationResult() { Address = validationResult.Key, ValidationResults = validationResult.Value @@ -101,18 +152,18 @@ public Task> ReadWorkbookAsync(ExcelArguments arguments, } } - - return Task.FromResult(output); } /// /// Reads a row and tries to convert it to the given model /// + /// /// instance data /// row to read - /// A dictionary containing validatio nresults of previous rows + /// A dictionary containing validation results of previous rows /// private static TModel? ReadRow( + IXLWorksheet sheet, SharpExcelWriterInstanceData instance, IXLRangeRow row, out Dictionary> validationResults) @@ -130,10 +181,11 @@ public Task> ReadWorkbookAsync(ExcelArguments arguments, RowNumber = row.RowNumber(), ColumnId = cell.Address.ColumnNumber, ColumnName = cell.Address.ColumnLetter, - HeaderName = columnData.Name + HeaderName = columnData.Name, + SheetName = sheet.Name }; - var dataValue = ExporterHelpers.TrySetCellValue(instance.Properties.EnumMappings, columnData, cell, + var dataValue = ExporterHelpers.TryGetCellValue(instance.Properties.EnumMappings, columnData, cell, instance.CultureInfo ?? CultureInfo.CurrentCulture); if (columnData.PropertyInfo.PropertyType == dataValue?.GetType()) @@ -163,7 +215,7 @@ public Task> ReadWorkbookAsync(ExcelArguments arguments, /// total used area of the workbook /// /// - private static int FindAndMapHeaderRow( + private static int FindAndMapHeaderRow(IXLWorksheet worksheet, SharpExcelWriterInstanceData instance, IXLRange usedArea) { @@ -171,6 +223,7 @@ private static int FindAndMapHeaderRow( x => !string.IsNullOrWhiteSpace(x.NormalizedName)) .Select(x => x.NormalizedName?.ToLowerInvariant())! ); + //find header row var headerRowIndex = usedArea .Rows(x => x.Cells() @@ -180,7 +233,7 @@ private static int FindAndMapHeaderRow( var propertiesByColumnName = instance.Properties.PropertyMappings.ToDictionary(x => x.NormalizedName); - foreach (var cell in instance.MainWorksheet.Row(headerRowIndex).Cells()) + foreach (var cell in worksheet.Row(headerRowIndex).Cells()) { if (!cell.TryGetValue(out string cellValue)) continue; @@ -200,7 +253,7 @@ private static int FindAndMapHeaderRow( } } - return headerRowIndex; + return headerRowIndex == 0 ? 1 : headerRowIndex; } /// @@ -209,7 +262,7 @@ private static int FindAndMapHeaderRow( /// arguments to use /// workbook to use /// - private SharpExcelWriterInstanceData CreateWriteInstanceData(ExcelArguments arguments, XLWorkbook workbook) + private SharpExcelWriterInstanceData CreateWriteInstanceData(CultureInfo cultureInfo, XLWorkbook workbook) { var random = new Random(); var randomNumber = random.Next(0, 1000000); @@ -221,9 +274,9 @@ private SharpExcelWriterInstanceData CreateWriteInstanceData(ExcelArgume ErrorStyle = _options.Value.Styling.DefaultErrorStyle, Properties = TypeMapper.GetModelMetaData(), StylingRuleLookup = _options.Value.Styling.ToStylingRuleLookup(), - MainWorksheet = workbook.AddWorksheet(arguments.SheetName), + Workbook = workbook, DropdownSourceWorksheet = workbook.AddWorksheet("Dropdowns_" + randomNumber.ToString("000000")).Hide(), - CultureInfo = arguments.CultureInfo + CultureInfo = cultureInfo }; return run; @@ -235,7 +288,7 @@ private SharpExcelWriterInstanceData CreateWriteInstanceData(ExcelArgume /// arguments to use /// workbook to use /// - private SharpExcelWriterInstanceData CreateReadInstanceData(ExcelArguments arguments, XLWorkbook workbook) + private SharpExcelWriterInstanceData CreateReadInstanceData(CultureInfo cultureInfo, XLWorkbook workbook) { return new SharpExcelWriterInstanceData() { @@ -244,8 +297,9 @@ private SharpExcelWriterInstanceData CreateReadInstanceData(ExcelArgumen ErrorStyle = _options.Value.Styling.DefaultErrorStyle, Properties = TypeMapper.GetModelMetaData(), StylingRuleLookup = _options.Value.Styling.ToStylingRuleLookup(), - MainWorksheet = workbook.Worksheet(arguments.SheetName), - CultureInfo = arguments.CultureInfo + TargetingRules = _options.Value.Targeting.Rules, + Workbook = workbook, + CultureInfo = cultureInfo }; } } \ No newline at end of file diff --git a/SharpExcel/Exporters/EnumExporter.cs b/SharpExcel/Exporters/EnumExporter.cs index 3290e5f..b891b65 100644 --- a/SharpExcel/Exporters/EnumExporter.cs +++ b/SharpExcel/Exporters/EnumExporter.cs @@ -12,7 +12,7 @@ internal class EnumExporter /// /// instance of this run /// - public static Dictionary AddEnumDropdownMappingsToSheet(SharpExcelWriterInstanceData instance) + public static void AddEnumDropdownMappingsToSheet(SharpExcelWriterInstanceData instance) where TModel : class { int dropDownWorkbookColumn = 1; @@ -32,7 +32,7 @@ public static Dictionary AddEnumDropdownMappingsToSheet(Sh dropDownWorkbookColumn++; } - return dropdownDataMappings; + instance.DropdownMappings = dropdownDataMappings; } /// @@ -44,7 +44,7 @@ public static Dictionary AddEnumDropdownMappingsToSheet(Sh /// /// public static void WriteEnumValue(SharpExcelWriterInstanceData instance, Type type, object dataValue, - IXLCell cell, Dictionary dropdownDataMappings) + IXLCell cell) where TModel : class { if (instance.Properties.EnumMappings.TryGetValue(type, out var enumValues)) @@ -61,7 +61,7 @@ public static void WriteEnumValue(SharpExcelWriterInstanceData i } } - if (dropdownDataMappings.TryGetValue(type, out var range)) + if (instance.DropdownMappings.TryGetValue(type, out var range)) { cell.CreateDataValidation().List(instance.DropdownSourceWorksheet.Range(range), true); } diff --git a/SharpExcel/Exporters/ExporterHelpers.cs b/SharpExcel/Exporters/ExporterHelpers.cs index d241b03..af1137d 100644 --- a/SharpExcel/Exporters/ExporterHelpers.cs +++ b/SharpExcel/Exporters/ExporterHelpers.cs @@ -3,6 +3,7 @@ using ClosedXML.Excel; using SharpExcel.Exporters.Helpers; using SharpExcel.Extensions; +using SharpExcel.Models.Data; using SharpExcel.Models.Results; using SharpExcel.Models.Styling; using SharpExcel.Models.Styling.Constants; @@ -21,12 +22,12 @@ internal static class ExporterHelpers /// /// /// - public static void ApplyCellValidation(string sheetName, XLWorkbook workbook, ExcelReadResult parsedWorkbook) + public static void ApplyCellValidation(XLWorkbook workbook, ExcelReadResult parsedWorkbook) where TModel : class, new() { foreach (var result in parsedWorkbook.ValidationResults) { - var cell = workbook.Worksheet(sheetName).Cell(result.Value.Address.RowNumber, result.Value.Address.ColumnId); + var cell = workbook.Worksheet(result.Value.Address.SheetName).Cell(result.Value.Address.RowNumber, result.Value.Address.ColumnId); var stringBuilder = new StringBuilder(); foreach (var item in result.Value.ValidationResults) { @@ -45,7 +46,7 @@ public static void ApplyCellValidation(string sheetName, XLWorkbook work /// cell to wrtie to /// /// - public static object? TrySetCellValue(Dictionary> enumMappings, PropertyData columnData, IXLCell cell, CultureInfo cultureInfo) + public static object? TryGetCellValue(Dictionary> enumMappings, PropertyData columnData, IXLCell cell, CultureInfo cultureInfo) { //extract underlying nullable type if there is one var actualType = Nullable.GetUnderlyingType(columnData.PropertyInfo.PropertyType) ?? columnData.PropertyInfo.PropertyType; @@ -99,29 +100,31 @@ public static void ApplyCellValidation(string sheetName, XLWorkbook work return default; } - + /// /// Writes a row of data cells /// + /// targeting rule for this sheet /// instance data for this run /// the current data item being processed /// index of the row to write to - /// dropdown data mappings for the hidden enum dropdown sheet + /// optional offset column /// type of the data item being processed public static void WriteDataRow( + TargetingRule targetingRule, SharpExcelWriterInstanceData instance, TModel dataItem, - int rowIndex, - Dictionary dropdownDataMappings) + int rowIndex, + int? columnIndex) where TModel : class { for (var i = 0; i < instance.Properties.PropertyMappings.Count; i++) { var mapping = instance.Properties.PropertyMappings[i]; - var row = instance.MainWorksheet.Row(rowIndex); - var cell = instance.MainWorksheet.Cell(rowIndex, i + 1 /* use +1 because Excel starts at 1 */); - WriteDataCell(instance, dataItem, dropdownDataMappings, mapping, cell); + var row = instance.Workbook.Worksheet(targetingRule.SheetName).Row(rowIndex); + var cell = instance.Workbook.Worksheet(targetingRule.SheetName).Cell(rowIndex, i + (columnIndex ?? 1) /* use +1 because Excel starts at 1 */); + WriteDataCell(instance, dataItem, mapping, cell); cell.Style.ApplyStyle(GetCellStyle(instance, dataItem, mapping, row)); } } @@ -138,7 +141,6 @@ public static void WriteDataRow( private static void WriteDataCell( SharpExcelWriterInstanceData instance, TModel dataItem, - Dictionary dropdownDataMappings, PropertyData mapping, IXLCell cell) where TModel : class @@ -148,7 +150,7 @@ private static void WriteDataCell( //handle enums if (mapping.PropertyInfo.PropertyType.IsEnum) { - EnumExporter.WriteEnumValue(instance, mapping.PropertyInfo.PropertyType, dataValue, cell, dropdownDataMappings); + EnumExporter.WriteEnumValue(instance, mapping.PropertyInfo.PropertyType, dataValue, cell); } //handle format else if (mapping.Format != null) @@ -198,24 +200,27 @@ private static ExcelCellStyle GetCellStyle(SharpExcelWriterInstanceData< /// /// Writes header row to cell /// + /// Targeting rule for this sheet /// instance data for this run /// the row index to write to + /// optional column offset /// type of the data item being processed public static void WriteHeaderRow( + TargetingRule targetingRule, SharpExcelWriterInstanceData instance, - int rowIndex) + int rowIndex, int? columnOffset) where TModel : class { for (var columnIndex = 0; columnIndex < instance.Properties.PropertyMappings.Count; columnIndex++) { var mapping = instance.Properties.PropertyMappings[columnIndex]; - var cell = instance.MainWorksheet.Cell(rowIndex, columnIndex + 1 /* use +1 because Excel starts at 1 */); + var cell = instance.Workbook.Worksheet(targetingRule.SheetName).Cell(rowIndex, columnIndex + (columnOffset ?? 1) /* use +1 because Excel starts at 1 */); cell.Style.ApplyStyle(instance.HeaderStyle); if (mapping.ColumnWidth > 0) { - instance.MainWorksheet.Column(columnIndex + 1).Width = mapping.ColumnWidth; + instance.Workbook.Worksheet(targetingRule.SheetName).Column(columnIndex + (columnOffset ?? 1)).Width = mapping.ColumnWidth; } cell.SetValue(mapping.Name); diff --git a/SharpExcel/Exporters/SharpExcelWriterInstanceData.cs b/SharpExcel/Exporters/SharpExcelWriterInstanceData.cs index 4bafa75..bf819ac 100644 --- a/SharpExcel/Exporters/SharpExcelWriterInstanceData.cs +++ b/SharpExcel/Exporters/SharpExcelWriterInstanceData.cs @@ -1,6 +1,7 @@ using System.Globalization; using ClosedXML.Excel; using SharpExcel.Exporters.Helpers; +using SharpExcel.Models.Data; using SharpExcel.Models.Styling; using SharpExcel.Models.Styling.Rules; @@ -33,15 +34,25 @@ internal class SharpExcelWriterInstanceData /// public Dictionary>> StylingRuleLookup { get; set; } = new(); + /// + /// List of targeting rules, so we can look up rules for properties faster + /// + public List> TargetingRules { get; set; } = new(); + + /// + /// DropdownMappings for this instance + /// + public Dictionary DropdownMappings { get; set; } = new(); + /// /// Collection of property metadata (column name etc.) /// public PropertyDataCollection Properties { get; set; } = new(); /// - /// Main worksheet to use for reading/writing + /// Workbook for this instance /// - public IXLWorksheet MainWorksheet { get; set; } = null!; + public IXLWorkbook Workbook { get; set; } = null!; /// /// Hidden worksheet to serve as source for all generated dropdown menus From 77443c5eb2f22b4951673bf85763ea53df1e201d Mon Sep 17 00:00:00 2001 From: hurles Date: Sun, 13 Apr 2025 11:14:07 +0200 Subject: [PATCH 3/4] fix header row not being calculated correctly when reading an excel file and the header row is not the first row --- .../Configuration/ExporterOptions.cs | 2 +- SharpExcel.TestApplication/Program.cs | 9 ++--- .../TestData/TestDataProvider.cs | 2 +- .../Exporters/BaseSharpExcelSynchronizer.cs | 33 ++++++++++++------- 4 files changed, 28 insertions(+), 18 deletions(-) diff --git a/SharpExcel.Models/Configuration/ExporterOptions.cs b/SharpExcel.Models/Configuration/ExporterOptions.cs index f555387..80002e9 100644 --- a/SharpExcel.Models/Configuration/ExporterOptions.cs +++ b/SharpExcel.Models/Configuration/ExporterOptions.cs @@ -72,7 +72,7 @@ public ExporterOptions WithStylingRule(Action /// constructs the styling rule /// - public ExporterOptions WithTargetingRule(Action> targetingRuleOptions) + public ExporterOptions WithTarget(Action> targetingRuleOptions) { var stylingRule = new TargetingRule(); targetingRuleOptions(stylingRule); diff --git a/SharpExcel.TestApplication/Program.cs b/SharpExcel.TestApplication/Program.cs index 192596d..986e439 100644 --- a/SharpExcel.TestApplication/Program.cs +++ b/SharpExcel.TestApplication/Program.cs @@ -48,20 +48,21 @@ rule.WhenFalse(ExcelCellStyleConstants.DefaultDataStyle.WithTextColor(new(80, 160, 80))); }); - options.WithTargetingRule(rule => + options.WithTarget(rule => { rule.WithCondition(x => x.Status != TestStatus.Fired); rule.WithSheetName("Employees"); - rule.WithStartColumn(1); + rule.WithStartColumn(3); rule.WithStartRow(3); }); - options.WithTargetingRule(rule => + options.WithTarget(rule => { rule.WithCondition(x => x.Status == TestStatus.Fired); rule.WithSheetName("Fired"); - rule.WithStartColumn(1); rule.WithStartRow(3); + rule.WithStartColumn(3); + }); }); diff --git a/SharpExcel.TestApplication/TestData/TestDataProvider.cs b/SharpExcel.TestApplication/TestData/TestDataProvider.cs index 870262c..9641d54 100644 --- a/SharpExcel.TestApplication/TestData/TestDataProvider.cs +++ b/SharpExcel.TestApplication/TestData/TestDataProvider.cs @@ -9,7 +9,7 @@ public static List GetTestData() new() { Id = 0, FirstName = "John", LastName = "Doe", Budget = 2400.34m, Email = "john.doe@example.com", TestDepartment = TestDepartment.Unknown, Status = TestStatus.Employed }, new() { Id = 1, FirstName = "Jane", LastName = "Doe", Budget = -200.42m, Email = "jane.doe@example.com", TestDepartment = TestDepartment.ValueB, Status = TestStatus.Fired }, new() { Id = 2, FirstName = "John", LastName = "Neutron", Budget = 0.0m, Email = null, TestDepartment = TestDepartment.ValueB, Status = TestStatus.Employed }, - new() { Id = 3, FirstName = "Ash", LastName = "Ketchum", Budget = 69m, Email = "ash@example.com", TestDepartment = TestDepartment.ValueC, Status = TestStatus.Fired }, + new() { Id = 3, FirstName = "Ash", LastName = "Ketchum", Budget = 69m, Email = null, TestDepartment = TestDepartment.ValueC, Status = TestStatus.Fired }, new() { Id = 4, FirstName = "Inspector", LastName = "Gadget", Budget = 1337m, Email = "gogogadget@example.com", TestDepartment = TestDepartment.ValueC, Status = TestStatus.Employed }, new() { Id = 5, FirstName = "Mindy", LastName = "", Budget = 2400.34m, Email = "mmouse@example.com", TestDepartment = TestDepartment.ValueA, Status = TestStatus.Employed }, new() { Id = 6, FirstName = "ThisIsLongerThan10", LastName = "Mouse", Budget = 2400.34m, Email = "mmouse@example.com", TestDepartment = TestDepartment.ValueA, Status = TestStatus.Employed }, diff --git a/SharpExcel/Exporters/BaseSharpExcelSynchronizer.cs b/SharpExcel/Exporters/BaseSharpExcelSynchronizer.cs index 98a25ed..aec076b 100644 --- a/SharpExcel/Exporters/BaseSharpExcelSynchronizer.cs +++ b/SharpExcel/Exporters/BaseSharpExcelSynchronizer.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.Globalization; using ClosedXML.Excel; +using DocumentFormat.OpenXml.Spreadsheet; using Microsoft.Extensions.Options; using SharpExcel.Abstraction; using SharpExcel.Extensions; @@ -48,7 +49,7 @@ public Task> ReadWorkbookAsync(CultureInfo cultureInfo, foreach (var rule in ruleGroup) { - ReadSheetAsync(output, instanceData, worksheet); + ReadSheetAsync(rule, output, instanceData, worksheet); } } @@ -114,7 +115,7 @@ internal virtual Task GenerateSheetAsync(TargetingRule targetingRule, Sh } /// - internal void ReadSheetAsync( ExcelReadResult result, SharpExcelWriterInstanceData instanceData, IXLWorksheet worksheet) + internal void ReadSheetAsync(TargetingRule rule, ExcelReadResult result, SharpExcelWriterInstanceData instanceData, IXLWorksheet worksheet) { var usedArea = worksheet.RangeUsed(); if (usedArea is null) @@ -122,17 +123,17 @@ internal void ReadSheetAsync( ExcelReadResult result, SharpExcelWriterIn return; } - var headerRowIndex = FindAndMapHeaderRow(worksheet, instanceData, usedArea); + var headerRowIndex = FindAndMapHeaderRow(rule, instanceData, usedArea); var remainingRows = usedArea.Rows(headerRowIndex, usedArea.RowCount()).ToList(); //parse remaining data rows foreach (var row in remainingRows) { - var data = ReadRow(worksheet, instanceData, row, out var validationResults); + var data = ReadRow(worksheet, instanceData, row.WorksheetRow(), out var validationResults); if (data == null) { - //skip to next record if we can read record + //skip to next record if we can't read record continue; } @@ -165,7 +166,7 @@ internal void ReadSheetAsync( ExcelReadResult result, SharpExcelWriterIn private static TModel? ReadRow( IXLWorksheet sheet, SharpExcelWriterInstanceData instance, - IXLRangeRow row, + IXLRow row, out Dictionary> validationResults) { var data = new TModel(); @@ -215,7 +216,8 @@ internal void ReadSheetAsync( ExcelReadResult result, SharpExcelWriterIn /// total used area of the workbook /// /// - private static int FindAndMapHeaderRow(IXLWorksheet worksheet, + private static int FindAndMapHeaderRow( + TargetingRule rule, SharpExcelWriterInstanceData instance, IXLRange usedArea) { @@ -225,16 +227,23 @@ private static int FindAndMapHeaderRow(IXLWorksheet worksheet, ); //find header row - var headerRowIndex = usedArea + var headerRow = usedArea .Rows(x => x.Cells() .Any(c => headerNames.Contains(c.Value.ToString().ToLowerInvariant()))) - .FirstOrDefault() - ?.RowNumber() ?? -1; + .FirstOrDefault()?.WorksheetRow(); var propertiesByColumnName = instance.Properties.PropertyMappings.ToDictionary(x => x.NormalizedName); - foreach (var cell in worksheet.Row(headerRowIndex).Cells()) + var startIndex = usedArea.FirstCell().WorksheetColumn().ColumnNumber(); + + + + if (rule.Column != null && rule.Column > startIndex) + startIndex = rule.Column ?? 1; + + for (int i = startIndex; i <= usedArea.ColumnCount(); i++) { + var cell = headerRow!.Cell(i); if (!cell.TryGetValue(out string cellValue)) continue; @@ -253,7 +262,7 @@ private static int FindAndMapHeaderRow(IXLWorksheet worksheet, } } - return headerRowIndex == 0 ? 1 : headerRowIndex; + return headerRow!.RowNumber() - 1; } /// From 2d2abf244d5e0deb0153edc8297df43a896127fd Mon Sep 17 00:00:00 2001 From: hurles Date: Sun, 13 Apr 2025 11:41:28 +0200 Subject: [PATCH 4/4] fix tests --- SharpExcel/Exporters/BaseSharpExcelSynchronizer.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/SharpExcel/Exporters/BaseSharpExcelSynchronizer.cs b/SharpExcel/Exporters/BaseSharpExcelSynchronizer.cs index aec076b..0fb44d6 100644 --- a/SharpExcel/Exporters/BaseSharpExcelSynchronizer.cs +++ b/SharpExcel/Exporters/BaseSharpExcelSynchronizer.cs @@ -232,12 +232,12 @@ private static int FindAndMapHeaderRow( .Any(c => headerNames.Contains(c.Value.ToString().ToLowerInvariant()))) .FirstOrDefault()?.WorksheetRow(); + var headerRowId = headerRow!.RowNumber(); + var propertiesByColumnName = instance.Properties.PropertyMappings.ToDictionary(x => x.NormalizedName); var startIndex = usedArea.FirstCell().WorksheetColumn().ColumnNumber(); - - if (rule.Column != null && rule.Column > startIndex) startIndex = rule.Column ?? 1; @@ -262,7 +262,7 @@ private static int FindAndMapHeaderRow( } } - return headerRow!.RowNumber() - 1; + return headerRowId <= 1 ? 2 : headerRowId - 1; } ///