From db42fa64e57994d62b0cfb0c1db00d01b496db44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 01:28:55 +0100 Subject: [PATCH 1/7] Record GitHub milestone convention in workflow rules Codifies the practice established while wrapping M1: each roadmap milestone gets a matching GitHub milestone, created per phase, with its PR associated on creation and the milestone closed on merge. --- .claude/rules/workflow.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/rules/workflow.md b/.claude/rules/workflow.md index ed783c8..026e83c 100644 --- a/.claude/rules/workflow.md +++ b/.claude/rules/workflow.md @@ -10,4 +10,5 @@ - Merge by squash so each feature arrives as a single logical commit on the default branch, consistent with the "one logical change per commit" rule above; the squash commit message keeps the imperative, *why*-focused form. - Versioning follows semantic versioning: each phase gets its own minor version (Phase 1 → `0.1.x`, Phase 2 → `0.2.x`, …; Phase 6 ships `1.0.0`). - When a phase completes, tag it on the default branch with an annotated tag (e.g., `git tag -a v0.1.0 -m "..."`) and push the tag to GitHub for reference. +- Each roadmap milestone has a matching GitHub milestone (created per phase); the milestone's PR is associated on creation and the milestone closed on merge. - Update this file when a new convention or correction is established. From e37ed6310d9c2ed551bd6eea6282ed6f169be05e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 01:34:33 +0100 Subject: [PATCH 2/7] Add .gitattributes to enforce LF line endings .editorconfig already declares end_of_line = lf for every file, but nothing enforced it at the git layer. On Windows with core.autocrlf=true (the common default), a fresh checkout converts every committed LF file to CRLF in the working tree, which then fails dotnet format's ENDOFLINE check on every file for any Windows contributor. Normalizing at the git level makes checkouts match the editorconfig policy regardless of the contributor's local autocrlf setting. --- .gitattributes | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf From de7c7afd62c2bb44b68ea9bd9aede8ff4ff77184 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 01:37:35 +0100 Subject: [PATCH 3/7] Upgrade test project from xunit 2.9.3 to xunit.v3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xunit 2.x is the legacy package line. Swaps it for xunit.v3 (3.2.2) while keeping Microsoft.NET.Test.Sdk and xunit.runner.visualstudio (bumped to 3.1.5) so execution stays on VSTest via `dotnet test` — no change to the CI workflow or how tests are run. The existing smoke test needed no code changes; namespace and Fact/Assert APIs are unchanged in v3. --- test/SqlBound.UnitTests/SqlBound.UnitTests.csproj | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/SqlBound.UnitTests/SqlBound.UnitTests.csproj b/test/SqlBound.UnitTests/SqlBound.UnitTests.csproj index 0ffbace..b7d5483 100644 --- a/test/SqlBound.UnitTests/SqlBound.UnitTests.csproj +++ b/test/SqlBound.UnitTests/SqlBound.UnitTests.csproj @@ -8,8 +8,11 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + From 7ce36e59e7b4c89a92672cbbedf8453b22296375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 01:39:40 +0100 Subject: [PATCH 4/7] Add SqlParameters: immutable named parameter set The first runtime primitive of the execution core, and the first production code in SqlBound. Public (not internal) since generated code from M4 onward will construct these directly in the consumer's assembly. Normalizes null values to DBNull.Value at construction so downstream ADO.NET binding never needs a separate null check, and rejects empty/whitespace/duplicate names to fail fast on caller bugs rather than silently misbinding a query parameter. --- src/SqlBound/SqlParameters.cs | 61 +++++++++++++++++ test/SqlBound.UnitTests/SqlParametersTests.cs | 68 +++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 src/SqlBound/SqlParameters.cs create mode 100644 test/SqlBound.UnitTests/SqlParametersTests.cs diff --git a/src/SqlBound/SqlParameters.cs b/src/SqlBound/SqlParameters.cs new file mode 100644 index 0000000..1573857 --- /dev/null +++ b/src/SqlBound/SqlParameters.cs @@ -0,0 +1,61 @@ +namespace SqlBound; + +/// +/// An immutable, named set of SQL parameter values. Null values are normalized to +/// so callers can bind them to an ADO.NET parameter without a +/// separate null check. +/// +public sealed class SqlParameters +{ + private readonly KeyValuePair[] _items; + + /// + /// Initializes a new instance with the given named parameter values. Names must be + /// non-empty and unique. + /// + /// + /// A parameter name is null, empty, whitespace, or duplicated. + /// + public SqlParameters(params (string Name, object? Value)[] parameters) + { + ArgumentNullException.ThrowIfNull(parameters); + + var items = new KeyValuePair[parameters.Length]; + var seenNames = new HashSet(StringComparer.Ordinal); + + for (var i = 0; i < parameters.Length; i++) + { + var (name, value) = parameters[i]; + + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Parameter name must not be null or whitespace.", nameof(parameters)); + } + + if (!seenNames.Add(name)) + { + throw new ArgumentException($"Duplicate parameter name '{name}'.", nameof(parameters)); + } + + items[i] = new KeyValuePair(name, value ?? DBNull.Value); + } + + _items = items; + } + + /// + /// An empty parameter set. + /// + public static SqlParameters Empty { get; } = new(); + + /// + /// The number of parameters in this set. + /// + public int Count => _items.Length; + + /// + /// The parameters, in the order they were given. Values are never null; a null + /// value provided at construction is normalized to . + /// + public IReadOnlyList> Items => _items; +} diff --git a/test/SqlBound.UnitTests/SqlParametersTests.cs b/test/SqlBound.UnitTests/SqlParametersTests.cs new file mode 100644 index 0000000..f7c2209 --- /dev/null +++ b/test/SqlBound.UnitTests/SqlParametersTests.cs @@ -0,0 +1,68 @@ +namespace SqlBound.UnitTests; + +public class SqlParametersTests +{ + [Fact] + public void Should_HaveZeroCount_When_ConstructedWithNoParameters() + { + var parameters = new SqlParameters(); + + Assert.Equal(0, parameters.Count); + } + + [Fact] + public void Should_HaveZeroCount_When_UsingEmpty() + { + Assert.Equal(0, SqlParameters.Empty.Count); + } + + [Fact] + public void Should_StoreNameAndValue_When_GivenSingleParameter() + { + var parameters = new SqlParameters(("id", 5)); + + Assert.Equal(1, parameters.Count); + Assert.Equal("id", parameters.Items[0].Key); + Assert.Equal(5, parameters.Items[0].Value); + } + + [Fact] + public void Should_NormalizeToDBNull_When_ValueIsNull() + { + var parameters = new SqlParameters(("name", null)); + + Assert.Equal(DBNull.Value, parameters.Items[0].Value); + } + + [Fact] + public void Should_PreserveOrder_When_GivenMultipleParameters() + { + var parameters = new SqlParameters(("id", 1), ("name", "Ada"), ("active", true)); + + Assert.Equal(["id", "name", "active"], parameters.Items.Select(item => item.Key)); + } + + [Fact] + public void Should_ThrowArgumentException_When_ParameterNameIsNull() + { + Assert.Throws(() => new SqlParameters((null!, 1))); + } + + [Fact] + public void Should_ThrowArgumentException_When_ParameterNameIsEmpty() + { + Assert.Throws(() => new SqlParameters(("", 1))); + } + + [Fact] + public void Should_ThrowArgumentException_When_ParameterNameIsWhitespace() + { + Assert.Throws(() => new SqlParameters((" ", 1))); + } + + [Fact] + public void Should_ThrowArgumentException_When_DuplicateParameterNameGiven() + { + Assert.Throws(() => new SqlParameters(("id", 1), ("id", 2))); + } +} From 0f527987aafd5b3760ac8606eb02b8a3923ea073 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 01:55:46 +0100 Subject: [PATCH 5/7] Exempt test methods from the async-suffix naming rule The Async-suffix naming rule added in M1 didn't account for testing.md's Should_ExpectedOutcome_When_Scenario convention, which async test methods must also follow. This is the first commit with async test methods, so it's the first time the two rules conflicted. Scopes the async-suffix rule off for test/**/*.cs, where the TDD naming convention takes precedence. --- .editorconfig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.editorconfig b/.editorconfig index 95cb50f..577e68b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -95,3 +95,8 @@ dotnet_naming_rule.locals_and_parameters_should_be_camel_case.style = camel_case dotnet_naming_symbols.locals_and_parameters_symbol.applicable_kinds = parameter, local dotnet_naming_style.camel_case_style.capitalization = camel_case + +# Test methods follow testing.md's Should_ExpectedOutcome_When_Scenario convention instead of +# the Async suffix rule above, even when they are themselves async. +[test/**/*.cs] +dotnet_naming_rule.async_methods_should_be_async_suffixed.severity = none From bbd43a3de37899425b56870aa59ffd872a20a33d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 01:56:08 +0100 Subject: [PATCH 6/7] Add SqlSession.RunAsync with hand-rolled ADO.NET test doubles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SqlSession is the instance-based entry point for the coexistence-safe dynamic surface (never named Query*/Execute*/ExecuteScalar* on DbConnection, per the Dapper-coexistence constraint). It never opens, closes, or owns the connection it's given — it requires an already-open one and fails fast with InvalidOperationException otherwise, so behavior can't surprise a caller sharing the connection with Dapper. RunAsync executes a non-query statement and returns the affected row count. Cancellation is checked before any ADO.NET call is made (a pre-canceled token throws before a command is even created) and the token is also threaded through to ExecuteNonQueryAsync so cancellation during execution propagates too. Provider exceptions are left unwrapped. FakeDbConnection/FakeDbCommand/FakeDbParameter(Collection) are minimal hand-rolled subclasses of the abstract System.Data.Common types, chosen over a real provider (e.g. SQLite) to keep unit tests hermetic and defer the real-provider question to M3, which needs one regardless for the Dapper-coexistence sample. --- src/SqlBound/SqlSession.cs | 75 ++++++++++ .../SqlBound.UnitTests/Fakes/FakeDbCommand.cs | 75 ++++++++++ .../Fakes/FakeDbConnection.cs | 49 +++++++ .../Fakes/FakeDbParameter.cs | 30 ++++ .../Fakes/FakeDbParameterCollection.cs | 60 ++++++++ test/SqlBound.UnitTests/SqlSessionTests.cs | 131 ++++++++++++++++++ 6 files changed, 420 insertions(+) create mode 100644 src/SqlBound/SqlSession.cs create mode 100644 test/SqlBound.UnitTests/Fakes/FakeDbCommand.cs create mode 100644 test/SqlBound.UnitTests/Fakes/FakeDbConnection.cs create mode 100644 test/SqlBound.UnitTests/Fakes/FakeDbParameter.cs create mode 100644 test/SqlBound.UnitTests/Fakes/FakeDbParameterCollection.cs create mode 100644 test/SqlBound.UnitTests/SqlSessionTests.cs diff --git a/src/SqlBound/SqlSession.cs b/src/SqlBound/SqlSession.cs new file mode 100644 index 0000000..b6bdcde --- /dev/null +++ b/src/SqlBound/SqlSession.cs @@ -0,0 +1,75 @@ +using System.Data; +using System.Data.Common; + +namespace SqlBound; + +/// +/// Executes SQL against an already-open . SqlSession never opens, +/// closes, or otherwise owns the connection or its lifecycle — that responsibility always +/// stays with the caller, so the same connection (and transaction) can be shared safely with +/// other data-access libraries. +/// +public sealed class SqlSession +{ + private readonly DbConnection _connection; + private readonly DbTransaction? _transaction; + + /// + /// Initializes a new session over the given connection and, optionally, an existing + /// transaction to enlist every command in. + /// + public SqlSession(DbConnection connection, DbTransaction? transaction = null) + { + ArgumentNullException.ThrowIfNull(connection); + + _connection = connection; + _transaction = transaction; + } + + /// + /// Executes a non-query statement and returns the number of affected rows. + /// + /// is null or whitespace. + /// The connection is not open. + public async Task RunAsync( + string sql, + SqlParameters? parameters = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sql); + cancellationToken.ThrowIfCancellationRequested(); + EnsureConnectionOpen(); + + using var command = CreateCommand(sql, parameters); + return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + private void EnsureConnectionOpen() + { + if (_connection.State != ConnectionState.Open) + { + throw new InvalidOperationException( + $"The connection must be open before executing a command. Current state: {_connection.State}."); + } + } + + private DbCommand CreateCommand(string sql, SqlParameters? parameters) + { + var command = _connection.CreateCommand(); + command.CommandText = sql; + command.Transaction = _transaction; + + if (parameters is not null) + { + foreach (var (name, value) in parameters.Items) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.Value = value; + command.Parameters.Add(parameter); + } + } + + return command; + } +} diff --git a/test/SqlBound.UnitTests/Fakes/FakeDbCommand.cs b/test/SqlBound.UnitTests/Fakes/FakeDbCommand.cs new file mode 100644 index 0000000..1e7a32a --- /dev/null +++ b/test/SqlBound.UnitTests/Fakes/FakeDbCommand.cs @@ -0,0 +1,75 @@ +using System.Data; +using System.Data.Common; +using System.Diagnostics.CodeAnalysis; + +namespace SqlBound.UnitTests.Fakes; + +internal sealed class FakeDbCommand : DbCommand +{ + private readonly FakeDbParameterCollection _parameters = new(); + + [AllowNull] + public override string CommandText { get; set; } = string.Empty; + + public override int CommandTimeout { get; set; } + + public override CommandType CommandType { get; set; } = CommandType.Text; + + public override bool DesignTimeVisible { get; set; } + + public override UpdateRowSource UpdatedRowSource { get; set; } + + public int ExecuteNonQueryResult { get; set; } + + public Exception? ExceptionToThrow { get; set; } + + public CancellationToken ReceivedCancellationToken { get; private set; } + + protected override DbConnection? DbConnection { get; set; } + + protected override DbParameterCollection DbParameterCollection => _parameters; + + protected override DbTransaction? DbTransaction { get; set; } + + public override void Cancel() + { + } + + public override int ExecuteNonQuery() + { + if (ExceptionToThrow is not null) + { + throw ExceptionToThrow; + } + + return ExecuteNonQueryResult; + } + + public override Task ExecuteNonQueryAsync(CancellationToken cancellationToken) + { + ReceivedCancellationToken = cancellationToken; + + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + if (ExceptionToThrow is not null) + { + return Task.FromException(ExceptionToThrow); + } + + return Task.FromResult(ExecuteNonQueryResult); + } + + public override object? ExecuteScalar() => throw new NotSupportedException(); + + public override void Prepare() + { + } + + protected override DbParameter CreateDbParameter() => new FakeDbParameter(); + + protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => + throw new NotSupportedException(); +} diff --git a/test/SqlBound.UnitTests/Fakes/FakeDbConnection.cs b/test/SqlBound.UnitTests/Fakes/FakeDbConnection.cs new file mode 100644 index 0000000..d080392 --- /dev/null +++ b/test/SqlBound.UnitTests/Fakes/FakeDbConnection.cs @@ -0,0 +1,49 @@ +using System.Data; +using System.Data.Common; +using System.Diagnostics.CodeAnalysis; + +namespace SqlBound.UnitTests.Fakes; + +internal sealed class FakeDbConnection : DbConnection +{ + public ConnectionState StateOverride { get; set; } = ConnectionState.Open; + + public int ExecuteNonQueryResult { get; set; } + + public Exception? ExecuteException { get; set; } + + public FakeDbCommand? LastCreatedCommand { get; private set; } + + [AllowNull] + public override string ConnectionString { get; set; } = string.Empty; + + public override string Database => string.Empty; + + public override string DataSource => string.Empty; + + public override string ServerVersion => string.Empty; + + public override ConnectionState State => StateOverride; + + public override void ChangeDatabase(string databaseName) => throw new NotSupportedException(); + + public override void Close() => StateOverride = ConnectionState.Closed; + + public override void Open() => StateOverride = ConnectionState.Open; + + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => + throw new NotSupportedException(); + + protected override DbCommand CreateDbCommand() + { + var command = new FakeDbCommand + { + Connection = this, + ExecuteNonQueryResult = ExecuteNonQueryResult, + ExceptionToThrow = ExecuteException, + }; + + LastCreatedCommand = command; + return command; + } +} diff --git a/test/SqlBound.UnitTests/Fakes/FakeDbParameter.cs b/test/SqlBound.UnitTests/Fakes/FakeDbParameter.cs new file mode 100644 index 0000000..eb251c7 --- /dev/null +++ b/test/SqlBound.UnitTests/Fakes/FakeDbParameter.cs @@ -0,0 +1,30 @@ +using System.Data; +using System.Data.Common; +using System.Diagnostics.CodeAnalysis; + +namespace SqlBound.UnitTests.Fakes; + +internal sealed class FakeDbParameter : DbParameter +{ + public override DbType DbType { get; set; } + + public override ParameterDirection Direction { get; set; } = ParameterDirection.Input; + + public override bool IsNullable { get; set; } + + [AllowNull] + public override string ParameterName { get; set; } = string.Empty; + + public override int Size { get; set; } + + [AllowNull] + public override string SourceColumn { get; set; } = string.Empty; + + public override bool SourceColumnNullMapping { get; set; } + + public override DataRowVersion SourceVersion { get; set; } + + public override object? Value { get; set; } + + public override void ResetDbType() => DbType = default; +} diff --git a/test/SqlBound.UnitTests/Fakes/FakeDbParameterCollection.cs b/test/SqlBound.UnitTests/Fakes/FakeDbParameterCollection.cs new file mode 100644 index 0000000..7940ebe --- /dev/null +++ b/test/SqlBound.UnitTests/Fakes/FakeDbParameterCollection.cs @@ -0,0 +1,60 @@ +using System.Collections; +using System.Data.Common; + +namespace SqlBound.UnitTests.Fakes; + +internal sealed class FakeDbParameterCollection : DbParameterCollection +{ + private readonly List _parameters = []; + + public override int Count => _parameters.Count; + + public override object SyncRoot { get; } = new(); + + public override int Add(object value) + { + _parameters.Add((DbParameter)value); + return _parameters.Count - 1; + } + + public override void AddRange(Array values) + { + foreach (var value in values) + { + Add(value!); + } + } + + public override void Clear() => _parameters.Clear(); + + public override bool Contains(object value) => _parameters.Contains((DbParameter)value); + + public override bool Contains(string value) => IndexOf(value) >= 0; + + public override void CopyTo(Array array, int index) => ((ICollection)_parameters).CopyTo(array, index); + + public override IEnumerator GetEnumerator() => _parameters.GetEnumerator(); + + public override int IndexOf(object value) => _parameters.IndexOf((DbParameter)value); + + public override int IndexOf(string parameterName) => + _parameters.FindIndex(parameter => parameter.ParameterName == parameterName); + + public override void Insert(int index, object value) => _parameters.Insert(index, (DbParameter)value); + + public override void Remove(object value) => _parameters.Remove((DbParameter)value); + + public override void RemoveAt(int index) => _parameters.RemoveAt(index); + + public override void RemoveAt(string parameterName) => RemoveAt(IndexOf(parameterName)); + + protected override DbParameter GetParameter(int index) => _parameters[index]; + + protected override DbParameter GetParameter(string parameterName) => + _parameters.First(parameter => parameter.ParameterName == parameterName); + + protected override void SetParameter(int index, DbParameter value) => _parameters[index] = value; + + protected override void SetParameter(string parameterName, DbParameter value) => + _parameters[IndexOf(parameterName)] = value; +} diff --git a/test/SqlBound.UnitTests/SqlSessionTests.cs b/test/SqlBound.UnitTests/SqlSessionTests.cs new file mode 100644 index 0000000..0814f44 --- /dev/null +++ b/test/SqlBound.UnitTests/SqlSessionTests.cs @@ -0,0 +1,131 @@ +using System.Data; +using SqlBound.UnitTests.Fakes; + +namespace SqlBound.UnitTests; + +public class SqlSessionTests +{ + [Fact] + public async Task Should_ReturnAffectedRowCount_When_ExecutingNonQuery() + { + var connection = new FakeDbConnection { ExecuteNonQueryResult = 3 }; + var session = new SqlSession(connection); + + var affected = await session.RunAsync("UPDATE t SET x = 1", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(3, affected); + } + + [Fact] + public async Task Should_SetCommandText_When_ExecutingNonQuery() + { + var connection = new FakeDbConnection(); + var session = new SqlSession(connection); + + await session.RunAsync("DELETE FROM t", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("DELETE FROM t", connection.LastCreatedCommand!.CommandText); + } + + [Fact] + public async Task Should_BindParameters_When_ParametersGiven() + { + var connection = new FakeDbConnection(); + var session = new SqlSession(connection); + var parameters = new SqlParameters(("id", 42)); + + await session.RunAsync("DELETE FROM t WHERE id = @id", parameters, TestContext.Current.CancellationToken); + + var command = connection.LastCreatedCommand!; + Assert.Single(command.Parameters); + Assert.Equal("id", command.Parameters[0].ParameterName); + Assert.Equal(42, command.Parameters[0].Value); + } + + [Fact] + public async Task Should_BindDBNull_When_ParameterValueIsNull() + { + var connection = new FakeDbConnection(); + var session = new SqlSession(connection); + var parameters = new SqlParameters(("name", null)); + + await session.RunAsync("UPDATE t SET name = @name", parameters, TestContext.Current.CancellationToken); + + Assert.Equal(DBNull.Value, connection.LastCreatedCommand!.Parameters[0].Value); + } + + [Fact] + public async Task Should_PassCancellationTokenToCommand_When_TokenProvided() + { + var connection = new FakeDbConnection(); + var session = new SqlSession(connection); + using var cts = new CancellationTokenSource(); + + await session.RunAsync("DELETE FROM t", cancellationToken: cts.Token); + + Assert.Equal(cts.Token, connection.LastCreatedCommand!.ReceivedCancellationToken); + } + + [Fact] + public async Task Should_ThrowOperationCanceledException_When_TokenAlreadyCanceled() + { + var connection = new FakeDbConnection(); + var session = new SqlSession(connection); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAsync( + () => session.RunAsync("DELETE FROM t", cancellationToken: cts.Token)); + + Assert.Null(connection.LastCreatedCommand); + } + + [Fact] + public async Task Should_ThrowInvalidOperationException_When_ConnectionIsClosed() + { + var connection = new FakeDbConnection { StateOverride = ConnectionState.Closed }; + var session = new SqlSession(connection); + + await Assert.ThrowsAsync( + () => session.RunAsync("DELETE FROM t", cancellationToken: TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Should_ThrowArgumentException_When_SqlIsEmpty() + { + var connection = new FakeDbConnection(); + var session = new SqlSession(connection); + + await Assert.ThrowsAsync( + () => session.RunAsync("", cancellationToken: TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Should_ThrowArgumentException_When_SqlIsWhitespace() + { + var connection = new FakeDbConnection(); + var session = new SqlSession(connection); + + await Assert.ThrowsAsync( + () => session.RunAsync(" ", cancellationToken: TestContext.Current.CancellationToken)); + } + + [Fact] + public void Should_ThrowArgumentNullException_When_ConnectionIsNull() + { + Assert.Throws(() => new SqlSession(null!)); + } + + [Fact] + public async Task Should_PropagateProviderException_When_CommandFails() + { + var providerException = new InvalidOperationException("boom"); + var connection = new FakeDbConnection { ExecuteException = providerException }; + var session = new SqlSession(connection); + + var thrown = await Assert.ThrowsAsync( + () => session.RunAsync("DELETE FROM t", cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Same(providerException, thrown); + } +} From dac6fe19606d5711912f49241ce1ccd294cc2501 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 01:59:32 +0100 Subject: [PATCH 7/7] Add SqlSession.FetchScalarAsync Executes a scalar-returning statement and converts the boxed ADO.NET result to T. A database null becomes default(T) when T can represent it (a reference type or Nullable); for a non-nullable value type it throws InvalidOperationException instead, since there's no way to represent "no value" there. Falls back to Convert.ChangeType for same-family conversions (e.g. a provider returning long for a query the caller expects as int) after a fast exact-type-match path. Extracts the sql/cancellation/connection-state validation shared with RunAsync into ValidateAndPrepare, now that two methods need it. --- src/SqlBound/SqlSession.cs | 56 ++++++++- .../SqlBound.UnitTests/Fakes/FakeDbCommand.cs | 29 ++++- .../Fakes/FakeDbConnection.cs | 3 + test/SqlBound.UnitTests/SqlSessionTests.cs | 117 ++++++++++++++++++ 4 files changed, 201 insertions(+), 4 deletions(-) diff --git a/src/SqlBound/SqlSession.cs b/src/SqlBound/SqlSession.cs index b6bdcde..f895a07 100644 --- a/src/SqlBound/SqlSession.cs +++ b/src/SqlBound/SqlSession.cs @@ -36,14 +36,64 @@ public async Task RunAsync( SqlParameters? parameters = null, CancellationToken cancellationToken = default) { - ArgumentException.ThrowIfNullOrWhiteSpace(sql); - cancellationToken.ThrowIfCancellationRequested(); - EnsureConnectionOpen(); + ValidateAndPrepare(sql, cancellationToken); using var command = CreateCommand(sql, parameters); return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } + /// + /// Executes a statement and returns its first column, first row as . + /// A database null converts to when can + /// represent it (a reference type or ); otherwise it throws. + /// + /// is null or whitespace. + /// + /// The connection is not open, or the result is a database null and + /// is a non-nullable value type. + /// + public async Task FetchScalarAsync( + string sql, + SqlParameters? parameters = null, + CancellationToken cancellationToken = default) + { + ValidateAndPrepare(sql, cancellationToken); + + using var command = CreateCommand(sql, parameters); + var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + + return ConvertScalar(result); + } + + private static T? ConvertScalar(object? result) + { + if (result is null or DBNull) + { + if (default(T) is not null) + { + throw new InvalidOperationException( + $"The query returned no value, but '{typeof(T)}' is a non-nullable value type."); + } + + return default; + } + + if (result is T typed) + { + return typed; + } + + var targetType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); + return (T)Convert.ChangeType(result, targetType); + } + + private void ValidateAndPrepare(string sql, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sql); + cancellationToken.ThrowIfCancellationRequested(); + EnsureConnectionOpen(); + } + private void EnsureConnectionOpen() { if (_connection.State != ConnectionState.Open) diff --git a/test/SqlBound.UnitTests/Fakes/FakeDbCommand.cs b/test/SqlBound.UnitTests/Fakes/FakeDbCommand.cs index 1e7a32a..24675ed 100644 --- a/test/SqlBound.UnitTests/Fakes/FakeDbCommand.cs +++ b/test/SqlBound.UnitTests/Fakes/FakeDbCommand.cs @@ -21,6 +21,8 @@ internal sealed class FakeDbCommand : DbCommand public int ExecuteNonQueryResult { get; set; } + public object? ExecuteScalarResult { get; set; } + public Exception? ExceptionToThrow { get; set; } public CancellationToken ReceivedCancellationToken { get; private set; } @@ -62,7 +64,32 @@ public override Task ExecuteNonQueryAsync(CancellationToken cancellationTok return Task.FromResult(ExecuteNonQueryResult); } - public override object? ExecuteScalar() => throw new NotSupportedException(); + public override object? ExecuteScalar() + { + if (ExceptionToThrow is not null) + { + throw ExceptionToThrow; + } + + return ExecuteScalarResult; + } + + public override Task ExecuteScalarAsync(CancellationToken cancellationToken) + { + ReceivedCancellationToken = cancellationToken; + + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + if (ExceptionToThrow is not null) + { + return Task.FromException(ExceptionToThrow); + } + + return Task.FromResult(ExecuteScalarResult); + } public override void Prepare() { diff --git a/test/SqlBound.UnitTests/Fakes/FakeDbConnection.cs b/test/SqlBound.UnitTests/Fakes/FakeDbConnection.cs index d080392..609150b 100644 --- a/test/SqlBound.UnitTests/Fakes/FakeDbConnection.cs +++ b/test/SqlBound.UnitTests/Fakes/FakeDbConnection.cs @@ -10,6 +10,8 @@ internal sealed class FakeDbConnection : DbConnection public int ExecuteNonQueryResult { get; set; } + public object? ExecuteScalarResult { get; set; } + public Exception? ExecuteException { get; set; } public FakeDbCommand? LastCreatedCommand { get; private set; } @@ -40,6 +42,7 @@ protected override DbCommand CreateDbCommand() { Connection = this, ExecuteNonQueryResult = ExecuteNonQueryResult, + ExecuteScalarResult = ExecuteScalarResult, ExceptionToThrow = ExecuteException, }; diff --git a/test/SqlBound.UnitTests/SqlSessionTests.cs b/test/SqlBound.UnitTests/SqlSessionTests.cs index 0814f44..34dea76 100644 --- a/test/SqlBound.UnitTests/SqlSessionTests.cs +++ b/test/SqlBound.UnitTests/SqlSessionTests.cs @@ -128,4 +128,121 @@ public async Task Should_PropagateProviderException_When_CommandFails() Assert.Same(providerException, thrown); } + + [Fact] + public async Task Should_ReturnValue_When_ScalarResultMatchesRequestedType() + { + var connection = new FakeDbConnection { ExecuteScalarResult = 42 }; + var session = new SqlSession(connection); + + var result = await session.FetchScalarAsync( + "SELECT COUNT(*) FROM t", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(42, result); + } + + [Fact] + public async Task Should_ConvertValue_When_ScalarResultTypeDiffersFromRequestedType() + { + var connection = new FakeDbConnection { ExecuteScalarResult = 42L }; + var session = new SqlSession(connection); + + var result = await session.FetchScalarAsync( + "SELECT COUNT(*) FROM t", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(42, result); + } + + [Fact] + public async Task Should_ReturnNull_When_ResultIsDBNullAndTypeIsNullableValueType() + { + var connection = new FakeDbConnection { ExecuteScalarResult = DBNull.Value }; + var session = new SqlSession(connection); + + var result = await session.FetchScalarAsync( + "SELECT MAX(x) FROM t", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Null(result); + } + + [Fact] + public async Task Should_ReturnNull_When_ResultIsNullAndTypeIsReferenceType() + { + var connection = new FakeDbConnection { ExecuteScalarResult = null }; + var session = new SqlSession(connection); + + var result = await session.FetchScalarAsync( + "SELECT name FROM t WHERE 1 = 0", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Null(result); + } + + [Fact] + public async Task Should_ThrowInvalidOperationException_When_ResultIsDBNullAndTypeIsNonNullableValueType() + { + var connection = new FakeDbConnection { ExecuteScalarResult = DBNull.Value }; + var session = new SqlSession(connection); + + await Assert.ThrowsAsync( + () => session.FetchScalarAsync("SELECT MAX(x) FROM t", cancellationToken: TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Should_ThrowInvalidOperationException_When_FetchingScalarOnClosedConnection() + { + var connection = new FakeDbConnection { StateOverride = ConnectionState.Closed }; + var session = new SqlSession(connection); + + await Assert.ThrowsAsync( + () => session.FetchScalarAsync("SELECT 1", cancellationToken: TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Should_ThrowArgumentException_When_FetchingScalarWithEmptySql() + { + var connection = new FakeDbConnection(); + var session = new SqlSession(connection); + + await Assert.ThrowsAsync( + () => session.FetchScalarAsync("", cancellationToken: TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Should_PassCancellationTokenToCommand_When_FetchingScalarWithTokenProvided() + { + var connection = new FakeDbConnection { ExecuteScalarResult = 1 }; + var session = new SqlSession(connection); + using var cts = new CancellationTokenSource(); + + await session.FetchScalarAsync("SELECT 1", cancellationToken: cts.Token); + + Assert.Equal(cts.Token, connection.LastCreatedCommand!.ReceivedCancellationToken); + } + + [Fact] + public async Task Should_ThrowOperationCanceledException_When_FetchingScalarWithAlreadyCanceledToken() + { + var connection = new FakeDbConnection(); + var session = new SqlSession(connection); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAsync( + () => session.FetchScalarAsync("SELECT 1", cancellationToken: cts.Token)); + + Assert.Null(connection.LastCreatedCommand); + } + + [Fact] + public async Task Should_PropagateProviderException_When_ScalarCommandFails() + { + var providerException = new InvalidOperationException("boom"); + var connection = new FakeDbConnection { ExecuteException = providerException }; + var session = new SqlSession(connection); + + var thrown = await Assert.ThrowsAsync( + () => session.FetchScalarAsync("SELECT 1", cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Same(providerException, thrown); + } }