Skip to content

Commit

Permalink
feat(core): Add many-to-many relationships
Browse files Browse the repository at this point in the history
  • Loading branch information
JoseLion committed Feb 26, 2024
1 parent 1dc932e commit 643f05e
Show file tree
Hide file tree
Showing 22 changed files with 914 additions and 122 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import java.util.stream.Stream;

import org.reactivestreams.Publisher;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Lazy;
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
import org.springframework.data.r2dbc.mapping.OutboundRow;
Expand All @@ -18,11 +19,13 @@
import org.springframework.data.relational.core.sql.SqlIdentifier;
import org.springframework.stereotype.Component;

import io.github.joselion.springr2dbcrelationships.annotations.ManyToMany;
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.ManyToManyProcessor;
import io.github.joselion.springr2dbcrelationships.processors.ManyToOneProcessor;
import io.github.joselion.springr2dbcrelationships.processors.OneToManyProcessor;
import io.github.joselion.springr2dbcrelationships.processors.OneToOneProcessor;
Expand All @@ -38,19 +41,22 @@
*
* @param <T> the entity type
* @param template the r2dbc entity template
* @param context the Spring application context
*/
@Component
public record RelationshipCallbacks<T>(
@Lazy R2dbcEntityTemplate template
@Lazy R2dbcEntityTemplate template,
@Lazy ApplicationContext context
) implements AfterConvertCallback<T>, AfterSaveCallback<T>, BeforeConvertCallback<T> {

@Override
public Publisher<T> 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);
final var manyToManyProcessor = new ManyToManyProcessor(this.template, entity, table, this.context);

return Mono.just(entity)
return this.checkingCycles(entity)
.map(T::getClass)
.map(Class::getDeclaredFields)
.flatMapMany(Flux::fromArray)
Expand All @@ -70,6 +76,11 @@ public Publisher<T> onAfterConvert(final T entity, final SqlIdentifier table) {
.mapNotNull(field::getAnnotation)
.flatMap(manyToOneProcessor.populate(field))
)
.switchIfEmpty(
Mono.just(ManyToMany.class)
.mapNotNull(field::getAnnotation)
.flatMap(manyToManyProcessor.populate(field))
)
.map(value -> Tuples.of(field, value))
)
.sequential()
Expand All @@ -87,8 +98,9 @@ public Publisher<T> onAfterConvert(final T entity, final SqlIdentifier table) {
public Publisher<T> onAfterSave(final T entity, final OutboundRow outboundRow, final SqlIdentifier table) {
final var oneToOneProcessor = new OneToOneProcessor(this.template, entity, table);
final var oneToManyProcessor = new OneToManyProcessor(this.template, entity, table);
final var manyToManyProcessor = new ManyToManyProcessor(this.template, entity, table, this.context);

return Mono.just(entity)
return this.checkingCycles(entity)
.map(T::getClass)
.map(Class::getDeclaredFields)
.flatMapIterable(List::of)
Expand All @@ -106,6 +118,12 @@ public Publisher<T> onAfterSave(final T entity, final OutboundRow outboundRow, f
.filter(not(OneToMany::readonly))
.flatMap(oneToManyProcessor.persist(field))
)
.switchIfEmpty(
Mono.just(ManyToMany.class)
.mapNotNull(field::getAnnotation)
.filter(not(ManyToMany::readonly))
.flatMap(manyToManyProcessor.persist(field))
)
.map(value -> Tuples.of(field, value))
)
.sequential()
Expand All @@ -115,12 +133,13 @@ public Publisher<T> onAfterSave(final T entity, final OutboundRow outboundRow, f

return Reflect.update(acc, field, value);
})
.defaultIfEmpty(entity);
.defaultIfEmpty(entity)
.contextWrite(this.addToContextStack(entity));
}

