Skip to content

Commit

Permalink
feat(core): Add preferences to handle orphans (#19)
Browse files Browse the repository at this point in the history
  • Loading branch information
JoseLion authored Feb 29, 2024
1 parent a77be20 commit c8dce08
Show file tree
Hide file tree
Showing 16 changed files with 397 additions and 135 deletions.
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ sonarLint {
'java:S107', // Allow constructors with more than 7 parameters
'java:S3776', // Allow methods with more than 15 lines
'java:S4032', // Allow packages only containing `package-info.java`
'java:S6203', // Allow textbloks in lambda expressions
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,25 @@
*/
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.
*
* @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 "foreign key" column that maps the
* annotated field's entity with the join table. This is usually optional if
Expand All @@ -71,10 +90,11 @@
String mappedBy() default "";

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,21 @@
@Target({FIELD, PARAMETER, ANNOTATION_TYPE})
public @interface OneToMany {

/**
* Whether orphan entities are preserved or not. Defaults to {@code false}.
*
* <p>Usually, one-to-many relationships have a parent-children configuration,
* meaning every child needs a parent assigned to it. By default, the
* annotation will delete orphan entites, or children which are no longer
* assigned to their 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 entities with the parent.
*
* @return {@code true} if orphan entities should be presereved, {@code false}
* otherwise
*/
boolean keepOrphans() 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 All @@ -42,10 +57,11 @@
String mappedBy() default "";

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ public Mono<List<?>> persist(final ManyToMany annotation, final Field field) {
final var innerType = this.domainFor(Reflect.innerTypeOf(field));
final var entityTable = this.tableNameOf(entityType);
final var innerTable = this.tableNameOf(innerType);
final var innerId = this.idColumnOf(innerType);
final var mappedBy = Optional.of(annotation)
.map(ManyToMany::mappedBy)
.filter(not(String::isBlank))
Expand All @@ -149,6 +150,24 @@ public Mono<List<?>> persist(final ManyToMany annotation, final Field field) {
.map(ManyToMany::linkedBy)
.filter(not(String::isBlank))
.orElseGet(() -> innerTable.concat("_id"));
final var orphansStatement = """
DELETE FROM %s
WHERE %s NOT IN (
SELECT j.%s FROM %s AS j
WHERE j.%s = $1
)
"""
.formatted(innerTable, innerId, linkedBy, joinTable, mappedBy);
final var deleteOrphans = Mono.just(annotation)
.filter(ManyToMany::deleteOrphans)
.flatMap(y ->
this.template
.getDatabaseClient()
.sql(orphansStatement)
.bind(0, entityId)
.fetch()
.rowsUpdated()
);

if (values.isEmpty()) {
return this.template
Expand All @@ -157,6 +176,7 @@ public Mono<List<?>> persist(final ManyToMany annotation, final Field field) {
.bind(0, entityId)
.fetch()
.rowsUpdated()
.delayUntil(x -> deleteOrphans)
.map(x -> List.of());
}

Expand Down Expand Up @@ -210,12 +230,11 @@ public Mono<List<?>> persist(final ManyToMany annotation, final Field field) {
final var paramsTemplate = IntStream.range(2, items.size() + 2)
.mapToObj(i -> "$" + i)
.collect(joining(", "));
final var statement = "DELETE FROM %s WHERE %s = $1 AND %s NOT IN (%s)".formatted(
joinTable,
mappedBy,
linkedBy,
paramsTemplate
);
final var statement = """
DELETE FROM %s
WHERE %s = $1 AND %s NOT IN (%s)
"""
.formatted(joinTable, mappedBy, linkedBy, paramsTemplate);
final var params = IntStream.range(2, items.size() + 2)
.mapToObj(i -> Map.entry("$" + i, this.idValueOf(items.get(i - 2))))
.collect(toMap(Entry::getKey, Entry::getValue));
Expand All @@ -226,7 +245,8 @@ public Mono<List<?>> persist(final ManyToMany annotation, final Field field) {
.bind(0, entityId)
.bindValues(params)
.fetch()
.rowsUpdated();
.rowsUpdated()
.delayUntil(x -> deleteOrphans);
});
}));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
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;
Expand Down Expand Up @@ -107,14 +108,21 @@ public Mono<List<?>> persist(final OneToMany annotation, final Field field) {
.flatMap(this::save)
.collectList()
.delayUntil(children -> {
final var keepOrphans = annotation.keepOrphans();
final var innerId = this.idColumnOf(innerType);
final var ids = children.stream()
.map(this::idValueOf)
.toList();
final var ids = children.stream().map(this::idValueOf).toList();
final var allOrphans = query(where(mappedBy).is(entityId).and(innerId).notIn(ids));

if (keepOrphans) {
return this.template
.update(innerType)
.matching(allOrphans)
.apply(Update.update(mappedBy, null));
}

return this.template
.delete(innerType)
.matching(query(where(mappedBy).is(entityId).and(innerId).notIn(ids)))
.matching(allOrphans)
.all();
});
});
Expand Down
Loading

0 comments on commit c8dce08

Please sign in to comment.