use serde::{Deserialize, Serialize};
use super::field::FieldDef;
pub trait ModelMeta: Sized {
const TABLE_NAME: &'static str;
fn table_def() -> TableDef;
fn primary_key_field() -> &'static str;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableDef {
pub name: String,
pub schema: Option<String>,
pub struct_name: String,
pub fields: Vec<FieldDef>,
pub indexes: Vec<IndexDef>,
pub composite_indexes: Vec<CompositeIndexDef>,
pub soft_delete: bool,
pub tenant_field: Option<String>,
pub doc: Option<String>,
}
impl TableDef {
pub fn new(name: &str, struct_name: &str) -> Self {
Self {
name: name.to_string(),
schema: None,
struct_name: struct_name.to_string(),
fields: Vec::new(),
indexes: Vec::new(),
composite_indexes: Vec::new(),
soft_delete: false,
tenant_field: None,
doc: None,
}
}
pub fn primary_key(&self) -> Option<&FieldDef> {
self.fields.iter().find(|f| f.is_primary_key())
}
pub fn indexed_fields(&self) -> Vec<&FieldDef> {
self.fields.iter().filter(|f| f.is_indexed()).collect()
}
pub fn unique_fields(&self) -> Vec<&FieldDef> {
self.fields.iter().filter(|f| f.is_unique()).collect()
}
pub fn to_create_table_sql(&self) -> String {
let table_name = self.qualified_name();
let columns: Vec<String> = self.fields.iter().map(|f| f.to_sql_column()).collect();
let mut sql = format!(
"CREATE TABLE {} (\n {}\n);\n",
table_name,
columns.join(",\n ")
);
for field in self.indexed_fields() {
if !field.is_primary_key() && !field.is_unique() {
if self.soft_delete && field.column_name != "deleted_at" {
sql.push_str(&format!(
"\nCREATE INDEX idx_{}_{} ON {}({}) WHERE deleted_at IS NULL;",
self.name, field.column_name, table_name, field.column_name
));
} else {
sql.push_str(&format!(
"\nCREATE INDEX idx_{}_{} ON {}({});",
self.name, field.column_name, table_name, field.column_name
));
}
}
}
for idx in &self.composite_indexes {
sql.push_str(&format!("\n{}", idx.to_sql(&self.name)));
}
sql.push_str(&format!(
"\n\nCREATE TRIGGER {}_notify_changes\n AFTER INSERT OR UPDATE OR DELETE ON {}\n FOR EACH ROW EXECUTE FUNCTION forge_notify_change();",
self.name, table_name
));
sql
}
pub fn to_typescript_interface(&self) -> String {
let fields: Vec<String> = self.fields.iter().map(|f| f.to_typescript()).collect();
format!(
"export interface {} {{\n{}\n}}",
self.struct_name,
fields.join("\n")
)
}
pub fn qualified_name(&self) -> String {
match &self.schema {
Some(schema) => format!("{}.{}", schema, self.name),
None => self.name.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexDef {
pub name: String,
pub column: String,
pub index_type: IndexType,
pub unique: bool,
pub where_clause: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompositeIndexDef {
pub name: Option<String>,
pub columns: Vec<String>,
pub orders: Vec<IndexOrder>,
pub index_type: IndexType,
pub unique: bool,
pub where_clause: Option<String>,
}
impl CompositeIndexDef {
pub fn to_sql(&self, table_name: &str) -> String {
let name = self
.name
.clone()
.unwrap_or_else(|| format!("idx_{}_{}", table_name, self.columns.join("_")));
let columns: Vec<String> = self
.columns
.iter()
.zip(self.orders.iter())
.map(|(col, order)| match order {
IndexOrder::Asc => col.clone(),
IndexOrder::Desc => format!("{} DESC", col),
})
.collect();
let unique = if self.unique { "UNIQUE " } else { "" };
let using = match self.index_type {
IndexType::Btree => "",
IndexType::Hash => " USING HASH",
IndexType::Gin => " USING GIN",
IndexType::Gist => " USING GIST",
};
let mut sql = format!(
"CREATE {}INDEX {}{} ON {}({});",
unique,
name,
using,
table_name,
columns.join(", ")
);
if let Some(ref where_clause) = self.where_clause {
sql = sql.trim_end_matches(';').to_string();
sql.push_str(&format!(" WHERE {};", where_clause));
}
sql
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum IndexType {
#[default]
Btree,
Hash,
Gin,
Gist,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum IndexOrder {
#[default]
Asc,
Desc,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum RelationType {
BelongsTo { target: String, foreign_key: String },
HasMany { target: String, foreign_key: String },
HasOne { target: String, foreign_key: String },
ManyToMany { target: String, through: String },
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::field::FieldAttribute;
use crate::schema::types::RustType;
#[test]
fn test_table_def_basic() {
let mut table = TableDef::new("users", "User");
let mut id_field = FieldDef::new("id", RustType::Uuid);
id_field.attributes.push(FieldAttribute::Id);
table.fields.push(id_field);
let mut email_field = FieldDef::new("email", RustType::String);
email_field.attributes.push(FieldAttribute::Indexed);
email_field.attributes.push(FieldAttribute::Unique);
table.fields.push(email_field);
let name_field = FieldDef::new("name", RustType::String);
table.fields.push(name_field);
assert_eq!(table.primary_key().unwrap().name, "id");
assert_eq!(table.indexed_fields().len(), 1);
assert_eq!(table.unique_fields().len(), 1);
}
#[test]
fn test_table_to_sql() {
let mut table = TableDef::new("users", "User");
let mut id_field = FieldDef::new("id", RustType::Uuid);
id_field.attributes.push(FieldAttribute::Id);
table.fields.push(id_field);
let email_field = FieldDef::new("email", RustType::String);
table.fields.push(email_field);
let sql = table.to_create_table_sql();
assert!(sql.contains("CREATE TABLE users"));
assert!(sql.contains("id UUID PRIMARY KEY"));
assert!(sql.contains("email VARCHAR(255) NOT NULL"));
}
#[test]
fn test_table_to_typescript() {
let mut table = TableDef::new("users", "User");
let id_field = FieldDef::new("id", RustType::Uuid);
table.fields.push(id_field);
let email_field = FieldDef::new("email", RustType::String);
table.fields.push(email_field);
let ts = table.to_typescript_interface();
assert!(ts.contains("export interface User"));
assert!(ts.contains("id: string"));
assert!(ts.contains("email: string"));
}
#[test]
fn test_composite_index_sql() {
let idx = CompositeIndexDef {
name: Some("idx_tasks_status_priority".to_string()),
columns: vec!["status".to_string(), "priority".to_string()],
orders: vec![IndexOrder::Asc, IndexOrder::Desc],
index_type: IndexType::Btree,
unique: false,
where_clause: None,
};
let sql = idx.to_sql("tasks");
assert_eq!(
sql,
"CREATE INDEX idx_tasks_status_priority ON tasks(status, priority DESC);"
);
}
}