From 38a3a76aeaddf7bedac457b2559f488e2e92a908 Mon Sep 17 00:00:00 2001 From: JoseLion Date: Thu, 22 Feb 2024 23:06:38 -0500 Subject: [PATCH] feat(core): Add many-to-one relationships --- build.gradle | 2 +- gradle.lockfile | 6 +- .../RelationshipCallbacks.java | 62 ++++++++-- .../annotations/ManyToOne.java | 50 ++++++++ .../annotations/OneToMany.java | 12 +- .../annotations/OneToOne.java | 4 +- .../processors/ManyToOneProcessor.java | 80 +++++++++++++ .../processors/OneToOneProcessor.java | 11 -- .../processors/Processable.java | 19 ++- .../processors/ManyToOneProcessorTest.java | 111 ++++++++++++++++++ .../processors/OneToManyProcessorTest.java | 14 +-- .../processors/OneToOneProcessorTest.java | 14 +-- .../testing/annotations/IntegrationTest.java | 3 +- .../testing/transactional/Transactions.java | 31 +++++ .../testing/transactional/TxStepVerifier.java | 33 ++++++ .../helpers/StaticContext.java | 20 ++++ .../models/city/City.java | 4 + .../models/city/CityRepository.java | 3 + .../models/country/Country.java | 5 +- .../models/town/Town.java | 37 ++++++ .../models/town/TownRepository.java | 9 ++ src/testFixtures/resources/schema.sql | 12 +- 22 files changed, 490 insertions(+), 52 deletions(-) create mode 100644 src/main/java/io/github/joselion/springr2dbcrelationships/annotations/ManyToOne.java create mode 100644 src/main/java/io/github/joselion/springr2dbcrelationships/processors/ManyToOneProcessor.java create mode 100644 src/test/java/io/github/joselion/springr2dbcrelationships/processors/ManyToOneProcessorTest.java create mode 100644 src/test/java/io/github/joselion/testing/transactional/Transactions.java create mode 100644 src/test/java/io/github/joselion/testing/transactional/TxStepVerifier.java create mode 100644 src/testFixtures/java/io/github/joselion/springr2dbcrelationships/helpers/StaticContext.java create mode 100644 src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/town/Town.java create mode 100644 src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/town/TownRepository.java diff --git a/build.gradle b/build.gradle index fe17943..f7c069d 100644 --- a/build.gradle +++ b/build.gradle @@ -109,6 +109,7 @@ dependencies { sonarlintCorePlugins(libs.sonarlint.java) implementation(libs.maybe) + implementation(libs.reactor.extra) testFixturesAnnotationProcessor(libs.lombok) testFixturesCompileOnly(libs.lombok) @@ -131,7 +132,6 @@ testing { implementation(libs.assertj.core) implementation(libs.mockito.core) - implementation(libs.reactor.extra) implementation(libs.reactor.test) implementation(libs.spring.boot.r2dbc) implementation(libs.spring.boot.test) diff --git a/gradle.lockfile b/gradle.lockfile index f2780a0..6dc3bda 100644 --- a/gradle.lockfile +++ b/gradle.lockfile @@ -47,11 +47,11 @@ io.netty:netty-transport-classes-epoll:4.1.105.Final=testCompileClasspath,testFi io.netty:netty-transport-native-epoll:4.1.105.Final=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath io.netty:netty-transport-native-unix-common:4.1.105.Final=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath io.netty:netty-transport:4.1.105.Final=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -io.projectreactor.addons:reactor-extra:3.5.1=testCompileClasspath,testRuntimeClasspath +io.projectreactor.addons:reactor-extra:3.5.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath io.projectreactor.addons:reactor-pool:1.0.5=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath io.projectreactor.netty:reactor-netty-core:1.1.15=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath io.projectreactor.netty:reactor-netty-http:1.1.15=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -io.projectreactor:reactor-core:3.6.2=compileClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.projectreactor:reactor-core:3.6.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath io.projectreactor:reactor-test:3.6.2=testCompileClasspath,testRuntimeClasspath io.r2dbc:r2dbc-h2:1.0.0.RELEASE=testFixturesRuntimeClasspath,testRuntimeClasspath io.r2dbc:r2dbc-pool:1.0.1.RELEASE=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath @@ -113,7 +113,7 @@ org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath org.ow2.asm:asm:9.0=sonarlintCoreClasspath org.ow2.asm:asm:9.3=testCompileClasspath,testRuntimeClasspath org.projectlombok:lombok:1.18.30=annotationProcessor,compileClasspath,testAnnotationProcessor,testCompileClasspath,testFixturesAnnotationProcessor,testFixturesCompileClasspath -org.reactivestreams:reactive-streams:1.0.4=compileClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.reactivestreams:reactive-streams:1.0.4=compileClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle org.skyscreamer:jsonassert:1.5.1=testCompileClasspath,testRuntimeClasspath org.slf4j:jul-to-slf4j:2.0.11=testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath diff --git a/src/main/java/io/github/joselion/springr2dbcrelationships/RelationshipCallbacks.java b/src/main/java/io/github/joselion/springr2dbcrelationships/RelationshipCallbacks.java index 08d6b04..a71534f 100644 --- a/src/main/java/io/github/joselion/springr2dbcrelationships/RelationshipCallbacks.java +++ b/src/main/java/io/github/joselion/springr2dbcrelationships/RelationshipCallbacks.java @@ -1,8 +1,11 @@ package io.github.joselion.springr2dbcrelationships; import static java.util.function.Predicate.not; +import static reactor.function.TupleUtils.function; import java.util.List; +import java.util.function.Function; +import java.util.function.UnaryOperator; import java.util.stream.Stream; import org.reactivestreams.Publisher; @@ -11,17 +14,22 @@ import org.springframework.data.r2dbc.mapping.OutboundRow; import org.springframework.data.r2dbc.mapping.event.AfterConvertCallback; import org.springframework.data.r2dbc.mapping.event.AfterSaveCallback; +import org.springframework.data.r2dbc.mapping.event.BeforeConvertCallback; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.stereotype.Component; +import io.github.joselion.springr2dbcrelationships.annotations.ManyToOne; import io.github.joselion.springr2dbcrelationships.annotations.OneToMany; import io.github.joselion.springr2dbcrelationships.annotations.OneToOne; +import io.github.joselion.springr2dbcrelationships.helpers.Commons; import io.github.joselion.springr2dbcrelationships.helpers.Reflect; +import io.github.joselion.springr2dbcrelationships.processors.ManyToOneProcessor; import io.github.joselion.springr2dbcrelationships.processors.OneToManyProcessor; import io.github.joselion.springr2dbcrelationships.processors.OneToOneProcessor; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; +import reactor.util.context.Context; import reactor.util.function.Tuples; /** @@ -34,12 +42,13 @@ @Component public record RelationshipCallbacks( @Lazy R2dbcEntityTemplate template -) implements AfterConvertCallback, AfterSaveCallback { +) implements AfterConvertCallback, AfterSaveCallback, BeforeConvertCallback { @Override public Publisher onAfterConvert(final T entity, final SqlIdentifier table) { final var oneToOneProcessor = new OneToOneProcessor(this.template, entity, table); final var oneToManyProcessor = new OneToManyProcessor(this.template, entity, table); + final var manyToOneProcessor = new ManyToOneProcessor(this.template, entity, table); return Mono.just(entity) .map(T::getClass) @@ -56,6 +65,11 @@ public Publisher onAfterConvert(final T entity, final SqlIdentifier table) { .mapNotNull(field::getAnnotation) .flatMap(oneToManyProcessor.populate(field)) ) + .switchIfEmpty( + Mono.just(ManyToOne.class) + .mapNotNull(field::getAnnotation) + .flatMap(manyToOneProcessor.populate(field)) + ) .map(value -> Tuples.of(field, value)) ) .sequential() @@ -66,15 +80,7 @@ public Publisher onAfterConvert(final T entity, final SqlIdentifier table) { return Reflect.update(acc, field, value); }) .defaultIfEmpty(entity) - .contextWrite(ctx -> { - final var typeName = entity.getClass().getName(); - final var next = ctx.>>getOrEmpty(RelationshipCallbacks.class) - .map(List::stream) - .map(prev -> Stream.concat(prev, Stream.of(typeName))) - .map(Stream::toList) - .orElse(List.of(typeName)); - return ctx.put(RelationshipCallbacks.class, next); - }); + .contextWrite(this.addToContextStack(entity)); } @Override @@ -111,4 +117,40 @@ public Publisher onAfterSave(final T entity, final OutboundRow outboundRow, f }) .defaultIfEmpty(entity); } + + @Override + public Publisher onBeforeConvert(final T entity, final SqlIdentifier table) { + return Mono.just(entity) + .map(T::getClass) + .map(Class::getDeclaredFields) + .flatMapIterable(List::of) + .reduce(Mono.just(entity), (acc, field) -> + Mono.just(ManyToOne.class) + .mapNotNull(field::getAnnotation) + .filter(ManyToOne::persist) + .zipWith(acc) + .flatMap(function((annotation, nextEntity) -> { + final var manyToOneProcessor = new ManyToOneProcessor(this.template, nextEntity, table); + return manyToOneProcessor.persist(annotation, field); + })) + .map(Commons::cast) + .switchIfEmpty(acc) + ) + .flatMap(Function.identity()) + .defaultIfEmpty(entity) + .contextWrite(this.addToContextStack(entity)); + } + + private UnaryOperator addToContextStack(final T entity) { + return context -> { + final var typeName = entity.getClass().getName(); + final var next = context.>>getOrEmpty(RelationshipCallbacks.class) + .map(List::stream) + .map(prev -> Stream.concat(prev, Stream.of(typeName))) + .map(Stream::toList) + .orElse(List.of(typeName)); + + return context.put(RelationshipCallbacks.class, next); + }; + } } diff --git a/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/ManyToOne.java b/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/ManyToOne.java new file mode 100644 index 0000000..7e185de --- /dev/null +++ b/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/ManyToOne.java @@ -0,0 +1,50 @@ +package io.github.joselion.springr2dbcrelationships.annotations; + +import static java.lang.annotation.ElementType.ANNOTATION_TYPE; +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.annotation.Transient; + +/** + * Marks a field to have a many-to-one relationship. + * + *

This annotation also adds the {@link Transient @Transient} and + * {@link Value @Value("null")} annotations to the field. + */ +@Transient +@Documented +@Value("null") // NOSONAR +@Retention(RUNTIME) +@Target({FIELD, PARAMETER, ANNOTATION_TYPE}) +public @interface ManyToOne { + + /** + * Used to specify the name of the "foreing key" column in the current + * entity's table. This is usually not necessary if the name of the column + * matches the name of the parent table followed by an {@code _id} suffix. + * + *

For example, given the parent table is {@code country} and the child + * table is {@code city}. By default, the annotation will use {@code country_id} + * as the "foreign key" column of the {@code city} table. + * + * @return the name of the "foreing key" column of the entity table + */ + String foreignKey() default ""; + + /** + * Should the entity on the annotated field be persisted. 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. + * + * @return whether the annotated entity is persisted or not + */ + 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 1f86cd3..24a74f4 100644 --- a/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/OneToMany.java +++ b/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/OneToMany.java @@ -29,15 +29,15 @@ public @interface OneToMany { /** - * Used to specify the name of the "foreing key" column of the child table. + * Used to specify the name of the "foreing key" column on the child table. * This is usually not necessary if the name of the column matches the name * of the parent table followed by an {@code _id} suffix. * - *

For example, given the parent table is {@code person} and the child - * table is {@code phone}. By default, the annotation will look for - * the "foreign key" column {@code person_id} in the {@code phone} table. + *

For example, given the parent table is {@code country} and the child + * table is {@code city}. By default, the annotation will use {@code country_id} + * as the "foreign key" column of the {@code city} table. * - * @return the name of the "foreing key" column + * @return the name of the "foreing key" column in the child table */ String mappedBy() default ""; @@ -45,7 +45,7 @@ * Should the entity on the annotated field be readonly. I.e., the entity is * never persisted. Defaults to {@code false}. * - * @return whether the annotated entoty is readonly or not + * @return whether the annotated entity is readonly or not */ boolean readonly() default false; 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 a2a97af..36dcbc1 100644 --- a/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/OneToOne.java +++ b/src/main/java/io/github/joselion/springr2dbcrelationships/annotations/OneToOne.java @@ -41,7 +41,7 @@ boolean backReference() default false; /** - * Used to specify the name of the "foreing key" column of the child table. + * Used to specify the name of the "foreing key" column on the child table. * This is usually not necessary if the name of the column matches the name * of the parent table followed by an {@code _id} suffix. * @@ -57,7 +57,7 @@ * Should the entity on the annotated field be readonly. I.e., the entity is * never persisted. Defaults to {@code false}. * - * @return whether the annotated entoty is readonly or not + * @return whether the annotated entity is readonly or not */ boolean readonly() default false; } diff --git a/src/main/java/io/github/joselion/springr2dbcrelationships/processors/ManyToOneProcessor.java b/src/main/java/io/github/joselion/springr2dbcrelationships/processors/ManyToOneProcessor.java new file mode 100644 index 0000000..37a1b40 --- /dev/null +++ b/src/main/java/io/github/joselion/springr2dbcrelationships/processors/ManyToOneProcessor.java @@ -0,0 +1,80 @@ +package io.github.joselion.springr2dbcrelationships.processors; + +import static java.util.function.Predicate.not; +import static org.springframework.data.relational.core.query.Criteria.where; +import static org.springframework.data.relational.core.query.Query.query; + +import java.lang.reflect.Field; +import java.util.Optional; + +import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; +import org.springframework.data.relational.core.sql.SqlIdentifier; + +import io.github.joselion.springr2dbcrelationships.annotations.ManyToOne; +import io.github.joselion.springr2dbcrelationships.exceptions.RelationshipException; +import io.github.joselion.springr2dbcrelationships.helpers.Commons; +import io.github.joselion.springr2dbcrelationships.helpers.Reflect; +import reactor.core.publisher.Mono; + +/** + * The {@link ManyToOne} annotation processor. + * + * @param template the r2dbc entity template + * @param entity the processed field entity + * @param table the processed field entity table + */ +public record ManyToOneProcessor( + R2dbcEntityTemplate template, + Object entity, + SqlIdentifier table +) implements Processable { + + @Override + public Mono populate(final ManyToOne annotation, final Field field) { + final var fieldType = field.getType(); + final var foreignKey = Optional.of(annotation) + .map(ManyToOne::foreignKey) + .filter(not(String::isBlank)) + .orElseGet(() -> this.tableNameOf(fieldType).concat("_id")); + final var foreignField = Commons.toCamelCase(foreignKey); + final var parentId = this.idColumnOf(fieldType); + final var keyValue = Optional.of(this.entity) + .map(Reflect.getter(foreignField)) + .orElseThrow(() -> { + final var message = "Entity <%s> is missing foreign key in field: %s".formatted( + this.entity.getClass().getName(), + foreignField + ); + + return RelationshipException.of(message); + }); + + return this.checkCycles() + .flatMap(x -> + this.template + .select(this.domainFor(fieldType)) + .as(fieldType) + .matching(query(where(parentId).is(keyValue))) + .one() + ); + } + + @Override + public Mono persist(final ManyToOne annotation, final Field field) { + final var fieldType = field.getType(); + final var foreignKey = Optional.of(annotation) + .map(ManyToOne::foreignKey) + .filter(not(String::isBlank)) + .orElseGet(() -> this.tableNameOf(fieldType).concat("_id")); + final var foreignField = Commons.toCamelCase(foreignKey); + + return this.checkCycles() + .mapNotNull(x -> Reflect.getter(this.entity, field)) + .flatMap(this::upsert) + .map(saved -> { + final var savedId = this.idValueOf(saved); + final var newEntity = Reflect.update(this.entity, field, saved); + return Reflect.update(newEntity, foreignField, savedId); + }); + } +} diff --git a/src/main/java/io/github/joselion/springr2dbcrelationships/processors/OneToOneProcessor.java b/src/main/java/io/github/joselion/springr2dbcrelationships/processors/OneToOneProcessor.java index 1fd8f48..b647f26 100644 --- a/src/main/java/io/github/joselion/springr2dbcrelationships/processors/OneToOneProcessor.java +++ b/src/main/java/io/github/joselion/springr2dbcrelationships/processors/OneToOneProcessor.java @@ -5,13 +5,11 @@ import static org.springframework.data.relational.core.query.Query.query; import java.lang.reflect.Field; -import java.util.List; import java.util.Optional; import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; import org.springframework.data.relational.core.sql.SqlIdentifier; -import io.github.joselion.springr2dbcrelationships.RelationshipCallbacks; import io.github.joselion.springr2dbcrelationships.annotations.OneToOne; import io.github.joselion.springr2dbcrelationships.exceptions.RelationshipException; import io.github.joselion.springr2dbcrelationships.helpers.Commons; @@ -111,13 +109,4 @@ public Mono persist(final OneToOne annotation, final Field field) { ); }); } - - private Mono checkCycles() { - return Mono.deferContextual(ctx -> - Mono.just(RelationshipCallbacks.class) - .map(ctx::>get) - .filter(stack -> stack.size() == stack.stream().distinct().count()) - .map(List::size) - ); - } } diff --git a/src/main/java/io/github/joselion/springr2dbcrelationships/processors/Processable.java b/src/main/java/io/github/joselion/springr2dbcrelationships/processors/Processable.java index bd163ee..8849960 100644 --- a/src/main/java/io/github/joselion/springr2dbcrelationships/processors/Processable.java +++ b/src/main/java/io/github/joselion/springr2dbcrelationships/processors/Processable.java @@ -4,6 +4,7 @@ import java.lang.annotation.Annotation; import java.lang.reflect.Field; +import java.util.List; import java.util.Optional; import java.util.function.Function; @@ -16,6 +17,7 @@ import org.springframework.data.relational.core.sql.SqlIdentifier; import io.github.joselion.maybe.Maybe; +import io.github.joselion.springr2dbcrelationships.RelationshipCallbacks; import io.github.joselion.springr2dbcrelationships.annotations.ProjectionOf; import io.github.joselion.springr2dbcrelationships.helpers.Commons; import io.github.joselion.springr2dbcrelationships.helpers.Reflect; @@ -102,7 +104,7 @@ default Function> persist(Field field) { */ default Mono upsert(final S entity) { final var template = this.template(); - final var type = entity.getClass(); + final var type = this.domainFor(entity.getClass()); final var isNew = template .getConverter() .getMappingContext() @@ -219,4 +221,19 @@ default String columnNameOf(final Field field) { .getColumnName() .getReference(); } + + /** + * Returns a publisher with the count of entities processed in the actual + * subscription or empty if the processing has become cyclical. + * + * @return a publisher with the entities proccesing count or empty + */ + default Mono checkCycles() { + return Mono.deferContextual(ctx -> + Mono.just(RelationshipCallbacks.class) + .map(ctx::>get) + .filter(stack -> stack.size() == stack.stream().distinct().count()) + .map(List::size) + ); + } } diff --git a/src/test/java/io/github/joselion/springr2dbcrelationships/processors/ManyToOneProcessorTest.java b/src/test/java/io/github/joselion/springr2dbcrelationships/processors/ManyToOneProcessorTest.java new file mode 100644 index 0000000..5fc4f3f --- /dev/null +++ b/src/test/java/io/github/joselion/springr2dbcrelationships/processors/ManyToOneProcessorTest.java @@ -0,0 +1,111 @@ +package io.github.joselion.springr2dbcrelationships.processors; + +import static org.assertj.core.api.Assertions.assertThat; +import static reactor.function.TupleUtils.consumer; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import io.github.joselion.springr2dbcrelationships.models.city.City; +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; +import reactor.core.publisher.Mono; + +@IntegrationTest class ManyToOneProcessorTest { + + @Autowired + private CountryRepository countryRepo; + + @Autowired + private CityRepository cityRepo; + + @Autowired + private TownRepository townRepo; + + private final City newYork = City.of("New York"); + + private final City boston = City.of("Boston"); + + private final City chicago = City.of("Chicago"); + + private final Country usa = Country.of("United States of America"); + + @Nested class populate { + @Test void populates_the_field_with_the_parent_entity() { + countryRepo.save(usa) + .map(Country::id) + .zipWhen(id -> + Flux.just(newYork, boston, chicago) + .map(city -> city.withCountryId(id)) + .publish(cityRepo::saveAll) + .then(cityRepo.findByName(boston.name())) + ) + .as(TxStepVerifier::withRollback) + .assertNext(consumer((countryId, city) -> { + assertThat(city.countryId()).isEqualTo(countryId); + assertThat(city.country()).isNotNull(); + assertThat(city.country().id()).isEqualTo(countryId); + assertThat(city.country().cities()) + .allSatisfy(c -> assertThat(c.country()).isNull()) + .extracting(City::name) + .containsExactly(newYork.name(), boston.name(), chicago.name()); + })) + .verifyComplete(); + } + } + + @Nested class persist { + @Nested class when_the_annotation_does_not_configure_persist { + @Test void does_not_persist_the_annotated_field_by_default() { + countryRepo.save(usa) + .map(saved -> saved.withName("USA")) + .map(updated -> + boston + .withCountry(updated) + .withCountryId(updated.id()) + ) + .flatMap(cityRepo::save) + .map(City::countryId) + .flatMap(countryRepo::findById) + .as(TxStepVerifier::withRollback) + .assertNext(found -> { + assertThat(found.name()).isEqualTo(usa.name()); + assertThat(found.cities()) + .extracting(City::name) + .containsExactly(boston.name()); + }) + .verifyComplete(); + + } + } + + @Nested class when_the_annotation_sets_persist_to_true { + @Test void persists_the_annotated_field() { + final var manhattan = Town.of("Manhattan"); + + Mono.just(usa) + .map(manhattan::withCountry) + .flatMap(townRepo::save) + .map(Town::id) + .flatMap(townRepo::findById) + .zipWhen(saved -> countryRepo.findById(saved.countryId())) + .as(TxStepVerifier::withRollback) + .assertNext(consumer((town, country) -> { + assertThat(town.countryId()).isEqualTo(country.id()); + assertThat(town.country().id()).isEqualTo(country.id()); + assertThat(country.towns()) + .extracting(Town::name) + .containsExactly(manhattan.name()); + })) + .verifyComplete(); + } + } + } +} 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 61e5ef1..e6d7567 100644 --- a/src/test/java/io/github/joselion/springr2dbcrelationships/processors/OneToManyProcessorTest.java +++ b/src/test/java/io/github/joselion/springr2dbcrelationships/processors/OneToManyProcessorTest.java @@ -14,9 +14,9 @@ import io.github.joselion.springr2dbcrelationships.models.country.Country; import io.github.joselion.springr2dbcrelationships.models.country.CountryRepository; import io.github.joselion.testing.annotations.IntegrationTest; +import io.github.joselion.testing.transactional.TxStepVerifier; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import reactor.test.StepVerifier; @IntegrationTest class OneToManyProcessorTest { @@ -44,7 +44,7 @@ .publish(cityRepo::saveAll) ) .flatMap(countryRepo::findById) - .as(StepVerifier::create) + .as(TxStepVerifier::withRollback) .assertNext(found -> { assertThat(found.id()).isNotNull(); assertThat(found.cities()) @@ -65,7 +65,7 @@ Mono.just(cities) .map(usa::withCities) .flatMap(countryRepo::save) - .as(StepVerifier::create) + .as(TxStepVerifier::withRollback) .assertNext(saved -> { assertThat(saved.id()).isNotNull(); assertThat(saved.cities()) @@ -100,7 +100,7 @@ .map(Country::id) .flatMapMany(cityRepo::findByCountryId) .collectList() - .as(StepVerifier::create) + .as(TxStepVerifier::withRollback) .assertNext(cities -> { assertThat(cities) .isNotEmpty() @@ -130,7 +130,7 @@ .map(Country::id) .flatMapMany(cityRepo::findByCountryId) .collectList() - .as(StepVerifier::create) + .as(TxStepVerifier::withRollback) .assertNext(result -> { assertThat(result) .extracting(City::name) @@ -152,7 +152,7 @@ .map(Country::id) .flatMapMany(cityRepo::findByCountryId) .collectList() - .as(StepVerifier::create) + .as(TxStepVerifier::withRollback) .assertNext(found -> { assertThat(found).isEmpty(); }) @@ -172,7 +172,7 @@ .map(Country::id) .flatMapMany(cityRepo::findByCountryId) .collectList() - .as(StepVerifier::create) + .as(TxStepVerifier::withRollback) .assertNext(found -> { assertThat(found).isEmpty(); }) diff --git a/src/test/java/io/github/joselion/springr2dbcrelationships/processors/OneToOneProcessorTest.java b/src/test/java/io/github/joselion/springr2dbcrelationships/processors/OneToOneProcessorTest.java index aa97b5e..feb1778 100644 --- a/src/test/java/io/github/joselion/springr2dbcrelationships/processors/OneToOneProcessorTest.java +++ b/src/test/java/io/github/joselion/springr2dbcrelationships/processors/OneToOneProcessorTest.java @@ -11,8 +11,8 @@ import io.github.joselion.springr2dbcrelationships.models.phone.details.PhoneDetails; import io.github.joselion.springr2dbcrelationships.models.phone.details.PhoneDetailsRepository; import io.github.joselion.testing.annotations.IntegrationTest; +import io.github.joselion.testing.transactional.TxStepVerifier; import reactor.core.publisher.Mono; -import reactor.test.StepVerifier; @IntegrationTest class OneToOneProcessorTest { @@ -39,7 +39,7 @@ .flatMap(phoneDetailsRepo::save) ) .flatMap(phoneRepo::findById) - .as(StepVerifier::create) + .as(TxStepVerifier::withRollback) .assertNext(found -> { final var phoneDetails = found.phoneDetails(); @@ -62,7 +62,7 @@ .flatMap(phoneDetailsRepo::save) .map(PhoneDetails::id) .flatMap(phoneDetailsRepo::findById) - .as(StepVerifier::create) + .as(TxStepVerifier::withRollback) .assertNext(found -> { assertThat(found.phone()).isNotNull(); assertThat(found.phone().id()).isNotNull(); @@ -88,7 +88,7 @@ .map(Phone::phoneDetails) .mapNotNull(PhoneDetails::id) .flatMap(phoneDetailsRepo::findById) - .as(StepVerifier::create) + .as(TxStepVerifier::withRollback) .assertNext(found -> { assertThat(found.id()).isNotNull(); assertThat(found.provider()).isEqualTo(details.provider()); @@ -110,7 +110,7 @@ .map(Phone::phoneDetails) .map(PhoneDetails::id) .flatMap(phoneDetailsRepo::findById) - .as(StepVerifier::create) + .as(TxStepVerifier::withRollback) .assertNext(found -> { assertThat(found.id()).isNotNull(); assertThat(found.provider()).isEqualTo(details.provider()); @@ -132,7 +132,7 @@ .map(Phone::phoneDetails) .map(PhoneDetails::id) .flatMap(phoneDetailsRepo::findById) - .as(StepVerifier::create) + .as(TxStepVerifier::withRollback) .expectNextCount(0) .verifyComplete(); } @@ -153,7 +153,7 @@ .map(PhoneDetails::phone) .map(Phone::id) .flatMap(phoneRepo::findById) - .as(StepVerifier::create) + .as(TxStepVerifier::withRollback) .assertNext(found -> { assertThat(found.number()).isEqualTo(phone.number()); }) diff --git a/src/test/java/io/github/joselion/testing/annotations/IntegrationTest.java b/src/test/java/io/github/joselion/testing/annotations/IntegrationTest.java index 32ac1fd..d80f19b 100644 --- a/src/test/java/io/github/joselion/testing/annotations/IntegrationTest.java +++ b/src/test/java/io/github/joselion/testing/annotations/IntegrationTest.java @@ -10,12 +10,13 @@ import org.springframework.boot.test.context.SpringBootTest; import io.github.joselion.springr2dbcrelationships.FixtureApplication; +import io.github.joselion.testing.transactional.Transactions; @UnitTest @Inherited @Target(TYPE) @Retention(RUNTIME) -@SpringBootTest(classes = FixtureApplication.class) +@SpringBootTest(classes = {FixtureApplication.class, Transactions.class}) public @interface IntegrationTest { } diff --git a/src/test/java/io/github/joselion/testing/transactional/Transactions.java b/src/test/java/io/github/joselion/testing/transactional/Transactions.java new file mode 100644 index 0000000..fa85dd3 --- /dev/null +++ b/src/test/java/io/github/joselion/testing/transactional/Transactions.java @@ -0,0 +1,31 @@ +package io.github.joselion.testing.transactional; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.reactive.TransactionalOperator; + +import io.github.joselion.springr2dbcrelationships.helpers.StaticContext; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@Component +public class Transactions { + + static Mono withRollback(final Mono publisher) { + final var rxtx = StaticContext.getBean(TransactionalOperator.class); + + return rxtx.execute(tx -> { + tx.setRollbackOnly(); + return publisher; + }) + .next(); + } + + static Flux withRollback(final Flux publisher) { + final var rxtx = StaticContext.getBean(TransactionalOperator.class); + + return rxtx.execute(tx -> { + tx.setRollbackOnly(); + return publisher; + }); + } +} diff --git a/src/test/java/io/github/joselion/testing/transactional/TxStepVerifier.java b/src/test/java/io/github/joselion/testing/transactional/TxStepVerifier.java new file mode 100644 index 0000000..1a4302a --- /dev/null +++ b/src/test/java/io/github/joselion/testing/transactional/TxStepVerifier.java @@ -0,0 +1,33 @@ +package io.github.joselion.testing.transactional; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Transactional {@link StepVerifier} interface. + */ +public interface TxStepVerifier extends StepVerifier { + + /** + * Creates a transactional step verifier with rollback from a Mono publisher. + * + * @param the type of the publisher + * @param publisher the Mono publisher + * @return a transactional stepo verifier + */ + static FirstStep withRollback(final Mono publisher) { + return StepVerifier.create(publisher.as(Transactions::withRollback)); + } + + /** + * Creates a transactional step verifier with rollback from a Flux publisher. + * + * @param the type of the publisher + * @param publisher the Flux publisher + * @return a transactional stepo verifier + */ + static FirstStep withRollback(final Flux publisher) { + return StepVerifier.create(publisher.as(Transactions::withRollback)); + } +} diff --git a/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/helpers/StaticContext.java b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/helpers/StaticContext.java new file mode 100644 index 0000000..4457449 --- /dev/null +++ b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/helpers/StaticContext.java @@ -0,0 +1,20 @@ +package io.github.joselion.springr2dbcrelationships.helpers; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Component; + +@Component +public record StaticContext(ApplicationContext context) implements InitializingBean { + + private static StaticContext holder; + + public static T getBean(final Class beanType) { + return holder.context.getBean(beanType); + } + + @Override + public void afterPropertiesSet() throws Exception { + StaticContext.holder = this; // NOSONAR + } +} diff --git a/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/city/City.java b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/city/City.java index 738a1d7..f7f8468 100644 --- a/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/city/City.java +++ b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/city/City.java @@ -8,6 +8,8 @@ import org.eclipse.jdt.annotation.Nullable; import org.springframework.data.annotation.Id; +import io.github.joselion.springr2dbcrelationships.annotations.ManyToOne; +import io.github.joselion.springr2dbcrelationships.models.country.Country; import lombok.With; @With @@ -15,6 +17,7 @@ public record City( @Id @Nullable UUID id, LocalDateTime createdAt, UUID countryId, + @ManyToOne Country country, String name ) { @@ -23,6 +26,7 @@ public static City empty() { null, LocalDateTime.now(), UUID_ZERO, + Country.empty(), "" ); } diff --git a/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/city/CityRepository.java b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/city/CityRepository.java index 35e06cd..d54c1d9 100644 --- a/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/city/CityRepository.java +++ b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/city/CityRepository.java @@ -5,8 +5,11 @@ import org.springframework.data.repository.reactive.ReactiveCrudRepository; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; public interface CityRepository extends ReactiveCrudRepository { Flux findByCountryId(UUID countryId); + + Mono findByName(String name); } 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 28246dc..c323bbb 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 @@ -9,6 +9,7 @@ import io.github.joselion.springr2dbcrelationships.annotations.OneToMany; import io.github.joselion.springr2dbcrelationships.models.city.City; +import io.github.joselion.springr2dbcrelationships.models.town.Town; import lombok.With; @With @@ -16,7 +17,8 @@ public record Country( @Id @Nullable UUID id, LocalDateTime createdAt, String name, - @OneToMany List cities + @OneToMany List cities, + @OneToMany List towns ) { public static Country empty() { @@ -24,6 +26,7 @@ public static Country empty() { null, LocalDateTime.now(), "", + List.of(), List.of() ); } 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 new file mode 100644 index 0000000..a5ce813 --- /dev/null +++ b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/town/Town.java @@ -0,0 +1,37 @@ +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; + +import org.eclipse.jdt.annotation.Nullable; +import org.springframework.data.annotation.Id; + +import io.github.joselion.springr2dbcrelationships.annotations.ManyToOne; +import io.github.joselion.springr2dbcrelationships.models.country.Country; +import lombok.With; + +@With +public record Town( + @Id @Nullable UUID id, + LocalDateTime createdAt, + UUID countryId, + @ManyToOne(persist = true) Country country, + String name +) { + + public static Town empty() { + return new Town( + null, + LocalDateTime.now(), + UUID_ZERO, + Country.empty(), + "" + ); + } + + public static Town of(final String name) { + return Town.empty().withName(name); + } +} 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 new file mode 100644 index 0000000..f1e038f --- /dev/null +++ b/src/testFixtures/java/io/github/joselion/springr2dbcrelationships/models/town/TownRepository.java @@ -0,0 +1,9 @@ +package io.github.joselion.springr2dbcrelationships.models.town; + +import java.util.UUID; + +import org.springframework.data.repository.reactive.ReactiveCrudRepository; + +public interface TownRepository extends ReactiveCrudRepository { + +} diff --git a/src/testFixtures/resources/schema.sql b/src/testFixtures/resources/schema.sql index edc84c4..3a02f2f 100644 --- a/src/testFixtures/resources/schema.sql +++ b/src/testFixtures/resources/schema.sql @@ -10,7 +10,7 @@ CREATE TABLE phone_details( phone_id uuid NOT NULL, provider varchar(255) NOT NULL, technology varchar(255) NOT NULL, - FOREIGN KEY (phone_id) REFERENCES phone + FOREIGN KEY (phone_id) REFERENCES phone ON DELETE CASCADE ); CREATE TABLE country( @@ -24,5 +24,13 @@ CREATE TABLE city( created_at timestamp(9) NOT NULL DEFAULT localtimestamp(), country_id uuid NOT NULL, name varchar(255) NOT NULL, - FOREIGN KEY (country_id) REFERENCES country + FOREIGN KEY (country_id) REFERENCES country ON DELETE CASCADE +); + +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, + name varchar(255) NOT NULL, + FOREIGN KEY (country_id) REFERENCES country ON DELETE CASCADE );