1313import org .elasticsearch .index .IndexMode ;
1414import org .elasticsearch .test .ESTestCase ;
1515import org .elasticsearch .xpack .esql .EsqlTestUtils ;
16+ import org .elasticsearch .xpack .esql .VerificationException ;
1617import org .elasticsearch .xpack .esql .analysis .Analyzer ;
1718import org .elasticsearch .xpack .esql .analysis .AnalyzerContext ;
1819import org .elasticsearch .xpack .esql .core .expression .Alias ;
4344import org .elasticsearch .xpack .esql .expression .predicate .operator .arithmetic .Add ;
4445import org .elasticsearch .xpack .esql .index .EsIndex ;
4546import org .elasticsearch .xpack .esql .index .IndexResolution ;
47+ import org .elasticsearch .xpack .esql .optimizer .rules .logical .OptimizerRules ;
4648import org .elasticsearch .xpack .esql .optimizer .rules .logical .local .InferIsNotNull ;
4749import org .elasticsearch .xpack .esql .parser .EsqlParser ;
4850import org .elasticsearch .xpack .esql .plan .logical .Aggregate ;
5961import org .elasticsearch .xpack .esql .plan .logical .local .EmptyLocalSupplier ;
6062import org .elasticsearch .xpack .esql .plan .logical .local .EsqlProject ;
6163import org .elasticsearch .xpack .esql .plan .logical .local .LocalRelation ;
64+ import org .elasticsearch .xpack .esql .rule .RuleExecutor ;
6265import org .elasticsearch .xpack .esql .stats .SearchStats ;
6366import org .hamcrest .Matchers ;
6467import org .junit .BeforeClass ;
6568
69+ import java .util .ArrayList ;
6670import java .util .List ;
6771import java .util .Locale ;
6872import java .util .Map ;
8892import static org .elasticsearch .xpack .esql .EsqlTestUtils .unboundLogicalOptimizerContext ;
8993import static org .elasticsearch .xpack .esql .EsqlTestUtils .withDefaultLimitWarning ;
9094import static org .elasticsearch .xpack .esql .core .tree .Source .EMPTY ;
95+ import static org .elasticsearch .xpack .esql .core .type .DataType .INTEGER ;
96+ import static org .elasticsearch .xpack .esql .optimizer .rules .logical .OptimizerRules .TransformDirection .DOWN ;
97+ import static org .elasticsearch .xpack .esql .optimizer .rules .logical .OptimizerRules .TransformDirection .UP ;
9198import static org .hamcrest .Matchers .contains ;
9299import static org .hamcrest .Matchers .containsString ;
93100import static org .hamcrest .Matchers .equalTo ;
@@ -780,7 +787,7 @@ public void testGroupingByMissingFields() {
780787 as (eval .child (), EsRelation .class );
781788 }
782789
783- public void testPlanSanityCheck () throws Exception {
790+ public void testVerifierOnMissingReferences () throws Exception {
784791 var plan = localPlan ("""
785792 from test
786793 | stats a = min(salary) by emp_no
@@ -806,6 +813,106 @@ public void testPlanSanityCheck() throws Exception {
806813 assertThat (e .getMessage (), containsString (" optimized incorrectly due to missing references [salary" ));
807814 }
808815
816+ private LocalLogicalPlanOptimizer getCustomRulesLocalLogicalPlanOptimizer (List <RuleExecutor .Batch <LogicalPlan >> batches ) {
817+ LocalLogicalOptimizerContext context = new LocalLogicalOptimizerContext (
818+ EsqlTestUtils .TEST_CFG ,
819+ FoldContext .small (),
820+ TEST_SEARCH_STATS
821+ );
822+ LocalLogicalPlanOptimizer customOptimizer = new LocalLogicalPlanOptimizer (context ) {
823+ @ Override
824+ protected List <Batch <LogicalPlan >> batches () {
825+ return batches ;
826+ }
827+ };
828+ return customOptimizer ;
829+ }
830+
831+ public void testVerifierOnAdditionalAttributeAdded () throws Exception {
832+ var plan = localPlan ("""
833+ from test
834+ | stats a = min(salary) by emp_no
835+ """ );
836+
837+ var limit = as (plan , Limit .class );
838+ var aggregate = as (limit .child (), Aggregate .class );
839+ var min = as (Alias .unwrap (aggregate .aggregates ().get (0 )), Min .class );
840+ var salary = as (min .field (), NamedExpression .class );
841+ assertThat (salary .name (), is ("salary" ));
842+
843+ // use a custom rule that adds another output attribute
844+ var customRuleBatch = new RuleExecutor .Batch <>(
845+ "CustomRuleBatch" ,
846+ RuleExecutor .Limiter .ONCE ,
847+ new OptimizerRules .ParameterizedOptimizerRule <Aggregate , LocalLogicalOptimizerContext >(UP ) {
848+ static Integer appliedCount = 0 ;
849+
850+ @ Override
851+ protected LogicalPlan rule (Aggregate plan , LocalLogicalOptimizerContext context ) {
852+ // This rule adds a missing attribute to the plan output
853+ // We only want to apply it once, so we use a static counter
854+ if (appliedCount == 0 ) {
855+ appliedCount ++;
856+ Literal additionalLiteral = new Literal (Source .EMPTY , "additional literal" , INTEGER );
857+ return new Eval (plan .source (), plan , List .of (new Alias (Source .EMPTY , "additionalAttribute" , additionalLiteral )));
858+ }
859+ return plan ;
860+ }
861+
862+ }
863+ );
864+ LocalLogicalPlanOptimizer customRulesLocalLogicalPlanOptimizer = getCustomRulesLocalLogicalPlanOptimizer (List .of (customRuleBatch ));
865+ Exception e = expectThrows (VerificationException .class , () -> customRulesLocalLogicalPlanOptimizer .localOptimize (plan ));
866+ assertThat (e .getMessage (), containsString ("Output has changed from" ));
867+ assertThat (e .getMessage (), containsString ("additionalAttribute" ));
868+ }
869+
870+ public void testVerifierOnAttributeDatatypeChanged (){
871+ var plan = localPlan ("""
872+ from test
873+ | stats a = min(salary) by emp_no
874+ """ );
875+
876+ var limit = as (plan , Limit .class );
877+ var aggregate = as (limit .child (), Aggregate .class );
878+ var min = as (Alias .unwrap (aggregate .aggregates ().get (0 )), Min .class );
879+ var salary = as (min .field (), NamedExpression .class );
880+ assertThat (salary .name (), is ("salary" ));
881+
882+ // use a custom rule that changes the datatype of an output attribute
883+ var customRuleBatch = new RuleExecutor .Batch <>(
884+ "CustomRuleBatch" ,
885+ RuleExecutor .Limiter .ONCE ,
886+ new OptimizerRules .ParameterizedOptimizerRule <LogicalPlan , LocalLogicalOptimizerContext >(DOWN ) {
887+ static Integer appliedCount = 0 ;
888+
889+ @ Override
890+ protected LogicalPlan rule (LogicalPlan plan , LocalLogicalOptimizerContext context ) {
891+ // We only want to apply it once, so we use a static counter
892+ if (appliedCount == 0 ) {
893+ appliedCount ++;
894+ Limit limit = as (plan , Limit .class );
895+ Limit newLimit = new Limit (plan .source (), limit .limit (), limit .child ()) {
896+ @ Override
897+ public List <Attribute > output () {
898+ List <Attribute > oldOutput = super .output ();
899+ List <Attribute > newOutput = new ArrayList <>(oldOutput );
900+ newOutput .set (0 , oldOutput .get (0 ).withDataType (DataType .DATETIME ));
901+ return newOutput ;
902+ }
903+ };
904+ return newLimit ;
905+ }
906+ return plan ;
907+ }
908+
909+ }
910+ );
911+ LocalLogicalPlanOptimizer customRulesLocalLogicalPlanOptimizer = getCustomRulesLocalLogicalPlanOptimizer (List .of (customRuleBatch ));
912+ Exception e = expectThrows (VerificationException .class , () -> customRulesLocalLogicalPlanOptimizer .localOptimize (plan ));
913+ assertThat (e .getMessage (), containsString ("Output has changed from" ));
914+ }
915+
809916 private IsNotNull isNotNull (Expression field ) {
810917 return new IsNotNull (EMPTY , field );
811918 }
0 commit comments