Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 213 additions & 11 deletions ExcelFlow.SourceGenerators/MapperGenerator.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Text;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
Expand Down Expand Up @@ -32,61 +32,263 @@ private static void Execute(SourceProductionContext context, ITypeSymbol classSy
try
{
string className = classSymbol.Name;

// Handle nested classes properly for namespace and class declaration
var enclosingClasses = new List<string>();
var current = classSymbol.ContainingType;
while (current != null)
{
enclosingClasses.Insert(0, current.Name);
current = current.ContainingType;
}

string namespaceName = classSymbol.ContainingNamespace.ToDisplayString();
var sb = new StringBuilder();

sb.AppendLine("// <auto-generated/>");
sb.AppendLine("#nullable enable");
sb.AppendLine("using System;");
sb.AppendLine("using System.Collections.Generic;");
sb.AppendLine("using ExcelFlow;");
sb.AppendLine("using ExcelDataReader;");
sb.AppendLine("using DocumentFormat.OpenXml;");
sb.AppendLine("using DocumentFormat.OpenXml.Spreadsheet;");
sb.AppendLine();
sb.AppendLine($"namespace {namespaceName}");
sb.AppendLine("{");

foreach (var enclosing in enclosingClasses)
{
sb.AppendLine($" public partial class {enclosing}");
sb.AppendLine(" {");
}

sb.AppendLine($" public partial class {className} : IExcelFlowSerializable<{className}>");
sb.AppendLine(" {");

sb.AppendLine($" public static global::System.Collections.Generic.IEnumerable<global::ExcelFlow.ExcelColumnDefinition<{className}>> GetDefinitions()");
sb.AppendLine(" {");

var properties = classSymbol.GetMembers().OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic);
.Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic)
.ToList();

foreach (var prop in properties)
{
var columnAttr = prop.GetAttributes().FirstOrDefault(ad =>
ad.AttributeClass?.Name.Contains("ExcelColumn") == true);

string columnName = columnAttr != null && columnAttr.ConstructorArguments.Length > 0
? columnAttr.ConstructorArguments[0].Value?.ToString() ?? prop.Name
: prop.Name;
string columnName = prop.Name;
if (columnAttr != null && columnAttr.ConstructorArguments.Length > 0)
{
columnName = columnAttr.ConstructorArguments[0].Value?.ToString() ?? prop.Name;
}

string propType = prop.Type.ToDisplayString();
string propName = prop.Name;

string typeofType = prop.Type.IsReferenceType
? prop.Type.WithNullableAnnotation(NullableAnnotation.NotAnnotated).ToDisplayString()
: propType;
var underlyingType = prop.Type.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T
? ((INamedTypeSymbol)prop.Type).TypeArguments[0]
: prop.Type;

var typeForTypeof = underlyingType.WithNullableAnnotation(NullableAnnotation.NotAnnotated).ToDisplayString();

string setterStr = $"(item, val) => item.{propName} = val == null ? default! : ({propType})val";
string getterStr = prop.GetMethod is not null ? $"(item) => item.{propName}" : "null";

sb.AppendLine($" yield return new global::ExcelFlow.ExcelColumnDefinition<{className}>(");
sb.AppendLine($" \"{columnName}\",");
sb.AppendLine($" typeof({typeofType}),");
sb.AppendLine($" typeof({typeForTypeof}),");
sb.AppendLine($" {setterStr},");
sb.AppendLine($" {getterStr}");
sb.AppendLine($" );");
}
sb.AppendLine(" }");

sb.AppendLine();
sb.AppendLine($" public static int[] InitializeIndexMap(global::System.Collections.Generic.Dictionary<string, int> headerMap)");
sb.AppendLine(" {");
sb.AppendLine($" int[] map = new int[{properties.Count}];");

for (int i = 0; i < properties.Count; i++)
{
var prop = properties[i];
var columnAttr = prop.GetAttributes().FirstOrDefault(ad =>
ad.AttributeClass?.Name.Contains("ExcelColumn") == true);

int indexVal = -1;
bool isRequired = false;
string columnName = prop.Name;

if (columnAttr != null)
{
if (columnAttr.ConstructorArguments.Length > 0)
columnName = columnAttr.ConstructorArguments[0].Value?.ToString() ?? prop.Name;

foreach (var namedArg in columnAttr.NamedArguments)
{
if (namedArg.Key == "Index" && namedArg.Value.Value is int idx)
indexVal = idx;
if (namedArg.Key == "IsRequired" && namedArg.Value.Value is bool req)
isRequired = req;
}
}

if (indexVal >= 0)
{
sb.AppendLine($" map[{i}] = {indexVal};");
}
else
{
sb.AppendLine($" if (headerMap.TryGetValue(\"{columnName}\", out int idx{i}))");
sb.AppendLine($" map[{i}] = idx{i};");
sb.AppendLine($" else");
if (isRequired)
sb.AppendLine($" throw new System.InvalidOperationException(\"Required column '{columnName}' is missing in the file.\");");
else
sb.AppendLine($" map[{i}] = -1;");
}
}
sb.AppendLine(" return map;");
sb.AppendLine(" }");

