Skip to content

Fix 500s from in, between, case and coalesce on object and nested fields - #5870

Merged
ahkcs merged 2 commits into
opensearch-project:mainfrom
cnoramut:fix/5856-in-between-case-object-type-check
Oct 19, 2026
Merged

ahkcs merged 2 commits into
opensearch-project:mainfrom
cnoramut:fix/5856-in-between-case-object-type-check

Conversation

@cnoramut

@cnoramut cnoramut commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Description

where f in ('x', 'y'), where f between 'a' and 'z' and eval x = case(host = 'a', f else 'z') return 500 when f is an object or nested field, with Cannot cast "java.lang.String" to "java.util.Map". coalesce(f, 'z') returns 500 with a ClassCastException once a row has f missing.

Root cause. Calcite 1.42 added two branches to SqlTypeUtil.canCastFrom that accept a string as the source of a cast to MAP or ARRAY.

} else if (fromType.getFamily() == SqlTypeFamily.CHARACTER
    || fromType.getSqlTypeName() == SqlTypeName.NULL) {
  // Cast from NULL or string to map is legal
  return true;
}

leastRestrictiveByCast relies on that check, so a string and an object or nested field now get the container as their common type. visitIn, visitBetween and visitCase reject a query only when no common type exists. They now cast the string to a map, and the generated code fails to compile. EnhancedCoalesceFunction takes its return type from the same call, so it declares a map column and then puts the string in it.

Fix. OpenSearchTypeFactory.leastRestrictive returns null when a string type meets an object or array type, which is the answer Calcite 1.41 gave, so the three guards return their 400s again and coalesce falls back to a string column. That fallback is now nullable unless an operand is NOT NULL, since a NOT NULL fallback let Calcite fold where isnull(x) to false for x = coalesce(obj, n) on main.

Before, source=objs | where f in ('x', 'y') | fields host, HTTP 500

"reason":  "Internal error while compiling the query plan."
"details": "Line 3, Column 87: Cannot cast \"java.lang.String\" to \"java.util.Map\""
"type":    "RuntimeException"

After, the same query, HTTP 400

"context": {"stage": "analyzing", ...}
"details": "In expression types are incompatible: fields type STRUCT, values type [STRING, STRING]"
"type":    "SemanticCheckException"

Notes

  1. Test posture - OpenSearchTypeFactoryTest pins the type answers, and issues/5856.yml runs each query end to end. Without the fix, 3 unit tests and 8 of the 9 YAML cases fail, and the keyword control passes.
  2. Why the type factory and not the three call sites - coalesce never goes through CalciteRexNodeVisitor, and a stricter SqlTypeMappingRule cannot help, since the new branches return before the rule is read. The same method already special-cases VARBINARY, and an ANY operand is left to Calcite.
  3. Behaviour change against 3.8 and 3.9 - Both ship Calcite 1.42, so coalesce(obj, 'z') returns a struct while every row has obj and 500 once a row lacks it, and array(obj, 'z') returns an array that mvindex fails on. This branch returns what 3.7 and main before the bump in Bump Apache Calcite 1.41.0 -> 1.42.0 (CVE-2026-46718) #5619 returned, a string column that renders an object as {a=x}, the same as coalesce(obj, 1) on main today.

These queries ran on 3a837689c, the last main commit before the Calcite 1.42 bump in #5619, on main at 779bdf5d2, and on this branch at d0063db46, each built from source with Calcite enabled. This branch returns a byte-identical response to the first column on every row. The bump commit 6a07689a7 gives the same status codes as 779bdf5d2. The nullable fallback came after d0063db46. By source reading it changes the declared nullability and not the values.

Query on an object or nested field 3a837689c, before #5619 779bdf5d2 This branch
where obj in ('x', 'y') 400 500 400
where obj between 'a' and 'z' 400 500 400
case(host = 'a', obj else 'z') 400 500 400
where items in ('x', 'y'), nested 400 400 AssertionError 400
coalesce(obj, 'z'), a row lacks obj 200 string, {a=x, b=1} and z 500 200 string, {a=x, b=1} and z
coalesce(obj, 'z'), every row has obj 200 string, {a=x, b=1} 200 struct, {"a": "x", "b": 1} 200 string, {a=x, b=1}
coalesce(items, 'z'), a row lacks items 200 string, [{a=x}] and z 500 200 string, [{a=x}] and z
coalesce(obj, 1) 200 string, {a=x, b=1} and 1 200 string, {a=x, b=1} and 1 200 string, {a=x, b=1} and 1
array(obj, 'z') 400 200 array 400
array(obj, 'z') then mvindex(x, 1) 400 500 400
where host in ('a', 'c'), keyword control 200 200 200

