diff --git a/ebean-api/src/main/java/io/ebean/bean/BeanCollection.java b/ebean-api/src/main/java/io/ebean/bean/BeanCollection.java index 6271d4051..96a37bb4a 100644 --- a/ebean-api/src/main/java/io/ebean/bean/BeanCollection.java +++ b/ebean-api/src/main/java/io/ebean/bean/BeanCollection.java @@ -18,7 +18,7 @@ import java.util.Set; * java.util.Collection. The reason being that java.util.Map is not a * Collection. I realise this makes this name confusing so I apologise for that. */ -public interface BeanCollection extends Serializable { +public interface BeanCollection extends Serializable, ToStringAware { enum ModifyListenMode { /** diff --git a/ebean-api/src/main/java/io/ebean/bean/EntityBean.java b/ebean-api/src/main/java/io/ebean/bean/EntityBean.java index ee54991ad..e12621829 100644 --- a/ebean-api/src/main/java/io/ebean/bean/EntityBean.java +++ b/ebean-api/src/main/java/io/ebean/bean/EntityBean.java @@ -11,7 +11,7 @@ import java.io.Serializable; * general application consumption. *

*/ -public interface EntityBean extends Serializable { +public interface EntityBean extends Serializable, ToStringAware { /** * Return all the property names in defined order. @@ -109,4 +109,8 @@ public interface EntityBean extends Serializable { throw new NotEnhancedException(); } + @Override + default void toString(ToStringBuilder builder) { + throw new NotEnhancedException(); + } } diff --git a/ebean-api/src/main/java/io/ebean/bean/ToStringAware.java b/ebean-api/src/main/java/io/ebean/bean/ToStringAware.java new file mode 100644 index 000000000..bb86d9629 --- /dev/null +++ b/ebean-api/src/main/java/io/ebean/bean/ToStringAware.java @@ -0,0 +1,12 @@ +package io.ebean.bean; + +/** + * A type that can participate in building toString content with ToStringBuilder. + */ +public interface ToStringAware { + + /** + * Append to the ToStringBuilder. + */ + void toString(ToStringBuilder builder); +} diff --git a/ebean-api/src/main/java/io/ebean/bean/ToStringBuilder.java b/ebean-api/src/main/java/io/ebean/bean/ToStringBuilder.java new file mode 100644 index 000000000..30894b9f6 --- /dev/null +++ b/ebean-api/src/main/java/io/ebean/bean/ToStringBuilder.java @@ -0,0 +1,153 @@ +package io.ebean.bean; + +import java.util.Collection; +import java.util.IdentityHashMap; + +/** + * Helps build toString content taking into account recursion. + *

+ * That is, it detects and handles the case where there are relationships that recurse + * and would otherwise become an infinite loop (e.g. bidirectional parent child). + */ +public final class ToStringBuilder { + + /** + * The max number of objects that we allow before stopping content being appended. + */ + private static final int MAX = 100; + + /** + * Max length of content in string form added for any given value. + */ + private static final int TRIM_LENGTH = 500; + + /** + * The max total content after which we stop content being appended. + */ + private static final int MAX_TOTAL_CONTENT = 2000; + + private final IdentityHashMap id = new IdentityHashMap<>(); + private final StringBuilder sb = new StringBuilder(50); + private boolean first = true; + private int counter; + + @Override + public String toString() { + return sb.toString(); + } + + /** + * Set of an object being added. + */ + public void start(Object bean) { + if (counter == 0) { + id.putIfAbsent(bean, 0); + } + if (counter <= MAX) { + sb.append(bean.getClass().getSimpleName()).append("@").append(counter).append("("); + } + } + + /** + * Add a property as name value pair. + */ + public void add(String name, Object value) { + if (value != null && counter <= MAX) { + key(name); + value(value); + } + } + + /** + * Add raw content. + */ + public void addRaw(String content) { + sb.append(content); + } + + /** + * End of an object. + */ + public void end() { + if (counter <= MAX) { + sb.append(")"); + } + } + + private void key(String name) { + if (counter > MAX) { + return; + } + if (first) { + first = false; + } else { + sb.append(", "); + } + sb.append(name).append(":"); + } + + private void value(Object value) { + if (counter > MAX) { + return; + } + if (value instanceof ToStringAware) { + if (push(value)) { + ((ToStringAware) value).toString(this); + } + } else if (value instanceof Collection) { + addCollection((Collection) value); + } else { + String content = String.valueOf(value); + if (content.length() > TRIM_LENGTH) { + content = content.substring(0, TRIM_LENGTH) + " "; + } + sb.append(content); + if (sb.length() >= MAX_TOTAL_CONTENT) { + sb.append(" ..."); + counter += MAX; + } + } + } + + /** + * Add a collection of values. + */ + public void addCollection(Collection c) { + if (c == null || c.isEmpty()) { + sb.append("[]"); + return; + } + int collectionPos = 0; + sb.append("["); + for (Object o : c) { + if (collectionPos++ > 0) { + sb.append(", "); + } + value(o); + if (counter > MAX) { + return; + } + } + sb.append("]"); + } + + private boolean push(Object bean) { + if (counter > MAX) { + return false; + } + if (counter == MAX) { + sb.append(" ..."); + counter++; + return false; + } + Integer idx = id.putIfAbsent(bean, counter++); + if (idx != null) { + --counter; + sb.append(bean.getClass().getSimpleName()).append("@").append(idx); + return false; + } + first = true; + return true; + } + +} diff --git a/ebean-api/src/main/java/io/ebean/common/BeanList.java b/ebean-api/src/main/java/io/ebean/common/BeanList.java index ba0adca99..0305b4a5d 100644 --- a/ebean-api/src/main/java/io/ebean/common/BeanList.java +++ b/ebean-api/src/main/java/io/ebean/common/BeanList.java @@ -1,9 +1,6 @@ package io.ebean.common; -import io.ebean.bean.BeanCollection; -import io.ebean.bean.BeanCollectionAdd; -import io.ebean.bean.BeanCollectionLoader; -import io.ebean.bean.EntityBean; +import io.ebean.bean.*; import java.io.Serializable; import java.util.ArrayList; @@ -47,6 +44,11 @@ public final class BeanList extends AbstractBeanCollection implements List super(loader, ownerBean, propertyName); } + @Override + public void toString(ToStringBuilder builder) { + builder.addCollection(list); + } + @Override public void reset(EntityBean ownerBean, String propertyName) { this.ownerBean = ownerBean; diff --git a/ebean-api/src/main/java/io/ebean/common/BeanMap.java b/ebean-api/src/main/java/io/ebean/common/BeanMap.java index 643febc14..25f51b7aa 100644 --- a/ebean-api/src/main/java/io/ebean/common/BeanMap.java +++ b/ebean-api/src/main/java/io/ebean/common/BeanMap.java @@ -3,6 +3,7 @@ package io.ebean.common; import io.ebean.bean.BeanCollection; import io.ebean.bean.BeanCollectionLoader; import io.ebean.bean.EntityBean; +import io.ebean.bean.ToStringBuilder; import java.util.Collection; import java.util.Collections; @@ -40,6 +41,19 @@ public final class BeanMap extends AbstractBeanCollection implements Ma super(ebeanServer, ownerBean, propertyName); } + @Override + public void toString(ToStringBuilder builder) { + if (map == null || map.isEmpty()) { + builder.addRaw("{}"); + } else { + builder.addRaw("{"); + for (Entry entry : map.entrySet()) { + builder.add(String.valueOf(entry.getKey()), entry.getValue()); + } + builder.addRaw("}"); + } + } + @Override public void reset(EntityBean ownerBean, String propertyName) { this.ownerBean = ownerBean; diff --git a/ebean-api/src/main/java/io/ebean/common/BeanSet.java b/ebean-api/src/main/java/io/ebean/common/BeanSet.java index 0c6f0c508..e82e82bbb 100644 --- a/ebean-api/src/main/java/io/ebean/common/BeanSet.java +++ b/ebean-api/src/main/java/io/ebean/common/BeanSet.java @@ -1,9 +1,6 @@ package io.ebean.common; -import io.ebean.bean.BeanCollection; -import io.ebean.bean.BeanCollectionAdd; -import io.ebean.bean.BeanCollectionLoader; -import io.ebean.bean.EntityBean; +import io.ebean.bean.*; import java.io.Serializable; import java.util.Collection; @@ -41,6 +38,11 @@ public final class BeanSet extends AbstractBeanCollection implements Set(null))).isEqualTo("[]"); + assertThat(toStringFor(new BeanList(Collections.emptyList()))).isEqualTo("[]"); + } + + @Test + void beanSet_null_empty() { + assertThat(toStringFor(new BeanSet(null))).isEqualTo("[]"); + assertThat(toStringFor(new BeanSet(Collections.emptySet()))).isEqualTo("[]"); + } + + @Test + void beanMap_null_empty() { + assertThat(toStringFor(new BeanMap(null))).isEqualTo("{}"); + assertThat(toStringFor(new BeanMap(Collections.emptyMap()))).isEqualTo("{}"); + } + + @Test + void beanList_some() { + BeanList list = new BeanList<>(List.of(new Recurse(1, "a"), new Recurse(2, "b"))); + assertThat(toStringFor(list)).isEqualTo("[Recurse@1(id:1, nm:a), Recurse@2(id:2, nm:b)]"); + } + + @Test + void beanSet_some() { + BeanSet list = new BeanSet<>(new LinkedHashSet<>(List.of(new Recurse(1, "a"), new Recurse(2, "b")))); + assertThat(toStringFor(list)).isEqualTo("[Recurse@1(id:1, nm:a), Recurse@2(id:2, nm:b)]"); + } + + @Test + void beanMap_some() { + Map under = new LinkedHashMap<>(); + under.put("a", new Recurse(1, "a")); + under.put("b", new Recurse(2, "b")); + BeanMap list = new BeanMap<>(under); + assertThat(toStringFor(list)).isEqualTo("{a:Recurse@1(id:1, nm:a), b:Recurse@2(id:2, nm:b)}"); + } + + private String toStringFor(ToStringAware aware) { + ToStringBuilder builder = new ToStringBuilder(); + aware.toString(builder); + return builder.toString(); + } + + static class Recurse implements ToStringAware { + + final int id; + final String nm; + Recurse other; + + Recurse(int id, String nm) { + this.id = id; + this.nm = nm; + } + + public String toString() { + ToStringBuilder builder = new ToStringBuilder(); + toString(builder); + return builder.toString(); + } + + @Override + public void toString(ToStringBuilder builder) { + builder.start(this); + builder.add("id", id); + builder.add("nm", nm); + builder.add("other", other); + builder.end(); + } + } +} diff --git a/ebean-core/src/main/resources/META-INF/ebean-version.mf b/ebean-core/src/main/resources/META-INF/ebean-version.mf index 6bcacf1d6..2eb6e1e8e 100644 --- a/ebean-core/src/main/resources/META-INF/ebean-version.mf +++ b/ebean-core/src/main/resources/META-INF/ebean-version.mf @@ -1 +1 @@ -ebean-version: 129 +ebean-version: 133