Skip to content

Commit

Permalink
feat(core); Add linkOnly option to one-to-many and many-to-many (#22)
Browse files Browse the repository at this point in the history
  • Loading branch information
JoseLion authored Mar 1, 2024
1 parent b282dd7 commit 8a87886
Show file tree
Hide file tree
Showing 12 changed files with 254 additions and 74 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,25 @@
@Target({FIELD, PARAMETER, ANNOTATION_TYPE})
public @interface ManyToMany {

/**
* Whether "orphan" entities should be deleted or not. Defaults to {@code false}.
*
* <p>Usually, many-to-many relationships are not mutually exclusive to each
* other, meaning that one can exist without the other even when they are not
* linked in their join table. In this context, "orphans" refers to all
* entities no longer linked to the current entity. By default, the
* annotation will only delete the links to the "orphans" entities in the
* join table. Setting this option to {@code true} will also delete the
* "orphan" entities.
*
* @return {@code true} if "orphan" entities should also be deleted, {@code false}
* otherwise
* @apiNote given the nature of many-to-many relationships, setting this
* option to {@code true} is highly discouraged as it can produce
* unexpected results, especially in bidirectional associations
*/
boolean deleteOrphans() default false;

/**
* Used to specify the name of the join table responsible for the
* many-to-many relationship between two tables. This is usually optional if
Expand Down Expand Up @@ -57,23 +76,17 @@
String linkedBy() default "";

/**
* Whether "orphan" entities should be deleted or not. Defaults to {@code false}.
*
* <p>Usually, many-to-many relationships are not mutually exclusive to each
* other, meaning that one can exist without the other even when they are not
* linked in their join table. In this context, "orphans" refers to all
* entities no longer linked to the current entity. By default, the
* annotation will only delete the links to the "orphans" entities in the
* join table. Setting this option to {@code true} will also delete the
* "orphan" entities.
* Whether the associated entities are only linked to the join table or not.
* Defaults to {@code false}.
*
* @return {@code true} if "orphan" entities should also be deleted, {@code false}
* <p>Link-only means the associated entities already exist. The annotation
* will only create the link in the join table column when required. The
* associated entities are never updated.
*
* @return {@code true} if the associated entities are link-only, {@code false}
* otherwise
* @apiNote given the nature of many-to-many relationships, setting this
* option to {@code true} is highly discouraged as it can produce
* unexpected results, especially in bidirectional associations
*/
boolean deleteOrphans() default false;
boolean linkOnly() default false;

/**
* Used to specify the name of the "foreign key" column that maps the
Expand All @@ -90,10 +103,10 @@
String mappedBy() default "";

/**
* Whether the entities on the annotated field are readonly or not. I.e., the
* "children" entities are never persisted. Defaults to {@code false}.
* Whether the associated entities are read-only or not, meaning they are
* never persisted or linked. Defaults to {@code false}.
*
* @return {@code true} if the children entities should be readonly, {@code false}
* @return {@code true} if the associated entities are read-only, {@code false}
* otherwise
*/
boolean readonly() default false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,16 @@
String foreignKey() default "";

/**
* Should the entity on the annotated field be persisted. Defaults to {@code false}.
* Whether the associated entity persists or not. 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.
* <p>Many-to-one relationships are a backreference of a one-to-many
* relationship. They are usually expected to be link-only, meaning the
* parent should exist to link the entity through their {@link #foreignKey},
* and no changes are made to the parent entity. Setting this option to
* {@code true} creates/updates the parent before linking the entity.
*
* @return whether the annotated entity is persisted or not
* @return {@code true} if the associated entity persists, {@code false}
* otherwise
*/
boolean persist() default false;
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,19 @@
*/
boolean keepOrphans() default false;

/**
* Whether children entities are only linked to the parent or not. Defaults to
* {@code false}.
*
* <p>Link-only means the children entities should already exist. The
* annotation will only update the "foreign key" link on each entity when
* required. Other values in the children entities are never updated.
*
* @return {@code true} if children entities are only linked, {@code false}
* otherwise
*/
boolean linkOnly() default false;

/**
* 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,11 @@
String mappedBy() default "";

/**
* Should the entity on the annotated field be readonly. I.e., the entity is
* never persisted. Defaults to {@code false}.
* Whether the associated entity is read-only or not, meaning it's never
* persisted or linked. Defaults to {@code false}.
*
* @return whether the annotated entity is readonly or not
* @return {@code true} if the associated entity is read-only, {@code false}
* otherwise
*/
boolean readonly() default false;
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package io.github.joselion.springr2dbcrelationships.processors;

import static java.util.Arrays.stream;
import static java.util.function.Predicate.isEqual;
import static java.util.function.Predicate.not;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toMap;
Expand Down Expand Up @@ -122,7 +121,7 @@ public Mono<List<?>> populate(final ManyToMany annotation, final Field field) {

@Override
public Mono<List<?>> persist(final ManyToMany annotation, final Field field) {
final var values = Reflect.<List<?>>getter(this.entity, field);
final var values = Reflect.<List<Object>>getter(this.entity, field);

if (values == null) {
return Mono.empty();
Expand Down Expand Up @@ -180,51 +179,49 @@ public Mono<List<?>> persist(final ManyToMany annotation, final Field field) {
.map(x -> List.of());
}

return Flux.fromIterable(values)
.map(value ->
stream(value.getClass().getDeclaredFields())
.filter(vf -> vf.isAnnotationPresent(ManyToMany.class))
.filter(vf -> this.domainFor(Reflect.innerTypeOf(vf)).equals(entityType))
.reduce(
value,
(acc, vf) -> Reflect.update(acc, vf, null),
(a, b) -> b
)
)
.flatMap(this::save)
.collectList()
.delayUntil(items -> {
final var newIds = values.stream()
.filter(this::isNew)
.map(this::idValueOf)
.toList();

return Flux.fromIterable(items)
.filter(item -> newIds.stream().anyMatch(not(isEqual(this.idValueOf(item)))))
return Mono.just(annotation)
.filter(not(ManyToMany::linkOnly))
.flatMap(x ->
Flux.fromIterable(values)
.map(this::toPreventingCycles)
.flatMap(this::save)
.collectList()
.filter(not(List::isEmpty))
.flatMap(newItems -> {
final var paramsTemplate = IntStream.range(2, newItems.size() + 2)
.mapToObj("($1, $%d)"::formatted)
.collect(joining(", "));
final var statement = "INSERT INTO %s (%s, %s) VALUES %s".formatted(
joinTable,
mappedBy,
linkedBy,
paramsTemplate
);
final var params = IntStream.range(2, newItems.size() + 2)
.mapToObj(i -> Map.entry("$" + i, this.idValueOf(newItems.get(i - 2))))
.collect(toMap(Entry::getKey, Entry::getValue));
)
.defaultIfEmpty(values)
.delayUntil(items ->
items.stream()
.filter(item -> this.idValueOf(item) == null)
.findFirst()
.map(Object::toString)
.map("Link-only entity is missing its primary key: "::concat)
.map(RelationshipException::of)
.map(Mono::error)
.orElseGet(Mono::empty)
)
.delayUntil(newItems -> {
final var paramsTemplate = IntStream.range(0, newItems.size())
.mapToObj("(:entityId, :link[%d])"::formatted)
.collect(joining(", "));
final var params = IntStream.range(0, newItems.size())
.mapToObj(i -> Map.entry("link[%d]".formatted(i), this.idValueOf(newItems.get(i))))
.collect(toMap(Entry::getKey, Entry::getValue));
final var statement = MessageFormat.format(
"""
INSERT INTO {0} ({1}, {2}) (
SELECT t.* FROM (VALUES {3}) AS t(mapped, linked)
WHERE t.linked NOT IN (SELECT {2} FROM {0} WHERE {1} = :entityId)
)
""",
joinTable, mappedBy, linkedBy, paramsTemplate
);

return this.template
.getDatabaseClient()
.sql(statement)
.bind(0, entityId)
.bindValues(params)
.fetch()
.rowsUpdated();
});
return this.template
.getDatabaseClient()
.sql(statement)
.bind("entityId", entityId)
.bindValues(params)
.fetch()
.rowsUpdated();
})
.delayUntil(items -> {
final var paramsTemplate = IntStream.range(2, items.size() + 2)
Expand Down Expand Up @@ -293,4 +290,18 @@ private <T> Function<T, Mono<T>> withCallbacksOf(final Class<?> type) {
.defaultIfEmpty(value);
};
}

private <T> T toPreventingCycles(final T value) {
final var entityType = this.domainFor(this.entity.getClass());
final var valueFields = value.getClass().getDeclaredFields();

return stream(valueFields)
.filter(field -> field.isAnnotationPresent(ManyToMany.class))
.filter(field -> this.domainFor(Reflect.innerTypeOf(field)).equals(entityType))
.reduce(
value,
(acc, field) -> Reflect.update(acc, field, null),
(a, b) -> b
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,33 @@ public Mono<List<?>> persist(final OneToMany annotation, final Field field) {
.orThrow(RelationshipException::of);

return Flux.fromIterable(values)
.map(Reflect.update(mappedField, entityId))
.flatMap(this::save)
.flatMap(value -> {
if (annotation.linkOnly()) {
final var innerTable = this.tableNameOf(innerType);
final var innerId = this.idColumnOf(innerType);
final var statement = "UPDATE %s SET %s = $1 WHERE %s = $2".formatted(innerTable, mappedBy, innerId);
final var linked = Reflect.update(value, mappedField, entityId);
final var missingId = RelationshipException.of("Link-only entity is missing its primary key: " + linked);

return Mono.just(value)
.mapNotNull(this::idValueOf)
.flatMap(valueId ->
this.template
.getDatabaseClient()
.sql(statement)
.bind(0, entityId)
.bind(1, valueId)
.fetch()
.rowsUpdated()
)
.map(x -> linked)
.switchIfEmpty(Mono.error(missingId));
}

return Mono.just(value)
.map(Reflect.update(mappedField, entityId))
.flatMap(this::save);
})
.collectList()
.delayUntil(children -> {
final var keepOrphans = annotation.keepOrphans();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;

import io.github.joselion.springr2dbcrelationships.exceptions.RelationshipException;
import io.github.joselion.springr2dbcrelationships.models.author.Author;
import io.github.joselion.springr2dbcrelationships.models.author.AuthorRepository;
import io.github.joselion.springr2dbcrelationships.models.authorbook.AuthorBook;
Expand Down Expand Up @@ -345,6 +346,59 @@
.verifyComplete();
}
}

@Nested class when_the_relation_is_link_only {
@Nested class and_all_the_entities_exist {
@Test void links_the_entitites_without_updating_them() {
Flux.just(blackHoles, superNovas, wormHoles)
.map(Paper::of)
.publish(paperRepo::saveAll)
.map(paper -> paper.withTitleBy(String::toUpperCase))
.collectList()
.map(nielDegreese.withPapers(null)::withStudies)
.flatMap(authorRepo::save)
.zipWhen(x -> paperRepo.findAll().collectList())
.as(TxStepVerifier::withRollback)
.assertNext(consumer((author, papers) -> {
assertThat(author.studies())
.extracting(Paper::title)
.containsExactly(
blackHoles.toUpperCase(),
superNovas.toUpperCase(),
wormHoles.toUpperCase()
);
assertThat(papers)
.allSatisfy(paper ->
assertThat(paper.authors())
.isNotEmpty()
.extracting(Author::id)
.containsExactly(author.id())
)
.extracting(Paper::title)
.containsExactly(blackHoles, superNovas, wormHoles);
}))
.verifyComplete();
}
}

@Nested class and_an_enity_does_not_exist {
@Test void raises_a_RelationshipException_error() {
Flux.just(blackHoles, superNovas)
.map(Paper::of)
.publish(paperRepo::saveAll)
.concatWithValues(Paper.of(wormHoles))
.collectList()
.map(nielDegreese.withPapers(null)::withStudies)
.flatMap(authorRepo::save)
.as(TxStepVerifier::withRollback)
.verifyErrorSatisfies(error ->
assertThat(error)
.isInstanceOf(RelationshipException.class)
.hasMessageStartingWith("Link-only entity is missing its primary key: Paper[id=null,")
);
}
}
}
}

private Mono<Tuple2<List<Book>, Author>> tolkienTrilogy() {
Expand Down
Loading

0 comments on commit 8a87886

Please sign in to comment.