Skip to main content

sofie_db/
schema_check.rs

1use std::fmt;
2
3use sofie_core::{Cast, Error, Result, SqlType, Uuid};
4
5use crate::{Aggregate, Behavior, BoxFuture, Field, Migrations, Schema, Versioned};
6
7#[derive(Clone, Debug, PartialEq, Eq)]
8pub struct CatalogColumn {
9    pub name: String,
10    pub ty: Option<SqlType>,
11    pub type_name: String,
12    pub nullable: bool,
13    pub filled: bool,
14}
15
16pub trait Catalog: Send + Sync {
17    fn columns<'a>(
18        &'a self,
19        schema: &'a str,
20        name: &'a str,
21    ) -> BoxFuture<'a, Result<Vec<CatalogColumn>>>;
22}
23
24#[derive(Clone, Copy)]
25pub struct AggregateTable {
26    module: &'static str,
27    name: &'static str,
28    fields: &'static [Field],
29    behaviors: &'static [&'static dyn Behavior],
30    #[cfg(feature = "children")]
31    children: &'static [&'static crate::ChildTable],
32    #[cfg(feature = "search")]
33    owner: fn() -> Option<&'static str>,
34}
35
36impl AggregateTable {
37    #[must_use]
38    pub const fn of<A: Aggregate>() -> Self {
39        Self {
40            module: A::MODULE,
41            name: A::NAME,
42            fields: A::FIELDS,
43            behaviors: A::BEHAVIORS,
44            #[cfg(feature = "children")]
45            children: A::CHILDREN,
46            #[cfg(feature = "search")]
47            owner: crate::aggregate::direct_owner::<A>,
48        }
49    }
50
51    #[cfg(feature = "search")]
52    pub(crate) fn search_index(&self) -> Option<crate::SearchIndex> {
53        let behaviors = self.behaviors;
54        behaviors
55            .iter()
56            .any(|behavior| behavior.searched())
57            .then(|| crate::SearchIndex {
58                module: self.module,
59                name: self.name,
60                owner: (self.owner)(),
61                hidden: behaviors.iter().find_map(|behavior| behavior.hides()),
62                fields: behaviors
63                    .iter()
64                    .map(|behavior| behavior.search_fields())
65                    .find(|fields| !fields.is_empty())
66                    .unwrap_or_default(),
67            })
68    }
69
70    fn tables(&self) -> Vec<ExpectedTable> {
71        const ID: Field = Field::new("id", Uuid::COLUMNS, false);
72        let behaviors = std::iter::once(&Versioned as &dyn Behavior)
73            .chain(self.behaviors.iter().copied())
74            .flat_map(Behavior::columns);
75        let own = ExpectedTable::new(
76            self.module,
77            self.name,
78            std::iter::once(&ID).chain(behaviors).chain(self.fields),
79        );
80        #[cfg_attr(not(feature = "children"), expect(unused_mut))]
81        let mut tables = vec![own];
82        #[cfg(feature = "children")]
83        tables.extend(self.children.iter().map(|child| {
84            let parent = Field::new(self.name, Uuid::COLUMNS, false);
85            ExpectedTable::new(
86                child.module,
87                child.name,
88                [&ID, &parent].into_iter().chain(child.fields),
89            )
90        }));
91        tables
92    }
93}
94
95impl fmt::Debug for AggregateTable {
96    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
97        write!(formatter, "AggregateTable({}.{})", self.module, self.name)
98    }
99}
100
101#[derive(Clone, Debug, PartialEq, Eq)]
102pub struct Mismatch {
103    pub table: String,
104    pub kind: MismatchKind,
105}
106
107#[derive(Clone, Debug, PartialEq, Eq)]
108pub enum MismatchKind {
109    MissingTable,
110    MissingColumn {
111        column: String,
112        expected: SqlType,
113    },
114    Type {
115        column: String,
116        expected: SqlType,
117        found: String,
118    },
119    Nullable {
120        column: String,
121    },
122    NotNull {
123        column: String,
124    },
125    Unwritten {
126        column: String,
127    },
128}
129
130impl fmt::Display for Mismatch {
131    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
132        write!(formatter, "{}: {}", self.table, self.kind)
133    }
134}
135
136impl fmt::Display for MismatchKind {
137    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
138        match self {
139            Self::MissingTable => write!(formatter, "the table does not exist"),
140            Self::MissingColumn { column, expected } => {
141                write!(formatter, "column `{column}` ({expected:?}) does not exist")
142            }
143            Self::Type {
144                column,
145                expected,
146                found,
147            } => write!(
148                formatter,
149                "column `{column}` is `{found}`, which does not store {expected:?}"
150            ),
151            Self::Nullable { column } => write!(
152                formatter,
153                "column `{column}` accepts null, which the schema never stores; add NOT NULL"
154            ),
155            Self::NotNull { column } => write!(
156                formatter,
157                "column `{column}` is NOT NULL, but the schema's field is optional"
158            ),
159            Self::Unwritten { column } => write!(
160                formatter,
161                "column `{column}` is NOT NULL without a default, and the schema never writes \
162                 it, so every insert fails"
163            ),
164        }
165    }
166}
167
168struct ExpectedTable {
169    module: &'static str,
170    name: &'static str,
171    columns: Vec<ExpectedColumn>,
172}
173
174struct ExpectedColumn {
175    name: String,
176    ty: SqlType,
177    nullable: bool,
178}
179
180impl ExpectedTable {
181    fn new<'a>(
182        module: &'static str,
183        name: &'static str,
184        fields: impl Iterator<Item = &'a Field>,
185    ) -> Self {
186        let columns = fields
187            .flat_map(|field| {
188                field.columns.iter().map(|column| ExpectedColumn {
189                    name: crate::sql::column_name(field.name, column),
190                    ty: column.ty,
191                    nullable: field.nullable,
192                })
193            })
194            .collect();
195        Self {
196            module,
197            name,
198            columns,
199        }
200    }
201
202    async fn mismatches(&self, catalog: &dyn Catalog) -> Result<Vec<Mismatch>> {
203        let found = catalog.columns(self.module, self.name).await?;
204        let kinds = if found.is_empty() {
205            vec![MismatchKind::MissingTable]
206        } else {
207            self.compare(&found)
208        };
209        let table = format!("{}.{}", self.module, self.name);
210        Ok(kinds
211            .into_iter()
212            .map(|kind| Mismatch {
213                table: table.clone(),
214                kind,
215            })
216            .collect())
217    }
218
219    fn compare(&self, found: &[CatalogColumn]) -> Vec<MismatchKind> {
220        let unwritten = found
221            .iter()
222            .filter(|column| !column.nullable && !column.filled)
223            .filter(|column| !self.columns.iter().any(|known| known.name == column.name))
224            .map(|column| MismatchKind::Unwritten {
225                column: column.name.clone(),
226            });
227        self.columns
228            .iter()
229            .filter_map(|column| compare(column, found))
230            .chain(unwritten)
231            .collect()
232    }
233}
234
235impl Schema<'_> {
236    pub async fn mismatches<A: Aggregate>(self) -> Result<Vec<Mismatch>> {
237        self.table_mismatches(&[AggregateTable::of::<A>()]).await
238    }
239
240    pub async fn check(self, migrations: &[Migrations]) -> Result<Vec<Mismatch>> {
241        let aggregates: Vec<AggregateTable> = migrations
242            .iter()
243            .flat_map(|module| module.aggregates.iter().copied())
244            .collect();
245        self.table_mismatches(&aggregates).await
246    }
247
248    async fn table_mismatches(self, aggregates: &[AggregateTable]) -> Result<Vec<Mismatch>> {
249        let catalog = self.db.driver().catalog().ok_or_else(|| {
250            Error::internal_message("this database describes no tables to check a schema against")
251        })?;
252        let mut mismatches = Vec::new();
253        for table in aggregates.iter().flat_map(AggregateTable::tables) {
254            mismatches.extend(table.mismatches(catalog).await?);
255        }
256        Ok(mismatches)
257    }
258}
259
260fn compare(expected: &ExpectedColumn, found: &[CatalogColumn]) -> Option<MismatchKind> {
261    let column = expected.name.clone();
262    let Some(actual) = found.iter().find(|actual| actual.name == expected.name) else {
263        return Some(MismatchKind::MissingColumn {
264            column,
265            expected: expected.ty,
266        });
267    };
268    if actual.ty != Some(expected.ty) {
269        return Some(MismatchKind::Type {
270            column,
271            expected: expected.ty,
272            found: actual.type_name.clone(),
273        });
274    }
275    match (expected.nullable, actual.nullable) {
276        (false, true) => Some(MismatchKind::Nullable { column }),
277        (true, false) => Some(MismatchKind::NotNull { column }),
278        _ => None,
279    }
280}