use toasty::stmt::{Expr, List, OrderByExpr, Query};
use topcoat::{Result, context::Cx};
use crate::{BoxFuture, policy::Ability};
pub trait OptionSource: Sized + Send + Sync + 'static {
type Model: toasty::schema::Model + toasty::stmt::IntoExpr<Self::Model> + Send + Sync + 'static;
fn scoped_query(cx: &Cx) -> Result<Query<List<Self::Model>>>;
fn allows(_cx: &Cx, _ability: Ability<'_, Self::Model>) -> bool {
false
}
fn requires_tenant(_cx: &Cx) -> bool {
false
}
fn search_expr(_cx: &Cx, _term: &str) -> Option<Expr<bool>> {
None
}
fn order_by(_cx: &Cx) -> Option<OrderByExpr> {
None
}
fn label(cx: &Cx, record: &Self::Model) -> String;
#[doc(hidden)]
fn available(_cx: &Cx) -> bool {
true
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum OptionLoadError {
Denied,
LoadFailed,
Overflow,
Misdeclared,
}
pub(crate) type RelationshipLoader = std::sync::Arc<
dyn Fn(&Cx) -> BoxFuture<'static, Result<Vec<(String, String)>, OptionLoadError>> + Send + Sync,
>;
pub(crate) type RelationshipSearchLoader = std::sync::Arc<
dyn Fn(
&Cx,
String,
Option<Expr<bool>>,
) -> BoxFuture<'static, Result<Vec<(String, String)>, OptionLoadError>>
+ Send
+ Sync,
>;
pub(crate) type RelationshipChecker = std::sync::Arc<
dyn for<'a> Fn(
&'a Cx,
String,
Option<Expr<bool>>,
&'a mut dyn toasty::Executor,
) -> BoxFuture<'a, Result<RelatedCheck, OptionLoadError>>
+ Send
+ Sync,
>;
fn ensure_option_access<R>(cx: &Cx) -> Result<(), OptionLoadError>
where
R: OptionSource,
{
if !R::allows(cx, Ability::ViewAny) {
return Err(OptionLoadError::Denied);
}
if R::requires_tenant(cx) && crate::tenancy::tenant_id(cx).is_none() {
return Err(OptionLoadError::Denied);
}
Ok(())
}
fn option_query<R>(cx: &Cx) -> Result<Query<List<R::Model>>, OptionLoadError>
where
R: OptionSource,
{
R::scoped_query(cx).map_err(|error| {
tracing::error!(
resource = std::any::type_name::<R>(),
error = %error,
"relationship option load cannot scope the related resource"
);
OptionLoadError::Misdeclared
})
}
pub const MAX_RELATIONSHIP_OPTIONS: usize = 200;
#[topcoat::context::memoize(as_ref)]
pub(crate) async fn related_records<R>(
cx: &Cx,
_tenant: Option<uuid::Uuid>,
) -> Result<Vec<R::Model>, OptionLoadError>
where
R: OptionSource,
{
ensure_option_access::<R>(cx)?;
let mut query = option_query::<R>(cx)?;
if let Some(ord) = R::order_by(cx) {
query = query.order_by(ord);
}
bounded_options::<R>(
cx,
query,
"relationship option load failed",
"relationship option table overflows the cap",
)
.await
}
async fn bounded_options<R>(
cx: &Cx,
query: Query<List<R::Model>>,
failed: &str,
overflow: &str,
) -> Result<Vec<R::Model>, OptionLoadError>
where
R: OptionSource,
{
let mut db = crate::db::db(cx);
let mut records = query
.limit(MAX_RELATIONSHIP_OPTIONS + 1)
.exec(&mut db)
.await
.map_err(|e| {
tracing::warn!(
resource = std::any::type_name::<R>(),
error = %e,
"{failed}"
);
OptionLoadError::LoadFailed
})?;
if records.len() > MAX_RELATIONSHIP_OPTIONS {
tracing::warn!(
resource = std::any::type_name::<R>(),
max = MAX_RELATIONSHIP_OPTIONS,
"{overflow}"
);
return Err(OptionLoadError::Overflow);
}
records.retain(|record| R::allows(cx, Ability::View(record)));
Ok(records)
}
pub(crate) async fn related_records_search<R>(
cx: &Cx,
q: String,
scope: Option<Expr<bool>>,
) -> Result<Vec<R::Model>, OptionLoadError>
where
R: OptionSource,
{
ensure_option_access::<R>(cx)?;
let term = crate::table::clamp_query_term(&q);
let mut query = option_query::<R>(cx)?;
if let Some(scope) = scope {
query = query.filter(scope);
}
if !term.is_empty()
&& let Some(expr) = R::search_expr(cx, &term)
{
query = query.filter(crate::toasty_compat::case_insensitive_search(
&crate::db::db(cx),
expr,
));
}
if let Some(ord) = R::order_by(cx) {
query = query.order_by(ord);
}
bounded_options::<R>(
cx,
query,
"relationship option search failed",
"relationship option search overflows the cap",
)
.await
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RelatedCheck {
FoundViewable,
FoundHidden,
NotFound,
}
pub(crate) async fn related_record_check<R>(
cx: &Cx,
value: String,
scope: Option<Expr<bool>>,
ex: &mut dyn toasty::Executor,
) -> Result<RelatedCheck, OptionLoadError>
where
R: OptionSource,
{
ensure_option_access::<R>(cx)?;
let trimmed = value.trim();
let Some(expr) = crate::toasty_compat::pk::pk_eq_expr::<R::Model>(trimmed) else {
return Ok(RelatedCheck::NotFound);
};
let mut query = option_query::<R>(cx)?.filter(expr);
if let Some(scope) = scope {
query = query.filter(scope);
}
let row = query.first().exec(ex).await.map_err(|e| {
tracing::warn!(
resource = std::any::type_name::<R>(),
error = %e,
"relationship option check failed"
);
OptionLoadError::LoadFailed
})?;
match row {
None => Ok(RelatedCheck::NotFound),
Some(record) => {
if R::allows(cx, Ability::View(&record)) {
Ok(RelatedCheck::FoundViewable)
} else {
Ok(RelatedCheck::FoundHidden)
}
}
}
}
#[cfg(test)]
mod tests;