Skip to content

Commit f6140fa

Browse files
xinlian12Copilot
andcommitted
fix: CustomItemSerializer not applied correctly in queries and SqlParameter
Bug fixes: - Use SELECT COUNT(1) instead of SELECT VALUE COUNT(1) in aggregate serializer test (custom serializers can't deserialize scalar _value wrapper) - Preserve original Java type when cloning SqlParameter values (use getRawValue() instead of getValue(Object.class) which loses Instant→Long) - Handle scalar values in BasicCustomItemSerializer.serialize() via JsonNode conversion + primitive value key pattern - Skip SqlParameter clone/serialization when query has no parameters Design improvements: - Add canSerialize capability flag to CosmosItemSerializer using the same private field + setter pattern as setShouldWrapSerializationExceptions. SqlParameter.applySerializer() checks this flag instead of catching exceptions, so serializer bugs always propagate immediately. - CosmosItemSerializerNoExceptionWrapping sets canSerialize=false via bridge accessor (both Spark Scala and test Java versions) New test: - queryWithSqlParameterDateTimeAndCustomSerializer validates end-to-end scenario: custom serializer writes Instant as ISO-8601 string, SqlParameter applies same serializer so query filter matches stored value Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 8337dcd commit f6140fa

8 files changed

Lines changed: 163 additions & 31 deletions

File tree

sdk/cosmos/azure-cosmos-spark_3/src/main/scala/com/azure/cosmos/CosmosItemSerializerNoExceptionWrapping.scala

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,9 @@ private[cosmos] abstract class CosmosItemSerializerNoExceptionWrapping extends C
99
.CosmosItemSerializerHelper
1010
.getCosmosItemSerializerAccessor
1111
.setShouldWrapSerializationExceptions(this, false)
12+
13+
ImplementationBridgeHelpers
14+
.CosmosItemSerializerHelper
15+
.getCosmosItemSerializerAccessor
16+
.setCanSerialize(this, false)
1217
}

sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosItemSerializerNoExceptionWrapping.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,10 @@ public CosmosItemSerializerNoExceptionWrapping() {
1414
.CosmosItemSerializerHelper
1515
.getCosmosItemSerializerAccessor()
1616
.setShouldWrapSerializationExceptions(this, false);
17+
18+
ImplementationBridgeHelpers
19+
.CosmosItemSerializerHelper
20+
.getCosmosItemSerializerAccessor()
21+
.setCanSerialize(this, false);
1722
}
1823
}

sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosItemSerializerTest.java

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import com.fasterxml.jackson.core.JsonParser;
3636
import com.fasterxml.jackson.core.JsonProcessingException;
3737
import com.fasterxml.jackson.databind.DeserializationFeature;
38+
import com.fasterxml.jackson.databind.JsonNode;
3839
import com.fasterxml.jackson.databind.ObjectMapper;
3940
import com.fasterxml.jackson.databind.SerializationFeature;
4041
import com.fasterxml.jackson.databind.node.ObjectNode;
@@ -45,6 +46,7 @@
4546
import org.testng.annotations.Factory;
4647
import org.testng.annotations.Test;
4748

49+
import java.time.Instant;
4850
import java.util.ArrayList;
4951
import java.util.Arrays;
5052
import java.util.HashMap;
@@ -771,17 +773,21 @@ public void queryWithAggregateAndCustomSerializer() {
771773
CosmosQueryRequestOptions queryRequestOptions = new CosmosQueryRequestOptions()
772774
.setCustomItemSerializer(clientSerializer);
773775

774-
// SELECT VALUE COUNT(1) returns a scalar integer, so use Integer.class.
775-
List<Integer> results = container
776+
// SELECT COUNT(1) returns an object (e.g. {"$1": 3}) rather than a scalar.
777+
// Custom serializers work with Map<String, Object> which is object-level
778+
// deserialization - they cannot handle scalar VALUE queries like
779+
// SELECT VALUE COUNT(1) because the aggregate pipeline wraps the scalar
780+
// in a {"_value": N} Document that can't be deserialized as Integer.
781+
List<ObjectNode> results = container
776782
.queryItems(
777-
"SELECT VALUE COUNT(1) FROM c WHERE c.mypk = '" + pkValue + "'",
783+
"SELECT COUNT(1) FROM c WHERE c.mypk = '" + pkValue + "'",
778784
queryRequestOptions,
779-
Integer.class)
785+
ObjectNode.class)
780786
.stream().collect(Collectors.toList());
781787

