#![allow(clippy::manual_async_fn)]
use crate::model::Model;
use crate::pg::accumulator::SqlAccumulator;
use crate::query::field::{DjogiField, FieldRef};
use crate::query::queryset::QuerySet;
use std::marker::PhantomData;
pub(crate) mod sealed {
pub trait Sealed {}
}
pub trait IntoGroupKeyTuple: sealed::Sealed {
type Decoded;
fn push_group_by_columns(&self, acc: &mut SqlAccumulator);
fn push_select_columns(&self, acc: &mut SqlAccumulator);
fn decode_tuple(row: &tokio_postgres::Row) -> Result<Self::Decoded, tokio_postgres::Error>;
}
impl<M: Model, V> sealed::Sealed for FieldRef<M, V> {}
impl<M: Model, V> IntoGroupKeyTuple for FieldRef<M, V>
where
V: for<'a> postgres_types::FromSql<'a> + Send + Unpin + 'static,
{
type Decoded = V;
fn push_group_by_columns(&self, acc: &mut SqlAccumulator) {
acc.push_sql(self.column());
}
fn push_select_columns(&self, acc: &mut SqlAccumulator) {
acc.push_sql(self.column());
}
fn decode_tuple(row: &tokio_postgres::Row) -> Result<Self::Decoded, tokio_postgres::Error> {
row.try_get::<_, V>(0)
}
}
macro_rules! impl_into_group_key_tuple {
(
arity = $arity:tt,
types = [ $( ($ty:ident, $slot:tt, $pos:literal) ),+ $(,)? ]
) => {
impl<M: Model, $($ty),+> sealed::Sealed for ( $(FieldRef<M, $ty>,)+ ) {}
impl<M: Model, $($ty),+> IntoGroupKeyTuple for ( $(FieldRef<M, $ty>,)+ )
where
$( $ty: for<'a> postgres_types::FromSql<'a> + Send + Unpin + 'static, )+
{
type Decoded = ( $($ty,)+ );
fn push_group_by_columns(&self, acc: &mut SqlAccumulator) {
let mut first = true;
$(
if !first { acc.push_sql(", "); }
first = false;
acc.push_sql(self.$slot.column());
)+
let _ = first;
}
fn push_select_columns(&self, acc: &mut SqlAccumulator) {
let mut first = true;
$(
if !first { acc.push_sql(", "); }
first = false;
acc.push_sql(self.$slot.column());
)+
let _ = first;
}
fn decode_tuple(row: &tokio_postgres::Row) -> Result<Self::Decoded, tokio_postgres::Error> {
Ok((
$( row.try_get::<_, $ty>($pos)?, )+
))
}
}
};
}
impl_into_group_key_tuple!(arity = 2, types = [(A, 0, 0), (B, 1, 1)]);
impl_into_group_key_tuple!(arity = 3, types = [(A, 0, 0), (B, 1, 1), (C, 2, 2)]);
impl_into_group_key_tuple!(
arity = 4,
types = [(A, 0, 0), (B, 1, 1), (C, 2, 2), (D, 3, 3)]
);
impl<M: Model, V> sealed::Sealed for DjogiField<M, V> {}
impl<M: Model, V> IntoGroupKeyTuple for DjogiField<M, V>
where
V: for<'a> postgres_types::FromSql<'a> + Send + Unpin + 'static,
{
type Decoded = V;
fn push_group_by_columns(&self, acc: &mut SqlAccumulator) {
acc.push_sql(self.column());
}
fn push_select_columns(&self, acc: &mut SqlAccumulator) {
acc.push_sql(self.column());
}
fn decode_tuple(row: &tokio_postgres::Row) -> Result<Self::Decoded, tokio_postgres::Error> {
row.try_get::<_, V>(0)
}
}
macro_rules! impl_into_group_key_tuple_djogi {
(
arity = $arity:tt,
types = [ $( ($ty:ident, $slot:tt, $pos:literal) ),+ $(,)? ]
) => {
impl<M: Model, $($ty),+> sealed::Sealed for ( $(DjogiField<M, $ty>,)+ ) {}
impl<M: Model, $($ty),+> IntoGroupKeyTuple for ( $(DjogiField<M, $ty>,)+ )
where
$( $ty: for<'a> postgres_types::FromSql<'a> + Send + Unpin + 'static, )+
{
type Decoded = ( $($ty,)+ );
fn push_group_by_columns(&self, acc: &mut SqlAccumulator) {
let mut first = true;
$(
if !first { acc.push_sql(", "); }
first = false;
acc.push_sql(self.$slot.column());
)+
let _ = first;
}
fn push_select_columns(&self, acc: &mut SqlAccumulator) {
let mut first = true;
$(
if !first { acc.push_sql(", "); }
first = false;
acc.push_sql(self.$slot.column());
)+
let _ = first;
}
fn decode_tuple(row: &tokio_postgres::Row) -> Result<Self::Decoded, tokio_postgres::Error> {
Ok((
$( row.try_get::<_, $ty>($pos)?, )+
))
}
}
};
}
impl_into_group_key_tuple_djogi!(arity = 2, types = [(A, 0, 0), (B, 1, 1)]);
impl_into_group_key_tuple_djogi!(arity = 3, types = [(A, 0, 0), (B, 1, 1), (C, 2, 2)]);
impl_into_group_key_tuple_djogi!(
arity = 4,
types = [(A, 0, 0), (B, 1, 1), (C, 2, 2), (D, 3, 3)]
);
#[cfg(feature = "spatial")]
#[derive(Debug, Clone)]
pub(crate) enum SpatialGroupSource {
Join(crate::query::spatial_grouping::SpatialJoinSpec),
Cluster(crate::query::spatial_grouping::ClusterSpec),
Geohash(crate::query::spatial_grouping::GeohashSpec),
}
impl sealed::Sealed for () {}
impl IntoGroupKeyTuple for () {
type Decoded = ();
fn push_group_by_columns(&self, _acc: &mut SqlAccumulator) {
}
fn push_select_columns(&self, _acc: &mut SqlAccumulator) {
}
fn decode_tuple(_row: &tokio_postgres::Row) -> Result<Self::Decoded, tokio_postgres::Error> {
Ok(())
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum GroupingMode {
Plain,
Rollup,
Cube,
Sets(Vec<Vec<&'static str>>),
}
#[must_use = "grouped queries are lazy — dropping one silently omits the query"]
pub struct GroupedQuerySet<T: Model, K: IntoGroupKeyTuple> {
pub(crate) qs: QuerySet<T>,
pub(crate) keys: K,
pub(crate) grouping: GroupingMode,
#[cfg(feature = "spatial")]
pub(crate) spatial_source: Option<SpatialGroupSource>,
pub(crate) _k: PhantomData<fn() -> K>,
}
#[must_use = "grouped queries are lazy — dropping one silently omits the query"]
pub struct GroupedAnnotatedQuerySet<
T: Model,
K: IntoGroupKeyTuple,
A: crate::query::annotate::IntoAggregateTuple,
> {
pub(crate) qs: QuerySet<T>,
pub(crate) keys: K,
pub(crate) grouping: GroupingMode,
pub(crate) aggregates: A,
pub(crate) having: Option<crate::expr::node::ExprNode>,
pub(crate) order: Vec<crate::query::order::OrderExpr>,
pub(crate) limit: Option<u64>,
pub(crate) offset: Option<u64>,
#[cfg(feature = "spatial")]
pub(crate) spatial_source: Option<SpatialGroupSource>,
pub(crate) _k: PhantomData<fn() -> K>,
pub(crate) _a: PhantomData<fn() -> A>,
}
impl<T: Model, K: IntoGroupKeyTuple> GroupedQuerySet<T, K> {
#[must_use = "grouped queries are lazy — dropping one silently omits the query"]
pub fn annotate<F, A>(self, f: F) -> GroupedAnnotatedQuerySet<T, K, A>
where
F: FnOnce(T::Fields) -> A,
A: crate::query::annotate::IntoAggregateTuple,
{
let aggregates = f(T::Fields::default());
GroupedAnnotatedQuerySet {
qs: self.qs,
keys: self.keys,
grouping: self.grouping,
aggregates,
having: None,
order: Vec::new(),
limit: None,
offset: None,
#[cfg(feature = "spatial")]
spatial_source: self.spatial_source,
_k: PhantomData,
_a: PhantomData,
}
}
}
impl<T: Model, K: IntoGroupKeyTuple + Clone, A: crate::query::annotate::IntoAggregateTuple + Clone>
GroupedAnnotatedQuerySet<T, K, A>
{
#[must_use = "grouped queries are lazy — dropping one silently omits the query"]
pub fn having<F>(mut self, f: F) -> Self
where
F: FnOnce(K, A) -> crate::expr::Expr<bool>,
{
let cond = f(self.keys.clone(), self.aggregates.clone());
self.having = Some(cond.node);
self
}
#[must_use = "grouped queries are lazy — dropping one silently omits the query"]
pub fn order_by<F>(mut self, f: F) -> Self
where
F: FnOnce(K, A) -> crate::query::order::OrderExpr,
{
let order = f(self.keys.clone(), self.aggregates.clone());
self.order.push(order);
self
}
}
impl<T: Model, K: IntoGroupKeyTuple, A: crate::query::annotate::IntoAggregateTuple>
GroupedAnnotatedQuerySet<T, K, A>
{
pub fn limit(mut self, n: u64) -> Self {
self.limit = Some(n);
self
}
pub fn offset(mut self, n: u64) -> Self {
self.offset = Some(n);
self
}
}
impl<T: Model, K: IntoGroupKeyTuple + Send, A: crate::query::annotate::IntoAggregateTuple + Send>
GroupedAnnotatedQuerySet<T, K, A>
where
T: Send,
K::Decoded: Send,
A::Decoded: Send,
{
#[allow(clippy::type_complexity)]
pub fn fetch_all<'ctx>(
self,
ctx: &'ctx mut crate::context::DjogiContext,
) -> impl std::future::Future<Output = Result<Vec<(K::Decoded, A::Decoded)>, crate::DjogiError>>
+ Send
+ 'ctx
where
T: 'ctx,
K: 'ctx,
A: 'ctx,
K::Decoded: 'ctx,
A::Decoded: 'ctx,
{
async move {
self.aggregates.check_legality()?;
if self.aggregates.requires_pair_tuple_scope()
|| self.aggregates.requires_closure_pair_join()
{
return Err(crate::DjogiError::Validation(
"grouped single-Model annotate cannot host a pair-tuple aggregate \
(e.g. PairClosureKinshipSum, PairAreaOverlapRatio). These aggregates \
reference pair-tuple emitter aliases (`l.` / `r.` / `la.` / `ra.`) that \
are only in scope inside a JoinedQuerySet. Use \
`model_objects.self_pairs().annotate(...)` (or \
`.left_join_closure_pair::<C>().annotate(...)` for closure-pair aggregates) \
for the joined-annotated terminal."
.to_string(),
));
}
crate::query::terminal::auto_set_tenant::<T>(ctx).await?;
let acc = crate::query::sql::build_grouped_annotated_select(&self)
.map_err(crate::DjogiError::from)?;
crate::query::sql::assert_no_alias_collision(acc.sql())?;
let (sql, binds) = acc.into_parts();
let params: Vec<&(dyn postgres_types::ToSql + Sync)> = binds
.iter()
.map(|b| b.as_ref() as &(dyn postgres_types::ToSql + Sync))
.collect();
let rows = ctx.query_all(&sql, ¶ms).await?;
let mut out = Vec::with_capacity(rows.len());
for row in &rows {
let k = K::decode_tuple(row).map_err(crate::DjogiError::from)?;
let a = self
.aggregates
.decode_tuple(row)
.map_err(crate::DjogiError::from)?;
out.push((k, a));
}
Ok(out)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::descriptor::ModelDescriptor;
struct Fake;
impl crate::model::__sealed::Sealed for Fake {}
#[allow(clippy::manual_async_fn)]
impl Model for Fake {
type Pk = i64;
type Fields = ();
fn table_name() -> &'static str {
"fakes"
}
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!() }
}
}
#[test]
fn arity_one_field_ref_implements_into_group_key_tuple() {
fn assert_bound<T: IntoGroupKeyTuple>() {}
assert_bound::<FieldRef<Fake, i64>>();
}
#[test]
fn arity_two_tuple_implements_into_group_key_tuple() {
fn assert_bound<T: IntoGroupKeyTuple>() {}
assert_bound::<(FieldRef<Fake, i64>, FieldRef<Fake, String>)>();
}
#[test]
fn arity_three_tuple_implements_into_group_key_tuple() {
fn assert_bound<T: IntoGroupKeyTuple>() {}
assert_bound::<(
FieldRef<Fake, i64>,
FieldRef<Fake, String>,
FieldRef<Fake, i32>,
)>();
}
#[test]
fn arity_four_tuple_implements_into_group_key_tuple() {
fn assert_bound<T: IntoGroupKeyTuple>() {}
assert_bound::<(
FieldRef<Fake, i64>,
FieldRef<Fake, String>,
FieldRef<Fake, i32>,
FieldRef<Fake, bool>,
)>();
}
#[test]
fn queryset_group_by_returns_grouped_queryset() {
let qs: QuerySet<Fake> = QuerySet::new();
let f: FieldRef<Fake, i64> = FieldRef::new("org_id");
let _grouped: GroupedQuerySet<Fake, FieldRef<Fake, i64>> = qs.group_by(|_| f);
}
#[test]
fn group_by_then_annotate_returns_grouped_annotated_queryset() {
use crate::expr::AggregateExpr;
let qs: QuerySet<Fake> = QuerySet::new();
let keys: FieldRef<Fake, i64> = FieldRef::new("org_id");
let vals: FieldRef<Fake, i64> = FieldRef::new("amount");
let _gaq: GroupedAnnotatedQuerySet<Fake, FieldRef<Fake, i64>, AggregateExpr<i64>> =
qs.group_by(|_| keys).annotate(|_| vals.sum());
}
#[test]
fn grouped_annotate_accepts_ordered_set_kind_without_synthesized_over() {
use crate::query::sql::build_grouped_annotated_select;
let qs: QuerySet<Fake> = QuerySet::new();
let keys: FieldRef<Fake, i64> = FieldRef::new("org_id");
let vals: FieldRef<Fake, f64> = FieldRef::new("score");
let gaq = qs
.group_by(|_| keys)
.annotate(|_| vals.percentile_cont(0.5));
let acc = build_grouped_annotated_select(&gaq).expect("grouped select");
let sql = acc.sql();
assert!(
sql.contains("PERCENTILE_CONT") && sql.contains("WITHIN GROUP"),
"expected ordered-set aggregate in grouped annotate, got: {sql}"
);
assert!(
!sql.contains("OVER ()"),
"grouped annotate must not synthesize OVER () for ordered-set aggregates, got: {sql}"
);
}
#[test]
fn grouped_annotate_accepts_hypothetical_set_kind_without_synthesized_over() {
use crate::query::sql::build_grouped_annotated_select;
let qs: QuerySet<Fake> = QuerySet::new();
let keys: FieldRef<Fake, i64> = FieldRef::new("org_id");
let vals: FieldRef<Fake, i64> = FieldRef::new("salary");
let gaq = qs.group_by(|_| keys).annotate(|_| vals.rank_of(7_500));
let acc = build_grouped_annotated_select(&gaq).expect("grouped select");
let sql = acc.sql();
assert!(
sql.contains("RANK(") && sql.contains("WITHIN GROUP"),
"expected hypothetical-set aggregate in grouped annotate, got: {sql}"
);
assert!(
!sql.contains("OVER ()"),
"grouped annotate must not synthesize OVER () for hypothetical-set aggregates, got: {sql}"
);
}
#[test]
fn grouped_annotate_accepts_metadata_kind_without_synthesized_over() {
use crate::query::sql::build_grouped_annotated_select;
let qs: QuerySet<Fake> = QuerySet::new();
let region: FieldRef<Fake, i64> = FieldRef::new("region");
let gaq = qs.rollup(|_| region).annotate(|_| region.grouping());
let acc = build_grouped_annotated_select(&gaq).expect("grouped select");
let sql = acc.sql();
assert!(
sql.contains("GROUPING(region)"),
"expected metadata aggregate in grouped annotate, got: {sql}"
);
assert!(
!sql.contains("OVER ()"),
"grouped annotate must not synthesize OVER () for metadata aggregates, got: {sql}"
);
}
#[test]
fn having_clause_emits_after_group_by() {
use crate::query::sql::build_grouped_annotated_select;
let qs: QuerySet<Fake> = QuerySet::new();
let keys: FieldRef<Fake, i64> = FieldRef::new("org_id");
let vals: FieldRef<Fake, i64> = FieldRef::new("amount");
let gaq = qs
.group_by(|_| keys)
.annotate(|_| vals.sum())
.having(|k, _a| k.as_expr().gt(crate::expr::Expr::literal(0i64)));
let acc = build_grouped_annotated_select(&gaq).expect("grouped select");
let sql = acc.sql();
assert!(
sql.contains("GROUP BY") && sql.contains("HAVING"),
"got: {sql}"
);
let group_pos = sql.find("GROUP BY").unwrap();
let having_pos = sql.find("HAVING").unwrap();
assert!(
having_pos > group_pos,
"HAVING must appear after GROUP BY, got: {sql}"
);
}
#[test]
fn grouped_order_by_emits_after_group_by() {
use crate::query::sql::build_grouped_annotated_select;
let qs: QuerySet<Fake> = QuerySet::new();
let keys: FieldRef<Fake, i64> = FieldRef::new("org_id");
let vals: FieldRef<Fake, i64> = FieldRef::new("amount");
let gaq = qs
.group_by(|_| keys)
.annotate(|_| vals.sum())
.order_by(|k, _a| k.asc());
let acc = build_grouped_annotated_select(&gaq).expect("grouped select");
let sql = acc.sql();
assert!(
sql.contains("GROUP BY") && sql.contains("ORDER BY"),
"got: {sql}"
);
let group_pos = sql.find("GROUP BY").unwrap();
let order_pos = sql.find("ORDER BY").unwrap();
assert!(
order_pos > group_pos,
"ORDER BY must appear after GROUP BY, got: {sql}"
);
}
#[test]
fn grouped_limit_offset_emit_in_order() {
use crate::query::sql::build_grouped_annotated_select;
let qs: QuerySet<Fake> = QuerySet::new();
let keys: FieldRef<Fake, i64> = FieldRef::new("org_id");
let vals: FieldRef<Fake, i64> = FieldRef::new("amount");
let gaq = qs
.group_by(|_| keys)
.annotate(|_| vals.sum())
.limit(10)
.offset(20);
let acc = build_grouped_annotated_select(&gaq).expect("grouped select");
let sql = acc.sql();
assert!(sql.contains("LIMIT"), "expected LIMIT, got: {sql}");
assert!(sql.contains("OFFSET"), "expected OFFSET, got: {sql}");
let limit_pos = sql.find("LIMIT").unwrap();
let offset_pos = sql.find("OFFSET").unwrap();
assert!(
offset_pos > limit_pos,
"OFFSET must come after LIMIT, got: {sql}"
);
}
#[test]
fn having_on_arity_two_key_emits_having_clause() {
use crate::query::sql::build_grouped_annotated_select;
let qs: QuerySet<Fake> = QuerySet::new();
let k1: FieldRef<Fake, i64> = FieldRef::new("org_id");
let k2: FieldRef<Fake, i64> = FieldRef::new("region_id");
let vals: FieldRef<Fake, i64> = FieldRef::new("amount");
let gaq = qs
.group_by(|_| (k1, k2))
.annotate(|_| vals.sum())
.having(|(_k1, _k2), _a| {
crate::expr::Expr::literal(1i64).gt(crate::expr::Expr::literal(0i64))
});
let acc = build_grouped_annotated_select(&gaq).expect("grouped select");
let sql = acc.sql();
assert!(
sql.contains("GROUP BY") && sql.contains("HAVING"),
"expected GROUP BY + HAVING for arity-2 key, got: {sql}"
);
}
#[test]
fn queryset_rollup_returns_grouped_queryset_with_rollup_mode() {
use crate::query::sql::build_grouped_annotated_select;
let qs: QuerySet<Fake> = QuerySet::new();
let f: FieldRef<Fake, i64> = FieldRef::new("org_id");
let vals: FieldRef<Fake, i64> = FieldRef::new("amount");
let gaq = qs.rollup(|_| f).annotate(|_| vals.sum());
let acc = build_grouped_annotated_select(&gaq).expect("grouped select");
let sql = acc.sql();
assert!(
sql.contains("GROUP BY ROLLUP (org_id)"),
"expected ROLLUP clause via .rollup entry point, got: {sql}"
);
}
#[test]
fn queryset_cube_returns_grouped_queryset_with_cube_mode() {
use crate::query::sql::build_grouped_annotated_select;
let qs: QuerySet<Fake> = QuerySet::new();
let f: FieldRef<Fake, i64> = FieldRef::new("org_id");
let vals: FieldRef<Fake, i64> = FieldRef::new("amount");
let gaq = qs.cube(|_| f).annotate(|_| vals.sum());
let acc = build_grouped_annotated_select(&gaq).expect("grouped select");
let sql = acc.sql();
assert!(
sql.contains("GROUP BY CUBE (org_id)"),
"expected CUBE clause via .cube entry point, got: {sql}"
);
}
#[test]
fn queryset_group_by_sets_returns_grouped_queryset_unit_key() {
use crate::query::sql::build_grouped_annotated_select;
let qs: QuerySet<Fake> = QuerySet::new();
let vals: FieldRef<Fake, i64> = FieldRef::new("amount");
let gaq = qs
.group_by_sets(|_| ["org_id", "region"])
.annotate(|_| vals.sum());
let acc = build_grouped_annotated_select(&gaq).expect("grouped select");
let sql = acc.sql();
assert!(
sql.contains("GROUPING SETS ((org_id), (region))"),
"expected GROUPING SETS clause, got: {sql}"
);
}
#[test]
fn queryset_grouping_sets_supports_multi_column_sets() {
use crate::query::sql::build_grouped_annotated_select;
let qs: QuerySet<Fake> = QuerySet::new();
let vals: FieldRef<Fake, i64> = FieldRef::new("amount");
let gaq = qs
.grouping_sets(|_| vec![vec!["region", "dept"], vec!["region"], vec![]])
.annotate(|_| vals.sum());
let acc = build_grouped_annotated_select(&gaq).expect("grouped select");
let sql = acc.sql();
assert!(
sql.contains("GROUPING SETS ((region, dept), (region), ())"),
"expected multi-column + empty-set GROUPING SETS clause, got: {sql}"
);
}
#[test]
fn queryset_grouping_sets_extracts_columns_from_field_refs() {
use crate::query::sql::build_grouped_annotated_select;
let qs: QuerySet<Fake> = QuerySet::new();
let vals: FieldRef<Fake, i64> = FieldRef::new("amount");
let region: FieldRef<Fake, i64> = FieldRef::new("region");
let dept: FieldRef<Fake, i64> = FieldRef::new("dept");
let gaq = qs
.grouping_sets(|_| {
vec![
vec![region.column(), dept.column()],
vec![region.column()],
vec![],
]
})
.annotate(|_| vals.sum());
let acc = build_grouped_annotated_select(&gaq).expect("grouped select");
let sql = acc.sql();
assert!(
sql.contains("GROUPING SETS ((region, dept), (region), ())"),
"expected sets extracted via FieldRef::column(), got: {sql}"
);
}
#[test]
fn having_on_arity_three_key_emits_having_clause() {
use crate::query::sql::build_grouped_annotated_select;
let qs: QuerySet<Fake> = QuerySet::new();
let k1: FieldRef<Fake, i64> = FieldRef::new("org_id");
let k2: FieldRef<Fake, i64> = FieldRef::new("region_id");
let k3: FieldRef<Fake, i64> = FieldRef::new("product_id");
let vals: FieldRef<Fake, i64> = FieldRef::new("amount");
let gaq = qs
.group_by(|_| (k1, k2, k3))
.annotate(|_| vals.sum())
.having(|(_k1, _k2, _k3), _a| {
crate::expr::Expr::literal(1i64).gt(crate::expr::Expr::literal(0i64))
});
let acc = build_grouped_annotated_select(&gaq).expect("grouped select");
let sql = acc.sql();
assert!(
sql.contains("GROUP BY") && sql.contains("HAVING"),
"expected GROUP BY + HAVING for arity-3 key, got: {sql}"
);
}
}