Skip to content

Commit 794f933

Browse files
authored
Feat: Add test entity builder, ease creation of test entity instances populated with random values (#3747)
* Docs: modify guides README with links to the available guides * Add TestEntityBuilder for building test entity instances populated by random values * Improve TestEntityBuilder for emails, BigDecimal precision/scale, protected method allow overriding * Improve TestEntityBuilder for emails, use PersonOther
1 parent c8a7a26 commit 794f933

6 files changed

Lines changed: 692 additions & 0 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ Goto [https://ebean.io/docs/](https://ebean.io/docs/)
8484
## Guides
8585
Step-by-step guides for common tasks: [docs/guides/](docs/guides/README.md)
8686

87+
Available guides:
88+
- [Maven POM setup](docs/guides/add-ebean-postgres-maven-pom.md)
89+
- [Database configuration](docs/guides/add-ebean-postgres-database-config.md)
90+
- [Test container setup](docs/guides/add-ebean-postgres-test-container.md)
91+
- [DB migration generation](docs/guides/add-ebean-db-migration-generation.md)
92+
- [Lombok with Ebean entity beans](docs/guides/lombok-with-ebean-entity-beans.md)
93+
8794
## Maven central
8895
[Maven central - g:io.ebean](http://search.maven.org/#search%7Cgav%7C1%7Cg%3A%22io.ebean%22%20)
8996

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
package io.ebean.test;
2+
3+
import io.ebeaninternal.server.deploy.BeanProperty;
4+
5+
import java.math.BigDecimal;
6+
import java.math.RoundingMode;
7+
import java.time.Instant;
8+
import java.time.LocalDate;
9+
import java.time.LocalDateTime;
10+
import java.time.OffsetDateTime;
11+
import java.time.ZonedDateTime;
12+
import java.util.UUID;
13+
import java.util.concurrent.ThreadLocalRandom;
14+
15+
/**
16+
* Generates random values for entity bean properties in tests.
17+
* <p>
18+
* The primary entry point is {@link #generate(BeanProperty)}, which is
19+
* property-aware (e.g. caps String values at the column's max length).
20+
* The secondary entry point {@link #generate(Class)} works on the Java type
21+
* alone and is useful when no property metadata is available.
22+
* </p>
23+
* <p>
24+
* All per-type factory methods ({@link #randomString(String, int)},
25+
* {@link #randomBigDecimal(int, int)}, {@link #randomLong()}, etc.) are
26+
* {@code protected} so that subclasses can override individual types without
27+
* replacing the full dispatch logic.
28+
* </p>
29+
* <p>
30+
* Returns {@code null} for types that are not mapped (exotic / unknown types) —
31+
* the caller is expected to set those fields manually.
32+
* </p>
33+
*/
34+
public class RandomValueGenerator {
35+
36+
/**
37+
* Generate a random value for the given bean property.
38+
* <p>
39+
* For {@code String} properties, the value is capped at the column's
40+
* {@link BeanProperty#dbLength()} when that length is positive.
41+
* For {@code BigDecimal} properties, precision and scale from the column
42+
* definition are used.
43+
* </p>
44+
*/
45+
public Object generate(BeanProperty prop) {
46+
Class<?> type = prop.type();
47+
if (type == String.class) {
48+
return randomString(prop.name(), prop.dbLength());
49+
}
50+
if (type == BigDecimal.class) {
51+
return randomBigDecimal(prop.dbLength(), prop.dbScale());
52+
}
53+
return generate(type);
54+
}
55+
56+
/**
57+
* Generate a random value for the given Java type, without property metadata.
58+
* <p>
59+
* String values produced here use a fixed 8-character length. Use
60+
* {@link #generate(BeanProperty)} when column-length constraints matter.
61+
* </p>
62+
*/
63+
@SuppressWarnings({"unchecked", "rawtypes"})
64+
public Object generate(Class<?> type) {
65+
if (type == null) return null;
66+
if (type == String.class) return randomString(null, 0);
67+
if (type == Boolean.class || type == boolean.class) return randomBoolean();
68+
if (type == UUID.class) return randomUUID();
69+
if (type == Instant.class) return randomInstant();
70+
if (type == OffsetDateTime.class) return randomOffsetDateTime();
71+
if (type == ZonedDateTime.class) return randomZonedDateTime();
72+
if (type == LocalDate.class) return randomLocalDate();
73+
if (type == LocalDateTime.class) return randomLocalDateTime();
74+
if (type == Long.class || type == long.class) return randomLong();
75+
if (type == Integer.class || type == int.class) return randomInt();
76+
if (type == Short.class || type == short.class) return randomShort();
77+
if (type == BigDecimal.class) return randomBigDecimal(0, -1);
78+
if (type == Double.class || type == double.class) return randomDouble();
79+
if (type == Float.class || type == float.class) return randomFloat();
80+
if (type.isEnum()) return randomEnum(type);
81+
return null; // exotic/unknown type — caller must set this field manually
82+
}
83+
84+
/** Return a random {@code long} in [1, 100_000). */
85+
protected long randomLong() {
86+
return ThreadLocalRandom.current().nextLong(1, 100_000L);
87+
}
88+
89+
/** Return a random {@code int} in [1, 1_000). */
90+
protected int randomInt() {
91+
return ThreadLocalRandom.current().nextInt(1, 1_000);
92+
}
93+
94+
/** Return a random {@code short} in [1, 100). */
95+
protected short randomShort() {
96+
return (short) ThreadLocalRandom.current().nextInt(1, 100);
97+
}
98+
99+
/** Return a random {@code boolean} — defaults to {@code true}. */
100+
protected boolean randomBoolean() {
101+
return true;
102+
}
103+
104+
/** Return a random {@link UUID}. */
105+
protected UUID randomUUID() {
106+
return UUID.randomUUID();
107+
}
108+
109+
/** Return a random {@link Instant} — defaults to now. */
110+
protected Instant randomInstant() {
111+
return Instant.now();
112+
}
113+
114+
/** Return a random {@link OffsetDateTime} — defaults to now. */
115+
protected OffsetDateTime randomOffsetDateTime() {
116+
return OffsetDateTime.now();
117+
}
118+
119+
/** Return a random {@link ZonedDateTime} — defaults to now. */
120+
protected ZonedDateTime randomZonedDateTime() {
121+
return ZonedDateTime.now();
122+
}
123+
124+
/** Return a random {@link LocalDate} — defaults to today. */
125+
protected LocalDate randomLocalDate() {
126+
return LocalDate.now();
127+
}
128+
129+
/** Return a random {@link LocalDateTime} — defaults to now. */
130+
protected LocalDateTime randomLocalDateTime() {
131+
return LocalDateTime.now();
132+
}
133+
134+
/** Return a random {@code double} in [1, 100). */
135+
protected double randomDouble() {
136+
return ThreadLocalRandom.current().nextDouble(1, 100);
137+
}
138+
139+
/** Return a random {@code float} in [1, 100). */
140+
protected float randomFloat() {
141+
return (float) ThreadLocalRandom.current().nextDouble(1, 100);
142+
}
143+
144+
/**
145+
* Return a value for the given enum type — defaults to the first declared constant.
146+
* Returns {@code null} if the enum has no constants.
147+
*/
148+
@SuppressWarnings({"unchecked", "rawtypes"})
149+
protected Object randomEnum(Class<?> type) {
150+
Object[] constants = type.getEnumConstants();
151+
return constants.length > 0 ? constants[0] : null;
152+
}
153+
154+
/**
155+
* Generate a random string, optionally capped at {@code maxLength}.
156+
* <p>
157+
* When the property name contains "email" (case-insensitive), a value of the
158+
* form {@code <prefix>@domain.com} is returned, truncated to fit {@code maxLength}.
159+
* Otherwise a UUID-derived string is returned, truncated to {@code maxLength}
160+
* (defaulting to 8 characters when {@code maxLength} is 0 or negative).
161+
* </p>
162+
*/
163+
protected String randomString(String propName, int maxLength) {
164+
String base = UUID.randomUUID().toString().replace("-", ""); // 32 chars
165+
if (propName != null && propName.toLowerCase().contains("email")) {
166+
String email = base.substring(0, 8) + "@domain.com";
167+
if (maxLength > 0 && email.length() > maxLength) {
168+
int localLen = maxLength - "@domain.com".length();
169+
email = (localLen > 0 ? base.substring(0, localLen) : base.substring(0, 1)) + "@domain.com";
170+
}
171+
return email;
172+
}
173+
if (maxLength <= 0) {
174+
return base.substring(0, 8);
175+
}
176+
int len = Math.min(maxLength, base.length());
177+
return base.substring(0, len);
178+
}
179+
180+
/**
181+
* Generate a random {@link BigDecimal} that fits within the given precision and scale.
182+
* <p>
183+
* {@code precision} is the total number of significant digits ({@code dbLength});
184+
* {@code scale} is the number of decimal places ({@code dbScale}).
185+
* When precision is 0 or negative (unknown), a default of 6 integer digits is used.
186+
* When scale is negative (unknown), a default scale of 2 is used.
187+
* </p>
188+
*/
189+
protected BigDecimal randomBigDecimal(int precision, int scale) {
190+
int actualScale = scale >= 0 ? scale : 2;
191+
int intDigits = precision > 0 ? Math.max(1, precision - actualScale) : 6;
192+
long maxInt = (long) Math.pow(10, intDigits) - 1;
193+
long intPart = ThreadLocalRandom.current().nextLong(1, maxInt + 1);
194+
if (actualScale == 0) {
195+
return BigDecimal.valueOf(intPart);
196+
}
197+
long scaleFactor = (long) Math.pow(10, actualScale);
198+
long fracPart = ThreadLocalRandom.current().nextLong(0, scaleFactor);
199+
double value = intPart + (double) fracPart / scaleFactor;
200+
return BigDecimal.valueOf(value).setScale(actualScale, RoundingMode.HALF_UP);
201+
}
202+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package io.ebean.test;
2+
3+
import io.ebean.Database;
4+
import io.ebean.bean.EntityBean;
5+
import io.ebeaninternal.server.deploy.BeanDescriptor;
6+
import io.ebeaninternal.server.deploy.BeanProperty;
7+
import io.ebeaninternal.server.deploy.BeanPropertyAssocMany;
8+
import io.ebeaninternal.server.deploy.BeanPropertyAssocOne;
9+
10+
import java.util.HashSet;
11+
import java.util.Set;
12+
13+
import static java.util.Objects.requireNonNull;
14+
15+
/**
16+
* Builds entity bean instances with randomly populated scalar fields for use in tests.
17+
* <p>
18+
* {@code @Id} and {@code @Version} properties are left at their defaults (zero/null).
19+
* {@code @ManyToOne} and {@code @OneToOne} relationships that have cascade persist are
20+
* recursively built and set. Collection relationships ({@code @OneToMany},
21+
* {@code @ManyToMany}) are left empty — the caller can populate them if needed.
22+
* </p>
23+
* <pre>{@code
24+
* TestEntityBuilder builder = TestEntityBuilder.builder(DB.getDefault()).build();
25+
*
26+
* // build in-memory (not saved to database)
27+
* MyEntity entity = builder.build(MyEntity.class);
28+
*
29+
* // build and insert to the database
30+
* MyEntity entity = builder.save(MyEntity.class);
31+
*
32+
* // supply a custom value generator for domain-specific values
33+
* TestEntityBuilder builder = TestEntityBuilder.builder(DB.getDefault())
34+
* .valueGenerator(myGenerator)
35+
* .build();
36+
* }</pre>
37+
*/
38+
public class TestEntityBuilder {
39+
40+
private final Database database;
41+
private final RandomValueGenerator valueGenerator;
42+
43+
private TestEntityBuilder(Database database, RandomValueGenerator valueGenerator) {
44+
this.database = database;
45+
this.valueGenerator = valueGenerator;
46+
}
47+
48+
/** Returns a new {@link Builder} for the given database. */
49+
public static Builder builder(Database database) {
50+
return new Builder(database);
51+
}
52+
53+
/** Builder for {@link TestEntityBuilder}. */
54+
public static final class Builder {
55+
56+
private final Database database;
57+
private RandomValueGenerator valueGenerator;
58+
59+
private Builder(Database database) {
60+
this.database = requireNonNull(database);
61+
}
62+
63+
/** Override the default {@link RandomValueGenerator}, e.g. for domain-specific value generation. */
64+
public Builder valueGenerator(RandomValueGenerator valueGenerator) {
65+
this.valueGenerator = valueGenerator;
66+
return this;
67+
}
68+
69+
/** Build and return a {@link TestEntityBuilder}. */
70+
public TestEntityBuilder build() {
71+
if (valueGenerator == null) {
72+
valueGenerator = new RandomValueGenerator();
73+
}
74+
return new TestEntityBuilder(database, valueGenerator);
75+
}
76+
}
77+
78+
/**
79+
* Build and return an instance of the entity class with scalar fields populated
80+
* with random values. The entity is not saved to the database.
81+
*
82+
* @param beanClass the entity class to build
83+
* @throws IllegalArgumentException if the class is not a known Ebean entity
84+
*/
85+
public <T> T build(Class<T> beanClass) {
86+
return build(beanClass, new HashSet<>());
87+
}
88+
89+
/**
90+
* Build an instance of the entity class, insert it to the database, and return it.
91+
*
92+
* @param beanClass the entity class to build and save
93+
* @throws IllegalArgumentException if the class is not a known Ebean entity
94+
*/
95+
public <T> T save(Class<T> beanClass) {
96+
T bean = build(beanClass);
97+
database.save(bean);
98+
return bean;
99+
}
100+
101+
private <T> T build(Class<T> beanClass, Set<Class<?>> buildStack) {
102+
BeanDescriptor<T> descriptor = (BeanDescriptor<T>) database.pluginApi().beanType(beanClass);
103+
if (descriptor == null) {
104+
throw new IllegalArgumentException("No BeanDescriptor found for " + beanClass.getName()
105+
+ " — is it an @Entity registered with this Database?");
106+
}
107+
108+
Set<String> importedSaveNames = importedSavePropertyNames(descriptor);
109+
110+
T bean = descriptor.createBean();
111+
buildStack.add(beanClass);
112+
try {
113+
for (BeanProperty prop : descriptor.propertiesAll()) {
114+
if (prop.isId() || prop.isVersion() || prop.isGenerated() || prop.isTransient()) {
115+
continue;
116+
}
117+
if (prop instanceof BeanPropertyAssocMany) {
118+
// leave collections empty — caller populates if needed
119+
continue;
120+
}
121+
if (prop instanceof BeanPropertyAssocOne) {
122+
if (importedSaveNames.contains(prop.name())) {
123+
BeanPropertyAssocOne<?> assocOne = (BeanPropertyAssocOne<?>) prop;
124+
Class<?> targetType = assocOne.targetType();
125+
if (!buildStack.contains(targetType)) {
126+
Object related = build(targetType, buildStack);
127+
prop.setValue((EntityBean) bean, related);
128+
}
129+
// else: cycle detected — leave the reference null
130+
}
131+
// non-cascade-save association — leave null
132+
continue;
133+
}
134+
// scalar property
135+
Object value = valueGenerator.generate(prop);
136+
if (value != null) {
137+
prop.setValue((EntityBean) bean, value);
138+
}
139+
}
140+
} finally {
141+
buildStack.remove(beanClass);
142+
}
143+
return bean;
144+
}
145+
146+
private Set<String> importedSavePropertyNames(BeanDescriptor<?> descriptor) {
147+
Set<String> names = new HashSet<>();
148+
for (BeanPropertyAssocOne<?> p : descriptor.propertiesOneImportedSave()) {
149+
names.add(p.name());
150+
}
151+
return names;
152+
}
153+
}

0 commit comments

Comments
 (0)