From dcdf7589b09421f7febf3bcb8c02592b593ff481 Mon Sep 17 00:00:00 2001 From: "robin.bygrave" Date: Sat, 11 Apr 2026 16:50:49 +1200 Subject: [PATCH 1/4] Docs: modify guides README with links to the available guides --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 9c01d7567e..5dda8a0ae0 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,13 @@ Goto [https://ebean.io/docs/](https://ebean.io/docs/) ## Guides Step-by-step guides for common tasks: [docs/guides/](docs/guides/README.md) +Available guides: +- [Maven POM setup](docs/guides/add-ebean-postgres-maven-pom.md) +- [Database configuration](docs/guides/add-ebean-postgres-database-config.md) +- [Test container setup](docs/guides/add-ebean-postgres-test-container.md) +- [DB migration generation](docs/guides/add-ebean-db-migration-generation.md) +- [Lombok with Ebean entity beans](docs/guides/lombok-with-ebean-entity-beans.md) + ## Maven central [Maven central - g:io.ebean](http://search.maven.org/#search%7Cgav%7C1%7Cg%3A%22io.ebean%22%20) From 176f336926329d4a0cc35b2f7a0a50402ad851e9 Mon Sep 17 00:00:00 2001 From: "robin.bygrave" Date: Sat, 11 Apr 2026 16:51:43 +1200 Subject: [PATCH 2/4] Add TestEntityBuilder for building test entity instances populated by random values --- .../io/ebean/test/RandomValueGenerator.java | 89 ++++++++++ .../java/io/ebean/test/TestEntityBuilder.java | 153 ++++++++++++++++++ .../ebean/test/RandomValueGeneratorTest.java | 105 ++++++++++++ .../io/ebean/test/TestEntityBuilderTest.java | 86 ++++++++++ 4 files changed, 433 insertions(+) create mode 100644 ebean-test/src/main/java/io/ebean/test/RandomValueGenerator.java create mode 100644 ebean-test/src/main/java/io/ebean/test/TestEntityBuilder.java create mode 100644 ebean-test/src/test/java/io/ebean/test/RandomValueGeneratorTest.java create mode 100644 ebean-test/src/test/java/io/ebean/test/TestEntityBuilderTest.java diff --git a/ebean-test/src/main/java/io/ebean/test/RandomValueGenerator.java b/ebean-test/src/main/java/io/ebean/test/RandomValueGenerator.java new file mode 100644 index 0000000000..3ab61ec000 --- /dev/null +++ b/ebean-test/src/main/java/io/ebean/test/RandomValueGenerator.java @@ -0,0 +1,89 @@ +package io.ebean.test; + +import io.ebeaninternal.server.deploy.BeanProperty; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Generates random values for entity bean properties in tests. + *

+ * The primary entry point is {@link #generate(BeanProperty)}, which is + * property-aware (e.g. caps String values at the column's max length). + * The secondary entry point {@link #generate(Class)} works on the Java type + * alone and is useful when no property metadata is available. + *

+ *

+ * Returns {@code null} for types that are not mapped (exotic / unknown types) — + * the caller is expected to set those fields manually. + *

+ */ +public class RandomValueGenerator { + + /** + * Generate a random value for the given bean property. + *

+ * For {@code String} properties, the value is capped at the column's + * {@link BeanProperty#dbLength()} when that length is positive. + *

+ */ + public Object generate(BeanProperty prop) { + Class type = prop.type(); + if (type == String.class) { + return randomString(prop.dbLength()); + } + return generate(type); + } + + /** + * Generate a random value for the given Java type, without property metadata. + *

+ * String values produced here use a fixed 8-character length. Use + * {@link #generate(BeanProperty)} when column-length constraints matter. + *

+ */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public Object generate(Class type) { + if (type == null) return null; + if (type == String.class) return randomString(0); + if (type == Long.class || type == long.class) return ThreadLocalRandom.current().nextLong(1, 100_000L); + if (type == Integer.class || type == int.class) return ThreadLocalRandom.current().nextInt(1, 1_000); + if (type == Short.class || type == short.class) return (short) ThreadLocalRandom.current().nextInt(1, 100); + if (type == Boolean.class || type == boolean.class) return Boolean.TRUE; + if (type == UUID.class) return UUID.randomUUID(); + if (type == Instant.class) return Instant.now(); + if (type == OffsetDateTime.class) return OffsetDateTime.now(); + if (type == LocalDate.class) return LocalDate.now(); + if (type == LocalDateTime.class) return LocalDateTime.now(); + if (type == BigDecimal.class) return BigDecimal.valueOf(ThreadLocalRandom.current().nextDouble(1, 100)); + if (type == Double.class || type == double.class) return ThreadLocalRandom.current().nextDouble(1, 100); + if (type == Float.class || type == float.class) return (float) ThreadLocalRandom.current().nextDouble(1, 100); + if (type.isEnum()) { + Object[] constants = type.getEnumConstants(); + return constants.length > 0 ? constants[0] : null; + } + return null; // exotic/unknown type — caller must set this field manually + } + + /** + * Generate a random string, optionally capped at {@code maxLength}. + *

+ * When {@code maxLength} is 0 or negative (unknown / unlimited), an 8-character + * UUID-derived string is returned. Otherwise the value is a UUID-derived string + * truncated to {@code maxLength} characters. + *

+ */ + private String randomString(int maxLength) { + String base = UUID.randomUUID().toString().replace("-", ""); // 32 chars + if (maxLength <= 0) { + return base.substring(0, 8); + } + int len = Math.min(maxLength, base.length()); + return base.substring(0, len); + } +} diff --git a/ebean-test/src/main/java/io/ebean/test/TestEntityBuilder.java b/ebean-test/src/main/java/io/ebean/test/TestEntityBuilder.java new file mode 100644 index 0000000000..4483c777a0 --- /dev/null +++ b/ebean-test/src/main/java/io/ebean/test/TestEntityBuilder.java @@ -0,0 +1,153 @@ +package io.ebean.test; + +import io.ebean.Database; +import io.ebean.bean.EntityBean; +import io.ebeaninternal.server.deploy.BeanDescriptor; +import io.ebeaninternal.server.deploy.BeanProperty; +import io.ebeaninternal.server.deploy.BeanPropertyAssocMany; +import io.ebeaninternal.server.deploy.BeanPropertyAssocOne; + +import java.util.HashSet; +import java.util.Set; + +import static java.util.Objects.requireNonNull; + +/** + * Builds entity bean instances with randomly populated scalar fields for use in tests. + *

+ * {@code @Id} and {@code @Version} properties are left at their defaults (zero/null). + * {@code @ManyToOne} and {@code @OneToOne} relationships that have cascade persist are + * recursively built and set. Collection relationships ({@code @OneToMany}, + * {@code @ManyToMany}) are left empty — the caller can populate them if needed. + *

+ *
{@code
+ *   TestEntityBuilder builder = TestEntityBuilder.builder(DB.getDefault()).build();
+ *
+ *   // build in-memory (not saved to database)
+ *   MyEntity entity = builder.build(MyEntity.class);
+ *
+ *   // build and insert to the database
+ *   MyEntity entity = builder.save(MyEntity.class);
+ *
+ *   // supply a custom value generator for domain-specific values
+ *   TestEntityBuilder builder = TestEntityBuilder.builder(DB.getDefault())
+ *       .valueGenerator(myGenerator)
+ *       .build();
+ * }
+ */ +public class TestEntityBuilder { + + private final Database database; + private final RandomValueGenerator valueGenerator; + + private TestEntityBuilder(Database database, RandomValueGenerator valueGenerator) { + this.database = database; + this.valueGenerator = valueGenerator; + } + + /** Returns a new {@link Builder} for the given database. */ + public static Builder builder(Database database) { + return new Builder(database); + } + + /** Builder for {@link TestEntityBuilder}. */ + public static final class Builder { + + private final Database database; + private RandomValueGenerator valueGenerator; + + private Builder(Database database) { + this.database = requireNonNull(database); + } + + /** Override the default {@link RandomValueGenerator}, e.g. for domain-specific value generation. */ + public Builder valueGenerator(RandomValueGenerator valueGenerator) { + this.valueGenerator = valueGenerator; + return this; + } + + /** Build and return a {@link TestEntityBuilder}. */ + public TestEntityBuilder build() { + if (valueGenerator == null) { + valueGenerator = new RandomValueGenerator(); + } + return new TestEntityBuilder(database, valueGenerator); + } + } + + /** + * Build and return an instance of the entity class with scalar fields populated + * with random values. The entity is not saved to the database. + * + * @param beanClass the entity class to build + * @throws IllegalArgumentException if the class is not a known Ebean entity + */ + public T build(Class beanClass) { + return build(beanClass, new HashSet<>()); + } + + /** + * Build an instance of the entity class, insert it to the database, and return it. + * + * @param beanClass the entity class to build and save + * @throws IllegalArgumentException if the class is not a known Ebean entity + */ + public T save(Class beanClass) { + T bean = build(beanClass); + database.save(bean); + return bean; + } + + private T build(Class beanClass, Set> buildStack) { + BeanDescriptor descriptor = (BeanDescriptor) database.pluginApi().beanType(beanClass); + if (descriptor == null) { + throw new IllegalArgumentException("No BeanDescriptor found for " + beanClass.getName() + + " — is it an @Entity registered with this Database?"); + } + + Set importedSaveNames = importedSavePropertyNames(descriptor); + + T bean = descriptor.createBean(); + buildStack.add(beanClass); + try { + for (BeanProperty prop : descriptor.propertiesAll()) { + if (prop.isId() || prop.isVersion() || prop.isGenerated() || prop.isTransient()) { + continue; + } + if (prop instanceof BeanPropertyAssocMany) { + // leave collections empty — caller populates if needed + continue; + } + if (prop instanceof BeanPropertyAssocOne) { + if (importedSaveNames.contains(prop.name())) { + BeanPropertyAssocOne assocOne = (BeanPropertyAssocOne) prop; + Class targetType = assocOne.targetType(); + if (!buildStack.contains(targetType)) { + Object related = build(targetType, buildStack); + prop.setValue((EntityBean) bean, related); + } + // else: cycle detected — leave the reference null + } + // non-cascade-save association — leave null + continue; + } + // scalar property + Object value = valueGenerator.generate(prop); + if (value != null) { + prop.setValue((EntityBean) bean, value); + } + } + } finally { + buildStack.remove(beanClass); + } + return bean; + } + + private Set importedSavePropertyNames(BeanDescriptor descriptor) { + Set names = new HashSet<>(); + for (BeanPropertyAssocOne p : descriptor.propertiesOneImportedSave()) { + names.add(p.name()); + } + return names; + } +} diff --git a/ebean-test/src/test/java/io/ebean/test/RandomValueGeneratorTest.java b/ebean-test/src/test/java/io/ebean/test/RandomValueGeneratorTest.java new file mode 100644 index 0000000000..b62dffe5fb --- /dev/null +++ b/ebean-test/src/test/java/io/ebean/test/RandomValueGeneratorTest.java @@ -0,0 +1,105 @@ +package io.ebean.test; + +import io.ebean.DB; +import io.ebean.xtest.BaseTestCase; +import io.ebeaninternal.server.deploy.BeanDescriptor; +import io.ebeaninternal.server.deploy.BeanProperty; +import org.junit.jupiter.api.Test; +import org.tests.model.basic.EBasic; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class RandomValueGeneratorTest extends BaseTestCase { + + private final RandomValueGenerator generator = new RandomValueGenerator(); + + @SuppressWarnings("unchecked") + private BeanDescriptor descriptor(Class cls) { + return (BeanDescriptor) DB.getDefault().pluginApi().beanType(cls); + } + + @Test + void generate_stringType_returnsEightCharString() { + Object value = generator.generate(String.class); + + assertThat(value).isInstanceOf(String.class); + assertThat((String) value).hasSize(8); + } + + @Test + void generate_stringPropWithLength_cappedAtDbLength() { + BeanDescriptor descriptor = descriptor(EBasic.class); + BeanProperty nameProp = descriptor.findProperty("name"); + + assertThat(nameProp.dbLength()).isEqualTo(127); + Object value = generator.generate(nameProp); + + assertThat(value).isInstanceOf(String.class); + assertThat((String) value).hasSizeLessThanOrEqualTo(127); + assertThat((String) value).isNotEmpty(); + } + + @Test + void generate_stringPropWithNoLength_returnsEightCharString() { + BeanDescriptor descriptor = descriptor(EBasic.class); + BeanProperty descProp = descriptor.findProperty("description"); + + // description has no @Size annotation — dbLength is 0 (unlimited) + Object value = generator.generate(descProp); + + assertThat(value).isInstanceOf(String.class); + assertThat((String) value).hasSize(8); + } + + @Test + void generate_variousScalarTypes_returnsExpectedTypes() { + assertThat(generator.generate(Long.class)).isInstanceOf(Long.class); + assertThat(generator.generate(long.class)).isInstanceOf(Long.class); + assertThat(generator.generate(Integer.class)).isInstanceOf(Integer.class); + assertThat(generator.generate(int.class)).isInstanceOf(Integer.class); + assertThat(generator.generate(Short.class)).isInstanceOf(Short.class); + assertThat(generator.generate(short.class)).isInstanceOf(Short.class); + assertThat(generator.generate(Boolean.class)).isEqualTo(Boolean.TRUE); + assertThat(generator.generate(boolean.class)).isEqualTo(Boolean.TRUE); + assertThat(generator.generate(UUID.class)).isInstanceOf(UUID.class); + assertThat(generator.generate(Instant.class)).isInstanceOf(Instant.class); + assertThat(generator.generate(OffsetDateTime.class)).isInstanceOf(OffsetDateTime.class); + assertThat(generator.generate(LocalDate.class)).isInstanceOf(LocalDate.class); + assertThat(generator.generate(LocalDateTime.class)).isInstanceOf(LocalDateTime.class); + assertThat(generator.generate(BigDecimal.class)).isInstanceOf(BigDecimal.class); + assertThat(generator.generate(Double.class)).isInstanceOf(Double.class); + assertThat(generator.generate(Float.class)).isInstanceOf(Float.class); + } + + @Test + void generate_enumType_returnsFirstConstant() { + Object value = generator.generate(EBasic.Status.class); + + assertThat(value).isEqualTo(EBasic.Status.NEW); + } + + @Test + void generate_unknownType_returnsNull() { + assertThat(generator.generate(Object.class)).isNull(); + assertThat(generator.generate((Class) null)).isNull(); + } + + @Test + void generate_stringPropWithLengthShorterThan32_truncatesCorrectly() { + BeanDescriptor descriptor = descriptor(EBasic.class); + BeanProperty nameProp = descriptor.findProperty("name"); + + // Run many times to verify no value ever exceeds the limit + for (int i = 0; i < 20; i++) { + Object value = generator.generate(nameProp); + assertThat((String) value).hasSizeLessThanOrEqualTo(127); + } + } +} diff --git a/ebean-test/src/test/java/io/ebean/test/TestEntityBuilderTest.java b/ebean-test/src/test/java/io/ebean/test/TestEntityBuilderTest.java new file mode 100644 index 0000000000..47a0bf9ba4 --- /dev/null +++ b/ebean-test/src/test/java/io/ebean/test/TestEntityBuilderTest.java @@ -0,0 +1,86 @@ +package io.ebean.test; + +import io.ebean.DB; +import io.ebean.xtest.BaseTestCase; +import org.junit.jupiter.api.Test; +import org.tests.model.basic.EBasic; +import org.tests.model.basic.UUOne; +import org.tests.model.basic.UUTwo; + +import static org.assertj.core.api.Assertions.assertThat; + +class TestEntityBuilderTest extends BaseTestCase { + + private final TestEntityBuilder builder = TestEntityBuilder.builder(DB.getDefault()).build(); + + @Test + void build_simpleEntity_populatesScalarFields() { + EBasic bean = builder.build(EBasic.class); + + assertThat(bean).isNotNull(); + assertThat(bean.getId()).isNull(); // @Id — not populated + assertThat(bean.getName()).isNotNull(); // String scalar — populated + assertThat(bean.getName()).hasSizeLessThanOrEqualTo(127); // @Size(max=127) respected + assertThat(bean.getDescription()).isNotNull(); + assertThat(bean.getStatus()).isNotNull(); // Enum — populated with first constant + assertThat(bean.getStatus()).isEqualTo(EBasic.Status.NEW); + } + + @Test + void build_entityWithCascadeManyToOne_populatesRelationship() { + UUTwo bean = builder.build(UUTwo.class); + + assertThat(bean).isNotNull(); + assertThat(bean.getId()).isNull(); // @Id — not populated + assertThat(bean.getVersion()).isZero(); // @Version — not populated + assertThat(bean.getName()).isNotNull(); + assertThat(bean.getMaster()).isNotNull(); // @ManyToOne(cascade=PERSIST) — recursively built + assertThat(bean.getMaster().getName()).isNotNull(); + assertThat(bean.getMaster().getId()).isNull(); // @Id on UUOne — not populated + assertThat(bean.getMaster().getVersion()).isZero(); // @Version on UUOne — not populated + } + + @Test + void build_calledTwice_producesDistinctInstances() { + EBasic first = builder.build(EBasic.class); + EBasic second = builder.build(EBasic.class); + + assertThat(first).isNotSameAs(second); + // String values should be different random values + assertThat(first.getName()).isNotEqualTo(second.getName()); + } + + @Test + void save_insertsEntityAndReturnsWithId() { + EBasic saved = builder.save(EBasic.class); + + assertThat(saved).isNotNull(); + assertThat(saved.getId()).isNotNull(); // @Id assigned after insert + assertThat(saved.getName()).isNotNull(); + + // verify it's actually in the database + EBasic found = DB.find(EBasic.class, saved.getId()); + assertThat(found).isNotNull(); + assertThat(found.getName()).isEqualTo(saved.getName()); + } + + @Test + void save_entityWithCascadeManyToOne_savesCascades() { + UUTwo saved = builder.save(UUTwo.class); + + assertThat(saved).isNotNull(); + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getMaster()).isNotNull(); + assertThat(saved.getMaster().getId()).isNotNull(); // parent also saved via cascade + + UUOne foundMaster = DB.find(UUOne.class, saved.getMaster().getId()); + assertThat(foundMaster).isNotNull(); + } + + @Test + void build_unknownClass_throwsIllegalArgumentException() { + org.assertj.core.api.Assertions.assertThatThrownBy(() -> builder.build(String.class)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("No BeanDescriptor found"); + } +} From 39e1d3ad39951383eed63947448f281f1544c82d Mon Sep 17 00:00:00 2001 From: "robin.bygrave" Date: Sat, 11 Apr 2026 17:57:14 +1200 Subject: [PATCH 3/4] Improve TestEntityBuilder for emails, BigDecimal precision/scale, protected method allow overriding --- .../io/ebean/test/RandomValueGenerator.java | 157 +++++++++++++++--- .../ebean/test/RandomValueGeneratorTest.java | 141 +++++++++++++++- 2 files changed, 274 insertions(+), 24 deletions(-) diff --git a/ebean-test/src/main/java/io/ebean/test/RandomValueGenerator.java b/ebean-test/src/main/java/io/ebean/test/RandomValueGenerator.java index 3ab61ec000..b121e88c27 100644 --- a/ebean-test/src/main/java/io/ebean/test/RandomValueGenerator.java +++ b/ebean-test/src/main/java/io/ebean/test/RandomValueGenerator.java @@ -3,10 +3,12 @@ import io.ebeaninternal.server.deploy.BeanProperty; import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.OffsetDateTime; +import java.time.ZonedDateTime; import java.util.UUID; import java.util.concurrent.ThreadLocalRandom; @@ -19,6 +21,12 @@ * alone and is useful when no property metadata is available. *

*

+ * All per-type factory methods ({@link #randomString(String, int)}, + * {@link #randomBigDecimal(int, int)}, {@link #randomLong()}, etc.) are + * {@code protected} so that subclasses can override individual types without + * replacing the full dispatch logic. + *

+ *

* Returns {@code null} for types that are not mapped (exotic / unknown types) — * the caller is expected to set those fields manually. *

@@ -30,12 +38,17 @@ public class RandomValueGenerator { *

* For {@code String} properties, the value is capped at the column's * {@link BeanProperty#dbLength()} when that length is positive. + * For {@code BigDecimal} properties, precision and scale from the column + * definition are used. *

*/ public Object generate(BeanProperty prop) { Class type = prop.type(); if (type == String.class) { - return randomString(prop.dbLength()); + return randomString(prop.name(), prop.dbLength()); + } + if (type == BigDecimal.class) { + return randomBigDecimal(prop.dbLength(), prop.dbScale()); } return generate(type); } @@ -50,40 +63,140 @@ public Object generate(BeanProperty prop) { @SuppressWarnings({"unchecked", "rawtypes"}) public Object generate(Class type) { if (type == null) return null; - if (type == String.class) return randomString(0); - if (type == Long.class || type == long.class) return ThreadLocalRandom.current().nextLong(1, 100_000L); - if (type == Integer.class || type == int.class) return ThreadLocalRandom.current().nextInt(1, 1_000); - if (type == Short.class || type == short.class) return (short) ThreadLocalRandom.current().nextInt(1, 100); - if (type == Boolean.class || type == boolean.class) return Boolean.TRUE; - if (type == UUID.class) return UUID.randomUUID(); - if (type == Instant.class) return Instant.now(); - if (type == OffsetDateTime.class) return OffsetDateTime.now(); - if (type == LocalDate.class) return LocalDate.now(); - if (type == LocalDateTime.class) return LocalDateTime.now(); - if (type == BigDecimal.class) return BigDecimal.valueOf(ThreadLocalRandom.current().nextDouble(1, 100)); - if (type == Double.class || type == double.class) return ThreadLocalRandom.current().nextDouble(1, 100); - if (type == Float.class || type == float.class) return (float) ThreadLocalRandom.current().nextDouble(1, 100); - if (type.isEnum()) { - Object[] constants = type.getEnumConstants(); - return constants.length > 0 ? constants[0] : null; - } + if (type == String.class) return randomString(null, 0); + if (type == Boolean.class || type == boolean.class) return randomBoolean(); + if (type == UUID.class) return randomUUID(); + if (type == Instant.class) return randomInstant(); + if (type == OffsetDateTime.class) return randomOffsetDateTime(); + if (type == ZonedDateTime.class) return randomZonedDateTime(); + if (type == LocalDate.class) return randomLocalDate(); + if (type == LocalDateTime.class) return randomLocalDateTime(); + if (type == Long.class || type == long.class) return randomLong(); + if (type == Integer.class || type == int.class) return randomInt(); + if (type == Short.class || type == short.class) return randomShort(); + if (type == BigDecimal.class) return randomBigDecimal(0, -1); + if (type == Double.class || type == double.class) return randomDouble(); + if (type == Float.class || type == float.class) return randomFloat(); + if (type.isEnum()) return randomEnum(type); return null; // exotic/unknown type — caller must set this field manually } + /** Return a random {@code long} in [1, 100_000). */ + protected long randomLong() { + return ThreadLocalRandom.current().nextLong(1, 100_000L); + } + + /** Return a random {@code int} in [1, 1_000). */ + protected int randomInt() { + return ThreadLocalRandom.current().nextInt(1, 1_000); + } + + /** Return a random {@code short} in [1, 100). */ + protected short randomShort() { + return (short) ThreadLocalRandom.current().nextInt(1, 100); + } + + /** Return a random {@code boolean} — defaults to {@code true}. */ + protected boolean randomBoolean() { + return true; + } + + /** Return a random {@link UUID}. */ + protected UUID randomUUID() { + return UUID.randomUUID(); + } + + /** Return a random {@link Instant} — defaults to now. */ + protected Instant randomInstant() { + return Instant.now(); + } + + /** Return a random {@link OffsetDateTime} — defaults to now. */ + protected OffsetDateTime randomOffsetDateTime() { + return OffsetDateTime.now(); + } + + /** Return a random {@link ZonedDateTime} — defaults to now. */ + protected ZonedDateTime randomZonedDateTime() { + return ZonedDateTime.now(); + } + + /** Return a random {@link LocalDate} — defaults to today. */ + protected LocalDate randomLocalDate() { + return LocalDate.now(); + } + + /** Return a random {@link LocalDateTime} — defaults to now. */ + protected LocalDateTime randomLocalDateTime() { + return LocalDateTime.now(); + } + + /** Return a random {@code double} in [1, 100). */ + protected double randomDouble() { + return ThreadLocalRandom.current().nextDouble(1, 100); + } + + /** Return a random {@code float} in [1, 100). */ + protected float randomFloat() { + return (float) ThreadLocalRandom.current().nextDouble(1, 100); + } + + /** + * Return a value for the given enum type — defaults to the first declared constant. + * Returns {@code null} if the enum has no constants. + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + protected Object randomEnum(Class type) { + Object[] constants = type.getEnumConstants(); + return constants.length > 0 ? constants[0] : null; + } + /** * Generate a random string, optionally capped at {@code maxLength}. *

- * When {@code maxLength} is 0 or negative (unknown / unlimited), an 8-character - * UUID-derived string is returned. Otherwise the value is a UUID-derived string - * truncated to {@code maxLength} characters. + * When the property name contains "email" (case-insensitive), a value of the + * form {@code @domain.com} is returned, truncated to fit {@code maxLength}. + * Otherwise a UUID-derived string is returned, truncated to {@code maxLength} + * (defaulting to 8 characters when {@code maxLength} is 0 or negative). *

*/ - private String randomString(int maxLength) { + protected String randomString(String propName, int maxLength) { String base = UUID.randomUUID().toString().replace("-", ""); // 32 chars + if (propName != null && propName.toLowerCase().contains("email")) { + String email = base.substring(0, 8) + "@domain.com"; + if (maxLength > 0 && email.length() > maxLength) { + int localLen = maxLength - "@domain.com".length(); + email = (localLen > 0 ? base.substring(0, localLen) : base.substring(0, 1)) + "@domain.com"; + } + return email; + } if (maxLength <= 0) { return base.substring(0, 8); } int len = Math.min(maxLength, base.length()); return base.substring(0, len); } + + /** + * Generate a random {@link BigDecimal} that fits within the given precision and scale. + *

+ * {@code precision} is the total number of significant digits ({@code dbLength}); + * {@code scale} is the number of decimal places ({@code dbScale}). + * When precision is 0 or negative (unknown), a default of 6 integer digits is used. + * When scale is negative (unknown), a default scale of 2 is used. + *

+ */ + protected BigDecimal randomBigDecimal(int precision, int scale) { + int actualScale = scale >= 0 ? scale : 2; + int intDigits = precision > 0 ? Math.max(1, precision - actualScale) : 6; + long maxInt = (long) Math.pow(10, intDigits) - 1; + long intPart = ThreadLocalRandom.current().nextLong(1, maxInt + 1); + if (actualScale == 0) { + return BigDecimal.valueOf(intPart); + } + long scaleFactor = (long) Math.pow(10, actualScale); + long fracPart = ThreadLocalRandom.current().nextLong(0, scaleFactor); + double value = intPart + (double) fracPart / scaleFactor; + return BigDecimal.valueOf(value).setScale(actualScale, RoundingMode.HALF_UP); + } } diff --git a/ebean-test/src/test/java/io/ebean/test/RandomValueGeneratorTest.java b/ebean-test/src/test/java/io/ebean/test/RandomValueGeneratorTest.java index b62dffe5fb..b3358baaec 100644 --- a/ebean-test/src/test/java/io/ebean/test/RandomValueGeneratorTest.java +++ b/ebean-test/src/test/java/io/ebean/test/RandomValueGeneratorTest.java @@ -5,6 +5,7 @@ import io.ebeaninternal.server.deploy.BeanDescriptor; import io.ebeaninternal.server.deploy.BeanProperty; import org.junit.jupiter.api.Test; +import org.multitenant.partition.MtTenant; import org.tests.model.basic.EBasic; import java.math.BigDecimal; @@ -12,6 +13,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.OffsetDateTime; +import java.time.ZonedDateTime; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; @@ -20,7 +22,6 @@ class RandomValueGeneratorTest extends BaseTestCase { private final RandomValueGenerator generator = new RandomValueGenerator(); - @SuppressWarnings("unchecked") private BeanDescriptor descriptor(Class cls) { return (BeanDescriptor) DB.getDefault().pluginApi().beanType(cls); } @@ -71,6 +72,7 @@ void generate_variousScalarTypes_returnsExpectedTypes() { assertThat(generator.generate(UUID.class)).isInstanceOf(UUID.class); assertThat(generator.generate(Instant.class)).isInstanceOf(Instant.class); assertThat(generator.generate(OffsetDateTime.class)).isInstanceOf(OffsetDateTime.class); + assertThat(generator.generate(ZonedDateTime.class)).isInstanceOf(ZonedDateTime.class); assertThat(generator.generate(LocalDate.class)).isInstanceOf(LocalDate.class); assertThat(generator.generate(LocalDateTime.class)).isInstanceOf(LocalDateTime.class); assertThat(generator.generate(BigDecimal.class)).isInstanceOf(BigDecimal.class); @@ -81,7 +83,6 @@ void generate_variousScalarTypes_returnsExpectedTypes() { @Test void generate_enumType_returnsFirstConstant() { Object value = generator.generate(EBasic.Status.class); - assertThat(value).isEqualTo(EBasic.Status.NEW); } @@ -91,6 +92,30 @@ void generate_unknownType_returnsNull() { assertThat(generator.generate((Class) null)).isNull(); } + @Test + void generate_emailPropName_returnsEmailAddress() { + BeanDescriptor descriptor = descriptor(MtTenant.class); + BeanProperty emailProp = descriptor.findProperty("email"); + + assertThat(emailProp.dbLength()).isEqualTo(50); + Object value = generator.generate(emailProp); + + assertThat(value).isInstanceOf(String.class); + assertThat((String) value).contains("@domain.com"); + assertThat((String) value).hasSizeLessThanOrEqualTo(50); + } + + @Test + void generate_bigDecimalType_returnsScaledValue() { + Object value = generator.generate(BigDecimal.class); + + assertThat(value).isInstanceOf(BigDecimal.class); + BigDecimal decimal = (BigDecimal) value; + assertThat(decimal.scale()).isEqualTo(2); + assertThat(decimal).isGreaterThan(BigDecimal.ZERO); + } + + @Test void generate_stringPropWithLengthShorterThan32_truncatesCorrectly() { BeanDescriptor descriptor = descriptor(EBasic.class); @@ -102,4 +127,116 @@ void generate_stringPropWithLengthShorterThan32_truncatesCorrectly() { assertThat((String) value).hasSizeLessThanOrEqualTo(127); } } + + // --- direct protected-method tests (no database needed) --- + + @Test + void randomLong_isPositiveAndInRange() { + long v = generator.randomLong(); + assertThat(v).isBetween(1L, 100_000L); + } + + @Test + void randomInt_isPositiveAndInRange() { + int v = generator.randomInt(); + assertThat(v).isBetween(1, 999); + } + + @Test + void randomShort_isPositiveAndInRange() { + short v = generator.randomShort(); + assertThat((int) v).isBetween(1, 99); + } + + @Test + void randomBoolean_returnsTrue() { + assertThat(generator.randomBoolean()).isTrue(); + } + + @Test + void randomUUID_returnsValidUUID() { + UUID v = generator.randomUUID(); + assertThat(v).isNotNull(); + assertThat(v.toString()).hasSize(36); + } + + @Test + void randomInstant_returnsInstant() { + assertThat(generator.randomInstant()).isInstanceOf(Instant.class); + } + + @Test + void randomOffsetDateTime_returnsOffsetDateTime() { + assertThat(generator.randomOffsetDateTime()).isInstanceOf(OffsetDateTime.class); + } + + @Test + void randomZonedDateTime_returnsZonedDateTime() { + assertThat(generator.randomZonedDateTime()).isInstanceOf(ZonedDateTime.class); + } + + @Test + void randomLocalDate_returnsLocalDate() { + assertThat(generator.randomLocalDate()).isInstanceOf(LocalDate.class); + } + + @Test + void randomLocalDateTime_returnsLocalDateTime() { + assertThat(generator.randomLocalDateTime()).isInstanceOf(LocalDateTime.class); + } + + @Test + void randomDouble_isPositiveAndInRange() { + double v = generator.randomDouble(); + assertThat(v).isBetween(1.0, 100.0); + } + + @Test + void randomFloat_isPositiveAndInRange() { + float v = generator.randomFloat(); + assertThat((double) v).isBetween(1.0, 100.0); + } + + @Test + void randomBigDecimal_defaultPrecisionAndScale_scaleIsTwo() { + BigDecimal v = generator.randomBigDecimal(0, -1); + assertThat(v.scale()).isEqualTo(2); + assertThat(v).isGreaterThan(BigDecimal.ZERO); + } + + @Test + void randomBigDecimal_explicitPrecisionAndScale_fitsWithinBounds() { + // DECIMAL(8,3) → max integer part 99999, scale 3 + BigDecimal v = generator.randomBigDecimal(8, 3); + assertThat(v.scale()).isEqualTo(3); + assertThat(v.precision()).isLessThanOrEqualTo(8); + assertThat(v).isGreaterThan(BigDecimal.ZERO); + } + + @Test + void randomString_emailPropName_containsDomainSuffix() { + String v = generator.randomString("emailAddress", 0); + assertThat(v).endsWith("@domain.com"); + } + + @Test + void randomString_regularPropName_returnsEightChars() { + String v = generator.randomString("name", 0); + assertThat(v).hasSize(8); + } + + @Test + void randomString_withMaxLength_isTruncated() { + String v = generator.randomString("description", 5); + assertThat(v).hasSize(5); + } + + @Test + void subclassCanOverrideRandomLong() { + RandomValueGenerator fixed = new RandomValueGenerator() { + @Override + protected long randomLong() { return 42L; } + }; + assertThat(fixed.generate(Long.class)).isEqualTo(42L); + } } From e5403c818c9c9dc6457507fd1d336fb29b30213e Mon Sep 17 00:00:00 2001 From: "robin.bygrave" Date: Sat, 11 Apr 2026 18:14:28 +1200 Subject: [PATCH 4/4] Improve TestEntityBuilder for emails, use PersonOther --- .../test/java/io/ebean/test/RandomValueGeneratorTest.java | 8 ++++---- .../test/java/org/tests/cache/personinfo/PersonOther.java | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/ebean-test/src/test/java/io/ebean/test/RandomValueGeneratorTest.java b/ebean-test/src/test/java/io/ebean/test/RandomValueGeneratorTest.java index b3358baaec..a17cd459b3 100644 --- a/ebean-test/src/test/java/io/ebean/test/RandomValueGeneratorTest.java +++ b/ebean-test/src/test/java/io/ebean/test/RandomValueGeneratorTest.java @@ -5,7 +5,7 @@ import io.ebeaninternal.server.deploy.BeanDescriptor; import io.ebeaninternal.server.deploy.BeanProperty; import org.junit.jupiter.api.Test; -import org.multitenant.partition.MtTenant; +import org.tests.cache.personinfo.PersonOther; import org.tests.model.basic.EBasic; import java.math.BigDecimal; @@ -94,15 +94,15 @@ void generate_unknownType_returnsNull() { @Test void generate_emailPropName_returnsEmailAddress() { - BeanDescriptor descriptor = descriptor(MtTenant.class); + BeanDescriptor descriptor = descriptor(PersonOther.class); BeanProperty emailProp = descriptor.findProperty("email"); - assertThat(emailProp.dbLength()).isEqualTo(50); + assertThat(emailProp.dbLength()).isEqualTo(60); Object value = generator.generate(emailProp); assertThat(value).isInstanceOf(String.class); assertThat((String) value).contains("@domain.com"); - assertThat((String) value).hasSizeLessThanOrEqualTo(50); + assertThat((String) value).hasSizeLessThanOrEqualTo(60); } @Test diff --git a/ebean-test/src/test/java/org/tests/cache/personinfo/PersonOther.java b/ebean-test/src/test/java/org/tests/cache/personinfo/PersonOther.java index 6ec0569c8f..1d15779a81 100644 --- a/ebean-test/src/test/java/org/tests/cache/personinfo/PersonOther.java +++ b/ebean-test/src/test/java/org/tests/cache/personinfo/PersonOther.java @@ -3,6 +3,7 @@ import io.ebean.annotation.WhenCreated; import io.ebean.annotation.WhenModified; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Version; @@ -16,6 +17,7 @@ public class PersonOther { @Size(max=128) private String id; + @Column(length = 60) private String email; @WhenCreated