Skip to content

Commit 8450183

Browse files
committed
fix(pgwire): honor requested binary result format per column
Bind's result-column format codes were accepted but never actually enforced against what the current pgwire feature set can encode, so a binary request for a feature-gated type (timestamp, json, arrays, ...) could produce a RowDescription/value mismatch. Resolve each column's neutral catalog type from its advertised pgwire Type, downgrade to text whenever binary encoding for that type isn't supported, and keep the portal's Describe-phase RowDescription in sync with what Execute will actually send. This also completes the output-schema typing this depended on: computed SELECT expressions, GROUP BY keys, and aggregate results now infer a real (or conservatively Text) catalog type instead of hardcoding Text, and ClusterArray plan dispatch is split into its own module so it can share the same shaping/format path.
1 parent cd4ca3c commit 8450183

19 files changed

Lines changed: 1193 additions & 162 deletions

File tree

nodedb/src/control/planner/sql_plan_convert/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pub mod filter;
1111
pub mod group_key_name;
1212
pub mod lateral;
1313
pub mod output_schema;
14+
pub mod output_schema_types;
1415
pub mod scan;
1516
pub mod scan_params;
1617
pub mod set_ops;

nodedb/src/control/planner/sql_plan_convert/output_schema.rs

Lines changed: 205 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
// SPDX-License-Identifier: BUSL-1.1
22

33
//! Derives the planner-authoritative [`OutputSchema`] from a compiled
4-
//! `SqlPlan` list, for later threading into response shaping.
4+
//! `SqlPlan` list, threaded into response shaping so the pgwire encoder can
5+
//! advertise correct RowDescription type OIDs.
56
//!
6-
//! Purely additive: nothing in this module is consumed by any existing
7-
//! call site yet. The single call site that invokes [`build_output_schema`]
8-
//! today discards the result.
7+
//! Bare columns carry their real catalog type; computed SELECT expressions,
8+
//! GROUP BY keys, and aggregate results are typed conservatively via
9+
//! [`output_schema_types`](super::output_schema_types). A wrong non-TEXT OID
10+
//! makes clients fail to parse the text value, so every uncertain case falls
11+
//! back to `DdlColType::Text`, the safe default.
912
1013
use std::collections::HashMap;
1114

@@ -14,6 +17,8 @@ use nodedb_sql::types::SqlPlan;
1417
use nodedb_sql::types::query::{AggOutputSlot, Projection};
1518
use nodedb_sql::types_expr::SqlExpr;
1619

