@@ -254,7 +254,10 @@ fn assigned_physical_annotation_asap_input_and_invalid_accuracy_are_rejected() {
254254 . clone ( ) ;
255255 producer. timing = Some ( asap_types:: post_asap:: ExecutionTiming :: QueryTime ) ;
256256 assert ! ( matches!(
257- enumerate_local_logical_candidates( vec![ ( 0 , QueryRoot :: Operator ( Rc :: new( producer) ) ) ] ) ,
257+ enumerate_local_logical_candidates( vec![ (
258+ 0 ,
259+ QueryRoot :: Operator ( Rc :: new( producer. clone( ) ) )
260+ ) ] ) ,
258261 Err ( LogicalCandidateError :: AssignedPhysicalAnnotation )
259262 ) ) ;
260263 let mut stored = ( * aggregate ( AggIntent :: Count {
@@ -264,9 +267,17 @@ fn assigned_physical_annotation_asap_input_and_invalid_accuracy_are_rejected() {
264267 stored. materialization =
265268 asap_types:: post_asap:: Materialization :: MatAtQuery ( asap_types:: post_asap:: Medium :: Memory ) ;
266269 assert ! ( matches!(
267- enumerate_local_logical_candidates( vec![ ( 0 , QueryRoot :: Operator ( Rc :: new( stored) ) ) ] ) ,
270+ enumerate_local_logical_candidates( vec![ ( 0 , QueryRoot :: Operator ( Rc :: new( stored. clone ( ) ) ) ) ] ) ,
268271 Err ( LogicalCandidateError :: AssignedPhysicalAnnotation )
269272 ) ) ;
273+ // An annotated node below an unannotated root is rejected too.
274+ for annotated in [ producer, stored] {
275+ let root = having_filter ( Rc :: new ( annotated) , always ( ) ) ;
276+ assert ! ( matches!(
277+ enumerate_local_logical_candidates( vec![ ( 0 , QueryRoot :: Operator ( root) ) ] ) ,
278+ Err ( LogicalCandidateError :: AssignedPhysicalAnnotation )
279+ ) ) ;
280+ }
270281 let summary = OperatorNode :: new_shared ( Operator :: ASAP ( ASAPOp :: SummaryAgg {
271282 child : scan ( false ) ,
272283 family : FieldDataType :: Sketch (
@@ -331,6 +342,10 @@ fn assigned_physical_annotation_asap_input_and_invalid_accuracy_are_rejected() {
331342 ) ,
332343 "{error:?}"
333344 ) ;
345+ assert ! (
346+ error. to_string( ) . contains( "root \" q\" , measure 1 " ) ,
347+ "{error}"
348+ ) ;
334349}
335350
336351/// Local TopK keeps both declared heap substrates without choosing an implementation.
@@ -674,3 +689,153 @@ fn example_1_exact_q2_prunes_topk_heaps() {
674689 && choices. pruned. is_empty( ) ) ) ;
675690 assert_eq ! ( found. len( ) , 2 , "{found:#?}" ) ;
676691}
692+
693+ /// The `HAVING` entries of the target whose measure satisfies `is`.
694+ fn having_of (
695+ targets : & [ asap_aware_mapping:: logical_candidates:: LocalLogicalTarget ] ,
696+ is : impl Fn ( & AggIntent ) -> bool ,
697+ ) -> Vec < HavingEntry > {
698+ let target = targets
699+ . iter ( )
700+ . find ( |target| match target. target . non_asap ( ) {
701+ Some ( NonASAPOp :: Aggregate { measures, .. } ) => is ( & measures[ target. measure ] ) ,
702+ _ => false ,
703+ } )
704+ . expect ( "target" ) ;
705+ target. having . clone ( )
706+ }
707+
708+ /// `Column(column) > ScalarSubquery(subquery)`.
709+ fn above_scalar ( column : usize , subquery : Rc < OperatorNode > ) -> Predicate {
710+ Predicate ( ScalarExpr :: Compare {
711+ left : Box :: new ( ScalarExpr :: Column ( column) ) ,
712+ op : CompareOpKind :: Gt ,
713+ right : Box :: new ( ScalarExpr :: ScalarSubquery ( subquery) ) ,
714+ semantics : ExprSemantics :: Sql ,
715+ } )
716+ }
717+
718+ /// A `Filter` that reads an aggregate only from its predicate is not that
719+ /// aggregate's `HAVING`; one that also filters the aggregate gives one entry.
720+ #[ test]
721+ fn scalar_reference_filter_is_not_having ( ) {
722+ let count = || {
723+ vec ! [ AggIntent :: Count {
724+ accuracy: approximate( ) ,
725+ } ]
726+ } ;
727+ let agg = aggregate_over ( scan ( false ) , count ( ) , vec ! [ ] , None ) ;
728+ let root = having_filter ( scan ( false ) , above_scalar ( 1 , agg. clone ( ) ) ) ;
729+ let targets = enumerate_local_logical_candidates ( vec ! [ ( 0 , QueryRoot :: Operator ( root) ) ] )
730+ . unwrap ( )
731+ . targets ;
732+ assert_eq ! ( targets. len( ) , 1 ) ;
733+ assert ! ( Rc :: ptr_eq( & targets[ 0 ] . target, & agg) ) ;
734+ assert ! ( targets[ 0 ] . having. is_empty( ) , "{:?}" , targets[ 0 ] . having) ;
735+
736+ let agg = aggregate_over ( scan ( false ) , count ( ) , vec ! [ ] , None ) ;
737+ let root = having_filter ( agg. clone ( ) , above_scalar ( 0 , agg. clone ( ) ) ) ;
738+ let targets = enumerate_local_logical_candidates ( vec ! [ ( 0 , QueryRoot :: Operator ( root. clone( ) ) ) ] )
739+ . unwrap ( )
740+ . targets ;
741+ assert_eq ! ( targets. len( ) , 1 ) ;
742+ assert_eq ! (
743+ targets[ 0 ] . having,
744+ vec![ HavingEntry {
745+ filter: Some ( root) ,
746+ predicate: above_scalar( 0 , agg) ,
747+ placement: HavingPlacement :: AboveOutput ,
748+ } ]
749+ ) ;
750+ }
751+
752+ /// PromQL `scalar(...)` operands are scalar references, not `HAVING`.
753+ #[ test]
754+ fn promql_scalar_operand_is_not_having ( ) {
755+ let roots = implicit_exact_promql ( "http_requests_total > scalar(sum(http_requests_total))" ) ;
756+ let targets = enumerate_local_logical_candidates ( roots. into_iter ( ) . enumerate ( ) . collect ( ) )
757+ . unwrap ( )
758+ . targets ;
759+ assert ! ( !targets. is_empty( ) ) ;
760+ for target in & targets {
761+ assert ! ( target. having. is_empty( ) , "{:?}" , target. having) ;
762+ }
763+
764+ let roots = implicit_exact_promql ( "sum by (job) (x) > scalar(count(x))" ) ;
765+ let targets = enumerate_local_logical_candidates ( roots. into_iter ( ) . enumerate ( ) . collect ( ) )
766+ . unwrap ( )
767+ . targets ;
768+ assert ! ( having_of( & targets, |intent| matches!( intent, AggIntent :: Count { .. } ) ) . is_empty( ) ) ;
769+ let sum = having_of ( & targets, |intent| matches ! ( intent, AggIntent :: Sum { .. } ) ) ;
770+ assert_eq ! ( sum. len( ) , 1 , "{sum:?}" ) ;
771+ assert_eq ! ( sum[ 0 ] . placement, HavingPlacement :: AboveOutput ) ;
772+ }
773+
774+ /// A non-root, non-`Filter` consumer keeps a key-only `HAVING` above.
775+ #[ test]
776+ fn having_shared_with_a_project_consumer_stays_above ( ) {
777+ let node = grouped_count ( None ) ;
778+ let key = equals ( 0 , ScalarValue :: Utf8 ( "a" . into ( ) ) ) ;
779+ let filtered = having_filter ( node. clone ( ) , key) ;
780+ let project = OperatorNode :: new_shared ( Operator :: NonASAP ( NonASAPOp :: Project {
781+ cols : vec ! [ asap_types:: ir:: ProjectItem {
782+ alias: None ,
783+ expr: ScalarExpr :: Column ( 0 ) ,
784+ } ] ,
785+ qualifier : None ,
786+ child : node,
787+ } ) )
788+ . unwrap ( ) ;
789+ let targets = enumerate_local_logical_candidates ( vec ! [
790+ ( 0 , QueryRoot :: Operator ( filtered) ) ,
791+ ( 1 , QueryRoot :: Operator ( project) ) ,
792+ ] )
793+ . unwrap ( )
794+ . targets ;
795+ assert_eq ! ( targets. len( ) , 1 ) ;
796+ let placements: Vec < _ > = targets[ 0 ]
797+ . having
798+ . iter ( )
799+ . map ( |entry| entry. placement )
800+ . collect ( ) ;
801+ assert_eq ! ( placements, vec![ HavingPlacement :: AboveOutput ] ) ;
802+ }
803+
804+ /// `without(...)` and per-entity aggregates have no fixed key prefix, so a
805+ /// `HAVING` over column 0 stays above.
806+ #[ test]
807+ fn without_and_per_entity_having_stays_above ( ) {
808+ for reduction in [
809+ Reduction :: Reduce ( asap_types:: ir:: operator_properties:: GroupKeys :: without (
810+ vec ! [ 1 ] ,
811+ ) ) ,
812+ Reduction :: PerEntity ,
813+ ] {
814+ let node = OperatorNode :: new_shared ( Operator :: NonASAP ( NonASAPOp :: Aggregate {
815+ child : scan ( false ) ,
816+ reduction : reduction. clone ( ) ,
817+ measures : vec ! [ AggIntent :: Count {
818+ accuracy: approximate( ) ,
819+ } ] ,
820+ output_names : vec ! [ ] ,
821+ filters : vec ! [ ] ,
822+ having : None ,
823+ } ) )
824+ . unwrap ( ) ;
825+ let root = having_filter ( node, equals ( 0 , ScalarValue :: Utf8 ( "a" . into ( ) ) ) ) ;
826+ let targets = enumerate_local_logical_candidates ( vec ! [ ( 0 , QueryRoot :: Operator ( root) ) ] )
827+ . unwrap ( )
828+ . targets ;
829+ assert_eq ! ( targets. len( ) , 1 , "{reduction:?}" ) ;
830+ let placements: Vec < _ > = targets[ 0 ]
831+ . having
832+ . iter ( )
833+ . map ( |entry| entry. placement )
834+ . collect ( ) ;
835+ assert_eq ! (
836+ placements,
837+ vec![ HavingPlacement :: AboveOutput ] ,
838+ "{reduction:?}"
839+ ) ;
840+ }
841+ }
0 commit comments