Skip to main content

tablo_core/schema/
relationship.rs

1//! Relationship option loading — bounded, memoized, policy-checked.
2//!
3//! Every relationship choice field over one source shares a single bounded load per `(request,
4//! tenant)` and fails closed instead of leaking labels.
5
6use toasty::stmt::{Expr, List, OrderByExpr, Query};
7use topcoat::{Result, context::Cx};
8
9use crate::policy::Ability;
10
11/// Describes the source a relationship option loader reads.
12///
13/// Every [`Resource`](crate::Resource) is one, answering from its def as the request's panel
14/// mounted it.
15pub trait OptionSource: Sized + Send + Sync + 'static {
16    /// The model whose rows become options.
17    type Model: toasty::schema::Model + toasty::stmt::IntoExpr<Self::Model> + Send + Sync + 'static;
18
19    /// States the tenant-scoped seed query every option load starts from and reports an unscopable
20    /// source as misdeclared.
21    fn scoped_query(cx: &Cx) -> Result<Query<List<Self::Model>>>;
22
23    /// Whether the current user may see the rows `ability` names; refusing `ViewAny` fails the
24    /// whole load closed. Defaults to refusing everything.
25    fn allows(_cx: &Cx, _ability: Ability<'_, Self::Model>) -> bool {
26        false
27    }
28
29    /// Declares whether the source's rows are tenant-owned and fails a tenantless request closed.
30    ///
31    /// A [`Resource`](crate::Resource) answers from its def's
32    /// [`tenancy`](crate::ResourceDef::tenancy). An implementor that is not one declares no def and
33    /// no tenant lens the framework could read, so it states the boolean and scopes
34    /// [`scoped_query`](Self::scoped_query) itself.
35    fn requires_tenant(_cx: &Cx) -> bool {
36        false
37    }
38
39    /// States the related source's search predicate for `term`, or `None` when it declares no
40    /// searchable column.
41    fn search_expr(_cx: &Cx, _term: &str) -> Option<Expr<bool>> {
42        None
43    }
44
45    /// States the related source's declared default ordering.
46    fn order_by(_cx: &Cx) -> Option<OrderByExpr> {
47        None
48    }
49
50    /// Whether the request's panel can load from the source: a resource only when the panel
51    /// mounts it.
52    #[doc(hidden)]
53    fn available(_cx: &Cx) -> bool {
54        true
55    }
56}
57
58/// Distinguishes a policy denial and an over-cap table from a retryable load failure so validation
59/// answers correctly.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub(crate) enum OptionLoadError {
62    Denied,
63    LoadFailed,
64    Overflow,
65    Misdeclared,
66}
67
68pub(crate) type RelationshipLoadFuture = std::pin::Pin<
69    Box<dyn std::future::Future<Output = Result<Vec<(String, String)>, OptionLoadError>> + Send>,
70>;
71
72#[allow(clippy::type_complexity)]
73pub(crate) type RelationshipLoader =
74    std::sync::Arc<dyn Fn(&Cx) -> RelationshipLoadFuture + Send + Sync>;
75
76#[allow(clippy::type_complexity)]
77pub(crate) type RelationshipSearchLoader =
78    std::sync::Arc<dyn Fn(&Cx, String) -> RelationshipLoadFuture + Send + Sync>;
79
80pub(crate) type RelationshipCheckFuture<'a> = std::pin::Pin<
81    Box<dyn std::future::Future<Output = Result<RelatedCheck, OptionLoadError>> + Send + 'a>,
82>;
83
84#[allow(clippy::type_complexity)]
85pub(crate) type RelationshipChecker = std::sync::Arc<
86    dyn for<'a> Fn(&'a Cx, String, &'a mut dyn toasty::Executor) -> RelationshipCheckFuture<'a>
87        + Send
88        + Sync,
89>;
90
91/// Refuses the option load closed when `ViewAny` is refused or a tenant-owned source gets a
92/// tenantless request.
93fn ensure_option_access<R>(cx: &Cx) -> Result<(), OptionLoadError>
94where
95    R: OptionSource,
96{
97    if !R::allows(cx, Ability::ViewAny) {
98        return Err(OptionLoadError::Denied);
99    }
100    if R::requires_tenant(cx) && crate::tenancy::tenant_id(cx).is_none() {
101        return Err(OptionLoadError::Denied);
102    }
103    Ok(())
104}
105
106/// Starts every option loader from the source's tenant-scoped seed query and reports an unscopable
107/// source as misdeclared.
108fn option_query<R>(cx: &Cx) -> Result<Query<List<R::Model>>, OptionLoadError>
109where
110    R: OptionSource,
111{
112    R::scoped_query(cx).map_err(|error| {
113        tracing::error!(
114            resource = std::any::type_name::<R>(),
115            error = %error,
116            "relationship option load cannot scope the related resource"
117        );
118        OptionLoadError::Misdeclared
119    })
120}
121
122/// Caps the options a relationship choice field loads instead of scanning a large table per select.
123pub const MAX_RELATIONSHIP_OPTIONS: usize = 200;
124
125/// Loads option records for one related resource, memoized per request, and checks the cap on the
126/// raw fetch before filtering rows through `View`.
127#[topcoat::context::memoize(as_ref)]
128pub(crate) async fn related_records<R>(
129    cx: &Cx,
130    _tenant: Option<uuid::Uuid>,
131) -> Result<Vec<R::Model>, OptionLoadError>
132where
133    R: OptionSource,
134{
135    ensure_option_access::<R>(cx)?;
136    let mut query = option_query::<R>(cx)?;
137    if let Some(ord) = R::order_by(cx) {
138        query = query.order_by(ord);
139    }
140    bounded_options::<R>(
141        cx,
142        query,
143        "relationship option load failed",
144        "relationship option table overflows the cap",
145    )
146    .await
147}
148
149/// Fetches one row past the cap, refuses a set over the cap with `Overflow`, and drops rows the
150/// caller cannot view.
151async fn bounded_options<R>(
152    cx: &Cx,
153    query: Query<List<R::Model>>,
154    failed: &str,
155    overflow: &str,
156) -> Result<Vec<R::Model>, OptionLoadError>
157where
158    R: OptionSource,
159{
160    let mut db = crate::db::db(cx);
161    let mut records = query
162        .limit(MAX_RELATIONSHIP_OPTIONS + 1)
163        .exec(&mut db)
164        .await
165        .map_err(|e| {
166            tracing::warn!(
167                resource = std::any::type_name::<R>(),
168                error = %e,
169                "{failed}"
170            );
171            OptionLoadError::LoadFailed
172        })?;
173    if records.len() > MAX_RELATIONSHIP_OPTIONS {
174        tracing::warn!(
175            resource = std::any::type_name::<R>(),
176            max = MAX_RELATIONSHIP_OPTIONS,
177            "{overflow}"
178        );
179        return Err(OptionLoadError::Overflow);
180    }
181    records.retain(|record| R::allows(cx, Ability::View(record)));
182    Ok(records)
183}
184
185/// Searches the related table's declared searchable columns in one bounded round-trip and fails
186/// past the cap with `Overflow`.
187pub(crate) async fn related_records_search<R>(
188    cx: &Cx,
189    q: String,
190) -> Result<Vec<R::Model>, OptionLoadError>
191where
192    R: OptionSource,
193{
194    ensure_option_access::<R>(cx)?;
195    let term = crate::query_term::clamp_query_term(&q);
196    let mut query = option_query::<R>(cx)?;
197    if !term.is_empty()
198        && let Some(expr) = R::search_expr(cx, &term)
199    {
200        query = query.filter(expr);
201    }
202    if let Some(ord) = R::order_by(cx) {
203        query = query.order_by(ord);
204    }
205    bounded_options::<R>(
206        cx,
207        query,
208        "relationship option search failed",
209        "relationship option search overflows the cap",
210    )
211    .await
212}
213
214/// Reports the outcome of the targeted existence check for overflowed selects.
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub(crate) enum RelatedCheck {
217    FoundViewable,
218    FoundHidden,
219    NotFound,
220}
221
222/// Checks one submitted key through the given executor against the tenant-scoped query and `View`.
223pub(crate) async fn related_record_check<R>(
224    cx: &Cx,
225    value: String,
226    ex: &mut dyn toasty::Executor,
227) -> Result<RelatedCheck, OptionLoadError>
228where
229    R: OptionSource,
230{
231    ensure_option_access::<R>(cx)?;
232    let trimmed = value.trim();
233    let Some(expr) = crate::toasty_compat::pk::pk_eq_expr::<R::Model>(trimmed) else {
234        return Ok(RelatedCheck::NotFound);
235    };
236    let row = option_query::<R>(cx)?
237        .filter(expr)
238        .first()
239        .exec(ex)
240        .await
241        .map_err(|e| {
242            tracing::warn!(
243                resource = std::any::type_name::<R>(),
244                error = %e,
245                "relationship option check failed"
246            );
247            OptionLoadError::LoadFailed
248        })?;
249    match row {
250        None => Ok(RelatedCheck::NotFound),
251        Some(record) => {
252            if R::allows(cx, Ability::View(&record)) {
253                Ok(RelatedCheck::FoundViewable)
254            } else {
255                Ok(RelatedCheck::FoundHidden)
256            }
257        }
258    }
259}
260
261#[cfg(test)]
262mod tests;