Skip to content

Commit 597b02f

Browse files
committed
Merge branch 'develop'
2 parents 22c9028 + 244565b commit 597b02f

14 files changed

Lines changed: 883 additions & 58 deletions

AppenderMap-Usage.md

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
# AppenderMap-based Type-Safe Appender
2+
3+
This implementation provides a type-safe way to append data to DuckDB tables using AppenderMap-based mappings with automatic type validation.
4+
5+
## Problem Solved
6+
7+
The original issue was that users could accidentally append values with mismatched types (e.g., `decimal` to `REAL` column), causing silent data corruption. The AppenderMap approach validates types against actual column types from the database.
8+
9+
## How It Works
10+
11+
### 1. Define an AppenderMap
12+
13+
Create an AppenderMap that defines property mappings in column order:
14+
15+
```csharp
16+
public class PersonMap : DuckDBAppenderMap<Person>
17+
{
18+
public PersonMap()
19+
{
20+
Map(p => p.Id); // Column 0: INTEGER
21+
Map(p => p.Name); // Column 1: VARCHAR
22+
Map(p => p.Height); // Column 2: REAL
23+
Map(p => p.BirthDate); // Column 3: TIMESTAMP
24+
}
25+
}
26+
```
27+
28+
### 2. Use Type-Safe Appender
29+
30+
```csharp
31+
// Create table
32+
connection.ExecuteNonQuery(
33+
"CREATE TABLE person(id INTEGER, name VARCHAR, height REAL, birth_date TIMESTAMP)");
34+
35+
// Create data
36+
var people = new[]
37+
{
38+
new Person { Id = 1, Name = "Alice", Height = 1.65f, BirthDate = new DateTime(1990, 1, 15) },
39+
new Person { Id = 2, Name = "Bob", Height = 1.80f, BirthDate = new DateTime(1985, 5, 20) },
40+
};
41+
42+
// Use mapped appender - type validation happens at creation
43+
using (var appender = connection.CreateAppender<Person, PersonMap>("person"))
44+
{
45+
appender.AppendRecords(people);
46+
}
47+
```
48+
49+
## Benefits
50+
51+
### 1. **Type Validation Against Database Schema**
52+
The mapped appender retrieves actual column types from the database and validates that your .NET types match:
53+
- `int``INTEGER`
54+
- `float``REAL`
55+
- `decimal``REAL` ❌ Throws exception at creation!
56+
57+
### 2. **No Performance Overhead**
58+
- Type validation happens once when creating the appender
59+
- Uses the same fast data chunk API as the low-level appender
60+
- No per-value type checks during append operations
61+
62+
### 3. **Support for Default and Null Values**
63+
```csharp
64+
public class MyMap : DuckDBAppenderMap<MyData>
65+
{
66+
public MyMap()
67+
{
68+
Map(d => d.Id);
69+
Map(d => d.Name);
70+
DefaultValue(); // Use column's default value
71+
NullValue(); // Insert NULL
72+
}
73+
}
74+
```
75+
76+
### 4. **Backward Compatible**
77+
The original fast, low-level `CreateAppender()` API remains unchanged:
78+
```csharp
79+
// Still available for maximum performance
80+
using var appender = connection.CreateAppender("myTable");
81+
appender.CreateRow()
82+
.AppendValue((float?)1.5)
83+
.EndRow();
84+
```
85+
86+
## Example: Preventing the Original Issue
87+
88+
### ❌ Before (Silent Corruption)
89+
```csharp
90+
public class MyData
91+
{
92+
public decimal Value { get; set; } // Oops! decimal is 16 bytes
93+
}
94+
95+
// This would silently corrupt data
96+
using var appender = connection.CreateAppender("myTable"); // REAL column
97+
appender.CreateRow()
98+
.AppendValue(data.Value) // decimal to REAL - CORRUPTION!
99+
.EndRow();
100+
```
101+
102+
### ✅ After (Type Safety with Validation)
103+
```csharp
104+
public class MyData
105+
{
106+
public float Value { get; set; } // Correct type!
107+
}
108+
109+
public class MyDataMap : DuckDBClassMap<MyData>
110+
{
111+
public MyDataMap()
112+
{
113+
Map(x => x.Value); // Validated: float → REAL ✅
114+
}
115+
}
116+
117+
// Type mismatch detected at appender creation
118+
using var appender = connection.CreateAppender<MyData, MyDataMap>("myTable");
119+
appender.AppendRecords(dataList); // Safe!
120+
```
121+
122+
If you tried to use a `decimal` property with a `REAL` column:
123+
```csharp
124+
public class WrongMap : DuckDBClassMap<MyData>
125+
{
126+
public WrongMap()
127+
{
128+
Map(x => x.DecimalValue); // decimal property
129+
}
130+
}
131+
132+
// Throws: "Type mismatch for property 'DecimalValue':
133+
// Property type is Decimal (maps to Decimal) but column 0 is Float"
134+
var appender = connection.CreateAppender<MyData, WrongMap>("myTable");
135+
```
136+
137+
## API Overview
138+
139+
### Creating Mapped Appenders
140+
141+
```csharp
142+
// Simple table name
143+
var appender = connection.CreateAppender<T, TMap>("tableName");
144+
145+
// With schema
146+
var appender = connection.CreateAppender<T, TMap>("schemaName", "tableName");
147+
148+
// With catalog and schema
149+
var appender = connection.CreateAppender<T, TMap>("catalog", "schema", "table");
150+
```
151+
152+
### Appending Data
153+
154+
```csharp
155+
// Multiple records
156+
appender.AppendRecords(recordList);
157+
158+
// Close and flush
159+
appender.Close();
160+
```
161+
162+
### Mapping Options
163+
164+
```csharp
165+
public class MyMap : DuckDBClassMap<MyData>
166+
{
167+
public MyMap()
168+
{
169+
Map(x => x.Property1); // Map to column in sequence
170+
Map(x => x.Property2);
171+
DefaultValue(); // Use column default
172+
NullValue(); // Insert NULL
173+
}
174+
}
175+
```
176+
177+
### Type Mappings
178+
179+
The mapper validates .NET types against DuckDB column types:
180+
181+
| .NET Type | DuckDB Type |
182+
|-----------|-------------|
183+
| `bool` | Boolean |
184+
| `sbyte` | TinyInt |
185+
| `short` | SmallInt |
186+
| `int` | Integer |
187+
| `long` | BigInt |
188+
| `byte` | UnsignedTinyInt |
189+
| `ushort` | UnsignedSmallInt |
190+
| `uint` | UnsignedInteger |
191+
| `ulong` | UnsignedBigInt |
192+
| `float` | Float |
193+
| `double` | Double |
194+
| `decimal` | Decimal |
195+
| `string` | Varchar |
196+
| `DateTime` | Timestamp |
197+
| `DateTimeOffset` | TimestampTz |
198+
| `TimeSpan` | Interval |
199+
| `Guid` | Uuid |
200+
| `DateOnly` | Date |
201+
| `TimeOnly` | Time |
202+
203+
## Performance
204+
205+
- **No runtime overhead**: Type mapping validated once at appender creation
206+
- **Fast value extraction**: Uses compiled expression getters
207+
- **Same underlying performance**: Uses the same fast data chunk API as the low-level appender
208+
- **Type safety without cost**: Validation at creation, not per-value

