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