ormada_schema/
types.rs

1//! Schema type definitions for Ormada migrations
2//!
3//! These types represent database schema in a database-agnostic way.
4//! They are used for:
5//! - Representing parsed `#[ormada_model]` and `#[ormada_schema]` definitions
6//! - Comparing schemas to generate migration diffs
7//! - Serializing schema state for migration files
8
9use serde::{Deserialize, Serialize};
10
11/// Represents a complete database table schema
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct TableSchema {
14    /// Table name in the database
15    pub name: String,
16    /// Columns in the table
17    pub columns: Vec<ColumnSchema>,
18    /// Indexes on the table (excluding primary key)
19    pub indexes: Vec<IndexSchema>,
20    /// Foreign key constraints
21    pub foreign_keys: Vec<ForeignKeySchema>,
22    /// Primary key columns (supports composite keys)
23    pub primary_key: Vec<String>,
24    /// Migration ID this schema belongs to (for tracking)
25    pub migration_id: Option<String>,
26}
27
28impl TableSchema {
29    /// Create a new empty table schema
30    pub fn new(name: impl Into<String>) -> Self {
31        Self {
32            name: name.into(),
33            columns: Vec::new(),
34            indexes: Vec::new(),
35            foreign_keys: Vec::new(),
36            primary_key: Vec::new(),
37            migration_id: None,
38        }
39    }
40
41    /// Add a column to the schema
42    pub fn add_column(&mut self, column: ColumnSchema) {
43        self.columns.push(column);
44    }
45
46    /// Find a column by name
47    pub fn find_column(&self, name: &str) -> Option<&ColumnSchema> {
48        self.columns.iter().find(|c| c.name == name)
49    }
50
51    /// Find a column by name (mutable)
52    pub fn find_column_mut(&mut self, name: &str) -> Option<&mut ColumnSchema> {
53        self.columns.iter_mut().find(|c| c.name == name)
54    }
55}
56
57/// Represents a database column
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct ColumnSchema {
60    /// Column name
61    pub name: String,
62    /// Column data type
63    pub column_type: ColumnType,
64    /// Whether the column allows NULL values
65    pub nullable: bool,
66    /// Default value expression (SQL string)
67    pub default: Option<String>,
68    /// Whether the column has a unique constraint
69    pub unique: bool,
70    /// Whether this column is part of the primary key
71    pub primary_key: bool,
72    /// Whether the primary key auto-increments
73    pub auto_increment: bool,
74    /// Whether this column has an index
75    pub indexed: bool,
76    /// Index name if indexed
77    pub index_name: Option<String>,
78    /// Max length for string types
79    pub max_length: Option<u32>,
80    /// Min length for string types (validation only)
81    pub min_length: Option<u32>,
82    /// Range constraints for numeric types (validation only)
83    pub range: Option<RangeConstraint>,
84    /// Whether this is a soft delete marker column
85    pub soft_delete: bool,
86    /// For delta migrations: this column was renamed from another
87    pub renamed_from: Option<String>,
88    /// For delta migrations: this column should be dropped
89    pub dropped: bool,
90}
91
92impl ColumnSchema {
93    /// Create a new column with the given name and type
94    pub fn new(name: impl Into<String>, column_type: ColumnType) -> Self {
95        Self {
96            name: name.into(),
97            column_type,
98            nullable: false,
99            default: None,
100            unique: false,
101            primary_key: false,
102            auto_increment: false,
103            indexed: false,
104            index_name: None,
105            max_length: None,
106            min_length: None,
107            range: None,
108            soft_delete: false,
109            renamed_from: None,
110            dropped: false,
111        }
112    }
113
114    /// Set nullable
115    pub fn nullable(mut self, nullable: bool) -> Self {
116        self.nullable = nullable;
117        self
118    }
119
120    /// Set default value
121    pub fn default(mut self, default: impl Into<String>) -> Self {
122        self.default = Some(default.into());
123        self
124    }
125
126    /// Set as primary key
127    pub fn primary_key(mut self, auto_increment: bool) -> Self {
128        self.primary_key = true;
129        self.auto_increment = auto_increment;
130        self
131    }
132
133    /// Set as indexed
134    pub fn indexed(mut self) -> Self {
135        self.indexed = true;
136        self
137    }
138
139    /// Set as unique
140    pub fn unique(mut self) -> Self {
141        self.unique = true;
142        self
143    }
144
145    /// Set max length
146    pub fn max_length(mut self, len: u32) -> Self {
147        self.max_length = Some(len);
148        self
149    }
150}
151
152/// Database column types
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154pub enum ColumnType {
155    /// Boolean type
156    Boolean,
157    /// Small integer (i16)
158    SmallInteger,
159    /// Integer (i32)
160    Integer,
161    /// Big integer (i64)
162    BigInteger,
163    /// Single precision float (f32)
164    Float,
165    /// Double precision float (f64)
166    Double,
167    /// Decimal with precision and scale
168    Decimal { precision: u32, scale: u32 },
169    /// Variable-length string with optional max length
170    String(Option<u32>),
171    /// Unlimited text
172    Text,
173    /// Binary data
174    Binary,
175    /// Date (no time)
176    Date,
177    /// Time (no date)
178    Time,
179    /// DateTime without timezone
180    DateTime,
181    /// DateTime with timezone
182    TimestampTz,
183    /// UUID
184    Uuid,
185    /// JSON
186    Json,
187    /// JSONB (PostgreSQL)
188    JsonB,
189}
190
191impl ColumnType {
192    /// Infer column type from Rust type string
193    pub fn from_rust_type(type_str: &str) -> Self {
194        let type_str = type_str.trim();
195
196        // Handle Option<T>
197        if type_str.starts_with("Option<") && type_str.ends_with('>') {
198            let inner = &type_str[7..type_str.len() - 1];
199            return Self::from_rust_type(inner);
200        }
201
202        // Handle paths like ormada::prelude::DateTimeWithTimeZone
203        let type_str = if let Some(last) = type_str.rsplit("::").next() { last } else { type_str };
204
205        match type_str {
206            "bool" => Self::Boolean,
207            "i16" => Self::SmallInteger,
208            "i32" => Self::Integer,
209            "i64" => Self::BigInteger,
210            "f32" => Self::Float,
211            "f64" => Self::Double,
212            "String" => Self::String(None),
213            "Vec<u8>" => Self::Binary,
214            "Uuid" => Self::Uuid,
215            "NaiveDate" | "Date" => Self::Date,
216            "NaiveTime" | "Time" => Self::Time,
217            "NaiveDateTime" | "DateTime" => Self::DateTime,
218            "DateTimeWithTimeZone" | "DateTime<FixedOffset>" => Self::TimestampTz,
219            "Value" => Self::Json,
220            _ => Self::String(None), // Default fallback
221        }
222    }
223
224    /// Check if this type is nullable by default (Option types)
225    pub fn is_option_type(type_str: &str) -> bool {
226        type_str.trim().starts_with("Option<")
227    }
228}
229
230/// Range constraint for numeric columns
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232pub struct RangeConstraint {
233    pub min: Option<i64>,
234    pub max: Option<i64>,
235}
236
237/// Index definition
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239pub struct IndexSchema {
240    /// Index name
241    pub name: String,
242    /// Columns in the index
243    pub columns: Vec<String>,
244    /// Whether this is a unique index
245    pub unique: bool,
246}
247
248impl IndexSchema {
249    /// Create a new index
250    pub fn new(name: impl Into<String>, columns: Vec<String>) -> Self {
251        Self {
252            name: name.into(),
253            columns,
254            unique: false,
255        }
256    }
257
258    /// Set as unique index
259    pub fn unique(mut self) -> Self {
260        self.unique = true;
261        self
262    }
263}
264
265/// Foreign key constraint
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267pub struct ForeignKeySchema {
268    /// Constraint name
269    pub name: Option<String>,
270    /// Column in this table
271    pub column: String,
272    /// Referenced table
273    pub references_table: String,
274    /// Referenced column
275    pub references_column: String,
276    /// ON DELETE behavior
277    pub on_delete: OnDeleteAction,
278    /// ON UPDATE behavior
279    pub on_update: OnUpdateAction,
280}
281
282impl ForeignKeySchema {
283    /// Create a new foreign key
284    pub fn new(
285        column: impl Into<String>,
286        references_table: impl Into<String>,
287        references_column: impl Into<String>,
288    ) -> Self {
289        Self {
290            name: None,
291            column: column.into(),
292            references_table: references_table.into(),
293            references_column: references_column.into(),
294            on_delete: OnDeleteAction::NoAction,
295            on_update: OnUpdateAction::NoAction,
296        }
297    }
298
299    /// Set ON DELETE action
300    pub fn on_delete(mut self, action: OnDeleteAction) -> Self {
301        self.on_delete = action;
302        self
303    }
304}
305
306/// ON DELETE actions for foreign keys
307#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
308pub enum OnDeleteAction {
309    #[default]
310    NoAction,
311    Restrict,
312    Cascade,
313    SetNull,
314    SetDefault,
315}
316
317impl OnDeleteAction {
318    /// Parse from string (case-insensitive)
319    #[allow(clippy::should_implement_trait)]
320    pub fn from_str(s: &str) -> Self {
321        match s.to_lowercase().as_str() {
322            "cascade" => Self::Cascade,
323            "restrict" => Self::Restrict,
324            "setnull" | "set_null" | "set null" => Self::SetNull,
325            "setdefault" | "set_default" | "set default" => Self::SetDefault,
326            _ => Self::NoAction,
327        }
328    }
329}
330
331/// ON UPDATE actions for foreign keys
332#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
333pub enum OnUpdateAction {
334    #[default]
335    NoAction,
336    Restrict,
337    Cascade,
338    SetNull,
339    SetDefault,
340}
341
342/// Migration metadata
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344pub struct MigrationMeta {
345    /// Migration ID (matches filename without extension)
346    pub id: String,
347    /// Migration this one depends on (for ordering)
348    pub after: Option<String>,
349    /// Tables defined or modified in this migration
350    pub tables: Vec<TableSchema>,
351    /// Data migration function name (if any)
352    pub data_migration: Option<String>,
353}
354
355impl MigrationMeta {
356    /// Create a new migration
357    pub fn new(id: impl Into<String>) -> Self {
358        Self {
359            id: id.into(),
360            after: None,
361            tables: Vec::new(),
362            data_migration: None,
363        }
364    }
365
366    /// Set the dependency
367    pub fn after(mut self, after: impl Into<String>) -> Self {
368        self.after = Some(after.into());
369        self
370    }
371
372    /// Add a table schema
373    pub fn add_table(&mut self, table: TableSchema) {
374        self.tables.push(table);
375    }
376}
377
378/// Schema delta for a table (used in extends migrations)
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
380pub struct TableDelta {
381    /// Table name
382    pub table: String,
383    /// Base migration this extends
384    pub extends: String,
385    /// Columns to add
386    pub add_columns: Vec<ColumnSchema>,
387    /// Columns to drop (by name)
388    pub drop_columns: Vec<String>,
389    /// Column renames (from -> to)
390    pub rename_columns: Vec<(String, String)>,
391    /// Column modifications
392    pub alter_columns: Vec<ColumnSchema>,
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn test_column_type_from_rust_type_primitives() {
401        assert_eq!(ColumnType::from_rust_type("bool"), ColumnType::Boolean);
402        assert_eq!(ColumnType::from_rust_type("i16"), ColumnType::SmallInteger);
403        assert_eq!(ColumnType::from_rust_type("i32"), ColumnType::Integer);
404        assert_eq!(ColumnType::from_rust_type("i64"), ColumnType::BigInteger);
405        assert_eq!(ColumnType::from_rust_type("f32"), ColumnType::Float);
406        assert_eq!(ColumnType::from_rust_type("f64"), ColumnType::Double);
407        assert_eq!(ColumnType::from_rust_type("String"), ColumnType::String(None));
408    }
409
410    #[test]
411    fn test_column_type_from_rust_type_datetime() {
412        // Simple names
413        assert_eq!(ColumnType::from_rust_type("DateTimeWithTimeZone"), ColumnType::TimestampTz);
414        assert_eq!(ColumnType::from_rust_type("NaiveDateTime"), ColumnType::DateTime);
415        assert_eq!(ColumnType::from_rust_type("NaiveDate"), ColumnType::Date);
416        assert_eq!(ColumnType::from_rust_type("NaiveTime"), ColumnType::Time);
417
418        // Full paths (as they appear when parsed from source)
419        assert_eq!(
420            ColumnType::from_rust_type("ormada::prelude::DateTimeWithTimeZone"),
421            ColumnType::TimestampTz
422        );
423        assert_eq!(ColumnType::from_rust_type("chrono::NaiveDateTime"), ColumnType::DateTime);
424        assert_eq!(ColumnType::from_rust_type("chrono::NaiveDate"), ColumnType::Date);
425        assert_eq!(ColumnType::from_rust_type("chrono::NaiveTime"), ColumnType::Time);
426    }
427
428    #[test]
429    fn test_column_type_from_rust_type_special() {
430        assert_eq!(ColumnType::from_rust_type("Uuid"), ColumnType::Uuid);
431        assert_eq!(ColumnType::from_rust_type("uuid::Uuid"), ColumnType::Uuid);
432        assert_eq!(ColumnType::from_rust_type("Vec<u8>"), ColumnType::Binary);
433        assert_eq!(ColumnType::from_rust_type("Value"), ColumnType::Json);
434        assert_eq!(ColumnType::from_rust_type("serde_json::Value"), ColumnType::Json);
435    }
436
437    #[test]
438    fn test_column_type_from_rust_type_option() {
439        // Option wrapping should unwrap and parse inner type
440        assert_eq!(ColumnType::from_rust_type("Option<i32>"), ColumnType::Integer);
441        assert_eq!(ColumnType::from_rust_type("Option<String>"), ColumnType::String(None));
442        assert_eq!(
443            ColumnType::from_rust_type("Option<DateTimeWithTimeZone>"),
444            ColumnType::TimestampTz
445        );
446        assert_eq!(
447            ColumnType::from_rust_type("Option<ormada::prelude::DateTimeWithTimeZone>"),
448            ColumnType::TimestampTz
449        );
450    }
451
452    #[test]
453    fn test_column_type_from_rust_type_unknown_fallback() {
454        // Unknown types should fall back to String
455        assert_eq!(ColumnType::from_rust_type("CustomType"), ColumnType::String(None));
456        assert_eq!(ColumnType::from_rust_type("my_module::MyType"), ColumnType::String(None));
457    }
458
459    #[test]
460    fn test_is_option_type() {
461        assert!(ColumnType::is_option_type("Option<i32>"));
462        assert!(ColumnType::is_option_type("Option<String>"));
463        assert!(ColumnType::is_option_type("Option<DateTimeWithTimeZone>"));
464        assert!(!ColumnType::is_option_type("i32"));
465        assert!(!ColumnType::is_option_type("String"));
466        assert!(!ColumnType::is_option_type("DateTimeWithTimeZone"));
467    }
468
469    #[test]
470    fn test_table_schema_builder() {
471        let mut table = TableSchema::new("books");
472        table.add_column(ColumnSchema::new("id", ColumnType::Integer).primary_key(true));
473        table.add_column(ColumnSchema::new("title", ColumnType::String(Some(200))).max_length(200));
474        table.primary_key = vec!["id".to_string()];
475
476        assert_eq!(table.name, "books");
477        assert_eq!(table.columns.len(), 2);
478        assert!(table.find_column("id").is_some());
479        assert!(table.find_column("title").is_some());
480        assert!(table.find_column("nonexistent").is_none());
481    }
482
483    #[test]
484    fn test_on_delete_from_str() {
485        assert_eq!(OnDeleteAction::from_str("Cascade"), OnDeleteAction::Cascade);
486        assert_eq!(OnDeleteAction::from_str("CASCADE"), OnDeleteAction::Cascade);
487        assert_eq!(OnDeleteAction::from_str("SetNull"), OnDeleteAction::SetNull);
488        assert_eq!(OnDeleteAction::from_str("set_null"), OnDeleteAction::SetNull);
489        assert_eq!(OnDeleteAction::from_str("unknown"), OnDeleteAction::NoAction);
490    }
491}