From de8e30dbb41d8d70b685324847b07f7800e31488 Mon Sep 17 00:00:00 2001 From: Kyle Ratti <kyleratti@users.noreply.github.com> Date: Sun, 23 Jun 2024 18:15:32 -0400 Subject: [PATCH] feat: initial port of data access --- .../ConnectionType.cs | 7 + ...yFoundation.DataAccess.Abstractions.csproj | 9 ++ .../IDatabaseConnection.cs | 14 ++ .../IDatabaseTransactionConnection.cs | 8 ++ .../IDbConnectionFactory.cs | 7 + .../INonTransactionalDbConnection.cs | 11 ++ .../DbConnectionFactory.cs | 33 +++++ .../DbTransaction.cs | 125 ++++++++++++++++++ .../FruityFoundation.DataAccess.Sqlite.csproj | 19 +++ .../NonTransactionalDbConnection.cs | 102 ++++++++++++++ .../ServiceCollectionExtensions.cs | 13 ++ .../SqliteConnectionExtensions.cs | 13 ++ FruityFoundation.sln | 6 + 13 files changed, 367 insertions(+) create mode 100644 FruityFoundation.DataAccess.Abstractions/ConnectionType.cs create mode 100644 FruityFoundation.DataAccess.Abstractions/FruityFoundation.DataAccess.Abstractions.csproj create mode 100644 FruityFoundation.DataAccess.Abstractions/IDatabaseConnection.cs create mode 100644 FruityFoundation.DataAccess.Abstractions/IDatabaseTransactionConnection.cs create mode 100644 FruityFoundation.DataAccess.Abstractions/IDbConnectionFactory.cs create mode 100644 FruityFoundation.DataAccess.Abstractions/INonTransactionalDbConnection.cs create mode 100644 FruityFoundation.DataAccess.Sqlite/DbConnectionFactory.cs create mode 100644 FruityFoundation.DataAccess.Sqlite/DbTransaction.cs create mode 100644 FruityFoundation.DataAccess.Sqlite/FruityFoundation.DataAccess.Sqlite.csproj create mode 100644 FruityFoundation.DataAccess.Sqlite/NonTransactionalDbConnection.cs create mode 100644 FruityFoundation.DataAccess.Sqlite/ServiceCollectionExtensions.cs create mode 100644 FruityFoundation.DataAccess.Sqlite/SqliteConnectionExtensions.cs diff --git a/FruityFoundation.DataAccess.Abstractions/ConnectionType.cs b/FruityFoundation.DataAccess.Abstractions/ConnectionType.cs new file mode 100644 index 0000000..fd0ccdd --- /dev/null +++ b/FruityFoundation.DataAccess.Abstractions/ConnectionType.cs @@ -0,0 +1,7 @@ +namespace FruityFoundation.DataAccess.Abstractions; + +public abstract class ConnectionType; + +public abstract class ReadOnly : ConnectionType; + +public abstract class ReadWrite : ReadOnly; diff --git a/FruityFoundation.DataAccess.Abstractions/FruityFoundation.DataAccess.Abstractions.csproj b/FruityFoundation.DataAccess.Abstractions/FruityFoundation.DataAccess.Abstractions.csproj new file mode 100644 index 0000000..3a63532 --- /dev/null +++ b/FruityFoundation.DataAccess.Abstractions/FruityFoundation.DataAccess.Abstractions.csproj @@ -0,0 +1,9 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + </PropertyGroup> + +</Project> diff --git a/FruityFoundation.DataAccess.Abstractions/IDatabaseConnection.cs b/FruityFoundation.DataAccess.Abstractions/IDatabaseConnection.cs new file mode 100644 index 0000000..e60597a --- /dev/null +++ b/FruityFoundation.DataAccess.Abstractions/IDatabaseConnection.cs @@ -0,0 +1,14 @@ +using System.Data.Common; + +namespace FruityFoundation.DataAccess.Abstractions; + +// ReSharper disable once UnusedTypeParameter +public interface IDatabaseConnection<out TConnectionType> where TConnectionType : ConnectionType +{ + public Task<IEnumerable<T>> Query<T>(string sql, object? param = null, CancellationToken cancellationToken = default); + public IAsyncEnumerable<T> QueryUnbuffered<T>(string sql, object? param = null, CancellationToken cancellationToken = default); + public Task<T> QuerySingle<T>(string sql, object? param = null, CancellationToken cancellationToken = default); + public Task Execute(string sql, object? param = null, CancellationToken cancellationToken = default); + public Task<T?> ExecuteScalar<T>(string sql, object? param = null, CancellationToken cancellationToken = default); + public Task<DbDataReader> ExecuteReader(string sql, object? param = null, CancellationToken cancellationToken = default); +} diff --git a/FruityFoundation.DataAccess.Abstractions/IDatabaseTransactionConnection.cs b/FruityFoundation.DataAccess.Abstractions/IDatabaseTransactionConnection.cs new file mode 100644 index 0000000..074e308 --- /dev/null +++ b/FruityFoundation.DataAccess.Abstractions/IDatabaseTransactionConnection.cs @@ -0,0 +1,8 @@ +namespace FruityFoundation.DataAccess.Abstractions; + +public interface IDatabaseTransactionConnection<out TConnectionType> : IDatabaseConnection<TConnectionType>, IDisposable, IAsyncDisposable + where TConnectionType : ConnectionType +{ + public Task Commit(CancellationToken cancellationToken); + public Task Rollback(CancellationToken cancellationToken); +} diff --git a/FruityFoundation.DataAccess.Abstractions/IDbConnectionFactory.cs b/FruityFoundation.DataAccess.Abstractions/IDbConnectionFactory.cs new file mode 100644 index 0000000..18735bb --- /dev/null +++ b/FruityFoundation.DataAccess.Abstractions/IDbConnectionFactory.cs @@ -0,0 +1,7 @@ +namespace FruityFoundation.DataAccess.Abstractions; + +public interface IDbConnectionFactory +{ + public INonTransactionalDbConnection<ReadWrite> CreateConnection(); + public INonTransactionalDbConnection<ReadOnly> CreateReadOnlyConnection(); +} diff --git a/FruityFoundation.DataAccess.Abstractions/INonTransactionalDbConnection.cs b/FruityFoundation.DataAccess.Abstractions/INonTransactionalDbConnection.cs new file mode 100644 index 0000000..9dcef19 --- /dev/null +++ b/FruityFoundation.DataAccess.Abstractions/INonTransactionalDbConnection.cs @@ -0,0 +1,11 @@ +using System.Data; + +namespace FruityFoundation.DataAccess.Abstractions; + +public interface INonTransactionalDbConnection<TConnectionType> : IDatabaseConnection<TConnectionType>, IDisposable, IAsyncDisposable + where TConnectionType : ConnectionType +{ + public Task<IDatabaseTransactionConnection<TConnectionType>> CreateTransaction(); + public Task<IDatabaseTransactionConnection<TConnectionType>> CreateTransaction(IsolationLevel isolationLevel); + public Task<IDatabaseTransactionConnection<TConnectionType>> CreateTransaction(IsolationLevel isolationLevel, bool deferred); +} diff --git a/FruityFoundation.DataAccess.Sqlite/DbConnectionFactory.cs b/FruityFoundation.DataAccess.Sqlite/DbConnectionFactory.cs new file mode 100644 index 0000000..7760c4c --- /dev/null +++ b/FruityFoundation.DataAccess.Sqlite/DbConnectionFactory.cs @@ -0,0 +1,33 @@ +using FruityFoundation.DataAccess.Abstractions; + +namespace FruityFoundation.DataAccess.Sqlite; + +public class DbConnectionFactory : IDbConnectionFactory +{ + private readonly string _readWriteConnectionString; + private readonly string _readOnlyConnectionString; + + public DbConnectionFactory(string readWriteConnectionString, string readOnlyConnectionString) + { + _readWriteConnectionString = readWriteConnectionString; + _readOnlyConnectionString = readOnlyConnectionString; + } + + public INonTransactionalDbConnection<ReadWrite> CreateConnection() + { + if (string.IsNullOrWhiteSpace(_readWriteConnectionString)) + throw new ApplicationException("ReadWrite connection string was not found or empty."); + + var connection = new NonTransactionalDbConnection<ReadWrite>(_readWriteConnectionString); + return connection; + } + + public INonTransactionalDbConnection<ReadOnly> CreateReadOnlyConnection() + { + if (string.IsNullOrWhiteSpace(_readOnlyConnectionString)) + throw new ApplicationException("ReadOnly connection string was not found or empty."); + + var connection = new NonTransactionalDbConnection<ReadOnly>(_readOnlyConnectionString); + return connection; + } +} diff --git a/FruityFoundation.DataAccess.Sqlite/DbTransaction.cs b/FruityFoundation.DataAccess.Sqlite/DbTransaction.cs new file mode 100644 index 0000000..609e4af --- /dev/null +++ b/FruityFoundation.DataAccess.Sqlite/DbTransaction.cs @@ -0,0 +1,125 @@ +using System.Data.Common; +using System.Runtime.CompilerServices; +using Dapper; +using FruityFoundation.DataAccess.Abstractions; +using Microsoft.Data.Sqlite; + +namespace FruityFoundation.DataAccess.Sqlite; + +public class DbTransaction<TConnectionType> : IDatabaseTransactionConnection<TConnectionType> + where TConnectionType : ConnectionType +{ + private readonly SqliteTransaction _transaction; + + internal DbTransaction(SqliteTransaction transaction) + { + _transaction = transaction; + } + + /// <exception cref="ArgumentNullException"></exception> + /// <inheritdoc /> + public async Task<IEnumerable<T>> Query<T>( + string sql, + object? param = null, + CancellationToken cancellationToken = default + ) + { + if (_transaction.Connection is not { } conn) + throw new InvalidOperationException("Transaction connection cannot be null"); + + var command = new CommandDefinition(sql, param, transaction: _transaction, cancellationToken: cancellationToken); + + return await conn.QueryAsync<T>(command); + } + + /// <inheritdoc /> + public async IAsyncEnumerable<T> QueryUnbuffered<T>( + string sql, + object? param = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default + ) + { + if (_transaction.Connection is not { } conn) + throw new InvalidOperationException("Transaction connection cannot be null"); + + var query = conn.QueryUnbufferedAsync<T>(sql, param, transaction: _transaction) + .WithCancellation(cancellationToken); + + await foreach (var item in query) + yield return item; + } + + /// <inheritdoc /> + public async Task<T> QuerySingle<T>(string sql, object? param = null, CancellationToken cancellationToken = default) + { + if (_transaction.Connection is not { } conn) + throw new InvalidOperationException("Transaction connection cannot be null"); + + var command = new CommandDefinition(sql, param, transaction: _transaction, cancellationToken: cancellationToken); + + return await conn.QuerySingleAsync<T>(command); + } + + /// <inheritdoc /> + public async Task Execute(string sql, object? param = null, CancellationToken cancellationToken = default) + { + if (_transaction.Connection is not { } conn) + throw new InvalidOperationException("Transaction connection cannot be null"); + + var command = new CommandDefinition(sql, param, transaction: _transaction, cancellationToken: cancellationToken); + + await conn.ExecuteAsync(command); + } + + /// <inheritdoc /> + public async Task<T?> ExecuteScalar<T>(string sql, object? param = null, CancellationToken cancellationToken = default) + { + if (_transaction.Connection is not { } conn) + throw new InvalidOperationException("Transaction connection cannot be null"); + + var command = new CommandDefinition(sql, param, transaction: _transaction, cancellationToken: cancellationToken); + + return await conn.ExecuteScalarAsync<T>(command); + } + + /// <inheritdoc /> + public async Task<DbDataReader> ExecuteReader( + string sql, + object? param = null, + CancellationToken cancellationToken = default + ) + { + if (_transaction.Connection is not { } conn) + throw new InvalidOperationException("Transaction connection cannot be null"); + + var command = new CommandDefinition(sql, param, transaction: _transaction, cancellationToken: cancellationToken); + + return await conn.ExecuteReaderAsync(command); + } + + /// <inheritdoc /> + public async Task Commit(CancellationToken cancellationToken) + { + await _transaction.CommitAsync(cancellationToken); + } + + /// <inheritdoc /> + public async Task Rollback(CancellationToken cancellationToken) + { + await _transaction.RollbackAsync(cancellationToken); + } + + /// <inheritdoc /> + public void Dispose() + { + _transaction.Dispose(); + GC.SuppressFinalize(this); + } + + /// <inheritdoc /> + public async ValueTask DisposeAsync() + { + await _transaction.DisposeAsync(); + GC.SuppressFinalize(this); + } +} diff --git a/FruityFoundation.DataAccess.Sqlite/FruityFoundation.DataAccess.Sqlite.csproj b/FruityFoundation.DataAccess.Sqlite/FruityFoundation.DataAccess.Sqlite.csproj new file mode 100644 index 0000000..4850e21 --- /dev/null +++ b/FruityFoundation.DataAccess.Sqlite/FruityFoundation.DataAccess.Sqlite.csproj @@ -0,0 +1,19 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\FruityFoundation.DataAccess.Abstractions\FruityFoundation.DataAccess.Abstractions.csproj" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="Dapper" Version="2.1.35" /> + <PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.6" /> + <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" /> + </ItemGroup> + +</Project> diff --git a/FruityFoundation.DataAccess.Sqlite/NonTransactionalDbConnection.cs b/FruityFoundation.DataAccess.Sqlite/NonTransactionalDbConnection.cs new file mode 100644 index 0000000..2caf299 --- /dev/null +++ b/FruityFoundation.DataAccess.Sqlite/NonTransactionalDbConnection.cs @@ -0,0 +1,102 @@ +using System.Data; +using System.Data.Common; +using System.Runtime.CompilerServices; +using Dapper; +using FruityFoundation.DataAccess.Abstractions; +using Microsoft.Data.Sqlite; + +namespace FruityFoundation.DataAccess.Sqlite; + +// ReSharper disable once UnusedTypeParameter +public class NonTransactionalDbConnection<TConnectionType> : SqliteConnection, INonTransactionalDbConnection<TConnectionType> + where TConnectionType : ConnectionType +{ + /// <summary> + /// C'tor + /// </summary> + public NonTransactionalDbConnection(string connectionString) : base(connectionString) + { + } + + /// <inheritdoc /> + public async Task<IEnumerable<T>> Query<T>( + string sql, + object? param = null, + CancellationToken cancellationToken = default + ) => await this.QueryAsync<T>(new CommandDefinition(sql, param, cancellationToken: cancellationToken)); + + /// <inheritdoc /> + public async IAsyncEnumerable<T> QueryUnbuffered<T>( + string sql, + object? param = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default + ) + { + var query = this.QueryUnbufferedAsync<T>(sql, param, transaction: null) + .WithCancellation(cancellationToken); + + await foreach (var item in query) + yield return item; + } + + /// <inheritdoc /> + public async Task<T> QuerySingle<T>( + string sql, + object? param = null, + CancellationToken cancellationToken = default + ) => await this.QuerySingleAsync<T>(new CommandDefinition(sql, param, cancellationToken: cancellationToken)); + + /// <inheritdoc /> + public async Task Execute( + string sql, + object? param = null, + CancellationToken cancellationToken = default + ) => await this.ExecuteAsync(new CommandDefinition(sql, param, cancellationToken: cancellationToken)); + + /// <inheritdoc /> + public async Task<T?> ExecuteScalar<T>( + string sql, + object? param = null, + CancellationToken cancellationToken = default + ) => await this.ExecuteScalarAsync<T>(new CommandDefinition(sql, param, cancellationToken: cancellationToken)); + + /// <inheritdoc /> + public async Task<DbDataReader> ExecuteReader( + string sql, + object? param = null, + CancellationToken cancellationToken = default + ) => await this.ExecuteReaderAsync(new CommandDefinition(sql, param, cancellationToken: cancellationToken)); + + /// <inheritdoc /> + public async Task<IDatabaseTransactionConnection<TConnectionType>> CreateTransaction() + { + if (!State.HasFlag(ConnectionState.Open)) + await OpenAsync(); + + var tx = BeginTransaction(); + + return new DbTransaction<TConnectionType>(tx); + } + + /// <inheritdoc /> + public async Task<IDatabaseTransactionConnection<TConnectionType>> CreateTransaction(IsolationLevel isolationLevel) + { + if (!State.HasFlag(ConnectionState.Open)) + await OpenAsync(); + + var tx = BeginTransaction(isolationLevel); + + return new DbTransaction<TConnectionType>(tx); + } + + /// <inheritdoc /> + public async Task<IDatabaseTransactionConnection<TConnectionType>> CreateTransaction(IsolationLevel isolationLevel, bool deferred) + { + if (!State.HasFlag(ConnectionState.Open)) + await OpenAsync(); + + var tx = BeginTransaction(isolationLevel, deferred); + + return new DbTransaction<TConnectionType>(tx); + } +} diff --git a/FruityFoundation.DataAccess.Sqlite/ServiceCollectionExtensions.cs b/FruityFoundation.DataAccess.Sqlite/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..adc5e38 --- /dev/null +++ b/FruityFoundation.DataAccess.Sqlite/ServiceCollectionExtensions.cs @@ -0,0 +1,13 @@ +using FruityFoundation.DataAccess.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace FruityFoundation.DataAccess.Sqlite; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddSqliteConnectionFactory(this IServiceCollection services) + { + services.AddSingleton<IDbConnectionFactory, DbConnectionFactory>(); + return services; + } +} diff --git a/FruityFoundation.DataAccess.Sqlite/SqliteConnectionExtensions.cs b/FruityFoundation.DataAccess.Sqlite/SqliteConnectionExtensions.cs new file mode 100644 index 0000000..c6bb5b3 --- /dev/null +++ b/FruityFoundation.DataAccess.Sqlite/SqliteConnectionExtensions.cs @@ -0,0 +1,13 @@ +using System.Data; +using Microsoft.Data.Sqlite; + +namespace FruityFoundation.DataAccess.Sqlite; + +public static class SqliteConnectionExtensions +{ + public static async Task<SqliteTransaction> CreateTransaction(this SqliteConnection connection, IsolationLevel isolationLevel) + { + await connection.OpenAsync(); + return connection.BeginTransaction(isolationLevel); + } +} diff --git a/FruityFoundation.sln b/FruityFoundation.sln index db789da..9083a66 100644 --- a/FruityFoundation.sln +++ b/FruityFoundation.sln @@ -14,6 +14,12 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FruityFoundation.Tests.FsBa EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FruityFoundation.Tests.FsBaseInterop", "FruityFoundation.Tests.FsBaseInterop\FruityFoundation.Tests.FsBaseInterop.csproj", "{A64E73D3-EF87-4938-B01E-F9CC0B59F9DE}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DataAccess", "DataAccess", "{5C3A014A-7931-4A36-95F0-5EFE15AB06A3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FruityFoundation.DataAccess.Abstractions", "FruityFoundation.DataAccess.Abstractions\FruityFoundation.DataAccess.Abstractions.csproj", "{C003E247-C62E-4830-94E4-F274D8466A5C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FruityFoundation.DataAccess.Sqlite", "FruityFoundation.DataAccess.Sqlite\FruityFoundation.DataAccess.Sqlite.csproj", "{BB25E92F-5D51-487A-8937-27E28EF5E20F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU