vespertide_core/schema/
column.rs

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