From 835666d243588d57d5ae29f07df6b0a14583858e Mon Sep 17 00:00:00 2001 From: Victor Irzak Date: Mon, 27 Jul 2026 16:22:43 -0400 Subject: [PATCH 1/2] Write a blank line for a null record in WriteRecordsAsync WriteRecords(IEnumerable) writes a blank line when a record is null, since every record in an untyped sequence could be a different type. The asynchronous overload lost that branch and falls through to the write delegate instead, so it emits a delimiter per member, and throws when the first record is null because there is no type to resolve a delegate from. The two implementations were last changed together in d0b6e3b8, which updated them inconsistently. WriteNullTests only covered the synchronous path, so nothing noticed. Add the branch back, and give the null tests asynchronous counterparts. --- src/CsvHelper/CsvWriter.cs | 9 +++- .../CsvHelper.Tests/Writing/WriteNullTests.cs | 53 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/CsvHelper/CsvWriter.cs b/src/CsvHelper/CsvWriter.cs index d74da342d..f9fb71c2b 100644 --- a/src/CsvHelper/CsvWriter.cs +++ b/src/CsvHelper/CsvWriter.cs @@ -489,7 +489,14 @@ public virtual async Task WriteRecordsAsync(IEnumerable records, CancellationTok var record = enumerator.Current; - if (write == null || (record != null && writeType.RecordType != record.GetType())) + if (record == null) + { + // Since every record could be a different type, just write a blank line. + await NextRecordAsync().ConfigureAwait(false); + continue; + } + + if (write == null || writeType.RecordType != record.GetType()) { writeType = GetTypeInfoForRecord(record); write = recordManager.Value.GetWriteDelegate(writeType); diff --git a/tests/CsvHelper.Tests/Writing/WriteNullTests.cs b/tests/CsvHelper.Tests/Writing/WriteNullTests.cs index d9400e049..0934345ab 100644 --- a/tests/CsvHelper.Tests/Writing/WriteNullTests.cs +++ b/tests/CsvHelper.Tests/Writing/WriteNullTests.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Threading.Tasks; using Xunit; namespace CsvHelper.Tests.Writing @@ -80,6 +81,58 @@ public void WriteRecord_RecordIsNull_WritesEmptyRecord() } } + [Fact] + public async Task WriteRecordsEnumerableGenericAsync_RecordIsNull_WritesEmptyRecord() + { + var records = new List + { + new Foo { Id = 1, Name = "one"}, + null, + new Foo { Id = 2, Name = "two" }, + }; + var config = new CsvConfiguration(CultureInfo.InvariantCulture); + using (var writer = new StringWriter()) + using (var csv = new CsvWriter(writer, config)) + { + await csv.WriteRecordsAsync(records); + await csv.FlushAsync(); + + var expected = new TestStringBuilder(config.NewLine); + expected.AppendLine("Id,Name"); + expected.AppendLine("1,one"); + expected.AppendLine(","); + expected.AppendLine("2,two"); + + Assert.Equal(expected, writer.ToString()); + } + } + + [Fact] + public async Task WriteRecordsEnumerableAsync_RecordIsNull_WritesEmptyRecord() + { + IEnumerable records = new List + { + new Foo { Id = 1, Name = "one"}, + null, + new Foo { Id = 2, Name = "two" }, + }; + var config = new CsvConfiguration(CultureInfo.InvariantCulture); + using (var writer = new StringWriter()) + using (var csv = new CsvWriter(writer, config)) + { + await csv.WriteRecordsAsync(records); + await csv.FlushAsync(); + + var expected = new TestStringBuilder(config.NewLine); + expected.AppendLine("Id,Name"); + expected.AppendLine("1,one"); + expected.AppendLine(""); + expected.AppendLine("2,two"); + + Assert.Equal(expected, writer.ToString()); + } + } + private class Foo { public int Id { get; set; } From 9d6b4674dea82e09074a1546443c3c2160f24ee8 Mon Sep 17 00:00:00 2001 From: Victor Irzak Date: Mon, 27 Jul 2026 16:25:00 -0400 Subject: [PATCH 2/2] Generate the synchronous overloads instead of maintaining them Three comments in these files ask that the synchronous and asynchronous implementations be kept identical by hand: CsvReader.Read Don't forget about the async method below! CsvWriter.WriteRecords Changes in this method require changes in method WriteRecords(IEnumerable) also. CsvWriter.WriteRecordsAsync These methods should all be the same The previous commit fixes what happens when one of them is missed. Delete the synchronous implementations and generate them from the asynchronous ones instead, so that the two cannot drift apart again: CsvWriter.WriteRecords(IEnumerable) CsvWriter.WriteRecords(IEnumerable) CsvWriter.NextRecord CsvWriter.Flush CsvReader.Read CsvParser.Read The generator runs at build time only and contributes nothing to the shipped assembly. Two pairs are left as they are. FlushBuffer documents itself as asynchronous, and documentation is copied to the generated method as written. GetRecords and EnumerateRecords name the method in an exception message, and the asynchronous copies of those messages still describe the synchronous overload. --- src/CsvHelper/CsvHelper.csproj | 7 ++ src/CsvHelper/CsvParser.cs | 37 +-------- src/CsvHelper/CsvReader.cs | 36 +-------- src/CsvHelper/CsvWriter.cs | 136 ++------------------------------- 4 files changed, 18 insertions(+), 198 deletions(-) diff --git a/src/CsvHelper/CsvHelper.csproj b/src/CsvHelper/CsvHelper.csproj index 804f98a56..4f9442b92 100644 --- a/src/CsvHelper/CsvHelper.csproj +++ b/src/CsvHelper/CsvHelper.csproj @@ -49,6 +49,13 @@ + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/src/CsvHelper/CsvParser.cs b/src/CsvHelper/CsvParser.cs index 83ad97b22..5b7e30cb5 100644 --- a/src/CsvHelper/CsvParser.cs +++ b/src/CsvHelper/CsvParser.cs @@ -13,7 +13,7 @@ namespace CsvHelper; /// /// Parses a CSV file. /// -public class CsvParser : IParser, IDisposable +public partial class CsvParser : IParser, IDisposable { private readonly IParserConfiguration configuration; private readonly FieldCache fieldCache = new FieldCache(); @@ -204,42 +204,9 @@ public CsvParser(TextReader reader, IParserConfiguration configuration, bool lea processedFields = new string[128]; } - /// - public bool Read() - { - isRecordProcessed = false; - rowStartPosition = bufferPosition; - fieldStartPosition = rowStartPosition; - fieldsPosition = 0; - quoteCount = 0; - row++; - rawRow++; - var c = '\0'; - var cPrev = c; - - while (true) - { - if (bufferPosition >= charsRead) - { - if (!FillBuffer()) - { - return ReadEndOfFile(); - } - - if (row == 1 && detectDelimiter) - { - DetectDelimiter(); - } - } - - if (ReadLine(ref c, ref cPrev) == ReadLineResult.Complete) - { - return true; - } - } - } /// + [Zomp.SyncMethodGenerator.CreateSyncVersion] public async Task ReadAsync() { isRecordProcessed = false; diff --git a/src/CsvHelper/CsvReader.cs b/src/CsvHelper/CsvReader.cs index 3fa74c2a0..ef12d777e 100644 --- a/src/CsvHelper/CsvReader.cs +++ b/src/CsvHelper/CsvReader.cs @@ -14,7 +14,7 @@ namespace CsvHelper; /// /// Reads data that was parsed from . /// -public class CsvReader : IReader +public partial class CsvReader : IReader { private readonly Lazy recordManager; private readonly bool detectColumnCountChanges; @@ -237,41 +237,9 @@ protected virtual void ValidateHeader(ClassMap map, List invalidH } } - /// - public virtual bool Read() - { - // Don't forget about the async method below! - - bool hasMoreRecords; - do - { - hasMoreRecords = parser.Read(); - hasBeenRead = true; - } - while (hasMoreRecords && (shouldSkipRecord?.Invoke(new ShouldSkipRecordArgs(this)) ?? false)); - - currentIndex = -1; - - if (detectColumnCountChanges && hasMoreRecords) - { - if (prevColumnCount > 0 && prevColumnCount != parser.Count) - { - var csvException = new BadDataException(string.Empty, parser.RawRecord, context, "An inconsistent number of columns has been detected."); - - var args = new ReadingExceptionOccurredArgs(csvException); - if (readingExceptionOccurred?.Invoke(args) ?? true) - { - throw csvException; - } - } - - prevColumnCount = parser.Count; - } - - return hasMoreRecords; - } /// + [Zomp.SyncMethodGenerator.CreateSyncVersion] public virtual async Task ReadAsync() { bool hasMoreRecords; diff --git a/src/CsvHelper/CsvWriter.cs b/src/CsvHelper/CsvWriter.cs index f9fb71c2b..a8249bac7 100644 --- a/src/CsvHelper/CsvWriter.cs +++ b/src/CsvHelper/CsvWriter.cs @@ -20,7 +20,7 @@ namespace CsvHelper; /// /// Used to write CSV files. /// -public class CsvWriter : IWriter +public partial class CsvWriter : IWriter { private readonly TextWriter writer; private readonly CsvContext context; @@ -346,119 +346,9 @@ public virtual void WriteRecord(T? record) } } - /// - public virtual void WriteRecords(IEnumerable records) - { - // Changes in this method require changes in method WriteRecords(IEnumerable records) also. - - var enumerator = records.GetEnumerator(); - - try - { - if (!enumerator.MoveNext()) - { - return; - } - - if (WriteHeaderFromRecord(enumerator.Current)) - { - NextRecord(); - } - - Action? write = null; - RecordTypeInfo writeType = default; - - do - { - var record = enumerator.Current; - - if (record == null) - { - // Since every record could be a different type, just write a blank line. - NextRecord(); - continue; - } - - if (write == null || writeType.RecordType != record.GetType()) - { - writeType = GetTypeInfoForRecord(record); - write = recordManager.Value.GetWriteDelegate(writeType); - } - - write(record); - NextRecord(); - } - while (enumerator.MoveNext()); - } - catch (Exception ex) when (ex is not CsvHelperException) - { - throw new WriterException(context, "An unexpected error occurred. See inner exception for details.", ex); - } - finally - { - if (enumerator is IDisposable en) - { - en.Dispose(); - } - } - } - - /// - public virtual void WriteRecords(IEnumerable records) - { - // Changes in this method require changes in method WriteRecords(IEnumerable records) also. - - var enumerator = records.GetEnumerator() ?? throw new InvalidOperationException("Enumerator is null."); - - try - { - if (WriteHeaderFromType()) - { - NextRecord(); - } - - if (!enumerator.MoveNext()) - { - return; - } - - if (WriteHeaderFromRecord(enumerator.Current)) - { - NextRecord(); - } - - Action? write = null; - RecordTypeInfo writeType = default; - - do - { - var record = enumerator.Current; - - if (write == null || (record != null && writeType.RecordType != typeof(T))) - { - writeType = GetTypeInfoForRecord(record); - write = recordManager.Value.GetWriteDelegate(writeType); - } - - write(record); - NextRecord(); - } - while (enumerator.MoveNext()); - } - catch (Exception ex) when (ex is not CsvHelperException) - { - throw new WriterException(context, "An unexpected error occurred. See inner exception for details.", ex); - } - finally - { - if (enumerator is IDisposable en) - { - en.Dispose(); - } - } - } /// + [Zomp.SyncMethodGenerator.CreateSyncVersion] public virtual async Task WriteRecordsAsync(IEnumerable records, CancellationToken cancellationToken = default) { // These methods should all be the same; @@ -521,6 +411,7 @@ public virtual async Task WriteRecordsAsync(IEnumerable records, CancellationTok } /// + [Zomp.SyncMethodGenerator.CreateSyncVersion] public virtual async Task WriteRecordsAsync(IEnumerable records, CancellationToken cancellationToken = default) { // These methods should all be the same; @@ -581,6 +472,8 @@ public virtual async Task WriteRecordsAsync(IEnumerable records, Cancellat } /// + [Zomp.SyncMethodGenerator.CreateSyncVersion] + [Zomp.SyncMethodGenerator.SkipSyncVersion] public virtual async Task WriteRecordsAsync(IAsyncEnumerable records, CancellationToken cancellationToken = default) { // These methods should all be the same; @@ -638,16 +531,7 @@ public virtual async Task WriteRecordsAsync(IAsyncEnumerable records, Canc } /// - public virtual void NextRecord() - { - WriteToBuffer(newLine); - FlushBuffer(); - - index = 0; - row++; - } - - /// + [Zomp.SyncMethodGenerator.CreateSyncVersion] public virtual async Task NextRecordAsync() { WriteToBuffer(newLine); @@ -658,13 +542,7 @@ public virtual async Task NextRecordAsync() } /// - public virtual void Flush() - { - FlushBuffer(); - writer.Flush(); - } - - /// + [Zomp.SyncMethodGenerator.CreateSyncVersion] public virtual async Task FlushAsync() { await FlushBufferAsync().ConfigureAwait(false);