diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalMappingContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalMappingContext.java index ddc673720c..8802bf4ccd 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalMappingContext.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalMappingContext.java @@ -16,10 +16,12 @@ package org.springframework.data.relational.core.mapping; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.jspecify.annotations.Nullable; - import org.springframework.beans.BeansException; import org.springframework.context.ApplicationContext; import org.springframework.core.env.Environment; @@ -45,6 +47,8 @@ public class RelationalMappingContext extends AbstractMappingContext, RelationalPersistentProperty> { + private static final Logger logger = LoggerFactory.getLogger(RelationalMappingContext.class); + private final NamingStrategy namingStrategy; private final Map aggregatePathCache = new ConcurrentHashMap<>(); @@ -142,6 +146,9 @@ protected RelationalPersistentEntity createPersistentEntity(TypeInformati this.namingStrategy, this.sqlIdentifierExpressionEvaluator); entity.setForceQuote(isForceQuote()); + // Validate Set properties in @MappedCollection context + validateSetMappedCollectionProperties(entity); + return entity; } @@ -219,6 +226,78 @@ public AggregatePath getAggregatePath(RelationalPersistentEntity type) { return aggregatePath; } + /** + * Validates Set properties in nested @MappedCollection scenarios. + * + * @param entity the entity to validate + */ + private void validateSetMappedCollectionProperties(RelationalPersistentEntity entity) { + for (RelationalPersistentProperty property : entity) { + if (isSetMappedCollection(property)) { + validateSetMappedCollectionProperty(property); + } + } + } + + /** + * Checks if a property is a Set with @MappedCollection annotation. + */ + private boolean isSetMappedCollection(RelationalPersistentProperty property) { + return property.isCollectionLike() + && Set.class.isAssignableFrom(property.getType()) + && property.isAnnotationPresent(MappedCollection.class); + } + + /** + * Validates a Set property in @MappedCollection context. + * + * @param property the Set property to validate + */ + private void validateSetMappedCollectionProperty(RelationalPersistentProperty property) { + Class elementType = property.getComponentType(); + if (elementType == null) { + return; + } + + RelationalPersistentEntity elementEntity = getPersistentEntity(elementType); + if (elementEntity == null) { + return; + } + + boolean hasId = elementEntity.hasIdProperty(); + boolean hasEntityOrCollectionReferences = hasEntityOrCollectionReferences(elementEntity); + + if (!hasId && hasEntityOrCollectionReferences) { + String message = String.format( + "Invalid @MappedCollection usage: Set<%s> in %s.%s. " + + "Set elements without @Id must not contain entity or collection references. " + + "Consider using List instead or add @Id to %s.", + elementType.getSimpleName(), + property.getOwner().getType().getSimpleName(), + property.getName(), + elementType.getSimpleName() + ); + + logger.warn(message); + } + } + + /** + * Checks if an entity has any properties that are entities or collections. + */ + private boolean hasEntityOrCollectionReferences(RelationalPersistentEntity entity) { + for (RelationalPersistentProperty prop : entity) { + if (prop.isIdProperty() || prop.isVersionProperty()) { + continue; + } + + if (prop.isEntity() || prop.isCollectionLike()) { + return true; + } + } + return false; + } + private record AggregatePathCacheKey(RelationalPersistentEntity root, @Nullable PersistentPropertyPath path) { diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/RelationalMappingContextUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/RelationalMappingContextUnitTests.java index 231c819cc7..abc605c715 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/RelationalMappingContextUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/RelationalMappingContextUnitTests.java @@ -19,6 +19,7 @@ import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; @@ -152,4 +153,74 @@ static class Base { static class Inherit1 extends Base {} static class Inherit2 extends Base {} + + // GH-2061 - Tests for Set validation in @MappedCollection context + + @Test // GH-2061 + void doesNotThrowExceptionForInvalidSetUsage() { + context = new RelationalMappingContext(); + context.setSimpleTypeHolder(holder); + + // Should not throw exception, just log warning + assertThatCode(() -> context.getPersistentEntity(AggregateWithInvalidSet.class)) + .doesNotThrowAnyException(); + } + + @Test // GH-2061 + void doesNotThrowExceptionWhenSetElementHasId() { + context = new RelationalMappingContext(); + context.setSimpleTypeHolder(holder); + + assertThatCode(() -> context.getPersistentEntity(AggregateWithValidSetHavingId.class)) + .doesNotThrowAnyException(); + } + + @Test // GH-2061 + void doesNotThrowExceptionWhenSetElementWithoutIdHasNoReferences() { + context = new RelationalMappingContext(); + context.setSimpleTypeHolder(holder); + + assertThatCode(() -> context.getPersistentEntity(AggregateWithValidSetWithoutReferences.class)) + .doesNotThrowAnyException(); + } + + // Test entities for GH-2061 + static class AggregateWithInvalidSet { + @Id Long id; + @MappedCollection(idColumn = "aggregate_id", keyColumn = "idx") + Set elements; + } + + static class InvalidElement { + String name; + OtherEntity reference; + } + + static class OtherEntity { + @Id Long id; + String value; + } + + static class AggregateWithValidSetHavingId { + @Id Long id; + @MappedCollection(idColumn = "aggregate_id") + Set elements; + } + + static class ElementWithId { + @Id Long id; + String name; + OtherEntity reference; + } + + static class AggregateWithValidSetWithoutReferences { + @Id Long id; + @MappedCollection(idColumn = "aggregate_id", keyColumn = "idx") + Set elements; + } + + static class SimpleElement { + String name; + int value; + } }