DuckDB.NET.Bindings/Bindings.csproj

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,11 @@
33
<PropertyGroup>
44
<Description>DuckDB Bindings for C#.</Description>
55
<PackageReleaseNotes>
6-
Added support for handling ± infinity dates and timestamps. Credits to @rynoV for the PR
7-
8-
Updated to DuckDB v1.4.3
6+
Updated to DuckDB v1.4.4
97
</PackageReleaseNotes>
108
<RootNamespace>DuckDB.NET.Native</RootNamespace>
119
<RuntimeIdentifiers>win-x64;win-arm64;linux-x64;linux-arm64;osx</RuntimeIdentifiers>
12-
<DuckDbArtifactRoot Condition=" '$(DuckDbArtifactRoot)' == '' ">https://github.com/duckdb/duckdb/releases/download/v1.4.3</DuckDbArtifactRoot>
10+
<DuckDbArtifactRoot Condition=" '$(DuckDbArtifactRoot)' == '' ">https://github.com/duckdb/duckdb/releases/download/v1.4.4</DuckDbArtifactRoot>
1311
<SignAssembly>True</SignAssembly>
1412
<AssemblyOriginatorKeyFile>..\keyPair.snk</AssemblyOriginatorKeyFile>
1513
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>

DuckDB.NET.Bindings/DuckDBWrapperObjects.cs

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -157,16 +157,51 @@ public T GetValue<T>()
157157
DuckDBType.Date => Cast(DuckDBDateOnly.FromDuckDBDate(NativeMethods.Value.DuckDBGetDate(this)).ToDateTime()),
158158
DuckDBType.Time => Cast(NativeMethods.DateTimeHelpers.DuckDBFromTime(NativeMethods.Value.DuckDBGetTime(this)).ToDateTime()),
159159
#endif
160-
//DuckDBType.TimeTz => expr,
160+
DuckDBType.TimeTz => Cast(GetTimeTzValue()),
161161
DuckDBType.Interval => Cast((TimeSpan)NativeMethods.Value.DuckDBGetInterval(this)),
162-
DuckDBType.Timestamp => Cast(DuckDBTimestamp.FromDuckDBTimestampStruct(NativeMethods.Value.DuckDBGetTimestamp(this)).ToDateTime()),
163-
//DuckDBType.TimestampS => expr,
164-
//DuckDBType.TimestampMs => expr,
165-
//DuckDBType.TimestampNs => expr,
166-
//DuckDBType.TimestampTz => expr,
162+
DuckDBType.Timestamp => Cast(GetTimestampValue(NativeMethods.Value.DuckDBGetTimestamp(this),DuckDBType.Timestamp)),
163+
DuckDBType.TimestampS => Cast(GetTimestampValue(NativeMethods.Value.DuckDBGetTimestampS(this), DuckDBType.TimestampS)),
164+
DuckDBType.TimestampMs => Cast(GetTimestampValue(NativeMethods.Value.DuckDBGetTimestampMs(this), DuckDBType.TimestampMs)),
165+
DuckDBType.TimestampNs => Cast(GetTimestampValue(NativeMethods.Value.DuckDBGetTimestampNs(this), DuckDBType.TimestampNs)),
166+
DuckDBType.TimestampTz => Cast(GetTimestampValue(NativeMethods.Value.DuckDBGetTimestamp(this), DuckDBType.TimestampTz)),
167167
_ => throw new NotImplementedException($"Cannot read value of type {typeof(T).FullName}")
168168
};
169169

