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 9ee52cd2f feat(csharp/src/Client): use the cancellable statement APIs
in AdbcCommand (#4901)
9ee52cd2f is described below
commit 9ee52cd2fe3420e9050b3e2629a28883abec8636
Author: Curt Hagenlocher <[email protected]>
AuthorDate: Thu Oct 15 13:07:08 2026 -0700
feat(csharp/src/Client): use the cancellable statement APIs in AdbcCommand
(#4901)
## What's changed
The ADO.NET wrapper's `AdbcCommand` now uses the asynchronous,
cancellable statement methods added in #4895:
- **`ExecuteReaderAsync`** passes its `CancellationToken` to
`AdbcStatement.ExecuteQueryAsync(CancellationToken)`. Before, the token
was checked once up front and then dropped.
- **`ExecuteNonQueryAsync`** is now overridden to call
`AdbcStatement.ExecuteUpdateAsync(CancellationToken)`. The `DbCommand`
default it replaces runs `ExecuteNonQuery` synchronously on the caller's
thread and ignores the token once the command has started.
- **`Cancel`** now calls `AdbcStatement.Cancel` instead of throwing
`NotImplementedException`. As the `DbCommand.Cancel` contract requires,
it does nothing if there's nothing to cancel, and it doesn't throw if
the driver can't cancel.
This is the fourth of several PRs implementing ADBC 1.1. #4884 fixed
bugs in the C importer and exporter, #4885 added the 1.1 APIs to the
base classes, and #4895 added async methods and cancellation. Wider
DuckDB tests come next.
### Compatibility
- **Drivers that implement only the synchronous methods** keep working
through the base class defaults, which run them on the thread pool.
- **Drivers that override the older parameterless `ExecuteQueryAsync()`
or `ExecuteUpdateAsync()`** still have those overrides called, because
the token overloads forward to them by default.
- **`Cancel` no longer throws `NotImplementedException`.** Code that
relied on that exception would see a change, but `DbCommand.Cancel`
isn't supposed to throw.
- **`ExecuteScalarAsync` and `PrepareAsync`** still use the `DbCommand`
defaults. `ExecuteScalar` isn't implemented yet, so they're left for a
follow-up.
## Testing
New and updated tests in `ClientTests`:
- **Token passing:** `ExecuteReaderAsync` and `ExecuteNonQueryAsync`
pass the caller's token to the statement.
- **Sync fallback:** `ExecuteNonQueryAsync` works with a driver that
implements only `ExecuteUpdate`.
- **Pre-cancelled tokens:** a token that's already cancelled stops
`ExecuteNonQueryAsync` before it reaches the statement.
- **`Cancel`:** it forwards to the statement, and it doesn't throw when
the driver can't cancel.
- **Updated mocks:** the existing tests now set up the token overload of
`ExecuteQueryAsync`, which the command calls.
**Results:**
- 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]>
---
csharp/src/Client/AdbcCommand.cs | 35 ++++++++--
.../Apache.Arrow.Adbc.Tests/Client/ClientTests.cs | 81 +++++++++++++++++++++-
2 files changed, 107 insertions(+), 9 deletions(-)
diff --git a/csharp/src/Client/AdbcCommand.cs b/csharp/src/Client/AdbcCommand.cs
index e4eb7fd29..d11812f2c 100644
--- a/csharp/src/Client/AdbcCommand.cs
+++ b/csharp/src/Client/AdbcCommand.cs
@@ -182,6 +182,15 @@ namespace Apache.Arrow.Adbc.Client
return Convert.ToInt32(AdbcStatement.ExecuteUpdate().AffectedRows);
}
+ public override async Task<int> ExecuteNonQueryAsync(CancellationToken
cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ BindParameters();
+ UpdateResult result = await
AdbcStatement.ExecuteUpdateAsync(cancellationToken).ConfigureAwait(false);
+ return Convert.ToInt32(result.AffectedRows);
+ }
+
/// <summary>
/// Similar to <see cref="ExecuteNonQuery"/> but returns Int64
/// instead of Int32.
@@ -217,7 +226,7 @@ namespace Apache.Arrow.Adbc.Client
cancellationToken.ThrowIfCancellationRequested();
BindParameters();
- QueryResult result = await
AdbcStatement.ExecuteQueryAsync().ConfigureAwait(false);
+ QueryResult result = await
AdbcStatement.ExecuteQueryAsync(cancellationToken).ConfigureAwait(false);
return new AdbcDataReader(this, result, this.DecimalBehavior,
this.StructBehavior, closeConnection);
}
@@ -561,6 +570,25 @@ namespace Apache.Arrow.Adbc.Client
}
#endif
+ /// <summary>
+ /// Attempts to cancel the execution of this command.
+ /// </summary>
+ /// <remarks>
+ /// As <see cref="DbCommand.Cancel"/> requires, nothing happens if
there is nothing
+ /// to cancel, and no exception is thrown if the attempt to cancel
fails, for
+ /// example because the driver doesn't support cancellation.
+ /// </remarks>
+ public override void Cancel()
+ {
+ try
+ {
+ _adbcStatement.Cancel();
+ }
+ catch (Exception)
+ {
+ }
+ }
+
#region NOT_IMPLEMENTED
public override bool DesignTimeVisible { get => throw new
NotImplementedException(); set => throw new NotImplementedException(); }
@@ -569,11 +597,6 @@ namespace Apache.Arrow.Adbc.Client
protected override DbTransaction? DbTransaction { get => throw new
NotImplementedException(); set => throw new NotImplementedException(); }
- public override void Cancel()
- {
- throw new NotImplementedException();
- }
-
public override object ExecuteScalar()
{
throw new NotImplementedException();
diff --git a/csharp/test/Apache.Arrow.Adbc.Tests/Client/ClientTests.cs
b/csharp/test/Apache.Arrow.Adbc.Tests/Client/ClientTests.cs
index 7c96dd02e..a95d25d43 100644
--- a/csharp/test/Apache.Arrow.Adbc.Tests/Client/ClientTests.cs
+++ b/csharp/test/Apache.Arrow.Adbc.Tests/Client/ClientTests.cs
@@ -294,10 +294,85 @@ namespace Apache.Arrow.Adbc.Tests.Client
{
}
- fixture.Statement.Verify(x => x.ExecuteQueryAsync(), Times.Once);
+ fixture.Statement.Verify(x =>
x.ExecuteQueryAsync(It.IsAny<CancellationToken>()), Times.Once);
fixture.Statement.Verify(x => x.ExecuteQuery(), Times.Never);
}
+ [Fact]
+ public async Task
ExecuteReaderAsyncPassesTheCancellationTokenToTheStatement()
+ {
+ AsyncReaderFixture fixture = AsyncReaderFixture.Create(batchCount:
1, rowsPerBatch: 1);
+
+ using CancellationTokenSource cts = new CancellationTokenSource();
+ using (DbDataReader reader = await
fixture.Command.ExecuteReaderAsync(cts.Token))
+ {
+ }
+
+ fixture.Statement.Verify(x => x.ExecuteQueryAsync(cts.Token),
Times.Once);
+ }
+
+ [Fact]
+ public async Task ExecuteNonQueryAsyncUsesTheAsyncStatementPath()
+ {
+ Mock<AdbcStatement> mockStatement = new Mock<AdbcStatement>();
+ mockStatement
+ .Setup(x =>
x.ExecuteUpdateAsync(It.IsAny<CancellationToken>()))
+ .Returns(Task.FromResult(new UpdateResult(7)));
+
+ AdbcCommand command = new AdbcCommand(mockStatement.Object, new
AdbcClient.AdbcConnection());
+
+ using CancellationTokenSource cts = new CancellationTokenSource();
+ Assert.Equal(7, await command.ExecuteNonQueryAsync(cts.Token));
+
+ mockStatement.Verify(x => x.ExecuteUpdateAsync(cts.Token),
Times.Once);
+ mockStatement.Verify(x => x.ExecuteUpdate(), Times.Never);
+ }
+
+ [Fact]
+ public async Task ExecuteNonQueryAsyncFallsBackToSyncExecuteUpdate()
+ {
+ Mock<AdbcStatement> mockStatement = new Mock<AdbcStatement>() {
CallBase = true };
+ mockStatement.Setup(x => x.ExecuteUpdate()).Returns(new
UpdateResult(3));
+
+ AdbcCommand command = new AdbcCommand(mockStatement.Object, new
AdbcClient.AdbcConnection());
+
+ Assert.Equal(3, await command.ExecuteNonQueryAsync());
+ }
+
+ [Fact]
+ public void CancelForwardsToTheStatement()
+ {
+ Mock<AdbcStatement> mockStatement = new Mock<AdbcStatement>();
+ AdbcCommand command = new AdbcCommand(mockStatement.Object, new
AdbcClient.AdbcConnection());
+
+ command.Cancel();
+
+ mockStatement.Verify(x => x.Cancel(), Times.Once);
+ }
+
+ [Fact]
+ public void CancelDoesNotThrowWhenTheDriverCannotCancel()
+ {
+ // The base AdbcStatement.Cancel throws NotImplemented
+ Mock<AdbcStatement> mockStatement = new Mock<AdbcStatement>() {
CallBase = true };
+ AdbcCommand command = new AdbcCommand(mockStatement.Object, new
AdbcClient.AdbcConnection());
+
+ command.Cancel();
+ }
+
+ [Fact]
+ public async Task ExecuteNonQueryAsyncHonorsAPreCancelledToken()
+ {
+ Mock<AdbcStatement> mockStatement = new Mock<AdbcStatement>();
+ AdbcCommand command = new AdbcCommand(mockStatement.Object, new
AdbcClient.AdbcConnection());
+
+ using CancellationTokenSource cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
command.ExecuteNonQueryAsync(cts.Token));
+ mockStatement.Verify(x =>
x.ExecuteUpdateAsync(It.IsAny<CancellationToken>()), Times.Never);
+ }
+
[Fact]
public async Task ReadAsyncPassesTheCancellationTokenToTheStream()
{
@@ -568,7 +643,7 @@ namespace Apache.Arrow.Adbc.Tests.Client
/// <param name="syncOnlyDriver">
/// Stub <see cref="AdbcStatement.ExecuteQuery"/> only, leaving the
base
- /// <see cref="AdbcStatement.ExecuteQueryAsync"/> to supply the result.
+ /// <see cref="AdbcStatement.ExecuteQueryAsync(CancellationToken)"/>
to supply the result.
/// </param>
public static AsyncReaderFixture Create(
int batchCount,
@@ -606,7 +681,7 @@ namespace Apache.Arrow.Adbc.Tests.Client
}
else
{
- mockStatement.Setup(x => x.ExecuteQueryAsync()).Returns(new
ValueTask<QueryResult>(queryResult));
+ mockStatement.Setup(x =>
x.ExecuteQueryAsync(It.IsAny<CancellationToken>())).Returns(new
ValueTask<QueryResult>(queryResult));
}
AdbcClient.AdbcConnection connection = new
AdbcClient.AdbcConnection();