This is an automated email from the ASF dual-hosted git repository.
CurtHagenlocher pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-adbc.git
The following commit(s) were added to refs/heads/main by this push:
new 0a40cf30d test(csharp): test the ADBC 1.1 and async APIs against
DuckDB (#4911)
0a40cf30d is described below
commit 0a40cf30d0458867cea48d0fbc5283340b59b40f
Author: Curt Hagenlocher <[email protected]>
AuthorDate: Thu Oct 15 20:59:19 2026 -0700
test(csharp): test the ADBC 1.1 and async APIs against DuckDB (#4911)
## What's changed
This adds tests that exercise the ADBC 1.1 APIs and the
async/cancellation support through the imported DuckDB driver. The other
tests use the managed round-trip fixture; these check the C# importer
against a real native 1.1 driver.
This is the last of the PRs implementing ADBC 1.1 in C#:
- #4884 fixed bugs in the C importer and exporter.
- #4885 added the 1.1 APIs to the base classes.
- #4895 added async methods and cancellation.
- #4901 used them in the Client.
There are no library changes in this PR.
### New tests in `ImportedDuckDbTests`
- **Options:**
- `DatabaseGetOption` and `TypedDatabaseOptionsAreAppliedAtOpen`. A
`long` value reaches DuckDB through `DatabaseSetOptionInt`.
- `StandardConnectionOptionsComeFromTheDriver`, for autocommit, current
catalog and current schema.
- `SetCurrentDbSchema`, which also uses `SetOptionAsync` and
`GetOptionAsync`.
- `ConnectWithTypedOptions` and `ConnectWithTypedOptionsAsync`.
- `StatementGetOption`.
- `UnsupportedOptions`, which checks the `NotFound` and `NotImplemented`
statuses at each level.
- **Errors:** `ErrorDetails` checks the 1.1 error details
(`duckdb:error_type`). It also checks that the vendor code field, which
the 1.1 error layout reuses, doesn't leak into `NativeError`.
- **Statistics:** `Statistics` covers `GetStatisticsNames` and
`GetStatistics`.
- **Async:**
- `OpenAndConnectAsync`.
- `ExecuteAsyncWithCancellationToken`.
- `TransactionsAsync`, for `CommitAsync` and `RollbackAsync`.
- `CancellationTokenCancelsQuery`. Like `CancelStatement` and
`CancelConnection`, it keeps cancelling until the query ends, because
DuckDB discards a cancel that arrives before the query has started.
`DuckDbFixture` gains an `OpenDatabase` overload that takes `object`
values, and an `OpenDatabaseAsync`.
### DuckDB 1.5.6 behavior the tests allow for
Each of these is commented where a test allows for it:
- **Interrupted queries** are reported as `InvalidArgument` rather than
`Cancelled`.
- **Unknown statement options** set as a string or integer get
`InvalidArgument` rather than the spec's `NotImplemented`. The test
accepts either, so a DuckDB fix won't break it.
- **Unknown database options** set as a string, integer or double on an
open database are accepted without an error, so the test doesn't check
those three setters.
- **Autocommit:** after the current catalog or schema has been read, a
later attempt to disable autocommit is silently ignored. The test checks
the catalog and schema last.
## Testing
- The full solution builds without warnings, and `dotnet format
--verify-no-changes` is clean.
- The core test project passes on net10.0, net8.0 and net472.
- 10 full runs with `DOTNET_PROCESSOR_COUNT=2` had no failures.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../test/Apache.Arrow.Adbc.Tests/DuckDbFixture.cs | 25 ++
.../Apache.Arrow.Adbc.Tests/ImportedDuckDbTests.cs | 292 +++++++++++++++++++++
2 files changed, 317 insertions(+)
diff --git a/csharp/test/Apache.Arrow.Adbc.Tests/DuckDbFixture.cs
b/csharp/test/Apache.Arrow.Adbc.Tests/DuckDbFixture.cs
index fe1019c17..4fa87e0d8 100644
--- a/csharp/test/Apache.Arrow.Adbc.Tests/DuckDbFixture.cs
+++ b/csharp/test/Apache.Arrow.Adbc.Tests/DuckDbFixture.cs
@@ -19,6 +19,8 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
using Apache.Arrow.Adbc.C;
using DbClient = Apache.Arrow.Adbc.Client;
@@ -59,6 +61,29 @@ namespace Apache.Arrow.Adbc.Tests
return _driver.Open(new Dictionary<string, string> { { "path",
Path.Combine(_dataDirectory, name) } });
}
+ /// <summary>
+ /// Opens a database through the <c>object</c>-valued overload so that
typed
+ /// options reach DuckDB through the ADBC 1.1 typed setters.
+ /// </summary>
+ public AdbcDatabase OpenDatabase(string name,
IReadOnlyDictionary<string, object> options)
+ {
+ if (_driver == null) throw new
ObjectDisposedException("DuckDbFixture");
+
+ var parameters = new Dictionary<string, object>(options.Count + 1)
{ { "path", Path.Combine(_dataDirectory, name) } };
+ foreach (KeyValuePair<string, object> pair in options)
+ {
+ parameters[pair.Key] = pair.Value;
+ }
+ return _driver.Open(parameters);
+ }
+
+ public Task<AdbcDatabase> OpenDatabaseAsync(string name,
CancellationToken cancellationToken = default)
+ {
+ if (_driver == null) throw new
ObjectDisposedException("DuckDbFixture");
+
+ return _driver.OpenAsync(new Dictionary<string, string> { {
"path", Path.Combine(_dataDirectory, name) } }, cancellationToken);
+ }
+
public DbClient.AdbcConnection CreateConnection(string name,
IReadOnlyDictionary<string, string>? connectionOptions)
{
AdbcDatabase? database;
diff --git a/csharp/test/Apache.Arrow.Adbc.Tests/ImportedDuckDbTests.cs
b/csharp/test/Apache.Arrow.Adbc.Tests/ImportedDuckDbTests.cs
index 2595b88b6..b29cc86d5 100644
--- a/csharp/test/Apache.Arrow.Adbc.Tests/ImportedDuckDbTests.cs
+++ b/csharp/test/Apache.Arrow.Adbc.Tests/ImportedDuckDbTests.cs
@@ -17,8 +17,12 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Runtime.InteropServices;
+using System.Text;
+using System.Threading;
using System.Threading.Tasks;
+using Apache.Arrow.Ipc;
using Apache.Arrow.Types;
using Xunit;
@@ -384,6 +388,286 @@ namespace Apache.Arrow.Adbc.Tests
Assert.Contains("Interrupted", exception.Message);
}
+ [Fact]
+ public void DatabaseGetOption()
+ {
+ using var database = _duckDb.OpenDatabase("dbgetoption.db");
+
+ Assert.EndsWith("dbgetoption.db", database.GetOption("path"));
+ Assert.Equal(AdbcStatusCode.NotFound,
Assert.ThrowsAny<AdbcException>(() => database.GetOption("unknown")).Status);
+ }
+
+ [Fact]
+ public void TypedDatabaseOptionsAreAppliedAtOpen()
+ {
+ // A long goes through DatabaseSetOptionInt rather than
DatabaseSetOption
+ using var database = _duckDb.OpenDatabase("typedopen.db", new
Dictionary<string, object> { { "threads", 3L } });
+ using var connection = database.Connect(null);
+ using var statement = connection.CreateStatement();
+
+ Assert.Equal("3", GetScalarString(statement, "SELECT
current_setting('threads')::VARCHAR"));
+ Assert.Equal("3", database.GetOption("threads"));
+ }
+
+ [Fact]
+ public async Task OpenAndConnectAsync()
+ {
+ using var database = await
_duckDb.OpenDatabaseAsync("openasync.db");
+ using var connection = await database.ConnectAsync(null);
+ using var statement = connection.CreateStatement();
+
+ Assert.Equal(3, GetResultCount(statement, "SELECT * FROM
range(3)"));
+ }
+
+ [Fact]
+ public void StandardConnectionOptionsComeFromTheDriver()
+ {
+ using var database = _duckDb.OpenDatabase("standardoptions.db");
+ using var connection = database.Connect(null);
+
+ // Nothing has been set through this connection, so these are read
from DuckDB
+ Assert.True(connection.AutoCommit);
+ Assert.Equal(AdbcOptions.Enabled,
connection.GetOption(AdbcOptions.Connection.Autocommit));
+
+ connection.AutoCommit = false;
+ Assert.Equal(AdbcOptions.Disabled,
connection.GetOption(AdbcOptions.Connection.Autocommit));
+
+ // In DuckDB 1.5.6, reading the current catalog or schema causes a
later attempt
+ // to disable autocommit to be silently ignored, so these are
checked last.
+ Assert.Equal("standardoptions", connection.CurrentCatalog);
+ Assert.Equal("main", connection.CurrentDbSchema);
+ }
+
+ [Fact]
+ public async Task SetCurrentDbSchema()
+ {
+ using var database = _duckDb.OpenDatabase("currentschema.db");
+ using var connection = database.Connect(null);
+ using var statement = connection.CreateStatement();
+
+ statement.SqlQuery = "CREATE SCHEMA other";
+ statement.ExecuteUpdate();
+
+ await
connection.SetOptionAsync(AdbcOptions.Connection.CurrentDbSchema, "other");
+ Assert.Equal("other", await
connection.GetOptionAsync(AdbcOptions.Connection.CurrentDbSchema));
+ Assert.Equal("other", connection.CurrentDbSchema);
+
+ statement.SqlQuery = "CREATE TABLE unqualified(x INTEGER)";
+ statement.ExecuteUpdate();
+ Assert.Equal("other", GetScalarString(statement, "SELECT
schema_name FROM duckdb_tables() WHERE table_name = 'unqualified'"));
+
+ connection.CurrentDbSchema = "main";
+ Assert.Equal("main", GetScalarString(statement, "SELECT
current_schema()"));
+ }
+
+ [Fact]
+ public void ConnectWithTypedOptions()
+ {
+ using var database = _duckDb.OpenDatabase("connecttyped.db");
+
+ // The bool is sent to DuckDB as the string "false". (That it's
sent before the
+ // connection is initialized is covered by
ExportedDriverRoundTrip11Tests.)
+ using var connection = database.ConnectWithTypedOptions(new
Dictionary<string, object>
+ {
+ { AdbcOptions.Connection.Autocommit, false },
+ });
+
+ Assert.Equal(AdbcOptions.Disabled,
connection.GetOption(AdbcOptions.Connection.Autocommit));
+ Assert.False(connection.AutoCommit);
+ }
+
+ [Fact]
+ public async Task ConnectWithTypedOptionsAsync()
+ {
+ using var database = _duckDb.OpenDatabase("connecttypedasync.db");
+
+ using var connection = await
database.ConnectWithTypedOptionsAsync(new Dictionary<string, object>
+ {
+ { AdbcOptions.Connection.Autocommit, false },
+ });
+
+ Assert.Equal(AdbcOptions.Disabled,
connection.GetOption(AdbcOptions.Connection.Autocommit));
+ }
+
+ [Fact]
+ public void UnsupportedOptions()
+ {
+ using var database = _duckDb.OpenDatabase("unsupportedoptions.db");
+ using var connection = database.Connect(null);
+ using var statement = connection.CreateStatement();
+
+ Assert.Equal(AdbcStatusCode.NotFound,
Assert.ThrowsAny<AdbcException>(() =>
database.GetOptionInt64("unknown")).Status);
+ Assert.Equal(AdbcStatusCode.NotFound,
Assert.ThrowsAny<AdbcException>(() =>
database.GetOptionDouble("unknown")).Status);
+ Assert.Equal(AdbcStatusCode.NotFound,
Assert.ThrowsAny<AdbcException>(() =>
database.GetOptionBytes("unknown")).Status);
+ Assert.Equal(AdbcStatusCode.NotImplemented,
Assert.ThrowsAny<AdbcException>(() => database.SetOptionBytes("unknown", new
byte[] { 1 })).Status);
+ // DuckDB 1.5.6 accepts unknown string, integer and double options
on an open
+ // database without reporting an error, so those setters aren't
checked here.
+
+ Assert.Equal(AdbcStatusCode.NotFound,
Assert.ThrowsAny<AdbcException>(() => connection.GetOption("unknown")).Status);
+ Assert.Equal(AdbcStatusCode.NotFound,
Assert.ThrowsAny<AdbcException>(() =>
connection.GetOptionInt64("unknown")).Status);
+ Assert.Equal(AdbcStatusCode.NotFound,
Assert.ThrowsAny<AdbcException>(() =>
connection.GetOptionDouble("unknown")).Status);
+ Assert.Equal(AdbcStatusCode.NotFound,
Assert.ThrowsAny<AdbcException>(() =>
connection.GetOptionBytes("unknown")).Status);
+ Assert.Equal(AdbcStatusCode.NotImplemented,
Assert.ThrowsAny<AdbcException>(() => connection.SetOption("unknown",
"x")).Status);
+ Assert.Equal(AdbcStatusCode.NotImplemented,
Assert.ThrowsAny<AdbcException>(() => connection.SetOptionDouble("unknown",
1.5)).Status);
+ Assert.Equal(AdbcStatusCode.NotImplemented,
Assert.ThrowsAny<AdbcException>(() => connection.SetOptionBytes("unknown", new
byte[] { 1 })).Status);
+
+ Assert.Equal(AdbcStatusCode.NotFound,
Assert.ThrowsAny<AdbcException>(() => statement.GetOption("unknown")).Status);
+ Assert.Equal(AdbcStatusCode.NotFound,
Assert.ThrowsAny<AdbcException>(() =>
statement.GetOptionInt64("unknown")).Status);
+ Assert.Equal(AdbcStatusCode.NotFound,
Assert.ThrowsAny<AdbcException>(() =>
statement.GetOptionDouble("unknown")).Status);
+ Assert.Equal(AdbcStatusCode.NotFound,
Assert.ThrowsAny<AdbcException>(() =>
statement.GetOptionBytes("unknown")).Status);
+ Assert.Equal(AdbcStatusCode.NotImplemented,
Assert.ThrowsAny<AdbcException>(() => statement.SetOptionDouble("unknown",
1.5)).Status);
+ Assert.Equal(AdbcStatusCode.NotImplemented,
Assert.ThrowsAny<AdbcException>(() => statement.SetOptionBytes("unknown", new
byte[] { 1 })).Status);
+
+ // The spec says NotImplemented, but DuckDB 1.5.6 returns
InvalidArgument for
+ // unknown string and integer statement options. Accept either so
a DuckDB fix
+ // doesn't break this.
+ AdbcStatusCode[] unknownStatementOption = {
AdbcStatusCode.NotImplemented, AdbcStatusCode.InvalidArgument };
+ Assert.Contains(Assert.ThrowsAny<AdbcException>(() =>
statement.SetOption("unknown", "x")).Status, unknownStatementOption);
+ Assert.Contains(Assert.ThrowsAny<AdbcException>(() =>
statement.SetOptionInt64("unknown", 1)).Status, unknownStatementOption);
+ }
+
+ [Fact]
+ public void StatementGetOption()
+ {
+ using var database = _duckDb.OpenDatabase("statementoption.db");
+ using var connection = database.Connect(null);
+ using var statement = connection.CreateStatement();
+
+ statement.SetOption(AdbcOptions.Ingest.TargetTable, "target");
+ Assert.Equal("target",
statement.GetOption(AdbcOptions.Ingest.TargetTable));
+ }
+
+ [Fact]
+ public void ErrorDetails()
+ {
+ using var database = _duckDb.OpenDatabase("errordetails.db");
+ using var connection = database.Connect(null);
+ using var statement = connection.CreateStatement();
+
+ statement.SqlQuery = "SELECT CAST('abc' AS INTEGER)";
+ AdbcException exception = Assert.ThrowsAny<AdbcException>(() =>
statement.ExecuteQuery());
+
+ Assert.Equal(AdbcStatusCode.InvalidArgument, exception.Status);
+ Assert.Contains("Conversion Error", exception.Message);
+ KeyValuePair<string, byte[]> detail =
Assert.Single(exception.Details, d => d.Key == "duckdb:error_type");
+ Assert.Equal("Conversion", Encoding.UTF8.GetString(detail.Value));
+
+ // The 1.1 error layout reuses the vendor code field, so it
mustn't leak through
+ Assert.Equal(0, exception.NativeError);
+ }
+
+ [Fact]
+ public async Task Statistics()
+ {
+ using var database = _duckDb.OpenDatabase("statistics.db");
+ using var connection = database.Connect(null);
+ using var statement = connection.CreateStatement();
+
+ using (IArrowArrayStream names = connection.GetStatisticsNames())
+ {
+ Assert.Equal(new[] { "statistic_name", "statistic_key" },
names.Schema.FieldsList.Select(f => f.Name));
+ Assert.Equal(ArrowTypeId.String,
names.Schema.FieldsList[0].DataType.TypeId);
+ Assert.Equal(ArrowTypeId.Int16,
names.Schema.FieldsList[1].DataType.TypeId);
+ }
+
+ statement.SqlQuery = "CREATE TABLE stats AS SELECT * FROM
range(10) r(x)";
+ statement.ExecuteUpdate();
+
+ using IArrowArrayStream statistics =
connection.GetStatistics(null, null, "stats", approximate: false);
+ Assert.Equal(new[] { "catalog_name", "catalog_db_schemas" },
statistics.Schema.FieldsList.Select(f => f.Name));
+
+ var catalogs = new List<string?>();
+ RecordBatch? batch;
+ while ((batch = await statistics.ReadNextRecordBatchAsync()) !=
null)
+ {
+ using (batch)
+ {
+ StringArray names = (StringArray)batch.Column(0);
+ for (int i = 0; i < names.Length; i++)
+ {
+ catalogs.Add(names.GetString(i));
+ }
+ }
+ }
+ Assert.Contains("statistics", catalogs);
+ }
+
+ [Fact]
+ public async Task ExecuteAsyncWithCancellationToken()
+ {
+ using var database = _duckDb.OpenDatabase("executeasync.db");
+ using var connection = database.Connect(null);
+ using var statement = connection.CreateStatement();
+ using var cts = new CancellationTokenSource();
+
+ statement.SqlQuery = "CREATE TABLE test(x INTEGER)";
+ await statement.ExecuteUpdateAsync(cts.Token);
+
+ statement.SqlQuery = "INSERT INTO test VALUES (1), (2)";
+ UpdateResult update = await
statement.ExecuteUpdateAsync(cts.Token);
+ Assert.Equal(2, update.AffectedRows);
+
+ statement.SqlQuery = "SELECT * FROM test";
+ QueryResult result = await statement.ExecuteQueryAsync(cts.Token);
+ Assert.Equal(2, GetResultCount(result));
+ }
+
+ [Fact]
+ public async Task CancellationTokenCancelsQuery()
+ {
+ using var database = _duckDb.OpenDatabase("canceltoken.db");
+ using var connection = database.Connect(null);
+ using var statement = connection.CreateStatement();
+ using var cts = new CancellationTokenSource();
+
+ statement.SqlQuery = "SELECT count(*) FROM range(1000000000000)";
+ Task<QueryResult> query =
statement.ExecuteQueryAsync(cts.Token).AsTask();
+
+ await Task.Delay(500);
+ cts.Cancel();
+
+ // The token calls Cancel once. Keep cancelling in case that
happened before
+ // DuckDB started the query; the token stays cancelled either way.
+ await CancelUntilCompleteAsync(statement.Cancel, query);
+ OperationCanceledException exception = await
Assert.ThrowsAnyAsync<OperationCanceledException>(() => query);
+ Assert.Equal(cts.Token, exception.CancellationToken);
+
+ // There's no inner exception if the token was cancelled before
the work started
+ if (exception.InnerException != null)
+ {
+ // DuckDB reports interruption as InvalidArgument rather than
Cancelled
+ Assert.Contains("Interrupted",
Assert.IsAssignableFrom<AdbcException>(exception.InnerException).Message);
+ }
+ }
+
+ [Fact]
+ public async Task TransactionsAsync()
+ {
+ using var database = _duckDb.OpenDatabase("transactionsasync.db");
+ using var connection = database.Connect(null);
+ using var statement = connection.CreateStatement();
+
+ statement.SqlQuery = "CREATE TABLE test(x INTEGER)";
+ statement.ExecuteUpdate();
+
+ connection.AutoCommit = false;
+
+ statement.SqlQuery = "INSERT INTO test VALUES (1)";
+ statement.ExecuteUpdate();
+ await connection.RollbackAsync();
+ Assert.Equal(0, GetResultCount(statement, "SELECT * FROM test"));
+
+ // The inserting connection can see its own uncommitted row, so
check from another one
+ using var observer = database.Connect(null);
+ using var observerStatement = observer.CreateStatement();
+
+ statement.SqlQuery = "INSERT INTO test VALUES (2)";
+ statement.ExecuteUpdate();
+ Assert.Equal(0, GetResultCount(observerStatement, "SELECT * FROM
test"));
+ await connection.CommitAsync();
+ Assert.Equal(1, GetResultCount(observerStatement, "SELECT * FROM
test"));
+ }
+
/// <summary>
/// Calls <paramref name="cancel"/> until <paramref name="query"/>
finishes. DuckDB clears
/// an interrupt when a query begins, so a single Cancel that arrives
before the query
@@ -400,6 +684,14 @@ namespace Apache.Arrow.Adbc.Tests
Assert.True(query.IsCompleted, "The query was not cancelled");
}
+ private static string? GetScalarString(AdbcStatement statement, string
query)
+ {
+ statement.SqlQuery = query;
+ using IArrowArrayStream stream = statement.ExecuteQuery().Stream
?? throw new InvalidOperationException("no results found");
+ using RecordBatch batch = stream.ReadNextRecordBatchAsync().Result
?? throw new InvalidOperationException("no rows found");
+ return ((StringArray)batch.Column(0)).GetString(0);
+ }
+
private static long GetResultCount(AdbcStatement statement, string
query)
{
statement.SqlQuery = query;