vespertide_core/schema/
column.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use crate::schema::{
5    foreign_key::ForeignKeySyntax, names::ColumnName, primary_key::PrimaryKeySyntax,
6    str_or_bool::StrOrBoolOrArray,
7};
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
10#[serde(rename_all = "snake_case")]
11pub struct ColumnDef {
12    pub name: ColumnName,
13    pub r#type: ColumnType,
14    pub nullable: bool,
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub default: Option<String>,
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub comment: Option<String>,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub primary_key: Option<PrimaryKeySyntax>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub unique: Option<StrOrBoolOrArray>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub index: Option<StrOrBoolOrArray>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub foreign_key: Option<ForeignKeySyntax>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
30#[serde(rename_all = "snake_case", untagged)]
31pub enum ColumnType {
32    Simple(SimpleColumnType),
33    Complex(ComplexColumnType),
34}
35
36impl ColumnType {
37    /// Returns true if this type supports auto_increment (integer types only)
38    pub fn supports_auto_increment(&self) -> bool {
39        match self {
40            ColumnType::Simple(ty) => ty.supports_auto_increment(),
41            ColumnType::Complex(_) => false,
42        }
43    }
44
45    /// Check if two column types require a migration.
46    /// For integer enums, no migration is ever needed because the underlying DB type is always INTEGER.
47    /// The enum name and values only affect code generation (SeaORM entities), not the database schema.
48    pub fn requires_migration(&self, other: &ColumnType) -> bool {
49        match (self, other) {
50            (
51                ColumnType::Complex(ComplexColumnType::Enum {
52                    values: values1, ..
53                }),
54                ColumnType::Complex(ComplexColumnType::Enum {
55                    values: values2, ..
56                }),
57            ) => {
58                // Both are integer enums - never require migration (DB type is always INTEGER)
59                if values1.is_integer() && values2.is_integer() {
60                    false
61                } else {
62                    // At least one is string enum - compare fully
63                    self != other
64                }
65            }
66            _ => self != other,
67        }
68    }
69
70    /// Convert column type to Rust type string (for SeaORM entity generation)
71    pub fn to_rust_type(&self, nullable: bool) -> String {
72        let base = match self {
73            ColumnType::Simple(ty) => match ty {
74                SimpleColumnType::SmallInt => "i16".to_string(),
75                SimpleColumnType::Integer => "i32".to_string(),
76                SimpleColumnType::BigInt => "i64".to_string(),
77                SimpleColumnType::Real => "f32".to_string(),
78                SimpleColumnType::DoublePrecision => "f64".to_string(),
79                SimpleColumnType::Text => "String".to_string(),
80                SimpleColumnType::Boolean => "bool".to_string(),
81                SimpleColumnType::Date => "Date".to_string(),
82                SimpleColumnType::Time => "Time".to_string(),
83                SimpleColumnType::Timestamp => "DateTime".to_string(),
84                SimpleColumnType::Timestamptz => "DateTimeWithTimeZone".to_string(),
85                SimpleColumnType::Interval => "String".to_string(),
86                SimpleColumnType::Bytea => "Vec<u8>".to_string(),
87                SimpleColumnType::Uuid => "Uuid".to_string(),
88                SimpleColumnType::Json | SimpleColumnType::Jsonb => "Json".to_string(),
89                SimpleColumnType::Inet | SimpleColumnType::Cidr => "String".to_string(),
90                SimpleColumnType::Macaddr => "String".to_string(),
91                SimpleColumnType::Xml => "String".to_string(),
92            },
93            ColumnType::Complex(ty) => match ty {
94                ComplexColumnType::Varchar { .. } => "String".to_string(),
95                ComplexColumnType::Numeric { .. } => "Decimal".to_string(),
96                ComplexColumnType::Char { .. } => "String".to_string(),
97                ComplexColumnType::Custom { .. } => "String".to_string(), // Default for custom types
98                ComplexColumnType::Enum { .. } => "String".to_string(),
99            },
100        };
101
102        if nullable {
103            format!("Option<{}>", base)
104        } else {
105            base
106        }
107    }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
111#[serde(rename_all = "snake_case")]
112pub enum SimpleColumnType {
113    SmallInt,
114    Integer,
115    BigInt,
116    Real,
117    DoublePrecision,
118
119    // Text types
120    Text,
121
122    // Boolean type
123    Boolean,
124
125    // Date/Time types
126    Date,
127    Time,
128    Timestamp,
129    Timestamptz,
130    Interval,
131
132    // Binary type
133    Bytea,
134
135    // UUID type
136    Uuid,
137
138    // JSON types
139    Json,
140    Jsonb,
141
142    // Network types
143    Inet,
144    Cidr,
145    Macaddr,
146
147    // XML type
148    Xml,
149}
150
151impl SimpleColumnType {
152    /// Returns true if this type supports auto_increment (integer types only)
153    pub fn supports_auto_increment(&self) -> bool {
154        matches!(
155            self,
156            SimpleColumnType::SmallInt | SimpleColumnType::Integer | SimpleColumnType::BigInt
157        )
158    }
159}
160
161/// Integer enum variant with name and numeric value
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
163pub struct NumValue {
164    pub name: String,
165    pub value: i32,
166}
167
168/// Enum values definition - either all string or all integer
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
170#[serde(untagged)]
171pub enum EnumValues {
172    String(Vec<String>),
173    Integer(Vec<NumValue>),
174}
175
176impl EnumValues {
177    /// Check if this is a string enum
178    pub fn is_string(&self) -> bool {
179        matches!(self, EnumValues::String(_))
180    }
181
182    /// Check if this is an integer enum
183    pub fn is_integer(&self) -> bool {
184        matches!(self, EnumValues::Integer(_))
185    }
186
187    /// Get all variant names
188    pub fn variant_names(&self) -> Vec<&str> {
189        match self {
190            EnumValues::String(values) => values.iter().map(|s| s.as_str()).collect(),
191            EnumValues::Integer(values) => values.iter().map(|v| v.name.as_str()).collect(),
192        }
193    }
194
195    /// Get the number of variants
196    pub fn len(&self) -> usize {
197        match self {
198            EnumValues::String(values) => values.len(),
199            EnumValues::Integer(values) => values.len(),
200        }
201    }
202
203    /// Check if there are no variants
204    pub fn is_empty(&self) -> bool {
205        self.len() == 0
206    }
207
208    /// Get SQL values for CREATE TYPE ENUM (only for string enums)
209    /// Returns quoted strings like 'value1', 'value2'
210    pub fn to_sql_values(&self) -> Vec<String> {
211        match self {
212            EnumValues::String(values) => values
213                .iter()
214                .map(|s| format!("'{}'", s.replace('\'', "''")))
215                .collect(),
216            EnumValues::Integer(values) => values.iter().map(|v| v.value.to_string()).collect(),
217        }
218    }
219}
220
221impl From<Vec<String>> for EnumValues {
222    fn from(values: Vec<String>) -> Self {
223        EnumValues::String(values)
224    }
225}
226
227impl From<Vec<&str>> for EnumValues {
228    fn from(values: Vec<&str>) -> Self {
229        EnumValues::String(values.into_iter().map(|s| s.to_string()).collect())
230    }
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
234#[serde(rename_all = "snake_case", tag = "kind")]
235pub enum ComplexColumnType {
236    Varchar { length: u32 },
237    Numeric { precision: u32, scale: u32 },
238    Char { length: u32 },
239    Custom { custom_type: String },
240    Enum { name: String, values: EnumValues },
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use rstest::rstest;
247
248    #[rstest]
249    #[case(SimpleColumnType::SmallInt, "i16")]
250    #[case(SimpleColumnType::Integer, "i32")]
251    #[case(SimpleColumnType::BigInt, "i64")]
252    #[case(SimpleColumnType::Real, "f32")]
253    #[case(SimpleColumnType::DoublePrecision, "f64")]
254    #[case(SimpleColumnType::Text, "String")]
255    #[case(SimpleColumnType::Boolean, "bool")]
256    #[case(SimpleColumnType::Date, "Date")]
257    #[case(SimpleColumnType::Time, "Time")]
258    #[case(SimpleColumnType::Timestamp, "DateTime")]
259    #[case(SimpleColumnType::Timestamptz, "DateTimeWithTimeZone")]
260    #[case(SimpleColumnType::Interval, "String")]
261    #[case(SimpleColumnType::Bytea, "Vec<u8>")]
262    #[case(SimpleColumnType::Uuid, "Uuid")]
263    #[case(SimpleColumnType::Json, "Json")]
264    #[case(SimpleColumnType::Jsonb, "Json")]
265    #[case(SimpleColumnType::Inet, "String")]
266    #[case(SimpleColumnType::Cidr, "String")]
267    #[case(SimpleColumnType::Macaddr, "String")]
268    #[case(SimpleColumnType::Xml, "String")]
269    fn test_simple_column_type_to_rust_type_not_nullable(
270        #[case] column_type: SimpleColumnType,
271        #[case] expected: &str,
272    ) {
273        assert_eq!(
274            ColumnType::Simple(column_type).to_rust_type(false),
275            expected
276        );
277    }
278
279    #[rstest]
280    #[case(SimpleColumnType::SmallInt, "Option<i16>")]
281    #[case(SimpleColumnType::Integer, "Option<i32>")]
282    #[case(SimpleColumnType::BigInt, "Option<i64>")]
283    #[case(SimpleColumnType::Real, "Option<f32>")]
284    #[case(SimpleColumnType::DoublePrecision, "Option<f64>")]
285    #[case(SimpleColumnType::Text, "Option<String>")]
286    #[case(SimpleColumnType::Boolean, "Option<bool>")]
287    #[case(SimpleColumnType::Date, "Option<Date>")]
288    #[case(SimpleColumnType::Time, "Option<Time>")]
289    #[case(SimpleColumnType::Timestamp, "Option<DateTime>")]
290    #[case(SimpleColumnType::Timestamptz, "Option<DateTimeWithTimeZone>")]
291    #[case(SimpleColumnType::Interval, "Option<String>")]
292    #[case(SimpleColumnType::Bytea, "Option<Vec<u8>>")]
293    #[case(SimpleColumnType::Uuid, "Option<Uuid>")]
294    #[case(SimpleColumnType::Json, "Option<Json>")]
295    #[case(SimpleColumnType::Jsonb, "Option<Json>")]
296    #[case(SimpleColumnType::Inet, "Option<String>")]
297    #[case(SimpleColumnType::Cidr, "Option<String>")]
298    #[case(SimpleColumnType::Macaddr, "Option<String>")]
299    #[case(SimpleColumnType::Xml, "Option<String>")]
300    fn test_simple_column_type_to_rust_type_nullable(
301        #[case] column_type: SimpleColumnType,
302        #[case] expected: &str,
303    ) {
304        assert_eq!(ColumnType::Simple(column_type).to_rust_type(true), expected);
305    }
306
307    #[rstest]
308    #[case(ComplexColumnType::Varchar { length: 255 }, false, "String")]
309    #[case(ComplexColumnType::Varchar { length: 50 }, false, "String")]
310    #[case(ComplexColumnType::Numeric { precision: 10, scale: 2 }, false, "Decimal")]
311    #[case(ComplexColumnType::Numeric { precision: 5, scale: 0 }, false, "Decimal")]
312    #[case(ComplexColumnType::Char { length: 10 }, false, "String")]
313    #[case(ComplexColumnType::Char { length: 1 }, false, "String")]
314    #[case(ComplexColumnType::Custom { custom_type: "MONEY".into() }, false, "String")]
315    #[case(ComplexColumnType::Custom { custom_type: "JSONB".into() }, false, "String")]
316    #[case(ComplexColumnType::Enum { name: "status".into(), values: EnumValues::String(vec!["active".into(), "inactive".into()]) }, false, "String")]
317    fn test_complex_column_type_to_rust_type_not_nullable(
318        #[case] column_type: ComplexColumnType,
319        #[case] nullable: bool,
320        #[case] expected: &str,
321    ) {
322        assert_eq!(
323            ColumnType::Complex(column_type).to_rust_type(nullable),
324            expected
325        );
326    }
327
328    #[rstest]
329    #[case(ComplexColumnType::Varchar { length: 255 }, "Option<String>")]
330    #[case(ComplexColumnType::Varchar { length: 50 }, "Option<String>")]
331    #[case(ComplexColumnType::Numeric { precision: 10, scale: 2 }, "Option<Decimal>")]
332    #[case(ComplexColumnType::Numeric { precision: 5, scale: 0 }, "Option<Decimal>")]
333    #[case(ComplexColumnType::Char { length: 10 }, "Option<String>")]
334    #[case(ComplexColumnType::Char { length: 1 }, "Option<String>")]
335    #[case(ComplexColumnType::Custom { custom_type: "MONEY".into() }, "Option<String>")]
336    #[case(ComplexColumnType::Custom { custom_type: "JSONB".into() }, "Option<String>")]
337    #[case(ComplexColumnType::Enum { name: "status".into(), values: EnumValues::String(vec!["active".into(), "inactive".into()]) }, "Option<String>")]
338    fn test_complex_column_type_to_rust_type_nullable(
339        #[case] column_type: ComplexColumnType,
340        #[case] expected: &str,
341    ) {
342        assert_eq!(
343            ColumnType::Complex(column_type).to_rust_type(true),
344            expected
345        );
346    }
347
348    #[rstest]
349    #[case(ComplexColumnType::Varchar { length: 255 })]
350    #[case(ComplexColumnType::Numeric { precision: 10, scale: 2 })]
351    #[case(ComplexColumnType::Char { length: 1 })]
352    #[case(ComplexColumnType::Custom { custom_type: "SERIAL".into() })]
353    #[case(ComplexColumnType::Enum { name: "status".into(), values: EnumValues::String(vec![]) })]
354    fn test_complex_column_type_does_not_support_auto_increment(
355        #[case] column_type: ComplexColumnType,
356    ) {
357        // Complex types never support auto_increment
358        assert!(!ColumnType::Complex(column_type).supports_auto_increment());
359    }
360
361    #[test]
362    fn test_enum_values_is_string() {
363        let string_vals = EnumValues::String(vec!["active".into()]);
364        let int_vals = EnumValues::Integer(vec![NumValue {
365            name: "Active".into(),
366            value: 1,
367        }]);
368        assert!(string_vals.is_string());
369        assert!(!int_vals.is_string());
370    }
371
372    #[test]
373    fn test_enum_values_is_integer() {
374        let string_vals = EnumValues::String(vec!["active".into()]);
375        let int_vals = EnumValues::Integer(vec![NumValue {
376            name: "Active".into(),
377            value: 1,
378        }]);
379        assert!(!string_vals.is_integer());
380        assert!(int_vals.is_integer());
381    }
382
383    #[test]
384    fn test_enum_values_variant_names_string() {
385        let vals = EnumValues::String(vec!["pending".into(), "active".into()]);
386        assert_eq!(vals.variant_names(), vec!["pending", "active"]);
387    }
388
389    #[test]
390    fn test_enum_values_variant_names_integer() {
391        let vals = EnumValues::Integer(vec![
392            NumValue {
393                name: "Low".into(),
394                value: 0,
395            },
396            NumValue {
397                name: "High".into(),
398                value: 10,
399            },
400        ]);
401        assert_eq!(vals.variant_names(), vec!["Low", "High"]);
402    }
403
404    #[test]
405    fn test_enum_values_len_and_is_empty() {
406        // String variant
407        let empty = EnumValues::String(vec![]);
408        let non_empty = EnumValues::String(vec!["a".into()]);
409        assert!(empty.is_empty());
410        assert_eq!(empty.len(), 0);
411        assert!(!non_empty.is_empty());
412        assert_eq!(non_empty.len(), 1);
413
414        // Integer variant
415        let empty_int = EnumValues::Integer(vec![]);
416        let non_empty_int = EnumValues::Integer(vec![
417            NumValue {
418                name: "A".into(),
419                value: 0,
420            },
421            NumValue {
422                name: "B".into(),
423                value: 1,
424            },
425        ]);
426        assert!(empty_int.is_empty());
427        assert_eq!(empty_int.len(), 0);
428        assert!(!non_empty_int.is_empty());
429        assert_eq!(non_empty_int.len(), 2);
430    }
431
432    #[test]
433    fn test_enum_values_to_sql_values_string() {
434        let vals = EnumValues::String(vec!["active".into(), "pending".into()]);
435        assert_eq!(vals.to_sql_values(), vec!["'active'", "'pending'"]);
436    }
437
438    #[test]
439    fn test_enum_values_to_sql_values_integer() {
440        let vals = EnumValues::Integer(vec![
441            NumValue {
442                name: "Low".into(),
443                value: 0,
444            },
445            NumValue {
446                name: "High".into(),
447                value: 10,
448            },
449        ]);
450        assert_eq!(vals.to_sql_values(), vec!["0", "10"]);
451    }
452
453    #[test]
454    fn test_enum_values_from_vec_string() {
455        let vals: EnumValues = vec!["a".to_string(), "b".to_string()].into();
456        assert!(matches!(vals, EnumValues::String(_)));
457    }
458
459    #[test]
460    fn test_enum_values_from_vec_str() {
461        let vals: EnumValues = vec!["a", "b"].into();
462        assert!(matches!(vals, EnumValues::String(_)));
463    }
464
465    #[rstest]
466    #[case(SimpleColumnType::SmallInt, true)]
467    #[case(SimpleColumnType::Integer, true)]
468    #[case(SimpleColumnType::BigInt, true)]
469    #[case(SimpleColumnType::Text, false)]
470    #[case(SimpleColumnType::Boolean, false)]
471    fn test_simple_column_type_supports_auto_increment(
472        #[case] ty: SimpleColumnType,
473        #[case] expected: bool,
474    ) {
475        assert_eq!(ty.supports_auto_increment(), expected);
476    }
477
478    #[rstest]
479    #[case(SimpleColumnType::Integer, true)]
480    #[case(SimpleColumnType::Text, false)]
481    fn test_column_type_simple_supports_auto_increment(
482        #[case] ty: SimpleColumnType,
483        #[case] expected: bool,
484    ) {
485        assert_eq!(ColumnType::Simple(ty).supports_auto_increment(), expected);
486    }
487
488    #[test]
489    fn test_requires_migration_integer_enum_values_changed() {
490        // Integer enum values changed - should NOT require migration
491        let from = ColumnType::Complex(ComplexColumnType::Enum {
492            name: "status".into(),
493            values: EnumValues::Integer(vec![
494                NumValue {
495                    name: "Pending".into(),
496                    value: 0,
497                },
498                NumValue {
499                    name: "Active".into(),
500                    value: 1,
501                },
502            ]),
503        });
504        let to = ColumnType::Complex(ComplexColumnType::Enum {
505            name: "status".into(),
506            values: EnumValues::Integer(vec![
507                NumValue {
508                    name: "Pending".into(),
509                    value: 0,
510                },
511                NumValue {
512                    name: "Active".into(),
513                    value: 1,
514                },
515                NumValue {
516                    name: "Completed".into(),
517                    value: 100,
518                },
519            ]),
520        });
521        assert!(!from.requires_migration(&to));
522    }
523
524    #[test]
525    fn test_requires_migration_integer_enum_name_changed() {
526        // Integer enum name changed - should NOT require migration (DB type is always INTEGER)
527        let from = ColumnType::Complex(ComplexColumnType::Enum {
528            name: "old_status".into(),
529            values: EnumValues::Integer(vec![NumValue {
530                name: "Pending".into(),
531                value: 0,
532            }]),
533        });
534        let to = ColumnType::Complex(ComplexColumnType::Enum {
535            name: "new_status".into(),
536            values: EnumValues::Integer(vec![NumValue {
537                name: "Pending".into(),
538                value: 0,
539            }]),
540        });
541        assert!(!from.requires_migration(&to));
542    }
543
544    #[test]
545    fn test_requires_migration_string_enum_values_changed() {
546        // String enum values changed - SHOULD require migration
547        let from = ColumnType::Complex(ComplexColumnType::Enum {
548            name: "status".into(),
549            values: EnumValues::String(vec!["pending".into(), "active".into()]),
550        });
551        let to = ColumnType::Complex(ComplexColumnType::Enum {
552            name: "status".into(),
553            values: EnumValues::String(vec!["pending".into(), "active".into(), "completed".into()]),
554        });
555        assert!(from.requires_migration(&to));
556    }
557
558    #[test]
559    fn test_requires_migration_simple_types() {
560        let int = ColumnType::Simple(SimpleColumnType::Integer);
561        let text = ColumnType::Simple(SimpleColumnType::Text);
562        assert!(int.requires_migration(&text));
563        assert!(!int.requires_migration(&int));
564    }
565
566    #[test]
567    fn test_requires_migration_mixed_enum_types() {
568        // String enum to integer enum - SHOULD require migration
569        let string_enum = ColumnType::Complex(ComplexColumnType::Enum {
570            name: "status".into(),
571            values: EnumValues::String(vec!["pending".into()]),
572        });
573        let int_enum = ColumnType::Complex(ComplexColumnType::Enum {
574            name: "status".into(),
575            values: EnumValues::Integer(vec![NumValue {
576                name: "Pending".into(),
577                value: 0,
578            }]),
579        });
580        assert!(string_enum.requires_migration(&int_enum));
581    }
582}