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. 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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf 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/src/SqlBound/SqlSession.cs b/src/SqlBound/SqlSession.cs new file mode 100644 index 0000000..f895a07 --- /dev/null +++ b/src/SqlBound/SqlSession.cs @@ -0,0 +1,125 @@ +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) + { + 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) + { + 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..24675ed --- /dev/null +++ b/test/SqlBound.UnitTests/Fakes/FakeDbCommand.cs @@ -0,0 +1,102 @@ +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 object? ExecuteScalarResult { 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() + { + 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() + { + } + + 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..609150b --- /dev/null +++ b/test/SqlBound.UnitTests/Fakes/FakeDbConnection.cs @@ -0,0 +1,52 @@ +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 object? ExecuteScalarResult { 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, + ExecuteScalarResult = ExecuteScalarResult, + 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/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 + + 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))); + } +} diff --git a/test/SqlBound.UnitTests/SqlSessionTests.cs b/test/SqlBound.UnitTests/SqlSessionTests.cs new file mode 100644 index 0000000..34dea76 --- /dev/null +++ b/test/SqlBound.UnitTests/SqlSessionTests.cs @@ -0,0 +1,248 @@ +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); + } + + [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); + } +}