use crate::model::Model;
use crate::pg::decode::FromJoinedPgRow;
use crate::query::PortablePredicateError;
use crate::query::condition::Condition;
use crate::query::field::FieldRef;
use crate::query::order::OrderExpr;
use crate::query::q::{CompoundOp, Q};
use crate::relation::path::RelationPath;
use crate::relation::prefetch::{ErasedPrefetch, prefetch_loader};
use crate::relation::select_related::{ErasedSelectRelated, child_descriptor, join_decoder};
use std::hash::Hash;
use std::marker::PhantomData;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum DistinctMode {
#[default]
None,
Plain,
On(Vec<&'static str>),
}
pub(crate) trait CacheTarget<T>: Send + Sync {
fn insert<'a>(
&'a self,
value: &'a T,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>>;
}
pub(crate) struct PunnuCacheTarget<T: crate::types::Cacheable> {
punnu: sassi::Punnu<T>,
}
impl<T: crate::types::Cacheable> PunnuCacheTarget<T> {
pub(crate) fn new(punnu: sassi::Punnu<T>) -> Self {
Self { punnu }
}
}
impl<T: crate::types::Cacheable + Clone> CacheTarget<T> for PunnuCacheTarget<T> {
fn insert<'a>(
&'a self,
value: &'a T,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> {
let punnu = self.punnu.clone();
let value = value.clone();
Box::pin(async move {
if let Err(e) = punnu.insert(value).await {
tracing::warn!(
target: "djogi::cache",
error = ?e,
"Punnu::insert failed during QuerySet::cache hook; continuing",
);
}
})
}
}
pub struct QuerySet<T: Model> {
pub(crate) condition: Q<T>,
pub(crate) ordering: Vec<OrderExpr>,
pub(crate) has_explicit_ordering: bool,
pub(crate) distinct: DistinctMode,
pub(crate) limit: Option<i64>,
pub(crate) offset: Option<i64>,
pub(crate) is_empty: bool,
pub(crate) prefetch_paths: Vec<ErasedPrefetch>,
pub(crate) select_related_paths: Vec<ErasedSelectRelated>,
pub(crate) lock: crate::query::lock::LockMode,
pub(crate) cache_target: Option<std::sync::Arc<dyn CacheTarget<T>>>,
_model: PhantomData<fn() -> T>,
}
pub struct PortableQuerySet<T: Model> {
inner: QuerySet<T>,
portable_predicate: sassi::BasicPredicate<T>,
}
pub struct CachedPortableQuerySet<'p, T: Model + crate::types::Cacheable + Clone> {
inner: PortableQuerySet<T>,
punnu: &'p sassi::Punnu<T>,
}
fn basic_predicate_kind<T: Model>(predicate: &sassi::BasicPredicate<T>) -> &'static str {
match predicate {
sassi::BasicPredicate::True => "True",
sassi::BasicPredicate::False => "False",
sassi::BasicPredicate::Field(_) => "Field",
sassi::BasicPredicate::And(_) => "And",
sassi::BasicPredicate::Or(_) => "Or",
sassi::BasicPredicate::Not(_) => "Not",
sassi::BasicPredicate::Xor(_, _) => "Xor",
_ => "<unknown>",
}
}
impl<T: Model> std::fmt::Debug for PortableQuerySet<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PortableQuerySet")
.field("inner", &self.inner)
.field(
"portable_predicate",
&basic_predicate_kind(&self.portable_predicate),
)
.finish()
}
}
impl<T: Model> PortableQuerySet<T> {
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn into_query_set(self) -> QuerySet<T> {
self.inner
}
pub fn as_query_set(&self) -> &QuerySet<T> {
&self.inner
}
pub fn predicate(&self) -> &sassi::BasicPredicate<T> {
&self.portable_predicate
}
pub fn fetch_all<'ctx>(
self,
ctx: &'ctx mut crate::DjogiContext,
) -> impl std::future::Future<Output = Result<Vec<T>, crate::DjogiError>> + Send + 'ctx
where
T: crate::pg::decode::FromPgRow + Send + Unpin + 'ctx,
{
self.inner.fetch_all(ctx)
}
pub fn fetch_one<'ctx>(
self,
ctx: &'ctx mut crate::DjogiContext,
) -> impl std::future::Future<Output = Result<T, crate::DjogiError>> + Send + 'ctx
where
T: crate::pg::decode::FromPgRow + Send + Unpin + 'ctx,
{
self.inner.fetch_one(ctx)
}
pub fn first<'ctx>(
self,
ctx: &'ctx mut crate::DjogiContext,
) -> impl std::future::Future<Output = Result<Option<T>, crate::DjogiError>> + Send + 'ctx
where
T: crate::pg::decode::FromPgRow + Send + Unpin + 'ctx,
{
self.inner.first(ctx)
}
pub fn count<'ctx>(
self,
ctx: &'ctx mut crate::DjogiContext,
) -> impl std::future::Future<Output = Result<i64, crate::DjogiError>> + Send + 'ctx
where
T: 'ctx,
{
self.inner.count(ctx)
}
}
impl<T: Model + crate::types::Cacheable + Clone> PortableQuerySet<T> {
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn cache(self, punnu: &sassi::Punnu<T>) -> CachedPortableQuerySet<'_, T> {
CachedPortableQuerySet { inner: self, punnu }
}
}
impl<T: Model + crate::types::Cacheable + Clone> CachedPortableQuerySet<'_, T> {
pub fn fetch_all<'ctx>(
self,
ctx: &'ctx mut crate::DjogiContext,
) -> impl std::future::Future<Output = Result<Vec<T>, crate::DjogiError>> + Send + 'ctx
where
T: crate::pg::decode::FromPgRow + Send + Unpin + 'ctx,
{
let qs = self.inner.into_query_set().bind_cache(self.punnu.clone());
qs.fetch_all(ctx)
}
pub fn fetch_one<'ctx>(
self,
ctx: &'ctx mut crate::DjogiContext,
) -> impl std::future::Future<Output = Result<T, crate::DjogiError>> + Send + 'ctx
where
T: crate::pg::decode::FromPgRow + Send + Unpin + 'ctx,
{
let qs = self.inner.into_query_set().bind_cache(self.punnu.clone());
qs.fetch_one(ctx)
}
pub fn first<'ctx>(
self,
ctx: &'ctx mut crate::DjogiContext,
) -> impl std::future::Future<Output = Result<Option<T>, crate::DjogiError>> + Send + 'ctx
where
T: crate::pg::decode::FromPgRow + Send + Unpin + 'ctx,
{
let qs = self.inner.into_query_set().bind_cache(self.punnu.clone());
qs.first(ctx)
}
pub fn count<'ctx>(
self,
ctx: &'ctx mut crate::DjogiContext,
) -> impl std::future::Future<Output = Result<i64, crate::DjogiError>> + Send + 'ctx
where
T: 'ctx,
{
self.inner.into_query_set().count(ctx)
}
}
#[cfg(any(test, feature = "testing"))]
impl<T> CachedPortableQuerySet<'_, T>
where
T: Model + crate::types::Cacheable + Clone + crate::pg::decode::FromPgRow,
{
pub fn render_select_sql_for_testing(&self) -> Result<String, PortablePredicateError> {
self.inner.as_query_set().render_select_sql_for_testing()
}
}
impl<T: Model + crate::types::Cacheable + Clone> std::fmt::Debug for CachedPortableQuerySet<'_, T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.inner.as_query_set().fmt(f)
}
}
impl<T: Model> Clone for QuerySet<T> {
fn clone(&self) -> Self {
QuerySet {
condition: self.condition.clone(),
ordering: self.ordering.clone(),
has_explicit_ordering: self.has_explicit_ordering,
distinct: self.distinct.clone(),
limit: self.limit,
offset: self.offset,
is_empty: self.is_empty,
prefetch_paths: self.prefetch_paths.clone(),
select_related_paths: self.select_related_paths.clone(),
lock: self.lock,
cache_target: self.cache_target.clone(),
_model: PhantomData,
}
}
}
impl<T: Model> std::fmt::Debug for QuerySet<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("QuerySet")
.field("table", &T::table_name())
.field("condition", &self.condition)
.field("ordering", &self.ordering)
.field("distinct", &self.distinct)
.field("limit", &self.limit)
.field("offset", &self.offset)
.field("is_empty", &self.is_empty)
.field("prefetch_paths", &self.prefetch_paths)
.field("select_related_paths", &self.select_related_paths)
.field("lock", &self.lock)
.finish()
}
}
#[cfg(any(test, feature = "testing"))]
impl<T> QuerySet<T>
where
T: Model + crate::pg::decode::FromPgRow,
{
pub fn render_select_sql_for_testing(&self) -> Result<String, PortablePredicateError> {
crate::query::sql::build_select(self).map(|acc| acc.into_parts().0)
}
}
impl<T: Model> Default for QuerySet<T> {
fn default() -> Self {
Self::new()
}
}
fn and_q_into_q<T: Model, A: crate::query::IntoQ<T>>(current: Q<T>, addition: A) -> Q<T> {
let added = addition.into_q();
if is_q_vacuously_true(¤t) {
added
} else {
current & added
}
}
fn is_q_vacuously_true<T: Model>(q: &Q<T>) -> bool {
use crate::query::q::CompoundOp;
match q {
Q::Portable(p) => match p.inner_ref() {
sassi::BasicPredicate::True => true,
sassi::BasicPredicate::And(parts) => parts
.iter()
.all(|c| matches!(c, sassi::BasicPredicate::True)),
_ => false,
},
Q::Condition(c) => c.is_vacuously_true(),
Q::Compound {
op: CompoundOp::And,
parts,
} => parts.iter().all(is_q_vacuously_true),
Q::Negated(inner) => match inner.as_ref() {
Q::Portable(p) => matches!(p.inner_ref(), sassi::BasicPredicate::False),
Q::Compound {
op: CompoundOp::Or,
parts,
} => parts.is_empty(),
_ => false,
},
_ => false,
}
}
impl<T: Model> QuerySet<T> {
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn new() -> Self {
let condition = T::default_filter_condition().map_or_else(Q::always_true, |c| {
use crate::query::q::Q;
Q::Condition(c)
});
let ordering = T::default_order_by();
QuerySet {
condition,
ordering,
has_explicit_ordering: false,
distinct: DistinctMode::None,
limit: None,
offset: None,
is_empty: false,
prefetch_paths: Vec::new(),
select_related_paths: Vec::new(),
lock: crate::query::lock::LockMode::None,
cache_target: None,
_model: PhantomData,
}
}
pub fn join_lateral<R: Model>(
self,
inner: QuerySet<R>,
) -> crate::query::lateral::LateralQuerySet<T, R, crate::query::lateral::InnerLateral> {
crate::query::lateral::LateralQuerySet {
outer: self,
inner,
_mode: std::marker::PhantomData,
}
}
pub fn left_join_lateral<R: Model>(
self,
inner: QuerySet<R>,
) -> crate::query::lateral::LateralQuerySet<T, R, crate::query::lateral::LeftLateral> {
crate::query::lateral::LateralQuerySet {
outer: self,
inner,
_mode: std::marker::PhantomData,
}
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn none(self) -> Self {
let _ = self;
let mut qs = Self::new();
qs.is_empty = true;
qs
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn filter<F, P>(mut self, f: F) -> Self
where
F: FnOnce(T::Fields) -> P,
P: crate::query::IntoQ<T>,
{
let added = f(T::Fields::default());
self.condition = and_q_into_q(self.condition, added);
self
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn filter_expr<F>(mut self, f: F) -> Self
where
F: FnOnce(T::Fields) -> crate::expr::Expr<bool>,
{
let expr = f(T::Fields::default());
self.condition = and_q_into_q(self.condition, expr);
self
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn exclude<F, P>(mut self, f: F) -> Self
where
F: FnOnce(T::Fields) -> P,
P: crate::query::IntoQ<T>,
{
let added = f(T::Fields::default()).into_q();
let negated = match added {
Q::Portable(p) => Q::Portable(!p),
other => !other,
};
self.condition = and_q_into_q(self.condition, negated);
self
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn filter_struct<F: crate::query::IntoQ<T>>(mut self, filter: F) -> Self {
let q = filter.into_q();
if is_q_vacuously_true(&q) {
return self;
}
self.condition = and_q_into_q(self.condition, q);
self
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn exclude_struct<F: crate::query::IntoQ<T>>(mut self, filter: F) -> Self {
let q = filter.into_q();
let negated = match q {
Q::Portable(p) => Q::Portable(!p),
other => !other,
};
self.condition = and_q_into_q(self.condition, negated);
self
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn order_by<F, O>(mut self, f: F) -> Self
where
F: FnOnce(T::Fields) -> O,
O: Into<Vec<OrderExpr>>,
{
self.has_explicit_ordering = true;
let exprs: Vec<OrderExpr> = f(T::Fields::default()).into();
self.ordering.extend(exprs);
self
}
pub(crate) fn validate_mutation_read_tail(
&self,
terminal: &str,
) -> Result<(), crate::DjogiError> {
let mut rejected: Vec<&'static str> = Vec::new();
if self.limit.is_some() {
rejected.push("limit");
}
if self.offset.is_some() {
rejected.push("offset");
}
if !matches!(self.distinct, DistinctMode::None) {
rejected.push("distinct");
}
if self.lock != crate::query::lock::LockMode::None {
rejected.push("row locks");
}
if self.has_explicit_ordering {
rejected.push("order_by");
}
if !self.prefetch_paths.is_empty() {
rejected.push("prefetch");
}
if !self.select_related_paths.is_empty() {
rejected.push("select_related");
}
if self.cache_target.is_some() {
rejected.push("cache_target");
}
if rejected.is_empty() {
return Ok(());
}
let rejected = rejected.join(", ");
Err(crate::DjogiError::Validation(format!(
"{terminal}() does not support queryset read-tail modifiers ({rejected}); remove them before calling {terminal}()"
)))
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn limit(mut self, n: u64) -> Self {
debug_assert!(
n <= i64::MAX as u64,
"QuerySet::limit(n = {n}) overflows i64 — Postgres bind type is BIGINT"
);
self.limit = Some(n as i64);
self
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn offset(mut self, n: u64) -> Self {
debug_assert!(
n <= i64::MAX as u64,
"QuerySet::offset(n = {n}) overflows i64 — Postgres bind type is BIGINT"
);
self.offset = Some(n as i64);
self
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn select_for_update(mut self) -> Self {
self.lock = crate::query::lock::LockMode::ForUpdate;
self
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn nowait(mut self) -> Self {
self.lock = crate::query::lock::LockMode::ForUpdateNowait;
self
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn skip_locked(mut self) -> Self {
self.lock = crate::query::lock::LockMode::ForUpdateSkipLocked;
self
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn select_for_share(mut self) -> Self {
self.lock = crate::query::lock::LockMode::ForShare;
self
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn for_share_nowait(mut self) -> Self {
self.lock = crate::query::lock::LockMode::ForShareNowait;
self
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn for_share_skip_locked(mut self) -> Self {
self.lock = crate::query::lock::LockMode::ForShareSkipLocked;
self
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn distinct(mut self) -> Self {
self.distinct = DistinctMode::Plain;
self
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn distinct_on<F, R>(mut self, f: F) -> Self
where
F: FnOnce(T::Fields) -> R,
R: IntoDistinctColumns,
{
let cols = f(T::Fields::default()).into_distinct_columns();
self.distinct = DistinctMode::On(cols);
self
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn prefetch<Target>(mut self, path: RelationPath<T, Target>) -> Self
where
T::Pk: postgres_types::ToSql
+ for<'r> postgres_types::FromSql<'r>
+ Eq
+ Hash
+ Clone
+ Send
+ Sync
+ 'static,
Target: Model + crate::pg::decode::FromPgRow + Clone + Send + Unpin + 'static,
{
if self
.prefetch_paths
.iter()
.any(|p| p.source_column == path.source_column())
{
return self;
}
self.prefetch_paths.push(ErasedPrefetch {
source_column: path.source_column(),
parent_table: T::table_name(),
loader: prefetch_loader::<T, Target>,
});
self
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn select_related<Child>(mut self, path: RelationPath<T, Child>) -> Self
where
Child: Model + FromJoinedPgRow + Send + Sync + 'static,
{
if self
.select_related_paths
.iter()
.any(|p| p.source_column == path.source_column())
{
return self;
}
self.select_related_paths.push(ErasedSelectRelated {
source_column: path.source_column(),
child_table: path.target_table(),
decoder: join_decoder::<Child>,
child_descriptor: child_descriptor::<Child>,
});
self
}
pub(crate) fn is_empty(&self) -> bool {
self.is_empty
}
#[must_use = "grouped queries are lazy — dropping one silently omits the query"]
pub fn group_by<F, K>(self, f: F) -> crate::query::grouped::GroupedQuerySet<T, K>
where
F: FnOnce(T::Fields) -> K,
K: crate::query::grouped::IntoGroupKeyTuple,
{
let keys = f(T::Fields::default());
crate::query::grouped::GroupedQuerySet {
qs: self,
keys,
grouping: crate::query::grouped::GroupingMode::Plain,
#[cfg(feature = "spatial")]
spatial_source: None,
_k: std::marker::PhantomData,
}
}
#[must_use = "grouped queries are lazy — dropping one silently omits the query"]
pub fn rollup<F, K>(self, f: F) -> crate::query::grouped::GroupedQuerySet<T, K>
where
F: FnOnce(T::Fields) -> K,
K: crate::query::grouped::IntoGroupKeyTuple,
{
let mut gq = self.group_by(f);
gq.grouping = crate::query::grouped::GroupingMode::Rollup;
gq
}
#[must_use = "grouped queries are lazy — dropping one silently omits the query"]
pub fn cube<F, K>(self, f: F) -> crate::query::grouped::GroupedQuerySet<T, K>
where
F: FnOnce(T::Fields) -> K,
K: crate::query::grouped::IntoGroupKeyTuple,
{
let mut gq = self.group_by(f);
gq.grouping = crate::query::grouped::GroupingMode::Cube;
gq
}
#[must_use = "grouped queries are lazy — dropping one silently omits the query"]
pub fn group_by_sets<F, const N: usize>(
self,
f: F,
) -> crate::query::grouped::GroupedQuerySet<T, ()>
where
F: FnOnce(T::Fields) -> [&'static str; N],
{
let cols = f(T::Fields::default());
let sets: Vec<Vec<&'static str>> = cols.iter().map(|c| vec![*c]).collect();
crate::query::grouped::GroupedQuerySet {
qs: self,
keys: (),
grouping: crate::query::grouped::GroupingMode::Sets(sets),
#[cfg(feature = "spatial")]
spatial_source: None,
_k: std::marker::PhantomData,
}
}
#[must_use = "grouped queries are lazy — dropping one silently omits the query"]
pub fn grouping_sets<F>(self, f: F) -> crate::query::grouped::GroupedQuerySet<T, ()>
where
F: FnOnce(T::Fields) -> Vec<Vec<&'static str>>,
{
let sets = f(T::Fields::default());
crate::query::grouped::GroupedQuerySet {
qs: self,
keys: (),
grouping: crate::query::grouped::GroupingMode::Sets(sets),
#[cfg(feature = "spatial")]
spatial_source: None,
_k: std::marker::PhantomData,
}
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn tree_descendants(
self,
edge: crate::relation::RelationPath<T, T>,
root_id: T::Pk,
) -> crate::query::recursive::RecursiveQuerySet<T>
where
T::Pk: postgres_types::ToSql + Sync + Send + 'static,
{
let _ = self;
crate::query::recursive::RecursiveQuerySet::from_path(
edge,
root_id,
crate::query::recursive::RecursiveDirection::Descendants,
)
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn tree_ancestors(
self,
edge: crate::relation::RelationPath<T, T>,
node_id: T::Pk,
) -> crate::query::recursive::RecursiveQuerySet<T>
where
T::Pk: postgres_types::ToSql + Sync + Send + 'static,
{
let _ = self;
crate::query::recursive::RecursiveQuerySet::from_path(
edge,
node_id,
crate::query::recursive::RecursiveDirection::Ancestors,
)
}
#[cfg(feature = "spatial")]
#[must_use = "grouped queries are lazy — dropping one silently omits the query"]
pub fn group_by_region<F, G, R, S>(
self,
field: F,
_regions: QuerySet<R>,
) -> crate::query::grouped::GroupedQuerySet<T, crate::query::spatial_grouping::RegionKey<R>>
where
F: FnOnce(T::Fields) -> S,
S: crate::query::field::IntoSqlField<T, G>,
G: crate::geo::GeographyValue,
R: Model,
R::Pk: for<'a> postgres_types::FromSql<'a> + Send + Unpin + 'static,
{
if !R::descriptor().has_gist_on_geography() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
tracing::warn!(
target: "djogi::spatial",
model = R::table_name(),
"group_by_region called against a region model with no GiST index on a \
geography column; spatial JOINs without GiST scale linearly in both \
table sizes — add IndexType::Gist on the region model's geography field \
or declare an IndexSpec with extension_dependency = Some(\"postgis\")"
);
});
}
let t_geo_col = field(T::Fields::default()).into_sql_field().column();
let r_geo_col = R::descriptor()
.fields
.iter()
.find(|f| {
matches!(
f.sql_type,
crate::descriptor::FieldSqlType::Geography { .. }
)
})
.map(|f| f.name)
.expect(
"region model R must have at least one Geography-typed field; \
add a GeoPoint / Polygon / … field before calling group_by_region",
);
let r_pk_col = R::descriptor()
.pk_column()
.expect("region model R must have a primary key");
let spec = crate::query::spatial_grouping::SpatialJoinSpec {
t_geo_col,
r_table: R::table_name(),
r_geo_col,
r_pk_col,
};
let keys = crate::query::spatial_grouping::RegionKey::<R> {
region_pk: None,
r_pk_col: Some(r_pk_col),
_phantom: std::marker::PhantomData,
};
crate::query::grouped::GroupedQuerySet {
qs: self,
keys,
grouping: crate::query::grouped::GroupingMode::Plain,
spatial_source: Some(crate::query::grouped::SpatialGroupSource::Join(spec)),
_k: std::marker::PhantomData,
}
}
#[cfg(feature = "spatial")]
#[must_use = "grouped queries are lazy — dropping one silently omits the query"]
pub fn count_by_region<F, G, R, S>(
self,
field: F,
regions: QuerySet<R>,
) -> crate::query::grouped::GroupedAnnotatedQuerySet<
T,
crate::query::spatial_grouping::RegionKey<R>,
crate::expr::AggregateExpr<i64>,
>
where
F: FnOnce(T::Fields) -> S,
S: crate::query::field::IntoSqlField<T, G>,
G: crate::geo::GeographyValue,
R: Model,
R::Pk: for<'a> postgres_types::FromSql<'a> + Send + Unpin + 'static,
{
self.group_by_region(field, regions)
.annotate(|_| crate::query::field::FieldRef::<T, i64>::new("id").count_star())
}
#[cfg(feature = "spatial")]
#[must_use = "grouped queries are lazy — dropping one silently omits the query"]
pub fn cluster_by_proximity<F, G, S>(
self,
field: F,
radius: crate::query::spatial_grouping::ClusterRadius,
) -> crate::query::grouped::GroupedQuerySet<T, crate::query::spatial_grouping::ClusterId>
where
F: FnOnce(T::Fields) -> S,
S: crate::query::field::IntoSqlField<T, G>,
G: crate::geo::GeographyValue,
{
let t_geo_col = field(T::Fields::default()).into_sql_field().column();
let spec = crate::query::spatial_grouping::ClusterSpec {
t_geo_col,
eps_degrees: radius.eps_degrees,
minpoints: radius.minpoints,
};
crate::query::grouped::GroupedQuerySet {
qs: self,
keys: crate::query::spatial_grouping::ClusterId(None),
grouping: crate::query::grouped::GroupingMode::Plain,
spatial_source: Some(crate::query::grouped::SpatialGroupSource::Cluster(spec)),
_k: std::marker::PhantomData,
}
}
#[cfg(feature = "spatial")]
#[must_use = "grouped queries are lazy — dropping one silently omits the query"]
pub fn bucket_by_cell<F, G, S>(
self,
field: F,
precision: crate::query::spatial_grouping::GeohashPrecision,
) -> crate::query::grouped::GroupedQuerySet<T, crate::query::spatial_grouping::GeohashKey>
where
F: FnOnce(T::Fields) -> S,
S: crate::query::field::IntoSqlField<T, G>,
G: crate::geo::GeographyValue,
{
let t_geo_col = field(T::Fields::default()).into_sql_field().column();
let spec = crate::query::spatial_grouping::GeohashSpec {
t_geo_col,
precision: precision.as_i32(),
};
crate::query::grouped::GroupedQuerySet {
qs: self,
keys: crate::query::spatial_grouping::GeohashKey(None),
grouping: crate::query::grouped::GroupingMode::Plain,
spatial_source: Some(crate::query::grouped::SpatialGroupSource::Geohash(spec)),
_k: std::marker::PhantomData,
}
}
}
impl<M: crate::SoftDeletable + 'static> QuerySet<M> {
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn not_deleted(mut self) -> Self {
let leaf = crate::query::condition::Leaf::new(
<M as crate::SoftDeletable>::COLUMN,
crate::query::condition::LookupOp::IsNull,
crate::query::condition::FilterValue::Null,
);
self.condition = and_q_into_q(self.condition, Condition::Leaf(leaf));
self
}
}
impl<T: Model + crate::types::Cacheable + Clone> QuerySet<T> {
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub(crate) fn bind_cache(mut self, punnu: sassi::Punnu<T>) -> Self {
self.cache_target = Some(std::sync::Arc::new(PunnuCacheTarget::new(punnu)));
self
}
#[cfg(any(test, feature = "testing"))]
#[doc(hidden)]
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn bind_cache_for_test(self, punnu: sassi::Punnu<T>) -> Self {
self.bind_cache(punnu)
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
#[allow(clippy::result_large_err)]
pub fn cache(
self,
punnu: &sassi::Punnu<T>,
) -> Result<CachedPortableQuerySet<'_, T>, (Self, PortablePredicateError)> {
match self.try_portable() {
Ok(portable) => Ok(portable.cache(punnu)),
Err((queryset, err)) => Err((queryset, err)),
}
}
}
mod distinct_seal {
pub trait Sealed {}
}
pub trait IntoDistinctColumns: distinct_seal::Sealed {
fn into_distinct_columns(self) -> Vec<&'static str>;
}
impl<M: Model, V> distinct_seal::Sealed for FieldRef<M, V> {}
impl<M: Model, V> IntoDistinctColumns for FieldRef<M, V> {
fn into_distinct_columns(self) -> Vec<&'static str> {
vec![self.column()]
}
}
impl<M: Model, V> distinct_seal::Sealed for crate::query::field::DjogiField<M, V> {}
impl<M: Model, V> IntoDistinctColumns for crate::query::field::DjogiField<M, V> {
fn into_distinct_columns(self) -> Vec<&'static str> {
vec![self.column()]
}
}
macro_rules! impl_into_distinct_columns_tuple {
($($name:ident),+) => {
impl<M: Model, $($name),+> distinct_seal::Sealed for ($(FieldRef<M, $name>,)+) {}
impl<M: Model, $($name),+> IntoDistinctColumns for ($(FieldRef<M, $name>,)+) {
fn into_distinct_columns(self) -> Vec<&'static str> {
#[allow(non_snake_case)]
let ($($name,)+) = self;
vec![$($name.column()),+]
}
}
};
}
impl_into_distinct_columns_tuple!(A);
impl_into_distinct_columns_tuple!(A, B);
impl_into_distinct_columns_tuple!(A, B, C);
impl_into_distinct_columns_tuple!(A, B, C, D);
impl_into_distinct_columns_tuple!(A, B, C, D, E);
impl_into_distinct_columns_tuple!(A, B, C, D, E, F);
macro_rules! impl_into_distinct_columns_djogi_tuple {
($($name:ident),+) => {
impl<M: Model, $($name),+> distinct_seal::Sealed
for ($(crate::query::field::DjogiField<M, $name>,)+)
{}
impl<M: Model, $($name),+> IntoDistinctColumns
for ($(crate::query::field::DjogiField<M, $name>,)+)
{
fn into_distinct_columns(self) -> Vec<&'static str> {
#[allow(non_snake_case)]
let ($($name,)+) = self;
vec![$($name.column()),+]
}
}
};
}
impl_into_distinct_columns_djogi_tuple!(A);
impl_into_distinct_columns_djogi_tuple!(A, B);
impl_into_distinct_columns_djogi_tuple!(A, B, C);
impl_into_distinct_columns_djogi_tuple!(A, B, C, D);
impl_into_distinct_columns_djogi_tuple!(A, B, C, D, E);
impl_into_distinct_columns_djogi_tuple!(A, B, C, D, E, F);
fn cache_invalid(kind: &'static str) -> PortablePredicateError {
PortablePredicateError::CacheInvalidNode { kind }
}
fn try_reduce_q_ref_to_basic<T: crate::model::Model>(
q: &Q<T>,
) -> Result<sassi::BasicPredicate<T>, PortablePredicateError> {
match q {
Q::Portable(p) => Ok(p.clone().into_inner()),
Q::Compound { op, parts } => {
let mut reduced_parts = Vec::with_capacity(parts.len());
for part in parts {
reduced_parts.push(try_reduce_q_ref_to_basic(part)?);
}
#[allow(unreachable_patterns)]
match op {
CompoundOp::And => Ok(sassi::BasicPredicate::And(reduced_parts)),
CompoundOp::Or => Ok(sassi::BasicPredicate::Or(reduced_parts)),
_ => Err(cache_invalid("Compound::<unknown>")),
}
}
Q::Xor(left, right) => Ok(sassi::BasicPredicate::Xor(
Box::new(try_reduce_q_ref_to_basic(left)?),
Box::new(try_reduce_q_ref_to_basic(right)?),
)),
Q::Negated(inner) => Ok(sassi::BasicPredicate::Not(Box::new(
try_reduce_q_ref_to_basic(inner)?,
))),
Q::Condition(_) => Err(cache_invalid("Condition")),
Q::Ilike(_, _) => Err(cache_invalid("Ilike")),
Q::Regex(_, _, _) => Err(cache_invalid("Regex")),
Q::Expression(_) => Err(cache_invalid("Expression")),
Q::Array(_) => Err(cache_invalid("Array")),
Q::JsonbPath(_) => Err(cache_invalid("JsonbPath")),
#[allow(unreachable_patterns)]
_ => Err(cache_invalid("Q::<unknown>")),
}
}
fn validate_portable_sql_emit<T: crate::model::Model>(
predicate: &sassi::BasicPredicate<T>,
) -> Result<(), PortablePredicateError> {
let mut acc = crate::pg::accumulator::SqlAccumulator::new("");
crate::query::portable::emit_basic_predicate::<T>(
&mut acc,
predicate,
crate::query::SqlEmitContext::root(),
crate::query::portable::JsonTrust::Trusted,
)
}
impl<T: crate::model::Model> QuerySet<T> {
pub fn is_portable(&self) -> Result<(), PortablePredicateError> {
let portable_predicate = if self.is_empty {
sassi::BasicPredicate::False
} else {
try_reduce_q_ref_to_basic(&self.condition)?
};
validate_portable_sql_emit::<T>(&portable_predicate)
}
#[allow(clippy::result_large_err)]
pub fn try_portable(self) -> Result<PortableQuerySet<T>, (Self, PortablePredicateError)> {
let portable_predicate = if self.is_empty {
sassi::BasicPredicate::False
} else {
match try_reduce_q_ref_to_basic(&self.condition) {
Ok(predicate) => predicate,
Err(err) => return Err((self, err)),
}
};
if let Err(err) = validate_portable_sql_emit::<T>(&portable_predicate) {
return Err((self, err));
}
Ok(PortableQuerySet {
inner: self,
portable_predicate,
})
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn portable_filter<F, P>(mut self, f: F) -> Self
where
F: FnOnce(T::Fields) -> P,
P: crate::query::IntoPortablePredicate<T>,
{
let predicate = f(T::Fields::default()).into_portable_predicate();
self.condition = and_q_into_q(self.condition, predicate);
self
}
pub fn into_basic_predicate(self) -> Option<sassi::BasicPredicate<T>> {
if self.is_empty {
return Some(sassi::BasicPredicate::False);
}
match try_reduce_q_ref_to_basic(&self.condition) {
Ok(predicate) => Some(predicate),
Err(PortablePredicateError::CacheInvalidNode { kind }) => {
tracing::warn!(
target: "djogi::cache",
model = std::any::type_name::<T>(),
reason = kind,
"QuerySet condition has non-portable predicates and cannot be \
reduced to a Sassi BasicPredicate. The cache and refresh paths \
reject non-portable querysets with a typed error; restructure \
the filter using only Djogi portable predicate operations to \
pass the cache/refresh boundary.",
);
None
}
Err(err) => {
tracing::warn!(
target: "djogi::cache",
model = std::any::type_name::<T>(),
error = ?err,
"QuerySet condition could not be reduced to a portable cache predicate.",
);
None
}
}
}
}
impl<T> QuerySet<T>
where
T: crate::model::Model
+ sassi::DeltaSyncCacheable
+ crate::pg::decode::FromPgRow
+ crate::cache::DjogiDeltaSyncMeta
+ Send
+ Sync
+ 'static,
T::Watermark: tokio_postgres::types::ToSql + tokio_postgres::types::FromSqlOwned + Sync,
T::Id: tokio_postgres::types::ToSql + Sync,
{
#[allow(clippy::result_large_err)]
pub fn refresh_into(
self,
punnu: &sassi::Punnu<T>,
pool: crate::pg::pool::DjogiPool,
auth: crate::auth::AuthContext,
) -> Result<sassi::DeltaRefreshHandle<T>, (Self, PortablePredicateError)> {
match self.try_portable() {
Ok(portable) => Ok(portable.refresh_into(punnu, pool, auth)),
Err((queryset, err)) => Err((queryset, err)),
}
}
}
impl<T> PortableQuerySet<T>
where
T: crate::model::Model
+ sassi::DeltaSyncCacheable
+ crate::pg::decode::FromPgRow
+ crate::cache::DjogiDeltaSyncMeta
+ Send
+ Sync
+ 'static,
T::Watermark: tokio_postgres::types::ToSql + tokio_postgres::types::FromSqlOwned + Sync,
T::Id: tokio_postgres::types::ToSql + Sync,
{
pub fn refresh_into(
self,
punnu: &sassi::Punnu<T>,
pool: crate::pg::pool::DjogiPool,
auth: crate::auth::AuthContext,
) -> sassi::DeltaRefreshHandle<T> {
let PortableQuerySet {
inner,
portable_predicate,
} = self;
let empty = inner.is_empty;
let filter = if empty {
None
} else {
match portable_predicate {
sassi::BasicPredicate::True => None,
predicate => Some(predicate),
}
};
let events_rx = std::sync::Mutex::new(punnu.events());
let fetcher = crate::query::refresh::DjogiDeltaFetcher::<T> {
pool,
auth,
empty,
filter,
lru_warn_issued: std::sync::atomic::AtomicBool::new(false),
events_rx,
outbox_watermark: std::sync::Mutex::new(None),
_model: std::marker::PhantomData,
};
let interval = std::time::Duration::from_secs(30);
punnu.start_delta_refresh(interval, fetcher)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::descriptor::ModelDescriptor;
use crate::pg::decode::{FromJoinedPgRow, FromPgRow};
use crate::relation::{RelationKind, RelationPath};
struct Fake;
impl crate::model::__sealed::Sealed for Fake {}
#[allow(clippy::manual_async_fn)]
impl Model for Fake {
type Pk = i64;
type Fields = ();
fn table_name() -> &'static str {
"fake"
}
fn pk_value(&self) -> &Self::Pk {
unreachable!("not called in QuerySet unit tests")
}
fn descriptor() -> &'static ModelDescriptor {
unreachable!("not called in QuerySet unit tests")
}
fn get(
_ctx: &mut crate::context::DjogiContext,
_id: Self::Pk,
) -> impl std::future::Future<Output = Result<Self, crate::DjogiError>> + Send {
async { unreachable!() }
}
fn create(
_ctx: &mut crate::context::DjogiContext,
_v: Self,
) -> impl std::future::Future<Output = Result<Self, crate::DjogiError>> + Send {
async { unreachable!() }
}
fn save<'ctx>(
&'ctx mut self,
_ctx: &'ctx mut crate::context::DjogiContext,
) -> impl std::future::Future<Output = Result<(), crate::DjogiError>> + Send + 'ctx
{
async { unreachable!() }
}
fn delete(
self,
_ctx: &mut crate::context::DjogiContext,
) -> impl std::future::Future<Output = Result<(), crate::DjogiError>> + Send {
async { unreachable!() }
}
fn refresh_from_db<'ctx>(
&'ctx self,
_ctx: &'ctx mut crate::context::DjogiContext,
) -> impl std::future::Future<Output = Result<Self, crate::DjogiError>> + Send + 'ctx
{
async { unreachable!() }
}
}
#[test]
fn new_queryset_has_no_filters() {
let qs: QuerySet<Fake> = QuerySet::new();
assert!(matches!(
crate::query::q::q_to_condition_ref(&qs.condition),
Condition::True
));
assert!(qs.ordering.is_empty());
assert!(matches!(qs.distinct, DistinctMode::None));
assert_eq!(qs.limit, None);
assert_eq!(qs.offset, None);
assert!(!qs.is_empty());
}
#[test]
fn none_marks_queryset_empty() {
let qs: QuerySet<Fake> = QuerySet::<Fake>::new().none();
assert!(qs.is_empty());
}
#[test]
fn none_discards_prior_filters() {
use crate::query::condition::{FilterValue, Leaf};
let qs: QuerySet<Fake> = QuerySet::new()
.filter(|_| Condition::Leaf(Leaf::eq_raw("a", FilterValue::Bool(true))))
.none();
assert!(qs.is_empty());
assert!(matches!(
crate::query::q::q_to_condition_ref(&qs.condition),
Condition::True
));
}
#[test]
fn filter_ands_onto_condition_tree() {
use crate::query::condition::{FilterValue, Leaf};
let qs: QuerySet<Fake> =
QuerySet::new().filter(|_| Condition::Leaf(Leaf::eq_raw("a", FilterValue::Bool(true))));
assert!(matches!(
crate::query::q::q_to_condition_ref(&qs.condition),
Condition::Leaf(_)
));
let qs2 = qs.filter(|_| Condition::Leaf(Leaf::eq_raw("b", FilterValue::Bool(false))));
match crate::query::q::q_to_condition(qs2.condition) {
Condition::And(parts) => assert_eq!(parts.len(), 2),
other => panic!("expected And, got {other:?}"),
}
}
#[test]
fn exclude_wraps_in_not() {
use crate::query::condition::{FilterValue, Leaf};
let qs: QuerySet<Fake> = QuerySet::new()
.exclude(|_| Condition::Leaf(Leaf::eq_raw("a", FilterValue::Bool(true))));
assert!(matches!(
crate::query::q::q_to_condition(qs.condition),
Condition::Not(_)
));
}
#[test]
fn filter_struct_accepts_q_directly() {
use crate::query::Q;
let q: Q<Fake> = Q::always_true();
let qs: QuerySet<Fake> = QuerySet::new().filter_struct(q);
assert!(matches!(
crate::query::q::q_to_condition(qs.condition),
Condition::True
));
}
#[test]
fn filter_struct_q_negated_ands_onto_tree() {
use crate::query::Q;
let q: Q<Fake> = Q::always_false();
let qs: QuerySet<Fake> = QuerySet::new().filter_struct(q);
match crate::query::q::q_to_condition(qs.condition) {
Condition::Or(v) => assert!(v.is_empty(), "expected Or(empty)"),
other => panic!("expected Condition::Or(empty), got {other:?}"),
}
}
#[test]
fn exclude_struct_false_simplifies_to_true() {
use crate::query::Q;
let q: Q<Fake> = Q::always_false();
let qs: QuerySet<Fake> = QuerySet::new().exclude_struct(q);
assert!(matches!(
crate::query::q::q_to_condition(qs.condition),
Condition::True
));
}
#[test]
fn exclude_struct_does_not_short_circuit_on_vacuous_true() {
use crate::query::Q;
let q: Q<Fake> = Q::always_true();
let qs: QuerySet<Fake> = QuerySet::new().exclude_struct(q);
match crate::query::q::q_to_condition(qs.condition) {
Condition::Or(parts) => assert!(parts.is_empty(), "expected false"),
other => panic!("expected Condition::Or(empty), got {other:?}"),
}
}
#[test]
fn filter_struct_accepts_portable_predicate_directly() {
use crate::query::PortablePredicate;
let predicate: PortablePredicate<Fake> = PortablePredicate::always_false();
let qs: QuerySet<Fake> = QuerySet::new().filter_struct(predicate);
match crate::query::q::q_to_condition(qs.condition) {
Condition::Or(v) => assert!(v.is_empty()),
other => panic!("expected Condition::Or(empty), got {other:?}"),
}
}
#[test]
fn queryset_condition_field_is_q_t() {
let qs: QuerySet<Fake> = QuerySet::new();
let _: &crate::query::Q<Fake> = &qs.condition;
let lowered = crate::query::q::q_to_condition(qs.condition);
assert!(matches!(lowered, Condition::True));
}
#[test]
fn limit_and_offset_round_trip() {
let qs: QuerySet<Fake> = QuerySet::new().limit(10).offset(20);
assert_eq!(qs.limit, Some(10));
assert_eq!(qs.offset, Some(20));
}
#[test]
fn distinct_plain_sets_mode() {
let qs: QuerySet<Fake> = QuerySet::new().distinct();
assert!(matches!(qs.distinct, DistinctMode::Plain));
}
#[test]
fn clone_is_structural() {
let qs: QuerySet<Fake> = QuerySet::new().limit(5);
let qs2 = qs.clone();
assert_eq!(qs.limit, qs2.limit);
}
#[derive(Clone)]
struct FakeProxy;
impl crate::model::__sealed::Sealed for FakeProxy {}
#[allow(clippy::manual_async_fn)]
impl Model for FakeProxy {
type Pk = i64;
type Fields = ();
fn table_name() -> &'static str {
"fake_proxy"
}
fn pk_value(&self) -> &Self::Pk {
unreachable!("not called in QuerySet unit tests")
}
fn descriptor() -> &'static crate::descriptor::ModelDescriptor {
unreachable!("not called in QuerySet unit tests")
}
fn default_filter_condition() -> Option<Condition> {
Some(Condition::__from_raw_sql_fragment("active = TRUE"))
}
fn default_order_by() -> Vec<crate::query::OrderExpr> {
vec![crate::query::OrderExpr::__from_macro_column(
"created_at",
crate::query::Direction::Desc,
crate::query::NullsOrder::Default,
)]
}
fn get(
_ctx: &mut crate::context::DjogiContext,
_id: Self::Pk,
) -> impl std::future::Future<Output = Result<Self, crate::DjogiError>> + Send {
async { unreachable!() }
}
fn create(
_ctx: &mut crate::context::DjogiContext,
_v: Self,
) -> impl std::future::Future<Output = Result<Self, crate::DjogiError>> + Send {
async { unreachable!() }
}
fn save<'ctx>(
&'ctx mut self,
_ctx: &'ctx mut crate::context::DjogiContext,
) -> impl std::future::Future<Output = Result<(), crate::DjogiError>> + Send + 'ctx
{
async { unreachable!() }
}
fn delete(
self,
_ctx: &mut crate::context::DjogiContext,
) -> impl std::future::Future<Output = Result<(), crate::DjogiError>> + Send {
async { unreachable!() }
}
fn refresh_from_db<'ctx>(
&'ctx self,
_ctx: &'ctx mut crate::context::DjogiContext,
) -> impl std::future::Future<Output = Result<Self, crate::DjogiError>> + Send + 'ctx
{
async { unreachable!() }
}
}
impl crate::types::Cacheable for FakeProxy {
type Id = i64;
type Fields = ();
fn id(&self) -> Self::Id {
0
}
fn fields() -> Self::Fields {}
}
impl FromPgRow for FakeProxy {
const COLUMNS: &'static [&'static str] = &[];
const COLUMN_LIST: &'static str = "";
fn from_pg_row(_row: &tokio_postgres::Row) -> Result<Self, crate::DjogiError> {
unreachable!("not called in QuerySet unit tests")
}
}
impl FromJoinedPgRow for FakeProxy {
fn from_joined_pg_row(
_row: &tokio_postgres::Row,
_prefix: &str,
) -> Result<Self, crate::DjogiError> {
unreachable!("not called in QuerySet unit tests")
}
}
#[test]
fn proxy_queryset_seeds_default_filter() {
let qs: QuerySet<FakeProxy> = QuerySet::new();
match crate::query::q::q_to_condition_ref(&qs.condition) {
Condition::RawSql(s) => assert_eq!(s.as_str(), "active = TRUE"),
other => panic!("expected RawSql variant, got {other:?}"),
}
}
#[test]
fn proxy_queryset_seeds_default_order() {
let qs: QuerySet<FakeProxy> = QuerySet::new();
assert_eq!(qs.ordering.len(), 1);
match &qs.ordering[0] {
crate::query::OrderExpr::Column {
column, direction, ..
} => {
assert_eq!(*column, "created_at");
assert!(matches!(direction, crate::query::Direction::Desc));
}
#[allow(unreachable_patterns)]
other => panic!("expected Column variant, got {other:?}"),
}
}
#[test]
fn proxy_filter_ands_with_default() {
use crate::query::condition::{FilterValue, Leaf};
let qs: QuerySet<FakeProxy> = QuerySet::<FakeProxy>::new()
.filter(|_| Condition::Leaf(Leaf::eq_raw("price", FilterValue::I64(100))));
match crate::query::q::q_to_condition_ref(&qs.condition) {
Condition::And(parts) => {
assert_eq!(parts.len(), 2, "expected proxy filter AND user filter");
assert!(matches!(parts[0], Condition::RawSql(_)));
assert!(matches!(parts[1], Condition::Leaf(_)));
}
other => panic!("expected And, got {other:?}"),
}
}
#[test]
fn proxy_order_by_appends_to_default() {
let user_order = crate::query::OrderExpr::__from_macro_column(
"id",
crate::query::Direction::Asc,
crate::query::NullsOrder::Default,
);
let qs: QuerySet<FakeProxy> = QuerySet::<FakeProxy>::new().order_by(|_| user_order);
assert_eq!(qs.ordering.len(), 2);
match &qs.ordering[0] {
crate::query::OrderExpr::Column { column, .. } => assert_eq!(*column, "created_at"),
#[allow(unreachable_patterns)]
other => panic!("expected Column, got {other:?}"),
}
match &qs.ordering[1] {
crate::query::OrderExpr::Column { column, .. } => assert_eq!(*column, "id"),
#[allow(unreachable_patterns)]
other => panic!("expected Column, got {other:?}"),
}
}
#[test]
fn mutation_guard_allows_proxy_default_ordering() {
let qs: QuerySet<FakeProxy> = QuerySet::new();
assert!(
qs.validate_mutation_read_tail("delete").is_ok(),
"proxy default ordering must not trip mutation guard"
);
}
#[test]
fn mutation_guard_rejects_explicit_order_by() {
let user_order = crate::query::OrderExpr::__from_macro_column(
"id",
crate::query::Direction::Asc,
crate::query::NullsOrder::Default,
);
let qs: QuerySet<FakeProxy> = QuerySet::<FakeProxy>::new().order_by(|_| user_order);
let err = qs
.validate_mutation_read_tail("delete")
.expect_err("explicit order_by should be rejected for mutation terminals");
match err {
crate::DjogiError::Validation(msg) => {
assert!(
msg.contains("order_by"),
"validation should mention order_by, got: {msg}"
);
}
other => panic!("expected Validation, got {other:?}"),
}
}
#[test]
fn mutation_guard_rejects_other_read_tail_modifiers() {
for (qs, expected) in [
(QuerySet::<Fake>::new().offset(10), "offset"),
(QuerySet::<Fake>::new().distinct(), "distinct"),
(QuerySet::<Fake>::new().select_for_update(), "row locks"),
] {
let err = qs
.validate_mutation_read_tail("delete")
.expect_err("read-tail modifier should be rejected for mutation terminals");
match err {
crate::DjogiError::Validation(msg) => {
assert!(
msg.contains(expected),
"validation should mention {expected}, got: {msg}"
);
}
other => panic!("expected Validation, got {other:?}"),
}
}
}
fn fake_relation_path() -> RelationPath<Fake, FakeProxy> {
RelationPath::new("proxy_id", "fake_proxy", RelationKind::ForeignKey)
}
#[test]
fn mutation_guard_rejects_prefetch_paths() {
let qs: QuerySet<Fake> = QuerySet::new().prefetch(fake_relation_path());
let err = qs
.validate_mutation_read_tail("delete")
.expect_err("prefetch should be rejected for mutation terminals");
match err {
crate::DjogiError::Validation(msg) => {
assert!(
msg.contains("prefetch"),
"validation should mention prefetch, got: {msg}"
);
}
other => panic!("expected Validation, got {other:?}"),
}
}
#[test]
fn mutation_guard_rejects_select_related_paths() {
let qs: QuerySet<Fake> = QuerySet::new().select_related(fake_relation_path());
let err = qs
.validate_mutation_read_tail("update")
.expect_err("select_related should be rejected for mutation terminals");
match err {
crate::DjogiError::Validation(msg) => {
assert!(
msg.contains("select_related"),
"validation should mention select_related, got: {msg}"
);
}
other => panic!("expected Validation, got {other:?}"),
}
}
#[test]
fn mutation_guard_rejects_cache_target() {
let punnu = crate::cache::Punnu::<FakeProxy>::builder().build();
let qs: QuerySet<FakeProxy> = QuerySet::new().bind_cache_for_test(punnu);
let err = qs
.validate_mutation_read_tail("delete")
.expect_err("cache_target should be rejected for mutation terminals");
match err {
crate::DjogiError::Validation(msg) => {
assert!(
msg.contains("cache_target"),
"validation should mention cache_target, got: {msg}"
);
}
other => panic!("expected Validation, got {other:?}"),
}
}
#[test]
fn non_proxy_queryset_unchanged_by_t3_4() {
let qs: QuerySet<Fake> = QuerySet::new();
assert!(matches!(
crate::query::q::q_to_condition_ref(&qs.condition),
Condition::True
));
assert!(qs.ordering.is_empty());
}
#[cfg(feature = "spatial")]
struct FakeUnindexedRegion;
#[cfg(feature = "spatial")]
impl crate::model::__sealed::Sealed for FakeUnindexedRegion {}
#[cfg(feature = "spatial")]
#[allow(clippy::manual_async_fn)]
impl Model for FakeUnindexedRegion {
type Pk = i64;
type Fields = ();
fn table_name() -> &'static str {
"unindexed_regions"
}
fn pk_value(&self) -> &i64 {
unreachable!()
}
fn descriptor() -> &'static ModelDescriptor {
use crate::descriptor::{
FieldDescriptor, FieldSqlType, GeographySubtype, PkType, field_descriptor,
model_descriptor,
};
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
..field_descriptor(
"boundary",
FieldSqlType::Geography {
subtype: GeographySubtype::Polygon,
srid: 4326,
},
false,
)
}];
static DESC: ModelDescriptor = ModelDescriptor {
..model_descriptor(
"FakeUnindexedRegion",
"unindexed_regions",
PkType::HeerId,
FIELDS,
)
};
&DESC
}
fn get(
_ctx: &mut crate::context::DjogiContext,
_id: i64,
) -> impl std::future::Future<Output = Result<Self, crate::DjogiError>> + Send {
async { unreachable!() }
}
fn create(
_ctx: &mut crate::context::DjogiContext,
_v: Self,
) -> impl std::future::Future<Output = Result<Self, crate::DjogiError>> + Send {
async { unreachable!() }
}
fn save<'ctx>(
&'ctx mut self,
_ctx: &'ctx mut crate::context::DjogiContext,
) -> impl std::future::Future<Output = Result<(), crate::DjogiError>> + Send + 'ctx
{
async { unreachable!() }
}
fn delete(
self,
_ctx: &mut crate::context::DjogiContext,
) -> impl std::future::Future<Output = Result<(), crate::DjogiError>> + Send {
async { unreachable!() }
}
fn refresh_from_db<'ctx>(
&'ctx self,
_ctx: &'ctx mut crate::context::DjogiContext,
) -> impl std::future::Future<Output = Result<Self, crate::DjogiError>> + Send + 'ctx
{
async { unreachable!() }
}
}
#[cfg(feature = "spatial")]
#[test]
fn group_by_region_warns_at_most_once_for_unindexed_region() {
use crate::geo::GeoPoint;
use crate::query::field::FieldRef;
use std::sync::atomic::{AtomicUsize, Ordering};
let warn_count = std::sync::Arc::new(AtomicUsize::new(0));
struct WarnCountSub {
count: std::sync::Arc<AtomicUsize>,
}
impl tracing::Subscriber for WarnCountSub {
fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
true
}
fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
tracing::span::Id::from_u64(1)
}
fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
fn event(&self, event: &tracing::Event<'_>) {
if *event.metadata().level() == tracing::Level::WARN
&& event.metadata().target() == "djogi::spatial"
{
self.count.fetch_add(1, Ordering::Relaxed);
}
}
fn enter(&self, _: &tracing::span::Id) {}
fn exit(&self, _: &tracing::span::Id) {}
}
let subscriber = WarnCountSub {
count: warn_count.clone(),
};
let _guard = tracing::subscriber::set_default(subscriber);
let _g1 = QuerySet::<Fake>::new().group_by_region(
|_| FieldRef::<Fake, GeoPoint>::new("location"),
QuerySet::<FakeUnindexedRegion>::new(),
);
let _g2 = QuerySet::<Fake>::new().group_by_region(
|_| FieldRef::<Fake, GeoPoint>::new("location"),
QuerySet::<FakeUnindexedRegion>::new(),
);
let count = warn_count.load(Ordering::Relaxed);
assert!(
count <= 1,
"expected at most 1 warn from group_by_region (Once guard), got {count}"
);
}
#[cfg(feature = "spatial")]
struct FakeIndexedRegion;
#[cfg(feature = "spatial")]
impl crate::model::__sealed::Sealed for FakeIndexedRegion {}
#[cfg(feature = "spatial")]
#[allow(clippy::manual_async_fn)]
impl Model for FakeIndexedRegion {
type Pk = i64;
type Fields = ();
fn table_name() -> &'static str {
"indexed_regions"
}
fn pk_value(&self) -> &i64 {
unreachable!()
}
fn descriptor() -> &'static ModelDescriptor {
use crate::descriptor::{
FieldDescriptor, FieldSqlType, GeographySubtype, IndexColumnSpec, IndexKind,
IndexSpec, IndexTarget, IndexType, PkType, field_descriptor, model_descriptor,
};
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
..field_descriptor(
"boundary",
FieldSqlType::Geography {
subtype: GeographySubtype::Polygon,
srid: 4326,
},
false,
)
}];
static BOUNDARY_COLS: &[IndexColumnSpec] = &[IndexColumnSpec::simple("boundary")];
static INDEXES: &[IndexSpec] = &[IndexSpec {
name: "idx_regions_boundary_gist",
target: IndexTarget::Columns(BOUNDARY_COLS),
kind: IndexKind::NonUnique,
index_type: IndexType::Gist,
predicate: None,
include: &[],
nulls_not_distinct: false,
requires_out_of_transaction: true,
extension_dependency: Some("postgis"),
}];
static DESC: ModelDescriptor = ModelDescriptor {
indexes: INDEXES,
..model_descriptor(
"FakeIndexedRegion",
"indexed_regions",
PkType::HeerId,
FIELDS,
)
};
&DESC
}
fn get(
_ctx: &mut crate::context::DjogiContext,
_id: i64,
) -> impl std::future::Future<Output = Result<Self, crate::DjogiError>> + Send {
async { unreachable!() }
}
fn create(
_ctx: &mut crate::context::DjogiContext,
_v: Self,
) -> impl std::future::Future<Output = Result<Self, crate::DjogiError>> + Send {
async { unreachable!() }
}
fn save<'ctx>(
&'ctx mut self,
_ctx: &'ctx mut crate::context::DjogiContext,
) -> impl std::future::Future<Output = Result<(), crate::DjogiError>> + Send + 'ctx
{
async { unreachable!() }
}
fn delete(
self,
_ctx: &mut crate::context::DjogiContext,
) -> impl std::future::Future<Output = Result<(), crate::DjogiError>> + Send {
async { unreachable!() }
}
fn refresh_from_db<'ctx>(
&'ctx self,
_ctx: &'ctx mut crate::context::DjogiContext,
) -> impl std::future::Future<Output = Result<Self, crate::DjogiError>> + Send + 'ctx
{
async { unreachable!() }
}
}
#[cfg(feature = "spatial")]
#[test]
fn group_by_region_returns_grouped_queryset_with_region_key() {
use crate::geo::GeoPoint;
use crate::query::field::FieldRef;
use crate::query::grouped::GroupedQuerySet;
use crate::query::spatial_grouping::RegionKey;
let qs: QuerySet<Fake> = QuerySet::new();
let regions: QuerySet<FakeIndexedRegion> = QuerySet::new();
let _grouped: GroupedQuerySet<Fake, RegionKey<FakeIndexedRegion>> =
qs.group_by_region(|_| FieldRef::<Fake, GeoPoint>::new("location"), regions);
}
#[cfg(feature = "spatial")]
#[test]
fn count_by_region_returns_grouped_annotated_queryset() {
use crate::expr::AggregateExpr;
use crate::geo::GeoPoint;
use crate::query::field::FieldRef;
use crate::query::grouped::GroupedAnnotatedQuerySet;
use crate::query::spatial_grouping::RegionKey;
let qs: QuerySet<Fake> = QuerySet::new();
let regions: QuerySet<FakeIndexedRegion> = QuerySet::new();
let _gaq: GroupedAnnotatedQuerySet<Fake, RegionKey<FakeIndexedRegion>, AggregateExpr<i64>> =
qs.count_by_region(|_| FieldRef::<Fake, GeoPoint>::new("location"), regions);
}
#[cfg(feature = "spatial")]
#[test]
fn group_by_region_twice_does_not_deadlock() {
use crate::geo::GeoPoint;
use crate::query::field::FieldRef;
let _g1 = QuerySet::<Fake>::new().group_by_region(
|_| FieldRef::<Fake, GeoPoint>::new("location"),
QuerySet::<FakeIndexedRegion>::new(),
);
let _g2 = QuerySet::<Fake>::new().group_by_region(
|_| FieldRef::<Fake, GeoPoint>::new("location"),
QuerySet::<FakeIndexedRegion>::new(),
);
}
#[cfg(feature = "spatial")]
#[test]
fn cluster_by_proximity_returns_grouped_queryset_with_cluster_id() {
use crate::geo::GeoPoint;
use crate::query::field::FieldRef;
use crate::query::grouped::GroupedQuerySet;
use crate::query::spatial_grouping::{ClusterId, ClusterRadius};
let _g: GroupedQuerySet<Fake, ClusterId> = QuerySet::<Fake>::new().cluster_by_proximity(
|_| FieldRef::<Fake, GeoPoint>::new("location"),
ClusterRadius::meters(500.0).min_points(3),
);
}
#[cfg(feature = "spatial")]
#[test]
fn bucket_by_cell_returns_grouped_queryset_with_geohash_key() {
use crate::geo::GeoPoint;
use crate::query::field::FieldRef;
use crate::query::grouped::GroupedQuerySet;
use crate::query::spatial_grouping::{GeohashKey, GeohashPrecision};
let _g: GroupedQuerySet<Fake, GeohashKey> = QuerySet::<Fake>::new().bucket_by_cell(
|_| FieldRef::<Fake, GeoPoint>::new("location"),
GeohashPrecision::P5,
);
}
#[test]
fn into_basic_predicate_unfiltered_returns_true() {
let qs: QuerySet<Fake> = QuerySet::new();
assert!(
matches!(&qs.condition, Q::Portable(_)),
"unfiltered QuerySet must start as Q::Portable (substrate regression?)",
);
let result = qs.into_basic_predicate();
assert!(
matches!(result, Some(sassi::BasicPredicate::True)),
"unfiltered QuerySet should reduce to Some(BasicPredicate::True)"
);
}
#[test]
fn into_basic_predicate_portable_false_reduces() {
let mut qs: QuerySet<Fake> = QuerySet::new();
qs.condition = Q::always_false();
let result = qs.into_basic_predicate();
assert!(
matches!(result, Some(sassi::BasicPredicate::False)),
"Q::Portable(False) should reduce to Some(BasicPredicate::False)"
);
}
#[test]
fn into_basic_predicate_compound_and_reduces() {
use crate::query::q::CompoundOp;
let mut qs: QuerySet<Fake> = QuerySet::new();
qs.condition = Q::Compound {
op: CompoundOp::And,
parts: vec![Q::always_true(), Q::always_false()],
};
let result = qs.into_basic_predicate();
match result {
Some(sassi::BasicPredicate::And(parts)) => {
assert_eq!(parts.len(), 2, "And predicate should have exactly 2 parts");
assert!(matches!(parts[0], sassi::BasicPredicate::True));
assert!(matches!(parts[1], sassi::BasicPredicate::False));
}
other => panic!(
"expected Some(BasicPredicate::And([True, False])), got {}",
if other.is_some() {
"Some(non-And variant)"
} else {
"None"
}
),
}
}
#[test]
fn into_basic_predicate_compound_or_reduces() {
use crate::query::q::CompoundOp;
let mut qs: QuerySet<Fake> = QuerySet::new();
qs.condition = Q::Compound {
op: CompoundOp::Or,
parts: vec![Q::always_true(), Q::always_false()],
};
let result = qs.into_basic_predicate();
match result {
Some(sassi::BasicPredicate::Or(parts)) => {
assert_eq!(parts.len(), 2, "Or predicate should have exactly 2 parts");
}
_ => panic!("expected Some(BasicPredicate::Or([True, False]))"),
}
}
#[test]
fn into_basic_predicate_negated_portable_reduces() {
let mut qs: QuerySet<Fake> = QuerySet::new();
qs.condition = Q::Negated(Box::new(Q::always_true()));
let result = qs.into_basic_predicate();
match result {
Some(sassi::BasicPredicate::Not(inner)) => {
assert!(
matches!(*inner, sassi::BasicPredicate::True),
"inner of Not should be True"
);
}
_ => panic!("expected Some(BasicPredicate::Not(True))"),
}
}
#[test]
fn into_basic_predicate_compound_with_ilike_refuses() {
use crate::query::field::FieldRef;
use crate::query::q::CompoundOp;
let mut qs: QuerySet<Fake> = QuerySet::new();
qs.condition = Q::Compound {
op: CompoundOp::And,
parts: vec![
Q::always_true(),
Q::Ilike(FieldRef::<Fake, String>::new("label"), "foo%".to_string()),
],
};
let result = qs.into_basic_predicate();
assert!(
result.is_none(),
"Q::Compound containing Q::Ilike must reduce to None"
);
}
#[test]
fn into_basic_predicate_legacy_condition_refuses() {
use crate::query::condition::{FilterValue, Leaf};
let qs: QuerySet<Fake> =
QuerySet::new().filter(|_| Condition::Leaf(Leaf::eq_raw("a", FilterValue::Bool(true))));
assert!(
matches!(&qs.condition, Q::Condition(_)),
"raw Condition payloads must reach the queryset as Q::Condition (regression in and_condition_into_q?)",
);
let result = qs.into_basic_predicate();
assert!(
result.is_none(),
"Q::Condition (legacy filter path) must reduce to None"
);
}
#[test]
fn into_basic_predicate_negated_non_basic_refuses() {
use crate::query::condition::{FilterValue, Leaf};
let inner_condition = Condition::Leaf(Leaf::eq_raw("x", FilterValue::Bool(false)));
let mut qs: QuerySet<Fake> = QuerySet::new();
qs.condition = Q::Negated(Box::new(Q::Condition(inner_condition)));
let result = qs.into_basic_predicate();
assert!(
result.is_none(),
"Q::Negated(Q::Condition(...)) must reduce to None"
);
}
#[test]
fn into_basic_predicate_xor_reduces() {
let mut qs: QuerySet<Fake> = QuerySet::new();
qs.condition = Q::Xor(Box::new(Q::always_true()), Box::new(Q::always_false()));
let result = qs.into_basic_predicate();
match result {
Some(sassi::BasicPredicate::Xor(left, right)) => {
assert!(matches!(*left, sassi::BasicPredicate::True));
assert!(matches!(*right, sassi::BasicPredicate::False));
}
_ => panic!("expected Some(BasicPredicate::Xor(True, False))"),
}
}
#[test]
fn try_portable_accepts_none_as_portable_false() {
let qs: QuerySet<Fake> = QuerySet::new().none();
assert!(qs.is_portable().is_ok());
let portable = qs.try_portable().expect("none() must satisfy try_portable");
assert!(matches!(portable.predicate(), sassi::BasicPredicate::False));
assert!(portable.into_query_set().is_empty);
}
#[test]
fn try_portable_unfiltered_yields_true_predicate() {
let qs: QuerySet<Fake> = QuerySet::new();
let portable = qs
.try_portable()
.expect("unfiltered queryset must satisfy try_portable");
assert!(matches!(portable.predicate(), sassi::BasicPredicate::True));
}
#[test]
fn try_portable_rejects_legacy_condition_payload() {
use crate::query::condition::{FilterValue, Leaf};
let qs: QuerySet<Fake> =
QuerySet::new().filter(|_| Condition::Leaf(Leaf::eq_raw("a", FilterValue::Bool(true))));
assert!(matches!(
qs.is_portable(),
Err(PortablePredicateError::CacheInvalidNode { kind: "Condition" }),
));
let (recovered, err) = qs
.try_portable()
.expect_err("legacy Condition must fail try_portable");
assert!(matches!(
err,
PortablePredicateError::CacheInvalidNode { kind: "Condition" }
));
assert!(matches!(&recovered.condition, Q::Condition(_)));
}
}