Skip to content

Add jsonExtractScalar scalar function for JSON_EXTRACT_SCALAR - #18979

Open
Vamsi-klu wants to merge 1 commit into
apache:masterfrom
Vamsi-klu:feature/16486-json-extract-scalar-function
Open

Add jsonExtractScalar scalar function for JSON_EXTRACT_SCALAR#18979
Vamsi-klu wants to merge 1 commit into
apache:masterfrom
Vamsi-klu:feature/16486-json-extract-scalar-function

Conversation

@Vamsi-klu

@Vamsi-klu Vamsi-klu commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

What's the problem

json_extract_scalar exists only as a transform function
(JsonExtractScalarTransformFunction). It was never registered as a
scalar function, so it cannot be resolved wherever the query planner needs
a scalar implementation — most visibly the multi-stage (v2) engine's
intermediate stages. A query such as
SELECT CAST(json_extract_scalar(a.col1, b.col2, 'INT') AS INT) FROM a JOIN b ...
fails outright with Unsupported function: JSONEXTRACTSCALAR.

Why it matters

The multi-stage engine is Pinot's path for JOINs, sub-queries, and other
intermediate-stage computation. Without a scalar form, one of the most common
JSON-extraction operations is unusable across that whole class of queries, and
in ad-hoc scalar/constant-folding contexts. Issue #16486 tracks exactly this
gap.

What I did

Registered jsonExtractScalar as a @ScalarFunction in pinot-common's
JsonFunctions, with full parity to the transform:

  • Single values: INT, LONG, FLOAT, DOUBLE, BIG_DECIMAL, BOOLEAN,
    TIMESTAMP, STRING, JSON, BYTES; arrays for
    INT/LONG/FLOAT/DOUBLE/BIG_DECIMAL/STRING.
  • Same coercion rules as the transform: BOOLEAN uses Pinot's numeric
    convention and returns stored INT (0/1); TIMESTAMP takes numeric epoch
    millis directly and ISO-8601 strings via TimestampUtils;
    BIG_DECIMAL/STRING/JSON read through a BigDecimal-preserving parser.
  • Same throw-on-unresolved semantics: an unresolved single-value path without a
    default throws; a multi-value path yields an empty array; a null element in a
    resolved array throws unless a default is supplied; malformed JSON is treated
    as unresolved.
  • Removed the now-obsolete multi-stage negative test that asserted the function
    was unsupported, and extended JsonFunctionsTest across every result type
    (happy / default / throw paths, arrays incl. null elements, malformed JSON,
    BigDecimal precision, unsupported type).

