Skip to content

Commit 2380af7

Browse files
authored
Enable PPL JSON scalar functions on the analytics-engine route (opensearch-project#21513)
* [Analytics Engine] Port json_array_length to DataFusion backend First PPL json_* function wired through PPL β†’ Calcite β†’ Substrait β†’ DataFusion. Scaffolds the pattern every follow-up UDF reuses: Rust kernel + YAML signature + ScalarFunction enum entry + JsonFunctionAdapters rename + FunctionMappings.s(...) binding + STANDARD_PROJECT_OPS entry. Rust UDF (rust/src/udf/json_array_length.rs) coerces the input to Utf8, parses with serde_json, and returns Int32 to match PPL's INTEGER_FORCE_NULLABLE declaration β€” returning Int64 would leak through column-valued calls even though literal args const-fold via a narrowing CAST. Malformed / non-array / NULL input β†’ NULL, matching legacy JsonArrayLengthFunctionImpl's NullPolicy.ANY + Gson parity. ScalarFunction.CAST added to STANDARD_PROJECT_OPS so PPL's implicit CAST around a UDF call (inserted when the UDF's declared return type differs from the eval column's inferred type) doesn't fail OpenSearchProjectRule with "No backend supports scalar function [CAST]". DataFusion handles CAST natively β€” no UDF needed. STANDARD_PROJECT_OPS and scalarFunctionAdapters reshaped to one-entry- per-line (Map.ofEntries / Set.of) so parallel json_* PRs append without touching neighbour lines. Tests: * 10 Rust unit tests (flat/nested arrays, non-array, malformed, NULL, coerce_types accept/reject, arity guard, scalar-input fast path). * JsonFunctionAdaptersTests guards adapter shape + return-type preservation (BIGINT vs LOCAL_OP's INTEGER_NULLABLE). * ScalarJsonFunctionIT covers happy path, empty array, non-array object β†’ NULL, malformed β†’ NULL via /_analytics/ppl. Parity-checked against legacy SQL plugin CalcitePPLJsonBuiltinFunctionIT.testJsonArrayLength. Signed-off-by: Eric Wei <mengwei.eric@gmail.com> * [Analytics Engine] JSON: introduce jsonpath-rust parser + shared helpers Lands the parser crate + a small shared helpers module ahead of the per- function json_* UDFs. Keeping this on its own commit lets reviewers sign off on the crate choice (jsonpath-rust 0.7) and path-conversion behaviour before 8 UDF bodies land on top. * rust/Cargo.toml: add jsonpath-rust = "0.7". * rust/src/udf/json_common.rs: - convert_ppl_path: PPL path syntax (`a{i}.b{}`) -> JSONPath (`$.a[i].b[*]`). Mirrors JsonUtils.convertToJsonPath in sql/core. Empty string maps to "$" to match legacy root semantics. - parse: serde_json wrapper returning None on malformed input, the contract every json_* UDF will share. - check_arity / check_arity_range: plan_err! wrappers for the top-of-invoke guards. * rust/src/udf/mod.rs: register the module (helpers are crate-private). Consumers land in follow-up commits on the same PR (opensearch-project#21513); a module- level #![allow(dead_code)] keeps this commit's cargo check clean. Signed-off-by: Eric Wei <mengwei.eric@gmail.com> * [Analytics Engine] Port json_keys to DataFusion backend Adds the second PPL json_* UDF on top of opensearch-project#21476 (json_array_length). Matches the legacy SQL-plugin contract: object β†’ JSON-array-encoded keys in insertion order; non-object / malformed / scalar β†’ SQL NULL. - Rust UDF at rust/src/udf/json_keys.rs with scalar + columnar paths - Shared rust/src/udf/json_common.rs helpers (parse, arity, Utf8 downcast, PPL-path β†’ JSONPath) seeded for later json_* UDFs - serde_json preserve_order feature to preserve legacy LinkedHashMap ordering - Java wiring: ScalarFunction.JSON_KEYS, JsonKeysAdapter, Substrait sig, YAML signature, plugin project-op + adapter registration - ScalarJsonFunctionIT parity test for the four legacy fixtures Signed-off-by: Eric Wei <mengwei.eric@gmail.com> * [Analytics Engine] Port json_extract to DataFusion backend Rust UDF at rust/src/udf/json_extract.rs wraps jsonpath-rust: single path β†’ unquoted scalar or JSON-serialized container; multi-path β†’ JSON array with literal null slots for misses. < 2 args, malformed doc, malformed path, and explicit-null matches all collapse to SQL NULL, matching legacy JsonExtractFunctionImpl's calcite jsonQuery/jsonValue pair. JsonExtractAdapter renames the PPL call to the Rust UDF name via the variadic path; routing lives in FunctionMappings.s(...) in DataFusionFragmentConvertor and the STANDARD_PROJECT_OPS allow-list. Also fixes a pre-existing transport bug in DatafusionResultStream.getFieldValue: VarCharVector.getObject returns Arrow Text, which StreamOutput.writeGenericValue cannot serialize, so string-valued UDF results (json_keys, json_extract) were dropped when shard results traveled back to the coordinator. Converting VarCharVector cells to String at the source mirrors ArrowValues.toJavaValue and unblocks every string-returning UDF. Parity IT (ScalarJsonFunctionIT) replays four verbatim legacy cases covering single-path scalar/container match, wildcard multi-match, multi-path with missing path, and explicit-null resolution. Signed-off-by: Eric Wei <mengwei.eric@gmail.com> * [Analytics Engine] Port json_delete to DataFusion backend Mutation UDF #1. Introduces the shared mutation walker that json_set, json_append, and json_extend will reuse on the same PR. Rust side (rust/src/udf/json_delete.rs + json_common.rs): * `parse_ppl_segments` tokenises PPL paths (a.b{0}.c{}) into Field / Index / Wildcard segments without allocating field names. * `walk_mut` drives a mutation closure against every terminal match in a serde_json::Value; missing intermediate keys and out-of-range indices are silent no-ops, matching Jayway's SUPPRESS_EXCEPTIONS behaviour that legacy `JsonDeleteFunctionImpl` (β†’ Calcite `JsonFunctions.jsonRemove`) relies on. * `json_delete` terminal closure: `shift_remove` on Object (preserves insertion order via serde_json's `preserve_order` feature), `Vec::remove` on Array-with-Index, `Vec::clear` on Array-with-Wildcard. Any-NULL-arg / malformed doc / malformed path β†’ NULL. The walker is generic enough that json_set / json_append / json_extend are now pure terminal-closure swaps (set value, push value, extend array) β€” no further traversal plumbing needed. Java side: * JSON_DELETE added to `ScalarFunction`, `STANDARD_PROJECT_OPS`, and `scalarFunctionAdapters`. * `JsonDeleteAdapter` is a plain `AbstractNameMappingAdapter` rename (matches the other json_* adapters). * Substrait YAML signature uses `variadic: {min: 1}` β€” same shape as json_extract. Tests: * 10 Rust unit tests for json_delete (4 legacy IT fixtures replayed: flat-key, nested, missing-path-unchanged, wildcard-array; plus any-NULL / malformed / coerce_types / return_type). * 4 new walker tests in json_common (tokeniser, flat-delete, missing-noop, wildcard-fan-out, index-out-of-range-noop). * ScalarJsonFunctionIT gains `testJsonDeleteParityWithLegacy` replaying all 4 legacy assertions. Parity-checked against legacy SQL plugin `CalcitePPLJsonBuiltinFunctionIT.testJsonDelete*`. Signed-off-by: Eric Wei <mengwei.eric@gmail.com> * [Analytics Engine] Port json_set to DataFusion backend Mutation UDF #2. Reuses the walker introduced by #json_delete; this commit is a pure terminal-closure swap on the Rust side (replace, not remove) plus the usual 7-file Java/YAML wiring. Rust side (rust/src/udf/json_set.rs): * Terminal closure overwrites only existing keys on Object (`map.contains_key` guard), in-range slots on Array-with-Index, and every element on Array-with-Wildcard. This is the replace-only semantics from legacy `JsonSetFunctionImpl` (β†’ Calcite `JsonFunctions.jsonSet`, which guards `ctx.set` with `ctx.read(k) != null`). * Variadic arity: (doc, path1, val1, [path2, val2, ...]). Fewer than 3 args or an odd total (unpaired trailing path) short-circuits to NULL, mirroring the "malformed input β†’ NULL" convention the other json_* UDFs follow. * Values are always stored as `Value::String` because every arg is coerced to Utf8 by `coerce_types` β€” matches the legacy fixture's `"b":"3"` (stringified, not numeric). * Root-path (`parse_ppl_segments` returns empty) is a no-op to match Jayway's behaviour: `ctx.set("$", v)` silently fails because the root is indelible and unreplaceable. Java side: * JSON_SET added to `ScalarFunction`, `STANDARD_PROJECT_OPS`, and `scalarFunctionAdapters`. * `JsonSetAdapter` is a plain `AbstractNameMappingAdapter` rename. * Substrait YAML signature uses `variadic: {min: 1}` β€” same shape as json_extract / json_delete. Tests: * 9 Rust unit tests for json_set (3 legacy IT fixtures replayed: wildcard-replace, wrong-path-unchanged, partial-wildcard-set; plus multi-pair / any-NULL / malformed-doc / malformed-path / coerce_types / return_type). * ScalarJsonFunctionIT gains `testJsonSetParityWithLegacy` replaying all 3 legacy assertions. Parity-checked against legacy SQL plugin `CalcitePPLJsonBuiltinFunctionIT.testJsonSet*`. Signed-off-by: Eric Wei <mengwei.eric@gmail.com> * [Analytics Engine] Port json_append to DataFusion backend Mutation UDF #3. Another walker reuse: terminal closure pushes the paired value onto array-valued targets (non-array / missing targets are silent no-ops). Rust side (rust/src/udf/json_append.rs): * Terminal closure branches: Object+Field β†’ look up field, if it's an Array push the stringified value; Array+Index β†’ if the indexed slot is an Array, push; Array+Wildcard β†’ push onto every array-valued child. Non-array matches are skipped, matching legacy `JsonFunctions.jsonInsert` via Jayway's Collection-parent branch (`Collection.add`) which is how `JsonAppendFunctionImpl`'s `.meaningless_key` suffix trick ultimately expands. * Variadic arity (doc, path1, val1, [path2, val2, ...]). Fewer than 3 args or an odd total (unpaired trailing path) β†’ NULL β€” the malformed-input-to-NULL convention all other json_* UDFs share. Matches legacy's `RuntimeException("needs corresponding path and values")` observably-as-error via NULL surface. * Pre-stringified values: all args are Utf8-coerced at `coerce_types` entry, so nested `json_object(...)` / `json_array(...)` arrive here already stringified. They are pushed as `Value::String`, which reproduces the legacy IT's quoted-JSON-as-element rows without the new engine having to implement `json_object`/`json_array` yet (they ship in a follow-up PR). Java side: * JSON_APPEND added to `ScalarFunction`, `STANDARD_PROJECT_OPS`, and `scalarFunctionAdapters`. * `JsonAppendAdapter` is a plain `AbstractNameMappingAdapter` rename. * Substrait YAML signature uses `variadic: {min: 1}` β€” same shape as json_extract / json_delete / json_set. Tests: * 12 Rust unit tests for json_append (3 legacy IT fixtures replayed with pre-stringified nested JSON: named-array push, nested-path push, stringified-object push; plus multi-pair / wildcard-fan-out / non-array-noop / missing-path-noop / any-NULL / malformed-doc / malformed-path / coerce_types / return_type). * ScalarJsonFunctionIT gains `testJsonAppendParityWithLegacy` replaying all 3 legacy assertions with literal stringified JSON in place of the nested constructor calls the legacy test uses. Parity-checked against legacy SQL plugin `CalcitePPLJsonBuiltinFunctionIT.testJsonAppend`. Signed-off-by: Eric Wei <mengwei.eric@gmail.com> * [Analytics Engine] Port json_extend to DataFusion backend Mutation UDF opensearch-project#4 β€” last walker reuse. Same push shape as json_append, but each paired value is first tried as a JSON-array parse: success β†’ spread the elements; failure β†’ push the whole string as one element (parity with legacy `JsonExtendFunctionImpl`'s `gson.fromJson(v, List.class)` try/fall-back). Rust side (rust/src/udf/json_extend.rs): * Helper `spread(raw) -> Vec<Value>`: returns the parsed items when `raw` is a JSON array, else `[Value::String(raw)]`. Scalars, objects, and malformed JSON all go through the single-push branch. * Terminal closure reuses json_append's array-target guards (Object field β†’ Array, Array+Index β†’ inner Array, Array+Wildcard β†’ every array child). `Vec::extend(items.iter().cloned())` handles the spread and the single-push case uniformly. * Variadic arity matches every other mutation UDF. Invalid arity / any-NULL / malformed-doc / malformed-path β†’ NULL. Deliberate divergence from legacy: integer-typed spread elements stay integers (serde_json preserves source type) rather than being widened to Double as Gson does. Documented in `json.md:555` but not covered by any legacy IT; we preserve the more useful default and will file a tracking issue for the wider Gson-compat decision. Java side: * JSON_EXTEND added to `ScalarFunction`, `STANDARD_PROJECT_OPS`, and `scalarFunctionAdapters`. * `JsonExtendAdapter` is a plain `AbstractNameMappingAdapter` rename. * Substrait YAML signature uses `variadic: {min: 1}` β€” same shape as the other variadic json_* UDFs. Tests: * 13 Rust unit tests for json_extend (3 legacy IT fixtures replayed: single-push on non-array value, plain-string push, JSON-array spread; plus empty-array-value / mixed-type-spread / wildcard-fan / non-array-noop / missing-path-noop / any-NULL / malformed-doc / malformed-path / coerce_types / return_type). * ScalarJsonFunctionIT gains `testJsonExtendParityWithLegacy` replaying all 3 legacy assertions with literal stringified JSON standing in for the nested constructor calls the legacy test uses. Parity-checked against legacy SQL plugin `CalcitePPLJsonBuiltinFunctionIT.testJsonExtend`. Signed-off-by: Eric Wei <mengwei.eric@gmail.com> --------- Signed-off-by: Eric Wei <mengwei.eric@gmail.com>
1 parent 4d3562b commit 2380af7

20 files changed

Lines changed: 2942 additions & 13 deletions

File tree

β€Žsandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/ScalarFunction.javaβ€Ž

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,16 @@ public enum ScalarFunction {
168168
CURRENT_TIME(Category.SCALAR, SqlKind.OTHER_FUNCTION),
169169
CURTIME(Category.SCALAR, SqlKind.OTHER_FUNCTION),
170170
CONVERT_TZ(Category.SCALAR, SqlKind.OTHER_FUNCTION),
171-
UNIX_TIMESTAMP(Category.SCALAR, SqlKind.OTHER_FUNCTION);
171+
UNIX_TIMESTAMP(Category.SCALAR, SqlKind.OTHER_FUNCTION),
172+
173+
// ── JSON ────────────────────────────────────────────────────────
174+
JSON_APPEND(Category.SCALAR, SqlKind.OTHER_FUNCTION),
175+
JSON_ARRAY_LENGTH(Category.SCALAR, SqlKind.OTHER_FUNCTION),
176+
JSON_DELETE(Category.SCALAR, SqlKind.OTHER_FUNCTION),
177+
JSON_EXTEND(Category.SCALAR, SqlKind.OTHER_FUNCTION),
178+
JSON_EXTRACT(Category.SCALAR, SqlKind.OTHER_FUNCTION),
179+
JSON_KEYS(Category.SCALAR, SqlKind.OTHER_FUNCTION),
180+
JSON_SET(Category.SCALAR, SqlKind.OTHER_FUNCTION);
172181

173182
/**
174183
* Category of scalar function.

β€Žsandbox/plugins/analytics-backend-datafusion/rust/Cargo.tomlβ€Ž

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,26 @@ chrono-tz = "0.10"
4949

5050
tokio-metrics = { workspace = true }
5151

52+
# serde_json `preserve_order` β€” backs `Map<String,Value>` with `IndexMap`
53+
# instead of `BTreeMap` so json_keys / mutation UDFs see object keys in
54+
# insertion order (parity with legacy SQL-plugin's LinkedHashMap; required by
55+
# `testJsonKeysParityWithLegacy` + byte-for-byte json_extract fixtures).
56+
# Cargo's feature unification propagates this to every workspace member that
57+
# pulls in serde_json. Audit (2026-05-07): the five other consumers
58+
# (parquet-data-format, native-repository-{s3,gcs,azure,fs}) only call
59+
# `serde_json::from_str` into typed config structs, whose field layout is
60+
# fixed at the type level β€” `preserve_order` is inert for them, so the
61+
# feature is additive with no observable blast radius outside this crate.
62+
serde_json = { workspace = true, features = ["preserve_order"] }
63+
# jsonpath-rust 0.7 β€” JSONPath evaluator for json_extract. Published at
64+
# https://github.com/besok/jsonpath-rust (crates.io). We pin `0.7` (latest
65+
# `0.7.5`) rather than tracking the newer `1.0` release line because 0.7's
66+
# `JsonPathValue` enum exposes the Found/NoValue distinction json_extract
67+
# relies on to render missing-path matches as literal `null` elements in the
68+
# multi-path JSON-array output. Moving to 1.x is a follow-up once we can
69+
# reproduce that distinction against the new API surface.
70+
jsonpath-rust = "0.7"
71+
5272
[dev-dependencies]
5373
criterion = { workspace = true }
5474
tempfile = { workspace = true }
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
/*
2+
* SPDX-License-Identifier: Apache-2.0
3+
*
4+
* The OpenSearch Contributors require contributions made to
5+
* this file be licensed under the Apache-2.0 license or a
6+
* compatible open source license.
7+
*/
8+
9+
//! `json_append(value, path1, val1, [path2, val2, ...])` β€” push `valN` onto
10+
//! each path-matched array (parity with legacy `JsonAppendFunctionImpl`, which
11+
//! delegates to `JsonFunctions.jsonInsert` + `.meaningless_key` trick so Jayway
12+
//! routes to `Collection.add`). Non-array / missing targets are silent no-ops;
13+
//! any-NULL-arg / odd trailing arg / malformed-doc / malformed-path β†’ NULL.
14+
//!
15+
//! Values always push as `Value::String` β€” every UDF arg is coerced to Utf8
16+
//! upstream, so nested `json_object` / `json_array` results arrive already
17+
//! stringified and append as strings, matching legacy.
18+
19+
use std::any::Any;
20+
use std::sync::Arc;
21+
22+
use datafusion::arrow::array::{Array, ArrayRef, StringBuilder};
23+
use datafusion::arrow::datatypes::DataType;
24+
use datafusion::common::ScalarValue;
25+
use datafusion::error::Result;
26+
use datafusion::execution::context::SessionContext;
27+
use datafusion::logical_expr::{
28+
ColumnarValue, ScalarFunctionArgs, ScalarUDF, ScalarUDFImpl, Signature, Volatility,
29+
};
30+
use serde_json::Value;
31+
32+
use super::json_common::{as_utf8_array, parse, parse_ppl_segments, walk_mut, Segment};
33+
use super::{coerce_slot, CoerceMode};
34+
35+
const NAME: &str = "json_append";
36+
37+
pub fn register_all(ctx: &SessionContext) {
38+
ctx.register_udf(ScalarUDF::from(JsonAppendUdf::new()));
39+
}
40+
41+
#[derive(Debug, PartialEq, Eq, Hash)]
42+
pub struct JsonAppendUdf {
43+
signature: Signature,
44+
}
45+
46+
impl JsonAppendUdf {
47+
pub fn new() -> Self {
48+
Self {
49+
signature: Signature::user_defined(Volatility::Immutable),
50+
}
51+
}
52+
}
53+
54+
impl Default for JsonAppendUdf {
55+
fn default() -> Self {
56+
Self::new()
57+
}
58+
}
59+
60+
impl ScalarUDFImpl for JsonAppendUdf {
61+
fn as_any(&self) -> &dyn Any {
62+
self
63+
}
64+
fn name(&self) -> &str {
65+
NAME
66+
}
67+
fn signature(&self) -> &Signature {
68+
&self.signature
69+
}
70+
fn return_type(&self, _args: &[DataType]) -> Result<DataType> {
71+
Ok(DataType::Utf8)
72+
}
73+
fn coerce_types(&self, args: &[DataType]) -> Result<Vec<DataType>> {
74+
args.iter()
75+
.enumerate()
76+
.map(|(i, ty)| coerce_slot(NAME, i, ty, CoerceMode::Utf8))
77+
.collect()
78+
}
79+
80+
fn invoke_with_args(&self, args: ScalarFunctionArgs) -> Result<ColumnarValue> {
81+
// Need doc + at least one (path, value) pair. Odd trailing arg mirrors
82+
// the legacy `RuntimeException("needs corresponding path and values")`
83+
// thrown by `JsonAppendFunctionImpl.eval`; we surface it as NULL to
84+
// keep parity with the "malformed input β†’ NULL" convention.
85+
if args.args.len() < 3 || args.args.len().is_multiple_of(2) {
86+
return Ok(ColumnarValue::Scalar(ScalarValue::Utf8(None)));
87+
}
88+
let n = args.number_rows;
89+
90+
if args
91+
.args
92+
.iter()
93+
.all(|v| matches!(v, ColumnarValue::Scalar(_)))
94+
{
95+
let doc = scalar_utf8(&args.args[0]);
96+
let rest: Vec<Option<&str>> = args.args[1..].iter().map(scalar_utf8).collect();
97+
let out = append(doc, &rest);
98+
return Ok(ColumnarValue::Scalar(ScalarValue::Utf8(out)));
99+
}
100+
101+
let arrays: Vec<ArrayRef> = args
102+
.args
103+
.iter()
104+
.map(|v| v.clone().into_array(n))
105+
.collect::<Result<_>>()?;
106+
let columns: Vec<&datafusion::arrow::array::StringArray> =
107+
arrays.iter().map(as_utf8_array).collect::<Result<_>>()?;
108+
109+
let mut b = StringBuilder::with_capacity(n, n * 16);
110+
let mut rest: Vec<Option<&str>> = Vec::with_capacity(columns.len() - 1);
111+
for i in 0..n {
112+
let doc = cell(columns[0], i);
113+
rest.clear();
114+
for col in &columns[1..] {
115+
rest.push(cell(col, i));
116+
}
117+
match append(doc, &rest) {
118+
Some(s) => b.append_value(&s),
119+
None => b.append_null(),
120+
}
121+
}
122+
Ok(ColumnarValue::Array(Arc::new(b.finish()) as ArrayRef))
123+
}
124+
}
125+
126+
fn scalar_utf8(v: &ColumnarValue) -> Option<&str> {
127+
match v {
128+
ColumnarValue::Scalar(
129+
ScalarValue::Utf8(s) | ScalarValue::LargeUtf8(s) | ScalarValue::Utf8View(s),
130+
) => s.as_deref(),
131+
_ => None,
132+
}
133+
}
134+
135+
fn cell(arr: &datafusion::arrow::array::StringArray, i: usize) -> Option<&str> {
136+
if arr.is_null(i) {
137+
None
138+
} else {
139+
Some(arr.value(i))
140+
}
141+
}
142+
143+
/// Apply each (path, value) pair to a fresh parse of `doc`. Push-only:
144+
/// non-array targets (scalar, object) are silent no-ops, matching legacy
145+
/// `jsonInsert`'s Collection-parent branch skip.
146+
fn append(doc: Option<&str>, rest: &[Option<&str>]) -> Option<String> {
147+
let doc_str = doc?;
148+
if rest.iter().any(|p| p.is_none()) {
149+
return None;
150+
}
151+
let mut value = parse(doc_str)?;
152+
for chunk in rest.chunks(2) {
153+
let path = chunk[0].unwrap();
154+
let new_val = chunk[1].unwrap();
155+
let segments = parse_ppl_segments(path).ok()?;
156+
if segments.is_empty() {
157+
// Root-path is a no-op (legacy `ctx.set("$", v)` is silently
158+
// discarded by Jayway for the same reason).
159+
continue;
160+
}
161+
append_one(&mut value, &segments, new_val);
162+
}
163+
serde_json::to_string(&value).ok()
164+
}
165+
166+
fn append_one(root: &mut Value, segments: &[Segment<'_>], new_val: &str) {
167+
let item = Value::String(new_val.to_string());
168+
walk_mut(root, segments, |parent, final_seg| {
169+
match (parent, final_seg) {
170+
// Push onto the matched array when the final segment names an
171+
// existing array-valued field. Non-array / missing β†’ no-op.
172+
(Value::Object(map), Segment::Field(name)) => {
173+
if let Some(Value::Array(arr)) = map.get_mut(*name) {
174+
arr.push(item.clone());
175+
}
176+
}
177+
// Direct array-index / wildcard targets: push onto the *addressed*
178+
// array element when that element is itself an array.
179+
(Value::Array(arr), Segment::Index(i)) if *i < arr.len() => {
180+
if let Value::Array(inner) = &mut arr[*i] {
181+
inner.push(item.clone());
182+
}
183+
}
184+
(Value::Array(arr), Segment::Wildcard) => {
185+
for slot in arr.iter_mut() {
186+
if let Value::Array(inner) = slot {
187+
inner.push(item.clone());
188+
}
189+
}
190+
}
191+
_ => {}
192+
}
193+
});
194+
}
195+
196+
#[cfg(test)]
197+
mod tests {
198+
use super::*;
199+
200+
#[test]
201+
fn single_value_appended_to_named_array() {
202+
// testJsonAppend case b, single pair.
203+
assert_eq!(
204+
append(
205+
Some(r#"{"teacher":["Alice"]}"#),
206+
&[Some("teacher"), Some("Tom")],
207+
)
208+
.as_deref(),
209+
Some(r#"{"teacher":["Alice","Tom"]}"#)
210+
);
211+
}
212+
213+
#[test]
214+
fn multiple_pairs_append_sequentially() {
215+
// testJsonAppend case b (multi-pair).
216+
assert_eq!(
217+
append(
218+
Some(r#"{"teacher":["Alice"]}"#),
219+
&[Some("teacher"), Some("Tom"), Some("teacher"), Some("Walt")],
220+
)
221+
.as_deref(),
222+
Some(r#"{"teacher":["Alice","Tom","Walt"]}"#)
223+
);
224+
}
225+
226+
#[test]
227+
fn nested_path_appends_to_inner_array() {
228+
// testJsonAppend case c β€” a pre-stringified JSON array is appended as
229+
// a single string element (legacy calls gson/jackson on the outer doc
230+
// but NOT on the value; our Utf8-coerced arg arrives already
231+
// stringified and is pushed as-is).
232+
assert_eq!(
233+
append(
234+
Some(r#"{"school":{"teacher":["Alice"]}}"#),
235+
&[Some("school.teacher"), Some(r#"["Tom","Walt"]"#)],
236+
)
237+
.as_deref(),
238+
Some(r#"{"school":{"teacher":["Alice","[\"Tom\",\"Walt\"]"]}}"#)
239+
);
240+
}
241+
242+
#[test]
243+
fn stringified_json_object_value_is_appended_as_single_string() {
244+
// testJsonAppend case a β€” `json_object(...)` lowers to a string, so
245+
// the element lands as a stringified object (legacy and Rust agree).
246+
assert_eq!(
247+
append(
248+
Some(r#"{"student":[{"name":"Bob","rank":1}]}"#),
249+
&[Some("student"), Some(r#"{"name":"Tomy","rank":5}"#)],
250+
)
251+
.as_deref(),
252+
Some(r#"{"student":[{"name":"Bob","rank":1},"{\"name\":\"Tomy\",\"rank\":5}"]}"#)
253+
);
254+
}
255+
256+
#[test]
257+
fn non_array_target_is_silent_noop() {
258+
// teacher is a scalar here, not an array β€” legacy `Collection.add`
259+
// branch skips; no-op is the observable parity.
260+
assert_eq!(
261+
append(
262+
Some(r#"{"teacher":"Alice"}"#),
263+
&[Some("teacher"), Some("Tom")],
264+
)
265+
.as_deref(),
266+
Some(r#"{"teacher":"Alice"}"#)
267+
);
268+
}
269+
270+
#[test]
271+
fn missing_path_is_silent_noop() {
272+
assert_eq!(
273+
append(
274+
Some(r#"{"teacher":["Alice"]}"#),
275+
&[Some("students"), Some("Tom")],
276+
)
277+
.as_deref(),
278+
Some(r#"{"teacher":["Alice"]}"#)
279+
);
280+
}
281+
282+
#[test]
283+
fn wildcard_path_appends_to_every_array_child() {
284+
// Nested wildcard: every element of groups is an array; each receives
285+
// the same appended scalar.
286+
assert_eq!(
287+
append(
288+
Some(r#"{"groups":[["a"],["b","c"]]}"#),
289+
&[Some("groups{}"), Some("x")],
290+
)
291+
.as_deref(),
292+
Some(r#"{"groups":[["a","x"],["b","c","x"]]}"#)
293+
);
294+
}
295+
296+
#[test]
297+
fn any_null_arg_returns_none() {
298+
assert!(append(None, &[Some("a"), Some("v")]).is_none());
299+
assert!(append(Some(r#"{"a":[1]}"#), &[None, Some("v")]).is_none());
300+
assert!(append(Some(r#"{"a":[1]}"#), &[Some("a"), None]).is_none());
301+
}
302+
303+
#[test]
304+
fn malformed_doc_returns_none() {
305+
assert!(append(Some("not-json"), &[Some("a"), Some("v")]).is_none());
306+
}
307+
308+
#[test]
309+
fn malformed_path_returns_none() {
310+
assert!(append(Some(r#"{"a":[1]}"#), &[Some("a{"), Some("v")]).is_none());
311+
}
312+
313+
#[test]
314+
fn coerce_types_enforces_string_on_every_slot() {
315+
let udf = JsonAppendUdf::new();
316+
assert_eq!(
317+
udf.coerce_types(&[DataType::Utf8, DataType::LargeUtf8, DataType::Utf8View])
318+
.unwrap(),
319+
vec![DataType::Utf8, DataType::Utf8, DataType::Utf8]
320+
);
321+
let err = udf
322+
.coerce_types(&[DataType::Utf8, DataType::Int32, DataType::Utf8])
323+
.unwrap_err()
324+
.to_string();
325+
assert!(err.contains("expected string"));
326+
}
327+
328+
#[test]
329+
fn return_type_is_utf8() {
330+
assert_eq!(
331+
JsonAppendUdf::new().return_type(&[DataType::Utf8]).unwrap(),
332+
DataType::Utf8
333+
);
334+
}
335+
}

0 commit comments

Comments
Β (0)