diff --git a/README.md b/README.md index fd430da9..5e74194c 100644 --- a/README.md +++ b/README.md @@ -828,8 +828,33 @@ public CultureInfo? CultureInfo { get; init; } /// public bool WriteHeader { get; init; } = true; /// -/// Specifies whether to escape column values -/// when writing. +/// Disables checking if column count is the +/// same for all rows. +/// +/// +/// When true, the +/// will define how columns that are not set +/// are handled. For example, whether to skip +/// or write an empty column if a column has +/// not been set for a given row. +/// +/// If any columns are skipped, then columns of +/// a row may, therefore, be out of sync with +/// column names if +/// is true. +/// +/// As such, any number of columns can be +/// written as long as done sequentially. +/// +public bool DisableColCountCheck { get; init; } = false; +/// +/// Specifies how to handle columns that are +/// not set. +/// +public SepColNotSetOption ColNotSetOption { get; init; } = SepColNotSetOption.Throw; +/// +/// Specifies whether to escape column names +/// and values when writing. /// /// /// When true, if a column contains a separator @@ -838,6 +863,8 @@ public bool WriteHeader { get; init; } = true; /// is prefixed and suffixed with quotes `"` /// and any quote in the column is escaped by /// adding an extra quote so it becomes `""`. +/// Note that escape applies to column names +/// too, but only the written name. /// public bool Escape { get; init; } = false; ``` @@ -1854,6 +1881,12 @@ namespace nietras.SeparatedValues public static nietras.SeparatedValues.SepWriterOptions Writer() { } public static nietras.SeparatedValues.SepWriterOptions Writer(System.Func configure) { } } + public enum SepColNotSetOption : byte + { + Throw = 0, + Empty = 1, + Skip = 2, + } public delegate nietras.SeparatedValues.SepToString SepCreateToString(nietras.SeparatedValues.SepReaderHeader? maybeHeader, int colCount); public static class SepDefaults { @@ -2142,7 +2175,9 @@ namespace nietras.SeparatedValues { public SepWriterOptions() { } public SepWriterOptions(nietras.SeparatedValues.Sep sep) { } + public nietras.SeparatedValues.SepColNotSetOption ColNotSetOption { get; init; } public System.Globalization.CultureInfo? CultureInfo { get; init; } + public bool DisableColCountCheck { get; init; } public bool Escape { get; init; } public nietras.SeparatedValues.Sep Sep { get; init; } public bool WriteHeader { get; init; } diff --git a/src/Sep.Test/SepWriterOptionsTest.cs b/src/Sep.Test/SepWriterOptionsTest.cs index 6c5a0f39..ddd0f42e 100644 --- a/src/Sep.Test/SepWriterOptionsTest.cs +++ b/src/Sep.Test/SepWriterOptionsTest.cs @@ -13,6 +13,8 @@ public void SepWriterOptionsTest_Defaults() Assert.AreEqual(Sep.Default, sut.Sep); Assert.AreSame(SepDefaults.CultureInfo, sut.CultureInfo); Assert.IsTrue(sut.WriteHeader); + Assert.IsFalse(sut.DisableColCountCheck); + Assert.AreEqual(SepColNotSetOption.Throw, sut.ColNotSetOption); Assert.IsFalse(sut.Escape); } } diff --git a/src/Sep.Test/SepWriterTest.cs b/src/Sep.Test/SepWriterTest.cs index 1e7f63a0..6b9d963a 100644 --- a/src/Sep.Test/SepWriterTest.cs +++ b/src/Sep.Test/SepWriterTest.cs @@ -176,6 +176,32 @@ public void SepWriterTest_ColMissingInSecondRow() Assert.AreEqual(expected, writer.ToString()); } + [TestMethod] + public void SepWriterTest_ColMissingInSecondRow_ColNotSetEmpty() + { + using var writer = Sep.Writer( + o => o with { ColNotSetOption = SepColNotSetOption.Empty }).ToText(); + { + using var row1 = writer.NewRow(); + row1["A"].Set("1"); + row1["B"].Set("2"); + } + { + var row2 = writer.NewRow(); + row2["B"].Set("3"); + var e = AssertThrowsException(row2, + r => { r.Dispose(); }); + // TODO: Make detailed exception message + Assert.AreEqual("Not all expected columns 'A,B' have been set.", e.Message); + } + // Expected output should only be valid rows + var expected = +@"A;B +1;2 +"; + Assert.AreEqual(expected, writer.ToString()); + } + [TestMethod] public void SepWriterTest_ToString_ToStreamWriter_Throws() { @@ -350,6 +376,248 @@ public void SepWriterTest_WriteHeader_False_ColMissingInSecondRow() Assert.AreEqual(expected, writer.ToString()); } + [TestMethod] + public void SepWriterTest_DisableColCountCheck_ColNotSetDefaultThrow_Header_LessColumns_Throws() + { + var options = new SepWriterOptions { DisableColCountCheck = true }; + using var writer = options.ToText(); + { + using var row = writer.NewRow(); + row["A"].Set("R1C1"); + row["B"].Set("R1C2"); + } + Assert.ThrowsException(() => + { + using var row = writer.NewRow(); + row["B"].Set("R2C2"); + }); + } + + [TestMethod] + public void SepWriterTest_DisableColCountCheck_ColNotSetDefaultThrow_Header_MoreColumns_Ok() + { + var options = new SepWriterOptions { DisableColCountCheck = true }; + using var writer = options.ToText(); + { + using var row = writer.NewRow(); + row["A"].Set("R1C1"); + row["B"].Set("R1C2"); + } + { + using var row = writer.NewRow(); + row["A"].Set("R2C1"); + row["B"].Set("R2C2"); + row[2].Set("R2C3"); + }; + var expected = +@"A;B +R1C1;R1C2 +R2C1;R2C2;R2C3 +"; + Assert.AreEqual(expected, writer.ToString()); + } + + [TestMethod] + public void SepWriterTest_DisableColCountCheck_ColNotSetSkip_Header() + { + var options = new SepWriterOptions + { + DisableColCountCheck = true, + ColNotSetOption = SepColNotSetOption.Skip, + }; + using var writer = options.ToText(); + { + using var row = writer.NewRow(); + row["A"].Set("R1C1"); + row["B"].Set("R1C2"); + } + { + using var row = writer.NewRow(); + row["B"].Set("R2C2"); + } + { + using var row = writer.NewRow(); + row["A"].Set("R3C1"); + } + { + using var row = writer.NewRow(); + row["A"].Set("R4C1"); + row[2].Set("R4C3"); + } + var expected = +@"A;B +R1C1;R1C2 +R2C2 +R3C1 +R4C1;R4C3 +"; + Assert.AreEqual(expected, writer.ToString()); + } + + [TestMethod] + public void SepWriterTest_DisableColCountCheck_ColNotSetEmpty_Header() + { + var options = new SepWriterOptions + { + DisableColCountCheck = true, + ColNotSetOption = SepColNotSetOption.Empty, + }; + using var writer = options.ToText(); + { + using var row = writer.NewRow(); + row["A"].Set("R1C1"); + row["B"].Set("R1C2"); + row["C"].Set("R1C3"); + } + { + using var row = writer.NewRow(); + row["B"].Set("R2C2"); + } + { + using var row = writer.NewRow(); + row["A"].Set("R3C1"); + row["C"].Set("R3C3"); + } + { + using var row = writer.NewRow(); + row["B"].Set("R4C2"); + row[3].Set("R4C4"); + } + var expected = +@"A;B;C +R1C1;R1C2;R1C3 +;R2C2; +R3C1;;R3C3 +;R4C2;;R4C4 +"; + Assert.AreEqual(expected, writer.ToString()); + } + + + [TestMethod] + public void SepWriterTest_DisableColCountCheck_ColNotSetDefaultThrow_NoHeader_LessColumns_Throws() + { + var options = new SepWriterOptions { WriteHeader = false, DisableColCountCheck = true }; + using var writer = options.ToText(); + { + using var row = writer.NewRow(); + row["A"].Set("R1C1"); + row["B"].Set("R1C2"); + } + Assert.ThrowsException(() => + { + using var row = writer.NewRow(); + row["B"].Set("R2C2"); + }); + } + + [TestMethod] + public void SepWriterTest_DisableColCountCheck_ColNotSetDefaultThrow_NoHeader_MoreColumns_Ok() + { + var options = new SepWriterOptions { WriteHeader = false, DisableColCountCheck = true }; + using var writer = options.ToText(); + { + using var row = writer.NewRow(); + row["A"].Set("R1C1"); + row["B"].Set("R1C2"); + } + { + using var row = writer.NewRow(); + row["A"].Set("R2C1"); + row["B"].Set("R2C2"); + row[2].Set("R2C3"); + }; + var expected = +@"R1C1;R1C2 +R2C1;R2C2;R2C3 +"; + Assert.AreEqual(expected, writer.ToString()); + } + [TestMethod] + public void SepWriterTest_DisableColCountCheck_ColNotSetSkip_NoHeader() + { + var options = new SepWriterOptions + { + WriteHeader = false, + DisableColCountCheck = true, + ColNotSetOption = SepColNotSetOption.Skip, + }; + using var writer = options.ToText(); + { + using var row = writer.NewRow(); + row["A"].Set("R1C1"); + row["B"].Set("R1C2"); + + } + { + using var row = writer.NewRow(); + row[0].Set("R2C1"); + row[1].Set("R2C2"); + row[2].Set("R2C3"); + row[3].Set("R2C4"); + } + { + using var row = writer.NewRow(); + row["A"].Set("R3C1"); + row[2].Set("R3C3"); + row[1].Set("R3C2"); + } + var expected = +@"R1C1;R1C2 +R2C1;R2C2;R2C3;R2C4 +R3C1;R3C2;R3C3 +"; + Assert.AreEqual(expected, writer.ToString()); + } + + [TestMethod] + public void SepWriterTest_DisableColCountCheck_ColNotSetEmpty_NoHeader() + { + var options = new SepWriterOptions + { + WriteHeader = false, + DisableColCountCheck = true, + ColNotSetOption = SepColNotSetOption.Empty, + }; + using var writer = options.ToText(); + { + using var row = writer.NewRow(); + row["A"].Set("R1C1"); + row["B"].Set("R1C2"); + } + { + using var row = writer.NewRow(); + row["B"].Set("R2C2"); + } + { + using var row = writer.NewRow(); + row[0].Set("R3C1"); + row[1].Set("R3C2"); + row[2].Set("R3C3"); + row[3].Set("R3C4"); + } + { + using var row = writer.NewRow(); + row["A"].Set("R4C1"); + row[2].Set("R4C3"); + row[1].Set("R4C2"); + } + { + using var row = writer.NewRow(); + row[2].Set("R5C3"); + } + // Note how empty columns are written depending on previously written + // maximum column count + var expected = +@"R1C1;R1C2 +;R2C2 +R3C1;R3C2;R3C3;R3C4 +R4C1;R4C2;R4C3; +;;R5C3; +"; + Assert.AreEqual(expected, writer.ToString()); + } + static SepWriter CreateWriter() => Sep.New(';').Writer().ToText(); diff --git a/src/Sep/Internals/SepThrow.cs b/src/Sep/Internals/SepThrow.cs index c1950b1a..389bc124 100644 --- a/src/Sep/Internals/SepThrow.cs +++ b/src/Sep/Internals/SepThrow.cs @@ -82,7 +82,7 @@ internal static void InvalidOperationException_WriterDoesNotHaveActiveRow() } [DoesNotReturn] - internal static void InvalidOperationException_NotAllColsSet(List cols, string[] colNamesHeader) + internal static void InvalidOperationException_NotAllExpectedColsSet(List cols, string[] colNamesHeader) { // TODO: Make detailed exception if (colNamesHeader.Length == 0) diff --git a/src/Sep/SepColNotSetOption.cs b/src/Sep/SepColNotSetOption.cs new file mode 100644 index 00000000..f70a9e79 --- /dev/null +++ b/src/Sep/SepColNotSetOption.cs @@ -0,0 +1,20 @@ +namespace nietras.SeparatedValues; + +/// +/// Specifies how to handle columns that are not set when writing. +/// +public enum SepColNotSetOption : byte +{ + /// + /// Throw exception if a column is not set. + /// + Throw, + /// + /// Write empty column if it is not set. + /// + Empty, + /// + /// Skip column if it is not set. + /// + Skip, +} diff --git a/src/Sep/SepWriter.Row.cs b/src/Sep/SepWriter.Row.cs index c391f165..5c65d27e 100644 --- a/src/Sep/SepWriter.Row.cs +++ b/src/Sep/SepWriter.Row.cs @@ -89,7 +89,7 @@ public void Dispose() internal ColImpl GetOrAddCol(int colIndex) { var cols = _cols; - if (colIndex == cols.Count && !_writeHeader) + if (colIndex == cols.Count && (!_writeHeader || _disableColCountCheck)) { var col = new ColImpl(this, colIndex, string.Empty, SepStringBuilderPool.Take()); _cols.Add(col); diff --git a/src/Sep/SepWriter.cs b/src/Sep/SepWriter.cs index 0d594801..f1324383 100644 --- a/src/Sep/SepWriter.cs +++ b/src/Sep/SepWriter.cs @@ -13,6 +13,8 @@ public sealed partial class SepWriter : IDisposable readonly Sep _sep; readonly CultureInfo? _cultureInfo; internal readonly bool _writeHeader; + readonly bool _disableColCountCheck; + readonly SepColNotSetOption _colNotSetOption; readonly bool _escape; // _writer dispose handled by _disposeTextWriter #pragma warning disable CA2213 // Disposable fields should be disposed @@ -29,6 +31,7 @@ public sealed partial class SepWriter : IDisposable internal readonly SepArrayPoolAccessIndexed _arrayPool = new(); internal bool _headerWrittenOrSkipped = false; + internal int _headerOrFirstRowColCount = -1; bool _newRowActive = false; int _cacheIndex = 0; @@ -37,6 +40,8 @@ internal SepWriter(SepWriterOptions options, TextWriter writer, Action public bool WriteHeader { get; init; } = true; /// + /// Disables checking if column count is the + /// same for all rows. + /// + /// + /// When true, the + /// will define how columns that are not set + /// are handled. For example, whether to skip + /// or write an empty column if a column has + /// not been set for a given row. + /// + /// If any columns are skipped, then columns of + /// a row may, therefore, be out of sync with + /// column names if + /// is true. + /// + /// As such, any number of columns can be + /// written as long as done sequentially. + /// + public bool DisableColCountCheck { get; init; } = false; + /// + /// Specifies how to handle columns that are + /// not set. + /// + public SepColNotSetOption ColNotSetOption { get; init; } = SepColNotSetOption.Throw; + /// /// Specifies whether to escape column names /// and values when writing. ///