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