The nested in row hits an engine assertion first on these assertion-enabled nodes and returns 400 AssertionError on main. Without assertions the same query returns 500 on main, reproduced at 866dafcbd.

  1. Not covered - lookup with append on a string field and an object field of the same name still returns 500, now while analyzing. union of an object with a string returns 500 from SchemaUnifier, which never asks leastRestrictive.

Related Issues

Resolves #5856

Testing

A 105-query sweep at 866dafcbd changed only the 500s and behaviour changes listed above and the array calls, and left if, ifnull, mvappend, append, multisearch and the keyword, long and temporal paths byte-identical. The suites below ran on the final tree.

Suite Result
:core:test, :ppl:test, :opensearch:test 9295 tests, 0 failures, 39 skipped
CalcitePPLEnhancedCoalesceIT, CalcitePPLConditionBuiltinFunctionIT, CalciteExplainIT, CalciteNoMvCommandIT, CalcitePPLLookupIT and CalciteFillNullCommandIT, with their no-pushdown runs 717 tests, 0 failures, 92 skipped
:integ-test:yamlRestTest 262 tests, 0 failures

Check List

  • New functionality includes testing.
  • New functionality has been documented.
  • New functionality has javadoc added.
  • New functionality has a user manual doc added.
  • New PPL command checklist all confirmed.
  • API changes companion pull request created.
  • Commits are signed per the DCO using --signoff or -s.
  • Public documentation issue/PR created.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.

Calcite 1.42 allows a cast from a string to MAP or ARRAY, so
leastRestrictive gave a string literal and an object or nested field
the container as their common type. IN, BETWEEN and CASE then cast the
string to a map or list, and the generated code failed to compile with
HTTP 500. COALESCE declared a map column and failed with a
ClassCastException when it took its string fallback.

OpenSearchTypeFactory.leastRestrictive now returns null when a VARCHAR
type meets an object or array type, which is what Calcite 1.41
returned. IN, BETWEEN and CASE return their existing 400 while
analyzing. COALESCE returns a string column, as it already does for an
object and a number. array() of an object and a string now fails while
analyzing, as array() of an object and a number already does.

Signed-off-by: Chayanin Noramuttha <cnoramut@amazon.com>
@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 10b713d)

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 No multiple PR themes
⚡ No major issues detected

@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 10b713d
Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix inverted nullability check logic

The nullability logic is inverted. The result should be nullable if ANY operand is
nullable (not all), matching standard COALESCE semantics where the result is
non-null only when all operands are non-null.

core/src/main/java/org/opensearch/sql/expression/function/udf/condition/EnhancedCoalesceFunction.java [105]

-boolean nullable = operandTypes.stream().allMatch(RelDataType::isNullable);
+boolean nullable = operandTypes.stream().anyMatch(RelDataType::isNullable);
Suggestion importance[1-10]: 9

__

Why: This is a critical logic error. The current code uses allMatch which makes the result nullable only when ALL operands are nullable, but COALESCE should be nullable when ANY operand is nullable (it returns the first non-null value). This directly contradicts standard SQL semantics and the comment mentions matching Calcite's COALESCE behavior.

High
Add null check before type inspection

The check for VARCHAR-container mixing should occur after verifying all types are
non-null to avoid potential NullPointerException. If any type in the list is null,
accessing its properties in mixesVarcharWithContainer could fail.

core/src/main/java/org/opensearch/sql/calcite/utils/OpenSearchTypeFactory.java [471-473]

+if (types.stream().anyMatch(t -> t == null)) {
+  return super.leastRestrictive(types);
+}
 if (mixesVarcharWithContainer(types)) {
   return null;
 }
Suggestion importance[1-10]: 2

__

Why: While adding null checks is generally good practice, the leastRestrictive method in Calcite's type system typically doesn't pass null types in the list. The mixesVarcharWithContainer method safely accesses getSqlTypeName() which would fail immediately if a type were null, making this a defensive but likely unnecessary check.

Low

Previous suggestions

Suggestions up to commit d0063db
CategorySuggestion                                                                                                                                    Impact
General
Explicitly handle null container type

The method should handle null values from getContainerTypeName(t) more explicitly
before using it in the boolean OR operation. While the current code works,
explicitly checking for non-null would make the intent clearer and prevent potential
issues if the method behavior changes.

