#![allow(clippy::manual_async_fn)]
use crate::DjogiError;
use crate::context::DjogiContext;
use crate::model::Model;
use crate::pg::accumulator::{SqlAccumulator, as_params};
use crate::pg::decode::FromPgRow;
use crate::query::order::OrderExpr;
use crate::query::queryset::QuerySet;
use std::future::Future;
use std::marker::PhantomData;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SetOpKind {
Union,
UnionAll,
Intersect,
Except,
}
impl SetOpKind {
fn keyword(self) -> &'static str {
match self {
SetOpKind::Union => "UNION",
SetOpKind::UnionAll => "UNION ALL",
SetOpKind::Intersect => "INTERSECT",
SetOpKind::Except => "EXCEPT",
}
}
}
#[doc(hidden)]
pub enum SetOpArm<T: Model> {
QuerySet(Box<QuerySet<T>>),
Nested(Box<SetOpQuerySet<T>>),
}
impl<T: Model> std::fmt::Debug for SetOpArm<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SetOpArm::QuerySet(qs) => f.debug_tuple("QuerySet").field(qs).finish(),
SetOpArm::Nested(nested) => f.debug_tuple("Nested").field(nested).finish(),
}
}
}
impl<T: Model> Clone for SetOpArm<T> {
fn clone(&self) -> Self {
match self {
SetOpArm::QuerySet(qs) => SetOpArm::QuerySet(qs.clone()),
SetOpArm::Nested(nested) => SetOpArm::Nested(nested.clone()),
}
}
}
mod sealed {
pub trait Sealed {}
impl<T: super::Model> Sealed for super::QuerySet<T> {}
impl<T: super::Model> Sealed for super::SetOpQuerySet<T> {}
}
pub trait IntoSetOpArm<T: Model>: sealed::Sealed {
#[doc(hidden)]
fn into_set_op_arm(self) -> SetOpArm<T>;
}
impl<T: Model> IntoSetOpArm<T> for QuerySet<T> {
fn into_set_op_arm(self) -> SetOpArm<T> {
SetOpArm::QuerySet(Box::new(self))
}
}
impl<T: Model> IntoSetOpArm<T> for SetOpQuerySet<T> {
fn into_set_op_arm(self) -> SetOpArm<T> {
SetOpArm::Nested(Box::new(self))
}
}
pub struct SetOpQuerySet<T: Model> {
pub(crate) left: SetOpArm<T>,
pub(crate) op: SetOpKind,
pub(crate) right: SetOpArm<T>,
pub(crate) ordering: Vec<OrderExpr>,
pub(crate) limit: Option<i64>,
pub(crate) offset: Option<i64>,
_model: PhantomData<fn() -> T>,
}
impl<T: Model> std::fmt::Debug for SetOpQuerySet<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SetOpQuerySet")
.field("table", &T::table_name())
.field("op", &self.op)
.field("left", &self.left)
.field("right", &self.right)
.field("ordering", &self.ordering)
.field("limit", &self.limit)
.field("offset", &self.offset)
.finish()
}
}
impl<T: Model> Clone for SetOpQuerySet<T> {
fn clone(&self) -> Self {
SetOpQuerySet {
left: self.left.clone(),
op: self.op,
right: self.right.clone(),
ordering: self.ordering.clone(),
limit: self.limit,
offset: self.offset,
_model: PhantomData,
}
}
}
#[cfg_attr(not(feature = "spatial"), allow(clippy::extra_unused_type_parameters))]
fn validate_outer_ordering<T: Model>(ordering: &[OrderExpr]) -> Result<(), DjogiError> {
for o in ordering {
match o {
OrderExpr::Column { .. } => {
}
#[cfg(feature = "spatial")]
OrderExpr::SpatialDistance { .. } => {
return Err(DjogiError::SetOpOuterOrderingInvalid {
table: T::table_name(),
reason: "spatial distance ordering (ST_Distance(...) from \
`order_by_distance`) is an expression, but Postgres \
set-operation outer ORDER BY accepts only output \
column names. Apply spatial ordering on a per-arm \
basis instead, or wrap the set-op result in a \
subquery before ordering by distance",
});
}
}
}
Ok(())
}
fn validate_arm<T: Model>(qs: &QuerySet<T>, side: &'static str) -> Result<(), DjogiError> {
if !qs.prefetch_paths.is_empty() {
return Err(DjogiError::SetOpArmInvalid {
table: T::table_name(),
side,
reason: "arm has registered .prefetch(...) paths; \
prefetch is not supported on set-op arms — \
run the set op then prefetch on the combined result",
});
}
if !qs.select_related_paths.is_empty() {
return Err(DjogiError::SetOpArmInvalid {
table: T::table_name(),
side,
reason: "arm has registered .select_related(...) paths; \
select_related is not supported on set-op arms because \
the joined projection would not match the other arm's column list",
});
}
if !matches!(qs.lock, crate::query::lock::LockMode::None) {
return Err(DjogiError::SetOpArmInvalid {
table: T::table_name(),
side,
reason: "arm has a row-level lock (FOR UPDATE / FOR SHARE / NOWAIT \
/ SKIP LOCKED); Postgres rejects FOR UPDATE and FOR \
SHARE inside a set-op subquery",
});
}
if qs.cache_target.is_some() {
return Err(DjogiError::SetOpArmInvalid {
table: T::table_name(),
side,
reason: "arm has a .cache(&punnu) binding; cache hooks are not yet \
supported on set-op terminals — bind the cache on a plain \
fetch instead",
});
}
Ok(())
}
fn emit_arm<T: Model + FromPgRow>(
acc: &mut SqlAccumulator,
arm: &SetOpArm<T>,
side: &'static str,
) -> Result<(), DjogiError> {
acc.push_sql("(");
match arm {
SetOpArm::QuerySet(qs) => {
validate_arm(qs, side)?;
if qs.is_empty() {
acc.push_sql("SELECT ");
acc.push_sql(<T as FromPgRow>::COLUMN_LIST);
acc.push_sql(" FROM ");
acc.push_sql(T::table_name());
acc.push_sql(" WHERE FALSE");
} else {
let inner = crate::query::sql::build_select(qs)?;
acc.extend_with(inner);
}
}
SetOpArm::Nested(nested) => {
build_set_op_select_inner(acc, nested)?;
}
}
acc.push_sql(")");
Ok(())
}
fn build_set_op_select_inner<T: Model + FromPgRow>(
acc: &mut SqlAccumulator,
sop: &SetOpQuerySet<T>,
) -> Result<(), DjogiError> {
validate_outer_ordering::<T>(&sop.ordering)?;
emit_arm(acc, &sop.left, "left")?;
acc.push_sql(" ");
acc.push_sql(sop.op.keyword());
acc.push_sql(" ");
emit_arm(acc, &sop.right, "right")?;
if !sop.ordering.is_empty() {
acc.push_sql(" ORDER BY ");
for (i, o) in sop.ordering.iter().enumerate() {
if i > 0 {
acc.push_sql(", ");
}
o.emit(acc, None);
}
}
if let Some(n) = sop.limit {
acc.push_sql(" LIMIT ");
acc.push_bind(n);
}
if let Some(n) = sop.offset {
acc.push_sql(" OFFSET ");
acc.push_bind(n);
}
Ok(())
}
pub(crate) fn build_set_op_select<T: Model + FromPgRow>(
sop: &SetOpQuerySet<T>,
) -> Result<SqlAccumulator, DjogiError> {
let mut acc = SqlAccumulator::new("");
build_set_op_select_inner(&mut acc, sop)?;
Ok(acc)
}
pub(crate) fn build_set_op_count<T: Model + FromPgRow>(
sop: &SetOpQuerySet<T>,
) -> Result<SqlAccumulator, DjogiError> {
validate_outer_ordering::<T>(&sop.ordering)?;
let mut acc = SqlAccumulator::new("SELECT COUNT(*) FROM (");
let stripped = SetOpQuerySet {
left: sop.left.clone(),
op: sop.op,
right: sop.right.clone(),
ordering: Vec::new(),
limit: None,
offset: None,
_model: PhantomData,
};
build_set_op_select_inner(&mut acc, &stripped)?;
acc.push_sql(") AS sub");
Ok(acc)
}
impl<T: Model> QuerySet<T> {
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn union<A: IntoSetOpArm<T>>(self, other: A) -> SetOpQuerySet<T> {
SetOpQuerySet {
left: SetOpArm::QuerySet(Box::new(self)),
op: SetOpKind::Union,
right: other.into_set_op_arm(),
ordering: Vec::new(),
limit: None,
offset: None,
_model: PhantomData,
}
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn union_all<A: IntoSetOpArm<T>>(self, other: A) -> SetOpQuerySet<T> {
SetOpQuerySet {
left: SetOpArm::QuerySet(Box::new(self)),
op: SetOpKind::UnionAll,
right: other.into_set_op_arm(),
ordering: Vec::new(),
limit: None,
offset: None,
_model: PhantomData,
}
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn intersect<A: IntoSetOpArm<T>>(self, other: A) -> SetOpQuerySet<T> {
SetOpQuerySet {
left: SetOpArm::QuerySet(Box::new(self)),
op: SetOpKind::Intersect,
right: other.into_set_op_arm(),
ordering: Vec::new(),
limit: None,
offset: None,
_model: PhantomData,
}
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn except<A: IntoSetOpArm<T>>(self, other: A) -> SetOpQuerySet<T> {
SetOpQuerySet {
left: SetOpArm::QuerySet(Box::new(self)),
op: SetOpKind::Except,
right: other.into_set_op_arm(),
ordering: Vec::new(),
limit: None,
offset: None,
_model: PhantomData,
}
}
}
impl<T: Model> SetOpQuerySet<T> {
pub fn op(&self) -> SetOpKind {
self.op
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn union<A: IntoSetOpArm<T>>(self, other: A) -> SetOpQuerySet<T> {
SetOpQuerySet {
left: SetOpArm::Nested(Box::new(self)),
op: SetOpKind::Union,
right: other.into_set_op_arm(),
ordering: Vec::new(),
limit: None,
offset: None,
_model: PhantomData,
}
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn union_all<A: IntoSetOpArm<T>>(self, other: A) -> SetOpQuerySet<T> {
SetOpQuerySet {
left: SetOpArm::Nested(Box::new(self)),
op: SetOpKind::UnionAll,
right: other.into_set_op_arm(),
ordering: Vec::new(),
limit: None,
offset: None,
_model: PhantomData,
}
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn intersect<A: IntoSetOpArm<T>>(self, other: A) -> SetOpQuerySet<T> {
SetOpQuerySet {
left: SetOpArm::Nested(Box::new(self)),
op: SetOpKind::Intersect,
right: other.into_set_op_arm(),
ordering: Vec::new(),
limit: None,
offset: None,
_model: PhantomData,
}
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn except<A: IntoSetOpArm<T>>(self, other: A) -> SetOpQuerySet<T> {
SetOpQuerySet {
left: SetOpArm::Nested(Box::new(self)),
op: SetOpKind::Except,
right: other.into_set_op_arm(),
ordering: Vec::new(),
limit: None,
offset: None,
_model: PhantomData,
}
}
#[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>>,
{
let exprs: Vec<OrderExpr> = f(T::Fields::default()).into();
self.ordering.extend(exprs);
self
}
#[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,
"SetOpQuerySet::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,
"SetOpQuerySet::offset(n = {n}) overflows i64 — Postgres bind type is BIGINT"
);
self.offset = Some(n as i64);
self
}
#[doc(hidden)]
pub fn __sql_for_test(&self) -> Result<String, DjogiError>
where
T: FromPgRow,
{
let acc = build_set_op_select(self)?;
let (sql, _binds) = acc.into_parts();
Ok(sql)
}
#[doc(hidden)]
pub fn __count_sql_for_test(&self) -> Result<(String, u32), DjogiError>
where
T: FromPgRow,
{
let acc = build_set_op_count(self)?;
let bind_count = acc.bind_count();
let (sql, _binds) = acc.into_parts();
Ok((sql, bind_count))
}
}
impl<T: Model> SetOpQuerySet<T>
where
T: FromPgRow + Send + Unpin,
{
pub fn fetch_all<'ctx>(
self,
ctx: &'ctx mut DjogiContext,
) -> impl Future<Output = Result<Vec<T>, DjogiError>> + Send + 'ctx
where
T: 'ctx,
{
async move {
let acc = build_set_op_select(&self)?;
crate::query::terminal::auto_set_tenant::<T>(ctx).await?;
let (sql, binds) = acc.into_parts();
let params = as_params(&binds);
let rows = ctx.query_all(&sql, ¶ms).await?;
let result: Vec<T> = rows
.iter()
.map(|r| T::from_pg_row(r))
.collect::<Result<Vec<T>, _>>()?;
Ok(result)
}
}
pub fn first<'ctx>(
self,
ctx: &'ctx mut DjogiContext,
) -> impl Future<Output = Result<Option<T>, DjogiError>> + Send + 'ctx
where
T: 'ctx,
{
async move {
let mut sop = self;
sop.limit = Some(1);
let acc = build_set_op_select(&sop)?;
crate::query::terminal::auto_set_tenant::<T>(ctx).await?;
let (sql, binds) = acc.into_parts();
let params = as_params(&binds);
let opt = ctx.query_opt(&sql, ¶ms).await?;
opt.as_ref().map(|r| T::from_pg_row(r)).transpose()
}
}
}
impl<T: Model> SetOpQuerySet<T>
where
T: FromPgRow,
{
pub fn count<'ctx>(
self,
ctx: &'ctx mut DjogiContext,
) -> impl Future<Output = Result<i64, DjogiError>> + Send + 'ctx
where
T: 'ctx,
{
async move {
let acc = build_set_op_count(&self)?;
crate::query::terminal::auto_set_tenant::<T>(ctx).await?;
let (sql, binds) = acc.into_parts();
let params = as_params(&binds);
let row = ctx.query_one(&sql, ¶ms).await?;
let n: i64 = crate::pg::decode::try_get_scalar(&row, 0)?;
Ok(n)
}
}
}
#[cfg(any(test, feature = "testing"))]
impl<T> SetOpQuerySet<T>
where
T: Model + FromPgRow,
{
pub fn render_set_op_sql_for_testing(&self) -> Result<String, DjogiError> {
let acc = build_set_op_select(self)?;
let (sql, _binds) = acc.into_parts();
Ok(sql)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::DjogiError;
#[test]
fn set_op_kind_keyword_renders_postgres_tokens() {
assert_eq!(SetOpKind::Union.keyword(), "UNION");
assert_eq!(SetOpKind::UnionAll.keyword(), "UNION ALL");
assert_eq!(SetOpKind::Intersect.keyword(), "INTERSECT");
assert_eq!(SetOpKind::Except.keyword(), "EXCEPT");
}
#[test]
fn set_op_arm_invalid_is_terminal() {
let err = DjogiError::SetOpArmInvalid {
table: "phase8_5_c4b_test",
side: "left",
reason: "test fixture",
};
assert!(err.is_terminal(), "SetOpArmInvalid must be terminal");
assert!(!err.is_transient(), "SetOpArmInvalid must not be transient");
}
#[test]
fn set_op_arm_invalid_display_names_table_and_side() {
let err = DjogiError::SetOpArmInvalid {
table: "phase8_5_c4b_dogs",
side: "right",
reason: "arm has a row-level lock",
};
let msg = format!("{err}");
assert!(msg.contains("phase8_5_c4b_dogs"), "{msg}");
assert!(msg.contains("right"), "{msg}");
assert!(msg.contains("row-level lock"), "{msg}");
}
}