From 8a878864dbc21ad9c867625be73b26276d355a20 Mon Sep 17 00:00:00 2001 From: Jose Luis Leon Date: Fri, 1 Mar 2024 16:48:15 -0500 Subject: [PATCH] feat(core); Add linkOnly option to one-to-many and many-to-many (#22) --- .../annotations/ManyToMany.java | 47 +++++--- .../annotations/ManyToOne.java | 12 ++- .../annotations/OneToMany.java | 13 +++ .../annotations/OneToOne.java | 7 +- .../processors/ManyToManyProcessor.java | 101 ++++++++++-------- .../processors/OneToManyProcessor.java | 29 ++++- .../processors/ManyToManyProcessorTest.java | 54 ++++++++++ .../processors/OneToManyProcessorTest.java | 51 +++++++++ .../models/author/Author.java | 4 +- .../models/country/Country.java | 6 +- .../models/paper/Paper.java | 2 + .../models/town/Town.java | 2 + 12 files changed, 254 insertions(+), 74 deletions(-) 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 1568f07..3326ada 100644 --- a/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/ManyToMany.java +++ b/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/ManyToMany.java @@ -28,6 +28,25 @@ @Target({FIELD, PARAMETER, ANNOTATION_TYPE}) public @interface ManyToMany { + /** + * 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 join table responsible for the * many-to-many relationship between two tables. This is usually optional if @@ -57,23 +76,17 @@ 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. + * Whether the associated entities are only linked to the join table or not. + * Defaults to {@code false}. * - * @return {@code true} if "orphan" entities should also be deleted, {@code false} + *

Link-only means the associated entities already exist. The annotation + * will only create the link in the join table column when required. The + * associated entities are never updated. + * + * @return {@code true} if the associated entities are link-only, {@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; + boolean linkOnly() default false; /** * Used to specify the name of the "foreign key" column that maps the @@ -90,10 +103,10 @@ String mappedBy() default ""; /** - * Whether the entities on the annotated field are readonly or not. I.e., the - * "children" entities are never persisted. Defaults to {@code false}. + * Whether the associated entities are read-only or not, meaning they are + * never persisted or linked. Defaults to {@code false}. * - * @return {@code true} if the children entities should be readonly, {@code false} + * @return {@code true} if the associated entities are read-only, {@code false} * otherwise */ boolean readonly() default false; diff --git a/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/ManyToOne.java b/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/ManyToOne.java index 23cf150..c69fe05 100644 --- a/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/ManyToOne.java +++ b/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/ManyToOne.java @@ -47,12 +47,16 @@ String foreignKey() default ""; /** - * Should the entity on the annotated field be persisted. Defaults to {@code false}. + * Whether the associated entity persists or not. Defaults to {@code false}. * - *

Many-to-one relationships are a back reference of a one-to-many - * relationship, so they are usually expected to be readonly. + *

Many-to-one relationships are a backreference of a one-to-many + * relationship. They are usually expected to be link-only, meaning the + * parent should exist to link the entity through their {@link #foreignKey}, + * and no changes are made to the parent entity. Setting this option to + * {@code true} creates/updates the parent before linking the entity. * - * @return whether the annotated entity is persisted or not + * @return {@code true} if the associated entity persists, {@code false} + * otherwise */ boolean persist() 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 e100ad4..a900214 100644 --- a/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/OneToMany.java +++ b/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/OneToMany.java @@ -43,6 +43,19 @@ */ boolean keepOrphans() default false; + /** + * Whether children entities are only linked to the parent or not. Defaults to + * {@code false}. + * + *

Link-only means the children entities should already exist. The + * annotation will only update the "foreign key" link on each entity when + * required. Other values in the children entities are never updated. + * + * @return {@code true} if children entities are only linked, {@code false} + * otherwise + */ + boolean linkOnly() 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 diff --git a/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/OneToOne.java b/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/OneToOne.java index cf085fc..af3b289 100644 --- a/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/OneToOne.java +++ b/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/OneToOne.java @@ -62,10 +62,11 @@ String mappedBy() default ""; /** - * Should the entity on the annotated field be readonly. I.e., the entity is - * never persisted. Defaults to {@code false}. + * Whether the associated entity is read-only or not, meaning it's never + * persisted or linked. Defaults to {@code false}. * - * @return whether the annotated entity is readonly or not + * @return {@code true} if the associated entity is read-only, {@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 e021603..64f72ca 100644 --- a/src/main/java/io/github/joselion/springr2dbcrelationships/processors/ManyToManyProcessor.java +++ b/src/main/java/io/github/joselion/springr2dbcrelationships/processors/ManyToManyProcessor.java @@ -1,7 +1,6 @@ package io.github.joselion.springr2dbcrelationships.processors; import static java.util.Arrays.stream; -import static java.util.function.Predicate.isEqual; import static java.util.function.Predicate.not; import static java.util.stream.Collectors.joining; import static java.util.stream.Collectors.toMap; @@ -122,7 +121,7 @@ public Mono> populate(final ManyToMany annotation, final Field field) { @Override public Mono> persist(final ManyToMany annotation, final Field field) { - final var values = Reflect.>getter(this.entity, field); + final var values = Reflect.>getter(this.entity, field); if (values == null) { return Mono.empty(); @@ -180,51 +179,49 @@ public Mono> persist(final ManyToMany annotation, final Field field) { .map(x -> List.of()); } - return Flux.fromIterable(values) - .map(value -> - stream(value.getClass().getDeclaredFields()) - .filter(vf -> vf.isAnnotationPresent(ManyToMany.class)) - .filter(vf -> this.domainFor(Reflect.innerTypeOf(vf)).equals(entityType)) - .reduce( - value, - (acc, vf) -> Reflect.update(acc, vf, null), - (a, b) -> b - ) - ) - .flatMap(this::save) - .collectList() - .delayUntil(items -> { - final var newIds = values.stream() - .filter(this::isNew) - .map(this::idValueOf) - .toList(); - - return Flux.fromIterable(items) - .filter(item -> newIds.stream().anyMatch(not(isEqual(this.idValueOf(item))))) + return Mono.just(annotation) + .filter(not(ManyToMany::linkOnly)) + .flatMap(x -> + Flux.fromIterable(values) + .map(this::toPreventingCycles) + .flatMap(this::save) .collectList() - .filter(not(List::isEmpty)) - .flatMap(newItems -> { - final var paramsTemplate = IntStream.range(2, newItems.size() + 2) - .mapToObj("($1, $%d)"::formatted) - .collect(joining(", ")); - final var statement = "INSERT INTO %s (%s, %s) VALUES %s".formatted( - joinTable, - mappedBy, - linkedBy, - paramsTemplate - ); - final var params = IntStream.range(2, newItems.size() + 2) - .mapToObj(i -> Map.entry("$" + i, this.idValueOf(newItems.get(i - 2)))) - .collect(toMap(Entry::getKey, Entry::getValue)); + ) + .defaultIfEmpty(values) + .delayUntil(items -> + items.stream() + .filter(item -> this.idValueOf(item) == null) + .findFirst() + .map(Object::toString) + .map("Link-only entity is missing its primary key: "::concat) + .map(RelationshipException::of) + .map(Mono::error) + .orElseGet(Mono::empty) + ) + .delayUntil(newItems -> { + final var paramsTemplate = IntStream.range(0, newItems.size()) + .mapToObj("(:entityId, :link[%d])"::formatted) + .collect(joining(", ")); + final var params = IntStream.range(0, newItems.size()) + .mapToObj(i -> Map.entry("link[%d]".formatted(i), this.idValueOf(newItems.get(i)))) + .collect(toMap(Entry::getKey, Entry::getValue)); + final var statement = MessageFormat.format( + """ + INSERT INTO {0} ({1}, {2}) ( + SELECT t.* FROM (VALUES {3}) AS t(mapped, linked) + WHERE t.linked NOT IN (SELECT {2} FROM {0} WHERE {1} = :entityId) + ) + """, + joinTable, mappedBy, linkedBy, paramsTemplate + ); - return this.template - .getDatabaseClient() - .sql(statement) - .bind(0, entityId) - .bindValues(params) - .fetch() - .rowsUpdated(); - }); + return this.template + .getDatabaseClient() + .sql(statement) + .bind("entityId", entityId) + .bindValues(params) + .fetch() + .rowsUpdated(); }) .delayUntil(items -> { final var paramsTemplate = IntStream.range(2, items.size() + 2) @@ -293,4 +290,18 @@ private Function> withCallbacksOf(final Class type) { .defaultIfEmpty(value); }; } + + private T toPreventingCycles(final T value) { + final var entityType = this.domainFor(this.entity.getClass()); + final var valueFields = value.getClass().getDeclaredFields(); + + return stream(valueFields) + .filter(field -> field.isAnnotationPresent(ManyToMany.class)) + .filter(field -> this.domainFor(Reflect.innerTypeOf(field)).equals(entityType)) + .reduce( + value, + (acc, field) -> Reflect.update(acc, field, null), + (a, b) -> b + ); + } } 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 f741f49..b335571 100644 --- a/src/main/java/io/github/joselion/springr2dbcrelationships/processors/OneToManyProcessor.java +++ b/src/main/java/io/github/joselion/springr2dbcrelationships/processors/OneToManyProcessor.java @@ -104,8 +104,33 @@ public Mono> persist(final OneToMany annotation, final Field field) { .orThrow(RelationshipException::of); return Flux.fromIterable(values) - .map(Reflect.update(mappedField, entityId)) - .flatMap(this::save) + .flatMap(value -> { + if (annotation.linkOnly()) { + final var innerTable = this.tableNameOf(innerType); + final var innerId = this.idColumnOf(innerType); + final var statement = "UPDATE %s SET %s = $1 WHERE %s = $2".formatted(innerTable, mappedBy, innerId); + final var linked = Reflect.update(value, mappedField, entityId); + final var missingId = RelationshipException.of("Link-only entity is missing its primary key: " + linked); + + return Mono.just(value) + .mapNotNull(this::idValueOf) + .flatMap(valueId -> + this.template + .getDatabaseClient() + .sql(statement) + .bind(0, entityId) + .bind(1, valueId) + .fetch() + .rowsUpdated() + ) + .map(x -> linked) + .switchIfEmpty(Mono.error(missingId)); + } + + return Mono.just(value) + .map(Reflect.update(mappedField, entityId)) + .flatMap(this::save); + }) .collectList() .delayUntil(children -> { final var keepOrphans = annotation.keepOrphans(); 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 eba5a02..0aacb60 100644 --- a/src/test/java/io/github/joselion/springr2dbcrelationships/processors/ManyToManyProcessorTest.java +++ b/src/test/java/io/github/joselion/springr2dbcrelationships/processors/ManyToManyProcessorTest.java @@ -12,6 +12,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import io.github.joselion.springr2dbcrelationships.exceptions.RelationshipException; import io.github.joselion.springr2dbcrelationships.models.author.Author; import io.github.joselion.springr2dbcrelationships.models.author.AuthorRepository; import io.github.joselion.springr2dbcrelationships.models.authorbook.AuthorBook; @@ -345,6 +346,59 @@ .verifyComplete(); } } + + @Nested class when_the_relation_is_link_only { + @Nested class and_all_the_entities_exist { + @Test void links_the_entitites_without_updating_them() { + Flux.just(blackHoles, superNovas, wormHoles) + .map(Paper::of) + .publish(paperRepo::saveAll) + .map(paper -> paper.withTitleBy(String::toUpperCase)) + .collectList() + .map(nielDegreese.withPapers(null)::withStudies) + .flatMap(authorRepo::save) + .zipWhen(x -> paperRepo.findAll().collectList()) + .as(TxStepVerifier::withRollback) + .assertNext(consumer((author, papers) -> { + assertThat(author.studies()) + .extracting(Paper::title) + .containsExactly( + blackHoles.toUpperCase(), + superNovas.toUpperCase(), + wormHoles.toUpperCase() + ); + assertThat(papers) + .allSatisfy(paper -> + assertThat(paper.authors()) + .isNotEmpty() + .extracting(Author::id) + .containsExactly(author.id()) + ) + .extracting(Paper::title) + .containsExactly(blackHoles, superNovas, wormHoles); + })) + .verifyComplete(); + } + } + + @Nested class and_an_enity_does_not_exist { + @Test void raises_a_RelationshipException_error() { + Flux.just(blackHoles, superNovas) + .map(Paper::of) + .publish(paperRepo::saveAll) + .concatWithValues(Paper.of(wormHoles)) + .collectList() + .map(nielDegreese.withPapers(null)::withStudies) + .flatMap(authorRepo::save) + .as(TxStepVerifier::withRollback) + .verifyErrorSatisfies(error -> + assertThat(error) + .isInstanceOf(RelationshipException.class) + .hasMessageStartingWith("Link-only entity is missing its primary key: Paper[id=null,") + ); + } + } + } } private Mono, Author>> tolkienTrilogy() { 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 34ccb87..c0f30f8 100644 --- a/src/test/java/io/github/joselion/springr2dbcrelationships/processors/OneToManyProcessorTest.java +++ b/src/test/java/io/github/joselion/springr2dbcrelationships/processors/OneToManyProcessorTest.java @@ -13,6 +13,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import io.github.joselion.springr2dbcrelationships.exceptions.RelationshipException; import io.github.joselion.springr2dbcrelationships.models.city.City; import io.github.joselion.springr2dbcrelationships.models.city.CityRepository; import io.github.joselion.springr2dbcrelationships.models.country.Country; @@ -262,5 +263,55 @@ .verifyComplete(); } } + + @Nested class when_the_relation_is_link_only { + @Nested class and_all_children_entities_exist { + @Test void links_the_children_without_updating_anything_else() { + Flux.just(manhattan, albuquerque, springfield) + .map(Town::of) + .publish(townRepo::saveAll) + .map(town -> town.withNameBy(String::toUpperCase)) + .collectList() + .map(usa.withTowns(null)::withSettlements) + .flatMap(countryRepo::save) + .zipWhen(country -> + Mono.just(country) + .map(Country::id) + .flatMapMany(townRepo::findByCountryId) + .collectList() + ) + .as(TxStepVerifier::withRollback) + .assertNext(consumer((country, towns) -> { + assertThat(country.settlements()) + .allSatisfy(town -> assertThat(town.countryId()).isEqualTo(country.id())) + .extracting(Town::name) + .containsExactly(manhattan.toUpperCase(), albuquerque.toUpperCase(), springfield.toUpperCase()); + assertThat(towns) + .allSatisfy(town -> assertThat(town.countryId()).isEqualTo(country.id())) + .extracting(Town::name) + .containsExactly(manhattan, albuquerque, springfield); + })) + .verifyComplete(); + } + } + + @Nested class and_a_child_enity_does_not_exist { + @Test void raises_a_RelationshipException_error() { + Flux.just(manhattan, albuquerque) + .map(Town::of) + .publish(townRepo::saveAll) + .concatWithValues(Town.of(springfield)) + .collectList() + .map(usa.withTowns(null)::withSettlements) + .flatMap(countryRepo::save) + .as(TxStepVerifier::withRollback) + .verifyErrorSatisfies(error -> { + assertThat(error) + .isInstanceOf(RelationshipException.class) + .hasMessageStartingWith("Link-only entity is missing its primary key: Town[id=null,"); + }); + } + } + } } } 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 b527419..6a95385 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 @@ -20,7 +20,8 @@ public record Author( LocalDateTime createdAt, String name, @ManyToMany List books, - @ManyToMany(deleteOrphans = true) @Nullable List papers + @ManyToMany(deleteOrphans = true) @Nullable List papers, + @ManyToMany(linkOnly = true) @Nullable List studies ) { public static Author empty() { @@ -29,6 +30,7 @@ public static Author empty() { LocalDateTime.now(), "", List.of(), + null, 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 10e02c9..fc25163 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 @@ -20,7 +20,8 @@ public record Country( LocalDateTime createdAt, String name, @OneToMany List cities, - @OneToMany(keepOrphans = true) List towns + @OneToMany(keepOrphans = true) List towns, + @OneToMany(linkOnly = true) @Nullable List settlements ) { public static Country empty() { @@ -29,7 +30,8 @@ public static Country empty() { LocalDateTime.now(), "", List.of(), - List.of() + List.of(), + null ); } 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 index b78f11a..1bb9dca 100644 --- a/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/paper/Paper.java +++ b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/paper/Paper.java @@ -10,8 +10,10 @@ import io.github.joselion.springr2dbcrelationships.annotations.ManyToMany; import io.github.joselion.springr2dbcrelationships.models.author.Author; import lombok.With; +import lombok.experimental.WithBy; @With +@WithBy public record Paper( @Id @Nullable UUID id, LocalDateTime createdAt, 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 e50abbd..6aa97ea 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 @@ -9,8 +9,10 @@ import io.github.joselion.springr2dbcrelationships.annotations.ManyToOne; import io.github.joselion.springr2dbcrelationships.models.country.Country; import lombok.With; +import lombok.experimental.WithBy; @With +@WithBy public record Town( @Id @Nullable UUID id, LocalDateTime createdAt,