Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3b61633
Introduce ViewUnionAll to differentiate view-produced unions from sub…
quackaplop Mar 4, 2026
9a597e6
Add changelog for #143564
quackaplop Mar 4, 2026
6091623
Add dedicated ViewUnionAllTests for plan node unit tests
quackaplop Mar 4, 2026
8d38fa0
Add ViewUnionAll to ApproximationSupportTests unsupported commands list
quackaplop Mar 4, 2026
a2c3ce4
Change changelog type to bug
quackaplop Mar 4, 2026
e2d0505
Delete unneeded changelog file
craigtaverner Mar 9, 2026
d6ed962
Add names to subqueries in ViewUnionAll
craigtaverner Mar 9, 2026
aa5dbfc
Add more tests for named subqueries and fix bug with Map.hashCode
craigtaverner Mar 10, 2026
a27ccbc
Support view names inside ViewUnionAll
craigtaverner Mar 13, 2026
9e9fb6d
Fix EsqlNodeSubclassTests
craigtaverner Mar 16, 2026
8cc54f2
Merge remote-tracking branch 'origin/main' into view_union_all_fork
craigtaverner Mar 16, 2026
5b6ae0a
Ensure that ViewUnionAll survives through semantic analysis
craigtaverner Mar 16, 2026
46b23c3
Mark NamedSubquery unsupported in ApproximationSupportTests
craigtaverner Mar 16, 2026
ee52e42
Merge branch 'main' into view_union_all_fork
craigtaverner Mar 16, 2026
145134d
Merge branch 'main' into view_union_all_fork
craigtaverner Mar 17, 2026
369eb46
Merge branch 'main' into view_union_all_fork
craigtaverner Mar 17, 2026
efa88cb
Merge remote-tracking branch 'origin/main' into view_union_all_fork
craigtaverner Mar 20, 2026
d04f4d2
Fixed AnalyzerTests after merging main
craigtaverner Mar 20, 2026
c67a4c9
Merge branch 'main' into view_union_all_fork
craigtaverner Mar 21, 2026
3877822
Merge branch 'main' into view_union_all_fork
craigtaverner Mar 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,22 @@
import org.elasticsearch.xpack.esql.plan.QuerySettings;
import org.elasticsearch.xpack.esql.plan.logical.Enrich;
import org.elasticsearch.xpack.esql.plan.logical.LogicalPlan;
import org.elasticsearch.xpack.esql.plan.logical.NamedSubquery;
import org.elasticsearch.xpack.esql.plan.logical.UnresolvedRelation;
import org.elasticsearch.xpack.esql.plan.logical.ViewUnionAll;
import org.elasticsearch.xpack.esql.session.Configuration;
import org.hamcrest.Matcher;
import org.hamcrest.Matchers;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Supplier;
import java.util.stream.Collectors;

import static junit.framework.Assert.assertTrue;
import static org.elasticsearch.test.ESTestCase.expectThrows;
Expand Down Expand Up @@ -77,6 +83,7 @@ public class TestAnalyzer {
private Supplier<TransportVersion> minimumTransportVersion = TransportVersionUtils::randomCompatibleVersion;
private ExternalSourceResolution externalSourceResolution = ExternalSourceResolution.EMPTY;
private boolean stripErrorPrefix;
private Map<String, String> views = new LinkedHashMap<>();

TestAnalyzer() {}

Expand Down Expand Up @@ -290,6 +297,14 @@ public TestAnalyzer addEnrichError(String policyName, Enrich.Mode mode, String r
return this;
}

/**
* Add a view definition.
*/
public TestAnalyzer addView(String name, String query) {
views.put(name, query);
return this;
}

/**
* Adds the standard set of enrich policy resolutions used by many analyzer tests.
*/
Expand Down Expand Up @@ -455,7 +470,83 @@ public TestAnalyzer minimumTransportVersion(TransportVersion minimumTransportVer
* Build the analyzer, parse the query, and analyze it.
*/
public LogicalPlan query(String query, QueryParams params) {
return buildAnalyzer().analyze(EsqlTestUtils.TEST_PARSER.parseQuery(query, params));
return buildAnalyzer().analyze(parseQuery(query, params));
}

private LogicalPlan parseQuery(String query, QueryParams params) {
var parsed = EsqlTestUtils.TEST_PARSER.parseQuery(query, params);
if (views.isEmpty()) {
return parsed;
}
return resolveViews(parsed);
}

// This most primitive view resolution only works for the simple cases being tested
private LogicalPlan resolveViews(LogicalPlan parsed) {
var viewDefinitions = resolveViews(views);
return parsed.transformDown(UnresolvedRelation.class, ur -> {
List<LogicalPlan> resolved = Arrays.stream(ur.indexPattern().indexPattern().split("\\s*\\,\\s*")).map(indexPattern -> {
var view = viewDefinitions.get(indexPattern);
return view == null
? (LogicalPlan) (makeUnresolvedRelation(ur, indexPattern))
: new NamedSubquery(view.source(), view, indexPattern);
}).toList();
if (resolved.size() == 1) {
var subplan = resolved.get(0);
if (subplan instanceof NamedSubquery n) {
return n.child();
}
return subplan;
}
List<UnresolvedRelation> unresolvedRelations = new ArrayList<>();
List<NamedSubquery> namedSubqueries = new ArrayList<>();
for (LogicalPlan l : resolved) {
if (l instanceof UnresolvedRelation u) {
unresolvedRelations.add(u);
} else if (l instanceof NamedSubquery n) {
namedSubqueries.add(n);
} else {
throw new IllegalArgumentException("Only support UnresolvedRelation and NamedSubquery in Views Analyzer Tests");
}
}
LinkedHashMap<String, LogicalPlan> subplans = new LinkedHashMap<>();
if (unresolvedRelations.size() == 1) {
subplans.put(null, unresolvedRelations.get(0));
} else if (unresolvedRelations.size() > 1) {
String indexPattern = unresolvedRelations.stream()
.map(u -> u.indexPattern().indexPattern())
.collect(Collectors.joining(","));
subplans.put(null, makeUnresolvedRelation(unresolvedRelations.get(0), indexPattern));
}
for (NamedSubquery namedSubquery : namedSubqueries) {
subplans.put(namedSubquery.name(), namedSubquery.child());
}
if (subplans.size() == 1) {
return namedSubqueries.get(0).child();
} else {
return new ViewUnionAll(ur.source(), subplans, List.of());
}
});
}

private static UnresolvedRelation makeUnresolvedRelation(UnresolvedRelation plan, String indexPattern) {
return new UnresolvedRelation(
plan.source(),
new IndexPattern(plan.source(), indexPattern),
plan.frozen(),
plan.metadataFields(),
plan.indexMode(),
plan.unresolvedMessage(),
plan.telemetryLabel()
);
}

private static Map<String, LogicalPlan> resolveViews(Map<String, String> views) {
var parsedViews = new HashMap<String, LogicalPlan>();
for (Map.Entry<String, String> entry : views.entrySet()) {
parsedViews.put(entry.getKey(), TEST_PARSER.parseQuery(entry.getValue()));
}
return parsedViews;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -420,3 +420,40 @@ rehired_count:long | is_rehired:boolean
11 | true
12 | false
;

// Testing views used inside subqueries
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could also test subqueries inside views

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done inside the InMemoryViewServiceTests

// These tests verify that view references inside user-written subqueries are resolved correctly.
// The parser produces a plain UnionAll for "FROM index, (FROM ...)" syntax, and ViewResolver
// must recurse into it to resolve any view references in the subquery children.

viewInSubquery
required_capability: subquery_in_from_command
required_capability: views_with_no_branching
required_capability: views_crud_as_index_actions

FROM sample_data, (FROM country_airports)
| STATS sd = COUNT(client_ip), ca = COUNT(count), total = COUNT()
;

sd:long | ca:long | total:long
7 | 15 | 22
;

viewInSubqueryMultipleRows
required_capability: subquery_in_from_command
required_capability: views_with_no_branching
required_capability: views_crud_as_index_actions

FROM sample_data, (FROM country_airports)
| STATS total = COUNT() BY country
| SORT total DESC, country ASC
| LIMIT 5
;

total:long | country:keyword
7 | null
1 | Argentina
1 | Australia
1 | Brazil
1 | Canada
;
Original file line number Diff line number Diff line change
Expand Up @@ -3064,7 +3064,7 @@ private static LogicalPlan rebuildUnionAll(
}
}
}
return new UnionAll(unionAll.source(), newChildren, newOutput);
return unionAll.replaceSubPlansAndOutput(newChildren, newOutput);
}

