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 {
16 type Model: toasty::schema::Model + toasty::stmt::IntoExpr<Self::Model> + Send + Sync + 'static;
18
19 fn scoped_query(cx: &Cx) -> Result<Query<List<Self::Model>>>;
22
23 fn allows(_cx: &Cx, _ability: Ability<'_, Self::Model>) -> bool {
26 false
27 }
28
29 fn requires_tenant(_cx: &Cx) -> bool {
36 false
37 }
38
39 fn search_expr(_cx: &Cx, _term: &str) -> Option<Expr<bool>> {
42 None
43 }
44
45 fn order_by(_cx: &Cx) -> Option<OrderByExpr> {
47 None
48 }
49
50 fn label(cx: &Cx, record: &Self::Model) -> String;
57
58 #[doc(hidden)]
61 fn available(_cx: &Cx) -> bool {
62 true
63 }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
69pub(crate) enum OptionLoadError {
70 Denied,
71 LoadFailed,
72 Overflow,
73 Misdeclared,
74}
75
76pub(crate) type RelationshipLoader = std::sync::Arc<
77 dyn Fn(&Cx) -> BoxFuture<'static, Result<Vec<(String, String)>, OptionLoadError>> + Send + Sync,
78>;
79
80pub(crate) type RelationshipSearchLoader = std::sync::Arc<
81 dyn Fn(
82 &Cx,
83 String,
84 Option<Expr<bool>>,
85 ) -> BoxFuture<'static, Result<Vec<(String, String)>, OptionLoadError>>
86 + Send
87 + Sync,
88>;
89
90pub(crate) type RelationshipChecker = std::sync::Arc<
91 dyn for<'a> Fn(
92 &'a Cx,
93 String,
94 Option<Expr<bool>>,
95 &'a mut dyn toasty::Executor,
96 ) -> BoxFuture<'a, Result<RelatedCheck, OptionLoadError>>
97 + Send
98 + Sync,
99>;
100
101fn ensure_option_access<R>(cx: &Cx) -> Result<(), OptionLoadError>
104where
105 R: OptionSource,
106{
107 if !R::allows(cx, Ability::ViewAny) {
108 return Err(OptionLoadError::Denied);
109 }
110 if R::requires_tenant(cx) && crate::tenancy::tenant_id(cx).is_none() {
111 return Err(OptionLoadError::Denied);
112 }
113 Ok(())
114}
115
116fn option_query<R>(cx: &Cx) -> Result<Query<List<R::Model>>, OptionLoadError>
119where
120 R: OptionSource,
121{
122 R::scoped_query(cx).map_err(|error| {
123 tracing::error!(
124 resource = std::any::type_name::<R>(),
125 error = %error,
126 "relationship option load cannot scope the related resource"
127 );
128 OptionLoadError::Misdeclared
129 })
130}
131
132pub const MAX_RELATIONSHIP_OPTIONS: usize = 200;
134
135#[topcoat::context::memoize(as_ref)]
138pub(crate) async fn related_records<R>(
139 cx: &Cx,
140 _tenant: Option<uuid::Uuid>,
141) -> Result<Vec<R::Model>, OptionLoadError>
142where
143 R: OptionSource,
144{
145 ensure_option_access::<R>(cx)?;
146 let mut query = option_query::<R>(cx)?;
147 if let Some(ord) = R::order_by(cx) {
148 query = query.order_by(ord);
149 }
150 bounded_options::<R>(
151 cx,
152 query,
153 "relationship option load failed",
154 "relationship option table overflows the cap",
155 )
156 .await
157}
158
159async fn bounded_options<R>(
162 cx: &Cx,
163 query: Query<List<R::Model>>,
164 failed: &str,
165 overflow: &str,
166) -> Result<Vec<R::Model>, OptionLoadError>
167where
168 R: OptionSource,
169{
170 let mut db = crate::db::db(cx);
171 let mut records = query
172 .limit(MAX_RELATIONSHIP_OPTIONS + 1)
173 .exec(&mut db)
174 .await
175 .map_err(|e| {
176 tracing::warn!(
177 resource = std::any::type_name::<R>(),
178 error = %e,
179 "{failed}"
180 );
181 OptionLoadError::LoadFailed
182 })?;
183 if records.len() > MAX_RELATIONSHIP_OPTIONS {
184 tracing::warn!(
185 resource = std::any::type_name::<R>(),
186 max = MAX_RELATIONSHIP_OPTIONS,
187 "{overflow}"
188 );
189 return Err(OptionLoadError::Overflow);
190 }
191 records.retain(|record| R::allows(cx, Ability::View(record)));
192 Ok(records)
193}
194
195pub(crate) async fn related_records_search<R>(
198 cx: &Cx,
199 q: String,
200 scope: Option<Expr<bool>>,
201) -> Result<Vec<R::Model>, OptionLoadError>
202where
203 R: OptionSource,
204{
205 ensure_option_access::<R>(cx)?;
206 let term = crate::table::clamp_query_term(&q);
207 let mut query = option_query::<R>(cx)?;
208 if let Some(scope) = scope {
209 query = query.filter(scope);
210 }
211 if !term.is_empty()
212 && let Some(expr) = R::search_expr(cx, &term)
213 {
214 query = query.filter(crate::toasty_compat::case_insensitive_search(
215 &crate::db::db(cx),
216 expr,
217 ));
218 }
219 if let Some(ord) = R::order_by(cx) {
220 query = query.order_by(ord);
221 }
222 bounded_options::<R>(
223 cx,
224 query,
225 "relationship option search failed",
226 "relationship option search overflows the cap",
227 )
228 .await
229}
230
231#[derive(Debug, Clone, PartialEq, Eq)]
233pub(crate) enum RelatedCheck {
234 FoundViewable,
235 FoundHidden,
236 NotFound,
237}
238
239pub(crate) async fn related_record_check<R>(
242 cx: &Cx,
243 value: String,
244 scope: Option<Expr<bool>>,
245 ex: &mut dyn toasty::Executor,
246) -> Result<RelatedCheck, OptionLoadError>
247where
248 R: OptionSource,
249{
250 ensure_option_access::<R>(cx)?;
251 let trimmed = value.trim();
252 let Some(expr) = crate::toasty_compat::pk::pk_eq_expr::<R::Model>(trimmed) else {
253 return Ok(RelatedCheck::NotFound);
254 };
255 let mut query = option_query::<R>(cx)?.filter(expr);
256 if let Some(scope) = scope {
257 query = query.filter(scope);
258 }
259 let row = query.first().exec(ex).await.map_err(|e| {
260 tracing::warn!(
261 resource = std::any::type_name::<R>(),
262 error = %e,
263 "relationship option check failed"
264 );
265 OptionLoadError::LoadFailed
266 })?;
267 match row {
268 None => Ok(RelatedCheck::NotFound),
269 Some(record) => {
270 if R::allows(cx, Ability::View(&record)) {
271 Ok(RelatedCheck::FoundViewable)
272 } else {
273 Ok(RelatedCheck::FoundHidden)
274 }
275 }
276 }
277}
278
279#[cfg(test)]
280mod tests;