Why I did it this way

  • Reused the transform's exact coercion and error rules so the scalar and
    transform forms are behaviorally identical — no second, subtly-different JSON
    semantics for users to reason about.
  • Add-only against JsonFunctions: the existing jsonPath* methods
    (including master's canExtractJsonPath() ingestion-hot-path fast-path) are
    untouched, so this carries zero regression risk for current callers.
  • A BigDecimal-based long parser stands in for pinot-core's
    NumberUtils.parseJsonLong, because pinot-common cannot depend on
    pinot-core; it reproduces the same truncate-toward-zero and exponent
    handling for JSON numeric strings.

Impact

json_extract_scalar now resolves in the multi-stage engine and scalar
contexts, with behavior identical to the transform. No existing function
changes behavior. Supersedes #16910 (revived, rebased on current master, with
that PR's review gaps on parity, throw semantics, and coverage closed).

closes #16486

Co-authored-by: Manik Somayaji somayajimanik@gmail.com

Generated-by: Claude Code (Opus 4.8)

@Vamsi-klu
Vamsi-klu marked this pull request as ready for review July 12, 2026 00:01
@Vamsi-klu

Copy link
Copy Markdown
Contributor Author

Ready for review. This replaces #16910 and rebases it onto current master. I also went back through the points raised in that PR's review: it now handles all the same result types as the transform function, throws in the same situations when a path can't be resolved, and I added a lot more coverage in JsonFunctionsTest.

@Jackie-Jiang you reviewed the original #16910, and @xiangfu0 you've been active in JsonFunctions lately, so a look from either of you would be really helpful whenever you get a chance. Happy to tweak anything.


Drafted-by: Claude Code (Opus 4.8); reviewed by @Vamsi-klu before posting

@Jackie-Jiang
Jackie-Jiang requested a lite review from Copilot August 5, 2026 01:44
@Jackie-Jiang Jackie-Jiang added the functions Related to scalar or aggregation functions label Aug 5, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a scalar-function implementation of jsonExtractScalar/json_extract_scalar in pinot-common so the function can be resolved and executed in scalar contexts (not just as a transform), notably enabling multi-stage (v2) engine intermediate stages to use JSON scalar extraction.

Changes:

  • Added @ScalarFunction-registered jsonExtractScalar to pinot-common’s JsonFunctions, mirroring the transform function’s coercion and error/default semantics across supported result types (including array variants).
  • Expanded JsonFunctionsTest to cover coercions, defaults, unresolved-path behavior, malformed JSON handling, array/null-element behavior, and unsupported types.
  • Removed the now-obsolete multi-stage negative test that asserted json_extract_scalar was unsupported.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/queries/QueryRunnerTest.java Removes the prior “unsupported function” assertion for json_extract_scalar in multi-stage contexts.
pinot-common/src/test/java/org/apache/pinot/common/function/JsonFunctionsTest.java Adds extensive unit coverage for the new scalar function’s coercion and error/default behavior.
pinot-common/src/main/java/org/apache/pinot/common/function/scalar/JsonFunctions.java Registers and implements the jsonExtractScalar scalar function (and default overload) with transform-parity semantics.
Suppressed comments (1)

pinot-common/src/main/java/org/apache/pinot/common/function/scalar/JsonFunctions.java:490

  • readJsonPathArray() has the same BYTES-input gap as readJsonPathValue(): JsonExtractScalarTransformFunction uses ParseContext#parseUtf8 for BYTES JSON columns, but the scalar version currently calls parseContext.parse(jsonInput) for byte[], which can fail to extract arrays from BYTES JSON inputs.
      ParseContext parseContext = useBigDecimal ? PARSE_CONTEXT_WITH_BIG_DECIMAL : PARSE_CONTEXT;
      Object read = jsonInput instanceof String ? parseContext.parse((String) jsonInput).read(jsonPath, NO_PREDICATES)
          : parseContext.parse(jsonInput).read(jsonPath, NO_PREDICATES);
      return convertObjectToArray(read);

Comment on lines +469 to +473
ParseContext parseContext = useBigDecimal ? PARSE_CONTEXT_WITH_BIG_DECIMAL : PARSE_CONTEXT;
if (jsonInput instanceof String) {
return parseContext.parse((String) jsonInput).read(jsonPath, NO_PREDICATES);
}
return parseContext.parse(jsonInput).read(jsonPath, NO_PREDICATES);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks. The transform parses BYTES columns via ParseContext.parseUtf8 (JsonExtractScalarTransformFunction line 804), while my helpers only special-case String, so a byte[] falls into parse(Object), which Jayway treats as an already parsed document instead of raw JSON. Because of the catch block, that would silently resolve to the default or throw instead of extracting. I will add an instanceof byte[] branch in both readJsonPathValue and readJsonPathArray that routes through parseContext.parseUtf8, and push it with the next update.

Comment on lines +841 to +845
@Test
public void testJsonExtractScalarBytes() {
assertEquals((byte[]) JsonFunctions.jsonExtractScalar("{\"h\":\"0a0b\"}", "$.h", "BYTES"),
new byte[]{0x0a, 0x0b});
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. The current byte[] input path is broken (it falls through to parse(Object)), so this gap is real. Along with the parseUtf8 fix in readJsonPathValue and readJsonPathArray, I will add tests that feed the same sample JSON as UTF-8 bytes (json.getBytes(StandardCharsets.UTF_8)) and assert parity with the String-input results, covering single-value extraction, array extraction, and the default-value path.

Comment on lines 330 to 334
// - PlaceholderScalarFunction registered will throw on intermediate stage, but works on leaf stage.
// - checked "Illegal Json Path" as col1 is not actually a json string, but the call is correctly triggered.
testCases.add(
new Object[]{"SELECT CAST(jsonExtractScalar(col1, 'path', 'INT') AS INT) FROM a", "Cannot resolve JSON path"});
// - checked function cannot be found b/c there's no intermediate stage impl for json_extract_scalar
testCases.add(new Object[]{
"SELECT CAST(json_extract_scalar(a.col1, b.col2, 'INT') AS INT) FROM a JOIN b ON a.col1 = b.col1",
"Unsupported function: JSONEXTRACTSCALAR"
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point. The removed case only asserted the old failure mode, and the remaining case at line 333 exercises the leaf stage. I will add a positive case to the row-count data provider that forces intermediate-stage evaluation across a JOIN, for example SELECT json_extract_scalar(a.col1, '$.x', 'INT', 0) FROM a JOIN b ON a.col1 = b.col1 (with a default value since col1 test data is not JSON), asserting the expected row count. That verifies the function resolves and runs in the v2 intermediate stage and guards against registration regressions.

json_extract_scalar was only implemented as a transform function, so it could
not be resolved in the multi-stage query engine or in ad-hoc scalar contexts.
Register it as a scalar function with full result-type parity to the transform:
INT/LONG/FLOAT/DOUBLE/BIG_DECIMAL/BOOLEAN/TIMESTAMP/STRING/JSON/BYTES single
values plus INT/LONG/FLOAT/DOUBLE/BIG_DECIMAL/STRING arrays, reusing the
transform's coercion rules (BooleanUtils numeric convention, TimestampUtils for
ISO-8601 strings, a BigDecimal-preserving parser) and its throw-on-unresolved
semantics. A BigDecimal-based long parser stands in for pinot-core's
NumberUtils.parseJsonLong, which pinot-common cannot depend on.

Supersedes apache#16910.

Co-authored-by: Manik Somayaji <somayajimanik@gmail.com>
@Vamsi-klu
Vamsi-klu force-pushed the feature/16486-json-extract-scalar-function branch from 3f38d79 to cd28a76 Compare August 6, 2026 03:37
@codecov-commenter

codecov-commenter commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 2.91971% with 133 lines in your changes missing coverage. Please review.
✅ Project coverage is 38.90%. Comparing base (cd2538d) to head (cd28a76).

Files with missing lines Patch % Lines
...he/pinot/common/function/scalar/JsonFunctions.java 2.91% 133 Missing ⚠️

❗ There is a different number of reports uploaded between BASE (cd2538d) and HEAD (cd28a76). Click for more details.

HEAD has 4 uploads less than BASE
Flag BASE (cd2538d) HEAD (cd28a76)
unittests1 1 0
unittests 2 1
java-25 5 4
temurin 5 4
Additional details and impacted files
@@              Coverage Diff              @@
##             master   #18979       +/-   ##
=============================================
- Coverage     66.59%   38.90%   -27.70%     
+ Complexity     1423     1422        -1     
=============================================
  Files          3443     3443               
  Lines        218536   218673      +137     
  Branches      34780    34813       +33     
=============================================
- Hits         145538    85077    -60461     
- Misses        61273   125841    +64568     
+ Partials      11725     7755     -3970     
Flag Coverage Δ
custom-integration1 100.00% <ø> (ø)
integration 100.00% <ø> (ø)
integration1 100.00% <ø> (ø)
integration2 0.00% <ø> (ø)
java-25 38.90% <2.91%> (-27.70%) ⬇️
temurin 38.90% <2.91%> (-27.70%) ⬇️
unittests 38.90% <2.91%> (-27.70%) ⬇️
unittests1 ?
unittests2 38.90% <2.91%> (-0.02%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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

Labels

functions Related to scalar or aggregation functions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add ScalarFunction for JSON_EXTRACT_SCALAR

4 participants