170-
T Cast<TSource>(TSource value) => Unsafe.As<TSource, T>(ref value);
170+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
171+
static T Cast<TSource>(TSource value) => Unsafe.As<TSource, T>(ref value);
172+
}
173+
174+
private DateTime GetTimestampValue(DuckDBTimestampStruct timestampStruct, DuckDBType duckDBType)
175+
{
176+
var additionalTicks = 0;
177+
178+
// The type-specific getters return values in their native units:
179+
// - TimestampS: seconds
180+
// - TimestampMs: milliseconds
181+
// - TimestampNs: nanoseconds
182+
// We need to convert to microseconds for DuckDBTimestamp.FromDuckDBTimestampStruct()
183+
if (duckDBType == DuckDBType.TimestampNs)
184+
{
185+
additionalTicks = (int)(timestampStruct.Micros % 1000 / 100);
186+
timestampStruct.Micros /= 1000;
187+
}
188+
if (duckDBType == DuckDBType.TimestampMs)
189+
{
190+
timestampStruct.Micros *= 1000;
191+
}
192+
if (duckDBType == DuckDBType.TimestampS)
193+
{
194+
timestampStruct.Micros *= 1000000;
195+
}
196+
197+
var timestamp = DuckDBTimestamp.FromDuckDBTimestampStruct(timestampStruct);
198+
return timestamp.ToDateTime().AddTicks(additionalTicks);
199+
}
200+
201+
private DateTimeOffset GetTimeTzValue()
202+
{
203+
var timeTzStruct = NativeMethods.Value.DuckDBGetTimeTz(this);
204+
var timeTz = NativeMethods.DateTimeHelpers.DuckDBFromTimeTz(timeTzStruct);
205+
return new DateTimeOffset(timeTz.Time.ToDateTime(), TimeSpan.FromSeconds(timeTz.Offset));
171206
}
172207
}

