use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum RepairKind {
CreateSchema,
CreateTable,
AddColumn,
SetDefault,
DropDefault,
SetNotNull,
DropNotNull,
EnableRls,
CreateUniqueIndex,
SetComment,
QdrantCreateCollection,
QdrantCreateIndex,
QdrantRecreateCollection,
MinioBucketCreate,
MinioBucketPolicy,
CreateIndex,
DropIndex,
CreateCheck,
CreateForeignKey,
ValidateForeignKey,
EnableRlsForce,
RefreshMaterializedView,
}
impl RepairKind {
pub fn is_auto_safe(&self) -> bool {
matches!(
self,
RepairKind::CreateSchema
| RepairKind::CreateTable
| RepairKind::AddColumn
| RepairKind::SetDefault
| RepairKind::DropDefault
| RepairKind::SetNotNull
| RepairKind::DropNotNull
| RepairKind::EnableRls
| RepairKind::CreateUniqueIndex
| RepairKind::SetComment
| RepairKind::CreateIndex
| RepairKind::CreateCheck
| RepairKind::CreateForeignKey | RepairKind::ValidateForeignKey | RepairKind::EnableRlsForce
| RepairKind::RefreshMaterializedView
| RepairKind::QdrantCreateCollection
| RepairKind::QdrantCreateIndex
| RepairKind::MinioBucketCreate
| RepairKind::MinioBucketPolicy
)
}
pub fn as_str(&self) -> &'static str {
match self {
Self::CreateSchema => "create_schema",
Self::CreateTable => "create_table",
Self::AddColumn => "add_column",
Self::SetDefault => "set_default",
Self::DropDefault => "drop_default",
Self::SetNotNull => "set_not_null",
Self::DropNotNull => "drop_not_null",
Self::EnableRls => "enable_rls",
Self::CreateUniqueIndex => "create_unique_index",
Self::SetComment => "set_comment",
Self::CreateIndex => "create_index",
Self::DropIndex => "drop_index",
Self::CreateCheck => "create_check",
Self::CreateForeignKey => "create_foreign_key",
Self::ValidateForeignKey => "validate_foreign_key",
Self::EnableRlsForce => "enable_rls_force",
Self::RefreshMaterializedView => "refresh_materialized_view",
Self::QdrantCreateCollection => "qdrant_create_collection",
Self::QdrantCreateIndex => "qdrant_create_index",
Self::QdrantRecreateCollection => "qdrant_recreate_collection",
Self::MinioBucketCreate => "minio_bucket_create",
Self::MinioBucketPolicy => "minio_bucket_policy",
}
}
pub fn tier(&self) -> &'static str {
match self {
Self::CreateSchema
| Self::CreateTable
| Self::AddColumn
| Self::SetDefault
| Self::DropDefault
| Self::SetNotNull
| Self::DropNotNull
| Self::EnableRls
| Self::CreateUniqueIndex
| Self::SetComment
| Self::CreateIndex
| Self::DropIndex
| Self::CreateCheck
| Self::CreateForeignKey
| Self::ValidateForeignKey
| Self::EnableRlsForce
| Self::RefreshMaterializedView => "sql",
Self::QdrantCreateCollection
| Self::QdrantCreateIndex
| Self::QdrantRecreateCollection => "vector",
Self::MinioBucketCreate | Self::MinioBucketPolicy => "object",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepairDecision {
pub kind: RepairKind,
pub schema: String,
pub table: String,
pub column: String,
pub ddl: String,
pub reason: String,
pub is_auto_safe: bool,
pub source_lint_kind: String,
}
impl RepairDecision {
pub fn schema(
schema: impl Into<String>,
ddl: impl Into<String>,
reason: impl Into<String>,
) -> Self {
let schema = schema.into();
Self {
kind: RepairKind::CreateSchema,
ddl: ddl.into(),
reason: reason.into(),
is_auto_safe: true,
source_lint_kind: "missing_schema".to_string(),
table: String::new(),
column: String::new(),
schema,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RepairPlan {
pub decisions: Vec<RepairDecision>,
pub auto_safe_count: usize,
pub requires_review_count: usize,
}
impl RepairPlan {
pub fn auto_safe_decisions(&self) -> impl Iterator<Item = &RepairDecision> {
self.decisions.iter().filter(|d| d.is_auto_safe)
}
pub fn review_decisions(&self) -> impl Iterator<Item = &RepairDecision> {
self.decisions.iter().filter(|d| !d.is_auto_safe)
}
}
pub fn plan_repairs(lint_items: &[LintInput]) -> RepairPlan {
let mut decisions: Vec<RepairDecision> = Vec::new();
let ordering = [
"missing_schema",
"missing_table",
"missing_column",
"default_mismatch",
"nullability_mismatch",
"rls_enabled_no_policies",
];
for &kind in &ordering {
for item in lint_items.iter().filter(|i| i.lint_kind == kind) {
if let Some(decision) = lint_to_repair(item) {
decisions.push(decision);
}
}
}
let auto_safe_count = decisions.iter().filter(|d| d.is_auto_safe).count();
let requires_review_count = decisions.len() - auto_safe_count;
RepairPlan {
decisions,
auto_safe_count,
requires_review_count,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LintInput {
pub lint_kind: String,
pub schema: String,
pub table: String,
pub column: String,
pub sql_type: String,
pub default_value: String,
}
fn lint_to_repair(item: &LintInput) -> Option<RepairDecision> {
let qi = |s: &str| format!("\"{}\"", s.replace('"', "\"\""));
let (kind, ddl, source) = match item.lint_kind.as_str() {
"missing_schema" => (
RepairKind::CreateSchema,
format!("CREATE SCHEMA IF NOT EXISTS {};", qi(&item.schema)),
"missing_schema",
),
"missing_table" => (
RepairKind::CreateTable,
format!(
"CREATE TABLE IF NOT EXISTS {}.{} (id BIGSERIAL PRIMARY KEY);",
qi(&item.schema),
qi(&item.table)
),
"missing_table",
),
"missing_column" => {
let sql_type = if item.sql_type.is_empty() {
"TEXT"
} else {
item.sql_type.as_str()
};
(
RepairKind::AddColumn,
format!(
"ALTER TABLE {}.{} ADD COLUMN IF NOT EXISTS {} {};",
qi(&item.schema),
qi(&item.table),
qi(&item.column),
sql_type
),
"missing_column",
)
}
"default_mismatch" => (
RepairKind::SetDefault,
if item.default_value.is_empty() {
format!(
"ALTER TABLE {}.{} ALTER COLUMN {} DROP DEFAULT;",
qi(&item.schema),
qi(&item.table),
qi(&item.column)
)
} else {
format!(
"ALTER TABLE {}.{} ALTER COLUMN {} SET DEFAULT {};",
qi(&item.schema),
qi(&item.table),
qi(&item.column),
item.default_value
)
},
"default_mismatch",
),
"nullability_mismatch" => (
RepairKind::SetNotNull,
format!(
"ALTER TABLE {}.{} ALTER COLUMN {} SET NOT NULL;",
qi(&item.schema),
qi(&item.table),
qi(&item.column)
),
"nullability_mismatch",
),
"missing_index" => (
RepairKind::CreateIndex,
format!(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_{schema}_{table}_{col} \
ON {qs}.{qt} USING BTREE ({qc});",
schema = item.schema.replace('"', ""),
table = item.table.replace('"', ""),
col = item.column.replace('"', ""),
qs = qi(&item.schema),
qt = qi(&item.table),
qc = qi(&item.column),
),
"missing_index",
),
"stale_materialized_view" => (
RepairKind::RefreshMaterializedView,
format!(
"REFRESH MATERIALIZED VIEW CONCURRENTLY {}.{};",
qi(&item.schema),
qi(&item.table)
),
"stale_materialized_view",
),
"rls_not_forced" => (
RepairKind::EnableRlsForce,
format!(
"ALTER TABLE {}.{} FORCE ROW LEVEL SECURITY;",
qi(&item.schema),
qi(&item.table)
),
"rls_not_forced",
),
"fk_target_not_in_manifest" => (
RepairKind::CreateForeignKey,
String::new(),
"fk_target_not_in_manifest",
),
_ => return None,
};
Some(RepairDecision {
is_auto_safe: kind.is_auto_safe(),
kind,
schema: item.schema.clone(),
table: item.table.clone(),
column: item.column.clone(),
ddl,
reason: format!("auto-repair triggered by lint kind '{}'", source),
source_lint_kind: source.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_repair_kinds_are_auto_safe() {
for kind in [
RepairKind::CreateSchema,
RepairKind::CreateTable,
RepairKind::AddColumn,
RepairKind::SetDefault,
RepairKind::DropDefault,
RepairKind::SetNotNull,
RepairKind::DropNotNull,
RepairKind::EnableRls,
RepairKind::CreateUniqueIndex,
RepairKind::SetComment,
RepairKind::CreateIndex,
RepairKind::CreateCheck,
RepairKind::CreateForeignKey,
RepairKind::ValidateForeignKey,
RepairKind::EnableRlsForce,
RepairKind::RefreshMaterializedView,
RepairKind::QdrantCreateCollection,
RepairKind::QdrantCreateIndex,
RepairKind::MinioBucketCreate,
RepairKind::MinioBucketPolicy,
] {
assert!(kind.is_auto_safe(), "{} should be auto-safe", kind.as_str());
}
}
#[test]
fn drop_index_requires_review() {
assert!(
!RepairKind::DropIndex.is_auto_safe(),
"drop_index must NOT be auto-safe"
);
}
#[test]
fn qdrant_recreate_collection_requires_review() {
assert!(
!RepairKind::QdrantRecreateCollection.is_auto_safe(),
"qdrant_recreate_collection must NOT be auto-safe (data loss risk)"
);
assert_eq!(RepairKind::QdrantRecreateCollection.tier(), "vector");
}
#[test]
fn repair_kinds_have_correct_tiers() {
assert_eq!(RepairKind::CreateSchema.tier(), "sql");
assert_eq!(RepairKind::QdrantCreateCollection.tier(), "vector");
assert_eq!(RepairKind::MinioBucketCreate.tier(), "object");
}
#[test]
fn plan_repairs_missing_schema() {
let items = vec![LintInput {
lint_kind: "missing_schema".to_string(),
schema: "app_intake".to_string(),
table: String::new(),
column: String::new(),
sql_type: String::new(),
default_value: String::new(),
}];
let plan = plan_repairs(&items);
assert_eq!(plan.auto_safe_count, 1);
assert_eq!(plan.requires_review_count, 0);
assert!(
plan.decisions[0]
.ddl
.contains("CREATE SCHEMA IF NOT EXISTS")
);
}
#[test]
fn plan_repairs_add_column() {
let items = vec![LintInput {
lint_kind: "missing_column".to_string(),
schema: "app_intake".to_string(),
table: "document_cases".to_string(),
column: "tenant_id".to_string(),
sql_type: "TEXT".to_string(),
default_value: String::new(),
}];
let plan = plan_repairs(&items);
assert_eq!(plan.auto_safe_count, 1);
let ddl = &plan.decisions[0].ddl;
assert!(ddl.contains("ADD COLUMN IF NOT EXISTS"));
assert!(ddl.contains("tenant_id"));
}
#[test]
fn plan_repairs_unknown_kind_is_skipped() {
let items = vec![LintInput {
lint_kind: "pii_not_masked_in_logs".to_string(),
schema: "ocr".to_string(),
table: "users".to_string(),
column: "email".to_string(),
sql_type: String::new(),
default_value: String::new(),
}];
let plan = plan_repairs(&items);
assert_eq!(plan.decisions.len(), 0);
}
}