1use serde::{Deserialize, Serialize};
2
3use super::field::FieldDef;
4
5pub trait ModelMeta: Sized {
8 const TABLE_NAME: &'static str;
10
11 fn table_def() -> TableDef;
13
14 fn primary_key_field() -> &'static str;
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct TableDef {
21 pub name: String,
23
24 pub schema: Option<String>,
26
27 pub struct_name: String,
29
30 pub fields: Vec<FieldDef>,
32
33 pub indexes: Vec<IndexDef>,
35
36 pub composite_indexes: Vec<CompositeIndexDef>,
38
39 pub soft_delete: bool,
41
42 pub tenant_field: Option<String>,
44
45 pub doc: Option<String>,
47}
48
49impl TableDef {
50 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 pub fn primary_key(&self) -> Option<&FieldDef> {
67 self.fields.iter().find(|f| f.is_primary_key())
68 }
69
70 pub fn indexed_fields(&self) -> Vec<&FieldDef> {
72 self.fields.iter().filter(|f| f.is_indexed()).collect()
73 }
74
75 pub fn unique_fields(&self) -> Vec<&FieldDef> {
77 self.fields.iter().filter(|f| f.is_unique()).collect()
78 }
79
80 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 for field in self.indexed_fields() {
93 if !field.is_primary_key() && !field.is_unique() {
94 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 for idx in &self.composite_indexes {
111 sql.push_str(&format!("\n{}", idx.to_sql(&self.name)));
112 }
113
114 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct IndexDef {
146 pub name: String,
148
149 pub column: String,
151
152 pub index_type: IndexType,
154
155 pub unique: bool,
157
158 pub where_clause: Option<String>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct CompositeIndexDef {
165 pub name: Option<String>,
167
168 pub columns: Vec<String>,
170
171 pub orders: Vec<IndexOrder>,
173
174 pub index_type: IndexType,
176
177 pub unique: bool,
179
180 pub where_clause: Option<String>,
182}
183
184impl CompositeIndexDef {
185 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
240pub enum IndexOrder {
241 #[default]
242 Asc,
243 Desc,
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248pub enum RelationType {
249 BelongsTo { target: String, foreign_key: String },
251 HasMany { target: String, foreign_key: String },
253 HasOne { target: String, foreign_key: String },
255 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}