immigrant_file_diffs/
migration.rs

1use 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	// id: MigrationId,
34	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	// Result the schema after this migration for daisy-chaining
63	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	// Convert reset schema to diff schema
71	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}