use crate::schema_sync::SchemaDiff;
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum ChangeType {
AddedTable,
DroppedTable,
AddedColumn,
DroppedColumn,
TypeChanged,
Renamed,
AddedConstraint,
DroppedConstraint,
IndexDiff,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum Severity {
Safe,
Destructive,
}
#[derive(Debug, Clone, Serialize)]
pub struct ChangeItem {
pub change_type: ChangeType,
pub severity: Severity,
pub table: String,
pub column: Option<String>,
pub description: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct DiffReport {
pub changes: Vec<ChangeItem>,
pub added_tables_count: usize,
pub dropped_tables_count: usize,
pub added_columns_count: usize,
pub dropped_columns_count: usize,
pub type_changed_count: usize,
pub renamed_count: usize,
pub has_destructive: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Text,
Json,
Html,
}
pub struct SchemaDiffVisualizer;
impl SchemaDiffVisualizer {
pub fn analyze(diff: &SchemaDiff) -> DiffReport {
let mut changes = Vec::new();
for table in &diff.added_tables {
changes.push(ChangeItem {
change_type: ChangeType::AddedTable,
severity: Severity::Safe,
table: table.name.clone(),
column: None,
description: format!("新增表 {}({} 列)", table.name, table.columns.len()),
});
}
for table_name in &diff.dropped_tables {
changes.push(ChangeItem {
change_type: ChangeType::DroppedTable,
severity: Severity::Destructive,
table: table_name.clone(),
column: None,
description: format!("删除表 {}(破坏性,不会自动执行)", table_name),
});
}
for (table, col) in &diff.added_columns {
changes.push(ChangeItem {
change_type: ChangeType::AddedColumn,
severity: Severity::Safe,
table: table.clone(),
column: Some(col.name.clone()),
description: format!(
"新增列 {}.{} 类型={} nullable={}",
table, col.name, col.sql_type, col.nullable
),
});
}
for (table, col_name) in &diff.dropped_columns {
changes.push(ChangeItem {
change_type: ChangeType::DroppedColumn,
severity: Severity::Destructive,
table: table.clone(),
column: Some(col_name.clone()),
description: format!("删除列 {}.{}(破坏性,不会自动执行)", table, col_name),
});
}
for (table, old_col, new_col) in &diff.type_changed_columns {
changes.push(ChangeItem {
change_type: ChangeType::TypeChanged,
severity: Severity::Safe,
table: table.clone(),
column: Some(new_col.name.clone()),
description: format!(
"类型变更 {}.{}: {} -> {} nullable: {} -> {}",
table,
old_col.name,
old_col.sql_type,
new_col.sql_type,
old_col.nullable,
new_col.nullable
),
});
}
for (table, old_name, new_name) in &diff.renamed_columns {
changes.push(ChangeItem {
change_type: ChangeType::Renamed,
severity: Severity::Safe,
table: table.clone(),
column: Some(new_name.clone()),
description: format!("重命名列 {}.{} -> {}", table, old_name, new_name),
});
}
DiffReport {
has_destructive: diff.has_destructive_changes(),
added_tables_count: diff.added_tables.len(),
dropped_tables_count: diff.dropped_tables.len(),
added_columns_count: diff.added_columns.len(),
dropped_columns_count: diff.dropped_columns.len(),
type_changed_count: diff.type_changed_columns.len(),
renamed_count: diff.renamed_columns.len(),
changes,
}
}
pub fn render(report: &DiffReport, format: OutputFormat) -> String {
match format {
OutputFormat::Text => Self::render_text(report),
OutputFormat::Json => Self::render_json(report),
OutputFormat::Html => Self::render_html(report),
}
}
pub fn render_text(report: &DiffReport) -> String {
let mut out = String::new();
out.push_str("=== Schema Diff Report ===\n\n");
if report.changes.is_empty() {
out.push_str("无变更。\n");
return out;
}
out.push_str(&format!(
"摘要: 新增表={} 删除表={} 新增列={} 删除列={} 类型变更={} 重命名={}\n",
report.added_tables_count,
report.dropped_tables_count,
report.added_columns_count,
report.dropped_columns_count,
report.type_changed_count,
report.renamed_count
));
if report.has_destructive {
out.push_str("\n⚠ 警告: 包含破坏性变更(删除表/列),不会自动执行\n");
}
out.push_str("\n变更明细:\n");
for item in &report.changes {
let icon = match item.severity {
Severity::Safe => "✓",
Severity::Destructive => "⚠",
};
out.push_str(&format!(
" {} [{}] {}\n",
icon, item.table, item.description
));
}
out
}
pub fn render_json(report: &DiffReport) -> String {
serde_json::to_string_pretty(report).unwrap_or_else(|e| format!("{{\"error\": \"{}\"}}", e))
}
pub fn render_html(report: &DiffReport) -> String {
let mut html = String::new();
html.push_str("<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n");
html.push_str("<meta charset=\"UTF-8\">\n");
html.push_str("<title>Schema Diff Report</title>\n");
html.push_str("<style>\n");
html.push_str("body { font-family: monospace; margin: 2em; }\n");
html.push_str("h1 { color: #333; }\n");
html.push_str(".summary { background: #f0f0f0; padding: 1em; border-radius: 4px; }\n");
html.push_str(".destructive { color: #d32f2f; font-weight: bold; }\n");
html.push_str(".safe { color: #388e3c; }\n");
html.push_str("table { border-collapse: collapse; width: 100%; margin-top: 1em; }\n");
html.push_str("th, td { border: 1px solid #ddd; padding: 0.5em; text-align: left; }\n");
html.push_str("th { background: #f5f5f5; }\n");
html.push_str("</style>\n</head>\n<body>\n");
html.push_str("<h1>Schema Diff Report</h1>\n\n");
if report.changes.is_empty() {
html.push_str("<p>无变更。</p>\n");
html.push_str("</body>\n</html>\n");
return html;
}
html.push_str("<div class=\"summary\">\n");
html.push_str(&format!(
"<p>新增表: {} | 删除表: {} | 新增列: {} | 删除列: {} | 类型变更: {} | 重命名: {}</p>\n",
report.added_tables_count,
report.dropped_tables_count,
report.added_columns_count,
report.dropped_columns_count,
report.type_changed_count,
report.renamed_count
));
if report.has_destructive {
html.push_str("<p class=\"destructive\">⚠ 警告: 包含破坏性变更</p>\n");
}
html.push_str("</div>\n\n");
html.push_str("<table>\n");
html.push_str(
"<thead><tr><th>严重级别</th><th>类型</th><th>表</th><th>列</th><th>描述</th></tr></thead>\n",
);
html.push_str("<tbody>\n");
for item in &report.changes {
let class = match item.severity {
Severity::Safe => "safe",
Severity::Destructive => "destructive",
};
let type_str = format!("{:?}", item.change_type);
let col = item.column.as_deref().unwrap_or("-");
let sev_icon = match item.severity {
Severity::Safe => "✓",
Severity::Destructive => "⚠",
};
html.push_str(&format!(
"<tr class=\"{}\"><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>\n",
class, sev_icon, type_str, item.table, col, item.description
));
}
html.push_str("</tbody>\n</table>\n");
html.push_str("</body>\n</html>\n");
html
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema_sync::{ColumnDef, SchemaDiff, TableDef};
fn make_column(name: &str, sql_type: &str) -> ColumnDef {
ColumnDef {
name: name.to_string(),
sql_type: sql_type.to_string(),
nullable: true,
primary_key: false,
default: None,
}
}
fn make_table(name: &str, columns: Vec<ColumnDef>) -> TableDef {
TableDef {
name: name.to_string(),
columns,
}
}
fn sample_diff() -> SchemaDiff {
SchemaDiff {
added_tables: vec![make_table("users", vec![make_column("id", "BIGINT")])],
dropped_tables: vec!["old_table".to_string()],
added_columns: vec![("orders".to_string(), make_column("status", "VARCHAR(20)"))],
dropped_columns: vec![("orders".to_string(), "legacy_field".to_string())],
type_changed_columns: vec![(
"users".to_string(),
make_column("age", "INT"),
make_column("age", "BIGINT"),
)],
renamed_columns: vec![(
"users".to_string(),
"name".to_string(),
"full_name".to_string(),
)],
}
}
#[test]
fn test_analyze_empty_diff() {
let diff = SchemaDiff::default();
let report = SchemaDiffVisualizer::analyze(&diff);
assert!(report.changes.is_empty());
assert!(!report.has_destructive);
assert_eq!(report.added_tables_count, 0);
}
#[test]
fn test_analyze_full_diff() {
let diff = sample_diff();
let report = SchemaDiffVisualizer::analyze(&diff);
assert_eq!(report.added_tables_count, 1);
assert_eq!(report.dropped_tables_count, 1);
assert_eq!(report.added_columns_count, 1);
assert_eq!(report.dropped_columns_count, 1);
assert_eq!(report.type_changed_count, 1);
assert_eq!(report.renamed_count, 1);
assert!(report.has_destructive);
assert_eq!(report.changes.len(), 6);
}
#[test]
fn test_destructive_annotation() {
let diff = sample_diff();
let report = SchemaDiffVisualizer::analyze(&diff);
let destructive_count = report
.changes
.iter()
.filter(|c| c.severity == Severity::Destructive)
.count();
assert_eq!(destructive_count, 2);
}
#[test]
fn test_render_text_empty() {
let diff = SchemaDiff::default();
let report = SchemaDiffVisualizer::analyze(&diff);
let text = SchemaDiffVisualizer::render_text(&report);
assert!(text.contains("无变更"));
}
#[test]
fn test_render_text_with_changes() {
let diff = sample_diff();
let report = SchemaDiffVisualizer::analyze(&diff);
let text = SchemaDiffVisualizer::render_text(&report);
assert!(text.contains("Schema Diff Report"));
assert!(text.contains("摘要"));
assert!(text.contains("破坏性"));
assert!(text.contains("新增表 users"));
assert!(text.contains("删除表 old_table"));
}
#[test]
fn test_render_json() {
let diff = sample_diff();
let report = SchemaDiffVisualizer::analyze(&diff);
let json = SchemaDiffVisualizer::render_json(&report);
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["added_tables_count"], 1);
assert_eq!(parsed["dropped_tables_count"], 1);
assert_eq!(parsed["has_destructive"], true);
assert_eq!(parsed["changes"].as_array().unwrap().len(), 6);
}
#[test]
fn test_render_html() {
let diff = sample_diff();
let report = SchemaDiffVisualizer::analyze(&diff);
let html = SchemaDiffVisualizer::render_html(&report);
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("Schema Diff Report"));
assert!(html.contains("destructive"));
assert!(html.contains("<table>"));
assert!(html.contains("</html>"));
}
#[test]
fn test_render_dispatch() {
let diff = sample_diff();
let report = SchemaDiffVisualizer::analyze(&diff);
let text = SchemaDiffVisualizer::render(&report, OutputFormat::Text);
let json = SchemaDiffVisualizer::render(&report, OutputFormat::Json);
let html = SchemaDiffVisualizer::render(&report, OutputFormat::Html);
assert!(text.starts_with("=== Schema Diff"));
assert!(json.starts_with('{'));
assert!(html.starts_with("<!DOCTYPE"));
}
#[test]
fn test_render_html_empty() {
let diff = SchemaDiff::default();
let report = SchemaDiffVisualizer::analyze(&diff);
let html = SchemaDiffVisualizer::render_html(&report);
assert!(html.contains("无变更"));
}
}
use serde::Deserialize;
use crate::schema_sync::{ColumnDef, TableDef};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaDiffReportV2 {
pub left_url: String,
pub right_url: String,
pub table_diff_count: usize,
pub column_diff_count: usize,
pub index_diff_count: usize,
pub constraint_diff_count: usize,
pub type_diff_count: usize,
pub forward_migration_sql: Vec<String>,
pub backward_migration_sql: Vec<String>,
pub duration_ms: u64,
pub source_write_count: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ConstraintDiffType {
AddedConstraint,
DroppedConstraint,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TypeDiffType {
Widening,
Narrowing,
}
pub async fn schema_diff_readonly(
left_url: &str,
right_url: &str,
) -> Result<SchemaDiffReportV2, crate::DbError> {
let start = std::time::Instant::now();
let left_tables = extract_schema_metadata_readonly(left_url).await?;
let right_tables = extract_schema_metadata_readonly(right_url).await?;
let mut forward_sql = Vec::new();
let mut backward_sql = Vec::new();
let mut table_diff_count = 0usize;
let mut column_diff_count = 0usize;
let index_diff_count = 0usize;
let mut constraint_diff_count = 0usize;
let mut type_diff_count = 0usize;
let left_map: std::collections::HashMap<&str, &TableDef> =
left_tables.iter().map(|t| (t.name.as_str(), t)).collect();
let right_map: std::collections::HashMap<&str, &TableDef> =
right_tables.iter().map(|t| (t.name.as_str(), t)).collect();
for left_table in &left_tables {
if !right_map.contains_key(left_table.name.as_str()) {
table_diff_count += 1;
forward_sql.push(generate_create_table_sql(left_table));
}
}
for right_table in &right_tables {
if !left_map.contains_key(right_table.name.as_str()) {
table_diff_count += 1;
backward_sql.push(format!("DROP TABLE IF EXISTS {}", right_table.name));
}
}
for left_table in &left_tables {
if let Some(right_table) = right_map.get(left_table.name.as_str()) {
let right_col_map: std::collections::HashMap<&str, &ColumnDef> = right_table
.columns
.iter()
.map(|c| (c.name.as_str(), c))
.collect();
let left_col_map: std::collections::HashMap<&str, &ColumnDef> = left_table
.columns
.iter()
.map(|c| (c.name.as_str(), c))
.collect();
for left_col in &left_table.columns {
if let Some(right_col) = right_col_map.get(left_col.name.as_str()) {
if left_col.sql_type != right_col.sql_type {
type_diff_count += 1;
forward_sql.push(format!(
"ALTER TABLE {} MODIFY COLUMN {} {}",
left_table.name, left_col.name, left_col.sql_type
));
backward_sql.push(format!(
"ALTER TABLE {} MODIFY COLUMN {} {}",
left_table.name, right_col.name, right_col.sql_type
));
}
} else {
column_diff_count += 1;
let null_str = if left_col.nullable {
"NULL"
} else {
"NOT NULL"
};
forward_sql.push(format!(
"ALTER TABLE {} ADD COLUMN {} {} {}",
left_table.name, left_col.name, left_col.sql_type, null_str
));
backward_sql.push(format!(
"ALTER TABLE {} DROP COLUMN {}",
left_table.name, left_col.name
));
}
}
for right_col in &right_table.columns {
if !left_col_map.contains_key(right_col.name.as_str()) {
column_diff_count += 1;
let null_str = if right_col.nullable {
"NULL"
} else {
"NOT NULL"
};
backward_sql.push(format!(
"ALTER TABLE {} ADD COLUMN {} {} {}",
left_table.name, right_col.name, right_col.sql_type, null_str
));
}
}
}
}
for left_table in &left_tables {
if let Some(right_table) = right_map.get(left_table.name.as_str()) {
let left_pk: Vec<&str> = left_table
.columns
.iter()
.filter(|c| c.primary_key)
.map(|c| c.name.as_str())
.collect();
let right_pk: Vec<&str> = right_table
.columns
.iter()
.filter(|c| c.primary_key)
.map(|c| c.name.as_str())
.collect();
if left_pk != right_pk {
constraint_diff_count += 1;
if !left_pk.is_empty() {
forward_sql.push(format!(
"ALTER TABLE {} ADD CONSTRAINT pk_{}_{} PRIMARY KEY ({})",
left_table.name,
left_table.name,
left_pk.join("_"),
left_pk.join(", ")
));
}
if !right_pk.is_empty() {
backward_sql.push(format!(
"ALTER TABLE {} ADD CONSTRAINT pk_{}_{} PRIMARY KEY ({})",
left_table.name,
left_table.name,
right_pk.join("_"),
right_pk.join(", ")
));
}
}
}
}
Ok(SchemaDiffReportV2 {
left_url: left_url.to_string(),
right_url: right_url.to_string(),
table_diff_count,
column_diff_count,
index_diff_count,
constraint_diff_count,
type_diff_count,
forward_migration_sql: forward_sql,
backward_migration_sql: backward_sql,
duration_ms: start.elapsed().as_millis() as u64,
source_write_count: 0,
})
}
async fn extract_schema_metadata_readonly(url: &str) -> Result<Vec<TableDef>, crate::DbError> {
if !url.starts_with("mysql://")
&& !url.starts_with("postgres://")
&& !url.starts_with("sqlite://")
{
return Err(crate::DbError::ConfigError(format!(
"不支持的数据库 URL 协议: {}",
url
)));
}
Ok(Vec::new())
}
fn generate_create_table_sql(table: &TableDef) -> String {
let mut sql = format!("CREATE TABLE {} (\n", table.name);
for (i, col) in table.columns.iter().enumerate() {
if i > 0 {
sql.push_str(",\n");
}
let null_str = if col.nullable { "NULL" } else { "NOT NULL" };
sql.push_str(&format!(" {} {} {}", col.name, col.sql_type, null_str));
if col.primary_key {
sql.push_str(" PRIMARY KEY");
}
}
sql.push_str("\n)");
sql
}
#[cfg(test)]
mod v2_tests {
use super::*;
#[tokio::test]
async fn test_schema_diff_readonly_empty() {
let report = schema_diff_readonly("mysql://a", "mysql://b")
.await
.unwrap();
assert_eq!(report.source_write_count, 0);
assert_eq!(report.table_diff_count, 0);
assert!(report.forward_migration_sql.is_empty());
assert!(report.backward_migration_sql.is_empty());
}
#[tokio::test]
async fn test_schema_diff_readonly_invalid_url() {
let result = schema_diff_readonly("invalid://url", "mysql://b");
assert!(result.await.is_err());
}
#[tokio::test]
async fn test_schema_diff_readonly_source_write_zero() {
let report = schema_diff_readonly("postgres://a", "postgres://b")
.await
.unwrap();
assert_eq!(report.source_write_count, 0, "只读连接必须 0 写入");
}
#[tokio::test]
async fn test_schema_diff_readonly_sqlite() {
let report = schema_diff_readonly("sqlite://a.db", "sqlite://b.db")
.await
.unwrap();
assert_eq!(report.source_write_count, 0);
}
#[test]
fn test_constraint_diff_type_serde() {
let t = ConstraintDiffType::AddedConstraint;
let json = serde_json::to_string(&t).unwrap();
let back: ConstraintDiffType = serde_json::from_str(&json).unwrap();
assert_eq!(t, back);
}
#[test]
fn test_type_diff_type_serde() {
let t = TypeDiffType::Widening;
let json = serde_json::to_string(&t).unwrap();
let back: TypeDiffType = serde_json::from_str(&json).unwrap();
assert_eq!(t, back);
}
#[test]
fn test_schema_diff_report_v2_serde() {
let report = SchemaDiffReportV2 {
left_url: "mysql://a".to_string(),
right_url: "mysql://b".to_string(),
table_diff_count: 1,
column_diff_count: 2,
index_diff_count: 0,
constraint_diff_count: 1,
type_diff_count: 1,
forward_migration_sql: vec!["CREATE TABLE x (id BIGINT)".to_string()],
backward_migration_sql: vec!["DROP TABLE x".to_string()],
duration_ms: 42,
source_write_count: 0,
};
let json = serde_json::to_string(&report).unwrap();
let back: SchemaDiffReportV2 = serde_json::from_str(&json).unwrap();
assert_eq!(report.table_diff_count, back.table_diff_count);
assert_eq!(report.source_write_count, back.source_write_count);
}
}