#![cfg(feature = "spatial")]
#![allow(clippy::manual_async_fn)]
use crate::DjogiError;
use crate::context::DjogiContext;
use crate::expr::row_aggregate::{
BinaryRowAgg, MvtOptions, RowAggregate, build_geobuf_aggregate, build_mvt_aggregate,
};
use crate::model::Model;
use crate::pg::accumulator::{SqlAccumulator, as_params};
use crate::pg::decode::FromPgRow;
use crate::query::annotate::{AnnotatedQuerySet, IntoAggregateTuple};
use crate::query::queryset::QuerySet;
use crate::query::sql::build_spatial_row_select_with_annotations_for_fetch;
use std::future::Future;
#[must_use = "row-shape aggregate terminals are lazy — dropping discards the query"]
pub struct AsMvtTerminal<T: Model, A: IntoAggregateTuple> {
pub(crate) qs: AnnotatedQuerySet<T, A>,
pub(crate) agg: RowAggregate<Vec<u8>, BinaryRowAgg>,
}
#[must_use = "row-shape aggregate terminals are lazy — dropping discards the query"]
pub struct AsGeobufTerminal<T: Model, A: IntoAggregateTuple> {
pub(crate) qs: AnnotatedQuerySet<T, A>,
pub(crate) agg: RowAggregate<Vec<u8>, BinaryRowAgg>,
}
fn build_row_aggregate_select<T, A>(
aqs: &AnnotatedQuerySet<T, A>,
agg: &RowAggregate<Vec<u8>, BinaryRowAgg>,
) -> Result<SqlAccumulator, crate::query::portable::PortablePredicateError>
where
T: Model + FromPgRow,
A: IntoAggregateTuple + crate::query::annotate::PlainAnnotationTuple,
{
let inner = build_spatial_row_select_with_annotations_for_fetch(
&aqs.qs,
|acc| {
aqs.aggregates.push_plain_columns(acc);
},
aqs.qualify.as_ref(),
)?;
let mut outer = SqlAccumulator::new("SELECT ");
crate::expr::sql::emit_expr(
&mut outer,
&agg.node,
crate::query::portable::SqlEmitContext::root(),
)?;
outer.push_sql(" FROM (");
outer.extend_with(inner);
outer.push_sql(") AS __djogi_row");
Ok(outer)
}
impl<T: Model, A: IntoAggregateTuple + Send> AsMvtTerminal<T, A>
where
T: FromPgRow + Send + Unpin,
A: crate::query::annotate::PlainAnnotationTuple,
{
pub fn fetch_one<'ctx>(
self,
ctx: &'ctx mut DjogiContext,
) -> impl Future<Output = Result<Vec<u8>, DjogiError>> + Send + 'ctx
where
T: 'ctx,
A: 'ctx,
{
async move {
let AsMvtTerminal { qs, agg } = self;
if qs.qs.is_empty() {
return Ok(Vec::new());
}
qs.aggregates.check_legality()?;
qs.aggregates.check_no_column_collision(T::COLUMNS)?;
if qs.aggregates.requires_pair_tuple_scope()
|| qs.aggregates.requires_closure_pair_join()
{
return Err(DjogiError::Validation(
"row-shape aggregate terminals cannot host pair-tuple aggregates in the \
annotation tuple (for example `PairAreaOverlapRatio` / `PairClosureKinshipSum`). \
These aggregates reference pair-tuple aliases (`l.`, `r.`, `la.`, `ra.`) that are \
only in scope on joined-pair query surfaces. Use a paired-query annotation \
surface (e.g. `QuerySet::self_pairs()` / `cross_join_with(...)` plus \
`.annotate(...)`) and a joined terminal that supports that joined aliasing model."
.to_string(),
));
}
crate::query::terminal::auto_set_tenant::<T>(ctx).await?;
let acc = build_row_aggregate_select(&qs, &agg).map_err(DjogiError::from)?;
let (sql, binds) = acc.into_parts();
let params = as_params(&binds);
let row = ctx.query_one(&sql, ¶ms).await?;
let bytes: Option<Vec<u8>> = row.try_get(0).map_err(DjogiError::from)?;
Ok(bytes.unwrap_or_default())
}
}
}
impl<T: Model, A: IntoAggregateTuple + Send> AsGeobufTerminal<T, A>
where
T: FromPgRow + Send + Unpin,
A: crate::query::annotate::PlainAnnotationTuple,
{
pub fn fetch_one<'ctx>(
self,
ctx: &'ctx mut DjogiContext,
) -> impl Future<Output = Result<Vec<u8>, DjogiError>> + Send + 'ctx
where
T: 'ctx,
A: 'ctx,
{
async move {
let AsGeobufTerminal { qs, agg } = self;
if qs.qs.is_empty() {
return Ok(Vec::new());
}
qs.aggregates.check_legality()?;
qs.aggregates.check_no_column_collision(T::COLUMNS)?;
if qs.aggregates.requires_pair_tuple_scope()
|| qs.aggregates.requires_closure_pair_join()
{
return Err(DjogiError::Validation(
"row-shape aggregate terminals cannot host pair-tuple aggregates in the \
annotation tuple (for example `PairAreaOverlapRatio` / `PairClosureKinshipSum`). \
These aggregates reference pair-tuple aliases (`l.`, `r.`, `la.`, `ra.`) that are \
only in scope on joined-pair query surfaces. Use a paired-query annotation \
surface (e.g. `QuerySet::self_pairs()` / `cross_join_with(...)` plus \
`.annotate(...)`) and a joined terminal that supports that joined aliasing model."
.to_string(),
));
}
crate::query::terminal::auto_set_tenant::<T>(ctx).await?;
let acc = build_row_aggregate_select(&qs, &agg).map_err(DjogiError::from)?;
let (sql, binds) = acc.into_parts();
let params = as_params(&binds);
let row = ctx.query_one(&sql, ¶ms).await?;
let bytes: Option<Vec<u8>> = row.try_get(0).map_err(DjogiError::from)?;
Ok(bytes.unwrap_or_default())
}
}
}
impl<T: Model + FromPgRow, A: IntoAggregateTuple> AnnotatedQuerySet<T, A> {
#[must_use = "row-shape aggregate terminals are lazy — dropping discards the query"]
pub fn as_mvt(self, layer_name: impl Into<String>) -> AsMvtTerminal<T, A> {
self.as_mvt_with_options(MvtOptions::new(layer_name))
}
#[must_use = "row-shape aggregate terminals are lazy — dropping discards the query"]
pub fn as_mvt_with_options(self, opts: MvtOptions) -> AsMvtTerminal<T, A> {
let columns = inner_columns_for::<T>();
let agg = build_mvt_aggregate(opts, columns);
AsMvtTerminal { qs: self, agg }
}
#[must_use = "row-shape aggregate terminals are lazy — dropping discards the query"]
pub fn as_geobuf(self, geom_name: impl Into<String>) -> AsGeobufTerminal<T, A> {
let columns = inner_columns_for::<T>();
let agg = build_geobuf_aggregate(geom_name, columns);
AsGeobufTerminal { qs: self, agg }
}
}
impl<T: Model + FromPgRow> QuerySet<T> {
#[must_use = "row-shape aggregate terminals are lazy — dropping discards the query"]
pub fn as_mvt(self, layer_name: impl Into<String>) -> AsMvtTerminal<T, EmptyAnnotation> {
self.as_mvt_with_options(MvtOptions::new(layer_name))
}
#[must_use = "row-shape aggregate terminals are lazy — dropping discards the query"]
pub fn as_mvt_with_options(self, opts: MvtOptions) -> AsMvtTerminal<T, EmptyAnnotation> {
let columns = inner_columns_for::<T>();
let agg = build_mvt_aggregate(opts, columns);
AsMvtTerminal {
qs: AnnotatedQuerySet {
qs: self,
aggregates: EmptyAnnotation,
qualify: None,
_a: std::marker::PhantomData,
},
agg,
}
}
#[must_use = "row-shape aggregate terminals are lazy — dropping discards the query"]
pub fn as_geobuf(self, geom_name: impl Into<String>) -> AsGeobufTerminal<T, EmptyAnnotation> {
let columns = inner_columns_for::<T>();
let agg = build_geobuf_aggregate(geom_name, columns);
AsGeobufTerminal {
qs: AnnotatedQuerySet {
qs: self,
aggregates: EmptyAnnotation,
qualify: None,
_a: std::marker::PhantomData,
},
agg,
}
}
}
#[doc(hidden)]
#[derive(Debug, Clone)]
pub struct EmptyAnnotation;
impl crate::query::annotate::sealed::Sealed for EmptyAnnotation {}
impl IntoAggregateTuple for EmptyAnnotation {
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
}
fn check_legality(&self) -> Result<(), crate::DjogiError> {
Ok(())
}
}
impl crate::query::annotate::PlainAnnotationTuple for EmptyAnnotation {
fn push_plain_columns(&self, _acc: &mut SqlAccumulator) {
}
}
fn inner_columns_for<T: FromPgRow>() -> Vec<&'static str> {
T::COLUMNS.to_vec()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::descriptor::{
FieldSqlType, GeographySubtype, ModelDescriptor, PkType, field_descriptor, model_descriptor,
};
use crate::expr::RowNumber;
use crate::testing;
struct TileFeature {
id: i64,
}
impl crate::model::__sealed::Sealed for TileFeature {}
#[allow(clippy::manual_async_fn)]
impl Model for TileFeature {
type Pk = i64;
type Fields = ();
fn table_name() -> &'static str {
"tile_features"
}
fn pk_value(&self) -> &i64 {
&self.id
}
fn descriptor() -> &'static ModelDescriptor {
static FIELDS: &[crate::descriptor::FieldDescriptor] = &[
field_descriptor("id", FieldSqlType::BigInt, false),
field_descriptor(
"geom",
FieldSqlType::Geography {
subtype: GeographySubtype::Point,
srid: 4326,
},
false,
),
field_descriptor("name", FieldSqlType::Text, false),
];
static DESCRIPTOR: ModelDescriptor =
model_descriptor("TileFeature", "tile_features", PkType::Serial, FIELDS);
&DESCRIPTOR
}
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 TileFeature {
const COLUMNS: &'static [&'static str] = &["id", "geom", "name"];
const COLUMN_LIST: &'static str = "id, geom, name";
fn from_pg_row(_row: &tokio_postgres::Row) -> Result<Self, crate::DjogiError> {
unreachable!("SQL-text unit tests do not exercise row decode")
}
}
fn empty_aqs() -> AnnotatedQuerySet<TileFeature, EmptyAnnotation> {
AnnotatedQuerySet {
qs: QuerySet::new(),
aggregates: EmptyAnnotation,
qualify: None,
_a: std::marker::PhantomData,
}
}
#[test]
fn as_mvt_default_options_emit_expected_sql() {
let terminal = empty_aqs().as_mvt("test_layer");
let acc = build_row_aggregate_select(&terminal.qs, &terminal.agg)
.expect("SQL build must succeed");
let (sql, _binds) = acc.into_parts();
assert!(
sql.starts_with("SELECT ST_AsMVT(__djogi_row, $1, $2, $3) FROM ("),
"unexpected outer SELECT shape: {sql}"
);
assert!(
sql.contains("SELECT t.id, t.geom::geometry AS geom, t.name FROM tile_features AS t"),
"unexpected inner SELECT shape: {sql}"
);
assert!(sql.ends_with(") AS __djogi_row"), "missing alias: {sql}");
assert!(
!sql.contains("ST_AsMVT(__djogi_row, $1, $2, $3, $4)"),
"feature_id_name was unexpectedly emitted: {sql}"
);
}
#[test]
fn as_mvt_with_feature_id_emits_fifth_argument() {
let terminal = empty_aqs().as_mvt_with_options(
MvtOptions::new("layer")
.with_extent(8192)
.with_geom_name("the_geom")
.with_feature_id_name("feature_pk"),
);
let acc = build_row_aggregate_select(&terminal.qs, &terminal.agg)
.expect("SQL build must succeed");
let (sql, binds) = acc.into_parts();
assert!(
sql.contains("ST_AsMVT(__djogi_row, $1, $2, $3, $4)"),
"expected feature_id_name argument: {sql}"
);
assert_eq!(
binds.len(),
4,
"expected 4 bind params for full-options MVT, got {}: {sql}",
binds.len()
);
}
#[test]
fn as_geobuf_emits_expected_sql() {
let terminal = empty_aqs().as_geobuf("the_geom");
let acc = build_row_aggregate_select(&terminal.qs, &terminal.agg)
.expect("SQL build must succeed");
let (sql, binds) = acc.into_parts();
assert!(
sql.starts_with("SELECT ST_AsGeobuf(__djogi_row, $1) FROM ("),
"unexpected outer SELECT shape: {sql}"
);
assert!(sql.ends_with(") AS __djogi_row"), "missing alias: {sql}");
assert_eq!(
binds.len(),
1,
"expected 1 bind param for Geobuf, got {}: {sql}",
binds.len()
);
}
#[test]
fn queryset_as_mvt_synthesises_empty_annotation() {
let qs_terminal: AsMvtTerminal<TileFeature, EmptyAnnotation> =
QuerySet::<TileFeature>::new().as_mvt("layer");
let aqs_terminal: AsMvtTerminal<TileFeature, EmptyAnnotation> = empty_aqs().as_mvt("layer");
let qs_sql = build_row_aggregate_select(&qs_terminal.qs, &qs_terminal.agg)
.expect("queryset terminal builds SQL")
.into_parts()
.0;
let aqs_sql = build_row_aggregate_select(&aqs_terminal.qs, &aqs_terminal.agg)
.expect("annotated terminal builds SQL")
.into_parts()
.0;
assert_eq!(
qs_sql, aqs_sql,
"QuerySet::as_mvt and AnnotatedQuerySet::as_mvt over empty annotation must produce identical SQL"
);
}
#[tokio::test]
async fn as_mvt_short_circuits_on_none_queryset() {
if std::env::var("DATABASE_URL").is_err() {
return;
}
let (_cleanup, mut ctx) = testing::setup_test_db_with_extensions(&["postgis"])
.await
.expect("DATABASE_URL must be set for row-aggregate none short-circuit tests");
let bytes = QuerySet::<TileFeature>::new()
.none()
.as_mvt("layer")
.fetch_one(&mut ctx)
.await
.expect("none queryset should short-circuit before emitting SQL");
assert!(
bytes.is_empty(),
"none queryset should return an empty payload without DB round-trip"
);
drop(_cleanup);
}
#[tokio::test]
async fn as_geobuf_short_circuits_on_none_queryset() {
if std::env::var("DATABASE_URL").is_err() {
return;
}
let (_cleanup, mut ctx) = testing::setup_test_db_with_extensions(&["postgis"])
.await
.expect("DATABASE_URL must be set for row-aggregate none short-circuit tests");
let bytes = QuerySet::<TileFeature>::new()
.none()
.as_geobuf("location")
.fetch_one(&mut ctx)
.await
.expect("none queryset should short-circuit before emitting SQL");
assert!(
bytes.is_empty(),
"none queryset should return an empty payload without DB round-trip"
);
drop(_cleanup);
}
#[test]
fn as_mvt_terminal_alias_colliding_with_tile_feature_column_returns_validation_error() {
let rn = RowNumber::new().alias("id");
let result = IntoAggregateTuple::check_no_column_collision(&rn, TileFeature::COLUMNS);
assert!(
matches!(result, Err(crate::DjogiError::Validation(_))),
"MVT terminal: alias colliding with TileFeature::COLUMNS 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 as_geobuf_terminal_alias_colliding_with_tile_feature_column_returns_validation_error() {
let rn = RowNumber::new().alias("geom");
let result = IntoAggregateTuple::check_no_column_collision(&rn, TileFeature::COLUMNS);
assert!(
matches!(result, Err(crate::DjogiError::Validation(_))),
"Geobuf terminal: alias colliding with TileFeature::COLUMNS must yield \
DjogiError::Validation, got: {result:?}"
);
if let Err(crate::DjogiError::Validation(msg)) = result {
assert!(
msg.contains("geom"),
"error message must name the conflicting alias, got: {msg}"
);
}
}
#[test]
fn as_mvt_terminal_non_colliding_alias_passes_collision_check() {
let rn = RowNumber::new().alias("rank");
assert!(
IntoAggregateTuple::check_no_column_collision(&rn, TileFeature::COLUMNS).is_ok(),
"MVT terminal: alias not matching any TileFeature column must pass the collision check"
);
}
#[test]
fn as_mvt_terminal_uppercase_alias_collides_with_tile_feature_column() {
let rn = RowNumber::new().alias("ID");
let result = IntoAggregateTuple::check_no_column_collision(&rn, TileFeature::COLUMNS);
assert!(
matches!(result, Err(crate::DjogiError::Validation(_))),
"MVT terminal: uppercase alias 'ID' must collide with column 'id' via case-fold \
comparator, got: {result:?}"
);
}
#[test]
fn as_geobuf_terminal_uppercase_alias_collides_with_tile_feature_column() {
let rn = RowNumber::new().alias("GEOM");
let result = IntoAggregateTuple::check_no_column_collision(&rn, TileFeature::COLUMNS);
assert!(
matches!(result, Err(crate::DjogiError::Validation(_))),
"Geobuf terminal: uppercase alias 'GEOM' must collide with column 'geom' via \
case-fold comparator, got: {result:?}"
);
}
#[tokio::test]
async fn as_mvt_terminal_fetch_one_rejects_colliding_alias() {
if std::env::var("DATABASE_URL").is_err() {
return;
}
let (_cleanup, mut ctx) = testing::setup_test_db()
.await
.expect("DATABASE_URL must be set for row-aggregate terminal collision tests");
let result = QuerySet::<TileFeature>::new()
.annotate(|_| RowNumber::new().alias("id"))
.as_mvt("layer")
.fetch_one(&mut ctx)
.await;
assert!(
matches!(result, Err(crate::DjogiError::Validation(_))),
"AsMvtTerminal::fetch_one must reject alias colliding with TileFeature column, \
got: {result:?}"
);
if let Err(crate::DjogiError::Validation(msg)) = result {
assert!(
msg.contains("id"),
"error message must name the conflicting alias, got: {msg}"
);
}
drop(_cleanup);
}
#[tokio::test]
async fn as_geobuf_terminal_fetch_one_rejects_colliding_alias() {
if std::env::var("DATABASE_URL").is_err() {
return;
}
let (_cleanup, mut ctx) = testing::setup_test_db()
.await
.expect("DATABASE_URL must be set for row-aggregate terminal collision tests");
let result = QuerySet::<TileFeature>::new()
.annotate(|_| RowNumber::new().alias("geom"))
.as_geobuf("geom")
.fetch_one(&mut ctx)
.await;
assert!(
matches!(result, Err(crate::DjogiError::Validation(_))),
"AsGeobufTerminal::fetch_one must reject alias colliding with TileFeature column, \
got: {result:?}"
);
if let Err(crate::DjogiError::Validation(msg)) = result {
assert!(
msg.contains("geom"),
"error message must name the conflicting alias, got: {msg}"
);
}
drop(_cleanup);
}
#[tokio::test]
async fn as_mvt_terminal_fetch_one_rejects_uppercase_case_fold_alias() {
if std::env::var("DATABASE_URL").is_err() {
return;
}
let (_cleanup, mut ctx) = testing::setup_test_db().await.expect(
"DATABASE_URL must be set for row-aggregate terminal case-fold collision tests",
);
let result = QuerySet::<TileFeature>::new()
.annotate(|_| RowNumber::new().alias("ID"))
.as_mvt("layer")
.fetch_one(&mut ctx)
.await;
assert!(
matches!(result, Err(crate::DjogiError::Validation(_))),
"AsMvtTerminal::fetch_one must reject uppercase alias 'ID' that case-folds to \
column 'id', got: {result:?}"
);
}
#[tokio::test]
async fn as_geobuf_terminal_fetch_one_rejects_uppercase_case_fold_alias() {
if std::env::var("DATABASE_URL").is_err() {
return;
}
let (_cleanup, mut ctx) = testing::setup_test_db().await.expect(
"DATABASE_URL must be set for row-aggregate terminal case-fold collision tests",
);
let result = QuerySet::<TileFeature>::new()
.annotate(|_| RowNumber::new().alias("GEOM"))
.as_geobuf("geom")
.fetch_one(&mut ctx)
.await;
assert!(
matches!(result, Err(crate::DjogiError::Validation(_))),
"AsGeobufTerminal::fetch_one must reject uppercase alias 'GEOM' that case-folds to \
column 'geom', got: {result:?}"
);
}
}