diff --git a/src/commands/cmd_search.cc b/src/commands/cmd_search.cc index 7dbaa4af02b..c6506644897 100644 --- a/src/commands/cmd_search.cc +++ b/src/commands/cmd_search.cc @@ -471,8 +471,49 @@ class CommandFTList : public Commander { class CommandFTDrop : public Commander { Status Execute(Server *srv, Connection *conn, std::string *output) override { const auto &index_name = args_[1]; + engine::Context ctx(srv->storage); + + GET_OR_RET(srv->index_mgr.Drop(ctx, index_name, conn->GetNamespace())); + + output->append(SimpleString("OK")); + + return Status::OK(); + }; +}; + +class CommandFTAliasAdd : public Commander { + Status Execute(Server *srv, Connection *conn, std::string *output) override { + const auto &alias_name = args_[1]; + const auto &index_name = args_[2]; + engine::Context ctx(srv->storage); + + GET_OR_RET(srv->index_mgr.AddAlias(ctx, alias_name, index_name, conn->GetNamespace())); + output->append(SimpleString("OK")); + + return Status::OK(); + }; +}; + +class CommandFTAliasDel : public Commander { + Status Execute(Server *srv, Connection *conn, std::string *output) override { + const auto &alias_name = args_[1]; + engine::Context ctx(srv->storage); + + GET_OR_RET(srv->index_mgr.DelAlias(ctx, alias_name, conn->GetNamespace())); + + output->append(SimpleString("OK")); + + return Status::OK(); + }; +}; + +class CommandFTAliasUpdate : public Commander { + Status Execute(Server *srv, Connection *conn, std::string *output) override { + const auto &alias_name = args_[1]; + const auto &index_name = args_[2]; + engine::Context ctx(srv->storage); - GET_OR_RET(srv->index_mgr.Drop(index_name, conn->GetNamespace())); + GET_OR_RET(srv->index_mgr.UpdateAlias(ctx, alias_name, index_name, conn->GetNamespace())); output->append(SimpleString("OK")); @@ -488,6 +529,9 @@ REDIS_REGISTER_COMMANDS(Search, MakeCmdAttr("ft.explain", -3, "read-only", 0, 0, 0), MakeCmdAttr("ft.info", 2, "read-only", 0, 0, 0), MakeCmdAttr("ft._list", 1, "read-only", 0, 0, 0), - MakeCmdAttr("ft.dropindex", 2, "write exclusive no-multi no-script", 0, 0, 0)); + MakeCmdAttr("ft.dropindex", 2, "write exclusive no-multi no-script", 0, 0, 0), + MakeCmdAttr("ft.aliasadd", 3, "write", 0, 0, 0), + MakeCmdAttr("ft.aliasdel", 2, "write", 0, 0, 0), + MakeCmdAttr("ft.aliasupdate", 3, "write", 0, 0, 0)); } // namespace redis diff --git a/src/search/index_info.h b/src/search/index_info.h index 3badc372adc..9fdc0ed573c 100644 --- a/src/search/index_info.h +++ b/src/search/index_info.h @@ -58,6 +58,7 @@ struct IndexInfo { FieldMap fields; redis::IndexPrefixes prefixes; std::string ns; + std::vector aliases; IndexInfo(std::string name, redis::IndexMetadata metadata, std::string ns) : name(std::move(name)), metadata(std::move(metadata)), ns(std::move(ns)) {} diff --git a/src/search/index_manager.h b/src/search/index_manager.h index 6ab88397ef8..09014f48369 100644 --- a/src/search/index_manager.h +++ b/src/search/index_manager.h @@ -91,6 +91,35 @@ struct IndexManager { auto info = std::make_unique(index_name.ToString(), metadata, ns); info->prefixes = prefixes; + auto alias_iter = util::UniqueIterator(no_txn_ctx, no_txn_ctx.DefaultScanOptions(), ColumnFamilyID::Search); + auto alias_prefix = index_key.ConstructAliasKey(""); + + for (alias_iter->Seek(alias_prefix); alias_iter->Valid(); alias_iter->Next()) { + auto key = alias_iter->key(); + + uint8_t ns_size = 0; + if (!GetFixed8(&key, &ns_size)) break; + if (ns_size != ns.size()) break; + if (!key.starts_with(ns)) break; + key.remove_prefix(ns_size); + + uint8_t subkey_type = 0; + if (!GetFixed8(&key, &subkey_type)) break; + if (subkey_type != (uint8_t)SearchSubkeyType::FIELD_ALIAS) break; + + Slice alias_name_slice; + if (!GetSizedString(&key, &alias_name_slice)) break; + + Slice index_name_slice; + if (!GetSizedString(&key, &index_name_slice)) break; + + info->aliases.emplace_back(alias_name_slice.ToStringView()); + } + + if (auto s = alias_iter->status(); !s.ok()) { + return {Status::NotOK, fmt::format("fail to load aliases: {}", s.ToString())}; + } + util::UniqueIterator field_iter(no_txn_ctx, no_txn_ctx.DefaultScanOptions(), ColumnFamilyID::Search); auto field_begin = index_key.ConstructFieldMeta(); @@ -226,20 +255,29 @@ struct IndexManager { return results; } - Status Drop(std::string_view index_name, const std::string &ns) { + Status Drop(engine::Context &ctx, std::string_view index_name, const std::string &ns) { auto iter = index_map.Find(index_name, ns); if (iter == index_map.end()) { return {Status::NotOK, "index not found"}; } auto info = iter->second.get(); - indexer->Remove(info); SearchKey index_key(info->ns, info->name); auto cf = storage->GetCFHandle(ColumnFamilyID::Search); auto batch = storage->GetWriteBatchBase(); + std::vector aliases_copy = info->aliases; + + for (const auto &alias_name : aliases_copy) { + auto s = DelAlias(ctx, alias_name, ns); + if (!s.IsOK()) { + return Status::NotOK; + } + } + indexer->Remove(info); + auto s = batch->Delete(cf, index_key.ConstructIndexMeta()); if (!s.ok()) { return {Status::NotOK, s.ToString()}; @@ -272,6 +310,137 @@ struct IndexManager { return Status::OK(); } + + Status AddAlias(engine::Context &ctx, std::string_view alias_name, std::string_view index_name, + const std::string &ns) { + auto iter = index_map.Find(index_name, ns); + if (iter == index_map.end()) { + return {Status::NotOK, "Target index not found"}; + } + + auto info = iter->second.get(); + + // Checks if the alias already exists in the index + auto it = std::find(info->aliases.begin(), info->aliases.end(), alias_name); + if (it != info->aliases.end()) { + return {Status::NotOK, fmt::format("Alias already exists inside index")}; + } + + auto cf = storage->GetCFHandle(ColumnFamilyID::Search); + auto batch = storage->GetWriteBatchBase(); + SearchKey alias_key(ns, ""); + + auto no_txn_ctx = engine::Context::NoTransactionContext(storage); + + std::string retrieve_index; + auto s = storage->Get(no_txn_ctx, no_txn_ctx.DefaultMultiGetOptions(), cf, alias_key.ConstructAliasKey(alias_name), + &retrieve_index); + if (s.ok()) { + return {Status::NotOK, fmt::format("Alias already exists")}; + } + + s = batch->Put(cf, alias_key.ConstructAliasKey(alias_name), std::string(index_name)); + if (!s.ok()) { + return {Status::NotOK, s.ToString()}; + } + + s = storage->Write(ctx, storage->DefaultWriteOptions(), batch->GetWriteBatch()); + if (!s.ok()) { + return {Status::NotOK, fmt::format("Failed to add alias metadata: {}", s.ToString())}; + } + + info->aliases.emplace_back(alias_name); + + return Status::OK(); + } + + Status DelAlias(engine::Context &ctx, std::string_view alias_name, const std::string &ns) { + auto cf = storage->GetCFHandle(ColumnFamilyID::Search); + auto batch = storage->GetWriteBatchBase(); + + auto no_txn_ctx = engine::Context::NoTransactionContext(storage); + + std::string retrieve_index; + SearchKey alias_key(ns, ""); + + auto s = storage->Get(no_txn_ctx, no_txn_ctx.DefaultMultiGetOptions(), cf, alias_key.ConstructAliasKey(alias_name), + &retrieve_index); + if (!s.ok() || retrieve_index == "") { + return {Status::NotOK, fmt::format("Alias does not exist")}; + } + LOG(INFO) << retrieve_index; + + s = batch->Delete(cf, alias_key.ConstructAliasKey(alias_name)); + if (!s.ok()) { + return {Status::NotOK, s.ToString()}; + } + + s = storage->Write(ctx, storage->DefaultWriteOptions(), batch->GetWriteBatch()); + if (!s.ok()) { + return {Status::NotOK, fmt::format("Failed to delete alias metadata: {}", s.ToString())}; + } + + auto iter = index_map.Find(retrieve_index, ns); + if (iter == index_map.end()) { + return {Status::NotOK, "index not found"}; + } + + auto info = iter->second.get(); + + auto it = std::find(info->aliases.begin(), info->aliases.end(), alias_name); + if (it == info->aliases.end()) { + return Status::NotOK; + } + info->aliases.erase(it); + + return Status::OK(); + } + + Status UpdateAlias(engine::Context &ctx, std::string_view alias_name, std::string_view index_name, + const std::string &ns) { + auto iter = index_map.Find(index_name, ns); + if (iter == index_map.end()) { + return {Status::NotOK, "index not found"}; + } + auto info = iter->second.get(); + auto it = std::find(info->aliases.begin(), info->aliases.end(), alias_name); + if (it != info->aliases.end()) { + return {Status::NotOK, fmt::format("Alias already in index.")}; + } + + auto cf = storage->GetCFHandle(ColumnFamilyID::Search); + auto batch = storage->GetWriteBatchBase(); + + SearchKey alias_key(ns, ""); + + auto no_txn_ctx = engine::Context::NoTransactionContext(storage); + + std::string retrieve_index; + auto s = storage->Get(no_txn_ctx, no_txn_ctx.DefaultMultiGetOptions(), cf, alias_key.ConstructAliasKey(alias_name), + &retrieve_index); + if (s.ok()) { + auto s = DelAlias(ctx, alias_name, ns); + if (!s.IsOK()) { + return Status::NotOK; + } + } + if (!s.ok()) { + return {Status::NotOK, fmt::format("Alias does not exist")}; + } + + s = batch->Put(cf, alias_key.ConstructAliasKey(alias_name), std::string(index_name)); + if (!s.ok()) { + return {Status::NotOK, s.ToString()}; + } + + s = storage->Write(ctx, storage->DefaultWriteOptions(), batch->GetWriteBatch()); + if (!s.ok()) { + return {Status::NotOK, fmt::format("Failed to update alias metadata: {}", s.ToString())}; + } + info->aliases.emplace_back(alias_name); + + return Status::OK(); + } }; } // namespace redis diff --git a/src/search/search_encoding.h b/src/search/search_encoding.h index 26b442ca32c..7a29a8b195e 100644 --- a/src/search/search_encoding.h +++ b/src/search/search_encoding.h @@ -216,6 +216,15 @@ struct SearchKey { return dst; } + std::string ConstructAliasKey(std::string_view alias_name) const { + std::string dst; + PutNamespace(&dst); + PutType(&dst, SearchSubkeyType::FIELD_ALIAS); + PutIndex(&dst); + PutSizedString(&dst, alias_name); + return dst; + } + std::string ConstructHnswLevelNodePrefix(uint16_t level) const { std::string dst; PutHnswLevelNodePrefix(&dst, level); diff --git a/tests/gocase/unit/search/search_test.go b/tests/gocase/unit/search/search_test.go index 0144599439f..74183d74b1e 100644 --- a/tests/gocase/unit/search/search_test.go +++ b/tests/gocase/unit/search/search_test.go @@ -156,6 +156,66 @@ func TestSearch(t *testing.T) { verify(t, res) }) + t.Run("FT.ALIASADD", func(t *testing.T) { + require.NoError(t, rdb.Do(ctx, "FT.ALIASADD", "alias1", "testidx1").Err()) + + // Add again + res := rdb.Do(ctx, "FT.ALIASADD", "alias1", "testidx1") + require.Error(t, res.Err()) + require.Contains(t, res.Err().Error(), "Alias already exists inside index") + + // Add to non-existent index + res = rdb.Do(ctx, "FT.ALIASADD", "alias1", "non_existent_index") + require.Error(t, res.Err()) + require.Contains(t, res.Err().Error(), "Target index not found") + }) + + t.Run("FT.ALIASDEL", func(t *testing.T) { + require.NoError(t, rdb.Do(ctx, "FT.ALIASDEL", "alias1").Err()) + + // Attempt to delete deleted alias again + res := rdb.Do(ctx, "FT.ALIASDEL", "alias1") + require.Error(t, res.Err()) + require.Contains(t, res.Err().Error(), "Alias does not exist") + + // Delete non-existent alias + res = rdb.Do(ctx, "FT.ALIASDEL", "alias_nonexistent") + require.Error(t, res.Err()) + require.Contains(t, res.Err().Error(), "Alias does not exist") + }) + + t.Run("FT.ALIASUPDATE", func(t *testing.T) { + require.NoError(t, rdb.Do(ctx, "FT.CREATE", "testidx3", "SCHEMA", "x", "NUMERIC").Err()) + + require.NoError(t, rdb.Do(ctx, "FT.ALIASADD", "alias4", "testidx1").Err()) + + // Update testidx2 to contain alias4 + require.NoError(t, rdb.Do(ctx, "FT.ALIASUPDATE", "alias4", "testidx3").Err()) + + // Check if updated properly + res := rdb.Do(ctx, "FT.ALIASADD", "alias4", "testidx1") + require.Error(t, res.Err()) + require.Contains(t, res.Err().Error(), "Alias already exists") + + res = rdb.Do(ctx, "FT.ALIASADD", "alias4", "testidx3") + require.Error(t, res.Err()) + require.Contains(t, res.Err().Error(), "Alias already exists inside index") + + // Updating non existent alias + res = rdb.Do(ctx, "FT.ALIASUPDATE", "alias_nonexistent", "testidx1") + require.Error(t, res.Err()) + require.Contains(t, res.Err().Error(), "Alias does not exist") + + // Check dropindex functionality + require.NoError(t, rdb.Do(ctx, "FT.ALIASADD", "alias_drop1", "testidx3").Err()) + + require.NoError(t, rdb.Do(ctx, "FT.DROPINDEX", "testidx3").Err()) + + res = rdb.Do(ctx, "FT.ALIASDEL", "alias_drop1") + require.Error(t, res.Err()) + require.Contains(t, res.Err().Error(), "Alias does not exist") + }) + t.Run("FT.DROPINDEX", func(t *testing.T) { require.NoError(t, rdb.Do(ctx, "FT.DROPINDEX", "testidx1").Err())