Skip to content
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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,10 @@ The goal is to make the developer experience as seamless as possible.
- `@Insert([InsertStrategy.PREFER_EXISTING/OVERWRITE_EXISTING])`: Controls insert behavior for `Reference<T>` and
foreign
columns.
- `@Delete([DeleteStrategy.CASCADE/NO_ACTION])`: Controls delete behavior. This has different behavior depending on the
relationship type, refer to the javadoc on each `DeleteStrategy` enum value for more information.
- `@Delete([DeleteStrategy.CASCADE/SET_NULL/NO_ACTION])`: Controls delete behavior. This has different behavior depending on the
relationship type. For many-to-many collections, `CASCADE` deletes the matching join rows and related objects,
`SET_NULL` deletes only the matching join rows, and `NO_ACTION` changes nothing. Refer to the javadoc on each
`DeleteStrategy` enum value for the behavior of other relationship types.
- `@UpdateInterval([milliseconds])`: Used on `PersistentValue<T>` fields to control how often changes are flushed to the
source database. The default is 0 milliseconds. Since a FIFO queue (one connection to the source database) is used to
dispatch updates, frequent updates may clog up the queue. When the update interval is set to a non-zero value, only
Expand Down Expand Up @@ -437,4 +439,4 @@ This flexibility allows all primitive types to be nullable when needed, while st

