1use toasty::stmt::{Expr, List, OrderByExpr, Query};
7use topcoat::{Result, context::Cx};
8
9use crate::{BoxFuture, policy::Ability};
10
11pub trait OptionSource: Sized + Send + Sync + 'static {
20 type Model: toasty::schema::Model + toasty::stmt::IntoExpr<Self::Model> + Send + Sync + 'static;
22
23 fn scoped_query(cx: &Cx) -> Result<Query<List<Self::Model>>>;
26
27 fn allows(_cx: &Cx, _ability: Ability<'_, Self::Model>) -> bool {
30 false
31 }
32
33 fn requires_tenant(_cx: &Cx) -> bool {
40 false
41 }
42
43 fn search_expr(_cx: &Cx, _term: &str) -> Option<Expr<bool>> {
46 None
47 }
48
49 fn order_by(_cx: &Cx) -> Option<OrderByExpr> {
51 None
52 }
53
54 fn label(cx: &Cx, record: &Self::Model) -> String;
61
62 #[doc(hidden)]
65 fn available(_cx: &Cx) -> bool {
66 true
67 }
68
69 #[doc(hidden)]
72 fn hidden_fields(_cx: &Cx) -> Vec<String> {
73 Vec::new()
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
80pub(crate) enum OptionLoadError {
81 Denied,
82 LoadFailed,
83 Overflow,
84 Misdeclared,
85}
86
87pub(crate) type RelationshipLoader = std::sync::Arc<
88 dyn Fn(&Cx) -> BoxFuture<'static, Result<Vec<(String, String)>, OptionLoadError>> + Send + Sync,
89>;
90
91pub(crate) type RelationshipSearchLoader = std::sync::Arc<
92 dyn Fn(
93 &Cx,
94 String,
95 Option<Expr<bool>>,
96 ) -> BoxFuture<'static, Result<Vec<(String, String)>, OptionLoadError>>
97 + Send
98 + Sync,
99>;
100
101pub(crate) type RelationshipChecker = std::sync::Arc<
102 dyn for<'a> Fn(
103 &'a Cx,
104 String,
105 Option<Expr<bool>>,
106 &'a mut dyn toasty::Executor,
107 ) -> BoxFuture<'a, Result<RelatedCheck, OptionLoadError>>
108 + Send
109 + Sync,
110>;
111
112pub(crate) fn ensure_option_access<R>(cx: &Cx) -> Result<(), OptionLoadError>
115where
116 R: OptionSource,
117{
118 if !R::allows(cx, Ability::ViewAny) {
119 return Err(OptionLoadError::Denied);
120 }
121 if R::requires_tenant(cx) && crate::tenancy::tenant_id(cx).is_none() {
122 return Err(OptionLoadError::Denied);
123 }
124 Ok(())
125}
126
127fn option_query<R>(cx: &Cx) -> Result<Query<List<R::Model>>, OptionLoadError>
130where
131 R: OptionSource,
132{
133 R::scoped_query(cx).map_err(|error| {
134 tracing::error!(
135 resource = std::any::type_name::<R>(),
136 error = %error,
137 "relationship option load cannot scope the related resource"
138 );
139 OptionLoadError::Misdeclared
140 })
141}
142
143pub const MAX_RELATIONSHIP_OPTIONS: usize = 200;
145
146#[topcoat::context::memoize(as_ref)]
149pub(crate) async fn related_records<R>(
150 cx: &Cx,
151 _tenant: Option<uuid::Uuid>,
152) -> Result<Vec<R::Model>, OptionLoadError>
153where
154 R: OptionSource,
155{
156 ensure_option_access::<R>(cx)?;
157 let mut query = option_query::<R>(cx)?;
158 if let Some(ord) = R::order_by(cx) {
159 query = query.order_by(ord);
160 }
161 bounded_options::<R>(
162 cx,
163 query,
164 "relationship option load failed",
165 "relationship option table overflows the cap",
166 )
167 .await
168}
169
170async fn bounded_options<R>(
173 cx: &Cx,
174 query: Query<List<R::Model>>,
175 failed: &str,
176 overflow: &str,
177) -> Result<Vec<R::Model>, OptionLoadError>
178where
179 R: OptionSource,
180{
181 let mut db = crate::db::db(cx).map_err(|_| OptionLoadError::Misdeclared)?;
182 let mut records = query
183 .limit(MAX_RELATIONSHIP_OPTIONS + 1)
184 .exec(&mut db)
185 .await
186 .map_err(|e| {
187 tracing::warn!(
188 resource = std::any::type_name::<R>(),
189 error = %e,
190 "{failed}"
191 );
192 OptionLoadError::LoadFailed
193 })?;
194 if records.len() > MAX_RELATIONSHIP_OPTIONS {
195 tracing::warn!(
196 resource = std::any::type_name::<R>(),
197 max = MAX_RELATIONSHIP_OPTIONS,
198 "{overflow}"
199 );
200 return Err(OptionLoadError::Overflow);
201 }
202 records.retain(|record| R::allows(cx, Ability::View(record)));
203 Ok(records)
204}
205
206pub(crate) const MAX_QUERY_TERM: usize = 128;
209
210pub(crate) fn clamp_query_term(term: &str) -> String {
213 term.trim().chars().take(MAX_QUERY_TERM).collect()
214}
215
216pub(crate) async fn related_records_search<R>(
219 cx: &Cx,
220 q: String,
221 scope: Option<Expr<bool>>,
222) -> Result<Vec<R::Model>, OptionLoadError>
223where
224 R: OptionSource,
225{
226 ensure_option_access::<R>(cx)?;
227 let term = clamp_query_term(&q);
228 let mut query = option_query::<R>(cx)?;
229 if let Some(scope) = scope {
230 query = query.filter(scope);
231 }
232 if !term.is_empty()
233 && let Some(expr) = R::search_expr(cx, &term)
234 {
235 query = query.filter(crate::toasty_compat::case_insensitive_search(
236 &crate::db::db(cx).map_err(|_| OptionLoadError::Misdeclared)?,
237 expr,
238 ));
239 }
240 if let Some(ord) = R::order_by(cx) {
241 query = query.order_by(ord);
242 }
243 bounded_options::<R>(
244 cx,
245 query,
246 "relationship option search failed",
247 "relationship option search overflows the cap",
248 )
249 .await
250}
251
252#[derive(Debug, Clone, PartialEq, Eq)]
254pub(crate) enum RelatedCheck {
255 FoundViewable,
256 FoundHidden,
257 NotFound,
258}
259
260pub(crate) async fn related_record_check<R>(
263 cx: &Cx,
264 value: String,
265 scope: Option<Expr<bool>>,
266 ex: &mut dyn toasty::Executor,
267) -> Result<RelatedCheck, OptionLoadError>
268where
269 R: OptionSource,
270{
271 ensure_option_access::<R>(cx)?;
272 let trimmed = value.trim();
273 let Some(expr) = crate::toasty_compat::pk::pk_eq_expr::<R::Model>(trimmed) else {
274 return Ok(RelatedCheck::NotFound);
275 };
276 let mut query = option_query::<R>(cx)?.filter(expr);
277 if let Some(scope) = scope {
278 query = query.filter(scope);
279 }
280 let row = query.first().exec(ex).await.map_err(|e| {
281 tracing::warn!(
282 resource = std::any::type_name::<R>(),
283 error = %e,
284 "relationship option check failed"
285 );
286 OptionLoadError::LoadFailed
287 })?;
288 match row {
289 None => Ok(RelatedCheck::NotFound),
290 Some(record) => {
291 if R::allows(cx, Ability::View(&record)) {
292 Ok(RelatedCheck::FoundViewable)
293 } else {
294 Ok(RelatedCheck::FoundHidden)
295 }
296 }
297 }
298}
299
300#[topcoat::context::memoize(as_ref)]
304pub(crate) async fn viewable_keys<R>(
305 cx: &Cx,
306 _tenant: Option<uuid::Uuid>,
307 keys: &[String],
308) -> Result<std::collections::HashSet<String>, OptionLoadError>
309where
310 R: OptionSource,
311{
312 ensure_option_access::<R>(cx)?;
313 let Some(any) = keys
314 .iter()
315 .filter_map(|key| crate::toasty_compat::pk::pk_eq_expr::<R::Model>(key))
316 .reduce(|any, one| any.or(one))
317 else {
318 return Ok(std::collections::HashSet::new());
319 };
320 let rows = option_query::<R>(cx)?
321 .filter(any)
322 .exec(&mut crate::db::db(cx).map_err(|_| OptionLoadError::Misdeclared)?)
323 .await
324 .map_err(|e| {
325 tracing::warn!(
326 resource = std::any::type_name::<R>(),
327 error = %e,
328 "relationship filter check failed"
329 );
330 OptionLoadError::LoadFailed
331 })?;
332 Ok(rows
333 .iter()
334 .filter(|record| R::allows(cx, Ability::View(record)))
335 .map(crate::toasty_compat::pk::pk_text)
336 .collect())
337}
338
339#[cfg(test)]
340mod tests;