This object exists so populators and helpers do not have to depend on + * builder-internal types. As more cross-cutting build information is needed + * (for example version index, deterministic seed, or builder configuration), + * it can be added here without changing populator method signatures.
+ * + * @param sequence deterministic sequence number for the fixture instance + */ +public record BuildContext( + long sequence, + Instant now +) { + + Timestamp getTimestamp() { + return Timestamp.from(now); + } + + Date getDate() { + return Date.from(now); + } + +} diff --git a/src/test/java/edu/harvard/iq/dataverse/util/testing/fixtures/DatasetFixture.java b/src/test/java/edu/harvard/iq/dataverse/util/testing/fixtures/DatasetFixture.java new file mode 100644 index 00000000000..f45ad6f7f91 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/util/testing/fixtures/DatasetFixture.java @@ -0,0 +1,58 @@ +package edu.harvard.iq.dataverse.util.testing.fixtures; + +import edu.harvard.iq.dataverse.DataFile; +import edu.harvard.iq.dataverse.DataTable; +import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.DatasetVersion; +import edu.harvard.iq.dataverse.FileMetadata; +import edu.harvard.iq.dataverse.dataset.DatasetType; +import edu.harvard.iq.dataverse.datavariable.DataVariable; +import edu.harvard.iq.dataverse.datavariable.VarGroup; +import edu.harvard.iq.dataverse.datavariable.VariableMetadata; + +import java.util.List; + +/** + * Immutable holder for a generated dataset fixture graph. + * + *This object gives tests convenient access not only to the root + * {@link Dataset}, but also to the current {@link DatasetVersion} and all major + * generated child entities. That makes it easier to inspect, persist, or tweak + * the graph after building it.
+ * + *The fixture currently represents a single dataset version. Multi-version + * support will be added in a later iteration via dedicated evolution recipes.
+ * + * @param dataset root dataset + * @param currentVersion current dataset version + * @param fileMetadatas generated file metadata objects + * @param dataFiles generated data files + * @param dataTables generated data tables + * @param dataVariables generated data variables + * @param varGroups generated variable groups + * @param variableMetadata generated variable metadata rows + */ +public record DatasetFixture( + Dataset dataset, + DatasetType datasetType, + DatasetVersion currentVersion, + ListThis class is intentionally responsible for relationship correctness and + * collection initialization, while recipes are responsible for deciding graph + * shape and populators are responsible for scalar-field initialization.
+ * + *Current scope:
+ *This is intentionally static so values are unique even across multiple + * tests running in the same JVM. It is not meant to be reset between tests.
+ */ + private static final AtomicLong SEQUENCE = new AtomicLong(1); + + /** + * Group index used for the single var group we currently create per tabular file. + *This will become recipe-driven once a {@code VarGroupRecipe} is introduced.
+ */ + private static final int FIRST_AND_ONLY_VAR_GROUP_INDEX = 0; + + private DatasetRecipe datasetRecipe; + private FixturePopulator populator = FixturePopulator.minimal(); + + /** + * Creates a new builder instance. + * + * @return a fresh fixture builder + */ + public static DatasetFixtureBuilder builder() { + return new DatasetFixtureBuilder(); + } + + /** + * Sets the recipe used to determine the graph shape. + * + * @param datasetRecipe dataset recipe to use + * @return this builder for fluent chaining + */ + public DatasetFixtureBuilder recipe(DatasetRecipe datasetRecipe) { + this.datasetRecipe = Objects.requireNonNull(datasetRecipe); + return this; + } + + /** + * Sets the scalar-field populator policy. + * + * @param populator populator to use + * @return this builder for fluent chaining + */ + public DatasetFixtureBuilder populator(FixturePopulator populator) { + this.populator = Objects.requireNonNull(populator); + return this; + } + + /** + * Builds a dataset fixture graph according to the configured recipe and populator. + * + *The build process happens in clearly separated phases:
+ *{@code Dataset} normally creates an initial version automatically. For fixtures we want + * full control over which versions exist, so we wipe that initial version before wiring.
+ * + * @param context fixture build context + * @return a freshly populated dataset with an empty version list + */ + private Dataset createEmptyDataset(BuildContext context) { + Dataset dataset = new Dataset(); + populator.populateDataset(dataset, context); + // DatasetType comes from the recipe, not the populator, because it is a shared + // reference entity that must pre-exist in the database. The recipe either wraps + // // a pre-existing instance or builds one from scalar values for this fixture. + dataset.setDatasetType(datasetRecipe.datasetTypeRecipe().datasetType()); + dataset.setVersions(new ArrayList<>()); + return dataset; + } + + /** + * Creates a {@link DatasetVersion} populated by the configured populator. + * + * @param context fixture build context + * @return a freshly populated dataset version + */ + private DatasetVersion createDatasetVersion(BuildContext context) { + DatasetVersion version = new DatasetVersion(); + populator.populateDatasetVersion(version, context); + return version; + } + + /** + * Iterates over all file recipes for the current version and builds each file in order. + * + *Each file gets a globally unique index across all file recipes in the version. That + * keeps populator-generated values such as labels deterministic and unique across the + * whole version.
+ * + * @param currentVersion current dataset version receiving the files + * @param context fixture build context + * @param accumulator accumulator collecting all generated entities + */ + private void buildVersionFiles( + DatasetVersion currentVersion, + BuildContext context, + BuildAccumulator accumulator + ) { + VersionRecipe versionRecipe = datasetRecipe.currentVersionRecipe(); + ListEach metadata entity links one {@link DataVariable} and one {@link FileMetadata}. + * Because the schema enforces uniqueness on that pair, we create at most one metadata + * row per variable for the given file metadata.
+ * + * @param fileMetadata file metadata for the current version + * @param fileVariables variables in the file's tabular structure + * @param tabularRecipe tabular file recipe + * @param metadataRecipe variable-metadata recipe deciding which pairs get metadata + * @param fileIndex zero-based file index + * @param accumulator accumulator collecting all generated entities + */ + private void buildVariableMetadata( + FileMetadata fileMetadata, + ListThis keeps the build helper methods compact and avoids passing many lists around.
+ */ + private static final class BuildAccumulator { + + private final ListThe builder/wiring layer is responsible for graph structure and + * relationship correctness. This population layer is responsible for making sure + * entities are also "safe enough" to serialize and persist by filling required + * or null-sensitive scalar fields and collections.
+ * + *This separation keeps shape decisions in recipes and scalar defaults here.
+ */ +public interface FixturePopulator { + + /** + * Populates scalar fields and safe defaults for a dataset. + * + * @param dataset dataset being initialized + * @param context fixture build context + */ + void populateDataset(Dataset dataset, BuildContext context); + + /** + * Populates scalar fields and safe defaults for a dataset version. + * + * @param version dataset version being initialized + * @param context fixture build context + */ + void populateDatasetVersion(DatasetVersion version, BuildContext context); + + /** + * Populates scalar fields and safe defaults for file metadata. + * + * @param fileMetadata file metadata being initialized + * @param fileBuildContext file build context + * @param context fixture build context + */ + void populateFileMetadata(FileMetadata fileMetadata, FileBuildContext fileBuildContext, BuildContext context); + + /** + * Populates scalar fields and safe defaults for a data file. + * + * @param dataFile data file being initialized + * @param fileBuildContext file build context + * @param context fixture build context + */ + void populateDataFile(DataFile dataFile, FileBuildContext fileBuildContext, BuildContext context); + + /** + * Populates scalar fields and safe defaults for a data table. + * + * @param dataTable data table being initialized + * @param fileBuildContext file build context + * @param context fixture build context + */ + void populateDataTable(DataTable dataTable, FileBuildContext fileBuildContext, BuildContext context); + + /** + * Populates scalar fields and safe defaults for a data variable. + * + * @param dataVariable data variable being initialized + * @param variableBuildContext variable set build context + * @param variableIndex zero-based variable index within the file/table + * @param context fixture build context + */ + void populateDataVariable( + DataVariable dataVariable, + VariableSetBuildContext variableBuildContext, + int variableIndex, + BuildContext context + ); + + /** + * Populates scalar fields and safe defaults for metadata of a variable. + * + * @param metadata variable metadata being initialized + * @param variableMetadataBuildContext variable metadata build context + */ + void populateVariableMetadata( + VariableMetadata metadata, + VariableMetadataBuildContext variableMetadataBuildContext + ); + + /** + * Populates scalar fields and safe defaults for a variable group. + * + * @param varGroup var group being initialized + * @param fileBuildContext file build context + * @param groupIndex zero-based group index within the file + * @param context fixture build context + */ + void populateVarGroup( + VarGroup varGroup, + FileBuildContext fileBuildContext, + int groupIndex, + BuildContext context + ); + + /** + * Returns a deterministic, minimal-safe entity populator. + * + *This implementation is intentionally conservative. It sets enough fields + * for fixture graphs to be usable in persistence and serialization tests, + * without trying to simulate realistic production content yet.
+ * + * @return standard, minimalized, and deterministic field populator + */ + static FixturePopulator minimal() { + return new MinimalPopulator(); + } +} diff --git a/src/test/java/edu/harvard/iq/dataverse/util/testing/fixtures/MinimalPopulator.java b/src/test/java/edu/harvard/iq/dataverse/util/testing/fixtures/MinimalPopulator.java new file mode 100644 index 00000000000..7add6a5b4b4 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/util/testing/fixtures/MinimalPopulator.java @@ -0,0 +1,180 @@ +package edu.harvard.iq.dataverse.util.testing.fixtures; + +import edu.harvard.iq.dataverse.DataFile; +import edu.harvard.iq.dataverse.DataTable; +import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.DatasetVersion; +import edu.harvard.iq.dataverse.FileMetadata; +import edu.harvard.iq.dataverse.TermsOfUseAndAccess; +import edu.harvard.iq.dataverse.datavariable.DataVariable; +import edu.harvard.iq.dataverse.datavariable.VarGroup; +import edu.harvard.iq.dataverse.datavariable.VariableMetadata; +import edu.harvard.iq.dataverse.util.testing.recipes.FileBuildContext; +import edu.harvard.iq.dataverse.util.testing.recipes.VariableMetadataBuildContext; +import edu.harvard.iq.dataverse.util.testing.recipes.VariableSetBuildContext; + +import java.util.ArrayList; +import java.util.HashSet; + +public final class MinimalPopulator implements FixturePopulator { + + /** + * Populates basic dataset scalar fields. + * + * @param dataset dataset being initialized + * @param context fixture build context + */ + @Override + public void populateDataset(Dataset dataset, BuildContext context) { + dataset.setProtocol("doi"); + dataset.setAuthority("10.5072"); + dataset.setIdentifier("fixture-dataset-" + context.sequence()); + dataset.setStorageIdentifier("fixture-storage-" + context.sequence()); + + // necessary as DvObject says "not nullable" + dataset.setCreateDate(context.getTimestamp()); + dataset.setModificationTime(context.getTimestamp()); + } + + /** + * Populates basic dataset-version scalar fields, timestamps, and terms. + * + * @param version dataset version being initialized + * @param context fixture build context + */ + @Override + public void populateDatasetVersion(DatasetVersion version, BuildContext context) { + version.setVersionNumber(1L); + version.setMinorVersionNumber(0L); + version.setVersionState(DatasetVersion.VersionState.DRAFT); + version.setVersionNote("fixture-version"); + version.setCreateTime(context.getDate()); + version.setLastUpdateTime(context.getDate()); + + // TermsOfUseAndAccess and DatasetVersion are mutually linked via a OneToOne. + // The validator reads datasetVersion from the terms object, so both sides + // must be wired before the entity graph is persisted. + TermsOfUseAndAccess terms = new TermsOfUseAndAccess(); + terms.setDatasetVersion(version); + version.setTermsOfUseAndAccess(terms); + } + + /** + * Populates basic file-metadata scalar fields. + * + * @param fileMetadata file metadata being initialized + * @param fileBuildContext file build context + * @param context fixture build context + */ + @Override + public void populateFileMetadata(FileMetadata fileMetadata, FileBuildContext fileBuildContext, BuildContext context) { + fileMetadata.setLabel("file-" + fileBuildContext.fileIndex() + ".tab"); + fileMetadata.setDescription("Fixture file " + fileBuildContext.fileIndex()); + fileMetadata.setVarGroups(new ArrayList<>()); + fileMetadata.setVariableMetadatas(new ArrayList<>()); + } + + /** + * Populates basic data-file scalar fields and null-sensitive defaults. + * + * @param dataFile data file being initialized + * @param fileBuildContext file build context + * @param context fixture build context + */ + @Override + public void populateDataFile(DataFile dataFile, FileBuildContext fileBuildContext, BuildContext context) { + dataFile.setContentType("text/tab-separated-values"); + dataFile.setChecksumType(DataFile.ChecksumType.SHA1); + dataFile.setChecksumValue("fixture-checksum-" + fileBuildContext.fileIndex()); + dataFile.setFilesize(1024L + fileBuildContext.fileIndex()); + dataFile.setDataTables(new ArrayList<>()); + dataFile.setFileMetadatas(new ArrayList<>()); + dataFile.setTags(new ArrayList<>()); + + // necessary as DvObject says "not nullable" + dataFile.setCreateDate(context.getTimestamp()); + dataFile.setModificationTime(context.getTimestamp()); + } + + /** + * Populates basic data-table scalar fields and variable collection defaults. + * + * @param dataTable data table being initialized + * @param fileBuildContext file build context + * @param context fixture build context + */ + @Override + public void populateDataTable(DataTable dataTable, FileBuildContext fileBuildContext, BuildContext context) { + dataTable.setVarQuantity(0L); + dataTable.setCaseQuantity(100L); + dataTable.setRecordsPerCase(1L); + dataTable.setUnf("UNF:fixture-table-" + fileBuildContext.fileIndex()); + dataTable.setDataVariables(new ArrayList<>()); + dataTable.setOriginalFileFormat("text/tab-separated-values"); + dataTable.setOriginalFileName("fixture-original-" + fileBuildContext.fileIndex() + ".tab"); + dataTable.setOriginalFileSize(2048L + fileBuildContext.fileIndex()); + } + + /** + * Populates basic data-variable scalar fields and initializes collections + * that are null-sensitive in serialization. + * + * @param dataVariable data variable being initialized + * @param variableSetBuildContext larger context of the data variable being populated + * @param variableIndex zero-based variable index within the file/table + * @param context fixture build context + */ + @Override + public void populateDataVariable( + DataVariable dataVariable, + VariableSetBuildContext variableSetBuildContext, + int variableIndex, + BuildContext context + ) { + dataVariable.setName("var_" + variableSetBuildContext.fileIndex() + "_" + variableIndex); + dataVariable.setLabel("Variable " + variableSetBuildContext.fileIndex() + "/" + variableIndex); + dataVariable.setType(DataVariable.VariableType.NUMERIC); + dataVariable.setFileOrder(variableIndex); + dataVariable.setUnf("UNF:fixture-var-" + variableSetBuildContext.fileIndex() + "-" + variableIndex); + dataVariable.setInvalidRanges(new ArrayList<>()); + dataVariable.setSummaryStatistics(new ArrayList<>()); + dataVariable.setCategories(new ArrayList<>()); + dataVariable.setVariableMetadatas(new ArrayList<>()); + dataVariable.setInvalidRangeItems(new ArrayList<>()); + } + + /** + * Populates metadata for a data variable. Updates the label with a unique identifier + * generated based on the provided build context. + * + * @param metadata the variable metadata object to be populated + * @param variableMetadataBuildContext the context containing information about + * the variable, including file and variable indices + */ + @Override + public void populateVariableMetadata(VariableMetadata metadata, VariableMetadataBuildContext variableMetadataBuildContext) { + metadata.setLabel("variable-metadata-" + variableMetadataBuildContext.fileIndex() + + "-" + variableMetadataBuildContext.variableIndex()); + } + + /** + * Populates basic variable-group scalar fields and initializes the backing + * variable set. + * + * @param varGroup var group being initialized + * @param fileBuildContext file build context + * @param groupIndex zero-based group index within the file + * @param context fixture build context + */ + @Override + public void populateVarGroup( + VarGroup varGroup, + FileBuildContext fileBuildContext, + int groupIndex, + BuildContext context + ) { + varGroup.setLabel("group-" + fileBuildContext.fileIndex() + "-" + groupIndex); + varGroup.setVarsInGroup(new HashSet<>()); + } + +} diff --git a/src/test/java/edu/harvard/iq/dataverse/util/testing/performance/JpaEntityManagerService.java b/src/test/java/edu/harvard/iq/dataverse/util/testing/performance/JpaEntityManagerService.java new file mode 100644 index 00000000000..a2b9927638e --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/util/testing/performance/JpaEntityManagerService.java @@ -0,0 +1,135 @@ +package edu.harvard.iq.dataverse.util.testing.performance; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.EntityTransaction; +import jakarta.persistence.Persistence; +import net.ttddyy.dsproxy.support.ProxyDataSourceBuilder; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; + +/** + * Service class managing the lifecycle and operations of an {@link EntityManagerFactory} + * for JPA-based persistence. This class is responsible for configuring the persistence + * unit, initializing the factory, and providing utility methods to interact with JPA + * entities within transactions. + * + * Implementation contracts: + * - The service must be explicitly started with the {@code start()} method before usage. + * - Resources are properly released when the service is closed via the {@code close()} method. + * - Transactions are managed and isolated when executing database operations. + * + * Use cases: + * - Configure and initialize an {@link EntityManagerFactory} with a non-JTA datasource. + * - Manage entity operations within transactions, supporting both functional and void work units. + * - Validate the underlying datasource and factory to ensure system integrity. + */ +public class JpaEntityManagerService implements AutoCloseable { + + public static final String PERSISTENCE_UNIT = "VDCNet-ejbPU-test"; + + private final DataSource baseDataSource; + private DataSource proxiedDataSource; + private EntityManagerFactory emf; + + public JpaEntityManagerService(DataSource dataSource) { + this.baseDataSource = dataSource; + } + + public void start() { + if (emf != null) { + throw new IllegalStateException("JpaEntityManagerService has already been started."); + } + + proxiedDataSource = ProxyDataSourceBuilder.create() + .dataSource(baseDataSource) + .countQuery() + .buildProxy(); + + validateDataSource(proxiedDataSource); + + Map+ * Applies automatic tags, enforces Testcontainers availability (skips if Docker is missing), + * and registers a custom extension to manage a shared PostgreSQL container and database isolation. + *
+ * Contract: Test classes using this annotation MUST declare a {@code static JpaEntityManagerService} field. + * Note: Due to the underlying extension's shared container management, test classes annotated with this + * will execute sequentially to prevent container state races. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Tag(Tags.PERFORMANCE_TEST) +@Tag(Tags.USES_TESTCONTAINERS) +@Testcontainers(disabledWithoutDocker = true) +@ExtendWith(JpaPerformanceTestExtension.class) +// Make sure the test methods are never run in parallel - this would be bad for a performance test... +@Execution(ExecutionMode.SAME_THREAD) +public @interface JpaPerformanceTest { +} diff --git a/src/test/java/edu/harvard/iq/dataverse/util/testing/performance/JpaPerformanceTestExtension.java b/src/test/java/edu/harvard/iq/dataverse/util/testing/performance/JpaPerformanceTestExtension.java new file mode 100644 index 00000000000..997da6aab84 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/util/testing/performance/JpaPerformanceTestExtension.java @@ -0,0 +1,135 @@ +package edu.harvard.iq.dataverse.util.testing.performance; + +import org.apache.commons.dbcp2.BasicDataSource; +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.testcontainers.postgresql.PostgreSQLContainer; + +import java.lang.reflect.Field; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.UUID; + +import static java.lang.reflect.Modifier.isStatic; + +/** + * JUnit 5 Extension that manages a shared PostgreSQL container for performance tests. + * It ensures a unique database is created for each test class to guarantee isolation. + */ +public class JpaPerformanceTestExtension implements BeforeAllCallback, AfterAllCallback { + + // Global shared container + private static PostgreSQLContainer sharedContainer; + + // This lock makes sure all tests using this extension are executed sequentially. + // For performance tests, executing test classes in parallel for the same, shared DB instance makes no sense. + // There is no JUnit way to express such a "global lock", thus we need to do this manually. + // Note: avoiding parallelism of test methods are done by the @JpaPerformanceTest annotation. + private static final Object CONTAINER_LOCK = new Object(); + + // Store the service instance to close it in AfterAll + private static final String SERVICE_FIELD_KEY = "jpa.service.instance"; + + @Override + public void beforeAll(ExtensionContext context) throws Exception { + // 1. Ensure the global container is running + ensureSharedContainerRunning(); + + // 2. Create a unique database for this test class + String uniqueDbName = "perf_test_" + UUID.randomUUID().toString().substring(0, 8); + createDatabase(uniqueDbName); + + // 3. Retrieve the JPA Service and inject into the test class field + JpaEntityManagerService service = getService(uniqueDbName); + injectService(context, service); + + // 4. Store reference for cleanup + context.getStore(ExtensionContext.Namespace.GLOBAL).put(SERVICE_FIELD_KEY, service); + } + + @Override + public void afterAll(ExtensionContext context) { + // Close the EntityManagerFactory and connections + JpaEntityManagerService service = (JpaEntityManagerService) context.getStore(ExtensionContext.Namespace.GLOBAL).get(SERVICE_FIELD_KEY); + if (service != null) { + try { + service.close(); + } catch (Exception e) { + e.printStackTrace(); + } + } + // Note: We do NOT stop the sharedContainer here. + // It stays running for the next test class. + } + + // --- Helper Methods --- + + private void ensureSharedContainerRunning() { + synchronized (CONTAINER_LOCK) { + if (sharedContainer == null || !sharedContainer.isRunning()) { + String pgVersion = System.getProperty("postgresql.server.version", "16"); + sharedContainer = new PostgreSQLContainer("postgres:" + pgVersion); + sharedContainer.start(); + } + } + } + + private void createDatabase(String dbName) { + try (Connection conn = DriverManager.getConnection( + sharedContainer.getJdbcUrl(), + sharedContainer.getUsername(), + sharedContainer.getPassword())) { + + // Postgres requires auto-commit to be true for CREATE DATABASE + conn.setAutoCommit(true); + Statement stmt = conn.createStatement(); + stmt.execute("CREATE DATABASE " + dbName); + } catch (SQLException e) { + // Ignore if DB already exists (unlikely with UUID, but safe) + if (!e.getMessage().contains("already exists")) { + throw new RuntimeException("Failed to create test database: " + dbName, e); + } + } + } + + private void injectService(ExtensionContext context, JpaEntityManagerService service) throws Exception { + Class> testClass = context.getRequiredTestClass(); + boolean hasBeenInjected = false; + + // Look for a static field of type JpaService + for (Field field : testClass.getDeclaredFields()) { + if (field.getType() == JpaEntityManagerService.class) { + if (!isStatic(field.getModifiers())) { + throw new RuntimeException("Cannot inject into field '" + field.getName() + "' of class '" + testClass.getName() + "': not a static field"); + } + if (hasBeenInjected) { + throw new RuntimeException("Cannot inject into field '" + field.getName() + "' of class '" + testClass.getName() + "': only one target field allowed"); + } + field.setAccessible(true); + field.set(null, service); + hasBeenInjected = true; + } + } + + if (!hasBeenInjected) { + throw new RuntimeException("Could not inject into a static field of class '" + testClass.getName() + "': no field found"); + } + } + + private static JpaEntityManagerService getService(String uniqueDbName) { + // Tune the URL as we need to apply our unique DB name (the container has a default one we override) + String tunedJdbcUrl = sharedContainer.getJdbcUrl() + .replaceFirst("/" + sharedContainer.getDatabaseName(), "/" + uniqueDbName); + + // Configure a pooled (!) DataSource for this unique database + BasicDataSource dataSource = new BasicDataSource(); + dataSource.setUrl(tunedJdbcUrl); + dataSource.setUsername(sharedContainer.getUsername()); + dataSource.setPassword(sharedContainer.getPassword()); + + return new JpaEntityManagerService(dataSource); + } +} diff --git a/src/test/java/edu/harvard/iq/dataverse/util/testing/recipes/DatasetRecipe.java b/src/test/java/edu/harvard/iq/dataverse/util/testing/recipes/DatasetRecipe.java new file mode 100644 index 00000000000..3c01e275ad9 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/util/testing/recipes/DatasetRecipe.java @@ -0,0 +1,57 @@ +package edu.harvard.iq.dataverse.util.testing.recipes; + +import java.util.Objects; + +/** + * Top-level recipe describing how to construct a {@code Dataset} fixture. + * + *
This is intentionally rooted at the dataset level rather than the dataset + * version level, so the fixture system can later support scenarios involving + * multiple versions, different current-version shapes, and dataset-level + * performance tests.
+ * + *For the initial implementation, a dataset recipe exposes exactly one + * "current version" recipe. This keeps the model simple while leaving room + * to evolve later.
+ */ +public interface DatasetRecipe { + + /** + * Returns the dataset type recipe providing the type to assign. + * + * @return dataset type recipe + */ + DatasetTypeRecipe datasetTypeRecipe(); + + /** + * Returns the recipe describing the current version of the dataset. + * + * @return recipe for the current dataset version + */ + VersionRecipe currentVersionRecipe(); + + /** + * Creates a dataset recipe with the supplied type and version recipes. + * + * @param datasetTypeRecipe recipe providing the dataset type + * @param currentVersionRecipe recipe for the current dataset version + * @return a dataset recipe + */ + static DatasetRecipe of(DatasetTypeRecipe datasetTypeRecipe, VersionRecipe currentVersionRecipe) { + Objects.requireNonNull(datasetTypeRecipe, "datasetTypeRecipe must not be null"); + Objects.requireNonNull(currentVersionRecipe, "currentVersionRecipe must not be null"); + return new SimpleDatasetRecipe(datasetTypeRecipe, currentVersionRecipe); + } + + /** + * Minimal immutable implementation of {@link DatasetRecipe}. + * + * @param datasetTypeRecipe recipe providing the dataset type + * @param currentVersionRecipe recipe for the current dataset version + */ + record SimpleDatasetRecipe( + DatasetTypeRecipe datasetTypeRecipe, + VersionRecipe currentVersionRecipe + ) implements DatasetRecipe { + } +} diff --git a/src/test/java/edu/harvard/iq/dataverse/util/testing/recipes/DatasetTypeRecipe.java b/src/test/java/edu/harvard/iq/dataverse/util/testing/recipes/DatasetTypeRecipe.java new file mode 100644 index 00000000000..8a78f37b2b1 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/util/testing/recipes/DatasetTypeRecipe.java @@ -0,0 +1,93 @@ +package edu.harvard.iq.dataverse.util.testing.recipes; + +import edu.harvard.iq.dataverse.dataset.DatasetType; + +/** + * Recipe providing the {@link DatasetType} to assign to a generated dataset fixture. + * + *Unlike structural recipes such as {@link VersionRecipe} or {@link FileRecipe}, + * this is not a construction recipe. It is a reference/creation provider — the + * dataset type it produces is expected to be persisted before the dataset fixture + * is committed to the database.
+ * + *Two factory styles are available:
+ *The returned instance may be newly created or pre-existing, depending on + * the implementation. Either way, it must be persisted before the dataset + * fixture is committed to the database.
+ * + * @return dataset type instance + */ + DatasetType datasetType(); + + /** + * Creates a recipe that builds a new {@link DatasetType} from the supplied + * scalar values. + * + *This is the preferred factory for single-dataset fixture scenarios where + * the type does not need to be reused or pre-built externally. The resulting + * type will need to be persisted before the dataset is committed.
+ * + * @param name machine-readable name used in APIs and stored in the database + * @param displayName human-readable name shown in the UI + * @param description optional description of the dataset type + * @return a dataset type recipe producing a new type from the supplied values + */ + static DatasetTypeRecipe of(String name, String displayName, String description) { + DatasetType datasetType = new DatasetType(); + datasetType.setName(name); + datasetType.setDisplayName(displayName); + datasetType.setDescription(description); + return new FixedDatasetTypeRecipe(datasetType); + } + + /** + * Creates a recipe that wraps a pre-existing {@link DatasetType} instance. + * + *Use this when the type has already been persisted, or when you want to + * share the same type instance across multiple dataset recipes.
+ * + * @param datasetType pre-existing dataset type to use + * @return a dataset type recipe wrapping the supplied instance + */ + static DatasetTypeRecipe of(DatasetType datasetType) { + return new FixedDatasetTypeRecipe(datasetType); + } + + /** + * Creates a recipe using the standard {@value DatasetType#DATASET_TYPE_DATASET} + * dataset type with sensible display defaults. + * + *This is a convenience shortcut for the most common fixture scenario, + * where you just need a valid persisted type and do not care about specific + * type semantics.
+ * + * @return a dataset type recipe for the default dataset type + */ + static DatasetTypeRecipe dataset() { + return of(DatasetType.DATASET_TYPE_DATASET, "Dataset", "Standard dataset type for fixtures"); + } + + /** + * Minimal immutable recipe holding a fixed dataset type instance. + * + * @param datasetType dataset type to return + */ + record FixedDatasetTypeRecipe( + DatasetType datasetType + ) implements DatasetTypeRecipe { + } +} \ No newline at end of file diff --git a/src/test/java/edu/harvard/iq/dataverse/util/testing/recipes/FileBuildContext.java b/src/test/java/edu/harvard/iq/dataverse/util/testing/recipes/FileBuildContext.java new file mode 100644 index 00000000000..cad16f95055 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/util/testing/recipes/FileBuildContext.java @@ -0,0 +1,15 @@ +package edu.harvard.iq.dataverse.util.testing.recipes; + +/** + * Context object supplied while deciding how to build a file fixture. + * + *For now this context only exposes the file index and the recipe which ordered the creation of the file. + * It exists as a dedicated type, so the API can grow later without constantly changing method signatures.
+ * + * @param fileIndex zero-based index of the file being created within a version + */ +public record FileBuildContext( + FileRecipe fileRecipe, + int fileIndex +) { +} diff --git a/src/test/java/edu/harvard/iq/dataverse/util/testing/recipes/FileRecipe.java b/src/test/java/edu/harvard/iq/dataverse/util/testing/recipes/FileRecipe.java new file mode 100644 index 00000000000..8a2a5336c9a --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/util/testing/recipes/FileRecipe.java @@ -0,0 +1,28 @@ +package edu.harvard.iq.dataverse.util.testing.recipes; + +public interface FileRecipe { + + /** + * Returns the total number of files to create. + * + * @return number of files in the generated dataset version + */ + int fileCount(); + + static FileRecipe tabular(int fileCount, VariableSetRecipe recipe) { + return new Tabular(fileCount, recipe); + } + + static FileRecipe regular(int fileCount) { + return new Regular(fileCount); + } + + record Tabular ( + int fileCount, + VariableSetRecipe variableSetRecipe + ) implements FileRecipe {} + + record Regular ( + int fileCount + ) implements FileRecipe {} +} diff --git a/src/test/java/edu/harvard/iq/dataverse/util/testing/recipes/VariableMetadataBuildContext.java b/src/test/java/edu/harvard/iq/dataverse/util/testing/recipes/VariableMetadataBuildContext.java new file mode 100644 index 00000000000..f2ad493e4f0 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/util/testing/recipes/VariableMetadataBuildContext.java @@ -0,0 +1,24 @@ +package edu.harvard.iq.dataverse.util.testing.recipes; + +/** + * Context object supplied while deciding whether a variable should receive + * {@link edu.harvard.iq.dataverse.datavariable.VariableMetadata}. + * + *A variable metadata entry belongs to a specific pair of:
+ *For now this context only exposes file and variable indices. It can grow + * later as fixture requirements become more sophisticated.
+ * + * @param fileIndex zero-based file index + * @param variableIndex zero-based variable index within the file/table + */ +public record VariableMetadataBuildContext( + FileRecipe.Tabular tabularRecipe, + int fileIndex, + int variableIndex +) { +} diff --git a/src/test/java/edu/harvard/iq/dataverse/util/testing/recipes/VariableMetadataRecipe.java b/src/test/java/edu/harvard/iq/dataverse/util/testing/recipes/VariableMetadataRecipe.java new file mode 100644 index 00000000000..f71bb670db8 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/util/testing/recipes/VariableMetadataRecipe.java @@ -0,0 +1,87 @@ +package edu.harvard.iq.dataverse.util.testing.recipes; + +import java.util.function.Predicate; + +/** + * Recipe describing whether a {@link edu.harvard.iq.dataverse.datavariable.VariableMetadata} row should be created for + * a generated {@code (FileMetadata, DataVariable)} pair. + * + *This is modeled as a yes/no decision because the current schema enforces + * uniqueness for each pair of {@code datavariable_id} and {@code filemetadata_id}. + * As filemetadata is associated with a single dataset version, this makes variable metadata versioned, too.
+ */ +public interface VariableMetadataRecipe { + + /** + * Returns whether metadata should be created for the supplied pair context. + * + * @param context build context describing the file-variable pair + * @return {@code true} if metadata should be created, otherwise {@code false} + */ + boolean createFor(VariableMetadataBuildContext context); + + /** + * Returns a recipe that never creates metadata. + * + * @return no-op recipe + */ + static VariableMetadataRecipe noop() { + return new Noop(); + } + + /** + * Returns a recipe that always creates metadata. + * + * @return always-on recipe + */ + static VariableMetadataRecipe always() { + return new Always(); + } + + /** + * Returns a predicate-driven metadata recipe. + * + * @param predicate predicate deciding whether metadata should be created + * @return predicate-based recipe + */ + static VariableMetadataRecipe byPredicate(PredicateAt present this only carries the file index. It is intentionally separated + * from {@link FileBuildContext} because variable population decisions may later + * need different context, such as table index, dataset version information, + * recipe seed, or file type details.
+ * + * @param fileIndex zero-based index of the file for which variables are being created + */ +public record VariableSetBuildContext( + FileRecipe.Tabular tabularRecipe, + int fileIndex +) { +} diff --git a/src/test/java/edu/harvard/iq/dataverse/util/testing/recipes/VariableSetRecipe.java b/src/test/java/edu/harvard/iq/dataverse/util/testing/recipes/VariableSetRecipe.java new file mode 100644 index 00000000000..3a88eb2db94 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/util/testing/recipes/VariableSetRecipe.java @@ -0,0 +1,153 @@ +package edu.harvard.iq.dataverse.util.testing.recipes; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; + +/** + * Recipe describing how many variables should be created for a tabular file + * or data table, and whether generated file-variable pairs should receive + * {@link edu.harvard.iq.dataverse.datavariable.VariableMetadata}. + */ +public interface VariableSetRecipe { + + /** + * Returns the number of variables to create for the given context. + * + * @param context contextual information about the file/table being populated + * @return variable count to create + */ + int variableCount(VariableSetBuildContext context); + + /** + * Returns the recipe describing whether metadata should be created for a + * generated {@code (FileMetadata, DataVariable)} pair. + * + * @return variable metadata recipe + */ + VariableMetadataRecipe variableMetadataRecipe(); + + /** + * Creates a uniform variable set recipe with no metadata generation. + * + * @param variableCount uniform variable count + * @return uniform variable set recipe + */ + static VariableSetRecipe uniform(int variableCount) { + return new UniformVariableSetRecipe(variableCount, VariableMetadataRecipe.noop()); + } + + /** + * Creates a uniform variable set recipe with the supplied metadata recipe. + * + * @param variableCount uniform variable count + * @param variableMetadataRecipe metadata recipe for generated pairs + * @return uniform variable set recipe + */ + static VariableSetRecipe uniform(int variableCount, VariableMetadataRecipe variableMetadataRecipe) { + return new UniformVariableSetRecipe(variableCount, variableMetadataRecipe); + } + + /** + * Creates a predicate-driven variable set recipe with no metadata generation. + * + * @return predicate-driven variable set recipe + */ + static PredicateVariableSetRecipe byPredicate() { + return new PredicateVariableSetRecipe(VariableMetadataRecipe.noop()); + } + + /** + * Creates a predicate-driven variable set recipe with the supplied metadata recipe. + * + * @param variableMetadataRecipe metadata recipe for generated pairs + * @return predicate-driven variable set recipe + */ + static PredicateVariableSetRecipe byPredicate(VariableMetadataRecipe variableMetadataRecipe) { + return new PredicateVariableSetRecipe(variableMetadataRecipe); + } + + /** + * Uniform variable set recipe. + * + * @param variableCount uniform variable count + * @param variableMetadataRecipe metadata recipe for generated pairs + */ + record UniformVariableSetRecipe( + int variableCount, + VariableMetadataRecipe variableMetadataRecipe + ) implements VariableSetRecipe { + + @Override + public int variableCount(VariableSetBuildContext context) { + return variableCount; + } + } + + /** + * Predicate-driven variable set recipe. + */ + final class PredicateVariableSetRecipe implements VariableSetRecipe { + + private final ListAt this stage, a version recipe is mainly responsible for delegating to a + * {@link FileRecipe}, which controls how files in that version are created.
+ * + *Later, this type can be extended with more version-level concerns such as: + * draft/released state, timestamps, version numbering, or version-specific + * metadata enrichment.
+ */ +public interface VersionRecipe { + + /** + * Returns the file recipes for this dataset version. + * + * @return recipes governing file creation for the version + */ + List