fix: get_json_object returns first value for duplicate keys to match Spark - #4971
fix: get_json_object returns first value for duplicate keys to match Spark#4971u70b3 wants to merge 4 commits into
get_json_object returns first value for duplicate keys to match Spark#4971Conversation
6f73bf5 to
361a314
Compare
7037198 to
c36c06c
Compare
c5b82da to
d38a346
Compare
Matching Spark's first-occurrence semantics for duplicate keys is the right fix, and keeping the full traversal so that malformed content after the match still rejects the row is a detail that would have been easy to get wrong. I think there is a case where the code does not do what the description says, though. A first match that resolves to nothing falls through to the second occurrence The description says:
But the code is: while let Some(matched) = map.next_key_seed(KeySeed(name))? {
if matched && !found.matched {
let candidate = map.next_value_seed(PathSeed { segments: &self.segments[1..], reject_direct_null: true })?;
if candidate.matched {
found = candidate;
}
} else {
map.next_value::<IgnoredAny>()?;
}
Two concrete cases I would expect to differ from Spark: SELECT get_json_object('{"a":{"b":1},"a":{"c":2}}', '$.a.b')Spark stops at the first SELECT get_json_object('{"a":null,"a":2}', '$.a')
Could you check that second case against Spark? If it does differ, the guard needs to be on whether the key was seen rather than on The change is bigger than the description says The description talks about A performance note on The |
|
Thanks for the review. I checked each point against Spark's 1. Lock semantics — the code matches Spark; it was the description that was stale Spark's object loop (lines 478-488) only skips later fields once
So guarding on "key was seen" (as the old description described) would actually diverge from Spark; locking on the first successful match is the correct semantics. A test for 2. Scope of the change Fair point — the description now covers the 3. The trailing |
a8b0928 to
1a7d25d
Compare
…h Spark Spark's GetJsonObjectEvaluator stops at the first matching field, but Comet's SegmentVisitor.visit_map kept overwriting the result, resolving a duplicated key to its last occurrence. Lock in the first match (even when the subpath misses, per Spark semantics) and skip later occurrences while still consuming all entries to validate the document. Closes apache#4947
Spark serializes a null reached through array traversal as the JSON text null (copyCurrentStructure), unlike a null directly under a named field, which is not a match. Add non-duplicate-key coverage for the $.a[0] and $.a[*] cases.
1a7d25d to
8db201e
Compare
| while let Some(matched) = map.next_key_seed(KeySeed(name))? { | ||
| if matched { | ||
| found = map.next_value_seed(PathSeed { | ||
| if matched && !found.matched { |
There was a problem hiding this comment.
This returns 1 for {"a":[[{"b":1}]],"a":null} with $.a[*][*].b, while Spark 4.1.3 and the pre-change native UDF return SQL NULL. Could you preserve Spark’s match decision here and add a regression test?
There was a problem hiding this comment.
Good catch — confirmed against Spark 4.1.3, and the root cause was deeper than the duplicate-key lock: Spark never treats [*][*] as two wildcards. Its parser emits Subscript :: Wildcard :: Subscript :: Wildcard, and evaluatePath consumes both at once (the "non-structure preserving double wildcard" case in JsonExpressionEvalUtils), applying the remaining path to the outer array's elements themselves in flatten style. So for {"a":[[{"b":1}]],"a":null} with $.a[*][*].b, the first a's outer element [{"b":1}] is an array and cannot match .b — nothing is written, dirty stays false — and the second a is null, hence SQL NULL.
Fixed in the latest commit: [*][*] now parses to a dedicated DoubleWildcard segment. The remaining path is applied to the outer elements with Spark's flatten style (array leaves are spliced recursively; an array that flattens to nothing writes no leaf nodes, so it is not a match), and the collected matches are always wrapped in a single array, even when there is only one — matching Spark's generator, which unconditionally wraps this case.
Regression coverage:
test_duplicate_key_double_wildcard_match_decisioncovers this exact input (-> NULL), the same shape without the duplicate key, and the fall-through to a later occurrence that does match ({"a":[[{"b":1}]],"a":[{"b":2}]}->[2]).- Five more unit tests pin the flatten semantics (one-level flatten mirroring Spark's
$.store.basket[*][*]suite case, single match staying wrapped, empty-flatten no-match, objects under[*][*].b, recursive flatten). - Added
[*][*]queries — including this exact query — tospark/src/test/resources/sql-tests/expressions/string/get_json_object.sql, so CI validates against Spark itself.CometSqlFileTestSuitepasses locally on Spark 4.1.3.
Spark's evaluatePath consumes two consecutive subscript wildcards as a
single non-structure-preserving step: the remaining path applies to the
outer array's elements in flatten style, and the collected matches are
always wrapped in one array, even a single one. Treating `[*][*]` as two
independent wildcards descended into the inner arrays instead, so
`{"a":[[{"b":1}]],"a":null}` with `$.a[*][*].b` returned 1 where Spark
and the pre-change native UDF return NULL: the first `a` misses (its
outer element is an array with no field `b`) and the second is null.
Parse `[*][*]` into a dedicated DoubleWildcard segment, propagate
Spark's flatten style to leaf values (splicing array leaves
recursively), and add Rust unit tests plus SQL-file cases.
Which issue does this PR close?
Closes #4947.
Rationale for this change
For a JSON object with duplicate keys, Spark's
GetJsonObjectEvaluatorkeeps the first occurrence that produces a value, but Comet's native implementation resolved the key to its last occurrence (matchingserde_json's overwrite semantics rather than Spark's):What changes are included in this PR?
SegmentVisitor::visit_mapinnative/spark-expr/src/string_funcs/get_json_object.rsnow locks in the first successful match for a key and consumes later occurrences viaIgnoredAnyinstead of re-parsing them withPathSeed:GetJsonObjectEvaluator.evaluatePath, where a named field whose value is JSON null — or whose subtree does not resolve the rest of the path — does not setdirty, and evaluation continues to later duplicate keys. For example,{"a":null,"a":2}/$.aand{"a":{"x":1},"a":{"b":2}}/$.a.bboth return2in Spark.The PR also includes two supporting changes in the same file:
Option<Value>is replaced by aPathResultcarrying the matched values plus a separate matched flag, andPathSeedgains areject_direct_nullflag capturing Spark's rule that a JSON null directly below a named field is not a match, while a null reached through array traversal is a match and serializes as the textnull(e.g.{"a":[null]}/$.a[0]now returns the stringnullinstead of SQL NULL, matching Spark'scopyCurrentStructure).serde_json::from_str. A single wildcard match on a null also serializes asnulltext, matching Spark.[*][*]parse to a dedicatedDoubleWildcardsegment, matching Spark's "non-structure preserving double wildcard" case inevaluatePath: the remaining path applies to the outer array's elements themselves (in flatten style, splicing array leaves recursively), and the collected matches are always wrapped in a single array, even a single one. Previously[*][*]was treated as two independent wildcards, which descended into the inner arrays instead — so{"a":[[{"b":1}]],"a":null}/$.a[*][*].breturned1where Spark returns NULL (the firsta's outer element is an array and cannot match.b, and the secondais null).How are these changes tested?
test_duplicate_key_last_winsunit test totest_duplicate_key_first_wins.test_duplicate_key_first_wins_nested({"a":{"b":1},"a":{"b":2}}/$.a.b->1).test_duplicate_key_first_successful_match_winscovering Spark's continue-on-null / continue-on-missing-subpath semantics ({"a":{"x":1},"a":{"b":2}}/$.a.b->2,{"a":null,"a":{"b":2}}/$.a.b->2,{"a":null,"a":2}/$.a->2,{"a":{"b":null,"b":2}}/$.a.b->2).test_duplicate_key_first_successful_match_wins_with_wildcardandtest_duplicate_key_null_reached_through_array_locks_match.test_null_reached_through_array_serializes_as_null_textfor the non-duplicate-key case ({"a":[null]}/$.a[0]and$.a[*]->nulltext,{"a":[null,1]}/$.a[*]->[null,1]).test_duplicate_key_double_wildcard_match_decisionplus five more unit tests pinning the[*][*]flatten semantics (one-level flatten mirroring Spark's own$.store.basket[*][*]case, single match staying wrapped, empty-flatten no-match, recursive flatten).[*][*]queries — including the reviewer-reported{"a":[[{"b":1}]],"a":null}/$.a[*][*].bcase — tospark/src/test/resources/sql-tests/expressions/string/get_json_object.sql, so they are validated against Spark itself.CometSqlFileTestSuitepasses locally on Spark 4.1.3 (463 tests).datafusion-comet-spark-exprsuite passes, plusclippyandfmtchecks.spark/src/test/resources/sql-tests/andCometJsonJvmSuitefor duplicate-key cases: the only repeated keys there are across different objects inside arrays, which are unaffected by this change.