Skip to content
Open
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
20 changes: 20 additions & 0 deletions src/CsvHelper/Configuration/ClassMap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions src/CsvHelper/ReflectionHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -197,4 +197,16 @@ public static Stack<MemberInfo> GetMembers<TModel, TProperty>(Expression<Func<TM

return memberExpression;
}

/// <summary>
/// Checks if the given type is a C# record.
/// This is determined by the presence of a compiler-generated &lt;Clone&gt;$ method.
/// </summary>
/// <param name="type">The type to check.</param>
/// <returns>True if the type is a record; otherwise, false.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsRecord(Type type)
{
return type.GetProperty("EqualityContract", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly) != null;
}
}
122 changes: 122 additions & 0 deletions tests/CsvHelper.Tests/Mappings/CombinedFieldRecordMapTests.cs
Original file line number Diff line number Diff line change
@@ -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<CombinedFieldRecord>
{
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<CombinedFieldRecord>().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<CombinedFieldRecord>
{
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<CombinedFieldRecordMap>();

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<CombinedFieldRecord>
{
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();
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<PersonRecordMulti>
{
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<PersonRecordMulti>().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<PartialRecord>().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<RecordWithIgnore>
{
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<RecordWithIgnore>().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
);


}
}
Loading