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