From 81d48d67a798f7f2e23fc163310a33922a283547 Mon Sep 17 00:00:00 2001 From: Rob Bygrave Date: Tue, 20 Feb 2018 14:40:21 +1300 Subject: [PATCH] #123 - Mapping - Add support for OneToMany JoinTables --- .../java/io/ebean/bean/BeanCollection.java | 5 + .../ebean/common/AbstractBeanCollection.java | 5 + .../ddlgeneration/platform/BaseTableDdl.java | 17 ++- .../platform/WriteUniqueConstraint.java | 47 +++++++ .../dbmigration/model/MTable.java | 1 - .../build/ModelBuildIntersectionTable.java | 4 +- .../build/ModelBuildPropertyVisitor.java | 13 +- .../server/deploy/BeanDescriptorManager.java | 6 +- .../server/deploy/BeanPropertyAssocMany.java | 87 ++++++------- .../meta/DeployBeanPropertyAssocMany.java | 12 ++ .../deploy/meta/DeployBeanPropertyLists.java | 10 +- .../deploy/parse/AnnotationAssocManys.java | 6 +- .../server/persist/DefaultPersister.java | 29 ++++- .../server/persist/SaveManyPropRequest.java | 2 +- .../server/query/SqlTreeNodeBean.java | 4 +- .../server/query/SqlTreeNodeExtraJoin.java | 2 +- .../query/SqlTreeNodeManyWhereJoin.java | 2 +- src/test/java/io/ebean/BaseTestCase.java | 11 +- .../org/tests/o2m/jointable/JtMonkey.java | 57 +++++++++ .../org/tests/o2m/jointable/JtTrainer.java | 66 ++++++++++ .../java/org/tests/o2m/jointable/JtTroop.java | 65 ++++++++++ .../o2m/jointable/TestOneToManyJoinTable.java | 115 ++++++++++++++++++ 22 files changed, 478 insertions(+), 88 deletions(-) create mode 100644 src/main/java/io/ebeaninternal/dbmigration/ddlgeneration/platform/WriteUniqueConstraint.java create mode 100644 src/test/java/org/tests/o2m/jointable/JtMonkey.java create mode 100644 src/test/java/org/tests/o2m/jointable/JtTrainer.java create mode 100644 src/test/java/org/tests/o2m/jointable/JtTroop.java create mode 100644 src/test/java/org/tests/o2m/jointable/TestOneToManyJoinTable.java diff --git a/src/main/java/io/ebean/bean/BeanCollection.java b/src/main/java/io/ebean/bean/BeanCollection.java index 28b17bd05..8c3b95b08 100644 --- a/src/main/java/io/ebean/bean/BeanCollection.java +++ b/src/main/java/io/ebean/bean/BeanCollection.java @@ -191,6 +191,11 @@ public interface BeanCollection extends Serializable { */ void setModifyListening(ModifyListenMode modifyListenMode); + /** + * Return the current modify listening mode. Can be null for on newly created beans. + */ + ModifyListenMode getModifyListening(); + /** * Add an object to the additions list. *

