1051 lines
41 KiB
Rust
1051 lines
41 KiB
Rust
use std::collections::{HashMap, HashSet};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::models::connection::DatabaseType;
|
|
use crate::types::{ColumnInfo, ForeignKeyInfo, IndexInfo, TableInfo, TriggerInfo};
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ColumnDiff {
|
|
#[serde(rename = "type")]
|
|
pub diff_type: String,
|
|
pub name: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub source: Option<ColumnInfo>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub target: Option<ColumnInfo>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub changes: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct IndexDiff {
|
|
#[serde(rename = "type")]
|
|
pub diff_type: String,
|
|
pub name: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub source: Option<IndexInfo>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub target: Option<IndexInfo>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub changes: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ForeignKeyDiff {
|
|
#[serde(rename = "type")]
|
|
pub diff_type: String,
|
|
pub name: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub source: Option<ForeignKeyInfo>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub target: Option<ForeignKeyInfo>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub changes: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct TriggerDiff {
|
|
#[serde(rename = "type")]
|
|
pub diff_type: String,
|
|
pub name: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub source: Option<TriggerInfo>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub target: Option<TriggerInfo>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub changes: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct TableDiff {
|
|
#[serde(rename = "type")]
|
|
pub diff_type: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub object_type: Option<String>,
|
|
pub name: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub columns: Option<Vec<ColumnDiff>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub indexes: Option<Vec<IndexDiff>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub foreign_keys: Option<Vec<ForeignKeyDiff>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub triggers: Option<Vec<TriggerDiff>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub ddl: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub source_table_comment: Option<Option<String>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub target_table_comment: Option<Option<String>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct TableSchemaDetail {
|
|
pub name: String,
|
|
#[serde(default)]
|
|
pub columns: Vec<ColumnInfo>,
|
|
#[serde(default)]
|
|
pub indexes: Vec<IndexInfo>,
|
|
#[serde(default)]
|
|
pub foreign_keys: Vec<ForeignKeyInfo>,
|
|
#[serde(default)]
|
|
pub triggers: Vec<TriggerInfo>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub ddl: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SchemaDiffPreparationOptions {
|
|
#[serde(default)]
|
|
pub source_tables: Vec<TableInfo>,
|
|
#[serde(default)]
|
|
pub target_tables: Vec<TableInfo>,
|
|
#[serde(default)]
|
|
pub source_details: Vec<TableSchemaDetail>,
|
|
#[serde(default)]
|
|
pub target_details: Vec<TableSchemaDetail>,
|
|
pub database_type: DatabaseType,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub target_schema: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SchemaDiffPreparation {
|
|
pub diffs: Vec<TableDiff>,
|
|
pub sync_sql: String,
|
|
}
|
|
|
|
pub fn prepare_schema_diff(options: SchemaDiffPreparationOptions) -> SchemaDiffPreparation {
|
|
let diffs = diff_schema(&options);
|
|
let sync_sql = generate_schema_sync_sql(&diffs, options.database_type, options.target_schema.as_deref());
|
|
SchemaDiffPreparation { diffs, sync_sql }
|
|
}
|
|
|
|
fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec<TableDiff> {
|
|
let source_details: HashMap<&str, &TableSchemaDetail> =
|
|
options.source_details.iter().map(|detail| (detail.name.as_str(), detail)).collect();
|
|
let target_details: HashMap<&str, &TableSchemaDetail> =
|
|
options.target_details.iter().map(|detail| (detail.name.as_str(), detail)).collect();
|
|
let source_table_comments: HashMap<&str, Option<String>> =
|
|
options.source_tables.iter().map(|table| (table.name.as_str(), table.comment.clone())).collect();
|
|
let target_table_comments: HashMap<&str, Option<String>> =
|
|
options.target_tables.iter().map(|table| (table.name.as_str(), table.comment.clone())).collect();
|
|
|
|
let source_table_names: Vec<String> = options
|
|
.source_tables
|
|
.iter()
|
|
.filter(|table| table.table_type != "VIEW")
|
|
.map(|table| table.name.clone())
|
|
.collect();
|
|
let target_table_names: Vec<String> = options
|
|
.target_tables
|
|
.iter()
|
|
.filter(|table| table.table_type != "VIEW")
|
|
.map(|table| table.name.clone())
|
|
.collect();
|
|
let source_view_names: Vec<String> = options
|
|
.source_tables
|
|
.iter()
|
|
.filter(|table| table.table_type == "VIEW")
|
|
.map(|table| table.name.clone())
|
|
.collect();
|
|
let target_view_names: Vec<String> = options
|
|
.target_tables
|
|
.iter()
|
|
.filter(|table| table.table_type == "VIEW")
|
|
.map(|table| table.name.clone())
|
|
.collect();
|
|
|
|
let (added, removed, common) = diff_names(&source_table_names, &target_table_names);
|
|
let (added_views, removed_views, _) = diff_names(&source_view_names, &target_view_names);
|
|
let mut result = Vec::new();
|
|
|
|
for name in added {
|
|
result.push(TableDiff {
|
|
diff_type: "added".to_string(),
|
|
object_type: Some("table".to_string()),
|
|
ddl: source_details.get(name.as_str()).and_then(|detail| detail.ddl.clone()),
|
|
name,
|
|
columns: None,
|
|
indexes: None,
|
|
foreign_keys: None,
|
|
triggers: None,
|
|
source_table_comment: None,
|
|
target_table_comment: None,
|
|
});
|
|
}
|
|
|
|
for name in removed {
|
|
result.push(TableDiff {
|
|
diff_type: "removed".to_string(),
|
|
object_type: Some("table".to_string()),
|
|
name,
|
|
columns: None,
|
|
indexes: None,
|
|
foreign_keys: None,
|
|
triggers: None,
|
|
ddl: None,
|
|
source_table_comment: None,
|
|
target_table_comment: None,
|
|
});
|
|
}
|
|
|
|
for name in added_views {
|
|
result.push(TableDiff {
|
|
diff_type: "added".to_string(),
|
|
object_type: Some("view".to_string()),
|
|
name,
|
|
columns: None,
|
|
indexes: None,
|
|
foreign_keys: None,
|
|
triggers: None,
|
|
ddl: None,
|
|
source_table_comment: None,
|
|
target_table_comment: None,
|
|
});
|
|
}
|
|
|
|
for name in removed_views {
|
|
result.push(TableDiff {
|
|
diff_type: "removed".to_string(),
|
|
object_type: Some("view".to_string()),
|
|
name,
|
|
columns: None,
|
|
indexes: None,
|
|
foreign_keys: None,
|
|
triggers: None,
|
|
ddl: None,
|
|
source_table_comment: None,
|
|
target_table_comment: None,
|
|
});
|
|
}
|
|
|
|
for name in common {
|
|
let Some(source) = source_details.get(name.as_str()) else { continue };
|
|
let Some(target) = target_details.get(name.as_str()) else { continue };
|
|
let column_diffs = diff_columns(&source.columns, &target.columns);
|
|
let index_diffs = diff_indexes(&source.indexes, &target.indexes);
|
|
let foreign_key_diffs = diff_foreign_keys(&source.foreign_keys, &target.foreign_keys);
|
|
let trigger_diffs = diff_triggers(&source.triggers, &target.triggers);
|
|
let source_comment = source_table_comments.get(name.as_str()).cloned().unwrap_or(None);
|
|
let target_comment = target_table_comments.get(name.as_str()).cloned().unwrap_or(None);
|
|
let comment_changed = source_comment.clone().unwrap_or_default() != target_comment.clone().unwrap_or_default();
|
|
|
|
if !column_diffs.is_empty()
|
|
|| !index_diffs.is_empty()
|
|
|| !foreign_key_diffs.is_empty()
|
|
|| !trigger_diffs.is_empty()
|
|
|| comment_changed
|
|
{
|
|
result.push(TableDiff {
|
|
diff_type: "modified".to_string(),
|
|
object_type: Some("table".to_string()),
|
|
name,
|
|
columns: (!column_diffs.is_empty()).then_some(column_diffs),
|
|
indexes: (!index_diffs.is_empty()).then_some(index_diffs),
|
|
foreign_keys: (!foreign_key_diffs.is_empty()).then_some(foreign_key_diffs),
|
|
triggers: (!trigger_diffs.is_empty()).then_some(trigger_diffs),
|
|
ddl: None,
|
|
source_table_comment: comment_changed.then_some(source_comment),
|
|
target_table_comment: comment_changed.then_some(target_comment),
|
|
});
|
|
}
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
fn diff_names(source: &[String], target: &[String]) -> (Vec<String>, Vec<String>, Vec<String>) {
|
|
let source_set: HashSet<&str> = source.iter().map(String::as_str).collect();
|
|
let target_set: HashSet<&str> = target.iter().map(String::as_str).collect();
|
|
(
|
|
source.iter().filter(|name| !target_set.contains(name.as_str())).cloned().collect(),
|
|
target.iter().filter(|name| !source_set.contains(name.as_str())).cloned().collect(),
|
|
source.iter().filter(|name| target_set.contains(name.as_str())).cloned().collect(),
|
|
)
|
|
}
|
|
|
|
pub fn diff_columns(source: &[ColumnInfo], target: &[ColumnInfo]) -> Vec<ColumnDiff> {
|
|
let mut diffs = Vec::new();
|
|
let target_map: HashMap<&str, &ColumnInfo> = target.iter().map(|column| (column.name.as_str(), column)).collect();
|
|
let source_map: HashMap<&str, &ColumnInfo> = source.iter().map(|column| (column.name.as_str(), column)).collect();
|
|
|
|
for source_column in source {
|
|
if let Some(target_column) = target_map.get(source_column.name.as_str()) {
|
|
let mut changes = Vec::new();
|
|
if source_column.data_type.to_lowercase() != target_column.data_type.to_lowercase() {
|
|
changes.push(format!("type: {} → {}", target_column.data_type, source_column.data_type));
|
|
}
|
|
if source_column.is_nullable != target_column.is_nullable {
|
|
changes.push(format!(
|
|
"nullable: {} → {}",
|
|
if target_column.is_nullable { "YES" } else { "NO" },
|
|
if source_column.is_nullable { "YES" } else { "NO" }
|
|
));
|
|
}
|
|
if source_column.column_default.as_deref().unwrap_or_default()
|
|
!= target_column.column_default.as_deref().unwrap_or_default()
|
|
{
|
|
changes.push(format!(
|
|
"default: {} → {}",
|
|
target_column.column_default.as_deref().unwrap_or("NULL"),
|
|
source_column.column_default.as_deref().unwrap_or("NULL")
|
|
));
|
|
}
|
|
if source_column.comment.as_deref().unwrap_or_default()
|
|
!= target_column.comment.as_deref().unwrap_or_default()
|
|
{
|
|
changes.push(format!(
|
|
"comment: {} → {}",
|
|
target_column.comment.as_deref().unwrap_or_default(),
|
|
source_column.comment.as_deref().unwrap_or_default()
|
|
));
|
|
}
|
|
if !changes.is_empty() {
|
|
diffs.push(ColumnDiff {
|
|
diff_type: "modified".to_string(),
|
|
name: source_column.name.clone(),
|
|
source: Some(source_column.clone()),
|
|
target: Some((*target_column).clone()),
|
|
changes,
|
|
});
|
|
}
|
|
} else {
|
|
diffs.push(ColumnDiff {
|
|
diff_type: "added".to_string(),
|
|
name: source_column.name.clone(),
|
|
source: Some(source_column.clone()),
|
|
target: None,
|
|
changes: Vec::new(),
|
|
});
|
|
}
|
|
}
|
|
|
|
for target_column in target {
|
|
if !source_map.contains_key(target_column.name.as_str()) {
|
|
diffs.push(ColumnDiff {
|
|
diff_type: "removed".to_string(),
|
|
name: target_column.name.clone(),
|
|
source: None,
|
|
target: Some(target_column.clone()),
|
|
changes: Vec::new(),
|
|
});
|
|
}
|
|
}
|
|
|
|
diffs
|
|
}
|
|
|
|
pub fn diff_indexes(source: &[IndexInfo], target: &[IndexInfo]) -> Vec<IndexDiff> {
|
|
let mut diffs = Vec::new();
|
|
let target_map: HashMap<&str, &IndexInfo> = target.iter().map(|index| (index.name.as_str(), index)).collect();
|
|
let source_map: HashMap<&str, &IndexInfo> = source.iter().map(|index| (index.name.as_str(), index)).collect();
|
|
|
|
for source_index in source {
|
|
if source_index.is_primary {
|
|
continue;
|
|
}
|
|
let Some(target_index) = target_map.get(source_index.name.as_str()) else {
|
|
diffs.push(IndexDiff {
|
|
diff_type: "added".to_string(),
|
|
name: source_index.name.clone(),
|
|
source: Some(source_index.clone()),
|
|
target: None,
|
|
changes: Vec::new(),
|
|
});
|
|
continue;
|
|
};
|
|
|
|
let mut changes = Vec::new();
|
|
if source_index.is_unique != target_index.is_unique {
|
|
changes.push(format!(
|
|
"unique: {} → {}",
|
|
if target_index.is_unique { "YES" } else { "NO" },
|
|
if source_index.is_unique { "YES" } else { "NO" }
|
|
));
|
|
}
|
|
if source_index.columns.join(",") != target_index.columns.join(",") {
|
|
changes.push(format!("columns: {} → {}", target_index.columns.join(", "), source_index.columns.join(", ")));
|
|
}
|
|
if source_index.index_type.as_deref().unwrap_or_default()
|
|
!= target_index.index_type.as_deref().unwrap_or_default()
|
|
{
|
|
changes.push(format!(
|
|
"type: {} → {}",
|
|
target_index.index_type.as_deref().unwrap_or("default"),
|
|
source_index.index_type.as_deref().unwrap_or("default")
|
|
));
|
|
}
|
|
if source_index.filter.as_deref().unwrap_or_default() != target_index.filter.as_deref().unwrap_or_default() {
|
|
changes.push(format!(
|
|
"filter: {} → {}",
|
|
target_index.filter.as_deref().unwrap_or("none"),
|
|
source_index.filter.as_deref().unwrap_or("none")
|
|
));
|
|
}
|
|
let source_included = source_index.included_columns.clone().unwrap_or_default();
|
|
let target_included = target_index.included_columns.clone().unwrap_or_default();
|
|
if source_included.join(",") != target_included.join(",") {
|
|
changes.push(format!(
|
|
"include: {} → {}",
|
|
if target_included.is_empty() { "none".to_string() } else { target_included.join(", ") },
|
|
if source_included.is_empty() { "none".to_string() } else { source_included.join(", ") }
|
|
));
|
|
}
|
|
if !changes.is_empty() {
|
|
diffs.push(IndexDiff {
|
|
diff_type: "modified".to_string(),
|
|
name: source_index.name.clone(),
|
|
source: Some(source_index.clone()),
|
|
target: Some((*target_index).clone()),
|
|
changes,
|
|
});
|
|
}
|
|
}
|
|
|
|
for target_index in target {
|
|
if target_index.is_primary {
|
|
continue;
|
|
}
|
|
if !source_map.contains_key(target_index.name.as_str()) {
|
|
diffs.push(IndexDiff {
|
|
diff_type: "removed".to_string(),
|
|
name: target_index.name.clone(),
|
|
source: None,
|
|
target: Some(target_index.clone()),
|
|
changes: Vec::new(),
|
|
});
|
|
}
|
|
}
|
|
|
|
diffs
|
|
}
|
|
|
|
pub fn diff_foreign_keys(source: &[ForeignKeyInfo], target: &[ForeignKeyInfo]) -> Vec<ForeignKeyDiff> {
|
|
let mut diffs = Vec::new();
|
|
let target_map: HashMap<&str, &ForeignKeyInfo> = target.iter().map(|fk| (fk.name.as_str(), fk)).collect();
|
|
let source_map: HashMap<&str, &ForeignKeyInfo> = source.iter().map(|fk| (fk.name.as_str(), fk)).collect();
|
|
|
|
for source_fk in source {
|
|
let Some(target_fk) = target_map.get(source_fk.name.as_str()) else {
|
|
diffs.push(ForeignKeyDiff {
|
|
diff_type: "added".to_string(),
|
|
name: source_fk.name.clone(),
|
|
source: Some(source_fk.clone()),
|
|
target: None,
|
|
changes: Vec::new(),
|
|
});
|
|
continue;
|
|
};
|
|
|
|
let mut changes = Vec::new();
|
|
if source_fk.column != target_fk.column {
|
|
changes.push(format!("column: {} → {}", target_fk.column, source_fk.column));
|
|
}
|
|
if source_fk.ref_table != target_fk.ref_table {
|
|
changes.push(format!("ref table: {} → {}", target_fk.ref_table, source_fk.ref_table));
|
|
}
|
|
if source_fk.ref_column != target_fk.ref_column {
|
|
changes.push(format!("ref column: {} → {}", target_fk.ref_column, source_fk.ref_column));
|
|
}
|
|
if !changes.is_empty() {
|
|
diffs.push(ForeignKeyDiff {
|
|
diff_type: "modified".to_string(),
|
|
name: source_fk.name.clone(),
|
|
source: Some(source_fk.clone()),
|
|
target: Some((*target_fk).clone()),
|
|
changes,
|
|
});
|
|
}
|
|
}
|
|
|
|
for target_fk in target {
|
|
if !source_map.contains_key(target_fk.name.as_str()) {
|
|
diffs.push(ForeignKeyDiff {
|
|
diff_type: "removed".to_string(),
|
|
name: target_fk.name.clone(),
|
|
source: None,
|
|
target: Some(target_fk.clone()),
|
|
changes: Vec::new(),
|
|
});
|
|
}
|
|
}
|
|
|
|
diffs
|
|
}
|
|
|
|
pub fn diff_triggers(source: &[TriggerInfo], target: &[TriggerInfo]) -> Vec<TriggerDiff> {
|
|
let mut diffs = Vec::new();
|
|
let target_map: HashMap<&str, &TriggerInfo> =
|
|
target.iter().map(|trigger| (trigger.name.as_str(), trigger)).collect();
|
|
let source_map: HashMap<&str, &TriggerInfo> =
|
|
source.iter().map(|trigger| (trigger.name.as_str(), trigger)).collect();
|
|
|
|
for source_trigger in source {
|
|
let Some(target_trigger) = target_map.get(source_trigger.name.as_str()) else {
|
|
diffs.push(TriggerDiff {
|
|
diff_type: "added".to_string(),
|
|
name: source_trigger.name.clone(),
|
|
source: Some(source_trigger.clone()),
|
|
target: None,
|
|
changes: Vec::new(),
|
|
});
|
|
continue;
|
|
};
|
|
|
|
let mut changes = Vec::new();
|
|
if source_trigger.event != target_trigger.event {
|
|
changes.push(format!("event: {} → {}", target_trigger.event, source_trigger.event));
|
|
}
|
|
if source_trigger.timing != target_trigger.timing {
|
|
changes.push(format!("timing: {} → {}", target_trigger.timing, source_trigger.timing));
|
|
}
|
|
if !changes.is_empty() {
|
|
diffs.push(TriggerDiff {
|
|
diff_type: "modified".to_string(),
|
|
name: source_trigger.name.clone(),
|
|
source: Some(source_trigger.clone()),
|
|
target: Some((*target_trigger).clone()),
|
|
changes,
|
|
});
|
|
}
|
|
}
|
|
|
|
for target_trigger in target {
|
|
if !source_map.contains_key(target_trigger.name.as_str()) {
|
|
diffs.push(TriggerDiff {
|
|
diff_type: "removed".to_string(),
|
|
name: target_trigger.name.clone(),
|
|
source: None,
|
|
target: Some(target_trigger.clone()),
|
|
changes: Vec::new(),
|
|
});
|
|
}
|
|
}
|
|
|
|
diffs
|
|
}
|
|
|
|
fn quote_id(name: &str, db_type: DatabaseType) -> String {
|
|
if matches!(db_type, DatabaseType::Mysql | DatabaseType::Doris | DatabaseType::StarRocks) {
|
|
format!("`{}`", name.replace('`', "``"))
|
|
} else {
|
|
format!("\"{}\"", name.replace('"', "\"\""))
|
|
}
|
|
}
|
|
|
|
fn column_def(col: &ColumnInfo, db_type: DatabaseType) -> String {
|
|
let mut definition = format!("{} {}", quote_id(&col.name, db_type), col.data_type);
|
|
if !col.is_nullable {
|
|
definition.push_str(" NOT NULL");
|
|
}
|
|
if let Some(default) = &col.column_default {
|
|
definition.push_str(&format!(" DEFAULT {default}"));
|
|
}
|
|
definition
|
|
}
|
|
|
|
fn qualified_name(name: &str, db_type: DatabaseType, schema: Option<&str>) -> String {
|
|
schema
|
|
.map(|schema| format!("{}.{}", quote_id(schema, db_type), quote_id(name, db_type)))
|
|
.unwrap_or_else(|| quote_id(name, db_type))
|
|
}
|
|
|
|
fn drop_index_sql(table_name: &str, index_name: &str, db_type: DatabaseType, schema: Option<&str>) -> String {
|
|
let table = qualified_name(table_name, db_type, schema);
|
|
let index = qualified_name(index_name, db_type, schema);
|
|
if matches!(db_type, DatabaseType::Mysql | DatabaseType::Doris | DatabaseType::StarRocks) {
|
|
format!("DROP INDEX {} ON {table};", quote_id(index_name, db_type))
|
|
} else {
|
|
format!("DROP INDEX IF EXISTS {index};")
|
|
}
|
|
}
|
|
|
|
fn create_index_sql(table_name: &str, index: &IndexInfo, db_type: DatabaseType, schema: Option<&str>) -> String {
|
|
let table = qualified_name(table_name, db_type, schema);
|
|
let columns = index.columns.iter().map(|column| quote_id(column, db_type)).collect::<Vec<_>>().join(", ");
|
|
let unique = if index.is_unique { "UNIQUE " } else { "" };
|
|
let index_type = index.index_type.as_deref().unwrap_or_default();
|
|
let using_clause = if !index_type.is_empty() && db_type == DatabaseType::Postgres {
|
|
format!(" USING {index_type}")
|
|
} else {
|
|
String::new()
|
|
};
|
|
let type_prefix = if !index_type.is_empty() && db_type == DatabaseType::SqlServer {
|
|
format!("{index_type} ")
|
|
} else {
|
|
String::new()
|
|
};
|
|
let included_columns = index.included_columns.clone().unwrap_or_default();
|
|
let include_clause =
|
|
if !included_columns.is_empty() && matches!(db_type, DatabaseType::Postgres | DatabaseType::SqlServer) {
|
|
format!(
|
|
" INCLUDE ({})",
|
|
included_columns.iter().map(|column| quote_id(column, db_type)).collect::<Vec<_>>().join(", ")
|
|
)
|
|
} else {
|
|
String::new()
|
|
};
|
|
let supports_where = matches!(db_type, DatabaseType::Postgres | DatabaseType::SqlServer | DatabaseType::Sqlite);
|
|
let filter = if supports_where { index.filter.as_deref().unwrap_or_default() } else { "" };
|
|
let filter_clause = if filter.is_empty() { String::new() } else { format!(" WHERE {filter}") };
|
|
format!(
|
|
"CREATE {unique}{type_prefix}INDEX {} ON {table}{using_clause} ({columns}){include_clause}{filter_clause};",
|
|
quote_id(&index.name, db_type)
|
|
)
|
|
}
|
|
|
|
fn drop_foreign_key_sql(table_name: &str, fk_name: &str, db_type: DatabaseType, schema: Option<&str>) -> String {
|
|
let table = qualified_name(table_name, db_type, schema);
|
|
let fk = quote_id(fk_name, db_type);
|
|
if matches!(db_type, DatabaseType::Mysql | DatabaseType::Doris | DatabaseType::StarRocks) {
|
|
format!("ALTER TABLE {table} DROP FOREIGN KEY {fk};")
|
|
} else {
|
|
format!("ALTER TABLE {table} DROP CONSTRAINT {fk};")
|
|
}
|
|
}
|
|
|
|
fn add_foreign_key_sql(table_name: &str, fk: &ForeignKeyInfo, db_type: DatabaseType, schema: Option<&str>) -> String {
|
|
let table = qualified_name(table_name, db_type, schema);
|
|
format!(
|
|
"ALTER TABLE {table} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({});",
|
|
quote_id(&fk.name, db_type),
|
|
quote_id(&fk.column, db_type),
|
|
quote_id(&fk.ref_table, db_type),
|
|
quote_id(&fk.ref_column, db_type)
|
|
)
|
|
}
|
|
|
|
fn drop_object_sql(diff: &TableDiff, db_type: DatabaseType, schema: Option<&str>) -> String {
|
|
let object_type = if diff.object_type.as_deref() == Some("view") { "VIEW" } else { "TABLE" };
|
|
format!("DROP {object_type} IF EXISTS {};", qualified_name(&diff.name, db_type, schema))
|
|
}
|
|
|
|
fn comment_literal(comment: &str) -> String {
|
|
format!("'{}'", comment.replace('\'', "''"))
|
|
}
|
|
|
|
fn column_comment_sql(
|
|
table_name: &str,
|
|
column_name: &str,
|
|
comment: &str,
|
|
db_type: DatabaseType,
|
|
schema: Option<&str>,
|
|
) -> String {
|
|
if matches!(db_type, DatabaseType::Mysql | DatabaseType::Doris | DatabaseType::StarRocks) {
|
|
return format!(
|
|
"-- Column comment for {column_name}: use ALTER TABLE ... MODIFY COLUMN to set comment in MySQL"
|
|
);
|
|
}
|
|
let table = qualified_name(table_name, db_type, schema);
|
|
format!("COMMENT ON COLUMN {table}.{} IS {};", quote_id(column_name, db_type), comment_literal(comment))
|
|
}
|
|
|
|
fn table_comment_sql(table_name: &str, comment: &str, db_type: DatabaseType, schema: Option<&str>) -> String {
|
|
let table = qualified_name(table_name, db_type, schema);
|
|
if matches!(db_type, DatabaseType::Mysql | DatabaseType::Doris | DatabaseType::StarRocks) {
|
|
format!("ALTER TABLE {table} COMMENT = {};", comment_literal(comment))
|
|
} else {
|
|
format!("COMMENT ON TABLE {table} IS {};", comment_literal(comment))
|
|
}
|
|
}
|
|
|
|
pub fn generate_schema_sync_sql(diffs: &[TableDiff], db_type: DatabaseType, schema: Option<&str>) -> String {
|
|
let mut lines = Vec::new();
|
|
let is_mysql = matches!(db_type, DatabaseType::Mysql | DatabaseType::Doris | DatabaseType::StarRocks);
|
|
|
|
for diff in diffs {
|
|
let table = qualified_name(&diff.name, db_type, schema);
|
|
|
|
if diff.diff_type == "added" && diff.ddl.is_some() {
|
|
lines.push(format!("-- Create {}: {}", diff.object_type.as_deref().unwrap_or("table"), diff.name));
|
|
lines.push(format!("{};", diff.ddl.as_deref().unwrap_or_default()));
|
|
lines.push(String::new());
|
|
continue;
|
|
}
|
|
|
|
if diff.diff_type == "added" && diff.object_type.as_deref() == Some("view") {
|
|
lines.push(format!("-- View exists only in source: {}", diff.name));
|
|
lines.push("-- Source view definition is not available from this driver yet.".to_string());
|
|
lines.push(String::new());
|
|
continue;
|
|
}
|
|
|
|
if diff.diff_type == "removed" {
|
|
lines.push(format!("-- Drop {}: {}", diff.object_type.as_deref().unwrap_or("table"), diff.name));
|
|
lines.push(drop_object_sql(diff, db_type, schema));
|
|
lines.push(String::new());
|
|
continue;
|
|
}
|
|
|
|
if diff.diff_type != "modified" {
|
|
continue;
|
|
}
|
|
|
|
let mut parts = Vec::new();
|
|
if let Some(foreign_keys) = &diff.foreign_keys {
|
|
for fk in foreign_keys {
|
|
if fk.diff_type == "removed" || fk.diff_type == "modified" {
|
|
lines.push(drop_foreign_key_sql(&diff.name, &fk.name, db_type, schema));
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(columns) = &diff.columns {
|
|
for column in columns {
|
|
match column.diff_type.as_str() {
|
|
"added" => {
|
|
if let Some(source) = &column.source {
|
|
parts.push(format!(" ADD COLUMN {}", column_def(source, db_type)));
|
|
}
|
|
}
|
|
"removed" => {
|
|
parts.push(format!(" DROP COLUMN {}", quote_id(&column.name, db_type)));
|
|
}
|
|
"modified" => {
|
|
if let Some(source) = &column.source {
|
|
if is_mysql {
|
|
parts.push(format!(" MODIFY COLUMN {}", column_def(source, db_type)));
|
|
} else {
|
|
let name = quote_id(&column.name, db_type);
|
|
if column.changes.iter().any(|change| change.starts_with("type:")) {
|
|
parts.push(format!(" ALTER COLUMN {name} TYPE {}", source.data_type));
|
|
}
|
|
if column.changes.iter().any(|change| change.starts_with("nullable:")) {
|
|
parts.push(if source.is_nullable {
|
|
format!(" ALTER COLUMN {name} DROP NOT NULL")
|
|
} else {
|
|
format!(" ALTER COLUMN {name} SET NOT NULL")
|
|
});
|
|
}
|
|
if column.changes.iter().any(|change| change.starts_with("default:")) {
|
|
parts.push(if let Some(default) = &source.column_default {
|
|
format!(" ALTER COLUMN {name} SET DEFAULT {default}")
|
|
} else {
|
|
format!(" ALTER COLUMN {name} DROP DEFAULT")
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !parts.is_empty() {
|
|
lines.push(format!("-- Alter table: {}", diff.name));
|
|
if is_mysql {
|
|
lines.push(format!("ALTER TABLE {table}"));
|
|
lines.push(format!("{};", parts.join(",\n")));
|
|
} else {
|
|
for part in parts {
|
|
lines.push(format!("ALTER TABLE {table}{part};"));
|
|
}
|
|
}
|
|
lines.push(String::new());
|
|
}
|
|
|
|
if let Some(columns) = &diff.columns {
|
|
for column in columns {
|
|
if let Some(source) = &column.source {
|
|
if column.changes.iter().any(|change| change.starts_with("comment:")) {
|
|
lines.push(column_comment_sql(
|
|
&diff.name,
|
|
&column.name,
|
|
source.comment.as_deref().unwrap_or_default(),
|
|
db_type,
|
|
schema,
|
|
));
|
|
}
|
|
if column.diff_type == "added" {
|
|
if let Some(comment) = &source.comment {
|
|
lines.push(column_comment_sql(&diff.name, &column.name, comment, db_type, schema));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if diff.source_table_comment.is_some() && diff.source_table_comment != diff.target_table_comment {
|
|
let comment = diff.source_table_comment.as_ref().and_then(|comment| comment.as_deref()).unwrap_or_default();
|
|
lines.push(table_comment_sql(&diff.name, comment, db_type, schema));
|
|
}
|
|
|
|
if let Some(indexes) = &diff.indexes {
|
|
for index in indexes {
|
|
match index.diff_type.as_str() {
|
|
"added" => {
|
|
if let Some(source) = &index.source {
|
|
lines.push(create_index_sql(&diff.name, source, db_type, schema));
|
|
}
|
|
}
|
|
"removed" => lines.push(drop_index_sql(&diff.name, &index.name, db_type, schema)),
|
|
"modified" => {
|
|
if let Some(source) = &index.source {
|
|
lines.push(drop_index_sql(&diff.name, &index.name, db_type, schema));
|
|
lines.push(create_index_sql(&diff.name, source, db_type, schema));
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(foreign_keys) = &diff.foreign_keys {
|
|
for fk in foreign_keys {
|
|
if (fk.diff_type == "added" || fk.diff_type == "modified") && fk.source.is_some() {
|
|
lines.push(add_foreign_key_sql(&diff.name, fk.source.as_ref().unwrap(), db_type, schema));
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(triggers) = &diff.triggers {
|
|
for trigger in triggers {
|
|
lines.push(format!(
|
|
"-- Trigger {}: {} on {}; review trigger definition manually.",
|
|
trigger.diff_type, trigger.name, diff.name
|
|
));
|
|
}
|
|
}
|
|
|
|
if diff.indexes.as_ref().is_some_and(|indexes| !indexes.is_empty())
|
|
|| diff.foreign_keys.as_ref().is_some_and(|foreign_keys| !foreign_keys.is_empty())
|
|
|| diff.triggers.as_ref().is_some_and(|triggers| !triggers.is_empty())
|
|
{
|
|
lines.push(String::new());
|
|
}
|
|
|
|
if db_type == DatabaseType::Sqlite
|
|
&& diff.foreign_keys.as_ref().is_some_and(|foreign_keys| !foreign_keys.is_empty())
|
|
{
|
|
lines.push(format!("-- SQLite foreign key synchronization may require table rebuild for: {}", diff.name));
|
|
lines.push(String::new());
|
|
}
|
|
}
|
|
|
|
lines.join("\n").trim().to_string()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn index(overrides: IndexInfo) -> IndexInfo {
|
|
IndexInfo {
|
|
name: if overrides.name.is_empty() { "idx_users_email".to_string() } else { overrides.name },
|
|
columns: if overrides.columns.is_empty() { vec!["email".to_string()] } else { overrides.columns },
|
|
is_unique: overrides.is_unique,
|
|
is_primary: overrides.is_primary,
|
|
filter: overrides.filter,
|
|
index_type: overrides.index_type,
|
|
included_columns: overrides.included_columns,
|
|
comment: overrides.comment,
|
|
}
|
|
}
|
|
|
|
fn foreign_key(overrides: ForeignKeyInfo) -> ForeignKeyInfo {
|
|
ForeignKeyInfo {
|
|
name: if overrides.name.is_empty() { "orders_user_id_fk".to_string() } else { overrides.name },
|
|
column: if overrides.column.is_empty() { "user_id".to_string() } else { overrides.column },
|
|
ref_table: if overrides.ref_table.is_empty() { "users".to_string() } else { overrides.ref_table },
|
|
ref_column: if overrides.ref_column.is_empty() { "id".to_string() } else { overrides.ref_column },
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn detects_modified_indexes_not_only_added_or_removed_indexes() {
|
|
let diffs = diff_indexes(
|
|
&[index(IndexInfo {
|
|
name: "idx_orders_status".to_string(),
|
|
columns: vec!["status".to_string(), "created_at".to_string()],
|
|
is_unique: false,
|
|
is_primary: false,
|
|
filter: None,
|
|
index_type: None,
|
|
included_columns: None,
|
|
comment: None,
|
|
})],
|
|
&[index(IndexInfo {
|
|
name: "idx_orders_status".to_string(),
|
|
columns: vec!["status".to_string()],
|
|
is_unique: true,
|
|
is_primary: false,
|
|
filter: None,
|
|
index_type: None,
|
|
included_columns: None,
|
|
comment: None,
|
|
})],
|
|
);
|
|
|
|
assert_eq!(diffs.len(), 1);
|
|
assert_eq!(diffs[0].diff_type, "modified");
|
|
assert_eq!(diffs[0].changes, vec!["unique: YES → NO", "columns: status → status, created_at"]);
|
|
}
|
|
|
|
#[test]
|
|
fn detects_foreign_key_additions_removals_and_target_changes() {
|
|
let diffs = diff_foreign_keys(
|
|
&[
|
|
foreign_key(ForeignKeyInfo {
|
|
name: "orders_user_id_fk".to_string(),
|
|
column: String::new(),
|
|
ref_table: String::new(),
|
|
ref_column: String::new(),
|
|
}),
|
|
foreign_key(ForeignKeyInfo {
|
|
name: "orders_account_id_fk".to_string(),
|
|
column: "account_id".to_string(),
|
|
ref_table: "accounts".to_string(),
|
|
ref_column: String::new(),
|
|
}),
|
|
],
|
|
&[
|
|
foreign_key(ForeignKeyInfo {
|
|
name: "orders_user_id_fk".to_string(),
|
|
column: String::new(),
|
|
ref_table: "members".to_string(),
|
|
ref_column: String::new(),
|
|
}),
|
|
foreign_key(ForeignKeyInfo {
|
|
name: "orders_region_id_fk".to_string(),
|
|
column: "region_id".to_string(),
|
|
ref_table: "regions".to_string(),
|
|
ref_column: String::new(),
|
|
}),
|
|
],
|
|
);
|
|
|
|
let summary: Vec<_> = diffs.iter().map(|diff| (diff.diff_type.as_str(), diff.name.as_str())).collect();
|
|
assert_eq!(
|
|
summary,
|
|
vec![
|
|
("modified", "orders_user_id_fk"),
|
|
("added", "orders_account_id_fk"),
|
|
("removed", "orders_region_id_fk"),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn generates_sync_sql_for_index_and_foreign_key_changes() {
|
|
let diffs = vec![TableDiff {
|
|
diff_type: "modified".to_string(),
|
|
object_type: None,
|
|
name: "orders".to_string(),
|
|
columns: None,
|
|
indexes: Some(vec![IndexDiff {
|
|
diff_type: "modified".to_string(),
|
|
name: "idx_orders_status".to_string(),
|
|
source: Some(index(IndexInfo {
|
|
name: "idx_orders_status".to_string(),
|
|
columns: vec!["status".to_string(), "created_at".to_string()],
|
|
is_unique: true,
|
|
is_primary: false,
|
|
filter: None,
|
|
index_type: None,
|
|
included_columns: None,
|
|
comment: None,
|
|
})),
|
|
target: None,
|
|
changes: Vec::new(),
|
|
}]),
|
|
foreign_keys: Some(vec![ForeignKeyDiff {
|
|
diff_type: "modified".to_string(),
|
|
name: "orders_user_id_fk".to_string(),
|
|
source: Some(foreign_key(ForeignKeyInfo {
|
|
name: "orders_user_id_fk".to_string(),
|
|
column: String::new(),
|
|
ref_table: "users".to_string(),
|
|
ref_column: String::new(),
|
|
})),
|
|
target: None,
|
|
changes: Vec::new(),
|
|
}]),
|
|
triggers: None,
|
|
ddl: None,
|
|
source_table_comment: None,
|
|
target_table_comment: None,
|
|
}];
|
|
|
|
assert_eq!(
|
|
generate_schema_sync_sql(&diffs, DatabaseType::Postgres, None),
|
|
[
|
|
"ALTER TABLE \"orders\" DROP CONSTRAINT \"orders_user_id_fk\";",
|
|
"DROP INDEX IF EXISTS \"idx_orders_status\";",
|
|
"CREATE UNIQUE INDEX \"idx_orders_status\" ON \"orders\" (\"status\", \"created_at\");",
|
|
"ALTER TABLE \"orders\" ADD CONSTRAINT \"orders_user_id_fk\" FOREIGN KEY (\"user_id\") REFERENCES \"users\" (\"id\");",
|
|
]
|
|
.join("\n")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn qualifies_generated_schema_sync_sql_with_target_schema() {
|
|
let diffs = vec![TableDiff {
|
|
diff_type: "modified".to_string(),
|
|
object_type: None,
|
|
name: "orders".to_string(),
|
|
columns: Some(vec![ColumnDiff {
|
|
diff_type: "added".to_string(),
|
|
name: "status".to_string(),
|
|
source: Some(ColumnInfo {
|
|
name: "status".to_string(),
|
|
data_type: "text".to_string(),
|
|
is_nullable: true,
|
|
column_default: None,
|
|
is_primary_key: false,
|
|
extra: None,
|
|
comment: None,
|
|
numeric_precision: None,
|
|
numeric_scale: None,
|
|
character_maximum_length: None,
|
|
}),
|
|
target: None,
|
|
changes: Vec::new(),
|
|
}]),
|
|
indexes: Some(vec![IndexDiff {
|
|
diff_type: "added".to_string(),
|
|
name: "idx_orders_status".to_string(),
|
|
source: Some(index(IndexInfo {
|
|
name: "idx_orders_status".to_string(),
|
|
columns: vec!["status".to_string()],
|
|
is_unique: false,
|
|
is_primary: false,
|
|
filter: None,
|
|
index_type: None,
|
|
included_columns: None,
|
|
comment: None,
|
|
})),
|
|
target: None,
|
|
changes: Vec::new(),
|
|
}]),
|
|
foreign_keys: None,
|
|
triggers: None,
|
|
ddl: None,
|
|
source_table_comment: None,
|
|
target_table_comment: None,
|
|
}];
|
|
|
|
assert_eq!(
|
|
generate_schema_sync_sql(&diffs, DatabaseType::Postgres, Some("sales")),
|
|
[
|
|
"-- Alter table: orders",
|
|
"ALTER TABLE \"sales\".\"orders\" ADD COLUMN \"status\" text;",
|
|
"",
|
|
"CREATE INDEX \"idx_orders_status\" ON \"sales\".\"orders\" (\"status\");",
|
|
]
|
|
.join("\n")
|
|
);
|
|
}
|
|
}
|