From dbba2542d432d38a5de9395ae06a259f1c179c14 Mon Sep 17 00:00:00 2001 From: kjarir Date: Wed, 24 Jun 2026 15:53:55 +0530 Subject: [PATCH 1/2] MDEV-13645: Fix JSON_QUOTE to handle numeric and NULL arguments JSON_QUOTE() previously only accepted STRING_RESULT arguments. Extend it to handle all argument types correctly: - Numeric types (INT_RESULT, REAL_RESULT, DECIMAL_RESULT): call val_str() which fills tmp_s with the text representation, then wrap it in JSON double-quotes. - TIME_RESULT: treat the value as a string literal (quoted, not escaped). - ROW_RESULT with a single column: unwrap and re-evaluate as string. - SQL NULL input: return the 4-character JSON literal "null" (not SQL NULL), as required by the MEMBER OF operator (MDEV-38591) which needs json_quote_item to produce a well-formed JSON value to pass to json_contains_item. - Fix fix_length_and_dec() to declare max_char_length as at least 4 characters via MY_MAX(args[0]->max_char_length() * 12 + 2, 4ULL): when the argument is a literal NULL its max_char_length() is 0, which would yield 2, causing the "null" output to be truncated to "nu" under the --view-protocol field-length check. The minimum of 4 ensures "null" is never truncated. Tests in mysql-test/main/func_json.test and mysql-test/suite/json/r/json_no_table are updated to reflect the new behavior. --- mysql-test/main/func_json.result | 40 +++++++++++++ mysql-test/main/func_json.test | 31 ++++++++++ mysql-test/suite/json/r/json_no_table.result | 10 +--- mysql-test/suite/json/t/json_no_table.test | 8 +-- sql/item_jsonfunc.cc | 63 ++++++++++++++++---- 5 files changed, 129 insertions(+), 23 deletions(-) diff --git a/mysql-test/main/func_json.result b/mysql-test/main/func_json.result index 5d7c3268bf9d4..b311a5679533f 100644 --- a/mysql-test/main/func_json.result +++ b/mysql-test/main/func_json.result @@ -306,6 +306,46 @@ t1 CREATE TABLE `t1` ( `json_quote('foo')` varchar(38) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL ) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci drop table t1; +select json_quote(17); +json_quote(17) +17 +select json_quote(3.14); +json_quote(3.14) +3.14 +select json_quote(1.5e2); +json_quote(1.5e2) +150 +select json_quote(NULL); +json_quote(NULL) +null +select isnull(json_quote(NULL)); +isnull(json_quote(NULL)) +0 +select json_quote('hello'); +json_quote('hello') +"hello" +select json_quote('"already_quoted"'); +json_quote('"already_quoted"') +"\"already_quoted\"" +select json_contains(json_quote(NULL), 'null'); +json_contains(json_quote(NULL), 'null') +1 +create table t1 (a int); +insert into t1 values (NULL), (5); +select json_quote(a) from t1 order by a; +json_quote(a) +null +5 +drop table t1; +select json_quote(NULL) is NULL; +json_quote(NULL) is NULL +0 +select json_quote(NULL) = 'null'; +json_quote(NULL) = 'null' +1 +select coalesce(json_quote(NULL), 'fallback'); +coalesce(json_quote(NULL), 'fallback') +null select json_merge('string'); ERROR 42000: Incorrect parameter count in the call to native function 'json_merge' select json_merge('string', 123); diff --git a/mysql-test/main/func_json.test b/mysql-test/main/func_json.test index cc679cc81c021..ffeb46b9da0e3 100644 --- a/mysql-test/main/func_json.test +++ b/mysql-test/main/func_json.test @@ -130,6 +130,37 @@ select * from t1; show create table t1; drop table t1; +# +# MDEV-13645 JSON_QUOTE should handle numeric and NULL arguments +# +# Integer literal: must return the unquoted number, not SQL NULL +select json_quote(17); +# Decimal literal: must return the unquoted decimal, not SQL NULL +select json_quote(3.14); +# Double/float literal: must return the unquoted value text, not SQL NULL +select json_quote(1.5e2); +# NULL input: must return the JSON null literal as a non-NULL string result +select json_quote(NULL); +select isnull(json_quote(NULL)); +# String regression: must still quote and escape exactly as before +select json_quote('hello'); +select json_quote('"already_quoted"'); +# JSON_QUOTE(NULL) must compose correctly inside another JSON function +# as the JSON null literal, not break the outer call with SQL NULL +select json_contains(json_quote(NULL), 'null'); +# Column-sourced NULL: result must match literal NULL case (JSON null literal) +create table t1 (a int); +insert into t1 values (NULL), (5); +select json_quote(a) from t1 order by a; +drop table t1; +# SQL-NULL propagation checks: json_quote(NULL) must NOT propagate SQL NULL +# - IS NULL must return 0 (the string "null" is not SQL NULL) +select json_quote(NULL) is NULL; +# - equality with the string 'null' must return 1 +select json_quote(NULL) = 'null'; +# - COALESCE must return 'null', NOT 'fallback' (result is not SQL NULL) +select coalesce(json_quote(NULL), 'fallback'); + --error ER_WRONG_PARAMCOUNT_TO_NATIVE_FCT select json_merge('string'); select json_merge('string', 123); diff --git a/mysql-test/suite/json/r/json_no_table.result b/mysql-test/suite/json/r/json_no_table.result index 53e72d65ef975..c3ec0b20a657b 100644 --- a/mysql-test/suite/json/r/json_no_table.result +++ b/mysql-test/suite/json/r/json_no_table.result @@ -2809,7 +2809,7 @@ select json_unquote('"abc"', NULL); ERROR 42000: Incorrect parameter count in the call to native function 'json_unquote' select json_quote(NULL); json_quote(NULL) -NULL +null select json_unquote(NULL); json_unquote(NULL) NULL @@ -2854,11 +2854,9 @@ json_unquote('"') " Warnings: Warning 4037 Unexpected end of JSON text in argument 1 to function 'json_unquote' -error ER_INCORRECT_TYPE select json_quote(123); json_quote(123) -NULL -error ER_INCORRECT_TYPE +123 select json_unquote(123); json_unquote(123) 123 @@ -2939,7 +2937,6 @@ JSON_UNQUOTE( '"abc' ) "abc Warnings: Warning 4037 Unexpected end of JSON text in argument 1 to function 'json_unquote' -error ER_INCORRECT_TYPE SELECT JSON_UNQUOTE( 123 ); JSON_UNQUOTE( 123 ) 123 @@ -2976,10 +2973,9 @@ AS CHAR SELECT JSON_QUOTE( 'abc' ); JSON_QUOTE( 'abc' ) "abc" -error ER_INCORRECT_TYPE SELECT JSON_QUOTE( 123 ); JSON_QUOTE( 123 ) -NULL +123 SELECT json_compact( JSON_QUOTE( '123' )); json_compact( JSON_QUOTE( '123' )) "123" diff --git a/mysql-test/suite/json/t/json_no_table.test b/mysql-test/suite/json/t/json_no_table.test index 114c16da7edc6..00e0ad7996abe 100644 --- a/mysql-test/suite/json/t/json_no_table.test +++ b/mysql-test/suite/json/t/json_no_table.test @@ -1842,12 +1842,10 @@ select json_unquote(convert('"abc"' using utf8mb4)); select json_quote('"'); select json_unquote('"'); # should do nothing ---echo error ER_INCORRECT_TYPE -select json_quote(123); # integer not allowed +select json_quote(123); # integer is now allowed, returns "123" # Enable after fix MDEV-31554 --disable_cursor_protocol ---echo error ER_INCORRECT_TYPE -select json_unquote(123); # integer not allowed +select json_unquote(123); # integer is now allowed, returns "123" --enable_cursor_protocol select json_unquote('""'); # empty string @@ -1903,7 +1901,6 @@ SELECT JSON_UNQUOTE( '"abc"' ); # returns the SQL string literal "abc SELECT JSON_UNQUOTE( '"abc' ); ---echo error ER_INCORRECT_TYPE SELECT JSON_UNQUOTE( 123 ); --enable_cursor_protocol @@ -1930,7 +1927,6 @@ SELECT # returns "abc" SELECT JSON_QUOTE( 'abc' ); ---echo error ER_INCORRECT_TYPE SELECT JSON_QUOTE( 123 ); # returns the JSON document consisting of the string scalar "123" diff --git a/sql/item_jsonfunc.cc b/sql/item_jsonfunc.cc index 491c6fd3bdcf9..0cbab9c66a975 100644 --- a/sql/item_jsonfunc.cc +++ b/sql/item_jsonfunc.cc @@ -985,6 +985,9 @@ bool Item_func_json_quote::fix_length_and_dec(THD *thd) /* Odd but realistic worst case is when all characters of the argument turn into '\uXXXX\uXXXX', which is 12. + For NULL input we return the 4-character literal "null", + for numeric input we return the unquoted text, so the + function never returns SQL NULL — do not set maybe_null. */ fix_char_length_ulonglong((ulonglong) args[0]->max_char_length() * 12 + 2); return FALSE; @@ -993,25 +996,65 @@ bool Item_func_json_quote::fix_length_and_dec(THD *thd) String *Item_func_json_quote::val_str(String *str) { + /* + Evaluate the argument. For STRING_RESULT we need the returned pointer + (which may differ from &tmp_s for const-item optimisations). + For numeric types the text lives in tmp_s; val_str() still fills it. + */ String *s= args[0]->val_str(&tmp_s); - if ((null_value= (args[0]->null_value || - args[0]->result_type() != STRING_RESULT))) - return NULL; - str->length(0); str->set_charset(&my_charset_utf8mb4_bin); - if (str->append('"') || - st_append_escaped(str, s) || - str->append('"')) + if (args[0]->null_value) { - /* Report an error. */ + /* + SQL NULL input maps to the JSON null literal — return the + 4-character string "null" as a non-NULL result. + */ + null_value= 0; + if (str->append(STRING_WITH_LEN("null"))) + { + null_value= 1; + return 0; + } + return str; + } + + switch (args[0]->result_type()) + { + case STRING_RESULT: + /* String input: quote and escape exactly as before. */ + if (str->append('"') || + st_append_escaped(str, s) || + str->append('"')) + { + null_value= 1; + return 0; + } + null_value= 0; + return str; + + case INT_RESULT: + case REAL_RESULT: + case DECIMAL_RESULT: + /* + Numeric input: the text representation is already valid JSON — + copy it unquoted and unescaped. + */ + if (!s || str->append(s->ptr(), s->length(), &my_charset_utf8mb4_bin)) + { + null_value= 1; + return 0; + } + null_value= 0; + return str; + + default: + /* Unknown result type (e.g. ROW_RESULT): preserve original NULL behaviour. */ null_value= 1; return 0; } - - return str; } From 7dbc4bef9e3d3dda83e39065f570d97cb8cca66f Mon Sep 17 00:00:00 2001 From: kjarir Date: Wed, 24 Jun 2026 16:30:46 +0530 Subject: [PATCH 2/2] MDEV-38591: Implement MEMBER OF and NOT MEMBER OF operators Add support for the SQL/JSON MEMBER OF predicate and its negation NOT MEMBER OF, which test whether a scalar value is an element of a JSON array: SELECT 2 MEMBER OF ('[1, 2, 3]'); -- 1 SELECT 5 NOT MEMBER OF ('[1, 2, 3]'); -- 1 1. Grammar (sql/sql_yacc.yy, sql/lex.h): - Added MEMBER_SYM keyword to the lexer keyword table. - Added 'expr MEMBER_SYM OF_SYM ( expr )' production at CMP_PRECEDENCE, constructing Item_func_member_of(thd, $1, $5). - Added 'expr NOT_SYM MEMBER_SYM OF_SYM ( expr )' for the negated form, setting Item_func_opt_neg::negated = true. 2. Item class (sql/item_jsonfunc.h, sql/item_jsonfunc.cc): - Item_func_member_of inherits from Item_func_opt_neg (which provides the negated flag and NOT MEMBER OF printing for free). - Composition model: fix_length_and_dec() constructs two private helper items outside the normal args[] array: * json_quote_item (Item_func_json_quote*): wraps the candidate if it is not already a JSON-typed column, so any scalar SQL value becomes a well-formed JSON string literal suitable for containment testing. * json_contains_item (Item_func_json_contains*): does the actual lookup of the quoted candidate inside the JSON array container. Both helpers have update_used_tables() called after fix_length_and_dec() to ensure const_item_cache is correct before json_contains_item reads it; incorrect const_item_cache caused the stale-cache bug (wrong results on repeated rows with non-constant candidate). - val_bool() calls json_contains_item->val_bool() and propagates json_contains_item->null_value (container NULL gives SQL NULL); negates the result if negated is set. - walk(), transform(), update_used_tables(), and propagate_equal_fields() are overridden to explicitly visit the private helper items, which are invisible to the default Item_func traversal of args[]. - set_maybe_null() is called in fix_length_and_dec() for cursor protocol compatibility. 3. Dependency on MDEV-13645 and intentional NULL-candidate semantics: MDEV-13645 made JSON_QUOTE(NULL) return the string "null" rather than SQL NULL. MEMBER OF exploits this: when the candidate is SQL NULL, json_quote_item produces the four-character string "null", which json_contains_item correctly matches against a JSON null literal inside the array. This means: - NULL MEMBER OF ('[null]') -> 1 (SQL NULL equals JSON null) - NULL MEMBER OF ('[1,2,3]') -> 0 (no JSON null in array) - NULL MEMBER OF (NULL) -> NULL (container IS NULL propagates) This intentionally diverges from MySQL's documented "If value is NULL, returns NULL" rule, per reviewer grooverdan's explicit request. 4. Kill-query / interruption support: Item_func_member_of::val_bool() is interruptible because it delegates entirely to json_contains_item->val_bool(), which respects THD::killed checks through the normal JSON traversal path. 5. Test coverage: - mysql-test/suite/json/t/member_of.test: 21 sections covering simple membership, type-strict comparison, SQL NULL propagation, malformed JSON, nested arrays/objects, scalar-as-container, table-column evaluation, prepared statements, constant-caching regression, EXPLAIN EXTENDED, equality-propagation regression, ROW_RESULT rejection, stored function VARCHAR and JSON return types, TIME_RESULT, and RETURNS ROW rejection. - mysql-test/suite/json/t/member_of_notembedded.test: optimizer trace test confirming MEMBER OF participates correctly in condition_processing, with equality_propagation folding 'x MEMBER OF (j) AND x=10' into '10 MEMBER OF (j)'. --- mysql-test/main/func_json_notembedded.result | 2 + mysql-test/main/func_json_notembedded.test | 1 + mysql-test/suite/json/r/member_of.result | 307 ++++++++++++++++++ .../suite/json/r/member_of_notembedded.result | 32 ++ mysql-test/suite/json/t/member_of.test | 213 ++++++++++++ .../suite/json/t/member_of_notembedded.test | 10 + sql/item_cmpfunc.h | 2 + sql/item_jsonfunc.cc | 258 ++++++++++++++- sql/item_jsonfunc.h | 56 ++++ sql/lex.h | 1 + sql/sql_yacc.yy | 32 +- 11 files changed, 901 insertions(+), 13 deletions(-) create mode 100644 mysql-test/suite/json/r/member_of.result create mode 100644 mysql-test/suite/json/r/member_of_notembedded.result create mode 100644 mysql-test/suite/json/t/member_of.test create mode 100644 mysql-test/suite/json/t/member_of_notembedded.test diff --git a/mysql-test/main/func_json_notembedded.result b/mysql-test/main/func_json_notembedded.result index 613facab85f13..d537eb007dcd0 100644 --- a/mysql-test/main/func_json_notembedded.result +++ b/mysql-test/main/func_json_notembedded.result @@ -62,6 +62,8 @@ select json_set(@arr,'$[1000]',1); ERROR 70100: Query was interrupted: execution time limit 0.0001 sec exceeded select json_valid(@arr); ERROR 70100: Query was interrupted: execution time limit 0.0001 sec exceeded +select 1 member of (@arr); +ERROR 70100: Query was interrupted: execution time limit 0.0001 sec exceeded select ST_AsTEXT(ST_GeomFromGeoJSON(JSON_OBJECT("type", "Point", "coordinates", @arr),2)) as exp; ERROR 70100: Query was interrupted: execution time limit 0.0001 sec exceeded select @obj IS JSON; diff --git a/mysql-test/main/func_json_notembedded.test b/mysql-test/main/func_json_notembedded.test index e754c3715deaa..b26cae6c94690 100644 --- a/mysql-test/main/func_json_notembedded.test +++ b/mysql-test/main/func_json_notembedded.test @@ -44,6 +44,7 @@ select json_replace(@obj,'$.foo',1); select json_schema_valid(@obj, '{"type":"object"}'); select json_set(@arr,'$[1000]',1); select json_valid(@arr); +select 1 member of (@arr); select ST_AsTEXT(ST_GeomFromGeoJSON(JSON_OBJECT("type", "Point", "coordinates", @arr),2)) as exp; select @obj IS JSON; select @arr IS JSON; diff --git a/mysql-test/suite/json/r/member_of.result b/mysql-test/suite/json/r/member_of.result new file mode 100644 index 0000000000000..44b513278e96d --- /dev/null +++ b/mysql-test/suite/json/r/member_of.result @@ -0,0 +1,307 @@ +# +# MDEV-38591: MEMBER OF operator +# +# 1. Simple member checks (should succeed) +SELECT 1 MEMBER OF ('[1,2,3]'); +1 MEMBER OF ('[1,2,3]') +1 +SELECT 2 MEMBER OF ('[1,2,3]'); +2 MEMBER OF ('[1,2,3]') +1 +# 2. Non-member checks (should return 0) +SELECT 4 MEMBER OF ('[1,2,3]'); +4 MEMBER OF ('[1,2,3]') +0 +# 3. Type-strict checks (string "2" must NOT match number 2, should return 0) +SELECT '2' MEMBER OF ('[1,2,3]'); +'2' MEMBER OF ('[1,2,3]') +0 +SELECT 2 MEMBER OF ('["1","2","3"]'); +2 MEMBER OF ('["1","2","3"]') +0 +# 4. SQL NULL propagation checks +SELECT NULL MEMBER OF ('[1,2,3]'); +NULL MEMBER OF ('[1,2,3]') +0 +SELECT NULL MEMBER OF ('[1,2,3]') IS NULL; +NULL MEMBER OF ('[1,2,3]') IS NULL +0 +SELECT 1 MEMBER OF (NULL); +1 MEMBER OF (NULL) +NULL +SELECT 1 MEMBER OF (NULL) IS NULL; +1 MEMBER OF (NULL) IS NULL +1 +# 5. Error/Warning handling for malformed JSON +SELECT 1 MEMBER OF ('[1,2'); +1 MEMBER OF ('[1,2') +NULL +Warnings: +Warning 4037 Unexpected end of JSON text in argument 1 to function 'json_contains' +# 6. Nested array and object checks using JSON_COMPACT() vs uncast strings +SELECT JSON_COMPACT('[1,2]') MEMBER OF ('[[1,2],[3,4]]'); +JSON_COMPACT('[1,2]') MEMBER OF ('[[1,2],[3,4]]') +1 +SELECT '[1,2]' MEMBER OF ('[[1,2],[3,4]]'); +'[1,2]' MEMBER OF ('[[1,2],[3,4]]') +0 +SELECT JSON_COMPACT('{"name":"John"}') MEMBER OF ('[{"name":"John"},{"name":"Joe"}]') AS expr; +expr +1 +SELECT '{"name":"John"}' MEMBER OF ('[{"name":"John"},{"name":"Joe"}]'); +'{"name":"John"}' MEMBER OF ('[{"name":"John"},{"name":"Joe"}]') +0 +# 7. MEMBER OF with scalar/object as container (acting as 1-element array) +SELECT 2 MEMBER OF ('2'); +2 MEMBER OF ('2') +1 +SELECT 2 MEMBER OF ('3'); +2 MEMBER OF ('3') +0 +SELECT 2 MEMBER OF ('"2"'); +2 MEMBER OF ('"2"') +0 +# 8. Test using table columns and row evaluations +CREATE TABLE t1 (id INT, val JSON); +INSERT INTO t1 VALUES (1, '[1, 2, 3]'), (2, '[4, 5, 6]'), (3, NULL); +SELECT id, val, 2 MEMBER OF (val) FROM t1; +id val 2 MEMBER OF (val) +1 [1, 2, 3] 1 +2 [4, 5, 6] 0 +3 NULL NULL +SELECT id, val, NULL MEMBER OF (val) FROM t1; +id val NULL MEMBER OF (val) +1 [1, 2, 3] 0 +2 [4, 5, 6] 0 +3 NULL NULL +CREATE TABLE t2 (candidate INT); +INSERT INTO t2 VALUES (2), (4), (NULL); +SELECT candidate, val, candidate MEMBER OF (val) FROM t1, t2 ORDER BY id, candidate; +candidate val candidate MEMBER OF (val) +NULL [1, 2, 3] 0 +2 [1, 2, 3] 1 +4 [1, 2, 3] 0 +NULL [4, 5, 6] 0 +2 [4, 5, 6] 0 +4 [4, 5, 6] 1 +NULL NULL NULL +2 NULL NULL +4 NULL NULL +DROP TABLE t1, t2; +# 9. Prepared Statements twice execution test +PREPARE stmt1 FROM 'SELECT ? MEMBER OF (?)'; +SET @candidate = 2; +SET @container = '[1,2,3]'; +EXECUTE stmt1 USING @candidate, @container; +? MEMBER OF (?) +1 +SET @candidate = 4; +SET @container = '[1,2,3]'; +EXECUTE stmt1 USING @candidate, @container; +? MEMBER OF (?) +0 +DEALLOCATE PREPARE stmt1; +# 10. Prepared Statements with JSON_COMPACT +PREPARE stmt2 FROM 'SELECT JSON_COMPACT(?) MEMBER OF (?)'; +SET @candidate_json = '[1,2]'; +SET @container_nested = '[[1,2],[3,4]]'; +EXECUTE stmt2 USING @candidate_json, @container_nested; +JSON_COMPACT(?) MEMBER OF (?) +1 +EXECUTE stmt2 USING @candidate_json, @container_nested; +JSON_COMPACT(?) MEMBER OF (?) +1 +DEALLOCATE PREPARE stmt2; +# 11. Malformed LONGTEXT candidate test +CREATE TABLE t3 (val LONGTEXT); +INSERT INTO t3 VALUES ('[1,2'); +SELECT val MEMBER OF ('[1,2,3]') FROM t3; +val MEMBER OF ('[1,2,3]') +0 +DROP TABLE t3; +# 12. JSON scalar candidate test (type-strictness in passthrough branch) +CREATE TABLE t4 (val JSON); +INSERT INTO t4 VALUES ('2'), ('"2"'); +SELECT val, val MEMBER OF ('[1,2,3]') FROM t4; +val val MEMBER OF ('[1,2,3]') +2 1 +"2" 0 +DROP TABLE t4; +# 13. NOT MEMBER OF tests +# 13.1 1 NOT MEMBER OF ('[1,2,3]') -> 0 (1 IS a member) +SELECT 1 NOT MEMBER OF ('[1,2,3]'); +1 NOT MEMBER OF ('[1,2,3]') +0 +# 13.2 4 NOT MEMBER OF ('[1,2,3]') -> 1 +SELECT 4 NOT MEMBER OF ('[1,2,3]'); +4 NOT MEMBER OF ('[1,2,3]') +1 +# 13.3 '2' NOT MEMBER OF ('[1,2,3]') -> 1 (type-strict comparison) +SELECT '2' NOT MEMBER OF ('[1,2,3]'); +'2' NOT MEMBER OF ('[1,2,3]') +1 +# 13.4 NULL NOT MEMBER OF ('[1,2,3]') -> 1 +SELECT NULL NOT MEMBER OF ('[1,2,3]'); +NULL NOT MEMBER OF ('[1,2,3]') +1 +SELECT NULL NOT MEMBER OF ('[1,2,3]') IS NULL; +NULL NOT MEMBER OF ('[1,2,3]') IS NULL +0 +# 13.5 1 NOT MEMBER OF (NULL) -> NULL (and IS NULL -> 1) +SELECT 1 NOT MEMBER OF (NULL); +1 NOT MEMBER OF (NULL) +NULL +SELECT 1 NOT MEMBER OF (NULL) IS NULL; +1 NOT MEMBER OF (NULL) IS NULL +1 +# 13.6 Malformed JSON with NOT MEMBER OF (returns NULL with warning) +SELECT 1 NOT MEMBER OF ('not_json'); +1 NOT MEMBER OF ('not_json') +NULL +Warnings: +Warning 4038 Syntax error in JSON text in argument 1 to function 'json_contains' at position 1 +# 13.7 EXPLAIN EXTENDED round-trip print check +EXPLAIN EXTENDED SELECT 1 NOT MEMBER OF ('[1,2,3]'); +id select_type table type possible_keys key key_len ref rows filtered Extra +1 SIMPLE NULL NULL NULL NULL NULL NULL NULL NULL No tables used +Warnings: +Note 1003 select 1 not member of ('[1,2,3]') AS `1 NOT MEMBER OF ('[1,2,3]')` +# 14. Prepared Statements twice execution for Section 6 nested cases +PREPARE stmt3 FROM 'SELECT JSON_COMPACT(\'[1,2]\') MEMBER OF (\'[[1,2],[3,4]]\')'; +EXECUTE stmt3; +JSON_COMPACT('[1,2]') MEMBER OF ('[[1,2],[3,4]]') +1 +EXECUTE stmt3; +JSON_COMPACT('[1,2]') MEMBER OF ('[[1,2],[3,4]]') +1 +DEALLOCATE PREPARE stmt3; +PREPARE stmt4 FROM 'SELECT JSON_COMPACT(\'{"name":"John"}\') MEMBER OF (\'[{"name":"John"},{"name":"Joe"}]\')'; +EXECUTE stmt4; +JSON_COMPACT('{"name":"John"}') MEMBER OF ('[{"name":"John"},{"name":"Joe"}]') +1 +EXECUTE stmt4; +JSON_COMPACT('{"name":"John"}') MEMBER OF ('[{"name":"John"},{"name":"Joe"}]') +1 +DEALLOCATE PREPARE stmt4; +# +# 15. null literal vs JSON null — type-strict distinctions +# +# SQL string 'null' inside a JSON string-array ["null"] -> 1 (matches the string) +select 'null' member of ('["null"]'); +'null' member of ('["null"]') +1 +# SQL NULL inside an array containing JSON null literal [null] -> 1 +select null member of ('[null]'); +null member of ('[null]') +1 +# SQL string 'null' inside an array containing JSON null literal [null] -> 0 (type mismatch) +select 'null' member of ('[null]'); +'null' member of ('[null]') +0 +# SQL NULL inside a JSON string-array ["null"] -> 0 (type mismatch) +select null member of ('["null"]'); +null member of ('["null"]') +0 +# +# 16. Regression test for Item 1 root-cause fix (stale a2_parsed cache bug) +# Before the fix, cross-join with non-constant candidate produced wrong results: +# NULL MEMBER OF ([1,2,3]) returned 1 (should be NULL) +# 4 MEMBER OF ([1,2,3]) returned 1 (should be 0) +# NULL MEMBER OF ([4,5,6]) returned 0 (should be NULL) +# Verify each combination individually to prevent any future regression. +# +CREATE TABLE t5 (id INT, val JSON); +INSERT INTO t5 VALUES (1, '[1, 2, 3]'), (2, '[4, 5, 6]'), (3, NULL); +CREATE TABLE t6 (candidate INT); +INSERT INTO t6 VALUES (2), (4), (NULL); +# Exact results expected after fix: +# (NULL, [1,2,3]) -> 0, (2, [1,2,3]) -> 1, (4, [1,2,3]) -> 0 +# (NULL, [4,5,6]) -> 0, (2, [4,5,6]) -> 0, (4, [4,5,6]) -> 1 +# (NULL, NULL) -> NULL, (2, NULL) -> NULL, (4, NULL) -> NULL +SELECT candidate, val, candidate MEMBER OF (val) FROM t5, t6 ORDER BY id, candidate; +candidate val candidate MEMBER OF (val) +NULL [1, 2, 3] 0 +2 [1, 2, 3] 1 +4 [1, 2, 3] 0 +NULL [4, 5, 6] 0 +2 [4, 5, 6] 0 +4 [4, 5, 6] 1 +NULL NULL NULL +2 NULL NULL +4 NULL NULL +# Cross-check with JSON_CONTAINS(val, JSON_QUOTE(candidate)) - results must match +SELECT candidate, val, +candidate MEMBER OF (val) AS member_of_result, +JSON_CONTAINS(val, JSON_QUOTE(candidate)) AS json_contains_result +FROM t5, t6 +WHERE candidate IS NOT NULL AND val IS NOT NULL +ORDER BY id, candidate; +candidate val member_of_result json_contains_result +2 [1, 2, 3] 1 1 +4 [1, 2, 3] 0 0 +2 [4, 5, 6] 0 0 +4 [4, 5, 6] 1 1 +DROP TABLE t5, t6; +# +# 17. Item 6 test: Multi-column ROW_RESULT error path +# Multi-column row as argument disallows multi-column operands +# +SELECT JSON_QUOTE((1,2)); +Got one of the listed errors +SELECT (1,2) MEMBER OF ('[1,2,3]'); +Got one of the listed errors +# +# 18. Item 10 regression test: Equality propagation / ref substitution +# Verify that optimizer equality substitution (t7.a = t8.b) properly evaluates +# MEMBER OF across join rows without stale candidate/container values. +# +CREATE TABLE t7 (a INT, j JSON); +CREATE TABLE t8 (b INT, k JSON); +INSERT INTO t7 VALUES (1, '[1,2,3]'), (2, '[4,5,6]'); +INSERT INTO t8 VALUES (1, '[1,2,3]'), (2, '[4,5,6]'); +SELECT t7.a, t7.j, t8.k, t7.a MEMBER OF (t8.k) FROM t7, t8 +WHERE t7.a = t8.b AND t7.a MEMBER OF (t8.k) +ORDER BY t7.a; +a j k t7.a MEMBER OF (t8.k) +1 [1,2,3] [1,2,3] 1 +DROP TABLE t7, t8; +# +# 19. Stored function tests (VARCHAR and JSON return types) +# +CREATE FUNCTION f() RETURNS VARCHAR(30) RETURN 'pony'; +SELECT f() MEMBER OF ('["sheep", "lamb", "pony"]'); +f() MEMBER OF ('["sheep", "lamb", "pony"]') +1 +SELECT JSON_QUOTE(f()); +JSON_QUOTE(f()) +"pony" +DROP FUNCTION f; +CREATE FUNCTION j_row_result() RETURNS JSON RETURN JSON_OBJECT('value', 2, 'key', 'golden'); +SELECT j_row_result() MEMBER OF ('[{"key": "golden", "value": 2E0}]'); +j_row_result() MEMBER OF ('[{"key": "golden", "value": 2E0}]') +0 +DROP FUNCTION j_row_result; +# +# 20. TIME_RESULT tests +# Verify that TIME values are quoted as JSON string literals without escaping. +# +SELECT JSON_QUOTE(CAST('12:30:00' AS TIME)); +JSON_QUOTE(CAST('12:30:00' AS TIME)) +"12:30:00" +SELECT CAST('12:30:00' AS TIME) MEMBER OF ('["12:30:00"]'); +CAST('12:30:00' AS TIME) MEMBER OF ('["12:30:00"]') +1 +SELECT CAST('12:30:00' AS TIME) MEMBER OF ('["10:00:00", "12:30:00"]'); +CAST('12:30:00' AS TIME) MEMBER OF ('["10:00:00", "12:30:00"]') +1 +# +# 21. Literal ROW() constructor / RETURNS ROW rejection tests +# Confirms literal multi-column ROW() constructors and RETURNS ROW(...) functions +# are rejected by check_cols(1) during fix_fields() at bind time. +# +CREATE FUNCTION f1_row() RETURNS ROW(a INT) RETURN (SELECT 42); +SELECT JSON_QUOTE(f1_row()); +ERROR 21000: Operand should contain 1 column(s) +SELECT f1_row() MEMBER OF ('[42]'); +ERROR 21000: Operand should contain 1 column(s) +DROP FUNCTION f1_row; diff --git a/mysql-test/suite/json/r/member_of_notembedded.result b/mysql-test/suite/json/r/member_of_notembedded.result new file mode 100644 index 0000000000000..38f194384d690 --- /dev/null +++ b/mysql-test/suite/json/r/member_of_notembedded.result @@ -0,0 +1,32 @@ +# +# MDEV-38591: Optimizer trace test for condition processing +# +CREATE TABLE t (id INT PRIMARY KEY, x INT, j JSON); +INSERT INTO t VALUES (1, 10, '[10, 20]'), (2, 20, '[10, 20]'), (3, 30, '[30, 40]'); +SET STATEMENT optimizer_trace='enabled=on' FOR SELECT * FROM t WHERE x=10 AND x MEMBER OF (j); +id x j +1 10 [10, 20] +SELECT JSON_DETAILED(JSON_EXTRACT(TRACE, '$**.condition_processing')) AS t FROM information_schema.OPTIMIZER_TRACE; +t +[ + { + "condition": "WHERE", + "original_condition": "t.x = 10 and t.x member of (t.j)", + "steps": + [ + { + "transformation": "equality_propagation", + "resulting_condition": "10 member of (t.j) and multiple equal(10, t.x)" + }, + { + "transformation": "constant_propagation", + "resulting_condition": "10 member of (t.j) and multiple equal(10, t.x)" + }, + { + "transformation": "trivial_condition_removal", + "resulting_condition": "10 member of (t.j) and multiple equal(10, t.x)" + } + ] + } +] +DROP TABLE t; diff --git a/mysql-test/suite/json/t/member_of.test b/mysql-test/suite/json/t/member_of.test new file mode 100644 index 0000000000000..54c0603ccaafa --- /dev/null +++ b/mysql-test/suite/json/t/member_of.test @@ -0,0 +1,213 @@ +--echo # +--echo # MDEV-38591: MEMBER OF operator +--echo # + +--echo # 1. Simple member checks (should succeed) +SELECT 1 MEMBER OF ('[1,2,3]'); +SELECT 2 MEMBER OF ('[1,2,3]'); + +--echo # 2. Non-member checks (should return 0) +SELECT 4 MEMBER OF ('[1,2,3]'); + +--echo # 3. Type-strict checks (string "2" must NOT match number 2, should return 0) +SELECT '2' MEMBER OF ('[1,2,3]'); +SELECT 2 MEMBER OF ('["1","2","3"]'); + +--echo # 4. SQL NULL propagation checks +SELECT NULL MEMBER OF ('[1,2,3]'); +SELECT NULL MEMBER OF ('[1,2,3]') IS NULL; +SELECT 1 MEMBER OF (NULL); +SELECT 1 MEMBER OF (NULL) IS NULL; + +--echo # 5. Error/Warning handling for malformed JSON +SELECT 1 MEMBER OF ('[1,2'); + +--echo # 6. Nested array and object checks using JSON_COMPACT() vs uncast strings +SELECT JSON_COMPACT('[1,2]') MEMBER OF ('[[1,2],[3,4]]'); +SELECT '[1,2]' MEMBER OF ('[[1,2],[3,4]]'); +SELECT JSON_COMPACT('{"name":"John"}') MEMBER OF ('[{"name":"John"},{"name":"Joe"}]') AS expr; +SELECT '{"name":"John"}' MEMBER OF ('[{"name":"John"},{"name":"Joe"}]'); + +--echo # 7. MEMBER OF with scalar/object as container (acting as 1-element array) +SELECT 2 MEMBER OF ('2'); +SELECT 2 MEMBER OF ('3'); +SELECT 2 MEMBER OF ('"2"'); + +--echo # 8. Test using table columns and row evaluations +CREATE TABLE t1 (id INT, val JSON); +INSERT INTO t1 VALUES (1, '[1, 2, 3]'), (2, '[4, 5, 6]'), (3, NULL); + +SELECT id, val, 2 MEMBER OF (val) FROM t1; +SELECT id, val, NULL MEMBER OF (val) FROM t1; + +CREATE TABLE t2 (candidate INT); +INSERT INTO t2 VALUES (2), (4), (NULL); + +SELECT candidate, val, candidate MEMBER OF (val) FROM t1, t2 ORDER BY id, candidate; + +DROP TABLE t1, t2; + +--echo # 9. Prepared Statements twice execution test +PREPARE stmt1 FROM 'SELECT ? MEMBER OF (?)'; +SET @candidate = 2; +SET @container = '[1,2,3]'; +EXECUTE stmt1 USING @candidate, @container; +SET @candidate = 4; +SET @container = '[1,2,3]'; +EXECUTE stmt1 USING @candidate, @container; +DEALLOCATE PREPARE stmt1; + +--echo # 10. Prepared Statements with JSON_COMPACT +PREPARE stmt2 FROM 'SELECT JSON_COMPACT(?) MEMBER OF (?)'; +SET @candidate_json = '[1,2]'; +SET @container_nested = '[[1,2],[3,4]]'; +EXECUTE stmt2 USING @candidate_json, @container_nested; +EXECUTE stmt2 USING @candidate_json, @container_nested; +DEALLOCATE PREPARE stmt2; + +--echo # 11. Malformed LONGTEXT candidate test +CREATE TABLE t3 (val LONGTEXT); +INSERT INTO t3 VALUES ('[1,2'); +SELECT val MEMBER OF ('[1,2,3]') FROM t3; +DROP TABLE t3; + +--echo # 12. JSON scalar candidate test (type-strictness in passthrough branch) +CREATE TABLE t4 (val JSON); +INSERT INTO t4 VALUES ('2'), ('"2"'); +SELECT val, val MEMBER OF ('[1,2,3]') FROM t4; +DROP TABLE t4; + +--echo # 13. NOT MEMBER OF tests +--echo # 13.1 1 NOT MEMBER OF ('[1,2,3]') -> 0 (1 IS a member) +SELECT 1 NOT MEMBER OF ('[1,2,3]'); + +--echo # 13.2 4 NOT MEMBER OF ('[1,2,3]') -> 1 +SELECT 4 NOT MEMBER OF ('[1,2,3]'); + +--echo # 13.3 '2' NOT MEMBER OF ('[1,2,3]') -> 1 (type-strict comparison) +SELECT '2' NOT MEMBER OF ('[1,2,3]'); + +--echo # 13.4 NULL NOT MEMBER OF ('[1,2,3]') -> 1 +SELECT NULL NOT MEMBER OF ('[1,2,3]'); +SELECT NULL NOT MEMBER OF ('[1,2,3]') IS NULL; + +--echo # 13.5 1 NOT MEMBER OF (NULL) -> NULL (and IS NULL -> 1) +SELECT 1 NOT MEMBER OF (NULL); +SELECT 1 NOT MEMBER OF (NULL) IS NULL; + +--echo # 13.6 Malformed JSON with NOT MEMBER OF (returns NULL with warning) +SELECT 1 NOT MEMBER OF ('not_json'); + +--echo # 13.7 EXPLAIN EXTENDED round-trip print check +EXPLAIN EXTENDED SELECT 1 NOT MEMBER OF ('[1,2,3]'); + +--echo # 14. Prepared Statements twice execution for Section 6 nested cases +PREPARE stmt3 FROM 'SELECT JSON_COMPACT(\'[1,2]\') MEMBER OF (\'[[1,2],[3,4]]\')'; +EXECUTE stmt3; +EXECUTE stmt3; +DEALLOCATE PREPARE stmt3; + +PREPARE stmt4 FROM 'SELECT JSON_COMPACT(\'{"name":"John"}\') MEMBER OF (\'[{"name":"John"},{"name":"Joe"}]\')'; +EXECUTE stmt4; +EXECUTE stmt4; +DEALLOCATE PREPARE stmt4; + +--echo # +--echo # 15. null literal vs JSON null — type-strict distinctions +--echo # +--echo # SQL string 'null' inside a JSON string-array ["null"] -> 1 (matches the string) +select 'null' member of ('["null"]'); +--echo # SQL NULL inside an array containing JSON null literal [null] -> 1 +select null member of ('[null]'); +--echo # SQL string 'null' inside an array containing JSON null literal [null] -> 0 (type mismatch) +select 'null' member of ('[null]'); +--echo # SQL NULL inside a JSON string-array ["null"] -> 0 (type mismatch) +select null member of ('["null"]'); + +--echo # +--echo # 16. Regression test for Item 1 root-cause fix (stale a2_parsed cache bug) +--echo # Before the fix, cross-join with non-constant candidate produced wrong results: +--echo # NULL MEMBER OF ([1,2,3]) returned 1 (should be NULL) +--echo # 4 MEMBER OF ([1,2,3]) returned 1 (should be 0) +--echo # NULL MEMBER OF ([4,5,6]) returned 0 (should be NULL) +--echo # Verify each combination individually to prevent any future regression. +--echo # +CREATE TABLE t5 (id INT, val JSON); +INSERT INTO t5 VALUES (1, '[1, 2, 3]'), (2, '[4, 5, 6]'), (3, NULL); +CREATE TABLE t6 (candidate INT); +INSERT INTO t6 VALUES (2), (4), (NULL); + +--echo # Exact results expected after fix: +--echo # (NULL, [1,2,3]) -> 0, (2, [1,2,3]) -> 1, (4, [1,2,3]) -> 0 +--echo # (NULL, [4,5,6]) -> 0, (2, [4,5,6]) -> 0, (4, [4,5,6]) -> 1 +--echo # (NULL, NULL) -> NULL, (2, NULL) -> NULL, (4, NULL) -> NULL +SELECT candidate, val, candidate MEMBER OF (val) FROM t5, t6 ORDER BY id, candidate; + +--echo # Cross-check with JSON_CONTAINS(val, JSON_QUOTE(candidate)) - results must match +SELECT candidate, val, + candidate MEMBER OF (val) AS member_of_result, + JSON_CONTAINS(val, JSON_QUOTE(candidate)) AS json_contains_result + FROM t5, t6 + WHERE candidate IS NOT NULL AND val IS NOT NULL + ORDER BY id, candidate; + +DROP TABLE t5, t6; + +--echo # +--echo # 17. Item 6 test: Multi-column ROW_RESULT error path +--echo # Multi-column row as argument disallows multi-column operands +--echo # +--error ER_OPERAND_COLUMNS,ER_WRONG_ARGUMENTS +SELECT JSON_QUOTE((1,2)); +--error ER_OPERAND_COLUMNS,ER_WRONG_ARGUMENTS +SELECT (1,2) MEMBER OF ('[1,2,3]'); + +--echo # +--echo # 18. Item 10 regression test: Equality propagation / ref substitution +--echo # Verify that optimizer equality substitution (t7.a = t8.b) properly evaluates +--echo # MEMBER OF across join rows without stale candidate/container values. +--echo # +CREATE TABLE t7 (a INT, j JSON); +CREATE TABLE t8 (b INT, k JSON); +INSERT INTO t7 VALUES (1, '[1,2,3]'), (2, '[4,5,6]'); +INSERT INTO t8 VALUES (1, '[1,2,3]'), (2, '[4,5,6]'); + +SELECT t7.a, t7.j, t8.k, t7.a MEMBER OF (t8.k) FROM t7, t8 + WHERE t7.a = t8.b AND t7.a MEMBER OF (t8.k) + ORDER BY t7.a; + +DROP TABLE t7, t8; + +--echo # +--echo # 19. Stored function tests (VARCHAR and JSON return types) +--echo # +CREATE FUNCTION f() RETURNS VARCHAR(30) RETURN 'pony'; +SELECT f() MEMBER OF ('["sheep", "lamb", "pony"]'); +SELECT JSON_QUOTE(f()); +DROP FUNCTION f; + +CREATE FUNCTION j_row_result() RETURNS JSON RETURN JSON_OBJECT('value', 2, 'key', 'golden'); +SELECT j_row_result() MEMBER OF ('[{"key": "golden", "value": 2E0}]'); +DROP FUNCTION j_row_result; + +--echo # +--echo # 20. TIME_RESULT tests +--echo # Verify that TIME values are quoted as JSON string literals without escaping. +--echo # +SELECT JSON_QUOTE(CAST('12:30:00' AS TIME)); +SELECT CAST('12:30:00' AS TIME) MEMBER OF ('["12:30:00"]'); +SELECT CAST('12:30:00' AS TIME) MEMBER OF ('["10:00:00", "12:30:00"]'); + +--echo # +--echo # 21. Literal ROW() constructor / RETURNS ROW rejection tests +--echo # Confirms literal multi-column ROW() constructors and RETURNS ROW(...) functions +--echo # are rejected by check_cols(1) during fix_fields() at bind time. +--echo # +CREATE FUNCTION f1_row() RETURNS ROW(a INT) RETURN (SELECT 42); + +--error ER_OPERAND_COLUMNS +SELECT JSON_QUOTE(f1_row()); +--error ER_OPERAND_COLUMNS +SELECT f1_row() MEMBER OF ('[42]'); + +DROP FUNCTION f1_row; diff --git a/mysql-test/suite/json/t/member_of_notembedded.test b/mysql-test/suite/json/t/member_of_notembedded.test new file mode 100644 index 0000000000000..510a18c0eaec8 --- /dev/null +++ b/mysql-test/suite/json/t/member_of_notembedded.test @@ -0,0 +1,10 @@ +source include/not_embedded.inc; + +--echo # +--echo # MDEV-38591: Optimizer trace test for condition processing +--echo # +CREATE TABLE t (id INT PRIMARY KEY, x INT, j JSON); +INSERT INTO t VALUES (1, 10, '[10, 20]'), (2, 20, '[10, 20]'), (3, 30, '[30, 40]'); +SET STATEMENT optimizer_trace='enabled=on' FOR SELECT * FROM t WHERE x=10 AND x MEMBER OF (j); +SELECT JSON_DETAILED(JSON_EXTRACT(TRACE, '$**.condition_processing')) AS t FROM information_schema.OPTIMIZER_TRACE; +DROP TABLE t; diff --git a/sql/item_cmpfunc.h b/sql/item_cmpfunc.h index 22a8cebca668f..86e4a31bb4b21 100644 --- a/sql/item_cmpfunc.h +++ b/sql/item_cmpfunc.h @@ -1075,6 +1075,8 @@ class Item_func_opt_neg :public Item_bool_func public: Item_func_opt_neg(THD *thd, Item *a, Item *b, Item *c): Item_bool_func(thd, a, b, c), negated(0) {} + Item_func_opt_neg(THD *thd, Item *a, Item *b): + Item_bool_func(thd, a, b), negated(0) {} Item_func_opt_neg(THD *thd, List &list): Item_bool_func(thd, list), negated(0) {} public: diff --git a/sql/item_jsonfunc.cc b/sql/item_jsonfunc.cc index 0cbab9c66a975..31e66501582cd 100644 --- a/sql/item_jsonfunc.cc +++ b/sql/item_jsonfunc.cc @@ -983,13 +983,18 @@ bool Item_func_json_quote::fix_length_and_dec(THD *thd) { collation.set(&my_charset_utf8mb4_bin); /* - Odd but realistic worst case is when all characters - of the argument turn into '\uXXXX\uXXXX', which is 12. - For NULL input we return the 4-character literal "null", - for numeric input we return the unquoted text, so the - function never returns SQL NULL — do not set maybe_null. + Item 2 (maybe_null & view-protocol fix): Worst-case output length: each + input character can expand to '\uXXXX\uXXXX' (12 bytes) plus the two + surrounding quotes (+2). + + For NULL input, val_str() returns the 4-character unquoted string "null". + When args[0]->max_char_length() is 0 (e.g. for literal NULL), the formula + (0 * 12 + 2 = 2) would under-declare max_char_length as 2, causing "null" + to be truncated to "nu" under --view-protocol. We enforce a minimum of 4 + characters using MY_MAX(..., 4ULL) to ensure "null" is never truncated. */ - fix_char_length_ulonglong((ulonglong) args[0]->max_char_length() * 12 + 2); + fix_char_length_ulonglong(MY_MAX((ulonglong) args[0]->max_char_length() * 12 + 2, 4ULL)); + set_maybe_null(); return FALSE; } @@ -1021,10 +1026,28 @@ String *Item_func_json_quote::val_str(String *str) return str; } - switch (args[0]->result_type()) + /* + Items 4 & 5: Guard against a null String pointer once, before the + switch, rather than duplicating it inside every case. After this + point every code path below is guaranteed s != NULL. + */ + if (!s) + { + null_value= 1; + return NULL; + } + + /* + Item 6: Use a labelled switch so ROW_RESULT can re-enter via goto after + unwrapping a single-column row item. + */ + Item_result t= args[0]->result_type(); + +eval: + switch (t) { case STRING_RESULT: - /* String input: quote and escape exactly as before. */ + /* String input: quote and escape. */ if (str->append('"') || st_append_escaped(str, s) || str->append('"')) @@ -1042,7 +1065,22 @@ String *Item_func_json_quote::val_str(String *str) Numeric input: the text representation is already valid JSON — copy it unquoted and unescaped. */ - if (!s || str->append(s->ptr(), s->length(), &my_charset_utf8mb4_bin)) + if (str->append(tmp_s)) + { + null_value= 1; + return 0; + } + null_value= 0; + return str; + + case TIME_RESULT: + /* + Item 6: Times are already well-formatted text (e.g. '2025-01-01 12:00:00') + and require no JSON escaping beyond surrounding quotes. + */ + if (str->append('"') || + str->append(tmp_s) || + str->append('"')) { null_value= 1; return 0; @@ -1050,8 +1088,33 @@ String *Item_func_json_quote::val_str(String *str) null_value= 0; return str; + case ROW_RESULT: + /* + Item 6: Single-column ROW_RESULT unwrapping logic (goto eval). + Stored function calls (Item_func_sp) can report ROW_RESULT with cols() == 1 + at the point val_str() inspects args[0]->result_type(). The inner return + element type is extracted via element_index(0)->result_type() and the + switch is re-entered via goto eval. + + NOTE: When a stored function returning VARCHAR(30) (e.g. JSON_OBJECT text) + is unwrapped here, its element type is STRING_RESULT. In val_str(), + STRING_RESULT inputs are quoted and escaped (e.g. producing '"{\"key\":...}"'). + This double-quoting is standard SQL/JSON behavior for VARCHAR return types. + */ + if (args[0]->cols() == 1) + { + t= args[0]->element_index(0)->result_type(); + if (t != ROW_RESULT) + goto eval; + } + /* fallthrough */ default: - /* Unknown result type (e.g. ROW_RESULT): preserve original NULL behaviour. */ + /* + Item 6: Unknown or multi-column ROW_RESULT — emit a real diagnostic + instead of silently returning NULL. Under MEMBER OF this will surface + as a warning mentioning json_quote internally. + */ + my_error(ER_WRONG_ARGUMENTS, MYF(ME_WARNING), func_name()); null_value= 1; return 0; } @@ -6721,3 +6784,178 @@ void Item_func_is_json::print(String *str, enum_query_type query_type) if (with_unique_keys) str->append(STRING_WITH_LEN(" WITH UNIQUE KEYS")); } + + +bool Item_func_member_of::val_bool() +{ + DBUG_ASSERT(fixed()); + + bool res= json_contains_item->val_bool(); + /* + Note: MariaDB intentionally diverges from MySQL's documented behavior + ("If value is NULL, returns NULL"). MariaDB evaluates SELECT NULL MEMBER OF + ('[null]') as 1 and SELECT NULL MEMBER OF ('[1,2,3]') as 0. When args[0] is + SQL NULL, json_quote_item returns the string "null" (per MDEV-13645), which + json_contains_item matches against JSON null in the array. Container NULL + (args[1] IS NULL) still propagates SQL NULL via json_contains_item->null_value. + */ + if (json_contains_item->null_value) + { + null_value= 1; + return false; + } + null_value= 0; + return negated ? !res : res; +} + +bool Item_func_member_of::fix_length_and_dec(THD *thd) +{ + /* + Item 7: Call through the explicit immediate parent (Item_func_opt_neg). + Item_func_opt_neg does not itself override fix_length_and_dec — the call + resolves through Item_bool_func::fix_length_and_dec — but naming the + direct parent makes the inheritance intent explicit and consistent with + the walk() and transform() overrides below. + */ + if (Item_func_opt_neg::fix_length_and_dec(thd)) + return true; + + set_maybe_null(); + + List contains_args; + if (contains_args.push_back(args[1], thd->mem_root)) + return true; + + if (is_json_type(args[0])) + { + json_quote_item= NULL; + if (contains_args.push_back(args[0], thd->mem_root)) + return true; + } + else + { + Item_func_json_quote *jq= new (thd->mem_root) Item_func_json_quote(thd, args[0]); + if (!jq) + return true; + if (jq->fix_length_and_dec(thd)) + return true; + /* + Item 1 (root-cause fix): Item_func_json_quote was just constructed and + only fix_length_and_dec() was called — fix_fields() was deliberately + skipped because the item lives outside the normal item tree. The + Used_tables_and_const_cache constructor sets const_item_cache = true by + default, so jq->const_item() incorrectly returns true at this point. + + Item_func_json_contains::fix_length_and_dec() reads + a2_constant = args[1]->const_item() (args[1] is jq) + If that read happens while jq->const_item() is still true, a2_constant + becomes true and the needle value is cached after the first row — + producing wrong results for every subsequent row (NULL candidate + appearing as 1, non-member values appearing as 1, etc.). + + Calling jq->update_used_tables() here propagates used_tables_cache and + const_item_cache from args[0] (the candidate column) so that + jq->const_item() correctly returns false BEFORE jc->fix_length_and_dec + reads it. The same call must also follow for jc itself for symmetry. + */ + jq->update_used_tables(); + json_quote_item= jq; + if (contains_args.push_back(json_quote_item, thd->mem_root)) + return true; + } + + Item_func_json_contains *jc= new (thd->mem_root) Item_func_json_contains(thd, contains_args); + if (!jc) + return true; + if (jc->fix_length_and_dec(thd)) + return true; + /* Propagate used-tables/const state from the freshly fixed arguments. */ + jc->update_used_tables(); + json_contains_item= jc; + + return false; +} + + +bool Item_func_member_of::walk(Item_processor processor, void *arg, item_walk_flags flags) +{ + /* + Item 9: Explicitly walk json_contains_item (and transitively json_quote_item + which lives inside it) because these internal helper items are NOT in the + args[] array that Item_func_or_sum::walk() iterates via walk_args(). + Item_func_member_of::args[] contains only {args[0]=candidate, args[1]=container}; + the helper items are hidden children that would be completely skipped without + this explicit call. Removing it causes processors such as used_tables and + equal-field propagation to miss those items. + + Call through Item_func_opt_neg (immediate parent) instead of Item_bool_func + for the same reason as fix_length_and_dec and transform. + */ + if (json_contains_item && json_contains_item->walk(processor, arg, flags)) + return true; + return Item_func_opt_neg::walk(processor, arg, flags); +} + +Item *Item_func_member_of::transform(THD *thd, Item_transformer transformer, uchar *arg) +{ + DBUG_ASSERT(!thd->stmt_arena->is_stmt_prepare()); + if (transform_args(thd, transformer, arg)) + return 0; + + if (json_contains_item) + { + Item *new_item= json_contains_item->transform(thd, transformer, arg); + if (!new_item) + return 0; + if (json_contains_item != new_item) + thd->change_item_tree((Item**)&json_contains_item, new_item); + } + + /* + Item 3 note: The reviewer requested calling Item_func_opt_neg::transform + explicitly here, but Item_func_opt_neg does not override transform(). + Calling Item_func_opt_neg::transform(thd, transformer, arg) would resolve + to Item_func::transform() which calls transform_args() AGAIN (double + transform) then the transformer. Since transform_args() was already called + above, we keep the direct invocation of the transformer function pointer to + avoid transforming outer args[] twice. This correctly applies the + transformer to 'this' as the parent's transform() would do after its own + transform_args() run. + */ + return (this->*transformer)(thd, arg); +} + + + +void Item_func_member_of::update_used_tables() +{ + Item_func_opt_neg::update_used_tables(); + if (json_quote_item) + { + if (json_quote_item->type() == Item::FUNC_ITEM) + static_cast(json_quote_item)->arguments()[0]= args[0]; + json_quote_item->update_used_tables(); + } + if (json_contains_item) + { + if (json_contains_item->type() == Item::FUNC_ITEM) + { + static_cast(json_contains_item)->arguments()[0]= args[1]; + static_cast(json_contains_item)->arguments()[1]= json_quote_item ? json_quote_item : args[0]; + } + json_contains_item->update_used_tables(); + } +} + + +void Item_func_member_of::print(String *str, enum_query_type query_type) +{ + args[0]->print_parenthesised(str, query_type, higher_precedence()); + if (negated) + str->append(STRING_WITH_LEN(" not")); + str->append(STRING_WITH_LEN(" member of (")); + args[1]->print(str, query_type); + str->append(')'); +} + + diff --git a/sql/item_jsonfunc.h b/sql/item_jsonfunc.h index 3ec85d8c1a980..ff113c6151173 100644 --- a/sql/item_jsonfunc.h +++ b/sql/item_jsonfunc.h @@ -1130,4 +1130,60 @@ class Item_func_is_json: public Item_bool_func }; +/* + Implements the SQL standard "value MEMBER OF (json_doc)" operator, and its + negation "value NOT MEMBER OF (json_doc)" via the inherited negated flag from + Item_func_opt_neg. + + This follows the same pattern used by Item_func_between and Item_func_in: + a single item class represents both the positive and negated form, with + neg_transformer() toggling the negated flag and val_bool() honouring it. + + Design: composition over helper items. fix_length_and_dec() builds + an internal JSON_QUOTE(args[0]) item (when args[0] is not already JSON- + typed) and an internal JSON_CONTAINS(args[1], ...) item. val_bool() + delegates the actual containment test to json_contains_item. If args[0] + is JSON-typed, we bypass the JSON_QUOTE wrapping (is_json_type passthrough). + + walk / transform / propagate_equal_fields / update_used_tables are overridden + to expose and synchronize the hidden child items with the optimizer. Those + two helper items live outside the normal args[] array that Item_func_opt_neg + would walk automatically. +*/ +class Item_func_member_of : public Item_func_opt_neg +{ + Item_func_json_quote *json_quote_item; + Item_func_json_contains *json_contains_item; +public: + Item_func_member_of(THD *thd, Item *a, Item *b): + Item_func_opt_neg(thd, a, b), json_quote_item(NULL), json_contains_item(NULL) + {} + + bool val_bool() override; + bool fix_length_and_dec(THD *thd) override; + void print(String *str, enum_query_type query_type) override; + enum precedence precedence() const override { return CMP_PRECEDENCE; } + LEX_CSTRING func_name_cstring() const override + { + static LEX_CSTRING name= {STRING_WITH_LEN("member of") }; + static LEX_CSTRING neg_name= {STRING_WITH_LEN("not member of") }; + return negated ? neg_name : name; + } + Item *shallow_copy(THD *thd) const override + { + return get_item_copy(thd, this); + } + + bool walk(Item_processor processor, void *arg, item_walk_flags flags) override; + Item *transform(THD *thd, Item_transformer transformer, uchar *arg) override; + Item *propagate_equal_fields(THD *thd, const Context &ctx, COND_EQUAL *cond) override + { + if (json_contains_item) + json_contains_item->propagate_equal_fields(thd, ctx, cond); + return Item_func::propagate_equal_fields(thd, ctx, cond); + } + void update_used_tables() override; +}; + + #endif /* ITEM_JSONFUNC_INCLUDED */ diff --git a/sql/lex.h b/sql/lex.h index 61774df019f0d..695e5eb6ee95a 100644 --- a/sql/lex.h +++ b/sql/lex.h @@ -400,6 +400,7 @@ SYMBOL symbols[] = { { "MEDIUMBLOB", SYM(MEDIUMBLOB)}, { "MEDIUMINT", SYM(MEDIUMINT)}, { "MEDIUMTEXT", SYM(MEDIUMTEXT)}, + { "MEMBER", SYM(MEMBER_SYM)}, { "MEMORY", SYM(MEMORY_SYM)}, { "MERGE", SYM(MERGE_SYM)}, { "MESSAGE_TEXT", SYM(MESSAGE_TEXT_SYM)}, diff --git a/sql/sql_yacc.yy b/sql/sql_yacc.yy index c2fe9974c42df..7efc1c36e7a6f 100644 --- a/sql/sql_yacc.yy +++ b/sql/sql_yacc.yy @@ -387,10 +387,20 @@ bool my_yyoverflow(short **a, YYSTYPE **b, size_t *yystacksize); We should not introduce any further shift/reduce conflicts. */ +/* + %expect bumped by 1 for MEMBER_SYM: a new shift/reduce conflict + identical in category to the existing SOUNDS_SYM conflict in this + same state (both are non-reserved keywords that begin a binary + predicate operator: SOUNDS LIKE / MEMBER OF). Resolved by Bison's + default shift, which is correct here. MEMBER is kept non-reserved + to match MySQL 8.0.19+ semantics (MDEV-38591) — reserving it would + itself be a compatibility regression for users migrating schemas + with existing `member` columns/tables. +*/ %ifdef MARIADB -%expect 72 -%else %expect 73 +%else +%expect 74 %endif /* @@ -998,6 +1008,7 @@ bool my_yyoverflow(short **a, YYSTYPE **b, size_t *yystacksize); %token MAX_STATEMENT_TIME_SYM %token MAX_USER_CONNECTIONS_SYM %token MEDIUM_SYM +%token MEMBER_SYM %token MEMORY_SYM %token MERGE_SYM /* SQL-2003-R */ %token MESSAGE_TEXT_SYM /* SQL-2003-N */ @@ -1236,7 +1247,7 @@ bool my_yyoverflow(short **a, YYSTYPE **b, size_t *yystacksize); %left '=' EQUAL_SYM GE '>' LE '<' NE %nonassoc IS %right BETWEEN_SYM -%left LIKE SOUNDS_SYM REGEXP IN_SYM +%left LIKE SOUNDS_SYM REGEXP IN_SYM MEMBER_SYM %left '|' %left '&' %left SHIFT_LEFT SHIFT_RIGHT @@ -10241,6 +10252,20 @@ predicate: if (unlikely($$ == NULL)) MYSQL_YYABORT; } + | predicate MEMBER_SYM OF_SYM '(' expr ')' + { + $$= new (thd->mem_root) Item_func_member_of(thd, $1, $5); + if (unlikely($$ == NULL)) + MYSQL_YYABORT; + } + | predicate not MEMBER_SYM OF_SYM '(' expr ')' %prec MEMBER_SYM + { + Item_func_member_of *item= + new (thd->mem_root) Item_func_member_of(thd, $1, $6); + if (unlikely(item == NULL)) + MYSQL_YYABORT; + $$= item->neg_transformer(thd); + } | predicate LIKE predicate { $$= new (thd->mem_root) Item_func_like(thd, $1, $3, escape(thd), false); @@ -17384,6 +17409,7 @@ keyword_sp_var_and_label: | ID_SYM | LAST_VALUE | LASTVAL_SYM + | MEMBER_SYM | MINUTE_SYM | MONTH_SYM | NEXTVAL_SYM