Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4669307
#2759 Enhance the testing framework to prevent entity ID conflicts
homedirectory Jun 3, 2026
c70b73f
#2759 Lift Cached Mode pre-population orchestration into the platform
homedirectory Jun 3, 2026
40d51a3
#2759 Rationalise conditions that determine initial pre-population
homedirectory Jun 4, 2026
51d5048
#2759 Extract system properties for test configuration into constants
homedirectory Jun 4, 2026
8b6a732
#2759 Delete all pre-population scripts before creating new ones in C…
homedirectory Jun 4, 2026
d0b4387
#2759 Save the ID seed script to file only if saveScriptsToFile is en…
homedirectory Jun 4, 2026
8ada4c5
#2759 Minor improvements to logging
homedirectory Jun 4, 2026
05f79ce
Merge branch '3.0.0-SNAPSHOT' into Issue-#2759
homedirectory Jun 5, 2026
bebd33c
#2759 Delete pre-population scripts only if saveScriptsToFile=true
homedirectory Jun 8, 2026
3569270
#2759 Restart the ID sequence before populateDomain()
homedirectory Jun 8, 2026
39cca89
#2759 Remove unused method AbstractDomainDrivenTestCase.domainEntityT…
homedirectory Jun 8, 2026
bcba17a
#2759 Minor cleanup
homedirectory Jun 8, 2026
c49ce57
#2759 A comment explaining reflective assignment of static fields in …
homedirectory Jun 8, 2026
3d119e7
#2759 Documentation improvements
homedirectory Jun 8, 2026
76a213c
#2759 Support more date formats in base test case method dateTime()
homedirectory Jun 12, 2026
9f5cf61
#2759 Tidy up base test case types
homedirectory Jun 12, 2026
e136b11
#2759 Improve error reporting
homedirectory Jun 12, 2026
e0bd0d9
#2759 Improve internal field names
homedirectory Jun 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ public <R> R execWithSession(final Function<ISessionEnabled, R> action) {
return action.apply(this);
}

/// Executes the specified `action` transactionally, providing this instance of [ISessionEnabled].
///
@SessionRequired
public void runWithSession(final Consumer<ISessionEnabled> action) {
action.accept(this);
}

/// Executes the specified `action` transactionally, providing this instance of [ISessionEnabled], and returns its result.
///
/// This is a **strict** execution method — it throws an exception if invoked within the scope of an existing session.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@

