rustrails-record 0.1.2

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

use serde_json::{Value, json};

use crate::associations::{AssociationMeta, AssociationType, HasAssociations};

/// A reflection view over declared association metadata.
#[derive(Debug, Clone, PartialEq)]
pub struct AssociationReflection {
    /// The association kind.
    pub kind: AssociationType,
    /// The association name.
    pub name: String,
    /// Association options encoded as JSON-compatible values.
    pub options: HashMap<String, Value>,
}

/// Reflection helpers backed by [`crate::associations::AssociationRegistry`].
pub trait Reflection: HasAssociations {
    /// Returns the reflection for a single association.
    #[must_use]
    fn reflect_on_association(name: &str) -> Option<AssociationReflection> {
        Self::associations()
            .get(name)
            .map(AssociationReflection::from)
    }

    /// Returns every declared association reflection in declaration order.
    #[must_use]
    fn reflect_on_all_associations() -> Vec<AssociationReflection> {
        Self::associations()
            .all()
            .iter()
            .map(AssociationReflection::from)
            .collect()
    }
}

impl From<&AssociationMeta> for AssociationReflection {
    fn from(meta: &AssociationMeta) -> Self {
        let mut options = HashMap::from([
            (
                "target_table".to_owned(),
                Value::String(meta.target_table.clone()),
            ),
            (
                "foreign_key".to_owned(),
                Value::String(meta.foreign_key.clone()),
            ),
            (
                "primary_key".to_owned(),
                Value::String(meta.primary_key.clone()),
            ),
            ("polymorphic".to_owned(), Value::Bool(meta.polymorphic)),
        ]);

        if let Some(through) = &meta.through {
            options.insert("through".to_owned(), Value::String(through.clone()));
        }
        if let Some(dependent) = meta.dependent {
            options.insert("dependent".to_owned(), json!(format!("{dependent:?}")));
        }

        Self {
            kind: meta.association_type,
            name: meta.name.clone(),
            options,
        }
    }
}

#[cfg(test)]
mod tests {
    use std::sync::LazyLock;

    use sea_orm::{ActiveModelTrait, EntityTrait};
    use serde_json::json;

    use super::{AssociationReflection, Reflection};
    use crate::{
        Record, RecordState,
        associations::{
            AssociationRegistry, AssociationType, BelongsToBuilder, DependentAction,
            HasAndBelongsToManyBuilder, HasAssociations, HasManyBuilder, HasOneBuilder,
        },
        base::test_support::test_user,
    };

    #[derive(Debug, Default)]
    struct ReflectionRecord {
        state: RecordState,
    }

    impl Record for ReflectionRecord {
        type Entity = test_user::Entity;

        fn table_name() -> &'static str {
            "test_users"
        }

        fn id(&self) -> Option<i64> {
            None
        }

        fn record_state(&self) -> RecordState {
            self.state
        }

        fn set_record_state(&mut self, state: RecordState) {
            self.state = state;
        }

        fn from_sea_model(_model: <Self::Entity as EntityTrait>::Model) -> Self {
            Self {
                state: RecordState::Persisted,
            }
        }

