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