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