| | | 1 | | using System.Data.Common; |
| | | 2 | | using ArturRios.Data.Relational.Core.Configuration; |
| | | 3 | | using ArturRios.Data.Relational.Core.Repositories; |
| | | 4 | | using ArturRios.Output; |
| | | 5 | | using Dapper; |
| | | 6 | | using Microsoft.EntityFrameworkCore; |
| | | 7 | | using Microsoft.EntityFrameworkCore.Storage; |
| | | 8 | | using Microsoft.Extensions.Logging; |
| | | 9 | | |
| | | 10 | | namespace ArturRios.Data.Dapper; |
| | | 11 | | |
| | | 12 | | /// <summary> |
| | | 13 | | /// Dapper-backed read-only query executor. Runs against the <see cref="BaseDbContext" />'s |
| | | 14 | | /// connection and enlists in its ambient transaction, so Dapper reads and EF writes share one |
| | | 15 | | /// connection and one unit-of-work transaction. Failures are returned as <see cref="DataOutput{T}" />. |
| | | 16 | | /// </summary> |
| | | 17 | | /// <param name="context">The application's <see cref="BaseDbContext" />.</param> |
| | | 18 | | /// <param name="logger"> |
| | | 19 | | /// Optional logger. Envelopes never carry provider text, so a query failure is otherwise |
| | | 20 | | /// undiagnosable: supply a logger and the full exception, plus the SQL that produced it, is |
| | | 21 | | /// written at <see cref="LogLevel.Error" />. Parameter values are never logged - they are the |
| | | 22 | | /// part most likely to hold personal data. Resolved from DI when logging is registered. |
| | | 23 | | /// </param> |
| | 42 | 24 | | public class DapperSqlQuery(BaseDbContext context, ILogger<DapperSqlQuery>? logger = null) |
| | | 25 | | : ISqlQuery, IAsyncSqlQuery |
| | | 26 | | { |
| | | 27 | | /// <summary>Message returned when a query fails with no finer classification.</summary> |
| | | 28 | | protected const string QueryFailedMessage = RelationalErrors.GenericMessage; |
| | | 29 | | |
| | | 30 | | /// <summary>The context's underlying database connection.</summary> |
| | 42 | 31 | | protected DbConnection Connection => context.Database.GetDbConnection(); |
| | | 32 | | |
| | | 33 | | /// <summary>The ambient database transaction, or <see langword="null" /> when none is active.</summary> |
| | 42 | 34 | | protected DbTransaction? Transaction => context.Database.CurrentTransaction?.GetDbTransaction(); |
| | | 35 | | |
| | | 36 | | /// <inheritdoc /> |
| | | 37 | | public Task<DataOutput<IEnumerable<T>>> QueryAsync<T>(string sql, object? parameters = null, |
| | | 38 | | CancellationToken ct = default) => |
| | 16 | 39 | | GuardedAsync(sql, async () => await Connection.QueryAsync<T>(Command(sql, parameters, ct)).ConfigureAwait(false) |
| | | 40 | | |
| | | 41 | | /// <inheritdoc /> |
| | | 42 | | public Task<DataOutput<T?>> QueryFirstOrDefaultAsync<T>(string sql, object? parameters = null, |
| | | 43 | | CancellationToken ct = default) => |
| | 4 | 44 | | GuardedAsync(sql, async () => await Connection.QueryFirstOrDefaultAsync<T?>(Command(sql, parameters, ct)).Config |
| | | 45 | | |
| | | 46 | | /// <inheritdoc /> |
| | | 47 | | public Task<DataOutput<T?>> QuerySingleOrDefaultAsync<T>(string sql, object? parameters = null, |
| | | 48 | | CancellationToken ct = default) => |
| | 4 | 49 | | GuardedAsync(sql, async () => await Connection.QuerySingleOrDefaultAsync<T?>(Command(sql, parameters, ct)).Confi |
| | | 50 | | |
| | | 51 | | /// <inheritdoc /> |
| | | 52 | | public Task<DataOutput<T?>> ExecuteScalarAsync<T>(string sql, object? parameters = null, |
| | | 53 | | CancellationToken ct = default) => |
| | 4 | 54 | | GuardedAsync(sql, async () => await Connection.ExecuteScalarAsync<T?>(Command(sql, parameters, ct)).ConfigureAwa |
| | | 55 | | |
| | | 56 | | /// <inheritdoc /> |
| | | 57 | | public DataOutput<IEnumerable<T>> Query<T>(string sql, object? parameters = null) => |
| | 28 | 58 | | Guarded(sql, () => Connection.Query<T>(sql, parameters, Transaction)); |
| | | 59 | | |
| | | 60 | | /// <inheritdoc /> |
| | | 61 | | public DataOutput<T?> QueryFirstOrDefault<T>(string sql, object? parameters = null) => |
| | 8 | 62 | | Guarded(sql, () => Connection.QueryFirstOrDefault<T?>(sql, parameters, Transaction)); |
| | | 63 | | |
| | | 64 | | /// <inheritdoc /> |
| | | 65 | | public DataOutput<T?> QuerySingleOrDefault<T>(string sql, object? parameters = null) => |
| | 4 | 66 | | Guarded(sql, () => Connection.QuerySingleOrDefault<T?>(sql, parameters, Transaction)); |
| | | 67 | | |
| | | 68 | | /// <inheritdoc /> |
| | | 69 | | public DataOutput<T?> ExecuteScalar<T>(string sql, object? parameters = null) => |
| | 16 | 70 | | Guarded(sql, () => Connection.ExecuteScalar<T?>(sql, parameters, Transaction)); |
| | | 71 | | |
| | | 72 | | /// <summary>Runs a synchronous query, converting failures to envelope errors.</summary> |
| | | 73 | | /// <param name="sql">The SQL being run, used as log context when a logger is configured.</param> |
| | | 74 | | /// <param name="operation">The query to run.</param> |
| | | 75 | | protected DataOutput<TResult> Guarded<TResult>(string sql, Func<TResult> operation) |
| | | 76 | | { |
| | | 77 | | try |
| | | 78 | | { |
| | 28 | 79 | | return DataOutput<TResult>.New.WithData(operation()); |
| | | 80 | | } |
| | 0 | 81 | | catch (OperationCanceledException) |
| | | 82 | | { |
| | 0 | 83 | | throw; |
| | | 84 | | } |
| | 14 | 85 | | catch (Exception ex) |
| | | 86 | | { |
| | 14 | 87 | | return Fail<TResult>(ex, sql); |
| | | 88 | | } |
| | 28 | 89 | | } |
| | | 90 | | |
| | | 91 | | /// <summary>Builds a Dapper command carrying the ambient transaction and cancellation token.</summary> |
| | | 92 | | private CommandDefinition Command(string sql, object? parameters, CancellationToken ct) => |
| | 14 | 93 | | new(sql, parameters, Transaction, cancellationToken: ct); |
| | | 94 | | |
| | | 95 | | /// <summary>Runs an asynchronous query, converting failures to envelope errors.</summary> |
| | | 96 | | /// <param name="sql">The SQL being run, used as log context when a logger is configured.</param> |
| | | 97 | | /// <param name="operation">The query to run.</param> |
| | | 98 | | protected async Task<DataOutput<TResult>> GuardedAsync<TResult>(string sql, Func<Task<TResult>> operation) |
| | | 99 | | { |
| | | 100 | | try |
| | | 101 | | { |
| | 14 | 102 | | return DataOutput<TResult>.New.WithData(await operation().ConfigureAwait(false)); |
| | | 103 | | } |
| | 0 | 104 | | catch (OperationCanceledException) |
| | | 105 | | { |
| | 0 | 106 | | throw; |
| | | 107 | | } |
| | 4 | 108 | | catch (Exception ex) |
| | | 109 | | { |
| | 4 | 110 | | return Fail<TResult>(ex, sql); |
| | | 111 | | } |
| | 14 | 112 | | } |
| | | 113 | | |
| | | 114 | | /// <summary> |
| | | 115 | | /// Logs the failure in full when a logger is configured, and returns the caller-safe |
| | | 116 | | /// envelope. Provider text names constraints, columns and SQL fragments, so it goes to |
| | | 117 | | /// the log and never to the caller. |
| | | 118 | | /// </summary> |
| | | 119 | | private DataOutput<TResult> Fail<TResult>(Exception ex, string sql) |
| | | 120 | | { |
| | 18 | 121 | | logger?.LogError(ex, "Dapper query failed. SQL: {Sql}", sql); |
| | | 122 | | |
| | 18 | 123 | | return DataOutput<TResult>.New.WithError(RelationalErrors.Describe(ex)); |
| | | 124 | | } |
| | | 125 | | } |