toasty_core/schema/db/
table.rs

1use super::{Column, ColumnId, Index, IndexId, PrimaryKey};
2use crate::{
3    schema::db::{column::ColumnsDiff, diff::DiffContext, index::IndicesDiff},
4    stmt,
5};
6
7use std::{
8    collections::{HashMap, HashSet},
9    fmt,
10    ops::Deref,
11};
12
13/// A database table with its columns, primary key, and indices.
14///
15/// # Examples
16///
17/// ```ignore
18/// use toasty_core::schema::db::{Table, TableId};
19///
20/// let table = Table::new(TableId(0), "users".to_string());
21/// assert_eq!(table.name, "users");
22/// assert!(table.columns.is_empty());
23/// ```
24#[derive(Debug, Clone)]
25#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
26pub struct Table {
27    /// Uniquely identifies a table within the schema.
28    pub id: TableId,
29
30    /// Name of the table as it appears in the database.
31    pub name: String,
32
33    /// The table's columns, in order.
34    pub columns: Vec<Column>,
35
36    /// The table's primary key definition.
37    pub primary_key: PrimaryKey,
38
39    /// Secondary indices on this table.
40    pub indices: Vec<Index>,
41}
42
43/// Uniquely identifies a table within a [`Schema`](super::Schema).
44///
45/// The inner `usize` is a zero-based index into [`Schema::tables`](super::Schema::tables).
46///
47/// # Examples
48///
49/// ```ignore
50/// use toasty_core::schema::db::TableId;
51///
52/// let id = TableId(0);
53/// assert_eq!(id.0, 0);
54/// ```
55#[derive(PartialEq, Eq, Clone, Copy, Hash)]
56#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
57pub struct TableId(pub usize);
58
59impl Table {
60    /// Returns the `i`-th column of this table's primary key.
61    ///
62    /// # Panics
63    ///
64    /// Panics if `i` is out of bounds for the primary key column list.
65    pub fn primary_key_column(&self, i: usize) -> &Column {
66        &self.columns[self.primary_key.columns[i].index]
67    }
68
69    /// Returns an iterator over the columns that make up this table's primary key.
70    pub fn primary_key_columns(&self) -> impl ExactSizeIterator<Item = &Column> + '_ {
71        self.primary_key
72            .columns
73            .iter()
74            .map(|column_id| &self.columns[column_id.index])
75    }
76
77    /// Returns the column identified by `id`.
78    ///
79    /// Only the column's `index` field is used; the `table` component is ignored.
80    ///
81    /// # Panics
82    ///
83    /// Panics if the column index is out of bounds.
84    pub fn column(&self, id: impl Into<ColumnId>) -> &Column {
85        &self.columns[id.into().index]
86    }
87
88    /// Resolves a single-step [`Projection`](stmt::Projection) to a column.
89    ///
90    /// # Panics
91    ///
92    /// Panics if the projection is empty or contains more than one step.
93    pub fn resolve(&self, projection: &stmt::Projection) -> &Column {
94        let [first, rest @ ..] = projection.as_slice() else {
95            panic!("need at most one path step")
96        };
97        assert!(rest.is_empty());
98
99        &self.columns[*first]
100    }
101
102    pub(crate) fn new(id: TableId, name: String) -> Self {
103        Self {
104            id,
105            name,
106            columns: vec![],
107            primary_key: PrimaryKey {
108                columns: vec![],
109                index: IndexId {
110                    table: id,
111                    index: 0,
112                },
113            },
114            indices: vec![],
115        }
116    }
117}
118
119impl TableId {
120    pub(crate) fn placeholder() -> Self {
121        Self(usize::MAX)
122    }
123}
124
125impl fmt::Debug for TableId {
126    fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
127        write!(fmt, "TableId({})", self.0)
128    }
129}
130
131/// The set of differences between two table lists.
132///
133/// Computed by [`TablesDiff::from`] and dereferences to
134/// `Vec<TablesDiffItem>` for iteration.
135///
136/// # Examples
137///
138/// ```ignore
139/// use toasty_core::schema::db::{TablesDiff, DiffContext, RenameHints, Schema};
140///
141/// let previous = Schema::default();
142/// let next = Schema::default();
143/// let hints = RenameHints::new();
144/// let cx = DiffContext::new(&previous, &next, &hints);
145/// let diff = TablesDiff::from(&cx, &[], &[]);
146/// assert!(diff.is_empty());
147/// ```
148pub struct TablesDiff<'a> {
149    items: Vec<TablesDiffItem<'a>>,
150}
151
152impl<'a> TablesDiff<'a> {
153    /// Computes the diff between two table slices.
154    ///
155    /// Uses [`DiffContext`] to resolve rename hints. Tables matched by name
156    /// (or by rename hint) are compared for column and index changes;
157    /// unmatched tables in `previous` become drops, and unmatched tables in
158    /// `next` become creates.
159    pub fn from(cx: &DiffContext<'a>, previous: &'a [Table], next: &'a [Table]) -> Self {
160        let mut items = vec![];
161        let mut create_ids: HashSet<_> = next.iter().map(|next| next.id).collect();
162
163        let next_map = HashMap::<&str, &'a Table>::from_iter(
164            next.iter().map(|next| (next.name.as_str(), next)),
165        );
166
167        for previous in previous {
168            let next = if let Some(next_id) = cx.rename_hints().get_table(previous.id) {
169                cx.next().table(next_id)
170            } else if let Some(to) = next_map.get(previous.name.as_str()) {
171                to
172            } else {
173                items.push(TablesDiffItem::DropTable(previous));
174                continue;
175            };
176
177            create_ids.remove(&next.id);
178
179            let columns = ColumnsDiff::from(cx, &previous.columns, &next.columns);
180            let indices = IndicesDiff::from(cx, &previous.indices, &next.indices);
181            if previous.name != next.name || !columns.is_empty() || !indices.is_empty() {
182                items.push(TablesDiffItem::AlterTable {
183                    previous,
184                    next,
185                    columns,
186                    indices,
187                });
188            }
189        }
190
191        for table_id in create_ids {
192            items.push(TablesDiffItem::CreateTable(cx.next().table(table_id)));
193        }
194
195        Self { items }
196    }
197}
198
199impl<'a> Deref for TablesDiff<'a> {
200    type Target = Vec<TablesDiffItem<'a>>;
201
202    fn deref(&self) -> &Self::Target {
203        &self.items
204    }
205}
206
207/// A single change detected between two table lists.
208pub enum TablesDiffItem<'a> {
209    /// A new table was created.
210    CreateTable(&'a Table),
211    /// An existing table was dropped.
212    DropTable(&'a Table),
213    /// A table was modified (name, columns, or indices changed).
214    AlterTable {
215        /// The table definition before the change.
216        previous: &'a Table,
217        /// The table definition after the change.
218        next: &'a Table,
219        /// Column-level changes within this table.
220        columns: ColumnsDiff<'a>,
221        /// Index-level changes within this table.
222        indices: IndicesDiff<'a>,
223    },
224}
225
226#[cfg(test)]
227mod tests {
228    use crate::schema::db::{
229        Column, ColumnId, DiffContext, IndexId, PrimaryKey, RenameHints, Schema, Table, TableId,
230        TablesDiff, TablesDiffItem, Type,
231    };
232    use crate::stmt;
233
234    fn make_table(id: usize, name: &str, num_columns: usize) -> Table {
235        let mut columns = vec![];
236        for i in 0..num_columns {
237            columns.push(Column {
238                id: ColumnId {
239                    table: TableId(id),
240                    index: i,
241                },
242                name: format!("col{}", i),
243                ty: stmt::Type::String,
244                storage_ty: Type::Text,
245                nullable: false,
246                primary_key: false,
247                auto_increment: false,
248            });
249        }
250
251        Table {
252            id: TableId(id),
253            name: name.to_string(),
254            columns,
255            primary_key: PrimaryKey {
256                columns: vec![],
257                index: IndexId {
258                    table: TableId(id),
259                    index: 0,
260                },
261            },
262            indices: vec![],
263        }
264    }
265
266    fn make_schema(tables: Vec<Table>) -> Schema {
267        Schema { tables }
268    }
269
270    #[test]
271    fn test_no_diff_same_tables() {
272        let from_tables = vec![make_table(0, "users", 2), make_table(1, "posts", 3)];
273        let to_tables = vec![make_table(0, "users", 2), make_table(1, "posts", 3)];
274
275        let from_schema = make_schema(from_tables.clone());
276        let to_schema = make_schema(to_tables.clone());
277        let hints = RenameHints::new();
278        let cx = DiffContext::new(&from_schema, &to_schema, &hints);
279
280        let diff = TablesDiff::from(&cx, &from_tables, &to_tables);
281        assert_eq!(diff.items.len(), 0);
282    }
283
284    #[test]
285    fn test_create_table() {
286        let from_tables = vec![make_table(0, "users", 2)];
287        let to_tables = vec![make_table(0, "users", 2), make_table(1, "posts", 3)];
288
289        let from_schema = make_schema(from_tables.clone());
290        let to_schema = make_schema(to_tables.clone());
291        let hints = RenameHints::new();
292        let cx = DiffContext::new(&from_schema, &to_schema, &hints);
293
294        let diff = TablesDiff::from(&cx, &from_tables, &to_tables);
295        assert_eq!(diff.items.len(), 1);
296        assert!(matches!(diff.items[0], TablesDiffItem::CreateTable(_)));
297        if let TablesDiffItem::CreateTable(table) = diff.items[0] {
298            assert_eq!(table.name, "posts");
299        }
300    }
301
302    #[test]
303    fn test_drop_table() {
304        let from_tables = vec![make_table(0, "users", 2), make_table(1, "posts", 3)];
305        let to_tables = vec![make_table(0, "users", 2)];
306
307        let from_schema = make_schema(from_tables.clone());
308        let to_schema = make_schema(to_tables.clone());
309        let hints = RenameHints::new();
310        let cx = DiffContext::new(&from_schema, &to_schema, &hints);
311
312        let diff = TablesDiff::from(&cx, &from_tables, &to_tables);
313        assert_eq!(diff.items.len(), 1);
314        assert!(matches!(diff.items[0], TablesDiffItem::DropTable(_)));
315        if let TablesDiffItem::DropTable(table) = diff.items[0] {
316            assert_eq!(table.name, "posts");
317        }
318    }
319
320    #[test]
321    fn test_rename_table_with_hint() {
322        let from_tables = vec![make_table(0, "old_users", 2)];
323        let to_tables = vec![make_table(0, "new_users", 2)];
324
325        let from_schema = make_schema(from_tables.clone());
326        let to_schema = make_schema(to_tables.clone());
327
328        let mut hints = RenameHints::new();
329        hints.add_table_hint(TableId(0), TableId(0));
330        let cx = DiffContext::new(&from_schema, &to_schema, &hints);
331
332        let diff = TablesDiff::from(&cx, &from_tables, &to_tables);
333        assert_eq!(diff.items.len(), 1);
334        assert!(matches!(diff.items[0], TablesDiffItem::AlterTable { .. }));
335        if let TablesDiffItem::AlterTable { previous, next, .. } = &diff.items[0] {
336            assert_eq!(previous.name, "old_users");
337            assert_eq!(next.name, "new_users");
338        }
339    }
340
341    #[test]
342    fn test_rename_table_without_hint_is_drop_and_create() {
343        let from_tables = vec![make_table(0, "old_users", 2)];
344        let to_tables = vec![make_table(0, "new_users", 2)];
345
346        let from_schema = make_schema(from_tables.clone());
347        let to_schema = make_schema(to_tables.clone());
348        let hints = RenameHints::new();
349        let cx = DiffContext::new(&from_schema, &to_schema, &hints);
350
351        let diff = TablesDiff::from(&cx, &from_tables, &to_tables);
352        assert_eq!(diff.items.len(), 2);
353
354        let has_drop = diff
355            .items
356            .iter()
357            .any(|item| matches!(item, TablesDiffItem::DropTable(_)));
358        let has_create = diff
359            .items
360            .iter()
361            .any(|item| matches!(item, TablesDiffItem::CreateTable(_)));
362        assert!(has_drop);
363        assert!(has_create);
364    }
365
366    #[test]
367    fn test_alter_table_column_change() {
368        let from_tables = vec![make_table(0, "users", 2)];
369        let to_tables = vec![make_table(0, "users", 3)]; // added a column
370
371        let from_schema = make_schema(from_tables.clone());
372        let to_schema = make_schema(to_tables.clone());
373        let hints = RenameHints::new();
374        let cx = DiffContext::new(&from_schema, &to_schema, &hints);
375
376        let diff = TablesDiff::from(&cx, &from_tables, &to_tables);
377        assert_eq!(diff.items.len(), 1);
378        assert!(matches!(diff.items[0], TablesDiffItem::AlterTable { .. }));
379    }
380
381    #[test]
382    fn test_multiple_operations() {
383        let from_tables = vec![
384            make_table(0, "users", 2),
385            make_table(1, "posts", 3),
386            make_table(2, "old_table", 1),
387        ];
388        let to_tables = vec![
389            make_table(0, "users", 3),     // added column
390            make_table(1, "new_posts", 3), // renamed
391            make_table(2, "comments", 2),  // new table (reused ID 2)
392        ];
393
394        let from_schema = make_schema(from_tables.clone());
395        let to_schema = make_schema(to_tables.clone());
396
397        let mut hints = RenameHints::new();
398        hints.add_table_hint(TableId(1), TableId(1));
399        let cx = DiffContext::new(&from_schema, &to_schema, &hints);
400
401        let diff = TablesDiff::from(&cx, &from_tables, &to_tables);
402        // Should have: 1 alter (users added column), 1 alter (posts renamed), 1 drop (old_table), 1 create (comments)
403        assert_eq!(diff.items.len(), 4);
404    }
405}