diff --git a/README.md b/README.md index bb6ee5e..cb5a3cf 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,8 @@ public record City( > [!Note] > Notice that having the `countryId` field, which maps to the foreign key column, is required for the relationship to work properly. +If the annotation is `persist = true` and the field is `null` upon persistence, the annotation shall never delete the parent because it can still have other linked children. However, it will change the foreign key to `null` to unlink the children from the parent. + ### ManyToMany The `@ManyToMany` annotation lets you mark fields to have a many-to-many relationship. The default behavior of the annotation is to populate the field after mapping the entity object, create/update the associated entities, and link the relations on the join table. The annotation uses the join table transparently, meaning you **don't need** to create an entity type for the join table on your codebase. diff --git a/src/main/java/io/github/joselion/springr2dbcrelationships/processors/ManyToOneProcessor.java b/src/main/java/io/github/joselion/springr2dbcrelationships/processors/ManyToOneProcessor.java index 44147fa..e6be6d2 100644 --- a/src/main/java/io/github/joselion/springr2dbcrelationships/processors/ManyToOneProcessor.java +++ b/src/main/java/io/github/joselion/springr2dbcrelationships/processors/ManyToOneProcessor.java @@ -7,8 +7,10 @@ import java.lang.reflect.Field; import java.util.List; import java.util.Optional; +import java.util.function.Function; import java.util.stream.Stream; +import org.eclipse.jdt.annotation.Nullable; import org.springframework.context.ApplicationContext; import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; import org.springframework.data.relational.core.sql.SqlIdentifier; @@ -20,6 +22,7 @@ import io.github.joselion.springr2dbcrelationships.helpers.Reflect; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.util.context.Context; /** * The {@link ManyToOne} annotation processor. @@ -43,6 +46,7 @@ public Mono populate(final ManyToOne annotation, final Field field) { final var fieldType = this.domainFor(fieldProjection); final var byTable = this.tableNameOf(fieldType).concat("_id"); final var byField = Commons.toSnakeCase(field.getName()).concat("_id"); + final var parentId = this.idColumnOf(fieldType); final var foreignField = Optional.of(annotation) .map(ManyToOne::foreignKey) .map(Commons::toCamelCase) @@ -57,33 +61,17 @@ public Mono populate(final ManyToOne annotation, final Field field) { .formatted(entityType.getSimpleName(), byTable, byField); return RelationshipException.of(message); }); - final var parentId = this.idColumnOf(fieldType); return Mono.just(this.entity) .mapNotNull(Reflect.getter(foreignField)) - .flatMap(fkValue -> - Mono.deferContextual(ctx -> { - final var store = ctx.>getOrDefault(OneToMany.class, List.of()); - - return Flux.fromIterable(store) - .filter(fkValue::equals) - .collectList() - .filter(List::isEmpty) - .map(x -> fkValue); - }) - ) + .flatMap(this::breakingCycles) .flatMap(fkValue -> this.template .select(fieldType) .as(fieldProjection) .matching(query(where(parentId).is(fkValue))) .one() - .contextWrite(ctx -> { - final var store = ctx.>getOrDefault(ManyToOne.class, List.of()); - final var next = Stream.concat(store.stream(), Stream.of(fkValue)).toList(); - - return ctx.put(ManyToOne.class, next); - }) + .contextWrite(this.storeWith(fkValue)) ); } @@ -94,15 +82,57 @@ public Mono persist(final ManyToOne annotation, final Field field) { .map(ManyToOne::foreignKey) .filter(not(String::isBlank)) .orElseGet(() -> this.tableNameOf(fieldType).concat("_id")); - final var foreignField = Commons.toCamelCase(foreignKey); + final var fkFieldName = Commons.toCamelCase(foreignKey); + final var fkValue = Reflect.getter(this.entity, fkFieldName); return Mono.just(this.entity) .mapNotNull(Reflect.getter(field)) + .flatMap(this.breakingCyclesWith(fkValue)) .flatMap(this::save) .map(saved -> { final var savedId = this.idValueOf(saved); final var newEntity = Reflect.update(this.entity, field, saved); - return Reflect.update(newEntity, foreignField, savedId); - }); + return Reflect.update(newEntity, fkFieldName, savedId); + }) + .switchIfEmpty( + Mono.just(this.entity) + .flatMap(this.breakingCyclesWith(fkValue)) + .map(Reflect.update(fkFieldName, null)) + .map(Reflect.update(field, null)) + ) + .contextWrite(this.storeWith(fkValue)); + } + + private Function> breakingCyclesWith(final @Nullable T fkValue) { + return value -> Mono.deferContextual(ctx -> { + if (fkValue != null) { + final var store = ctx.>getOrDefault(OneToMany.class, List.of()); + + return Flux.fromIterable(store) + .filter(fkValue::equals) + .collectList() + .filter(List::isEmpty) + .map(x -> value); + } + + return Mono.just(value); + }); + } + + private Mono breakingCycles(final T fkValue) { + return this.breakingCyclesWith(fkValue).apply(fkValue); + } + + private Function storeWith(final @Nullable T fkValue) { + return ctx -> { + if (fkValue != null) { + final var store = ctx.>getOrDefault(ManyToOne.class, List.of()); + final var next = Stream.concat(store.stream(), Stream.of(fkValue)).toList(); + + return ctx.put(ManyToOne.class, next); + } + + return ctx; + }; } } 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 b335571..231c810 100644 --- a/src/main/java/io/github/joselion/springr2dbcrelationships/processors/OneToManyProcessor.java +++ b/src/main/java/io/github/joselion/springr2dbcrelationships/processors/OneToManyProcessor.java @@ -3,16 +3,17 @@ 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 static org.springframework.data.relational.core.query.Update.update; import java.lang.reflect.Field; import java.util.List; import java.util.Optional; +import java.util.function.Function; import java.util.stream.Stream; 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; @@ -22,6 +23,7 @@ import io.github.joselion.springr2dbcrelationships.helpers.Reflect; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.util.context.Context; /** * The {@link OneToMany} annotation processor. @@ -55,17 +57,7 @@ public Mono> populate(final OneToMany annotation, final Field field) { return Mono.just(this.entity) .mapNotNull(this::idValueOf) - .flatMap(entityId -> - Mono.deferContextual(ctx -> { - final var store = ctx.>getOrDefault(OneToMany.class, List.of()); - - return Flux.fromIterable(store) - .filter(entityId::equals) - .collectList() - .filter(List::isEmpty) - .map(x -> entityId); - }) - ) + .flatMap(this::breackingCycles) .flatMap(entityId -> this.template .select(innerType) @@ -73,12 +65,7 @@ public Mono> populate(final OneToMany annotation, final Field field) { .matching(query(where(mappedBy).is(entityId)).sort(byColumn)) .all() .collectList() - .contextWrite(ctx -> { - final var store = ctx.>getOrDefault(OneToMany.class, List.of()); - final var next = Stream.concat(store.stream(), Stream.of(entityId)).toList(); - - return ctx.put(OneToMany.class, next); - }) + .contextWrite(this.storeWith(entityId)) ); } @@ -92,6 +79,7 @@ public Mono> persist(final OneToMany annotation, final Field field) { return Mono.just(this.entity) .mapNotNull(this::idValueOf) + .flatMap(this::breackingCycles) .flatMap(entityId -> { final var innerType = this.domainFor(Reflect.innerTypeOf(field)); final var mappedBy = Optional.of(annotation) @@ -142,14 +130,36 @@ public Mono> persist(final OneToMany annotation, final Field field) { return this.template .update(innerType) .matching(allOrphans) - .apply(Update.update(mappedBy, null)); + .apply(update(mappedBy, null)); } return this.template .delete(innerType) .matching(allOrphans) .all(); - }); + }) + .contextWrite(this.storeWith(entityId)); }); } + + private Mono breackingCycles(final T entityId) { + return Mono.deferContextual(ctx -> { + final var store = ctx.>getOrDefault(OneToMany.class, List.of()); + + return Flux.fromIterable(store) + .filter(entityId::equals) + .collectList() + .filter(List::isEmpty) + .map(x -> entityId); + }); + } + + private Function storeWith(final Object entityId) { + return ctx -> { + final var store = ctx.>getOrDefault(OneToMany.class, List.of()); + final var next = Stream.concat(store.stream(), Stream.of(entityId)).toList(); + + return ctx.put(OneToMany.class, next); + }; + } } 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 4566cf2..1e66c0a 100644 --- a/src/main/java/io/github/joselion/springr2dbcrelationships/processors/OneToOneProcessor.java +++ b/src/main/java/io/github/joselion/springr2dbcrelationships/processors/OneToOneProcessor.java @@ -53,27 +53,27 @@ public Mono populate(final OneToOne annotation, final Field field) { return Mono.just(this.entity) .mapNotNull(Reflect.getter(mappedField)) - .flatMap(this::breakOnCycle) + .flatMap(this::breakingCycles) .flatMap(fkValue -> this.template .select(fieldType) .as(fieldProjection) .matching(query(where(parentId).is(fkValue))) .one() - .contextWrite(this.storeOf(fkValue)) + .contextWrite(this.storeWith(fkValue)) ); } return Mono.just(this.entity) .mapNotNull(this::idValueOf) - .flatMap(this::breakOnCycle) + .flatMap(this::breakingCycles) .flatMap(entityId -> this.template .select(fieldType) .as(fieldProjection) .matching(query(where(mappedBy).is(entityId))) .one() - .contextWrite(this.storeOf(entityId)) + .contextWrite(this.storeWith(entityId)) ); } @@ -90,7 +90,7 @@ public Mono persist(final OneToOne annotation, final Field field) { return Mono.just(this.entity) .mapNotNull(Reflect.getter(field)) - .flatMap(this::breakOnCycle) + .flatMap(this::breakingCycles) .flatMap(this::save) .flatMap(saved -> { final var savedId = this.idValueOf(saved); @@ -98,12 +98,12 @@ public Mono persist(final OneToOne annotation, final Field field) { return Mono.just(this.entity) .map(Reflect.update(mappedField, savedId)) .map(Reflect.update(field, saved)) - .contextWrite(this.storeOf(savedId)); + .contextWrite(this.storeWith(savedId)); }) .switchIfEmpty( Mono.just(this.entity) .map(Reflect.update(mappedField, null)) - .flatMap(this::breakOnCycle) + .flatMap(this::breakingCycles) .delayUntil(x -> { if (!annotation.keepOrphan() && mappedId != null) { final var parentId = this.idColumnOf(fieldType); @@ -118,12 +118,12 @@ public Mono persist(final OneToOne annotation, final Field field) { }) ) .defaultIfEmpty(this.entity) - .contextWrite(this.storeOf(mappedId)); + .contextWrite(this.storeWith(mappedId)); } return Mono.just(this.entity) .mapNotNull(this::idValueOf) - .flatMap(this::breakOnCycle) + .flatMap(this::breakingCycles) .flatMap(entityId -> Mono.just(this.entity) .mapNotNull(Reflect.getter(field)) @@ -146,7 +146,7 @@ public Mono persist(final OneToOne annotation, final Field field) { ) .then(Mono.empty()) ) - .contextWrite(this.storeOf(entityId)) + .contextWrite(this.storeWith(entityId)) ); } @@ -213,7 +213,7 @@ private String inferMappedBy(final OneToOne annotation, final Field field) { }); } - private Mono breakOnCycle(final Object entityId) { + private Mono breakingCycles(final Object entityId) { return Mono.deferContextual(ctx -> { final var store = ctx.>getOrDefault(OneToOne.class, List.of()); final var distinct = store.stream().distinct().toList(); @@ -230,7 +230,7 @@ private Mono breakOnCycle(final Object entityId) { }); } - private Function storeOf(final @Nullable Object entityId) { + private Function storeWith(final @Nullable Object entityId) { return ctx -> { if (entityId != null) { final var store = ctx.getOrDefault(OneToOne.class, List.of()); 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 f078a9b..ccd6b1e 100644 --- a/src/test/java/io/github/joselion/springr2dbcrelationships/processors/ManyToOneProcessorTest.java +++ b/src/test/java/io/github/joselion/springr2dbcrelationships/processors/ManyToOneProcessorTest.java @@ -39,6 +39,8 @@ private final String chicago = "Chicago"; + private final String manhattan = "Manhattan"; + @Nested class populate { @Test void populates_the_field_with_the_parent_entity() { countryRepo.save(usa) @@ -52,12 +54,13 @@ .then(cityRepo.findByName(boston)) ) .as(TxStepVerifier::withRollback) - .assertNext(consumer((countryId, found) -> { - assertThat(found.countryId()).isEqualTo(countryId); - assertThat(found.country()).isNotNull(); - assertThat(found.country().id()).isEqualTo(countryId); - assertThat(found.country().cities()) - .allSatisfy(city -> assertThat(city.country()).isNull()) + .assertNext(consumer((countryId, city) -> { + assertThat(city.countryId()).isEqualTo(countryId); + assertThat(city.country()).isNotNull(); + assertThat(city.country().id()).isEqualTo(countryId); + System.err.println("*********** " + city.country()); + assertThat(city.country().cities()) + .allSatisfy(c -> assertThat(c.country()).isNull()) .extracting(City::name) .containsExactly(chicago, boston, newYork); })) @@ -66,7 +69,7 @@ } @Nested class persist { - @Nested class when_the_annotation_does_not_configure_persist { + @Nested class when_the_persist_option_is_false { @Test void does_not_persist_the_annotated_field_by_default() { countryRepo.save(usa) .map(saved -> saved.withName("USA")) @@ -90,25 +93,66 @@ } } - @Nested class when_the_annotation_sets_persist_to_true { - @Test void persists_the_annotated_field() { - final var manhattan = Town.of("Manhattan"); + @Nested class when_the_persist_option_is_true { + @Nested class and_the_parent_does_not_exist { + @Test void creates_the_parent_entity() { + Mono.just(usa) + .map(Town.of(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); + })) + .verifyComplete(); + } + } - 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(); + @Nested class and_the_parent_does_already_exists { + @Test void updates_the_parent_entity() { + Mono.just(usa) + .map(Town.of(manhattan)::withCountry) + .flatMap(townRepo::save) + .map(town -> town.withCountryBy(country -> country.withName("USA"))) + .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.name()).isEqualTo("USA"); + assertThat(country.towns()) + .extracting(Town::name) + .containsExactly(manhattan); + })) + .verifyComplete(); + } + } + + @Nested class and_the_parent_is_null { + @Test void unlinks_the_entity_from_the_parent() { + Mono.just(usa) + .map(Town.of(manhattan)::withCountry) + .flatMap(townRepo::save) + .map(town -> town.withCountry(null)) + .flatMap(townRepo::save) + .map(Town::id) + .flatMap(townRepo::findById) + .as(TxStepVerifier::withRollback) + .assertNext(town -> { + assertThat(town.country()).isNull(); + assertThat(town.countryId()).isNull(); + }) + .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 c0f30f8..5575f36 100644 --- a/src/test/java/io/github/joselion/springr2dbcrelationships/processors/OneToManyProcessorTest.java +++ b/src/test/java/io/github/joselion/springr2dbcrelationships/processors/OneToManyProcessorTest.java @@ -313,5 +313,31 @@ } } } + + @Nested class when_the_children_persist_option_is_true { + @Test void creates_the_children_entities_breaking_cycles() { + Flux.just(manhattan, albuquerque, springfield) + .map(Town::of) + .delayElements(Duration.ofMillis(1)) + .collectList() + .map(usa::withTowns) + .flatMap(countryRepo::save) + .map(Country::id) + .flatMap(countryRepo::findById) + .zipWhen(x -> countryRepo.count()) + .as(TxStepVerifier::withRollback) + .assertNext(consumer((country, countryCount) -> { + assertThat(countryCount).isOne(); + assertThat(country.towns()) + .allSatisfy(town -> { + assertThat(town.countryId()).isEqualTo(country.id()); + assertThat(town.country()).isNull(); + }) + .extracting(Town::name) + .containsExactly(springfield, albuquerque, manhattan); + })) + .verifyComplete(); + } + } } }