782788
assertThat(results).isNotNull();
783789
assertThat(results).hasSize(1);
784-
assertThat(results.get(0)).isEqualTo(3);
790+
assertThat(results.get(0).get("$1").asInt()).isEqualTo(3);
785791
} finally {
786792
for (String id : createdIds) {
787793
try {
@@ -919,6 +925,62 @@ public void queryWithSqlParameterAndCustomSerializer() {
919925
}
920926
}
921927

928+
@Test(groups = { "fast", "emulator" }, timeOut = TIMEOUT * 1000000)
929+
public void queryWithSqlParameterDateTimeAndCustomSerializer() {
930+
CosmosItemSerializer clientSerializer = this.getClientBuilder().getCustomItemSerializer();
931+
if (clientSerializer == null || clientSerializer == CosmosItemSerializer.DEFAULT_SERIALIZER) {
932+
return;
933+
}
934+
935+
boolean isEnvelopeWrapper = clientSerializer instanceof EnvelopWrappingItemSerializer;
936+
if (isEnvelopeWrapper) {
937+
return;
938+
}
939+
940+
// This test validates that when a custom serializer changes how values are
941+
// stored (e.g. Instant as ISO-8601 string instead of a timestamp number),
942+
// SqlParameter correctly applies the same serializer so that query filters
943+
// match the stored representation.
944+
String id = UUID.randomUUID().toString();
945+
String pkValue = id;
946+
Instant createdAt = Instant.parse("2026-03-15T10:30:00Z");
947+
TestDocumentWithTimestamp doc = new TestDocumentWithTimestamp();
948+
doc.id = id;
949+
doc.mypk = pkValue;
950+
doc.createdAt = createdAt;
951+
doc.description = "test-datetime-serialization";
952+
953+
try {
954+
CosmosItemRequestOptions requestOptions = new CosmosItemRequestOptions()
955+
.setCustomItemSerializer(clientSerializer);
956+
container.createItem(doc, new PartitionKey(pkValue), requestOptions);
957+
958+
CosmosQueryRequestOptions queryRequestOptions = new CosmosQueryRequestOptions()
959+
.setCustomItemSerializer(clientSerializer);
960+
961+
// Query using SqlParameter with the same Instant value — the custom serializer
962+
// must serialize the parameter the same way as the document field.
963+
SqlQuerySpec querySpec = new SqlQuerySpec(
964+
"SELECT * FROM c WHERE c.createdAt = @createdAt AND c.id = @id",
965+
new SqlParameter("@createdAt", createdAt),
966+
new SqlParameter("@id", id));
967+
968+
List<TestDocumentWithTimestamp> results = container
969+
.queryItems(querySpec, queryRequestOptions, TestDocumentWithTimestamp.class)
970+
.stream().collect(Collectors.toList());
971+
972+
assertThat(results).isNotNull();
973+
assertThat(results).hasSize(1);
974+
assertThat(results.get(0).id).isEqualTo(id);
975+
assertThat(results.get(0).createdAt).isEqualTo(createdAt);
976+
assertThat(results.get(0).description).isEqualTo("test-datetime-serialization");
977+
} finally {
978+
try {
979+
container.deleteItem(id, new PartitionKey(pkValue), new CosmosItemRequestOptions());
980+
} catch (Exception ignored) { }
981+
}
982+
}
983+
922984
private <T> void runBatchAndChangeFeedTestCase(
923985
Function<String, T> docGenerator,
924986
CosmosItemSerializer requestLevelSerializer,
@@ -1149,6 +1211,13 @@ private static class TestDocumentWithNullableField {
11491211
public String nullableField;
11501212
}
11511213

1214+
private static class TestDocumentWithTimestamp {
1215+
public String id;
1216+
public String mypk;
1217+
public Instant createdAt;
1218+
public String description;
1219+
}
1220+
11521221
private static class TestDocumentWrappedInEnvelope {
11531222
public String id;
11541223

@@ -1269,7 +1338,22 @@ public <T> Map<String, Object> serialize(T item) {
12691338
if (item == null) {
12701339
return null;
12711340
}
1272-
return customMapper.convertValue(item, Map.class);
1341+
1342+
JsonNode jsonNode = customMapper.convertValue(item, JsonNode.class);
1343+
if (jsonNode == null) {
1344+
return null;
1345+
}
1346+
1347+
if (jsonNode.isObject()) {
1348+
return customMapper.convertValue(jsonNode, Map.class);
1349+
}
1350+
1351+
// For scalar values (e.g. Instant, String, numbers), return a single-entry
1352+
// map using the well-known primitive value key so the framework correctly
1353+
// sets it as a scalar in the JSON property bag.
1354+
Map<String, Object> primitiveMap = new HashMap<>();
1355+
primitiveMap.put("__primitive_json-node_value__", customMapper.convertValue(jsonNode, Object.class));
1356+
return primitiveMap;
12731357
}
12741358

12751359
@Override

sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/CosmosItemSerializer.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ public abstract class CosmosItemSerializer {
3838
public final static CosmosItemSerializer DEFAULT_SERIALIZER = DefaultCosmosItemSerializer.DEFAULT_SERIALIZER;
3939

4040
private boolean shouldWrapSerializationExceptions;
41+
private boolean canSerialize;
4142

4243
private ObjectMapper mapper = Utils.getSimpleObjectMapper();
4344

@@ -46,6 +47,7 @@ public abstract class CosmosItemSerializer {
4647
*/
4748
protected CosmosItemSerializer() {
4849
this.shouldWrapSerializationExceptions = true;
50+
this.canSerialize = true;
4951
}
5052

5153
/**
@@ -130,6 +132,14 @@ void setShouldWrapSerializationExceptions(boolean enabled) {
130132
this.shouldWrapSerializationExceptions = enabled;
131133
}
132134

135+
void setCanSerialize(boolean canSerialize) {
136+
this.canSerialize = canSerialize;
137+
}
138+
139+
boolean canSerialize() {
140+
return this.canSerialize;
141+
}
142+
133143

134144
///////////////////////////////////////////////////////////////////////////////////////////
135145
// the following helper/accessor only helps to access this class outside of this package.//
@@ -161,6 +171,16 @@ public void setItemObjectMapper(CosmosItemSerializer serializer, ObjectMapper ma
161171
public ObjectMapper getItemObjectMapper(CosmosItemSerializer serializer) {
162172
return serializer.getItemObjectMapper();
163173
}
174+
175+
@Override
176+
public boolean canSerialize(CosmosItemSerializer serializer) {
177+
return serializer.canSerialize();
178+
}
179+
180+
@Override
181+
public void setCanSerialize(CosmosItemSerializer serializer, boolean canSerialize) {
182+
serializer.setCanSerialize(canSerialize);
183+
}
164184
});
165185
}
166186

sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/ImplementationBridgeHelpers.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
import com.azure.cosmos.models.PartitionKeyDefinition;
7878
import com.azure.cosmos.models.PriorityLevel;
7979
import com.azure.cosmos.models.ShowQueryMode;
80+
import com.azure.cosmos.models.SqlParameter;
8081
import com.azure.cosmos.models.SqlQuerySpec;
8182
import com.azure.cosmos.util.CosmosPagedFlux;
8283
import com.azure.cosmos.util.UtilBridgeInternal;
@@ -1892,6 +1893,9 @@ void setShouldWrapSerializationExceptions(
18921893

18931894
void setItemObjectMapper(CosmosItemSerializer serializer, ObjectMapper mapper);
18941895
ObjectMapper getItemObjectMapper(CosmosItemSerializer serializer);
1896+
1897+
void setCanSerialize(CosmosItemSerializer serializer, boolean canSerialize);
1898+
boolean canSerialize(CosmosItemSerializer serializer);
18951899
}
18961900
}
18971901

@@ -1965,6 +1969,7 @@ public static SqlQuerySpecAccessor getSqlQuerySpecAccessor() {
19651969

19661970
public interface SqlQuerySpecAccessor {
19671971
void applySerializerToParameters(SqlQuerySpec sqlQuerySpec, CosmosItemSerializer serializer);
1972+
SqlParameter cloneSqlParameter(SqlParameter original);
19681973
}
19691974
}
19701975
}

sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/query/PipelinedQueryExecutionContextBase.java

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import com.azure.cosmos.implementation.DiagnosticsClientContext;
77
import com.azure.cosmos.implementation.DocumentCollection;
88
import com.azure.cosmos.implementation.ImplementationBridgeHelpers;
9+
import com.azure.cosmos.implementation.ImplementationBridgeHelpers.CosmosItemSerializerHelper.CosmosItemSerializerAccessor;
10+
import com.azure.cosmos.implementation.ImplementationBridgeHelpers.SqlQuerySpecHelper.SqlQuerySpecAccessor;
911
import com.azure.cosmos.implementation.Utils;
1012
import com.azure.cosmos.implementation.query.hybridsearch.HybridSearchQueryInfo;
1113
import com.azure.cosmos.models.CosmosQueryRequestOptions;
@@ -71,19 +73,16 @@ public static <T> Flux<PipelinedQueryExecutionContextBase<T>> createAsync(
7173
// which could race if the same instance is reused across concurrent queries.
7274
if (candidateSerializer != CosmosItemSerializer.DEFAULT_SERIALIZER) {
7375
SqlQuerySpec original = initParams.getQuery();
74-
List<SqlParameter> clonedParams = new ArrayList<>();
7576
List<SqlParameter> originalParams = original.getParameters();
76-
if (originalParams != null) {
77+
if (originalParams != null && !originalParams.isEmpty()) {
78+
List<SqlParameter> clonedParams = new ArrayList<>();
7779
for (SqlParameter p : originalParams) {
78-
clonedParams.add(new SqlParameter(p.getName(), p.getValue(Object.class)));
80+
clonedParams.add(sqlQuerySpecAccessor().cloneSqlParameter(p));
7981
}
82+
SqlQuerySpec clonedQuery = new SqlQuerySpec(original.getQueryText(), clonedParams);
83+
sqlQuerySpecAccessor().applySerializerToParameters(clonedQuery, candidateSerializer);
84+
initParams.setQuery(clonedQuery);
8085
}
81-
SqlQuerySpec clonedQuery = new SqlQuerySpec(original.getQueryText(), clonedParams);
82-
ImplementationBridgeHelpers
83-
.SqlQuerySpecHelper
84-
.getSqlQuerySpecAccessor()
85-
.applySerializerToParameters(clonedQuery, candidateSerializer);
86-
initParams.setQuery(clonedQuery);
8786
}
8887

8988
if (hybridSearchQueryInfo != null) {
@@ -220,4 +219,8 @@ public QueryInfo getQueryInfo() {
220219
public HybridSearchQueryInfo getHybridSearchQueryInfo() {
221220
return this.hybridSearchQueryInfo;
222221
}
222+
223+
private static SqlQuerySpecAccessor sqlQuerySpecAccessor() {
224+
return ImplementationBridgeHelpers.SqlQuerySpecHelper.getSqlQuerySpecAccessor();
225+
}
223226
}

sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/models/SqlParameter.java

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,17 @@
44
package com.azure.cosmos.models;
55

66
import com.azure.cosmos.CosmosItemSerializer;
7+
import com.azure.cosmos.implementation.ImplementationBridgeHelpers;
8+
import com.azure.cosmos.implementation.ImplementationBridgeHelpers.CosmosItemSerializerHelper.CosmosItemSerializerAccessor;
79
import com.azure.cosmos.implementation.JsonSerializable;
810
import com.fasterxml.jackson.databind.node.ObjectNode;
9-
import org.slf4j.Logger;
10-
import org.slf4j.LoggerFactory;
1111

1212
import java.util.Objects;
1313

1414
/**
1515
* Represents a SQL parameter in the SqlQuerySpec used for queries in the Azure Cosmos DB database service.
1616
*/
1717
public final class SqlParameter {
18-
private static final Logger LOGGER = LoggerFactory.getLogger(SqlParameter.class);
1918
private JsonSerializable jsonSerializable;
2019
private Object rawValue;
2120

@@ -98,6 +97,16 @@ public SqlParameter setValue(Object value) {
9897
return this;
9998
}
10099

100+
/**
101+
* Returns the original raw value before any serialization.
102+
* Package-private — used internally to clone parameters while preserving the
103+
* original Java type (e.g. Instant) that would otherwise be lost when reading
104+
* from the JSON property bag.
105+
*/
106+
Object getRawValue() {
107+
return this.rawValue;
108+
}
109+
101110
/**
102111
* Re-serializes the parameter value using the given custom item serializer.
103112
* This is called internally during query execution to ensure parameter values
@@ -106,19 +115,11 @@ public SqlParameter setValue(Object value) {
106115
* @param serializer the custom item serializer to apply.
107116
*/
108117
void applySerializer(CosmosItemSerializer serializer) {
109-
if (this.rawValue != null && serializer != null) {
110-
try {
111-
this.jsonSerializable.set("value", this.rawValue, serializer, true);
112-
} catch (Throwable t) {
113-
// Some serializer implementations (e.g. Spark connector) only implement
114-
// deserialize() and stub serialize() as unimplemented. Fall back to the
115-
// default serialization that was already applied via setValue().
116-
LOGGER.debug(
117-
"Custom serializer '{}' does not support serialize(); "
118-
+ "falling back to default serialization for SqlParameter value.",
119-
serializer.getClass().getSimpleName(),
120-
t);
121-
}
118+
if (this.rawValue != null
119+
&& serializer != null
120+
&& cosmosItemSerializerAccessor().canSerialize(serializer)) {
121+
122+
this.jsonSerializable.set("value", this.rawValue, serializer, true);
122123
}
123124
}
124125

@@ -128,6 +129,10 @@ void populatePropertyBag() {
128129

129130
JsonSerializable getJsonSerializable() { return this.jsonSerializable; }
130131

132+
private static CosmosItemSerializerAccessor cosmosItemSerializerAccessor() {
133+
return ImplementationBridgeHelpers.CosmosItemSerializerHelper.getCosmosItemSerializerAccessor();
134+
}
135+
131136
@Override
132137
public boolean equals(Object o) {
133138
if (this == o) {

sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/models/SqlQuerySpec.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,11 @@ public void applySerializerToParameters(SqlQuerySpec sqlQuerySpec, CosmosItemSer
163163
sqlQuerySpec.applySerializerToParameters(serializer);
164164
}
165165
}
166+
167+
@Override
168+
public SqlParameter cloneSqlParameter(SqlParameter original) {
169+
return new SqlParameter(original.getName(), original.getRawValue());
170+
}
166171
}
167172
);
168173
}

0 commit comments

Comments
 (0)