diff --git a/build.gradle b/build.gradle index ee2614a..c0f7fad 100644 --- a/build.gradle +++ b/build.gradle @@ -80,6 +80,7 @@ sonarLint { 'java:S107', // Allow constructors with more than 7 parameters 'java:S3776', // Allow methods with more than 15 lines 'java:S4032', // Allow packages only containing `package-info.java` + 'java:S6203', // Allow textbloks in lambda expressions ) } } diff --git a/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/ManyToMany.java b/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/ManyToMany.java index 0547303..1568f07 100644 --- a/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/ManyToMany.java +++ b/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/ManyToMany.java @@ -56,6 +56,25 @@ */ String linkedBy() default ""; + /** + * Whether "orphan" entities should be deleted or not. Defaults to {@code false}. + * + *
Usually, many-to-many relationships are not mutually exclusive to each + * other, meaning that one can exist without the other even when they are not + * linked in their join table. In this context, "orphans" refers to all + * entities no longer linked to the current entity. By default, the + * annotation will only delete the links to the "orphans" entities in the + * join table. Setting this option to {@code true} will also delete the + * "orphan" entities. + * + * @return {@code true} if "orphan" entities should also be deleted, {@code false} + * otherwise + * @apiNote given the nature of many-to-many relationships, setting this + * option to {@code true} is highly discouraged as it can produce + * unexpected results, especially in bidirectional associations + */ + boolean deleteOrphans() default false; + /** * Used to specify the name of the "foreign key" column that maps the * annotated field's entity with the join table. This is usually optional if @@ -71,10 +90,11 @@ String mappedBy() default ""; /** - * Should the entities on the annotated field be readonly. I.e., the entities - * are never persisted. Defaults to {@code false}. + * Whether the entities on the annotated field are readonly or not. I.e., the + * "children" entities are never persisted. Defaults to {@code false}. * - * @return whether the annotated entity is readonly or not + * @return {@code true} if the children entities should be readonly, {@code false} + * otherwise */ boolean readonly() default false; diff --git a/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/OneToMany.java b/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/OneToMany.java index 495e66a..e100ad4 100644 --- a/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/OneToMany.java +++ b/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/OneToMany.java @@ -28,6 +28,21 @@ @Target({FIELD, PARAMETER, ANNOTATION_TYPE}) public @interface OneToMany { + /** + * Whether orphan entities are preserved or not. Defaults to {@code false}. + * + *
Usually, one-to-many relationships have a parent-children configuration,
+ * meaning every child needs a parent assigned to it. By default, the
+ * annotation will delete orphan entites, or children which are no longer
+ * assigned to their parent. You can prevent this behavior by setting this
+ * option to {@code true}, in which case the annotation will only remove the
+ * link of the orphan entities with the parent.
+ *
+ * @return {@code true} if orphan entities should be presereved, {@code false}
+ * otherwise
+ */
+ boolean keepOrphans() default false;
+
/**
* Used to specify the name of the "foreign key" column on the child table.
* This is usually optional if the name of the column matches the name of the
@@ -42,10 +57,11 @@
String mappedBy() default "";
/**
- * Should the entities on the annotated field be readonly. I.e., the entities
- * are never persisted. Defaults to {@code false}.
+ * Whether the entities on the annotated field are readonly or not. I.e., the
+ * children entities are never persisted. Defaults to {@code false}.
*
- * @return whether the annotated entity is readonly or not
+ * @return {@code true} if the children entities should be readonly, {@code false}
+ * otherwise
*/
boolean readonly() default false;
diff --git a/src/main/java/io/github/joselion/springr2dbcrelationships/processors/ManyToManyProcessor.java b/src/main/java/io/github/joselion/springr2dbcrelationships/processors/ManyToManyProcessor.java
index e34adb1..e021603 100644
--- a/src/main/java/io/github/joselion/springr2dbcrelationships/processors/ManyToManyProcessor.java
+++ b/src/main/java/io/github/joselion/springr2dbcrelationships/processors/ManyToManyProcessor.java
@@ -141,6 +141,7 @@ public Mono> persist(final ManyToMany annotation, final Field field) {
final var innerType = this.domainFor(Reflect.innerTypeOf(field));
final var entityTable = this.tableNameOf(entityType);
final var innerTable = this.tableNameOf(innerType);
+ final var innerId = this.idColumnOf(innerType);
final var mappedBy = Optional.of(annotation)
.map(ManyToMany::mappedBy)
.filter(not(String::isBlank))
@@ -149,6 +150,24 @@ public Mono
> persist(final ManyToMany annotation, final Field field) {
.map(ManyToMany::linkedBy)
.filter(not(String::isBlank))
.orElseGet(() -> innerTable.concat("_id"));
+ final var orphansStatement = """
+ DELETE FROM %s
+ WHERE %s NOT IN (
+ SELECT j.%s FROM %s AS j
+ WHERE j.%s = $1
+ )
+ """
+ .formatted(innerTable, innerId, linkedBy, joinTable, mappedBy);
+ final var deleteOrphans = Mono.just(annotation)
+ .filter(ManyToMany::deleteOrphans)
+ .flatMap(y ->
+ this.template
+ .getDatabaseClient()
+ .sql(orphansStatement)
+ .bind(0, entityId)
+ .fetch()
+ .rowsUpdated()
+ );
if (values.isEmpty()) {
return this.template
@@ -157,6 +176,7 @@ public Mono
> persist(final ManyToMany annotation, final Field field) {
.bind(0, entityId)
.fetch()
.rowsUpdated()
+ .delayUntil(x -> deleteOrphans)
.map(x -> List.of());
}
@@ -210,12 +230,11 @@ public Mono
> persist(final ManyToMany annotation, final Field field) {
final var paramsTemplate = IntStream.range(2, items.size() + 2)
.mapToObj(i -> "$" + i)
.collect(joining(", "));
- final var statement = "DELETE FROM %s WHERE %s = $1 AND %s NOT IN (%s)".formatted(
- joinTable,
- mappedBy,
- linkedBy,
- paramsTemplate
- );
+ final var statement = """
+ DELETE FROM %s
+ WHERE %s = $1 AND %s NOT IN (%s)
+ """
+ .formatted(joinTable, mappedBy, linkedBy, paramsTemplate);
final var params = IntStream.range(2, items.size() + 2)
.mapToObj(i -> Map.entry("$" + i, this.idValueOf(items.get(i - 2))))
.collect(toMap(Entry::getKey, Entry::getValue));
@@ -226,7 +245,8 @@ public Mono
> persist(final ManyToMany annotation, final Field field) {
.bind(0, entityId)
.bindValues(params)
.fetch()
- .rowsUpdated();
+ .rowsUpdated()
+ .delayUntil(x -> deleteOrphans);
});
}));
}
diff --git a/src/main/java/io/github/joselion/springr2dbcrelationships/processors/OneToManyProcessor.java b/src/main/java/io/github/joselion/springr2dbcrelationships/processors/OneToManyProcessor.java
index 2fdd253..f741f49 100644
--- a/src/main/java/io/github/joselion/springr2dbcrelationships/processors/OneToManyProcessor.java
+++ b/src/main/java/io/github/joselion/springr2dbcrelationships/processors/OneToManyProcessor.java
@@ -12,6 +12,7 @@
import org.springframework.context.ApplicationContext;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
+import org.springframework.data.relational.core.query.Update;
import org.springframework.data.relational.core.sql.SqlIdentifier;
import io.github.joselion.maybe.Maybe;
@@ -107,14 +108,21 @@ public Mono
> persist(final OneToMany annotation, final Field field) {
.flatMap(this::save)
.collectList()
.delayUntil(children -> {
+ final var keepOrphans = annotation.keepOrphans();
final var innerId = this.idColumnOf(innerType);
- final var ids = children.stream()
- .map(this::idValueOf)
- .toList();
+ final var ids = children.stream().map(this::idValueOf).toList();
+ final var allOrphans = query(where(mappedBy).is(entityId).and(innerId).notIn(ids));
+
+ if (keepOrphans) {
+ return this.template
+ .update(innerType)
+ .matching(allOrphans)
+ .apply(Update.update(mappedBy, null));
+ }
return this.template
.delete(innerType)
- .matching(query(where(mappedBy).is(entityId).and(innerId).notIn(ids)))
+ .matching(allOrphans)
.all();
});
});
diff --git a/src/test/java/io/github/joselion/springr2dbcrelationships/processors/ManyToManyProcessorTest.java b/src/test/java/io/github/joselion/springr2dbcrelationships/processors/ManyToManyProcessorTest.java
index dac5091..3c3bd71 100644
--- a/src/test/java/io/github/joselion/springr2dbcrelationships/processors/ManyToManyProcessorTest.java
+++ b/src/test/java/io/github/joselion/springr2dbcrelationships/processors/ManyToManyProcessorTest.java
@@ -1,5 +1,6 @@
package io.github.joselion.springr2dbcrelationships.processors;
+import static java.util.function.Predicate.not;
import static org.assertj.core.api.Assertions.assertThat;
import static reactor.function.TupleUtils.consumer;
import static reactor.function.TupleUtils.function;
@@ -17,6 +18,8 @@
import io.github.joselion.springr2dbcrelationships.models.authorbook.AuthorBookRepository;
import io.github.joselion.springr2dbcrelationships.models.book.Book;
import io.github.joselion.springr2dbcrelationships.models.book.BookRepository;
+import io.github.joselion.springr2dbcrelationships.models.paper.Paper;
+import io.github.joselion.springr2dbcrelationships.models.paper.PaperRepository;
import io.github.joselion.testing.annotations.IntegrationTest;
import io.github.joselion.testing.transactional.TxStepVerifier;
import reactor.core.publisher.Flux;
@@ -35,6 +38,9 @@
@Autowired
private AuthorBookRepository authorBookRepo;
+ @Autowired
+ private PaperRepository paperRepo;
+
private final Author tolkien = Author.of("J. R. R. Tolkien");
private final String fellowship = "The Fellowship of the Ring";
@@ -43,6 +49,14 @@
private final String kingsReturn = "The Return of the King";
+ private Author nielDegreese = Author.of("Niel Degreese Tyson");
+
+ private String blackHoles = "Super Masive Black holes";
+
+ private String superNovas = "Effects of Super Novas";
+
+ private String wormHoles = "Theoretical Worm Holes";
+
@Nested class populate {
@Test void populates_the_field_with_the_joined_entities() throws InterruptedException {
final var chrisTolkin = Author.of("Christopher Tolkien");
@@ -153,8 +167,6 @@
)
.as(TxStepVerifier::withRollback)
.assertNext(consumer((author, books) -> {
- // System.err.println("******************* " + author);
- // System.err.println("=================== " + books);
assertThat(author.books())
.allSatisfy(book -> assertThat(book.id()).isNotNull())
.extracting(Book::title)
@@ -183,69 +195,126 @@
}
@Nested class when_there_are_orphan_items {
- @Test void persists_the_items_and_delete_the_orphan_join_links() {
- tolkienTrilogy()
- .flatMap(function((books, author) ->
- Flux.fromIterable(books)
- .filter(book -> book.title().length() > 15)
- .collectList()
- .map(author::withBooks)
- ))
- .flatMap(authorRepo::save)
- .zipWhen(author ->
- Mono.just(author)
- .map(Author::id)
- .flatMapMany(authorBookRepo::findByAuthorId)
- .collectList()
- )
- .zipWhen(
- x -> bookRepo.findAll().collectList(),
- (t, books) -> Tuples.of(t.getT1(), t.getT2(), books))
- .as(TxStepVerifier::withRollback)
- .assertNext(consumer((author, joins, books) -> {
- assertThat(author.books())
- .allSatisfy(book -> assertThat(book.id()).isNotNull())
- .extracting(Book::title)
- .contains(fellowship, kingsReturn);
- assertThat(joins)
- .hasSameSizeAs(author.books())
- .allSatisfy(join -> assertThat(join.authorId()).isEqualTo(author.id()))
- .extracting(AuthorBook::bookId)
- .containsAll(author.books().stream().map(Book::id).toList());
- assertThat(books)
- .allSatisfy(book -> assertThat(book.id()).isNotNull())
- .extracting(Book::title)
- .containsExactly(fellowship, twoTowers, kingsReturn);
- }))
- .verifyComplete();
+ @Nested class and_the_deleteOrphans_option_is_false {
+ @Test void persists_the_items_and_delete_the_orphan_join_links() {
+ tolkienTrilogy()
+ .flatMap(function((books, author) ->
+ Flux.fromIterable(books)
+ .filter(book -> book.title().length() > 15)
+ .collectList()
+ .map(author::withBooks)
+ ))
+ .flatMap(authorRepo::save)
+ .zipWhen(author ->
+ Mono.just(author)
+ .map(Author::id)
+ .flatMapMany(authorBookRepo::findByAuthorId)
+ .collectList()
+ )
+ .zipWhen(
+ x -> bookRepo.findAll().collectList(),
+ (t, books) -> Tuples.of(t.getT1(), t.getT2(), books))
+ .as(TxStepVerifier::withRollback)
+ .assertNext(consumer((author, joins, books) -> {
+ assertThat(author.books())
+ .allSatisfy(book -> assertThat(book.id()).isNotNull())
+ .extracting(Book::title)
+ .contains(fellowship, kingsReturn);
+ assertThat(joins)
+ .hasSameSizeAs(author.books())
+ .allSatisfy(join -> assertThat(join.authorId()).isEqualTo(author.id()))
+ .extracting(AuthorBook::bookId)
+ .containsAll(author.books().stream().map(Book::id).toList());
+ assertThat(books)
+ .allSatisfy(book -> assertThat(book.id()).isNotNull())
+ .extracting(Book::title)
+ .containsExactly(fellowship, twoTowers, kingsReturn);
+ }))
+ .verifyComplete();
+ }
+ }
+
+ @Nested class and_the_deleteOrphans_option_is_true {
+ @Test void persists_the_items_deletes_the_orphan_join_links_and_delete_the_orphan_items() {
+ Flux.just(blackHoles, superNovas, wormHoles)
+ .map(Paper::of)
+ .delayElements(Duration.ofMillis(1))
+ .collectList()
+ .map(nielDegreese::withPapers)
+ .flatMap(authorRepo::save)
+ .map(saved ->
+ saved.withPapersBy(papers ->
+ papers.stream()
+ .filter(not(paper -> paper.title().equals(superNovas)))
+ .toList()
+ )
+ )
+ .flatMap(authorRepo::save)
+ .map(Author::id)
+ .flatMap(authorRepo::findById)
+ .zipWhen(x -> paperRepo.findAll().collectList())
+ .as(TxStepVerifier::withRollback)
+ .assertNext(consumer((author, papers) -> {
+ assertThat(author.papers())
+ .extracting(Paper::title)
+ .containsExactly(wormHoles, blackHoles);
+ assertThat(papers)
+ .extracting(Paper::title)
+ .containsExactly(blackHoles, wormHoles);
+ }))
+ .verifyComplete();
+ }
}
}
@Nested class when_all_the_items_are_left_orphan {
- @Test void deletes_all_the_orphan_join_links() {
- tolkienTrilogy()
- .map(function((books, author) -> author.withBooks(List.of())))
- .flatMap(authorRepo::save)
- .zipWhen(author ->
- Mono.just(author)
- .map(Author::id)
- .flatMapMany(authorBookRepo::findByAuthorId)
- .collectList()
- )
- .zipWhen(
- x -> bookRepo.findAll().collectList(),
- (t, books) -> Tuples.of(t.getT1(), t.getT2(), books)
- )
- .as(TxStepVerifier::withRollback)
- .assertNext(consumer((author, joins, books) -> {
- assertThat(author.books()).isEmpty();
- assertThat(joins).isEmpty();
- assertThat(books)
- .allSatisfy(book -> assertThat(book.id()).isNotNull())
- .extracting(Book::title)
- .containsExactly(fellowship, twoTowers, kingsReturn);
- }))
- .verifyComplete();
+ @Nested class and_the_deleteOrphans_option_is_false {
+ @Test void deletes_all_the_orphan_join_links() {
+ tolkienTrilogy()
+ .map(function((books, author) -> author.withBooks(List.of())))
+ .flatMap(authorRepo::save)
+ .zipWhen(author ->
+ Mono.just(author)
+ .map(Author::id)
+ .flatMapMany(authorBookRepo::findByAuthorId)
+ .collectList()
+ )
+ .zipWhen(
+ x -> bookRepo.findAll().collectList(),
+ (t, books) -> Tuples.of(t.getT1(), t.getT2(), books)
+ )
+ .as(TxStepVerifier::withRollback)
+ .assertNext(consumer((author, joins, books) -> {
+ assertThat(author.books()).isEmpty();
+ assertThat(joins).isEmpty();
+ assertThat(books)
+ .allSatisfy(book -> assertThat(book.id()).isNotNull())
+ .extracting(Book::title)
+ .containsExactly(fellowship, twoTowers, kingsReturn);
+ }))
+ .verifyComplete();
+ }
+ }
+
+ @Nested class and_the_deleteOrphans_option_is_true {
+ @Test void deletes_all_the_orphan_join_links_and_all_the_orphan_items() {
+ Flux.just(blackHoles, superNovas, wormHoles)
+ .map(Paper::of)
+ .collectList()
+ .map(nielDegreese::withPapers)
+ .flatMap(authorRepo::save)
+ .map(saved -> saved.withPapers(List.of()))
+ .flatMap(authorRepo::save)
+ .map(Author::id)
+ .flatMap(authorRepo::findById)
+ .zipWhen(x -> paperRepo.findAll().collectList())
+ .as(TxStepVerifier::withRollback)
+ .assertNext(consumer((author, papers) -> {
+ assertThat(author.papers()).isEmpty();
+ assertThat(papers).isEmpty();
+ }))
+ .verifyComplete();
+ }
}
}
@@ -280,7 +349,6 @@
private Mono