up
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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('_');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user