DuckDB.NET.Bindings/NativeMethods/NativeMethods.Value.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,15 @@ public static class Value
140140
[DllImport(DuckDbLibrary, CallingConvention = CallingConvention.Cdecl, EntryPoint = "duckdb_get_timestamp")]
141141
public static extern DuckDBTimestampStruct DuckDBGetTimestamp(DuckDBValue value);
142142

143+
[DllImport(DuckDbLibrary, CallingConvention = CallingConvention.Cdecl, EntryPoint = "duckdb_get_timestamp_s")]
144+
public static extern DuckDBTimestampStruct DuckDBGetTimestampS(DuckDBValue value);
145+
146+
[DllImport(DuckDbLibrary, CallingConvention = CallingConvention.Cdecl, EntryPoint = "duckdb_get_timestamp_ms")]
147+
public static extern DuckDBTimestampStruct DuckDBGetTimestampMs(DuckDBValue value);
148+
149+
[DllImport(DuckDbLibrary, CallingConvention = CallingConvention.Cdecl, EntryPoint = "duckdb_get_timestamp_ns")]
150+
public static extern DuckDBTimestampStruct DuckDBGetTimestampNs(DuckDBValue value);
151+
143152
[DllImport(DuckDbLibrary, CallingConvention = CallingConvention.Cdecl, EntryPoint = "duckdb_get_interval")]
144153
public static extern DuckDBInterval DuckDBGetInterval(DuckDBValue value);
145154

DuckDB.NET.Data/Data.csproj

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,9 @@
33
<PropertyGroup>
44
<Description>DuckDB ADO.NET Provider for C#.</Description>
55
<PackageReleaseNotes>
6-
Added support for handling ± infinity dates and timestamps. Credits to @rynoV for the PR
6+
Added DuckDBMappedAppender for a type-safe way to bulk load data
77

8-
Improved support for handling transaction abortions. Credits to @rynoV for the PR.
9-
10-
Fixed decimal parameter handling in prepared statements. Credits to @rynoV for the PR.
11-
12-
Updated to DuckDB v1.4.3
8+
Updated to DuckDB v1.4.4
139
</PackageReleaseNotes>
1410
<SignAssembly>True</SignAssembly>
1511
<AssemblyOriginatorKeyFile>..\keyPair.snk</AssemblyOriginatorKeyFile>

DuckDB.NET.Data/DuckDBAppender.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using DuckDB.NET.Data.DataChunk.Writer;
33
using DuckDB.NET.Native;
44
using System;
5+
using System.Collections.Generic;
56
using System.Diagnostics;
67
using System.Diagnostics.CodeAnalysis;
78

@@ -39,6 +40,11 @@ internal DuckDBAppender(Native.DuckDBAppender appender, string qualifiedTableNam
3940
dataChunk = NativeMethods.DataChunks.DuckDBCreateDataChunk(logicalTypeHandles, columnCount);
4041
}
4142

