From e533a7895e8b26a89e58aac5d6acc5dda338c7ac Mon Sep 17 00:00:00 2001 From: Jialiang Liang Date: Wed, 10 Jun 2026 18:10:58 +0000 Subject: [PATCH 1/4] Fix opaque NullPointerException for unresolvable alias-type field path When a mapping contains a field of "type": "alias" whose "path" points to a target absent from the flattened mapping (a text multi-field such as field.keyword, or a removed/renamed field), validateAliasType passed a null target into the OpenSearchAliasType constructor, which dereferenced it at super(type.getExprCoreType()) and surfaced an opaque NullPointerException. Guard the null target and throw a SemanticCheckException naming the alias field and its unresolved path. SemanticCheckException extends QueryEngineException, so JdbcResponseFormatter maps it to HTTP 400 (client error) rather than the misleading 500 a generic exception would produce. Add unit tests covering the .keyword multi-field and missing-field cases. Fixes #5535 Signed-off-by: Jialiang Liang --- .../data/type/OpenSearchDataType.java | 13 ++++++- .../data/type/OpenSearchDataTypeTest.java | 38 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/opensearch/src/main/java/org/opensearch/sql/opensearch/data/type/OpenSearchDataType.java b/opensearch/src/main/java/org/opensearch/sql/opensearch/data/type/OpenSearchDataType.java index 79d49a143de..00f2a5a7f56 100644 --- a/opensearch/src/main/java/org/opensearch/sql/opensearch/data/type/OpenSearchDataType.java +++ b/opensearch/src/main/java/org/opensearch/sql/opensearch/data/type/OpenSearchDataType.java @@ -16,6 +16,7 @@ import org.apache.commons.lang3.EnumUtils; import org.opensearch.sql.data.type.ExprCoreType; import org.opensearch.sql.data.type.ExprType; +import org.opensearch.sql.exception.SemanticCheckException; /** The extension of ExprType in OpenSearch. */ @EqualsAndHashCode @@ -297,7 +298,17 @@ private static void validateAliasType(Map result) { (key, value) -> { if (value instanceof OpenSearchAliasType && value.getOriginalPath().isPresent()) { String originalPath = value.getOriginalPath().get(); - result.put(key, new OpenSearchAliasType(originalPath, result.get(originalPath))); + OpenSearchDataType target = result.get(originalPath); + if (target == null) { + throw new SemanticCheckException( + String.format( + "Alias field [%s] refers to unresolved path [%s]. The alias path must point" + + " to an existing field in the mapping; a text multi-field (e.g." + + " \"%s.keyword\") or a removed/renamed field is not a valid alias" + + " target.", + key, originalPath, originalPath)); + } + result.put(key, new OpenSearchAliasType(originalPath, target)); } }); } diff --git a/opensearch/src/test/java/org/opensearch/sql/opensearch/data/type/OpenSearchDataTypeTest.java b/opensearch/src/test/java/org/opensearch/sql/opensearch/data/type/OpenSearchDataTypeTest.java index 40985130c52..51dc53637c1 100644 --- a/opensearch/src/test/java/org/opensearch/sql/opensearch/data/type/OpenSearchDataTypeTest.java +++ b/opensearch/src/test/java/org/opensearch/sql/opensearch/data/type/OpenSearchDataTypeTest.java @@ -43,6 +43,7 @@ import org.junit.jupiter.params.provider.MethodSource; import org.opensearch.sql.data.type.ExprCoreType; import org.opensearch.sql.data.type.ExprType; +import org.opensearch.sql.exception.SemanticCheckException; @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) class OpenSearchDataTypeTest { @@ -483,6 +484,43 @@ public void test_AliasType() { () -> assertEquals("original_path2", aliasTypeOnDouble.getOriginalPath().orElseThrow())); } + @Test + public void traverseAndFlatten_alias_to_unresolvable_path_throws_descriptive_error() { + // An alias whose path targets a text multi-field (e.g. "source.keyword"). Multi-fields are + // stored under OpenSearchTextType.fields, not properties, so they are never added to the + // flattened mapping and the alias target resolves to null. Previously this surfaced as an + // opaque NullPointerException (issue #5535). + Map keywordAliasTree = + Map.of( + "source", textKeywordType, + "source_alias", + new OpenSearchAliasType("source.keyword", OpenSearchDataType.of(MappingType.Invalid))); + SemanticCheckException keywordException = + assertThrows( + SemanticCheckException.class, + () -> OpenSearchDataType.traverseAndFlatten(keywordAliasTree)); + assertEquals( + "Alias field [source_alias] refers to unresolved path [source.keyword]. The alias path" + + " must point to an existing field in the mapping; a text multi-field (e.g." + + " \"source.keyword.keyword\") or a removed/renamed field is not a valid alias target.", + keywordException.getMessage()); + + // An alias whose path targets a field that does not exist (e.g. renamed/removed). + Map missingFieldTree = + Map.of( + "col1", textType, + "col_alias", new OpenSearchAliasType("missing", OpenSearchDataType.of(MappingType.Invalid))); + SemanticCheckException missingException = + assertThrows( + SemanticCheckException.class, + () -> OpenSearchDataType.traverseAndFlatten(missingFieldTree)); + assertEquals( + "Alias field [col_alias] refers to unresolved path [missing]. The alias path must point to" + + " an existing field in the mapping; a text multi-field (e.g. \"missing.keyword\") or a" + + " removed/renamed field is not a valid alias target.", + missingException.getMessage()); + } + @Test public void test_parseMapping_on_AliasType() { Map indexMapping1 = From 0d76d1c436dea3e3626d18c11282baca4dc85466 Mon Sep 17 00:00:00 2001 From: Jialiang Liang Date: Wed, 10 Jun 2026 18:18:30 +0000 Subject: [PATCH 2/4] Add integration test and trim unit-test comments for alias path fix Add a QueryValidationIT case asserting that SELECT * over an index whose alias field targets a text multi-field (source.keyword) returns a 400 SemanticCheckException with the descriptive message. An alias pointing at a truly missing field is rejected by OpenSearch at index-creation time, so it is not reachable through the SQL plugin and is covered by the unit test only. Shorten the unit-test comments and drop inline issue references. Signed-off-by: Jialiang Liang --- .../opensearch/sql/sql/QueryValidationIT.java | 27 +++++++++++++++++++ .../data/type/OpenSearchDataTypeTest.java | 7 ++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/integ-test/src/test/java/org/opensearch/sql/sql/QueryValidationIT.java b/integ-test/src/test/java/org/opensearch/sql/sql/QueryValidationIT.java index 3373b46303c..6220092b1d0 100644 --- a/integ-test/src/test/java/org/opensearch/sql/sql/QueryValidationIT.java +++ b/integ-test/src/test/java/org/opensearch/sql/sql/QueryValidationIT.java @@ -103,6 +103,33 @@ private static void whenExecuteMalformedPayload() throws IOException { client().performRequest(request); } + // An alias field whose path targets a text multi-field (e.g. "source.keyword") must fail with a + // descriptive client error rather than an opaque NullPointerException. + @Test + public void testAliasToKeywordMultiFieldFailsWithBadRequest() throws IOException { + String index = "test_alias_unresolved_keyword"; + createIndexWithMapping( + index, + "{ \"properties\": {" + + " \"source\": { \"type\": \"text\", \"fields\": { \"keyword\": { \"type\":" + + " \"keyword\" } } }," + + " \"source_alias\": { \"type\": \"alias\", \"path\": \"source.keyword\" } } }"); + + expectResponseException() + .hasStatusCode(BAD_REQUEST) + .hasErrorType("SemanticCheckException") + .containsMessage( + "Alias field [source_alias] refers to unresolved path [source.keyword]") + .whenExecute(String.format(Locale.ROOT, "SELECT * FROM %s", index)); + } + + private static void createIndexWithMapping(String indexName, String mapping) + throws IOException { + Request request = new Request("PUT", "/" + indexName); + request.setJsonEntity(String.format(Locale.ROOT, "{ \"mappings\": %s }", mapping)); + client().performRequest(request); + } + public ResponseExceptionAssertion expectResponseException() { return new ResponseExceptionAssertion(exceptionRule); } diff --git a/opensearch/src/test/java/org/opensearch/sql/opensearch/data/type/OpenSearchDataTypeTest.java b/opensearch/src/test/java/org/opensearch/sql/opensearch/data/type/OpenSearchDataTypeTest.java index 51dc53637c1..00445e69c75 100644 --- a/opensearch/src/test/java/org/opensearch/sql/opensearch/data/type/OpenSearchDataTypeTest.java +++ b/opensearch/src/test/java/org/opensearch/sql/opensearch/data/type/OpenSearchDataTypeTest.java @@ -486,10 +486,7 @@ public void test_AliasType() { @Test public void traverseAndFlatten_alias_to_unresolvable_path_throws_descriptive_error() { - // An alias whose path targets a text multi-field (e.g. "source.keyword"). Multi-fields are - // stored under OpenSearchTextType.fields, not properties, so they are never added to the - // flattened mapping and the alias target resolves to null. Previously this surfaced as an - // opaque NullPointerException (issue #5535). + // Alias path targets a text multi-field, which is not in the flattened mapping. Map keywordAliasTree = Map.of( "source", textKeywordType, @@ -505,7 +502,7 @@ public void traverseAndFlatten_alias_to_unresolvable_path_throws_descriptive_err + " \"source.keyword.keyword\") or a removed/renamed field is not a valid alias target.", keywordException.getMessage()); - // An alias whose path targets a field that does not exist (e.g. renamed/removed). + // Alias path targets a field that does not exist. Map missingFieldTree = Map.of( "col1", textType, From 4d1c8e3b0bb4f8880a423199e5563203d8d3a97f Mon Sep 17 00:00:00 2001 From: Jialiang Liang Date: Wed, 10 Jun 2026 18:27:09 +0000 Subject: [PATCH 3/4] Apply spotless formatting Signed-off-by: Jialiang Liang --- .../opensearch/sql/sql/QueryValidationIT.java | 6 ++--- .../data/type/OpenSearchDataTypeTest.java | 22 +++++++++++-------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/integ-test/src/test/java/org/opensearch/sql/sql/QueryValidationIT.java b/integ-test/src/test/java/org/opensearch/sql/sql/QueryValidationIT.java index 6220092b1d0..b4fedf794d1 100644 --- a/integ-test/src/test/java/org/opensearch/sql/sql/QueryValidationIT.java +++ b/integ-test/src/test/java/org/opensearch/sql/sql/QueryValidationIT.java @@ -118,13 +118,11 @@ public void testAliasToKeywordMultiFieldFailsWithBadRequest() throws IOException expectResponseException() .hasStatusCode(BAD_REQUEST) .hasErrorType("SemanticCheckException") - .containsMessage( - "Alias field [source_alias] refers to unresolved path [source.keyword]") + .containsMessage("Alias field [source_alias] refers to unresolved path [source.keyword]") .whenExecute(String.format(Locale.ROOT, "SELECT * FROM %s", index)); } - private static void createIndexWithMapping(String indexName, String mapping) - throws IOException { + private static void createIndexWithMapping(String indexName, String mapping) throws IOException { Request request = new Request("PUT", "/" + indexName); request.setJsonEntity(String.format(Locale.ROOT, "{ \"mappings\": %s }", mapping)); client().performRequest(request); diff --git a/opensearch/src/test/java/org/opensearch/sql/opensearch/data/type/OpenSearchDataTypeTest.java b/opensearch/src/test/java/org/opensearch/sql/opensearch/data/type/OpenSearchDataTypeTest.java index 00445e69c75..eb9f72901eb 100644 --- a/opensearch/src/test/java/org/opensearch/sql/opensearch/data/type/OpenSearchDataTypeTest.java +++ b/opensearch/src/test/java/org/opensearch/sql/opensearch/data/type/OpenSearchDataTypeTest.java @@ -489,32 +489,36 @@ public void traverseAndFlatten_alias_to_unresolvable_path_throws_descriptive_err // Alias path targets a text multi-field, which is not in the flattened mapping. Map keywordAliasTree = Map.of( - "source", textKeywordType, + "source", + textKeywordType, "source_alias", - new OpenSearchAliasType("source.keyword", OpenSearchDataType.of(MappingType.Invalid))); + new OpenSearchAliasType("source.keyword", OpenSearchDataType.of(MappingType.Invalid))); SemanticCheckException keywordException = assertThrows( SemanticCheckException.class, () -> OpenSearchDataType.traverseAndFlatten(keywordAliasTree)); assertEquals( - "Alias field [source_alias] refers to unresolved path [source.keyword]. The alias path" - + " must point to an existing field in the mapping; a text multi-field (e.g." - + " \"source.keyword.keyword\") or a removed/renamed field is not a valid alias target.", + "Alias field [source_alias] refers to unresolved path [source.keyword]. The alias path must" + + " point to an existing field in the mapping; a text multi-field (e.g." + + " \"source.keyword.keyword\") or a removed/renamed field is not a valid alias" + + " target.", keywordException.getMessage()); // Alias path targets a field that does not exist. Map missingFieldTree = Map.of( - "col1", textType, - "col_alias", new OpenSearchAliasType("missing", OpenSearchDataType.of(MappingType.Invalid))); + "col1", + textType, + "col_alias", + new OpenSearchAliasType("missing", OpenSearchDataType.of(MappingType.Invalid))); SemanticCheckException missingException = assertThrows( SemanticCheckException.class, () -> OpenSearchDataType.traverseAndFlatten(missingFieldTree)); assertEquals( "Alias field [col_alias] refers to unresolved path [missing]. The alias path must point to" - + " an existing field in the mapping; a text multi-field (e.g. \"missing.keyword\") or a" - + " removed/renamed field is not a valid alias target.", + + " an existing field in the mapping; a text multi-field (e.g. \"missing.keyword\") or" + + " a removed/renamed field is not a valid alias target.", missingException.getMessage()); } From 9295c925ce5df4c89b299ad40b5ee5de0e19b866 Mon Sep 17 00:00:00 2001 From: Jialiang Liang Date: Wed, 10 Jun 2026 20:00:45 +0000 Subject: [PATCH 4/4] Adopt ErrorReport for unresolvable alias path error Wrap the SemanticCheckException in an ErrorReport (the report-builder interface from #5266) so the error carries structured context as it bubbles up: FIELD_NOT_FOUND code, ANALYZING stage, a location chain, the alias field and path as context, and a fix suggestion. On the PPL/Calcite path this renders as a rich structured error; on the SQL JDBC path it still returns a clear 400 (RestSqlAction unwraps to the SemanticCheckException cause), though the JdbcResponseFormatter does not yet render the ErrorReport structure. Update the unit test to assert the ErrorReport code/stage/context/cause, the SQL IT for the ErrorReport type, and add a PPL IT asserting the structured FIELD_NOT_FOUND error in CalciteErrorReportStageIT. Signed-off-by: Jialiang Liang --- .../remote/CalciteErrorReportStageIT.java | 33 +++++++++++++ .../opensearch/sql/sql/QueryValidationIT.java | 2 +- .../data/type/OpenSearchDataType.java | 26 ++++++++--- .../data/type/OpenSearchDataTypeTest.java | 46 ++++++++++++------- 4 files changed, 82 insertions(+), 25 deletions(-) diff --git a/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalciteErrorReportStageIT.java b/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalciteErrorReportStageIT.java index f51ffabdc35..d2813f6eb17 100644 --- a/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalciteErrorReportStageIT.java +++ b/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalciteErrorReportStageIT.java @@ -11,6 +11,7 @@ import java.io.IOException; import org.json.JSONObject; import org.junit.jupiter.api.Test; +import org.opensearch.client.Request; import org.opensearch.client.ResponseException; import org.opensearch.sql.ppl.PPLIntegTestCase; @@ -214,4 +215,36 @@ public void testStageDescriptionIsUserFriendly() throws IOException { || stageDescription.toLowerCase().contains("run") || stageDescription.toLowerCase().contains("query")); } + + // An alias field whose path targets a text multi-field (e.g. "source.keyword") is not present in + // the flattened mapping. It used to surface an opaque NullPointerException; it must now report a + // structured FIELD_NOT_FOUND error with a suggestion. + @Test + public void testAliasToUnresolvablePathIncludesStructuredError() throws IOException { + String index = "test_alias_unresolved_keyword"; + Request createIndex = new Request("PUT", "/" + index); + createIndex.setJsonEntity( + "{ \"mappings\": { \"properties\": {" + + " \"source\": { \"type\": \"text\", \"fields\": { \"keyword\": { \"type\":" + + " \"keyword\" } } }," + + " \"source_alias\": { \"type\": \"alias\", \"path\": \"source.keyword\" } } } }"); + client().performRequest(createIndex); + + ResponseException exception = + assertThrows(ResponseException.class, () -> executeQuery("source=" + index)); + + JSONObject error = + new JSONObject(getResponseBody(exception.getResponse())).getJSONObject("error"); + + assertEquals("FIELD_NOT_FOUND", error.getString("code")); + assertTrue( + "Details should name the alias field and path", + error + .getString("details") + .contains("Alias field [source_alias] refers to unresolved path [source.keyword]")); + JSONObject context = error.getJSONObject("context"); + assertEquals("source_alias", context.getString("alias_field")); + assertEquals("source.keyword", context.getString("alias_path")); + assertTrue("Should include a suggestion", error.has("suggestion")); + } } diff --git a/integ-test/src/test/java/org/opensearch/sql/sql/QueryValidationIT.java b/integ-test/src/test/java/org/opensearch/sql/sql/QueryValidationIT.java index b4fedf794d1..71f16f30e43 100644 --- a/integ-test/src/test/java/org/opensearch/sql/sql/QueryValidationIT.java +++ b/integ-test/src/test/java/org/opensearch/sql/sql/QueryValidationIT.java @@ -117,7 +117,7 @@ public void testAliasToKeywordMultiFieldFailsWithBadRequest() throws IOException expectResponseException() .hasStatusCode(BAD_REQUEST) - .hasErrorType("SemanticCheckException") + .hasErrorType("ErrorReport") .containsMessage("Alias field [source_alias] refers to unresolved path [source.keyword]") .whenExecute(String.format(Locale.ROOT, "SELECT * FROM %s", index)); } diff --git a/opensearch/src/main/java/org/opensearch/sql/opensearch/data/type/OpenSearchDataType.java b/opensearch/src/main/java/org/opensearch/sql/opensearch/data/type/OpenSearchDataType.java index 00f2a5a7f56..76de0c30a08 100644 --- a/opensearch/src/main/java/org/opensearch/sql/opensearch/data/type/OpenSearchDataType.java +++ b/opensearch/src/main/java/org/opensearch/sql/opensearch/data/type/OpenSearchDataType.java @@ -14,6 +14,9 @@ import lombok.EqualsAndHashCode; import lombok.Getter; import org.apache.commons.lang3.EnumUtils; +import org.opensearch.sql.common.error.ErrorCode; +import org.opensearch.sql.common.error.ErrorReport; +import org.opensearch.sql.common.error.QueryProcessingStage; import org.opensearch.sql.data.type.ExprCoreType; import org.opensearch.sql.data.type.ExprType; import org.opensearch.sql.exception.SemanticCheckException; @@ -300,13 +303,22 @@ private static void validateAliasType(Map result) { String originalPath = value.getOriginalPath().get(); OpenSearchDataType target = result.get(originalPath); if (target == null) { - throw new SemanticCheckException( - String.format( - "Alias field [%s] refers to unresolved path [%s]. The alias path must point" - + " to an existing field in the mapping; a text multi-field (e.g." - + " \"%s.keyword\") or a removed/renamed field is not a valid alias" - + " target.", - key, originalPath, originalPath)); + throw ErrorReport.wrap( + new SemanticCheckException( + String.format( + "Alias field [%s] refers to unresolved path [%s].", + key, originalPath))) + .code(ErrorCode.FIELD_NOT_FOUND) + .stage(QueryProcessingStage.ANALYZING) + .location("while resolving alias fields in the index mapping") + .context("alias_field", key) + .context("alias_path", originalPath) + .suggestion( + "The alias path must point to an existing field in the mapping; a text" + + " multi-field (e.g. \"" + + originalPath + + ".keyword\") or a removed/renamed field is not a valid alias target.") + .build(); } result.put(key, new OpenSearchAliasType(originalPath, target)); } diff --git a/opensearch/src/test/java/org/opensearch/sql/opensearch/data/type/OpenSearchDataTypeTest.java b/opensearch/src/test/java/org/opensearch/sql/opensearch/data/type/OpenSearchDataTypeTest.java index eb9f72901eb..1479ccfb615 100644 --- a/opensearch/src/test/java/org/opensearch/sql/opensearch/data/type/OpenSearchDataTypeTest.java +++ b/opensearch/src/test/java/org/opensearch/sql/opensearch/data/type/OpenSearchDataTypeTest.java @@ -41,6 +41,9 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.MethodSource; +import org.opensearch.sql.common.error.ErrorCode; +import org.opensearch.sql.common.error.ErrorReport; +import org.opensearch.sql.common.error.QueryProcessingStage; import org.opensearch.sql.data.type.ExprCoreType; import org.opensearch.sql.data.type.ExprType; import org.opensearch.sql.exception.SemanticCheckException; @@ -493,16 +496,23 @@ public void traverseAndFlatten_alias_to_unresolvable_path_throws_descriptive_err textKeywordType, "source_alias", new OpenSearchAliasType("source.keyword", OpenSearchDataType.of(MappingType.Invalid))); - SemanticCheckException keywordException = + ErrorReport keywordError = assertThrows( - SemanticCheckException.class, - () -> OpenSearchDataType.traverseAndFlatten(keywordAliasTree)); - assertEquals( - "Alias field [source_alias] refers to unresolved path [source.keyword]. The alias path must" - + " point to an existing field in the mapping; a text multi-field (e.g." - + " \"source.keyword.keyword\") or a removed/renamed field is not a valid alias" - + " target.", - keywordException.getMessage()); + ErrorReport.class, () -> OpenSearchDataType.traverseAndFlatten(keywordAliasTree)); + assertAll( + () -> assertEquals(ErrorCode.FIELD_NOT_FOUND, keywordError.getCode()), + () -> assertEquals(QueryProcessingStage.ANALYZING, keywordError.getStage()), + () -> assertTrue(keywordError.getCause() instanceof SemanticCheckException), + () -> + assertEquals( + "Alias field [source_alias] refers to unresolved path [source.keyword].", + keywordError.getCause().getMessage()), + () -> assertEquals("source_alias", keywordError.getContext().get("alias_field")), + () -> assertEquals("source.keyword", keywordError.getContext().get("alias_path")), + () -> + assertTrue( + keywordError.getSuggestion().contains("\"source.keyword.keyword\"") + && keywordError.getSuggestion().contains("not a valid alias target"))); // Alias path targets a field that does not exist. Map missingFieldTree = @@ -511,15 +521,17 @@ public void traverseAndFlatten_alias_to_unresolvable_path_throws_descriptive_err textType, "col_alias", new OpenSearchAliasType("missing", OpenSearchDataType.of(MappingType.Invalid))); - SemanticCheckException missingException = + ErrorReport missingError = assertThrows( - SemanticCheckException.class, - () -> OpenSearchDataType.traverseAndFlatten(missingFieldTree)); - assertEquals( - "Alias field [col_alias] refers to unresolved path [missing]. The alias path must point to" - + " an existing field in the mapping; a text multi-field (e.g. \"missing.keyword\") or" - + " a removed/renamed field is not a valid alias target.", - missingException.getMessage()); + ErrorReport.class, () -> OpenSearchDataType.traverseAndFlatten(missingFieldTree)); + assertAll( + () -> assertEquals(ErrorCode.FIELD_NOT_FOUND, missingError.getCode()), + () -> assertTrue(missingError.getCause() instanceof SemanticCheckException), + () -> + assertEquals( + "Alias field [col_alias] refers to unresolved path [missing].", + missingError.getCause().getMessage()), + () -> assertEquals("missing", missingError.getContext().get("alias_path"))); } @Test