/**
Expand Down Expand Up @@ -3310,7 +3310,7 @@ private static UnionAll rebuildUnionAllOutput(
newOutput.add(oldAttr);
}
}
return new UnionAll(unionAll.source(), newChildren, newOutput);
return unionAll.replaceSubPlansAndOutput(newChildren, newOutput);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
package org.elasticsearch.xpack.esql.plan.logical;

import org.elasticsearch.xpack.esql.core.tree.NodeInfo;
import org.elasticsearch.xpack.esql.core.tree.Source;

import java.util.Objects;

/**
* A {@link Subquery} that carries the view name it was resolved from.
* <p>
* Unlike plain {@link Subquery}, the name participates in {@link #equals} and {@link #hashCode},
* which allows {@code Node.transformDown} to distinguish a newly-tagged subquery from its
* untagged predecessor. After view resolution, a post-processing pass converts
* {@link UnionAll} nodes containing {@code NamedSubquery} children into {@link ViewUnionAll}.
* <p>
* This class should only be used during query re-writing and not survive in the final query plan.
* If we decide to keep named subqueries as a feature later, we should add serialization support.
*/
public class NamedSubquery extends Subquery {
private final String name;

public NamedSubquery(Source source, LogicalPlan subqueryPlan, String name) {
super(source, subqueryPlan);
this.name = Objects.requireNonNull(name);
}

public String name() {
return name;
}

@Override
protected NodeInfo<NamedSubquery> info() {
return NodeInfo.create(this, NamedSubquery::new, child(), name);
}

@Override
public UnaryPlan replaceChild(LogicalPlan newChild) {
return new NamedSubquery(source(), newChild, name);
}

@Override
public int hashCode() {
return Objects.hash(name, child());
}

@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
NamedSubquery other = (NamedSubquery) obj;
return Objects.equals(name, other.name) && Objects.equals(child(), other.child());
}

@Override
public String nodeString(NodeStringFormat format) {
return nodeName() + "[" + name + "]";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,28 +21,19 @@

public class Subquery extends UnaryPlan implements TelemetryAware, SortAgnostic {
public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(LogicalPlan.class, "Subquery", Subquery::new);
private final String name; // named subqueries are views (information useful for debugging planning)

// subquery alias/qualifier could be added in the future if needed

public Subquery(Source source, LogicalPlan subqueryPlan) {
this(source, subqueryPlan, null);
}

public Subquery(Source source, LogicalPlan subqueryPlan, String name) {
super(source, subqueryPlan);
this.name = name;
}

private Subquery(StreamInput in) throws IOException {
this(Source.readFrom((PlanStreamInput) in), in.readNamedWriteable(LogicalPlan.class), null);
this(Source.readFrom((PlanStreamInput) in), in.readNamedWriteable(LogicalPlan.class));
}

@Override
public void writeTo(StreamOutput out) throws IOException {
Source.EMPTY.writeTo(out);
out.writeNamedWriteable(child());
// View names are not needed on the data nodes, only on the coordinating node for debugging purposes
}

@Override
Expand All @@ -51,13 +42,13 @@ public String getWriteableName() {
}

@Override
protected NodeInfo<Subquery> info() {
return NodeInfo.create(this, Subquery::new, child(), name);
protected NodeInfo<? extends Subquery> info() {
return NodeInfo.create(this, Subquery::new, child());
}

@Override
public UnaryPlan replaceChild(LogicalPlan newChild) {
return new Subquery(source(), newChild, name);
return new Subquery(source(), newChild);
}

@Override
Expand Down Expand Up @@ -91,7 +82,7 @@ public boolean equals(Object obj) {

@Override
public String nodeString(NodeStringFormat format) {
return nodeName() + "[" + (name == null ? "" : name) + "]";
return nodeName() + "[]";
}

public LogicalPlan plan() {
Expand Down
Loading
Loading