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