2424import org .elasticsearch .xpack .esql .plan .logical .Filter ;
2525import org .elasticsearch .xpack .esql .plan .logical .LogicalPlan ;
2626import org .elasticsearch .xpack .esql .plan .logical .Project ;
27+ import org .elasticsearch .xpack .esql .plan .logical .Row ;
28+ import org .elasticsearch .xpack .esql .plan .logical .join .StubRelation ;
2729import org .elasticsearch .xpack .esql .plan .logical .local .EsqlProject ;
2830import org .elasticsearch .xpack .esql .rule .ParameterizedRule ;
2931
3032import java .util .ArrayList ;
3133import java .util .HashMap ;
34+ import java .util .IdentityHashMap ;
3235import java .util .List ;
3336import java .util .Map ;
3437
@@ -74,25 +77,98 @@ public class PushExpressionsToFieldLoad extends ParameterizedRule<LogicalPlan, L
7477
7578 @ Override
7679 public LogicalPlan apply (LogicalPlan plan , LocalLogicalOptimizerContext context ) {
77- Rule rule = new Rule (context , plan );
80+ Rule rule = new Rule (context );
7881 return plan .transformDown (LogicalPlan .class , rule ::doRule );
7982 }
8083
84+ /**
85+ * Lazily scans the plan for "primaries". A "primary" here is an {@link EsRelation}
86+ * we can push a field load into.
87+ * <p>
88+ * Every node in the plan in the plan can be traced "down" to a leaf - the source
89+ * of all of its data. This rule can only push expressions into {@link EsRelation},
90+ * but there are lots of other kinds of leaf nodes like {@link StubRelation} and
91+ * {@link Row}. If a node has any of those unsupported ancestors then {@link #primariesFor}
92+ * will return an empty {@link List}. This is the signal the rest of the code uses
93+ * for "can't push".
94+ * </p>
95+ */
96+ private class Primaries {
97+ /**
98+ * A map from each node to all of its "primaries". The empty list is special here - it means that the node's
99+ * parent doesn't support pushing.
100+ * <p>
101+ * Note: The primary itself will be in the map, pointing to itself.
102+ * </p>
103+ */
104+ private Map <LogicalPlan , List <EsRelation >> primaries = new IdentityHashMap <>();
105+
106+ /**
107+ * Find "primaries" for a node. Returning the empty list is special here - it
108+ * means that the node's ancestors contain a node to which we cannot push.
109+ */
110+ List <EsRelation > primariesFor (LogicalPlan plan ) {
111+ scanSubtree (plan );
112+ return primaries .get (plan );
113+ }
114+
115+ /**
116+ * Recursively scan the tree under {@code plan}, visiting ancestors
117+ * before children, and ignoring any trees we've scanned before.
118+ */
119+ private void scanSubtree (LogicalPlan plan ) {
120+ if (primaries .containsKey (plan )) {
121+ return ;
122+ }
123+ if (plan .children ().isEmpty ()) {
124+ onLeaf (plan );
125+ } else {
126+ for (LogicalPlan child : plan .children ()) {
127+ scanSubtree (child );
128+ }
129+ onInner (plan );
130+ }
131+ }
132+
133+ private void onLeaf (LogicalPlan plan ) {
134+ if (plan instanceof EsRelation rel ) {
135+ if (rel .indexMode () == IndexMode .LOOKUP ) {
136+ primaries .put (plan , List .of ());
137+ } else {
138+ primaries .put (rel , List .of (rel ));
139+ }
140+ } else {
141+ primaries .put (plan , List .of ());
142+ }
143+ }
144+
145+ private void onInner (LogicalPlan plan ) {
146+ List <EsRelation > result = new ArrayList <>(plan .children ().size ());
147+ for (LogicalPlan child : plan .children ()) {
148+ List <EsRelation > childPrimaries = primaries .get (child );
149+ assert childPrimaries != null : "scanned depth first " + child ;
150+ if (childPrimaries .isEmpty ()) {
151+ log .trace ("{} unsupported primaries {}" , plan , child );
152+ primaries .put (plan , List .of ());
153+ return ;
154+ }
155+ result .addAll (childPrimaries );
156+ }
157+ log .trace ("{} primaries {}" , plan , result );
158+ primaries .put (plan , result );
159+ }
160+ }
161+
81162 private class Rule {
82163 private final Map <Attribute .IdIgnoringWrapper , Attribute > addedAttrs = new HashMap <>();
83164
84165 private final LocalLogicalOptimizerContext context ;
85- private final LogicalPlan plan ;
166+ private final Primaries primaries = new Primaries () ;
86167
87- /**
88- * The primary indices, lazily initialized.
89- */
90- private List <EsRelation > primaries ;
91168 private boolean addedNewAttribute = false ;
92169
93- private Rule (LocalLogicalOptimizerContext context , LogicalPlan plan ) {
170+ private Rule (LocalLogicalOptimizerContext context ) {
94171 this .context = context ;
95- this .plan = plan ;
96172 }
97173
98174 private LogicalPlan doRule (LogicalPlan plan ) {
@@ -115,7 +191,7 @@ private LogicalPlan doRule(LogicalPlan plan) {
115191 private LogicalPlan transformPotentialInvocation (LogicalPlan plan ) {
116192 LogicalPlan transformedPlan = plan .transformExpressionsOnly (Expression .class , e -> {
117193 if (e instanceof BlockLoaderExpression ble ) {
118- return transformExpression (e , ble );
194+ return transformExpression (plan , e , ble );
119195 }
120196 return e ;
121197 });
@@ -130,12 +206,16 @@ private LogicalPlan transformPotentialInvocation(LogicalPlan plan) {
130206 return new EsqlProject (Source .EMPTY , transformedPlan , transformedPlan .output ());
131207 }
132208
133- private Expression transformExpression (Expression e , BlockLoaderExpression ble ) {
209+ private Expression transformExpression (LogicalPlan nodeWithExpression , Expression e , BlockLoaderExpression ble ) {
134210 BlockLoaderExpression .PushedBlockLoaderExpression fuse = ble .tryPushToFieldLoading (context .searchStats ());
135211 if (fuse == null ) {
136212 return e ;
137213 }
138- if (anyPrimaryContains (fuse .field ()) == false ) {
214+ List <EsRelation > planPrimaries = primaries .primariesFor (nodeWithExpression );
215+ log .trace ("found primaries {} {}" , nodeWithExpression , planPrimaries );
216+ if (planPrimaries .size () != 1 ) {
217+ // Empty list means that we can't push.
218+ // >1 primary is currently unsupported, though we expect to support it later.
139219 return e ;
140220 }
141221 var preference = context .configuration ().pragmas ().fieldExtractPreference ();
@@ -184,26 +264,5 @@ private Expression replaceFieldsForFieldTransformations(Expression e, BlockLoade
184264 addedAttrs .put (key , newFunctionAttr );
185265 return newFunctionAttr ;
186266 }
187-
188- private List <EsRelation > primaries () {
189- if (primaries == null ) {
190- primaries = new ArrayList <>(2 );
191- plan .forEachUp (EsRelation .class , r -> {
192- if (r .indexMode () != IndexMode .LOOKUP ) {
193- primaries .add (r );
194- }
195- });
196- }
197- return primaries ;
198- }
199-
200- private boolean anyPrimaryContains (FieldAttribute attr ) {
201- for (EsRelation primary : primaries ()) {
202- if (primary .outputSet ().contains (attr )) {
203- return true ;
204- }
205- }
206- return false ;
207- }
208267 }
209268}
0 commit comments