Skip to content

Null Handling in JSON Extract (Scalar and Index Based)#205

Open
siddharthteotia wants to merge 14 commits into
masterfrom
siddharthteotia/json-extract-scalar-honor-null-handling
Open

Null Handling in JSON Extract (Scalar and Index Based)#205
siddharthteotia wants to merge 14 commits into
masterfrom
siddharthteotia/json-extract-scalar-honor-null-handling

Conversation

@siddharthteotia

@siddharthteotia siddharthteotia commented May 28, 2026

Copy link
Copy Markdown
Collaborator

Improvement Summary

Fixes issue - apache/pinot#18568

Both JsonExtractScalarTransformFunction and JsonExtractIndexTransformFunction had _nullHandlingEnabled plumbed through (or accessible from BaseTransformFunction) 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 SQL NULL (typed null placeholder + a bit in getNullBitmap()) 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):

  row 0:  flatJson   = {"country":"US", "clicks":5}
          nestedJson = {"location":{"city":"SF","country":"US"},
                        "tags":["red","blue","green"],
                        "events":[{"country":"US"},{"country":"CA"}]}
  row 1:  flatJson   = {"country":"CA", "clicks":3}
          nestedJson = {"location":{"city":"Tor"},          -- no country key
                        "tags":["green"],
                        "events":[{"country":null}]}        -- inner explicit null
  row 2:  flatJson   = {"country":null, "clicks":null}
          nestedJson = {"location":null, "tags":null, "events":null}
  row 3:  flatJson   = {}
          nestedJson = {}
SET enableNullHandling = true;

Scenario 1: Projection — flat path

SELECT jsonExtractScalar(flatJson, '$.country', 'STRING') AS c FROM clicks ORDER BY c ASC NULLS LAST;
SELECT jsonExtractIndex(flatJson, '$.country', 'STRING') AS c FROM clicks ORDER BY c ASC NULLS LAST;
- `jsonExtractScalar`
    - Before: throws `IllegalArgumentException: Cannot resolve JSON path on some records…`
    - After: `"CA"`, `"US"`, `NULL`, `NULL`

  - `jsonExtractIndex`
    - Before: throws `RuntimeException: Illegal Json Path: [$.country], for docId [N]`
    - After: `"CA"`, `"US"`, `NULL`, `NULL`

Scenario 2: Projection — nested path

SELECT jsonExtractScalar(nestedJson, '$.tags[0]', 'STRING') AS tag FROM clicks ORDER BY tag ASC NULLS LAST;
SELECT jsonExtractIndex(nestedJson, '$.tags[0]', 'STRING') AS tag FROM clicks ORDER BY tag ASC NULLS LAST;
- `jsonExtractScalar`
    - Before: throws
    - After: `"green"`, `"red"`, `NULL`, `NULL`

  - `jsonExtractIndex`
    - Before: throws
    - After: `"green"`, `"red"`, `NULL`, `NULL`

Scenario 3: DISTINCT — flat path

SELECT DISTINCT jsonExtractScalar(flatJson, '$.country', 'STRING') AS c 
              FROM clicks ORDER BY c ASC NULLS LAST;
SELECT DISTINCT jsonExtractIndex(flatJson, '$.country', 'STRING') AS c 
              FROM clicks ORDER BY c ASC NULLS LAST;
- `jsonExtractScalar`
    - Before: throws
    - After: `"CA"`, `"US"`, `NULL`

  - `jsonExtractIndex`
    - Before: **already returned `"CA"`, `"US"`, `NULL`** via `JsonIndexDistinctOperator` — operator path unchanged
    - After: `"CA"`, `"US"`, `NULL`

Scenario 4: DISTINCT - nested path

SELECT DISTINCT jsonExtractScalar(nestedJson, '$.location.country', 'STRING') AS c 
               FROM clicks ORDER BY c ASC NULLS LAST;
SELECT DISTINCT jsonExtractIndex(nestedJson, '$.location.country', 'STRING') AS c 
               FROM clicks ORDER BY c ASC NULLS LAST;
  - `jsonExtractScalar`
    - Before: throws
    - After: `"US"`, `NULL`

  - `jsonExtractIndex`
    - Before: **already returned `"US"`, `NULL`** (operator path)
    - After: `US`, `NULL`

Scenario 5: GROUP BY + COUNT — flat path

SELECT jsonExtractScalar(flatJson, '$.country', 'STRING') AS c, COUNT(*) 
              FROM clicks GROUP BY c ORDER BY c ASC NULLS LAST;
SELECT jsonExtractIndex(flatJson, '$.country', 'STRING') AS c, COUNT(*) 
              FROM clicks GROUP BY c ORDER BY c ASC NULLS LAST;
 - `jsonExtractScalar`
    - Before: throws
    - After: `("CA", 1)`, `("US", 1)`, `(NULL, 2)`

  - `jsonExtractIndex`
    - Before: throws
    - After: `("CA", 1)`, `("US", 1)`, `(NULL, 2)`

