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