forge-core 0.9.0

Core types and traits for the Forge framework
Documentation
use serde::{Deserialize, Serialize};

use super::field::FieldDef;

/// Trait implemented by all FORGE models.
/// Generated by the #[forge::model] macro.
pub trait ModelMeta: Sized {
    /// Table name in the database.
    const TABLE_NAME: &'static str;

    /// Get the table definition.
    fn table_def() -> TableDef;

    /// Get the primary key field name.
    fn primary_key_field() -> &'static str;
}

/// Complete table definition.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableDef {
    /// Table name.
    pub name: String,

    /// Optional schema name (e.g., for multi-tenancy).
    pub schema: Option<String>,

    /// Rust struct name.
    pub struct_name: String,

    /// Field definitions.
    pub fields: Vec<FieldDef>,

    /// Index definitions.
    pub indexes: Vec<IndexDef>,

    /// Composite indexes.
    pub composite_indexes: Vec<CompositeIndexDef>,

    /// Whether soft delete is enabled.
    pub soft_delete: bool,

    /// Tenant field for row-level security.
    pub tenant_field: Option<String>,

    /// Documentation comment.
    pub doc: Option<String>,

    /// Whether this is a DTO (data transfer object) vs database table.
    #[serde(default)]
    pub is_dto: bool,
}

impl TableDef {
    /// Create a new table definition.
    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,
            is_dto: false,
        }
    }

    /// Generate TypeScript interface.
    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")
        )
    }

    /// Get the fully qualified table name.
    pub fn qualified_name(&self) -> String {
        match &self.schema {
            Some(schema) => format!("{}.{}", schema, self.name),
            None => self.name.clone(),
        }
    }
}

/// Single-column index definition.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexDef {
    /// Index name.
    pub name: String,

    /// Column name.
    pub column: String,

    /// Index type (btree, hash, gin, gist).
    pub index_type: IndexType,

    /// Whether the index is unique.
    pub unique: bool,

    /// Optional WHERE clause for partial index.
    pub where_clause: Option<String>,
}

/// Composite (multi-column) index definition.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompositeIndexDef {
    /// Index name (optional, auto-generated if not provided).
    pub name: Option<String>,

    /// Column names.
    pub columns: Vec<String>,

    /// Column ordering (ASC/DESC for each).
    pub orders: Vec<IndexOrder>,

    /// Index type.
    pub index_type: IndexType,

    /// Whether the index is unique.
    pub unique: bool,

    /// Optional WHERE clause.
    pub where_clause: Option<String>,
}

impl CompositeIndexDef {
    /// Generate the CREATE INDEX SQL.
    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
    }
}

/// Index type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum IndexType {
    #[default]
    Btree,
    Hash,
    Gin,
    Gist,
}

/// Column ordering in indexes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum IndexOrder {
    #[default]
    Asc,
    Desc,
}

/// Relation type for foreign key relationships.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum RelationType {
    /// Belongs to another model (has foreign key).
    BelongsTo { target: String, foreign_key: String },
    /// Has many of another model.
    HasMany { target: String, foreign_key: String },
    /// Has one of another model.
    HasOne { target: String, foreign_key: String },
    /// Many-to-many through a join table.
    ManyToMany { target: String, through: String },
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
mod tests {
    use super::*;
    use crate::schema::types::RustType;

    #[test]
    fn test_table_def_basic() {
        let mut table = TableDef::new("users", "User");
        table.fields.push(FieldDef::new("id", RustType::Uuid));
        table.fields.push(FieldDef::new("email", RustType::String));
        assert_eq!(table.fields.len(), 2);
    }

    #[test]
    fn test_table_to_typescript() {
        let mut table = TableDef::new("users", "User");
        table.fields.push(FieldDef::new("id", RustType::Uuid));
        table.fields.push(FieldDef::new("email", RustType::String));

        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);"
        );
    }
}