#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum RelationKind {
FK,
O2O,
M2M,
}
#[derive(Debug, Clone, Copy)]
#[doc(hidden)]
pub struct ReverseRelationMarker {
pub(crate) kind: RelationKind,
pub(crate) source: &'static str,
pub(crate) name: &'static str,
pub(crate) target: &'static str,
pub(crate) via: &'static str,
}
impl ReverseRelationMarker {
#[inline]
pub fn kind(&self) -> RelationKind {
self.kind
}
#[inline]
pub fn source(&self) -> &'static str {
self.source
}
#[inline]
pub fn name(&self) -> &'static str {
self.name
}
#[inline]
pub fn target(&self) -> &'static str {
self.target
}
#[inline]
pub fn via(&self) -> &'static str {
self.via
}
}
::inventory::collect!(ReverseRelationMarker);
#[derive(Debug, Clone, thiserror::Error)]
#[non_exhaustive]
pub enum RelationRegistryError {
#[error("relation-accessor collisions detected:\n{}", .0.iter().map(ToString::to_string).collect::<Vec<_>>().join(""))]
AccessorCollisions(Vec<RelationAccessorCollision>),
}
#[derive(Debug, Clone)]
pub struct RelationAccessorCollision {
pub source: &'static str,
pub name: &'static str,
pub markers: Vec<ReverseRelationMarker>,
}
impl std::fmt::Display for RelationAccessorCollision {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(
f,
" - `{}::{}` is registered by {} markers, but they disagree on kind / target / via:",
self.source,
self.name,
self.markers.len(),
)?;
for m in &self.markers {
writeln!(
f,
" kind={:?}, target={}, via={}",
m.kind(),
m.target(),
m.via(),
)?;
}
writeln!(
f,
" fix: rename one of the accessors so each `(source, name)` pair is \
unique, or align the macro invocations on a single kind/target/via.",
)
}
}
const fn kind_order(k: RelationKind) -> u8 {
match k {
RelationKind::FK => 0,
RelationKind::O2O => 1,
RelationKind::M2M => 2,
}
}
pub fn validate_relation_accessor_collisions<'a, I>(markers: I) -> Result<(), RelationRegistryError>
where
I: IntoIterator<Item = &'a ReverseRelationMarker>,
{
use std::collections::BTreeMap;
let mut by_pair: BTreeMap<(&'static str, &'static str), Vec<ReverseRelationMarker>> =
BTreeMap::new();
for marker in markers {
by_pair
.entry((marker.source(), marker.name()))
.or_default()
.push(*marker);
}
let mut collisions: Vec<RelationAccessorCollision> = Vec::new();
for ((source, name), mut group) in by_pair {
if group.len() < 2 {
continue;
}
let head = &group[0];
let identical = group.iter().all(|m| {
m.kind() == head.kind() && m.target() == head.target() && m.via() == head.via()
});
if identical {
continue;
}
group.sort_by(|l, r| {
(kind_order(l.kind()), l.target(), l.via()).cmp(&(
kind_order(r.kind()),
r.target(),
r.via(),
))
});
collisions.push(RelationAccessorCollision {
source,
name,
markers: group,
});
}
if collisions.is_empty() {
Ok(())
} else {
Err(RelationRegistryError::AccessorCollisions(collisions))
}
}
pub fn validate_global_relation_accessor_registry() -> Result<(), RelationRegistryError> {
validate_relation_accessor_collisions(::inventory::iter::<ReverseRelationMarker>())
}
#[doc(hidden)]
pub mod __macro_support {
use super::{RelationKind, ReverseRelationMarker};
use crate::ident::{const_assert_plain_ident, const_assert_user_supplied_ident};
#[doc(hidden)]
pub const fn __make_reverse_relation_marker(
kind: RelationKind,
source: &'static str,
name: &'static str,
target: &'static str,
via: &'static str,
) -> ReverseRelationMarker {
const_assert_user_supplied_ident(name, "reverse_relation_name");
const_assert_user_supplied_ident(via, "reverse_relation_via");
ReverseRelationMarker {
kind,
source,
name,
target,
via,
}
}
#[doc(hidden)]
pub const fn __const_assert_plain_ident(value: &'static str, role: &'static str) {
const_assert_plain_ident(value, role);
}
#[doc(hidden)]
pub const fn __const_assert_user_supplied_ident(value: &'static str, role: &'static str) {
const_assert_user_supplied_ident(value, role);
}
#[cfg(test)]
mod tests {
use super::*;
fn try_make(
name: &'static str,
via: &'static str,
) -> std::thread::Result<ReverseRelationMarker> {
std::panic::catch_unwind(|| {
__make_reverse_relation_marker(RelationKind::FK, "Owner", name, "Vehicle", via)
})
}
fn try_const_assert(value: &'static str) -> std::thread::Result<()> {
std::panic::catch_unwind(|| __const_assert_plain_ident(value, "test_role"))
}
fn try_const_assert_user(value: &'static str) -> std::thread::Result<()> {
std::panic::catch_unwind(|| __const_assert_user_supplied_ident(value, "test_role"))
}
#[test]
fn accepts_plain_identifiers() {
let marker = __make_reverse_relation_marker(
RelationKind::FK,
"Owner",
"cars",
"Vehicle",
"owner_id",
);
assert_eq!(marker.name(), "cars");
assert_eq!(marker.via(), "owner_id");
assert_eq!(marker.source(), "Owner");
assert_eq!(marker.target(), "Vehicle");
assert_eq!(marker.kind(), RelationKind::FK);
}
#[test]
fn rejects_bad_name() {
assert!(try_make("1bad", "owner_id").is_err());
}
#[test]
fn rejects_bad_via() {
assert!(try_make("cars", "col) OR 1=1 --").is_err());
}
#[test]
fn rejects_reserved_via_keyword() {
assert!(try_make("cars", "select").is_err());
}
#[test]
fn rejects_reserved_djogi_prefix_in_name_and_via() {
assert!(try_make("__djogi_cars", "owner_id").is_err());
assert!(try_make("__DJOGI_cars", "owner_id").is_err());
assert!(try_make("cars", "__djogi_owner_id").is_err());
assert!(try_make("cars", "__Djogi_owner_id").is_err());
}
#[test]
fn const_assert_wrapper_rejects_reserved_keywords() {
assert!(try_const_assert("owner_id").is_ok());
assert!(try_const_assert("select").is_err());
}
#[test]
fn const_user_assert_wrapper_rejects_reserved_djogi_prefix() {
assert!(try_const_assert_user("owner_id").is_ok());
assert!(try_const_assert_user("__djogi_owner_id").is_err());
assert!(try_const_assert_user("__DJOGI_owner_id").is_err());
assert!(try_const_assert_user("_djogi_owner_id").is_ok());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
::inventory::submit! {
crate::relation::registry::__macro_support::__make_reverse_relation_marker(
RelationKind::FK,
"TestSource",
"test_accessor",
"TestTarget",
"test_via_id",
)
}
#[test]
fn relation_kind_is_copy() {
fn assert_copy<T: Copy>() {}
assert_copy::<RelationKind>();
assert_copy::<ReverseRelationMarker>();
}
#[test]
fn relation_kind_variants_are_distinct() {
assert_ne!(RelationKind::FK, RelationKind::O2O);
assert_ne!(RelationKind::O2O, RelationKind::M2M);
assert_ne!(RelationKind::FK, RelationKind::M2M);
}
#[test]
fn inventory_collects_test_marker() {
let mut seen = false;
for marker in ::inventory::iter::<ReverseRelationMarker> {
if marker.source() == "TestSource" && marker.name() == "test_accessor" {
assert_eq!(marker.kind(), RelationKind::FK);
assert_eq!(marker.target(), "TestTarget");
assert_eq!(marker.via(), "test_via_id");
seen = true;
}
}
assert!(
seen,
"inventory::iter<ReverseRelationMarker> did not surface the test marker — \
either linkage dropped it or the submit! block expanded without registering."
);
}
fn make(
kind: RelationKind,
source: &'static str,
name: &'static str,
target: &'static str,
via: &'static str,
) -> ReverseRelationMarker {
super::__macro_support::__make_reverse_relation_marker(kind, source, name, target, via)
}
#[test]
fn validator_accepts_empty_input() {
let markers: [ReverseRelationMarker; 0] = [];
assert!(validate_relation_accessor_collisions(markers.iter()).is_ok());
}
#[test]
fn validator_accepts_unrelated_markers() {
let markers = [
make(RelationKind::FK, "Owner", "cars", "Vehicle", "owner_id"),
make(RelationKind::M2M, "Person", "groups", "Group", "person_id"),
make(RelationKind::O2O, "User", "profile", "Profile", "user_id"),
];
assert!(validate_relation_accessor_collisions(markers.iter()).is_ok());
}
#[test]
fn validator_tolerates_identical_duplicates() {
let m = make(RelationKind::FK, "Owner", "cars", "Vehicle", "owner_id");
let markers = [m, m];
assert!(validate_relation_accessor_collisions(markers.iter()).is_ok());
}
#[test]
fn validator_flags_cross_kind_fk_vs_m2m() {
let markers = [
make(RelationKind::FK, "Owner", "cars", "Vehicle", "owner_id"),
make(RelationKind::M2M, "Owner", "cars", "Garage", "owner_id"),
];
let err = validate_relation_accessor_collisions(markers.iter())
.expect_err("FK + M2M with the same (source, name) must collide");
let RelationRegistryError::AccessorCollisions(collisions) = err;
assert_eq!(collisions.len(), 1);
let c = &collisions[0];
assert_eq!(c.source, "Owner");
assert_eq!(c.name, "cars");
assert_eq!(c.markers.len(), 2);
}
#[test]
fn validator_flags_cross_kind_o2o_vs_m2m() {
let markers = [
make(RelationKind::O2O, "User", "profile", "Profile", "user_id"),
make(RelationKind::M2M, "User", "profile", "Avatar", "user_id"),
];
let err = validate_relation_accessor_collisions(markers.iter())
.expect_err("O2O + M2M with the same (source, name) must collide");
let RelationRegistryError::AccessorCollisions(collisions) = err;
assert_eq!(collisions.len(), 1);
assert_eq!(collisions[0].markers.len(), 2);
}
#[test]
fn validator_flags_same_kind_different_target() {
let markers = [
make(RelationKind::FK, "Owner", "cars", "Vehicle", "owner_id"),
make(RelationKind::FK, "Owner", "cars", "Truck", "owner_id"),
];
let err = validate_relation_accessor_collisions(markers.iter())
.expect_err("same (source, name) but different target must collide");
let RelationRegistryError::AccessorCollisions(collisions) = err;
assert_eq!(collisions.len(), 1);
}
#[test]
fn validator_flags_same_kind_different_via() {
let markers = [
make(RelationKind::FK, "Owner", "cars", "Vehicle", "owner_id"),
make(RelationKind::FK, "Owner", "cars", "Vehicle", "old_owner_id"),
];
let err = validate_relation_accessor_collisions(markers.iter())
.expect_err("same (source, name, target) but different via must collide");
let RelationRegistryError::AccessorCollisions(collisions) = err;
assert_eq!(collisions.len(), 1);
}
#[test]
fn validator_reports_multiple_collisions_in_one_pass() {
let markers = [
make(RelationKind::FK, "A", "x", "Vehicle", "a_id"),
make(RelationKind::M2M, "A", "x", "Garage", "a_id"),
make(RelationKind::O2O, "B", "y", "Vehicle", "b_id"),
make(RelationKind::M2M, "B", "y", "Garage", "b_id"),
make(RelationKind::FK, "C", "z", "Vehicle", "c_id"), ];
let err = validate_relation_accessor_collisions(markers.iter()).unwrap_err();
let RelationRegistryError::AccessorCollisions(mut collisions) = err;
collisions.sort_by(|l, r| (l.source, l.name).cmp(&(r.source, r.name)));
assert_eq!(collisions.len(), 2);
assert_eq!((collisions[0].source, collisions[0].name), ("A", "x"));
assert_eq!((collisions[1].source, collisions[1].name), ("B", "y"));
}
#[test]
fn validator_diagnostic_ordering_is_deterministic() {
let a = [
make(RelationKind::FK, "B", "y", "Vehicle", "b_id"),
make(RelationKind::M2M, "B", "y", "Garage", "b_id"),
make(RelationKind::FK, "A", "x", "Vehicle", "a_id"),
make(RelationKind::M2M, "A", "x", "Garage", "a_id"),
];
let b = [
make(RelationKind::M2M, "A", "x", "Garage", "a_id"),
make(RelationKind::FK, "A", "x", "Vehicle", "a_id"),
make(RelationKind::M2M, "B", "y", "Garage", "b_id"),
make(RelationKind::FK, "B", "y", "Vehicle", "b_id"),
];
let err_a = validate_relation_accessor_collisions(a.iter())
.unwrap_err()
.to_string();
let err_b = validate_relation_accessor_collisions(b.iter())
.unwrap_err()
.to_string();
assert_eq!(err_a, err_b);
}
#[test]
fn validator_error_display_mentions_source_name_and_kinds() {
let markers = [
make(RelationKind::FK, "Owner", "cars", "Vehicle", "owner_id"),
make(RelationKind::M2M, "Owner", "cars", "Garage", "owner_id"),
];
let msg = validate_relation_accessor_collisions(markers.iter())
.unwrap_err()
.to_string();
assert!(msg.contains("Owner"), "missing source: {msg}");
assert!(msg.contains("cars"), "missing accessor name: {msg}");
assert!(msg.contains("FK"), "missing FK kind: {msg}");
assert!(msg.contains("M2M"), "missing M2M kind: {msg}");
assert!(msg.contains("Vehicle"), "missing FK target: {msg}");
assert!(msg.contains("Garage"), "missing M2M target: {msg}");
}
#[test]
fn validator_inventory_walk_compiles() {
let _ = validate_relation_accessor_collisions(::inventory::iter::<ReverseRelationMarker>());
}
#[test]
fn validate_global_relation_accessor_registry_is_zero_arg_shortcut() {
let direct =
validate_relation_accessor_collisions(::inventory::iter::<ReverseRelationMarker>());
let via_helper = validate_global_relation_accessor_registry();
assert_eq!(direct.is_ok(), via_helper.is_ok());
if let (Err(d), Err(h)) = (&direct, &via_helper) {
assert_eq!(d.to_string(), h.to_string());
}
}
}