diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 25a1b9e9..5c5635d3 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -43,10 +43,11 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: | - 8.0.x - 10.0.x - + dotnet-version: 8.0.x + + - name: Restore dependencies + run: dotnet restore + # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v3 @@ -71,7 +72,7 @@ jobs: # uses a compiled language - name: Manual build - run: dotnet build + run: dotnet build MiniExcel.slnx --no-restore --configuration Release - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 diff --git a/README-V2.md b/README-V2.md index 80ebc085..ace49a81 100644 --- a/README-V2.md +++ b/README-V2.md @@ -230,6 +230,7 @@ You can find the benchmarks' results for the latest release [here](benchmarks/re - [Attributes and configuration](#docs-attributes) - [CSV specifics](#docs-csv) - [Other functionalities](#docs-other) +- [Fluent Cell Mapping](#docs-mapping) - [FAQ](#docs-faq) - [Limitations](#docs-limitations) @@ -647,29 +648,8 @@ When queried, the resource will be converted back to `byte[]`. If you don't need ![image](https://user-images.githubusercontent.com/12729184/153702334-c3b834f4-6ae4-4ddf-bd4e-e5005d5d8c6a.png) -#### 12. Merge same cells vertically -This functionality merges cells vertically between the tags `@merge` and `@endmerge`. -You can use `@mergelimit` to limit boundaries of merging cells vertically. - -```csharp -var templater = MiniExcel.Templaters.GetOpenXmlTemplater(); -templater.MergeSameCells(mergedFilePath, templatePath); -``` - -File content before and after merge without merge limit: - -Screenshot 2023-08-07 at 11 59 24 - -Screenshot 2023-08-07 at 11 59 57 - -File content before and after merge with merge limit: - -Screenshot 2023-08-08 at 18 21 00 - -Screenshot 2023-08-08 at 18 21 40 - -#### 13. Null values handling +#### 12. Null values handling By default, null values will be treated as empty strings when exporting: @@ -718,7 +698,7 @@ exporter.Export("test.xlsx", value, configuration: config); Both properties work with `null` and `DBNull` values. -#### 14. Freeze Panes +#### 13. Freeze Panes MiniExcel allows you to freeze both rows and columns in place: @@ -985,15 +965,15 @@ var value = new Dictionary() var templater = MiniExcel.Templaters.GetOpenXmlTemplater(); templater.ApplyTemplate(path, templatePath, value); ``` -- With `@group` tag and with `@header` tag +- Without `@group` tag Before: -![before_with_header](https://user-images.githubusercontent.com/38832863/218646717-21b9d57a-2be2-4e9a-801b-ae212231d2b4.PNG) +![without_group](https://user-images.githubusercontent.com/38832863/218646975-f52a68eb-e031-43b5-abaa-03b67c052d1a.PNG) After: -![after_with_header](https://user-images.githubusercontent.com/38832863/218646721-58a7a340-7004-4bc2-af24-cffcb2c20737.PNG) +![without_group_after](https://user-images.githubusercontent.com/38832863/218646974-4a3c0e07-7c66-4088-ad07-b4ad3695b7e1.PNG) - With `@group` tag and without `@header` tag @@ -1001,19 +981,19 @@ Before: ![before_without_header](https://user-images.githubusercontent.com/38832863/218646873-b12417fa-801b-4890-8e96-669ed3b43902.PNG) -After; +After: ![after_without_header](https://user-images.githubusercontent.com/38832863/218646872-622461ba-342e-49ee-834f-b91ad9c2dac3.PNG) -- Without `@group` tag +- With both `@group` and `@header` tags Before: -![without_group](https://user-images.githubusercontent.com/38832863/218646975-f52a68eb-e031-43b5-abaa-03b67c052d1a.PNG) +![before_with_header](https://user-images.githubusercontent.com/38832863/218646717-21b9d57a-2be2-4e9a-801b-ae212231d2b4.PNG) After: -![without_group_after](https://user-images.githubusercontent.com/38832863/218646974-4a3c0e07-7c66-4088-ad07-b4ad3695b7e1.PNG) +![after_with_header](https://user-images.githubusercontent.com/38832863/218646721-58a7a340-7004-4bc2-af24-cffcb2c20737.PNG) #### 7. If/ElseIf/Else Statements inside cell @@ -1043,7 +1023,31 @@ After: ![if_after](https://user-images.githubusercontent.com/38832863/235360609-869bb960-d63d-45ae-8d64-9e8b0d0ab658.PNG) -#### 8. DataTable as parameter + +#### 8. Merge same cells vertically + +This functionality merges cells vertically between the tags `@merge` and `@endmerge`. +You can use `@mergelimit` to limit boundaries of merging cells vertically. + +```csharp +var templater = MiniExcel.Templaters.GetOpenXmlTemplater(); +templater.MergeSameCells(mergedFilePath, templatePath); +``` + +File content before and after merge without merge limit: + +Screenshot 2023-08-07 at 11 59 24 + +Screenshot 2023-08-07 at 11 59 57 + +File content before and after merge with merge limit: + +Screenshot 2023-08-08 at 18 21 00 + +Screenshot 2023-08-08 at 18 21 40 + + +#### 9. DataTable as parameter ```csharp var managers = new DataTable(); @@ -1063,7 +1067,8 @@ var value = new Dictionary() var templater = MiniExcel.Templaters.GetOpenXmlTemplater(); templater.ApplyTemplate(path, templatePath, value); ``` -#### 9. Formulas + +#### 10. Formulas Prefix your formula with `$` and use `$enumrowstart` and `$enumrowend` to mark references to the enumerable start and end rows: ![image](docs/images/template-formulas-1.png) @@ -1081,7 +1086,7 @@ _Other examples_: | Range | `$=MAX(C{{$enumrowstart}}:C{{$enumrowend}}) - MIN(C{{$enumrowstart}}:C{{$enumrowend}})` | -#### 10. Checking template parameter key +#### 11. Checking template parameter key When a parameter key is missing it will be replaced with an empty string by default. You can change this behaviour to throw an exception by setting the `IgnoreTemplateParameterMissing` configuration property: @@ -1295,7 +1300,142 @@ var exporter = MiniExcel.Exporters.GetOpenXmlExporter(); exporter.Export(path, sheets, configuration: configuration); ``` -### CSV + +### Fluent Cell Mapping + +Since v2.0.0, MiniExcel supports a fluent API for precise cell-by-cell mapping, giving you complete control over Excel layout without relying on conventions or attributes. + +>⚠️ **Important:** Compile mappings only once during application startup! + +Mapping compilation is a one-time operation that generates optimized runtime code. Create a single `MappingRegistry` instance and configure all your mappings at startup. Reuse this registry throughout your application for optimal performance. + +#### 1. Basic Property Mapping + +Map properties to specific cells using the fluent configuration API: + +```csharp +// Configure once at application startup +var registry = new MappingRegistry(); +registry.Configure(cfg => +{ + cfg.Property(p => p.Name).ToCell("A1"); + cfg.Property(p => p.Age).ToCell("B1"); + cfg.Property(p => p.Email).ToCell("C1"); + cfg.Property(p => p.Salary).ToCell("D1").WithFormat("#,##0.00"); + cfg.Property(p => p.BirthDate).ToCell("E1").WithFormat("yyyy-MM-dd"); + cfg.ToWorksheet("Employees"); +}); + +var exporter = MiniExcel.Exporters.GetMappingExporter(registry); +await exporter.ExportAsync(stream, people); +``` + +#### 2. Reading with Fluent Mappings + +```csharp +// Configure once at startup +var registry = new MappingRegistry(); +registry.Configure(cfg => +{ + cfg.Property(p => p.Name).ToCell("A2"); + cfg.Property(p => p.Age).ToCell("B2"); + cfg.Property(p => p.Email).ToCell("C2"); +}); + +// Read data using the mapping +var importer = MiniExcel.Importers.GetMappingImporter(registry); +var people = importer.Query(stream).ToList(); +``` + +#### 3. Collection Mapping + +Map collections to specific cell ranges (collections are laid out vertically by default): + +```csharp +registry.Configure(cfg => +{ + cfg.Property(d => d.Name).ToCell("A1"); + + // Simple collections (strings, numbers, etc.) - starts at A3 and goes down + cfg.Collection(d => d.PhoneNumbers).StartAt("A3"); + + // Complex object collections - starts at C3 and goes down + cfg.Collection(d => d.Employees).StartAt("C3"); +}); +``` + +You can optionally add spacing between collection items: + +```csharp +registry.Configure(cfg => +{ + cfg.Property(e => e.Name).ToCell("A1"); + cfg.Collection(e => e.Skills).StartAt("B1").WithSpacing(1); // 1 row spacing between items +}); +``` + +#### 4. Formulas and Formatting + +```csharp +registry.Configure(cfg => +{ + cfg.Property(p => p.Price).ToCell("B1"); + cfg.Property(p => p.Stock).ToCell("C1"); + + // Add a formula for calculated values + cfg.Property(p => p.Price).ToCell("D1").WithFormula("=B1*C1"); + + // Apply custom number formatting + cfg.Property(p => p.Price).ToCell("E1").WithFormat("$#,##0.00"); +}); +``` + +#### 5. Template Support + +Apply mappings to existing Excel templates: + +```csharp +registry.Configure(cfg => +{ + cfg.Property(x => x.Name).ToCell("A3"); + cfg.Property(x => x.CreateDate).ToCell("B3"); + cfg.Property(x => x.VIP).ToCell("C3"); + cfg.Property(x => x.Points).ToCell("D3"); +}); + +var data = new TestEntity +{ + Name = "Jack", + CreateDate = new DateTime(2021, 01, 01), + VIP = true, + Points = 123 +}; + +var termplater = MiniExcel.Templaters.GetMappingExporter(registry); +await termplater.ApplyTemplateAsync(outputPath, templatePath, new[] { data }); +``` + +#### 6. Advanced: Nested Collections with Item Mapping + +Configure how items within a collection should be mapped: + +```csharp +registry.Configure(cfg => +{ + cfg.Property(c => c.Name).ToCell("A1"); + + cfg.Collection(c => c.Departments) + .StartAt("A3") + .WithItemMapping(deptCfg => + { + deptCfg.Property(d => d.Name).ToCell("A3"); + deptCfg.Collection(d => d.Employees).StartAt("B3"); + }); +}); +``` + + +### CSV Specifics > Unlike Excel queries, csv always maps values to `string` by default, unless you are querying to a strongly defined type. diff --git a/README.md b/README.md index 8825ee56..49e5024a 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,8 @@ If you do, make sure to also check out the [new docs](README-V2.md) and the [upg - [Excel Column Name/Index/Ignore Attribute](#getstart4) +- [Fluent Cell Mapping](#getstart4.5) + - [Examples](#getstart5) @@ -1105,7 +1107,6 @@ public class Dto ``` - #### 5. System.ComponentModel.DisplayNameAttribute = ExcelColumnName.excelColumnNameAttribute Since 1.24.0, system supports System.ComponentModel.DisplayNameAttribute = ExcelColumnName.excelColumnNameAttribute diff --git a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/CreateExcelBenchmark.cs b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/CreateExcelBenchmark.cs index 0ac47cd6..8c519a4c 100644 --- a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/CreateExcelBenchmark.cs +++ b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/CreateExcelBenchmark.cs @@ -6,6 +6,7 @@ using DocumentFormat.OpenXml.Spreadsheet; using MiniExcelLib.Benchmarks.Utils; using MiniExcelLib.Core; +using MiniExcelLib.Core.FluentMapping; using NPOI.XSSF.UserModel; using OfficeOpenXml; @@ -14,6 +15,7 @@ namespace MiniExcelLib.Benchmarks.BenchmarkSections; public class CreateExcelBenchmark : BenchmarkBase { private OpenXmlExporter _exporter; + private MappingExporter _simpleMappingExporter; [GlobalSetup] public void SetUp() @@ -22,6 +24,22 @@ public void SetUp() Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); _exporter = MiniExcel.Exporters.GetOpenXmlExporter(); + + var simpleRegistry = new MappingRegistry(); + simpleRegistry.Configure(config => + { + config.Property(x => x.Column1).ToCell("A1"); + config.Property(x => x.Column2).ToCell("B1"); + config.Property(x => x.Column3).ToCell("C1"); + config.Property(x => x.Column4).ToCell("D1"); + config.Property(x => x.Column5).ToCell("E1"); + config.Property(x => x.Column6).ToCell("F1"); + config.Property(x => x.Column7).ToCell("G1"); + config.Property(x => x.Column8).ToCell("H1"); + config.Property(x => x.Column9).ToCell("I1"); + config.Property(x => x.Column10).ToCell("J1"); + }); + _simpleMappingExporter = MiniExcel.Exporters.GetMappingExporter(simpleRegistry); } [Benchmark(Description = "MiniExcel Create Xlsx")] @@ -31,6 +49,14 @@ public void MiniExcelCreateTest() _exporter.Export(path.FilePath, GetValue()); } + [Benchmark(Description = "MiniExcel Create Xlsx with Simple Mapping")] + public void MiniExcelCreateWithSimpleMappingTest() + { + using var path = AutoDeletingPath.Create(); + using var stream = File.Create(path.FilePath); + _simpleMappingExporter.Export(stream, GetValue()); + } + [Benchmark(Description = "ClosedXml Create Xlsx")] public void ClosedXmlCreateTest() { diff --git a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/QueryExcelBenchmark.cs b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/QueryExcelBenchmark.cs index e0e18a30..24d04fdc 100644 --- a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/QueryExcelBenchmark.cs +++ b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/QueryExcelBenchmark.cs @@ -5,6 +5,7 @@ using DocumentFormat.OpenXml.Spreadsheet; using ExcelDataReader; using MiniExcelLib.Core; +using MiniExcelLib.Core.FluentMapping; using NPOI.XSSF.UserModel; using OfficeOpenXml; @@ -13,6 +14,7 @@ namespace MiniExcelLib.Benchmarks.BenchmarkSections; public class QueryExcelBenchmark : BenchmarkBase { private OpenXmlImporter _importer; + private MappingImporter _mappingImporter; [GlobalSetup] public void SetUp() @@ -21,6 +23,23 @@ public void SetUp() Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); _importer = MiniExcel.Importers.GetOpenXmlImporter(); + + // Setup mapping for query (matches CreateExcelBenchmark mapping) + var registry = new MappingRegistry(); + registry.Configure(config => + { + config.Property(x => x.Column1).ToCell("A1"); + config.Property(x => x.Column2).ToCell("B1"); + config.Property(x => x.Column3).ToCell("C1"); + config.Property(x => x.Column4).ToCell("D1"); + config.Property(x => x.Column5).ToCell("E1"); + config.Property(x => x.Column6).ToCell("F1"); + config.Property(x => x.Column7).ToCell("G1"); + config.Property(x => x.Column8).ToCell("H1"); + config.Property(x => x.Column9).ToCell("I1"); + config.Property(x => x.Column10).ToCell("J1"); + }); + _mappingImporter = MiniExcel.Importers.GetMappingImporter(registry); } [Benchmark(Description = "MiniExcel QueryFirst")] @@ -35,6 +54,18 @@ public void MiniExcel_Query() foreach (var _ in _importer.Query(FilePath)) { } } + [Benchmark(Description = "MiniExcel QueryFirst with Mapping")] + public void MiniExcel_QueryFirst_Mapping_Test() + { + _ = _mappingImporter.Query(FilePath).First(); + } + + [Benchmark(Description = "MiniExcel Query with Mapping")] + public void MiniExcel_Query_Mapping() + { + foreach (var _ in _mappingImporter.Query(FilePath)) { } + } + [Benchmark(Description = "ExcelDataReader QueryFirst")] public void ExcelDataReader_QueryFirst_Test() { diff --git a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/TemplateExcelBenchmark.cs b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/TemplateExcelBenchmark.cs index c4fe16b7..89173841 100644 --- a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/TemplateExcelBenchmark.cs +++ b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/TemplateExcelBenchmark.cs @@ -2,17 +2,35 @@ using ClosedXML.Report; using MiniExcelLib.Benchmarks.Utils; using MiniExcelLib.Core; +using MiniExcelLib.Core.FluentMapping; namespace MiniExcelLib.Benchmarks.BenchmarkSections; public class TemplateExcelBenchmark : BenchmarkBase { private OpenXmlTemplater _templater; + private MappingTemplater _mappingTemplater; + private OpenXmlExporter _exporter; + + public class Employee + { + public string Name { get; set; } = ""; + public string Department { get; set; } = ""; + } [GlobalSetup] public void Setup() { _templater = MiniExcel.Templaters.GetOpenXmlTemplater(); + _exporter = MiniExcel.Exporters.GetOpenXmlExporter(); + + var registry = new MappingRegistry(); + registry.Configure(config => + { + config.Property(x => x.Name).ToCell("A2"); + config.Property(x => x.Department).ToCell("B2"); + }); + _mappingTemplater = MiniExcel.Templaters.GetMappingTemplater(registry); } [Benchmark(Description = "MiniExcel Template Generate")] @@ -56,4 +74,26 @@ public void ClosedXml_Report_Template_Generate_Test() template.SaveAs(path.FilePath); } + + [Benchmark(Description = "MiniExcel Mapping Template Generate")] + public void MiniExcel_Mapping_Template_Generate_Test() + { + using var templatePath = AutoDeletingPath.Create(); + var templateData = new[] + { + new { A = "Name", B = "Department" }, + new { A = "", B = "" } // Empty row for data + }; + _exporter.Export(templatePath.FilePath, templateData); + + using var outputPath = AutoDeletingPath.Create(); + var employees = Enumerable.Range(1, RowCount) + .Select(s => new Employee + { + Name = "Jack", + Department = "HR" + }); + + _mappingTemplater.ApplyTemplate(outputPath.FilePath, templatePath.FilePath, employees); + } } \ No newline at end of file diff --git a/src/MiniExcel.Core/FluentMapping/Api/MappingExporter.cs b/src/MiniExcel.Core/FluentMapping/Api/MappingExporter.cs new file mode 100644 index 00000000..62e51654 --- /dev/null +++ b/src/MiniExcel.Core/FluentMapping/Api/MappingExporter.cs @@ -0,0 +1,41 @@ +namespace MiniExcelLib.Core.FluentMapping; + +public sealed partial class MappingExporter +{ + private readonly MappingRegistry _registry; + + public MappingExporter() + { + _registry = new MappingRegistry(); + } + + public MappingExporter(MappingRegistry registry) + { + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + } + + [CreateSyncVersion] + public async Task ExportAsync(string path, IEnumerable? values, bool overwriteFile = false, CancellationToken cancellationToken = default) where T : class + { + var filePath = path.EndsWith(".xlsx", StringComparison.InvariantCultureIgnoreCase) ? path : $"{path}.xlsx" ; + + using var stream = overwriteFile ? File.Create(filePath) : new FileStream(filePath, FileMode.CreateNew); + await ExportAsync(stream, values, cancellationToken).ConfigureAwait(false); + } + + [CreateSyncVersion] + public async Task ExportAsync(Stream? stream, IEnumerable? values, CancellationToken cancellationToken = default) where T : class + { + if (stream is null) + throw new ArgumentNullException(nameof(stream)); + if (values is null) + throw new ArgumentNullException(nameof(values)); + + if (!_registry.HasMapping()) + throw new InvalidOperationException($"No mapping configured for type {typeof(T).Name}. Call Configure<{typeof(T).Name}>() first."); + + var mapping = _registry.GetMapping(); + + await MappingWriter.SaveAsAsync(stream, values, mapping, cancellationToken).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/FluentMapping/Api/MappingImporter.cs b/src/MiniExcel.Core/FluentMapping/Api/MappingImporter.cs new file mode 100644 index 00000000..d7a1e14d --- /dev/null +++ b/src/MiniExcel.Core/FluentMapping/Api/MappingImporter.cs @@ -0,0 +1,56 @@ +namespace MiniExcelLib.Core.FluentMapping; + +public sealed partial class MappingImporter() +{ + private readonly MappingRegistry _registry = new(); + + public MappingImporter(MappingRegistry registry) : this() + { + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + } + + [CreateSyncVersion] + public async IAsyncEnumerable QueryAsync(string path, [EnumeratorCancellation] CancellationToken cancellationToken = default) where T : class, new() + { + using var stream = File.OpenRead(path); + await foreach (var item in QueryAsync(stream, cancellationToken).ConfigureAwait(false)) + yield return item; + } + + [CreateSyncVersion] + public async IAsyncEnumerable QueryAsync(Stream? stream, [EnumeratorCancellation] CancellationToken cancellationToken = default) where T : class, new() + { + if (stream is null) + throw new ArgumentNullException(nameof(stream)); + + if (_registry.GetCompiledMapping() is not { } mapping) + throw new InvalidOperationException($"No mapping configuration found for type {typeof(T).Name}. Configure the mapping using MappingRegistry.Configure<{typeof(T).Name}>()."); + + await foreach (var item in MappingReader.QueryAsync(stream, mapping, cancellationToken).ConfigureAwait(false)) + yield return item; + } + + [CreateSyncVersion] + public async Task QuerySingleAsync(string path, CancellationToken cancellationToken = default) where T : class, new() + { + using var stream = File.OpenRead(path); + return await QuerySingleAsync(stream, cancellationToken).ConfigureAwait(false); + } + + [CreateSyncVersion] + private async Task QuerySingleAsync(Stream? stream, CancellationToken cancellationToken = default) where T : class, new() + { + if (stream is null) + throw new ArgumentNullException(nameof(stream)); + + if (_registry.GetCompiledMapping() is not { } mapping) + throw new InvalidOperationException($"No mapping configuration found for type {typeof(T).Name}. Configure the mapping using MappingRegistry.Configure<{typeof(T).Name}>()."); + + await foreach (var item in MappingReader.QueryAsync(stream, mapping, cancellationToken).ConfigureAwait(false)) + { + return item; // Return the first item + } + + throw new InvalidOperationException("No data found."); + } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/FluentMapping/Api/MappingTemplater.cs b/src/MiniExcel.Core/FluentMapping/Api/MappingTemplater.cs new file mode 100644 index 00000000..c2de939b --- /dev/null +++ b/src/MiniExcel.Core/FluentMapping/Api/MappingTemplater.cs @@ -0,0 +1,71 @@ +namespace MiniExcelLib.Core.FluentMapping; + +public sealed partial class MappingTemplater() +{ + private readonly MappingRegistry _registry = new(); + + public MappingTemplater(MappingRegistry registry) : this() + { + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + } + + [CreateSyncVersion] + public async Task ApplyTemplateAsync( + string? outputPath, + string? templatePath, + IEnumerable? values, + CancellationToken cancellationToken = default) where T : class + { + if (string.IsNullOrEmpty(outputPath)) + throw new ArgumentException("Output path cannot be null or empty", nameof(outputPath)); + if (string.IsNullOrEmpty(templatePath)) + throw new ArgumentException("Template path cannot be null or empty", nameof(templatePath)); + if (values is null) + throw new ArgumentNullException(nameof(values)); + + using var outputStream = File.Create(outputPath); + using var templateStream = File.OpenRead(templatePath); + await ApplyTemplateAsync(outputStream, templateStream, values, cancellationToken).ConfigureAwait(false); + } + + [CreateSyncVersion] + public async Task ApplyTemplateAsync( + Stream? outputStream, + Stream? templateStream, + IEnumerable? values, + CancellationToken cancellationToken = default) where T : class + { + if (outputStream is null) + throw new ArgumentNullException(nameof(outputStream)); + if (templateStream is null) + throw new ArgumentNullException(nameof(templateStream)); + if (values is null) + throw new ArgumentNullException(nameof(values)); + + if (!_registry.HasMapping()) + throw new InvalidOperationException( + $"No mapping configured for type {typeof(T).Name}. Call Configure<{typeof(T).Name}>() first."); + + var mapping = _registry.GetMapping(); + await MappingTemplateApplicator.ApplyTemplateAsync( + outputStream, templateStream, values, mapping, cancellationToken).ConfigureAwait(false); + } + + [CreateSyncVersion] + public async Task ApplyTemplateAsync( + Stream? outputStream, + byte[]? templateBytes, + IEnumerable? values, + CancellationToken cancellationToken = default) where T : class + { + if (outputStream is null) + throw new ArgumentNullException(nameof(outputStream)); + if (templateBytes is null) + throw new ArgumentNullException(nameof(templateBytes)); + if (values is null) + throw new ArgumentNullException(nameof(values)); + + using var templateStream = new MemoryStream(templateBytes); + await ApplyTemplateAsync(outputStream, templateStream, values, cancellationToken).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/FluentMapping/Api/ProviderExtensions.cs b/src/MiniExcel.Core/FluentMapping/Api/ProviderExtensions.cs new file mode 100644 index 00000000..6ab369c2 --- /dev/null +++ b/src/MiniExcel.Core/FluentMapping/Api/ProviderExtensions.cs @@ -0,0 +1,13 @@ +namespace MiniExcelLib.Core.FluentMapping; + +public static class ProviderExtensions +{ + public static MappingExporter GetMappingExporter(this MiniExcelExporterProvider exporterProvider) => new(); + public static MappingExporter GetMappingExporter(this MiniExcelExporterProvider exporterProvider, MappingRegistry registry) => new(registry); + + public static MappingImporter GetMappingImporter(this MiniExcelImporterProvider importerProvider) => new(); + public static MappingImporter GetMappingImporter(this MiniExcelImporterProvider importerProvider, MappingRegistry registry) => new(registry); + + public static MappingTemplater GetMappingTemplater(this MiniExcelTemplaterProvider templaterProvider) => new(); + public static MappingTemplater GetMappingTemplater(this MiniExcelTemplaterProvider templaterProvider, MappingRegistry registry) => new(registry); +} \ No newline at end of file diff --git a/src/MiniExcel.Core/FluentMapping/CompiledMapping.cs b/src/MiniExcel.Core/FluentMapping/CompiledMapping.cs new file mode 100644 index 00000000..84238b3c --- /dev/null +++ b/src/MiniExcel.Core/FluentMapping/CompiledMapping.cs @@ -0,0 +1,324 @@ +namespace MiniExcelLib.Core.FluentMapping; + +internal class CompiledMapping +{ + public string WorksheetName { get; set; } = "Sheet1"; + public IReadOnlyList Properties { get; set; } = new List(); + public IReadOnlyList Collections { get; set; } = new List(); + + // Optimization structures + /// + /// Pre-calculated cell grid for fast mapping. + /// Indexed as [row-relative][column-relative] where indices are relative to MinRow/MinColumn + /// + public OptimizedCellHandler[,]? OptimizedCellGrid { get; set; } + + /// Mapping boundaries and optimization metadata + public OptimizedMappingBoundaries? OptimizedBoundaries { get; set; } + + /// + /// For reading: array of column handlers indexed by (column - MinColumn). + /// Provides O(1) lookup from column index to property setter. + /// + public OptimizedCellHandler[]? OptimizedColumnHandlers { get; set; } + + /// + /// Pre-compiled collection helpers for fast collection handling + /// + public IReadOnlyList? OptimizedCollectionHelpers { get; set; } + + /// + /// Pre-compiled nested mapping information for complex collection types. + /// Indexed by collection index to provide fast access to nested property info. + /// + public IReadOnlyDictionary? NestedMappings { get; set; } + + /// + /// Tries to get the cell handler at the specified absolute row and column position. + /// + /// The absolute row number (1-based) + /// The absolute column number (1-based) + /// The handler if found, or default if not + /// True if a non-empty handler was found at the position + public bool TryGetHandler(int absoluteRow, int absoluteCol, out OptimizedCellHandler handler) + { + handler = default!; + + if (OptimizedCellGrid is null || OptimizedBoundaries is null) + return false; + + var relRow = absoluteRow - OptimizedBoundaries.MinRow; + var relCol = absoluteCol - OptimizedBoundaries.MinColumn; + + if (relRow < 0 || relRow >= OptimizedBoundaries.GridHeight || + relCol < 0 || relCol >= OptimizedBoundaries.GridWidth) + return false; + + handler = OptimizedCellGrid[relRow, relCol]; + return handler.Type != CellHandlerType.Empty; + } + + /// + /// Tries to extract a value from an item using the specified handler. + /// + /// The type of the item + /// The cell handler containing the value extractor + /// The item to extract the value from + /// The extracted value, or null if extraction failed + /// True if the value was successfully extracted + public bool TryGetValue(OptimizedCellHandler handler, TItem? item, out object? value) where TItem : class + { + value = null; + + if (item is null || handler.ValueExtractor is null) + return false; + + value = handler.ValueExtractor(item, 0); + return true; + } + + /// + /// Tries to set a value on an item using the specified handler. + /// + /// The type of the item + /// The cell handler containing the value setter + /// The item to set the value on + /// The value to set + /// True if the value was successfully set + public bool TrySetValue(OptimizedCellHandler handler, TItem? item, object? value) where TItem : class + { + if (item is null || handler.ValueSetter is null) + return false; + + handler.ValueSetter(item, value); + return true; + } + + /// + /// Convenience method that combines TryGetHandler and TryGetValue in a single call. + /// + /// The type of the item + /// The absolute row number (1-based) + /// The absolute column number (1-based) + /// The item to extract the value from + /// The extracted value, or null if not found + /// True if a value was successfully extracted + public bool TryGetCellValue(int row, int col, TItem item, out object? value) where TItem : class + { + value = null; + + return TryGetHandler(row, col, out var handler) && + TryGetValue(handler, item, out value); + } + + /// + /// Convenience method that combines TryGetHandler and TrySetValue in a single call. + /// + /// The type of the item + /// The absolute row number (1-based) + /// The absolute column number (1-based) + /// The item to set the value on + /// The value to set + /// True if the value was successfully set + public bool TrySetCellValue(int row, int col, TItem item, object? value) where TItem : class + { + return TryGetHandler(row, col, out var handler) && + TrySetValue(handler, item, value); + } + + /// + /// Tries to set a property value on an item using the compiled property mapping. + /// + /// The type of the item + /// The compiled property mapping + /// The item to set the value on + /// The value to set + /// True if the value was successfully set + public bool TrySetPropertyValue(CompiledPropertyMapping property, TItem item, object? value) where TItem : class + { + if (property.Setter is null) + return false; + + property.Setter(item, value); + return true; + } + + /// + /// Tries to set a collection value on an item using the compiled collection mapping. + /// + /// The type of the item + /// The compiled collection mapping + /// The item to set the collection on + /// The collection value to set + /// True if the collection was successfully set + public bool TrySetCollectionValue(CompiledCollectionMapping collection, TItem item, object? value) where TItem : class + { + if (collection.Setter is null) + return false; + + collection.Setter(item, value); + return true; + } +} + +/// +/// Pre-compiled helpers for collection handling +/// +internal class OptimizedCollectionHelper +{ + public Func Factory { get; set; } = null!; + public Func Finalizer { get; set; } = null!; + public Action? Setter { get; set; } + public bool IsArray { get; set; } + public Func DefaultItemFactory { get; set; } = null!; + public Type ItemType { get; set; } = null!; + public bool IsItemValueType { get; set; } + public bool IsItemPrimitive { get; set; } + public object? DefaultValue { get; set; } +} + +internal class CompiledPropertyMapping +{ + public Func Getter { get; set; } = null!; + public string CellAddress { get; set; } = null!; + public int CellColumn { get; set; } // Pre-parsed column index + public int CellRow { get; set; } // Pre-parsed row index + public string? Format { get; set; } + public string? Formula { get; set; } + public Type PropertyType { get; set; } = null!; + public string PropertyName { get; set; } = null!; + public Action? Setter { get; set; } +} + +internal class CompiledCollectionMapping +{ + public Func Getter { get; set; } = null!; + public int StartCellColumn { get; set; } // Pre-parsed column index + public int StartCellRow { get; set; } // Pre-parsed row index + public CollectionLayout Layout { get; set; } + public int RowSpacing { get; set; } + public Type? ItemType { get; set; } + public string PropertyName { get; set; } = null!; + public Action? Setter { get; set; } + public MappingRegistry? Registry { get; set; } // For looking up nested type mappings +} + +/// +/// Defines the layout direction for collections in Excel mappings. +/// +internal enum CollectionLayout +{ + /// Collections expand vertically (downward in rows) + Vertical = 0, +} + + +/// +/// Represents the type of data a cell contains in the mapping +/// +internal enum CellHandlerType +{ + /// Cell is empty/unused + Empty, + /// Cell contains a simple property value + Property, + /// Cell contains an item from a collection + CollectionItem, + /// Cell contains a formula + Formula +} + +/// +/// Pre-compiled handler for a specific cell in the mapping grid. +/// Contains all information needed to extract/set values for that cell without runtime parsing. +/// +internal class OptimizedCellHandler +{ + /// Type of data this cell contains + public CellHandlerType Type { get; set; } = CellHandlerType.Empty; + + /// For Property/Formula: direct property getter. For CollectionItem: collection getter + indexer + public Func? ValueExtractor { get; set; } + + /// For reading: direct property setter with conversion built-in + public Action? ValueSetter { get; set; } + + /// Property name for debugging/error reporting + public string? PropertyName { get; set; } + + /// For collection items: which collection this belongs to + public int CollectionIndex { get; set; } = -1; + + /// For collection items: offset within collection + public int CollectionItemOffset { get; set; } + + /// For formulas: the formula text + public string? Formula { get; set; } + + /// For formatted values: the format string + public string? Format { get; set; } + + /// For collection items: reference to the collection mapping + public CompiledCollectionMapping? CollectionMapping { get; set; } + + /// For collection items: pre-compiled converter from cell value to collection item type + public Func? CollectionItemConverter { get; set; } + + /// + /// For multiple items scenario: which item (0, 1, 2...) this handler belongs to. + /// -1 means this handler applies to all items (unbounded collection). + /// + public int ItemIndex { get; set; } + + /// + /// For collection handlers: the row where this collection stops reading (exclusive). + /// -1 means unbounded (continue until no more data). + /// + public int BoundaryRow { get; set; } = -1; + + /// + /// For collection handlers: the column where this collection stops reading (exclusive). + /// -1 means unbounded (continue until no more data). + /// + public int BoundaryColumn { get; set; } = -1; +} + +/// +/// Optimized mapping boundaries and metadata +/// +internal class OptimizedMappingBoundaries +{ + /// Minimum row used by any mapping (1-based) + public int MinRow { get; set; } = int.MaxValue; + + /// Maximum row used by any mapping (1-based) + public int MaxRow { get; set; } + + /// Minimum column used by any mapping (1-based) + public int MinColumn { get; set; } = int.MaxValue; + + /// Maximum column used by any mapping (1-based) + public int MaxColumn { get; set; } + + /// Width of the cell grid (MaxColumn - MinColumn + 1) + public int GridWidth => MaxColumn > 0 ? MaxColumn - MinColumn + 1 : 0; + + /// Height of the cell grid (MaxRow - MinRow + 1) + public int GridHeight => MaxRow > 0 ? MaxRow - MinRow + 1 : 0; + + /// Whether this mapping has collections that can expand dynamically + public bool HasDynamicCollections { get; set; } + + /// + /// For multiple items with collections: the height of the repeating pattern. + /// This is the distance from one item's properties to the next item's properties. + /// 0 means no repeating pattern (single item or no collections). + /// + public int PatternHeight { get; set; } + + /// + /// For multiple items: whether this mapping supports multiple items with collections. + /// When true, the grid pattern repeats every PatternHeight rows. + /// + public bool IsMultiItemPattern { get; set; } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/FluentMapping/Configuration/CollectionMappingBuilder.cs b/src/MiniExcel.Core/FluentMapping/Configuration/CollectionMappingBuilder.cs new file mode 100644 index 00000000..57719ea8 --- /dev/null +++ b/src/MiniExcel.Core/FluentMapping/Configuration/CollectionMappingBuilder.cs @@ -0,0 +1,56 @@ +namespace MiniExcelLib.Core.FluentMapping.Configuration; + +internal partial class CollectionMappingBuilder : ICollectionMappingBuilder where TCollection : IEnumerable +{ +#if NET7_0_OR_GREATER + [GeneratedRegex("^[A-Z]+[0-9]+$")] private static partial Regex CellAddressRegexImpl(); + private static readonly Regex CellAddressRegex = CellAddressRegexImpl(); +#else + private static readonly Regex CellAddressRegex = new("^[A-Z]+[0-9]+$", RegexOptions.Compiled); +#endif + + private readonly CollectionMapping _mapping; + + internal CollectionMappingBuilder(CollectionMapping mapping) + { + _mapping = mapping; + // Collections are always vertical (rows) by default + _mapping.Layout = CollectionLayout.Vertical; + } + + public ICollectionMappingBuilder StartAt(string cellAddress) + { + if (string.IsNullOrEmpty(cellAddress)) + throw new ArgumentException("Cell address cannot be null or empty", nameof(cellAddress)); + + // Basic validation for cell address format + if (!CellAddressRegex.IsMatch(cellAddress)) + throw new ArgumentException($"Invalid cell address format: {cellAddress}. Expected format like A1, B2, AA10, etc.", nameof(cellAddress)); + + _mapping.StartCell = cellAddress; + return this; + } + + public ICollectionMappingBuilder WithSpacing(int spacing) + { + if (spacing < 0) + throw new ArgumentException("Spacing cannot be negative", nameof(spacing)); + + _mapping.RowSpacing = spacing; + return this; + } + + public ICollectionMappingBuilder WithItemMapping(Action> configure) + { + if (configure is null) + throw new ArgumentNullException(nameof(configure)); + + var itemConfig = new MappingConfiguration(); + configure(itemConfig); + + _mapping.ItemConfiguration = itemConfig; + _mapping.ItemType = typeof(TItem); + + return this; + } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/FluentMapping/Configuration/ICollectionMappingBuilder.cs b/src/MiniExcel.Core/FluentMapping/Configuration/ICollectionMappingBuilder.cs new file mode 100644 index 00000000..03fd8dc7 --- /dev/null +++ b/src/MiniExcel.Core/FluentMapping/Configuration/ICollectionMappingBuilder.cs @@ -0,0 +1,10 @@ +namespace MiniExcelLib.Core.FluentMapping.Configuration; + +public interface ICollectionMappingBuilder where TCollection : IEnumerable +{ + ICollectionMappingBuilder StartAt(string cellAddress); + + ICollectionMappingBuilder WithSpacing(int spacing); + + ICollectionMappingBuilder WithItemMapping(Action> configure); +} \ No newline at end of file diff --git a/src/MiniExcel.Core/FluentMapping/Configuration/IMappingConfiguration.cs b/src/MiniExcel.Core/FluentMapping/Configuration/IMappingConfiguration.cs new file mode 100644 index 00000000..c8200540 --- /dev/null +++ b/src/MiniExcel.Core/FluentMapping/Configuration/IMappingConfiguration.cs @@ -0,0 +1,10 @@ +using System.Linq.Expressions; + +namespace MiniExcelLib.Core.FluentMapping.Configuration; + +public interface IMappingConfiguration +{ + IPropertyMappingBuilder Property(Expression> property); + ICollectionMappingBuilder Collection(Expression> collection) where TCollection : IEnumerable; + IMappingConfiguration ToWorksheet(string worksheetName); +} \ No newline at end of file diff --git a/src/MiniExcel.Core/FluentMapping/Configuration/IPropertyMappingBuilder.cs b/src/MiniExcel.Core/FluentMapping/Configuration/IPropertyMappingBuilder.cs new file mode 100644 index 00000000..8580ed9c --- /dev/null +++ b/src/MiniExcel.Core/FluentMapping/Configuration/IPropertyMappingBuilder.cs @@ -0,0 +1,8 @@ +namespace MiniExcelLib.Core.FluentMapping.Configuration; + +public interface IPropertyMappingBuilder +{ + IPropertyMappingBuilder ToCell(string cellAddress); + IPropertyMappingBuilder WithFormat(string format); + IPropertyMappingBuilder WithFormula(string formula); +} \ No newline at end of file diff --git a/src/MiniExcel.Core/FluentMapping/Configuration/MappingConfiguration.cs b/src/MiniExcel.Core/FluentMapping/Configuration/MappingConfiguration.cs new file mode 100644 index 00000000..1f35f778 --- /dev/null +++ b/src/MiniExcel.Core/FluentMapping/Configuration/MappingConfiguration.cs @@ -0,0 +1,71 @@ +using System.Linq.Expressions; + +namespace MiniExcelLib.Core.FluentMapping.Configuration; + +internal class MappingConfiguration : IMappingConfiguration +{ + internal readonly List PropertyMappings = []; + internal readonly List CollectionMappings = []; + internal string? WorksheetName { get; private set; } + + public IPropertyMappingBuilder Property( + Expression> property) + { + if (property is null) + throw new ArgumentNullException(nameof(property)); + + var mapping = new PropertyMapping + { + Expression = property, + PropertyType = typeof(TProperty) + }; + PropertyMappings.Add(mapping); + + return new PropertyMappingBuilder(mapping); + } + + public ICollectionMappingBuilder Collection( + Expression>? collection) where TCollection : IEnumerable + { + if (collection is null) + throw new ArgumentNullException(nameof(collection)); + + var mapping = new CollectionMapping + { + Expression = collection, + PropertyType = typeof(TCollection) + }; + CollectionMappings.Add(mapping); + + return new CollectionMappingBuilder(mapping); + } + + public IMappingConfiguration ToWorksheet(string worksheetName) + { + if (string.IsNullOrEmpty(worksheetName)) + throw new ArgumentException("Sheet names cannot be empty or null", nameof(worksheetName)); + if (worksheetName.Length > 31) + throw new ArgumentException("Sheet names must be less than 31 characters", nameof(worksheetName)); + + WorksheetName = worksheetName; + return this; + } +} + +internal class PropertyMapping +{ + public LambdaExpression Expression { get; set; } = null!; + public Type PropertyType { get; set; } = null!; + public string? CellAddress { get; set; } + public string? Format { get; set; } + public string? Formula { get; set; } +} + +internal class CollectionMapping : PropertyMapping +{ + public string? StartCell { get; set; } + public CollectionLayout Layout { get; set; } = CollectionLayout.Vertical; + public int RowSpacing { get; set; } + public object? ItemConfiguration { get; set; } + public Type? ItemType { get; set; } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/FluentMapping/Configuration/PropertyMappingBuilder.cs b/src/MiniExcel.Core/FluentMapping/Configuration/PropertyMappingBuilder.cs new file mode 100644 index 00000000..f0c490fd --- /dev/null +++ b/src/MiniExcel.Core/FluentMapping/Configuration/PropertyMappingBuilder.cs @@ -0,0 +1,43 @@ +namespace MiniExcelLib.Core.FluentMapping.Configuration; + +internal partial class PropertyMappingBuilder : IPropertyMappingBuilder +{ +#if NET7_0_OR_GREATER + [GeneratedRegex("^[A-Z]+[0-9]+$")] private static partial Regex CellAddressRegexImpl(); + private static readonly Regex CellAddressRegex = CellAddressRegexImpl(); +#else + private static readonly Regex CellAddressRegex = new("^[A-Z]+[0-9]+$", RegexOptions.Compiled); +#endif + + private readonly PropertyMapping _mapping; + + internal PropertyMappingBuilder(PropertyMapping mapping) + { + _mapping = mapping; + } + + public IPropertyMappingBuilder ToCell(string cellAddress) + { + if (string.IsNullOrEmpty(cellAddress)) + throw new ArgumentException("Cell address cannot be null or empty", nameof(cellAddress)); + + // Basic validation for cell address format (e.g., A1, AB123, etc.) + if (!CellAddressRegex.IsMatch(cellAddress)) + throw new ArgumentException($"Invalid cell address format: {cellAddress}. Expected format like A1, B2, AA10, etc.", nameof(cellAddress)); + + _mapping.CellAddress = cellAddress; + return this; + } + + public IPropertyMappingBuilder WithFormat(string format) + { + _mapping.Format = format; + return this; + } + + public IPropertyMappingBuilder WithFormula(string formula) + { + _mapping.Formula = formula; + return this; + } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/FluentMapping/MappingCellStream.cs b/src/MiniExcel.Core/FluentMapping/MappingCellStream.cs new file mode 100644 index 00000000..1834de67 --- /dev/null +++ b/src/MiniExcel.Core/FluentMapping/MappingCellStream.cs @@ -0,0 +1,251 @@ +using MiniExcelLib.Core.WriteAdapters; + +namespace MiniExcelLib.Core.FluentMapping; + +internal interface IMappingCellStream +{ + IMiniExcelWriteAdapter CreateAdapter(); +} + +internal readonly struct MappingCellStream(IEnumerable items, CompiledMapping mapping, string[] columnLetters) : IMappingCellStream + where T : class +{ + public MappingCellEnumerator GetEnumerator() + => new(items.GetEnumerator(), mapping, columnLetters); + + public IMiniExcelWriteAdapter CreateAdapter() + => new MappingCellStreamAdapter(this, columnLetters); +} + +internal struct MappingCellEnumerator : IEnumerator + where T : class +{ + private readonly IEnumerator _itemEnumerator; + private readonly CompiledMapping _mapping; + private readonly string[] _columnLetters; + private readonly OptimizedMappingBoundaries _boundaries; + private readonly int _columnCount; + + private T? _currentItem; + private int _currentRowIndex; + private int _currentColumnIndex; + private bool _hasStartedData; + private bool _isComplete; + private readonly object _emptyCell; + private int _maxCollectionRows; + private int _currentCollectionRow; + + public MappingCellEnumerator(IEnumerator itemEnumerator, CompiledMapping mapping, string[] columnLetters) + { + _itemEnumerator = itemEnumerator; + _mapping = mapping; + _columnLetters = columnLetters; + _boundaries = mapping.OptimizedBoundaries!; + _columnCount = _boundaries.MaxColumn - _boundaries.MinColumn + 1; + + _currentItem = default; + _currentRowIndex = 0; + _currentColumnIndex = 0; + _hasStartedData = false; + _isComplete = false; + _emptyCell = string.Empty; + _maxCollectionRows = 0; + _currentCollectionRow = 0; + + Current = default; + } + + public MappingCell Current { get; private set; } + object IEnumerator.Current => Current; + + public bool MoveNext() + { + while (true) + { + if (_isComplete) + return false; + + // Handle rows before data starts (if MinRow > 1) + if (!_hasStartedData) + { + if (_currentRowIndex == 0) + { + _currentRowIndex = 1; + _currentColumnIndex = 0; + } + + // Emit empty cells for rows before MinRow + if (_currentRowIndex < _boundaries.MinRow) + { + if (_currentColumnIndex < _columnCount) + { + var columnLetter = _columnLetters[_currentColumnIndex]; + Current = new MappingCell(columnLetter, _currentRowIndex, _emptyCell); + _currentColumnIndex++; + return true; + } + + // Move to next empty row + _currentRowIndex++; + _currentColumnIndex = 0; + + if (_currentRowIndex < _boundaries.MinRow) + { + continue; + } + } + + // Start processing actual data + _hasStartedData = true; + if (!_itemEnumerator.MoveNext()) + { + _isComplete = true; + return false; + } + + _currentItem = _itemEnumerator.Current; + _currentColumnIndex = 0; + } + + // Process current item's cells + if (_currentItem is not null) + { + // Cache collection metrics when we start processing an item + if (_currentColumnIndex == 0 && _currentCollectionRow == 0 && _mapping.Collections.Count > 0) + { + _maxCollectionRows = 0; + + for (var i = 0; i < _mapping.Collections.Count; i++) + { + var collection = _mapping.Collections[i]; + if (collection.Getter(_currentItem) is not { } collectionData) + continue; + + // Convert to a list once - this is the only enumeration + var items = collectionData.Cast().ToList(); + + // Resolve nested mapping info if available + NestedMappingInfo? nestedInfo = null; + if (_mapping.NestedMappings?.TryGetValue(i, out var precompiledNested) is true) + { + nestedInfo = precompiledNested; + } + + // Calculate the furthest row this collection (including nested collections) needs + var collectionMaxRow = collection.StartCellRow - 1; + + for (var itemIndex = 0; itemIndex < items.Count; itemIndex++) + { + if (items[itemIndex] is not { } item) + continue; + + var itemRow = collection.StartCellRow + itemIndex * (1 + collection.RowSpacing); + if (itemRow > collectionMaxRow) + { + collectionMaxRow = itemRow; + } + + if (nestedInfo?.Collections is { Count: > 0 } collections) + { + foreach (var nested in collections.Values) + { + if (nested.Getter(item) is { } nestedData) + { + var nestedIndex = 0; + foreach (var _ in nestedData) + { + var nestedRow = nested.StartRow + + itemIndex * (1 + collection.RowSpacing) + + nestedIndex * (1 + nested.RowSpacing); + + if (nestedRow > collectionMaxRow) + { + collectionMaxRow = nestedRow; + } + + nestedIndex++; + } + } + } + } + } + + var neededRows = collectionMaxRow - _currentRowIndex + 1; + if (neededRows > _maxCollectionRows) + { + _maxCollectionRows = neededRows; + } + } + } + + // Emit cells for current row + if (_currentColumnIndex < _columnCount) + { + var columnLetter = _columnLetters[_currentColumnIndex]; + var columnNumber = _boundaries.MinColumn + _currentColumnIndex; + + object? cellValue = _emptyCell; + + // Use the optimized grid for fast lookup + if (_mapping.TryGetHandler(_currentRowIndex, columnNumber, out var handler)) + { + if (_mapping.TryGetValue(handler, _currentItem, out var value)) + { + cellValue = value ?? _emptyCell; + + if (value is IFormattable formattable && !string.IsNullOrEmpty(handler.Format)) + { + cellValue = formattable.ToString(handler.Format, null); + } + } + } + + Current = new MappingCell(columnLetter, _currentRowIndex, cellValue); + _currentColumnIndex++; + return true; + } + + // Check if we need to emit more rows for collections + _currentCollectionRow++; + if (_currentCollectionRow < _maxCollectionRows) + { + _currentRowIndex++; + _currentColumnIndex = 0; + continue; + } + + // Reset for next item + _currentCollectionRow = 0; + + // Move to next item + if (_itemEnumerator.MoveNext()) + { + _currentItem = _itemEnumerator.Current; + _currentRowIndex++; + _currentColumnIndex = 0; + continue; + } + } + + _isComplete = true; + return false; + } + } + + public void Reset() + { + throw new NotSupportedException(); + } + + public void Dispose() + { + _itemEnumerator?.Dispose(); + } +} + +internal readonly struct MappingCell(string columnLetter, int rowIndex, object? value) +{ + public readonly string ColumnLetter = columnLetter; + public readonly int RowIndex = rowIndex; + public readonly object? Value = value; +} \ No newline at end of file diff --git a/src/MiniExcel.Core/FluentMapping/MappingCompiler.cs b/src/MiniExcel.Core/FluentMapping/MappingCompiler.cs new file mode 100644 index 00000000..4c5a1490 --- /dev/null +++ b/src/MiniExcel.Core/FluentMapping/MappingCompiler.cs @@ -0,0 +1,775 @@ +using System.Linq.Expressions; +using MiniExcelLib.Core.FluentMapping.Configuration; + +namespace MiniExcelLib.Core.FluentMapping; + +/// +/// Compiles mapping configurations into optimized runtime representations for efficient Excel read/write operations. +/// Uses a universal optimization strategy with pre-compiled property accessors and cell grids. +/// +internal static class MappingCompiler +{ + // Conservative estimates for collection bounds when actual size is unknown + private const int DefaultCollectionHeight = 100; + private const int DefaultGridSize = 10; + private const int MaxPatternHeight = 20; + private const int MinItemsForPatternCalc = 2; + + /// + /// Compiles a mapping configuration into an optimized runtime representation. + /// + public static CompiledMapping Compile(MappingConfiguration? configuration, MappingRegistry? registry = null) + { + if (configuration is null) + throw new ArgumentNullException(nameof(configuration)); + + var properties = new List(); + var collections = new List(); + + // Compile property mappings + foreach (var prop in configuration.PropertyMappings) + { + if (string.IsNullOrEmpty(prop.CellAddress)) + throw new InvalidOperationException("Property mapping must specify a cell address using ToCell()"); + + var propertyName = GetPropertyName(prop.Expression); + + // Build getter expression + var parameter = Expression.Parameter(typeof(object), "obj"); + var cast = Expression.Convert(parameter, typeof(T)); + var propertyAccess = Expression.Invoke(prop.Expression, cast); + var convertToObject = Expression.Convert(propertyAccess, typeof(object)); + var lambda = Expression.Lambda>(convertToObject, parameter); + var compiled = lambda.Compile(); + + // Create setter with proper type conversion using centralized logic + Action? setter = null; + if (prop.Expression.Body is MemberExpression { Member: PropertyInfo propInfo }) + { + setter = ConversionHelper.CreateTypedPropertySetter(propInfo); + } + + // Pre-parse cell coordinates for runtime performance + if (prop.CellAddress is null) + continue; + + ReferenceHelper.ParseReference(prop.CellAddress, out int cellCol, out int cellRow); + + properties.Add(new CompiledPropertyMapping + { + Getter = compiled, + CellAddress = prop.CellAddress ?? string.Empty, + CellColumn = cellCol, + CellRow = cellRow, + Format = prop.Format, + Formula = prop.Formula, + PropertyType = prop.PropertyType, + PropertyName = propertyName, + Setter = setter + }); + } + + // Compile collection mappings + foreach (var coll in configuration.CollectionMappings) + { + if (string.IsNullOrEmpty(coll.StartCell)) + throw new InvalidOperationException("Collection mapping must specify a start cell using StartAt()"); + + var parameter = Expression.Parameter(typeof(object), "obj"); + var cast = Expression.Convert(parameter, typeof(T)); + var collectionAccess = Expression.Invoke(coll.Expression, cast); + var convertToEnumerable = Expression.Convert(collectionAccess, typeof(IEnumerable)); + var lambda = Expression.Lambda>(convertToEnumerable, parameter); + var compiled = lambda.Compile(); + + // Extract property name from expression + var collectionPropertyName = GetPropertyName(coll.Expression); + + // Determine the item type using centralized logic + var collectionType = coll.PropertyType; + Type? itemType = CollectionAccessor.GetItemType(collectionType); + + // Create setter for collection + Action? collectionSetter = null; + if (coll.Expression.Body is MemberExpression { Member: PropertyInfo collPropInfo }) + { + var memberSetter = new MemberSetter(collPropInfo); + collectionSetter = memberSetter.Invoke; + } + + // Pre-parse start cell coordinates + if (coll.StartCell is null) + continue; + + ReferenceHelper.ParseReference(coll.StartCell, out int startCol, out int startRow); + + var compiledCollection = new CompiledCollectionMapping + { + Getter = compiled, + StartCellColumn = startCol, + StartCellRow = startRow, + Layout = coll.Layout, + RowSpacing = coll.RowSpacing, + ItemType = itemType ?? coll.ItemType, + PropertyName = collectionPropertyName, + Setter = collectionSetter, + Registry = registry + }; + + collections.Add(compiledCollection); + } + + var compiledMapping = new CompiledMapping + { + WorksheetName = configuration.WorksheetName ?? "Sheet1", + Properties = properties, + Collections = collections + }; + + OptimizeMapping(compiledMapping); + return compiledMapping; + } + + private static string GetPropertyName(LambdaExpression expression) + { + return expression.Body switch + { + MemberExpression memberExpr => memberExpr.Member.Name, + UnaryExpression { Operand: MemberExpression unaryMemberExpr } => unaryMemberExpr.Member.Name, + _ => throw new InvalidOperationException($"Cannot extract property name from expression: {expression}") + }; + } + + /// + /// Optimizes a compiled mapping for runtime performance by pre-calculating cell positions + /// and building optimized data structures for fast lookup and processing. + /// + private static void OptimizeMapping(CompiledMapping? mapping) + { + if (mapping is null) + throw new ArgumentNullException(nameof(mapping)); + + // If already optimized, skip + if (mapping is { OptimizedCellGrid: not null, OptimizedBoundaries: not null }) + return; + + // Step 1: Calculate mapping boundaries + var boundaries = CalculateMappingBoundaries(mapping); + mapping.OptimizedBoundaries = boundaries; + + // Step 3: Build the optimized cell grid + var cellGrid = BuildOptimizedCellGrid(mapping, boundaries); + mapping.OptimizedCellGrid = cellGrid; + + // Step 4: Build optimized column handlers for reading + var columnHandlers = BuildOptimizedColumnHandlers(mapping, boundaries); + mapping.OptimizedColumnHandlers = columnHandlers; + + // Step 5: Pre-compile collection factories and finalizers + PreCompileCollectionHelpers(mapping); + } + + private static OptimizedMappingBoundaries CalculateMappingBoundaries(CompiledMapping mapping) + { + var boundaries = new OptimizedMappingBoundaries(); + + // Process simple properties + foreach (var prop in mapping.Properties) + { + UpdateBoundaries(boundaries, prop.CellColumn, prop.CellRow); + } + + // Process collections - calculate their potential extent + foreach (var coll in mapping.Collections) + { + var (minRow, maxRow, minCol, maxCol) = CalculateCollectionBounds(coll); + + UpdateBoundaries(boundaries, minCol, minRow); + UpdateBoundaries(boundaries, maxCol, maxRow); + + boundaries.HasDynamicCollections = true; // Collections can expand dynamically + } + + // Set reasonable defaults if no mappings found + if (boundaries.MinRow == int.MaxValue) + { + boundaries.MinRow = 1; + boundaries.MaxRow = 1; + boundaries.MinColumn = 1; + boundaries.MaxColumn = 1; + } + + // Detect multiple item pattern + // NOTE: Multi-item pattern should only be detected when we have simple collections + // that belong directly to the root item. Nested collections (like Departments in a Company) + // should NOT trigger multi-item pattern detection. + // For now, we'll be conservative and only enable multi-item pattern for specific scenarios + if (mapping is not { Collections.Count: > 0, Properties.Count: > 0 }) + return boundaries; + + // Check if any collection has nested mapping (complex types) + bool hasNestedCollections = false; + foreach (var coll in mapping.Collections) + { + // Check if the collection's item type has a mapping (complex type) + if (coll is { ItemType: not null, Registry: not null}) + { + // Try to get the nested mapping - if it exists, it's a complex type + var nestedMapping = coll.Registry.GetCompiledMapping(coll.ItemType); + var isComplexType = coll.ItemType != typeof(string) && + coll.ItemType is { IsValueType: false, IsPrimitive: false }; + + if (nestedMapping is not null && isComplexType) + { + hasNestedCollections = true; + break; + } + } + } + + // Only enable multi-item pattern for simple collections (not nested) + // This is a heuristic - nested collections typically mean a single root item + // with complex child items, not multiple root items + if (!hasNestedCollections) + { + // Calculate pattern height for multiple items with collections + var firstPropRow = mapping.Properties.Min(p => p.CellRow); + + // Find the actual last row of mapped elements (not the conservative bounds) + var lastMappedRow = firstPropRow; + + // Check actual collection start positions + foreach (var coll in mapping.Collections) + { + // For vertical collections, we need a reasonable estimate + // Use startRow + a small number of items (not the full 100 conservative limit) + var estimatedEndRow = coll.StartCellRow + MinItemsForPatternCalc; + lastMappedRow = Math.Max(lastMappedRow, estimatedEndRow); + } + + // The pattern height is the total height needed for one complete item + // including its properties and collections + boundaries.PatternHeight = lastMappedRow - firstPropRow + 1; + + // If we have a reasonable pattern height, mark this as a multi-item pattern + // This allows the grid to repeat for multiple items + if (boundaries.PatternHeight is > 0 and < MaxPatternHeight) + { + boundaries.IsMultiItemPattern = true; + } + } + + return boundaries; + } + + private static void UpdateBoundaries(OptimizedMappingBoundaries boundaries, int column, int row) + { + if (row < boundaries.MinRow) boundaries.MinRow = row; + if (row > boundaries.MaxRow) boundaries.MaxRow = row; + if (column < boundaries.MinColumn) boundaries.MinColumn = column; + if (column > boundaries.MaxColumn) boundaries.MaxColumn = column; + } + + private static (int minRow, int maxRow, int minCol, int maxCol) CalculateCollectionBounds(CompiledCollectionMapping collection) + { + var startRow = collection.StartCellRow; + var startCol = collection.StartCellColumn; + + // Calculate bounds based on layout + switch (collection.Layout) + { + // Vertical collections: grow downward + case CollectionLayout.Vertical: + // Use conservative estimate for initial bounds + var totalHeight = startRow + DefaultCollectionHeight; + + // Check if this is a complex type with nested mapping + var maxCol = startCol; + if (collection.ItemType is null || collection.Registry is null) + return (startRow, totalHeight, startCol, maxCol); + + var nestedMapping = collection.Registry.GetCompiledMapping(collection.ItemType); + if (nestedMapping is null || !MappingMetadataExtractor.IsComplexType(collection.ItemType)) + return (startRow, totalHeight, startCol, maxCol); + + // Extract nested mapping info to get max column + var nestedInfo = MappingMetadataExtractor.ExtractNestedMappingInfo(nestedMapping, collection.ItemType); + if (nestedInfo is { Properties.Count: > 0 }) + { + maxCol = GetMaxColumnIndex(nestedInfo, maxCol); + } + + return (startRow, totalHeight, startCol, maxCol); + } + + // Default fallback + return (startRow, startRow + DefaultGridSize, startCol, startCol + DefaultGridSize); + } + + private static OptimizedCellHandler[,] BuildOptimizedCellGrid(CompiledMapping mapping, OptimizedMappingBoundaries boundaries) + { + var height = boundaries.GridHeight; + var width = boundaries.GridWidth; + + var grid = new OptimizedCellHandler[height, width]; + + // Initialize all cells as empty + for (int row = 0; row < height; row++) + { + for (int col = 0; col < width; col++) + { + grid[row, col] = new OptimizedCellHandler { Type = CellHandlerType.Empty }; + } + } + + // Process simple properties + foreach (var prop in mapping.Properties) + { + var relativeRow = prop.CellRow - boundaries.MinRow; + var relativeCol = prop.CellColumn - boundaries.MinColumn; + + if (relativeRow >= 0 && relativeRow < height && relativeCol >= 0 && relativeCol < width) + { + grid[relativeRow, relativeCol] = new OptimizedCellHandler + { + Type = string.IsNullOrEmpty(prop.Formula) ? CellHandlerType.Property : CellHandlerType.Formula, + ValueExtractor = CreatePropertyValueExtractor(prop), + ValueSetter = prop.Setter, + PropertyName = prop.PropertyName, + Format = prop.Format, + Formula = prop.Formula, + ItemIndex = 0, // Properties belong to the first item in the pattern + BoundaryRow = -1, // Properties don't have boundaries + BoundaryColumn = -1 + }; + } + } + + // Process collections - mark their cell ranges + // Sort collections by start position to process them in order + var sortedCollections = mapping.Collections + .Select((c, i) => new { Collection = c, Index = i }) + .OrderBy(x => x.Collection.StartCellRow) + .ThenBy(x => x.Collection.StartCellColumn) + .ToList(); + + for (int i = 0; i < sortedCollections.Count; i++) + { + var item = sortedCollections[i]; + // Find the next collection's start row to use as boundary + int? nextCollectionStartRow = null; + if (i + 1 < sortedCollections.Count) + { + nextCollectionStartRow = sortedCollections[i + 1].Collection.StartCellRow; + } + MarkCollectionCells(grid, item.Collection, item.Index, boundaries, nextCollectionStartRow); + } + + return grid; + } + + private static void MarkCollectionCells(OptimizedCellHandler[,] grid, CompiledCollectionMapping collection, + int collectionIndex, OptimizedMappingBoundaries boundaries, int? nextCollectionStartRow = null) + { + var startRow = collection.StartCellRow; + var startCol = collection.StartCellColumn; + + // Mark collection cells based on layout + // Only support vertical collections + if (collection.Layout == CollectionLayout.Vertical) + { + // Mark vertical range - we'll handle dynamic expansion during runtime + MarkVerticalCollectionCells(grid, collection, collectionIndex, boundaries, startRow, startCol, nextCollectionStartRow); + } + } + + + private static void MarkVerticalCollectionCells(OptimizedCellHandler[,] grid, CompiledCollectionMapping collection, + int collectionIndex, OptimizedMappingBoundaries boundaries, int startRow, int startCol, int? nextCollectionStartRow = null) + { + var relativeCol = startCol - boundaries.MinColumn; + if (relativeCol < 0 || relativeCol >= grid.GetLength(1)) + return; + + // Check if the collection's item type has a mapping (complex type) + var itemType = collection.ItemType ?? typeof(object); + var nestedMapping = collection.Registry?.GetCompiledMapping(itemType); + + if (nestedMapping is not null && MappingMetadataExtractor.IsComplexType(itemType)) + { + // Complex type with mapping - expand each item across multiple columns + MarkVerticalComplexCollectionCells(grid, collection, collectionIndex, boundaries, startRow, nestedMapping, nextCollectionStartRow); + } + else + { + // Simple type - single column + var maxRows = Math.Min(DefaultCollectionHeight, grid.GetLength(0)); + var startRelativeRow = startRow - boundaries.MinRow; + + // Pre-compile the item converter for this collection + var itemConverter = CreatePreCompiledItemConverter(itemType); + + for (int r = startRelativeRow; r >= 0 && r < maxRows && r < grid.GetLength(0); r++) + { + // Skip rows with spacing + var itemIndex = (r - startRelativeRow) / (1 + collection.RowSpacing); + var isDataRow = (r - startRelativeRow) % (1 + collection.RowSpacing) == 0; + + if (isDataRow && grid[r, relativeCol].Type == CellHandlerType.Empty) + { + grid[r, relativeCol] = new OptimizedCellHandler + { + Type = CellHandlerType.CollectionItem, + ValueExtractor = CreateCollectionValueExtractor(collection, itemIndex), + CollectionIndex = collectionIndex, + CollectionItemOffset = itemIndex, + CollectionMapping = collection, + CollectionItemConverter = itemConverter, + ItemIndex = 0, // Collections belong to the first item in the pattern + BoundaryRow = boundaries.IsMultiItemPattern ? boundaries.MinRow + boundaries.PatternHeight : -1, + BoundaryColumn = -1 // Vertical collections don't have column boundaries + }; + } + } + } + } + + + private static OptimizedCellHandler[] BuildOptimizedColumnHandlers(CompiledMapping mapping, OptimizedMappingBoundaries boundaries) + { + var columnHandlers = new OptimizedCellHandler[boundaries.GridWidth]; + + // Initialize all columns as empty + for (int i = 0; i < columnHandlers.Length; i++) + { + columnHandlers[i] = new OptimizedCellHandler { Type = CellHandlerType.Empty }; + } + + // For reading, we primarily care about the first row where headers/properties are typically defined + // Build column handlers from the first row that has properties + foreach (var prop in mapping.Properties) + { + var relativeCol = prop.CellColumn - boundaries.MinColumn; + if (relativeCol >= 0 && relativeCol < columnHandlers.Length) + { + columnHandlers[relativeCol] = new OptimizedCellHandler + { + Type = CellHandlerType.Property, + ValueSetter = prop.Setter, + PropertyName = prop.PropertyName + }; + } + } + + return columnHandlers; + } + + private static Func CreatePropertyValueExtractor(CompiledPropertyMapping property) + { + // The property getter is already compiled, just wrap it to match our signature + var getter = property.Getter; + return (obj, _) => getter(obj); + } + + private static Func CreateCollectionValueExtractor(CompiledCollectionMapping collection, int offset) + { + var getter = collection.Getter; + return (obj, _) => + { + var enumerable = getter(obj); + return CollectionAccessor.GetItemAt(enumerable, offset); + }; + } + + private static void PreCompileCollectionHelpers(CompiledMapping mapping) + { + if (mapping.Collections.Count == 0) + return; + + // Store pre-compiled helpers for each collection + var helpers = new List(); + var nestedMappings = new Dictionary(); + + for (int i = 0; i < mapping.Collections.Count; i++) + { + var collection = mapping.Collections[i]; + var helper = new OptimizedCollectionHelper(); + + // Get the actual property info using centralized helper + var propInfo = MappingMetadataExtractor.GetPropertyByName(typeof(T), collection.PropertyName); + if (propInfo is null) + continue; + + var propertyType = propInfo.PropertyType; + var itemType = collection.ItemType ?? typeof(object); + helper.ItemType = itemType; + + // Create simple factory functions using centralized logic + helper.Factory = () => CollectionAccessor.CreateTypedList(itemType); + helper.DefaultItemFactory = CollectionAccessor.CreateItemFactory(itemType); + helper.Finalizer = list => CollectionAccessor.FinalizeCollection(list, propertyType, itemType); + helper.IsArray = propertyType.IsArray; + helper.Setter = collection.Setter; + + // Pre-compute type metadata to avoid runtime reflection + helper.IsItemValueType = itemType.IsValueType; + helper.IsItemPrimitive = itemType.IsPrimitive; + helper.DefaultValue = itemType.IsValueType ? helper.DefaultItemFactory() : null; + + helpers.Add(helper); + + // Pre-compile nested mapping info if it's a complex type + if (collection.Registry is not null && MappingMetadataExtractor.IsComplexType(itemType)) + { + var nestedMapping = collection.Registry.GetCompiledMapping(itemType); + if (nestedMapping is not null) + { + var nestedInfo = MappingMetadataExtractor.ExtractNestedMappingInfo(nestedMapping, itemType); + if (nestedInfo is not null) + { + nestedMappings[i] = nestedInfo; + } + } + } + } + + mapping.OptimizedCollectionHelpers = helpers; + if (nestedMappings.Count > 0) + { + mapping.NestedMappings = nestedMappings; + } + } + + private static void MarkVerticalComplexCollectionCells(OptimizedCellHandler[,] grid, CompiledCollectionMapping collection, + int collectionIndex, OptimizedMappingBoundaries boundaries, int startRow, object nestedMapping, int? nextCollectionStartRow = null) + { + // Extract pre-compiled nested mapping info without reflection + var nestedInfo = MappingMetadataExtractor.ExtractNestedMappingInfo(nestedMapping, collection.ItemType ?? typeof(object)); + if (nestedInfo is null) + return; + + // Now mark cells for each property of each collection item + var maxRows = Math.Min(100, grid.GetLength(0)); // Conservative range + var startRelativeRow = startRow - boundaries.MinRow; + var rowSpacing = collection.RowSpacing; + + // Calculate the maximum number of items we can mark + var maxItems = 20; // Conservative default + if (nextCollectionStartRow.HasValue) + { + // Limit to the rows before the next collection starts + var availableRows = nextCollectionStartRow.Value - startRow; + maxItems = Math.Min(maxItems, Math.Max(0, availableRows / (1 + rowSpacing))); + } + + for (int itemIndex = 0; itemIndex < maxItems; itemIndex++) + { + var r = startRelativeRow + itemIndex * (1 + rowSpacing); + if (r < 0 || r >= maxRows || r >= grid.GetLength(0)) + continue; + + // Additional check: don't go past the next collection's start + var absoluteRow = r + boundaries.MinRow; + if (absoluteRow >= nextCollectionStartRow) + break; + + foreach (var prop in nestedInfo.Properties) + { + var c = prop.ColumnIndex - boundaries.MinColumn; + if (c >= 0 && c < grid.GetLength(1)) + { + if (prop.Setter is null) + throw new InvalidOperationException($"Nested property '{prop.PropertyName}' is missing a setter. Ensure the mapping for '{collection.ItemType?.Name}' is configured correctly."); + + // Only mark if not already occupied + if (grid[r, c].Type == CellHandlerType.Empty) + { + grid[r, c] = new OptimizedCellHandler + { + Type = CellHandlerType.CollectionItem, + ValueExtractor = CreateNestedPropertyExtractor(collection, itemIndex, prop.Getter), + ValueSetter = prop.Setter, + CollectionIndex = collectionIndex, + CollectionItemOffset = itemIndex, + PropertyName = prop.PropertyName, + CollectionMapping = collection, + CollectionItemConverter = null // No conversion needed, property getter handles it + }; + } + } + } + + if (nestedInfo.Collections.Count <= 0) + continue; + + foreach (var nestedCollection in nestedInfo.Collections.Values) + { + if (nestedCollection.Layout != CollectionLayout.Vertical) + continue; + + var nestedMappingInfo = nestedCollection.NestedMapping; + if (nestedMappingInfo is null || nestedMappingInfo.Properties.Count == 0) + continue; + + const int nestedMaxItems = 20; + for (int nestedIndex = 0; nestedIndex < nestedMaxItems; nestedIndex++) + { + var nestedAbsoluteRow = nestedCollection.StartRow + nestedIndex * (1 + nestedCollection.RowSpacing); + // Offset by the parent item index so nested items follow the parent row pattern + nestedAbsoluteRow += itemIndex * (1 + rowSpacing); + if (nestedAbsoluteRow >= nextCollectionStartRow) + break; + + var nestedRelativeRow = nestedAbsoluteRow - boundaries.MinRow; + if (nestedRelativeRow < 0 || nestedRelativeRow >= maxRows || nestedRelativeRow >= grid.GetLength(0)) + continue; + + foreach (var nestedProp in nestedMappingInfo.Properties) + { + if (nestedProp.Setter is null) + throw new InvalidOperationException($"Nested property '{nestedProp.PropertyName}' is missing a setter. Ensure the mapping for '{nestedCollection.ItemType.Name}' is configured correctly."); + + var columnIndex = nestedProp.ColumnIndex - boundaries.MinColumn; + if (columnIndex < 0 || columnIndex >= grid.GetLength(1)) + continue; + + if (grid[nestedRelativeRow, columnIndex].Type != CellHandlerType.Empty) + continue; + + grid[nestedRelativeRow, columnIndex] = new OptimizedCellHandler + { + Type = CellHandlerType.CollectionItem, + ValueExtractor = CreateNestedCollectionPropertyExtractor(collection, itemIndex, nestedCollection, nestedIndex, nestedProp.Getter), + ValueSetter = CreateNestedCollectionPropertySetter(nestedCollection, nestedIndex, nestedProp.Setter), + CollectionIndex = collectionIndex, + CollectionItemOffset = itemIndex, + PropertyName = nestedProp.PropertyName, + CollectionMapping = collection, + CollectionItemConverter = null + }; + } + } + } + } + } + + private static Func CreateNestedPropertyExtractor(CompiledCollectionMapping collection, int offset, Func propertyGetter) + { + var collectionGetter = collection.Getter; + return (obj, _) => + { + var enumerable = collectionGetter(obj); + var item = CollectionAccessor.GetItemAt(enumerable, offset); + + return item is not null ? propertyGetter(item) : null; + }; + } + + private static Func CreateNestedCollectionPropertyExtractor( + CompiledCollectionMapping parentCollection, + int parentOffset, + NestedCollectionInfo nestedCollection, + int nestedOffset, + Func propertyGetter) + { + var parentGetter = parentCollection.Getter; + return (obj, _) => + { + var parents = parentGetter(obj); + var parentItem = CollectionAccessor.GetItemAt(parents, parentOffset); + if (parentItem is null) + return null; + + var nestedEnumerable = nestedCollection.Getter(parentItem); + var nestedItem = CollectionAccessor.GetItemAt(nestedEnumerable, nestedOffset); + + return nestedItem is not null ? propertyGetter(nestedItem) : null; + }; + } + + private static Action CreateNestedCollectionPropertySetter( + NestedCollectionInfo collectionInfo, + int nestedOffset, + Action setter) + { + return (parent, value) => + { + if (parent is null) + return; + + IList list; + var collection = collectionInfo.Getter(parent); + if (collection is IList existingList) + { + list = existingList; + } + else if (collection is not null) + { + list = collectionInfo.ListFactory(); + foreach (var item in collection) + { + list.Add(item); + } + + if (collectionInfo.Setter is null) + throw new InvalidOperationException($"Collection property '{collectionInfo.PropertyName}' must be writable to capture nested values."); + + collectionInfo.Setter(parent, list); + } + else + { + if (collectionInfo.Setter is null) + throw new InvalidOperationException($"Collection property '{collectionInfo.PropertyName}' must be writable to capture nested values."); + + list = collectionInfo.ListFactory(); + collectionInfo.Setter(parent, list); + } + + while (list.Count <= nestedOffset) + { + var newItem = collectionInfo.ItemFactory(); + if (newItem is null) + throw new InvalidOperationException($"Collection item factory returned null for type '{collectionInfo.ItemType}'. Ensure it has an accessible parameterless constructor."); + + list.Add(newItem); + } + + var nestedItem = list[nestedOffset]; + if (nestedItem is null) + { + nestedItem = collectionInfo.ItemFactory(); + list[nestedOffset] = nestedItem ?? throw new InvalidOperationException( + $"Collection item factory returned null for type '{collectionInfo.ItemType}'. Ensure it has an accessible parameterless constructor."); + } + + setter(nestedItem, value); + }; + } + + private static Func CreatePreCompiledItemConverter(Type targetType) + { + return value => ConversionHelper.ConvertValue(value, targetType); + } + + private static int GetMaxColumnIndex(NestedMappingInfo nestedInfo, int currentMax) + { + if (nestedInfo.Properties.Count > 0) + { + currentMax = Math.Max(currentMax, nestedInfo.Properties.Max(p => p.ColumnIndex)); + } + + if (nestedInfo.Collections.Count > 0) + { + foreach (var collectionInfo in nestedInfo.Collections.Values) + { + currentMax = Math.Max(currentMax, collectionInfo.StartColumn); + if (collectionInfo.NestedMapping is not null) + { + currentMax = GetMaxColumnIndex(collectionInfo.NestedMapping, currentMax); + } + } + } + + return currentMax; + } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/FluentMapping/MappingReader.cs b/src/MiniExcel.Core/FluentMapping/MappingReader.cs new file mode 100644 index 00000000..0ed06914 --- /dev/null +++ b/src/MiniExcel.Core/FluentMapping/MappingReader.cs @@ -0,0 +1,472 @@ +namespace MiniExcelLib.Core.FluentMapping; + +internal static partial class MappingReader where T : class, new() +{ + [CreateSyncVersion] + public static async IAsyncEnumerable QueryAsync(Stream stream, CompiledMapping mapping, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (stream is null) + throw new ArgumentNullException(nameof(stream)); + if (mapping is null) + throw new ArgumentNullException(nameof(mapping)); + + await foreach (var item in QueryOptimizedAsync(stream, mapping, cancellationToken).ConfigureAwait(false)) + yield return item; + } + + [CreateSyncVersion] + private static async IAsyncEnumerable QueryOptimizedAsync(Stream stream, CompiledMapping mapping, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (mapping.OptimizedCellGrid is null || mapping.OptimizedBoundaries is null) + throw new InvalidOperationException("QueryOptimizedAsync requires an optimized mapping"); + + var boundaries = mapping.OptimizedBoundaries!; + var cellGrid = mapping.OptimizedCellGrid!; + + // Read the Excel file using OpenXmlReader's direct mapping path + using var reader = await OpenXmlReader.CreateAsync(stream, new OpenXmlConfiguration + { + FillMergedCells = false, + FastMode = false + }, cancellationToken).ConfigureAwait(false); + + // If we have collections, we need to handle multiple items with collections + if (mapping.Collections.Any()) + { + // Check if this is a multi-item pattern + bool isMultiItemPattern = boundaries is { IsMultiItemPattern: true, PatternHeight: > 0 }; + + T? currentItem = null; + Dictionary? currentCollections = null; + var currentItemIndex = -1; + + await foreach (var mappedRow in reader.QueryMappedAsync(mapping.WorksheetName, cancellationToken).ConfigureAwait(false)) + { + var currentRowIndex = mappedRow.RowIndex + 1; + + // Use our own row counter since OpenXmlReader doesn't provide row numbers + int rowNumber = currentRowIndex; + if (rowNumber < boundaries.MinRow) + continue; + + // Calculate which item this row belongs to based on the pattern + var relativeRow = rowNumber - boundaries.MinRow; + int itemIndex = 0; + int gridRow = relativeRow; + + if (isMultiItemPattern && boundaries.PatternHeight > 0) + { + // Pre-calculated: which item does this row belong to? + itemIndex = relativeRow / boundaries.PatternHeight; + gridRow = relativeRow % boundaries.PatternHeight; + } + + // Check if we're starting a new item + if (itemIndex != currentItemIndex) + { + // Save the previous item if we have one + if (currentItem is not null && currentCollections is not null) + { + FinalizeCollections(currentItem, mapping, currentCollections); + if (HasAnyData(currentItem, mapping)) + { + yield return currentItem; + } + } + + // Start the new item + currentItem = new T(); + currentCollections = InitializeCollections(mapping); + currentItemIndex = itemIndex; + } + + // If we don't have a current item yet, skip this row + if (currentItem is null) + continue; + + if (gridRow < 0 || gridRow >= cellGrid.GetLength(0)) + continue; + + // Process each cell in the row using the pre-calculated grid + for (int col = boundaries.MinColumn; col <= boundaries.MaxColumn; col++) + { + var cellValue = mappedRow.GetCell(col - 1); // Convert to 0-based for MappedRow + + if (mapping.TryGetHandler(rowNumber, col, out var handler)) + { + ProcessCellValue(handler, cellValue, currentItem, currentCollections, mapping); + } + } + } + + // Finalize the last item if we have one + if (currentItem is null || currentCollections is null) + yield break; + + FinalizeCollections(currentItem, mapping, currentCollections); + if (HasAnyData(currentItem, mapping)) + { + yield return currentItem; + } + } + else + { + // Check if this is a column layout (properties in same column, different rows) + // Column layout has GridHeight > 1 and all properties in same column + bool isColumnLayout = boundaries.GridHeight > 1; + + if (isColumnLayout) + { + // Column layout mode - all rows form a single object + var item = new T(); + + await foreach (var mappedRow in reader.QueryMappedAsync(mapping.WorksheetName, cancellationToken).ConfigureAwait(false)) + { + var currentRowIndex = mappedRow.RowIndex + 1; + + int rowNumber = currentRowIndex; + + // Process properties for this row + foreach (var prop in mapping.Properties) + { + if (prop.CellRow == rowNumber) + { + var cellValue = mappedRow.GetCell(prop.CellColumn - 1); // Convert to 0-based + + if (cellValue is not null) + { + // Trust the precompiled setter to handle conversion + mapping.TrySetPropertyValue(prop, item, cellValue); + } + } + } + } + + if (HasAnyData(item, mapping)) + { + yield return item; + } + } + else + { + // Row layout mode - each row is a separate item + await foreach (var mappedRow in reader.QueryMappedAsync(mapping.WorksheetName, cancellationToken).ConfigureAwait(false)) + { + // Use our own row counter since OpenXmlReader doesn't provide row numbers + var currentRowIndex = mappedRow.RowIndex + 1; + if (currentRowIndex < boundaries.MinRow) + continue; + + var item = new T(); + + // Process properties for this row + // Check if this is a table pattern (all properties on row 1) + var allOnRow1 = mapping.Properties.All(p => p.CellRow == 1); + + foreach (var prop in mapping.Properties) + { + // For table pattern (all on row 1), properties define columns + // For cell-specific mapping, only read from the specific row + if (!allOnRow1 && prop.CellRow != currentRowIndex) + continue; + + var cellValue = mappedRow.GetCell(prop.CellColumn - 1); // Convert to 0-based + if (cellValue is not null) + { + // Trust the precompiled setter to handle conversion + prop.Setter?.Invoke(item, cellValue); + } + } + + if (HasAnyData(item, mapping)) + { + yield return item; + } + } + } + } + } + + private static Dictionary InitializeCollections(CompiledMapping mapping) + { + var collections = new Dictionary(); + + // Use precompiled collection helpers if available + if (mapping.OptimizedCollectionHelpers is not null) + { + for (int i = 0; i < mapping.OptimizedCollectionHelpers.Count && i < mapping.Collections.Count; i++) + { + var helper = mapping.OptimizedCollectionHelpers[i]; + collections[i] = helper.Factory(); + } + } + else + { + // This should never happen with properly optimized mappings + throw new InvalidOperationException( + "OptimizedCollectionHelpers is null. Ensure the mapping was properly compiled and optimized."); + } + + return collections; + } + + private static void ProcessCellValue(OptimizedCellHandler handler, object? value, T item, + Dictionary? collections, CompiledMapping mapping) + { + // Skip empty handlers + if (handler.Type == CellHandlerType.Empty) + return; + + switch (handler.Type) + { + case CellHandlerType.Property: + // Direct property - use pre-compiled setter + mapping.TrySetValue(handler, item, value); + break; + + case CellHandlerType.CollectionItem: + if (handler.CollectionIndex >= 0 + && collections is not null + && collections.TryGetValue(handler.CollectionIndex, out var collection)) + { + var collectionMapping = handler.CollectionMapping!; + var itemType = collectionMapping.ItemType ?? typeof(object); + + // Check if this is a complex type with nested properties + var nestedMapping = collectionMapping.Registry?.GetCompiledMapping(itemType); + + // Use pre-compiled type metadata from the helper instead of runtime reflection + var typeHelper = mapping.OptimizedCollectionHelpers?[handler.CollectionIndex]; + + if (nestedMapping is not null && + itemType != typeof(string) && + typeHelper is { IsItemValueType: false, IsItemPrimitive: false }) + { + // Complex type - we need to build/update the object + ProcessComplexCollectionItem(collection, handler, value, mapping); + } + else + { + // Simple type - add directly + while (collection.Count <= handler.CollectionItemOffset) + { + // Use precompiled default factory if available + object? defaultValue; + if (mapping.OptimizedCollectionHelpers is not null && + handler.CollectionIndex >= 0 && + handler.CollectionIndex < mapping.OptimizedCollectionHelpers.Count) + { + var helper = mapping.OptimizedCollectionHelpers[handler.CollectionIndex]; + defaultValue = helper.DefaultItemFactory.Invoke(); + } + else + { + // This should never happen with properly optimized mappings + throw new InvalidOperationException( + $"No OptimizedCollectionHelper found for collection at index {handler.CollectionIndex}. " + + "Ensure the mapping was properly compiled and optimized."); + } + collection.Add(defaultValue); + } + + // Skip empty values for value type collections + if (value is string str && string.IsNullOrEmpty(str)) + { + // Don't add empty values to value type collections + // Use pre-compiled type metadata from the helper + var itemHelper = mapping.OptimizedCollectionHelpers?[handler.CollectionIndex]; + if (itemHelper is { IsItemValueType: false }) + { + // Only set null if the collection has the item already + if (handler.CollectionItemOffset < collection.Count) + { + collection[handler.CollectionItemOffset] = null; + } + } + // For value types, we just skip - the default value is already there + } + else + { + // Use pre-compiled converter if available + var convertedValue = handler.CollectionItemConverter is not null + ? handler.CollectionItemConverter(value) + : value; + + collection[handler.CollectionItemOffset] = convertedValue; + } + } + } + break; + } + } + + private static void ProcessComplexCollectionItem(IList collection, OptimizedCellHandler handler, object? value, CompiledMapping mapping) + { + if (collection.Count <= handler.CollectionItemOffset && !HasMeaningfulValue(value)) + return; + + // Ensure the collection has enough items + while (collection.Count <= handler.CollectionItemOffset) + { + // Use precompiled default factory + if (mapping.OptimizedCollectionHelpers is null || + handler.CollectionIndex < 0 || + handler.CollectionIndex >= mapping.OptimizedCollectionHelpers.Count) + { + throw new InvalidOperationException( + $"No OptimizedCollectionHelper found for collection at index {handler.CollectionIndex}. " + + "Ensure the mapping was properly compiled and optimized."); + } + + var helper = mapping.OptimizedCollectionHelpers[handler.CollectionIndex]; + var newItem = helper.DefaultItemFactory.Invoke(); + collection.Add(newItem); + } + + var item = collection[handler.CollectionItemOffset]; + if (item is null) + { + if (mapping.OptimizedCollectionHelpers is null || + handler.CollectionIndex < 0 || + handler.CollectionIndex >= mapping.OptimizedCollectionHelpers.Count) + { + throw new InvalidOperationException( + $"No OptimizedCollectionHelper found for collection at index {handler.CollectionIndex}. " + + "Ensure the mapping was properly compiled and optimized."); + } + + var helper = mapping.OptimizedCollectionHelpers[handler.CollectionIndex]; + item = helper.DefaultItemFactory.Invoke(); + + collection[handler.CollectionItemOffset] = item ?? throw new InvalidOperationException( + $"Collection item factory returned null for type '{helper.ItemType}'. Ensure it has an accessible parameterless constructor."); + } + + // Try to set the value using the handler + if (!mapping.TrySetValue(handler, item, value)) + { + // For nested mappings, we need to look up the pre-compiled setter + if (mapping.NestedMappings?.TryGetValue(handler.CollectionIndex, out var nestedInfo) is true) + { + // Find the matching property setter in the nested mapping + var nestedProp = nestedInfo.Properties.FirstOrDefault(p => p.PropertyName == handler.PropertyName); + if (nestedProp?.Setter is not null) + { + handler.ValueSetter = nestedProp.Setter; + nestedProp.Setter(item, value); + return; + } + } + + throw new InvalidOperationException( + $"ValueSetter is null for complex collection item handler at property '{handler.PropertyName}'. " + + "This indicates the mapping was not properly optimized. Ensure the type was mapped in the MappingRegistry."); + } + } + + private static bool HasMeaningfulValue(object? value) => value switch + { + null => false, + string str => !string.IsNullOrWhiteSpace(str), + _ => true + }; + + private static void FinalizeCollections(T item, CompiledMapping mapping, Dictionary collections) + { + for (int i = 0; i < mapping.Collections.Count; i++) + { + var collectionMapping = mapping.Collections[i]; + if (!collections.TryGetValue(i, out var list)) + continue; + + // Get the default value using precompiled factory if available + object? defaultValue = null; + if (mapping.OptimizedCollectionHelpers is not null && i < mapping.OptimizedCollectionHelpers.Count) + { + var helper = mapping.OptimizedCollectionHelpers[i]; + // Use pre-compiled type metadata instead of runtime check + if (helper.IsItemValueType) + { + defaultValue = helper.DefaultValue ?? helper.DefaultItemFactory.Invoke(); + } + } + else + { + // This should never happen with properly optimized mappings + throw new InvalidOperationException( + $"No OptimizedCollectionHelper found for collection at index {i}. " + + "Ensure the mapping was properly compiled and optimized."); + } + + while (list.Count > 0) + { + var lastItem = list[^1]; + // Use pre-compiled type metadata from helper + var listHelper = mapping.OptimizedCollectionHelpers?[i]; + bool isDefault = lastItem is null || + (lastItem.Equals(defaultValue) && listHelper is { IsItemValueType: true }); + if (isDefault) + { + list.RemoveAt(list.Count - 1); + } + else + { + break; // Stop when we find a non-default value + } + } + + // Convert to final type if needed + object finalValue = list; + + if (collectionMapping.Setter is null) + continue; + + // Use precompiled collection helper to convert to final type + if (mapping.OptimizedCollectionHelpers is not null && i < mapping.OptimizedCollectionHelpers.Count) + { + var helper = mapping.OptimizedCollectionHelpers[i]; + finalValue = helper.Finalizer(list); + } + + mapping.TrySetCollectionValue(collectionMapping, item, finalValue); + } + } + + + private static bool HasAnyData(T item, CompiledMapping mapping) + { + // Check if any properties have non-default values + var values = mapping.Properties.Select(prop => prop.Getter(item)); + if (values.Any(v => !IsDefaultValue(v))) + { + return true; + } + + // Check if any collections have items + foreach (var collMap in mapping.Collections) + { + var collection = collMap.Getter(item); + var enumerator = collection.GetEnumerator(); + using var disposableEnumerator = enumerator as IDisposable; + if (enumerator.MoveNext()) + { + return true; + } + } + + return false; + } + + private static bool IsDefaultValue(object value) => value switch + { + string s => string.IsNullOrEmpty(s), + DateTime dt => dt == default, + int i => i == 0, + long l => l == 0L, + decimal m => m == 0M, + double d => d == 0D, + float f => f == 0F, + bool b => !b, + _ => false + }; +} \ No newline at end of file diff --git a/src/MiniExcel.Core/FluentMapping/MappingRegistry.cs b/src/MiniExcel.Core/FluentMapping/MappingRegistry.cs new file mode 100644 index 00000000..90893f3a --- /dev/null +++ b/src/MiniExcel.Core/FluentMapping/MappingRegistry.cs @@ -0,0 +1,103 @@ +using MiniExcelLib.Core.FluentMapping.Configuration; + +namespace MiniExcelLib.Core.FluentMapping; + +public sealed class MappingRegistry +{ + private readonly Dictionary _compiledMappings = new(); + +#if NET9_0_OR_GREATER + private readonly Lock _lock = new(); +#else + private readonly object _lock = new(); +#endif + + public void Configure(Action>? configure) + { + if (configure is null) + throw new ArgumentNullException(nameof(configure)); + + lock (_lock) + { + var config = new MappingConfiguration(); + configure(config); + + CompileNestedMappings(config); + + var compiledMapping = MappingCompiler.Compile(config, this); + _compiledMappings[typeof(T)] = compiledMapping; + } + } + + internal CompiledMapping GetMapping() + { + lock (_lock) + { + return _compiledMappings.TryGetValue(typeof(T), out var mapping) + ? (CompiledMapping)mapping + : throw new InvalidOperationException($"No mapping configured for type {typeof(T).Name}. Call Configure<{typeof(T).Name}>() first."); + } + } + + public bool HasMapping() + { + lock (_lock) + { + return _compiledMappings.ContainsKey(typeof(T)); + } + } + + internal CompiledMapping? GetCompiledMapping() + { + lock (_lock) + { + return _compiledMappings.TryGetValue(typeof(T), out var mapping) + ? (CompiledMapping)mapping + : null; + } + } + + internal object? GetCompiledMapping(Type type) + { + lock (_lock) + { + return _compiledMappings.TryGetValue(type, out var mapping) + ? mapping + : null; + } + } + + private void CompileNestedMappings(MappingConfiguration mappingConfiguration) + { + foreach (var collection in mappingConfiguration.CollectionMappings) + { + if (collection is { ItemConfiguration: { } configuration, ItemType: { } type }) + { + CompileNestedMappingInternal(type, configuration); + } + } + } + + private void CompileNestedMappingInternal(Type itemType, object itemConfiguration) + { + var method = typeof(MappingRegistry) + .GetMethod(nameof(CompileNestedMapping), BindingFlags.Instance | BindingFlags.NonPublic)? + .MakeGenericMethod(itemType); + + method?.Invoke(this, [itemConfiguration]); + } + + private void CompileNestedMapping(MappingConfiguration configuration) + { + CompileNestedMappings(configuration); + + lock (_lock) + { + if (_compiledMappings.ContainsKey(typeof(TItem))) + return; + + var compiled = MappingCompiler.Compile(configuration, this); + _compiledMappings[typeof(TItem)] = compiled; + } + } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/FluentMapping/MappingTemplateApplicator.cs b/src/MiniExcel.Core/FluentMapping/MappingTemplateApplicator.cs new file mode 100644 index 00000000..ef165eaf --- /dev/null +++ b/src/MiniExcel.Core/FluentMapping/MappingTemplateApplicator.cs @@ -0,0 +1,156 @@ +namespace MiniExcelLib.Core.FluentMapping; + +internal static partial class MappingTemplateApplicator where T : class +{ + [CreateSyncVersion] + public static async Task ApplyTemplateAsync( + Stream outputStream, + Stream templateStream, + IEnumerable values, + CompiledMapping mapping, + CancellationToken cancellationToken = default) + { + if (outputStream is null) + throw new ArgumentNullException(nameof(outputStream)); + if (templateStream is null) + throw new ArgumentNullException(nameof(templateStream)); + if (values is null) + throw new ArgumentNullException(nameof(values)); + if (mapping is null) + throw new ArgumentNullException(nameof(mapping)); + + // Ensure we can seek the template stream + if (!templateStream.CanSeek) + { + // Copy to memory stream if not seekable + var memStream = new MemoryStream(); +#if NETCOREAPP2_1_OR_GREATER + await templateStream.CopyToAsync(memStream, cancellationToken).ConfigureAwait(false); +#else + await templateStream.CopyToAsync(memStream).ConfigureAwait(false); +#endif + memStream.Position = 0; + templateStream = memStream; + } + + templateStream.Position = 0; + + // Open template archive for reading + using var templateArchive = new ZipArchive(templateStream, ZipArchiveMode.Read, leaveOpen: true); + + // Create output archive + using var outputArchive = new ZipArchive(outputStream, ZipArchiveMode.Create, leaveOpen: true); + + // Process each entry + foreach (var entry in templateArchive.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (IsWorksheetEntry(entry.FullName)) + { + // Get worksheet name from path (e.g., "xl/worksheets/sheet1.xml" -> "sheet1") + var worksheetName = GetWorksheetName(entry.FullName); + + // Check if this worksheet matches the mapping's worksheet + if (mapping.WorksheetName is null || + string.Equals(worksheetName, mapping.WorksheetName, StringComparison.OrdinalIgnoreCase) || + (mapping.WorksheetName == "Sheet1" && worksheetName == "sheet1")) + { + // Process this worksheet with mapping + await ProcessWorksheetAsync( + entry, + outputArchive, + values, + mapping, + cancellationToken).ConfigureAwait(false); + } + else + { + // Copy worksheet as-is + await CopyEntryAsync(entry, outputArchive, cancellationToken).ConfigureAwait(false); + } + } + else + { + // Copy non-worksheet files as-is + await CopyEntryAsync(entry, outputArchive, cancellationToken).ConfigureAwait(false); + } + } + } + + private static bool IsWorksheetEntry(string fullName) + { + return fullName.StartsWith("xl/worksheets/sheet", StringComparison.OrdinalIgnoreCase) && + fullName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase); + } + + private static string GetWorksheetName(string fullName) + { + // Extract "sheet1" from "xl/worksheets/sheet1.xml" + var fileName = Path.GetFileNameWithoutExtension(fullName); + return fileName; + } + + [CreateSyncVersion] + private static async Task CopyEntryAsync( + ZipArchiveEntry sourceEntry, + ZipArchive targetArchive, + CancellationToken cancellationToken) + { + var targetEntry = targetArchive.CreateEntry(sourceEntry.FullName, CompressionLevel.Fastest); + + // Copy metadata + targetEntry.LastWriteTime = sourceEntry.LastWriteTime; + + // Copy content +#if NET10_0_OR_GREATER + using var sourceStream = await sourceEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + using var targetStream = await targetEntry.OpenAsync(cancellationToken).ConfigureAwait(false); +#else + using var sourceStream = sourceEntry.Open(); + using var targetStream = targetEntry.Open(); +#endif + +#if NETCOREAPP2_1_OR_GREATER + await sourceStream.CopyToAsync(targetStream, cancellationToken).ConfigureAwait(false); +#else + await sourceStream.CopyToAsync(targetStream).ConfigureAwait(false); +#endif + } + + [CreateSyncVersion] + private static async Task ProcessWorksheetAsync( + ZipArchiveEntry sourceEntry, + ZipArchive targetArchive, + IEnumerable values, + CompiledMapping mapping, + CancellationToken cancellationToken) + { + var targetEntry = targetArchive.CreateEntry(sourceEntry.FullName, CompressionLevel.Fastest); + + // Copy metadata + targetEntry.LastWriteTime = sourceEntry.LastWriteTime; + + // Open streams +#if NET10_0_OR_GREATER + using var sourceStream = await sourceEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + using var targetStream = await targetEntry.OpenAsync(cancellationToken).ConfigureAwait(false); +#else + using var sourceStream = sourceEntry.Open(); + using var targetStream = targetEntry.Open(); +#endif + + // Create processor for this worksheet + var processor = new MappingTemplateProcessor(mapping); + + // Use enumerator for values + using var enumerator = values.GetEnumerator(); + + // Process the worksheet + await processor.ProcessSheetAsync( + sourceStream, + targetStream, + enumerator, + cancellationToken).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/FluentMapping/MappingTemplateProcessor.cs b/src/MiniExcel.Core/FluentMapping/MappingTemplateProcessor.cs new file mode 100644 index 00000000..dd21c705 --- /dev/null +++ b/src/MiniExcel.Core/FluentMapping/MappingTemplateProcessor.cs @@ -0,0 +1,448 @@ +namespace MiniExcelLib.Core.FluentMapping; + +internal partial struct MappingTemplateProcessor(CompiledMapping mapping) where T : class +{ + [CreateSyncVersion] + public async Task ProcessSheetAsync( + Stream sourceStream, + Stream targetStream, + IEnumerator dataEnumerator, + CancellationToken cancellationToken) + { + var readerSettings = new XmlReaderSettings + { + Async = true, + IgnoreWhitespace = false, + IgnoreComments = false, + CheckCharacters = false + }; + + var writerSettings = new XmlWriterSettings + { + Async = true, + Indent = false, + OmitXmlDeclaration = false, + Encoding = Encoding.UTF8 + }; + + using var reader = XmlReader.Create(sourceStream, readerSettings); + using var writer = XmlWriter.Create(targetStream, writerSettings); + + // Get first data item + var currentItem = dataEnumerator.MoveNext() ? dataEnumerator.Current : null; + var currentItemIndex = currentItem is not null ? 0 : -1; + + + // Track which rows have been written from the template + var writtenRows = new HashSet(); + + // Process the XML stream + while (await reader.ReadAsync().ConfigureAwait(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + + switch (reader.NodeType) + { + case XmlNodeType.XmlDeclaration: + await writer.WriteStartDocumentAsync().ConfigureAwait(false); + break; + + case XmlNodeType.Element: + if (reader.LocalName == "row") + { + var rowNumber = GetRowNumber(reader); + writtenRows.Add(rowNumber); + + // Check if we need to advance to next item + if (mapping.OptimizedBoundaries is { IsMultiItemPattern: true, PatternHeight: > 0 }) + { + var relativeRow = rowNumber - mapping.OptimizedBoundaries.MinRow; + var itemIndex = relativeRow / mapping.OptimizedBoundaries.PatternHeight; + + if (itemIndex > currentItemIndex) + { + // Advance to next item + currentItem = dataEnumerator.MoveNext() ? dataEnumerator.Current : null; + currentItemIndex = itemIndex; + } + } + + // Process the row + await ProcessRowAsync(reader, writer, rowNumber, currentItem).ConfigureAwait(false); + } + else if (reader.LocalName is "worksheet" or "sheetData") + { + // For worksheet and sheetData elements, we need to process their content manually + // Copy start tag with attributes + await writer.WriteStartElementAsync(reader.Prefix, reader.LocalName, reader.NamespaceURI).ConfigureAwait(false); + + if (reader.HasAttributes) + { + while (reader.MoveToNextAttribute()) + { + await writer.WriteAttributeStringAsync( + reader.Prefix, + reader.LocalName, + reader.NamespaceURI, + reader.Value).ConfigureAwait(false); + } + reader.MoveToElement(); + } + + // Don't call CopyElementAsync as it will consume all content + // Just continue processing in the main loop + } + else + { + // Copy element as-is + await CopyElementAsync(reader, writer).ConfigureAwait(false); + } + break; + + case XmlNodeType.EndElement: + if (reader.LocalName == "sheetData") + { + // Before closing sheetData, write any missing rows that have mappings + await WriteMissingRowsAsync(writer, currentItem, writtenRows).ConfigureAwait(false); + } + await writer.WriteEndElementAsync().ConfigureAwait(false); + break; + + default: + // Copy node as-is + await CopyNodeAsync(reader, writer).ConfigureAwait(false); + break; + } + } + + await writer.FlushAsync().ConfigureAwait(false); + } + + private static int GetRowNumber(XmlReader reader) + { + var rowAttr = reader.GetAttribute("r"); + if (!string.IsNullOrEmpty(rowAttr) && int.TryParse(rowAttr, out var rowNum)) + { + return rowNum; + } + return 0; + } + + [CreateSyncVersion] + private async Task ProcessRowAsync( + XmlReader reader, + XmlWriter writer, + int rowNumber, + T? currentItem) + { + // Write row start tag with all attributes + await writer.WriteStartElementAsync(reader.Prefix, "row", reader.NamespaceURI).ConfigureAwait(false); + + // Copy all row attributes + if (reader.HasAttributes) + { + while (reader.MoveToNextAttribute()) + { + await writer.WriteAttributeStringAsync( + reader.Prefix, + reader.LocalName, + reader.NamespaceURI, + reader.Value).ConfigureAwait(false); + } + reader.MoveToElement(); + } + + // Track which columns have been written + var writtenColumns = new HashSet(); + + // Read row content + var isEmpty = reader.IsEmptyElement; + if (!isEmpty) + { + // Process cells in the row + while (await reader.ReadAsync().ConfigureAwait(false)) + { + if (reader is { NodeType: XmlNodeType.Element, LocalName: "c" }) + { + // Get cell reference + var cellRef = reader.GetAttribute("r"); + + if (!string.IsNullOrEmpty(cellRef)) + { + // Parse cell reference to get column and row + if (ReferenceHelper.TryParseCellReference(cellRef, out var col, out var row)) + { + // Track that we've written this column + writtenColumns.Add(col); + + bool cellHandled = false; + + // Check if we have a handler for this cell + if (mapping.TryGetHandler(row, col, out var handler)) + { + // Use the pre-calculated handler to extract the value + if (mapping.TryGetValue(handler, currentItem, out var value)) + { + // Special handling for collection items + if (handler.Type == CellHandlerType.CollectionItem && value is null) + { + // IMPORTANT: If collection item is null (beyond collection bounds), + // preserve template content instead of overwriting with null + // Skip this cell to preserve template content + } + else + { + // Write the mapped value using centralized helper + await XmlCellWriter.WriteMappedCellAsync(reader, writer, value).ConfigureAwait(false); + cellHandled = true; + } + } + else if (handler.Type == CellHandlerType.Property) + { + // Property with no value - write null using centralized helper + await XmlCellWriter.WriteMappedCellAsync(reader, writer, null).ConfigureAwait(false); + cellHandled = true; + } + } + + if (!cellHandled) + { + // Cell not in grid - just copy as-is from template + await CopyElementAsync(reader, writer).ConfigureAwait(false); + } + } + else + { + // Copy cell as-is if we can't parse the reference + await CopyElementAsync(reader, writer).ConfigureAwait(false); + } + } + else + { + // No cell reference, copy as-is + await CopyElementAsync(reader, writer).ConfigureAwait(false); + } + } + else if (reader is { NodeType: XmlNodeType.EndElement, LocalName: "row" }) + { + break; + } + else + { + await CopyNodeAsync(reader, writer).ConfigureAwait(false); + } + } + } + + // After processing existing cells, check for missing mapped cells in this row + await WriteMissingCellsAsync(writer, rowNumber, writtenColumns, currentItem).ConfigureAwait(false); + + await writer.WriteEndElementAsync().ConfigureAwait(false); + } + + [CreateSyncVersion] + private async Task WriteMissingRowsAsync( + XmlWriter writer, + T? currentItem, + HashSet writtenRows) + { + // Check if we have an optimized grid with mappings + if (mapping.OptimizedCellGrid is null || mapping.OptimizedBoundaries is null) + return; + + + // Check each row in the grid to see if it has mappings but wasn't written + for (int relRow = 0; relRow < mapping.OptimizedBoundaries.GridHeight; relRow++) + { + var actualRow = relRow + mapping.OptimizedBoundaries.MinRow; + + // Skip if this row was already written from the template + if (writtenRows.Contains(actualRow)) + continue; + + // Check if this row has any mapped cells with actual values + bool hasMapping = false; + bool hasValue = false; + for (int relCol = 0; relCol < mapping.OptimizedBoundaries.GridWidth; relCol++) + { + var actualCol = relCol + mapping.OptimizedBoundaries.MinColumn; + if (mapping.TryGetHandler(actualRow, actualCol, out var handler)) + { + hasMapping = true; + // Check if there's an actual value to write + if (mapping.TryGetValue(handler, currentItem, out var value) && value is not null) + { + hasValue = true; + break; + } + } + } + + if (hasMapping && hasValue) + { + // Write this missing row + await WriteNewRowAsync(writer, actualRow, currentItem).ConfigureAwait(false); + } + } + } + + [CreateSyncVersion] + private async Task WriteNewRowAsync( + XmlWriter writer, + int rowNumber, + T? currentItem) + { + // Write row element + await writer.WriteStartElementAsync("", "row", "").ConfigureAwait(false); + await writer.WriteAttributeStringAsync("", "r", "", rowNumber.ToString()).ConfigureAwait(false); + + // Check each column in this row for mapped cells + if (mapping.OptimizedBoundaries is not null) + { + for (int col = mapping.OptimizedBoundaries.MinColumn; col <= mapping.OptimizedBoundaries.MaxColumn; col++) + { + // Check if we have a handler for this cell + if (mapping.TryGetHandler(rowNumber, col, out var handler)) + { + // Try to get the value + if (mapping.TryGetValue(handler, currentItem, out var value) && value is not null) + { + var cellRef = ReferenceHelper.ConvertCoordinatesToCell(col, rowNumber); + await XmlCellWriter.WriteNewCellAsync(writer, cellRef, value).ConfigureAwait(false); + } + } + } + } + + await writer.WriteEndElementAsync().ConfigureAwait(false); // + } + + [CreateSyncVersion] + private async Task WriteMissingCellsAsync( + XmlWriter writer, + int rowNumber, + HashSet writtenColumns, + T? currentItem) + { + + // Check if we have an optimized grid with mappings for this row + if (mapping.OptimizedBoundaries is not null) + { + // Check each column in the grid for this row + for (int col = mapping.OptimizedBoundaries.MinColumn; col <= mapping.OptimizedBoundaries.MaxColumn; col++) + { + // Skip if we already wrote this column + if (writtenColumns.Contains(col)) + continue; + + // Check if we have a handler for this cell + if (mapping.TryGetHandler(rowNumber, col, out var handler)) + { + // We have a mapping for this cell but it wasn't in the template + // Try to get the value + if (mapping.TryGetValue(handler, currentItem, out var value) && value is not null) + { + // Create cell reference + var cellRef = ReferenceHelper.ConvertCoordinatesToCell(col, rowNumber); + + // Write the cell using centralized helper + await XmlCellWriter.WriteNewCellAsync(writer, cellRef, value).ConfigureAwait(false); + } + } + } + } + } + + + + + [CreateSyncVersion] + private static async Task CopyElementAsync(XmlReader reader, XmlWriter writer) + { + // Write start element + await writer.WriteStartElementAsync(reader.Prefix, reader.LocalName, reader.NamespaceURI).ConfigureAwait(false); + + // Copy attributes + if (reader.HasAttributes) + { + while (reader.MoveToNextAttribute()) + { + await writer.WriteAttributeStringAsync( + reader.Prefix, + reader.LocalName, + reader.NamespaceURI, + reader.Value).ConfigureAwait(false); + } + reader.MoveToElement(); + } + + // If empty element, we're done + if (reader.IsEmptyElement) + { + await writer.WriteEndElementAsync().ConfigureAwait(false); + return; + } + + // Copy content + var depth = reader.Depth; + while (await reader.ReadAsync().ConfigureAwait(false)) + { + if (reader.NodeType == XmlNodeType.EndElement && reader.Depth == depth) + { + await writer.WriteEndElementAsync().ConfigureAwait(false); + break; + } + + await CopyNodeAsync(reader, writer).ConfigureAwait(false); + } + } + + [CreateSyncVersion] + private static async Task CopyNodeAsync(XmlReader reader, XmlWriter writer) + { + switch (reader.NodeType) + { + case XmlNodeType.Element: + await CopyElementAsync(reader, writer).ConfigureAwait(false); + break; + + case XmlNodeType.Text: + await writer.WriteStringAsync(reader.Value).ConfigureAwait(false); + break; + + case XmlNodeType.Whitespace: + case XmlNodeType.SignificantWhitespace: + await writer.WriteWhitespaceAsync(reader.Value).ConfigureAwait(false); + break; + + case XmlNodeType.CDATA: + await writer.WriteCDataAsync(reader.Value).ConfigureAwait(false); + break; + + case XmlNodeType.Comment: + await writer.WriteCommentAsync(reader.Value).ConfigureAwait(false); + break; + + case XmlNodeType.ProcessingInstruction: + await writer.WriteProcessingInstructionAsync(reader.Name, reader.Value).ConfigureAwait(false); + break; + + case XmlNodeType.EntityReference: + await writer.WriteEntityRefAsync(reader.Name).ConfigureAwait(false); + break; + + case XmlNodeType.XmlDeclaration: + // Write the XML declaration properly + await writer.WriteStartDocumentAsync().ConfigureAwait(false); + break; + + case XmlNodeType.DocumentType: + await writer.WriteRawAsync(reader.Value).ConfigureAwait(false); + break; + + case XmlNodeType.EndElement: + await writer.WriteEndElementAsync().ConfigureAwait(false); + break; + } + } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/FluentMapping/MappingWriter.cs b/src/MiniExcel.Core/FluentMapping/MappingWriter.cs new file mode 100644 index 00000000..656ddb89 --- /dev/null +++ b/src/MiniExcel.Core/FluentMapping/MappingWriter.cs @@ -0,0 +1,46 @@ +namespace MiniExcelLib.Core.FluentMapping; + +internal static partial class MappingWriter + where T : class +{ + [CreateSyncVersion] + public static async Task SaveAsAsync(Stream stream, IEnumerable value, CompiledMapping mapping, CancellationToken cancellationToken = default) + { + if (stream is null) + throw new ArgumentNullException(nameof(stream)); + if (value is null) + throw new ArgumentNullException(nameof(value)); + if (mapping is null) + throw new ArgumentNullException(nameof(mapping)); + + return await SaveAsOptimizedAsync(stream, value, mapping, cancellationToken).ConfigureAwait(false); + } + + [CreateSyncVersion] + private static async Task SaveAsOptimizedAsync(Stream stream, IEnumerable value, CompiledMapping mapping, CancellationToken cancellationToken = default) + { + if (mapping.OptimizedCellGrid is null || mapping.OptimizedBoundaries is null) + throw new InvalidOperationException("SaveAsOptimizedAsync requires an optimized mapping"); + + var configuration = new OpenXmlConfiguration { FastMode = false }; + + // Pre-calculate column letters once for all cells + var boundaries = mapping.OptimizedBoundaries; + var columnLetters = new string[boundaries.MaxColumn - boundaries.MinColumn + 1]; + for (int i = 0; i < columnLetters.Length; i++) + { + var cellRef = ReferenceHelper.ConvertCoordinatesToCell(boundaries.MinColumn + i, 1); + columnLetters[i] = ReferenceHelper.GetCellLetter(cellRef); + } + + // Create cell stream instead of dictionary rows + var cellStream = new MappingCellStream(value, mapping, columnLetters); + + // Use the cell stream directly - it will be handled by the adapter + var writer = await OpenXmlWriter + .CreateAsync(stream, cellStream, mapping.WorksheetName, false, configuration, cancellationToken) + .ConfigureAwait(false); + + return await writer.SaveAsAsync(null, cancellationToken).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/FluentMapping/NestedMappingInfo.cs b/src/MiniExcel.Core/FluentMapping/NestedMappingInfo.cs new file mode 100644 index 00000000..3d701b4f --- /dev/null +++ b/src/MiniExcel.Core/FluentMapping/NestedMappingInfo.cs @@ -0,0 +1,77 @@ +namespace MiniExcelLib.Core.FluentMapping; + +/// +/// Stores pre-compiled information about nested properties in collection mappings. +/// This eliminates the need for runtime reflection when processing complex collection types. +/// +internal class NestedMappingInfo +{ + /// + /// Pre-compiled property accessors for the nested type. + /// + public IReadOnlyList Properties { get; set; } = new List(); + + /// + /// Pre-compiled nested collection accessors keyed by property name. + /// + public IReadOnlyDictionary Collections { get; set; } = new Dictionary(); + + /// + /// The type of items in the collection. + /// + public Type ItemType { get; set; } = null!; + + /// + /// Pre-compiled factory for creating instances of the item type. + /// + public Func ItemFactory { get; set; } = null!; +} + +/// +/// Pre-compiled information about a single property in a nested type. +/// +internal class NestedPropertyInfo +{ + /// + /// The name of the property. + /// + public string PropertyName { get; set; } = null!; + + /// + /// The Excel column index (1-based) where this property is mapped. + /// + public int ColumnIndex { get; set; } + + /// + /// Pre-compiled getter for extracting the property value from an object. + /// + public Func Getter { get; set; } = null!; + + /// + /// Pre-compiled setter for setting the property value on an object. + /// + public Action Setter { get; set; } = null!; + + /// + /// The type of the property. + /// + public Type PropertyType { get; set; } = null!; +} + +/// +/// Pre-compiled information about a nested collection within a complex type. +/// +internal class NestedCollectionInfo +{ + public string PropertyName { get; set; } = null!; + public int StartColumn { get; set; } + public int StartRow { get; set; } + public CollectionLayout Layout { get; set; } + public int RowSpacing { get; set; } + public Type ItemType { get; set; } = typeof(object); + public Func Getter { get; set; } = null!; + public Action? Setter { get; set; } + public Func ListFactory { get; set; } = null!; + public Func ItemFactory { get; set; } = null!; + public NestedMappingInfo? NestedMapping { get; set; } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/Helpers/CellFormatter.cs b/src/MiniExcel.Core/Helpers/CellFormatter.cs new file mode 100644 index 00000000..cb7da44d --- /dev/null +++ b/src/MiniExcel.Core/Helpers/CellFormatter.cs @@ -0,0 +1,62 @@ +namespace MiniExcelLib.Core.Helpers; + +/// +/// Utility class for formatting cell values consistently across the mapping system. +/// Centralizes Excel-specific formatting logic to reduce code duplication. +/// +internal static class CellFormatter +{ + /// + /// Excel epoch date used for date/time calculations. + /// Excel treats dates as days since this date. + /// + public static readonly DateTime ExcelEpoch = new(1899, 12, 30); + + /// + /// Formats a value for Excel cell output, returning both the formatted string and cell type. + /// + /// The value to format + /// A tuple containing the formatted value and the Excel cell type + public static (string? value, string? type) FormatCellValue(object? value) + { + if (value is null) + return (null, null); + + switch (value) + { + case string s: + // Use inline string to avoid shared string table + return (s, "inlineStr"); + + case DateTime dt: + // Excel stores dates as numbers + var excelDate = (dt - ExcelEpoch).TotalDays; + return (excelDate.ToString(CultureInfo.InvariantCulture), null); + + case DateTimeOffset dto: + var excelDateOffset = (dto.DateTime - ExcelEpoch).TotalDays; + return (excelDateOffset.ToString(CultureInfo.InvariantCulture), null); + + case bool b: + return (b ? "1" : "0", "b"); + + case byte: + case sbyte: + case short: + case ushort: + case int: + case uint: + case long: + case ulong: + case float: + case double: + case decimal: + return (Convert.ToString(value, CultureInfo.InvariantCulture), null); + + default: + // Convert to string + return (value.ToString(), "inlineStr"); + } + } + +} \ No newline at end of file diff --git a/src/MiniExcel.Core/Helpers/CollectionAccessor.cs b/src/MiniExcel.Core/Helpers/CollectionAccessor.cs new file mode 100644 index 00000000..20b198dd --- /dev/null +++ b/src/MiniExcel.Core/Helpers/CollectionAccessor.cs @@ -0,0 +1,101 @@ +using System.Linq.Expressions; + +namespace MiniExcelLib.Core.Helpers; + +/// +/// Optimized collection access utilities to reduce code duplication across mapping components. +/// Provides consistent handling of IList vs IEnumerable patterns. +/// +internal static class CollectionAccessor +{ + /// + /// Gets an item at the specified offset from a collection, with optimized handling for IList. + /// + /// The collection to access + /// The zero-based index of the item to retrieve + /// The item at the specified offset, or null if not found or out of bounds + public static object? GetItemAt(IEnumerable? enumerable, int offset) + { + return enumerable switch + { + null => null, + IList list => offset < list.Count ? list[offset] : null, + _ => enumerable.Cast().Skip(offset).FirstOrDefault() + }; + } + + /// + /// Creates a typed list of the specified item type. + /// + /// The type of items the list will contain + /// A new generic List instance + public static IList CreateTypedList(Type itemType) + { + var listType = typeof(List<>).MakeGenericType(itemType); + return (IList)Activator.CreateInstance(listType)!; + } + + /// + /// Converts a generic collection to the appropriate collection type (array or list). + /// + /// The source list to convert + /// The target collection type + /// The type of items in the collection + /// The converted collection + public static object FinalizeCollection(IList list, Type targetType, Type itemType) + { + if (!targetType.IsArray) + return list; + + var array = Array.CreateInstance(itemType, list.Count); + list.CopyTo(array, 0); + return array; + + } + + /// + /// Creates a default item factory for the specified type. + /// + /// The type to create instances of + /// A factory function that creates new instances + public static Func CreateItemFactory(Type itemType) + { + // Value types can always be created via Activator.CreateInstance + if (itemType.IsValueType) + { + return () => Activator.CreateInstance(itemType); + } + + // For reference types, prefer a compiled parameterless constructor if available + var ctor = itemType.GetConstructor(Type.EmptyTypes); + if (ctor is null) + { + // No default constructor - unable to materialize items automatically + return () => null; + } + + var newExpression = Expression.New(ctor); + var lambda = Expression.Lambda>(Expression.Convert(newExpression, typeof(object))); + var factory = lambda.Compile(); + return factory; + } + + /// + /// Determines the item type from a collection type. + /// + /// The collection type to analyze + /// The item type, or null if not determinable + public static Type? GetItemType(Type collectionType) + { + if (collectionType.IsArray) + { + return collectionType.GetElementType(); + } + + if (!collectionType.IsGenericType) + return null; + + var genericArgs = collectionType.GetGenericArguments(); + return genericArgs.Length > 0 ? genericArgs[0] : null; + } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/Helpers/ConversionHelper.cs b/src/MiniExcel.Core/Helpers/ConversionHelper.cs new file mode 100644 index 00000000..1efad08b --- /dev/null +++ b/src/MiniExcel.Core/Helpers/ConversionHelper.cs @@ -0,0 +1,316 @@ +using System.Collections.Concurrent; +using System.Linq.Expressions; + +namespace MiniExcelLib.Core.Helpers; + +/// +/// Optimized value conversion with caching +/// +internal static class ConversionHelper +{ + // Cache compiled conversion delegates + private static readonly ConcurrentDictionary<(Type Source, Type Target), Func> ConversionCache = new(); + + public static object? ConvertValue(object value, Type targetType, string? format = null) + { + var sourceType = value.GetType(); + + // Fast path: no conversion needed + if (targetType.IsAssignableFrom(sourceType)) + return value; + + // Get or create cached converter + var key = (sourceType, targetType); + var converter = ConversionCache.GetOrAdd(key, CreateConverter); + + try + { + var result = converter(value); + + // Note: Format is for writing/display, not for reading + // When reading, we return the typed value, not formatted string + + return result; + } + catch + { + // Fallback to basic conversion + return ConvertValueFallback(value, targetType); + } + } + + private static Func CreateConverter((Type Source, Type Target) types) + { + var (sourceType, targetType) = types; + + // Handle nullable types + var underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType; + + // Special case for string source (most common in Excel) + if (sourceType == typeof(string)) + return CreateStringConverter(underlyingType, targetType != underlyingType); + + // Try to create expression-based converter + try + { + var parameter = Expression.Parameter(typeof(object), "value"); + var convert = Expression.Convert( + Expression.Convert(parameter, sourceType), + targetType + ); + var lambda = Expression.Lambda>( + Expression.Convert(convert, typeof(object)), + parameter + ); + return lambda.Compile(); + } + catch + { + // Fallback to runtime conversion + return value => ConvertValueFallback(value, targetType); + } + } + + private static Func CreateStringConverter(Type targetType, bool isNullable) + { + // Optimized converters for common types from string + if (targetType == typeof(int)) + { + return value => + { + var str = value as string; + if (string.IsNullOrWhiteSpace(str)) + return isNullable ? null : 0; + + return int.TryParse(str, out var result) + ? result + : isNullable ? null : 0; + }; + } + + if (targetType == typeof(long)) + { + return value => + { + var str = value as string; + if (string.IsNullOrWhiteSpace(str)) + return isNullable ? null : 0L; + + return long.TryParse(str, out var result) + ? result + : isNullable ? null : 0L; + }; + } + + if (targetType == typeof(double)) + { + return value => + { + var str = value as string; + if (string.IsNullOrWhiteSpace(str)) + return isNullable ? null : 0D; + + return double.TryParse(str, NumberStyles.Any, CultureInfo.InvariantCulture, out var result) + ? result + : isNullable ? null : 0D; + }; + } + + if (targetType == typeof(decimal)) + { + return value => + { + var str = value as string; + if (string.IsNullOrWhiteSpace(str)) + return isNullable ? null : 0M; + + return decimal.TryParse(str, NumberStyles.Any, CultureInfo.InvariantCulture, out var result) + ? result + : isNullable ? null : 0M; + }; + } + + if (targetType == typeof(bool)) + { + return value => + { + var str = value as string; + if (string.IsNullOrWhiteSpace(str)) + return isNullable ? null : false; + + return bool.TryParse(str, out var result) + ? result + : isNullable ? null : false; + }; + } + + if (targetType == typeof(DateTime)) + { + return value => + { + var str = value as string; + if (string.IsNullOrWhiteSpace(str)) + return isNullable ? null : DateTime.MinValue; + + return DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.None, out var result) + ? result + : isNullable ? null : DateTime.MinValue; + }; + } + + if (targetType == typeof(TimeSpan)) + { + return value => + { + var str = value as string; + if (string.IsNullOrWhiteSpace(str)) + return isNullable ? null : TimeSpan.MinValue; + + return TimeSpan.TryParse(str, CultureInfo.InvariantCulture, out var result) + ? result + : isNullable ? null : TimeSpan.MinValue; + }; + } + + if (targetType == typeof(Guid)) + { + return value => + { + var str = value as string; + if (string.IsNullOrWhiteSpace(str)) + return isNullable ? null : Guid.Empty; + + return Guid.TryParse(str, out var result) + ? result + : isNullable ? null : Guid.Empty; + }; + } + + // Default converter using Convert.ChangeType + var newType = isNullable ? typeof(Nullable<>).MakeGenericType(targetType) : targetType; + return value => ConvertValueFallback(value, newType); + } + + private static object? ConvertValueFallback(object? value, Type targetType) + { + try + { + if (Nullable.GetUnderlyingType(targetType) is { } underlyingType) + { + return value is not (null or "" or " ") + ? Convert.ChangeType(value, underlyingType, CultureInfo.InvariantCulture) + : null; + } + + return Convert.ChangeType(value, targetType, CultureInfo.InvariantCulture); + } + catch + { + // Last resort: return default value + return targetType.IsValueType ? Activator.CreateInstance(targetType) : null; + } + } + + /// + /// Creates a compiled setter expression for the specified target type with proper conversion handling. + /// This consolidates type conversion logic from various parts of the codebase. + /// + /// The target property type + /// The parameter expression for the input value + /// A compiled expression that converts and assigns values + private static Expression CreateTypedConversionExpression(Type targetType, ParameterExpression valueParameter) + { + // Handle nullable types + var underlyingType = Nullable.GetUnderlyingType(targetType); + var isNullable = underlyingType is not null; + var effectiveType = underlyingType ?? targetType; + + Expression convertExpression; + + // Create conversion expression based on effective type + if (effectiveType == typeof(int)) + { + var convertMethod = typeof(Convert).GetMethod("ToInt32", [typeof(object)]); + convertExpression = Expression.Call(convertMethod!, valueParameter); + } + else if (effectiveType == typeof(decimal)) + { + var convertMethod = typeof(Convert).GetMethod("ToDecimal", [typeof(object)]); + convertExpression = Expression.Call(convertMethod!, valueParameter); + } + else if (effectiveType == typeof(long)) + { + var convertMethod = typeof(Convert).GetMethod("ToInt64", [typeof(object)]); + convertExpression = Expression.Call(convertMethod!, valueParameter); + } + else if (effectiveType == typeof(float)) + { + var convertMethod = typeof(Convert).GetMethod("ToSingle", [typeof(object)]); + convertExpression = Expression.Call(convertMethod!, valueParameter); + } + else if (effectiveType == typeof(double)) + { + var convertMethod = typeof(Convert).GetMethod("ToDouble", [typeof(object)]); + convertExpression = Expression.Call(convertMethod!, valueParameter); + } + else if (effectiveType == typeof(DateTime)) + { + var convertMethod = typeof(Convert).GetMethod("ToDateTime", [typeof(object)]); + convertExpression = Expression.Call(convertMethod!, valueParameter); + } + else if (effectiveType == typeof(bool)) + { + var convertMethod = typeof(Convert).GetMethod("ToBoolean", [typeof(object)]); + convertExpression = Expression.Call(convertMethod!, valueParameter); + } + else if (effectiveType == typeof(string)) + { + var convertMethod = typeof(Convert).GetMethod("ToString", [typeof(object)]); + convertExpression = Expression.Call(convertMethod!, valueParameter); + } + else + { + // Default: direct cast for other types + convertExpression = Expression.Convert(valueParameter, effectiveType); + } + + // If the target type is nullable, convert the result to nullable + if (isNullable) + { + convertExpression = Expression.Convert(convertExpression, targetType); + } + + return convertExpression; + } + + /// + /// Creates a compiled property setter with type conversion for the specified property. + /// + /// The containing type + /// The property to create a setter for + /// A compiled setter action or null if the property is not settable + public static Action? CreateTypedPropertySetter(PropertyInfo propertyInfo) + { + if (!propertyInfo.CanWrite) + return null; + + var setterParam = Expression.Parameter(typeof(object), "obj"); + var valueParam = Expression.Parameter(typeof(object), "value"); + var castObj = Expression.Convert(setterParam, typeof(T)); + + // Use the centralized conversion logic + var convertedValue = CreateTypedConversionExpression(propertyInfo.PropertyType, valueParam); + + var assign = Expression.Assign(Expression.Property(castObj, propertyInfo), convertedValue); + var setterLambda = Expression.Lambda>(assign, setterParam, valueParam); + return setterLambda.Compile(); + } + + /// + /// Clear the conversion cache (useful for testing or memory management) + /// + public static void ClearCache() + { + ConversionCache.Clear(); + } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/Helpers/MappingMetadataExtractor.cs b/src/MiniExcel.Core/Helpers/MappingMetadataExtractor.cs new file mode 100644 index 00000000..388a3e5e --- /dev/null +++ b/src/MiniExcel.Core/Helpers/MappingMetadataExtractor.cs @@ -0,0 +1,199 @@ +using MiniExcelLib.Core.FluentMapping; + +namespace MiniExcelLib.Core.Helpers; + +/// +/// Helper class for extracting mapping metadata using reflection. +/// Consolidates reflection-based property extraction logic to reduce duplication and improve performance. +/// +internal static class MappingMetadataExtractor +{ + private static readonly MethodInfo? CreateTypedSetterMethod = typeof(ConversionHelper) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .FirstOrDefault(m => m is { Name: nameof(ConversionHelper.CreateTypedPropertySetter), IsGenericMethodDefinition: true }); + + private static readonly Action DefaultNestedPropertySetter = (_, _) => { }; + + /// + /// Extracts nested mapping information from a compiled mapping object. + /// This method minimizes reflection by extracting properties once at compile time. + /// + /// The nested mapping object to extract information from + /// The type of items in the nested mapping + /// Nested mapping information or null if extraction fails + public static NestedMappingInfo? ExtractNestedMappingInfo(object nestedMapping, Type itemType) + { + // Use reflection minimally to extract properties from the nested mapping + // This is done once at compile time, not at runtime + var nestedMappingType = nestedMapping.GetType(); + var propsProperty = nestedMappingType.GetProperty("Properties"); + + if (propsProperty?.GetValue(nestedMapping) is not IEnumerable properties) + return null; + + var nestedInfo = new NestedMappingInfo + { + ItemType = itemType, + ItemFactory = CollectionAccessor.CreateItemFactory(itemType) + }; + + var propertyList = ExtractPropertyList(properties, itemType); + nestedInfo.Properties = propertyList; + + var collectionsProperty = nestedMappingType.GetProperty("Collections"); + if (collectionsProperty?.GetValue(nestedMapping) is not IEnumerable collectionMappings) + return nestedInfo; + + var nestedCollections = new Dictionary(StringComparer.Ordinal); + foreach (var collection in collectionMappings) + { + if (collection is not CompiledCollectionMapping compiledCollection) + continue; + + var nestedItemType = compiledCollection.ItemType ?? typeof(object); + var collectionInfo = new NestedCollectionInfo + { + PropertyName = compiledCollection.PropertyName, + StartColumn = compiledCollection.StartCellColumn, + StartRow = compiledCollection.StartCellRow, + Layout = compiledCollection.Layout, + RowSpacing = compiledCollection.RowSpacing, + ItemType = nestedItemType, + Getter = compiledCollection.Getter, + Setter = compiledCollection.Setter, + ListFactory = () => CollectionAccessor.CreateTypedList(nestedItemType), + ItemFactory = CollectionAccessor.CreateItemFactory(nestedItemType) + }; + + if (compiledCollection.Registry is not null && nestedItemType != typeof(object)) + { + var childMapping = compiledCollection.Registry.GetCompiledMapping(nestedItemType); + if (childMapping is not null) + { + collectionInfo.NestedMapping = ExtractNestedMappingInfo(childMapping, nestedItemType); + } + } + + nestedCollections[collectionInfo.PropertyName] = collectionInfo; + } + + if (nestedCollections.Count > 0) + { + nestedInfo.Collections = nestedCollections; + } + + return nestedInfo; + } + + /// + /// Extracts a list of property information from a collection of property mapping objects. + /// + /// The collection of property mappings + /// A list of nested property information + private static List ExtractPropertyList(IEnumerable properties, Type itemType) + { + var propertyList = new List(); + + foreach (var prop in properties) + { + var propType = prop.GetType(); + var nameProperty = propType.GetProperty("PropertyName"); + var columnProperty = propType.GetProperty("CellColumn"); + var getterProperty = propType.GetProperty("Getter"); + var setterProperty = propType.GetProperty("Setter"); + var typeProperty = propType.GetProperty("PropertyType"); + + if (nameProperty is null || columnProperty is null || getterProperty is null) + continue; + + var name = nameProperty.GetValue(prop) as string; + var column = (int)columnProperty.GetValue(prop)!; + var getter = getterProperty.GetValue(prop) as Func; + var setter = setterProperty?.GetValue(prop) as Action; + var propTypeValue = typeProperty?.GetValue(prop) as Type; + + if (setter is null && name is not null) + { + var propertyInfo = itemType.GetProperty(name, BindingFlags.Public | BindingFlags.Instance); + if (propertyInfo?.CanWrite == true) + { + setter = CreateSetterWithConversion(itemType, propertyInfo) + ?? CreateFallbackSetter(propertyInfo); + } + } + + setter ??= DefaultNestedPropertySetter; + + if (name is not null && getter is not null) + { + propertyList.Add(new NestedPropertyInfo + { + PropertyName = name, + ColumnIndex = column, + Getter = getter, + Setter = setter, + PropertyType = propTypeValue ?? typeof(object) + }); + } + } + + return propertyList; + } + + private static Action? CreateSetterWithConversion(Type itemType, PropertyInfo propertyInfo) + { + if (CreateTypedSetterMethod is null) + return null; + + try + { + var generic = CreateTypedSetterMethod.MakeGenericMethod(itemType); + return generic.Invoke(null, [propertyInfo]) as Action; + } + catch + { + return null; + } + } + + private static Action? CreateFallbackSetter(PropertyInfo propertyInfo) + { + try + { + var memberSetter = new MemberSetter(propertyInfo); + return memberSetter.Invoke; + } + catch + { + return null; + } + } + + /// + /// Gets a specific property by name from a type. + /// + /// The type to search + /// The name of the property + /// PropertyInfo if found, otherwise null + public static PropertyInfo? GetPropertyByName(Type type, string propertyName) + { + return type.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); + } + + private static bool IsSimpleType(Type type) + { + return type == typeof(string) || + type.IsValueType || + type.IsPrimitive; + } + + /// + /// Determines if a type is a complex type that likely has nested properties. + /// + /// The type to check + /// True if the type is considered complex + public static bool IsComplexType(Type type) + { + return !IsSimpleType(type) && type != typeof(object); + } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/Helpers/XmlCellWriter.cs b/src/MiniExcel.Core/Helpers/XmlCellWriter.cs new file mode 100644 index 00000000..49253738 --- /dev/null +++ b/src/MiniExcel.Core/Helpers/XmlCellWriter.cs @@ -0,0 +1,176 @@ +namespace MiniExcelLib.Core.Helpers; + +/// +/// Helper class for writing Excel cell XML with consistent formatting. +/// Consolidates XML cell writing patterns to reduce duplication. +/// +internal static partial class XmlCellWriter +{ + /// + /// Writes a new cell element with the specified reference and value. + /// + /// The XML writer + /// The cell reference (e.g., "A1") + /// The cell value + /// Cancellation token + [CreateSyncVersion] + public static async Task WriteNewCellAsync( + XmlWriter writer, + string cellRef, + object? value, + CancellationToken cancellationToken = default) + { + // Use centralized formatting + var (cellValue, cellType) = CellFormatter.FormatCellValue(value); + + if (string.IsNullOrEmpty(cellValue) && string.IsNullOrEmpty(cellType)) + return; // Don't write empty cells + + // Write cell element + await writer.WriteStartElementAsync("", "c", "").ConfigureAwait(false); + await writer.WriteAttributeStringAsync("", "r", "", cellRef).ConfigureAwait(false); + + if (!string.IsNullOrEmpty(cellType)) + { + await writer.WriteAttributeStringAsync("", "t", "", cellType).ConfigureAwait(false); + } + + // Write the value content + await WriteCellValueContentAsync(writer, cellValue, cellType).ConfigureAwait(false); + + await writer.WriteEndElementAsync().ConfigureAwait(false); // + } + + /// + /// Writes a cell element replacing template content with new value. + /// + /// The XML reader positioned on the cell element + /// The XML writer + /// The new cell value + /// Cancellation token + [CreateSyncVersion] + public static async Task WriteMappedCellAsync( + XmlReader reader, + XmlWriter writer, + object? value, + CancellationToken cancellationToken = default) + { + // Use centralized formatting + var (cellValue, cellType) = CellFormatter.FormatCellValue(value); + + // Write cell start tag + await writer.WriteStartElementAsync(reader.Prefix, "c", reader.NamespaceURI).ConfigureAwait(false); + + // Copy attributes, potentially updating type + await CopyAndUpdateCellAttributesAsync(reader, writer, cellType).ConfigureAwait(false); + + // Write the value content + await WriteCellValueContentAsync(writer, cellValue, cellType, reader.NamespaceURI).ConfigureAwait(false); + + // Skip original cell content + await SkipOriginalCellContentAsync(reader).ConfigureAwait(false); + + await writer.WriteEndElementAsync().ConfigureAwait(false); // + } + + /// + /// Writes the value content (v or is elements) for a cell. + /// + [CreateSyncVersion] + private static async Task WriteCellValueContentAsync( + XmlWriter writer, + string? cellValue, + string? cellType, + string namespaceUri = "") + { + if (cellType == "inlineStr" && !string.IsNullOrEmpty(cellValue)) + { + // Write inline string + await writer.WriteStartElementAsync("", "is", namespaceUri).ConfigureAwait(false); + await writer.WriteStartElementAsync("", "t", namespaceUri).ConfigureAwait(false); + await writer.WriteStringAsync(cellValue).ConfigureAwait(false); + await writer.WriteEndElementAsync().ConfigureAwait(false); // + await writer.WriteEndElementAsync().ConfigureAwait(false); // + } + else if (!string.IsNullOrEmpty(cellValue)) + { + // Write value element + await writer.WriteStartElementAsync("", "v", namespaceUri).ConfigureAwait(false); + await writer.WriteStringAsync(cellValue).ConfigureAwait(false); + await writer.WriteEndElementAsync().ConfigureAwait(false); // + } + } + + /// + /// Copies cell attributes from reader to writer, updating the type attribute if needed. + /// + [CreateSyncVersion] + private static async Task CopyAndUpdateCellAttributesAsync( + XmlReader reader, + XmlWriter writer, + string? newCellType) + { + if (reader.HasAttributes) + { + while (reader.MoveToNextAttribute()) + { + if (reader.LocalName == "t") + { + // Write our type instead + if (!string.IsNullOrEmpty(newCellType)) + { + await writer.WriteAttributeStringAsync("", "t", "", newCellType).ConfigureAwait(false); + } + } + else if (reader.LocalName == "s") + { + // Skip style if we're writing inline string + if (newCellType != "inlineStr") + { + await writer.WriteAttributeStringAsync( + reader.Prefix, + reader.LocalName, + reader.NamespaceURI, + reader.Value).ConfigureAwait(false); + } + } + else + { + // Copy other attributes + await writer.WriteAttributeStringAsync( + reader.Prefix, + reader.LocalName, + reader.NamespaceURI, + reader.Value).ConfigureAwait(false); + } + } + reader.MoveToElement(); + } + + // If we didn't have a type attribute but need one, add it + if (!string.IsNullOrEmpty(newCellType) && reader.GetAttribute("t") is null) + { + await writer.WriteAttributeStringAsync("", "t", "", newCellType).ConfigureAwait(false); + } + } + + /// + /// Skips the original cell content when replacing with new content. + /// + [CreateSyncVersion] + private static async Task SkipOriginalCellContentAsync(XmlReader reader) + { + var isEmpty = reader.IsEmptyElement; + if (!isEmpty) + { + var depth = reader.Depth; + while (await reader.ReadAsync().ConfigureAwait(false)) + { + if (reader.NodeType == XmlNodeType.EndElement && reader.Depth == depth) + { + break; + } + } + } + } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/MiniExcelProviders.cs b/src/MiniExcel.Core/MiniExcelProviders.cs index 9a15527c..ead314a9 100644 --- a/src/MiniExcel.Core/MiniExcelProviders.cs +++ b/src/MiniExcel.Core/MiniExcelProviders.cs @@ -1,3 +1,8 @@ +using MiniExcelLib.Core.FluentMapping; +using MappingExporter = MiniExcelLib.Core.FluentMapping.MappingExporter; +using MappingImporter = MiniExcelLib.Core.FluentMapping.MappingImporter; +using MappingTemplater = MiniExcelLib.Core.FluentMapping.MappingTemplater; + namespace MiniExcelLib.Core; public sealed class MiniExcelImporterProvider diff --git a/src/MiniExcel.Core/OpenXml/MappedRow.cs b/src/MiniExcel.Core/OpenXml/MappedRow.cs new file mode 100644 index 00000000..2989984c --- /dev/null +++ b/src/MiniExcel.Core/OpenXml/MappedRow.cs @@ -0,0 +1,33 @@ +namespace MiniExcelLib.Core.OpenXml; + +internal struct MappedRow(int rowIndex) +{ + private const int MaxColumns = 100; + private object?[]? _cells = null; + + public int RowIndex { get; } = rowIndex; + + public void SetCell(int columnIndex, object? value) + { + if (value is null) + return; + + // Lazy initialize cells array + _cells ??= new object?[MaxColumns]; + + if (columnIndex is >= 0 and < MaxColumns) + { + _cells[columnIndex] = value; + } + } + + public object? GetCell(int columnIndex) + { + if (_cells is null || (columnIndex is < 0 or >= MaxColumns)) + return null; + + return _cells[columnIndex]; + } + + public bool HasData => _cells is not null; +} \ No newline at end of file diff --git a/src/MiniExcel.Core/OpenXml/OpenXmlReader.cs b/src/MiniExcel.Core/OpenXml/OpenXmlReader.cs index 999cec5b..a898d771 100644 --- a/src/MiniExcel.Core/OpenXml/OpenXmlReader.cs +++ b/src/MiniExcel.Core/OpenXml/OpenXmlReader.cs @@ -1097,6 +1097,147 @@ internal static async Task TryGetMergeCellsAsync(ZipArchiveEntry sheetEntr Dispose(false); } + /// + /// Direct mapped query that bypasses dictionary creation for better performance + /// + [CreateSyncVersion] + internal async IAsyncEnumerable QueryMappedAsync( + string? sheetName, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var sheetEntry = GetSheetEntry(sheetName); + var withoutCr = false; + + var mergeCellsContext = new MergeCellsContext(); + if (_config.FillMergedCells) + { + await TryGetMergeCellsAsync(sheetEntry, mergeCellsContext, cancellationToken).ConfigureAwait(false); + } + var mergeCells = _config.FillMergedCells ? mergeCellsContext.MergeCells : null; + + // Direct XML reading without dictionary creation + var xmlSettings = new XmlReaderSettings + { + CheckCharacters = false, + IgnoreWhitespace = true, + IgnoreComments = true, + XmlResolver = null, + Async = true + }; + +#if NET10_0_OR_GREATER + using var sheetStream = await sheetEntry.OpenAsync(cancellationToken).ConfigureAwait(false); +#else + using var sheetStream = sheetEntry.Open(); +#endif + using var reader = XmlReader.Create(sheetStream, xmlSettings); + + if (!XmlReaderHelper.IsStartElement(reader, "worksheet", Ns)) + yield break; + + if (!await XmlReaderHelper.ReadFirstContentAsync(reader, cancellationToken).ConfigureAwait(false)) + yield break; + + while (!reader.EOF) + { + if (XmlReaderHelper.IsStartElement(reader, "sheetData", Ns)) + { + if (!await XmlReaderHelper.ReadFirstContentAsync(reader, cancellationToken).ConfigureAwait(false)) + continue; + + int rowIndex = -1; + while (!reader.EOF) + { + if (XmlReaderHelper.IsStartElement(reader, "row", Ns)) + { + if (int.TryParse(reader.GetAttribute("r"), out int arValue)) + rowIndex = arValue - 1; // The row attribute is 1-based + else + rowIndex++; + + // Read row directly into mapped structure + await foreach (var mappedRow in ReadMappedRowAsync(reader, rowIndex, withoutCr, mergeCells, cancellationToken).ConfigureAwait(false)) + { + yield return mappedRow; + } + } + else if (!await XmlReaderHelper.SkipContentAsync(reader, cancellationToken).ConfigureAwait(false)) + { + break; + } + } + } + else if (!await XmlReaderHelper.SkipContentAsync(reader, cancellationToken).ConfigureAwait(false)) + { + break; + } + } + } + + [CreateSyncVersion] + private async IAsyncEnumerable ReadMappedRowAsync( + XmlReader reader, + int rowIndex, + bool withoutCr, + MergeCells? mergeCells, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (!await XmlReaderHelper.ReadFirstContentAsync(reader, cancellationToken).ConfigureAwait(false)) + { + // Empty row + yield return new MappedRow(rowIndex); + yield break; + } + + var row = new MappedRow(rowIndex); + var columnIndex = withoutCr ? -1 : 0; + + while (!reader.EOF) + { + if (XmlReaderHelper.IsStartElement(reader, "c", Ns)) + { + var aS = reader.GetAttribute("s"); + var aR = reader.GetAttribute("r"); + var aT = reader.GetAttribute("t"); + + var cellAndColumn = await ReadCellAndSetColumnIndexAsync(reader, columnIndex, withoutCr, 0, aR, aT, cancellationToken).ConfigureAwait(false); + var cellValue = cellAndColumn.CellValue; + columnIndex = cellAndColumn.ColumnIndex; + + if (_config.FillMergedCells && mergeCells is not null) + { + if (mergeCells.MergesValues.ContainsKey(aR)) + { + mergeCells.MergesValues[aR] = cellValue; + } + else if (mergeCells.MergesMap.TryGetValue(aR, out var mergeKey)) + { + mergeCells.MergesValues.TryGetValue(mergeKey, out cellValue); + } + } + + if (!string.IsNullOrEmpty(aS)) // Custom style + { + if (int.TryParse(aS, NumberStyles.Any, CultureInfo.InvariantCulture, out var styleIndex)) + { + _style ??= new OpenXmlStyles(Archive); + cellValue = _style.ConvertValueByStyleFormat(styleIndex, cellValue); + } + } + + row.SetCell(columnIndex, cellValue); + } + else if (!await XmlReaderHelper.SkipContentAsync(reader, cancellationToken).ConfigureAwait(false)) + { + break; + } + } + + yield return row; + } + public void Dispose() { Dispose(true); diff --git a/src/MiniExcel.Core/OpenXml/Utils/ReferenceHelper.cs b/src/MiniExcel.Core/OpenXml/Utils/ReferenceHelper.cs index 4cd4135f..157362ba 100644 --- a/src/MiniExcel.Core/OpenXml/Utils/ReferenceHelper.cs +++ b/src/MiniExcel.Core/OpenXml/Utils/ReferenceHelper.cs @@ -45,6 +45,34 @@ public static string ConvertCoordinatesToCell(int x, int y) return $"{columnName}{y}"; } + /// + /// Try to parse cell reference (e.g., "A1") into column and row numbers. + /// + /// The cell reference (e.g., "A1", "B2", "AA10") + /// The column number (1-based) + /// The row number (1-based) + /// True if successfully parsed, false otherwise + public static bool TryParseCellReference(string cellRef, out int column, out int row) + { + column = 0; + row = 0; + + if (string.IsNullOrEmpty(cellRef)) + return false; + + try + { + var coords = ConvertCellToCoordinates(cellRef); + column = coords.Item1; + row = coords.Item2; + return column > 0 && row > 0; + } + catch + { + return false; + } + } + /**The code below was copied and modified from ExcelDataReader - @MIT License**/ /// /// Logic for the Excel dimensions. Ex: A15 diff --git a/src/MiniExcel.Core/WriteAdapters/MappingCellStreamAdapter.cs b/src/MiniExcel.Core/WriteAdapters/MappingCellStreamAdapter.cs new file mode 100644 index 00000000..bdf49a1d --- /dev/null +++ b/src/MiniExcel.Core/WriteAdapters/MappingCellStreamAdapter.cs @@ -0,0 +1,90 @@ +using MiniExcelLib.Core.FluentMapping; + +namespace MiniExcelLib.Core.WriteAdapters; + +internal class MappingCellStreamAdapter : IMiniExcelWriteAdapter + where T : class +{ + private readonly MappingCellStream _cellStream; + private readonly string[] _columnLetters; + + public MappingCellStreamAdapter(MappingCellStream cellStream, string[] columnLetters) + { + _cellStream = cellStream; + _columnLetters = columnLetters; + } + + public bool TryGetKnownCount(out int count) + { + // We don't know the exact row count without iterating + count = 0; + return false; + } + + public List GetColumns() + { + var props = new List(); + + for (int i = 0; i < _columnLetters.Length; i++) + { + props.Add(new MiniExcelColumnInfo + { + Key = _columnLetters[i], + ExcelColumnName = _columnLetters[i], + ExcelColumnIndex = i + }); + } + + return props; + } + + public IEnumerable> GetRows(List props, CancellationToken cancellationToken = default) + { + var currentRow = new Dictionary(); + var currentRowIndex = 0; + + foreach (var cell in _cellStream) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Check if we've moved to a new row + if (cell.RowIndex != currentRowIndex) + { + // Yield the completed row if we have one + if (currentRowIndex > 0 && currentRow.Count > 0) + { + yield return ConvertRowToCellWriteInfos(currentRow, props); + } + + // Start new row + currentRow.Clear(); + currentRowIndex = cell.RowIndex; + } + + // Add cell to current row + currentRow[cell.ColumnLetter] = cell.Value; + } + + // Yield the final row + if (currentRow.Count > 0) + { + yield return ConvertRowToCellWriteInfos(currentRow, props); + } + } + + private static IEnumerable ConvertRowToCellWriteInfos(Dictionary row, List props) + { + var columnIndex = 1; + foreach (var prop in props) + { + object? cellValue = null; + if (row.TryGetValue(prop.Key.ToString(), out var value)) + { + cellValue = value; + } + + yield return new CellWriteInfo(cellValue, columnIndex, prop); + columnIndex++; + } + } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/WriteAdapters/MiniExcelWriteAdapterFactory.cs b/src/MiniExcel.Core/WriteAdapters/MiniExcelWriteAdapterFactory.cs index 65552977..09185b8d 100644 --- a/src/MiniExcel.Core/WriteAdapters/MiniExcelWriteAdapterFactory.cs +++ b/src/MiniExcel.Core/WriteAdapters/MiniExcelWriteAdapterFactory.cs @@ -1,4 +1,6 @@ -namespace MiniExcelLib.Core.WriteAdapters; +using MiniExcelLib.Core.FluentMapping; + +namespace MiniExcelLib.Core.WriteAdapters; public static class MiniExcelWriteAdapterFactory { @@ -25,6 +27,7 @@ public static IMiniExcelWriteAdapter GetWriteAdapter(object values, MiniExcelBas { return values switch { + IMappingCellStream mappingStream => mappingStream.CreateAdapter(), IDataReader dataReader => new DataReaderWriteAdapter(dataReader, configuration), IEnumerable enumerable => new EnumerableWriteAdapter(enumerable, configuration), DataTable dataTable => new DataTableWriteAdapter(dataTable, configuration), diff --git a/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingCompilerTests.cs b/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingCompilerTests.cs new file mode 100644 index 00000000..490bf9ff --- /dev/null +++ b/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingCompilerTests.cs @@ -0,0 +1,341 @@ +using MiniExcelLib.Core.FluentMapping; + +namespace MiniExcelLib.Tests.FluentMapping +{ + /// + /// Tests for the mapping compiler and optimization system. + /// Focuses on internal optimization details and performance characteristics. + /// + public class MiniExcelMappingCompilerTests + { + #region Test Models + + private class SimpleEntity + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public decimal Value { get; set; } + } + + private class ComplexEntity + { + public int Id { get; set; } + public string Title { get; set; } = ""; + public List Items { get; set; } = new(); + public Dictionary Properties { get; set; } = new(); + } + + #endregion + + #region Optimization Detection Tests + + [Fact] + public void Sequential_Properties_Should_Be_Detected() + { + // Arrange + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Id).ToCell("A1"); + cfg.Property(e => e.Name).ToCell("B1"); + cfg.Property(e => e.Value).ToCell("C1"); + }); + + // Act + var mapping = registry.GetMapping(); + + // Assert - verify optimization is applied + Assert.NotNull(mapping.OptimizedBoundaries); + Assert.NotNull(mapping.OptimizedCellGrid); + Assert.Equal(3, mapping.Properties.Count); + + // Verify properties are correctly mapped + Assert.Equal("A1", mapping.Properties[0].CellAddress); + Assert.Equal("B1", mapping.Properties[1].CellAddress); + Assert.Equal("C1", mapping.Properties[2].CellAddress); + } + + [Fact] + public void NonSequential_Properties_Should_Use_Optimization() + { + // Arrange + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Id).ToCell("A1"); + cfg.Property(e => e.Name).ToCell("C1"); // Skip B + cfg.Property(e => e.Value).ToCell("B2"); // Different row + }); + + // Act + var mapping = registry.GetMapping(); + + // Assert - verify optimization is applied + Assert.NotNull(mapping.OptimizedBoundaries); + Assert.NotNull(mapping.OptimizedCellGrid); + } + + #endregion + + #region Cell Grid Tests + + [Fact] + public void OptimizedCellGrid_Should_Have_Correct_Dimensions() + { + // Arrange + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Id).ToCell("A1"); + cfg.Property(e => e.Name).ToCell("E1"); // Column E + cfg.Property(e => e.Value).ToCell("C3"); // Row 3 + }); + + // Act + var mapping = registry.GetMapping(); + + // Assert + Assert.NotNull(mapping.OptimizedBoundaries); + var boundaries = mapping.OptimizedBoundaries; + + Assert.Equal(1, boundaries.MinRow); + Assert.Equal(3, boundaries.MaxRow); + Assert.Equal(1, boundaries.MinColumn); // A = 1 + Assert.Equal(5, boundaries.MaxColumn); // E = 5 + + Assert.Equal(3, boundaries.GridHeight); // 3 - 1 + 1 = 3 + Assert.Equal(5, boundaries.GridWidth); // 5 - 1 + 1 = 5 + } + + [Fact] + public void OptimizedCellGrid_Should_Map_Properties_Correctly() + { + // Arrange + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Id).ToCell("B2"); + cfg.Property(e => e.Name).ToCell("D2"); + cfg.Property(e => e.Value).ToCell("B4"); + }); + + // Act + var mapping = registry.GetMapping(); + var grid = mapping.OptimizedCellGrid!; + var boundaries = mapping.OptimizedBoundaries!; + + // Assert + // Grid should be 3x3 (rows 2-4, columns B-D which is 2-4) + Assert.Equal(3, grid.GetLength(0)); // Height + Assert.Equal(3, grid.GetLength(1)); // Width + + // Check Id at B2 (relative: 0,0) + var idHandler = grid[0, 0]; + Assert.Equal(CellHandlerType.Property, idHandler.Type); + Assert.Equal("Id", idHandler.PropertyName); + + // Check Name at D2 (relative: 0,2) + var nameHandler = grid[0, 2]; + Assert.Equal(CellHandlerType.Property, nameHandler.Type); + Assert.Equal("Name", nameHandler.PropertyName); + + // Check Value at B4 (relative: 2,0) + var valueHandler = grid[2, 0]; + Assert.Equal(CellHandlerType.Property, valueHandler.Type); + Assert.Equal("Value", valueHandler.PropertyName); + + // Check empty cells + Assert.Equal(CellHandlerType.Empty, grid[0, 1].Type); // C2 + Assert.Equal(CellHandlerType.Empty, grid[1, 0].Type); // B3 + } + + #endregion + + #region Collection Optimization Tests + + [Fact] + public void Collection_Should_Mark_Grid_Cells() + { + // Arrange + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Id).ToCell("A1"); + cfg.Property(e => e.Title).ToCell("B1"); + cfg.Collection(e => e.Items).StartAt("A2"); + }); + + // Act + var mapping = registry.GetMapping(); + var grid = mapping.OptimizedCellGrid!; + + // Assert + // Check that collection cells are marked + // Note: Collection handling depends on implementation details + Assert.NotNull(grid); + Assert.True(mapping.OptimizedBoundaries!.HasDynamicCollections); + } + + [Fact] + public void Multiple_Collections_Should_Be_Handled() + { + // Arrange + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Id).ToCell("A1"); + cfg.Collection(e => e.Items).StartAt("B1"); + cfg.Collection(e => e.Properties).StartAt("C1"); + }); + + // Act + var mapping = registry.GetMapping(); + + // Assert + Assert.Equal(2, mapping.Collections.Count); + } + + #endregion + + #region Pre-compilation Tests + + [Fact] + public void Property_Getters_Should_Be_Compiled() + { + // Arrange + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Id).ToCell("A1"); + cfg.Property(e => e.Name).ToCell("B1"); + }); + + // Act + var mapping = registry.GetMapping(); + + // Assert + foreach (var prop in mapping.Properties) + { + Assert.NotNull(prop.Getter); + + // Test getter works + var entity = new SimpleEntity { Id = 123, Name = "Test" }; + var idValue = mapping.Properties[0].Getter(entity); + Assert.Equal(123, idValue); + } + } + + [Fact] + public void Property_Setters_Should_Be_Compiled() + { + // Arrange + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Id).ToCell("A1"); + cfg.Property(e => e.Name).ToCell("B1"); + }); + + // Act + var mapping = registry.GetMapping(); + + // Assert + foreach (var prop in mapping.Properties) + { + Assert.NotNull(prop.Setter); + + // Test setter works + var entity = new SimpleEntity(); + mapping.Properties[0].Setter!(entity, 456); + Assert.Equal(456, entity.Id); + } + } + + #endregion + + #region Formula and Format Tests + + [Fact] + public void Formula_Properties_Should_Be_Marked() + { + // Arrange + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Id).ToCell("A1"); + cfg.Property(e => e.Value).ToCell("B1").WithFormula("=A1*2"); + }); + + // Act + var mapping = registry.GetMapping(); + var grid = mapping.OptimizedCellGrid!; + + // Assert + var formulaHandler = grid[0, 1]; // B1 relative position + Assert.Equal(CellHandlerType.Formula, formulaHandler.Type); + Assert.Equal("=A1*2", formulaHandler.Formula); + } + + [Fact] + public void Format_Should_Be_Preserved() + { + // Arrange + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Value).ToCell("A1").WithFormat("#,##0.00"); + }); + + // Act + var mapping = registry.GetMapping(); + + // Assert + var prop = mapping.Properties[0]; + Assert.Equal("#,##0.00", prop.Format); + } + + #endregion + + #region Edge Cases + + [Fact] + public void Empty_Configuration_Should_Be_Valid() + { + // Arrange + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + // No mappings + }); + + // Act + var mapping = registry.GetMapping(); + + // Assert + Assert.NotNull(mapping); + Assert.Empty(mapping.Properties); + Assert.Empty(mapping.Collections); + } + + [Fact] + public void Duplicate_Cell_Mapping_Should_Be_Allowed() + { + // Arrange + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Id).ToCell("A1"); + cfg.Property(e => e.Name).ToCell("A1"); // Same cell + }); + + // Act + var mapping = registry.GetMapping(); + + // Assert + Assert.Equal(2, mapping.Properties.Count); + // Both properties map to A1 - last one wins in the grid + } + + #endregion + } +} \ No newline at end of file diff --git a/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTemplateTests.cs b/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTemplateTests.cs new file mode 100644 index 00000000..b6c561f5 --- /dev/null +++ b/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTemplateTests.cs @@ -0,0 +1,387 @@ +using MiniExcelLib.Core.FluentMapping; +using MiniExcelLib.Tests.Common.Utils; + +namespace MiniExcelLib.Tests.FluentMapping; + +public class MiniExcelMappingTemplateTests +{ + private readonly OpenXmlImporter _importer = MiniExcel.Importers.GetOpenXmlImporter(); + private readonly OpenXmlExporter _exporter = MiniExcel.Exporters.GetOpenXmlExporter(); + + private static DateTime ParseDateValue(object? value) + { + return value switch + { + double serialDate => DateTime.FromOADate(serialDate), + DateTime dt => dt, + _ => DateTime.Parse(value?.ToString() ?? "") + }; + } + + private class TestEntity + { + public string Name { get; set; } = ""; + public DateTime CreateDate { get; set; } + public bool VIP { get; set; } + public int Points { get; set; } + } + + private class Department + { + public string Title { get; set; } = ""; + public List Managers { get; set; } = []; + public List Employees { get; set; } = []; + } + + private class Person + { + public string Name { get; set; } = ""; + public string Department { get; set; } = ""; + } + + [Fact] + public async Task BasicTemplateTest() + { + using var templatePath = AutoDeletingPath.Create(); + + var templateData = new[] + { + new { A = "Name", B = "Date", C = "VIP", D = "Points" }, + new { A = "", B = "", C = "", D = "" } // Empty row for data + }; + await _exporter.ExportAsync(templatePath.ToString(), templateData); + + var registry = new MappingRegistry(); + registry.Configure(config => + { + config.Property(x => x.Name).ToCell("A3"); + config.Property(x => x.CreateDate).ToCell("B3"); + config.Property(x => x.VIP).ToCell("C3"); + config.Property(x => x.Points).ToCell("D3"); + }); + + var data = new TestEntity + { + Name = "Jack", + CreateDate = new DateTime(2021, 01, 01), + VIP = true, + Points = 123 + }; + + using var outputPath = AutoDeletingPath.Create(); + var templater = MiniExcel.Templaters.GetMappingTemplater(registry); + await templater.ApplyTemplateAsync(outputPath.ToString(), templatePath.ToString(), [data]); + + var rows = _importer.Query(outputPath.ToString(), useHeaderRow: false).ToList(); + + Assert.Equal(3, rows.Count); + + // Row 0 is column headers + Assert.Equal("A", rows[0].A); + Assert.Equal("B", rows[0].B); + Assert.Equal("C", rows[0].C); + Assert.Equal("D", rows[0].D); + + // Row 1 is our custom headers + Assert.Equal("Name", rows[1].A); + Assert.Equal("Date", rows[1].B); + Assert.Equal("VIP", rows[1].C); + Assert.Equal("Points", rows[1].D); + + // Row 2 is the data + Assert.Equal("Jack", rows[2].A); + Assert.Equal(new DateTime(2021, 01, 01), ParseDateValue(rows[2].B)); + Assert.Equal(true, rows[2].C); + Assert.Equal(123, rows[2].D); + } + + [Fact] + public async Task StreamOverloadTest() + { + using var templatePath = AutoDeletingPath.Create(); + var templateData = new[] + { + new { A = "Name", B = "Date", C = "VIP", D = "Points" }, + new { A = "", B = "", C = "", D = "" } + }; + await _exporter.ExportAsync(templatePath.ToString(), templateData); + + var registry = new MappingRegistry(); + registry.Configure(config => + { + config.Property(x => x.Name).ToCell("A3"); + config.Property(x => x.CreateDate).ToCell("B3"); + config.Property(x => x.VIP).ToCell("C3"); + config.Property(x => x.Points).ToCell("D3"); + }); + + var data = new TestEntity + { + Name = "Jack", + CreateDate = new DateTime(2021, 01, 01), + VIP = true, + Points = 123 + }; + + // Test stream overload + using var outputPath = AutoDeletingPath.Create(); + using (var outputStream = File.Create(outputPath.ToString())) + using (var templateStream = File.OpenRead(templatePath.ToString())) + { + var templater = MiniExcel.Templaters.GetMappingTemplater(registry); + await templater.ApplyTemplateAsync(outputStream, templateStream, [data]); + } + + var rows = _importer.Query(outputPath.ToString(), useHeaderRow: false).ToList(); + Assert.Equal("Jack", rows[2].A); + } + + [Fact] + public async Task ByteArrayOverloadTest() + { + using var templatePath = AutoDeletingPath.Create(); + var templateData = new[] + { + new { A = "Name", B = "Date", C = "VIP", D = "Points" }, + new { A = "", B = "", C = "", D = "" } + }; + await _exporter.ExportAsync(templatePath.ToString(), templateData); + + var templateBytes = await File.ReadAllBytesAsync(templatePath.ToString()); + + var registry = new MappingRegistry(); + registry.Configure(config => + { + config.Property(x => x.Name).ToCell("A3"); + config.Property(x => x.CreateDate).ToCell("B3"); + config.Property(x => x.VIP).ToCell("C3"); + config.Property(x => x.Points).ToCell("D3"); + }); + + var data = new TestEntity + { + Name = "Jack", + CreateDate = new DateTime(2021, 01, 01), + VIP = true, + Points = 123 + }; + + using var outputPath = AutoDeletingPath.Create(); + using (var outputStream = File.Create(outputPath.ToString())) + { + var templater = MiniExcel.Templaters.GetMappingTemplater(registry); + await templater.ApplyTemplateAsync(outputStream, templateBytes, [data]); + } + + var rows = _importer.Query(outputPath.ToString(), useHeaderRow: false).ToList(); + Assert.Equal("Jack", rows[2].A); + } + + [Fact] + public async Task CollectionTemplateTest() + { + using var templatePath = AutoDeletingPath.Create(); + var templateData = new List + { + new { A = "Company", B = "", C = "" }, + new { A = "", B = "", C = "" }, // Row 2 + new { A = "Managers", B = "Department", C = "" } // Row 3 + }; + + for (int i = 0; i < 3; i++) + { + templateData.Add(new { A = "", B = "", C = "" }); + } + + templateData.Add(new { A = "Employees", B = "Department", C = "" }); // Row 7 + + for (int i = 0; i < 3; i++) + { + templateData.Add(new { A = "", B = "", C = "" }); + } + + // Saving our actual template first + await _exporter.ExportAsync(templatePath.ToString(), templateData); + + var registry = new MappingRegistry(); + + registry.Configure(config => + { + config.Property(x => x.Name).ToCell("A1"); + config.Property(x => x.Department).ToCell("B1"); + }); + + registry.Configure(config => + { + config.Property(x => x.Title).ToCell("A2"); + config.Collection(x => x.Managers).StartAt("A5"); + config.Collection(x => x.Employees).StartAt("A9"); + }); + + var dept = new Department + { + Title = "FooCompany", + Managers = + [ + new Person { Name = "Jack", Department = "HR" }, + new Person { Name = "Jane", Department = "IT" } + ], + Employees = + [ + new Person { Name = "Wade", Department = "HR" }, + new Person { Name = "John", Department = "Sales" } + ] + }; + + using var outputPath = AutoDeletingPath.Create(); + var templater = MiniExcel.Templaters.GetMappingTemplater(registry); + await templater.ApplyTemplateAsync(outputPath.ToString(), templatePath.ToString(), [dept]); + + var rows = _importer.Query(outputPath.ToString(), useHeaderRow: false).ToList(); + + + Assert.Equal(11, rows.Count); // We expect 11 rows total + + Assert.Equal("FooCompany", rows[1].A); + + Assert.Equal("Managers", rows[3].A); + Assert.Equal("Department", rows[3].B); + + Assert.Equal("Jack", rows[4].A); + Assert.Equal("HR", rows[4].B); + Assert.Equal("Jane", rows[5].A); + Assert.Equal("IT", rows[5].B); + + Assert.Equal("Employees", rows[7].A); + Assert.Equal("Department", rows[7].B); + + Assert.Equal("Wade", rows[8].A); + Assert.Equal("HR", rows[8].B); + Assert.Equal("John", rows[9].A); + Assert.Equal("Sales", rows[9].B); + } + + [Fact] + public async Task EmptyDataTest() + { + using var templatePath = AutoDeletingPath.Create(); + var templateData = new[] + { + new { A = "Name", B = "Date", C = "VIP", D = "Points" }, + new { A = "", B = "", C = "", D = "" } + }; + await _exporter.ExportAsync(templatePath.ToString(), templateData); + + var registry = new MappingRegistry(); + registry.Configure(config => + { + config.Property(x => x.Name).ToCell("A3"); + config.Property(x => x.CreateDate).ToCell("B3"); + config.Property(x => x.VIP).ToCell("C3"); + config.Property(x => x.Points).ToCell("D3"); + }); + + using var outputPath = AutoDeletingPath.Create(); + var templater = MiniExcel.Templaters.GetMappingTemplater(registry); + await templater.ApplyTemplateAsync(outputPath.ToString(), templatePath.ToString(), Array.Empty()); + + var rows = _importer.Query(outputPath.ToString(), useHeaderRow: false).ToList(); + Assert.Equal(3, rows.Count); // Column headers + our headers + empty data row + Assert.Equal("Name", rows[1].A); + Assert.Equal("Date", rows[1].B); + + // Third row should be empty + Assert.True(string.IsNullOrEmpty(rows[2].A?.ToString())); + } + + [Fact] + public async Task NullValuesTest() + { + // Create template + using var templatePath = AutoDeletingPath.Create(); + var templateData = new[] + { + new { A = "Name", B = "Date", C = "VIP", D = "Points" }, + new { A = "Default", B = "2020-01-01", C = "false", D = "0" } + }; + await _exporter.ExportAsync(templatePath.ToString(), templateData); + + // Setup mapping + var registry = new MappingRegistry(); + registry.Configure(config => + { + config.Property(x => x.Name).ToCell("A3"); + config.Property(x => x.CreateDate).ToCell("B3"); + config.Property(x => x.VIP).ToCell("C3"); + config.Property(x => x.Points).ToCell("D3"); + }); + + var data = new TestEntity + { + Name = null!, // Null value + CreateDate = new DateTime(2021, 01, 01), + VIP = false, + Points = 0 + }; + + // Apply template + using var outputPath = AutoDeletingPath.Create(); + var templater = MiniExcel.Templaters.GetMappingTemplater(registry); + await templater.ApplyTemplateAsync(outputPath.ToString(), templatePath.ToString(), [data]); + + // Verify null handling + // Verify - use useHeaderRow=false since we want to see all rows + var rows = _importer.Query(outputPath.ToString(), useHeaderRow: false).ToList(); + Assert.True(string.IsNullOrEmpty(rows[2].A?.ToString())); // Null replaced the default + Assert.Equal(new DateTime(2021, 01, 01), ParseDateValue(rows[2].B)); + Assert.Equal(false, rows[2].C); + Assert.Equal(0, rows[2].D); + } + + [Fact] + public async Task MultipleItemsTest() + { + // Create template with space for multiple items + using var templatePath = AutoDeletingPath.Create(); + var templateData = new[] + { + new { A = "Name", B = "Date", C = "VIP", D = "Points" }, + new { A = "", B = "", C = "", D = "" }, + new { A = "", B = "", C = "", D = "" }, + new { A = "", B = "", C = "", D = "" } + }; + await _exporter.ExportAsync(templatePath.ToString(), templateData); + + // Setup mapping for multiple rows + var registry = new MappingRegistry(); + registry.Configure(config => + { + config.Property(x => x.Name).ToCell("A3"); + config.Property(x => x.CreateDate).ToCell("B3"); + config.Property(x => x.VIP).ToCell("C3"); + config.Property(x => x.Points).ToCell("D3"); + }); + + var data = new[] + { + new TestEntity { Name = "Jack", CreateDate = new DateTime(2021, 01, 01), VIP = true, Points = 123 }, + new TestEntity { Name = "Jane", CreateDate = new DateTime(2021, 01, 02), VIP = false, Points = 456 }, + new TestEntity { Name = "John", CreateDate = new DateTime(2021, 01, 03), VIP = true, Points = 789 } + }; + + // Apply template + using var outputPath = AutoDeletingPath.Create(); + var templater = MiniExcel.Templaters.GetMappingTemplater(registry); + await templater.ApplyTemplateAsync(outputPath.ToString(), templatePath.ToString(), data); + + // Verify - should only update first item since mapping is for specific cells + // Verify - use useHeaderRow=false since we want to see all rows + var rows = _importer.Query(outputPath.ToString(), useHeaderRow: false).ToList(); + Assert.Equal("Jack", rows[2].A); + Assert.Equal(123, rows[2].D); + + // Other rows should remain empty as we mapped to specific cells (A3, B3, etc.) + Assert.True(string.IsNullOrEmpty(rows[3].A?.ToString())); + Assert.True(string.IsNullOrEmpty(rows[4].A?.ToString())); + } +} \ No newline at end of file diff --git a/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTests.cs b/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTests.cs new file mode 100644 index 00000000..d260bef1 --- /dev/null +++ b/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTests.cs @@ -0,0 +1,1259 @@ +using System.Reflection; +using MiniExcelLib.Core.FluentMapping; + +namespace MiniExcelLib.Tests.FluentMapping +{ + public class MiniExcelMappingTests + { + #region Test Models + + public class Person + { + public string Name { get; set; } = ""; + public int Age { get; set; } + public string Email { get; set; } = ""; + public string Department { get; set; } = ""; + public DateTime BirthDate { get; set; } + public decimal Salary { get; set; } + } + + public class Product + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Category { get; set; } = ""; + public decimal Price { get; set; } + public int Stock { get; set; } + public DateTime LastRestocked { get; set; } + public bool IsActive { get; set; } + public double? DiscountPercentage { get; set; } + } + + public class ComplexEntity + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public DateTime CreatedDate { get; set; } + public double Score { get; set; } + public bool IsEnabled { get; set; } + public string? Description { get; set; } + public decimal Amount { get; set; } + public List Tags { get; set; } = []; + public int[] Numbers { get; set; } = []; + } + + public class ComplexModel + { + public Guid Id { get; set; } + public string Title { get; set; } = ""; + public DateTimeOffset CreatedAt { get; set; } + public TimeSpan Duration { get; set; } + public byte[] BinaryData { get; set; } = []; + public Uri? Website { get; set; } + } + + public class Department + { + public string Name { get; set; } = ""; + public List Managers { get; set; } = []; + public List Employees { get; set; } = []; + public List PhoneNumbers { get; set; } = []; + public string[] Tags { get; set; } = []; + public IEnumerable Projects { get; set; } = []; + } + + public class Company + { + public string Name { get; set; } = ""; + public List Departments { get; set; } = []; + } + + public class TestModel + { + public string Name { get; set; } = ""; + public int Value { get; set; } + } + + public class Project + { + public string Code { get; set; } = ""; + public string Title { get; set; } = ""; + public DateTime StartDate { get; set; } + public decimal Budget { get; set; } + public List Tasks { get; set; } = []; + } + + public class ProjectTask + { + public string Name { get; set; } = ""; + public int EstimatedHours { get; set; } + public bool IsCompleted { get; set; } + } + + public class Report + { + public string Title { get; set; } = ""; + public DateTime GeneratedAt { get; set; } + public List Numbers { get; set; } = []; + public Dictionary Metrics { get; set; } = new(); + } + + public class Address + { + public string Street { get; set; } = ""; + public string City { get; set; } = ""; + public string PostalCode { get; set; } = ""; + } + + public class NestedModel + { + public string Name { get; set; } = ""; + public Address HomeAddress { get; set; } = new(); + public Address? WorkAddress { get; set; } + } + + #endregion + + #region Basic Mapping Tests + + [Fact] + public async Task MappingReader_ReadBasicData_Success() + { + // Arrange + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Name).ToCell("A1"); + cfg.Property(p => p.Value).ToCell("B1"); + }); + + var testData = new[] { new TestModel { Name = "Test", Value = 42 } }; + using var stream = new MemoryStream(); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + await exporter.ExportAsync(stream, testData); + stream.Position = 0; + + // Act + var importer = MiniExcel.Importers.GetMappingImporter(registry); + var resultList = new List(); + await foreach (var item in importer.QueryAsync(stream)) + { + resultList.Add(item); + } + + // Assert + Assert.Single(resultList); + Assert.Equal("Test", resultList[0].Name); + Assert.Equal(42, resultList[0].Value); + } + + [Fact] + public async Task SaveAs_WithBasicMapping_ShouldGenerateCorrectFile() + { + // Arrange + var people = new[] + { + new Person { Name = "Alice", Age = 30, Email = "alice@example.com", BirthDate = new DateTime(1993, 5, 15), Salary = 75000.50m } + }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Name).ToCell("A1"); + cfg.Property(p => p.Age).ToCell("B1"); + cfg.Property(p => p.Email).ToCell("C1"); + cfg.ToWorksheet("People"); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + + // Act & Assert + using var stream = new MemoryStream(); + await exporter.ExportAsync(stream, people); + Assert.True(stream.Length > 0); + } + + [Fact] + public void SaveAs_WithBasicMapping_SyncVersion_ShouldGenerateCorrectFile() + { + // Arrange + var people = new[] + { + new Person { Name = "Bob", Age = 25, Email = "bob@example.com", BirthDate = new DateTime(1998, 8, 20), Salary = 60000.00m } + }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Name).ToCell("B2"); + cfg.Property(p => p.Age).ToCell("C2"); + cfg.Property(p => p.Email).ToCell("D2"); + cfg.ToWorksheet("Employees"); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + + // Act & Assert + using var stream = new MemoryStream(); + exporter.Export(stream, people); + Assert.True(stream.Length > 0); + } + + [Fact] + public async Task Query_WithBasicMapping_ShouldReadDataCorrectly() + { + // Arrange + var testData = new[] + { + new Person { Name = "John", Age = 35, Email = "john@test.com", BirthDate = new DateTime(1988, 3, 10), Salary = 85000m }, + new Person { Name = "Jane", Age = 28, Email = "jane@test.com", BirthDate = new DateTime(1995, 7, 22), Salary = 72000m } + }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Name).ToCell("A1"); + cfg.Property(p => p.Age).ToCell("B1"); + cfg.Property(p => p.Email).ToCell("C1"); + cfg.Property(p => p.BirthDate).ToCell("D1"); + cfg.Property(p => p.Salary).ToCell("E1"); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + var importer = MiniExcel.Importers.GetMappingImporter(registry); + + // Act + using var stream = new MemoryStream(); + await exporter.ExportAsync(stream, testData); + + stream.Position = 0; + var results = importer.Query(stream).ToList(); + + // Assert + Assert.NotNull(results); + Assert.NotEmpty(results); + } + + #endregion + + #region Sequential Mapping Tests + + [Fact] + public async Task Sequential_Mapping_Should_Optimize_Performance() + { + // Test that sequential column mappings (A1, B1, C1...) are optimized + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Id).ToCell("A1"); + cfg.Property(p => p.Name).ToCell("B1"); + cfg.Property(p => p.Price).ToCell("C1"); + cfg.Property(p => p.Stock).ToCell("D1"); + cfg.Property(p => p.IsActive).ToCell("E1"); + }); + + var mapping = registry.GetMapping(); + + // Verify optimization is applied + Assert.NotNull(mapping.OptimizedBoundaries); + Assert.NotNull(mapping.OptimizedCellGrid); + } + + [Fact] + public async Task NonSequential_Mapping_Should_Use_Universal_Optimization() + { + // Test that non-sequential mappings use universal optimization + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Id).ToCell("A1"); + cfg.Property(p => p.Name).ToCell("C1"); // Skip B + cfg.Property(p => p.Price).ToCell("E1"); // Skip D + cfg.Property(p => p.Stock).ToCell("B2"); // Different row + cfg.Property(p => p.IsActive).ToCell("D2"); + }); + + var mapping = registry.GetMapping(); + + // Verify optimization is used + Assert.NotNull(mapping.OptimizedCellGrid); + Assert.NotNull(mapping.OptimizedBoundaries); + } + + #endregion + + #region Collection Mapping Tests + + [Fact] + public async Task Collection_Vertical_Should_Write_And_Read_Correctly() + { + // Test vertical collection layout (default) + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Id).ToCell("A1"); + cfg.Property(e => e.Name).ToCell("B1"); + cfg.Collection(e => e.Tags).StartAt("C2"); // Vertical by default + }); + + var testData = new[] + { + new ComplexEntity + { + Id = 1, + Name = "Test", + Tags = ["Tag1", "Tag2", "Tag3"] + } + }; + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + var importer = MiniExcel.Importers.GetMappingImporter(registry); + + using var stream = new MemoryStream(); + await exporter.ExportAsync(stream, testData); + + stream.Position = 0; + var results = importer.Query(stream).ToList(); + + Assert.Single(results); + Assert.Equal(3, results[0].Tags.Count); + Assert.Equal("Tag1", results[0].Tags[0]); + } + + [Fact] + public async Task Collection_ComplexObjectsWithMapping_ShouldMapCorrectly() + { + // Arrange + var departments = new[] + { + new Department + { + Name = "Engineering", + Employees = + [ + new Person { Name = "Alice", Age = 35, Email = "alice@example.com", Salary = 95000 }, + new Person { Name = "Bob", Age = 28, Email = "bob@example.com", Salary = 75000 }, + new Person { Name = "Charlie", Age = 24, Email = "charlie@example.com", Salary = 55000 } + ] + } + }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(d => d.Name).ToCell("A1"); + cfg.Collection(d => d.Employees) + .StartAt("A3") + .WithItemMapping(empCfg => + { + empCfg.Property(p => p.Name).ToCell("A3"); + empCfg.Property(p => p.Age).ToCell("B3"); + empCfg.Property(p => p.Email).ToCell("C3"); + }); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + var importer = MiniExcel.Importers.GetMappingImporter(registry); + + var compiled = registry.GetMapping(); + var boundaries = compiled.OptimizedBoundaries!; + var grid = compiled.OptimizedCellGrid!; + for (var r = 0; r < grid.GetLength(0); r++) + { + for (var c = 0; c < grid.GetLength(1); c++) + { + var handler = grid[r, c]; + if (handler.Type != CellHandlerType.Empty) + { + + } + } + } + + // Act + using var stream = new MemoryStream(); + await exporter.ExportAsync(stream, departments); + stream.Position = 0; + + var results = importer.Query(stream).ToList(); + + // Assert + Assert.Single(results); + var department = results[0]; + Assert.Equal("Engineering", department.Name); + Assert.Equal(3, department.Employees.Count); + Assert.Equal("Alice", department.Employees[0].Name); + Assert.Equal(35, department.Employees[0].Age); + Assert.Equal("alice@example.com", department.Employees[0].Email); + } + + [Fact] + public async Task Collection_WithItemMappingOnly_ShouldWriteAndReadCorrectly() + { + var departments = new[] + { + new Department + { + Name = "Operations", + Managers = + [ + new Person { Name = "Ellen", Department = "Ops" }, + new Person { Name = "Scott", Department = "Ops" } + ] + } + }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(d => d.Name).ToCell("A1"); + cfg.Collection(d => d.Managers) + .StartAt("A3") + .WithItemMapping(managerCfg => + { + managerCfg.Property(p => p.Name).ToCell("A3"); + managerCfg.Property(p => p.Department).ToCell("B3"); + }); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + var importer = MiniExcel.Importers.GetMappingImporter(registry); + + using var stream = new MemoryStream(); + await exporter.ExportAsync(stream, departments); + stream.Position = 0; + + Assert.True(registry.HasMapping()); + + var compiledMappingsField = typeof(MappingRegistry).GetField("_compiledMappings", BindingFlags.NonPublic | BindingFlags.Instance); + var compiledMappings = (Dictionary)compiledMappingsField!.GetValue(registry)!; + var departmentMapping = compiledMappings[typeof(Department)]; + var nestedMappingsProp = departmentMapping.GetType().GetProperty("NestedMappings", BindingFlags.Instance | BindingFlags.Public); + var nestedMappingsObj = nestedMappingsProp?.GetValue(departmentMapping); + Assert.NotNull(nestedMappingsObj); + var countProp = nestedMappingsObj!.GetType().GetProperty("Count"); + var nestedCount = (int)(countProp?.GetValue(nestedMappingsObj) ?? 0); + Assert.True(nestedCount > 0); + + var getEnumerator = nestedMappingsObj.GetType().GetMethod("GetEnumerator"); + var enumerator = (System.Collections.IEnumerator)getEnumerator!.Invoke(nestedMappingsObj, null)!; + Assert.True(enumerator.MoveNext()); + var entry = enumerator.Current; + var valueProp = entry!.GetType().GetProperty("Value"); + var nestedInfo = valueProp!.GetValue(entry); + var propertiesProp = nestedInfo!.GetType().GetProperty("Properties", BindingFlags.Instance | BindingFlags.Public); + var nestedProperties = (System.Collections.IEnumerable?)propertiesProp?.GetValue(nestedInfo); + Assert.NotNull(nestedProperties); + + var firstProperty = nestedProperties!.Cast().FirstOrDefault(); + Assert.NotNull(firstProperty); + var setterProp = firstProperty!.GetType().GetProperty("Setter", BindingFlags.Instance | BindingFlags.Public); + var setter = setterProp?.GetValue(firstProperty); + Assert.NotNull(setter); + + var gridProp = departmentMapping.GetType().GetProperty("OptimizedCellGrid", BindingFlags.Instance | BindingFlags.Public); + var grid = gridProp?.GetValue(departmentMapping) as Array; + Assert.NotNull(grid); + var handlerType = typeof(MappingRegistry).Assembly.GetType("MiniExcelLib.Core.FluentMapping.OptimizedCellHandler"); + Assert.NotNull(handlerType); + var valueSetterProperty = handlerType!.GetProperty("ValueSetter", BindingFlags.Instance | BindingFlags.Public); + var propertyNameProperty = handlerType.GetProperty("PropertyName", BindingFlags.Instance | BindingFlags.Public); + var collectionIndexProperty = handlerType.GetProperty("CollectionIndex", BindingFlags.Instance | BindingFlags.Public); + + var hasSetter = false; + for (int r = 0; r < grid!.GetLength(0); r++) + { + for (int c = 0; c < grid.GetLength(1); c++) + { + if (grid.GetValue(r, c) is { } handler) + { + var propertyName = propertyNameProperty?.GetValue(handler) as string; + var collectionIndex = (int)(collectionIndexProperty?.GetValue(handler) ?? -1); + if (string.Equals(propertyName, "Name", StringComparison.Ordinal) && collectionIndex == 0) + { + var valueSetter = valueSetterProperty?.GetValue(handler); + if (valueSetter is not null) + { + hasSetter = true; + break; + } + } + } + } + if (hasSetter) + { + break; + } + } + + Assert.True(hasSetter); + + var boundariesProp = departmentMapping.GetType().GetProperty("OptimizedBoundaries", BindingFlags.Instance | BindingFlags.Public); + var boundaries = boundariesProp?.GetValue(departmentMapping); + Assert.NotNull(boundaries); + var minRowProp = boundaries!.GetType().GetProperty("MinRow", BindingFlags.Instance | BindingFlags.Public); + var maxRowProp = boundaries.GetType().GetProperty("MaxRow", BindingFlags.Instance | BindingFlags.Public); + var minColProp = boundaries.GetType().GetProperty("MinColumn", BindingFlags.Instance | BindingFlags.Public); + var maxColProp = boundaries.GetType().GetProperty("MaxColumn", BindingFlags.Instance | BindingFlags.Public); + var minRow = (int)(minRowProp?.GetValue(boundaries) ?? 0); + var maxRow = (int)(maxRowProp?.GetValue(boundaries) ?? 0); + var minCol = (int)(minColProp?.GetValue(boundaries) ?? 0); + var maxCol = (int)(maxColProp?.GetValue(boundaries) ?? 0); + + var tryGetHandlerMethod = departmentMapping.GetType().GetMethod("TryGetHandler", BindingFlags.Instance | BindingFlags.Public); + Assert.NotNull(tryGetHandlerMethod); + + var setterFoundViaTryGet = false; + for (var row = minRow; row <= maxRow && !setterFoundViaTryGet; row++) + { + for (var col = minCol; col <= maxCol && !setterFoundViaTryGet; col++) + { + var parameters = new object?[] { row, col, null }; + var success = (bool)tryGetHandlerMethod!.Invoke(departmentMapping, parameters)!; + if (!success) + continue; + + if (parameters[2] is { } handlerInstance) + { + var propertyName = propertyNameProperty?.GetValue(handlerInstance) as string; + var collectionIndex = (int)(collectionIndexProperty?.GetValue(handlerInstance) ?? -1); + if (collectionIndex == 0 && string.Equals(propertyName, "Name", StringComparison.Ordinal)) + { + var valueSetter = valueSetterProperty?.GetValue(handlerInstance); + if (valueSetter is not null) + { + setterFoundViaTryGet = true; + } + } + } + } + } + Assert.True(setterFoundViaTryGet); + + var results = importer.Query(stream).ToList(); + + Assert.Single(results); + var managers = results[0].Managers; + Assert.Equal(2, managers.Count); + Assert.Equal("Ellen", managers[0].Name); + Assert.Equal("Scott", managers[1].Name); + } + + [Fact] + public async Task Collection_NestedCollections_ShouldMapCorrectly() + { + // Arrange + var departments = new[] + { + new Department + { + Name = "Product Development", + Projects = new List + { + new Project + { + Code = "PROJ-001", + Title = "New Feature", + StartDate = new DateTime(2024, 1, 1), + Budget = 100000, + Tasks = + [ + new ProjectTask { Name = "Design", EstimatedHours = 40, IsCompleted = true }, + new ProjectTask { Name = "Implementation", EstimatedHours = 120, IsCompleted = false }, + new ProjectTask { Name = "Testing", EstimatedHours = 60, IsCompleted = false } + ] + } + } + } + }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(d => d.Name).ToCell("A1"); + cfg.Collection(d => d.Projects) + .StartAt("A3") + .WithItemMapping(projectCfg => + { + projectCfg.Property(p => p.Code).ToCell("A3"); + projectCfg.Property(p => p.Title).ToCell("B3"); + projectCfg.Property(p => p.StartDate).ToCell("C3"); + projectCfg.Collection(p => p.Tasks) + .StartAt("D3") + .WithItemMapping(taskCfg => + { + taskCfg.Property(t => t.Name).ToCell("D3"); + taskCfg.Property(t => t.EstimatedHours).ToCell("E3"); + taskCfg.Property(t => t.IsCompleted).ToCell("F3"); + }); + }); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + var importer = MiniExcel.Importers.GetMappingImporter(registry); + + // Act + using var stream = new MemoryStream(); + await exporter.ExportAsync(stream, departments); + + stream.Position = 0; + + var results = importer.Query(stream).ToList(); + + // Assert + Assert.Single(results); + var department = results[0]; + Assert.Equal("Product Development", department.Name); + + var projects = department.Projects.ToList(); + Assert.Single(projects); + + var project = projects[0]; + Assert.Equal("PROJ-001", project.Code); + Assert.Equal("New Feature", project.Title); + + Assert.Equal(3, project.Tasks.Count); + Assert.Equal("Design", project.Tasks[0].Name); + Assert.True(project.Tasks[0].IsCompleted); + Assert.Equal(120, project.Tasks[1].EstimatedHours); + } + + [Fact] + public void Collection_WithoutStartCell_ShouldThrowException() + { + // Arrange + var registry = new MappingRegistry(); + + // Act & Assert + var exception = Assert.Throws(() => + { + registry.Configure(cfg => + { + cfg.Collection(d => d.PhoneNumbers); // Missing StartAt() + }); + }); + + Assert.Contains("start cell", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Collection_MixedSimpleAndComplex_ShouldMapCorrectly() + { + // Arrange + var department = new Department + { + Name = "Mixed Department", + PhoneNumbers = ["555-1111", "555-2222"], + Employees = + [ + new Person { Name = "Dave", Age = 35, Email = "dave@example.com", Salary = 85000 }, + new Person { Name = "Eve", Age = 29, Email = "eve@example.com", Salary = 75000 } + ] + }; + + var departments = new[] { department }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(d => d.Name).ToCell("A1"); + cfg.Collection(d => d.PhoneNumbers).StartAt("A3"); + cfg.Collection(d => d.Employees) + .StartAt("C3") + .WithItemMapping(x => + { + x.Property(e => e.Name).ToCell("C3"); + x.Property(e => e.Age).ToCell("D3"); + x.Property(e => e.Salary).ToCell("E3"); + x.Property(e => e.Email).ToCell("F3"); + }); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + var importer = MiniExcel.Importers.GetMappingImporter(registry); + + // Act + using var stream = new MemoryStream(); + await exporter.ExportAsync(stream, departments); + stream.Seek(0, SeekOrigin.Begin); + + // Assert + var results = importer.Query(stream).ToList(); + var first = results[0]; + + Assert.Equal("555-1111", first.PhoneNumbers[0]); + Assert.Equal("555-2222", first.PhoneNumbers[1]); + + Assert.Equal("Dave", first.Employees[0].Name); + Assert.Equal(35, first.Employees[0].Age); + Assert.Equal(85000, first.Employees[0].Salary); + Assert.Equal("dave@example.com", first.Employees[0].Email); + + Assert.Equal("Eve", first.Employees[1].Name); + Assert.Equal(29, first.Employees[1].Age); + Assert.Equal(75000, first.Employees[1].Salary); + Assert.Equal("eve@example.com", first.Employees[1].Email); + } + + #endregion + + #region Complex Type and Formula Tests + + [Fact] + public async Task Formula_Properties_Should_Be_Handled_Correctly() + { + // Test formula support + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Id).ToCell("A1"); + cfg.Property(p => p.Price).ToCell("B1"); + cfg.Property(p => p.Stock).ToCell("C1"); + cfg.Property(p => p.Price).ToCell("D1").WithFormula("=B1*C1"); // Total value formula + }); + + var testData = new[] + { + new Product { Id = 1, Price = 10.50m, Stock = 100 } + }; + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + + using var stream = new MemoryStream(); + await exporter.ExportAsync(stream, testData); + Assert.True(stream.Length > 0); + } + + [Fact] + public async Task Format_Properties_Should_Apply_Formatting() + { + // Test format support + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Name).ToCell("A1"); + cfg.Property(p => p.BirthDate).ToCell("B1").WithFormat("yyyy-MM-dd"); + cfg.Property(p => p.Salary).ToCell("C1").WithFormat("#,##0.00"); + }); + + var testData = new[] + { + new Person + { + Name = "Test", + BirthDate = new DateTime(1990, 6, 15), + Salary = 12345.67m + } + }; + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + + using var stream = new MemoryStream(); + await exporter.ExportAsync(stream, testData); + Assert.True(stream.Length > 0); + } + + #endregion + + #region Extended Mapping Tests + + [Fact] + public async Task Mapping_WithComplexCellAddresses_ShouldMapCorrectly() + { + // Test various cell address formats + var products = new[] + { + new Product { Id = 1, Name = "Laptop", Price = 999.99m, Stock = 10 } + }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Id).ToCell("AA1"); + cfg.Property(p => p.Name).ToCell("AB1"); + cfg.Property(p => p.Price).ToCell("AC1"); + cfg.Property(p => p.Stock).ToCell("ZZ1"); + cfg.ToWorksheet("Products"); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + using var stream = new MemoryStream(); + await exporter.ExportAsync(stream, products); + + // Verify the file was created + Assert.True(stream.Length > 0); + + // Read back and verify + stream.Position = 0; + var importer = MiniExcel.Importers.GetOpenXmlImporter(); + var data = importer.Query(stream); + var firstRow = data.FirstOrDefault(); + Assert.NotNull(firstRow); + } + + [Fact] + public async Task Mapping_WithNumericFormats_ShouldApplyCorrectly() + { + var products = new[] + { + new Product + { + Id = 1, + Name = "Widget", + Price = 1234.5678m, + DiscountPercentage = 0.15 + } + }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Name).ToCell("A1"); + cfg.Property(p => p.Price).ToCell("B1").WithFormat("$#,##0.00"); + cfg.Property(p => p.DiscountPercentage).ToCell("C1").WithFormat("0.00%"); + cfg.ToWorksheet("Formatted"); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + using var stream = new MemoryStream(); + await exporter.ExportAsync(stream, products); + + Assert.True(stream.Length > 0); + } + + [Fact] + public async Task Mapping_WithDateFormats_ShouldApplyCorrectly() + { + var products = new[] + { + new Product + { + Name = "Item", + LastRestocked = new DateTime(2024, 3, 15, 14, 30, 0) + } + }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Name).ToCell("A1"); + cfg.Property(p => p.LastRestocked).ToCell("B1").WithFormat("yyyy-MM-dd"); + cfg.Property(p => p.LastRestocked).ToCell("C1").WithFormat("MM/dd/yyyy hh:mm:ss"); + cfg.Property(p => p.LastRestocked).ToCell("D1").WithFormat("dddd, MMMM d, yyyy"); + cfg.ToWorksheet("DateFormats"); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + using var stream = new MemoryStream(); + await exporter.ExportAsync(stream, products); + + Assert.True(stream.Length > 0); + } + + [Fact] + public async Task Mapping_WithBooleanValues_ShouldMapCorrectly() + { + var products = new[] + { + new Product { Name = "Active", IsActive = true }, + new Product { Name = "Inactive", IsActive = false } + }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Name).ToCell("A1"); + cfg.Property(p => p.IsActive).ToCell("B1"); + cfg.ToWorksheet("Booleans"); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + using var stream = new MemoryStream(); + await exporter.ExportAsync(stream, products); + + Assert.True(stream.Length > 0); + } + + [Fact] + public async Task Mapping_WithMultipleRowsToSameCells_ShouldOverwrite() + { + // When mapping multiple items to the same cells, last one should win + var products = new[] + { + new Product { Id = 1, Name = "First" }, + new Product { Id = 2, Name = "Second" }, + new Product { Id = 3, Name = "Third" } + }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Id).ToCell("A1"); + cfg.Property(p => p.Name).ToCell("B1"); + cfg.ToWorksheet("Overwrite"); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + using var stream = new MemoryStream(); + await exporter.ExportAsync(stream, products); + + // The file should contain only the last item's data + Assert.True(stream.Length > 0); + } + + [Fact] + public async Task Mapping_WithComplexTypes_ShouldHandleCorrectly() + { + var items = new[] + { + new ComplexModel + { + Id = Guid.NewGuid(), + Title = "Complex Item", + CreatedAt = DateTimeOffset.Now, + Duration = TimeSpan.FromHours(2.5), + BinaryData = [1, 2, 3, 4, 5], + Website = new Uri("https://example.com") + } + }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Id).ToCell("A1"); + cfg.Property(p => p.Title).ToCell("B1"); + cfg.Property(p => p.CreatedAt).ToCell("C1").WithFormat("yyyy-MM-dd HH:mm:ss"); + cfg.Property(p => p.Duration).ToCell("D1"); + cfg.Property(p => p.Website).ToCell("E1"); + cfg.ToWorksheet("ComplexTypes"); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + using var stream = new MemoryStream(); + await exporter.ExportAsync(stream, items); + + Assert.True(stream.Length > 0); + } + + [Fact] + public async Task Mapping_WithMultipleConfigurations_ShouldUseLast() + { + var products = new[] + { + new Product { Id = 1, Name = "Test" } + }; + + var registry = new MappingRegistry(); + + // First configuration + registry.Configure(cfg => + { + cfg.Property(p => p.Id).ToCell("A1"); + cfg.Property(p => p.Name).ToCell("B1"); + cfg.ToWorksheet("First"); + }); + + // Second configuration should override + registry.Configure(cfg => + { + cfg.Property(p => p.Id).ToCell("X1"); + cfg.Property(p => p.Name).ToCell("Y1"); + cfg.ToWorksheet("Second"); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + using var stream = new MemoryStream(); + await exporter.ExportAsync(stream, products); + + Assert.True(stream.Length > 0); + } + + [Fact] + public void Mapping_WithInvalidCellAddress_ShouldThrowException() + { + var registry = new MappingRegistry(); + + // Test various invalid cell addresses + var invalidAddresses = new[] { "", " ", "123", "A", "1A", "@1" }; + + foreach (var invalidAddress in invalidAddresses) + { + Assert.Throws(() => + { + registry.Configure(cfg => + { + cfg.Property(p => p.Name).ToCell(invalidAddress); + }); + }); + } + } + + [Fact] + public async Task Mapping_WithEnumerableTypes_ShouldHandleCorrectly() + { + // Test with IEnumerable, List, Array, etc. + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Name).ToCell("A1"); + cfg.Property(p => p.Price).ToCell("B1"); + cfg.ToWorksheet("Enumerable"); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + + // Test with array + var array = new[] { new Product { Name = "Array", Price = 10 } }; + using (var stream = new MemoryStream()) + { + await exporter.ExportAsync(stream, array); + Assert.True(stream.Length > 0); + } + + // Test with List + var list = new List { new Product { Name = "List", Price = 20 } }; + using (var stream = new MemoryStream()) + { + await exporter.ExportAsync(stream, list); + Assert.True(stream.Length > 0); + } + + // Test with IEnumerable + IEnumerable enumerable = list; + using (var stream = new MemoryStream()) + { + await exporter.ExportAsync(stream, enumerable); + Assert.True(stream.Length > 0); + } + } + + [Fact] + public async Task Mapping_WithThreadSafety_ShouldWork() + { + var registry = new MappingRegistry(); + var tasks = new List(); + + // Configure multiple types concurrently + for (int i = 0; i < 10; i++) + { + var index = i; + tasks.Add(Task.Run(() => + { + if (index % 2 == 0) + { + registry.Configure(cfg => + { + cfg.Property(p => p.Name).ToCell("A1"); + cfg.ToWorksheet($"Sheet{index}"); + }); + } + else + { + registry.Configure(cfg => + { + cfg.Property(p => p.Title).ToCell("A1"); + cfg.ToWorksheet($"Sheet{index}"); + }); + } + })); + } + + await Task.WhenAll(tasks); + + // Verify both configurations exist + Assert.True(registry.HasMapping()); + Assert.True(registry.HasMapping()); + } + + [Fact] + public async Task Mapping_WithSaveToFile_ShouldCreateFile() + { + var products = new[] + { + new Product { Id = 1, Name = "FileTest", Price = 99.99m } + }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Id).ToCell("A1"); + cfg.Property(p => p.Name).ToCell("B1"); + cfg.Property(p => p.Price).ToCell("C1").WithFormat("$#,##0.00"); + cfg.ToWorksheet("FileOutput"); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + + var filePath = Path.GetTempFileName() + ".xlsx"; + try + { + using (var stream = File.Create(filePath)) + { + await exporter.ExportAsync(stream, products); + } + + // Verify file exists and has content + Assert.True(File.Exists(filePath)); + Assert.True(new FileInfo(filePath).Length > 0); + } + finally + { + if (File.Exists(filePath)) + File.Delete(filePath); + } + } + + #endregion + + #region Edge Cases and Error Handling + + [Fact] + public void Configuration_Without_Cell_Should_Throw() + { + var registry = new MappingRegistry(); + + Assert.Throws(() => + { + registry.Configure(cfg => + { + cfg.Property(p => p.Name); // Missing ToCell() + }); + + var mapping = registry.GetMapping(); + }); + } + + [Fact] + public async Task Empty_Collection_Should_Handle_Gracefully() + { + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Id).ToCell("A1"); + cfg.Collection(e => e.Tags).StartAt("B1"); + }); + + var testData = new[] + { + new ComplexEntity { Id = 1, Tags = [] } // Empty collection + }; + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + + using var stream = new MemoryStream(); + await exporter.ExportAsync(stream, testData); + Assert.True(stream.Length > 0); + } + + [Fact] + public async Task Null_Values_Should_Handle_Gracefully() + { + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Id).ToCell("A1"); + cfg.Property(e => e.Name).ToCell("B1"); + cfg.Property(e => e.Description).ToCell("C1"); + }); + + var testData = new[] + { + new ComplexEntity + { + Id = 1, + Name = null!, // Null value + Description = null // Nullable property + } + }; + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + + using var stream = new MemoryStream(); + await exporter.ExportAsync(stream, testData); + Assert.True(stream.Length > 0); + } + + #endregion + + #region Performance and Optimization Tests + + [Fact] + public void Universal_Optimization_Should_Create_Cell_Grid() + { + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Id).ToCell("A1"); + cfg.Property(p => p.Name).ToCell("C1"); + cfg.Property(p => p.Price).ToCell("E2"); + }); + + var mapping = registry.GetMapping(); + Assert.NotNull(mapping.OptimizedCellGrid); + Assert.NotNull(mapping.OptimizedBoundaries); + + // Verify grid dimensions + var boundaries = mapping.OptimizedBoundaries; + Assert.Equal(1, boundaries.MinRow); + Assert.Equal(2, boundaries.MaxRow); + Assert.Equal(1, boundaries.MinColumn); // A + Assert.Equal(5, boundaries.MaxColumn); // E + } + + [Fact] + public async Task Large_Dataset_Should_Stream_Efficiently() + { + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Id).ToCell("A1"); + cfg.Property(p => p.Name).ToCell("B1"); + cfg.Property(p => p.Price).ToCell("C1"); + }); + + // Generate large dataset + var testData = Enumerable.Range(1, 10000).Select(i => new Product + { + Id = i, + Name = $"Product {i}", + Price = i * 10.5m + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + + using var stream = new MemoryStream(); + await exporter.ExportAsync(stream, testData); + + // Should complete without OutOfMemory + Assert.True(stream.Length > 0); + } + + #endregion + + #region Multiple Items and Pattern Tests + + [Fact] + public void Multiple_Items_With_Collections_Should_Detect_Pattern() + { + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Id).ToCell("A1"); + cfg.Property(e => e.Name).ToCell("B1"); + cfg.Collection(e => e.Tags).StartAt("A2"); + }); + + var mapping = registry.GetMapping(); + + if (mapping.Collections.Any()) + { + var boundaries = mapping.OptimizedBoundaries; + // Pattern detection for multiple items + Assert.True(boundaries.PatternHeight > 0 || !boundaries.IsMultiItemPattern); + } + } + + #endregion + } +} \ No newline at end of file diff --git a/tests/MiniExcel.Core.Tests/MiniExcelOpenXmlTests.cs b/tests/MiniExcel.Core.Tests/MiniExcelOpenXmlTests.cs index d6708f35..64bd1e82 100644 --- a/tests/MiniExcel.Core.Tests/MiniExcelOpenXmlTests.cs +++ b/tests/MiniExcel.Core.Tests/MiniExcelOpenXmlTests.cs @@ -1,8 +1,5 @@ -using System.Drawing; -using ClosedXML.Excel; +using ClosedXML.Excel; using ExcelDataReader; -using MiniExcelLib.Core.Enums; -using MiniExcelLib.Core.OpenXml.Styles; using MiniExcelLib.Core.OpenXml.Utils; using MiniExcelLib.Tests.Common.Utils; diff --git a/tests/MiniExcel.Csv.Tests/IssueTests.cs b/tests/MiniExcel.Csv.Tests/IssueTests.cs index 2dfb55be..4826bf5b 100644 --- a/tests/MiniExcel.Csv.Tests/IssueTests.cs +++ b/tests/MiniExcel.Csv.Tests/IssueTests.cs @@ -1008,5 +1008,4 @@ public void Issue507_3_MismatchedQuoteCsv() var getRowsInfo = _csvImporter.Query(stream, configuration: config).ToArray(); Assert.Equal(2, getRowsInfo.Length); } - }