diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/SearchResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/SearchResourceIT.java index 10798fbf9e49..e38e1f3b008a 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/SearchResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/SearchResourceIT.java @@ -1926,4 +1926,261 @@ void testExportWithFromBeyondResults(TestNamespace ns) throws Exception { String[] lines = response.body().split("\n"); assertEquals(1, lines.length, "Export beyond results should only contain header"); } + + // =================================================================== + // FETCH PARENT/CHILD ALIASES TESTS + // =================================================================== + + private HttpResponse httpGetJson(String path) throws Exception { + String baseUrl = SdkClients.getServerUrl(); + String token = SdkClients.getAdminToken(); + + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(baseUrl + path)) + .header("Authorization", "Bearer " + token) + .header("Accept", "application/json") + .timeout(Duration.ofSeconds(30)) + .GET() + .build(); + + return HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + } + + /** + * Wait until the column document is visible in {@code column_search_index}. The test fixtures + * deliberately put {@code unique} only into the column name (not the table name), which is + * exactly the shape the original bug report describes: a UI search for {@code index=table} + * accidentally returns column docs because ES alias expansion routes the {@code table} alias + * to {@code column_search_index} too. + */ + private void awaitColumnIndexed(String unique) { + Awaitility.await() + .atMost(90, TimeUnit.SECONDS) + .pollInterval(500, TimeUnit.MILLISECONDS) + .until( + () -> { + HttpResponse r = + httpGetJson("/v1/search/query?q=" + unique + "&index=tableColumn&size=20"); + return r.statusCode() == 200 + && OBJECT_MAPPER.readTree(r.body()).path("hits").path("hits").size() > 0; + }); + } + + private static boolean anyHitOfType(JsonNode hits, String entityType) { + for (JsonNode hit : hits) { + if (entityType.equalsIgnoreCase(hit.path("_source").path("entityType").asText())) { + return true; + } + } + return false; + } + + /** + * Bug regression: the UI now passes the alias {@code "table"} (instead of the legacy + * {@code table_search_index}). ES alias expansion bleeds {@code column_search_index} into the + * results because tableColumn declares {@code "table"} as a parent alias. Asserts the bleed + * reproduces when child expansion is explicitly enabled via {@code fetchChildAliases=*} and + * disappears under the new default ({@code fetchChildAliases=none}) — a comparison test, not + * a vacuous "no columns" check that would pass on an empty response. + */ + @Test + void testDefaultChildScopeExcludesColumns(TestNamespace ns) throws Exception { + String unique = "fetchchild_excl_" + ns.shortPrefix(); + Column uniqueColumn = + new Column() + .withName(unique + "_col") + .withDataType(ColumnDataType.VARCHAR) + .withDataLength(64); + createTestTableWithColumns(ns, ns.prefix("fetchchild_excl_t"), List.of(uniqueColumn)); + + awaitColumnIndexed(unique); + + // Opt-in legacy expansion (fetchChildAliases=*): tableColumn hits MUST appear so the rest + // of the assertion isn't vacuous. + HttpResponse withChildren = + httpGetJson("/v1/search/query?q=" + unique + "&index=table&fetchChildAliases=*&size=20"); + assertEquals(200, withChildren.statusCode()); + JsonNode withChildrenHits = + OBJECT_MAPPER.readTree(withChildren.body()).path("hits").path("hits"); + assertTrue( + anyHitOfType(withChildrenHits, "tableColumn"), + "fetchChildAliases=* on index=table must surface tableColumn hits — otherwise the " + + "fixture is broken and the no-columns assertion would pass vacuously. body=" + + withChildren.body()); + + // Default flags (fetchChildAliases=none) — the bug fix path. tableColumn hits must NOT leak. + HttpResponse withoutChildren = + httpGetJson("/v1/search/query?q=" + unique + "&index=table&size=20"); + assertEquals(200, withoutChildren.statusCode()); + JsonNode withoutChildrenHits = + OBJECT_MAPPER.readTree(withoutChildren.body()).path("hits").path("hits"); + assertFalse( + anyHitOfType(withoutChildrenHits, "tableColumn"), + "Default flags on index=table must drop tableColumn hits, got: " + withoutChildren.body()); + } + + /** + * Confirms the named-filter syntax: passing only a specific child entity type expands that one + * but no others. Uses {@code fetchChildAliases=tableColumn} so the response includes + * {@code tableColumn} hits but not, say, {@code testCase} hits (testCase isn't a child of + * 'table' in indexMapping.json so it's already implicitly excluded — this test pins the + * positive direction). + */ + @Test + void testFetchChildAliasesNamedFilterIncludesOnlySpecified(TestNamespace ns) throws Exception { + String unique = "fetchchild_named_" + ns.shortPrefix(); + Column uniqueColumn = + new Column() + .withName(unique + "_col") + .withDataType(ColumnDataType.VARCHAR) + .withDataLength(64); + createTestTableWithColumns(ns, ns.prefix("fetchchild_named_t"), List.of(uniqueColumn)); + + awaitColumnIndexed(unique); + + HttpResponse response = + httpGetJson( + "/v1/search/query?q=" + unique + "&index=table&fetchChildAliases=tableColumn&size=20"); + assertEquals(200, response.statusCode()); + JsonNode hits = OBJECT_MAPPER.readTree(response.body()).path("hits").path("hits"); + assertTrue( + anyHitOfType(hits, "tableColumn"), + "fetchChildAliases=tableColumn must include tableColumn docs: " + response.body()); + + // A filter that names a non-child must not introduce that index — passing 'topic' (not a + // child of 'table') should yield zero tableColumn hits. + HttpResponse nonChild = + httpGetJson( + "/v1/search/query?q=" + unique + "&index=table&fetchChildAliases=topic&size=20"); + assertEquals(200, nonChild.statusCode()); + JsonNode nonChildHits = OBJECT_MAPPER.readTree(nonChild.body()).path("hits").path("hits"); + assertFalse( + anyHitOfType(nonChildHits, "tableColumn"), + "fetchChildAliases=topic must not introduce tableColumn hits: " + nonChild.body()); + } + + @Test + void testFetchParentsAliasesWildcardIncludesParents(TestNamespace ns) throws Exception { + String unique = "fetchparents_" + ns.shortPrefix(); + Column uniqueColumn = + new Column() + .withName(unique + "_col") + .withDataType(ColumnDataType.VARCHAR) + .withDataLength(64); + Table parentTable = + createTestTableWithColumns(ns, ns.prefix("fetchparents_" + unique), List.of(uniqueColumn)); + String tableName = parentTable.getName(); + + awaitColumnIndexed(unique); + + Awaitility.await() + .atMost(90, TimeUnit.SECONDS) + .pollInterval(500, TimeUnit.MILLISECONDS) + .until( + () -> { + HttpResponse r = + httpGetJson( + "/v1/search/query?q=" + + tableName + + "&index=tableColumn&fetchParentsAliases=*&fetchChildAliases=none&size=20"); + return r.statusCode() == 200 + && anyHitOfType( + OBJECT_MAPPER.readTree(r.body()).path("hits").path("hits"), "table"); + }); + } + + /** + * The flag must also be honored on the streaming export endpoint. Comparison test: default + * export carries column rows; explicit fetchChildAliases=false drops them. + */ + @Test + void testDefaultChildScopeOnExportEndpoint(TestNamespace ns) throws Exception { + String unique = "fetchchild_exp_" + ns.shortPrefix(); + Column uniqueColumn = + new Column() + .withName(unique + "_col") + .withDataType(ColumnDataType.VARCHAR) + .withDataLength(64); + createTestTableWithColumns(ns, ns.prefix("fetchchild_exp_t"), List.of(uniqueColumn)); + + awaitColumnIndexed(unique); + + HttpResponse wildcardExport = + httpGetExport("/v1/search/export?q=" + unique + "&index=table&fetchChildAliases=*&size=50"); + assertEquals(200, wildcardExport.statusCode()); + String wildcardBody = wildcardExport.body(); + boolean wildcardHasColumn = + java.util.Arrays.stream(wildcardBody.split("\n")) + .skip(1) + .anyMatch(row -> row.toLowerCase().startsWith("tablecolumn,")); + assertTrue( + wildcardHasColumn, + "fetchChildAliases=* export on index=table must include tableColumn rows; " + + "otherwise the no-columns assertion below is vacuous. body=" + + wildcardBody); + + HttpResponse defaultExport = + httpGetExport("/v1/search/export?q=" + unique + "&index=table&size=50"); + assertEquals(200, defaultExport.statusCode()); + String defaultBody = defaultExport.body(); + for (String row : defaultBody.split("\n")) { + assertFalse( + row.toLowerCase().startsWith("tablecolumn,"), + "Default export on /export must drop tableColumn rows, got: " + row); + } + } + + /** + * The flag must propagate to /aggregate. Comparison test: default aggregation has a column + * bucket; explicit fetchChildAliases=false drops it. + */ + @Test + void testDefaultChildScopeOnAggregate(TestNamespace ns) throws Exception { + String unique = "fetchchild_agg_" + ns.shortPrefix(); + Column uniqueColumn = + new Column() + .withName(unique + "_col") + .withDataType(ColumnDataType.VARCHAR) + .withDataLength(64); + createTestTableWithColumns(ns, ns.prefix("fetchchild_agg_t"), List.of(uniqueColumn)); + + awaitColumnIndexed(unique); + + // value=.* — the aggregate endpoint always wraps the terms agg in + // .include(regexp(fieldValue.toLowerCase())), so an empty fieldValue compiles to an empty + // regex that matches no bucket keys. ".*" sidesteps that filter. + HttpResponse wildcardAggregate = + httpGetJson( + "/v1/search/aggregate?index=table&field=entityType.keyword&value=.*&q=" + + unique + + "&fetchChildAliases=*&size=20"); + assertEquals(200, wildcardAggregate.statusCode()); + JsonNode wildcardBuckets = OBJECT_MAPPER.readTree(wildcardAggregate.body()).findPath("buckets"); + boolean wildcardHasColumn = false; + for (JsonNode bucket : wildcardBuckets) { + if ("tablecolumn".equalsIgnoreCase(bucket.path("key").asText())) { + wildcardHasColumn = true; + break; + } + } + assertTrue( + wildcardHasColumn, + "fetchChildAliases=* aggregate on index=table must include a 'tablecolumn' bucket; " + + "otherwise the no-columns assertion below is vacuous. body=" + + wildcardAggregate.body()); + + HttpResponse defaultAggregate = + httpGetJson( + "/v1/search/aggregate?index=table&field=entityType.keyword&value=.*&q=" + + unique + + "&size=20"); + assertEquals(200, defaultAggregate.statusCode()); + JsonNode defaultBuckets = OBJECT_MAPPER.readTree(defaultAggregate.body()).findPath("buckets"); + for (JsonNode bucket : defaultBuckets) { + assertFalse( + "tablecolumn".equalsIgnoreCase(bucket.path("key").asText()), + "Default aggregate on /aggregate must drop 'tablecolumn' bucket: " + bucket); + } + } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/search/SearchResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/search/SearchResource.java index 18131ea22627..639794a51c15 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/search/SearchResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/search/SearchResource.java @@ -219,7 +219,19 @@ public Response search( "Include aggregations in the search response. Defaults to true. Set to false to skip aggregations for faster response times when only search results are needed.") @DefaultValue("true") @QueryParam("include_aggregations") - boolean includeAggregations) + boolean includeAggregations, + @Parameter( + description = + "Selective expansion toward parent aliases declared in indexMapping.json. Pass `*` (or `all`) for every parent, `none` (or empty) for none, or a comma-separated list of entity types (e.g. `database,databaseSchema`). Defaults to `none`.") + @DefaultValue("none") + @QueryParam("fetchParentsAliases") + String fetchParentsAliases, + @Parameter( + description = + "Selective expansion toward child aliases (entities that list this alias in their parentAliases). Pass `*` (or `all`) for every child, `none` (or empty) for none, or a comma-separated list of entity types (e.g. `tableColumn`). Defaults to `none` so the response is scoped to the requested index.") + @DefaultValue("none") + @QueryParam("fetchChildAliases") + String fetchChildAliases) throws IOException { if (nullOrEmpty(query)) { @@ -236,7 +248,9 @@ public Response search( new SearchRequest() .withQuery(query) .withSize(size) - .withIndex(Entity.getSearchRepository().getIndexOrAliasName(index)) + .withIndex( + Entity.getSearchRepository() + .getIndexOrAliasName(index, fetchParentsAliases, fetchChildAliases)) .withFrom(from) .withQueryFilter(queryFilter) .withPostFilter(postFilter) @@ -310,7 +324,19 @@ public Response exportSearchResults( "Starting offset for export. Use with size to export a specific page of results (e.g., from=30&size=15 for page 3).") @DefaultValue("0") @QueryParam("from") - int from) + int from, + @Parameter( + description = + "Selective expansion toward parent aliases declared in indexMapping.json. Pass `*` (or `all`) for every parent, `none` (or empty) for none, or a comma-separated list of entity types (e.g. `database,databaseSchema`). Defaults to `none`.") + @DefaultValue("none") + @QueryParam("fetchParentsAliases") + String fetchParentsAliases, + @Parameter( + description = + "Selective expansion toward child aliases (entities that list this alias in their parentAliases). Pass `*` (or `all`) for every child, `none` (or empty) for none, or a comma-separated list of entity types (e.g. `tableColumn`). Defaults to `none` so the response is scoped to the requested index.") + @DefaultValue("none") + @QueryParam("fetchChildAliases") + String fetchChildAliases) throws IOException { SubjectContext subjectContext = getSubjectContext(securityContext); @@ -323,7 +349,9 @@ public Response exportSearchResults( queryFilter, postFilter, sortFieldParam, - sortOrder); + sortOrder, + fetchParentsAliases, + fetchChildAliases); int totalHits = searchRepository.countSearchResults(request, subjectContext); final int effectiveTotal = @@ -358,7 +386,9 @@ private SearchRequest buildExportSearchRequest( String queryFilter, String postFilter, String sortFieldParam, - String sortOrder) { + String sortOrder, + String fetchParentsAliases, + String fetchChildAliases) { String resolvedQuery = nullOrEmpty(query) ? "*" : query; List domains = new ArrayList<>(); @@ -368,7 +398,9 @@ private SearchRequest buildExportSearchRequest( return new SearchRequest() .withQuery(resolvedQuery) - .withIndex(Entity.getSearchRepository().getIndexOrAliasName(index)) + .withIndex( + Entity.getSearchRepository() + .getIndexOrAliasName(index, fetchParentsAliases, fetchChildAliases)) .withQueryFilter(queryFilter) .withPostFilter(postFilter) .withDeleted(deleted) @@ -409,7 +441,12 @@ public Response previewSearch( new SearchRequest() .withQuery(previewRequest.getQuery()) .withSize(previewRequest.getSize()) - .withIndex(Entity.getSearchRepository().getIndexOrAliasName(previewRequest.getIndex())) + .withIndex( + Entity.getSearchRepository() + .getIndexOrAliasName( + previewRequest.getIndex(), + previewRequest.getFetchParentsAliases(), + previewRequest.getFetchChildAliases())) .withFrom(previewRequest.getFrom()) .withQueryFilter(previewRequest.getQueryFilter()) .withPostFilter(previewRequest.getPostFilter()) @@ -498,7 +535,19 @@ public Response searchWithNLQ( @Parameter(description = "Explain the results of the query") @DefaultValue("false") @QueryParam("explain") - boolean explain) + boolean explain, + @Parameter( + description = + "Selective expansion toward parent aliases declared in indexMapping.json. Pass `*` (or `all`) for every parent, `none` (or empty) for none, or a comma-separated list of entity types (e.g. `database,databaseSchema`). Defaults to `none`.") + @DefaultValue("none") + @QueryParam("fetchParentsAliases") + String fetchParentsAliases, + @Parameter( + description = + "Selective expansion toward child aliases (entities that list this alias in their parentAliases). Pass `*` (or `all`) for every child, `none` (or empty) for none, or a comma-separated list of entity types (e.g. `tableColumn`). Defaults to `none` so the response is scoped to the requested index.") + @DefaultValue("none") + @QueryParam("fetchChildAliases") + String fetchChildAliases) throws IOException { // Add Domain Filter @@ -512,7 +561,9 @@ public Response searchWithNLQ( new SearchRequest() .withQuery(nlqQuery) .withSize(size) - .withIndex(Entity.getSearchRepository().getIndexOrAliasName(index)) + .withIndex( + Entity.getSearchRepository() + .getIndexOrAliasName(index, fetchParentsAliases, fetchChildAliases)) .withFrom(from) .withQueryFilter(queryFilter) .withPostFilter(postFilter) @@ -592,10 +643,23 @@ public Response searchByField( @Parameter(description = "Size field to limit the no.of results returned, defaults to 10") @DefaultValue("10") @QueryParam("size") - int size) + int size, + @Parameter( + description = + "Selective expansion toward parent aliases declared in indexMapping.json. Pass `*` (or `all`) for every parent, `none` (or empty) for none, or a comma-separated list of entity types (e.g. `database,databaseSchema`). Defaults to `none`.") + @DefaultValue("none") + @QueryParam("fetchParentsAliases") + String fetchParentsAliases, + @Parameter( + description = + "Selective expansion toward child aliases (entities that list this alias in their parentAliases). Pass `*` (or `all`) for every child, `none` (or empty) for none, or a comma-separated list of entity types (e.g. `tableColumn`). Defaults to `none` so the response is scoped to the requested index.") + @DefaultValue("none") + @QueryParam("fetchChildAliases") + String fetchChildAliases) throws IOException { - return searchRepository.searchByField(fieldName, fieldValue, index, deleted, from, size); + return searchRepository.searchByField( + fieldName, fieldValue, index, deleted, from, size, fetchParentsAliases, fetchChildAliases); } @GET @@ -672,7 +736,19 @@ public Response aggregate( @DefaultValue("false") @QueryParam("deleted") boolean deleted, @Parameter(description = "Free-text search query to scope aggregation results") @QueryParam("queryText") - String queryText) + String queryText, + @Parameter( + description = + "Selective expansion toward parent aliases declared in indexMapping.json. Pass `*` (or `all`) for every parent, `none` (or empty) for none, or a comma-separated list of entity types (e.g. `database,databaseSchema`). Defaults to `none`.") + @DefaultValue("none") + @QueryParam("fetchParentsAliases") + String fetchParentsAliases, + @Parameter( + description = + "Selective expansion toward child aliases (entities that list this alias in their parentAliases). Pass `*` (or `all`) for every child, `none` (or empty) for none, or a comma-separated list of entity types (e.g. `tableColumn`). Defaults to `none` so the response is scoped to the requested index.") + @DefaultValue("none") + @QueryParam("fetchChildAliases") + String fetchChildAliases) throws IOException { AggregationRequest aggregationRequest = @@ -684,7 +760,9 @@ public Response aggregate( .withFieldValue(value) .withSourceFields(SearchUtils.sourceFields(sourceFieldsParam)) .withDeleted(deleted) - .withQueryText(queryText); + .withQueryText(queryText) + .withFetchParentsAliases(fetchParentsAliases) + .withFetchChildAliases(fetchChildAliases); return searchRepository.aggregate(aggregationRequest); } @@ -746,7 +824,19 @@ public Response getEntityTypeCounts( String queryFilter, @Parameter(description = "Elasticsearch query that will be used as a post_filter") @QueryParam("post_filter") - String postFilter) + String postFilter, + @Parameter( + description = + "Selective expansion toward parent aliases declared in indexMapping.json. Pass `*` (or `all`) for every parent, `none` (or empty) for none, or a comma-separated list of entity types (e.g. `database,databaseSchema`). Defaults to `none`.") + @DefaultValue("none") + @QueryParam("fetchParentsAliases") + String fetchParentsAliases, + @Parameter( + description = + "Selective expansion toward child aliases (entities that list this alias in their parentAliases). Pass `*` (or `all`) for every child, `none` (or empty) for none, or a comma-separated list of entity types (e.g. `tableColumn`). Defaults to `none` so the response is scoped to the requested index.") + @DefaultValue("none") + @QueryParam("fetchChildAliases") + String fetchChildAliases) throws IOException { if (nullOrEmpty(query)) { @@ -771,7 +861,8 @@ public Response getEntityTypeCounts( .withPostFilter(postFilter) .withDomains(domains); - return searchRepository.getEntityTypeCounts(request, index); + return searchRepository.getEntityTypeCounts( + request, index, fetchParentsAliases, fetchChildAliases); } @POST diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchManagementClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchManagementClient.java index 3de2966834b5..a9e5c3d8a3c2 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchManagementClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchManagementClient.java @@ -76,6 +76,30 @@ Response searchByField( String fieldName, String fieldValue, String index, Boolean deleted, int from, int size) throws IOException; + /** + * Variant that honors {@code fetchParentsAliases} / {@code fetchChildAliases} when resolving + * the supplied {@code index} alias. Lets the alias-graph traversal happen exactly once, at + * the manager boundary, instead of the resource pre-resolving and the manager re-prefixing. + * Filter syntax: {@code "*"} (or {@code "all"}) for every alias, {@code "none"} (or empty) for + * none, or a comma-separated list of specific entity types. Null inputs are treated as + * {@code "none"}. Default implementation forwards to the legacy paging-only signature, which + * means SearchClient implementations that haven't opted into the filter-aware path get the + * same scoping behavior they get when callers pass {@code "none"} / {@code "none"} — the + * requested index only, no parent or child expansion. + */ + default Response searchByField( + String fieldName, + String fieldValue, + String index, + Boolean deleted, + int from, + int size, + String fetchParentsAliases, + String fetchChildAliases) + throws IOException { + return searchByField(fieldName, fieldValue, index, deleted, from, size); + } + /** * List entities with pagination support. * diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchRepository.java index d530cefd57fe..056f9c5571aa 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/SearchRepository.java @@ -155,6 +155,13 @@ public class SearchRepository { @Getter private Map entityIndexMap; + /** + * Reverse map: alias name -> entity types whose IndexMapping declares this alias as a parent. + * Lets us answer "what are the children of alias X?" in O(1). Built from indexMapping.json on + * load and used by {@link #getIndexOrAliasName(String, String, String)}. + */ + private Map> aliasToChildEntityTypes = Map.of(); + /** * Staged index names being populated by an in-flight reindex, keyed by the canonical index name * the alias normally points at (e.g. {@code openmetadata_table_search_index}). While an entry @@ -276,6 +283,23 @@ public SearchClient getSearchClient() { private void loadIndexMappings() { IndexMappingLoader mappingLoader = IndexMappingLoader.getInstance(); entityIndexMap = mappingLoader.getIndexMapping(); + aliasToChildEntityTypes = buildAliasToChildEntityTypes(entityIndexMap); + } + + static Map> buildAliasToChildEntityTypes( + Map indexMap) { + Map> reverse = new HashMap<>(); + for (Map.Entry entry : indexMap.entrySet()) { + String entityType = entry.getKey(); + List parentAliases = entry.getValue().getParentAliases(); + if (parentAliases == null) { + continue; + } + for (String parentAlias : parentAliases) { + reverse.computeIfAbsent(parentAlias, k -> new ArrayList<>()).add(entityType); + } + } + return reverse; } public SearchClient buildSearchClient(ElasticSearchConfiguration config) { @@ -645,9 +669,241 @@ public String getIndexOrAliasName(String name) { if (clusterAlias == null || clusterAlias.isEmpty()) { return name; } - return Arrays.stream(name.split(",")) - .map(index -> clusterAlias + INDEX_NAME_SEPARATOR + index.trim()) - .collect(Collectors.joining(",")); + String prefix = clusterAlias + INDEX_NAME_SEPARATOR; + // Defense-in-depth idempotency. The root-cause double-prefix is fixed by routing + // resource-layer resolution through the flag-aware overload exactly once, but tokens that + // already start with "_" should never get prefixed again — guards against any + // future code path that hands an already-resolved canonical index name back into this method + // (e.g. a helper layered on top of the new resolver). Empty tokens (from inputs like + // "table," or "," with stray commas) are dropped instead of materializing as a bare prefix. + String prefixed = + Arrays.stream(name.split(",")) + .map(String::trim) + .filter(index -> !index.isEmpty()) + .map(index -> index.startsWith(prefix) ? index : prefix + index) + .collect(Collectors.joining(",")); + // If the input was nothing but commas/whitespace, return it unchanged rather than emitting + // an empty index list — mirrors prefixCommaList so degenerate inputs surface as the original + // ES error (unknown index "X") instead of a confusing empty-target error. + return prefixed.isEmpty() ? name : prefixed; + } + + /** + * Resolve a UI-facing alias (e.g. {@code "table"}) into an explicit comma-separated list of the + * actual Elasticsearch index names to query. Bypasses ES alias expansion so that we don't bleed + * results from indexes that happen to share an alias because of the parent/child graph in + * {@code indexMapping.json}. + * + *

