Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changepacks/changepack_log_fIoUZOkWt-518L5MIjOim.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"changes":{"crates/vespertide-core/Cargo.toml":"Minor","crates/vespertide-planner/Cargo.toml":"Minor","crates/vespertide-query/Cargo.toml":"Minor","crates/vespertide-cli/Cargo.toml":"Minor","crates/vespertide-lsp/Cargo.toml":"Patch"},"note":"data_migration 액션 추가: MigrationAction::DataMigration(#[non_exhaustive]라 additive), DataMigrationSql/leading_ddl_keyword/sql_preview 공개 API 신설, DDL 가드용 PlannerError::DataMigrationContainsDdl 변형 추가(PlannerError는 exhaustive라 0.x 기준 breaking), find_raw_sql_replay_hazards 신설, diff/status의 raw_sql 리플레이 경고","date":"2026-08-20T11:20:29.9859819Z"}
17 changes: 16 additions & 1 deletion crates/vespertide-cli/src/commands/diff/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ use vespertide_planner::{
find_type_narrowings, plan_next_migration, render_reference_action, schema_from_plans,
};

use super::raw_sql_warning::emit_raw_sql_replay_warning;
use crate::utils::{load_config, load_migrations, load_models};
use vespertide_core::action::truncate_comment;
use vespertide_core::action::{sql_preview, truncate_comment};
use vespertide_core::{MigrationAction, MigrationPlan, TableDef};

