diff --git a/ebean-api/pom.xml b/ebean-api/pom.xml index d3868ff91..503c2f2c5 100644 --- a/ebean-api/pom.xml +++ b/ebean-api/pom.xml @@ -50,7 +50,7 @@ io.ebean ebean-annotation - 7.0 + 7.2 diff --git a/ebean-api/src/main/java/io/ebean/bean/EntityBeanIntercept.java b/ebean-api/src/main/java/io/ebean/bean/EntityBeanIntercept.java index 28a088ce6..78d8e5f8c 100644 --- a/ebean-api/src/main/java/io/ebean/bean/EntityBeanIntercept.java +++ b/ebean-api/src/main/java/io/ebean/bean/EntityBeanIntercept.java @@ -51,6 +51,11 @@ public final class EntityBeanIntercept implements Serializable { */ private static final byte FLAG_ORIG_VALUE_SET = 8; + /** + * Flags indicating if the mutable hash is set. + */ + private static final byte FLAG_MUTABLE_HASH_SET = 16; + private transient final ReentrantLock lock = new ReentrantLock(); private transient NodeUsageCollector nodeUsageCollector; private transient PersistenceContext persistenceContext; @@ -91,6 +96,17 @@ public final class EntityBeanIntercept implements Serializable { private Object ownerId; private int sortOrder; + /** + * Holds information of json loaded jackson beans (e.g. the original json or checksum). + */ + private MutableValueInfo[] mutableInfo; + + /** + * Holds json content determined at point of dirty check. + * Stored here on dirty check such that we only convert to json once. + */ + private MutableValueNext[] mutableNext; + /** * Create a intercept with a given entity. */ @@ -228,6 +244,17 @@ public final class EntityBeanIntercept implements Serializable { * if any embedded beans are either new or dirty (and hence need saving). */ public boolean isDirty() { + if (dirty) { + return true; + } + if (mutableInfo != null) { + for (int i = 0; i < mutableInfo.length; i++) { + if (mutableInfo[i] != null && !mutableInfo[i].isEqualToObject(owner._ebean_getField(i))) { + dirty = true; + break; + } + } + } return dirty; } @@ -368,8 +395,9 @@ public final class EntityBeanIntercept implements Serializable { this.owner._ebean_setEmbeddedLoaded(); this.lazyLoadProperty = -1; this.origValues = null; + this.mutableNext = null; for (int i = 0; i < flags.length; i++) { - flags[i] &= ~(FLAG_CHANGED_PROP + FLAG_ORIG_VALUE_SET); + flags[i] &= ~(FLAG_CHANGED_PROP | FLAG_ORIG_VALUE_SET); } this.dirty = false; } @@ -442,6 +470,10 @@ public final class EntityBeanIntercept implements Serializable { * Return the original value that was changed via an update. */ public Object getOrigValue(int propertyIndex) { + if ((flags[propertyIndex] & (FLAG_ORIG_VALUE_SET | FLAG_MUTABLE_HASH_SET)) == FLAG_MUTABLE_HASH_SET) { + // mutable hash set, but not ORIG_VALUE + setOriginalValue(propertyIndex, mutableInfo[propertyIndex].get()); + } if (origValues == null) { return null; } @@ -638,7 +670,7 @@ public final class EntityBeanIntercept implements Serializable { public void addDirtyPropertyNames(Set props, String prefix) { int len = getPropertyLength(); for (int i = 0; i < len; i++) { - if ((flags[i] & FLAG_CHANGED_PROP) != 0) { + if (isChangedProp(i)) { // the property has been changed on this bean props.add((prefix == null ? getProperty(i) : prefix + getProperty(i))); } else if ((flags[i] & FLAG_EMBEDDED_DIRTY) != 0) { @@ -656,7 +688,7 @@ public final class EntityBeanIntercept implements Serializable { String[] names = owner._ebean_getPropertyNames(); int len = getPropertyLength(); for (int i = 0; i < len; i++) { - if ((flags[i] & FLAG_CHANGED_PROP) != 0) { + if (isChangedProp(i)) { if (propertyNames.contains(names[i])) { return true; } @@ -684,7 +716,7 @@ public final class EntityBeanIntercept implements Serializable { public void addDirtyPropertyValues(Map dirtyValues, String prefix) { int len = getPropertyLength(); for (int i = 0; i < len; i++) { - if ((flags[i] & FLAG_CHANGED_PROP) != 0) { + if (isChangedProp(i)) { // the property has been changed on this bean String propName = (prefix == null ? getProperty(i) : prefix + getProperty(i)); Object newVal = owner._ebean_getField(i); @@ -706,7 +738,7 @@ public final class EntityBeanIntercept implements Serializable { public void addDirtyPropertyValues(BeanDiffVisitor visitor) { int len = getPropertyLength(); for (int i = 0; i < len; i++) { - if ((flags[i] & FLAG_CHANGED_PROP) != 0) { + if (isChangedProp(i)) { // the property has been changed on this bean Object newVal = owner._ebean_getField(i); Object oldVal = getOrigValue(i); @@ -741,7 +773,7 @@ public final class EntityBeanIntercept implements Serializable { } int len = getPropertyLength(); for (int i = 0; i < len; i++) { - if ((flags[i] & FLAG_CHANGED_PROP) != 0) { + if ((flags[i] & FLAG_CHANGED_PROP) != 0) { // we do not check against mutablecontent here. sb.append(i).append(','); } else if ((flags[i] & FLAG_EMBEDDED_DIRTY) != 0) { // an embedded property has been changed - recurse @@ -1138,4 +1170,62 @@ public final class EntityBeanIntercept implements Serializable { } return ret; } + + private boolean isChangedProp(int i) { + if ((flags[i] & FLAG_CHANGED_PROP) != 0) { + return true; + } else if (mutableInfo == null || mutableInfo[i] == null || mutableInfo[i].isEqualToObject(owner._ebean_getField(i))) { + return false; + } else { + // mark for change + flags[i] |= FLAG_CHANGED_PROP; + dirty = true; // this makes the bean automatically dirty! + return true; + } + } + + /** + * Return the MutableValueInfo for the given property or null. + */ + public MutableValueInfo mutableInfo(int propertyIndex) { + return mutableInfo == null ? null : mutableInfo[propertyIndex]; + } + + /** + * Set the MutableValueInfo for the given property. + */ + public void mutableInfo(int propertyIndex, MutableValueInfo info) { + if (mutableInfo == null) { + mutableInfo = new MutableValueInfo[flags.length]; + } + flags[propertyIndex] |= FLAG_MUTABLE_HASH_SET; + mutableInfo[propertyIndex] = info; + } + + /** + * Dirty detection set the next mutable property content and info . + *

+ * Set here as the mutable property dirty detection is based on json content comparison. + * We only want to perform the json serialisation once so storing it here as part of + * dirty detection so that we can get it back to bind in insert or update etc. + */ + public void mutableNext(int propertyIndex, MutableValueNext next) { + if (mutableNext == null) { + mutableNext = new MutableValueNext[flags.length]; + } + mutableNext[propertyIndex] = next; + } + + /** + * Update the 'next' mutable info returning the content that was obtained via dirty detection. + */ + public String mutableNext(int propertyIndex) { + if (mutableNext == null) { + return null; + } + final MutableValueNext next = mutableNext[propertyIndex]; + mutableInfo(propertyIndex, next.info()); + return next.content(); + } + } diff --git a/ebean-api/src/main/java/io/ebean/bean/MutableValueInfo.java b/ebean-api/src/main/java/io/ebean/bean/MutableValueInfo.java new file mode 100644 index 000000000..0f9c223aa --- /dev/null +++ b/ebean-api/src/main/java/io/ebean/bean/MutableValueInfo.java @@ -0,0 +1,42 @@ +package io.ebean.bean; + +/** + * Holds information on mutable values (like plain beans stored as json). + *

+ * Used internally in EntityBeanIntercept for dirty detection on mutable values. + * Typically dirty detection is based on a hash/checksum of json content or the + * original json content itself. + *

+ * Refer to the mapping options {@code @DbJson(dirtyDetection)} and {@code @DbJson(keepSource)}. + */ +public interface MutableValueInfo { + + /** + * Compares the given json returning null if deemed unchanged or returning + * the MutableValueNext to use if deemed dirty/changed. + *

+ * Returning MutableValueNext allows an implementation based on hash/checksum + * to only perform that computation once. + * + * @return Null if deemed unchanged or the MutableValueNext if deemed changed. + */ + MutableValueNext nextDirty(String json); + + /** + * Compares the given object to an internal value. + *

+ * This is used to support changelog/beanState. The implementation can serialize the + * object into json form and compare it against the original json. + */ + boolean isEqualToObject(Object obj); + + /** + * Creates a new instance from the internal json string. + *

+ * This is used to provide an original/old value for change logging / persist listeners. + * This is only available for properties that have {@code @DbJson(keepSource=true)}. + */ + default Object get() { + return null; + } +} diff --git a/ebean-api/src/main/java/io/ebean/bean/MutableValueNext.java b/ebean-api/src/main/java/io/ebean/bean/MutableValueNext.java new file mode 100644 index 000000000..401d3c223 --- /dev/null +++ b/ebean-api/src/main/java/io/ebean/bean/MutableValueNext.java @@ -0,0 +1,17 @@ +package io.ebean.bean; + +/** + * Represents a next value to use for mutable content properties (DbJson with jackson beans). + */ +public interface MutableValueNext { + + /** + * Return the next content to use. Provided such that we serialise to json once. + */ + String content(); + + /** + * Return the next MutableValueInfo to use after an update. + */ + MutableValueInfo info(); +} diff --git a/ebean-core-type/src/main/java/io/ebean/core/type/DataBinder.java b/ebean-core-type/src/main/java/io/ebean/core/type/DataBinder.java index be6a3b559..b08bb529a 100644 --- a/ebean-core-type/src/main/java/io/ebean/core/type/DataBinder.java +++ b/ebean-core-type/src/main/java/io/ebean/core/type/DataBinder.java @@ -163,4 +163,15 @@ public interface DataBinder { * Bind an array value. */ void setArray(String arrayType, Object[] elements) throws SQLException; + + /** + * Push json from dirty detection to be available for binding. + */ + void pushJson(String json); + + /** + * Pop json made during dirty detection for scalarType binding. + */ + String popJson(); + } diff --git a/ebean-core-type/src/main/java/io/ebean/core/type/DataReader.java b/ebean-core-type/src/main/java/io/ebean/core/type/DataReader.java index a8ace12be..ef61b9122 100644 --- a/ebean-core-type/src/main/java/io/ebean/core/type/DataReader.java +++ b/ebean-core-type/src/main/java/io/ebean/core/type/DataReader.java @@ -48,4 +48,14 @@ public interface DataReader { Object getObject() throws SQLException; InputStream getBinaryStream() throws SQLException; + + /** + * Push json from dirty detection to be available for binding. + */ + void pushJson(String json); + + /** + * Pop json made during dirty detection for scalarType binding. + */ + String popJson(); } diff --git a/ebean-core-type/src/main/java/io/ebean/core/type/ScalarType.java b/ebean-core-type/src/main/java/io/ebean/core/type/ScalarType.java index a971a0115..6c01811c5 100644 --- a/ebean-core-type/src/main/java/io/ebean/core/type/ScalarType.java +++ b/ebean-core-type/src/main/java/io/ebean/core/type/ScalarType.java @@ -2,6 +2,7 @@ package io.ebean.core.type; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.JsonParser; + import io.ebean.text.StringFormatter; import io.ebean.text.StringParser; @@ -34,6 +35,10 @@ import java.sql.SQLException; */ public interface ScalarType extends StringParser, StringFormatter, ScalarDataReader { + default boolean isJsonMapper() { + return false; + } + /** * Return true if this is a binary type and can not support parse() and format() from/to string. * This allows Ebean to optimise marshalling types to string. diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/BeanDescriptor.java b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/BeanDescriptor.java index 63599b7ad..8f0fea0f2 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/BeanDescriptor.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/BeanDescriptor.java @@ -2022,7 +2022,7 @@ public class BeanDescriptor implements BeanType, STreeType, SpiBeanType { public boolean isTableManaged(String tableName) { return owner.isTableManaged(tableName); } - + /** * Return the order column property. */ @@ -3198,9 +3198,9 @@ public class BeanDescriptor implements BeanType, STreeType, SpiBeanType { public void checkMutableProperties(EntityBeanIntercept ebi) { for (BeanProperty beanProperty : propertiesMutable) { int propertyIndex = beanProperty.getPropertyIndex(); - if (!ebi.isDirtyProperty(propertyIndex) && ebi.isLoadedProperty(propertyIndex)) { + if (ebi.isLoadedProperty(propertyIndex)) { Object value = beanProperty.getValue(ebi.getOwner()); - if (value != null && beanProperty.isDirtyValue(value)) { + if (beanProperty.checkMutable(value, ebi.isDirtyProperty(propertyIndex), ebi)) { // mutable scalar value which is considered dirty so mark // it as such so that it is included in an update ebi.markPropertyAsChanged(propertyIndex); diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/BeanProperty.java b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/BeanProperty.java index 8793942b6..a33d183ea 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/BeanProperty.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/BeanProperty.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.JsonToken; import io.ebean.ValuePair; import io.ebean.bean.EntityBean; import io.ebean.bean.EntityBeanIntercept; +import io.ebean.bean.MutableValueInfo; import io.ebean.bean.PersistenceContext; import io.ebean.config.EncryptKey; import io.ebean.config.dbplatform.DbEncryptFunction; @@ -13,6 +14,7 @@ import io.ebean.core.type.DocPropertyType; import io.ebean.core.type.ScalarType; import io.ebean.plugin.Property; import io.ebean.text.StringParser; +import io.ebean.text.TextException; import io.ebean.util.SplitName; import io.ebeaninternal.api.SpiExpressionRequest; import io.ebeaninternal.api.SpiQuery; @@ -31,11 +33,7 @@ import io.ebeaninternal.server.properties.BeanPropertySetter; import io.ebeaninternal.server.query.STreeProperty; import io.ebeaninternal.server.query.SqlBeanLoad; import io.ebeaninternal.server.query.SqlJoinType; -import io.ebeaninternal.server.type.DataBind; -import io.ebeaninternal.server.type.LocalEncryptedType; -import io.ebeaninternal.server.type.ScalarTypeBoolean; -import io.ebeaninternal.server.type.ScalarTypeEnum; -import io.ebeaninternal.server.type.ScalarTypeLogicalType; +import io.ebeaninternal.server.type.*; import io.ebeaninternal.util.ValueUtil; import io.ebeanservice.docstore.api.mapping.DocMappingBuilder; import io.ebeanservice.docstore.api.mapping.DocPropertyMapping; @@ -632,6 +630,8 @@ public class BeanProperty implements ElPropertyValue, Property, STreeProperty { setValue(bean, value); } return value; + } catch (TextException e) { + throw e; } catch (Exception e) { throw new PersistenceException("Error readSet on " + descriptor + "." + name, e); } @@ -642,15 +642,7 @@ public class BeanProperty implements ElPropertyValue, Property, STreeProperty { } public Object readSet(DbReadContext ctx, EntityBean bean) throws SQLException { - try { - Object value = scalarType.read(ctx.getDataReader()); - if (bean != null) { - setValue(bean, value); - } - return value; - } catch (Exception e) { - throw new PersistenceException("Error readSet on " + descriptor + "." + name, e); - } + return readSet(ctx.getDataReader(), bean); } @SuppressWarnings("unchecked") @@ -828,6 +820,13 @@ public class BeanProperty implements ElPropertyValue, Property, STreeProperty { return scalarType.parse(value); } + /** + * creates a mutableHash for the given JSON value. + */ + public MutableValueInfo createMutableInfo(String json) { + throw new UnsupportedOperationException(); + } + /** * Read the value for this property from L2 cache entry and set it to the bean. *

@@ -909,7 +908,8 @@ public class BeanProperty implements ElPropertyValue, Property, STreeProperty { /** * Return the name of the property. */ - @Override @Nonnull + @Override + @Nonnull public String getName() { return name; } @@ -1018,8 +1018,8 @@ public class BeanProperty implements ElPropertyValue, Property, STreeProperty { * Return true if the mutable value is considered dirty. * This is only used for 'mutable' scalar types like hstore etc. */ - boolean isDirtyValue(Object value) { - return scalarType.isDirty(value); + boolean checkMutable(Object value, boolean alreadyDirty, EntityBeanIntercept ebi) { + return alreadyDirty || value != null && scalarType.isDirty(value); } /** @@ -1281,7 +1281,7 @@ public class BeanProperty implements ElPropertyValue, Property, STreeProperty { @Override public Object localEncrypt(Object value) { - return ((LocalEncryptedType)scalarType).localEncrypt(value); + return ((LocalEncryptedType) scalarType).localEncrypt(value); } /** @@ -1394,7 +1394,8 @@ public class BeanProperty implements ElPropertyValue, Property, STreeProperty { /** * Return the property type. */ - @Override @Nonnull + @Override + @Nonnull public Class getPropertyType() { return propertyType; } diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/BeanPropertyJsonMapper.java b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/BeanPropertyJsonMapper.java new file mode 100644 index 000000000..bc7392287 --- /dev/null +++ b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/BeanPropertyJsonMapper.java @@ -0,0 +1,195 @@ +package io.ebeaninternal.server.deploy; + +import io.ebean.annotation.MutationDetection; +import io.ebean.bean.EntityBean; +import io.ebean.bean.EntityBeanIntercept; +import io.ebean.bean.MutableValueInfo; +import io.ebean.bean.MutableValueNext; +import io.ebean.core.type.DataReader; +import io.ebean.core.type.ScalarType; +import io.ebean.text.TextException; +import io.ebeaninternal.server.deploy.meta.DeployBeanProperty; +import io.ebeaninternal.server.util.Checksum; + +import javax.persistence.PersistenceException; +import java.sql.SQLException; +import java.util.Objects; + +/** + * Handle json property with MutationDetection of SOURCE or HASH only. + */ +public class BeanPropertyJsonMapper extends BeanProperty { + + private final boolean sourceDetection; + + public BeanPropertyJsonMapper(BeanDescriptor desc, DeployBeanProperty deployProp) { + super(desc, deployProp); + this.sourceDetection = deployProp.getMutationDetection() == MutationDetection.SOURCE; + } + + @Override + public MutableValueInfo createMutableInfo(String json) { + if (sourceDetection) { + return new SourceMutableValue(scalarType, json); + } else { + return new ChecksumMutableValue(scalarType, json); + } + } + + /** + * Next when no prior MutableValueInfo. + */ + private MutableValueNext next(String json) { + if (sourceDetection) { + return new SourceMutableValue(scalarType, json); + } else { + return new NextPair(json, new ChecksumMutableValue(scalarType, json)); + } + } + + /** + * Return true if the json property is considered dirty. + */ + @Override + boolean checkMutable(Object value, boolean alreadyDirty, EntityBeanIntercept ebi) { + // mutation detection based on json content or checksum of json content + // only perform serialisation to json once + final String json = scalarType.format(value); + final MutableValueInfo oldHash = ebi.mutableInfo(propertyIndex); + if (oldHash == null) { + if (value == null) { + return false; // no change, still null + } + ebi.mutableNext(propertyIndex, next(json)); + return true; + } + // only perform compute of checksum/hash once (if checksum based) + final MutableValueNext next = oldHash.nextDirty(json); + if (next != null) { + ebi.mutableNext(propertyIndex, next); + return true; + } + return false; + } + + @Override + public Object readSet(DataReader reader, EntityBean bean) throws SQLException { + try { + Object value = scalarType.read(reader); + if (bean != null) { + setValue(bean, value); + String json = reader.popJson(); + if (json != null) { + final MutableValueInfo hash = createMutableInfo(json); + bean._ebean_getIntercept().mutableInfo(propertyIndex, hash); + } + } + return value; + } catch (TextException e) { + throw e; + } catch (Exception e) { + throw new PersistenceException("Error readSet on " + descriptor + "." + name, e); + } + } + + private static final class NextPair implements MutableValueNext { + + private final String json; + private final MutableValueInfo next; + + NextPair(String json, MutableValueInfo next) { + this.json = json; + this.next = next; + } + + @Override + public String content() { + return json; + } + + @Override + public MutableValueInfo info() { + return next; + } + } + + /** + * Hold checksum of json source content to use for dirty detection. + *

+ * Does not support rebuilding 'oldValue' as no original json content. + */ + private static final class ChecksumMutableValue implements MutableValueInfo { + + private final ScalarType parent; + private final long checksum; + + ChecksumMutableValue(ScalarType parent, String json) { + this.parent = parent; + this.checksum = Checksum.checksum(json); + } + + /** + * Create with pre-computed checksum. + */ + ChecksumMutableValue(ScalarType parent, long checksum) { + this.parent = parent; + this.checksum = checksum; + } + + @Override + public MutableValueNext nextDirty(String json) { + final long nextChecksum = Checksum.checksum(json); + return nextChecksum == checksum ? null : new NextPair(json, new ChecksumMutableValue(parent, nextChecksum)); + } + + @Override + public boolean isEqualToObject(Object obj) { + return Checksum.checksum(parent.format(obj)) == checksum; + } + + @Override + public Object get() { + return null; // cannot create object from json + } + } + + /** + * Hold json source content. This supports rebuilding the 'oldValue'. + */ + private static final class SourceMutableValue implements MutableValueInfo, MutableValueNext { + + private final String originalJson; + private final ScalarType parent; + + SourceMutableValue(ScalarType parent, String json) { + this.parent = parent; + this.originalJson = json; + } + + @Override + public MutableValueNext nextDirty(String json) { + return Objects.equals(originalJson, json) ? null : new SourceMutableValue(parent, json); + } + + @Override + public boolean isEqualToObject(Object obj) { + return Objects.equals(originalJson, parent.format(obj)); + } + + @Override + public Object get() { + // rebuild the 'oldValue' for change log etc + return parent.parse(originalJson); + } + + @Override + public String content() { + return originalJson; + } + + @Override + public MutableValueInfo info() { + return this; + } + } +} diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/meta/DeployBeanProperty.java b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/meta/DeployBeanProperty.java index e20c898ce..3da06d559 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/meta/DeployBeanProperty.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/meta/DeployBeanProperty.java @@ -1,18 +1,6 @@ package io.ebeaninternal.server.deploy.meta; -import io.ebean.annotation.CreatedTimestamp; -import io.ebean.annotation.DocCode; -import io.ebean.annotation.DocProperty; -import io.ebean.annotation.DocSortable; -import io.ebean.annotation.Formula; -import io.ebean.annotation.Platform; -import io.ebean.annotation.SoftDelete; -import io.ebean.annotation.UpdatedTimestamp; -import io.ebean.annotation.WhenCreated; -import io.ebean.annotation.WhenModified; -import io.ebean.annotation.Where; -import io.ebean.annotation.WhoCreated; -import io.ebean.annotation.WhoModified; +import io.ebean.annotation.*; import io.ebean.config.ScalarTypeConverter; import io.ebean.config.dbplatform.DbDefaultValue; import io.ebean.config.dbplatform.DbEncrypt; @@ -39,7 +27,6 @@ import java.lang.reflect.Field; import java.lang.reflect.Type; import java.sql.Types; import java.util.ArrayList; -import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -109,6 +96,7 @@ public class DeployBeanProperty { private boolean jsonSerialize = true; private boolean jsonDeserialize = true; + private MutationDetection mutationDetection; private boolean dbEncrypted; private DbEncryptFunction dbEncryptFunction; @@ -327,6 +315,17 @@ public class DeployBeanProperty { this.jsonDeserialize = jsonDeserialize; } + public MutationDetection getMutationDetection() { + if (mutationDetection == null) { + mutationDetection = MutationDetection.DEFAULT; + } + return mutationDetection; + } + + public void setMutationDetection(MutationDetection dirtyDetection) { + this.mutationDetection = dirtyDetection; + } + /** * Return the sortOrder for the properties. */ @@ -1201,4 +1200,8 @@ public class DeployBeanProperty { return false; } + boolean isJsonMapper() { + return scalarType != null && scalarType.isJsonMapper(); + } + } diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/meta/DeployBeanPropertyLists.java b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/meta/DeployBeanPropertyLists.java index f6280b260..90503c93f 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/meta/DeployBeanPropertyLists.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/meta/DeployBeanPropertyLists.java @@ -1,15 +1,7 @@ package io.ebeaninternal.server.deploy.meta; import io.ebean.bean.EntityBean; -import io.ebeaninternal.server.deploy.BeanDescriptor; -import io.ebeaninternal.server.deploy.BeanDescriptorMap; -import io.ebeaninternal.server.deploy.BeanProperty; -import io.ebeaninternal.server.deploy.BeanPropertyAssocMany; -import io.ebeaninternal.server.deploy.BeanPropertyAssocOne; -import io.ebeaninternal.server.deploy.BeanPropertyIdClass; -import io.ebeaninternal.server.deploy.BeanPropertyOrderColumn; -import io.ebeaninternal.server.deploy.BeanPropertySimpleCollection; -import io.ebeaninternal.server.deploy.InheritInfo; +import io.ebeaninternal.server.deploy.*; import io.ebeaninternal.server.deploy.generatedproperty.GeneratedProperty; import io.ebeaninternal.server.properties.BeanPropertySetter; import io.ebeaninternal.server.type.ScalarTypeString; @@ -30,47 +22,28 @@ public class DeployBeanPropertyLists { private static final NoopSetter NOOP_SETTER = new NoopSetter(); private BeanProperty versionProperty; - private BeanProperty unmappedJson; - private BeanProperty draft; - private BeanProperty draftDirty; - private BeanProperty tenant; - private final BeanDescriptor desc; - private final LinkedHashMap propertyMap; - private BeanProperty id; private final List local = new ArrayList<>(); - private final List mutable = new ArrayList<>(); - private final List> manys = new ArrayList<>(); - private final List nonManys = new ArrayList<>(); - private final List aggs = new ArrayList<>(); - private final List> ones = new ArrayList<>(); - private final List> onesImported = new ArrayList<>(); - private final List> embedded = new ArrayList<>(); - private final List baseScalar = new ArrayList<>(); - private final List transients = new ArrayList<>(); - private final List nonTransients = new ArrayList<>(); - private final BeanPropertyAssocOne unidirectional; private final BeanProperty orderColumn; - @SuppressWarnings({"unchecked"}) public DeployBeanPropertyLists(BeanDescriptorMap owner, BeanDescriptor desc, DeployBeanDescriptor deploy) { this.desc = desc; @@ -86,7 +59,7 @@ public class DeployBeanPropertyLists { this.orderColumn = deployOrderColumn != null ? new BeanPropertyOrderColumn(desc, deployOrderColumn) : null; DeployBeanPropertyAssocOne deployUnidirectional = deploy.getUnidirectional(); - this.unidirectional = deployUnidirectional == null ? null : new BeanPropertyAssocOne(owner, desc, deployUnidirectional); + this.unidirectional = deployUnidirectional == null ? null : new BeanPropertyAssocOne<>(owner, desc, deployUnidirectional); this.propertyMap = new LinkedHashMap<>(); @@ -127,7 +100,7 @@ public class DeployBeanPropertyLists { } if (orderColumn != null) { - orderColumn.setDeployOrder(order++); + orderColumn.setDeployOrder(order); allocateToList(orderColumn); propertyMap.put(orderColumn.getName(), orderColumn); } @@ -154,7 +127,6 @@ public class DeployBeanPropertyLists { } private void setImportedPrimaryKeysFor(DeployBeanDescriptor deploy, DeployBeanPropertyAssocOne id) { - for (DeployBeanProperty prop : id.getTargetDeploy().properties()) { DeployBeanProperty match = findImported(deploy, prop); if (match != null) { @@ -164,7 +136,6 @@ public class DeployBeanPropertyLists { } private DeployBeanProperty findImported(DeployBeanDescriptor deploy, DeployBeanProperty embeddedScalar) { - // the logical name and db column we are looking for a match on String name = embeddedScalar.getName(); String dbColumn = embeddedScalar.getDbColumn(); @@ -180,7 +151,6 @@ public class DeployBeanPropertyLists { return assocOne; } } - return null; } @@ -368,7 +338,6 @@ public class DeployBeanPropertyLists { } public BeanProperty getSoftDeleteProperty() { - for (BeanProperty prop : nonManys) { if (prop.isSoftDelete()) { return prop; @@ -385,7 +354,6 @@ public class DeployBeanPropertyLists { * Return the properties set via generated values on insert. */ public BeanProperty[] getGeneratedInsert() { - List list = new ArrayList<>(); for (BeanProperty prop : nonTransients) { GeneratedProperty gen = prop.getGeneratedProperty(); @@ -400,7 +368,6 @@ public class DeployBeanPropertyLists { * Return the properties set via generated values on update. */ public BeanProperty[] getGeneratedUpdate() { - List list = new ArrayList<>(); for (BeanProperty prop : nonTransients) { GeneratedProperty gen = prop.getGeneratedProperty(); @@ -438,8 +405,7 @@ public class DeployBeanPropertyLists { } } } - - return (BeanPropertyAssocOne[]) list.toArray(new BeanPropertyAssocOne[0]); + return list.toArray(new BeanPropertyAssocOne[0]); } private BeanPropertyAssocMany[] getMany2Many() { @@ -449,8 +415,7 @@ public class DeployBeanPropertyLists { list.add(prop); } } - - return (BeanPropertyAssocMany[]) list.toArray(new BeanPropertyAssocMany[0]); + return list.toArray(new BeanPropertyAssocMany[0]); } private BeanPropertyAssocMany[] getMany(Mode mode) { @@ -471,25 +436,23 @@ public class DeployBeanPropertyLists { break; } } - - return (BeanPropertyAssocMany[]) list.toArray(new BeanPropertyAssocMany[0]); + return list.toArray(new BeanPropertyAssocMany[0]); } @SuppressWarnings({"unchecked", "rawtypes"}) private BeanProperty createBeanProperty(BeanDescriptorMap owner, DeployBeanProperty deployProp) { - if (deployProp instanceof DeployBeanPropertyAssocOne) { return new BeanPropertyAssocOne(owner, desc, (DeployBeanPropertyAssocOne) deployProp); } - if (deployProp instanceof DeployBeanPropertySimpleCollection) { return new BeanPropertySimpleCollection(desc, (DeployBeanPropertySimpleCollection) deployProp); } - if (deployProp instanceof DeployBeanPropertyAssocMany) { return new BeanPropertyAssocMany(desc, (DeployBeanPropertyAssocMany) deployProp); } - + if (deployProp.isJsonMapper()) { + return new BeanPropertyJsonMapper(desc, deployProp); + } return new BeanProperty(desc, deployProp); } diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/parse/DeployUtil.java b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/parse/DeployUtil.java index 65c7cf45e..0792c66d5 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/deploy/parse/DeployUtil.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/deploy/parse/DeployUtil.java @@ -1,10 +1,6 @@ package io.ebeaninternal.server.deploy.parse; -import io.ebean.annotation.DbArray; -import io.ebean.annotation.DbJson; -import io.ebean.annotation.DbJsonB; -import io.ebean.annotation.DbJsonType; -import io.ebean.annotation.DbMap; +import io.ebean.annotation.*; import io.ebean.config.DatabaseConfig; import io.ebean.config.EncryptDeploy; import io.ebean.config.EncryptDeployManager; @@ -213,22 +209,21 @@ public class DeployUtil { * This property is marked as a Lob object. */ void setDbJsonType(DeployBeanProperty prop, DbJson dbJsonType) { - int dbType = getDbJsonStorage(dbJsonType.storage()); - setDbJsonType(prop, dbType, dbJsonType.length()); + setDbJsonType(prop, dbType, dbJsonType.length(), dbJsonType.mutationDetection()); } void setDbJsonBType(DeployBeanProperty prop, DbJsonB dbJsonB) { - setDbJsonType(prop, DbPlatformType.JSONB, dbJsonB.length()); + setDbJsonType(prop, DbPlatformType.JSONB, dbJsonB.length(), dbJsonB.mutationDetection()); } - private void setDbJsonType(DeployBeanProperty prop, int dbType, int dbLength) { - + private void setDbJsonType(DeployBeanProperty prop, int dbType, int dbLength, MutationDetection mutationDetection) { + prop.setDbType(dbType); + prop.setMutationDetection(mutationDetection); ScalarType scalarType = typeManager.getJsonScalarType(prop, dbType, dbLength); if (scalarType == null) { throw new RuntimeException("No ScalarType for JSON property [" + prop + "] [" + dbType + "]"); } - prop.setDbType(dbType); prop.setScalarType(scalarType); if (dbType == Types.VARCHAR || dbLength > 0) { // determine the db column size diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/persist/dml/DmlHandler.java b/ebean-core/src/main/java/io/ebeaninternal/server/persist/dml/DmlHandler.java index 189613808..0a4bb6b8a 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/persist/dml/DmlHandler.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/persist/dml/DmlHandler.java @@ -63,6 +63,11 @@ public abstract class DmlHandler implements PersistHandler, BindableRequest { } } + @Override + public void pushJson(String json) { + dataBind.pushJson(json); + } + @Override public long now() { return now; diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/persist/dmlbind/BindablePropertyJsonInsert.java b/ebean-core/src/main/java/io/ebeaninternal/server/persist/dmlbind/BindablePropertyJsonInsert.java new file mode 100644 index 000000000..1cbb7eeca --- /dev/null +++ b/ebean-core/src/main/java/io/ebeaninternal/server/persist/dmlbind/BindablePropertyJsonInsert.java @@ -0,0 +1,42 @@ +package io.ebeaninternal.server.persist.dmlbind; + +import io.ebean.bean.EntityBean; +import io.ebean.bean.MutableValueInfo; +import io.ebeaninternal.server.deploy.BeanProperty; + +import java.sql.SQLException; + +/** + * For JSON Jackson properties - dirty detection via MD5 of json content. + */ +class BindablePropertyJsonInsert extends BindableProperty { + + private final int propertyIndex; + + BindablePropertyJsonInsert(BeanProperty prop) { + super(prop); + this.propertyIndex = prop.getPropertyIndex(); + } + + /** + * Normal binding of a property value from the bean. + */ + @Override + public void dmlBind(BindableRequest request, EntityBean bean) throws SQLException { + if (bean == null) { + request.bind(null, prop); + } else { + Object value = prop.getValue(bean); + if (value == null) { + request.bind(null, prop); + } else { + // on insert store hash and push json + final String json = prop.format(value); + final MutableValueInfo hash = prop.createMutableInfo(json); + bean._ebean_getIntercept().mutableInfo(propertyIndex, hash); + request.pushJson(json); + request.bind(value, prop); + } + } + } +} diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/persist/dmlbind/BindablePropertyJsonUpdate.java b/ebean-core/src/main/java/io/ebeaninternal/server/persist/dmlbind/BindablePropertyJsonUpdate.java new file mode 100644 index 000000000..cfa056a63 --- /dev/null +++ b/ebean-core/src/main/java/io/ebeaninternal/server/persist/dmlbind/BindablePropertyJsonUpdate.java @@ -0,0 +1,33 @@ +package io.ebeaninternal.server.persist.dmlbind; + +import io.ebean.bean.EntityBean; +import io.ebeaninternal.server.deploy.BeanProperty; + +import java.sql.SQLException; + +/** + * For JSON Jackson properties - dirty detection via MD5 of json content. + */ +class BindablePropertyJsonUpdate extends BindableProperty { + + private final int propertyIndex; + + BindablePropertyJsonUpdate(BeanProperty prop) { + super(prop); + this.propertyIndex = prop.getPropertyIndex(); + } + + /** + * Normal binding of a property value from the bean. + */ + @Override + public void dmlBind(BindableRequest request, EntityBean bean) throws SQLException { + if (bean == null) { + request.bind(null, prop); + } else { + // update mutableInfo and push json + request.pushJson(bean._ebean_getIntercept().mutableNext(propertyIndex)); + request.bind(prop.getValue(bean), prop); + } + } +} diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/persist/dmlbind/BindableRequest.java b/ebean-core/src/main/java/io/ebeaninternal/server/persist/dmlbind/BindableRequest.java index 4a1fc6754..e06862e86 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/persist/dmlbind/BindableRequest.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/persist/dmlbind/BindableRequest.java @@ -57,4 +57,9 @@ public interface BindableRequest { * Return true if this is an update request. */ boolean isUpdate(); + + /** + * Push json content for scalarType bind(). + */ + void pushJson(String json); } diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/persist/dmlbind/FactoryProperty.java b/ebean-core/src/main/java/io/ebeaninternal/server/persist/dmlbind/FactoryProperty.java index b47b4ecb6..d5c59daf3 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/persist/dmlbind/FactoryProperty.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/persist/dmlbind/FactoryProperty.java @@ -2,6 +2,7 @@ package io.ebeaninternal.server.persist.dmlbind; import io.ebeaninternal.server.deploy.BeanProperty; import io.ebeaninternal.server.deploy.BeanPropertyAssocOne; +import io.ebeaninternal.server.deploy.BeanPropertyJsonMapper; import io.ebeaninternal.server.persist.dml.DmlMode; /** @@ -23,14 +24,12 @@ class FactoryProperty { * Create a Bindable for the property given the mode and withLobs flag. */ public Bindable create(BeanProperty prop, DmlMode mode, boolean withLobs, boolean allowManyToOne) { - if (DmlMode.INSERT == mode && !prop.isDbInsertable()) { return null; } if (DmlMode.UPDATE == mode && !prop.isDbUpdatable()) { return null; } - if (prop.isLob() && !withLobs) { // Lob exclusion return null; @@ -38,11 +37,16 @@ class FactoryProperty { if (prop.isDbEncrypted()){ return new BindableEncryptedProperty(prop, bindEncryptDataFirst); } - if (allowManyToOne && prop instanceof BeanPropertyAssocOne) { return new BindableAssocOne((BeanPropertyAssocOne)prop); } - + if (prop instanceof BeanPropertyJsonMapper) { + if (DmlMode.INSERT == mode) { + return new BindablePropertyJsonInsert(prop); + } else if (DmlMode.UPDATE == mode) { + return new BindablePropertyJsonUpdate(prop); + } + } return new BindableProperty(prop); } } diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlBeanLoad.java b/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlBeanLoad.java index ac1386e11..83c7b2936 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlBeanLoad.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/query/SqlBeanLoad.java @@ -18,19 +18,15 @@ public class SqlBeanLoad { private final DbReadContext ctx; private final EntityBean bean; private final EntityBeanIntercept ebi; - private final Class type; private final boolean lazyLoading; - private final boolean refreshLoading; private final boolean rawSql; SqlBeanLoad(DbReadContext ctx, Class type, EntityBean bean, Mode queryMode) { - this.ctx = ctx; this.rawSql = ctx.isRawSql(); this.type = type; this.lazyLoading = queryMode == Mode.LAZYLOAD_BEAN; - this.refreshLoading = queryMode == Mode.REFRESH_BEAN; this.bean = bean; this.ebi = bean == null ? null : bean._ebean_getIntercept(); } @@ -50,34 +46,21 @@ public class SqlBeanLoad { } public Object load(BeanProperty prop) { - if (!rawSql && !prop.isLoadProperty(ctx.isDraftQuery())) { return null; } - if ((bean == null) || (lazyLoading && ebi.isLoadedProperty(prop.getPropertyIndex())) || (type != null && !prop.isAssignableFrom(type))) { - // ignore this property // ... null: bean already in persistence context // ... lazyLoading: partial bean that is lazy loading // ... type: inheritance and not assignable to this instance - prop.loadIgnore(ctx); return null; } - try { - Object dbVal = prop.read(ctx); - if (!refreshLoading) { - prop.setValue(bean, dbVal); - } else { - prop.setValueIntercept(bean, dbVal); - } - - return dbVal; - + return prop.readSet(ctx, bean); } catch (Exception e) { bean._ebean_getIntercept().setLoadError(prop.getPropertyIndex(), e); ctx.handleLoadError(prop.getFullBeanName(), e); @@ -89,10 +72,6 @@ public class SqlBeanLoad { * Load the given value into the property. */ public void load(BeanProperty target, Object dbVal) { - if (!refreshLoading) { - target.setValue(bean, dbVal); - } else { - target.setValueIntercept(bean, dbVal); - } + target.setValue(bean, dbVal); } } diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/type/DataBind.java b/ebean-core/src/main/java/io/ebeaninternal/server/type/DataBind.java index 2959c872c..a1a9ec8cf 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/type/DataBind.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/type/DataBind.java @@ -36,6 +36,7 @@ public class DataBind implements DataBinder { private List inputStreams; protected int pos; + private String json; public DataBind(DataTimeZone dataTimeZone, PreparedStatement pstmt, Connection connection) { this.dataTimeZone = dataTimeZone; @@ -43,6 +44,19 @@ public class DataBind implements DataBinder { this.connection = connection; } + @Override + public void pushJson(String json) { + assert this.json == null; // we can only push one value + this.json = json; + } + + @Override + public String popJson() { + String ret = json; + json = null; + return ret; + } + @Override public StringBuilder append(Object entry) { return bindLog.append(entry); diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/type/DefaultTypeManager.java b/ebean-core/src/main/java/io/ebeaninternal/server/type/DefaultTypeManager.java index befe46b20..28999e838 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/type/DefaultTypeManager.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/type/DefaultTypeManager.java @@ -2,7 +2,6 @@ package io.ebeaninternal.server.type; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.introspect.AnnotatedField; import io.ebean.annotation.*; import io.ebean.config.DatabaseConfig; import io.ebean.config.JsonConfig; @@ -426,7 +425,7 @@ public final class DefaultTypeManager implements TypeManager { if (objectMapper == null) { throw new IllegalArgumentException("Type [" + type + "] unsupported for @DbJson mapping - Jackson ObjectMapper not present"); } - return ScalarTypeJsonObjectMapper.createTypeFor(jsonManager, (AnnotatedField) prop.getJacksonField(), dbType, docType); + return ScalarTypeJsonObjectMapper.createTypeFor(jsonManager, prop, dbType, docType); } /** diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/type/RsetDataReader.java b/ebean-core/src/main/java/io/ebeaninternal/server/type/RsetDataReader.java index 9763a5667..b85c8e688 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/type/RsetDataReader.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/type/RsetDataReader.java @@ -20,22 +20,29 @@ import java.util.Calendar; public class RsetDataReader implements DataReader { private static final int bufferSize = 512; - static final int clobBufferSize = 512; - static final int stringInitialSize = 512; private final DataTimeZone dataTimeZone; - private final ResultSet rset; - protected int pos; + private String json; public RsetDataReader(DataTimeZone dataTimeZone, ResultSet rset) { this.dataTimeZone = dataTimeZone; this.rset = rset; } + @Override + public void pushJson(String json) { + this.json = json; + } + + @Override + public String popJson() { + return json; + } + @Override public void close() throws SQLException { rset.close(); diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/type/ScalarTypeJsonObjectMapper.java b/ebean-core/src/main/java/io/ebeaninternal/server/type/ScalarTypeJsonObjectMapper.java index 5e24412b5..7fb3aa4b4 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/type/ScalarTypeJsonObjectMapper.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/type/ScalarTypeJsonObjectMapper.java @@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectWriter; import com.fasterxml.jackson.databind.introspect.AnnotatedField; +import io.ebean.annotation.MutationDetection; import io.ebean.core.type.DataBinder; import io.ebean.core.type.DataReader; import io.ebean.core.type.DocPropertyType; @@ -15,6 +16,7 @@ import io.ebean.text.TextException; import io.ebeaninternal.json.ModifyAwareList; import io.ebeaninternal.json.ModifyAwareMap; import io.ebeaninternal.json.ModifyAwareSet; +import io.ebeaninternal.server.deploy.meta.DeployBeanProperty; import javax.persistence.PersistenceException; import java.io.DataInput; @@ -34,8 +36,16 @@ class ScalarTypeJsonObjectMapper { /** * Create and return the appropriate ScalarType. */ - static ScalarType createTypeFor(TypeJsonManager jsonManager, AnnotatedField field, int dbType, DocPropertyType docType) { + static ScalarType createTypeFor(TypeJsonManager jsonManager, DeployBeanProperty prop, int dbType, DocPropertyType docType) { + AnnotatedField field = (AnnotatedField) prop.getJacksonField(); Class type = field.getRawType(); + + MutationDetection mode = prop.getMutationDetection(); + if (mode == MutationDetection.NONE) { + return new NoMutationDetection(jsonManager, field, dbType, type); + } else if (mode != MutationDetection.DEFAULT) { + return new GenericObject(jsonManager, field, dbType, type); + } if (Set.class.equals(type)) { return new OmSet(jsonManager, field, dbType, docType); } @@ -45,17 +55,77 @@ class ScalarTypeJsonObjectMapper { if (Map.class.equals(type)) { return new OmMap(jsonManager, field, dbType); } + prop.setMutationDetection(MutationDetection.HASH); return new GenericObject(jsonManager, field, dbType, type); } /** - * Maps any type (Object) using Jackson ObjectMapper. + * No mutation detection on this json property. + */ + private static class NoMutationDetection extends Base { + + NoMutationDetection(TypeJsonManager jsonManager, AnnotatedField field, int dbType, Class rawType) { + super(Object.class, jsonManager, field, dbType, DocPropertyType.OBJECT, rawType); + } + + @Override + public boolean isMutable() { + return false; + } + + @Override + public boolean isDirty(Object value) { + return false; + } + } + + /** + * Supports HASH and SOURCE dirty detection modes. */ private static class GenericObject extends Base { GenericObject(TypeJsonManager jsonManager, AnnotatedField field, int dbType, Class rawType) { super(Object.class, jsonManager, field, dbType, DocPropertyType.OBJECT, rawType); } + + @Override + public boolean isJsonMapper() { + return true; + } + + @Override + public Object read(DataReader reader) throws SQLException { + String json = reader.getString(); + if (json == null || json.isEmpty()) { + return null; + } + // pushJson such that we MD5 and store on EntityBeanIntercept later + reader.pushJson(json); + try { + return objectReader.readValue(json, deserType); + } catch (IOException e) { + throw new TextException("Failed to parse JSON [{}] as " + deserType, json, e); + } + } + + @Override + public void bind(DataBinder binder, Object value) throws SQLException { + // popJson as dirty detection already converted to json string + String rawJson = binder.popJson(); + if (rawJson == null && value != null) { + rawJson = formatValue(value); // not expected, need to check? + } + if (pgType != null) { + binder.setObject(PostgresHelper.asObject(pgType, rawJson)); + } else { + if (value == null) { + // use varchar, otherwise SqlServer/db2 will fail with 'Invalid JDBC data type 5.001.' + binder.setNull(Types.VARCHAR); + } else { + binder.setString(rawJson); + } + } + } } /** @@ -118,10 +188,10 @@ class ScalarTypeJsonObjectMapper { */ private static abstract class Base extends ScalarTypeBase { - private final ObjectWriter objectWriter; - private final ObjectMapper objectReader; - private final JavaType deserType; - private final String pgType; + protected final ObjectWriter objectWriter; + protected final ObjectMapper objectReader; + protected final JavaType deserType; + protected final String pgType; private final DocPropertyType docType; private final TypeJsonManager.DirtyHandler dirtyHandler; diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/util/Checksum.java b/ebean-core/src/main/java/io/ebeaninternal/server/util/Checksum.java new file mode 100644 index 000000000..8295703c1 --- /dev/null +++ b/ebean-core/src/main/java/io/ebeaninternal/server/util/Checksum.java @@ -0,0 +1,20 @@ +package io.ebeaninternal.server.util; + +import java.nio.charset.StandardCharsets; +import java.util.zip.CRC32; + +/** + * Compute a checksum for String content. Use when we desire cheaper option than MD5. + */ +public final class Checksum { + + /** + * Return the checksum for the given String input. + */ + public static long checksum(String input) { + CRC32 checksum = new CRC32(); + final byte[] bytes = input.getBytes(StandardCharsets.UTF_8); + checksum.update(bytes, 0, bytes.length); + return checksum.getValue(); + } +} diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/util/Md5.java b/ebean-core/src/main/java/io/ebeaninternal/server/util/Md5.java index 1c848f55d..7329d3f25 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/util/Md5.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/util/Md5.java @@ -21,7 +21,6 @@ public final class Md5 { * Convert the digest into a hex value. */ private static String digestToHex(byte[] digest) { - StringBuilder sb = new StringBuilder(); for (byte aDigest : digest) { sb.append(Integer.toString((aDigest & 0xff) + 0x100, 16).substring(1)); diff --git a/ebean-core/src/test/java/io/ebean/EbeanServer_refresh.java b/ebean-core/src/test/java/io/ebean/EbeanServer_refresh.java index 1e1900a13..66af5d164 100644 --- a/ebean-core/src/test/java/io/ebean/EbeanServer_refresh.java +++ b/ebean-core/src/test/java/io/ebean/EbeanServer_refresh.java @@ -11,7 +11,7 @@ import java.util.HashMap; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.Assert.assertEquals; +import static org.junit.Assert.*; public class EbeanServer_refresh { @@ -39,8 +39,11 @@ public class EbeanServer_refresh { assertEquals(rows, 1); + basic.setName("modify"); + assertTrue(DB.getBeanState(basic).isDirty()); server.refresh(basic); assertEquals(basic.getStatus(), EBasic.Status.ACTIVE); + assertFalse(DB.getBeanState(basic).isDirty()); } @Test diff --git a/ebean-core/src/test/java/io/ebeaninternal/server/util/ChecksumTest.java b/ebean-core/src/test/java/io/ebeaninternal/server/util/ChecksumTest.java new file mode 100644 index 000000000..9eceabb0a --- /dev/null +++ b/ebean-core/src/test/java/io/ebeaninternal/server/util/ChecksumTest.java @@ -0,0 +1,23 @@ +package io.ebeaninternal.server.util; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ChecksumTest { + + @Test + public void checksum() { + final long val = Checksum.checksum("Hello world"); + assertThat(val).isEqualTo(2346098258L); + assertThat(Checksum.checksum("Hello world")).isEqualTo(val); + assertThat(Checksum.checksum("hello world")).isNotEqualTo(val); + } + + @Test + public void checksum_shortString() { + final long val0 = Checksum.checksum("2012-01-11"); + final long val1 = Checksum.checksum("2012-10-02"); + assertThat(val0).isNotEqualTo(val1); + } +} diff --git a/ebean-core/src/test/java/org/tests/json/TestDbJson_Jackson3.java b/ebean-core/src/test/java/org/tests/json/TestDbJson_Jackson3.java index 106beb9f2..c1cc3c40d 100644 --- a/ebean-core/src/test/java/org/tests/json/TestDbJson_Jackson3.java +++ b/ebean-core/src/test/java/org/tests/json/TestDbJson_Jackson3.java @@ -1,7 +1,11 @@ package org.tests.json; import io.ebean.BaseTestCase; +import io.ebean.BeanState; import io.ebean.DB; +import io.ebean.ValuePair; +import io.ebean.event.BeanPersistAdapter; +import io.ebean.event.BeanPersistRequest; import io.ebeantest.LoggedSql; import org.junit.Test; import org.tests.model.json.EBasicJsonJackson3; @@ -11,11 +15,33 @@ import org.tests.model.json.PlainBeanDirtyAware; import java.util.Arrays; import java.util.List; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; public class TestDbJson_Jackson3 extends BaseTestCase { + public static class EBasicJsonListPersistController extends BeanPersistAdapter { + + private static Map updatedValues; + + @Override + public boolean isRegisterFor(Class cls) { + return EBasicJsonList.class.isAssignableFrom(cls); + } + + @Override + public boolean preInsert(BeanPersistRequest request) { + updatedValues = request.getUpdatedValues(); + return true; + } + + @Override + public boolean preUpdate(BeanPersistRequest request) { + updatedValues = request.getUpdatedValues(); + return true; + } + } @Test public void updateIncludesJsonColumn_when_explicit_isMarkedDirty() { @@ -24,6 +50,7 @@ public class TestDbJson_Jackson3 extends BaseTestCase { EBasicJsonJackson3 bean = new EBasicJsonJackson3(); bean.setName("b1"); bean.setPlainValue(contentBean); + bean.setPlainValue2(contentBean); bean.save(); @@ -32,20 +59,15 @@ public class TestDbJson_Jackson3 extends BaseTestCase { LoggedSql.start(); found.save(); - - List sql = LoggedSql.collect(); - assertThat(sql).hasSize(1); - assertThat(sql.get(0)).contains("update ebasic_json_jackson3 set name=?, version=? where id=? and version=?"); + expectedSql(0, "update ebasic_json_jackson3 set name=?, version=? where id=? and version=?"); found.setName("b1-mod2"); found.getPlainValue().setName("b"); - found.getPlainValue().setMarkedDirty(true); + // found.getPlainValue().setMarkedDirty(true); // Irrelevant for SOURCE or HASH based mutation detection found.save(); - - sql = LoggedSql.stop(); - assertThat(sql).hasSize(1); - assertThat(sql.get(0)).contains("update ebasic_json_jackson3 set name=?, plain_value=?, version=? where id=? and version=?"); + expectedSql(0, "update ebasic_json_jackson3 set name=?, plain_value=?, version=? where id=? and version=?"); + LoggedSql.stop(); final EBasicJsonJackson3 found2 = DB.find(EBasicJsonJackson3.class, bean.getId()); @@ -69,11 +91,138 @@ public class TestDbJson_Jackson3 extends BaseTestCase { found.setName("p1-mod"); found.setBeanList(null); + BeanState state = DB.getBeanState(found); + assertThat(state.getChangedProps()).containsExactlyInAnyOrder("name", "beanList"); + + ValuePair pair = state.getDirtyValues().get("name"); + assertThat(pair.getNewValue()).isEqualTo("p1-mod"); + assertThat(pair.getOldValue()).isEqualTo("p1"); + + pair = state.getDirtyValues().get("beanList"); + assertThat(pair.getNewValue()).isEqualTo(null); + assertThat((List)pair.getOldValue()).hasSize(1) + .extracting(PlainBean::getName).containsExactly("a"); + + LoggedSql.start(); DB.save(found); - final List sql = LoggedSql.stop(); - assertThat(sql).hasSize(1); - assertThat(sql.get(0)).contains("update ebasic_json_list set name=?, bean_list=?, plain_bean=?, version=? where id=?"); + // plain_bean=?, no longer included with MD5 dirty detection + expectedSql(0, "update ebasic_json_list set name=?, bean_list=?, version=? where id=?"); + + assertThat(EBasicJsonListPersistController.updatedValues.entrySet()) + .extracting(Map.Entry::toString) + .containsExactlyInAnyOrder("beanList=null,[name:a]","name=p1-mod,p1","version=2,1"); + + assertThat(DB.getBeanState(found).isDirty()).isFalse(); + + found.getPlainBean().setName("b"); + assertThat(DB.getBeanState(found).isDirty()).isTrue(); + + state = DB.getBeanState(found); + assertThat(state.getChangedProps()).containsExactlyInAnyOrder("plainBean"); + pair = state.getDirtyValues().get("plainBean"); + assertThat(pair.getNewValue()).hasToString("name:b"); + assertThat(pair.getOldValue()).hasToString("name:a"); + + + LoggedSql.start(); + DB.save(found); + + // plain_bean=?, no longer included with MD5 dirty detection + expectedSql(0, "update ebasic_json_list set plain_bean=?, version=? where id=?"); + + assertThat(EBasicJsonListPersistController.updatedValues.entrySet()) + .extracting(Map.Entry::toString) + .containsExactlyInAnyOrder("plainBean=name:b,name:a", "version=3,2"); + + LoggedSql.stop(); + } + + @Test + public void updateIncludesJsonColumn_when_list_loadedAndNotDirtyAware() { + + PlainBean contentBean = new PlainBean("a", 42); + EBasicJsonList bean = new EBasicJsonList(); + bean.setName("p1"); + bean.setPlainBean(contentBean); + bean.setBeanList(Arrays.asList(contentBean)); + + DB.save(bean); + final EBasicJsonList found = DB.find(EBasicJsonList.class, bean.getId()); + found.getBeanList().get(0).setName("p1-mod"); + + BeanState state = DB.getBeanState(found); + assertThat(state.getChangedProps()).containsExactlyInAnyOrder("beanList"); + } + + @Test + public void update_with_differentDbJsonSettings() { + PlainBeanDirtyAware contentBean1 = new PlainBeanDirtyAware("x", 42); + PlainBeanDirtyAware contentBean2 = new PlainBeanDirtyAware("y", 43); + PlainBeanDirtyAware contentBean3 = new PlainBeanDirtyAware("z", 44); + + EBasicJsonJackson3 bean = new EBasicJsonJackson3(); + bean.setName("b1"); + bean.setPlainValue(contentBean1); + bean.setPlainValue2(contentBean2); + bean.setPlainValue3(contentBean3); + + BeanState state = DB.getBeanState(bean); + // a new bean is not considered as dirty (thus have no changed props) + assertThat(state.isDirty()).isFalse(); + assertThat(state.isNewOrDirty()).isTrue(); + assertThat(state.getChangedProps()).isEmpty(); + + bean.save(); + + bean = DB.find(EBasicJsonJackson3.class, bean.getId()); + state = DB.getBeanState(bean); + // a fresh loaded bean is also not considered as dirty + assertThat(state.isDirty()).isFalse(); + assertThat(state.isNewOrDirty()).isFalse(); + assertThat(state.getChangedProps()).isEmpty(); + + bean.getPlainValue().setName("a"); // has SOURCE + + assertThat(state.isDirty()).isTrue(); + assertThat(state.getChangedProps()).containsExactly("plainValue"); + + bean.getPlainValue2().setName("b"); + assertThat(state.getChangedProps()).containsExactlyInAnyOrder("plainValue", "plainValue2"); + + bean.getPlainValue3().setName("c"); // has mutationDetection = NONE + + Map dirtyValues = state.getDirtyValues(); + assertThat(dirtyValues).hasSize(2).containsKeys("plainValue", "plainValue2"); + + assertThat(dirtyValues.get("plainValue")).hasToString("name:a,name:x"); // SOURCE -> origValue present + assertThat(dirtyValues.get("plainValue2")).hasToString("name:b,null"); // without SOURCE no origValue present + + LoggedSql.start(); + bean.save(); + expectedSql(0, "update ebasic_json_jackson3 set plain_value=?, plain_value2=?, version=? where id=?"); + + bean = DB.find(EBasicJsonJackson3.class, bean.getId()); + LoggedSql.collect(); // ignore the select + assertThat(bean.getPlainValue().getName()).isEqualTo("a"); + assertThat(bean.getPlainValue2().getName()).isEqualTo("b"); + assertThat(bean.getPlainValue3().getName()).isEqualTo("z"); // value is not updated + + bean.getPlainValue3().setName("c"); + bean.getPlainValue3().setMarkedDirty(true); // This is ignored because it is MutationDetection.NONE + bean.save(); + // no update as plainValue3 has MutationDetection.NONE (ModifyAwareType = NONE isn't an expected combination to me) + assertThat(LoggedSql.collect()).isEmpty(); + + bean.getPlainValue2().setName("b2"); // effectively HASH mode mutation detection + bean.save(); + expectedSql(0, "update ebasic_json_jackson3 set plain_value2=?, version=? where id=? and version=?"); + + LoggedSql.stop(); + } + + private void expectedSql(int i, String s) { + assertThat(LoggedSql.collect().get(i)).contains(s); } } diff --git a/ebean-core/src/test/java/org/tests/json/TestDbJson_List.java b/ebean-core/src/test/java/org/tests/json/TestDbJson_List.java index 28fd78221..f43d6f23c 100644 --- a/ebean-core/src/test/java/org/tests/json/TestDbJson_List.java +++ b/ebean-core/src/test/java/org/tests/json/TestDbJson_List.java @@ -113,7 +113,8 @@ public class TestDbJson_List extends BaseTestCase { List sql = LoggedSqlCollector.stop(); // we don't update the phone numbers (as they are not dirty) - assertSql(sql.get(0)).contains("update ebasic_json_list set name=?, plain_bean=?, version=? where"); + // plain_bean=?, no longer included with MD5 dirty detection + assertSql(sql.get(0)).contains("update ebasic_json_list set name=?, version=? where"); } public void update_when_dirty() { @@ -126,7 +127,8 @@ public class TestDbJson_List extends BaseTestCase { List sql = LoggedSqlCollector.stop(); // we don't update the phone numbers (as they are not dirty) - assertSql(sql.get(0)).contains("update ebasic_json_list set plain_bean=?, tags=?, version=? where id=? and version=?"); + // plain_bean=? not included using MD5 dirty detection + assertSql(sql.get(0)).contains("update ebasic_json_list set tags=?, version=? where id=? and version=?"); } public void update_when_dirty_flags() { @@ -139,7 +141,8 @@ public class TestDbJson_List extends BaseTestCase { List sql = LoggedSqlCollector.stop(); // we don't update the phone numbers (as they are not dirty) - assertSql(sql.get(0)).contains("update ebasic_json_list set plain_bean=?, flags=?, version=? where id=? and version=?;"); + // plain_bean=? not included with MD5 dirty detection + assertSql(sql.get(0)).contains("update ebasic_json_list set flags=?, version=? where id=? and version=?;"); } public void update_when_dirty_SetListMap() { @@ -154,7 +157,8 @@ public class TestDbJson_List extends BaseTestCase { List sql = LoggedSqlCollector.stop(); // we don't update the phone numbers (as they are not dirty) - assertSql(sql.get(0)).contains("update ebasic_json_list set beans=?, bean_list=?, bean_map=?, plain_bean=?, version=? where id=? and version=?"); + // plain_bean=? not included with MD5 dirty detection + assertSql(sql.get(0)).contains("update ebasic_json_list set beans=?, bean_list=?, bean_map=?, version=? where id=? and version=?"); } @Test diff --git a/ebean-core/src/test/java/org/tests/model/json/EBasicJsonJackson3.java b/ebean-core/src/test/java/org/tests/model/json/EBasicJsonJackson3.java index 3d942d58b..cee12d9e7 100644 --- a/ebean-core/src/test/java/org/tests/model/json/EBasicJsonJackson3.java +++ b/ebean-core/src/test/java/org/tests/model/json/EBasicJsonJackson3.java @@ -2,11 +2,15 @@ package org.tests.model.json; import io.ebean.Model; import io.ebean.annotation.DbJson; +import io.ebean.annotation.MutationDetection; import javax.persistence.Entity; import javax.persistence.Id; import javax.persistence.Version; +import static io.ebean.annotation.MutationDetection.NONE; +import static io.ebean.annotation.MutationDetection.SOURCE; + @Entity public class EBasicJsonJackson3 extends Model { @@ -15,9 +19,15 @@ public class EBasicJsonJackson3 extends Model { String name; - @DbJson(length = 500) + @DbJson(length = 500, mutationDetection = SOURCE) PlainBeanDirtyAware plainValue; + @DbJson(length = 500) + PlainBeanDirtyAware plainValue2; + + @DbJson(length = 500, mutationDetection = NONE) + PlainBeanDirtyAware plainValue3; + @Version long version; @@ -45,6 +55,22 @@ public class EBasicJsonJackson3 extends Model { this.plainValue = plainValue; } + public PlainBeanDirtyAware getPlainValue2() { + return plainValue2; + } + + public void setPlainValue2(PlainBeanDirtyAware plainValue2) { + this.plainValue2 = plainValue2; + } + + public PlainBeanDirtyAware getPlainValue3() { + return plainValue3; + } + + public void setPlainValue3(PlainBeanDirtyAware plainValue3) { + this.plainValue3 = plainValue3; + } + public long getVersion() { return version; } diff --git a/ebean-core/src/test/java/org/tests/model/json/EBasicJsonList.java b/ebean-core/src/test/java/org/tests/model/json/EBasicJsonList.java index 22b32e3e1..f76d282f7 100644 --- a/ebean-core/src/test/java/org/tests/model/json/EBasicJsonList.java +++ b/ebean-core/src/test/java/org/tests/model/json/EBasicJsonList.java @@ -7,12 +7,10 @@ import io.ebean.annotation.DbJsonType; import javax.persistence.Entity; import javax.persistence.Id; import javax.persistence.Version; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; +import java.util.*; + +import static io.ebean.annotation.MutationDetection.HASH; +import static io.ebean.annotation.MutationDetection.SOURCE; @Entity public class EBasicJsonList { @@ -25,13 +23,13 @@ public class EBasicJsonList { @DbJson(length = 700, name = "beans") Set beanSet; - @DbJsonB + @DbJsonB(mutationDetection = HASH) List beanList; @DbJson(length = 700) Map beanMap = new LinkedHashMap<>(); - @DbJson(length = 500) + @DbJson(length = 500, mutationDetection = SOURCE) // such that we can rebuild old values PlainBean plainBean; @DbJson(length = 50) diff --git a/ebean-core/src/test/java/org/tests/model/json/EBasicPlain.java b/ebean-core/src/test/java/org/tests/model/json/EBasicPlain.java new file mode 100644 index 000000000..051b4a344 --- /dev/null +++ b/ebean-core/src/test/java/org/tests/model/json/EBasicPlain.java @@ -0,0 +1,67 @@ +package org.tests.model.json; + +import io.ebean.annotation.DbJson; + +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Version; + +import static io.ebean.annotation.MutationDetection.NONE; + +@Entity +public class EBasicPlain { + + @Id + long id; + + String attr; + + @DbJson(length = 500) + PlainBean plainBean; + + @DbJson(length = 500, mutationDetection = NONE) // only update when property set + PlainBean plainBean2; + + @Version + long version; + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getAttr() { + return attr; + } + + public void setAttr(String attr) { + this.attr = attr; + } + + public PlainBean getPlainBean() { + return plainBean; + } + + public void setPlainBean(PlainBean plainBean) { + this.plainBean = plainBean; + } + + public PlainBean getPlainBean2() { + return plainBean2; + } + + public void setPlainBean2(PlainBean plainBean2) { + this.plainBean2 = plainBean2; + } + + public long getVersion() { + return version; + } + + public void setVersion(long version) { + this.version = version; + } +} diff --git a/ebean-core/src/test/java/org/tests/model/json/TestJacksonPlainBean.java b/ebean-core/src/test/java/org/tests/model/json/TestJacksonPlainBean.java new file mode 100644 index 000000000..b2237731d --- /dev/null +++ b/ebean-core/src/test/java/org/tests/model/json/TestJacksonPlainBean.java @@ -0,0 +1,95 @@ +package org.tests.model.json; + +import io.ebean.DB; +import io.ebeantest.LoggedSql; +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class TestJacksonPlainBean { + + @Test + public void insertNullStayNull() { + + // insert with jackson beans as null + EBasicPlain bean = new EBasicPlain(); + bean.setAttr("n0"); + DB.save(bean); + + LoggedSql.start(); + bean.setAttr("n1"); + DB.save(bean); + expectedSql(0, "update ebasic_plain set attr=?, version=? where id=? and version=?"); + + bean.setPlainBean(new PlainBean("x", 1)); + DB.save(bean); + expectedSql(0, "update ebasic_plain set plain_bean=?, version=? where id=? and version=?"); + + final EBasicPlain found = DB.find(EBasicPlain.class, bean.getId()); + found.setAttr("n2"); + DB.save(found); + expectedSql(1, "update ebasic_plain set attr=?, version=? where id=? and version=?"); + + LoggedSql.stop(); + } + + @Test + public void insertUpdate() { + + DB.getDefault(); + LoggedSql.start(); + + PlainBean content = new PlainBean("foo", 42); + EBasicPlain bean = new EBasicPlain(); + bean.setAttr("attr0"); + bean.setPlainBean(content); + bean.setPlainBean2(new PlainBean("bar", 27)); + + DB.save(bean); + expectedSql(0, "insert into ebasic_plain (attr, plain_bean, plain_bean2, version) values (?,?,?,?)"); + + // inserted plainBean has not been mutated + bean.setAttr("attr1"); + DB.save(bean); + expectedSql(0, "update ebasic_plain set attr=?, version=? where id=? and version=?"); + + // inserted plainBean has now been mutated + content.setName("notFoo"); + bean.setAttr("attr2"); + DB.save(bean); + expectedSql(0, "update ebasic_plain set attr=?, plain_bean=?, version=? where id=? and version=?"); + + + final EBasicPlain found = DB.find(EBasicPlain.class, bean.getId()); + + // update mutating PlainBean only + final PlainBean plainBean = found.getPlainBean(); + plainBean.setName("mod1"); + DB.save(found); + expectedSql(1, "update ebasic_plain set plain_bean=?, version=? where id=? and version=?"); + + // dirtyDetection = false, so not included in update + found.getPlainBean2().setName("Modification Ignored"); + // dirtyDetection = true, mutation detected + plainBean.setName("mod2"); + DB.save(found); + expectedSql(0, "update ebasic_plain set plain_bean=?, version=? where id=? and version=?"); + + // update bean, not mutating PlainBean + found.setAttr("attr3"); + DB.save(found); + expectedSql(0, "update ebasic_plain set attr=?, version=? where id=? and version=?"); + + // dirtyDetection = false, set a new plainBean2 instance, included in update + found.setPlainBean2(new PlainBean("bar", 27)); + DB.save(found); + expectedSql(0, "update ebasic_plain set plain_bean2=?, version=? where id=? and version=?"); + + LoggedSql.stop(); + } + + private void expectedSql(int i, String s) { + assertThat(LoggedSql.collect().get(i)).contains(s); + } + +}