Skip to main content

dovecote_sqlx_postgres/
migration.rs

1//! Versioned PostgreSQL migration metadata.
2
3use sqlx::FromRow;
4
5/// Schema version adapters compare before using these migration artifacts.
6pub const SCHEMA_VERSION: u32 = 1;
7
8/// Numeric crate version used to evaluate migration compatibility without
9/// parsing free-form requirement strings.
10#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
11pub struct CrateVersion {
12    major: u16,
13    minor: u16,
14    patch: u16,
15}
16
17impl CrateVersion {
18    pub const fn new(major: u16, minor: u16, patch: u16) -> Self {
19        Self {
20            major,
21            minor,
22            patch,
23        }
24    }
25
26    pub const fn major(self) -> u16 {
27        self.major
28    }
29
30    pub const fn minor(self) -> u16 {
31        self.minor
32    }
33
34    pub const fn patch(self) -> u16 {
35        self.patch
36    }
37
38    const fn is_less_than(self, other: Self) -> bool {
39        self.major < other.major
40            || (self.major == other.major
41                && (self.minor < other.minor
42                    || (self.minor == other.minor && self.patch < other.patch)))
43    }
44}
45
46pub(crate) fn current_crate_version() -> CrateVersion {
47    CrateVersion::new(
48        env!("CARGO_PKG_VERSION_MAJOR")
49            .parse()
50            .expect("Cargo supplies a numeric major version"),
51        env!("CARGO_PKG_VERSION_MINOR")
52            .parse()
53            .expect("Cargo supplies a numeric minor version"),
54        env!("CARGO_PKG_VERSION_PATCH")
55            .parse()
56            .expect("Cargo supplies a numeric patch version"),
57    )
58}
59
60#[derive(Debug, FromRow)]
61pub(crate) struct SchemaMarker {
62    pub(crate) schema_version: i32,
63    pub(crate) minimum_crate_major: i16,
64    pub(crate) minimum_crate_minor: i16,
65    pub(crate) minimum_crate_patch: i16,
66    pub(crate) rolling_compatible: bool,
67}
68
69/// Returned when a migration's maximum supported release precedes its minimum.
70#[derive(Clone, Copy, Debug, Eq, PartialEq)]
71pub struct MigrationCompatibilityError;
72
73impl std::fmt::Display for MigrationCompatibilityError {
74    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        formatter.write_str("migration compatibility maximum precedes minimum")
76    }
77}
78
79impl std::error::Error for MigrationCompatibilityError {}
80
81/// A checked crate-version range for a migration artifact.
82///
83/// Use [`Self::contains`] when deciding whether the running adapter may apply it.
84#[derive(Clone, Copy, Debug, Eq, PartialEq)]
85pub struct MigrationCompatibility {
86    minimum: CrateVersion,
87    maximum: Option<CrateVersion>,
88}
89
90impl MigrationCompatibility {
91    const fn new(minimum: CrateVersion, maximum: Option<CrateVersion>) -> Self {
92        Self { minimum, maximum }
93    }
94
95    pub const fn try_new(
96        minimum: CrateVersion,
97        maximum: Option<CrateVersion>,
98    ) -> Result<Self, MigrationCompatibilityError> {
99        if let Some(maximum) = maximum
100            && maximum.is_less_than(minimum)
101        {
102            return Err(MigrationCompatibilityError);
103        }
104        Ok(Self::new(minimum, maximum))
105    }
106
107    pub const fn minimum(self) -> CrateVersion {
108        self.minimum
109    }
110
111    pub const fn maximum(self) -> Option<CrateVersion> {
112        self.maximum
113    }
114
115    pub const fn contains(self, version: CrateVersion) -> bool {
116        !version.is_less_than(self.minimum)
117            && match self.maximum {
118                Some(maximum) => !maximum.is_less_than(version),
119                None => true,
120            }
121    }
122}
123
124/// An immutable SQL artifact whose compatibility metadata cannot be fabricated
125/// by callers of the adapter crate.
126#[derive(Clone, Copy, Debug, Eq, PartialEq)]
127pub struct Migration {
128    version: u32,
129    sql: &'static str,
130    compatibility: MigrationCompatibility,
131    rolling_compatible: bool,
132}
133
134impl Migration {
135    const fn new(
136        version: u32,
137        sql: &'static str,
138        compatibility: MigrationCompatibility,
139        rolling_compatible: bool,
140    ) -> Self {
141        Self {
142            version,
143            sql,
144            compatibility,
145            rolling_compatible,
146        }
147    }
148
149    pub const fn version(self) -> u32 {
150        self.version
151    }
152
153    pub const fn sql(self) -> &'static str {
154        self.sql
155    }
156
157    pub const fn compatibility(self) -> MigrationCompatibility {
158        self.compatibility
159    }
160
161    pub const fn rolling_compatible(self) -> bool {
162        self.rolling_compatible
163    }
164}
165
166/// The migration sequence shipped with this adapter; entries are append-only.
167pub const MIGRATIONS: &[Migration] = &[Migration::new(
168    1,
169    include_str!("../migrations/0001_dovecote.sql"),
170    MigrationCompatibility::new(CrateVersion::new(0, 1, 0), None),
171    false,
172)];
173
174pub(crate) fn current_migration() -> Result<Migration, String> {
175    MIGRATIONS
176        .iter()
177        .find(|migration| migration.version() == SCHEMA_VERSION)
178        .copied()
179        .ok_or_else(|| format!("adapter does not ship schema version {SCHEMA_VERSION}"))
180}
181
182pub(crate) fn marker_compatibility(
183    marker: &SchemaMarker,
184) -> Result<MigrationCompatibility, String> {
185    let minimum = CrateVersion::new(
186        u16::try_from(marker.minimum_crate_major)
187            .map_err(|_| "schema marker minimum major version is negative".to_owned())?,
188        u16::try_from(marker.minimum_crate_minor)
189            .map_err(|_| "schema marker minimum minor version is negative".to_owned())?,
190        u16::try_from(marker.minimum_crate_patch)
191            .map_err(|_| "schema marker minimum patch version is negative".to_owned())?,
192    );
193    MigrationCompatibility::try_new(minimum, None)
194        .map_err(|error| format!("schema marker compatibility is invalid: {error}"))
195}
196
197pub(crate) fn marker_matches_migration(
198    marker: &SchemaMarker,
199    migration: Migration,
200) -> Result<(), String> {
201    let marker_version = u32::try_from(marker.schema_version)
202        .map_err(|_| "schema marker version is negative".to_owned())?;
203    let compatibility = marker_compatibility(marker)?;
204    if marker_version != migration.version() {
205        return Err(format!(
206            "schema marker version {} does not match installed version {}",
207            marker_version,
208            migration.version()
209        ));
210    }
211
212    if compatibility != migration.compatibility() {
213        return Err("schema marker compatibility range is incompatible".to_owned());
214    }
215
216    if marker.rolling_compatible != migration.rolling_compatible() {
217        return Err("schema marker rolling compatibility is incompatible".to_owned());
218    }
219
220    if !migration.compatibility().contains(current_crate_version()) {
221        return Err("current crate is outside the migration compatibility range".to_owned());
222    }
223    Ok(())
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn migrations_are_ordered_and_typed() {
232        assert_eq!(MIGRATIONS[0].version(), SCHEMA_VERSION);
233        assert!(!MIGRATIONS[0].sql().is_empty());
234        assert_eq!(
235            MIGRATIONS[0].compatibility().minimum(),
236            CrateVersion::new(0, 1, 0)
237        );
238        assert!(!MIGRATIONS[0].rolling_compatible());
239        assert!(
240            MIGRATIONS[0]
241                .compatibility()
242                .contains(CrateVersion::new(0, 9, 0))
243        );
244        assert!(
245            MigrationCompatibility::try_new(
246                CrateVersion::new(1, 0, 0),
247                Some(CrateVersion::new(0, 9, 0))
248            )
249            .is_err()
250        );
251    }
252}