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,242 @@
using System.Data;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.Infrastructure.Postgres.Connections;
/// <summary>
/// Base class for module-specific PostgreSQL data sources.
/// Manages connection pooling, tenant context configuration, and session settings.
/// </summary>
/// <remarks>
/// Each module should derive from this class to create a strongly-typed data source.
/// Example: <c>AuthorityDataSource : DataSourceBase</c>
/// </remarks>
public abstract class DataSourceBase : IAsyncDisposable
{
private readonly NpgsqlDataSource _dataSource;
private readonly ILogger _logger;
private bool _disposed;
/// <summary>
/// PostgreSQL options for this data source.
/// </summary>
protected PostgresOptions Options { get; }
/// <summary>
/// Module name for logging and metrics.
/// </summary>
protected abstract string ModuleName { get; }
/// <summary>
/// Creates a new data source with the specified options.
/// </summary>
protected DataSourceBase(PostgresOptions options, ILogger logger)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(logger);
Options = options;
_logger = logger;
var builder = new NpgsqlDataSourceBuilder(options.ConnectionString)
{
Name = ModuleName
};
ConfigureDataSourceBuilder(builder);
_dataSource = builder.Build();
}
/// <summary>
/// Command timeout in seconds from options.
/// </summary>
public int CommandTimeoutSeconds => Options.CommandTimeoutSeconds;
/// <summary>
/// Schema name for this module's tables.
/// </summary>
public string? SchemaName => Options.SchemaName;
/// <summary>
/// Disposes the data source and releases all pooled connections.
/// </summary>
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
await _dataSource.DisposeAsync().ConfigureAwait(false);
GC.SuppressFinalize(this);
}
/// <summary>
/// Opens a connection with tenant context configured.
/// </summary>
/// <param name="tenantId">Tenant identifier for session configuration.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Open PostgreSQL connection with tenant context set.</returns>
public Task<NpgsqlConnection> OpenConnectionAsync(string tenantId, CancellationToken cancellationToken = default)
=> OpenConnectionInternalAsync(tenantId, "default", cancellationToken);
/// <summary>
/// Opens a connection with tenant context and role label configured.
/// </summary>
/// <param name="tenantId">Tenant identifier for session configuration.</param>
/// <param name="role">Role label for metrics/logging (e.g., "reader", "writer").</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Open PostgreSQL connection with tenant context set.</returns>
public Task<NpgsqlConnection> OpenConnectionAsync(string tenantId, string role, CancellationToken cancellationToken = default)
=> OpenConnectionInternalAsync(tenantId, role, cancellationToken);
/// <summary>
/// Opens a connection for system operations without tenant context.
/// Use sparingly - only for migrations, health checks, and cross-tenant admin operations.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Open PostgreSQL connection without tenant context.</returns>
public async Task<NpgsqlConnection> OpenSystemConnectionAsync(CancellationToken cancellationToken = default)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
try
{
await ConfigureSystemSessionAsync(connection, cancellationToken).ConfigureAwait(false);
OnConnectionOpened("system");
}
catch
{
await connection.DisposeAsync().ConfigureAwait(false);
throw;
}
return connection;
}
/// <summary>
/// Override to configure additional NpgsqlDataSourceBuilder options.
/// </summary>
protected virtual void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder)
{
// Override in derived classes to add custom type mappings, etc.
}
/// <summary>
/// Override to add custom session configuration.
/// </summary>
protected virtual async Task ConfigureSessionAsync(
NpgsqlConnection connection,
string tenantId,
CancellationToken cancellationToken)
{
// Set UTC timezone for deterministic timestamps
await using var tzCommand = new NpgsqlCommand("SET TIME ZONE 'UTC';", connection);
tzCommand.CommandTimeout = Options.CommandTimeoutSeconds;
await tzCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
// Set statement timeout
await using var timeoutCommand = new NpgsqlCommand(
$"SET statement_timeout = '{Options.CommandTimeoutSeconds}s';", connection);
timeoutCommand.CommandTimeout = Options.CommandTimeoutSeconds;
await timeoutCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
// Set tenant context for row-level security
if (!string.IsNullOrWhiteSpace(tenantId))
{
await using var tenantCommand = new NpgsqlCommand(
"SELECT set_config('app.current_tenant', @tenant, false);", connection);
tenantCommand.CommandTimeout = Options.CommandTimeoutSeconds;
tenantCommand.Parameters.AddWithValue("tenant", tenantId);
await tenantCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
// Set search path if schema is specified
if (!string.IsNullOrWhiteSpace(Options.SchemaName))
{
await using var schemaCommand = new NpgsqlCommand(
$"SET search_path TO {Options.SchemaName}, public;", connection);
schemaCommand.CommandTimeout = Options.CommandTimeoutSeconds;
await schemaCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Override to add custom system session configuration.
/// </summary>
protected virtual async Task ConfigureSystemSessionAsync(
NpgsqlConnection connection,
CancellationToken cancellationToken)
{
// Set UTC timezone for deterministic timestamps
await using var tzCommand = new NpgsqlCommand("SET TIME ZONE 'UTC';", connection);
tzCommand.CommandTimeout = Options.CommandTimeoutSeconds;
await tzCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
// Set statement timeout
await using var timeoutCommand = new NpgsqlCommand(
$"SET statement_timeout = '{Options.CommandTimeoutSeconds}s';", connection);
timeoutCommand.CommandTimeout = Options.CommandTimeoutSeconds;
await timeoutCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
// Set search path if schema is specified
if (!string.IsNullOrWhiteSpace(Options.SchemaName))
{
await using var schemaCommand = new NpgsqlCommand(
$"SET search_path TO {Options.SchemaName}, public;", connection);
schemaCommand.CommandTimeout = Options.CommandTimeoutSeconds;
await schemaCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Override to handle connection opened events for metrics.
/// </summary>
protected virtual void OnConnectionOpened(string role)
{
// Override in derived classes to emit metrics
}
/// <summary>
/// Override to handle connection closed events for metrics.
/// </summary>
protected virtual void OnConnectionClosed(string role)
{
// Override in derived classes to emit metrics
}
private async Task<NpgsqlConnection> OpenConnectionInternalAsync(
string tenantId,
string role,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
try
{
await ConfigureSessionAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
OnConnectionOpened(role);
connection.StateChange += (_, args) =>
{
if (args.CurrentState == ConnectionState.Closed)
{
OnConnectionClosed(role);
}
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to configure PostgreSQL session for tenant {TenantId} in module {Module}.",
tenantId, ModuleName);
await connection.DisposeAsync().ConfigureAwait(false);
throw;
}
return connection;
}
}

View File

@@ -0,0 +1,94 @@
using Npgsql;
namespace StellaOps.Infrastructure.Postgres.Exceptions;
/// <summary>
/// Helper methods for handling PostgreSQL exceptions.
/// </summary>
public static class PostgresExceptionHelper
{
/// <summary>
/// PostgreSQL error code for unique constraint violation.
/// </summary>
public const string UniqueViolation = "23505";
/// <summary>
/// PostgreSQL error code for foreign key violation.
/// </summary>
public const string ForeignKeyViolation = "23503";
/// <summary>
/// PostgreSQL error code for not null violation.
/// </summary>
public const string NotNullViolation = "23502";
/// <summary>
/// PostgreSQL error code for check constraint violation.
/// </summary>
public const string CheckViolation = "23514";
/// <summary>
/// PostgreSQL error code for serialization failure (retry needed).
/// </summary>
public const string SerializationFailure = "40001";
/// <summary>
/// PostgreSQL error code for deadlock detected (retry needed).
/// </summary>
public const string DeadlockDetected = "40P01";
/// <summary>
/// Checks if the exception is a unique constraint violation.
/// </summary>
public static bool IsUniqueViolation(PostgresException ex)
=> string.Equals(ex.SqlState, UniqueViolation, StringComparison.Ordinal);
/// <summary>
/// Checks if the exception is a unique constraint violation for a specific constraint.
/// </summary>
public static bool IsUniqueViolation(PostgresException ex, string constraintName)
=> IsUniqueViolation(ex) &&
string.Equals(ex.ConstraintName, constraintName, StringComparison.Ordinal);
/// <summary>
/// Checks if the exception is a foreign key violation.
/// </summary>
public static bool IsForeignKeyViolation(PostgresException ex)
=> string.Equals(ex.SqlState, ForeignKeyViolation, StringComparison.Ordinal);
/// <summary>
/// Checks if the exception is a not null violation.
/// </summary>
public static bool IsNotNullViolation(PostgresException ex)
=> string.Equals(ex.SqlState, NotNullViolation, StringComparison.Ordinal);
/// <summary>
/// Checks if the exception is a check constraint violation.
/// </summary>
public static bool IsCheckViolation(PostgresException ex)
=> string.Equals(ex.SqlState, CheckViolation, StringComparison.Ordinal);
/// <summary>
/// Checks if the exception is retryable (serialization failure or deadlock).
/// </summary>
public static bool IsRetryable(PostgresException ex)
=> string.Equals(ex.SqlState, SerializationFailure, StringComparison.Ordinal) ||
string.Equals(ex.SqlState, DeadlockDetected, StringComparison.Ordinal);
/// <summary>
/// Checks if any exception in the chain is retryable.
/// </summary>
public static bool IsRetryable(Exception ex)
{
var current = ex;
while (current != null)
{
if (current is PostgresException pgEx && IsRetryable(pgEx))
{
return true;
}
current = current.InnerException;
}
return false;
}
}

View File

@@ -0,0 +1,284 @@
using Microsoft.Extensions.Logging;
using Npgsql;
namespace StellaOps.Infrastructure.Postgres.Migrations;
/// <summary>
/// Runs SQL migrations for a PostgreSQL schema.
/// Migrations are idempotent and tracked in a schema_migrations table.
/// </summary>
public sealed class MigrationRunner
{
private readonly string _connectionString;
private readonly string _schemaName;
private readonly string _moduleName;
private readonly ILogger _logger;
/// <summary>
/// Creates a new migration runner.
/// </summary>
/// <param name="connectionString">PostgreSQL connection string.</param>
/// <param name="schemaName">Schema name for the module.</param>
/// <param name="moduleName">Module name for logging.</param>
/// <param name="logger">Logger instance.</param>
public MigrationRunner(
string connectionString,
string schemaName,
string moduleName,
ILogger logger)
{
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
_schemaName = schemaName ?? throw new ArgumentNullException(nameof(schemaName));
_moduleName = moduleName ?? throw new ArgumentNullException(nameof(moduleName));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Runs all pending migrations from the specified path.
/// </summary>
/// <param name="migrationsPath">Path to directory containing SQL migration files.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of migrations applied.</returns>
public async Task<int> RunAsync(string migrationsPath, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(migrationsPath);
if (!Directory.Exists(migrationsPath))
{
throw new DirectoryNotFoundException($"Migrations directory not found: {migrationsPath}");
}
var migrationFiles = Directory.GetFiles(migrationsPath, "*.sql")
.OrderBy(f => Path.GetFileName(f))
.ToList();
if (migrationFiles.Count == 0)
{
_logger.LogInformation("No migration files found in {Path} for module {Module}.",
migrationsPath, _moduleName);
return 0;
}
await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
// Ensure schema exists
await EnsureSchemaAsync(connection, cancellationToken).ConfigureAwait(false);
// Ensure migrations table exists
await EnsureMigrationsTableAsync(connection, cancellationToken).ConfigureAwait(false);
// Get applied migrations
var appliedMigrations = await GetAppliedMigrationsAsync(connection, cancellationToken)
.ConfigureAwait(false);
var appliedCount = 0;
foreach (var file in migrationFiles)
{
var fileName = Path.GetFileName(file);
if (appliedMigrations.Contains(fileName))
{
_logger.LogDebug("Migration {Migration} already applied for module {Module}.",
fileName, _moduleName);
continue;
}
_logger.LogInformation("Applying migration {Migration} for module {Module}...",
fileName, _moduleName);
await ApplyMigrationAsync(connection, file, fileName, cancellationToken)
.ConfigureAwait(false);
appliedCount++;
_logger.LogInformation("Migration {Migration} applied successfully for module {Module}.",
fileName, _moduleName);
}
if (appliedCount > 0)
{
_logger.LogInformation("Applied {Count} migration(s) for module {Module}.",
appliedCount, _moduleName);
}
else
{
_logger.LogInformation("Database is up to date for module {Module}.", _moduleName);
}
return appliedCount;
}
/// <summary>
/// Gets the current migration version (latest applied migration).
/// </summary>
public async Task<string?> GetCurrentVersionAsync(CancellationToken cancellationToken = default)
{
await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
var tableExists = await CheckMigrationsTableExistsAsync(connection, cancellationToken)
.ConfigureAwait(false);
if (!tableExists) return null;
await using var command = new NpgsqlCommand(
$"SELECT migration_name FROM {_schemaName}.schema_migrations ORDER BY applied_at DESC LIMIT 1",
connection);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return result as string;
}
/// <summary>
/// Gets all applied migrations.
/// </summary>
public async Task<IReadOnlyList<MigrationInfo>> GetAppliedMigrationInfoAsync(
CancellationToken cancellationToken = default)
{
await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
var tableExists = await CheckMigrationsTableExistsAsync(connection, cancellationToken)
.ConfigureAwait(false);
if (!tableExists) return [];
await using var command = new NpgsqlCommand(
$"""
SELECT migration_name, applied_at, checksum
FROM {_schemaName}.schema_migrations
ORDER BY applied_at
""",
connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var migrations = new List<MigrationInfo>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
migrations.Add(new MigrationInfo(
Name: reader.GetString(0),
AppliedAt: reader.GetFieldValue<DateTimeOffset>(1),
Checksum: reader.GetString(2)));
}
return migrations;
}
private async Task EnsureSchemaAsync(NpgsqlConnection connection, CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand(
$"CREATE SCHEMA IF NOT EXISTS {_schemaName};", connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private async Task EnsureMigrationsTableAsync(NpgsqlConnection connection, CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand(
$"""
CREATE TABLE IF NOT EXISTS {_schemaName}.schema_migrations (
migration_name TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
checksum TEXT NOT NULL
);
""",
connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private async Task<bool> CheckMigrationsTableExistsAsync(
NpgsqlConnection connection,
CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand(
"""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = @schema
AND table_name = 'schema_migrations'
);
""",
connection);
command.Parameters.AddWithValue("schema", _schemaName);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return result is true;
}
private async Task<HashSet<string>> GetAppliedMigrationsAsync(
NpgsqlConnection connection,
CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand(
$"SELECT migration_name FROM {_schemaName}.schema_migrations;",
connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var migrations = new HashSet<string>(StringComparer.Ordinal);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
migrations.Add(reader.GetString(0));
}
return migrations;
}
private async Task ApplyMigrationAsync(
NpgsqlConnection connection,
string filePath,
string fileName,
CancellationToken cancellationToken)
{
var sql = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
var checksum = ComputeChecksum(sql);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken)
.ConfigureAwait(false);
try
{
// Run migration SQL
await using (var migrationCommand = new NpgsqlCommand(sql, connection, transaction))
{
migrationCommand.CommandTimeout = 300; // 5 minute timeout for migrations
await migrationCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
// Record migration
await using (var recordCommand = new NpgsqlCommand(
$"""
INSERT INTO {_schemaName}.schema_migrations (migration_name, checksum)
VALUES (@name, @checksum);
""",
connection,
transaction))
{
recordCommand.Parameters.AddWithValue("name", fileName);
recordCommand.Parameters.AddWithValue("checksum", checksum);
await recordCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
catch
{
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
throw;
}
}
private static string ComputeChecksum(string content)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(content);
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return Convert.ToHexStringLower(hash);
}
}
/// <summary>
/// Information about an applied migration.
/// </summary>
public readonly record struct MigrationInfo(string Name, DateTimeOffset AppliedAt, string Checksum);

View File

@@ -0,0 +1,75 @@
namespace StellaOps.Infrastructure.Postgres.Options;
/// <summary>
/// Persistence backend selection for dual-write/migration scenarios.
/// </summary>
public enum PersistenceBackend
{
/// <summary>
/// Use MongoDB as the primary backend (legacy).
/// </summary>
Mongo,
/// <summary>
/// Use PostgreSQL as the primary backend.
/// </summary>
Postgres,
/// <summary>
/// Dual-write mode: write to both backends, read from primary.
/// Used during migration phase for data consistency verification.
/// </summary>
DualWrite
}
/// <summary>
/// Persistence options for module backend selection.
/// </summary>
public sealed class PersistenceOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Persistence";
/// <summary>
/// Backend for Authority module.
/// </summary>
public PersistenceBackend Authority { get; set; } = PersistenceBackend.Mongo;
/// <summary>
/// Backend for Scheduler module.
/// </summary>
public PersistenceBackend Scheduler { get; set; } = PersistenceBackend.Mongo;
/// <summary>
/// Backend for Notify module.
/// </summary>
public PersistenceBackend Notify { get; set; } = PersistenceBackend.Mongo;
/// <summary>
/// Backend for Policy module.
/// </summary>
public PersistenceBackend Policy { get; set; } = PersistenceBackend.Mongo;
/// <summary>
/// Backend for Concelier (vulnerability) module.
/// </summary>
public PersistenceBackend Concelier { get; set; } = PersistenceBackend.Mongo;
/// <summary>
/// Backend for Excititor (VEX/graph) module.
/// </summary>
public PersistenceBackend Excititor { get; set; } = PersistenceBackend.Mongo;
/// <summary>
/// In dual-write mode, which backend to read from.
/// </summary>
public PersistenceBackend DualWriteReadFrom { get; set; } = PersistenceBackend.Mongo;
/// <summary>
/// Enable comparison logging in dual-write mode.
/// Logs discrepancies between backends for debugging.
/// </summary>
public bool DualWriteComparisonLogging { get; set; }
}

View File

@@ -0,0 +1,52 @@
namespace StellaOps.Infrastructure.Postgres.Options;
/// <summary>
/// PostgreSQL connection and behavior options.
/// </summary>
public sealed class PostgresOptions
{
/// <summary>
/// PostgreSQL connection string.
/// </summary>
public required string ConnectionString { get; set; }
/// <summary>
/// Command timeout in seconds. Default is 30 seconds.
/// </summary>
public int CommandTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Maximum number of connections in the pool. Default is 100.
/// </summary>
public int MaxPoolSize { get; set; } = 100;
/// <summary>
/// Minimum number of connections in the pool. Default is 1.
/// </summary>
public int MinPoolSize { get; set; } = 1;
/// <summary>
/// Connection idle lifetime in seconds. Default is 300 seconds (5 minutes).
/// </summary>
public int ConnectionIdleLifetimeSeconds { get; set; } = 300;
/// <summary>
/// Enable connection pooling. Default is true.
/// </summary>
public bool Pooling { get; set; } = true;
/// <summary>
/// Schema name for module-specific tables. If null, uses public schema.
/// </summary>
public string? SchemaName { get; set; }
/// <summary>
/// Enable automatic migration on startup. Default is false for production safety.
/// </summary>
public bool AutoMigrate { get; set; }
/// <summary>
/// Path to SQL migration files. Required if AutoMigrate is true.
/// </summary>
public string? MigrationsPath { get; set; }
}

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

View File

@@ -0,0 +1,55 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.Infrastructure.Postgres;
/// <summary>
/// Extension methods for configuring PostgreSQL infrastructure services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds PostgreSQL infrastructure options from configuration.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration root.</param>
/// <param name="sectionName">Configuration section name for PostgreSQL options.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddPostgresOptions(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = "Postgres")
{
services.Configure<PostgresOptions>(configuration.GetSection(sectionName));
return services;
}
/// <summary>
/// Adds persistence backend options from configuration.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration root.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddPersistenceOptions(
this IServiceCollection services,
IConfiguration configuration)
{
services.Configure<PersistenceOptions>(configuration.GetSection(PersistenceOptions.SectionName));
return services;
}
/// <summary>
/// Adds PostgreSQL infrastructure with the specified options.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configureOptions">Options configuration action.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddPostgresInfrastructure(
this IServiceCollection services,
Action<PostgresOptions> configureOptions)
{
services.Configure(configureOptions);
return services;
}
}

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Infrastructure.Postgres</RootNamespace>
<AssemblyName>StellaOps.Infrastructure.Postgres</AssemblyName>
<Description>Shared PostgreSQL infrastructure for StellaOps modules</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Npgsql" Version="9.0.2" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,211 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Migrations;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.Infrastructure.Postgres.Testing;
/// <summary>
/// Test fixture for PostgreSQL integration tests.
/// Provides connection management and schema setup for tests.
/// </summary>
/// <remarks>
/// Use with Testcontainers or a local PostgreSQL instance.
/// Each test class should create its own schema for isolation.
/// </remarks>
public sealed class PostgresFixture : IAsyncDisposable
{
private readonly string _connectionString;
private readonly string _schemaName;
private readonly ILogger _logger;
private bool _disposed;
/// <summary>
/// Creates a new PostgreSQL test fixture.
/// </summary>
/// <param name="connectionString">PostgreSQL connection string.</param>
/// <param name="schemaName">Unique schema name for test isolation.</param>
/// <param name="logger">Optional logger.</param>
public PostgresFixture(
string connectionString,
string schemaName,
ILogger? logger = null)
{
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
_schemaName = schemaName ?? throw new ArgumentNullException(nameof(schemaName));
_logger = logger ?? NullLogger.Instance;
}
/// <summary>
/// Connection string for the test database.
/// </summary>
public string ConnectionString => _connectionString;
/// <summary>
/// Schema name for test isolation.
/// </summary>
public string SchemaName => _schemaName;
/// <summary>
/// Creates PostgreSQL options for the test fixture.
/// </summary>
public PostgresOptions CreateOptions() => new()
{
ConnectionString = _connectionString,
SchemaName = _schemaName,
CommandTimeoutSeconds = 30,
AutoMigrate = false
};
/// <summary>
/// Initializes the test schema.
/// </summary>
public async Task InitializeAsync(CancellationToken cancellationToken = default)
{
await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
// Create schema
await using var createSchemaCmd = new NpgsqlCommand(
$"CREATE SCHEMA IF NOT EXISTS {_schemaName};", connection);
await createSchemaCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Created test schema: {Schema}", _schemaName);
}
/// <summary>
/// Runs migrations for the test schema.
/// </summary>
/// <param name="migrationsPath">Path to migration SQL files.</param>
/// <param name="moduleName">Module name for logging.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task RunMigrationsAsync(
string migrationsPath,
string moduleName,
CancellationToken cancellationToken = default)
{
var runner = new MigrationRunner(
_connectionString,
_schemaName,
moduleName,
_logger);
await runner.RunAsync(migrationsPath, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Executes raw SQL for test setup.
/// </summary>
public async Task ExecuteSqlAsync(string sql, CancellationToken cancellationToken = default)
{
await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(sql, connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Truncates all tables in the test schema for test isolation.
/// </summary>
public async Task TruncateAllTablesAsync(CancellationToken cancellationToken = default)
{
await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
// Get all tables in the schema
await using var getTablesCmd = new NpgsqlCommand(
"""
SELECT table_name FROM information_schema.tables
WHERE table_schema = @schema AND table_type = 'BASE TABLE'
AND table_name != 'schema_migrations';
""",
connection);
getTablesCmd.Parameters.AddWithValue("schema", _schemaName);
var tables = new List<string>();
await using (var reader = await getTablesCmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false))
{
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
tables.Add(reader.GetString(0));
}
}
if (tables.Count == 0) return;
// Truncate all tables
var truncateSql = $"TRUNCATE TABLE {string.Join(", ", tables.Select(t => $"{_schemaName}.{t}"))} CASCADE;";
await using var truncateCmd = new NpgsqlCommand(truncateSql, connection);
await truncateCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Truncated {Count} tables in schema {Schema}", tables.Count, _schemaName);
}
/// <summary>
/// Cleans up the test schema.
/// </summary>
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
try
{
await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync().ConfigureAwait(false);
await using var dropSchemaCmd = new NpgsqlCommand(
$"DROP SCHEMA IF EXISTS {_schemaName} CASCADE;", connection);
await dropSchemaCmd.ExecuteNonQueryAsync().ConfigureAwait(false);
_logger.LogInformation("Dropped test schema: {Schema}", _schemaName);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to drop test schema: {Schema}", _schemaName);
}
}
}
/// <summary>
/// Factory for creating PostgreSQL test fixtures.
/// </summary>
public static class PostgresFixtureFactory
{
/// <summary>
/// Creates a fixture with a unique schema name based on the test class name.
/// </summary>
/// <param name="connectionString">PostgreSQL connection string.</param>
/// <param name="testClassName">Test class name for schema naming.</param>
/// <param name="logger">Optional logger.</param>
public static PostgresFixture Create(
string connectionString,
string testClassName,
ILogger? logger = null)
{
// Create a unique schema name based on test class and timestamp
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var schemaName = $"test_{SanitizeIdentifier(testClassName)}_{timestamp}";
return new PostgresFixture(connectionString, schemaName, logger);
}
/// <summary>
/// Creates a fixture with a random schema name.
/// </summary>
public static PostgresFixture CreateRandom(string connectionString, ILogger? logger = null)
{
var schemaName = $"test_{Guid.NewGuid():N}";
return new PostgresFixture(connectionString, schemaName, logger);
}
private static string SanitizeIdentifier(string name)
{
// Convert to lowercase and replace non-alphanumeric with underscore
return string.Concat(name.ToLowerInvariant()
.Select(c => char.IsLetterOrDigit(c) ? c : '_'))
.TrimEnd('_');
}
}

View File

@@ -0,0 +1,101 @@
using FluentAssertions;
using StellaOps.Infrastructure.Postgres.Testing;
using Testcontainers.PostgreSql;
using Xunit;
namespace StellaOps.Infrastructure.Postgres.Tests;
/// <summary>
/// Integration tests for PostgresFixture.
/// Uses Testcontainers to spin up a real PostgreSQL instance.
/// </summary>
public sealed class PostgresFixtureTests : IAsyncLifetime
{
private PostgreSqlContainer? _container;
public async Task InitializeAsync()
{
_container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.Build();
await _container.StartAsync();
}
public async Task DisposeAsync()
{
if (_container != null)
{
await _container.DisposeAsync();
}
}
[Fact]
public async Task Initialize_CreatesSchema()
{
// Arrange
var connectionString = _container!.GetConnectionString();
await using var fixture = PostgresFixtureFactory.Create(connectionString, nameof(Initialize_CreatesSchema));
// Act
await fixture.InitializeAsync();
// Assert
var options = fixture.CreateOptions();
options.SchemaName.Should().StartWith("test_initialize_createsschema_");
}
[Fact]
public async Task TruncateAllTables_ClearsTables()
{
// Arrange
var connectionString = _container!.GetConnectionString();
await using var fixture = PostgresFixtureFactory.CreateRandom(connectionString);
await fixture.InitializeAsync();
// Create a test table and insert data
await fixture.ExecuteSqlAsync($"""
CREATE TABLE {fixture.SchemaName}.test_table (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL
);
INSERT INTO {fixture.SchemaName}.test_table (name) VALUES ('test1'), ('test2');
""");
// Act
await fixture.TruncateAllTablesAsync();
// Assert - table should be empty
await using var conn = new Npgsql.NpgsqlConnection(connectionString);
await conn.OpenAsync();
await using var cmd = new Npgsql.NpgsqlCommand(
$"SELECT COUNT(*) FROM {fixture.SchemaName}.test_table", conn);
var count = await cmd.ExecuteScalarAsync();
count.Should().Be(0L);
}
[Fact]
public async Task Dispose_DropsSchema()
{
// Arrange
var connectionString = _container!.GetConnectionString();
string schemaName;
// Create and dispose fixture
{
await using var fixture = PostgresFixtureFactory.CreateRandom(connectionString);
await fixture.InitializeAsync();
schemaName = fixture.SchemaName;
}
// Assert - schema should not exist
await using var conn = new Npgsql.NpgsqlConnection(connectionString);
await conn.OpenAsync();
await using var cmd = new Npgsql.NpgsqlCommand(
"SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = @name)",
conn);
cmd.Parameters.AddWithValue("name", schemaName);
var exists = await cmd.ExecuteScalarAsync();
exists.Should().Be(false);
}
}

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.1.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
</ItemGroup>
</Project>