arcature-data 2026.2.0

Arcature high-level data layer: explicit-ownership model/query ergonomics over SeaORM/SQLx, N+1 detection, and migration lint.
Documentation
//! The N+1 detection report: per-relation findings with source attribution.
//!
//! [`Report`] is the analyzer output. It carries [`Finding`]s, each naming
//! the relation whose access caused a per-row re-query (the actual N+1 bug
//! class), the parent query that loaded the rows, how many per-row queries it
//! triggered, and a concrete recommendation to eager-load instead.

use std::fmt;

use serde::Serialize;

/// A classification of how a relation access triggered N+1.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum FindingKind {
    /// A parent query loaded N rows, then N per-row child queries accessed the
    /// same relation on each row (the textbook N+1).
    RelationPerRow,
}

/// One detected N+1 finding, with source attribution.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Finding {
    /// The relation whose access caused the per-row re-query (e.g.
    /// `"User.posts"` — `parent_relation.child_relation`).
    pub relation: String,
    /// The kind of N+1 pattern detected.
    pub kind: FindingKind,
    /// The signature of the parent query that loaded the rows (e.g.
    /// `"SELECT posts WHERE user_id = $1"` or the caller-supplied source).
    pub parent_query: String,
    /// The number of per-row child queries fired after the parent query.
    pub child_queries: usize,
    /// A concrete recommendation to fix the N+1 (eager-load this relation).
    pub recommendation: String,
}

/// The analyzer output: all N+1 findings for a request scope.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct Report {
    /// The detected N+1 findings, in detection order.
    pub findings: Vec<Finding>,
}

impl Report {
    /// `true` when at least one N+1 finding was detected.
    #[must_use]
    pub fn has_findings(&self) -> bool {
        !self.findings.is_empty()
    }

    /// The number of detected findings.
    #[must_use]
    pub fn len(&self) -> usize {
        self.findings.len()
    }

    /// `true` when there are no findings.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.findings.is_empty()
    }
}

impl fmt::Display for Report {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        if self.findings.is_empty() {
            return write!(formatter, "no N+1 findings");
        }
        writeln!(formatter, "{} N+1 finding(s):", self.findings.len())?;
        for (index, finding) in self.findings.iter().enumerate() {
            writeln!(
                formatter,
                "  {index}. relation `{}`: {} per-row child queries after parent query `{}`",
                finding.relation, finding.child_queries, finding.parent_query
            )?;
            writeln!(formatter, "     recommendation: {}", finding.recommendation)?;
        }
        Ok(())
    }
}