rustrails-record 0.1.2

ORM layer (ActiveRecord equivalent)
Documentation
use std::collections::HashMap;

use rustrails_support::{database, runtime};
use sea_orm::{
    ConnectionTrait, DatabaseConnection,
    sea_query::{Alias, Expr, Query},
};
use serde_json::Value;

use crate::{base::RecordError, relation::json_to_sea_value};

/// Fixture data for tests and seeds.
#[derive(Debug, Clone, PartialEq)]
pub struct Fixture {
    /// The target table or model name.
    pub model_name: String,
    /// The records to insert.
    pub records: Vec<HashMap<String, Value>>,
}

impl Fixture {
    /// Parses fixture records from a JSON array of objects.
    pub fn from_json(model: &str, json: &str) -> Result<Self, serde_json::Error> {
        let records = serde_json::from_str(json)?;
        Ok(Self::from_value(model, records))
    }

    /// Builds a fixture from already-parsed records.
    pub fn from_value(model: &str, records: Vec<HashMap<String, Value>>) -> Self {
        Self {
            model_name: model.to_owned(),
            records,
        }
    }

    /// Returns the contained records.
    pub fn records(&self) -> &[HashMap<String, Value>] {
        &self.records
    }
}

/// Loads fixtures into the database.
pub async fn load_fixtures(
    fixtures: &[Fixture],
    db: &DatabaseConnection,
) -> Result<(), RecordError> {
    for fixture in fixtures {
        for record in fixture.records() {
            if record.is_empty() {
                continue;
            }

            let mut query = Query::insert();
            query.into_table(Alias::new(&fixture.model_name));

            let mut columns = Vec::with_capacity(record.len());
            let mut values = Vec::with_capacity(record.len());
            for (column, value) in record {
                columns.push(Alias::new(column));
                values.push(json_to_sea_value(value)?);
            }

            query.columns(columns);
            query.values_panic(values.into_iter().map(Expr::val));

            db.execute(&query).await?;
        }
    }

    Ok(())
}

