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