use toasty::stmt::{Expr, List, OrderByExpr, Query};
use topcoat::{Result, context::Cx};
use crate::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
}
#[doc(hidden)]
fn available(_cx: &Cx) -> bool {
true
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum OptionLoadError {
Denied,
LoadFailed,
Overflow,
Misdeclared,
}
pub(crate) type RelationshipLoadFuture = std::pin::Pin<
Box<dyn std::future::Future<Output = Result<Vec<(String, String)>, OptionLoadError>> + Send>,
>;
#[allow(clippy::type_complexity)]
pub(crate) type RelationshipLoader =
std::sync::Arc<dyn Fn(&Cx) -> RelationshipLoadFuture + Send + Sync>;
#[allow(clippy::type_complexity)]
pub(crate) type RelationshipSearchLoader =
std::sync::Arc<dyn Fn(&Cx, String) -> RelationshipLoadFuture + Send + Sync>;
pub(crate) type RelationshipCheckFuture<'a> = std::pin::Pin<
Box<dyn std::future::Future<Output = Result<RelatedCheck, OptionLoadError>> + Send + 'a>,
>;
#[allow(clippy::type_complexity)]
pub(crate) type RelationshipChecker = std::sync::Arc<
dyn for<'a> Fn(&'a Cx, String, &'a mut dyn toasty::Executor) -> RelationshipCheckFuture<'a>
+ 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,
) -> Result<Vec<R::Model>, OptionLoadError>
where
R: OptionSource,
{
ensure_option_access::<R>(cx)?;
let term = crate::query_term::clamp_query_term(&q);
let mut query = option_query::<R>(cx)?;
if !term.is_empty()
&& let Some(expr) = R::search_expr(cx, &term)
{
query = query.filter(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,
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 row = option_query::<R>(cx)?
.filter(expr)
.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;