Skip to content

Commit ab1f6cf

Browse files
authored
feat: impl many to many deletion strategy (#37)
1 parent dfdfa69 commit ab1f6cf

9 files changed

Lines changed: 792 additions & 19 deletions

File tree

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,10 @@ The goal is to make the developer experience as seamless as possible.
9797
- `@Insert([InsertStrategy.PREFER_EXISTING/OVERWRITE_EXISTING])`: Controls insert behavior for `Reference<T>` and
9898
foreign
9999
columns.
100-
- `@Delete([DeleteStrategy.CASCADE/NO_ACTION])`: Controls delete behavior. This has different behavior depending on the
101-
relationship type, refer to the javadoc on each `DeleteStrategy` enum value for more information.
100+
- `@Delete([DeleteStrategy.CASCADE/SET_NULL/NO_ACTION])`: Controls delete behavior. This has different behavior depending on the
101+
relationship type. For many-to-many collections, `CASCADE` deletes the matching join rows and related objects,
102+
`SET_NULL` deletes only the matching join rows, and `NO_ACTION` changes nothing. Refer to the javadoc on each
103+
`DeleteStrategy` enum value for the behavior of other relationship types.
102104
- `@UpdateInterval([milliseconds])`: Used on `PersistentValue<T>` fields to control how often changes are flushed to the
103105
source database. The default is 0 milliseconds. Since a FIFO queue (one connection to the source database) is used to
104106
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
437439

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

440-
[//]: # (TODO: talk about update handlers, & add/remove handlers)
442+
[//]: # (TODO: talk about update handlers, & add/remove handlers)

annotations/src/main/java/net/staticstudios/data/Delete.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,16 @@
55
import java.lang.annotation.RetentionPolicy;
66
import java.lang.annotation.Target;
77

8+
/**
9+
* Configures how data represented by a relationship field is handled when the field's holder is deleted.
10+
*/
811
@Retention(RetentionPolicy.RUNTIME)
912
@Target(ElementType.FIELD)
1013
public @interface Delete {
14+
/**
15+
* Controls what happens to data represented by the annotated field when its holder is deleted.
16+
*
17+
* @return the deletion strategy for the relationship
18+
*/
1119
DeleteStrategy value();
1220
}
Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,28 @@
11
package net.staticstudios.data;
22

3+
/**
4+
* Actions that can be applied to related data when the object holding a relationship field is deleted.
5+
*/
36
public enum DeleteStrategy {
47
/**
5-
* When the parent data is deleted, delete this data as well.
6-
* For all data types, this means that the referenced data will be deleted when the parent data is deleted.
8+
* Delete the related data when the object holding the annotated field is deleted.
9+
* For a many-to-many collection, the matching join-table entries are removed and the
10+
* referenced objects are deleted.
711
*/
812
CASCADE,
913

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

1622
/**
17-
* In the context of a <b>persistent value, a reference, or a one-to-many collection:</b> <br>
18-
* Do nothing when the parent data is deleted.
19-
* <br><br>
20-
* For a <b>many-to-many</b> collection, this will remove the entries in the join table.
23+
* Do not modify the related data or relationship when the object holding the annotated
24+
* field is deleted. A foreign-key constraint may therefore reject the deletion while a
25+
* relationship still exists.
2126
*/
2227
NO_ACTION
2328
}

annotations/src/main/java/net/staticstudios/data/ManyToMany.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
/**
2626
* Should a foreign key constraint be created for this relation?
27+
* The configured {@link DeleteStrategy} is still applied by deletion triggers when this is {@code false}.
2728
*
2829
* @return Whether to create a foreign key constraint
2930
*/
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
package net.staticstudios.data.impl.h2.trigger;
2+
3+
import net.staticstudios.data.DeleteStrategy;
4+
import net.staticstudios.data.utils.Link;
5+
import org.h2.api.Trigger;
6+
import org.slf4j.Logger;
7+
import org.slf4j.LoggerFactory;
8+
9+
import java.sql.Connection;
10+
import java.sql.PreparedStatement;
11+
import java.sql.ResultSet;
12+
import java.sql.SQLException;
13+
import java.util.ArrayList;
14+
import java.util.List;
15+
16+
public class H2ManyToManyDeleteStrategyTrigger implements Trigger {
17+
private final Logger logger = LoggerFactory.getLogger(H2ManyToManyDeleteStrategyTrigger.class);
18+
private final List<String> holderColumnNames = new ArrayList<>();
19+
private String holderSchema;
20+
private String holderTable;
21+
private String joinSchema;
22+
private String joinTable;
23+
private String targetSchema;
24+
private String targetTable;
25+
private DeleteStrategy deleteStrategy;
26+
private List<Link> joinTableToHolderLinks;
27+
private List<Link> joinTableToTargetLinks;
28+
29+
@Override
30+
public void init(Connection connection, String schemaName, String triggerName, String tableName, boolean before, int type) {
31+
EncodedValues encodedValues = new EncodedValues(triggerName.split("static_data_v3_m2m_", 2)[1]);
32+
holderSchema = encodedValues.readValue();
33+
encodedValues.skip("_");
34+
holderTable = encodedValues.readValue();
35+
encodedValues.skip("_");
36+
joinSchema = encodedValues.readValue();
37+
encodedValues.skip("_");
38+
joinTable = encodedValues.readValue();
39+
encodedValues.skip("_");
40+
targetSchema = encodedValues.readValue();
41+
encodedValues.skip("_");
42+
targetTable = encodedValues.readValue();
43+
encodedValues.skip("__holder_links__");
44+
joinTableToHolderLinks = encodedValues.readLinks();
45+
encodedValues.skip("__target_links__");
46+
joinTableToTargetLinks = encodedValues.readLinks();
47+
encodedValues.skip("__strategy__");
48+
deleteStrategy = DeleteStrategy.valueOf(encodedValues.readUntil("__delete_trigger"));
49+
}
50+
51+
@Override
52+
public void fire(Connection connection, Object[] oldRow, Object[] newRow) throws SQLException {
53+
if (newRow != null || oldRow == null) {
54+
return;
55+
}
56+
loadHolderColumnNames(connection, oldRow.length);
57+
List<Object> holderValues = getHolderValues(oldRow);
58+
59+
if (deleteStrategy == DeleteStrategy.CASCADE) {
60+
deleteTargets(connection, holderValues);
61+
}
62+
deleteJoinEntries(connection, holderValues);
63+
}
64+
65+
private List<Object> getHolderValues(Object[] oldRow) throws SQLException {
66+
List<Object> holderValues = new ArrayList<>();
67+
for (Link link : joinTableToHolderLinks) {
68+
int holderColumnIndex = holderColumnNames.indexOf(link.columnInReferencedTable());
69+
if (holderColumnIndex < 0) {
70+
throw new SQLException("Could not find holder column " + link.columnInReferencedTable());
71+
}
72+
holderValues.add(oldRow[holderColumnIndex]);
73+
}
74+
return holderValues;
75+
}
76+
77+
private void deleteTargets(Connection connection, List<Object> holderValues) throws SQLException {
78+
StringBuilder select = new StringBuilder("SELECT ");
79+
for (Link link : joinTableToTargetLinks) {
80+
select.append("\"").append(link.columnInReferringTable()).append("\", ");
81+
}
82+
select.setLength(select.length() - 2);
83+
select.append(" FROM \"").append(joinSchema).append("\".\"").append(joinTable).append("\" WHERE ");
84+
appendJoinHolderPredicate(select);
85+
86+
List<Object[]> targetIds = new ArrayList<>();
87+
try (PreparedStatement statement = connection.prepareStatement(select.toString())) {
88+
setParameters(statement, holderValues);
89+
try (ResultSet resultSet = statement.executeQuery()) {
90+
while (resultSet.next()) {
91+
Object[] targetId = new Object[joinTableToTargetLinks.size()];
92+
for (int i = 0; i < targetId.length; i++) {
93+
targetId[i] = resultSet.getObject(i + 1);
94+
}
95+
targetIds.add(targetId);
96+
}
97+
}
98+
}
99+
100+
StringBuilder delete = new StringBuilder("DELETE FROM \"").append(targetSchema).append("\".\"")
101+
.append(targetTable).append("\" WHERE ");
102+
for (Link link : joinTableToTargetLinks) {
103+
delete.append("\"").append(link.columnInReferencedTable()).append("\" = ? AND ");
104+
}
105+
delete.setLength(delete.length() - 5);
106+
logger.debug("Executing many-to-many cascade delete: {}", delete);
107+
108+
try (PreparedStatement statement = connection.prepareStatement(delete.toString())) {
109+
for (Object[] targetId : targetIds) {
110+
for (int i = 0; i < targetId.length; i++) {
111+
statement.setObject(i + 1, targetId[i]);
112+
}
113+
statement.addBatch();
114+
}
115+
if (!targetIds.isEmpty()) {
116+
statement.executeBatch();
117+
}
118+
}
119+
}
120+
121+
private void deleteJoinEntries(Connection connection, List<Object> holderValues) throws SQLException {
122+
StringBuilder delete = new StringBuilder("DELETE FROM \"").append(joinSchema).append("\".\"")
123+
.append(joinTable).append("\" WHERE ");
124+
appendJoinHolderPredicate(delete);
125+
logger.debug("Executing many-to-many join cleanup: {}", delete);
126+
127+
try (PreparedStatement statement = connection.prepareStatement(delete.toString())) {
128+
setParameters(statement, holderValues);
129+
statement.executeUpdate();
130+
}
131+
}
132+
133+
private void appendJoinHolderPredicate(StringBuilder sql) {
134+
for (Link link : joinTableToHolderLinks) {
135+
sql.append("\"").append(link.columnInReferringTable()).append("\" = ? AND ");
136+
}
137+
sql.setLength(sql.length() - 5);
138+
}
139+
140+
private void setParameters(PreparedStatement statement, List<Object> values) throws SQLException {
141+
for (int i = 0; i < values.size(); i++) {
142+
statement.setObject(i + 1, values.get(i));
143+
}
144+
}
145+
146+
private void loadHolderColumnNames(Connection connection, int expectedColumnCount) throws SQLException {
147+
if (holderColumnNames.size() == expectedColumnCount) {
148+
return;
149+
}
150+
List<String> columns = new ArrayList<>(expectedColumnCount);
151+
try (PreparedStatement statement = connection.prepareStatement(
152+
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION"
153+
)) {
154+
statement.setString(1, holderSchema);
155+
statement.setString(2, holderTable);
156+
try (ResultSet resultSet = statement.executeQuery()) {
157+
while (resultSet.next()) {
158+
columns.add(resultSet.getString("COLUMN_NAME"));
159+
}
160+
}
161+
}
162+
holderColumnNames.clear();
163+
holderColumnNames.addAll(columns);
164+
}
165+
166+
private static final class EncodedValues {
167+
private String remaining;
168+
169+
private EncodedValues(String encoded) {
170+
remaining = encoded;
171+
}
172+
173+
private String readValue() {
174+
String[] parts = remaining.split("_", 2);
175+
int length = Integer.parseInt(parts[0]);
176+
String value = parts[1].substring(0, length);
177+
remaining = parts[1].substring(length);
178+
return value;
179+
}
180+
181+
private List<Link> readLinks() {
182+
String[] parts = remaining.split("_", 2);
183+
int valueCount = Integer.parseInt(parts[0]);
184+
remaining = parts[1];
185+
List<String> values = new ArrayList<>(valueCount);
186+
while (values.size() < valueCount) {
187+
values.add(readValue());
188+
}
189+
List<Link> links = new ArrayList<>(valueCount / 2);
190+
for (int i = 0; i < values.size(); i += 2) {
191+
links.add(new Link(values.get(i + 1), values.get(i)));
192+
}
193+
return links;
194+
}
195+
196+
private String readUntil(String suffix) {
197+
int suffixIndex = remaining.indexOf(suffix);
198+
if (suffixIndex < 0) {
199+
throw new IllegalArgumentException("Invalid encoded trigger name");
200+
}
201+
String value = remaining.substring(0, suffixIndex);
202+
remaining = remaining.substring(suffixIndex + suffix.length());
203+
return value;
204+
}
205+
206+
private void skip(String prefix) {
207+
if (!remaining.startsWith(prefix)) {
208+
throw new IllegalArgumentException("Invalid encoded trigger name");
209+
}
210+
remaining = remaining.substring(prefix.length());
211+
}
212+
}
213+
}

core/src/main/java/net/staticstudios/data/parse/SQLBuilder.java

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -748,18 +748,27 @@ private void parseManyToManyPersistentCollection(ManyToMany manyToMany, Class<?
748748

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

756752
if (manyToMany.fkey()) {
757-
ForeignKey foreignKeyJoinToDataTable = new ForeignKey("pc_m2m", joinSchema.getName(), joinTable.getName(), schema.getName(), table.getName(), onDelete);
758-
ForeignKey foreignKeyJoinToReferenceTable = new ForeignKey("pc_m2m", joinSchema.getName(), joinTable.getName(), referencedSchema.getName(), referencedTable.getName(), onDelete);
753+
OnDelete holderOnDelete = deleteStrategy == DeleteStrategy.NO_ACTION ? OnDelete.NO_ACTION : OnDelete.CASCADE;
754+
ForeignKey foreignKeyJoinToDataTable = new ForeignKey("pc_m2m", joinSchema.getName(), joinTable.getName(), schema.getName(), table.getName(), holderOnDelete);
755+
ForeignKey foreignKeyJoinToReferenceTable = new ForeignKey("pc_m2m", joinSchema.getName(), joinTable.getName(), referencedSchema.getName(), referencedTable.getName(), OnDelete.CASCADE);
759756
joinTableToDataTableLinks.forEach(foreignKeyJoinToDataTable::addLink);
760757
joinTableToReferencedTableLinks.forEach(foreignKeyJoinToReferenceTable::addLink);
761758
joinTable.addForeignKey(foreignKeyJoinToDataTable);
762759
joinTable.addForeignKey(foreignKeyJoinToReferenceTable);
763760
}
761+
762+
table.addTrigger(new SQLManyToManyDeleteStrategyTrigger(
763+
schema.getName(),
764+
table.getName(),
765+
joinSchema.getName(),
766+
joinTable.getName(),
767+
referencedSchema.getName(),
768+
referencedTable.getName(),
769+
deleteStrategy,
770+
joinTableToDataTableLinks,
771+
joinTableToReferencedTableLinks
772+
));
764773
}
765774
}

0 commit comments

Comments
 (0)