Skip to content

Commit b113692

Browse files
committed
Support readonly properties for entities.
The `@ReadOnlyProperty` annotation is now honoured for references to entities or collections of entities. For tables mapped to such annotated references, no insert, delete or update statements will be created. The user has to maintain that data through some other means. These could be triggers or external process or `ON DELETE CASCADE` configuration in the database schema. See #1249
1 parent 8f366b5 commit b113692

File tree

8 files changed

+137
-7
lines changed

8 files changed

+137
-7
lines changed

spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/PersistentPropertyPathExtensionUnitTests.java

+15-1
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@
1515
*/
1616
package org.springframework.data.jdbc.core;
1717

18+
import static org.assertj.core.api.Assertions.*;
1819
import static org.assertj.core.api.SoftAssertions.*;
1920
import static org.springframework.data.relational.core.sql.SqlIdentifier.*;
2021

2122
import java.util.List;
2223

2324
import org.junit.jupiter.api.Test;
2425
import org.springframework.data.annotation.Id;
26+
import org.springframework.data.annotation.ReadOnlyProperty;
2527
import org.springframework.data.jdbc.core.mapping.JdbcMappingContext;
2628
import org.springframework.data.mapping.PersistentPropertyPath;
2729
import org.springframework.data.relational.core.mapping.Embedded;
@@ -202,7 +204,7 @@ void extendBy() {
202204
});
203205
}
204206

205-
@Test // GH--1164
207+
@Test // GH-1164
206208
void equalsWorks() {
207209

208210
PersistentPropertyPathExtension root1 = extPath(entity);
@@ -222,6 +224,17 @@ void equalsWorks() {
222224
});
223225
}
224226

