rustango 0.43.1

Django-shaped batteries-included web framework for Rust: ORM + migrations + auto-admin + multi-tenancy + audit log + auth (sessions, JWT, OAuth2/OIDC, HMAC) + APIs (ViewSet, OpenAPI auto-derive, JSON:API) + jobs (in-mem + Postgres) + email + media (S3 / R2 / B2 / MinIO + presigned uploads + collections + tags) + production middleware (CSRF, CSP, rate-limiting, compression, idempotency, etc.).
Documentation
//! Test fixture loader — seed a test database from JSON files.
//!
//! ## Quick start
//!
//! ```ignore
//! use rustango::fixtures::Fixture;
//!
//! // fixtures/users.json:
//! // [
//! //   {"username": "alice", "email": "a@x.com"},
//! //   {"username": "bob",   "email": "b@x.com"}
//! // ]
//!
//! Fixture::new("users")
//!     .from_file("fixtures/users.json")?
//!     .load_into("rustango_users", &pool).await?;
//! ```
//!
//! ## How it works
//!
//! Each fixture is an array of JSON objects. For each object, the loader
//! emits an `INSERT INTO <table> (col1, col2, ...) VALUES (...)` against
//! the pool. Column names come from the JSON object's keys; values are
//! bound via sqlx parameter binding (no SQL injection).
//!
//! ## Ordering
//!
//! Fixtures load in registration order — register parent tables before
//! children to satisfy FK constraints.

use std::collections::HashSet;
use std::path::Path;

use serde_json::Value;

#[cfg(feature = "postgres")]
use crate::sql::sqlx::PgPool;
use crate::sql::Pool;

#[derive(Debug, thiserror::Error)]
pub enum FixtureError {
    #[error("io error: {0}")]
    Io(String),
    #[error("invalid fixture format in {file}: {detail}")]
    Format { file: String, detail: String },
    #[error("database error: {0}")]
    Database(String),
}

/// One named fixture — a list of JSON object rows.
pub struct Fixture {
    name: String,
    rows: Vec<serde_json::Map<String, Value>>,
}

impl Fixture {
    /// New empty fixture with the given name (used in error messages).
    #[must_use]
    pub fn new(name: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            rows: Vec::new(),
        }
    }

    /// Add one row to the fixture.
    #[must_use]
    pub fn with_row(mut self, row: serde_json::Map<String, Value>) -> Self {
        self.rows.push(row);
        self
    }

    /// Number of rows in the fixture.
    #[must_use]
    pub fn row_count(&self) -> usize {
        self.rows.len()
    }

    /// Load rows from a JSON file. The file must contain an array of objects.
    ///
    /// # Errors
    /// [`FixtureError::Io`] when the file can't be read.
    /// [`FixtureError::Format`] when the JSON isn't an array of objects.
    pub fn from_file(mut self, path: impl AsRef<Path>) -> Result<Self, FixtureError> {
        let path = path.as_ref();
        let raw = std::fs::read_to_string(path).map_err(|e| FixtureError::Io(e.to_string()))?;
        let v: Value = serde_json::from_str(&raw).map_err(|e| FixtureError::Format {
            file: path.display().to_string(),
            detail: e.to_string(),
        })?;
        let arr = v.as_array().ok_or_else(|| FixtureError::Format {
            file: path.display().to_string(),
            detail: "expected top-level array".into(),
        })?;
        for (i, item) in arr.iter().enumerate() {
            let obj = item.as_object().ok_or_else(|| FixtureError::Format {
                file: path.display().to_string(),
                detail: format!("entry {i} is not an object"),
            })?;
            self.rows.push(obj.clone());
        }
        Ok(self)
    }

    /// Load rows from a JSON `Value` (must be an array of objects).
    ///
    /// # Errors
    /// [`FixtureError::Format`] when not an array of objects.
    pub fn from_value(mut self, v: Value) -> Result<Self, FixtureError> {
        let arr = v.as_array().ok_or_else(|| FixtureError::Format {
            file: self.name.clone(),
            detail: "expected top-level array".into(),
        })?;
        for (i, item) in arr.iter().enumerate() {
            let obj = item.as_object().ok_or_else(|| FixtureError::Format {
                file: self.name.clone(),
                detail: format!("entry {i} is not an object"),
            })?;
            self.rows.push(obj.clone());
        }
        Ok(self)
    }

    /// Insert every row into `table` against any rustango-supported
    /// backend. Routes through the [`crate::sql::Pool`] enum +
    /// per-dialect SQL emission (`pool.dialect().placeholder(n)` for
    /// `$N` / `?`; `pool.dialect().quote_ident(c)` for `"col"` /
    /// `` `col` ``).
    ///
    /// # Errors
    /// [`FixtureError::Database`] on driver-level failures.
    pub async fn load_into_pool(&self, table: &str, pool: &Pool) -> Result<usize, FixtureError> {
        validate_ident(table)?;
        let mut count = 0;
        for row in &self.rows {
            insert_row_pool(pool, table, row).await?;
            count += 1;
        }
        Ok(count)
    }

    /// PG-typed back-compat shim around [`Self::load_into_pool`].
    ///
    /// # Errors
    /// As [`Self::load_into_pool`].
    #[cfg(feature = "postgres")]
    pub async fn load_into(&self, table: &str, pool: &PgPool) -> Result<usize, FixtureError> {
        self.load_into_pool(table, &Pool::Postgres(pool.clone()))
            .await
    }
}

