diff --git a/README.md b/README.md index e17111c..b1c2616 100644 --- a/README.md +++ b/README.md @@ -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` 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` 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 @@ -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) \ No newline at end of file +[//]: # (TODO: talk about update handlers, & add/remove handlers) diff --git a/annotations/src/main/java/net/staticstudios/data/Delete.java b/annotations/src/main/java/net/staticstudios/data/Delete.java index b709461..5c6fcd3 100644 --- a/annotations/src/main/java/net/staticstudios/data/Delete.java +++ b/annotations/src/main/java/net/staticstudios/data/Delete.java @@ -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(); } diff --git a/annotations/src/main/java/net/staticstudios/data/DeleteStrategy.java b/annotations/src/main/java/net/staticstudios/data/DeleteStrategy.java index 1ad0b48..c8f92ba 100644 --- a/annotations/src/main/java/net/staticstudios/data/DeleteStrategy.java +++ b/annotations/src/main/java/net/staticstudios/data/DeleteStrategy.java @@ -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 persistent value, a reference, or a one-to-many collection:
- * Do nothing when the parent data is deleted. - *

- * For a many-to-many 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 } diff --git a/annotations/src/main/java/net/staticstudios/data/ManyToMany.java b/annotations/src/main/java/net/staticstudios/data/ManyToMany.java index 50122aa..1bb1d4f 100644 --- a/annotations/src/main/java/net/staticstudios/data/ManyToMany.java +++ b/annotations/src/main/java/net/staticstudios/data/ManyToMany.java @@ -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 */ diff --git a/core/src/main/java/net/staticstudios/data/impl/h2/trigger/H2ManyToManyDeleteStrategyTrigger.java b/core/src/main/java/net/staticstudios/data/impl/h2/trigger/H2ManyToManyDeleteStrategyTrigger.java new file mode 100644 index 0000000..43710b6 --- /dev/null +++ b/core/src/main/java/net/staticstudios/data/impl/h2/trigger/H2ManyToManyDeleteStrategyTrigger.java @@ -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 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 joinTableToHolderLinks; + private List 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 holderValues = getHolderValues(oldRow); + + if (deleteStrategy == DeleteStrategy.CASCADE) { + deleteTargets(connection, holderValues); + } + deleteJoinEntries(connection, holderValues); + } + + private List getHolderValues(Object[] oldRow) throws SQLException { + List 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 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 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 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 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 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 readLinks() { + String[] parts = remaining.split("_", 2); + int valueCount = Integer.parseInt(parts[0]); + remaining = parts[1]; + List values = new ArrayList<>(valueCount); + while (values.size() < valueCount) { + values.add(readValue()); + } + List 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()); + } + } +} diff --git a/core/src/main/java/net/staticstudios/data/parse/SQLBuilder.java b/core/src/main/java/net/staticstudios/data/parse/SQLBuilder.java index 9e96818..a6bce68 100644 --- a/core/src/main/java/net/staticstudios/data/parse/SQLBuilder.java +++ b/core/src/main/java/net/staticstudios/data/parse/SQLBuilder.java @@ -748,18 +748,27 @@ private void parseManyToManyPersistentCollection(ManyToMany manyToMany, Class joinTableToHolderLinks; + private final List joinTableToTargetLinks; + + public SQLManyToManyDeleteStrategyTrigger( + String holderSchema, + String holderTable, + String joinSchema, + String joinTable, + String targetSchema, + String targetTable, + DeleteStrategy deleteStrategy, + List joinTableToHolderLinks, + List joinTableToTargetLinks + ) { + this.holderSchema = holderSchema; + this.holderTable = holderTable; + this.joinSchema = joinSchema; + this.joinTable = joinTable; + this.targetSchema = targetSchema; + this.targetTable = targetTable; + this.deleteStrategy = deleteStrategy; + this.joinTableToHolderLinks = List.copyOf(joinTableToHolderLinks); + this.joinTableToTargetLinks = List.copyOf(joinTableToTargetLinks); + } + + @Override + public String getPgSQL() { + String triggerName = postgresTriggerName(); + if (deleteStrategy == DeleteStrategy.NO_ACTION) { + return "DROP TRIGGER IF EXISTS \"" + triggerName + "\" ON \"" + holderSchema + "\".\"" + holderTable + "\";"; + } + + @Language("SQL") String createTriggerFunction = """ + CREATE OR REPLACE FUNCTION "%s"() + RETURNS TRIGGER AS $$ + BEGIN + %s + RETURN OLD; + END; + $$ LANGUAGE plpgsql; + + DROP TRIGGER IF EXISTS "%s" ON "%s"."%s"; + CREATE TRIGGER "%s" + BEFORE DELETE ON "%s"."%s" + FOR EACH ROW EXECUTE FUNCTION "%s"(); + """; + + StringBuilder action = new StringBuilder(); + if (deleteStrategy == DeleteStrategy.CASCADE) { + action.append("DELETE FROM \"") + .append(targetSchema).append("\".\"").append(targetTable).append("\" AS _target USING \"") + .append(joinSchema).append("\".\"").append(joinTable).append("\" AS _join WHERE "); + appendHolderMatch(action); + for (Link link : joinTableToTargetLinks) { + action.append("_target.\"").append(link.columnInReferencedTable()).append("\" = _join.\"") + .append(link.columnInReferringTable()).append("\" AND "); + } + action.setLength(action.length() - 5); + action.append(';'); + } + action.append(" DELETE FROM \"").append(joinSchema).append("\".\"").append(joinTable).append("\" AS _join WHERE "); + appendHolderMatch(action); + action.setLength(action.length() - 5); + action.append(';'); + + return createTriggerFunction.formatted( + triggerName, + action, + triggerName, holderSchema, holderTable, + triggerName, holderSchema, holderTable, + triggerName + ); + } + + private void appendHolderMatch(StringBuilder action) { + for (Link link : joinTableToHolderLinks) { + action.append("_join.\"").append(link.columnInReferringTable()).append("\" = OLD.\"") + .append(link.columnInReferencedTable()).append("\" AND "); + } + } + + @Override + public String getH2SQL() { + String cascadeTriggerName = h2TriggerName(DeleteStrategy.CASCADE); + String setNullTriggerName = h2TriggerName(DeleteStrategy.SET_NULL); + if (deleteStrategy == DeleteStrategy.NO_ACTION) { + return "DROP TRIGGER IF EXISTS \"" + cascadeTriggerName + "\"; DROP TRIGGER IF EXISTS \"" + setNullTriggerName + "\""; + } + String triggerName = h2TriggerName(deleteStrategy); + String obsoleteTriggerName = h2TriggerName(deleteStrategy == DeleteStrategy.CASCADE ? DeleteStrategy.SET_NULL : DeleteStrategy.CASCADE); + return "DROP TRIGGER IF EXISTS \"" + obsoleteTriggerName + "\"; CREATE TRIGGER IF NOT EXISTS \"" + triggerName + + "\" BEFORE DELETE ON \"" + holderSchema + "\".\"" + holderTable + "\" FOR EACH ROW CALL \"" + + H2ManyToManyDeleteStrategyTrigger.class.getName() + "\""; + } + +private String postgresTriggerName() { + String signature = holderSchema + "." + holderTable + "|" + joinSchema + "." + joinTable + "|" + targetSchema + "." + targetTable; + return "static_data_v3_m2m_" + Integer.toUnsignedString(signature.hashCode(), 16) + "_delete_trigger"; +} + + private String h2TriggerName(DeleteStrategy strategy) { + String encodedHolderLinks = encodeLinks(joinTableToHolderLinks); + String encodedTargetLinks = encodeLinks(joinTableToTargetLinks); + return "static_data_v3_m2m_" + + prefixString(holderSchema) + "_" + prefixString(holderTable) + "_" + + prefixString(joinSchema) + "_" + prefixString(joinTable) + "_" + + prefixString(targetSchema) + "_" + prefixString(targetTable) + + "__holder_links__" + (joinTableToHolderLinks.size() * 2) + "_" + encodedHolderLinks + + "__target_links__" + (joinTableToTargetLinks.size() * 2) + "_" + encodedTargetLinks + + "__strategy__" + strategy.name() + + "__delete_trigger"; + } + + private String encodeLinks(List links) { + return String.join("", links.stream() + .map(link -> prefixString(link.columnInReferringTable()) + prefixString(link.columnInReferencedTable())) + .toList()); + } + + private String prefixString(String value) { + return value.length() + "_" + value; + } +} diff --git a/core/src/test/java/net/staticstudios/data/DeletionTest.java b/core/src/test/java/net/staticstudios/data/DeletionTest.java index d1b6438..795693d 100644 --- a/core/src/test/java/net/staticstudios/data/DeletionTest.java +++ b/core/src/test/java/net/staticstudios/data/DeletionTest.java @@ -1,8 +1,19 @@ package net.staticstudios.data; import net.staticstudios.data.misc.DataTest; +import net.staticstudios.data.parse.ForeignKey; +import net.staticstudios.data.parse.SQLManyToManyDeleteStrategyTrigger; +import net.staticstudios.data.parse.SQLSchema; +import net.staticstudios.data.parse.SQLTable; +import net.staticstudios.data.parse.SQLTrigger; +import net.staticstudios.data.util.OnDelete; import org.junit.jupiter.api.Test; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + import static org.junit.jupiter.api.Assertions.*; public class DeletionTest extends DataTest { @@ -174,8 +185,286 @@ public void testOneToManyCascade2() { assertFalse(user.isDeleted()); } - //todo: many-to-many collections test and impl. + @Test + public void testManyToManyCascadeDeletesJoinEntriesAndChildren() throws SQLException { + DataManager dataManager = load(ManyToManyCascadeHolder.class); + + ManyToManyCascadeHolder holder = ManyToManyCascadeHolder.builder(dataManager) + .id(1) + .insert(InsertMode.SYNC); + ManyToManyCascadeChild firstChild = ManyToManyCascadeChild.builder(dataManager) + .id(10) + .insert(InsertMode.SYNC); + ManyToManyCascadeChild secondChild = ManyToManyCascadeChild.builder(dataManager) + .id(11) + .insert(InsertMode.SYNC); + holder.children.add(firstChild); + holder.children.add(secondChild); + + holder.delete(); + + assertTrue(holder.isDeleted()); + assertTrue(firstChild.isDeleted()); + assertTrue(secondChild.isDeleted()); + assertTableRowCount(dataManager, "m2m_cascade_join", 0); + assertTableRowCount(dataManager, "m2m_cascade_children", 0); + } + + @Test + public void testManyToManyCascadeDoesNotDeleteUnlinkedChildren() throws SQLException { + DataManager dataManager = load(ManyToManyCascadeHolder.class); + + ManyToManyCascadeHolder holder = ManyToManyCascadeHolder.builder(dataManager) + .id(1) + .insert(InsertMode.SYNC); + ManyToManyCascadeChild linkedChild = ManyToManyCascadeChild.builder(dataManager) + .id(10) + .insert(InsertMode.SYNC); + ManyToManyCascadeChild unlinkedChild = ManyToManyCascadeChild.builder(dataManager) + .id(11) + .insert(InsertMode.SYNC); + holder.children.add(linkedChild); + + holder.delete(); + + assertTrue(linkedChild.isDeleted()); + assertFalse(unlinkedChild.isDeleted()); + assertTableRowCount(dataManager, "m2m_cascade_children", 1); + } + + @Test + public void testManyToManySetNullDeletesOnlyJoinEntries() throws SQLException { + DataManager dataManager = load(ManyToManySetNullHolder.class); + + ManyToManySetNullHolder holder = ManyToManySetNullHolder.builder(dataManager) + .id(1) + .insert(InsertMode.SYNC); + ManyToManySetNullChild child = ManyToManySetNullChild.builder(dataManager) + .id(10) + .insert(InsertMode.SYNC); + holder.children.add(child); + + holder.delete(); + + assertTrue(holder.isDeleted()); + assertFalse(child.isDeleted()); + assertTableRowCount(dataManager, "m2m_set_null_join", 0); + assertTableRowCount(dataManager, "m2m_set_null_children", 1); + } + + @Test + public void testManyToManyNoActionRejectsLinkedHolderDeletion() throws SQLException { + DataManager dataManager = load(ManyToManyNoActionHolder.class); + + ManyToManyNoActionHolder holder = ManyToManyNoActionHolder.builder(dataManager) + .id(1) + .insert(InsertMode.SYNC); + ManyToManyNoActionChild child = ManyToManyNoActionChild.builder(dataManager) + .id(10) + .insert(InsertMode.SYNC); + holder.children.add(child); + + assertThrows(RuntimeException.class, holder::delete); + + assertFalse(holder.isDeleted()); + assertFalse(child.isDeleted()); + assertTableRowCount(dataManager, "m2m_no_action_holders", 1); + assertTableRowCount(dataManager, "m2m_no_action_children", 1); + assertTableRowCount(dataManager, "m2m_no_action_join", 1); + } + + @Test + public void testManyToManyNoActionAllowsUnlinkedHolderDeletion() throws SQLException { + DataManager dataManager = load(ManyToManyNoActionHolder.class); + + ManyToManyNoActionHolder holder = ManyToManyNoActionHolder.builder(dataManager) + .id(1) + .insert(InsertMode.SYNC); + + holder.delete(); + + assertTrue(holder.isDeleted()); + assertTableRowCount(dataManager, "m2m_no_action_holders", 0); + } + + @Test + public void testDeletingManyToManyChildAlwaysCleansJoinEntry() throws SQLException { + DataManager dataManager = load(ManyToManyNoActionHolder.class); + + ManyToManyNoActionHolder holder = ManyToManyNoActionHolder.builder(dataManager) + .id(1) + .insert(InsertMode.SYNC); + ManyToManyNoActionChild child = ManyToManyNoActionChild.builder(dataManager) + .id(10) + .insert(InsertMode.SYNC); + holder.children.add(child); + + child.delete(); + + assertFalse(holder.isDeleted()); + assertTrue(child.isDeleted()); + assertTableRowCount(dataManager, "m2m_no_action_join", 0); + } + + @Test + public void testManyToManyCascadeSupportsCompositeLinks() throws SQLException { + DataManager dataManager = load(ManyToManyCompositeHolder.class); + ManyToManyCompositeHolder holder = ManyToManyCompositeHolder.builder(dataManager) + .tenantId(1) + .childId(2) + .insert(InsertMode.SYNC); + ManyToManyCompositeChild child = ManyToManyCompositeChild.builder(dataManager) + .groupId(1) + .id(2) + .insert(InsertMode.SYNC); + holder.children.add(child); + + holder.delete(); + + assertTrue(child.isDeleted()); + assertTableRowCount(dataManager, "m2m_composite_join", 0); + assertTableRowCount(dataManager, "m2m_composite_children", 0); + } + + @Test + public void testManyToManyDeleteStrategiesWorkWithoutForeignKeys() throws SQLException { + DataManager dataManager = load( + ManyToManyNoForeignKeyCascadeHolder.class, + ManyToManyNoForeignKeySetNullHolder.class, + ManyToManyNoForeignKeyNoActionHolder.class + ); + + ManyToManyNoForeignKeyCascadeHolder cascadeHolder = ManyToManyNoForeignKeyCascadeHolder.builder(dataManager) + .id(1) + .insert(InsertMode.SYNC); + ManyToManyNoForeignKeyChild cascadeChild = ManyToManyNoForeignKeyChild.builder(dataManager) + .id(10) + .insert(InsertMode.SYNC); + cascadeHolder.children.add(cascadeChild); + + ManyToManyNoForeignKeySetNullHolder setNullHolder = ManyToManyNoForeignKeySetNullHolder.builder(dataManager) + .id(2) + .insert(InsertMode.SYNC); + ManyToManyNoForeignKeyChild setNullChild = ManyToManyNoForeignKeyChild.builder(dataManager) + .id(20) + .insert(InsertMode.SYNC); + setNullHolder.children.add(setNullChild); + + ManyToManyNoForeignKeyNoActionHolder noActionHolder = ManyToManyNoForeignKeyNoActionHolder.builder(dataManager) + .id(3) + .insert(InsertMode.SYNC); + ManyToManyNoForeignKeyChild noActionChild = ManyToManyNoForeignKeyChild.builder(dataManager) + .id(30) + .insert(InsertMode.SYNC); + noActionHolder.children.add(noActionChild); + + cascadeHolder.delete(); + setNullHolder.delete(); + noActionHolder.delete(); + + assertTrue(cascadeChild.isDeleted()); + assertFalse(setNullChild.isDeleted()); + assertFalse(noActionChild.isDeleted()); + assertTableRowCount(dataManager, "m2m_no_fkey_cascade_join", 0); + assertTableRowCount(dataManager, "m2m_no_fkey_set_null_join", 0); + assertTableRowCount(dataManager, "m2m_no_fkey_no_action_join", 1); + + SQLSchema schema = dataManager.getSQLBuilder().getSchema("test"); + assertNotNull(schema); + assertTrue(schema.getTable("m2m_no_fkey_cascade_join").getForeignKeys().isEmpty()); + assertTrue(schema.getTable("m2m_no_fkey_set_null_join").getForeignKeys().isEmpty()); + assertTrue(schema.getTable("m2m_no_fkey_no_action_join").getForeignKeys().isEmpty()); + } + + @Test + public void testManyToManyDeleteStrategiesConfigureForeignKeys() { + DataManager dataManager = load( + ManyToManyCascadeHolder.class, + ManyToManySetNullHolder.class, + ManyToManyNoActionHolder.class + ); + + assertForeignKeyActions(dataManager, "m2m_cascade_join", "m2m_cascade_holders", "m2m_cascade_children", OnDelete.CASCADE); + assertForeignKeyActions(dataManager, "m2m_set_null_join", "m2m_set_null_holders", "m2m_set_null_children", OnDelete.CASCADE); + assertForeignKeyActions(dataManager, "m2m_no_action_join", "m2m_no_action_holders", "m2m_no_action_children", OnDelete.NO_ACTION); + } + + @Test + public void testManyToManyDeleteStrategiesCreateAppropriateTriggers() { + DataManager dataManager = load( + ManyToManyCascadeHolder.class, + ManyToManySetNullHolder.class, + ManyToManyNoActionHolder.class + ); + + SQLManyToManyDeleteStrategyTrigger cascadeTrigger = getManyToManyTrigger(dataManager, "m2m_cascade_holders"); + assertTrue(cascadeTrigger.getPgSQL().contains("BEFORE DELETE ON \"test\".\"m2m_cascade_holders\"")); + assertTrue(cascadeTrigger.getPgSQL().contains("DELETE FROM \"test\".\"m2m_cascade_children\"")); + assertTrue(cascadeTrigger.getH2SQL().contains("BEFORE DELETE ON \"test\".\"m2m_cascade_holders\"")); + + SQLManyToManyDeleteStrategyTrigger setNullTrigger = getManyToManyTrigger(dataManager, "m2m_set_null_holders"); + assertTrue(setNullTrigger.getPgSQL().contains("BEFORE DELETE ON \"test\".\"m2m_set_null_holders\"")); + assertTrue(setNullTrigger.getPgSQL().contains("DELETE FROM \"test\".\"m2m_set_null_join\"")); + assertFalse(setNullTrigger.getPgSQL().contains("DELETE FROM \"test\".\"m2m_set_null_children\"")); + assertTrue(setNullTrigger.getH2SQL().startsWith("DROP TRIGGER IF EXISTS")); + assertTrue(setNullTrigger.getH2SQL().contains("BEFORE DELETE ON \"test\".\"m2m_set_null_holders\"")); + + SQLManyToManyDeleteStrategyTrigger noActionTrigger = getManyToManyTrigger(dataManager, "m2m_no_action_holders"); + assertTrue(noActionTrigger.getPgSQL().startsWith("DROP TRIGGER IF EXISTS")); + assertTrue(noActionTrigger.getH2SQL().startsWith("DROP TRIGGER IF EXISTS")); + } + + @SafeVarargs + private DataManager load(Class... classes) { + DataManager dataManager = getMockEnvironments().getFirst().dataManager(); + dataManager.load(classes); + dataManager.finishLoading(); + return dataManager; + } + + private void assertTableRowCount(DataManager dataManager, String table, int expected) throws SQLException { + assertEquals(expected, tableRowCount(getH2Connection(dataManager), table), "Unexpected H2 row count for " + table); + dataManager.flushTaskQueue(); + assertEquals(expected, tableRowCount(getConnection(), table), "Unexpected PostgreSQL row count for " + table); + } + + private int tableRowCount(Connection connection, String table) throws SQLException { + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT COUNT(*) FROM \"test\".\"" + table + "\"")) { + assertTrue(resultSet.next()); + return resultSet.getInt(1); + } + } + + private void assertForeignKeyActions(DataManager dataManager, String joinTableName, String holderTableName, String targetTableName, OnDelete holderAction) { + SQLSchema schema = dataManager.getSQLBuilder().getSchema("test"); + assertNotNull(schema); + SQLTable joinTable = schema.getTable(joinTableName); + assertNotNull(joinTable); + + assertEquals(holderAction, findForeignKey(joinTable, holderTableName).getOnDelete()); + assertEquals(OnDelete.CASCADE, findForeignKey(joinTable, targetTableName).getOnDelete()); + } + + private ForeignKey findForeignKey(SQLTable table, String referencedTable) { + return table.getForeignKeys().stream() + .filter(foreignKey -> foreignKey.getReferencedTable().equals(referencedTable)) + .findFirst() + .orElseThrow(() -> new AssertionError("No foreign key from " + table.getName() + " to " + referencedTable)); + } + + private SQLManyToManyDeleteStrategyTrigger getManyToManyTrigger(DataManager dataManager, String holderTableName) { + SQLSchema schema = dataManager.getSQLBuilder().getSchema("test"); + assertNotNull(schema); + SQLTable holderTable = schema.getTable(holderTableName); + assertNotNull(holderTable); + SQLTrigger trigger = holderTable.getTriggers().stream() + .filter(SQLManyToManyDeleteStrategyTrigger.class::isInstance) + .findFirst() + .orElseThrow(() -> new AssertionError("No many-to-many deletion trigger for " + holderTableName)); + return (SQLManyToManyDeleteStrategyTrigger) trigger; + } @Data(schema = "test", table = "user_metadata") static class UserMetadataSetNull extends UniqueData { @@ -242,4 +531,110 @@ static class UserCascade extends UniqueData { @Delete(DeleteStrategy.SET_NULL) public PersistentCollection actions; } + + @Data(schema = "test", table = "m2m_cascade_children") + static class ManyToManyCascadeChild extends UniqueData { + @IdColumn(name = "id") + public PersistentValue id; + } + + @Data(schema = "test", table = "m2m_cascade_holders") + static class ManyToManyCascadeHolder extends UniqueData { + @IdColumn(name = "id") + public PersistentValue id; + + @ManyToMany(link = "id=id", joinTable = "m2m_cascade_join") + @Delete(DeleteStrategy.CASCADE) + public PersistentCollection children; + } + + @Data(schema = "test", table = "m2m_set_null_children") + static class ManyToManySetNullChild extends UniqueData { + @IdColumn(name = "id") + public PersistentValue id; + } + + @Data(schema = "test", table = "m2m_set_null_holders") + static class ManyToManySetNullHolder extends UniqueData { + @IdColumn(name = "id") + public PersistentValue id; + + @ManyToMany(link = "id=id", joinTable = "m2m_set_null_join") + @Delete(DeleteStrategy.SET_NULL) + public PersistentCollection children; + } + + @Data(schema = "test", table = "m2m_no_action_children") + static class ManyToManyNoActionChild extends UniqueData { + @IdColumn(name = "id") + public PersistentValue id; + } + + @Data(schema = "test", table = "m2m_no_action_holders") + static class ManyToManyNoActionHolder extends UniqueData { + @IdColumn(name = "id") + public PersistentValue id; + + @ManyToMany(link = "id=id", joinTable = "m2m_no_action_join") + @Delete(DeleteStrategy.NO_ACTION) + public PersistentCollection children; + } + + @Data(schema = "test", table = "m2m_composite_children") + static class ManyToManyCompositeChild extends UniqueData { + @IdColumn(name = "group_id") + public PersistentValue groupId; + + @IdColumn(name = "id") + public PersistentValue id; + } + + @Data(schema = "test", table = "m2m_composite_holders") + static class ManyToManyCompositeHolder extends UniqueData { + @IdColumn(name = "tenant_id") + public PersistentValue tenantId; + + @IdColumn(name = "child_id") + public PersistentValue childId; + + @ManyToMany(link = "tenant_id=group_id, child_id=id", joinTable = "m2m_composite_join") + @Delete(DeleteStrategy.CASCADE) + public PersistentCollection children; + } + + @Data(schema = "test", table = "m2m_no_fkey_children") + static class ManyToManyNoForeignKeyChild extends UniqueData { + @IdColumn(name = "id") + public PersistentValue id; + } + + @Data(schema = "test", table = "m2m_no_fkey_cascade_holders") + static class ManyToManyNoForeignKeyCascadeHolder extends UniqueData { + @IdColumn(name = "id") + public PersistentValue id; + + @ManyToMany(link = "id=id", joinTable = "m2m_no_fkey_cascade_join", fkey = false) + @Delete(DeleteStrategy.CASCADE) + public PersistentCollection children; + } + + @Data(schema = "test", table = "m2m_no_fkey_set_null_holders") + static class ManyToManyNoForeignKeySetNullHolder extends UniqueData { + @IdColumn(name = "id") + public PersistentValue id; + + @ManyToMany(link = "id=id", joinTable = "m2m_no_fkey_set_null_join", fkey = false) + @Delete(DeleteStrategy.SET_NULL) + public PersistentCollection children; + } + + @Data(schema = "test", table = "m2m_no_fkey_no_action_holders") + static class ManyToManyNoForeignKeyNoActionHolder extends UniqueData { + @IdColumn(name = "id") + public PersistentValue id; + + @ManyToMany(link = "id=id", joinTable = "m2m_no_fkey_no_action_join", fkey = false) + @Delete(DeleteStrategy.NO_ACTION) + public PersistentCollection children; + } } diff --git a/core/src/test/java/net/staticstudios/data/mock/user/MockUser.java b/core/src/test/java/net/staticstudios/data/mock/user/MockUser.java index 02cc1e9..f49d116 100644 --- a/core/src/test/java/net/staticstudios/data/mock/user/MockUser.java +++ b/core/src/test/java/net/staticstudios/data/mock/user/MockUser.java @@ -73,7 +73,7 @@ public class MockUser extends UniqueData { @Identifier("friend_removals") public CachedValue friendRemovals = CachedValue.of(this, Integer.class) .withFallback(0); - @Delete(DeleteStrategy.CASCADE) //todo: impl delete strategy for many to many collections + @Delete(DeleteStrategy.CASCADE) @ManyToMany(link = "id=id", joinTable = "user_friends") //todo: there should be a way to specify the column names for the join tables within the ManyToMany annotation. this only matters if the referring and referenced tables are the same. public PersistentCollection friends = PersistentCollection.of(this, MockUser.class)