miden_node_db/migration/
schema.rs1use std::fmt;
2
3use anyhow::{Context, Result, ensure};
4use rusqlite::Connection;
5use sha2::{Digest, Sha256};
6
7#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
21pub struct SchemaHash([u8; 32]);
22
23impl SchemaHash {
24 pub const fn from_hex(hex: &str) -> Self {
28 assert!(hex.len() == 64, "schema hash must be 64 hex characters");
29
30 let mut hash = [0_u8; 32];
31 let bytes = hex.as_bytes();
32 let mut idx = 0;
33 while idx < 32 {
34 let high = hex_digit(bytes[idx * 2]);
35 let low = hex_digit(bytes[idx * 2 + 1]);
36 hash[idx] = (high << 4) | low;
37 idx += 1;
38 }
39
40 Self(hash)
41 }
42
43 pub fn new(conn: &Connection) -> Result<Self> {
47 let mut stmt = conn
48 .prepare(
49 "SELECT type, name, tbl_name, sql FROM sqlite_schema \
50 WHERE sql IS NOT NULL \
51 AND name NOT LIKE 'sqlite_%' \
52 ORDER BY type, name, tbl_name",
53 )
54 .context("failed to prepare sqlite_schema query")?;
55
56 let rows = stmt
57 .query_map([], |row| {
58 Ok(SchemaEntry {
59 object_type: row.get(0)?,
60 name: row.get(1)?,
61 table_name: row.get(2)?,
62 sql: normalize_sql(&row.get::<_, String>(3)?),
63 })
64 })
65 .context("failed to query sqlite_schema rows")?;
66
67 let schema_entries = rows
68 .collect::<rusqlite::Result<Vec<_>>>()
69 .context("failed to read sqlite_schema rows")?;
70
71 let mut hasher = Sha256::new();
72 hash_field(&mut hasher, "schema-hash-v1");
73 for entry in schema_entries {
74 hash_field(&mut hasher, &entry.object_type);
75 hash_field(&mut hasher, &entry.name);
76 hash_field(&mut hasher, &entry.table_name);
77 hash_field(&mut hasher, &entry.sql);
78 }
79
80 let digest = hasher.finalize();
81 let mut hash = [0_u8; 32];
82 hash.copy_from_slice(&digest);
83 Ok(Self(hash))
84 }
85}
86
87struct SchemaEntry {
88 object_type: String,
89 name: String,
90 table_name: String,
91 sql: String,
92}
93
94impl fmt::Display for SchemaHash {
95 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96 for byte in self.0 {
97 write!(f, "{byte:02x}")?;
98 }
99 Ok(())
100 }
101}
102
103#[derive(PartialEq)]
104pub struct SchemaHashes<'a>(pub &'a [SchemaHash]);
105
106impl fmt::Display for SchemaHashes<'_> {
107 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108 for hash in self.0 {
109 writeln!(f, "{hash}")?;
110 }
111 Ok(())
112 }
113}
114
115impl fmt::Debug for SchemaHashes<'_> {
116 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117 fmt::Display::fmt(self, f)
118 }
119}
120
121impl SchemaHashes<'_> {
122 pub fn len(&self) -> usize {
123 self.0.len()
124 }
125
126 pub fn is_empty(&self) -> bool {
127 self.0.is_empty()
128 }
129}
130
131fn normalize_sql(sql: &str) -> String {
132 sql.trim_end()
133 .trim_end_matches(';')
134 .split_whitespace()
135 .collect::<Vec<_>>()
136 .join(" ")
137}
138
139fn hash_field(hasher: &mut Sha256, field: &str) {
140 hasher.update(field.len().to_le_bytes());
141 hasher.update(field.as_bytes());
142}
143
144const fn hex_digit(byte: u8) -> u8 {
145 match byte {
146 b'0'..=b'9' => byte - b'0',
147 b'a'..=b'f' => byte - b'a' + 10,
148 b'A'..=b'F' => byte - b'A' + 10,
149 _ => panic!("invalid schema hash hex digit"),
150 }
151}
152
153pub fn get_version(conn: &Connection) -> Result<usize> {
154 let version: i64 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
155 ensure!(version >= 0, "database user_version is negative: {version}");
156 usize::try_from(version).context("database user_version does not fit into usize")
157}
158
159pub fn set_version(conn: &Connection, version: usize) -> Result<()> {
160 let version = version_to_user_version(version)?;
161 conn.execute_batch(&format!("PRAGMA user_version = {version};"))?;
162 Ok(())
163}
164
165fn version_to_user_version(version: usize) -> Result<i32> {
166 i32::try_from(version).with_context(|| {
167 format!("migration version {version} exceeds SQLite user_version i32 range")
168 })
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn schema_hash_round_trips_as_hex() {
177 const HASH: SchemaHash = SchemaHash::from_hex(
178 "abababababababababababababababababababababababababababababababab",
179 );
180
181 assert_eq!(HASH.to_string(), "ab".repeat(32));
182 }
183
184 #[test]
185 fn schema_hash_is_stable_across_creation_order() -> Result<()> {
186 let left = Connection::open_in_memory()?;
187 left.execute_batch(
188 "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT);
189 CREATE TABLE notes (id INTEGER PRIMARY KEY, item_id INTEGER);
190 CREATE INDEX idx_notes_item_id ON notes(item_id);",
191 )?;
192
193 let right = Connection::open_in_memory()?;
194 right.execute_batch(
195 "CREATE TABLE notes (id INTEGER PRIMARY KEY, item_id INTEGER);
196 CREATE INDEX idx_notes_item_id ON notes(item_id);
197 CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT);",
198 )?;
199
200 assert_eq!(SchemaHash::new(&left)?, SchemaHash::new(&right)?);
201 Ok(())
202 }
203
204 #[test]
205 fn schema_hash_changes_for_object_identity() -> Result<()> {
206 let left = Connection::open_in_memory()?;
207 left.execute_batch("CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT);")?;
208
209 let right = Connection::open_in_memory()?;
210 right.execute_batch("CREATE TABLE entries (id INTEGER PRIMARY KEY, value TEXT);")?;
211
212 assert_ne!(SchemaHash::new(&left)?, SchemaHash::new(&right)?);
213 Ok(())
214 }
215
216 #[test]
217 fn schema_hash_changes_for_views_triggers_indexes_and_constraints() -> Result<()> {
218 let base = Connection::open_in_memory()?;
219 base.execute_batch("CREATE TABLE items (id INTEGER PRIMARY KEY, value INTEGER);")?;
220 let base_hash = SchemaHash::new(&base)?;
221
222 let with_index = Connection::open_in_memory()?;
223 with_index.execute_batch(
224 "CREATE TABLE items (id INTEGER PRIMARY KEY, value INTEGER);
225 CREATE INDEX idx_items_value ON items(value);",
226 )?;
227 assert_ne!(base_hash, SchemaHash::new(&with_index)?);
228
229 let with_view = Connection::open_in_memory()?;
230 with_view.execute_batch(
231 "CREATE TABLE items (id INTEGER PRIMARY KEY, value INTEGER);
232 CREATE VIEW item_values AS SELECT value FROM items;",
233 )?;
234 assert_ne!(base_hash, SchemaHash::new(&with_view)?);
235
236 let with_trigger = Connection::open_in_memory()?;
237 with_trigger.execute_batch(
238 "CREATE TABLE items (id INTEGER PRIMARY KEY, value INTEGER);
239 CREATE TRIGGER items_positive_value
240 BEFORE INSERT ON items
241 WHEN NEW.value < 0
242 BEGIN
243 SELECT RAISE(ABORT, 'negative value');
244 END;",
245 )?;
246 assert_ne!(base_hash, SchemaHash::new(&with_trigger)?);
247
248 let with_constraints = Connection::open_in_memory()?;
249 with_constraints.execute_batch(
250 "CREATE TABLE items (
251 id INTEGER PRIMARY KEY,
252 value INTEGER UNIQUE CHECK (value > 0)
253 );",
254 )?;
255 assert_ne!(base_hash, SchemaHash::new(&with_constraints)?);
256
257 Ok(())
258 }
259}