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;

Reply via email to