        fn to_active_model(&self) -> <Self::Entity as EntityTrait>::ActiveModel
        where
            <Self::Entity as EntityTrait>::ActiveModel: ActiveModelTrait,
        {
            <test_user::ActiveModel as Default>::default()
        }
    }

    static TEST_ASSOCIATIONS: LazyLock<AssociationRegistry> = LazyLock::new(|| {
        let mut registry = AssociationRegistry::new();
        registry.add(
            HasManyBuilder::new("comments")
                .dependent(DependentAction::Destroy)
                .build(),
        );
        registry.add(HasOneBuilder::new("profile").build());
        registry.add(BelongsToBuilder::new("account").build());
        registry.add(
            HasAndBelongsToManyBuilder::new("roles")
                .through("accounts_roles")
                .build(),
        );
        registry
    });

    impl HasAssociations for ReflectionRecord {
        fn associations() -> &'static AssociationRegistry {
            &TEST_ASSOCIATIONS
        }
    }

    impl Reflection for ReflectionRecord {}

    #[test]
    fn reflect_on_association_returns_matching_metadata() {
        let reflection = ReflectionRecord::reflect_on_association("comments")
            .expect("comments reflection should exist");

        assert_eq!(reflection.kind, AssociationType::HasMany);
        assert_eq!(reflection.name, "comments");
        assert_eq!(reflection.options.get("dependent"), Some(&json!("Destroy")));
    }

    #[test]
    fn reflect_on_association_returns_none_for_unknown_name() {
        assert!(ReflectionRecord::reflect_on_association("missing").is_none());
    }

    #[test]
    fn reflect_on_all_associations_preserves_declaration_order() {
        let names = ReflectionRecord::reflect_on_all_associations()
            .into_iter()
            .map(|reflection| reflection.name)
            .collect::<Vec<_>>();

        assert_eq!(names, vec!["comments", "profile", "account", "roles"]);
    }

    #[test]
    fn reflection_includes_through_option_when_present() {
        let reflection = ReflectionRecord::reflect_on_association("roles")
            .expect("roles reflection should exist");
        assert_eq!(
            reflection.options.get("through"),
            Some(&json!("accounts_roles"))
        );
    }

    #[test]
    fn reflection_includes_core_key_options() {
        let reflection = ReflectionRecord::reflect_on_association("account")
            .expect("account reflection should exist");
        assert!(reflection.options.contains_key("target_table"));
        assert!(reflection.options.contains_key("foreign_key"));
        assert!(reflection.options.contains_key("primary_key"));
        assert!(reflection.options.contains_key("polymorphic"));
    }

    #[test]
    fn reflection_omits_optional_keys_when_not_configured() {
        let reflection = ReflectionRecord::reflect_on_association("profile")
            .expect("profile reflection should exist");

        assert!(!reflection.options.contains_key("through"));
        assert!(!reflection.options.contains_key("dependent"));
    }

    #[test]
    fn reflection_with_dependent_does_not_include_null_through_option() {
        let reflection = ReflectionRecord::reflect_on_association("comments")
            .expect("comments reflection should exist");

        assert!(!reflection.options.contains_key("through"));
        assert_eq!(reflection.options.get("dependent"), Some(&json!("Destroy")));
    }

    #[test]
    fn reflection_with_through_does_not_include_null_dependent_option() {
        let reflection = ReflectionRecord::reflect_on_association("roles")
            .expect("roles reflection should exist");

        assert_eq!(
            reflection.options.get("through"),
            Some(&json!("accounts_roles"))
        );
        assert!(!reflection.options.contains_key("dependent"));
    }

    #[test]
    fn reflection_options_match_underlying_metadata_values() {
        let meta = ReflectionRecord::associations()
            .get("account")
            .expect("account meta should exist");
        let reflection = AssociationReflection::from(meta);

        assert_eq!(
            reflection.options.get("target_table"),
            Some(&json!(meta.target_table.as_str()))
        );
        assert_eq!(
            reflection.options.get("foreign_key"),
            Some(&json!(meta.foreign_key.as_str()))
        );
        assert_eq!(
            reflection.options.get("primary_key"),
            Some(&json!(meta.primary_key.as_str()))
        );
        assert_eq!(
            reflection.options.get("polymorphic"),
            Some(&json!(meta.polymorphic))
        );
    }

    #[test]
    fn reflect_on_all_associations_round_trips_individual_lookups() {
        for reflection in ReflectionRecord::reflect_on_all_associations() {
            assert_eq!(
                ReflectionRecord::reflect_on_association(&reflection.name),
                Some(reflection)
            );
        }
    }

    #[test]
    fn association_reflection_can_be_built_from_meta() {
        let meta = ReflectionRecord::associations()
            .get("profile")
            .expect("meta should exist");
        let reflection = AssociationReflection::from(meta);

        assert_eq!(reflection.kind, AssociationType::HasOne);
        assert_eq!(reflection.name, "profile");
    }
}