feat: initial port of data access

This commit is contained in:
Kyle Ratti 2024-06-23 18:15:32 -04:00
parent 720340f0dd
commit de8e30dbb4
No known key found for this signature in database
GPG Key ID: 4D429B6287C68DD9
13 changed files with 367 additions and 0 deletions

View File

@ -0,0 +1,7 @@
namespace FruityFoundation.DataAccess.Abstractions;
public abstract class ConnectionType;
public abstract class ReadOnly : ConnectionType;
public abstract class ReadWrite : ReadOnly;

View File

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@ -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);
}

View File

@ -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);
}

View File

@ -0,0 +1,7 @@
namespace FruityFoundation.DataAccess.Abstractions;
public interface IDbConnectionFactory
{
public INonTransactionalDbConnection<ReadWrite> CreateConnection();
public INonTransactionalDbConnection<ReadOnly> CreateReadOnlyConnection();
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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>

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -14,6 +14,12 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FruityFoundation.Tests.FsBa
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FruityFoundation.Tests.FsBaseInterop", "FruityFoundation.Tests.FsBaseInterop\FruityFoundation.Tests.FsBaseInterop.csproj", "{A64E73D3-EF87-4938-B01E-F9CC0B59F9DE}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FruityFoundation.Tests.FsBaseInterop", "FruityFoundation.Tests.FsBaseInterop\FruityFoundation.Tests.FsBaseInterop.csproj", "{A64E73D3-EF87-4938-B01E-F9CC0B59F9DE}"
EndProject 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU