diff --git a/pom.xml b/pom.xml index cd1b866..b1370d1 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 org.example - example-mininal + minimal-bug-reproduction 1.1-SNAPSHOT @@ -14,6 +14,9 @@ 3.8.1 3.0.0-M5 3.0.0-M5 + 13.8.0 + + @@ -27,14 +30,20 @@ io.ebean ebean - 12.11.1 + ${ebean-version} + + + + mysql + mysql-connector-java + 8.0.28 io.ebean querybean-generator - 12.11.1 + ${ebean-version} provided @@ -43,7 +52,7 @@ io.ebean ebean-test - 12.11.1 + ${ebean-version} test @@ -75,7 +84,7 @@ org.avaje.tile:java-compile:11 - io.ebean.tile:enhancement:12.11.1 + io.ebean.tile:enhancement:${ebean-version} diff --git a/src/main/java/org/example/domain/Customer.java b/src/main/java/org/example/domain/Customer.java deleted file mode 100644 index 0d3da1f..0000000 --- a/src/main/java/org/example/domain/Customer.java +++ /dev/null @@ -1,61 +0,0 @@ -package org.example.domain; - -import io.ebean.Model; - -import javax.persistence.Entity; -import javax.persistence.Id; -import javax.persistence.Version; - -@Entity -public class Customer extends Model { - - @Id - Long id; - - String name; - - String notes; - - @Version - Long version; - - public Customer(String name) { - this.name = name; - } - - public Customer() { - } - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getNotes() { - return notes; - } - - public void setNotes(String notes) { - this.notes = notes; - } - - public Long getVersion() { - return version; - } - - public void setVersion(Long version) { - this.version = version; - } - -} diff --git a/src/main/java/org/example/domain/Environment.java b/src/main/java/org/example/domain/Environment.java new file mode 100644 index 0000000..f02cd34 --- /dev/null +++ b/src/main/java/org/example/domain/Environment.java @@ -0,0 +1,34 @@ +package org.example.domain; + +import java.util.UUID; + +import javax.persistence.Entity; +import javax.persistence.Id; + +import io.ebean.Model; +import io.ebean.annotation.SoftDelete; + +@Entity +public class Environment extends Model { + @Id + private UUID id; + + @SoftDelete + private boolean deleted; + + private String name; + + public Environment(String name, boolean deleted) { + this.id = UUID.randomUUID(); + this.name = name; + this.deleted = deleted; + } + + public UUID getId() { + return id; + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/org/example/domain/ServiceAccount.java b/src/main/java/org/example/domain/ServiceAccount.java new file mode 100644 index 0000000..f6755b7 --- /dev/null +++ b/src/main/java/org/example/domain/ServiceAccount.java @@ -0,0 +1,45 @@ +package org.example.domain; + +import java.util.UUID; + +import javax.persistence.CascadeType; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.ManyToOne; +import javax.persistence.OneToOne; + +import io.ebean.Model; +import io.ebean.annotation.SoftDelete; + +@Entity +public class ServiceAccount extends Model { + @Id + private UUID id; + + @ManyToOne(cascade = CascadeType.REMOVE) + private Environment environment; + + @SoftDelete + private boolean deleted; + + private String name; + + public ServiceAccount(String name, Environment environment, boolean deleted) { + this.id = UUID.randomUUID(); + this.name = name; + this.environment = environment; + this.deleted = deleted; + } + + public UUID getId() { + return id; + } + + public Environment getEnvironment() { + return environment; + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/org/example/domain/UsageRaw.java b/src/main/java/org/example/domain/UsageRaw.java new file mode 100644 index 0000000..9e362e0 --- /dev/null +++ b/src/main/java/org/example/domain/UsageRaw.java @@ -0,0 +1,39 @@ +package org.example.domain; + +import java.util.UUID; + +import javax.persistence.CascadeType; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.ManyToOne; + +import io.ebean.Model; + +@Entity +public class UsageRaw extends Model { + @Id + private UUID id; + + @ManyToOne(cascade = CascadeType.REMOVE) + private ServiceAccount serviceAccount; + + private String name; + + public UsageRaw(String name, ServiceAccount serviceAccount) { + this.id = UUID.randomUUID(); + this.name = name; + this.serviceAccount = serviceAccount; + } + + public UUID getId() { + return id; + } + + public ServiceAccount getServiceAccount() { + return serviceAccount; + } + + public String getName() { + return name; + } +} diff --git a/src/main/resources/dbmigration/1.1_create_tables.xml b/src/main/resources/dbmigration/1.1_create_tables.xml new file mode 100644 index 0000000..a4857e1 --- /dev/null +++ b/src/main/resources/dbmigration/1.1_create_tables.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/java/main/MainDbMigration.java b/src/test/java/main/MainDbMigration.java index 428b246..0db1f00 100644 --- a/src/test/java/main/MainDbMigration.java +++ b/src/test/java/main/MainDbMigration.java @@ -18,7 +18,7 @@ public static void main(String[] args) throws Exception { //System.setProperty("ddl.migration.pendingDropsFor", "1.1"); DbMigration dbMigration = DbMigration.create(); - dbMigration.setPlatform(Platform.POSTGRES); + dbMigration.setPlatform(Platform.MYSQL); // generate the migration ddl and xml dbMigration.generateMigration(); } diff --git a/src/test/java/org/example/domain/CustomerQueryTest.java b/src/test/java/org/example/domain/CustomerQueryTest.java deleted file mode 100644 index 6524ab1..0000000 --- a/src/test/java/org/example/domain/CustomerQueryTest.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.example.domain; - -import io.ebean.DB; -import org.example.domain.query.QCustomer; -import org.junit.jupiter.api.Test; - -public class CustomerQueryTest { - - @Test - public void findAll() { - - DB.find(Customer.class) - .findList(); - - new QCustomer() - .id.greaterOrEqualTo(1L) - .findList(); - } -} diff --git a/src/test/java/org/example/domain/CustomerTest.java b/src/test/java/org/example/domain/CustomerTest.java deleted file mode 100644 index 29e66e4..0000000 --- a/src/test/java/org/example/domain/CustomerTest.java +++ /dev/null @@ -1,78 +0,0 @@ -package org.example.domain; - -import io.ebean.DB; -import io.ebean.Database; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertNotNull; - - -/** - * When running tests in the IDE install the "Enhancement plugin". - *

- * http://ebean-orm.github.io/docs/setup/enhancement#ide - */ -public class CustomerTest { - - - /** - * Get the "default database" and save(). - */ - @Test - public void insert_via_database() { - - Customer rob = new Customer("Rob"); - - Database server = DB.getDefault(); - server.save(rob); - - assertNotNull(rob.getId()); - } - - /** - * Use the Ebean singleton (effectively using the "default server"). - */ - @Test - public void insert_via_model() { - - Customer jim = new Customer("Jim"); - jim.save(); - - assertNotNull(jim.getId()); - } - - - /** - * Find and then update. - */ - @Test - public void updateRob() { - - Customer newBob = new Customer("Bob"); - newBob.save(); - - Customer bob = DB.find(Customer.class) - .where().eq("name", "Bob") - .findOne(); - - bob.setNotes("Doing an update"); - bob.save(); - } - - /** - * Execute an update without a prior query. - */ - @Test - public void statelessUpdate() { - - Customer newMob = new Customer("Mob"); - newMob.save(); - - Customer upd = new Customer(); - upd.setId(newMob.getId()); - upd.setNotes("Update without a fetch"); - - upd.update(); - } - -} diff --git a/src/test/java/org/example/domain/LazyLoadingSoftDeletedReferencesTest.java b/src/test/java/org/example/domain/LazyLoadingSoftDeletedReferencesTest.java new file mode 100644 index 0000000..6b1c261 --- /dev/null +++ b/src/test/java/org/example/domain/LazyLoadingSoftDeletedReferencesTest.java @@ -0,0 +1,157 @@ +package org.example.domain; + +import static java.util.Objects.nonNull; + +import java.util.List; +import java.util.function.Predicate; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.ebean.DB; +import io.ebean.Database; + +public class LazyLoadingSoftDeletedReferencesTest { + @BeforeEach + private void setup() { + insertRows(true); + insertRows(false); + } + + @AfterEach + private void cleanup() { + Database server = DB.getDefault(); + server.deleteAll(server.find(UsageRaw.class).findList()); + } + + /** + * Demonstrates that default fetching behaves differently as of 13.6.6. + * + * Also demonstrates workaround, using fetchLazy() which yields desired results. + */ + @Test + public void setIncludeSoftDeletes_does_not_fetch_deep_relations_that_are_softdeleted() { + // given: existing usage + /* + [ + { id: 1, account: { name: 'usage-1', deleted: false, environment: { name: 'env-1', deleted: false } }, + { id: 2, account: { name: 'usage-2', deleted: true, environment: { name: 'env-2', deleted: true } }, + ] */ + Database server = DB.getDefault(); + + // demonstrates new behaviour in 13.6.6 + // when: selecting without fetch variations + List usage = server.find(UsageRaw.class) + .setIncludeSoftDeletes() + .findList(); + // then: soft-deleted environments not loaded + assert(usage.size() == 2); + assert(usage.stream().allMatch(accountLoaded())); + assert(usage.stream().filter(environmentLoaded()).count() == 1); + + // demonstrates workaround, use fetchLazy() + // when: lazy fetching + List lazyUsage = server.find(UsageRaw.class) + .setIncludeSoftDeletes() + .fetchLazy("serviceAccount.environment") + .findList(); + // then: soft-deleted environments are loaded + assert(lazyUsage.size() == 2); + assert(lazyUsage.stream().allMatch(accountLoaded())); + assert(lazyUsage.stream().allMatch(environmentLoaded())); + } + + /** + * Demonstrates that .fetchLazy() with multiple paths behaves differently in 13.6.6 + * and does not behave the same as .fetch(). + * + * Also demonstrates workaround that yield desired results. + */ + + @Test + public void fetchLazy_with_multiple_paths_does_not_fetch_deep_relations_that_are_softdeleted() { + // given: existing usage + /* + [ + { id: 1, account: { name: 'usage-1', deleted: false, environment: { name: 'env-1', deleted: false } }, + { id: 2, account: { name: 'usage-2', deleted: true, environment: { name: 'env-2', deleted: true } }, + ] */ + Database server = DB.getDefault(); + + // demonstrates new behaviour in 13.6.6 + // when: lazy fetching serviceAccount AND serviceAccount.environment + List lazyUsageMultiplePaths = server.find(UsageRaw.class) + .setIncludeSoftDeletes() + .fetchLazy("serviceAccount") + .fetchLazy("serviceAccount.environment") + .findList(); + // then: soft-deleted environments are NOT loaded (not the case before 13.6.6) + assert(lazyUsageMultiplePaths.size() == 2); + assert(lazyUsageMultiplePaths.stream().allMatch(accountLoaded())); + assert(lazyUsageMultiplePaths.stream().filter(environmentLoaded()).count() == 1); + + // demonstrates workaround, use single path, all required properties are loaded + // when: lazy fetching serviceAccount.environment + List lazyUsageSinglePath = server.find(UsageRaw.class) + .setIncludeSoftDeletes() + .fetchLazy("serviceAccount.environment") + .findList(); + // then: soft-deleted environments loaded + assert(lazyUsageSinglePath.size() == 2); + assert(lazyUsageSinglePath.stream().allMatch(accountLoaded())); + assert(lazyUsageSinglePath.stream().allMatch(environmentLoaded())); + + // demonstrates other workaround, use fetch() for environment, all required properties are loaded + // when: lazy fetching serviceAccount.environment + List lazyUsageMultiplePathsWithFetch = server.find(UsageRaw.class) + .setIncludeSoftDeletes() + .fetchLazy("serviceAccount") + .fetch("serviceAccount.environment") + .findList(); + // then: soft-deleted environments loaded + assert(lazyUsageMultiplePathsWithFetch.size() == 2); + assert(lazyUsageMultiplePathsWithFetch.stream().allMatch(accountLoaded())); + assert(lazyUsageMultiplePathsWithFetch.stream().allMatch(environmentLoaded())); + + // demonstrates that eager fetching works when multiple paths are used + // when: eagerly fetching serviceAccount AND serviceAccount.environment + List eagerUsage = server.find(UsageRaw.class) + .setIncludeSoftDeletes() + .fetch("serviceAccount") + .fetch("serviceAccount.environment") + .findList(); + // then: soft-deleted environments loaded + assert(eagerUsage.size() == 2); + assert(eagerUsage.stream().allMatch(accountLoaded())); + assert(eagerUsage.stream().allMatch(environmentLoaded())); + } + + private static Predicate accountLoaded() { + return usage -> { + boolean loaded = nonNull(usage.getServiceAccount().getName()); + // put breakpoint here to see post-load state + return loaded; + }; + } + + private static Predicate environmentLoaded() { + return usage -> { + boolean loaded = nonNull(usage.getServiceAccount()) + && nonNull(usage.getServiceAccount().getEnvironment().getName()); + // put breakpoint here to see post-load state + return loaded; + }; + } + + private static void insertRows(boolean softDeleted) { + Environment environment = new Environment("environment", softDeleted); + ServiceAccount account = new ServiceAccount("account", environment, softDeleted); + UsageRaw usage = new UsageRaw("usage", account); + + Database server = DB.getDefault(); + server.save(environment); + server.save(account); + server.save(usage); + } +} diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index 49adf3b..fc4c72e 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -1,7 +1,11 @@ ebean: test: -# useDocker: false -# shutdown: stop # stop | remove - platform: h2 # h2, postgres, mysql, oracle, sqlserver, sqlite - ddlMode: dropCreate # none | dropCreate | create | migration | createOnly | migrationDropCreate - dbName: myapp + useDocker: true + platform: mysql # h2, postgres, mysql, oracle, sqlserver + dbName: test + mysql: + username: your-username + password: your-password + url: jdbc:mysql://localhost:3306/test + collation: utf8mb4_unicode_ci + characterSet: utf8mb4 diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml index e16eb74..0c9a6e1 100644 --- a/src/test/resources/logback-test.xml +++ b/src/test/resources/logback-test.xml @@ -28,7 +28,7 @@ - + @@ -45,4 +45,4 @@ - \ No newline at end of file +