using Npgsql;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugins;
using StellaOps.Doctor.Plugins.Builders;
using System.Globalization;
namespace StellaOps.Doctor.Plugins.Database.Checks;
///
/// Checks for schema version consistency across database objects.
///
public sealed class SchemaVersionCheck : DatabaseCheckBase
{
///
public override string CheckId => "check.db.schema.version";
///
public override string Name => "Schema Version";
///
public override string Description => "Verifies database schema version and consistency";
///
public override IReadOnlyList Tags => ["database", "schema", "migrations"];
///
protected override async Task ExecuteCheckAsync(
DoctorPluginContext context,
string connectionString,
CheckResultBuilder result,
CancellationToken ct)
{
await using var connection = await CreateConnectionAsync(connectionString, ct);
// Get schema information
await using var cmd = new NpgsqlCommand(@"
SELECT
n.nspname AS schema_name,
COUNT(c.relname) AS table_count
FROM pg_catalog.pg_namespace n
LEFT JOIN pg_catalog.pg_class c ON c.relnamespace = n.oid AND c.relkind = 'r'
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
GROUP BY n.nspname
ORDER BY n.nspname",
connection);
var schemas = new List<(string Name, int TableCount)>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
schemas.Add((
reader.GetString(0),
reader.GetInt32(1)
));
}
await reader.CloseAsync();
// Get latest migration info if available
string? latestMigration = null;
var migrationTableExists = await CheckMigrationTableExistsAsync(connection, ct);
if (migrationTableExists)
{
await using var migrationCmd = new NpgsqlCommand(
"SELECT \"MigrationId\" FROM \"__EFMigrationsHistory\" ORDER BY \"MigrationId\" DESC LIMIT 1",
connection);
latestMigration = await migrationCmd.ExecuteScalarAsync(ct) as string;
}
// Check for orphaned foreign keys
var orphanedFks = await GetOrphanedForeignKeysCountAsync(connection, ct);
if (orphanedFks > 0)
{
return result
.Warn($"Schema has {orphanedFks} orphaned foreign key constraint(s)")
.WithEvidence("Schema details", e =>
{
e.Add("SchemaCount", schemas.Count.ToString(CultureInfo.InvariantCulture));
e.Add("OrphanedForeignKeys", orphanedFks.ToString(CultureInfo.InvariantCulture));
if (latestMigration != null)
{
e.Add("LatestMigration", latestMigration);
}
foreach (var schema in schemas)
{
e.Add($"Schema_{schema.Name}", $"{schema.TableCount} tables");
}
})
.WithCauses(
"Failed migration left orphaned constraints",
"Manual DDL changes")
.WithRemediation(r => r
.AddShellStep(1, "List orphaned FKs", "psql -c \"SELECT conname FROM pg_constraint WHERE NOT convalidated\"")
.AddManualStep(2, "Review and clean up", "Drop or fix orphaned constraints"))
.WithVerification("stella doctor --check check.db.schema.version")
.Build();
}
var totalTables = schemas.Sum(s => s.TableCount);
return result
.Pass($"Schema healthy: {schemas.Count} schema(s), {totalTables} table(s)")
.WithEvidence("Schema details", e =>
{
e.Add("SchemaCount", schemas.Count.ToString(CultureInfo.InvariantCulture));
e.Add("TotalTables", totalTables.ToString(CultureInfo.InvariantCulture));
e.Add("OrphanedForeignKeys", "0");
if (latestMigration != null)
{
e.Add("LatestMigration", latestMigration);
}
foreach (var schema in schemas)
{
e.Add($"Schema_{schema.Name}", $"{schema.TableCount} tables");
}
})
.Build();
}
private static async Task CheckMigrationTableExistsAsync(NpgsqlConnection connection, CancellationToken ct)
{
await using var cmd = new NpgsqlCommand(
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = '__EFMigrationsHistory')",
connection);
return Convert.ToBoolean(await cmd.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture);
}
private static async Task GetOrphanedForeignKeysCountAsync(NpgsqlConnection connection, CancellationToken ct)
{
await using var cmd = new NpgsqlCommand(
"SELECT COUNT(*) FROM pg_constraint WHERE NOT convalidated",
connection);
return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture);
}
}