diff --git a/src/main/java/io/ebean/Ebean.java b/src/main/java/io/ebean/Ebean.java index 2f0031fd1..97f895921 100644 --- a/src/main/java/io/ebean/Ebean.java +++ b/src/main/java/io/ebean/Ebean.java @@ -638,6 +638,16 @@ public final class Ebean { serverMgr.getDefaultServer().updateAll(beans); } + /** + * Merge the bean using the given merge options. + * + * @param bean The bean to merge + * @param options The options to control the merge + */ + public static void merge(Object bean, MergeOptions options) { + serverMgr.getDefaultServer().merge(bean, options); + } + /** * Save all the beans from a Collection. */ diff --git a/src/main/java/io/ebean/EbeanServer.java b/src/main/java/io/ebean/EbeanServer.java index eab9ee668..f13cb86e7 100644 --- a/src/main/java/io/ebean/EbeanServer.java +++ b/src/main/java/io/ebean/EbeanServer.java @@ -1615,6 +1615,22 @@ public interface EbeanServer { */ void updateAll(Collection beans, Transaction transaction) throws OptimisticLockException; + /** + * Merge the bean using the given merge options. + * + * @param bean The bean to merge + * @param options The options to control the merge + */ + void merge(Object bean, MergeOptions options); + + /** + * Merge the bean using the given merge options and a transaction. + * + * @param bean The bean to merge + * @param options The options to control the merge + */ + void merge(Object bean, MergeOptions options, Transaction transaction); + /** * Insert the bean. *

diff --git a/src/main/java/io/ebean/MergeOptions.java b/src/main/java/io/ebean/MergeOptions.java new file mode 100644 index 000000000..0d1fae70b --- /dev/null +++ b/src/main/java/io/ebean/MergeOptions.java @@ -0,0 +1,29 @@ +package io.ebean; + +import java.util.Set; + +/** + * Options used to control a merge. Use MergeOptionsBuilder to create an instance. + *

+ * Instances of MergeOptions are thread safe and safe to share across threads. + */ +public interface MergeOptions { + + /** + * Returns true if Id values are supplied by the client. + *

+ * This would be the case when for example a mobile creates data in it's own local database + * and then sync's. In this case often the id values are UUID. + */ + boolean isClientGeneratedIds(); + + /** + * Return true if delete permanent should be used and false for 'normal' delete that allows soft deletes. + */ + boolean isDeletePermanent(); + + /** + * Return the paths included in the merge. + */ + Set paths(); +} diff --git a/src/main/java/io/ebean/MergeOptionsBuilder.java b/src/main/java/io/ebean/MergeOptionsBuilder.java new file mode 100644 index 000000000..c17b2d88d --- /dev/null +++ b/src/main/java/io/ebean/MergeOptionsBuilder.java @@ -0,0 +1,80 @@ +package io.ebean; + +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Builds a MergeOptions which is immutable and thread safe. + */ +public class MergeOptionsBuilder { + + private Set paths = new LinkedHashSet<>(); + + private boolean clientGeneratedIds; + + private boolean deletePermanent = true; + + /** + * Add a path that will included in the merge. + * + * @param path The path relative to the root type. + * @return The builder to chain another addPath() or build(). + */ + public MergeOptionsBuilder addPath(String path) { + paths.add(path); + return this; + } + + /** + * Set to true if Id values are supplied by the client. + *

+ * This would be the case when for example a mobile creates data in it's own local database + * and then sync's. In this case often the id values are UUID. + */ + public MergeOptionsBuilder setClientGeneratedIds() { + this.clientGeneratedIds = true; + return this; + } + + /** + * Set that deletions should use delete permanent (rather than default which allows soft deletes). + */ + public MergeOptionsBuilder setDeletePermanent() { + this.deletePermanent = true; + return this; + } + + /** + * Build and return the MergeOptions instance. + */ + public MOptions build() { + return new MOptions(paths, clientGeneratedIds, deletePermanent); + } + + private static class MOptions implements MergeOptions { + + private final boolean clientGeneratedIds; + private final boolean deletePermanent; + private final Set paths; + + private MOptions(Set paths, boolean clientGeneratedIds, boolean deletePermanent) { + this.paths = paths; + this.clientGeneratedIds = clientGeneratedIds; + this.deletePermanent = deletePermanent; + } + + public Set paths() { + return paths; + } + + @Override + public boolean isClientGeneratedIds() { + return clientGeneratedIds; + } + + @Override + public boolean isDeletePermanent() { + return deletePermanent; + } + } +} diff --git a/src/main/java/io/ebean/bean/EntityBeanIntercept.java b/src/main/java/io/ebean/bean/EntityBeanIntercept.java index de6c82456..cde09876f 100644 --- a/src/main/java/io/ebean/bean/EntityBeanIntercept.java +++ b/src/main/java/io/ebean/bean/EntityBeanIntercept.java @@ -52,6 +52,8 @@ public final class EntityBeanIntercept implements Serializable { */ private int state; + private boolean forceUpdate; + private boolean readOnly; private boolean dirty; @@ -292,6 +294,20 @@ public final class EntityBeanIntercept implements Serializable { this.readOnly = readOnly; } + /** + * Set the bean to be updated when persisted (for merge). + */ + public void setForceUpdate(boolean forceUpdate) { + this.forceUpdate = forceUpdate; + } + + /** + * Return true if the entity should be updated. + */ + public boolean isUpdate() { + return forceUpdate || state == STATE_LOADED; + } + /** * Return true if the entity has been loaded. */ diff --git a/src/main/java/io/ebeaninternal/api/SpiEbeanServer.java b/src/main/java/io/ebeaninternal/api/SpiEbeanServer.java index eb449157f..8232773fc 100644 --- a/src/main/java/io/ebeaninternal/api/SpiEbeanServer.java +++ b/src/main/java/io/ebeaninternal/api/SpiEbeanServer.java @@ -263,4 +263,9 @@ public interface SpiEbeanServer extends EbeanServer, BeanLoader, BeanCollectionL * Visit all the metrics (typically reporting them). */ void visitMetrics(MetricVisitor visitor); + + /** + * Return true if a row for the bean type and id exists. + */ + boolean exists(Class beanType, Object beanId, Transaction transaction); } diff --git a/src/main/java/io/ebeaninternal/server/core/DefaultServer.java b/src/main/java/io/ebeaninternal/server/core/DefaultServer.java index 700f10439..7a75abd5f 100644 --- a/src/main/java/io/ebeaninternal/server/core/DefaultServer.java +++ b/src/main/java/io/ebeaninternal/server/core/DefaultServer.java @@ -12,6 +12,7 @@ import io.ebean.Filter; import io.ebean.FutureIds; import io.ebean.FutureList; import io.ebean.FutureRowCount; +import io.ebean.MergeOptions; import io.ebean.PagedList; import io.ebean.PersistenceContextScope; import io.ebean.ProfileLocation; @@ -915,6 +916,20 @@ public final class DefaultServer implements SpiServer, SpiEbeanServer { return new DefaultUpdateQuery<>(createQuery(beanType)); } + @Override + public void merge(Object bean, MergeOptions options) { + merge(bean, options, null); + } + + @Override + public void merge(Object bean, MergeOptions options, Transaction transaction) { + BeanDescriptor desc = getBeanDescriptor(bean.getClass()); + if (desc == null) { + throw new PersistenceException(bean.getClass().getName() + " is NOT an Entity Bean registered with this server?"); + } + executeInTrans((txn) -> persister.merge(desc, checkEntityBean(bean), options, txn), transaction); + } + @Override public Query find(Class beanType) { return createQuery(beanType); @@ -1271,6 +1286,12 @@ public final class DefaultServer implements SpiServer, SpiEbeanServer { } } + @Override + public boolean exists(Class beanType, Object beanId, Transaction transaction) { + List ids = findIds(find(beanType).setId(beanId), transaction); + return !ids.isEmpty(); + } + @Override public List findIds(Query query, Transaction t) { @@ -2234,8 +2255,7 @@ public final class DefaultServer implements SpiServer, SpiEbeanServer { /** * Returns a set of properties if saving the bean will violate the unique constraints (defined by given properties). */ - private Set checkUniqueness(EntityBean entityBean, BeanDescriptor beanDesc, BeanProperty[] props, - Transaction transaction) { + private Set checkUniqueness(EntityBean entityBean, BeanDescriptor beanDesc, BeanProperty[] props, Transaction transaction) { BeanProperty idProperty = beanDesc.getIdProperty(); Query query = new DefaultOrmQuery<>(beanDesc, this, expressionFactory); ExpressionList exprList = query.where(); diff --git a/src/main/java/io/ebeaninternal/server/core/PersistRequestBean.java b/src/main/java/io/ebeaninternal/server/core/PersistRequestBean.java index 8c0113b1e..33f72c636 100644 --- a/src/main/java/io/ebeaninternal/server/core/PersistRequestBean.java +++ b/src/main/java/io/ebeaninternal/server/core/PersistRequestBean.java @@ -23,6 +23,7 @@ import io.ebeaninternal.server.deploy.generatedproperty.GeneratedProperty; import io.ebeaninternal.server.deploy.id.ImportedId; import io.ebeaninternal.server.persist.BatchControl; import io.ebeaninternal.server.persist.BatchedSqlException; +import io.ebeaninternal.server.persist.Flags; import io.ebeaninternal.server.persist.PersistExecute; import io.ebeaninternal.server.transaction.BeanPersistIdMap; import io.ebeanservice.docstore.api.DocStoreUpdate; @@ -75,6 +76,8 @@ public final class PersistRequestBean extends PersistRequest implements BeanP private final boolean publish; + private int flags; + private DocStoreMode docStoreMode; private ConcurrencyMode concurrencyMode; @@ -152,7 +155,7 @@ public final class PersistRequestBean extends PersistRequest implements BeanP private boolean getterCallback; public PersistRequestBean(SpiEbeanServer server, T bean, Object parentBean, BeanManager mgr, SpiTransaction t, - PersistExecute persistExecute, PersistRequest.Type type, boolean saveRecurse, boolean publish) { + PersistExecute persistExecute, PersistRequest.Type type, int flags) { super(server, t, persistExecute); this.entityBean = (EntityBean) bean; @@ -165,7 +168,8 @@ public final class PersistRequestBean extends PersistRequest implements BeanP this.controller = beanDescriptor.getPersistController(); this.type = type; this.docStoreMode = calcDocStoreMode(transaction, type); - if (saveRecurse) { + this.flags = flags; + if (Flags.isRecurse(flags)) { this.persistCascade = t.isPersistCascade(); } @@ -179,7 +183,7 @@ public final class PersistRequestBean extends PersistRequest implements BeanP beanDescriptor.checkMutableProperties(intercept); } this.concurrencyMode = beanDescriptor.getConcurrencyMode(intercept); - this.publish = publish; + this.publish = Flags.isPublish(flags); if (isMarkDraftDirty(publish)) { beanDescriptor.setDraftDirty(entityBean, true); } @@ -231,6 +235,13 @@ public final class PersistRequestBean extends PersistRequest implements BeanP if (createImplicitTransIfRequired()) { docStoreMode = calcDocStoreMode(transaction, type); } + checkBatchEscalationOnCascade(); + } + + /** + * Check for batch escalation on cascade. + */ + public void checkBatchEscalationOnCascade() { if (transaction.checkBatchEscalationOnCascade(this)) { // we escalated to use batch mode so flush when done // but if createdTransaction then commit will flush it @@ -1076,6 +1087,13 @@ public final class PersistRequestBean extends PersistRequest implements BeanP return requestUpdateAllLoadedProps; } + /** + * Return the flags set on this persist request. + */ + public int getFlags() { + return flags; + } + /** * Return true if this request is a 'publish' action. */ @@ -1226,4 +1244,25 @@ public final class PersistRequestBean extends PersistRequest implements BeanP public void profile() { profileBase(type.profileEventId, profileOffset, beanDescriptor.getProfileId(), 1); } + + /** + * Set the request flags indicating this is an insert. + */ + public void flagInsert() { + flags = Flags.setInsert(flags); + } + + /** + * Unset the request insert flag indicating this is an update. + */ + public void flagUpdate() { + flags = Flags.unsetInsert(flags); + } + + /** + * Return true if this request is an insert. + */ + public boolean isInsertedParent() { + return Flags.isInsert(flags); + } } diff --git a/src/main/java/io/ebeaninternal/server/core/Persister.java b/src/main/java/io/ebeaninternal/server/core/Persister.java index 981032053..82e46b856 100644 --- a/src/main/java/io/ebeaninternal/server/core/Persister.java +++ b/src/main/java/io/ebeaninternal/server/core/Persister.java @@ -1,12 +1,15 @@ package io.ebeaninternal.server.core; import io.ebean.CallableSql; +import io.ebean.MergeOptions; import io.ebean.Query; import io.ebean.SqlUpdate; import io.ebean.Transaction; import io.ebean.Update; import io.ebean.bean.EntityBean; import io.ebean.meta.MetricVisitor; +import io.ebeaninternal.api.SpiTransaction; +import io.ebeaninternal.server.deploy.BeanDescriptor; import java.util.Collection; import java.util.List; @@ -16,6 +19,11 @@ import java.util.List; */ public interface Persister { + /** + * Merge the bean. + */ + int merge(BeanDescriptor desc, EntityBean entityBean, MergeOptions options, SpiTransaction transaction); + /** * Update the bean. */ diff --git a/src/main/java/io/ebeaninternal/server/deploy/BaseCollectionHelp.java b/src/main/java/io/ebeaninternal/server/deploy/BaseCollectionHelp.java new file mode 100644 index 000000000..5e50e9700 --- /dev/null +++ b/src/main/java/io/ebeaninternal/server/deploy/BaseCollectionHelp.java @@ -0,0 +1,17 @@ +package io.ebeaninternal.server.deploy; + +import io.ebean.bean.BeanCollection; + +import java.util.Collection; + +abstract class BaseCollectionHelp implements BeanCollectionHelp { + + @Override + public Collection underlying(Object value) { + if (value instanceof BeanCollection) { + return ((BeanCollection)value).getActualDetails(); + } else { + return (Collection)value; + } + } +} diff --git a/src/main/java/io/ebeaninternal/server/deploy/BeanCollectionHelp.java b/src/main/java/io/ebeaninternal/server/deploy/BeanCollectionHelp.java index 1de0e6d24..8891048bb 100644 --- a/src/main/java/io/ebeaninternal/server/deploy/BeanCollectionHelp.java +++ b/src/main/java/io/ebeaninternal/server/deploy/BeanCollectionHelp.java @@ -10,6 +10,7 @@ import io.ebean.bean.EntityBean; import io.ebeaninternal.server.text.json.SpiJsonWriter; import java.io.IOException; +import java.util.Collection; /** * Helper functions for performing tasks on Lists Sets or Maps. @@ -21,6 +22,11 @@ public interface BeanCollectionHelp { */ void setLoader(BeanCollectionLoader loader); + /** + * Return the underlying collection of beans. + */ + Collection underlying(Object value); + /** * Return the mechanism to add beans to the underlying collection. *

diff --git a/src/main/java/io/ebeaninternal/server/deploy/BeanListHelp.java b/src/main/java/io/ebeaninternal/server/deploy/BeanListHelp.java index 1f5bde3c8..df5429233 100644 --- a/src/main/java/io/ebeaninternal/server/deploy/BeanListHelp.java +++ b/src/main/java/io/ebeaninternal/server/deploy/BeanListHelp.java @@ -17,7 +17,7 @@ import java.util.List; /** * Helper object for dealing with Lists. */ -public final class BeanListHelp implements BeanCollectionHelp { +public final class BeanListHelp extends BaseCollectionHelp { private final BeanPropertyAssocMany many; private final BeanDescriptor targetDescriptor; diff --git a/src/main/java/io/ebeaninternal/server/deploy/BeanMapHelp.java b/src/main/java/io/ebeaninternal/server/deploy/BeanMapHelp.java index c91b4d22e..e3b42bea7 100644 --- a/src/main/java/io/ebeaninternal/server/deploy/BeanMapHelp.java +++ b/src/main/java/io/ebeaninternal/server/deploy/BeanMapHelp.java @@ -18,7 +18,7 @@ import java.util.Map.Entry; /** * Helper specifically for dealing with Maps. */ -public final class BeanMapHelp implements BeanCollectionHelp { +public final class BeanMapHelp extends BaseCollectionHelp { private final BeanPropertyAssocMany many; private final BeanDescriptor targetDescriptor; diff --git a/src/main/java/io/ebeaninternal/server/deploy/BeanPropertyAssocMany.java b/src/main/java/io/ebeaninternal/server/deploy/BeanPropertyAssocMany.java index 9f52a6a33..f193624b0 100644 --- a/src/main/java/io/ebeaninternal/server/deploy/BeanPropertyAssocMany.java +++ b/src/main/java/io/ebeaninternal/server/deploy/BeanPropertyAssocMany.java @@ -205,6 +205,13 @@ public class BeanPropertyAssocMany extends BeanPropertyAssoc { // by default not including "Many" properties in document store } + /** + * Return the underlying collection of beans. + */ + public Collection getRawCollection(EntityBean bean) { + return help.underlying(getVal(bean)); + } + /** * Copy collection value if existing is empty. */ diff --git a/src/main/java/io/ebeaninternal/server/deploy/BeanSetHelp.java b/src/main/java/io/ebeaninternal/server/deploy/BeanSetHelp.java index f191b919f..0dfdf40af 100644 --- a/src/main/java/io/ebeaninternal/server/deploy/BeanSetHelp.java +++ b/src/main/java/io/ebeaninternal/server/deploy/BeanSetHelp.java @@ -17,7 +17,7 @@ import java.util.Set; /** * Helper specifically for dealing with Sets. */ -public final class BeanSetHelp implements BeanCollectionHelp { +public final class BeanSetHelp extends BaseCollectionHelp { private final BeanPropertyAssocMany many; private final BeanDescriptor targetDescriptor; diff --git a/src/main/java/io/ebeaninternal/server/persist/DefaultPersister.java b/src/main/java/io/ebeaninternal/server/persist/DefaultPersister.java index 0be2f64ae..0b3d20e1a 100644 --- a/src/main/java/io/ebeaninternal/server/persist/DefaultPersister.java +++ b/src/main/java/io/ebeaninternal/server/persist/DefaultPersister.java @@ -1,6 +1,7 @@ package io.ebeaninternal.server.persist; import io.ebean.CallableSql; +import io.ebean.MergeOptions; import io.ebean.Query; import io.ebean.SqlUpdate; import io.ebean.Transaction; @@ -53,8 +54,6 @@ import java.util.Set; *

  • Handles cascading of save and delete
  • *
  • Handles the batching and queueing
  • *

    - * - * @see io.ebeaninternal.server.persist.DefaultPersistExecute */ public final class DefaultPersister implements Persister { @@ -161,7 +160,7 @@ public final class DefaultPersister implements Persister { draftHandler.resetDraft(draftBean); PUB.trace("draftRestore bean [{}] id[{}]", desc.getName(), draftHandler.getId()); - update(createRequest(draftBean, transaction, null, mgr, Type.UPDATE, true, false)); + update(createRequest(draftBean, transaction, null, mgr, Type.UPDATE, Flags.RECURSE)); } PUB.debug("draftRestore - complete for [{}]", desc.getName()); @@ -211,7 +210,7 @@ public final class DefaultPersister implements Persister { Type persistType = draftHandler.isInsert() ? Type.INSERT : Type.UPDATE; PUB.trace("publish bean [{}] id[{}] type[{}]", desc.getName(), draftHandler.getId(), persistType); - PersistRequestBean request = createRequest(liveBean, transaction, null, mgr, persistType, true, true); + PersistRequestBean request = createRequest(liveBean, transaction, null, mgr, persistType, Flags.PUBLISH_RECURSE); if (persistType == Type.INSERT) { insert(request); } else { @@ -281,7 +280,7 @@ public final class DefaultPersister implements Persister { // update the dirty status on the drafts that have been published PUB.debug("publish - update dirty status on [{}] drafts", draftUpdates.size()); for (T draftUpdate : draftUpdates) { - update(createRequest(draftUpdate, transaction, null, mgr, Type.UPDATE, false, false)); + update(createRequest(draftUpdate, transaction, null, mgr, Type.UPDATE, Flags.ZERO)); } } } @@ -346,6 +345,28 @@ public final class DefaultPersister implements Persister { return deleteRequest(createRequest(detailBean, t, deleteType)); } + @Override + public int merge(BeanDescriptor desc, EntityBean bean, MergeOptions options, SpiTransaction transaction) { + + MergeHandler merge = new MergeHandler(server, desc, bean, options, transaction); + List deleteBeans = merge.merge(); + if (!deleteBeans.isEmpty()) { + // all detected deletes for the merge paths + for (EntityBean deleteBean : deleteBeans) { + delete(deleteBean, transaction, options.isDeletePermanent()); + } + } + + // cascade save as normal with forceUpdate flags set + PersistRequestBean request = createRequestRecurse(bean, transaction, null, Flags.MERGE); + request.checkBatchEscalationOnCascade(); + saveRecurse(request); + request.flushBatchOnCascade(); + + // lambda expects a return + return 0; + } + /** * Update the bean. */ @@ -368,7 +389,7 @@ public final class DefaultPersister implements Persister { if (req.isReference()) { // its a reference so see if there are manys to save... if (req.isPersistCascade()) { - saveAssocMany(false, req, false); + saveAssocMany(req); } req.checkUpdatedManysOnly(); } else { @@ -389,7 +410,7 @@ public final class DefaultPersister implements Persister { */ @Override public void save(EntityBean bean, Transaction t) { - if (bean._ebean_getIntercept().isLoaded()) { + if (bean._ebean_getIntercept().isUpdate()) { // deleteMissingChildren is false when using 'save' on 'loaded' beans update(bean, t, false); } else { @@ -416,19 +437,22 @@ public final class DefaultPersister implements Persister { } } - void saveRecurse(EntityBean bean, Transaction t, Object parentBean, boolean insertMode, boolean publish) { + void saveRecurse(EntityBean bean, Transaction t, Object parentBean, int flags) { // determine insert or update taking into account stateless updates - PersistRequestBean request = createRequestRecurse(bean, t, parentBean, insertMode, publish); + PersistRequestBean request = createRequestRecurse(bean, t, parentBean, flags); + saveRecurse(request); + } + private void saveRecurse(PersistRequestBean request) { if (request.isReference()) { // its a reference... if (request.isPersistCascade()) { // save any associated List held beans - saveAssocMany(false, request, insertMode); + request.flagUpdate(); + saveAssocMany(request); } request.checkUpdatedManysOnly(); - } else { if (request.isInsert()) { insert(request); @@ -447,11 +471,11 @@ public final class DefaultPersister implements Persister { // skip as already inserted/updated in this request (recursive cascading) return; } - + request.flagInsert(); try { if (request.isPersistCascade()) { // save associated One beans recursively first - saveAssocOne(request, true); + saveAssocOne(request); } // set the IDGenerated value if required @@ -460,7 +484,7 @@ public final class DefaultPersister implements Persister { if (request.isPersistCascade()) { // save any associated List held beans - saveAssocMany(true, request, true); + saveAssocMany(request); } } finally { request.unRegisterBean(); @@ -476,11 +500,11 @@ public final class DefaultPersister implements Persister { // skip as already inserted/updated in this request (recursive cascading) return; } - + request.flagUpdate(); try { if (request.isPersistCascade()) { // save associated One beans recursively first - saveAssocOne(request, false); + saveAssocOne(request); } if (request.isDirty()) { @@ -495,7 +519,7 @@ public final class DefaultPersister implements Persister { if (request.isPersistCascade()) { // save all the beans in assocMany's after - saveAssocMany(false, request, false); + saveAssocMany(request); } request.checkUpdatedManysOnly(); @@ -518,7 +542,7 @@ public final class DefaultPersister implements Persister { if (originalRequest.isHardDeleteDraft()) { // a hard delete of a draftable bean so first we need to delete the associated 'live' bean // due to FK constraint and then after that execute the original delete of the draft bean - return deleteRequest(createPublishRequest(originalRequest.createReference(), t, Type.DELETE_PERMANENT, true), originalRequest); + return deleteRequest(createPublishRequest(originalRequest.createReference(), t, Type.DELETE_PERMANENT, Flags.PUBLISH), originalRequest); } else { // normal delete or soft delete @@ -610,7 +634,7 @@ public final class DefaultPersister implements Persister { int rowCount = deleteRecurse(bean, transaction, permanent); if (rowCount == -1) { total = -1; - } else if (total != -1){ + } else if (total != -1) { total += rowCount; } } @@ -838,7 +862,7 @@ public final class DefaultPersister implements Persister { * bean to the child beans. *

    */ - private void saveAssocMany(boolean insertedParent, PersistRequestBean request, boolean insertMode) { + private void saveAssocMany(PersistRequestBean request) { EntityBean parentBean = request.getEntityBean(); BeanDescriptor desc = request.getBeanDescriptor(); @@ -854,7 +878,7 @@ public final class DefaultPersister implements Persister { if (!prop.isSaveRecurseSkippable(detailBean)) { t.depth(+1); prop.setParentBeanToChild(parentBean, detailBean); - saveRecurse(detailBean, t, parentBean, insertMode, request.isPublish()); + saveRecurse(detailBean, t, parentBean, request.getFlags()); t.depth(-1); } } @@ -863,10 +887,11 @@ public final class DefaultPersister implements Persister { // many's with cascade save BeanPropertyAssocMany[] manys = desc.propertiesManySave(); + boolean insertedParent = request.isInsertedParent(); for (BeanPropertyAssocMany many : manys) { // check that property is loaded and collection should be cascaded to if (request.isLoadedProperty(many) && !many.isSkipSaveBeanCollection(parentBean, insertedParent)) { - saveMany(new SaveManyPropRequest(insertedParent, many, parentBean, request), insertMode); + saveMany(new SaveManyPropRequest(insertedParent, many, parentBean, request)); if (!insertedParent) { request.addUpdatedManyProperty(many); } @@ -874,7 +899,7 @@ public final class DefaultPersister implements Persister { } } - private void saveMany(SaveManyPropRequest saveMany, boolean insertMode) { + private void saveMany(SaveManyPropRequest saveMany) { if (saveMany.getMany().hasJoinTable()) { @@ -882,7 +907,7 @@ public final class DefaultPersister implements Persister { // we only allow one direction based on first traversed basis boolean saveIntersectionFromThisDirection = saveMany.isSaveIntersection(); if (saveMany.isCascade()) { - saveAssocManyDetails(saveMany, false, insertMode); + saveAssocManyDetails(saveMany, false); } // for ManyToMany save the 'relationship' via inserts/deletes // into/from the intersection table @@ -900,7 +925,7 @@ public final class DefaultPersister implements Persister { } if (saveMany.isCascade()) { // potentially deletes 'missing children' for 'stateless update' - saveAssocManyDetails(saveMany, saveMany.isDeleteMissingChildren(), insertMode); + saveAssocManyDetails(saveMany, saveMany.isDeleteMissingChildren()); } } } @@ -922,7 +947,7 @@ public final class DefaultPersister implements Persister { EntityBean eb = (EntityBean) removedBean; if (eb._ebean_getIntercept().isLoaded()) { // only delete if the bean was loaded meaning that it is known to exist in the DB - deleteRequest(createPublishRequest(removedBean, saveMany.getTransaction(), PersistRequest.Type.DELETE, saveMany.isPublish())); + deleteRequest(createPublishRequest(removedBean, saveMany.getTransaction(), PersistRequest.Type.DELETE, saveMany.getFlags())); } } } @@ -933,9 +958,9 @@ public final class DefaultPersister implements Persister { /** * Save the details from a OneToMany collection. */ - private void saveAssocManyDetails(SaveManyPropRequest saveMany, boolean deleteMissingChildren, boolean insertMode) { + private void saveAssocManyDetails(SaveManyPropRequest saveMany, boolean deleteMissingChildren) { - saveMany.saveDetails(this, deleteMissingChildren, insertMode); + saveMany.saveDetails(this, deleteMissingChildren); } /** @@ -1056,13 +1081,12 @@ public final class DefaultPersister implements Persister { return false; } - private int deleteAssocManyIntersection(EntityBean bean, BeanPropertyAssocMany many, Transaction t, boolean publish) { + private void deleteAssocManyIntersection(EntityBean bean, BeanPropertyAssocMany many, Transaction t, boolean publish) { // delete all intersection rows for this bean IntersectionRow intRow = many.buildManyToManyDeleteChildren(bean, publish); SqlUpdate sqlDelete = intRow.createDeleteChildren(server); - - return executeSqlUpdate(sqlDelete, t); + executeSqlUpdate(sqlDelete, t); } /** @@ -1154,7 +1178,7 @@ public final class DefaultPersister implements Persister { *

    */ void deleteManyDetails(SpiTransaction t, BeanDescriptor desc, EntityBean parentBean, - BeanPropertyAssocMany many, List excludeDetailIds, boolean softDelete) { + BeanPropertyAssocMany many, List excludeDetailIds, boolean softDelete) { if (many.getCascadeInfo().isDelete()) { // cascade delete the beans in the collection @@ -1202,7 +1226,7 @@ public final class DefaultPersister implements Persister { /** * Save any associated one beans. */ - private void saveAssocOne(PersistRequestBean request, boolean insertMode) { + private void saveAssocOne(PersistRequestBean request) { BeanDescriptor desc = request.getBeanDescriptor(); @@ -1219,7 +1243,7 @@ public final class DefaultPersister implements Persister { && !request.isParent(detailBean)) { SpiTransaction t = request.getTransaction(); t.depth(-1); - saveRecurse(detailBean, t, null, insertMode, request.isPublish()); + saveRecurse(detailBean, t, null, request.getFlags()); t.depth(+1); } } @@ -1315,25 +1339,25 @@ public final class DefaultPersister implements Persister { * perform an insert, update or delete. */ private PersistRequestBean createRequest(T bean, Transaction t, PersistRequest.Type type) { - return createRequestInternal(bean, t, type, false, false); + return createRequestInternal(bean, t, type, Flags.ZERO); } /** * Create the Persist Request Object additionally specifying the publish status. */ - private PersistRequestBean createPublishRequest(T bean, Transaction t, PersistRequest.Type type, boolean publish) { - return createRequestInternal(bean, t, type, false, publish); + private PersistRequestBean createPublishRequest(T bean, Transaction t, PersistRequest.Type type, int flags) { + return createRequestInternal(bean, t, type, Flags.unsetRecuse(flags)); } /** * Create the Persist Request Object additionally specifying the publish status. */ - private PersistRequestBean createRequestInternal(T bean, Transaction t, PersistRequest.Type type, boolean saveRecurse, boolean publish) { + private PersistRequestBean createRequestInternal(T bean, Transaction t, PersistRequest.Type type, int flags) { BeanManager mgr = getBeanManager(bean); if (mgr == null) { throw new PersistenceException(errNotRegistered(bean.getClass())); } - return createRequest(bean, t, null, mgr, type, saveRecurse, publish); + return createRequest(bean, t, null, mgr, type, flags); } /** @@ -1341,7 +1365,7 @@ public final class DefaultPersister implements Persister { *

    * This call determines the PersistRequest.Type based on bean state and the insert flag (root persist type). */ - private PersistRequestBean createRequestRecurse(T bean, Transaction t, Object parentBean, boolean insertMode, boolean publish) { + private PersistRequestBean createRequestRecurse(T bean, Transaction t, Object parentBean, int flags) { BeanManager mgr = getBeanManager(bean); if (mgr == null) { throw new PersistenceException(errNotRegistered(bean.getClass())); @@ -1349,14 +1373,15 @@ public final class DefaultPersister implements Persister { BeanDescriptor desc = mgr.getBeanDescriptor(); EntityBean entityBean = (EntityBean) bean; PersistRequest.Type type; - if (publish) { + if (Flags.isPublishOrMerge(flags)) { // insert if it is a new bean (as publish created it) - type = entityBean._ebean_getIntercept().isNew() ? Type.INSERT : Type.UPDATE; + type = entityBean._ebean_getIntercept().isUpdate() ? Type.UPDATE : Type.INSERT; } else { // determine Insert or Update based on bean state and insert flag + boolean insertMode = Flags.isInsert(flags); type = desc.isInsertMode(entityBean._ebean_getIntercept(), insertMode) ? Type.INSERT : Type.UPDATE; } - return createRequest(bean, t, parentBean, mgr, type, true, publish); + return createRequest(bean, t, parentBean, mgr, type, Flags.setRecurse(flags)); } /** @@ -1365,7 +1390,7 @@ public final class DefaultPersister implements Persister { */ @SuppressWarnings({"unchecked", "rawtypes"}) private PersistRequestBean createRequest(T bean, Transaction t, Object parentBean, BeanManager mgr, - PersistRequest.Type type, boolean saveRecurse, boolean publish) { + PersistRequest.Type type, int flags) { if (type == Type.DELETE_PERMANENT) { type = Type.DELETE; @@ -1374,7 +1399,7 @@ public final class DefaultPersister implements Persister { type = Type.SOFT_DELETE; } - return new PersistRequestBean(server, bean, parentBean, mgr, (SpiTransaction) t, persistExecute, type, saveRecurse, publish); + return new PersistRequestBean(server, bean, parentBean, mgr, (SpiTransaction) t, persistExecute, type, flags); } private String errNotRegistered(Class beanClass) { diff --git a/src/main/java/io/ebeaninternal/server/persist/Flags.java b/src/main/java/io/ebeaninternal/server/persist/Flags.java new file mode 100644 index 000000000..ffde410c5 --- /dev/null +++ b/src/main/java/io/ebeaninternal/server/persist/Flags.java @@ -0,0 +1,135 @@ +package io.ebeaninternal.server.persist; + +/** + * Flags used in persistence. + *

    + * Allows passing of flag state when recursively persisting. + */ +public class Flags { + + /** + * Indicates the bean is being inserted. + */ + public static final int INSERT = 0x00000001; + + /** + * Indicates persist cascade. + */ + public static final int RECURSE = 0x00000002; + + /** + * Indicates Publish mode. + */ + public static final int PUBLISH = 0x00000004; + + /** + * Indicates Merge mode. + */ + public static final int MERGE = 0x00000008; + + /** + * No flags set. + */ + public static final int ZERO = 0; + + public static final int PUBLISH_RECURSE = PUBLISH + RECURSE; + + private static final int PUBLISH_MERGE = PUBLISH + MERGE; + + /** + * Return true if the bean is being inserted. + */ + public static boolean isInsert(int state) { + return isSet(state, INSERT); + } + + /** + * Return true if persist cascading. + */ + public static boolean isRecurse(int state) { + return isSet(state, RECURSE); + } + + /** + * Return true if part of a Publish. + */ + public static boolean isPublish(int state) { + return isSet(state, PUBLISH); + } + + /** + * Return true if part of a Merge. + */ + public static boolean isMerge(int state) { + return isSet(state, PUBLISH); + } + + /** + * Return true if part of a Merge or Publish. + */ + public static boolean isPublishOrMerge(long state) { + return (state & PUBLISH_MERGE) != 0; + } + + /** + * Return true if the given flag is set. + */ + public static boolean isSet(int state, int flag) { + return (state & flag) == flag; + } + + /** + * Set Insert flag. + */ + public static int setInsert(int state) { + return set(state, INSERT, true); + } + + /** + * Parent was not inserted. + */ + public static int unsetInsert(int state) { + return set(state, INSERT, false); + } + + /** + * Set Recurse flag. + */ + public static int setRecurse(int state) { + return set(state, RECURSE, true); + } + + public static int unsetRecuse(int state) { + return set(state, RECURSE, false); + } + + /** + * Set Publish flag. + */ + public static int setPublish(int state) { + return set(state, PUBLISH, true); + } + + public static int unsetPublish(int state) { + return set(state, PUBLISH, false); + } + + /** + * Set Merge flag. + */ + public static int setMerge(int state) { + return set(state, MERGE, true); + } + + public static int unsetMerge(int state) { + return set(state, MERGE, false); + } + + private static int set(int state, int flag, boolean setFlag) { + if (setFlag) { + return (state |= flag); + } else { + return state &= ~flag; + } + } +} diff --git a/src/main/java/io/ebeaninternal/server/persist/MergeContext.java b/src/main/java/io/ebeaninternal/server/persist/MergeContext.java new file mode 100644 index 000000000..8de5b7ee4 --- /dev/null +++ b/src/main/java/io/ebeaninternal/server/persist/MergeContext.java @@ -0,0 +1,56 @@ +package io.ebeaninternal.server.persist; + +import io.ebean.bean.EntityBean; +import io.ebeaninternal.api.SpiEbeanServer; +import io.ebeaninternal.api.SpiTransaction; + +import java.util.ArrayList; +import java.util.List; + +/** + * Context used for merge processing. + */ +class MergeContext { + + private final SpiEbeanServer server; + + private final SpiTransaction transaction; + + private final List deleteBeans = new ArrayList<>(); + + private boolean clientGeneratedIds; + + MergeContext(SpiEbeanServer server, SpiTransaction transaction, boolean clientGeneratedIds) { + this.server = server; + this.transaction = transaction; + this.clientGeneratedIds = clientGeneratedIds; + } + + /** + * Add to the list of beans to delete. + */ + void addDelete(EntityBean deleteBean) { + deleteBeans.add(deleteBean); + } + + /** + * Return true if Ids must be checked against the DB. + */ + boolean isClientGeneratedIds() { + return clientGeneratedIds; + } + + /** + * Check if the Id / bean exists in the database. + */ + boolean idExists(Class beanType, Object beanId) { + return server.exists(beanType, beanId, transaction); + } + + /** + * Return the list of beans to delete. + */ + List getDeletedBeans() { + return deleteBeans; + } +} diff --git a/src/main/java/io/ebeaninternal/server/persist/MergeHandler.java b/src/main/java/io/ebeaninternal/server/persist/MergeHandler.java new file mode 100644 index 000000000..c82febba7 --- /dev/null +++ b/src/main/java/io/ebeaninternal/server/persist/MergeHandler.java @@ -0,0 +1,147 @@ +package io.ebeaninternal.server.persist; + +import io.ebean.CacheMode; +import io.ebean.MergeOptions; +import io.ebean.PersistenceContextScope; +import io.ebean.Query; +import io.ebean.bean.EntityBean; +import io.ebeaninternal.api.SpiEbeanServer; +import io.ebeaninternal.api.SpiTransaction; +import io.ebeaninternal.server.deploy.BeanDescriptor; +import io.ebeaninternal.server.deploy.BeanProperty; +import io.ebeaninternal.server.deploy.BeanPropertyAssoc; +import io.ebeaninternal.server.deploy.BeanPropertyAssocMany; +import io.ebeaninternal.server.deploy.BeanPropertyAssocOne; + +import javax.persistence.PersistenceException; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Drives the merge processing. + */ +class MergeHandler { + + private final Pattern PATH_SPLIT = Pattern.compile("\\."); + + private final SpiEbeanServer server; + private final BeanDescriptor desc; + private final EntityBean bean; + private final MergeOptions options; + private final SpiTransaction transaction; + + private final Map nodes = new LinkedHashMap<>(); + + + MergeHandler(SpiEbeanServer server, BeanDescriptor desc, EntityBean bean, MergeOptions options, SpiTransaction transaction) { + this.server = server; + this.desc = desc; + this.bean = bean; + this.options = options; + this.transaction = transaction; + } + + /** + * Fetch the Ids for the graph and use them to determine inserts, updates and deletes for the merge paths. + */ + List merge() { + + Set paths = options.paths(); + if (paths.isEmpty() && !options.isClientGeneratedIds()) { + // just do a single insert or update based on Id value present + Object id = desc.getId(bean); + if (id != null) { + bean._ebean_getIntercept().setForceUpdate(true); + } + return Collections.emptyList(); + } + + EntityBean outline = fetchOutline(paths); + if (outline == null) { + // considered an insert ... + return Collections.emptyList(); + } + + // the top level bean is an update + bean._ebean_getIntercept().setForceUpdate(true); + + // detect what beans are updates and recursively set forceUpdate as needed + // and outline beans not in the merge graph are generally considered as deletes + MergeContext context = new MergeContext(server, transaction, options.isClientGeneratedIds()); + MergeRequest request = new MergeRequest(context, bean, outline); + for (MergeNode value : nodes.values()) { + value.merge(request); + } + + return context.getDeletedBeans(); + } + + /** + * Fetch the outline bean with associated one and associated many beans loaded with Id values only. + *

    + * We use the Id values to determine what are inserts, updates and deletes as part of the merge. + */ + private EntityBean fetchOutline(Set paths) { + + Query query = server.find(desc.getBeanType()); + + query.setBeanCacheMode(CacheMode.OFF); + query.setPersistenceContextScope(PersistenceContextScope.QUERY); + query.setId(desc.getId(bean)); + query.select(desc.getIdProperty().getName()); + + for (String path : paths) { + MergeNode node = buildNode(path); + node.addSelectId(query); + } + return (EntityBean) server.findOne(query, transaction); + } + + private MergeNode buildNode(String path) { + String[] split = PATH_SPLIT.split(path); + if (split.length == 1) { + return addRootLevelNode(split[0]); + } else { + return addSubNode(path, split); + } + } + + private MergeNode addSubNode(String fullPath, String[] split) { + MergeNode parent = nodes.get(split[0]); + if (parent == null) { + throw new PersistenceException("Unable to find parent path " + split[0] + " in merge paths?"); + } + + for (int i = 1; i < split.length - 1; i++) { + parent = parent.get(split[i]); + if (parent == null) { + throw new PersistenceException("Unable to find parent path " + split[0] + " in merge paths?"); + } + } + return parent.addChild(fullPath, split[split.length - 1]); + } + + private MergeNode addRootLevelNode(String rootPath) { + + MergeNode node = createMergeNode(rootPath, desc, rootPath); + nodes.put(rootPath, node); + return node; + } + + static MergeNode createMergeNode(String fullPath, BeanDescriptor targetDesc, String path) { + + BeanProperty prop = targetDesc.getBeanProperty(path); + if (prop == null || !(prop instanceof BeanPropertyAssoc)) { + throw new PersistenceException("merge path [" + path + "] is not a ToMany or ToOne property of " + targetDesc.getFullName()); + } + if (prop instanceof BeanPropertyAssocMany) { + return new MergeNodeAssocMany(fullPath, (BeanPropertyAssocMany) prop); + } else { + return new MergeNodeAssocOne(fullPath, (BeanPropertyAssocOne) prop); + } + } +} diff --git a/src/main/java/io/ebeaninternal/server/persist/MergeNode.java b/src/main/java/io/ebeaninternal/server/persist/MergeNode.java new file mode 100644 index 000000000..0f7e47a48 --- /dev/null +++ b/src/main/java/io/ebeaninternal/server/persist/MergeNode.java @@ -0,0 +1,74 @@ +package io.ebeaninternal.server.persist; + +import io.ebean.Query; +import io.ebean.bean.EntityBean; +import io.ebeaninternal.server.deploy.BeanDescriptor; +import io.ebeaninternal.server.deploy.BeanProperty; +import io.ebeaninternal.server.deploy.BeanPropertyAssoc; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Base class for merge nodes. + */ +abstract class MergeNode { + + protected final String fullPath; + protected final BeanDescriptor targetDescriptor; + protected Map children; + + MergeNode(String fullPath, BeanPropertyAssoc property) { + this.fullPath = fullPath; + this.targetDescriptor = property.getTargetDescriptor(); + } + + /** + * Perform the merge processing. + */ + abstract void merge(MergeRequest request); + + /** + * Add a child node given the fullPath and relative path. + */ + MergeNode addChild(String fullPath, String path) { + MergeNode childNode = MergeHandler.createMergeNode(fullPath, targetDescriptor, path); + if (children == null) { + children = new LinkedHashMap<>(); + } + children.put(path, childNode); + return childNode; + } + + /** + * Return the node given the relative path. + */ + MergeNode get(String path) { + if (children != null) { + return children.get(path); + } + return null; + } + + /** + * Add to the query to fetch the Ids values for the foreign keys basically. + */ + void addSelectId(Query query) { + + BeanProperty idProperty = targetDescriptor.getIdProperty(); + query.fetch(fullPath, idProperty.getName()); + } + + /** + * Cascade the merge processing if this has child nodes. + */ + protected void cascade(EntityBean entityBean, EntityBean outlineBean, MergeRequest request) { + + if (children != null && !children.isEmpty()) { + MergeRequest sub = request.sub(entityBean, outlineBean); + for (MergeNode node : children.values()) { + node.merge(sub); + } + } + } +} diff --git a/src/main/java/io/ebeaninternal/server/persist/MergeNodeAssocMany.java b/src/main/java/io/ebeaninternal/server/persist/MergeNodeAssocMany.java new file mode 100644 index 000000000..d6e1a8e8f --- /dev/null +++ b/src/main/java/io/ebeaninternal/server/persist/MergeNodeAssocMany.java @@ -0,0 +1,57 @@ +package io.ebeaninternal.server.persist; + +import io.ebean.bean.EntityBean; +import io.ebeaninternal.server.deploy.BeanPropertyAssocMany; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +/** + * Node for processing merge on ToMany properties. + */ +class MergeNodeAssocMany extends MergeNode { + + private final BeanPropertyAssocMany many; + + MergeNodeAssocMany(String fullPath, BeanPropertyAssocMany property) { + super(fullPath, property); + this.many = property; + } + + public void merge(MergeRequest request) { + + Collection beans = many.getRawCollection(request.getBean()); + Collection outlines = many.getRawCollection(request.getOutline()); + + Map outlineIds = new HashMap<>(); + if (outlines != null) { + for (Object outline : outlines) { + EntityBean outlineBean = (EntityBean) outline; + Object outlineId = targetDescriptor.getId(outlineBean); + outlineIds.put(outlineId, outlineBean); + } + } + + if (beans != null) { + for (Object bean : beans) { + EntityBean entityBean = (EntityBean) bean; + Object beanId = targetDescriptor.getId(entityBean); + if (beanId != null) { + EntityBean outlineBean = outlineIds.remove(beanId); + if (outlineBean != null) { + // must be an update + entityBean._ebean_getIntercept().setForceUpdate(true); + cascade(entityBean, outlineBean, request); + } + } + } + } + + // any remaining are considered deletes + for (EntityBean outlineBean : outlineIds.values()) { + request.addDelete(outlineBean); + } + } + +} diff --git a/src/main/java/io/ebeaninternal/server/persist/MergeNodeAssocOne.java b/src/main/java/io/ebeaninternal/server/persist/MergeNodeAssocOne.java new file mode 100644 index 000000000..b33c393c5 --- /dev/null +++ b/src/main/java/io/ebeaninternal/server/persist/MergeNodeAssocOne.java @@ -0,0 +1,60 @@ +package io.ebeaninternal.server.persist; + +import io.ebean.bean.EntityBean; +import io.ebeaninternal.server.deploy.BeanPropertyAssocOne; + +import java.util.Objects; + +/** + * Node for processing merge on ToOne properties. + */ +class MergeNodeAssocOne extends MergeNode { + + private final BeanPropertyAssocOne one; + + MergeNodeAssocOne(String fullPath, BeanPropertyAssocOne property) { + super(fullPath, property); + this.one = property; + } + + public void merge(MergeRequest request) { + + EntityBean entityBean = getEntityBean(request.getBean()); + if (entityBean == null) { + checkOrphanRemoval(request); + + } else { + Object beanId = targetDescriptor.getId(entityBean); + if (beanId == null) { + checkOrphanRemoval(request); + + } else { + EntityBean outlineBean = getEntityBean(request.getOutline()); + Object outlineId = (outlineBean == null) ? null : targetDescriptor.getId(outlineBean); + if (isUpdate(beanId, outlineId, request)) { + entityBean._ebean_getIntercept().setForceUpdate(true); + cascade(entityBean, outlineBean, request); + } + } + } + } + + private void checkOrphanRemoval(MergeRequest request) { + if (one.isOrphanRemoval()) { + EntityBean outlineBean = getEntityBean(request.getOutline()); + if (outlineBean != null) { + request.addDelete(outlineBean); + } + } + } + + private boolean isUpdate(Object beanId, Object outlineId, MergeRequest request) { + return Objects.equals(beanId, outlineId) + || !request.isClientGeneratedIds() + || request.idExists(targetDescriptor.getBeanType(), beanId); + } + + private EntityBean getEntityBean(Object bean) { + return (EntityBean) one.getVal(bean); + } +} diff --git a/src/main/java/io/ebeaninternal/server/persist/MergeRequest.java b/src/main/java/io/ebeaninternal/server/persist/MergeRequest.java new file mode 100644 index 000000000..303ccb382 --- /dev/null +++ b/src/main/java/io/ebeaninternal/server/persist/MergeRequest.java @@ -0,0 +1,64 @@ +package io.ebeaninternal.server.persist; + +import io.ebean.bean.EntityBean; + +/** + * Request object used for processing the merge. + */ +class MergeRequest { + + private final EntityBean bean; + private final EntityBean outline; + + private final MergeContext context; + + MergeRequest(MergeContext context, EntityBean bean, EntityBean outline) { + this.context = context; + this.bean = bean; + this.outline = outline; + } + + /** + * Create a sub request with the given beans (to cascade the processing). + */ + public MergeRequest sub(EntityBean entityBean, EntityBean outlineBean) { + return new MergeRequest(context, entityBean, outlineBean); + } + + /** + * Return the entity bean being merged. + */ + public EntityBean getBean() { + return bean; + } + + /** + * Return the outline bean (only has Id property). + */ + public EntityBean getOutline() { + return outline; + } + + /** + * Add a bean to the deletion list. + */ + public void addDelete(EntityBean deleteRemain) { + context.addDelete(deleteRemain); + } + + /** + * Return true if the Ids are generated by the client. This means we can't know if a bean + * should be inserted or updated based on having an Id value. + */ + public boolean isClientGeneratedIds() { + return context.isClientGeneratedIds(); + } + + /** + * Return true if a bean of the type with the given Id exists in the database. + */ + public boolean idExists(Class beanType, Object beanId) { + return context.idExists(beanType, beanId); + } + +} diff --git a/src/main/java/io/ebeaninternal/server/persist/SaveManyPropRequest.java b/src/main/java/io/ebeaninternal/server/persist/SaveManyPropRequest.java index 4556edcd2..97f9d1131 100644 --- a/src/main/java/io/ebeaninternal/server/persist/SaveManyPropRequest.java +++ b/src/main/java/io/ebeaninternal/server/persist/SaveManyPropRequest.java @@ -21,6 +21,7 @@ import java.util.Map; */ class SaveManyPropRequest { + private final PersistRequestBean request; private final boolean insertedParent; private final BeanPropertyAssocMany many; private final EntityBean parentBean; @@ -37,10 +38,10 @@ class SaveManyPropRequest { private Collection collection; private DefaultPersister persister; private boolean deleteMissing; - private boolean insertMode; private int sortOrder; SaveManyPropRequest(boolean insertedParent, BeanPropertyAssocMany many, EntityBean parentBean, PersistRequestBean request) { + this.request = request; this.insertedParent = insertedParent; this.many = many; this.cascade = many.getCascadeInfo().isSave(); @@ -70,6 +71,10 @@ class SaveManyPropRequest { return deleteMissingChildren; } + public int getFlags() { + return request.getFlags(); + } + boolean isInsertedParent() { return insertedParent; } @@ -108,11 +113,10 @@ class SaveManyPropRequest { } } - void saveDetails(DefaultPersister persister, boolean deleteMissing, boolean insertMode) { + void saveDetails(DefaultPersister persister, boolean deleteMissing) { this.persister = persister; this.deleteMissing = deleteMissing; - this.insertMode = insertMode; // check that the list is not null and if it is a BeanCollection // check that is has been populated (don't trigger lazy loading) @@ -198,7 +202,7 @@ class SaveManyPropRequest { } if (!skipSavingThisBean) { - persister.saveRecurse(detail, transaction, parentBean, insertMode, publish); + persister.saveRecurse(detail, transaction, parentBean, request.getFlags()); } } } diff --git a/src/test/java/io/ebeaninternal/api/TDSpiEbeanServer.java b/src/test/java/io/ebeaninternal/api/TDSpiEbeanServer.java index 62265c0fb..5755f6ed5 100644 --- a/src/test/java/io/ebeaninternal/api/TDSpiEbeanServer.java +++ b/src/test/java/io/ebeaninternal/api/TDSpiEbeanServer.java @@ -11,6 +11,7 @@ import io.ebean.Filter; import io.ebean.FutureIds; import io.ebean.FutureList; import io.ebean.FutureRowCount; +import io.ebean.MergeOptions; import io.ebean.PagedList; import io.ebean.PersistenceContextScope; import io.ebean.Query; @@ -236,6 +237,16 @@ public class TDSpiEbeanServer implements SpiEbeanServer { return 0; } + @Override + public void merge(Object bean, MergeOptions options) { + + } + + @Override + public void merge(Object bean, MergeOptions options, Transaction transaction) { + + } + @Override public List> findVersions(Query query, Transaction transaction) { return null; @@ -551,6 +562,11 @@ public class TDSpiEbeanServer implements SpiEbeanServer { } + @Override + public boolean exists(Class beanType, Object beanId, Transaction transaction) { + return false; + } + @Override public T find(Class beanType, Object uid) { return null; diff --git a/src/test/java/io/ebeaninternal/server/persist/BatchedBeanHolderTest.java b/src/test/java/io/ebeaninternal/server/persist/BatchedBeanHolderTest.java index 2e200abd1..12a7acef0 100644 --- a/src/test/java/io/ebeaninternal/server/persist/BatchedBeanHolderTest.java +++ b/src/test/java/io/ebeaninternal/server/persist/BatchedBeanHolderTest.java @@ -6,8 +6,8 @@ import io.ebeaninternal.server.core.PersistRequest; import io.ebeaninternal.server.core.PersistRequestBean; import io.ebeaninternal.server.deploy.BeanDescriptor; import io.ebeaninternal.server.deploy.BeanManager; -import org.tests.model.basic.Customer; import org.junit.Test; +import org.tests.model.basic.Customer; import java.sql.Timestamp; @@ -17,8 +17,7 @@ public class BatchedBeanHolderTest extends BaseTestCase { @SuppressWarnings({ "rawtypes", "unchecked" }) @Test - public void testAppend() throws Exception { - + public void testAppend() { TDSpiEbeanServer server = new TDSpiEbeanServer("foo"); @@ -32,13 +31,13 @@ public class BatchedBeanHolderTest extends BaseTestCase { Customer customer = new Customer(); customer.setUpdtime(new Timestamp(System.currentTimeMillis())); - PersistRequestBean req1 = new PersistRequestBean(server, customer, null, beanManager, null, null, PersistRequest.Type.INSERT, false, false); + PersistRequestBean req1 = new PersistRequestBean(server, customer, null, beanManager, null, null, PersistRequest.Type.INSERT, 0); int size = holder.append(req1); assertEquals(1, size); - PersistRequestBean req2 = new PersistRequestBean(server, customer, null, beanManager, null, null, PersistRequest.Type.INSERT, false, false); + PersistRequestBean req2 = new PersistRequestBean(server, customer, null, beanManager, null, null, PersistRequest.Type.INSERT, 0); size = holder.append(req2); assertEquals(0, size); diff --git a/src/test/java/io/ebeaninternal/server/persist/FlagsTest.java b/src/test/java/io/ebeaninternal/server/persist/FlagsTest.java new file mode 100644 index 000000000..4e4d947c4 --- /dev/null +++ b/src/test/java/io/ebeaninternal/server/persist/FlagsTest.java @@ -0,0 +1,44 @@ +package io.ebeaninternal.server.persist; + + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class FlagsTest { + + + @Test + public void test() { + + int state = 0; + + state = Flags.setPublish(state); + assertThat(Flags.isSet(state, Flags.PUBLISH)).isTrue(); + + state = Flags.setMerge(state); + state = Flags.setInsert(state); + assertThat(Flags.isSet(state, Flags.PUBLISH)).isTrue(); + assertThat(Flags.isSet(state, Flags.MERGE)).isTrue(); + assertThat(Flags.isSet(state, Flags.INSERT)).isTrue(); + + state = Flags.unsetPublish(state); + assertThat(Flags.isSet(state, Flags.PUBLISH)).isFalse(); + assertThat(Flags.isSet(state, Flags.MERGE)).isTrue(); + assertThat(Flags.isSet(state, Flags.INSERT)).isTrue(); + + } + + @Test + public void isPublishOrMerge() { + + assertThat(Flags.isPublishOrMerge(0)).isFalse(); + assertThat(Flags.isPublishOrMerge(Flags.INSERT)).isFalse(); + + assertThat(Flags.isPublishOrMerge(Flags.PUBLISH)).isTrue(); + assertThat(Flags.isPublishOrMerge(Flags.MERGE)).isTrue(); + + int mergePublish = Flags.setMerge(Flags.setPublish(0)); + assertThat(Flags.isPublishOrMerge(mergePublish)).isTrue(); + } +} diff --git a/src/test/java/org/tests/merge/MAddress.java b/src/test/java/org/tests/merge/MAddress.java new file mode 100644 index 000000000..cfeb31bb6 --- /dev/null +++ b/src/test/java/org/tests/merge/MAddress.java @@ -0,0 +1,33 @@ +package org.tests.merge; + +import javax.persistence.Entity; + +@Entity +public class MAddress extends MBase { + + private String street; + + private String city; + + public MAddress(String street, String city) { + this.street = street; + this.city = city; + } + + public String getStreet() { + return street; + } + + public void setStreet(String street) { + this.street = street; + } + + public String getCity() { + return city; + } + + public void setCity(String city) { + this.city = city; + } + +} diff --git a/src/test/java/org/tests/merge/MBase.java b/src/test/java/org/tests/merge/MBase.java new file mode 100644 index 000000000..0172e9893 --- /dev/null +++ b/src/test/java/org/tests/merge/MBase.java @@ -0,0 +1,32 @@ +package org.tests.merge; + +import javax.persistence.Id; +import javax.persistence.MappedSuperclass; +import javax.persistence.Version; +import java.util.UUID; + +@MappedSuperclass +public class MBase { + + @Id + private UUID id; + + @Version + private long version; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public long getVersion() { + return version; + } + + public void setVersion(long version) { + this.version = version; + } +} diff --git a/src/test/java/org/tests/merge/MContact.java b/src/test/java/org/tests/merge/MContact.java new file mode 100644 index 000000000..9331bbb43 --- /dev/null +++ b/src/test/java/org/tests/merge/MContact.java @@ -0,0 +1,67 @@ +package org.tests.merge; + +import javax.persistence.CascadeType; +import javax.persistence.Entity; +import javax.persistence.ManyToOne; +import javax.persistence.OneToMany; +import java.util.List; + +@Entity +public class MContact extends MBase { + + String email; + String firstName; + String lastName; + + @ManyToOne + MCustomer customer; + + @OneToMany(cascade = CascadeType.ALL, mappedBy = "contact") + List messages; + + public MContact(String email, String firstName, String lastName) { + this.email = email; + this.firstName = firstName; + this.lastName = lastName; + } + + public MCustomer getCustomer() { + return customer; + } + + public void setCustomer(MCustomer customer) { + this.customer = customer; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public List getMessages() { + return messages; + } + + public void setMessages(List messages) { + this.messages = messages; + } +} diff --git a/src/test/java/org/tests/merge/MContactMessage.java b/src/test/java/org/tests/merge/MContactMessage.java new file mode 100644 index 000000000..95d22026f --- /dev/null +++ b/src/test/java/org/tests/merge/MContactMessage.java @@ -0,0 +1,54 @@ +package org.tests.merge; + +import javax.persistence.Entity; +import javax.persistence.ManyToOne; + +@Entity +public class MContactMessage extends MBase { + + private String title; + + private String subject; + + private String notes; + + @ManyToOne(optional = false) + private MContact contact; + + public MContactMessage(String title, String subject) { + this.title = title; + this.subject = subject; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getNotes() { + return notes; + } + + public void setNotes(String notes) { + this.notes = notes; + } + + public String getSubject() { + return subject; + } + + public void setSubject(String subject) { + this.subject = subject; + } + + public MContact getContact() { + return contact; + } + + public void setContact(MContact contact) { + this.contact = contact; + } +} diff --git a/src/test/java/org/tests/merge/MCustomer.java b/src/test/java/org/tests/merge/MCustomer.java new file mode 100644 index 000000000..802f8000b --- /dev/null +++ b/src/test/java/org/tests/merge/MCustomer.java @@ -0,0 +1,68 @@ +package org.tests.merge; + +import javax.persistence.CascadeType; +import javax.persistence.Entity; +import javax.persistence.ManyToOne; +import javax.persistence.OneToMany; +import java.util.List; + +@Entity +public class MCustomer extends MBase { + + private String name; + + private String notes; + + @OneToMany(cascade = CascadeType.ALL, mappedBy = "customer") + private List contacts; + + @ManyToOne(cascade = CascadeType.ALL) + private MAddress shippingAddress; + + @ManyToOne(cascade = CascadeType.ALL) + private MAddress billingAddress; + + public MCustomer(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getNotes() { + return notes; + } + + public void setNotes(String notes) { + this.notes = notes; + } + + public List getContacts() { + return contacts; + } + + public void setContacts(List contacts) { + this.contacts = contacts; + } + + public MAddress getShippingAddress() { + return shippingAddress; + } + + public void setShippingAddress(MAddress shippingAddress) { + this.shippingAddress = shippingAddress; + } + + public MAddress getBillingAddress() { + return billingAddress; + } + + public void setBillingAddress(MAddress billingAddress) { + this.billingAddress = billingAddress; + } +} diff --git a/src/test/java/org/tests/merge/TestMergeBasic.java b/src/test/java/org/tests/merge/TestMergeBasic.java new file mode 100644 index 000000000..03938fa71 --- /dev/null +++ b/src/test/java/org/tests/merge/TestMergeBasic.java @@ -0,0 +1,117 @@ +package org.tests.merge; + +import io.ebean.BaseTestCase; +import io.ebean.Ebean; +import io.ebean.MergeOptions; +import io.ebean.MergeOptionsBuilder; +import org.ebeantest.LoggedSqlCollector; +import org.junit.Test; +import org.tests.model.basic.UUOne; +import org.tests.model.basic.UUTwo; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +public class TestMergeBasic extends BaseTestCase { + + + private UUOne rebuildViaJson(UUOne input) { + String asJson = Ebean.json().toJson(input); + return Ebean.json().toBean(UUOne.class, asJson); + } + + private UUOne build() { + UUOne uuOne = buildGraph(); + Ebean.save(uuOne); + return rebuildViaJson(uuOne); + } + + @Test + public void with_setClientGeneratedIds_expect_noDifferenceWithToMany() { + + UUOne one = build(); + + one.setDescription("mod"); + List comments = one.getComments(); + comments.remove(0); + comments.add(0, new UUTwo("twoMore", UUID.randomUUID())); + comments.add(new UUTwo("twoExtra", UUID.randomUUID())); + + MergeOptions options = new MergeOptionsBuilder() + .addPath("comments") + .setClientGeneratedIds() + .build(); + + LoggedSqlCollector.start(); + Ebean.merge(one, options); + + + List sql = LoggedSqlCollector.stop(); + // fetch the Ids ... used to identity inserts, updates and deletes + assertThat(sql.get(0)).contains("select t0.id, t1.id from uuone t0 left join uutwo t1 on t1.master_id = t0.id where t0.id = ?"); + + // deletes of Ids that are no longer in the graph + assertThat(sql.get(1)).contains("delete from uutwo where id=?"); + + // cascade persist ... master + assertThat(sql.get(2)).contains("update uuone set name=?, description=?, version=? where id=? and version=?"); + + // persist children ... + assertThat(sql.get(3)).contains("insert into uutwo (id, name, notes, version, master_id) values (?,?,?,?,?);"); + assertThat(sql.get(4)).contains("insert into uutwo (id, name, notes, version, master_id) values (?,?,?,?,?);"); + + assertThat(sql.get(5)).contains("update uutwo set name=?, notes=?, version=?, master_id=? where id=? and version=?"); + assertThat(sql.get(6)).contains("update uutwo set name=?, notes=?, version=?, master_id=? where id=? and version=?"); + assertThat(sql.get(7)).contains("update uutwo set name=?, notes=?, version=?, master_id=? where id=? and version=?"); + } + + @Test + public void test() { + + UUOne one = build(); + one.setDescription("mod"); + + List comments = one.getComments(); + comments.remove(0); + comments.add(0, new UUTwo("twoMore", UUID.randomUUID())); + comments.add(new UUTwo("twoExtra", UUID.randomUUID())); + + MergeOptions options = new MergeOptionsBuilder() + .addPath("comments") + .build(); + + LoggedSqlCollector.start(); + Ebean.merge(one, options); + + + List sql = LoggedSqlCollector.stop(); + // fetch the Ids ... used to identity inserts, updates and deletes + assertThat(sql.get(0)).contains("select t0.id, t1.id from uuone t0 left join uutwo t1 on t1.master_id = t0.id where t0.id = ?"); + + // deletes of Ids that are no longer in the graph + assertThat(sql.get(1)).contains("delete from uutwo where id=?"); + + // cascade persist ... master + assertThat(sql.get(2)).contains("update uuone set name=?, description=?, version=? where id=? and version=?"); + + // persist children ... + assertThat(sql.get(3)).contains("insert into uutwo (id, name, notes, version, master_id) values (?,?,?,?,?);"); + assertThat(sql.get(4)).contains("insert into uutwo (id, name, notes, version, master_id) values (?,?,?,?,?);"); + + assertThat(sql.get(5)).contains("update uutwo set name=?, notes=?, version=?, master_id=? where id=? and version=?"); + assertThat(sql.get(6)).contains("update uutwo set name=?, notes=?, version=?, master_id=? where id=? and version=?"); + assertThat(sql.get(7)).contains("update uutwo set name=?, notes=?, version=?, master_id=? where id=? and version=?"); + + } + + private UUOne buildGraph() { + UUOne one = new UUOne("one1", UUID.randomUUID()); + for (int i = 1; i < 5; i++) { + UUTwo two = new UUTwo("two" + i, UUID.randomUUID()); + one.getComments().add(two); + } + return one; + } +} diff --git a/src/test/java/org/tests/merge/TestMergeCustomer.java b/src/test/java/org/tests/merge/TestMergeCustomer.java new file mode 100644 index 000000000..8f314cac5 --- /dev/null +++ b/src/test/java/org/tests/merge/TestMergeCustomer.java @@ -0,0 +1,376 @@ +package org.tests.merge; + +import io.ebean.BaseTestCase; +import io.ebean.Ebean; +import io.ebean.FetchPath; +import io.ebean.MergeOptions; +import io.ebean.MergeOptionsBuilder; +import io.ebean.text.PathProperties; +import org.ebeantest.LoggedSqlCollector; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +public class TestMergeCustomer extends BaseTestCase { + + private Random random = new Random(); + + /** + * So this is effectively the same as a stateless update. + */ + @Test + public void customerOnly_expect_updateOnly() { + + + MCustomer mCustomer = partial("cust1", "(id,name,version)"); + mCustomer.setName("NotCust1"); + + MergeOptions options = new MergeOptionsBuilder().build(); + + + LoggedSqlCollector.start(); + + Ebean.merge(mCustomer, options); + + List sql = LoggedSqlCollector.stop(); + assertThat(sql).hasSize(1); + assertThat(sql.get(0)).contains("update mcustomer set name=?, version=? where id=? and version=?"); + } + + @Test + public void customerOnly_setClientGeneratedIds_expect_selectAndUpdate() { + + + MCustomer mCustomer = partial("cust2", "(id,name,version)"); + mCustomer.setName("NotCust2"); + + MergeOptions options = new MergeOptionsBuilder().setClientGeneratedIds().build(); + + LoggedSqlCollector.start(); + + server().merge(mCustomer, options); + + List sql = LoggedSqlCollector.stop(); + assertThat(sql).hasSize(2); + assertThat(sql.get(0)).contains("select t0.id from mcustomer t0 where t0.id = ?"); + assertThat(sql.get(1)).contains("update mcustomer set name=?, version=? where id=? and version=?"); + } + + @Test + public void customerWithAddresses_setClientGeneratedIds_expect_selectAndUpdate() { + + + MCustomer mCustomer = partial("cust3", "(id,name,version,shippingAddress(*),billingAddress(*))"); + mCustomer.setName("NotCust3"); + mCustomer.getBillingAddress().setStreet("modBillStreet"); + mCustomer.getShippingAddress().setCity("modShipCity"); + + MergeOptions options = new MergeOptionsBuilder() + .addPath("shippingAddress") + .addPath("billingAddress") + .setClientGeneratedIds() + .build(); + + LoggedSqlCollector.start(); + + server().merge(mCustomer, options); + + List sql = LoggedSqlCollector.stop(); + assertThat(sql).hasSize(4); + assertThat(sql.get(0)).contains("select t0.id, t2.id, t1.id from mcustomer t0 left join maddress t2 on t2.id = t0.shipping_address_id left join maddress t1 on t1.id = t0.billing_address_id where t0.id = ?"); + assertThat(sql.get(1)).contains("update maddress set street=?, city=?, version=? where id=? and version=?"); + assertThat(sql.get(2)).contains("update maddress set street=?, city=?, version=? where id=? and version=?"); + assertThat(sql.get(3)).contains("update mcustomer set name=?, version=?, shipping_address_id=?, billing_address_id=? where id=? and version=?"); + } + + @Test + public void customerWithAddresses_newAddress_setClientGeneratedIds_expect_insertAddress() { + + + MCustomer mCustomer = partial("cust3", "(id,name,version,shippingAddress(*),billingAddress(*))"); + mCustomer.setName("NotCust3"); + + // new billing address - no Id value so must be insert + mCustomer.setBillingAddress(new MAddress("Short", "Mid Wicket")); + mCustomer.getShippingAddress().setCity("modShipCity"); + + MergeOptions options = new MergeOptionsBuilder() + .addPath("shippingAddress") + .addPath("billingAddress") + .setClientGeneratedIds() + .build(); + + LoggedSqlCollector.start(); + + server().merge(mCustomer, options); + + List sql = LoggedSqlCollector.stop(); + assertThat(sql).hasSize(4); + assertThat(sql.get(0)).contains("select t0.id, t2.id, t1.id from mcustomer t0 left join maddress t2 on t2.id = t0.shipping_address_id left join maddress t1 on t1.id = t0.billing_address_id where t0.id = ?"); + assertThat(sql.get(1)).contains("update maddress set street=?, city=?, version=? where id=? and version=?"); + assertThat(sql.get(2)).contains("insert into maddress (id, street, city, version) values (?,?,?,?);"); + assertThat(sql.get(3)).contains("update mcustomer set name=?, version=?, shipping_address_id=?, billing_address_id=? where id=? and version=?"); + } + + @Test + public void customerWithAddresses_newAddressWithId_setClientGeneratedIds_expect_additionalCheckForAddressInsert() { + + + MCustomer mCustomer = partial("cust3", "(id,name,version,shippingAddress(*),billingAddress(*))"); + mCustomer.setName("NotCust3"); + + // new billing address - has Id value + setClientGeneratedIds ... so extra query to check + MAddress mAddress = new MAddress("Short", "Mid Wicket"); + mAddress.setId(UUID.randomUUID()); + mCustomer.setBillingAddress(mAddress); + mCustomer.getShippingAddress().setCity("modShipCity"); + + MergeOptions options = new MergeOptionsBuilder() + .addPath("shippingAddress") + .addPath("billingAddress") + .setClientGeneratedIds() // As we are using clientIds ... we don't know if the new UUID is an insert or update without checking + .build(); + + LoggedSqlCollector.start(); + + server().merge(mCustomer, options); + + List sql = LoggedSqlCollector.stop(); + assertThat(sql).hasSize(5); + assertThat(sql.get(0)).contains("select t0.id, t2.id, t1.id from mcustomer t0 left join maddress t2 on t2.id = t0.shipping_address_id left join maddress t1 on t1.id = t0.billing_address_id where t0.id = ?"); + + // Additional check to see if the address with the unknown UUID is 'insert' or 'update' + assertThat(sql.get(1)).contains("select t0.id from maddress t0 where t0.id = ?"); + + assertThat(sql.get(2)).contains("update maddress set street=?, city=?, version=? where id=? and version=?"); + assertThat(sql.get(3)).contains("insert into maddress (id, street, city, version) values (?,?,?,?);"); + assertThat(sql.get(4)).contains("update mcustomer set name=?, version=?, shipping_address_id=?, billing_address_id=? where id=? and version=?"); + } + + @Test + public void assocOne_onlineIsNull() { + + MCustomer c = new MCustomer("Null Address"); + c.setBillingAddress(new MAddress("Cow corner", "Mid Wicket")); + + Ebean.save(c); + + MCustomer mCustomer = rebuildViaJson(c); + + MAddress mAddress = new MAddress("Silly", "Mid Wicket"); + mAddress.setId(UUID.randomUUID()); + mCustomer.setShippingAddress(mAddress); + + MergeOptions options = new MergeOptionsBuilder() + .addPath("shippingAddress") + .addPath("billingAddress") + .setClientGeneratedIds() + .build(); + + Ebean.merge(mCustomer, options); + + } + + private MCustomer rebuildViaJson(MCustomer input) { + String asJson = Ebean.json().toJson(input); + return Ebean.json().toBean(MCustomer.class, asJson); + } + + @Test + public void whenContacts_isNull_expect_deleteContacts() { + + MCustomer mCustomer = partial("cust6", "(id,name,version,shippingAddress(id),billingAddress(id),contacts(*))"); + + // null contacts ... but in path so this means - delete all contacts + mCustomer.setContacts(null); + + MergeOptions options = new MergeOptionsBuilder() + .addPath("contacts") + .build(); + + LoggedSqlCollector.start(); + server().merge(mCustomer, options); + + List sql = LoggedSqlCollector.stop(); + assertThat(sql.get(0)).contains("select t0.id, t1.id from mcustomer t0 left join mcontact t1 on t1.customer_id = t0.id where t0.id = ?"); + assertThat(sql.get(1)).contains("delete from mcontact_message where contact_id = ?"); + assertThat(sql.get(2)).contains("delete from mcontact where id=?"); + + assertThat(sql.get(13)).contains("update mcustomer set name=?, version=?, shipping_address_id=?, billing_address_id=? where id=? and version=?"); + } + + + @Test + public void whenContacts_isEmpty_expect_deleteContacts() { + + MCustomer mCustomer = partial("cust6", "(id,name,version,shippingAddress(id),billingAddress(id),contacts(*))"); + + // empty contacts ... but in path so this means - delete all contacts + mCustomer.setContacts(new ArrayList<>()); + + MergeOptions options = new MergeOptionsBuilder() + .addPath("contacts") + .build(); + + LoggedSqlCollector.start(); + server().merge(mCustomer, options); + + List sql = LoggedSqlCollector.stop(); + assertThat(sql.get(0)).contains("select t0.id, t1.id from mcustomer t0 left join mcontact t1 on t1.customer_id = t0.id where t0.id = ?"); + assertThat(sql.get(1)).contains("delete from mcontact_message where contact_id = ?"); + assertThat(sql.get(2)).contains("delete from mcontact where id=?"); + + assertThat(sql.get(13)).contains("update mcustomer set name=?, version=?, shipping_address_id=?, billing_address_id=? where id=? and version=?"); + } + + @Test + public void whenContacts_mixed_expect_deleteInsertUpdateContacts() { + + MCustomer mCustomer = partial("cust6", "(id,name,version,shippingAddress(id),billingAddress(id),contacts(*))"); + List contacts = mCustomer.getContacts(); + + MContact mContact = new MContact("z@a.com", "z", "zed"); + mContact.setId(UUID.randomUUID()); + + contacts.add(mContact); + + contacts.get(0).setEmail("a@beta.com"); + contacts.get(1).setEmail("b@beta.com"); + contacts.remove(4); + contacts.remove(2); + + MContact mContactEnd = new MContact("z@z.com", "zx", "zedXtra"); + mContactEnd.setId(UUID.randomUUID()); + contacts.add(mContactEnd); + + + MergeOptions options = new MergeOptionsBuilder() + .addPath("contacts") + .build(); + + LoggedSqlCollector.start(); + server().merge(mCustomer, options); + + List sql = LoggedSqlCollector.stop(); + assertThat(sql.get(0)).contains("select t0.id, t1.id from mcustomer t0 left join mcontact t1 on t1.customer_id = t0.id where t0.id = ?"); + assertThat(sql.get(1)).contains("delete from mcontact_message where contact_id = ?"); + assertThat(sql.get(2)).contains("delete from mcontact where id=?"); + + assertThat(sql.get(5)).contains("update mcustomer set name=?, version=?, shipping_address_id=?, billing_address_id=? where id=? and version=?"); + assertThat(sql.get(6)).contains("insert into mcontact"); + assertThat(sql.get(7)).contains("insert into mcontact"); + assertThat(sql.get(8)).contains("update mcontact set email=?, first_name=?, last_name=?, version=?, customer_id=? where id=? and version=?"); + assertThat(sql.get(11)).contains("update mcontact set email=?, first_name=?, last_name=?, version=?, customer_id=? where id=? and version=?"); + } + + @Test + public void fullMonty() { + + MCustomer cust1 = customer("monty1"); + + modify(cust1); + + MergeOptions options = new MergeOptionsBuilder() + .addPath("billingAddress") + .addPath("shippingAddress") + .addPath("contacts") + .addPath("contacts.messages") + .setClientGeneratedIds() + .setDeletePermanent() + .build(); + + + LoggedSqlCollector.start(); + server().merge(cust1, options); + + List sql = LoggedSqlCollector.stop(); + assertThat(sql.get(0)).contains("select t0.id, t3.id, t1.id, t2.id from mcustomer t0 left join maddress t3 on t3.id = t0.shipping_address_id left join maddress t1 on t1.id = t0.billing_address_id left join mcontact t2 on t2.customer_id = t0.id where t0.id = ?"); + if (isH2()) { + // with nested OneToMany .. we need a second query to read the contact message ids + assertThat(sql.get(1)).contains("select t0.contact_id, t0.id from mcontact_message t0 where (t0.contact_id) in (?, ?, ?, ?, ?, ?, ?, ?, ?, ? )"); + } + assertThat(sql.get(2)).contains("delete from mcontact_message where contact_id = ?"); + assertThat(sql.get(3)).contains("delete from mcontact where id=?"); + assertThat(sql.get(4)).contains("delete from mcontact_message where contact_id = ?"); + assertThat(sql.get(5)).contains("delete from mcontact where id=?"); + + assertThat(sql.get(6)).contains("update maddress set street=?, city=?, version=? where id=? and version=?"); + assertThat(sql.get(7)).contains("update mcustomer set name=?, notes=?, version=?, shipping_address_id=?, billing_address_id=? where id=? and version=?"); + + assertThat(sql.get(8)).contains("insert into mcontact"); + assertThat(sql.get(9)).contains("update mcontact set email=?, first_name=?, last_name=?, version=?, customer_id=? where id=? and version=?"); + + assertThat(sql.get(13)).contains("update mcontact_message set title=?, subject=?, notes=?, version=?, contact_id=? where id=? and version=?"); + + } + + private void modify(MCustomer cust) { + + List contacts = cust.getContacts(); + MContact mContact = new MContact("z@a.com", "z", "zed"); + mContact.setId(UUID.randomUUID()); + + contacts.add(mContact); + + contacts.get(0).setEmail("a@beta.com"); + contacts.get(1).setEmail("b@beta.com"); + contacts.remove(4); + contacts.remove(2); + + cust.setName(cust.getName()+" modified"); + cust.getBillingAddress().setStreet("Short"); + + MAddress mAddress = new MAddress("Broken", "Dreams"); + mAddress.setId(UUID.randomUUID()); + cust.setShippingAddress(null); + } + + private MCustomer partial(String name, String fetchGraph) { + MCustomer cust1 = buildCustomer(name); + Ebean.save(cust1); + + FetchPath fetchPath = PathProperties.parse(fetchGraph); + String asJson = Ebean.json().toJson(cust1, fetchPath); + return Ebean.json().toBean(MCustomer.class, asJson); + } + + private MCustomer customer(String name) { + + MCustomer cust1 = buildCustomer(name); + Ebean.save(cust1); + + String asJson = Ebean.json().toJson(cust1); + + return Ebean.json().toBean(MCustomer.class, asJson); + } + + private MCustomer buildCustomer(String name) { + + MCustomer c = new MCustomer(name); + c.setShippingAddress(new MAddress("Fleet st", "London")); + c.setBillingAddress(new MAddress("Cow corner", "Mid Wicket")); + + c.getContacts().add(addContact("a@a.com", "a", "alligator")); + c.getContacts().add(addContact("b@a.com", "b", "beaver")); + c.getContacts().add(addContact("c@a.com", "c", "crow")); + c.getContacts().add(addContact("d@a.com", "d", "dog")); + c.getContacts().add(addContact("e@a.com", "e", "ent")); + c.getContacts().add(addContact("f@a.com", "f", "frog")); + + return c; + } + + private MContact addContact(String email, String first, String last) { + MContact mContact = new MContact(email, first, last); + int i = random.nextInt(2); + for (int j = 0; j < i; j++) { + mContact.getMessages().add(new MContactMessage(first+" "+i, last+" "+i)); + } + return mContact; + } +} diff --git a/src/test/java/org/tests/model/basic/UUOne.java b/src/test/java/org/tests/model/basic/UUOne.java index 8efe6da67..53c88446a 100644 --- a/src/test/java/org/tests/model/basic/UUOne.java +++ b/src/test/java/org/tests/model/basic/UUOne.java @@ -6,6 +6,7 @@ import javax.persistence.CascadeType; import javax.persistence.Entity; import javax.persistence.Id; import javax.persistence.OneToMany; +import javax.persistence.Version; import java.util.List; import java.util.UUID; @@ -18,10 +19,22 @@ public class UUOne { String name; + String description; @OneToMany(cascade = CascadeType.ALL, mappedBy = "master") List comments; + @Version + long version; + + public UUOne() { + } + + public UUOne(String name, UUID id) { + this.name = name; + this.id = id; + } + public UUID getId() { return id; } @@ -38,6 +51,14 @@ public class UUOne { this.name = name; } + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + public List getComments() { return comments; } @@ -46,4 +67,11 @@ public class UUOne { this.comments = comments; } + public long getVersion() { + return version; + } + + public void setVersion(long version) { + this.version = version; + } } diff --git a/src/test/java/org/tests/model/basic/UUTwo.java b/src/test/java/org/tests/model/basic/UUTwo.java index cea9a9efd..f446fe397 100644 --- a/src/test/java/org/tests/model/basic/UUTwo.java +++ b/src/test/java/org/tests/model/basic/UUTwo.java @@ -4,6 +4,7 @@ import javax.persistence.CascadeType; import javax.persistence.Entity; import javax.persistence.Id; import javax.persistence.ManyToOne; +import javax.persistence.Version; import java.util.UUID; @Entity @@ -14,9 +15,22 @@ public class UUTwo { String name; + String notes; + @ManyToOne(cascade = CascadeType.PERSIST) UUOne master; + @Version + long version; + + public UUTwo() { + } + + public UUTwo(String name, UUID id) { + this.name = name; + this.id = id; + } + public UUID getId() { return id; } @@ -33,6 +47,14 @@ public class UUTwo { this.name = name; } + public String getNotes() { + return notes; + } + + public void setNotes(String notes) { + this.notes = notes; + } + public UUOne getMaster() { return master; } @@ -41,4 +63,11 @@ public class UUTwo { this.master = master; } + public long getVersion() { + return version; + } + + public void setVersion(long version) { + this.version = version; + } } diff --git a/src/test/java/org/tests/query/joins/TestDisjunctWhereOuterOnMany.java b/src/test/java/org/tests/query/joins/TestDisjunctWhereOuterOnMany.java index 1cdd79c0d..cb7c6522d 100644 --- a/src/test/java/org/tests/query/joins/TestDisjunctWhereOuterOnMany.java +++ b/src/test/java/org/tests/query/joins/TestDisjunctWhereOuterOnMany.java @@ -3,10 +3,10 @@ package org.tests.query.joins; import io.ebean.BaseTestCase; import io.ebean.Ebean; import io.ebean.Query; -import org.tests.model.basic.UUOne; -import org.tests.model.basic.UUTwo; import org.junit.Assert; import org.junit.Test; +import org.tests.model.basic.UUOne; +import org.tests.model.basic.UUTwo; import java.util.List; @@ -58,11 +58,11 @@ public class TestDisjunctWhereOuterOnMany extends BaseTestCase { Assert.assertEquals(2, rowCount); if (isPostgres()) { - String expectedSql = "select distinct on (t0.id) t0.id, t0.name from uuone t0 left join uutwo u1 on u1.master_id = t0.id where (t0.name = ? or u1.name = ? ) "; + String expectedSql = "select distinct on (t0.id) t0.id, t0.name, t0.description, t0.version from uuone t0 left join uutwo u1 on u1.master_id = t0.id where (t0.name = ? or u1.name = ? ) "; assertThat(sqlOf(query, 1)).contains(expectedSql); } else { - String expectedSql = "select distinct t0.id, t0.name from uuone t0 left join uutwo u1 on u1.master_id = t0.id where (t0.name = ? or u1.name = ? ) "; + String expectedSql = "select distinct t0.id, t0.name, t0.description, t0.version from uuone t0 left join uutwo u1 on u1.master_id = t0.id where (t0.name = ? or u1.name = ? ) "; assertThat(sqlOf(query, 1)).contains(expectedSql); }