227+
@Test // GH-1249
228+
void isWritable() {
229+
230+
assertSoftly(softly -> {
231+
softly.assertThat(PersistentPropertyPathExtension.isWritable(createSimplePath("withId"))).describedAs("simple path is writable").isTrue();
232+
softly.assertThat(PersistentPropertyPathExtension.isWritable(createSimplePath("secondList.third2"))).describedAs("long path is writable").isTrue();
233+
softly.assertThat(PersistentPropertyPathExtension.isWritable(createSimplePath("second"))).describedAs("simple read only path is not writable").isFalse();
234+
softly.assertThat(PersistentPropertyPathExtension.isWritable(createSimplePath("second.third"))).describedAs("long path containing read only element is not writable").isFalse();
235+
});
236+
}
237+
225238
private PersistentPropertyPathExtension extPath(RelationalPersistentEntity<?> entity) {
226239
return new PersistentPropertyPathExtension(context, entity);
227240
}
@@ -237,6 +250,7 @@ PersistentPropertyPath<RelationalPersistentProperty> createSimplePath(String pat
237250
@SuppressWarnings("unused")
238251
static class DummyEntity {
239252
@Id Long entityId;
253+
@ReadOnlyProperty
240254
Second second;
241255
@Embedded(onEmpty = OnEmpty.USE_NULL, prefix = "sec") Second second2;
242256
@Embedded(onEmpty = OnEmpty.USE_NULL) Second second3;

spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalEntityDeleteWriter.java

+10-4
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@
2121

2222
import org.springframework.data.convert.EntityWriter;
2323
import org.springframework.data.mapping.PersistentProperty;
24+
import org.springframework.data.relational.core.mapping.PersistentPropertyPathExtension;
2425
import org.springframework.data.relational.core.mapping.RelationalMappingContext;
2526
import org.springframework.data.relational.core.mapping.RelationalPersistentEntity;
27+
import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
2628
import org.springframework.lang.Nullable;
2729
import org.springframework.util.Assert;
2830

@@ -72,8 +74,10 @@ private List<DbAction<?>> deleteAll(Class<?> entityType) {
7274

7375
List<DbAction<?>> deleteReferencedActions = new ArrayList<>();
7476

75-
context.findPersistentPropertyPaths(entityType, PersistentProperty::isEntity)
76-
.filter(p -> !p.getRequiredLeafProperty().isEmbedded()).forEach(p -> deleteReferencedActions.add(new DbAction.DeleteAll<>(p)));
77+
context.findPersistentPropertyPaths(entityType, PersistentProperty::isEntity) //
78+
.filter(p -> !p.getRequiredLeafProperty().isEmbedded() //
79+
&& PersistentPropertyPathExtension.isWritable(p)) //
80+
.forEach(p -> deleteReferencedActions.add(new DbAction.DeleteAll<>(p)));
7781

7882
Collections.reverse(deleteReferencedActions);
7983

@@ -114,8 +118,10 @@ private List<DbAction<?>> deleteReferencedEntities(Object id, AggregateChange<?>
114118

115119
List<DbAction<?>> actions = new ArrayList<>();
116120

117-
context.findPersistentPropertyPaths(aggregateChange.getEntityType(), PersistentProperty::isEntity)
118-
.filter(p -> !p.getRequiredLeafProperty().isEmbedded()).forEach(p -> actions.add(new DbAction.Delete<>(id, p)));
121+
context.findPersistentPropertyPaths(aggregateChange.getEntityType(), p -> p.isEntity()) //
122+
.filter(p -> !p.getRequiredLeafProperty().isEmbedded() //
123+
&& PersistentPropertyPathExtension.isWritable(p)) //
124+
.forEach(p -> actions.add(new DbAction.Delete<>(id, p)));
119125

120126
Collections.reverse(actions);
121127

spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/WritingContext.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ class WritingContext<T> {
6363
this.aggregateChange = aggregateChange;
6464
this.rootIdValueSource = IdValueSource.forInstance(root,
6565
context.getRequiredPersistentEntity(aggregateChange.getEntityType()));
66-
this.paths = context.findPersistentPropertyPaths(entityType, (p) -> p.isEntity() && !p.isEmbedded());
66+
this.paths = context.findPersistentPropertyPaths(entityType, (p) -> p.isEntity() && !p.isEmbedded() && p.isWritable());
6767
}
6868

6969
/**

spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/PersistentPropertyPathExtension.java

+5
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ public PersistentPropertyPathExtension(
8080
this.path = path;
8181
}
8282

83+
public static boolean isWritable(PersistentPropertyPath<? extends RelationalPersistentProperty> path) {
84+
85+
return path.isEmpty() || (path.getRequiredLeafProperty().isWritable() && isWritable(path.getParentPath()));
86+
}
87+
8388
/**
8489
* Returns {@literal true} exactly when the path is non empty and the leaf property an embedded one.
8590
*

spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/RelationalEntityDeleteWriterUnitTests.java

+42
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@
2020
import java.util.ArrayList;
2121
import java.util.List;
2222

23+
import lombok.RequiredArgsConstructor;
2324
import org.assertj.core.api.Assertions;
2425
import org.assertj.core.groups.Tuple;
2526
import org.junit.jupiter.api.Test;
2627
import org.junit.jupiter.api.extension.ExtendWith;
2728
import org.mockito.junit.jupiter.MockitoExtension;
2829
import org.springframework.data.annotation.Id;
30+
import org.springframework.data.annotation.ReadOnlyProperty;
2931
import org.springframework.data.relational.core.conversion.DbAction.AcquireLockAllRoot;
3032
import org.springframework.data.relational.core.conversion.DbAction.AcquireLockRoot;
3133
import org.springframework.data.relational.core.conversion.DbAction.Delete;
@@ -108,6 +110,39 @@ public void deleteAllDeletesAllEntitiesAndNoReferencedEntities() {
108110
.containsExactly(Tuple.tuple(DeleteAllRoot.class, SingleEntity.class, ""));
109111
}
110112

113+
@Test // GH-1249
114+
public void deleteDoesNotDeleteReadOnlyReferences() {
115+
116+
WithReadOnlyReference entity = new WithReadOnlyReference(23L);
117+
118+
MutableAggregateChange<WithReadOnlyReference> aggregateChange = MutableAggregateChange.forDelete(WithReadOnlyReference.class);
119+
120+
converter.write(entity.id, aggregateChange);
121+
122+
Assertions.assertThat(extractActions(aggregateChange))
123+
.extracting(DbAction::getClass, DbAction::getEntityType, DbActionTestSupport::extractPath) //
124+
.containsExactly( //
125+
Tuple.tuple(DeleteRoot.class, WithReadOnlyReference.class, "") //
126+
);
127+
}
128+
129+
@Test // GH-1249
130+
public void deleteAllDoesNotDeleteReadOnlyReferences() {
131+
132+
WithReadOnlyReference entity = new WithReadOnlyReference(23L);
133+
134+
MutableAggregateChange<WithReadOnlyReference> aggregateChange = MutableAggregateChange.forDelete(WithReadOnlyReference.class);
135+
136+
converter.write(null, aggregateChange);
137+
138+
Assertions.assertThat(extractActions(aggregateChange))
139+
.extracting(DbAction::getClass, DbAction::getEntityType, DbActionTestSupport::extractPath) //
140+
.containsExactly( //
141+
Tuple.tuple(DeleteAllRoot.class, WithReadOnlyReference.class, "") //
142+
);
143+
}
144+
145+
111146
private List<DbAction<?>> extractActions(MutableAggregateChange<?> aggregateChange) {
112147

113148
List<DbAction<?>> actions = new ArrayList<>();
@@ -141,4 +176,11 @@ private class SingleEntity {
141176
@Id final Long id;
142177
String name;
143178
}
179+
180+
@RequiredArgsConstructor
181+
private static class WithReadOnlyReference {
182+
@Id final Long id;
183+
@ReadOnlyProperty
184+
OtherEntity other;
185+
}
144186
}

spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/RelationalEntityInsertWriterUnitTests.java

+2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import org.junit.jupiter.api.extension.ExtendWith;
2727
import org.mockito.junit.jupiter.MockitoExtension;
2828
import org.springframework.data.annotation.Id;
29+
import org.springframework.data.annotation.ReadOnlyProperty;
2930
import org.springframework.data.relational.core.conversion.DbAction.InsertRoot;
3031
import org.springframework.data.relational.core.mapping.RelationalMappingContext;
3132

@@ -75,6 +76,7 @@ public void existingEntityGetsNotConvertedToDeletePlusUpdate() {
7576

7677
}
7778

79+
7880
private List<DbAction<?>> extractActions(MutableAggregateChange<?> aggregateChange) {
7981

8082
List<DbAction<?>> actions = new ArrayList<>();

spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/RelationalEntityWriterUnitTests.java

+50
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import org.junit.jupiter.api.extension.ExtendWith;
3434
import org.mockito.junit.jupiter.MockitoExtension;
3535
import org.springframework.data.annotation.Id;
36+
import org.springframework.data.annotation.ReadOnlyProperty;
3637
import org.springframework.data.mapping.PersistentPropertyPath;
3738
import org.springframework.data.mapping.PersistentPropertyPaths;
3839
import org.springframework.data.relational.core.conversion.DbAction.Delete;
@@ -817,6 +818,46 @@ void newEntityWithCollection_whenElementHasPrimitiveId_batchInsertDoesNotInclude
817818
);
818819
}
819820

821+
@Test // GH-1249
822+
public void readOnlyReferenceDoesNotCreateInsertsOnCreation() {
823+
824+
WithReadOnlyReference entity = new WithReadOnlyReference(null);
825+
entity.readOnly = new Element(SOME_ENTITY_ID);
826+
827+
AggregateChangeWithRoot<WithReadOnlyReference> aggregateChange = MutableAggregateChange.forSave(entity);
828+
829+
new RelationalEntityWriter<WithReadOnlyReference>(context).write(entity, aggregateChange);
830+
831+
assertThat(extractActions(aggregateChange)) //
832+
.extracting(DbAction::getClass, DbAction::getEntityType, DbActionTestSupport::extractPath,
833+
DbActionTestSupport::actualEntityType, DbActionTestSupport::isWithDependsOn) //
834+
.containsExactly( //
835+
tuple(InsertRoot.class, WithReadOnlyReference.class, "", WithReadOnlyReference.class, false) //
836+
// no insert for element
837+
);
838+
839+
}
840+
841+
@Test // GH-1249
842+
public void readOnlyReferenceDoesNotCreateDeletesOrInsertsDuringUpdate() {
843+
844+
WithReadOnlyReference entity = new WithReadOnlyReference(SOME_ENTITY_ID);
845+
entity.readOnly = new Element(SOME_ENTITY_ID);
846+
847+
AggregateChangeWithRoot<WithReadOnlyReference> aggregateChange = MutableAggregateChange.forSave(entity);
848+
849+
new RelationalEntityWriter<WithReadOnlyReference>(context).write(entity, aggregateChange);
850+
851+
assertThat(extractActions(aggregateChange)) //
852+
.extracting(DbAction::getClass, DbAction::getEntityType, DbActionTestSupport::extractPath,
853+
DbActionTestSupport::actualEntityType, DbActionTestSupport::isWithDependsOn) //
854+
.containsExactly( //
855+
tuple(UpdateRoot.class, WithReadOnlyReference.class, "", WithReadOnlyReference.class, false) //
856+
// no insert for element
857+
);
858+
859+
}
860+
820861
private List<DbAction<?>> extractActions(MutableAggregateChange<?> aggregateChange) {
821862

822863
List<DbAction<?>> actions = new ArrayList<>();
@@ -1015,4 +1056,13 @@ private static class NoIdElement {
10151056
// empty classes feel weird.
10161057
String name;
10171058
}
1059+
1060+
@RequiredArgsConstructor
1061+
private static class WithReadOnlyReference {
1062+
1063+
@Id final Long id;
1064+
@ReadOnlyProperty
1065+
Element readOnly;
1066+
}
1067+
10181068
}

src/main/asciidoc/jdbc.adoc

+12-1
Original file line numberDiff line numberDiff line change
@@ -417,13 +417,24 @@ include::{spring-data-commons-docs}/is-new-state-detection.adoc[leveloffset=+2]
417417
Spring Data JDBC uses the ID to identify entities.
418418
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.
419419

420-
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.
420+
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.
421421

422422
One important constraint is that, after saving an entity, the entity must not be new any more.
423423
Note that whether an entity is new is part of the entity's state.
424424
With auto-increment columns, this happens automatically, because the ID gets set by Spring Data with the value from the ID column.
425425
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).
426426

427+
[[jdbc.entity-persistence.read-only-properties]]
428+
=== Read Only Properties
429+
430+
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.
431+
432+
Spring Data JDBC will not automatically reload an entity after writing it.
433+
Therefore, you have to reload it explicitly if you want to see data that was generated in the database for such columns.
434+
435+
If the annotated attribute is an entity or collection of entities, it is represented by one or more separate rows in separate tables.
436+
Spring Data JDBC will not perform any insert, delete or update for these rows.
437+
427438
[[jdbc.entity-persistence.optimistic-locking]]
428439
=== Optimistic Locking
429440

0 commit comments

Comments
 (0)