From 699680e6f8d0decd07f1c49cc0fe3aeb34775d87 Mon Sep 17 00:00:00 2001 From: Jerry Hu Date: Mon, 19 Oct 2026 10:47:41 +0800 Subject: [PATCH] [fix](be) Quote JSON_SEARCH path legs that need escaping (#69170) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What problem does this PR solve? Issue Number: None Problem Summary: `JSON_SEARCH` builds the returned JSON path by concatenating each object key as a bare member leg (`.key`), only double-quoting the leg when the key contains whitespace. When the key itself contains a character that `JsonbPath`'s parser treats as structural when unquoted — `.`, `[`, a leading `*`, or an empty key — the emitted path does not parse back into the same single leg, so feeding the result into `JSON_EXTRACT`/`JSON_SEARCH` again resolves to a different value (or fails to parse). For example: ```sql JSON_SEARCH('{"a.b":"hit","a":{"b":"miss"}}', 'one', 'hit') -- returns $.a.b, which JSON_EXTRACT parses as a -> b and resolves to "miss" instead of "hit" ``` The fix makes `leg_info::to_string` double-quote a member leg whenever the unquoted form would not read back as the same leg (mirroring `JsonbPath`'s own unquoted-leg grammar: empty key, leading `*`, or containing whitespace/`.`/`[`), and adds a few supporting changes so the rest of the path code treats a quoted literal key consistently with a parsed wildcard: - A new `leg_info::is_wildcard` flag, set only by the parser for an unquoted `*` / `[*]` leg, distinguishes a real wildcard from the literal key `"*"`. `to_string` and `findValue` both switch to this flag instead of comparing leg bytes, since a quoted `"*"` leg has the same bytes as a wildcard leg. - `$.""` (an explicit empty key) is accepted by the parser and can be looked up; `ObjectVal::search` no longer treats an empty key length as "not found", and the internal lookup distinguishes an explicit empty key from an object entry that is stored by numeric key id (which also has key length 0). - `JsonbValue::contains` looks up a key-id-encoded right-hand key by id instead of by name, for the same reason. - `JSON_SET`/`JSON_INSERT` no longer write a member-style leg into an array (matching MySQL); previously the leg's default array index (0) would overwrite or append to the array. - A pre-existing formatting issue where a wildcard array leg (`[*]`) was printed back as `[0]` is fixed alongside, since it shares the same root cause (no explicit wildcard marker). ### Release note `JSON_SEARCH` now quotes a returned path segment whenever the key contains `.`, `[`, starts with `*`, or is empty, so the result can be parsed again by `JSON_EXTRACT`/`JSON_SEARCH`/`JSON_SET`/etc. As a side effect, `$.""` is now a valid path for an explicit empty key, a quoted `"*"` key is treated as a literal key instead of a wildcard, and `JSON_SET`/`JSON_INSERT` no longer modify an array when the path's last segment is a member (non-array-index) leg. ### Check List (For Author) - Test - [x] Unit Test - [x] Regression test - Behavior changed: - [x] Yes. See Release note above. - Does this need documentation? - [ ] No. --- be/src/exprs/function/function_jsonb.cpp | 16 +- be/src/util/jsonb_document.cpp | 6 +- be/src/util/jsonb_document.h | 56 ++++- be/test/core/jsonb/jsonb_document_test.cpp | 213 ++++++++++++++++++ .../exprs/function/function_jsonb_test.cpp | 64 ++++++ .../json_functions/json_search.out | 15 ++ .../json_functions/json_search.groovy | 58 +++++ 7 files changed, 405 insertions(+), 23 deletions(-) diff --git a/be/src/exprs/function/function_jsonb.cpp b/be/src/exprs/function/function_jsonb.cpp index 3b5b9d4417e..840889c2875 100644 --- a/be/src/exprs/function/function_jsonb.cpp +++ b/be/src/exprs/function/function_jsonb.cpp @@ -2067,10 +2067,7 @@ public: DCHECK_GT(legs_count, 0); for (size_t j = 0; j + 1 < legs_count; ++j) { auto* current_leg = json_path[path_index].get_leg_from_leg_vector(j); - std::unique_ptr leg = std::make_unique( - current_leg->leg_ptr, current_leg->leg_len, - current_leg->array_index, current_leg->type); - new_path.add_leg_to_leg_vector(std::move(leg)); + new_path.add_leg_to_leg_vector(std::make_unique(*current_leg)); } if (!build_parents_by_path(json_documents[row_idx]->getValue(), new_path, @@ -2083,6 +2080,12 @@ public: legs_count > 0 ? json_path[path_index].get_leg_from_leg_vector(legs_count - 1) : nullptr; + // A member leg names an object key and never an array element, so inserting it + // into an array leaves the document unchanged (as MySQL does). Otherwise its + // default array_index (0) would overwrite the first element. + if (!replace && parents.back()->isArray() && last_leg->type == MEMBER_CODE) { + continue; + } RETURN_IF_ERROR(write_json_value(json_documents[row_idx]->getValue(), parents, 0, json_value[value_index], replace, last_leg, writer)); @@ -2135,10 +2138,7 @@ public: JsonbPath current; auto* current_leg = path.get_leg_from_leg_vector(index); - std::unique_ptr leg = - std::make_unique(current_leg->leg_ptr, current_leg->leg_len, - current_leg->array_index, current_leg->type); - current.add_leg_to_leg_vector(std::move(leg)); + current.add_leg_to_leg_vector(std::make_unique(*current_leg)); auto find_result = root->findValue(current); if (!find_result.value) { diff --git a/be/src/util/jsonb_document.cpp b/be/src/util/jsonb_document.cpp index 05a44f4b30f..4f85313b55b 100644 --- a/be/src/util/jsonb_document.cpp +++ b/be/src/util/jsonb_document.cpp @@ -95,8 +95,7 @@ JsonbFindResult JsonbValue::findValue(JsonbPath& path) const { switch (path.get_leg_from_leg_vector(i)->type) { case MEMBER_CODE: { if (LIKELY(pval->type == JsonbType::T_Object)) { - if (path.get_leg_from_leg_vector(i)->leg_len == 1 && - *path.get_leg_from_leg_vector(i)->leg_ptr == WILDCARD) { + if (path.get_leg_from_leg_vector(i)->is_wildcard) { is_wildcard = true; for (const auto& it : *pval->unpack()) { results.emplace_back(it.value()); @@ -115,8 +114,7 @@ JsonbFindResult JsonbValue::findValue(JsonbPath& path) const { continue; } case ARRAY_CODE: { - if (path.get_leg_from_leg_vector(i)->leg_len == 1 && - *path.get_leg_from_leg_vector(i)->leg_ptr == WILDCARD) { + if (path.get_leg_from_leg_vector(i)->is_wildcard) { if (LIKELY(pval->type == JsonbType::T_Array)) { is_wildcard = true; for (const auto& it : *pval->unpack()) { diff --git a/be/src/util/jsonb_document.h b/be/src/util/jsonb_document.h index 62d013bd0cb..8c9177752c1 100644 --- a/be/src/util/jsonb_document.h +++ b/be/src/util/jsonb_document.h @@ -418,16 +418,25 @@ struct leg_info { ///type: 0 is member 1 is array unsigned int type; + ///true only for an unquoted `*` member leg or a `[*]` array leg; a quoted "*" member leg + ///is the literal key `*` + bool is_wildcard = false; + // NOLINTNEXTLINE(readability-non-const-parameter): str is an output parameter. bool to_string(std::string* str) const { if (type == MEMBER_CODE) { str->push_back(BEGIN_MEMBER); - bool contains_space = false; + // JsonbPath::parse_member reads an unquoted member leg until the next '.' or '[', + // rejects unquoted whitespace, treats a leading '*' as a wildcard and rejects an + // unquoted empty leg. A key that would not read back as this same single leg must be + // double-quoted so the emitted path can be parsed again (for example by + // JSON_EXTRACT on the result of JSON_SEARCH). A parsed wildcard leg stays `*`. + bool needs_quotes = leg_len == 0 || (!is_wildcard && *leg_ptr == WILDCARD); std::string tmp; for (auto* it = leg_ptr; it != (leg_ptr + leg_len); ++it) { auto c = static_cast(*it); - if (std::isspace(c)) { - contains_space = true; + if (std::isspace(c) || *it == BEGIN_MEMBER || *it == BEGIN_ARRAY) { + needs_quotes = true; } switch (*it) { @@ -464,18 +473,21 @@ struct leg_info { break; } } - if (contains_space) { + if (needs_quotes) { str->push_back(DOUBLE_QUOTE); } str->append(tmp); - if (contains_space) { + if (needs_quotes) { str->push_back(DOUBLE_QUOTE); } return true; } else if (type == ARRAY_CODE) { str->push_back(BEGIN_ARRAY); - std::string int_str = std::to_string(array_index); - str->append(int_str); + if (is_wildcard) { + str->push_back(WILDCARD); + } else { + str->append(std::to_string(array_index)); + } str->push_back(END_ARRAY); return true; } else { @@ -979,7 +991,8 @@ struct ObjectVal : public ContainerVal { } const_iterator search(const char* key, unsigned int klen) const { - if (!key || !klen) { + // An empty key is a valid object key, so klen == 0 is still searched. + if (!key) { return end(); } return internalSearch(key, klen); @@ -1019,6 +1032,16 @@ struct ObjectVal : public ContainerVal { return kv->value(); } + // find the JSONB value by a key id, as stored when klen() == 0 (sMaxKeyId is the empty key) + const JsonbValue* find_by_key_id(JsonbKeyValue::keyid_type key_id) const { + for (auto it = begin(); it != end(); ++it) { + if (it->klen() == 0 && it->getKeyId() == key_id) { + return it->value(); + } + } + return nullptr; + } + const_iterator begin() const { return const_iterator((pointer)payload); } const_iterator end() const { return const_iterator((pointer)(payload + size)); } @@ -1032,7 +1055,10 @@ private: while (pch < fence) { const auto* pkey = (const JsonbKeyValue*)(pch); - if (klen == pkey->klen() && strncmp(key, pkey->getKeyStr(), klen) == 0) { + // A key id entry is also stored with klen() == 0, so an empty name only matches + // the sMaxKeyId sentinel that JsonbWriter writes for an empty key. + if (klen == 0 ? pkey->klen() == 0 && pkey->getKeyId() == JsonbKeyValue::sMaxKeyId + : klen == pkey->klen() && strncmp(key, pkey->getKeyStr(), klen) == 0) { return const_iterator(pkey); } pch += pkey->numPackedBytes(); @@ -1463,7 +1489,11 @@ inline bool JsonbValue::contains(const JsonbValue* rhs) const { const auto* obj_value1 = unpack(); const auto* obj_value2 = rhs->unpack(); for (auto it = obj_value2->begin(); it != obj_value2->end(); ++it) { - const JsonbValue* value = obj_value1->find(it->getKeyStr(), it->klen()); + // A key stored as an id (including the empty key sentinel) has klen() == 0 and + // must be matched by id, not looked up as an empty key name. + const JsonbValue* value = it->klen() == 0 + ? obj_value1->find_by_key_id(it->getKeyId()) + : obj_value1->find(it->getKeyStr(), it->klen()); if (value == nullptr || !value->contains(it->value())) { return false; } @@ -1602,6 +1632,7 @@ inline bool JsonbPath::parse_array(Stream* stream, JsonbPath* path) { if (stream->peek() == END_ARRAY) { std::unique_ptr leg( new leg_info(stream->get_leg_ptr(), stream->get_leg_len(), 0, ARRAY_CODE)); + leg->is_wildcard = true; path->add_leg_to_leg_vector(std::move(leg)); stream->skip(1); path->_is_wildcard = true; @@ -1690,6 +1721,7 @@ inline bool JsonbPath::parse_member(Stream* stream, JsonbPath* path) { stream->skip(1); std::unique_ptr leg( new leg_info(stream->get_leg_ptr(), stream->get_leg_len(), 0, MEMBER_CODE)); + leg->is_wildcard = true; path->add_leg_to_leg_vector(std::move(leg)); path->_is_wildcard = true; return true; @@ -1736,8 +1768,10 @@ inline bool JsonbPath::parse_member(Stream* stream, JsonbPath* path) { stream->add_leg_len(); } + // An empty member name is only valid when it is written explicitly as `""`. if ((left_quotation_marks != nullptr && right_quotation_marks == nullptr) || - stream->get_leg_ptr() == nullptr || stream->get_leg_len() == 0) { + stream->get_leg_ptr() == nullptr || + (left_quotation_marks == nullptr && stream->get_leg_len() == 0)) { return false; //invalid json path } diff --git a/be/test/core/jsonb/jsonb_document_test.cpp b/be/test/core/jsonb/jsonb_document_test.cpp index 4801e1cbfc8..501b3fd2658 100644 --- a/be/test/core/jsonb/jsonb_document_test.cpp +++ b/be/test/core/jsonb/jsonb_document_test.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include "core/types.h" #include "util/jsonb_utils.h" @@ -320,6 +321,218 @@ TEST_F(JsonbDocumentTest, jsonb_path_member_to_string_escapes_control_characters EXPECT_EQ(round_trip, expected_path); } +TEST_F(JsonbDocumentTest, jsonb_path_member_to_string_quotes_dotted_key) { + std::string key = "a.b"; + + leg_info leg(key.data(), static_cast(key.size()), 0, MEMBER_CODE); + + std::string out; + ASSERT_TRUE(leg.to_string(&out)); + // A literal '.' inside the key is a path separator in JsonbPath syntax, so the + // leg must be double-quoted (".\"a.b\"") or a later JsonbPath::seek on this + // same string will split it into two legs ("a" then "b") instead of one. + EXPECT_EQ(out, ".\"a.b\""); + + const std::string expected_path = "$" + out; + std::string parsed_path = expected_path; + JsonbPath path; + ASSERT_TRUE(path.seek(parsed_path.data(), parsed_path.size())); + EXPECT_EQ(path.get_leg_vector_size(), 1); + + std::string round_trip; + ASSERT_TRUE(path.to_string(&round_trip)); + EXPECT_EQ(round_trip, expected_path); +} + +TEST_F(JsonbDocumentTest, jsonb_path_member_to_string_quotes_only_unparsable_keys) { + struct Case { + std::string key; + std::string expected; + }; + const std::vector cases = { + {"a[0]", ".\"a[0]\""}, // '[' starts an array leg when unquoted + {"*", ".\"*\""}, // a bare '*' is a wildcard when unquoted + {"*a", ".\"*a\""}, // a leading '*' is parsed as a wildcard leg + {"a*", ".a*"}, // '*' elsewhere is an ordinary character + {"a-b", ".a-b"}, // no structural character, stays unquoted + {"a]b", ".a]b"}, // ']' is not special for an unquoted member + {"a\"b", ".a\\\"b"}, // escaped quote is readable without quoting + {"", ".\"\""}, // an empty key is only valid when quoted + }; + + for (const auto& c : cases) { + std::string key = c.key; + leg_info leg(key.data(), static_cast(key.size()), 0, MEMBER_CODE); + + std::string out; + ASSERT_TRUE(leg.to_string(&out)) << c.key; + EXPECT_EQ(out, c.expected) << c.key; + + const std::string expected_path = "$" + out; + std::string parsed_path = expected_path; + JsonbPath path; + ASSERT_TRUE(path.seek(parsed_path.data(), parsed_path.size())) << c.key; + ASSERT_EQ(path.get_leg_vector_size(), 1) << c.key; + const auto* parsed = path.get_leg_from_leg_vector(0); + EXPECT_EQ(std::string(parsed->leg_ptr, parsed->leg_len), c.key); + + std::string round_trip; + ASSERT_TRUE(path.to_string(&round_trip)) << c.key; + EXPECT_EQ(round_trip, expected_path) << c.key; + } +} + +TEST_F(JsonbDocumentTest, jsonb_path_quoted_member_is_literal_key) { + JsonbWriter writer; + writer.writeStartObject(); + writer.writeKey("", 0); + writer.writeInt(1); + writer.writeKey("*", 1); + writer.writeInt(2); + writer.writeKey("other", static_cast(sizeof("other") - 1)); + writer.writeInt(3); + writer.writeEndObject(); + const auto* root = jsonb_value(writer); + + // A quoted member leg names exactly that key, including the empty key and "*". + { + std::string path_str = "$.\"\""; + JsonbPath path; + ASSERT_TRUE(path.seek(path_str.data(), path_str.size())); + auto result = root->findValue(path); + EXPECT_FALSE(result.is_wildcard); + ASSERT_NE(result.value, nullptr); + EXPECT_EQ(JsonbToJson {}.to_json_string(result.value), "1"); + } + { + std::string path_str = "$.\"*\""; + JsonbPath path; + ASSERT_TRUE(path.seek(path_str.data(), path_str.size())); + EXPECT_FALSE(path.is_wildcard()); + auto result = root->findValue(path); + EXPECT_FALSE(result.is_wildcard); + ASSERT_NE(result.value, nullptr); + EXPECT_EQ(JsonbToJson {}.to_json_string(result.value), "2"); + } + // An unquoted '*' is still the member wildcard. + { + std::string path_str = "$.*"; + JsonbPath path; + ASSERT_TRUE(path.seek(path_str.data(), path_str.size())); + EXPECT_TRUE(path.is_wildcard()); + auto result = root->findValue(path); + EXPECT_TRUE(result.is_wildcard); + ASSERT_NE(result.value, nullptr); + EXPECT_EQ(JsonbToJson {}.to_json_string(result.value), "[1,2,3]"); + } + + // An unquoted empty member leg is still invalid. + for (std::string path_str : {"$.", "$.other.", "$..other"}) { + JsonbPath path; + EXPECT_FALSE(path.seek(path_str.data(), path_str.size())) << path_str; + } +} + +TEST_F(JsonbDocumentTest, jsonb_empty_key_lookup_skips_key_ids) { + // A key id entry is stored with klen() == 0 like an empty key, but only the + // sMaxKeyId sentinel is the empty key name. + JsonbWriter writer; + writer.writeStartObject(); + writer.writeKey(static_cast(42)); + writer.writeInt(1); + writer.writeKey("", 0); + writer.writeInt(2); + writer.writeEndObject(); + const auto* root = jsonb_value(writer); + const auto* obj = root->unpack(); + + const auto* found = obj->find("", 0); + ASSERT_NE(found, nullptr); + EXPECT_EQ(JsonbToJson {}.to_json_string(found), "2"); + + std::string path_str = "$.\"\""; + JsonbPath path; + ASSERT_TRUE(path.seek(path_str.data(), path_str.size())); + auto result = root->findValue(path); + ASSERT_NE(result.value, nullptr); + EXPECT_EQ(JsonbToJson {}.to_json_string(result.value), "2"); + + // Without an empty key, a key id entry is never returned for an empty name. + JsonbWriter id_only_writer; + id_only_writer.writeStartObject(); + id_only_writer.writeKey(static_cast(42)); + id_only_writer.writeInt(1); + id_only_writer.writeEndObject(); + EXPECT_EQ(jsonb_value(id_only_writer)->unpack()->find("", 0), nullptr); +} + +TEST_F(JsonbDocumentTest, jsonb_path_wildcard_to_string_round_trip) { + JsonbWriter writer; + writer.writeStartObject(); + writer.writeKey("*", 1); + writer.writeInt(1); + writer.writeKey("other", static_cast(sizeof("other") - 1)); + writer.writeInt(2); + writer.writeEndObject(); + const auto* root = jsonb_value(writer); + + // A parsed wildcard formats back to the wildcard and a quoted "*" stays the literal key. + struct Case { + std::string path; + bool is_wildcard; + std::string value; + }; + const std::vector cases = { + {"$.*", true, "[1,2]"}, + {"$.\"*\"", false, "1"}, + }; + for (const auto& c : cases) { + std::string path_str = c.path; + JsonbPath path; + ASSERT_TRUE(path.seek(path_str.data(), path_str.size())) << c.path; + std::string formatted; + ASSERT_TRUE(path.to_string(&formatted)) << c.path; + EXPECT_EQ(formatted, c.path); + + JsonbPath reparsed; + ASSERT_TRUE(reparsed.seek(formatted.data(), formatted.size())) << c.path; + auto result = root->findValue(reparsed); + EXPECT_EQ(result.is_wildcard, c.is_wildcard) << c.path; + ASSERT_NE(result.value, nullptr) << c.path; + EXPECT_EQ(JsonbToJson {}.to_json_string(result.value), c.value) << c.path; + } + + for (std::string path_str : {"$[*]", "$[1]", "$.a[*].b"}) { + JsonbPath path; + ASSERT_TRUE(path.seek(path_str.data(), path_str.size())) << path_str; + std::string formatted; + ASSERT_TRUE(path.to_string(&formatted)) << path_str; + EXPECT_EQ(formatted, path_str); + } +} + +TEST_F(JsonbDocumentTest, jsonb_object_contains_matches_key_ids_by_id) { + JsonbWriter empty_key_writer; + empty_key_writer.writeStartObject(); + empty_key_writer.writeKey("", 0); + empty_key_writer.writeInt(1); + empty_key_writer.writeEndObject(); + const auto* empty_key = jsonb_value(empty_key_writer); + + JsonbWriter id_key_writer; + id_key_writer.writeStartObject(); + id_key_writer.writeKey(static_cast(42)); + id_key_writer.writeInt(1); + id_key_writer.writeEndObject(); + const auto* id_key = jsonb_value(id_key_writer); + + // Both keys are stored with klen() == 0, but key id 42 is not the empty key. + EXPECT_FALSE(empty_key->contains(id_key)); + EXPECT_FALSE(id_key->contains(empty_key)); + EXPECT_TRUE(empty_key->contains(empty_key)); + EXPECT_TRUE(id_key->contains(id_key)); +} + TEST_F(JsonbDocumentTest, jsonb_path_member_to_string_escapes_all_control_forms) { std::string key = "a"; key.push_back('\b'); diff --git a/be/test/exprs/function/function_jsonb_test.cpp b/be/test/exprs/function/function_jsonb_test.cpp index 7736362271c..fc6744b8b1c 100644 --- a/be/test/exprs/function/function_jsonb_test.cpp +++ b/be/test/exprs/function/function_jsonb_test.cpp @@ -1117,4 +1117,68 @@ TEST(FunctionJsonbTEST, JsonbModifyMissingPathParent) { } } +TEST(FunctionJsonbTEST, JsonbModifyMemberPathOnArray) { + InputTypeSet input_types = {PrimitiveType::TYPE_JSONB, PrimitiveType::TYPE_VARCHAR, + PrimitiveType::TYPE_JSONB}; + + // A member leg never addresses an array element, so a missing member under an array is + // not inserted; array legs and quoted literal keys still work. + DataSet data_set = { + {{STRING("[1]"), STRING("$.a"), STRING("2")}, STRING("[1]")}, + {{STRING("[]"), STRING("$.a"), STRING("2")}, STRING("[]")}, + {{STRING("[1]"), STRING(R"($."a.b")"), STRING("2")}, STRING("[1]")}, + {{STRING("[[1]]"), STRING("$[0].a"), STRING("2")}, STRING("[[1]]")}, + {{STRING(R"({"a":[1]})"), STRING("$.a.b"), STRING("2")}, STRING(R"({"a":[1]})")}, + {{STRING("[1]"), STRING("$[1]"), STRING("2")}, STRING("[1,2]")}, + {{STRING("{}"), STRING(R"($."a.b")"), STRING("2")}, STRING(R"({"a.b":2})")}, + {{STRING("{}"), STRING(R"($."")"), STRING("2")}, STRING(R"({"":2})")}, + }; + static_cast(check_function("jsonb_insert", input_types, data_set)); + static_cast(check_function("jsonb_set", input_types, data_set)); + + // Quoted "*" and "" name existing literal keys, including as parents of a nested leg. + DataSet set_existing = { + {{STRING(R"({"*":1,"b":1})"), STRING(R"($."*")"), STRING("2")}, + STRING(R"({"*":2,"b":1})")}, + {{STRING(R"({"":1,"b":1})"), STRING(R"($."")"), STRING("2")}, + STRING(R"({"":2,"b":1})")}, + {{STRING(R"({"*":{"":1},"b":1})"), STRING(R"($."*"."")"), STRING("2")}, + STRING(R"({"*":{"":2},"b":1})")}, + }; + static_cast(check_function("jsonb_set", input_types, set_existing)); + static_cast( + check_function("jsonb_replace", input_types, set_existing)); + + // jsonb_insert keeps existing literal keys unchanged and only fills a missing nested key. + DataSet insert_existing = { + {{STRING(R"({"*":1,"b":1})"), STRING(R"($."*")"), STRING("2")}, + STRING(R"({"*":1,"b":1})")}, + {{STRING(R"({"":1,"b":1})"), STRING(R"($."")"), STRING("2")}, + STRING(R"({"":1,"b":1})")}, + {{STRING(R"({"*":{"":1},"b":1})"), STRING(R"($."*"."")"), STRING("2")}, + STRING(R"({"*":{"":1},"b":1})")}, + {{STRING(R"({"b":1,"*":{}})"), STRING(R"($."*"."")"), STRING("2")}, + STRING(R"({"b":1,"*":{"":2}})")}, + }; + static_cast( + check_function("jsonb_insert", input_types, insert_existing)); + + // jsonb_replace leaves a missing literal key alone. + DataSet replace_missing = { + {{STRING(R"({"b":1})"), STRING(R"($."*")"), STRING("2")}, STRING(R"({"b":1})")}, + {{STRING(R"({"b":1})"), STRING(R"($."")"), STRING("2")}, STRING(R"({"b":1})")}, + }; + static_cast( + check_function("jsonb_replace", input_types, replace_missing)); + + // An unquoted '*' is still a wildcard and is rejected by every modify function. + DataSet wildcard = { + {{STRING(R"({"*":1,"b":1})"), STRING("$.*"), STRING("2")}, Null()}, + }; + for (const auto* func_name : {"jsonb_insert", "jsonb_set", "jsonb_replace"}) { + static_cast(check_function(func_name, input_types, wildcard, -1, + -1, true)); + } +} + } // namespace doris diff --git a/regression-test/data/query_p0/sql_functions/json_functions/json_search.out b/regression-test/data/query_p0/sql_functions/json_functions/json_search.out index c5eda451e47..75caae5d1cb 100644 --- a/regression-test/data/query_p0/sql_functions/json_functions/json_search.out +++ b/regression-test/data/query_p0/sql_functions/json_functions/json_search.out @@ -170,3 +170,18 @@ -- !search_control_escape -- "$.\\"a\\\\nb\\"" "$.\\"a\\\\tb\\"" "$.\\"a\\\\rb\\"" "$.a\\\\u0001b" "needle" +-- !search_dotted_key -- +"$.\\"a.b\\"" "hit" "hit" + +-- !search_bracket_and_wildcard_key -- +"$.\\"a[0]\\"" "hit" "$.\\"*a\\"" "hit" + +-- !search_empty_key -- +"$.\\"\\"" "hit" "hit" + +-- !search_literal_star_key -- +"$.\\"*\\"" "hit" ["hit","miss"] {"other":"miss"} ["k"] + +-- !search_member_path_on_array -- +[1] [1] [1] [] {"x":1,"a.b":2} [1,2] + diff --git a/regression-test/suites/query_p0/sql_functions/json_functions/json_search.groovy b/regression-test/suites/query_p0/sql_functions/json_functions/json_search.groovy index 1a4c515a340..06f965116b4 100644 --- a/regression-test/suites/query_p0/sql_functions/json_functions/json_search.groovy +++ b/regression-test/suites/query_p0/sql_functions/json_functions/json_search.groovy @@ -153,4 +153,62 @@ suite("test_json_search") { JSON_UNQUOTE(JSON_SEARCH('{ "a\\\\nb": "needle" }', 'one', 'needle')) ); """ + + // A key that contains a path separator must be quoted so the returned path replays + // through JSON_EXTRACT and finds the same value JSON_SEARCH matched. + qt_search_dotted_key """ + select JSON_SEARCH('{"a.b":"hit","a":{"b":"miss"}}', 'one', 'hit'), + JSON_EXTRACT('{"a.b":"hit","a":{"b":"miss"}}', + JSON_UNQUOTE(JSON_SEARCH('{"a.b":"hit","a":{"b":"miss"}}', 'one', 'hit'))), + JSON_EXTRACT('{"a.b":"hit","a":{"b":"miss"}}', '\$."a.b"'); + """ + + qt_search_bracket_and_wildcard_key """ + select JSON_SEARCH('{"a[0]":"hit","a":["miss"]}', 'one', 'hit'), + JSON_EXTRACT('{"a[0]":"hit","a":["miss"]}', + JSON_UNQUOTE(JSON_SEARCH('{"a[0]":"hit","a":["miss"]}', 'one', 'hit'))), + JSON_SEARCH('{"*a":"hit"}', 'one', 'hit'), + JSON_EXTRACT('{"*a":"hit"}', + JSON_UNQUOTE(JSON_SEARCH('{"*a":"hit"}', 'one', 'hit'))); + """ + + // The empty key is emitted as `$.""`, which names exactly that key. + qt_search_empty_key """ + select JSON_SEARCH('{"":"hit","a":"miss"}', 'one', 'hit'), + JSON_EXTRACT('{"":"hit","a":"miss"}', + JSON_UNQUOTE(JSON_SEARCH('{"":"hit","a":"miss"}', 'one', 'hit'))), + JSON_EXTRACT('{"":{"":"hit"}}', '\$."".""'); + """ + + test { + sql """select JSON_EXTRACT('{"":"hit"}', '\$.');""" + exception "Invalid Json Path" + } + + // A quoted "*" is the literal key `*`, an unquoted * is still the wildcard. + qt_search_literal_star_key """ + select JSON_SEARCH('{"*":"hit","other":"miss"}', 'one', 'hit'), + JSON_EXTRACT('{"*":"hit","other":"miss"}', + JSON_UNQUOTE(JSON_SEARCH('{"*":"hit","other":"miss"}', 'one', 'hit'))), + JSON_EXTRACT('{"*":"hit","other":"miss"}', '\$.*'), + JSON_REMOVE('{"*":"hit","other":"miss"}', + JSON_UNQUOTE(JSON_SEARCH('{"*":"hit","other":"miss"}', 'one', 'hit'))), + JSON_KEYS('{"*":{"k":"hit"},"other":{"x":"miss"}}', '\$."*"'); + """ + + test { + sql """select JSON_REMOVE('{"*":"hit","other":"miss"}', '\$.*');""" + exception "path expressions may not contain the * and ** tokens" + } + + // A member path never addresses an array element, so JSON_SET / JSON_INSERT leave an + // array unchanged instead of overwriting or appending an element. + qt_search_member_path_on_array """ + select JSON_SET('[1]', JSON_UNQUOTE(JSON_SEARCH('{"a.b":"hit"}', 'one', 'hit')), 2), + JSON_INSERT('[1]', JSON_UNQUOTE(JSON_SEARCH('{"a.b":"hit"}', 'one', 'hit')), 2), + JSON_SET('[1]', '\$.a', 2), + JSON_INSERT('[]', '\$.a', 2), + JSON_SET('{"x":1}', JSON_UNQUOTE(JSON_SEARCH('{"a.b":"hit"}', 'one', 'hit')), 2), + JSON_SET('[1]', '\$[1]', 2); + """ }