diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/PersistentPropertyPathExtensionUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/PersistentPropertyPathExtensionUnitTests.java index 9751351a2..d343e4fc7 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/PersistentPropertyPathExtensionUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/PersistentPropertyPathExtensionUnitTests.java @@ -15,6 +15,7 @@ */ package org.springframework.data.jdbc.core; +import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.SoftAssertions.*; import static org.springframework.data.relational.core.sql.SqlIdentifier.*; @@ -22,6 +23,7 @@ import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.ReadOnlyProperty; import org.springframework.data.jdbc.core.mapping.JdbcMappingContext; import org.springframework.data.mapping.PersistentPropertyPath; import org.springframework.data.relational.core.mapping.Embedded; @@ -202,7 +204,7 @@ public class PersistentPropertyPathExtensionUnitTests { }); } - @Test // GH--1164 + @Test // GH-1164 void equalsWorks() { PersistentPropertyPathExtension root1 = extPath(entity); @@ -222,6 +224,17 @@ public class PersistentPropertyPathExtensionUnitTests { }); } + @Test // GH-1249 + void isWritable() { + + assertSoftly(softly -> { + softly.assertThat(PersistentPropertyPathExtension.isWritable(createSimplePath("withId"))).describedAs("simple path is writable").isTrue(); + softly.assertThat(PersistentPropertyPathExtension.isWritable(createSimplePath("secondList.third2"))).describedAs("long path is writable").isTrue(); + softly.assertThat(PersistentPropertyPathExtension.isWritable(createSimplePath("second"))).describedAs("simple read only path is not writable").isFalse(); + softly.assertThat(PersistentPropertyPathExtension.isWritable(createSimplePath("second.third"))).describedAs("long path containing read only element is not writable").isFalse(); + }); + } + private PersistentPropertyPathExtension extPath(RelationalPersistentEntity entity) { return new PersistentPropertyPathExtension(context, entity); } @@ -237,6 +250,7 @@ public class PersistentPropertyPathExtensionUnitTests { @SuppressWarnings("unused") static class DummyEntity { @Id Long entityId; + @ReadOnlyProperty Second second; @Embedded(onEmpty = OnEmpty.USE_NULL, prefix = "sec") Second second2; @Embedded(onEmpty = OnEmpty.USE_NULL) Second second3; diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalEntityDeleteWriter.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalEntityDeleteWriter.java index 1aa436374..8c7c728a7 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalEntityDeleteWriter.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalEntityDeleteWriter.java @@ -21,8 +21,10 @@ import java.util.List; import org.springframework.data.convert.EntityWriter; import org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.relational.core.mapping.PersistentPropertyPathExtension; import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; +import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -72,8 +74,10 @@ public class RelationalEntityDeleteWriter implements EntityWriter> deleteReferencedActions = new ArrayList<>(); - context.findPersistentPropertyPaths(entityType, PersistentProperty::isEntity) - .filter(p -> !p.getRequiredLeafProperty().isEmbedded()).forEach(p -> deleteReferencedActions.add(new DbAction.DeleteAll<>(p))); + context.findPersistentPropertyPaths(entityType, PersistentProperty::isEntity) // + .filter(p -> !p.getRequiredLeafProperty().isEmbedded() // + && PersistentPropertyPathExtension.isWritable(p)) // + .forEach(p -> deleteReferencedActions.add(new DbAction.DeleteAll<>(p))); Collections.reverse(deleteReferencedActions); @@ -114,8 +118,10 @@ public class RelationalEntityDeleteWriter implements EntityWriter> actions = new ArrayList<>(); - context.findPersistentPropertyPaths(aggregateChange.getEntityType(), PersistentProperty::isEntity) - .filter(p -> !p.getRequiredLeafProperty().isEmbedded()).forEach(p -> actions.add(new DbAction.Delete<>(id, p))); + context.findPersistentPropertyPaths(aggregateChange.getEntityType(), p -> p.isEntity()) // + .filter(p -> !p.getRequiredLeafProperty().isEmbedded() // + && PersistentPropertyPathExtension.isWritable(p)) // + .forEach(p -> actions.add(new DbAction.Delete<>(id, p))); Collections.reverse(actions); diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/WritingContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/WritingContext.java index a453d279f..6eb3d2951 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/WritingContext.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/WritingContext.java @@ -63,7 +63,7 @@ class WritingContext { this.aggregateChange = aggregateChange; this.rootIdValueSource = IdValueSource.forInstance(root, context.getRequiredPersistentEntity(aggregateChange.getEntityType())); - this.paths = context.findPersistentPropertyPaths(entityType, (p) -> p.isEntity() && !p.isEmbedded()); + this.paths = context.findPersistentPropertyPaths(entityType, (p) -> p.isEntity() && !p.isEmbedded() && p.isWritable()); } /** diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/PersistentPropertyPathExtension.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/PersistentPropertyPathExtension.java index 690128553..b60ceb202 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/PersistentPropertyPathExtension.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/PersistentPropertyPathExtension.java @@ -80,6 +80,11 @@ public class PersistentPropertyPathExtension { this.path = path; } + public static boolean isWritable(PersistentPropertyPath path) { + + return path.isEmpty() || (path.getRequiredLeafProperty().isWritable() && isWritable(path.getParentPath())); + } + /** * Returns {@literal true} exactly when the path is non empty and the leaf property an embedded one. * diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/RelationalEntityDeleteWriterUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/RelationalEntityDeleteWriterUnitTests.java index c7c2b0254..6dae85fa0 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/RelationalEntityDeleteWriterUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/RelationalEntityDeleteWriterUnitTests.java @@ -20,12 +20,14 @@ import lombok.Data; import java.util.ArrayList; import java.util.List; +import lombok.RequiredArgsConstructor; import org.assertj.core.api.Assertions; import org.assertj.core.groups.Tuple; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.ReadOnlyProperty; import org.springframework.data.relational.core.conversion.DbAction.AcquireLockAllRoot; import org.springframework.data.relational.core.conversion.DbAction.AcquireLockRoot; import org.springframework.data.relational.core.conversion.DbAction.Delete; @@ -108,6 +110,39 @@ public class RelationalEntityDeleteWriterUnitTests { .containsExactly(Tuple.tuple(DeleteAllRoot.class, SingleEntity.class, "")); } + @Test // GH-1249 + public void deleteDoesNotDeleteReadOnlyReferences() { + + WithReadOnlyReference entity = new WithReadOnlyReference(23L); + + MutableAggregateChange aggregateChange = MutableAggregateChange.forDelete(WithReadOnlyReference.class); + + converter.write(entity.id, aggregateChange); + + Assertions.assertThat(extractActions(aggregateChange)) + .extracting(DbAction::getClass, DbAction::getEntityType, DbActionTestSupport::extractPath) // + .containsExactly( // + Tuple.tuple(DeleteRoot.class, WithReadOnlyReference.class, "") // + ); + } + + @Test // GH-1249 + public void deleteAllDoesNotDeleteReadOnlyReferences() { + + WithReadOnlyReference entity = new WithReadOnlyReference(23L); + + MutableAggregateChange aggregateChange = MutableAggregateChange.forDelete(WithReadOnlyReference.class); + + converter.write(null, aggregateChange); + + Assertions.assertThat(extractActions(aggregateChange)) + .extracting(DbAction::getClass, DbAction::getEntityType, DbActionTestSupport::extractPath) // + .containsExactly( // + Tuple.tuple(DeleteAllRoot.class, WithReadOnlyReference.class, "") // + ); + } + + private List> extractActions(MutableAggregateChange aggregateChange) { List> actions = new ArrayList<>(); @@ -141,4 +176,11 @@ public class RelationalEntityDeleteWriterUnitTests { @Id final Long id; String name; } + + @RequiredArgsConstructor + private static class WithReadOnlyReference { + @Id final Long id; + @ReadOnlyProperty + OtherEntity other; + } } diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/RelationalEntityInsertWriterUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/RelationalEntityInsertWriterUnitTests.java index d0e483ec0..b3a7bed67 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/RelationalEntityInsertWriterUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/RelationalEntityInsertWriterUnitTests.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.ReadOnlyProperty; import org.springframework.data.relational.core.conversion.DbAction.InsertRoot; import org.springframework.data.relational.core.mapping.RelationalMappingContext; @@ -75,6 +76,7 @@ public class RelationalEntityInsertWriterUnitTests { } + private List> extractActions(MutableAggregateChange aggregateChange) { List> actions = new ArrayList<>(); diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/RelationalEntityWriterUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/RelationalEntityWriterUnitTests.java index 8405479aa..9870ed9fe 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/RelationalEntityWriterUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/RelationalEntityWriterUnitTests.java @@ -33,6 +33,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.ReadOnlyProperty; import org.springframework.data.mapping.PersistentPropertyPath; import org.springframework.data.mapping.PersistentPropertyPaths; import org.springframework.data.relational.core.conversion.DbAction.Delete; @@ -817,6 +818,46 @@ public class RelationalEntityWriterUnitTests { ); } + @Test // GH-1249 + public void readOnlyReferenceDoesNotCreateInsertsOnCreation() { + + WithReadOnlyReference entity = new WithReadOnlyReference(null); + entity.readOnly = new Element(SOME_ENTITY_ID); + + AggregateChangeWithRoot aggregateChange = MutableAggregateChange.forSave(entity); + + new RelationalEntityWriter(context).write(entity, aggregateChange); + + assertThat(extractActions(aggregateChange)) // + .extracting(DbAction::getClass, DbAction::getEntityType, DbActionTestSupport::extractPath, + DbActionTestSupport::actualEntityType, DbActionTestSupport::isWithDependsOn) // + .containsExactly( // + tuple(InsertRoot.class, WithReadOnlyReference.class, "", WithReadOnlyReference.class, false) // + // no insert for element + ); + + } + + @Test // GH-1249 + public void readOnlyReferenceDoesNotCreateDeletesOrInsertsDuringUpdate() { + + WithReadOnlyReference entity = new WithReadOnlyReference(SOME_ENTITY_ID); + entity.readOnly = new Element(SOME_ENTITY_ID); + + AggregateChangeWithRoot aggregateChange = MutableAggregateChange.forSave(entity); + + new RelationalEntityWriter(context).write(entity, aggregateChange); + + assertThat(extractActions(aggregateChange)) // + .extracting(DbAction::getClass, DbAction::getEntityType, DbActionTestSupport::extractPath, + DbActionTestSupport::actualEntityType, DbActionTestSupport::isWithDependsOn) // + .containsExactly( // + tuple(UpdateRoot.class, WithReadOnlyReference.class, "", WithReadOnlyReference.class, false) // + // no insert for element + ); + + } + private List> extractActions(MutableAggregateChange aggregateChange) { List> actions = new ArrayList<>(); @@ -1015,4 +1056,13 @@ public class RelationalEntityWriterUnitTests { // empty classes feel weird. String name; } + + @RequiredArgsConstructor + private static class WithReadOnlyReference { + + @Id final Long id; + @ReadOnlyProperty + Element readOnly; + } + } diff --git a/src/main/asciidoc/jdbc.adoc b/src/main/asciidoc/jdbc.adoc index a0d2cc37e..5ecad2f46 100644 --- a/src/main/asciidoc/jdbc.adoc +++ b/src/main/asciidoc/jdbc.adoc @@ -417,13 +417,24 @@ include::{spring-data-commons-docs}/is-new-state-detection.adoc[leveloffset=+2] Spring Data JDBC uses the ID to identify entities. The ID of an entity must be annotated with Spring Data's https://docs.spring.io/spring-data/commons/docs/current/api/org/springframework/data/annotation/Id.html[`@Id`] annotation. -When your data base has an auto-increment column for the ID column, the generated value gets set in the entity after inserting it into the database. +When your database has an auto-increment column for the ID column, the generated value gets set in the entity after inserting it into the database. One important constraint is that, after saving an entity, the entity must not be new any more. Note that whether an entity is new is part of the entity's state. With auto-increment columns, this happens automatically, because the ID gets set by Spring Data with the value from the ID column. If you are not using auto-increment columns, you can use a `BeforeConvert` listener, which sets the ID of the entity (covered later in this document). +[[jdbc.entity-persistence.read-only-properties]] +=== Read Only Properties + +Attributes annotated with `@ReadOnlyProperty` will not be written to the database by Spring Data JDBC, but they will be read when an entity gets loaded. + +Spring Data JDBC will not automatically reload an entity after writing it. +Therefore, you have to reload it explicitly if you want to see data that was generated in the database for such columns. + +If the annotated attribute is an entity or collection of entities, it is represented by one or more separate rows in separate tables. +Spring Data JDBC will not perform any insert, delete or update for these rows. + [[jdbc.entity-persistence.optimistic-locking]] === Optimistic Locking