Skip to main content

forge_core/schema/
model.rs

1use serde::{Deserialize, Serialize};
2
3use super::field::FieldDef;
4
5/// Trait implemented by all FORGE models.
6/// Generated by the #[forge::model] macro.
7pub trait ModelMeta: Sized {
8    /// Table name in the database.
9    const TABLE_NAME: &'static str;
10
11    /// Get the table definition.
12    fn table_def() -> TableDef;
13
14    /// Get the primary key field name.
15    fn primary_key_field() -> &'static str;
16}
17
18/// Complete table definition.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct TableDef {
21    /// Table name.
22    pub name: String,
23
24    /// Optional schema name (e.g., for multi-tenancy).
25    pub schema: Option<String>,
26
27    /// Rust struct name.
28    pub struct_name: String,
29
30    /// Field definitions.
31    pub fields: Vec<FieldDef>,
32
33    /// Index definitions.
34    pub indexes: Vec<IndexDef>,
35
36    /// Composite indexes.
37    pub composite_indexes: Vec<CompositeIndexDef>,
38
39    /// Whether soft delete is enabled.
40    pub soft_delete: bool,
41
42    /// Tenant field for row-level security.
43    pub tenant_field: Option<String>,
44
45    /// Documentation comment.
46    pub doc: Option<String>,
47
48    /// Whether this is a DTO (data transfer object) vs database table.
49    #[serde(default)]
50    pub is_dto: bool,
51}
52
53impl TableDef {
54    /// Create a new table definition.
55    pub fn new(name: &str, struct_name: &str) -> Self {
56        Self {
57            name: name.to_string(),
58            schema: None,
59            struct_name: struct_name.to_string(),
60            fields: Vec::new(),
61            indexes: Vec::new(),
62            composite_indexes: Vec::new(),
63            soft_delete: false,
64            tenant_field: None,
65            doc: None,
66            is_dto: false,
67        }
68    }
69
70    /// Get the fully qualified table name.
71    pub fn qualified_name(&self) -> String {
72        match &self.schema {
73            Some(schema) => format!("{}.{}", schema, self.name),
74            None => self.name.clone(),
75        }
76    }
77}
78
79/// Single-column index definition.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct IndexDef {
82    /// Index name.
83    pub name: String,
84
85    /// Column name.
86    pub column: String,
87
88    /// Index type (btree, hash, gin, gist).
89    pub index_type: IndexType,
90
91    /// Whether the index is unique.
92    pub unique: bool,
93
94    /// Optional WHERE clause for partial index.
95    pub where_clause: Option<String>,
96}
97
98/// Composite (multi-column) index definition.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct CompositeIndexDef {
101    /// Index name (optional, auto-generated if not provided).
102    pub name: Option<String>,
103
104    /// Column names.
105    pub columns: Vec<String>,
106
107    /// Column ordering (ASC/DESC for each).
108    pub orders: Vec<IndexOrder>,
109
110    /// Index type.
111    pub index_type: IndexType,
112
113    /// Whether the index is unique.
114    pub unique: bool,
115
116    /// Optional WHERE clause.
117    pub where_clause: Option<String>,
118}
119
120impl CompositeIndexDef {
121    /// Generate the CREATE INDEX SQL.
122    pub fn to_sql(&self, table_name: &str) -> String {
123        let name = self
124            .name
125            .clone()
126            .unwrap_or_else(|| format!("idx_{}_{}", table_name, self.columns.join("_")));
127
128        let columns: Vec<String> = self
129            .columns
130            .iter()
131            .zip(self.orders.iter())
132            .map(|(col, order)| match order {
133                IndexOrder::Asc => col.clone(),
134                IndexOrder::Desc => format!("{} DESC", col),
135            })
136            .collect();
137
138        let unique = if self.unique { "UNIQUE " } else { "" };
139        let using = match self.index_type {
140            IndexType::Btree => "",
141            IndexType::Hash => " USING HASH",
142            IndexType::Gin => " USING GIN",
143            IndexType::Gist => " USING GIST",
144        };
145
146        let mut sql = format!(
147            "CREATE {}INDEX {}{} ON {}({});",
148            unique,
149            name,
150            using,
151            table_name,
152            columns.join(", ")
153        );
154
155        if let Some(ref where_clause) = self.where_clause {
156            sql = sql.trim_end_matches(';').to_string();
157            sql.push_str(&format!(" WHERE {};", where_clause));
158        }
159
160        sql
161    }
162}
163
164/// Index type.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
166pub enum IndexType {
167    #[default]
168    Btree,
169    Hash,
170    Gin,
171    Gist,
172}
173
174/// Column ordering in indexes.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
176pub enum IndexOrder {
177    #[default]
178    Asc,
179    Desc,
180}
181
182/// Relation type for foreign key relationships.
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184pub enum RelationType {
185    /// Belongs to another model (has foreign key).
186    BelongsTo { target: String, foreign_key: String },
187    /// Has many of another model.
188    HasMany { target: String, foreign_key: String },
189    /// Has one of another model.
190    HasOne { target: String, foreign_key: String },
191    /// Many-to-many through a join table.
192    ManyToMany { target: String, through: String },
193}
194
195#[cfg(test)]
196#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
197mod tests {
198    use super::*;
199    use crate::schema::types::RustType;
200
201    #[test]
202    fn test_table_def_basic() {
203        let mut table = TableDef::new("users", "User");
204        table.fields.push(FieldDef::new("id", RustType::Uuid));
205        table.fields.push(FieldDef::new("email", RustType::String));
206        assert_eq!(table.fields.len(), 2);
207    }
208
209    #[test]
210    fn test_composite_index_sql() {
211        let idx = CompositeIndexDef {
212            name: Some("idx_tasks_status_priority".to_string()),
213            columns: vec!["status".to_string(), "priority".to_string()],
214            orders: vec![IndexOrder::Asc, IndexOrder::Desc],
215            index_type: IndexType::Btree,
216            unique: false,
217            where_clause: None,
218        };
219
220        let sql = idx.to_sql("tasks");
221        assert_eq!(
222            sql,
223            "CREATE INDEX idx_tasks_status_priority ON tasks(status, priority DESC);"
224        );
225    }
226}