From c0066e9a73155d837141ff91d728c61897a37771 Mon Sep 17 00:00:00 2001 From: Roland Praml Date: Fri, 12 Aug 2022 07:43:38 +0200 Subject: [PATCH] Improved Managed M2M relations (#72) Co-authored-by: Roland Praml --- .../annotation/ext/IntersectionFactory.java | 29 ++++++++ .../server/deploy/BeanPropertyAssocMany.java | 11 ++++ .../deploy/IntersectionFactoryHelp.java | 66 +++++++++++++++++++ .../server/deploy/IntersectionRow.java | 21 +++++- .../meta/DeployBeanPropertyAssocMany.java | 21 ++++++ .../deploy/parse/AnnotationAssocManys.java | 17 +++-- .../server/persist/DefaultPersister.java | 4 +- .../server/persist/SaveManyBeans.java | 26 ++++---- .../java/org/tests/model/m2m/MnyEdge.java | 20 +++++- .../java/org/tests/model/m2m/MnyNode.java | 8 ++- .../org/tests/model/m2m/TestM2MWithWhere.java | 59 +++++++++++++++-- 11 files changed, 252 insertions(+), 30 deletions(-) create mode 100644 ebean-api/src/main/java/io/ebean/annotation/ext/IntersectionFactory.java create mode 100644 ebean-core/src/main/java/io/ebeaninternal/server/deploy/IntersectionFactoryHelp.java diff --git a/ebean-api/src/main/java/io/ebean/annotation/ext/IntersectionFactory.java b/ebean-api/src/main/java/io/ebean/annotation/ext/IntersectionFactory.java new file mode 100644 index 000000000..5749549fb --- /dev/null +++ b/ebean-api/src/main/java/io/ebean/annotation/ext/IntersectionFactory.java @@ -0,0 +1,29 @@ +package io.ebean.annotation.ext; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Annotation to define a factory for an intersection model. This class MUST have a constructor or factory method with two parameters that accepts parent and property type. + * @author Roland Praml, FOCONIS AG + */ +@Documented +@Target({ FIELD, TYPE }) +@Retention(RUNTIME) +public @interface IntersectionFactory { + + /** + * The intersection model class. + */ + Class value(); + + /** + * An optional factory method. + */ + String factoryMethod() default ""; +} diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/BeanPropertyAssocMany.java b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/BeanPropertyAssocMany.java index 13ceb7aa2..9ff5ad68c 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/BeanPropertyAssocMany.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/BeanPropertyAssocMany.java @@ -41,6 +41,7 @@ public class BeanPropertyAssocMany extends BeanPropertyAssoc implements ST private final String intersectionPublishTable; private final String intersectionDraftTable; private final boolean orphanRemoval; + private final IntersectionFactoryHelp intersectionFactory; private IntersectionTable intersectionTable; /** * For ManyToMany this is the Inverse join used to build reference queries. @@ -99,6 +100,7 @@ public class BeanPropertyAssocMany extends BeanPropertyAssoc implements ST this.mapKey = deploy.getMapKey(); this.fetchOrderBy = deploy.getFetchOrderBy(); this.intersectionJoin = deploy.createIntersectionTableJoin(); + this.intersectionFactory = deploy.getIntersectionFactory(); if (intersectionJoin != null) { this.tableManaged = deploy.isTableManaged(); this.intersectionPublishTable = intersectionJoin.getTable(); @@ -1059,4 +1061,13 @@ public class BeanPropertyAssocMany extends BeanPropertyAssoc implements ST return false; } } + + public boolean isTableManaged() { + return tableManaged; + } + + public IntersectionFactoryHelp getIntersectionFactory() { + return intersectionFactory; + } + } diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/IntersectionFactoryHelp.java b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/IntersectionFactoryHelp.java new file mode 100644 index 000000000..e9bafa289 --- /dev/null +++ b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/IntersectionFactoryHelp.java @@ -0,0 +1,66 @@ +package io.ebeaninternal.server.deploy; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; + +/** + * Helper class to construct intersection beans. + * + * @author Roland Praml, FOCONIS AG + */ +public class IntersectionFactoryHelp { + private static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup(); + private final MethodHandle handle; + + public IntersectionFactoryHelp(Class clazz, Class leftSide, Class rightSide, String factoryMethod) { + try { + if (factoryMethod.isEmpty()) { + Constructor ctor = findCtor(clazz, leftSide, rightSide); + handle = LOOKUP.findConstructor(clazz, MethodType.methodType(void.class, ctor.getParameterTypes())); + } else { + Method method = findMethod(clazz, factoryMethod, leftSide, rightSide); + handle = LOOKUP.findStatic(clazz, method.getName(), MethodType.methodType(method.getReturnType(), method.getParameterTypes())); + } + } catch (NoSuchMethodException | IllegalAccessException e) { + throw new RuntimeException("The factory" + clazz.getName() + + " must define a public constructor or static factory method that accepts (" + leftSide.getName() + ", " + rightSide.getName() + ")", e); + } + } + + private Constructor findCtor(Class clazz, Class leftSide, Class rightSide) throws NoSuchMethodException { + for (Constructor constructor : clazz.getConstructors()) { + Class[] types = constructor.getParameterTypes(); + if (types.length == 2) { + // we are only interested in ctors with 2 arguments + if (types[0].isAssignableFrom(leftSide) && types[1].isAssignableFrom(rightSide)) { + return constructor; + } + } + } + throw new NoSuchMethodException("Could not find valid constructor"); + } + + private Method findMethod(Class clazz, String methodName, Class leftSide, Class rightSide) throws NoSuchMethodException { + for (Method method : clazz.getMethods()) { + Class[] types = method.getParameterTypes(); + if (types.length == 2 && method.getName().equals(methodName)) { + // we are only interested in ctors with 2 arguments + if (types[0].isAssignableFrom(leftSide) && types[1].isAssignableFrom(rightSide)) { + return method; + } + } + } + throw new NoSuchMethodException("Could not find valid method"); + } + + public Object invoke(Object left, Object right) { + try { + return handle.invoke(left, right); + } catch (Throwable e) { + throw new RuntimeException("Unexpected error creating Intersection bean", e); + } + } +} diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/IntersectionRow.java b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/IntersectionRow.java index f5480ac77..49fdbe010 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/IntersectionRow.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/IntersectionRow.java @@ -65,7 +65,7 @@ public final class IntersectionRow { return new DefaultSqlUpdate(server, sb.toString(), bindParams); } - public SpiSqlUpdate createDelete(SpiEbeanServer server, DeleteMode deleteMode) { + public SpiSqlUpdate createDelete(SpiEbeanServer server, DeleteMode deleteMode, String extraWhere) { BindParams bindParams = new BindParams(); StringBuilder sb = new StringBuilder(); if (deleteMode.isHard()) { @@ -89,17 +89,34 @@ public final class IntersectionRow { bindParams.setParameter(++count, bindValue); } } + addExtraWhere(sb, extraWhere); + return new DefaultSqlUpdate(server, sb.toString(), bindParams); } - public SpiSqlUpdate createDeleteChildren(SpiEbeanServer server) { + + public SpiSqlUpdate createDeleteChildren(SpiEbeanServer server, String extraWhere) { BindParams bindParams = new BindParams(); StringBuilder sb = new StringBuilder(); sb.append("delete from ").append(tableName).append(" where "); setBindParams(bindParams, sb); + addExtraWhere(sb, extraWhere); return new DefaultSqlUpdate(server, sb.toString(), bindParams); } + private void addExtraWhere(StringBuilder sb, String extraWhere) { + if (extraWhere != null) { + if (extraWhere.indexOf("${ta}") == -1) { + // no table alias append ${mta} to query. + sb.append(" and ").append(extraWhere.replace("${mta}", tableName)); + } else if (extraWhere.indexOf("${mta}") != -1) { + // we have a table alias - this is not interesting for deletion. + // but if have also a m2m table alias - this is a problem now! + throw new UnsupportedOperationException("extraWhere \'" + extraWhere + "\' has both ${ta} and ${mta} - this is not yet supported"); + } + } + } + private int setBindParams(BindParams bindParams, StringBuilder sb) { int count = 0; for (Map.Entry entry : values.entrySet()) { diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/meta/DeployBeanPropertyAssocMany.java b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/meta/DeployBeanPropertyAssocMany.java index edd41640c..45f61b77e 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/meta/DeployBeanPropertyAssocMany.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/meta/DeployBeanPropertyAssocMany.java @@ -2,6 +2,7 @@ package io.ebeaninternal.server.deploy.meta; import io.ebean.bean.BeanCollection.ModifyListenMode; import io.ebeaninternal.server.deploy.BeanDescriptor; +import io.ebeaninternal.server.deploy.IntersectionFactoryHelp; import io.ebeaninternal.server.deploy.ManyType; import io.ebeaninternal.server.deploy.TableJoin; import io.ebeaninternal.server.type.TypeReflectHelper; @@ -32,6 +33,12 @@ public class DeployBeanPropertyAssocMany extends DeployBeanPropertyAssoc { * Join for manyToMany intersection table. */ private DeployTableJoin intersectionJoin; + + /** + * Factory to create intersection beans (instead of rows). For managed intersections. + */ + private IntersectionFactoryHelp intersectionFactory; + /** * For ManyToMany this is the Inverse join used to build reference queries. */ @@ -153,6 +160,20 @@ public class DeployBeanPropertyAssocMany extends DeployBeanPropertyAssoc { this.inverseJoin = inverseJoin; } + /** + * Return the intersection factory. + */ + public IntersectionFactoryHelp getIntersectionFactory() { + return intersectionFactory; + } + + /** + * Sets the intersection factory. + */ + public void setIntersectionFactory(IntersectionFactoryHelp intersectionFactory) { + this.intersectionFactory = intersectionFactory; + } + /** * Return the order by clause used to order the fetching of the data for * this list, set or map. diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/parse/AnnotationAssocManys.java b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/parse/AnnotationAssocManys.java index 736602695..3c869933a 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/parse/AnnotationAssocManys.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/parse/AnnotationAssocManys.java @@ -4,15 +4,13 @@ import io.ebean.annotation.DbForeignKey; import io.ebean.annotation.FetchPreference; import io.ebean.annotation.HistoryExclude; import io.ebean.annotation.Where; +import io.ebean.annotation.ext.IntersectionFactory; import io.ebean.bean.BeanCollection.ModifyListenMode; import io.ebean.config.NamingConvention; import io.ebean.config.TableName; import io.ebean.core.type.ScalarType; import io.ebean.util.CamelCaseHelper; -import io.ebeaninternal.server.deploy.BeanDescriptorManager; -import io.ebeaninternal.server.deploy.BeanProperty; -import io.ebeaninternal.server.deploy.BeanTable; -import io.ebeaninternal.server.deploy.PropertyForeignKey; +import io.ebeaninternal.server.deploy.*; import io.ebeaninternal.server.deploy.meta.DeployBeanDescriptor; import io.ebeaninternal.server.deploy.meta.DeployBeanProperty; import io.ebeaninternal.server.deploy.meta.DeployBeanPropertyAssocMany; @@ -128,6 +126,11 @@ final class AnnotationAssocManys extends AnnotationAssoc { prop.getTableJoin().addJoinColumn(util, true, joinColumns, beanTable); } + IntersectionFactory intersectionFactory = get(prop, IntersectionFactory.class); + if (intersectionFactory != null) { + readIntersectionFactory(prop, intersectionFactory); + } + JoinTable joinTable = get(prop, JoinTable.class); if (joinTable != null) { if (prop.isManyToMany()) { @@ -165,6 +168,12 @@ final class AnnotationAssocManys extends AnnotationAssoc { } } + private void readIntersectionFactory(DeployBeanPropertyAssocMany prop, IntersectionFactory factory) { + Class leftSide = descriptor.getBeanType(); + Class rightSide = prop.getPropertyType(); + prop.setIntersectionFactory(new IntersectionFactoryHelp(factory.value(), leftSide, rightSide, factory.factoryMethod())); + } + private void checkSelfManyToMany(DeployBeanPropertyAssocMany prop) { if (prop.getTargetType().equals(descriptor.getBeanType())) { throw new IllegalStateException("@ManyToMany mapping for " + prop.getFullBeanName() + " requires explicit @JoinTable with joinColumns & inverseJoinColumns. Refer issue #2157"); diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/persist/DefaultPersister.java b/ebean-core/src/main/java/io/ebeaninternal/server/persist/DefaultPersister.java index efd5e29fc..056912c75 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/persist/DefaultPersister.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/persist/DefaultPersister.java @@ -913,7 +913,7 @@ public final class DefaultPersister implements Persister { private SpiSqlUpdate deleteAllIntersection(EntityBean bean, BeanPropertyAssocMany many, boolean publish) { IntersectionRow intRow = many.buildManyToManyDeleteChildren(bean, publish); - return intRow.createDeleteChildren(server); + return intRow.createDeleteChildren(server, many.extraWhere()); } /** @@ -1010,7 +1010,7 @@ public final class DefaultPersister implements Persister { if (targetDesc.isDeleteByStatement()) { // Just delete all the children with one statement IntersectionRow intRow = many.buildManyDeleteChildren(parentBean, excludeDetailIds); - SqlUpdate sqlDelete = intRow.createDelete(server, deleteMode); + SqlUpdate sqlDelete = intRow.createDelete(server, deleteMode, many.extraWhere()); executeSqlUpdate(sqlDelete, t); } else { diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/persist/SaveManyBeans.java b/ebean-core/src/main/java/io/ebeaninternal/server/persist/SaveManyBeans.java index 7b7c2d970..aaa4b6ba2 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/persist/SaveManyBeans.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/persist/SaveManyBeans.java @@ -6,18 +6,10 @@ import io.ebean.bean.EntityBeanIntercept; import io.ebeaninternal.api.CoreLog; import io.ebeaninternal.api.SpiSqlUpdate; import io.ebeaninternal.server.core.PersistRequestBean; -import io.ebeaninternal.server.deploy.BeanCollectionUtil; -import io.ebeaninternal.server.deploy.BeanDescriptor; -import io.ebeaninternal.server.deploy.BeanProperty; -import io.ebeaninternal.server.deploy.BeanPropertyAssocMany; -import io.ebeaninternal.server.deploy.IntersectionRow; +import io.ebeaninternal.server.deploy.*; import javax.persistence.PersistenceException; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Map; -import java.util.Set; +import java.util.*; import static io.ebeaninternal.server.persist.DmlUtil.isNullOrZero; @@ -305,7 +297,7 @@ final class SaveManyBeans extends SaveManyBase { // the object from the 'other' side of the ManyToMany // build a intersection row for 'delete' IntersectionRow intRow = many.buildManyToManyMapBean(parentBean, otherDelete, publish); - SpiSqlUpdate sqlDelete = intRow.createDelete(server, DeleteMode.HARD); + SpiSqlUpdate sqlDelete = intRow.createDelete(server, DeleteMode.HARD, many.extraWhere()); persister.executeOrQueue(sqlDelete, transaction, queue); } } @@ -322,6 +314,18 @@ final class SaveManyBeans extends SaveManyBase { } else { if (!many.hasImportedId(otherBean)) { throw new PersistenceException("ManyToMany bean " + otherBean + " does not have an Id value."); + } else if (many.getIntersectionFactory() != null) { + // build a intersection bean for 'insert' + // They need to be executed very late and would normally go to Queue#2, but we do not have + // a SpiSqlUpdate for now. + if (queue) { + transaction.depth(+100); + } + Object intersectionBean = many.getIntersectionFactory().invoke(parentBean, otherBean); + persister.saveRecurse((EntityBean) intersectionBean, transaction, parentBean, request.flags()); + if (queue) { + transaction.depth(-100); + } } else { // build a intersection row for 'insert' IntersectionRow intRow = many.buildManyToManyMapBean(parentBean, otherBean, publish); diff --git a/ebean-test/src/test/java/org/tests/model/m2m/MnyEdge.java b/ebean-test/src/test/java/org/tests/model/m2m/MnyEdge.java index 28e7636a4..c048d4ac7 100644 --- a/ebean-test/src/test/java/org/tests/model/m2m/MnyEdge.java +++ b/ebean-test/src/test/java/org/tests/model/m2m/MnyEdge.java @@ -7,8 +7,8 @@ import javax.persistence.Id; import javax.persistence.ManyToOne; @Entity -@Index(unique = true, columnNames = { "from_id", "to_id" }) -@Index(unique = true, columnNames = { "to_id", "from_id" }) +@Index(unique = true, columnNames = {"from_id", "to_id"}) +@Index(unique = true, columnNames = {"to_id", "from_id"}) public class MnyEdge { @Id @@ -20,6 +20,22 @@ public class MnyEdge { @ManyToOne private MnyNode to; + + public MnyEdge() { +// default + } + + public MnyEdge(Object from, Object to) { + this.from = (MnyNode) from; + this.to = (MnyNode) to; + this.id = this.from.id * 10000 + this.to.id; + this.flags = this.from.id + this.to.id; + } + + public static MnyEdge createReverseRelation(Object to, MnyNode from) { + return new MnyEdge(from, to); + } + private int flags; public Integer getId() { diff --git a/ebean-test/src/test/java/org/tests/model/m2m/MnyNode.java b/ebean-test/src/test/java/org/tests/model/m2m/MnyNode.java index 0e0fbd039..71444320f 100644 --- a/ebean-test/src/test/java/org/tests/model/m2m/MnyNode.java +++ b/ebean-test/src/test/java/org/tests/model/m2m/MnyNode.java @@ -3,6 +3,7 @@ package org.tests.model.m2m; import io.ebean.annotation.Identity; import io.ebean.annotation.Platform; import io.ebean.annotation.Where; +import io.ebean.annotation.ext.IntersectionFactory; import javax.persistence.*; import java.util.List; @@ -16,16 +17,19 @@ public class MnyNode { String name; - @ManyToMany(cascade = CascadeType.REFRESH) + @ManyToMany(cascade = CascadeType.ALL) @JoinTable(name = "mny_edge", joinColumns = @JoinColumn(name = "from_id", referencedColumnName = "id"), inverseJoinColumns = @JoinColumn(name = "to_id", referencedColumnName = "id")) + @IntersectionFactory(MnyEdge.class) + @Where(clause = "${mta}.flags != 12345 and '${dbTableName}' = 'mny_node'") List allRelations; - @ManyToMany + @ManyToMany(cascade = CascadeType.ALL) @JoinTable(name = "mny_edge", joinColumns = @JoinColumn(name = "to_id", referencedColumnName = "id"), inverseJoinColumns = @JoinColumn(name = "from_id", referencedColumnName = "id")) + @IntersectionFactory(value =MnyEdge.class, factoryMethod = "createReverseRelation") List allReverseRelations; @ManyToMany diff --git a/ebean-test/src/test/java/org/tests/model/m2m/TestM2MWithWhere.java b/ebean-test/src/test/java/org/tests/model/m2m/TestM2MWithWhere.java index a375f9a2e..6ef20ce92 100644 --- a/ebean-test/src/test/java/org/tests/model/m2m/TestM2MWithWhere.java +++ b/ebean-test/src/test/java/org/tests/model/m2m/TestM2MWithWhere.java @@ -1,8 +1,8 @@ package org.tests.model.m2m; -import io.ebean.xtest.BaseTestCase; import io.ebean.DB; import io.ebean.test.LoggedSql; +import io.ebean.xtest.BaseTestCase; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -14,27 +14,72 @@ import static org.assertj.core.api.Assertions.assertThat; * Tests M2M with complex where queries. * * @author Roland Praml, FOCONIS AG - * */ public class TestM2MWithWhere extends BaseTestCase { @Test public void testModify() throws Exception { + + MnyNode node1 = new MnyNode(); + node1.setName("node1"); + node1.setId(111); + MnyNode node2 = new MnyNode(); + node2.setName("node2"); + node2.setId(222); + MnyNode node3 = new MnyNode(); + node3.setName("node3"); + node3.setId(333); + MnyNode node4 = new MnyNode(); + node4.setName("node4"); + node4.setId(444); + + node1.getAllReverseRelations().add(node2); + node1.getAllRelations().add(node2); + node2.getAllRelations().add(node3); + node3.getAllRelations().add(node4); + DB.save(node1); + DB.save(node1); + + DB.refresh(node2); + DB.refresh(node3); + assertThat(node2.getAllRelations()).containsExactlyInAnyOrder(node1, node3); + assertThat(node3.getAllReverseRelations()).containsExactlyInAnyOrder(node2); + + DB.refresh(node1); + node1.getAllReverseRelations().clear(); + System.out.println("Clearing"); + DB.save(node1); + DB.refresh(node2); + assertThat(node2.getAllRelations()).containsExactlyInAnyOrder(node3); + + } + + @Test + public void testAccessAndModify() throws Exception { createTestData(); + MnyNode node = DB.find(MnyNode.class, 1); node.setName("fooBarBaz"); MnyNode removed = node.getAllRelations().remove(0); + LoggedSql.start(); DB.save(node); List sql = LoggedSql.stop(); - sql.forEach(System.out::println); + assertThat(sql).hasSize(3); + assertThat(sql.get(0)).contains("update mny_node set name=? where id=?; -- bind(fooBarBaz"); + assertThat(sql.get(1)).contains("delete from mny_edge where from_id = ? and to_id = ? and mny_edge.flags != 12345 and 'mny_node' = 'mny_node'"); + assertThat(sql.get(2)).contains("-- bind"); node.getAllRelations().add(removed); LoggedSql.start(); DB.save(node); sql = LoggedSql.stop(); - sql.forEach(System.out::println); + assertThat(sql).hasSize(2); + assertThat(sql.get(0)).contains("insert into mny_edge (id, flags, from_id, to_id) values (?,?,?,?)"); + assertThat(sql.get(1)).contains("-- bind"); + } + @Test public void testQuery() throws Exception { createTestData(); @@ -87,9 +132,9 @@ public class TestM2MWithWhere extends BaseTestCase { // prefetch everything LoggedSql.start(); node = DB.find(MnyNode.class) - .fetch("bit1Relations","*") - .fetch("bit1ReverseRelations","*") - .where().idEq(3).findOne(); + .fetch("bit1Relations", "*") + .fetch("bit1ReverseRelations", "*") + .where().idEq(3).findOne(); sqls = LoggedSql.stop(); assertThat(sqls).hasSize(2);