From 21704191d32651e9742a850d9433037f352fb6b5 Mon Sep 17 00:00:00 2001 From: Stsiapan Bahrytsevich Date: Wed, 30 Oct 2024 16:20:48 +0100 Subject: [PATCH] fix(search_family): Fix LOAD fields parsing in the FT.AGGREGATE and FT.SEARCH commands fixes dragonflydb#3989 Signed-off-by: Stsiapan Bahrytsevich --- src/core/search/search.cc | 14 +- src/core/search/search.h | 9 +- src/server/search/doc_accessors.cc | 16 ++- src/server/search/doc_index.cc | 51 +++++--- src/server/search/doc_index.h | 104 ++++++++++++++- src/server/search/search_family.cc | 34 +++-- src/server/search/search_family_test.cc | 165 +++++++++++++++++++++++- 7 files changed, 357 insertions(+), 36 deletions(-) diff --git a/src/core/search/search.cc b/src/core/search/search.cc index cd0cc5a8a232..e5c8d8e73fc1 100644 --- a/src/core/search/search.cc +++ b/src/core/search/search.cc @@ -501,6 +501,12 @@ string_view Schema::LookupAlias(string_view alias) const { return alias; } +string_view Schema::LookupIdentifier(string_view identifier) const { + if (auto it = fields.find(identifier); it != fields.end()) + return it->second.short_name; + return identifier; +} + IndicesOptions::IndicesOptions() { static absl::flat_hash_set kDefaultStopwords{ "a", "is", "the", "an", "and", "are", "as", "at", "be", "but", "by", @@ -621,10 +627,14 @@ const Schema& FieldIndices::GetSchema() const { return schema_; } -vector> FieldIndices::ExtractStoredValues(DocId doc) const { +vector> FieldIndices::ExtractStoredValues( + DocId doc, const absl::flat_hash_map& aliases) const { vector> out; for (const auto& [ident, index] : sort_indices_) { - out.emplace_back(ident, index->Lookup(doc)); + const auto& it = aliases.find(ident); + const auto& name = it == aliases.end() ? schema_.LookupIdentifier(ident) : it->second; + + out.emplace_back(name, index->Lookup(doc)); } return out; } diff --git a/src/core/search/search.h b/src/core/search/search.h index c37a67fa7d6e..1cdee57b4dc8 100644 --- a/src/core/search/search.h +++ b/src/core/search/search.h @@ -60,6 +60,9 @@ struct Schema { // Return identifier for alias if found, otherwise return passed value std::string_view LookupAlias(std::string_view alias) const; + + // Return alias for identifier if found, otherwise return passed value + std::string_view LookupIdentifier(std::string_view identifier) const; }; struct IndicesOptions { @@ -88,7 +91,11 @@ class FieldIndices { const Schema& GetSchema() const; // Extract values stored in sort indices - std::vector> ExtractStoredValues(DocId doc) const; + // aliases are specified in addition to the aliases in search::Schema + std::vector> ExtractStoredValues( + DocId doc, + const absl::flat_hash_map& + aliases) const; absl::flat_hash_set GetSortIndiciesFields() const; diff --git a/src/server/search/doc_accessors.cc b/src/server/search/doc_accessors.cc index b256647fbf97..d178ede13ca8 100644 --- a/src/server/search/doc_accessors.cc +++ b/src/server/search/doc_accessors.cc @@ -82,8 +82,14 @@ search::SortableValue ExtractSortableValueFromJson(const search::Schema& schema, SearchDocData BaseAccessor::Serialize( const search::Schema& schema, absl::Span> fields) const { SearchDocData out{}; - for (const auto& [fident, fname] : fields) { - out[fname] = ExtractSortableValue(schema, fident, absl::StrJoin(GetStrings(fident), ",")); + for (const auto& field : fields) { + const auto& fident = field.GetIdentifier(schema, false); + const auto& fname = field.GetShortName(schema); + + auto value = GetStrings(fident); + if (!value.empty()) { + out[fname] = ExtractSortableValue(schema, fident, absl::StrJoin(value, ",")); + } } return out; } @@ -250,14 +256,16 @@ JsonAccessor::JsonPathContainer* JsonAccessor::GetPath(std::string_view field) c SearchDocData JsonAccessor::Serialize(const search::Schema& schema) const { SearchFieldsList fields{}; for (const auto& [fname, fident] : schema.field_names) - fields.emplace_back(fident, fname); + fields.emplace_back(fident, NameType::kIdentifier, fname); return Serialize(schema, fields); } SearchDocData JsonAccessor::Serialize( const search::Schema& schema, absl::Span> fields) const { SearchDocData out{}; - for (const auto& [ident, name] : fields) { + for (const auto& field : fields) { + const auto& ident = field.GetIdentifier(schema, true); + const auto& name = field.GetShortName(schema); if (auto* path = GetPath(ident); path) { if (auto res = path->Evaluate(json_); !res.empty()) out[name] = ExtractSortableValueFromJson(schema, ident, res[0]); diff --git a/src/server/search/doc_index.cc b/src/server/search/doc_index.cc index 5835971eb76f..d902e71a63de 100644 --- a/src/server/search/doc_index.cc +++ b/src/server/search/doc_index.cc @@ -60,8 +60,8 @@ bool SerializedSearchDoc::operator>=(const SerializedSearchDoc& other) const { return this->score >= other.score; } -bool SearchParams::ShouldReturnField(std::string_view field) const { - auto cb = [field](const auto& entry) { return entry.first == field; }; +bool SearchParams::ShouldReturnField(std::string_view alias) const { + auto cb = [alias](const auto& entry) { return entry.GetShortName() == alias; }; return !return_fields || any_of(return_fields->begin(), return_fields->end(), cb); } @@ -211,12 +211,13 @@ bool ShardDocIndex::Matches(string_view key, unsigned obj_code) const { return base_->Matches(key, obj_code); } -SearchFieldsList ToSV(const std::optional& fields) { +SearchFieldsList ToSV(const search::Schema& schema, + const std::optional& fields) { SearchFieldsList sv_fields; if (fields) { sv_fields.reserve(fields->size()); - for (const auto& [fident, fname] : fields.value()) { - sv_fields.emplace_back(fident, fname); + for (const auto& field : fields.value()) { + sv_fields.emplace_back(field); } } return sv_fields; @@ -230,8 +231,8 @@ SearchResult ShardDocIndex::Search(const OpArgs& op_args, const SearchParams& pa if (!search_results.error.empty()) return SearchResult{facade::ErrorReply{std::move(search_results.error)}}; - SearchFieldsList fields_to_load = - ToSV(params.ShouldReturnAllFields() ? params.load_fields : params.return_fields); + SearchFieldsList fields_to_load = ToSV( + base_->schema, params.ShouldReturnAllFields() ? params.load_fields : params.return_fields); vector out; out.reserve(search_results.ids.size()); @@ -281,8 +282,19 @@ vector ShardDocIndex::SearchForAggregator( if (!search_results.error.empty()) return {}; - SearchFieldsList fields_to_load = - GetFieldsToLoad(params.load_fields, indices_->GetSortIndiciesFields()); + auto sort_indicies_fields = indices_->GetSortIndiciesFields(); + SearchFieldsList fields_to_load = GetFieldsToLoad(params.load_fields, sort_indicies_fields); + + // aliases for ExtractStoredValues + absl::flat_hash_map sort_indicies_aliases; + if (params.load_fields) { + for (const auto& field : params.load_fields.value()) { + auto ident = field.GetIdentifier(base_->schema); + if (sort_indicies_fields.contains(ident)) { + sort_indicies_aliases[ident] = field.GetShortName(); + } + } + } vector> out; for (DocId doc : search_results.ids) { @@ -293,7 +305,7 @@ vector ShardDocIndex::SearchForAggregator( continue; auto accessor = GetAccessor(op_args.db_cntx, (*it)->second); - auto extracted = indices_->ExtractStoredValues(doc); + auto extracted = indices_->ExtractStoredValues(doc, sort_indicies_aliases); SearchDocData loaded = accessor->Serialize(base_->schema, fields_to_load); @@ -307,25 +319,32 @@ vector ShardDocIndex::SearchForAggregator( SearchFieldsList ShardDocIndex::GetFieldsToLoad( const std::optional& load_fields, const absl::flat_hash_set& skip_fields) const { - // identifier to short name - absl::flat_hash_map unique_fields; + absl::flat_hash_map> unique_fields; unique_fields.reserve(base_->schema.field_names.size()); for (const auto& [fname, fident] : base_->schema.field_names) { if (!skip_fields.contains(fident)) { - unique_fields[fident] = fname; + unique_fields[fident] = {std::string_view{fident}, NameType::kIdentifier, + std::string_view{fname}}; } } if (load_fields) { - for (const auto& [fident, fname] : load_fields.value()) { + for (const auto& field : load_fields.value()) { + const auto& fident = field.GetIdentifier(base_->schema); if (!skip_fields.contains(fident)) { - unique_fields[fident] = fname; + unique_fields[fident] = field; } } } - return {unique_fields.begin(), unique_fields.end()}; + SearchFieldsList fields; + fields.reserve(unique_fields.size()); + for (auto& [_, field] : unique_fields) { + fields.emplace_back(std::move(field)); + } + + return fields; } DocIndexInfo ShardDocIndex::GetInfo() const { diff --git a/src/server/search/doc_index.h b/src/server/search/doc_index.h index 564ca6193540..4c73c3134cf2 100644 --- a/src/server/search/doc_index.h +++ b/src/server/search/doc_index.h @@ -52,7 +52,102 @@ struct SearchResult { std::optional error; }; -template using SearchField = std::pair; +enum class NameType : uint8_t { kIdentifier, kShortName, kUndefined }; + +template class SearchField { + private: + using SingleName = std::pair; + + static bool IsJsonPath(const T& name) { + if (name.size() < 2) { + return false; + } + return name.front() == '$' && (name[1] == '.' || name[1] == '['); + } + + public: + SearchField() = default; + + SearchField(T name, NameType name_type) : name_(std::make_pair(std::move(name), name_type)) { + } + + SearchField(T name, NameType name_type, T new_alias) + : name_(std::make_pair(std::move(name), name_type)), new_alias_(std::move(new_alias)) { + } + + template >> + explicit SearchField(const SearchField& other) + : name_(std::make_pair(T{other.name_.first}, other.name_.second)) { + if (other.HasNewAlias()) { + new_alias_ = T{other.new_alias_.value()}; + } else { + new_alias_.reset(); + } + } + + template >> + SearchField& operator=(const SearchField& other) { + name_ = std::make_pair(T{other.name_.first}, other.name_.second); + if (other.HasNewAlias()) { + new_alias_ = T{other.new_alias_.value()}; + } else { + new_alias_.reset(); + } + return *this; + } + + ~SearchField() = default; + + std::string_view GetIdentifier(const search::Schema& schema) const { + return GetIdentifier(schema, [&](const SingleName& single_name) { + return single_name.second == NameType::kIdentifier; + }); + } + + std::string_view GetIdentifier(const search::Schema& schema, bool is_json_field) const { + return GetIdentifier(schema, [&](const SingleName& single_name) { + return single_name.second == NameType::kIdentifier || + (is_json_field && IsJsonPath(single_name.first)); + }); + } + + std::string_view GetShortName() const { + if (HasNewAlias()) { + return new_alias_.value(); + } + return name_.first; + } + + std::string_view GetShortName(const search::Schema& schema) const { + if (HasNewAlias()) { + return new_alias_.value(); + } + + if (name_.second == NameType::kShortName) { + return name_.first; + } + return schema.LookupIdentifier(std::string_view{name_.first}); + } + + private: + template + std::string_view GetIdentifier(const search::Schema& schema, Callback is_identifier) const { + if (is_identifier(name_)) { + return name_.first; + } + return schema.LookupAlias(std::string_view{name_.first}); + } + + bool HasNewAlias() const { + return new_alias_.has_value(); + } + + template friend class SearchField; + + private: + SingleName name_; + std::optional new_alias_; +}; using SearchFieldsList = std::vector>; using OwnedSearchFieldsList = std::vector>; @@ -88,7 +183,7 @@ struct SearchParams { return return_fields && return_fields->empty(); } - bool ShouldReturnField(std::string_view field) const; + bool ShouldReturnField(std::string_view alias) const; }; struct AggregateParams { @@ -169,8 +264,9 @@ class ShardDocIndex { io::Result GetTagVals(std::string_view field) const; private: - // Returns the fields that are the union of the already indexed fields and load_fields, excluding - // skip_fields Load_fields should not be destroyed while the result of this function is being used + /* Returns the fields that are the union of the already indexed fields and load_fields, excluding + skip_fields. + Load_fields should not be destroyed while the result of this function is being used */ SearchFieldsList GetFieldsToLoad(const std::optional& load_fields, const absl::flat_hash_set& skip_fields) const; diff --git a/src/server/search/search_family.cc b/src/server/search/search_family.cc index 6ef550715c10..bad7bea649c3 100644 --- a/src/server/search/search_family.cc +++ b/src/server/search/search_family.cc @@ -183,9 +183,13 @@ optional ParseSchemaOrReply(DocIndex::DataType type, CmdArgParse #pragma GCC diagnostic pop #endif +bool StartsWithAtSign(std::string_view field) { + return !field.empty() && field.front() == '@'; +} + std::string_view ParseField(CmdArgParser* parser) { std::string_view field = parser->Next(); - if (!field.empty() && field.front() == '@') { + if (StartsWithAtSign(field)) { field.remove_prefix(1); // remove leading @ if exists } return field; @@ -193,7 +197,7 @@ std::string_view ParseField(CmdArgParser* parser) { std::string_view ParseFieldWithAtSign(CmdArgParser* parser) { std::string_view field = parser->Next(); - if (!field.empty() && field.front() == '@') { + if (StartsWithAtSign(field)) { field.remove_prefix(1); // remove leading @ } else { // Temporary warning until we can throw an error @@ -210,9 +214,18 @@ void ParseLoadFields(CmdArgParser* parser, std::optional* } while (num_fields--) { - string_view field = ParseField(parser); - string_view alias = parser->Check("AS") ? parser->Next() : field; - load_fields->value().emplace_back(field, alias); + string_view str = parser->Next(); + + if (StartsWithAtSign(str)) { + str.remove_prefix(1); // remove leading @ + } + + if (parser->Check("AS")) { + load_fields->value().emplace_back(std::string{str}, NameType::kShortName, + std::string{parser->Next()}); + } else { + load_fields->value().emplace_back(std::string{str}, NameType::kShortName); + } } } @@ -251,9 +264,14 @@ optional ParseSearchParamsOrReply(CmdArgParser parser, SinkReplyBu size_t num_fields = parser.Next(); params.return_fields.emplace(); while (params.return_fields->size() < num_fields) { - string_view ident = parser.Next(); - string_view alias = parser.Check("AS") ? parser.Next() : ident; - params.return_fields->emplace_back(ident, alias); + std::string_view str = parser.Next(); + + if (parser.Check("AS")) { + params.return_fields->emplace_back(std::string{str}, NameType::kShortName, + std::string{parser.Next()}); + } else { + params.return_fields->emplace_back(std::string{str}, NameType::kShortName); + } } } else if (parser.Check("NOCONTENT")) { // NOCONTENT params.load_fields.emplace(); diff --git a/src/server/search/search_family_test.cc b/src/server/search/search_family_test.cc index dbe063ab22e5..3d00a5f44b97 100644 --- a/src/server/search/search_family_test.cc +++ b/src/server/search/search_family_test.cc @@ -628,7 +628,7 @@ TEST_F(SearchFamilyTest, TestReturn) { // Check non-existing field resp = Run({"ft.search", "i1", "@justA:0", "return", "1", "nothere"}); - EXPECT_THAT(resp, MatchEntry("k0", "nothere", "")); + EXPECT_THAT(resp, MatchEntry("k0")); // Checl implcit __vector_score is provided float score = 20; @@ -1156,4 +1156,167 @@ TEST_F(SearchFamilyTest, AggregateWithLoadOptionHard) { } #endif +TEST_F(SearchFamilyTest, SearchLoadReturnJson) { + Run({"JSON.SET", "j1", ".", R"({"a":"one"})"}); + Run({"JSON.SET", "j2", ".", R"({"a":"two"})"}); + + auto resp = Run({"FT.CREATE", "i1", "ON", "JSON", "SCHEMA", "$.a", "AS", "a", "TEXT"}); + EXPECT_EQ(resp, "OK"); + + // Search with RETURN $.a + resp = Run({"FT.SEARCH", "i1", "*", "RETURN", "1", "$.a"}); + EXPECT_THAT(resp, IsMapWithSize("j1", IsMap("$.a", "\"one\""), "j2", IsMap("$.a", "\"two\""))); + + // Search with RETURN a + resp = Run({"FT.SEARCH", "i1", "*", "RETURN", "1", "a"}); + EXPECT_THAT(resp, IsMapWithSize("j1", IsMap("a", "\"one\""), "j2", IsMap("a", "\"two\""))); + + // Search with RETURN @a + resp = Run({"FT.SEARCH", "i1", "*", "RETURN", "1", "@a"}); + EXPECT_THAT(resp, IsMapWithSize("j1", IsMap(), "j2", IsMap())); + + // Search with RETURN $.a AS vvv + resp = Run({"FT.SEARCH", "i1", "*", "RETURN", "1", "$.a", "AS", "vvv"}); + EXPECT_THAT(resp, IsMapWithSize("j1", IsMap("vvv", "\"one\""), "j2", IsMap("vvv", "\"two\""))); + + // Search with RETURN a AS vvv + resp = Run({"FT.SEARCH", "i1", "*", "RETURN", "1", "a", "AS", "vvv"}); + EXPECT_THAT(resp, IsMapWithSize("j1", IsMap("vvv", "\"one\""), "j2", IsMap("vvv", "\"two\""))); + + // Search with RETURN @a AS vvv + resp = Run({"FT.SEARCH", "i1", "*", "RETURN", "1", "@a", "AS", "vvv"}); + EXPECT_THAT(resp, IsMapWithSize("j1", IsMap(), "j2", IsMap())); + + // Search with LOAD $.a + resp = Run({"FT.SEARCH", "i1", "*", "LOAD", "1", "$.a"}); + EXPECT_THAT(resp, IsMapWithSize("j1", IsMap("$.a", "\"one\"", "$", R"({"a":"one"})"), "j2", + IsMap("$.a", "\"two\"", "$", R"({"a":"two"})"))); + + // Search with LOAD a + resp = Run({"FT.SEARCH", "i1", "*", "LOAD", "1", "a"}); + EXPECT_THAT(resp, IsMapWithSize("j1", IsMap("a", "\"one\"", "$", R"({"a":"one"})"), "j2", + IsMap("a", "\"two\"", "$", R"({"a":"two"})"))); + + // Search with LOAD @a + resp = Run({"FT.SEARCH", "i1", "*", "LOAD", "1", "@a"}); + EXPECT_THAT(resp, IsMapWithSize("j1", IsMap("a", "\"one\"", "$", R"({"a":"one"})"), "j2", + IsMap("a", "\"two\"", "$", R"({"a":"two"})"))); + + // Search with LOAD $.a AS vvv + resp = Run({"FT.SEARCH", "i1", "*", "LOAD", "1", "$.a", "AS", "vvv"}); + EXPECT_THAT(resp, IsMapWithSize("j1", IsMap("$", R"({"a":"one"})", "vvv", "\"one\""), "j2", + IsMap("$", R"({"a":"two"})", "vvv", "\"two\""))); + + // Search with LOAD a AS vvv + resp = Run({"FT.SEARCH", "i1", "*", "LOAD", "1", "a", "AS", "vvv"}); + EXPECT_THAT(resp, IsMapWithSize("j1", IsMap("$", R"({"a":"one"})", "vvv", "\"one\""), "j2", + IsMap("$", R"({"a":"two"})", "vvv", "\"two\""))); + + // Search with LOAD @a AS vvv + resp = Run({"FT.SEARCH", "i1", "*", "LOAD", "1", "@a", "AS", "vvv"}); + EXPECT_THAT(resp, IsMapWithSize("j1", IsMap("$", R"({"a":"one"})", "vvv", "\"one\""), "j2", + IsMap("$", R"({"a":"two"})", "vvv", "\"two\""))); + + /* Test another name */ + + resp = Run({"FT.CREATE", "i2", "ON", "JSON", "SCHEMA", "$.a", "AS", "nnn", "TEXT"}); + EXPECT_EQ(resp, "OK"); + + // Search with RETURN nnn + resp = Run({"FT.SEARCH", "i2", "*", "RETURN", "1", "nnn"}); + EXPECT_THAT(resp, IsMapWithSize("j1", IsMap("nnn", "\"one\""), "j2", IsMap("nnn", "\"two\""))); + + // Search with RETURN @nnn + resp = Run({"FT.SEARCH", "i2", "*", "RETURN", "1", "@nnn"}); + EXPECT_THAT(resp, IsMapWithSize("j1", IsMap(), "j2", IsMap())); + + // Search with RETURN a + resp = Run({"FT.SEARCH", "i2", "*", "RETURN", "1", "a"}); + EXPECT_THAT(resp, IsMapWithSize("j1", IsMap(), "j2", IsMap())); + + // Search with RETURN @a + resp = Run({"FT.SEARCH", "i2", "*", "RETURN", "1", "@a"}); + EXPECT_THAT(resp, IsMapWithSize("j1", IsMap(), "j2", IsMap())); + + // Search with LOAD nnn + resp = Run({"FT.SEARCH", "i2", "*", "LOAD", "1", "nnn"}); + EXPECT_THAT(resp, IsMapWithSize("j1", IsMap("nnn", "\"one\"", "$", R"({"a":"one"})"), "j2", + IsMap("nnn", "\"two\"", "$", R"({"a":"two"})"))); + + // Search with LOAD @nnn + resp = Run({"FT.SEARCH", "i2", "*", "LOAD", "1", "@nnn"}); + EXPECT_THAT(resp, IsMapWithSize("j1", IsMap("nnn", "\"one\"", "$", R"({"a":"one"})"), "j2", + IsMap("nnn", "\"two\"", "$", R"({"a":"two"})"))); + + // Search with LOAD a + resp = Run({"FT.SEARCH", "i2", "*", "LOAD", "1", "a"}); + EXPECT_THAT( + resp, IsMapWithSize("j1", IsMap("$", R"({"a":"one"})"), "j2", IsMap("$", R"({"a":"two"})"))); + + // Search with LOAD @a + resp = Run({"FT.SEARCH", "i2", "*", "LOAD", "1", "@a"}); + EXPECT_THAT( + resp, IsMapWithSize("j1", IsMap("$", R"({"a":"one"})"), "j2", IsMap("$", R"({"a":"two"})"))); +} + +TEST_F(SearchFamilyTest, SearchLoadReturnHash) { + Run({"HSET", "h1", "a", "one"}); + Run({"HSET", "h2", "a", "two"}); + + auto resp = Run({"FT.CREATE", "i1", "ON", "HASH", "SCHEMA", "a", "TEXT"}); + EXPECT_EQ(resp, "OK"); + + // Search with RETURN $.a + resp = Run({"FT.SEARCH", "i1", "*", "RETURN", "1", "$.a"}); + EXPECT_THAT(resp, IsMapWithSize("h2", IsMap(), "h1", IsMap())); + + // Search with RETURN a + resp = Run({"FT.SEARCH", "i1", "*", "RETURN", "1", "a"}); + EXPECT_THAT(resp, IsMapWithSize("h2", IsMap("a", "two"), "h1", IsMap("a", "one"))); + + // Search with RETURN @a + resp = Run({"FT.SEARCH", "i1", "*", "RETURN", "1", "@a"}); + EXPECT_THAT(resp, IsMapWithSize("h2", IsMap(), "h1", IsMap())); + + // Search with RETURN $.a AS vvv + resp = Run({"FT.SEARCH", "i1", "*", "RETURN", "1", "$.a", "AS", "vvv"}); + EXPECT_THAT(resp, IsMapWithSize("h2", IsMap(), "h1", IsMap())); + + // Search with RETURN a AS vvv + resp = Run({"FT.SEARCH", "i1", "*", "RETURN", "1", "a", "AS", "vvv"}); + EXPECT_THAT(resp, IsMapWithSize("h2", IsMap("vvv", "two"), "h1", IsMap("vvv", "one"))); + + // Search with RETURN @a AS vvv + resp = Run({"FT.SEARCH", "i1", "*", "RETURN", "1", "@a", "AS", "vvv"}); + EXPECT_THAT(resp, IsMapWithSize("h2", IsMap(), "h1", IsMap())); + + // Search with LOAD $.a + resp = Run({"FT.SEARCH", "i1", "*", "LOAD", "1", "$.a"}); + EXPECT_THAT(resp, IsMapWithSize("h2", IsMap("a", "two"), "h1", IsMap("a", "one"))); + + // Search with LOAD a + resp = Run({"FT.SEARCH", "i1", "*", "LOAD", "1", "a"}); + EXPECT_THAT(resp, IsMapWithSize("h2", IsMap("a", "two"), "h1", IsMap("a", "one"))); + + // Search with LOAD @a + resp = Run({"FT.SEARCH", "i1", "*", "LOAD", "1", "@a"}); + EXPECT_THAT(resp, IsMapWithSize("h2", IsMap("a", "two"), "h1", IsMap("a", "one"))); + + // Search with LOAD $.a AS vvv + resp = Run({"FT.SEARCH", "i1", "*", "LOAD", "1", "$.a", "AS", "vvv"}); + EXPECT_THAT(resp, IsMapWithSize("h2", IsMap("a", "two"), "h1", IsMap("a", "one"))); + + // Search with LOAD a AS vvv + resp = Run({"FT.SEARCH", "i1", "*", "LOAD", "1", "a", "AS", "vvv"}); + EXPECT_THAT(resp, IsMapWithSize("h2", IsMap("vvv", "two", "a", "two"), "h1", + IsMap("vvv", "one", "a", "one"))); + + // Search with LOAD @a AS vvv + resp = Run({"FT.SEARCH", "i1", "*", "LOAD", "1", "@a", "AS", "vvv"}); + EXPECT_THAT(resp, IsMapWithSize("h2", IsMap("vvv", "two", "a", "two"), "h1", + IsMap("vvv", "one", "a", "one"))); + + /* Test another name */ +} + } // namespace dfly