diff --git a/platform-dao/src/main/java/ua/com/fielden/platform/dao/session/TransactionalExecution.java b/platform-dao/src/main/java/ua/com/fielden/platform/dao/session/TransactionalExecution.java index e269c309c1b..9569bc85849 100644 --- a/platform-dao/src/main/java/ua/com/fielden/platform/dao/session/TransactionalExecution.java +++ b/platform-dao/src/main/java/ua/com/fielden/platform/dao/session/TransactionalExecution.java @@ -109,6 +109,13 @@ public R execWithSession(final Function action) { return action.apply(this); } + /// Executes the specified `action` transactionally, providing this instance of [ISessionEnabled]. + /// + @SessionRequired + public void runWithSession(final Consumer 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. diff --git a/platform-dao/src/main/java/ua/com/fielden/platform/data/IDomainDrivenData.java b/platform-dao/src/main/java/ua/com/fielden/platform/data/IDomainDrivenData.java index 3d34dbf79d3..b9f2d3f1e1a 100644 --- a/platform-dao/src/main/java/ua/com/fielden/platform/data/IDomainDrivenData.java +++ b/platform-dao/src/main/java/ua/com/fielden/platform/data/IDomainDrivenData.java @@ -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. /// @@ -30,7 +30,7 @@ public interface IDomainDrivenData { /// /// To specify a custom fetch model for refetching, use [#save(AbstractEntity, Optional)]. /// - > T save(final T instance); + > T save(T instance); /// Calls _save-with-fetch_ on the companion of the specified entity. /// @@ -69,23 +69,54 @@ static > Optional> noFetch() { return save(entity, noFetch()).asLeft().value(); } - , K extends Comparable> T new_(final Class entityClass); + /// Instantiates a new entity. + /// + , K extends Comparable> T new_(Class entityClass); - , K extends Comparable> T new_(final Class entityClass, final K key); + /// Instantiates a new entity with a simple key. + /// + , K extends Comparable> T new_(Class entityClass, K key); - , K extends Comparable> T new_(final Class entityClass, final K key, final String desc); + /// Instantiates a new entity with a simple key and description. + /// + , K extends Comparable> T new_(Class entityClass, K key, String desc); - > T new_composite(final Class 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 new_composite(Class entityClass, Object... keys); - T getInstance(final Class type); + T getInstance(Class type); - , E extends AbstractEntity> T co$(final Class type); + , E extends AbstractEntity> T co$(Class type); - , E extends AbstractEntity> T co(final Class type); + , E extends AbstractEntity> T co(Class 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); diff --git a/platform-dao/src/main/java/ua/com/fielden/platform/test/AbstractDomainDrivenTestCase.java b/platform-dao/src/main/java/ua/com/fielden/platform/test/AbstractDomainDrivenTestCase.java index da62f7289c7..fe58751df04 100644 --- a/platform-dao/src/main/java/ua/com/fielden/platform/test/AbstractDomainDrivenTestCase.java +++ b/platform-dao/src/main/java/ua/com/fielden/platform/test/AbstractDomainDrivenTestCase.java @@ -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; @@ -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, 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, Long> idSeedMap = new HashMap<>(); + private DbCreator dbCreator; private Session session; private String transactionGuid; @@ -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>> 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 @@ -154,47 +247,54 @@ public , E extends AbstractEntity> C co(final Class 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 , K extends Comparable> T new_(final Class entityClass, final K key, final String desc) { final T entity = new_(entityClass); @@ -203,8 +303,6 @@ public , 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 , K extends Comparable> T new_(final Class entityClass, final K key) { final T entity = new_(entityClass); @@ -212,10 +310,6 @@ public , K extends Comparable> T new_(final Class 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 new_composite(final Class entityClass, final Object... keys) { final T entity = new_(entityClass); @@ -234,8 +328,6 @@ public > 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 , K extends Comparable> T new_(final Class entityClass) { final IEntityDao co = co$(entityClass); diff --git a/platform-dao/src/main/java/ua/com/fielden/platform/test/DbCreator.java b/platform-dao/src/main/java/ua/com/fielden/platform/test/DbCreator.java index 4a6b3506bc1..1f79e784e73 100644 --- a/platform-dao/src/main/java/ua/com/fielden/platform/test/DbCreator.java +++ b/platform-dao/src/main/java/ua/com/fielden/platform/test/DbCreator.java @@ -5,22 +5,29 @@ import org.hibernate.dialect.Dialect; import ua.com.fielden.platform.dao.session.TransactionalExecution; import ua.com.fielden.platform.ddl.IDdlGenerator; +import ua.com.fielden.platform.entity.AbstractEntity; import ua.com.fielden.platform.entity.query.DbVersion; +import ua.com.fielden.platform.entity.query.IDbVersionProvider; import ua.com.fielden.platform.meta.EntityMetadata; import ua.com.fielden.platform.meta.IDomainMetadataUtils; +import ua.com.fielden.platform.reflection.PropertyTypeDeterminator; import ua.com.fielden.platform.test.exceptions.DomainDrivenTestException; +import ua.com.fielden.platform.utils.DbUtils; import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; import java.nio.file.Paths; import java.sql.Connection; -import java.sql.SQLException; import java.util.*; +import java.util.stream.Stream; import static com.google.common.collect.ImmutableList.toImmutableList; import static java.lang.String.format; import static org.apache.logging.log4j.LogManager.getLogger; +import static ua.com.fielden.platform.entity.query.DbVersion.ID_SEQUENCE_NAME; +import static ua.com.fielden.platform.test.AbstractDomainDrivenTestCase.*; import static ua.com.fielden.platform.utils.DbUtils.batchExecSql; /// Abstracts the logic for creating the initial test-case database @@ -62,11 +69,31 @@ /// For this option to be effective, test data must have been previously saved to a file. /// public abstract class DbCreator { + public static final String baseDir = "./src/test/resources/db"; public static final String ddlScriptFileName = format("%s/create-db-ddl.script", DbCreator.baseDir); public static final int BATCH_SIZE = 1000; + public static String prePopulationScriptPath(final String name) { + return format("%s/prePopulate-%s.script", DbCreator.baseDir, name); + } + + private static boolean isPrePopulationScript(final Path path) { + final var filename = path.getFileName().toString(); + return filename.startsWith("prePopulate-") && filename.endsWith(".script"); + } + + private static boolean PRE_POPULATED = false; + + /// The ID seed that gets assigned based on pre-populated data. + /// It is guaranteed to be greater than any entity ID in all of pre-populated data. + /// It is relevant only for tests in Cached Mode. + /// + /// This field is guaranteed to be non-null when [#PRE_POPULATED] is `true`. + /// + private static Long PRE_POPULATED_ID_SEED = null; + public final IDomainDrivenTestCaseConfiguration config; protected final Logger logger = getLogger(getClass()); @@ -125,24 +152,31 @@ public Collection persistentEntitiesMetadata() { /// Executes the test data population logic. /// Should be invoked before each unit test. /// - public final DbCreator populateOrRestoreData(final AbstractDomainDrivenTestCase testCase) throws SQLException { + public final DbCreator populateOrRestoreData(final AbstractDomainDrivenTestCase testCase) { + runPrePopulation(testCase); + + final var dbUtils = config.getInstance(DbUtils.class); + if (testCase.useSavedDataPopulationScript() && testCase.saveDataPopulationScriptToFile()) { throw new DomainDrivenTestException("useSavedDataPopulationScript() && saveDataPopulationScriptToFile() should not be true at the same time."); } Optional raisedEx = Optional.empty(); - if (!dataScripts.isEmpty()) { - // Apply the data population script. - logger.debug("Executing data population script."); - config.getInstance(TransactionalExecution.class).execStrict(conn -> batchExecSql(new ArrayList<>(dataScripts), conn, BATCH_SIZE)); - } else { + // For the first test in the test class: initial population of test data. + if (dataScripts.isEmpty()) { try { if (testCase.useSavedDataPopulationScript()) { config.getInstance(TransactionalExecution.class).execStrict(conn -> restoreDataFromFile(testCaseType, conn)); + // Set the ID seed to a value greater than any ID used in the script. + // This is to prevent ID conflicts with populateDomain() which may save new entities. + testCase.setIdSeed(AbstractDomainDrivenTestCase.ID_HEADROOM + dbUtils.maxEntityId()); } - // Need to call populateDomain, which might have some initialization even if the actual data saving does not need to occur. + testCase.resetIdGenerator(); + // Call populateDomain regardless of using a data population script -- populateDomain may contain extra initialisation. testCase.populateDomain(); + testCase.setIdSeed(AbstractDomainDrivenTestCase.ID_HEADROOM + dbUtils.maxEntityId()); + // No need to resetIdGenerator here, each test class has a @Before method that will do this. } catch (final Exception ex) { raisedEx = Optional.of(ex); } @@ -156,6 +190,12 @@ public final DbCreator populateOrRestoreData(final AbstractDomainDrivenTestCase } } } + // After the first test in the test class: repopulation of test data. + else { + // Apply the data population script. + logger.debug("Executing data population script."); + config.getInstance(TransactionalExecution.class).execStrict(conn -> batchExecSql(new ArrayList<>(dataScripts), conn, BATCH_SIZE)); + } if (raisedEx.isPresent()) { raisedEx.ifPresent(ex -> logger.fatal(ex.getMessage(), ex)); @@ -165,6 +205,120 @@ public final DbCreator populateOrRestoreData(final AbstractDomainDrivenTestCase return this; } + private void runPrePopulation(final AbstractDomainDrivenTestCase testCase) { + final var loadDataScriptFromFile = Boolean.getBoolean(LOAD_DATA_SCRIPT_FROM_FILE); + final var saveScriptsToFile = Boolean.getBoolean(SAVE_SCRIPTS_TO_FILE); + final var dbUtils = config.getInstance(DbUtils.class); + final var dbVersionProvider = config.getInstance(IDbVersionProvider.class); + final var testCaseName = PropertyTypeDeterminator.stripIfNeeded(testCase.getClass()).getSimpleName(); + + // Cached Mode: pre-populate or load from file. + if (!testCase.skipCaching()) { + logger.info(() -> format("%s: Cached Mode is active. [%s = %s], [%s = %s]", + testCaseName, LOAD_DATA_SCRIPT_FROM_FILE, loadDataScriptFromFile, SAVE_SCRIPTS_TO_FILE, saveScriptsToFile)); + // Pre-population occurs only once per JVM (controlled by PRE_POPULATED). + if (!PRE_POPULATED) { + if (!loadDataScriptFromFile) { + logger.info(() -> "Performing initial pre-population."); + + if (saveScriptsToFile) { + // Delete all existing pre-population scripts. + if (java.nio.file.Files.exists(Path.of(baseDir))) { + logger.info(() -> "Deleting existing pre-population scripts."); + try (final Stream paths = java.nio.file.Files.list(Path.of(baseDir))) { + final var delCount = paths.filter(DbCreator::isPrePopulationScript) + .filter(p -> { + try { + return java.nio.file.Files.deleteIfExists(p); + } catch (final IOException ex) { + logger.warn(() -> "Could not delete pre-population script [%s]. This may affect test results.".formatted(p), ex); + return false; + } + }).count(); + logger.info(() -> "Deleted %s pre-population scripts.".formatted(delCount)); + } catch (final IOException ex) { + logger.warn(() -> "Could not list existing pre-population scripts. This may affect test results.", ex); + } + } + + // Delete the ID seed script. + try { + if (java.nio.file.Files.deleteIfExists(Path.of(idSequenceScriptPath()))) { + logger.info(() -> "Deleted [%s].".formatted(idSequenceScriptPath())); + } + } catch (final IOException ex) { + logger.warn(() -> "Could not delete [%s].".formatted(idSequenceScriptPath()), ex); + } + } + + // let's use non-strict mode for scripting + try { + AbstractEntity.useNonStrictModelVerification(); + testCase.prePopulateDomain(); + } finally { + // reset model verification mode to strict after scripting + AbstractEntity.useStrictModelVerification(); + } + + PRE_POPULATED_ID_SEED = AbstractDomainDrivenTestCase.ID_HEADROOM + dbUtils.maxEntityId(); + if (saveScriptsToFile) { + saveScriptToFile(List.of(dbUtils.sqlRestartSequence(dbVersionProvider.dbVersion(), ID_SEQUENCE_NAME, PRE_POPULATED_ID_SEED)), + idSequenceScriptPath()); + logger.info(() -> "Created %s with ID=%s.".formatted(idSequenceScriptPath(), PRE_POPULATED_ID_SEED)); + } + + logger.info(() -> "Completed creating all pre-population scripts. Clearing the DB."); + + // After pre-population clear the DB for the upcoming test case. + try { + testCase.afterPrePopulation(); + config.getInstance(TransactionalExecution.class).execStrict(conn -> { + final List script = genTruncStmt(persistentEntitiesMetadata(), conn); + batchExecSql(script, conn, DbCreator.BATCH_SIZE); + }); + } catch (final Exception ex) { + final String msg = "Failed to clear the DB after pre-population."; + logger.fatal(msg, ex); + throw new DomainDrivenTestException(msg, ex); + } + } + else { + // loadDataScriptFromFile = true means that a prior Cached Mode test run performed pre-population. + // Load the seed ID from a script created by that test run. + + logger.info(() -> "Skipping pre-population. Loading the seed ID."); + + final var idSequenceScript = new File(idSequenceScriptPath()); + if (idSequenceScript.exists()) { + try { + final var lines = Files.readLines(idSequenceScript, StandardCharsets.UTF_8); + config.getInstance(TransactionalExecution.class).exec(conn -> batchExecSql(lines, conn, 1)); + } catch (final Exception ex) { + throw new RuntimeException(ex); + } + PRE_POPULATED_ID_SEED = config.getInstance(TransactionalExecution.class).execWithSession($ -> DbUtils.nextIdValue(ID_SEQUENCE_NAME, $.getSession())); + } + else { + logger.warn(() -> format("%s does not exist, but [%s = %s]." + + " This may result in entity ID conflicts during test data population." + + " It is recommended to regenerate all scripts by running all tests with [%s = false].", + idSequenceScriptPath(), LOAD_DATA_SCRIPT_FROM_FILE, loadDataScriptFromFile, LOAD_DATA_SCRIPT_FROM_FILE)); + PRE_POPULATED_ID_SEED = DEFAULT_ID_SEED; + } + } + PRE_POPULATED = true; + } + + // PRE_POPULATED_ID_SEED should not be null at this point, but let's keep this condition just in case. + if (PRE_POPULATED_ID_SEED != null && testCase.getIdSeed() == null) { + testCase.setIdSeed(PRE_POPULATED_ID_SEED); + } + } + else { + logger.info(() -> "%s: Uncached Mode is active.".formatted(testCaseName)); + } + } + /// Executes the script that truncates database tables. /// Should be invoked after each unit test. /// @@ -268,4 +422,11 @@ public static void saveScriptToFile(final List scripts, final String fil } } + /// Returns a relative path to the ID sequence script. + /// This is an SQL script that restarts the entity ID sequence with a value that is greater than any entity ID used in `populate*` scripts. + /// + private static String idSequenceScriptPath() { + return "%s/id-sequence.script".formatted(baseDir); + } + } diff --git a/platform-dao/src/main/java/ua/com/fielden/platform/test/runners/AbstractDomainDrivenTestCaseRunner.java b/platform-dao/src/main/java/ua/com/fielden/platform/test/runners/AbstractDomainDrivenTestCaseRunner.java index 2b9db8f6fd3..2ee31917a74 100644 --- a/platform-dao/src/main/java/ua/com/fielden/platform/test/runners/AbstractDomainDrivenTestCaseRunner.java +++ b/platform-dao/src/main/java/ua/com/fielden/platform/test/runners/AbstractDomainDrivenTestCaseRunner.java @@ -25,6 +25,7 @@ import static org.apache.commons.lang3.StringUtils.isEmpty; import static org.apache.logging.log4j.LogManager.getLogger; import static ua.com.fielden.platform.reflection.Reflector.assignStatic; +import static ua.com.fielden.platform.test.AbstractDomainDrivenTestCase.*; import static ua.com.fielden.platform.test.DbCreator.ddlScriptFileName; /// The domain test case runner responsible for instantiating and initializing domain test cases. @@ -76,36 +77,18 @@ public AbstractDomainDrivenTestCaseRunner( // databaseUri value should be specified in POM or come from the command line // however, need to provide a sensible default not to force developers to specify this parameter for each test case in IDE, assuming H2 is the default - if (isEmpty(System.getProperty("databaseUri"))) { + if (isEmpty(System.getProperty(DATABASE_URI))) { databaseUri = "./src/test/resources/db/DEFAULT_TEST_DB"; } else { - databaseUri = System.getProperty("databaseUri"); + databaseUri = System.getProperty(DATABASE_URI); } - // check if loadDdlScriptFromFile is specified - final boolean loadDdlScriptFromFile; - if (isEmpty(System.getProperty("loadDdlScriptFromFile"))) { - loadDdlScriptFromFile = false; - } else { - loadDdlScriptFromFile = Boolean.parseBoolean(System.getProperty("loadDdlScriptFromFile")); - } - - // check if saveDdlScriptToFile is specified - final boolean saveScriptsToFile; - if (isEmpty(System.getProperty("saveScriptsToFile"))) { - saveScriptsToFile = false; - } else { - saveScriptsToFile = Boolean.parseBoolean(System.getProperty("saveScriptsToFile")); - } - - final boolean loadDataScriptFromFile; - if (isEmpty(System.getProperty("loadDataScriptFromFile"))) { - loadDataScriptFromFile = false; - } else { - loadDataScriptFromFile = Boolean.parseBoolean(System.getProperty("loadDataScriptFromFile")); - } + final boolean loadDdlScriptFromFile = Boolean.getBoolean(LOAD_DDL_SCRIPT_FROM_FILE); + final boolean saveScriptsToFile = Boolean.getBoolean(SAVE_SCRIPTS_TO_FILE); + final boolean loadDataScriptFromFile = Boolean.getBoolean(LOAD_DATA_SCRIPT_FROM_FILE); - logger.info(() -> "Running [%s] with loadDdlScriptFromFile = [%s], saveScriptsToFile = [%s], loadDataScriptFromFile = [%s] and databaseUri = [%s]".formatted(klass, loadDdlScriptFromFile, saveScriptsToFile, loadDataScriptFromFile, databaseUri)); + logger.info(() -> "Running [%s] with %s = [%s], %s = [%s], %s = [%s] and databaseUri = [%s]".formatted( + klass, LOAD_DDL_SCRIPT_FROM_FILE, loadDdlScriptFromFile, SAVE_SCRIPTS_TO_FILE, saveScriptsToFile, LOAD_DATA_SCRIPT_FROM_FILE, loadDataScriptFromFile, databaseUri)); // let's construct and assign test configuration // this should occur only once per JVM instance as this is a computationally intensive operation diff --git a/platform-dao/src/main/java/ua/com/fielden/platform/utils/DbUtils.java b/platform-dao/src/main/java/ua/com/fielden/platform/utils/DbUtils.java index 0f2d3eac122..bab1827cc75 100644 --- a/platform-dao/src/main/java/ua/com/fielden/platform/utils/DbUtils.java +++ b/platform-dao/src/main/java/ua/com/fielden/platform/utils/DbUtils.java @@ -1,5 +1,6 @@ package ua.com.fielden.platform.utils; +import jakarta.inject.Inject; import org.apache.logging.log4j.Logger; import org.hibernate.HibernateException; import org.hibernate.Session; @@ -14,6 +15,12 @@ import org.hibernate.tool.schema.TargetType; import ua.com.fielden.platform.dao.exceptions.DbException; import ua.com.fielden.platform.ddl.MetadataProvider; +import ua.com.fielden.platform.entity.factory.ICompanionObjectFinder; +import ua.com.fielden.platform.entity.query.DbVersion; +import ua.com.fielden.platform.entity.query.EntityAggregates; +import ua.com.fielden.platform.entity.query.model.AggregatedResultQueryModel; +import ua.com.fielden.platform.meta.EntityMetadata; +import ua.com.fielden.platform.meta.IDomainMetadataUtils; import java.io.*; import java.sql.*; @@ -24,7 +31,10 @@ import static java.util.Optional.empty; import static java.util.Optional.ofNullable; import static org.apache.logging.log4j.LogManager.getLogger; +import static ua.com.fielden.platform.entity.AbstractEntity.ID; import static ua.com.fielden.platform.entity.query.DbVersion.ID_SEQUENCE_NAME; +import static ua.com.fielden.platform.entity.query.fluent.EntityQueryUtils.from; +import static ua.com.fielden.platform.entity.query.fluent.EntityQueryUtils.select; /// A collection of convenient DB related utilities such as to generate DDL and obtain the next value for sequence by name. @@ -45,7 +55,41 @@ public class DbUtils { /// public static final String PHASE_BOUNDARY_MARKER = "-- TG_DDL_PHASE_BOUNDARY"; - private DbUtils() {} + private final IDomainMetadataUtils domainMetadataUtils; + private final ICompanionObjectFinder coFinder; + + @Inject + protected DbUtils(final IDomainMetadataUtils domainMetadataUtils, final ICompanionObjectFinder coFinder) { + this.domainMetadataUtils = domainMetadataUtils; + this.coFinder = coFinder; + } + + /// Finds the maximum ID among all persisted entities in the application. + /// + public Long maxEntityId() { + final var sourceQueries = domainMetadataUtils.registeredEntities() + .map(EntityMetadata::asPersistent) + .flatMap(Optional::stream) + .map(em -> select(em.javaType()) + .yield().maxOf().prop(ID).as("maxId") + .modelAsAggregate()) + .toArray(AggregatedResultQueryModel[]::new); + final var query = select(sourceQueries) + .yield().maxOf().prop("maxId").as("maxId") + .modelAsAggregate(); + final var coAgg = coFinder.find(EntityAggregates.class, true); + return coAgg.getEntityOptional(from(query).model()) + .map(agg -> agg.get("maxId")) + .orElse(0L); + } + + /// Creates an SQL statement that restarts an existing sequence. + /// + public String sqlRestartSequence(final DbVersion dbVersion, final String sequenceName, final Long value) { + return switch (dbVersion) { + default -> "ALTER SEQUENCE %s RESTART WITH %s".formatted(sequenceName, value); + }; + } /// Returns the next sequence value using DB independent way that utilises the Hibernate's Dialect support. /// diff --git a/platform-dao/src/test/java/ua/com/fielden/platform/test_config/AbstractDaoTestCase.java b/platform-dao/src/test/java/ua/com/fielden/platform/test_config/AbstractDaoTestCase.java index 1fb8000d696..d44409b89ba 100644 --- a/platform-dao/src/test/java/ua/com/fielden/platform/test_config/AbstractDaoTestCase.java +++ b/platform-dao/src/test/java/ua/com/fielden/platform/test_config/AbstractDaoTestCase.java @@ -2,17 +2,13 @@ import org.joda.time.DateTime; import org.junit.runner.RunWith; -import ua.com.fielden.platform.entity.AbstractEntity; import ua.com.fielden.platform.sample.domain.TgPerson; import ua.com.fielden.platform.security.provider.ISecurityTokenProvider; import ua.com.fielden.platform.security.user.*; import ua.com.fielden.platform.test.AbstractDomainDrivenTestCase; -import ua.com.fielden.platform.test.PlatformTestDomainTypes; import ua.com.fielden.platform.test.ioc.UniversalConstantsForTesting; import ua.com.fielden.platform.utils.IUniversalConstants; -import java.util.List; - /// Should be used as a convenient base class for domain driven test cases. /// @RunWith(H2OrPostgreSqlOrSqlServerContextSelector.class) @@ -20,11 +16,12 @@ public abstract class AbstractDaoTestCase extends AbstractDomainDrivenTestCase { public static final String UNIT_TEST_USER = User.system_users.UNIT_TEST_USER.name(); public static final String UNIT_TEST_ROLE = "UNIT_TEST_ROLE"; - + @Override - protected List>> domainEntityTypes() { - return PlatformTestDomainTypes.entityTypes; - } + public void prePopulateDomain() {} + + @Override + public void afterPrePopulation() {} /// Initialises a test user. /// Needs to be invoked in descendant classes. diff --git a/platform-dao/src/test/java/ua/com/fielden/platform/test_config/DateParsingTest.java b/platform-dao/src/test/java/ua/com/fielden/platform/test_config/DateParsingTest.java new file mode 100644 index 00000000000..06d5b66d8cf --- /dev/null +++ b/platform-dao/src/test/java/ua/com/fielden/platform/test_config/DateParsingTest.java @@ -0,0 +1,49 @@ +package ua.com.fielden.platform.test_config; + +import org.joda.time.DateTime; +import org.junit.Test; + +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Date; + +import static org.junit.Assert.assertEquals; + +public class DateParsingTest extends AbstractDaoTestCase { + + private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"); + + @Test + public void date_parses_all_supported_string_formats() { + assertSameDateTime("2024-01-25 00:00:00.000", date("2024-01-25")); + assertSameDateTime("2024-01-25 12:35:00.000", date("2024-01-25 12:35")); + assertSameDateTime("2024-01-25 12:35:45.000", date("2024-01-25 12:35:45")); + assertSameDateTime("2024-01-25 12:35:45.678", date("2024-01-25 12:35:45.678")); + } + + @Test + public void dateTime_parses_all_supported_string_formats() { + assertSameDateTime("2024-01-25 00:00:00.000", dateTime("2024-01-25")); + assertSameDateTime("2024-01-25 12:35:00.000", dateTime("2024-01-25 12:35")); + assertSameDateTime("2024-01-25 12:35:45.000", dateTime("2024-01-25 12:35:45")); + assertSameDateTime("2024-01-25 12:35:45.678", dateTime("2024-01-25 12:35:45.678")); + } + + @Override + public boolean useSavedDataPopulationScript() {return false;} + + @Override + public boolean saveDataPopulationScriptToFile() {return false;} + + @Override + protected void populateDomain() {} + + private void assertSameDateTime(final String expected, final Date actual) { + assertEquals(expected, formatter.format(actual.toInstant().atZone(ZoneId.systemDefault()))); + } + + private void assertSameDateTime(final String expected, final DateTime dateTime) { + assertSameDateTime(expected, dateTime.toDate()); + } + +} diff --git a/platform-dao/src/test/java/ua/com/fielden/platform/test_config/H2OrPostgreSqlOrSqlServerContextSelector.java b/platform-dao/src/test/java/ua/com/fielden/platform/test_config/H2OrPostgreSqlOrSqlServerContextSelector.java index c5663392e57..17fe470b959 100644 --- a/platform-dao/src/test/java/ua/com/fielden/platform/test_config/H2OrPostgreSqlOrSqlServerContextSelector.java +++ b/platform-dao/src/test/java/ua/com/fielden/platform/test_config/H2OrPostgreSqlOrSqlServerContextSelector.java @@ -1,10 +1,5 @@ package ua.com.fielden.platform.test_config; -import static org.apache.commons.lang3.StringUtils.isEmpty; - -import java.util.Optional; -import java.util.Properties; - import ua.com.fielden.platform.test.DbCreator; import ua.com.fielden.platform.test.IDomainDrivenTestCaseConfiguration; import ua.com.fielden.platform.test.db_creators.H2DbCreator; @@ -13,6 +8,12 @@ import ua.com.fielden.platform.test.runners.PostgresqlDomainDrivenTestCaseRunner; import ua.com.fielden.platform.test.runners.SqlServerDomainDrivenTestCaseRunner; +import java.util.Optional; +import java.util.Properties; + +import static org.apache.commons.lang3.StringUtils.isEmpty; +import static ua.com.fielden.platform.test.AbstractDomainDrivenTestCase.DATABASE_URI; + /** * A test runner that selects a test configuration {@link ITestContext} from either {@link H2DomainDrivenTestCaseRunner} or {@link PostgresqlDomainDrivenTestCaseRunner} for running unit test. * The criteria for selecting the appropriate test runner is based on runtime settings. @@ -23,8 +24,8 @@ public class H2OrPostgreSqlOrSqlServerContextSelector extends AbstractDomainDriv // Note: This assumes PostgreSQL is listening on port 5432 (the default). // There is not much else in the URI that would uniquely identify that we are connecting to a PostgreSQL database. - private static final boolean POSTGRESQL = !isEmpty(System.getProperty("databaseUri")) && System.getProperty("databaseUri").contains("5432"); - private static final boolean SQL_SERVER = !isEmpty(System.getProperty("databaseUri")) && System.getProperty("databaseUri").contains("database"); + private static final boolean POSTGRESQL = !isEmpty(System.getProperty(DATABASE_URI)) && System.getProperty(DATABASE_URI).contains("5432"); + private static final boolean SQL_SERVER = !isEmpty(System.getProperty(DATABASE_URI)) && System.getProperty(DATABASE_URI).contains("database"); public H2OrPostgreSqlOrSqlServerContextSelector(final Class klass) throws Exception { super(klass, POSTGRESQL ? PostgresqlDbCreator.class : (SQL_SERVER ? SqlServerDbCreator.class : H2DbCreator.class), Optional.empty()); diff --git a/platform-dao/src/test/java/ua/com/fielden/platform/validators/OverlappingSequentialClosedPeriodsWithoutGapsTest.java b/platform-dao/src/test/java/ua/com/fielden/platform/validators/OverlappingSequentialClosedPeriodsWithoutGapsTest.java index c1717a4ce2b..2a0721159ff 100644 --- a/platform-dao/src/test/java/ua/com/fielden/platform/validators/OverlappingSequentialClosedPeriodsWithoutGapsTest.java +++ b/platform-dao/src/test/java/ua/com/fielden/platform/validators/OverlappingSequentialClosedPeriodsWithoutGapsTest.java @@ -7,14 +7,10 @@ import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; -import java.util.List; - import org.junit.Test; -import ua.com.fielden.platform.entity.AbstractEntity; import ua.com.fielden.platform.sample.domain.ITgTimesheet; import ua.com.fielden.platform.sample.domain.TgTimesheet; -import ua.com.fielden.platform.test.PlatformTestDomainTypes; import ua.com.fielden.platform.test_config.AbstractDaoTestCase; import ua.com.fielden.platform.utils.Validators; @@ -145,9 +141,4 @@ protected void populateDomain() { save(new_composite(TgTimesheet.class, "USER1", date("2011-11-01 13:00:00")).setFinishDate(date("2011-11-01 15:00:00")).setIncident("002")); } - @Override - protected List>> domainEntityTypes() { - return PlatformTestDomainTypes.entityTypes; - } - } diff --git a/platform-dao/src/test/java/ua/com/fielden/platform/validators/OverlappingSequentialOpenAndClosedPeriodsWithGapsTest.java b/platform-dao/src/test/java/ua/com/fielden/platform/validators/OverlappingSequentialOpenAndClosedPeriodsWithGapsTest.java index 3dc7657f3a5..c6ed1cb1b8f 100644 --- a/platform-dao/src/test/java/ua/com/fielden/platform/validators/OverlappingSequentialOpenAndClosedPeriodsWithGapsTest.java +++ b/platform-dao/src/test/java/ua/com/fielden/platform/validators/OverlappingSequentialOpenAndClosedPeriodsWithGapsTest.java @@ -5,14 +5,10 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; -import java.util.List; - import org.junit.Test; -import ua.com.fielden.platform.entity.AbstractEntity; import ua.com.fielden.platform.sample.domain.ITgTimesheet; import ua.com.fielden.platform.sample.domain.TgTimesheet; -import ua.com.fielden.platform.test.PlatformTestDomainTypes; import ua.com.fielden.platform.test_config.AbstractDaoTestCase; import ua.com.fielden.platform.utils.Validators; @@ -86,9 +82,4 @@ protected void populateDomain() { save(new_composite(TgTimesheet.class, "USER1", date("2011-11-01 15:00:00")).setIncident("002")); } - @Override - protected List>> domainEntityTypes() { - return PlatformTestDomainTypes.entityTypes; - } - }