139 lines
5.4 KiB
C#
139 lines
5.4 KiB
C#
|
|
using Npgsql;
|
|
using StellaOps.Doctor.Models;
|
|
using StellaOps.Doctor.Plugins;
|
|
using StellaOps.Doctor.Plugins.Builders;
|
|
using System.Globalization;
|
|
|
|
namespace StellaOps.Doctor.Plugins.Database.Checks;
|
|
|
|
/// <summary>
|
|
/// Checks for schema version consistency across database objects.
|
|
/// </summary>
|
|
public sealed class SchemaVersionCheck : DatabaseCheckBase
|
|
{
|
|
/// <inheritdoc />
|
|
public override string CheckId => "check.db.schema.version";
|
|
|
|
/// <inheritdoc />
|
|
public override string Name => "Schema Version";
|
|
|
|
/// <inheritdoc />
|
|
public override string Description => "Verifies database schema version and consistency";
|
|
|
|
/// <inheritdoc />
|
|
public override IReadOnlyList<string> Tags => ["database", "schema", "migrations"];
|
|
|
|
/// <inheritdoc />
|
|
protected override async Task<DoctorCheckResult> 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<bool> 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<int> 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);
|
|
}
|
|
}
|