#![deny(unsafe_code)]
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SchemaSnapshot {
pub version: u32,
pub tables: Vec<TableSnapshot>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TableSnapshot {
pub name: String,
pub columns: Vec<ColumnSnapshot>,
#[serde(default)]
pub ddl: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ColumnSnapshot {
pub name: String,
pub sql_type: String,
pub not_null: bool,
pub pk: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub renamed_from: Option<String>,
}
impl SchemaSnapshot {
pub fn read_from(path: &Path) -> std::io::Result<Self> {
let raw = std::fs::read_to_string(path)?;
serde_json::from_str(&raw).map_err(std::io::Error::other)
}
pub fn from_slice(bytes: &[u8]) -> std::io::Result<Self> {
serde_json::from_slice(bytes).map_err(std::io::Error::other)
}
pub fn to_json(&self) -> std::io::Result<String> {
serde_json::to_string_pretty(self).map_err(std::io::Error::other)
}
pub fn write_to(&self, path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, self.to_json()?)
}
pub fn table(&self, name: &str) -> Option<&TableSnapshot> {
self.tables.iter().find(|t| t.name == name)
}
pub fn canonical_string(&self) -> String {
fn push_field(out: &mut String, s: &str) {
out.push_str(&format!("{}:{}", s.len(), s));
}
let mut tables: Vec<&TableSnapshot> = self.tables.iter().collect();
tables.sort_by(|a, b| a.name.cmp(&b.name));
let mut out = format!("v{};", self.version);
for t in tables {
out.push('t');
push_field(&mut out, &t.name);
out.push('(');
for c in &t.columns {
out.push('c');
push_field(&mut out, &c.name);
out.push('y');
push_field(&mut out, &c.sql_type);
out.push_str(&format!("n{}p{},", c.not_null as u8, c.pk as u8));
}
out.push_str(");");
for d in &t.ddl {
out.push('d');
push_field(&mut out, d);
out.push(';');
}
}
out
}
pub fn hash(&self) -> u64 {
fnv1a64(self.canonical_string().as_bytes())
}
}
impl TableSnapshot {
pub fn has_column(&self, name: &str) -> bool {
self.columns.iter().any(|c| c.name == name)
}
}
fn fnv1a64(bytes: &[u8]) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for b in bytes {
hash ^= u64::from(*b);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
const DECOMPRESS_LIMIT: usize = 64 * 1024 * 1024;
pub fn compress_snapshot(bytes: &[u8]) -> Vec<u8> {
miniz_oxide::deflate::compress_to_vec(bytes, 8)
}
pub fn decompress_snapshot(bytes: &[u8]) -> std::io::Result<Vec<u8>> {
miniz_oxide::inflate::decompress_to_vec_with_limit(bytes, DECOMPRESS_LIMIT)
.map_err(|_| std::io::Error::other("스냅샷 압축 해제 실패: 스트림 손상 또는 64MB 초과"))
}
pub const SCHEMA_DIR_RELATIVE: &str = "migrations/schema";
pub fn resolve_schema_dir(manifest_dir: &str) -> PathBuf {
match std::env::var("ROOMRS_SCHEMA_DIR") {
Ok(p) if !p.is_empty() => {
let p = PathBuf::from(p);
if p.is_relative() {
Path::new(manifest_dir).join(p)
} else {
p
}
}
_ => Path::new(manifest_dir).join(SCHEMA_DIR_RELATIVE),
}
}
pub fn snapshot_file_name(db_name: &str, version: u32) -> String {
format!("{db_name}.{version}.json")
}
pub fn snapshot_path(dir: &Path, db_name: &str, version: u32) -> PathBuf {
dir.join(snapshot_file_name(db_name, version))
}
pub fn list_snapshot_versions(dir: &Path, db_name: &str) -> std::io::Result<Vec<(u32, PathBuf)>> {
let mut out: Vec<(u32, PathBuf)> = Vec::new();
let rd = match std::fs::read_dir(dir) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
Err(e) => return Err(e),
};
let prefix = format!("{db_name}.");
for entry in rd {
let entry = entry?;
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
let Some(v) = parse_snapshot_version(name, &prefix)? else {
continue;
};
out.push((v, entry.path()));
}
out.sort_by_key(|(v, _)| *v);
Ok(out)
}
fn parse_snapshot_version(name: &str, prefix: &str) -> std::io::Result<Option<u32>> {
let Some(rest) = name.strip_prefix(prefix) else {
return Ok(None);
};
let Some(num) = rest.strip_suffix(".json") else {
return Ok(None);
};
if num.is_empty() || !num.bytes().all(|b| b.is_ascii_digit()) {
return Ok(None);
}
if num.len() > 1 && num.starts_with('0') {
return Ok(None);
}
match num.parse() {
Ok(v) => Ok(Some(v)),
Err(_) => Err(std::io::Error::other(format!(
"스냅샷 파일명 버전이 u32 범위를 넘습니다: {name}"
))),
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DiffPlan {
pub safe: Vec<String>,
pub destructive: Vec<String>,
pub warnings: Vec<String>,
}
pub fn diff_plan(old: &SchemaSnapshot, new: &SchemaSnapshot) -> DiffPlan {
let mut plan = DiffPlan::default();
for nt in &new.tables {
if old.table(&nt.name).is_none() {
plan.safe.extend(nt.ddl.iter().cloned());
}
}
for ot in &old.tables {
if new.table(&ot.name).is_none() {
plan.destructive.push(format!("DROP TABLE \"{}\"", ot.name));
}
}
for nt in &new.tables {
let Some(ot) = old.table(&nt.name) else {
continue;
};
diff_columns(ot, nt, &mut plan);
diff_indexes(ot, nt, &mut plan);
diff_table_constraints(ot, nt, &mut plan);
}
plan
}
fn diff_table_constraints(ot: &TableSnapshot, nt: &TableSnapshot, plan: &mut DiffPlan) {
let same_columns = ot.columns.len() == nt.columns.len()
&& nt.columns.iter().all(|n| {
ot.columns.iter().any(|o| {
o.name == n.name
&& o.sql_type == n.sql_type
&& o.not_null == n.not_null
&& o.pk == n.pk
})
});
if !same_columns {
return;
}
if ot.ddl.is_empty() || nt.ddl.is_empty() {
if ot.ddl.is_empty() != nt.ddl.is_empty() {
plan.warnings.push(format!(
"제약 비교 스킵: \"{}\" — 한쪽 스냅샷에 ddl 정보가 없습니다(구형 스냅샷) — write_schema_snapshot 재생성 권장",
nt.name
));
}
return;
}
if create_table_entries(ot) != create_table_entries(nt) {
plan.destructive.push(format!(
"테이블 재작성 필요(제약/DDL 변경): \"{}\" — 컬럼 정의는 동일하나 CREATE TABLE 문이 다릅니다",
nt.name
));
}
}
fn create_table_entries(t: &TableSnapshot) -> Vec<String> {
t.ddl
.iter()
.filter(|d| index_name(d).is_none())
.map(|d| normalize_ws_outside_quotes(d))
.collect()
}
fn normalize_ws_outside_quotes(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut quote: Option<char> = None;
let mut pending_ws = false;
for c in s.chars() {
match quote {
Some(q) => {
out.push(c);
if c == q {
quote = None;
}
}
None if c.is_whitespace() => pending_ws = true,
None => {
if pending_ws && !out.is_empty() {
out.push(' ');
}
pending_ws = false;
if c == '\'' || c == '"' {
quote = Some(c);
}
out.push(c);
}
}
}
out
}
fn diff_columns(ot: &TableSnapshot, nt: &TableSnapshot, plan: &mut DiffPlan) {
let mut consumed_sources: Vec<&str> = Vec::new();
for nc in &nt.columns {
if let Some(oc) = ot.columns.iter().find(|c| c.name == nc.name) {
let mut changes: Vec<String> = Vec::new();
if oc.sql_type != nc.sql_type {
changes.push(format!(
"sql_type \"{}\" -> \"{}\"",
oc.sql_type, nc.sql_type
));
}
if oc.not_null != nc.not_null {
changes.push(format!("not_null {} -> {}", oc.not_null, nc.not_null));
}
if oc.pk != nc.pk {
changes.push(format!("pk {} -> {}", oc.pk, nc.pk));
}
if !changes.is_empty() {
plan.destructive.push(format!(
"테이블 재작성 필요: \"{}\".\"{}\" 정의 변경 ({})",
nt.name,
nc.name,
changes.join(", ")
));
}
continue;
}
if let Some(from) = &nc.renamed_from {
if consumed_sources.contains(&from.as_str()) {
plan.warnings.push(format!(
"rename 힌트 중복: \"{}\".\"{from}\" 은 이미 다른 컬럼의 rename 원본으로 소비됨 — \"{}\"는 ADD COLUMN으로 처리",
nt.name, nc.name
));
} else if nt.has_column(from) {
plan.warnings.push(format!(
"rename 힌트 무시: 새 스키마 \"{}\"에 \"{from}\" 컬럼이 여전히 존재 — \"{}\"는 ADD COLUMN으로 처리",
nt.name, nc.name
));
} else if let Some(of) = ot.columns.iter().find(|c| &c.name == from) {
consumed_sources.push(from);
if of.sql_type == nc.sql_type && of.not_null == nc.not_null && of.pk == nc.pk {
plan.safe.push(format!(
"ALTER TABLE \"{}\" RENAME COLUMN \"{from}\" TO \"{}\"",
nt.name, nc.name
));
} else {
plan.destructive.push(format!(
"테이블 재작성 필요: \"{}\".\"{from}\" -> \"{}\" rename에 정의 변경이 동반됨",
nt.name, nc.name
));
}
continue;
} else {
plan.warnings.push(format!(
"잘못된 rename 힌트: 옛 스키마 \"{}\"에 \"{from}\" 컬럼 없음 — \"{}\"는 ADD COLUMN으로 처리",
nt.name, nc.name
));
}
}
let mut col = format!("\"{}\"", nc.name);
if !nc.sql_type.is_empty() {
col.push_str(&format!(" {}", nc.sql_type));
}
if nc.pk {
plan.destructive.push(format!(
"테이블 재작성 필요: \"{}\".\"{}\" — PK 컬럼은 ADD COLUMN 불가",
nt.name, nc.name
));
} else if nc.not_null {
plan.destructive.push(format!(
"ALTER TABLE \"{}\" ADD COLUMN {col} NOT NULL — DEFAULT 필요(기존 행)",
nt.name
));
} else {
plan.safe
.push(format!("ALTER TABLE \"{}\" ADD COLUMN {col}", nt.name));
}
}
for oc in &ot.columns {
let renamed_away = nt
.columns
.iter()
.any(|nc| nc.renamed_from.as_deref() == Some(oc.name.as_str()));
if !nt.has_column(&oc.name) && !renamed_away {
plan.destructive.push(format!(
"ALTER TABLE \"{}\" DROP COLUMN \"{}\"",
nt.name, oc.name
));
}
}
}
fn diff_indexes(ot: &TableSnapshot, nt: &TableSnapshot, plan: &mut DiffPlan) {
let old_idx: Vec<(String, &String)> = index_entries(ot);
let new_idx: Vec<(String, &String)> = index_entries(nt);
for (name, ddl) in &new_idx {
match old_idx.iter().find(|(n, _)| n == name) {
None => plan.safe.push((*ddl).clone()),
Some((_, old_ddl))
if normalize_ws_outside_quotes(old_ddl) != normalize_ws_outside_quotes(ddl) =>
{
plan.destructive.push(format!(
"인덱스 재생성 필요: DROP INDEX \"{name}\" 후 재생성 ({ddl})"
))
}
Some(_) => {}
}
}
for (name, _) in &old_idx {
if !new_idx.iter().any(|(n, _)| n == name) {
plan.destructive.push(format!("DROP INDEX \"{name}\""));
}
}
}
fn index_entries(t: &TableSnapshot) -> Vec<(String, &String)> {
t.ddl
.iter()
.filter_map(|d| index_name(d).map(|n| (n, d)))
.collect()
}
fn index_name(ddl: &str) -> Option<String> {
let mut rest = strip_keyword(ddl, "CREATE")?;
if let Some(r) = strip_keyword(rest, "UNIQUE") {
rest = r;
}
rest = strip_keyword(rest, "INDEX")?;
if let Some(r) = strip_keyword(rest, "IF") {
rest = strip_keyword(strip_keyword(r, "NOT")?, "EXISTS")?;
}
parse_leading_identifier(rest)
}
fn strip_keyword<'a>(s: &'a str, kw: &str) -> Option<&'a str> {
let s = s.trim_start();
if s.len() < kw.len() || !s.as_bytes()[..kw.len()].eq_ignore_ascii_case(kw.as_bytes()) {
return None;
}
let rest = &s[kw.len()..];
match rest.chars().next() {
Some(c) if c.is_ascii_alphanumeric() || c == '_' => None,
_ => Some(rest),
}
}
fn parse_leading_identifier(s: &str) -> Option<String> {
let s = s.trim_start();
let mut chars = s.chars();
match chars.next()? {
q @ ('"' | '`') => {
let mut out = String::new();
let mut iter = chars.peekable();
while let Some(c) = iter.next() {
if c == q {
if iter.peek() == Some(&q) {
iter.next();
out.push(q);
continue;
}
return Some(out);
}
out.push(c);
}
None
}
'[' => s[1..].find(']').map(|end| s[1..1 + end].to_string()),
_ => {
let end = s
.find(|c: char| c.is_whitespace() || c == '(')
.unwrap_or(s.len());
if end == 0 {
None
} else {
Some(s[..end].to_string())
}
}
}
}
pub fn diff_sql(old: &SchemaSnapshot, new: &SchemaSnapshot) -> String {
let plan = diff_plan(old, new);
let mut out = format!(
"-- roomrs 자동 diff 초안: v{} -> v{}\n-- 검토 후 사용하세요. 파괴적 변경은 주석 처리되어 있습니다.\n",
old.version, new.version
);
for s in &plan.safe {
out.push_str(&format!("{s};\n"));
}
for d in &plan.destructive {
out.push_str(&format!("-- TODO(파괴적): {d}\n"));
}
for w in &plan.warnings {
out.push_str(&format!("-- 경고: {w}\n"));
}
out.push_str(&format!("PRAGMA user_version = {};\n", new.version));
out
}
#[cfg(test)]
mod tests {
use super::*;
fn col(name: &str, sql_type: &str, not_null: bool, pk: bool) -> ColumnSnapshot {
ColumnSnapshot {
name: name.into(),
sql_type: sql_type.into(),
not_null,
pk,
renamed_from: None,
}
}
fn table(name: &str, columns: Vec<ColumnSnapshot>, ddl: Vec<&str>) -> TableSnapshot {
TableSnapshot {
name: name.into(),
columns,
ddl: ddl.into_iter().map(String::from).collect(),
}
}
fn snap(version: u32, tables: Vec<TableSnapshot>) -> SchemaSnapshot {
SchemaSnapshot { version, tables }
}
#[test]
fn canonical_is_order_invariant() {
let a = snap(
1,
vec![table("b", vec![], vec![]), table("a", vec![], vec![])],
);
let mut b = a.clone();
b.tables.reverse();
assert_eq!(a.hash(), b.hash());
}
#[test]
fn hash_changes_on_column_change() {
let a = snap(
1,
vec![table("t", vec![col("id", "INTEGER", true, true)], vec![])],
);
let mut b = a.clone();
b.tables[0].columns[0].name = "id2".into();
assert_ne!(a.hash(), b.hash());
}
#[test]
fn canonical_no_field_boundary_collision() {
let a = snap(
1,
vec![table("t", vec![col("a", "X:1", false, false)], vec![])],
);
let b = snap(
1,
vec![table("t", vec![col("a:X", "1", false, false)], vec![])],
);
assert_ne!(a.canonical_string(), b.canonical_string());
assert_ne!(a.hash(), b.hash());
}
#[test]
fn compress_roundtrip() {
let json = snap(
3,
vec![table("t", vec![col("id", "INTEGER", true, true)], vec![])],
)
.to_json()
.unwrap();
let comp = compress_snapshot(json.as_bytes());
let back = decompress_snapshot(&comp).unwrap();
assert_eq!(back, json.as_bytes());
assert!(decompress_snapshot(&[0xFF, 0x00, 0x12]).is_err());
}
#[test]
fn list_snapshot_versions_strict() {
let dir = tempfile::tempdir().unwrap();
let s = snap(1, vec![]);
for name in [
"app.2.json",
"app.1.json",
"app.10.json",
"app.01.json", "app.x.json", "app..json", "other.3.json", "app.1.sql", ] {
s.write_to(&dir.path().join(name)).unwrap();
}
let got = list_snapshot_versions(dir.path(), "app").unwrap();
let versions: Vec<u32> = got.iter().map(|(v, _)| *v).collect();
assert_eq!(versions, vec![1, 2, 10], "오름차순 + 엄격 파스");
let missing = dir.path().join("없는-디렉토리");
assert!(list_snapshot_versions(&missing, "app").unwrap().is_empty());
}
#[test]
fn snapshot_path_rule() {
assert_eq!(snapshot_file_name("app_db", 3), "app_db.3.json");
assert_eq!(
snapshot_path(Path::new("/x"), "app_db", 3),
Path::new("/x").join("app_db.3.json")
);
}
#[test]
fn diff_plan_type_change_is_destructive() {
let old = snap(
1,
vec![table("t", vec![col("c", "TEXT", true, false)], vec![])],
);
let new = snap(
2,
vec![table("t", vec![col("c", "INTEGER", true, false)], vec![])],
);
let plan = diff_plan(&old, &new);
assert!(plan.safe.is_empty(), "{plan:?}");
assert_eq!(plan.destructive.len(), 1);
assert!(
plan.destructive[0].contains("테이블 재작성 필요"),
"{plan:?}"
);
assert!(plan.destructive[0].contains("sql_type"), "{plan:?}");
}
#[test]
fn diff_plan_valid_rename_is_safe() {
let old = snap(
1,
vec![table("t", vec![col("title", "TEXT", true, false)], vec![])],
);
let mut renamed = col("subject", "TEXT", true, false);
renamed.renamed_from = Some("title".into());
let new = snap(2, vec![table("t", vec![renamed], vec![])]);
let plan = diff_plan(&old, &new);
assert_eq!(
plan.safe,
vec![r#"ALTER TABLE "t" RENAME COLUMN "title" TO "subject""#.to_string()]
);
assert!(plan.destructive.is_empty(), "{plan:?}");
assert!(plan.warnings.is_empty(), "{plan:?}");
}
#[test]
fn diff_plan_rename_hint_ignored_when_source_still_exists() {
let old = snap(
1,
vec![table("t", vec![col("title", "TEXT", false, false)], vec![])],
);
let mut copied = col("subject", "TEXT", false, false);
copied.renamed_from = Some("title".into());
let new = snap(
2,
vec![table(
"t",
vec![col("title", "TEXT", false, false), copied],
vec![],
)],
);
let plan = diff_plan(&old, &new);
assert_eq!(plan.warnings.len(), 1, "{plan:?}");
assert!(plan.warnings[0].contains("rename 힌트 무시"), "{plan:?}");
assert_eq!(
plan.safe,
vec![r#"ALTER TABLE "t" ADD COLUMN "subject" TEXT"#.to_string()]
);
}
#[test]
fn diff_plan_rename_hint_missing_source_warns() {
let old = snap(
1,
vec![table("t", vec![col("id", "INTEGER", true, true)], vec![])],
);
let mut renamed = col("subject", "TEXT", false, false);
renamed.renamed_from = Some("ghost".into());
let new = snap(
2,
vec![table(
"t",
vec![col("id", "INTEGER", true, true), renamed],
vec![],
)],
);
let plan = diff_plan(&old, &new);
assert_eq!(plan.warnings.len(), 1, "{plan:?}");
assert!(plan.warnings[0].contains("잘못된 rename 힌트"), "{plan:?}");
assert_eq!(
plan.safe,
vec![r#"ALTER TABLE "t" ADD COLUMN "subject" TEXT"#.to_string()]
);
}
#[test]
fn diff_plan_rename_with_type_change_is_destructive() {
let old = snap(
1,
vec![table("t", vec![col("title", "TEXT", true, false)], vec![])],
);
let mut renamed = col("subject", "INTEGER", true, false);
renamed.renamed_from = Some("title".into());
let new = snap(2, vec![table("t", vec![renamed], vec![])]);
let plan = diff_plan(&old, &new);
assert!(plan.safe.is_empty(), "{plan:?}");
assert_eq!(plan.destructive.len(), 1, "{plan:?}");
assert!(
plan.destructive[0].contains("테이블 재작성 필요"),
"{plan:?}"
);
}
#[test]
fn diff_plan_index_add_remove() {
let ct = r#"CREATE TABLE "t" ("id" INTEGER PRIMARY KEY)"#;
let idx_a = r#"CREATE INDEX IF NOT EXISTS "idx_t_a" ON "t"("a")"#;
let idx_b = r#"CREATE UNIQUE INDEX "idx_t_b" ON "t"("b")"#;
let cols = vec![col("id", "INTEGER", true, true)];
let old = snap(1, vec![table("t", cols.clone(), vec![ct, idx_a])]);
let new = snap(2, vec![table("t", cols, vec![ct, idx_b])]);
let plan = diff_plan(&old, &new);
assert_eq!(plan.safe, vec![idx_b.to_string()], "신규 인덱스 = 안전");
assert_eq!(
plan.destructive,
vec![r#"DROP INDEX "idx_t_a""#.to_string()]
);
}
#[test]
fn diff_plan_not_null_add_is_destructive() {
let old = snap(
1,
vec![table("t", vec![col("id", "INTEGER", true, true)], vec![])],
);
let new = snap(
2,
vec![table(
"t",
vec![
col("id", "INTEGER", true, true),
col("note", "TEXT", true, false),
],
vec![],
)],
);
let plan = diff_plan(&old, &new);
assert!(plan.safe.is_empty(), "{plan:?}");
assert_eq!(plan.destructive.len(), 1, "{plan:?}");
assert!(plan.destructive[0].contains("DEFAULT 필요"), "{plan:?}");
assert!(
plan.destructive[0].contains(r#"ADD COLUMN "note""#),
"{plan:?}"
);
}
#[test]
fn diff_plan_table_add_remove() {
let ct = r#"CREATE TABLE "b" ("id" INTEGER PRIMARY KEY)"#;
let old = snap(1, vec![table("a", vec![], vec![])]);
let new = snap(
2,
vec![table("b", vec![col("id", "INTEGER", true, true)], vec![ct])],
);
let plan = diff_plan(&old, &new);
assert_eq!(plan.safe, vec![ct.to_string()]);
assert_eq!(plan.destructive, vec![r#"DROP TABLE "a""#.to_string()]);
}
#[test]
fn diff_plan_constraint_change_is_destructive() {
let cols = vec![
col("id", "INTEGER", true, true),
col("email", "TEXT", true, false),
];
let ct_old = r#"CREATE TABLE "t" ("id" INTEGER PRIMARY KEY, "email" TEXT NOT NULL)"#;
let ct_new = r#"CREATE TABLE "t" ("id" INTEGER PRIMARY KEY, "email" TEXT NOT NULL UNIQUE)"#;
let old = snap(1, vec![table("t", cols.clone(), vec![ct_old])]);
let new = snap(2, vec![table("t", cols, vec![ct_new])]);
let plan = diff_plan(&old, &new);
assert!(plan.safe.is_empty(), "{plan:?}");
assert_eq!(plan.destructive.len(), 1, "{plan:?}");
assert!(plan.destructive[0].contains("제약/DDL 변경"), "{plan:?}");
}
#[test]
fn diff_plan_constraint_same_ddl_no_item() {
let cols = vec![col("id", "INTEGER", true, true)];
let ct = r#"CREATE TABLE "t" ("id" INTEGER PRIMARY KEY)"#;
let ct_ws = "CREATE TABLE \"t\"\n(\"id\" INTEGER PRIMARY KEY)";
let old = snap(1, vec![table("t", cols.clone(), vec![ct])]);
let new = snap(2, vec![table("t", cols, vec![ct_ws])]);
let plan = diff_plan(&old, &new);
assert!(plan.destructive.is_empty(), "공백 정규화: {plan:?}");
assert!(plan.safe.is_empty(), "{plan:?}");
}
#[test]
fn diff_plan_column_add_no_false_rewrite() {
let ct_old = r#"CREATE TABLE "t" ("id" INTEGER PRIMARY KEY)"#;
let ct_new = r#"CREATE TABLE "t" ("id" INTEGER PRIMARY KEY, "note" TEXT)"#;
let old = snap(
1,
vec![table(
"t",
vec![col("id", "INTEGER", true, true)],
vec![ct_old],
)],
);
let new = snap(
2,
vec![table(
"t",
vec![
col("id", "INTEGER", true, true),
col("note", "TEXT", false, false),
],
vec![ct_new],
)],
);
let plan = diff_plan(&old, &new);
assert_eq!(
plan.safe,
vec![r#"ALTER TABLE "t" ADD COLUMN "note" TEXT"#.to_string()]
);
assert!(plan.destructive.is_empty(), "재작성 오탐: {plan:?}");
}
#[test]
fn diff_plan_duplicate_rename_source_degrades() {
let old = snap(
1,
vec![table("t", vec![col("a", "TEXT", false, false)], vec![])],
);
let mut b = col("b", "TEXT", false, false);
b.renamed_from = Some("a".into());
let mut c = col("c", "TEXT", false, false);
c.renamed_from = Some("a".into());
let new = snap(2, vec![table("t", vec![b, c], vec![])]);
let plan = diff_plan(&old, &new);
assert_eq!(
plan.safe,
vec![
r#"ALTER TABLE "t" RENAME COLUMN "a" TO "b""#.to_string(),
r#"ALTER TABLE "t" ADD COLUMN "c" TEXT"#.to_string(),
],
"{plan:?}"
);
assert_eq!(plan.warnings.len(), 1, "{plan:?}");
assert!(plan.warnings[0].contains("rename 힌트 중복"), "{plan:?}");
assert!(plan.destructive.is_empty(), "{plan:?}");
}
#[test]
fn index_name_multibyte_no_panic() {
assert_eq!(
index_name(r#"CREATE INDEX "할일_idx" ON t(a)"#),
Some("할일_idx".to_string())
);
assert_eq!(
index_name("CREATE INDEX 할일 ON t(a)"),
Some("할일".to_string())
);
assert_eq!(
index_name(r#"CREATE UNIQUE INDEX IF NOT EXISTS "메모 색인" ON t(b)"#),
Some("메모 색인".to_string())
);
}
#[test]
fn diff_plan_constraint_literal_space_change_detected() {
let cols = vec![col("a", "TEXT", false, false)];
let ct_old = r#"CREATE TABLE "t" ("a" TEXT DEFAULT 'a b')"#;
let ct_new = r#"CREATE TABLE "t" ("a" TEXT DEFAULT 'a b')"#;
let old = snap(1, vec![table("t", cols.clone(), vec![ct_old])]);
let new = snap(2, vec![table("t", cols, vec![ct_new])]);
let plan = diff_plan(&old, &new);
assert_eq!(
plan.destructive.len(),
1,
"리터럴 내부 공백 = 실 변경: {plan:?}"
);
assert!(plan.destructive[0].contains("제약/DDL 변경"), "{plan:?}");
}
#[test]
fn diff_plan_constraint_empty_ddl_degrades_to_warning() {
let cols = vec![col("a", "TEXT", false, false)];
let ct = r#"CREATE TABLE "t" ("a" TEXT)"#;
let old = snap(1, vec![table("t", cols.clone(), vec![])]);
let new = snap(2, vec![table("t", cols, vec![ct])]);
let plan = diff_plan(&old, &new);
assert!(plan.destructive.is_empty(), "재작성 오탐: {plan:?}");
assert_eq!(plan.warnings.len(), 1, "{plan:?}");
assert!(plan.warnings[0].contains("제약 비교 스킵"), "{plan:?}");
}
#[test]
fn normalize_ws_quote_aware() {
assert_eq!(
normalize_ws_outside_quotes("CREATE TABLE \"t\"\n(\"a b\" TEXT DEFAULT 'x y')"),
r#"CREATE TABLE "t" ("a b" TEXT DEFAULT 'x y')"#
);
assert_eq!(
normalize_ws_outside_quotes("DEFAULT 'o''clock x'"),
"DEFAULT 'o''clock x'"
);
}
#[test]
fn index_diff_ignores_whitespace_outside_quotes() {
let old = snap(
1,
vec![table(
"items",
vec![],
vec!["CREATE INDEX idx_name\n ON items (name)"],
)],
);
let new = snap(
2,
vec![table(
"items",
vec![],
vec!["CREATE INDEX idx_name ON items (name)"],
)],
);
assert!(diff_plan(&old, &new).destructive.is_empty());
}
#[test]
fn index_diff_preserves_whitespace_inside_quotes() {
let old = snap(
1,
vec![table(
"items",
vec![],
vec!["CREATE INDEX idx_name ON items ('a b')"],
)],
);
let new = snap(
2,
vec![table(
"items",
vec![],
vec!["CREATE INDEX idx_name ON items ('a b')"],
)],
);
assert_eq!(diff_plan(&old, &new).destructive.len(), 1);
}
#[test]
fn index_name_quoted_variants() {
assert_eq!(
index_name(r#"CREATE INDEX "my idx" ON "t"("a")"#),
Some("my idx".to_string())
);
assert_eq!(
index_name(r#"CREATE UNIQUE INDEX IF NOT EXISTS "a""b" ON t(x)"#),
Some("a\"b".to_string())
);
assert_eq!(
index_name("CREATE INDEX `sp ace` ON t(a)"),
Some("sp ace".to_string())
);
assert_eq!(
index_name("CREATE INDEX [br idx] ON t(a)"),
Some("br idx".to_string())
);
assert_eq!(
index_name("create unique index idx_t_a(a)"),
Some("idx_t_a".to_string())
);
assert_eq!(index_name(r#"CREATE TABLE "t" ("id" INTEGER)"#), None);
assert_eq!(index_name(r#"CREATE INDEX "broken ON t(a)"#), None);
assert_eq!(index_name("CREATE INDEXED_VIEW x"), None);
}
#[test]
fn list_snapshot_versions_overflow_is_error() {
let dir = tempfile::tempdir().unwrap();
let s = snap(1, vec![]);
s.write_to(&dir.path().join("app.1.json")).unwrap();
s.write_to(&dir.path().join("app.99999999999.json"))
.unwrap();
let err = list_snapshot_versions(dir.path(), "app").unwrap_err();
assert!(err.to_string().contains("u32 범위"), "{err}");
}
#[test]
fn resolve_schema_dir_relative_env() {
#[allow(unsafe_code)]
unsafe {
std::env::set_var("ROOMRS_SCHEMA_DIR", "custom/schema");
}
let got = resolve_schema_dir("/proj");
#[allow(unsafe_code)]
unsafe {
std::env::remove_var("ROOMRS_SCHEMA_DIR");
}
assert_eq!(got, Path::new("/proj").join("custom/schema"));
}
#[test]
fn diff_sql_renders_plan() {
let old = snap(
1,
vec![table(
"t",
vec![
col("id", "INTEGER", true, true),
col("gone", "TEXT", false, false),
],
vec![],
)],
);
let new = snap(
2,
vec![table(
"t",
vec![
col("id", "INTEGER", true, true),
col("name", "TEXT", false, false),
],
vec![],
)],
);
let sql = diff_sql(&old, &new);
assert!(
sql.contains("ALTER TABLE \"t\" ADD COLUMN \"name\" TEXT;"),
"{sql}"
);
assert!(
sql.contains("-- TODO(파괴적): ALTER TABLE \"t\" DROP COLUMN \"gone\""),
"{sql}"
);
assert!(sql.contains("PRAGMA user_version = 2;"), "{sql}");
}
}