rusticx-core 1.0.0

Core traits, types, and abstractions for the Rusticx multi-database ORM
Documentation
use crate::{
    adapter::DatabaseAdapter,
    error::Result,
    model::{Model, TableSchema},
    query::{CondOp, QueryBuilder},
    value::Value,
};
use std::sync::Arc;

/// Typed, high-level repository for a single model type `M`.
///
/// `Repository` is the main entry point for CRUD operations. It wraps an
/// [`Arc`]-shared [`DatabaseAdapter`] so multiple repositories (or threads)
/// can share one connection pool.
///
/// # Obtaining a repository
///
/// ```rust,ignore
/// use rusticx::prelude::*;
/// use std::sync::Arc;
///
/// let adapter = PostgresAdapter::connect_url("postgres://localhost/mydb").await?;
/// let repo: Repository<User, _> = Repository::new(Arc::new(adapter));
/// ```
///
/// # Thread safety
///
/// `Repository` is `Clone` and `Send + Sync` — share it freely across tasks
/// and threads. The underlying connection pool handles concurrency.
pub struct Repository<M: Model, A: DatabaseAdapter> {
    adapter: Arc<A>,
    _phantom: std::marker::PhantomData<M>,
}

impl<M: Model, A: DatabaseAdapter> Repository<M, A> {
    pub fn new(adapter: Arc<A>) -> Self {
        Self { adapter, _phantom: std::marker::PhantomData }
    }

    pub fn adapter(&self) -> &A {
        &self.adapter
    }

    // ── Schema ───────────────────────────────────────────────────────────

    /// Create the table / collection for `M` if it does not already exist.
    ///
    /// Safe to call on every startup — it is idempotent (`CREATE TABLE IF NOT EXISTS`).
    pub async fn migrate(&self) -> Result<()> {
        let schema = TableSchema::from_model::<M>();
        self.adapter.create_table(&schema).await
    }

    // ── CRUD ─────────────────────────────────────────────────────────────

    /// Insert one model instance and return it with any generated fields filled in
    /// (e.g. database-generated timestamps, auto-increment IDs).
    pub async fn insert(&self, model: &M) -> Result<M> {
        let row = model.to_row()?;
        let inserted = self.adapter.insert(M::table_name(), row).await?;
        M::from_row(inserted)
    }

    /// Insert a slice of models in a single transaction. Returns the count inserted.
    pub async fn insert_many(&self, models: &[M]) -> Result<u64> {
        let rows: Result<Vec<_>> = models.iter().map(|m| m.to_row()).collect();
        self.adapter.insert_many(M::table_name(), rows?).await
    }

    /// Fetch every row in the table. Use [`find`] with a [`QueryBuilder`] for filtering.
    ///
    /// **Caution:** can return very large result sets on big tables.
    ///
    /// [`find`]: Self::find
    pub async fn find_all(&self) -> Result<Vec<M>> {
        let qb = QueryBuilder::table(M::table_name());
        let rows = self.adapter.find(&qb).await?;
        rows.into_iter().map(M::from_row).collect()
    }

    /// Find a single record by primary key. Returns `None` if not found.
    pub async fn find_by_id(&self, id: impl Into<Value>) -> Result<Option<M>> {
        let qb = QueryBuilder::table(M::table_name())
            .r#where(M::primary_key(), CondOp::Eq, id.into());
        let row = self.adapter.find_one(&qb).await?;
        row.map(M::from_row).transpose()
    }

    /// Find the first record matching the given query. Returns `None` if no match.
    pub async fn find_one(&self, qb: QueryBuilder) -> Result<Option<M>> {
        let row = self.adapter.find_one(&qb).await?;
        row.map(M::from_row).transpose()
    }

    /// Find all records matching the given query.
    ///
    /// Build the query with [`Repository::query`] for ergonomic chaining:
    ///
    /// ```rust,ignore
    /// let results = repo.find(
    ///     repo.query()
    ///         .r#where("age", CondOp::Gte, 18)
    ///         .order_by("name", Direction::Asc)
    ///         .limit(50)
    /// ).await?;
    /// ```
    pub async fn find(&self, qb: QueryBuilder) -> Result<Vec<M>> {
        let rows = self.adapter.find(&qb).await?;
        rows.into_iter().map(M::from_row).collect()
    }

    /// Fetch one page of results. `page` is 1-indexed.
    ///
    /// ```rust,ignore
    /// // Page 2, 20 records per page
    /// let page = repo.paginate(2, 20).await?;
    /// ```
    pub async fn paginate(&self, page: u64, per_page: u64) -> Result<Vec<M>> {
        let qb = QueryBuilder::table(M::table_name())
            .limit(per_page)
            .offset((page.saturating_sub(1)) * per_page);
        let rows = self.adapter.find(&qb).await?;
        rows.into_iter().map(M::from_row).collect()
    }

    /// Count records. Pass `None` to count all rows, or `Some(qb)` to count
    /// only rows matching the query.
    pub async fn count(&self, qb: Option<QueryBuilder>) -> Result<u64> {
        let qb = qb.unwrap_or_else(|| QueryBuilder::table(M::table_name()));
        self.adapter.count(&qb).await
    }

    /// Update rows matching the query. Returns the number of rows affected.
    ///
    /// Use `.set(column, value)` on the query builder to specify new values:
    ///
    /// ```rust,ignore
    /// repo.update(
    ///     repo.query()
    ///         .r#where("email", CondOp::Eq, "alice@example.com")
    ///         .set("age", 31)
    ///         .set("active", false)
    /// ).await?;
    /// ```
    pub async fn update(&self, qb: QueryBuilder) -> Result<u64> {
        self.adapter.update(&qb).await
    }

    /// Upsert: insert if the primary key is null/absent, update if it is set.
    ///
    /// This is the idiomatic way to persist a model without knowing whether it
    /// already exists in the database.
    pub async fn save(&self, model: &M) -> Result<M> {
        let pk = model.pk_value();
        match pk {
            Ok(Value::Null) | Err(_) => self.insert(model).await,
            Ok(pk_val) => {
                let row = model.to_row()?;
                let mut qb = QueryBuilder::table(M::table_name())
                    .r#where(M::primary_key(), CondOp::Eq, pk_val)
                    .operation(crate::query::Operation::Update);
                for (col, val) in row {
                    if col != M::primary_key() {
                        qb = qb.set(col, val);
                    }
                }
                self.adapter.update(&qb).await?;
                self.find_by_id(model.pk_value()?).await?.ok_or_else(|| {
                    crate::error::RusticxError::NotFound("record after save".to_owned())
                })
            }
        }
    }

    /// Delete the record with the given primary key. Returns 1 if deleted, 0 if not found.
    pub async fn delete_by_id(&self, id: impl Into<Value>) -> Result<u64> {
        let qb = QueryBuilder::table(M::table_name())
            .r#where(M::primary_key(), CondOp::Eq, id.into())
            .operation(crate::query::Operation::Delete);
        self.adapter.delete(&qb).await
    }

    /// Delete all records matching the query. Returns the count deleted.
    pub async fn delete(&self, qb: QueryBuilder) -> Result<u64> {
        self.adapter.delete(&qb).await
    }

    /// Start building a query pre-scoped to this model's table.
    ///
    /// This is the idiomatic starting point for all filtered operations:
    ///
    /// ```rust,ignore
    /// repo.find(repo.query().r#where("active", CondOp::Eq, true)).await?
    /// ```
    pub fn query(&self) -> QueryBuilder {
        QueryBuilder::table(M::table_name())
    }
}