Refactor repository and SQL procedures; add repo tests

This commit is contained in:
Aaron Po
2026-01-25 23:26:40 -05:00
parent a56ea77861
commit 68ff549635
28 changed files with 573 additions and 430 deletions

View File

@@ -1,12 +1,12 @@
using System.Data.Common;
using DataAccessLayer.Sql;
using Microsoft.Data.SqlClient;
namespace DataAccessLayer.Repositories
{
public abstract class Repository<T>(ISqlConnectionFactory connectionFactory)
where T : class
{
protected async Task<SqlConnection> CreateConnection()
protected async Task<DbConnection> CreateConnection()
{
var connection = connectionFactory.CreateConnection();
await connection.OpenAsync();
@@ -19,6 +19,6 @@ namespace DataAccessLayer.Repositories
public abstract Task UpdateAsync(T entity);
public abstract Task DeleteAsync(Guid id);
protected abstract T MapToEntity(SqlDataReader reader);
protected abstract T MapToEntity(DbDataReader reader);
}
}

View File

@@ -1,24 +1,28 @@
using System.Data;
using System.Data.Common;
using DataAccessLayer.Sql;
using Microsoft.Data.SqlClient;
namespace DataAccessLayer.Repositories.UserAccount
{
public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
: Repository<Entities.UserAccount>(connectionFactory), IUserAccountRepository
{
/**
* @todo update the create user account stored proc to add user credential creation in
* a single transaction, use that transaction instead.
*/
public override async Task AddAsync(Entities.UserAccount userAccount)
{
await using var connection = await CreateConnection();
await using var command = new SqlCommand("usp_CreateUserAccount", connection);
await using var command = connection.CreateCommand();
command.CommandText = "usp_CreateUserAccount";
command.CommandType = CommandType.StoredProcedure;
command.Parameters.Add("@UserAccountId", SqlDbType.UniqueIdentifier).Value = userAccount.UserAccountId;
command.Parameters.Add("@Username", SqlDbType.NVarChar, 100).Value = userAccount.Username;
command.Parameters.Add("@FirstName", SqlDbType.NVarChar, 100).Value = userAccount.FirstName;
command.Parameters.Add("@LastName", SqlDbType.NVarChar, 100).Value = userAccount.LastName;
command.Parameters.Add("@Email", SqlDbType.NVarChar, 256).Value = userAccount.Email;
command.Parameters.Add("@DateOfBirth", SqlDbType.Date).Value = userAccount.DateOfBirth;
AddParameter(command, "@UserAccountId", userAccount.UserAccountId);
AddParameter(command, "@Username", userAccount.Username);
AddParameter(command, "@FirstName", userAccount.FirstName);
AddParameter(command, "@LastName", userAccount.LastName);
AddParameter(command, "@Email", userAccount.Email);
AddParameter(command, "@DateOfBirth", userAccount.DateOfBirth);
await command.ExecuteNonQueryAsync();
}
@@ -26,12 +30,11 @@ namespace DataAccessLayer.Repositories.UserAccount
public override async Task<Entities.UserAccount?> GetByIdAsync(Guid id)
{
await using var connection = await CreateConnection();
await using var command = new SqlCommand("usp_GetUserAccountById", connection)
{
CommandType = CommandType.StoredProcedure
};
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountById";
command.CommandType = CommandType.StoredProcedure;
command.Parameters.Add("@UserAccountId", SqlDbType.UniqueIdentifier).Value = id;
AddParameter(command, "@UserAccountId", id);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
@@ -40,14 +43,15 @@ namespace DataAccessLayer.Repositories.UserAccount
public override async Task<IEnumerable<Entities.UserAccount>> GetAllAsync(int? limit, int? offset)
{
await using var connection = await CreateConnection();
await using var command = new SqlCommand("usp_GetAllUserAccounts", connection);
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetAllUserAccounts";
command.CommandType = CommandType.StoredProcedure;
if (limit.HasValue)
command.Parameters.Add("@Limit", SqlDbType.Int).Value = limit.Value;
AddParameter(command, "@Limit", limit.Value);
if (offset.HasValue)
command.Parameters.Add("@Offset", SqlDbType.Int).Value = offset.Value;
AddParameter(command, "@Offset", offset.Value);
await using var reader = await command.ExecuteReaderAsync();
var users = new List<Entities.UserAccount>();
@@ -63,15 +67,16 @@ namespace DataAccessLayer.Repositories.UserAccount
public override async Task UpdateAsync(Entities.UserAccount userAccount)
{
await using var connection = await CreateConnection();
await using var command = new SqlCommand("usp_UpdateUserAccount", connection);
await using var command = connection.CreateCommand();
command.CommandText = "usp_UpdateUserAccount";
command.CommandType = CommandType.StoredProcedure;
command.Parameters.Add("@UserAccountId", SqlDbType.UniqueIdentifier).Value = userAccount.UserAccountId;
command.Parameters.Add("@Username", SqlDbType.NVarChar, 100).Value = userAccount.Username;
command.Parameters.Add("@FirstName", SqlDbType.NVarChar, 100).Value = userAccount.FirstName;
command.Parameters.Add("@LastName", SqlDbType.NVarChar, 100).Value = userAccount.LastName;
command.Parameters.Add("@Email", SqlDbType.NVarChar, 256).Value = userAccount.Email;
command.Parameters.Add("@DateOfBirth", SqlDbType.Date).Value = userAccount.DateOfBirth;
AddParameter(command, "@UserAccountId", userAccount.UserAccountId);
AddParameter(command, "@Username", userAccount.Username);
AddParameter(command, "@FirstName", userAccount.FirstName);
AddParameter(command, "@LastName", userAccount.LastName);
AddParameter(command, "@Email", userAccount.Email);
AddParameter(command, "@DateOfBirth", userAccount.DateOfBirth);
await command.ExecuteNonQueryAsync();
}
@@ -79,20 +84,22 @@ namespace DataAccessLayer.Repositories.UserAccount
public override async Task DeleteAsync(Guid id)
{
await using var connection = await CreateConnection();
await using var command = new SqlCommand("usp_DeleteUserAccount", connection);
await using var command = connection.CreateCommand();
command.CommandText = "usp_DeleteUserAccount";
command.CommandType = CommandType.StoredProcedure;
command.Parameters.Add("@UserAccountId", SqlDbType.UniqueIdentifier).Value = id;
AddParameter(command, "@UserAccountId", id);
await command.ExecuteNonQueryAsync();
}
public async Task<Entities.UserAccount?> GetByUsernameAsync(string username)
{
await using var connection = await CreateConnection();
await using var command = new SqlCommand("usp_GetUserAccountByUsername", connection);
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByUsername";
command.CommandType = CommandType.StoredProcedure;
command.Parameters.Add("@Username", SqlDbType.NVarChar, 100).Value = username;
AddParameter(command, "@Username", username);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
@@ -101,16 +108,17 @@ namespace DataAccessLayer.Repositories.UserAccount
public async Task<Entities.UserAccount?> GetByEmailAsync(string email)
{
await using var connection = await CreateConnection();
await using var command = new SqlCommand("usp_GetUserAccountByEmail", connection);
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByEmail";
command.CommandType = CommandType.StoredProcedure;
command.Parameters.Add("@Email", SqlDbType.NVarChar, 256).Value = email;
AddParameter(command, "@Email", email);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
protected override Entities.UserAccount MapToEntity(SqlDataReader reader)
protected override Entities.UserAccount MapToEntity(DbDataReader reader)
{
return new Entities.UserAccount
{
@@ -129,5 +137,13 @@ namespace DataAccessLayer.Repositories.UserAccount
: (byte[])reader["Timer"]
};
}
private static void AddParameter(DbCommand command, string name, object? value)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
}
}

View File

@@ -4,4 +4,5 @@ public interface IUserCredentialRepository
{
Task RotateCredentialAsync(Guid userAccountId, UserCredential credential);
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId);
Task InvalidateCredentialsByUserAccountIdAsync(Guid userAccountId);
}

View File

@@ -0,0 +1,93 @@
using System.Data;
using System.Data.Common;
using DataAccessLayer.Sql;
namespace DataAccessLayer.Repositories.UserCredential
{
public class UserCredentialRepository(ISqlConnectionFactory connectionFactory)
: DataAccessLayer.Repositories.Repository<Entities.UserCredential>(connectionFactory), IUserCredentialRepository
{
public async Task RotateCredentialAsync(Guid userAccountId, Entities.UserCredential credential)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_RotateUserCredential";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccountId);
AddParameter(command, "@Hash", credential.Hash);
await command.ExecuteNonQueryAsync();
}
public async Task<Entities.UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_GetActiveUserCredentialByUserAccountId";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccountId);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
public async Task InvalidateCredentialsByUserAccountIdAsync(Guid userAccountId)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_InvalidateUserCredential";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccountId);
await command.ExecuteNonQueryAsync();
}
public override Task AddAsync(Entities.UserCredential entity)
=> throw new NotSupportedException("Use RotateCredentialAsync for adding/rotating credentials.");
public override Task<IEnumerable<Entities.UserCredential>> GetAllAsync(int? limit, int? offset)
=> throw new NotSupportedException("Listing credentials is not supported.");
public override Task<Entities.UserCredential?> GetByIdAsync(Guid id)
=> throw new NotSupportedException("Fetching credential by ID is not supported.");
public override Task UpdateAsync(Entities.UserCredential entity)
=> throw new NotSupportedException("Use RotateCredentialAsync to update credentials.");
public override Task DeleteAsync(Guid id)
=> throw new NotSupportedException("Deleting a credential by ID is not supported.");
protected override Entities.UserCredential MapToEntity(DbDataReader reader)
{
var entity = new Entities.UserCredential
{
UserCredentialId = reader.GetGuid(reader.GetOrdinal("UserCredentialId")),
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
Hash = reader.GetString(reader.GetOrdinal("Hash")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt"))
};
// Optional columns
var hasTimer = reader.GetSchemaTable()?.Rows
.Cast<System.Data.DataRow>()
.Any(r => string.Equals(r["ColumnName"]?.ToString(), "Timer", StringComparison.OrdinalIgnoreCase)) ?? false;
if (hasTimer)
{
entity.Timer = reader.IsDBNull(reader.GetOrdinal("Timer")) ? null : (byte[])reader["Timer"];
}
return entity;
}
private static void AddParameter(DbCommand command, string name, object? value)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
}
}