immigrant_file_diffs/
migration.rs1use std::{
2 fmt,
3 iter::Peekable,
4 result,
5 str::{FromStr, Lines},
6};
7
8use imara_diff::{Algorithm, BasicLineDiffPrinter, Diff, InternedInput, UnifiedDiffConfig};
9use Error::*;
10
11use crate::patch_util::{self, OwnedPatch};
12
13#[derive(thiserror::Error, Debug)]
14pub enum Error {
15 #[error("migration patch failed: {0}")]
16 PatchFailed(#[from] patch_util::Error),
17 #[error("update is empty")]
18 UpdateIsEmpty,
19 #[error("file should start with the header, which is prefixed by '# ' (notice it should have a space after sharp)")]
20 MissingHeader,
21 #[error("unexpected header: {0}")]
22 UnexpectedHeader(String),
23}
24pub type Result<T, E = Error> = result::Result<T, E>;
25
26enum MigrationSchemaDiff {
27 None,
28 Reset(String),
29 Diff(OwnedPatch),
30}
31
32pub struct Migration {
33 name: String,
35 description: String,
36 schema_diff: MigrationSchemaDiff,
37 pub before_up_sql: String,
38 pub after_up_sql: String,
39 pub before_down_sql: String,
40 pub after_down_sql: String,
41}
42impl Migration {
43 pub fn new(
44 name: String,
45 description: String,
46 before_up: Option<String>,
47 after_up: Option<String>,
48 before_down: Option<String>,
49 after_down: Option<String>,
50 reset: String,
51 ) -> Self {
52 Self {
53 name,
54 description,
55 before_up_sql: before_up.unwrap_or_default(),
56 after_up_sql: after_up.unwrap_or_default(),
57 before_down_sql: before_down.unwrap_or_default(),
58 after_down_sql: after_down.unwrap_or_default(),
59 schema_diff: MigrationSchemaDiff::Reset(reset),
60 }
61 }
62 pub fn apply_diff(&self, old_schema: String) -> Result<String> {
64 Ok(match &self.schema_diff {
65 MigrationSchemaDiff::None => old_schema,
66 MigrationSchemaDiff::Reset(schema) => schema.to_owned(),
67 MigrationSchemaDiff::Diff(diff) => diff.apply(&old_schema)?,
68 })
69 }
70 pub fn to_diff(&mut self, old_schema: String) -> Result<()> {
72 let MigrationSchemaDiff::Reset(reset) = &self.schema_diff else {
73 return Ok(());
74 };
75 let diff_input = InternedInput::new(old_schema.as_str(), reset.as_str());
76 let diff = Diff::compute(Algorithm::Histogram, &diff_input);
77 let update = diff
78 .unified_diff(
79 &BasicLineDiffPrinter(&diff_input.interner),
80 UnifiedDiffConfig::default(),
81 &diff_input,
82 )
83 .to_string();
84 if update.trim().is_empty() {
85 self.schema_diff = MigrationSchemaDiff::None;
86 } else {
87 let update = OwnedPatch::from_str(&update)?;
88 self.schema_diff = MigrationSchemaDiff::Diff(update);
89 }
90 Ok(())
91 }
92 pub fn is_noop(&self) -> bool {
93 matches!(self.schema_diff, MigrationSchemaDiff::None)
94 && self.before_up_sql.is_empty()
95 && self.after_up_sql.is_empty()
96 && self.before_down_sql.is_empty()
97 && self.after_down_sql.is_empty()
98 }
99 pub fn schema_check_string(&self) -> String {
100 match &self.schema_diff {
101 MigrationSchemaDiff::None => "<noop>".to_string(),
102 MigrationSchemaDiff::Reset(s) => format!("<reset>\n{s}"),
103 MigrationSchemaDiff::Diff(d) => format!("<diff>\n{d}"),
104 }
105 }
106}
107impl FromStr for Migration {
108 type Err = Error;
109
110 fn from_str(migration: &str) -> Result<Self> {
111 let mut lines = migration.lines().peekable();
112 skip_empty(&mut lines);
113 let Some(header) = lines.next() else {
114 return Err(UpdateIsEmpty);
115 };
116 let Some(name) = header.strip_prefix("# ") else {
117 return Err(MissingHeader);
118 };
119 let name = name.to_owned();
120
121 let description = until_next_header(&mut lines);
122
123 let schema_diff = if lines.next_if_eq(&"## Schema diff").is_some() {
124 let schema_diff = until_next_header(&mut lines);
125 MigrationSchemaDiff::Diff(OwnedPatch::from_str(&schema_diff)?)
126 } else if lines.next_if_eq(&"## Schema reset").is_some() {
127 let schema_diff = until_next_header(&mut lines);
128 MigrationSchemaDiff::Reset(schema_diff)
129 } else {
130 MigrationSchemaDiff::None
131 };
132
133 let before_up_sql = if lines.next_if_eq(&"## Before").is_some() {
134 until_next_header(&mut lines)
135 } else {
136 "".to_owned()
137 };
138 let after_up_sql = if lines.next_if_eq(&"## After").is_some() {
139 until_next_header(&mut lines)
140 } else {
141 "".to_owned()
142 };
143 let before_down_sql = if lines.next_if_eq(&"## Before (down)").is_some() {
144 until_next_header(&mut lines)
145 } else {
146 "".to_owned()
147 };
148 let after_down_sql = if lines.next_if_eq(&"## After (down)").is_some() {
149 until_next_header(&mut lines)
150 } else {
151 "".to_owned()
152 };
153 if let Some(line) = lines.next() {
154 return Err(UnexpectedHeader(line.to_owned()));
155 }
156
157 Ok(Migration {
158 name,
159 description,
160 schema_diff,
161 before_up_sql,
162 after_up_sql,
163 before_down_sql,
164 after_down_sql,
165 })
166 }
167}
168impl fmt::Display for Migration {
169 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170 writeln!(f, "# {}", self.name)?;
171 if !self.description.is_empty() {
172 writeln!(f, "{}", self.description)?;
173 }
174 match &self.schema_diff {
175 MigrationSchemaDiff::None => {}
176 MigrationSchemaDiff::Reset(reset) => {
177 writeln!(f)?;
178 writeln!(f, "## Schema reset")?;
179 writeln!(f, "{reset}")?;
180 writeln!(f)?;
181 }
182 MigrationSchemaDiff::Diff(diff) => {
183 writeln!(f)?;
184 writeln!(f, "## Schema diff")?;
185 write!(f, "{diff}")?;
186 }
187 }
188 if !self.before_up_sql.is_empty() {
189 writeln!(f)?;
190 writeln!(f, "## Before")?;
191 writeln!(f, "{}", self.before_up_sql)?;
192 }
193 if !self.after_up_sql.is_empty() {
194 writeln!(f)?;
195 writeln!(f, "## After")?;
196 writeln!(f, "{}", self.after_up_sql)?;
197 }
198 if !self.before_down_sql.is_empty() {
199 writeln!(f)?;
200 writeln!(f, "## Before (down)")?;
201 writeln!(f, "{}", self.before_up_sql)?;
202 }
203 if !self.after_down_sql.is_empty() {
204 writeln!(f)?;
205 writeln!(f, "## After (down)")?;
206 writeln!(f, "{}", self.after_up_sql)?;
207 }
208
209 Ok(())
210 }
211}
212
213fn skip_empty(l: &mut Peekable<Lines>) {
214 if l.peek().map(|l| l.is_empty()).unwrap_or(false) {
215 l.next();
216 }
217}
218fn until_next_header(l: &mut Peekable<Lines>) -> String {
219 let mut out = Vec::new();
220 skip_empty(l);
221 loop {
222 let Some(line) = l.next_if(|l| !l.starts_with('#')) else {
223 break;
224 };
225 out.push(line);
226 }
227 while out.last().map(|l| l.trim().is_empty()).unwrap_or(false) {
228 out.pop();
229 }
230 out.join("\n")
231}