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. A choice offers the rows the source's
5//! list shows: its scoped query's, when its policy allows listing them.
6
7use toasty::stmt::{Expr, List, OrderByExpr, Query};
8use topcoat::{Result, context::Cx};
9
10use crate::BoxFuture;
11
12/// Describes the source a relationship option loader reads.
13///
14/// Every [`Resource`](crate::Resource) is one, answering from its def as the context's panel
15/// mounted it.
16///
17/// Every relationship choice over one source shares a single load per request and tenant,
18/// bounded to [`MAX_RELATIONSHIP_OPTIONS`] rows of its [`scoped_query`](Self::scoped_query). A
19/// load the source refuses fails closed: the choice offers nothing rather than leak a label.
20pub trait OptionSource: Sized + Send + Sync + 'static {
21    /// The model whose rows become options.
22    type Model: toasty::schema::Model + toasty::stmt::IntoExpr<Self::Model> + Send + Sync + 'static;
23
24    /// States the tenant-scoped seed query every option load starts from and reports an unscopable
25    /// source as misdeclared.
26    fn scoped_query(cx: &Cx) -> Result<Query<List<Self::Model>>>;
27
28    /// Whether the current user may list the source's rows; refusing fails every load closed.
29    /// Defaults to refusing. The rows the user may not see belong out of
30    /// [`scoped_query`](Self::scoped_query).
31    fn lists(_cx: &Cx) -> bool {
32        false
33    }
34
35    /// Declares whether the source's rows are tenant-owned and fails a tenantless request closed.
36    ///
37    /// A [`Resource`](crate::Resource) answers from its def's
38    /// [`tenancy`](crate::ResourceDef::tenancy). An implementor that is not one declares no def and
39    /// no tenant lens the framework could read, so it states the boolean and scopes
40    /// [`scoped_query`](Self::scoped_query) itself.
41    fn requires_tenant(_cx: &Cx) -> bool {
42        false
43    }
44
45    /// States the related source's search predicate for `term`, or `None` when it declares no
46    /// searchable column.
47    fn search_expr(_cx: &Cx, _term: &str) -> Option<Expr<bool>> {
48        None
49    }
50
51    /// States the related source's declared default ordering.
52    fn order_by(_cx: &Cx) -> Option<OrderByExpr> {
53        None
54    }
55
56    /// Labels `record` wherever a relationship choice offers it or a
57    /// [`RelationColumn::of`](crate::RelationColumn::of) shows it.
58    ///
59    /// A [`Resource`](crate::Resource) answers with its def's
60    /// [`record_title`](crate::ResourceDef::record_title), else its label and the record's key.
61    /// The record is loaded without its relations.
62    fn label(cx: &Cx, record: &Self::Model) -> String;
63
64    /// Whether the context's panel can load from the source: a resource only when the panel
65    /// mounts it.
66    #[doc(hidden)]
67    fn available(_cx: &Cx) -> bool {
68        true
69    }
70
71    /// The root fields of the source's records the request may not view: for a resource, the
72    /// ones the panel's [`field_access`](crate::ResourceDef::field_access) hides from the request.
73    #[doc(hidden)]
74    fn hidden_fields(_cx: &Cx) -> Vec<String> {
75        Vec::new()
76    }
77
78    /// Whether the source has a create page a choice could open: only a resource does.
79    #[doc(hidden)]
80    fn creates() -> bool {
81        false
82    }
83
84    /// The source's create page, when the context's panel mounts it with one and lets the user
85    /// create.
86    #[doc(hidden)]
87    fn create_page(_cx: &Cx) -> Option<String> {
88        None
89    }
90}
91
92/// Distinguishes a policy denial and an over-cap table from a retryable load failure so validation
93/// answers correctly.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub(crate) enum OptionLoadError {
96    Denied,
97    LoadFailed,
98    Overflow,
99    Misdeclared,
100}
101
102pub(crate) type RelationshipLoader = std::sync::Arc<
103    dyn Fn(&Cx) -> BoxFuture<'static, Result<Vec<(String, String)>, OptionLoadError>> + Send + Sync,
104>;
105
106pub(crate) type RelationshipSearchLoader = std::sync::Arc<
107    dyn Fn(
108            &Cx,
109            String,
110            Option<Expr<bool>>,
111        ) -> BoxFuture<'static, Result<Vec<(String, String)>, OptionLoadError>>
112        + Send
113        + Sync,
114>;
115
116pub(crate) type RelationshipChecker = std::sync::Arc<
117    dyn for<'a> Fn(
118            &'a Cx,
119            String,
120            Option<Expr<bool>>,
121            &'a mut dyn toasty::Executor,
122        ) -> BoxFuture<'a, Result<bool, OptionLoadError>>
123        + Send
124        + Sync,
125>;
126
127/// Refuses the option load closed when the source refuses the list or a tenant-owned source gets
128/// a tenantless request.
129pub(crate) fn ensure_option_access<R>(cx: &Cx) -> Result<(), OptionLoadError>
130where
131    R: OptionSource,
132{
133    if !R::lists(cx) {
134        return Err(OptionLoadError::Denied);
135    }
136    if R::requires_tenant(cx) && crate::tenancy::tenant_id(cx).is_none() {
137        return Err(OptionLoadError::Denied);
138    }
139    Ok(())
140}
141
142/// Starts every option loader from the source's tenant-scoped seed query and reports an unscopable
143/// source as misdeclared.
144fn option_query<R>(cx: &Cx) -> Result<Query<List<R::Model>>, OptionLoadError>
145where
146    R: OptionSource,
147{
148    R::scoped_query(cx).map_err(|error| {
149        tracing::error!(
150            resource = std::any::type_name::<R>(),
151            error = %error,
152            "relationship option load cannot scope the related resource"
153        );
154        OptionLoadError::Misdeclared
155    })
156}
157
158/// Caps the options a relationship choice field loads instead of scanning a large table per select.
159pub const MAX_RELATIONSHIP_OPTIONS: usize = 200;
160
161/// Loads option records for one related resource, memoized per request, and checks the cap.
162#[topcoat::context::memoize(as_ref)]
163pub(crate) async fn related_records<R>(
164    cx: &Cx,
165    _tenant: Option<crate::TenantId>,
166) -> Result<Vec<R::Model>, OptionLoadError>
167where
168    R: OptionSource,
169{
170    ensure_option_access::<R>(cx)?;
171    let mut query = option_query::<R>(cx)?;
172    if let Some(ord) = R::order_by(cx) {
173        query = query.order_by(ord);
174    }
175    bounded_options::<R>(
176        cx,
177        query,
178        "relationship option load failed",
179        "relationship option table overflows the cap",
180    )
181    .await
182}
183
184/// Fetches one row past the cap and refuses a set over the cap with `Overflow`.
185async fn bounded_options<R>(
186    cx: &Cx,
187    query: Query<List<R::Model>>,
188    failed: &str,
189    overflow: &str,
190) -> Result<Vec<R::Model>, OptionLoadError>
191where
192    R: OptionSource,
193{
194    let mut db = crate::db::db(cx).map_err(|_| OptionLoadError::Misdeclared)?;
195    let records = query
196        .limit(MAX_RELATIONSHIP_OPTIONS + 1)
197        .exec(&mut db)
198        .await
199        .map_err(|e| {
200            tracing::warn!(
201                resource = std::any::type_name::<R>(),
202                error = %e,
203                "{failed}"
204            );
205            OptionLoadError::LoadFailed
206        })?;
207    if records.len() > MAX_RELATIONSHIP_OPTIONS {
208        tracing::warn!(
209            resource = std::any::type_name::<R>(),
210            max = MAX_RELATIONSHIP_OPTIONS,
211            "{overflow}"
212        );
213        return Err(OptionLoadError::Overflow);
214    }
215    Ok(records)
216}
217
218/// Longest accepted search term, in chars; a relationship's option search and a table's `?q=`
219/// search share it.
220pub(crate) const MAX_QUERY_TERM: usize = 128;
221
222/// Clamps a search term to [`MAX_QUERY_TERM`] chars, so a multibyte term truncates on a char
223/// boundary.
224pub(crate) fn clamp_query_term(term: &str) -> String {
225    term.trim().chars().take(MAX_QUERY_TERM).collect()
226}
227
228/// Searches the related table's declared searchable columns, among the rows `scope` selects, in
229/// one bounded round-trip and fails past the cap with `Overflow`.
230pub(crate) async fn related_records_search<R>(
231    cx: &Cx,
232    q: String,
233    scope: Option<Expr<bool>>,
234) -> Result<Vec<R::Model>, OptionLoadError>
235where
236    R: OptionSource,
237{
238    ensure_option_access::<R>(cx)?;
239    let term = clamp_query_term(&q);
240    let mut query = option_query::<R>(cx)?;
241    if let Some(scope) = scope {
242        query = query.filter(scope);
243    }
244    if !term.is_empty()
245        && let Some(expr) = R::search_expr(cx, &term)
246    {
247        query = query.filter(crate::toasty_compat::case_insensitive_search(
248            &crate::db::db(cx).map_err(|_| OptionLoadError::Misdeclared)?,
249            expr,
250        ));
251    }
252    if let Some(ord) = R::order_by(cx) {
253        query = query.order_by(ord);
254    }
255    bounded_options::<R>(
256        cx,
257        query,
258        "relationship option search failed",
259        "relationship option search overflows the cap",
260    )
261    .await
262}
263
264/// Whether one submitted key names a row of the source's scoped query among the rows `scope`
265/// selects, read through the given executor.
266pub(crate) async fn related_record_check<R>(
267    cx: &Cx,
268    value: String,
269    scope: Option<Expr<bool>>,
270    ex: &mut dyn toasty::Executor,
271) -> Result<bool, OptionLoadError>
272where
273    R: OptionSource,
274{
275    ensure_option_access::<R>(cx)?;
276    let trimmed = value.trim();
277    let Some(expr) = crate::toasty_compat::pk::pk_eq_expr::<R::Model>(trimmed) else {
278        return Ok(false);
279    };
280    let mut query = option_query::<R>(cx)?.filter(expr);
281    if let Some(scope) = scope {
282        query = query.filter(scope);
283    }
284    let row = query.first().exec(ex).await.map_err(|e| {
285        tracing::warn!(
286            resource = std::any::type_name::<R>(),
287            error = %e,
288            "relationship option check failed"
289        );
290        OptionLoadError::LoadFailed
291    })?;
292    Ok(row.is_some())
293}
294
295/// The keys among `keys` naming a row `R`'s scoped query holds, read in one query: what a
296/// relationship filter admits past the cap. Memoized per request, so the list and its filter menu
297/// ask once.
298#[topcoat::context::memoize(as_ref)]
299pub(crate) async fn held_keys<R>(
300    cx: &Cx,
301    _tenant: Option<crate::TenantId>,
302    keys: &[String],
303) -> Result<std::collections::HashSet<String>, OptionLoadError>
304where
305    R: OptionSource,
306{
307    ensure_option_access::<R>(cx)?;
308    let Some(any) = keys
309        .iter()
310        .filter_map(|key| crate::toasty_compat::pk::pk_eq_expr::<R::Model>(key))
311        .reduce(|any, one| any.or(one))
312    else {
313        return Ok(std::collections::HashSet::new());
314    };
315    let rows = option_query::<R>(cx)?
316        .filter(any)
317        .exec(&mut crate::db::db(cx).map_err(|_| OptionLoadError::Misdeclared)?)
318        .await
319        .map_err(|e| {
320            tracing::warn!(
321                resource = std::any::type_name::<R>(),
322                error = %e,
323                "relationship filter check failed"
324            );
325            OptionLoadError::LoadFailed
326        })?;
327    Ok(rows.iter().map(crate::toasty_compat::pk::pk_text).collect())
328}
329
330#[cfg(test)]
331mod tests;