core/src/main/java/org/opensearch/sql/calcite/utils/OpenSearchTypeFactory.java [504-516]

 private static boolean mixesVarcharWithContainer(List<RelDataType> types) {
   boolean hasVarchar = false;
   boolean hasContainer = false;
   for (RelDataType t : types) {
     SqlTypeName name = t.getSqlTypeName();
     if (name == SqlTypeName.ANY) {
       return false;
     }
     hasVarchar |= name == SqlTypeName.VARCHAR || name == SqlTypeName.CHAR;
-    hasContainer |= getContainerTypeName(t) != null;
+    String containerType = getContainerTypeName(t);
+    hasContainer |= containerType != null;
   }
   return hasVarchar && hasContainer;
 }
Suggestion importance[1-10]: 3

__

Why: The suggestion improves code clarity by extracting getContainerTypeName(t) into a variable, making the null check more explicit. However, the existing code already handles null correctly since null != null is false, so this is a minor readability improvement with no functional impact.

Low

@codecov

codecov Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 63.15789% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 63.88%. Comparing base (0026279) to head (10b713d).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
...nction/udf/condition/EnhancedCoalesceFunction.java 0.00% 7 Missing ⚠️

❌ Your project check has failed because the head coverage (63.88%) is below the target coverage (99.00%). You can increase the head coverage or adjust the target coverage.

Additional details and impacted files
@@             Coverage Diff              @@
##               main    #5870      +/-   ##
============================================
+ Coverage     63.80%   63.88%   +0.08%     
- Complexity     9070     9107      +37     
============================================
  Files           962      962              
  Lines         40989    41063      +74     
  Branches       4660     4675      +15     
============================================
+ Hits          26152    26235      +83     
+ Misses        13960    13950      -10     
- Partials        877      878       +1     
Flag Coverage Δ
sql-engine 63.88% <63.15%> (+0.08%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

// string value converts at run time. Returning null restores the Calcite 1.41 answer, so a
// string type and an object or array type have no common type.
if (mixesVarcharWithContainer(types)) {
return null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This sends coalesce(obj, host) to EnhancedCoalesceFunction's fallback, createSqlType(VARCHAR), which is NOT NULL. When both fields are missing the value is null, but Calcite assumes it can't be, so where isnull(x) gets folded to false. Could the fallback be nullable when every operand is?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. The VARCHAR fallback in EnhancedCoalesceFunction is now nullable unless some operand is NOT NULL, the same rule Calcite's own COALESCE uses, so coalesce(obj, 'z') stays NOT NULL. The fallback was already NOT NULL on main, where where isnull(x) on coalesce(obj, n) with a number field dropped the rows missing both fields, and this fixes that path too. The new case in issues/5856.yml runs both queries against a document with no fields. It fails on main at coalesce(obj, n) and on the previous head of this branch at coalesce(obj, host).

query: "source=object_field_string_operand_5856 | eval x = coalesce(obj, 'z') | sort host | fields x"
- match: {"total": 2}
- match: {"schema": [{"name": "x", "type": "string"}]}
- match: {"datarows": [["{a=x}"], ["z"]]}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

On 3.8 and 3.9, when every row has obj, this returned a struct ({"a": "x"}). Now it returns the string {a=x}, which is Java's Map.toString and isn't JSON. The table lists the struct → string change but not the new value format. Worth noting, or should objects render as JSON here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The table now shows the value as well as the type, {"a": "x", "b": 1} on 3.8, 3.9 and main against {a=x, b=1} on this branch. The struct came in with the Calcite 1.42 bump alone. 3.7 and main before the bump returned the same {a=x, b=1} string, and coalesce(obj, 1) still returns it on main. A struct column cannot hold the 'z' fallback either, which is why the query returns 500 once a row lacks obj. I kept Map.toString because JSON rendering would also change coalesce(obj, 1) and every other shape that prints {a=x} today, and I can switch it to JSON here if you would prefer that.

When leastRestrictive finds no common type, EnhancedCoalesceFunction
falls back to VARCHAR. That type was NOT NULL, so Calcite folded
isnull(x) on the result to false and dropped the rows where every
operand was null. An object and a number already took this path on
main, and the previous commit sent an object and a string down it too.

The fallback is now nullable unless some operand is NOT NULL, the rule
Calcite's own COALESCE uses, so coalesce(obj, 'z') stays NOT NULL.

Signed-off-by: Chayanin Noramuttha <cnoramut@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 10b713d

@ahkcs
ahkcs merged commit a12e4c0 into opensearch-project:main Oct 19, 2026
46 of 48 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] in, between, case and coalesce on an object or nested field return 500

2 participants