Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): Add many-to-one relationships #9

Merged
merged 1 commit into from
Feb 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ dependencies {
sonarlintCorePlugins(libs.sonarlint.java)

implementation(libs.maybe)
implementation(libs.reactor.extra)

testFixturesAnnotationProcessor(libs.lombok)
testFixturesCompileOnly(libs.lombok)
Expand All @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions gradle.lockfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;

/**
Expand All @@ -34,12 +42,13 @@
@Component
public record RelationshipCallbacks<T>(
@Lazy R2dbcEntityTemplate template
) implements AfterConvertCallback<T>, AfterSaveCallback<T> {
) 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);

return Mono.just(entity)
.map(T::getClass)
Expand All @@ -56,6 +65,11 @@ public Publisher<T> 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()
Expand All @@ -66,15 +80,7 @@ public Publisher<T> 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.<List<Class<?>>>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
Expand Down Expand Up @@ -111,4 +117,40 @@ public Publisher<T> onAfterSave(final T entity, final OutboundRow outboundRow, f
})
.defaultIfEmpty(entity);
}

@Override
public Publisher<T> 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::<T>cast)
.switchIfEmpty(acc)
)
.flatMap(Function.identity())
.defaultIfEmpty(entity)
.contextWrite(this.addToContextStack(entity));
}

private UnaryOperator<Context> addToContextStack(final T entity) {
return context -> {
final var typeName = entity.getClass().getName();
final var next = context.<List<Class<?>>>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);
};
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <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 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.
*
* <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
*/
String foreignKey() default "";

/**
* Should the entity on the annotated field be persisted. Defaults to {@code false}.
*
* <p>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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,23 +29,23 @@
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.
*
* <p>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.
* <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
* @return the name of the "foreing 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}.
*
* @return whether the annotated entoty is readonly or not
* @return whether the annotated entity is readonly or not
*/
boolean readonly() default false;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<ManyToOne, Object> {

@Override
public Mono<Object> 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<Object> 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);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -111,13 +109,4 @@ public Mono<Object> persist(final OneToOne annotation, final Field field) {
);
});
}

private Mono<Integer> checkCycles() {
return Mono.deferContextual(ctx ->
Mono.just(RelationshipCallbacks.class)
.map(ctx::<List<?>>get)
.filter(stack -> stack.size() == stack.stream().distinct().count())
.map(List::size)
);
}
}
Loading
Loading