up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-28 20:55:22 +02:00
parent d040c001ac
commit 2548abc56f
231 changed files with 47468 additions and 68 deletions

View File

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