Skip to main content

lenso_postgres_kit/
plan.rs

1use std::{fmt, sync::Arc};
2
3use sha2::{Digest, Sha256};
4use thiserror::Error;
5
6/// One immutable, ordered migration owned by a Module.
7#[derive(Clone, Copy)]
8pub struct Migration {
9    version: u64,
10    name: &'static str,
11    sql: &'static str,
12}
13
14impl Migration {
15    /// Defines one migration. Validation happens when constructing a [`SchemaPlan`].
16    pub const fn new(version: u64, name: &'static str, sql: &'static str) -> Self {
17        Self { version, name, sql }
18    }
19
20    /// Returns the monotonic migration version.
21    pub const fn version(&self) -> u64 {
22        self.version
23    }
24
25    /// Returns the stable migration name.
26    pub const fn name(&self) -> &'static str {
27        self.name
28    }
29
30    /// Returns the SQL applied inside the owned migration transaction.
31    pub const fn sql(&self) -> &'static str {
32        self.sql
33    }
34
35    pub(crate) fn checksum(&self) -> String {
36        let mut digest = Sha256::new();
37        digest.update(self.version.to_be_bytes());
38        digest.update([0]);
39        digest.update(self.name.as_bytes());
40        digest.update([0]);
41        digest.update(self.sql.as_bytes());
42        hex::encode(digest.finalize())
43    }
44}
45
46impl fmt::Debug for Migration {
47    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48        formatter
49            .debug_struct("Migration")
50            .field("version", &self.version)
51            .field("name", &self.name)
52            .field("checksum", &self.checksum())
53            .finish_non_exhaustive()
54    }
55}
56
57/// An immutable description of one Module-owned `PostgreSQL` schema.
58#[derive(Clone)]
59pub struct SchemaPlan {
60    schema: Arc<str>,
61    migrations: &'static [Migration],
62}
63
64impl SchemaPlan {
65    /// Validates and creates a schema plan.
66    pub fn new(
67        schema: impl Into<Arc<str>>,
68        migrations: &'static [Migration],
69    ) -> Result<Self, PlanError> {
70        let schema = schema.into();
71        validate_schema_name(&schema)?;
72        validate_migrations(migrations)?;
73        Ok(Self { schema, migrations })
74    }
75
76    /// Returns the `PostgreSQL` schema name owned by the Module.
77    pub fn schema(&self) -> &str {
78        &self.schema
79    }
80
81    /// Returns the current version declared by the Module.
82    pub fn current_version(&self) -> u64 {
83        self.migrations.last().map_or(0, Migration::version)
84    }
85
86    pub(crate) const fn migrations(&self) -> &'static [Migration] {
87        self.migrations
88    }
89}
90
91impl fmt::Debug for SchemaPlan {
92    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
93        formatter
94            .debug_struct("SchemaPlan")
95            .field("schema", &self.schema)
96            .field("current_version", &self.current_version())
97            .field("migration_count", &self.migrations.len())
98            .finish()
99    }
100}
101
102/// A schema plan is invalid and cannot be used for setup or preparation.
103#[derive(Clone, Debug, Error, PartialEq, Eq)]
104pub enum PlanError {
105    #[error("invalid owned schema name `{schema}`")]
106    InvalidSchemaName { schema: Arc<str> },
107    #[error("schema plan must contain at least one migration")]
108    EmptyMigrations,
109    #[error("migration `{name}` has version {actual}; expected {expected}")]
110    NonContiguousVersion {
111        name: &'static str,
112        expected: u64,
113        actual: u64,
114    },
115    #[error("migration version {version} has invalid name `{name}`")]
116    InvalidMigrationName { version: u64, name: &'static str },
117    #[error("migration version {version} has empty SQL")]
118    EmptyMigrationSql { version: u64 },
119    #[error("migration version {version} exceeds PostgreSQL bigint range")]
120    MigrationVersionTooLarge { version: u64 },
121}
122
123fn validate_schema_name(schema: &str) -> Result<(), PlanError> {
124    let valid_length = !schema.is_empty() && schema.len() <= 63;
125    let mut bytes = schema.bytes();
126    let valid_start = bytes.next().is_some_and(|byte| byte.is_ascii_lowercase());
127    let valid_rest =
128        bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_');
129    let reserved =
130        schema == "public" || schema == "information_schema" || schema.starts_with("pg_");
131    if valid_length && valid_start && valid_rest && !reserved {
132        Ok(())
133    } else {
134        Err(PlanError::InvalidSchemaName {
135            schema: Arc::from(schema),
136        })
137    }
138}
139
140fn validate_migrations(migrations: &[Migration]) -> Result<(), PlanError> {
141    if migrations.is_empty() {
142        return Err(PlanError::EmptyMigrations);
143    }
144
145    for (index, migration) in migrations.iter().enumerate() {
146        let expected = u64::try_from(index).expect("migration index fits u64") + 1;
147        if migration.version != expected {
148            return Err(PlanError::NonContiguousVersion {
149                name: migration.name,
150                expected,
151                actual: migration.version,
152            });
153        }
154        if i64::try_from(migration.version).is_err() {
155            return Err(PlanError::MigrationVersionTooLarge {
156                version: migration.version,
157            });
158        }
159        let mut bytes = migration.name.bytes();
160        let valid_start = bytes.next().is_some_and(|byte| byte.is_ascii_lowercase());
161        let valid_rest = bytes.all(|byte| {
162            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-')
163        });
164        if migration.name.len() > 128 || !valid_start || !valid_rest {
165            return Err(PlanError::InvalidMigrationName {
166                version: migration.version,
167                name: migration.name,
168            });
169        }
170        if migration.sql.trim().is_empty() {
171            return Err(PlanError::EmptyMigrationSql {
172                version: migration.version,
173            });
174        }
175    }
176    Ok(())
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    const VALID: &[Migration] = &[
184        Migration::new(
185            1,
186            "create-items",
187            "CREATE TABLE items (id bigint PRIMARY KEY)",
188        ),
189        Migration::new(2, "add-label", "ALTER TABLE items ADD COLUMN label text"),
190    ];
191
192    #[test]
193    fn accepts_a_contiguous_owned_plan() {
194        let plan = SchemaPlan::new("orders_module", VALID).unwrap();
195        assert_eq!(plan.schema(), "orders_module");
196        assert_eq!(plan.current_version(), 2);
197        assert!(!VALID[0].checksum().is_empty());
198    }
199
200    #[test]
201    fn rejects_shared_or_unsafe_schema_names() {
202        for name in [
203            "",
204            "public",
205            "pg_catalog",
206            "Orders",
207            "orders-module",
208            "1orders",
209        ] {
210            assert!(matches!(
211                SchemaPlan::new(name, VALID),
212                Err(PlanError::InvalidSchemaName { .. })
213            ));
214        }
215    }
216
217    #[test]
218    fn rejects_non_contiguous_migrations() {
219        const GAP: &[Migration] = &[
220            Migration::new(1, "create-items", "SELECT 1"),
221            Migration::new(3, "skip-two", "SELECT 3"),
222        ];
223        assert!(matches!(
224            SchemaPlan::new("orders", GAP),
225            Err(PlanError::NonContiguousVersion {
226                expected: 2,
227                actual: 3,
228                ..
229            })
230        ));
231    }
232
233    #[test]
234    fn checksum_binds_version_name_and_sql() {
235        let original = Migration::new(1, "create-items", "SELECT 1");
236        let renamed = Migration::new(1, "create-records", "SELECT 1");
237        let changed = Migration::new(1, "create-items", "SELECT 2");
238        assert_ne!(original.checksum(), renamed.checksum());
239        assert_ne!(original.checksum(), changed.checksum());
240    }
241}