diff --git a/benchmarks/src/main/java/org/elasticsearch/benchmark/esql/ViewResolutionBenchmarkBase.java b/benchmarks/src/main/java/org/elasticsearch/benchmark/esql/ViewResolutionBenchmarkBase.java index 3e0c5fd480137..048945c12dbd9 100644 --- a/benchmarks/src/main/java/org/elasticsearch/benchmark/esql/ViewResolutionBenchmarkBase.java +++ b/benchmarks/src/main/java/org/elasticsearch/benchmark/esql/ViewResolutionBenchmarkBase.java @@ -33,6 +33,7 @@ import org.elasticsearch.index.IndexVersion; import org.elasticsearch.indices.SystemIndices; import org.elasticsearch.license.XPackLicenseState; +import org.elasticsearch.search.crossproject.CrossProjectModeDecider; import org.elasticsearch.telemetry.metric.MeterRegistry; import org.elasticsearch.threadpool.DefaultBuiltInExecutorBuilders; import org.elasticsearch.threadpool.ThreadPool; @@ -342,7 +343,7 @@ static class BenchmarkViewResolver extends ViewResolver { boolean enabled, ViewResolutionService viewResolutionService ) { - super(clusterService, projectResolver, null); + super(clusterService, projectResolver, null, CrossProjectModeDecider.NOOP); this.enabled = enabled; this.viewResolutionService = viewResolutionService; this.benchmarkClusterService = clusterService; diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/EsqlPlugin.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/EsqlPlugin.java index fcbdf501173dd..fc85f16e878e0 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/EsqlPlugin.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plugin/EsqlPlugin.java @@ -282,7 +282,14 @@ public Collection createComponents(PluginServices services) { ); if (ESQL_VIEWS_FEATURE_FLAG.isEnabled()) { components = new ArrayList<>(components); - components.add(new ViewResolver(services.clusterService(), services.projectResolver(), services.client())); + components.add( + new ViewResolver( + services.clusterService(), + services.projectResolver(), + services.client(), + services.crossProjectModeDecider() + ) + ); components.add(new ViewService(services.clusterService(), parser)); } return components; diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/ViewResolver.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/ViewResolver.java index 4c0628e7d17b5..7a438f7a8469e 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/ViewResolver.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/view/ViewResolver.java @@ -20,6 +20,7 @@ import org.elasticsearch.index.IndexMode; import org.elasticsearch.logging.LogManager; import org.elasticsearch.logging.Logger; +import org.elasticsearch.search.crossproject.CrossProjectModeDecider; import org.elasticsearch.xpack.core.esql.EsqlFeatureFlags; import org.elasticsearch.xpack.esql.VerificationException; import org.elasticsearch.xpack.esql.action.EsqlResolveViewAction; @@ -51,6 +52,7 @@ public class ViewResolver { protected Logger log = LogManager.getLogger(getClass()); private final ClusterService clusterService; private final ProjectResolver projectResolver; + private final CrossProjectModeDecider crossProjectModeDecider; private volatile int maxViewDepth; private final Client client; public static final Setting MAX_VIEW_DEPTH_SETTING = Setting.intSetting( @@ -68,13 +70,20 @@ public class ViewResolver { public ViewResolver() { this.clusterService = null; this.projectResolver = null; + this.crossProjectModeDecider = CrossProjectModeDecider.NOOP; this.maxViewDepth = 0; this.client = null; } - public ViewResolver(ClusterService clusterService, ProjectResolver projectResolver, Client client) { + public ViewResolver( + ClusterService clusterService, + ProjectResolver projectResolver, + Client client, + CrossProjectModeDecider crossProjectModeDecider + ) { this.clusterService = clusterService; this.projectResolver = projectResolver; + this.crossProjectModeDecider = crossProjectModeDecider; this.client = client; clusterService.getClusterSettings().initializeAndWatch(MAX_VIEW_DEPTH_SETTING, v -> this.maxViewDepth = v); } @@ -310,8 +319,11 @@ private List buildUnresolvedPatterns( unresolvedPatterns.add(resolvedIndexExpression.original()); continue; } - // If any of the concrete resources were not views, pass them along as an unresolved relation - if (resolvedIndexExpression.localExpressions().indices().stream().anyMatch(index -> seenViews.contains(index) == false)) { + // If any of the concrete resources were not views, pass them along as an unresolved relation. + // When CPS is enabled, also keep wildcard patterns because they may match remote indexes + // in other projects (unlike CCS, CPS does not require explicit remote references). + if (resolvedIndexExpression.localExpressions().indices().stream().anyMatch(index -> seenViews.contains(index) == false) + || (crossProjectModeDecider.crossProjectEnabled() && Regex.isSimpleMatchPattern(resolvedIndexExpression.original()))) { unresolvedPatterns.add(resolvedIndexExpression.original()); } } diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/view/InMemoryViewResolver.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/view/InMemoryViewResolver.java index fcf554e8fd0af..6f60e5da8a09c 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/view/InMemoryViewResolver.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/view/InMemoryViewResolver.java @@ -17,6 +17,7 @@ import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.util.concurrent.ThreadContext; import org.elasticsearch.indices.EmptySystemIndices; +import org.elasticsearch.search.crossproject.CrossProjectModeDecider; import org.elasticsearch.tasks.Task; import org.elasticsearch.transport.TransportService; import org.elasticsearch.xpack.esql.action.EsqlResolveViewAction; @@ -32,8 +33,12 @@ public class InMemoryViewResolver extends ViewResolver { protected ClusterService clusterService; protected ProjectResolver projectResolver; - public InMemoryViewResolver(ClusterService clusterService, Supplier metadata) { - super(clusterService, null, null); + public InMemoryViewResolver( + ClusterService clusterService, + Supplier metadata, + CrossProjectModeDecider crossProjectModeDecider + ) { + super(clusterService, null, null, crossProjectModeDecider); this.projectResolver = DefaultProjectResolver.INSTANCE; this.indexNameExpressionResolver = new IndexNameExpressionResolver( new ThreadContext(Settings.EMPTY), diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/view/InMemoryViewService.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/view/InMemoryViewService.java index ce2e3f4f97eaf..c8cec86e41868 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/view/InMemoryViewService.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/view/InMemoryViewService.java @@ -21,6 +21,7 @@ import org.elasticsearch.common.settings.Setting; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.index.IndexVersion; +import org.elasticsearch.search.crossproject.CrossProjectModeDecider; import org.elasticsearch.test.ClusterServiceUtils; import org.elasticsearch.threadpool.TestThreadPool; import org.elasticsearch.threadpool.ThreadPool; @@ -161,6 +162,10 @@ void clearAllViewsAndIndices() { } public InMemoryViewResolver getViewResolver() { - return new InMemoryViewResolver(clusterService, () -> viewMetadata); + return new InMemoryViewResolver(clusterService, () -> viewMetadata, CrossProjectModeDecider.NOOP); + } + + public InMemoryViewResolver getViewResolver(CrossProjectModeDecider crossProjectModeDecider) { + return new InMemoryViewResolver(clusterService, () -> viewMetadata, crossProjectModeDecider); } } diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/view/InMemoryViewServiceTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/view/InMemoryViewServiceTests.java index 26b91ef2c6da1..0d1101f23ba07 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/view/InMemoryViewServiceTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/view/InMemoryViewServiceTests.java @@ -13,6 +13,7 @@ import org.elasticsearch.cluster.metadata.View; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.core.TimeValue; +import org.elasticsearch.search.crossproject.CrossProjectModeDecider; import org.elasticsearch.xpack.esql.ConfigurationTestUtils; import org.elasticsearch.xpack.esql.SerializationTestUtils; import org.elasticsearch.xpack.esql.VerificationException; @@ -1060,12 +1061,104 @@ public void testIndexCompactionWithNestedNamedSubqueries() { } } + /** + * When CPS is enabled and a wildcard matches only views (no local indexes), the wildcard is still preserved + * as an unresolved pattern because remote projects may have matching indexes. + */ + public void testCPSWildcardPreservedWhenOnlyViewsMatch() { + addView("view1", "FROM emp1"); + addView("view2", "FROM emp2"); + addView("view3", "FROM emp3"); + LogicalPlan plan = query("FROM view*"); + // Without CPS: wildcard is fully replaced (no unresolved pattern) + assertThat(replaceViews(plan), matchesPlan(query("FROM emp1, emp2, emp3"))); + // With CPS: wildcard is preserved alongside the resolved views + assertThat(replaceViewsWithCPS(plan), matchesPlan(query("FROM emp1, emp2, emp3, view*"))); + } + + /** + * When CPS is enabled and a wildcard matches views with pipe bodies (no local indexes), the wildcard is preserved. + */ + public void testCPSWildcardPreservedWithPipeBodiesWhenOnlyViewsMatch() { + addView("view_1", "FROM emp1 | WHERE emp.age > 30"); + addView("view_2", "FROM emp2 | WHERE emp.age < 40"); + addView("view_3", "FROM emp3 | WHERE emp.salary > 50000"); + LogicalPlan plan = query("FROM view*"); + // Without CPS: 3 subqueries (just the views) + LogicalPlan withoutCPS = replaceViews(plan); + assertThat(withoutCPS, instanceOf(ViewUnionAll.class)); + assertThat(withoutCPS.children().size(), equalTo(3)); + // With CPS: 4 subqueries (3 views + the preserved wildcard) + LogicalPlan withCPS = replaceViewsWithCPS(plan); + assertThat(withCPS, instanceOf(ViewUnionAll.class)); + assertThat(withCPS.children().size(), equalTo(4)); + assertThat( + withCPS.children(), + containsInAnyOrder( + matchesPlan(query("FROM view*")), + matchesPlan(query("FROM emp1 | WHERE emp.age > 30")), + matchesPlan(query("FROM emp2 | WHERE emp.age < 40")), + matchesPlan(query("FROM emp3 | WHERE emp.salary > 50000")) + ) + ); + } + + /** + * When CPS is enabled and a wildcard already matches a local index alongside views, the wildcard is preserved + * (same as without CPS in this case). + */ + public void testCPSWildcardWithIndexMatchBehavesLikeNonCPS() { + addIndex("viewX"); + addView("view1", "FROM emp1"); + addView("view2", "FROM emp2"); + addView("view3", "FROM emp3"); + LogicalPlan plan = query("FROM view*"); + // Both should preserve the wildcard since there's a matching local index + assertThat(replaceViews(plan), matchesPlan(query("FROM emp1,emp2,emp3,view*"))); + assertThat(replaceViewsWithCPS(plan), matchesPlan(query("FROM emp1,emp2,emp3,view*"))); + } + + /** + * CPS does not affect concrete (non-wildcard) view references — they are still fully replaced. + * This is because we separately report the view names to the index resolution layer for CPS anyway. + */ + public void testCPSConcreteViewFullyReplaced() { + addView("view1", "FROM emp1"); + LogicalPlan plan = query("FROM view1"); + assertThat(replaceViews(plan), matchesPlan(query("FROM emp1"))); + assertThat(replaceViewsWithCPS(plan), matchesPlan(query("FROM emp1"))); + } + + /** + * When CPS is enabled and nested views use wildcards that match only views, wildcards are preserved. + */ + public void testCPSNestedWildcardPreserved() { + addView("view_1", "FROM emp1"); + addView("view_2", "FROM emp2"); + addView("view_3", "FROM emp3"); + addView("view_1_2", "FROM view_1, view_2"); + addView("view_1_3", "FROM view_1, view_3"); + LogicalPlan plan = query("FROM view_1_*"); + // Without CPS: wildcard fully replaced + assertThat(replaceViews(plan), matchesPlan(query("FROM emp1,emp3,emp1,emp2"))); + // With CPS: wildcard preserved + assertThat(replaceViewsWithCPS(plan), matchesPlan(query("FROM emp1,emp3,emp1,emp2,view_1_*"))); + } + private LogicalPlan replaceViews(LogicalPlan plan) { PlainActionFuture future = new PlainActionFuture<>(); viewResolver.replaceViews(plan, this::parse, future); return future.actionGet().plan(); } + private LogicalPlan replaceViewsWithCPS(LogicalPlan plan) { + var cpsDecider = new CrossProjectModeDecider(Settings.builder().put("serverless.cross_project.enabled", true).build()); + InMemoryViewResolver cpsResolver = viewService.getViewResolver(cpsDecider); + PlainActionFuture future = new PlainActionFuture<>(); + cpsResolver.replaceViews(plan, this::parse, future); + return future.actionGet().plan(); + } + private void addIndex(String name) { viewService.addIndex(projectId, name); }