auditlog 0.1.0

Audit trail for your data models — an ORM-agnostic core with a pluggable, async sqlx backend (SQLite & Postgres).
Documentation
//! The [`Auditable`] trait: implement it for your model to gain audit recording, querying, and
//! revision reconstruction.

use chrono::{DateTime, Utc};

use crate::audit::Audit;
use crate::backend::{AuditQuery, Backend};
use crate::changes::ValueMap;
use crate::config::{self, AuditOptions};
use crate::engine;
use crate::error::Result;
use crate::id::AuditId;
use crate::query::AuditQueryBuilder;
use crate::revision::{self, Revision};

/// Make a model auditable.
///
/// Implement the four required items; everything else (writing audits, querying, scopes, revisions,
/// enable/disable) is provided.
///
/// ```
/// use auditlog::{Auditable, AuditOptions, AuditId, ValueMap};
/// use serde_json::json;
///
/// struct Post { id: i64, title: String, body: String }
///
/// impl Auditable for Post {
///     fn auditable_type() -> &'static str { "Post" }
///     fn auditable_id(&self) -> AuditId { self.id.into() }
///     fn audited_attributes(&self) -> ValueMap {
///         let mut m = ValueMap::new();
///         m.insert("id".into(), json!(self.id));
///         m.insert("title".into(), json!(self.title));
///         m.insert("body".into(), json!(self.body));
///         m
///     }
///     fn audit_options() -> AuditOptions {
///         AuditOptions::builder().except(["body"]).build()
///     }
/// }
/// ```
///
/// The async writing/querying methods are `where Self: Sized` and take a `&dyn Backend`, so call
/// them directly on your value: `post.audited_create(&backend).await?`.
#[allow(async_fn_in_trait)]
pub trait Auditable: Send + Sync {
    /// The polymorphic type name stored in `auditable_type`, e.g. `"Post"`.
    fn auditable_type() -> &'static str
    where
        Self: Sized;

    /// This record's id.
    fn auditable_id(&self) -> AuditId;

    /// The *full* attribute map (all columns) as JSON values. Filtering by `only`/`except` and the
    /// default-ignored columns is applied by the crate, so return everything here. The integer vs
    /// label representation of enum columns is whatever you put in this map.
    fn audited_attributes(&self) -> ValueMap;

    /// The per-model options controlling how this type is audited.
    fn audit_options() -> AuditOptions
    where
        Self: Sized;

    /// The primary-key column name (default `"id"`), excluded from audits by default.
    fn primary_key() -> &'static str
    where
        Self: Sized,
    {
        "id"
    }

    /// The single-table-inheritance discriminator column (e.g. `"type"`), if your model uses STI.
    /// When set, it is excluded from audits by default. Default: none.
    fn inheritance_column() -> Option<&'static str>
    where
        Self: Sized,
    {
        None
    }

    /// The polymorphic associated/parent record `(type, id)` to record on each audit. Default:
    /// none.
    fn audit_associated(&self) -> Option<(String, AuditId)> {
        None
    }

    /// Instance condition: audit only when this returns `true`. Default `true`.
    fn audit_if(&self) -> bool {
        true
    }

    /// Instance condition: skip auditing when this returns `true`. Default
    /// `false`.
    fn audit_unless(&self) -> bool {
        false
    }

    // ----- writing audits -----

    /// Record a `create` audit. Call after persisting the new record. Returns the written audit,
    /// or `None` if auditing was disabled / not configured for `create`.
    async fn audited_create(&self, backend: &dyn Backend) -> Result<Option<Audit>>
    where
        Self: Sized,
    {
        let options = Self::audit_options();
        engine::create(
            backend,
            &engine::meta_for(self, &options),
            self.audited_attributes(),
            None,
        )
        .await
    }

    /// [`Auditable::audited_create`] with an audit comment.
    async fn audited_create_with_comment(
        &self,
        backend: &dyn Backend,
        comment: impl Into<String> + Send,
    ) -> Result<Option<Audit>>
    where
        Self: Sized,
    {
        let options = Self::audit_options();
        engine::create(
            backend,
            &engine::meta_for(self, &options),
            self.audited_attributes(),
            Some(comment.into()),
        )
        .await
    }

    /// Record an `update` audit, diffing `old` (the prior state) against `self` (the new state).
    /// Call *before* persisting the change if you want comment-required to be able to abort it.
    async fn audited_update(&self, backend: &dyn Backend, old: &Self) -> Result<Option<Audit>>
    where
        Self: Sized,
    {
        let options = Self::audit_options();
        engine::update(
            backend,
            &engine::meta_for(self, &options),
            old.audited_attributes(),
            self.audited_attributes(),
            None,
        )
        .await
    }

    /// [`Auditable::audited_update`] with an audit comment. A comment alone (no attribute changes)
    /// still writes an audit unless `update_with_comment_only(false)` was set.
    async fn audited_update_with_comment(
        &self,
        backend: &dyn Backend,
        old: &Self,
        comment: impl Into<String> + Send,
    ) -> Result<Option<Audit>>
    where
        Self: Sized,
    {
        let options = Self::audit_options();
        engine::update(
            backend,
            &engine::meta_for(self, &options),
            old.audited_attributes(),
            self.audited_attributes(),
            Some(comment.into()),
        )
        .await
    }