20+
use super::lateral::collection_name_from_plan;
21+
use super::output_schema_types::{infer_aggregate_type, infer_computed_expr_type};
1722
use crate::control::server::response_shape::schema::{
1823
OutputColumn, OutputSchema, sql_data_type_to_ddl_col_type,
1924
};
@@ -69,7 +74,7 @@ fn projection_to_column(
6974
Some(OutputColumn {
7075
display_name: alias.clone(),
7176
lookup_key,
72-
ty: DdlColType::Text,
77+
ty: infer_computed_expr_type(expr, types),
7378
})
7479
}
7580
Projection::Star | Projection::QualifiedStar(_) => None,
@@ -104,28 +109,33 @@ fn column_types_for<C: SqlCatalog>(
104109
/// the aggregate executor emits the value under), so `project_row` still finds
105110
/// the value.
106111
///
107-
/// The output type is `DdlColType::Text`: the pgwire SELECT encoder is
108-
/// text-format only, so advertising a non-TEXT type OID without matching typed
109-
/// value encoding would break the extended-query protocol. (Typed aggregate /
110-
/// group-key reporting is deferred to a dedicated pgwire value-encoding unit.)
112+
/// The output type is the grouped column's catalog type when the key is a bare
113+
/// column (resolved from `types`, default `Text`); a computed-expression key is
114+
/// typed conservatively via [`infer_computed_expr_type`], defaulting to `Text`.
111115
///
112116
/// A non-`Column` GROUP BY key (a computed expression) derives its `lookup_key`
113117
/// from the shared index-based `computed_group_key_name` rule — the exact name
114118
/// the aggregate spec emits the evaluated value under, so the two can never
115119
/// diverge. Its `display_name` is the SELECT-list alias when present
116120
/// (`UPPER(label) AS u` shows column `u`), else the same placeholder.
117-
fn group_by_key_column(expr: &SqlExpr, index: usize, alias: Option<&str>) -> OutputColumn {
121+
fn group_by_key_column(
122+
expr: &SqlExpr,
123+
index: usize,
124+
alias: Option<&str>,
125+
types: &HashMap<String, DdlColType>,
126+
) -> OutputColumn {
118127
match expr {
119128
SqlExpr::Column { table, name } => {
120129
let lookup_key = match table {
121130
Some(t) => format!("{t}.{name}"),
122131
None => name.clone(),
123132
};
124133
let display_name = alias.map(str::to_string).unwrap_or_else(|| name.clone());
134+
let ty = types.get(name).copied().unwrap_or(DdlColType::Text);
125135
OutputColumn {
126136
display_name,
127137
lookup_key,
128-
ty: DdlColType::Text,
138+
ty,
129139
}
130140
}
131141
_ => {
@@ -140,7 +150,7 @@ fn group_by_key_column(expr: &SqlExpr, index: usize, alias: Option<&str>) -> Out
140150
OutputColumn {
141151
display_name,
142152
lookup_key,
143-
ty: DdlColType::Text,
153+
ty: infer_computed_expr_type(expr, types),
144154
}
145155
}
146156
}
@@ -302,35 +312,44 @@ pub fn build_output_schema<C: SqlCatalog>(
302312
is_star: false,
303313
},
304314
SqlPlan::Aggregate {
315+
input,
305316
group_by,
306317
group_by_aliases,
307318
output_order,
308319
aggregates,
309320
..
310321
} => {
322+
// Catalog types of the aggregate's underlying columns, resolved from
323+
// the input plan's single source collection when it has one (Scan /
324+
// point-get style). GROUP BY bare keys and MIN/MAX/SUM/AVG argument
325+
// columns are typed against this; anything unresolvable stays Text.
326+
let types = match collection_name_from_plan(input) {
327+
Some(collection) => column_types_for(catalog, database_id, &collection),
328+
None => HashMap::new(),
329+
};
311330
// Derives the `OutputColumn` for one GROUP BY key: `group_by_aliases`
312331
// is parallel to `group_by` when populated, but may be empty when the
313332
// plan was built without a projection in scope — treat a
314333
// missing/`None` entry as "no alias".
315334
let key_column = |index: usize| {
316335
group_by.get(index).map(|key| {
317336
let alias = group_by_aliases.get(index).and_then(|a| a.as_deref());
318-
group_by_key_column(key, index, alias)
337+
group_by_key_column(key, index, alias, &types)
319338
})
320339
};
321340
// Derives the `OutputColumn` for one aggregate. `AggregateExpr::alias`
322341
// is always populated by the planner: either the user's explicit
323342
// alias, or (for unnamed projections) the lowercased unparsed
324343
// expression text — e.g. `count(*)` — matching this module's own
325344
// lowercasing of non-column expressions. So the alias is already the
326-
// canonical name; no separate derivation needed. All aggregate result
327-
// values ship as TEXT (the pgwire SELECT encoder is text-format only;
328-
// typed OIDs are deferred to a dedicated value-encoding unit).
345+
// canonical name; no separate derivation needed. The result type is
346+
// inferred conservatively (COUNT -> Int8, MIN/MAX preserve the input
347+
// column type, SUM/AVG of a float -> Float8, else Text).
329348
let agg_column = |index: usize| {
330349
aggregates.get(index).map(|agg| OutputColumn {
331350
display_name: agg.alias.clone(),
332351
lookup_key: agg.alias.clone(),
333-
ty: DdlColType::Text,
352+
ty: infer_aggregate_type(agg, &types),
334353
})
335354
};
336355
let mut columns = Vec::with_capacity(group_by.len() + aggregates.len());
@@ -588,12 +607,13 @@ mod tests {
588607
assert_eq!(schema.columns[0].lookup_key, "status");
589608
assert_eq!(schema.columns[1].display_name, "total");
590609
assert_eq!(schema.columns[1].lookup_key, "total");
591-
// Aggregate result columns ship as TEXT (pgwire SELECT encoder is
592-
// text-format only; typed OIDs deferred to a value-encoding unit).
610+
// sum(x) stays TEXT here because this test has no catalog, so the
611+
// argument's numeric type is unresolvable and falls back to TEXT.
593612
assert_eq!(schema.columns[1].ty, DdlColType::Text);
594613
assert_eq!(schema.columns[2].display_name, "count(*)");
595614
assert_eq!(schema.columns[2].lookup_key, "count(*)");
596-
assert_eq!(schema.columns[2].ty, DdlColType::Text);
615+
// count(*) is always Postgres bigint (Int8), independent of catalog.
616+
assert_eq!(schema.columns[2].ty, DdlColType::Int8);
597617
assert!(!schema.is_star);
598618
}
599619

@@ -720,4 +740,169 @@ mod tests {
720740
let schema = build_output_schema(&plans, &NoCatalog, nodedb_types::DatabaseId::DEFAULT);
721741
assert_id_and_dist_schema(&schema);
722742
}
743+
744+
/// Catalog stub exposing a single `metrics` collection with a text
745+
/// `region`, integer `n`, and float `amount` column — used to exercise
746+
/// catalog-backed type resolution for GROUP BY keys, aggregate arguments,
747+
/// and bare-column computed projections.
748+
struct TypedCatalog;
749+
750+
impl SqlCatalog for TypedCatalog {
751+
fn get_collection(
752+
&self,
753+
_database_id: nodedb_types::DatabaseId,
754+
name: &str,
755+
) -> Result<Option<nodedb_sql::types::CollectionInfo>, nodedb_sql::catalog::SqlCatalogError>
756+
{
757+
use nodedb_sql::types::collection::ColumnInfo;
758+
use nodedb_sql::types::query::EngineType;
759+
use nodedb_sql::types_expr::SqlDataType;
760+
761+
if name != "metrics" {
762+
return Ok(None);
763+
}
764+
let col = |n: &str, t: SqlDataType| ColumnInfo {
765+
name: n.to_string(),
766+
data_type: t,
767+
nullable: true,
768+
is_primary_key: false,
769+
default: None,
770+
raw_type: None,
771+
};
772+
Ok(Some(nodedb_sql::types::CollectionInfo {
773+
name: "metrics".to_string(),
774+
engine: EngineType::DocumentStrict,
775+
columns: vec![
776+
col("region", SqlDataType::String),
777+
col("n", SqlDataType::Int64),
778+
col("amount", SqlDataType::Float64),
779+
],
780+
primary_key: None,
781+
has_auto_tier: false,
782+
indexes: Vec::new(),
783+
bitemporal: false,
784+
primary: nodedb_types::PrimaryEngine::Document,
785+
vector_primary: None,
786+
partition_strategy: nodedb_types::PartitionStrategy::CollectionHomed,
787+
}))
788+
}
789+
}
790+
791+
fn agg_expr(
792+
function: &str,
793+
args: Vec<SqlExpr>,
794+
alias: &str,
795+
) -> nodedb_sql::types::query::AggregateExpr {
796+
nodedb_sql::types::query::AggregateExpr {
797+
function: function.to_string(),
798+
args,
799+
alias: alias.to_string(),
800+
distinct: false,
801+
grouping_col_index: None,
802+
}
803+
}
804+
805+
fn metrics_column(name: &str) -> SqlExpr {
806+
SqlExpr::Column {
807+
table: None,
808+
name: name.to_string(),
809+
}
810+
}
811+
812+
/// GROUP BY a bare text column plus MIN/SUM/COUNT aggregates resolve to
813+
/// their real catalog-derived types, while SUM over an integer column and
814+
/// a computed GROUP BY key stay Text.
815+
#[test]
816+
fn aggregate_types_resolve_against_catalog() {
817+
let plans = vec![SqlPlan::Aggregate {
818+
input: Box::new(scan_plan("metrics", vec![])),
819+
group_by: vec![metrics_column("region")],
820+
group_by_aliases: vec![None],
821+
output_order: Vec::new(),
822+
aggregates: vec![
823+
agg_expr("count", vec![SqlExpr::Wildcard], "count(*)"),
824+
agg_expr("min", vec![metrics_column("n")], "min_n"),
825+
agg_expr("sum", vec![metrics_column("amount")], "sum_amount"),
826+
agg_expr("sum", vec![metrics_column("n")], "sum_n"),
827+
],
828+
having: Vec::new(),
829+
limit: 0,
830+
grouping_sets: None,
831+
sort_keys: Vec::new(),
832+
}];
833+
let schema = build_output_schema(&plans, &TypedCatalog, nodedb_types::DatabaseId::DEFAULT);
834+
// group-keys-first fallback (empty output_order): region, then aggs.
835+
assert_eq!(schema.columns.len(), 5);
836+
// GROUP BY text column -> the text column's catalog type.
837+
assert_eq!(schema.columns[0].display_name, "region");
838+
assert_eq!(schema.columns[0].ty, DdlColType::Text);
839+
// COUNT(*) -> Int8 (Postgres bigint).
840+
assert_eq!(schema.columns[1].display_name, "count(*)");
841+
assert_eq!(schema.columns[1].ty, DdlColType::Int8);
842+
// MIN(int_col) preserves the integer input type.
843+
assert_eq!(schema.columns[2].display_name, "min_n");
844+
assert_eq!(schema.columns[2].ty, DdlColType::Int8);
845+
// SUM(float_col) -> Float8.
846+
assert_eq!(schema.columns[3].display_name, "sum_amount");
847+
assert_eq!(schema.columns[3].ty, DdlColType::Float8);
848+
// SUM(int_col) stays Text (numeric promotion, no regression).
849+
assert_eq!(schema.columns[4].display_name, "sum_n");
850+
assert_eq!(schema.columns[4].ty, DdlColType::Text);
851+
}
852+
853+
/// A computed GROUP BY key (`UPPER(region)`) is not a bare column, so it
854+
/// defaults to Text.
855+
#[test]
856+
fn computed_group_by_key_is_text() {
857+
let upper = SqlExpr::Function {
858+
name: "upper".to_string(),
859+
args: vec![metrics_column("region")],
860+
distinct: false,
861+
};
862+
let plans = vec![SqlPlan::Aggregate {
863+
input: Box::new(scan_plan("metrics", vec![])),
864+
group_by: vec![upper],
865+
group_by_aliases: vec![Some("u".to_string())],
866+
output_order: Vec::new(),
867+
aggregates: Vec::new(),
868+
having: Vec::new(),
869+
limit: 0,
870+
grouping_sets: None,
871+
sort_keys: Vec::new(),
872+
}];
873+
let schema = build_output_schema(&plans, &TypedCatalog, nodedb_types::DatabaseId::DEFAULT);
874+
assert_eq!(schema.columns.len(), 1);
875+
assert_eq!(schema.columns[0].display_name, "u");
876+
assert_eq!(schema.columns[0].ty, DdlColType::Text);
877+
}
878+
879+
/// A computed SELECT expression that is really a bare column reference
880+
/// carries that column's catalog type; a boolean comparison is `Bool`.
881+
#[test]
882+
fn computed_projection_types_resolve_against_catalog() {
883+
let projection = vec![
884+
Projection::Computed {
885+
expr: metrics_column("n"),
886+
alias: "aliased_n".to_string(),
887+
},
888+
Projection::Computed {
889+
expr: SqlExpr::BinaryOp {
890+
left: Box::new(metrics_column("n")),
891+
op: nodedb_sql::types_expr::BinaryOp::Gt,
892+
right: Box::new(SqlExpr::Literal(nodedb_sql::types_expr::SqlValue::Int(0))),
893+
},
894+
alias: "positive".to_string(),
895+
},
896+
];
897+
let plans = vec![scan_plan("metrics", projection)];
898+
let schema = build_output_schema(&plans, &TypedCatalog, nodedb_types::DatabaseId::DEFAULT);
899+
assert_eq!(schema.columns.len(), 2);
900+
// Bare-column-passthrough computed expr -> the column's catalog type.
901+
assert_eq!(schema.columns[0].display_name, "aliased_n");
902+
assert_eq!(schema.columns[0].lookup_key, "n");
903+
assert_eq!(schema.columns[0].ty, DdlColType::Int8);
904+
// Boolean comparison expression -> Bool.
905+
assert_eq!(schema.columns[1].display_name, "positive");
906+
assert_eq!(schema.columns[1].ty, DdlColType::Bool);
907+
}
723908
}

0 commit comments

Comments
 (0)