For each comma-separated token in {@code name}: + * + *

    + *
  • If the token matches an entity type in {@code entityIndexMap}, its actual indexName is + * always included. The {@code fetchParents} / {@code fetchChildren} filters control which + * parent / child entity indexes are also included — see {@link AliasFilter#parse(String)} + * for the accepted syntax ({@code *}, {@code none}, comma-separated entity types). Null + * and empty inputs are treated as {@code none}. + *
  • If the token is a compound alias (e.g. {@code "all"}, {@code "dataAsset"}) — not a key + * in {@code entityIndexMap} — and the children filter is non-empty, all entities that + * list this alias as a parent (and pass the filter) are expanded. Otherwise the token is + * passed through with the cluster prefix applied, preserving the legacy behavior. + *
+ */ + public String getIndexOrAliasName(String name, String fetchParents, String fetchChildren) { + return resolveIndexes( + name, fetchParents, fetchChildren, entityIndexMap, aliasToChildEntityTypes, clusterAlias); + } + + /** + * Parsed alias-filter expression for the {@code fetchParentsAliases} / {@code fetchChildAliases} + * inputs. The accepted syntax is documented on the schema fields: + * + *
    + *
  • {@code null}, empty / whitespace, or {@code "none"} — reject every candidate (no + * expansion). + *
  • {@code "*"} or {@code "all"} — accept every candidate. + *
  • {@code "table,topic"} — accept only the listed entity types. + *
+ */ + static final class AliasFilter { + static final AliasFilter NONE = new AliasFilter(false, Set.of()); + static final AliasFilter ALL = new AliasFilter(true, Set.of()); + + private final boolean matchAll; + private final Set allowed; + + private AliasFilter(boolean matchAll, Set allowed) { + this.matchAll = matchAll; + this.allowed = allowed; + } + + static AliasFilter parse(String raw) { + if (raw == null) { + return NONE; + } + String trimmed = raw.trim(); + if (trimmed.isEmpty() || "none".equalsIgnoreCase(trimmed)) { + return NONE; + } + if ("*".equals(trimmed) || "all".equalsIgnoreCase(trimmed)) { + return ALL; + } + Set allowed = + Arrays.stream(trimmed.split(",")) + .map(String::trim) + .filter(t -> !t.isEmpty()) + .collect(Collectors.toUnmodifiableSet()); + return allowed.isEmpty() ? NONE : new AliasFilter(false, allowed); + } + + boolean accepts(String candidate) { + return matchAll || allowed.contains(candidate); + } + + boolean isNoExpansion() { + return !matchAll && allowed.isEmpty(); + } + } + + /** + * Stateless resolver kept here so it can be exercised from unit tests by passing a mapping + * loaded from {@link IndexMappingLoader} directly, without constructing a SearchRepository. + */ + static String resolveIndexes( + String name, + String fetchParents, + String fetchChildren, + Map entityIndexMap, + Map> aliasToChildEntityTypes, + String clusterAlias) { + if (nullOrEmpty(name)) { + return name; + } + AliasFilter parentFilter = AliasFilter.parse(fetchParents); + AliasFilter childFilter = AliasFilter.parse(fetchChildren); + java.util.LinkedHashSet resolved = new java.util.LinkedHashSet<>(); + for (String rawToken : name.split(",")) { + String token = rawToken.trim(); + if (token.isEmpty()) { + continue; + } + expandSingleAlias( + token, + parentFilter, + childFilter, + entityIndexMap, + aliasToChildEntityTypes, + clusterAlias, + resolved); + } + if (resolved.isEmpty()) { + return prefixCommaList(name, clusterAlias); + } + return String.join(",", resolved); + } + + private static void expandSingleAlias( + String token, + AliasFilter parentFilter, + AliasFilter childFilter, + Map entityIndexMap, + Map> aliasToChildEntityTypes, + String clusterAlias, + java.util.Set resolved) { + IndexMapping mapping = entityIndexMap == null ? null : entityIndexMap.get(token); + boolean expandChildren = !childFilter.isNoExpansion(); + boolean expandParents = !parentFilter.isNoExpansion(); + if (mapping != null) { + resolved.add(mapping.getIndexName(clusterAlias)); + if (expandParents) { + addParentIndexes(mapping, parentFilter, entityIndexMap, clusterAlias, resolved); + } + if (expandChildren) { + addChildIndexes( + token, childFilter, entityIndexMap, aliasToChildEntityTypes, clusterAlias, resolved); + } + return; + } + if (expandChildren) { + addChildIndexes( + token, childFilter, entityIndexMap, aliasToChildEntityTypes, clusterAlias, resolved); + } + boolean alreadyExpanded = + expandChildren + && aliasToChildEntityTypes != null + && aliasToChildEntityTypes.containsKey(token); + if (!alreadyExpanded) { + resolved.add(prefixWithClusterAlias(token, clusterAlias)); + } + } + + private static void addParentIndexes( + IndexMapping mapping, + AliasFilter parentFilter, + Map entityIndexMap, + String clusterAlias, + java.util.Set resolved) { + List parents = mapping.getParentAliases(); + if (parents == null) { + return; + } + for (String parentAlias : parents) { + if (!parentFilter.accepts(parentAlias)) { + continue; + } + IndexMapping parentMapping = entityIndexMap.get(parentAlias); + if (parentMapping != null) { + resolved.add(parentMapping.getIndexName(clusterAlias)); + } + } + } + + private static void addChildIndexes( + String alias, + AliasFilter childFilter, + Map entityIndexMap, + Map> aliasToChildEntityTypes, + String clusterAlias, + java.util.Set resolved) { + if (aliasToChildEntityTypes == null) { + return; + } + List childTypes = aliasToChildEntityTypes.get(alias); + if (childTypes == null) { + return; + } + for (String childType : childTypes) { + if (!childFilter.accepts(childType)) { + continue; + } + IndexMapping childMapping = entityIndexMap.get(childType); + if (childMapping != null) { + resolved.add(childMapping.getIndexName(clusterAlias)); + } + } + } + + private static String prefixWithClusterAlias(String token, String clusterAlias) { + if (clusterAlias == null || clusterAlias.isEmpty()) { + return token; + } + String prefix = clusterAlias + INDEX_NAME_SEPARATOR; + return token.startsWith(prefix) ? token : prefix + token; + } + + private static String prefixCommaList(String name, String clusterAlias) { + if (clusterAlias == null || clusterAlias.isEmpty()) { + return name; + } + String prefix = clusterAlias + INDEX_NAME_SEPARATOR; + String prefixed = + Arrays.stream(name.split(",")) + .map(String::trim) + .filter(t -> !t.isEmpty()) + .map(t -> t.startsWith(prefix) ? t : prefix + t) + .collect(Collectors.joining(",")); + // If the input was nothing but commas/whitespace, return it unchanged rather than emitting an + // empty index list that would surface as a confusing ES error downstream. + return prefixed.isEmpty() ? name : prefixed; } private static final Map> RBAC_CHILD_TYPES = @@ -2933,6 +3189,26 @@ public Response searchByField( return searchClient.searchByField(fieldName, fieldValue, index, deleted, from, size); } + /** + * Variant that honors the alias-expansion filters. Forwards them to the SearchClient so the + * manager can resolve the alias graph exactly once — no resource-side pre-resolution, so the + * cluster prefix isn't double-applied. See {@link AliasFilter#parse(String)} for the accepted + * syntax. + */ + public Response searchByField( + String fieldName, + String fieldValue, + String index, + Boolean deleted, + int from, + int size, + String fetchParentsAliases, + String fetchChildAliases) + throws IOException { + return searchClient.searchByField( + fieldName, fieldValue, index, deleted, from, size, fetchParentsAliases, fetchChildAliases); + } + public Response aggregate(AggregationRequest request) throws IOException { return searchClient.aggregate(request); } @@ -2941,6 +3217,18 @@ public Response getEntityTypeCounts(SearchRequest request, String index) throws return searchClient.getEntityTypeCounts(request, index); } + /** + * Variant that honors the alias-expansion filters. Sets them on the request so the manager + * resolves the alias once with the same logic used by /search/query. + */ + public Response getEntityTypeCounts( + SearchRequest request, String index, String fetchParentsAliases, String fetchChildAliases) + throws IOException { + request.setFetchParentsAliases(fetchParentsAliases); + request.setFetchChildAliases(fetchChildAliases); + return searchClient.getEntityTypeCounts(request, index); + } + public JsonObject aggregate( String query, String entityType, SearchAggregation searchAggregation, SearchListFilter filter) throws IOException { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchAggregationManager.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchAggregationManager.java index edaf9f78f29a..444b1b9bc75d 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchAggregationManager.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchAggregationManager.java @@ -88,7 +88,12 @@ public Response aggregate(AggregationRequest request) throws IOException { try { SearchRequest.Builder searchRequestBuilder = new SearchRequest.Builder(); - String indexName = Entity.getSearchRepository().getIndexOrAliasName(request.getIndex()); + String indexName = + Entity.getSearchRepository() + .getIndexOrAliasName( + request.getIndex(), + request.getFetchParentsAliases(), + request.getFetchChildAliases()); searchRequestBuilder.index(indexName); Query query = null; @@ -616,9 +621,14 @@ public Response getEntityTypeCounts( requestBuilder.size(0); requestBuilder.trackTotalHits(true); - // Resolve the index alias properly + // Resolve the index alias properly — honor filters from the request when present so the + // resource layer doesn't need to pre-resolve (which would double-prefix the cluster alias). String resolvedIndex = - Entity.getSearchRepository().getIndexOrAliasName(index != null ? index : "all"); + Entity.getSearchRepository() + .getIndexOrAliasName( + index != null ? index : "all", + request.getFetchParentsAliases(), + request.getFetchChildAliases()); // Build and execute search SearchRequest searchRequest = requestBuilder.build(resolvedIndex); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java index 6f4a22d2f2cc..a12a043fbdf3 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchClient.java @@ -467,6 +467,21 @@ public Response searchByField( return searchManager.searchByField(fieldName, fieldValue, index, deleted, from, size); } + @Override + public Response searchByField( + String fieldName, + String fieldValue, + String index, + Boolean deleted, + int from, + int size, + String fetchParentsAliases, + String fetchChildAliases) + throws IOException { + return searchManager.searchByField( + fieldName, fieldValue, index, deleted, from, size, fetchParentsAliases, fetchChildAliases); + } + @Override public Response getEntityTypeCounts(SearchRequest request, String index) throws IOException { return aggregationManager.getEntityTypeCounts(request, index); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchSearchManager.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchSearchManager.java index b1f36cfc4adb..8c0729445509 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchSearchManager.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchSearchManager.java @@ -172,14 +172,32 @@ public Response searchBySourceUrl(String sourceUrl) throws IOException { public Response searchByField( String fieldName, String fieldValue, String index, Boolean deleted, int from, int size) throws IOException { + return searchByField(fieldName, fieldValue, index, deleted, from, size, "none", "none"); + } + + @Override + public Response searchByField( + String fieldName, + String fieldValue, + String index, + Boolean deleted, + int from, + int size, + String fetchParentsAliases, + String fetchChildAliases) + throws IOException { if (!isClientAvailable) { throw new IOException("Elasticsearch client is not available"); } + String resolvedIndex = + Entity.getSearchRepository() + .getIndexOrAliasName(index, fetchParentsAliases, fetchChildAliases); + SearchRequest searchRequest = SearchRequest.of( s -> - s.index(Entity.getSearchRepository().getIndexOrAliasName(index)) + s.index(resolvedIndex) .from(from) .size(size) .query( diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchAggregationManager.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchAggregationManager.java index bb6a8fef92cf..9b5f7d2d98f1 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchAggregationManager.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchAggregationManager.java @@ -87,7 +87,12 @@ public Response aggregate(AggregationRequest request) throws IOException { try { SearchRequest.Builder searchRequestBuilder = new SearchRequest.Builder(); - String indexName = Entity.getSearchRepository().getIndexOrAliasName(request.getIndex()); + String indexName = + Entity.getSearchRepository() + .getIndexOrAliasName( + request.getIndex(), + request.getFetchParentsAliases(), + request.getFetchChildAliases()); searchRequestBuilder.index(indexName); Query query = null; @@ -505,9 +510,14 @@ public Response getEntityTypeCounts( requestBuilder.size(0); requestBuilder.trackTotalHits(true); - // Resolve the index alias properly + // Resolve the index alias properly — honor filters from the request when present so the + // resource layer doesn't need to pre-resolve (which would double-prefix the cluster alias). String resolvedIndex = - Entity.getSearchRepository().getIndexOrAliasName(index != null ? index : "all"); + Entity.getSearchRepository() + .getIndexOrAliasName( + index != null ? index : "all", + request.getFetchParentsAliases(), + request.getFetchChildAliases()); // Build and execute search SearchRequest searchRequest = requestBuilder.build(resolvedIndex); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java index 2e721f4380e8..bbc21cbf8bb0 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchClient.java @@ -453,6 +453,21 @@ public Response searchByField( return searchManager.searchByField(fieldName, fieldValue, index, deleted, from, size); } + @Override + public Response searchByField( + String fieldName, + String fieldValue, + String index, + Boolean deleted, + int from, + int size, + String fetchParentsAliases, + String fetchChildAliases) + throws IOException { + return searchManager.searchByField( + fieldName, fieldValue, index, deleted, from, size, fetchParentsAliases, fetchChildAliases); + } + @Override public Response getEntityTypeCounts(SearchRequest request, String index) throws IOException { return aggregationManager.getEntityTypeCounts(request, index); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchSearchManager.java b/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchSearchManager.java index d0183b37885f..ae3eb1f55fd8 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchSearchManager.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchSearchManager.java @@ -192,14 +192,32 @@ public Response searchBySourceUrl(String sourceUrl) throws IOException { public Response searchByField( String fieldName, String fieldValue, String index, Boolean deleted, int from, int size) throws IOException { + return searchByField(fieldName, fieldValue, index, deleted, from, size, "none", "none"); + } + + @Override + public Response searchByField( + String fieldName, + String fieldValue, + String index, + Boolean deleted, + int from, + int size, + String fetchParentsAliases, + String fetchChildAliases) + throws IOException { if (!isClientAvailable) { throw new IOException("OpenSearch client is not available"); } + String resolvedIndex = + Entity.getSearchRepository() + .getIndexOrAliasName(index, fetchParentsAliases, fetchChildAliases); + SearchRequest searchRequest = SearchRequest.of( s -> - s.index(Entity.getSearchRepository().getIndexOrAliasName(index)) + s.index(resolvedIndex) .from(from) .size(size) .query( diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/search/SearchRepositoryAliasResolutionTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/search/SearchRepositoryAliasResolutionTest.java new file mode 100644 index 000000000000..a32ffdc52e4c --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/search/SearchRepositoryAliasResolutionTest.java @@ -0,0 +1,258 @@ +/* + * Copyright 2021 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openmetadata.service.search; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.openmetadata.search.IndexMapping; +import org.openmetadata.search.IndexMappingLoader; + +class SearchRepositoryAliasResolutionTest { + + private static Map entityIndexMap; + private static Map> aliasToChildEntityTypes; + + @BeforeAll + static void loadMappings() throws IOException { + IndexMappingLoader.init(); + entityIndexMap = IndexMappingLoader.getInstance().getIndexMapping(); + aliasToChildEntityTypes = SearchRepository.buildAliasToChildEntityTypes(entityIndexMap); + } + + @Test + void bothFiltersNoneReturnsOnlyOwnIndex() { + String resolved = + SearchRepository.resolveIndexes( + "table", "none", "none", entityIndexMap, aliasToChildEntityTypes, ""); + assertEquals("table_search_index", resolved); + } + + @Test + void wildcardChildrenIncludesEveryReverseMapEntry() { + String resolved = + SearchRepository.resolveIndexes( + "table", "none", "*", entityIndexMap, aliasToChildEntityTypes, ""); + List indexes = Arrays.asList(resolved.split(",")); + assertTrue( + indexes.contains("table_search_index"), "Own index must always be present: " + resolved); + assertTrue( + indexes.contains("column_search_index"), + "tableColumn lists 'table' as parent, so column index should be expanded: " + resolved); + assertFalse( + indexes.contains("database_search_index"), + "Database is a parent of table, not a child — must not appear: " + resolved); + } + + @Test + void namedChildrenIncludesOnlyTheListedEntityTypes() { + String resolved = + SearchRepository.resolveIndexes( + "table", "none", "tableColumn", entityIndexMap, aliasToChildEntityTypes, ""); + List indexes = Arrays.asList(resolved.split(",")); + assertTrue(indexes.contains("table_search_index")); + assertTrue( + indexes.contains("column_search_index"), "tableColumn explicitly listed: " + resolved); + // No other reverse-map child should leak through. + for (String idx : indexes) { + assertTrue( + idx.equals("table_search_index") || idx.equals("column_search_index"), + "Unexpected index in named-filter result: " + idx + "; full=" + resolved); + } + } + + @Test + void namedFilterIgnoresEntriesNotInTheGraph() { + // 'topic' is not a child of 'table' (its parentAliases don't list 'table'). Including it in + // the filter must not magically introduce topic_search_index. + String resolved = + SearchRepository.resolveIndexes( + "table", "none", "tableColumn,topic", entityIndexMap, aliasToChildEntityTypes, ""); + List indexes = Arrays.asList(resolved.split(",")); + assertTrue(indexes.contains("column_search_index")); + assertFalse( + indexes.contains("topic_search_index"), + "Filter accepts 'topic', but topic isn't a child of 'table': " + resolved); + } + + @Test + void wildcardParentsIncludesDeclaredParentEntities() { + String resolved = + SearchRepository.resolveIndexes( + "tableColumn", "*", "none", entityIndexMap, aliasToChildEntityTypes, ""); + List indexes = Arrays.asList(resolved.split(",")); + assertTrue(indexes.contains("column_search_index"), "Own index must be present: " + resolved); + assertTrue( + indexes.contains("table_search_index"), + "tableColumn declares 'table' in its parentAliases: " + resolved); + // Compound aliases like 'all'/'dataAsset' have no IndexMapping entry and must be silently + // skipped (no NPE), and must not introduce bogus indexes. + assertFalse(indexes.contains("all")); + assertFalse(indexes.contains("dataAsset")); + } + + @Test + void namedParentsIncludesOnlyListedEntities() { + String resolved = + SearchRepository.resolveIndexes( + "tableColumn", "table", "none", entityIndexMap, aliasToChildEntityTypes, ""); + List indexes = Arrays.asList(resolved.split(",")); + assertTrue(indexes.contains("column_search_index")); + assertTrue(indexes.contains("table_search_index")); + // 'database' is not a parent of tableColumn, only of table — must not be picked up. + assertFalse(indexes.contains("database_search_index")); + } + + @Test + void compoundAliasExpandsToAllDeclaredChildrenWhenChildrenIsWildcard() { + String resolved = + SearchRepository.resolveIndexes( + "dataAsset", "none", "*", entityIndexMap, aliasToChildEntityTypes, ""); + List indexes = Arrays.asList(resolved.split(",")); + assertTrue( + indexes.contains("table_search_index"), + "table declares dataAsset as a parent: " + resolved); + assertTrue( + indexes.contains("topic_search_index"), + "topic declares dataAsset as a parent: " + resolved); + assertFalse( + indexes.contains("dataAsset"), + "Compound alias literal must not appear when an expansion happened: " + resolved); + } + + @Test + void unknownTokenWithoutExpansionFallsBackToOriginalToken() { + String resolved = + SearchRepository.resolveIndexes( + "definitely_not_an_alias", "none", "*", entityIndexMap, aliasToChildEntityTypes, ""); + assertEquals("definitely_not_an_alias", resolved); + } + + @Test + void clusterAliasIsAppliedUniformlyToAllResolvedIndexes() { + String resolved = + SearchRepository.resolveIndexes( + "table", "none", "*", entityIndexMap, aliasToChildEntityTypes, "tenant42"); + for (String token : resolved.split(",")) { + assertTrue( + token.startsWith("tenant42_"), + "Every resolved index must carry the cluster prefix: " + resolved); + } + } + + @Test + void commaSeparatedInputResolvesEachTokenIndependentlyAndDeduplicates() { + String resolved = + SearchRepository.resolveIndexes( + "table,topic", "none", "none", entityIndexMap, aliasToChildEntityTypes, ""); + List indexes = Arrays.asList(resolved.split(",")); + assertTrue(indexes.contains("table_search_index")); + assertTrue(indexes.contains("topic_search_index")); + assertEquals( + indexes.size(), + indexes.stream().distinct().count(), + "Comma-separated tokens must be deduplicated: " + resolved); + } + + /** + * Defense-in-depth: even after the resource-layer pre-resolution was removed for /aggregate, + * /fieldQuery, and /entityTypeCounts, an already-prefixed token must not be prefixed again if + * any future code path hands it back into the resolver. Exercises the fallback path of + * {@link SearchRepository#resolveIndexes}, which routes unknown tokens through + * {@code prefixWithClusterAlias}. + */ + @Test + void resolveIndexesDoesNotDoublePrefixAlreadyPrefixedTokens() { + String alreadyPrefixed = + SearchRepository.resolveIndexes( + "tenant42_some_search_index", + "none", + "none", + entityIndexMap, + aliasToChildEntityTypes, + "tenant42"); + assertEquals("tenant42_some_search_index", alreadyPrefixed); + + String mixed = + SearchRepository.resolveIndexes( + "tenant42_some_search_index,topic_search_index", + "none", + "none", + entityIndexMap, + aliasToChildEntityTypes, + "tenant42"); + List tokens = Arrays.asList(mixed.split(",")); + assertTrue( + tokens.contains("tenant42_some_search_index"), + "Already-prefixed token must not be re-prefixed: " + mixed); + assertTrue( + tokens.contains("tenant42_topic_search_index"), + "Unprefixed token must be prefixed exactly once: " + mixed); + } + + /** + * Empty tokens from inputs like {@code "table,"} (trailing comma) or {@code ","} must not + * materialize as bare-prefixed index targets such as {@code "tenant42_"} — those would 404 in + * a confusing way at the ES boundary. + */ + @Test + void resolveIndexesDropsEmptyTokensFromStrayCommas() { + String trailing = + SearchRepository.resolveIndexes( + "table,", "none", "none", entityIndexMap, aliasToChildEntityTypes, "tenant42"); + assertEquals("tenant42_table_search_index", trailing); + + String embedded = + SearchRepository.resolveIndexes( + "table, ,topic", "none", "none", entityIndexMap, aliasToChildEntityTypes, "tenant42"); + List tokens = Arrays.asList(embedded.split(",")); + assertTrue(tokens.contains("tenant42_table_search_index")); + assertTrue(tokens.contains("tenant42_topic_search_index")); + assertFalse( + tokens.contains("tenant42_"), + "Bare cluster prefix must not be emitted from empty tokens: " + embedded); + + String allEmpty = + SearchRepository.resolveIndexes( + ", ,", "none", "none", entityIndexMap, aliasToChildEntityTypes, "tenant42"); + assertEquals(", ,", allEmpty); + assertFalse( + allEmpty.isEmpty(), "All-empty input must not collapse to an empty string: " + allEmpty); + } + + @Test + void buildReverseMapMatchesEveryEntityWithItsDeclaredParents() { + for (Map.Entry entry : entityIndexMap.entrySet()) { + List parents = entry.getValue().getParentAliases(); + if (parents == null) { + continue; + } + for (String parentAlias : parents) { + List reverseChildren = aliasToChildEntityTypes.get(parentAlias); + assertTrue( + reverseChildren != null && reverseChildren.contains(entry.getKey()), + entry.getKey() + + " declares " + + parentAlias + + " as a parent, so the reverse map must list it as a child"); + } + } + } +} diff --git a/openmetadata-spec/src/main/resources/json/schema/api/search/previewSearchRequest.json b/openmetadata-spec/src/main/resources/json/schema/api/search/previewSearchRequest.json index a7e1c75c11ad..22a4e0b117d9 100644 --- a/openmetadata-spec/src/main/resources/json/schema/api/search/previewSearchRequest.json +++ b/openmetadata-spec/src/main/resources/json/schema/api/search/previewSearchRequest.json @@ -58,6 +58,16 @@ "explain": { "type": "boolean", "default": false + }, + "fetchParentsAliases": { + "description": "Selective expansion toward parent aliases. Pass `*`/`all` for every parent, `none` (or empty) for none, or a comma-separated list of entity types. Defaults to `none`.", + "type": "string", + "default": "none" + }, + "fetchChildAliases": { + "description": "Selective expansion toward child aliases. Pass `*`/`all` for every child, `none` (or empty) for none, or a comma-separated list of entity types. Defaults to `none`.", + "type": "string", + "default": "none" } } } \ No newline at end of file diff --git a/openmetadata-spec/src/main/resources/json/schema/search/aggregationRequest.json b/openmetadata-spec/src/main/resources/json/schema/search/aggregationRequest.json index 456f52a7efea..973d00093426 100644 --- a/openmetadata-spec/src/main/resources/json/schema/search/aggregationRequest.json +++ b/openmetadata-spec/src/main/resources/json/schema/search/aggregationRequest.json @@ -73,6 +73,16 @@ "sortField": "_doc", "sortOrder": "asc" } + }, + "fetchParentsAliases": { + "description": "Selective expansion toward parent aliases. Pass `*`/`all` for every parent, `none` (or empty) for none, or a comma-separated list of entity types. Defaults to `none`.", + "type": "string", + "default": "none" + }, + "fetchChildAliases": { + "description": "Selective expansion toward child aliases. Pass `*`/`all` for every child, `none` (or empty) for none, or a comma-separated list of entity types. Defaults to `none`.", + "type": "string", + "default": "none" } }, "required": ["fieldName"], diff --git a/openmetadata-spec/src/main/resources/json/schema/search/searchRequest.json b/openmetadata-spec/src/main/resources/json/schema/search/searchRequest.json index 470503f3c3f3..af7164e88426 100644 --- a/openmetadata-spec/src/main/resources/json/schema/search/searchRequest.json +++ b/openmetadata-spec/src/main/resources/json/schema/search/searchRequest.json @@ -109,6 +109,16 @@ "description": "Include aggregations in the search response. Defaults to true. Set to false to skip aggregations for faster response times when only search results are needed.", "type": "boolean", "default": true + }, + "fetchParentsAliases": { + "description": "Selective expansion of the requested index toward its parent aliases declared in indexMapping.json. Pass `*` (or `all`) to include every parent, `none` (or empty) to include none, or a comma-separated list of specific entity types (e.g. `database,databaseSchema`) to include only those. Defaults to `none`.", + "type": "string", + "default": "none" + }, + "fetchChildAliases": { + "description": "Selective expansion of the requested index toward its child aliases (entities that list this alias in their parentAliases). Pass `*` (or `all`) for every child, `none` (or empty) for none, or a comma-separated list of specific entity types (e.g. `tableColumn`) to include only those. Defaults to `none` so a query for `index=table` is scoped to table documents and does not bleed in column documents from indexes that share the alias.", + "type": "string", + "default": "none" } }, "additionalProperties": false diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/search/previewSearchRequest.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/search/previewSearchRequest.ts index 640fc7efb079..c9b1e2a88d94 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/api/search/previewSearchRequest.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/search/previewSearchRequest.ts @@ -14,8 +14,18 @@ * Preview Search Results */ export interface PreviewSearchRequest { - explain?: boolean; - fetchSource?: boolean; + explain?: boolean; + /** + * Selective expansion toward child aliases. Pass `*`/`all` for every child, `none` (or + * empty) for none, or a comma-separated list of entity types. Defaults to `none`. + */ + fetchChildAliases?: string; + /** + * Selective expansion toward parent aliases. Pass `*`/`all` for every parent, `none` (or + * empty) for none, or a comma-separated list of entity types. Defaults to `none`. + */ + fetchParentsAliases?: string; + fetchSource?: boolean; /** * Pagination start index. */ diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/search/aggregationRequest.ts b/openmetadata-ui/src/main/resources/ui/src/generated/search/aggregationRequest.ts index 55201902383a..9c8a563fbdb0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/search/aggregationRequest.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/search/aggregationRequest.ts @@ -18,6 +18,16 @@ export interface AggregationRequest { * Whether to include deleted documents. */ deleted?: boolean; + /** + * Selective expansion toward child aliases. Pass `*`/`all` for every child, `none` (or + * empty) for none, or a comma-separated list of entity types. Defaults to `none`. + */ + fetchChildAliases?: string; + /** + * Selective expansion toward parent aliases. Pass `*`/`all` for every parent, `none` (or + * empty) for none, or a comma-separated list of entity types. Defaults to `none`. + */ + fetchParentsAliases?: string; /** * Field name to aggregate on (typically a keyword field like service.displayName.keyword). */ diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/search/searchRequest.ts b/openmetadata-ui/src/main/resources/ui/src/generated/search/searchRequest.ts index 826f6ed17c83..8e660b723b34 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/search/searchRequest.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/search/searchRequest.ts @@ -35,6 +35,21 @@ export interface SearchRequest { * Explain the results of the query. Defaults to false. Only for debugging purposes. */ explain?: boolean; + /** + * Selective expansion of the requested index toward its child aliases (entities that list + * this alias in their parentAliases). Pass `*` (or `all`) for every child, `none` (or + * empty) for none, or a comma-separated list of specific entity types (e.g. `tableColumn`) + * to include only those. Defaults to `none` so a query for `index=table` is scoped to table + * documents and does not bleed in column documents from indexes that share the alias. + */ + fetchChildAliases?: string; + /** + * Selective expansion of the requested index toward its parent aliases declared in + * indexMapping.json. Pass `*` (or `all`) to include every parent, `none` (or empty) to + * include none, or a comma-separated list of specific entity types (e.g. + * `database,databaseSchema`) to include only those. Defaults to `none`. + */ + fetchParentsAliases?: string; /** * Get document body for each hit */