Skip to main content

toasty_core/schema/db/
column.rs

1use super::{DiffContext, TableId, Type, table};
2use crate::stmt;
3
4use hashbrown::{HashMap, HashSet};
5use std::{fmt, ops::Deref};
6
7/// A column in a database table.
8///
9/// Each column has a logical type ([`stmt::Type`]) used by the query engine and
10/// a storage type ([`Type`]) representing how the value is stored in the database.
11///
12/// # Examples
13///
14/// ```ignore
15/// use toasty_core::schema::db::{Column, ColumnId, TableId, Type};
16/// use toasty_core::stmt;
17///
18/// let column = Column {
19///     id: ColumnId { table: TableId(0), index: 0 },
20///     name: "email".to_string(),
21///     ty: stmt::Type::String,
22///     storage_ty: Type::VarChar(255),
23///     nullable: false,
24///     primary_key: false,
25///     auto_increment: false,
26/// };
27///
28/// assert_eq!(column.name, "email");
29/// assert!(!column.nullable);
30/// ```
31#[derive(Debug, Clone, PartialEq)]
32#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
33pub struct Column {
34    /// Uniquely identifies the column in the schema.
35    pub id: ColumnId,
36
37    /// The name of the column in the database.
38    pub name: String,
39
40    /// The column type, from Toasty's point of view.
41    pub ty: stmt::Type,
42
43    /// The database storage type of the column.
44    pub storage_ty: Type,
45
46    /// Whether or not the column is nullable
47    #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "is_false"))]
48    pub nullable: bool,
49
50    /// True if the column is part of the table's primary key
51    #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "is_false"))]
52    pub primary_key: bool,
53
54    /// True if the column is an integer that should be auto-incremented
55    /// with each insertion of a new row. This should be false if a `storage_ty`
56    /// of type `Serial` is used.
57    #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "is_false"))]
58    pub auto_increment: bool,
59
60    /// True if the column tracks an OCC version counter.
61    #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "is_false"))]
62    pub versionable: bool,
63}
64
65#[cfg(feature = "serde")]
66fn is_false(b: &bool) -> bool {
67    !*b
68}
69
70/// Uniquely identifies a column within a schema.
71///
72/// A `ColumnId` combines the [`TableId`] of the owning table with the column's
73/// positional index within that table's column list.
74///
75/// # Examples
76///
77/// ```ignore
78/// use toasty_core::schema::db::{ColumnId, TableId};
79///
80/// let id = ColumnId { table: TableId(0), index: 2 };
81/// assert_eq!(id.index, 2);
82/// ```
83#[derive(PartialEq, Eq, Clone, Copy, Hash)]
84#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
85pub struct ColumnId {
86    /// The table this column belongs to.
87    pub table: TableId,
88    /// Zero-based position of this column in the table's column list.
89    pub index: usize,
90}
91
92impl ColumnId {
93    pub(crate) fn placeholder() -> Self {
94        Self {
95            table: table::TableId::placeholder(),
96            index: usize::MAX,
97        }
98    }
99}
100
101impl From<&Column> for ColumnId {
102    fn from(value: &Column) -> Self {
103        value.id
104    }
105}
106
107impl fmt::Debug for ColumnId {
108    fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
109        write!(fmt, "ColumnId({}/{})", self.table.0, self.index)
110    }
111}
112
113/// The set of differences between two column lists.
114///
115/// Computed by [`ColumnsDiff::from`] and dereferences to
116/// `Vec<ColumnsDiffItem>` for iteration.
117///
118/// # Examples
119///
120/// ```ignore
121/// use toasty_core::schema::db::{ColumnsDiff, DiffContext, RenameHints, Schema};
122///
123/// let previous = Schema::default();
124/// let next = Schema::default();
125/// let hints = RenameHints::new();
126/// let cx = DiffContext::new(&previous, &next, &hints);
127/// let diff = ColumnsDiff::from(&cx, &[], &[]);
128/// assert!(diff.is_empty());
129/// ```
130pub struct ColumnsDiff<'a> {
131    items: Vec<ColumnsDiffItem<'a>>,
132}
133
134impl<'a> ColumnsDiff<'a> {
135    /// Computes the diff between two column slices.
136    ///
137    /// Uses [`DiffContext`] to resolve rename hints. Columns matched by name
138    /// (or by rename hint) are compared field-by-field; unmatched columns in
139    /// `previous` become drops, and unmatched columns in `next` become adds.
140    pub fn from(cx: &DiffContext<'a>, previous: &'a [Column], next: &'a [Column]) -> Self {
141        fn has_diff(previous: &Column, next: &Column) -> bool {
142            previous.name != next.name
143                || previous.storage_ty != next.storage_ty
144                || previous.nullable != next.nullable
145                || previous.primary_key != next.primary_key
146                || previous.auto_increment != next.auto_increment
147                || previous.versionable != next.versionable
148        }
149
150        let mut items = vec![];
151        let mut add_ids: HashSet<_> = next.iter().map(|next| next.id).collect();
152
153        let next_map =
154            HashMap::<&str, &'a Column>::from_iter(next.iter().map(|to| (to.name.as_str(), to)));
155
156        for previous in previous {
157            let next = if let Some(next_id) = cx.rename_hints().get_column(previous.id) {
158                cx.next().column(next_id)
159            } else if let Some(next) = next_map.get(previous.name.as_str()) {
160                next
161            } else {
162                items.push(ColumnsDiffItem::DropColumn(previous));
163                continue;
164            };
165
166            add_ids.remove(&next.id);
167
168            if has_diff(previous, next) {
169                items.push(ColumnsDiffItem::AlterColumn { previous, next });
170            }
171        }
172
173        for column_id in add_ids {
174            items.push(ColumnsDiffItem::AddColumn(cx.next().column(column_id)));
175        }
176
177        Self { items }
178    }
179
180    /// Returns `true` if there are no column changes.
181    pub const fn is_empty(&self) -> bool {
182        self.items.is_empty()
183    }
184}
185
186impl<'a> Deref for ColumnsDiff<'a> {
187    type Target = Vec<ColumnsDiffItem<'a>>;
188
189    fn deref(&self) -> &Self::Target {
190        &self.items
191    }
192}
193
194/// A single change detected between two column lists.
195pub enum ColumnsDiffItem<'a> {
196    /// A new column was added.
197    AddColumn(&'a Column),
198    /// An existing column was removed.
199    DropColumn(&'a Column),
200    /// A column was modified (name, type, nullability, or other property changed).
201    AlterColumn {
202        /// The column definition before the change.
203        previous: &'a Column,
204        /// The column definition after the change.
205        next: &'a Column,
206    },
207}
208
209#[cfg(test)]
210mod tests {
211    use crate::schema::db::{
212        Column, ColumnId, ColumnsDiff, ColumnsDiffItem, DiffContext, PrimaryKey, RenameHints,
213        Schema, Table, TableId, Type,
214    };
215    use crate::stmt;
216
217    fn make_column(
218        table_id: usize,
219        index: usize,
220        name: &str,
221        storage_ty: Type,
222        nullable: bool,
223    ) -> Column {
224        Column {
225            id: ColumnId {
226                table: TableId(table_id),
227                index,
228            },
229            name: name.to_string(),
230            ty: stmt::Type::String, // Simplified for tests
231            storage_ty,
232            nullable,
233            primary_key: false,
234            auto_increment: false,
235            versionable: false,
236        }
237    }
238
239    fn make_schema_with_columns(table_id: usize, columns: Vec<Column>) -> Schema {
240        let mut schema = Schema::default();
241        schema.tables.push(Table {
242            id: TableId(table_id),
243            name: "test_table".to_string(),
244            columns,
245            primary_key: PrimaryKey {
246                columns: vec![],
247                index: super::super::IndexId {
248                    table: TableId(table_id),
249                    index: 0,
250                },
251            },
252            indices: vec![],
253        });
254        schema
255    }
256
257    #[test]
258    fn test_no_diff_same_columns() {
259        let from_cols = vec![
260            make_column(0, 0, "id", Type::Integer(8), false),
261            make_column(0, 1, "name", Type::Text, false),
262        ];
263        let to_cols = vec![
264            make_column(0, 0, "id", Type::Integer(8), false),
265            make_column(0, 1, "name", Type::Text, false),
266        ];
267
268        let from_schema = make_schema_with_columns(0, from_cols.clone());
269        let to_schema = make_schema_with_columns(0, to_cols.clone());
270        let hints = RenameHints::new();
271        let cx = DiffContext::new(&from_schema, &to_schema, &hints);
272
273        let diff = ColumnsDiff::from(&cx, &from_cols, &to_cols);
274        assert!(diff.is_empty());
275    }
276
277    #[test]
278    fn test_add_column() {
279        let from_cols = vec![make_column(0, 0, "id", Type::Integer(8), false)];
280        let to_cols = vec![
281            make_column(0, 0, "id", Type::Integer(8), false),
282            make_column(0, 1, "name", Type::Text, false),
283        ];
284
285        let from_schema = make_schema_with_columns(0, from_cols.clone());
286        let to_schema = make_schema_with_columns(0, to_cols.clone());
287        let hints = RenameHints::new();
288        let cx = DiffContext::new(&from_schema, &to_schema, &hints);
289
290        let diff = ColumnsDiff::from(&cx, &from_cols, &to_cols);
291        assert_eq!(diff.items.len(), 1);
292        assert!(matches!(diff.items[0], ColumnsDiffItem::AddColumn(_)));
293        if let ColumnsDiffItem::AddColumn(col) = diff.items[0] {
294            assert_eq!(col.name, "name");
295        }
296    }
297
298    #[test]
299    fn test_drop_column() {
300        let from_cols = vec![
301            make_column(0, 0, "id", Type::Integer(8), false),
302            make_column(0, 1, "name", Type::Text, false),
303        ];
304        let to_cols = vec![make_column(0, 0, "id", Type::Integer(8), false)];
305
306        let from_schema = make_schema_with_columns(0, from_cols.clone());
307        let to_schema = make_schema_with_columns(0, to_cols.clone());
308        let hints = RenameHints::new();
309        let cx = DiffContext::new(&from_schema, &to_schema, &hints);
310
311        let diff = ColumnsDiff::from(&cx, &from_cols, &to_cols);
312        assert_eq!(diff.items.len(), 1);
313        assert!(matches!(diff.items[0], ColumnsDiffItem::DropColumn(_)));
314        if let ColumnsDiffItem::DropColumn(col) = diff.items[0] {
315            assert_eq!(col.name, "name");
316        }
317    }
318
319    #[test]
320    fn test_alter_column_type() {
321        let from_cols = vec![make_column(0, 0, "id", Type::Integer(8), false)];
322        let to_cols = vec![make_column(0, 0, "id", Type::Text, false)];
323
324        let from_schema = make_schema_with_columns(0, from_cols.clone());
325        let to_schema = make_schema_with_columns(0, to_cols.clone());
326        let hints = RenameHints::new();
327        let cx = DiffContext::new(&from_schema, &to_schema, &hints);
328
329        let diff = ColumnsDiff::from(&cx, &from_cols, &to_cols);
330        assert_eq!(diff.items.len(), 1);
331        assert!(matches!(diff.items[0], ColumnsDiffItem::AlterColumn { .. }));
332    }
333
334    #[test]
335    fn test_alter_column_nullable() {
336        let from_cols = vec![make_column(0, 0, "id", Type::Integer(8), false)];
337        let to_cols = vec![make_column(0, 0, "id", Type::Integer(8), true)];
338
339        let from_schema = make_schema_with_columns(0, from_cols.clone());
340        let to_schema = make_schema_with_columns(0, to_cols.clone());
341        let hints = RenameHints::new();
342        let cx = DiffContext::new(&from_schema, &to_schema, &hints);
343
344        let diff = ColumnsDiff::from(&cx, &from_cols, &to_cols);
345        assert_eq!(diff.items.len(), 1);
346        assert!(matches!(diff.items[0], ColumnsDiffItem::AlterColumn { .. }));
347    }
348
349    #[test]
350    fn test_rename_column_with_hint() {
351        // Column renamed from "old_name" to "new_name"
352        let from_cols = vec![make_column(0, 0, "old_name", Type::Text, false)];
353        let to_cols = vec![make_column(0, 0, "new_name", Type::Text, false)];
354
355        let from_schema = make_schema_with_columns(0, from_cols.clone());
356        let to_schema = make_schema_with_columns(0, to_cols.clone());
357
358        let mut hints = RenameHints::new();
359        hints.add_column_hint(
360            ColumnId {
361                table: TableId(0),
362                index: 0,
363            },
364            ColumnId {
365                table: TableId(0),
366                index: 0,
367            },
368        );
369        let cx = DiffContext::new(&from_schema, &to_schema, &hints);
370
371        let diff = ColumnsDiff::from(&cx, &from_cols, &to_cols);
372        assert_eq!(diff.items.len(), 1);
373        assert!(matches!(diff.items[0], ColumnsDiffItem::AlterColumn { .. }));
374        if let ColumnsDiffItem::AlterColumn { previous, next } = diff.items[0] {
375            assert_eq!(previous.name, "old_name");
376            assert_eq!(next.name, "new_name");
377        }
378    }
379
380    #[test]
381    fn test_rename_column_without_hint_is_drop_and_add() {
382        // Column renamed from "old_name" to "new_name", but no hint provided
383        // Should be treated as drop + add
384        let from_cols = vec![make_column(0, 0, "old_name", Type::Text, false)];
385        let to_cols = vec![make_column(0, 0, "new_name", Type::Text, false)];
386
387        let from_schema = make_schema_with_columns(0, from_cols.clone());
388        let to_schema = make_schema_with_columns(0, to_cols.clone());
389        let hints = RenameHints::new();
390        let cx = DiffContext::new(&from_schema, &to_schema, &hints);
391
392        let diff = ColumnsDiff::from(&cx, &from_cols, &to_cols);
393        assert_eq!(diff.items.len(), 2);
394
395        let has_drop = diff
396            .items
397            .iter()
398            .any(|item| matches!(item, ColumnsDiffItem::DropColumn(_)));
399        let has_add = diff
400            .items
401            .iter()
402            .any(|item| matches!(item, ColumnsDiffItem::AddColumn(_)));
403        assert!(has_drop);
404        assert!(has_add);
405    }
406
407    #[cfg(feature = "serde")]
408    mod serde_tests {
409        use crate::schema::db::{Column, ColumnId, TableId, Type};
410        use crate::stmt;
411
412        fn base_column() -> Column {
413            Column {
414                id: ColumnId {
415                    table: TableId(0),
416                    index: 0,
417                },
418                name: "test".to_string(),
419                ty: stmt::Type::String,
420                storage_ty: Type::Text,
421                nullable: false,
422                primary_key: false,
423                auto_increment: false,
424                versionable: false,
425            }
426        }
427
428        #[test]
429        fn false_booleans_are_omitted() {
430            let toml = toml::to_string(&base_column()).unwrap();
431            assert!(!toml.contains("nullable"), "toml: {toml}");
432            assert!(!toml.contains("primary_key"), "toml: {toml}");
433            assert!(!toml.contains("auto_increment"), "toml: {toml}");
434            assert!(!toml.contains("versionable"), "toml: {toml}");
435        }
436
437        #[test]
438        fn nullable_true_is_included() {
439            let col = Column {
440                nullable: true,
441                ..base_column()
442            };
443            let toml = toml::to_string(&col).unwrap();
444            assert!(toml.contains("nullable = true"), "toml: {toml}");
445        }
446
447        #[test]
448        fn primary_key_true_is_included() {
449            let col = Column {
450                primary_key: true,
451                ..base_column()
452            };
453            let toml = toml::to_string(&col).unwrap();
454            assert!(toml.contains("primary_key = true"), "toml: {toml}");
455        }
456
457        #[test]
458        fn auto_increment_true_is_included() {
459            let col = Column {
460                auto_increment: true,
461                ..base_column()
462            };
463            let toml = toml::to_string(&col).unwrap();
464            assert!(toml.contains("auto_increment = true"), "toml: {toml}");
465        }
466
467        #[test]
468        fn missing_bool_fields_deserialize_as_false() {
469            let toml = "name = \"test\"\nty = \"String\"\nstorage_ty = \"Text\"\n\n[id]\ntable = 0\nindex = 0\n";
470            let col: Column = toml::from_str(toml).unwrap();
471            assert!(!col.nullable);
472            assert!(!col.primary_key);
473            assert!(!col.auto_increment);
474            assert!(!col.versionable);
475        }
476
477        #[test]
478        fn round_trip_all_true() {
479            let original = Column {
480                nullable: true,
481                primary_key: true,
482                auto_increment: true,
483                ..base_column()
484            };
485            let decoded: Column = toml::from_str(&toml::to_string(&original).unwrap()).unwrap();
486            assert_eq!(original, decoded);
487        }
488    }
489
490    #[test]
491    fn test_multiple_operations() {
492        let from_cols = vec![
493            make_column(0, 0, "id", Type::Integer(8), false),
494            make_column(0, 1, "old_name", Type::Text, false),
495            make_column(0, 2, "to_drop", Type::Text, false),
496        ];
497        let to_cols = vec![
498            make_column(0, 0, "id", Type::Text, false), // type changed
499            make_column(0, 1, "new_name", Type::Text, false), // renamed
500            make_column(0, 2, "added", Type::Integer(8), false), // new column
501        ];
502
503        let from_schema = make_schema_with_columns(0, from_cols.clone());
504        let to_schema = make_schema_with_columns(0, to_cols.clone());
505
506        let mut hints = RenameHints::new();
507        hints.add_column_hint(
508            ColumnId {
509                table: TableId(0),
510                index: 1,
511            },
512            ColumnId {
513                table: TableId(0),
514                index: 1,
515            },
516        );
517        let cx = DiffContext::new(&from_schema, &to_schema, &hints);
518
519        let diff = ColumnsDiff::from(&cx, &from_cols, &to_cols);
520        // Should have: 1 alter (id type changed), 1 alter (renamed), 1 drop (to_drop), 1 add (added)
521        assert_eq!(diff.items.len(), 4);
522    }
523}