@Override
public Publisher<T> onBeforeConvert(final T entity, final SqlIdentifier table) {
return Mono.just(entity)
return this.checkingCycles(entity)
.map(T::getClass)
.map(Class::getDeclaredFields)
.flatMapIterable(List::of)
Expand All @@ -142,15 +161,26 @@ public Publisher<T> onBeforeConvert(final T entity, final SqlIdentifier table) {
}

private UnaryOperator<Context> addToContextStack(final T entity) {
return context -> {
return ctx -> {
final var typeName = entity.getClass().getName();
final var next = context.<List<Class<?>>>getOrEmpty(RelationshipCallbacks.class)
final var stack = ctx.<List<String>>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);
return ctx.put(RelationshipCallbacks.class, stack);
};
}

private <S> Mono<S> checkingCycles(final S data) {
return Mono.deferContextual(ctx -> {
final var stack = ctx.<List<String>>getOrEmpty(RelationshipCallbacks.class);

return Mono.justOrEmpty(stack)
.defaultIfEmpty(List.of())
.filter(s -> s.size() == s.stream().distinct().count())
.map(x -> data);
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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.CreatedDate;
import org.springframework.data.annotation.Transient;
import org.springframework.data.domain.Auditable;
import org.springframework.data.domain.Sort.Direction;

/**
* Marks a field to have a many-to-many relationship.
*
* <p>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 ManyToMany {

/**
* Used to specify the name of the join table responsible for the
* many-to-many relationship between two tables. This is usually optional if
* the name of the join table matches the names of both related tables joined
* by an underscore (in any order).
*
* <p>For example, given a table {@code author} and a table {@code book}, the
* default join table for the relationship can be either {@code author_book}
* or {@code book_author}.
*
* @return the name of the relationship join table
*/
String joinTable() default "";

/**
* Used to specify the name of the "foreign key" column that maps the join
* table with the linked table. This is usually optional if the column's name
* matches the linked table name followed by an {@code _id} suffix.
*
* <p>For example, given a table {@code author} and a table {@code book}, and
* given the annotation is used in a field of {@code author}'s entity, we can
* say the linked table is {@code book} and its "foreign key" column in the
* join table will be {@code book_id} by default.
*
* @return the name of the column linking the join table
*/
String linkedBy() default "";

/**
* 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
* the column's name matches the entity's table name followed by an {@code _id}
* suffix.
*
* <p>For example, given a table {@code author} and a table {@code book}, and
* given the annotation is used in a field of {@code author}'s entity, the
* "foreign key" column in the join table will be {@code author_id} by default.
*
* @return the name of the column mapping the join table
*/
String mappedBy() default "";

/**
* Should the entities on the annotated field be readonly. I.e., the entities
* are never persisted. Defaults to {@code false}.
*
* @return whether the annotated entity is readonly or not
*/
boolean readonly() default false;

/**
* The column used to sort the populated children entities. When not
* specified, the annotation tries to find the field associated to
* {@link Auditable#getCreatedDate()} or annotated with {@link CreatedDate}.
* If none can be resolved, falls back to {@code "created_at"} by default.
*
* <p>If all of the above fails, the children will be unsorted.
*
* @return the sorting column name
*/
String sortBy() default "";

/**
* The direction to sort the populated children entities. Defaults to
* {@link Direction#DESC ascending} direction.
*
* @return the sort direction
*/
Direction sortIn() default Direction.DESC;
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,15 @@
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.
* Used to specify the name of the "foreign key" column in the current
* entity's table. This is usually optional if the name of the column matches
* the name of the parent table followed by an {@code _id} suffix.
*
* <p>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
* @return the name of the "foreign key" column of the entity table
*/
String foreignKey() default "";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,21 @@
public @interface OneToMany {

/**
* 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.
* 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
* parent table followed by an {@code _id} suffix.
*
* <p>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 in the child table
* @return the name of the "foreign key" column in the child table
*/
String mappedBy() default "";

/**
* Should the entity on the annotated field be readonly. I.e., the entity is
* never persisted. Defaults to {@code false}.
* Should the entities on the annotated field be readonly. I.e., the entities
* are never persisted. Defaults to {@code false}.
*
* @return whether the annotated entity is readonly or not
*/
Expand All @@ -63,9 +63,9 @@

/**
* The direction to sort the populated children entities. Defaults to
* {@link Direction#ASC ascending} direction.
* {@link Direction#DESC ascending} direction.
*
* @return the sort direction
*/
Direction sortIn() default Direction.ASC;
Direction sortIn() default Direction.DESC;
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,15 @@
boolean backReference() default false;

/**
* 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.
* 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
* parent table followed by an {@code _id} suffix.
*
* <p>For example, given the parent table is {@code phone} and the child
* table is {@code phone_details}. By default, the annotation will look for
* the "foreign key" column {@code phone_id} in the {@code phone_details} table.
*
* @return the name of the "foreing key" column
* @return the name of the "foreign key" column
*/
String mappedBy() default "";

Expand Down
Loading

0 comments on commit 643f05e

Please sign in to comment.