diff --git a/src/main/java/io/ebean/common/AbstractBeanCollection.java b/src/main/java/io/ebean/common/AbstractBeanCollection.java index d02c0ffc7..866d8d0c0 100644 --- a/src/main/java/io/ebean/common/AbstractBeanCollection.java +++ b/src/main/java/io/ebean/common/AbstractBeanCollection.java @@ -140,6 +140,11 @@ abstract class AbstractBeanCollection implements BeanCollection { // Support for modify additions deletions etc - ManyToMany // --------------------------------------------------------- + @Override + public ModifyListenMode getModifyListening() { + return modifyListenMode; + } + /** * set modifyListening to be on or off. */ diff --git a/src/main/java/io/ebeaninternal/dbmigration/ddlgeneration/platform/BaseTableDdl.java b/src/main/java/io/ebeaninternal/dbmigration/ddlgeneration/platform/BaseTableDdl.java index 8969e7c7d..d94c59837 100644 --- a/src/main/java/io/ebeaninternal/dbmigration/ddlgeneration/platform/BaseTableDdl.java +++ b/src/main/java/io/ebeaninternal/dbmigration/ddlgeneration/platform/BaseTableDdl.java @@ -541,17 +541,14 @@ public class BaseTableDdl implements TableDdl { boolean inlineUniqueWhenNullable = platformDdl.isInlineUniqueWhenNullable(); - List columns = createTable.getColumn(); + List columns = new WriteUniqueConstraint(createTable.getColumn()).uniqueKeys(); for (Column column : columns) { - if (!Boolean.TRUE.equals(column.isPrimaryKey()) - && (hasValue(column.getUnique()) || hasValue(column.getUniqueOneToOne()))) { - if (Boolean.TRUE.equals(column.isNotnull()) || inlineUniqueWhenNullable) { - // normal mechanism for adding unique constraint - inlineUniqueConstraintSingle(apply, column); - } else { - // MsSqlServer & DB2 specific mechanism for adding unique constraints (that allow nulls) - externalUnique.add(column); - } + if (Boolean.TRUE.equals(column.isNotnull()) || inlineUniqueWhenNullable) { + // normal mechanism for adding unique constraint + inlineUniqueConstraintSingle(apply, column); + } else { + // SqlServer & DB2 specific mechanism for adding unique constraints (that allow nulls) + externalUnique.add(column); } } } diff --git a/src/main/java/io/ebeaninternal/dbmigration/ddlgeneration/platform/WriteUniqueConstraint.java b/src/main/java/io/ebeaninternal/dbmigration/ddlgeneration/platform/WriteUniqueConstraint.java new file mode 100644 index 000000000..b3e1c242b --- /dev/null +++ b/src/main/java/io/ebeaninternal/dbmigration/ddlgeneration/platform/WriteUniqueConstraint.java @@ -0,0 +1,47 @@ +package io.ebeaninternal.dbmigration.ddlgeneration.platform; + +import io.ebeaninternal.dbmigration.migration.Column; + +import java.util.ArrayList; +import java.util.List; + +class WriteUniqueConstraint { + + private final List primaryKeys = new ArrayList<>(); + private final List uniqueKeys = new ArrayList<>(); + + WriteUniqueConstraint(List columns) { + + // filter for unique and primary keys + for (Column column : columns) { + if (Boolean.TRUE.equals(column.isPrimaryKey())) { + primaryKeys.add(column); + } + if (hasValue(column.getUnique()) || hasValue(column.getUniqueOneToOne())){ + uniqueKeys.add(column); + } + } + } + + /** + * Return true if null or trimmed string is empty. + */ + boolean hasValue(String value) { + return value != null && !value.trim().isEmpty(); + } + + /** + * Return the single columns with unique constraints (that are not the primary key). + */ + public List uniqueKeys() { + if (uniqueKeys.isEmpty() || primaryKeys.size() > 1) { + // all single column unique constraints are valid + return uniqueKeys; + } + // filter out PFK + for (Column primaryKey : primaryKeys) { + uniqueKeys.remove(primaryKey); + } + return uniqueKeys; + } +} diff --git a/src/main/java/io/ebeaninternal/dbmigration/model/MTable.java b/src/main/java/io/ebeaninternal/dbmigration/model/MTable.java index 2401d6b31..7e02a0e03 100644 --- a/src/main/java/io/ebeaninternal/dbmigration/model/MTable.java +++ b/src/main/java/io/ebeaninternal/dbmigration/model/MTable.java @@ -408,7 +408,6 @@ public class MTable { * Return all the columns (excluding columns marked as dropped). */ public Collection allColumns() { - return columns.values(); } diff --git a/src/main/java/io/ebeaninternal/dbmigration/model/build/ModelBuildIntersectionTable.java b/src/main/java/io/ebeaninternal/dbmigration/model/build/ModelBuildIntersectionTable.java index a0b70e594..9bf4d50c0 100644 --- a/src/main/java/io/ebeaninternal/dbmigration/model/build/ModelBuildIntersectionTable.java +++ b/src/main/java/io/ebeaninternal/dbmigration/model/build/ModelBuildIntersectionTable.java @@ -9,7 +9,6 @@ import io.ebeaninternal.server.deploy.BeanPropertyAssocMany; import io.ebeaninternal.server.deploy.TableJoin; import io.ebeaninternal.server.deploy.TableJoinColumn; - /** * Add the intersection table to the model. */ @@ -32,7 +31,7 @@ public class ModelBuildIntersectionTable { this.tableJoin = manyProp.getTableJoin(); } - public void build() { + public MTable build() { intersectionTable = createTable(); MTable existingTable = ctx.addTable(intersectionTable); @@ -47,6 +46,7 @@ public class ModelBuildIntersectionTable { ctx.createDraft(intersectionTable, false); } + return intersectionTable; } private void buildFkConstraints() { diff --git a/src/main/java/io/ebeaninternal/dbmigration/model/build/ModelBuildPropertyVisitor.java b/src/main/java/io/ebeaninternal/dbmigration/model/build/ModelBuildPropertyVisitor.java index b5b631458..8d9f64be5 100644 --- a/src/main/java/io/ebeaninternal/dbmigration/model/build/ModelBuildPropertyVisitor.java +++ b/src/main/java/io/ebeaninternal/dbmigration/model/build/ModelBuildPropertyVisitor.java @@ -17,6 +17,7 @@ import io.ebeaninternal.server.deploy.TableJoinColumn; import io.ebeaninternal.server.deploy.id.ImportedId; import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.Set; @@ -120,7 +121,7 @@ public class ModelBuildPropertyVisitor extends BaseTablePropertyVisitor { @Override public void visitMany(BeanPropertyAssocMany p) { - if (p.isManyToMany()) { + if (p.hasJoinTable()) { if (p.getMappedBy() == null) { // only create on other 'owning' side @@ -130,7 +131,15 @@ public class ModelBuildPropertyVisitor extends BaseTablePropertyVisitor { // build the create table and fkey constraints // putting the DDL into ctx for later output as we are // in the middle of rendering the create table DDL - new ModelBuildIntersectionTable(ctx, p).build(); + MTable intersectionTable = new ModelBuildIntersectionTable(ctx, p).build(); + if (p.isO2mJoinTable()) { + Collection cols = intersectionTable.allColumns(); + if (cols.size() == 2) { + // always the second column that we put the unique constraint on + MColumn col = new ArrayList<>(cols).get(1); + col.setUnique(determineUniqueConstraintName(col.getName())); + } + } } } } diff --git a/src/main/java/io/ebeaninternal/server/deploy/BeanDescriptorManager.java b/src/main/java/io/ebeaninternal/server/deploy/BeanDescriptorManager.java index 134d4c3e6..ed2945ef6 100644 --- a/src/main/java/io/ebeaninternal/server/deploy/BeanDescriptorManager.java +++ b/src/main/java/io/ebeaninternal/server/deploy/BeanDescriptorManager.java @@ -1138,14 +1138,16 @@ public class BeanDescriptorManager implements BeanDescriptorMap { if (prop.getMappedBy() == null) { // if we are doc store only we are done - // this allowes the use of @OneToMany in @DocStore - Entities + // this allows the use of @OneToMany in @DocStore - Entities if (info.getDescriptor().isDocStoreOnly()) { prop.setUnidirectional(); return; } if (!findMappedBy(prop)) { - makeUnidirectional(info, prop); + if (!prop.isO2mJoinTable()) { + makeUnidirectional(info, prop); + } return; } } diff --git a/src/main/java/io/ebeaninternal/server/deploy/BeanPropertyAssocMany.java b/src/main/java/io/ebeaninternal/server/deploy/BeanPropertyAssocMany.java index 8f902c047..7c2f3cc08 100644 --- a/src/main/java/io/ebeaninternal/server/deploy/BeanPropertyAssocMany.java +++ b/src/main/java/io/ebeaninternal/server/deploy/BeanPropertyAssocMany.java @@ -58,6 +58,8 @@ public class BeanPropertyAssocMany extends BeanPropertyAssoc { */ private final boolean unidirectional; + private final boolean o2mJoinTable; + /** * Flag to indicate that the target has a order column to auto populate. */ @@ -114,6 +116,7 @@ public class BeanPropertyAssocMany extends BeanPropertyAssoc { public BeanPropertyAssocMany(BeanDescriptor descriptor, DeployBeanPropertyAssocMany deploy) { super(descriptor, deploy); this.unidirectional = deploy.isUnidirectional(); + this.o2mJoinTable = deploy.isO2mJoinTable(); this.hasOrderColumn = deploy.hasOrderColumn(); this.manyToMany = deploy.isManyToMany(); this.manyType = deploy.getManyType(); @@ -139,8 +142,7 @@ public class BeanPropertyAssocMany extends BeanPropertyAssoc { if (!isTransient) { this.help = BeanCollectionHelpFactory.create(this); - if (manyToMany) { - // only manyToMany's have imported properties + if (hasJoinTable()) { importedId = createImportedId(this, targetDescriptor, tableJoin); } else { @@ -169,7 +171,7 @@ public class BeanPropertyAssocMany extends BeanPropertyAssoc { sb.append(", "); } // these fk columns are either on the intersection (int_) or base table (t0) - String fkTableAlias = isManyToMany() ? "int_" : "t0"; + String fkTableAlias = hasJoinTable() ? "int_" : "t0"; sb.append(fkTableAlias).append(".").append(exportedProperties[i].getForeignDbColumn()); } sb.append(", ").append(fetchOrderBy); @@ -178,7 +180,7 @@ public class BeanPropertyAssocMany extends BeanPropertyAssoc { } String delStmt; - if (manyToMany) { + if (hasJoinTable()) { delStmt = "delete from " + inverseJoin.getTable() + " where "; } else { delStmt = "delete from " + targetDescriptor.getBaseTable() + " where "; @@ -347,8 +349,8 @@ public class BeanPropertyAssocMany extends BeanPropertyAssoc { */ private void addWhereParentIdIn(SpiQuery query, List parentIds) { - String tableAlias = manyToMany ? "int_." : "t0."; - if (manyToMany) { + String tableAlias = hasJoinTable() ? "int_." : "t0."; + if (hasJoinTable()) { query.setM2MIncludeJoin(inverseJoin); } String rawWhere = deriveWhereParentIdSql(true, tableAlias); @@ -475,12 +477,12 @@ public class BeanPropertyAssocMany extends BeanPropertyAssoc { StringBuilder sb = new StringBuilder(50); SpiQuery query = request.getQueryRequest().getQuery(); - if (manyToMany) { + if (hasJoinTable()) { sb.append(query.isAsDraft() ? intersectionDraftTable : intersectionPublishTable); } else { sb.append(targetDescriptor.getBaseTable(query.getTemporalMode())); } - if (softDelete && manyToMany) { + if (softDelete && hasJoinTable()) { sb.append(" x join "); sb.append(targetDescriptor.getBaseTable(query.getTemporalMode())); sb.append(" x2 on "); @@ -497,7 +499,7 @@ public class BeanPropertyAssocMany extends BeanPropertyAssoc { exportedProperties[i].appendWhere(sb, "x.", path); } if (softDelete) { - String alias = (manyToMany) ? "x2" : "x"; + String alias = hasJoinTable() ? "x2" : "x"; sb.append(" and ").append(targetDescriptor.getSoftDeletePredicate(alias)); } return sb.toString(); @@ -574,6 +576,20 @@ public class BeanPropertyAssocMany extends BeanPropertyAssoc { return manyType; } + /** + * Return true if this is many to many. + */ + public boolean hasJoinTable() { + return manyToMany || o2mJoinTable; + } + + /** + * Return true if this is a one to many with a join table. + */ + public boolean isO2mJoinTable() { + return o2mJoinTable; + } + /** * Return true if this is many to many. */ @@ -661,7 +677,7 @@ public class BeanPropertyAssocMany extends BeanPropertyAssoc { public void addSelectExported(DbSqlContext ctx, String tableAlias) { - String alias = manyToMany ? "int_" : tableAlias; + String alias = hasJoinTable() ? "int_" : tableAlias; if (alias == null) { alias = "t0"; } @@ -694,45 +710,6 @@ public class BeanPropertyAssocMany extends BeanPropertyAssoc { return sb.toString(); } -// public void setPredicates(SpiQuery query, EntityBean parentBean) { -// -// if (manyToMany){ -// // for ManyToMany lazy loading we need to include a -// // join to the intersection table. The predicate column -// // is not on the 'destination many table'. -// query.setIncludeTableJoin(inverseJoin); -// } -// -// if (embeddedExportedProperties) { -// // use the EmbeddedId object instead of the parentBean -// BeanProperty idProp = descriptor.getIdProperty(); -// parentBean = (EntityBean)idProp.getValue(parentBean); -// } -// -// for (int i = 0; i < exportedProperties.length; i++) { -// Object val = exportedProperties[i].getValue(parentBean); -// String fkColumn = exportedProperties[i].getForeignDbColumn(); -// if (!manyToMany){ -// fkColumn = targetDescriptor.getBaseTableAlias()+"."+fkColumn; -// } else { -// // use hard coded alias for intersection table -// fkColumn = "int_."+fkColumn; -// } -// query.where().eq(fkColumn, val); -// } -// -// if (extraWhere != null){ -// // replace the table alias place holder -// String ta = targetDescriptor.getBaseTableAlias(); -// String where = StringHelper.replaceString(extraWhere, "${ta}", ta); -// query.where().raw(where); -// } -// -// if (fetchOrderBy != null){ -// query.order(fetchOrderBy); -// } -// } - /** * Create the array of ExportedProperty used to build reference objects. */ @@ -776,7 +753,7 @@ public class BeanPropertyAssocMany extends BeanPropertyAssoc { String searchTable; TableJoinColumn[] columns; - if (manyToMany) { + if (hasJoinTable()) { // look for column going to intersection columns = intersectionJoin.columns(); searchTable = intersectionJoin.getTable(); @@ -1024,4 +1001,14 @@ public class BeanPropertyAssocMany extends BeanPropertyAssoc { return liveMap; } + public boolean isIncludeCascadeSave() { + // Note ManyToMany always included as we always 'save' + // the relationship via insert/delete of intersection table + // REMOVALS means including PrivateOwned relationships + return cascadeInfo.isSave() || hasJoinTable() || ModifyListenMode.REMOVALS == modifyListenMode; + } + + public boolean isIncludeCascadeDelete() { + return cascadeInfo.isDelete() || o2mJoinTable || ModifyListenMode.REMOVALS == modifyListenMode; + } } diff --git a/src/main/java/io/ebeaninternal/server/deploy/meta/DeployBeanPropertyAssocMany.java b/src/main/java/io/ebeaninternal/server/deploy/meta/DeployBeanPropertyAssocMany.java index 121938fab..112223110 100644 --- a/src/main/java/io/ebeaninternal/server/deploy/meta/DeployBeanPropertyAssocMany.java +++ b/src/main/java/io/ebeaninternal/server/deploy/meta/DeployBeanPropertyAssocMany.java @@ -21,6 +21,8 @@ public class DeployBeanPropertyAssocMany extends DeployBeanPropertyAssoc { */ private boolean manyToMany; + private boolean o2mJoinTable; + /** * Flag to indicate this is a unidirectional relationship. */ @@ -220,4 +222,14 @@ public class DeployBeanPropertyAssocMany extends DeployBeanPropertyAssoc { public boolean hasOrderColumn() { return orderColumn != null; } + + public boolean isO2mJoinTable() { + return o2mJoinTable; + } + + public void setO2mJoinTable() { + this.o2mJoinTable = true; + setModifyListenMode(ModifyListenMode.ALL); + } } + diff --git a/src/main/java/io/ebeaninternal/server/deploy/meta/DeployBeanPropertyLists.java b/src/main/java/io/ebeaninternal/server/deploy/meta/DeployBeanPropertyLists.java index c8bcfc6ce..b67093ec3 100644 --- a/src/main/java/io/ebeaninternal/server/deploy/meta/DeployBeanPropertyLists.java +++ b/src/main/java/io/ebeaninternal/server/deploy/meta/DeployBeanPropertyLists.java @@ -1,6 +1,5 @@ package io.ebeaninternal.server.deploy.meta; -import io.ebean.bean.BeanCollection.ModifyListenMode; import io.ebeaninternal.server.deploy.BeanDescriptor; import io.ebeaninternal.server.deploy.BeanDescriptorMap; import io.ebeaninternal.server.deploy.BeanProperty; @@ -437,17 +436,12 @@ public class DeployBeanPropertyLists { for (BeanPropertyAssocMany prop : manys) { switch (mode) { case Save: - if (prop.getCascadeInfo().isSave() || prop.isManyToMany() - || ModifyListenMode.REMOVALS == prop.getModifyListenMode()) { - // Note ManyToMany always included as we always 'save' - // the relationship via insert/delete of intersection table - // REMOVALS means including PrivateOwned relationships + if (prop.isIncludeCascadeSave()) { list.add(prop); } break; case Delete: - if (prop.getCascadeInfo().isDelete() || ModifyListenMode.REMOVALS == prop.getModifyListenMode()) { - // REMOVALS means including PrivateOwned relationships + if (prop.isIncludeCascadeDelete()) { list.add(prop); } break; diff --git a/src/main/java/io/ebeaninternal/server/deploy/parse/AnnotationAssocManys.java b/src/main/java/io/ebeaninternal/server/deploy/parse/AnnotationAssocManys.java index a451886ba..df4edb1d6 100644 --- a/src/main/java/io/ebeaninternal/server/deploy/parse/AnnotationAssocManys.java +++ b/src/main/java/io/ebeaninternal/server/deploy/parse/AnnotationAssocManys.java @@ -122,8 +122,10 @@ class AnnotationAssocManys extends AnnotationParser { readJoinTable(joinTable, prop); } else { - // OneToMany in theory - prop.getTableJoin().addJoinColumn(true, joinTable.joinColumns(), beanTable); + // OneToMany with @JoinTable + prop.setO2mJoinTable(); + readJoinTable(joinTable, prop); + manyToManyDefaultJoins(prop); } } diff --git a/src/main/java/io/ebeaninternal/server/persist/DefaultPersister.java b/src/main/java/io/ebeaninternal/server/persist/DefaultPersister.java index 3c9effc4d..41cce82bf 100644 --- a/src/main/java/io/ebeaninternal/server/persist/DefaultPersister.java +++ b/src/main/java/io/ebeaninternal/server/persist/DefaultPersister.java @@ -825,7 +825,7 @@ public final class DefaultPersister implements Persister { private void saveMany(SaveManyPropRequest saveMany, boolean insertMode) { - if (saveMany.getMany().isManyToMany()) { + if (saveMany.getMany().hasJoinTable()) { // check if we can save the m2m intersection in this direction // we only allow one direction based on first traversed basis @@ -925,13 +925,19 @@ public final class DefaultPersister implements Persister { throw new PersistenceException(msg); } if (!vanillaCollection) { - ((BeanCollection) value).modifyReset(); + BeanCollection manyValue = (BeanCollection) value; + setListenMode(manyValue, prop); + manyValue.modifyReset(); } } else { // BeanCollection so get the additions/deletions BeanCollection manyValue = (BeanCollection) value; - additions = manyValue.getModifyAdditions(); - deletions = manyValue.getModifyRemovals(); + if (setListenMode(manyValue, prop)) { + additions = manyValue.getActualDetails(); + } else { + additions = manyValue.getModifyAdditions(); + deletions = manyValue.getModifyRemovals(); + } // reset so the changes are only processed once manyValue.modifyReset(); } @@ -986,6 +992,19 @@ public final class DefaultPersister implements Persister { t.depth(-1); } + /** + * Check if we need to set the listen mode (on new collections persisted for the first time). + */ + private boolean setListenMode(BeanCollection manyValue, BeanPropertyAssocMany prop) { + ModifyListenMode mode = manyValue.getModifyListening(); + if (mode == null) { + // new collection persisted for the first time + manyValue.setModifyListening(prop.getModifyListenMode()); + return true; + } + return false; + } + private int deleteAssocManyIntersection(EntityBean bean, BeanPropertyAssocMany many, Transaction t, boolean publish) { // delete all intersection rows for this bean @@ -1039,7 +1058,7 @@ public final class DefaultPersister implements Persister { // Many's with delete cascade BeanPropertyAssocMany[] manys = desc.propertiesManyDelete(); for (BeanPropertyAssocMany many : manys) { - if (many.isManyToMany()) { + if (many.hasJoinTable()) { if (!softDelete) { // delete associated rows from intersection table (but not during soft delete) deleteAssocManyIntersection(parentBean, many, t, request.isPublish()); diff --git a/src/main/java/io/ebeaninternal/server/persist/SaveManyPropRequest.java b/src/main/java/io/ebeaninternal/server/persist/SaveManyPropRequest.java index f25fd9d48..4556edcd2 100644 --- a/src/main/java/io/ebeaninternal/server/persist/SaveManyPropRequest.java +++ b/src/main/java/io/ebeaninternal/server/persist/SaveManyPropRequest.java @@ -175,7 +175,7 @@ class SaveManyPropRequest { if (detailBean instanceof EntityBean) { EntityBean detail = (EntityBean) detailBean; EntityBeanIntercept ebi = detail._ebean_getIntercept(); - if (many.isManyToMany()) { + if (many.hasJoinTable()) { skipSavingThisBean = targetDescriptor.isReference(ebi); } else { if (orderColumn != null) { diff --git a/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeBean.java b/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeBean.java index a041c49c2..22a3be01d 100644 --- a/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeBean.java +++ b/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeBean.java @@ -12,8 +12,8 @@ import io.ebeaninternal.api.SpiQuery.Mode; import io.ebeaninternal.server.deploy.BeanDescriptor; import io.ebeaninternal.server.deploy.BeanProperty; import io.ebeaninternal.server.deploy.BeanPropertyAssoc; -import io.ebeaninternal.server.deploy.BeanPropertyAssocOne; import io.ebeaninternal.server.deploy.BeanPropertyAssocMany; +import io.ebeaninternal.server.deploy.BeanPropertyAssocOne; import io.ebeaninternal.server.deploy.DbReadContext; import io.ebeaninternal.server.deploy.DbSqlContext; import io.ebeaninternal.server.deploy.InheritInfo; @@ -597,7 +597,7 @@ class SqlTreeNodeBean implements SqlTreeNode { if (nodeBeanProp instanceof BeanPropertyAssocMany) { BeanPropertyAssocMany manyProp = (BeanPropertyAssocMany) nodeBeanProp; - if (manyProp.isManyToMany()) { + if (manyProp.hasJoinTable()) { String alias = ctx.getTableAlias(prefix); String[] split = SplitName.split(prefix); diff --git a/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeExtraJoin.java b/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeExtraJoin.java index 93d664c79..d6d642166 100644 --- a/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeExtraJoin.java +++ b/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeExtraJoin.java @@ -105,7 +105,7 @@ class SqlTreeNodeExtraJoin implements SqlTreeNode { if (assocBeanProperty instanceof BeanPropertyAssocMany) { BeanPropertyAssocMany manyProp = (BeanPropertyAssocMany) assocBeanProperty; - if (manyProp.isManyToMany()) { + if (manyProp.hasJoinTable()) { manyToMany = true; diff --git a/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeManyWhereJoin.java b/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeManyWhereJoin.java index 3ec80cbab..07ab86398 100644 --- a/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeManyWhereJoin.java +++ b/src/main/java/io/ebeaninternal/server/query/SqlTreeNodeManyWhereJoin.java @@ -96,7 +96,7 @@ class SqlTreeNodeManyWhereJoin implements SqlTreeNode { } else { BeanPropertyAssocMany manyProp = (BeanPropertyAssocMany) nodeBeanProp; - if (!manyProp.isManyToMany()) { + if (!manyProp.hasJoinTable()) { manyProp.addJoin(joinType, parentAlias, alias, ctx); } else { diff --git a/src/test/java/io/ebean/BaseTestCase.java b/src/test/java/io/ebean/BaseTestCase.java index effac9205..a7cbb0644 100644 --- a/src/test/java/io/ebean/BaseTestCase.java +++ b/src/test/java/io/ebean/BaseTestCase.java @@ -40,7 +40,7 @@ public abstract class BaseTestCase { * Return the generated sql trimming column alias if required. */ protected String sqlOf(Query query) { - return trimSql(query.getGeneratedSql(), 0); + return trimSql(query.getGeneratedSql()); } /** @@ -50,6 +50,15 @@ public abstract class BaseTestCase { return trimSql(query.getGeneratedSql(), columns); } + protected String trimSql(String sql) { + + if (sql.contains(" c1,")) { + // for oracle we include column alias so lets remove those + return trimSql(sql, 10); + } + return trimSql(sql, 0); + } + /** * Trim out column alias if required from the generated sql. */ diff --git a/src/test/java/org/tests/o2m/jointable/JtMonkey.java b/src/test/java/org/tests/o2m/jointable/JtMonkey.java new file mode 100644 index 000000000..eda26cf3d --- /dev/null +++ b/src/test/java/org/tests/o2m/jointable/JtMonkey.java @@ -0,0 +1,57 @@ +package org.tests.o2m.jointable; + +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Version; + +@Entity +@Table(name="monkey") +public class JtMonkey { + + @Id + long mid; + + String name; + + String foodPreference; + + @Version + long version; + + public JtMonkey(String name) { + this.name = name; + } + + public long getMid() { + return mid; + } + + public void setMid(long mid) { + this.mid = mid; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getFoodPreference() { + return foodPreference; + } + + public void setFoodPreference(String foodPreference) { + this.foodPreference = foodPreference; + } + + public long getVersion() { + return version; + } + + public void setVersion(long version) { + this.version = version; + } +} diff --git a/src/test/java/org/tests/o2m/jointable/JtTrainer.java b/src/test/java/org/tests/o2m/jointable/JtTrainer.java new file mode 100644 index 000000000..66a2e3474 --- /dev/null +++ b/src/test/java/org/tests/o2m/jointable/JtTrainer.java @@ -0,0 +1,66 @@ +package org.tests.o2m.jointable; + +import javax.persistence.CascadeType; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.JoinTable; +import javax.persistence.OneToMany; +import javax.persistence.Table; +import javax.persistence.Version; +import java.util.List; + +@Entity +@Table(name="trainer") +public class JtTrainer { + + @Id + long tid; + + String name; + + /** + * Cascade so maintain join table and save any dirty Monkey beans. + */ + @OneToMany(cascade = CascadeType.PERSIST) + @JoinTable(name = "trainer_monkey") + List monkeys; + + @Version + long version; + + public JtTrainer(String name) { + this.name = name; + } + + public long getTid() { + return tid; + } + + public void setTid(long tid) { + this.tid = tid; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public List getMonkeys() { + return monkeys; + } + + public void setMonkeys(List monkeys) { + this.monkeys = monkeys; + } + + public long getVersion() { + return version; + } + + public void setVersion(long version) { + this.version = version; + } +} diff --git a/src/test/java/org/tests/o2m/jointable/JtTroop.java b/src/test/java/org/tests/o2m/jointable/JtTroop.java new file mode 100644 index 000000000..06a4b79eb --- /dev/null +++ b/src/test/java/org/tests/o2m/jointable/JtTroop.java @@ -0,0 +1,65 @@ +package org.tests.o2m.jointable; + +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.JoinTable; +import javax.persistence.OneToMany; +import javax.persistence.Table; +import javax.persistence.Version; +import java.util.List; + +@Entity +@Table(name="troop") +public class JtTroop { + + @Id + long pid; + + String name; + + /** + * No cascading over to Monkey but we do maintain the join table regardless. + */ + @OneToMany + @JoinTable(name = "troop_monkey") + List monkeys; + + @Version + long version; + + public JtTroop(String name) { + this.name = name; + } + + public long getPid() { + return pid; + } + + public void setPid(long pid) { + this.pid = pid; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public List getMonkeys() { + return monkeys; + } + + public void setMonkeys(List monkeys) { + this.monkeys = monkeys; + } + + public long getVersion() { + return version; + } + + public void setVersion(long version) { + this.version = version; + } +} diff --git a/src/test/java/org/tests/o2m/jointable/TestOneToManyJoinTable.java b/src/test/java/org/tests/o2m/jointable/TestOneToManyJoinTable.java new file mode 100644 index 000000000..dd7f6b6c1 --- /dev/null +++ b/src/test/java/org/tests/o2m/jointable/TestOneToManyJoinTable.java @@ -0,0 +1,115 @@ +package org.tests.o2m.jointable; + +import io.ebean.BaseTestCase; +import io.ebean.Ebean; +import org.ebeantest.LoggedSqlCollector; +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class TestOneToManyJoinTable extends BaseTestCase { + + private JtTroop troop = new JtTroop("Blue"); + + private JtMonkey m0 = new JtMonkey("Sim"); + private JtMonkey m1 = new JtMonkey("Tim"); + private JtMonkey m2 = new JtMonkey("Uim"); + + private void initialInsert() { + Ebean.saveAll(Arrays.asList(troop, m0, m1, m2)); + } + + @Test + public void base() { + + initialInsert(); + + LoggedSqlCollector.start(); + + // make m0 dirty ... but no cascade saved? + m0.setFoodPreference("banana"); + troop.getMonkeys().add(m0); + troop.getMonkeys().add(m1); + + Ebean.save(troop); + + List sql = LoggedSqlCollector.current(); + assertThat(sql).hasSize(1); + assertThat(sql.get(0)).contains("insert into troop_monkey (troop_pid, monkey_mid) values (?, ?)"); + + int intersectionRows = Ebean.createSqlQuery("select count(*) as total from troop_monkey where troop_pid = ?") + .setParameter(1, troop.getPid()) + .findOne() + .getInteger("total"); + + assertThat(intersectionRows).isEqualTo(2); + + LoggedSqlCollector.current(); + JtTroop fetchTroop = Ebean.find(JtTroop.class) + .fetch("monkeys") + .where().idEq(troop.getPid()) + .findOne(); + + assertThat(fetchTroop.getMonkeys()).hasSize(2); + + sql = LoggedSqlCollector.current(); + assertThat(sql).hasSize(1); + assertThat(trimSql(sql.get(0))).contains("from troop t0 left join troop_monkey t1z_ on t1z_.troop_pid = t0.pid left join monkey t1 on t1.mid = t1z_.monkey_mid where t0.pid = ?"); + assertThat(trimSql(sql.get(0))).contains("select t0.pid, t0.name, t0.version, t1.mid, t1.name, t1.food_preference, t1.version"); + + Ebean.delete(troop); + + sql = LoggedSqlCollector.stop(); + assertThat(sql).hasSize(2); + assertThat(sql.get(0)).contains("delete from troop_monkey where troop_pid = ?"); + assertThat(sql.get(1)).contains("delete from troop where pid=? and version=?"); + + + insertWithCascade(); + } + + private void insertWithCascade() { + + JtTrainer trainer = new JtTrainer("Frank"); + + // make m2 dirty ... cascades to an update on Uim + m2.setFoodPreference("Apple"); + trainer.getMonkeys().add(m2); + trainer.getMonkeys().add(Ebean.getReference(JtMonkey.class, m1.getMid())); + trainer.getMonkeys().add(new JtMonkey("FAlp")); + trainer.getMonkeys().add(new JtMonkey("FBet")); + trainer.getMonkeys().add(new JtMonkey("FThe")); + + LoggedSqlCollector.start(); + Ebean.save(trainer); + + List sql = LoggedSqlCollector.current(); + + assertThat(sql).hasSize(6); + assertThat(sql.get(0)).contains("insert into trainer "); + assertThat(sql.get(1)).contains("insert into monkey "); + assertThat(sql.get(4)).contains("update monkey set name=?, food_preference=?, version=? where mid=? and version=?"); + assertThat(sql.get(5)).contains("insert into trainer_monkey "); + + + int intersectionRows = Ebean.createSqlQuery("select count(*) as total from trainer_monkey where trainer_tid = ?") + .setParameter(1, trainer.getTid()) + .findOne() + .getInteger("total"); + + assertThat(intersectionRows).isEqualTo(5); + + + LoggedSqlCollector.current(); + Ebean.delete(trainer); + + sql = LoggedSqlCollector.stop(); + assertThat(sql).hasSize(2); + assertThat(sql.get(0)).contains("delete from trainer_monkey where trainer_tid = ?"); + assertThat(sql.get(1)).contains("delete from trainer where tid=?"); + + } +}