Skip to main content

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