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
1013use std:: collections:: HashMap ;
1114
@@ -14,6 +17,8 @@ use nodedb_sql::types::SqlPlan;
1417use nodedb_sql:: types:: query:: { AggOutputSlot , Projection } ;
1518use 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} ;
1722use 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