diff --git a/examples/SharpFunctional.MSSQL.Example/Program.cs b/examples/SharpFunctional.MSSQL.Example/Program.cs index 989f896..ea656cd 100644 --- a/examples/SharpFunctional.MSSQL.Example/Program.cs +++ b/examples/SharpFunctional.MSSQL.Example/Program.cs @@ -50,7 +50,7 @@ await using var sqlConnection = new SqlConnection(connectionString); await sqlConnection.OpenAsync(); -var db = new FunctionalMsSqlDb(dbContext: dbContext, connection: sqlConnection); +var db = new FunctionalMsSqlDb(dbContext: dbContext, dbConnection: sqlConnection); // ═══════════════════════════════════════════════════════════════════════════ // 2. Seed data — customers, products, orders, and order lines diff --git a/src/SharpFunctional.MSSQL/Common/CircuitBreaker.cs b/src/SharpFunctional.MSSQL/Common/CircuitBreaker.cs index 2e0af3a..860c881 100644 --- a/src/SharpFunctional.MSSQL/Common/CircuitBreaker.cs +++ b/src/SharpFunctional.MSSQL/Common/CircuitBreaker.cs @@ -74,14 +74,15 @@ public sealed class CircuitBreakerOptions /// }, cancellationToken); /// /// -public sealed class CircuitBreaker(CircuitBreakerOptions? options = null) +public sealed class CircuitBreaker(CircuitBreakerOptions? options = null, TimeProvider? timeProvider = null) { private readonly CircuitBreakerOptions _options = options ?? new CircuitBreakerOptions(); + private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System; private readonly Lock _stateLock = new(); private CircuitState _state = CircuitState.Closed; private DateTime _openedAtUtc = DateTime.MinValue; - private DateTime _stateChangedAtUtc = DateTime.UtcNow; + private DateTime _stateChangedAtUtc = (timeProvider ?? TimeProvider.System).GetUtcNow().UtcDateTime; private int _failureCount; private int _halfOpenSuccessCount; @@ -120,7 +121,7 @@ public CircuitBreakerSnapshot GetSnapshot() { lock (_stateLock) { - var nowUtc = DateTime.UtcNow; + var nowUtc = _timeProvider.GetUtcNow().UtcDateTime; var state = EvaluateState(nowUtc); return new CircuitBreakerSnapshot( State: state, @@ -186,12 +187,12 @@ public void Reset() _state = CircuitState.Closed; _failureCount = 0; _halfOpenSuccessCount = 0; - _stateChangedAtUtc = DateTime.UtcNow; + _stateChangedAtUtc = _timeProvider.GetUtcNow().UtcDateTime; } } /// Must be called inside lock (_stateLock). - private CircuitState EvaluateState() => EvaluateState(DateTime.UtcNow); + private CircuitState EvaluateState() => EvaluateState(_timeProvider.GetUtcNow().UtcDateTime); /// Must be called inside lock (_stateLock). private CircuitState EvaluateState(DateTime utcNow) @@ -215,13 +216,13 @@ private void RecordFailure() if (_state == CircuitState.HalfOpen) { _state = CircuitState.Open; - _openedAtUtc = DateTime.UtcNow; + _openedAtUtc = _timeProvider.GetUtcNow().UtcDateTime; _stateChangedAtUtc = _openedAtUtc; } else if (_state == CircuitState.Closed && _failureCount >= _options.FailureThreshold) { _state = CircuitState.Open; - _openedAtUtc = DateTime.UtcNow; + _openedAtUtc = _timeProvider.GetUtcNow().UtcDateTime; _stateChangedAtUtc = _openedAtUtc; } } @@ -238,7 +239,7 @@ private void RecordSuccess() _state = CircuitState.Closed; _failureCount = 0; _halfOpenSuccessCount = 0; - _stateChangedAtUtc = DateTime.UtcNow; + _stateChangedAtUtc = _timeProvider.GetUtcNow().UtcDateTime; } } else diff --git a/src/SharpFunctional.MSSQL/Common/FunctionalExtensions.cs b/src/SharpFunctional.MSSQL/Common/FunctionalExtensions.cs index e029ae5..0969ef3 100644 --- a/src/SharpFunctional.MSSQL/Common/FunctionalExtensions.cs +++ b/src/SharpFunctional.MSSQL/Common/FunctionalExtensions.cs @@ -34,8 +34,13 @@ public static async Task> Bind( None: () => Task.FromResult(Option.None)) .ConfigureAwait(false); } - catch + catch (OperationCanceledException) { + throw; + } + catch (Exception) + { + // TODO -review This eats all exception information. Consider logging the exception or returning a Result type that can capture it. Or leave it to the caller to log by throwing. return Option.None; } } @@ -66,8 +71,13 @@ public static async Task> Bind( None: () => Task.FromResult(Seq())) .ConfigureAwait(false); } - catch + catch (OperationCanceledException) + { + throw; + } + catch (Exception) { + // TODO -review This eats all exception information. Consider logging the exception or returning a Result type that can capture it. Or leave it to the caller to log by throwing. return Seq(); } } @@ -95,8 +105,13 @@ public static async Task> Map( var sequence = await source.WaitAsync(cancellationToken).ConfigureAwait(false); return mapper(sequence); } - catch + catch (OperationCanceledException) + { + throw; + } + catch (Exception) { + // TODO -review This eats all exception information. Consider logging the exception or returning a Result type that can capture it. Or leave it to the caller to log by throwing. return Seq(); } } diff --git a/src/SharpFunctional.MSSQL/Dapper/DapperFunctionalDb.cs b/src/SharpFunctional.MSSQL/Dapper/DapperFunctionalDb.cs index 897b017..59d9ac0 100644 --- a/src/SharpFunctional.MSSQL/Dapper/DapperFunctionalDb.cs +++ b/src/SharpFunctional.MSSQL/Dapper/DapperFunctionalDb.cs @@ -55,7 +55,7 @@ public async Task> ExecuteStoredProcSingleAsync( var command = new CommandDefinition( procName, param, - transaction: Owner.AmbientTransaction, + transaction: Owner.GetAmbientTransaction(), commandTimeout: Options.CommandTimeoutSeconds, commandType: CommandType.StoredProcedure, cancellationToken: ct); @@ -112,7 +112,7 @@ public async Task> ExecuteStoredProcAsync( var command = new CommandDefinition( procName, param, - transaction: Owner.AmbientTransaction, + transaction: Owner.GetAmbientTransaction(), commandTimeout: Options.CommandTimeoutSeconds, commandType: CommandType.StoredProcedure, cancellationToken: ct); @@ -173,7 +173,7 @@ await ExecuteWithRetryAsync( var command = new CommandDefinition( procName, param, - transaction: Owner.AmbientTransaction, + transaction: Owner.GetAmbientTransaction(), commandTimeout: Options.CommandTimeoutSeconds, commandType: CommandType.StoredProcedure, cancellationToken: ct); @@ -231,7 +231,7 @@ public async Task> QueryAsync( var command = new CommandDefinition( sql, param, - transaction: Owner.AmbientTransaction, + transaction: Owner.GetAmbientTransaction(), commandTimeout: Options.CommandTimeoutSeconds, cancellationToken: ct); @@ -287,7 +287,7 @@ public async Task> QuerySingleAsync( var command = new CommandDefinition( sql, param, - transaction: Owner.AmbientTransaction, + transaction: Owner.GetAmbientTransaction(), commandTimeout: Options.CommandTimeoutSeconds, cancellationToken: ct); @@ -358,7 +358,7 @@ public async Task>> ExecuteStoredProcPaginatedAsync( var command = new CommandDefinition( procName, param, - transaction: Owner.AmbientTransaction, + transaction: Owner.GetAmbientTransaction(), commandTimeout: Options.CommandTimeoutSeconds, commandType: CommandType.StoredProcedure, cancellationToken: ct); diff --git a/src/SharpFunctional.MSSQL/DependencyInjection/ServiceCollectionExtensions.cs b/src/SharpFunctional.MSSQL/DependencyInjection/ServiceCollectionExtensions.cs index 7137ed7..d430dbc 100644 --- a/src/SharpFunctional.MSSQL/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/SharpFunctional.MSSQL/DependencyInjection/ServiceCollectionExtensions.cs @@ -86,7 +86,7 @@ public static IServiceCollection AddFunctionalMsSqlDapper( var opts = sp.GetRequiredService>().Value; var connection = new SqlConnection(opts.ConnectionString); var logger = sp.GetService>(); - return new FunctionalMsSqlDb(connection: connection, executionOptions: opts.ExecutionOptions, logger: logger); + return new FunctionalMsSqlDb(dbConnection: connection, executionOptions: opts.ExecutionOptions, logger: logger); }); return services; @@ -132,7 +132,7 @@ public static IServiceCollection AddFunctionalMsSql( var opts = sp.GetRequiredService>().Value; var connection = new SqlConnection(opts.ConnectionString); var logger = sp.GetService>(); - return new FunctionalMsSqlDb(dbContext: context, connection: connection, executionOptions: opts.ExecutionOptions, logger: logger); + return new FunctionalMsSqlDb(dbContext: context, dbConnection: connection, executionOptions: opts.ExecutionOptions, logger: logger); }); return services; @@ -180,7 +180,7 @@ public static IServiceCollection AddFunctionalMsSql( var connection = new SqlConnection(opts.ConnectionString); var logger = sp.GetService>(); - return new FunctionalMsSqlDb(dbContext: context, connection: connection, executionOptions: opts.ExecutionOptions, logger: logger); + return new FunctionalMsSqlDb(dbContext: context, dbConnection: connection, executionOptions: opts.ExecutionOptions, logger: logger); }); return services; diff --git a/src/SharpFunctional.MSSQL/FunctionalMsSqlDb.Log.cs b/src/SharpFunctional.MSSQL/FunctionalMsSqlDb.Log.cs index 2f9086d..5ee3c4e 100644 --- a/src/SharpFunctional.MSSQL/FunctionalMsSqlDb.Log.cs +++ b/src/SharpFunctional.MSSQL/FunctionalMsSqlDb.Log.cs @@ -5,35 +5,35 @@ namespace SharpFunctional.MsSql; internal static partial class FunctionalMsSqlDbLog { [LoggerMessage(EventId = 1000, Level = LogLevel.Debug, Message = "Starting EF transaction for result type {ResultType}")] - internal static partial void StartingEfTransaction(ILogger logger, string resultType); + internal static partial void StartingEfTransaction(this ILogger logger, string resultType); [LoggerMessage(EventId = 1001, Level = LogLevel.Debug, Message = "Committed EF transaction for result type {ResultType}")] - internal static partial void CommittedEfTransaction(ILogger logger, string resultType); + internal static partial void CommittedEfTransaction(this ILogger logger, string resultType); [LoggerMessage(EventId = 1002, Level = LogLevel.Warning, Message = "Rolled back EF transaction due to failed result for type {ResultType}")] - internal static partial void RolledBackEfTransaction(ILogger logger, string resultType); + internal static partial void RolledBackEfTransaction(this ILogger logger, string resultType); [LoggerMessage(EventId = 1003, Level = LogLevel.Error, Message = "EF transaction failed for result type {ResultType}")] - internal static partial void EfTransactionFailed(ILogger logger, string resultType, Exception exception); + internal static partial void EfTransactionFailed(this ILogger logger, string resultType, Exception exception); [LoggerMessage(EventId = 1010, Level = LogLevel.Debug, Message = "Starting Dapper transaction for result type {ResultType}")] - internal static partial void StartingDapperTransaction(ILogger logger, string resultType); + internal static partial void StartingDapperTransaction(this ILogger logger, string resultType); [LoggerMessage(EventId = 1011, Level = LogLevel.Debug, Message = "Committed Dapper transaction for result type {ResultType}")] - internal static partial void CommittedDapperTransaction(ILogger logger, string resultType); + internal static partial void CommittedDapperTransaction(this ILogger logger, string resultType); [LoggerMessage(EventId = 1012, Level = LogLevel.Warning, Message = "Rolled back Dapper transaction due to failed result for type {ResultType}")] - internal static partial void RolledBackDapperTransaction(ILogger logger, string resultType); + internal static partial void RolledBackDapperTransaction(this ILogger logger, string resultType); [LoggerMessage(EventId = 1013, Level = LogLevel.Error, Message = "Dapper transaction failed for result type {ResultType}")] - internal static partial void DapperTransactionFailed(ILogger logger, string resultType, Exception exception); + internal static partial void DapperTransactionFailed(this ILogger logger, string resultType, Exception exception); [LoggerMessage(EventId = 1020, Level = LogLevel.Debug, Message = "Opened SQL connection after {AttemptCount} attempt(s)")] - internal static partial void OpenedSqlConnection(ILogger logger, int attemptCount); + internal static partial void OpenedSqlConnection(this ILogger logger, int attemptCount); [LoggerMessage(EventId = 1021, Level = LogLevel.Warning, Message = "Transient SQL open failure on attempt {Attempt}. Retrying in {DelayMs} ms")] - internal static partial void TransientSqlOpenFailure(ILogger logger, int attempt, double delayMs, Exception exception); + internal static partial void TransientSqlOpenFailure(this ILogger logger, int attempt, double delayMs, Exception exception); [LoggerMessage(EventId = 1022, Level = LogLevel.Error, Message = "SQL connection open failed after {AttemptCount} attempt(s)")] - internal static partial void SqlConnectionOpenFailed(ILogger logger, int attemptCount, Exception exception); + internal static partial void SqlConnectionOpenFailed(this ILogger logger, int attemptCount, Exception exception); } diff --git a/src/SharpFunctional.MSSQL/FunctionalMsSqlDb.cs b/src/SharpFunctional.MSSQL/FunctionalMsSqlDb.cs index 04fda67..fc22e99 100644 --- a/src/SharpFunctional.MSSQL/FunctionalMsSqlDb.cs +++ b/src/SharpFunctional.MSSQL/FunctionalMsSqlDb.cs @@ -26,7 +26,7 @@ namespace SharpFunctional.MsSql; /// /// Dapper example: /// -/// var db = new FunctionalMsSqlDb(connection: sqlConnection); +/// var db = new FunctionalMsSqlDb(dbconnection: sqlConnection); /// var rows = await db.Dapper().ExecuteStoredProcAsync<UserDto>("dbo.Users_GetByStatus", new { Status = 1 }); /// /// @@ -51,26 +51,29 @@ namespace SharpFunctional.MsSql; /// public sealed class FunctionalMsSqlDb( DbContext? dbContext = null, - IDbConnection? connection = null, + IDbConnection? dbConnection = null, SqlExecutionOptions? executionOptions = null, ILogger? logger = null) { - private DbContext? EfContext => dbContext; - private IDbConnection? ConnectionDb => connection; + private readonly bool _hasBackend = dbContext is not null || dbConnection is not null + ? true + : throw new ArgumentException("Either dbContext or dbConnection must be provided."); + private IDbTransaction? _ambientTransaction; + private SqlExecutionOptions Options => executionOptions ?? SqlExecutionOptions.Default; private ILogger? Log => logger; - internal IDbTransaction? AmbientTransaction { get; private set; } + internal IDbTransaction? GetAmbientTransaction() => _ambientTransaction; /// /// Returns the EF Core functional accessor. Default behavior is no-tracking. /// - public EfFunctionalDb Ef() => new(EfContext, executionOptions: Options); + public EfFunctionalDb Ef() => new(dbContext, executionOptions: Options); /// /// Returns the Dapper functional accessor. /// - public DapperFunctionalDb Dapper() => new(ConnectionDb, this, Options, Log); + public DapperFunctionalDb Dapper() => new(dbConnection, this, Options, Log); /// /// Executes an action in a transaction and commits only when the action succeeds. @@ -83,6 +86,11 @@ public async Task> InTransactionAsync( Func>> action, CancellationToken cancellationToken = default) { + if (!_hasBackend) + { + return FinFail(Error.New("No backend is configured. Configure either DbContext or DbConnection.")); + } + if (action is null) { return FinFail(Error.New("Transaction action cannot be null.")); @@ -91,7 +99,7 @@ public async Task> InTransactionAsync( var loggerInstance = Log; var resultTypeName = typeof(T).Name; - if (EfContext is not null) + if (dbContext is not null) { using var activity = SharpFunctionalMsSqlDiagnostics.ActivitySource.StartActivity("sharpfunctional.mssql.transaction"); SharpFunctionalMsSqlDiagnostics.ApplyActivityEnricher(activity, Options); @@ -101,21 +109,15 @@ public async Task> InTransactionAsync( try { - if (loggerInstance is not null) - { - FunctionalMsSqlDbLog.StartingEfTransaction(loggerInstance, resultTypeName); - } + loggerInstance?.StartingEfTransaction(resultTypeName); - await using var transaction = await EfContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); var result = await action(this).ConfigureAwait(false); if (result.IsSucc) { await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); - if (loggerInstance is not null) - { - FunctionalMsSqlDbLog.CommittedEfTransaction(loggerInstance, resultTypeName); - } + loggerInstance?.CommittedEfTransaction(resultTypeName); activity?.SetTag(SharpFunctionalMsSqlDiagnostics.SuccessTag, true); activity?.SetStatus(ActivityStatusCode.Ok); @@ -123,10 +125,7 @@ public async Task> InTransactionAsync( } await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false); - if (loggerInstance is not null) - { - FunctionalMsSqlDbLog.RolledBackEfTransaction(loggerInstance, resultTypeName); - } + loggerInstance?.RolledBackEfTransaction(resultTypeName); activity?.SetTag(SharpFunctionalMsSqlDiagnostics.SuccessTag, false); activity?.SetStatus(ActivityStatusCode.Error, "transaction result failed"); @@ -134,10 +133,7 @@ public async Task> InTransactionAsync( } catch (Exception exception) { - if (loggerInstance is not null) - { - FunctionalMsSqlDbLog.EfTransactionFailed(loggerInstance, resultTypeName, exception); - } + loggerInstance?.EfTransactionFailed(resultTypeName, exception); activity?.SetTag(SharpFunctionalMsSqlDiagnostics.SuccessTag, false); activity?.SetStatus(ActivityStatusCode.Error, exception.Message); @@ -145,12 +141,12 @@ public async Task> InTransactionAsync( } } - if (ConnectionDb is null) + if (dbConnection is null) { return FinFail(Error.New("No backend is configured. Configure either DbContext or IDbConnection.")); } - if (AmbientTransaction is not null) + if (_ambientTransaction is not null) { return FinFail(Error.New("Nested transactions are not supported for Dapper backend.")); } @@ -163,53 +159,43 @@ public async Task> InTransactionAsync( try { - if (loggerInstance is not null) - { - FunctionalMsSqlDbLog.StartingDapperTransaction(loggerInstance, resultTypeName); - } + loggerInstance?.StartingDapperTransaction(resultTypeName); - if (ConnectionDb.State != ConnectionState.Open) + if (dbConnection.State != ConnectionState.Open) { - if (ConnectionDb is not System.Data.Common.DbConnection dbConnection) + if (dbConnection is not System.Data.Common.DbConnection dbConn) { dapperActivity?.SetTag(SharpFunctionalMsSqlDiagnostics.SuccessTag, false); - dapperActivity?.SetStatus(ActivityStatusCode.Error, "connection does not derive from DbConnection"); - return FinFail(Error.New("Connection must derive from DbConnection for async open operations.")); + dapperActivity?.SetStatus(ActivityStatusCode.Error, "dbConnection does not derive from DbConnection"); + return FinFail(Error.New("DbConnection must derive from DbConnection for async open operations.")); } - var openResult = await OpenConnectionWithRetryAsync(dbConnection, cancellationToken).ConfigureAwait(false); + var openResult = await OpenConnectionWithRetryAsync(dbConn, cancellationToken).ConfigureAwait(false); if (openResult.IsFail) { dapperActivity?.SetTag(SharpFunctionalMsSqlDiagnostics.SuccessTag, false); - dapperActivity?.SetStatus(ActivityStatusCode.Error, "connection open failed"); + dapperActivity?.SetStatus(ActivityStatusCode.Error, "dbConnection open failed"); return openResult.Match( - Succ: _ => FinFail(Error.New("Failed to open SQL connection.")), + Succ: _ => FinFail(Error.New("Failed to open SQL dbConnection.")), Fail: error => FinFail(error)); } } - using var transaction = ConnectionDb.BeginTransaction(); - AmbientTransaction = transaction; + _ambientTransaction = dbConnection.BeginTransaction(); var result = await action(this).ConfigureAwait(false); if (result.IsSucc) { - transaction.Commit(); - if (loggerInstance is not null) - { - FunctionalMsSqlDbLog.CommittedDapperTransaction(loggerInstance, resultTypeName); - } + _ambientTransaction.Commit(); + loggerInstance?.CommittedDapperTransaction(resultTypeName); dapperActivity?.SetTag(SharpFunctionalMsSqlDiagnostics.SuccessTag, true); dapperActivity?.SetStatus(ActivityStatusCode.Ok); return result; } - transaction.Rollback(); - if (loggerInstance is not null) - { - FunctionalMsSqlDbLog.RolledBackDapperTransaction(loggerInstance, resultTypeName); - } + _ambientTransaction.Rollback(); + loggerInstance?.RolledBackDapperTransaction(resultTypeName); dapperActivity?.SetTag(SharpFunctionalMsSqlDiagnostics.SuccessTag, false); dapperActivity?.SetStatus(ActivityStatusCode.Error, "transaction result failed"); @@ -217,10 +203,9 @@ public async Task> InTransactionAsync( } catch (Exception exception) { - if (loggerInstance is not null) - { - FunctionalMsSqlDbLog.DapperTransactionFailed(loggerInstance, resultTypeName, exception); - } + try { _ambientTransaction?.Rollback(); } catch { } + + loggerInstance?.DapperTransactionFailed(resultTypeName, exception); dapperActivity?.SetTag(SharpFunctionalMsSqlDiagnostics.SuccessTag, false); dapperActivity?.SetStatus(ActivityStatusCode.Error, exception.Message); @@ -228,7 +213,8 @@ public async Task> InTransactionAsync( } finally { - AmbientTransaction = null; + _ambientTransaction?.Dispose(); + _ambientTransaction = null; } } @@ -236,10 +222,10 @@ private async Task> OpenConnectionWithRetryAsync( System.Data.Common.DbConnection dbConnection, CancellationToken cancellationToken) { - using var activity = SharpFunctionalMsSqlDiagnostics.ActivitySource.StartActivity("sharpfunctional.mssql.connection.open"); + using var activity = SharpFunctionalMsSqlDiagnostics.ActivitySource.StartActivity("sharpfunctional.mssql.dbconnection.open"); SharpFunctionalMsSqlDiagnostics.ApplyActivityEnricher(activity, Options); activity?.SetTag(SharpFunctionalMsSqlDiagnostics.BackendTag, "dapper"); - activity?.SetTag(SharpFunctionalMsSqlDiagnostics.OperationTag, "connection.open"); + activity?.SetTag(SharpFunctionalMsSqlDiagnostics.OperationTag, "dbconnection.open"); activity?.SetTag("db.system", "mssql"); var loggerInstance = Log; @@ -250,10 +236,7 @@ private async Task> OpenConnectionWithRetryAsync( try { await dbConnection.OpenAsync(cancellationToken).ConfigureAwait(false); - if (loggerInstance is not null) - { - FunctionalMsSqlDbLog.OpenedSqlConnection(loggerInstance, attempt + 1); - } + loggerInstance?.OpenedSqlConnection(attempt + 1); activity?.SetTag(SharpFunctionalMsSqlDiagnostics.RetryAttemptTag, attempt + 1); activity?.SetTag(SharpFunctionalMsSqlDiagnostics.SuccessTag, true); @@ -264,10 +247,7 @@ private async Task> OpenConnectionWithRetryAsync( when (attempt < Options.MaxRetryCount && SqlTransientDetector.IsTransient(exception)) { var retryDelay = Options.GetRetryDelay(attempt + 1); - if (loggerInstance is not null) - { - FunctionalMsSqlDbLog.TransientSqlOpenFailure(loggerInstance, attempt + 1, retryDelay.TotalMilliseconds, exception); - } + loggerInstance?.TransientSqlOpenFailure(attempt + 1, retryDelay.TotalMilliseconds, exception); activity?.AddEvent(new ActivityEvent("retry", tags: new ActivityTagsCollection { @@ -279,10 +259,7 @@ private async Task> OpenConnectionWithRetryAsync( } catch (Exception exception) { - if (loggerInstance is not null) - { - FunctionalMsSqlDbLog.SqlConnectionOpenFailed(loggerInstance, attempt + 1, exception); - } + loggerInstance?.SqlConnectionOpenFailed(attempt + 1, exception); activity?.SetTag(SharpFunctionalMsSqlDiagnostics.RetryAttemptTag, attempt + 1); activity?.SetTag(SharpFunctionalMsSqlDiagnostics.SuccessTag, false); diff --git a/tests/SharpFunctional.MSSQL.Tests/CircuitBreakerTests.cs b/tests/SharpFunctional.MSSQL.Tests/CircuitBreakerTests.cs index 926a003..7654c54 100644 --- a/tests/SharpFunctional.MSSQL.Tests/CircuitBreakerTests.cs +++ b/tests/SharpFunctional.MSSQL.Tests/CircuitBreakerTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Time.Testing; using SharpFunctional.MsSql.Common; using SharpFunctional.MsSql.Functional; using Xunit; @@ -124,17 +125,18 @@ public async Task ExecuteAsync_WhenOpen_ShouldRejectImmediately() public async Task State_AfterOpenDurationExpires_ShouldTransitionToHalfOpen() { // Arrange + var fakeTime = new FakeTimeProvider(); var breaker = new CircuitBreaker(new CircuitBreakerOptions { FailureThreshold = 1, OpenDuration = TimeSpan.FromMilliseconds(50) - }); + }, fakeTime); var ct = TestContext.Current.CancellationToken; await breaker.ExecuteAsync(FailOp, ct); Assert.Equal(CircuitState.Open, breaker.State); - // Act — wait for duration to expire - await Task.Delay(100, ct); + // Act — advance time past the open duration + fakeTime.Advance(TimeSpan.FromMilliseconds(100)); // Assert Assert.Equal(CircuitState.HalfOpen, breaker.State); @@ -146,15 +148,16 @@ public async Task State_AfterOpenDurationExpires_ShouldTransitionToHalfOpen() public async Task ExecuteAsync_WhenHalfOpenAndSucceeds_ShouldCloseAfterThreshold() { // Arrange + var fakeTime = new FakeTimeProvider(); var breaker = new CircuitBreaker(new CircuitBreakerOptions { FailureThreshold = 1, OpenDuration = TimeSpan.FromMilliseconds(50), SuccessThresholdInHalfOpen = 2 - }); + }, fakeTime); var ct = TestContext.Current.CancellationToken; await breaker.ExecuteAsync(FailOp, ct); - await Task.Delay(100, ct); + fakeTime.Advance(TimeSpan.FromMilliseconds(100)); Assert.Equal(CircuitState.HalfOpen, breaker.State); // Act — two successes in half-open @@ -170,14 +173,15 @@ public async Task ExecuteAsync_WhenHalfOpenAndSucceeds_ShouldCloseAfterThreshold public async Task ExecuteAsync_WhenHalfOpenAndFails_ShouldReopenImmediately() { // Arrange + var fakeTime = new FakeTimeProvider(); var breaker = new CircuitBreaker(new CircuitBreakerOptions { FailureThreshold = 1, OpenDuration = TimeSpan.FromMilliseconds(50) - }); + }, fakeTime); var ct = TestContext.Current.CancellationToken; await breaker.ExecuteAsync(FailOp, ct); - await Task.Delay(100, ct); + fakeTime.Advance(TimeSpan.FromMilliseconds(100)); Assert.Equal(CircuitState.HalfOpen, breaker.State); // Act — fail in half-open diff --git a/tests/SharpFunctional.MSSQL.Tests/DapperFunctionalDbTests.cs b/tests/SharpFunctional.MSSQL.Tests/DapperFunctionalDbTests.cs index e436376..d5c3fdd 100644 --- a/tests/SharpFunctional.MSSQL.Tests/DapperFunctionalDbTests.cs +++ b/tests/SharpFunctional.MSSQL.Tests/DapperFunctionalDbTests.cs @@ -1,3 +1,4 @@ +using Microsoft.EntityFrameworkCore; using SharpFunctional.MsSql.Functional; using Xunit; @@ -9,7 +10,8 @@ public class DapperFunctionalDbTests public async Task ExecuteStoredProcSingleAsync_WithNullConnection_ShouldReturnNone() { // Arrange - var db = new FunctionalMsSqlDb(connection: null); + using var placeholderDbContext = new DbContext(new DbContextOptionsBuilder().Options); + var db = new FunctionalMsSqlDb(dbContext: placeholderDbContext, dbConnection: null); var dapper = db.Dapper(); // Act @@ -23,7 +25,8 @@ public async Task ExecuteStoredProcSingleAsync_WithNullConnection_ShouldReturnNo public async Task ExecuteStoredProcSingleAsync_WithEmptyProcName_ShouldReturnNone() { // Arrange - var db = new FunctionalMsSqlDb(connection: null); + using var placeholderDbContext = new DbContext(new DbContextOptionsBuilder().Options); + var db = new FunctionalMsSqlDb(dbContext: placeholderDbContext, dbConnection: null); var dapper = db.Dapper(); // Act @@ -37,7 +40,8 @@ public async Task ExecuteStoredProcSingleAsync_WithEmptyProcName_ShouldReturnNon public async Task ExecuteStoredProcAsync_WithNullConnection_ShouldReturnEmptySeq() { // Arrange - var db = new FunctionalMsSqlDb(connection: null); + using var placeholderDbContext = new DbContext(new DbContextOptionsBuilder().Options); + var db = new FunctionalMsSqlDb(dbContext: placeholderDbContext, dbConnection: null); var dapper = db.Dapper(); // Act @@ -51,7 +55,8 @@ public async Task ExecuteStoredProcAsync_WithNullConnection_ShouldReturnEmptySeq public async Task ExecuteStoredProcNonQueryAsync_WithNullConnection_ShouldReturnFail() { // Arrange - var db = new FunctionalMsSqlDb(connection: null); + using var placeholderDbContext = new DbContext(new DbContextOptionsBuilder().Options); + var db = new FunctionalMsSqlDb(dbContext: placeholderDbContext, dbConnection: null); var dapper = db.Dapper(); // Act @@ -65,7 +70,8 @@ public async Task ExecuteStoredProcNonQueryAsync_WithNullConnection_ShouldReturn public async Task ExecuteStoredProcNonQueryAsync_WithEmptyProcName_ShouldReturnFail() { // Arrange - var db = new FunctionalMsSqlDb(connection: null); + using var placeholderDbContext = new DbContext(new DbContextOptionsBuilder().Options); + var db = new FunctionalMsSqlDb(dbContext: placeholderDbContext, dbConnection: null); var dapper = db.Dapper(); // Act @@ -79,7 +85,8 @@ public async Task ExecuteStoredProcNonQueryAsync_WithEmptyProcName_ShouldReturnF public async Task QueryAsync_WithNullConnection_ShouldReturnEmptySeq() { // Arrange - var db = new FunctionalMsSqlDb(connection: null); + using var placeholderDbContext = new DbContext(new DbContextOptionsBuilder().Options); + var db = new FunctionalMsSqlDb(dbContext: placeholderDbContext, dbConnection: null); var dapper = db.Dapper(); // Act @@ -93,7 +100,8 @@ public async Task QueryAsync_WithNullConnection_ShouldReturnEmptySeq() public async Task QuerySingleAsync_WithNullConnection_ShouldReturnNone() { // Arrange - var db = new FunctionalMsSqlDb(connection: null); + using var placeholderDbContext = new DbContext(new DbContextOptionsBuilder().Options); + var db = new FunctionalMsSqlDb(dbContext: placeholderDbContext, dbConnection: null); var dapper = db.Dapper(); // Act @@ -109,7 +117,8 @@ public async Task QuerySingleAsync_WithNullConnection_ShouldReturnNone() public async Task ExecuteStoredProcPaginatedAsync_WithNullConnection_ShouldReturnFail() { // Arrange - var db = new FunctionalMsSqlDb(connection: null); + using var placeholderDbContext = new DbContext(new DbContextOptionsBuilder().Options); + var db = new FunctionalMsSqlDb(dbContext: placeholderDbContext, dbConnection: null); var dapper = db.Dapper(); // Act @@ -123,7 +132,8 @@ public async Task ExecuteStoredProcPaginatedAsync_WithNullConnection_ShouldRetur public async Task ExecuteStoredProcPaginatedAsync_WithEmptyProcName_ShouldReturnFail() { // Arrange - var db = new FunctionalMsSqlDb(connection: null); + using var placeholderDbContext = new DbContext(new DbContextOptionsBuilder().Options); + var db = new FunctionalMsSqlDb(dbContext: placeholderDbContext, dbConnection: null); var dapper = db.Dapper(); // Act diff --git a/tests/SharpFunctional.MSSQL.Tests/FunctionalMsSqlDbTests.cs b/tests/SharpFunctional.MSSQL.Tests/FunctionalMsSqlDbTests.cs index 06db143..18630ed 100644 --- a/tests/SharpFunctional.MSSQL.Tests/FunctionalMsSqlDbTests.cs +++ b/tests/SharpFunctional.MSSQL.Tests/FunctionalMsSqlDbTests.cs @@ -1,6 +1,7 @@ using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using System.Diagnostics.CodeAnalysis; using SharpFunctional.MsSql.Dapper; using SharpFunctional.MsSql.Ef; using SharpFunctional.MsSql.Functional; @@ -41,7 +42,7 @@ public void Ef_ShouldReturnEfFunctionalDb() public void Dapper_ShouldReturnDapperFunctionalDb() { // Arrange - var db = new FunctionalMsSqlDb(connection: _connection); + var db = new FunctionalMsSqlDb(dbConnection: _connection); // Act var dapper = db.Dapper(); @@ -130,7 +131,7 @@ public async Task InTransactionAsync_WithLoggerOnDapperOpenFailure_ShouldWriteGe // Arrange using var loggerFactory = TestLoggerFactory.Create(); var db = new FunctionalMsSqlDb( - connection: new FailingOpenDbConnection(), + dbConnection: new FailingOpenDbConnection(), logger: loggerFactory.Factory.CreateLogger()); // Act @@ -143,7 +144,35 @@ public async Task InTransactionAsync_WithLoggerOnDapperOpenFailure_ShouldWriteGe // Assert Assert.True(result.IsFail); Assert.Contains(loggerFactory.Entries, entry => entry.Level == LogLevel.Debug && entry.Message == "Starting Dapper transaction for result type Int32"); - Assert.Contains(loggerFactory.Entries, entry => entry.Level == LogLevel.Error && entry.Message == "SQL connection open failed after 1 attempt(s)" && entry.Exception is InvalidOperationException); + Assert.Contains(loggerFactory.Entries, entry => entry.Level == LogLevel.Error && entry.Message == "SQL dbConnection open failed after 1 attempt(s)" && entry.Exception is InvalidOperationException); + } + + [Fact] + public async Task InTransactionAsync_WithThrowingDapperAction_ShouldRollbackAndClearAmbientTransaction() + { + // Arrange + var fakeConnection = new RecordingDbConnection(); + var db = new FunctionalMsSqlDb(dbConnection: fakeConnection); + + // Act + var result = await db.InTransactionAsync(_ => throw new InvalidOperationException("boom"), TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.IsFail); + Assert.Equal(1, fakeConnection.Transaction.BeginCount); + Assert.Equal(0, fakeConnection.Transaction.CommitCount); + Assert.Equal(1, fakeConnection.Transaction.RollbackCount); + Assert.Equal(1, fakeConnection.Transaction.DisposeCount); + + // Verify ambient transaction state was cleared by starting a new transaction. + var secondResult = await db.InTransactionAsync(async _ => + { + await Task.CompletedTask; + return Fin.Succ(42); + }, TestContext.Current.CancellationToken); + + Assert.True(secondResult.IsSucc); + Assert.Equal(2, fakeConnection.Transaction.BeginCount); } [Fact] @@ -168,20 +197,13 @@ public async Task InTransactionAsync_WithCanceledToken_ShouldReturnFail() } [Fact] - public async Task InTransactionAsync_WithNoBackend_ShouldReturnFail() + public void Constructor_WithNoBackend_ShouldThrowArgumentException() { // Arrange - var db = new FunctionalMsSqlDb(); - - // Act - var result = await db.InTransactionAsync(async _ => - { - await Task.CompletedTask; - return Fin.Succ(1); - }, TestContext.Current.CancellationToken); + var exception = Assert.Throws(() => new FunctionalMsSqlDb()); // Assert - Assert.True(result.IsFail); + Assert.Contains("Either dbContext or dbConnection must be provided.", exception.Message); } private sealed record TestLogEntry(LogLevel Level, string Message, Exception? Exception); @@ -244,6 +266,7 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except private sealed class FailingOpenDbConnection : System.Data.Common.DbConnection { + [AllowNull] public override string ConnectionString { get; set; } = string.Empty; public override string Database => "Test"; @@ -270,4 +293,74 @@ public override void Close() protected override System.Data.Common.DbCommand CreateDbCommand() => throw new NotSupportedException(); } + + private sealed class RecordingDbConnection : System.Data.Common.DbConnection + { + public RecordingDbConnection() + { + Transaction = new RecordingDbTransaction(this); + } + + public RecordingDbTransaction Transaction { get; } + + [AllowNull] + public override string ConnectionString { get; set; } = string.Empty; + + public override string Database => "Test"; + + public override string DataSource => "Test"; + + public override string ServerVersion => "1.0"; + + public override System.Data.ConnectionState State => System.Data.ConnectionState.Open; + + public override void ChangeDatabase(string databaseName) + { + } + + public override void Close() + { + } + + public override void Open() + { + } + + protected override System.Data.Common.DbTransaction BeginDbTransaction(System.Data.IsolationLevel isolationLevel) + { + Transaction.BeginCount++; + return Transaction; + } + + protected override System.Data.Common.DbCommand CreateDbCommand() => throw new NotSupportedException(); + } + + private sealed class RecordingDbTransaction(RecordingDbConnection connection) : System.Data.Common.DbTransaction + { + public int BeginCount { get; set; } + + public int CommitCount { get; private set; } + + public int RollbackCount { get; private set; } + + public int DisposeCount { get; private set; } + + public override System.Data.IsolationLevel IsolationLevel => System.Data.IsolationLevel.ReadCommitted; + + protected override System.Data.Common.DbConnection DbConnection => connection; + + public override void Commit() => CommitCount++; + + public override void Rollback() => RollbackCount++; + + protected override void Dispose(bool disposing) + { + if (disposing) + { + DisposeCount++; + } + + base.Dispose(disposing); + } + } } diff --git a/tests/SharpFunctional.MSSQL.Tests/OpenTelemetryInstrumentationTests.cs b/tests/SharpFunctional.MSSQL.Tests/OpenTelemetryInstrumentationTests.cs index fa228d1..b7e323f 100644 --- a/tests/SharpFunctional.MSSQL.Tests/OpenTelemetryInstrumentationTests.cs +++ b/tests/SharpFunctional.MSSQL.Tests/OpenTelemetryInstrumentationTests.cs @@ -51,7 +51,7 @@ public async Task QuerySingleAsync_ShouldEmitDapperActivity() // Arrange var activities = new List(); using var listener = CreateListener(activities); - var db = new FunctionalMsSqlDb(connection: _connection); + var db = new FunctionalMsSqlDb(dbConnection: _connection); // Act var result = await db.Dapper().QuerySingleAsync("SELECT 1", new { }, TestContext.Current.CancellationToken); @@ -73,7 +73,7 @@ public async Task QuerySingleAsync_WithActivityEnricher_ShouldAddCustomTag() using var listener = CreateListener(activities); var options = new SqlExecutionOptions( activityEnricher: static activity => activity.SetTag("sharpfunctional.mssql.test.tag", "enriched")); - var db = new FunctionalMsSqlDb(connection: _connection, executionOptions: options); + var db = new FunctionalMsSqlDb(dbConnection: _connection, executionOptions: options); // Act var result = await db.Dapper().QuerySingleAsync("SELECT 1", new { }, TestContext.Current.CancellationToken); @@ -93,7 +93,7 @@ public async Task QuerySingleAsync_WithFailingActivityEnricher_ShouldNotFailOper using var listener = CreateListener(activities); var options = new SqlExecutionOptions( activityEnricher: static _ => throw new InvalidOperationException("enricher failed")); - var db = new FunctionalMsSqlDb(connection: _connection, executionOptions: options); + var db = new FunctionalMsSqlDb(dbConnection: _connection, executionOptions: options); // Act var result = await db.Dapper().QuerySingleAsync("SELECT 1", new { }, TestContext.Current.CancellationToken); diff --git a/tests/SharpFunctional.MSSQL.Tests/SharpFunctional.MSSQL.Tests.csproj b/tests/SharpFunctional.MSSQL.Tests/SharpFunctional.MSSQL.Tests.csproj index 6ede82d..bba3cda 100644 --- a/tests/SharpFunctional.MSSQL.Tests/SharpFunctional.MSSQL.Tests.csproj +++ b/tests/SharpFunctional.MSSQL.Tests/SharpFunctional.MSSQL.Tests.csproj @@ -10,6 +10,7 @@ +