From d632f09c54850574a8e7917378e78ee2677a348e Mon Sep 17 00:00:00 2001 From: SteveGuoh Date: Mon, 4 Aug 2025 13:34:35 +1000 Subject: [PATCH] fix: support FormatAttribute or other write-related attributes on record constructor parameters and field-style records (#2141) --- src/CsvHelper/Configuration/ClassMap.cs | 20 +++ src/CsvHelper/ReflectionHelper.cs | 12 ++ .../Mappings/CombinedFieldRecordMapTests.cs | 122 ++++++++++++++++ .../ConstructorRecordAttributeMappingTests.cs | 138 ++++++++++++++++++ .../FieldRecordAttributeMappingTests.cs | 121 +++++++++++++++ .../Reflection/RecordDetectionTests.cs | 40 +++++ 6 files changed, 453 insertions(+) create mode 100644 tests/CsvHelper.Tests/Mappings/CombinedFieldRecordMapTests.cs create mode 100644 tests/CsvHelper.Tests/Mappings/ConstructorRecordAttributeMappingTests.cs create mode 100644 tests/CsvHelper.Tests/Mappings/FieldRecordAttributeMappingTests.cs create mode 100644 tests/CsvHelper.Tests/Reflection/RecordDetectionTests.cs diff --git a/src/CsvHelper/Configuration/ClassMap.cs b/src/CsvHelper/Configuration/ClassMap.cs index 40885feb0..0d19fcae2 100644 --- a/src/CsvHelper/Configuration/ClassMap.cs +++ b/src/CsvHelper/Configuration/ClassMap.cs @@ -432,6 +432,26 @@ protected virtual void AutoMapMembers(ClassMap map, CsvContext context, LinkedLi memberMap.Data.TypeConverterOptions = TypeConverterOptions.Merge(new TypeConverterOptions(), context.TypeConverterOptionsCache.GetOptions(member.MemberType()), memberMap.Data.TypeConverterOptions); memberMap.Data.Index = map.GetMaxIndex() + 1; + // Try to find a constructor parameter that matches the current member by name (case-sensitive). + var matchingParam = map.ParameterMaps + .FirstOrDefault(p => string.Equals(p.Data.Parameter.Name, member.Name, StringComparison.Ordinal)); + + // If this is a record type and a matching constructor parameter was found, + // attempt to apply mapping attributes from the constructor parameter to the member map. + if (matchingParam != null && ReflectionHelper.IsRecord(map.ClassType)) + { + // Iterate over all attributes declared on the constructor parameter. + foreach (var attr in matchingParam.Data.Parameter.GetCustomAttributes(true)) + { + // Only consider attributes that implement IMemberMapper, such as Name, Format, Default, etc. + if (attr is IMemberMapper memberMapperAttr) + { + // Apply the attribute to the current member map. + memberMapperAttr.ApplyTo(memberMap); + } + } + } + ApplyAttributes(memberMap); map.MemberMaps.Add(memberMap); diff --git a/src/CsvHelper/ReflectionHelper.cs b/src/CsvHelper/ReflectionHelper.cs index 28655842b..d2c76e48a 100644 --- a/src/CsvHelper/ReflectionHelper.cs +++ b/src/CsvHelper/ReflectionHelper.cs @@ -197,4 +197,16 @@ public static Stack GetMembers(Expression + /// Checks if the given type is a C# record. + /// This is determined by the presence of a compiler-generated <Clone>$ method. + /// + /// The type to check. + /// True if the type is a record; otherwise, false. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsRecord(Type type) + { + return type.GetProperty("EqualityContract", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly) != null; + } } diff --git a/tests/CsvHelper.Tests/Mappings/CombinedFieldRecordMapTests.cs b/tests/CsvHelper.Tests/Mappings/CombinedFieldRecordMapTests.cs new file mode 100644 index 000000000..2a1089d74 --- /dev/null +++ b/tests/CsvHelper.Tests/Mappings/CombinedFieldRecordMapTests.cs @@ -0,0 +1,122 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Collections.Generic; +using CsvHelper; +using CsvHelper.Configuration; +using CsvHelper.Configuration.Attributes; +using CsvHelper.TypeConversion; +using Xunit; + +namespace CsvHelper.Tests.Mappings +{ + public class CombinedFieldRecordMapTests + { + [Fact] + public void Write_WithAttributes_ShouldRespectAttributes() + { + var records = new List + { + new CombinedFieldRecord + { + Name = "Dana", + Birthday = new DateTime(2005, 5, 5), + Age = 50, + Country = "AU", + IsActive = true + } + }; + + var config = new CsvConfiguration(CultureInfo.InvariantCulture); + using var writer = new StringWriter(); + using var csvWriter = new CsvWriter(writer, config); + csvWriter.WriteRecords(records); + + var result = writer.ToString(); + var expected = "Name,Birthday,Age,Country,IsActive\r\nDana,2005-05-05,50,AU,True\r\n"; + Assert.Equal(expected, result); + } + + [Fact] + public void Read_WithAttributes_ShouldApplyDefaultsAndConstants() + { + var inputCsv = "Name,Birthday\r\nDana,2005-05-05"; + + var config = new CsvConfiguration(CultureInfo.InvariantCulture) + { + HasHeaderRecord = true, + MissingFieldFound = null, + HeaderValidated = null + }; + + using var reader = new StringReader(inputCsv); + using var csvReader = new CsvReader(reader, config); + var records = csvReader.GetRecords().ToList(); + + Assert.Single(records); + var record = records[0]; + Assert.Equal("Dana", record.Name); + Assert.Equal(new DateTime(2005, 5, 5), record.Birthday); + Assert.Equal(50, record.Age); + Assert.Equal("AU", record.Country); + Assert.True(record.IsActive); + } + + [Fact] + public void Write_WithClassMap_ShouldOverrideAttributes() + { + var records = new List + { + new CombinedFieldRecord + { + Name = "Dana", + Birthday = new DateTime(2005, 5, 5), + Age = 99, + Country = "NZ", + IsActive = false + } + }; + + var config = new CsvConfiguration(CultureInfo.InvariantCulture); + using var writer = new StringWriter(); + using var csvWriter = new CsvWriter(writer, config); + csvWriter.Context.RegisterClassMap(); + + csvWriter.WriteRecords(records); + + var result = writer.ToString(); + var expected = "FullName,BirthdayFormatted\r\nDana,05-05-2005\r\n"; + Assert.Equal(expected, result); + } + + public record CombinedFieldRecord + { + public string? Name { get; init; } + + [Format("yyyy-MM-dd")] + public DateTime Birthday { get; init; } + + [Default(50)] + public int Age { get; init; } + + [Constant("AU")] + public string? Country { get; init; } + + [Constant(true)] + public bool IsActive { get; init; } + } + + public sealed class CombinedFieldRecordMap : ClassMap + { + public CombinedFieldRecordMap() + { + Map(m => m.Name).Name("FullName"); + Map(m => m.Birthday).Name("BirthdayFormatted").TypeConverterOption.Format("dd-MM-yyyy"); + Map(m => m.Age).Ignore(); + Map(m => m.Country).Ignore(); + Map(m => m.IsActive).Ignore(); + } + } + } +} diff --git a/tests/CsvHelper.Tests/Mappings/ConstructorRecordAttributeMappingTests.cs b/tests/CsvHelper.Tests/Mappings/ConstructorRecordAttributeMappingTests.cs new file mode 100644 index 000000000..87e69a22b --- /dev/null +++ b/tests/CsvHelper.Tests/Mappings/ConstructorRecordAttributeMappingTests.cs @@ -0,0 +1,138 @@ +using System.Globalization; +using CsvHelper.Configuration; +using CsvHelper.Configuration.Attributes; +using System.Runtime; +using Xunit; + + +namespace CsvHelper.Tests.Mappings +{ + public class ConstructorRecordAttributeMappingTests + { + [Fact] + public void WriteRecord_WithMultipleAttributes_ShouldRespectAll() + { + var records = new List + { + new(true, false, "Alice", new DateTime(1990, 2, 1), "CN", 35) + }; + + var writer = new StringWriter(); + var config = new CsvConfiguration(CultureInfo.InvariantCulture); + using var csv = new CsvWriter(writer, config); + csv.WriteRecords(records); + + var result = writer.ToString(); + var expected = "IsActive,Full Name,IsDeleted,Birthday,Country,Age\r\nTrue,Alice,False,1990-02-01,AU,35\r\n"; + Assert.Equal(expected, result); + } + + [Fact] + public void ReadRecord_WithMultipleAttributes_ShouldParseCorrectly() + { + var csv = "IsActive,Full Name,IsDeleted,Birthday,Country,Age\r\nTrue,Alice,False,1990-02-01,NZ,35"; + + var config = new CsvConfiguration(CultureInfo.InvariantCulture) + { + HasHeaderRecord = true + }; + + using var reader = new StringReader(csv); + using var csvReader = new CsvReader(reader, config); + var records = csvReader.GetRecords().ToList(); + + Assert.Single(records); + var r = records[0]; + Assert.True(r.IsActive); + Assert.False(r.IsDeleted); + Assert.Equal("Alice", r.Name); + Assert.Equal(new DateTime(1990, 2, 1), r.Birthday); + Assert.Equal("AU", r.Country); + Assert.Equal(35, r.Age); + } + + public record PersonRecordMulti( + [Name("IsActive")][Index(0)] bool IsActive, + [Name("IsDeleted")][Index(2)] bool IsDeleted, + [Name("Full Name")][Index(1)] string Name, + [Name("Birthday")][Format("yyyy-MM-dd")][Index(3)] DateTime Birthday, + [Name("Country")][Constant("AU")][Index(4)] string Country, + [Name("Age")] int Age, + [Ignore] string? Ignored = null + ); + + [Fact] + public void ReadRecord_WithMissingFieldAndDefaultAttribute_ShouldUseDefault() + { + var csv = "Full Name,Birthday\r\nBob,1999-12-12"; + + var config = new CsvConfiguration(CultureInfo.InvariantCulture) + { + HasHeaderRecord = true, + MissingFieldFound = null, + HeaderValidated = null + }; + + using var reader = new StringReader(csv); + using var csvReader = new CsvReader(reader, config); + var records = csvReader.GetRecords().ToList(); + + Assert.Single(records); + Assert.Equal("Bob", records[0].Name); + Assert.Equal(new DateTime(1999, 12, 12), records[0].Birthday); + Assert.Equal(99, records[0].Age); + } + + public record PartialRecord( + [Name("Full Name")] string Name, + [Format("yyyy-MM-dd")] DateTime Birthday, + [Default(99)] int Age + ); + + [Fact] + public void WriteRecord_WithIgnoreAttribute_ShouldNotWriteIgnoredField() + { + var records = new List + { + new("Visible", "ShouldBeIgnored") + }; + + var writer = new StringWriter(); + var config = new CsvConfiguration(CultureInfo.InvariantCulture); + using var csv = new CsvWriter(writer, config); + csv.WriteRecords(records); + + var result = writer.ToString(); + var expected = "VisibleField\r\nVisible\r\n"; + Assert.Equal(expected, result); + } + + [Fact] + public void ReadRecord_WithIgnoreAttribute_ShouldIgnoreFieldWhenReading() + { + var csv = "VisibleField,IgnoredField\r\nVisible,ShouldBeIgnored"; + + var config = new CsvConfiguration(CultureInfo.InvariantCulture) + { + HasHeaderRecord = true, + MissingFieldFound = null, + HeaderValidated = null + }; + + using var reader = new StringReader(csv); + using var csvReader = new CsvReader(reader, config); + var records = csvReader.GetRecords().ToList(); + + Assert.Single(records); + Assert.Equal("Visible", records[0].VisibleField); + Assert.Null(records[0].IgnoredField); + } + + public record RecordWithIgnore( + [Name("VisibleField")] string VisibleField, + [Ignore] string? IgnoredField + ); + + + } +} \ No newline at end of file diff --git a/tests/CsvHelper.Tests/Mappings/FieldRecordAttributeMappingTests.cs b/tests/CsvHelper.Tests/Mappings/FieldRecordAttributeMappingTests.cs new file mode 100644 index 000000000..9e67add51 --- /dev/null +++ b/tests/CsvHelper.Tests/Mappings/FieldRecordAttributeMappingTests.cs @@ -0,0 +1,121 @@ +using System.Globalization; +using CsvHelper.Configuration; +using CsvHelper.Configuration.Attributes; +using Xunit; + +namespace CsvHelper.Tests.Mappings +{ + public class FieldRecordAttributeMappingTests + { + [Fact] + public void WriteRecord_WithIgnoreAttribute_ShouldNotWriteIgnoredField() + { + var records = new List + { + new("Visible", "ShouldBeIgnored") + }; + + var writer = new StringWriter(); + var config = new CsvConfiguration(CultureInfo.InvariantCulture); + using var csv = new CsvWriter(writer, config); + csv.WriteRecords(records); + + var result = writer.ToString(); + var expected = "VisibleField\r\nVisible\r\n"; + Assert.Equal(expected, result); + } + + [Fact] + public void ReadRecord_WithIgnoreAttribute_ShouldIgnoreFieldWhenReading() + { + var csv = "VisibleField,IgnoredField\r\nVisible,ShouldBeIgnored"; + + var config = new CsvConfiguration(CultureInfo.InvariantCulture) + { + HasHeaderRecord = true, + MissingFieldFound = null, + HeaderValidated = null + }; + + using var reader = new StringReader(csv); + using var csvReader = new CsvReader(reader, config); + var records = csvReader.GetRecords().ToList(); + + Assert.Single(records); + Assert.Equal("Visible", records[0].VisibleField); + Assert.Null(records[0].IgnoredField); + } + + public record RecordWithIgnore( + [Name("VisibleField")] string VisibleField, + [Ignore] string? IgnoredField + ); + + [Fact] + public void Write_FieldStyleRecord_ShouldRespectAttributes() + { + var records = new List + { + new CombinedFieldRecord + { + Name = "Dana", + Birthday = new DateTime(2005, 5, 5), + Age = 50, + Country = "AU", + IsActive = true + } + }; + + var config = new CsvConfiguration(CultureInfo.InvariantCulture); + var writer = new StringWriter(); + using var csvWriter = new CsvWriter(writer, config); + csvWriter.WriteRecords(records); + + var written = writer.ToString(); + var expected = "Name,Birthday,Age,Country,IsActive\r\nDana,2005-05-05,50,AU,True\r\n"; + Assert.Equal(expected, written); + } + + [Fact] + public void Read_FieldStyleRecord_ShouldRespectAttributes() + { + var inputCsv = "Name,Birthday\r\nDana,2005-05-05"; + + var config = new CsvConfiguration(CultureInfo.InvariantCulture) + { + HasHeaderRecord = true, + MissingFieldFound = null, + HeaderValidated = null + }; + + using var reader = new StringReader(inputCsv); + using var csvReader = new CsvReader(reader, config); + var records = csvReader.GetRecords().ToList(); + + Assert.Single(records); + var record = records[0]; + Assert.Equal("Dana", record.Name); + Assert.Equal(new DateTime(2005, 5, 5), record.Birthday); + Assert.Equal(50, record.Age); + Assert.Equal("AU", record.Country); + Assert.True(record.IsActive); + } + + public record CombinedFieldRecord + { + public string? Name { get; init; } + + [Format("yyyy-MM-dd")] + public DateTime Birthday { get; init; } + + [Default(50)] + public int Age { get; init; } + + [Constant("AU")] + public string? Country { get; init; } + + [Constant(true)] + public bool IsActive { get; init; } + } + } +} diff --git a/tests/CsvHelper.Tests/Reflection/RecordDetectionTests.cs b/tests/CsvHelper.Tests/Reflection/RecordDetectionTests.cs new file mode 100644 index 000000000..4ebbe2609 --- /dev/null +++ b/tests/CsvHelper.Tests/Reflection/RecordDetectionTests.cs @@ -0,0 +1,40 @@ +using Xunit; + +namespace CsvHelper.Tests.Configuration +{ + public class RecordDetectionTests + { + private class TestClass + { + public int Id { get; set; } + } + + private record TestRecord(int Id); + private struct TestStruct + { + public int Id { get; } + public TestStruct(int id) + { + Id = id; + } + } + + [Fact] + public void IsRecord_ReturnsFalse_ForNormalClass() + { + Assert.False(ReflectionHelper.IsRecord(typeof(TestClass))); + } + + [Fact] + public void IsRecord_ReturnsFalse_ForStruct() + { + Assert.False(ReflectionHelper.IsRecord(typeof(TestStruct))); + } + + [Fact] + public void IsRecord_ReturnsTrue_ForRecord() + { + Assert.True(ReflectionHelper.IsRecord(typeof(TestRecord))); + } + } +}