pub async fn cmd_diff() -> Result<()> {
Expand All @@ -21,6 +22,10 @@ pub async fn cmd_diff() -> Result<()> {
let plan = plan_next_migration(&current_models, &applied_plans)
.map_err(|e| anyhow::anyhow!("planning error: {e}"))?;

// Emitted before the action list: when replay was incomplete the list
// itself is untrustworthy, so the caveat has to arrive first.
emit_raw_sql_replay_warning(&applied_plans);

if plan.actions.is_empty() {
println!(
"{} {}",
Expand Down Expand Up @@ -514,6 +519,16 @@ fn format_action(action: &MigrationAction) -> String {
sql.bright_cyan()
)
}
MigrationAction::DataMigration { sql, description } => {
let summary = description
.clone()
.unwrap_or_else(|| sql_preview(sql.postgres()));
format!(
"{} {}",
"Data migration:".bright_yellow(),
summary.bright_cyan()
)
}
MigrationAction::AddConstraint { constraint, .. } => {
format!(
"{} {} {} {}",
Expand Down
65 changes: 65 additions & 0 deletions crates/vespertide-cli/src/commands/diff/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,14 @@ fn chk_age() -> TableConstraint {
MigrationAction::RemapEnumValues { table: "users".into(), column: "status".into(), mapping: { let mut m = std::collections::BTreeMap::new(); m.insert(0, 10); m.insert(1, 20); m } },
format!("{} {}.{} [{}]", "Remap enum values:".bright_yellow(), "users".bright_cyan(), "status".bright_cyan().bold(), "0->10, 1->20".bright_white())
)]
#[case(
MigrationAction::DataMigration { sql: "UPDATE users SET tier = 'pro'".into(), description: Some("promote beta users".into()) },
format!("{} {}", "Data migration:".bright_yellow(), "promote beta users".bright_cyan())
)]
#[case(
MigrationAction::DataMigration { sql: "UPDATE users SET tier = 'pro'".into(), description: None },
format!("{} {}", "Data migration:".bright_yellow(), "UPDATE users SET tier = 'pro'".bright_cyan())
)]
#[serial]
fn format_action_cases(#[case] action: MigrationAction, #[case] expected: String) {
assert_eq!(format_action(&action), expected);
Expand Down Expand Up @@ -209,6 +217,63 @@ async fn cmd_diff_when_no_changes() {
assert!(result.is_ok());
}

#[rstest]
#[serial]
#[tokio::test]
async fn cmd_diff_warns_when_history_contains_raw_sql() {
let tmp = tempdir().unwrap();
let _guard = CwdGuard::new(&tmp.path().to_path_buf());

write_default_config();
write_simple_id_model("users");
fs::create_dir_all("migrations").unwrap();
fs::write(
"migrations/0001_init.json",
serde_json::to_string_pretty(&MigrationPlan {
id: String::new(),
comment: None,
created_at: None,
version: 1,
actions: vec![MigrationAction::RawSql {
sql: "ALTER TABLE users ADD COLUMN legacy int".into(),
}],
})
.unwrap(),
)
.unwrap();

assert!(cmd_diff().await.is_ok());
}

#[rstest]
#[serial]
#[tokio::test]
async fn cmd_diff_stays_quiet_when_history_uses_data_migration() {
let tmp = tempdir().unwrap();
let _guard = CwdGuard::new(&tmp.path().to_path_buf());

write_default_config();
write_simple_id_model("users");
fs::create_dir_all("migrations").unwrap();
fs::write(
"migrations/0001_init.json",
serde_json::to_string_pretty(&MigrationPlan {
id: String::new(),
comment: None,
created_at: None,
version: 1,
actions: vec![MigrationAction::DataMigration {
sql: "UPDATE users SET id = id".into(),
description: Some("no-op backfill".into()),
}],
})
.unwrap(),
)
.unwrap();

assert!(cmd_diff().await.is_ok());
}

#[test]
fn test_constraint_display_unnamed_index() {
let constraint = TableConstraint::Index {
Expand Down
1 change: 1 addition & 0 deletions crates/vespertide-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pub mod export;
pub mod init;
pub mod log;
pub mod new;
mod raw_sql_warning;
pub mod revision;
pub mod sql;
pub mod status;
Expand Down
118 changes: 118 additions & 0 deletions crates/vespertide-cli/src/commands/raw_sql_warning.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
use std::fmt::Write as _;

use colored::Colorize;
use vespertide_core::MigrationPlan;
use vespertide_planner::{RawSqlReplayHazard, find_raw_sql_replay_hazards};

/// Warn that `raw_sql` in the applied history makes baseline replay incomplete.
///
/// Shared by `vespertide diff` and `vespertide status`. Silent when the history
/// has no `raw_sql`, which is the common case.
pub(super) fn emit_raw_sql_replay_warning(plans: &[MigrationPlan]) {
let hazards = find_raw_sql_replay_hazards(plans);
if hazards.is_empty() {
return;
}

println!();
for line in format_raw_sql_replay_warning(&hazards).lines() {
println!("{line}");
}
}

/// Render the warning as a multi-line indented block.
/// Extracted so its output can be unit-tested without going through stdout.
fn format_raw_sql_replay_warning(hazards: &[RawSqlReplayHazard]) -> String {
let versions = hazards
.iter()
.map(|hazard| {
let plural = if hazard.count == 1 { "" } else { "s" };
format!("{} ({} action{})", hazard.version, hazard.count, plural)
})
.collect::<Vec<_>>()
.join(", ");

let mut out = format!(
"{} {}",
"⚠".bright_yellow().bold(),
format!(
"{} applied migration(s) use raw_sql — baseline replay may be incomplete:",
hazards.len()
)
.bright_yellow()
);
let _ = write!(
out,
"\n {} {}",
"versions:".bright_white(),
versions.bright_cyan().bold()
);
let _ = write!(
out,
"\n {} replay cannot interpret raw SQL, so any schema change those actions made \
is missing from the reconstructed baseline — `vespertide diff` may report changes \
that are already applied",
"why:".bright_white()
);
let _ = write!(
out,
"\n {} re-express schema changes as typed actions; use `data_migration` for \
data-only SQL so replay can skip it safely",
"fix:".bright_green()
);
out
}

#[cfg(test)]
mod tests {
use super::*;
use vespertide_core::MigrationAction;

fn plan_of(version: u32, actions: Vec<MigrationAction>) -> MigrationPlan {
MigrationPlan {
id: String::new(),
comment: None,
created_at: None,
version,
actions,
}
}

fn raw() -> MigrationAction {
MigrationAction::RawSql {
sql: "ALTER TABLE users ADD c int".to_string(),
}
}

#[test]
fn warning_names_every_affected_version_with_its_action_count() {
let rendered = format_raw_sql_replay_warning(&[
RawSqlReplayHazard {
version: 3,
count: 1,
},
RawSqlReplayHazard {
version: 7,
count: 2,
},
]);

assert!(
rendered.contains("2 applied migration(s) use raw_sql"),
"{rendered}"
);
assert!(rendered.contains("3 (1 action)"), "{rendered}");
assert!(rendered.contains("7 (2 actions)"), "{rendered}");
assert!(rendered.contains("data_migration"), "{rendered}");
}

#[test]
fn emit_is_silent_for_a_history_without_raw_sql() {
emit_raw_sql_replay_warning(&[plan_of(1, vec![])]);
}

#[test]
fn emit_prints_for_a_history_with_raw_sql() {
emit_raw_sql_replay_warning(&[plan_of(1, vec![raw()])]);
}
}
29 changes: 29 additions & 0 deletions crates/vespertide-cli/src/commands/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use anyhow::Result;
use colored::Colorize;
use vespertide_planner::schema_from_plans;

use super::raw_sql_warning::emit_raw_sql_replay_warning;
use crate::utils::{load_config, load_migrations, load_models};
use std::collections::HashSet;

Expand Down Expand Up @@ -66,6 +67,7 @@ pub async fn cmd_status() -> Result<()> {
);
}
}
emit_raw_sql_replay_warning(&applied_plans);
println!();

println!(
Expand Down Expand Up @@ -270,6 +272,33 @@ mod tests {
cmd_status().await.unwrap();
}

#[tokio::test]
#[serial]
async fn cmd_status_warns_when_history_contains_raw_sql() {
let tmp = tempdir().unwrap();
let _guard = CwdGuard::new(&tmp.path().to_path_buf());

let cfg = write_default_config();
write_simple_id_model("users");
fs::create_dir_all(cfg.migrations_dir()).unwrap();
let plan = MigrationPlan {
id: String::new(),
comment: None,
created_at: None,
version: 1,
actions: vec![MigrationAction::RawSql {
sql: "ALTER TABLE users ADD COLUMN legacy int".into(),
}],
};
fs::write(
cfg.migrations_dir().join("0001_init.json"),
serde_json::to_string_pretty(&plan).unwrap(),
)
.unwrap();

cmd_status().await.unwrap();
}

#[tokio::test]
#[serial]
async fn cmd_status_model_with_description() {
Expand Down
Loading
Loading