From 7ca321e369d1bfc2ab15b7fcd6036b353ad10185 Mon Sep 17 00:00:00 2001 From: Corey Kaylor Date: Tue, 5 Aug 2025 13:44:09 -0600 Subject: [PATCH 01/16] Draft: fluent-mapping support --- src/MiniExcel.Core/GlobalUsings.cs | 1 + .../Helpers/ConversionHelper.cs | 192 +++ src/MiniExcel.Core/Helpers/DictionaryPool.cs | 41 + src/MiniExcel.Core/Mapping/CompiledMapping.cs | 239 ++++ .../Configuration/CollectionMappingBuilder.cs | 47 + .../ICollectionMappingBuilder.cs | 10 + .../Configuration/IMappingConfiguration.cs | 10 + .../Configuration/IPropertyMappingBuilder.cs | 8 + .../Configuration/MappingConfiguration.cs | 71 ++ .../Configuration/PropertyMappingBuilder.cs | 36 + src/MiniExcel.Core/Mapping/MappingCompiler.cs | 809 +++++++++++++ src/MiniExcel.Core/Mapping/MappingExporter.cs | 35 + src/MiniExcel.Core/Mapping/MappingImporter.cs | 57 + src/MiniExcel.Core/Mapping/MappingReader.cs | 1070 +++++++++++++++++ src/MiniExcel.Core/Mapping/MappingRegistry.cs | 70 ++ src/MiniExcel.Core/Mapping/MappingWriter.cs | 965 +++++++++++++++ .../Mapping/OptimizedMappingExecutor.cs | 146 +++ src/MiniExcel.Core/MiniExcelProviders.cs | 4 + .../MiniExcelMappingCompilerTests.cs | 349 ++++++ .../MiniExcelMappingTests.cs | 1033 ++++++++++++++++ 20 files changed, 5193 insertions(+) create mode 100644 src/MiniExcel.Core/Helpers/ConversionHelper.cs create mode 100644 src/MiniExcel.Core/Helpers/DictionaryPool.cs create mode 100644 src/MiniExcel.Core/Mapping/CompiledMapping.cs create mode 100644 src/MiniExcel.Core/Mapping/Configuration/CollectionMappingBuilder.cs create mode 100644 src/MiniExcel.Core/Mapping/Configuration/ICollectionMappingBuilder.cs create mode 100644 src/MiniExcel.Core/Mapping/Configuration/IMappingConfiguration.cs create mode 100644 src/MiniExcel.Core/Mapping/Configuration/IPropertyMappingBuilder.cs create mode 100644 src/MiniExcel.Core/Mapping/Configuration/MappingConfiguration.cs create mode 100644 src/MiniExcel.Core/Mapping/Configuration/PropertyMappingBuilder.cs create mode 100644 src/MiniExcel.Core/Mapping/MappingCompiler.cs create mode 100644 src/MiniExcel.Core/Mapping/MappingExporter.cs create mode 100644 src/MiniExcel.Core/Mapping/MappingImporter.cs create mode 100644 src/MiniExcel.Core/Mapping/MappingReader.cs create mode 100644 src/MiniExcel.Core/Mapping/MappingRegistry.cs create mode 100644 src/MiniExcel.Core/Mapping/MappingWriter.cs create mode 100644 src/MiniExcel.Core/Mapping/OptimizedMappingExecutor.cs create mode 100644 tests/MiniExcel.Core.Tests/MiniExcelMappingCompilerTests.cs create mode 100644 tests/MiniExcel.Core.Tests/MiniExcelMappingTests.cs diff --git a/src/MiniExcel.Core/GlobalUsings.cs b/src/MiniExcel.Core/GlobalUsings.cs index 05f12759..fec482eb 100644 --- a/src/MiniExcel.Core/GlobalUsings.cs +++ b/src/MiniExcel.Core/GlobalUsings.cs @@ -11,6 +11,7 @@ global using System.Xml; global using MiniExcelLib.Core.Abstractions; global using MiniExcelLib.Core.Helpers; +global using MiniExcelLib.Core.Mapping; global using MiniExcelLib.Core.OpenXml; global using MiniExcelLib.Core.OpenXml.Utils; global using MiniExcelLib.Core.Reflection; diff --git a/src/MiniExcel.Core/Helpers/ConversionHelper.cs b/src/MiniExcel.Core/Helpers/ConversionHelper.cs new file mode 100644 index 00000000..8549c50b --- /dev/null +++ b/src/MiniExcel.Core/Helpers/ConversionHelper.cs @@ -0,0 +1,192 @@ +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 = (string)value; + 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 = (string)value; + 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 = (string)value; + if (string.IsNullOrWhiteSpace(str)) + return isNullable ? null : 0.0; + return double.TryParse(str, NumberStyles.Any, CultureInfo.InvariantCulture, out var result) + ? result : (isNullable ? null : 0.0); + }; + } + + if (targetType == typeof(decimal)) + { + return value => + { + var str = (string)value; + 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 = (string)value; + 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 = (string)value; + 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(Guid)) + { + return value => + { + var str = (string)value; + 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 + return value => ConvertValueFallback(value, isNullable ? typeof(Nullable<>).MakeGenericType(targetType) : targetType); + } + + private static object? ConvertValueFallback(object value, Type targetType) + { + try + { + var underlyingType = Nullable.GetUnderlyingType(targetType); + if (underlyingType != null) + { + if (value is string str && string.IsNullOrWhiteSpace(str)) + return null; + + return Convert.ChangeType(value, underlyingType, CultureInfo.InvariantCulture); + } + + return Convert.ChangeType(value, targetType, CultureInfo.InvariantCulture); + } + catch + { + // Last resort: return default value + return targetType.IsValueType ? Activator.CreateInstance(targetType) : null; + } + } + + /// + /// 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/DictionaryPool.cs b/src/MiniExcel.Core/Helpers/DictionaryPool.cs new file mode 100644 index 00000000..29ec9272 --- /dev/null +++ b/src/MiniExcel.Core/Helpers/DictionaryPool.cs @@ -0,0 +1,41 @@ +using System.Collections.Concurrent; + +namespace MiniExcelLib.Core.Helpers; + +/// +/// Simple object pool for dictionaries to reduce allocations +/// +internal static class DictionaryPool +{ + // Simple ConcurrentBag-based pool for .NET Standard + private static readonly ConcurrentBag> Pool = new(); + private static int _poolSize; + private const int MaxPoolSize = 100; + + public static Dictionary Rent() + { + if (Pool.TryTake(out var dictionary)) + { + Interlocked.Decrement(ref _poolSize); + return dictionary; + } + + return new Dictionary(16); // Pre-size for typical row + } + + public static void Return(Dictionary dictionary) + { + // Don't pool huge dictionaries + if (dictionary.Count > 1000) + return; + + dictionary.Clear(); + + // Limit pool size to prevent unbounded growth + if (_poolSize < MaxPoolSize) + { + Pool.Add(dictionary); + Interlocked.Increment(ref _poolSize); + } + } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/Mapping/CompiledMapping.cs b/src/MiniExcel.Core/Mapping/CompiledMapping.cs new file mode 100644 index 00000000..1129a8d2 --- /dev/null +++ b/src/MiniExcel.Core/Mapping/CompiledMapping.cs @@ -0,0 +1,239 @@ +namespace MiniExcelLib.Core.Mapping; + +public class CompiledMapping +{ + public string WorksheetName { get; set; } = "Sheet1"; + public IReadOnlyList Properties { get; set; } = new List(); + public IReadOnlyList Collections { get; set; } = new List(); + + // Universal 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; } + + /// + /// Collection expansion strategies for handling dynamic collections + /// + public IReadOnlyList? CollectionExpansions { get; set; } + + /// + /// Whether this mapping has been optimized with the universal optimization system + /// + public bool IsUniversallyOptimized => OptimizedCellGrid != null && OptimizedBoundaries != null; + + /// + /// Pre-compiled collection helpers for fast collection handling + /// + public IReadOnlyList? OptimizedCollectionHelpers { get; set; } +} + +/// +/// Pre-compiled helpers for collection handling +/// +public 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 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; } +} + +public class CompiledCollectionMapping +{ + public Func Getter { get; set; } = null!; + public string StartCell { 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; } = 0; + public object? ItemMapping { get; set; } // CompiledMapping + 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. +/// +public enum CollectionLayout +{ + /// Collections expand vertically (downward in rows) + Vertical = 0, + + /// Collections expand horizontally (rightward in columns) - DEPRECATED + [Obsolete("Horizontal collections are no longer supported. Use Vertical layout instead.")] + Horizontal = 1, + + /// Collections expand in a grid pattern - DEPRECATED + [Obsolete("Grid collections are no longer supported. Use Vertical layout instead.")] + Grid = 2 +} + + +/// +/// Represents the type of data a cell contains in the mapping +/// +public 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. +/// +public 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; } = 0; + + /// 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 collections: pre-compiled factory to create the collection instance + public Func? CollectionFactory { get; set; } + + /// For collections: pre-compiled converter from list to final type (e.g., array) + public Func? CollectionFinalizer { get; set; } + + /// For collections: whether the target type is an array (vs list) + public bool IsArrayTarget { 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; } = 0; + + /// + /// 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 +/// +public 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; } = 0; + + /// 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; } = 0; + + /// 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; + + /// Total number of items this mapping can handle (based on collection layouts) + public int MaxItemCapacity { get; set; } = 1; + + /// 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; } = 0; + + /// + /// 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; } = false; +} + +/// +/// Collection expansion strategy - how to handle collections with unknown sizes +/// +public class CollectionExpansionInfo +{ + /// Starting row for expansion + public int StartRow { get; set; } + + /// Starting column for expansion + public int StartColumn { get; set; } + + /// Layout direction for expansion + public CollectionLayout Layout { get; set; } + + /// Row spacing between items + public int RowSpacing { get; set; } + + /// Collection mapping this expansion belongs to + public CompiledCollectionMapping CollectionMapping { get; set; } = null!; +} \ No newline at end of file diff --git a/src/MiniExcel.Core/Mapping/Configuration/CollectionMappingBuilder.cs b/src/MiniExcel.Core/Mapping/Configuration/CollectionMappingBuilder.cs new file mode 100644 index 00000000..3d6191c4 --- /dev/null +++ b/src/MiniExcel.Core/Mapping/Configuration/CollectionMappingBuilder.cs @@ -0,0 +1,47 @@ +namespace MiniExcelLib.Core.Mapping.Configuration; + +internal class CollectionMappingBuilder : ICollectionMappingBuilder where TCollection : IEnumerable +{ + 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 (!Regex.IsMatch(cellAddress, @"^[A-Z]+[0-9]+$")) + 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 == null) + throw new ArgumentNullException(nameof(configure)); + + var itemConfig = new MappingConfiguration(); + configure(itemConfig); + _mapping.ItemConfiguration = itemConfig as MappingConfiguration; + _mapping.ItemType = typeof(TItem); + return this; + } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/Mapping/Configuration/ICollectionMappingBuilder.cs b/src/MiniExcel.Core/Mapping/Configuration/ICollectionMappingBuilder.cs new file mode 100644 index 00000000..6e29b9b2 --- /dev/null +++ b/src/MiniExcel.Core/Mapping/Configuration/ICollectionMappingBuilder.cs @@ -0,0 +1,10 @@ +namespace MiniExcelLib.Core.Mapping.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/Mapping/Configuration/IMappingConfiguration.cs b/src/MiniExcel.Core/Mapping/Configuration/IMappingConfiguration.cs new file mode 100644 index 00000000..f7d9ca41 --- /dev/null +++ b/src/MiniExcel.Core/Mapping/Configuration/IMappingConfiguration.cs @@ -0,0 +1,10 @@ +using System.Linq.Expressions; + +namespace MiniExcelLib.Core.Mapping.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/Mapping/Configuration/IPropertyMappingBuilder.cs b/src/MiniExcel.Core/Mapping/Configuration/IPropertyMappingBuilder.cs new file mode 100644 index 00000000..04072926 --- /dev/null +++ b/src/MiniExcel.Core/Mapping/Configuration/IPropertyMappingBuilder.cs @@ -0,0 +1,8 @@ +namespace MiniExcelLib.Core.Mapping.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/Mapping/Configuration/MappingConfiguration.cs b/src/MiniExcel.Core/Mapping/Configuration/MappingConfiguration.cs new file mode 100644 index 00000000..94fbf803 --- /dev/null +++ b/src/MiniExcel.Core/Mapping/Configuration/MappingConfiguration.cs @@ -0,0 +1,71 @@ +using System.Linq.Expressions; + +namespace MiniExcelLib.Core.Mapping.Configuration; + +internal class MappingConfiguration : IMappingConfiguration +{ + internal readonly List PropertyMappings = new(); + internal readonly List CollectionMappings = new(); + internal string? WorksheetName { get; private set; } + + public IPropertyMappingBuilder Property( + Expression> property) + { + if (property == 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 == 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 MappingConfiguration? ItemConfiguration { get; set; } + public Type? ItemType { get; set; } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/Mapping/Configuration/PropertyMappingBuilder.cs b/src/MiniExcel.Core/Mapping/Configuration/PropertyMappingBuilder.cs new file mode 100644 index 00000000..c0a51bab --- /dev/null +++ b/src/MiniExcel.Core/Mapping/Configuration/PropertyMappingBuilder.cs @@ -0,0 +1,36 @@ +namespace MiniExcelLib.Core.Mapping.Configuration; + +internal class PropertyMappingBuilder : IPropertyMappingBuilder +{ + 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 (!Regex.IsMatch(cellAddress, @"^[A-Z]+[0-9]+$")) + 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/Mapping/MappingCompiler.cs b/src/MiniExcel.Core/Mapping/MappingCompiler.cs new file mode 100644 index 00000000..cd61a675 --- /dev/null +++ b/src/MiniExcel.Core/Mapping/MappingCompiler.cs @@ -0,0 +1,809 @@ +using System.Collections; +using System.Linq.Expressions; +using System.Reflection; +using MiniExcelLib.Core.Mapping.Configuration; +using MiniExcelLib.Core.OpenXml.Utils; + +namespace MiniExcelLib.Core.Mapping; + +/// +/// 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 DefaultCollectionWidth = 100; + private const int DefaultGridSize = 10; + private const int MaxItemsToMarkInGrid = 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 == 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 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(); + + // Extract property name from expression + var propertyName = GetPropertyName(prop.Expression); + + // Create setter + var setterParam = Expression.Parameter(typeof(object), "obj"); + var valueParam = Expression.Parameter(typeof(object), "value"); + var castObj = Expression.Convert(setterParam, typeof(T)); + var castValue = Expression.Convert(valueParam, prop.PropertyType); + + // Create assignment if it's a member expression + Action? setter = null; + if (prop.Expression.Body is MemberExpression memberExpr && memberExpr.Member is PropertyInfo propInfo) + { + var assign = Expression.Assign(Expression.Property(castObj, propInfo), castValue); + var setterLambda = Expression.Lambda>(assign, setterParam, valueParam); + setter = setterLambda.Compile(); + } + + // Pre-parse cell coordinates for runtime performance + 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 + var collectionType = coll.PropertyType; + Type? itemType = null; + + if (collectionType.IsArray) + { + itemType = collectionType.GetElementType(); + } + else if (collectionType.IsGenericType) + { + var genericArgs = collectionType.GetGenericArguments(); + if (genericArgs.Length > 0) + { + itemType = genericArgs[0]; + } + } + + // Create setter for collection + Action? collectionSetter = null; + if (coll.Expression.Body is MemberExpression collMemberExpr && collMemberExpr.Member is System.Reflection.PropertyInfo collPropInfo) + { + var setterParam = Expression.Parameter(typeof(object), "obj"); + var valueParam = Expression.Parameter(typeof(object), "value"); + var castObj = Expression.Convert(setterParam, typeof(T)); + var castValue = Expression.Convert(valueParam, collPropInfo.PropertyType); + var assign = Expression.Assign(Expression.Property(castObj, collPropInfo), castValue); + var setterLambda = Expression.Lambda>(assign, setterParam, valueParam); + collectionSetter = setterLambda.Compile(); + } + + // Pre-parse start cell coordinates + ReferenceHelper.ParseReference(coll.StartCell, out int startCol, out int startRow); + + var compiledCollection = new CompiledCollectionMapping + { + Getter = compiled, + StartCell = coll.StartCell, + StartCellColumn = startCol, + StartCellRow = startRow, + Layout = coll.Layout, + RowSpacing = coll.RowSpacing, + ItemType = itemType ?? coll.ItemType, + PropertyName = collectionPropertyName, + Setter = collectionSetter, + Registry = registry + }; + + // Try to get the item mapping from the registry if available + if (itemType != null && registry != null) + { + var itemMapping = registry.GetCompiledMapping(itemType); + if (itemMapping != null) + { + compiledCollection.ItemMapping = itemMapping; + } + } + // Otherwise compile nested item mapping if exists + else if (coll.ItemConfiguration != null && coll.ItemType != null) + { + var compileMethod = typeof(MappingCompiler) + .GetMethod(nameof(Compile))! + .MakeGenericMethod(coll.ItemType); + + compiledCollection.ItemMapping = compileMethod.Invoke(null, [coll.ItemConfiguration, registry]); + } + + collections.Add(compiledCollection); + } + + var compiledMapping = new CompiledMapping + { + WorksheetName = configuration.WorksheetName ?? "Sheet1", + Properties = properties, + Collections = collections + }; + + // Apply universal optimization to all mappings + OptimizeMapping(compiledMapping, registry); + + return compiledMapping; + } + + private static string GetPropertyName(LambdaExpression expression) + { + if (expression.Body is MemberExpression memberExpr) + { + return memberExpr.Member.Name; + } + + if (expression.Body is UnaryExpression unaryExpr && unaryExpr.Operand is MemberExpression unaryMemberExpr) + { + return 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. + /// + public static void OptimizeMapping(CompiledMapping mapping, MappingRegistry? registry = null) + { + if (mapping == null) + throw new ArgumentNullException(nameof(mapping)); + + // If already optimized, skip + if (mapping.IsUniversallyOptimized) + return; + + // Step 1: Calculate mapping boundaries + var boundaries = CalculateMappingBoundaries(mapping); + mapping.OptimizedBoundaries = boundaries; + + // Step 2: Pre-calculate collection expansion info + var expansions = CalculateCollectionExpansions(mapping); + mapping.CollectionExpansions = expansions; + + // 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.Collections.Any() && mapping.Properties.Any()) + { + // 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.ItemType != null && coll.Registry != null) + { + // Try to get the nested mapping - if it exists, it's a complex type + var nestedMapping = coll.Registry.GetCompiledMapping(coll.ItemType); + if (nestedMapping != null && coll.ItemType != typeof(string) && + !coll.ItemType.IsValueType && !coll.ItemType.IsPrimitive) + { + 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 > 0 && boundaries.PatternHeight < 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) + { + case CollectionLayout.Vertical: + // Vertical collections: grow downward + // Use conservative estimate for initial bounds + var verticalHeight = DefaultCollectionHeight; + + // Check if this is a complex type with nested mapping + var maxCol = startCol; + if (collection.ItemType != null && collection.Registry != null) + { + var nestedMapping = collection.Registry.GetCompiledMapping(collection.ItemType); + if (nestedMapping != null && collection.ItemType != typeof(string) && + !collection.ItemType.IsValueType && !collection.ItemType.IsPrimitive) + { + // Extract max column from nested mapping properties + var nestedMappingType = nestedMapping.GetType(); + var propsProperty = nestedMappingType.GetProperty("Properties"); + if (propsProperty != null) + { + var properties = propsProperty.GetValue(nestedMapping) as System.Collections.IEnumerable; + if (properties != null) + { + foreach (var prop in properties) + { + var propType = prop.GetType(); + var columnProperty = propType.GetProperty("CellColumn"); + if (columnProperty != null) + { + var column = (int)columnProperty.GetValue(prop); + maxCol = Math.Max(maxCol, column); + } + } + } + } + } + } + + return (startRow, startRow + verticalHeight, startCol, maxCol); + } + + // Default fallback + return (startRow, startRow + DefaultGridSize, startCol, startCol + DefaultGridSize); + } + + private static List CalculateCollectionExpansions(CompiledMapping mapping) + { + var expansions = new List(); + + foreach (var collection in mapping.Collections) + { + expansions.Add(new CollectionExpansionInfo + { + StartRow = collection.StartCellRow, + StartColumn = collection.StartCellColumn, + Layout = collection.Layout, + RowSpacing = collection.RowSpacing, + CollectionMapping = collection + }); + } + + return expansions; + } + + 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 r = 0; r < height; r++) + { + for (int c = 0; c < width; c++) + { + grid[r, c] = new OptimizedCellHandler { Type = CellHandlerType.Empty }; + } + } + + // Process simple properties + for (int i = 0; i < mapping.Properties.Count; i++) + { + var prop = mapping.Properties[i]; + 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 = CreatePreCompiledSetter(prop), // Pre-compiled setter with conversion built-in + 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(); + + foreach (var item in sortedCollections) + { + MarkCollectionCells(grid, item.Collection, item.Index, boundaries); + } + + return grid; + } + + private static void MarkCollectionCells(OptimizedCellHandler[,] grid, CompiledCollectionMapping collection, + int collectionIndex, OptimizedMappingBoundaries boundaries) + { + 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); + } + } + + + private static void MarkVerticalCollectionCells(OptimizedCellHandler[,] grid, CompiledCollectionMapping collection, + int collectionIndex, OptimizedMappingBoundaries boundaries, int startRow, int startCol) + { + 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 != null && itemType != typeof(string) && !itemType.IsValueType && !itemType.IsPrimitive) + { + // Complex type with mapping - expand each item across multiple columns + MarkVerticalComplexCollectionCells(grid, collection, collectionIndex, boundaries, startRow, startCol, nestedMapping); + } + 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 = CreatePreCompiledSetter(prop), + 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, itemIndex) => getter(obj); + } + + private static Action? CreatePreCompiledSetter(CompiledPropertyMapping property) + { + // Pre-compile the setter with type conversion built in + var originalSetter = property.Setter; + if (originalSetter == null) return null; + + var targetType = property.PropertyType; + + // Build a setter that includes conversion + return (obj, value) => + { + if (value == null) + { + originalSetter(obj, null); + return; + } + + // Pre-compiled conversion logic - this runs at compile time, not runtime! + object? convertedValue = value; + + if (value.GetType() != targetType) + { + convertedValue = targetType switch + { + _ when targetType == typeof(string) => value.ToString(), + _ when targetType == typeof(int) => Convert.ToInt32(value), + _ when targetType == typeof(long) => Convert.ToInt64(value), + _ when targetType == typeof(decimal) => Convert.ToDecimal(value), + _ when targetType == typeof(double) => Convert.ToDouble(value), + _ when targetType == typeof(float) => Convert.ToSingle(value), + _ when targetType == typeof(bool) => Convert.ToBoolean(value), + _ when targetType == typeof(DateTime) => Convert.ToDateTime(value), + _ => value + }; + } + + originalSetter(obj, convertedValue); + }; + } + + private static Func CreateCollectionValueExtractor(CompiledCollectionMapping collection, int offset) + { + var getter = collection.Getter; + return (obj, itemIndex) => + { + var enumerable = getter(obj); + if (enumerable == null) return null; + + // Try to use IList for O(1) access if possible + if (enumerable is IList list) + { + return offset < list.Count ? list[offset] : null; + } + + // Fallback to Skip/FirstOrDefault for other IEnumerable + return enumerable.Cast().Skip(offset).FirstOrDefault(); + }; + } + + private static void PreCompileCollectionHelpers(CompiledMapping mapping) + { + if (!mapping.Collections.Any()) return; + + // Store pre-compiled helpers for each collection + var helpers = new List(); + + foreach (var collection in mapping.Collections) + { + var helper = new OptimizedCollectionHelper(); + + // Get the actual property info + var propInfo = typeof(T).GetProperty(collection.PropertyName); + if (propInfo == null) continue; + + var propertyType = propInfo.PropertyType; + var itemType = collection.ItemType ?? typeof(object); + + // Pre-compile collection factory + helper.Factory = () => + { + var listType = typeof(List<>).MakeGenericType(itemType); + return (System.Collections.IList)Activator.CreateInstance(listType)!; + }; + + // Pre-compile finalizer (converts list to final type) + if (propertyType.IsArray) + { + helper.IsArray = true; + var elementType = propertyType.GetElementType()!; + helper.Finalizer = (list) => + { + var array = Array.CreateInstance(elementType, list.Count); + list.CopyTo(array, 0); + return array; + }; + } + else + { + helper.IsArray = false; + helper.Finalizer = (list) => list; + } + + // Pre-compile setter + helper.Setter = collection.Setter; + + helpers.Add(helper); + } + + mapping.OptimizedCollectionHelpers = helpers; + } + + private static void MarkVerticalComplexCollectionCells(OptimizedCellHandler[,] grid, CompiledCollectionMapping collection, + int collectionIndex, OptimizedMappingBoundaries boundaries, int startRow, int startCol, object nestedMapping) + { + // For complex types, we need to extract individual properties + // Use reflection to get the properties from the nested mapping + var nestedMappingType = nestedMapping.GetType(); + var propsProperty = nestedMappingType.GetProperty("Properties"); + if (propsProperty == null) return; + + var properties = propsProperty.GetValue(nestedMapping) as System.Collections.IEnumerable; + if (properties == null) return; + + var propertyList = new List<(string Name, int Column, Func Getter)>(); + foreach (var prop in properties) + { + var propType = prop.GetType(); + var nameProperty = propType.GetProperty("PropertyName"); + var columnProperty = propType.GetProperty("CellColumn"); + var getterProperty = propType.GetProperty("Getter"); + + if (nameProperty != null && columnProperty != null && getterProperty != null) + { + var name = nameProperty.GetValue(prop) as string; + var column = (int)columnProperty.GetValue(prop); + var getter = getterProperty.GetValue(prop) as Func; + + if (name != null && getter != null) + { + propertyList.Add((name, column, getter)); + } + } + } + + // 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; + + for (int itemIndex = 0; itemIndex < 20; itemIndex++) // Conservative estimate of collection size + { + var r = startRelativeRow + itemIndex * (1 + rowSpacing); + if (r >= 0 && r < maxRows && r < grid.GetLength(0)) + { + foreach (var (propName, propColumn, propGetter) in propertyList) + { + var c = propColumn - boundaries.MinColumn; + if (c >= 0 && c < grid.GetLength(1)) + { + // 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, propGetter), + CollectionIndex = collectionIndex, + CollectionItemOffset = itemIndex, + PropertyName = propName, + CollectionMapping = collection, + CollectionItemConverter = null // No conversion needed, property getter handles it + }; + } + } + } + } + } + } + + private static Func CreateNestedPropertyExtractor(CompiledCollectionMapping collection, int offset, Func propertyGetter) + { + var collectionGetter = collection.Getter; + return (obj, itemIndex) => + { + var enumerable = collectionGetter(obj); + if (enumerable == null) return null; + + // Try to use IList for O(1) access if possible + if (enumerable is IList list) + { + if (offset < list.Count && list[offset] != null) + { + // Extract the property from the nested object + return propertyGetter(list[offset]); + } + } + else + { + // Fall back to enumeration (slower but works) + var items = enumerable.Cast().Skip(offset).Take(1).ToArray(); + if (items.Length > 0 && items[0] != null) + { + return propertyGetter(items[0]); + } + } + + return null; + }; + } + + private static Func CreatePreCompiledItemConverter(Type targetType) + { + // Pre-compile all the conversion logic + return (value) => + { + if (value == null) return null; + if (value.GetType() == targetType) return value; + + // These conversions are JIT-compiled and inlined + try + { + return targetType switch + { + _ when targetType == typeof(string) => value.ToString(), + _ when targetType == typeof(int) => Convert.ToInt32(value), + _ when targetType == typeof(long) => Convert.ToInt64(value), + _ when targetType == typeof(decimal) => Convert.ToDecimal(value), + _ when targetType == typeof(double) => Convert.ToDouble(value), + _ when targetType == typeof(float) => Convert.ToSingle(value), + _ when targetType == typeof(bool) => Convert.ToBoolean(value), + _ when targetType == typeof(DateTime) => Convert.ToDateTime(value), + _ => value + }; + } + catch + { + // Fallback to string parsing for robustness + var str = value.ToString(); + if (string.IsNullOrEmpty(str)) return null; + + return targetType switch + { + _ when targetType == typeof(int) && int.TryParse(str, out var i) => i, + _ when targetType == typeof(long) && long.TryParse(str, out var l) => l, + _ when targetType == typeof(decimal) && decimal.TryParse(str, out var d) => d, + _ when targetType == typeof(double) && double.TryParse(str, out var db) => db, + _ when targetType == typeof(float) && float.TryParse(str, out var f) => f, + _ when targetType == typeof(bool) && bool.TryParse(str, out var b) => b, + _ when targetType == typeof(DateTime) && DateTime.TryParse(str, out var dt) => dt, + _ => value + }; + } + }; + } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/Mapping/MappingExporter.cs b/src/MiniExcel.Core/Mapping/MappingExporter.cs new file mode 100644 index 00000000..74f06328 --- /dev/null +++ b/src/MiniExcel.Core/Mapping/MappingExporter.cs @@ -0,0 +1,35 @@ +namespace MiniExcelLib.Core.Mapping; + +public 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 SaveAsAsync(Stream stream, IEnumerable values, CancellationToken cancellationToken = default) + { + if (stream == null) + throw new ArgumentNullException(nameof(stream)); + if (values == 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); + } + + + public MappingRegistry Registry => _registry; +} \ No newline at end of file diff --git a/src/MiniExcel.Core/Mapping/MappingImporter.cs b/src/MiniExcel.Core/Mapping/MappingImporter.cs new file mode 100644 index 00000000..b11ebc1e --- /dev/null +++ b/src/MiniExcel.Core/Mapping/MappingImporter.cs @@ -0,0 +1,57 @@ +namespace MiniExcelLib.Core.Mapping; + +public partial class MappingImporter +{ + private readonly MappingRegistry _registry; + + public MappingImporter() + { + _registry = new MappingRegistry(); + } + + public MappingImporter(MappingRegistry registry) + { + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + } + + [CreateSyncVersion] + public async Task> QueryAsync(string path, CancellationToken cancellationToken = default) where T : class, new() + { + using var stream = File.OpenRead(path); + return await QueryAsync(stream, cancellationToken).ConfigureAwait(false); + } + + [CreateSyncVersion] + public async Task> QueryAsync(Stream stream, CancellationToken cancellationToken = default) where T : class, new() + { + if (stream == null) + throw new ArgumentNullException(nameof(stream)); + + var mapping = _registry.GetCompiledMapping(); + if (mapping == null) + throw new InvalidOperationException($"No mapping configuration found for type {typeof(T).Name}. Configure the mapping using MappingRegistry.Configure<{typeof(T).Name}>()."); + + return await MappingReader.QueryAsync(stream, mapping, cancellationToken).ConfigureAwait(false); + } + + [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] + public async Task QuerySingleAsync(Stream stream, CancellationToken cancellationToken = default) where T : class, new() + { + if (stream == null) + throw new ArgumentNullException(nameof(stream)); + + var mapping = _registry.GetCompiledMapping(); + if (mapping == null) + throw new InvalidOperationException($"No mapping configuration found for type {typeof(T).Name}. Configure the mapping using MappingRegistry.Configure<{typeof(T).Name}>()."); + + var results = await MappingReader.QueryAsync(stream, mapping, cancellationToken).ConfigureAwait(false); + return results.FirstOrDefault() ?? new T(); + } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/Mapping/MappingReader.cs b/src/MiniExcel.Core/Mapping/MappingReader.cs new file mode 100644 index 00000000..d4edd280 --- /dev/null +++ b/src/MiniExcel.Core/Mapping/MappingReader.cs @@ -0,0 +1,1070 @@ +using MiniExcelLib.Core.Helpers; +using MiniExcelLib.Core.OpenXml.Utils; + +namespace MiniExcelLib.Core.Mapping; + +internal static partial class MappingReader where T : class, new() +{ + [CreateSyncVersion] + public static async Task> QueryAsync(Stream stream, CompiledMapping mapping, CancellationToken cancellationToken = default) + { + if (stream == null) + throw new ArgumentNullException(nameof(stream)); + if (mapping == null) + throw new ArgumentNullException(nameof(mapping)); + + // Use optimized universal reader if mapping is optimized + if (mapping.IsUniversallyOptimized) + { + return await QueryUniversalAsync(stream, mapping, cancellationToken).ConfigureAwait(false); + } + + // Legacy path for non-optimized mappings + var importer = new OpenXmlImporter(); + + var dataList = new List>(); + + await foreach (var row in importer.QueryAsync(stream, useHeaderRow: false, sheetName: mapping.WorksheetName, startCell: "A1", cancellationToken: cancellationToken).ConfigureAwait(false)) + { + if (row is IDictionary dict) + { + // Include all rows, even if they appear empty + dataList.Add(dict); + } + } + + if (!dataList.Any()) + { + return []; + } + + // Build a cell lookup dictionary for efficient access + var cellLookup = BuildCellLookup(dataList); + + // Read the mapped data + var results = ReadMappedData(cellLookup, mapping); + return results; + } + + [CreateSyncVersion] + public static async Task QuerySingleAsync(Stream stream, CompiledMapping mapping, CancellationToken cancellationToken = default) + { + var results = await QueryAsync(stream, mapping, cancellationToken).ConfigureAwait(false); + return results.FirstOrDefault() ?? new T(); + } + + private static Dictionary BuildCellLookup(List> data) + { + var lookup = new Dictionary(); + + for (int rowIndex = 0; rowIndex < data.Count; rowIndex++) + { + var row = data[rowIndex]; + var rowNumber = rowIndex + 1; // Row is 1-based + + foreach (var kvp in row) + { + var columnLetter = kvp.Key; + var cellAddress = $"{columnLetter}{rowNumber}"; + lookup[cellAddress] = kvp.Value; + } + } + + return lookup; + } + + private static IEnumerable ReadMappedData(Dictionary cellLookup, CompiledMapping mapping) + { + // Calculate the expected spacing between items based on mapping configuration + var maxPropertyRow = 0; + foreach (var prop in mapping.Properties) + { + if (prop.CellRow > maxPropertyRow) + maxPropertyRow = prop.CellRow; + } + + // Also check collection start rows as they may be part of the item definition + foreach (var coll in mapping.Collections) + { + if (coll.StartCellRow > maxPropertyRow) + maxPropertyRow = coll.StartCellRow; + } + + // Determine item spacing based on the mapping pattern + // If all properties are on row 1 (A1, B1, C1...), it's likely a table pattern where each row is an item + // Otherwise, use the writer's spacing pattern (maxPropertyRow + 2) + var allOnRow1 = mapping.Properties.All(p => p.CellRow == 1); + var itemSpacing = allOnRow1 ? 1 : maxPropertyRow + 2; + + #if DEBUG + System.Diagnostics.Debug.WriteLine($"ReadMappedData: allOnRow1={allOnRow1}, itemSpacing={itemSpacing}"); + #endif + + // Find the base row where properties start + var baseRow = int.MaxValue; + foreach (var prop in mapping.Properties) + { + if (prop.CellRow < baseRow) + baseRow = prop.CellRow; + } + + if (baseRow == int.MaxValue) + baseRow = 1; + + // Debug logging + #if DEBUG + System.Diagnostics.Debug.WriteLine($"ReadMappedData: maxPropertyRow={maxPropertyRow}, itemSpacing={itemSpacing}, baseRow={baseRow}"); + #endif + + // Read items at expected intervals + var currentRow = baseRow; + var itemsFound = 0; + var maxItems = 1_000_000; // Safety limit - 1 million items should be enough + + while (itemsFound < maxItems) + { + var result = new T(); + var hasData = false; + + #if DEBUG + System.Diagnostics.Debug.WriteLine($"Reading item at currentRow={currentRow}"); + #endif + + // Read simple properties at current offset + foreach (var prop in mapping.Properties) + { + var offsetRow = currentRow + (prop.CellRow - baseRow); + var cellAddress = ReferenceHelper.ConvertCoordinatesToCell(prop.CellColumn, offsetRow); + + if (cellLookup.TryGetValue(cellAddress, out var value)) + { + SetPropertyValue(result, prop, value); + hasData = true; + #if DEBUG + System.Diagnostics.Debug.WriteLine($" Found property {prop.PropertyName} at {cellAddress}: {value}"); + #endif + } + else + { + // Try column compression fallback + var fallbackAddress = $"A{offsetRow}"; + if (cellLookup.TryGetValue(fallbackAddress, out var fallbackValue)) + { + SetPropertyValue(result, prop, fallbackValue); + hasData = true; + #if DEBUG + System.Diagnostics.Debug.WriteLine($" Found property {prop.PropertyName} at fallback {fallbackAddress}: {fallbackValue}"); + #endif + } + } + } + + if (!hasData) + { + // No more items found + #if DEBUG + System.Diagnostics.Debug.WriteLine($"No data found at row {currentRow}, stopping"); + #endif + break; + } + + // Read collections at current offset + for (int collIndex = 0; collIndex < mapping.Collections.Count; collIndex++) + { + var coll = mapping.Collections[collIndex]; + var offsetStartRow = currentRow + (coll.StartCellRow - baseRow); + var offsetStartCell = ReferenceHelper.ConvertCoordinatesToCell(coll.StartCellColumn, offsetStartRow); + + // Determine collection boundaries + int? maxRow = null; + int? maxCol = null; + + // Check if there's another collection after this one on the same item + for (int nextCollIndex = collIndex + 1; nextCollIndex < mapping.Collections.Count; nextCollIndex++) + { + var nextColl = mapping.Collections[nextCollIndex]; + var nextOffsetStartRow = currentRow + (nextColl.StartCellRow - baseRow); + + // Only vertical collections are supported + if (coll.Layout == CollectionLayout.Vertical && nextColl.Layout == CollectionLayout.Vertical && nextColl.StartCellColumn == coll.StartCellColumn) + { + maxRow = nextOffsetStartRow - 1; + break; + } + } + + // Check if there's definitely another item to limit collection boundaries + // This prevents reading collection data from the next item + if (maxRow == null && coll.Layout == CollectionLayout.Vertical) + { + // Check if there's a next item by looking for ALL properties (not just one) + // Only consider it a next item if we find MULTIPLE property values + var nextItemPropertyCount = 0; + if (mapping.Properties.Any()) + { + // Check all properties to see if any exist at the next item position + foreach (var prop in mapping.Properties) + { + if (ReferenceHelper.ParseReference(prop.CellAddress, out int propCol, out int propRow)) + { + var nextItemRow = currentRow + itemSpacing + (propRow - baseRow); + var nextItemCell = ReferenceHelper.ConvertCoordinatesToCell(propCol, nextItemRow); + if (cellLookup.TryGetValue(nextItemCell, out var value) && value != null) + { + nextItemPropertyCount++; + } + } + } + } + + // Only limit if we find at least 2 properties or the majority of properties + var minPropsForNextItem = Math.Max(2, mapping.Properties.Count / 2); + if (nextItemPropertyCount >= minPropsForNextItem) + { + maxRow = currentRow + itemSpacing - 1; + } + } + + var collectionData = ReadCollectionDataWithOffset(cellLookup, coll, offsetStartCell, maxRow, maxCol); + + #if DEBUG + System.Diagnostics.Debug.WriteLine($" Collection {coll.PropertyName} at {offsetStartCell}: {collectionData.Count} items"); + #endif + + SetCollectionValue(result, coll, collectionData); + } + + yield return result; + itemsFound++; + currentRow += itemSpacing; + + #if DEBUG + System.Diagnostics.Debug.WriteLine($"Item {itemsFound} read, moving to row {currentRow}"); + #endif + } + } + + private static void SetPropertyValue(T instance, CompiledPropertyMapping prop, object value) + { + if (prop.Setter != null) + { + var convertedValue = ConversionHelper.ConvertValue(value, prop.PropertyType, prop.Format); + prop.Setter(instance, convertedValue); + } + } + + private static List ReadCollectionDataWithOffset(Dictionary cellLookup, CompiledCollectionMapping coll, string offsetStartCell, int? maxRow = null, int? maxCol = null) + { + var results = new List(); + + if (!ReferenceHelper.ParseReference(offsetStartCell, out int startColumn, out int startRow)) + return results; + + var currentRow = startRow; + var currentCol = startColumn; + var itemIndex = 0; + var emptyCellCount = 0; + const int maxEmptyCells = 10; + const int maxIterations = 1000; + var iterations = 0; + + while (emptyCellCount < maxEmptyCells && iterations < maxIterations && (!maxRow.HasValue || currentRow <= maxRow.Value)) + { + if (coll.ItemMapping != null && coll.ItemType != null) + { + var item = ReadComplexItem(cellLookup, coll, currentRow, currentCol, itemIndex); + if (item != null) + { + results.Add(item); + emptyCellCount = 0; + } + else + { + emptyCellCount++; + } + } + else + { + var cellAddress = CalculateCellPosition(offsetStartCell, currentRow, currentCol, itemIndex, coll); + if (cellLookup.TryGetValue(cellAddress, out var value) && value != null && !string.IsNullOrEmpty(value.ToString())) + { + results.Add(value); + emptyCellCount = 0; + } + else + { + emptyCellCount++; + } + } + + UpdatePosition(ref currentRow, ref currentCol, ref itemIndex, coll); + iterations++; + } + + return results; + } + + + private static object? ReadComplexItem(Dictionary cellLookup, CompiledCollectionMapping coll, int currentRow, int currentCol, int itemIndex) + { + if (coll.ItemType == null || coll.ItemMapping == null) + return null; + + var item = Activator.CreateInstance(coll.ItemType); + if (item == null) + return null; + + var itemMapping = coll.ItemMapping; + var itemMappingType = itemMapping.GetType(); + var propsProperty = itemMappingType.GetProperty("Properties"); + var properties = propsProperty?.GetValue(itemMapping) as IEnumerable; + + var hasAnyValue = false; + + if (properties != null) + { + foreach (var prop in properties) + { + // For nested mappings, we need to adjust the property's cell address relative to the collection item's position + if (!ReferenceHelper.ParseReference(prop.CellAddress, out int propCol, out int propRow)) + continue; + + // Only vertical layout is supported + var cellAddress = coll.Layout == CollectionLayout.Vertical + ? ReferenceHelper.ConvertCoordinatesToCell(currentCol + propCol - 1, currentRow + propRow - 1) + : prop.CellAddress; + + if (cellLookup.TryGetValue(cellAddress, out var value) && value != null && !string.IsNullOrEmpty(value.ToString())) + { + SetItemPropertyValue(item, prop, value); + hasAnyValue = true; + } + } + } + + return hasAnyValue ? item : null; + } + + private static void SetItemPropertyValue(object instance, CompiledPropertyMapping prop, object value) + { + if (prop.Setter == null) return; + + var convertedValue = ConversionHelper.ConvertValue(value, prop.PropertyType, prop.Format); + prop.Setter(instance, convertedValue); + } + + private static void SetCollectionValue(T instance, CompiledCollectionMapping coll, List items) + { + if (coll.Setter != null) + { + var targetType = typeof(T); + var propertyInfo = targetType.GetProperty(coll.PropertyName); + + if (propertyInfo != null) + { + var collectionType = propertyInfo.PropertyType; + var convertedCollection = ConvertToTypedCollection(items, collectionType, coll.ItemType); + coll.Setter(instance, convertedCollection); + } + } + } + + private static string CalculateCellPosition(string baseCellAddress, int currentRow, int currentCol, int itemIndex, CompiledCollectionMapping mapping) + { + if (!ReferenceHelper.ParseReference(baseCellAddress, out int baseColumn, out int baseRow)) + return baseCellAddress; + + // Only vertical layout is supported + return ReferenceHelper.ConvertCoordinatesToCell(baseColumn, currentRow); + } + + private static void UpdatePosition(ref int currentRow, ref int currentCol, ref int itemIndex, CompiledCollectionMapping mapping) + { + itemIndex++; + + // Only vertical layout is supported + if (mapping.Layout == CollectionLayout.Vertical) + { + currentRow += 1 + mapping.RowSpacing; + } + } + private static object? ConvertToTypedCollection(List items, Type collectionType, Type? itemType) + { + if (items.Count == 0) + { + // For arrays, return empty array instead of null + if (collectionType.IsArray) + { + var elementType = collectionType.GetElementType() ?? typeof(object); + return Array.CreateInstance(elementType, 0); + } + return null; + } + + // Handle arrays + if (collectionType.IsArray) + { + var elementType = collectionType.GetElementType() ?? typeof(object); + var array = Array.CreateInstance(elementType, items.Count); + for (int i = 0; i < items.Count; i++) + { + array.SetValue(ConversionHelper.ConvertValue(items[i], elementType, null), i); + } + return array; + } + + // Handle List + if (collectionType.IsGenericType && collectionType.GetGenericTypeDefinition() == typeof(List<>)) + { + var elementType = collectionType.GetGenericArguments()[0]; + var listType = typeof(List<>).MakeGenericType(elementType); + var list = Activator.CreateInstance(listType) as IList; + + foreach (var item in items) + { + var convertedValue = ConversionHelper.ConvertValue(item, elementType, null); + if (convertedValue != null) + { + list?.Add(convertedValue); + } + } + return list; + } + + // Handle IEnumerable + if (collectionType.IsGenericType && + (collectionType.GetGenericTypeDefinition() == typeof(IEnumerable<>) || + collectionType.GetInterface(typeof(IEnumerable<>).Name) != null)) + { + var elementType = itemType ?? collectionType.GetGenericArguments()[0]; + var listType = typeof(List<>).MakeGenericType(elementType); + var list = Activator.CreateInstance(listType) as IList; + + foreach (var item in items) + { + list?.Add(item); // Items are already converted + } + return list; + } + + return items; + } + + // Universal optimized reader implementation + [CreateSyncVersion] + private static async Task> QueryUniversalAsync(Stream stream, CompiledMapping mapping, CancellationToken cancellationToken = default) + { + if (!mapping.IsUniversallyOptimized) + throw new InvalidOperationException("QueryUniversalAsync requires a universally optimized mapping"); + + var boundaries = mapping.OptimizedBoundaries!; + var cellGrid = mapping.OptimizedCellGrid!; + var columnHandlers = mapping.OptimizedColumnHandlers!; + + var results = new List(); + + // Read the Excel file using OpenXmlReader + using var reader = await OpenXmlReader.CreateAsync(stream, new OpenXmlConfiguration + { + FillMergedCells = false, + FastMode = true + }, 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.IsMultiItemPattern && boundaries.PatternHeight > 0; + + T? currentItem = null; + Dictionary? currentCollections = null; + int currentItemIndex = -1; + + int currentRowIndex = 0; + await foreach (var row in reader.QueryAsync(false, mapping.WorksheetName, "A1", cancellationToken).ConfigureAwait(false)) + { + currentRowIndex++; + if (row == null) continue; + var rowDict = row as IDictionary; + if (rowDict == null) continue; + + + // 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 != null && currentCollections != null) + { + FinalizeCollections(currentItem, mapping, currentCollections); + if (HasAnyData(currentItem, mapping)) + { + results.Add(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 == null) continue; + + if (gridRow < 0 || gridRow >= cellGrid.GetLength(0)) continue; + + // Process each cell in the row using the pre-calculated grid + foreach (var kvp in rowDict) + { + if (kvp.Key.StartsWith("__")) continue; // Skip metadata keys + + // Convert column letter to index + if (!TryParseColumnIndex(kvp.Key, out int columnIndex)) + continue; + + var relativeCol = columnIndex - boundaries.MinColumn; + if (relativeCol < 0 || relativeCol >= cellGrid.GetLength(1)) + continue; + + var handler = cellGrid[gridRow, relativeCol]; + ProcessCellValue(handler, kvp.Value, currentItem, currentCollections, gridRow); + } + } + + // Finalize the last item if we have one + if (currentItem != null && currentCollections != null) + { + FinalizeCollections(currentItem, mapping, currentCollections); + + + if (HasAnyData(currentItem, mapping)) + { + results.Add(currentItem); + } + else + { + } + } + } + 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(); + int currentRowIndex = 0; + + await foreach (var row in reader.QueryAsync(false, mapping.WorksheetName, "A1", cancellationToken).ConfigureAwait(false)) + { + currentRowIndex++; + if (row == null) continue; + var rowDict = row as IDictionary; + if (rowDict == null) continue; + + int rowNumber = currentRowIndex; + + // Process properties for this row + foreach (var prop in mapping.Properties) + { + if (prop.CellRow == rowNumber) + { + var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( + OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(prop.CellColumn, 1)); + + if (rowDict.TryGetValue(columnLetter, out var value) && value != null) + { + // Apply type conversion if needed + if (prop.Setter != null) + { + var targetType = prop.PropertyType; + if (value.GetType() != targetType) + { + // Pre-compiled conversion logic + try + { + value = targetType switch + { + _ when targetType == typeof(string) => value.ToString(), + _ when targetType == typeof(int) => Convert.ToInt32(value), + _ when targetType == typeof(long) => Convert.ToInt64(value), + _ when targetType == typeof(decimal) => Convert.ToDecimal(value), + _ when targetType == typeof(double) => Convert.ToDouble(value), + _ when targetType == typeof(float) => Convert.ToSingle(value), + _ when targetType == typeof(bool) => Convert.ToBoolean(value), + _ when targetType == typeof(DateTime) => Convert.ToDateTime(value), + _ => value + }; + } + catch + { + // Fallback to string parsing + var str = value.ToString(); + if (!string.IsNullOrEmpty(str)) + { + value = targetType switch + { + _ when targetType == typeof(int) && int.TryParse(str, out var i) => i, + _ when targetType == typeof(long) && long.TryParse(str, out var l) => l, + _ when targetType == typeof(decimal) && decimal.TryParse(str, out var d) => d, + _ when targetType == typeof(double) && double.TryParse(str, out var db) => db, + _ when targetType == typeof(float) && float.TryParse(str, out var f) => f, + _ when targetType == typeof(bool) && bool.TryParse(str, out var b) => b, + _ when targetType == typeof(DateTime) && DateTime.TryParse(str, out var dt) => dt, + _ => value + }; + } + } + } + prop.Setter.Invoke(item, value); + } + } + } + } + } + + if (HasAnyData(item, mapping)) + { + results.Add(item); + } + } + else + { + // Row layout mode - each row is a separate item + int currentRowIndex = 0; + await foreach (var row in reader.QueryAsync(false, mapping.WorksheetName, "A1", cancellationToken).ConfigureAwait(false)) + { + currentRowIndex++; + if (row == null) continue; + var rowDict = row as IDictionary; + if (rowDict == null) continue; + + // Use our own row counter since OpenXmlReader doesn't provide row numbers + int rowNumber = currentRowIndex; + if (rowNumber < 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 == rowNumber) + { + var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( + OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(prop.CellColumn, 1)); + + if (rowDict.TryGetValue(columnLetter, out var value) && value != null) + { + // Apply type conversion if needed + if (prop.Setter != null) + { + var targetType = prop.PropertyType; + if (value.GetType() != targetType) + { + // Pre-compiled conversion logic + try + { + value = targetType switch + { + _ when targetType == typeof(string) => value.ToString(), + _ when targetType == typeof(int) => Convert.ToInt32(value), + _ when targetType == typeof(long) => Convert.ToInt64(value), + _ when targetType == typeof(decimal) => Convert.ToDecimal(value), + _ when targetType == typeof(double) => Convert.ToDouble(value), + _ when targetType == typeof(float) => Convert.ToSingle(value), + _ when targetType == typeof(bool) => Convert.ToBoolean(value), + _ when targetType == typeof(DateTime) => Convert.ToDateTime(value), + _ => value + }; + } + catch + { + // Fallback to string parsing + var str = value.ToString(); + if (!string.IsNullOrEmpty(str)) + { + value = targetType switch + { + _ when targetType == typeof(int) && int.TryParse(str, out var i) => i, + _ when targetType == typeof(long) && long.TryParse(str, out var l) => l, + _ when targetType == typeof(decimal) && decimal.TryParse(str, out var d) => d, + _ when targetType == typeof(double) && double.TryParse(str, out var db) => db, + _ when targetType == typeof(float) && float.TryParse(str, out var f) => f, + _ when targetType == typeof(bool) && bool.TryParse(str, out var b) => b, + _ when targetType == typeof(DateTime) && DateTime.TryParse(str, out var dt) => dt, + _ => value + }; + } + } + } + prop.Setter.Invoke(item, value); + } + } + } + } + + if (HasAnyData(item, mapping)) + { + results.Add(item); + } + } + } + } + + return results; + } + + private static Dictionary InitializeCollections(CompiledMapping mapping) + { + var collections = new Dictionary(); + + for (int i = 0; i < mapping.Collections.Count; i++) + { + var collection = mapping.Collections[i]; + var itemType = collection.ItemType ?? typeof(object); + + // Check if this is a complex type with nested mapping + var nestedMapping = collection.Registry?.GetCompiledMapping(itemType); + if (nestedMapping != null && itemType != typeof(string) && !itemType.IsValueType && !itemType.IsPrimitive) + { + // Complex type - we'll build objects as we go + var listType = typeof(List<>).MakeGenericType(itemType); + collections[i] = (IList)Activator.CreateInstance(listType)!; + } + else + { + // Simple type collection + var listType = typeof(List<>).MakeGenericType(itemType); + collections[i] = (IList)Activator.CreateInstance(listType)!; + } + } + + return collections; + } + + private static void ProcessCellValue(OptimizedCellHandler handler, object value, T item, + Dictionary collections, int relativeRow) + { + switch (handler.Type) + { + case CellHandlerType.Property: + // Direct property - use pre-compiled setter + handler.ValueSetter?.Invoke(item, value); + break; + + case CellHandlerType.CollectionItem: + if (handler.CollectionIndex >= 0 && collections.ContainsKey(handler.CollectionIndex)) + { + var collection = collections[handler.CollectionIndex]; + 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); + if (nestedMapping != null && itemType != typeof(string) && !itemType.IsValueType && !itemType.IsPrimitive) + { + // Complex type - we need to build/update the object + ProcessComplexCollectionItem(collection, handler, value, itemType, nestedMapping); + } + else + { + // Simple type - add directly + while (collection.Count <= handler.CollectionItemOffset) + { + // For value types, we need to add default value not null + var defaultValue = itemType.IsValueType ? Activator.CreateInstance(itemType) : null; + collection.Add(defaultValue); + } + + // Skip empty values for value type collections + if (value == null || (value is string str && string.IsNullOrEmpty(str))) + { + // Don't add empty values to value type collections + if (!itemType.IsValueType) + { + // 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 + if (handler.CollectionItemConverter != null) + { + collection[handler.CollectionItemOffset] = handler.CollectionItemConverter(value); + } + else + { + collection[handler.CollectionItemOffset] = value; + } + } + } + } + break; + } + } + + private static void ProcessComplexCollectionItem(IList collection, OptimizedCellHandler handler, + object value, Type itemType, object nestedMapping) + { + // Ensure the collection has enough items + while (collection.Count <= handler.CollectionItemOffset) + { + collection.Add(Activator.CreateInstance(itemType)); + } + + var item = collection[handler.CollectionItemOffset]; + if (item == null) + { + item = Activator.CreateInstance(itemType)!; + collection[handler.CollectionItemOffset] = item; + } + + // Set the property on the complex object + if (!string.IsNullOrEmpty(handler.PropertyName)) + { + // Find the property setter from the nested mapping + var nestedMappingType = nestedMapping.GetType(); + var propsProperty = nestedMappingType.GetProperty("Properties"); + if (propsProperty != null) + { + var properties = propsProperty.GetValue(nestedMapping) as IEnumerable; + if (properties != null) + { + foreach (var prop in properties) + { + var propType = prop.GetType(); + var nameProperty = propType.GetProperty("PropertyName"); + var setterProperty = propType.GetProperty("Setter"); + + if (nameProperty != null && setterProperty != null) + { + var name = nameProperty.GetValue(prop) as string; + if (name == handler.PropertyName) + { + var setter = setterProperty.GetValue(prop) as Action; + if (setter != null) + { + // Apply type conversion if needed + var propTypeProperty = propType.GetProperty("PropertyType"); + if (propTypeProperty != null) + { + var targetType = propTypeProperty.GetValue(prop) as Type; + if (targetType != null && value != null && value.GetType() != targetType) + { + // Pre-compiled conversion logic - same as in MappingCompiler + try + { + value = targetType switch + { + _ when targetType == typeof(string) => value.ToString(), + _ when targetType == typeof(int) => Convert.ToInt32(value), + _ when targetType == typeof(long) => Convert.ToInt64(value), + _ when targetType == typeof(decimal) => Convert.ToDecimal(value), + _ when targetType == typeof(double) => Convert.ToDouble(value), + _ when targetType == typeof(float) => Convert.ToSingle(value), + _ when targetType == typeof(bool) => Convert.ToBoolean(value), + _ when targetType == typeof(DateTime) => Convert.ToDateTime(value), + _ => value + }; + } + catch + { + // Fallback to string parsing + var str = value.ToString(); + if (!string.IsNullOrEmpty(str)) + { + value = targetType switch + { + _ when targetType == typeof(int) && int.TryParse(str, out var i) => i, + _ when targetType == typeof(long) && long.TryParse(str, out var l) => l, + _ when targetType == typeof(decimal) && decimal.TryParse(str, out var d) => d, + _ when targetType == typeof(double) && double.TryParse(str, out var db) => db, + _ when targetType == typeof(float) && float.TryParse(str, out var f) => f, + _ when targetType == typeof(bool) && bool.TryParse(str, out var b) => b, + _ when targetType == typeof(DateTime) && DateTime.TryParse(str, out var dt) => dt, + _ => value + }; + } + } + } + } + setter.Invoke(item, value); + } + break; + } + } + } + } + } + } + } + + 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)) + { + // Remove any trailing null or default values + var itemType = collectionMapping.ItemType ?? typeof(object); + while (list.Count > 0) + { + var lastItem = list[list.Count - 1]; + bool isDefault = lastItem == null || + (itemType.IsValueType && lastItem.Equals(Activator.CreateInstance(itemType))); + 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 != null) + { + // Get the property type to determine if we need array conversion + var propInfo = typeof(T).GetProperty(collectionMapping.PropertyName); + if (propInfo != null && propInfo.PropertyType.IsArray) + { + var elementType = propInfo.PropertyType.GetElementType()!; + var array = Array.CreateInstance(elementType, list.Count); + list.CopyTo(array, 0); + finalValue = array; + } + + collectionMapping.Setter(item, finalValue); + } + } + } + } + + private static bool HasAnyData(T item, CompiledMapping mapping) + { + // Check if any properties have non-default values + foreach (var prop in mapping.Properties) + { + var value = prop.Getter(item); + if (value != null && !IsDefaultValue(value)) + { + return true; + } + } + + // Check if any collections have items + foreach (var coll in mapping.Collections) + { + var collection = coll.Getter(item); + if (collection != null && collection.Cast().Any()) + { + return true; + } + } + + return false; + } + + private static bool IsDefaultValue(object value) + { + return value switch + { + string s => string.IsNullOrEmpty(s), + int i => i == 0, + long l => l == 0, + decimal d => d == 0, + double d => d == 0, + float f => f == 0, + bool b => !b, + DateTime dt => dt == default, + _ => false + }; + } + + private static int ExtractRowNumber(IDictionary row) + { + // Try to get row number from metadata + if (row.TryGetValue("__rowIndex", out var rowIndex) && rowIndex is int index) + { + return index; + } + + // Fallback: parse from first cell reference + foreach (var key in row.Keys) + { + if (!key.StartsWith("__") && TryParseRowFromCellReference(key, out int rowNum)) + { + return rowNum; + } + } + + return 1; // Default to row 1 + } + + private static bool TryParseColumnIndex(string columnLetter, out int columnIndex) + { + columnIndex = 0; + if (string.IsNullOrEmpty(columnLetter)) return false; + + // Convert column letter (A, B, AA, etc.) to index + columnLetter = columnLetter.ToUpperInvariant(); + for (int i = 0; i < columnLetter.Length; i++) + { + char c = columnLetter[i]; + if (c < 'A' || c > 'Z') return false; + columnIndex = columnIndex * 26 + (c - 'A' + 1); + } + + return columnIndex > 0; + } + + private static bool TryParseRowFromCellReference(string cellRef, out int row) + { + row = 0; + if (string.IsNullOrEmpty(cellRef)) return false; + + // Find where letters end and numbers begin + int i = 0; + while (i < cellRef.Length && char.IsLetter(cellRef[i])) i++; + + if (i < cellRef.Length && int.TryParse(cellRef.Substring(i), out row)) + { + return row > 0; + } + + return false; + } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/Mapping/MappingRegistry.cs b/src/MiniExcel.Core/Mapping/MappingRegistry.cs new file mode 100644 index 00000000..4debbf61 --- /dev/null +++ b/src/MiniExcel.Core/Mapping/MappingRegistry.cs @@ -0,0 +1,70 @@ +using MiniExcelLib.Core.Mapping.Configuration; + +namespace MiniExcelLib.Core.Mapping; + +public sealed class MappingRegistry +{ + private readonly Dictionary _compiledMappings = new(); + private readonly object _lock = new(); + + public void Configure(Action> configure) + { + if (configure == null) + throw new ArgumentNullException(nameof(configure)); + + lock (_lock) + { + var config = new MappingConfiguration(); + configure(config); + + var compiledMapping = MappingCompiler.Compile(config, this); + _compiledMappings[typeof(T)] = compiledMapping; + } + } + + public CompiledMapping GetMapping() + { + lock (_lock) + { + if (_compiledMappings.TryGetValue(typeof(T), out var mapping)) + { + return (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)); + } + } + + public CompiledMapping? GetCompiledMapping() + { + lock (_lock) + { + if (_compiledMappings.TryGetValue(typeof(T), out var mapping)) + { + return (CompiledMapping)mapping; + } + + return null; + } + } + + internal object? GetCompiledMapping(Type type) + { + lock (_lock) + { + if (_compiledMappings.TryGetValue(type, out var mapping)) + { + return mapping; + } + return null; + } + } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/Mapping/MappingWriter.cs b/src/MiniExcel.Core/Mapping/MappingWriter.cs new file mode 100644 index 00000000..0989e2f6 --- /dev/null +++ b/src/MiniExcel.Core/Mapping/MappingWriter.cs @@ -0,0 +1,965 @@ +using MiniExcelLib.Core.Helpers; +using MiniExcelLib.Core.OpenXml.Utils; + +namespace MiniExcelLib.Core.Mapping; + +internal class WriterBoundaries +{ + public int MaxPropertyRow { get; set; } + public int MaxPropertyColumn { get; set; } +} + +internal class ItemOffset +{ + public int RowOffset { get; set; } + public int ColumnOffset { get; set; } +} + + +internal static partial class MappingWriter +{ + [CreateSyncVersion] + public static async Task SaveAsAsync(Stream stream, IEnumerable value, CompiledMapping mapping, CancellationToken cancellationToken = default) + { + if (stream == null) + throw new ArgumentNullException(nameof(stream)); + if (value == null) + throw new ArgumentNullException(nameof(value)); + if (mapping == null) + throw new ArgumentNullException(nameof(mapping)); + + // Use optimized universal writer if mapping is optimized + if (mapping.IsUniversallyOptimized) + { + return await SaveAsUniversalAsync(stream, value, mapping, cancellationToken).ConfigureAwait(false); + } + + // Legacy path for non-optimized mappings + // Convert mapped data to row-based format that OpenXmlWriter expects. + // This uses an optimized streaming approach that only buffers rows for the current item. + var mappedData = ConvertToMappedData(value, mapping); + + var configuration = new OpenXmlConfiguration { FastMode = true }; + var writer = await OpenXmlWriter + .CreateAsync(stream, mappedData, mapping.WorksheetName, false, configuration, cancellationToken) + .ConfigureAwait(false); + + return await writer.SaveAsAsync(cancellationToken).ConfigureAwait(false); + } + + private static IEnumerable> ConvertToMappedData(IEnumerable value, CompiledMapping mapping) + { + // Analyze mapping configuration to determine overlapping areas and boundaries + var boundaries = CalculateMappingBoundaries(mapping); + var allColumns = DetermineAllColumns(mapping); + var rowBuffer = new SortedDictionary>(); + + // Process all items with proper offsets based on boundaries + var itemIndex = 0; + foreach (var item in value) + { + ProcessItemData(item, mapping, rowBuffer, itemIndex, boundaries); + itemIndex++; + } + + // Yield all rows from 1 to maxRow to ensure proper row positioning + if (rowBuffer.Count > 0) + { + var maxRow = rowBuffer.Keys.Max(); + + for (int rowNum = 1; rowNum <= maxRow; rowNum++) + { + var rowDict = CreateRowDict(rowBuffer, rowNum, allColumns); + yield return rowDict; + + // Return dictionary to pool after yielding + if (rowBuffer.TryGetValue(rowNum, out var sourceDict)) + { + DictionaryPool.Return(sourceDict); + rowBuffer.Remove(rowNum); + } + } + } + } + + private static void ProcessItemData(T item, CompiledMapping mapping, SortedDictionary> rowBuffer, int itemIndex, WriterBoundaries boundaries) + { + if(item == null) + throw new ArgumentNullException(nameof(item)); + + // Calculate offset for this item based on boundaries and mapping type + var itemOffset = CalculateItemOffset(itemIndex, boundaries, mapping); + + // Add simple properties with offset + foreach (var prop in mapping.Properties) + { + var propValue = prop.Getter(item); + var cellValue = ConvertValue(propValue, prop); + var offsetCellAddress = ApplyOffset(prop.CellAddress, itemOffset.RowOffset, itemOffset.ColumnOffset); + AddCellToBuffer(rowBuffer, offsetCellAddress, cellValue); + } + + // Add collections with offset + foreach (var coll in mapping.Collections) + { + var collection = coll.Getter(item); + if (collection != null) + { + var offsetStartCell = ApplyOffset(coll.StartCell, itemOffset.RowOffset, itemOffset.ColumnOffset); + foreach (var cellInfo in StreamCollectionCellsWithOffset(collection, coll, offsetStartCell)) + { + AddCellToBuffer(rowBuffer, cellInfo.Key, cellInfo.Value); + } + } + } + } + + private static void AddCellToBuffer(SortedDictionary> rowBuffer, string cellAddress, object value) + { + if (!ReferenceHelper.ParseReference(cellAddress, out _, out int row)) + return; + + if (!rowBuffer.ContainsKey(row)) + rowBuffer[row] = DictionaryPool.Rent(); + + rowBuffer[row][cellAddress] = value; + } + + private static WriterBoundaries CalculateMappingBoundaries(CompiledMapping mapping) + { + var boundaries = new WriterBoundaries(); + + // Find the maximum row used by properties + foreach (var prop in mapping.Properties) + { + if (ReferenceHelper.ParseReference(prop.CellAddress, out int col, out int row)) + { + if (row > boundaries.MaxPropertyRow) + boundaries.MaxPropertyRow = row; + if (col > boundaries.MaxPropertyColumn) + boundaries.MaxPropertyColumn = col; + } + } + + // Calculate boundaries for collections + foreach (var coll in mapping.Collections) + { + if (ReferenceHelper.ParseReference(coll.StartCell, out _, out var startRow)) + { + // Also update MaxPropertyRow to include collection start rows + if (startRow > boundaries.MaxPropertyRow) + boundaries.MaxPropertyRow = startRow; + + // Collection layout information is handled during actual writing + } + } + + return boundaries; + } + + private static ItemOffset CalculateItemOffset(int itemIndex, WriterBoundaries boundaries, CompiledMapping mapping) + { + if (itemIndex == 0) + { + // First item uses original positions + return new ItemOffset { RowOffset = 0, ColumnOffset = 0 }; + } + + // For subsequent items, we need to offset based on the mapping layout + + // If all properties are on row 1 (A1, B1, C1...), it's a table pattern - items go in consecutive rows + // Otherwise, offset by the max property row + spacing for each item + var allOnRow1 = mapping.Properties.All(p => p.CellRow == 1); + var spacing = allOnRow1 ? 1 : (boundaries.MaxPropertyRow + 2); + var rowOffset = itemIndex * spacing; + + return new ItemOffset { RowOffset = rowOffset, ColumnOffset = 0 }; + } + + private static string ApplyOffset(string cellAddress, int rowOffset, int columnOffset) + { + if (!ReferenceHelper.ParseReference(cellAddress, out int col, out int row)) + return cellAddress; + + var newRow = row + rowOffset; + var newCol = col + columnOffset; + + return ReferenceHelper.ConvertCoordinatesToCell(newCol, newRow); + } + + private static Dictionary CreateRowDict(SortedDictionary> rowBuffer, int rowNum, List allColumns) + { + var rowDict = DictionaryPool.Rent(); + + if (rowBuffer.TryGetValue(rowNum, out var sourceRow)) + { + foreach (var column in allColumns) + { + object? cellValue = null; + foreach (var kvp in sourceRow) + { + if (ReferenceHelper.GetCellLetter(kvp.Key) == column) + { + cellValue = kvp.Value; + break; + } + } + rowDict[column] = cellValue ?? string.Empty; + } + } + else + { + // Empty row + foreach (var column in allColumns) + { + rowDict[column] = string.Empty; + } + } + + return rowDict; + } + + private static List DetermineAllColumns(CompiledMapping mapping) + { + var columns = new HashSet(); + + // Add columns from properties + foreach (var prop in mapping.Properties) + { + if (ReferenceHelper.ParseReference(prop.CellAddress, out int col, out _)) + { + var column = ReferenceHelper.GetCellLetter(ReferenceHelper.ConvertCoordinatesToCell(col, 1)); + if (!string.IsNullOrEmpty(column)) + columns.Add(column); + } + } + + // For collections, determine columns from mapping configuration without iterating data + foreach (var coll in mapping.Collections) + { + if (ReferenceHelper.ParseReference(coll.StartCell, out int startCol, out _)) + { + // Only support vertical collections + if (coll.Layout == CollectionLayout.Vertical) + { + // Vertical layout or nested collection + if (coll.ItemMapping != null) + { + // For nested mappings, get columns from the item mapping + var itemMappingType = coll.ItemMapping.GetType(); + var propsProperty = itemMappingType.GetProperty("Properties"); + + if (propsProperty?.GetValue(coll.ItemMapping) is IEnumerable properties) + { + foreach (var prop in properties) + { + if (ReferenceHelper.ParseReference(prop.CellAddress, out int propCol, out _)) + { + var propColumn = ReferenceHelper.GetCellLetter(ReferenceHelper.ConvertCoordinatesToCell(propCol, 1)); + if (!string.IsNullOrEmpty(propColumn)) + columns.Add(propColumn); + } + } + } + } + else + { + // Simple vertical collection + var col = ReferenceHelper.GetCellLetter(ReferenceHelper.ConvertCoordinatesToCell(startCol, 1)); + if (!string.IsNullOrEmpty(col)) + columns.Add(col); + } + } + } + } + + // Ensure all columns between min and max are included to prevent compression + if (columns.Count > 0) + { + var sortedColumns = columns.OrderBy(c => c).ToList(); + var minColumn = sortedColumns.First(); + var maxColumn = sortedColumns.Last(); + + // Convert column letters to numbers for easier range calculation + var minColNum = ReferenceHelper.ParseReference(minColumn + "1", out int minCol, out _) ? minCol : 1; + var maxColNum = ReferenceHelper.ParseReference(maxColumn + "1", out int maxCol, out _) ? maxCol : 1; + + // Add all columns in the range + var allColumnsInRange = new List(); + for (int col = minColNum; col <= maxColNum; col++) + { + var columnLetter = ReferenceHelper.GetCellLetter(ReferenceHelper.ConvertCoordinatesToCell(col, 1)); + if (!string.IsNullOrEmpty(columnLetter)) + { + allColumnsInRange.Add(columnLetter); + } + } + + return allColumnsInRange; + } + + return columns.OrderBy(c => c).ToList(); + } + + private static IEnumerable> StreamCollectionCellsWithOffset(IEnumerable collection, CompiledCollectionMapping mapping, string offsetStartCell) + { + if (!ReferenceHelper.ParseReference(offsetStartCell, out int startColumn, out int startRow)) + throw new InvalidOperationException($"Invalid start cell address: {offsetStartCell}"); + + var currentRow = startRow; + var currentCol = startColumn; + var itemIndex = 0; + + // Process collection items one at a time without buffering + foreach (var item in collection) + { + if (mapping.ItemMapping != null && mapping.ItemType != null) + { + // Complex item with nested mapping + var itemMapping = mapping.ItemMapping; + var itemMappingType = itemMapping.GetType(); + var propsProperty = itemMappingType.GetProperty("Properties"); + + if (propsProperty?.GetValue(itemMapping) is IEnumerable properties) + { + foreach (var prop in properties) + { + var propValue = prop.Getter(item); + var cellValue = ConvertValue(propValue, prop); + + // For nested mappings, we need to adjust the property's cell address relative to the collection item's position + if (!ReferenceHelper.ParseReference(prop.CellAddress, out int propCol, out int propRow)) + continue; + + // Only vertical layout is supported + var cellAddress = mapping.Layout == CollectionLayout.Vertical + ? ReferenceHelper.ConvertCoordinatesToCell(startColumn + propCol - 1, currentRow + propRow - 1) + : prop.CellAddress; + + yield return new KeyValuePair(cellAddress, cellValue); + } + } + } + else + { + // Simple item - just write the value + var cellAddress = CalculateCellPositionWithBase(offsetStartCell, currentRow, currentCol, itemIndex, mapping, startColumn, startRow); + yield return new KeyValuePair(cellAddress, ConvertValue(item, null)); + } + + // Update position for next item + UpdatePosition(ref currentRow, ref currentCol, ref itemIndex, mapping); + } + } + + private static string CalculateCellPositionWithBase(string baseCellAddress, int currentRow, int currentCol, int itemIndex, CompiledCollectionMapping mapping, int startColumn, int startRow) + { + // Only vertical layout is supported + return ReferenceHelper.ConvertCoordinatesToCell(startColumn, currentRow); + } + + private static void UpdatePosition(ref int currentRow, ref int currentCol, ref int itemIndex, CompiledCollectionMapping mapping) + { + itemIndex++; + + // Only vertical layout is supported + if (mapping.Layout == CollectionLayout.Vertical) + { + currentRow += 1 + mapping.RowSpacing; + } + } + + private static object ConvertValue(object? value, CompiledPropertyMapping? prop) + { + if (value == null) return string.Empty; + + if (prop != null && !string.IsNullOrEmpty(prop.Format)) + { + if (value is IFormattable formattable) + return formattable.ToString(prop.Format, null); + } + + return value; + } + + // Universal optimized writer implementation + [CreateSyncVersion] + private static async Task SaveAsUniversalAsync(Stream stream, IEnumerable value, CompiledMapping mapping, CancellationToken cancellationToken = default) + { + if (!mapping.IsUniversallyOptimized) + throw new InvalidOperationException("SaveAsUniversalAsync requires a universally optimized mapping"); + + // Use optimized direct row streaming based on pre-calculated cell grid + var rowEnumerable = CreateOptimizedRows(value, mapping); + + var configuration = new OpenXmlConfiguration { FastMode = true }; + var writer = await OpenXmlWriter + .CreateAsync(stream, rowEnumerable, mapping.WorksheetName, false, configuration, cancellationToken) + .ConfigureAwait(false); + + return await writer.SaveAsAsync(cancellationToken).ConfigureAwait(false); + } + + private static IEnumerable> CreateOptimizedRows(IEnumerable items, CompiledMapping mapping) + { + var boundaries = mapping.OptimizedBoundaries!; + + // For simple mappings without collections, handle row positioning + if (!mapping.Collections.Any()) + { + // If data starts at row > 1, we need to write placeholder rows + if (boundaries.MinRow > 1) + { + // Write a single placeholder row with all columns + var placeholderRow = new Dictionary(); + for (int col = boundaries.MinColumn; col <= boundaries.MaxColumn; col++) + { + var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( + OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(col, 1)); + placeholderRow[columnLetter] = ""; + } + + // Write placeholder rows until we reach the data start row + for (int emptyRow = 1; emptyRow < boundaries.MinRow; emptyRow++) + { + yield return new Dictionary(placeholderRow); + } + } + + // Now write the actual data rows + var currentRow = boundaries.MinRow; + foreach (var item in items) + { + if (item == null) continue; + + // For column layouts (GridHeight > 1), we need to write multiple rows for each item + if (boundaries.GridHeight > 1) + { + // Write multiple rows for this item - one for each row in the grid + for (int gridRow = 0; gridRow < boundaries.GridHeight; gridRow++) + { + var absoluteRow = boundaries.MinRow + gridRow; + var row = CreateColumnLayoutRowForItem(item, absoluteRow, gridRow, mapping, boundaries); + if (row.Count > 0) + { + yield return row; + } + } + } + else + { + // Regular single-row layout + var row = CreateSimpleRowForItem(item, currentRow, mapping, boundaries); + if (row.Count > 0) + { + yield return row; + } + currentRow++; + } + } + } + else + { + // For complex mappings with collections, use the existing grid approach + var cellGrid = mapping.OptimizedCellGrid!; + var itemList = items.ToList(); + if (itemList.Count == 0) yield break; + + // If data starts at row > 1, we need to write placeholder rows first + if (boundaries.MinRow > 1) + { + // Write empty placeholder rows to position data correctly + // IMPORTANT: Must include ALL columns that will be used in data rows + // Otherwise OpenXmlWriter will only write columns present in first row + var placeholderRow = new Dictionary(); + + // Find the maximum column that will have data + int maxDataCol = 0; + for (int relativeCol = 0; relativeCol < cellGrid.GetLength(1); relativeCol++) + { + for (int relativeRow = 0; relativeRow < cellGrid.GetLength(0); relativeRow++) + { + var handler = cellGrid[relativeRow, relativeCol]; + if (handler.Type != CellHandlerType.Empty) + { + maxDataCol = Math.Max(maxDataCol, relativeCol + boundaries.MinColumn); + } + } + } + + // Initialize all columns that will be used + for (int col = 1; col <= maxDataCol; col++) + { + var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( + OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(col, 1)); + placeholderRow[columnLetter] = ""; + } + + for (int emptyRow = 1; emptyRow < boundaries.MinRow; emptyRow++) + { + yield return new Dictionary(placeholderRow); + } + } + + var totalRowsNeeded = CalculateTotalRowsNeeded(itemList, mapping, boundaries); + + for (int absoluteRow = boundaries.MinRow; absoluteRow <= totalRowsNeeded; absoluteRow++) + { + var row = CreateRowForAbsoluteRow(absoluteRow, itemList, mapping, cellGrid, boundaries); + + // Always yield rows, even if empty, to maintain proper row spacing + // If row is empty, ensure it has at least column A for OpenXmlWriter + if (row.Count == 0) + { + row["A"] = ""; + } + + yield return row; + } + } + } + + private static Dictionary CreateSimpleRowForItem(T item, int currentRow, CompiledMapping mapping, OptimizedMappingBoundaries boundaries) + { + var row = new Dictionary(); + + // Initialize all columns with empty values + for (int col = boundaries.MinColumn; col <= boundaries.MaxColumn; col++) + { + var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( + OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(col, 1)); + row[columnLetter] = string.Empty; + } + + // Fill in property values + foreach (var prop in mapping.Properties) + { + var value = prop.Getter(item); + if (value != null) + { + var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( + OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(prop.CellColumn, 1)); + + + // Apply formatting if specified + if (!string.IsNullOrEmpty(prop.Format) && value is IFormattable formattable) + { + value = formattable.ToString(prop.Format, null); + } + + row[columnLetter] = value; + } + } + + return row; + } + + private static Dictionary CreateColumnLayoutRowForItem(T item, int absoluteRow, int gridRow, CompiledMapping mapping, OptimizedMappingBoundaries boundaries) + { + var row = new Dictionary(); + + // Initialize all columns with empty values - start from column A to ensure proper column positioning + int startCol = Math.Min(1, boundaries.MinColumn); // Always include column A (column 1) + for (int col = startCol; col <= boundaries.MaxColumn; col++) + { + var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( + OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(col, 1)); + row[columnLetter] = string.Empty; + } + + // Only fill in the property value that belongs to this specific row + foreach (var prop in mapping.Properties) + { + // Check if this property belongs to the current row + if (prop.CellRow == absoluteRow) + { + var value = prop.Getter(item); + if (value != null) + { + var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( + OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(prop.CellColumn, 1)); + + + // Apply formatting if specified + if (!string.IsNullOrEmpty(prop.Format) && value is IFormattable formattable) + { + value = formattable.ToString(prop.Format, null); + } + + row[columnLetter] = value; + } + } + } + + return row; + } + + private static int CalculateTotalRowsNeeded(List items, CompiledMapping mapping, OptimizedMappingBoundaries boundaries) + { + // Use pre-calculated pattern height if available + if (boundaries.IsMultiItemPattern && boundaries.PatternHeight > 0 && items.Count > 1) + { + // Use the pre-calculated pattern height for efficiency + var totalRows = boundaries.MinRow - 1; // Start counting from before first data row + + foreach (var item in items) + { + if (item == null) continue; + + // Each item starts where the previous one ended + var itemStartRow = totalRows + 1; + var itemMaxRow = itemStartRow; + + // Use pre-calculated pattern height, but also check actual collection sizes + // to ensure we have enough space + foreach (var expansion in mapping.CollectionExpansions ?? []) + { + var collection = expansion.CollectionMapping.Getter(item); + if (collection != null) + { + var collectionSize = collection.Cast().Count(); + if (collectionSize > 0) + { + // Adjust collection start row relative to this item's start + var collStartRow = itemStartRow + (expansion.StartRow - boundaries.MinRow); + var collectionMaxRow = collStartRow + (collectionSize - 1) * (1 + expansion.RowSpacing); + itemMaxRow = Math.Max(itemMaxRow, collectionMaxRow); + } + } + } + + // Use at least the pattern height to maintain consistent spacing + itemMaxRow = Math.Max(itemMaxRow, itemStartRow + boundaries.PatternHeight - 1); + totalRows = itemMaxRow; + } + + return totalRows; + } + // For scenarios with multiple items and collections without pre-calculated pattern + else if (items.Count > 1 && mapping.Collections.Any()) + { + // Multiple items with collections - each item needs its own space + var totalRows = boundaries.MinRow - 1; // Start counting from before first data row + + foreach (var item in items) + { + if (item == null) continue; + + // Each item starts where the previous one ended + var itemStartRow = totalRows + 1; + var itemMaxRow = itemStartRow; + + // Account for properties + foreach (var prop in mapping.Properties) + { + // Adjust property row relative to this item's start + var propRow = itemStartRow + (prop.CellRow - boundaries.MinRow); + itemMaxRow = Math.Max(itemMaxRow, propRow); + } + + // Account for collections + foreach (var expansion in mapping.CollectionExpansions ?? []) + { + var collection = expansion.CollectionMapping.Getter(item); + if (collection != null) + { + var collectionSize = collection.Cast().Count(); + if (collectionSize > 0) + { + // Adjust collection start row relative to this item's start + var collStartRow = itemStartRow + (expansion.StartRow - boundaries.MinRow); + var collectionMaxRow = collStartRow + (collectionSize - 1) * (1 + expansion.RowSpacing); + itemMaxRow = Math.Max(itemMaxRow, collectionMaxRow); + } + } + } + + totalRows = itemMaxRow; + } + + return totalRows; + } + else + { + // Single item or no collections - use original logic + var maxRow = 0; + + // Consider fixed properties + foreach (var prop in mapping.Properties) + { + maxRow = Math.Max(maxRow, prop.CellRow); + } + + // Calculate expanded rows based on actual collection sizes + foreach (var item in items) + { + if (item == null) continue; + + foreach (var expansion in mapping.CollectionExpansions ?? []) + { + var collection = expansion.CollectionMapping.Getter(item); + if (collection != null) + { + var collectionSize = collection.Cast().Count(); + if (collectionSize > 0) + { + var collectionMaxRow = CalculateCollectionMaxRow(expansion, collectionSize); + maxRow = Math.Max(maxRow, collectionMaxRow); + } + } + } + } + + return maxRow; + } + } + + private static int CalculateCollectionMaxRow(CollectionExpansionInfo expansion, int collectionSize) + { + // Only support vertical collections + if (expansion.Layout == CollectionLayout.Vertical) + { + return expansion.StartRow + (collectionSize - 1) * (1 + expansion.RowSpacing); + } + + return expansion.StartRow; + } + + private static Dictionary CreateRowForAbsoluteRow(int absoluteRow, List items, + CompiledMapping mapping, OptimizedCellHandler[,] cellGrid, OptimizedMappingBoundaries boundaries) + { + var row = new Dictionary(); + + // For multiple items with collections, determine which item this row belongs to + int itemIndex = 0; + int itemStartRow = boundaries.MinRow; + + if (boundaries.IsMultiItemPattern && boundaries.PatternHeight > 0 && items.Count > 1) + { + // Use pre-calculated pattern to determine item - ZERO runtime calculation! + var relativeRowForPattern = absoluteRow - boundaries.MinRow; + + // Pre-calculated: which item does this belong to? + // But we need to account for actual collection sizes which may vary + var currentRow = boundaries.MinRow; + + for (int i = 0; i < items.Count; i++) + { + if (items[i] == null) continue; + + // Calculate this item's actual row span (may differ from pattern if collections vary) + var itemEndRow = currentRow + boundaries.PatternHeight - 1; + + // Check actual collection sizes to ensure we have the right boundaries + foreach (var expansion in mapping.CollectionExpansions ?? []) + { + var collection = expansion.CollectionMapping.Getter(items[i]); + if (collection != null) + { + var collectionSize = collection.Cast().Count(); + if (collectionSize > 0) + { + var collStartRow = currentRow + (expansion.StartRow - boundaries.MinRow); + var collectionMaxRow = collStartRow + (collectionSize - 1) * (1 + expansion.RowSpacing); + itemEndRow = Math.Max(itemEndRow, collectionMaxRow); + } + } + } + + if (absoluteRow >= currentRow && absoluteRow <= itemEndRow) + { + itemIndex = i; + itemStartRow = currentRow; + break; + } + + currentRow = itemEndRow + 1; + } + } + else if (items.Count > 1 && mapping.Collections.Any()) + { + // Fallback for non-pattern scenarios + var currentRow = boundaries.MinRow; + + for (int i = 0; i < items.Count; i++) + { + if (items[i] == null) continue; + + // Calculate this item's row span + var itemEndRow = currentRow; + + // Account for properties + foreach (var prop in mapping.Properties) + { + var propRow = currentRow + (prop.CellRow - boundaries.MinRow); + itemEndRow = Math.Max(itemEndRow, propRow); + } + + // Account for collections + foreach (var expansion in mapping.CollectionExpansions ?? []) + { + var collection = expansion.CollectionMapping.Getter(items[i]); + if (collection != null) + { + var collectionSize = collection.Cast().Count(); + if (collectionSize > 0) + { + var collStartRow = currentRow + (expansion.StartRow - boundaries.MinRow); + var collectionMaxRow = collStartRow + (collectionSize - 1) * (1 + expansion.RowSpacing); + itemEndRow = Math.Max(itemEndRow, collectionMaxRow); + } + } + } + + if (absoluteRow >= currentRow && absoluteRow <= itemEndRow) + { + itemIndex = i; + itemStartRow = currentRow; + break; + } + + currentRow = itemEndRow + 1; + } + } + + // Calculate relative row within the item's space + var relativeRow = absoluteRow - itemStartRow; + + // Map to grid row (original mapping space) + var gridRow = relativeRow; + + // If row is beyond our pre-calculated grid, use the pattern from the last grid row + // This allows unlimited data without runtime overhead + if (gridRow >= cellGrid.GetLength(0)) + { + // For collections that extend beyond our grid, repeat the last row's pattern + // This is pre-calculated with zero runtime parsing + gridRow = cellGrid.GetLength(0) - 1; + } + + if (gridRow < 0) + { + return row; + } + + // Initialize columns up to the last actual data column + // This ensures proper column spacing + int maxDataCol = 0; + for (int relativeCol = 0; relativeCol < cellGrid.GetLength(1); relativeCol++) + { + var handler = cellGrid[gridRow, relativeCol]; + if (handler.Type != CellHandlerType.Empty) + { + maxDataCol = relativeCol + boundaries.MinColumn; + } + } + + // Initialize ALL columns from A to the maximum column in boundaries + // This ensures all rows have the same columns, which is required by OpenXmlWriter + for (int col = 1; col <= boundaries.MaxColumn; col++) + { + var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( + OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(col, 1)); + row[columnLetter] = ""; + } + + // Process each column in the row using the pre-calculated cell grid + for (int relativeCol = 0; relativeCol < cellGrid.GetLength(1); relativeCol++) + { + var cellHandler = cellGrid[gridRow, relativeCol]; + if (cellHandler.Type == CellHandlerType.Empty) + continue; + + var absoluteCol = relativeCol + boundaries.MinColumn; + + // Get just the column letter for the dictionary key + var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( + OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(absoluteCol, 1)); + + object? cellValue = null; + + switch (cellHandler.Type) + { + case CellHandlerType.Property: + // Use the item that this row belongs to + if (itemIndex >= 0 && itemIndex < items.Count && items[itemIndex] != null) + { + if (cellHandler.ValueExtractor != null) + { + cellValue = cellHandler.ValueExtractor(items[itemIndex], itemIndex); + } + } + break; + + case CellHandlerType.CollectionItem: + // Collection item - extract from the correct item + if (itemIndex >= 0 && itemIndex < items.Count && items[itemIndex] != null) + { + cellValue = ExtractCollectionItemValueForItem(items[itemIndex], cellHandler, absoluteRow - itemStartRow + boundaries.MinRow, absoluteCol, boundaries); + } + break; + + case CellHandlerType.Formula: + // Formula - set the formula directly + cellValue = cellHandler.Formula; + break; + } + + if (cellValue != null) + { + // Type conversion is built into the ValueExtractor + + // Apply formatting if specified + if (!string.IsNullOrEmpty(cellHandler.Format) && cellValue is IFormattable formattable) + { + cellValue = formattable.ToString(cellHandler.Format, null); + } + + row[columnLetter] = cellValue; + } + } + + return row; + } + + private static object? ExtractCollectionItemValueForItem(T item, OptimizedCellHandler cellHandler, + int absoluteRow, int absoluteCol, OptimizedMappingBoundaries boundaries) + { + if (cellHandler.CollectionMapping == null || item == null) + return null; + + var collectionMapping = cellHandler.CollectionMapping; + var collection = collectionMapping.Getter(item); + if (collection == null) return null; + + // Calculate the actual item index based on the absolute row + // This handles rows beyond our pre-calculated grid + int actualItemIndex = cellHandler.CollectionItemOffset; + + // For vertical collections with row spacing, calculate the actual index + if (collectionMapping.Layout == CollectionLayout.Vertical) + { + var rowsSinceStart = absoluteRow - collectionMapping.StartCellRow; + if (rowsSinceStart >= 0) + { + // Calculate which item this row belongs to based on row spacing + // This is O(1) - just arithmetic, no iteration + actualItemIndex = rowsSinceStart / (1 + collectionMapping.RowSpacing); + } + } + + // If we have a pre-compiled value extractor for nested properties, use it + if (cellHandler.ValueExtractor != null) + { + // The ValueExtractor was pre-compiled to extract the specific property from the nested object + return cellHandler.ValueExtractor(item, actualItemIndex); + } + + // Otherwise fall back to simple collection item extraction + var collectionItems = collection.Cast().ToArray(); + if (collectionItems.Length == 0) return null; + + // Return the collection item if index is valid + return actualItemIndex >= 0 && actualItemIndex < collectionItems.Length ? collectionItems[actualItemIndex] : null; + } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/Mapping/OptimizedMappingExecutor.cs b/src/MiniExcel.Core/Mapping/OptimizedMappingExecutor.cs new file mode 100644 index 00000000..8f7213bf --- /dev/null +++ b/src/MiniExcel.Core/Mapping/OptimizedMappingExecutor.cs @@ -0,0 +1,146 @@ +using System.Runtime.CompilerServices; + +namespace MiniExcelLib.Core.Mapping; + +/// +/// Optimized executor that uses pre-calculated handler arrays for maximum performance. +/// Zero allocations, zero lookups, just direct array access. +/// +public sealed class OptimizedMappingExecutor +{ + // Pre-calculated array of value extractors indexed by column (0-based) + private readonly Func[] _columnGetters; + + // Pre-calculated array of value setters indexed by column (0-based) + private readonly Action[] _columnSetters; + + // Column count for bounds checking + private readonly int _columnCount; + + // Minimum column number (1-based) for offset calculation + private readonly int _minColumn; + + public OptimizedMappingExecutor(CompiledMapping mapping) + { + if (mapping?.OptimizedBoundaries == null) + throw new ArgumentException("Mapping must be optimized"); + + var boundaries = mapping.OptimizedBoundaries; + _minColumn = boundaries.MinColumn; + _columnCount = boundaries.GridWidth; + + // Pre-allocate arrays + _columnGetters = new Func[_columnCount]; + _columnSetters = new Action[_columnCount]; + + // Build optimized getters and setters for each column + BuildOptimizedHandlers(mapping); + } + + private void BuildOptimizedHandlers(CompiledMapping mapping) + { + // Initialize all columns with no-op handlers + for (int i = 0; i < _columnCount; i++) + { + _columnGetters[i] = static (obj) => null; + _columnSetters[i] = static (obj, val) => { }; + } + + // Map properties to their column positions + foreach (var prop in mapping.Properties) + { + var columnIndex = prop.CellColumn - _minColumn; + if (columnIndex >= 0 && columnIndex < _columnCount) + { + // Create optimized getter that directly accesses the property + var getter = prop.Getter; + _columnGetters[columnIndex] = (T obj) => getter(obj); + + // Create optimized setter if available + var setter = prop.Setter; + if (setter != null) + { + _columnSetters[columnIndex] = (T obj, object? value) => setter(obj, value); + } + } + } + + // Pre-calculate collection element accessors + foreach (var collection in mapping.Collections) + { + PreCalculateCollectionAccessors(collection, mapping.OptimizedBoundaries!); + } + } + + private void PreCalculateCollectionAccessors(CompiledCollectionMapping collection, OptimizedMappingBoundaries boundaries) + { + var startCol = collection.StartCellColumn; + var startRow = collection.StartCellRow; + + // Only support vertical collections + if (collection.Layout == CollectionLayout.Vertical) + { + // For vertical, we'd handle differently based on row + // This is simplified - real implementation would consider rows + var colIndex = startCol - _minColumn; + if (colIndex >= 0 && colIndex < _columnCount) + { + var collectionGetter = collection.Getter; + _columnGetters[colIndex] = (T obj) => + { + var enumerable = collectionGetter(obj); + return enumerable?.Cast().FirstOrDefault(); + }; + } + } + } + + /// + /// Get value for a specific column + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public object? GetValue(T item, int column) + { + var index = column - _minColumn; + if (index >= 0 && index < _columnCount) + { + return _columnGetters[index](item); + } + return null; + } + + /// + /// Set value for a specific column + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SetValue(T item, int column, object? value) + { + var index = column - _minColumn; + if (index >= 0 && index < _columnCount) + { + _columnSetters[index](item, value); + } + } + + /// + /// Create optimized row dictionary for OpenXmlWriter + /// + public Dictionary CreateRow(T item) + { + var row = new Dictionary(_columnCount); + + for (int i = 0; i < _columnCount; i++) + { + var value = _columnGetters[i](item); + if (value != null) + { + var column = i + _minColumn; + var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( + OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(column, 1)); + row[columnLetter] = value; + } + } + + return row; + } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/MiniExcelProviders.cs b/src/MiniExcel.Core/MiniExcelProviders.cs index 9a15527c..7f8cdeb3 100644 --- a/src/MiniExcel.Core/MiniExcelProviders.cs +++ b/src/MiniExcel.Core/MiniExcelProviders.cs @@ -5,6 +5,8 @@ public sealed class MiniExcelImporterProvider internal MiniExcelImporterProvider() { } public OpenXmlImporter GetOpenXmlImporter() => new(); + public MappingImporter GetMappingImporter() => new(); + public MappingImporter GetMappingImporter(MappingRegistry registry) => new(registry); } public sealed class MiniExcelExporterProvider @@ -12,6 +14,8 @@ public sealed class MiniExcelExporterProvider internal MiniExcelExporterProvider() { } public OpenXmlExporter GetOpenXmlExporter() => new(); + public MappingExporter GetMappingExporter() => new(); + public MappingExporter GetMappingExporter(MappingRegistry registry) => new(registry); } public sealed class MiniExcelTemplaterProvider diff --git a/tests/MiniExcel.Core.Tests/MiniExcelMappingCompilerTests.cs b/tests/MiniExcel.Core.Tests/MiniExcelMappingCompilerTests.cs new file mode 100644 index 00000000..0017ed7c --- /dev/null +++ b/tests/MiniExcel.Core.Tests/MiniExcelMappingCompilerTests.cs @@ -0,0 +1,349 @@ +using MiniExcelLib.Core.Mapping; +using MiniExcelLib.Core.Mapping.Configuration; +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace MiniExcelLib.Tests +{ + /// + /// Tests for the mapping compiler and optimization system. + /// Focuses on internal optimization details and performance characteristics. + /// + public class MiniExcelMappingCompilerTests + { + #region Test Models + + public class SimpleEntity + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public decimal Value { get; set; } + } + + public 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 universal optimization is applied + Assert.True(mapping.IsUniversallyOptimized); + 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.True(mapping.IsUniversallyOptimized); + Assert.NotNull(mapping.OptimizedBoundaries); + Assert.NotNull(mapping.OptimizedCellGrid); + } + + #endregion + + #region Cell Grid Tests + + [Fact] + public void OptimizedCellGrid_Should_Have_Correct_Dimensions() + { + // Arrange + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Id).ToCell("A1"); + cfg.Property(e => e.Name).ToCell("E1"); // Column E + cfg.Property(e => e.Value).ToCell("C3"); // Row 3 + }); + + // Act + var mapping = registry.GetMapping(); + + // Assert + Assert.NotNull(mapping.OptimizedBoundaries); + var boundaries = mapping.OptimizedBoundaries; + + Assert.Equal(1, boundaries.MinRow); + Assert.Equal(3, boundaries.MaxRow); + Assert.Equal(1, boundaries.MinColumn); // A = 1 + Assert.Equal(5, boundaries.MaxColumn); // E = 5 + + Assert.Equal(3, boundaries.GridHeight); // 3 - 1 + 1 = 3 + Assert.Equal(5, boundaries.GridWidth); // 5 - 1 + 1 = 5 + } + + [Fact] + public void OptimizedCellGrid_Should_Map_Properties_Correctly() + { + // Arrange + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Id).ToCell("B2"); + cfg.Property(e => e.Name).ToCell("D2"); + cfg.Property(e => e.Value).ToCell("B4"); + }); + + // Act + var mapping = registry.GetMapping(); + var grid = mapping.OptimizedCellGrid!; + var boundaries = mapping.OptimizedBoundaries!; + + // Assert + // Grid should be 3x3 (rows 2-4, columns B-D which is 2-4) + Assert.Equal(3, grid.GetLength(0)); // Height + Assert.Equal(3, grid.GetLength(1)); // Width + + // Check Id at B2 (relative: 0,0) + var idHandler = grid[0, 0]; + Assert.Equal(CellHandlerType.Property, idHandler.Type); + Assert.Equal("Id", idHandler.PropertyName); + + // Check Name at D2 (relative: 0,2) + var nameHandler = grid[0, 2]; + Assert.Equal(CellHandlerType.Property, nameHandler.Type); + Assert.Equal("Name", nameHandler.PropertyName); + + // Check Value at B4 (relative: 2,0) + var valueHandler = grid[2, 0]; + Assert.Equal(CellHandlerType.Property, valueHandler.Type); + Assert.Equal("Value", valueHandler.PropertyName); + + // Check empty cells + Assert.Equal(CellHandlerType.Empty, grid[0, 1].Type); // C2 + Assert.Equal(CellHandlerType.Empty, grid[1, 0].Type); // B3 + } + + #endregion + + #region Collection Optimization Tests + + [Fact] + public void Collection_Should_Mark_Grid_Cells() + { + // Arrange + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Id).ToCell("A1"); + cfg.Property(e => e.Title).ToCell("B1"); + cfg.Collection(e => e.Items).StartAt("A2"); + }); + + // Act + var mapping = registry.GetMapping(); + var grid = mapping.OptimizedCellGrid!; + + // Assert + // Check that collection cells are marked + // Note: Collection handling depends on implementation details + Assert.NotNull(grid); + Assert.True(mapping.OptimizedBoundaries!.HasDynamicCollections); + } + + [Fact] + public void Multiple_Collections_Should_Be_Handled() + { + // Arrange + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Id).ToCell("A1"); + cfg.Collection(e => e.Items).StartAt("B1"); + cfg.Collection(e => e.Properties).StartAt("C1"); + }); + + // Act + var mapping = registry.GetMapping(); + + // Assert + Assert.Equal(2, mapping.Collections.Count); + Assert.True(mapping.IsUniversallyOptimized); + } + + #endregion + + #region Pre-compilation Tests + + [Fact] + public void Property_Getters_Should_Be_Compiled() + { + // Arrange + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Id).ToCell("A1"); + cfg.Property(e => e.Name).ToCell("B1"); + }); + + // Act + var mapping = registry.GetMapping(); + + // Assert + foreach (var prop in mapping.Properties) + { + Assert.NotNull(prop.Getter); + + // Test getter works + var entity = new SimpleEntity { Id = 123, Name = "Test" }; + var idValue = mapping.Properties[0].Getter(entity); + Assert.Equal(123, idValue); + } + } + + [Fact] + public void Property_Setters_Should_Be_Compiled() + { + // Arrange + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Id).ToCell("A1"); + cfg.Property(e => e.Name).ToCell("B1"); + }); + + // Act + var mapping = registry.GetMapping(); + + // Assert + foreach (var prop in mapping.Properties) + { + Assert.NotNull(prop.Setter); + + // Test setter works + var entity = new SimpleEntity(); + mapping.Properties[0].Setter!(entity, 456); + Assert.Equal(456, entity.Id); + } + } + + #endregion + + #region Formula and Format Tests + + [Fact] + public void Formula_Properties_Should_Be_Marked() + { + // Arrange + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Id).ToCell("A1"); + cfg.Property(e => e.Value).ToCell("B1").WithFormula("=A1*2"); + }); + + // Act + var mapping = registry.GetMapping(); + var grid = mapping.OptimizedCellGrid!; + + // Assert + var formulaHandler = grid[0, 1]; // B1 relative position + Assert.Equal(CellHandlerType.Formula, formulaHandler.Type); + Assert.Equal("=A1*2", formulaHandler.Formula); + } + + [Fact] + public void Format_Should_Be_Preserved() + { + // Arrange + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Value).ToCell("A1").WithFormat("#,##0.00"); + }); + + // Act + var mapping = registry.GetMapping(); + + // Assert + var prop = mapping.Properties[0]; + Assert.Equal("#,##0.00", prop.Format); + } + + #endregion + + #region Edge Cases + + [Fact] + public void Empty_Configuration_Should_Be_Valid() + { + // Arrange + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + // No mappings + }); + + // Act + var mapping = registry.GetMapping(); + + // Assert + Assert.NotNull(mapping); + Assert.Empty(mapping.Properties); + Assert.Empty(mapping.Collections); + } + + [Fact] + public void Duplicate_Cell_Mapping_Should_Be_Allowed() + { + // Arrange + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Id).ToCell("A1"); + cfg.Property(e => e.Name).ToCell("A1"); // Same cell + }); + + // Act + var mapping = registry.GetMapping(); + + // Assert + Assert.Equal(2, mapping.Properties.Count); + // Both properties map to A1 - last one wins in the grid + } + + #endregion + } +} \ No newline at end of file diff --git a/tests/MiniExcel.Core.Tests/MiniExcelMappingTests.cs b/tests/MiniExcel.Core.Tests/MiniExcelMappingTests.cs new file mode 100644 index 00000000..f2dc3dc8 --- /dev/null +++ b/tests/MiniExcel.Core.Tests/MiniExcelMappingTests.cs @@ -0,0 +1,1033 @@ +using MiniExcelLib.Core; +using MiniExcelLib.Core.Mapping; +using MiniExcelLib.Core.Mapping.Configuration; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace MiniExcelLib.Tests +{ + public class MiniExcelMappingTests + { + #region Test Models + + public class Person + { + public string Name { get; set; } = ""; + public int Age { get; set; } + public string Email { get; set; } = ""; + public DateTime BirthDate { get; set; } + public decimal Salary { get; set; } + } + + public class Product + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Category { get; set; } = ""; + public decimal Price { get; set; } + public int Stock { get; set; } + public DateTime LastRestocked { get; set; } + public bool IsActive { get; set; } + public double? DiscountPercentage { get; set; } + } + + public class ComplexEntity + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public DateTime CreatedDate { get; set; } + public double Score { get; set; } + public bool IsEnabled { get; set; } + public string? Description { get; set; } + public decimal Amount { get; set; } + public List Tags { get; set; } = new(); + public int[] Numbers { get; set; } = Array.Empty(); + } + + public class ComplexModel + { + public Guid Id { get; set; } + public string Title { get; set; } = ""; + public DateTimeOffset CreatedAt { get; set; } + public TimeSpan Duration { get; set; } + public byte[] BinaryData { get; set; } = Array.Empty(); + public Uri? Website { get; set; } + } + + public class Department + { + public string Name { get; set; } = ""; + public List Employees { get; set; } = new(); + public List PhoneNumbers { get; set; } = new(); + public string[] Tags { get; set; } = Array.Empty(); + public IEnumerable Projects { get; set; } = Enumerable.Empty(); + } + + public class Company + { + public string Name { get; set; } = ""; + public List Departments { get; set; } = new(); + } + + public class TestModel + { + public string Name { get; set; } = ""; + public int Value { get; set; } + } + + public class Employee + { + public string Name { get; set; } = ""; + public string Position { get; set; } = ""; + public decimal Salary { get; set; } + public List Skills { get; set; } = new(); + } + + public class Project + { + public string Code { get; set; } = ""; + public string Title { get; set; } = ""; + public DateTime StartDate { get; set; } + public decimal Budget { get; set; } + public List Tasks { get; set; } = new(); + } + + public class ProjectTask + { + public string Name { get; set; } = ""; + public int EstimatedHours { get; set; } + public bool IsCompleted { get; set; } + } + + public class Report + { + public string Title { get; set; } = ""; + public DateTime GeneratedAt { get; set; } + public List Numbers { get; set; } = new(); + public Dictionary Metrics { get; set; } = new(); + } + + public class Address + { + public string Street { get; set; } = ""; + public string City { get; set; } = ""; + public string PostalCode { get; set; } = ""; + } + + public class NestedModel + { + public string Name { get; set; } = ""; + public Address HomeAddress { get; set; } = new(); + public Address? WorkAddress { get; set; } + } + + #endregion + + #region Basic Mapping Tests + + [Fact] + public async Task MappingReader_ReadBasicData_Success() + { + // Arrange + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Name).ToCell("A1"); + cfg.Property(p => p.Value).ToCell("B1"); + }); + + var testData = new[] { new TestModel { Name = "Test", Value = 42 } }; + using var stream = new MemoryStream(); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + await exporter.SaveAsAsync(stream, testData); + stream.Position = 0; + + // Act + var importer = MiniExcel.Importers.GetMappingImporter(registry); + var results = await importer.QueryAsync(stream); + var resultList = results.ToList(); + + // Assert + Assert.Single(resultList); + Assert.Equal("Test", resultList[0].Name); + Assert.Equal(42, resultList[0].Value); + } + + [Fact] + public async Task SaveAs_WithBasicMapping_ShouldGenerateCorrectFile() + { + // Arrange + var people = new[] + { + new Person { Name = "Alice", Age = 30, Email = "alice@example.com", BirthDate = new DateTime(1993, 5, 15), Salary = 75000.50m } + }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Name).ToCell("A1"); + cfg.Property(p => p.Age).ToCell("B1"); + cfg.Property(p => p.Email).ToCell("C1"); + cfg.ToWorksheet("People"); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + + // Act & Assert + using var stream = new MemoryStream(); + await exporter.SaveAsAsync(stream, people); + Assert.True(stream.Length > 0); + } + + [Fact] + public void SaveAs_WithBasicMapping_SyncVersion_ShouldGenerateCorrectFile() + { + // Arrange + var people = new[] + { + new Person { Name = "Bob", Age = 25, Email = "bob@example.com", BirthDate = new DateTime(1998, 8, 20), Salary = 60000.00m } + }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Name).ToCell("B2"); + cfg.Property(p => p.Age).ToCell("C2"); + cfg.Property(p => p.Email).ToCell("D2"); + cfg.ToWorksheet("Employees"); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + + // Act & Assert + using var stream = new MemoryStream(); + exporter.SaveAs(stream, people); + Assert.True(stream.Length > 0); + } + + [Fact] + public async Task Query_WithBasicMapping_ShouldReadDataCorrectly() + { + // Arrange + var testData = new[] + { + new Person { Name = "John", Age = 35, Email = "john@test.com", BirthDate = new DateTime(1988, 3, 10), Salary = 85000m }, + new Person { Name = "Jane", Age = 28, Email = "jane@test.com", BirthDate = new DateTime(1995, 7, 22), Salary = 72000m } + }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Name).ToCell("A1"); + cfg.Property(p => p.Age).ToCell("B1"); + cfg.Property(p => p.Email).ToCell("C1"); + cfg.Property(p => p.BirthDate).ToCell("D1"); + cfg.Property(p => p.Salary).ToCell("E1"); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + var importer = MiniExcel.Importers.GetMappingImporter(registry); + + // Act + using var stream = new MemoryStream(); + await exporter.SaveAsAsync(stream, testData); + + stream.Position = 0; + var results = importer.Query(stream).ToList(); + + // Assert + Assert.NotNull(results); + Assert.NotEmpty(results); + } + + #endregion + + #region Sequential Mapping Tests + + [Fact] + public async Task Sequential_Mapping_Should_Optimize_Performance() + { + // Test that sequential column mappings (A1, B1, C1...) are optimized + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Id).ToCell("A1"); + cfg.Property(p => p.Name).ToCell("B1"); + cfg.Property(p => p.Price).ToCell("C1"); + cfg.Property(p => p.Stock).ToCell("D1"); + cfg.Property(p => p.IsActive).ToCell("E1"); + }); + + var mapping = registry.GetMapping(); + + // Verify universal optimization is applied + Assert.True(mapping.IsUniversallyOptimized); + Assert.NotNull(mapping.OptimizedBoundaries); + Assert.NotNull(mapping.OptimizedCellGrid); + } + + [Fact] + public async Task NonSequential_Mapping_Should_Use_Universal_Optimization() + { + // Test that non-sequential mappings use universal optimization + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Id).ToCell("A1"); + cfg.Property(p => p.Name).ToCell("C1"); // Skip B + cfg.Property(p => p.Price).ToCell("E1"); // Skip D + cfg.Property(p => p.Stock).ToCell("B2"); // Different row + cfg.Property(p => p.IsActive).ToCell("D2"); + }); + + var mapping = registry.GetMapping(); + + // Verify universal optimization is used + Assert.True(mapping.IsUniversallyOptimized); + Assert.NotNull(mapping.OptimizedCellGrid); + Assert.NotNull(mapping.OptimizedBoundaries); + } + + #endregion + + #region Collection Mapping Tests + + [Fact] + public async Task Collection_Vertical_Should_Write_And_Read_Correctly() + { + // Test vertical collection layout (default) + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Id).ToCell("A1"); + cfg.Property(e => e.Name).ToCell("B1"); + cfg.Collection(e => e.Tags).StartAt("C2"); // Vertical by default + }); + + var testData = new[] + { + new ComplexEntity + { + Id = 1, + Name = "Test", + Tags = new List { "Tag1", "Tag2", "Tag3" } + } + }; + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + var importer = MiniExcel.Importers.GetMappingImporter(registry); + + using var stream = new MemoryStream(); + await exporter.SaveAsAsync(stream, testData); + + stream.Position = 0; + var results = importer.Query(stream).ToList(); + + Assert.Single(results); + Assert.Equal(3, results[0].Tags.Count); + Assert.Equal("Tag1", results[0].Tags[0]); + } + + [Fact] + public async Task Collection_ComplexObjectsWithMapping_ShouldMapCorrectly() + { + // Arrange + var departments = new[] + { + new Department + { + Name = "Engineering", + Employees = new List + { + new Person { Name = "Alice", Age = 35, Email = "alice@example.com", Salary = 95000 }, + new Person { Name = "Bob", Age = 28, Email = "bob@example.com", Salary = 75000 }, + new Person { Name = "Charlie", Age = 24, Email = "charlie@example.com", Salary = 55000 } + } + } + }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(d => d.Name).ToCell("A1"); + cfg.Collection(d => d.Employees).StartAt("A3"); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + + // Act + using var stream = new MemoryStream(); + await exporter.SaveAsAsync(stream, departments); + + // Assert + Assert.True(stream.Length > 0); + } + + [Fact] + public async Task Collection_NestedCollections_ShouldMapCorrectly() + { + // Arrange + var departments = new[] + { + new Department + { + Name = "Product Development", + Projects = new List + { + new Project + { + Code = "PROJ-001", + Title = "New Feature", + StartDate = new DateTime(2024, 1, 1), + Budget = 100000, + Tasks = new List + { + new ProjectTask { Name = "Design", EstimatedHours = 40, IsCompleted = true }, + new ProjectTask { Name = "Implementation", EstimatedHours = 120, IsCompleted = false }, + new ProjectTask { Name = "Testing", EstimatedHours = 60, IsCompleted = false } + } + } + } + } + }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(d => d.Name).ToCell("A1"); + cfg.Collection(d => d.Projects).StartAt("A3"); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + + // Act + using var stream = new MemoryStream(); + await exporter.SaveAsAsync(stream, departments); + + // Assert + Assert.True(stream.Length > 0); + } + + [Fact] + public void Collection_WithoutStartCell_ShouldThrowException() + { + // Arrange + var registry = new MappingRegistry(); + + // Act & Assert + var exception = Assert.Throws(() => + { + registry.Configure(cfg => + { + cfg.Collection(d => d.PhoneNumbers); // Missing StartAt() + }); + + var mapping = registry.GetMapping(); + }); + + Assert.Contains("start cell", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Collection_MixedSimpleAndComplex_ShouldMapCorrectly() + { + // Arrange + var department = new Department + { + Name = "Mixed Department", + PhoneNumbers = new List { "555-1111", "555-2222" }, + Employees = new List + { + new Person { Name = "Dave", Age = 35, Email = "dave@example.com", Salary = 85000 }, + new Person { Name = "Eve", Age = 29, Email = "eve@example.com", Salary = 75000 } + } + }; + + var departments = new[] { department }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(d => d.Name).ToCell("A1"); + cfg.Collection(d => d.PhoneNumbers).StartAt("A3"); + cfg.Collection(d => d.Employees).StartAt("C3"); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + + // Act + using var stream = new MemoryStream(); + await exporter.SaveAsAsync(stream, departments); + + // Assert + Assert.True(stream.Length > 0); + } + + #endregion + + #region Complex Type and Formula Tests + + [Fact] + public async Task Formula_Properties_Should_Be_Handled_Correctly() + { + // Test formula support + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Id).ToCell("A1"); + cfg.Property(p => p.Price).ToCell("B1"); + cfg.Property(p => p.Stock).ToCell("C1"); + cfg.Property(p => p.Price).ToCell("D1").WithFormula("=B1*C1"); // Total value formula + }); + + var testData = new[] + { + new Product { Id = 1, Price = 10.50m, Stock = 100 } + }; + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + + using var stream = new MemoryStream(); + await exporter.SaveAsAsync(stream, testData); + Assert.True(stream.Length > 0); + } + + [Fact] + public async Task Format_Properties_Should_Apply_Formatting() + { + // Test format support + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Name).ToCell("A1"); + cfg.Property(p => p.BirthDate).ToCell("B1").WithFormat("yyyy-MM-dd"); + cfg.Property(p => p.Salary).ToCell("C1").WithFormat("#,##0.00"); + }); + + var testData = new[] + { + new Person + { + Name = "Test", + BirthDate = new DateTime(1990, 6, 15), + Salary = 12345.67m + } + }; + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + + using var stream = new MemoryStream(); + await exporter.SaveAsAsync(stream, testData); + Assert.True(stream.Length > 0); + } + + #endregion + + #region Extended Mapping Tests + + [Fact] + public async Task Mapping_WithComplexCellAddresses_ShouldMapCorrectly() + { + // Test various cell address formats + var products = new[] + { + new Product { Id = 1, Name = "Laptop", Price = 999.99m, Stock = 10 } + }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Id).ToCell("AA1"); + cfg.Property(p => p.Name).ToCell("AB1"); + cfg.Property(p => p.Price).ToCell("AC1"); + cfg.Property(p => p.Stock).ToCell("ZZ1"); + cfg.ToWorksheet("Products"); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + using var stream = new MemoryStream(); + await exporter.SaveAsAsync(stream, products); + + // Verify the file was created + Assert.True(stream.Length > 0); + + // Read back and verify + stream.Position = 0; + var importer = MiniExcel.Importers.GetOpenXmlImporter(); + var data = importer.Query(stream); + var firstRow = data.FirstOrDefault(); + Assert.NotNull(firstRow); + } + + [Fact] + public async Task Mapping_WithNumericFormats_ShouldApplyCorrectly() + { + var products = new[] + { + new Product + { + Id = 1, + Name = "Widget", + Price = 1234.5678m, + DiscountPercentage = 0.15 + } + }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Name).ToCell("A1"); + cfg.Property(p => p.Price).ToCell("B1").WithFormat("$#,##0.00"); + cfg.Property(p => p.DiscountPercentage).ToCell("C1").WithFormat("0.00%"); + cfg.ToWorksheet("Formatted"); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + using var stream = new MemoryStream(); + await exporter.SaveAsAsync(stream, products); + + Assert.True(stream.Length > 0); + } + + [Fact] + public async Task Mapping_WithDateFormats_ShouldApplyCorrectly() + { + var products = new[] + { + new Product + { + Name = "Item", + LastRestocked = new DateTime(2024, 3, 15, 14, 30, 0) + } + }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Name).ToCell("A1"); + cfg.Property(p => p.LastRestocked).ToCell("B1").WithFormat("yyyy-MM-dd"); + cfg.Property(p => p.LastRestocked).ToCell("C1").WithFormat("MM/dd/yyyy hh:mm:ss"); + cfg.Property(p => p.LastRestocked).ToCell("D1").WithFormat("dddd, MMMM d, yyyy"); + cfg.ToWorksheet("DateFormats"); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + using var stream = new MemoryStream(); + await exporter.SaveAsAsync(stream, products); + + Assert.True(stream.Length > 0); + } + + [Fact] + public async Task Mapping_WithBooleanValues_ShouldMapCorrectly() + { + var products = new[] + { + new Product { Name = "Active", IsActive = true }, + new Product { Name = "Inactive", IsActive = false } + }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Name).ToCell("A1"); + cfg.Property(p => p.IsActive).ToCell("B1"); + cfg.ToWorksheet("Booleans"); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + using var stream = new MemoryStream(); + await exporter.SaveAsAsync(stream, products); + + Assert.True(stream.Length > 0); + } + + [Fact] + public async Task Mapping_WithMultipleRowsToSameCells_ShouldOverwrite() + { + // When mapping multiple items to the same cells, last one should win + var products = new[] + { + new Product { Id = 1, Name = "First" }, + new Product { Id = 2, Name = "Second" }, + new Product { Id = 3, Name = "Third" } + }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Id).ToCell("A1"); + cfg.Property(p => p.Name).ToCell("B1"); + cfg.ToWorksheet("Overwrite"); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + using var stream = new MemoryStream(); + await exporter.SaveAsAsync(stream, products); + + // The file should contain only the last item's data + Assert.True(stream.Length > 0); + } + + [Fact] + public async Task Mapping_WithComplexTypes_ShouldHandleCorrectly() + { + var items = new[] + { + new ComplexModel + { + Id = Guid.NewGuid(), + Title = "Complex Item", + CreatedAt = DateTimeOffset.Now, + Duration = TimeSpan.FromHours(2.5), + BinaryData = new byte[] { 1, 2, 3, 4, 5 }, + Website = new Uri("https://example.com") + } + }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Id).ToCell("A1"); + cfg.Property(p => p.Title).ToCell("B1"); + cfg.Property(p => p.CreatedAt).ToCell("C1").WithFormat("yyyy-MM-dd HH:mm:ss"); + cfg.Property(p => p.Duration).ToCell("D1"); + cfg.Property(p => p.Website).ToCell("E1"); + cfg.ToWorksheet("ComplexTypes"); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + using var stream = new MemoryStream(); + await exporter.SaveAsAsync(stream, items); + + Assert.True(stream.Length > 0); + } + + [Fact] + public async Task Mapping_WithMultipleConfigurations_ShouldUseLast() + { + var products = new[] + { + new Product { Id = 1, Name = "Test" } + }; + + var registry = new MappingRegistry(); + + // First configuration + registry.Configure(cfg => + { + cfg.Property(p => p.Id).ToCell("A1"); + cfg.Property(p => p.Name).ToCell("B1"); + cfg.ToWorksheet("First"); + }); + + // Second configuration should override + registry.Configure(cfg => + { + cfg.Property(p => p.Id).ToCell("X1"); + cfg.Property(p => p.Name).ToCell("Y1"); + cfg.ToWorksheet("Second"); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + using var stream = new MemoryStream(); + await exporter.SaveAsAsync(stream, products); + + Assert.True(stream.Length > 0); + } + + [Fact] + public void Mapping_WithInvalidCellAddress_ShouldThrowException() + { + var registry = new MappingRegistry(); + + // Test various invalid cell addresses + var invalidAddresses = new[] { "", " ", "123", "A", "1A", "@1" }; + + foreach (var invalidAddress in invalidAddresses) + { + Assert.Throws(() => + { + registry.Configure(cfg => + { + cfg.Property(p => p.Name).ToCell(invalidAddress); + }); + }); + } + } + + [Fact] + public async Task Mapping_WithEnumerableTypes_ShouldHandleCorrectly() + { + // Test with IEnumerable, List, Array, etc. + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Name).ToCell("A1"); + cfg.Property(p => p.Price).ToCell("B1"); + cfg.ToWorksheet("Enumerable"); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + + // Test with array + var array = new[] { new Product { Name = "Array", Price = 10 } }; + using (var stream = new MemoryStream()) + { + await exporter.SaveAsAsync(stream, array); + Assert.True(stream.Length > 0); + } + + // Test with List + var list = new List { new Product { Name = "List", Price = 20 } }; + using (var stream = new MemoryStream()) + { + await exporter.SaveAsAsync(stream, list); + Assert.True(stream.Length > 0); + } + + // Test with IEnumerable + IEnumerable enumerable = list; + using (var stream = new MemoryStream()) + { + await exporter.SaveAsAsync(stream, enumerable); + Assert.True(stream.Length > 0); + } + } + + [Fact] + public async Task Mapping_WithThreadSafety_ShouldWork() + { + var registry = new MappingRegistry(); + var tasks = new List(); + + // Configure multiple types concurrently + for (int i = 0; i < 10; i++) + { + var index = i; + tasks.Add(Task.Run(() => + { + if (index % 2 == 0) + { + registry.Configure(cfg => + { + cfg.Property(p => p.Name).ToCell("A1"); + cfg.ToWorksheet($"Sheet{index}"); + }); + } + else + { + registry.Configure(cfg => + { + cfg.Property(p => p.Title).ToCell("A1"); + cfg.ToWorksheet($"Sheet{index}"); + }); + } + })); + } + + await Task.WhenAll(tasks); + + // Verify both configurations exist + Assert.True(registry.HasMapping()); + Assert.True(registry.HasMapping()); + } + + [Fact] + public async Task Mapping_WithSaveToFile_ShouldCreateFile() + { + var products = new[] + { + new Product { Id = 1, Name = "FileTest", Price = 99.99m } + }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Id).ToCell("A1"); + cfg.Property(p => p.Name).ToCell("B1"); + cfg.Property(p => p.Price).ToCell("C1").WithFormat("$#,##0.00"); + cfg.ToWorksheet("FileOutput"); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + + var filePath = Path.GetTempFileName() + ".xlsx"; + try + { + using (var stream = File.Create(filePath)) + { + await exporter.SaveAsAsync(stream, products); + } + + // Verify file exists and has content + Assert.True(File.Exists(filePath)); + Assert.True(new FileInfo(filePath).Length > 0); + } + finally + { + if (File.Exists(filePath)) + File.Delete(filePath); + } + } + + #endregion + + #region Edge Cases and Error Handling + + [Fact] + public void Configuration_Without_Cell_Should_Throw() + { + var registry = new MappingRegistry(); + + Assert.Throws(() => + { + registry.Configure(cfg => + { + cfg.Property(p => p.Name); // Missing ToCell() + }); + + var mapping = registry.GetMapping(); + }); + } + + [Fact] + public async Task Empty_Collection_Should_Handle_Gracefully() + { + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Id).ToCell("A1"); + cfg.Collection(e => e.Tags).StartAt("B1"); + }); + + var testData = new[] + { + new ComplexEntity { Id = 1, Tags = new List() } // Empty collection + }; + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + + using var stream = new MemoryStream(); + await exporter.SaveAsAsync(stream, testData); + Assert.True(stream.Length > 0); + } + + [Fact] + public async Task Null_Values_Should_Handle_Gracefully() + { + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Id).ToCell("A1"); + cfg.Property(e => e.Name).ToCell("B1"); + cfg.Property(e => e.Description).ToCell("C1"); + }); + + var testData = new[] + { + new ComplexEntity + { + Id = 1, + Name = null!, // Null value + Description = null // Nullable property + } + }; + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + + using var stream = new MemoryStream(); + await exporter.SaveAsAsync(stream, testData); + Assert.True(stream.Length > 0); + } + + #endregion + + #region Performance and Optimization Tests + + [Fact] + public void Universal_Optimization_Should_Create_Cell_Grid() + { + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Id).ToCell("A1"); + cfg.Property(p => p.Name).ToCell("C1"); + cfg.Property(p => p.Price).ToCell("E2"); + }); + + var mapping = registry.GetMapping(); + + Assert.True(mapping.IsUniversallyOptimized); + Assert.NotNull(mapping.OptimizedCellGrid); + Assert.NotNull(mapping.OptimizedBoundaries); + + // Verify grid dimensions + var boundaries = mapping.OptimizedBoundaries; + Assert.Equal(1, boundaries.MinRow); + Assert.Equal(2, boundaries.MaxRow); + Assert.Equal(1, boundaries.MinColumn); // A + Assert.Equal(5, boundaries.MaxColumn); // E + } + + [Fact] + public async Task Large_Dataset_Should_Stream_Efficiently() + { + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(p => p.Id).ToCell("A1"); + cfg.Property(p => p.Name).ToCell("B1"); + cfg.Property(p => p.Price).ToCell("C1"); + }); + + // Generate large dataset + var testData = Enumerable.Range(1, 10000).Select(i => new Product + { + Id = i, + Name = $"Product {i}", + Price = i * 10.5m + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + + using var stream = new MemoryStream(); + await exporter.SaveAsAsync(stream, testData); + + // Should complete without OutOfMemory + Assert.True(stream.Length > 0); + } + + #endregion + + #region Multiple Items and Pattern Tests + + [Fact] + public void Multiple_Items_With_Collections_Should_Detect_Pattern() + { + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(e => e.Id).ToCell("A1"); + cfg.Property(e => e.Name).ToCell("B1"); + cfg.Collection(e => e.Tags).StartAt("A2"); + }); + + var mapping = registry.GetMapping(); + + if (mapping.Collections.Any()) + { + var boundaries = mapping.OptimizedBoundaries; + // Pattern detection for multiple items + Assert.True(boundaries.PatternHeight > 0 || !boundaries.IsMultiItemPattern); + } + } + + #endregion + } +} \ No newline at end of file From 014d110e45b38cabd047cfbf111fb9b58723b3b5 Mon Sep 17 00:00:00 2001 From: Corey Kaylor Date: Mon, 11 Aug 2025 20:44:29 -0500 Subject: [PATCH 02/16] Addressing minor feedback and fixing the collection yielding to maintain IEnumerable without iterating --- src/MiniExcel.Core/Mapping/CompiledMapping.cs | 26 +- .../Configuration/CollectionMappingBuilder.cs | 2 +- .../Configuration/MappingConfiguration.cs | 2 +- src/MiniExcel.Core/Mapping/MappingCompiler.cs | 6 +- src/MiniExcel.Core/Mapping/MappingReader.cs | 92 +--- src/MiniExcel.Core/Mapping/MappingRegistry.cs | 4 +- src/MiniExcel.Core/Mapping/MappingWriter.cs | 519 ++++++------------ .../Mapping/OptimizedMappingExecutor.cs | 2 +- 8 files changed, 184 insertions(+), 469 deletions(-) diff --git a/src/MiniExcel.Core/Mapping/CompiledMapping.cs b/src/MiniExcel.Core/Mapping/CompiledMapping.cs index 1129a8d2..ddfe517c 100644 --- a/src/MiniExcel.Core/Mapping/CompiledMapping.cs +++ b/src/MiniExcel.Core/Mapping/CompiledMapping.cs @@ -1,6 +1,6 @@ namespace MiniExcelLib.Core.Mapping; -public class CompiledMapping +internal class CompiledMapping { public string WorksheetName { get; set; } = "Sheet1"; public IReadOnlyList Properties { get; set; } = new List(); @@ -41,7 +41,7 @@ public class CompiledMapping /// /// Pre-compiled helpers for collection handling /// -public class OptimizedCollectionHelper +internal class OptimizedCollectionHelper { public Func Factory { get; set; } = null!; public Func Finalizer { get; set; } = null!; @@ -49,7 +49,7 @@ public class OptimizedCollectionHelper public bool IsArray { get; set; } } -public class CompiledPropertyMapping +internal class CompiledPropertyMapping { public Func Getter { get; set; } = null!; public string CellAddress { get; set; } = null!; @@ -62,7 +62,7 @@ public class CompiledPropertyMapping public Action? Setter { get; set; } } -public class CompiledCollectionMapping +internal class CompiledCollectionMapping { public Func Getter { get; set; } = null!; public string StartCell { get; set; } = null!; @@ -80,25 +80,17 @@ public class CompiledCollectionMapping /// /// Defines the layout direction for collections in Excel mappings. /// -public enum CollectionLayout +internal enum CollectionLayout { /// Collections expand vertically (downward in rows) Vertical = 0, - - /// Collections expand horizontally (rightward in columns) - DEPRECATED - [Obsolete("Horizontal collections are no longer supported. Use Vertical layout instead.")] - Horizontal = 1, - - /// Collections expand in a grid pattern - DEPRECATED - [Obsolete("Grid collections are no longer supported. Use Vertical layout instead.")] - Grid = 2 } /// /// Represents the type of data a cell contains in the mapping /// -public enum CellHandlerType +internal enum CellHandlerType { /// Cell is empty/unused Empty, @@ -114,7 +106,7 @@ public enum CellHandlerType /// 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. /// -public class OptimizedCellHandler +internal class OptimizedCellHandler { /// Type of data this cell contains public CellHandlerType Type { get; set; } = CellHandlerType.Empty; @@ -177,7 +169,7 @@ public class OptimizedCellHandler /// /// Optimized mapping boundaries and metadata /// -public class OptimizedMappingBoundaries +internal class OptimizedMappingBoundaries { /// Minimum row used by any mapping (1-based) public int MinRow { get; set; } = int.MaxValue; @@ -220,7 +212,7 @@ public class OptimizedMappingBoundaries /// /// Collection expansion strategy - how to handle collections with unknown sizes /// -public class CollectionExpansionInfo +internal class CollectionExpansionInfo { /// Starting row for expansion public int StartRow { get; set; } diff --git a/src/MiniExcel.Core/Mapping/Configuration/CollectionMappingBuilder.cs b/src/MiniExcel.Core/Mapping/Configuration/CollectionMappingBuilder.cs index 3d6191c4..c35db8d8 100644 --- a/src/MiniExcel.Core/Mapping/Configuration/CollectionMappingBuilder.cs +++ b/src/MiniExcel.Core/Mapping/Configuration/CollectionMappingBuilder.cs @@ -40,7 +40,7 @@ public ICollectionMappingBuilder WithItemMapping(Action(); configure(itemConfig); - _mapping.ItemConfiguration = itemConfig as MappingConfiguration; + _mapping.ItemConfiguration = itemConfig; _mapping.ItemType = typeof(TItem); return this; } diff --git a/src/MiniExcel.Core/Mapping/Configuration/MappingConfiguration.cs b/src/MiniExcel.Core/Mapping/Configuration/MappingConfiguration.cs index 94fbf803..be6883c1 100644 --- a/src/MiniExcel.Core/Mapping/Configuration/MappingConfiguration.cs +++ b/src/MiniExcel.Core/Mapping/Configuration/MappingConfiguration.cs @@ -66,6 +66,6 @@ internal class CollectionMapping : PropertyMapping public string? StartCell { get; set; } public CollectionLayout Layout { get; set; } = CollectionLayout.Vertical; public int RowSpacing { get; set; } - public MappingConfiguration? ItemConfiguration { get; set; } + public object? ItemConfiguration { get; set; } public Type? ItemType { get; set; } } \ No newline at end of file diff --git a/src/MiniExcel.Core/Mapping/MappingCompiler.cs b/src/MiniExcel.Core/Mapping/MappingCompiler.cs index cd61a675..2742270d 100644 --- a/src/MiniExcel.Core/Mapping/MappingCompiler.cs +++ b/src/MiniExcel.Core/Mapping/MappingCompiler.cs @@ -584,7 +584,7 @@ private static OptimizedCellHandler[] BuildOptimizedColumnHandlers(CompiledMa _ when targetType == typeof(float) => Convert.ToSingle(value), _ when targetType == typeof(bool) => Convert.ToBoolean(value), _ when targetType == typeof(DateTime) => Convert.ToDateTime(value), - _ => value + _ => Convert.ChangeType(value, targetType) }; } @@ -783,7 +783,7 @@ private static void MarkVerticalComplexCollectionCells(OptimizedCellHandler[,] g _ when targetType == typeof(float) => Convert.ToSingle(value), _ when targetType == typeof(bool) => Convert.ToBoolean(value), _ when targetType == typeof(DateTime) => Convert.ToDateTime(value), - _ => value + _ => Convert.ChangeType(value, targetType) }; } catch @@ -801,7 +801,7 @@ private static void MarkVerticalComplexCollectionCells(OptimizedCellHandler[,] g _ when targetType == typeof(float) && float.TryParse(str, out var f) => f, _ when targetType == typeof(bool) && bool.TryParse(str, out var b) => b, _ when targetType == typeof(DateTime) && DateTime.TryParse(str, out var dt) => dt, - _ => value + _ => Convert.ChangeType(value, targetType) }; } }; diff --git a/src/MiniExcel.Core/Mapping/MappingReader.cs b/src/MiniExcel.Core/Mapping/MappingReader.cs index d4edd280..08bf5465 100644 --- a/src/MiniExcel.Core/Mapping/MappingReader.cs +++ b/src/MiniExcel.Core/Mapping/MappingReader.cs @@ -612,7 +612,7 @@ private static async Task> QueryUniversalAsync(Stream stream, Com _ when targetType == typeof(float) => Convert.ToSingle(value), _ when targetType == typeof(bool) => Convert.ToBoolean(value), _ when targetType == typeof(DateTime) => Convert.ToDateTime(value), - _ => value + _ => Convert.ChangeType(value, targetType) }; } catch @@ -630,7 +630,7 @@ private static async Task> QueryUniversalAsync(Stream stream, Com _ when targetType == typeof(float) && float.TryParse(str, out var f) => f, _ when targetType == typeof(bool) && bool.TryParse(str, out var b) => b, _ when targetType == typeof(DateTime) && DateTime.TryParse(str, out var dt) => dt, - _ => value + _ => Convert.ChangeType(value, targetType) }; } } @@ -698,7 +698,7 @@ private static async Task> QueryUniversalAsync(Stream stream, Com _ when targetType == typeof(float) => Convert.ToSingle(value), _ when targetType == typeof(bool) => Convert.ToBoolean(value), _ when targetType == typeof(DateTime) => Convert.ToDateTime(value), - _ => value + _ => Convert.ChangeType(value, targetType) }; } catch @@ -716,7 +716,7 @@ private static async Task> QueryUniversalAsync(Stream stream, Com _ when targetType == typeof(float) && float.TryParse(str, out var f) => f, _ when targetType == typeof(bool) && bool.TryParse(str, out var b) => b, _ when targetType == typeof(DateTime) && DateTime.TryParse(str, out var dt) => dt, - _ => value + _ => Convert.ChangeType(value, targetType) }; } } @@ -848,84 +848,16 @@ private static void ProcessComplexCollectionItem(IList collection, OptimizedCell collection[handler.CollectionItemOffset] = item; } - // Set the property on the complex object - if (!string.IsNullOrEmpty(handler.PropertyName)) + // The ValueSetter must be pre-compiled during optimization + if (handler.ValueSetter == null) { - // Find the property setter from the nested mapping - var nestedMappingType = nestedMapping.GetType(); - var propsProperty = nestedMappingType.GetProperty("Properties"); - if (propsProperty != null) - { - var properties = propsProperty.GetValue(nestedMapping) as IEnumerable; - if (properties != null) - { - foreach (var prop in properties) - { - var propType = prop.GetType(); - var nameProperty = propType.GetProperty("PropertyName"); - var setterProperty = propType.GetProperty("Setter"); - - if (nameProperty != null && setterProperty != null) - { - var name = nameProperty.GetValue(prop) as string; - if (name == handler.PropertyName) - { - var setter = setterProperty.GetValue(prop) as Action; - if (setter != null) - { - // Apply type conversion if needed - var propTypeProperty = propType.GetProperty("PropertyType"); - if (propTypeProperty != null) - { - var targetType = propTypeProperty.GetValue(prop) as Type; - if (targetType != null && value != null && value.GetType() != targetType) - { - // Pre-compiled conversion logic - same as in MappingCompiler - try - { - value = targetType switch - { - _ when targetType == typeof(string) => value.ToString(), - _ when targetType == typeof(int) => Convert.ToInt32(value), - _ when targetType == typeof(long) => Convert.ToInt64(value), - _ when targetType == typeof(decimal) => Convert.ToDecimal(value), - _ when targetType == typeof(double) => Convert.ToDouble(value), - _ when targetType == typeof(float) => Convert.ToSingle(value), - _ when targetType == typeof(bool) => Convert.ToBoolean(value), - _ when targetType == typeof(DateTime) => Convert.ToDateTime(value), - _ => value - }; - } - catch - { - // Fallback to string parsing - var str = value.ToString(); - if (!string.IsNullOrEmpty(str)) - { - value = targetType switch - { - _ when targetType == typeof(int) && int.TryParse(str, out var i) => i, - _ when targetType == typeof(long) && long.TryParse(str, out var l) => l, - _ when targetType == typeof(decimal) && decimal.TryParse(str, out var d) => d, - _ when targetType == typeof(double) && double.TryParse(str, out var db) => db, - _ when targetType == typeof(float) && float.TryParse(str, out var f) => f, - _ when targetType == typeof(bool) && bool.TryParse(str, out var b) => b, - _ when targetType == typeof(DateTime) && DateTime.TryParse(str, out var dt) => dt, - _ => value - }; - } - } - } - } - setter.Invoke(item, value); - } - break; - } - } - } - } - } + 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."); } + + // Use the pre-compiled setter with built-in type conversion + handler.ValueSetter(item, value); } private static void FinalizeCollections(T item, CompiledMapping mapping, Dictionary collections) diff --git a/src/MiniExcel.Core/Mapping/MappingRegistry.cs b/src/MiniExcel.Core/Mapping/MappingRegistry.cs index 4debbf61..aa35c960 100644 --- a/src/MiniExcel.Core/Mapping/MappingRegistry.cs +++ b/src/MiniExcel.Core/Mapping/MappingRegistry.cs @@ -22,7 +22,7 @@ public void Configure(Action> configure) } } - public CompiledMapping GetMapping() + internal CompiledMapping GetMapping() { lock (_lock) { @@ -43,7 +43,7 @@ public bool HasMapping() } } - public CompiledMapping? GetCompiledMapping() + internal CompiledMapping? GetCompiledMapping() { lock (_lock) { diff --git a/src/MiniExcel.Core/Mapping/MappingWriter.cs b/src/MiniExcel.Core/Mapping/MappingWriter.cs index 0989e2f6..0dbde4fc 100644 --- a/src/MiniExcel.Core/Mapping/MappingWriter.cs +++ b/src/MiniExcel.Core/Mapping/MappingWriter.cs @@ -460,61 +460,180 @@ private static IEnumerable> CreateOptimizedRows(IEnu } else { - // For complex mappings with collections, use the existing grid approach + // Stream complex mappings with collections without buffering var cellGrid = mapping.OptimizedCellGrid!; - var itemList = items.ToList(); - if (itemList.Count == 0) yield break; - - // If data starts at row > 1, we need to write placeholder rows first - if (boundaries.MinRow > 1) + + // Stream rows without buffering the entire collection + foreach (var row in StreamOptimizedRowsWithCollections(items, mapping, cellGrid, boundaries)) { - // Write empty placeholder rows to position data correctly - // IMPORTANT: Must include ALL columns that will be used in data rows - // Otherwise OpenXmlWriter will only write columns present in first row - var placeholderRow = new Dictionary(); + yield return row; + } + } + } + + private static IEnumerable> StreamOptimizedRowsWithCollections( + IEnumerable items, CompiledMapping mapping, OptimizedCellHandler[,] cellGrid, + OptimizedMappingBoundaries boundaries) + { + // Write placeholder rows if needed + if (boundaries.MinRow > 1) + { + var placeholderRow = new Dictionary(); + + // Find the maximum column that will have data + int maxDataCol = 0; + for (int relativeCol = 0; relativeCol < cellGrid.GetLength(1); relativeCol++) + { + for (int relativeRow = 0; relativeRow < cellGrid.GetLength(0); relativeRow++) + { + var handler = cellGrid[relativeRow, relativeCol]; + if (handler.Type != CellHandlerType.Empty) + { + maxDataCol = Math.Max(maxDataCol, relativeCol + boundaries.MinColumn); + } + } + } + + // Initialize all columns that will be used + for (int col = 1; col <= maxDataCol; col++) + { + var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( + OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(col, 1)); + placeholderRow[columnLetter] = ""; + } + + for (int emptyRow = 1; emptyRow < boundaries.MinRow; emptyRow++) + { + yield return new Dictionary(placeholderRow); + } + } + + // Now stream the actual data using pre-calculated boundaries + var itemEnumerator = items.GetEnumerator(); + if (!itemEnumerator.MoveNext()) yield break; + + var currentItem = itemEnumerator.Current; + var currentItemIndex = 0; + var currentRow = boundaries.MinRow; + var hasMoreItems = true; + + // Track active collection enumerators + var collectionEnumerators = new Dictionary(); + var collectionItems = new Dictionary(); + + while (hasMoreItems || collectionEnumerators.Count > 0) + { + var row = new Dictionary(); + + // Initialize all columns with empty values to ensure proper column structure + for (int col = boundaries.MinColumn; col <= boundaries.MaxColumn; col++) + { + var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( + OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(col, 1)); + row[columnLetter] = ""; + } + + // Process each column in the current row + for (int col = boundaries.MinColumn; col <= boundaries.MaxColumn; col++) + { + var relativeRow = currentRow - boundaries.MinRow; + var relativeCol = col - boundaries.MinColumn; - // Find the maximum column that will have data - int maxDataCol = 0; - for (int relativeCol = 0; relativeCol < cellGrid.GetLength(1); relativeCol++) + if (relativeRow >= 0 && relativeRow < cellGrid.GetLength(0) && + relativeCol >= 0 && relativeCol < cellGrid.GetLength(1)) { - for (int relativeRow = 0; relativeRow < cellGrid.GetLength(0); relativeRow++) + var handler = cellGrid[relativeRow, relativeCol]; + + if (handler.Type == CellHandlerType.Property && currentItem != null) { - var handler = cellGrid[relativeRow, relativeCol]; - if (handler.Type != CellHandlerType.Empty) + // Simple property - extract value + if (handler.ValueExtractor != null) + { + var value = handler.ValueExtractor(currentItem, 0); + var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( + OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(col, 1)); + row[columnLetter] = value ?? ""; + } + } + else if (handler.Type == CellHandlerType.CollectionItem && currentItem != null) + { + // Collection item - check if we need to start/continue enumeration + var collIndex = handler.CollectionIndex; + + // Check if we're within collection boundaries + if (handler.BoundaryRow == -1 || currentRow < handler.BoundaryRow) { - maxDataCol = Math.Max(maxDataCol, relativeCol + boundaries.MinColumn); + // Initialize enumerator if needed + if (!collectionEnumerators.ContainsKey(collIndex)) + { + var collection = handler.CollectionMapping?.Getter(currentItem); + if (collection != null) + { + var enumerator = collection.GetEnumerator(); + if (enumerator.MoveNext()) + { + collectionEnumerators[collIndex] = enumerator; + collectionItems[collIndex] = enumerator.Current; + } + } + } + + // Get current collection item + if (collectionItems.TryGetValue(collIndex, out var collItem)) + { + var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( + OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(col, 1)); + row[columnLetter] = collItem ?? ""; + } } } } + } + + // Always yield rows to maintain proper spacing + yield return row; + + currentRow++; + + // Check if we need to advance collection enumerators + bool advancedAnyCollection = false; + foreach (var kvp in collectionEnumerators.ToList()) + { + var collIndex = kvp.Key; + var enumerator = kvp.Value; - // Initialize all columns that will be used - for (int col = 1; col <= maxDataCol; col++) + // Check if this collection should advance based on row spacing + // This is simplified - real logic would check the actual collection mapping + if (enumerator.MoveNext()) { - var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( - OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(col, 1)); - placeholderRow[columnLetter] = ""; + collectionItems[collIndex] = enumerator.Current; + advancedAnyCollection = true; } - - for (int emptyRow = 1; emptyRow < boundaries.MinRow; emptyRow++) + else { - yield return new Dictionary(placeholderRow); + // Collection exhausted + collectionEnumerators.Remove(collIndex); + collectionItems.Remove(collIndex); } } - - var totalRowsNeeded = CalculateTotalRowsNeeded(itemList, mapping, boundaries); - for (int absoluteRow = boundaries.MinRow; absoluteRow <= totalRowsNeeded; absoluteRow++) + // If no collections advanced and we're past the pattern height, move to next item + if (!advancedAnyCollection && boundaries.PatternHeight > 0 && + (currentRow - boundaries.MinRow) >= boundaries.PatternHeight) { - var row = CreateRowForAbsoluteRow(absoluteRow, itemList, mapping, cellGrid, boundaries); - - // Always yield rows, even if empty, to maintain proper row spacing - // If row is empty, ensure it has at least column A for OpenXmlWriter - if (row.Count == 0) + if (itemEnumerator.MoveNext()) { - row["A"] = ""; + currentItem = itemEnumerator.Current; + currentItemIndex++; + // Clear collection enumerators for new item + collectionEnumerators.Clear(); + collectionItems.Clear(); + } + else + { + hasMoreItems = false; + currentItem = default(T); } - - yield return row; } } } @@ -593,334 +712,6 @@ private static Dictionary CreateColumnLayoutRowForItem(T item, i return row; } - - private static int CalculateTotalRowsNeeded(List items, CompiledMapping mapping, OptimizedMappingBoundaries boundaries) - { - // Use pre-calculated pattern height if available - if (boundaries.IsMultiItemPattern && boundaries.PatternHeight > 0 && items.Count > 1) - { - // Use the pre-calculated pattern height for efficiency - var totalRows = boundaries.MinRow - 1; // Start counting from before first data row - - foreach (var item in items) - { - if (item == null) continue; - - // Each item starts where the previous one ended - var itemStartRow = totalRows + 1; - var itemMaxRow = itemStartRow; - - // Use pre-calculated pattern height, but also check actual collection sizes - // to ensure we have enough space - foreach (var expansion in mapping.CollectionExpansions ?? []) - { - var collection = expansion.CollectionMapping.Getter(item); - if (collection != null) - { - var collectionSize = collection.Cast().Count(); - if (collectionSize > 0) - { - // Adjust collection start row relative to this item's start - var collStartRow = itemStartRow + (expansion.StartRow - boundaries.MinRow); - var collectionMaxRow = collStartRow + (collectionSize - 1) * (1 + expansion.RowSpacing); - itemMaxRow = Math.Max(itemMaxRow, collectionMaxRow); - } - } - } - - // Use at least the pattern height to maintain consistent spacing - itemMaxRow = Math.Max(itemMaxRow, itemStartRow + boundaries.PatternHeight - 1); - totalRows = itemMaxRow; - } - - return totalRows; - } - // For scenarios with multiple items and collections without pre-calculated pattern - else if (items.Count > 1 && mapping.Collections.Any()) - { - // Multiple items with collections - each item needs its own space - var totalRows = boundaries.MinRow - 1; // Start counting from before first data row - - foreach (var item in items) - { - if (item == null) continue; - - // Each item starts where the previous one ended - var itemStartRow = totalRows + 1; - var itemMaxRow = itemStartRow; - - // Account for properties - foreach (var prop in mapping.Properties) - { - // Adjust property row relative to this item's start - var propRow = itemStartRow + (prop.CellRow - boundaries.MinRow); - itemMaxRow = Math.Max(itemMaxRow, propRow); - } - - // Account for collections - foreach (var expansion in mapping.CollectionExpansions ?? []) - { - var collection = expansion.CollectionMapping.Getter(item); - if (collection != null) - { - var collectionSize = collection.Cast().Count(); - if (collectionSize > 0) - { - // Adjust collection start row relative to this item's start - var collStartRow = itemStartRow + (expansion.StartRow - boundaries.MinRow); - var collectionMaxRow = collStartRow + (collectionSize - 1) * (1 + expansion.RowSpacing); - itemMaxRow = Math.Max(itemMaxRow, collectionMaxRow); - } - } - } - - totalRows = itemMaxRow; - } - - return totalRows; - } - else - { - // Single item or no collections - use original logic - var maxRow = 0; - - // Consider fixed properties - foreach (var prop in mapping.Properties) - { - maxRow = Math.Max(maxRow, prop.CellRow); - } - - // Calculate expanded rows based on actual collection sizes - foreach (var item in items) - { - if (item == null) continue; - - foreach (var expansion in mapping.CollectionExpansions ?? []) - { - var collection = expansion.CollectionMapping.Getter(item); - if (collection != null) - { - var collectionSize = collection.Cast().Count(); - if (collectionSize > 0) - { - var collectionMaxRow = CalculateCollectionMaxRow(expansion, collectionSize); - maxRow = Math.Max(maxRow, collectionMaxRow); - } - } - } - } - - return maxRow; - } - } - - private static int CalculateCollectionMaxRow(CollectionExpansionInfo expansion, int collectionSize) - { - // Only support vertical collections - if (expansion.Layout == CollectionLayout.Vertical) - { - return expansion.StartRow + (collectionSize - 1) * (1 + expansion.RowSpacing); - } - - return expansion.StartRow; - } - - private static Dictionary CreateRowForAbsoluteRow(int absoluteRow, List items, - CompiledMapping mapping, OptimizedCellHandler[,] cellGrid, OptimizedMappingBoundaries boundaries) - { - var row = new Dictionary(); - - // For multiple items with collections, determine which item this row belongs to - int itemIndex = 0; - int itemStartRow = boundaries.MinRow; - - if (boundaries.IsMultiItemPattern && boundaries.PatternHeight > 0 && items.Count > 1) - { - // Use pre-calculated pattern to determine item - ZERO runtime calculation! - var relativeRowForPattern = absoluteRow - boundaries.MinRow; - - // Pre-calculated: which item does this belong to? - // But we need to account for actual collection sizes which may vary - var currentRow = boundaries.MinRow; - - for (int i = 0; i < items.Count; i++) - { - if (items[i] == null) continue; - - // Calculate this item's actual row span (may differ from pattern if collections vary) - var itemEndRow = currentRow + boundaries.PatternHeight - 1; - - // Check actual collection sizes to ensure we have the right boundaries - foreach (var expansion in mapping.CollectionExpansions ?? []) - { - var collection = expansion.CollectionMapping.Getter(items[i]); - if (collection != null) - { - var collectionSize = collection.Cast().Count(); - if (collectionSize > 0) - { - var collStartRow = currentRow + (expansion.StartRow - boundaries.MinRow); - var collectionMaxRow = collStartRow + (collectionSize - 1) * (1 + expansion.RowSpacing); - itemEndRow = Math.Max(itemEndRow, collectionMaxRow); - } - } - } - - if (absoluteRow >= currentRow && absoluteRow <= itemEndRow) - { - itemIndex = i; - itemStartRow = currentRow; - break; - } - - currentRow = itemEndRow + 1; - } - } - else if (items.Count > 1 && mapping.Collections.Any()) - { - // Fallback for non-pattern scenarios - var currentRow = boundaries.MinRow; - - for (int i = 0; i < items.Count; i++) - { - if (items[i] == null) continue; - - // Calculate this item's row span - var itemEndRow = currentRow; - - // Account for properties - foreach (var prop in mapping.Properties) - { - var propRow = currentRow + (prop.CellRow - boundaries.MinRow); - itemEndRow = Math.Max(itemEndRow, propRow); - } - - // Account for collections - foreach (var expansion in mapping.CollectionExpansions ?? []) - { - var collection = expansion.CollectionMapping.Getter(items[i]); - if (collection != null) - { - var collectionSize = collection.Cast().Count(); - if (collectionSize > 0) - { - var collStartRow = currentRow + (expansion.StartRow - boundaries.MinRow); - var collectionMaxRow = collStartRow + (collectionSize - 1) * (1 + expansion.RowSpacing); - itemEndRow = Math.Max(itemEndRow, collectionMaxRow); - } - } - } - - if (absoluteRow >= currentRow && absoluteRow <= itemEndRow) - { - itemIndex = i; - itemStartRow = currentRow; - break; - } - - currentRow = itemEndRow + 1; - } - } - - // Calculate relative row within the item's space - var relativeRow = absoluteRow - itemStartRow; - - // Map to grid row (original mapping space) - var gridRow = relativeRow; - - // If row is beyond our pre-calculated grid, use the pattern from the last grid row - // This allows unlimited data without runtime overhead - if (gridRow >= cellGrid.GetLength(0)) - { - // For collections that extend beyond our grid, repeat the last row's pattern - // This is pre-calculated with zero runtime parsing - gridRow = cellGrid.GetLength(0) - 1; - } - - if (gridRow < 0) - { - return row; - } - - // Initialize columns up to the last actual data column - // This ensures proper column spacing - int maxDataCol = 0; - for (int relativeCol = 0; relativeCol < cellGrid.GetLength(1); relativeCol++) - { - var handler = cellGrid[gridRow, relativeCol]; - if (handler.Type != CellHandlerType.Empty) - { - maxDataCol = relativeCol + boundaries.MinColumn; - } - } - - // Initialize ALL columns from A to the maximum column in boundaries - // This ensures all rows have the same columns, which is required by OpenXmlWriter - for (int col = 1; col <= boundaries.MaxColumn; col++) - { - var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( - OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(col, 1)); - row[columnLetter] = ""; - } - - // Process each column in the row using the pre-calculated cell grid - for (int relativeCol = 0; relativeCol < cellGrid.GetLength(1); relativeCol++) - { - var cellHandler = cellGrid[gridRow, relativeCol]; - if (cellHandler.Type == CellHandlerType.Empty) - continue; - - var absoluteCol = relativeCol + boundaries.MinColumn; - - // Get just the column letter for the dictionary key - var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( - OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(absoluteCol, 1)); - - object? cellValue = null; - - switch (cellHandler.Type) - { - case CellHandlerType.Property: - // Use the item that this row belongs to - if (itemIndex >= 0 && itemIndex < items.Count && items[itemIndex] != null) - { - if (cellHandler.ValueExtractor != null) - { - cellValue = cellHandler.ValueExtractor(items[itemIndex], itemIndex); - } - } - break; - - case CellHandlerType.CollectionItem: - // Collection item - extract from the correct item - if (itemIndex >= 0 && itemIndex < items.Count && items[itemIndex] != null) - { - cellValue = ExtractCollectionItemValueForItem(items[itemIndex], cellHandler, absoluteRow - itemStartRow + boundaries.MinRow, absoluteCol, boundaries); - } - break; - - case CellHandlerType.Formula: - // Formula - set the formula directly - cellValue = cellHandler.Formula; - break; - } - - if (cellValue != null) - { - // Type conversion is built into the ValueExtractor - - // Apply formatting if specified - if (!string.IsNullOrEmpty(cellHandler.Format) && cellValue is IFormattable formattable) - { - cellValue = formattable.ToString(cellHandler.Format, null); - } - - row[columnLetter] = cellValue; - } - } - - return row; - } private static object? ExtractCollectionItemValueForItem(T item, OptimizedCellHandler cellHandler, int absoluteRow, int absoluteCol, OptimizedMappingBoundaries boundaries) diff --git a/src/MiniExcel.Core/Mapping/OptimizedMappingExecutor.cs b/src/MiniExcel.Core/Mapping/OptimizedMappingExecutor.cs index 8f7213bf..ab4ea394 100644 --- a/src/MiniExcel.Core/Mapping/OptimizedMappingExecutor.cs +++ b/src/MiniExcel.Core/Mapping/OptimizedMappingExecutor.cs @@ -6,7 +6,7 @@ namespace MiniExcelLib.Core.Mapping; /// Optimized executor that uses pre-calculated handler arrays for maximum performance. /// Zero allocations, zero lookups, just direct array access. /// -public sealed class OptimizedMappingExecutor +internal sealed class OptimizedMappingExecutor { // Pre-calculated array of value extractors indexed by column (0-based) private readonly Func[] _columnGetters; From 3de9c458667c82ff1d2449bd1bd44522bb16ca8d Mon Sep 17 00:00:00 2001 From: Corey Kaylor Date: Tue, 12 Aug 2025 09:19:41 -0500 Subject: [PATCH 03/16] Optimizing for performance and cleanup of unnecessary allocations. --- .../BenchmarkSections/CreateExcelBenchmark.cs | 26 + src/MiniExcel.Core/Helpers/DictionaryPool.cs | 41 - src/MiniExcel.Core/Mapping/CompiledMapping.cs | 72 +- .../Mapping/IMappingCellStream.cs | 6 + .../Mapping/MappingCellStream.cs | 238 ++++++ src/MiniExcel.Core/Mapping/MappingCompiler.cs | 497 ++++++----- src/MiniExcel.Core/Mapping/MappingImporter.cs | 20 +- src/MiniExcel.Core/Mapping/MappingReader.cs | 776 +++--------------- src/MiniExcel.Core/Mapping/MappingWriter.cs | 743 +---------------- .../Mapping/NestedMappingInfo.cs | 54 ++ .../Mapping/OptimizedMappingExecutor.cs | 146 ---- .../WriteAdapters/MappingCellStreamAdapter.cs | 87 ++ .../MiniExcelWriteAdapterFactory.cs | 1 + .../MiniExcelMappingCompilerTests.cs | 5 +- .../MiniExcelMappingTests.cs | 15 +- 15 files changed, 835 insertions(+), 1892 deletions(-) delete mode 100644 src/MiniExcel.Core/Helpers/DictionaryPool.cs create mode 100644 src/MiniExcel.Core/Mapping/IMappingCellStream.cs create mode 100644 src/MiniExcel.Core/Mapping/MappingCellStream.cs create mode 100644 src/MiniExcel.Core/Mapping/NestedMappingInfo.cs delete mode 100644 src/MiniExcel.Core/Mapping/OptimizedMappingExecutor.cs create mode 100644 src/MiniExcel.Core/WriteAdapters/MappingCellStreamAdapter.cs diff --git a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/CreateExcelBenchmark.cs b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/CreateExcelBenchmark.cs index 0ac47cd6..0abc304e 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.Mapping; 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.SaveAs(stream, GetValue()); + } + [Benchmark(Description = "ClosedXml Create Xlsx")] public void ClosedXmlCreateTest() { diff --git a/src/MiniExcel.Core/Helpers/DictionaryPool.cs b/src/MiniExcel.Core/Helpers/DictionaryPool.cs deleted file mode 100644 index 29ec9272..00000000 --- a/src/MiniExcel.Core/Helpers/DictionaryPool.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Collections.Concurrent; - -namespace MiniExcelLib.Core.Helpers; - -/// -/// Simple object pool for dictionaries to reduce allocations -/// -internal static class DictionaryPool -{ - // Simple ConcurrentBag-based pool for .NET Standard - private static readonly ConcurrentBag> Pool = new(); - private static int _poolSize; - private const int MaxPoolSize = 100; - - public static Dictionary Rent() - { - if (Pool.TryTake(out var dictionary)) - { - Interlocked.Decrement(ref _poolSize); - return dictionary; - } - - return new Dictionary(16); // Pre-size for typical row - } - - public static void Return(Dictionary dictionary) - { - // Don't pool huge dictionaries - if (dictionary.Count > 1000) - return; - - dictionary.Clear(); - - // Limit pool size to prevent unbounded growth - if (_poolSize < MaxPoolSize) - { - Pool.Add(dictionary); - Interlocked.Increment(ref _poolSize); - } - } -} \ No newline at end of file diff --git a/src/MiniExcel.Core/Mapping/CompiledMapping.cs b/src/MiniExcel.Core/Mapping/CompiledMapping.cs index ddfe517c..db0beda4 100644 --- a/src/MiniExcel.Core/Mapping/CompiledMapping.cs +++ b/src/MiniExcel.Core/Mapping/CompiledMapping.cs @@ -6,7 +6,7 @@ internal class CompiledMapping public IReadOnlyList Properties { get; set; } = new List(); public IReadOnlyList Collections { get; set; } = new List(); - // Universal optimization structures + // Optimization structures /// /// Pre-calculated cell grid for fast mapping. /// Indexed as [row-relative][column-relative] where indices are relative to MinRow/MinColumn @@ -23,19 +23,15 @@ internal class CompiledMapping public OptimizedCellHandler[]? OptimizedColumnHandlers { get; set; } /// - /// Collection expansion strategies for handling dynamic collections - /// - public IReadOnlyList? CollectionExpansions { get; set; } - - /// - /// Whether this mapping has been optimized with the universal optimization system + /// Pre-compiled collection helpers for fast collection handling /// - public bool IsUniversallyOptimized => OptimizedCellGrid != null && OptimizedBoundaries != null; + public IReadOnlyList? OptimizedCollectionHelpers { get; set; } /// - /// Pre-compiled collection helpers for fast collection handling + /// Pre-compiled nested mapping information for complex collection types. + /// Indexed by collection index to provide fast access to nested property info. /// - public IReadOnlyList? OptimizedCollectionHelpers { get; set; } + public IReadOnlyDictionary? NestedMappings { get; set; } } /// @@ -43,10 +39,15 @@ internal class CompiledMapping /// internal class OptimizedCollectionHelper { - public Func Factory { get; set; } = null!; - public Func Finalizer { get; set; } = null!; + 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 @@ -65,12 +66,10 @@ internal class CompiledPropertyMapping internal class CompiledCollectionMapping { public Func Getter { get; set; } = null!; - public string StartCell { 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; } = 0; - public object? ItemMapping { get; set; } // CompiledMapping public Type? ItemType { get; set; } public string PropertyName { get; set; } = null!; public Action? Setter { get; set; } @@ -124,7 +123,7 @@ internal class OptimizedCellHandler public int CollectionIndex { get; set; } = -1; /// For collection items: offset within collection - public int CollectionItemOffset { get; set; } = 0; + public int CollectionItemOffset { get; set; } /// For formulas: the formula text public string? Formula { get; set; } @@ -138,20 +137,11 @@ internal class OptimizedCellHandler /// For collection items: pre-compiled converter from cell value to collection item type public Func? CollectionItemConverter { get; set; } - /// For collections: pre-compiled factory to create the collection instance - public Func? CollectionFactory { get; set; } - - /// For collections: pre-compiled converter from list to final type (e.g., array) - public Func? CollectionFinalizer { get; set; } - - /// For collections: whether the target type is an array (vs list) - public bool IsArrayTarget { 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; } = 0; + public int ItemIndex { get; set; } /// /// For collection handlers: the row where this collection stops reading (exclusive). @@ -175,13 +165,13 @@ internal class OptimizedMappingBoundaries public int MinRow { get; set; } = int.MaxValue; /// Maximum row used by any mapping (1-based) - public int MaxRow { get; set; } = 0; + 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; } = 0; + public int MaxColumn { get; set; } /// Width of the cell grid (MaxColumn - MinColumn + 1) public int GridWidth => MaxColumn > 0 ? MaxColumn - MinColumn + 1 : 0; @@ -189,9 +179,6 @@ internal class OptimizedMappingBoundaries /// Height of the cell grid (MaxRow - MinRow + 1) public int GridHeight => MaxRow > 0 ? MaxRow - MinRow + 1 : 0; - /// Total number of items this mapping can handle (based on collection layouts) - public int MaxItemCapacity { get; set; } = 1; - /// Whether this mapping has collections that can expand dynamically public bool HasDynamicCollections { get; set; } @@ -200,32 +187,11 @@ internal class OptimizedMappingBoundaries /// 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; } = 0; + 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; } = false; -} - -/// -/// Collection expansion strategy - how to handle collections with unknown sizes -/// -internal class CollectionExpansionInfo -{ - /// Starting row for expansion - public int StartRow { get; set; } - - /// Starting column for expansion - public int StartColumn { get; set; } - - /// Layout direction for expansion - public CollectionLayout Layout { get; set; } - - /// Row spacing between items - public int RowSpacing { get; set; } - - /// Collection mapping this expansion belongs to - public CompiledCollectionMapping CollectionMapping { get; set; } = null!; + public bool IsMultiItemPattern { get; set; } } \ No newline at end of file diff --git a/src/MiniExcel.Core/Mapping/IMappingCellStream.cs b/src/MiniExcel.Core/Mapping/IMappingCellStream.cs new file mode 100644 index 00000000..5aa21260 --- /dev/null +++ b/src/MiniExcel.Core/Mapping/IMappingCellStream.cs @@ -0,0 +1,6 @@ +namespace MiniExcelLib.Core.Mapping; + +internal interface IMappingCellStream +{ + IMiniExcelWriteAdapter CreateAdapter(); +} \ No newline at end of file diff --git a/src/MiniExcel.Core/Mapping/MappingCellStream.cs b/src/MiniExcel.Core/Mapping/MappingCellStream.cs new file mode 100644 index 00000000..6a11dfc4 --- /dev/null +++ b/src/MiniExcel.Core/Mapping/MappingCellStream.cs @@ -0,0 +1,238 @@ +using MiniExcelLib.Core.WriteAdapters; + +namespace MiniExcelLib.Core.Mapping; + +internal readonly struct MappingCellStream(IEnumerable items, CompiledMapping mapping, string[] columnLetters) : IMappingCellStream +{ + public MappingCellEnumerator GetEnumerator() + => new(items.GetEnumerator(), mapping, columnLetters); + + public IMiniExcelWriteAdapter CreateAdapter() + => new MappingCellStreamAdapter(this, columnLetters); +} + +internal struct MappingCellEnumerator +{ + 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; } + + public bool MoveNext() + { + 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) + { + return MoveNext(); // Continue with next empty row + } + } + + // 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 != null) + { + // Calculate max collection rows when we start processing an item + if (_currentColumnIndex == 0 && _currentCollectionRow == 0) + { + _maxCollectionRows = 0; + foreach (var coll in _mapping.Collections) + { + var collectionData = coll.Getter(_currentItem); + if (collectionData != null) + { + var count = 0; + foreach (var _ in collectionData) + count++; + + // For vertical collections, we need rows from StartCellRow + var neededRows = coll.StartCellRow + count - _currentRowIndex; + 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; + + // First check properties + for (var index = 0; index < _mapping.Properties.Count; index++) + { + var prop = _mapping.Properties[index]; + if (prop.CellColumn == columnNumber && prop.CellRow == _currentRowIndex) + { + cellValue = prop.Getter(_currentItem); + + // Apply formatting if specified + if (!string.IsNullOrEmpty(prop.Format) && cellValue is IFormattable formattable) + { + cellValue = formattable.ToString(prop.Format, null); + } + + if (cellValue == null) + cellValue = _emptyCell; + + break; + } + } + + // Then check collections + if (cellValue == _emptyCell) + { + for (var collIndex = 0; collIndex < _mapping.Collections.Count; collIndex++) + { + var coll = _mapping.Collections[collIndex]; + if (coll.StartCellColumn == columnNumber) + { + // This is a collection column - check if this row has a collection item + var collectionRowOffset = _currentRowIndex - coll.StartCellRow; + if (collectionRowOffset >= 0) + { + var collectionData = coll.Getter(_currentItem); + if (collectionData != null) + { + var itemIndex = 0; + foreach (var item in collectionData) + { + if (itemIndex == collectionRowOffset) + { + cellValue = item ?? _emptyCell; + break; + } + itemIndex++; + } + } + } + break; + } + } + } + + 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; + return MoveNext(); + } + + // Reset for next item + _currentCollectionRow = 0; + + // Move to next item + if (_itemEnumerator.MoveNext()) + { + _currentItem = _itemEnumerator.Current; + _currentRowIndex++; + _currentColumnIndex = 0; + return MoveNext(); + } + } + + _isComplete = true; + return false; + } + + public void Reset() + { + throw new NotSupportedException(); + } + + public void Dispose() + { + _itemEnumerator?.Dispose(); + } +} + +internal readonly struct MappingCell +{ + public readonly string ColumnLetter; + public readonly int RowIndex; + public readonly object? Value; + + public MappingCell(string columnLetter, int rowIndex, object? value) + { + ColumnLetter = columnLetter; + RowIndex = rowIndex; + Value = value; + } +} \ No newline at end of file diff --git a/src/MiniExcel.Core/Mapping/MappingCompiler.cs b/src/MiniExcel.Core/Mapping/MappingCompiler.cs index 2742270d..0f246ccd 100644 --- a/src/MiniExcel.Core/Mapping/MappingCompiler.cs +++ b/src/MiniExcel.Core/Mapping/MappingCompiler.cs @@ -1,8 +1,5 @@ -using System.Collections; using System.Linq.Expressions; -using System.Reflection; using MiniExcelLib.Core.Mapping.Configuration; -using MiniExcelLib.Core.OpenXml.Utils; namespace MiniExcelLib.Core.Mapping; @@ -14,9 +11,7 @@ internal static class MappingCompiler { // Conservative estimates for collection bounds when actual size is unknown private const int DefaultCollectionHeight = 100; - private const int DefaultCollectionWidth = 100; private const int DefaultGridSize = 10; - private const int MaxItemsToMarkInGrid = 10; private const int MaxPatternHeight = 20; private const int MinItemsForPatternCalc = 2; @@ -37,34 +32,90 @@ public static CompiledMapping Compile(MappingConfiguration configuratio 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(); - - // Extract property name from expression - var propertyName = GetPropertyName(prop.Expression); - // Create setter - var setterParam = Expression.Parameter(typeof(object), "obj"); - var valueParam = Expression.Parameter(typeof(object), "value"); - var castObj = Expression.Convert(setterParam, typeof(T)); - var castValue = Expression.Convert(valueParam, prop.PropertyType); - - // Create assignment if it's a member expression + // Create setter with proper type conversion Action? setter = null; if (prop.Expression.Body is MemberExpression memberExpr && memberExpr.Member is PropertyInfo propInfo) { - var assign = Expression.Assign(Expression.Property(castObj, propInfo), castValue); + var setterParam = Expression.Parameter(typeof(object), "obj"); + var valueParam = Expression.Parameter(typeof(object), "value"); + var castObj = Expression.Convert(setterParam, typeof(T)); + + // Build conversion expression based on target type + Expression convertedValue; + if (prop.PropertyType == typeof(int)) + { + // For int properties, handle double -> int conversion from Excel + var convertMethod = typeof(Convert).GetMethod("ToInt32", new[] { typeof(object) }); + convertedValue = Expression.Call(convertMethod!, valueParam); + } + else if (prop.PropertyType == typeof(decimal)) + { + // For decimal properties + var convertMethod = typeof(Convert).GetMethod("ToDecimal", new[] { typeof(object) }); + convertedValue = Expression.Call(convertMethod!, valueParam); + } + else if (prop.PropertyType == typeof(long)) + { + // For long properties + var convertMethod = typeof(Convert).GetMethod("ToInt64", new[] { typeof(object) }); + convertedValue = Expression.Call(convertMethod!, valueParam); + } + else if (prop.PropertyType == typeof(float)) + { + // For float properties + var convertMethod = typeof(Convert).GetMethod("ToSingle", new[] { typeof(object) }); + convertedValue = Expression.Call(convertMethod!, valueParam); + } + else if (prop.PropertyType == typeof(double)) + { + // For double properties + var convertMethod = typeof(Convert).GetMethod("ToDouble", new[] { typeof(object) }); + convertedValue = Expression.Call(convertMethod!, valueParam); + } + else if (prop.PropertyType == typeof(DateTime)) + { + // For DateTime properties + var convertMethod = typeof(Convert).GetMethod("ToDateTime", new[] { typeof(object) }); + convertedValue = Expression.Call(convertMethod!, valueParam); + } + else if (prop.PropertyType == typeof(bool)) + { + // For bool properties + var convertMethod = typeof(Convert).GetMethod("ToBoolean", new[] { typeof(object) }); + convertedValue = Expression.Call(convertMethod!, valueParam); + } + else if (prop.PropertyType == typeof(string)) + { + // For string properties + var convertMethod = typeof(Convert).GetMethod("ToString", new[] { typeof(object) }); + convertedValue = Expression.Call(convertMethod!, valueParam); + } + else + { + // Default: direct cast for other types + convertedValue = Expression.Convert(valueParam, prop.PropertyType); + } + + var assign = Expression.Assign(Expression.Property(castObj, propInfo), convertedValue); var setterLambda = Expression.Lambda>(assign, setterParam, valueParam); setter = setterLambda.Compile(); } // Pre-parse cell coordinates for runtime performance - ReferenceHelper.ParseReference(prop.CellAddress, out int cellCol, out int cellRow); + if (prop.CellAddress == null) continue; + ReferenceHelper.ParseReference(prop.CellAddress, out int cellCol, out int cellRow); + properties.Add(new CompiledPropertyMapping { Getter = compiled, @@ -114,7 +165,7 @@ public static CompiledMapping Compile(MappingConfiguration configuratio // Create setter for collection Action? collectionSetter = null; - if (coll.Expression.Body is MemberExpression collMemberExpr && collMemberExpr.Member is System.Reflection.PropertyInfo collPropInfo) + if (coll.Expression.Body is MemberExpression collMemberExpr && collMemberExpr.Member is PropertyInfo collPropInfo) { var setterParam = Expression.Parameter(typeof(object), "obj"); var valueParam = Expression.Parameter(typeof(object), "value"); @@ -126,12 +177,13 @@ public static CompiledMapping Compile(MappingConfiguration configuratio } // Pre-parse start cell coordinates + if (coll.StartCell == null) continue; + ReferenceHelper.ParseReference(coll.StartCell, out int startCol, out int startRow); - + var compiledCollection = new CompiledCollectionMapping { Getter = compiled, - StartCell = coll.StartCell, StartCellColumn = startCol, StartCellRow = startRow, Layout = coll.Layout, @@ -142,25 +194,6 @@ public static CompiledMapping Compile(MappingConfiguration configuratio Registry = registry }; - // Try to get the item mapping from the registry if available - if (itemType != null && registry != null) - { - var itemMapping = registry.GetCompiledMapping(itemType); - if (itemMapping != null) - { - compiledCollection.ItemMapping = itemMapping; - } - } - // Otherwise compile nested item mapping if exists - else if (coll.ItemConfiguration != null && coll.ItemType != null) - { - var compileMethod = typeof(MappingCompiler) - .GetMethod(nameof(Compile))! - .MakeGenericMethod(coll.ItemType); - - compiledCollection.ItemMapping = compileMethod.Invoke(null, [coll.ItemConfiguration, registry]); - } - collections.Add(compiledCollection); } @@ -171,8 +204,7 @@ public static CompiledMapping Compile(MappingConfiguration configuratio Collections = collections }; - // Apply universal optimization to all mappings - OptimizeMapping(compiledMapping, registry); + OptimizeMapping(compiledMapping); return compiledMapping; } @@ -196,23 +228,19 @@ private static string GetPropertyName(LambdaExpression expression) /// Optimizes a compiled mapping for runtime performance by pre-calculating cell positions /// and building optimized data structures for fast lookup and processing. /// - public static void OptimizeMapping(CompiledMapping mapping, MappingRegistry? registry = null) + private static void OptimizeMapping(CompiledMapping mapping) { if (mapping == null) throw new ArgumentNullException(nameof(mapping)); // If already optimized, skip - if (mapping.IsUniversallyOptimized) + if (mapping.OptimizedCellGrid != null && mapping.OptimizedBoundaries != null) return; // Step 1: Calculate mapping boundaries var boundaries = CalculateMappingBoundaries(mapping); mapping.OptimizedBoundaries = boundaries; - // Step 2: Pre-calculate collection expansion info - var expansions = CalculateCollectionExpansions(mapping); - mapping.CollectionExpansions = expansions; - // Step 3: Build the optimized cell grid var cellGrid = BuildOptimizedCellGrid(mapping, boundaries); mapping.OptimizedCellGrid = cellGrid; @@ -222,7 +250,7 @@ public static void OptimizeMapping(CompiledMapping mapping, MappingRegistr mapping.OptimizedColumnHandlers = columnHandlers; // Step 5: Pre-compile collection factories and finalizers - PreCompileCollectionHelpers(mapping); + PreCompileCollectionHelpers(mapping); } private static OptimizedMappingBoundaries CalculateMappingBoundaries(CompiledMapping mapping) @@ -339,35 +367,21 @@ private static (int minRow, int maxRow, int minCol, int maxCol) CalculateCollect // Check if this is a complex type with nested mapping var maxCol = startCol; - if (collection.ItemType != null && collection.Registry != null) + if (collection.ItemType == null || collection.Registry == null) + return (startRow, startRow + verticalHeight, startCol, maxCol); + + var nestedMapping = collection.Registry.GetCompiledMapping(collection.ItemType); + if (nestedMapping == null || collection.ItemType == typeof(string) || + collection.ItemType.IsValueType || + collection.ItemType.IsPrimitive) return (startRow, startRow + verticalHeight, startCol, maxCol); + + // Extract nested mapping info to get max column + var nestedInfo = ExtractNestedMappingInfo(nestedMapping, collection.ItemType); + if (nestedInfo != null && nestedInfo.Properties.Count > 0) { - var nestedMapping = collection.Registry.GetCompiledMapping(collection.ItemType); - if (nestedMapping != null && collection.ItemType != typeof(string) && - !collection.ItemType.IsValueType && !collection.ItemType.IsPrimitive) - { - // Extract max column from nested mapping properties - var nestedMappingType = nestedMapping.GetType(); - var propsProperty = nestedMappingType.GetProperty("Properties"); - if (propsProperty != null) - { - var properties = propsProperty.GetValue(nestedMapping) as System.Collections.IEnumerable; - if (properties != null) - { - foreach (var prop in properties) - { - var propType = prop.GetType(); - var columnProperty = propType.GetProperty("CellColumn"); - if (columnProperty != null) - { - var column = (int)columnProperty.GetValue(prop); - maxCol = Math.Max(maxCol, column); - } - } - } - } - } + maxCol = nestedInfo.Properties.Max(p => p.ColumnIndex); } - + return (startRow, startRow + verticalHeight, startCol, maxCol); } @@ -375,25 +389,6 @@ private static (int minRow, int maxRow, int minCol, int maxCol) CalculateCollect return (startRow, startRow + DefaultGridSize, startCol, startCol + DefaultGridSize); } - private static List CalculateCollectionExpansions(CompiledMapping mapping) - { - var expansions = new List(); - - foreach (var collection in mapping.Collections) - { - expansions.Add(new CollectionExpansionInfo - { - StartRow = collection.StartCellRow, - StartColumn = collection.StartCellColumn, - Layout = collection.Layout, - RowSpacing = collection.RowSpacing, - CollectionMapping = collection - }); - } - - return expansions; - } - private static OptimizedCellHandler[,] BuildOptimizedCellGrid(CompiledMapping mapping, OptimizedMappingBoundaries boundaries) { var height = boundaries.GridHeight; @@ -423,7 +418,7 @@ private static List CalculateCollectionExpansions(Co { Type = string.IsNullOrEmpty(prop.Formula) ? CellHandlerType.Property : CellHandlerType.Formula, ValueExtractor = CreatePropertyValueExtractor(prop), - ValueSetter = CreatePreCompiledSetter(prop), // Pre-compiled setter with conversion built-in + ValueSetter = prop.Setter, PropertyName = prop.PropertyName, Format = prop.Format, Formula = prop.Formula, @@ -479,7 +474,7 @@ private static void MarkVerticalCollectionCells(OptimizedCellHandler[,] grid, Co if (nestedMapping != null && itemType != typeof(string) && !itemType.IsValueType && !itemType.IsPrimitive) { // Complex type with mapping - expand each item across multiple columns - MarkVerticalComplexCollectionCells(grid, collection, collectionIndex, boundaries, startRow, startCol, nestedMapping); + MarkVerticalComplexCollectionCells(grid, collection, collectionIndex, boundaries, startRow, nestedMapping); } else { @@ -536,7 +531,7 @@ private static OptimizedCellHandler[] BuildOptimizedColumnHandlers(CompiledMa columnHandlers[relativeCol] = new OptimizedCellHandler { Type = CellHandlerType.Property, - ValueSetter = CreatePreCompiledSetter(prop), + ValueSetter = prop.Setter, PropertyName = prop.PropertyName }; } @@ -549,65 +544,21 @@ private static OptimizedCellHandler[] BuildOptimizedColumnHandlers(CompiledMa { // The property getter is already compiled, just wrap it to match our signature var getter = property.Getter; - return (obj, itemIndex) => getter(obj); - } - - private static Action? CreatePreCompiledSetter(CompiledPropertyMapping property) - { - // Pre-compile the setter with type conversion built in - var originalSetter = property.Setter; - if (originalSetter == null) return null; - - var targetType = property.PropertyType; - - // Build a setter that includes conversion - return (obj, value) => - { - if (value == null) - { - originalSetter(obj, null); - return; - } - - // Pre-compiled conversion logic - this runs at compile time, not runtime! - object? convertedValue = value; - - if (value.GetType() != targetType) - { - convertedValue = targetType switch - { - _ when targetType == typeof(string) => value.ToString(), - _ when targetType == typeof(int) => Convert.ToInt32(value), - _ when targetType == typeof(long) => Convert.ToInt64(value), - _ when targetType == typeof(decimal) => Convert.ToDecimal(value), - _ when targetType == typeof(double) => Convert.ToDouble(value), - _ when targetType == typeof(float) => Convert.ToSingle(value), - _ when targetType == typeof(bool) => Convert.ToBoolean(value), - _ when targetType == typeof(DateTime) => Convert.ToDateTime(value), - _ => Convert.ChangeType(value, targetType) - }; - } - - originalSetter(obj, convertedValue); - }; + return (obj, _) => getter(obj); } private static Func CreateCollectionValueExtractor(CompiledCollectionMapping collection, int offset) { var getter = collection.Getter; - return (obj, itemIndex) => + return (obj, _) => { var enumerable = getter(obj); - if (enumerable == null) return null; - - // Try to use IList for O(1) access if possible - if (enumerable is IList list) + return enumerable switch { - return offset < list.Count ? list[offset] : null; - } - - // Fallback to Skip/FirstOrDefault for other IEnumerable - return enumerable.Cast().Skip(offset).FirstOrDefault(); + null => null, + IList list => offset < list.Count ? list[offset] : null, + _ => enumerable.Cast().Skip(offset).FirstOrDefault() + }; }; } @@ -617,9 +568,11 @@ private static void PreCompileCollectionHelpers(CompiledMapping mapping) // Store pre-compiled helpers for each collection var helpers = new List(); + var nestedMappings = new Dictionary(); - foreach (var collection in mapping.Collections) + for (int i = 0; i < mapping.Collections.Count; i++) { + var collection = mapping.Collections[i]; var helper = new OptimizedCollectionHelper(); // Get the actual property info @@ -628,73 +581,54 @@ private static void PreCompileCollectionHelpers(CompiledMapping mapping) var propertyType = propInfo.PropertyType; var itemType = collection.ItemType ?? typeof(object); + helper.ItemType = itemType; - // Pre-compile collection factory - helper.Factory = () => - { - var listType = typeof(List<>).MakeGenericType(itemType); - return (System.Collections.IList)Activator.CreateInstance(listType)!; - }; + // Create simple factory functions + var listType = typeof(List<>).MakeGenericType(itemType); + helper.Factory = () => (IList)Activator.CreateInstance(listType)!; + helper.DefaultItemFactory = () => itemType.IsValueType ? Activator.CreateInstance(itemType) : null; + helper.Finalizer = propertyType.IsArray + ? list => { var array = Array.CreateInstance(itemType, list.Count); list.CopyTo(array, 0); return array; } + : list => list; + helper.IsArray = propertyType.IsArray; + helper.Setter = collection.Setter; - // Pre-compile finalizer (converts list to final type) - if (propertyType.IsArray) + // 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 != null && itemType != typeof(string) && + !itemType.IsValueType && !itemType.IsPrimitive) { - helper.IsArray = true; - var elementType = propertyType.GetElementType()!; - helper.Finalizer = (list) => + var nestedMapping = collection.Registry.GetCompiledMapping(itemType); + if (nestedMapping != null) { - var array = Array.CreateInstance(elementType, list.Count); - list.CopyTo(array, 0); - return array; - }; - } - else - { - helper.IsArray = false; - helper.Finalizer = (list) => list; + var nestedInfo = ExtractNestedMappingInfo(nestedMapping, itemType); + if (nestedInfo != null) + { + nestedMappings[i] = nestedInfo; + } + } } - - // Pre-compile setter - helper.Setter = collection.Setter; - - helpers.Add(helper); } mapping.OptimizedCollectionHelpers = helpers; + if (nestedMappings.Count > 0) + { + mapping.NestedMappings = nestedMappings; + } } private static void MarkVerticalComplexCollectionCells(OptimizedCellHandler[,] grid, CompiledCollectionMapping collection, - int collectionIndex, OptimizedMappingBoundaries boundaries, int startRow, int startCol, object nestedMapping) + int collectionIndex, OptimizedMappingBoundaries boundaries, int startRow, object nestedMapping) { - // For complex types, we need to extract individual properties - // Use reflection to get the properties from the nested mapping - var nestedMappingType = nestedMapping.GetType(); - var propsProperty = nestedMappingType.GetProperty("Properties"); - if (propsProperty == null) return; - - var properties = propsProperty.GetValue(nestedMapping) as System.Collections.IEnumerable; - if (properties == null) return; - - var propertyList = new List<(string Name, int Column, Func Getter)>(); - foreach (var prop in properties) - { - var propType = prop.GetType(); - var nameProperty = propType.GetProperty("PropertyName"); - var columnProperty = propType.GetProperty("CellColumn"); - var getterProperty = propType.GetProperty("Getter"); - - if (nameProperty != null && columnProperty != null && getterProperty != null) - { - var name = nameProperty.GetValue(prop) as string; - var column = (int)columnProperty.GetValue(prop); - var getter = getterProperty.GetValue(prop) as Func; - - if (name != null && getter != null) - { - propertyList.Add((name, column, getter)); - } - } - } + // Extract pre-compiled nested mapping info without reflection + var nestedInfo = ExtractNestedMappingInfo(nestedMapping, collection.ItemType ?? typeof(object)); + if (nestedInfo == null) return; // Now mark cells for each property of each collection item var maxRows = Math.Min(100, grid.GetLength(0)); // Conservative range @@ -704,27 +638,26 @@ private static void MarkVerticalComplexCollectionCells(OptimizedCellHandler[,] g for (int itemIndex = 0; itemIndex < 20; itemIndex++) // Conservative estimate of collection size { var r = startRelativeRow + itemIndex * (1 + rowSpacing); - if (r >= 0 && r < maxRows && r < grid.GetLength(0)) + if (r < 0 || r >= maxRows || r >= grid.GetLength(0)) continue; + + foreach (var prop in nestedInfo.Properties) { - foreach (var (propName, propColumn, propGetter) in propertyList) + var c = prop.ColumnIndex - boundaries.MinColumn; + if (c >= 0 && c < grid.GetLength(1)) { - var c = propColumn - boundaries.MinColumn; - if (c >= 0 && c < grid.GetLength(1)) + // Only mark if not already occupied + if (grid[r, c].Type == CellHandlerType.Empty) { - // Only mark if not already occupied - if (grid[r, c].Type == CellHandlerType.Empty) + grid[r, c] = new OptimizedCellHandler { - grid[r, c] = new OptimizedCellHandler - { - Type = CellHandlerType.CollectionItem, - ValueExtractor = CreateNestedPropertyExtractor(collection, itemIndex, propGetter), - CollectionIndex = collectionIndex, - CollectionItemOffset = itemIndex, - PropertyName = propName, - CollectionMapping = collection, - CollectionItemConverter = null // No conversion needed, property getter handles it - }; - } + Type = CellHandlerType.CollectionItem, + ValueExtractor = CreateNestedPropertyExtractor(collection, itemIndex, prop.Getter), + CollectionIndex = collectionIndex, + CollectionItemOffset = itemIndex, + PropertyName = prop.PropertyName, + CollectionMapping = collection, + CollectionItemConverter = null // No conversion needed, property getter handles it + }; } } } @@ -734,76 +667,108 @@ private static void MarkVerticalComplexCollectionCells(OptimizedCellHandler[,] g private static Func CreateNestedPropertyExtractor(CompiledCollectionMapping collection, int offset, Func propertyGetter) { var collectionGetter = collection.Getter; - return (obj, itemIndex) => + return (obj, _) => { var enumerable = collectionGetter(obj); - if (enumerable == null) return null; - - // Try to use IList for O(1) access if possible - if (enumerable is IList list) + switch (enumerable) { - if (offset < list.Count && list[offset] != null) + case null: + break; + case IList list: { - // Extract the property from the nested object - return propertyGetter(list[offset]); + if (offset < list.Count && list[offset] != null) + { + // Extract the property from the nested object + return propertyGetter(list[offset]); + } + + break; } - } - else - { - // Fall back to enumeration (slower but works) - var items = enumerable.Cast().Skip(offset).Take(1).ToArray(); - if (items.Length > 0 && items[0] != null) + default: { - return propertyGetter(items[0]); + // Fall back to enumeration (slower but works) + var items = enumerable.Cast().Skip(offset).Take(1).ToArray(); + if (items.Length > 0 && items[0] != null) + { + return propertyGetter(items[0]); + } + + break; } } - + return null; }; } private static Func CreatePreCompiledItemConverter(Type targetType) { - // Pre-compile all the conversion logic - return (value) => + // Simple converter that handles common type conversions + return value => { if (value == null) return null; if (value.GetType() == targetType) return value; - // These conversions are JIT-compiled and inlined try { - return targetType switch - { - _ when targetType == typeof(string) => value.ToString(), - _ when targetType == typeof(int) => Convert.ToInt32(value), - _ when targetType == typeof(long) => Convert.ToInt64(value), - _ when targetType == typeof(decimal) => Convert.ToDecimal(value), - _ when targetType == typeof(double) => Convert.ToDouble(value), - _ when targetType == typeof(float) => Convert.ToSingle(value), - _ when targetType == typeof(bool) => Convert.ToBoolean(value), - _ when targetType == typeof(DateTime) => Convert.ToDateTime(value), - _ => Convert.ChangeType(value, targetType) - }; + return Convert.ChangeType(value, targetType); } catch { - // Fallback to string parsing for robustness - var str = value.ToString(); - if (string.IsNullOrEmpty(str)) return null; + return targetType.IsValueType ? Activator.CreateInstance(targetType) : null; + } + }; + } + + private 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 == null) return null; + + var properties = propsProperty.GetValue(nestedMapping) as IEnumerable; + if (properties == null) return null; + + var nestedInfo = new NestedMappingInfo + { + ItemType = itemType, + ItemFactory = () => itemType.IsValueType ? Activator.CreateInstance(itemType) : null + }; + + 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 == null || columnProperty == null || getterProperty == 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; - return targetType switch + if (name != null && getter != null) + { + propertyList.Add(new NestedPropertyInfo { - _ when targetType == typeof(int) && int.TryParse(str, out var i) => i, - _ when targetType == typeof(long) && long.TryParse(str, out var l) => l, - _ when targetType == typeof(decimal) && decimal.TryParse(str, out var d) => d, - _ when targetType == typeof(double) && double.TryParse(str, out var db) => db, - _ when targetType == typeof(float) && float.TryParse(str, out var f) => f, - _ when targetType == typeof(bool) && bool.TryParse(str, out var b) => b, - _ when targetType == typeof(DateTime) && DateTime.TryParse(str, out var dt) => dt, - _ => Convert.ChangeType(value, targetType) - }; + PropertyName = name, + ColumnIndex = column, + Getter = getter, + Setter = setter ?? ((_, _) => { }), + PropertyType = propTypeValue ?? typeof(object) + }); } - }; + } + + nestedInfo.Properties = propertyList; + return nestedInfo; } } \ No newline at end of file diff --git a/src/MiniExcel.Core/Mapping/MappingImporter.cs b/src/MiniExcel.Core/Mapping/MappingImporter.cs index b11ebc1e..4de73ce2 100644 --- a/src/MiniExcel.Core/Mapping/MappingImporter.cs +++ b/src/MiniExcel.Core/Mapping/MappingImporter.cs @@ -15,14 +15,15 @@ public MappingImporter(MappingRegistry registry) } [CreateSyncVersion] - public async Task> QueryAsync(string path, CancellationToken cancellationToken = default) where T : class, new() + public async IAsyncEnumerable QueryAsync(string path, [EnumeratorCancellation] CancellationToken cancellationToken = default) where T : class, new() { using var stream = File.OpenRead(path); - return await QueryAsync(stream, cancellationToken).ConfigureAwait(false); + await foreach (var item in QueryAsync(stream, cancellationToken).ConfigureAwait(false)) + yield return item; } [CreateSyncVersion] - public async Task> QueryAsync(Stream stream, CancellationToken cancellationToken = default) where T : class, new() + public async IAsyncEnumerable QueryAsync(Stream stream, [EnumeratorCancellation] CancellationToken cancellationToken = default) where T : class, new() { if (stream == null) throw new ArgumentNullException(nameof(stream)); @@ -31,7 +32,8 @@ public MappingImporter(MappingRegistry registry) if (mapping == null) throw new InvalidOperationException($"No mapping configuration found for type {typeof(T).Name}. Configure the mapping using MappingRegistry.Configure<{typeof(T).Name}>()."); - return await MappingReader.QueryAsync(stream, mapping, cancellationToken).ConfigureAwait(false); + await foreach (var item in MappingReader.QueryAsync(stream, mapping, cancellationToken).ConfigureAwait(false)) + yield return item; } [CreateSyncVersion] @@ -42,7 +44,7 @@ public MappingImporter(MappingRegistry registry) } [CreateSyncVersion] - public async Task QuerySingleAsync(Stream stream, CancellationToken cancellationToken = default) where T : class, new() + private async Task QuerySingleAsync(Stream stream, CancellationToken cancellationToken = default) where T : class, new() { if (stream == null) throw new ArgumentNullException(nameof(stream)); @@ -51,7 +53,11 @@ public MappingImporter(MappingRegistry registry) if (mapping == null) throw new InvalidOperationException($"No mapping configuration found for type {typeof(T).Name}. Configure the mapping using MappingRegistry.Configure<{typeof(T).Name}>()."); - var results = await MappingReader.QueryAsync(stream, mapping, cancellationToken).ConfigureAwait(false); - return results.FirstOrDefault() ?? new T(); + 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/Mapping/MappingReader.cs b/src/MiniExcel.Core/Mapping/MappingReader.cs index 08bf5465..b2a85e09 100644 --- a/src/MiniExcel.Core/Mapping/MappingReader.cs +++ b/src/MiniExcel.Core/Mapping/MappingReader.cs @@ -1,467 +1,27 @@ -using MiniExcelLib.Core.Helpers; -using MiniExcelLib.Core.OpenXml.Utils; - namespace MiniExcelLib.Core.Mapping; internal static partial class MappingReader where T : class, new() { [CreateSyncVersion] - public static async Task> QueryAsync(Stream stream, CompiledMapping mapping, CancellationToken cancellationToken = default) + public static async IAsyncEnumerable QueryAsync(Stream stream, CompiledMapping mapping, [EnumeratorCancellation] CancellationToken cancellationToken = default) { if (stream == null) throw new ArgumentNullException(nameof(stream)); if (mapping == null) throw new ArgumentNullException(nameof(mapping)); - // Use optimized universal reader if mapping is optimized - if (mapping.IsUniversallyOptimized) - { - return await QueryUniversalAsync(stream, mapping, cancellationToken).ConfigureAwait(false); - } - - // Legacy path for non-optimized mappings - var importer = new OpenXmlImporter(); - - var dataList = new List>(); - - await foreach (var row in importer.QueryAsync(stream, useHeaderRow: false, sheetName: mapping.WorksheetName, startCell: "A1", cancellationToken: cancellationToken).ConfigureAwait(false)) - { - if (row is IDictionary dict) - { - // Include all rows, even if they appear empty - dataList.Add(dict); - } - } - - if (!dataList.Any()) - { - return []; - } - - // Build a cell lookup dictionary for efficient access - var cellLookup = BuildCellLookup(dataList); - - // Read the mapped data - var results = ReadMappedData(cellLookup, mapping); - return results; - } - - [CreateSyncVersion] - public static async Task QuerySingleAsync(Stream stream, CompiledMapping mapping, CancellationToken cancellationToken = default) - { - var results = await QueryAsync(stream, mapping, cancellationToken).ConfigureAwait(false); - return results.FirstOrDefault() ?? new T(); - } - - private static Dictionary BuildCellLookup(List> data) - { - var lookup = new Dictionary(); - - for (int rowIndex = 0; rowIndex < data.Count; rowIndex++) - { - var row = data[rowIndex]; - var rowNumber = rowIndex + 1; // Row is 1-based - - foreach (var kvp in row) - { - var columnLetter = kvp.Key; - var cellAddress = $"{columnLetter}{rowNumber}"; - lookup[cellAddress] = kvp.Value; - } - } - - return lookup; - } - - private static IEnumerable ReadMappedData(Dictionary cellLookup, CompiledMapping mapping) - { - // Calculate the expected spacing between items based on mapping configuration - var maxPropertyRow = 0; - foreach (var prop in mapping.Properties) - { - if (prop.CellRow > maxPropertyRow) - maxPropertyRow = prop.CellRow; - } - - // Also check collection start rows as they may be part of the item definition - foreach (var coll in mapping.Collections) - { - if (coll.StartCellRow > maxPropertyRow) - maxPropertyRow = coll.StartCellRow; - } - - // Determine item spacing based on the mapping pattern - // If all properties are on row 1 (A1, B1, C1...), it's likely a table pattern where each row is an item - // Otherwise, use the writer's spacing pattern (maxPropertyRow + 2) - var allOnRow1 = mapping.Properties.All(p => p.CellRow == 1); - var itemSpacing = allOnRow1 ? 1 : maxPropertyRow + 2; - - #if DEBUG - System.Diagnostics.Debug.WriteLine($"ReadMappedData: allOnRow1={allOnRow1}, itemSpacing={itemSpacing}"); - #endif - - // Find the base row where properties start - var baseRow = int.MaxValue; - foreach (var prop in mapping.Properties) - { - if (prop.CellRow < baseRow) - baseRow = prop.CellRow; - } - - if (baseRow == int.MaxValue) - baseRow = 1; - - // Debug logging - #if DEBUG - System.Diagnostics.Debug.WriteLine($"ReadMappedData: maxPropertyRow={maxPropertyRow}, itemSpacing={itemSpacing}, baseRow={baseRow}"); - #endif - - // Read items at expected intervals - var currentRow = baseRow; - var itemsFound = 0; - var maxItems = 1_000_000; // Safety limit - 1 million items should be enough - - while (itemsFound < maxItems) - { - var result = new T(); - var hasData = false; - - #if DEBUG - System.Diagnostics.Debug.WriteLine($"Reading item at currentRow={currentRow}"); - #endif - - // Read simple properties at current offset - foreach (var prop in mapping.Properties) - { - var offsetRow = currentRow + (prop.CellRow - baseRow); - var cellAddress = ReferenceHelper.ConvertCoordinatesToCell(prop.CellColumn, offsetRow); - - if (cellLookup.TryGetValue(cellAddress, out var value)) - { - SetPropertyValue(result, prop, value); - hasData = true; - #if DEBUG - System.Diagnostics.Debug.WriteLine($" Found property {prop.PropertyName} at {cellAddress}: {value}"); - #endif - } - else - { - // Try column compression fallback - var fallbackAddress = $"A{offsetRow}"; - if (cellLookup.TryGetValue(fallbackAddress, out var fallbackValue)) - { - SetPropertyValue(result, prop, fallbackValue); - hasData = true; - #if DEBUG - System.Diagnostics.Debug.WriteLine($" Found property {prop.PropertyName} at fallback {fallbackAddress}: {fallbackValue}"); - #endif - } - } - } - - if (!hasData) - { - // No more items found - #if DEBUG - System.Diagnostics.Debug.WriteLine($"No data found at row {currentRow}, stopping"); - #endif - break; - } - - // Read collections at current offset - for (int collIndex = 0; collIndex < mapping.Collections.Count; collIndex++) - { - var coll = mapping.Collections[collIndex]; - var offsetStartRow = currentRow + (coll.StartCellRow - baseRow); - var offsetStartCell = ReferenceHelper.ConvertCoordinatesToCell(coll.StartCellColumn, offsetStartRow); - - // Determine collection boundaries - int? maxRow = null; - int? maxCol = null; - - // Check if there's another collection after this one on the same item - for (int nextCollIndex = collIndex + 1; nextCollIndex < mapping.Collections.Count; nextCollIndex++) - { - var nextColl = mapping.Collections[nextCollIndex]; - var nextOffsetStartRow = currentRow + (nextColl.StartCellRow - baseRow); - - // Only vertical collections are supported - if (coll.Layout == CollectionLayout.Vertical && nextColl.Layout == CollectionLayout.Vertical && nextColl.StartCellColumn == coll.StartCellColumn) - { - maxRow = nextOffsetStartRow - 1; - break; - } - } - - // Check if there's definitely another item to limit collection boundaries - // This prevents reading collection data from the next item - if (maxRow == null && coll.Layout == CollectionLayout.Vertical) - { - // Check if there's a next item by looking for ALL properties (not just one) - // Only consider it a next item if we find MULTIPLE property values - var nextItemPropertyCount = 0; - if (mapping.Properties.Any()) - { - // Check all properties to see if any exist at the next item position - foreach (var prop in mapping.Properties) - { - if (ReferenceHelper.ParseReference(prop.CellAddress, out int propCol, out int propRow)) - { - var nextItemRow = currentRow + itemSpacing + (propRow - baseRow); - var nextItemCell = ReferenceHelper.ConvertCoordinatesToCell(propCol, nextItemRow); - if (cellLookup.TryGetValue(nextItemCell, out var value) && value != null) - { - nextItemPropertyCount++; - } - } - } - } - - // Only limit if we find at least 2 properties or the majority of properties - var minPropsForNextItem = Math.Max(2, mapping.Properties.Count / 2); - if (nextItemPropertyCount >= minPropsForNextItem) - { - maxRow = currentRow + itemSpacing - 1; - } - } - - var collectionData = ReadCollectionDataWithOffset(cellLookup, coll, offsetStartCell, maxRow, maxCol); - - #if DEBUG - System.Diagnostics.Debug.WriteLine($" Collection {coll.PropertyName} at {offsetStartCell}: {collectionData.Count} items"); - #endif - - SetCollectionValue(result, coll, collectionData); - } - - yield return result; - itemsFound++; - currentRow += itemSpacing; - - #if DEBUG - System.Diagnostics.Debug.WriteLine($"Item {itemsFound} read, moving to row {currentRow}"); - #endif - } - } - - private static void SetPropertyValue(T instance, CompiledPropertyMapping prop, object value) - { - if (prop.Setter != null) - { - var convertedValue = ConversionHelper.ConvertValue(value, prop.PropertyType, prop.Format); - prop.Setter(instance, convertedValue); - } - } - - private static List ReadCollectionDataWithOffset(Dictionary cellLookup, CompiledCollectionMapping coll, string offsetStartCell, int? maxRow = null, int? maxCol = null) - { - var results = new List(); - - if (!ReferenceHelper.ParseReference(offsetStartCell, out int startColumn, out int startRow)) - return results; - - var currentRow = startRow; - var currentCol = startColumn; - var itemIndex = 0; - var emptyCellCount = 0; - const int maxEmptyCells = 10; - const int maxIterations = 1000; - var iterations = 0; - - while (emptyCellCount < maxEmptyCells && iterations < maxIterations && (!maxRow.HasValue || currentRow <= maxRow.Value)) - { - if (coll.ItemMapping != null && coll.ItemType != null) - { - var item = ReadComplexItem(cellLookup, coll, currentRow, currentCol, itemIndex); - if (item != null) - { - results.Add(item); - emptyCellCount = 0; - } - else - { - emptyCellCount++; - } - } - else - { - var cellAddress = CalculateCellPosition(offsetStartCell, currentRow, currentCol, itemIndex, coll); - if (cellLookup.TryGetValue(cellAddress, out var value) && value != null && !string.IsNullOrEmpty(value.ToString())) - { - results.Add(value); - emptyCellCount = 0; - } - else - { - emptyCellCount++; - } - } - - UpdatePosition(ref currentRow, ref currentCol, ref itemIndex, coll); - iterations++; - } - - return results; - } - - - private static object? ReadComplexItem(Dictionary cellLookup, CompiledCollectionMapping coll, int currentRow, int currentCol, int itemIndex) - { - if (coll.ItemType == null || coll.ItemMapping == null) - return null; - - var item = Activator.CreateInstance(coll.ItemType); - if (item == null) - return null; - - var itemMapping = coll.ItemMapping; - var itemMappingType = itemMapping.GetType(); - var propsProperty = itemMappingType.GetProperty("Properties"); - var properties = propsProperty?.GetValue(itemMapping) as IEnumerable; - - var hasAnyValue = false; - - if (properties != null) - { - foreach (var prop in properties) - { - // For nested mappings, we need to adjust the property's cell address relative to the collection item's position - if (!ReferenceHelper.ParseReference(prop.CellAddress, out int propCol, out int propRow)) - continue; - - // Only vertical layout is supported - var cellAddress = coll.Layout == CollectionLayout.Vertical - ? ReferenceHelper.ConvertCoordinatesToCell(currentCol + propCol - 1, currentRow + propRow - 1) - : prop.CellAddress; - - if (cellLookup.TryGetValue(cellAddress, out var value) && value != null && !string.IsNullOrEmpty(value.ToString())) - { - SetItemPropertyValue(item, prop, value); - hasAnyValue = true; - } - } - } - - return hasAnyValue ? item : null; - } - - private static void SetItemPropertyValue(object instance, CompiledPropertyMapping prop, object value) - { - if (prop.Setter == null) return; - - var convertedValue = ConversionHelper.ConvertValue(value, prop.PropertyType, prop.Format); - prop.Setter(instance, convertedValue); - } - - private static void SetCollectionValue(T instance, CompiledCollectionMapping coll, List items) - { - if (coll.Setter != null) - { - var targetType = typeof(T); - var propertyInfo = targetType.GetProperty(coll.PropertyName); - - if (propertyInfo != null) - { - var collectionType = propertyInfo.PropertyType; - var convertedCollection = ConvertToTypedCollection(items, collectionType, coll.ItemType); - coll.Setter(instance, convertedCollection); - } - } - } - - private static string CalculateCellPosition(string baseCellAddress, int currentRow, int currentCol, int itemIndex, CompiledCollectionMapping mapping) - { - if (!ReferenceHelper.ParseReference(baseCellAddress, out int baseColumn, out int baseRow)) - return baseCellAddress; - - // Only vertical layout is supported - return ReferenceHelper.ConvertCoordinatesToCell(baseColumn, currentRow); - } - - private static void UpdatePosition(ref int currentRow, ref int currentCol, ref int itemIndex, CompiledCollectionMapping mapping) - { - itemIndex++; - - // Only vertical layout is supported - if (mapping.Layout == CollectionLayout.Vertical) - { - currentRow += 1 + mapping.RowSpacing; - } - } - private static object? ConvertToTypedCollection(List items, Type collectionType, Type? itemType) - { - if (items.Count == 0) - { - // For arrays, return empty array instead of null - if (collectionType.IsArray) - { - var elementType = collectionType.GetElementType() ?? typeof(object); - return Array.CreateInstance(elementType, 0); - } - return null; - } - - // Handle arrays - if (collectionType.IsArray) - { - var elementType = collectionType.GetElementType() ?? typeof(object); - var array = Array.CreateInstance(elementType, items.Count); - for (int i = 0; i < items.Count; i++) - { - array.SetValue(ConversionHelper.ConvertValue(items[i], elementType, null), i); - } - return array; - } - - // Handle List - if (collectionType.IsGenericType && collectionType.GetGenericTypeDefinition() == typeof(List<>)) - { - var elementType = collectionType.GetGenericArguments()[0]; - var listType = typeof(List<>).MakeGenericType(elementType); - var list = Activator.CreateInstance(listType) as IList; - - foreach (var item in items) - { - var convertedValue = ConversionHelper.ConvertValue(item, elementType, null); - if (convertedValue != null) - { - list?.Add(convertedValue); - } - } - return list; - } - - // Handle IEnumerable - if (collectionType.IsGenericType && - (collectionType.GetGenericTypeDefinition() == typeof(IEnumerable<>) || - collectionType.GetInterface(typeof(IEnumerable<>).Name) != null)) - { - var elementType = itemType ?? collectionType.GetGenericArguments()[0]; - var listType = typeof(List<>).MakeGenericType(elementType); - var list = Activator.CreateInstance(listType) as IList; - - foreach (var item in items) - { - list?.Add(item); // Items are already converted - } - return list; - } - - return items; + await foreach (var item in QueryOptimizedAsync(stream, mapping, cancellationToken).ConfigureAwait(false)) + yield return item; } - // Universal optimized reader implementation [CreateSyncVersion] - private static async Task> QueryUniversalAsync(Stream stream, CompiledMapping mapping, CancellationToken cancellationToken = default) + private static async IAsyncEnumerable QueryOptimizedAsync(Stream stream, CompiledMapping mapping, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - if (!mapping.IsUniversallyOptimized) - throw new InvalidOperationException("QueryUniversalAsync requires a universally optimized mapping"); + if (mapping.OptimizedCellGrid == null || mapping.OptimizedBoundaries == null) + throw new InvalidOperationException("QueryOptimizedAsync requires an optimized mapping"); var boundaries = mapping.OptimizedBoundaries!; var cellGrid = mapping.OptimizedCellGrid!; - var columnHandlers = mapping.OptimizedColumnHandlers!; - - var results = new List(); // Read the Excel file using OpenXmlReader using var reader = await OpenXmlReader.CreateAsync(stream, new OpenXmlConfiguration @@ -486,9 +46,8 @@ private static async Task> QueryUniversalAsync(Stream stream, Com currentRowIndex++; if (row == null) continue; var rowDict = row as IDictionary; - if (rowDict == null) continue; - - + + // Use our own row counter since OpenXmlReader doesn't provide row numbers int rowNumber = currentRowIndex; if (rowNumber < boundaries.MinRow) continue; @@ -514,7 +73,7 @@ private static async Task> QueryUniversalAsync(Stream stream, Com FinalizeCollections(currentItem, mapping, currentCollections); if (HasAnyData(currentItem, mapping)) { - results.Add(currentItem); + yield return currentItem; } } @@ -543,7 +102,7 @@ private static async Task> QueryUniversalAsync(Stream stream, Com continue; var handler = cellGrid[gridRow, relativeCol]; - ProcessCellValue(handler, kvp.Value, currentItem, currentCollections, gridRow); + ProcessCellValue(handler, kvp.Value, currentItem, currentCollections, mapping); } } @@ -552,13 +111,9 @@ private static async Task> QueryUniversalAsync(Stream stream, Com { FinalizeCollections(currentItem, mapping, currentCollections); - if (HasAnyData(currentItem, mapping)) { - results.Add(currentItem); - } - else - { + yield return currentItem; } } } @@ -579,8 +134,7 @@ private static async Task> QueryUniversalAsync(Stream stream, Com currentRowIndex++; if (row == null) continue; var rowDict = row as IDictionary; - if (rowDict == null) continue; - + int rowNumber = currentRowIndex; // Process properties for this row @@ -588,55 +142,13 @@ private static async Task> QueryUniversalAsync(Stream stream, Com { if (prop.CellRow == rowNumber) { - var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( - OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(prop.CellColumn, 1)); + var columnLetter = ReferenceHelper.GetCellLetter( + ReferenceHelper.ConvertCoordinatesToCell(prop.CellColumn, 1)); if (rowDict.TryGetValue(columnLetter, out var value) && value != null) { - // Apply type conversion if needed - if (prop.Setter != null) - { - var targetType = prop.PropertyType; - if (value.GetType() != targetType) - { - // Pre-compiled conversion logic - try - { - value = targetType switch - { - _ when targetType == typeof(string) => value.ToString(), - _ when targetType == typeof(int) => Convert.ToInt32(value), - _ when targetType == typeof(long) => Convert.ToInt64(value), - _ when targetType == typeof(decimal) => Convert.ToDecimal(value), - _ when targetType == typeof(double) => Convert.ToDouble(value), - _ when targetType == typeof(float) => Convert.ToSingle(value), - _ when targetType == typeof(bool) => Convert.ToBoolean(value), - _ when targetType == typeof(DateTime) => Convert.ToDateTime(value), - _ => Convert.ChangeType(value, targetType) - }; - } - catch - { - // Fallback to string parsing - var str = value.ToString(); - if (!string.IsNullOrEmpty(str)) - { - value = targetType switch - { - _ when targetType == typeof(int) && int.TryParse(str, out var i) => i, - _ when targetType == typeof(long) && long.TryParse(str, out var l) => l, - _ when targetType == typeof(decimal) && decimal.TryParse(str, out var d) => d, - _ when targetType == typeof(double) && double.TryParse(str, out var db) => db, - _ when targetType == typeof(float) && float.TryParse(str, out var f) => f, - _ when targetType == typeof(bool) && bool.TryParse(str, out var b) => b, - _ when targetType == typeof(DateTime) && DateTime.TryParse(str, out var dt) => dt, - _ => Convert.ChangeType(value, targetType) - }; - } - } - } - prop.Setter.Invoke(item, value); - } + // Trust the precompiled setter to handle conversion + prop.Setter?.Invoke(item, value); } } } @@ -644,7 +156,7 @@ private static async Task> QueryUniversalAsync(Stream stream, Com if (HasAnyData(item, mapping)) { - results.Add(item); + yield return item; } } else @@ -656,8 +168,7 @@ private static async Task> QueryUniversalAsync(Stream stream, Com currentRowIndex++; if (row == null) continue; var rowDict = row as IDictionary; - if (rowDict == null) continue; - + // Use our own row counter since OpenXmlReader doesn't provide row numbers int rowNumber = currentRowIndex; if (rowNumber < boundaries.MinRow) continue; @@ -672,102 +183,52 @@ private static async Task> QueryUniversalAsync(Stream stream, Com { // For table pattern (all on row 1), properties define columns // For cell-specific mapping, only read from the specific row - if (allOnRow1 || prop.CellRow == rowNumber) - { - var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( - OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(prop.CellColumn, 1)); - - if (rowDict.TryGetValue(columnLetter, out var value) && value != null) - { - // Apply type conversion if needed - if (prop.Setter != null) - { - var targetType = prop.PropertyType; - if (value.GetType() != targetType) - { - // Pre-compiled conversion logic - try - { - value = targetType switch - { - _ when targetType == typeof(string) => value.ToString(), - _ when targetType == typeof(int) => Convert.ToInt32(value), - _ when targetType == typeof(long) => Convert.ToInt64(value), - _ when targetType == typeof(decimal) => Convert.ToDecimal(value), - _ when targetType == typeof(double) => Convert.ToDouble(value), - _ when targetType == typeof(float) => Convert.ToSingle(value), - _ when targetType == typeof(bool) => Convert.ToBoolean(value), - _ when targetType == typeof(DateTime) => Convert.ToDateTime(value), - _ => Convert.ChangeType(value, targetType) - }; - } - catch - { - // Fallback to string parsing - var str = value.ToString(); - if (!string.IsNullOrEmpty(str)) - { - value = targetType switch - { - _ when targetType == typeof(int) && int.TryParse(str, out var i) => i, - _ when targetType == typeof(long) && long.TryParse(str, out var l) => l, - _ when targetType == typeof(decimal) && decimal.TryParse(str, out var d) => d, - _ when targetType == typeof(double) && double.TryParse(str, out var db) => db, - _ when targetType == typeof(float) && float.TryParse(str, out var f) => f, - _ when targetType == typeof(bool) && bool.TryParse(str, out var b) => b, - _ when targetType == typeof(DateTime) && DateTime.TryParse(str, out var dt) => dt, - _ => Convert.ChangeType(value, targetType) - }; - } - } - } - prop.Setter.Invoke(item, value); - } - } - } + if (!allOnRow1 && prop.CellRow != rowNumber) continue; + + var columnLetter = ReferenceHelper.GetCellLetter( + ReferenceHelper.ConvertCoordinatesToCell(prop.CellColumn, 1)); + + if (!rowDict.TryGetValue(columnLetter, out var value) || value == null) continue; + + // Trust the precompiled setter to handle conversion + if (prop.Setter == null) continue; + prop.Setter.Invoke(item, value); } if (HasAnyData(item, mapping)) { - results.Add(item); + yield return item; } } } } - - return results; } private static Dictionary InitializeCollections(CompiledMapping mapping) { var collections = new Dictionary(); - for (int i = 0; i < mapping.Collections.Count; i++) + // Use precompiled collection helpers if available + if (mapping.OptimizedCollectionHelpers != null) { - var collection = mapping.Collections[i]; - var itemType = collection.ItemType ?? typeof(object); - - // Check if this is a complex type with nested mapping - var nestedMapping = collection.Registry?.GetCompiledMapping(itemType); - if (nestedMapping != null && itemType != typeof(string) && !itemType.IsValueType && !itemType.IsPrimitive) - { - // Complex type - we'll build objects as we go - var listType = typeof(List<>).MakeGenericType(itemType); - collections[i] = (IList)Activator.CreateInstance(listType)!; - } - else + for (int i = 0; i < mapping.OptimizedCollectionHelpers.Count && i < mapping.Collections.Count; i++) { - // Simple type collection - var listType = typeof(List<>).MakeGenericType(itemType); - collections[i] = (IList)Activator.CreateInstance(listType)!; + 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, int relativeRow) + Dictionary? collections, CompiledMapping mapping) { switch (handler.Type) { @@ -777,34 +238,53 @@ private static void ProcessCellValue(OptimizedCellHandler handler, object value, break; case CellHandlerType.CollectionItem: - if (handler.CollectionIndex >= 0 && collections.ContainsKey(handler.CollectionIndex)) + if (handler.CollectionIndex >= 0 + && collections != null + && collections.TryGetValue(handler.CollectionIndex, out var collection)) { - var collection = collections[handler.CollectionIndex]; 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); - if (nestedMapping != null && itemType != typeof(string) && !itemType.IsValueType && !itemType.IsPrimitive) + // Use pre-compiled type metadata from the helper instead of runtime reflection + var typeHelper = mapping.OptimizedCollectionHelpers?[handler.CollectionIndex]; + if (nestedMapping != null && itemType != typeof(string) && typeHelper != null && !typeHelper.IsItemValueType && !typeHelper.IsItemPrimitive) { // Complex type - we need to build/update the object - ProcessComplexCollectionItem(collection, handler, value, itemType, nestedMapping); + ProcessComplexCollectionItem(collection, handler, value, mapping); } else { // Simple type - add directly while (collection.Count <= handler.CollectionItemOffset) { - // For value types, we need to add default value not null - var defaultValue = itemType.IsValueType ? Activator.CreateInstance(itemType) : null; + // Use precompiled default factory if available + object? defaultValue; + if (mapping.OptimizedCollectionHelpers != 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 == null || (value is string str && string.IsNullOrEmpty(str))) + if (value is string str && string.IsNullOrEmpty(str)) { // Don't add empty values to value type collections - if (!itemType.IsValueType) + // Use pre-compiled type metadata from the helper + var itemHelper = mapping.OptimizedCollectionHelpers?[handler.CollectionIndex]; + if (itemHelper != null && !itemHelper.IsItemValueType) { // Only set null if the collection has the item already if (handler.CollectionItemOffset < collection.Count) @@ -833,31 +313,68 @@ private static void ProcessCellValue(OptimizedCellHandler handler, object value, } private static void ProcessComplexCollectionItem(IList collection, OptimizedCellHandler handler, - object value, Type itemType, object nestedMapping) + object value, CompiledMapping mapping) { // Ensure the collection has enough items while (collection.Count <= handler.CollectionItemOffset) { - collection.Add(Activator.CreateInstance(itemType)); + // Use precompiled default factory + if (mapping.OptimizedCollectionHelpers == 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 == null) { - item = Activator.CreateInstance(itemType)!; + // Use precompiled factory for creating the item + if (mapping.OptimizedCollectionHelpers == 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; } // The ValueSetter must be pre-compiled during optimization if (handler.ValueSetter == null) { + // For nested mappings, we need to look up the pre-compiled setter + if (mapping.NestedMappings != null && + mapping.NestedMappings.TryGetValue(handler.CollectionIndex, out var nestedInfo)) + { + // Find the matching property setter in the nested mapping + var nestedProp = nestedInfo.Properties.FirstOrDefault(p => p.PropertyName == handler.PropertyName); + if (nestedProp?.Setter != null && item != null) + { + 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."); } // Use the pre-compiled setter with built-in type conversion - handler.ValueSetter(item, value); + if (item != null) + handler.ValueSetter(item, value); } private static void FinalizeCollections(T item, CompiledMapping mapping, Dictionary collections) @@ -867,13 +384,32 @@ private static void FinalizeCollections(T item, CompiledMapping mapping, Dict var collectionMapping = mapping.Collections[i]; if (collections.TryGetValue(i, out var list)) { - // Remove any trailing null or default values - var itemType = collectionMapping.ItemType ?? typeof(object); + // Get the default value using precompiled factory if available + object? defaultValue = null; + if (mapping.OptimizedCollectionHelpers != 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[list.Count - 1]; + var lastItem = list[^1]; + // Use pre-compiled type metadata from helper + var listHelper = mapping.OptimizedCollectionHelpers?[i]; bool isDefault = lastItem == null || - (itemType.IsValueType && lastItem.Equals(Activator.CreateInstance(itemType))); + (listHelper != null && listHelper.IsItemValueType && lastItem.Equals(defaultValue)); if (isDefault) { list.RemoveAt(list.Count - 1); @@ -889,14 +425,11 @@ private static void FinalizeCollections(T item, CompiledMapping mapping, Dict if (collectionMapping.Setter != null) { - // Get the property type to determine if we need array conversion - var propInfo = typeof(T).GetProperty(collectionMapping.PropertyName); - if (propInfo != null && propInfo.PropertyType.IsArray) + // Use precompiled collection helper to convert to final type + if (mapping.OptimizedCollectionHelpers != null && i < mapping.OptimizedCollectionHelpers.Count) { - var elementType = propInfo.PropertyType.GetElementType()!; - var array = Array.CreateInstance(elementType, list.Count); - list.CopyTo(array, 0); - finalValue = array; + var helper = mapping.OptimizedCollectionHelpers[i]; + finalValue = helper.Finalizer(list); } collectionMapping.Setter(item, finalValue); @@ -946,26 +479,6 @@ private static bool IsDefaultValue(object value) }; } - private static int ExtractRowNumber(IDictionary row) - { - // Try to get row number from metadata - if (row.TryGetValue("__rowIndex", out var rowIndex) && rowIndex is int index) - { - return index; - } - - // Fallback: parse from first cell reference - foreach (var key in row.Keys) - { - if (!key.StartsWith("__") && TryParseRowFromCellReference(key, out int rowNum)) - { - return rowNum; - } - } - - return 1; // Default to row 1 - } - private static bool TryParseColumnIndex(string columnLetter, out int columnIndex) { columnIndex = 0; @@ -982,21 +495,4 @@ private static bool TryParseColumnIndex(string columnLetter, out int columnIndex return columnIndex > 0; } - - private static bool TryParseRowFromCellReference(string cellRef, out int row) - { - row = 0; - if (string.IsNullOrEmpty(cellRef)) return false; - - // Find where letters end and numbers begin - int i = 0; - while (i < cellRef.Length && char.IsLetter(cellRef[i])) i++; - - if (i < cellRef.Length && int.TryParse(cellRef.Substring(i), out row)) - { - return row > 0; - } - - return false; - } } \ No newline at end of file diff --git a/src/MiniExcel.Core/Mapping/MappingWriter.cs b/src/MiniExcel.Core/Mapping/MappingWriter.cs index 0dbde4fc..27f349b0 100644 --- a/src/MiniExcel.Core/Mapping/MappingWriter.cs +++ b/src/MiniExcel.Core/Mapping/MappingWriter.cs @@ -1,21 +1,5 @@ -using MiniExcelLib.Core.Helpers; -using MiniExcelLib.Core.OpenXml.Utils; - namespace MiniExcelLib.Core.Mapping; -internal class WriterBoundaries -{ - public int MaxPropertyRow { get; set; } - public int MaxPropertyColumn { get; set; } -} - -internal class ItemOffset -{ - public int RowOffset { get; set; } - public int ColumnOffset { get; set; } -} - - internal static partial class MappingWriter { [CreateSyncVersion] @@ -28,729 +12,34 @@ public static async Task SaveAsAsync(Stream stream, IEnumerable value, if (mapping == null) throw new ArgumentNullException(nameof(mapping)); - // Use optimized universal writer if mapping is optimized - if (mapping.IsUniversallyOptimized) - { - return await SaveAsUniversalAsync(stream, value, mapping, cancellationToken).ConfigureAwait(false); - } - - // Legacy path for non-optimized mappings - // Convert mapped data to row-based format that OpenXmlWriter expects. - // This uses an optimized streaming approach that only buffers rows for the current item. - var mappedData = ConvertToMappedData(value, mapping); - - var configuration = new OpenXmlConfiguration { FastMode = true }; - var writer = await OpenXmlWriter - .CreateAsync(stream, mappedData, mapping.WorksheetName, false, configuration, cancellationToken) - .ConfigureAwait(false); - - return await writer.SaveAsAsync(cancellationToken).ConfigureAwait(false); + return await SaveAsOptimizedAsync(stream, value, mapping, cancellationToken).ConfigureAwait(false); } - private static IEnumerable> ConvertToMappedData(IEnumerable value, CompiledMapping mapping) - { - // Analyze mapping configuration to determine overlapping areas and boundaries - var boundaries = CalculateMappingBoundaries(mapping); - var allColumns = DetermineAllColumns(mapping); - var rowBuffer = new SortedDictionary>(); - - // Process all items with proper offsets based on boundaries - var itemIndex = 0; - foreach (var item in value) - { - ProcessItemData(item, mapping, rowBuffer, itemIndex, boundaries); - itemIndex++; - } - - // Yield all rows from 1 to maxRow to ensure proper row positioning - if (rowBuffer.Count > 0) - { - var maxRow = rowBuffer.Keys.Max(); - - for (int rowNum = 1; rowNum <= maxRow; rowNum++) - { - var rowDict = CreateRowDict(rowBuffer, rowNum, allColumns); - yield return rowDict; - - // Return dictionary to pool after yielding - if (rowBuffer.TryGetValue(rowNum, out var sourceDict)) - { - DictionaryPool.Return(sourceDict); - rowBuffer.Remove(rowNum); - } - } - } - } - - private static void ProcessItemData(T item, CompiledMapping mapping, SortedDictionary> rowBuffer, int itemIndex, WriterBoundaries boundaries) - { - if(item == null) - throw new ArgumentNullException(nameof(item)); - - // Calculate offset for this item based on boundaries and mapping type - var itemOffset = CalculateItemOffset(itemIndex, boundaries, mapping); - - // Add simple properties with offset - foreach (var prop in mapping.Properties) - { - var propValue = prop.Getter(item); - var cellValue = ConvertValue(propValue, prop); - var offsetCellAddress = ApplyOffset(prop.CellAddress, itemOffset.RowOffset, itemOffset.ColumnOffset); - AddCellToBuffer(rowBuffer, offsetCellAddress, cellValue); - } - - // Add collections with offset - foreach (var coll in mapping.Collections) - { - var collection = coll.Getter(item); - if (collection != null) - { - var offsetStartCell = ApplyOffset(coll.StartCell, itemOffset.RowOffset, itemOffset.ColumnOffset); - foreach (var cellInfo in StreamCollectionCellsWithOffset(collection, coll, offsetStartCell)) - { - AddCellToBuffer(rowBuffer, cellInfo.Key, cellInfo.Value); - } - } - } - } - - private static void AddCellToBuffer(SortedDictionary> rowBuffer, string cellAddress, object value) - { - if (!ReferenceHelper.ParseReference(cellAddress, out _, out int row)) - return; - - if (!rowBuffer.ContainsKey(row)) - rowBuffer[row] = DictionaryPool.Rent(); - - rowBuffer[row][cellAddress] = value; - } - - private static WriterBoundaries CalculateMappingBoundaries(CompiledMapping mapping) - { - var boundaries = new WriterBoundaries(); - - // Find the maximum row used by properties - foreach (var prop in mapping.Properties) - { - if (ReferenceHelper.ParseReference(prop.CellAddress, out int col, out int row)) - { - if (row > boundaries.MaxPropertyRow) - boundaries.MaxPropertyRow = row; - if (col > boundaries.MaxPropertyColumn) - boundaries.MaxPropertyColumn = col; - } - } - - // Calculate boundaries for collections - foreach (var coll in mapping.Collections) - { - if (ReferenceHelper.ParseReference(coll.StartCell, out _, out var startRow)) - { - // Also update MaxPropertyRow to include collection start rows - if (startRow > boundaries.MaxPropertyRow) - boundaries.MaxPropertyRow = startRow; - - // Collection layout information is handled during actual writing - } - } - - return boundaries; - } - - private static ItemOffset CalculateItemOffset(int itemIndex, WriterBoundaries boundaries, CompiledMapping mapping) - { - if (itemIndex == 0) - { - // First item uses original positions - return new ItemOffset { RowOffset = 0, ColumnOffset = 0 }; - } - - // For subsequent items, we need to offset based on the mapping layout - - // If all properties are on row 1 (A1, B1, C1...), it's a table pattern - items go in consecutive rows - // Otherwise, offset by the max property row + spacing for each item - var allOnRow1 = mapping.Properties.All(p => p.CellRow == 1); - var spacing = allOnRow1 ? 1 : (boundaries.MaxPropertyRow + 2); - var rowOffset = itemIndex * spacing; - - return new ItemOffset { RowOffset = rowOffset, ColumnOffset = 0 }; - } - - private static string ApplyOffset(string cellAddress, int rowOffset, int columnOffset) - { - if (!ReferenceHelper.ParseReference(cellAddress, out int col, out int row)) - return cellAddress; - - var newRow = row + rowOffset; - var newCol = col + columnOffset; - - return ReferenceHelper.ConvertCoordinatesToCell(newCol, newRow); - } - - private static Dictionary CreateRowDict(SortedDictionary> rowBuffer, int rowNum, List allColumns) - { - var rowDict = DictionaryPool.Rent(); - - if (rowBuffer.TryGetValue(rowNum, out var sourceRow)) - { - foreach (var column in allColumns) - { - object? cellValue = null; - foreach (var kvp in sourceRow) - { - if (ReferenceHelper.GetCellLetter(kvp.Key) == column) - { - cellValue = kvp.Value; - break; - } - } - rowDict[column] = cellValue ?? string.Empty; - } - } - else - { - // Empty row - foreach (var column in allColumns) - { - rowDict[column] = string.Empty; - } - } - - return rowDict; - } - - private static List DetermineAllColumns(CompiledMapping mapping) - { - var columns = new HashSet(); - - // Add columns from properties - foreach (var prop in mapping.Properties) - { - if (ReferenceHelper.ParseReference(prop.CellAddress, out int col, out _)) - { - var column = ReferenceHelper.GetCellLetter(ReferenceHelper.ConvertCoordinatesToCell(col, 1)); - if (!string.IsNullOrEmpty(column)) - columns.Add(column); - } - } - - // For collections, determine columns from mapping configuration without iterating data - foreach (var coll in mapping.Collections) - { - if (ReferenceHelper.ParseReference(coll.StartCell, out int startCol, out _)) - { - // Only support vertical collections - if (coll.Layout == CollectionLayout.Vertical) - { - // Vertical layout or nested collection - if (coll.ItemMapping != null) - { - // For nested mappings, get columns from the item mapping - var itemMappingType = coll.ItemMapping.GetType(); - var propsProperty = itemMappingType.GetProperty("Properties"); - - if (propsProperty?.GetValue(coll.ItemMapping) is IEnumerable properties) - { - foreach (var prop in properties) - { - if (ReferenceHelper.ParseReference(prop.CellAddress, out int propCol, out _)) - { - var propColumn = ReferenceHelper.GetCellLetter(ReferenceHelper.ConvertCoordinatesToCell(propCol, 1)); - if (!string.IsNullOrEmpty(propColumn)) - columns.Add(propColumn); - } - } - } - } - else - { - // Simple vertical collection - var col = ReferenceHelper.GetCellLetter(ReferenceHelper.ConvertCoordinatesToCell(startCol, 1)); - if (!string.IsNullOrEmpty(col)) - columns.Add(col); - } - } - } - } - - // Ensure all columns between min and max are included to prevent compression - if (columns.Count > 0) - { - var sortedColumns = columns.OrderBy(c => c).ToList(); - var minColumn = sortedColumns.First(); - var maxColumn = sortedColumns.Last(); - - // Convert column letters to numbers for easier range calculation - var minColNum = ReferenceHelper.ParseReference(minColumn + "1", out int minCol, out _) ? minCol : 1; - var maxColNum = ReferenceHelper.ParseReference(maxColumn + "1", out int maxCol, out _) ? maxCol : 1; - - // Add all columns in the range - var allColumnsInRange = new List(); - for (int col = minColNum; col <= maxColNum; col++) - { - var columnLetter = ReferenceHelper.GetCellLetter(ReferenceHelper.ConvertCoordinatesToCell(col, 1)); - if (!string.IsNullOrEmpty(columnLetter)) - { - allColumnsInRange.Add(columnLetter); - } - } - - return allColumnsInRange; - } - - return columns.OrderBy(c => c).ToList(); - } - - private static IEnumerable> StreamCollectionCellsWithOffset(IEnumerable collection, CompiledCollectionMapping mapping, string offsetStartCell) + [CreateSyncVersion] + private static async Task SaveAsOptimizedAsync(Stream stream, IEnumerable value, CompiledMapping mapping, CancellationToken cancellationToken = default) { - if (!ReferenceHelper.ParseReference(offsetStartCell, out int startColumn, out int startRow)) - throw new InvalidOperationException($"Invalid start cell address: {offsetStartCell}"); - - var currentRow = startRow; - var currentCol = startColumn; - var itemIndex = 0; - - // Process collection items one at a time without buffering - foreach (var item in collection) - { - if (mapping.ItemMapping != null && mapping.ItemType != null) - { - // Complex item with nested mapping - var itemMapping = mapping.ItemMapping; - var itemMappingType = itemMapping.GetType(); - var propsProperty = itemMappingType.GetProperty("Properties"); + if (mapping.OptimizedCellGrid == null || mapping.OptimizedBoundaries == null) + throw new InvalidOperationException("SaveAsOptimizedAsync requires an optimized mapping"); - if (propsProperty?.GetValue(itemMapping) is IEnumerable properties) - { - foreach (var prop in properties) - { - var propValue = prop.Getter(item); - var cellValue = ConvertValue(propValue, prop); - - // For nested mappings, we need to adjust the property's cell address relative to the collection item's position - if (!ReferenceHelper.ParseReference(prop.CellAddress, out int propCol, out int propRow)) - continue; - - // Only vertical layout is supported - var cellAddress = mapping.Layout == CollectionLayout.Vertical - ? ReferenceHelper.ConvertCoordinatesToCell(startColumn + propCol - 1, currentRow + propRow - 1) - : prop.CellAddress; - - yield return new KeyValuePair(cellAddress, cellValue); - } - } - } - else - { - // Simple item - just write the value - var cellAddress = CalculateCellPositionWithBase(offsetStartCell, currentRow, currentCol, itemIndex, mapping, startColumn, startRow); - yield return new KeyValuePair(cellAddress, ConvertValue(item, null)); - } - - // Update position for next item - UpdatePosition(ref currentRow, ref currentCol, ref itemIndex, mapping); - } - } - - private static string CalculateCellPositionWithBase(string baseCellAddress, int currentRow, int currentCol, int itemIndex, CompiledCollectionMapping mapping, int startColumn, int startRow) - { - // Only vertical layout is supported - return ReferenceHelper.ConvertCoordinatesToCell(startColumn, currentRow); - } - - private static void UpdatePosition(ref int currentRow, ref int currentCol, ref int itemIndex, CompiledCollectionMapping mapping) - { - itemIndex++; + var configuration = new OpenXmlConfiguration { FastMode = false }; - // Only vertical layout is supported - if (mapping.Layout == CollectionLayout.Vertical) + // 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++) { - currentRow += 1 + mapping.RowSpacing; + var cellRef = ReferenceHelper.ConvertCoordinatesToCell(boundaries.MinColumn + i, 1); + columnLetters[i] = ReferenceHelper.GetCellLetter(cellRef); } - } - - private static object ConvertValue(object? value, CompiledPropertyMapping? prop) - { - if (value == null) return string.Empty; - if (prop != null && !string.IsNullOrEmpty(prop.Format)) - { - if (value is IFormattable formattable) - return formattable.ToString(prop.Format, null); - } - - return value; - } - - // Universal optimized writer implementation - [CreateSyncVersion] - private static async Task SaveAsUniversalAsync(Stream stream, IEnumerable value, CompiledMapping mapping, CancellationToken cancellationToken = default) - { - if (!mapping.IsUniversallyOptimized) - throw new InvalidOperationException("SaveAsUniversalAsync requires a universally optimized mapping"); - - // Use optimized direct row streaming based on pre-calculated cell grid - var rowEnumerable = CreateOptimizedRows(value, mapping); + // Create cell stream instead of dictionary rows + var cellStream = new MappingCellStream(value, mapping, columnLetters); - var configuration = new OpenXmlConfiguration { FastMode = true }; + // Use the cell stream directly - it will be handled by the adapter var writer = await OpenXmlWriter - .CreateAsync(stream, rowEnumerable, mapping.WorksheetName, false, configuration, cancellationToken) + .CreateAsync(stream, cellStream, mapping.WorksheetName, false, configuration, cancellationToken) .ConfigureAwait(false); return await writer.SaveAsAsync(cancellationToken).ConfigureAwait(false); } - - private static IEnumerable> CreateOptimizedRows(IEnumerable items, CompiledMapping mapping) - { - var boundaries = mapping.OptimizedBoundaries!; - - // For simple mappings without collections, handle row positioning - if (!mapping.Collections.Any()) - { - // If data starts at row > 1, we need to write placeholder rows - if (boundaries.MinRow > 1) - { - // Write a single placeholder row with all columns - var placeholderRow = new Dictionary(); - for (int col = boundaries.MinColumn; col <= boundaries.MaxColumn; col++) - { - var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( - OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(col, 1)); - placeholderRow[columnLetter] = ""; - } - - // Write placeholder rows until we reach the data start row - for (int emptyRow = 1; emptyRow < boundaries.MinRow; emptyRow++) - { - yield return new Dictionary(placeholderRow); - } - } - - // Now write the actual data rows - var currentRow = boundaries.MinRow; - foreach (var item in items) - { - if (item == null) continue; - - // For column layouts (GridHeight > 1), we need to write multiple rows for each item - if (boundaries.GridHeight > 1) - { - // Write multiple rows for this item - one for each row in the grid - for (int gridRow = 0; gridRow < boundaries.GridHeight; gridRow++) - { - var absoluteRow = boundaries.MinRow + gridRow; - var row = CreateColumnLayoutRowForItem(item, absoluteRow, gridRow, mapping, boundaries); - if (row.Count > 0) - { - yield return row; - } - } - } - else - { - // Regular single-row layout - var row = CreateSimpleRowForItem(item, currentRow, mapping, boundaries); - if (row.Count > 0) - { - yield return row; - } - currentRow++; - } - } - } - else - { - // Stream complex mappings with collections without buffering - var cellGrid = mapping.OptimizedCellGrid!; - - // Stream rows without buffering the entire collection - foreach (var row in StreamOptimizedRowsWithCollections(items, mapping, cellGrid, boundaries)) - { - yield return row; - } - } - } - - private static IEnumerable> StreamOptimizedRowsWithCollections( - IEnumerable items, CompiledMapping mapping, OptimizedCellHandler[,] cellGrid, - OptimizedMappingBoundaries boundaries) - { - // Write placeholder rows if needed - if (boundaries.MinRow > 1) - { - var placeholderRow = new Dictionary(); - - // Find the maximum column that will have data - int maxDataCol = 0; - for (int relativeCol = 0; relativeCol < cellGrid.GetLength(1); relativeCol++) - { - for (int relativeRow = 0; relativeRow < cellGrid.GetLength(0); relativeRow++) - { - var handler = cellGrid[relativeRow, relativeCol]; - if (handler.Type != CellHandlerType.Empty) - { - maxDataCol = Math.Max(maxDataCol, relativeCol + boundaries.MinColumn); - } - } - } - - // Initialize all columns that will be used - for (int col = 1; col <= maxDataCol; col++) - { - var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( - OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(col, 1)); - placeholderRow[columnLetter] = ""; - } - - for (int emptyRow = 1; emptyRow < boundaries.MinRow; emptyRow++) - { - yield return new Dictionary(placeholderRow); - } - } - - // Now stream the actual data using pre-calculated boundaries - var itemEnumerator = items.GetEnumerator(); - if (!itemEnumerator.MoveNext()) yield break; - - var currentItem = itemEnumerator.Current; - var currentItemIndex = 0; - var currentRow = boundaries.MinRow; - var hasMoreItems = true; - - // Track active collection enumerators - var collectionEnumerators = new Dictionary(); - var collectionItems = new Dictionary(); - - while (hasMoreItems || collectionEnumerators.Count > 0) - { - var row = new Dictionary(); - - // Initialize all columns with empty values to ensure proper column structure - for (int col = boundaries.MinColumn; col <= boundaries.MaxColumn; col++) - { - var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( - OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(col, 1)); - row[columnLetter] = ""; - } - - // Process each column in the current row - for (int col = boundaries.MinColumn; col <= boundaries.MaxColumn; col++) - { - var relativeRow = currentRow - boundaries.MinRow; - var relativeCol = col - boundaries.MinColumn; - - if (relativeRow >= 0 && relativeRow < cellGrid.GetLength(0) && - relativeCol >= 0 && relativeCol < cellGrid.GetLength(1)) - { - var handler = cellGrid[relativeRow, relativeCol]; - - if (handler.Type == CellHandlerType.Property && currentItem != null) - { - // Simple property - extract value - if (handler.ValueExtractor != null) - { - var value = handler.ValueExtractor(currentItem, 0); - var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( - OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(col, 1)); - row[columnLetter] = value ?? ""; - } - } - else if (handler.Type == CellHandlerType.CollectionItem && currentItem != null) - { - // Collection item - check if we need to start/continue enumeration - var collIndex = handler.CollectionIndex; - - // Check if we're within collection boundaries - if (handler.BoundaryRow == -1 || currentRow < handler.BoundaryRow) - { - // Initialize enumerator if needed - if (!collectionEnumerators.ContainsKey(collIndex)) - { - var collection = handler.CollectionMapping?.Getter(currentItem); - if (collection != null) - { - var enumerator = collection.GetEnumerator(); - if (enumerator.MoveNext()) - { - collectionEnumerators[collIndex] = enumerator; - collectionItems[collIndex] = enumerator.Current; - } - } - } - - // Get current collection item - if (collectionItems.TryGetValue(collIndex, out var collItem)) - { - var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( - OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(col, 1)); - row[columnLetter] = collItem ?? ""; - } - } - } - } - } - - // Always yield rows to maintain proper spacing - yield return row; - - currentRow++; - - // Check if we need to advance collection enumerators - bool advancedAnyCollection = false; - foreach (var kvp in collectionEnumerators.ToList()) - { - var collIndex = kvp.Key; - var enumerator = kvp.Value; - - // Check if this collection should advance based on row spacing - // This is simplified - real logic would check the actual collection mapping - if (enumerator.MoveNext()) - { - collectionItems[collIndex] = enumerator.Current; - advancedAnyCollection = true; - } - else - { - // Collection exhausted - collectionEnumerators.Remove(collIndex); - collectionItems.Remove(collIndex); - } - } - - // If no collections advanced and we're past the pattern height, move to next item - if (!advancedAnyCollection && boundaries.PatternHeight > 0 && - (currentRow - boundaries.MinRow) >= boundaries.PatternHeight) - { - if (itemEnumerator.MoveNext()) - { - currentItem = itemEnumerator.Current; - currentItemIndex++; - // Clear collection enumerators for new item - collectionEnumerators.Clear(); - collectionItems.Clear(); - } - else - { - hasMoreItems = false; - currentItem = default(T); - } - } - } - } - - private static Dictionary CreateSimpleRowForItem(T item, int currentRow, CompiledMapping mapping, OptimizedMappingBoundaries boundaries) - { - var row = new Dictionary(); - - // Initialize all columns with empty values - for (int col = boundaries.MinColumn; col <= boundaries.MaxColumn; col++) - { - var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( - OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(col, 1)); - row[columnLetter] = string.Empty; - } - - // Fill in property values - foreach (var prop in mapping.Properties) - { - var value = prop.Getter(item); - if (value != null) - { - var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( - OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(prop.CellColumn, 1)); - - - // Apply formatting if specified - if (!string.IsNullOrEmpty(prop.Format) && value is IFormattable formattable) - { - value = formattable.ToString(prop.Format, null); - } - - row[columnLetter] = value; - } - } - - return row; - } - - private static Dictionary CreateColumnLayoutRowForItem(T item, int absoluteRow, int gridRow, CompiledMapping mapping, OptimizedMappingBoundaries boundaries) - { - var row = new Dictionary(); - - // Initialize all columns with empty values - start from column A to ensure proper column positioning - int startCol = Math.Min(1, boundaries.MinColumn); // Always include column A (column 1) - for (int col = startCol; col <= boundaries.MaxColumn; col++) - { - var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( - OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(col, 1)); - row[columnLetter] = string.Empty; - } - - // Only fill in the property value that belongs to this specific row - foreach (var prop in mapping.Properties) - { - // Check if this property belongs to the current row - if (prop.CellRow == absoluteRow) - { - var value = prop.Getter(item); - if (value != null) - { - var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( - OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(prop.CellColumn, 1)); - - - // Apply formatting if specified - if (!string.IsNullOrEmpty(prop.Format) && value is IFormattable formattable) - { - value = formattable.ToString(prop.Format, null); - } - - row[columnLetter] = value; - } - } - } - - return row; - } - - private static object? ExtractCollectionItemValueForItem(T item, OptimizedCellHandler cellHandler, - int absoluteRow, int absoluteCol, OptimizedMappingBoundaries boundaries) - { - if (cellHandler.CollectionMapping == null || item == null) - return null; - - var collectionMapping = cellHandler.CollectionMapping; - var collection = collectionMapping.Getter(item); - if (collection == null) return null; - - // Calculate the actual item index based on the absolute row - // This handles rows beyond our pre-calculated grid - int actualItemIndex = cellHandler.CollectionItemOffset; - - // For vertical collections with row spacing, calculate the actual index - if (collectionMapping.Layout == CollectionLayout.Vertical) - { - var rowsSinceStart = absoluteRow - collectionMapping.StartCellRow; - if (rowsSinceStart >= 0) - { - // Calculate which item this row belongs to based on row spacing - // This is O(1) - just arithmetic, no iteration - actualItemIndex = rowsSinceStart / (1 + collectionMapping.RowSpacing); - } - } - - // If we have a pre-compiled value extractor for nested properties, use it - if (cellHandler.ValueExtractor != null) - { - // The ValueExtractor was pre-compiled to extract the specific property from the nested object - return cellHandler.ValueExtractor(item, actualItemIndex); - } - - // Otherwise fall back to simple collection item extraction - var collectionItems = collection.Cast().ToArray(); - if (collectionItems.Length == 0) return null; - - // Return the collection item if index is valid - return actualItemIndex >= 0 && actualItemIndex < collectionItems.Length ? collectionItems[actualItemIndex] : null; - } } \ No newline at end of file diff --git a/src/MiniExcel.Core/Mapping/NestedMappingInfo.cs b/src/MiniExcel.Core/Mapping/NestedMappingInfo.cs new file mode 100644 index 00000000..5d1089bd --- /dev/null +++ b/src/MiniExcel.Core/Mapping/NestedMappingInfo.cs @@ -0,0 +1,54 @@ +namespace MiniExcelLib.Core.Mapping; + +/// +/// 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(); + + /// + /// 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!; +} \ No newline at end of file diff --git a/src/MiniExcel.Core/Mapping/OptimizedMappingExecutor.cs b/src/MiniExcel.Core/Mapping/OptimizedMappingExecutor.cs deleted file mode 100644 index ab4ea394..00000000 --- a/src/MiniExcel.Core/Mapping/OptimizedMappingExecutor.cs +++ /dev/null @@ -1,146 +0,0 @@ -using System.Runtime.CompilerServices; - -namespace MiniExcelLib.Core.Mapping; - -/// -/// Optimized executor that uses pre-calculated handler arrays for maximum performance. -/// Zero allocations, zero lookups, just direct array access. -/// -internal sealed class OptimizedMappingExecutor -{ - // Pre-calculated array of value extractors indexed by column (0-based) - private readonly Func[] _columnGetters; - - // Pre-calculated array of value setters indexed by column (0-based) - private readonly Action[] _columnSetters; - - // Column count for bounds checking - private readonly int _columnCount; - - // Minimum column number (1-based) for offset calculation - private readonly int _minColumn; - - public OptimizedMappingExecutor(CompiledMapping mapping) - { - if (mapping?.OptimizedBoundaries == null) - throw new ArgumentException("Mapping must be optimized"); - - var boundaries = mapping.OptimizedBoundaries; - _minColumn = boundaries.MinColumn; - _columnCount = boundaries.GridWidth; - - // Pre-allocate arrays - _columnGetters = new Func[_columnCount]; - _columnSetters = new Action[_columnCount]; - - // Build optimized getters and setters for each column - BuildOptimizedHandlers(mapping); - } - - private void BuildOptimizedHandlers(CompiledMapping mapping) - { - // Initialize all columns with no-op handlers - for (int i = 0; i < _columnCount; i++) - { - _columnGetters[i] = static (obj) => null; - _columnSetters[i] = static (obj, val) => { }; - } - - // Map properties to their column positions - foreach (var prop in mapping.Properties) - { - var columnIndex = prop.CellColumn - _minColumn; - if (columnIndex >= 0 && columnIndex < _columnCount) - { - // Create optimized getter that directly accesses the property - var getter = prop.Getter; - _columnGetters[columnIndex] = (T obj) => getter(obj); - - // Create optimized setter if available - var setter = prop.Setter; - if (setter != null) - { - _columnSetters[columnIndex] = (T obj, object? value) => setter(obj, value); - } - } - } - - // Pre-calculate collection element accessors - foreach (var collection in mapping.Collections) - { - PreCalculateCollectionAccessors(collection, mapping.OptimizedBoundaries!); - } - } - - private void PreCalculateCollectionAccessors(CompiledCollectionMapping collection, OptimizedMappingBoundaries boundaries) - { - var startCol = collection.StartCellColumn; - var startRow = collection.StartCellRow; - - // Only support vertical collections - if (collection.Layout == CollectionLayout.Vertical) - { - // For vertical, we'd handle differently based on row - // This is simplified - real implementation would consider rows - var colIndex = startCol - _minColumn; - if (colIndex >= 0 && colIndex < _columnCount) - { - var collectionGetter = collection.Getter; - _columnGetters[colIndex] = (T obj) => - { - var enumerable = collectionGetter(obj); - return enumerable?.Cast().FirstOrDefault(); - }; - } - } - } - - /// - /// Get value for a specific column - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public object? GetValue(T item, int column) - { - var index = column - _minColumn; - if (index >= 0 && index < _columnCount) - { - return _columnGetters[index](item); - } - return null; - } - - /// - /// Set value for a specific column - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void SetValue(T item, int column, object? value) - { - var index = column - _minColumn; - if (index >= 0 && index < _columnCount) - { - _columnSetters[index](item, value); - } - } - - /// - /// Create optimized row dictionary for OpenXmlWriter - /// - public Dictionary CreateRow(T item) - { - var row = new Dictionary(_columnCount); - - for (int i = 0; i < _columnCount; i++) - { - var value = _columnGetters[i](item); - if (value != null) - { - var column = i + _minColumn; - var columnLetter = OpenXml.Utils.ReferenceHelper.GetCellLetter( - OpenXml.Utils.ReferenceHelper.ConvertCoordinatesToCell(column, 1)); - row[columnLetter] = value; - } - } - - return row; - } -} \ No newline at end of file diff --git a/src/MiniExcel.Core/WriteAdapters/MappingCellStreamAdapter.cs b/src/MiniExcel.Core/WriteAdapters/MappingCellStreamAdapter.cs new file mode 100644 index 00000000..a8be8cd8 --- /dev/null +++ b/src/MiniExcel.Core/WriteAdapters/MappingCellStreamAdapter.cs @@ -0,0 +1,87 @@ +namespace MiniExcelLib.Core.WriteAdapters; + +internal class MappingCellStreamAdapter : IMiniExcelWriteAdapter +{ + 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..9e7572ec 100644 --- a/src/MiniExcel.Core/WriteAdapters/MiniExcelWriteAdapterFactory.cs +++ b/src/MiniExcel.Core/WriteAdapters/MiniExcelWriteAdapterFactory.cs @@ -25,6 +25,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/MiniExcelMappingCompilerTests.cs b/tests/MiniExcel.Core.Tests/MiniExcelMappingCompilerTests.cs index 0017ed7c..f6d47754 100644 --- a/tests/MiniExcel.Core.Tests/MiniExcelMappingCompilerTests.cs +++ b/tests/MiniExcel.Core.Tests/MiniExcelMappingCompilerTests.cs @@ -49,8 +49,7 @@ public void Sequential_Properties_Should_Be_Detected() // Act var mapping = registry.GetMapping(); - // Assert - verify universal optimization is applied - Assert.True(mapping.IsUniversallyOptimized); + // Assert - verify optimization is applied Assert.NotNull(mapping.OptimizedBoundaries); Assert.NotNull(mapping.OptimizedCellGrid); Assert.Equal(3, mapping.Properties.Count); @@ -77,7 +76,6 @@ public void NonSequential_Properties_Should_Use_Optimization() var mapping = registry.GetMapping(); // Assert - verify optimization is applied - Assert.True(mapping.IsUniversallyOptimized); Assert.NotNull(mapping.OptimizedBoundaries); Assert.NotNull(mapping.OptimizedCellGrid); } @@ -200,7 +198,6 @@ public void Multiple_Collections_Should_Be_Handled() // Assert Assert.Equal(2, mapping.Collections.Count); - Assert.True(mapping.IsUniversallyOptimized); } #endregion diff --git a/tests/MiniExcel.Core.Tests/MiniExcelMappingTests.cs b/tests/MiniExcel.Core.Tests/MiniExcelMappingTests.cs index f2dc3dc8..ba43e72b 100644 --- a/tests/MiniExcel.Core.Tests/MiniExcelMappingTests.cs +++ b/tests/MiniExcel.Core.Tests/MiniExcelMappingTests.cs @@ -149,8 +149,11 @@ public async Task MappingReader_ReadBasicData_Success() // Act var importer = MiniExcel.Importers.GetMappingImporter(registry); - var results = await importer.QueryAsync(stream); - var resultList = results.ToList(); + var resultList = new List(); + await foreach (var item in importer.QueryAsync(stream)) + { + resultList.Add(item); + } // Assert Assert.Single(resultList); @@ -265,8 +268,7 @@ public async Task Sequential_Mapping_Should_Optimize_Performance() var mapping = registry.GetMapping(); - // Verify universal optimization is applied - Assert.True(mapping.IsUniversallyOptimized); + // Verify optimization is applied Assert.NotNull(mapping.OptimizedBoundaries); Assert.NotNull(mapping.OptimizedCellGrid); } @@ -287,8 +289,7 @@ public async Task NonSequential_Mapping_Should_Use_Universal_Optimization() var mapping = registry.GetMapping(); - // Verify universal optimization is used - Assert.True(mapping.IsUniversallyOptimized); + // Verify optimization is used Assert.NotNull(mapping.OptimizedCellGrid); Assert.NotNull(mapping.OptimizedBoundaries); } @@ -962,8 +963,6 @@ public void Universal_Optimization_Should_Create_Cell_Grid() }); var mapping = registry.GetMapping(); - - Assert.True(mapping.IsUniversallyOptimized); Assert.NotNull(mapping.OptimizedCellGrid); Assert.NotNull(mapping.OptimizedBoundaries); From 71fe88cce89609aa015e33fb8ba52a161dd8e15b Mon Sep 17 00:00:00 2001 From: Corey Kaylor Date: Sun, 17 Aug 2025 11:31:13 -0500 Subject: [PATCH 04/16] Adding support for templates with mappings --- .../BenchmarkSections/QueryExcelBenchmark.cs | 31 + .../TemplateExcelBenchmark.cs | 40 + .../Mapping/MappingCellStream.cs | 62 +- src/MiniExcel.Core/Mapping/MappingCompiler.cs | 41 +- src/MiniExcel.Core/Mapping/MappingExporter.cs | 60 +- src/MiniExcel.Core/Mapping/MappingReader.cs | 103 +-- .../Mapping/MappingTemplateApplicator.cs | 156 ++++ .../Mapping/MappingTemplateProcessor.cs | 684 ++++++++++++++++++ src/MiniExcel.Core/OpenXml/MappedRow.cs | 36 + src/MiniExcel.Core/OpenXml/OpenXmlReader.cs | 141 ++++ .../OpenXml/Utils/ReferenceHelper.cs | 28 + .../MiniExcelMappingTemplateTests.cs | 386 ++++++++++ 12 files changed, 1660 insertions(+), 108 deletions(-) create mode 100644 src/MiniExcel.Core/Mapping/MappingTemplateApplicator.cs create mode 100644 src/MiniExcel.Core/Mapping/MappingTemplateProcessor.cs create mode 100644 src/MiniExcel.Core/OpenXml/MappedRow.cs create mode 100644 tests/MiniExcel.Core.Tests/MiniExcelMappingTemplateTests.cs diff --git a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/QueryExcelBenchmark.cs b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/QueryExcelBenchmark.cs index e0e18a30..553051e4 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.Mapping; 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..73d2f8b9 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.Mapping; namespace MiniExcelLib.Benchmarks.BenchmarkSections; public class TemplateExcelBenchmark : BenchmarkBase { private OpenXmlTemplater _templater; + private MappingExporter _mappingExporter; + 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"); + }); + _mappingExporter = MiniExcel.Exporters.GetMappingExporter(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" + }); + + _mappingExporter.ApplyTemplate(outputPath.FilePath, templatePath.FilePath, employees); + } } \ No newline at end of file diff --git a/src/MiniExcel.Core/Mapping/MappingCellStream.cs b/src/MiniExcel.Core/Mapping/MappingCellStream.cs index 6a11dfc4..766ca441 100644 --- a/src/MiniExcel.Core/Mapping/MappingCellStream.cs +++ b/src/MiniExcel.Core/Mapping/MappingCellStream.cs @@ -27,6 +27,7 @@ internal struct MappingCellEnumerator private readonly object _emptyCell; private int _maxCollectionRows; private int _currentCollectionRow; + private object?[][]? _currentCollectionArrays; public MappingCellEnumerator(IEnumerator itemEnumerator, CompiledMapping mapping, string[] columnLetters) { @@ -44,6 +45,7 @@ public MappingCellEnumerator(IEnumerator itemEnumerator, CompiledMapping m _emptyCell = string.Empty; _maxCollectionRows = 0; _currentCollectionRow = 0; + _currentCollectionArrays = null; Current = default; } @@ -99,24 +101,35 @@ public bool MoveNext() // Process current item's cells if (_currentItem != null) { - // Calculate max collection rows when we start processing an item - if (_currentColumnIndex == 0 && _currentCollectionRow == 0) + // Cache collections as arrays when we start processing an item + if (_currentColumnIndex == 0 && _currentCollectionRow == 0 && _mapping.Collections.Count > 0) { _maxCollectionRows = 0; - foreach (var coll in _mapping.Collections) + _currentCollectionArrays = new object?[_mapping.Collections.Count][]; + + for (var i = 0; i < _mapping.Collections.Count; i++) { + var coll = _mapping.Collections[i]; var collectionData = coll.Getter(_currentItem); if (collectionData != null) { - var count = 0; - foreach (var _ in collectionData) - count++; + // Convert to array once - this is the only enumeration + var items = new List(); + foreach (var item in collectionData) + { + items.Add(item); + } + _currentCollectionArrays[i] = items.ToArray(); // For vertical collections, we need rows from StartCellRow - var neededRows = coll.StartCellRow + count - _currentRowIndex; + var neededRows = coll.StartCellRow + items.Count - _currentRowIndex; if (neededRows > _maxCollectionRows) _maxCollectionRows = neededRows; } + else + { + _currentCollectionArrays[i] = []; + } } } @@ -142,15 +155,14 @@ public bool MoveNext() cellValue = formattable.ToString(prop.Format, null); } - if (cellValue == null) - cellValue = _emptyCell; + cellValue ??= _emptyCell; break; } } // Then check collections - if (cellValue == _emptyCell) + if (cellValue == _emptyCell && _currentCollectionArrays != null) { for (var collIndex = 0; collIndex < _mapping.Collections.Count; collIndex++) { @@ -161,19 +173,10 @@ public bool MoveNext() var collectionRowOffset = _currentRowIndex - coll.StartCellRow; if (collectionRowOffset >= 0) { - var collectionData = coll.Getter(_currentItem); - if (collectionData != null) + var collectionArray = _currentCollectionArrays[collIndex]; + if (collectionRowOffset < collectionArray.Length) { - var itemIndex = 0; - foreach (var item in collectionData) - { - if (itemIndex == collectionRowOffset) - { - cellValue = item ?? _emptyCell; - break; - } - itemIndex++; - } + cellValue = collectionArray[collectionRowOffset] ?? _emptyCell; } } break; @@ -223,16 +226,9 @@ public void Dispose() } } -internal readonly struct MappingCell +internal readonly struct MappingCell(string columnLetter, int rowIndex, object? value) { - public readonly string ColumnLetter; - public readonly int RowIndex; - public readonly object? Value; - - public MappingCell(string columnLetter, int rowIndex, object? value) - { - ColumnLetter = columnLetter; - RowIndex = rowIndex; - Value = 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/Mapping/MappingCompiler.cs b/src/MiniExcel.Core/Mapping/MappingCompiler.cs index 0f246ccd..866f7545 100644 --- a/src/MiniExcel.Core/Mapping/MappingCompiler.cs +++ b/src/MiniExcel.Core/Mapping/MappingCompiler.cs @@ -437,16 +437,23 @@ private static (int minRow, int maxRow, int minCol, int maxCol) CalculateCollect .ThenBy(x => x.Collection.StartCellColumn) .ToList(); - foreach (var item in sortedCollections) + for (int i = 0; i < sortedCollections.Count; i++) { - MarkCollectionCells(grid, item.Collection, item.Index, boundaries); + 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 collectionIndex, OptimizedMappingBoundaries boundaries, int? nextCollectionStartRow = null) { var startRow = collection.StartCellRow; var startCol = collection.StartCellColumn; @@ -456,13 +463,13 @@ private static void MarkCollectionCells(OptimizedCellHandler[,] grid, CompiledCo if (collection.Layout == CollectionLayout.Vertical) { // Mark vertical range - we'll handle dynamic expansion during runtime - MarkVerticalCollectionCells(grid, collection, collectionIndex, boundaries, startRow, startCol); + 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 collectionIndex, OptimizedMappingBoundaries boundaries, int startRow, int startCol, int? nextCollectionStartRow = null) { var relativeCol = startCol - boundaries.MinColumn; if (relativeCol < 0 || relativeCol >= grid.GetLength(1)) return; @@ -474,7 +481,7 @@ private static void MarkVerticalCollectionCells(OptimizedCellHandler[,] grid, Co if (nestedMapping != null && itemType != typeof(string) && !itemType.IsValueType && !itemType.IsPrimitive) { // Complex type with mapping - expand each item across multiple columns - MarkVerticalComplexCollectionCells(grid, collection, collectionIndex, boundaries, startRow, nestedMapping); + MarkVerticalComplexCollectionCells(grid, collection, collectionIndex, boundaries, startRow, nestedMapping, nextCollectionStartRow); } else { @@ -624,7 +631,7 @@ private static void PreCompileCollectionHelpers(CompiledMapping mapping) } private static void MarkVerticalComplexCollectionCells(OptimizedCellHandler[,] grid, CompiledCollectionMapping collection, - int collectionIndex, OptimizedMappingBoundaries boundaries, int startRow, object nestedMapping) + int collectionIndex, OptimizedMappingBoundaries boundaries, int startRow, object nestedMapping, int? nextCollectionStartRow = null) { // Extract pre-compiled nested mapping info without reflection var nestedInfo = ExtractNestedMappingInfo(nestedMapping, collection.ItemType ?? typeof(object)); @@ -635,11 +642,25 @@ private static void MarkVerticalComplexCollectionCells(OptimizedCellHandler[,] g var startRelativeRow = startRow - boundaries.MinRow; var rowSpacing = collection.RowSpacing; - for (int itemIndex = 0; itemIndex < 20; itemIndex++) // Conservative estimate of collection size + // 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 (nextCollectionStartRow.HasValue && absoluteRow >= nextCollectionStartRow.Value) + break; + foreach (var prop in nestedInfo.Properties) { var c = prop.ColumnIndex - boundaries.MinColumn; @@ -679,7 +700,7 @@ private static void MarkVerticalComplexCollectionCells(OptimizedCellHandler[,] g if (offset < list.Count && list[offset] != null) { // Extract the property from the nested object - return propertyGetter(list[offset]); + return propertyGetter(list[offset]!); } break; @@ -750,7 +771,7 @@ private static void MarkVerticalComplexCollectionCells(OptimizedCellHandler[,] g if (nameProperty == null || columnProperty == null || getterProperty == null) continue; var name = nameProperty.GetValue(prop) as string; - var column = (int)columnProperty.GetValue(prop); + 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; diff --git a/src/MiniExcel.Core/Mapping/MappingExporter.cs b/src/MiniExcel.Core/Mapping/MappingExporter.cs index 74f06328..26edc282 100644 --- a/src/MiniExcel.Core/Mapping/MappingExporter.cs +++ b/src/MiniExcel.Core/Mapping/MappingExporter.cs @@ -30,6 +30,62 @@ public async Task SaveAsAsync(Stream stream, IEnumerable values, Cancellat await MappingWriter.SaveAsAsync(stream, values, mapping, cancellationToken).ConfigureAwait(false); } - - public MappingRegistry Registry => _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 == 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 == null) + throw new ArgumentNullException(nameof(outputStream)); + if (templateStream == null) + throw new ArgumentNullException(nameof(templateStream)); + if (values == 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 == null) + throw new ArgumentNullException(nameof(outputStream)); + if (templateBytes == null) + throw new ArgumentNullException(nameof(templateBytes)); + if (values == 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/Mapping/MappingReader.cs b/src/MiniExcel.Core/Mapping/MappingReader.cs index b2a85e09..a1208342 100644 --- a/src/MiniExcel.Core/Mapping/MappingReader.cs +++ b/src/MiniExcel.Core/Mapping/MappingReader.cs @@ -23,11 +23,11 @@ private static async IAsyncEnumerable QueryOptimizedAsync(Stream stream, Comp var boundaries = mapping.OptimizedBoundaries!; var cellGrid = mapping.OptimizedCellGrid!; - // Read the Excel file using OpenXmlReader + // Read the Excel file using OpenXmlReader's direct mapping path using var reader = await OpenXmlReader.CreateAsync(stream, new OpenXmlConfiguration { FillMergedCells = false, - FastMode = true + FastMode = false }, cancellationToken).ConfigureAwait(false); // If we have collections, we need to handle multiple items with collections @@ -38,14 +38,11 @@ private static async IAsyncEnumerable QueryOptimizedAsync(Stream stream, Comp T? currentItem = null; Dictionary? currentCollections = null; - int currentItemIndex = -1; - - int currentRowIndex = 0; - await foreach (var row in reader.QueryAsync(false, mapping.WorksheetName, "A1", cancellationToken).ConfigureAwait(false)) + var currentItemIndex = -1; + + await foreach (var mappedRow in reader.QueryMappedAsync(mapping.WorksheetName, cancellationToken).ConfigureAwait(false)) { - currentRowIndex++; - if (row == null) continue; - var rowDict = row as IDictionary; + var currentRowIndex = mappedRow.RowIndex + 1; // Use our own row counter since OpenXmlReader doesn't provide row numbers @@ -89,32 +86,27 @@ private static async IAsyncEnumerable QueryOptimizedAsync(Stream stream, Comp if (gridRow < 0 || gridRow >= cellGrid.GetLength(0)) continue; // Process each cell in the row using the pre-calculated grid - foreach (var kvp in rowDict) + for (int col = boundaries.MinColumn; col <= boundaries.MaxColumn; col++) { - if (kvp.Key.StartsWith("__")) continue; // Skip metadata keys - - // Convert column letter to index - if (!TryParseColumnIndex(kvp.Key, out int columnIndex)) - continue; + var cellValue = mappedRow.GetCell(col - 1); // Convert to 0-based for MappedRow - var relativeCol = columnIndex - boundaries.MinColumn; + var relativeCol = col - boundaries.MinColumn; if (relativeCol < 0 || relativeCol >= cellGrid.GetLength(1)) continue; var handler = cellGrid[gridRow, relativeCol]; - ProcessCellValue(handler, kvp.Value, currentItem, currentCollections, mapping); + ProcessCellValue(handler, cellValue, currentItem, currentCollections, mapping); } } // Finalize the last item if we have one - if (currentItem != null && currentCollections != null) + if (currentItem == null || currentCollections == null) + yield break; + + FinalizeCollections(currentItem, mapping, currentCollections); + if (HasAnyData(currentItem, mapping)) { - FinalizeCollections(currentItem, mapping, currentCollections); - - if (HasAnyData(currentItem, mapping)) - { - yield return currentItem; - } + yield return currentItem; } } else @@ -127,13 +119,10 @@ private static async IAsyncEnumerable QueryOptimizedAsync(Stream stream, Comp { // Column layout mode - all rows form a single object var item = new T(); - int currentRowIndex = 0; - - await foreach (var row in reader.QueryAsync(false, mapping.WorksheetName, "A1", cancellationToken).ConfigureAwait(false)) + + await foreach (var mappedRow in reader.QueryMappedAsync(mapping.WorksheetName, cancellationToken).ConfigureAwait(false)) { - currentRowIndex++; - if (row == null) continue; - var rowDict = row as IDictionary; + var currentRowIndex = mappedRow.RowIndex + 1; int rowNumber = currentRowIndex; @@ -142,13 +131,12 @@ private static async IAsyncEnumerable QueryOptimizedAsync(Stream stream, Comp { if (prop.CellRow == rowNumber) { - var columnLetter = ReferenceHelper.GetCellLetter( - ReferenceHelper.ConvertCoordinatesToCell(prop.CellColumn, 1)); + var cellValue = mappedRow.GetCell(prop.CellColumn - 1); // Convert to 0-based - if (rowDict.TryGetValue(columnLetter, out var value) && value != null) + if (cellValue != null) { // Trust the precompiled setter to handle conversion - prop.Setter?.Invoke(item, value); + prop.Setter?.Invoke(item, cellValue); } } } @@ -162,12 +150,9 @@ private static async IAsyncEnumerable QueryOptimizedAsync(Stream stream, Comp else { // Row layout mode - each row is a separate item - int currentRowIndex = 0; - await foreach (var row in reader.QueryAsync(false, mapping.WorksheetName, "A1", cancellationToken).ConfigureAwait(false)) + await foreach (var mappedRow in reader.QueryMappedAsync(mapping.WorksheetName, cancellationToken).ConfigureAwait(false)) { - currentRowIndex++; - if (row == null) continue; - var rowDict = row as IDictionary; + var currentRowIndex = mappedRow.RowIndex + 1; // Use our own row counter since OpenXmlReader doesn't provide row numbers int rowNumber = currentRowIndex; @@ -185,14 +170,12 @@ private static async IAsyncEnumerable QueryOptimizedAsync(Stream stream, Comp // For cell-specific mapping, only read from the specific row if (!allOnRow1 && prop.CellRow != rowNumber) continue; - var columnLetter = ReferenceHelper.GetCellLetter( - ReferenceHelper.ConvertCoordinatesToCell(prop.CellColumn, 1)); - - if (!rowDict.TryGetValue(columnLetter, out var value) || value == null) continue; + var cellValue = mappedRow.GetCell(prop.CellColumn - 1); // Convert to 0-based + if (cellValue == null) continue; // Trust the precompiled setter to handle conversion if (prop.Setter == null) continue; - prop.Setter.Invoke(item, value); + prop.Setter.Invoke(item, cellValue); } if (HasAnyData(item, mapping)) @@ -230,6 +213,10 @@ private static Dictionary InitializeCollections(CompiledMapping m 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: @@ -438,6 +425,7 @@ private static void FinalizeCollections(T item, CompiledMapping mapping, Dict } } + private static bool HasAnyData(T item, CompiledMapping mapping) { // Check if any properties have non-default values @@ -454,9 +442,15 @@ private static bool HasAnyData(T item, CompiledMapping mapping) foreach (var coll in mapping.Collections) { var collection = coll.Getter(item); - if (collection != null && collection.Cast().Any()) + if (collection != null) { - return true; + // Avoid Cast().Any() which causes boxing + var enumerator = collection.GetEnumerator(); + using var disposableEnumerator = enumerator as IDisposable; + if (enumerator.MoveNext()) + { + return true; + } } } @@ -478,21 +472,4 @@ private static bool IsDefaultValue(object value) _ => false }; } - - private static bool TryParseColumnIndex(string columnLetter, out int columnIndex) - { - columnIndex = 0; - if (string.IsNullOrEmpty(columnLetter)) return false; - - // Convert column letter (A, B, AA, etc.) to index - columnLetter = columnLetter.ToUpperInvariant(); - for (int i = 0; i < columnLetter.Length; i++) - { - char c = columnLetter[i]; - if (c < 'A' || c > 'Z') return false; - columnIndex = columnIndex * 26 + (c - 'A' + 1); - } - - return columnIndex > 0; - } } \ No newline at end of file diff --git a/src/MiniExcel.Core/Mapping/MappingTemplateApplicator.cs b/src/MiniExcel.Core/Mapping/MappingTemplateApplicator.cs new file mode 100644 index 00000000..e577e9b1 --- /dev/null +++ b/src/MiniExcel.Core/Mapping/MappingTemplateApplicator.cs @@ -0,0 +1,156 @@ +namespace MiniExcelLib.Core.Mapping; + +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 == null) + throw new ArgumentNullException(nameof(outputStream)); + if (templateStream == null) + throw new ArgumentNullException(nameof(templateStream)); + if (values == null) + throw new ArgumentNullException(nameof(values)); + if (mapping == 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 == 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/Mapping/MappingTemplateProcessor.cs b/src/MiniExcel.Core/Mapping/MappingTemplateProcessor.cs new file mode 100644 index 00000000..467587b3 --- /dev/null +++ b/src/MiniExcel.Core/Mapping/MappingTemplateProcessor.cs @@ -0,0 +1,684 @@ +namespace MiniExcelLib.Core.Mapping; + +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 != 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 != null && + mapping.OptimizedBoundaries.IsMultiItemPattern && + mapping.OptimizedBoundaries.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 == "worksheet" || reader.LocalName == "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.NodeType == XmlNodeType.Element && reader.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 an optimized grid and this cell is within bounds + if (mapping.OptimizedCellGrid != null && mapping.OptimizedBoundaries != null) + { + var relRow = row - mapping.OptimizedBoundaries.MinRow; + var relCol = col - mapping.OptimizedBoundaries.MinColumn; + + + if (relRow >= 0 && relRow < mapping.OptimizedBoundaries.GridHeight && + relCol >= 0 && relCol < mapping.OptimizedBoundaries.GridWidth) + { + var handler = mapping.OptimizedCellGrid[relRow, relCol]; + + + if (handler.Type != CellHandlerType.Empty) + { + // Use the pre-calculated handler to extract the value + object? value = null; + bool skipCell = false; + + if (handler.Type == CellHandlerType.Property && handler.ValueExtractor != null) + { + value = currentItem != null ? handler.ValueExtractor(currentItem, 0) : null; + } + else if (handler.Type == CellHandlerType.CollectionItem && handler.ValueExtractor != null) + { + // For collections, the ValueExtractor is pre-configured with the right offset + // Just pass the parent object that contains the collection + value = currentItem != null ? handler.ValueExtractor(currentItem, 0) : null; + + // IMPORTANT: If collection item is null (beyond collection bounds), + // preserve template content instead of overwriting with null + if (value == null) + { + skipCell = true; + } + } + + + // Only write if we have a value to write + if (!skipCell) + { + await WriteMappedCellAsync(reader, writer, value).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.NodeType == XmlNodeType.EndElement && reader.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 == null || mapping.OptimizedBoundaries == 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 handler = mapping.OptimizedCellGrid[relRow, relCol]; + if (handler.Type != CellHandlerType.Empty) + { + hasMapping = true; + // Check if there's an actual value to write + if (handler.ValueExtractor != null && currentItem != null) + { + var value = handler.ValueExtractor(currentItem, 0); + if (value != 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.OptimizedCellGrid != null && mapping.OptimizedBoundaries != null) + { + var relRow = rowNumber - mapping.OptimizedBoundaries.MinRow; + + if (relRow >= 0 && relRow < mapping.OptimizedBoundaries.GridHeight) + { + for (int relCol = 0; relCol < mapping.OptimizedBoundaries.GridWidth; relCol++) + { + var handler = mapping.OptimizedCellGrid[relRow, relCol]; + if (handler.Type == CellHandlerType.Empty) continue; + + // Extract the value + object? value = null; + + if (handler.Type == CellHandlerType.Property && handler.ValueExtractor != null) + { + value = currentItem != null ? handler.ValueExtractor(currentItem, 0) : null; + } + else if (handler.Type == CellHandlerType.CollectionItem && handler.ValueExtractor != null) + { + value = currentItem != null ? handler.ValueExtractor(currentItem, 0) : null; + } + + if (value == null) continue; + + var actualCol = relCol + mapping.OptimizedBoundaries.MinColumn; + var cellRef = ReferenceHelper.ConvertCoordinatesToCell(actualCol, rowNumber); + await 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.OptimizedCellGrid != null && mapping.OptimizedBoundaries != null) + { + var relRow = rowNumber - mapping.OptimizedBoundaries.MinRow; + + if (relRow >= 0 && relRow < mapping.OptimizedBoundaries.GridHeight) + { + // Check each column in the grid for this row + for (int relCol = 0; relCol < mapping.OptimizedBoundaries.GridWidth; relCol++) + { + var actualCol = relCol + mapping.OptimizedBoundaries.MinColumn; + + // Skip if we already wrote this column + if (writtenColumns.Contains(actualCol)) + continue; + + var handler = mapping.OptimizedCellGrid[relRow, relCol]; + if (handler.Type == CellHandlerType.Empty) continue; + + // We have a mapping for this cell but it wasn't in the template + // Create a new cell for it + object? value = null; + + if (handler.Type == CellHandlerType.Property && handler.ValueExtractor != null) + { + value = currentItem != null ? handler.ValueExtractor(currentItem, 0) : null; + } + else if (handler.Type == CellHandlerType.CollectionItem && handler.ValueExtractor != null) + { + value = currentItem != null ? handler.ValueExtractor(currentItem, 0) : null; + } + + if (value != null) + { + // Create cell reference + var cellRef = ReferenceHelper.ConvertCoordinatesToCell(actualCol, rowNumber); + + + // Write the cell + await WriteNewCellAsync(writer, cellRef, value).ConfigureAwait(false); + } + } + } + } + } + + [CreateSyncVersion] + private async Task WriteNewCellAsync( + XmlWriter writer, + string cellRef, + object? value) + { + // Determine cell type and formatted value + var (cellValue, cellType) = 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 + if (cellType == "inlineStr" && !string.IsNullOrEmpty(cellValue)) + { + // Write inline string + await writer.WriteStartElementAsync("", "is", "").ConfigureAwait(false); + await writer.WriteStartElementAsync("", "t", "").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", "").ConfigureAwait(false); + await writer.WriteStringAsync(cellValue).ConfigureAwait(false); + await writer.WriteEndElementAsync().ConfigureAwait(false); // + } + + await writer.WriteEndElementAsync().ConfigureAwait(false); // + } + + [CreateSyncVersion] + private static async Task WriteMappedCellAsync( + XmlReader reader, + XmlWriter writer, + object? value) + { + // Determine cell type and formatted value + var (cellValue, cellType) = FormatCellValue(value); + + // Write cell start tag + await writer.WriteStartElementAsync(reader.Prefix, "c", reader.NamespaceURI).ConfigureAwait(false); + + // Copy attributes, potentially updating type + if (reader.HasAttributes) + { + while (reader.MoveToNextAttribute()) + { + if (reader.LocalName == "t") + { + // Write our type instead + if (!string.IsNullOrEmpty(cellType)) + { + await writer.WriteAttributeStringAsync("", "t", "", cellType).ConfigureAwait(false); + } + } + else if (reader.LocalName == "s") + { + // Skip style if we're writing inline string + if (cellType != "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(cellType) && reader.GetAttribute("t") == null) + { + await writer.WriteAttributeStringAsync("", "t", "", cellType).ConfigureAwait(false); + } + + // Write the value + if (cellType == "inlineStr" && !string.IsNullOrEmpty(cellValue)) + { + // Write inline string + await writer.WriteStartElementAsync("", "is", reader.NamespaceURI).ConfigureAwait(false); + await writer.WriteStartElementAsync("", "t", reader.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", reader.NamespaceURI).ConfigureAwait(false); + await writer.WriteStringAsync(cellValue).ConfigureAwait(false); + await writer.WriteEndElementAsync().ConfigureAwait(false); // + } + + // Skip original cell content + 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; + } + } + } + + await writer.WriteEndElementAsync().ConfigureAwait(false); // + } + + private static (string? value, string? type) FormatCellValue(object? value) + { + if (value == 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 - new DateTime(1899, 12, 30)).TotalDays; + return (excelDate.ToString(CultureInfo.InvariantCulture), null); + + case DateTimeOffset dto: + var excelDateOffset = (dto.DateTime - new DateTime(1899, 12, 30)).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"); + } + } + + [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/OpenXml/MappedRow.cs b/src/MiniExcel.Core/OpenXml/MappedRow.cs new file mode 100644 index 00000000..5b4872ed --- /dev/null +++ b/src/MiniExcel.Core/OpenXml/MappedRow.cs @@ -0,0 +1,36 @@ +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 == null) + return; + + // Lazy initialize cells array + if (_cells == null) + { + _cells = new object?[MaxColumns]; + } + + if (columnIndex >= 0 && columnIndex < MaxColumns) + { + _cells[columnIndex] = value; + } + } + + public object? GetCell(int columnIndex) + { + if (_cells == null || columnIndex < 0 || columnIndex >= MaxColumns) + return null; + + return _cells[columnIndex]; + } + + public bool HasData => _cells != 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..7c945ef0 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 != 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/tests/MiniExcel.Core.Tests/MiniExcelMappingTemplateTests.cs b/tests/MiniExcel.Core.Tests/MiniExcelMappingTemplateTests.cs new file mode 100644 index 00000000..5981cc7b --- /dev/null +++ b/tests/MiniExcel.Core.Tests/MiniExcelMappingTemplateTests.cs @@ -0,0 +1,386 @@ +using MiniExcelLib.Core.Mapping; +using MiniExcelLib.Tests.Common.Utils; + +namespace MiniExcelLib.Tests; + +public class MiniExcelMappingTemplateTests +{ + private readonly OpenXmlImporter _importer = MiniExcel.Importers.GetOpenXmlImporter(); + private readonly OpenXmlExporter _exporter = MiniExcel.Exporters.GetOpenXmlExporter(); + + private static DateTime ParseDateValue(object? value) + { + if (value is double serialDate) + return DateTime.FromOADate(serialDate); + if (value is DateTime dt) + return dt; + return DateTime.Parse(value?.ToString() ?? ""); + } + + public class TestEntity + { + public string Name { get; set; } = ""; + public DateTime CreateDate { get; set; } + public bool VIP { get; set; } + public int Points { get; set; } + } + + public class Department + { + public string Title { get; set; } = ""; + public List Managers { get; set; } = new(); + public List Employees { get; set; } = new(); + } + + public class Person + { + public string Name { get; set; } = ""; + public string Department { get; set; } = ""; + } + + [Fact] + public async Task BasicTemplateTest() + { + using var templatePath = AutoDeletingPath.Create(); + + var templateData = new[] + { + new { A = "Name", B = "Date", C = "VIP", D = "Points" }, + new { A = "", B = "", C = "", D = "" } // Empty row for data + }; + await _exporter.ExportAsync(templatePath.ToString(), templateData); + + var registry = new MappingRegistry(); + registry.Configure(config => + { + config.Property(x => x.Name).ToCell("A3"); + config.Property(x => x.CreateDate).ToCell("B3"); + config.Property(x => x.VIP).ToCell("C3"); + config.Property(x => x.Points).ToCell("D3"); + }); + + var data = new TestEntity + { + Name = "Jack", + CreateDate = new DateTime(2021, 01, 01), + VIP = true, + Points = 123 + }; + + using var outputPath = AutoDeletingPath.Create(); + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + await exporter.ApplyTemplateAsync(outputPath.ToString(), templatePath.ToString(), [data]); + + var rows = _importer.Query(outputPath.ToString(), useHeaderRow: false).ToList(); + + Assert.Equal(3, rows.Count); + + // Row 0 is column headers + Assert.Equal("A", rows[0].A); + Assert.Equal("B", rows[0].B); + Assert.Equal("C", rows[0].C); + Assert.Equal("D", rows[0].D); + + // Row 1 is our custom headers + Assert.Equal("Name", rows[1].A); + Assert.Equal("Date", rows[1].B); + Assert.Equal("VIP", rows[1].C); + Assert.Equal("Points", rows[1].D); + + // Row 2 is the data + Assert.Equal("Jack", rows[2].A); + Assert.Equal(new DateTime(2021, 01, 01), ParseDateValue(rows[2].B)); + Assert.Equal(true, rows[2].C); + Assert.Equal(123, rows[2].D); + } + + [Fact] + public async Task StreamOverloadTest() + { + using var templatePath = AutoDeletingPath.Create(); + var templateData = new[] + { + new { A = "Name", B = "Date", C = "VIP", D = "Points" }, + new { A = "", B = "", C = "", D = "" } + }; + await _exporter.ExportAsync(templatePath.ToString(), templateData); + + var registry = new MappingRegistry(); + registry.Configure(config => + { + config.Property(x => x.Name).ToCell("A3"); + config.Property(x => x.CreateDate).ToCell("B3"); + config.Property(x => x.VIP).ToCell("C3"); + config.Property(x => x.Points).ToCell("D3"); + }); + + var data = new TestEntity + { + Name = "Jack", + CreateDate = new DateTime(2021, 01, 01), + VIP = true, + Points = 123 + }; + + // Test stream overload + using var outputPath = AutoDeletingPath.Create(); + using (var outputStream = File.Create(outputPath.ToString())) + using (var templateStream = File.OpenRead(templatePath.ToString())) + { + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + await exporter.ApplyTemplateAsync(outputStream, templateStream, [data]); + } + + var rows = _importer.Query(outputPath.ToString(), useHeaderRow: false).ToList(); + Assert.Equal("Jack", rows[2].A); + } + + [Fact] + public async Task ByteArrayOverloadTest() + { + using var templatePath = AutoDeletingPath.Create(); + var templateData = new[] + { + new { A = "Name", B = "Date", C = "VIP", D = "Points" }, + new { A = "", B = "", C = "", D = "" } + }; + await _exporter.ExportAsync(templatePath.ToString(), templateData); + + var templateBytes = await File.ReadAllBytesAsync(templatePath.ToString()); + + var registry = new MappingRegistry(); + registry.Configure(config => + { + config.Property(x => x.Name).ToCell("A3"); + config.Property(x => x.CreateDate).ToCell("B3"); + config.Property(x => x.VIP).ToCell("C3"); + config.Property(x => x.Points).ToCell("D3"); + }); + + var data = new TestEntity + { + Name = "Jack", + CreateDate = new DateTime(2021, 01, 01), + VIP = true, + Points = 123 + }; + + using var outputPath = AutoDeletingPath.Create(); + using (var outputStream = File.Create(outputPath.ToString())) + { + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + await exporter.ApplyTemplateAsync(outputStream, templateBytes, [data]); + } + + var rows = _importer.Query(outputPath.ToString(), useHeaderRow: false).ToList(); + Assert.Equal("Jack", rows[2].A); + } + + [Fact] + public async Task CollectionTemplateTest() + { + using var templatePath = AutoDeletingPath.Create(); + var templateData = new List + { + new { A = "Company", B = "", C = "" }, + new { A = "", B = "", C = "" }, // Row 2 + new { A = "Managers", B = "Department", C = "" } // Row 3 + }; + + for (int i = 0; i < 3; i++) + { + templateData.Add(new { A = "", B = "", C = "" }); + } + + templateData.Add(new { A = "Employees", B = "Department", C = "" }); // Row 7 + + for (int i = 0; i < 3; i++) + { + templateData.Add(new { A = "", B = "", C = "" }); + } + + // Saving our actual template first + await _exporter.ExportAsync(templatePath.ToString(), templateData); + + var registry = new MappingRegistry(); + + registry.Configure(config => + { + config.Property(x => x.Name).ToCell("A1"); + config.Property(x => x.Department).ToCell("B1"); + }); + + registry.Configure(config => + { + config.Property(x => x.Title).ToCell("A2"); + config.Collection(x => x.Managers).StartAt("A5"); + config.Collection(x => x.Employees).StartAt("A9"); + }); + + var dept = new Department + { + Title = "FooCompany", + Managers = + [ + new Person { Name = "Jack", Department = "HR" }, + new Person { Name = "Jane", Department = "IT" } + ], + Employees = + [ + new Person { Name = "Wade", Department = "HR" }, + new Person { Name = "John", Department = "Sales" } + ] + }; + + using var outputPath = AutoDeletingPath.Create(); + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + await exporter.ApplyTemplateAsync(outputPath.ToString(), templatePath.ToString(), [dept]); + + var rows = _importer.Query(outputPath.ToString(), useHeaderRow: false).ToList(); + + + Assert.Equal(11, rows.Count); // We expect 11 rows total + + Assert.Equal("FooCompany", rows[1].A); + + Assert.Equal("Managers", rows[3].A); + Assert.Equal("Department", rows[3].B); + + Assert.Equal("Jack", rows[4].A); + Assert.Equal("HR", rows[4].B); + Assert.Equal("Jane", rows[5].A); + Assert.Equal("IT", rows[5].B); + + Assert.Equal("Employees", rows[7].A); + Assert.Equal("Department", rows[7].B); + + Assert.Equal("Wade", rows[8].A); + Assert.Equal("HR", rows[8].B); + Assert.Equal("John", rows[9].A); + Assert.Equal("Sales", rows[9].B); + } + + [Fact] + public async Task EmptyDataTest() + { + using var templatePath = AutoDeletingPath.Create(); + var templateData = new[] + { + new { A = "Name", B = "Date", C = "VIP", D = "Points" }, + new { A = "", B = "", C = "", D = "" } + }; + await _exporter.ExportAsync(templatePath.ToString(), templateData); + + var registry = new MappingRegistry(); + registry.Configure(config => + { + config.Property(x => x.Name).ToCell("A3"); + config.Property(x => x.CreateDate).ToCell("B3"); + config.Property(x => x.VIP).ToCell("C3"); + config.Property(x => x.Points).ToCell("D3"); + }); + + using var outputPath = AutoDeletingPath.Create(); + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + await exporter.ApplyTemplateAsync(outputPath.ToString(), templatePath.ToString(), Array.Empty()); + + var rows = _importer.Query(outputPath.ToString(), useHeaderRow: false).ToList(); + Assert.Equal(3, rows.Count); // Column headers + our headers + empty data row + Assert.Equal("Name", rows[1].A); + Assert.Equal("Date", rows[1].B); + + // Third row should be empty + Assert.True(string.IsNullOrEmpty(rows[2].A?.ToString())); + } + + [Fact] + public async Task NullValuesTest() + { + // Create template + using var templatePath = AutoDeletingPath.Create(); + var templateData = new[] + { + new { A = "Name", B = "Date", C = "VIP", D = "Points" }, + new { A = "Default", B = "2020-01-01", C = "false", D = "0" } + }; + await _exporter.ExportAsync(templatePath.ToString(), templateData); + + // Setup mapping + var registry = new MappingRegistry(); + registry.Configure(config => + { + config.Property(x => x.Name).ToCell("A3"); + config.Property(x => x.CreateDate).ToCell("B3"); + config.Property(x => x.VIP).ToCell("C3"); + config.Property(x => x.Points).ToCell("D3"); + }); + + var data = new TestEntity + { + Name = null!, // Null value + CreateDate = new DateTime(2021, 01, 01), + VIP = false, + Points = 0 + }; + + // Apply template + using var outputPath = AutoDeletingPath.Create(); + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + await exporter.ApplyTemplateAsync(outputPath.ToString(), templatePath.ToString(), [data]); + + // Verify null handling + // Verify - use useHeaderRow=false since we want to see all rows + var rows = _importer.Query(outputPath.ToString(), useHeaderRow: false).ToList(); + Assert.True(string.IsNullOrEmpty(rows[2].A?.ToString())); // Null replaced the default + Assert.Equal(new DateTime(2021, 01, 01), ParseDateValue(rows[2].B)); + Assert.Equal(false, rows[2].C); + Assert.Equal(0, rows[2].D); + } + + [Fact] + public async Task MultipleItemsTest() + { + // Create template with space for multiple items + using var templatePath = AutoDeletingPath.Create(); + var templateData = new[] + { + new { A = "Name", B = "Date", C = "VIP", D = "Points" }, + new { A = "", B = "", C = "", D = "" }, + new { A = "", B = "", C = "", D = "" }, + new { A = "", B = "", C = "", D = "" } + }; + await _exporter.ExportAsync(templatePath.ToString(), templateData); + + // Setup mapping for multiple rows + var registry = new MappingRegistry(); + registry.Configure(config => + { + config.Property(x => x.Name).ToCell("A3"); + config.Property(x => x.CreateDate).ToCell("B3"); + config.Property(x => x.VIP).ToCell("C3"); + config.Property(x => x.Points).ToCell("D3"); + }); + + var data = new[] + { + new TestEntity { Name = "Jack", CreateDate = new DateTime(2021, 01, 01), VIP = true, Points = 123 }, + new TestEntity { Name = "Jane", CreateDate = new DateTime(2021, 01, 02), VIP = false, Points = 456 }, + new TestEntity { Name = "John", CreateDate = new DateTime(2021, 01, 03), VIP = true, Points = 789 } + }; + + // Apply template + using var outputPath = AutoDeletingPath.Create(); + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + await exporter.ApplyTemplateAsync(outputPath.ToString(), templatePath.ToString(), data); + + // Verify - should only update first item since mapping is for specific cells + // Verify - use useHeaderRow=false since we want to see all rows + var rows = _importer.Query(outputPath.ToString(), useHeaderRow: false).ToList(); + Assert.Equal("Jack", rows[2].A); + Assert.Equal(123, rows[2].D); + + // Other rows should remain empty as we mapped to specific cells (A3, B3, etc.) + Assert.True(string.IsNullOrEmpty(rows[3].A?.ToString())); + Assert.True(string.IsNullOrEmpty(rows[4].A?.ToString())); + } +} \ No newline at end of file From 9a14b1656ac88c45a98a0489ea59d57ab6e088be Mon Sep 17 00:00:00 2001 From: Corey Kaylor Date: Mon, 18 Aug 2025 11:29:53 -0500 Subject: [PATCH 05/16] Consolidating some of the grid logic --- src/MiniExcel.Core/Helpers/CellFormatter.cs | 62 ++++ .../Helpers/CollectionAccessor.cs | 82 +++++ .../Helpers/ConversionHelper.cs | 95 +++++ .../Helpers/MappingMetadataExtractor.cs | 106 ++++++ src/MiniExcel.Core/Helpers/XmlCellWriter.cs | 176 ++++++++++ src/MiniExcel.Core/Mapping/CompiledMapping.cs | 133 ++++++- .../Mapping/MappingCellStream.cs | 32 +- src/MiniExcel.Core/Mapping/MappingCompiler.cs | 228 ++---------- src/MiniExcel.Core/Mapping/MappingExporter.cs | 1 + src/MiniExcel.Core/Mapping/MappingReader.cs | 133 ++++--- .../Mapping/MappingTemplateProcessor.cs | 327 +++--------------- src/MiniExcel.Core/Mapping/MappingWriter.cs | 1 + .../WriteAdapters/MappingCellStreamAdapter.cs | 1 + 13 files changed, 805 insertions(+), 572 deletions(-) create mode 100644 src/MiniExcel.Core/Helpers/CellFormatter.cs create mode 100644 src/MiniExcel.Core/Helpers/CollectionAccessor.cs create mode 100644 src/MiniExcel.Core/Helpers/MappingMetadataExtractor.cs create mode 100644 src/MiniExcel.Core/Helpers/XmlCellWriter.cs diff --git a/src/MiniExcel.Core/Helpers/CellFormatter.cs b/src/MiniExcel.Core/Helpers/CellFormatter.cs new file mode 100644 index 00000000..03fb97de --- /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 == 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..a6d9d280 --- /dev/null +++ b/src/MiniExcel.Core/Helpers/CollectionAccessor.cs @@ -0,0 +1,82 @@ +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) + { + return () => itemType.IsValueType ? Activator.CreateInstance(itemType) : null; + } + + /// + /// 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 index 8549c50b..9558586a 100644 --- a/src/MiniExcel.Core/Helpers/ConversionHelper.cs +++ b/src/MiniExcel.Core/Helpers/ConversionHelper.cs @@ -182,6 +182,101 @@ internal static class ConversionHelper } } + /// + /// 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 != 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) /// diff --git a/src/MiniExcel.Core/Helpers/MappingMetadataExtractor.cs b/src/MiniExcel.Core/Helpers/MappingMetadataExtractor.cs new file mode 100644 index 00000000..5728286c --- /dev/null +++ b/src/MiniExcel.Core/Helpers/MappingMetadataExtractor.cs @@ -0,0 +1,106 @@ +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 +{ + /// + /// 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 == null) return null; + + var properties = propsProperty.GetValue(nestedMapping) as IEnumerable; + if (properties == null) return null; + + var nestedInfo = new NestedMappingInfo + { + ItemType = itemType, + ItemFactory = CollectionAccessor.CreateItemFactory(itemType) + }; + + var propertyList = ExtractPropertyList(properties); + nestedInfo.Properties = propertyList; + + 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) + { + 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 == null || columnProperty == null || getterProperty == 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 (name != null && getter != null) + { + propertyList.Add(new NestedPropertyInfo + { + PropertyName = name, + ColumnIndex = column, + Getter = getter, + Setter = setter ?? ((_, _) => { }), + PropertyType = propTypeValue ?? typeof(object) + }); + } + } + + return propertyList; + } + + /// + /// 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..02059b05 --- /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") == 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/Mapping/CompiledMapping.cs b/src/MiniExcel.Core/Mapping/CompiledMapping.cs index db0beda4..45d52db3 100644 --- a/src/MiniExcel.Core/Mapping/CompiledMapping.cs +++ b/src/MiniExcel.Core/Mapping/CompiledMapping.cs @@ -32,6 +32,137 @@ internal class CompiledMapping /// 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 == null || OptimizedBoundaries == 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 == null || handler.ValueExtractor == 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 == null || handler.ValueSetter == 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; + + if (!TryGetHandler(row, col, out var handler)) + return false; + + return 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 + { + if (!TryGetHandler(row, col, out var handler)) + return false; + + return 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 == 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 == null) + return false; + + collection.Setter(item, value); + return true; + } } /// @@ -69,7 +200,7 @@ internal class CompiledCollectionMapping 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; } = 0; + public int RowSpacing { get; set; } public Type? ItemType { get; set; } public string PropertyName { get; set; } = null!; public Action? Setter { get; set; } diff --git a/src/MiniExcel.Core/Mapping/MappingCellStream.cs b/src/MiniExcel.Core/Mapping/MappingCellStream.cs index 766ca441..45657e33 100644 --- a/src/MiniExcel.Core/Mapping/MappingCellStream.cs +++ b/src/MiniExcel.Core/Mapping/MappingCellStream.cs @@ -3,6 +3,7 @@ namespace MiniExcelLib.Core.Mapping; internal readonly struct MappingCellStream(IEnumerable items, CompiledMapping mapping, string[] columnLetters) : IMappingCellStream + where T : class { public MappingCellEnumerator GetEnumerator() => new(items.GetEnumerator(), mapping, columnLetters); @@ -12,6 +13,7 @@ public IMiniExcelWriteAdapter CreateAdapter() } internal struct MappingCellEnumerator + where T : class { private readonly IEnumerator _itemEnumerator; private readonly CompiledMapping _mapping; @@ -141,29 +143,23 @@ public bool MoveNext() object? cellValue = _emptyCell; - // First check properties - for (var index = 0; index < _mapping.Properties.Count; index++) + // Use the optimized grid for fast lookup + if (_mapping.TryGetCellValue(_currentRowIndex, columnNumber, _currentItem, out var value)) { - var prop = _mapping.Properties[index]; - if (prop.CellColumn == columnNumber && prop.CellRow == _currentRowIndex) + cellValue = value ?? _emptyCell; + + // Apply formatting if needed + if (value is IFormattable formattable && + _mapping.TryGetHandler(_currentRowIndex, columnNumber, out var handler) && + !string.IsNullOrEmpty(handler.Format)) { - cellValue = prop.Getter(_currentItem); - - // Apply formatting if specified - if (!string.IsNullOrEmpty(prop.Format) && cellValue is IFormattable formattable) - { - cellValue = formattable.ToString(prop.Format, null); - } - - cellValue ??= _emptyCell; - - break; + cellValue = formattable.ToString(handler.Format, null); } } - - // Then check collections - if (cellValue == _emptyCell && _currentCollectionArrays != null) + else if (_currentCollectionArrays != null) { + // Fallback for collections that might not be in the grid yet + // This handles dynamic collection expansion for (var collIndex = 0; collIndex < _mapping.Collections.Count; collIndex++) { var coll = _mapping.Collections[collIndex]; diff --git a/src/MiniExcel.Core/Mapping/MappingCompiler.cs b/src/MiniExcel.Core/Mapping/MappingCompiler.cs index 866f7545..119cb93d 100644 --- a/src/MiniExcel.Core/Mapping/MappingCompiler.cs +++ b/src/MiniExcel.Core/Mapping/MappingCompiler.cs @@ -42,73 +42,11 @@ public static CompiledMapping Compile(MappingConfiguration configuratio var lambda = Expression.Lambda>(convertToObject, parameter); var compiled = lambda.Compile(); - // Create setter with proper type conversion + // Create setter with proper type conversion using centralized logic Action? setter = null; if (prop.Expression.Body is MemberExpression memberExpr && memberExpr.Member is PropertyInfo propInfo) { - var setterParam = Expression.Parameter(typeof(object), "obj"); - var valueParam = Expression.Parameter(typeof(object), "value"); - var castObj = Expression.Convert(setterParam, typeof(T)); - - // Build conversion expression based on target type - Expression convertedValue; - if (prop.PropertyType == typeof(int)) - { - // For int properties, handle double -> int conversion from Excel - var convertMethod = typeof(Convert).GetMethod("ToInt32", new[] { typeof(object) }); - convertedValue = Expression.Call(convertMethod!, valueParam); - } - else if (prop.PropertyType == typeof(decimal)) - { - // For decimal properties - var convertMethod = typeof(Convert).GetMethod("ToDecimal", new[] { typeof(object) }); - convertedValue = Expression.Call(convertMethod!, valueParam); - } - else if (prop.PropertyType == typeof(long)) - { - // For long properties - var convertMethod = typeof(Convert).GetMethod("ToInt64", new[] { typeof(object) }); - convertedValue = Expression.Call(convertMethod!, valueParam); - } - else if (prop.PropertyType == typeof(float)) - { - // For float properties - var convertMethod = typeof(Convert).GetMethod("ToSingle", new[] { typeof(object) }); - convertedValue = Expression.Call(convertMethod!, valueParam); - } - else if (prop.PropertyType == typeof(double)) - { - // For double properties - var convertMethod = typeof(Convert).GetMethod("ToDouble", new[] { typeof(object) }); - convertedValue = Expression.Call(convertMethod!, valueParam); - } - else if (prop.PropertyType == typeof(DateTime)) - { - // For DateTime properties - var convertMethod = typeof(Convert).GetMethod("ToDateTime", new[] { typeof(object) }); - convertedValue = Expression.Call(convertMethod!, valueParam); - } - else if (prop.PropertyType == typeof(bool)) - { - // For bool properties - var convertMethod = typeof(Convert).GetMethod("ToBoolean", new[] { typeof(object) }); - convertedValue = Expression.Call(convertMethod!, valueParam); - } - else if (prop.PropertyType == typeof(string)) - { - // For string properties - var convertMethod = typeof(Convert).GetMethod("ToString", new[] { typeof(object) }); - convertedValue = Expression.Call(convertMethod!, valueParam); - } - else - { - // Default: direct cast for other types - convertedValue = Expression.Convert(valueParam, prop.PropertyType); - } - - var assign = Expression.Assign(Expression.Property(castObj, propInfo), convertedValue); - var setterLambda = Expression.Lambda>(assign, setterParam, valueParam); - setter = setterLambda.Compile(); + setter = ConversionHelper.CreateTypedPropertySetter(propInfo); } // Pre-parse cell coordinates for runtime performance @@ -146,34 +84,16 @@ public static CompiledMapping Compile(MappingConfiguration configuratio // Extract property name from expression var collectionPropertyName = GetPropertyName(coll.Expression); - // Determine the item type + // Determine the item type using centralized logic var collectionType = coll.PropertyType; - Type? itemType = null; - - if (collectionType.IsArray) - { - itemType = collectionType.GetElementType(); - } - else if (collectionType.IsGenericType) - { - var genericArgs = collectionType.GetGenericArguments(); - if (genericArgs.Length > 0) - { - itemType = genericArgs[0]; - } - } + Type? itemType = CollectionAccessor.GetItemType(collectionType); // Create setter for collection Action? collectionSetter = null; if (coll.Expression.Body is MemberExpression collMemberExpr && collMemberExpr.Member is PropertyInfo collPropInfo) { - var setterParam = Expression.Parameter(typeof(object), "obj"); - var valueParam = Expression.Parameter(typeof(object), "value"); - var castObj = Expression.Convert(setterParam, typeof(T)); - var castValue = Expression.Convert(valueParam, collPropInfo.PropertyType); - var assign = Expression.Assign(Expression.Property(castObj, collPropInfo), castValue); - var setterLambda = Expression.Lambda>(assign, setterParam, valueParam); - collectionSetter = setterLambda.Compile(); + var memberSetter = new MemberSetter(collPropInfo); + collectionSetter = memberSetter.Invoke; } // Pre-parse start cell coordinates @@ -371,12 +291,11 @@ private static (int minRow, int maxRow, int minCol, int maxCol) CalculateCollect return (startRow, startRow + verticalHeight, startCol, maxCol); var nestedMapping = collection.Registry.GetCompiledMapping(collection.ItemType); - if (nestedMapping == null || collection.ItemType == typeof(string) || - collection.ItemType.IsValueType || - collection.ItemType.IsPrimitive) return (startRow, startRow + verticalHeight, startCol, maxCol); + if (nestedMapping == null || !MappingMetadataExtractor.IsComplexType(collection.ItemType)) + return (startRow, startRow + verticalHeight, startCol, maxCol); // Extract nested mapping info to get max column - var nestedInfo = ExtractNestedMappingInfo(nestedMapping, collection.ItemType); + var nestedInfo = MappingMetadataExtractor.ExtractNestedMappingInfo(nestedMapping, collection.ItemType); if (nestedInfo != null && nestedInfo.Properties.Count > 0) { maxCol = nestedInfo.Properties.Max(p => p.ColumnIndex); @@ -478,7 +397,7 @@ private static void MarkVerticalCollectionCells(OptimizedCellHandler[,] grid, Co var itemType = collection.ItemType ?? typeof(object); var nestedMapping = collection.Registry?.GetCompiledMapping(itemType); - if (nestedMapping != null && itemType != typeof(string) && !itemType.IsValueType && !itemType.IsPrimitive) + if (nestedMapping != null && MappingMetadataExtractor.IsComplexType(itemType)) { // Complex type with mapping - expand each item across multiple columns MarkVerticalComplexCollectionCells(grid, collection, collectionIndex, boundaries, startRow, nestedMapping, nextCollectionStartRow); @@ -560,12 +479,7 @@ private static OptimizedCellHandler[] BuildOptimizedColumnHandlers(CompiledMa return (obj, _) => { var enumerable = getter(obj); - return enumerable switch - { - null => null, - IList list => offset < list.Count ? list[offset] : null, - _ => enumerable.Cast().Skip(offset).FirstOrDefault() - }; + return CollectionAccessor.GetItemAt(enumerable, offset); }; } @@ -582,21 +496,18 @@ private static void PreCompileCollectionHelpers(CompiledMapping mapping) var collection = mapping.Collections[i]; var helper = new OptimizedCollectionHelper(); - // Get the actual property info - var propInfo = typeof(T).GetProperty(collection.PropertyName); + // Get the actual property info using centralized helper + var propInfo = MappingMetadataExtractor.GetPropertyByName(typeof(T), collection.PropertyName); if (propInfo == null) continue; var propertyType = propInfo.PropertyType; var itemType = collection.ItemType ?? typeof(object); helper.ItemType = itemType; - // Create simple factory functions - var listType = typeof(List<>).MakeGenericType(itemType); - helper.Factory = () => (IList)Activator.CreateInstance(listType)!; - helper.DefaultItemFactory = () => itemType.IsValueType ? Activator.CreateInstance(itemType) : null; - helper.Finalizer = propertyType.IsArray - ? list => { var array = Array.CreateInstance(itemType, list.Count); list.CopyTo(array, 0); return array; } - : list => list; + // 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; @@ -608,13 +519,12 @@ private static void PreCompileCollectionHelpers(CompiledMapping mapping) helpers.Add(helper); // Pre-compile nested mapping info if it's a complex type - if (collection.Registry != null && itemType != typeof(string) && - !itemType.IsValueType && !itemType.IsPrimitive) + if (collection.Registry != null && MappingMetadataExtractor.IsComplexType(itemType)) { var nestedMapping = collection.Registry.GetCompiledMapping(itemType); if (nestedMapping != null) { - var nestedInfo = ExtractNestedMappingInfo(nestedMapping, itemType); + var nestedInfo = MappingMetadataExtractor.ExtractNestedMappingInfo(nestedMapping, itemType); if (nestedInfo != null) { nestedMappings[i] = nestedInfo; @@ -634,7 +544,7 @@ private static void MarkVerticalComplexCollectionCells(OptimizedCellHandler[,] g int collectionIndex, OptimizedMappingBoundaries boundaries, int startRow, object nestedMapping, int? nextCollectionStartRow = null) { // Extract pre-compiled nested mapping info without reflection - var nestedInfo = ExtractNestedMappingInfo(nestedMapping, collection.ItemType ?? typeof(object)); + var nestedInfo = MappingMetadataExtractor.ExtractNestedMappingInfo(nestedMapping, collection.ItemType ?? typeof(object)); if (nestedInfo == null) return; // Now mark cells for each property of each collection item @@ -691,105 +601,15 @@ private static void MarkVerticalComplexCollectionCells(OptimizedCellHandler[,] g return (obj, _) => { var enumerable = collectionGetter(obj); - switch (enumerable) - { - case null: - break; - case IList list: - { - if (offset < list.Count && list[offset] != null) - { - // Extract the property from the nested object - return propertyGetter(list[offset]!); - } - - break; - } - default: - { - // Fall back to enumeration (slower but works) - var items = enumerable.Cast().Skip(offset).Take(1).ToArray(); - if (items.Length > 0 && items[0] != null) - { - return propertyGetter(items[0]); - } - - break; - } - } - - return null; + var item = CollectionAccessor.GetItemAt(enumerable, offset); + + return item != null ? propertyGetter(item) : null; }; } private static Func CreatePreCompiledItemConverter(Type targetType) { - // Simple converter that handles common type conversions - return value => - { - if (value == null) return null; - if (value.GetType() == targetType) return value; - - try - { - return Convert.ChangeType(value, targetType); - } - catch - { - return targetType.IsValueType ? Activator.CreateInstance(targetType) : null; - } - }; + return value => ConversionHelper.ConvertValue(value, targetType); } - private 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 == null) return null; - - var properties = propsProperty.GetValue(nestedMapping) as IEnumerable; - if (properties == null) return null; - - var nestedInfo = new NestedMappingInfo - { - ItemType = itemType, - ItemFactory = () => itemType.IsValueType ? Activator.CreateInstance(itemType) : null - }; - - 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 == null || columnProperty == null || getterProperty == 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 (name != null && getter != null) - { - propertyList.Add(new NestedPropertyInfo - { - PropertyName = name, - ColumnIndex = column, - Getter = getter, - Setter = setter ?? ((_, _) => { }), - PropertyType = propTypeValue ?? typeof(object) - }); - } - } - - nestedInfo.Properties = propertyList; - return nestedInfo; - } } \ No newline at end of file diff --git a/src/MiniExcel.Core/Mapping/MappingExporter.cs b/src/MiniExcel.Core/Mapping/MappingExporter.cs index 26edc282..690267c1 100644 --- a/src/MiniExcel.Core/Mapping/MappingExporter.cs +++ b/src/MiniExcel.Core/Mapping/MappingExporter.cs @@ -16,6 +16,7 @@ public MappingExporter(MappingRegistry registry) [CreateSyncVersion] public async Task SaveAsAsync(Stream stream, IEnumerable values, CancellationToken cancellationToken = default) + where T : class { if (stream == null) throw new ArgumentNullException(nameof(stream)); diff --git a/src/MiniExcel.Core/Mapping/MappingReader.cs b/src/MiniExcel.Core/Mapping/MappingReader.cs index a1208342..255b290a 100644 --- a/src/MiniExcel.Core/Mapping/MappingReader.cs +++ b/src/MiniExcel.Core/Mapping/MappingReader.cs @@ -90,12 +90,10 @@ private static async IAsyncEnumerable QueryOptimizedAsync(Stream stream, Comp { var cellValue = mappedRow.GetCell(col - 1); // Convert to 0-based for MappedRow - var relativeCol = col - boundaries.MinColumn; - if (relativeCol < 0 || relativeCol >= cellGrid.GetLength(1)) - continue; - - var handler = cellGrid[gridRow, relativeCol]; - ProcessCellValue(handler, cellValue, currentItem, currentCollections, mapping); + if (mapping.TryGetHandler(rowNumber, col, out var handler)) + { + ProcessCellValue(handler, cellValue, currentItem, currentCollections, mapping); + } } } @@ -136,7 +134,7 @@ private static async IAsyncEnumerable QueryOptimizedAsync(Stream stream, Comp if (cellValue != null) { // Trust the precompiled setter to handle conversion - prop.Setter?.Invoke(item, cellValue); + mapping.TrySetPropertyValue(prop, item, cellValue); } } } @@ -210,7 +208,7 @@ private static Dictionary InitializeCollections(CompiledMapping m return collections; } - private static void ProcessCellValue(OptimizedCellHandler handler, object value, T item, + private static void ProcessCellValue(OptimizedCellHandler handler, object? value, T item, Dictionary? collections, CompiledMapping mapping) { // Skip empty handlers @@ -221,7 +219,7 @@ private static void ProcessCellValue(OptimizedCellHandler handler, object value, { case CellHandlerType.Property: // Direct property - use pre-compiled setter - handler.ValueSetter?.Invoke(item, value); + mapping.TrySetValue(handler, item, value); break; case CellHandlerType.CollectionItem: @@ -300,7 +298,7 @@ private static void ProcessCellValue(OptimizedCellHandler handler, object value, } private static void ProcessComplexCollectionItem(IList collection, OptimizedCellHandler handler, - object value, CompiledMapping mapping) + object? value, CompiledMapping mapping) { // Ensure the collection has enough items while (collection.Count <= handler.CollectionItemOffset) @@ -338,8 +336,8 @@ private static void ProcessComplexCollectionItem(IList collection, OptimizedCell collection[handler.CollectionItemOffset] = item; } - // The ValueSetter must be pre-compiled during optimization - if (handler.ValueSetter == null) + // 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 != null && @@ -358,10 +356,6 @@ private static void ProcessComplexCollectionItem(IList collection, OptimizedCell $"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."); } - - // Use the pre-compiled setter with built-in type conversion - if (item != null) - handler.ValueSetter(item, value); } private static void FinalizeCollections(T item, CompiledMapping mapping, Dictionary collections) @@ -369,59 +363,60 @@ private static void FinalizeCollections(T item, CompiledMapping mapping, Dict for (int i = 0; i < mapping.Collections.Count; i++) { var collectionMapping = mapping.Collections[i]; - if (collections.TryGetValue(i, out var list)) + if (!collections.TryGetValue(i, out var list)) + continue; + + // Get the default value using precompiled factory if available + object? defaultValue = null; + if (mapping.OptimizedCollectionHelpers != null && i < mapping.OptimizedCollectionHelpers.Count) { - // Get the default value using precompiled factory if available - object? defaultValue = null; - if (mapping.OptimizedCollectionHelpers != null && i < mapping.OptimizedCollectionHelpers.Count) + var helper = mapping.OptimizedCollectionHelpers[i]; + // Use pre-compiled type metadata instead of runtime check + if (helper.IsItemValueType) { - var helper = mapping.OptimizedCollectionHelpers[i]; - // Use pre-compiled type metadata instead of runtime check - if (helper.IsItemValueType) - { - defaultValue = helper.DefaultValue ?? helper.DefaultItemFactory.Invoke(); - } + defaultValue = helper.DefaultValue ?? helper.DefaultItemFactory.Invoke(); } - else + } + 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 == null || + (listHelper != null && listHelper.IsItemValueType && + lastItem.Equals(defaultValue)); + if (isDefault) { - // 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."); + list.RemoveAt(list.Count - 1); } - - while (list.Count > 0) - { - var lastItem = list[^1]; - // Use pre-compiled type metadata from helper - var listHelper = mapping.OptimizedCollectionHelpers?[i]; - bool isDefault = lastItem == null || - (listHelper != null && listHelper.IsItemValueType && lastItem.Equals(defaultValue)); - 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 != null) + else { - // Use precompiled collection helper to convert to final type - if (mapping.OptimizedCollectionHelpers != null && i < mapping.OptimizedCollectionHelpers.Count) - { - var helper = mapping.OptimizedCollectionHelpers[i]; - finalValue = helper.Finalizer(list); - } - - collectionMapping.Setter(item, finalValue); + break; // Stop when we find a non-default value } } + + // Convert to final type if needed + object finalValue = list; + + if (collectionMapping.Setter == null) + continue; + + // Use precompiled collection helper to convert to final type + if (mapping.OptimizedCollectionHelpers != null && i < mapping.OptimizedCollectionHelpers.Count) + { + var helper = mapping.OptimizedCollectionHelpers[i]; + finalValue = helper.Finalizer(list); + } + + mapping.TrySetCollectionValue(collectionMapping, item, finalValue); } } @@ -432,7 +427,7 @@ private static bool HasAnyData(T item, CompiledMapping mapping) foreach (var prop in mapping.Properties) { var value = prop.Getter(item); - if (value != null && !IsDefaultValue(value)) + if (!IsDefaultValue(value)) { return true; } @@ -442,18 +437,14 @@ private static bool HasAnyData(T item, CompiledMapping mapping) foreach (var coll in mapping.Collections) { var collection = coll.Getter(item); - if (collection != null) + var enumerator = collection.GetEnumerator(); + using var disposableEnumerator = enumerator as IDisposable; + if (enumerator.MoveNext()) { - // Avoid Cast().Any() which causes boxing - var enumerator = collection.GetEnumerator(); - using var disposableEnumerator = enumerator as IDisposable; - if (enumerator.MoveNext()) - { - return true; - } + return true; } } - + return false; } diff --git a/src/MiniExcel.Core/Mapping/MappingTemplateProcessor.cs b/src/MiniExcel.Core/Mapping/MappingTemplateProcessor.cs index 467587b3..7d439f1a 100644 --- a/src/MiniExcel.Core/Mapping/MappingTemplateProcessor.cs +++ b/src/MiniExcel.Core/Mapping/MappingTemplateProcessor.cs @@ -183,51 +183,31 @@ await writer.WriteAttributeStringAsync( bool cellHandled = false; - // Check if we have an optimized grid and this cell is within bounds - if (mapping.OptimizedCellGrid != null && mapping.OptimizedBoundaries != null) + // Check if we have a handler for this cell + if (mapping.TryGetHandler(row, col, out var handler)) { - var relRow = row - mapping.OptimizedBoundaries.MinRow; - var relCol = col - mapping.OptimizedBoundaries.MinColumn; - - - if (relRow >= 0 && relRow < mapping.OptimizedBoundaries.GridHeight && - relCol >= 0 && relCol < mapping.OptimizedBoundaries.GridWidth) + // Use the pre-calculated handler to extract the value + if (mapping.TryGetValue(handler, currentItem, out var value)) { - var handler = mapping.OptimizedCellGrid[relRow, relCol]; - - - if (handler.Type != CellHandlerType.Empty) + // Special handling for collection items + if (handler.Type == CellHandlerType.CollectionItem && value == null) { - // Use the pre-calculated handler to extract the value - object? value = null; - bool skipCell = false; - - if (handler.Type == CellHandlerType.Property && handler.ValueExtractor != null) - { - value = currentItem != null ? handler.ValueExtractor(currentItem, 0) : null; - } - else if (handler.Type == CellHandlerType.CollectionItem && handler.ValueExtractor != null) - { - // For collections, the ValueExtractor is pre-configured with the right offset - // Just pass the parent object that contains the collection - value = currentItem != null ? handler.ValueExtractor(currentItem, 0) : null; - - // IMPORTANT: If collection item is null (beyond collection bounds), - // preserve template content instead of overwriting with null - if (value == null) - { - skipCell = true; - } - } - - - // Only write if we have a value to write - if (!skipCell) - { - await WriteMappedCellAsync(reader, writer, value).ConfigureAwait(false); - cellHandled = true; - } + // 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; } } @@ -291,19 +271,15 @@ private async Task WriteMissingRowsAsync( bool hasValue = false; for (int relCol = 0; relCol < mapping.OptimizedBoundaries.GridWidth; relCol++) { - var handler = mapping.OptimizedCellGrid[relRow, relCol]; - if (handler.Type != CellHandlerType.Empty) + 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 (handler.ValueExtractor != null && currentItem != null) + if (mapping.TryGetValue(handler, currentItem, out var value) && value != null) { - var value = handler.ValueExtractor(currentItem, 0); - if (value != null) - { - hasValue = true; - break; - } + hasValue = true; + break; } } } @@ -327,34 +303,19 @@ private async Task WriteNewRowAsync( await writer.WriteAttributeStringAsync("", "r", "", rowNumber.ToString()).ConfigureAwait(false); // Check each column in this row for mapped cells - if (mapping.OptimizedCellGrid != null && mapping.OptimizedBoundaries != null) + if (mapping.OptimizedBoundaries != null) { - var relRow = rowNumber - mapping.OptimizedBoundaries.MinRow; - - if (relRow >= 0 && relRow < mapping.OptimizedBoundaries.GridHeight) + for (int col = mapping.OptimizedBoundaries.MinColumn; col <= mapping.OptimizedBoundaries.MaxColumn; col++) { - for (int relCol = 0; relCol < mapping.OptimizedBoundaries.GridWidth; relCol++) + // Check if we have a handler for this cell + if (mapping.TryGetHandler(rowNumber, col, out var handler)) { - var handler = mapping.OptimizedCellGrid[relRow, relCol]; - if (handler.Type == CellHandlerType.Empty) continue; - - // Extract the value - object? value = null; - - if (handler.Type == CellHandlerType.Property && handler.ValueExtractor != null) - { - value = currentItem != null ? handler.ValueExtractor(currentItem, 0) : null; - } - else if (handler.Type == CellHandlerType.CollectionItem && handler.ValueExtractor != null) + // Try to get the value + if (mapping.TryGetValue(handler, currentItem, out var value) && value != null) { - value = currentItem != null ? handler.ValueExtractor(currentItem, 0) : null; + var cellRef = ReferenceHelper.ConvertCoordinatesToCell(col, rowNumber); + await XmlCellWriter.WriteNewCellAsync(writer, cellRef, value).ConfigureAwait(false); } - - if (value == null) continue; - - var actualCol = relCol + mapping.OptimizedBoundaries.MinColumn; - var cellRef = ReferenceHelper.ConvertCoordinatesToCell(actualCol, rowNumber); - await WriteNewCellAsync(writer, cellRef, value).ConfigureAwait(false); } } } @@ -371,225 +332,35 @@ private async Task WriteMissingCellsAsync( { // Check if we have an optimized grid with mappings for this row - if (mapping.OptimizedCellGrid != null && mapping.OptimizedBoundaries != null) + if (mapping.OptimizedBoundaries != null) { - var relRow = rowNumber - mapping.OptimizedBoundaries.MinRow; - - if (relRow >= 0 && relRow < mapping.OptimizedBoundaries.GridHeight) + // Check each column in the grid for this row + for (int col = mapping.OptimizedBoundaries.MinColumn; col <= mapping.OptimizedBoundaries.MaxColumn; col++) { - // Check each column in the grid for this row - for (int relCol = 0; relCol < mapping.OptimizedBoundaries.GridWidth; relCol++) + // 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)) { - var actualCol = relCol + mapping.OptimizedBoundaries.MinColumn; - - // Skip if we already wrote this column - if (writtenColumns.Contains(actualCol)) - continue; - - var handler = mapping.OptimizedCellGrid[relRow, relCol]; - if (handler.Type == CellHandlerType.Empty) continue; - // We have a mapping for this cell but it wasn't in the template - // Create a new cell for it - object? value = null; - - if (handler.Type == CellHandlerType.Property && handler.ValueExtractor != null) - { - value = currentItem != null ? handler.ValueExtractor(currentItem, 0) : null; - } - else if (handler.Type == CellHandlerType.CollectionItem && handler.ValueExtractor != null) - { - value = currentItem != null ? handler.ValueExtractor(currentItem, 0) : null; - } - - if (value != null) + // Try to get the value + if (mapping.TryGetValue(handler, currentItem, out var value) && value != null) { // Create cell reference - var cellRef = ReferenceHelper.ConvertCoordinatesToCell(actualCol, rowNumber); - - - // Write the cell - await WriteNewCellAsync(writer, cellRef, value).ConfigureAwait(false); + var cellRef = ReferenceHelper.ConvertCoordinatesToCell(col, rowNumber); + + // Write the cell using centralized helper + await XmlCellWriter.WriteNewCellAsync(writer, cellRef, value).ConfigureAwait(false); } } } } } - [CreateSyncVersion] - private async Task WriteNewCellAsync( - XmlWriter writer, - string cellRef, - object? value) - { - // Determine cell type and formatted value - var (cellValue, cellType) = 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 - if (cellType == "inlineStr" && !string.IsNullOrEmpty(cellValue)) - { - // Write inline string - await writer.WriteStartElementAsync("", "is", "").ConfigureAwait(false); - await writer.WriteStartElementAsync("", "t", "").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", "").ConfigureAwait(false); - await writer.WriteStringAsync(cellValue).ConfigureAwait(false); - await writer.WriteEndElementAsync().ConfigureAwait(false); // - } - - await writer.WriteEndElementAsync().ConfigureAwait(false); // - } - [CreateSyncVersion] - private static async Task WriteMappedCellAsync( - XmlReader reader, - XmlWriter writer, - object? value) - { - // Determine cell type and formatted value - var (cellValue, cellType) = FormatCellValue(value); - - // Write cell start tag - await writer.WriteStartElementAsync(reader.Prefix, "c", reader.NamespaceURI).ConfigureAwait(false); - - // Copy attributes, potentially updating type - if (reader.HasAttributes) - { - while (reader.MoveToNextAttribute()) - { - if (reader.LocalName == "t") - { - // Write our type instead - if (!string.IsNullOrEmpty(cellType)) - { - await writer.WriteAttributeStringAsync("", "t", "", cellType).ConfigureAwait(false); - } - } - else if (reader.LocalName == "s") - { - // Skip style if we're writing inline string - if (cellType != "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(cellType) && reader.GetAttribute("t") == null) - { - await writer.WriteAttributeStringAsync("", "t", "", cellType).ConfigureAwait(false); - } - - // Write the value - if (cellType == "inlineStr" && !string.IsNullOrEmpty(cellValue)) - { - // Write inline string - await writer.WriteStartElementAsync("", "is", reader.NamespaceURI).ConfigureAwait(false); - await writer.WriteStartElementAsync("", "t", reader.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", reader.NamespaceURI).ConfigureAwait(false); - await writer.WriteStringAsync(cellValue).ConfigureAwait(false); - await writer.WriteEndElementAsync().ConfigureAwait(false); // - } - - // Skip original cell content - 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; - } - } - } - - await writer.WriteEndElementAsync().ConfigureAwait(false); // - } - private static (string? value, string? type) FormatCellValue(object? value) - { - if (value == 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 - new DateTime(1899, 12, 30)).TotalDays; - return (excelDate.ToString(CultureInfo.InvariantCulture), null); - - case DateTimeOffset dto: - var excelDateOffset = (dto.DateTime - new DateTime(1899, 12, 30)).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"); - } - } [CreateSyncVersion] private static async Task CopyElementAsync(XmlReader reader, XmlWriter writer) diff --git a/src/MiniExcel.Core/Mapping/MappingWriter.cs b/src/MiniExcel.Core/Mapping/MappingWriter.cs index 27f349b0..eec1817b 100644 --- a/src/MiniExcel.Core/Mapping/MappingWriter.cs +++ b/src/MiniExcel.Core/Mapping/MappingWriter.cs @@ -1,6 +1,7 @@ namespace MiniExcelLib.Core.Mapping; internal static partial class MappingWriter + where T : class { [CreateSyncVersion] public static async Task SaveAsAsync(Stream stream, IEnumerable value, CompiledMapping mapping, CancellationToken cancellationToken = default) diff --git a/src/MiniExcel.Core/WriteAdapters/MappingCellStreamAdapter.cs b/src/MiniExcel.Core/WriteAdapters/MappingCellStreamAdapter.cs index a8be8cd8..4df5ad3a 100644 --- a/src/MiniExcel.Core/WriteAdapters/MappingCellStreamAdapter.cs +++ b/src/MiniExcel.Core/WriteAdapters/MappingCellStreamAdapter.cs @@ -1,6 +1,7 @@ namespace MiniExcelLib.Core.WriteAdapters; internal class MappingCellStreamAdapter : IMiniExcelWriteAdapter + where T : class { private readonly MappingCellStream _cellStream; private readonly string[] _columnLetters; From a829e1f30097e931ae5394b620c00089ca6124c2 Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Wed, 13 Aug 2025 01:56:14 +0200 Subject: [PATCH 06/16] Updating PR --- .../Helpers/ConversionHelper.cs | 83 +++++++++++++------ .../Configuration/CollectionMappingBuilder.cs | 15 +++- .../Configuration/MappingConfiguration.cs | 10 +-- .../Configuration/PropertyMappingBuilder.cs | 11 ++- src/MiniExcel.Core/Mapping/MappingRegistry.cs | 37 ++++----- 5 files changed, 99 insertions(+), 57 deletions(-) diff --git a/src/MiniExcel.Core/Helpers/ConversionHelper.cs b/src/MiniExcel.Core/Helpers/ConversionHelper.cs index 9558586a..6d445e90 100644 --- a/src/MiniExcel.Core/Helpers/ConversionHelper.cs +++ b/src/MiniExcel.Core/Helpers/ConversionHelper.cs @@ -48,9 +48,7 @@ internal static class ConversionHelper // 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 @@ -78,12 +76,15 @@ internal static class ConversionHelper // Optimized converters for common types from string if (targetType == typeof(int)) { - return value => + return value => { - var str = (string)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); + + return int.TryParse(str, out var result) + ? result + : isNullable ? null : 0; }; } @@ -91,10 +92,13 @@ internal static class ConversionHelper { return value => { - var str = (string)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); + + return long.TryParse(str, out var result) + ? result + : isNullable ? null : 0L; }; } @@ -102,11 +106,13 @@ internal static class ConversionHelper { return value => { - var str = (string)value; + var str = value as string; if (string.IsNullOrWhiteSpace(str)) - return isNullable ? null : 0.0; + return isNullable ? null : 0D; + return double.TryParse(str, NumberStyles.Any, CultureInfo.InvariantCulture, out var result) - ? result : (isNullable ? null : 0.0); + ? result + : isNullable ? null : 0D; }; } @@ -114,11 +120,13 @@ internal static class ConversionHelper { return value => { - var str = (string)value; + var str = value as string; if (string.IsNullOrWhiteSpace(str)) - return isNullable ? null : 0m; + return isNullable ? null : 0M; + return decimal.TryParse(str, NumberStyles.Any, CultureInfo.InvariantCulture, out var result) - ? result : (isNullable ? null : 0m); + ? result + : isNullable ? null : 0M; }; } @@ -126,10 +134,13 @@ internal static class ConversionHelper { return value => { - var str = (string)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); + + return bool.TryParse(str, out var result) + ? result + : isNullable ? null : false; }; } @@ -137,11 +148,27 @@ internal static class ConversionHelper { return value => { - var str = (string)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); + ? 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; }; } @@ -149,28 +176,30 @@ internal static class ConversionHelper { return value => { - var str = (string)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); + + return Guid.TryParse(str, out var result) + ? result + : isNullable ? null : Guid.Empty; }; } // Default converter using Convert.ChangeType - return value => ConvertValueFallback(value, isNullable ? typeof(Nullable<>).MakeGenericType(targetType) : targetType); + var newType = isNullable ? typeof(Nullable<>).MakeGenericType(targetType) : targetType; + return value => ConvertValueFallback(value, newType); } - private static object? ConvertValueFallback(object value, Type targetType) + private static object? ConvertValueFallback(object? value, Type targetType) { try { - var underlyingType = Nullable.GetUnderlyingType(targetType); - if (underlyingType != null) + if (Nullable.GetUnderlyingType(targetType) is { } underlyingType) { - if (value is string str && string.IsNullOrWhiteSpace(str)) - return null; - - return Convert.ChangeType(value, underlyingType, CultureInfo.InvariantCulture); + return value is not (null or "" or " ") + ? Convert.ChangeType(value, underlyingType, CultureInfo.InvariantCulture) + : null; } return Convert.ChangeType(value, targetType, CultureInfo.InvariantCulture); diff --git a/src/MiniExcel.Core/Mapping/Configuration/CollectionMappingBuilder.cs b/src/MiniExcel.Core/Mapping/Configuration/CollectionMappingBuilder.cs index c35db8d8..98f30e41 100644 --- a/src/MiniExcel.Core/Mapping/Configuration/CollectionMappingBuilder.cs +++ b/src/MiniExcel.Core/Mapping/Configuration/CollectionMappingBuilder.cs @@ -1,7 +1,14 @@ namespace MiniExcelLib.Core.Mapping.Configuration; -internal class CollectionMappingBuilder : ICollectionMappingBuilder where TCollection : IEnumerable +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) @@ -17,7 +24,7 @@ public ICollectionMappingBuilder StartAt(string cellAddress) throw new ArgumentException("Cell address cannot be null or empty", nameof(cellAddress)); // Basic validation for cell address format - if (!Regex.IsMatch(cellAddress, @"^[A-Z]+[0-9]+$")) + if (!CellAddressRegex.IsMatch(cellAddress)) throw new ArgumentException($"Invalid cell address format: {cellAddress}. Expected format like A1, B2, AA10, etc.", nameof(cellAddress)); _mapping.StartCell = cellAddress; @@ -35,13 +42,15 @@ public ICollectionMappingBuilder WithSpacing(int spacing) public ICollectionMappingBuilder WithItemMapping(Action> configure) { - if (configure == null) + 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/Mapping/Configuration/MappingConfiguration.cs b/src/MiniExcel.Core/Mapping/Configuration/MappingConfiguration.cs index be6883c1..6c0a1781 100644 --- a/src/MiniExcel.Core/Mapping/Configuration/MappingConfiguration.cs +++ b/src/MiniExcel.Core/Mapping/Configuration/MappingConfiguration.cs @@ -4,14 +4,14 @@ namespace MiniExcelLib.Core.Mapping.Configuration; internal class MappingConfiguration : IMappingConfiguration { - internal readonly List PropertyMappings = new(); - internal readonly List CollectionMappings = new(); + internal readonly List PropertyMappings = []; + internal readonly List CollectionMappings = []; internal string? WorksheetName { get; private set; } public IPropertyMappingBuilder Property( Expression> property) { - if (property == null) + if (property is null) throw new ArgumentNullException(nameof(property)); var mapping = new PropertyMapping @@ -20,14 +20,14 @@ public IPropertyMappingBuilder Property( PropertyType = typeof(TProperty) }; PropertyMappings.Add(mapping); - + return new PropertyMappingBuilder(mapping); } public ICollectionMappingBuilder Collection( Expression> collection) where TCollection : IEnumerable { - if (collection == null) + if (collection is null) throw new ArgumentNullException(nameof(collection)); var mapping = new CollectionMapping diff --git a/src/MiniExcel.Core/Mapping/Configuration/PropertyMappingBuilder.cs b/src/MiniExcel.Core/Mapping/Configuration/PropertyMappingBuilder.cs index c0a51bab..8ce06c60 100644 --- a/src/MiniExcel.Core/Mapping/Configuration/PropertyMappingBuilder.cs +++ b/src/MiniExcel.Core/Mapping/Configuration/PropertyMappingBuilder.cs @@ -1,7 +1,14 @@ namespace MiniExcelLib.Core.Mapping.Configuration; -internal class PropertyMappingBuilder : IPropertyMappingBuilder +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) @@ -15,7 +22,7 @@ public IPropertyMappingBuilder ToCell(string 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 (!Regex.IsMatch(cellAddress, @"^[A-Z]+[0-9]+$")) + if (!CellAddressRegex.IsMatch(cellAddress)) throw new ArgumentException($"Invalid cell address format: {cellAddress}. Expected format like A1, B2, AA10, etc.", nameof(cellAddress)); _mapping.CellAddress = cellAddress; diff --git a/src/MiniExcel.Core/Mapping/MappingRegistry.cs b/src/MiniExcel.Core/Mapping/MappingRegistry.cs index aa35c960..b58fdc20 100644 --- a/src/MiniExcel.Core/Mapping/MappingRegistry.cs +++ b/src/MiniExcel.Core/Mapping/MappingRegistry.cs @@ -5,11 +5,16 @@ namespace MiniExcelLib.Core.Mapping; 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(); - - public void Configure(Action> configure) +#endif + + public void Configure(Action>? configure) { - if (configure == null) + if (configure is null) throw new ArgumentNullException(nameof(configure)); lock (_lock) @@ -26,12 +31,9 @@ internal CompiledMapping GetMapping() { lock (_lock) { - if (_compiledMappings.TryGetValue(typeof(T), out var mapping)) - { - return (CompiledMapping)mapping; - } - - throw new InvalidOperationException($"No mapping configured for type {typeof(T).Name}. Call Configure<{typeof(T).Name}>() first."); + 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."); } } @@ -47,12 +49,9 @@ public bool HasMapping() { lock (_lock) { - if (_compiledMappings.TryGetValue(typeof(T), out var mapping)) - { - return (CompiledMapping)mapping; - } - - return null; + return _compiledMappings.TryGetValue(typeof(T), out var mapping) + ? (CompiledMapping)mapping + : null; } } @@ -60,11 +59,9 @@ public bool HasMapping() { lock (_lock) { - if (_compiledMappings.TryGetValue(type, out var mapping)) - { - return mapping; - } - return null; + return _compiledMappings.TryGetValue(type, out var mapping) + ? mapping + : null; } } } \ No newline at end of file From e09562a8abda37c10a6e47f477d27593d96f6247 Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Sun, 14 Sep 2025 22:13:49 +0200 Subject: [PATCH 07/16] Mostly style and format changes Changed == null checks to is null checks, added nullable reference types annotations and pattern matching where appropriate, moved new tests to FluentMapping folder, other minor changes --- src/MiniExcel.Core/Helpers/CellFormatter.cs | 2 +- .../Helpers/ConversionHelper.cs | 2 +- .../Helpers/MappingMetadataExtractor.cs | 12 +-- src/MiniExcel.Core/Helpers/XmlCellWriter.cs | 2 +- src/MiniExcel.Core/Mapping/CompiledMapping.cs | 22 ++--- .../Configuration/MappingConfiguration.cs | 2 +- .../Mapping/MappingCellStream.cs | 12 +-- src/MiniExcel.Core/Mapping/MappingCompiler.cs | 84 ++++++++--------- src/MiniExcel.Core/Mapping/MappingExporter.cs | 38 ++++---- src/MiniExcel.Core/Mapping/MappingImporter.cs | 14 ++- src/MiniExcel.Core/Mapping/MappingReader.cs | 91 ++++++++++--------- .../Mapping/MappingTemplateApplicator.cs | 10 +- .../Mapping/MappingTemplateProcessor.cs | 35 +++---- src/MiniExcel.Core/Mapping/MappingWriter.cs | 8 +- src/MiniExcel.Core/OpenXml/MappedRow.cs | 13 +-- src/MiniExcel.Core/OpenXml/OpenXmlReader.cs | 2 +- .../MiniExcelMappingCompilerTests.cs | 11 +-- .../MiniExcelMappingTemplateTests.cs | 29 +++--- .../MiniExcelMappingTests.cs | 60 ++++++------ .../MiniExcelOpenXmlTests.cs | 5 +- 20 files changed, 211 insertions(+), 243 deletions(-) rename tests/MiniExcel.Core.Tests/{ => FluentMapping}/MiniExcelMappingCompilerTests.cs (95%) rename tests/MiniExcel.Core.Tests/{ => FluentMapping}/MiniExcelMappingTemplateTests.cs (94%) rename tests/MiniExcel.Core.Tests/{ => FluentMapping}/MiniExcelMappingTests.cs (93%) diff --git a/src/MiniExcel.Core/Helpers/CellFormatter.cs b/src/MiniExcel.Core/Helpers/CellFormatter.cs index 03fb97de..cb7da44d 100644 --- a/src/MiniExcel.Core/Helpers/CellFormatter.cs +++ b/src/MiniExcel.Core/Helpers/CellFormatter.cs @@ -19,7 +19,7 @@ internal static class CellFormatter /// A tuple containing the formatted value and the Excel cell type public static (string? value, string? type) FormatCellValue(object? value) { - if (value == null) + if (value is null) return (null, null); switch (value) diff --git a/src/MiniExcel.Core/Helpers/ConversionHelper.cs b/src/MiniExcel.Core/Helpers/ConversionHelper.cs index 6d445e90..1efad08b 100644 --- a/src/MiniExcel.Core/Helpers/ConversionHelper.cs +++ b/src/MiniExcel.Core/Helpers/ConversionHelper.cs @@ -222,7 +222,7 @@ private static Expression CreateTypedConversionExpression(Type targetType, Param { // Handle nullable types var underlyingType = Nullable.GetUnderlyingType(targetType); - var isNullable = underlyingType != null; + var isNullable = underlyingType is not null; var effectiveType = underlyingType ?? targetType; Expression convertExpression; diff --git a/src/MiniExcel.Core/Helpers/MappingMetadataExtractor.cs b/src/MiniExcel.Core/Helpers/MappingMetadataExtractor.cs index 5728286c..8fd319fa 100644 --- a/src/MiniExcel.Core/Helpers/MappingMetadataExtractor.cs +++ b/src/MiniExcel.Core/Helpers/MappingMetadataExtractor.cs @@ -19,10 +19,9 @@ internal static class MappingMetadataExtractor // This is done once at compile time, not at runtime var nestedMappingType = nestedMapping.GetType(); var propsProperty = nestedMappingType.GetProperty("Properties"); - if (propsProperty == null) return null; - - var properties = propsProperty.GetValue(nestedMapping) as IEnumerable; - if (properties == null) return null; + + if (propsProperty?.GetValue(nestedMapping) is not IEnumerable properties) + return null; var nestedInfo = new NestedMappingInfo { @@ -54,7 +53,8 @@ private static List ExtractPropertyList(IEnumerable properti var setterProperty = propType.GetProperty("Setter"); var typeProperty = propType.GetProperty("PropertyType"); - if (nameProperty == null || columnProperty == null || getterProperty == null) continue; + if (nameProperty is null || columnProperty is null || getterProperty is null) + continue; var name = nameProperty.GetValue(prop) as string; var column = (int)columnProperty.GetValue(prop)!; @@ -62,7 +62,7 @@ private static List ExtractPropertyList(IEnumerable properti var setter = setterProperty?.GetValue(prop) as Action; var propTypeValue = typeProperty?.GetValue(prop) as Type; - if (name != null && getter != null) + if (name is not null && getter is not null) { propertyList.Add(new NestedPropertyInfo { diff --git a/src/MiniExcel.Core/Helpers/XmlCellWriter.cs b/src/MiniExcel.Core/Helpers/XmlCellWriter.cs index 02059b05..49253738 100644 --- a/src/MiniExcel.Core/Helpers/XmlCellWriter.cs +++ b/src/MiniExcel.Core/Helpers/XmlCellWriter.cs @@ -148,7 +148,7 @@ await writer.WriteAttributeStringAsync( } // If we didn't have a type attribute but need one, add it - if (!string.IsNullOrEmpty(newCellType) && reader.GetAttribute("t") == null) + if (!string.IsNullOrEmpty(newCellType) && reader.GetAttribute("t") is null) { await writer.WriteAttributeStringAsync("", "t", "", newCellType).ConfigureAwait(false); } diff --git a/src/MiniExcel.Core/Mapping/CompiledMapping.cs b/src/MiniExcel.Core/Mapping/CompiledMapping.cs index 45d52db3..f055f727 100644 --- a/src/MiniExcel.Core/Mapping/CompiledMapping.cs +++ b/src/MiniExcel.Core/Mapping/CompiledMapping.cs @@ -44,7 +44,7 @@ public bool TryGetHandler(int absoluteRow, int absoluteCol, out OptimizedCellHan { handler = default!; - if (OptimizedCellGrid == null || OptimizedBoundaries == null) + if (OptimizedCellGrid is null || OptimizedBoundaries is null) return false; var relRow = absoluteRow - OptimizedBoundaries.MinRow; @@ -70,7 +70,7 @@ public bool TryGetValue(OptimizedCellHandler handler, TItem? item, out ob { value = null; - if (item == null || handler.ValueExtractor == null) + if (item is null || handler.ValueExtractor is null) return false; value = handler.ValueExtractor(item, 0); @@ -87,7 +87,7 @@ public bool TryGetValue(OptimizedCellHandler handler, TItem? item, out ob /// True if the value was successfully set public bool TrySetValue(OptimizedCellHandler handler, TItem? item, object? value) where TItem : class { - if (item == null || handler.ValueSetter == null) + if (item is null || handler.ValueSetter is null) return false; handler.ValueSetter(item, value); @@ -107,10 +107,8 @@ public bool TryGetCellValue(int row, int col, TItem item, out object? val { value = null; - if (!TryGetHandler(row, col, out var handler)) - return false; - - return TryGetValue(handler, item, out value); + return TryGetHandler(row, col, out var handler) && + TryGetValue(handler, item, out value); } /// @@ -124,10 +122,8 @@ public bool TryGetCellValue(int row, int col, TItem item, out object? val /// True if the value was successfully set public bool TrySetCellValue(int row, int col, TItem item, object? value) where TItem : class { - if (!TryGetHandler(row, col, out var handler)) - return false; - - return TrySetValue(handler, item, value); + return TryGetHandler(row, col, out var handler) && + TrySetValue(handler, item, value); } /// @@ -140,7 +136,7 @@ public bool TrySetCellValue(int row, int col, TItem item, object? value) /// True if the value was successfully set public bool TrySetPropertyValue(CompiledPropertyMapping property, TItem item, object? value) where TItem : class { - if (property.Setter == null) + if (property.Setter is null) return false; property.Setter(item, value); @@ -157,7 +153,7 @@ public bool TrySetPropertyValue(CompiledPropertyMapping property, TItem i /// True if the collection was successfully set public bool TrySetCollectionValue(CompiledCollectionMapping collection, TItem item, object? value) where TItem : class { - if (collection.Setter == null) + if (collection.Setter is null) return false; collection.Setter(item, value); diff --git a/src/MiniExcel.Core/Mapping/Configuration/MappingConfiguration.cs b/src/MiniExcel.Core/Mapping/Configuration/MappingConfiguration.cs index 6c0a1781..ca25c4f8 100644 --- a/src/MiniExcel.Core/Mapping/Configuration/MappingConfiguration.cs +++ b/src/MiniExcel.Core/Mapping/Configuration/MappingConfiguration.cs @@ -25,7 +25,7 @@ public IPropertyMappingBuilder Property( } public ICollectionMappingBuilder Collection( - Expression> collection) where TCollection : IEnumerable + Expression>? collection) where TCollection : IEnumerable { if (collection is null) throw new ArgumentNullException(nameof(collection)); diff --git a/src/MiniExcel.Core/Mapping/MappingCellStream.cs b/src/MiniExcel.Core/Mapping/MappingCellStream.cs index 45657e33..22a623dd 100644 --- a/src/MiniExcel.Core/Mapping/MappingCellStream.cs +++ b/src/MiniExcel.Core/Mapping/MappingCellStream.cs @@ -101,7 +101,7 @@ public bool MoveNext() } // Process current item's cells - if (_currentItem != null) + if (_currentItem is not null) { // Cache collections as arrays when we start processing an item if (_currentColumnIndex == 0 && _currentCollectionRow == 0 && _mapping.Collections.Count > 0) @@ -113,14 +113,10 @@ public bool MoveNext() { var coll = _mapping.Collections[i]; var collectionData = coll.Getter(_currentItem); - if (collectionData != null) + if (collectionData is not null) { // Convert to array once - this is the only enumeration - var items = new List(); - foreach (var item in collectionData) - { - items.Add(item); - } + var items = collectionData.Cast().ToList(); _currentCollectionArrays[i] = items.ToArray(); // For vertical collections, we need rows from StartCellRow @@ -156,7 +152,7 @@ public bool MoveNext() cellValue = formattable.ToString(handler.Format, null); } } - else if (_currentCollectionArrays != null) + else if (_currentCollectionArrays is not null) { // Fallback for collections that might not be in the grid yet // This handles dynamic collection expansion diff --git a/src/MiniExcel.Core/Mapping/MappingCompiler.cs b/src/MiniExcel.Core/Mapping/MappingCompiler.cs index 119cb93d..287ef934 100644 --- a/src/MiniExcel.Core/Mapping/MappingCompiler.cs +++ b/src/MiniExcel.Core/Mapping/MappingCompiler.cs @@ -18,9 +18,9 @@ internal static class MappingCompiler /// /// Compiles a mapping configuration into an optimized runtime representation. /// - public static CompiledMapping Compile(MappingConfiguration configuration, MappingRegistry? registry = null) + public static CompiledMapping Compile(MappingConfiguration? configuration, MappingRegistry? registry = null) { - if (configuration == null) + if (configuration is null) throw new ArgumentNullException(nameof(configuration)); var properties = new List(); @@ -44,13 +44,14 @@ public static CompiledMapping Compile(MappingConfiguration configuratio // Create setter with proper type conversion using centralized logic Action? setter = null; - if (prop.Expression.Body is MemberExpression memberExpr && memberExpr.Member is PropertyInfo propInfo) + if (prop.Expression.Body is MemberExpression { Member: PropertyInfo propInfo }) { setter = ConversionHelper.CreateTypedPropertySetter(propInfo); } // Pre-parse cell coordinates for runtime performance - if (prop.CellAddress == null) continue; + if (prop.CellAddress is null) + continue; ReferenceHelper.ParseReference(prop.CellAddress, out int cellCol, out int cellRow); @@ -90,14 +91,15 @@ public static CompiledMapping Compile(MappingConfiguration configuratio // Create setter for collection Action? collectionSetter = null; - if (coll.Expression.Body is MemberExpression collMemberExpr && collMemberExpr.Member is PropertyInfo collPropInfo) + 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 == null) continue; + if (coll.StartCell is null) + continue; ReferenceHelper.ParseReference(coll.StartCell, out int startCol, out int startRow); @@ -125,36 +127,30 @@ public static CompiledMapping Compile(MappingConfiguration configuratio }; OptimizeMapping(compiledMapping); - return compiledMapping; } private static string GetPropertyName(LambdaExpression expression) { - if (expression.Body is MemberExpression memberExpr) - { - return memberExpr.Member.Name; - } - - if (expression.Body is UnaryExpression unaryExpr && unaryExpr.Operand is MemberExpression unaryMemberExpr) + return expression.Body switch { - return unaryMemberExpr.Member.Name; - } - - throw new InvalidOperationException($"Cannot extract property name from expression: {expression}"); + 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) + private static void OptimizeMapping(CompiledMapping? mapping) { - if (mapping == null) + if (mapping is null) throw new ArgumentNullException(nameof(mapping)); // If already optimized, skip - if (mapping.OptimizedCellGrid != null && mapping.OptimizedBoundaries != null) + if (mapping is { OptimizedCellGrid: not null, OptimizedBoundaries: not null }) return; // Step 1: Calculate mapping boundaries @@ -208,19 +204,20 @@ private static OptimizedMappingBoundaries CalculateMappingBoundaries(Compiled // 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.Collections.Any() && mapping.Properties.Any()) + if (mapping is { Collections.Count: > 0, Properties.Count: > 0 }) { // 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.ItemType != null && coll.Registry != null) + 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); - if (nestedMapping != null && coll.ItemType != typeof(string) && - !coll.ItemType.IsValueType && !coll.ItemType.IsPrimitive) + if (nestedMapping is not null && + coll.ItemType != typeof(string) && + coll.ItemType is { IsValueType: false, IsPrimitive: false }) { hasNestedCollections = true; break; @@ -254,7 +251,7 @@ private static OptimizedMappingBoundaries CalculateMappingBoundaries(Compiled // 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 > 0 && boundaries.PatternHeight < MaxPatternHeight) + if (boundaries.PatternHeight is > 0 and < MaxPatternHeight) { boundaries.IsMultiItemPattern = true; } @@ -287,16 +284,16 @@ private static (int minRow, int maxRow, int minCol, int maxCol) CalculateCollect // Check if this is a complex type with nested mapping var maxCol = startCol; - if (collection.ItemType == null || collection.Registry == null) + if (collection.ItemType is null || collection.Registry is null) return (startRow, startRow + verticalHeight, startCol, maxCol); var nestedMapping = collection.Registry.GetCompiledMapping(collection.ItemType); - if (nestedMapping == null || !MappingMetadataExtractor.IsComplexType(collection.ItemType)) + if (nestedMapping is null || !MappingMetadataExtractor.IsComplexType(collection.ItemType)) return (startRow, startRow + verticalHeight, startCol, maxCol); // Extract nested mapping info to get max column var nestedInfo = MappingMetadataExtractor.ExtractNestedMappingInfo(nestedMapping, collection.ItemType); - if (nestedInfo != null && nestedInfo.Properties.Count > 0) + if (nestedInfo is { Properties.Count: > 0 }) { maxCol = nestedInfo.Properties.Max(p => p.ColumnIndex); } @@ -325,9 +322,8 @@ private static (int minRow, int maxRow, int minCol, int maxCol) CalculateCollect } // Process simple properties - for (int i = 0; i < mapping.Properties.Count; i++) + foreach (var prop in mapping.Properties) { - var prop = mapping.Properties[i]; var relativeRow = prop.CellRow - boundaries.MinRow; var relativeCol = prop.CellColumn - boundaries.MinColumn; @@ -391,13 +387,14 @@ private static void MarkVerticalCollectionCells(OptimizedCellHandler[,] grid, Co int collectionIndex, OptimizedMappingBoundaries boundaries, int startRow, int startCol, int? nextCollectionStartRow = null) { var relativeCol = startCol - boundaries.MinColumn; - if (relativeCol < 0 || relativeCol >= grid.GetLength(1)) return; + 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 != null && MappingMetadataExtractor.IsComplexType(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); @@ -485,7 +482,8 @@ private static OptimizedCellHandler[] BuildOptimizedColumnHandlers(CompiledMa private static void PreCompileCollectionHelpers(CompiledMapping mapping) { - if (!mapping.Collections.Any()) return; + if (!mapping.Collections.Any()) + return; // Store pre-compiled helpers for each collection var helpers = new List(); @@ -498,7 +496,8 @@ private static void PreCompileCollectionHelpers(CompiledMapping mapping) // Get the actual property info using centralized helper var propInfo = MappingMetadataExtractor.GetPropertyByName(typeof(T), collection.PropertyName); - if (propInfo == null) continue; + if (propInfo is null) + continue; var propertyType = propInfo.PropertyType; var itemType = collection.ItemType ?? typeof(object); @@ -519,13 +518,13 @@ private static void PreCompileCollectionHelpers(CompiledMapping mapping) helpers.Add(helper); // Pre-compile nested mapping info if it's a complex type - if (collection.Registry != null && MappingMetadataExtractor.IsComplexType(itemType)) + if (collection.Registry is not null && MappingMetadataExtractor.IsComplexType(itemType)) { var nestedMapping = collection.Registry.GetCompiledMapping(itemType); - if (nestedMapping != null) + if (nestedMapping is not null) { var nestedInfo = MappingMetadataExtractor.ExtractNestedMappingInfo(nestedMapping, itemType); - if (nestedInfo != null) + if (nestedInfo is not null) { nestedMappings[i] = nestedInfo; } @@ -545,7 +544,8 @@ private static void MarkVerticalComplexCollectionCells(OptimizedCellHandler[,] g { // Extract pre-compiled nested mapping info without reflection var nestedInfo = MappingMetadataExtractor.ExtractNestedMappingInfo(nestedMapping, collection.ItemType ?? typeof(object)); - if (nestedInfo == null) return; + 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 @@ -564,11 +564,12 @@ private static void MarkVerticalComplexCollectionCells(OptimizedCellHandler[,] g for (int itemIndex = 0; itemIndex < maxItems; itemIndex++) { var r = startRelativeRow + itemIndex * (1 + rowSpacing); - if (r < 0 || r >= maxRows || r >= grid.GetLength(0)) continue; + 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 (nextCollectionStartRow.HasValue && absoluteRow >= nextCollectionStartRow.Value) + if (absoluteRow >= nextCollectionStartRow) break; foreach (var prop in nestedInfo.Properties) @@ -603,7 +604,7 @@ private static void MarkVerticalComplexCollectionCells(OptimizedCellHandler[,] g var enumerable = collectionGetter(obj); var item = CollectionAccessor.GetItemAt(enumerable, offset); - return item != null ? propertyGetter(item) : null; + return item is not null ? propertyGetter(item) : null; }; } @@ -611,5 +612,4 @@ private static void MarkVerticalComplexCollectionCells(OptimizedCellHandler[,] g { return value => ConversionHelper.ConvertValue(value, targetType); } - } \ No newline at end of file diff --git a/src/MiniExcel.Core/Mapping/MappingExporter.cs b/src/MiniExcel.Core/Mapping/MappingExporter.cs index 690267c1..9dc25b45 100644 --- a/src/MiniExcel.Core/Mapping/MappingExporter.cs +++ b/src/MiniExcel.Core/Mapping/MappingExporter.cs @@ -15,12 +15,12 @@ public MappingExporter(MappingRegistry registry) } [CreateSyncVersion] - public async Task SaveAsAsync(Stream stream, IEnumerable values, CancellationToken cancellationToken = default) + public async Task SaveAsAsync(Stream? stream, IEnumerable? values, CancellationToken cancellationToken = default) where T : class { - if (stream == null) + if (stream is null) throw new ArgumentNullException(nameof(stream)); - if (values == null) + if (values is null) throw new ArgumentNullException(nameof(values)); if (!_registry.HasMapping()) @@ -33,16 +33,16 @@ public async Task SaveAsAsync(Stream stream, IEnumerable values, Cancellat [CreateSyncVersion] public async Task ApplyTemplateAsync( - string outputPath, - string templatePath, - IEnumerable values, + 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 == null) + if (values is null) throw new ArgumentNullException(nameof(values)); using var outputStream = File.Create(outputPath); @@ -52,16 +52,16 @@ public async Task ApplyTemplateAsync( [CreateSyncVersion] public async Task ApplyTemplateAsync( - Stream outputStream, - Stream templateStream, - IEnumerable values, + Stream? outputStream, + Stream? templateStream, + IEnumerable? values, CancellationToken cancellationToken = default) where T : class { - if (outputStream == null) + if (outputStream is null) throw new ArgumentNullException(nameof(outputStream)); - if (templateStream == null) + if (templateStream is null) throw new ArgumentNullException(nameof(templateStream)); - if (values == null) + if (values is null) throw new ArgumentNullException(nameof(values)); if (!_registry.HasMapping()) @@ -74,16 +74,16 @@ await MappingTemplateApplicator.ApplyTemplateAsync( [CreateSyncVersion] public async Task ApplyTemplateAsync( - Stream outputStream, - byte[] templateBytes, - IEnumerable values, + Stream? outputStream, + byte[]? templateBytes, + IEnumerable? values, CancellationToken cancellationToken = default) where T : class { - if (outputStream == null) + if (outputStream is null) throw new ArgumentNullException(nameof(outputStream)); - if (templateBytes == null) + if (templateBytes is null) throw new ArgumentNullException(nameof(templateBytes)); - if (values == null) + if (values is null) throw new ArgumentNullException(nameof(values)); using var templateStream = new MemoryStream(templateBytes); diff --git a/src/MiniExcel.Core/Mapping/MappingImporter.cs b/src/MiniExcel.Core/Mapping/MappingImporter.cs index 4de73ce2..2f1d91cb 100644 --- a/src/MiniExcel.Core/Mapping/MappingImporter.cs +++ b/src/MiniExcel.Core/Mapping/MappingImporter.cs @@ -23,13 +23,12 @@ public MappingImporter(MappingRegistry registry) } [CreateSyncVersion] - public async IAsyncEnumerable QueryAsync(Stream stream, [EnumeratorCancellation] CancellationToken cancellationToken = default) where T : class, new() + public async IAsyncEnumerable QueryAsync(Stream? stream, [EnumeratorCancellation] CancellationToken cancellationToken = default) where T : class, new() { - if (stream == null) + if (stream is null) throw new ArgumentNullException(nameof(stream)); - var mapping = _registry.GetCompiledMapping(); - if (mapping == null) + 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)) @@ -44,13 +43,12 @@ public MappingImporter(MappingRegistry registry) } [CreateSyncVersion] - private async Task QuerySingleAsync(Stream stream, CancellationToken cancellationToken = default) where T : class, new() + private async Task QuerySingleAsync(Stream? stream, CancellationToken cancellationToken = default) where T : class, new() { - if (stream == null) + if (stream is null) throw new ArgumentNullException(nameof(stream)); - var mapping = _registry.GetCompiledMapping(); - if (mapping == null) + 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)) diff --git a/src/MiniExcel.Core/Mapping/MappingReader.cs b/src/MiniExcel.Core/Mapping/MappingReader.cs index 255b290a..36c8efb7 100644 --- a/src/MiniExcel.Core/Mapping/MappingReader.cs +++ b/src/MiniExcel.Core/Mapping/MappingReader.cs @@ -5,9 +5,9 @@ namespace MiniExcelLib.Core.Mapping; [CreateSyncVersion] public static async IAsyncEnumerable QueryAsync(Stream stream, CompiledMapping mapping, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - if (stream == null) + if (stream is null) throw new ArgumentNullException(nameof(stream)); - if (mapping == null) + if (mapping is null) throw new ArgumentNullException(nameof(mapping)); await foreach (var item in QueryOptimizedAsync(stream, mapping, cancellationToken).ConfigureAwait(false)) @@ -17,7 +17,7 @@ public static async IAsyncEnumerable QueryAsync(Stream stream, CompiledMappin [CreateSyncVersion] private static async IAsyncEnumerable QueryOptimizedAsync(Stream stream, CompiledMapping mapping, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - if (mapping.OptimizedCellGrid == null || mapping.OptimizedBoundaries == null) + if (mapping.OptimizedCellGrid is null || mapping.OptimizedBoundaries is null) throw new InvalidOperationException("QueryOptimizedAsync requires an optimized mapping"); var boundaries = mapping.OptimizedBoundaries!; @@ -34,7 +34,7 @@ private static async IAsyncEnumerable QueryOptimizedAsync(Stream stream, Comp if (mapping.Collections.Any()) { // Check if this is a multi-item pattern - bool isMultiItemPattern = boundaries.IsMultiItemPattern && boundaries.PatternHeight > 0; + bool isMultiItemPattern = boundaries is { IsMultiItemPattern: true, PatternHeight: > 0 }; T? currentItem = null; Dictionary? currentCollections = null; @@ -44,10 +44,10 @@ private static async IAsyncEnumerable QueryOptimizedAsync(Stream stream, Comp { 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; + if (rowNumber < boundaries.MinRow) + continue; // Calculate which item this row belongs to based on the pattern var relativeRow = rowNumber - boundaries.MinRow; @@ -65,7 +65,7 @@ private static async IAsyncEnumerable QueryOptimizedAsync(Stream stream, Comp if (itemIndex != currentItemIndex) { // Save the previous item if we have one - if (currentItem != null && currentCollections != null) + if (currentItem is not null && currentCollections is not null) { FinalizeCollections(currentItem, mapping, currentCollections); if (HasAnyData(currentItem, mapping)) @@ -81,9 +81,11 @@ private static async IAsyncEnumerable QueryOptimizedAsync(Stream stream, Comp } // If we don't have a current item yet, skip this row - if (currentItem == null) continue; + if (currentItem is null) + continue; - if (gridRow < 0 || gridRow >= cellGrid.GetLength(0)) 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++) @@ -98,7 +100,7 @@ private static async IAsyncEnumerable QueryOptimizedAsync(Stream stream, Comp } // Finalize the last item if we have one - if (currentItem == null || currentCollections == null) + if (currentItem is null || currentCollections is null) yield break; FinalizeCollections(currentItem, mapping, currentCollections); @@ -131,7 +133,7 @@ private static async IAsyncEnumerable QueryOptimizedAsync(Stream stream, Comp { var cellValue = mappedRow.GetCell(prop.CellColumn - 1); // Convert to 0-based - if (cellValue != null) + if (cellValue is not null) { // Trust the precompiled setter to handle conversion mapping.TrySetPropertyValue(prop, item, cellValue); @@ -150,11 +152,10 @@ private static async IAsyncEnumerable QueryOptimizedAsync(Stream stream, Comp // Row layout mode - each row is a separate item 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; + var currentRowIndex = mappedRow.RowIndex + 1; + if (currentRowIndex < boundaries.MinRow) + continue; var item = new T(); @@ -166,14 +167,15 @@ private static async IAsyncEnumerable QueryOptimizedAsync(Stream stream, Comp { // For table pattern (all on row 1), properties define columns // For cell-specific mapping, only read from the specific row - if (!allOnRow1 && prop.CellRow != rowNumber) continue; + if (!allOnRow1 && prop.CellRow != currentRowIndex) + continue; var cellValue = mappedRow.GetCell(prop.CellColumn - 1); // Convert to 0-based - if (cellValue == null) continue; - - // Trust the precompiled setter to handle conversion - if (prop.Setter == null) continue; - prop.Setter.Invoke(item, cellValue); + if (cellValue is not null) + { + // Trust the precompiled setter to handle conversion + prop.Setter?.Invoke(item, cellValue); + } } if (HasAnyData(item, mapping)) @@ -190,7 +192,7 @@ private static Dictionary InitializeCollections(CompiledMapping m var collections = new Dictionary(); // Use precompiled collection helpers if available - if (mapping.OptimizedCollectionHelpers != null) + if (mapping.OptimizedCollectionHelpers is not null) { for (int i = 0; i < mapping.OptimizedCollectionHelpers.Count && i < mapping.Collections.Count; i++) { @@ -224,7 +226,7 @@ private static void ProcessCellValue(OptimizedCellHandler handler, object? value case CellHandlerType.CollectionItem: if (handler.CollectionIndex >= 0 - && collections != null + && collections is not null && collections.TryGetValue(handler.CollectionIndex, out var collection)) { var collectionMapping = handler.CollectionMapping!; @@ -232,9 +234,13 @@ private static void ProcessCellValue(OptimizedCellHandler handler, object? value // 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 != null && itemType != typeof(string) && typeHelper != null && !typeHelper.IsItemValueType && !typeHelper.IsItemPrimitive) + + 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); @@ -246,7 +252,7 @@ private static void ProcessCellValue(OptimizedCellHandler handler, object? value { // Use precompiled default factory if available object? defaultValue; - if (mapping.OptimizedCollectionHelpers != null && + if (mapping.OptimizedCollectionHelpers is not null && handler.CollectionIndex >= 0 && handler.CollectionIndex < mapping.OptimizedCollectionHelpers.Count) { @@ -269,7 +275,7 @@ private static void ProcessCellValue(OptimizedCellHandler handler, object? value // 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 != null && !itemHelper.IsItemValueType) + if (itemHelper is { IsItemValueType: false }) { // Only set null if the collection has the item already if (handler.CollectionItemOffset < collection.Count) @@ -282,14 +288,11 @@ private static void ProcessCellValue(OptimizedCellHandler handler, object? value else { // Use pre-compiled converter if available - if (handler.CollectionItemConverter != null) - { - collection[handler.CollectionItemOffset] = handler.CollectionItemConverter(value); - } - else - { - collection[handler.CollectionItemOffset] = value; - } + var convertedValue = handler.CollectionItemConverter is not null + ? handler.CollectionItemConverter(value) + : value; + + collection[handler.CollectionItemOffset] = convertedValue; } } } @@ -304,7 +307,7 @@ private static void ProcessComplexCollectionItem(IList collection, OptimizedCell while (collection.Count <= handler.CollectionItemOffset) { // Use precompiled default factory - if (mapping.OptimizedCollectionHelpers == null || + if (mapping.OptimizedCollectionHelpers is null || handler.CollectionIndex < 0 || handler.CollectionIndex >= mapping.OptimizedCollectionHelpers.Count) { @@ -319,10 +322,10 @@ private static void ProcessComplexCollectionItem(IList collection, OptimizedCell } var item = collection[handler.CollectionItemOffset]; - if (item == null) + if (item is null) { // Use precompiled factory for creating the item - if (mapping.OptimizedCollectionHelpers == null || + if (mapping.OptimizedCollectionHelpers is null || handler.CollectionIndex < 0 || handler.CollectionIndex >= mapping.OptimizedCollectionHelpers.Count) { @@ -340,12 +343,12 @@ private static void ProcessComplexCollectionItem(IList collection, OptimizedCell if (!mapping.TrySetValue(handler, item!, value)) { // For nested mappings, we need to look up the pre-compiled setter - if (mapping.NestedMappings != null && + if (mapping.NestedMappings is not null && mapping.NestedMappings.TryGetValue(handler.CollectionIndex, out var nestedInfo)) { // Find the matching property setter in the nested mapping var nestedProp = nestedInfo.Properties.FirstOrDefault(p => p.PropertyName == handler.PropertyName); - if (nestedProp?.Setter != null && item != null) + if (nestedProp?.Setter is not null && item is not null) { nestedProp.Setter(item, value); return; @@ -368,7 +371,7 @@ private static void FinalizeCollections(T item, CompiledMapping mapping, Dict // Get the default value using precompiled factory if available object? defaultValue = null; - if (mapping.OptimizedCollectionHelpers != null && i < mapping.OptimizedCollectionHelpers.Count) + if (mapping.OptimizedCollectionHelpers is not null && i < mapping.OptimizedCollectionHelpers.Count) { var helper = mapping.OptimizedCollectionHelpers[i]; // Use pre-compiled type metadata instead of runtime check @@ -390,8 +393,8 @@ private static void FinalizeCollections(T item, CompiledMapping mapping, Dict var lastItem = list[^1]; // Use pre-compiled type metadata from helper var listHelper = mapping.OptimizedCollectionHelpers?[i]; - bool isDefault = lastItem == null || - (listHelper != null && listHelper.IsItemValueType && + bool isDefault = lastItem is null || + (listHelper is { IsItemValueType: true } && lastItem.Equals(defaultValue)); if (isDefault) { @@ -406,11 +409,11 @@ private static void FinalizeCollections(T item, CompiledMapping mapping, Dict // Convert to final type if needed object finalValue = list; - if (collectionMapping.Setter == null) + if (collectionMapping.Setter is null) continue; // Use precompiled collection helper to convert to final type - if (mapping.OptimizedCollectionHelpers != null && i < mapping.OptimizedCollectionHelpers.Count) + if (mapping.OptimizedCollectionHelpers is not null && i < mapping.OptimizedCollectionHelpers.Count) { var helper = mapping.OptimizedCollectionHelpers[i]; finalValue = helper.Finalizer(list); diff --git a/src/MiniExcel.Core/Mapping/MappingTemplateApplicator.cs b/src/MiniExcel.Core/Mapping/MappingTemplateApplicator.cs index e577e9b1..7b8f0013 100644 --- a/src/MiniExcel.Core/Mapping/MappingTemplateApplicator.cs +++ b/src/MiniExcel.Core/Mapping/MappingTemplateApplicator.cs @@ -10,13 +10,13 @@ public static async Task ApplyTemplateAsync( CompiledMapping mapping, CancellationToken cancellationToken = default) { - if (outputStream == null) + if (outputStream is null) throw new ArgumentNullException(nameof(outputStream)); - if (templateStream == null) + if (templateStream is null) throw new ArgumentNullException(nameof(templateStream)); - if (values == null) + if (values is null) throw new ArgumentNullException(nameof(values)); - if (mapping == null) + if (mapping is null) throw new ArgumentNullException(nameof(mapping)); // Ensure we can seek the template stream @@ -52,7 +52,7 @@ public static async Task ApplyTemplateAsync( var worksheetName = GetWorksheetName(entry.FullName); // Check if this worksheet matches the mapping's worksheet - if (mapping.WorksheetName == null || + if (mapping.WorksheetName is null || string.Equals(worksheetName, mapping.WorksheetName, StringComparison.OrdinalIgnoreCase) || (mapping.WorksheetName == "Sheet1" && worksheetName == "sheet1")) { diff --git a/src/MiniExcel.Core/Mapping/MappingTemplateProcessor.cs b/src/MiniExcel.Core/Mapping/MappingTemplateProcessor.cs index 7d439f1a..e652c5e2 100644 --- a/src/MiniExcel.Core/Mapping/MappingTemplateProcessor.cs +++ b/src/MiniExcel.Core/Mapping/MappingTemplateProcessor.cs @@ -1,7 +1,6 @@ namespace MiniExcelLib.Core.Mapping; -internal partial struct MappingTemplateProcessor(CompiledMapping mapping) - where T : class +internal partial struct MappingTemplateProcessor(CompiledMapping mapping) where T : class { [CreateSyncVersion] public async Task ProcessSheetAsync( @@ -31,7 +30,7 @@ public async Task ProcessSheetAsync( // Get first data item var currentItem = dataEnumerator.MoveNext() ? dataEnumerator.Current : null; - var currentItemIndex = currentItem != null ? 0 : -1; + var currentItemIndex = currentItem is not null ? 0 : -1; // Track which rows have been written from the template @@ -55,9 +54,7 @@ public async Task ProcessSheetAsync( writtenRows.Add(rowNumber); // Check if we need to advance to next item - if (mapping.OptimizedBoundaries != null && - mapping.OptimizedBoundaries.IsMultiItemPattern && - mapping.OptimizedBoundaries.PatternHeight > 0) + if (mapping.OptimizedBoundaries is { IsMultiItemPattern: true, PatternHeight: > 0 }) { var relativeRow = rowNumber - mapping.OptimizedBoundaries.MinRow; var itemIndex = relativeRow / mapping.OptimizedBoundaries.PatternHeight; @@ -71,11 +68,9 @@ public async Task ProcessSheetAsync( } // Process the row - await ProcessRowAsync( - reader, writer, rowNumber, - currentItem).ConfigureAwait(false); + await ProcessRowAsync(reader, writer, rowNumber, currentItem).ConfigureAwait(false); } - else if (reader.LocalName == "worksheet" || reader.LocalName == "sheetData") + 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 @@ -167,12 +162,11 @@ await writer.WriteAttributeStringAsync( // Process cells in the row while (await reader.ReadAsync().ConfigureAwait(false)) { - if (reader.NodeType == XmlNodeType.Element && reader.LocalName == "c") + 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 @@ -190,7 +184,7 @@ await writer.WriteAttributeStringAsync( if (mapping.TryGetValue(handler, currentItem, out var value)) { // Special handling for collection items - if (handler.Type == CellHandlerType.CollectionItem && value == null) + 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 @@ -229,7 +223,7 @@ await writer.WriteAttributeStringAsync( await CopyElementAsync(reader, writer).ConfigureAwait(false); } } - else if (reader.NodeType == XmlNodeType.EndElement && reader.LocalName == "row") + else if (reader is { NodeType: XmlNodeType.EndElement, LocalName: "row" }) { break; } @@ -253,7 +247,7 @@ private async Task WriteMissingRowsAsync( HashSet writtenRows) { // Check if we have an optimized grid with mappings - if (mapping.OptimizedCellGrid == null || mapping.OptimizedBoundaries == null) + if (mapping.OptimizedCellGrid is null || mapping.OptimizedBoundaries is null) return; @@ -276,7 +270,7 @@ private async Task WriteMissingRowsAsync( { hasMapping = true; // Check if there's an actual value to write - if (mapping.TryGetValue(handler, currentItem, out var value) && value != null) + if (mapping.TryGetValue(handler, currentItem, out var value) && value is not null) { hasValue = true; break; @@ -303,7 +297,7 @@ private async Task WriteNewRowAsync( await writer.WriteAttributeStringAsync("", "r", "", rowNumber.ToString()).ConfigureAwait(false); // Check each column in this row for mapped cells - if (mapping.OptimizedBoundaries != null) + if (mapping.OptimizedBoundaries is not null) { for (int col = mapping.OptimizedBoundaries.MinColumn; col <= mapping.OptimizedBoundaries.MaxColumn; col++) { @@ -311,7 +305,7 @@ private async Task WriteNewRowAsync( if (mapping.TryGetHandler(rowNumber, col, out var handler)) { // Try to get the value - if (mapping.TryGetValue(handler, currentItem, out var value) && value != null) + 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); @@ -332,7 +326,7 @@ private async Task WriteMissingCellsAsync( { // Check if we have an optimized grid with mappings for this row - if (mapping.OptimizedBoundaries != null) + 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++) @@ -346,7 +340,7 @@ private async Task WriteMissingCellsAsync( { // 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 != null) + if (mapping.TryGetValue(handler, currentItem, out var value) && value is not null) { // Create cell reference var cellRef = ReferenceHelper.ConvertCoordinatesToCell(col, rowNumber); @@ -451,5 +445,4 @@ private static async Task CopyNodeAsync(XmlReader reader, XmlWriter writer) break; } } - } \ No newline at end of file diff --git a/src/MiniExcel.Core/Mapping/MappingWriter.cs b/src/MiniExcel.Core/Mapping/MappingWriter.cs index eec1817b..da19f024 100644 --- a/src/MiniExcel.Core/Mapping/MappingWriter.cs +++ b/src/MiniExcel.Core/Mapping/MappingWriter.cs @@ -6,11 +6,11 @@ internal static partial class MappingWriter [CreateSyncVersion] public static async Task SaveAsAsync(Stream stream, IEnumerable value, CompiledMapping mapping, CancellationToken cancellationToken = default) { - if (stream == null) + if (stream is null) throw new ArgumentNullException(nameof(stream)); - if (value == null) + if (value is null) throw new ArgumentNullException(nameof(value)); - if (mapping == null) + if (mapping is null) throw new ArgumentNullException(nameof(mapping)); return await SaveAsOptimizedAsync(stream, value, mapping, cancellationToken).ConfigureAwait(false); @@ -19,7 +19,7 @@ public static async Task SaveAsAsync(Stream stream, IEnumerable value, [CreateSyncVersion] private static async Task SaveAsOptimizedAsync(Stream stream, IEnumerable value, CompiledMapping mapping, CancellationToken cancellationToken = default) { - if (mapping.OptimizedCellGrid == null || mapping.OptimizedBoundaries == null) + if (mapping.OptimizedCellGrid is null || mapping.OptimizedBoundaries is null) throw new InvalidOperationException("SaveAsOptimizedAsync requires an optimized mapping"); var configuration = new OpenXmlConfiguration { FastMode = false }; diff --git a/src/MiniExcel.Core/OpenXml/MappedRow.cs b/src/MiniExcel.Core/OpenXml/MappedRow.cs index 5b4872ed..2989984c 100644 --- a/src/MiniExcel.Core/OpenXml/MappedRow.cs +++ b/src/MiniExcel.Core/OpenXml/MappedRow.cs @@ -9,16 +9,13 @@ internal struct MappedRow(int rowIndex) public void SetCell(int columnIndex, object? value) { - if (value == null) + if (value is null) return; // Lazy initialize cells array - if (_cells == null) - { - _cells = new object?[MaxColumns]; - } + _cells ??= new object?[MaxColumns]; - if (columnIndex >= 0 && columnIndex < MaxColumns) + if (columnIndex is >= 0 and < MaxColumns) { _cells[columnIndex] = value; } @@ -26,11 +23,11 @@ public void SetCell(int columnIndex, object? value) public object? GetCell(int columnIndex) { - if (_cells == null || columnIndex < 0 || columnIndex >= MaxColumns) + if (_cells is null || (columnIndex is < 0 or >= MaxColumns)) return null; return _cells[columnIndex]; } - public bool HasData => _cells != null; + 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 7c945ef0..a898d771 100644 --- a/src/MiniExcel.Core/OpenXml/OpenXmlReader.cs +++ b/src/MiniExcel.Core/OpenXml/OpenXmlReader.cs @@ -1206,7 +1206,7 @@ private async IAsyncEnumerable ReadMappedRowAsync( var cellValue = cellAndColumn.CellValue; columnIndex = cellAndColumn.ColumnIndex; - if (_config.FillMergedCells && mergeCells != null) + if (_config.FillMergedCells && mergeCells is not null) { if (mergeCells.MergesValues.ContainsKey(aR)) { diff --git a/tests/MiniExcel.Core.Tests/MiniExcelMappingCompilerTests.cs b/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingCompilerTests.cs similarity index 95% rename from tests/MiniExcel.Core.Tests/MiniExcelMappingCompilerTests.cs rename to tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingCompilerTests.cs index f6d47754..6c121c31 100644 --- a/tests/MiniExcel.Core.Tests/MiniExcelMappingCompilerTests.cs +++ b/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingCompilerTests.cs @@ -1,11 +1,6 @@ using MiniExcelLib.Core.Mapping; -using MiniExcelLib.Core.Mapping.Configuration; -using System; -using System.Collections.Generic; -using System.Linq; -using Xunit; -namespace MiniExcelLib.Tests +namespace MiniExcelLib.Tests.FluentMapping { /// /// Tests for the mapping compiler and optimization system. @@ -15,14 +10,14 @@ public class MiniExcelMappingCompilerTests { #region Test Models - public class SimpleEntity + private class SimpleEntity { public int Id { get; set; } public string Name { get; set; } = ""; public decimal Value { get; set; } } - public class ComplexEntity + private class ComplexEntity { public int Id { get; set; } public string Title { get; set; } = ""; diff --git a/tests/MiniExcel.Core.Tests/MiniExcelMappingTemplateTests.cs b/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTemplateTests.cs similarity index 94% rename from tests/MiniExcel.Core.Tests/MiniExcelMappingTemplateTests.cs rename to tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTemplateTests.cs index 5981cc7b..0619bc6b 100644 --- a/tests/MiniExcel.Core.Tests/MiniExcelMappingTemplateTests.cs +++ b/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTemplateTests.cs @@ -1,7 +1,7 @@ using MiniExcelLib.Core.Mapping; using MiniExcelLib.Tests.Common.Utils; -namespace MiniExcelLib.Tests; +namespace MiniExcelLib.Tests.FluentMapping; public class MiniExcelMappingTemplateTests { @@ -10,29 +10,30 @@ public class MiniExcelMappingTemplateTests private static DateTime ParseDateValue(object? value) { - if (value is double serialDate) - return DateTime.FromOADate(serialDate); - if (value is DateTime dt) - return dt; - return DateTime.Parse(value?.ToString() ?? ""); + return value switch + { + double serialDate => DateTime.FromOADate(serialDate), + DateTime dt => dt, + _ => DateTime.Parse(value?.ToString() ?? "") + }; } - - public class TestEntity + + private class TestEntity { public string Name { get; set; } = ""; public DateTime CreateDate { get; set; } public bool VIP { get; set; } public int Points { get; set; } } - - public class Department + + private class Department { public string Title { get; set; } = ""; - public List Managers { get; set; } = new(); - public List Employees { get; set; } = new(); + public List Managers { get; set; } = []; + public List Employees { get; set; } = []; } - - public class Person + + private class Person { public string Name { get; set; } = ""; public string Department { get; set; } = ""; diff --git a/tests/MiniExcel.Core.Tests/MiniExcelMappingTests.cs b/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTests.cs similarity index 93% rename from tests/MiniExcel.Core.Tests/MiniExcelMappingTests.cs rename to tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTests.cs index ba43e72b..c50cbd98 100644 --- a/tests/MiniExcel.Core.Tests/MiniExcelMappingTests.cs +++ b/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTests.cs @@ -1,14 +1,6 @@ -using MiniExcelLib.Core; using MiniExcelLib.Core.Mapping; -using MiniExcelLib.Core.Mapping.Configuration; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using Xunit; - -namespace MiniExcelLib.Tests + +namespace MiniExcelLib.Tests.FluentMapping { public class MiniExcelMappingTests { @@ -44,8 +36,8 @@ public class ComplexEntity public bool IsEnabled { get; set; } public string? Description { get; set; } public decimal Amount { get; set; } - public List Tags { get; set; } = new(); - public int[] Numbers { get; set; } = Array.Empty(); + public List Tags { get; set; } = []; + public int[] Numbers { get; set; } = []; } public class ComplexModel @@ -54,23 +46,23 @@ public class ComplexModel public string Title { get; set; } = ""; public DateTimeOffset CreatedAt { get; set; } public TimeSpan Duration { get; set; } - public byte[] BinaryData { get; set; } = Array.Empty(); + public byte[] BinaryData { get; set; } = []; public Uri? Website { get; set; } } public class Department { public string Name { get; set; } = ""; - public List Employees { get; set; } = new(); - public List PhoneNumbers { get; set; } = new(); - public string[] Tags { get; set; } = Array.Empty(); - public IEnumerable Projects { get; set; } = Enumerable.Empty(); + public List Employees { get; set; } = []; + public List PhoneNumbers { get; set; } = []; + public string[] Tags { get; set; } = []; + public IEnumerable Projects { get; set; } = []; } public class Company { public string Name { get; set; } = ""; - public List Departments { get; set; } = new(); + public List Departments { get; set; } = []; } public class TestModel @@ -84,7 +76,7 @@ public class Employee public string Name { get; set; } = ""; public string Position { get; set; } = ""; public decimal Salary { get; set; } - public List Skills { get; set; } = new(); + public List Skills { get; set; } = []; } public class Project @@ -93,7 +85,7 @@ public class Project public string Title { get; set; } = ""; public DateTime StartDate { get; set; } public decimal Budget { get; set; } - public List Tasks { get; set; } = new(); + public List Tasks { get; set; } = []; } public class ProjectTask @@ -107,7 +99,7 @@ public class Report { public string Title { get; set; } = ""; public DateTime GeneratedAt { get; set; } - public List Numbers { get; set; } = new(); + public List Numbers { get; set; } = []; public Dictionary Metrics { get; set; } = new(); } @@ -316,7 +308,7 @@ public async Task Collection_Vertical_Should_Write_And_Read_Correctly() { Id = 1, Name = "Test", - Tags = new List { "Tag1", "Tag2", "Tag3" } + Tags = ["Tag1", "Tag2", "Tag3"] } }; @@ -343,12 +335,12 @@ public async Task Collection_ComplexObjectsWithMapping_ShouldMapCorrectly() new Department { Name = "Engineering", - Employees = new List - { + Employees = + [ new Person { Name = "Alice", Age = 35, Email = "alice@example.com", Salary = 95000 }, new Person { Name = "Bob", Age = 28, Email = "bob@example.com", Salary = 75000 }, new Person { Name = "Charlie", Age = 24, Email = "charlie@example.com", Salary = 55000 } - } + ] } }; @@ -386,12 +378,12 @@ public async Task Collection_NestedCollections_ShouldMapCorrectly() Title = "New Feature", StartDate = new DateTime(2024, 1, 1), Budget = 100000, - Tasks = new List - { + Tasks = + [ new ProjectTask { Name = "Design", EstimatedHours = 40, IsCompleted = true }, new ProjectTask { Name = "Implementation", EstimatedHours = 120, IsCompleted = false }, new ProjectTask { Name = "Testing", EstimatedHours = 60, IsCompleted = false } - } + ] } } } @@ -441,12 +433,12 @@ public async Task Collection_MixedSimpleAndComplex_ShouldMapCorrectly() var department = new Department { Name = "Mixed Department", - PhoneNumbers = new List { "555-1111", "555-2222" }, - Employees = new List - { + PhoneNumbers = ["555-1111", "555-2222"], + Employees = + [ new Person { Name = "Dave", Age = 35, Email = "dave@example.com", Salary = 85000 }, new Person { Name = "Eve", Age = 29, Email = "eve@example.com", Salary = 75000 } - } + ] }; var departments = new[] { department }; @@ -686,7 +678,7 @@ public async Task Mapping_WithComplexTypes_ShouldHandleCorrectly() Title = "Complex Item", CreatedAt = DateTimeOffset.Now, Duration = TimeSpan.FromHours(2.5), - BinaryData = new byte[] { 1, 2, 3, 4, 5 }, + BinaryData = [1, 2, 3, 4, 5], Website = new Uri("https://example.com") } }; @@ -909,7 +901,7 @@ public async Task Empty_Collection_Should_Handle_Gracefully() var testData = new[] { - new ComplexEntity { Id = 1, Tags = new List() } // Empty collection + new ComplexEntity { Id = 1, Tags = [] } // Empty collection }; var exporter = MiniExcel.Exporters.GetMappingExporter(registry); diff --git a/tests/MiniExcel.Core.Tests/MiniExcelOpenXmlTests.cs b/tests/MiniExcel.Core.Tests/MiniExcelOpenXmlTests.cs index d6708f35..64bd1e82 100644 --- a/tests/MiniExcel.Core.Tests/MiniExcelOpenXmlTests.cs +++ b/tests/MiniExcel.Core.Tests/MiniExcelOpenXmlTests.cs @@ -1,8 +1,5 @@ -using System.Drawing; -using ClosedXML.Excel; +using ClosedXML.Excel; using ExcelDataReader; -using MiniExcelLib.Core.Enums; -using MiniExcelLib.Core.OpenXml.Styles; using MiniExcelLib.Core.OpenXml.Utils; using MiniExcelLib.Tests.Common.Utils; From 863c4a65ca6cf38fdc888eea29310b353f821e19 Mon Sep 17 00:00:00 2001 From: Corey Kaylor Date: Wed, 1 Oct 2025 11:07:04 -0500 Subject: [PATCH 08/16] Added mapping samples to readme --- README.md | 137 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/README.md b/README.md index 8825ee56..2d3451b4 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) @@ -1106,6 +1108,141 @@ public class Dto +### 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 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.SaveAsAsync(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 exporter = MiniExcel.Exporters.GetMappingExporter(registry); +await exporter.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"); + }); +}); +``` + + + #### 5. System.ComponentModel.DisplayNameAttribute = ExcelColumnName.excelColumnNameAttribute Since 1.24.0, system supports System.ComponentModel.DisplayNameAttribute = ExcelColumnName.excelColumnNameAttribute From 9816d52411d4a526f86014c67a0a0462e2f44553 Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Sat, 4 Oct 2025 17:32:18 +0200 Subject: [PATCH 09/16] Rebased and moved fluent mapping docs to new readme --- README-V2.md | 208 ++++++++++++++++++++++++++++++++++++++++++--------- README.md | 136 --------------------------------- 2 files changed, 174 insertions(+), 170 deletions(-) diff --git a/README-V2.md b/README-V2.md index 80ebc085..0ac072a3 100644 --- a/README-V2.md +++ b/README-V2.md @@ -230,6 +230,7 @@ You can find the benchmarks' results for the latest release [here](benchmarks/re - [Attributes and configuration](#docs-attributes) - [CSV specifics](#docs-csv) - [Other functionalities](#docs-other) +- [Fluent Cell Mapping](#docs-mapping) - [FAQ](#docs-faq) - [Limitations](#docs-limitations) @@ -647,29 +648,8 @@ When queried, the resource will be converted back to `byte[]`. If you don't need ![image](https://user-images.githubusercontent.com/12729184/153702334-c3b834f4-6ae4-4ddf-bd4e-e5005d5d8c6a.png) -#### 12. Merge same cells vertically -This functionality merges cells vertically between the tags `@merge` and `@endmerge`. -You can use `@mergelimit` to limit boundaries of merging cells vertically. - -```csharp -var templater = MiniExcel.Templaters.GetOpenXmlTemplater(); -templater.MergeSameCells(mergedFilePath, templatePath); -``` - -File content before and after merge without merge limit: - -Screenshot 2023-08-07 at 11 59 24 - -Screenshot 2023-08-07 at 11 59 57 - -File content before and after merge with merge limit: - -Screenshot 2023-08-08 at 18 21 00 - -Screenshot 2023-08-08 at 18 21 40 - -#### 13. Null values handling +#### 12. Null values handling By default, null values will be treated as empty strings when exporting: @@ -718,7 +698,7 @@ exporter.Export("test.xlsx", value, configuration: config); Both properties work with `null` and `DBNull` values. -#### 14. Freeze Panes +#### 13. Freeze Panes MiniExcel allows you to freeze both rows and columns in place: @@ -985,15 +965,15 @@ var value = new Dictionary() var templater = MiniExcel.Templaters.GetOpenXmlTemplater(); templater.ApplyTemplate(path, templatePath, value); ``` -- With `@group` tag and with `@header` tag +- Without `@group` tag Before: -![before_with_header](https://user-images.githubusercontent.com/38832863/218646717-21b9d57a-2be2-4e9a-801b-ae212231d2b4.PNG) +![without_group](https://user-images.githubusercontent.com/38832863/218646975-f52a68eb-e031-43b5-abaa-03b67c052d1a.PNG) After: -![after_with_header](https://user-images.githubusercontent.com/38832863/218646721-58a7a340-7004-4bc2-af24-cffcb2c20737.PNG) +![without_group_after](https://user-images.githubusercontent.com/38832863/218646974-4a3c0e07-7c66-4088-ad07-b4ad3695b7e1.PNG) - With `@group` tag and without `@header` tag @@ -1001,19 +981,19 @@ Before: ![before_without_header](https://user-images.githubusercontent.com/38832863/218646873-b12417fa-801b-4890-8e96-669ed3b43902.PNG) -After; +After: ![after_without_header](https://user-images.githubusercontent.com/38832863/218646872-622461ba-342e-49ee-834f-b91ad9c2dac3.PNG) -- Without `@group` tag +- With both `@group` and `@header` tags Before: -![without_group](https://user-images.githubusercontent.com/38832863/218646975-f52a68eb-e031-43b5-abaa-03b67c052d1a.PNG) +![before_with_header](https://user-images.githubusercontent.com/38832863/218646717-21b9d57a-2be2-4e9a-801b-ae212231d2b4.PNG) After: -![without_group_after](https://user-images.githubusercontent.com/38832863/218646974-4a3c0e07-7c66-4088-ad07-b4ad3695b7e1.PNG) +![after_with_header](https://user-images.githubusercontent.com/38832863/218646721-58a7a340-7004-4bc2-af24-cffcb2c20737.PNG) #### 7. If/ElseIf/Else Statements inside cell @@ -1043,7 +1023,31 @@ After: ![if_after](https://user-images.githubusercontent.com/38832863/235360609-869bb960-d63d-45ae-8d64-9e8b0d0ab658.PNG) -#### 8. DataTable as parameter + +#### 8. Merge same cells vertically + +This functionality merges cells vertically between the tags `@merge` and `@endmerge`. +You can use `@mergelimit` to limit boundaries of merging cells vertically. + +```csharp +var templater = MiniExcel.Templaters.GetOpenXmlTemplater(); +templater.MergeSameCells(mergedFilePath, templatePath); +``` + +File content before and after merge without merge limit: + +Screenshot 2023-08-07 at 11 59 24 + +Screenshot 2023-08-07 at 11 59 57 + +File content before and after merge with merge limit: + +Screenshot 2023-08-08 at 18 21 00 + +Screenshot 2023-08-08 at 18 21 40 + + +#### 9. DataTable as parameter ```csharp var managers = new DataTable(); @@ -1063,7 +1067,8 @@ var value = new Dictionary() var templater = MiniExcel.Templaters.GetOpenXmlTemplater(); templater.ApplyTemplate(path, templatePath, value); ``` -#### 9. Formulas + +#### 10. Formulas Prefix your formula with `$` and use `$enumrowstart` and `$enumrowend` to mark references to the enumerable start and end rows: ![image](docs/images/template-formulas-1.png) @@ -1081,7 +1086,7 @@ _Other examples_: | Range | `$=MAX(C{{$enumrowstart}}:C{{$enumrowend}}) - MIN(C{{$enumrowstart}}:C{{$enumrowend}})` | -#### 10. Checking template parameter key +#### 11. Checking template parameter key When a parameter key is missing it will be replaced with an empty string by default. You can change this behaviour to throw an exception by setting the `IgnoreTemplateParameterMissing` configuration property: @@ -1295,7 +1300,142 @@ var exporter = MiniExcel.Exporters.GetOpenXmlExporter(); exporter.Export(path, sheets, configuration: configuration); ``` -### CSV + +### Fluent Cell Mapping + +Since v2.0.0, MiniExcel supports a fluent API for precise cell-by-cell mapping, giving you complete control over Excel layout without relying on conventions or attributes. + +>⚠️ **Important:** Compile mappings only once during application startup! + +Mapping compilation is a one-time operation that generates optimized runtime code. Create a single `MappingRegistry` instance and configure all your mappings at startup. Reuse this registry throughout your application for optimal performance. + +#### 1. Basic Property Mapping + +Map properties to specific cells using the fluent configuration API: + +```csharp +// Configure once at application startup +var registry = new MappingRegistry(); +registry.Configure(cfg => +{ + cfg.Property(p => p.Name).ToCell("A1"); + cfg.Property(p => p.Age).ToCell("B1"); + cfg.Property(p => p.Email).ToCell("C1"); + cfg.Property(p => p.Salary).ToCell("D1").WithFormat("#,##0.00"); + cfg.Property(p => p.BirthDate).ToCell("E1").WithFormat("yyyy-MM-dd"); + cfg.ToWorksheet("Employees"); +}); + +var exporter = MiniExcel.Exporters.GetMappingExporter(registry); +await exporter.SaveAsAsync(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 exporter = MiniExcel.Exporters.GetMappingExporter(registry); +await exporter.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 2d3451b4..49e5024a 100644 --- a/README.md +++ b/README.md @@ -1107,142 +1107,6 @@ public class Dto ``` - -### 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 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.SaveAsAsync(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 exporter = MiniExcel.Exporters.GetMappingExporter(registry); -await exporter.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"); - }); -}); -``` - - - #### 5. System.ComponentModel.DisplayNameAttribute = ExcelColumnName.excelColumnNameAttribute Since 1.24.0, system supports System.ComponentModel.DisplayNameAttribute = ExcelColumnName.excelColumnNameAttribute From 08a742994a106ba71b51952353ec14e4a789ed83 Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Sat, 4 Oct 2025 18:23:13 +0200 Subject: [PATCH 10/16] Moved templating logic to MappingTemplater and renamed MappingExporter's SaveAsAsync method to ExportAsync for consistency --- .../BenchmarkSections/CreateExcelBenchmark.cs | 2 +- .../TemplateExcelBenchmark.cs | 6 +- src/MiniExcel.Core/Mapping/MappingExporter.cs | 73 +------------------ src/MiniExcel.Core/Mapping/MappingImporter.cs | 11 +-- .../Mapping/MappingTemplater.cs | 71 ++++++++++++++++++ src/MiniExcel.Core/MiniExcelProviders.cs | 1 + .../MiniExcelMappingTemplateTests.cs | 28 +++---- .../FluentMapping/MiniExcelMappingTests.cs | 48 ++++++------ 8 files changed, 121 insertions(+), 119 deletions(-) create mode 100644 src/MiniExcel.Core/Mapping/MappingTemplater.cs diff --git a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/CreateExcelBenchmark.cs b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/CreateExcelBenchmark.cs index 0abc304e..223ecdcc 100644 --- a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/CreateExcelBenchmark.cs +++ b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/CreateExcelBenchmark.cs @@ -54,7 +54,7 @@ public void MiniExcelCreateWithSimpleMappingTest() { using var path = AutoDeletingPath.Create(); using var stream = File.Create(path.FilePath); - _simpleMappingExporter.SaveAs(stream, GetValue()); + _simpleMappingExporter.Export(stream, GetValue()); } [Benchmark(Description = "ClosedXml Create Xlsx")] diff --git a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/TemplateExcelBenchmark.cs b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/TemplateExcelBenchmark.cs index 73d2f8b9..9606efdc 100644 --- a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/TemplateExcelBenchmark.cs +++ b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/TemplateExcelBenchmark.cs @@ -9,7 +9,7 @@ namespace MiniExcelLib.Benchmarks.BenchmarkSections; public class TemplateExcelBenchmark : BenchmarkBase { private OpenXmlTemplater _templater; - private MappingExporter _mappingExporter; + private MappingTemplater _mappingTemplater; private OpenXmlExporter _exporter; public class Employee @@ -30,7 +30,7 @@ public void Setup() config.Property(x => x.Name).ToCell("A2"); config.Property(x => x.Department).ToCell("B2"); }); - _mappingExporter = MiniExcel.Exporters.GetMappingExporter(registry); + _mappingTemplater = MiniExcel.Templaters.GetMappingTemplater(registry); } [Benchmark(Description = "MiniExcel Template Generate")] @@ -94,6 +94,6 @@ public void MiniExcel_Mapping_Template_Generate_Test() Department = "HR" }); - _mappingExporter.ApplyTemplate(outputPath.FilePath, templatePath.FilePath, employees); + _mappingTemplater.ApplyTemplate(outputPath.FilePath, templatePath.FilePath, employees); } } \ No newline at end of file diff --git a/src/MiniExcel.Core/Mapping/MappingExporter.cs b/src/MiniExcel.Core/Mapping/MappingExporter.cs index 9dc25b45..68740bf3 100644 --- a/src/MiniExcel.Core/Mapping/MappingExporter.cs +++ b/src/MiniExcel.Core/Mapping/MappingExporter.cs @@ -1,22 +1,16 @@ namespace MiniExcelLib.Core.Mapping; -public partial class MappingExporter +public sealed partial class MappingExporter() { - private readonly MappingRegistry _registry; + private readonly MappingRegistry _registry = new(); - public MappingExporter() - { - _registry = new MappingRegistry(); - } - - public MappingExporter(MappingRegistry registry) + public MappingExporter(MappingRegistry registry) : this() { _registry = registry ?? throw new ArgumentNullException(nameof(registry)); } [CreateSyncVersion] - public async Task SaveAsAsync(Stream? stream, IEnumerable? values, CancellationToken cancellationToken = default) - where T : class + public async Task ExportAsync(Stream? stream, IEnumerable? values, CancellationToken cancellationToken = default) where T : class { if (stream is null) throw new ArgumentNullException(nameof(stream)); @@ -30,63 +24,4 @@ public async Task SaveAsAsync(Stream? stream, IEnumerable? values, Cancell await MappingWriter.SaveAsAsync(stream, values, mapping, cancellationToken).ConfigureAwait(false); } - - [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/Mapping/MappingImporter.cs b/src/MiniExcel.Core/Mapping/MappingImporter.cs index 2f1d91cb..b64d70ff 100644 --- a/src/MiniExcel.Core/Mapping/MappingImporter.cs +++ b/src/MiniExcel.Core/Mapping/MappingImporter.cs @@ -1,15 +1,10 @@ namespace MiniExcelLib.Core.Mapping; -public partial class MappingImporter +public sealed partial class MappingImporter() { - private readonly MappingRegistry _registry; + private readonly MappingRegistry _registry = new(); - public MappingImporter() - { - _registry = new MappingRegistry(); - } - - public MappingImporter(MappingRegistry registry) + public MappingImporter(MappingRegistry registry) : this() { _registry = registry ?? throw new ArgumentNullException(nameof(registry)); } diff --git a/src/MiniExcel.Core/Mapping/MappingTemplater.cs b/src/MiniExcel.Core/Mapping/MappingTemplater.cs new file mode 100644 index 00000000..1ece7d7f --- /dev/null +++ b/src/MiniExcel.Core/Mapping/MappingTemplater.cs @@ -0,0 +1,71 @@ +namespace MiniExcelLib.Core.Mapping; + +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/MiniExcelProviders.cs b/src/MiniExcel.Core/MiniExcelProviders.cs index 7f8cdeb3..4903f49f 100644 --- a/src/MiniExcel.Core/MiniExcelProviders.cs +++ b/src/MiniExcel.Core/MiniExcelProviders.cs @@ -23,4 +23,5 @@ public sealed class MiniExcelTemplaterProvider internal MiniExcelTemplaterProvider() { } public OpenXmlTemplater GetOpenXmlTemplater() => new(); + public MappingTemplater GetMappingTemplater(MappingRegistry registry) => new(registry); } diff --git a/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTemplateTests.cs b/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTemplateTests.cs index 0619bc6b..557f9895 100644 --- a/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTemplateTests.cs +++ b/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTemplateTests.cs @@ -69,8 +69,8 @@ public async Task BasicTemplateTest() }; using var outputPath = AutoDeletingPath.Create(); - var exporter = MiniExcel.Exporters.GetMappingExporter(registry); - await exporter.ApplyTemplateAsync(outputPath.ToString(), templatePath.ToString(), [data]); + var templater = MiniExcel.Templaters.GetMappingTemplater(registry); + await templater.ApplyTemplateAsync(outputPath.ToString(), templatePath.ToString(), [data]); var rows = _importer.Query(outputPath.ToString(), useHeaderRow: false).ToList(); @@ -128,8 +128,8 @@ public async Task StreamOverloadTest() using (var outputStream = File.Create(outputPath.ToString())) using (var templateStream = File.OpenRead(templatePath.ToString())) { - var exporter = MiniExcel.Exporters.GetMappingExporter(registry); - await exporter.ApplyTemplateAsync(outputStream, templateStream, [data]); + var templater = MiniExcel.Templaters.GetMappingTemplater(registry); + await templater.ApplyTemplateAsync(outputStream, templateStream, [data]); } var rows = _importer.Query(outputPath.ToString(), useHeaderRow: false).ToList(); @@ -169,8 +169,8 @@ public async Task ByteArrayOverloadTest() using var outputPath = AutoDeletingPath.Create(); using (var outputStream = File.Create(outputPath.ToString())) { - var exporter = MiniExcel.Exporters.GetMappingExporter(registry); - await exporter.ApplyTemplateAsync(outputStream, templateBytes, [data]); + var templater = MiniExcel.Templaters.GetMappingTemplater(registry); + await templater.ApplyTemplateAsync(outputStream, templateBytes, [data]); } var rows = _importer.Query(outputPath.ToString(), useHeaderRow: false).ToList(); @@ -234,8 +234,8 @@ public async Task CollectionTemplateTest() }; using var outputPath = AutoDeletingPath.Create(); - var exporter = MiniExcel.Exporters.GetMappingExporter(registry); - await exporter.ApplyTemplateAsync(outputPath.ToString(), templatePath.ToString(), [dept]); + var templater = MiniExcel.Templaters.GetMappingTemplater(registry); + await templater.ApplyTemplateAsync(outputPath.ToString(), templatePath.ToString(), [dept]); var rows = _importer.Query(outputPath.ToString(), useHeaderRow: false).ToList(); @@ -282,8 +282,8 @@ public async Task EmptyDataTest() }); using var outputPath = AutoDeletingPath.Create(); - var exporter = MiniExcel.Exporters.GetMappingExporter(registry); - await exporter.ApplyTemplateAsync(outputPath.ToString(), templatePath.ToString(), Array.Empty()); + var templater = MiniExcel.Templaters.GetMappingTemplater(registry); + await templater.ApplyTemplateAsync(outputPath.ToString(), templatePath.ToString(), Array.Empty()); var rows = _importer.Query(outputPath.ToString(), useHeaderRow: false).ToList(); Assert.Equal(3, rows.Count); // Column headers + our headers + empty data row @@ -326,8 +326,8 @@ public async Task NullValuesTest() // Apply template using var outputPath = AutoDeletingPath.Create(); - var exporter = MiniExcel.Exporters.GetMappingExporter(registry); - await exporter.ApplyTemplateAsync(outputPath.ToString(), templatePath.ToString(), [data]); + var templater = MiniExcel.Templaters.GetMappingTemplater(registry); + await templater.ApplyTemplateAsync(outputPath.ToString(), templatePath.ToString(), [data]); // Verify null handling // Verify - use useHeaderRow=false since we want to see all rows @@ -371,8 +371,8 @@ public async Task MultipleItemsTest() // Apply template using var outputPath = AutoDeletingPath.Create(); - var exporter = MiniExcel.Exporters.GetMappingExporter(registry); - await exporter.ApplyTemplateAsync(outputPath.ToString(), templatePath.ToString(), data); + var templater = MiniExcel.Templaters.GetMappingTemplater(registry); + await templater.ApplyTemplateAsync(outputPath.ToString(), templatePath.ToString(), data); // Verify - should only update first item since mapping is for specific cells // Verify - use useHeaderRow=false since we want to see all rows diff --git a/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTests.cs b/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTests.cs index c50cbd98..63380556 100644 --- a/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTests.cs +++ b/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTests.cs @@ -136,7 +136,7 @@ public async Task MappingReader_ReadBasicData_Success() using var stream = new MemoryStream(); var exporter = MiniExcel.Exporters.GetMappingExporter(registry); - await exporter.SaveAsAsync(stream, testData); + await exporter.ExportAsync(stream, testData); stream.Position = 0; // Act @@ -175,7 +175,7 @@ public async Task SaveAs_WithBasicMapping_ShouldGenerateCorrectFile() // Act & Assert using var stream = new MemoryStream(); - await exporter.SaveAsAsync(stream, people); + await exporter.ExportAsync(stream, people); Assert.True(stream.Length > 0); } @@ -201,7 +201,7 @@ public void SaveAs_WithBasicMapping_SyncVersion_ShouldGenerateCorrectFile() // Act & Assert using var stream = new MemoryStream(); - exporter.SaveAs(stream, people); + exporter.Export(stream, people); Assert.True(stream.Length > 0); } @@ -230,7 +230,7 @@ public async Task Query_WithBasicMapping_ShouldReadDataCorrectly() // Act using var stream = new MemoryStream(); - await exporter.SaveAsAsync(stream, testData); + await exporter.ExportAsync(stream, testData); stream.Position = 0; var results = importer.Query(stream).ToList(); @@ -316,7 +316,7 @@ public async Task Collection_Vertical_Should_Write_And_Read_Correctly() var importer = MiniExcel.Importers.GetMappingImporter(registry); using var stream = new MemoryStream(); - await exporter.SaveAsAsync(stream, testData); + await exporter.ExportAsync(stream, testData); stream.Position = 0; var results = importer.Query(stream).ToList(); @@ -355,7 +355,7 @@ public async Task Collection_ComplexObjectsWithMapping_ShouldMapCorrectly() // Act using var stream = new MemoryStream(); - await exporter.SaveAsAsync(stream, departments); + await exporter.ExportAsync(stream, departments); // Assert Assert.True(stream.Length > 0); @@ -400,7 +400,7 @@ public async Task Collection_NestedCollections_ShouldMapCorrectly() // Act using var stream = new MemoryStream(); - await exporter.SaveAsAsync(stream, departments); + await exporter.ExportAsync(stream, departments); // Assert Assert.True(stream.Length > 0); @@ -455,7 +455,7 @@ public async Task Collection_MixedSimpleAndComplex_ShouldMapCorrectly() // Act using var stream = new MemoryStream(); - await exporter.SaveAsAsync(stream, departments); + await exporter.ExportAsync(stream, departments); // Assert Assert.True(stream.Length > 0); @@ -486,7 +486,7 @@ public async Task Formula_Properties_Should_Be_Handled_Correctly() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.SaveAsAsync(stream, testData); + await exporter.ExportAsync(stream, testData); Assert.True(stream.Length > 0); } @@ -515,7 +515,7 @@ public async Task Format_Properties_Should_Apply_Formatting() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.SaveAsAsync(stream, testData); + await exporter.ExportAsync(stream, testData); Assert.True(stream.Length > 0); } @@ -544,7 +544,7 @@ public async Task Mapping_WithComplexCellAddresses_ShouldMapCorrectly() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.SaveAsAsync(stream, products); + await exporter.ExportAsync(stream, products); // Verify the file was created Assert.True(stream.Length > 0); @@ -582,7 +582,7 @@ public async Task Mapping_WithNumericFormats_ShouldApplyCorrectly() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.SaveAsAsync(stream, products); + await exporter.ExportAsync(stream, products); Assert.True(stream.Length > 0); } @@ -611,7 +611,7 @@ public async Task Mapping_WithDateFormats_ShouldApplyCorrectly() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.SaveAsAsync(stream, products); + await exporter.ExportAsync(stream, products); Assert.True(stream.Length > 0); } @@ -635,7 +635,7 @@ public async Task Mapping_WithBooleanValues_ShouldMapCorrectly() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.SaveAsAsync(stream, products); + await exporter.ExportAsync(stream, products); Assert.True(stream.Length > 0); } @@ -661,7 +661,7 @@ public async Task Mapping_WithMultipleRowsToSameCells_ShouldOverwrite() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.SaveAsAsync(stream, products); + await exporter.ExportAsync(stream, products); // The file should contain only the last item's data Assert.True(stream.Length > 0); @@ -696,7 +696,7 @@ public async Task Mapping_WithComplexTypes_ShouldHandleCorrectly() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.SaveAsAsync(stream, items); + await exporter.ExportAsync(stream, items); Assert.True(stream.Length > 0); } @@ -729,7 +729,7 @@ public async Task Mapping_WithMultipleConfigurations_ShouldUseLast() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.SaveAsAsync(stream, products); + await exporter.ExportAsync(stream, products); Assert.True(stream.Length > 0); } @@ -772,7 +772,7 @@ public async Task Mapping_WithEnumerableTypes_ShouldHandleCorrectly() var array = new[] { new Product { Name = "Array", Price = 10 } }; using (var stream = new MemoryStream()) { - await exporter.SaveAsAsync(stream, array); + await exporter.ExportAsync(stream, array); Assert.True(stream.Length > 0); } @@ -780,7 +780,7 @@ public async Task Mapping_WithEnumerableTypes_ShouldHandleCorrectly() var list = new List { new Product { Name = "List", Price = 20 } }; using (var stream = new MemoryStream()) { - await exporter.SaveAsAsync(stream, list); + await exporter.ExportAsync(stream, list); Assert.True(stream.Length > 0); } @@ -788,7 +788,7 @@ public async Task Mapping_WithEnumerableTypes_ShouldHandleCorrectly() IEnumerable enumerable = list; using (var stream = new MemoryStream()) { - await exporter.SaveAsAsync(stream, enumerable); + await exporter.ExportAsync(stream, enumerable); Assert.True(stream.Length > 0); } } @@ -855,7 +855,7 @@ public async Task Mapping_WithSaveToFile_ShouldCreateFile() { using (var stream = File.Create(filePath)) { - await exporter.SaveAsAsync(stream, products); + await exporter.ExportAsync(stream, products); } // Verify file exists and has content @@ -907,7 +907,7 @@ public async Task Empty_Collection_Should_Handle_Gracefully() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.SaveAsAsync(stream, testData); + await exporter.ExportAsync(stream, testData); Assert.True(stream.Length > 0); } @@ -935,7 +935,7 @@ public async Task Null_Values_Should_Handle_Gracefully() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.SaveAsAsync(stream, testData); + await exporter.ExportAsync(stream, testData); Assert.True(stream.Length > 0); } @@ -988,7 +988,7 @@ public async Task Large_Dataset_Should_Stream_Efficiently() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.SaveAsAsync(stream, testData); + await exporter.ExportAsync(stream, testData); // Should complete without OutOfMemory Assert.True(stream.Length > 0); From 9752a1c04b5a2bb129e8175827a09fab625886ea Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Sat, 4 Oct 2025 20:55:20 +0200 Subject: [PATCH 11/16] Added overload for exporting by path instead of stream --- README-V2.md | 6 +++--- src/MiniExcel.Core/Mapping/MappingExporter.cs | 20 ++++++++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/README-V2.md b/README-V2.md index 0ac072a3..ace49a81 100644 --- a/README-V2.md +++ b/README-V2.md @@ -1327,7 +1327,7 @@ registry.Configure(cfg => }); var exporter = MiniExcel.Exporters.GetMappingExporter(registry); -await exporter.SaveAsAsync(stream, people); +await exporter.ExportAsync(stream, people); ``` #### 2. Reading with Fluent Mappings @@ -1411,8 +1411,8 @@ var data = new TestEntity Points = 123 }; -var exporter = MiniExcel.Exporters.GetMappingExporter(registry); -await exporter.ApplyTemplateAsync(outputPath, templatePath, new[] { data }); +var termplater = MiniExcel.Templaters.GetMappingExporter(registry); +await termplater.ApplyTemplateAsync(outputPath, templatePath, new[] { data }); ``` #### 6. Advanced: Nested Collections with Item Mapping diff --git a/src/MiniExcel.Core/Mapping/MappingExporter.cs b/src/MiniExcel.Core/Mapping/MappingExporter.cs index 68740bf3..6b38c52e 100644 --- a/src/MiniExcel.Core/Mapping/MappingExporter.cs +++ b/src/MiniExcel.Core/Mapping/MappingExporter.cs @@ -1,14 +1,28 @@ namespace MiniExcelLib.Core.Mapping; -public sealed partial class MappingExporter() +public sealed partial class MappingExporter { - private readonly MappingRegistry _registry = new(); + private readonly MappingRegistry _registry; - public MappingExporter(MappingRegistry registry) : this() + 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 { From 0952b2e09c416887f862d25ec52afe294eb64887 Mon Sep 17 00:00:00 2001 From: Corey Kaylor Date: Wed, 8 Oct 2025 11:52:44 -0500 Subject: [PATCH 12/16] Fixed issue with nested collection mapping and improved test assertions --- .../Helpers/CollectionAccessor.cs | 29 +- .../Helpers/MappingMetadataExtractor.cs | 298 ++++++++++++------ .../Mapping/MappingCellStream.cs | 123 ++++---- src/MiniExcel.Core/Mapping/MappingCompiler.cs | 167 +++++++++- src/MiniExcel.Core/Mapping/MappingReader.cs | 30 +- src/MiniExcel.Core/Mapping/MappingRegistry.cs | 33 ++ .../Mapping/NestedMappingInfo.cs | 23 ++ .../FluentMapping/MiniExcelMappingTests.cs | 279 ++++++++++++++-- 8 files changed, 782 insertions(+), 200 deletions(-) diff --git a/src/MiniExcel.Core/Helpers/CollectionAccessor.cs b/src/MiniExcel.Core/Helpers/CollectionAccessor.cs index a6d9d280..20b198dd 100644 --- a/src/MiniExcel.Core/Helpers/CollectionAccessor.cs +++ b/src/MiniExcel.Core/Helpers/CollectionAccessor.cs @@ -1,4 +1,6 @@ -namespace MiniExcelLib.Core.Helpers; +using System.Linq.Expressions; + +namespace MiniExcelLib.Core.Helpers; /// /// Optimized collection access utilities to reduce code duplication across mapping components. @@ -56,10 +58,27 @@ public static object FinalizeCollection(IList list, Type targetType, Type itemTy /// /// The type to create instances of /// A factory function that creates new instances - public static Func CreateItemFactory(Type itemType) - { - return () => itemType.IsValueType ? Activator.CreateInstance(itemType) : null; - } + 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. diff --git a/src/MiniExcel.Core/Helpers/MappingMetadataExtractor.cs b/src/MiniExcel.Core/Helpers/MappingMetadataExtractor.cs index 8fd319fa..0d82eea9 100644 --- a/src/MiniExcel.Core/Helpers/MappingMetadataExtractor.cs +++ b/src/MiniExcel.Core/Helpers/MappingMetadataExtractor.cs @@ -1,106 +1,194 @@ -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 -{ - /// - /// 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); - nestedInfo.Properties = propertyList; - - 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) - { - 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 (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; - } - - /// - /// 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); - } +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 +{ + /// + /// 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 IEnumerable collectionMappings) + { + 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 readonly MethodInfo? CreateTypedSetterMethod = typeof(ConversionHelper) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .FirstOrDefault(m => m.Name == nameof(ConversionHelper.CreateTypedPropertySetter) && m.IsGenericMethodDefinition); + + 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 ??= (_, _) => { }; + + 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, new object[] { 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/Mapping/MappingCellStream.cs b/src/MiniExcel.Core/Mapping/MappingCellStream.cs index 22a623dd..38f1a58c 100644 --- a/src/MiniExcel.Core/Mapping/MappingCellStream.cs +++ b/src/MiniExcel.Core/Mapping/MappingCellStream.cs @@ -28,8 +28,7 @@ internal struct MappingCellEnumerator private bool _isComplete; private readonly object _emptyCell; private int _maxCollectionRows; - private int _currentCollectionRow; - private object?[][]? _currentCollectionArrays; + private int _currentCollectionRow; public MappingCellEnumerator(IEnumerator itemEnumerator, CompiledMapping mapping, string[] columnLetters) { @@ -45,9 +44,8 @@ public MappingCellEnumerator(IEnumerator itemEnumerator, CompiledMapping m _hasStartedData = false; _isComplete = false; _emptyCell = string.Empty; - _maxCollectionRows = 0; - _currentCollectionRow = 0; - _currentCollectionArrays = null; + _maxCollectionRows = 0; + _currentCollectionRow = 0; Current = default; } @@ -103,11 +101,10 @@ public bool MoveNext() // Process current item's cells if (_currentItem is not null) { - // Cache collections as arrays when we start processing an item + // Cache collection metrics when we start processing an item if (_currentColumnIndex == 0 && _currentCollectionRow == 0 && _mapping.Collections.Count > 0) { _maxCollectionRows = 0; - _currentCollectionArrays = new object?[_mapping.Collections.Count][]; for (var i = 0; i < _mapping.Collections.Count; i++) { @@ -115,19 +112,62 @@ public bool MoveNext() var collectionData = coll.Getter(_currentItem); if (collectionData is not null) { - // Convert to array once - this is the only enumeration - var items = collectionData.Cast().ToList(); - _currentCollectionArrays[i] = items.ToArray(); - - // For vertical collections, we need rows from StartCellRow - var neededRows = coll.StartCellRow + items.Count - _currentRowIndex; + // Convert to a list once - this is the only enumeration + var items = collectionData.Cast().ToList(); + + // Resolve nested mapping info if available + NestedMappingInfo? nestedInfo = null; + nestedInfo = _mapping.NestedMappings is not null && _mapping.NestedMappings.TryGetValue(i, out var precompiledNested) + ? precompiledNested + : null; + + // Calculate the furthest row this collection (including nested collections) needs + var collectionMaxRow = coll.StartCellRow - 1; + + for (var itemIndex = 0; itemIndex < items.Count; itemIndex++) + { + var item = items[itemIndex]; + if (item is null) + continue; + + var itemRow = coll.StartCellRow + itemIndex * (1 + coll.RowSpacing); + if (itemRow > collectionMaxRow) + collectionMaxRow = itemRow; + + if (nestedInfo?.Collections is null || nestedInfo.Collections.Count == 0) + continue; + + foreach (var nested in nestedInfo.Collections.Values) + { + var nestedData = nested.Getter(item); + if (nestedData is not IEnumerable nestedEnumerable) + continue; + + var nestedIndex = 0; + foreach (var _ in nestedEnumerable) + { + var nestedRow = nested.StartRow + + itemIndex * (1 + coll.RowSpacing) + + nestedIndex * (1 + nested.RowSpacing); + + if (nestedRow > collectionMaxRow) + collectionMaxRow = nestedRow; + + nestedIndex++; + } + } + } + + var neededRows = collectionMaxRow - _currentRowIndex + 1; if (neededRows > _maxCollectionRows) + { _maxCollectionRows = neededRows; + } } - else - { - _currentCollectionArrays[i] = []; - } + else + { + continue; + } } } @@ -139,42 +179,19 @@ public bool MoveNext() object? cellValue = _emptyCell; - // Use the optimized grid for fast lookup - if (_mapping.TryGetCellValue(_currentRowIndex, columnNumber, _currentItem, out var value)) - { - cellValue = value ?? _emptyCell; - - // Apply formatting if needed - if (value is IFormattable formattable && - _mapping.TryGetHandler(_currentRowIndex, columnNumber, out var handler) && - !string.IsNullOrEmpty(handler.Format)) - { - cellValue = formattable.ToString(handler.Format, null); - } - } - else if (_currentCollectionArrays is not null) - { - // Fallback for collections that might not be in the grid yet - // This handles dynamic collection expansion - for (var collIndex = 0; collIndex < _mapping.Collections.Count; collIndex++) - { - var coll = _mapping.Collections[collIndex]; - if (coll.StartCellColumn == columnNumber) - { - // This is a collection column - check if this row has a collection item - var collectionRowOffset = _currentRowIndex - coll.StartCellRow; - if (collectionRowOffset >= 0) - { - var collectionArray = _currentCollectionArrays[collIndex]; - if (collectionRowOffset < collectionArray.Length) - { - cellValue = collectionArray[collectionRowOffset] ?? _emptyCell; - } - } - break; - } - } - } + // 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++; diff --git a/src/MiniExcel.Core/Mapping/MappingCompiler.cs b/src/MiniExcel.Core/Mapping/MappingCompiler.cs index 287ef934..e3c424c9 100644 --- a/src/MiniExcel.Core/Mapping/MappingCompiler.cs +++ b/src/MiniExcel.Core/Mapping/MappingCompiler.cs @@ -295,7 +295,7 @@ private static (int minRow, int maxRow, int minCol, int maxCol) CalculateCollect var nestedInfo = MappingMetadataExtractor.ExtractNestedMappingInfo(nestedMapping, collection.ItemType); if (nestedInfo is { Properties.Count: > 0 }) { - maxCol = nestedInfo.Properties.Max(p => p.ColumnIndex); + maxCol = GetMaxColumnIndex(nestedInfo, maxCol); } return (startRow, startRow + verticalHeight, startCol, maxCol); @@ -577,6 +577,8 @@ private static void MarkVerticalComplexCollectionCells(OptimizedCellHandler[,] g 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) { @@ -584,6 +586,7 @@ private static void MarkVerticalComplexCollectionCells(OptimizedCellHandler[,] g { Type = CellHandlerType.CollectionItem, ValueExtractor = CreateNestedPropertyExtractor(collection, itemIndex, prop.Getter), + ValueSetter = prop.Setter, CollectionIndex = collectionIndex, CollectionItemOffset = itemIndex, PropertyName = prop.PropertyName, @@ -593,6 +596,62 @@ private static void MarkVerticalComplexCollectionCells(OptimizedCellHandler[,] g } } } + + 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; + + var 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 (nextCollectionStartRow.HasValue && nestedAbsoluteRow >= nextCollectionStartRow.Value) + { + 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 + }; + } + } + } } } @@ -607,9 +666,115 @@ private static void MarkVerticalComplexCollectionCells(OptimizedCellHandler[,] g 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; + + var collection = collectionInfo.Getter(parent); + IList list; + + if (collection is IList existingList) + { + list = existingList; + } + else if (collection is IEnumerable enumerable) + { + list = collectionInfo.ListFactory(); + foreach (var item in enumerable) + { + 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(); + if (nestedItem is null) + throw new InvalidOperationException($"Collection item factory returned null for type '{collectionInfo.ItemType}'. Ensure it has an accessible parameterless constructor."); + + list[nestedOffset] = nestedItem; + } + + 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/Mapping/MappingReader.cs b/src/MiniExcel.Core/Mapping/MappingReader.cs index 36c8efb7..49a1e62a 100644 --- a/src/MiniExcel.Core/Mapping/MappingReader.cs +++ b/src/MiniExcel.Core/Mapping/MappingReader.cs @@ -303,6 +303,11 @@ private static void ProcessCellValue(OptimizedCellHandler handler, object? value 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) { @@ -324,19 +329,20 @@ private static void ProcessComplexCollectionItem(IList collection, OptimizedCell var item = collection[handler.CollectionItemOffset]; if (item is null) { - // Use precompiled factory for creating the item - if (mapping.OptimizedCollectionHelpers is null || - handler.CollectionIndex < 0 || + 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; + + 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 @@ -348,8 +354,9 @@ private static void ProcessComplexCollectionItem(IList collection, OptimizedCell { // 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 && item is not null) + if (nestedProp?.Setter is not null) { + handler.ValueSetter = nestedProp.Setter; nestedProp.Setter(item, value); return; } @@ -360,6 +367,17 @@ private static void ProcessComplexCollectionItem(IList collection, OptimizedCell "This indicates the mapping was not properly optimized. Ensure the type was mapped in the MappingRegistry."); } } + + private static bool HasMeaningfulValue(object? value) + { + if (value is null) + return false; + + if (value is string str) + return !string.IsNullOrWhiteSpace(str); + + return true; + } private static void FinalizeCollections(T item, CompiledMapping mapping, Dictionary collections) { diff --git a/src/MiniExcel.Core/Mapping/MappingRegistry.cs b/src/MiniExcel.Core/Mapping/MappingRegistry.cs index b58fdc20..c9813519 100644 --- a/src/MiniExcel.Core/Mapping/MappingRegistry.cs +++ b/src/MiniExcel.Core/Mapping/MappingRegistry.cs @@ -22,6 +22,8 @@ public void Configure(Action>? configure) var config = new MappingConfiguration(); configure(config); + CompileNestedMappings(config); + var compiledMapping = MappingCompiler.Compile(config, this); _compiledMappings[typeof(T)] = compiledMapping; } @@ -64,4 +66,35 @@ public bool HasMapping() : null; } } + + private void CompileNestedMappings(MappingConfiguration configuration) + { + foreach (var collection in configuration.CollectionMappings) + { + if (collection.ItemConfiguration is null || collection.ItemType is null) + continue; + + CompileNestedMappingInternal(collection.ItemType, collection.ItemConfiguration); + } + } + + private void CompileNestedMappingInternal(Type itemType, object itemConfiguration) + { + var method = typeof(MappingRegistry) + .GetMethod(nameof(CompileNestedMapping), BindingFlags.Instance | BindingFlags.NonPublic)? + .MakeGenericMethod(itemType); + + method?.Invoke(this, new[] { itemConfiguration }); + } + + private void CompileNestedMapping(MappingConfiguration configuration) + { + CompileNestedMappings(configuration); + + 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/Mapping/NestedMappingInfo.cs b/src/MiniExcel.Core/Mapping/NestedMappingInfo.cs index 5d1089bd..b5fadad6 100644 --- a/src/MiniExcel.Core/Mapping/NestedMappingInfo.cs +++ b/src/MiniExcel.Core/Mapping/NestedMappingInfo.cs @@ -11,6 +11,11 @@ internal class NestedMappingInfo /// 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. /// @@ -51,4 +56,22 @@ internal class NestedPropertyInfo /// 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/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTests.cs b/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTests.cs index 63380556..92fc1beb 100644 --- a/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTests.cs +++ b/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTests.cs @@ -1,3 +1,4 @@ +using System.Reflection; using MiniExcelLib.Core.Mapping; namespace MiniExcelLib.Tests.FluentMapping @@ -11,6 +12,7 @@ public class Person public string Name { get; set; } = ""; public int Age { get; set; } public string Email { get; set; } = ""; + public string Department { get; set; } = ""; public DateTime BirthDate { get; set; } public decimal Salary { get; set; } } @@ -53,6 +55,7 @@ public class ComplexModel public class Department { public string Name { get; set; } = ""; + public List Managers { get; set; } = []; public List Employees { get; set; } = []; public List PhoneNumbers { get; set; } = []; public string[] Tags { get; set; } = []; @@ -136,7 +139,7 @@ public async Task MappingReader_ReadBasicData_Success() using var stream = new MemoryStream(); var exporter = MiniExcel.Exporters.GetMappingExporter(registry); - await exporter.ExportAsync(stream, testData); + await exporter.ExportAsync(stream, testData); stream.Position = 0; // Act @@ -175,7 +178,7 @@ public async Task SaveAs_WithBasicMapping_ShouldGenerateCorrectFile() // Act & Assert using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, people); + await exporter.ExportAsync(stream, people); Assert.True(stream.Length > 0); } @@ -201,7 +204,7 @@ public void SaveAs_WithBasicMapping_SyncVersion_ShouldGenerateCorrectFile() // Act & Assert using var stream = new MemoryStream(); - exporter.Export(stream, people); + exporter.Export(stream, people); Assert.True(stream.Length > 0); } @@ -230,7 +233,7 @@ public async Task Query_WithBasicMapping_ShouldReadDataCorrectly() // Act using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, testData); + await exporter.ExportAsync(stream, testData); stream.Position = 0; var results = importer.Query(stream).ToList(); @@ -316,7 +319,7 @@ public async Task Collection_Vertical_Should_Write_And_Read_Correctly() var importer = MiniExcel.Importers.GetMappingImporter(registry); using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, testData); + await exporter.ExportAsync(stream, testData); stream.Position = 0; var results = importer.Query(stream).ToList(); @@ -348,17 +351,200 @@ public async Task Collection_ComplexObjectsWithMapping_ShouldMapCorrectly() registry.Configure(cfg => { cfg.Property(d => d.Name).ToCell("A1"); - cfg.Collection(d => d.Employees).StartAt("A3"); + cfg.Collection(d => d.Employees) + .StartAt("A3") + .WithItemMapping(empCfg => + { + empCfg.Property(p => p.Name).ToCell("A3"); + empCfg.Property(p => p.Age).ToCell("B3"); + empCfg.Property(p => p.Email).ToCell("C3"); + }); }); var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + var importer = MiniExcel.Importers.GetMappingImporter(registry); + + var compiled = registry.GetMapping(); + var boundaries = compiled.OptimizedBoundaries!; + var grid = compiled.OptimizedCellGrid!; + for (var r = 0; r < grid.GetLength(0); r++) + { + for (var c = 0; c < grid.GetLength(1); c++) + { + var handler = grid[r, c]; + if (handler.Type != CellHandlerType.Empty) + { + } + } + } // Act using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, departments); - + await exporter.ExportAsync(stream, departments); + stream.Position = 0; + + var results = importer.Query(stream).ToList(); + // Assert - Assert.True(stream.Length > 0); + Assert.Single(results); + var department = results[0]; + Assert.Equal("Engineering", department.Name); + Assert.Equal(3, department.Employees.Count); + Assert.Equal("Alice", department.Employees[0].Name); + Assert.Equal(35, department.Employees[0].Age); + Assert.Equal("alice@example.com", department.Employees[0].Email); + } + + [Fact] + public async Task Collection_WithItemMappingOnly_ShouldWriteAndReadCorrectly() + { + var departments = new[] + { + new Department + { + Name = "Operations", + Managers = + [ + new Person { Name = "Ellen", Department = "Ops" }, + new Person { Name = "Scott", Department = "Ops" } + ] + } + }; + + var registry = new MappingRegistry(); + registry.Configure(cfg => + { + cfg.Property(d => d.Name).ToCell("A1"); + cfg.Collection(d => d.Managers) + .StartAt("A3") + .WithItemMapping(managerCfg => + { + managerCfg.Property(p => p.Name).ToCell("A3"); + managerCfg.Property(p => p.Department).ToCell("B3"); + }); + }); + + var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + var importer = MiniExcel.Importers.GetMappingImporter(registry); + + using var stream = new MemoryStream(); + await exporter.ExportAsync(stream, departments); + stream.Position = 0; + + Assert.True(registry.HasMapping()); + + var compiledMappingsField = typeof(MappingRegistry).GetField("_compiledMappings", BindingFlags.NonPublic | BindingFlags.Instance); + var compiledMappings = (Dictionary)compiledMappingsField!.GetValue(registry)!; + var departmentMapping = compiledMappings[typeof(Department)]; + var nestedMappingsProp = departmentMapping.GetType().GetProperty("NestedMappings", BindingFlags.Instance | BindingFlags.Public); + var nestedMappingsObj = nestedMappingsProp?.GetValue(departmentMapping); + Assert.NotNull(nestedMappingsObj); + var countProp = nestedMappingsObj!.GetType().GetProperty("Count"); + var nestedCount = (int)(countProp?.GetValue(nestedMappingsObj) ?? 0); + Assert.True(nestedCount > 0); + + var getEnumerator = nestedMappingsObj.GetType().GetMethod("GetEnumerator"); + var enumerator = (System.Collections.IEnumerator)getEnumerator!.Invoke(nestedMappingsObj, null)!; + Assert.True(enumerator.MoveNext()); + var entry = enumerator.Current; + var valueProp = entry!.GetType().GetProperty("Value"); + var nestedInfo = valueProp!.GetValue(entry); + var propertiesProp = nestedInfo!.GetType().GetProperty("Properties", BindingFlags.Instance | BindingFlags.Public); + var nestedProperties = (System.Collections.IEnumerable?)propertiesProp?.GetValue(nestedInfo); + Assert.NotNull(nestedProperties); + + var firstProperty = nestedProperties!.Cast().FirstOrDefault(); + Assert.NotNull(firstProperty); + var setterProp = firstProperty!.GetType().GetProperty("Setter", BindingFlags.Instance | BindingFlags.Public); + var setter = setterProp?.GetValue(firstProperty); + Assert.NotNull(setter); + + var gridProp = departmentMapping.GetType().GetProperty("OptimizedCellGrid", BindingFlags.Instance | BindingFlags.Public); + var grid = gridProp?.GetValue(departmentMapping) as Array; + Assert.NotNull(grid); + var handlerType = typeof(MappingRegistry).Assembly.GetType("MiniExcelLib.Core.Mapping.OptimizedCellHandler"); + Assert.NotNull(handlerType); + var valueSetterProperty = handlerType!.GetProperty("ValueSetter", BindingFlags.Instance | BindingFlags.Public); + var propertyNameProperty = handlerType.GetProperty("PropertyName", BindingFlags.Instance | BindingFlags.Public); + var collectionIndexProperty = handlerType.GetProperty("CollectionIndex", BindingFlags.Instance | BindingFlags.Public); + + var hasSetter = false; + for (int r = 0; r < grid!.GetLength(0); r++) + { + for (int c = 0; c < grid.GetLength(1); c++) + { + if (grid.GetValue(r, c) is { } handler) + { + var propertyName = propertyNameProperty?.GetValue(handler) as string; + var collectionIndex = (int)(collectionIndexProperty?.GetValue(handler) ?? -1); + if (string.Equals(propertyName, "Name", StringComparison.Ordinal) && collectionIndex == 0) + { + var valueSetter = valueSetterProperty?.GetValue(handler); + if (valueSetter is not null) + { + hasSetter = true; + break; + } + } + } + } + if (hasSetter) + { + break; + } + } + + Assert.True(hasSetter); + + var boundariesProp = departmentMapping.GetType().GetProperty("OptimizedBoundaries", BindingFlags.Instance | BindingFlags.Public); + var boundaries = boundariesProp?.GetValue(departmentMapping); + Assert.NotNull(boundaries); + var minRowProp = boundaries!.GetType().GetProperty("MinRow", BindingFlags.Instance | BindingFlags.Public); + var maxRowProp = boundaries.GetType().GetProperty("MaxRow", BindingFlags.Instance | BindingFlags.Public); + var minColProp = boundaries.GetType().GetProperty("MinColumn", BindingFlags.Instance | BindingFlags.Public); + var maxColProp = boundaries.GetType().GetProperty("MaxColumn", BindingFlags.Instance | BindingFlags.Public); + var minRow = (int)(minRowProp?.GetValue(boundaries) ?? 0); + var maxRow = (int)(maxRowProp?.GetValue(boundaries) ?? 0); + var minCol = (int)(minColProp?.GetValue(boundaries) ?? 0); + var maxCol = (int)(maxColProp?.GetValue(boundaries) ?? 0); + + var tryGetHandlerMethod = departmentMapping.GetType().GetMethod("TryGetHandler", BindingFlags.Instance | BindingFlags.Public); + Assert.NotNull(tryGetHandlerMethod); + + var setterFoundViaTryGet = false; + for (var row = minRow; row <= maxRow && !setterFoundViaTryGet; row++) + { + for (var col = minCol; col <= maxCol && !setterFoundViaTryGet; col++) + { + var parameters = new object?[] { row, col, null }; + var success = (bool)tryGetHandlerMethod!.Invoke(departmentMapping, parameters)!; + if (!success) + continue; + + if (parameters[2] is { } handlerInstance) + { + var propertyName = propertyNameProperty?.GetValue(handlerInstance) as string; + var collectionIndex = (int)(collectionIndexProperty?.GetValue(handlerInstance) ?? -1); + if (collectionIndex == 0 && string.Equals(propertyName, "Name", StringComparison.Ordinal)) + { + var valueSetter = valueSetterProperty?.GetValue(handlerInstance); + if (valueSetter is not null) + { + setterFoundViaTryGet = true; + } + } + } + } + } + Assert.True(setterFoundViaTryGet); + + var results = importer.Query(stream).ToList(); + + Assert.Single(results); + var managers = results[0].Managers; + Assert.Equal(2, managers.Count); + Assert.Equal("Ellen", managers[0].Name); + Assert.Equal("Scott", managers[1].Name); } [Fact] @@ -393,17 +579,50 @@ public async Task Collection_NestedCollections_ShouldMapCorrectly() registry.Configure(cfg => { cfg.Property(d => d.Name).ToCell("A1"); - cfg.Collection(d => d.Projects).StartAt("A3"); + cfg.Collection(d => d.Projects) + .StartAt("A3") + .WithItemMapping(projectCfg => + { + projectCfg.Property(p => p.Code).ToCell("A3"); + projectCfg.Property(p => p.Title).ToCell("B3"); + projectCfg.Property(p => p.StartDate).ToCell("C3"); + projectCfg.Collection(p => p.Tasks) + .StartAt("D3") + .WithItemMapping(taskCfg => + { + taskCfg.Property(t => t.Name).ToCell("D3"); + taskCfg.Property(t => t.EstimatedHours).ToCell("E3"); + taskCfg.Property(t => t.IsCompleted).ToCell("F3"); + }); + }); }); var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + var importer = MiniExcel.Importers.GetMappingImporter(registry); // Act using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, departments); - + await exporter.ExportAsync(stream, departments); + stream.Position = 0; + + var results = importer.Query(stream).ToList(); + // Assert - Assert.True(stream.Length > 0); + Assert.Single(results); + var department = results[0]; + Assert.Equal("Product Development", department.Name); + + var projects = department.Projects.ToList(); + Assert.Single(projects); + + var project = projects[0]; + Assert.Equal("PROJ-001", project.Code); + Assert.Equal("New Feature", project.Title); + + Assert.Equal(3, project.Tasks.Count); + Assert.Equal("Design", project.Tasks[0].Name); + Assert.True(project.Tasks[0].IsCompleted); + Assert.Equal(120, project.Tasks[1].EstimatedHours); } [Fact] @@ -455,7 +674,7 @@ public async Task Collection_MixedSimpleAndComplex_ShouldMapCorrectly() // Act using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, departments); + await exporter.ExportAsync(stream, departments); // Assert Assert.True(stream.Length > 0); @@ -486,7 +705,7 @@ public async Task Formula_Properties_Should_Be_Handled_Correctly() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, testData); + await exporter.ExportAsync(stream, testData); Assert.True(stream.Length > 0); } @@ -515,7 +734,7 @@ public async Task Format_Properties_Should_Apply_Formatting() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, testData); + await exporter.ExportAsync(stream, testData); Assert.True(stream.Length > 0); } @@ -544,7 +763,7 @@ public async Task Mapping_WithComplexCellAddresses_ShouldMapCorrectly() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, products); + await exporter.ExportAsync(stream, products); // Verify the file was created Assert.True(stream.Length > 0); @@ -582,7 +801,7 @@ public async Task Mapping_WithNumericFormats_ShouldApplyCorrectly() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, products); + await exporter.ExportAsync(stream, products); Assert.True(stream.Length > 0); } @@ -611,7 +830,7 @@ public async Task Mapping_WithDateFormats_ShouldApplyCorrectly() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, products); + await exporter.ExportAsync(stream, products); Assert.True(stream.Length > 0); } @@ -635,7 +854,7 @@ public async Task Mapping_WithBooleanValues_ShouldMapCorrectly() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, products); + await exporter.ExportAsync(stream, products); Assert.True(stream.Length > 0); } @@ -661,7 +880,7 @@ public async Task Mapping_WithMultipleRowsToSameCells_ShouldOverwrite() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, products); + await exporter.ExportAsync(stream, products); // The file should contain only the last item's data Assert.True(stream.Length > 0); @@ -696,7 +915,7 @@ public async Task Mapping_WithComplexTypes_ShouldHandleCorrectly() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, items); + await exporter.ExportAsync(stream, items); Assert.True(stream.Length > 0); } @@ -729,7 +948,7 @@ public async Task Mapping_WithMultipleConfigurations_ShouldUseLast() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, products); + await exporter.ExportAsync(stream, products); Assert.True(stream.Length > 0); } @@ -772,7 +991,7 @@ public async Task Mapping_WithEnumerableTypes_ShouldHandleCorrectly() var array = new[] { new Product { Name = "Array", Price = 10 } }; using (var stream = new MemoryStream()) { - await exporter.ExportAsync(stream, array); + await exporter.ExportAsync(stream, array); Assert.True(stream.Length > 0); } @@ -780,7 +999,7 @@ public async Task Mapping_WithEnumerableTypes_ShouldHandleCorrectly() var list = new List { new Product { Name = "List", Price = 20 } }; using (var stream = new MemoryStream()) { - await exporter.ExportAsync(stream, list); + await exporter.ExportAsync(stream, list); Assert.True(stream.Length > 0); } @@ -788,7 +1007,7 @@ public async Task Mapping_WithEnumerableTypes_ShouldHandleCorrectly() IEnumerable enumerable = list; using (var stream = new MemoryStream()) { - await exporter.ExportAsync(stream, enumerable); + await exporter.ExportAsync(stream, enumerable); Assert.True(stream.Length > 0); } } @@ -855,7 +1074,7 @@ public async Task Mapping_WithSaveToFile_ShouldCreateFile() { using (var stream = File.Create(filePath)) { - await exporter.ExportAsync(stream, products); + await exporter.ExportAsync(stream, products); } // Verify file exists and has content @@ -907,7 +1126,7 @@ public async Task Empty_Collection_Should_Handle_Gracefully() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, testData); + await exporter.ExportAsync(stream, testData); Assert.True(stream.Length > 0); } @@ -935,7 +1154,7 @@ public async Task Null_Values_Should_Handle_Gracefully() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, testData); + await exporter.ExportAsync(stream, testData); Assert.True(stream.Length > 0); } @@ -988,7 +1207,7 @@ public async Task Large_Dataset_Should_Stream_Efficiently() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, testData); + await exporter.ExportAsync(stream, testData); // Should complete without OutOfMemory Assert.True(stream.Length > 0); From c30d621733b4995b3eed6c1b0ca57542c4e8bd5e Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Sun, 12 Oct 2025 00:30:31 +0200 Subject: [PATCH 13/16] Various adjustments - Added explicit IEnumerator implementation to MappingCellEnumerator for clarity - Straightened recursive implementation of MappingCellEnumerator's MoveNext into a loop - Expanded one of the tests - Changed some checks to pattern matching - Some other minor style changes --- .../Helpers/MappingMetadataExtractor.cs | 85 +++--- .../Mapping/MappingCellStream.cs | 261 +++++++++--------- src/MiniExcel.Core/Mapping/MappingCompiler.cs | 129 +++++---- src/MiniExcel.Core/Mapping/MappingReader.cs | 67 ++--- src/MiniExcel.Core/Mapping/MappingRegistry.cs | 25 +- .../FluentMapping/MiniExcelMappingTests.cs | 98 ++++--- 6 files changed, 335 insertions(+), 330 deletions(-) diff --git a/src/MiniExcel.Core/Helpers/MappingMetadataExtractor.cs b/src/MiniExcel.Core/Helpers/MappingMetadataExtractor.cs index 0d82eea9..fdcbe692 100644 --- a/src/MiniExcel.Core/Helpers/MappingMetadataExtractor.cs +++ b/src/MiniExcel.Core/Helpers/MappingMetadataExtractor.cs @@ -6,6 +6,12 @@ namespace MiniExcelLib.Core.Helpers; /// 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. @@ -33,48 +39,47 @@ internal static class MappingMetadataExtractor nestedInfo.Properties = propertyList; var collectionsProperty = nestedMappingType.GetProperty("Collections"); - if (collectionsProperty?.GetValue(nestedMapping) is IEnumerable collectionMappings) + if (collectionsProperty?.GetValue(nestedMapping) is not IEnumerable collectionMappings) + return nestedInfo; + + var nestedCollections = new Dictionary(StringComparer.Ordinal); + foreach (var collection in collectionMappings) { - var nestedCollections = new Dictionary(StringComparer.Ordinal); + if (collection is not CompiledCollectionMapping compiledCollection) + continue; - foreach (var collection in collectionMappings) + var nestedItemType = compiledCollection.ItemType ?? typeof(object); + var collectionInfo = new NestedCollectionInfo { - 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)) + 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) { - var childMapping = compiledCollection.Registry.GetCompiledMapping(nestedItemType); - if (childMapping is not null) - { - collectionInfo.NestedMapping = ExtractNestedMappingInfo(childMapping, nestedItemType); - } + collectionInfo.NestedMapping = ExtractNestedMappingInfo(childMapping, nestedItemType); } - - nestedCollections[collectionInfo.PropertyName] = collectionInfo; } - if (nestedCollections.Count > 0) - { - nestedInfo.Collections = nestedCollections; - } + nestedCollections[collectionInfo.PropertyName] = collectionInfo; } - + + if (nestedCollections.Count > 0) + { + nestedInfo.Collections = nestedCollections; + } + return nestedInfo; } @@ -83,10 +88,6 @@ internal static class MappingMetadataExtractor /// /// The collection of property mappings /// A list of nested property information - private static readonly MethodInfo? CreateTypedSetterMethod = typeof(ConversionHelper) - .GetMethods(BindingFlags.Public | BindingFlags.Static) - .FirstOrDefault(m => m.Name == nameof(ConversionHelper.CreateTypedPropertySetter) && m.IsGenericMethodDefinition); - private static List ExtractPropertyList(IEnumerable properties, Type itemType) { var propertyList = new List(); @@ -119,7 +120,7 @@ private static List ExtractPropertyList(IEnumerable properti } } - setter ??= (_, _) => { }; + setter ??= DefaultNestedPropertySetter; if (name is not null && getter is not null) { @@ -145,7 +146,7 @@ private static List ExtractPropertyList(IEnumerable properti try { var generic = CreateTypedSetterMethod.MakeGenericMethod(itemType); - return generic.Invoke(null, new object[] { propertyInfo }) as Action; + return generic.Invoke(null, [propertyInfo]) as Action; } catch { @@ -179,7 +180,9 @@ private static List ExtractPropertyList(IEnumerable properti private static bool IsSimpleType(Type type) { - return type == typeof(string) || type.IsValueType || type.IsPrimitive; + return type == typeof(string) || + type.IsValueType || + type.IsPrimitive; } /// diff --git a/src/MiniExcel.Core/Mapping/MappingCellStream.cs b/src/MiniExcel.Core/Mapping/MappingCellStream.cs index 38f1a58c..2021e0ad 100644 --- a/src/MiniExcel.Core/Mapping/MappingCellStream.cs +++ b/src/MiniExcel.Core/Mapping/MappingCellStream.cs @@ -12,7 +12,7 @@ public IMiniExcelWriteAdapter CreateAdapter() => new MappingCellStreamAdapter(this, columnLetters); } -internal struct MappingCellEnumerator +internal struct MappingCellEnumerator : IEnumerator where T : class { private readonly IEnumerator _itemEnumerator; @@ -28,7 +28,7 @@ internal struct MappingCellEnumerator private bool _isComplete; private readonly object _emptyCell; private int _maxCollectionRows; - private int _currentCollectionRow; + private int _currentCollectionRow; public MappingCellEnumerator(IEnumerator itemEnumerator, CompiledMapping mapping, string[] columnLetters) { @@ -44,116 +44,123 @@ public MappingCellEnumerator(IEnumerator itemEnumerator, CompiledMapping m _hasStartedData = false; _isComplete = false; _emptyCell = string.Empty; - _maxCollectionRows = 0; - _currentCollectionRow = 0; + _maxCollectionRows = 0; + _currentCollectionRow = 0; Current = default; } public MappingCell Current { get; private set; } + object IEnumerator.Current => Current; public bool MoveNext() { - if (_isComplete) - return false; - - // Handle rows before data starts (if MinRow > 1) - if (!_hasStartedData) + while (true) { - if (_currentRowIndex == 0) - { - _currentRowIndex = 1; - _currentColumnIndex = 0; - } + if (_isComplete) + return false; - // Emit empty cells for rows before MinRow - if (_currentRowIndex < _boundaries.MinRow) + // Handle rows before data starts (if MinRow > 1) + if (!_hasStartedData) { - if (_currentColumnIndex < _columnCount) + if (_currentRowIndex == 0) { - var columnLetter = _columnLetters[_currentColumnIndex]; - Current = new MappingCell(columnLetter, _currentRowIndex, _emptyCell); - _currentColumnIndex++; - return true; + _currentRowIndex = 1; + _currentColumnIndex = 0; } - // Move to next empty row - _currentRowIndex++; - _currentColumnIndex = 0; - + // Emit empty cells for rows before MinRow if (_currentRowIndex < _boundaries.MinRow) { - return MoveNext(); // Continue with next empty row + 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; + // Start processing actual data + _hasStartedData = true; + if (!_itemEnumerator.MoveNext()) + { + _isComplete = true; + return false; + } + + _currentItem = _itemEnumerator.Current; + _currentColumnIndex = 0; } - _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) + // Process current item's cells + if (_currentItem is not null) { - _maxCollectionRows = 0; - - for (var i = 0; i < _mapping.Collections.Count; i++) + // Cache collection metrics when we start processing an item + if (_currentColumnIndex == 0 && _currentCollectionRow == 0 && _mapping.Collections.Count > 0) { - var coll = _mapping.Collections[i]; - var collectionData = coll.Getter(_currentItem); - if (collectionData is not null) + _maxCollectionRows = 0; + + for (var i = 0; i < _mapping.Collections.Count; i++) { - // Convert to a list once - this is the only enumeration - var items = collectionData.Cast().ToList(); + 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; - nestedInfo = _mapping.NestedMappings is not null && _mapping.NestedMappings.TryGetValue(i, out var precompiledNested) - ? precompiledNested - : 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 = coll.StartCellRow - 1; + var collectionMaxRow = collection.StartCellRow - 1; for (var itemIndex = 0; itemIndex < items.Count; itemIndex++) { - var item = items[itemIndex]; - if (item is null) + if (items[itemIndex] is not { } item) continue; - - var itemRow = coll.StartCellRow + itemIndex * (1 + coll.RowSpacing); + + var itemRow = collection.StartCellRow + itemIndex * (1 + collection.RowSpacing); if (itemRow > collectionMaxRow) + { collectionMaxRow = itemRow; + } - if (nestedInfo?.Collections is null || nestedInfo.Collections.Count == 0) - continue; - - foreach (var nested in nestedInfo.Collections.Values) + if (nestedInfo?.Collections is { Count: > 0 } collections) { - var nestedData = nested.Getter(item); - if (nestedData is not IEnumerable nestedEnumerable) - continue; - - var nestedIndex = 0; - foreach (var _ in nestedEnumerable) + foreach (var nested in collections.Values) { - var nestedRow = nested.StartRow + - itemIndex * (1 + coll.RowSpacing) + - nestedIndex * (1 + nested.RowSpacing); - - if (nestedRow > collectionMaxRow) - collectionMaxRow = nestedRow; - - nestedIndex++; + 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++; + } + } } } } @@ -164,64 +171,60 @@ public bool MoveNext() _maxCollectionRows = neededRows; } } - else - { - continue; - } } - } - - // 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; - return MoveNext(); - } + // Emit cells for current row + if (_currentColumnIndex < _columnCount) + { + var columnLetter = _columnLetters[_currentColumnIndex]; + var columnNumber = _boundaries.MinColumn + _currentColumnIndex; - // Reset for next item - _currentCollectionRow = 0; - - // Move to next item - if (_itemEnumerator.MoveNext()) - { - _currentItem = _itemEnumerator.Current; - _currentRowIndex++; - _currentColumnIndex = 0; - return MoveNext(); + 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; + _isComplete = true; + return false; + } } public void Reset() diff --git a/src/MiniExcel.Core/Mapping/MappingCompiler.cs b/src/MiniExcel.Core/Mapping/MappingCompiler.cs index e3c424c9..88d20d24 100644 --- a/src/MiniExcel.Core/Mapping/MappingCompiler.cs +++ b/src/MiniExcel.Core/Mapping/MappingCompiler.cs @@ -30,7 +30,7 @@ public static CompiledMapping Compile(MappingConfiguration? configurati foreach (var prop in configuration.PropertyMappings) { if (string.IsNullOrEmpty(prop.CellAddress)) - throw new InvalidOperationException($"Property mapping must specify a cell address using ToCell()"); + throw new InvalidOperationException("Property mapping must specify a cell address using ToCell()"); var propertyName = GetPropertyName(prop.Expression); @@ -73,7 +73,7 @@ public static CompiledMapping Compile(MappingConfiguration? configurati foreach (var coll in configuration.CollectionMappings) { if (string.IsNullOrEmpty(coll.StartCell)) - throw new InvalidOperationException($"Collection mapping must specify a start cell using StartAt()"); + 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)); @@ -204,57 +204,58 @@ private static OptimizedMappingBoundaries CalculateMappingBoundaries(Compiled // 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 { Collections.Count: > 0, Properties.Count: > 0 }) + 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 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}) { - // 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) { - // Try to get the nested mapping - if it exists, it's a complex type - var nestedMapping = coll.Registry.GetCompiledMapping(coll.ItemType); - if (nestedMapping is not null && - coll.ItemType != typeof(string) && - coll.ItemType is { IsValueType: false, IsPrimitive: false }) - { - hasNestedCollections = true; - break; - } + 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); + // 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; + // 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); - } + // 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; + // 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; - } + // 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; } } @@ -277,19 +278,19 @@ private static (int minRow, int maxRow, int minCol, int maxCol) CalculateCollect // Calculate bounds based on layout switch (collection.Layout) { + // Vertical collections: grow downward case CollectionLayout.Vertical: - // Vertical collections: grow downward // Use conservative estimate for initial bounds - var verticalHeight = DefaultCollectionHeight; - + 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, startRow + verticalHeight, startCol, maxCol); + return (startRow, totalHeight, startCol, maxCol); var nestedMapping = collection.Registry.GetCompiledMapping(collection.ItemType); if (nestedMapping is null || !MappingMetadataExtractor.IsComplexType(collection.ItemType)) - return (startRow, startRow + verticalHeight, startCol, maxCol); + return (startRow, totalHeight, startCol, maxCol); // Extract nested mapping info to get max column var nestedInfo = MappingMetadataExtractor.ExtractNestedMappingInfo(nestedMapping, collection.ItemType); @@ -298,7 +299,7 @@ private static (int minRow, int maxRow, int minCol, int maxCol) CalculateCollect maxCol = GetMaxColumnIndex(nestedInfo, maxCol); } - return (startRow, startRow + verticalHeight, startCol, maxCol); + return (startRow, totalHeight, startCol, maxCol); } // Default fallback @@ -313,11 +314,11 @@ private static (int minRow, int maxRow, int minCol, int maxCol) CalculateCollect var grid = new OptimizedCellHandler[height, width]; // Initialize all cells as empty - for (int r = 0; r < height; r++) + for (int row = 0; row < height; row++) { - for (int c = 0; c < width; c++) + for (int col = 0; col < width; col++) { - grid[r, c] = new OptimizedCellHandler { Type = CellHandlerType.Empty }; + grid[row, col] = new OptimizedCellHandler { Type = CellHandlerType.Empty }; } } @@ -482,7 +483,7 @@ private static OptimizedCellHandler[] BuildOptimizedColumnHandlers(CompiledMa private static void PreCompileCollectionHelpers(CompiledMapping mapping) { - if (!mapping.Collections.Any()) + if (mapping.Collections.Count == 0) return; // Store pre-compiled helpers for each collection @@ -579,6 +580,7 @@ private static void MarkVerticalComplexCollectionCells(OptimizedCellHandler[,] g { 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) { @@ -609,16 +611,14 @@ private static void MarkVerticalComplexCollectionCells(OptimizedCellHandler[,] g if (nestedMappingInfo is null || nestedMappingInfo.Properties.Count == 0) continue; - var nestedMaxItems = 20; + 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 (nextCollectionStartRow.HasValue && nestedAbsoluteRow >= nextCollectionStartRow.Value) - { + if (nestedAbsoluteRow >= nextCollectionStartRow) break; - } var nestedRelativeRow = nestedAbsoluteRow - boundaries.MinRow; if (nestedRelativeRow < 0 || nestedRelativeRow >= maxRows || nestedRelativeRow >= grid.GetLength(0)) @@ -634,9 +634,7 @@ private static void MarkVerticalComplexCollectionCells(OptimizedCellHandler[,] g continue; if (grid[nestedRelativeRow, columnIndex].Type != CellHandlerType.Empty) - { continue; - } grid[nestedRelativeRow, columnIndex] = new OptimizedCellHandler { @@ -699,17 +697,16 @@ private static void MarkVerticalComplexCollectionCells(OptimizedCellHandler[,] g if (parent is null) return; - var collection = collectionInfo.Getter(parent); IList list; - + var collection = collectionInfo.Getter(parent); if (collection is IList existingList) { list = existingList; } - else if (collection is IEnumerable enumerable) + else if (collection is not null) { list = collectionInfo.ListFactory(); - foreach (var item in enumerable) + foreach (var item in collection) { list.Add(item); } @@ -741,10 +738,8 @@ private static void MarkVerticalComplexCollectionCells(OptimizedCellHandler[,] g if (nestedItem is null) { nestedItem = collectionInfo.ItemFactory(); - if (nestedItem is null) - throw new InvalidOperationException($"Collection item factory returned null for type '{collectionInfo.ItemType}'. Ensure it has an accessible parameterless constructor."); - - list[nestedOffset] = nestedItem; + 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); diff --git a/src/MiniExcel.Core/Mapping/MappingReader.cs b/src/MiniExcel.Core/Mapping/MappingReader.cs index 49a1e62a..d71e21a5 100644 --- a/src/MiniExcel.Core/Mapping/MappingReader.cs +++ b/src/MiniExcel.Core/Mapping/MappingReader.cs @@ -300,13 +300,10 @@ private static void ProcessCellValue(OptimizedCellHandler handler, object? value } } - private static void ProcessComplexCollectionItem(IList collection, OptimizedCellHandler handler, - object? value, CompiledMapping mapping) + 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) @@ -346,11 +343,10 @@ private static void ProcessComplexCollectionItem(IList collection, OptimizedCell } // Try to set the value using the handler - if (!mapping.TrySetValue(handler, item!, value)) + if (!mapping.TrySetValue(handler, item, value)) { // For nested mappings, we need to look up the pre-compiled setter - if (mapping.NestedMappings is not null && - mapping.NestedMappings.TryGetValue(handler.CollectionIndex, out var nestedInfo)) + 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); @@ -368,17 +364,13 @@ private static void ProcessComplexCollectionItem(IList collection, OptimizedCell } } - private static bool HasMeaningfulValue(object? value) + private static bool HasMeaningfulValue(object? value) => value switch { - if (value is null) - return false; - - if (value is string str) - return !string.IsNullOrWhiteSpace(str); + null => false, + string str => !string.IsNullOrWhiteSpace(str), + _ => true + }; - return true; - } - private static void FinalizeCollections(T item, CompiledMapping mapping, Dictionary collections) { for (int i = 0; i < mapping.Collections.Count; i++) @@ -412,8 +404,7 @@ private static void FinalizeCollections(T item, CompiledMapping mapping, Dict // Use pre-compiled type metadata from helper var listHelper = mapping.OptimizedCollectionHelpers?[i]; bool isDefault = lastItem is null || - (listHelper is { IsItemValueType: true } && - lastItem.Equals(defaultValue)); + (lastItem.Equals(defaultValue) && listHelper is { IsItemValueType: true }); if (isDefault) { list.RemoveAt(list.Count - 1); @@ -445,19 +436,16 @@ private static void FinalizeCollections(T item, CompiledMapping mapping, Dict private static bool HasAnyData(T item, CompiledMapping mapping) { // Check if any properties have non-default values - foreach (var prop in mapping.Properties) + var values = mapping.Properties.Select(prop => prop.Getter(item)); + if (values.Any(v => !IsDefaultValue(v))) { - var value = prop.Getter(item); - if (!IsDefaultValue(value)) - { - return true; - } + return true; } - + // Check if any collections have items - foreach (var coll in mapping.Collections) + foreach (var collMap in mapping.Collections) { - var collection = coll.Getter(item); + var collection = collMap.Getter(item); var enumerator = collection.GetEnumerator(); using var disposableEnumerator = enumerator as IDisposable; if (enumerator.MoveNext()) @@ -469,19 +457,16 @@ private static bool HasAnyData(T item, CompiledMapping mapping) return false; } - private static bool IsDefaultValue(object value) + private static bool IsDefaultValue(object value) => value switch { - return value switch - { - string s => string.IsNullOrEmpty(s), - int i => i == 0, - long l => l == 0, - decimal d => d == 0, - double d => d == 0, - float f => f == 0, - bool b => !b, - DateTime dt => dt == default, - _ => false - }; - } + 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/Mapping/MappingRegistry.cs b/src/MiniExcel.Core/Mapping/MappingRegistry.cs index c9813519..53fe6b8f 100644 --- a/src/MiniExcel.Core/Mapping/MappingRegistry.cs +++ b/src/MiniExcel.Core/Mapping/MappingRegistry.cs @@ -67,14 +67,14 @@ public bool HasMapping() } } - private void CompileNestedMappings(MappingConfiguration configuration) + private void CompileNestedMappings(MappingConfiguration mappingConfiguration) { - foreach (var collection in configuration.CollectionMappings) + foreach (var collection in mappingConfiguration.CollectionMappings) { - if (collection.ItemConfiguration is null || collection.ItemType is null) - continue; - - CompileNestedMappingInternal(collection.ItemType, collection.ItemConfiguration); + if (collection is { ItemConfiguration: { } configuration, ItemType: { } type }) + { + CompileNestedMappingInternal(type, configuration); + } } } @@ -84,17 +84,20 @@ private void CompileNestedMappingInternal(Type itemType, object itemConfiguratio .GetMethod(nameof(CompileNestedMapping), BindingFlags.Instance | BindingFlags.NonPublic)? .MakeGenericMethod(itemType); - method?.Invoke(this, new[] { itemConfiguration }); + method?.Invoke(this, [itemConfiguration]); } private void CompileNestedMapping(MappingConfiguration configuration) { CompileNestedMappings(configuration); - if (_compiledMappings.ContainsKey(typeof(TItem))) - return; + lock (_lock) + { + if (_compiledMappings.ContainsKey(typeof(TItem))) + return; - var compiled = MappingCompiler.Compile(configuration, this); - _compiledMappings[typeof(TItem)] = compiled; + var compiled = MappingCompiler.Compile(configuration, this); + _compiledMappings[typeof(TItem)] = compiled; + } } } \ No newline at end of file diff --git a/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTests.cs b/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTests.cs index 92fc1beb..47c08a2e 100644 --- a/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTests.cs +++ b/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTests.cs @@ -74,14 +74,6 @@ public class TestModel public int Value { get; set; } } - public class Employee - { - public string Name { get; set; } = ""; - public string Position { get; set; } = ""; - public decimal Salary { get; set; } - public List Skills { get; set; } = []; - } - public class Project { public string Code { get; set; } = ""; @@ -139,7 +131,7 @@ public async Task MappingReader_ReadBasicData_Success() using var stream = new MemoryStream(); var exporter = MiniExcel.Exporters.GetMappingExporter(registry); - await exporter.ExportAsync(stream, testData); + await exporter.ExportAsync(stream, testData); stream.Position = 0; // Act @@ -178,7 +170,7 @@ public async Task SaveAs_WithBasicMapping_ShouldGenerateCorrectFile() // Act & Assert using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, people); + await exporter.ExportAsync(stream, people); Assert.True(stream.Length > 0); } @@ -204,7 +196,7 @@ public void SaveAs_WithBasicMapping_SyncVersion_ShouldGenerateCorrectFile() // Act & Assert using var stream = new MemoryStream(); - exporter.Export(stream, people); + exporter.Export(stream, people); Assert.True(stream.Length > 0); } @@ -233,7 +225,7 @@ public async Task Query_WithBasicMapping_ShouldReadDataCorrectly() // Act using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, testData); + await exporter.ExportAsync(stream, testData); stream.Position = 0; var results = importer.Query(stream).ToList(); @@ -319,7 +311,7 @@ public async Task Collection_Vertical_Should_Write_And_Read_Correctly() var importer = MiniExcel.Importers.GetMappingImporter(registry); using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, testData); + await exporter.ExportAsync(stream, testData); stream.Position = 0; var results = importer.Query(stream).ToList(); @@ -374,13 +366,14 @@ public async Task Collection_ComplexObjectsWithMapping_ShouldMapCorrectly() var handler = grid[r, c]; if (handler.Type != CellHandlerType.Empty) { + } } } // Act using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, departments); + await exporter.ExportAsync(stream, departments); stream.Position = 0; var results = importer.Query(stream).ToList(); @@ -428,7 +421,7 @@ public async Task Collection_WithItemMappingOnly_ShouldWriteAndReadCorrectly() var importer = MiniExcel.Importers.GetMappingImporter(registry); using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, departments); + await exporter.ExportAsync(stream, departments); stream.Position = 0; Assert.True(registry.HasMapping()); @@ -512,10 +505,10 @@ public async Task Collection_WithItemMappingOnly_ShouldWriteAndReadCorrectly() Assert.NotNull(tryGetHandlerMethod); var setterFoundViaTryGet = false; - for (var row = minRow; row <= maxRow && !setterFoundViaTryGet; row++) - { - for (var col = minCol; col <= maxCol && !setterFoundViaTryGet; col++) - { + for (var row = minRow; row <= maxRow && !setterFoundViaTryGet; row++) + { + for (var col = minCol; col <= maxCol && !setterFoundViaTryGet; col++) + { var parameters = new object?[] { row, col, null }; var success = (bool)tryGetHandlerMethod!.Invoke(departmentMapping, parameters)!; if (!success) @@ -602,7 +595,8 @@ public async Task Collection_NestedCollections_ShouldMapCorrectly() // Act using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, departments); + await exporter.ExportAsync(stream, departments); + stream.Position = 0; var results = importer.Query(stream).ToList(); @@ -638,8 +632,6 @@ public void Collection_WithoutStartCell_ShouldThrowException() { cfg.Collection(d => d.PhoneNumbers); // Missing StartAt() }); - - var mapping = registry.GetMapping(); }); Assert.Contains("start cell", exception.Message, StringComparison.OrdinalIgnoreCase); @@ -667,17 +659,41 @@ public async Task Collection_MixedSimpleAndComplex_ShouldMapCorrectly() { cfg.Property(d => d.Name).ToCell("A1"); cfg.Collection(d => d.PhoneNumbers).StartAt("A3"); - cfg.Collection(d => d.Employees).StartAt("C3"); + cfg.Collection(d => d.Employees) + .StartAt("C3") + .WithItemMapping(x => + { + x.Property(e => e.Name).ToCell("C3"); + x.Property(e => e.Age).ToCell("D3"); + x.Property(e => e.Salary).ToCell("E3"); + x.Property(e => e.Email).ToCell("F3"); + }); }); var exporter = MiniExcel.Exporters.GetMappingExporter(registry); + var importer = MiniExcel.Importers.GetMappingImporter(registry); // Act using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, departments); + await exporter.ExportAsync(stream, departments); + stream.Seek(0, SeekOrigin.Begin); // Assert - Assert.True(stream.Length > 0); + var results = importer.Query(stream).ToList(); + var first = results[0]; + + Assert.Equal("555-1111", first.PhoneNumbers[0]); + Assert.Equal("555-2222", first.PhoneNumbers[1]); + + Assert.Equal("Dave", first.Employees[0].Name); + Assert.Equal(35, first.Employees[0].Age); + Assert.Equal(85000, first.Employees[0].Salary); + Assert.Equal("dave@example.com", first.Employees[0].Email); + + Assert.Equal("Eve", first.Employees[1].Name); + Assert.Equal(29, first.Employees[1].Age); + Assert.Equal(75000, first.Employees[1].Salary); + Assert.Equal("eve@example.com", first.Employees[1].Email); } #endregion @@ -705,7 +721,7 @@ public async Task Formula_Properties_Should_Be_Handled_Correctly() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, testData); + await exporter.ExportAsync(stream, testData); Assert.True(stream.Length > 0); } @@ -734,7 +750,7 @@ public async Task Format_Properties_Should_Apply_Formatting() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, testData); + await exporter.ExportAsync(stream, testData); Assert.True(stream.Length > 0); } @@ -763,7 +779,7 @@ public async Task Mapping_WithComplexCellAddresses_ShouldMapCorrectly() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, products); + await exporter.ExportAsync(stream, products); // Verify the file was created Assert.True(stream.Length > 0); @@ -801,7 +817,7 @@ public async Task Mapping_WithNumericFormats_ShouldApplyCorrectly() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, products); + await exporter.ExportAsync(stream, products); Assert.True(stream.Length > 0); } @@ -830,7 +846,7 @@ public async Task Mapping_WithDateFormats_ShouldApplyCorrectly() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, products); + await exporter.ExportAsync(stream, products); Assert.True(stream.Length > 0); } @@ -854,7 +870,7 @@ public async Task Mapping_WithBooleanValues_ShouldMapCorrectly() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, products); + await exporter.ExportAsync(stream, products); Assert.True(stream.Length > 0); } @@ -880,7 +896,7 @@ public async Task Mapping_WithMultipleRowsToSameCells_ShouldOverwrite() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, products); + await exporter.ExportAsync(stream, products); // The file should contain only the last item's data Assert.True(stream.Length > 0); @@ -915,7 +931,7 @@ public async Task Mapping_WithComplexTypes_ShouldHandleCorrectly() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, items); + await exporter.ExportAsync(stream, items); Assert.True(stream.Length > 0); } @@ -948,7 +964,7 @@ public async Task Mapping_WithMultipleConfigurations_ShouldUseLast() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, products); + await exporter.ExportAsync(stream, products); Assert.True(stream.Length > 0); } @@ -991,7 +1007,7 @@ public async Task Mapping_WithEnumerableTypes_ShouldHandleCorrectly() var array = new[] { new Product { Name = "Array", Price = 10 } }; using (var stream = new MemoryStream()) { - await exporter.ExportAsync(stream, array); + await exporter.ExportAsync(stream, array); Assert.True(stream.Length > 0); } @@ -999,7 +1015,7 @@ public async Task Mapping_WithEnumerableTypes_ShouldHandleCorrectly() var list = new List { new Product { Name = "List", Price = 20 } }; using (var stream = new MemoryStream()) { - await exporter.ExportAsync(stream, list); + await exporter.ExportAsync(stream, list); Assert.True(stream.Length > 0); } @@ -1007,7 +1023,7 @@ public async Task Mapping_WithEnumerableTypes_ShouldHandleCorrectly() IEnumerable enumerable = list; using (var stream = new MemoryStream()) { - await exporter.ExportAsync(stream, enumerable); + await exporter.ExportAsync(stream, enumerable); Assert.True(stream.Length > 0); } } @@ -1074,7 +1090,7 @@ public async Task Mapping_WithSaveToFile_ShouldCreateFile() { using (var stream = File.Create(filePath)) { - await exporter.ExportAsync(stream, products); + await exporter.ExportAsync(stream, products); } // Verify file exists and has content @@ -1126,7 +1142,7 @@ public async Task Empty_Collection_Should_Handle_Gracefully() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, testData); + await exporter.ExportAsync(stream, testData); Assert.True(stream.Length > 0); } @@ -1154,7 +1170,7 @@ public async Task Null_Values_Should_Handle_Gracefully() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, testData); + await exporter.ExportAsync(stream, testData); Assert.True(stream.Length > 0); } @@ -1207,7 +1223,7 @@ public async Task Large_Dataset_Should_Stream_Efficiently() var exporter = MiniExcel.Exporters.GetMappingExporter(registry); using var stream = new MemoryStream(); - await exporter.ExportAsync(stream, testData); + await exporter.ExportAsync(stream, testData); // Should complete without OutOfMemory Assert.True(stream.Length > 0); From 16834a25ba5f1a722a573a17a748e43e1cd06838 Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Tue, 14 Oct 2025 21:01:26 +0200 Subject: [PATCH 14/16] Changes to the FluentMapping folder structure - Renamed namespace Mapping to FluentMapping - Moved mapping importer, exporter and templater to new Api folder - Moved methods GetMappingImporter, GetMappingExporter and GetMappingTemplater from MiniExcelProviders to a ProviderExtensions class as extension methods --- .../BenchmarkSections/CreateExcelBenchmark.cs | 2 +- .../BenchmarkSections/QueryExcelBenchmark.cs | 2 +- .../BenchmarkSections/TemplateExcelBenchmark.cs | 2 +- .../Api}/MappingExporter.cs | 2 +- .../Api}/MappingImporter.cs | 2 +- .../Api}/MappingTemplater.cs | 2 +- .../FluentMapping/Api/ProviderExtensions.cs | 13 +++++++++++++ .../{Mapping => FluentMapping}/CompiledMapping.cs | 2 +- .../Configuration/CollectionMappingBuilder.cs | 2 +- .../Configuration/ICollectionMappingBuilder.cs | 2 +- .../Configuration/IMappingConfiguration.cs | 2 +- .../Configuration/IPropertyMappingBuilder.cs | 2 +- .../Configuration/MappingConfiguration.cs | 2 +- .../Configuration/PropertyMappingBuilder.cs | 2 +- .../{Mapping => FluentMapping}/MappingCellStream.cs | 7 ++++++- .../{Mapping => FluentMapping}/MappingCompiler.cs | 4 ++-- .../{Mapping => FluentMapping}/MappingReader.cs | 2 +- .../{Mapping => FluentMapping}/MappingRegistry.cs | 4 ++-- .../MappingTemplateApplicator.cs | 2 +- .../MappingTemplateProcessor.cs | 2 +- .../{Mapping => FluentMapping}/MappingWriter.cs | 2 +- .../{Mapping => FluentMapping}/NestedMappingInfo.cs | 2 +- src/MiniExcel.Core/GlobalUsings.cs | 1 - .../Helpers/MappingMetadataExtractor.cs | 2 ++ src/MiniExcel.Core/Mapping/IMappingCellStream.cs | 6 ------ src/MiniExcel.Core/MiniExcelProviders.cs | 10 +++++----- .../WriteAdapters/MappingCellStreamAdapter.cs | 2 ++ .../WriteAdapters/MiniExcelWriteAdapterFactory.cs | 4 +++- .../FluentMapping/MiniExcelMappingCompilerTests.cs | 2 +- .../FluentMapping/MiniExcelMappingTemplateTests.cs | 2 +- .../FluentMapping/MiniExcelMappingTests.cs | 4 ++-- tests/MiniExcel.Csv.Tests/IssueTests.cs | 1 - 32 files changed, 57 insertions(+), 41 deletions(-) rename src/MiniExcel.Core/{Mapping => FluentMapping/Api}/MappingExporter.cs (94%) rename src/MiniExcel.Core/{Mapping => FluentMapping/Api}/MappingImporter.cs (96%) rename src/MiniExcel.Core/{Mapping => FluentMapping/Api}/MappingTemplater.cs (96%) create mode 100644 src/MiniExcel.Core/FluentMapping/Api/ProviderExtensions.cs rename src/MiniExcel.Core/{Mapping => FluentMapping}/CompiledMapping.cs (97%) rename src/MiniExcel.Core/{Mapping => FluentMapping}/Configuration/CollectionMappingBuilder.cs (94%) rename src/MiniExcel.Core/{Mapping => FluentMapping}/Configuration/ICollectionMappingBuilder.cs (84%) rename src/MiniExcel.Core/{Mapping => FluentMapping}/Configuration/IMappingConfiguration.cs (85%) rename src/MiniExcel.Core/{Mapping => FluentMapping}/Configuration/IPropertyMappingBuilder.cs (80%) rename src/MiniExcel.Core/{Mapping => FluentMapping}/Configuration/MappingConfiguration.cs (94%) rename src/MiniExcel.Core/{Mapping => FluentMapping}/Configuration/PropertyMappingBuilder.cs (93%) rename src/MiniExcel.Core/{Mapping => FluentMapping}/MappingCellStream.cs (96%) rename src/MiniExcel.Core/{Mapping => FluentMapping}/MappingCompiler.cs (97%) rename src/MiniExcel.Core/{Mapping => FluentMapping}/MappingReader.cs (97%) rename src/MiniExcel.Core/{Mapping => FluentMapping}/MappingRegistry.cs (93%) rename src/MiniExcel.Core/{Mapping => FluentMapping}/MappingTemplateApplicator.cs (96%) rename src/MiniExcel.Core/{Mapping => FluentMapping}/MappingTemplateProcessor.cs (97%) rename src/MiniExcel.Core/{Mapping => FluentMapping}/MappingWriter.cs (95%) rename src/MiniExcel.Core/{Mapping => FluentMapping}/NestedMappingInfo.cs (95%) delete mode 100644 src/MiniExcel.Core/Mapping/IMappingCellStream.cs diff --git a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/CreateExcelBenchmark.cs b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/CreateExcelBenchmark.cs index 223ecdcc..8c519a4c 100644 --- a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/CreateExcelBenchmark.cs +++ b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/CreateExcelBenchmark.cs @@ -6,7 +6,7 @@ using DocumentFormat.OpenXml.Spreadsheet; using MiniExcelLib.Benchmarks.Utils; using MiniExcelLib.Core; -using MiniExcelLib.Core.Mapping; +using MiniExcelLib.Core.FluentMapping; using NPOI.XSSF.UserModel; using OfficeOpenXml; diff --git a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/QueryExcelBenchmark.cs b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/QueryExcelBenchmark.cs index 553051e4..24d04fdc 100644 --- a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/QueryExcelBenchmark.cs +++ b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/QueryExcelBenchmark.cs @@ -5,7 +5,7 @@ using DocumentFormat.OpenXml.Spreadsheet; using ExcelDataReader; using MiniExcelLib.Core; -using MiniExcelLib.Core.Mapping; +using MiniExcelLib.Core.FluentMapping; using NPOI.XSSF.UserModel; using OfficeOpenXml; diff --git a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/TemplateExcelBenchmark.cs b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/TemplateExcelBenchmark.cs index 9606efdc..89173841 100644 --- a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/TemplateExcelBenchmark.cs +++ b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/TemplateExcelBenchmark.cs @@ -2,7 +2,7 @@ using ClosedXML.Report; using MiniExcelLib.Benchmarks.Utils; using MiniExcelLib.Core; -using MiniExcelLib.Core.Mapping; +using MiniExcelLib.Core.FluentMapping; namespace MiniExcelLib.Benchmarks.BenchmarkSections; diff --git a/src/MiniExcel.Core/Mapping/MappingExporter.cs b/src/MiniExcel.Core/FluentMapping/Api/MappingExporter.cs similarity index 94% rename from src/MiniExcel.Core/Mapping/MappingExporter.cs rename to src/MiniExcel.Core/FluentMapping/Api/MappingExporter.cs index 6b38c52e..62e51654 100644 --- a/src/MiniExcel.Core/Mapping/MappingExporter.cs +++ b/src/MiniExcel.Core/FluentMapping/Api/MappingExporter.cs @@ -1,4 +1,4 @@ -namespace MiniExcelLib.Core.Mapping; +namespace MiniExcelLib.Core.FluentMapping; public sealed partial class MappingExporter { diff --git a/src/MiniExcel.Core/Mapping/MappingImporter.cs b/src/MiniExcel.Core/FluentMapping/Api/MappingImporter.cs similarity index 96% rename from src/MiniExcel.Core/Mapping/MappingImporter.cs rename to src/MiniExcel.Core/FluentMapping/Api/MappingImporter.cs index b64d70ff..d7a1e14d 100644 --- a/src/MiniExcel.Core/Mapping/MappingImporter.cs +++ b/src/MiniExcel.Core/FluentMapping/Api/MappingImporter.cs @@ -1,4 +1,4 @@ -namespace MiniExcelLib.Core.Mapping; +namespace MiniExcelLib.Core.FluentMapping; public sealed partial class MappingImporter() { diff --git a/src/MiniExcel.Core/Mapping/MappingTemplater.cs b/src/MiniExcel.Core/FluentMapping/Api/MappingTemplater.cs similarity index 96% rename from src/MiniExcel.Core/Mapping/MappingTemplater.cs rename to src/MiniExcel.Core/FluentMapping/Api/MappingTemplater.cs index 1ece7d7f..c2de939b 100644 --- a/src/MiniExcel.Core/Mapping/MappingTemplater.cs +++ b/src/MiniExcel.Core/FluentMapping/Api/MappingTemplater.cs @@ -1,4 +1,4 @@ -namespace MiniExcelLib.Core.Mapping; +namespace MiniExcelLib.Core.FluentMapping; public sealed partial class MappingTemplater() { 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/Mapping/CompiledMapping.cs b/src/MiniExcel.Core/FluentMapping/CompiledMapping.cs similarity index 97% rename from src/MiniExcel.Core/Mapping/CompiledMapping.cs rename to src/MiniExcel.Core/FluentMapping/CompiledMapping.cs index f055f727..84238b3c 100644 --- a/src/MiniExcel.Core/Mapping/CompiledMapping.cs +++ b/src/MiniExcel.Core/FluentMapping/CompiledMapping.cs @@ -1,4 +1,4 @@ -namespace MiniExcelLib.Core.Mapping; +namespace MiniExcelLib.Core.FluentMapping; internal class CompiledMapping { diff --git a/src/MiniExcel.Core/Mapping/Configuration/CollectionMappingBuilder.cs b/src/MiniExcel.Core/FluentMapping/Configuration/CollectionMappingBuilder.cs similarity index 94% rename from src/MiniExcel.Core/Mapping/Configuration/CollectionMappingBuilder.cs rename to src/MiniExcel.Core/FluentMapping/Configuration/CollectionMappingBuilder.cs index 98f30e41..57719ea8 100644 --- a/src/MiniExcel.Core/Mapping/Configuration/CollectionMappingBuilder.cs +++ b/src/MiniExcel.Core/FluentMapping/Configuration/CollectionMappingBuilder.cs @@ -1,4 +1,4 @@ -namespace MiniExcelLib.Core.Mapping.Configuration; +namespace MiniExcelLib.Core.FluentMapping.Configuration; internal partial class CollectionMappingBuilder : ICollectionMappingBuilder where TCollection : IEnumerable { diff --git a/src/MiniExcel.Core/Mapping/Configuration/ICollectionMappingBuilder.cs b/src/MiniExcel.Core/FluentMapping/Configuration/ICollectionMappingBuilder.cs similarity index 84% rename from src/MiniExcel.Core/Mapping/Configuration/ICollectionMappingBuilder.cs rename to src/MiniExcel.Core/FluentMapping/Configuration/ICollectionMappingBuilder.cs index 6e29b9b2..03fd8dc7 100644 --- a/src/MiniExcel.Core/Mapping/Configuration/ICollectionMappingBuilder.cs +++ b/src/MiniExcel.Core/FluentMapping/Configuration/ICollectionMappingBuilder.cs @@ -1,4 +1,4 @@ -namespace MiniExcelLib.Core.Mapping.Configuration; +namespace MiniExcelLib.Core.FluentMapping.Configuration; public interface ICollectionMappingBuilder where TCollection : IEnumerable { diff --git a/src/MiniExcel.Core/Mapping/Configuration/IMappingConfiguration.cs b/src/MiniExcel.Core/FluentMapping/Configuration/IMappingConfiguration.cs similarity index 85% rename from src/MiniExcel.Core/Mapping/Configuration/IMappingConfiguration.cs rename to src/MiniExcel.Core/FluentMapping/Configuration/IMappingConfiguration.cs index f7d9ca41..c8200540 100644 --- a/src/MiniExcel.Core/Mapping/Configuration/IMappingConfiguration.cs +++ b/src/MiniExcel.Core/FluentMapping/Configuration/IMappingConfiguration.cs @@ -1,6 +1,6 @@ using System.Linq.Expressions; -namespace MiniExcelLib.Core.Mapping.Configuration; +namespace MiniExcelLib.Core.FluentMapping.Configuration; public interface IMappingConfiguration { diff --git a/src/MiniExcel.Core/Mapping/Configuration/IPropertyMappingBuilder.cs b/src/MiniExcel.Core/FluentMapping/Configuration/IPropertyMappingBuilder.cs similarity index 80% rename from src/MiniExcel.Core/Mapping/Configuration/IPropertyMappingBuilder.cs rename to src/MiniExcel.Core/FluentMapping/Configuration/IPropertyMappingBuilder.cs index 04072926..8580ed9c 100644 --- a/src/MiniExcel.Core/Mapping/Configuration/IPropertyMappingBuilder.cs +++ b/src/MiniExcel.Core/FluentMapping/Configuration/IPropertyMappingBuilder.cs @@ -1,4 +1,4 @@ -namespace MiniExcelLib.Core.Mapping.Configuration; +namespace MiniExcelLib.Core.FluentMapping.Configuration; public interface IPropertyMappingBuilder { diff --git a/src/MiniExcel.Core/Mapping/Configuration/MappingConfiguration.cs b/src/MiniExcel.Core/FluentMapping/Configuration/MappingConfiguration.cs similarity index 94% rename from src/MiniExcel.Core/Mapping/Configuration/MappingConfiguration.cs rename to src/MiniExcel.Core/FluentMapping/Configuration/MappingConfiguration.cs index ca25c4f8..1f35f778 100644 --- a/src/MiniExcel.Core/Mapping/Configuration/MappingConfiguration.cs +++ b/src/MiniExcel.Core/FluentMapping/Configuration/MappingConfiguration.cs @@ -1,6 +1,6 @@ using System.Linq.Expressions; -namespace MiniExcelLib.Core.Mapping.Configuration; +namespace MiniExcelLib.Core.FluentMapping.Configuration; internal class MappingConfiguration : IMappingConfiguration { diff --git a/src/MiniExcel.Core/Mapping/Configuration/PropertyMappingBuilder.cs b/src/MiniExcel.Core/FluentMapping/Configuration/PropertyMappingBuilder.cs similarity index 93% rename from src/MiniExcel.Core/Mapping/Configuration/PropertyMappingBuilder.cs rename to src/MiniExcel.Core/FluentMapping/Configuration/PropertyMappingBuilder.cs index 8ce06c60..f0c490fd 100644 --- a/src/MiniExcel.Core/Mapping/Configuration/PropertyMappingBuilder.cs +++ b/src/MiniExcel.Core/FluentMapping/Configuration/PropertyMappingBuilder.cs @@ -1,4 +1,4 @@ -namespace MiniExcelLib.Core.Mapping.Configuration; +namespace MiniExcelLib.Core.FluentMapping.Configuration; internal partial class PropertyMappingBuilder : IPropertyMappingBuilder { diff --git a/src/MiniExcel.Core/Mapping/MappingCellStream.cs b/src/MiniExcel.Core/FluentMapping/MappingCellStream.cs similarity index 96% rename from src/MiniExcel.Core/Mapping/MappingCellStream.cs rename to src/MiniExcel.Core/FluentMapping/MappingCellStream.cs index 2021e0ad..1834de67 100644 --- a/src/MiniExcel.Core/Mapping/MappingCellStream.cs +++ b/src/MiniExcel.Core/FluentMapping/MappingCellStream.cs @@ -1,6 +1,11 @@ using MiniExcelLib.Core.WriteAdapters; -namespace MiniExcelLib.Core.Mapping; +namespace MiniExcelLib.Core.FluentMapping; + +internal interface IMappingCellStream +{ + IMiniExcelWriteAdapter CreateAdapter(); +} internal readonly struct MappingCellStream(IEnumerable items, CompiledMapping mapping, string[] columnLetters) : IMappingCellStream where T : class diff --git a/src/MiniExcel.Core/Mapping/MappingCompiler.cs b/src/MiniExcel.Core/FluentMapping/MappingCompiler.cs similarity index 97% rename from src/MiniExcel.Core/Mapping/MappingCompiler.cs rename to src/MiniExcel.Core/FluentMapping/MappingCompiler.cs index 88d20d24..4c5a1490 100644 --- a/src/MiniExcel.Core/Mapping/MappingCompiler.cs +++ b/src/MiniExcel.Core/FluentMapping/MappingCompiler.cs @@ -1,7 +1,7 @@ using System.Linq.Expressions; -using MiniExcelLib.Core.Mapping.Configuration; +using MiniExcelLib.Core.FluentMapping.Configuration; -namespace MiniExcelLib.Core.Mapping; +namespace MiniExcelLib.Core.FluentMapping; /// /// Compiles mapping configurations into optimized runtime representations for efficient Excel read/write operations. diff --git a/src/MiniExcel.Core/Mapping/MappingReader.cs b/src/MiniExcel.Core/FluentMapping/MappingReader.cs similarity index 97% rename from src/MiniExcel.Core/Mapping/MappingReader.cs rename to src/MiniExcel.Core/FluentMapping/MappingReader.cs index d71e21a5..0ed06914 100644 --- a/src/MiniExcel.Core/Mapping/MappingReader.cs +++ b/src/MiniExcel.Core/FluentMapping/MappingReader.cs @@ -1,4 +1,4 @@ -namespace MiniExcelLib.Core.Mapping; +namespace MiniExcelLib.Core.FluentMapping; internal static partial class MappingReader where T : class, new() { diff --git a/src/MiniExcel.Core/Mapping/MappingRegistry.cs b/src/MiniExcel.Core/FluentMapping/MappingRegistry.cs similarity index 93% rename from src/MiniExcel.Core/Mapping/MappingRegistry.cs rename to src/MiniExcel.Core/FluentMapping/MappingRegistry.cs index 53fe6b8f..90893f3a 100644 --- a/src/MiniExcel.Core/Mapping/MappingRegistry.cs +++ b/src/MiniExcel.Core/FluentMapping/MappingRegistry.cs @@ -1,6 +1,6 @@ -using MiniExcelLib.Core.Mapping.Configuration; +using MiniExcelLib.Core.FluentMapping.Configuration; -namespace MiniExcelLib.Core.Mapping; +namespace MiniExcelLib.Core.FluentMapping; public sealed class MappingRegistry { diff --git a/src/MiniExcel.Core/Mapping/MappingTemplateApplicator.cs b/src/MiniExcel.Core/FluentMapping/MappingTemplateApplicator.cs similarity index 96% rename from src/MiniExcel.Core/Mapping/MappingTemplateApplicator.cs rename to src/MiniExcel.Core/FluentMapping/MappingTemplateApplicator.cs index 7b8f0013..ef165eaf 100644 --- a/src/MiniExcel.Core/Mapping/MappingTemplateApplicator.cs +++ b/src/MiniExcel.Core/FluentMapping/MappingTemplateApplicator.cs @@ -1,4 +1,4 @@ -namespace MiniExcelLib.Core.Mapping; +namespace MiniExcelLib.Core.FluentMapping; internal static partial class MappingTemplateApplicator where T : class { diff --git a/src/MiniExcel.Core/Mapping/MappingTemplateProcessor.cs b/src/MiniExcel.Core/FluentMapping/MappingTemplateProcessor.cs similarity index 97% rename from src/MiniExcel.Core/Mapping/MappingTemplateProcessor.cs rename to src/MiniExcel.Core/FluentMapping/MappingTemplateProcessor.cs index e652c5e2..dd21c705 100644 --- a/src/MiniExcel.Core/Mapping/MappingTemplateProcessor.cs +++ b/src/MiniExcel.Core/FluentMapping/MappingTemplateProcessor.cs @@ -1,4 +1,4 @@ -namespace MiniExcelLib.Core.Mapping; +namespace MiniExcelLib.Core.FluentMapping; internal partial struct MappingTemplateProcessor(CompiledMapping mapping) where T : class { diff --git a/src/MiniExcel.Core/Mapping/MappingWriter.cs b/src/MiniExcel.Core/FluentMapping/MappingWriter.cs similarity index 95% rename from src/MiniExcel.Core/Mapping/MappingWriter.cs rename to src/MiniExcel.Core/FluentMapping/MappingWriter.cs index da19f024..4b29f3b1 100644 --- a/src/MiniExcel.Core/Mapping/MappingWriter.cs +++ b/src/MiniExcel.Core/FluentMapping/MappingWriter.cs @@ -1,4 +1,4 @@ -namespace MiniExcelLib.Core.Mapping; +namespace MiniExcelLib.Core.FluentMapping; internal static partial class MappingWriter where T : class diff --git a/src/MiniExcel.Core/Mapping/NestedMappingInfo.cs b/src/MiniExcel.Core/FluentMapping/NestedMappingInfo.cs similarity index 95% rename from src/MiniExcel.Core/Mapping/NestedMappingInfo.cs rename to src/MiniExcel.Core/FluentMapping/NestedMappingInfo.cs index b5fadad6..3d701b4f 100644 --- a/src/MiniExcel.Core/Mapping/NestedMappingInfo.cs +++ b/src/MiniExcel.Core/FluentMapping/NestedMappingInfo.cs @@ -1,4 +1,4 @@ -namespace MiniExcelLib.Core.Mapping; +namespace MiniExcelLib.Core.FluentMapping; /// /// Stores pre-compiled information about nested properties in collection mappings. diff --git a/src/MiniExcel.Core/GlobalUsings.cs b/src/MiniExcel.Core/GlobalUsings.cs index fec482eb..05f12759 100644 --- a/src/MiniExcel.Core/GlobalUsings.cs +++ b/src/MiniExcel.Core/GlobalUsings.cs @@ -11,7 +11,6 @@ global using System.Xml; global using MiniExcelLib.Core.Abstractions; global using MiniExcelLib.Core.Helpers; -global using MiniExcelLib.Core.Mapping; global using MiniExcelLib.Core.OpenXml; global using MiniExcelLib.Core.OpenXml.Utils; global using MiniExcelLib.Core.Reflection; diff --git a/src/MiniExcel.Core/Helpers/MappingMetadataExtractor.cs b/src/MiniExcel.Core/Helpers/MappingMetadataExtractor.cs index fdcbe692..388a3e5e 100644 --- a/src/MiniExcel.Core/Helpers/MappingMetadataExtractor.cs +++ b/src/MiniExcel.Core/Helpers/MappingMetadataExtractor.cs @@ -1,3 +1,5 @@ +using MiniExcelLib.Core.FluentMapping; + namespace MiniExcelLib.Core.Helpers; /// diff --git a/src/MiniExcel.Core/Mapping/IMappingCellStream.cs b/src/MiniExcel.Core/Mapping/IMappingCellStream.cs deleted file mode 100644 index 5aa21260..00000000 --- a/src/MiniExcel.Core/Mapping/IMappingCellStream.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace MiniExcelLib.Core.Mapping; - -internal interface IMappingCellStream -{ - IMiniExcelWriteAdapter CreateAdapter(); -} \ No newline at end of file diff --git a/src/MiniExcel.Core/MiniExcelProviders.cs b/src/MiniExcel.Core/MiniExcelProviders.cs index 4903f49f..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 @@ -5,8 +10,6 @@ public sealed class MiniExcelImporterProvider internal MiniExcelImporterProvider() { } public OpenXmlImporter GetOpenXmlImporter() => new(); - public MappingImporter GetMappingImporter() => new(); - public MappingImporter GetMappingImporter(MappingRegistry registry) => new(registry); } public sealed class MiniExcelExporterProvider @@ -14,8 +17,6 @@ public sealed class MiniExcelExporterProvider internal MiniExcelExporterProvider() { } public OpenXmlExporter GetOpenXmlExporter() => new(); - public MappingExporter GetMappingExporter() => new(); - public MappingExporter GetMappingExporter(MappingRegistry registry) => new(registry); } public sealed class MiniExcelTemplaterProvider @@ -23,5 +24,4 @@ public sealed class MiniExcelTemplaterProvider internal MiniExcelTemplaterProvider() { } public OpenXmlTemplater GetOpenXmlTemplater() => new(); - public MappingTemplater GetMappingTemplater(MappingRegistry registry) => new(registry); } diff --git a/src/MiniExcel.Core/WriteAdapters/MappingCellStreamAdapter.cs b/src/MiniExcel.Core/WriteAdapters/MappingCellStreamAdapter.cs index 4df5ad3a..bdf49a1d 100644 --- a/src/MiniExcel.Core/WriteAdapters/MappingCellStreamAdapter.cs +++ b/src/MiniExcel.Core/WriteAdapters/MappingCellStreamAdapter.cs @@ -1,3 +1,5 @@ +using MiniExcelLib.Core.FluentMapping; + namespace MiniExcelLib.Core.WriteAdapters; internal class MappingCellStreamAdapter : IMiniExcelWriteAdapter diff --git a/src/MiniExcel.Core/WriteAdapters/MiniExcelWriteAdapterFactory.cs b/src/MiniExcel.Core/WriteAdapters/MiniExcelWriteAdapterFactory.cs index 9e7572ec..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 { diff --git a/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingCompilerTests.cs b/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingCompilerTests.cs index 6c121c31..490bf9ff 100644 --- a/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingCompilerTests.cs +++ b/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingCompilerTests.cs @@ -1,4 +1,4 @@ -using MiniExcelLib.Core.Mapping; +using MiniExcelLib.Core.FluentMapping; namespace MiniExcelLib.Tests.FluentMapping { diff --git a/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTemplateTests.cs b/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTemplateTests.cs index 557f9895..b6c561f5 100644 --- a/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTemplateTests.cs +++ b/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTemplateTests.cs @@ -1,4 +1,4 @@ -using MiniExcelLib.Core.Mapping; +using MiniExcelLib.Core.FluentMapping; using MiniExcelLib.Tests.Common.Utils; namespace MiniExcelLib.Tests.FluentMapping; diff --git a/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTests.cs b/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTests.cs index 47c08a2e..d260bef1 100644 --- a/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTests.cs +++ b/tests/MiniExcel.Core.Tests/FluentMapping/MiniExcelMappingTests.cs @@ -1,5 +1,5 @@ using System.Reflection; -using MiniExcelLib.Core.Mapping; +using MiniExcelLib.Core.FluentMapping; namespace MiniExcelLib.Tests.FluentMapping { @@ -455,7 +455,7 @@ public async Task Collection_WithItemMappingOnly_ShouldWriteAndReadCorrectly() var gridProp = departmentMapping.GetType().GetProperty("OptimizedCellGrid", BindingFlags.Instance | BindingFlags.Public); var grid = gridProp?.GetValue(departmentMapping) as Array; Assert.NotNull(grid); - var handlerType = typeof(MappingRegistry).Assembly.GetType("MiniExcelLib.Core.Mapping.OptimizedCellHandler"); + var handlerType = typeof(MappingRegistry).Assembly.GetType("MiniExcelLib.Core.FluentMapping.OptimizedCellHandler"); Assert.NotNull(handlerType); var valueSetterProperty = handlerType!.GetProperty("ValueSetter", BindingFlags.Instance | BindingFlags.Public); var propertyNameProperty = handlerType.GetProperty("PropertyName", BindingFlags.Instance | BindingFlags.Public); diff --git a/tests/MiniExcel.Csv.Tests/IssueTests.cs b/tests/MiniExcel.Csv.Tests/IssueTests.cs index 2dfb55be..4826bf5b 100644 --- a/tests/MiniExcel.Csv.Tests/IssueTests.cs +++ b/tests/MiniExcel.Csv.Tests/IssueTests.cs @@ -1008,5 +1008,4 @@ public void Issue507_3_MismatchedQuoteCsv() var getRowsInfo = _csvImporter.Query(stream, configuration: config).ToArray(); Assert.Equal(2, getRowsInfo.Length); } - } From f507c61ad595f16a5cb66a9cea7cd9ed06268886 Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Tue, 14 Oct 2025 22:01:26 +0200 Subject: [PATCH 15/16] Rebasing and fixing wrong parameter order --- src/MiniExcel.Core/FluentMapping/MappingWriter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MiniExcel.Core/FluentMapping/MappingWriter.cs b/src/MiniExcel.Core/FluentMapping/MappingWriter.cs index 4b29f3b1..656ddb89 100644 --- a/src/MiniExcel.Core/FluentMapping/MappingWriter.cs +++ b/src/MiniExcel.Core/FluentMapping/MappingWriter.cs @@ -41,6 +41,6 @@ private static async Task SaveAsOptimizedAsync(Stream stream, IEnumerable .CreateAsync(stream, cellStream, mapping.WorksheetName, false, configuration, cancellationToken) .ConfigureAwait(false); - return await writer.SaveAsAsync(cancellationToken).ConfigureAwait(false); + return await writer.SaveAsAsync(null, cancellationToken).ConfigureAwait(false); } } \ No newline at end of file From f58ab72e21ab2f136366206c3da8a119fefb5427 Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Tue, 14 Oct 2025 23:26:14 +0200 Subject: [PATCH 16/16] Repairing codeql setup --- .github/workflows/codeql-analysis.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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