Skip to content

Commit

Permalink
fix(core): OneToOne bidirectional persist (#25)
Browse files Browse the repository at this point in the history
  • Loading branch information
JoseLion authored Mar 5, 2024
1 parent ffc5f63 commit 11927b2
Show file tree
Hide file tree
Showing 15 changed files with 528 additions and 208 deletions.
22 changes: 13 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ Spring R2DBC is not an ORM, and _Spring R2DBC Relationships_ abides by the same

The `@OneToOne` annotation lets you mark fields to have a one-to-one relationship. The default behavior of the annotation is to populate the field after mapping the entity object, create/update the associated entity, and link the relation by setting the "foreign key" field in the proper entity.

You can use the annotation on both sides of the relationship to achieve a bidirectional association. However, doing so can also lead to unexpected updates when persisting the backreference. To solve this problem, you can set `backreference = true` in the annotation parameters.
You can use the annotation on both sides of the relationship to achieve a bidirectional association--the annotation handles cyclic persistence and population automatically. However, persisting from the parent side of the relationship is highly recommended since the parent entity will be unlinked (and optionally deleted) whenever the parent field in the child is `null`. So, suppose you configure the foreign key in the database to `ON DELETE CASCADE`. In that case, the persist operation will fail with a `TransientDataAccessResourceException` because the child entity gets deleted before the update can finish. Similarly, if the foreign key column is `NOT NULL`, you won't be able to have a child without its parent.

#### Example

Expand All @@ -97,7 +97,7 @@ CREATE TABLE phone(
number varchar(255) NOT NULL
);

CREATE TABLE phone_details(
CREATE TABLE details(
id uuid NOT NULL DEFAULT random_uuid() PRIMARY KEY,
created_at timestamp(9) NOT NULL DEFAULT localtimestamp(),
phone_id uuid NOT NULL,
Expand All @@ -107,26 +107,26 @@ CREATE TABLE phone_details(
);
```

You can use the `@OneToOne` annotation in both `Phone` and `PhoneDetails` entities:
You can use the `@OneToOne` annotation in both `Phone` and `Details` entities:

```java
@With
public record Phone(
@Id UUID id,
LocalDateTime createdAt,
String number,
@OneToOne PhoneDetails details
@OneToOne Details details
) {

// implementation omitted...
}

@With
public record PhoneDetails(
public record Details(
@Id UUID id,
LocalDateTime createdAt,
UUID phoneId,
@OneToOne(backreference = true) Phone phone,
@OneToOne(readonly = true) Phone phone,
String provider,
String technology
) {
Expand All @@ -135,8 +135,8 @@ public record PhoneDetails(
}
```

> [!NOTE]
> Notice that the `PhoneDetails` annotation is a backreference to `Phone`. Setting `backreference = true` will also make the annotated field entity read-only by default, meaning it's never persisted or linked. This behavior lets us safely persist a `Phone` instance containing a `PhoneDetails` field that, in turn, includes a `Phone` field, which might not be the same as the first `Phone` instance.
> [!NOTE]
> The annotation in `Details` is a backreference to `Phone`, and in the example details cannot exist without a phone. To prevent data integrity violations, we have configured the `Details#phone` field as read-only, meaning it's never persisted or linked whenever `Details` persists.
### @OneToMany

Expand All @@ -147,6 +147,10 @@ You can achieve bidirectional one-to-many relationships using the `@ManyToOne` a
> [!IMPORTANT]
> The annotation only supports `List<T>` types for now. We'll consider support for more collection types as the use cases present.
#### Handling orphans

Usually, one-to-one relationships have a parent-child configuration, meaning the child needs to have the parent assigned to it. By default, the annotation will delete the associated entity when it becomes an orphan or the child is no longer assigned to the parent. You can prevent this behavior by setting `keepOrphan = true` in the annotation parameters, in which case it will only remove the link of the orphan entity with the parent.

#### Example

Given the following tables present in your database:
Expand Down Expand Up @@ -288,7 +292,7 @@ However, there's also the case where you manage the associations from one side o

Spring Data allows us to use [Entity Projections](https://docs.spring.io/spring-data/relational/reference/repositories/projections.html) right out of the box--there's no need to add anything to the projected type. However, _Spring R2DBC Relationships_ needs the complete entity information so the relationship processors can obtain accurate metadata hidden in the projection.

To use projections on relationship types, you can annotate the type with `@ProjectionOf(..)` and provide the projected type its value parameter. For example, given a `Person` entity that contains a large number of properties, you can create a projection named `PersonMin` with minimum properties:
Using projections on relationship types, you can annotate the type with `@ProjectionOf(..)` and provide the projected type's value parameter. For example, given a `Person` entity that contains a large number of properties, you can create a projection named `PersonMin` with minimum properties:

```java
@With
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
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;
Expand Down Expand Up @@ -103,7 +102,7 @@ public Publisher<T> onAfterSave(final T entity, final OutboundRow outboundRow, f
Mono.just(OneToOne.class)
.mapNotNull(field::getAnnotation)
.filter(not(OneToOne::readonly))
.filter(not(OneToOne::backreference))
.filter(not(oneToOneProcessor.isBackReference(field)))
.flatMap(oneToOneProcessor.persist(field))
.switchIfEmpty(
Mono.just(OneToMany.class)
Expand Down Expand Up @@ -131,21 +130,32 @@ public Publisher<T> onAfterSave(final T entity, final OutboundRow outboundRow, f

@Override
public Publisher<T> onBeforeConvert(final T entity, final SqlIdentifier table) {
final var annotations = List.of(OneToOne.class, ManyToOne.class);

return Mono.just(entity)
.map(T::getClass)
.map(Class::getDeclaredFields)
.flatMapIterable(List::of)
.filter(field -> annotations.stream().anyMatch(field::isAnnotationPresent))
.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, this.context);
return manyToOneProcessor.persist(annotation, field);
}))
.map(Commons::<T>cast)
.switchIfEmpty(acc)
acc.flatMap(nextEntity -> {
final var oneToOneProcessor = new OneToOneProcessor(this.template, nextEntity, table, this.context);
final var manyToOneProcessor = new ManyToOneProcessor(this.template, nextEntity, table, this.context);

return Mono.just(OneToOne.class)
.mapNotNull(field::getAnnotation)
.filter(not(OneToOne::readonly))
.filter(oneToOneProcessor.isBackReference(field))
.flatMap(oneToOneProcessor.persist(field))
.switchIfEmpty(
Mono.just(ManyToOne.class)
.mapNotNull(field::getAnnotation)
.filter(ManyToOne::persist)
.flatMap(manyToOneProcessor.persist(field))
)
.switchIfEmpty(acc);
})
.map(Commons::<T>cast)
)
.flatMap(Function.identity())
.defaultIfEmpty(entity);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,19 @@
public @interface OneToOne {

/**
* Whether or not the annotated field is only a backreference in the
* one-to-one relationship. Defaults to {@code false}.
* Whether the orphan entity is preserved or not. Defaults to {@code false}.
*
* <p>Using the {@code @OneToOne} annotation in the child side instead of the
* parent is usually intended to have a backreference to the parent.
* Therefore, when this option is {@code true}, the {@link #readonly()}
* property is changed to {@code true} by default.
* <p>Usually, one-to-one relationships have a parent-child configuration,
* meaning the child needs to have the parent assigned to it. By default, the
* annotation will delete the associated entity when it becomes an orphan or
* the child is no longer assigned to the 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 entity with the parent.
*
* @return {@code true} if the field is a backreference, {@code false}
* @return {@code true} if orphan entities should be presereved, {@code false}
* otherwise
*/
boolean backreference() default false;
boolean keepOrphan() default false;

/**
* Specifies the name of the "foreign key" column in the associated table.
Expand All @@ -50,9 +51,9 @@
* <p>2. The column's name matches the name of the annotated field followed
* by an {@code _id} suffix.
*
* <p>For example, given a parent table {@code phone}, a child table {@code phone_details},
* and the annotated field {@code @OneToOne PhoneDetails details;}. The
* "foreign key" column of the {@code phone_details} table will be inferred
* <p>For example, given a parent table {@code phone}, a child table {@code details},
* and the annotated field {@code @OneToOne Details details;}. The
* "foreign key" column of the {@code details} table will be inferred
* as either {@code phone_id} using option (1) or as {@code details_id} using
* option (2).
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ public static <T> T getter(final Object target, final Method method) {
* fails to be invoked
*/
@Nullable
public static <T> T update(final T target, final Field field, final Object value) {
public static <T> T update(final T target, final Field field, final @Nullable Object value) {
final var targetType = field.getDeclaringClass();
final var fieldType = field.getType();

Expand Down Expand Up @@ -191,7 +191,7 @@ public static <T> T update(final T target, final Field field, final Object value
* @return a function that takes the target as argument and returns the
* updated target
*/
public static <T> Function<T, @Nullable T> update(final Field field, final Object value) {
public static <T> Function<T, @Nullable T> update(final Field field, final @Nullable Object value) {
return target -> Reflect.update(target, field, value);
}

Expand All @@ -208,7 +208,7 @@ public static <T> T update(final T target, final Field field, final Object value
* fails to be invoked
*/
@Nullable
public static <T> T update(final T target, final String fieldName, final Object value) {
public static <T> T update(final T target, final String fieldName, final @Nullable Object value) {
final var field = Maybe.of(fieldName)
.solve(target.getClass()::getDeclaredField)
.orThrow(ReflectException::of);
Expand All @@ -225,7 +225,7 @@ public static <T> T update(final T target, final String fieldName, final Object
* @return a function that takes the target as argument and returns the
* updated target
*/
public static <T> Function<T, @Nullable T> update(final String fieldName, final Object value) {
public static <T> Function<T, @Nullable T> update(final String fieldName, final @Nullable Object value) {
return target -> Reflect.update(target, fieldName, value);
}

Expand Down
Loading

0 comments on commit 11927b2

Please sign in to comment.