Repository: dragonflydb/dragonfly (2026-05-28T09:56:58Z)

Issue:
`FT.SEARCH` omits TAG fields with empty-string values from `RETURN`
When a JSON document has a `TAG` field whose stored value is an empty string (`""`), `FT.SEARCH ... RETURN ... <field>` does **not** include that field in the per-document reply array. Reference behavior is to return `<field>, ""` so the client can distinguish *missing* from *present-but-empty*.

This breaks client libraries that rely on field presence (e.g. `redisvl.Document` attribute access), even though the data is logically correct.

Reproduction:
```
redis-cli -p 6380 FT.CREATE probe ON JSON PREFIX 1 probe: \
    SCHEMA '$.parent' AS parent TAG
redis-cli -p 6380 JSON.SET probe:1 '$' '{"parent":""}'
redis-cli -p 6380 JSON.SET probe:2 '$' '{"parent":"a"}'
redis-cli -p 6380 FT.SEARCH probe '*' RETURN 1 parent SORTBY parent
```

Actual (Dragonfly):
```
1) (integer) 2
2) "probe:1"
3) (empty array)          <-- parent field omitted
4) "probe:2"
5) 1) "parent"
   2) "a"
```

Expected:
```
1) (integer) 2
2) "probe:1"
3) 1) "parent"
   2) ""                  <-- empty string returned
4) "probe:2"
5) 1) "parent"
   2) "a"
```

Impact:
Discovered while running the LangGraph (langgraph-checkpoint-redis 0.4.1) workload against Dragonfly. Every root checkpoint in a workflow has `parent_checkpoint_id=""`, so `RedisSaver.list()` raises `AttributeError: 'Document' object has no attribute 'parent_checkpoint_id'` on every call.

This is the same kind of contract violation that broader clients depend on (`Document.fieldname` accessors, generated typed wrappers, etc.). Any framework iterating over checkpoint history is affected.

Acceptance criteria:
- `FT.SEARCH ... RETURN N <field1> <field2> ...` includes every requested field in every per-document reply, with the stored value (including empty string) - not omitted when the value is `""`.
- Behavior is consistent across `TAG`, `TEXT`, and `NUMERIC` schema field types (the empty-string `NUMERIC` case is not meaningful, but `TAG` and `TEXT` must be).



Discussion:

Patch:
diff --git a/src/server/search/doc_accessors.cc b/src/server/search/doc_accessors.cc
index 32870caa5694..dcfbb75e70f3 100644
--- a/src/server/search/doc_accessors.cc
+++ b/src/server/search/doc_accessors.cc
@@ -45,18 +45,17 @@ string_view SdsToSafeSv(sds str) {
 using FieldValue = std::optional<search::SortableValue>;
 
 FieldValue ToSortableValue(search::SchemaField::FieldType type, string_view value) {
-  if (value.empty()) {
-    return std::nullopt;
-  }
-
   if (type == search::SchemaField::NUMERIC) {
     auto value_as_double = search::ParseNumericField(value);
-    if (!value_as_double) {  // temporary convert to double
+    if (!value_as_double) {
       return std::nullopt;
     }
     return value_as_double.value();
   }
   if (type == search::SchemaField::VECTOR) {
+    if (value.empty()) {
+      return std::nullopt;
+    }
     auto opt_vector = search::BytesToFtVectorSafe(value);
     if (!opt_vector) {
       return std::nullopt;
@@ -108,8 +107,11 @@ SearchDocData BaseAccessor::Serialize(const search::Schema& schema,
   SearchDocData out{};
   for (const auto& field : fields) {
     string_view fident = field.Identifier(schema, false);
-    auto field_value =
-        ExtractSortableValue(schema, fident, absl::StrJoin(GetStrings(fident).value(), ","));
+    auto strings = GetStrings(fident);
+    if (!strings || strings->empty()) {
+      continue;
+    }
+    auto field_value = ExtractSortableValue(schema, fident, absl::StrJoin(*strings, ","));
     if (field_value) {
       out[field.OutputName()] = std::move(field_value).value();
     }


Test patch:
diff --git a/src/server/search/search_family_test.cc b/src/server/search/search_family_test.cc
index 50b43a363f57..019b9d7a540f 100644
--- a/src/server/search/search_family_test.cc
+++ b/src/server/search/search_family_test.cc
@@ -782,6 +782,28 @@ TEST_F(SearchFamilyTest, TagNumbers) {
   EXPECT_THAT(Run({"ft.search", "i1", "@number:{1|hello|2}"}), AreDocIds("d:1", "d:2"));
 }
 
+TEST_F(SearchFamilyTest, ReturnEmptyFieldValue) {
+  EXPECT_EQ(Run({"ft.create", "probe", "ON", "JSON", "PREFIX", "1", "probe:", "SCHEMA", "$.parent",
+                 "AS", "parent", "TAG", "$.body", "AS", "body", "TEXT"}),
+            "OK");
+  EXPECT_EQ(Run({"json.set", "probe:1", "$", R"({"parent":"","body":""})"}), "OK");
+  EXPECT_EQ(Run({"json.set", "probe:2", "$", R"({"parent":"a","body":"hello"})"}), "OK");
+
+  auto resp = Run({"ft.search", "probe", "*", "RETURN", "2", "parent", "body", "SORTBY", "parent"});
+  EXPECT_THAT(resp, IsMapWithSize("probe:1", IsMap("parent", "", "body", ""), "probe:2",
+                                  IsMap("parent", "a", "body", "hello")));
+
+  EXPECT_EQ(Run({"ft.create", "hprobe", "ON", "HASH", "PREFIX", "1", "h:", "SCHEMA", "parent",
+                 "TAG", "body", "TEXT"}),
+            "OK");
+  Run({"hset", "h:1", "parent", "", "body", ""});
+  Run({"hset", "h:2", "parent", "a", "body", "hello"});
+
+  resp = Run({"ft.search", "hprobe", "*", "RETURN", "2", "parent", "body", "SORTBY", "parent"});
+  EXPECT_THAT(resp, IsMapWithSize("h:1", IsMap("parent", "", "body", ""), "h:2",
+                                  IsMap("parent", "a", "body", "hello")));
+}
+
 TEST_F(SearchFamilyTest, TagEscapeCharacters) {
   EXPECT_EQ(Run({"ft.create", "item_idx", "ON", "JSON", "PREFIX", "1", "p", "SCHEMA", "$.name",
                  "AS", "name", "TAG"}),