/// Synchronous wrapper for [`load_fixtures`].
pub fn load_fixtures_sync(fixtures: &[Fixture]) -> Result<(), RecordError> {
    database::with_db(|db| runtime::block_on(load_fixtures(fixtures, db)))
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use sea_orm::{ConnectionTrait, Schema};
    use serde_json::{Value, error::Category, json};

    use super::{Fixture, load_fixtures, load_fixtures_sync};
    use crate::{
        RecordError,
        base::test_support::{TestUser, setup_db, test_user},
        querying::AsyncQuerying,
    };
    use rustrails_support::{database, runtime};

    fn run_sync_fixture_test(test: impl FnOnce() + Send + 'static) {
        std::thread::spawn(move || {
            let _rt = runtime::init_runtime();
            database::establish("sqlite::memory:")
                .expect("sqlite in-memory connection should succeed");
            runtime::block_on(async {
                let db = database::db();
                let schema = Schema::new(db.get_database_backend());
                db.execute(&schema.create_table_from_entity(test_user::Entity))
                    .await
                    .expect("test_users table should be created");
            });
            test();
        })
        .join()
        .unwrap();
    }

    #[tokio::test]
    async fn fixture_parses_from_json() {
        let fixture = Fixture::from_json(
            "test_users",
            r#"[{"name":"Alice","email":"alice@example.com"}]"#,
        )
        .expect("fixture json should parse");

        assert_eq!(fixture.model_name, "test_users");
        assert_eq!(fixture.records.len(), 1);
        assert_eq!(fixture.records[0]["name"], json!("Alice"));
    }

    #[tokio::test]
    async fn fixture_from_json_rejects_invalid_json_syntax() {
        let error = Fixture::from_json("test_users", r#"[{"name":"Alice"}"#)
            .expect_err("invalid fixture json should fail");

        assert!(matches!(error.classify(), Category::Syntax | Category::Eof));
    }

    #[tokio::test]
    async fn fixture_from_json_rejects_object_root() {
        let error = Fixture::from_json("test_users", r#"{"name":"Alice"}"#)
            .expect_err("object payload should fail");

        assert_eq!(error.classify(), Category::Data);
    }

    #[tokio::test]
    async fn fixture_from_json_rejects_non_object_entries() {
        let error = Fixture::from_json("test_users", r#"["Alice"]"#)
            .expect_err("non-object records should fail");

        assert_eq!(error.classify(), Category::Data);
    }

    #[tokio::test]
    async fn records_accessor_returns_loaded_rows() {
        let fixture = Fixture::from_value(
            "test_users",
            vec![HashMap::from([
                ("name".to_owned(), json!("Bob")),
                ("email".to_owned(), json!("bob@example.com")),
            ])],
        );

        assert_eq!(fixture.records().len(), 1);
        assert_eq!(fixture.records()[0]["email"], json!("bob@example.com"));
    }

    #[tokio::test]
    async fn load_fixtures_inserts_records() {
        let db = setup_db().await;
        let fixtures = [Fixture::from_value(
            "test_users",
            vec![
                HashMap::from([
                    ("name".to_owned(), json!("Alice")),
                    ("email".to_owned(), json!("alice@example.com")),
                ]),
                HashMap::from([
                    ("name".to_owned(), json!("Bob")),
                    ("email".to_owned(), json!("bob@example.com")),
                ]),
            ],
        )];

        load_fixtures(&fixtures, &db)
            .await
            .expect("fixtures should load");

        let users = TestUser::all(&db).await.expect("records should load");
        assert_eq!(users.len(), 2);
        assert_eq!(users[0].name, "Alice");
        assert_eq!(users[1].name, "Bob");
    }

    #[tokio::test]
    async fn load_fixtures_skips_empty_records() {
        let db = setup_db().await;
        let fixtures = [Fixture::from_value(
            "test_users",
            vec![
                HashMap::new(),
                HashMap::from([
                    ("name".to_owned(), json!("Alice")),
                    ("email".to_owned(), json!("alice@example.com")),
                ]),
            ],
        )];

        load_fixtures(&fixtures, &db)
            .await
            .expect("non-empty fixtures should load");

        let users = TestUser::all(&db).await.expect("records should load");
        assert_eq!(users.len(), 1);
        assert_eq!(users[0].name, "Alice");
    }

    #[tokio::test]
    async fn load_fixtures_rejects_null_values() {
        let db = setup_db().await;
        let fixtures = [Fixture::from_value(
            "test_users",
            vec![HashMap::from([
                ("name".to_owned(), json!("Alice")),
                ("email".to_owned(), Value::Null),
            ])],
        )];

        let error = load_fixtures(&fixtures, &db)
            .await
            .expect_err("null values should fail");

        assert!(matches!(error, RecordError::Invalid(_)));
    }

    #[tokio::test]
    async fn load_fixtures_rejects_non_scalar_values() {
        let db = setup_db().await;
        let fixtures = [Fixture::from_value(
            "test_users",
            vec![HashMap::from([
                ("name".to_owned(), json!("Alice")),
                ("email".to_owned(), json!({"address": "alice@example.com"})),
            ])],
        )];

        let error = load_fixtures(&fixtures, &db)
            .await
            .expect_err("non-scalar values should fail");

        assert!(matches!(error, RecordError::Invalid(_)));
    }

    #[tokio::test]
    async fn load_fixtures_inserts_multiple_fixture_groups() {
        let db = setup_db().await;
        let fixtures = [
            Fixture::from_value(
                "test_users",
                vec![HashMap::from([
                    ("name".to_owned(), json!("Alice")),
                    ("email".to_owned(), json!("alice@example.com")),
                ])],
            ),
            Fixture::from_value(
                "test_users",
                vec![
                    HashMap::from([
                        ("name".to_owned(), json!("Bob")),
                        ("email".to_owned(), json!("bob@example.com")),
                    ]),
                    HashMap::from([
                        ("name".to_owned(), json!("Carol")),
                        ("email".to_owned(), json!("carol@example.com")),
                    ]),
                ],
            ),
        ];

        load_fixtures(&fixtures, &db)
            .await
            .expect("fixture groups should load");

        let mut names = TestUser::all(&db)
            .await
            .expect("records should load")
            .into_iter()
            .map(|user| user.name)
            .collect::<Vec<_>>();
        names.sort();

        assert_eq!(names, vec!["Alice", "Bob", "Carol"]);
    }

    #[test]
    fn load_fixtures_sync_inserts_records() {
        run_sync_fixture_test(|| {
            let fixtures = [Fixture::from_value(
                "test_users",
                vec![HashMap::from([
                    ("name".to_owned(), json!("Alice")),
                    ("email".to_owned(), json!("alice@example.com")),
                ])],
            )];

            load_fixtures_sync(&fixtures).expect("fixtures should load");

            let users = runtime::block_on(async {
                let db = database::db();
                TestUser::all(&db).await.expect("records should load")
            });
            assert_eq!(users.len(), 1);
            assert_eq!(users[0].name, "Alice");
        });
    }
}