[//]: # (TODO: this section is incomplete since the impl isnt finished. update this later)

[//]: # (TODO: talk about update handlers, & add/remove handlers)
[//]: # (TODO: talk about update handlers, & add/remove handlers)
8 changes: 8 additions & 0 deletions annotations/src/main/java/net/staticstudios/data/Delete.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,16 @@
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Configures how data represented by a relationship field is handled when the field's holder is deleted.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Delete {
/**
* Controls what happens to data represented by the annotated field when its holder is deleted.
*
* @return the deletion strategy for the relationship
*/
DeleteStrategy value();
}
Original file line number Diff line number Diff line change
@@ -1,23 +1,28 @@
package net.staticstudios.data;

/**
* Actions that can be applied to related data when the object holding a relationship field is deleted.
*/
public enum DeleteStrategy {
/**
* When the parent data is deleted, delete this data as well.
* For all data types, this means that the referenced data will be deleted when the parent data is deleted.
* Delete the related data when the object holding the annotated field is deleted.
* For a many-to-many collection, the matching join-table entries are removed and the
* referenced objects are deleted.
*/
CASCADE,

/**
* In the context of a reference or one-to-many collection,
* set the columns in the referenced table to null.
* Preserve the related data but remove the relationship when the object holding the
* annotated field is deleted. For a one-to-many collection, the linking columns are set
* to {@code null}. For a many-to-many collection, the matching join-table entries are
* deleted.
*/
SET_NULL,

/**
* In the context of a <b>persistent value, a reference, or a one-to-many collection:</b> <br>
* Do nothing when the parent data is deleted.
* <br><br>
* For a <b>many-to-many</b> collection, this will remove the entries in the join table.
* Do not modify the related data or relationship when the object holding the annotated
* field is deleted. A foreign-key constraint may therefore reject the deletion while a
* relationship still exists.
*/
NO_ACTION
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

/**
* Should a foreign key constraint be created for this relation?
* The configured {@link DeleteStrategy} is still applied by deletion triggers when this is {@code false}.
*
* @return Whether to create a foreign key constraint
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
package net.staticstudios.data.impl.h2.trigger;

import net.staticstudios.data.DeleteStrategy;
import net.staticstudios.data.utils.Link;
import org.h2.api.Trigger;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;

public class H2ManyToManyDeleteStrategyTrigger implements Trigger {
private final Logger logger = LoggerFactory.getLogger(H2ManyToManyDeleteStrategyTrigger.class);
private final List<String> holderColumnNames = new ArrayList<>();
private String holderSchema;
private String holderTable;
private String joinSchema;
private String joinTable;
private String targetSchema;
private String targetTable;
private DeleteStrategy deleteStrategy;
private List<Link> joinTableToHolderLinks;
private List<Link> joinTableToTargetLinks;

@Override
public void init(Connection connection, String schemaName, String triggerName, String tableName, boolean before, int type) {
EncodedValues encodedValues = new EncodedValues(triggerName.split("static_data_v3_m2m_", 2)[1]);
holderSchema = encodedValues.readValue();
encodedValues.skip("_");
holderTable = encodedValues.readValue();
encodedValues.skip("_");
joinSchema = encodedValues.readValue();
encodedValues.skip("_");
joinTable = encodedValues.readValue();
encodedValues.skip("_");
targetSchema = encodedValues.readValue();
encodedValues.skip("_");
targetTable = encodedValues.readValue();
encodedValues.skip("__holder_links__");
joinTableToHolderLinks = encodedValues.readLinks();
encodedValues.skip("__target_links__");
joinTableToTargetLinks = encodedValues.readLinks();
encodedValues.skip("__strategy__");
deleteStrategy = DeleteStrategy.valueOf(encodedValues.readUntil("__delete_trigger"));
}

@Override
public void fire(Connection connection, Object[] oldRow, Object[] newRow) throws SQLException {
if (newRow != null || oldRow == null) {
return;
}
loadHolderColumnNames(connection, oldRow.length);
List<Object> holderValues = getHolderValues(oldRow);

if (deleteStrategy == DeleteStrategy.CASCADE) {
deleteTargets(connection, holderValues);
}
deleteJoinEntries(connection, holderValues);
}

private List<Object> getHolderValues(Object[] oldRow) throws SQLException {
List<Object> holderValues = new ArrayList<>();
for (Link link : joinTableToHolderLinks) {
int holderColumnIndex = holderColumnNames.indexOf(link.columnInReferencedTable());
if (holderColumnIndex < 0) {
throw new SQLException("Could not find holder column " + link.columnInReferencedTable());
}
holderValues.add(oldRow[holderColumnIndex]);
}
return holderValues;
}

private void deleteTargets(Connection connection, List<Object> holderValues) throws SQLException {
StringBuilder select = new StringBuilder("SELECT ");
for (Link link : joinTableToTargetLinks) {
select.append("\"").append(link.columnInReferringTable()).append("\", ");
}
select.setLength(select.length() - 2);
select.append(" FROM \"").append(joinSchema).append("\".\"").append(joinTable).append("\" WHERE ");
appendJoinHolderPredicate(select);

List<Object[]> targetIds = new ArrayList<>();
try (PreparedStatement statement = connection.prepareStatement(select.toString())) {
setParameters(statement, holderValues);
try (ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
Object[] targetId = new Object[joinTableToTargetLinks.size()];
for (int i = 0; i < targetId.length; i++) {
targetId[i] = resultSet.getObject(i + 1);
}
targetIds.add(targetId);
}
}
}

StringBuilder delete = new StringBuilder("DELETE FROM \"").append(targetSchema).append("\".\"")
.append(targetTable).append("\" WHERE ");
for (Link link : joinTableToTargetLinks) {
delete.append("\"").append(link.columnInReferencedTable()).append("\" = ? AND ");
}
delete.setLength(delete.length() - 5);
logger.debug("Executing many-to-many cascade delete: {}", delete);

try (PreparedStatement statement = connection.prepareStatement(delete.toString())) {
for (Object[] targetId : targetIds) {
for (int i = 0; i < targetId.length; i++) {
statement.setObject(i + 1, targetId[i]);
}
statement.addBatch();
}
if (!targetIds.isEmpty()) {
statement.executeBatch();
}
}
}

private void deleteJoinEntries(Connection connection, List<Object> holderValues) throws SQLException {
StringBuilder delete = new StringBuilder("DELETE FROM \"").append(joinSchema).append("\".\"")
.append(joinTable).append("\" WHERE ");
appendJoinHolderPredicate(delete);
logger.debug("Executing many-to-many join cleanup: {}", delete);

try (PreparedStatement statement = connection.prepareStatement(delete.toString())) {
setParameters(statement, holderValues);
statement.executeUpdate();
}
}

private void appendJoinHolderPredicate(StringBuilder sql) {
for (Link link : joinTableToHolderLinks) {
sql.append("\"").append(link.columnInReferringTable()).append("\" = ? AND ");
}
sql.setLength(sql.length() - 5);
}

private void setParameters(PreparedStatement statement, List<Object> values) throws SQLException {
for (int i = 0; i < values.size(); i++) {
statement.setObject(i + 1, values.get(i));
}
}

private void loadHolderColumnNames(Connection connection, int expectedColumnCount) throws SQLException {
if (holderColumnNames.size() == expectedColumnCount) {
return;
}
List<String> columns = new ArrayList<>(expectedColumnCount);
try (PreparedStatement statement = connection.prepareStatement(
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION"
)) {
statement.setString(1, holderSchema);
statement.setString(2, holderTable);
try (ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
columns.add(resultSet.getString("COLUMN_NAME"));
}
}
}
holderColumnNames.clear();
holderColumnNames.addAll(columns);
}

private static final class EncodedValues {
private String remaining;

private EncodedValues(String encoded) {
remaining = encoded;
}

private String readValue() {
String[] parts = remaining.split("_", 2);
int length = Integer.parseInt(parts[0]);
String value = parts[1].substring(0, length);
remaining = parts[1].substring(length);
return value;
}

private List<Link> readLinks() {
String[] parts = remaining.split("_", 2);
int valueCount = Integer.parseInt(parts[0]);
remaining = parts[1];
List<String> values = new ArrayList<>(valueCount);
while (values.size() < valueCount) {
values.add(readValue());
}
List<Link> links = new ArrayList<>(valueCount / 2);
for (int i = 0; i < values.size(); i += 2) {
links.add(new Link(values.get(i + 1), values.get(i)));
}
return links;
}

private String readUntil(String suffix) {
int suffixIndex = remaining.indexOf(suffix);
if (suffixIndex < 0) {
throw new IllegalArgumentException("Invalid encoded trigger name");
}
String value = remaining.substring(0, suffixIndex);
remaining = remaining.substring(suffixIndex + suffix.length());
return value;
}

private void skip(String prefix) {
if (!remaining.startsWith(prefix)) {
throw new IllegalArgumentException("Invalid encoded trigger name");
}
remaining = remaining.substring(prefix.length());
}
}
}
21 changes: 15 additions & 6 deletions core/src/main/java/net/staticstudios/data/parse/SQLBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -748,18 +748,27 @@ private void parseManyToManyPersistentCollection(ManyToMany manyToMany, Class<?

Delete delete = field.getAnnotation(Delete.class);
DeleteStrategy deleteStrategy = delete != null ? delete.value() : DeleteStrategy.NO_ACTION;
OnDelete onDelete = OnDelete.CASCADE;
//todo: deletion strategy in this case is different than in the one to many case.
// it should always cascade on the join referringTable, but depending on the delete strategy, we may or may not delete the referenced data.
// impl with a trigger?

if (manyToMany.fkey()) {
ForeignKey foreignKeyJoinToDataTable = new ForeignKey("pc_m2m", joinSchema.getName(), joinTable.getName(), schema.getName(), table.getName(), onDelete);
ForeignKey foreignKeyJoinToReferenceTable = new ForeignKey("pc_m2m", joinSchema.getName(), joinTable.getName(), referencedSchema.getName(), referencedTable.getName(), onDelete);
OnDelete holderOnDelete = deleteStrategy == DeleteStrategy.NO_ACTION ? OnDelete.NO_ACTION : OnDelete.CASCADE;
ForeignKey foreignKeyJoinToDataTable = new ForeignKey("pc_m2m", joinSchema.getName(), joinTable.getName(), schema.getName(), table.getName(), holderOnDelete);
ForeignKey foreignKeyJoinToReferenceTable = new ForeignKey("pc_m2m", joinSchema.getName(), joinTable.getName(), referencedSchema.getName(), referencedTable.getName(), OnDelete.CASCADE);
joinTableToDataTableLinks.forEach(foreignKeyJoinToDataTable::addLink);
joinTableToReferencedTableLinks.forEach(foreignKeyJoinToReferenceTable::addLink);
joinTable.addForeignKey(foreignKeyJoinToDataTable);
joinTable.addForeignKey(foreignKeyJoinToReferenceTable);
}

table.addTrigger(new SQLManyToManyDeleteStrategyTrigger(
schema.getName(),
table.getName(),
joinSchema.getName(),
joinTable.getName(),
referencedSchema.getName(),
referencedTable.getName(),
deleteStrategy,
joinTableToDataTableLinks,
joinTableToReferencedTableLinks
));
}
}
Loading
Loading