diff --git a/build.gradle b/build.gradle index 18a018c87a..32522aef4c 100644 --- a/build.gradle +++ b/build.gradle @@ -63,7 +63,7 @@ subprojects { Project subproject -> if (JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_15)) { jvmArgs "--enable-preview" } - timeout = Duration.ofMinutes(20) + timeout = Duration.ofHours(1) } if (subproject.name != 'data-bom') { @@ -80,7 +80,7 @@ subprojects { Project subproject -> if (JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_15)) { jvmArgs "--enable-preview" } - timeout = Duration.ofMinutes(20) + timeout = Duration.ofHours(1) } } diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/h2/one2many/OneToManyChildrenSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/h2/one2many/OneToManyChildrenSpec.groovy new file mode 100644 index 0000000000..94b0d501c2 --- /dev/null +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/h2/one2many/OneToManyChildrenSpec.groovy @@ -0,0 +1,89 @@ +package io.micronaut.data.jdbc.h2.one2many + +import io.micronaut.context.ApplicationContext +import io.micronaut.data.annotation.* +import io.micronaut.data.jdbc.annotation.JdbcRepository +import io.micronaut.data.jdbc.h2.H2DBProperties +import io.micronaut.data.jdbc.h2.H2TestPropertyProvider +import io.micronaut.data.model.query.builder.sql.Dialect +import io.micronaut.data.repository.CrudRepository +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import spock.lang.AutoCleanup +import spock.lang.Shared +import spock.lang.Specification + +import javax.inject.Inject + +@MicronautTest +@H2DBProperties +class OneToManyChildrenSpec extends Specification implements H2TestPropertyProvider { + @AutoCleanup + @Shared + ApplicationContext applicationContext = ApplicationContext.run(getProperties()) + + @Shared + @Inject + ParentRepository parentRepository = applicationContext.getBean(ParentRepository) + + void 'test one-to-many hierarchy'() { + given: + def children = [] + Parent parent = new Parent(name: "parent", children: children) + children.add new Child(name: "A", parent: parent) + children.add new Child(name: "B", parent: parent) + children.add new Child(name: "C", parent: parent) + when: + parentRepository.save(parent) + then: + parent.id + parent.children.size() == 3 + parent.children.forEach { + verifyAll(it) { + it.id + it.parent + it.name + } + } + when: + parent = parentRepository.findById(parent.id).get() + then: + parent.id + parent.children.size() == 3 + parent.children.forEach { + verifyAll(it) { + it.id + it.parent + it.name + } + } + } + +} + +@JdbcRepository(dialect = Dialect.H2) +interface ParentRepository extends CrudRepository { + + @Join(value = "children", type = Join.Type.FETCH) + @Override + Optional findById(Long id); +} + +@MappedEntity("x_product") +class Parent { + String name + @Relation(value = Relation.Kind.ONE_TO_MANY, mappedBy = "parent", cascade = Relation.Cascade.ALL) + List children + @Id + @GeneratedValue + Long id +} + +@MappedEntity("x_child") +class Child { + String name + @Relation(value = Relation.Kind.MANY_TO_ONE) + Parent parent + @Id + @GeneratedValue + Long id +} \ No newline at end of file diff --git a/data-runtime/src/main/java/io/micronaut/data/runtime/mapper/sql/SqlResultEntityTypeMapper.java b/data-runtime/src/main/java/io/micronaut/data/runtime/mapper/sql/SqlResultEntityTypeMapper.java index f6fd04b3ed..0f17870624 100644 --- a/data-runtime/src/main/java/io/micronaut/data/runtime/mapper/sql/SqlResultEntityTypeMapper.java +++ b/data-runtime/src/main/java/io/micronaut/data/runtime/mapper/sql/SqlResultEntityTypeMapper.java @@ -66,7 +66,7 @@ * using column naming conventions mapped by the entity. * * @param The result set type - * @param The result type + * @param The result type */ @Internal public final class SqlResultEntityTypeMapper implements SqlTypeMapper { @@ -81,10 +81,11 @@ public final class SqlResultEntityTypeMapper implements SqlTypeMapper entity, @@ -111,10 +113,11 @@ public SqlResultEntityTypeMapper( /** * Constructor used to customize the join paths. - * @param entity The entity + * + * @param entity The entity * @param resultReader The result reader - * @param joinPaths The join paths - * @param jsonCodec The JSON codec + * @param joinPaths The join paths + * @param jsonCodec The JSON codec * @param loadListener The event listener */ public SqlResultEntityTypeMapper( @@ -128,9 +131,10 @@ public SqlResultEntityTypeMapper( /** * Constructor used to customize the join paths. - * @param entity The entity + * + * @param entity The entity * @param resultReader The result reader - * @param joinPaths The join paths + * @param joinPaths The join paths */ private SqlResultEntityTypeMapper( @NonNull RuntimePersistentEntity entity, @@ -159,14 +163,16 @@ private SqlResultEntityTypeMapper( /** * @return The entity to be materialized */ - public @NonNull RuntimePersistentEntity getEntity() { + public @NonNull + RuntimePersistentEntity getEntity() { return entity; } /** * @return The result reader instance. */ - public @NonNull ResultReader getResultReader() { + public @NonNull + ResultReader getResultReader() { return resultReader; } @@ -250,7 +256,7 @@ public R getResult() { return null; } if (!joinPaths.isEmpty()) { - entityInstance = (R) setChildrenAndTriggerPostLoad(entityInstance, ctx); + entityInstance = (R) setChildrenAndTriggerPostLoad(entityInstance, ctx, null); } else { return triggerPostLoad(entity, entityInstance); } @@ -290,7 +296,7 @@ public List getResult() { List values = new ArrayList<>(processed.size()); for (Map.Entry> e : processed.entrySet()) { MappingContext ctx = e.getValue(); - R entityInstance = (R) setChildrenAndTriggerPostLoad(ctx.entity, ctx); + R entityInstance = (R) setChildrenAndTriggerPostLoad(ctx.entity, ctx, null); values.add(entityInstance); } return values; @@ -321,11 +327,11 @@ private void readChildren(RS rs, Object instance, Object parent, MappingContext< } } - private Object setChildrenAndTriggerPostLoad(Object instance, MappingContext ctx) { + private Object setChildrenAndTriggerPostLoad(Object instance, MappingContext ctx, Object parent) { if (ctx.manyAssociations != null) { List values = new ArrayList<>(ctx.manyAssociations.size()); for (MappingContext associationCtx : ctx.manyAssociations.values()) { - values.add(setChildrenAndTriggerPostLoad(associationCtx.entity, associationCtx)); + values.add(setChildrenAndTriggerPostLoad(associationCtx.entity, associationCtx, parent)); } return values; } else if (ctx.associations != null) { @@ -335,18 +341,28 @@ private Object setChildrenAndTriggerPostLoad(Object instance, MappingContext BeanProperty beanProperty = runtimeAssociation.getProperty(); if (runtimeAssociation.getKind().isSingleEnded() && (associationCtx.manyAssociations == null || associationCtx.manyAssociations.isEmpty())) { Object value = beanProperty.get(instance); - Object newValue = setChildrenAndTriggerPostLoad(value, associationCtx); + Object newValue = setChildrenAndTriggerPostLoad(value, associationCtx, instance); if (newValue != value) { instance = setProperty(beanProperty, instance, newValue); } } else { - Object newValue = setChildrenAndTriggerPostLoad(null, associationCtx); + Object newValue = setChildrenAndTriggerPostLoad(null, associationCtx, instance); newValue = resultReader.convertRequired(newValue == null ? new ArrayList<>() : newValue, beanProperty.getType()); instance = setProperty(beanProperty, instance, newValue); } } } if (instance != null && (ctx.association == null || ctx.jp != null)) { + if (parent != null && ctx.association != null && ctx.association.isBidirectional()) { + RuntimeAssociation inverseAssociation = (RuntimeAssociation) ctx.association.getInverseSide().get(); + if (inverseAssociation.getKind().isSingleEnded()) { + BeanProperty inverseProperty = inverseAssociation.getProperty(); + Object inverseInstance = inverseProperty.get(instance); + if (inverseInstance != parent) { + instance = setProperty(inverseProperty, instance, parent); + } + } + } triggerPostLoad(ctx.persistentEntity, instance); } return instance; @@ -565,7 +581,8 @@ private K triggerPostLoad(RuntimePersistentEntity persistentEntity, K ent return finalEntity; } - private @Nullable Object readEntityId(RS rs, MappingContext ctx) { + private @Nullable + Object readEntityId(RS rs, MappingContext ctx) { RuntimePersistentProperty identity = ctx.persistentEntity.getIdentity(); if (identity == null) { return null; @@ -833,7 +850,7 @@ private static List associated(List associations, Asso * The pushing mapper helper interface. * * @param The row type - * @param The result type + * @param The result type */ public interface PushingMapper { diff --git a/doc-examples/jdbc-example-kotlin/src/main/kotlin/example/Child.kt b/doc-examples/jdbc-example-kotlin/src/main/kotlin/example/Child.kt new file mode 100644 index 0000000000..d86f21df6e --- /dev/null +++ b/doc-examples/jdbc-example-kotlin/src/main/kotlin/example/Child.kt @@ -0,0 +1,46 @@ +package example + +import io.micronaut.data.annotation.GeneratedValue +import io.micronaut.data.annotation.Id +import io.micronaut.data.annotation.MappedEntity +import io.micronaut.data.annotation.Relation + +@MappedEntity +data class Child( + + val name: String, + + @Relation(Relation.Kind.MANY_TO_ONE) + val parent: Parent? = null, + + @field:Id @GeneratedValue val id: Int? = null + + + +) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Child) return false + + if (name != other.name) return false + if (parent?.id != other.parent?.id) return false + if (id != other.id) return false + + return true + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + parent?.id.hashCode() + result = 31 * result + (id ?: 0) + return result + } + + override fun toString(): String { + return "Child(name='$name', parent=${parent?.id}, id=$id)" + } + + +} + diff --git a/doc-examples/jdbc-example-kotlin/src/main/kotlin/example/Parent.kt b/doc-examples/jdbc-example-kotlin/src/main/kotlin/example/Parent.kt new file mode 100644 index 0000000000..d69373774e --- /dev/null +++ b/doc-examples/jdbc-example-kotlin/src/main/kotlin/example/Parent.kt @@ -0,0 +1,19 @@ +package example + +import io.micronaut.data.annotation.GeneratedValue +import io.micronaut.data.annotation.Id +import io.micronaut.data.annotation.MappedEntity +import io.micronaut.data.annotation.Relation + +@MappedEntity +data class Parent( + + val name: String, + + @Relation(value = Relation.Kind.ONE_TO_MANY, mappedBy = "parent", cascade = [Relation.Cascade.ALL]) + val children: List, + + @field:Id @GeneratedValue + val id: Int? = null + +) diff --git a/doc-examples/jdbc-example-kotlin/src/main/kotlin/example/ParentRepository.kt b/doc-examples/jdbc-example-kotlin/src/main/kotlin/example/ParentRepository.kt new file mode 100644 index 0000000000..513fcb62c0 --- /dev/null +++ b/doc-examples/jdbc-example-kotlin/src/main/kotlin/example/ParentRepository.kt @@ -0,0 +1,16 @@ +package example + +import example.Parent +import io.micronaut.data.annotation.Join +import io.micronaut.data.jdbc.annotation.JdbcRepository +import io.micronaut.data.model.query.builder.sql.Dialect +import io.micronaut.data.repository.CrudRepository +import java.util.Optional + +@JdbcRepository(dialect = Dialect.H2) +abstract class ParentRepository : CrudRepository { + + @Join(value = "children", type = Join.Type.FETCH) + abstract override fun findById(id: Int): Optional + +} diff --git a/doc-examples/jdbc-example-kotlin/src/test/kotlin/example/ParentRepositoryTest.kt b/doc-examples/jdbc-example-kotlin/src/test/kotlin/example/ParentRepositoryTest.kt new file mode 100644 index 0000000000..416458ebc7 --- /dev/null +++ b/doc-examples/jdbc-example-kotlin/src/test/kotlin/example/ParentRepositoryTest.kt @@ -0,0 +1,41 @@ +package example + +import io.micronaut.test.extensions.junit5.annotation.MicronautTest +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import javax.inject.Inject + +@MicronautTest +class ParentRepositoryTest { + + @Inject + private lateinit var repository: ParentRepository + + @Test + internal fun `save parent with children`() { + val children = mutableListOf() + val parent = Parent("parent", children) + children.addAll( + arrayOf( + Child("A", parent), + Child("B", parent), + Child("C", parent) + ) + ) + val saved = repository.save(parent) + println(saved) + assertNotNull(saved.id) + saved.children.forEach { assertNotNull(it.id) } + + val found = repository.findById(saved.id!!).get() + println(found) + found.children.forEach { assertNotNull(it.parent) } + + val modifiedParent = found.copy(name = found.name + " mod!") + repository.update(modifiedParent) + val found2 = repository.findById(saved.id!!).get() + assertTrue(found2.name.endsWith(" mod!")) + assertTrue(found2.children.size == 3) + } +}