Repository: ebean-orm/ebean-agent
Pull request #220: Support initialisation of ToMany collections using List.of() etc
Author: login: rbygrave
Created: 2025-03-03T23:27:51Z | Merged: 2025-03-08T20:03:33Z

- Add Support for List.of() & Collections.emptyList()
- Add Support for Set.of() & Collections.emptySet()
- Add Support for Map.of() & Collections.emptyMap()

For example:
```java
@OneToMany(cascade = CascadeType.PERSIST)
List<Contact> contacts = List.of();
```


Diff:
diff --git a/ebean-agent/pom.xml b/ebean-agent/pom.xml
index f3bd6944..23c3f01b 100644
--- a/ebean-agent/pom.xml
+++ b/ebean-agent/pom.xml
@@ -59,7 +59,7 @@
     <dependency>
       <groupId>io.avaje</groupId>
       <artifactId>junit</artifactId>
-      <version>1.1</version>
+      <version>1.5</version>
       <scope>test</scope>
     </dependency>
 
@@ -92,23 +92,9 @@
     </dependency>
 
     <dependency>
-      <groupId>org.avaje.composite</groupId>
-      <artifactId>logback</artifactId>
-      <version>1.1</version>
-      <scope>test</scope>
-    </dependency>
-
-    <dependency>
-      <groupId>org.mockito</groupId>
-      <artifactId>mockito-core</artifactId>
-      <version>3.11.2</version>
-      <scope>test</scope>
-    </dependency>
-
-    <dependency>
-      <groupId>org.avaje.composite</groupId>
-      <artifactId>composite-testing</artifactId>
-      <version>3.1</version>
+      <groupId>ch.qos.logback</groupId>
+      <artifactId>logback-classic</artifactId>
+      <version>1.5.17</version>
       <scope>test</scope>
     </dependency>
 
