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
49impl TableDef {
50    /// Create a new table definition.
51    pub fn new(name: &str, struct_name: &str) -> Self {
52        Self {
53            name: name.to_string(),
54            schema: None,
55            struct_name: struct_name.to_string(),
56            fields: Vec::new(),
57            indexes: Vec::new(),
58            composite_indexes: Vec::new(),
59            soft_delete: false,
60            tenant_field: None,
61            doc: None,
62        }
63    }
64
65    /// Get the primary key field.
66    pub fn primary_key(&self) -> Option<&FieldDef> {
67        self.fields.iter().find(|f| f.is_primary_key())
68    }
69
70    /// Get all indexed fields.
71    pub fn indexed_fields(&self) -> Vec<&FieldDef> {
72        self.fields.iter().filter(|f| f.is_indexed()).collect()
73    }
74
75    /// Get all unique fields.
76    pub fn unique_fields(&self) -> Vec<&FieldDef> {
77        self.fields.iter().filter(|f| f.is_unique()).collect()
78    }
79
80    /// Generate CREATE TABLE SQL.
81    pub fn to_create_table_sql(&self) -> String {
82        let table_name = self.qualified_name();
83        let columns: Vec<String> = self.fields.iter().map(|f| f.to_sql_column()).collect();
84
85        let mut sql = format!(
86            "CREATE TABLE {} (\n    {}\n);\n",
87            table_name,
88            columns.join(",\n    ")
89        );
90
91        // Add individual field indexes
92        for field in self.indexed_fields() {
93            if !field.is_primary_key() && !field.is_unique() {
94                // For soft delete tables, add partial index excluding deleted rows
95                if self.soft_delete && field.column_name != "deleted_at" {
96                    sql.push_str(&format!(
97                        "\nCREATE INDEX idx_{}_{} ON {}({}) WHERE deleted_at IS NULL;",
98                        self.name, field.column_name, table_name, field.column_name
99                    ));
100                } else {
101                    sql.push_str(&format!(
102                        "\nCREATE INDEX idx_{}_{} ON {}({});",
103                        self.name, field.column_name, table_name, field.column_name
104                    ));
105                }
106            }
107        }
108
109        // Add composite indexes
110        for idx in &self.composite_indexes {
111            sql.push_str(&format!("\n{}", idx.to_sql(&self.name)));
112        }
113
114        // Add change tracking trigger
115        sql.push_str(&format!(
116            "\n\nCREATE TRIGGER {}_notify_changes\n    AFTER INSERT OR UPDATE OR DELETE ON {}\n    FOR EACH ROW EXECUTE FUNCTION forge_notify_change();",
117            self.name, table_name
118        ));
119
120        sql
121    }
122
123    /// Generate TypeScript interface.
124    pub fn to_typescript_interface(&self) -> String {
125        let fields: Vec<String> = self.fields.iter().map(|f| f.to_typescript()).collect();
126
127        format!(
128            "export interface {} {{\n{}\n}}",
129            self.struct_name,
130            fields.join("\n")
131        )
132    }
133
134    /// Get the fully qualified table name.
135    pub fn qualified_name(&self) -> String {
136        match &self.schema {
137            Some(schema) => format!("{}.{}", schema, self.name),
138            None => self.name.clone(),
139        }
140    }
141}
142
143/// Single-column index definition.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct IndexDef {
146    /// Index name.
147    pub name: String,
148
149    /// Column name.
150    pub column: String,
151
152    /// Index type (btree, hash, gin, gist).
153    pub index_type: IndexType,
154
155    /// Whether the index is unique.
156    pub unique: bool,
157
158    /// Optional WHERE clause for partial index.
159    pub where_clause: Option<String>,
160}
161
162/// Composite (multi-column) index definition.
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct CompositeIndexDef {
165    /// Index name (optional, auto-generated if not provided).
166    pub name: Option<String>,
167
168    /// Column names.
169    pub columns: Vec<String>,
170
171    /// Column ordering (ASC/DESC for each).
172    pub orders: Vec<IndexOrder>,
173
174    /// Index type.
175    pub index_type: IndexType,
176
177    /// Whether the index is unique.
178    pub unique: bool,
179
180    /// Optional WHERE clause.
181    pub where_clause: Option<String>,
182}
183
184impl CompositeIndexDef {
185    /// Generate the CREATE INDEX SQL.
186    pub fn to_sql(&self, table_name: &str) -> String {
187        let name = self
188            .name
189            .clone()
190            .unwrap_or_else(|| format!("idx_{}_{}", table_name, self.columns.join("_")));
191
192        let columns: Vec<String> = self
193            .columns
194            .iter()
195            .zip(self.orders.iter())
196            .map(|(col, order)| match order {
197                IndexOrder::Asc => col.clone(),
198                IndexOrder::Desc => format!("{} DESC", col),
199            })
200            .collect();
201
202        let unique = if self.unique { "UNIQUE " } else { "" };
203        let using = match self.index_type {
204            IndexType::Btree => "",
205            IndexType::Hash => " USING HASH",
206            IndexType::Gin => " USING GIN",
207            IndexType::Gist => " USING GIST",
208        };
209
210        let mut sql = format!(
211            "CREATE {}INDEX {}{} ON {}({});",
212            unique,
213            name,
214            using,
215            table_name,
216            columns.join(", ")
217        );
218
219        if let Some(ref where_clause) = self.where_clause {
220            sql = sql.trim_end_matches(';').to_string();
221            sql.push_str(&format!(" WHERE {};", where_clause));
222        }
223
224        sql
225    }
226}
227
228/// Index type.
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
230pub enum IndexType {
231    #[default]
232    Btree,
233    Hash,
234    Gin,
235    Gist,
236}
237
238/// Column ordering in indexes.
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
240pub enum IndexOrder {
241    #[default]
242    Asc,
243    Desc,
244}
245
246/// Relation type for foreign key relationships.
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248pub enum RelationType {
249    /// Belongs to another model (has foreign key).
250    BelongsTo { target: String, foreign_key: String },
251    /// Has many of another model.
252    HasMany { target: String, foreign_key: String },
253    /// Has one of another model.
254    HasOne { target: String, foreign_key: String },
255    /// Many-to-many through a join table.
256    ManyToMany { target: String, through: String },
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use crate::schema::field::FieldAttribute;
263    use crate::schema::types::RustType;
264
265    #[test]
266    fn test_table_def_basic() {
267        let mut table = TableDef::new("users", "User");
268
269        let mut id_field = FieldDef::new("id", RustType::Uuid);
270        id_field.attributes.push(FieldAttribute::Id);
271        table.fields.push(id_field);
272
273        let mut email_field = FieldDef::new("email", RustType::String);
274        email_field.attributes.push(FieldAttribute::Indexed);
275        email_field.attributes.push(FieldAttribute::Unique);
276        table.fields.push(email_field);
277
278        let name_field = FieldDef::new("name", RustType::String);
279        table.fields.push(name_field);
280
281        assert_eq!(table.primary_key().unwrap().name, "id");
282        assert_eq!(table.indexed_fields().len(), 1);
283        assert_eq!(table.unique_fields().len(), 1);
284    }
285
286    #[test]
287    fn test_table_to_sql() {
288        let mut table = TableDef::new("users", "User");
289
290        let mut id_field = FieldDef::new("id", RustType::Uuid);
291        id_field.attributes.push(FieldAttribute::Id);
292        table.fields.push(id_field);
293
294        let email_field = FieldDef::new("email", RustType::String);
295        table.fields.push(email_field);
296
297        let sql = table.to_create_table_sql();
298        assert!(sql.contains("CREATE TABLE users"));
299        assert!(sql.contains("id UUID PRIMARY KEY"));
300        assert!(sql.contains("email VARCHAR(255) NOT NULL"));
301    }
302
303    #[test]
304    fn test_table_to_typescript() {
305        let mut table = TableDef::new("users", "User");
306
307        let id_field = FieldDef::new("id", RustType::Uuid);
308        table.fields.push(id_field);
309
310        let email_field = FieldDef::new("email", RustType::String);
311        table.fields.push(email_field);
312
313        let ts = table.to_typescript_interface();
314        assert!(ts.contains("export interface User"));
315        assert!(ts.contains("id: string"));
316        assert!(ts.contains("email: string"));
317    }
318
319    #[test]
320    fn test_composite_index_sql() {
321        let idx = CompositeIndexDef {
322            name: Some("idx_tasks_status_priority".to_string()),
323            columns: vec!["status".to_string(), "priority".to_string()],
324            orders: vec![IndexOrder::Asc, IndexOrder::Desc],
325            index_type: IndexType::Btree,
326            unique: false,
327            where_clause: None,
328        };
329
330        let sql = idx.to_sql("tasks");
331        assert_eq!(
332            sql,
333            "CREATE INDEX idx_tasks_status_priority ON tasks(status, priority DESC);"
334        );
335    }
336}