diff --git a/DuckDB.NET.Benchmarks/AppenderBenchmark.cs b/DuckDB.NET.Benchmarks/AppenderBenchmark.cs index 162ed2e..fdc2345 100644 --- a/DuckDB.NET.Benchmarks/AppenderBenchmark.cs +++ b/DuckDB.NET.Benchmarks/AppenderBenchmark.cs @@ -64,4 +64,21 @@ public void AppendRowsWithAppendRow() }); } } + + [Benchmark] + public void AppendRowsWithScopedWriter() + { + using var appender = connection.CreateAppender("bench"); + + for (var i = 0; i < RowCount; i++) + { + appender.AppendRowScoped(i, static (ref DuckDBAppenderRowWriter writer, int value) => + { + writer.AppendValue(value); + writer.AppendValue((long)value); + writer.AppendValue((double)value); + writer.AppendValue(value % 2 == 0); + }); + } + } } diff --git a/DuckDB.NET.Data/DuckDBAppender.cs b/DuckDB.NET.Data/DuckDBAppender.cs index ef9216e..7ef58fd 100644 --- a/DuckDB.NET.Data/DuckDBAppender.cs +++ b/DuckDB.NET.Data/DuckDBAppender.cs @@ -8,7 +8,8 @@ namespace DuckDB.NET.Data; /// /// /// Instances are not thread-safe. Do not call other methods on the same appender from an -/// callback. +/// or +/// callback. /// public class DuckDBAppender : IDisposable { @@ -109,21 +110,46 @@ public void AppendRow(TState state, Action w } catch (Exception appendException) { - if (row is not null) - { - try - { - FinalizeFailedAppendRow(row); - } - catch (Exception finalizationException) - { - throw new AggregateException( - "Appending the row failed and the previously completed rows could not be finalized", - appendException, - finalizationException); - } - } + FinalizeFailedAppendRowOrThrow(row, appendException); + throw; + } + finally + { + isAppendingRow = false; + } + } + /// + /// Appends a complete row through a stack-only writer that cannot escape the callback. + /// + /// The type of value used to populate the row. + /// The value used to populate the row. + /// A callback that appends every column value. This method validates + /// and completes the row after the callback returns. + /// + /// The callback must not call other methods on this appender. If the callback or automatic + /// row completion fails, all previously completed rows are flushed, the failed row is + /// discarded, and the appender cannot be reused. Use a static or cached callback for an + /// allocation-free per-row path; a capturing callback can allocate. + /// + public void AppendRowScoped(TState state, DuckDBAppenderRowWriterAction writeRow) + { + ArgumentNullException.ThrowIfNull(writeRow); + EnsureUsable(); + + DuckDBAppenderRow? row = null; + isAppendingRow = true; + + try + { + row = CreateReusableRow(); + var writer = new DuckDBAppenderRowWriter(row); + writeRow(ref writer, state); + row.EndRow(); + } + catch (Exception appendException) + { + FinalizeFailedAppendRowOrThrow(row, appendException); throw; } finally @@ -158,9 +184,12 @@ private ulong PrepareRow() { AppendDataChunk(); - InitVectorWriters(); - + // AppendDataChunk resets the chunk. Update the managed count before recreating the + // writers so a writer-initialization failure cannot make CloseCore append the already + // completed chunk a second time. rowCount = 0; + + InitVectorWriters(); } rowCount++; @@ -262,16 +291,42 @@ private void AppendDataChunk() NativeMethods.DataChunks.DuckDBDataChunkReset(dataChunk); } - private void FinalizeFailedAppendRow(DuckDBAppenderRow row) + private void FinalizeFailedAppendRow(DuckDBAppenderRow? row) { - // The row index is also the number of completed rows before the failed row in this chunk. - rowCount = row.ChunkRowIndex; - row.Invalidate(); isFaulted = true; + if (row is null) + { + // Row preparation failed while flushing or recreating the current chunk. Discard any + // uncommitted managed rows by closing the chunk at size zero so the appender cannot be + // reused in a partially transitioned state. + rowCount = 0; + } + else + { + // The row index is also the number of completed rows before the failed row in this chunk. + rowCount = row.ChunkRowIndex; + row.Invalidate(); + } + CloseCore(); } + private void FinalizeFailedAppendRowOrThrow(DuckDBAppenderRow? row, Exception appendException) + { + try + { + FinalizeFailedAppendRow(row); + } + catch (Exception finalizationException) + { + throw new AggregateException( + "Appending the row failed and the previously completed rows could not be finalized", + appendException, + finalizationException); + } + } + private void EnsureNotAppendingRow() { if (isAppendingRow) diff --git a/DuckDB.NET.Data/DuckDBAppenderRowWriter.cs b/DuckDB.NET.Data/DuckDBAppenderRowWriter.cs new file mode 100644 index 0000000..9b095ad --- /dev/null +++ b/DuckDB.NET.Data/DuckDBAppenderRowWriter.cs @@ -0,0 +1,111 @@ +using System.Runtime.CompilerServices; + +namespace DuckDB.NET.Data; + +/// +/// A stack-only writer for a single appender row. +/// +/// +/// Instances are valid only for the duration of a +/// +/// callback. The stack-only type cannot be boxed, captured, or stored on the managed heap. +/// Row completion is performed by the appender after the callback returns. +/// +public ref struct DuckDBAppenderRowWriter +{ + private readonly DuckDBAppenderRow row; + + internal DuckDBAppenderRowWriter(DuckDBAppenderRow row) + { + this.row = row; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendNullValue() => row.AppendNullValue(); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendValue(bool? value) => row.AppendValue(value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendValue(byte[]? value) => row.AppendValue(value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendValue(Span value) => row.AppendValue(value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendValue(string? value) => row.AppendValue(value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendValue(decimal? value) => row.AppendValue(value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendValue(Guid? value) => row.AppendValue(value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendValue(BigInteger? value) => row.AppendValue(value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendValue(sbyte? value) => row.AppendValue(value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendValue(short? value) => row.AppendValue(value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendValue(int? value) => row.AppendValue(value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendValue(long? value) => row.AppendValue(value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendValue(byte? value) => row.AppendValue(value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendValue(ushort? value) => row.AppendValue(value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendValue(uint? value) => row.AppendValue(value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendValue(ulong? value) => row.AppendValue(value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendValue(TEnum? value) where TEnum : Enum => row.AppendValue(value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendValue(float? value) => row.AppendValue(value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendValue(double? value) => row.AppendValue(value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendValue(DateOnly? value) => row.AppendValue(value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendValue(TimeOnly? value) => row.AppendValue(value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendValue(DuckDBDateOnly? value) => row.AppendValue(value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendValue(DuckDBTimeOnly? value) => row.AppendValue(value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendValue(DateTime? value) => row.AppendValue(value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendValue(DateTimeOffset? value) => row.AppendValue(value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendValue(TimeSpan? value) => row.AppendValue(value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendValue(IEnumerable? value) => row.AppendValue(value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendDefault() => row.AppendDefault(); +} + +/// +/// Writes one complete row through a stack-only . +/// +public delegate void DuckDBAppenderRowWriterAction(ref DuckDBAppenderRowWriter row, TState state); diff --git a/DuckDB.NET.Data/Extensions/DateTimeExtensions.cs b/DuckDB.NET.Data/Extensions/DateTimeExtensions.cs index f737fde..2d37ffc 100644 --- a/DuckDB.NET.Data/Extensions/DateTimeExtensions.cs +++ b/DuckDB.NET.Data/Extensions/DateTimeExtensions.cs @@ -16,14 +16,22 @@ public static DuckDBTimeTzStruct ToTimeTzStruct(this DateTimeOffset value) public static DuckDBTimestampStruct ToTimestampStruct(this DateTimeOffset value) { - var timestamp = DuckDBTimestamp.FromDateTime(value.UtcDateTime).ToDuckDBTimestampStruct(); - - return timestamp; + return value.UtcDateTime.ToTimestampStruct(DuckDBType.Timestamp); } public static DuckDBTimestampStruct ToTimestampStruct(this DateTime value, DuckDBType duckDBType) { - var timestamp = DuckDBTimestamp.FromDateTime(value).ToDuckDBTimestampStruct(); + var ticksSinceEpoch = value.Ticks - DateTime.UnixEpoch.Ticks; + var microseconds = ticksSinceEpoch / TicksPerMicrosecond; + + // duckdb_to_timestamp truncates the time-of-day to microseconds after resolving the date, + // which is floor division for pre-epoch values with sub-microsecond ticks. + if (ticksSinceEpoch < 0 && ticksSinceEpoch % TicksPerMicrosecond != 0) + { + microseconds--; + } + + var timestamp = new DuckDBTimestampStruct { Micros = microseconds }; if (duckDBType == DuckDBType.TimestampNs) { diff --git a/DuckDB.NET.Test/DateTimeConversionTests.cs b/DuckDB.NET.Test/DateTimeConversionTests.cs new file mode 100644 index 0000000..15ab6f9 --- /dev/null +++ b/DuckDB.NET.Test/DateTimeConversionTests.cs @@ -0,0 +1,221 @@ +using DuckDB.NET.Data.Extensions; + +namespace DuckDB.NET.Test; + +public class DateTimeConversionTests(DuckDBDatabaseFixture db) : DuckDBTestBase(db) +{ + public static IEnumerable TimestampConversionCases() + { + var epoch = DateTime.UnixEpoch; + var values = new[] + { + DateTime.MinValue, + new DateTime(1600, 2, 29, 23, 59, 59, DateTimeKind.Unspecified).AddTicks(9_999_999), + epoch.AddTicks(-11), + epoch.AddTicks(-10), + epoch.AddTicks(-9), + epoch.AddTicks(-1), + epoch, + epoch.AddTicks(1), + epoch.AddTicks(9), + epoch.AddTicks(10), + epoch.AddTicks(11), + new DateTime(2000, 2, 29, 12, 34, 56, DateTimeKind.Utc).AddTicks(7_654_321), + new DateTime(2262, 4, 11, 23, 47, 16, DateTimeKind.Unspecified).AddTicks(8_000_000), + DateTime.MaxValue, + }; + + var types = new[] + { + DuckDBType.Timestamp, + DuckDBType.TimestampS, + DuckDBType.TimestampMs, + DuckDBType.TimestampNs, + DuckDBType.TimestampTz, + }; + + foreach (var value in values) + { + foreach (var type in types) + { + yield return new object[] { value, type }; + } + } + } + + public static IEnumerable TimestampOffsetConversionCases() + { + yield return new object[] { new DateTimeOffset(1970, 1, 1, 0, 30, 0, TimeSpan.FromHours(1)) }; + yield return new object[] { new DateTimeOffset(1969, 12, 31, 23, 30, 0, TimeSpan.FromHours(-1)) }; + yield return new object[] { new DateTimeOffset(2000, 2, 29, 23, 45, 12, 345, TimeSpan.FromHours(5.5)).AddTicks(6_789) }; + yield return new object[] { DateTimeOffset.MinValue }; + yield return new object[] { DateTimeOffset.MaxValue }; + } + + [Theory] + [MemberData(nameof(TimestampConversionCases))] + public void ManagedTimestampConversionMatchesPreviousNativeConversion(DateTime value, DuckDBType type) + { + var expected = ConvertUsingNativeTimestampHelper(value, type); + var actual = value.ToTimestampStruct(type); + + actual.Micros.Should().Be(expected.Micros); + } + + [Theory] + [MemberData(nameof(TimestampOffsetConversionCases))] + public void ManagedTimestampOffsetConversionMatchesPreviousNativeConversion(DateTimeOffset value) + { + var expected = DuckDBTimestamp.FromDateTime(value.UtcDateTime).ToDuckDBTimestampStruct(); + var actual = value.ToTimestampStruct(); + + actual.Micros.Should().Be(expected.Micros); + } + + [Fact] + public void TimestampEdgeCasesRoundTripThroughScopedAppenderAndPreparedStatement() + { + var rows = new[] + { + new TimestampEdgeRow( + 1, + new DateTime(DateTime.UnixEpoch.Ticks - 11, DateTimeKind.Unspecified), + new DateTimeOffset(1970, 1, 1, 0, 30, 0, TimeSpan.FromHours(1))), + new TimestampEdgeRow( + 2, + new DateTime(DateTime.UnixEpoch.Ticks - 1, DateTimeKind.Unspecified), + new DateTimeOffset(1969, 12, 31, 23, 30, 0, TimeSpan.FromHours(-1))), + new TimestampEdgeRow( + 3, + new DateTime(DateTime.UnixEpoch.Ticks + 11, DateTimeKind.Unspecified), + new DateTimeOffset(2000, 2, 29, 23, 45, 12, 345, TimeSpan.FromHours(5.5)).AddTicks(6_789)), + }; + + CreateTimestampTable("managedTimestampScopedAppender"); + using (var appender = Connection.CreateAppender("managedTimestampScopedAppender")) + { + foreach (var row in rows) + { + appender.AppendRowScoped(row, + static (ref DuckDBAppenderRowWriter writer, TimestampEdgeRow value) => + { + writer.AppendValue((int?)value.Id); + writer.AppendValue((DateTime?)value.Timestamp); + writer.AppendValue((DateTime?)value.Timestamp); + writer.AppendValue((DateTime?)value.Timestamp); + writer.AppendValue((DateTime?)value.Timestamp); + writer.AppendValue((DateTimeOffset?)value.TimestampOffset); + }); + } + } + + CreateTimestampTable("managedTimestampPreparedStatement"); + InsertPreparedTimestampRows("managedTimestampPreparedStatement", rows); + + AssertTimestampRows("managedTimestampScopedAppender", rows); + AssertTimestampRows("managedTimestampPreparedStatement", rows); + } + + private void CreateTimestampTable(string tableName) + { + Command.CommandText = $$""" + CREATE TABLE {{tableName}}( + id INTEGER, + timestamp_value TIMESTAMP, + timestamp_s_value TIMESTAMP_S, + timestamp_ms_value TIMESTAMP_MS, + timestamp_ns_value TIMESTAMP_NS, + timestamp_tz_value TIMESTAMPTZ) + """; + Command.ExecuteNonQuery(); + } + + private void InsertPreparedTimestampRows(string tableName, IReadOnlyList rows) + { + using var command = Connection.CreateCommand(); + command.CommandText = $"INSERT INTO {tableName} VALUES (?, ?, ?, ?, ?, ?)"; + + var id = new DuckDBParameter(rows[0].Id); + var timestamp = new DuckDBParameter(rows[0].Timestamp); + var timestampS = new DuckDBParameter(rows[0].Timestamp); + var timestampMs = new DuckDBParameter(rows[0].Timestamp); + var timestampNs = new DuckDBParameter(rows[0].Timestamp); + var timestampTz = new DuckDBParameter(rows[0].TimestampOffset); + command.Parameters.Add(id); + command.Parameters.Add(timestamp); + command.Parameters.Add(timestampS); + command.Parameters.Add(timestampMs); + command.Parameters.Add(timestampNs); + command.Parameters.Add(timestampTz); + command.Prepare(); + + foreach (var row in rows) + { + id.Value = row.Id; + timestamp.Value = row.Timestamp; + timestampS.Value = row.Timestamp; + timestampMs.Value = row.Timestamp; + timestampNs.Value = row.Timestamp; + timestampTz.Value = row.TimestampOffset; + command.ExecuteNonQuery(); + } + } + + private void AssertTimestampRows(string tableName, IReadOnlyList rows) + { + Command.CommandText = $"SELECT * FROM {tableName} ORDER BY id"; + using var reader = Command.ExecuteReader(); + + foreach (var row in rows) + { + reader.Read().Should().BeTrue(); + reader.GetInt32(0).Should().Be(row.Id); + reader.GetDateTime(1).Ticks.Should().Be(ExpectedDateTime(row.Timestamp, DuckDBType.Timestamp).Ticks); + reader.GetDateTime(2).Ticks.Should().Be(ExpectedDateTime(row.Timestamp, DuckDBType.TimestampS).Ticks); + reader.GetDateTime(3).Ticks.Should().Be(ExpectedDateTime(row.Timestamp, DuckDBType.TimestampMs).Ticks); + reader.GetDateTime(4).Ticks.Should().Be(ExpectedDateTime(row.Timestamp, DuckDBType.TimestampNs).Ticks); + reader.GetFieldValue(5).UtcDateTime.Ticks.Should() + .Be(ExpectedDateTime(row.TimestampOffset.UtcDateTime, DuckDBType.TimestampTz).Ticks); + } + + reader.Read().Should().BeFalse(); + } + + private static DateTime ExpectedDateTime(DateTime value, DuckDBType type) + { + var timestamp = ConvertUsingNativeTimestampHelper(value, type); + var (duckDBTimestamp, additionalTicks) = timestamp.ToDuckDBTimestamp(type); + return duckDBTimestamp.ToDateTime().AddTicks(additionalTicks); + } + + private static DuckDBTimestampStruct ConvertUsingNativeTimestampHelper(DateTime value, DuckDBType type) + { + var timestamp = DuckDBTimestamp.FromDateTime(value).ToDuckDBTimestampStruct(); + + unchecked + { + if (type == DuckDBType.TimestampNs) + { + timestamp.Micros *= 1000; + timestamp.Micros += value.Nanosecond; + } + + if (type == DuckDBType.TimestampMs) + { + timestamp.Micros /= 1000; + } + + if (type == DuckDBType.TimestampS) + { + timestamp.Micros /= 1_000_000; + } + } + + return timestamp; + } + + private readonly record struct TimestampEdgeRow( + int Id, + DateTime Timestamp, + DateTimeOffset TimestampOffset); +} diff --git a/DuckDB.NET.Test/DuckDBManagedAppenderTests.cs b/DuckDB.NET.Test/DuckDBManagedAppenderTests.cs index f570f49..6558e47 100644 --- a/DuckDB.NET.Test/DuckDBManagedAppenderTests.cs +++ b/DuckDB.NET.Test/DuckDBManagedAppenderTests.cs @@ -779,6 +779,216 @@ public void AppendRowStateOverloadWritesRows() reader.Read().Should().BeFalse(); } + [Fact] + public void AppendRowScopedWritesMultipleMixedRowsIncludingNulls() + { + Command.CommandText = """ + CREATE TABLE managedAppenderStackOnlyRows( + id INTEGER, + event_time TIMESTAMP, + amount DOUBLE, + category VARCHAR, + active BOOLEAN) + """; + Command.ExecuteNonQuery(); + + var rows = new[] + { + new ScopedMixedRow(1, DateTime.UnixEpoch.AddTicks(-1), 12.5, "alpha", true), + new ScopedMixedRow(2, DateTime.UnixEpoch.AddTicks(10), null, null, null), + new ScopedMixedRow(3, DateTime.UnixEpoch.AddDays(1), -3.25, "gamma", false), + }; + + using (var appender = Connection.CreateAppender("managedAppenderStackOnlyRows")) + { + foreach (var row in rows) + { + appender.AppendRowScoped(row, + static (ref DuckDBAppenderRowWriter writer, ScopedMixedRow value) => + { + writer.AppendValue(value.Id); + writer.AppendValue(value.EventTime); + writer.AppendValue(value.Amount); + writer.AppendValue(value.Category); + writer.AppendValue(value.Active); + }); + } + } + + Command.CommandText = """ + SELECT id, event_time, amount, category, active + FROM managedAppenderStackOnlyRows + ORDER BY id + """; + using var reader = Command.ExecuteReader(); + + reader.Read().Should().BeTrue(); + reader.GetInt32(0).Should().Be(1); + reader.GetDateTime(1).Should().Be(DateTime.UnixEpoch.AddTicks(-10)); + reader.GetDouble(2).Should().Be(12.5); + reader.GetString(3).Should().Be("alpha"); + reader.GetBoolean(4).Should().BeTrue(); + + reader.Read().Should().BeTrue(); + reader.GetInt32(0).Should().Be(2); + reader.GetDateTime(1).Should().Be(DateTime.UnixEpoch.AddTicks(10)); + reader.IsDBNull(2).Should().BeTrue(); + reader.IsDBNull(3).Should().BeTrue(); + reader.IsDBNull(4).Should().BeTrue(); + + reader.Read().Should().BeTrue(); + reader.GetInt32(0).Should().Be(3); + reader.GetDateTime(1).Should().Be(DateTime.UnixEpoch.AddDays(1)); + reader.GetDouble(2).Should().Be(-3.25); + reader.GetString(3).Should().Be("gamma"); + reader.GetBoolean(4).Should().BeFalse(); + reader.Read().Should().BeFalse(); + } + + [Fact] + public void AppendRowScopedWritesAcrossChunkBoundary() + { + Command.CommandText = "CREATE TABLE managedAppenderStackOnlyChunks(id INTEGER, doubled BIGINT)"; + Command.ExecuteNonQuery(); + + var rowCount = checked((int)DuckDBGlobalData.VectorSize + 3); + using (var appender = Connection.CreateAppender("managedAppenderStackOnlyChunks")) + { + for (var i = 0; i < rowCount; i++) + { + appender.AppendRowScoped(i, + static (ref DuckDBAppenderRowWriter writer, int value) => + { + writer.AppendValue(value); + writer.AppendValue((long)value * 2); + }); + } + } + + Command.CommandText = """ + SELECT count(*)::BIGINT, min(id), max(id), sum(doubled)::BIGINT + FROM managedAppenderStackOnlyChunks + """; + using var reader = Command.ExecuteReader(); + reader.Read().Should().BeTrue(); + reader.GetInt64(0).Should().Be(rowCount); + reader.GetInt32(1).Should().Be(0); + reader.GetInt32(2).Should().Be(rowCount - 1); + reader.GetInt64(3).Should().Be((long)(rowCount - 1) * rowCount); + } + + [Fact] + public void AppendRowScopedPreparationFailureFaultsAppenderAtChunkBoundary() + { + Command.CommandText = "CREATE TABLE managedAppenderStackOnlyPreparationFailure(id INTEGER)"; + Command.ExecuteNonQuery(); + + using (var appender = Connection.CreateAppender("managedAppenderStackOnlyPreparationFailure")) + { + for (var i = 0; i < checked((int)DuckDBGlobalData.VectorSize); i++) + { + appender.AppendRowScoped(i, + static (ref DuckDBAppenderRowWriter writer, int value) => + writer.AppendValue((int?)value)); + } + + // Constraint checks are deferred until duckdb_appender_close, so close the native + // handle directly to deterministically exercise a failure in the next managed + // vector-boundary flush before CreateReusableRow can return a row. + var nativeAppenderField = typeof(DuckDB.NET.Data.DuckDBAppender).GetField( + "nativeAppender", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + nativeAppenderField.Should().NotBeNull(); + var nativeAppender = nativeAppenderField!.GetValue(appender) + .Should().BeOfType().Subject; + nativeAppender.Close(); + + var failure = appender.Invoking(value => value.AppendRowScoped(2, + static (ref DuckDBAppenderRowWriter writer, int state) => + writer.AppendValue((int?)state))) + .Should().Throw().Which; + + failure.InnerExceptions.Should().HaveCount(2) + .And.OnlyContain(exception => exception is ObjectDisposedException); + + appender.Invoking(value => value.AppendRowScoped(3, + static (ref DuckDBAppenderRowWriter writer, int state) => + writer.AppendValue((int?)state))) + .Should().Throw() + .WithMessage("*cannot be reused*"); + } + } + + [Fact] + public void IncompleteAppendRowScopedDiscardsFailedRowAndFaultsAppender() + { + Command.CommandText = "CREATE TABLE managedAppenderIncompleteStackOnlyRow(a INTEGER, b INTEGER)"; + Command.ExecuteNonQuery(); + + using (var appender = Connection.CreateAppender("managedAppenderIncompleteStackOnlyRow")) + { + appender.Invoking(value => value.AppendRowScoped(1, + static (ref DuckDBAppenderRowWriter writer, int state) => writer.AppendValue(state))) + .Should().Throw() + .WithMessage("*specified only 1 values"); + + appender.Invoking(value => value.AppendRowScoped((2, 3), + static (ref DuckDBAppenderRowWriter writer, (int First, int Second) state) => + { + writer.AppendValue(state.First); + writer.AppendValue(state.Second); + })) + .Should().Throw() + .WithMessage("*cannot be reused*"); + } + + Command.CommandText = "SELECT count(*) FROM managedAppenderIncompleteStackOnlyRow"; + Command.ExecuteScalar().Should().Be(0); + } + + [Fact] + public void AppendRowScopedFailureFlushesCompletedRowsAndFaultsAppender() + { + Command.CommandText = "CREATE TABLE managedAppenderThrownStackOnlyRow(a INTEGER, b VARCHAR)"; + Command.ExecuteNonQuery(); + + using (var appender = Connection.CreateAppender("managedAppenderThrownStackOnlyRow")) + { + appender.AppendRowScoped((Id: 1, Name: "complete"), + static (ref DuckDBAppenderRowWriter writer, (int Id, string Name) value) => + { + writer.AppendValue(value.Id); + writer.AppendValue(value.Name); + }); + + appender.Invoking(value => value.AppendRowScoped((Id: 2, Name: "discarded"), + static (ref DuckDBAppenderRowWriter writer, (int Id, string Name) state) => + { + writer.AppendValue(state.Id); + writer.AppendValue(state.Name); + throw new InvalidOperationException("callback failed"); + })) + .Should().Throw() + .WithMessage("callback failed"); + + appender.Invoking(value => value.AppendRowScoped((Id: 3, Name: "rejected"), + static (ref DuckDBAppenderRowWriter writer, (int Id, string Name) state) => + { + writer.AppendValue(state.Id); + writer.AppendValue(state.Name); + })) + .Should().Throw() + .WithMessage("*cannot be reused*"); + } + + Command.CommandText = "SELECT a, b FROM managedAppenderThrownStackOnlyRow"; + using var reader = Command.ExecuteReader(); + reader.Read().Should().BeTrue(); + reader.GetInt32(0).Should().Be(1); + reader.GetString(1).Should().Be("complete"); + reader.Read().Should().BeFalse(); + } + [Fact] public void AppendRowActionOverloadWritesCompleteRow() { @@ -1150,6 +1360,13 @@ private static string GetQualifiedObjectName(params string[] parts) => Select(p => '"' + p + '"') ); + private readonly record struct ScopedMixedRow( + int Id, + DateTime EventTime, + double? Amount, + string Category, + bool? Active); + private enum TestEnum1 { Test1 = 0,