JoshClose / CsvHelper

Library to help reading and writing CSV files
http://joshclose.github.io/CsvHelper/
Other
4.73k stars 1.06k forks source link

Unexpected `InvalidCastException` in `TypeConverter<>` with nullable structs and value types #2238

Closed fjmorel closed 6 months ago

fjmorel commented 7 months ago

Describe the bug

When using the new generic TypeConverter<T> with a nullable value type (like bool?), writing null values does not work. I was working with Mongo's ObjectId, but converted this sample to use bool instead. This probably applies to any structs.

To Reproduce

using CsvHelper;
using CsvHelper.Configuration;
using CsvHelper.Configuration.Attributes;
using CsvHelper.TypeConversion;
using System.Globalization;
using System.Text;

namespace Tests;

public class CsvTests
{
    private readonly CsvConfiguration _config = new(CultureInfo.InvariantCulture);

    [Fact]
    public async Task WriteAndRead_GetIdenticalResults()
    {
        // bool? test = null;
        // Assert.True(test is Nullable<bool>);

        var original = new Item[]
        {
            new(true),
            new(false),
            new(null),
        };

        var text = await WriteCsv(original);
        // Assert.Equal("Test\r\nTrue\r\nFalse\r\n", text);
        var parsed = await ReadCsv(text);
        Assert.Equal(original, parsed);
    }

    public async Task<string> WriteCsv(IEnumerable<Item> items)
    {
        var stream = new MemoryStream();

        // Write data to stream
        var textWriter = new StreamWriter(stream);
        var writer = new CsvWriter(textWriter, _config);
        await writer.WriteRecordsAsync(items);
        await writer.FlushAsync();

        // Reset stream to beginning and read it
        stream.Position = 0;
        var reader = new StreamReader(stream);
        return await reader.ReadToEndAsync();
    }

    public async Task<List<Item>> ReadCsv(string text)
    {
        var bytes = Encoding.Default.GetBytes(text);
        var stream = new MemoryStream(bytes);
        var streamReader = new StreamReader(stream);
        var reader = new CsvReader(streamReader, _config);
        return await reader.GetRecordsAsync<Item>().ToListAsync();
    }
}

public record Item([property: TypeConverter(typeof(NullableValueTypeConverter))] bool? Test);

public sealed class NullableValueTypeConverter : TypeConverter<bool?>
{
    public override bool? ConvertFromString(string text, IReaderRow row, MemberMapData memberMapData)
    {
        if (bool.TryParse(text, out bool value))
            return value;
        return null;
    }

    public override string ConvertToString(bool? value, IWriterRow row, MemberMapData memberMapData)
    {
        return value?.ToString() ?? "";
    }
}

Expected behavior

When testing my type converter, I expect to be able to read and write any valid value.

Screenshots

image

Additional context

I believe this is due to the cast in TypeConverter<>. If you uncomment the first 2 lines of the test, you'll see that bool? null is bool? doesn't work.

string ITypeConverter.ConvertToString(object value, IWriterRow row, MemberMapData memberMapData)
{
    // this cast doesn't work as expected with nullable value types
    return value is T v
        ? ConvertToString(v, row, memberMapData)
        : throw new InvalidCastException();
}

Stack trace:

CsvHelper.WriterException: An unexpected error occurred. See inner exception for details.

CsvHelper.WriterException
An unexpected error occurred. See inner exception for details.
IWriter state:
   Row: 4
   Index: 0
   HeaderRecord:
4

   at CsvHelper.CsvWriter.WriteRecord[T](T record)
   at CsvHelper.CsvWriter.WriteRecordsAsync[T](IEnumerable`1 records, CancellationToken cancellationToken)
   ...

System.InvalidCastException
Specified cast is not valid.
   at CsvHelper.TypeConversion.TypeConverter`1.CsvHelper.TypeConversion.ITypeConverter.ConvertToString(Object value, IWriterRow row, MemberMapData memberMapData)
   at lambda_method25(Closure, Item)
   at CsvHelper.Expressions.RecordWriter.Write[T](T record)
   at CsvHelper.Expressions.RecordManager.Write[T](T record)
   at CsvHelper.CsvWriter.WriteRecord[T](T record)
fredericDelaporte commented 6 months ago

I was expecting nullable types like DateTime? to be supported, with null handling done in the custom converter implementation.

Maybe the issue could be solved by changing the code pinpointed by fjmorel to something like this:

return value is T v
    ? ConvertToString(v, row, memberMapData)
    : value == null && default(T) == null ? ConvertToString(default(T), row, memberMapData) : throw new InvalidCastException();
JoshClose commented 6 months ago

Fixed and in release 31.0.3 on NuGet.