rustrails-record 0.1.2

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

use serde_json::Value;

/// Configuration for single-table inheritance behavior.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InheritanceConfig {
    /// The discriminator column name.
    pub inheritance_column: String,
}

impl Default for InheritanceConfig {
    fn default() -> Self {
        Self {
            inheritance_column: "type".to_owned(),
        }
    }
}

impl InheritanceConfig {
    /// Creates a new inheritance configuration.
    #[must_use]
    pub fn new(inheritance_column: impl Into<String>) -> Self {
        Self {
            inheritance_column: inheritance_column.into(),
        }
    }
}

/// Describes a concrete STI subtype.
pub trait StiType {
    /// Returns the discriminator value stored for this subtype.
    fn sti_name() -> &'static str;
}

/// Trait implemented by base records that participate in STI.
pub trait SingleTableInheritance {
    /// Returns STI configuration for the record hierarchy.
    fn inheritance_config() -> &'static InheritanceConfig {
        static DEFAULT_CONFIG: std::sync::LazyLock<InheritanceConfig> =
            std::sync::LazyLock::new(InheritanceConfig::default);
        &DEFAULT_CONFIG
    }
}

/// Casts a record into another subtype via `From` conversion.
#[must_use]
pub fn becomes<T, R>(record: R) -> T
where
    T: From<R>,
{
    T::from(record)
}

/// Applies an STI discriminator filter to query conditions.
#[must_use]
pub fn scope_for_type<T>(
    config: &InheritanceConfig,
    mut conditions: HashMap<String, Value>,
) -> HashMap<String, Value>
where
    T: StiType,
{
    conditions.insert(
        config.inheritance_column.clone(),
        Value::String(T::sti_name().to_owned()),
    );
    conditions
}

/// Returns `true` when the attribute hash matches the requested subtype.
#[must_use]
pub fn matches_type<T>(config: &InheritanceConfig, attributes: &HashMap<String, Value>) -> bool
where
    T: StiType,
{
    attributes
        .get(&config.inheritance_column)
        .and_then(Value::as_str)
        == Some(T::sti_name())
}

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

    use serde_json::json;

    use super::{
        InheritanceConfig, SingleTableInheritance, StiType, becomes, matches_type, scope_for_type,
    };

    #[derive(Debug, Clone, PartialEq, Eq)]
    struct CompanyRecord {
        id: i64,
        name: String,
        record_type: String,
    }

    #[derive(Debug, Clone, PartialEq, Eq)]
    struct FirmRecord {
        id: i64,
        name: String,
    }

    #[derive(Debug, Clone, PartialEq, Eq)]
    struct ClientRecord {
        id: i64,
        name: String,
    }

    impl From<CompanyRecord> for FirmRecord {
        fn from(value: CompanyRecord) -> Self {
            Self {
                id: value.id,
                name: value.name,
            }
        }
    }

    impl From<CompanyRecord> for ClientRecord {
        fn from(value: CompanyRecord) -> Self {
            Self {
                id: value.id,
                name: value.name,
            }
        }
    }

    impl StiType for FirmRecord {
        fn sti_name() -> &'static str {
            "Firm"
        }
    }

    impl StiType for ClientRecord {
        fn sti_name() -> &'static str {
            "Client"
        }
    }

    impl SingleTableInheritance for CompanyRecord {
        fn inheritance_config() -> &'static InheritanceConfig {
            static CONFIG: LazyLock<InheritanceConfig> =
                LazyLock::new(|| InheritanceConfig::new("record_type"));
            &CONFIG
        }
    }

    #[test]
    fn default_config_uses_type_column() {
        assert_eq!(InheritanceConfig::default().inheritance_column, "type");
    }

    #[test]
    fn custom_config_uses_custom_column() {
        assert_eq!(
            CompanyRecord::inheritance_config().inheritance_column,
            "record_type"
        );
    }

    #[test]
    fn becomes_casts_between_subtypes() {
        let company = CompanyRecord {
            id: 1,
            name: "Acme".to_owned(),
            record_type: "Firm".to_owned(),
        };

        let firm: FirmRecord = becomes(company);
        assert_eq!(firm.id, 1);
        assert_eq!(firm.name, "Acme");
    }

    #[test]
    fn scope_for_type_adds_discriminator() {
        let scope =
            scope_for_type::<FirmRecord>(CompanyRecord::inheritance_config(), HashMap::new());
        assert_eq!(scope.get("record_type"), Some(&json!("Firm")));
    }

    #[test]
    fn scope_for_type_preserves_existing_conditions() {
        let scope = scope_for_type::<ClientRecord>(
            CompanyRecord::inheritance_config(),
            HashMap::from([("active".to_owned(), json!(true))]),
        );

        assert_eq!(scope.get("active"), Some(&json!(true)));
        assert_eq!(scope.get("record_type"), Some(&json!("Client")));
    }

    #[test]
    fn matches_type_checks_discriminator_column() {
        let attrs = HashMap::from([("record_type".to_owned(), json!("Firm"))]);
        assert!(matches_type::<FirmRecord>(
            CompanyRecord::inheritance_config(),
            &attrs
        ));
        assert!(!matches_type::<ClientRecord>(
            CompanyRecord::inheritance_config(),
            &attrs
        ));
    }

    #[test]
    fn matches_type_returns_false_when_discriminator_missing() {
        let attrs = HashMap::new();
        assert!(!matches_type::<FirmRecord>(
            CompanyRecord::inheritance_config(),
            &attrs
        ));
    }
}