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 preferences to handle orphans #19

Merged
merged 1 commit into from
Feb 29, 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
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
Loading