use crate::colors::*;
use crate::migrations::types::{MigrationClass, classify_migration};
use anyhow::Result;
use qail_core::migrate::{diff_schemas_checked, parse_qail, parse_qail_file};
use qail_core::prelude::*;
use qail_core::transpiler::Dialect;
#[derive(Clone)]
pub enum OutputFormat {
Sql,
Json,
Pretty,
}
fn cmds_wire_json(cmds: &[Qail], dialect: Dialect) -> serde_json::Value {
let rows = cmds
.iter()
.map(|cmd| {
serde_json::json!({
"wire": qail_core::wire::encode_cmd_text(cmd),
"sql": cmd.to_sql_with_dialect(dialect),
"action": format!("{}", cmd.action),
"table": cmd.table.clone(),
})
})
.collect();
serde_json::Value::Array(rows)
}
pub fn check_schema(
schema_path: &str,
src_dir: Option<&str>,
migrations_dir: &str,
nplus1_deny: bool,
) -> Result<()> {
if schema_path.contains(':') && !schema_path.starts_with("postgres") {
let parts: Vec<&str> = schema_path.splitn(2, ':').collect();
if parts.len() == 2 {
println!(
"{} {} → {}",
"Checking migration:".cyan().bold(),
parts[0].yellow(),
parts[1].yellow()
);
return check_migration(parts[0], parts[1]);
}
}
println!(
"{} {}",
"Checking schema:".cyan().bold(),
schema_path.yellow()
);
let content = qail_core::schema_source::read_qail_schema_source(schema_path)
.map_err(|e| anyhow::anyhow!("Failed to read schema source '{}': {}", schema_path, e))?;
match parse_qail(&content) {
Ok(schema) => {
println!("{}", "✓ Schema is valid".green().bold());
println!(" Tables: {}", schema.tables.len());
let mut total_columns = 0;
let mut primary_keys = 0;
let mut unique_constraints = 0;
for table in schema.tables.values() {
total_columns += table.columns.len();
for col in &table.columns {
if col.primary_key {
primary_keys += 1;
}
if col.unique {
unique_constraints += 1;
}
}
}
println!(" Columns: {}", total_columns);
println!(" Indexes: {}", schema.indexes.len());
println!(" Migration Hints: {}", schema.migrations.len());
if primary_keys > 0 {
println!(" {} {} primary key(s)", "✓".green(), primary_keys);
}
if unique_constraints > 0 {
println!(
" {} {} unique constraint(s)",
"✓".green(),
unique_constraints
);
}
if let Some(src) = src_dir {
println!();
println!("{}", "── Source Validation & RLS Audit ──".cyan().bold());
let mut build_schema = qail_core::build::Schema::parse(&content)
.map_err(|e| anyhow::anyhow!("Failed to parse schema for audit: {}", e))?;
let mig_path = std::path::Path::new(migrations_dir);
if mig_path.exists() {
let merged = build_schema.merge_migrations(migrations_dir).unwrap_or(0);
if merged > 0 {
println!(
" {} Merged {} schema changes from {}",
"✓".green(),
merged,
migrations_dir
);
}
}
let rls_tables = build_schema.rls_tables();
if rls_tables.is_empty() {
println!(" {} No RLS-enabled tables detected", "ℹ".dimmed());
} else {
println!(
" {} {} RLS-enabled table(s): {}",
"🔐".to_string().green(),
rls_tables.len(),
rls_tables.join(", ").yellow()
);
}
let usages = qail_core::build::scan_source_files(src);
if usages.is_empty() {
println!(" {} No Qail queries found in {}", "ℹ".dimmed(), src);
} else {
let diagnostics = qail_core::build::validate_against_schema_diagnostics(
&build_schema,
&usages,
);
let schema_errors: Vec<_> = diagnostics
.iter()
.filter(|d| {
matches!(
d.kind,
qail_core::build::ValidationDiagnosticKind::SchemaError
)
})
.collect();
let rls_warnings: Vec<_> = diagnostics
.iter()
.filter(|d| {
matches!(
d.kind,
qail_core::build::ValidationDiagnosticKind::RlsWarning
)
})
.collect();
let total_queries = usages.len();
let rls_scoped = usages.iter().filter(|u| u.has_rls).count();
let on_rls_tables = usages
.iter()
.filter(|u| build_schema.is_rls_table(&u.table))
.count();
println!(
" {} {} queries scanned in {}",
"✓".green(),
total_queries,
src
);
if schema_errors.is_empty() {
println!(" {} All queries valid against schema", "✓".green());
} else {
println!(" {} {} schema error(s):", "✗".red(), schema_errors.len());
for err in &schema_errors {
println!(" {}", err.message.red());
}
}
if on_rls_tables > 0 {
let coverage = if on_rls_tables > 0 {
(rls_scoped as f64 / on_rls_tables as f64 * 100.0) as u32
} else {
100
};
println!();
println!(
" {} RLS Coverage: {}/{} queries scoped ({}%)",
if rls_warnings.is_empty() {
"✓".green()
} else {
"⚠".yellow()
},
rls_scoped,
on_rls_tables,
if coverage == 100 {
format!("{}", coverage).green()
} else {
format!("{}", coverage).yellow()
}
);
if !rls_warnings.is_empty() {
println!();
println!(
" {} {} unscoped query(ies) on RLS tables:",
"⚠".yellow(),
rls_warnings.len()
);
for warn in &rls_warnings {
println!(" {}", warn.message.yellow());
}
}
}
}
println!();
println!("{}", "── N+1 Query Detection ──".cyan().bold());
let diagnostics =
qail_core::analyzer::detect_n_plus_one_in_dir(std::path::Path::new(src));
if diagnostics.is_empty() {
println!(" {} No N+1 patterns detected", "✓".green());
} else {
let errors: Vec<_> = diagnostics
.iter()
.filter(|d| d.severity == qail_core::analyzer::NPlusOneSeverity::Error)
.collect();
let warnings: Vec<_> = diagnostics
.iter()
.filter(|d| d.severity == qail_core::analyzer::NPlusOneSeverity::Warning)
.collect();
if !errors.is_empty() {
println!(" {} {} N+1 error(s):", "✗".red(), errors.len());
for diag in &errors {
println!(" {} {}", diag.code.as_str().red(), diag);
}
}
if !warnings.is_empty() {
println!(" {} {} N+1 warning(s):", "⚠".yellow(), warnings.len());
for diag in &warnings {
println!(" {} {}", diag.code.as_str().yellow(), diag);
}
}
if nplus1_deny {
return Err(anyhow::anyhow!(
"N+1 detection: {} diagnostic(s) found (--nplus1-deny is set)",
diagnostics.len()
));
}
}
}
Ok(())
}
Err(e) => {
println!("{} {}", "✗ Schema validation failed:".red().bold(), e);
Err(anyhow::anyhow!("Schema is invalid"))
}
}
}
pub fn check_migration(old_path: &str, new_path: &str) -> Result<()> {
let old_schema = parse_qail_file(old_path)
.map_err(|e| anyhow::anyhow!("Failed to parse old schema: {}", e))?;
let new_schema = parse_qail_file(new_path)
.map_err(|e| anyhow::anyhow!("Failed to parse new schema: {}", e))?;
println!("{}", "✓ Both schemas are valid".green().bold());
let cmds = diff_schemas_checked(&old_schema, &new_schema)
.map_err(|e| anyhow::anyhow!("State-based diff unsupported for this schema pair: {}", e))?;
if cmds.is_empty() {
println!(
"{}",
"✓ No migration needed - schemas are identical".green()
);
return Ok(());
}
println!(
"{} {} operation(s)",
"Migration preview:".cyan().bold(),
cmds.len()
);
let mut safe_ops = 0;
let mut reversible_ops = 0;
let mut destructive_ops = 0;
for cmd in &cmds {
match cmd.action {
Action::Make | Action::Alter | Action::Index => safe_ops += 1,
Action::Set | Action::Mod => reversible_ops += 1,
Action::Drop | Action::AlterDrop | Action::DropIndex => destructive_ops += 1,
_ => {}
}
}
if safe_ops > 0 {
println!(
" {} {} safe operation(s) (CREATE TABLE, ADD COLUMN, CREATE INDEX)",
"✓".green(),
safe_ops
);
}
if reversible_ops > 0 {
println!(
" {} {} reversible operation(s) (UPDATE, RENAME)",
"⚠️ ".yellow(),
reversible_ops
);
}
if destructive_ops > 0 {
println!(
" {} {} destructive operation(s) (DROP)",
"⚠️ ".red(),
destructive_ops
);
println!(
" {} Review carefully before applying!",
"⚠ WARNING:".red().bold()
);
}
Ok(())
}
pub fn diff_schemas_cmd(
old_path: &str,
new_path: &str,
format: OutputFormat,
dialect: Dialect,
) -> Result<()> {
println!(
"{} {} → {}",
"Diffing:".cyan(),
old_path.yellow(),
new_path.yellow()
);
let old_schema = parse_qail_file(old_path)
.map_err(|e| anyhow::anyhow!("Failed to parse old schema: {}", e))?;
let new_schema = parse_qail_file(new_path)
.map_err(|e| anyhow::anyhow!("Failed to parse new schema: {}", e))?;
let cmds = diff_schemas_checked(&old_schema, &new_schema)
.map_err(|e| anyhow::anyhow!("State-based diff unsupported for this schema pair: {}", e))?;
if cmds.is_empty() {
println!("{}", "No changes detected.".green());
return Ok(());
}
println!("{} {} migration command(s):", "Found:".green(), cmds.len());
println!();
match format {
OutputFormat::Sql => {
for cmd in &cmds {
println!("{};", cmd.to_sql_with_dialect(dialect));
}
}
OutputFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(&cmds_wire_json(&cmds, dialect))?
);
}
OutputFormat::Pretty => {
for (i, cmd) in cmds.iter().enumerate() {
let class = classify_migration(cmd);
let class_str = match class {
MigrationClass::Reversible => "reversible".green(),
MigrationClass::DataLosing => "data-losing".red(),
MigrationClass::Irreversible => "irreversible".red().bold(),
};
println!(
"{} {} {}",
format!("{}.", i + 1).cyan(),
format!("{}", cmd.action).yellow(),
cmd.table.white()
);
println!(" {}", cmd.to_sql_with_dialect(dialect).dimmed());
println!(" Class: {}", class_str);
}
}
}
Ok(())
}
pub async fn diff_live(
db_url: &str,
new_path: &str,
format: OutputFormat,
dialect: Dialect,
) -> Result<()> {
use qail_pg::driver::PgDriver;
println!(
"{} {} → {}",
"Drift detection:".cyan().bold(),
"[live DB]".yellow(),
new_path.yellow()
);
println!(" {} Introspecting live database...", "→".dimmed());
let mut driver = PgDriver::connect_url(db_url)
.await
.map_err(|e| anyhow::anyhow!("Connection failed: {}", e))?;
let live_schema = crate::shadow::introspect_schema(&mut driver).await?;
println!(
" {} tables, {} indexes introspected",
live_schema.tables.len().to_string().green(),
live_schema.indexes.len().to_string().green()
);
let new_schema =
parse_qail_file(new_path).map_err(|e| anyhow::anyhow!("Failed to parse schema: {}", e))?;
let cmds = diff_schemas_checked(&live_schema, &new_schema)
.map_err(|e| anyhow::anyhow!("State-based diff unsupported for this schema pair: {}", e))?;
if cmds.is_empty() {
println!(
"\n{}",
"✅ No drift detected — live DB matches schema file."
.green()
.bold()
);
return Ok(());
}
println!(
"\n{} {} drift(s) detected:\n",
"⚠️".yellow(),
cmds.len().to_string().red().bold()
);
match format {
OutputFormat::Sql => {
for cmd in &cmds {
println!("{};", cmd.to_sql_with_dialect(dialect));
}
}
OutputFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(&cmds_wire_json(&cmds, dialect))?
);
}
OutputFormat::Pretty => {
for (i, cmd) in cmds.iter().enumerate() {
let class = classify_migration(cmd);
let class_str = match class {
MigrationClass::Reversible => "reversible".green(),
MigrationClass::DataLosing => "data-losing".red(),
MigrationClass::Irreversible => "irreversible".red().bold(),
};
println!(
"{} {} {}",
format!("{}.", i + 1).cyan(),
format!("{}", cmd.action).yellow(),
cmd.table.white()
);
println!(" {}", cmd.to_sql_with_dialect(dialect).dimmed());
println!(" Class: {}", class_str);
}
}
}
Ok(())
}