43+
/// <summary>
44+
/// Gets the logical types of the columns in the appender.
45+
/// </summary>
46+
internal IReadOnlyList<DuckDBLogicalType> LogicalTypes => logicalTypes;
47+
4248
public IDuckDBAppenderRow CreateRow()
4349
{
4450
if (closed)

DuckDB.NET.Data/DuckDBConnection.TableFunction.cs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ partial class DuckDBConnection
2222
[Experimental("DuckDBNET001")]
2323
public void RegisterTableFunction(string name, Func<TableFunction> resultCallback, Action<object?, IDuckDBDataWriter[], ulong> mapperCallback)
2424
{
25-
RegisterTableFunctionInternal(name, (_) => resultCallback(), mapperCallback);
25+
RegisterTableFunctionInternal(name, (_) => resultCallback(), mapperCallback, Array.Empty<Type>());
2626
}
2727

2828
[Experimental("DuckDBNET001")]
@@ -73,6 +73,12 @@ public void RegisterTableFunction<T1, T2, T3, T4, T5, T6, T7, T8>(string name, F
7373
RegisterTableFunctionInternal(name, resultCallback, mapperCallback, typeof(T1), typeof(T2), typeof(T3), typeof(T4), typeof(T5), typeof(T6), typeof(T7), typeof(T8));
7474
}
7575

76+
[Experimental("DuckDBNET001")]
77+
public void RegisterTableFunction(string name, Func<IReadOnlyList<IDuckDBValueReader>, TableFunction> resultCallback, Action<object?, IDuckDBDataWriter[], ulong> mapperCallback, params DuckDBType[] parameterTypes)
78+
{
79+
RegisterTableFunctionInternal(name, resultCallback, mapperCallback, parameterTypes);
80+
}
81+
7682
[Experimental("DuckDBNET001")]
7783
private unsafe void RegisterTableFunctionInternal(string name, Func<IReadOnlyList<IDuckDBValueReader>, TableFunction> resultCallback, Action<object?, IDuckDBDataWriter[], ulong> mapperCallback, params Type[] parameterTypes)
7884
{
@@ -105,6 +111,38 @@ private unsafe void RegisterTableFunctionInternal(string name, Func<IReadOnlyLis
105111
NativeMethods.TableFunction.DuckDBDestroyTableFunction(ref function);
106112
}
107113

114+
[Experimental("DuckDBNET001")]
115+
private unsafe void RegisterTableFunctionInternal(string name, Func<IReadOnlyList<IDuckDBValueReader>, TableFunction> resultCallback, Action<object?, IDuckDBDataWriter[], ulong> mapperCallback, params DuckDBType[] parameterTypes)
116+
{
117+
var function = NativeMethods.TableFunction.DuckDBCreateTableFunction();
118+
using (var handle = name.ToUnmanagedString())
119+
{
120+
NativeMethods.TableFunction.DuckDBTableFunctionSetName(function, handle);
121+
}
122+
123+
foreach (var duckDBType in parameterTypes)
124+
{
125+
using var logicalType = NativeMethods.LogicalType.DuckDBCreateLogicalType(duckDBType);
126+
NativeMethods.TableFunction.DuckDBTableFunctionAddParameter(function, logicalType);
127+
}
128+
129+
var tableFunctionInfo = new TableFunctionInfo(resultCallback, mapperCallback);
130+
131+
NativeMethods.TableFunction.DuckDBTableFunctionSetBind(function, &Bind);
132+
NativeMethods.TableFunction.DuckDBTableFunctionSetInit(function, &Init);
133+
NativeMethods.TableFunction.DuckDBTableFunctionSetFunction(function, &TableFunction);
134+
NativeMethods.TableFunction.DuckDBTableFunctionSetExtraInfo(function, tableFunctionInfo.ToHandle(), &DestroyExtraInfo);
135+
136+
var state = NativeMethods.TableFunction.DuckDBRegisterTableFunction(NativeConnection, function);
137+
138+
if (!state.IsSuccess())
139+
{
140+
throw new InvalidOperationException($"Error registering user defined table function: {name}");
141+
}
142+
143+
NativeMethods.TableFunction.DuckDBDestroyTableFunction(ref function);
144+
}
145+
108146
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
109147
public static unsafe void Bind(IntPtr info)
110148
{

0 commit comments

Comments
 (0)