#![allow(clippy::manual_async_fn)]
use crate::DjogiError;
use crate::context::DjogiContext;
use crate::expr::{
AggregateExpr, DenseRank, FirstValueWindow, LagWindow, LastValueWindow, LeadWindow,
NthValueWindow, Rank, RowNumber,
aggregate::{KindEvidence, ValueAgg},
};
use crate::model::Model;
use crate::pg::accumulator::{SqlAccumulator, as_params};
use crate::pg::decode::FromPgRow;
use crate::query::queryset::QuerySet;
use crate::query::sql::{build_annotated_select_for_fetch, emit_aggregate_with_window_and_cast};
use std::future::Future;
use std::marker::PhantomData;
pub(crate) mod sealed {
pub trait Sealed {}
}
pub(crate) mod annotation_slot_sealed {
pub trait Sealed {}
}
fn aggregate_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."
),
}
}
#[doc(hidden)]
pub trait AnnotationSlot: annotation_slot_sealed::Sealed {
type Decoded;
fn push_column(&self, acc: &mut SqlAccumulator, slot: usize);
fn push_column_bare(&self, acc: &mut SqlAccumulator, slot: usize);
fn push_column_bare_after(
&self,
acc: &mut SqlAccumulator,
slot: usize,
has_previous_columns: bool,
);
fn decode_column(
&self,
row: &tokio_postgres::Row,
slot: usize,
) -> Result<Self::Decoded, tokio_postgres::Error>;
fn check_legality(&self) -> Result<(), crate::DjogiError> {
Ok(())
}
fn check_no_column_collision(
&self,
#[allow(unused_variables)] model_columns: &'static [&'static str],
) -> Result<(), crate::DjogiError> {
Ok(())
}
fn requires_closure_pair_join(&self) -> bool {
false
}
fn is_joined_safe(&self) -> bool {
false
}
fn requires_pair_tuple_scope(&self) -> bool {
false
}
fn validate_against_closure_pair(
&self,
#[allow(unused_variables)] closure_pair: Option<&crate::query::joined::ClosurePairJoin>,
) -> Result<(), crate::DjogiError> {
Ok(())
}
}
#[doc(hidden)]
pub trait PlainAnnotationSlot: AnnotationSlot {}
#[doc(hidden)]
pub trait PlainAnnotationTuple: IntoAggregateTuple {
fn push_plain_columns(&self, acc: &mut SqlAccumulator);
}
pub trait IntoAggregateTuple: sealed::Sealed {
type Decoded;
fn push_columns(&self, acc: &mut SqlAccumulator);
fn push_columns_bare(&self, acc: &mut SqlAccumulator);
fn push_columns_bare_after(&self, acc: &mut SqlAccumulator, has_previous_columns: bool);
fn decode_tuple(
&self,
row: &tokio_postgres::Row,
) -> Result<Self::Decoded, tokio_postgres::Error>;
fn annotation_count(&self) -> usize;
fn check_legality(&self) -> Result<(), crate::DjogiError> {
Ok(())
}
fn check_no_column_collision(
&self,
#[allow(unused_variables)] model_columns: &'static [&'static str],
) -> Result<(), crate::DjogiError> {
Ok(())
}
fn requires_closure_pair_join(&self) -> bool {
false
}
fn is_joined_safe(&self) -> bool {
true
}
fn requires_pair_tuple_scope(&self) -> bool {
false
}
fn validate_against_closure_pair(
&self,
#[allow(unused_variables)] closure_pair: Option<&crate::query::joined::ClosurePairJoin>,
) -> Result<(), crate::DjogiError> {
Ok(())
}
}
impl<V, K> annotation_slot_sealed::Sealed for AggregateExpr<V, K> {}
impl<V, K> AnnotationSlot for AggregateExpr<V, K>
where
K: KindEvidence,
V: for<'a> postgres_types::FromSql<'a> + Send + Unpin + 'static,
{
type Decoded = V;
fn push_column(&self, acc: &mut SqlAccumulator, slot: usize) {
acc.push_sql(", ");
emit_aggregate_with_window_and_cast(acc, &self.node)
.expect("aggregate annotation emission cannot fail for typed-aggregate inputs");
acc.push_sql(" AS ");
acc.push_sql(aggregate_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(", ");
}
crate::query::sql::emit_aggregate_with_cast(acc, &self.node)
.expect("aggregate annotation emission cannot fail for typed-aggregate inputs");
acc.push_sql(" AS ");
acc.push_sql(aggregate_alias(slot));
}
fn decode_column(
&self,
row: &tokio_postgres::Row,
slot: usize,
) -> Result<Self::Decoded, tokio_postgres::Error> {
row.try_get::<_, V>(aggregate_alias(slot))
}
fn check_legality(&self) -> Result<(), crate::DjogiError> {
crate::expr::sql::check_aggregate_legality(&self.node)
}
}
impl<V> PlainAnnotationSlot for AggregateExpr<V, ValueAgg> where
V: for<'a> postgres_types::FromSql<'a> + Send + Unpin + 'static
{
}
#[inline]
pub(crate) fn alias_collides_with_column(alias: &str, col: &str) -> bool {
alias.eq_ignore_ascii_case(col)
}
macro_rules! impl_window_annotation_slot {
($type_name:ty, $display:literal) => {
impl_window_annotation_slot!($type_name, $display, decoded = i64);
};
($type_name:ty, $display:literal, decoded = $decoded:ty) => {
impl annotation_slot_sealed::Sealed for $type_name {}
impl AnnotationSlot for $type_name {
type Decoded = $decoded;
fn push_column(&self, acc: &mut SqlAccumulator, _slot: usize) {
acc.push_sql(", ");
self.push_annotated_column(acc);
}
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.push_annotated_column(acc);
}
fn decode_column(
&self,
row: &tokio_postgres::Row,
_slot: usize,
) -> Result<Self::Decoded, tokio_postgres::Error> {
row.try_get::<_, $decoded>(
self.alias_name()
.expect("window function annotations are checked before row decode"),
)
}
fn check_legality(&self) -> Result<(), crate::DjogiError> {
if self.alias_name().is_some() {
Ok(())
} else {
Err(crate::DjogiError::Validation(format!(
"{} window annotation requires .alias(\"name\") before annotate",
$display
)))
}
}
fn check_no_column_collision(
&self,
model_columns: &'static [&'static str],
) -> Result<(), crate::DjogiError> {
if let Some(alias) = self.alias_name() {
if model_columns
.iter()
.any(|col| crate::query::annotate::alias_collides_with_column(alias, col))
{
return Err(crate::DjogiError::Validation(format!(
"{} window annotation alias {:?} collides with the model column of \
the same name. The derived-table qualify lowering exposes both the \
model column (emitted as `t.{alias}`) and the window output \
(emitted as `… AS {alias}`) under the same name, making an outer \
`WHERE {alias}` predicate ambiguous at Postgres. Choose an alias \
that does not match any model column (e.g. append \"_rank\" or \
\"_window\" to make the intent clear).",
$display,
alias
)));
}
}
Ok(())
}
fn is_joined_safe(&self) -> bool {
self.window.is_pair_qualified()
}
}
impl PlainAnnotationSlot for $type_name {}
};
}
macro_rules! impl_window_annotation_slot_generic_v {
($type_name:ident, $display:literal) => {
impl<V> annotation_slot_sealed::Sealed for $type_name<V> {}
impl<V> AnnotationSlot for $type_name<V>
where
V: for<'a> postgres_types::FromSql<'a> + Send + Unpin + 'static,
{
type Decoded = V;
fn push_column(&self, acc: &mut SqlAccumulator, _slot: usize) {
acc.push_sql(", ");
self.push_annotated_column(acc);
}
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.push_annotated_column(acc);
}
fn decode_column(
&self,
row: &tokio_postgres::Row,
_slot: usize,
) -> Result<Self::Decoded, tokio_postgres::Error> {
row.try_get::<_, V>(
self.alias_name()
.expect("window function annotations are checked before row decode"),
)
}
fn check_legality(&self) -> Result<(), crate::DjogiError> {
if self.alias_name().is_some() {
Ok(())
} else {
Err(crate::DjogiError::Validation(format!(
"{} window annotation requires .alias(\"name\") before annotate",
$display
)))
}
}
fn check_no_column_collision(
&self,
model_columns: &'static [&'static str],
) -> Result<(), crate::DjogiError> {
if let Some(alias) = self.alias_name() {
if model_columns
.iter()
.any(|col| crate::query::annotate::alias_collides_with_column(alias, col))
{
return Err(crate::DjogiError::Validation(format!(
"{} window annotation alias {:?} collides with the model column of \
the same name. The derived-table qualify lowering exposes both the \
model column (emitted as `t.{alias}`) and the window output \
(emitted as `… AS {alias}`) under the same name, making an outer \
`WHERE {alias}` predicate ambiguous at Postgres. Choose an alias \
that does not match any model column (e.g. append \"_rank\" or \
\"_window\" to make the intent clear).",
$display,
alias
)));
}
}
Ok(())
}
fn is_joined_safe(&self) -> bool {
false
}
}
impl<V> PlainAnnotationSlot for $type_name<V> where
V: for<'a> postgres_types::FromSql<'a> + Send + Unpin + 'static
{
}
};
}
impl_window_annotation_slot!(RowNumber, "RowNumber");
impl_window_annotation_slot!(Rank, "Rank");
impl_window_annotation_slot!(DenseRank, "DenseRank");
impl_window_annotation_slot!(
crate::expr::PercentRankWindow,
"PercentRankWindow",
decoded = f64
);
impl_window_annotation_slot!(crate::expr::CumeDistWindow, "CumeDistWindow", decoded = f64);
impl_window_annotation_slot!(crate::expr::NtileWindow, "NtileWindow", decoded = i32);
impl_window_annotation_slot_generic_v!(FirstValueWindow, "FirstValueWindow");
impl_window_annotation_slot_generic_v!(LastValueWindow, "LastValueWindow");
impl_window_annotation_slot_generic_v!(LeadWindow, "LeadWindow");
impl_window_annotation_slot_generic_v!(LagWindow, "LagWindow");
impl_window_annotation_slot_generic_v!(NthValueWindow, "NthValueWindow");
impl<S> sealed::Sealed for S where S: AnnotationSlot {}
impl<S> IntoAggregateTuple for S
where
S: AnnotationSlot,
{
type Decoded = <S as AnnotationSlot>::Decoded;
fn push_columns(&self, acc: &mut SqlAccumulator) {
self.push_column(acc, 0);
}
fn push_columns_bare(&self, acc: &mut SqlAccumulator) {
self.push_columns_bare_after(acc, true);
}
fn push_columns_bare_after(&self, acc: &mut SqlAccumulator, has_previous_columns: bool) {
self.push_column_bare_after(acc, 0, has_previous_columns);
}
fn decode_tuple(
&self,
row: &tokio_postgres::Row,
) -> Result<Self::Decoded, tokio_postgres::Error> {
self.decode_column(row, 0)
}
fn annotation_count(&self) -> usize {
1
}
fn check_legality(&self) -> Result<(), crate::DjogiError> {
AnnotationSlot::check_legality(self)
}
fn check_no_column_collision(
&self,
model_columns: &'static [&'static str],
) -> Result<(), crate::DjogiError> {
AnnotationSlot::check_no_column_collision(self, model_columns)
}
fn requires_closure_pair_join(&self) -> bool {
AnnotationSlot::requires_closure_pair_join(self)
}
fn is_joined_safe(&self) -> bool {
AnnotationSlot::is_joined_safe(self)
}
fn requires_pair_tuple_scope(&self) -> bool {
AnnotationSlot::requires_pair_tuple_scope(self)
}
fn validate_against_closure_pair(
&self,
closure_pair: Option<&crate::query::joined::ClosurePairJoin>,
) -> Result<(), crate::DjogiError> {
AnnotationSlot::validate_against_closure_pair(self, closure_pair)
}
}
impl<S> PlainAnnotationTuple for S
where
S: PlainAnnotationSlot,
{
fn push_plain_columns(&self, acc: &mut SqlAccumulator) {
self.push_column(acc, 0);
}
}
impl sealed::Sealed for () {}
impl IntoAggregateTuple for () {
type Decoded = ();
fn push_columns(&self, _acc: &mut SqlAccumulator) {}
fn push_columns_bare(&self, _acc: &mut SqlAccumulator) {}
fn push_columns_bare_after(&self, _acc: &mut SqlAccumulator, _has_previous_columns: bool) {}
fn decode_tuple(
&self,
_row: &tokio_postgres::Row,
) -> Result<Self::Decoded, tokio_postgres::Error> {
Ok(())
}
fn annotation_count(&self) -> usize {
0
}
}
impl PlainAnnotationTuple for () {
fn push_plain_columns(&self, _acc: &mut SqlAccumulator) {}
}
macro_rules! impl_into_aggregate_tuple {
(
arity = $arity:tt,
types = [ $( ($ty:ident, $slot:tt) ),+ $(,)? ]
) => {
impl<$($ty),+> sealed::Sealed for ( $($ty,)+ )
where
$($ty: AnnotationSlot,)+
{}
impl<$($ty),+> IntoAggregateTuple for ( $($ty,)+ )
where
$($ty: AnnotationSlot,)+
{
type Decoded = ( $(<$ty as AnnotationSlot>::Decoded,)+ );
fn push_columns(&self, acc: &mut SqlAccumulator) {
$(
self.$slot.push_column(acc, $slot);
)+
}
fn push_columns_bare(&self, acc: &mut SqlAccumulator) {
self.push_columns_bare_after(acc, true);
}
fn push_columns_bare_after(&self, acc: &mut SqlAccumulator, has_previous_columns: bool) {
$(
self.$slot.push_column_bare_after(acc, $slot, has_previous_columns || $slot > 0);
)+
}
fn decode_tuple(&self, row: &tokio_postgres::Row) -> Result<Self::Decoded, tokio_postgres::Error> {
Ok((
$(
self.$slot.decode_column(row, $slot)?,
)+
))
}
fn annotation_count(&self) -> usize {
$arity
}
fn check_legality(&self) -> Result<(), crate::DjogiError> {
$(
self.$slot.check_legality()?;
)+
Ok(())
}
fn check_no_column_collision(
&self,
model_columns: &'static [&'static str],
) -> Result<(), crate::DjogiError> {
$(
self.$slot.check_no_column_collision(model_columns)?;
)+
Ok(())
}
fn requires_closure_pair_join(&self) -> bool {
false $(
|| self.$slot.requires_closure_pair_join()
)+
}
fn is_joined_safe(&self) -> bool {
true $(
&& self.$slot.is_joined_safe()
)+
}
fn requires_pair_tuple_scope(&self) -> bool {
false $(
|| self.$slot.requires_pair_tuple_scope()
)+
}
fn validate_against_closure_pair(
&self,
closure_pair: Option<&crate::query::joined::ClosurePairJoin>,
) -> Result<(), crate::DjogiError> {
$(
self.$slot.validate_against_closure_pair(closure_pair)?;
)+
Ok(())
}
}
impl<$($ty),+> PlainAnnotationTuple for ( $($ty,)+ )
where
$($ty: PlainAnnotationSlot,)+
{
fn push_plain_columns(&self, acc: &mut SqlAccumulator) {
$(
self.$slot.push_column(acc, $slot);
)+
}
}
};
}
impl_into_aggregate_tuple!(arity = 2, types = [(A, 0), (B, 1),]);
impl_into_aggregate_tuple!(arity = 3, types = [(A, 0), (B, 1), (C, 2),]);
impl_into_aggregate_tuple!(arity = 4, types = [(A, 0), (B, 1), (C, 2), (D, 3),]);
#[must_use = "annotated queries are lazy — dropping one silently omits the query"]
pub struct AnnotatedQuerySet<T: Model, A: IntoAggregateTuple> {
pub(crate) qs: QuerySet<T>,
pub(crate) aggregates: A,
pub(crate) qualify: Option<crate::expr::QualifyCondition>,
pub(crate) _a: PhantomData<fn() -> A>,
}
impl<T: Model, A: IntoAggregateTuple> AnnotatedQuerySet<T, A> {
#[must_use = "annotated queries 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
}
}
impl<T: Model, A: PlainAnnotationTuple + Send> AnnotatedQuerySet<T, A>
where
T: FromPgRow + Send + Unpin,
{
pub fn fetch_all<'ctx>(
self,
ctx: &'ctx mut DjogiContext,
) -> impl Future<Output = Result<Vec<(T, A::Decoded)>, DjogiError>> + Send + 'ctx
where
T: 'ctx,
A: 'ctx,
A::Decoded: Send + 'ctx,
{
async move {
let AnnotatedQuerySet {
qs,
aggregates,
qualify,
..
} = self;
if qs.is_empty() {
return Ok(Vec::new());
}
aggregates.check_legality()?;
aggregates.check_no_column_collision(T::COLUMNS)?;
if aggregates.requires_pair_tuple_scope() || aggregates.requires_closure_pair_join() {
return Err(DjogiError::Validation(
"single-Model QuerySet::annotate cannot host a pair-tuple aggregate \
(e.g. PairClosureKinshipSum, PairAreaOverlapRatio). These aggregates \
reference the pair-tuple emitter's `l.` / `r.` / `la.` / `ra.` aliases \
which are only in scope inside a JoinedQuerySet. Use \
`model_objects.self_pairs().annotate(...)` (or \
`.left_join_closure_pair::<C>().annotate(...)` for closure-pair aggregates) \
to reach the joined-annotated terminal."
.to_string(),
));
}
crate::query::terminal::auto_set_tenant::<T>(ctx).await?;
let acc = build_annotated_select_for_fetch(
&qs,
|acc| {
aggregates.push_plain_columns(acc);
},
qualify.as_ref(),
)
.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<(T, A::Decoded)> = Vec::with_capacity(rows.len());
for row in &rows {
let model = T::from_pg_row(row)?;
let agg = aggregates.decode_tuple(row).map_err(DjogiError::from)?;
out.push((model, agg));
}
Ok(out)
}
}
}
impl<T: Model> QuerySet<T> {
#[must_use = "annotated queries are lazy — dropping one silently omits the query"]
pub fn annotate<F, A>(self, f: F) -> AnnotatedQuerySet<T, A>
where
F: FnOnce(T::Fields) -> A,
A: PlainAnnotationTuple,
{
let aggregates = f(T::Fields::default());
AnnotatedQuerySet {
qs: self,
aggregates,
qualify: None,
_a: PhantomData,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::descriptor::ModelDescriptor;
use crate::expr::{DenseRank, Expr, Rank, RowNumber};
use crate::query::field::FieldRef;
use crate::query::sql::build_select_with_annotations;
struct Acc;
impl crate::model::__sealed::Sealed for Acc {}
#[allow(clippy::manual_async_fn)]
impl Model for Acc {
type Pk = i64;
type Fields = ();
fn table_name() -> &'static str {
"accs"
}
fn pk_value(&self) -> &i64 {
unreachable!()
}
fn descriptor() -> &'static ModelDescriptor {
unreachable!()
}
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!() }
}
}
impl FromPgRow for Acc {
const COLUMNS: &'static [&'static str] = &["id"];
const COLUMN_LIST: &'static str = "id";
fn from_pg_row(_row: &tokio_postgres::Row) -> Result<Self, crate::DjogiError> {
unreachable!("SQL-text unit tests do not exercise row decode")
}
}
#[test]
fn annotate_arity_one_emits_expected_sql() {
let qs: QuerySet<Acc> = QuerySet::new();
let f: FieldRef<Acc, i64> = FieldRef::new("balance");
let agg = f.sum();
let acc = build_select_with_annotations(&qs, |acc| {
agg.push_columns(acc);
})
.expect("annotate select");
let sql = acc.sql();
assert!(
sql.contains(
"SELECT t.id, (SUM(balance) OVER ())::BIGINT AS __djogi_agg_0 FROM accs AS t"
),
"got: {sql}"
);
}
#[test]
fn annotate_arity_two_emits_both_aggregates() {
let qs: QuerySet<Acc> = QuerySet::new();
let f1: FieldRef<Acc, i64> = FieldRef::new("balance");
let f2: FieldRef<Acc, i64> = FieldRef::new("balance");
let tuple = (f1.sum(), f2.count());
let acc = build_select_with_annotations(&qs, |acc| {
tuple.push_columns(acc);
})
.expect("annotate select");
let sql = acc.sql();
assert!(
sql.contains("(SUM(balance) OVER ())::BIGINT AS __djogi_agg_0"),
"got: {sql}"
);
assert!(
sql.contains("COUNT(balance) OVER () AS __djogi_agg_1"),
"got: {sql}"
);
}
#[test]
fn aggregate_scalar_emits_select_agg_from_table() {
use crate::query::sql::build_aggregate_select;
let qs: QuerySet<Acc> = QuerySet::new();
let f: FieldRef<Acc, i64> = FieldRef::new("balance");
let agg = f.sum();
let acc = build_aggregate_select(&qs, &agg.node).expect("aggregate select");
let sql = acc.sql();
assert_eq!(
sql.trim(),
"SELECT (SUM(balance))::BIGINT FROM accs",
"got: {sql}"
);
}
#[test]
fn aggregate_count_with_filter_emits_filter_clause() {
use crate::query::sql::build_aggregate_select;
let qs: QuerySet<Acc> = QuerySet::new();
let f_count: FieldRef<Acc, i64> = FieldRef::new("balance");
let f_cond: FieldRef<Acc, i64> = FieldRef::new("balance");
let agg = f_count
.count()
.filter(f_cond.as_expr().lt(Expr::literal(0i64)));
let acc = build_aggregate_select(&qs, &agg.node).expect("aggregate select");
let sql = acc.sql();
assert!(
sql.contains("COUNT(balance) FILTER (WHERE balance < $1)"),
"got: {sql}"
);
}
#[test]
fn row_number_window_annotation_emits_required_over_and_alias() {
let qs: QuerySet<Acc> = QuerySet::new();
let herd: FieldRef<Acc, i64> = FieldRef::new("herd_id");
let score: FieldRef<Acc, i64> = FieldRef::new("score");
let row_number = RowNumber::new()
.partition_by(herd)
.order_by(score.desc())
.alias("rank");
let acc = build_select_with_annotations(&qs, |acc| {
row_number.push_columns(acc);
})
.expect("annotate select");
let sql = acc.sql();
assert!(
sql.contains("ROW_NUMBER() OVER (PARTITION BY herd_id ORDER BY score DESC) AS rank"),
"got: {sql}"
);
}
#[test]
fn rank_window_annotation_emits_required_over_and_alias() {
let qs: QuerySet<Acc> = QuerySet::new();
let herd: FieldRef<Acc, i64> = FieldRef::new("herd_id");
let score: FieldRef<Acc, i64> = FieldRef::new("score");
let rank = Rank::new()
.partition_by(herd)
.order_by(score.desc())
.alias("rank");
let acc = build_select_with_annotations(&qs, |acc| {
rank.push_columns(acc);
})
.expect("annotate select");
let sql = acc.sql();
assert!(
sql.contains("RANK() OVER (PARTITION BY herd_id ORDER BY score DESC) AS rank"),
"got: {sql}"
);
}
#[test]
fn dense_rank_window_annotation_emits_required_over_and_alias() {
let qs: QuerySet<Acc> = QuerySet::new();
let herd: FieldRef<Acc, i64> = FieldRef::new("herd_id");
let score: FieldRef<Acc, i64> = FieldRef::new("score");
let dense_rank = DenseRank::new()
.partition_by(herd)
.order_by(score.desc())
.alias("dense_rank");
let acc = build_select_with_annotations(&qs, |acc| {
dense_rank.push_columns(acc);
})
.expect("annotate select");
let sql = acc.sql();
assert!(
sql.contains(
"DENSE_RANK() OVER (PARTITION BY herd_id ORDER BY score DESC) AS dense_rank"
),
"got: {sql}"
);
}
#[test]
fn qualify_lowers_to_derived_table_where() {
let score: FieldRef<Acc, i64> = FieldRef::new("score");
let annotated = QuerySet::<Acc>::new()
.annotate(|_| RowNumber::new().order_by(score.desc()).alias("rank"))
.qualify(|w| w.lte(3));
let acc = build_annotated_select_for_fetch(
&annotated.qs,
|acc| annotated.aggregates.push_columns(acc),
annotated.qualify.as_ref(),
)
.expect("annotated select");
let sql = acc.sql();
assert!(
sql.starts_with(
"SELECT * FROM (SELECT t.id, ROW_NUMBER() OVER (ORDER BY score DESC) AS rank FROM accs AS t"
),
"got: {sql}"
);
assert!(
sql.contains(") AS __djogi_q WHERE rank <= $1"),
"got: {sql}"
);
}
#[test]
fn qualify_lowering_never_emits_qualify_clause_token() {
let score: FieldRef<Acc, i64> = FieldRef::new("score");
let annotated = QuerySet::<Acc>::new()
.annotate(|_| RowNumber::new().order_by(score.desc()).alias("rank"))
.qualify(|w| w.lte(3));
let acc = build_annotated_select_for_fetch(
&annotated.qs,
|acc| annotated.aggregates.push_columns(acc),
annotated.qualify.as_ref(),
)
.expect("annotated select");
let sql = acc.sql();
assert!(!sql.contains("QUALIFY"), "got: {sql}");
assert!(!sql.contains("qualify"), "got: {sql}");
}
#[test]
#[should_panic(expected = "is reserved")]
fn alias_rejects_framework_reserved_djogi_q_prefix() {
let _ = RowNumber::new().alias("__djogi_q");
}
#[test]
#[should_panic(expected = "is reserved")]
fn alias_rejects_framework_reserved_agg_slot_prefix() {
let _ = Rank::new().alias("__djogi_agg_0");
}
#[test]
fn row_number_alias_colliding_with_model_column_returns_validation_error() {
let rn = RowNumber::new()
.order_by(FieldRef::<Acc, i64>::new("score").desc())
.alias("id");
let result = AnnotationSlot::check_no_column_collision(&rn, Acc::COLUMNS);
assert!(
matches!(result, Err(crate::DjogiError::Validation(_))),
"alias colliding with model column must yield DjogiError::Validation, got: {result:?}"
);
if let Err(crate::DjogiError::Validation(msg)) = result {
assert!(
msg.contains("id"),
"error message must name the conflicting alias, got: {msg}"
);
}
}
#[test]
fn rank_alias_colliding_with_model_column_returns_validation_error() {
let r = Rank::new().alias("id");
let result = AnnotationSlot::check_no_column_collision(&r, Acc::COLUMNS);
assert!(
matches!(result, Err(crate::DjogiError::Validation(_))),
"Rank alias collision must yield DjogiError::Validation, got: {result:?}"
);
}
#[test]
fn dense_rank_alias_colliding_with_model_column_returns_validation_error() {
let dr = DenseRank::new().alias("id");
let result = AnnotationSlot::check_no_column_collision(&dr, Acc::COLUMNS);
assert!(
matches!(result, Err(crate::DjogiError::Validation(_))),
"DenseRank alias collision must yield DjogiError::Validation, got: {result:?}"
);
}
#[test]
fn row_number_alias_not_in_model_columns_passes_collision_check() {
let rn = RowNumber::new().alias("rank");
assert!(
AnnotationSlot::check_no_column_collision(&rn, Acc::COLUMNS).is_ok(),
"alias not matching any model column must pass the collision check"
);
}
#[test]
fn row_number_without_alias_skips_collision_check() {
let rn = RowNumber::new(); assert!(
AnnotationSlot::check_no_column_collision(&rn, &["id", "score", "rank"]).is_ok(),
"unaliased window annotation must pass the collision check (no alias to compare)"
);
}
#[test]
fn lead_window_alias_collision_returns_validation_error() {
use crate::expr::LeadWindow;
let amount: FieldRef<Acc, i64> = FieldRef::new("amount");
let lead: LeadWindow<i64> = LeadWindow::new(amount).alias("id");
let result = AnnotationSlot::check_no_column_collision(&lead, Acc::COLUMNS);
assert!(
matches!(result, Err(crate::DjogiError::Validation(_))),
"LeadWindow alias collision must yield DjogiError::Validation, got: {result:?}"
);
}
#[test]
fn aggregate_expr_never_trips_column_collision_check() {
let f: FieldRef<Acc, i64> = FieldRef::new("balance");
let agg = f.sum();
let result = AnnotationSlot::check_no_column_collision(&agg, &["id", "balance", "score"]);
assert!(
result.is_ok(),
"AggregateExpr must never fail the column-collision check (uses framework aliases)"
);
}
#[test]
fn tuple_collision_check_walks_all_slots() {
let rn_ok = RowNumber::new().alias("rank"); let rn_bad = Rank::new().alias("id");
let score: FieldRef<Acc, i64> = FieldRef::new("score");
let ok_pair = (rn_ok, score.sum());
assert!(
IntoAggregateTuple::check_no_column_collision(&ok_pair, Acc::COLUMNS).is_ok(),
"tuple of non-colliding slots must pass"
);
let rn_ok2 = RowNumber::new().alias("rank");
let bad_pair = (rn_ok2, rn_bad);
assert!(
matches!(
IntoAggregateTuple::check_no_column_collision(&bad_pair, Acc::COLUMNS),
Err(crate::DjogiError::Validation(_))
),
"tuple with any colliding slot must fail the collision check"
);
}
#[test]
fn row_number_uppercase_alias_collides_with_lowercase_model_column() {
let rn = RowNumber::new().alias("ID");
let result = AnnotationSlot::check_no_column_collision(&rn, Acc::COLUMNS);
assert!(
matches!(result, Err(crate::DjogiError::Validation(_))),
"uppercase alias 'ID' must collide with lowercase column 'id' via case-fold \
comparator, got: {result:?}"
);
}
#[test]
fn lead_window_uppercase_alias_collides_with_lowercase_model_column() {
let amount: FieldRef<Acc, i64> = FieldRef::new("amount");
let lead: LeadWindow<i64> = LeadWindow::new(amount).alias("ID");
let result = AnnotationSlot::check_no_column_collision(&lead, Acc::COLUMNS);
assert!(
matches!(result, Err(crate::DjogiError::Validation(_))),
"LeadWindow: uppercase alias 'ID' must collide with lowercase column 'id' via \
case-fold comparator, got: {result:?}"
);
}
#[test]
fn tuple_collision_check_catches_case_fold_collision() {
let rn_ok = RowNumber::new().alias("rank"); let rn_upper = Rank::new().alias("ID");
let score: FieldRef<Acc, i64> = FieldRef::new("score");
let ok_pair = (rn_ok, score.sum());
assert!(
IntoAggregateTuple::check_no_column_collision(&ok_pair, Acc::COLUMNS).is_ok(),
"tuple of non-colliding slots must pass"
);
let rn_ok2 = RowNumber::new().alias("rank");
let upper_pair = (rn_ok2, rn_upper);
assert!(
matches!(
IntoAggregateTuple::check_no_column_collision(&upper_pair, Acc::COLUMNS),
Err(crate::DjogiError::Validation(_))
),
"tuple containing a case-fold-colliding slot must fail the collision check"
);
}
#[test]
fn percent_rank_window_emits_over_clause_and_alias() {
use crate::expr::PercentRankWindow;
let qs: QuerySet<Acc> = QuerySet::new();
let amount: FieldRef<Acc, i64> = FieldRef::new("amount");
let pr = PercentRankWindow::new()
.order_by(amount.desc())
.alias("amount_pct");
let acc = build_select_with_annotations(&qs, |acc| pr.push_columns(acc))
.expect("annotate select");
let sql = acc.sql();
assert!(
sql.contains("PERCENT_RANK() OVER (ORDER BY amount DESC) AS amount_pct"),
"got: {sql}"
);
}
#[test]
fn cume_dist_window_emits_over_clause_and_alias() {
use crate::expr::CumeDistWindow;
let qs: QuerySet<Acc> = QuerySet::new();
let amount: FieldRef<Acc, i64> = FieldRef::new("amount");
let cd = CumeDistWindow::new()
.order_by(amount.asc())
.alias("cume_dist");
let acc = build_select_with_annotations(&qs, |acc| cd.push_columns(acc))
.expect("annotate select");
let sql = acc.sql();
assert!(
sql.contains("CUME_DIST() OVER (ORDER BY amount ASC) AS cume_dist"),
"got: {sql}"
);
}
#[test]
fn ntile_window_binds_bucket_count_and_emits_over() {
use crate::expr::NtileWindow;
let qs: QuerySet<Acc> = QuerySet::new();
let amount: FieldRef<Acc, i64> = FieldRef::new("amount");
let ntile = NtileWindow::new(4)
.order_by(amount.desc())
.alias("quartile");
let acc = build_select_with_annotations(&qs, |acc| ntile.push_columns(acc))
.expect("annotate select");
let sql = acc.sql();
assert!(
sql.contains("NTILE($") && sql.contains(") OVER (ORDER BY amount DESC) AS quartile"),
"got: {sql}"
);
}
#[test]
fn first_value_window_emits_column_and_over() {
use crate::expr::FirstValueWindow;
let qs: QuerySet<Acc> = QuerySet::new();
let amount: FieldRef<Acc, i64> = FieldRef::new("amount");
let herd: FieldRef<Acc, i64> = FieldRef::new("herd_id");
let fv: FirstValueWindow<i64> = FirstValueWindow::new(amount)
.partition_by(herd)
.order_by(amount.desc())
.alias("top_amount");
let acc = build_select_with_annotations(&qs, |acc| fv.push_columns(acc))
.expect("annotate select");
let sql = acc.sql();
assert!(
sql.contains(
"FIRST_VALUE(amount) OVER (PARTITION BY herd_id ORDER BY amount DESC) AS top_amount"
),
"got: {sql}"
);
}
#[test]
fn last_value_window_emits_column_and_over() {
use crate::expr::LastValueWindow;
let qs: QuerySet<Acc> = QuerySet::new();
let amount: FieldRef<Acc, i64> = FieldRef::new("amount");
let lv: LastValueWindow<i64> = LastValueWindow::new(amount)
.order_by(amount.asc())
.alias("bottom_amount");
let acc = build_select_with_annotations(&qs, |acc| lv.push_columns(acc))
.expect("annotate select");
let sql = acc.sql();
assert!(
sql.contains("LAST_VALUE(amount) OVER (ORDER BY amount ASC) AS bottom_amount"),
"got: {sql}"
);
}
#[test]
fn lead_window_default_offset_emits_no_offset_arg() {
use crate::expr::LeadWindow;
let qs: QuerySet<Acc> = QuerySet::new();
let amount: FieldRef<Acc, i64> = FieldRef::new("amount");
let lead: LeadWindow<i64> = LeadWindow::new(amount)
.order_by(amount.asc())
.alias("next_amount");
let acc = build_select_with_annotations(&qs, |acc| lead.push_columns(acc))
.expect("annotate select");
let sql = acc.sql();
assert!(
sql.contains("LEAD(amount) OVER (ORDER BY amount ASC) AS next_amount"),
"got: {sql}"
);
}
#[test]
fn lead_window_with_offset_binds_offset_arg() {
use crate::expr::LeadWindow;
let qs: QuerySet<Acc> = QuerySet::new();
let amount: FieldRef<Acc, i64> = FieldRef::new("amount");
let lead: LeadWindow<i64> = LeadWindow::new(amount)
.offset(3)
.order_by(amount.asc())
.alias("third_next_amount");
let acc = build_select_with_annotations(&qs, |acc| lead.push_columns(acc))
.expect("annotate select");
let sql = acc.sql();
assert!(
sql.contains("LEAD(amount, $")
&& sql.contains(") OVER (ORDER BY amount ASC) AS third_next_amount"),
"got: {sql}"
);
}
#[test]
fn lag_window_emits_lag_keyword() {
use crate::expr::LagWindow;
let qs: QuerySet<Acc> = QuerySet::new();
let amount: FieldRef<Acc, i64> = FieldRef::new("amount");
let lag: LagWindow<i64> = LagWindow::new(amount)
.order_by(amount.asc())
.alias("prev_amount");
let acc = build_select_with_annotations(&qs, |acc| lag.push_columns(acc))
.expect("annotate select");
let sql = acc.sql();
assert!(
sql.contains("LAG(amount) OVER (ORDER BY amount ASC) AS prev_amount"),
"got: {sql}"
);
}
#[test]
fn nth_value_window_emits_column_and_n() {
use crate::expr::NthValueWindow;
let qs: QuerySet<Acc> = QuerySet::new();
let amount: FieldRef<Acc, i64> = FieldRef::new("amount");
let nv: NthValueWindow<i64> = NthValueWindow::new(amount, 3)
.order_by(amount.desc())
.alias("third");
let acc = build_select_with_annotations(&qs, |acc| nv.push_columns(acc))
.expect("annotate select");
let sql = acc.sql();
assert!(
sql.contains("NTH_VALUE(amount, $")
&& sql.contains(") OVER (ORDER BY amount DESC) AS third"),
"got: {sql}"
);
}
#[test]
fn lead_window_partition_by_renders_partition_clause() {
use crate::expr::LeadWindow;
let qs: QuerySet<Acc> = QuerySet::new();
let amount: FieldRef<Acc, i64> = FieldRef::new("amount");
let session: FieldRef<Acc, i64> = FieldRef::new("session_id");
let lead: LeadWindow<i64> = LeadWindow::new(amount)
.partition_by(session)
.order_by(amount.asc())
.alias("next_amount");
let acc = build_select_with_annotations(&qs, |acc| lead.push_columns(acc))
.expect("annotate select");
let sql = acc.sql();
assert!(
sql.contains(
"LEAD(amount) OVER (PARTITION BY session_id ORDER BY amount ASC) AS next_amount"
),
"PARTITION BY must render before ORDER BY in window clause, got: {sql}"
);
}
#[test]
fn ntile_window_partition_by_renders_partition_clause() {
use crate::expr::NtileWindow;
let qs: QuerySet<Acc> = QuerySet::new();
let amount: FieldRef<Acc, i64> = FieldRef::new("amount");
let dept: FieldRef<Acc, i64> = FieldRef::new("dept_id");
let ntile = NtileWindow::new(4)
.partition_by(dept)
.order_by(amount.desc())
.alias("dept_quartile");
let acc = build_select_with_annotations(&qs, |acc| ntile.push_columns(acc))
.expect("annotate select");
let sql = acc.sql();
assert!(
sql.contains("NTILE($")
&& sql.contains(
") OVER (PARTITION BY dept_id ORDER BY amount DESC) AS dept_quartile"
),
"got: {sql}"
);
}
#[test]
fn lead_window_decode_type_pinned_to_v() {
use crate::expr::LeadWindow;
let amount: FieldRef<Acc, i64> = FieldRef::new("amount");
let _: LeadWindow<i64> = LeadWindow::new(amount);
let label: FieldRef<Acc, String> = FieldRef::new("label");
let _: LeadWindow<String> = LeadWindow::new(label);
}
#[test]
fn first_value_window_decode_type_pinned_to_v() {
use crate::expr::FirstValueWindow;
let amount: FieldRef<Acc, i64> = FieldRef::new("amount");
let _: FirstValueWindow<i64> = FirstValueWindow::new(amount);
let label: FieldRef<Acc, String> = FieldRef::new("label");
let _: FirstValueWindow<String> = FirstValueWindow::new(label);
}
#[test]
fn last_value_window_decode_type_pinned_to_v() {
use crate::expr::LastValueWindow;
let amount: FieldRef<Acc, i64> = FieldRef::new("amount");
let _: LastValueWindow<i64> = LastValueWindow::new(amount);
let label: FieldRef<Acc, String> = FieldRef::new("label");
let _: LastValueWindow<String> = LastValueWindow::new(label);
}
#[test]
fn lag_window_decode_type_pinned_to_v() {
use crate::expr::LagWindow;
let amount: FieldRef<Acc, i64> = FieldRef::new("amount");
let _: LagWindow<i64> = LagWindow::new(amount);
let label: FieldRef<Acc, String> = FieldRef::new("label");
let _: LagWindow<String> = LagWindow::new(label);
}
#[test]
fn nth_value_window_decode_type_pinned_to_v() {
use crate::expr::NthValueWindow;
let amount: FieldRef<Acc, i64> = FieldRef::new("amount");
let _: NthValueWindow<i64> = NthValueWindow::new(amount, 3);
let label: FieldRef<Acc, String> = FieldRef::new("label");
let _: NthValueWindow<String> = NthValueWindow::new(label, 5);
}
#[test]
#[should_panic(expected = "is reserved")]
fn lead_window_alias_rejects_djogi_prefix() {
use crate::expr::LeadWindow;
let amount: FieldRef<Acc, i64> = FieldRef::new("amount");
let _: LeadWindow<i64> = LeadWindow::new(amount).alias("__djogi_q");
}
#[test]
#[should_panic(expected = "is reserved")]
fn ntile_window_alias_rejects_djogi_prefix() {
use crate::expr::NtileWindow;
let _ = NtileWindow::new(4).alias("__djogi_agg_0");
}
#[test]
#[should_panic(expected = "is reserved")]
fn percent_rank_window_alias_rejects_djogi_prefix() {
use crate::expr::PercentRankWindow;
let _ = PercentRankWindow::new().alias("__djogi_q");
}
#[test]
fn aggregate_expr_default_requires_pair_tuple_scope_false() {
let f: FieldRef<Acc, i64> = FieldRef::new("balance");
let sum = f.sum();
assert!(
!AnnotationSlot::requires_pair_tuple_scope(&sum),
"AggregateExpr<V> must default to requires_pair_tuple_scope() = false"
);
let tuple_view: &dyn IntoAggregateTuple<Decoded = i64> = ∑
assert!(
!tuple_view.requires_pair_tuple_scope(),
"IntoAggregateTuple blanket must forward AnnotationSlot::requires_pair_tuple_scope through"
);
}
#[test]
fn row_number_default_requires_pair_tuple_scope_false() {
let rn = RowNumber::new().alias("rank");
assert!(
!AnnotationSlot::requires_pair_tuple_scope(&rn),
"RowNumber must default to requires_pair_tuple_scope() = false"
);
}
#[test]
fn unit_tuple_requires_pair_tuple_scope_false() {
let unit: () = ();
assert!(
!IntoAggregateTuple::requires_pair_tuple_scope(&unit),
"() / no-annotation case must report requires_pair_tuple_scope() = false"
);
}
#[test]
fn tuple_with_pair_only_slot_propagates_through_or() {
struct PairOnlySlot;
impl annotation_slot_sealed::Sealed for PairOnlySlot {}
impl AnnotationSlot for PairOnlySlot {
type Decoded = i64;
fn push_column(&self, _acc: &mut SqlAccumulator, _slot: usize) {
unreachable!("test slot — emitter never invoked")
}
fn push_column_bare(&self, _acc: &mut SqlAccumulator, _slot: usize) {
unreachable!("test slot — emitter never invoked")
}
fn push_column_bare_after(
&self,
_acc: &mut SqlAccumulator,
_slot: usize,
_has_previous_columns: bool,
) {
unreachable!("test slot — emitter never invoked")
}
fn decode_column(
&self,
_row: &tokio_postgres::Row,
_slot: usize,
) -> Result<i64, tokio_postgres::Error> {
unreachable!("test slot — decoder never invoked")
}
fn is_joined_safe(&self) -> bool {
true
}
fn requires_pair_tuple_scope(&self) -> bool {
true
}
}
let pair_only = PairOnlySlot;
let tuple_view: &dyn IntoAggregateTuple<Decoded = i64> = &pair_only;
assert!(
tuple_view.requires_pair_tuple_scope(),
"single-slot blanket must forward requires_pair_tuple_scope() = true"
);
let f: FieldRef<Acc, i64> = FieldRef::new("balance");
let pair = (f.sum(), PairOnlySlot);
assert!(
pair.requires_pair_tuple_scope(),
"arity-2 tuple with any pair-only slot must OR to true"
);
let g: FieldRef<Acc, i64> = FieldRef::new("balance");
let ordinary = (f.sum(), g.count());
assert!(
!ordinary.requires_pair_tuple_scope(),
"arity-2 tuple of ordinary slots must remain false"
);
}
#[test]
fn percent_rank_qualify_lt_lowers_to_derived_table_where_f64() {
use crate::expr::PercentRankWindow;
let amount: FieldRef<Acc, i64> = FieldRef::new("amount");
let annotated = QuerySet::<Acc>::new()
.annotate(|_| {
PercentRankWindow::new()
.order_by(amount.desc())
.alias("amount_pct")
})
.qualify(|w| w.lt(0.5));
let acc = build_annotated_select_for_fetch(
&annotated.qs,
|acc| annotated.aggregates.push_columns(acc),
annotated.qualify.as_ref(),
)
.expect("annotated select for percent_rank qualify lt");
let sql = acc.sql();
assert!(
sql.contains("PERCENT_RANK() OVER (ORDER BY amount DESC) AS amount_pct"),
"inner PERCENT_RANK emission missing, got: {sql}"
);
assert!(
sql.contains(") AS __djogi_q WHERE amount_pct < $"),
"outer WHERE predicate missing or malformed, got: {sql}"
);
assert!(
!sql.contains("QUALIFY"),
"QUALIFY token must not appear, got: {sql}"
);
let (_, binds) = acc.into_parts();
assert_eq!(
binds.len(),
1,
"expected exactly one bind slot (the f64 threshold)"
);
}
#[test]
fn percent_rank_qualify_gte_lowers_to_derived_table_where_f64() {
use crate::expr::PercentRankWindow;
let amount: FieldRef<Acc, i64> = FieldRef::new("amount");
let annotated = QuerySet::<Acc>::new()
.annotate(|_| {
PercentRankWindow::new()
.order_by(amount.desc())
.alias("top_pct")
})
.qualify(|w| w.gte(0.9));
let acc = build_annotated_select_for_fetch(
&annotated.qs,
|acc| annotated.aggregates.push_columns(acc),
annotated.qualify.as_ref(),
)
.expect("annotated select for percent_rank qualify gte");
let sql = acc.sql();
assert!(
sql.contains(") AS __djogi_q WHERE top_pct >= $"),
"outer WHERE must use >= operator, got: {sql}"
);
let (_, binds) = acc.into_parts();
assert_eq!(binds.len(), 1, "expected exactly one f64 bind slot");
}
#[test]
fn cume_dist_qualify_lte_lowers_to_derived_table_where_f64() {
use crate::expr::CumeDistWindow;
let amount: FieldRef<Acc, i64> = FieldRef::new("amount");
let annotated = QuerySet::<Acc>::new()
.annotate(|_| {
CumeDistWindow::new()
.order_by(amount.asc())
.alias("cume_dist")
})
.qualify(|w| w.lte(0.25));
let acc = build_annotated_select_for_fetch(
&annotated.qs,
|acc| annotated.aggregates.push_columns(acc),
annotated.qualify.as_ref(),
)
.expect("annotated select for cume_dist qualify lte");
let sql = acc.sql();
assert!(
sql.contains("CUME_DIST() OVER (ORDER BY amount ASC) AS cume_dist"),
"inner CUME_DIST emission missing, got: {sql}"
);
assert!(
sql.contains(") AS __djogi_q WHERE cume_dist <= $"),
"outer WHERE must use <= operator, got: {sql}"
);
assert!(
!sql.contains("QUALIFY"),
"QUALIFY token must not appear, got: {sql}"
);
let (_, binds) = acc.into_parts();
assert_eq!(
binds.len(),
1,
"expected exactly one bind slot (the f64 threshold)"
);
}
#[test]
fn cume_dist_qualify_gt_lowers_to_derived_table_where_f64() {
use crate::expr::CumeDistWindow;
let amount: FieldRef<Acc, i64> = FieldRef::new("amount");
let annotated = QuerySet::<Acc>::new()
.annotate(|_| {
CumeDistWindow::new()
.order_by(amount.asc())
.alias("cume_dist")
})
.qualify(|w| w.gt(0.0));
let acc = build_annotated_select_for_fetch(
&annotated.qs,
|acc| annotated.aggregates.push_columns(acc),
annotated.qualify.as_ref(),
)
.expect("annotated select for cume_dist qualify gt");
let sql = acc.sql();
assert!(
sql.contains(") AS __djogi_q WHERE cume_dist > $"),
"outer WHERE must use > operator, got: {sql}"
);
let (_, binds) = acc.into_parts();
assert_eq!(binds.len(), 1, "expected exactly one f64 bind slot");
}
#[test]
fn percent_rank_qualify_eq_lowers_to_derived_table_where_f64() {
use crate::expr::PercentRankWindow;
let score: FieldRef<Acc, i64> = FieldRef::new("score");
let annotated = QuerySet::<Acc>::new()
.annotate(|_| PercentRankWindow::new().order_by(score.desc()).alias("pct"))
.qualify(|w| w.eq(0.0));
let acc = build_annotated_select_for_fetch(
&annotated.qs,
|acc| annotated.aggregates.push_columns(acc),
annotated.qualify.as_ref(),
)
.expect("annotated select for percent_rank qualify eq");
let sql = acc.sql();
assert!(
sql.contains(") AS __djogi_q WHERE pct = $"),
"outer WHERE must use = operator, got: {sql}"
);
let (_, binds) = acc.into_parts();
assert_eq!(binds.len(), 1, "expected exactly one f64 bind slot");
}
#[test]
#[should_panic(expected = "qualify can only reference a window annotation")]
fn percent_rank_qualify_without_alias_panics() {
use crate::expr::PercentRankWindow;
let _ = PercentRankWindow::new().lt(0.5);
}
#[test]
#[should_panic(expected = "qualify can only reference a window annotation")]
fn cume_dist_qualify_without_alias_panics() {
use crate::expr::CumeDistWindow;
let _ = CumeDistWindow::new().gte(0.9);
}
#[test]
fn percent_rank_qualify_bind_count_is_one_f64() {
use crate::expr::PercentRankWindow;
let cond = PercentRankWindow::new().alias("pct").lt(0.75);
let mut acc = crate::pg::accumulator::SqlAccumulator::new("");
cond.push_outer_where(&mut acc);
assert_eq!(
acc.bind_count(),
1,
"qualify predicate must consume exactly one bind slot"
);
assert!(
acc.sql().contains("$1"),
"bind placeholder must appear in SQL, got: {}",
acc.sql()
);
assert!(
!acc.sql().contains("0.75"),
"threshold value must NOT appear verbatim in SQL text (injection safety), got: {}",
acc.sql()
);
}
}