22
33use std:: collections:: HashMap ;
44
5- use super :: types:: POSITION_VALUES ;
5+ use super :: types:: { wrap_with_dummy_axis , POSITION_VALUES } ;
66use super :: { DefaultAesthetics , GeomTrait , GeomType } ;
77use crate :: {
88 naming,
@@ -26,7 +26,10 @@ impl GeomTrait for Boxplot {
2626 fn aesthetics ( & self ) -> DefaultAesthetics {
2727 DefaultAesthetics {
2828 defaults : & [
29- ( "pos1" , DefaultAestheticValue :: Required ) ,
29+ // pos1 is optional - if omitted, stat_boxplot synthesises a
30+ // dummy categorical axis so the geom renders a single boxplot
31+ // of the whole pos2 distribution.
32+ ( "pos1" , DefaultAestheticValue :: Null ) ,
3033 ( "pos2" , DefaultAestheticValue :: Required ) ,
3134 ( "stroke" , DefaultAestheticValue :: String ( "black" ) ) ,
3235 ( "fill" , DefaultAestheticValue :: String ( "white" ) ) ,
@@ -46,10 +49,6 @@ impl GeomTrait for Boxplot {
4649 & [ "pos2" ]
4750 }
4851
49- fn needs_stat_transform ( & self , _aesthetics : & Mappings ) -> bool {
50- true
51- }
52-
5352 fn default_params ( & self ) -> & ' static [ super :: ParamDefinition ] {
5453 const PARAMS : & [ ParamDefinition ] = & [
5554 ParamDefinition {
@@ -79,6 +78,7 @@ impl GeomTrait for Boxplot {
7978 fn default_remappings ( & self ) -> DefaultAesthetics {
8079 DefaultAesthetics {
8180 defaults : & [
81+ ( "pos1" , DefaultAestheticValue :: Column ( "pos1" ) ) ,
8282 ( "pos2" , DefaultAestheticValue :: Column ( "value" ) ) ,
8383 ( "pos2end" , DefaultAestheticValue :: Column ( "value2" ) ) ,
8484 ( "type" , DefaultAestheticValue :: Column ( "type" ) ) ,
@@ -117,9 +117,17 @@ fn stat_boxplot(
117117 let y = get_column_name ( aesthetics, "pos2" ) . ok_or_else ( || {
118118 GgsqlError :: ValidationError ( "Boxplot requires 'y' aesthetic mapping" . to_string ( ) )
119119 } ) ?;
120- let x = get_column_name ( aesthetics, "pos1" ) . ok_or_else ( || {
121- GgsqlError :: ValidationError ( "Boxplot requires 'x' aesthetic mapping" . to_string ( ) )
122- } ) ?;
120+
121+ // pos1 is optional. When the user omits it, wrap the input query with a
122+ // synthetic dummy categorical column and group by that column, so the
123+ // existing GROUP BY / summary pipeline collapses to a single boxplot.
124+ let ( working_query, x, use_dummy) = match get_column_name ( aesthetics, "pos1" ) {
125+ Some ( col) => ( query. to_string ( ) , col, false ) ,
126+ None => {
127+ let dummy_col = naming:: stat_column ( "pos1" ) ;
128+ ( wrap_with_dummy_axis ( query, "pos1" ) , dummy_col, true )
129+ }
130+ } ;
123131
124132 // Get coef parameter (validated by ParamConstraint::number_min)
125133 let ParameterValue :: Number ( coef) = parameters. get ( "coef" ) . unwrap ( ) else {
@@ -148,17 +156,26 @@ fn stat_boxplot(
148156 }
149157
150158 // Query for boxplot summary statistics
151- let summary = boxplot_sql_compute_summary ( query, & groups, & value_col, coef, dialect) ;
152- let stats_query = boxplot_sql_append_outliers ( & summary, & groups, & value_col, query, outliers) ;
159+ let summary =
160+ boxplot_sql_compute_summary ( & working_query, & groups, & value_col, coef, dialect) ;
161+ let stats_query =
162+ boxplot_sql_append_outliers ( & summary, & groups, & value_col, & working_query, outliers) ;
163+
164+ let mut stat_columns = vec ! [
165+ "type" . to_string( ) ,
166+ "value" . to_string( ) ,
167+ "value2" . to_string( ) ,
168+ ] ;
169+ let mut dummy_columns: Vec < String > = vec ! [ ] ;
170+ if use_dummy {
171+ stat_columns. push ( "pos1" . to_string ( ) ) ;
172+ dummy_columns. push ( "pos1" . to_string ( ) ) ;
173+ }
153174
154175 Ok ( StatResult :: Transformed {
155176 query : stats_query,
156- stat_columns : vec ! [
157- "type" . to_string( ) ,
158- "value" . to_string( ) ,
159- "value2" . to_string( ) ,
160- ] ,
161- dummy_columns : vec ! [ ] ,
177+ stat_columns,
178+ dummy_columns,
162179 consumed_aesthetics : vec ! [ "pos2" . to_string( ) ] ,
163180 } )
164181}
@@ -517,9 +534,10 @@ mod tests {
517534 let boxplot = Boxplot ;
518535 let aes = boxplot. aesthetics ( ) ;
519536
520- assert ! ( aes. is_required( "pos1" ) ) ;
537+ // pos1 is optional (omit → dummy categorical axis); pos2 is required.
538+ assert ! ( !aes. is_required( "pos1" ) ) ;
521539 assert ! ( aes. is_required( "pos2" ) ) ;
522- assert_eq ! ( aes. required( ) . len ( ) , 2 ) ;
540+ assert_eq ! ( aes. required( ) , vec! [ "pos2" ] ) ;
523541 }
524542
525543 #[ test]
@@ -575,7 +593,10 @@ mod tests {
575593 let boxplot = Boxplot ;
576594 let remappings = boxplot. default_remappings ( ) ;
577595
578- assert_eq ! ( remappings. defaults. len( ) , 3 ) ;
596+ assert_eq ! ( remappings. defaults. len( ) , 4 ) ;
597+ assert ! ( remappings
598+ . defaults
599+ . contains( & ( "pos1" , DefaultAestheticValue :: Column ( "pos1" ) ) ) ) ;
579600 assert ! ( remappings
580601 . defaults
581602 . contains( & ( "pos2" , DefaultAestheticValue :: Column ( "value" ) ) ) ) ;
@@ -587,6 +608,48 @@ mod tests {
587608 . contains( & ( "type" , DefaultAestheticValue :: Column ( "type" ) ) ) ) ;
588609 }
589610
611+ #[ test]
612+ fn test_boxplot_dummy_pos1_when_unmapped ( ) {
613+ use crate :: plot:: AestheticValue ;
614+ let mut aesthetics = Mappings :: new ( ) ;
615+ aesthetics. insert (
616+ "pos2" . to_string ( ) ,
617+ AestheticValue :: standard_column ( "value" . to_string ( ) ) ,
618+ ) ;
619+ let mut parameters: HashMap < String , ParameterValue > = HashMap :: new ( ) ;
620+ parameters. insert ( "coef" . to_string ( ) , ParameterValue :: Number ( 1.5 ) ) ;
621+ parameters. insert ( "outliers" . to_string ( ) , ParameterValue :: Boolean ( true ) ) ;
622+
623+ let result = stat_boxplot (
624+ "SELECT * FROM data" ,
625+ & aesthetics,
626+ & [ ] ,
627+ & parameters,
628+ & AnsiDialect ,
629+ )
630+ . expect ( "stat_boxplot should succeed without pos1" ) ;
631+
632+ match result {
633+ StatResult :: Transformed {
634+ query,
635+ stat_columns,
636+ dummy_columns,
637+ consumed_aesthetics,
638+ } => {
639+ // The wrapped input introduces a synthetic pos1 column that the
640+ // GROUP BY then collapses to a single boxplot.
641+ assert ! ( query. contains( "__ggsql_stat_dummy" ) ) ;
642+ assert ! ( query. contains( "__ggsql_stat_pos1" ) ) ;
643+ assert ! ( stat_columns. contains( & "pos1" . to_string( ) ) ) ;
644+ assert ! ( stat_columns. contains( & "type" . to_string( ) ) ) ;
645+ assert ! ( stat_columns. contains( & "value" . to_string( ) ) ) ;
646+ assert_eq ! ( dummy_columns, vec![ "pos1" . to_string( ) ] ) ;
647+ assert_eq ! ( consumed_aesthetics, vec![ "pos2" . to_string( ) ] ) ;
648+ }
649+ _ => panic ! ( "expected Transformed" ) ,
650+ }
651+ }
652+
590653 #[ test]
591654 fn test_boxplot_stat_consumed_aesthetics ( ) {
592655 let boxplot = Boxplot ;
@@ -596,13 +659,6 @@ mod tests {
596659 assert_eq ! ( consumed[ 0 ] , "pos2" ) ;
597660 }
598661
599- #[ test]
600- fn test_boxplot_needs_stat_transform ( ) {
601- let boxplot = Boxplot ;
602- let aesthetics = Mappings :: new ( ) ;
603- assert ! ( boxplot. needs_stat_transform( & aesthetics) ) ;
604- }
605-
606662 #[ test]
607663 fn test_boxplot_display ( ) {
608664 let boxplot = Boxplot ;
0 commit comments