1use std::{fmt, sync::Arc};
2
3use sha2::{Digest, Sha256};
4use thiserror::Error;
5
6#[derive(Clone, Copy)]
8pub struct Migration {
9 version: u64,
10 name: &'static str,
11 sql: &'static str,
12}
13
14impl Migration {
15 pub const fn new(version: u64, name: &'static str, sql: &'static str) -> Self {
17 Self { version, name, sql }
18 }
19
20 pub const fn version(&self) -> u64 {
22 self.version
23 }
24
25 pub const fn name(&self) -> &'static str {
27 self.name
28 }
29
30 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#[derive(Clone)]
59pub struct SchemaPlan {
60 schema: Arc<str>,
61 migrations: &'static [Migration],
62}
63
64impl SchemaPlan {
65 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 pub fn schema(&self) -> &str {
78 &self.schema
79 }
80
81 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#[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}