Skip to content

Commit 29c7ecb

Browse files
authored
Fix foreach JSON array type coercion (opensearch-project#5637)
Signed-off-by: Songkan Tang <songkant@amazon.com>
1 parent e5a0bc3 commit 29c7ecb

7 files changed

Lines changed: 41 additions & 18 deletions

File tree

‎core/src/main/java/org/opensearch/sql/calcite/ForeachPlanner.java‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -432,9 +432,9 @@ private UnresolvedExpression asArrayExpression(
432432
* VARCHAR.
433433
*
434434
* <p>For {@code json_array()} calls and string literals the content is visible at plan time;
435-
* mixed content is rejected. For opaque expressions — typically a field holding JSON text, the
436-
* primary Splunk use of json_array mode — content is unknowable, so infer from usage: an item
437-
* placeholder consumed by arithmetic means numeric elements, anything else means strings.
435+
* mixed content is rejected. For opaque expressions, typically a field holding JSON text, content
436+
* is unknowable, so infer from usage: an item placeholder consumed by arithmetic means numeric
437+
* elements, anything else means strings.
438438
*/
439439
private SqlTypeName jsonElementType(
440440
UnresolvedExpression collection, CalcitePlanContext context, Foreach node) {

‎core/src/main/java/org/opensearch/sql/expression/function/jsonUDF/ForeachJsonArrayFunctionImpl.java‎

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import com.google.gson.JsonArray;
1111
import com.google.gson.JsonElement;
1212
import com.google.gson.JsonSyntaxException;
13-
import java.math.BigDecimal;
1413
import java.util.List;
1514
import java.util.stream.StreamSupport;
1615
import org.apache.calcite.adapter.enumerable.NotNullImplementor;
@@ -20,6 +19,7 @@
2019
import org.apache.calcite.linq4j.tree.Expressions;
2120
import org.apache.calcite.linq4j.tree.Types;
2221
import org.apache.calcite.rex.RexCall;
22+
import org.apache.calcite.runtime.SqlFunctions;
2323
import org.apache.calcite.sql.type.SqlReturnTypeInference;
2424
import org.apache.calcite.sql.type.SqlTypeName;
2525
import org.apache.calcite.sql.type.SqlTypeUtil;
@@ -97,14 +97,19 @@ private static Object cast(Object value, SqlTypeName elementType) {
9797
if (value == null) {
9898
return null;
9999
}
100-
return switch (elementType) {
101-
case DOUBLE -> ((Number) value).doubleValue();
102-
case VARCHAR ->
103-
value instanceof List<?> || value instanceof java.util.Map<?, ?>
104-
? gson.toJson(value)
105-
: String.valueOf(value);
106-
case DECIMAL -> BigDecimal.valueOf(((Number) value).doubleValue());
107-
default -> value;
108-
};
100+
// Match Calcite SAFE_CAST semantics for runtime values whose type is unknown during planning.
101+
try {
102+
return switch (elementType) {
103+
case DOUBLE -> SqlFunctions.toDouble(value);
104+
case VARCHAR ->
105+
value instanceof List<?> || value instanceof java.util.Map<?, ?>
106+
? gson.toJson(value)
107+
: String.valueOf(value);
108+
case DECIMAL -> SqlFunctions.toBigDecimal(value);
109+
default -> value;
110+
};
111+
} catch (RuntimeException e) {
112+
return null;
113+
}
109114
}
110115
}

‎core/src/test/java/org/opensearch/sql/expression/function/CollectionUDF/ForeachFunctionImplTest.java‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ public void testMalformedJsonArrayIsEmpty() {
4040
assertEquals(List.of(), ForeachJsonArrayFunctionImpl.eval("not-json", "VARCHAR"));
4141
}
4242

43+
@Test
44+
public void testJsonArraySafelyCoercesNumericElements() {
45+
assertEquals(
46+
Arrays.asList(10.0, 20.0, null),
47+
ForeachJsonArrayFunctionImpl.eval("[10,\"20\",\"not-a-number\"]", "DOUBLE"));
48+
}
49+
4350
@Test
4451
public void testStatePreservesHeterogeneousAndNullSlots() {
4552
assertEquals(

‎docs/user/ppl/cmd/foreach.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@ Placeholders renamed via `itemstr`/`iterstr` may be written without the `<<...>>
4141
The following considerations apply when using the `foreach` command:
4242

4343
* In collection modes, the bracketed `eval` acts as an accumulator: each target field must already exist (typically initialized with a preceding `eval`), and the expressions are applied once per element. Multiple assignments run from left to right, so a later assignment in the same iteration sees an earlier assignment's updated value.
44-
* In `json_array` mode the element type is inferred: a `json_array(...)` call or JSON string literal is inspected at plan time; for a field holding JSON text, elements are treated as numbers when `<<ITEM>>` is used in arithmetic and as strings otherwise. Mixed string/number JSON arrays are rejected.
44+
* In `json_array` mode the element type is inferred: a `json_array(...)` call or JSON string literal is inspected at plan time; for a field holding JSON text, elements are treated as numbers when `<<ITEM>>` is used in arithmetic and as strings otherwise. Plan-time arrays with mixed string/number elements are rejected. When numeric use is inferred for field-backed JSON text, elements that cannot be converted to numbers evaluate to `null`.
4545
* Placeholders are also substituted inside string literals. For example, `eval <<FIELD>> = '<<FIELD>>'` replaces each selected field value with that field's name.
46-
* As in Splunk, `multivalue` mode applied to a non-array value and `json_array` mode applied to a native array are no-ops. A field whose mapping is scalar cannot be identified as multivalue at plan time even when a document stores several values, so `multivalue` mode also no-ops for that mapping.
46+
* `multivalue` mode applied to a non-array value and `json_array` mode applied to a native array are no-ops. A field whose mapping is scalar cannot be identified as multivalue at plan time even when a document stores several values, so `multivalue` mode also no-ops for that mapping.
4747

4848
## Example 1: Apply the same calculation to multiple fields
4949

‎docs/user/ppl/index.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ source=accounts
4343
| [fields command](cmd/fields.md) | 1.0 | stable (since 1.0) | Keep or remove fields from the search result. |
4444
| [rename command](cmd/rename.md) | 1.0 | stable (since 1.0) | Rename one or more fields in the search result. |
4545
| [eval command](cmd/eval.md) | 1.0 | stable (since 1.0) | Evaluate an expression and append the result to the search result. |
46+
| [foreach command](cmd/foreach.md) | 3.8 | experimental (since 3.8) | Run a templated evaluation for each selected field or collection element. |
4647
| [convert command](cmd/convert.md) | 3.5 | experimental (since 3.5) | Transform field values to numeric values using specialized conversion functions. |
4748
| [replace command](cmd/replace.md) | 3.4 | experimental (since 3.4) | Replace text in one or more fields in the search result |
4849
| [fillnull command](cmd/fillnull.md) | 3.0 | experimental (since 3.0) | Fill null with provided value in one or more fields in the search result. |

‎integ-test/src/test/java/org/opensearch/sql/calcite/CalciteNoPushdownIT.java‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
CalciteExpandCommandIT.class,
3939
CalciteFieldFormatCommandIT.class,
4040
CalciteForeachCommandIT.class,
41+
ForeachFieldJsonIT.class,
4142
CalciteFieldsCommandIT.class,
4243
CalciteFillNullCommandIT.class,
4344
CalciteFlattenCommandIT.class,

‎integ-test/src/test/java/org/opensearch/sql/calcite/remote/ForeachFieldJsonIT.java‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import org.opensearch.sql.legacy.TestUtils;
1818
import org.opensearch.sql.ppl.PPLIntegTestCase;
1919

20-
/** Foreach collection modes over index fields (Splunk-parity scenarios). */
20+
/** Foreach collection modes over index fields. */
2121
public class ForeachFieldJsonIT extends PPLIntegTestCase {
2222

2323
@Override
@@ -42,7 +42,6 @@ public void init() throws Exception {
4242

4343
@Test
4444
public void testJsonArrayModeOnFieldWithNumericContent() throws IOException {
45-
// Splunk: field holding "[10,20,30]" with foreach mode=json_array sums to 60.
4645
JSONObject result =
4746
executeQuery(
4847
"source=test_foreach_field2 | eval total = 0 | foreach mode=json_array jsonfield ["
@@ -81,6 +80,16 @@ public void testJsonArrayModeOnFieldWithStringContent() throws IOException {
8180
verifyDataRows(result, rows("ab"));
8281
}
8382

83+
@Test
84+
public void testJsonArrayFieldSafelyCoercesNonNumericItems() throws IOException {
85+
JSONObject result =
86+
executeQuery(
87+
"source=test_foreach_field2 | eval total = 0 | foreach mode=json_array jsonstrs ["
88+
+ " eval total = total + <<ITEM>> ] | fields total");
89+
verifySchema(result, schema("total", "double"));
90+
verifyDataRows(result, rows((Object) null));
91+
}
92+
8493
/**
8594
* Native OpenSearch array fields (a long field holding [1,2,3]) are typed as scalar BIGINT at
8695
* plan time because OpenSearch mappings do not distinguish scalars from arrays. foreach
@@ -111,7 +120,7 @@ public void testNestedFieldMultivalueIterates() throws IOException {
111120
verifyDataRows(result, rows(2));
112121
}
113122

114-
/** Splunk silently no-ops when a mode is fed the wrong collection shape. */
123+
/** Collection modes are no-ops when the input has a different collection shape. */
115124
@Test
116125
public void testJsonArrayModeOnRealArrayIsNoOp() throws IOException {
117126
JSONObject result =

0 commit comments

Comments
 (0)