tablo_core/schema/
relationship.rs1use toasty::stmt::{Expr, List, OrderByExpr, Query};
8use topcoat::{Result, context::Cx};
9
10use crate::BoxFuture;
11
12pub trait OptionSource: Sized + Send + Sync + 'static {
21 type Model: toasty::schema::Model + toasty::stmt::IntoExpr<Self::Model> + Send + Sync + 'static;
23
24 fn scoped_query(cx: &Cx) -> Result<Query<List<Self::Model>>>;
27
28 fn lists(_cx: &Cx) -> bool {
32 false
33 }
34
35 fn requires_tenant(_cx: &Cx) -> bool {
42 false
43 }
44
45 fn search_expr(_cx: &Cx, _term: &str) -> Option<Expr<bool>> {
48 None
49 }
50
51 fn order_by(_cx: &Cx) -> Option<OrderByExpr> {
53 None
54 }
55
56 fn label(cx: &Cx, record: &Self::Model) -> String;
63
64 #[doc(hidden)]
67 fn available(_cx: &Cx) -> bool {
68 true
69 }
70
71 #[doc(hidden)]
74 fn hidden_fields(_cx: &Cx) -> Vec<String> {
75 Vec::new()
76 }
77
78 #[doc(hidden)]
80 fn creates() -> bool {
81 false
82 }
83
84 #[doc(hidden)]
87 fn create_page(_cx: &Cx) -> Option<String> {
88 None
89 }
90}
91
92#[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
127pub(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
142fn 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
158pub const MAX_RELATIONSHIP_OPTIONS: usize = 200;
160
161#[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
184async 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
218pub(crate) const MAX_QUERY_TERM: usize = 128;
221
222pub(crate) fn clamp_query_term(term: &str) -> String {
225 term.trim().chars().take(MAX_QUERY_TERM).collect()
226}
227
228pub(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
264pub(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#[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;