Skip to main content

dovecote_sqlx_sqlite/
schema.rs

1//! `SQLite` schema verification.
2
3use crate::{
4    error::SchemaError,
5    migration::{current_migration, migration_is_usable},
6};
7use sqlx::{FromRow, Row, SqliteConnection, SqlitePool, query, query_as, query_scalar};
8
9#[derive(Debug, FromRow)]
10struct SchemaMarker {
11    schema_version: i64,
12    minimum_crate_major: i64,
13    minimum_crate_minor: i64,
14    minimum_crate_patch: i64,
15    rolling_compatible: i64,
16}
17
18/// Verifies the exact v2 table shape, constraints, indexes, and foreign key of
19/// the installed schema. It never applies a migration.
20///
21/// # Errors
22/// Returns an error for an unsupported backend, missing or incompatible
23/// migration markers, tables, constraints or indexes, or failed catalog reads.
24pub async fn check_schema(pool: &SqlitePool) -> Result<(), SchemaError> {
25    let mut connection = pool
26        .acquire()
27        .await
28        .map_err(|source| SchemaError::sql("acquire schema-check connection", source))?;
29    check_schema_connection(&mut connection).await
30}
31
32/// Performs the complete schema check on an already-owned connection.
33pub(crate) async fn check_schema_connection(
34    connection: &mut SqliteConnection,
35) -> Result<(), SchemaError> {
36    let enabled: i64 = query_scalar("PRAGMA foreign_keys")
37        .fetch_one(&mut *connection)
38        .await
39        .map_err(|source| SchemaError::sql("check foreign-key enforcement", source))?;
40    if enabled != 1 {
41        return Err(mismatch("foreign-key enforcement is disabled"));
42    }
43
44    let migration = current_migration().map_err(mismatch)?;
45    migration_is_usable(migration).map_err(mismatch)?;
46    check_schema_marker(connection, migration).await?;
47
48    check_columns(
49        connection,
50        "dovecote_events",
51        &[
52            ColumnSpec::required("row_id", "INTEGER", true),
53            ColumnSpec::required("tenant_id", "TEXT", false),
54            ColumnSpec::required("stream", "TEXT", false),
55            ColumnSpec::required("specversion", "TEXT", false),
56            ColumnSpec::required("event_id", "TEXT", false),
57            ColumnSpec::required("source", "TEXT", false),
58            ColumnSpec::required("event_type", "TEXT", false),
59            ColumnSpec::optional("subject", "TEXT", false),
60            ColumnSpec::optional("occurred_at", "TEXT", false),
61            ColumnSpec::optional("datacontenttype", "TEXT", false),
62            ColumnSpec::optional("dataschema", "TEXT", false),
63            ColumnSpec::optional("partitionkey", "TEXT", false),
64            ColumnSpec::required("extensions", "TEXT", false),
65            ColumnSpec::optional("data_kind", "TEXT", false),
66            ColumnSpec::optional("data", "BLOB", false),
67            ColumnSpec::required("enqueued_at", "TEXT", false),
68        ],
69    )
70    .await?;
71    check_columns(
72        connection,
73        "dovecote_deliveries",
74        &[
75            ColumnSpec::required("event_row_id", "INTEGER", true),
76            ColumnSpec::required("tenant_id", "TEXT", false),
77            ColumnSpec::required("state", "TEXT", false),
78            ColumnSpec::required("available_at", "TEXT", false),
79            ColumnSpec::required("attempts", "INTEGER", false),
80            ColumnSpec::optional("claim_token", "BLOB", false),
81            ColumnSpec::optional("claimed_by", "TEXT", false),
82            ColumnSpec::optional("claim_expires_at", "TEXT", false),
83            ColumnSpec::optional("last_failure_code", "TEXT", false),
84            ColumnSpec::optional("last_failure_detail", "TEXT", false),
85            ColumnSpec::optional("delivered_at", "TEXT", false),
86            ColumnSpec::optional("quarantined_at", "TEXT", false),
87            ColumnSpec::optional("quarantine_reason", "TEXT", false),
88        ],
89    )
90    .await?;
91
92    let sources = query_as::<_, TableSource>(
93        "SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name IN ('dovecote_schema', 'dovecote_events', 'dovecote_deliveries')",
94    ).fetch_all(&mut *connection).await
95        .map_err(|source| SchemaError::sql("read schema definitions", source))?;
96    for name in ["dovecote_schema", "dovecote_events", "dovecote_deliveries"] {
97        if !sources.iter().any(|source| source.name == name) {
98            return Err(mismatch(format!("required table {name} is missing")));
99        }
100    }
101
102    for name in ["dovecote_schema", "dovecote_events", "dovecote_deliveries"] {
103        let source = sources
104            .iter()
105            .find(|source| source.name == name)
106            .expect("checked above");
107        let expected = expected_table_source(migration.sql(), name).map_err(mismatch)?;
108        if normalize_sql(&source.sql) != normalize_sql(&expected) {
109            return Err(mismatch(format!(
110                "table {name} definition is incompatible with schema version {}",
111                migration.version()
112            )));
113        }
114    }
115
116    // TEMP triggers and indexes can target a main-schema table, so inspect
117    // both catalogs.  Leaving sqlite_temp_master out would allow a caller to
118    // add an unreviewed trigger that changes durable invariants for this
119    // connection while the main schema still appears exact.
120    let extra_objects: Vec<SchemaObject> = query_as(
121        "SELECT type, name, COALESCE(tbl_name, '') AS tbl_name FROM sqlite_master WHERE (name LIKE 'dovecote_%' OR tbl_name IN ('dovecote_events', 'dovecote_deliveries')) AND NOT (type = 'table' AND name IN ('dovecote_schema', 'dovecote_events', 'dovecote_deliveries')) AND NOT (type = 'index' AND name IN ('dovecote_events_tenant_source_event_id', 'dovecote_events_tenant_row', 'dovecote_deliveries_claimable', 'dovecote_deliveries_expired_claims', 'sqlite_autoindex_dovecote_events_1')) UNION ALL SELECT type, name, COALESCE(tbl_name, '') AS tbl_name FROM sqlite_temp_master WHERE name LIKE 'dovecote_%' OR tbl_name IN ('dovecote_events', 'dovecote_deliveries')",
122    )
123    .fetch_all(&mut *connection)
124    .await
125    .map_err(|source| SchemaError::sql("check schema object isolation", source))?;
126    if let Some(object) = extra_objects.first() {
127        return Err(mismatch(format!(
128            "unsupported SQLite schema object {} {} on {}",
129            object.object_type, object.name, object.table_name
130        )));
131    }
132
133    check_index(
134        connection,
135        "dovecote_events",
136        "dovecote_events_tenant_source_event_id",
137        true,
138        &["tenant_id", "source", "event_id"],
139        migration.sql(),
140    )
141    .await?;
142    check_index(
143        connection,
144        "dovecote_events",
145        "dovecote_events_tenant_row",
146        false,
147        &["tenant_id", "row_id"],
148        migration.sql(),
149    )
150    .await?;
151    check_index(
152        connection,
153        "dovecote_deliveries",
154        "dovecote_deliveries_claimable",
155        false,
156        &["tenant_id", "state", "available_at", "event_row_id"],
157        migration.sql(),
158    )
159    .await?;
160    check_index(
161        connection,
162        "dovecote_deliveries",
163        "dovecote_deliveries_expired_claims",
164        false,
165        &["tenant_id", "state", "claim_expires_at", "event_row_id"],
166        migration.sql(),
167    )
168    .await?;
169    check_foreign_key(connection).await?;
170    let violations = query("PRAGMA foreign_key_check")
171        .fetch_all(&mut *connection)
172        .await
173        .map_err(|source| SchemaError::sql("check foreign-key integrity", source))?;
174    if !violations.is_empty() {
175        return Err(mismatch("installed schema contains foreign-key violations"));
176    }
177    Ok(())
178}
179
180async fn check_schema_marker(
181    connection: &mut SqliteConnection,
182    migration: crate::migration::Migration,
183) -> Result<(), SchemaError> {
184    let markers = query_as::<_, SchemaMarker>(
185        "SELECT schema_version, minimum_crate_major, minimum_crate_minor, minimum_crate_patch, rolling_compatible FROM dovecote_schema",
186    )
187    .fetch_all(&mut *connection)
188    .await
189    .map_err(|source| SchemaError::sql("check schema marker", source))?;
190    if markers.len() != 1 {
191        return Err(mismatch(format!(
192            "expected exactly one schema marker row, found {}",
193            markers.len()
194        )));
195    }
196
197    let marker = &markers[0];
198    let minimum = migration.compatibility().minimum();
199    if marker.schema_version != i64::from(migration.version())
200        || marker.minimum_crate_major != i64::from(minimum.major())
201        || marker.minimum_crate_minor != i64::from(minimum.minor())
202        || marker.minimum_crate_patch != i64::from(minimum.patch())
203        || marker.rolling_compatible != i64::from(migration.rolling_compatible())
204    {
205        return Err(mismatch("schema marker is incompatible with this adapter"));
206    }
207    Ok(())
208}
209
210fn mismatch(detail: impl Into<String>) -> SchemaError {
211    SchemaError::MigrationMismatch {
212        detail: detail.into(),
213    }
214}
215
216#[derive(Clone, Copy)]
217struct ColumnSpec {
218    name: &'static str,
219    kind: &'static str,
220    primary_key: bool,
221    not_null: bool,
222}
223impl ColumnSpec {
224    const fn required(name: &'static str, kind: &'static str, primary_key: bool) -> Self {
225        Self {
226            name,
227            kind,
228            primary_key,
229            not_null: !primary_key,
230        }
231    }
232    const fn optional(name: &'static str, kind: &'static str, primary_key: bool) -> Self {
233        Self {
234            name,
235            kind,
236            primary_key,
237            not_null: false,
238        }
239    }
240}
241
242#[derive(Debug, FromRow)]
243struct TableSource {
244    name: String,
245    sql: String,
246}
247
248#[derive(Debug, FromRow)]
249struct SchemaObject {
250    #[sqlx(rename = "type")]
251    object_type: String,
252    name: String,
253    #[sqlx(rename = "tbl_name")]
254    table_name: String,
255}
256
257async fn check_columns(
258    connection: &mut SqliteConnection,
259    table: &str,
260    expected: &[ColumnSpec],
261) -> Result<(), SchemaError> {
262    let sql = sqlx::AssertSqlSafe(format!("PRAGMA table_info({table})"));
263    let rows = query(sql)
264        .fetch_all(&mut *connection)
265        .await
266        .map_err(|source| SchemaError::sql("check table columns", source))?;
267    for spec in expected {
268        let Some(row) = rows
269            .iter()
270            .find(|row| row.try_get::<String, _>("name").ok().as_deref() == Some(spec.name))
271        else {
272            return Err(mismatch(format!(
273                "required column {table}.{} is missing",
274                spec.name
275            )));
276        };
277
278        let kind = row
279            .try_get::<String, _>("type")
280            .map_err(|_| mismatch(format!("column {table}.{} has no type", spec.name)))?;
281        if !kind.eq_ignore_ascii_case(spec.kind) {
282            return Err(mismatch(format!(
283                "column {table}.{} has type {kind}, expected {}",
284                spec.name, spec.kind
285            )));
286        }
287
288        let pk = row.try_get::<i64, _>("pk").unwrap_or_default();
289        if spec.primary_key && pk != 1 {
290            return Err(mismatch(format!(
291                "column {table}.{} is not the primary key",
292                spec.name
293            )));
294        }
295
296        let not_null = row.try_get::<i64, _>("notnull").unwrap_or_default() != 0;
297        if spec.not_null && !not_null {
298            return Err(mismatch(format!(
299                "column {table}.{} must be NOT NULL",
300                spec.name
301            )));
302        }
303    }
304
305    Ok(())
306}
307
308async fn check_index(
309    connection: &mut SqliteConnection,
310    table: &str,
311    expected_name: &str,
312    unique: bool,
313    columns: &[&str],
314    migration: &str,
315) -> Result<(), SchemaError> {
316    let sql = sqlx::AssertSqlSafe(format!("PRAGMA index_list({table})"));
317    let indexes = query(sql)
318        .fetch_all(&mut *connection)
319        .await
320        .map_err(|source| SchemaError::sql("check schema indexes", source))?;
321    let Some(index) = indexes
322        .iter()
323        .find(|row| row.try_get::<String, _>("name").ok().as_deref() == Some(expected_name))
324    else {
325        return Err(mismatch(format!(
326            "required index {expected_name} is missing"
327        )));
328    };
329
330    let actual_unique = index.try_get::<i64, _>("unique").unwrap_or_default() != 0;
331    if actual_unique != unique {
332        return Err(mismatch(format!(
333            "index {expected_name} uniqueness is incompatible"
334        )));
335    }
336
337    let source: Option<String> =
338        query_scalar("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?")
339            .bind(expected_name)
340            .fetch_optional(&mut *connection)
341            .await
342            .map_err(|source| SchemaError::sql("read schema index definition", source))?;
343    let Some(source) = source else {
344        return Err(mismatch(format!("index {expected_name} has no definition")));
345    };
346
347    let expected = expected_index_source(migration, expected_name).map_err(mismatch)?;
348    if normalize_sql(&source) != normalize_sql(&expected) {
349        return Err(mismatch(format!(
350            "index {expected_name} definition is incompatible"
351        )));
352    }
353
354    let info_sql = sqlx::AssertSqlSafe(format!("PRAGMA index_info({expected_name})"));
355    let info = query(info_sql)
356        .fetch_all(&mut *connection)
357        .await
358        .map_err(|source| SchemaError::sql("read schema index columns", source))?;
359    let actual = info
360        .iter()
361        .filter_map(|row| row.try_get::<String, _>("name").ok())
362        .collect::<Vec<_>>();
363    if actual
364        != columns
365            .iter()
366            .map(|column| (*column).to_owned())
367            .collect::<Vec<_>>()
368    {
369        return Err(mismatch(format!(
370            "index {expected_name} columns are incompatible"
371        )));
372    }
373
374    if expected_name == "dovecote_events_tenant_source_event_id" {
375        let xinfo_sql = sqlx::AssertSqlSafe(format!("PRAGMA index_xinfo({expected_name})"));
376        let xinfo = query(xinfo_sql)
377            .fetch_all(&mut *connection)
378            .await
379            .map_err(|source| SchemaError::sql("read identity index collation", source))?;
380        let collations = xinfo
381            .iter()
382            .filter_map(|row| row.try_get::<i64, _>("key").ok().filter(|key| *key != 0))
383            .zip(
384                xinfo
385                    .iter()
386                    .filter_map(|row| row.try_get::<String, _>("coll").ok()),
387            )
388            .map(|(_, collation)| collation)
389            .collect::<Vec<_>>();
390        if collations
391            != [
392                "BINARY".to_owned(),
393                "BINARY".to_owned(),
394                "BINARY".to_owned(),
395            ]
396        {
397            return Err(mismatch("identity index collation is not BINARY"));
398        }
399    }
400
401    Ok(())
402}
403
404async fn check_foreign_key(connection: &mut SqliteConnection) -> Result<(), SchemaError> {
405    let rows = query("PRAGMA foreign_key_list(dovecote_deliveries)")
406        .fetch_all(&mut *connection)
407        .await
408        .map_err(|source| SchemaError::sql("check delivery foreign key", source))?;
409    let matching = rows
410        .iter()
411        .filter(|row| row.try_get::<String, _>("table").ok().as_deref() == Some("dovecote_events"))
412        .collect::<Vec<_>>();
413    if matching.len() != 2 {
414        return Err(mismatch("delivery foreign key is missing"));
415    }
416
417    let columns = matching
418        .iter()
419        .map(|row| {
420            (
421                row.try_get::<String, _>("from").unwrap_or_default(),
422                row.try_get::<String, _>("to").unwrap_or_default(),
423            )
424        })
425        .collect::<Vec<_>>();
426    if !columns.contains(&("tenant_id".to_owned(), "tenant_id".to_owned()))
427        || !columns.contains(&("event_row_id".to_owned(), "row_id".to_owned()))
428    {
429        return Err(mismatch("delivery foreign key is incompatible"));
430    }
431    Ok(())
432}
433
434fn expected_table_source(migration: &str, table: &str) -> Result<String, String> {
435    let needle = format!("CREATE TABLE {table}");
436    let start = migration
437        .find(&needle)
438        .ok_or_else(|| format!("migration does not define {table}"))?;
439    let mut depth = 0_u32;
440    let mut quoted = false;
441    for (offset, character) in migration[start..].char_indices() {
442        match character {
443            '\'' => quoted = !quoted,
444            '(' if !quoted => depth = depth.saturating_add(1),
445            ')' if !quoted => depth = depth.saturating_sub(1),
446            ';' if !quoted && depth == 0 => return Ok(migration[start..start + offset].to_owned()),
447            _ => {}
448        }
449    }
450    Err(format!("migration statement for {table} is unterminated"))
451}
452
453fn expected_index_source(migration: &str, index: &str) -> Result<String, String> {
454    let needle = if migration.contains(&format!("CREATE UNIQUE INDEX {index}")) {
455        format!("CREATE UNIQUE INDEX {index}")
456    } else {
457        format!("CREATE INDEX {index}")
458    };
459    let start = migration
460        .find(&needle)
461        .ok_or_else(|| format!("migration does not define {index}"))?;
462    let end = migration[start..]
463        .find(';')
464        .map(|offset| start + offset)
465        .ok_or_else(|| format!("migration statement for {index} is unterminated"))?;
466    Ok(migration[start..end].to_owned())
467}
468
469fn normalize_sql(value: &str) -> String {
470    let mut normalized = String::with_capacity(value.len());
471    let mut in_string = false;
472    for character in value.chars() {
473        match (character, in_string) {
474            ('\'', _) => {
475                in_string = !in_string;
476                normalized.push(character);
477            }
478            ('"', false) => {
479                // SQLite quotes a rebuilt table name after ALTER TABLE RENAME;
480                // identifier quoting does not change the table contract.
481            }
482            (character, true) if !character.is_ascii_whitespace() => {
483                normalized.push(character);
484            }
485            (character, false) if !character.is_ascii_whitespace() => {
486                normalized.extend(character.to_lowercase());
487            }
488            _ => {}
489        }
490    }
491    normalized
492}