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    /// Generate TypeScript interface.
71    pub fn to_typescript_interface(&self) -> String {
72        let fields: Vec<String> = self.fields.iter().map(|f| f.to_typescript()).collect();
73
74        format!(
75            "export interface {} {{\n{}\n}}",
76            self.struct_name,
77            fields.join("\n")
78        )
79    }
80
81    /// Get the fully qualified table name.
82    pub fn qualified_name(&self) -> String {
83        match &self.schema {
84            Some(schema) => format!("{}.{}", schema, self.name),
85            None => self.name.clone(),
86        }
87    }
88}
89
90/// Single-column index definition.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct IndexDef {
93    /// Index name.
94    pub name: String,
95
96    /// Column name.
97    pub column: String,
98
99    /// Index type (btree, hash, gin, gist).
100    pub index_type: IndexType,
101
102    /// Whether the index is unique.
103    pub unique: bool,
104
105    /// Optional WHERE clause for partial index.
106    pub where_clause: Option<String>,
107}
108
109/// Composite (multi-column) index definition.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct CompositeIndexDef {
112    /// Index name (optional, auto-generated if not provided).
113    pub name: Option<String>,
114
115    /// Column names.
116    pub columns: Vec<String>,
117
118    /// Column ordering (ASC/DESC for each).
119    pub orders: Vec<IndexOrder>,
120
121    /// Index type.
122    pub index_type: IndexType,
123
124    /// Whether the index is unique.
125    pub unique: bool,
126
127    /// Optional WHERE clause.
128    pub where_clause: Option<String>,
129}
130
131impl CompositeIndexDef {
132    /// Generate the CREATE INDEX SQL.
133    pub fn to_sql(&self, table_name: &str) -> String {
134        let name = self
135            .name
136            .clone()
137            .unwrap_or_else(|| format!("idx_{}_{}", table_name, self.columns.join("_")));
138
139        let columns: Vec<String> = self
140            .columns
141            .iter()
142            .zip(self.orders.iter())
143            .map(|(col, order)| match order {
144                IndexOrder::Asc => col.clone(),
145                IndexOrder::Desc => format!("{} DESC", col),
146            })
147            .collect();
148
149        let unique = if self.unique { "UNIQUE " } else { "" };
150        let using = match self.index_type {
151            IndexType::Btree => "",
152            IndexType::Hash => " USING HASH",
153            IndexType::Gin => " USING GIN",
154            IndexType::Gist => " USING GIST",
155        };
156
157        let mut sql = format!(
158            "CREATE {}INDEX {}{} ON {}({});",
159            unique,
160            name,
161            using,
162            table_name,
163            columns.join(", ")
164        );
165
166        if let Some(ref where_clause) = self.where_clause {
167            sql = sql.trim_end_matches(';').to_string();
168            sql.push_str(&format!(" WHERE {};", where_clause));
169        }
170
171        sql
172    }
173}
174
175/// Index type.
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
177pub enum IndexType {
178    #[default]
179    Btree,
180    Hash,
181    Gin,
182    Gist,
183}
184
185/// Column ordering in indexes.
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
187pub enum IndexOrder {
188    #[default]
189    Asc,
190    Desc,
191}
192
193/// Relation type for foreign key relationships.
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195pub enum RelationType {
196    /// Belongs to another model (has foreign key).
197    BelongsTo { target: String, foreign_key: String },
198    /// Has many of another model.
199    HasMany { target: String, foreign_key: String },
200    /// Has one of another model.
201    HasOne { target: String, foreign_key: String },
202    /// Many-to-many through a join table.
203    ManyToMany { target: String, through: String },
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::schema::types::RustType;
210
211    #[test]
212    fn test_table_def_basic() {
213        let mut table = TableDef::new("users", "User");
214        table.fields.push(FieldDef::new("id", RustType::Uuid));
215        table.fields.push(FieldDef::new("email", RustType::String));
216        assert_eq!(table.fields.len(), 2);
217    }
218
219    #[test]
220    fn test_table_to_typescript() {
221        let mut table = TableDef::new("users", "User");
222        table.fields.push(FieldDef::new("id", RustType::Uuid));
223        table.fields.push(FieldDef::new("email", RustType::String));
224
225        let ts = table.to_typescript_interface();
226        assert!(ts.contains("export interface User"));
227        assert!(ts.contains("id: string"));
228        assert!(ts.contains("email: string"));
229    }
230
231    #[test]
232    fn test_composite_index_sql() {
233        let idx = CompositeIndexDef {
234            name: Some("idx_tasks_status_priority".to_string()),
235            columns: vec!["status".to_string(), "priority".to_string()],
236            orders: vec![IndexOrder::Asc, IndexOrder::Desc],
237            index_type: IndexType::Btree,
238            unique: false,
239            where_clause: None,
240        };
241
242        let sql = idx.to_sql("tasks");
243        assert_eq!(
244            sql,
245            "CREATE INDEX idx_tasks_status_priority ON tasks(status, priority DESC);"
246        );
247    }
248}