cratestack_sql/relation_scope.rs
1//! What a relation subquery may see of its *related* table.
2//!
3//! A relation filter (`EXISTS (SELECT 1 FROM related WHERE ...)`) or a
4//! relation sort (`(SELECT related.col FROM related WHERE ... LIMIT 1)`)
5//! reads the related table directly. Unless that subquery applies the
6//! related model's own read policy and soft-delete filter, a caller can
7//! test and order by values of rows they cannot read — rows `?include=`
8//! correctly reports as `null` / absent (GHSA-p55v-6xv5-93p3).
9//!
10//! Every public constructor of a relation hop, relation filter or
11//! relation sort therefore takes a [`RelatedReadScope`]; there is no
12//! default. Generated code passes the related model's
13//! [`ModelDescriptor::related_read_scope`]. Hand-written code that
14//! genuinely wants the raw table passes [`RelatedReadScope::Unscoped`],
15//! which is the named escape hatch and never the fallback.
16
17use cratestack_policy::ReadPolicy;
18
19use crate::descriptor::ModelDescriptor;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum RelatedReadScope {
23 /// Apply the related model's list-slot read policy (`@@allow`/`@@deny`
24 /// on `read`/`list`) and its `@@soft_delete` filter inside the
25 /// subquery — exactly what `find_many` on that model applies, which
26 /// is also what `?include=` goes through. A related row the caller
27 /// cannot read then behaves as if it did not exist: a to-one filter
28 /// (including `ne` and `isNull`) does not match it, `none`/`every`
29 /// over hidden-only children are vacuously true, and a relation sort
30 /// key reads as `NULL`.
31 ///
32 /// An empty `allow` slice renders `FALSE` (default deny), the same as
33 /// a direct read of a model with no read rule.
34 Policy {
35 allow: &'static [ReadPolicy],
36 deny: &'static [ReadPolicy],
37 /// `Some("deleted_at")` when the related model is `@@soft_delete`.
38 soft_delete_column: Option<&'static str>,
39 },
40 /// Escape hatch: read the related table raw — no policy, no
41 /// soft-delete filter. For trusted server code that deliberately wants
42 /// every related row regardless of the caller. The embedded (rusqlite)
43 /// backend ignores the scope either way. Generated client code (which
44 /// never renders SQL) also uses it. Never use it for a filter or sort
45 /// whose values a caller controls.
46 Unscoped,
47}
48
49impl<M, PK> ModelDescriptor<M, PK> {
50 /// The scope a relation subquery into this model must apply: the same
51 /// list-slot policies and soft-delete filter `find_many` applies.
52 pub const fn related_read_scope(&self) -> RelatedReadScope {
53 RelatedReadScope::Policy {
54 allow: self.read_allow_policies,
55 deny: self.read_deny_policies,
56 soft_delete_column: self.soft_delete_column,
57 }
58 }
59}