Null Handling in JSON Extract (Scalar and Index Based)#205
Null Handling in JSON Extract (Scalar and Index Based)#205siddharthteotia wants to merge 14 commits into
Conversation
|
Out of scope — pre-existing quirks intentionally not addressed These have been documented as deferred follow-ups; no behavior change in this PR. NH off + SQL NULL literal as default -- enableNullHandling stays false jsonExtractScalar silently emits the typed zero (0 for INT, "" for STRING, etc.) for unresolved rows. Arguably wrong — passing SQL NULL when NH is off has no consistent semantics — but pre-existing and out of scope. jsonExtractIndex previously had the same family of issues (STRING "", numeric init-throw); the Form B fix narrowed those to a consistent "behave like 3-arg form" under NH on but did not touch NH-off. IMPORTANT NOTE - My ideal direction would be to get rid of nullHandlingEnabled all together and just have one function that is smart enough to support NULLs, decides when to use index or not etc. Having 1 v/s 2 functions is not that big of a deal but having an index based function and non index based function along with choice to handle NULLs or not handle them is poor SQL abstraction IMHO. Also not aligned with first principles of query planning and optimization. For now, the PR extends the functionality so that we offer correct and complete semantics to the users in the current dialect. Once we have had discussions on generic query planning and optimization in context of both SSE and MSE to decide upon a direction, we can fix such leaky abstractions in multiple places in the code. We will have to think of backwards compatibility as well at that point |
59d55a6 to
6c2dc35
Compare
5b19a7e to
5c29e2d
Compare
When query-level null handling is enabled and no default literal is set, the typed SV paths now emit SQL NULL for unresolved JSON paths instead of throwing IllegalArgumentException.
Mirror the scalar fix: when query-level null handling is enabled and no default literal is set, the six typed SV paths emit SQL NULL for unresolved JSON paths instead of throwing RuntimeException.
Named single-row JSON constants with pretty-printed comments, named fixture rows, helper methods that eliminate the inline FluentQueryTest boilerplate, and per-test Javadocs with a concrete example query and expected result.
Replace scattered single-row JSON constants with one 4-row x 2-column fixture (flatJson + nestedJson) appended to the end of the test class. Tests are grouped by query shape: projection -> DISTINCT -> GROUP BY, each with NH-on and NH-off variants. Per-test Javadocs include a concrete query and expected result; result rows are ordered ASC NULLS LAST.
…ionTest Replace the minimal unit-level null-handling tests with the same fixture + projection/DISTINCT/GROUP BY structure as the scalar test, so both test files cover the same query shapes against the same data model. The helper configures a JSON index on both columns via FieldConfig.
In each SV transform, by the time control reaches the null-handling branch, the earlier `if (_defaultValue != null)` check has already filled defaults, so the remaining decision reduces to `_nullHandlingEnabled`. getNullBitmap inlines the equivalent short-circuit condition. No behavior change; 209 tests unchanged.
Three tests per file (projection, DISTINCT, GROUP BY) pin the SV transform's priority order: a user-supplied default wins over the null-handling placeholder. Without these, a future refactor could silently swap the order so unresolved rows surface as SQL NULL instead of the user's default.
Default-precedence tests in both files now parameterize over flatJson + nestedJson (mirrors the projection/DISTINCT/GROUP BY data providers). Scalar test adds Section 5 covering 4-arg with SQL NULL literal under NH on, pinning the _defaultIsNull code path in getNullBitmap.
Both test files now have Section 1 (Projection), 2 (DISTINCT), 3 (GROUP BY) where each section owns its own sub-cases (.1 NH-on 3-arg, .2 NH-on with SQL-NULL default [scalar only], .3 NH-on with non-null default, .4 NH-off). Previously, default-precedence and SQL-NULL-default tests lived in separate sections 4/5 even though they belonged to the same three query shapes.
- Cache the per-ValueBlock getValuesSV result so the SV transform and getNullBitmap don't each hit the index reader under null handling (perf MAJOR). - Add null-handling note to the class Javadoc (doc MAJOR). - Use valueBlock.getNumDocs() for the loop bound in getNullBitmap to match sibling SV transforms (MINOR). - Swap @OverRide @nullable to @nullable @OverRide to match the parent's convention (MINOR).
init now detects a SQL NULL literal as the 4-arg default via literal.isNull() and leaves _defaultValue null, so 3-arg semantics apply (NH on -> SQL NULL, NH off -> throw). Without this, STRING/JSON silently emitted "" and numerics failed at init -- both inconsistent with the scalar function's fix. Adds matching Section 1.2 / 2.2 / 3.2 tests in the index test file (6 new parameterized cases) covering flat + nested paths under projection, DISTINCT, and GROUP BY. Also fixes a comment typo (broker -> server) flagged by review.
Mirrors the prereqs-PR adaptation for JsonExtractScalar. The 11 upstream feature commits target BaseTransformFunction.init's boolean nullHandlingEnabled argument; li-pinot's BaseTransformFunction uses QueryContext instead, so we propagate the type change here. _nullHandlingEnabled is still inherited from the base class via super.init.
5c29e2d to
c1c9db6
Compare
|
The Pre-Req PR #204 has been merged. This is rebased. Will mark it ready for review once all tests have passed. |
…miss PR #205's null-handling broadcasted past its intended scope: the existing broad `catch (Exception ignored)` swallowed jayway's `InvalidJsonException` (input isn't JSON at all) alongside the legitimate `PathNotFoundException` (valid JSON, path absent), then coerced both to SQL NULL under NH-on. This silently masked schema modeling errors — e.g. `jsonExtractScalar` invoked on a non-JSON STRING column returned 15 rows of NULL instead of surfacing the caller bug. Split the catch arms in all 12 transform sites: - PathNotFoundException → silent; the existing null-handling / default / throw cascade decides per row (PR #205's feature, preserved). - InvalidJsonException → throw IllegalArgumentException with a clear "received input that is not valid JSON" message, regardless of NH state. Updates the upstream `QueryRunnerTest` case #12 expectation to match the new contract, and adds two new tests in `JsonExtractScalarTransformFunctionTest` locking in "non-JSON input throws under both NH on and off". JsonExtractIndex is deliberately untouched — it already rejects non-JSON columns at init() via the "must have JSON index" precondition. Known open question: local Temurin-21 run does not reproduce the CI failure (test passes locally pre- and post-patch). CI-vs-local divergence likely TestNG ordering / fixture state; pushing for empirical CI signal. Fallback if InvalidJsonException doesn't fire under CI conditions is to remove `Option.SUPPRESS_EXCEPTIONS` from the parser contexts (documented in CLAUDE.local.md resume notes). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The prior WIP commit narrowed the per-row catch to PathNotFoundException +
InvalidJsonException to surface non-JSON input as a hard error. But jayway
throws a third, distinct exception for a null/empty JSON cell —
IllegalArgumentException("json can not be null") from ParseContextImpl.parse —
which neither arm caught. That exception escaped and broke the 14
testDefaultValue cases from #204 (null JSON cell + a configured default must
return the default, not throw).
Restore the broad catch for everything that represents a missing value, and
peel off only genuinely malformed input:
- InvalidJsonException -> throw IllegalArgumentException("...not valid
JSON..."), regardless of null handling
(non-JSON column is a schema modeling error).
- everything else (null/empty input, missing path) -> swallowed; result stays
null and the existing null-handling / default
cascade decides (preserves #204 and #205).
Add a boundary test (testNonJsonInputThrowsEvenWithDefaultValue) proving a
configured default does not mask non-JSON input — the parse failure is thrown
before the default cascade is reached.
Verified locally: JsonExtractScalarTransformFunctionTest 145/145,
QueryRunnerTest 131/131 (case #12 throws the expected message), spotless +
checkstyle clean on pinot-core and pinot-query-runtime.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
praveenc7
left a comment
There was a problem hiding this comment.
Overall looks good, left a couple of comments
| Object result = null; | ||
| try { | ||
| result = resultExtractor.apply(i); | ||
| } catch (InvalidJsonException e) { |
There was a problem hiding this comment.
Looks like we repeating this for all types can we do something like
Object result = extractOrNull(resultExtractor, i);
@Nullable
private <T> T extractOrNull(IntFunction<T> extractor, int i) {
try {
return extractor.apply(i);
} catch (InvalidJsonException e) {
throw new IllegalArgumentException(
"jsonExtractScalar received input that is not valid JSON. "
+ "This function requires a JSON-typed column or a JSON-formatted string.", e);
} catch (Exception ignored) {
// null/empty input or missing path → null; caller's cascade decides
return null;
}
}
| return super.transformToIntValuesSV(valueBlock); | ||
| } | ||
| initIntValuesSV(valueBlock.getNumDocs()); | ||
| IntFunction<Object> resultExtractor = getResultExtractor(valueBlock); |
There was a problem hiding this comment.
when a null-aware consumer needs both the values and the null bitmap for a block, the block is parsed twice (getNullBitmap and transformToIntValuesSV) I see we are caching in the JsonExtractIndexTransformFuntion class to avoid this, is there a reason of not having the same here
Improvement Summary
Fixes issue - apache/pinot#18568
Both
JsonExtractScalarTransformFunctionandJsonExtractIndexTransformFunctionhad_nullHandlingEnabledplumbed through (or accessible fromBaseTransformFunction) but their six typed SV methods (transformTo{Int,Long,Float,Double,BigDecimal,String}ValuesSV) ignored it and threw unconditionally when the JSON path didn't resolve and no default literal was supplied. This PR makes both transforms honor_nullHandlingEnabled: when query-level null handling is on, unresolved rows surface as SQLNULL(typed null placeholder + a bit ingetNullBitmap()) instead of throwing.All examples use a clicks table with two JSON columns and 4 rows. Both columns have a JSON index (so jsonExtractIndex is applicable):
Scenario 1: Projection — flat path
Scenario 2: Projection — nested path
Scenario 3: DISTINCT — flat path
Scenario 4: DISTINCT - nested path
Scenario 5: GROUP BY + COUNT — flat path
Scenario 6: GROUP BY + COUNT — nested path
Scenario 7: 4-arg with SQL NULL literal as default, enableNullHandling = true
Scenario 8: 4-arg with non-null default literal. Worked correctly before this PR as well
Testing Done