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