#![allow(clippy::manual_async_fn)]
use crate::DjogiError;
use crate::context::DjogiContext;
use crate::descriptor::PkType;
use crate::model::Model;
use crate::pg::accumulator::{SqlAccumulator, as_params};
use crate::pg::decode::{FromJoinedPgRow, FromPgRow, try_get_scalar};
use crate::query::annotate::IntoAggregateTuple;
use crate::query::closure::ClosureModel;
use crate::query::order::OrderExpr;
use crate::query::queryset::QuerySet;
use crate::query::terminal::auto_set_tenant;
use std::future::Future;
use std::marker::PhantomData;
pub(crate) const LEFT_ALIAS: &str = "l";
pub(crate) const RIGHT_ALIAS: &str = "r";
pub(crate) const LEFT_COLUMN_PREFIX: &str = "l_";
pub(crate) const RIGHT_COLUMN_PREFIX: &str = "r_";
pub(crate) const LEFT_CLOSURE_ALIAS: &str = "la";
pub(crate) const RIGHT_CLOSURE_ALIAS: &str = "ra";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PairSide {
Left,
Right,
}
impl PairSide {
pub(crate) const fn alias(self) -> &'static str {
match self {
PairSide::Left => LEFT_ALIAS,
PairSide::Right => RIGHT_ALIAS,
}
}
pub(crate) const fn column_prefix(self) -> &'static str {
match self {
PairSide::Left => LEFT_COLUMN_PREFIX,
PairSide::Right => RIGHT_COLUMN_PREFIX,
}
}
}
#[derive(Debug, Clone)]
pub struct PairOrderExpr {
pub(crate) side: PairSide,
pub(crate) order: OrderExpr,
}
impl PairOrderExpr {
pub fn left(order: OrderExpr) -> Self {
Self {
side: PairSide::Left,
order,
}
}
pub fn right(order: OrderExpr) -> Self {
Self {
side: PairSide::Right,
order,
}
}
}
#[doc(hidden)]
#[derive(Debug, Clone)]
pub struct ClosurePairJoin {
pub(crate) table: &'static str,
pub(crate) source_column: &'static str,
pub(crate) ancestor_column: &'static str,
pub(crate) depth_column: &'static str,
pub(crate) path_count_column: &'static str,
}
impl ClosurePairJoin {
pub(crate) fn validate_idents(&self) -> Result<(), DjogiError> {
for (label, col) in [
("closure table", self.table),
("source_column", self.source_column),
("ancestor_column", self.ancestor_column),
("depth_column", self.depth_column),
("path_count_column", self.path_count_column),
] {
crate::ident::check_user_supplied_ident(col, true).map_err(|e| {
DjogiError::Validation(format!(
"ClosurePairJoin {label} identifier {col:?} rejected: {e:?}"
))
})?;
}
Ok(())
}
}
pub(crate) fn validated_pk_column_for_pair_identity<M: Model>() -> Result<&'static str, DjogiError>
{
let desc = M::descriptor();
match &desc.pk_type {
PkType::HeerId
| PkType::RanjId
| PkType::HeerIdDesc
| PkType::RanjIdDesc
| PkType::Serial
| PkType::Custom(_) => Ok(desc.pk_column().expect(
"single-column PK shapes always produce a column via ModelDescriptor::pk_column",
)),
PkType::None => Err(DjogiError::Validation(format!(
"model {} has no primary key (PkType::None); pair-tuple paths that reference \
per-row identity (`l.<pk> <> r.<pk>` self-pair anti-equality, closure-pair \
joins, or pair-aggregate GROUP BY) require a single-column PK. Either give \
the model a PK or build the query through a path that does not need row \
identity (cross-join with `include_equal_pk` and no closure-pair join).",
M::table_name()
))),
PkType::Composite(cols) => Err(DjogiError::Validation(format!(
"model {} has a composite primary key ({:?}); the v0.1.0 pair-tuple substrate \
does not emit multi-column anti-equality, closure-pair join keys, or GROUP BY. \
A future slice will add multi-column emission; until then, pair-tuple paths \
that require per-row identity are rejected at the terminal gate.",
M::table_name(),
cols
))),
}
}
pub(crate) fn validate_pair_identity_pk<L: Model, R: Model>(
needs_identity: bool,
) -> Result<(Option<&'static str>, Option<&'static str>), DjogiError> {
if !needs_identity {
return Ok((None, None));
}
let l_pk = validated_pk_column_for_pair_identity::<L>()?;
let r_pk = validated_pk_column_for_pair_identity::<R>()?;
Ok((Some(l_pk), Some(r_pk)))
}
#[must_use = "joined querysets are lazy — dropping one silently omits the query"]
pub struct JoinedQuerySet<L: Model, R: Model> {
pub(crate) left: QuerySet<L>,
pub(crate) right: QuerySet<R>,
pub(crate) exclude_equal_pk: bool,
pub(crate) ordering: Vec<PairOrderExpr>,
pub(crate) limit: Option<i64>,
pub(crate) offset: Option<i64>,
pub(crate) closure_pair: Option<ClosurePairJoin>,
pub(crate) _marker: PhantomData<fn() -> (L, R)>,
}
impl<L: Model, R: Model> std::fmt::Debug for JoinedQuerySet<L, R> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JoinedQuerySet")
.field("left_table", &L::table_name())
.field("right_table", &R::table_name())
.field("left_condition", &self.left.condition)
.field("right_condition", &self.right.condition)
.field("exclude_equal_pk", &self.exclude_equal_pk)
.field("ordering", &self.ordering)
.field("limit", &self.limit)
.field("offset", &self.offset)
.field("closure_pair", &self.closure_pair)
.finish()
}
}
impl<L: Model, R: Model> Clone for JoinedQuerySet<L, R> {
fn clone(&self) -> Self {
Self {
left: self.left.clone(),
right: self.right.clone(),
exclude_equal_pk: self.exclude_equal_pk,
ordering: self.ordering.clone(),
limit: self.limit,
offset: self.offset,
closure_pair: self.closure_pair.clone(),
_marker: PhantomData,
}
}
}
impl<L: Model, R: Model> JoinedQuerySet<L, R> {
#[must_use = "joined querysets are lazy — dropping one silently omits the query"]
pub fn filter_left<F, P>(mut self, f: F) -> Self
where
F: FnOnce(L::Fields) -> P,
P: crate::query::IntoQ<L>,
{
self.left = self.left.filter(f);
self
}
#[must_use = "joined querysets are lazy — dropping one silently omits the query"]
pub fn filter_right<F, P>(mut self, f: F) -> Self
where
F: FnOnce(R::Fields) -> P,
P: crate::query::IntoQ<R>,
{
self.right = self.right.filter(f);
self
}
#[must_use = "joined querysets are lazy — dropping one silently omits the query"]
pub fn include_equal_pk(mut self) -> Self {
self.exclude_equal_pk = false;
self
}
#[must_use = "joined querysets are lazy — dropping one silently omits the query"]
pub fn order_by_left<F>(mut self, f: F) -> Self
where
F: FnOnce(L::Fields) -> OrderExpr,
{
let o = f(L::Fields::default());
self.ordering.push(PairOrderExpr::left(o));
self
}
#[must_use = "joined querysets are lazy — dropping one silently omits the query"]
pub fn order_by_right<F>(mut self, f: F) -> Self
where
F: FnOnce(R::Fields) -> OrderExpr,
{
let o = f(R::Fields::default());
self.ordering.push(PairOrderExpr::right(o));
self
}
#[must_use = "joined querysets are lazy — dropping one silently omits the query"]
pub fn limit(mut self, n: i64) -> Self {
self.limit = Some(n);
self
}
#[must_use = "joined querysets are lazy — dropping one silently omits the query"]
pub fn offset(mut self, n: i64) -> Self {
self.offset = Some(n);
self
}
#[must_use = "joined querysets are lazy — dropping one silently omits the query"]
pub fn annotate<F, A>(self, f: F) -> JoinedAnnotatedQuerySet<L, R, A>
where
F: FnOnce(L::Fields, R::Fields) -> A,
A: IntoAggregateTuple,
{
let aggregates = f(L::Fields::default(), R::Fields::default());
JoinedAnnotatedQuerySet {
inner: self,
aggregates,
qualify: None,
_a: PhantomData,
}
}
}
impl<L: Model> JoinedQuerySet<L, L> {
#[must_use = "joined querysets are lazy — dropping one silently omits the query"]
pub fn left_join_closure_pair<C>(mut self) -> Self
where
C: ClosureModel<Source = L>,
{
self.closure_pair = Some(ClosurePairJoin {
table: C::table(),
source_column: C::source_column(),
ancestor_column: C::ancestor_column(),
depth_column: C::depth_column(),
path_count_column: C::path_count_column(),
});
self
}
}
impl<L: Model, R: Model> JoinedQuerySet<L, R>
where
L: FromPgRow + FromJoinedPgRow + Send + Unpin,
R: FromPgRow + FromJoinedPgRow + Send + Unpin,
{
pub fn fetch_all<'ctx>(
self,
ctx: &'ctx mut DjogiContext,
) -> impl Future<Output = Result<Vec<(L, R)>, DjogiError>> + Send + 'ctx
where
L: 'ctx,
R: 'ctx,
{
async move {
if self.left.is_empty() || self.right.is_empty() {
return Ok(Vec::new());
}
if let Some(cp) = self.closure_pair.as_ref() {
cp.validate_idents()?;
}
let needs_identity = self.exclude_equal_pk || self.closure_pair.is_some();
let (l_pk, r_pk) = validate_pair_identity_pk::<L, R>(needs_identity)?;
auto_set_tenant::<L>(ctx).await?;
auto_set_tenant::<R>(ctx).await?;
let acc = build_joined_select(&self, l_pk, r_pk).map_err(DjogiError::from)?;
let (sql, binds) = acc.into_parts();
let params = as_params(&binds);
let rows = ctx.query_all(&sql, ¶ms).await?;
let mut out: Vec<(L, R)> = Vec::with_capacity(rows.len());
for row in &rows {
let left = L::from_joined_pg_row(row, LEFT_COLUMN_PREFIX)?;
let right = R::from_joined_pg_row(row, RIGHT_COLUMN_PREFIX)?;
out.push((left, right));
}
Ok(out)
}
}
}
impl<L: Model, R: Model> JoinedQuerySet<L, R> {
pub fn count<'ctx>(
self,
ctx: &'ctx mut DjogiContext,
) -> impl Future<Output = Result<i64, DjogiError>> + Send + 'ctx
where
L: 'ctx,
R: 'ctx,
{
async move {
if self.left.is_empty() || self.right.is_empty() {
return Ok(0);
}
if let Some(cp) = self.closure_pair.as_ref() {
cp.validate_idents()?;
}
let needs_identity = self.exclude_equal_pk;
let (l_pk, r_pk) = validate_pair_identity_pk::<L, R>(needs_identity)?;
auto_set_tenant::<L>(ctx).await?;
auto_set_tenant::<R>(ctx).await?;
let acc = build_joined_count(&self, l_pk, r_pk).map_err(DjogiError::from)?;
let (sql, binds) = acc.into_parts();
let params = as_params(&binds);
let row = ctx.query_one(&sql, ¶ms).await?;
try_get_scalar::<i64>(&row, 0)
}
}
}
impl<L: Model> QuerySet<L> {
#[must_use = "joined querysets are lazy — dropping one silently omits the query"]
pub fn cross_join_with<R: Model>(self, other: QuerySet<R>) -> JoinedQuerySet<L, R> {
JoinedQuerySet {
left: self,
right: other,
exclude_equal_pk: false,
ordering: Vec::new(),
limit: None,
offset: None,
closure_pair: None,
_marker: PhantomData,
}
}
#[must_use = "joined querysets are lazy — dropping one silently omits the query"]
pub fn self_pairs(self) -> JoinedQuerySet<L, L> {
let right = self.clone();
JoinedQuerySet {
left: self,
right,
exclude_equal_pk: true,
ordering: Vec::new(),
limit: None,
offset: None,
closure_pair: None,
_marker: PhantomData,
}
}
}
#[must_use = "joined querysets are lazy — dropping one silently omits the query"]
pub struct JoinedAnnotatedQuerySet<L: Model, R: Model, A: IntoAggregateTuple> {
pub(crate) inner: JoinedQuerySet<L, R>,
pub(crate) aggregates: A,
pub(crate) qualify: Option<crate::expr::QualifyCondition>,
pub(crate) _a: PhantomData<fn() -> A>,
}
impl<L: Model, R: Model, A: IntoAggregateTuple> JoinedAnnotatedQuerySet<L, R, A> {
#[must_use = "joined querysets are lazy — dropping one silently omits the query"]
pub fn qualify<F>(mut self, f: F) -> Self
where
F: FnOnce(&A) -> crate::expr::QualifyCondition,
{
let cond = f(&self.aggregates);
self.qualify = Some(cond);
self
}
#[must_use = "joined querysets are lazy — dropping one silently omits the query"]
pub fn order_by_left<F>(mut self, f: F) -> Self
where
F: FnOnce(L::Fields) -> OrderExpr,
{
self.inner = self.inner.order_by_left(f);
self
}
#[must_use = "joined querysets are lazy — dropping one silently omits the query"]
pub fn order_by_right<F>(mut self, f: F) -> Self
where
F: FnOnce(R::Fields) -> OrderExpr,
{
self.inner = self.inner.order_by_right(f);
self
}
#[must_use = "joined querysets are lazy — dropping one silently omits the query"]
pub fn limit(mut self, n: i64) -> Self {
self.inner = self.inner.limit(n);
self
}
#[must_use = "joined querysets are lazy — dropping one silently omits the query"]
pub fn offset(mut self, n: i64) -> Self {
self.inner = self.inner.offset(n);
self
}
}
pub type JoinedAnnotatedRow<L, R, A> = ((L, R), <A as IntoAggregateTuple>::Decoded);
impl<L: Model, R: Model, A: IntoAggregateTuple + Send> JoinedAnnotatedQuerySet<L, R, A>
where
L: FromPgRow + FromJoinedPgRow + Send + Unpin,
R: FromPgRow + FromJoinedPgRow + Send + Unpin,
{
pub fn fetch_all<'ctx>(
self,
ctx: &'ctx mut DjogiContext,
) -> impl Future<Output = Result<Vec<JoinedAnnotatedRow<L, R, A>>, DjogiError>> + Send + 'ctx
where
L: 'ctx,
R: 'ctx,
A: 'ctx,
A::Decoded: Send + 'ctx,
{
async move {
if self.inner.left.is_empty() || self.inner.right.is_empty() {
return Ok(Vec::new());
}
let JoinedAnnotatedQuerySet {
inner,
aggregates,
qualify,
..
} = self;
aggregates.check_legality()?;
if aggregates.requires_closure_pair_join() && inner.closure_pair.is_none() {
return Err(DjogiError::Validation(
"annotated joined-queryset terminal includes a closure-pair aggregate \
(e.g. PairClosureKinshipSum) but the queryset has no \
`left_join_closure_pair::<C>()` join. Call \
`.left_join_closure_pair::<YourClosure>()` on the JoinedQuerySet \
before `.annotate(...)`."
.to_string(),
));
}
if let Some(cp) = inner.closure_pair.as_ref() {
cp.validate_idents()?;
}
aggregates.validate_against_closure_pair(inner.closure_pair.as_ref())?;
if !aggregates.is_joined_safe() {
return Err(DjogiError::Validation(
"joined-queryset `.annotate(...)` rejected an annotation slot that is \
not joined-context-safe. Ordinary single-Model aggregates \
(e.g. `l.age().sum()`) emit a bare column reference like `SUM(age) \
OVER ()` that is ambiguous when both pair sides share column names \
(always true for self-joins). Use a pair-aware annotation: \
`PairClosureKinshipSum<C>` for kinship summation, or a window \
function with `partition_by_pair(PairSide::Left, ...)` / \
`order_by_pair_asc(PairSide::Right, ...)`. A pair-aware ordinary \
aggregate surface is a future slice."
.to_string(),
));
}
let needs_identity = inner.exclude_equal_pk
|| inner.closure_pair.is_some()
|| aggregates.requires_closure_pair_join();
let (l_pk, r_pk) = validate_pair_identity_pk::<L, R>(needs_identity)?;
auto_set_tenant::<L>(ctx).await?;
auto_set_tenant::<R>(ctx).await?;
let acc = build_joined_annotated_select_for_fetch(
&inner,
&aggregates,
qualify.as_ref(),
l_pk,
r_pk,
)
.map_err(DjogiError::from)?;
let (sql, binds) = acc.into_parts();
let params = as_params(&binds);
let rows = ctx.query_all(&sql, ¶ms).await?;
let mut out: Vec<JoinedAnnotatedRow<L, R, A>> = Vec::with_capacity(rows.len());
for row in &rows {
let left = L::from_joined_pg_row(row, LEFT_COLUMN_PREFIX)?;
let right = R::from_joined_pg_row(row, RIGHT_COLUMN_PREFIX)?;
let agg = aggregates.decode_tuple(row).map_err(DjogiError::from)?;
out.push(((left, right), agg));
}
Ok(out)
}
}
}
pub(crate) fn build_joined_select<L, R>(
jqs: &JoinedQuerySet<L, R>,
l_pk: Option<&'static str>,
r_pk: Option<&'static str>,
) -> Result<SqlAccumulator, crate::query::PortablePredicateError>
where
L: Model + crate::pg::decode::FromPgRow,
R: Model + crate::pg::decode::FromPgRow,
{
let mut acc = SqlAccumulator::new("SELECT ");
push_aliased_columns::<L>(&mut acc, PairSide::Left, true);
push_aliased_columns::<R>(&mut acc, PairSide::Right, false);
acc.push_sql(" FROM ");
acc.push_sql(L::table_name());
acc.push_sql(" AS ");
acc.push_sql(LEFT_ALIAS);
acc.push_sql(" CROSS JOIN ");
acc.push_sql(R::table_name());
acc.push_sql(" AS ");
acc.push_sql(RIGHT_ALIAS);
push_closure_pair_joins::<L, R>(&mut acc, jqs, l_pk, r_pk);
push_joined_where::<L, R>(&mut acc, jqs, l_pk, r_pk)?;
push_joined_order_by(&mut acc, &jqs.ordering);
if let Some(n) = jqs.limit {
acc.push_sql(" LIMIT ");
acc.push_bind(n);
}
if let Some(n) = jqs.offset {
acc.push_sql(" OFFSET ");
acc.push_bind(n);
}
Ok(acc)
}
pub(crate) fn build_joined_count<L: Model, R: Model>(
jqs: &JoinedQuerySet<L, R>,
l_pk: Option<&'static str>,
r_pk: Option<&'static str>,
) -> Result<SqlAccumulator, crate::query::PortablePredicateError> {
let mut acc = SqlAccumulator::new("SELECT COUNT(*) FROM ");
acc.push_sql(L::table_name());
acc.push_sql(" AS ");
acc.push_sql(LEFT_ALIAS);
acc.push_sql(" CROSS JOIN ");
acc.push_sql(R::table_name());
acc.push_sql(" AS ");
acc.push_sql(RIGHT_ALIAS);
push_joined_where::<L, R>(&mut acc, jqs, l_pk, r_pk)?;
Ok(acc)
}
pub(crate) fn build_joined_annotated_select_for_fetch<L, R, A>(
jqs: &JoinedQuerySet<L, R>,
aggregates: &A,
qualify: Option<&crate::expr::QualifyCondition>,
l_pk: Option<&'static str>,
r_pk: Option<&'static str>,
) -> Result<SqlAccumulator, crate::query::PortablePredicateError>
where
L: Model + crate::pg::decode::FromPgRow,
R: Model + crate::pg::decode::FromPgRow,
A: IntoAggregateTuple,
{
let inner = build_joined_annotated_inner::<L, R, A>(jqs, aggregates, l_pk, r_pk)?;
let Some(qualify) = qualify else {
return Ok(inner);
};
let mut wrapped = SqlAccumulator::new("SELECT * FROM (");
wrapped.extend_with(inner);
wrapped.push_sql(") AS __djogi_q WHERE ");
qualify.push_outer_where(&mut wrapped);
Ok(wrapped)
}
fn build_joined_annotated_inner<L, R, A>(
jqs: &JoinedQuerySet<L, R>,
aggregates: &A,
l_pk: Option<&'static str>,
r_pk: Option<&'static str>,
) -> Result<SqlAccumulator, crate::query::PortablePredicateError>
where
L: Model + crate::pg::decode::FromPgRow,
R: Model + crate::pg::decode::FromPgRow,
A: IntoAggregateTuple,
{
let mut acc = SqlAccumulator::new("SELECT ");
push_aliased_columns::<L>(&mut acc, PairSide::Left, true);
push_aliased_columns::<R>(&mut acc, PairSide::Right, false);
aggregates.push_columns(&mut acc);
acc.push_sql(" FROM ");
acc.push_sql(L::table_name());
acc.push_sql(" AS ");
acc.push_sql(LEFT_ALIAS);
acc.push_sql(" CROSS JOIN ");
acc.push_sql(R::table_name());
acc.push_sql(" AS ");
acc.push_sql(RIGHT_ALIAS);
push_closure_pair_joins::<L, R>(&mut acc, jqs, l_pk, r_pk);
push_joined_where::<L, R>(&mut acc, jqs, l_pk, r_pk)?;
push_joined_group_by_if_needed::<L, R, A>(&mut acc, jqs, aggregates, l_pk, r_pk);
push_joined_order_by(&mut acc, &jqs.ordering);
if let Some(n) = jqs.limit {
acc.push_sql(" LIMIT ");
acc.push_bind(n);
}
if let Some(n) = jqs.offset {
acc.push_sql(" OFFSET ");
acc.push_bind(n);
}
Ok(acc)
}
fn push_joined_group_by_if_needed<L, R, A>(
acc: &mut SqlAccumulator,
jqs: &JoinedQuerySet<L, R>,
aggregates: &A,
l_pk: Option<&'static str>,
r_pk: Option<&'static str>,
) where
L: Model,
R: Model,
A: IntoAggregateTuple,
{
if !aggregates.requires_closure_pair_join() || jqs.closure_pair.is_none() {
return;
}
let l_pk = l_pk.expect(
"terminal must validate left PK via validated_pk_column_for_pair_identity \
when the aggregate tuple requires a closure-pair join",
);
let r_pk = r_pk.expect(
"terminal must validate right PK via validated_pk_column_for_pair_identity \
when the aggregate tuple requires a closure-pair join",
);
acc.push_sql(" GROUP BY ");
acc.push_sql(LEFT_ALIAS);
acc.push_sql(".");
acc.push_sql(l_pk);
acc.push_sql(", ");
acc.push_sql(RIGHT_ALIAS);
acc.push_sql(".");
acc.push_sql(r_pk);
}
pub(crate) fn push_aliased_columns<M: Model + crate::pg::decode::FromPgRow>(
acc: &mut SqlAccumulator,
side: PairSide,
is_first_block: bool,
) {
let alias = side.alias();
let prefix = side.column_prefix();
for (i, col) in <M as crate::pg::decode::FromPgRow>::COLUMNS
.iter()
.enumerate()
{
if !(is_first_block && i == 0) {
acc.push_sql(", ");
}
acc.push_sql(alias);
acc.push_sql(".");
acc.push_sql(col);
acc.push_sql(" AS ");
acc.push_sql(prefix);
acc.push_sql(col);
}
}
fn push_closure_pair_joins<L: Model, R: Model>(
acc: &mut SqlAccumulator,
jqs: &JoinedQuerySet<L, R>,
l_pk: Option<&'static str>,
r_pk: Option<&'static str>,
) {
let Some(cp) = jqs.closure_pair.as_ref() else {
return;
};
let l_pk = l_pk.expect(
"terminal must validate left PK via validated_pk_column_for_pair_identity \
before emitting closure-pair LEFT JOIN ON-clauses",
);
let r_pk = r_pk.expect(
"terminal must validate right PK via validated_pk_column_for_pair_identity \
before emitting closure-pair LEFT JOIN ON-clauses",
);
acc.push_sql(" LEFT JOIN ");
acc.push_sql(cp.table);
acc.push_sql(" AS ");
acc.push_sql(LEFT_CLOSURE_ALIAS);
acc.push_sql(" ON ");
acc.push_sql(LEFT_CLOSURE_ALIAS);
acc.push_sql(".");
acc.push_sql(cp.source_column);
acc.push_sql(" = ");
acc.push_sql(LEFT_ALIAS);
acc.push_sql(".");
acc.push_sql(l_pk);
acc.push_sql(" LEFT JOIN ");
acc.push_sql(cp.table);
acc.push_sql(" AS ");
acc.push_sql(RIGHT_CLOSURE_ALIAS);
acc.push_sql(" ON ");
acc.push_sql(RIGHT_CLOSURE_ALIAS);
acc.push_sql(".");
acc.push_sql(cp.source_column);
acc.push_sql(" = ");
acc.push_sql(RIGHT_ALIAS);
acc.push_sql(".");
acc.push_sql(r_pk);
acc.push_sql(" AND ");
acc.push_sql(RIGHT_CLOSURE_ALIAS);
acc.push_sql(".");
acc.push_sql(cp.ancestor_column);
acc.push_sql(" = ");
acc.push_sql(LEFT_CLOSURE_ALIAS);
acc.push_sql(".");
acc.push_sql(cp.ancestor_column);
}
fn push_joined_where<L: Model, R: Model>(
acc: &mut SqlAccumulator,
jqs: &JoinedQuerySet<L, R>,
l_pk: Option<&'static str>,
r_pk: Option<&'static str>,
) -> Result<(), crate::query::PortablePredicateError> {
let mut parts: Vec<&'static str> = Vec::with_capacity(3);
let left_has_condition = !crate::query::sql::q_is_vacuously_true(&jqs.left.condition);
let right_has_condition = !crate::query::sql::q_is_vacuously_true(&jqs.right.condition);
if jqs.exclude_equal_pk {
parts.push("pair");
}
if left_has_condition {
parts.push("left");
}
if right_has_condition {
parts.push("right");
}
if parts.is_empty() {
return Ok(());
}
acc.push_sql(" WHERE ");
for (i, tag) in parts.iter().enumerate() {
if i > 0 {
acc.push_sql(" AND ");
}
match *tag {
"pair" => {
let l_pk = l_pk.expect(
"terminal must validate left PK via validated_pk_column_for_pair_identity \
before emitting self-pair anti-equality (exclude_equal_pk=true)",
);
let r_pk = r_pk.expect(
"terminal must validate right PK via validated_pk_column_for_pair_identity \
before emitting self-pair anti-equality (exclude_equal_pk=true)",
);
acc.push_sql(LEFT_ALIAS);
acc.push_sql(".");
acc.push_sql(l_pk);
acc.push_sql(" <> ");
acc.push_sql(RIGHT_ALIAS);
acc.push_sql(".");
acc.push_sql(r_pk);
}
"left" => {
let ctx = crate::query::SqlEmitContext::joined(LEFT_ALIAS);
crate::query::sql::emit_q::<L>(acc, &jqs.left.condition, ctx)?;
}
"right" => {
let ctx = crate::query::SqlEmitContext::joined(RIGHT_ALIAS);
crate::query::sql::emit_q::<R>(acc, &jqs.right.condition, ctx)?;
}
_ => unreachable!("part tag must be one of pair/left/right"),
}
}
Ok(())
}
fn push_joined_order_by(acc: &mut SqlAccumulator, ordering: &[PairOrderExpr]) {
if ordering.is_empty() {
return;
}
acc.push_sql(" ORDER BY ");
for (i, ord) in ordering.iter().enumerate() {
if i > 0 {
acc.push_sql(", ");
}
ord.order.emit(acc, Some(ord.side.alias()));
}
}
pub trait PairWindowExt: Sized {
#[must_use = "window functions are lazy annotations - dropping one omits the column"]
fn partition_by_pair<M, V, S>(self, side: PairSide, field: S) -> Self
where
M: Model,
S: crate::query::field::IntoSqlField<M, V>;
#[must_use = "window functions are lazy annotations - dropping one omits the column"]
fn order_by_pair_asc<M, V, S>(self, side: PairSide, field: S) -> Self
where
M: Model,
S: crate::query::field::IntoSqlField<M, V>;
#[must_use = "window functions are lazy annotations - dropping one omits the column"]
fn order_by_pair_desc<M, V, S>(self, side: PairSide, field: S) -> Self
where
M: Model,
S: crate::query::field::IntoSqlField<M, V>;
}
fn intern_alias_column(alias: &'static str, column: &'static str) -> &'static str {
use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
static INTERN: OnceLock<Mutex<HashSet<&'static str>>> = OnceLock::new();
let set_mutex = INTERN.get_or_init(|| Mutex::new(HashSet::new()));
let candidate = format!("{alias}.{column}");
let mut set = set_mutex
.lock()
.expect("joined-window intern mutex poisoned");
if let Some(existing) = set.get(candidate.as_str()) {
return existing;
}
let leaked: &'static str = Box::leak(candidate.into_boxed_str());
set.insert(leaked);
leaked
}
macro_rules! impl_pair_window_ext {
($ty:ident) => {
impl PairWindowExt for crate::expr::$ty {
fn partition_by_pair<M, V, S>(mut self, side: PairSide, field: S) -> Self
where
M: Model,
S: crate::query::field::IntoSqlField<M, V>,
{
let qualified = intern_alias_column(side.alias(), field.into_sql_field().column());
self.window.partition_by.push(qualified);
self
}
fn order_by_pair_asc<M, V, S>(mut self, side: PairSide, field: S) -> Self
where
M: Model,
S: crate::query::field::IntoSqlField<M, V>,
{
let qualified = intern_alias_column(side.alias(), field.into_sql_field().column());
self.window
.order_by
.push((qualified, crate::query::order::Direction::Asc));
self
}
fn order_by_pair_desc<M, V, S>(mut self, side: PairSide, field: S) -> Self
where
M: Model,
S: crate::query::field::IntoSqlField<M, V>,
{
let qualified = intern_alias_column(side.alias(), field.into_sql_field().column());
self.window
.order_by
.push((qualified, crate::query::order::Direction::Desc));
self
}
}
};
}
impl_pair_window_ext!(RowNumber);
impl_pair_window_ext!(Rank);
impl_pair_window_ext!(DenseRank);
pub struct PairClosureKinshipSum<C: ClosureModel> {
_c: PhantomData<fn() -> C>,
}
impl<C: ClosureModel> Default for PairClosureKinshipSum<C> {
fn default() -> Self {
Self::new()
}
}
impl<C: ClosureModel> PairClosureKinshipSum<C> {
pub fn new() -> Self {
Self { _c: PhantomData }
}
fn depth_column() -> &'static str {
C::depth_column()
}
fn path_count_column() -> &'static str {
C::path_count_column()
}
fn emit_inline(acc: &mut SqlAccumulator) {
acc.push_sql("COALESCE(SUM(");
acc.push_sql(LEFT_CLOSURE_ALIAS);
acc.push_sql(".");
acc.push_sql(Self::path_count_column());
acc.push_sql("::numeric * ");
acc.push_sql(RIGHT_CLOSURE_ALIAS);
acc.push_sql(".");
acc.push_sql(Self::path_count_column());
acc.push_sql("::numeric * POWER(0.5::numeric, (");
acc.push_sql(LEFT_CLOSURE_ALIAS);
acc.push_sql(".");
acc.push_sql(Self::depth_column());
acc.push_sql(" + ");
acc.push_sql(RIGHT_CLOSURE_ALIAS);
acc.push_sql(".");
acc.push_sql(Self::depth_column());
acc.push_sql(" + 1)::numeric)), 0)::float8");
}
}
impl<C: ClosureModel> crate::query::annotate::AnnotationSlot for PairClosureKinshipSum<C> {
type Decoded = f64;
fn push_column(&self, acc: &mut SqlAccumulator, slot: usize) {
acc.push_sql(", ");
Self::emit_inline(acc);
acc.push_sql(" AS ");
acc.push_sql(annotation_alias(slot));
}
fn push_column_bare(&self, acc: &mut SqlAccumulator, slot: usize) {
self.push_column_bare_after(acc, slot, true);
}
fn push_column_bare_after(
&self,
acc: &mut SqlAccumulator,
slot: usize,
has_previous_columns: bool,
) {
if has_previous_columns {
acc.push_sql(", ");
}
Self::emit_inline(acc);
acc.push_sql(" AS ");
acc.push_sql(annotation_alias(slot));
}
fn decode_column(
&self,
row: &tokio_postgres::Row,
slot: usize,
) -> Result<Self::Decoded, tokio_postgres::Error> {
row.try_get::<_, f64>(annotation_alias(slot))
}
fn requires_closure_pair_join(&self) -> bool {
true
}
fn is_joined_safe(&self) -> bool {
true
}
fn requires_pair_tuple_scope(&self) -> bool {
true
}
fn validate_against_closure_pair(
&self,
closure_pair: Option<&crate::query::joined::ClosurePairJoin>,
) -> Result<(), crate::DjogiError> {
crate::query::closure::validate_closure_metadata_idents::<C>()?;
if let Some(cp) = closure_pair {
let c_table = C::table();
let c_source = C::source_column();
let c_ancestor = C::ancestor_column();
let c_depth = C::depth_column();
let c_path_count = C::path_count_column();
if cp.table != c_table
|| cp.source_column != c_source
|| cp.ancestor_column != c_ancestor
|| cp.depth_column != c_depth
|| cp.path_count_column != c_path_count
{
return Err(crate::DjogiError::Validation(format!(
"ClosureModel mismatch on PairClosureKinshipSum: \
`left_join_closure_pair::<...>()` captured \
{{ table: {join_table:?}, source: {join_source:?}, \
ancestor: {join_ancestor:?}, depth: {join_depth:?}, \
path_count: {join_path_count:?} }} but \
PairClosureKinshipSum<{c_type}> emits \
{{ table: {c_table:?}, source: {c_source:?}, \
ancestor: {c_ancestor:?}, depth: {c_depth:?}, \
path_count: {c_path_count:?} }}. \
The aggregate and the join must reference the same \
ClosureModel — use the same `C` type parameter on both \
`left_join_closure_pair::<C>()` and `PairClosureKinshipSum::<C>::new()`.",
join_table = cp.table,
join_source = cp.source_column,
join_ancestor = cp.ancestor_column,
join_depth = cp.depth_column,
join_path_count = cp.path_count_column,
c_type = std::any::type_name::<C>(),
c_table = c_table,
c_source = c_source,
c_ancestor = c_ancestor,
c_depth = c_depth,
c_path_count = c_path_count,
)));
}
}
Ok(())
}
}
impl<C: ClosureModel> crate::query::annotate::PlainAnnotationSlot for PairClosureKinshipSum<C> {}
fn annotation_alias(slot: usize) -> &'static str {
match slot {
0 => "__djogi_agg_0",
1 => "__djogi_agg_1",
2 => "__djogi_agg_2",
3 => "__djogi_agg_3",
_ => unreachable!(
"djogi annotate arity max is 4 — slot {slot} not reachable. \
A new impl_into_aggregate_tuple! arity must extend this match."
),
}
}
impl<C: ClosureModel> crate::query::annotate::annotation_slot_sealed::Sealed
for PairClosureKinshipSum<C>
{
}
#[cfg(feature = "spatial")]
pub struct PairAreaOverlapRatio<L: Model, R: Model> {
left_column: &'static str,
right_column: &'static str,
_marker: PhantomData<fn() -> (L, R)>,
}
#[cfg(feature = "spatial")]
impl<L: Model, R: Model> std::fmt::Debug for PairAreaOverlapRatio<L, R> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PairAreaOverlapRatio")
.field("left_column", &self.left_column)
.field("right_column", &self.right_column)
.field("left_table", &L::table_name())
.field("right_table", &R::table_name())
.finish()
}
}
#[cfg(feature = "spatial")]
impl<L: Model, R: Model> Clone for PairAreaOverlapRatio<L, R> {
fn clone(&self) -> Self {
Self {
left_column: self.left_column,
right_column: self.right_column,
_marker: PhantomData,
}
}
}
#[cfg(feature = "spatial")]
impl<L: Model, R: Model> PairAreaOverlapRatio<L, R> {
pub fn new<VL, VR, SL, SR>(left_geom: SL, right_geom: SR) -> Self
where
VL: crate::geo::SpatialColumnValue,
VR: crate::geo::SpatialColumnValue,
SL: crate::query::field::IntoSqlField<L, VL>,
SR: crate::query::field::IntoSqlField<R, VR>,
{
Self {
left_column: left_geom.into_sql_field().column(),
right_column: right_geom.into_sql_field().column(),
_marker: PhantomData,
}
}
fn emit_inline(&self, acc: &mut SqlAccumulator) {
acc.push_sql("COALESCE(ST_Area(ST_Intersection(");
acc.push_sql(LEFT_ALIAS);
acc.push_sql(".");
acc.push_sql(self.left_column);
acc.push_sql("::geometry, ");
acc.push_sql(RIGHT_ALIAS);
acc.push_sql(".");
acc.push_sql(self.right_column);
acc.push_sql("::geometry)::geography), 0)::float8 / NULLIF(ST_Area(");
acc.push_sql(LEFT_ALIAS);
acc.push_sql(".");
acc.push_sql(self.left_column);
acc.push_sql("::geography), 0)::float8");
}
}
#[cfg(feature = "spatial")]
impl<L: Model, R: Model> crate::query::annotate::AnnotationSlot for PairAreaOverlapRatio<L, R> {
type Decoded = f64;
fn push_column(&self, acc: &mut SqlAccumulator, slot: usize) {
acc.push_sql(", ");
self.emit_inline(acc);
acc.push_sql(" AS ");
acc.push_sql(annotation_alias(slot));
}
fn push_column_bare(&self, acc: &mut SqlAccumulator, slot: usize) {
self.push_column_bare_after(acc, slot, true);
}
fn push_column_bare_after(
&self,
acc: &mut SqlAccumulator,
slot: usize,
has_previous_columns: bool,
) {
if has_previous_columns {
acc.push_sql(", ");
}
self.emit_inline(acc);
acc.push_sql(" AS ");
acc.push_sql(annotation_alias(slot));
}
fn decode_column(
&self,
row: &tokio_postgres::Row,
slot: usize,
) -> Result<Self::Decoded, tokio_postgres::Error> {
let v: Option<f64> = row.try_get(annotation_alias(slot))?;
Ok(v.unwrap_or(0.0))
}
fn is_joined_safe(&self) -> bool {
true
}
fn requires_pair_tuple_scope(&self) -> bool {
true
}
}
#[cfg(feature = "spatial")]
impl<L: Model, R: Model> crate::query::annotate::PlainAnnotationSlot
for PairAreaOverlapRatio<L, R>
{
}
#[cfg(feature = "spatial")]
impl<L: Model, R: Model> crate::query::annotate::annotation_slot_sealed::Sealed
for PairAreaOverlapRatio<L, R>
{
}
#[cfg(test)]
mod tests {
use super::*;
use crate::descriptor::{
FieldDescriptor, FieldSqlType, ModelDescriptor, PkType, field_descriptor, model_descriptor,
};
use crate::pg::decode::FromPgRow;
use crate::types::HeerId;
struct Mini;
impl crate::model::__sealed::Sealed for Mini {}
#[allow(clippy::manual_async_fn)]
impl crate::model::Model for Mini {
type Pk = HeerId;
type Fields = ();
fn table_name() -> &'static str {
"minis"
}
fn pk_value(&self) -> &Self::Pk {
unreachable!()
}
fn descriptor() -> &'static ModelDescriptor {
&MINI_DESC
}
fn get(
_ctx: &mut DjogiContext,
_id: Self::Pk,
) -> impl Future<Output = Result<Self, DjogiError>> + Send {
async { unreachable!() }
}
fn create(
_ctx: &mut DjogiContext,
_v: Self,
) -> impl Future<Output = Result<Self, DjogiError>> + Send {
async { unreachable!() }
}
fn save<'ctx>(
&'ctx mut self,
_ctx: &'ctx mut DjogiContext,
) -> impl Future<Output = Result<(), DjogiError>> + Send + 'ctx {
async { unreachable!() }
}
fn delete(
self,
_ctx: &mut DjogiContext,
) -> impl Future<Output = Result<(), DjogiError>> + Send {
async { unreachable!() }
}
fn refresh_from_db<'ctx>(
&'ctx self,
_ctx: &'ctx mut DjogiContext,
) -> impl Future<Output = Result<Self, DjogiError>> + Send + 'ctx {
async { unreachable!() }
}
}
impl FromPgRow for Mini {
const COLUMNS: &'static [&'static str] = &["id", "name"];
const COLUMN_LIST: &'static str = "id, name";
fn from_pg_row(_row: &tokio_postgres::Row) -> Result<Self, DjogiError> {
unreachable!()
}
}
static MINI_FIELDS: &[FieldDescriptor] = &[field_descriptor("name", FieldSqlType::Text, false)];
static MINI_DESC: ModelDescriptor =
model_descriptor("Mini", "minis", PkType::HeerId, MINI_FIELDS);
fn build_joined_select_for_test<L, R>(
jqs: &JoinedQuerySet<L, R>,
) -> Result<SqlAccumulator, crate::query::PortablePredicateError>
where
L: Model + crate::pg::decode::FromPgRow,
R: Model + crate::pg::decode::FromPgRow,
{
let needs_identity = jqs.exclude_equal_pk || jqs.closure_pair.is_some();
let (l_pk, r_pk) = validate_pair_identity_pk::<L, R>(needs_identity)
.expect("test fixtures use single-column PKs that pass the validator");
build_joined_select(jqs, l_pk, r_pk)
}
fn build_joined_count_for_test<L, R>(
jqs: &JoinedQuerySet<L, R>,
) -> Result<SqlAccumulator, crate::query::PortablePredicateError>
where
L: Model,
R: Model,
{
let needs_identity = jqs.exclude_equal_pk;
let (l_pk, r_pk) = validate_pair_identity_pk::<L, R>(needs_identity)
.expect("test fixtures use single-column PKs that pass the validator");
build_joined_count(jqs, l_pk, r_pk)
}
fn build_joined_annotated_select_for_fetch_for_test<L, R, A>(
jqs: &JoinedQuerySet<L, R>,
aggregates: &A,
qualify: Option<&crate::expr::QualifyCondition>,
) -> Result<SqlAccumulator, crate::query::PortablePredicateError>
where
L: Model + crate::pg::decode::FromPgRow,
R: Model + crate::pg::decode::FromPgRow,
A: IntoAggregateTuple,
{
let needs_identity = jqs.exclude_equal_pk
|| jqs.closure_pair.is_some()
|| aggregates.requires_closure_pair_join();
let (l_pk, r_pk) = validate_pair_identity_pk::<L, R>(needs_identity)
.expect("test fixtures use single-column PKs that pass the validator");
build_joined_annotated_select_for_fetch(jqs, aggregates, qualify, l_pk, r_pk)
}
#[test]
fn self_pairs_emits_cross_join_and_exclude_equal_pk() {
let jqs: JoinedQuerySet<Mini, Mini> = QuerySet::<Mini>::new().self_pairs();
let acc = build_joined_select_for_test(&jqs).expect("emit");
let sql = acc.sql();
assert!(
sql.contains("FROM minis AS l CROSS JOIN minis AS r"),
"cross-join FROM shape mismatch: {sql}"
);
assert!(
sql.contains("WHERE l.id <> r.id"),
"self_pairs default exclude_equal_pk missing: {sql}"
);
assert!(sql.contains("l.id AS l_id"), "left id alias missing: {sql}");
assert!(
sql.contains("l.name AS l_name"),
"left name alias missing: {sql}"
);
assert!(
sql.contains("r.id AS r_id"),
"right id alias missing: {sql}"
);
assert!(
sql.contains("r.name AS r_name"),
"right name alias missing: {sql}"
);
}
#[test]
fn cross_join_with_does_not_emit_exclude_pk_default() {
let jqs: JoinedQuerySet<Mini, Mini> =
QuerySet::<Mini>::new().cross_join_with(QuerySet::<Mini>::new());
let acc = build_joined_select_for_test(&jqs).expect("emit");
let sql = acc.sql();
assert!(
!sql.contains("WHERE l.id <> r.id"),
"cross_join_with must NOT inject exclude_equal_pk by default: {sql}"
);
assert!(
sql.contains("FROM minis AS l CROSS JOIN minis AS r"),
"cross-join FROM shape: {sql}"
);
}
#[test]
fn include_equal_pk_drops_anti_equality_filter() {
let jqs: JoinedQuerySet<Mini, Mini> =
QuerySet::<Mini>::new().self_pairs().include_equal_pk();
let acc = build_joined_select_for_test(&jqs).expect("emit");
let sql = acc.sql();
assert!(
!sql.contains("WHERE l.id <> r.id"),
"include_equal_pk should drop the default anti-equality: {sql}"
);
}
#[test]
fn limit_offset_emit_after_select_body() {
let jqs: JoinedQuerySet<Mini, Mini> =
QuerySet::<Mini>::new().self_pairs().limit(10).offset(5);
let acc = build_joined_select_for_test(&jqs).expect("emit");
let sql = acc.sql();
let limit_idx = sql.find("LIMIT").expect("LIMIT in SQL");
let offset_idx = sql.find("OFFSET").expect("OFFSET in SQL");
assert!(limit_idx < offset_idx, "LIMIT must precede OFFSET: {sql}");
}
#[test]
fn order_by_emits_alias_qualified_columns() {
use crate::query::FieldRef;
let name_ref: FieldRef<Mini, String> = FieldRef::new("name");
let id_ref: FieldRef<Mini, HeerId> = FieldRef::new("id");
let jqs: JoinedQuerySet<Mini, Mini> = QuerySet::<Mini>::new()
.self_pairs()
.order_by_left(|_f| name_ref.asc())
.order_by_right(|_f| id_ref.desc());
let acc = build_joined_select_for_test(&jqs).expect("emit");
let sql = acc.sql();
assert!(
sql.contains("ORDER BY l.name ASC, r.id DESC"),
"alias-qualified ORDER BY missing: {sql}"
);
}
#[test]
fn count_omits_order_limit_and_closure_pair_joins() {
let jqs: JoinedQuerySet<Mini, Mini> = QuerySet::<Mini>::new()
.self_pairs()
.limit(5)
.order_by_left(|_| OrderExpr::Column {
column: "name",
direction: crate::query::order::Direction::Asc,
nulls: crate::query::order::NullsOrder::Default,
});
let acc = build_joined_count_for_test(&jqs).expect("emit");
let sql = acc.sql();
assert!(
sql.starts_with("SELECT COUNT(*) FROM minis AS l CROSS JOIN minis AS r"),
"count SQL shape mismatch: {sql}"
);
assert!(
!sql.contains("ORDER BY"),
"count must not include ORDER BY: {sql}"
);
assert!(
!sql.contains("LIMIT"),
"count must not include LIMIT: {sql}"
);
assert!(
sql.contains("WHERE l.id <> r.id"),
"count must preserve exclude_equal_pk: {sql}"
);
}
struct MiniClosure;
impl crate::model::__sealed::Sealed for MiniClosure {}
#[allow(clippy::manual_async_fn)]
impl crate::model::Model for MiniClosure {
type Pk = HeerId;
type Fields = ();
fn table_name() -> &'static str {
"mini_ancestries"
}
fn pk_value(&self) -> &Self::Pk {
unreachable!()
}
fn descriptor() -> &'static ModelDescriptor {
&MINI_CLOSURE_DESC
}
fn get(
_ctx: &mut DjogiContext,
_id: Self::Pk,
) -> impl Future<Output = Result<Self, DjogiError>> + Send {
async { unreachable!() }
}
fn create(
_ctx: &mut DjogiContext,
_v: Self,
) -> impl Future<Output = Result<Self, DjogiError>> + Send {
async { unreachable!() }
}
fn save<'ctx>(
&'ctx mut self,
_ctx: &'ctx mut DjogiContext,
) -> impl Future<Output = Result<(), DjogiError>> + Send + 'ctx {
async { unreachable!() }
}
fn delete(
self,
_ctx: &mut DjogiContext,
) -> impl Future<Output = Result<(), DjogiError>> + Send {
async { unreachable!() }
}
fn refresh_from_db<'ctx>(
&'ctx self,
_ctx: &'ctx mut DjogiContext,
) -> impl Future<Output = Result<Self, DjogiError>> + Send + 'ctx {
async { unreachable!() }
}
}
impl FromPgRow for MiniClosure {
const COLUMNS: &'static [&'static str] = &["mini_id", "ancestor_id", "depth", "path_count"];
const COLUMN_LIST: &'static str = "mini_id, ancestor_id, depth, path_count";
fn from_pg_row(_row: &tokio_postgres::Row) -> Result<Self, DjogiError> {
unreachable!()
}
}
static MINI_CLOSURE_FIELDS: &[FieldDescriptor] = &[
field_descriptor("mini_id", FieldSqlType::BigInt, false),
field_descriptor("ancestor_id", FieldSqlType::BigInt, false),
field_descriptor("depth", FieldSqlType::Integer, false),
field_descriptor("path_count", FieldSqlType::BigInt, false),
];
static MINI_CLOSURE_DESC: ModelDescriptor = model_descriptor(
"MiniClosure",
"mini_ancestries",
PkType::HeerId,
MINI_CLOSURE_FIELDS,
);
impl crate::query::ClosureModel for MiniClosure {
type Source = Mini;
fn source_column() -> &'static str {
"mini_id"
}
fn ancestor_column() -> &'static str {
"ancestor_id"
}
fn depth_column() -> &'static str {
"depth"
}
fn path_count_column() -> &'static str {
"path_count"
}
}
#[test]
fn left_join_closure_pair_emits_two_left_joins_with_shared_ancestor_semi_join() {
let jqs: JoinedQuerySet<Mini, Mini> = QuerySet::<Mini>::new()
.self_pairs()
.left_join_closure_pair::<MiniClosure>();
let acc = build_joined_select_for_test(&jqs).expect("emit");
let sql = acc.sql();
assert!(
sql.contains("LEFT JOIN mini_ancestries AS la ON la.mini_id = l.id"),
"left closure LEFT JOIN missing: {sql}"
);
assert!(
sql.contains(
"LEFT JOIN mini_ancestries AS ra ON ra.mini_id = r.id AND ra.ancestor_id = la.ancestor_id"
),
"right closure LEFT JOIN with shared-ancestor predicate missing: {sql}"
);
}
#[test]
fn pair_closure_kinship_sum_emits_wright_style_summation() {
let sum: PairClosureKinshipSum<MiniClosure> = PairClosureKinshipSum::new();
let mut acc = SqlAccumulator::new("");
crate::query::annotate::AnnotationSlot::push_column(&sum, &mut acc, 0);
let sql = acc.sql();
assert!(
sql.contains("la.path_count::numeric * ra.path_count::numeric"),
"kinship-sum path_count product missing: {sql}"
);
assert!(
sql.contains("POWER(0.5::numeric, (la.depth + ra.depth + 1)::numeric)"),
"kinship-sum depth-weighted half-power missing: {sql}"
);
assert!(
sql.starts_with(", COALESCE(SUM("),
"COALESCE wrap missing: {sql}"
);
assert!(sql.contains(")::float8"), "::float8 cast missing: {sql}");
assert!(
!sql.contains("OVER ("),
"kinship-sum must NOT emit window OVER clause — outer GROUP BY drives aggregation: {sql}"
);
assert!(
sql.contains(" AS __djogi_agg_0"),
"annotation alias missing: {sql}"
);
}
#[test]
fn pair_closure_kinship_sum_reports_requires_closure_pair_join() {
let sum: PairClosureKinshipSum<MiniClosure> = PairClosureKinshipSum::new();
assert!(
crate::query::annotate::AnnotationSlot::requires_closure_pair_join(&sum),
"PairClosureKinshipSum must report it requires the closure-pair LEFT JOIN"
);
let tuple_view: &dyn crate::query::IntoAggregateTuple<Decoded = f64> = ∑
assert!(
tuple_view.requires_closure_pair_join(),
"IntoAggregateTuple blanket must forward to AnnotationSlot::requires_closure_pair_join"
);
}
#[test]
fn build_joined_annotated_inner_emits_group_by_when_kinship_aggregate_requires_pair_join() {
let jqs: JoinedQuerySet<Mini, Mini> = QuerySet::<Mini>::new()
.self_pairs()
.left_join_closure_pair::<MiniClosure>();
let annotated = jqs.annotate(|_l, _r| PairClosureKinshipSum::<MiniClosure>::new());
let acc = build_joined_annotated_select_for_fetch_for_test(
&annotated.inner,
&annotated.aggregates,
annotated.qualify.as_ref(),
)
.expect("emit");
let sql = acc.sql();
assert!(
sql.contains(" GROUP BY l.id, r.id"),
"per-pair GROUP BY must be emitted when closure-pair aggregate is in the tuple: {sql}"
);
assert!(
!sql.contains("OVER ("),
"kinship-sum must emit bare aggregate when GROUP BY is in scope: {sql}"
);
}
#[test]
fn build_joined_annotated_inner_omits_group_by_when_no_pair_aggregate() {
let jqs: JoinedQuerySet<Mini, Mini> = QuerySet::<Mini>::new().self_pairs();
let annotated = jqs.annotate(|_l, _r| {
crate::expr::RowNumber::new()
.partition_by_pair(
PairSide::Left,
crate::query::FieldRef::<Mini, HeerId>::new("id"),
)
.alias("rank")
});
let acc = build_joined_annotated_select_for_fetch_for_test(
&annotated.inner,
&annotated.aggregates,
annotated.qualify.as_ref(),
)
.expect("emit");
let sql = acc.sql();
assert!(
!sql.contains("GROUP BY"),
"non-kinship annotated joined query must NOT emit GROUP BY: {sql}"
);
}
#[test]
fn closure_pair_join_validate_idents_accepts_clean_metadata() {
let cp = ClosurePairJoin {
table: "mini_ancestries",
source_column: "mini_id",
ancestor_column: "ancestor_id",
depth_column: "depth",
path_count_column: "path_count",
};
assert!(cp.validate_idents().is_ok());
}
#[test]
fn closure_pair_join_validate_idents_rejects_sql_injection_table() {
let cp = ClosurePairJoin {
table: "ancestries; DROP TABLE users;--",
source_column: "mini_id",
ancestor_column: "ancestor_id",
depth_column: "depth",
path_count_column: "path_count",
};
let err = cp.validate_idents().expect_err("must reject");
match err {
DjogiError::Validation(msg) => {
assert!(
msg.contains("closure table") && msg.contains("DROP TABLE"),
"validation error must name the offending field + content: {msg}"
);
}
other => panic!("expected DjogiError::Validation, got {other:?}"),
}
}
#[test]
fn closure_pair_join_validate_idents_rejects_reserved_djogi_prefix() {
let cp = ClosurePairJoin {
table: "mini_ancestries",
source_column: "__djogi_internal_source",
ancestor_column: "ancestor_id",
depth_column: "depth",
path_count_column: "path_count",
};
let err = cp.validate_idents().expect_err("must reject");
match err {
DjogiError::Validation(msg) => {
assert!(
msg.contains("source_column") && msg.contains("__djogi_internal_source"),
"validation error must name the offending column: {msg}"
);
}
other => panic!("expected DjogiError::Validation, got {other:?}"),
}
}
#[test]
fn closure_pair_join_validate_idents_rejects_each_column_in_turn() {
for (which, bad_label, mut cp) in [
(
"ancestor_column",
"ancestor_column",
ClosurePairJoin {
table: "mini_ancestries",
source_column: "mini_id",
ancestor_column: "0bad",
depth_column: "depth",
path_count_column: "path_count",
},
),
(
"depth_column",
"depth_column",
ClosurePairJoin {
table: "mini_ancestries",
source_column: "mini_id",
ancestor_column: "ancestor_id",
depth_column: "0bad",
path_count_column: "path_count",
},
),
(
"path_count_column",
"path_count_column",
ClosurePairJoin {
table: "mini_ancestries",
source_column: "mini_id",
ancestor_column: "ancestor_id",
depth_column: "depth",
path_count_column: "0bad",
},
),
] {
let _ = (&which, &mut cp);
let err = cp.validate_idents().expect_err("must reject");
let msg = match err {
DjogiError::Validation(s) => s,
other => panic!("expected DjogiError::Validation, got {other:?}"),
};
assert!(
msg.contains(bad_label),
"{which}: must label offending slot, got {msg}"
);
}
}
#[test]
fn left_join_closure_pair_fetch_all_rejects_bad_closure_metadata() {
use crate::context::DjogiContext;
struct HostileClosure;
impl crate::model::__sealed::Sealed for HostileClosure {}
#[allow(clippy::manual_async_fn)]
impl crate::model::Model for HostileClosure {
type Pk = HeerId;
type Fields = ();
fn table_name() -> &'static str {
"mini_ancestries"
}
fn pk_value(&self) -> &Self::Pk {
unreachable!()
}
fn descriptor() -> &'static ModelDescriptor {
&MINI_CLOSURE_DESC
}
fn get(
_ctx: &mut DjogiContext,
_id: Self::Pk,
) -> impl Future<Output = Result<Self, DjogiError>> + Send {
async { unreachable!() }
}
fn create(
_ctx: &mut DjogiContext,
_v: Self,
) -> impl Future<Output = Result<Self, DjogiError>> + Send {
async { unreachable!() }
}
fn save<'ctx>(
&'ctx mut self,
_ctx: &'ctx mut DjogiContext,
) -> impl Future<Output = Result<(), DjogiError>> + Send + 'ctx {
async { unreachable!() }
}
fn delete(
self,
_ctx: &mut DjogiContext,
) -> impl Future<Output = Result<(), DjogiError>> + Send {
async { unreachable!() }
}
fn refresh_from_db<'ctx>(
&'ctx self,
_ctx: &'ctx mut DjogiContext,
) -> impl Future<Output = Result<Self, DjogiError>> + Send + 'ctx {
async { unreachable!() }
}
}
impl FromPgRow for HostileClosure {
const COLUMNS: &'static [&'static str] =
&["mini_id", "ancestor_id", "depth", "path_count"];
const COLUMN_LIST: &'static str = "mini_id, ancestor_id, depth, path_count";
fn from_pg_row(_row: &tokio_postgres::Row) -> Result<Self, DjogiError> {
unreachable!()
}
}
impl crate::query::ClosureModel for HostileClosure {
type Source = Mini;
fn source_column() -> &'static str {
"mini_id"
}
fn ancestor_column() -> &'static str {
"ancestor_id"
}
fn depth_column() -> &'static str {
"depth"
}
fn path_count_column() -> &'static str {
"path_count; DROP TABLE users;--"
}
}
let jqs: JoinedQuerySet<Mini, Mini> = QuerySet::<Mini>::new()
.self_pairs()
.left_join_closure_pair::<HostileClosure>();
let cp = jqs.closure_pair.as_ref().expect("closure_pair set");
let err = cp.validate_idents().expect_err("must reject");
match err {
DjogiError::Validation(msg) => {
assert!(
msg.contains("path_count_column"),
"must label offending slot: {msg}"
);
}
other => panic!("expected DjogiError::Validation, got {other:?}"),
}
}
#[test]
fn left_join_closure_pair_compiles_on_self_pairs() {
let _jqs: JoinedQuerySet<Mini, Mini> = QuerySet::<Mini>::new()
.self_pairs()
.left_join_closure_pair::<MiniClosure>();
}
#[test]
fn filter_expr_on_joined_query_emits_alias_qualified_column() {
use crate::expr::Expr;
let name_ref: crate::query::FieldRef<Mini, String> = crate::query::FieldRef::new("name");
let bool_expr: Expr<bool> = name_ref.as_expr().eq(Expr::literal("alpha".to_string()));
let q: crate::query::Q<Mini> = crate::query::Q::Expression(bool_expr);
let mut acc_left = crate::pg::accumulator::SqlAccumulator::new("");
crate::query::sql::emit_q::<Mini>(
&mut acc_left,
&q,
crate::query::SqlEmitContext::joined(LEFT_ALIAS),
)
.expect("emit");
let sql_left = acc_left.sql();
assert!(
sql_left.contains("l.name = $"),
"joined-context filter_expr must alias-qualify as `l.name`: {sql_left}"
);
let mut acc_right = crate::pg::accumulator::SqlAccumulator::new("");
crate::query::sql::emit_q::<Mini>(
&mut acc_right,
&q,
crate::query::SqlEmitContext::joined(RIGHT_ALIAS),
)
.expect("emit");
let sql_right = acc_right.sql();
assert!(
sql_right.contains("r.name = $"),
"joined-context filter_expr must alias-qualify as `r.name`: {sql_right}"
);
let mut acc_root = crate::pg::accumulator::SqlAccumulator::new("");
crate::query::sql::emit_q::<Mini>(&mut acc_root, &q, crate::query::SqlEmitContext::root())
.expect("emit");
let sql_root = acc_root.sql();
assert!(
sql_root.starts_with("name = $"),
"root-context emission must remain bare (Phase 2 contract): {sql_root}"
);
}
#[test]
fn pair_aggregate_into_aggregate_tuple_signals_pair_only() {
let sum: PairClosureKinshipSum<MiniClosure> = PairClosureKinshipSum::new();
let tuple_view: &dyn crate::query::IntoAggregateTuple<Decoded = f64> = ∑
assert!(
tuple_view.requires_closure_pair_join(),
"PairClosureKinshipSum reports it requires a closure-pair join through the tuple bridge \
— single-Model annotate's `requires_closure_pair_join` check uses this same signal \
to reject the slot at terminal-call time"
);
}
#[test]
fn pair_closure_kinship_sum_validates_clean_closure_metadata() {
let sum: PairClosureKinshipSum<MiniClosure> = PairClosureKinshipSum::new();
assert!(
crate::query::annotate::AnnotationSlot::validate_against_closure_pair(&sum, None)
.is_ok(),
"clean ClosureModel passes the standalone metadata gate"
);
let matching_cp = ClosurePairJoin {
table: MiniClosure::table(),
source_column: MiniClosure::source_column(),
ancestor_column: MiniClosure::ancestor_column(),
depth_column: MiniClosure::depth_column(),
path_count_column: MiniClosure::path_count_column(),
};
assert!(
crate::query::annotate::AnnotationSlot::validate_against_closure_pair(
&sum,
Some(&matching_cp)
)
.is_ok(),
"matching ClosureModel passes the join-C-vs-aggregate-C gate"
);
}
#[test]
fn pair_closure_kinship_sum_rejects_hostile_closure_metadata_in_aggregate() {
struct HostileForAggregate;
impl crate::model::__sealed::Sealed for HostileForAggregate {}
#[allow(clippy::manual_async_fn)]
impl crate::model::Model for HostileForAggregate {
type Pk = HeerId;
type Fields = ();
fn table_name() -> &'static str {
"mini_ancestries"
}
fn pk_value(&self) -> &Self::Pk {
unreachable!()
}
fn descriptor() -> &'static ModelDescriptor {
&MINI_CLOSURE_DESC
}
fn get(
_ctx: &mut DjogiContext,
_id: Self::Pk,
) -> impl Future<Output = Result<Self, DjogiError>> + Send {
async { unreachable!() }
}
fn create(
_ctx: &mut DjogiContext,
_v: Self,
) -> impl Future<Output = Result<Self, DjogiError>> + Send {
async { unreachable!() }
}
fn save<'ctx>(
&'ctx mut self,
_ctx: &'ctx mut DjogiContext,
) -> impl Future<Output = Result<(), DjogiError>> + Send + 'ctx {
async { unreachable!() }
}
fn delete(
self,
_ctx: &mut DjogiContext,
) -> impl Future<Output = Result<(), DjogiError>> + Send {
async { unreachable!() }
}
fn refresh_from_db<'ctx>(
&'ctx self,
_ctx: &'ctx mut DjogiContext,
) -> impl Future<Output = Result<Self, DjogiError>> + Send + 'ctx {
async { unreachable!() }
}
}
impl FromPgRow for HostileForAggregate {
const COLUMNS: &'static [&'static str] =
&["mini_id", "ancestor_id", "depth", "path_count"];
const COLUMN_LIST: &'static str = "mini_id, ancestor_id, depth, path_count";
fn from_pg_row(_row: &tokio_postgres::Row) -> Result<Self, DjogiError> {
unreachable!()
}
}
impl crate::query::ClosureModel for HostileForAggregate {
type Source = Mini;
fn source_column() -> &'static str {
"mini_id"
}
fn ancestor_column() -> &'static str {
"ancestor_id"
}
fn depth_column() -> &'static str {
"depth"
}
fn path_count_column() -> &'static str {
"path_count) FROM pg_class; --"
}
}
let sum: PairClosureKinshipSum<HostileForAggregate> = PairClosureKinshipSum::new();
let err = crate::query::annotate::AnnotationSlot::validate_against_closure_pair(&sum, None)
.expect_err("hostile aggregate C must be rejected before SQL emission");
match err {
DjogiError::Validation(msg) => {
assert!(
msg.contains("path_count_column") || msg.contains("path_count)"),
"validation error must point at the offending C accessor: {msg}"
);
}
other => panic!("expected DjogiError::Validation, got {other:?}"),
}
}
#[test]
fn pair_closure_kinship_sum_rejects_mismatched_closure_join() {
let mismatched_cp = ClosurePairJoin {
table: "different_closure_table",
source_column: "different_source",
ancestor_column: "different_ancestor",
depth_column: "different_depth",
path_count_column: "different_path_count",
};
let sum: PairClosureKinshipSum<MiniClosure> = PairClosureKinshipSum::new();
let err = crate::query::annotate::AnnotationSlot::validate_against_closure_pair(
&sum,
Some(&mismatched_cp),
)
.expect_err("mismatched aggregate-C vs join-C must be rejected");
match err {
DjogiError::Validation(msg) => {
assert!(
msg.contains("ClosureModel mismatch"),
"validation message must name the mismatch: {msg}"
);
assert!(
msg.contains("different_path_count") && msg.contains("path_count"),
"diagnostic must surface join and aggregate column shapes: {msg}"
);
}
other => panic!("expected DjogiError::Validation, got {other:?}"),
}
}
struct NoPkModel;
impl crate::model::__sealed::Sealed for NoPkModel {}
#[allow(clippy::manual_async_fn)]
impl crate::model::Model for NoPkModel {
type Pk = HeerId;
type Fields = ();
fn table_name() -> &'static str {
"no_pk_minis"
}
fn pk_value(&self) -> &Self::Pk {
unreachable!()
}
fn descriptor() -> &'static ModelDescriptor {
&NO_PK_MODEL_DESC
}
fn get(
_ctx: &mut DjogiContext,
_id: Self::Pk,
) -> impl Future<Output = Result<Self, DjogiError>> + Send {
async { unreachable!() }
}
fn create(
_ctx: &mut DjogiContext,
_v: Self,
) -> impl Future<Output = Result<Self, DjogiError>> + Send {
async { unreachable!() }
}
fn save<'ctx>(
&'ctx mut self,
_ctx: &'ctx mut DjogiContext,
) -> impl Future<Output = Result<(), DjogiError>> + Send + 'ctx {
async { unreachable!() }
}
fn delete(
self,
_ctx: &mut DjogiContext,
) -> impl Future<Output = Result<(), DjogiError>> + Send {
async { unreachable!() }
}
fn refresh_from_db<'ctx>(
&'ctx self,
_ctx: &'ctx mut DjogiContext,
) -> impl Future<Output = Result<Self, DjogiError>> + Send + 'ctx {
async { unreachable!() }
}
}
impl FromPgRow for NoPkModel {
const COLUMNS: &'static [&'static str] = &["name"];
const COLUMN_LIST: &'static str = "name";
fn from_pg_row(_row: &tokio_postgres::Row) -> Result<Self, DjogiError> {
unreachable!()
}
}
static NO_PK_MODEL_FIELDS: &[FieldDescriptor] =
&[field_descriptor("name", FieldSqlType::Text, false)];
static NO_PK_MODEL_DESC: ModelDescriptor =
model_descriptor("NoPkModel", "no_pk_minis", PkType::None, NO_PK_MODEL_FIELDS);
struct CompositePkModel;
impl crate::model::__sealed::Sealed for CompositePkModel {}
#[allow(clippy::manual_async_fn)]
impl crate::model::Model for CompositePkModel {
type Pk = HeerId;
type Fields = ();
fn table_name() -> &'static str {
"composite_pk_minis"
}
fn pk_value(&self) -> &Self::Pk {
unreachable!()
}
fn descriptor() -> &'static ModelDescriptor {
&COMPOSITE_PK_MODEL_DESC
}
fn get(
_ctx: &mut DjogiContext,
_id: Self::Pk,
) -> impl Future<Output = Result<Self, DjogiError>> + Send {
async { unreachable!() }
}
fn create(
_ctx: &mut DjogiContext,
_v: Self,
) -> impl Future<Output = Result<Self, DjogiError>> + Send {
async { unreachable!() }
}
fn save<'ctx>(
&'ctx mut self,
_ctx: &'ctx mut DjogiContext,
) -> impl Future<Output = Result<(), DjogiError>> + Send + 'ctx {
async { unreachable!() }
}
fn delete(
self,
_ctx: &mut DjogiContext,
) -> impl Future<Output = Result<(), DjogiError>> + Send {
async { unreachable!() }
}
fn refresh_from_db<'ctx>(
&'ctx self,
_ctx: &'ctx mut DjogiContext,
) -> impl Future<Output = Result<Self, DjogiError>> + Send + 'ctx {
async { unreachable!() }
}
}
impl FromPgRow for CompositePkModel {
const COLUMNS: &'static [&'static str] = &["a", "b"];
const COLUMN_LIST: &'static str = "a, b";
fn from_pg_row(_row: &tokio_postgres::Row) -> Result<Self, DjogiError> {
unreachable!()
}
}
static COMPOSITE_PK_MODEL_FIELDS: &[FieldDescriptor] = &[
field_descriptor("a", FieldSqlType::Text, false),
field_descriptor("b", FieldSqlType::Text, false),
];
static COMPOSITE_PK_COLS: &[&str] = &["a", "b"];
static COMPOSITE_PK_MODEL_DESC: ModelDescriptor = model_descriptor(
"CompositePkModel",
"composite_pk_minis",
PkType::Composite(COMPOSITE_PK_COLS),
COMPOSITE_PK_MODEL_FIELDS,
);
#[test]
fn validated_pk_column_for_pair_identity_accepts_simple_pks() {
let pk =
validated_pk_column_for_pair_identity::<Mini>().expect("HeerId PK should validate");
assert_eq!(pk, "id");
}
#[test]
fn validated_pk_column_for_pair_identity_rejects_no_pk() {
let err = validated_pk_column_for_pair_identity::<NoPkModel>()
.expect_err("PkType::None must be rejected");
match err {
DjogiError::Validation(msg) => {
assert!(msg.contains("no primary key") || msg.contains("PkType::None"));
assert!(
msg.contains("no_pk_minis"),
"diagnostic must name the model table: {msg}"
);
}
other => panic!("expected DjogiError::Validation, got {other:?}"),
}
}
#[test]
fn validated_pk_column_for_pair_identity_rejects_composite_pk() {
let err = validated_pk_column_for_pair_identity::<CompositePkModel>()
.expect_err("PkType::Composite must be rejected for this slice");
match err {
DjogiError::Validation(msg) => {
assert!(
msg.contains("composite primary key"),
"diagnostic must mention composite PK: {msg}"
);
assert!(
msg.contains("\"a\"") && msg.contains("\"b\""),
"diagnostic must surface the composite columns: {msg}"
);
}
other => panic!("expected DjogiError::Validation, got {other:?}"),
}
}
#[test]
fn validate_pair_identity_pk_short_circuits_when_not_needed() {
let result = validate_pair_identity_pk::<NoPkModel, CompositePkModel>(false)
.expect("no-identity path accepts any PK shape");
assert_eq!(result, (None, None));
}
#[test]
fn validate_pair_identity_pk_validates_both_sides_when_needed() {
let err = validate_pair_identity_pk::<Mini, NoPkModel>(true)
.expect_err("right side has no PK; must reject");
match err {
DjogiError::Validation(msg) => {
assert!(msg.contains("no primary key") || msg.contains("PkType::None"));
}
other => panic!("expected DjogiError::Validation, got {other:?}"),
}
}
#[test]
fn aggregate_expr_is_not_joined_safe_by_default() {
let age_ref: crate::query::FieldRef<Mini, i64> = crate::query::FieldRef::new("age");
let agg: crate::expr::AggregateExpr<i64> = age_ref.sum();
assert!(
!crate::query::annotate::AnnotationSlot::is_joined_safe(&agg),
"AggregateExpr<V> must not be joined-safe by default — it emits bare-column SQL"
);
}
#[test]
fn pair_closure_kinship_sum_is_joined_safe() {
let sum: PairClosureKinshipSum<MiniClosure> = PairClosureKinshipSum::new();
assert!(
crate::query::annotate::AnnotationSlot::is_joined_safe(&sum),
"PairClosureKinshipSum must be joined-safe"
);
}
#[test]
fn empty_window_function_slot_is_vacuously_joined_safe() {
let win = crate::expr::RowNumber::new().alias("rank");
assert!(
crate::query::annotate::AnnotationSlot::is_joined_safe(&win),
"empty-window RowNumber must be joined-safe (no column refs to be ambiguous)"
);
}
#[test]
fn rank_window_with_bare_partition_by_is_not_joined_safe() {
let id_ref: crate::query::FieldRef<Mini, i64> = crate::query::FieldRef::new("id");
let win = crate::expr::RowNumber::new()
.partition_by(id_ref)
.alias("rank");
assert!(
!crate::query::annotate::AnnotationSlot::is_joined_safe(&win),
"RowNumber::partition_by(bare) must be rejected by the joined-annotation safety gate"
);
}
#[test]
fn rank_window_with_bare_order_by_is_not_joined_safe() {
let score_ref: crate::query::FieldRef<Mini, i64> = crate::query::FieldRef::new("age");
let win = crate::expr::RowNumber::new()
.order_by(score_ref.desc())
.alias("rank");
assert!(
!crate::query::annotate::AnnotationSlot::is_joined_safe(&win),
"RowNumber::order_by(bare.desc()) must be rejected by the joined-annotation safety gate"
);
}
#[test]
fn rank_window_with_pair_partition_by_is_joined_safe() {
use crate::query::joined::{PairSide, PairWindowExt};
let id_ref: crate::query::FieldRef<Mini, i64> = crate::query::FieldRef::new("id");
let win = crate::expr::RowNumber::new()
.partition_by_pair(PairSide::Left, id_ref)
.alias("rank");
assert!(
crate::query::annotate::AnnotationSlot::is_joined_safe(&win),
"RowNumber::partition_by_pair must produce a joined-safe slot"
);
}
#[test]
fn rank_window_with_pair_order_by_is_joined_safe() {
use crate::query::joined::{PairSide, PairWindowExt};
let age_ref: crate::query::FieldRef<Mini, i64> = crate::query::FieldRef::new("age");
let win = crate::expr::RowNumber::new()
.order_by_pair_desc(PairSide::Right, age_ref)
.alias("rank");
assert!(
crate::query::annotate::AnnotationSlot::is_joined_safe(&win),
"RowNumber::order_by_pair_desc must produce a joined-safe slot"
);
}
#[test]
fn rank_window_with_mixed_pair_and_bare_is_not_joined_safe() {
use crate::query::joined::{PairSide, PairWindowExt};
let id_ref: crate::query::FieldRef<Mini, i64> = crate::query::FieldRef::new("id");
let age_ref: crate::query::FieldRef<Mini, i64> = crate::query::FieldRef::new("age");
let win = crate::expr::RowNumber::new()
.partition_by_pair(PairSide::Left, id_ref)
.order_by(age_ref.desc()) .alias("rank");
assert!(
!crate::query::annotate::AnnotationSlot::is_joined_safe(&win),
"any single bare column re-introduces ambiguity and disqualifies the slot"
);
}
#[test]
fn rank_pair_helpers_apply_to_dense_rank_and_rank_too() {
use crate::query::joined::{PairSide, PairWindowExt};
let id_ref: crate::query::FieldRef<Mini, i64> = crate::query::FieldRef::new("id");
let bare_rank = crate::expr::Rank::new().partition_by(id_ref).alias("r");
assert!(
!crate::query::annotate::AnnotationSlot::is_joined_safe(&bare_rank),
"Rank with bare partition_by must be rejected"
);
let pair_rank = crate::expr::Rank::new()
.partition_by_pair(PairSide::Left, id_ref)
.alias("r");
assert!(
crate::query::annotate::AnnotationSlot::is_joined_safe(&pair_rank),
"Rank with partition_by_pair must be joined-safe"
);
let bare_dense = crate::expr::DenseRank::new()
.partition_by(id_ref)
.alias("dr");
assert!(
!crate::query::annotate::AnnotationSlot::is_joined_safe(&bare_dense),
"DenseRank with bare partition_by must be rejected"
);
let pair_dense = crate::expr::DenseRank::new()
.partition_by_pair(PairSide::Right, id_ref)
.alias("dr");
assert!(
crate::query::annotate::AnnotationSlot::is_joined_safe(&pair_dense),
"DenseRank with partition_by_pair must be joined-safe"
);
}
#[test]
fn first_value_window_is_never_joined_safe() {
let name_ref: crate::query::FieldRef<Mini, i64> = crate::query::FieldRef::new("age");
let win = crate::expr::FirstValueWindow::<i64>::new(name_ref).alias("first_age");
assert!(
!crate::query::annotate::AnnotationSlot::is_joined_safe(&win),
"FirstValueWindow must always be rejected in joined annotations \
(bare target column has no pair-aware constructor)"
);
}
#[test]
fn last_value_window_is_never_joined_safe() {
let age_ref: crate::query::FieldRef<Mini, i64> = crate::query::FieldRef::new("age");
let win = crate::expr::LastValueWindow::<i64>::new(age_ref).alias("last_age");
assert!(
!crate::query::annotate::AnnotationSlot::is_joined_safe(&win),
"LastValueWindow must always be rejected in joined annotations"
);
}
#[test]
fn lead_window_is_never_joined_safe() {
let age_ref: crate::query::FieldRef<Mini, i64> = crate::query::FieldRef::new("age");
let win = crate::expr::LeadWindow::<i64>::new(age_ref).alias("next_age");
assert!(
!crate::query::annotate::AnnotationSlot::is_joined_safe(&win),
"LeadWindow must always be rejected in joined annotations"
);
}
#[test]
fn lag_window_is_never_joined_safe() {
let age_ref: crate::query::FieldRef<Mini, i64> = crate::query::FieldRef::new("age");
let win = crate::expr::LagWindow::<i64>::new(age_ref).alias("prev_age");
assert!(
!crate::query::annotate::AnnotationSlot::is_joined_safe(&win),
"LagWindow must always be rejected in joined annotations"
);
}
#[test]
fn nth_value_window_is_never_joined_safe() {
let age_ref: crate::query::FieldRef<Mini, i64> = crate::query::FieldRef::new("age");
let win = crate::expr::NthValueWindow::<i64>::new(age_ref, 3).alias("third_age");
assert!(
!crate::query::annotate::AnnotationSlot::is_joined_safe(&win),
"NthValueWindow must always be rejected in joined annotations"
);
}
#[test]
fn percent_rank_window_without_pair_helpers_rejects_bare_partition() {
let id_ref: crate::query::FieldRef<Mini, i64> = crate::query::FieldRef::new("id");
let win = crate::expr::PercentRankWindow::new()
.partition_by(id_ref)
.alias("pct");
assert!(
!crate::query::annotate::AnnotationSlot::is_joined_safe(&win),
"PercentRankWindow with bare partition_by must be rejected — \
no PairWindowExt impl exists for this type"
);
}
#[test]
fn cume_dist_window_without_pair_helpers_rejects_bare_order() {
let age_ref: crate::query::FieldRef<Mini, i64> = crate::query::FieldRef::new("age");
let win = crate::expr::CumeDistWindow::new()
.order_by(age_ref.asc())
.alias("cd");
assert!(
!crate::query::annotate::AnnotationSlot::is_joined_safe(&win),
"CumeDistWindow with bare order_by must be rejected"
);
}
#[test]
fn ntile_window_without_pair_helpers_rejects_bare_partition() {
let id_ref: crate::query::FieldRef<Mini, i64> = crate::query::FieldRef::new("id");
let win = crate::expr::NtileWindow::new(4)
.partition_by(id_ref)
.alias("q");
assert!(
!crate::query::annotate::AnnotationSlot::is_joined_safe(&win),
"NtileWindow with bare partition_by must be rejected"
);
}
#[test]
fn tuple_with_unsafe_window_slot_is_not_joined_safe() {
let name_ref: crate::query::FieldRef<Mini, i64> = crate::query::FieldRef::new("age");
let safe_slot: PairClosureKinshipSum<MiniClosure> = PairClosureKinshipSum::new();
let unsafe_slot = crate::expr::FirstValueWindow::<i64>::new(name_ref).alias("first_age");
let tuple = (safe_slot, unsafe_slot);
assert!(
!crate::query::IntoAggregateTuple::is_joined_safe(&tuple),
"tuple containing FirstValueWindow must NOT be joined-safe"
);
}
#[test]
fn tuple_with_only_pair_safe_window_slots_is_joined_safe() {
use crate::query::joined::{PairSide, PairWindowExt};
let id_ref: crate::query::FieldRef<Mini, i64> = crate::query::FieldRef::new("id");
let safe_window = crate::expr::RowNumber::new()
.partition_by_pair(PairSide::Left, id_ref)
.alias("rank");
let safe_kinship: PairClosureKinshipSum<MiniClosure> = PairClosureKinshipSum::new();
let tuple = (safe_window, safe_kinship);
assert!(
crate::query::IntoAggregateTuple::is_joined_safe(&tuple),
"tuple of (pair-aware RowNumber, kinship sum) must be joined-safe"
);
}
#[test]
fn into_aggregate_tuple_is_joined_safe_ands_across_slots() {
let age_ref: crate::query::FieldRef<Mini, i64> = crate::query::FieldRef::new("age");
let safe_slot: PairClosureKinshipSum<MiniClosure> = PairClosureKinshipSum::new();
let unsafe_slot: crate::expr::AggregateExpr<i64> = age_ref.sum();
let tuple = (safe_slot, unsafe_slot);
assert!(
!crate::query::IntoAggregateTuple::is_joined_safe(&tuple),
"tuple with an AggregateExpr slot must NOT be joined-safe"
);
}
#[test]
fn empty_tuple_is_joined_safe() {
assert!(
crate::query::IntoAggregateTuple::is_joined_safe(&()),
"() annotation must be joined-safe"
);
}
#[test]
fn joined_annotate_rejects_ordinary_aggregate_expr_via_gate() {
let age_ref: crate::query::FieldRef<Mini, i64> = crate::query::FieldRef::new("age");
let jqs: JoinedQuerySet<Mini, Mini> = QuerySet::<Mini>::new().self_pairs();
let annotated = jqs.annotate(|_l, _r| age_ref.sum());
assert!(annotated.aggregates.check_legality().is_ok());
assert!(!annotated.aggregates.requires_closure_pair_join());
assert!(
annotated
.aggregates
.validate_against_closure_pair(annotated.inner.closure_pair.as_ref())
.is_ok()
);
assert!(
!annotated.aggregates.is_joined_safe(),
"ordinary AggregateExpr in joined.annotate must trip the is_joined_safe gate"
);
}
#[cfg(feature = "spatial")]
#[test]
fn pair_area_overlap_ratio_emits_left_normalised_intersection_ratio() {
let l_col: crate::query::FieldRef<Mini, crate::geo::Polygon> =
crate::query::FieldRef::new("territory");
let r_col: crate::query::FieldRef<Mini, crate::geo::Polygon> =
crate::query::FieldRef::new("territory");
let overlap: PairAreaOverlapRatio<Mini, Mini> = PairAreaOverlapRatio::new(l_col, r_col);
let mut acc = SqlAccumulator::new("");
crate::query::annotate::AnnotationSlot::push_column(&overlap, &mut acc, 0);
let sql = acc.sql();
assert!(
sql.contains(
"ST_Area(ST_Intersection(l.territory::geometry, r.territory::geometry)::geography)"
),
"intersection numerator missing or mis-shaped: {sql}"
);
assert!(
sql.contains("COALESCE(ST_Area(ST_Intersection(") && sql.contains("), 0)::float8"),
"COALESCE wrap on numerator missing: {sql}"
);
assert!(
sql.contains("NULLIF(ST_Area(l.territory::geography), 0)::float8"),
"denominator (left area with NULLIF guard) missing: {sql}"
);
assert!(
sql.contains(" AS __djogi_agg_0"),
"annotation alias missing: {sql}"
);
assert!(
sql.starts_with(", COALESCE(ST_Area("),
"non-bare push must start with `, COALESCE(...)`: {sql}"
);
}
#[cfg(feature = "spatial")]
#[test]
fn pair_area_overlap_ratio_bare_after_omits_separator_for_first_column() {
let l_col: crate::query::FieldRef<Mini, crate::geo::Polygon> =
crate::query::FieldRef::new("hull");
let r_col: crate::query::FieldRef<Mini, crate::geo::Polygon> =
crate::query::FieldRef::new("hull");
let overlap: PairAreaOverlapRatio<Mini, Mini> = PairAreaOverlapRatio::new(l_col, r_col);
let mut acc = SqlAccumulator::new("");
crate::query::annotate::AnnotationSlot::push_column_bare_after(
&overlap, &mut acc, 0, false,
);
let sql = acc.sql();
assert!(
sql.starts_with("COALESCE(ST_Area("),
"bare-after with has_previous_columns=false must omit leading `, `: {sql}"
);
assert!(
sql.contains(" AS __djogi_agg_0"),
"annotation alias missing on bare-after path: {sql}"
);
}
#[cfg(feature = "spatial")]
#[test]
fn pair_area_overlap_ratio_is_joined_safe() {
let l_col: crate::query::FieldRef<Mini, crate::geo::Polygon> =
crate::query::FieldRef::new("territory");
let r_col: crate::query::FieldRef<Mini, crate::geo::Polygon> =
crate::query::FieldRef::new("territory");
let overlap: PairAreaOverlapRatio<Mini, Mini> = PairAreaOverlapRatio::new(l_col, r_col);
assert!(
crate::query::annotate::AnnotationSlot::is_joined_safe(&overlap),
"PairAreaOverlapRatio must be joined-safe — its emitted SQL alias-qualifies both columns explicitly"
);
let tuple_view: &dyn crate::query::IntoAggregateTuple<Decoded = f64> = &overlap;
assert!(
tuple_view.is_joined_safe(),
"IntoAggregateTuple blanket must forward AnnotationSlot::is_joined_safe through"
);
}
#[cfg(feature = "spatial")]
#[test]
fn pair_area_overlap_ratio_does_not_require_closure_pair_join() {
let l_col: crate::query::FieldRef<Mini, crate::geo::Polygon> =
crate::query::FieldRef::new("territory");
let r_col: crate::query::FieldRef<Mini, crate::geo::Polygon> =
crate::query::FieldRef::new("territory");
let overlap: PairAreaOverlapRatio<Mini, Mini> = PairAreaOverlapRatio::new(l_col, r_col);
assert!(
!crate::query::annotate::AnnotationSlot::requires_closure_pair_join(&overlap),
"PairAreaOverlapRatio is closure-free — must report requires_closure_pair_join()=false"
);
}
#[cfg(feature = "spatial")]
#[test]
fn pair_area_overlap_ratio_requires_pair_tuple_scope() {
let l_col: crate::query::FieldRef<Mini, crate::geo::Polygon> =
crate::query::FieldRef::new("territory");
let r_col: crate::query::FieldRef<Mini, crate::geo::Polygon> =
crate::query::FieldRef::new("territory");
let overlap: PairAreaOverlapRatio<Mini, Mini> = PairAreaOverlapRatio::new(l_col, r_col);
assert!(
crate::query::annotate::AnnotationSlot::requires_pair_tuple_scope(&overlap),
"PairAreaOverlapRatio MUST report requires_pair_tuple_scope() = true — its emitted SQL splices the `l.` / `r.` aliases that only exist inside a JoinedQuerySet"
);
let tuple_view: &dyn crate::query::IntoAggregateTuple<Decoded = f64> = &overlap;
assert!(
tuple_view.requires_pair_tuple_scope(),
"IntoAggregateTuple blanket must forward AnnotationSlot::requires_pair_tuple_scope through"
);
}
#[test]
fn pair_closure_kinship_sum_requires_pair_tuple_scope() {
let sum: PairClosureKinshipSum<MiniClosure> = PairClosureKinshipSum::new();
assert!(
crate::query::annotate::AnnotationSlot::requires_pair_tuple_scope(&sum),
"PairClosureKinshipSum MUST report requires_pair_tuple_scope() = true — same invariant as PairAreaOverlapRatio"
);
assert!(
crate::query::annotate::AnnotationSlot::requires_closure_pair_join(&sum),
"PairClosureKinshipSum's requires_closure_pair_join() must remain true alongside the broader scope signal"
);
}
#[cfg(feature = "spatial")]
#[test]
fn pair_area_overlap_ratio_full_select_shape() {
let l_col: crate::query::FieldRef<Mini, crate::geo::Polygon> =
crate::query::FieldRef::new("territory");
let r_col: crate::query::FieldRef<Mini, crate::geo::Polygon> =
crate::query::FieldRef::new("territory");
let jqs: JoinedQuerySet<Mini, Mini> = QuerySet::<Mini>::new().self_pairs();
let annotated =
jqs.annotate(|_l, _r| PairAreaOverlapRatio::<Mini, Mini>::new(l_col, r_col));
let acc = build_joined_annotated_select_for_fetch_for_test(
&annotated.inner,
&annotated.aggregates,
annotated.qualify.as_ref(),
)
.expect("emit");
let sql = acc.sql();
assert!(
!sql.contains("GROUP BY"),
"PairAreaOverlapRatio is per-row, not aggregated — must not emit GROUP BY: {sql}"
);
assert!(
sql.contains("__djogi_agg_0"),
"overlap ratio must be aliased under __djogi_agg_0: {sql}"
);
assert!(
sql.contains("minis AS l CROSS JOIN minis AS r"),
"pair-tuple FROM/CROSS JOIN missing: {sql}"
);
}
#[cfg(feature = "spatial")]
#[test]
fn pair_area_overlap_ratio_accepts_djogi_field_handle() {
let l_col: crate::query::FieldRef<Mini, crate::geo::Polygon> =
crate::query::FieldRef::new("territory");
let r_col: crate::query::FieldRef<Mini, crate::geo::Polygon> =
crate::query::FieldRef::new("territory");
let _: PairAreaOverlapRatio<Mini, Mini> = PairAreaOverlapRatio::new(l_col, r_col);
}
}