/// Load multiple fixtures in registration order against any
/// rustango-supported backend. Stops at first error.
///
/// # Errors
/// First fixture error encountered.
pub async fn load_all_pool(
    fixtures: &[(&str, &Fixture)],
    pool: &Pool,
) -> Result<usize, FixtureError> {
    let mut total = 0;
    for (table, fixture) in fixtures {
        total += fixture.load_into_pool(table, pool).await?;
    }
    Ok(total)
}

/// PG-typed back-compat shim around [`load_all_pool`].
///
/// # Errors
/// First fixture error encountered.
#[cfg(feature = "postgres")]
pub async fn load_all(fixtures: &[(&str, &Fixture)], pool: &PgPool) -> Result<usize, FixtureError> {
    load_all_pool(fixtures, &Pool::Postgres(pool.clone())).await
}

async fn insert_row_pool(
    pool: &Pool,
    table: &str,
    row: &serde_json::Map<String, Value>,
) -> Result<(), FixtureError> {
    if row.is_empty() {
        return Err(FixtureError::Format {
            file: table.to_owned(),
            detail: "row has no columns".into(),
        });
    }
    let columns: Vec<&String> = row.keys().collect();
    for col in &columns {
        validate_ident(col)?;
    }
    let dialect = pool.dialect();
    let cols_sql: Vec<String> = columns.iter().map(|c| dialect.quote_ident(c)).collect();
    let placeholders: Vec<String> = (1..=columns.len())
        .map(|i| dialect.placeholder(i))
        .collect();
    let sql = format!(
        "INSERT INTO {} ({}) VALUES ({})",
        dialect.quote_ident(table),
        cols_sql.join(", "),
        placeholders.join(", "),
    );
    // #561 — was three byte-identical `bind_pg`/`bind_my`/`bind_sqlite`
    // helpers each matching on `serde_json::Value` and binding the
    // backend-specific sqlx arg. Convert each row value to a
    // `SqlValue` once and let `raw_execute_pool` dispatch through
    // the canonical executor binding macros — arrays / objects go
    // through `SqlValue::Json` which the executor wraps in
    // `sqlx::types::Json(...)` for every backend.
    let binds: Vec<crate::core::SqlValue> = columns
        .iter()
        .map(|col| value_to_sqlvalue(&row[col.as_str()]))
        .collect();
    crate::sql::raw_execute_pool(pool, &sql, binds)
        .await
        .map_err(|e| FixtureError::Database(e.to_string()))?;
    Ok(())
}

