diff --git a/pom.xml b/pom.xml index 29ed32f2a3..7d13ca9480 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-commons - 3.0.0-SNAPSHOT + 3.0.0-GH-2625-SNAPSHOT Spring Data Core Core Spring concepts underpinning every Spring Data module. diff --git a/src/main/asciidoc/object-mapping.adoc b/src/main/asciidoc/object-mapping.adoc index 6a99e0b005..e2582b38b3 100644 --- a/src/main/asciidoc/object-mapping.adoc +++ b/src/main/asciidoc/object-mapping.adoc @@ -20,7 +20,8 @@ The resolution algorithm works as follows: 1. If there is a single static factory method annotated with `@PersistenceCreator` then it is used. 2. If there is a single constructor, it is used. 3. If there are multiple constructors and exactly one is annotated with `@PersistenceCreator`, it is used. -4. If there's a no-argument constructor, it is used. +4. If the type is a Java `Record` the canonical constructor is used. +5. If there's a no-argument constructor, it is used. Other constructors will be ignored. The value resolution assumes constructor/factory method argument names to match the property names of the entity, i.e. the resolution will be performed as if the property was to be populated, including all customizations in mapping (different datastore column or field name etc.). @@ -295,9 +296,23 @@ Using the same field/column name for different values typically leads to corrupt Spring Data adapts specifics of Kotlin to allow object creation and mutation. +[[mapping.kotlin.creation]] === Kotlin object creation -Kotlin classes are supported to be instantiated , all classes are immutable by default and require explicit property declarations to define mutable properties. +Kotlin classes are supported to be instantiated, all classes are immutable by default and require explicit property declarations to define mutable properties. + +Spring Data automatically tries to detect a persistent entity's constructor to be used to materialize objects of that type. +The resolution algorithm works as follows: + +1. If there is a constructor that is annotated with `@PersistenceCreator`, it is used. +2. If the type is a <> the primary constructor is used. +3. If there is a single static factory method annotated with `@PersistenceCreator` then it is used. +4. If there is a single constructor, it is used. +5. If there are multiple constructors and exactly one is annotated with `@PersistenceCreator`, it is used. +6. If the type is a Java `Record` the canonical constructor is used. +7. If there's a no-argument constructor, it is used. +Other constructors will be ignored. + Consider the following `data` class `Person`: ==== diff --git a/src/main/java/org/springframework/data/mapping/model/PreferredConstructorDiscoverer.java b/src/main/java/org/springframework/data/mapping/model/PreferredConstructorDiscoverer.java index be04f5a516..1ed42f72e6 100644 --- a/src/main/java/org/springframework/data/mapping/model/PreferredConstructorDiscoverer.java +++ b/src/main/java/org/springframework/data/mapping/model/PreferredConstructorDiscoverer.java @@ -22,6 +22,7 @@ import java.lang.annotation.Annotation; import java.lang.reflect.Constructor; +import java.lang.reflect.RecordComponent; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -34,11 +35,11 @@ import org.springframework.data.mapping.PersistentEntity; import org.springframework.data.mapping.PersistentProperty; import org.springframework.data.mapping.PreferredConstructor; -import org.springframework.data.util.ClassTypeInformation; import org.springframework.data.util.KotlinReflectionUtils; import org.springframework.data.util.TypeInformation; import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; /** * Helper class to find a {@link PreferredConstructor}. @@ -49,7 +50,7 @@ * @author Mark Paluch * @author Xeno Amess */ -public interface PreferredConstructorDiscoverer> { +public interface PreferredConstructorDiscoverer { /** * Discovers the {@link PreferredConstructor} for the given type. @@ -124,12 +125,45 @@ > PreferredConstructor discover(TypeInf } } + if (rawOwningType.isRecord() && (candidates.size() > 1 || (noArg != null && !candidates.isEmpty()))) { + return RECORD.discover(type, entity); + } + if (noArg != null) { return buildPreferredConstructor(noArg, type, entity); } - return candidates.size() > 1 || candidates.isEmpty() ? null - : buildPreferredConstructor(candidates.iterator().next(), type, entity); + if (candidates.size() == 1) { + return buildPreferredConstructor(candidates.iterator().next(), type, entity); + } + + return null; + } + }, + + /** + * Discovers the canonical constructor for Java Record types. + * + * @since 3.0 + */ + RECORD { + + @Nullable + @Override + > PreferredConstructor discover(TypeInformation type, + @Nullable PersistentEntity entity) { + + Class rawOwningType = type.getType(); + + if (!rawOwningType.isRecord()) { + return null; + } + + Class[] paramTypes = Arrays.stream(rawOwningType.getRecordComponents()).map(RecordComponent::getType) + .toArray(Class[]::new); + Constructor canonicalConstructor = ClassUtils.getConstructorIfAvailable(rawOwningType, paramTypes); + + return canonicalConstructor != null ? buildPreferredConstructor(canonicalConstructor, type, entity) : null; } }, diff --git a/src/test/java/org/springframework/data/mapping/InstantiationAwarePersistentPropertyAccessorUnitTests.java b/src/test/java/org/springframework/data/mapping/InstantiationAwarePersistentPropertyAccessorUnitTests.java index d3def3c588..69620d9391 100644 --- a/src/test/java/org/springframework/data/mapping/InstantiationAwarePersistentPropertyAccessorUnitTests.java +++ b/src/test/java/org/springframework/data/mapping/InstantiationAwarePersistentPropertyAccessorUnitTests.java @@ -20,6 +20,7 @@ import lombok.Value; import org.junit.jupiter.api.Test; + import org.springframework.data.mapping.context.SampleMappingContext; import org.springframework.data.mapping.context.SamplePersistentProperty; import org.springframework.data.mapping.model.EntityInstantiators; @@ -44,8 +45,7 @@ void shouldCreateNewInstance() { var sample = new Sample("Dave", "Matthews", 42); PersistentPropertyAccessor wrapper = new InstantiationAwarePropertyAccessor<>(sample, - entity::getPropertyAccessor, - instantiators); + entity::getPropertyAccessor, instantiators); wrapper.setProperty(entity.getRequiredPersistentProperty("firstname"), "Oliver August"); @@ -71,10 +71,38 @@ void shouldSetMultipleProperties() { assertThat(wrapper.getBean()).isEqualTo(new Sample("Oliver August", "Heisenberg", 42)); } + @Test // GH-2625 + void shouldSetPropertyOfRecordUsingCanonicalConstructor() { + + var instantiators = new EntityInstantiators(); + var context = new SampleMappingContext(); + + PersistentEntity entity = context + .getRequiredPersistentEntity(WithSingleArgConstructor.class); + + var bean = new WithSingleArgConstructor(42L, "Dave"); + + PersistentPropertyAccessor wrapper = new InstantiationAwarePropertyAccessor<>(bean, + entity::getPropertyAccessor, instantiators); + + wrapper.setProperty(entity.getRequiredPersistentProperty("name"), "Oliver August"); + wrapper.setProperty(entity.getRequiredPersistentProperty("id"), 41L); + + assertThat(wrapper.getBean()).isEqualTo(new WithSingleArgConstructor(41L, "Oliver August")); + } + @Value static class Sample { String firstname, lastname; int age; } + + public record WithSingleArgConstructor(Long id, String name) { + + public WithSingleArgConstructor(String name) { + this(null, name); + } + } + } diff --git a/src/test/java/org/springframework/data/mapping/PreferredConstructorDiscovererUnitTests.java b/src/test/java/org/springframework/data/mapping/PreferredConstructorDiscovererUnitTests.java index 7443da754b..d5f4b8bfa7 100755 --- a/src/test/java/org/springframework/data/mapping/PreferredConstructorDiscovererUnitTests.java +++ b/src/test/java/org/springframework/data/mapping/PreferredConstructorDiscovererUnitTests.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.annotation.PersistenceConstructor; +import org.springframework.data.annotation.PersistenceCreator; import org.springframework.data.mapping.PreferredConstructorDiscovererUnitTests.Outer.Inner; import org.springframework.data.mapping.model.BasicPersistentEntity; import org.springframework.data.mapping.model.PreferredConstructorDiscoverer; @@ -149,6 +150,34 @@ void detectsMetaAnnotatedValueAnnotation() { }); } + @Test // GH-2332 + void detectsCanonicalRecordConstructor() { + + assertThat(PreferredConstructorDiscoverer.discover(RecordWithSingleArgConstructor.class)).satisfies(ctor -> { + + assertThat(ctor.getParameters()).hasSize(2); + assertThat(ctor.getParameters().get(0).getRawType()).isEqualTo(Long.class); + assertThat(ctor.getParameters().get(1).getRawType()).isEqualTo(String.class); + }); + + assertThat(PreferredConstructorDiscoverer.discover(RecordWithNoArgConstructor.class)).satisfies(ctor -> { + + assertThat(ctor.getParameters()).hasSize(2); + assertThat(ctor.getParameters().get(0).getRawType()).isEqualTo(Long.class); + assertThat(ctor.getParameters().get(1).getRawType()).isEqualTo(String.class); + }); + } + + @Test // GH-2332 + void detectsAnnotatedRecordConstructor() { + + assertThat(PreferredConstructorDiscoverer.discover(RecordWithPersistenceCreator.class)).satisfies(ctor -> { + + assertThat(ctor.getParameters()).hasSize(1); + assertThat(ctor.getParameters().get(0).getRawType()).isEqualTo(String.class); + }); + } + static class SyntheticConstructor { @PersistenceConstructor private SyntheticConstructor(String x) {} @@ -227,5 +256,28 @@ static class ClassWithMetaAnnotatedParameter { @Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) @Value("${hello-world}") - @interface MyValue {} + @interface MyValue { + } + + public record RecordWithSingleArgConstructor(Long id, String name) { + + public RecordWithSingleArgConstructor(String name) { + this(null, name); + } + } + + public record RecordWithNoArgConstructor(Long id, String name) { + + public RecordWithNoArgConstructor(String name) { + this(null, null); + } + } + + public record RecordWithPersistenceCreator(Long id, String name) { + + @PersistenceCreator + public RecordWithPersistenceCreator(String name) { + this(null, name); + } + } }