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

-#### 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:
-
-
-
-
-
-File content before and after merge with merge limit:
-
-
-
-
-
-#### 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:
-
+
After:
-
+
- With `@group` tag and without `@header` tag
@@ -1001,19 +981,19 @@ Before:

-After;
+After:

-- Without `@group` tag
+- With both `@group` and `@header` tags
Before:
-
+
After:
-
+
#### 7. If/ElseIf/Else Statements inside cell
@@ -1043,7 +1023,31 @@ After:

-#### 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:
+
+
+
+
+
+File content before and after merge with merge limit:
+
+
+
+
+
+
+#### 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:

@@ -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