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