[FLINK-40089][table] Implement JSON_LENGTH function - #28688
Conversation
| public static Integer jsonLength(String input, String pathSpec) { | ||
| return jsonLength(jsonApiCommonSyntax(input, pathSpec)); | ||
| } | ||
|
|
||
| private static Integer jsonLength(JsonPathContext context) { | ||
| if (context.hasException()) { | ||
| return null; | ||
| } | ||
| final Object value = context.obj; | ||
| if (value == null) { | ||
| return null; | ||
| } | ||
| if (value instanceof Map) { | ||
| return ((Map<?, ?>) value).size(); | ||
| } | ||
| if (value instanceof Collection) { | ||
| return ((Collection<?>) value).size(); | ||
| } | ||
| return 1; | ||
| } |
There was a problem hiding this comment.
Why to we need this? Don't we have already the runtime class JsonLengthFunction?
| super(BuiltInFunctionDefinitions.JSON_LENGTH, context); | ||
| } | ||
|
|
||
| public @Nullable Integer eval(@Nullable StringData jsonInput) { |
There was a problem hiding this comment.
nit: here and everywhere else make parameters/variables that are immutable final (not enforced on Flink but nicer to read).
| public @Nullable Integer eval(@Nullable StringData jsonInput) { | |
| public @Nullable Integer eval(final @Nullable StringData jsonInput) { |
gustavodemorais
left a comment
There was a problem hiding this comment.
Thanks for the contribution, @VasShabu! Good job on the first version of the PR. We'll go through some iterations to make sure things look good
Apart from what we already flagged above: we have two implementations of the same function. eval(json) uses a new Jackson readTree, eval(json, path) uses the existing Calcite path in SqlJsonUtils. Different parsers + different data models = they can disagree on the same input. We can make the design more consistent by using only one path. Duplicate ObjectMapper - reuse the configured one in SqlJsonUtils instead of a second bare instance. This is important because built-in functions are on the hot path. The logic might be invoked millions of times in a couple of seconds and we have to make sure they allocate as little as possible and their performance are optimized or else the engine will be wasting resources.
That said, In this case, we could go two paths
- Best performance: We build a custom parser to optimize it for length checking by skipping the children. MAPPER.getFactory().createParser(str), read first token; if START_ARRAY/START_OBJECT walk top level counting, skipping children via parser.skipChildren(). No tree alloc. Best for length-only.
- Simplest reuse the shared SqlJsonUtils path so both overloads share one parser.
Take a look at both paths and let me know what you think
| } else if (jsonNode.isTextual() | ||
| || jsonNode.isNumber() | ||
| || jsonNode.isBoolean() | ||
| || jsonNode.isBinary()) { |
There was a problem hiding this comment.
Isn't this check dead code? Can a jsonNode be binary? You can try to write a test to check if it's dead code or it's possible
There was a problem hiding this comment.
Are we sure the selected approach is the right one?
With this approach the same JSON will be parsed several times for queries like
SELECT JSON_LENGTH(my_json), JSON_LENGTH(my_json), JSON_LENGTH(my_json) FROM...or
SELECT JSON_LENGTH(my_json), JSON_VALUE(my_json, '$.a') FROM...| table: jsonLength(jsonObject[, path]) | ||
| description: | | ||
| Returns the number of elements in a JSON document, or the length of the value at the specified path if one is provided. | ||
| Returns NULL if the argument is NULL or the path does not locate a value. |
There was a problem hiding this comment.
this does not look true
simple tests show different behavior
SELECT json_length('$');
SELECT json_length('{');each returns -1 and the doc says nothing about this or did I miss anything?
There was a problem hiding this comment.
More findings:
- Doc doesn't reflect the real behavior: it returns
-1in case of invalid json, while docs says aboutNULL, e.g. querySELECT JSON_LENGTH('null'); - Strange handling of
nullvalues
like a queryboth in MySQL and in Flink it returnsSELECT json_length('{"a":[true, false, null]}', '$.a[1]')
1
nowin MySQL it returnsSELECT json_length('{"a":[true, false, null]}', '$.a[2]')
1, in Flink it returns-1why? - Seems need to check what the behavior should be in case of
lax/strictsince right now it is weirdSELECT json_length('{"x": {"a":1}, "y": {"a":[2, 3]}}', 'lax $.*.a'); -- returns 2
SELECT json_length('{"x": {"a":[1, 2, 3, 4]}, "y": {"a":[2, 3]}}', 'lax $.*.a'); -- returns 2
SELECT json_length('{"x": {"a":[3, 2 , 3]}, "y": {"a":null}, "z":{"a":1}}', 'lax *.a'); -- returns 3
SELECT json_length('{"x": {"a":[3, 2 , 3]}, "y": {"a":null}, "z":{"a":1}}', 'lax *.a[0]'); --returns 1
SELECT json_length('{"x": {"a":[3, 2 , 3]}, "y": {"a":null}, "z":{"a":1}}', 'lax *.a[1]'); --returns 1
SELECT json_length('{"x": {"a":[3, 2 , 3]}, "y": {"a":null}, "z":{"a":1}}', 'lax *.a[2]'); --returns 1
SELECT json_length('{"x": {"a":[3, 2 , 3]}, "y": {"a":null}, "z":{"a":1}}', 'lax *.a[3]'); --returns 0
| """ | ||
| return _unary_op("jsonUnquote")(self) | ||
|
|
||
| def json_length(self, path = None) -> 'Expression': |
There was a problem hiding this comment.
It seems that many databases throw an error, I am curious why we are not throwing an error. Also I see that there are best practises that we should document like using IF(string_column IS JSON, to protect against non json and avoid potential errors.
| final JsonPathContext context = jsonApiCommonSyntax(parsedInput, pathSpec); | ||
| final Object value = context.hasException() ? null : context.obj; | ||
|
|
||
| if (value instanceof LinkedList) { |
There was a problem hiding this comment.
is there a reason we expect LinkedList instead of just List?
There was a problem hiding this comment.
the comment marked as resolved however no answer yet
can you elaborate here?
| logger.codelog.name = org.apache.flink.table.runtime.generated.CompileUtils | ||
| logger.codelog.level = DEBUG | ||
| logger.codelog.appenderRef.test.ref = TestLogger |
There was a problem hiding this comment.
it might be helpful for local debug, however why do we need in repo and making it executable for every ci run?
| if (matched.size() != 1) { | ||
| if (context.mode == PathMode.STRICT || matched.isEmpty()) { | ||
| return null; | ||
| } | ||
| return matched.size(); | ||
| } |
There was a problem hiding this comment.
Could you please elaborate on this? In which conditions we get a null and in which conditions we get the size of the matched?
There was a problem hiding this comment.
looks like not addressed yet
| #logger.codelog.name = org.apache.flink.table.runtime.generated.CompileUtils | ||
| #logger.codelog.level = DEBUG | ||
| #logger.codelog.appenderRef.test.ref = TestLogger | ||
|
|
There was a problem hiding this comment.
can we just drop it?
keeping log setting for every helper here might be overkill
| generateCallWithStmtIfArgsNotNull(ctx, resultType, operands, resultNullable = true) { | ||
| argTerms => | ||
| val inputTerm = s"${argTerms.head}.toString()" | ||
| val (varName, parseCode) = | ||
| ctx.getReusableInputUnboxingExprs(inputTerm, Int.MinValue) match { | ||
| case Some(expr) => (expr.resultTerm, "") | ||
| case None => | ||
| val v = CodeGenUtils.newName(ctx, "jsonParsed") | ||
| ctx.addReusableMember( | ||
| s"${classOf[SqlJsonUtils.JsonValueContext].getName} $v;") | ||
| ctx.addReusableInputUnboxingExprs( | ||
| inputTerm, | ||
| Int.MinValue, | ||
| GeneratedExpression(v, "false", "", null)) | ||
| (v, s"$v = ${qualifyMethod(BuiltInMethods.JSON_PARSE)}($inputTerm);") | ||
| } | ||
| val (method, terms) = | ||
| if (operands.length > 1) | ||
| (BuiltInMethods.JSON_LENGTH_PATH, Seq(varName, s"${argTerms(1)}.toString()")) | ||
| else | ||
| (BuiltInMethods.JSON_LENGTH, Seq(varName)) | ||
| (parseCode, s"${qualifyMethod(method)}(${terms.mkString(", ")})") |
There was a problem hiding this comment.
is it possible to extract it ideally to java helper?
There was a problem hiding this comment.
it seems the lax problem is still not solved
to highlight the issue there are 2 queries:
1.
SELECT json_length('{"x": {"a":[3, 2 , 3]}}', 'lax *.a');which returns 3, I guess the size of x.a.
2.
SELECT json_length('{"x": {"a":[3, 2 , 3]}, "y": {"a":null}}', 'lax *.a');now returns 2 which is amount of x.a, not the amount of elements.
There is nothing about this in doc, and it is very non obvious that depending on query the same syntax will mean completely different things
and there is no info about this in docs
3eabbcc to
4a0aa91
Compare
|
I have decided to go with the approach of not allowing the use of wildcards, just like how MariaDB chose to implement this because when you have 2 potential paths, the output of the function is ambiguous. you will not know as a user if you have found a path with 5 element or if you pattern matches 5 different paths. |
|
|
||
| Nested arrays and objects each count as a single element and their contents are not included in the count. | ||
|
|
||
| When provided with a path that uses a wildcard and resolves in 2 or more paths, JSON_LENGTH will resolve as NULL. |
There was a problem hiding this comment.
Make sure to sync the chinese version
| return _unary_op("jsonUnquote")(self) | ||
|
|
||
| def json_length(self, path = None) -> 'Expression': | ||
| """ |
There was a problem hiding this comment.
Use the same javadoc and the one you added to the java interface in BaseExpressions.java. We use the Python API as a mirror of the java one and we don't want to have two versions
| * <li>Returns Null if the json input is Null or if the given path does not resolve to | ||
| * anything. | ||
| * </ul> | ||
| * |
There was a problem hiding this comment.
Examples are always very useful so add an example here as well
| table: jsonLength(jsonObject[, path]) | ||
| description: | | ||
| Returns the number of elements in a JSON document, or the length of the value at the specified path if one is provided. | ||
| Returns NULL if the argument is NULL or the path does not locate a value. |
There was a problem hiding this comment.
| Returns NULL if the argument is NULL or the path does not locate a value. | |
| Returns NULL if the argument is NULL, the json is invalid, or the path does not locate a value. |
| return toApiSpecificExpression(unresolvedCall(JSON_LENGTH, toExpr())); | ||
| } | ||
|
|
||
| public OutType jsonLength(String path) { |
There was a problem hiding this comment.
Add documentation and examples here
| .testSqlResult("JSON_LENGTH('{\"a\":[1,2,3]}', '$.b')", null, INT().nullable()) | ||
| .testSqlResult("JSON_LENGTH('{\"a\":[1,2,3]}', '$.a')", 3, INT().nullable()) | ||
|
|
||
| // lax vs strict |
There was a problem hiding this comment.
Since we've said both behaviors will be the same, I guess "// lax vs strict" tests are not bringing value and we can delete them?
We should reject lax/strict mode with a nice error stating that we don't support and and point users to use the helper functions "IS JSON/JSON_EXISTS"
| JSON_LENGTH('hello') | ||
| -- 1 | ||
|
|
||
| JSON_LENGTH('{1: "hello", 2: "bye bye"}', '$.2') |
There was a problem hiding this comment.
Let's add an example using it with IS_JSON for the case where the user pass on param and JSON_EXISTS for the case with a path. We always want to point users to good patterns, which in this case is using helper functions to make sure they explicitly deal with invalid json
| if (value instanceof Map) { | ||
| return ((Map<?, ?>) value).size(); | ||
| } | ||
| if (value instanceof Collection) { | ||
| return ((Collection<?>) value).size(); | ||
| } | ||
| // Scalars, including a JSON null literal, have length 1. | ||
| return 1; |
There was a problem hiding this comment.
are we sure it can not be array here?
| generateCallWithStmtIfArgsNotNull(ctx, resultType, operands, resultNullable = true) { | ||
| argTerms => | ||
| val inputTerm = s"${argTerms.head}.toString()" | ||
| val parsed = JsonCodeGenHelper.getOrCreateParsedJson(ctx, inputTerm) | ||
| val varName = parsed.varName | ||
| val (method, terms) = | ||
| if (operands.length > 1) | ||
| (BuiltInMethods.JSON_LENGTH_PATH, Seq(varName, s"${argTerms(1)}.toString()")) | ||
| else | ||
| (BuiltInMethods.JSON_LENGTH, Seq(varName)) | ||
| (parsed.parseCode, s"${qualifyMethod(method)}(${terms.mkString(", ")})") |
There was a problem hiding this comment.
is there a reason we still have this code here?
Didn't we say that it should be extracted in java class?
| JSON_LENGTH('hello') | ||
|
|
||
| -- 1 | ||
| JSON_LENGTH('{1: "hello", 2: "bye bye"}', '$.2') |
There was a problem hiding this comment.
the example is invalid
you can check it's output in flink-sql tool
There was a problem hiding this comment.
also see https://www.json.org/json-en.html
for more details: only string key is allowed
| Returns NULL if the argument is NULL, the json is invalid, or the path does not locate a value. | ||
| eg. | ||
| -- 2 | ||
| JSON_LENGTH('{1: "hello", 2: "bye bye"}') |
There was a problem hiding this comment.
it does not work for the case of numbers in keys
moreover: json itself is invalid in this example
snuyanzin
left a comment
There was a problem hiding this comment.
Please fix examples:
in a number of examples json is invalid
| Returns NULL if the argument is NULL, the json is invalid, or the path does not locate a value. | ||
| eg. | ||
| -- 2 | ||
| JSON_LENGTH('{1: "hello", 2: "bye bye"}') |
snuyanzin
left a comment
There was a problem hiding this comment.
it looks like we still do more parse operations than we need
especially here https://github.com/apache/flink/pull/28688/changes#r3658409889
|
Also this query is still failing (with empty path) SELECT json_length('{}', '');as |
| public static Integer jsonLength(final JsonValueContext parsedInput) { | ||
| // TODO FLINK-40233: A null context can result from a shared parse that was short-circuited | ||
| // before parsing. | ||
| if (parsedInput.hasException()) { |
There was a problem hiding this comment.
parsedInput might be null if input was null e.g. for queries
SELECT json_value(v, cast(null as string)), json_length(v) FROM (values('{"a":1, "b":2}')) AS t(v);| @@ -374,6 +389,72 @@ private static Object errorResultForJsonQuery( | |||
| } | |||
| } | |||
|
|
|||
| /** Accepts a pre-parsed context from {@link #jsonParse}. */ | |||
| public static Integer jsonLength(final JsonValueContext parsedInput) { | |||
| // TODO FLINK-40233: A null context can result from a shared parse that was short-circuited | |||
There was a problem hiding this comment.
I guess no need for this TODO anymore since it is merged
[FLINK-40089][table] decided how to deal with lax *
…ith JSON_LENGTH + pr issues
…null, true, false' issue
…with jsonlength + to javadoc
…JS_VAL,JS_QUERY,JS_LENGTH at once)
…() + moved jsLeng in sqlFuncZh.yml
246c8e1 to
7e21cc3
Compare
What is the purpose of the change
This pull request adds support for the built-in SQL function
JSON_LENGTH, which returns the number of top-level elements in a JSON array or object. Scalars have a length of 1, andNULLinput returnsNULL. Nested arrays or objects are not counted recursively.Brief change log
JsonLengthFunctionimplementing theJSON_LENGTHbuilt-in functionJSON_LENGTHas a built-in function in the function catalogJSON_LENGTHinJsonFunctionsITCaseVerifying this change
Please make sure both new and modified tests in this PR follow the conventions for tests defined in our code quality guide.
This change added tests and can be verified as follows:
mvn test -pl flink-table/flink-table-runtime -Dtest=JsonFunctionsITCaseJSON_LENGTHcovering scalars, arrays, objects, andNULLinput inJsonFunctionsITCaseDoes this pull request potentially affect one of the following parts:
@Public(Evolving): yesDocumentation
Was generative AI tooling used to co-author this PR?