/// #561 — `serde_json::Value` → `SqlValue` adapter. Matches the
/// behavior of the previous per-backend `bind_*` helpers:
/// scalars route through the typed `SqlValue` variants; arrays and
/// objects route through `SqlValue::Json` so the executor's
/// `bind_match!` wraps in `sqlx::types::Json(...)` for JSONB / JSON
/// / TEXT on PG / MySQL / SQLite respectively.
fn value_to_sqlvalue(v: &Value) -> crate::core::SqlValue {
    use crate::core::SqlValue;
    match v {
        Value::Null => SqlValue::Null,
        Value::Bool(b) => SqlValue::Bool(*b),
        Value::Number(n) => {
            if let Some(i) = n.as_i64() {
                SqlValue::I64(i)
            } else if let Some(f) = n.as_f64() {
                SqlValue::F64(f)
            } else {
                SqlValue::String(n.to_string())
            }
        }
        Value::String(s) => SqlValue::String(s.clone()),
        Value::Array(_) | Value::Object(_) => SqlValue::Json(v.clone()),
    }
}

/// Reject identifiers (table / column names) with characters that could
/// break out of the quoted form. The full identifier is wrapped in `"..."`
/// so we just need to forbid `"`, NUL, and any control char.
fn validate_ident(name: &str) -> Result<(), FixtureError> {
    if name.is_empty() {
        return Err(FixtureError::Format {
            file: "<ident>".into(),
            detail: "identifier is empty".into(),
        });
    }
    let bad: HashSet<char> = ['"', '\0', '\n', '\r', '\\'].into();
    if name.chars().any(|c| bad.contains(&c) || c.is_control()) {
        return Err(FixtureError::Format {
            file: "<ident>".into(),
            detail: format!("identifier `{name}` contains forbidden characters"),
        });
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn fixture_with_row_increments_count() {
        let f = Fixture::new("test")
            .with_row(json!({"a": 1}).as_object().unwrap().clone())
            .with_row(json!({"a": 2}).as_object().unwrap().clone());
        assert_eq!(f.row_count(), 2);
    }

    #[test]
    fn from_value_parses_array() {
        let v = json!([{"name": "alice"}, {"name": "bob"}]);
        let f = Fixture::new("users").from_value(v).unwrap();
        assert_eq!(f.row_count(), 2);
    }

    #[test]
    fn from_value_rejects_non_array() {
        let v = json!({"not": "an array"});
        let r = Fixture::new("x").from_value(v);
        assert!(matches!(r, Err(FixtureError::Format { .. })));
    }

    #[test]
    fn from_value_rejects_non_object_entry() {
        let v = json!([{"ok": 1}, "scalar-not-object"]);
        let r = Fixture::new("x").from_value(v);
        assert!(matches!(r, Err(FixtureError::Format { .. })));
    }

    #[test]
    fn validate_ident_accepts_normal() {
        assert!(validate_ident("users").is_ok());
        assert!(validate_ident("user_id").is_ok());
        assert!(validate_ident("rustango_audit_log").is_ok());
    }

    #[test]
    fn validate_ident_rejects_quote() {
        assert!(validate_ident("evil\"name").is_err());
    }

    #[test]
    fn validate_ident_rejects_newline() {
        assert!(validate_ident("a\nb").is_err());
    }

    #[test]
    fn validate_ident_rejects_empty() {
        assert!(validate_ident("").is_err());
    }

    #[test]
    fn from_file_loads_array() {
        use std::io::Write;
        let path = std::env::temp_dir().join(format!(
            "rustango_fixture_test_{}_{}.json",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        ));
        std::fs::File::create(&path)
            .unwrap()
            .write_all(br#"[{"id": 1, "name": "one"}, {"id": 2, "name": "two"}]"#)
            .unwrap();
        let f = Fixture::new("test").from_file(&path).unwrap();
        assert_eq!(f.row_count(), 2);
        let _ = std::fs::remove_file(&path);
    }

    #[test]
    fn from_file_missing_file_is_io_error() {
        let r = Fixture::new("x").from_file("/no/such/file/exists.json");
        assert!(matches!(r, Err(FixtureError::Io(_))));
    }

    #[test]
    fn from_file_invalid_json_is_format_error() {
        use std::io::Write;
        let path =
            std::env::temp_dir().join(format!("rustango_fixture_bad_{}.json", std::process::id()));
        std::fs::File::create(&path)
            .unwrap()
            .write_all(b"{not valid json")
            .unwrap();
        let r = Fixture::new("x").from_file(&path);
        assert!(matches!(r, Err(FixtureError::Format { .. })));
        let _ = std::fs::remove_file(&path);
    }
}