From 0d345f21b2038da6665b8bab640966b5a363f5c8 Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Sun, 12 Apr 2026 14:24:59 -0400 Subject: [PATCH] Fix GROUP BY aggregates IllegalArgumentException for non-entity return types (#43912) MappingCosmosConverter.read() crashed with 'Entity is null' when the return type was not a Cosmos-mapped entity (ObjectNode, Map, DTO, Object). The mapping context either returned null or threw when introspecting non-entity classes. Changes: - Guard mappingContext.getPersistentEntity() with try-catch in read() so non-entity types fall through with null entity - Replace Assert.notNull(entity) in readInternal() with graceful fallback to direct Jackson deserialization via objectMapper.treeToValue() - Add AggregateDto test domain class for non-entity deserialization - Add 4 unit tests: ObjectNode, Map, plain DTO, and Object return types - Update CHANGELOG.md with bug fix entry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure-spring-data-cosmos/CHANGELOG.md | 2 + .../core/convert/MappingCosmosConverter.java | 17 +++++- .../MappingCosmosConverterUnitTest.java | 54 +++++++++++++++++++ .../data/cosmos/domain/AggregateDto.java | 36 +++++++++++++ 4 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 sdk/spring/azure-spring-data-cosmos/src/test/java/com/azure/spring/data/cosmos/domain/AggregateDto.java diff --git a/sdk/spring/azure-spring-data-cosmos/CHANGELOG.md b/sdk/spring/azure-spring-data-cosmos/CHANGELOG.md index ed86107cd18e..072247ad0b52 100644 --- a/sdk/spring/azure-spring-data-cosmos/CHANGELOG.md +++ b/sdk/spring/azure-spring-data-cosmos/CHANGELOG.md @@ -8,6 +8,8 @@ #### Bugs Fixed +* Fixed `IllegalArgumentException: Entity is null` when using `@Query` with GROUP BY aggregates returning non-entity types (`ObjectNode`, `Map`, custom DTOs) - See [Bug #43912](https://github.com/Azure/azure-sdk-for-java/issues/43912). + #### Other Changes ### 7.1.0 (2026-03-11) diff --git a/sdk/spring/azure-spring-data-cosmos/src/main/java/com/azure/spring/data/cosmos/core/convert/MappingCosmosConverter.java b/sdk/spring/azure-spring-data-cosmos/src/main/java/com/azure/spring/data/cosmos/core/convert/MappingCosmosConverter.java index 3080b8b5c1f0..4df2d62c9ddf 100644 --- a/sdk/spring/azure-spring-data-cosmos/src/main/java/com/azure/spring/data/cosmos/core/convert/MappingCosmosConverter.java +++ b/sdk/spring/azure-spring-data-cosmos/src/main/java/com/azure/spring/data/cosmos/core/convert/MappingCosmosConverter.java @@ -75,7 +75,15 @@ public MappingCosmosConverter( @Override public R read(Class type, JsonNode jsonNode) { - final CosmosPersistentEntity entity = mappingContext.getPersistentEntity(type); + CosmosPersistentEntity entity = null; + try { + entity = mappingContext.getPersistentEntity(type); + } catch (Exception e) { + // Non-entity types (ObjectNode, Map, DTOs) cannot be introspected by the + // mapping context. Fall through with null entity to use direct Jackson + // deserialization in readInternal. (GitHub #43912) + LOGGER.debug("No persistent entity found for type {}, using direct deserialization", type.getName()); + } return readInternal(entity, type, jsonNode); } @@ -91,7 +99,12 @@ private R readInternal(final CosmosPersistentEntity entity, Class type return objectMapper.treeToValue(jsonNode, type); } - Assert.notNull(entity, "Entity is null."); + // When the return type is not a Cosmos-mapped entity (e.g., ObjectNode, Map, DTO), + // mappingContext.getPersistentEntity() returns null. Fall back to direct Jackson + // deserialization, bypassing entity-specific id/etag remapping. (GitHub #43912) + if (entity == null) { + return objectMapper.treeToValue(jsonNode, type); + } final ObjectNode objectNode = jsonNode.deepCopy(); final CosmosPersistentProperty idProperty = entity.getIdProperty(); final JsonNode idValue = jsonNode.get("id"); diff --git a/sdk/spring/azure-spring-data-cosmos/src/test/java/com/azure/spring/data/cosmos/core/converter/MappingCosmosConverterUnitTest.java b/sdk/spring/azure-spring-data-cosmos/src/test/java/com/azure/spring/data/cosmos/core/converter/MappingCosmosConverterUnitTest.java index f86817f8f79a..c983ac5b2335 100644 --- a/sdk/spring/azure-spring-data-cosmos/src/test/java/com/azure/spring/data/cosmos/core/converter/MappingCosmosConverterUnitTest.java +++ b/sdk/spring/azure-spring-data-cosmos/src/test/java/com/azure/spring/data/cosmos/core/converter/MappingCosmosConverterUnitTest.java @@ -7,6 +7,7 @@ import com.azure.spring.data.cosmos.core.convert.ObjectMapperFactory; import com.azure.spring.data.cosmos.core.mapping.CosmosMappingContext; import com.azure.spring.data.cosmos.domain.Address; +import com.azure.spring.data.cosmos.domain.AggregateDto; import com.azure.spring.data.cosmos.domain.BigJavaMathTypes; import com.azure.spring.data.cosmos.domain.Importance; import com.azure.spring.data.cosmos.domain.Memo; @@ -25,6 +26,7 @@ import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; +import java.util.Map; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; @@ -177,5 +179,57 @@ public void mapsDefaultEtag() { assertThat(jsonNode.get(TestConstants.PROPERTY_ETAG_DEFAULT).asText()).isEqualTo(etagValue); } + + // Tests for GitHub #43912 — GROUP BY aggregates with non-entity return types + + @Test + public void readObjectNodeFromJsonNodeSucceeds() { + final ObjectNode objectNode = ObjectMapperFactory.getObjectMapper().createObjectNode(); + objectNode.put("id_count", 5); + objectNode.put("intValue", 42); + + final ObjectNode result = mappingCosmosConverter.read(ObjectNode.class, objectNode); + + assertThat(result).isNotNull(); + assertThat(result.get("id_count").asInt()).isEqualTo(5); + assertThat(result.get("intValue").asInt()).isEqualTo(42); + } + + @Test + @SuppressWarnings("unchecked") + public void readMapFromJsonNodeSucceeds() { + final ObjectNode objectNode = ObjectMapperFactory.getObjectMapper().createObjectNode(); + objectNode.put("num_ids", 3); + objectNode.put("department", "engineering"); + + final Map result = mappingCosmosConverter.read(Map.class, objectNode); + + assertThat(result).isNotNull(); + assertThat(result.get("num_ids")).isEqualTo(3); + assertThat(result.get("department")).isEqualTo("engineering"); + } + + @Test + public void readPlainDtoFromJsonNodeSucceeds() { + final ObjectNode objectNode = ObjectMapperFactory.getObjectMapper().createObjectNode(); + objectNode.put("count", 7); + objectNode.put("label", "group-a"); + + final AggregateDto result = mappingCosmosConverter.read(AggregateDto.class, objectNode); + + assertThat(result).isNotNull(); + assertThat(result.getCount()).isEqualTo(7); + assertThat(result.getLabel()).isEqualTo("group-a"); + } + + @Test + public void readObjectFromJsonNodeSucceeds() { + final ObjectNode objectNode = ObjectMapperFactory.getObjectMapper().createObjectNode(); + objectNode.put("key", "value"); + + final Object result = mappingCosmosConverter.read(Object.class, objectNode); + + assertThat(result).isNotNull(); + } } diff --git a/sdk/spring/azure-spring-data-cosmos/src/test/java/com/azure/spring/data/cosmos/domain/AggregateDto.java b/sdk/spring/azure-spring-data-cosmos/src/test/java/com/azure/spring/data/cosmos/domain/AggregateDto.java new file mode 100644 index 000000000000..d4d5daf56670 --- /dev/null +++ b/sdk/spring/azure-spring-data-cosmos/src/test/java/com/azure/spring/data/cosmos/domain/AggregateDto.java @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.data.cosmos.domain; + +/** + * Simple DTO for testing non-entity deserialization of GROUP BY aggregate query results. + * Not annotated with @Container — intentionally a plain POJO (GitHub #43912). + */ +public class AggregateDto { + private int count; + private String label; + + public AggregateDto() { + } + + public AggregateDto(int count, String label) { + this.count = count; + this.label = label; + } + + public int getCount() { + return count; + } + + public void setCount(int count) { + this.count = count; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } +}