Skip to content

Commit facb13a

Browse files
committed
fix(serialization): use Gson JsonObject for projection field serialization (#676)
Replace manual StringBuilder JSON construction with Gson's JsonObject in parseDocumentResult() to properly handle string values with spaces and special characters preventing MalformedJsonException when projected TEXT fields contained spaces (e.g., "makeup spatula premium"). Changes: - Use JsonObject.addProperty() which handles quoting and escaping - Parse numeric values as Double/Long instead of raw strings - Properly handle already-quoted strings by removing outer quotes Closes #676
1 parent 291036a commit facb13a

File tree

4 files changed

+164
-38
lines changed

4 files changed

+164
-38
lines changed

redis-om-spring/src/main/java/com/redis/om/spring/repository/query/RediSearchQuery.java

Lines changed: 26 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import com.google.gson.Gson;
3737
import com.google.gson.GsonBuilder;
3838
import com.google.gson.JsonArray;
39+
import com.google.gson.JsonObject;
3940
import com.redis.om.spring.RedisOMProperties;
4041
import com.redis.om.spring.annotations.*;
4142
import com.redis.om.spring.indexing.RediSearchIndexer;
@@ -962,35 +963,19 @@ private Object parseDocumentResult(redis.clients.jedis.search.Document doc) {
962963
} else {
963964
// Projection case - individual fields returned from Redis search optimization
964965
// When projection optimization is enabled, Redis returns individual fields instead of full JSON
965-
Map<String, Object> fieldMap = new HashMap<>();
966+
// Use Gson's JsonObject to properly handle JSON serialization including escaping and quoting
967+
JsonObject jsonObject = new JsonObject();
968+
966969
for (Entry<String, Object> entry : doc.getProperties()) {
967970
String fieldName = entry.getKey();
968971
Object fieldValue = entry.getValue();
969972

973+
String valueStr;
970974
if (fieldValue instanceof byte[]) {
971-
// Convert byte array to string - this is the JSON representation from Redis
972-
String stringValue = SafeEncoder.encode((byte[]) fieldValue);
973-
fieldMap.put(fieldName, stringValue);
975+
valueStr = SafeEncoder.encode((byte[]) fieldValue);
974976
} else {
975-
fieldMap.put(fieldName, fieldValue);
976-
}
977-
}
978-
979-
// Build JSON manually to handle the different field formats from Redis search
980-
StringBuilder jsonBuilder = new StringBuilder();
981-
jsonBuilder.append("{");
982-
boolean first = true;
983-
for (Entry<String, Object> entry : fieldMap.entrySet()) {
984-
if (!first) {
985-
jsonBuilder.append(",");
977+
valueStr = String.valueOf(fieldValue);
986978
}
987-
first = false;
988-
989-
String fieldName = entry.getKey();
990-
Object fieldValue = entry.getValue();
991-
String valueStr = (String) fieldValue;
992-
993-
jsonBuilder.append("\"").append(fieldName).append("\":");
994979

995980
// Check if this field is a Point type in the domain class
996981
boolean isPointField = false;
@@ -1006,31 +991,34 @@ private Object parseDocumentResult(redis.clients.jedis.search.Document doc) {
1006991
// Handle different types based on the raw value from Redis
1007992
if (isPointField && valueStr.contains(",") && !valueStr.startsWith("\"")) {
1008993
// Point field - stored as "lon,lat" in Redis, needs to be quoted for PointTypeAdapter
1009-
jsonBuilder.append("\"").append(valueStr).append("\"");
1010-
} else if (fieldName.equals("name") || (valueStr.startsWith("\"") && valueStr.endsWith("\""))) {
1011-
// String field - quote if not already quoted
1012-
if (valueStr.startsWith("\"") && valueStr.endsWith("\"")) {
1013-
jsonBuilder.append(valueStr);
1014-
} else {
1015-
jsonBuilder.append("\"").append(valueStr).append("\"");
1016-
}
994+
jsonObject.addProperty(fieldName, valueStr);
995+
} else if (valueStr.startsWith("\"") && valueStr.endsWith("\"")) {
996+
// Already quoted string - remove quotes and add as property (Gson will re-quote)
997+
jsonObject.addProperty(fieldName, valueStr.substring(1, valueStr.length() - 1));
1017998
} else if (valueStr.equals("true") || valueStr.equals("false")) {
1018999
// Boolean
1019-
jsonBuilder.append(valueStr);
1000+
jsonObject.addProperty(fieldName, Boolean.parseBoolean(valueStr));
10201001
} else if (valueStr.equals("1") && fieldName.equals("active")) {
10211002
// Special case for boolean stored as 1/0
1022-
jsonBuilder.append("true");
1003+
jsonObject.addProperty(fieldName, true);
10231004
} else if (valueStr.equals("0") && fieldName.equals("active")) {
1024-
jsonBuilder.append("false");
1005+
jsonObject.addProperty(fieldName, false);
10251006
} else {
1026-
// Number or other type - keep as is
1027-
jsonBuilder.append(valueStr);
1007+
// Try to parse as number, otherwise treat as string
1008+
try {
1009+
if (valueStr.contains(".")) {
1010+
jsonObject.addProperty(fieldName, Double.parseDouble(valueStr));
1011+
} else {
1012+
jsonObject.addProperty(fieldName, Long.parseLong(valueStr));
1013+
}
1014+
} catch (NumberFormatException e) {
1015+
// Not a number - treat as string (Gson will handle proper quoting and escaping)
1016+
jsonObject.addProperty(fieldName, valueStr);
1017+
}
10281018
}
10291019
}
1030-
jsonBuilder.append("}");
10311020

1032-
String jsonFromFields = jsonBuilder.toString();
1033-
entity = gsonInstance.fromJson(jsonFromFields, domainType);
1021+
entity = gsonInstance.fromJson(jsonObject, domainType);
10341022
}
10351023

10361024
return ObjectUtils.populateRedisKey(entity, doc.getId());
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package com.redis.om.spring.annotations.document;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import java.util.List;
6+
7+
import org.junit.jupiter.api.BeforeEach;
8+
import org.junit.jupiter.api.Test;
9+
import org.springframework.beans.factory.annotation.Autowired;
10+
11+
import com.redis.om.spring.AbstractBaseDocumentTest;
12+
import com.redis.om.spring.fixtures.document.model.ProductDoc;
13+
import com.redis.om.spring.fixtures.document.repository.ProductDocRepository;
14+
15+
import redis.clients.jedis.search.SearchResult;
16+
17+
/**
18+
* Tests for Issue #676: Gson JSON serialization bug with spaces in projected fields.
19+
*
20+
* When using FT.SEARCH with field projections (returnFields), a JsonSyntaxException
21+
* occurs if projected TEXT fields contain values with spaces (e.g., "makeup spatula premium").
22+
*
23+
* The issue is in RediSearchQuery.parseDocumentResult() which manually constructs JSON
24+
* strings via StringBuilder without properly quoting string values.
25+
*
26+
* @see <a href="https://github.com/redis/redis-om-spring/issues/676">Issue #676</a>
27+
*/
28+
class QueryProjectionJsonSerializationTest extends AbstractBaseDocumentTest {
29+
30+
@Autowired
31+
ProductDocRepository productDocRepository;
32+
33+
@BeforeEach
34+
void setup() {
35+
productDocRepository.deleteAll();
36+
37+
// Create products with multi-word keywords (spaces in the value)
38+
productDocRepository.save(ProductDoc.of("makeup spatula premium", "beauty", 19.99));
39+
productDocRepository.save(ProductDoc.of("kitchen knife set", "kitchen", 49.99));
40+
productDocRepository.save(ProductDoc.of("wireless bluetooth headphones", "electronics", 89.99));
41+
productDocRepository.save(ProductDoc.of("organic green tea", "food", 12.99));
42+
productDocRepository.save(ProductDoc.of("simple", "beauty", 9.99)); // single word for comparison
43+
}
44+
45+
/**
46+
* Test that projected fields with spaces are correctly serialized.
47+
* This test would fail before the fix with a JsonSyntaxException because
48+
* the manual JSON construction didn't quote string values properly.
49+
*/
50+
@Test
51+
void testProjectedFieldsWithSpacesDoNotCauseJsonSyntaxException() {
52+
// First verify data was saved
53+
assertThat(productDocRepository.count()).isEqualTo(5);
54+
55+
// This query returns projected fields (keyword, category) as domain objects
56+
// The keyword field contains spaces which should be properly quoted in JSON
57+
List<ProductDoc> products = productDocRepository.findByCategoryWithProjection("beauty");
58+
59+
assertThat(products).hasSize(2);
60+
assertThat(products).extracting("keyword")
61+
.containsExactlyInAnyOrder("makeup spatula premium", "simple");
62+
assertThat(products).extracting("category")
63+
.containsOnly("beauty");
64+
}
65+
66+
/**
67+
* Test that the raw SearchResult query works (baseline).
68+
*/
69+
@Test
70+
void testSearchResultQueryWorks() {
71+
SearchResult result = productDocRepository.findByCategoryReturningSearchResult("beauty");
72+
assertThat(result.getTotalResults()).isEqualTo(2);
73+
}
74+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.redis.om.spring.fixtures.document.model;
2+
3+
import org.springframework.data.annotation.Id;
4+
5+
import com.redis.om.spring.annotations.Document;
6+
import com.redis.om.spring.annotations.Indexed;
7+
import com.redis.om.spring.annotations.Searchable;
8+
9+
import lombok.*;
10+
11+
/**
12+
* Model for testing Issue #676: Gson JSON serialization bug with spaces in projected fields.
13+
*/
14+
@Data
15+
@RequiredArgsConstructor(staticName = "of")
16+
@AllArgsConstructor(access = AccessLevel.PROTECTED)
17+
@NoArgsConstructor(force = true)
18+
@Document("productdoc")
19+
public class ProductDoc {
20+
@Id
21+
private String id;
22+
23+
@NonNull
24+
@Searchable
25+
private String keyword;
26+
27+
@NonNull
28+
@Indexed
29+
private String category;
30+
31+
@NonNull
32+
@Indexed
33+
private Double price;
34+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.redis.om.spring.fixtures.document.repository;
2+
3+
import java.util.List;
4+
5+
import org.springframework.data.repository.query.Param;
6+
7+
import com.redis.om.spring.annotations.Query;
8+
import com.redis.om.spring.fixtures.document.model.ProductDoc;
9+
import com.redis.om.spring.repository.RedisDocumentRepository;
10+
11+
import redis.clients.jedis.search.SearchResult;
12+
13+
/**
14+
* Repository for testing Issue #676: Gson JSON serialization bug with spaces in projected fields.
15+
*/
16+
public interface ProductDocRepository extends RedisDocumentRepository<ProductDoc, String> {
17+
18+
/**
19+
* Query that returns projected fields (keyword, category) as domain objects.
20+
* This triggers the parseDocumentResult projection logic path.
21+
*/
22+
@Query(value = "@category:{$category}", returnFields = {"keyword", "category"})
23+
List<ProductDoc> findByCategoryWithProjection(@Param("category") String category);
24+
25+
/**
26+
* Baseline query returning SearchResult to verify search works.
27+
*/
28+
@Query(value = "@category:{$category}", returnFields = {"keyword", "category"})
29+
SearchResult findByCategoryReturningSearchResult(@Param("category") String category);
30+
}

0 commit comments

Comments
 (0)