spark-test commented on code in PR #58024:
URL: https://github.com/apache/spark/pull/58024#discussion_r4472316912
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/JsonExpressionsSuite.scala:
##########
@@ -1138,4 +1139,144 @@ 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)") {
+ // Single quotes and unescaped control characters are allowed by the
Hive-compatible parser
+ // shared by json_typeof etc., but the ANSI SQL IS JSON predicate must
reject them.
+ checkEvaluation(IsJson(Literal("{'a':1}"), IsJsonShape.Any), false)
+ checkEvaluation(IsJson(Literal("['a', 'b']"), IsJsonShape.Any), false)
+ // A raw (unescaped) control character inside a JSON string is not valid
ANSI JSON.
+ checkEvaluation(IsJson(Literal("\"line1\nline2\""), IsJsonShape.Any),
false)
+ // The double-quoted equivalents are well-formed ANSI JSON.
+ checkEvaluation(IsJson(Literal("""{"a":1}"""), IsJsonShape.Any), true)
+ checkEvaluation(IsJson(Literal("""["a", "b"]"""), IsJsonShape.Any), true)
+ // Other parser extensions are rejected too: comments, unquoted field
names, non-numeric
+ // numbers, leading zeros, and whitespace-only input.
+ Seq("/* c */ {}", "{} // c", "# c\n{}", "{a:1}", "NaN", "Infinity",
"-Infinity", "01",
+ "-01", "[01]", " ", "\n\t").foreach { json =>
+ checkEvaluation(IsJson(Literal(json), IsJsonShape.Any), false)
+ }
+ // Duplicate field names are accepted (no WITH UNIQUE KEYS support).
+ checkEvaluation(IsJson(Literal("""{"a":1,"a":2}"""), IsJsonShape.Object),
true)
+ }
+
+ test("SPARK-58793: IS JSON rejects structurally malformed containers") {
+ // Containers that only turn malformed after a valid first token:
truncated input, trailing
+ // commas, missing values, a missing colon, and mismatched or unmatched
closers. Pinning these
+ // guards against a strict factory accidentally built with
JsonReadFeature.ALLOW_TRAILING_COMMA
+ // or ALLOW_MISSING_VALUES, or against a token walk that accepts a
container ending early.
+ Seq("{", "[", "[1,", "{\"a\"", "{\"a\":", "[1,]", "{\"a\":1,}", "[1,,2]",
"{\"a\"}", "[1}",
+ "{]", "}", "]").foreach { json =>
+ checkEvaluation(IsJson(Literal(json), IsJsonShape.Any), false)
+ }
+ // The matching shape qualifier must reject them too, not only bare IS
JSON: the first token
+ // matches the shape, so only the malformed rest can reject the input.
+ checkEvaluation(IsJson(Literal("[1,]"), IsJsonShape.Array), false)
+ checkEvaluation(IsJson(Literal("{\"a\":1,}"), IsJsonShape.Object), false)
+ checkEvaluation(IsJson(Literal("[1,,2]"), IsJsonShape.Array), false)
+ checkEvaluation(IsJson(Literal("[1}"), IsJsonShape.Array), false)
+ }
+
+ test("SPARK-58793: IS JSON fails on input beyond the parser limits, like
parse_json") {
+ def checkLimit(
+ json: String,
+ shape: IsJsonShape,
+ condition: String,
+ parameters: Map[String, String]): Unit = {
+ // Only the first 128 characters of the input are echoed in the error.
+ checkErrorInExpression[SparkRuntimeException](
+ IsJson(Literal(json), shape),
+ InternalRow.empty,
+ condition,
+ parameters + ("inputPrefix" -> s"'${json.take(128)}...'"))
+ }
+ // Inputs at the limits are classified as usual.
+ checkEvaluation(IsJson(Literal("[" * 1000 + "]" * 1000),
IsJsonShape.Array), true)
+ checkEvaluation(IsJson(Literal("1" * 1000), IsJsonShape.Scalar), true)
+ // One past the nesting depth, number length, or field name length limit
is an error rather
Review Comment:
**Nit (P3):** This block covers three of the four documented limits. Nothing
reaches `IS_JSON_INPUT_LIMIT_EXCEEDED.STRING_LENGTH`. That branch rests on two
premises no test checks. First, `limitError` must recognize Jackson's "String
value length" message. Second, the token walk must keep decoding every string
value through `getText()`, which is what makes Jackson enforce the limit.
If a Jackson upgrade rewords the message, the error silently becomes
`UNKNOWN`. If the walk stops decoding strings, an over-long string returns
true. A case with a string value of
`StreamReadConstraints.DEFAULT_MAX_STRING_LEN + 1` characters, as
`JsonFunctionsSuite` already builds, would pin both.
**Verification:**
- **Regression:** The new case fails if the string-length message stops
being recognized, which would yield the UNKNOWN sub-condition, or if string
values stop being decoded during the walk, which would yield true.
##########
sql/catalyst/src/main/java/org/apache/spark/sql/catalyst/expressions/json/JsonExpressionUtils.java:
##########
@@ -117,4 +122,151 @@ 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. Malformed input yields false. Input that exceeds one of Jackson's
default
+ * {@code StreamReadConstraints} (nesting depth, number length, string
length, or field name
+ * length) cannot be classified and raises an IS_JSON_INPUT_LIMIT_EXCEEDED
condition, at the
+ * same limits where {@code parse_json} fails -- but only if checking the
input actually
+ * requires going past a limit: a shape mismatch decided by the first token
(including an
+ * over-long root number under OBJECT or ARRAY, which cannot be an object or
an array)
+ * returns false, like any other mismatch.
+ */
+ public static boolean isJson(UTF8String json, int shape) {
+ // The parser decodes the bytes through a UTF-8 reader that silently
replaces malformed
+ // sequences with U+FFFD, so check the raw bytes first: input that is not
valid UTF-8
+ // (e.g. a lone continuation byte, or an encoded surrogate) is not
well-formed JSON text.
+ if (!json.isValid()) {
+ return false;
+ }
+ try (JsonParser jsonParser =
+ CreateJacksonParser.utf8String(STRICT_JSON_FACTORY, json)) {
+ JsonToken token;
+ try {
+ token = jsonParser.nextToken();
+ } catch (StreamConstraintsException e) {
+ // Jackson validates a number's length while producing its token, and
no other limit can
+ // be reached while producing the first token (nesting needs a second
token, and strings
Review Comment:
**Nit (P3):** The parenthetical is half right. Jackson defers decoding
string values until `getText()`, but it decodes a field name when `nextToken()`
produces the `FIELD_NAME` token, and the name is available from `currentName()`
without `getText()`. The conclusion still holds because the first token can
never be a field name, and the comment could give that reason instead: nesting
needs a second token, string values are decoded lazily, and a first token is
never a field name. As written, it misleads anyone who extends this handler or
the trailing-content check about which limits can fire where.
**Verification:**
- **Inspection:** The first-token handler's comment no longer claims that
field names are decoded only at getText, and it justifies the over-long-number
premise from the kinds of token that can come first.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/jsonExpressions.scala:
##########
@@ -2689,3 +2689,70 @@ object JsonObjectExpr {
case _ => false
}
}
+
+// Shape qualifier for IS [NOT] JSON: which top-level JSON type to require.
+sealed trait IsJsonShape
+object IsJsonShape {
+ // Per ANSI SQL:2016 T821, IS JSON VALUE is semantically equivalent to bare
IS JSON:
+ // both accept any well-formed JSON value (object, array, or scalar). They
are kept as
+ // separate shapes so the sql() output round-trips faithfully.
+ case object Any extends IsJsonShape // IS JSON (no qualifier)
+ case object Value extends IsJsonShape // IS JSON VALUE
+ case object Scalar extends IsJsonShape // IS JSON SCALAR -- primitives only
(not object/array)
+ case object Array extends IsJsonShape // IS JSON ARRAY
+ case object Object extends IsJsonShape // IS JSON OBJECT
+}
+
+case class IsJson(child: Expression, shape: IsJsonShape = IsJsonShape.Any)
Review Comment:
**Nit (P3):** `IsJson` is built by the parser but is neither a `Predicate`
nor listed in `ResolverGuard.isGenerallySupportedExpression`, so the
single-pass analyzer's guard rejects every `IS [NOT] JSON` query, and `NOT (...
IS JSON)` through its child. Its parser-built scalar siblings `JsonArray`,
`JsonObjectExpr`, `JsonValue`, `JsonQuery` and `JsonExists` are all in the
guard's JSON arm and in ResolverGuardSuite's "SQL JSON constructor and path
functions" test. `JSON_OBJECT` added its entry in the same change that
introduced it.
As a result, IS JSON gets no single-pass resolution and no dual-run
comparison in tests, where
`spark.sql.analyzer.singlePassResolver.dualRunWithLegacy` defaults to true.
Results under the default configuration are unaffected, because the single-pass
resolver is off. Adding `IsJson` to that JSON arm, with guard-suite cases,
would close the gap.
**Verification:**
- **Behavior:** The resolver guard accepts SELECT '{}' IS JSON, SELECT '{}'
IS NOT JSON OBJECT and SELECT NULL IS JSON.
- **Compatibility:** With dual-run analysis enabled, IS JSON queries over
STRING, collated, CHAR/VARCHAR and untyped NULL operands produce identical
plans from both analyzers.
##########
sql/catalyst/src/main/java/org/apache/spark/sql/catalyst/expressions/json/JsonExpressionUtils.java:
##########
@@ -117,4 +122,151 @@ 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. Malformed input yields false. Input that exceeds one of Jackson's
default
+ * {@code StreamReadConstraints} (nesting depth, number length, string
length, or field name
+ * length) cannot be classified and raises an IS_JSON_INPUT_LIMIT_EXCEEDED
condition, at the
+ * same limits where {@code parse_json} fails -- but only if checking the
input actually
+ * requires going past a limit: a shape mismatch decided by the first token
(including an
+ * over-long root number under OBJECT or ARRAY, which cannot be an object or
an array)
+ * returns false, like any other mismatch.
+ */
+ public static boolean isJson(UTF8String json, int shape) {
+ // The parser decodes the bytes through a UTF-8 reader that silently
replaces malformed
+ // sequences with U+FFFD, so check the raw bytes first: input that is not
valid UTF-8
+ // (e.g. a lone continuation byte, or an encoded surrogate) is not
well-formed JSON text.
+ if (!json.isValid()) {
+ return false;
+ }
+ try (JsonParser jsonParser =
+ CreateJacksonParser.utf8String(STRICT_JSON_FACTORY, json)) {
+ JsonToken token;
+ try {
+ token = jsonParser.nextToken();
+ } catch (StreamConstraintsException e) {
+ // Jackson validates a number's length while producing its token, and
no other limit can
+ // be reached while producing the first token (nesting needs a second
token, and strings
+ // and field names are not decoded until getText), so the first value
is an over-long
+ // number. It can never be an object or an array, so the shape is
decided without
+ // classifying the number; the other shapes accept a number and still
need it classified.
+ if (shape == SHAPE_OBJECT || shape == SHAPE_ARRAY) {
+ return false;
+ }
+ throw limitError(e, json);
+ }
+ if (token == null) {
+ // Empty input is not a JSON value.
+ return false;
+ }
+ boolean matchesShape = switch (shape) {
+ case SHAPE_OBJECT -> token == JsonToken.START_OBJECT;
+ case SHAPE_ARRAY -> token == JsonToken.START_ARRAY;
+ case SHAPE_SCALAR -> switch (token) {
+ case VALUE_STRING, VALUE_NUMBER_INT, VALUE_NUMBER_FLOAT,
+ VALUE_TRUE, VALUE_FALSE, VALUE_NULL -> true;
+ default -> false;
+ };
+ // SHAPE_ANY: any well-formed JSON value (object, array, or scalar).
+ default -> switch (token) {
+ case START_OBJECT, START_ARRAY, VALUE_STRING, VALUE_NUMBER_INT,
+ VALUE_NUMBER_FLOAT, VALUE_TRUE, VALUE_FALSE, VALUE_NULL -> true;
+ default -> false;
+ };
+ };
+ if (!matchesShape) {
+ return false;
+ }
+ // Walk the whole value token by token (rather than `skipChildren`,
which does not look at
+ // string contents) so that malformed input (e.g. `{`, `[1,`) surfaces
as a parse error
+ // and every string value and field name can be checked for unpaired
surrogates.
+ int depth = 0;
+ do {
+ switch (token) {
+ case START_OBJECT, START_ARRAY -> depth++;
+ case END_OBJECT, END_ARRAY -> depth--;
+ case FIELD_NAME, VALUE_STRING -> {
+ if (hasUnpairedSurrogate(jsonParser.getText())) {
+ return false;
+ }
+ }
+ default -> { }
+ }
+ } while (depth > 0 && (token = jsonParser.nextToken()) != null);
+ if (depth > 0) {
+ return false;
+ }
+ // Reject trailing content after the first value, e.g. `1 2` or `{} []`,
so only a single
+ // well-formed JSON value is accepted.
+ return jsonParser.nextToken() == null;
Review Comment:
**Nit (P3):** Any trailing token already decides the answer, but this call
runs inside the outer `try` whose `StreamConstraintsException` handler raises
`IS_JSON_INPUT_LIMIT_EXCEEDED`. Suppose a complete value is followed by a
number longer than 1000 characters, such as `concat('{} ', repeat('9', 1001))`
under `IS JSON OBJECT` or `concat('1 ', repeat('9', 1001))` under `IS JSON`.
Jackson rejects that trailing number while producing its token, the same eager
check the first-token handler relies on. The query then fails with
`NUMBER_LENGTH` instead of returning false (true for `IS NOT JSON`).
That contradicts the Javadoc and `sql-ref-is-json.md`, which say trailing
content returns false and the error applies only when checking the input
requires going past a limit. It affects every shape whose first token matches,
so one such row aborts a `WHERE payload IS JSON` filter. Giving this call its
own handler that returns false would fix it. No test covers trailing over-limit
content yet.
This extends an [existing
discussion](https://github.com/apache/spark/pull/58024#issuecomment-6587687004).
**Verification:**
- **Regression:** For every shape whose first token matches (no qualifier or
VALUE, OBJECT, ARRAY and SCALAR), a complete value followed by whitespace and
an integer or floating-point number longer than the maximum number length
returns false, and the IS NOT JSON form returns true, under interpreted and
generated evaluation. Examples: concat('{} ', repeat('9', 1001)) IS JSON
OBJECT, concat('[1] ', repeat('9', 1001)) IS JSON ARRAY, concat('1 ',
repeat('9', 1001)) IS JSON and concat('"a" 0.', repeat('1', 1001)) IS JSON
SCALAR.
- **Behavior:** Existing limit cases still raise NESTING_DEPTH,
NUMBER_LENGTH and NAME_LENGTH with the same parameters, and trailing content
such as '1 2' and '{} []' still returns false.
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/parser/ExpressionParserSuite.scala:
##########
@@ -291,6 +291,45 @@ 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))
+ // Each comment form precedes every type keyword, so the closers only
split if skipping that
Review Comment:
**Nit (P3):** The inputs below put each comment form after every type
keyword, between the keyword and its `<` (`array $c<array $c<int>>`). That is
the gap `isFollowedByLessThan` has to skip. A comment placed before a keyword
would not exercise the lookahead at all, so this description is inverted.
Someone who follows it could move the comments and silently lose the coverage
the loop exists to provide. Saying "after every type keyword, before its `<`"
would match the inputs.
**Verification:**
- **Inspection:** The comment above the comment-form loop describes each
comment as following a type keyword and preceding its '<', matching the inputs.
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/parser/ExpressionParserSuite.scala:
##########
@@ -291,6 +291,45 @@ 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))
+ // Each comment form precedes every type keyword, so the closers only
split if skipping that
+ // comment form works: a single counted keyword would be enough to keep
`>>` apart.
+ Seq("/* c */", "/* a /* b */ c */", "-- c\n", "-- c\r\n", "-- c\r", "-- c
\\\n c\n")
Review Comment:
**Nit (P3):** These cases separate each keyword from `<` with an ASCII space
and a comment. `isFollowedByLessThan` also has to skip the WS rule's no-break
spaces (U+00A0, U+2007 and U+202F), and only its `Character.isSpaceChar` check
does that. If that disjunct were dropped, or the check narrowed to
`Character.isWhitespace`, a type such as `cast(a as
array\u00A0<array\u00A0<int>>)` would lex its closers as a shift and fail to
parse, although it parses on master, and every test would stay green. Adding
the three no-break spaces, written as Unicode escapes, to these two- and
three-level cases would pin the lexer comment's claim that the check covers
every character of the WS rule.
**Verification:**
- **Regression:** The new cases fail if the separator check is narrowed to
Character.isWhitespace, because the closers would then lex as a shift operator.
##########
sql/core/src/test/resources/sql-tests/inputs/json-functions.sql:
##########
@@ -335,3 +335,133 @@ select json_object('a' VALUE '{"b":1}');
select json_object('a' VALUE 1 RETURNING INT);
-- JSON_OBJECT error: malformed FORMAT JSON value
select json_object('a' VALUE '{bad' FORMAT JSON);
+
+-- IS [NOT] JSON
+-- basic well-formedness
+select '{"a":1}' IS JSON;
+select '[1,2]' IS JSON;
+select '42' IS JSON;
+select '"hello"' IS JSON;
+select 'not json' IS JSON;
+select '' IS JSON;
+-- IS NOT JSON
+select '{"a":1}' IS NOT JSON;
+select 'bad' IS NOT JSON;
+-- NULL propagates
+select cast(null as string) IS JSON;
+select cast(null as string) IS NOT JSON;
+-- shape qualifiers
+select '{"a":1}' IS JSON OBJECT;
+select '[1,2]' IS JSON OBJECT;
+select '[1,2]' IS JSON ARRAY;
+select '{"a":1}' IS JSON ARRAY;
+-- IS JSON VALUE: any valid JSON value (including objects and arrays)
+select '42' IS JSON VALUE;
+select '"hello"' IS JSON VALUE;
+select '{"a":1}' IS JSON VALUE;
+select '[1,2]' IS JSON VALUE;
+select 'bad' IS JSON VALUE;
+-- IS JSON SCALAR: only primitives (string, number, boolean, null -- not
object/array)
+select '42' IS JSON SCALAR;
+select '"hello"' IS JSON SCALAR;
+select 'true' IS JSON SCALAR;
+select 'false' IS JSON SCALAR;
+select '[1]' IS JSON SCALAR;
+select '{"a":1}' IS JSON SCALAR;
+-- IS NOT JSON with shape qualifiers
+select '{"a":1}' IS NOT JSON OBJECT;
+select '[1,2]' IS NOT JSON ARRAY;
+select '{"a":1}' IS NOT JSON VALUE;
+select 'bad' IS NOT JSON VALUE;
+select '{"a":1}' IS NOT JSON SCALAR;
+select '42' IS NOT JSON SCALAR;
+-- JSON null literal is valid JSON and counts as a scalar
+select 'null' IS JSON;
+select 'null' IS JSON SCALAR;
+select 'null' IS JSON VALUE;
+select 'null' IS JSON OBJECT;
+-- strict ANSI parsing: Hive-style leniency (single quotes) is rejected
+select '{''a'':1}' IS JSON;
+select '[''a'', ''b'']' IS JSON;
+-- trailing content after a well-formed value is rejected
+select '1 2' IS JSON;
+select '{} []' IS JSON;
+-- other parser extensions are rejected: comments, unquoted names,
NaN/Infinity, leading zeros
+select '/* c */ {}' IS JSON, '{a:1}' IS JSON, 'NaN' IS JSON, 'Infinity' IS
JSON, '01' IS JSON;
+-- whitespace-only input is not a JSON value
+select ' ' IS JSON;
+-- duplicate field names are accepted
+select '{"a":1,"a":2}' IS JSON OBJECT;
+-- input beyond the parser limits raises an error, like parse_json
+select concat(repeat('[', 1001), repeat(']', 1001)) IS JSON;
+-- a shape mismatch is decided by the first token even for over-limit input:
an over-long root
+-- number is neither an object nor an array, so those shapes return false
instead of raising
+select repeat('9', 1001) IS JSON OBJECT, repeat('9', 1001) IS JSON ARRAY;
+-- structurally malformed containers are rejected: truncated input, trailing
commas, missing
+-- values, and a mismatched closer
+select '{' IS JSON, '[1,' IS JSON, '{"a"' IS JSON;
+select '[1,]' IS JSON ARRAY, '{"a":1,}' IS JSON OBJECT, '[1,,2]' IS JSON
ARRAY, '[1}' IS JSON ARRAY;
+-- input that is not valid UTF-8 is rejected (lone continuation byte, encoded
surrogate)
+select cast(X'228022' AS STRING) IS JSON;
+select cast(X'22EDA08022' AS STRING) IS JSON;
+-- unpaired surrogate escapes are rejected in values and field names, also
when nested
+select '"\\uD835"' IS JSON;
+select '"\\uDC00"' IS JSON SCALAR;
+select '{"\\uD835":1}' IS JSON OBJECT;
+select '[{"a":["\\uDC00"]}]' IS JSON ARRAY;
+select '"\\uD835\\uDC00"' IS JSON;
+-- shift operators after the ARRAY qualifier
+select '[1]' IS JSON ARRAY, 8 >> 1, 8 >>> 1;
+-- collated and trim-collated operands behave like STRING
+select '{"a":1}' COLLATE UTF8_LCASE IS JSON OBJECT, '[1] ' COLLATE
UTF8_LCASE_RTRIM IS JSON ARRAY;
+select 'bad' COLLATE UNICODE IS JSON;
+-- by default a CHAR/VARCHAR cast target is rewritten to STRING at parse time,
so this case
+-- exercises a STRING operand
+select cast('[1]' AS VARCHAR(10)) IS JSON ARRAY;
+-- with first-class CHAR/VARCHAR types (preserveCharVarcharTypeInfo or
standard semantics), the
+-- cast keeps its type, so IS JSON gets a real VARCHAR or CHAR operand
Review Comment:
**Nit (P3):** This is true under `preserveCharVarcharTypeInfo` but not under
standard semantics. There, type coercion promotes the operand to STRING, as the
analyzer golden records: `is_json(cast(cast([1] as varchar(10)) as string),
Array)`. The standard-semantics block also has only VARCHAR casts, so CHAR
promotion is not exercised there. The comment could describe each config
separately, and a CHAR case in the standard-semantics block, with the golden
files regenerated, would close the gap.
**Verification:**
- **Behavior:** Under standard semantics, cast('[1]' AS CHAR(10)) IS JSON
ARRAY returns true, and its analyzed plan shows the operand promoted to STRING.
- **Inspection:** The comment describes real VARCHAR and CHAR operands only
under preserveCharVarcharTypeInfo, and STRING promotion under standard
semantics.
--
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]