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