1#![deny(unsafe_code)]
6
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub struct SchemaSnapshot {
13 pub version: u32,
15 pub tables: Vec<TableSnapshot>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub struct TableSnapshot {
22 pub name: String,
23 pub columns: Vec<ColumnSnapshot>,
24 #[serde(default)]
26 pub ddl: Vec<String>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31pub struct ColumnSnapshot {
32 pub name: String,
33 pub sql_type: String,
35 pub not_null: bool,
36 pub pk: bool,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub renamed_from: Option<String>,
40}
41
42impl SchemaSnapshot {
43 pub fn read_from(path: &Path) -> std::io::Result<Self> {
45 let raw = std::fs::read_to_string(path)?;
46 serde_json::from_str(&raw).map_err(std::io::Error::other)
47 }
48
49 pub fn from_slice(bytes: &[u8]) -> std::io::Result<Self> {
51 serde_json::from_slice(bytes).map_err(std::io::Error::other)
52 }
53
54 pub fn to_json(&self) -> std::io::Result<String> {
56 serde_json::to_string_pretty(self).map_err(std::io::Error::other)
57 }
58
59 pub fn write_to(&self, path: &Path) -> std::io::Result<()> {
61 if let Some(parent) = path.parent() {
62 std::fs::create_dir_all(parent)?;
63 }
64 std::fs::write(path, self.to_json()?)
65 }
66
67 pub fn table(&self, name: &str) -> Option<&TableSnapshot> {
69 self.tables.iter().find(|t| t.name == name)
70 }
71
72 pub fn canonical_string(&self) -> String {
76 fn push_field(out: &mut String, s: &str) {
78 out.push_str(&format!("{}:{}", s.len(), s));
79 }
80 let mut tables: Vec<&TableSnapshot> = self.tables.iter().collect();
81 tables.sort_by(|a, b| a.name.cmp(&b.name));
82 let mut out = format!("v{};", self.version);
83 for t in tables {
84 out.push('t');
85 push_field(&mut out, &t.name);
86 out.push('(');
87 for c in &t.columns {
88 out.push('c');
89 push_field(&mut out, &c.name);
90 out.push('y');
91 push_field(&mut out, &c.sql_type);
92 out.push_str(&format!("n{}p{},", c.not_null as u8, c.pk as u8));
93 }
94 out.push_str(");");
95 for d in &t.ddl {
97 out.push('d');
98 push_field(&mut out, d);
99 out.push(';');
100 }
101 }
102 out
103 }
104
105 pub fn hash(&self) -> u64 {
107 fnv1a64(self.canonical_string().as_bytes())
108 }
109}
110
111impl TableSnapshot {
112 pub fn has_column(&self, name: &str) -> bool {
114 self.columns.iter().any(|c| c.name == name)
115 }
116}
117
118fn fnv1a64(bytes: &[u8]) -> u64 {
120 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
121 for b in bytes {
122 hash ^= u64::from(*b);
123 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
124 }
125 hash
126}
127
128const DECOMPRESS_LIMIT: usize = 64 * 1024 * 1024;
132
133pub fn compress_snapshot(bytes: &[u8]) -> Vec<u8> {
138 miniz_oxide::deflate::compress_to_vec(bytes, 8)
139}
140
141pub fn decompress_snapshot(bytes: &[u8]) -> std::io::Result<Vec<u8>> {
146 miniz_oxide::inflate::decompress_to_vec_with_limit(bytes, DECOMPRESS_LIMIT)
147 .map_err(|_| std::io::Error::other("스냅샷 압축 해제 실패: 스트림 손상 또는 64MB 초과"))
148}
149
150pub const SCHEMA_DIR_RELATIVE: &str = "migrations/schema";
154
155pub fn resolve_schema_dir(manifest_dir: &str) -> PathBuf {
166 match std::env::var("ROOMRS_SCHEMA_DIR") {
167 Ok(p) if !p.is_empty() => {
168 let p = PathBuf::from(p);
169 if p.is_relative() {
172 Path::new(manifest_dir).join(p)
173 } else {
174 p
175 }
176 }
177 _ => Path::new(manifest_dir).join(SCHEMA_DIR_RELATIVE),
178 }
179}
180
181pub fn snapshot_file_name(db_name: &str, version: u32) -> String {
184 format!("{db_name}.{version}.json")
185}
186
187pub fn snapshot_path(dir: &Path, db_name: &str, version: u32) -> PathBuf {
189 dir.join(snapshot_file_name(db_name, version))
190}
191
192pub fn list_snapshot_versions(dir: &Path, db_name: &str) -> std::io::Result<Vec<(u32, PathBuf)>> {
197 let mut out: Vec<(u32, PathBuf)> = Vec::new();
198 let rd = match std::fs::read_dir(dir) {
199 Ok(rd) => rd,
200 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
202 Err(e) => return Err(e),
203 };
204 let prefix = format!("{db_name}.");
205 for entry in rd {
206 let entry = entry?;
207 let name = entry.file_name();
208 let Some(name) = name.to_str() else { continue };
209 let Some(v) = parse_snapshot_version(name, &prefix)? else {
210 continue;
211 };
212 out.push((v, entry.path()));
213 }
214 out.sort_by_key(|(v, _)| *v);
215 Ok(out)
216}
217
218fn parse_snapshot_version(name: &str, prefix: &str) -> std::io::Result<Option<u32>> {
222 let Some(rest) = name.strip_prefix(prefix) else {
223 return Ok(None);
224 };
225 let Some(num) = rest.strip_suffix(".json") else {
226 return Ok(None);
227 };
228 if num.is_empty() || !num.bytes().all(|b| b.is_ascii_digit()) {
229 return Ok(None);
230 }
231 if num.len() > 1 && num.starts_with('0') {
233 return Ok(None);
234 }
235 match num.parse() {
236 Ok(v) => Ok(Some(v)),
237 Err(_) => Err(std::io::Error::other(format!(
239 "스냅샷 파일명 버전이 u32 범위를 넘습니다: {name}"
240 ))),
241 }
242}
243
244#[derive(Debug, Clone, Default, PartialEq, Eq)]
255pub struct DiffPlan {
256 pub safe: Vec<String>,
258 pub destructive: Vec<String>,
260 pub warnings: Vec<String>,
262}
263
264pub fn diff_plan(old: &SchemaSnapshot, new: &SchemaSnapshot) -> DiffPlan {
281 let mut plan = DiffPlan::default();
282
283 for nt in &new.tables {
285 if old.table(&nt.name).is_none() {
286 plan.safe.extend(nt.ddl.iter().cloned());
287 }
288 }
289
290 for ot in &old.tables {
292 if new.table(&ot.name).is_none() {
293 plan.destructive.push(format!("DROP TABLE \"{}\"", ot.name));
294 }
295 }
296
297 for nt in &new.tables {
299 let Some(ot) = old.table(&nt.name) else {
300 continue;
301 };
302 diff_columns(ot, nt, &mut plan);
303 diff_indexes(ot, nt, &mut plan);
304 diff_table_constraints(ot, nt, &mut plan);
305 }
306
307 plan
308}
309
310fn diff_table_constraints(ot: &TableSnapshot, nt: &TableSnapshot, plan: &mut DiffPlan) {
320 let same_columns = ot.columns.len() == nt.columns.len()
322 && nt.columns.iter().all(|n| {
323 ot.columns.iter().any(|o| {
324 o.name == n.name
325 && o.sql_type == n.sql_type
326 && o.not_null == n.not_null
327 && o.pk == n.pk
328 })
329 });
330 if !same_columns {
331 return;
332 }
333 if ot.ddl.is_empty() || nt.ddl.is_empty() {
336 if ot.ddl.is_empty() != nt.ddl.is_empty() {
337 plan.warnings.push(format!(
338 "제약 비교 스킵: \"{}\" — 한쪽 스냅샷에 ddl 정보가 없습니다(구형 스냅샷) — write_schema_snapshot 재생성 권장",
339 nt.name
340 ));
341 }
342 return;
343 }
344 if create_table_entries(ot) != create_table_entries(nt) {
345 plan.destructive.push(format!(
346 "테이블 재작성 필요(제약/DDL 변경): \"{}\" — 컬럼 정의는 동일하나 CREATE TABLE 문이 다릅니다",
347 nt.name
348 ));
349 }
350}
351
352fn create_table_entries(t: &TableSnapshot) -> Vec<String> {
355 t.ddl
356 .iter()
357 .filter(|d| index_name(d).is_none())
358 .map(|d| normalize_ws_outside_quotes(d))
359 .collect()
360}
361
362fn normalize_ws_outside_quotes(s: &str) -> String {
367 let mut out = String::with_capacity(s.len());
368 let mut quote: Option<char> = None;
370 let mut pending_ws = false;
372 for c in s.chars() {
373 match quote {
374 Some(q) => {
375 out.push(c);
376 if c == q {
377 quote = None;
378 }
379 }
380 None if c.is_whitespace() => pending_ws = true,
381 None => {
382 if pending_ws && !out.is_empty() {
383 out.push(' ');
384 }
385 pending_ws = false;
386 if c == '\'' || c == '"' {
387 quote = Some(c);
388 }
389 out.push(c);
390 }
391 }
392 }
393 out
394}
395
396fn diff_columns(ot: &TableSnapshot, nt: &TableSnapshot, plan: &mut DiffPlan) {
399 let mut consumed_sources: Vec<&str> = Vec::new();
402 for nc in &nt.columns {
403 if let Some(oc) = ot.columns.iter().find(|c| c.name == nc.name) {
405 let mut changes: Vec<String> = Vec::new();
406 if oc.sql_type != nc.sql_type {
407 changes.push(format!(
408 "sql_type \"{}\" -> \"{}\"",
409 oc.sql_type, nc.sql_type
410 ));
411 }
412 if oc.not_null != nc.not_null {
413 changes.push(format!("not_null {} -> {}", oc.not_null, nc.not_null));
414 }
415 if oc.pk != nc.pk {
416 changes.push(format!("pk {} -> {}", oc.pk, nc.pk));
417 }
418 if !changes.is_empty() {
419 plan.destructive.push(format!(
420 "테이블 재작성 필요: \"{}\".\"{}\" 정의 변경 ({})",
421 nt.name,
422 nc.name,
423 changes.join(", ")
424 ));
425 }
426 continue;
427 }
428
429 if let Some(from) = &nc.renamed_from {
431 if consumed_sources.contains(&from.as_str()) {
432 plan.warnings.push(format!(
435 "rename 힌트 중복: \"{}\".\"{from}\" 은 이미 다른 컬럼의 rename 원본으로 소비됨 — \"{}\"는 ADD COLUMN으로 처리",
436 nt.name, nc.name
437 ));
438 } else if nt.has_column(from) {
439 plan.warnings.push(format!(
441 "rename 힌트 무시: 새 스키마 \"{}\"에 \"{from}\" 컬럼이 여전히 존재 — \"{}\"는 ADD COLUMN으로 처리",
442 nt.name, nc.name
443 ));
444 } else if let Some(of) = ot.columns.iter().find(|c| &c.name == from) {
445 consumed_sources.push(from);
447 if of.sql_type == nc.sql_type && of.not_null == nc.not_null && of.pk == nc.pk {
448 plan.safe.push(format!(
449 "ALTER TABLE \"{}\" RENAME COLUMN \"{from}\" TO \"{}\"",
450 nt.name, nc.name
451 ));
452 } else {
453 plan.destructive.push(format!(
455 "테이블 재작성 필요: \"{}\".\"{from}\" -> \"{}\" rename에 정의 변경이 동반됨",
456 nt.name, nc.name
457 ));
458 }
459 continue;
460 } else {
461 plan.warnings.push(format!(
462 "잘못된 rename 힌트: 옛 스키마 \"{}\"에 \"{from}\" 컬럼 없음 — \"{}\"는 ADD COLUMN으로 처리",
463 nt.name, nc.name
464 ));
465 }
466 }
467
468 let mut col = format!("\"{}\"", nc.name);
470 if !nc.sql_type.is_empty() {
471 col.push_str(&format!(" {}", nc.sql_type));
472 }
473 if nc.pk {
474 plan.destructive.push(format!(
475 "테이블 재작성 필요: \"{}\".\"{}\" — PK 컬럼은 ADD COLUMN 불가",
476 nt.name, nc.name
477 ));
478 } else if nc.not_null {
479 plan.destructive.push(format!(
482 "ALTER TABLE \"{}\" ADD COLUMN {col} NOT NULL — DEFAULT 필요(기존 행)",
483 nt.name
484 ));
485 } else {
486 plan.safe
487 .push(format!("ALTER TABLE \"{}\" ADD COLUMN {col}", nt.name));
488 }
489 }
490
491 for oc in &ot.columns {
493 let renamed_away = nt
494 .columns
495 .iter()
496 .any(|nc| nc.renamed_from.as_deref() == Some(oc.name.as_str()));
497 if !nt.has_column(&oc.name) && !renamed_away {
498 plan.destructive.push(format!(
499 "ALTER TABLE \"{}\" DROP COLUMN \"{}\"",
500 nt.name, oc.name
501 ));
502 }
503 }
504}
505
506fn diff_indexes(ot: &TableSnapshot, nt: &TableSnapshot, plan: &mut DiffPlan) {
508 let old_idx: Vec<(String, &String)> = index_entries(ot);
509 let new_idx: Vec<(String, &String)> = index_entries(nt);
510
511 for (name, ddl) in &new_idx {
512 match old_idx.iter().find(|(n, _)| n == name) {
513 None => plan.safe.push((*ddl).clone()),
515 Some((_, old_ddl))
517 if normalize_ws_outside_quotes(old_ddl) != normalize_ws_outside_quotes(ddl) =>
518 {
519 plan.destructive.push(format!(
520 "인덱스 재생성 필요: DROP INDEX \"{name}\" 후 재생성 ({ddl})"
521 ))
522 }
523 Some(_) => {}
524 }
525 }
526 for (name, _) in &old_idx {
527 if !new_idx.iter().any(|(n, _)| n == name) {
528 plan.destructive.push(format!("DROP INDEX \"{name}\""));
529 }
530 }
531}
532
533fn index_entries(t: &TableSnapshot) -> Vec<(String, &String)> {
535 t.ddl
536 .iter()
537 .filter_map(|d| index_name(d).map(|n| (n, d)))
538 .collect()
539}
540
541fn index_name(ddl: &str) -> Option<String> {
545 let mut rest = strip_keyword(ddl, "CREATE")?;
546 if let Some(r) = strip_keyword(rest, "UNIQUE") {
547 rest = r;
548 }
549 rest = strip_keyword(rest, "INDEX")?;
550 if let Some(r) = strip_keyword(rest, "IF") {
551 rest = strip_keyword(strip_keyword(r, "NOT")?, "EXISTS")?;
553 }
554 parse_leading_identifier(rest)
555}
556
557fn strip_keyword<'a>(s: &'a str, kw: &str) -> Option<&'a str> {
562 let s = s.trim_start();
563 if s.len() < kw.len() || !s.as_bytes()[..kw.len()].eq_ignore_ascii_case(kw.as_bytes()) {
564 return None;
565 }
566 let rest = &s[kw.len()..];
567 match rest.chars().next() {
569 Some(c) if c.is_ascii_alphanumeric() || c == '_' => None,
570 _ => Some(rest),
571 }
572}
573
574fn parse_leading_identifier(s: &str) -> Option<String> {
576 let s = s.trim_start();
577 let mut chars = s.chars();
578 match chars.next()? {
579 q @ ('"' | '`') => {
581 let mut out = String::new();
582 let mut iter = chars.peekable();
583 while let Some(c) = iter.next() {
584 if c == q {
585 if iter.peek() == Some(&q) {
586 iter.next();
587 out.push(q);
588 continue;
589 }
590 return Some(out);
591 }
592 out.push(c);
593 }
594 None
596 }
597 '[' => s[1..].find(']').map(|end| s[1..1 + end].to_string()),
599 _ => {
600 let end = s
602 .find(|c: char| c.is_whitespace() || c == '(')
603 .unwrap_or(s.len());
604 if end == 0 {
605 None
606 } else {
607 Some(s[..end].to_string())
608 }
609 }
610 }
611}
612
613pub fn diff_sql(old: &SchemaSnapshot, new: &SchemaSnapshot) -> String {
617 let plan = diff_plan(old, new);
618 let mut out = format!(
619 "-- roomrs 자동 diff 초안: v{} -> v{}\n-- 검토 후 사용하세요. 파괴적 변경은 주석 처리되어 있습니다.\n",
620 old.version, new.version
621 );
622 for s in &plan.safe {
623 out.push_str(&format!("{s};\n"));
624 }
625 for d in &plan.destructive {
626 out.push_str(&format!("-- TODO(파괴적): {d}\n"));
627 }
628 for w in &plan.warnings {
629 out.push_str(&format!("-- 경고: {w}\n"));
630 }
631 out.push_str(&format!("PRAGMA user_version = {};\n", new.version));
632 out
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638
639 fn col(name: &str, sql_type: &str, not_null: bool, pk: bool) -> ColumnSnapshot {
641 ColumnSnapshot {
642 name: name.into(),
643 sql_type: sql_type.into(),
644 not_null,
645 pk,
646 renamed_from: None,
647 }
648 }
649
650 fn table(name: &str, columns: Vec<ColumnSnapshot>, ddl: Vec<&str>) -> TableSnapshot {
652 TableSnapshot {
653 name: name.into(),
654 columns,
655 ddl: ddl.into_iter().map(String::from).collect(),
656 }
657 }
658
659 fn snap(version: u32, tables: Vec<TableSnapshot>) -> SchemaSnapshot {
661 SchemaSnapshot { version, tables }
662 }
663
664 #[test]
666 fn canonical_is_order_invariant() {
667 let a = snap(
668 1,
669 vec![table("b", vec![], vec![]), table("a", vec![], vec![])],
670 );
671 let mut b = a.clone();
672 b.tables.reverse();
673 assert_eq!(a.hash(), b.hash());
674 }
675
676 #[test]
678 fn hash_changes_on_column_change() {
679 let a = snap(
680 1,
681 vec![table("t", vec![col("id", "INTEGER", true, true)], vec![])],
682 );
683 let mut b = a.clone();
684 b.tables[0].columns[0].name = "id2".into();
685 assert_ne!(a.hash(), b.hash());
686 }
687
688 #[test]
690 fn canonical_no_field_boundary_collision() {
691 let a = snap(
693 1,
694 vec![table("t", vec![col("a", "X:1", false, false)], vec![])],
695 );
696 let b = snap(
697 1,
698 vec![table("t", vec![col("a:X", "1", false, false)], vec![])],
699 );
700 assert_ne!(a.canonical_string(), b.canonical_string());
701 assert_ne!(a.hash(), b.hash());
702 }
703
704 #[test]
706 fn compress_roundtrip() {
707 let json = snap(
708 3,
709 vec![table("t", vec![col("id", "INTEGER", true, true)], vec![])],
710 )
711 .to_json()
712 .unwrap();
713 let comp = compress_snapshot(json.as_bytes());
714 let back = decompress_snapshot(&comp).unwrap();
715 assert_eq!(back, json.as_bytes());
716 assert!(decompress_snapshot(&[0xFF, 0x00, 0x12]).is_err());
718 }
719
720 #[test]
722 fn list_snapshot_versions_strict() {
723 let dir = tempfile::tempdir().unwrap();
724 let s = snap(1, vec![]);
725 for name in [
726 "app.2.json",
727 "app.1.json",
728 "app.10.json",
729 "app.01.json", "app.x.json", "app..json", "other.3.json", "app.1.sql", ] {
735 s.write_to(&dir.path().join(name)).unwrap();
736 }
737 let got = list_snapshot_versions(dir.path(), "app").unwrap();
738 let versions: Vec<u32> = got.iter().map(|(v, _)| *v).collect();
739 assert_eq!(versions, vec![1, 2, 10], "오름차순 + 엄격 파스");
740
741 let missing = dir.path().join("없는-디렉토리");
743 assert!(list_snapshot_versions(&missing, "app").unwrap().is_empty());
744 }
745
746 #[test]
748 fn snapshot_path_rule() {
749 assert_eq!(snapshot_file_name("app_db", 3), "app_db.3.json");
750 assert_eq!(
751 snapshot_path(Path::new("/x"), "app_db", 3),
752 Path::new("/x").join("app_db.3.json")
753 );
754 }
755
756 #[test]
758 fn diff_plan_type_change_is_destructive() {
759 let old = snap(
760 1,
761 vec![table("t", vec![col("c", "TEXT", true, false)], vec![])],
762 );
763 let new = snap(
764 2,
765 vec![table("t", vec![col("c", "INTEGER", true, false)], vec![])],
766 );
767 let plan = diff_plan(&old, &new);
768 assert!(plan.safe.is_empty(), "{plan:?}");
769 assert_eq!(plan.destructive.len(), 1);
770 assert!(
771 plan.destructive[0].contains("테이블 재작성 필요"),
772 "{plan:?}"
773 );
774 assert!(plan.destructive[0].contains("sql_type"), "{plan:?}");
775 }
776
777 #[test]
779 fn diff_plan_valid_rename_is_safe() {
780 let old = snap(
781 1,
782 vec![table("t", vec![col("title", "TEXT", true, false)], vec![])],
783 );
784 let mut renamed = col("subject", "TEXT", true, false);
785 renamed.renamed_from = Some("title".into());
786 let new = snap(2, vec![table("t", vec![renamed], vec![])]);
787 let plan = diff_plan(&old, &new);
788 assert_eq!(
789 plan.safe,
790 vec![r#"ALTER TABLE "t" RENAME COLUMN "title" TO "subject""#.to_string()]
791 );
792 assert!(plan.destructive.is_empty(), "{plan:?}");
793 assert!(plan.warnings.is_empty(), "{plan:?}");
794 }
795
796 #[test]
798 fn diff_plan_rename_hint_ignored_when_source_still_exists() {
799 let old = snap(
800 1,
801 vec![table("t", vec![col("title", "TEXT", false, false)], vec![])],
802 );
803 let mut copied = col("subject", "TEXT", false, false);
804 copied.renamed_from = Some("title".into());
805 let new = snap(
806 2,
807 vec![table(
808 "t",
809 vec![col("title", "TEXT", false, false), copied],
810 vec![],
811 )],
812 );
813 let plan = diff_plan(&old, &new);
814 assert_eq!(plan.warnings.len(), 1, "{plan:?}");
815 assert!(plan.warnings[0].contains("rename 힌트 무시"), "{plan:?}");
816 assert_eq!(
818 plan.safe,
819 vec![r#"ALTER TABLE "t" ADD COLUMN "subject" TEXT"#.to_string()]
820 );
821 }
822
823 #[test]
825 fn diff_plan_rename_hint_missing_source_warns() {
826 let old = snap(
827 1,
828 vec![table("t", vec![col("id", "INTEGER", true, true)], vec![])],
829 );
830 let mut renamed = col("subject", "TEXT", false, false);
831 renamed.renamed_from = Some("ghost".into());
832 let new = snap(
833 2,
834 vec![table(
835 "t",
836 vec![col("id", "INTEGER", true, true), renamed],
837 vec![],
838 )],
839 );
840 let plan = diff_plan(&old, &new);
841 assert_eq!(plan.warnings.len(), 1, "{plan:?}");
842 assert!(plan.warnings[0].contains("잘못된 rename 힌트"), "{plan:?}");
843 assert_eq!(
844 plan.safe,
845 vec![r#"ALTER TABLE "t" ADD COLUMN "subject" TEXT"#.to_string()]
846 );
847 }
848
849 #[test]
851 fn diff_plan_rename_with_type_change_is_destructive() {
852 let old = snap(
853 1,
854 vec![table("t", vec![col("title", "TEXT", true, false)], vec![])],
855 );
856 let mut renamed = col("subject", "INTEGER", true, false);
857 renamed.renamed_from = Some("title".into());
858 let new = snap(2, vec![table("t", vec![renamed], vec![])]);
859 let plan = diff_plan(&old, &new);
860 assert!(plan.safe.is_empty(), "{plan:?}");
861 assert_eq!(plan.destructive.len(), 1, "{plan:?}");
862 assert!(
863 plan.destructive[0].contains("테이블 재작성 필요"),
864 "{plan:?}"
865 );
866 }
867
868 #[test]
870 fn diff_plan_index_add_remove() {
871 let ct = r#"CREATE TABLE "t" ("id" INTEGER PRIMARY KEY)"#;
872 let idx_a = r#"CREATE INDEX IF NOT EXISTS "idx_t_a" ON "t"("a")"#;
873 let idx_b = r#"CREATE UNIQUE INDEX "idx_t_b" ON "t"("b")"#;
874 let cols = vec![col("id", "INTEGER", true, true)];
875 let old = snap(1, vec![table("t", cols.clone(), vec![ct, idx_a])]);
876 let new = snap(2, vec![table("t", cols, vec![ct, idx_b])]);
877 let plan = diff_plan(&old, &new);
878 assert_eq!(plan.safe, vec![idx_b.to_string()], "신규 인덱스 = 안전");
879 assert_eq!(
880 plan.destructive,
881 vec![r#"DROP INDEX "idx_t_a""#.to_string()]
882 );
883 }
884
885 #[test]
887 fn diff_plan_not_null_add_is_destructive() {
888 let old = snap(
889 1,
890 vec![table("t", vec![col("id", "INTEGER", true, true)], vec![])],
891 );
892 let new = snap(
893 2,
894 vec![table(
895 "t",
896 vec![
897 col("id", "INTEGER", true, true),
898 col("note", "TEXT", true, false),
899 ],
900 vec![],
901 )],
902 );
903 let plan = diff_plan(&old, &new);
904 assert!(plan.safe.is_empty(), "{plan:?}");
905 assert_eq!(plan.destructive.len(), 1, "{plan:?}");
906 assert!(plan.destructive[0].contains("DEFAULT 필요"), "{plan:?}");
907 assert!(
908 plan.destructive[0].contains(r#"ADD COLUMN "note""#),
909 "{plan:?}"
910 );
911 }
912
913 #[test]
915 fn diff_plan_table_add_remove() {
916 let ct = r#"CREATE TABLE "b" ("id" INTEGER PRIMARY KEY)"#;
917 let old = snap(1, vec![table("a", vec![], vec![])]);
918 let new = snap(
919 2,
920 vec![table("b", vec![col("id", "INTEGER", true, true)], vec![ct])],
921 );
922 let plan = diff_plan(&old, &new);
923 assert_eq!(plan.safe, vec![ct.to_string()]);
924 assert_eq!(plan.destructive, vec![r#"DROP TABLE "a""#.to_string()]);
925 }
926
927 #[test]
929 fn diff_plan_constraint_change_is_destructive() {
930 let cols = vec![
931 col("id", "INTEGER", true, true),
932 col("email", "TEXT", true, false),
933 ];
934 let ct_old = r#"CREATE TABLE "t" ("id" INTEGER PRIMARY KEY, "email" TEXT NOT NULL)"#;
935 let ct_new = r#"CREATE TABLE "t" ("id" INTEGER PRIMARY KEY, "email" TEXT NOT NULL UNIQUE)"#;
936 let old = snap(1, vec![table("t", cols.clone(), vec![ct_old])]);
937 let new = snap(2, vec![table("t", cols, vec![ct_new])]);
938 let plan = diff_plan(&old, &new);
939 assert!(plan.safe.is_empty(), "{plan:?}");
940 assert_eq!(plan.destructive.len(), 1, "{plan:?}");
941 assert!(plan.destructive[0].contains("제약/DDL 변경"), "{plan:?}");
942 }
943
944 #[test]
946 fn diff_plan_constraint_same_ddl_no_item() {
947 let cols = vec![col("id", "INTEGER", true, true)];
948 let ct = r#"CREATE TABLE "t" ("id" INTEGER PRIMARY KEY)"#;
949 let ct_ws = "CREATE TABLE \"t\"\n(\"id\" INTEGER PRIMARY KEY)";
950 let old = snap(1, vec![table("t", cols.clone(), vec![ct])]);
951 let new = snap(2, vec![table("t", cols, vec![ct_ws])]);
952 let plan = diff_plan(&old, &new);
953 assert!(plan.destructive.is_empty(), "공백 정규화: {plan:?}");
954 assert!(plan.safe.is_empty(), "{plan:?}");
955 }
956
957 #[test]
959 fn diff_plan_column_add_no_false_rewrite() {
960 let ct_old = r#"CREATE TABLE "t" ("id" INTEGER PRIMARY KEY)"#;
961 let ct_new = r#"CREATE TABLE "t" ("id" INTEGER PRIMARY KEY, "note" TEXT)"#;
962 let old = snap(
963 1,
964 vec![table(
965 "t",
966 vec![col("id", "INTEGER", true, true)],
967 vec![ct_old],
968 )],
969 );
970 let new = snap(
971 2,
972 vec![table(
973 "t",
974 vec![
975 col("id", "INTEGER", true, true),
976 col("note", "TEXT", false, false),
977 ],
978 vec![ct_new],
979 )],
980 );
981 let plan = diff_plan(&old, &new);
982 assert_eq!(
983 plan.safe,
984 vec![r#"ALTER TABLE "t" ADD COLUMN "note" TEXT"#.to_string()]
985 );
986 assert!(plan.destructive.is_empty(), "재작성 오탐: {plan:?}");
987 }
988
989 #[test]
991 fn diff_plan_duplicate_rename_source_degrades() {
992 let old = snap(
993 1,
994 vec![table("t", vec![col("a", "TEXT", false, false)], vec![])],
995 );
996 let mut b = col("b", "TEXT", false, false);
997 b.renamed_from = Some("a".into());
998 let mut c = col("c", "TEXT", false, false);
999 c.renamed_from = Some("a".into());
1000 let new = snap(2, vec![table("t", vec![b, c], vec![])]);
1001 let plan = diff_plan(&old, &new);
1002 assert_eq!(
1003 plan.safe,
1004 vec![
1005 r#"ALTER TABLE "t" RENAME COLUMN "a" TO "b""#.to_string(),
1006 r#"ALTER TABLE "t" ADD COLUMN "c" TEXT"#.to_string(),
1007 ],
1008 "{plan:?}"
1009 );
1010 assert_eq!(plan.warnings.len(), 1, "{plan:?}");
1011 assert!(plan.warnings[0].contains("rename 힌트 중복"), "{plan:?}");
1012 assert!(plan.destructive.is_empty(), "{plan:?}");
1014 }
1015
1016 #[test]
1018 fn index_name_multibyte_no_panic() {
1019 assert_eq!(
1021 index_name(r#"CREATE INDEX "할일_idx" ON t(a)"#),
1022 Some("할일_idx".to_string())
1023 );
1024 assert_eq!(
1026 index_name("CREATE INDEX 할일 ON t(a)"),
1027 Some("할일".to_string())
1028 );
1029 assert_eq!(
1031 index_name(r#"CREATE UNIQUE INDEX IF NOT EXISTS "메모 색인" ON t(b)"#),
1032 Some("메모 색인".to_string())
1033 );
1034 }
1035
1036 #[test]
1038 fn diff_plan_constraint_literal_space_change_detected() {
1039 let cols = vec![col("a", "TEXT", false, false)];
1040 let ct_old = r#"CREATE TABLE "t" ("a" TEXT DEFAULT 'a b')"#;
1041 let ct_new = r#"CREATE TABLE "t" ("a" TEXT DEFAULT 'a b')"#;
1042 let old = snap(1, vec![table("t", cols.clone(), vec![ct_old])]);
1043 let new = snap(2, vec![table("t", cols, vec![ct_new])]);
1044 let plan = diff_plan(&old, &new);
1045 assert_eq!(
1046 plan.destructive.len(),
1047 1,
1048 "리터럴 내부 공백 = 실 변경: {plan:?}"
1049 );
1050 assert!(plan.destructive[0].contains("제약/DDL 변경"), "{plan:?}");
1051 }
1052
1053 #[test]
1055 fn diff_plan_constraint_empty_ddl_degrades_to_warning() {
1056 let cols = vec![col("a", "TEXT", false, false)];
1057 let ct = r#"CREATE TABLE "t" ("a" TEXT)"#;
1058 let old = snap(1, vec![table("t", cols.clone(), vec![])]);
1059 let new = snap(2, vec![table("t", cols, vec![ct])]);
1060 let plan = diff_plan(&old, &new);
1061 assert!(plan.destructive.is_empty(), "재작성 오탐: {plan:?}");
1062 assert_eq!(plan.warnings.len(), 1, "{plan:?}");
1063 assert!(plan.warnings[0].contains("제약 비교 스킵"), "{plan:?}");
1064 }
1065
1066 #[test]
1068 fn normalize_ws_quote_aware() {
1069 assert_eq!(
1070 normalize_ws_outside_quotes("CREATE TABLE \"t\"\n(\"a b\" TEXT DEFAULT 'x y')"),
1071 r#"CREATE TABLE "t" ("a b" TEXT DEFAULT 'x y')"#
1072 );
1073 assert_eq!(
1075 normalize_ws_outside_quotes("DEFAULT 'o''clock x'"),
1076 "DEFAULT 'o''clock x'"
1077 );
1078 }
1079
1080 #[test]
1082 fn index_diff_ignores_whitespace_outside_quotes() {
1083 let old = snap(
1084 1,
1085 vec![table(
1086 "items",
1087 vec![],
1088 vec!["CREATE INDEX idx_name\n ON items (name)"],
1089 )],
1090 );
1091 let new = snap(
1092 2,
1093 vec![table(
1094 "items",
1095 vec![],
1096 vec!["CREATE INDEX idx_name ON items (name)"],
1097 )],
1098 );
1099 assert!(diff_plan(&old, &new).destructive.is_empty());
1100 }
1101
1102 #[test]
1104 fn index_diff_preserves_whitespace_inside_quotes() {
1105 let old = snap(
1106 1,
1107 vec![table(
1108 "items",
1109 vec![],
1110 vec!["CREATE INDEX idx_name ON items ('a b')"],
1111 )],
1112 );
1113 let new = snap(
1114 2,
1115 vec![table(
1116 "items",
1117 vec![],
1118 vec!["CREATE INDEX idx_name ON items ('a b')"],
1119 )],
1120 );
1121 assert_eq!(diff_plan(&old, &new).destructive.len(), 1);
1122 }
1123
1124 #[test]
1126 fn index_name_quoted_variants() {
1127 assert_eq!(
1128 index_name(r#"CREATE INDEX "my idx" ON "t"("a")"#),
1129 Some("my idx".to_string())
1130 );
1131 assert_eq!(
1132 index_name(r#"CREATE UNIQUE INDEX IF NOT EXISTS "a""b" ON t(x)"#),
1133 Some("a\"b".to_string())
1134 );
1135 assert_eq!(
1136 index_name("CREATE INDEX `sp ace` ON t(a)"),
1137 Some("sp ace".to_string())
1138 );
1139 assert_eq!(
1140 index_name("CREATE INDEX [br idx] ON t(a)"),
1141 Some("br idx".to_string())
1142 );
1143 assert_eq!(
1145 index_name("create unique index idx_t_a(a)"),
1146 Some("idx_t_a".to_string())
1147 );
1148 assert_eq!(index_name(r#"CREATE TABLE "t" ("id" INTEGER)"#), None);
1150 assert_eq!(index_name(r#"CREATE INDEX "broken ON t(a)"#), None);
1151 assert_eq!(index_name("CREATE INDEXED_VIEW x"), None);
1153 }
1154
1155 #[test]
1157 fn list_snapshot_versions_overflow_is_error() {
1158 let dir = tempfile::tempdir().unwrap();
1159 let s = snap(1, vec![]);
1160 s.write_to(&dir.path().join("app.1.json")).unwrap();
1161 s.write_to(&dir.path().join("app.99999999999.json"))
1163 .unwrap();
1164 let err = list_snapshot_versions(dir.path(), "app").unwrap_err();
1165 assert!(err.to_string().contains("u32 범위"), "{err}");
1166 }
1167
1168 #[test]
1170 fn resolve_schema_dir_relative_env() {
1171 #[allow(unsafe_code)]
1174 unsafe {
1175 std::env::set_var("ROOMRS_SCHEMA_DIR", "custom/schema");
1176 }
1177 let got = resolve_schema_dir("/proj");
1178 #[allow(unsafe_code)]
1179 unsafe {
1180 std::env::remove_var("ROOMRS_SCHEMA_DIR");
1181 }
1182 assert_eq!(got, Path::new("/proj").join("custom/schema"));
1183 }
1184
1185 #[test]
1187 fn diff_sql_renders_plan() {
1188 let old = snap(
1189 1,
1190 vec![table(
1191 "t",
1192 vec![
1193 col("id", "INTEGER", true, true),
1194 col("gone", "TEXT", false, false),
1195 ],
1196 vec![],
1197 )],
1198 );
1199 let new = snap(
1200 2,
1201 vec![table(
1202 "t",
1203 vec![
1204 col("id", "INTEGER", true, true),
1205 col("name", "TEXT", false, false),
1206 ],
1207 vec![],
1208 )],
1209 );
1210 let sql = diff_sql(&old, &new);
1211 assert!(
1212 sql.contains("ALTER TABLE \"t\" ADD COLUMN \"name\" TEXT;"),
1213 "{sql}"
1214 );
1215 assert!(
1216 sql.contains("-- TODO(파괴적): ALTER TABLE \"t\" DROP COLUMN \"gone\""),
1217 "{sql}"
1218 );
1219 assert!(sql.contains("PRAGMA user_version = 2;"), "{sql}");
1220 }
1221}