From 603c3620ac387f40b8acd76320fd9d0c577dc19d Mon Sep 17 00:00:00 2001 From: JoseLion Date: Thu, 29 Feb 2024 14:32:43 -0500 Subject: [PATCH] feat(core): Add preferences to handle orphans --- build.gradle | 1 + .../annotations/ManyToMany.java | 26 ++- .../annotations/OneToMany.java | 22 +- .../processors/ManyToManyProcessor.java | 34 +++- .../processors/OneToManyProcessor.java | 16 +- .../processors/ManyToManyProcessorTest.java | 192 ++++++++++++------ .../processors/ManyToOneProcessorTest.java | 3 - .../processors/OneToManyProcessorTest.java | 150 ++++++++++---- .../testing/transactional/Transactions.java | 2 +- .../models/author/Author.java | 9 +- .../models/country/Country.java | 4 +- .../models/paper/Paper.java | 34 ++++ .../models/paper/PaperRepository.java | 9 + .../models/town/Town.java | 10 +- .../models/town/TownRepository.java | 3 + src/testFixtures/resources/schema.sql | 17 +- 16 files changed, 397 insertions(+), 135 deletions(-) create mode 100644 src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/paper/Paper.java create mode 100644 src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/paper/PaperRepository.java 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, Author>> tolkienTrilogy() { return Flux.just(fellowship, twoTowers, kingsReturn) - .delayElements(Duration.ofMillis(10)) .map(Book::of) .publish(bookRepo::saveAll) .collectList() diff --git a/src/test/java/io/github/joselion/springr2dbcrelationships/processors/ManyToOneProcessorTest.java b/src/test/java/io/github/joselion/springr2dbcrelationships/processors/ManyToOneProcessorTest.java index c0bc61e..be28735 100644 --- a/src/test/java/io/github/joselion/springr2dbcrelationships/processors/ManyToOneProcessorTest.java +++ b/src/test/java/io/github/joselion/springr2dbcrelationships/processors/ManyToOneProcessorTest.java @@ -3,8 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static reactor.function.TupleUtils.consumer; -import java.time.Duration; - import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -45,7 +43,6 @@ .map(Country::id) .zipWhen(id -> Flux.just(newYork, boston, chicago) - .delayElements(Duration.ofMillis(10)) .map(City::of) .map(city -> city.withCountryId(id)) .publish(cityRepo::saveAll) diff --git a/src/test/java/io/github/joselion/springr2dbcrelationships/processors/OneToManyProcessorTest.java b/src/test/java/io/github/joselion/springr2dbcrelationships/processors/OneToManyProcessorTest.java index ab93c23..e0581a0 100644 --- a/src/test/java/io/github/joselion/springr2dbcrelationships/processors/OneToManyProcessorTest.java +++ b/src/test/java/io/github/joselion/springr2dbcrelationships/processors/OneToManyProcessorTest.java @@ -1,10 +1,11 @@ package io.github.joselion.springr2dbcrelationships.processors; +import static java.util.function.Predicate.not; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; import static reactor.function.TupleUtils.consumer; import static reactor.function.TupleUtils.function; -import java.time.Duration; import java.util.List; import org.junit.jupiter.api.Nested; @@ -15,6 +16,8 @@ import io.github.joselion.springr2dbcrelationships.models.city.CityRepository; import io.github.joselion.springr2dbcrelationships.models.country.Country; import io.github.joselion.springr2dbcrelationships.models.country.CountryRepository; +import io.github.joselion.springr2dbcrelationships.models.town.Town; +import io.github.joselion.springr2dbcrelationships.models.town.TownRepository; import io.github.joselion.testing.annotations.IntegrationTest; import io.github.joselion.testing.transactional.TxStepVerifier; import reactor.core.publisher.Flux; @@ -28,6 +31,9 @@ @Autowired private CityRepository cityRepo; + @Autowired + private TownRepository townRepo; + private final Country usa = Country.of("United States of America"); private final String newYork = "New York"; @@ -36,13 +42,18 @@ private final String chicago = "Chicago"; + private final String manhattan = "Manhattan"; + + private final String albuquerque = "Albuquerque"; + + private final String springfield = "Springfield"; + @Nested class populate { @Test void populates_the_field_with_the_children_entities() { countryRepo.save(usa) .map(Country::id) .delayUntil(id -> Flux.just(newYork, boston, chicago) - .delayElements(Duration.ofMillis(10)) .map(City::of) .map(city -> city.withCountryId(id)) .publish(cityRepo::saveAll) @@ -122,50 +133,105 @@ } @Nested class when_there_are_orphan_children { - @Test void persists_the_children_entities_and_delete_the_orphans() { - Flux.just(newYork, boston, chicago) - .map(City::of) - .collectList() - .map(usa::withCities) - .flatMap(countryRepo::save) - .map(saved -> { - final var nextCities = saved.cities() - .stream() - .filter(city -> city.name().length() > 6) - .toList(); - return saved.withCities(nextCities); - }) - .flatMap(countryRepo::save) - .map(Country::id) - .flatMapMany(cityRepo::findByCountryId) - .collectList() - .as(TxStepVerifier::withRollback) - .assertNext(result -> { - assertThat(result) - .extracting(City::name) - .containsExactly(newYork, chicago); - }) - .verifyComplete(); + @Nested class and_the_keepOrphans_option_is_false { + @Test void persists_the_children_entities_and_delete_the_orphans() { + Flux.just(newYork, boston, chicago) + .map(City::of) + .collectList() + .map(usa::withCities) + .flatMap(countryRepo::save) + .map(saved -> + saved.withCitiesBy(cities -> + cities.stream() + .filter(not(city -> city.name().equals(boston))) + .toList() + ) + ) + .flatMap(countryRepo::save) + .map(Country::id) + .flatMapMany(cityRepo::findByCountryId) + .collectList() + .as(TxStepVerifier::withRollback) + .assertNext(result -> { + assertThat(result) + .extracting(City::name) + .containsExactly(newYork, chicago); + }) + .verifyComplete(); + } + } + + @Nested class and_the_keepOrphans_option_is_true { + @Test void persists_the_children_entities_and_unlinks_the_orphans() { + Flux.just(manhattan, albuquerque, springfield) + .map(Town::of) + .collectList() + .map(usa::withTowns) + .flatMap(countryRepo::save) + .map(saved -> + saved.withTownsBy(towns -> + towns.stream() + .filter(not(town -> town.name().equals(albuquerque))) + .toList() + ) + ) + .flatMap(countryRepo::save) + .zipWhen(x -> townRepo.findAll().collectList()) + .as(TxStepVerifier::withRollback) + .assertNext(consumer((country, found) -> { + assertThat(found) + .extracting(Town::name, Town::countryId) + .containsExactly( + tuple(manhattan, country.id()), + tuple(albuquerque, null), + tuple(springfield, country.id()) + ); + })) + .verifyComplete(); + } } } @Nested class when_all_the_children_are_left_orphan { - @Test void deletes_all_the_orphan_children() { - Flux.just(newYork, boston, chicago) - .map(City::of) - .collectList() - .map(usa::withCities) - .flatMap(countryRepo::save) - .map(saved -> saved.withCities(List.of())) - .flatMap(countryRepo::save) - .map(Country::id) - .flatMapMany(cityRepo::findByCountryId) - .collectList() - .as(TxStepVerifier::withRollback) - .assertNext(found -> { - assertThat(found).isEmpty(); - }) - .verifyComplete(); + @Nested class and_the_keepOrphans_option_is_false { + @Test void deletes_all_the_orphan_children() { + Flux.just(newYork, boston, chicago) + .map(City::of) + .collectList() + .map(usa::withCities) + .flatMap(countryRepo::save) + .map(saved -> saved.withCities(List.of())) + .flatMap(countryRepo::save) + .map(Country::id) + .flatMapMany(cityRepo::findByCountryId) + .collectList() + .as(TxStepVerifier::withRollback) + .assertNext(found -> { + assertThat(found).isEmpty(); + }) + .verifyComplete(); + } + } + + @Nested class and_the_keepOrphans_option_is_true { + @Test void unlinks_all_the_orphan_children() { + Flux.just(manhattan, albuquerque, springfield) + .map(Town::of) + .collectList() + .map(usa::withTowns) + .flatMap(countryRepo::save) + .map(saved -> saved.withTowns(List.of())) + .flatMap(countryRepo::save) + .then(townRepo.findAll().collectList()) + .as(TxStepVerifier::withRollback) + .assertNext(found -> { + assertThat(found) + .allSatisfy(town -> assertThat(town.countryId()).isNull()) + .extracting(Town::name) + .containsExactly(manhattan, albuquerque, springfield); + }) + .verifyComplete(); + } } } diff --git a/src/test/java/io/github/joselion/testing/transactional/Transactions.java b/src/test/java/io/github/joselion/testing/transactional/Transactions.java index fa85dd3..680857e 100644 --- a/src/test/java/io/github/joselion/testing/transactional/Transactions.java +++ b/src/test/java/io/github/joselion/testing/transactional/Transactions.java @@ -17,7 +17,7 @@ static Mono withRollback(final Mono publisher) { tx.setRollbackOnly(); return publisher; }) - .next(); + .singleOrEmpty(); } static Flux withRollback(final Flux publisher) { diff --git a/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/author/Author.java b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/author/Author.java index b9daf1b..b527419 100644 --- a/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/author/Author.java +++ b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/author/Author.java @@ -9,14 +9,18 @@ import io.github.joselion.springr2dbcrelationships.annotations.ManyToMany; import io.github.joselion.springr2dbcrelationships.models.book.Book; +import io.github.joselion.springr2dbcrelationships.models.paper.Paper; import lombok.With; +import lombok.experimental.WithBy; @With +@WithBy public record Author( @Id @Nullable UUID id, LocalDateTime createdAt, String name, - @ManyToMany List books + @ManyToMany List books, + @ManyToMany(deleteOrphans = true) @Nullable List papers ) { public static Author empty() { @@ -24,7 +28,8 @@ public static Author empty() { null, LocalDateTime.now(), "", - List.of() + List.of(), + null ); } diff --git a/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/country/Country.java b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/country/Country.java index c323bbb..10e02c9 100644 --- a/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/country/Country.java +++ b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/country/Country.java @@ -11,14 +11,16 @@ import io.github.joselion.springr2dbcrelationships.models.city.City; import io.github.joselion.springr2dbcrelationships.models.town.Town; import lombok.With; +import lombok.experimental.WithBy; @With +@WithBy public record Country( @Id @Nullable UUID id, LocalDateTime createdAt, String name, @OneToMany List cities, - @OneToMany List towns + @OneToMany(keepOrphans = true) List towns ) { public static Country empty() { diff --git a/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/paper/Paper.java b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/paper/Paper.java new file mode 100644 index 0000000..b78f11a --- /dev/null +++ b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/paper/Paper.java @@ -0,0 +1,34 @@ +package io.github.joselion.springr2dbcrelationships.models.paper; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import org.eclipse.jdt.annotation.Nullable; +import org.springframework.data.annotation.Id; + +import io.github.joselion.springr2dbcrelationships.annotations.ManyToMany; +import io.github.joselion.springr2dbcrelationships.models.author.Author; +import lombok.With; + +@With +public record Paper( + @Id @Nullable UUID id, + LocalDateTime createdAt, + String title, + @ManyToMany List authors +) { + + public static Paper empty() { + return new Paper( + null, + LocalDateTime.now(), + "", + List.of() + ); + } + + public static Paper of(final String title) { + return Paper.empty().withTitle(title); + } +} diff --git a/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/paper/PaperRepository.java b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/paper/PaperRepository.java new file mode 100644 index 0000000..79b4c13 --- /dev/null +++ b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/paper/PaperRepository.java @@ -0,0 +1,9 @@ +package io.github.joselion.springr2dbcrelationships.models.paper; + +import java.util.UUID; + +import org.springframework.data.repository.reactive.ReactiveCrudRepository; + +public interface PaperRepository extends ReactiveCrudRepository { + +} diff --git a/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/town/Town.java b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/town/Town.java index a5ce813..e50abbd 100644 --- a/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/town/Town.java +++ b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/town/Town.java @@ -1,7 +1,5 @@ package io.github.joselion.springr2dbcrelationships.models.town; -import static io.github.joselion.springr2dbcrelationships.helpers.Constants.UUID_ZERO; - import java.time.LocalDateTime; import java.util.UUID; @@ -16,8 +14,8 @@ public record Town( @Id @Nullable UUID id, LocalDateTime createdAt, - UUID countryId, - @ManyToOne(persist = true) Country country, + @Nullable UUID countryId, + @ManyToOne(persist = true) @Nullable Country country, String name ) { @@ -25,8 +23,8 @@ public static Town empty() { return new Town( null, LocalDateTime.now(), - UUID_ZERO, - Country.empty(), + null, + null, "" ); } diff --git a/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/town/TownRepository.java b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/town/TownRepository.java index f1e038f..20a8c85 100644 --- a/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/town/TownRepository.java +++ b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/town/TownRepository.java @@ -4,6 +4,9 @@ import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import reactor.core.publisher.Flux; + public interface TownRepository extends ReactiveCrudRepository { + Flux findByCountryId(UUID countryId); } diff --git a/src/testFixtures/resources/schema.sql b/src/testFixtures/resources/schema.sql index 3657e88..122ca6b 100644 --- a/src/testFixtures/resources/schema.sql +++ b/src/testFixtures/resources/schema.sql @@ -30,7 +30,7 @@ CREATE TABLE city( CREATE TABLE town( id uuid NOT NULL DEFAULT random_uuid() PRIMARY KEY, created_at timestamp(9) NOT NULL DEFAULT localtimestamp(), - country_id uuid NOT NULL, + country_id uuid, name varchar(255) NOT NULL, FOREIGN KEY (country_id) REFERENCES country ON DELETE CASCADE ); @@ -55,3 +55,18 @@ CREATE TABLE author_book( FOREIGN KEY (book_id) REFERENCES book ON DELETE CASCADE, UNIQUE (author_id, book_id) ); + +CREATE TABLE paper( + id uuid NOT NULL DEFAULT random_uuid() PRIMARY KEY, + created_at timestamp(9) NOT NULL DEFAULT localtimestamp(), + title varchar(255) NOT NULL +); + +CREATE TABLE author_paper( + id uuid NOT NULL DEFAULT random_uuid() PRIMARY KEY, + author_id uuid NOT NULL, + paper_id uuid NOT NULL, + FOREIGN KEY (author_id) REFERENCES author ON DELETE CASCADE, + FOREIGN KEY (paper_id) REFERENCES paper ON DELETE CASCADE, + UNIQUE (author_id, paper_id) +);