Scenario 6: GROUP BY + COUNT — nested path

SELECT jsonExtractScalar(nestedJson, '$.events[0].country', 'STRING') AS c, COUNT(*) 
              FROM clicks GROUP BY c ORDER BY c ASC NULLS LAST;
SELECT jsonExtractIndex(nestedJson, '$.events[0].country', 'STRING') AS c, COUNT(*) 
              FROM clicks GROUP BY c ORDER BY c ASC NULLS LAST;
  - `jsonExtractScalar`
    - Before: throws
    - After: `("US", 1)`, `(NULL, 3)`

  - `jsonExtractIndex`
    - Before: throws
    - After: `("US", 1)`, `(NULL, 3)`

Scenario 7: 4-arg with SQL NULL literal as default, enableNullHandling = true

  • Semantically, passing NULL as the default is equivalent to passing no default at all under NH on — both should surface SQL NULL for unresolved rows.
  • Before this PR, jsonExtractScalar already behaved correctly here. No change there.
  • Before this PR, jsonExtractIndex did not — for STRING it silently emitted "", and for numeric types it failed at init with NumberFormatException. After the fix, init detects the SQL NULL literal and leaves _defaultValue null, so the 3-arg behavior applies.
SELECT jsonExtractScalar(nestedJson, '$.tags[0]', 'STRING', NULL) AS tag 
              FROM clicks ORDER BY tag ASC NULLS LAST;
SELECT jsonExtractIndex(nestedJson, '$.tags[0]', 'STRING', NULL) AS tag 
             FROM clicks ORDER BY tag ASC NULLS LAST;
  │     Function      │                        Before                         │                 After                  │
  ├───────────────────┼───────────────────────────────────────────────────────┼────────────────────────────────────────┤
  │ jsonExtractScalar │ "green", "red", NULL, NULL (already correct)          │ "green", "red", NULL, NULL (unchanged) │
  ├───────────────────┼───────────────────────────────────────────────────────┼────────────────────────────────────────┤
  │ jsonExtractIndex  │ "green", "red", "", "" ← empty string instead of NULL │ "green", "red", NULL, NULL ✓           │
  └───────────────────┴───────────────────────────────────────────────────────┴────────────────────────────────────────┘

Scenario 8: 4-arg with non-null default literal. Worked correctly before this PR as well

 -- NullHandling on or off — behavior is the same
 SELECT jsonExtractScalar(flatJson, '$.country', 'STRING', 'foobar') FROM clicks;
 SELECT jsonExtractIndex(flatJson, '$.country', 'STRING', 'foobar') FROM clicks;
┌─────────────────────────┬─────────────────────────────────────┐
 │       Query shape       │   Before & After (both functions)   │
 ├─────────────────────────┼─────────────────────────────────────┤
 │ Projection              │ "US", "CA", "foobar", "foobar"      │
 ├─────────────────────────┼─────────────────────────────────────┤
 │ DISTINCT (ORDER BY ASC) │ "CA", "US", "foobar"                │
 ├─────────────────────────┼─────────────────────────────────────┤
 │ GROUP BY + COUNT        │ ("CA", 1), ("US", 1), ("foobar", 2) │
 └─────────────────────────┴─────────────────────────────────────┘

Testing Done

  • Added new tests to JsonExtractScalarTransformFunctionTest and JsonExtractIndexTransformFunctionTest to get 100% coverage for null handling
  • Added multiple query shapes -- PROJECT, DISTINCT and GROUP BY
  • Added multiple Json column shapes -- flat, arbitrarily nested

@siddharthteotia siddharthteotia changed the title Honor null handling in jsonExtractScalar and jsonExtractIndex SV transforms Null Handling in JSON Extract (Scalar and Index Based) May 28, 2026
@siddharthteotia

siddharthteotia commented May 28, 2026

Copy link
Copy Markdown
Collaborator Author

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
SELECT jsonExtractScalar(flatJson, '$.country', 'STRING', NULL) FROM clicks;

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

@siddharthteotia siddharthteotia force-pushed the siddharthteotia/json-extract-prereqs branch from 59d55a6 to 6c2dc35 Compare May 29, 2026 18:35
@siddharthteotia siddharthteotia force-pushed the siddharthteotia/json-extract-scalar-honor-null-handling branch 2 times, most recently from 5b19a7e to 5c29e2d Compare May 29, 2026 22:28
Base automatically changed from siddharthteotia/json-extract-prereqs to master June 4, 2026 08:24
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.
@siddharthteotia siddharthteotia force-pushed the siddharthteotia/json-extract-scalar-honor-null-handling branch from 5c29e2d to c1c9db6 Compare June 4, 2026 08:31
@siddharthteotia

Copy link
Copy Markdown
Collaborator Author

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 praveenc7 left a comment

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.

Overall looks good, left a couple of comments

Object result = null;
try {
result = resultExtractor.apply(i);
} catch (InvalidJsonException e) {

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.

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);

@praveenc7 praveenc7 Jun 15, 2026

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.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants