Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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