    /// Record a `destroy` audit from the record's current snapshot. Call *before* deleting.
    async fn audited_destroy(&self, backend: &dyn Backend) -> Result<Option<Audit>>
    where
        Self: Sized,
    {
        let options = Self::audit_options();
        engine::destroy(
            backend,
            &engine::meta_for(self, &options),
            self.audited_attributes(),
            None,
        )
        .await
    }

    /// [`Auditable::audited_destroy`] with an audit comment.
    async fn audited_destroy_with_comment(
        &self,
        backend: &dyn Backend,
        comment: impl Into<String> + Send,
    ) -> Result<Option<Audit>>
    where
        Self: Sized,
    {
        let options = Self::audit_options();
        engine::destroy(
            backend,
            &engine::meta_for(self, &options),
            self.audited_attributes(),
            Some(comment.into()),
        )
        .await
    }

    // ----- querying audits -----

    /// All audits for the given record id, ordered by `version` ascending.
    async fn audits(backend: &dyn Backend, id: impl Into<AuditId> + Send) -> Result<Vec<Audit>>
    where
        Self: Sized,
    {
        let q = AuditQuery::default();
        backend
            .audits_for_auditable(Self::auditable_type(), &id.into(), &q)
            .await
    }

    /// Start a scoped query (`.creates()`, `.updates()`, `.from_version(..)`, `.descending()`, …).
    fn query(backend: &dyn Backend, id: impl Into<AuditId>) -> AuditQueryBuilder<'_>
    where
        Self: Sized,
    {
        AuditQueryBuilder::new(
            backend,
            Self::auditable_type().to_string(),
            id.into(),
            false,
        )
    }

    /// Start a scoped query over the audits *associated with* this record (see
    /// [`Auditable::associated_audits`]).
    fn associated_query(backend: &dyn Backend, id: impl Into<AuditId>) -> AuditQueryBuilder<'_>
    where
        Self: Sized,
    {
        AuditQueryBuilder::new(backend, Self::auditable_type().to_string(), id.into(), true)
    }

    /// Audits whose `associated` points at this record.
    async fn associated_audits(
        backend: &dyn Backend,
        id: impl Into<AuditId> + Send,
    ) -> Result<Vec<Audit>>
    where
        Self: Sized,
    {
        Self::associated_query(backend, id).fetch().await
    }

    /// Union of this record's own audits and its associated audits, ordered by `created_at`
    /// descending.
    async fn own_and_associated_audits(
        backend: &dyn Backend,
        id: impl Into<AuditId> + Send,
    ) -> Result<Vec<Audit>>
    where
        Self: Sized,
    {
        backend
            .own_and_associated_audits(Self::auditable_type(), &id.into())
            .await
    }

    // ----- revisions -----

    /// All revisions (one per audit), from version 1.
    async fn revisions(
        backend: &dyn Backend,
        id: impl Into<AuditId> + Send,
    ) -> Result<Vec<Revision>>
    where
        Self: Sized,
    {
        Self::revisions_from(backend, id, 1).await
    }

    /// All revisions at/after `from_version`.
    async fn revisions_from(
        backend: &dyn Backend,
        id: impl Into<AuditId> + Send,
        from_version: i32,
    ) -> Result<Vec<Revision>>
    where
        Self: Sized,
    {
        let all = Self::audits(backend, id).await?;
        Ok(revision::revisions(&all, from_version))
    }

    /// The revision at a specific version, or `None` if `version` exceeds the latest.
    async fn revision(
        backend: &dyn Backend,
        id: impl Into<AuditId> + Send,
        version: i32,
    ) -> Result<Option<Revision>>
    where
        Self: Sized,
    {
        let all = Self::audits(backend, id).await?;
        Ok(revision::revision_at_version(&all, version))
    }

    /// The previous revision (second-most-recent version), or `None` if there is no history.
    async fn revision_previous(
        backend: &dyn Backend,
        id: impl Into<AuditId> + Send,
    ) -> Result<Option<Revision>>
    where
        Self: Sized,
    {
        let all = Self::audits(backend, id).await?;
        Ok(revision::revision_previous(&all))
    }

    /// The latest revision at/before `time`, or `None` if `time` precedes all audits.
    async fn revision_at(
        backend: &dyn Backend,
        id: impl Into<AuditId> + Send,
        time: DateTime<Utc>,
    ) -> Result<Option<Revision>>
    where
        Self: Sized,
    {
        let q = AuditQuery {
            up_until: Some(time),
            ..Default::default()
        };
        let audits = backend
            .audits_for_auditable(Self::auditable_type(), &id.into(), &q)
            .await?;
        Ok(revision::revision_up_until(&audits))
    }

    // ----- enable / disable -----

    /// Persistently disable auditing for this type.
    fn disable_auditing()
    where
        Self: Sized,
    {
        config::set_type_enabled(Self::auditable_type(), false);
    }

    /// Persistently re-enable auditing for this type.
    fn enable_auditing()
    where
        Self: Sized,
    {
        config::set_type_enabled(Self::auditable_type(), true);
    }

    /// Whether auditing is currently enabled for this type (global master switch AND the type's
    /// persistent flag). Scope guards (`without_auditing`/`with_auditing`) are not reflected here
    /// since they are task-local.
    fn auditing_enabled() -> bool
    where
        Self: Sized,
    {
        config::auditing_enabled() && config::type_enabled(Self::auditable_type())
    }
}