cloud-fan commented on code in PR #58024:
URL: https://github.com/apache/spark/pull/58024#discussion_r4385471348
##########
sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseLexer.g4:
##########
@@ -80,10 +80,52 @@ import java.util.Deque;
public int complex_type_level_counter = 0;
/**
- * Increase the counter by one when hits KEYWORD 'ARRAY', 'MAP', 'STRUCT'.
+ * Increase the counter by one when hits KEYWORD 'ARRAY', 'MAP', 'STRUCT'
that starts an
+ * angle-bracketed complex type, such as ARRAY<INT>. Other uses of these
keywords, such as the
+ * ARRAY(1, 2) function or the `IS JSON ARRAY` predicate, have no matching
'>' and must leave
+ * the counter unchanged, otherwise a later '>>' could not be lexed as a
shift operator.
*/
public void incComplexTypeLevelCounter() {
- complex_type_level_counter++;
+ if (isFollowedByLessThan()) complex_type_level_counter++;
+ }
+
+ /**
+ * Returns whether the next character after the current token, skipping
whitespace and
+ * comments, is '<'. Called from a lexer action, where the input is
positioned right after the
+ * matched keyword.
+ */
+ private boolean isFollowedByLessThan() {
+ int i = 1;
+ while (true) {
+ int c = _input.LA(i);
+ if (c != CharStream.EOF && (Character.isWhitespace(c) ||
Character.isSpaceChar(c))) {
+ // Covers every character of the WS rule, including no-break spaces.
+ i++;
+ } else if (c == '-' && _input.LA(i + 1) == '-') {
+ // Simple comment: skip to the end of the line, honoring backslash
line continuations.
+ i += 2;
+ while (_input.LA(i) != '\n' && _input.LA(i) != CharStream.EOF) {
Review Comment:
**Blocking (P1):** `isFollowedByLessThan` ends a `--` comment only at LF,
but the lexer's `SIMPLE_COMMENT` rule (line 765) also ends one at a bare CR.
When SQL text uses bare-CR line endings and a `--` comment sits between
`ARRAY`/`MAP`/`STRUCT` and its `<`, this loop runs past the `<` to EOF (or the
next LF), the lookahead returns false, and the keyword is no longer counted.
Once enough of the open keywords go uncounted (both, for a two-level type), the
adjacent closers lex as a shift operator and a type that parses on master
becomes a syntax error. This applies to every CAST target, DDL column type, and
schema string this lexer parses; CRLF and LF line endings are unaffected.
I tokenized `CAST(NULL AS ARRAY -- c<CR><ARRAY -- c<CR><INT>>)` (`<CR>` is a
bare carriage return) with lexers generated by ANTLR 4.13.1 from both
revisions: master gives `LT LT GT GT`, this PR gives `LT LT SHIFT_RIGHT`. The
three-level `>>>` and the MAP forms fail the same way. Also stopping at `'\r'`
matches `SIMPLE_COMMENT`, and with that change every case lexes as on master
while `SELECT array(1), 8 >> 1` and `'[1]' IS JSON ARRAY, 8 >> 1` still get
`SHIFT_RIGHT`:
```suggestion
while (_input.LA(i) != '\n' && _input.LA(i) != '\r' && _input.LA(i)
!= CharStream.EOF) {
```
See **Shared repair plan 1** in the review body.
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/JsonExpressionsSuite.scala:
##########
@@ -1138,4 +1138,81 @@ class JsonExpressionsSuite extends SparkFunSuite with
ExpressionEvalHelper {
StructType(StructField("f", FileType.external) :: Nil))
}
}
+
+ test("SPARK-58793: IS JSON evaluates its operand exactly once") {
+ // Regression test: a non-deterministic operand (e.g. `IF(rand() < 0.5,
NULL, '{}')`) must be
+ // evaluated a single time, otherwise the predicate could observe two
different values and
+ // return a wrong result (false, when only NULL or true are valid). The
replacement must
+ // therefore reference the operand exactly once.
+ val child = Literal.create("{}", StringType)
+ Seq(IsJsonShape.Any, IsJsonShape.Value, IsJsonShape.Object,
IsJsonShape.Array,
+ IsJsonShape.Scalar).foreach { shape =>
+ val references = IsJson(child, shape).replacement.collect { case e if e
eq child => e }
+ assert(references.length == 1,
+ s"operand referenced ${references.length} times for shape $shape,
expected exactly once")
+ }
+ }
+
+ test("SPARK-58793: IS JSON requires strict ANSI JSON (no Hive leniency)") {
Review Comment:
**Nit (P3):** The strictness tests cover single quotes and raw control
characters, but several false cases that `sql-ref-is-json.md` promises have no
assertion: comments, unquoted field names, `NaN`/`Infinity`, leading zeros, and
whitespace-only input. The documented acceptance of duplicate keys and the
operand contract are untested too: there is no `UTF8_LCASE` or trim-collated
operand, no `VARCHAR`, no untyped `NULL IS JSON` (the PR description's example,
which relies on the `ExpectsInputTypes` NullType coercion), and no case showing
that `1 IS JSON` is rejected. Enabling `ALLOW_NON_NUMERIC_NUMBERS` or
`ALLOW_JAVA_COMMENTS` on the strict factory, turning on duplicate detection, or
switching to `ImplicitCastInputTypes` (which would make `123 IS JSON` true)
would all leave every current test green. A few cases here or in
`json-functions.sql`, with the golden files regenerated, would pin the
documented contract.
**Verification:**
- **Behavior:** Each documented false case (comments, unquoted field names,
NaN or Infinity, leading zeros, whitespace-only input) makes IS JSON return
false, and an object with duplicate field names makes it return true.
- **Behavior:** IS JSON over a UTF8_LCASE operand, a trim-collated operand,
and a VARCHAR operand returns the same results as over a plain STRING; untyped
`NULL IS JSON` returns NULL; and `1 IS JSON` fails analysis with a data-type
mismatch.
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/parser/ExpressionParserSuite.scala:
##########
@@ -291,6 +291,31 @@ class ExpressionParserSuite extends AnalysisTest {
assertEqual("a is not distinct from b", $"a" <=> $"b")
}
+ test("SPARK-58793: is json expressions") {
+ assertEqual("a is json", IsJson($"a", IsJsonShape.Any))
+ assertEqual("a is not json", !IsJson($"a", IsJsonShape.Any))
+ assertEqual("a is json value", IsJson($"a", IsJsonShape.Value))
+ assertEqual("a is json object", IsJson($"a", IsJsonShape.Object))
+ assertEqual("a is not json array", !IsJson($"a", IsJsonShape.Array))
+ assertEqual("a is json scalar", IsJson($"a", IsJsonShape.Scalar))
+ // A non-type ARRAY/MAP/STRUCT keyword must not leave the lexer in
complex-type mode, which
+ // would make a later `>>` or `>>>` lex as two `>` tokens and fail to
parse.
+ assertEqual("(a is json array) = (b >> 1)",
+ IsJson($"a", IsJsonShape.Array) === ShiftRight($"b", 1))
+ assertEqual("(a is json array) = (b >>> 1)",
+ IsJson($"a", IsJsonShape.Array) === ShiftRightUnsigned($"b", 1))
+ assertEqual("array(a) = array(b >> 1)",
+ $"array".function($"a") === $"array".function(ShiftRight($"b", 1)))
+ assertEqual("map(a, b >> 1)", $"map".function($"a", ShiftRight($"b", 1)))
+ assertEqual("struct(a, b >>> 1)", CreateStruct(Seq($"a",
ShiftRightUnsigned($"b", 1))))
+ // Angle-bracketed complex types still split consecutive closing brackets,
including when
+ // whitespace or comments separate the keyword from '<'.
+ assertEqual("cast(a as array<array<int>>) = b >> 1",
+ Cast($"a", ArrayType(ArrayType(IntegerType))) === ShiftRight($"b", 1))
+ assertEqual("cast(a as map /* c */ <int, array -- c\n <int>>) = b >> 1",
Review Comment:
**Nit (P3):** These comment cases can't catch a regression in a single
comment-skipping branch of `isFollowedByLessThan`. Here `map` has a bracketed
comment and `array` a `--` comment, so if either branch alone breaks, the other
keyword is still counted and `>>` still splits; the case fails only when both
branches break. The new `bitwise.sql` case comments only the outer `array`, so
it passes even with no comment skipping at all. I checked both with lexers
generated from this grammar with one or both branches forced to return false.
To isolate a branch, the same comment form has to precede every type keyword of
the nested type, e.g. `array /* c */ <array /* c */ <int>>` for `>>` and a
three-level type for `>>>`. A comment before only the inner keyword doesn't
work either, because the outer keyword is still counted directly. Bare-CR,
CRLF, nested bracketed, and backslash-continued `--` comments have no case yet;
the bare-CR form is the one that currently breaks.
This extends an [existing
discussion](https://github.com/apache/spark/pull/58024#discussion_r4068683249).
See **Shared repair plan 1** in the review body.
##########
sql/catalyst/src/main/java/org/apache/spark/sql/catalyst/expressions/json/JsonExpressionUtils.java:
##########
@@ -117,4 +118,62 @@ public static UTF8String jsonTypeof(UTF8String json) {
return null;
}
}
+
+ // Shape codes for the SQL `IS [NOT] JSON` predicate, kept in sync with
IsJsonShape on the
+ // Scala side. SHAPE_ANY covers both bare `IS JSON` and `IS JSON VALUE` (any
well-formed value).
+ public static final int SHAPE_ANY = 0;
+ public static final int SHAPE_OBJECT = 1;
+ public static final int SHAPE_ARRAY = 2;
+ public static final int SHAPE_SCALAR = 3;
+
+ // A strict JSON factory for the ANSI SQL `IS [NOT] JSON` predicate. Unlike
the shared factory
+ // used by the Hive-compatible JSON functions, it leaves ALLOW_SINGLE_QUOTES
and
+ // ALLOW_UNESCAPED_CONTROL_CHARS disabled, so only well-formed ANSI JSON is
accepted
+ // (e.g. `{'a':1}` with single quotes is rejected).
+ private static final JsonFactory STRICT_JSON_FACTORY = new JsonFactory();
+
+ /**
+ * Evaluates the SQL `IS JSON` predicate for a single, already-evaluated
operand: returns whether
+ * {@code json} is a well-formed ANSI JSON value of the requested {@code
shape}. The operand is
+ * read exactly once here, so a non-deterministic input is evaluated a
single time (unlike a
+ * plan that references the child in more than one place). NULL handling
(NULL input yields a
+ * NULL result) is left to the calling expression; this method is only
invoked for non-null
+ * input and always returns a definite true/false.
+ */
+ public static boolean isJson(UTF8String json, int shape) {
+ try (JsonParser jsonParser =
+ CreateJacksonParser.utf8String(STRICT_JSON_FACTORY, json)) {
Review Comment:
Thanks, verified. `isJson` now returns false when `UTF8String.isValid()`
fails, before decoding, and the `X'228022'` and `X'22EDA08022'` cases cover it.
<!-- SPARK_DEV_REVIEW_REPLY
{"feedback_id":"inline:4068683245","thread_id":"inline:4068683245","verdict_sha256":"38cf435f3ba8bf60d83b17dd355dd38452a4bcf26c8a70b16e14d3ea33ac3f9b"}
-->
##########
sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseParser.g4:
##########
@@ -1417,6 +1417,7 @@ predicate
| IS errorCapturingNot? kind=NULL
| IS errorCapturingNot? kind=(TRUE | FALSE | UNKNOWN)
| IS errorCapturingNot? kind=DISTINCT FROM right=valueExpression
+ | IS errorCapturingNot? kind=JSON shape=(VALUE | ARRAY | OBJECT | SCALAR)?
Review Comment:
Thanks, the counter now moves only when the keyword is followed by `<`, and
the `IS JSON ARRAY` and `array(...)` cases followed by `>>`/`>>>` parse. This
review also raises a testing follow-up: the lookahead's comment cases don't
fail if only one of its comment-skipping branches regresses.
<!-- SPARK_DEV_REVIEW_REPLY
{"feedback_id":"inline:4068683249","thread_id":"inline:4068683249","verdict_sha256":"38cf435f3ba8bf60d83b17dd355dd38452a4bcf26c8a70b16e14d3ea33ac3f9b"}
-->
##########
sql/catalyst/src/main/java/org/apache/spark/sql/catalyst/expressions/json/JsonExpressionUtils.java:
##########
@@ -117,4 +118,62 @@ public static UTF8String jsonTypeof(UTF8String json) {
return null;
}
}
+
+ // Shape codes for the SQL `IS [NOT] JSON` predicate, kept in sync with
IsJsonShape on the
+ // Scala side. SHAPE_ANY covers both bare `IS JSON` and `IS JSON VALUE` (any
well-formed value).
+ public static final int SHAPE_ANY = 0;
+ public static final int SHAPE_OBJECT = 1;
+ public static final int SHAPE_ARRAY = 2;
+ public static final int SHAPE_SCALAR = 3;
+
+ // A strict JSON factory for the ANSI SQL `IS [NOT] JSON` predicate. Unlike
the shared factory
+ // used by the Hive-compatible JSON functions, it leaves ALLOW_SINGLE_QUOTES
and
+ // ALLOW_UNESCAPED_CONTROL_CHARS disabled, so only well-formed ANSI JSON is
accepted
+ // (e.g. `{'a':1}` with single quotes is rejected).
+ private static final JsonFactory STRICT_JSON_FACTORY = new JsonFactory();
+
+ /**
+ * Evaluates the SQL `IS JSON` predicate for a single, already-evaluated
operand: returns whether
+ * {@code json} is a well-formed ANSI JSON value of the requested {@code
shape}. The operand is
+ * read exactly once here, so a non-deterministic input is evaluated a
single time (unlike a
+ * plan that references the child in more than one place). NULL handling
(NULL input yields a
+ * NULL result) is left to the calling expression; this method is only
invoked for non-null
+ * input and always returns a definite true/false.
+ */
+ public static boolean isJson(UTF8String json, int shape) {
+ try (JsonParser jsonParser =
+ CreateJacksonParser.utf8String(STRICT_JSON_FACTORY, json)) {
+ JsonToken token = jsonParser.nextToken();
Review Comment:
Thanks, verified. The full token walk now rejects unpaired surrogate escapes
in both values and field names at any depth, and correctly paired escapes still
pass.
<!-- SPARK_DEV_REVIEW_REPLY
{"feedback_id":"inline:4068683254","thread_id":"inline:4068683254","verdict_sha256":"38cf435f3ba8bf60d83b17dd355dd38452a4bcf26c8a70b16e14d3ea33ac3f9b"}
-->
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]