diff --git a/ebean-agent/src/main/java/io/ebean/enhance/EnhancementException.java b/ebean-agent/src/main/java/io/ebean/enhance/EnhancementException.java
new file mode 100644
index 00000000..dd72e2e2
--- /dev/null
+++ b/ebean-agent/src/main/java/io/ebean/enhance/EnhancementException.java
@@ -0,0 +1,12 @@
+package io.ebean.enhance;
+
+/**
+ * Some unexpected bytecode that can't be supported.
+ * <p>
+ * For example, some unsupported OneToMany collection initialisation.
+ */
+public class EnhancementException extends RuntimeException {
+  public EnhancementException(String message) {
+    super(message);
+  }
+}
diff --git a/ebean-agent/src/main/java/io/ebean/enhance/Transformer.java b/ebean-agent/src/main/java/io/ebean/enhance/Transformer.java
index 7b3b605a..afd2a1c4 100644
--- a/ebean-agent/src/main/java/io/ebean/enhance/Transformer.java
+++ b/ebean-agent/src/main/java/io/ebean/enhance/Transformer.java
@@ -191,6 +191,9 @@ public byte[] transform(ClassLoader loader, String className, Class<?> classBein
       // the class is an interface
       log(8, className, "No Enhancement required " + e.getMessage());
       return null;
+    } catch (EnhancementException e) {
+      enhanceContext.log(className, "Transform error " + e.getMessage());
+      throw e;
     } catch (IllegalArgumentException | IllegalStateException e) {
       log(2, className, "No enhancement on class due to " + e);
       return null;
diff --git a/ebean-agent/src/main/java/io/ebean/enhance/ant/OfflineFileTransform.java b/ebean-agent/src/main/java/io/ebean/enhance/ant/OfflineFileTransform.java
index b7cd1fce..ab17a0d6 100644
--- a/ebean-agent/src/main/java/io/ebean/enhance/ant/OfflineFileTransform.java
+++ b/ebean-agent/src/main/java/io/ebean/enhance/ant/OfflineFileTransform.java
@@ -1,5 +1,6 @@
 package io.ebean.enhance.ant;
 
+import io.ebean.enhance.EnhancementException;
 import io.ebean.enhance.Transformer;
 import io.ebean.enhance.common.InputStreamTransform;
 
@@ -60,7 +61,6 @@ private String trimSlash(String dir) {
    * Process the packageNames as comma delimited string.
    */
   public void process(String packageNames) {
-
     if (packageNames == null) {
       // just process all directories
       processPackage("");
@@ -69,7 +69,6 @@ public void process(String packageNames) {
 
     Set<String> pkgNames = new LinkedHashSet<>();
     Collections.addAll(pkgNames, packageNames.split(","));
-
     process(pkgNames);
   }
 
@@ -81,7 +80,6 @@ public void process(String packageNames) {
    * </p>
    */
   public void process(Set<String> packageNames) {
-
     if (packageNames == null || packageNames.isEmpty()) {
       // just process all directories
       inputStreamTransform.log(2, "processing all directories (as no explicit packages)");
@@ -90,9 +88,7 @@ public void process(Set<String> packageNames) {
     }
 
     for (String pkgName : packageNames) {
-
       String pkg = pkgName.trim().replace('.', '/');
-
       if (pkg.endsWith("**")) {
         pkg = pkg.substring(0, pkg.length() - 2);
       } else if (pkg.endsWith("*")) {
@@ -100,7 +96,6 @@ public void process(Set<String> packageNames) {
       }
 
       pkg = trimSlash(pkg);
-
       processPackage(pkg);
     }
   }
@@ -142,15 +137,20 @@ private void processPackage(String dir) {
   }
 
   private void transformFile(File file) throws IOException, IllegalClassFormatException {
-
     String className = getClassName(file);
-
-    byte[] result = inputStreamTransform.transform(className, file);
-
-    if (result != null) {
-      InputStreamTransform.writeBytes(result, file);
-      if (listener != null && logLevel > 0) {
-        listener.logEvent("Enhanced " + file);
+    try {
+      byte[] result = inputStreamTransform.transform(className, file);
+      if (result != null) {
+        InputStreamTransform.writeBytes(result, file);
+        if (listener != null && logLevel > 0) {
+          listener.logEvent("Enhanced " + file);
+        }
+      }
+    } catch (EnhancementException e) {
+      if (listener != null) {
+        listener.logError("Error enhancing class " + className + " " + e.getMessage());
+      } else {
+        throw e;
       }
     }
   }
diff --git a/ebean-agent/src/main/java/io/ebean/enhance/common/AgentManifest.java b/ebean-agent/src/main/java/io/ebean/enhance/common/AgentManifest.java
index adb22ecc..d7462e79 100644
--- a/ebean-agent/src/main/java/io/ebean/enhance/common/AgentManifest.java
+++ b/ebean-agent/src/main/java/io/ebean/enhance/common/AgentManifest.java
@@ -28,6 +28,7 @@ public final class AgentManifest {
   private boolean transientInternalFields;
   private boolean transientInit;
   private boolean transientInitThrowError;
+  private boolean unsupportedInitThrowError = true;
   private boolean checkNullManyFields = true;
   private boolean enableProfileLocation = true;
   private boolean enableEntityFieldAccess;
@@ -151,6 +152,10 @@ public boolean isTransientInitThrowError() {
     return transientInitThrowError;
   }
 
+  public boolean isUnsupportedInitThrowError() {
+    return unsupportedInitThrowError;
+  }
+
   /**
    * Return true if we should use transient internal fields.
    */
@@ -315,6 +320,7 @@ private void readOptions(Attributes attributes) {
     transientInternalFields = bool("transient-internal-fields", transientInternalFields, attributes);
     checkNullManyFields = bool("check-null-many-fields", checkNullManyFields, attributes);
     allowNullableDbArray = bool("allow-nullable-dbarray", allowNullableDbArray, attributes);
+    unsupportedInitThrowError = bool("unsupported-init-error", unsupportedInitThrowError, attributes);
   }
 
   private boolean bool(String key, boolean defaultValue, Attributes attributes) {
diff --git a/ebean-agent/src/main/java/io/ebean/enhance/common/ClassMeta.java b/ebean-agent/src/main/java/io/ebean/enhance/common/ClassMeta.java
index 85902b71..3613705a 100644
--- a/ebean-agent/src/main/java/io/ebean/enhance/common/ClassMeta.java
+++ b/ebean-agent/src/main/java/io/ebean/enhance/common/ClassMeta.java
@@ -50,6 +50,7 @@ public class ClassMeta {
   /**
    * If enhancement is adding a default constructor - only default constructors are supported initialising transient fields.
    */
+  private final Set<String> unsupportedInitMany = new LinkedHashSet<>();
   private final Set<String> unsupportedTransientInitialisation = new LinkedHashSet<>();
   private final Map<String, CapturedInitCode> transientInitCode = new LinkedHashMap<>();
   private final LinkedHashMap<String, FieldMeta> fields = new LinkedHashMap<>();
@@ -447,6 +448,20 @@ public Collection<CapturedInitCode> transientInit() {
     return transientInitCode.values();
   }
 
+  public void addUnsupportedInitMany(String name) {
+    unsupportedInitMany.add(name);
+  }
+
+  public boolean hasUnsupportedInitMany() {
+    return !unsupportedInitMany.isEmpty();
+  }
+
+  public String initFieldErrorMessage() {
+    return "ERROR: Unsupported initialisation of @OneToMany or @ManyToMany on: "
+      + className + " fields: " + unsupportedInitMany
+      + " Refer: https://ebean.io/docs/trouble-shooting#initialisation-error";
+  }
+
   public void addUnsupportedTransientInit(String name) {
     unsupportedTransientInitialisation.add(name);
   }
diff --git a/ebean-agent/src/main/java/io/ebean/enhance/common/EnhanceContext.java b/ebean-agent/src/main/java/io/ebean/enhance/common/EnhanceContext.java
index 7c4aa028..bfc08dc8 100644
--- a/ebean-agent/src/main/java/io/ebean/enhance/common/EnhanceContext.java
+++ b/ebean-agent/src/main/java/io/ebean/enhance/common/EnhanceContext.java
@@ -344,6 +344,10 @@ public boolean isTransientInitThrowError() {
     return manifest.isTransientInitThrowError();
   }
 
+  public boolean isUnsupportedInitThrowError() {
+    return manifest.isUnsupportedInitThrowError();
+  }
+
   /**
    * Return true if internal ebean fields in entity classes should be transient.
    */
diff --git a/ebean-agent/src/main/java/io/ebean/enhance/entity/ClassAdapterEntity.java b/ebean-agent/src/main/java/io/ebean/enhance/entity/ClassAdapterEntity.java
index 558311cd..cf02dc2c 100644
--- a/ebean-agent/src/main/java/io/ebean/enhance/entity/ClassAdapterEntity.java
+++ b/ebean-agent/src/main/java/io/ebean/enhance/entity/ClassAdapterEntity.java
@@ -1,5 +1,6 @@
 package io.ebean.enhance.entity;
 
+import io.ebean.enhance.EnhancementException;
 import io.ebean.enhance.asm.AnnotationVisitor;
 import io.ebean.enhance.asm.ClassVisitor;
 import io.ebean.enhance.asm.FieldVisitor;
@@ -205,6 +206,14 @@ public void visitEnd() {
     if (!classMeta.isEntityEnhancementRequired()) {
       throw new NoEnhancementRequiredException();
     }
+    if (classMeta.hasUnsupportedInitMany()) {
+      if (classMeta.context().isUnsupportedInitThrowError()) {
+        throw new EnhancementException(classMeta.initFieldErrorMessage());
+      } else {
+        // the default constructor being added will leave some transient fields uninitialised (null, 0, false etc)
+        System.err.println(classMeta.initFieldErrorMessage());
+      }
+    }
     if (!classMeta.hasStaticInit()) {
       IndexFieldWeaver.addPropertiesInit(cv, classMeta);
     }
diff --git a/ebean-agent/src/main/java/io/ebean/enhance/entity/ConstructorDeferredCode.java b/ebean-agent/src/main/java/io/ebean/enhance/entity/ConstructorDeferredCode.java
index 96a7f704..61cf0f20 100644
--- a/ebean-agent/src/main/java/io/ebean/enhance/entity/ConstructorDeferredCode.java
+++ b/ebean-agent/src/main/java/io/ebean/enhance/entity/ConstructorDeferredCode.java
@@ -45,7 +45,8 @@ enum State {
     INVOKE_SPECIAL,
     KT_CHECKCAST,   // optional kotlin state
     KT_LABEL,        // optional kotlin state
-    KT_EMPTYLIST
+    EMPTY,
+    MAYBE_UNSUPPORTED
   }
 
   private static final ALoad ALOAD_INSTRUCTION = new ALoad();
@@ -130,13 +131,28 @@ boolean deferVisitMethodInsn(int opcode, String owner, String name, String desc,
       stateInitialiseType = owner;
       return true;
     }
-    if (opcode == INVOKESTATIC && stateAload() && kotlinEmptyList(owner, name, desc)) {
-      codes.add(new NoArgInit(opcode, owner, name, desc, itf));
-      state = State.KT_EMPTYLIST;
-      stateInitialiseType = "java/util/ArrayList";
-      return true;
+    if (opcode == INVOKESTATIC && stateAload()) {
+      if (kotlinEmptyList(owner, name, desc) || emptyList(owner, name, desc)) {
+        codes.add(new NoArgInit(opcode, owner, name, desc, itf));
+        state = State.EMPTY;
+        stateInitialiseType = "java/util/ArrayList";
+        return true;
+      }
+      if (emptySet(owner, name, desc)) {
+        codes.add(new NoArgInit(opcode, owner, name, desc, itf));
+        state = State.EMPTY;
+        stateInitialiseType = "java/util/LinkedHashSet";
+        return true;
+      }
+      if (emptyMap(owner, name, desc)) {
+        codes.add(new NoArgInit(opcode, owner, name, desc, itf));
+        state = State.EMPTY;
+        stateInitialiseType = "java/util/LinkedHashMap";
+        return true;
+      }
     }
     flush();
+    state = State.MAYBE_UNSUPPORTED;
     return false;
   }
 
@@ -144,6 +160,24 @@ private boolean isNoArgInit(String name, String desc) {
     return name.equals(INIT) && desc.equals(NOARG_VOID);
   }
 
+  private boolean emptyList(String owner, String name, String desc) {
+    return desc.equals("()Ljava/util/List;")
+      && ((owner.equals("java/util/List") && name.equals("of"))
+      || (owner.equals("java/util/Collections") && name.equals("emptyList")));
+  }
+
+  private boolean emptySet(String owner, String name, String desc) {
+    return desc.equals("()Ljava/util/Set;")
+      && ((owner.equals("java/util/Set") && name.equals("of"))
+      || (owner.equals("java/util/Collections") && name.equals("emptySet")));
+  }
+
+  private boolean emptyMap(String owner, String name, String desc) {
+    return desc.equals("()Ljava/util/Map;")
+      && ((owner.equals("java/util/Map") && name.equals("of"))
+      || (owner.equals("java/util/Collections") && name.equals("emptyMap")));
+  }
+
   private boolean kotlinEmptyList(String owner, String name, String desc) {
     return owner.equals("kotlin/collections/CollectionsKt")
       && name.equals("emptyList")
@@ -155,6 +189,12 @@ private boolean kotlinEmptyList(String owner, String name, String desc) {
    */
   boolean consumeVisitFieldInsn(int opcode, String owner, String name, String desc) {
     if (opcode == PUTFIELD) {
+      if (state == State.MAYBE_UNSUPPORTED && meta.isConsumeInitMany(name)) {
+        // a OneToMany/ManyToMany is initialised in an unsupported manor
+        meta.addUnsupportedInitMany(name);
+        flush();
+        return false;
+      }
       if (stateConsumeDeferred()) {
         if (meta.isConsumeInitMany(name) && isConsumeManyType()) {
           if (meta.isLog(3)) {
@@ -232,17 +272,17 @@ private boolean stateInvokeSpecial() {
   }
 
   private boolean stateConsumeDeferred() {
-    return state == State.INVOKE_SPECIAL || state == State.KT_CHECKCAST || state == State.KT_EMPTYLIST;
+    return state == State.INVOKE_SPECIAL || state == State.KT_CHECKCAST || state == State.EMPTY;
   }
 
   /**
    * Return true if the type being initialised is valid for auto initialisation of ToMany or DbArray.
    */
   private boolean isConsumeManyType() {
-    return ("java/util/ArrayList".equals(stateInitialiseType)
+    return "java/util/ArrayList".equals(stateInitialiseType)
       || "java/util/LinkedHashSet".equals(stateInitialiseType)
-      || "java/util/HashSet".equals(stateInitialiseType));
-      //|| "java/util/LinkedHashMap".equals(stateInitialiseType)
+      || "java/util/HashSet".equals(stateInitialiseType)
+      || "java/util/LinkedHashMap".equals(stateInitialiseType);
       //|| "java/util/HashMap".equals(stateInitialiseType));
   }
 
diff --git a/test/pom.xml b/test/pom.xml
index 6da12b36..2f6459a2 100644
--- a/test/pom.xml
+++ b/test/pom.xml
@@ -14,6 +14,7 @@
 
   <properties>
     <ebean.version>13.4.0</ebean.version>
+    <java.version>11</java.version>
   </properties>
 
   <dependencies>
diff --git a/test/src/test/java/test/enhancement/WithInitialisedCollections2Test.java b/test/src/test/java/test/enhancement/WithInitialisedCollections2Test.java
new file mode 100644
index 00000000..f31e8b11
--- /dev/null
+++ b/test/src/test/java/test/enhancement/WithInitialisedCollections2Test.java
@@ -0,0 +1,40 @@
+package test.enhancement;
+
+import io.ebean.common.BeanList;
+import io.ebean.common.BeanMap;
+import io.ebean.common.BeanSet;
+import org.junit.jupiter.api.Test;
+import test.model.Contact;
+import test.model.WithInitialisedCollections2;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class WithInitialisedCollections2Test extends BaseTest {
+
+  @Test
+  void oneToMany_initialisationCode_expect_removed() {
+    WithInitialisedCollections2 bean = new WithInitialisedCollections2();
+
+    assertThat(bean.listOf()).isInstanceOf(BeanList.class);
+    assertThat(bean.listCollEmpty()).isInstanceOf(BeanList.class);
+    assertThat(bean.setOf()).isInstanceOf(BeanSet.class);
+    assertThat(bean.setCollEmpty()).isInstanceOf(BeanSet.class);
+    assertThat(bean.mapOf()).isInstanceOf(BeanMap.class);
+    assertThat(bean.mapCollEmpty()).isInstanceOf(BeanMap.class);
+
+
+    assertThat(bean.transientList()).isNotInstanceOf(BeanList.class);
+    assertThat(bean.transientList2()).isNotInstanceOf(BeanList.class);
+    assertThat(bean.transientSet()).isNotInstanceOf(BeanSet.class);
+    assertThat(bean.transientSet2()).isNotInstanceOf(BeanSet.class);
+    assertThat(bean.transientMap()).isNotInstanceOf(BeanMap.class);
+    assertThat(bean.transientMap2()).isNotInstanceOf(BeanMap.class);
+
+
+    // these methods work because the underlying collection is a BeanCollection
+    bean.listOf().add(new Contact("junk"));
+    bean.setOf().add(new Contact("junk"));
+    bean.listCollEmpty().add(new Contact("junk"));
+    bean.setCollEmpty().add(new Contact("junk"));
+  }
+}
diff --git a/test/src/test/java/test/enhancement/WithInitialisedCollectionsTest.java b/test/src/test/java/test/enhancement/WithInitialisedCollectionsTest.java
index 26088bb9..7f5aa8ee 100644
--- a/test/src/test/java/test/enhancement/WithInitialisedCollectionsTest.java
+++ b/test/src/test/java/test/enhancement/WithInitialisedCollectionsTest.java
@@ -7,18 +7,18 @@
 import java.util.ArrayList;
 import java.util.List;
 
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.jupiter.api.Assertions.*;
 
 public class WithInitialisedCollectionsTest extends BaseTest {
 
-
   @Test
   public void test() {
 
     WithInitialisedCollections bean = new WithInitialisedCollections();
     assertNotNull(bean);
 
-    assertTrue(bean instanceof EntityBean);
+    assertInstanceOf(EntityBean.class, bean);
 
     EntityBean eb = (EntityBean)bean;
     String[] props = eb._ebean_getPropertyNames();
@@ -43,7 +43,6 @@ public void test() {
 
     assertNotNull(bean.getMyset());
     assertNotNull(bean.getMyLinkedSet());
-
   }
 
 
@@ -53,7 +52,7 @@ public void test_withTransient() {
     WithInitialisedCollectionAndTransient bean = new WithInitialisedCollectionAndTransient();
     assertNotNull(bean);
 
-    assertTrue(bean instanceof EntityBean);
+    assertInstanceOf(EntityBean.class, bean);
 
     assertNotNull(bean.getBuffer());
 
@@ -69,7 +68,7 @@ public void test_withAtTransient() {
     WithInitialisedCollectionAndAtTransient bean = new WithInitialisedCollectionAndAtTransient();
     assertNotNull(bean);
 
-    assertTrue(bean instanceof EntityBean);
+    assertInstanceOf(EntityBean.class, bean);
 
     assertNotNull(bean.getBuffer());
 
@@ -87,6 +86,6 @@ public void test_withConstructor() {
 
     WithInitialisedCollectionsAndConstructor bean = new WithInitialisedCollectionsAndConstructor(contacts);
 
-    assertTrue(bean.getContacts().size() == 1);
+    assertThat(bean.getContacts()).hasSize(1);
   }
 }
diff --git a/test/src/test/java/test/model/WithInitialisedCollections2.java b/test/src/test/java/test/model/WithInitialisedCollections2.java
new file mode 100644
index 00000000..7f406346
--- /dev/null
+++ b/test/src/test/java/test/model/WithInitialisedCollections2.java
@@ -0,0 +1,98 @@
+package test.model;
+
+
+import javax.persistence.CascadeType;
+import javax.persistence.Entity;
+import javax.persistence.OneToMany;
+import javax.persistence.Transient;
+import java.util.*;
+
+@Entity
+public class WithInitialisedCollections2 extends BaseEntity {
+
+  String name;
+
+  @OneToMany(cascade = CascadeType.PERSIST)
+  final List<Contact> listOf = List.of();
+  @OneToMany(cascade = CascadeType.PERSIST)
+  final Set<Contact> setOf = Set.of();
+  @OneToMany(cascade = CascadeType.PERSIST)
+  Map<Long,Contact> mapOf = Map.of();
+
+  @OneToMany(cascade = CascadeType.PERSIST)
+  final List<Contact> listCollEmpty = Collections.emptyList();
+  @OneToMany(cascade = CascadeType.PERSIST)
+  final Set<Contact> setCollEmpty = Collections.emptySet();
+  @OneToMany(cascade = CascadeType.PERSIST)
+  Map<Long,Contact> mapCollEmpty = Collections.emptyMap();
+
+  @Transient
+  List<String> transientList = List.of();
+  @Transient
+  Set<String> transientSet = Set.of();
+  @Transient
+  Map<String,Contact> transientMap = Map.of();
+  @Transient
+  List<String> transientList2 = Collections.emptyList();
+  @Transient
+  Set<String> transientSet2 = Collections.emptySet();
+  @Transient
+  Map<String,Contact> transientMap2 = Collections.emptyMap();
+
+  public String name() {
+    return name;
+  }
+
+  public WithInitialisedCollections2 setName(String name) {
+    this.name = name;
+    return this;
+  }
+
+  public List<Contact> listOf() {
+    return listOf;
+  }
+
+  public Set<Contact> setOf() {
+    return setOf;
+  }
+
+  public Map<Long, Contact> mapOf() {
+    return mapOf;
+  }
+
+  public List<Contact> listCollEmpty() {
+    return listCollEmpty;
+  }
+
+  public Set<Contact> setCollEmpty() {
+    return setCollEmpty;
+  }
+
+  public Map<Long, Contact> mapCollEmpty() {
+    return mapCollEmpty;
+  }
+
+  public List<String> transientList() {
+    return transientList;
+  }
+
+  public Set<String> transientSet() {
+    return transientSet;
+  }
+
+  public List<String> transientList2() {
+    return transientList2;
+  }
+
+  public Set<String> transientSet2() {
+    return transientSet2;
+  }
+
+  public Map<String, Contact> transientMap() {
+    return transientMap;
+  }
+
+  public Map<String, Contact> transientMap2() {
+    return transientMap2;
+  }
+}


Reviews:
user: login: rPraml
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T03:55:36Z
user: login: rbygrave
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T10:21:24Z
user: login: rbygrave
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T10:25:44Z
user: login: rbygrave
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T10:27:07Z
user: login: rPraml
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T11:08:24Z
user: login: rbygrave
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T19:26:01Z
user: login: rPraml
body: This looks good to me now. We should not add too much complexity
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T19:54:57Z
user: login: rbygrave
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T19:55:42Z

Review comments:
diff_hunk: @@ -0,0 +1,40 @@
+package test.enhancement;
+
+import io.ebean.common.BeanList;
+import io.ebean.common.BeanMap;
+import io.ebean.common.BeanSet;
+import org.junit.jupiter.api.Test;
+import test.model.Contact;
+import test.model.WithInitialisedCollections2;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class WithInitialisedCollections2Test extends BaseTest {
+
+  @Test
+  void oneToMany_initialisationCode_expect_removed() {
+    WithInitialisedCollections2 bean = new WithInitialisedCollections2();
+
+    assertThat(bean.listOf()).isInstanceOf(BeanList.class);
+    assertThat(bean.listCollEmpty()).isInstanceOf(BeanList.class);
+    assertThat(bean.setOf()).isInstanceOf(BeanSet.class);
+    assertThat(bean.setCollEmpty()).isInstanceOf(BeanSet.class);
+    assertThat(bean.mapOf()).isInstanceOf(BeanMap.class);
+    assertThat(bean.mapCollEmpty()).isInstanceOf(BeanMap.class);
+
+
+    assertThat(bean.transientList()).isNotInstanceOf(BeanList.class);
+    assertThat(bean.transientList2()).isNotInstanceOf(BeanList.class);
+    assertThat(bean.transientSet()).isNotInstanceOf(BeanSet.class);
+    assertThat(bean.transientSet2()).isNotInstanceOf(BeanSet.class);
+    assertThat(bean.transientMap()).isNotInstanceOf(BeanMap.class);
+    assertThat(bean.transientMap2()).isNotInstanceOf(BeanMap.class);
+
+
+    // these methods work because the underlying collection is a BeanCollection
+    bean.listOf().add(new Contact("junk"));
path: test/src/test/java/test/enhancement/WithInitialisedCollections2Test.java
user: login: rPraml
body: My expectation is, that this should fail.
When initializing with List.of etc. the collection is immutable.
Can we transfer this information to the BeanCollection? (if agent detects immutable initialization, the replaced collection is also immutable from the perspective of the user - not for lazy loading)

BTW. There are other ways to initialize immutable lists:
- Collections.EMTY_LIST
- Collections.unmodifiableList(new ArrsyList()) 
- Arrays.asList(...)

I think, the agent should fail or at least warn, if an unknown initialization is detectef
created_at: 2025-03-04T03:55:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,40 @@
+package test.enhancement;
+
+import io.ebean.common.BeanList;
+import io.ebean.common.BeanMap;
+import io.ebean.common.BeanSet;
+import org.junit.jupiter.api.Test;
+import test.model.Contact;
+import test.model.WithInitialisedCollections2;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class WithInitialisedCollections2Test extends BaseTest {
+
+  @Test
+  void oneToMany_initialisationCode_expect_removed() {
+    WithInitialisedCollections2 bean = new WithInitialisedCollections2();
+
+    assertThat(bean.listOf()).isInstanceOf(BeanList.class);
+    assertThat(bean.listCollEmpty()).isInstanceOf(BeanList.class);
+    assertThat(bean.setOf()).isInstanceOf(BeanSet.class);
+    assertThat(bean.setCollEmpty()).isInstanceOf(BeanSet.class);
+    assertThat(bean.mapOf()).isInstanceOf(BeanMap.class);
+    assertThat(bean.mapCollEmpty()).isInstanceOf(BeanMap.class);
+
+
+    assertThat(bean.transientList()).isNotInstanceOf(BeanList.class);
+    assertThat(bean.transientList2()).isNotInstanceOf(BeanList.class);
+    assertThat(bean.transientSet()).isNotInstanceOf(BeanSet.class);
+    assertThat(bean.transientSet2()).isNotInstanceOf(BeanSet.class);
+    assertThat(bean.transientMap()).isNotInstanceOf(BeanMap.class);
+    assertThat(bean.transientMap2()).isNotInstanceOf(BeanMap.class);
+
+
+    // these methods work because the underlying collection is a BeanCollection
+    bean.listOf().add(new Contact("junk"));
path: test/src/test/java/test/enhancement/WithInitialisedCollections2Test.java
user: login: rbygrave
body: > My expectation is, that this should fail.

That is reasonable. My expectation was that people wouldn't use `List.of()` **_at all_** ...


> Can we transfer this information to the BeanCollection? 

The initialisation for OneToMany and ManyToMany is removed.  So for example, if we detect `new ArrayList<>()` initialisation for a OneToMany - _that bytecode is removed so it is as if it wasn't there at all_.

So this PR was done such that initialisation via `List.of()` ... was detected and removed as if it wasn't there at all.

That is, the only reasons why we'd choose to initialise OneToMany collections in such a way is so that (A) the fields can be final and (B) it gives the _impression_ to people reading the code that the collection will not be null.
... noting the for (B) this is only an impression because ebean will make sure the collection is not null regardless [well except for the new immutable read only stuff that isn't in yet and will throw an exception ... so also there no application code will see a null OneToMany collection].

-----

So that all said, I've pushed another PR just now that detects when initialisation code for OneToMany or ManyToMany isn't supported and throws a new EnhancementException [and for maven we will fail the build at enhancement due when this is detected].


> I think, the agent should fail or at least warn, if an unknown initialisation is detect

Yes. I think I've just managed to do that with the latest commit pushed here plus a change to the maven plugin to detect and fail the build for that case.



The outstanding question I think is, should we support `List.of()` and `Collections.emptyList()` [where support means that this bytecode is detected and removed just like `new ArrayList<>()` is].

created_at: 2025-03-04T10:21:24Z
author_association: MEMBER
diff_hunk: @@ -142,15 +137,20 @@ private void processPackage(String dir) {
   }
 
   private void transformFile(File file) throws IOException, IllegalClassFormatException {
-
     String className = getClassName(file);
-
-    byte[] result = inputStreamTransform.transform(className, file);
-
-    if (result != null) {
-      InputStreamTransform.writeBytes(result, file);
-      if (listener != null && logLevel > 0) {
-        listener.logEvent("Enhanced " + file);
+    try {
+      byte[] result = inputStreamTransform.transform(className, file);
+      if (result != null) {
+        InputStreamTransform.writeBytes(result, file);
+        if (listener != null && logLevel > 0) {
+          listener.logEvent("Enhanced " + file);
+        }
+      }
+    } catch (EnhancementException e) {
+      if (listener != null) {
+        listener.logError("Error enhancing class " + className + " " + e.getMessage());
path: ebean-agent/src/main/java/io/ebean/enhance/ant/OfflineFileTransform.java
user: login: rbygrave
body: the maven plugin registers a listener. This is the mechanism such that the maven plugin knows this EnhancementException was thrown ... and that means the maven plugin should throw a MojoExecutionException such that the maven build fails.
created_at: 2025-03-04T10:25:44Z
author_association: MEMBER
diff_hunk: @@ -130,20 +131,53 @@ boolean deferVisitMethodInsn(int opcode, String owner, String name, String desc,
       stateInitialiseType = owner;
       return true;
     }
-    if (opcode == INVOKESTATIC && stateAload() && kotlinEmptyList(owner, name, desc)) {
-      codes.add(new NoArgInit(opcode, owner, name, desc, itf));
-      state = State.KT_EMPTYLIST;
-      stateInitialiseType = "java/util/ArrayList";
-      return true;
+    if (opcode == INVOKESTATIC && stateAload()) {
+      if (kotlinEmptyList(owner, name, desc)) { // emptyList(owner, name, desc) ||
+        codes.add(new NoArgInit(opcode, owner, name, desc, itf));
+        state = State.EMPTY;
+        stateInitialiseType = "java/util/ArrayList";
+        return true;
+      }
+//      if (emptySet(owner, name, desc)) {
+//        codes.add(new NoArgInit(opcode, owner, name, desc, itf));
+//        state = State.EMPTY;
+//        stateInitialiseType = "java/util/LinkedHashSet";
+//        return true;
+//      }
+//      if (emptyMap(owner, name, desc)) {
+//        codes.add(new NoArgInit(opcode, owner, name, desc, itf));
+//        state = State.EMPTY;
+//        stateInitialiseType = "java/util/LinkedHashMap";
+//        return true;
+//      }
     }
     flush();
+    state = State.MAYBE_UNSUPPORTED;
     return false;
   }
 
   private boolean isNoArgInit(String name, String desc) {
     return name.equals(INIT) && desc.equals(NOARG_VOID);
   }
 
+//  private boolean emptyList(String owner, String name, String desc) {
path: ebean-agent/src/main/java/io/ebean/enhance/entity/ConstructorDeferredCode.java
user: login: rbygrave
body: we could uncomment these to support `List.of()` `Collections.emptyList()` etc
created_at: 2025-03-04T10:27:07Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,40 @@
+package test.enhancement;
+
+import io.ebean.common.BeanList;
+import io.ebean.common.BeanMap;
+import io.ebean.common.BeanSet;
+import org.junit.jupiter.api.Test;
+import test.model.Contact;
+import test.model.WithInitialisedCollections2;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class WithInitialisedCollections2Test extends BaseTest {
+
+  @Test
+  void oneToMany_initialisationCode_expect_removed() {
+    WithInitialisedCollections2 bean = new WithInitialisedCollections2();
+
+    assertThat(bean.listOf()).isInstanceOf(BeanList.class);
+    assertThat(bean.listCollEmpty()).isInstanceOf(BeanList.class);
+    assertThat(bean.setOf()).isInstanceOf(BeanSet.class);
+    assertThat(bean.setCollEmpty()).isInstanceOf(BeanSet.class);
+    assertThat(bean.mapOf()).isInstanceOf(BeanMap.class);
+    assertThat(bean.mapCollEmpty()).isInstanceOf(BeanMap.class);
+
+
+    assertThat(bean.transientList()).isNotInstanceOf(BeanList.class);
+    assertThat(bean.transientList2()).isNotInstanceOf(BeanList.class);
+    assertThat(bean.transientSet()).isNotInstanceOf(BeanSet.class);
+    assertThat(bean.transientSet2()).isNotInstanceOf(BeanSet.class);
+    assertThat(bean.transientMap()).isNotInstanceOf(BeanMap.class);
+    assertThat(bean.transientMap2()).isNotInstanceOf(BeanMap.class);
+
+
+    // these methods work because the underlying collection is a BeanCollection
+    bean.listOf().add(new Contact("junk"));
path: test/src/test/java/test/enhancement/WithInitialisedCollections2Test.java
user: login: rPraml
body: > That is reasonable. My expectation was that people wouldn't use List.of() at all ...

I would never have thought of that ;)

We would need the following use cases:
- `null` / `new ArrayList()` for normal lists
- a distinction between unsorted sets and sorted sets/maps (we initialize them with `LinkedHashSet`/`LinkedHashMap`)

> The outstanding question I think is, should we support List.of() and Collections.emptyList()

if we decide to do, I would expect, that the BeanList/BeanSet etc. is also readonly. So BeanList needs a readOnly flag and the agent must call the new constructor `new BeanList(underlyingList, true)`

I can imagine use cases, where I want to protect the modification of that list by someone else.

but this is more theoretical, so it would be also OK to throw an error in the enhancer
created_at: 2025-03-04T11:08:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,40 @@
+package test.enhancement;
+
+import io.ebean.common.BeanList;
+import io.ebean.common.BeanMap;
+import io.ebean.common.BeanSet;
+import org.junit.jupiter.api.Test;
+import test.model.Contact;
+import test.model.WithInitialisedCollections2;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class WithInitialisedCollections2Test extends BaseTest {
+
+  @Test
+  void oneToMany_initialisationCode_expect_removed() {
+    WithInitialisedCollections2 bean = new WithInitialisedCollections2();
+
+    assertThat(bean.listOf()).isInstanceOf(BeanList.class);
+    assertThat(bean.listCollEmpty()).isInstanceOf(BeanList.class);
+    assertThat(bean.setOf()).isInstanceOf(BeanSet.class);
+    assertThat(bean.setCollEmpty()).isInstanceOf(BeanSet.class);
+    assertThat(bean.mapOf()).isInstanceOf(BeanMap.class);
+    assertThat(bean.mapCollEmpty()).isInstanceOf(BeanMap.class);
+
+
+    assertThat(bean.transientList()).isNotInstanceOf(BeanList.class);
+    assertThat(bean.transientList2()).isNotInstanceOf(BeanList.class);
+    assertThat(bean.transientSet()).isNotInstanceOf(BeanSet.class);
+    assertThat(bean.transientSet2()).isNotInstanceOf(BeanSet.class);
+    assertThat(bean.transientMap()).isNotInstanceOf(BeanMap.class);
+    assertThat(bean.transientMap2()).isNotInstanceOf(BeanMap.class);
+
+
+    // these methods work because the underlying collection is a BeanCollection
+    bean.listOf().add(new Contact("junk"));
path: test/src/test/java/test/enhancement/WithInitialisedCollections2Test.java
user: login: rbygrave
body: > a distinction between unsorted sets and sorted sets/maps

I have no intention of ebean supporting unsorted sets/maps. I don't think that is a need / requirement.


>  I would expect, that the BeanList/BeanSet etc. is also readonly.

I don't think we can easily do that at this point. I don't want to try to get that clever unless we have to.

My suspicion is that I don't think people are thinking deeply about this. I suspect the use of List.of() was more just a bit of an accident. 
created_at: 2025-03-04T19:26:01Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,40 @@
+package test.enhancement;
+
+import io.ebean.common.BeanList;
+import io.ebean.common.BeanMap;
+import io.ebean.common.BeanSet;
+import org.junit.jupiter.api.Test;
+import test.model.Contact;
+import test.model.WithInitialisedCollections2;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class WithInitialisedCollections2Test extends BaseTest {
+
+  @Test
+  void oneToMany_initialisationCode_expect_removed() {
+    WithInitialisedCollections2 bean = new WithInitialisedCollections2();
+
+    assertThat(bean.listOf()).isInstanceOf(BeanList.class);
+    assertThat(bean.listCollEmpty()).isInstanceOf(BeanList.class);
+    assertThat(bean.setOf()).isInstanceOf(BeanSet.class);
+    assertThat(bean.setCollEmpty()).isInstanceOf(BeanSet.class);
+    assertThat(bean.mapOf()).isInstanceOf(BeanMap.class);
+    assertThat(bean.mapCollEmpty()).isInstanceOf(BeanMap.class);
+
+
+    assertThat(bean.transientList()).isNotInstanceOf(BeanList.class);
+    assertThat(bean.transientList2()).isNotInstanceOf(BeanList.class);
+    assertThat(bean.transientSet()).isNotInstanceOf(BeanSet.class);
+    assertThat(bean.transientSet2()).isNotInstanceOf(BeanSet.class);
+    assertThat(bean.transientMap()).isNotInstanceOf(BeanMap.class);
+    assertThat(bean.transientMap2()).isNotInstanceOf(BeanMap.class);
+
+
+    // these methods work because the underlying collection is a BeanCollection
+    bean.listOf().add(new Contact("junk"));
path: test/src/test/java/test/enhancement/WithInitialisedCollections2Test.java
user: login: rbygrave
body: Reviewing the JPA spec, the initialisation of OneToMany and ManyToMany is vague: 

_"Collection-valued persistent fields and properties must be defined in terms of one of the following collection-valued interfaces regardless of whether the entity class otherwise adheres to the JavaBeans method conventions noted above and whether field or property access is used: java.util.Collection, java.util.Set, java.util.List [[3](https://jakarta.ee/specifications/persistence/3.0/jakarta-persistence-spec-3.0#_footnotedef_3)], java.util.Map. The collection implementation type may be used by the application to initialize fields or properties before the entity is made persistent."_


Noting that stack overflow has - https://stackoverflow.com/questions/20715143/to-initialize-or-not-initialize-jpa-relationship-mappings

At this stage, it feels like the safest option would be to not support List.of() and Collections.emptyList() etc as long as we can explicitly fail the enhancement. Noting that there might have "holes" in this detection like an application that ONLY using javaagent style enhancement could miss an error going to system out [but the entity would not be enhanced so it would not be useable as an entity - it's just the error might get misleading].
created_at: 2025-03-04T19:55:42Z
author_association: MEMBER

PR comments:
user: login: rbygrave
created_at: 2025-03-04T20:22:02Z
body: Ok cool - I think we can roll with this.
author_association: MEMBER
user: login: rbygrave
created_at: 2025-03-08T20:48:35Z
body: Also needs PR #221 to capture some non valid cases 
author_association: MEMBER