1use std::collections::HashSet;
21use std::io::IsTerminal;
22use std::path::PathBuf;
23
24use colored::Colorize;
25use drizzle_migrations::{DiffOptions, RenameAnswer, RenameKind, RenameQuestion, Snapshot};
26use serde_json::Value;
27
28use crate::error::CliError;
29
30#[derive(clap::Args, Debug, Clone, Default)]
32pub struct HintArgs {
33 #[arg(long, value_name = "JSON", conflicts_with = "hints_file")]
37 pub hints: Option<String>,
38
39 #[arg(long = "hints-file", value_name = "PATH")]
41 pub hints_file: Option<PathBuf>,
42}
43
44#[derive(Clone, Debug, PartialEq, Eq)]
46pub enum Hint {
47 Rename {
49 kind: String,
51 from: Vec<String>,
53 to: Vec<String>,
55 },
56 Create {
58 kind: String,
60 entity: Vec<String>,
62 },
63 ConfirmDataLoss,
66}
67
68const RENAME_KINDS: &[(&str, usize)] = &[
70 ("table", 2),
71 ("column", 3),
72 ("default", 3),
73 ("schema", 1),
74 ("enum", 2),
75 ("sequence", 2),
76 ("view", 2),
77 ("policy", 3),
78 ("role", 1),
79 ("privilege", 5),
80 ("check", 3),
81 ("index", 3),
82 ("unique", 3),
83 ("primary_key", 3),
84 ("foreign key", 3),
85];
86
87const CONFIRM_KINDS: &[(&str, usize)] = &[
89 ("table", 2),
90 ("column", 3),
91 ("schema", 1),
92 ("view", 2),
93 ("primary_key", 3),
94 ("add_not_null", 3),
95 ("add_unique", 3),
96];
97
98impl HintArgs {
99 pub fn load(&self) -> Result<Vec<Hint>, CliError> {
106 let (source, text) = if let Some(path) = &self.hints_file {
107 let text = std::fs::read_to_string(path).map_err(|error| {
108 CliError::InvalidHints(format!(
109 "Failed to read hints file '{}': {error}",
110 path.display()
111 ))
112 })?;
113 ("file", text)
114 } else if let Some(text) = &self.hints {
115 ("inline", text.clone())
116 } else {
117 return Ok(Vec::new());
118 };
119 let json: Value = serde_json::from_str(&text).map_err(|error| {
120 CliError::InvalidHints(format!("Failed to parse hints JSON from {source}: {error}"))
121 })?;
122 parse_hints(&json)
123 }
124}
125
126pub fn parse_hints(json: &Value) -> Result<Vec<Hint>, CliError> {
132 let invalid = |path: &str, message: &str| {
133 CliError::InvalidHints(format!("Invalid hint shape at {path}: {message}"))
134 };
135 let items = json
136 .as_array()
137 .ok_or_else(|| invalid("<root>", "expected an array"))?;
138 items
139 .iter()
140 .enumerate()
141 .map(|(index, item)| {
142 let at = |field: &str| format!("[{index}]{field}");
143 let object = item
144 .as_object()
145 .ok_or_else(|| invalid(&at(""), "expected an object"))?;
146 let text = |field: &str| -> Result<&str, CliError> {
147 object
148 .get(field)
149 .and_then(Value::as_str)
150 .ok_or_else(|| invalid(&at(&format!(".{field}")), "expected a string"))
151 };
152 let kind = text("kind")?;
153 let hint_type = text("type")?;
154 let (kinds, fields): (&[(&str, usize)], &[&str]) = match hint_type {
155 "rename" => (RENAME_KINDS, &["type", "kind", "from", "to"]),
156 "create" => (RENAME_KINDS, &["type", "kind", "entity"]),
157 "confirm_data_loss" => (CONFIRM_KINDS, &["type", "kind", "entity"]),
158 _ => {
159 return Err(invalid(
160 &at(".type"),
161 "expected \"rename\", \"create\" or \"confirm_data_loss\"",
162 ));
163 }
164 };
165 if let Some(extra) = object.keys().find(|key| !fields.contains(&key.as_str())) {
166 return Err(invalid(&at(""), &format!("unrecognized key \"{extra}\"")));
167 }
168 let arity = kinds
169 .iter()
170 .find(|(name, _)| *name == kind)
171 .map(|(_, arity)| *arity)
172 .ok_or_else(|| {
173 invalid(
174 &at(".kind"),
175 &format!("unknown kind \"{kind}\" for a {hint_type} hint"),
176 )
177 })?;
178 let tuple = |field: &str| -> Result<Vec<String>, CliError> {
179 let values = object
180 .get(field)
181 .and_then(Value::as_array)
182 .filter(|values| values.len() == arity)
183 .ok_or_else(|| {
184 invalid(
185 &at(&format!(".{field}")),
186 &format!("expected an array of {arity} strings"),
187 )
188 })?;
189 values
190 .iter()
191 .map(|value| {
192 value
193 .as_str()
194 .map(str::to_string)
195 .ok_or_else(|| invalid(&at(&format!(".{field}")), "expected strings"))
196 })
197 .collect()
198 };
199 Ok(match hint_type {
200 "rename" => Hint::Rename {
201 kind: kind.to_string(),
202 from: tuple("from")?,
203 to: tuple("to")?,
204 },
205 "create" => Hint::Create {
206 kind: kind.to_string(),
207 entity: tuple("entity")?,
208 },
209 _ => {
210 tuple("entity")?;
211 Hint::ConfirmDataLoss
212 }
213 })
214 })
215 .collect()
216}
217
218pub trait RenamePrompt {
220 fn ask(&mut self, question: &RenameQuestion) -> Result<RenameAnswer, CliError>;
226}
227
228#[derive(Debug, Default)]
231pub struct TerminalPrompt;
232
233impl RenamePrompt for TerminalPrompt {
234 fn ask(&mut self, question: &RenameQuestion) -> Result<RenameAnswer, CliError> {
235 let choices = prompt_choices(question);
236 let selected = inquire::Select::new(&prompt_message(question), choices)
237 .without_filtering()
238 .without_help_message()
239 .with_page_size(10)
240 .raw_prompt()
241 .map_err(|error| match error {
242 inquire::InquireError::OperationCanceled
243 | inquire::InquireError::OperationInterrupted => {
244 CliError::Aborted("Rename prompt aborted, so nothing was changed".to_string())
245 }
246 other => CliError::Other(format!("Rename prompt failed: {other}")),
247 })?;
248 Ok(selected.value.answer)
249 }
250}
251
252pub enum Mode<'a> {
254 Interactive(&'a mut dyn RenamePrompt),
256 Hints,
258}
259
260#[derive(Debug)]
262pub struct Resolution {
263 pub options: DiffOptions,
266 pub missing: Vec<RenameQuestion>,
268}
269
270pub fn resolve_renames(
279 prev: &Snapshot,
280 current: &Snapshot,
281 base: DiffOptions,
282 hints: &[Hint],
283 mut mode: Mode<'_>,
284) -> Result<Resolution, CliError> {
285 let mut options = base.infer_renames(false);
286 let mut missing = Vec::new();
287 let mut asked = HashSet::new();
288 let mut group = Group::default();
291
292 loop {
293 let questions = drizzle_migrations::rename_questions(prev, current, &options)
294 .map_err(|error| CliError::MigrationError(error.to_string()))?;
295 let Some(question) = questions.into_iter().next() else {
296 break;
297 };
298 group.enter(&question);
299
300 let key = (question.kind, entity_id(&question, &question.name));
301 if !asked.insert(key) {
302 return Err(CliError::Other(format!(
303 "internal error: the answer to {} `{}` did not apply",
304 question.kind, question.name
305 )));
306 }
307
308 let answer = if let Some(answer) = answer_from_hints(&question, hints)? {
309 answer
310 } else {
311 match &mut mode {
312 Mode::Interactive(prompt) => prompt.ask(&question)?,
313 Mode::Hints => {
314 missing.push(question.clone());
316 question
317 .answer(&mut options.renames, &RenameAnswer::Create)
318 .map_err(|error| CliError::Other(error.to_string()))?;
319 continue;
320 }
321 }
322 };
323 println!("{}", selection_line(&question, &answer));
324 group.decided = true;
325 question
326 .answer(&mut options.renames, &answer)
327 .map_err(|error| CliError::Other(error.to_string()))?;
328 }
329 group.finish();
330
331 Ok(Resolution { options, missing })
332}
333
334#[derive(Default)]
336struct Group {
337 scope: Option<(RenameKind, Option<String>, Option<String>)>,
338 decided: bool,
339}
340
341impl Group {
342 fn enter(&mut self, question: &RenameQuestion) {
343 let scope = (
344 question.kind,
345 question.schema.clone(),
346 question.table.clone(),
347 );
348 if self.scope.as_ref() != Some(&scope) {
349 self.finish();
350 self.scope = Some(scope);
351 }
352 }
353
354 fn finish(&mut self) {
355 if let Some((kind, _, _)) = self.scope.take()
356 && std::mem::take(&mut self.decided)
357 {
358 println!(
359 "{}",
360 format!("--- all {} conflicts resolved ---\n", humanize(kind)).bright_black()
361 );
362 }
363 }
364}
365
366pub fn resolve_for_command(
375 prev: &Snapshot,
376 current: &Snapshot,
377 base: DiffOptions,
378 hint_args: &HintArgs,
379) -> Result<DiffOptions, CliError> {
380 let hints = hint_args.load()?;
381 if hints
382 .iter()
383 .any(|hint| matches!(hint, Hint::ConfirmDataLoss))
384 {
385 println!(
386 "{}",
387 crate::output::warn_line(
388 "confirm_data_loss hints are ignored; use `push --force` to approve data loss"
389 )
390 );
391 }
392 let mut prompt = TerminalPrompt;
393 let mode = if std::io::stdin().is_terminal() {
394 Mode::Interactive(&mut prompt)
395 } else {
396 Mode::Hints
397 };
398 let resolution = resolve_renames(prev, current, base, &hints, mode)?;
399 if !resolution.missing.is_empty() {
400 return Err(CliError::MissingHints(missing_hints_report(
401 &resolution.missing,
402 )));
403 }
404 Ok(resolution.options)
405}
406
407fn answer_from_hints(
409 question: &RenameQuestion,
410 hints: &[Hint],
411) -> Result<Option<RenameAnswer>, CliError> {
412 let kind = question.kind.as_str();
413 let id = entity_id(question, &question.name);
414 for hint in hints {
415 if let Hint::Rename {
416 kind: hint_kind,
417 from,
418 to,
419 } = hint
420 && hint_kind == kind
421 && *to == id
422 {
423 let source = question
424 .candidates
425 .iter()
426 .find(|candidate| entity_id(question, candidate) == *from);
427 return match source {
428 Some(source) => Ok(Some(RenameAnswer::RenameFrom(source.clone()))),
429 None => Err(CliError::InvalidHints(format!(
430 "rename hint's `from` {} doesn't match any deleted {}",
431 json_tuple(from),
432 humanize(question.kind)
433 ))),
434 };
435 }
436 }
437 let created = hints.iter().any(|hint| {
438 matches!(hint, Hint::Create { kind: hint_kind, entity } if hint_kind == kind && *entity == id)
439 });
440 Ok(created.then_some(RenameAnswer::Create))
441}
442
443#[must_use]
449pub fn entity_id(question: &RenameQuestion, name: &str) -> Vec<String> {
450 let schema = question.schema.as_deref().unwrap_or("public").to_string();
451 match question.kind {
452 RenameKind::Schema => vec![name.to_string()],
453 RenameKind::Column
454 | RenameKind::Index
455 | RenameKind::Unique
456 | RenameKind::Check
457 | RenameKind::PrimaryKey
458 | RenameKind::ForeignKey => vec![
459 schema,
460 question.table.clone().unwrap_or_default(),
461 name.to_string(),
462 ],
463 _ => vec![schema, name.to_string()],
464 }
465}
466
467fn humanize(kind: RenameKind) -> String {
468 kind.as_str().replace('_', " ")
469}
470
471fn json_tuple(segments: &[String]) -> String {
472 let quoted: Vec<String> = segments
473 .iter()
474 .map(|segment| Value::String(segment.clone()).to_string())
475 .collect();
476 format!("[{}]", quoted.join(", "))
477}
478
479fn key_for(question: &RenameQuestion, name: &str) -> String {
482 let mut key = String::new();
483 if let Some(schema) = question.schema.as_deref()
484 && schema != "public"
485 {
486 key.push_str(schema);
487 key.push('.');
488 }
489 if let Some(table) = question.table.as_deref() {
490 key.push_str(table);
491 key.push('.');
492 }
493 key.push_str(name);
494 key
495}
496
497#[must_use]
499pub fn prompt_message(question: &RenameQuestion) -> String {
500 let kind = humanize(question.kind);
501 format!(
502 "Is {} {kind} created or renamed from another {kind}?",
503 key_for(question, &question.name).bold().blue()
504 )
505}
506
507#[derive(Clone, Debug, PartialEq, Eq)]
509pub struct Choice {
510 pub answer: RenameAnswer,
512 label: String,
513}
514
515impl std::fmt::Display for Choice {
516 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
517 f.write_str(&self.label)
518 }
519}
520
521#[must_use]
525pub fn prompt_choices(question: &RenameQuestion) -> Vec<Choice> {
526 let kind = humanize(question.kind);
527 let key = key_for(question, &question.name);
528 let titles: Vec<String> = question
529 .candidates
530 .iter()
531 .map(|candidate| format!("{} › {key}", key_for(question, candidate)))
532 .collect();
533 let width = titles
534 .iter()
535 .map(|title| title.chars().count())
536 .max()
537 .unwrap_or(0);
538
539 let mut choices = vec![Choice {
540 answer: RenameAnswer::Create,
541 label: format!(
542 "{} {key:<width$} {}",
543 "+".green(),
544 format!("create {kind}").bright_black()
545 ),
546 }];
547 for (candidate, title) in question.candidates.iter().zip(titles) {
548 let pad = width.saturating_sub(title.chars().count());
549 choices.push(Choice {
550 answer: RenameAnswer::RenameFrom(candidate.clone()),
551 label: format!(
552 "{} {title}{} {}",
553 "~".yellow(),
554 " ".repeat(pad),
555 format!("rename {kind}").bright_black()
556 ),
557 });
558 }
559 choices
560}
561
562fn selection_line(question: &RenameQuestion, answer: &RenameAnswer) -> String {
564 let kind = humanize(question.kind);
565 match answer {
566 RenameAnswer::Create => format!(
567 "{} {} {}",
568 "+".green(),
569 question.name,
570 format!("{kind} will be created").bright_black()
571 ),
572 RenameAnswer::RenameFrom(from) => format!(
573 "{} {} › {} {}",
574 "~".yellow(),
575 key_for(question, from),
576 key_for(question, &question.name),
577 format!("{kind} will be renamed/moved").bright_black()
578 ),
579 }
580}
581
582#[must_use]
585pub fn missing_hints_report(missing: &[RenameQuestion]) -> String {
586 let mut lines = vec![
587 format!("missing_hints: {} unresolved decisions", missing.len())
588 .yellow()
589 .bold()
590 .to_string(),
591 ];
592 for (index, question) in missing.iter().enumerate() {
593 let kind = question.kind.as_str();
594 let id = entity_id(question, &question.name);
595 let placeholder: Vec<String> = match id.len() {
596 1 => vec!["<old_name>".into()],
597 2 => vec!["<schema>".into(), "<old_name>".into()],
598 _ => vec!["<schema>".into(), "<table>".into(), "<old_name>".into()],
599 };
600 let candidates: Vec<String> = question
601 .candidates
602 .iter()
603 .map(|candidate| json_tuple(&entity_id(question, candidate)))
604 .collect();
605 lines.push(String::new());
606 lines.push(format!(
607 "{}. Rename or create {} {} {}",
608 index + 1,
609 "—".bright_black(),
610 humanize(question.kind),
611 id.join(".").bold().cyan()
612 ));
613 lines.push(format!(
614 " {} {}",
615 "Deleted candidates:".bright_black(),
616 candidates.join(", ")
617 ));
618 lines.push(" Add to --hints:".to_string());
619 lines.push(format!(
620 " {{ \"type\": \"rename\", \"kind\": \"{kind}\", \"from\": {}, \"to\": {} }}",
621 json_tuple(&placeholder),
622 json_tuple(&id)
623 ));
624 lines.push(" OR".to_string());
625 lines.push(format!(
626 " {{ \"type\": \"create\", \"kind\": \"{kind}\", \"entity\": {} }}",
627 json_tuple(&id)
628 ));
629 }
630 lines.push(String::new());
631 lines.push(
632 "Re-run with --hints '<json-array>' or --hints-file <path>. Exit code 2."
633 .bright_black()
634 .to_string(),
635 );
636 lines.join("\n") + "\n"
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642 use drizzle_migrations::sqlite::SQLiteSnapshot;
643 use drizzle_migrations::sqlite::ddl::{Column, SqliteEntity, Table};
644
645 fn plain(text: &str) -> String {
646 let mut out = String::new();
647 let mut chars = text.chars();
648 while let Some(c) = chars.next() {
649 if c == '\u{1b}' {
650 for c in chars.by_ref() {
651 if c == 'm' {
652 break;
653 }
654 }
655 } else {
656 out.push(c);
657 }
658 }
659 out
660 }
661
662 fn sqlite(tables: &[(&'static str, &[&'static str])]) -> Snapshot {
663 let mut snapshot = SQLiteSnapshot::new();
664 for (table, columns) in tables {
665 snapshot.add_entity(SqliteEntity::Table(Table::new(*table)));
666 for column in *columns {
667 snapshot.add_entity(SqliteEntity::Column(
668 Column::new(*table, *column, "text").not_null(),
669 ));
670 }
671 }
672 Snapshot::Sqlite(snapshot)
673 }
674
675 fn question(
676 kind: RenameKind,
677 schema: Option<&str>,
678 table: Option<&str>,
679 name: &str,
680 candidates: &[&str],
681 ) -> RenameQuestion {
682 RenameQuestion {
683 kind,
684 schema: schema.map(str::to_string),
685 table: table.map(str::to_string),
686 name: name.to_string(),
687 candidates: candidates.iter().map(|c| (*c).to_string()).collect(),
688 }
689 }
690
691 struct Scripted {
693 answers: Vec<usize>,
694 asked: Vec<(String, Vec<String>)>,
695 }
696
697 impl RenamePrompt for Scripted {
698 fn ask(&mut self, question: &RenameQuestion) -> Result<RenameAnswer, CliError> {
699 let choices = prompt_choices(question);
700 self.asked.push((
701 plain(&prompt_message(question)),
702 choices.iter().map(|c| plain(&c.to_string())).collect(),
703 ));
704 let pick = self.answers.remove(0);
705 Ok(choices[pick].answer.clone())
706 }
707 }
708
709 #[test]
710 fn renders_like_drizzle_kit() {
711 let q = question(
712 RenameKind::Table,
713 None,
714 None,
715 "accounts",
716 &["users", "people"],
717 );
718 assert_eq!(
719 plain(&prompt_message(&q)),
720 "Is accounts table created or renamed from another table?"
721 );
722 let choices: Vec<String> = prompt_choices(&q)
723 .iter()
724 .map(|c| plain(&c.to_string()))
725 .collect();
726 assert_eq!(
727 choices,
728 [
729 "+ accounts create table",
730 "~ users › accounts rename table",
731 "~ people › accounts rename table",
732 ]
733 );
734
735 let q = question(
736 RenameKind::Column,
737 Some("app"),
738 Some("users"),
739 "full_name",
740 &["name"],
741 );
742 assert_eq!(
743 plain(&prompt_message(&q)),
744 "Is app.users.full_name column created or renamed from another column?"
745 );
746 assert_eq!(
747 plain(&prompt_choices(&q)[1].to_string()),
748 "~ app.users.name › app.users.full_name rename column"
749 );
750 assert_eq!(
751 entity_id(&q, "name"),
752 ["app", "users", "name"].map(String::from)
753 );
754 assert_eq!(
755 entity_id(&question(RenameKind::Table, None, None, "t", &["x"]), "t"),
756 ["public", "t"].map(String::from)
757 );
758 }
759
760 #[test]
761 fn interactive_answers_apply_in_order_and_consume_candidates() {
762 let prev = sqlite(&[("a", &["id"]), ("b", &["id", "old"])]);
763 let cur = sqlite(&[("c", &["id"]), ("d", &["id", "new"])]);
764 let mut prompt = Scripted {
767 answers: vec![2, 0],
768 asked: Vec::new(),
769 };
770 let resolution = resolve_renames(
771 &prev,
772 &cur,
773 DiffOptions::new(),
774 &[],
775 Mode::Interactive(&mut prompt),
776 )
777 .unwrap();
778
779 assert_eq!(prompt.asked.len(), 2);
780 assert_eq!(
781 prompt.asked[0].1,
782 [
783 "+ c create table",
784 "~ a › c rename table",
785 "~ b › c rename table",
786 ]
787 );
788 assert_eq!(
789 prompt.asked[1].1,
790 ["+ d create table", "~ a › d rename table"],
791 "b was consumed by the first answer"
792 );
793 assert!(resolution.missing.is_empty());
794 assert!(!resolution.options.infer_renames);
795 let plan = drizzle_migrations::diff_with(&prev, &cur, &resolution.options).unwrap();
796 assert!(
797 plan.statements
798 .contains(&"ALTER TABLE `b` RENAME TO `c`;".to_string())
799 );
800 assert!(
801 plan.statements
802 .iter()
803 .any(|s| s.starts_with("CREATE TABLE `d`"))
804 );
805 assert!(
806 plan.statements
807 .iter()
808 .any(|s| s.starts_with("DROP TABLE `a`"))
809 );
810 }
811
812 #[test]
813 fn column_questions_use_the_renamed_table() {
814 let prev = sqlite(&[("users", &["id", "name"])]);
815 let cur = sqlite(&[("accounts", &["id", "full_name"])]);
816 let mut prompt = Scripted {
817 answers: vec![1, 1],
818 asked: Vec::new(),
819 };
820 let resolution = resolve_renames(
821 &prev,
822 &cur,
823 DiffOptions::new(),
824 &[],
825 Mode::Interactive(&mut prompt),
826 )
827 .unwrap();
828 assert_eq!(
829 prompt.asked[1].0,
830 "Is accounts.full_name column created or renamed from another column?"
831 );
832 let plan = drizzle_migrations::diff_with(&prev, &cur, &resolution.options).unwrap();
833 assert_eq!(
834 plan.statements,
835 [
836 "ALTER TABLE `users` RENAME TO `accounts`;",
837 "ALTER TABLE `accounts` RENAME COLUMN `name` TO `full_name`;",
838 ]
839 );
840 }
841
842 #[test]
843 fn hints_answer_without_prompting_and_unanswered_are_missing() {
844 let prev = sqlite(&[("users", &["id", "name"]), ("logs", &["id", "body"])]);
845 let cur = sqlite(&[("accounts", &["id", "name"]), ("logs", &["id", "text"])]);
846 let hints = parse_hints(&serde_json::json!([
847 {"type": "rename", "kind": "table", "from": ["public", "users"], "to": ["public", "accounts"]},
848 {"type": "rename", "kind": "table", "from": ["public", "x"], "to": ["public", "unrelated"]},
849 ]))
850 .unwrap();
851
852 let resolution =
853 resolve_renames(&prev, &cur, DiffOptions::new(), &hints, Mode::Hints).unwrap();
854 assert_eq!(
855 resolution.missing,
856 [question(
857 RenameKind::Column,
858 None,
859 Some("logs"),
860 "text",
861 &["body"]
862 )]
863 );
864 let report = plain(&missing_hints_report(&resolution.missing));
865 assert!(
866 report.starts_with("missing_hints: 1 unresolved decisions\n"),
867 "{report}"
868 );
869 assert!(report.contains("1. Rename or create — column public.logs.text"));
870 assert!(report.contains("Deleted candidates: [\"public\", \"logs\", \"body\"]"));
871 assert!(report.contains(
872 r#"{ "type": "rename", "kind": "column", "from": ["<schema>", "<table>", "<old_name>"], "to": ["public", "logs", "text"] }"#
873 ));
874 assert!(report.contains(
875 r#"{ "type": "create", "kind": "column", "entity": ["public", "logs", "text"] }"#
876 ));
877
878 let mut prompt = Scripted {
880 answers: vec![0],
881 asked: Vec::new(),
882 };
883 resolve_renames(
884 &prev,
885 &cur,
886 DiffOptions::new(),
887 &hints,
888 Mode::Interactive(&mut prompt),
889 )
890 .unwrap();
891 assert_eq!(prompt.asked.len(), 1);
892 assert!(prompt.asked[0].0.starts_with("Is logs.text column"));
893 }
894
895 #[test]
896 fn create_hints_keep_drop_and_create() {
897 let prev = sqlite(&[("users", &["id"])]);
898 let cur = sqlite(&[("accounts", &["id"])]);
899 let hints = parse_hints(&serde_json::json!([
900 {"type": "create", "kind": "table", "entity": ["public", "accounts"]},
901 ]))
902 .unwrap();
903 let resolution =
904 resolve_renames(&prev, &cur, DiffOptions::new(), &hints, Mode::Hints).unwrap();
905 assert!(resolution.missing.is_empty());
906 let plan = drizzle_migrations::diff_with(&prev, &cur, &resolution.options).unwrap();
907 assert!(!plan.statements.iter().any(|s| s.contains("RENAME")));
908 }
909
910 #[test]
911 fn rename_hint_from_a_non_candidate_is_rejected() {
912 let prev = sqlite(&[("users", &["id"])]);
913 let cur = sqlite(&[("accounts", &["id"])]);
914 let hints = parse_hints(&serde_json::json!([
915 {"type": "rename", "kind": "table", "from": ["public", "people"], "to": ["public", "accounts"]},
916 ]))
917 .unwrap();
918 let error =
919 resolve_renames(&prev, &cur, DiffOptions::new(), &hints, Mode::Hints).unwrap_err();
920 assert_eq!(
921 error.to_string(),
922 "Invalid hints: rename hint's `from` [\"public\", \"people\"] doesn't match any deleted table"
923 );
924 }
925
926 #[test]
927 fn hint_shapes_are_validated() {
928 for (json, message) in [
929 (serde_json::json!({}), "at <root>: expected an array"),
930 (
931 serde_json::json!([{"type": "rename", "kind": "table", "from": ["users"], "to": ["public", "a"]}]),
932 "at [0].from: expected an array of 2 strings",
933 ),
934 (
935 serde_json::json!([{"type": "create", "kind": "tables", "entity": ["public", "a"]}]),
936 "at [0].kind: unknown kind \"tables\"",
937 ),
938 (
939 serde_json::json!([{"type": "create", "kind": "table", "entity": ["public", "a"], "x": 1}]),
940 "unrecognized key \"x\"",
941 ),
942 ] {
943 let error = parse_hints(&json).unwrap_err().to_string();
944 assert!(error.contains(message), "{error}");
945 }
946 assert_eq!(
947 parse_hints(&serde_json::json!([
948 {"type": "confirm_data_loss", "kind": "table", "entity": ["public", "a"]},
949 {"type": "rename", "kind": "foreign key", "from": ["public", "t", "a"], "to": ["public", "t", "b"]},
950 ]))
951 .unwrap()
952 .len(),
953 2,
954 "kinds drizzle-kit knows parse even when this CLI never asks about them"
955 );
956 }
957}