up
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Infrastructure.Postgres.Connections;
|
||||
|
||||
namespace StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for PostgreSQL repositories providing common patterns and utilities.
|
||||
/// </summary>
|
||||
/// <typeparam name="TDataSource">The module-specific data source type.</typeparam>
|
||||
public abstract class RepositoryBase<TDataSource> where TDataSource : DataSourceBase
|
||||
{
|
||||
/// <summary>
|
||||
/// The data source for database connections.
|
||||
/// </summary>
|
||||
protected TDataSource DataSource { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Logger for this repository.
|
||||
/// </summary>
|
||||
protected ILogger Logger { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new repository with the specified data source and logger.
|
||||
/// </summary>
|
||||
protected RepositoryBase(TDataSource dataSource, ILogger logger)
|
||||
{
|
||||
DataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Command timeout from data source options.
|
||||
/// </summary>
|
||||
protected int CommandTimeoutSeconds => DataSource.CommandTimeoutSeconds;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a command with timeout configured.
|
||||
/// </summary>
|
||||
protected NpgsqlCommand CreateCommand(string sql, NpgsqlConnection connection)
|
||||
{
|
||||
var command = new NpgsqlCommand(sql, connection)
|
||||
{
|
||||
CommandTimeout = CommandTimeoutSeconds
|
||||
};
|
||||
return command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a parameter to the command, handling null values.
|
||||
/// </summary>
|
||||
protected static void AddParameter(NpgsqlCommand command, string name, object? value)
|
||||
{
|
||||
command.Parameters.AddWithValue(name, value ?? DBNull.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a typed JSONB parameter to the command.
|
||||
/// </summary>
|
||||
protected static void AddJsonbParameter(NpgsqlCommand command, string name, string? jsonValue)
|
||||
{
|
||||
command.Parameters.Add(new NpgsqlParameter<string?>(name, NpgsqlDbType.Jsonb) { TypedValue = jsonValue });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a UUID array parameter to the command.
|
||||
/// </summary>
|
||||
protected static void AddUuidArrayParameter(NpgsqlCommand command, string name, Guid[]? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
command.Parameters.AddWithValue(name, DBNull.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
command.Parameters.Add(new NpgsqlParameter<Guid[]>(name, NpgsqlDbType.Array | NpgsqlDbType.Uuid)
|
||||
{
|
||||
TypedValue = values
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a text array parameter to the command.
|
||||
/// </summary>
|
||||
protected static void AddTextArrayParameter(NpgsqlCommand command, string name, string[]? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
command.Parameters.AddWithValue(name, DBNull.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
command.Parameters.Add(new NpgsqlParameter<string[]>(name, NpgsqlDbType.Array | NpgsqlDbType.Text)
|
||||
{
|
||||
TypedValue = values
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a nullable string from the reader.
|
||||
/// </summary>
|
||||
protected static string? GetNullableString(NpgsqlDataReader reader, int ordinal)
|
||||
=> reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a nullable Guid from the reader.
|
||||
/// </summary>
|
||||
protected static Guid? GetNullableGuid(NpgsqlDataReader reader, int ordinal)
|
||||
=> reader.IsDBNull(ordinal) ? null : reader.GetGuid(ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a nullable int from the reader.
|
||||
/// </summary>
|
||||
protected static int? GetNullableInt32(NpgsqlDataReader reader, int ordinal)
|
||||
=> reader.IsDBNull(ordinal) ? null : reader.GetInt32(ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a nullable long from the reader.
|
||||
/// </summary>
|
||||
protected static long? GetNullableInt64(NpgsqlDataReader reader, int ordinal)
|
||||
=> reader.IsDBNull(ordinal) ? null : reader.GetInt64(ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a nullable DateTimeOffset from the reader.
|
||||
/// </summary>
|
||||
protected static DateTimeOffset? GetNullableDateTimeOffset(NpgsqlDataReader reader, int ordinal)
|
||||
=> reader.IsDBNull(ordinal) ? null : reader.GetFieldValue<DateTimeOffset>(ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a nullable bool from the reader.
|
||||
/// </summary>
|
||||
protected static bool? GetNullableBoolean(NpgsqlDataReader reader, int ordinal)
|
||||
=> reader.IsDBNull(ordinal) ? null : reader.GetBoolean(ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Executes a query and returns all results as a list.
|
||||
/// </summary>
|
||||
protected async Task<IReadOnlyList<T>> QueryAsync<T>(
|
||||
string tenantId,
|
||||
string sql,
|
||||
Action<NpgsqlCommand>? configureCommand,
|
||||
Func<NpgsqlDataReader, T> mapRow,
|
||||
CancellationToken cancellationToken,
|
||||
[CallerMemberName] string? callerName = null)
|
||||
{
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
configureCommand?.Invoke(command);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
var results = new List<T>();
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(mapRow(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a query and returns a single result or null.
|
||||
/// </summary>
|
||||
protected async Task<T?> QuerySingleOrDefaultAsync<T>(
|
||||
string tenantId,
|
||||
string sql,
|
||||
Action<NpgsqlCommand>? configureCommand,
|
||||
Func<NpgsqlDataReader, T> mapRow,
|
||||
CancellationToken cancellationToken,
|
||||
[CallerMemberName] string? callerName = null) where T : class
|
||||
{
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
configureCommand?.Invoke(command);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return mapRow(reader);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a non-query command and returns the number of affected rows.
|
||||
/// </summary>
|
||||
protected async Task<int> ExecuteAsync(
|
||||
string tenantId,
|
||||
string sql,
|
||||
Action<NpgsqlCommand>? configureCommand,
|
||||
CancellationToken cancellationToken,
|
||||
[CallerMemberName] string? callerName = null)
|
||||
{
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
configureCommand?.Invoke(command);
|
||||
|
||||
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a scalar query and returns the result.
|
||||
/// </summary>
|
||||
protected async Task<T?> ExecuteScalarAsync<T>(
|
||||
string tenantId,
|
||||
string sql,
|
||||
Action<NpgsqlCommand>? configureCommand,
|
||||
CancellationToken cancellationToken,
|
||||
[CallerMemberName] string? callerName = null)
|
||||
{
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
configureCommand?.Invoke(command);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return result is DBNull or null ? default : (T)result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a dynamic WHERE clause with the specified conditions.
|
||||
/// </summary>
|
||||
protected static (string whereClause, List<(string name, object value)> parameters) BuildWhereClause(
|
||||
params (string condition, string paramName, object? value, bool include)[] conditions)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var parameters = new List<(string, object)>();
|
||||
var first = true;
|
||||
|
||||
foreach (var (condition, paramName, value, include) in conditions)
|
||||
{
|
||||
if (!include || value is null) continue;
|
||||
|
||||
sb.Append(first ? " WHERE " : " AND ");
|
||||
sb.Append(condition);
|
||||
parameters.Add((paramName, value));
|
||||
first = false;
|
||||
}
|
||||
|
||||
return (sb.ToString(), parameters);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds ORDER BY clause with deterministic ordering.
|
||||
/// Always includes a unique column (typically id) as tiebreaker for pagination stability.
|
||||
/// </summary>
|
||||
protected static string BuildOrderByClause(
|
||||
string primaryColumn,
|
||||
bool descending = false,
|
||||
string? tiebreaker = "id")
|
||||
{
|
||||
var direction = descending ? "DESC" : "ASC";
|
||||
var tiebreakerDirection = descending ? "DESC" : "ASC";
|
||||
|
||||
if (string.IsNullOrEmpty(tiebreaker) || primaryColumn == tiebreaker)
|
||||
{
|
||||
return $" ORDER BY {primaryColumn} {direction}";
|
||||
}
|
||||
|
||||
return $" ORDER BY {primaryColumn} {direction}, {tiebreaker} {tiebreakerDirection}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds LIMIT/OFFSET clause for pagination.
|
||||
/// </summary>
|
||||
protected static string BuildPaginationClause(int limit, int offset)
|
||||
=> $" LIMIT {limit} OFFSET {offset}";
|
||||
}
|
||||
Reference in New Issue
Block a user