public interface IDomainDrivenData {

public static final String ADMIN = "ADMIN";
public static final String BASE_SUFFIX = "_BASE";
public static final String SUPER_SECRET_PASSWORD = "cooking with rocket fuel";
String ADMIN = "ADMIN";
String BASE_SUFFIX = "_BASE";
String SUPER_SECRET_PASSWORD = "cooking with rocket fuel";

/// Saves the specified entity and returns a refetched instance.
///
Expand All @@ -30,7 +30,7 @@ public interface IDomainDrivenData {
///
/// To specify a custom fetch model for refetching, use [#save(AbstractEntity, Optional)].
///
<T extends AbstractEntity<?>> T save(final T instance);
<T extends AbstractEntity<?>> T save(T instance);

/// Calls _save-with-fetch_ on the companion of the specified entity.
///
Expand Down Expand Up @@ -69,23 +69,54 @@ static <T extends AbstractEntity<?>> Optional<fetch<T>> noFetch() {
return save(entity, noFetch()).asLeft().value();
}

<T extends AbstractEntity<K>, K extends Comparable<?>> T new_(final Class<T> entityClass);
/// Instantiates a new entity.
///
<T extends AbstractEntity<K>, K extends Comparable<?>> T new_(Class<T> entityClass);

<T extends AbstractEntity<K>, K extends Comparable<?>> T new_(final Class<T> entityClass, final K key);
/// Instantiates a new entity with a simple key.
///
<T extends AbstractEntity<K>, K extends Comparable<?>> T new_(Class<T> entityClass, K key);

<T extends AbstractEntity<K>, K extends Comparable<?>> T new_(final Class<T> entityClass, final K key, final String desc);
/// Instantiates a new entity with a simple key and description.
///
<T extends AbstractEntity<K>, K extends Comparable<?>> T new_(Class<T> entityClass, K key, String desc);

<T extends AbstractEntity<DynamicEntityKey>> T new_composite(final Class<T> entityClass, final Object... keys);
/// Instantiates a new entity with a composite key.
/// The order of key member values must match the order defined in the entity type.
///
/// If the list of key values is not empty, the number of provided key values must be equal to the number of key members.
///
/// If the list of key values is empty, no key members are assigned.
///
<T extends AbstractEntity<DynamicEntityKey>> T new_composite(Class<T> entityClass, Object... keys);

<T> T getInstance(final Class<T> type);
<T> T getInstance(Class<T> type);

<T extends IEntityDao<E>, E extends AbstractEntity<?>> T co$(final Class<E> type);
<T extends IEntityDao<E>, E extends AbstractEntity<?>> T co$(Class<E> type);

<T extends IEntityDao<E>, E extends AbstractEntity<?>> T co(final Class<E> type);
<T extends IEntityDao<E>, E extends AbstractEntity<?>> T co(Class<E> type);

Date date(final String dateTime);
/// Parses a [Date] using the system's default time zone.
///
/// Supported formats:
///
/// - `yyyy-MM-dd` (time of day defaults to all zeroes)
/// - `yyyy-MM-dd HH:mm` (seconds default to zero)
/// - `yyyy-MM-dd HH:mm:ss` (milliseconds default to zero)
/// - `yyyy-MM-dd HH:mm:ss.SSS`
///
Date date(String dateTime);

DateTime dateTime(final String dateTime);
/// Parses a [DateTime] using the system's default time zone.
///
/// Supported formats:
///
/// - `yyyy-MM-dd` (time of day defaults to all zeroes)
/// - `yyyy-MM-dd HH:mm` (seconds default to zero)
/// - `yyyy-MM-dd HH:mm:ss` (milliseconds default to zero)
/// - `yyyy-MM-dd HH:mm:ss.SSS`
///
DateTime dateTime(String dateTime);

default BigDecimal decimal(final String value) {
return new BigDecimal(value);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package ua.com.fielden.platform.test;

import jakarta.annotation.Nullable;
import org.apache.commons.lang3.StringUtils;
import org.hibernate.Session;
import org.joda.time.DateTime;
Expand Down Expand Up @@ -50,17 +51,66 @@ public abstract class AbstractDomainDrivenTestCase implements IDomainDrivenData,
ERR_INVALID_NUMBER_OF_KEY_VALUES = "Number of key values is %s but should be %s.",
ERR_MISSING_SESSION = "Session is missing, most likely, due to missing @SessionRequired annotation.";

private static final DateTimeFormatter jodaFormatter = DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss");
private static final DateFormat DATE_TIME_FORMAT_WITHOUT_SECONDS = new SimpleDateFormat("yyyy-MM-dd HH:mm");
private static final DateFormat DATE_TIME_FORMAT_WITHOUT_MILLIS = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
private static final DateFormat DATE_TIME_FORMAT_WITH_MILLIS = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd");
/// An offset for the ID sequence when it is being reset to prevent overlaps between test data population and intermediate
/// data in test cases.
/// It serves as extra headroom for unusual circumstances (e.g., manually adding statements to a data population script).
///
public static final int ID_HEADROOM = 1_000_000;

/// A fallback value for the ID seed used to restart the ID sequence before each test method.
///
public static final long DEFAULT_ID_SEED = 10_000_000L;

/// A system property (type: boolean) that enables test data pre-population scripts to be loaded from disk.
/// When `true`, initial pre-population for Cached Mode tests ([#prePopulateDomain]) is skipped in favour of
/// scripts created by a prior Cached Mode test run.
///
public static final String LOAD_DATA_SCRIPT_FROM_FILE = "loadDataScriptFromFile";

/// A system property (type: boolean) that enables test data pre-population scripts to be persisted to disk.
/// When `true`, the scripts created during initial pre-population for Cached Mode tests ([#prePopulateDomain])
/// and a DDL script are all persisted to disk.
/// Those scripts can later be used by enabling [#LOAD_DATA_SCRIPT_FROM_FILE] and [#LOAD_DDL_SCRIPT_FROM_FILE].
///
public static final String SAVE_SCRIPTS_TO_FILE = "saveScriptsToFile";

/// A system property (type: boolean) that enables a DDL script to be loaded from disk.
/// If `true`, but a DDL script does not exist, the DDL will be generated ad-hoc.
///
public static final String LOAD_DDL_SCRIPT_FROM_FILE = "loadDdlScriptFromFile";

/// A system property that specifies a URI to the database that will be used for testing.
///
public static final String DATABASE_URI = "databaseUri";

private static final DateTimeFormatter JODA_FORMAT_WITH_MINUTES = DateTimeFormat.forPattern("yyyy-MM-dd HH:mm");
private static final DateTimeFormatter JODA_FORMAT_WITH_SECONDS = DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter JODA_FORMAT_WITH_MILLIS = DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss.SSS");
private static final DateTimeFormatter JODA_FORMAT_DATE_ONLY = DateTimeFormat.forPattern("yyyy-MM-dd");

private static final DateFormat DATE_FORMAT_WITH_MINUTES = new SimpleDateFormat("yyyy-MM-dd HH:mm");
private static final DateFormat DATE_FORMAT_WITH_SECONDS = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
private static final DateFormat DATE_FORMAT_WITH_MILLIS = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
private static final DateFormat DATE_FORMAT_DATE_ONLY = new SimpleDateFormat("yyyy-MM-dd");

// The following three static fields are reflectively assigned only once, by the platform test runner.
// We could make these fields non-static and @Inject, but a lot of application-level tests use getInstance() in field
// initialisers, which requires `instantiator` to already have been injected in the parent constructor.
// Field-level injection through @Inject occurs only after the constructor.
// Therefore, such a change would break existing application tests.
private static ICompanionObjectFinder coFinder;
private static EntityFactory factory;
private static Function<Class<?>, Object> instantiator;

/// This map stores ID seeds for all test classes.
/// Each test class is assigned an ID seed after its own dataset is populated, which occurs before the first test method is executed.
/// Before each test method, the ID sequence is restarted with the ID seed of the corresponding test class to prevent
/// ID conflicts between entities in the dataset and any intermediate entities persisted within a test method.
///
/// No synchronisation is required as TG supports only synchronous execution of tests within one JVM.
///
private static final Map<Class<?>, Long> idSeedMap = new HashMap<>();

private DbCreator dbCreator;
private Session session;
private String transactionGuid;
Expand All @@ -69,18 +119,61 @@ public abstract class AbstractDomainDrivenTestCase implements IDomainDrivenData,
///
protected abstract void populateDomain();

/// Should return a complete list of domain entity types.
/// Controls caching of data produced by methods annotated with `@EnsureData`.
///
public boolean skipCaching() {
return useSavedDataPopulationScript() || saveDataPopulationScriptToFile();
}

/// Builds the JVM-wide pre-population dataset for tests in Cached Mode.
///
/// Invoked once per JVM, before any test in Cached Mode runs, by the test framework when:
/// - the current test class is in Cached Mode ([#skipCaching] returns `false`), AND
/// - pre-population has not yet occurred in this JVM, AND
/// - [#LOAD_DATA_SCRIPT_FROM_FILE] is disabled (i.e., scripts are being generated, not loaded from disk).
///
/// If [#LOAD_DATA_SCRIPT_FROM_FILE] is enabled, this method is never called, and previously created scripts are used instead.
///
/// Implementations should call all methods annotated with `@EnsureData`.
/// Each such call is intercepted by the `@EnsureData` interceptor and recorded as an SQL script.
/// Calling all such methods in this single procedure ensures that the IDs assigned to entities
/// across different methods do not conflict, since they all draw from the ID sequence in one
/// continuous run.
///
/// After this method returns:
/// - The framework captures the ID seed from the populated state.
/// - If [#SAVE_SCRIPTS_TO_FILE] is enabled, the seed is persisted to disk as a sequence-restart script for a future JVM run with [#LOAD_DATA_SCRIPT_FROM_FILE] enabled.
/// - The database is truncated; only the in-memory `@EnsureData` scripts remain, ready to be replayed by subsequent test classes.
///
/// This method will be called with non-strict model verification active ([AbstractEntity#useNonStrictModelVerification]).
///
public abstract void prePopulateDomain();

/// Invoked by the test framework after [#prePopulateDomain] completes, but **before** the database is truncated.
///
protected abstract List<Class<? extends AbstractEntity<?>>> domainEntityTypes();
/// Implementations should release any state accumulated during pre-population that must be reset for the upcoming test methods.
/// The typical use case is invoking the cleanup routine registered by the `@EnsureData` interceptor.
///
public abstract void afterPrePopulation();

@Before
public final void beforeTest() throws Exception {
public final void beforeTest() {
dbCreator.populateOrRestoreData(this);
resetIdGenerator();
}

@SessionRequired
protected void resetIdGenerator() {
DbUtils.resetSequenceGenerator(ID_SEQUENCE_NAME, 1000000, this.getSession());
final var seed = idSeedMap.getOrDefault(this.getClass(), DEFAULT_ID_SEED);
DbUtils.resetSequenceGenerator(ID_SEQUENCE_NAME, seed.intValue(), this.getSession());
}

protected void setIdSeed(final long value) {
idSeedMap.put(this.getClass(), value);
}

protected @Nullable Long getIdSeed() {
return idSeedMap.get(this.getClass());
}

@After
Expand Down Expand Up @@ -154,47 +247,54 @@ public <C extends IEntityDao<E>, E extends AbstractEntity<?>> C co(final Class<E

}

/// Converts a date string to a [Date] using system's default time zone.
///
/// Supported formats:
///
/// - `yyyy-MM-dd`
/// - `yyyy-MM-dd HH:mm`
/// - `yyyy-MM-dd HH:mm:ss`
/// - `yyyy-MM-dd HH:mm:ss.SSS`
///
@Override
public final Date date(final String dateTime) {
try {
// Has millis part?
if (dateTime.indexOf('.') > 0) {
return DATE_TIME_FORMAT_WITH_MILLIS.parse(dateTime);
return DATE_FORMAT_WITH_MILLIS.parse(dateTime);
}
// Has time part without seconds?
else if (dateTime.lastIndexOf(":") == 13) {
return DATE_TIME_FORMAT_WITHOUT_SECONDS.parse(dateTime);
return DATE_FORMAT_WITH_MINUTES.parse(dateTime);
}
// Has time part without millis?
else if (dateTime.indexOf(":") > 0) {
return DATE_TIME_FORMAT_WITHOUT_MILLIS.parse(dateTime);
return DATE_FORMAT_WITH_SECONDS.parse(dateTime);
}
// Otherwise, assume the date without the time part.
else {
return DATE_FORMAT.parse(dateTime);
return DATE_FORMAT_DATE_ONLY.parse(dateTime);
}
} catch (ParseException e) {
throw new DomainDrivenTestException(ERR_PARSING_DATE.formatted(dateTime));
} catch (final ParseException ex) {
throw new DomainDrivenTestException(ERR_PARSING_DATE.formatted(dateTime), ex);
}
}

@Override
public final DateTime dateTime(final String dateTime) {
return jodaFormatter.parseDateTime(dateTime);
try {
// Has millis part?
if (dateTime.indexOf('.') > 0) {
return JODA_FORMAT_WITH_MILLIS.parseDateTime(dateTime);
}
// Has time part without seconds?
else if (dateTime.lastIndexOf(":") == 13) {
return JODA_FORMAT_WITH_MINUTES.parseDateTime(dateTime);
}
// Has time part without millis?
else if (dateTime.indexOf(":") > 0) {
return JODA_FORMAT_WITH_SECONDS.parseDateTime(dateTime);
}
// Otherwise, assume the date without the time part.
else {
return JODA_FORMAT_DATE_ONLY.parseDateTime(dateTime);
}
} catch (final Exception ex) {
throw new DomainDrivenTestException(ERR_PARSING_DATE.formatted(dateTime), ex);
}
}

/// Instantiates a new entity with a non-composite key, where the key value is provided as the second argument,
/// and the description is provided as the third argument.
///
@Override
public <T extends AbstractEntity<K>, K extends Comparable<?>> T new_(final Class<T> entityClass, final K key, final String desc) {
final T entity = new_(entityClass);
Expand All @@ -203,19 +303,13 @@ public <T extends AbstractEntity<K>, K extends Comparable<?>> T new_(final Class
return entity;
}

/// Instantiates a new entity with a non-composite key, whose value is provided as the second argument.
///
@Override
public <T extends AbstractEntity<K>, K extends Comparable<?>> T new_(final Class<T> entityClass, final K key) {
final T entity = new_(entityClass);
entity.setKey(key);
return entity;
}

/// Instantiates a new entity with a composite key, where the key members are assigned based on the provided values.
/// The order of values must match the order defined in the key member definitions.
/// An empty list of key values is permitted.
///
@Override
public <T extends AbstractEntity<DynamicEntityKey>> T new_composite(final Class<T> entityClass, final Object... keys) {
final T entity = new_(entityClass);
Expand All @@ -234,8 +328,6 @@ public <T extends AbstractEntity<DynamicEntityKey>> T new_composite(final Class<
return entity;
}

/// Instantiates a new entity based solely on the provided type, resulting in a completely empty instance with no properties assigned.
///
@Override
public <T extends AbstractEntity<K>, K extends Comparable<?>> T new_(final Class<T> entityClass) {
final IEntityDao<T> co = co$(entityClass);
Expand Down
Loading