Improved Managed M2M relations (#72)

Co-authored-by: Roland Praml <roland.praml@foconis.de>
This commit is contained in:
Roland Praml
2022-08-12 07:43:38 +02:00
committed by GitHub
co-authored by Roland Praml
parent a749954cdd
commit c0066e9a73
11 changed files with 252 additions and 30 deletions
@@ -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 "";
}
@@ -41,6 +41,7 @@ public class BeanPropertyAssocMany<T> extends BeanPropertyAssoc<T> 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<T> extends BeanPropertyAssoc<T> 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<T> extends BeanPropertyAssoc<T> implements ST
return false;
}
}
public boolean isTableManaged() {
return tableManaged;
}
public IntersectionFactoryHelp getIntersectionFactory() {
return intersectionFactory;
}
}
@@ -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);
}
}
}
@@ -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<String, Object> entry : values.entrySet()) {
@@ -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<T> extends DeployBeanPropertyAssoc<T> {
* 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<T> extends DeployBeanPropertyAssoc<T> {
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.
@@ -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");
@@ -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 {
@@ -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);
@@ -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() {
@@ -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<MnyNode> 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<MnyNode> allReverseRelations;
@ManyToMany
@@ -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<String> 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);