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 @@ + + + + net8.0 + enable + enable + + + 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 where TConnectionType : ConnectionType +{ + public Task> Query(string sql, object? param = null, CancellationToken cancellationToken = default); + public IAsyncEnumerable QueryUnbuffered(string sql, object? param = null, CancellationToken cancellationToken = default); + public Task QuerySingle(string sql, object? param = null, CancellationToken cancellationToken = default); + public Task Execute(string sql, object? param = null, CancellationToken cancellationToken = default); + public Task ExecuteScalar(string sql, object? param = null, CancellationToken cancellationToken = default); + public Task 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 : IDatabaseConnection, 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 CreateConnection(); + public INonTransactionalDbConnection 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 : IDatabaseConnection, IDisposable, IAsyncDisposable + where TConnectionType : ConnectionType +{ + public Task> CreateTransaction(); + public Task> CreateTransaction(IsolationLevel isolationLevel); + public Task> 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 CreateConnection() + { + if (string.IsNullOrWhiteSpace(_readWriteConnectionString)) + throw new ApplicationException("ReadWrite connection string was not found or empty."); + + var connection = new NonTransactionalDbConnection(_readWriteConnectionString); + return connection; + } + + public INonTransactionalDbConnection CreateReadOnlyConnection() + { + if (string.IsNullOrWhiteSpace(_readOnlyConnectionString)) + throw new ApplicationException("ReadOnly connection string was not found or empty."); + + var connection = new NonTransactionalDbConnection(_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 : IDatabaseTransactionConnection + where TConnectionType : ConnectionType +{ + private readonly SqliteTransaction _transaction; + + internal DbTransaction(SqliteTransaction transaction) + { + _transaction = transaction; + } + + /// + /// + public async Task> Query( + 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(command); + } + + /// + public async IAsyncEnumerable QueryUnbuffered( + 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(sql, param, transaction: _transaction) + .WithCancellation(cancellationToken); + + await foreach (var item in query) + yield return item; + } + + /// + public async Task QuerySingle(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(command); + } + + /// + 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); + } + + /// + public async Task ExecuteScalar(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(command); + } + + /// + public async Task 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); + } + + /// + public async Task Commit(CancellationToken cancellationToken) + { + await _transaction.CommitAsync(cancellationToken); + } + + /// + public async Task Rollback(CancellationToken cancellationToken) + { + await _transaction.RollbackAsync(cancellationToken); + } + + /// + public void Dispose() + { + _transaction.Dispose(); + GC.SuppressFinalize(this); + } + + /// + 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 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + 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 : SqliteConnection, INonTransactionalDbConnection + where TConnectionType : ConnectionType +{ + /// + /// C'tor + /// + public NonTransactionalDbConnection(string connectionString) : base(connectionString) + { + } + + /// + public async Task> Query( + string sql, + object? param = null, + CancellationToken cancellationToken = default + ) => await this.QueryAsync(new CommandDefinition(sql, param, cancellationToken: cancellationToken)); + + /// + public async IAsyncEnumerable QueryUnbuffered( + string sql, + object? param = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default + ) + { + var query = this.QueryUnbufferedAsync(sql, param, transaction: null) + .WithCancellation(cancellationToken); + + await foreach (var item in query) + yield return item; + } + + /// + public async Task QuerySingle( + string sql, + object? param = null, + CancellationToken cancellationToken = default + ) => await this.QuerySingleAsync(new CommandDefinition(sql, param, cancellationToken: cancellationToken)); + + /// + public async Task Execute( + string sql, + object? param = null, + CancellationToken cancellationToken = default + ) => await this.ExecuteAsync(new CommandDefinition(sql, param, cancellationToken: cancellationToken)); + + /// + public async Task ExecuteScalar( + string sql, + object? param = null, + CancellationToken cancellationToken = default + ) => await this.ExecuteScalarAsync(new CommandDefinition(sql, param, cancellationToken: cancellationToken)); + + /// + public async Task ExecuteReader( + string sql, + object? param = null, + CancellationToken cancellationToken = default + ) => await this.ExecuteReaderAsync(new CommandDefinition(sql, param, cancellationToken: cancellationToken)); + + /// + public async Task> CreateTransaction() + { + if (!State.HasFlag(ConnectionState.Open)) + await OpenAsync(); + + var tx = BeginTransaction(); + + return new DbTransaction(tx); + } + + /// + public async Task> CreateTransaction(IsolationLevel isolationLevel) + { + if (!State.HasFlag(ConnectionState.Open)) + await OpenAsync(); + + var tx = BeginTransaction(isolationLevel); + + return new DbTransaction(tx); + } + + /// + public async Task> CreateTransaction(IsolationLevel isolationLevel, bool deferred) + { + if (!State.HasFlag(ConnectionState.Open)) + await OpenAsync(); + + var tx = BeginTransaction(isolationLevel, deferred); + + return new DbTransaction(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(); + 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 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