sb.AppendLine();
sb.AppendLine($" public static void ParseRow(IExcelDataReader reader, int[] indexMap, out {className} item, Action<ExcelParseError>? onError, int rowNumber)");
sb.AppendLine(" {");
sb.AppendLine($" item = new {className}();");

for (int i = 0; i < properties.Count; i++)
{
var prop = properties[i];
string propName = prop.Name;
string fullType = prop.Type.ToDisplayString();

var underlyingType = prop.Type.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T
? ((INamedTypeSymbol)prop.Type).TypeArguments[0]
: prop.Type;

var typeForTypeof = underlyingType.WithNullableAnnotation(NullableAnnotation.NotAnnotated).ToDisplayString();
string underStr = underlyingType.ToDisplayString();

sb.AppendLine($" if (indexMap[{i}] >= 0)");
sb.AppendLine(" {");
sb.AppendLine($" if (!reader.IsDBNull(indexMap[{i}]))");
sb.AppendLine(" {");
sb.AppendLine($" object rawValue = reader.GetValue(indexMap[{i}]);");
sb.AppendLine(" try {");

if (underStr == "string" || underStr == "string?")
{
sb.AppendLine($" item.{propName} = rawValue.ToString()!;");
}
else if (underStr == "int" || underStr == "long" || underStr == "short" || underStr == "byte" || underStr == "double" || underStr == "float" || underStr == "decimal")
{
sb.AppendLine($" if (rawValue is double d) item.{propName} = ({fullType})d;");
sb.AppendLine($" else if (rawValue is string s) item.{propName} = {typeForTypeof}.Parse(s, System.Globalization.CultureInfo.InvariantCulture);");
sb.AppendLine($" else item.{propName} = ({fullType})Convert.ChangeType(rawValue, typeof({typeForTypeof}), System.Globalization.CultureInfo.InvariantCulture);");
}
else if (underStr == "System.DateTime")
{
sb.AppendLine($" if (rawValue is System.DateTime dt) item.{propName} = dt;");
sb.AppendLine($" else if (rawValue is double d) item.{propName} = System.DateTime.FromOADate(d);");
sb.AppendLine($" else if (rawValue is string s) item.{propName} = System.DateTime.Parse(s);");
sb.AppendLine($" else item.{propName} = (System.DateTime)Convert.ChangeType(rawValue, typeof(System.DateTime), System.Globalization.CultureInfo.InvariantCulture);");
}
else if (underStr == "bool")
{
sb.AppendLine($" if (rawValue is bool b) item.{propName} = b;");
sb.AppendLine($" else if (rawValue is double d) item.{propName} = d != 0;");
sb.AppendLine($" else if (rawValue is string s) item.{propName} = bool.Parse(s);");
sb.AppendLine($" else item.{propName} = (bool)Convert.ChangeType(rawValue, typeof(bool), System.Globalization.CultureInfo.InvariantCulture);");
}
else
{
sb.AppendLine($" item.{propName} = ({fullType})Convert.ChangeType(rawValue, typeof({typeForTypeof}), System.Globalization.CultureInfo.InvariantCulture);");
}

sb.AppendLine(" } catch (Exception) {");
sb.AppendLine($" onError?.Invoke(new ExcelParseError(rowNumber, \"{propName}\", rawValue?.ToString() ?? \"\", \"{fullType}\"));");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" }");
}
sb.AppendLine(" }");

sb.AppendLine();
sb.AppendLine($" public static void WriteHeaders(OpenXmlWriter writer)");
sb.AppendLine(" {");
sb.AppendLine(" writer.WriteStartElement(new Row());");
foreach (var prop in properties)
{
var columnAttr = prop.GetAttributes().FirstOrDefault(ad =>
ad.AttributeClass?.Name.Contains("ExcelColumn") == true);
string columnName = prop.Name;
if (columnAttr != null && columnAttr.ConstructorArguments.Length > 0)
columnName = columnAttr.ConstructorArguments[0].Value?.ToString() ?? prop.Name;

sb.AppendLine($" writer.WriteElement(new Cell {{ CellValue = new CellValue(\"{columnName}\"), DataType = CellValues.String }});");
}
sb.AppendLine(" writer.WriteEndElement();");
sb.AppendLine(" }");

sb.AppendLine();
sb.AppendLine($" public static void WriteRow(OpenXmlWriter writer, {className} item)");
sb.AppendLine(" {");
sb.AppendLine(" writer.WriteStartElement(new Row());");
foreach (var prop in properties)
{
var underlyingType = prop.Type.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T
? ((INamedTypeSymbol)prop.Type).TypeArguments[0]
: prop.Type;
string underStr = underlyingType.ToDisplayString();
bool isNullable = prop.Type.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T || prop.Type.IsReferenceType;

sb.AppendLine(" {");
if (isNullable)
{
sb.AppendLine($" if (item.{prop.Name} == null) writer.WriteElement(new Cell());");
sb.AppendLine(" else {");
}

if (underStr == "int" || underStr == "long" || underStr == "decimal" || underStr == "double" || underStr == "float")
{
sb.AppendLine($" writer.WriteElement(new Cell {{ CellValue = new CellValue(item.{prop.Name}.ToString(System.Globalization.CultureInfo.InvariantCulture)), DataType = CellValues.Number }});");
}
else if (underStr == "System.DateTime")
{
sb.AppendLine($" writer.WriteElement(new Cell {{ CellValue = new CellValue(item.{prop.Name}.ToOADate().ToString(System.Globalization.CultureInfo.InvariantCulture)), DataType = CellValues.Number }});");
}
else if (underStr == "bool")
{
sb.AppendLine($" writer.WriteElement(new Cell {{ CellValue = new CellValue(item.{prop.Name} ? \"1\" : \"0\"), DataType = CellValues.Boolean }});");
}
else
{
sb.AppendLine($" writer.WriteElement(new Cell {{ CellValue = new CellValue(item.{prop.Name}.ToString()), DataType = CellValues.String }});");
}

if (isNullable)
sb.AppendLine(" }");
sb.AppendLine(" }");
}
sb.AppendLine(" writer.WriteEndElement();");
sb.AppendLine(" }");

sb.AppendLine(" }");

foreach (string? enclosing in enclosingClasses)
{
sb.AppendLine(" }");
}

sb.AppendLine("}");

context.AddSource($"{className}.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8));
string safeClassName = string.Join("_", enclosingClasses.Concat(new[] { className }));
context.AddSource($"{safeClassName}.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8));
}
catch (Exception ex)
{
throw new Exception($"On mapper generation error: {ex}");
}
}
}
}
1 change: 1 addition & 0 deletions ExcelFlow.Tests/ExcelFlow.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

<ItemGroup>
<ProjectReference Include="..\ExcelFlow\ExcelFlow.csproj" />
<ProjectReference Include="..\ExcelFlow.SourceGenerators\ExcelFlow.SourceGenerators.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>

</Project>
22 changes: 11 additions & 11 deletions ExcelFlow.Tests/ExcelFlowComprehensiveTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,23 @@

namespace ExcelFlow.Tests;

public class ExcelFlowComprehensiveTests
public partial class ExcelFlowComprehensiveTests
{
// A fully compliant test model simulating an AOT-ready user class
public class ProductRecord : IExcelFlowSerializable<ProductRecord>
[ExcelFlowSerializable]
public partial class ProductRecord
{
[ExcelColumn("ID")]
public int Id { get; set; }

[ExcelColumn("Product Name")]
public string? Name { get; set; }

[ExcelColumn("Price")]
public decimal Price { get; set; }

[ExcelColumn("Added")]
public DateTime AddedDate { get; set; }

public static IEnumerable<ExcelColumnDefinition<ProductRecord>> GetDefinitions()
{
yield return new ExcelColumnDefinition<ProductRecord>("ID", typeof(int), (item, val) => item.Id = val == null ? default! : (int)val, item => item.Id);
yield return new ExcelColumnDefinition<ProductRecord>("Product Name", typeof(string), (item, val) => item.Name = (string?)val, item => item.Name);
yield return new ExcelColumnDefinition<ProductRecord>("Price", typeof(decimal), (item, val) => item.Price = val == null ? default! : (decimal)val, item => item.Price);
yield return new ExcelColumnDefinition<ProductRecord>("Added", typeof(DateTime), (item, val) => item.AddedDate = val == null ? default! : (DateTime)val, item => item.AddedDate);
}
}

private Stream GenerateTestExcelStream(bool includeTitleRow = false)
Expand Down Expand Up @@ -109,7 +109,7 @@ public void Read_OnError_CatchesTypeErrors()
// Assert
Assert.Single(errors);
Assert.Equal("Price", errors[0].ColumnName);
Assert.Equal("Decimal", errors[0].ExpectedType);
Assert.Equal("decimal", errors[0].ExpectedType);
}

[Fact]
Expand Down
11 changes: 11 additions & 0 deletions ExcelFlow.Tests/ValidationFluentTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
namespace ExcelFlow.Tests;
using ExcelDataReader;

public class ValidationFluentTests
{
Expand All @@ -13,6 +14,16 @@ public static IEnumerable<ExcelColumnDefinition<DummyRow>> GetDefinitions()
yield return new ExcelColumnDefinition<DummyRow>("Id", typeof(int), (item, val) => item.Id = (int)val!, item => item.Id);
yield return new ExcelColumnDefinition<DummyRow>("Amount", typeof(decimal), (item, val) => item.Amount = (decimal)val!, item => item.Amount);
}

public static int[] InitializeIndexMap(Dictionary<string, int> headerMap) => new[] { 0, 1 };
public static void ParseRow(IExcelDataReader reader, int[] indexMap, out DummyRow item, Action<ExcelParseError>? onError, int rowNumber)
{
item = new DummyRow();
// Dummy parsing logic not needed for builder tests
}

public static void WriteHeaders(DocumentFormat.OpenXml.OpenXmlWriter writer) { }
public static void WriteRow(DocumentFormat.OpenXml.OpenXmlWriter writer, DummyRow item) { }
}

[Fact]
Expand Down
Loading
Loading