tablo_core/schema/
relationship.rs1use toasty::stmt::{Expr, List, OrderByExpr, Query};
7use topcoat::{Result, context::Cx};
8
9use crate::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 #[doc(hidden)]
53 fn available(_cx: &Cx) -> bool {
54 true
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
61pub(crate) enum OptionLoadError {
62 Denied,
63 LoadFailed,
64 Overflow,
65 Misdeclared,
66}
67
68pub(crate) type RelationshipLoadFuture = std::pin::Pin<
69 Box<dyn std::future::Future<Output = Result<Vec<(String, String)>, OptionLoadError>> + Send>,
70>;
71
72#[allow(clippy::type_complexity)]
73pub(crate) type RelationshipLoader =
74 std::sync::Arc<dyn Fn(&Cx) -> RelationshipLoadFuture + Send + Sync>;
75
76#[allow(clippy::type_complexity)]
77pub(crate) type RelationshipSearchLoader =
78 std::sync::Arc<dyn Fn(&Cx, String) -> RelationshipLoadFuture + Send + Sync>;
79
80pub(crate) type RelationshipCheckFuture<'a> = std::pin::Pin<
81 Box<dyn std::future::Future<Output = Result<RelatedCheck, OptionLoadError>> + Send + 'a>,
82>;
83
84#[allow(clippy::type_complexity)]
85pub(crate) type RelationshipChecker = std::sync::Arc<
86 dyn for<'a> Fn(&'a Cx, String, &'a mut dyn toasty::Executor) -> RelationshipCheckFuture<'a>
87 + Send
88 + Sync,
89>;
90
91fn ensure_option_access<R>(cx: &Cx) -> Result<(), OptionLoadError>
94where
95 R: OptionSource,
96{
97 if !R::allows(cx, Ability::ViewAny) {
98 return Err(OptionLoadError::Denied);
99 }
100 if R::requires_tenant(cx) && crate::tenancy::tenant_id(cx).is_none() {
101 return Err(OptionLoadError::Denied);
102 }
103 Ok(())
104}
105
106fn option_query<R>(cx: &Cx) -> Result<Query<List<R::Model>>, OptionLoadError>
109where
110 R: OptionSource,
111{
112 R::scoped_query(cx).map_err(|error| {
113 tracing::error!(
114 resource = std::any::type_name::<R>(),
115 error = %error,
116 "relationship option load cannot scope the related resource"
117 );
118 OptionLoadError::Misdeclared
119 })
120}
121
122pub const MAX_RELATIONSHIP_OPTIONS: usize = 200;
124
125#[topcoat::context::memoize(as_ref)]
128pub(crate) async fn related_records<R>(
129 cx: &Cx,
130 _tenant: Option<uuid::Uuid>,
131) -> Result<Vec<R::Model>, OptionLoadError>
132where
133 R: OptionSource,
134{
135 ensure_option_access::<R>(cx)?;
136 let mut query = option_query::<R>(cx)?;
137 if let Some(ord) = R::order_by(cx) {
138 query = query.order_by(ord);
139 }
140 bounded_options::<R>(
141 cx,
142 query,
143 "relationship option load failed",
144 "relationship option table overflows the cap",
145 )
146 .await
147}
148
149async fn bounded_options<R>(
152 cx: &Cx,
153 query: Query<List<R::Model>>,
154 failed: &str,
155 overflow: &str,
156) -> Result<Vec<R::Model>, OptionLoadError>
157where
158 R: OptionSource,
159{
160 let mut db = crate::db::db(cx);
161 let mut records = query
162 .limit(MAX_RELATIONSHIP_OPTIONS + 1)
163 .exec(&mut db)
164 .await
165 .map_err(|e| {
166 tracing::warn!(
167 resource = std::any::type_name::<R>(),
168 error = %e,
169 "{failed}"
170 );
171 OptionLoadError::LoadFailed
172 })?;
173 if records.len() > MAX_RELATIONSHIP_OPTIONS {
174 tracing::warn!(
175 resource = std::any::type_name::<R>(),
176 max = MAX_RELATIONSHIP_OPTIONS,
177 "{overflow}"
178 );
179 return Err(OptionLoadError::Overflow);
180 }
181 records.retain(|record| R::allows(cx, Ability::View(record)));
182 Ok(records)
183}
184
185pub(crate) async fn related_records_search<R>(
188 cx: &Cx,
189 q: String,
190) -> Result<Vec<R::Model>, OptionLoadError>
191where
192 R: OptionSource,
193{
194 ensure_option_access::<R>(cx)?;
195 let term = crate::query_term::clamp_query_term(&q);
196 let mut query = option_query::<R>(cx)?;
197 if !term.is_empty()
198 && let Some(expr) = R::search_expr(cx, &term)
199 {
200 query = query.filter(expr);
201 }
202 if let Some(ord) = R::order_by(cx) {
203 query = query.order_by(ord);
204 }
205 bounded_options::<R>(
206 cx,
207 query,
208 "relationship option search failed",
209 "relationship option search overflows the cap",
210 )
211 .await
212}
213
214#[derive(Debug, Clone, PartialEq, Eq)]
216pub(crate) enum RelatedCheck {
217 FoundViewable,
218 FoundHidden,
219 NotFound,
220}
221
222pub(crate) async fn related_record_check<R>(
224 cx: &Cx,
225 value: String,
226 ex: &mut dyn toasty::Executor,
227) -> Result<RelatedCheck, OptionLoadError>
228where
229 R: OptionSource,
230{
231 ensure_option_access::<R>(cx)?;
232 let trimmed = value.trim();
233 let Some(expr) = crate::toasty_compat::pk::pk_eq_expr::<R::Model>(trimmed) else {
234 return Ok(RelatedCheck::NotFound);
235 };
236 let row = option_query::<R>(cx)?
237 .filter(expr)
238 .first()
239 .exec(ex)
240 .await
241 .map_err(|e| {
242 tracing::warn!(
243 resource = std::any::type_name::<R>(),
244 error = %e,
245 "relationship option check failed"
246 );
247 OptionLoadError::LoadFailed
248 })?;
249 match row {
250 None => Ok(RelatedCheck::NotFound),
251 Some(record) => {
252 if R::allows(cx, Ability::View(&record)) {
253 Ok(RelatedCheck::FoundViewable)
254 } else {
255 Ok(RelatedCheck::FoundHidden)
256 }
257 }
258 }
259}
260
261#[cfg(test)]
262mod tests;