diff --git a/CLAUDE.md b/CLAUDE.md index 4191582f8..fcc81e523 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -147,7 +147,7 @@ Integration tests use `DatabaseEnvironment` to bootstrap the target database fro - Adhere strictly to de facto standard Git commit message formatting. - Use Conventional Commits format. - **Commit Types:** `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `build:`, `ci:`, `perf:` - - **Scopes:** any of the database names, `assertion`, `pom`, `log`, `docker`, `database`, `dataset`, `metadata`, `resultset`, `scripts`, `site`, `statement`, `search` + - **Scopes:** any of the database names, `assertion`, `pom`, `log`, `docker`, `database`, `dataset`, `metadata`, `resultset`, `scripts`, `site`, `statement`, `search`, `util`, `ant`, `operation` - Capitalize the first word after the type and scope. - You may suggest additional CC commit types and scopes when encountering situations where the changes do not fit into the approved lists above. - Reference GitHub issues in the commit footer with `Refs: ` (e.g. `Refs: 123`). Do not use a # before the number. diff --git a/pom.xml b/pom.xml index 98c0fa7b4..10c50841b 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ org.dbunit dbunit - 3.3.1-SNAPSHOT + 3.4.0-SNAPSHOT jar dbUnit Extension https://github.com/dbunit/dbunit-extension @@ -47,8 +47,6 @@ 1.5.38 5.23.0 5.2.5 - 1.7.25 - 2.2 23.26.2.0.0 @@ -1039,8 +1037,6 @@ - 3.2.0-to-3.3.0 diff --git a/src/changes/changes.xml b/src/changes/changes.xml index 0727491ab..f08e57b04 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -13,7 +13,7 @@ - + Implement the 8 empty, @Disabled DefaultPrepAndExpectedTestCaseTest stub methods covering preTest, postTest, setupData, verifyData, cleanupData, and makeCompositeDataSet, using the class's existing Mockito-based connection harness. Move the test fixture's databaseTester/tc construction from field initializers to a @BeforeEach method so @Mock injection (MockitoExtension) completes before they are built. @@ -35,6 +35,123 @@ Add real assertions to DefaultPrepAndExpectedTestCaseTest.testApplyColumnFiltersBothNull/BothNotNull, which previously discarded applyColumnFilters()'s result and asserted nothing, and document via the not-null case that include filtering is applied before exclude filtering. + + Add DatabaseTestCase.tearDown(Throwable), for use when a test failure is already in flight, that attaches a tear-down operation failure to the original failure via addSuppressed instead of letting it replace and hide the original. The existing no-arg tearDown() is unchanged. + + + Fix DefaultPrepAndExpectedTestCase.runTest() losing the original test failure when the cleanup path (postTest(false)) also throws: attach the cleanup failure to the original via addSuppressed instead of letting it replace and hide the real test failure, mirroring DatabaseTestCase.tearDown(Throwable). + + + Rewrite DbUnitTaskIT's obsolete queryset id/refid test: it asserted the old behavior of throwing a BuildException when both attributes are set, but Ant resolves refid first and no longer errors on the combination; the test now asserts today's actual, no-throw behavior. + + + Delete two commented-out test fragments that encoded no restorable behavior: InsertOperationIT's testExecuteNullAsNone referenced DatabaseConfig.FEATURE_NULL_AS_NONE, which does not exist anywhere in production code (an apparently never-implemented feature; the NO_VALUE-omission behavior it targeted is already covered by testExecute_withNoValueFields_omitsNoValueColumnsFromInsertSql in InsertOperationTest), and DatabaseDataSetIT's testGetTableNamesAndCaseSensitive was two lines cut off mid-statement with no coherent assertion, probing raw DatabaseMetaData introspection that dbunit's own case-sensitivity handling (FEATURE_CASE_SENSITIVE_TABLE_NAMES) does not use. + + + Rename the stale JUnit3-era xtest*-prefixed methods in PostgresSQLOidIT, PostgresqlUuidIT, and SQLHelperDomainPostgreSQLIT to test*, and remove the try/catch wrapper around each body that reported a nonsense fixed message instead of the real failure whenever a non-assertion exception occurred (AssertionError is not an Exception, so real assertion failures already propagated normally; only the underlying operation's own failures were obscured). Verified against a Dockerized postgres:16.3. + + + Add CachingConnectionProvider: an opt-in, thread-safe cache for one IDatabaseConnection (and therefore its metadata cache) that detects a dead or expired connection and transparently replaces it instead of returning it or failing. Wire it into JdbcDatabaseTester, DataSourceDatabaseTester, JndiDatabaseTester, PropertiesBasedJdbcDatabaseTester, and DefaultDatabaseTester via new, additive constructor overloads, making the existing manual DefaultDatabaseTester + NO_OP_OPERATION_LISTENER reuse pattern safe to opt into without hand-wiring a static connection field. Update the FAQ's performance and keep-connection-open entries accordingly. Verified against hsqldb, h2, derby, and postgresql. + + + Reduce DefaultPrepAndExpectedTestCase's per-test connection churn: setupData(), verifyData(), and cleanupData() now share one IDatabaseConnection acquired lazily and closed once by cleanupData(), instead of each step acquiring and often closing its own; configureTest() caches its resolved FEATURE_CASE_SENSITIVE_TABLE_NAMES value so cleanupData() no longer needs a second connection just to re-read it. Together these cut a 4-5-connection-per-test lifecycle down to 1-2. Add setCloseConnectionAfterTest(boolean), defaulting to true, so a databaseTester sharing a CachingConnectionProvider across test methods can opt this class out of closing a connection other tests still expect to reuse. Verified against all 9 supported databases (hsqldb, h2, derby, postgresql, mysql, mssql, db2, oracle-18, oracle-23). + + + Fix DefaultPrepAndExpectedTestCase.makeExpectedTableColumns() column-name matching to use Locale.ENGLISH instead of the JVM default locale, so a Turkish default locale's dotless-i no longer breaks matching differently-cased ASCII column names, matching DatabaseDataSet.SchemaSet.normalizeSchema's existing convention. + + + Harmonize DefaultPrepAndExpectedTestCase.getSetUpOperation()/getTearDownOperation() on throwing IllegalStateException for a null databaseTester, matching setupData()/verifyData()/cleanupData(), instead of JUnit's assertNotNull()/AssertionError; remove the now-unused assertNotNull import. + + + Code-quality cleanups in DefaultPrepAndExpectedTestCase: use generics in makeCompositeDataSet() instead of a raw List and unchecked array cast, move applyColumnFilters()'s null check ahead of first use, log include/exclude column filters with Arrays.toString() instead of a confusing single-element Object[] wrapper, and document the legacy JUnit-3-era tearDown() hook's non-invocation under JUnit 5 and its double-cleanup risk. + + + Pin remaining identifier case folding to Locale.ENGLISH so Turkish-family default locales no longer break table/column matching, filtering, and extended-syntax command parsing. + + + Fix NullPointerException exporting datasets containing NO_VALUE cells to flat XML, and stop YAML export dumping the raw NO_VALUE sentinel. + + + Fix SQLHelper.logInfoIfValueChanged's isInfoEnabled() guard not matching its logger.debug() call, which silently dropped the schema-correction notice whenever INFO was enabled but DEBUG was not; the guard now checks isDebugEnabled() to match, keeping the notice at its original DEBUG level. + + + Close file/URL input streams in BytesDataType (handle leak; Windows file locking) and read them in chunks instead of per byte. + + + BytesDataType now throws TypeCastException for an invalid [text] encoding id or corrupt [base64] input instead of storing error text or NULL, and the plain-text fallback consistently uses UTF-8. Behavior change: datasets that previously "worked" by silently inserting garbage/NULL now fail fast, and fallback text bytes change on platforms whose default charset is not UTF-8. + + + Fix QualifiedTableName escaping treating a single-character segment immediately before a dot as part of the following segment instead of splitting them. + + + Fix the Ant export step rejecting format="CSV" in uppercase and leaving an empty output file behind for unsupported formats. Also, an unsupported format is now rejected before the database connection is queried for the export dataset, instead of after, since format validity doesn't depend on the connection. + + + Fix Export.setFormat()'s IllegalArgumentException message omitting 'csv' from the list of valid formats, even though csv has always been accepted. + + + DefaultPrepAndExpectedTestCase.postTest now attaches a verify failure as suppressed on the cleanup exception that deliberately shadows it, instead of losing it. + + + Fix InsertOperation reusing a statement across rows whose null-with-column-default shape differs, which bound NULL into NOT NULL DEFAULT columns depending on row order. + + + XML export now round-trips supplementary characters (previously written as invalid surrogate entities) and replaces XML-unrepresentable control characters with U+FFFD plus a warning (previously written raw, producing unparseable files). + + + Fix TimestampDataType timezone application mis-encoding pre-epoch fractional-second timestamps and throwing on negative encoded times. + + + TransactionOperation now rolls back when an Error propagates instead of implicitly committing partial work via auto-commit restoration. Also, in all four rollback catch blocks (DatabaseUnitException, SQLException, RuntimeException, and Error), a rollback failure is now attached to the original exception as suppressed instead of replacing it, via a shared handleException() helper. + + + XlsTable now reports 0 rows for an empty sheet instead of -1 and returns nulls for physically-missing rows instead of throwing NullPointerException. + + + YAML and flat-DTD exports, and OracleXMLTypeDataType's internal byte/String conversions, are now UTF-8 explicitly instead of the platform default charset, matching what their readers decode; previously non-ASCII exports were unreadable on platforms with a non-UTF-8 default charset. + + + Skip a redundant per-table getTables metadata query in DatabaseDataSet.getTableMetaData; existence is already proven by the dataset's table enumeration. + + + Guard or remove per-cell debug logging in the datatype package to eliminate autoboxing overhead on hot read/write paths. + + + Fix a data race that could mask the real producer failure behind a generic interruption error in StreamingIterator. + + + Fix CsvParser duplicating the continuation line in its buffer when a multi-line field completes with too few columns, garbling the error report or fabricating data. + + + Use DatabaseConfig.getFeature for the qualified-table-names check in DatabaseDataSet instead of a Boolean reference comparison. + + + Fix FileHelper.copyFile leaking the source stream when the destination cannot be opened. + + + DefaultPrepAndExpectedTestCase.configureTest now keeps its lazily-acquired connection open for the test lifecycle instead of closing it for setupData to immediately reacquire, completing the one-connection-per-test reduction; like setupData/verifyData, a standalone configureTest call leaves the connection open until cleanupData. + + + Remove the dead, never-assigned PreparedStatement field from RefreshOperation's UpdateRowOperation, and close both row operations in execute's cleanup even if the first close fails. + + + Route Base64's bad-input console diagnostic through its slf4j logger at WARN instead of System.err.println, and remove four e.printStackTrace() calls that duplicated an adjacent logger.error() call to stderr. + + + Remove XmlWriter's dead private setEncoding(String) overload, unreachable from setWriter and never matching the common "UTF-8" spelling even if it were called. + + + Fix CsvProducer truncating a table name at the first ".csv" occurrence instead of the last, which misread a table named e.g. a.csvx back as a on round-trip; also null-safe the NULL-token comparison. + + + Remove the unused, stale slf4jVersion and snakeYamlVersion pom properties, superseded by the actually-used slf4jApiVersion and snakeyamlVersion. + + + Reindent SQLHelper.logDebugIfValueChanged's closing braces to match their actual nesting depth; cosmetic only, brace count was already balanced. + + + Centralize the duplicated Turkish-locale save/mutate/restore boilerplate across 13 test classes into a shared @TurkishDefaultLocale JUnit 5 extension. + diff --git a/src/main/java/org/dbunit/DataSourceDatabaseTester.java b/src/main/java/org/dbunit/DataSourceDatabaseTester.java index fd1909ff5..672209824 100644 --- a/src/main/java/org/dbunit/DataSourceDatabaseTester.java +++ b/src/main/java/org/dbunit/DataSourceDatabaseTester.java @@ -25,6 +25,7 @@ import javax.sql.DataSource; +import org.dbunit.database.CachingConnectionProvider; import org.dbunit.database.DatabaseConnection; import org.dbunit.database.IDatabaseConnection; @@ -45,6 +46,7 @@ public class DataSourceDatabaseTester extends AbstractDatabaseTester */ private static final Logger logger = LoggerFactory.getLogger(DataSourceDatabaseTester.class); + private final CachingConnectionProvider connectionProvider; private DataSource dataSource; /** @@ -54,13 +56,7 @@ public class DataSourceDatabaseTester extends AbstractDatabaseTester */ public DataSourceDatabaseTester( DataSource dataSource ) { - super(); - - if (dataSource == null) { - throw new NullPointerException( - "The parameter 'dataSource' must not be null"); - } - this.dataSource = dataSource; + this(dataSource, null, null); } /** @@ -69,15 +65,38 @@ public DataSourceDatabaseTester( DataSource dataSource ) * @param schema The schema name to be used for new dbunit connections * @since 2.4.5 */ - public DataSourceDatabaseTester(DataSource dataSource, String schema) + public DataSourceDatabaseTester(DataSource dataSource, String schema) { + this(dataSource, schema, null); + } + + /** + * Creates a new DataSourceDatabaseTester with the specified DataSource and schema name, + * reusing one {@link IDatabaseConnection} across calls via the given + * {@link CachingConnectionProvider} instead of creating a new one on every call.
+ * Share the same connectionProvider instance across the testers created for + * each test to get reuse across test methods; pair it with + * {@link IOperationListener#NO_OP_OPERATION_LISTENER} (or an equivalent non-closing + * listener) so the cached connection is not closed after every {@link #onSetup()}/ + * {@link #onTearDown()} call. + * + * @param dataSource the DataSource to pull connections from + * @param schema The schema name to be used for new dbunit connections - can be null + * @param connectionProvider caches and validates the connection across calls - can be + * null, in which case a new connection is created on every call as before + * @since 3.4.0 + */ + public DataSourceDatabaseTester(DataSource dataSource, String schema, + CachingConnectionProvider connectionProvider) + { super(schema); - + if (dataSource == null) { throw new NullPointerException( "The parameter 'dataSource' must not be null"); } this.dataSource = dataSource; + this.connectionProvider = connectionProvider; } public IDatabaseConnection getConnection() throws Exception @@ -85,6 +104,15 @@ public IDatabaseConnection getConnection() throws Exception logger.debug("getConnection() - start"); assertTrue( "DataSource is not set", dataSource!=null ); + if (connectionProvider != null) + { + return connectionProvider.getConnection(this::createConnection); + } + return createConnection(); + } + + private IDatabaseConnection createConnection() throws Exception + { return new DatabaseConnection( dataSource.getConnection(), getSchema() ); } } diff --git a/src/main/java/org/dbunit/DatabaseTestCase.java b/src/main/java/org/dbunit/DatabaseTestCase.java index 06692a9c4..b0510932a 100644 --- a/src/main/java/org/dbunit/DatabaseTestCase.java +++ b/src/main/java/org/dbunit/DatabaseTestCase.java @@ -159,17 +159,62 @@ protected void tearDown() throws Exception logger.debug("tearDown() - start"); try { - final IDatabaseTester databaseTester = getDatabaseTester(); - assertNotNull(databaseTester, "DatabaseTester is not set"); - databaseTester.setTearDownOperation(getTearDownOperation()); - databaseTester.setDataSet(getDataSet()); - databaseTester.setOperationListener(getOperationListener()); - databaseTester.onTearDown(); + runTearDownOperation(); } finally { tester = null; } } + /** + * Runs tear down the same as {@link #tearDown()}, for use when a test + * failure is already in flight (e.g. calling this from a catch/finally + * around the test body). A tear-down failure never replaces the given + * testFailure; it is instead attached via {@link Throwable#addSuppressed} + * so the original failure remains the one reported, with the tear-down + * failure still visible alongside it. + * + * @param testFailure + * The throwable already in flight from the test body, or + * null if there is none, in which case this + * behaves the same as {@link #tearDown()}. + * @throws Throwable + * testFailure, if not null; otherwise a + * tear-down failure, if one occurred. + * @since 3.4.0 + */ + protected void tearDown(final Throwable testFailure) throws Throwable + { + logger.debug("tearDown(testFailure={}) - start", testFailure); + + if (testFailure == null) + { + tearDown(); + return; + } + + try { + try { + runTearDownOperation(); + } finally { + tester = null; + } + } catch (final Throwable tearDownFailure) { + testFailure.addSuppressed(tearDownFailure); + } + + throw testFailure; + } + + private void runTearDownOperation() throws Exception + { + final IDatabaseTester databaseTester = getDatabaseTester(); + assertNotNull(databaseTester, "DatabaseTester is not set"); + databaseTester.setTearDownOperation(getTearDownOperation()); + databaseTester.setDataSet(getDataSet()); + databaseTester.setOperationListener(getOperationListener()); + databaseTester.onTearDown(); + } + /** * @return The {@link IOperationListener} to be used by the {@link IDatabaseTester}. * @since 2.4.4 diff --git a/src/main/java/org/dbunit/DefaultDatabaseTester.java b/src/main/java/org/dbunit/DefaultDatabaseTester.java index 9d50a44d2..05f30a9cc 100644 --- a/src/main/java/org/dbunit/DefaultDatabaseTester.java +++ b/src/main/java/org/dbunit/DefaultDatabaseTester.java @@ -20,6 +20,9 @@ */ package org.dbunit; +import java.util.concurrent.Callable; + +import org.dbunit.database.CachingConnectionProvider; import org.dbunit.database.IDatabaseConnection; /** @@ -30,20 +33,69 @@ * @version $Revision$ * @since 2.2 */ +public class DefaultDatabaseTester extends AbstractDatabaseTester +{ + private final IDatabaseConnection connection; + private final CachingConnectionProvider connectionProvider; + private final Callable connectionFactory; -public class DefaultDatabaseTester extends AbstractDatabaseTester { - - final IDatabaseConnection connection; - - /** - * Creates a new DefaultDatabaseTester with the supplied connection. - */ - public DefaultDatabaseTester( final IDatabaseConnection connection ) { - this.connection = connection; - } + /** + * Creates a new DefaultDatabaseTester with the supplied connection.
+ * The same connection instance is returned by every {@link #getConnection()} + * call, with no liveness check. Pair this with a non-closing + * {@link IOperationListener} (e.g. {@link IOperationListener#NO_OP_OPERATION_LISTENER}) + * to keep it open across test methods. + * + * @param connection the connection to return from every {@link #getConnection()} call + */ + public DefaultDatabaseTester(final IDatabaseConnection connection) + { + this.connection = connection; + this.connectionProvider = null; + this.connectionFactory = null; + } - public IDatabaseConnection getConnection() throws Exception { - return this.connection; - } + /** + * Creates a new DefaultDatabaseTester that reuses one connection - created + * with the given factory - across calls via the given + * {@link CachingConnectionProvider}, transparently replacing it if it is no + * longer alive.
+ * Share the same connectionProvider instance across the testers + * created for each test to get reuse across test methods; pair it with + * {@link IOperationListener#NO_OP_OPERATION_LISTENER} (or an equivalent + * non-closing listener) so the cached connection is not closed after every + * {@link #onSetup()}/{@link #onTearDown()} call. + * + * @param connectionProvider caches and validates the connection across calls + * @param connectionFactory creates a new connection; only invoked by + * connectionProvider when there is no live cached + * connection to reuse + * @since 3.4.0 + */ + public DefaultDatabaseTester(final CachingConnectionProvider connectionProvider, + final Callable connectionFactory) + { + if (connectionProvider == null) + { + throw new NullPointerException( + "The parameter 'connectionProvider' must not be null"); + } + if (connectionFactory == null) + { + throw new NullPointerException( + "The parameter 'connectionFactory' must not be null"); + } + this.connection = null; + this.connectionProvider = connectionProvider; + this.connectionFactory = connectionFactory; + } + public IDatabaseConnection getConnection() throws Exception + { + if (connectionProvider != null) + { + return connectionProvider.getConnection(connectionFactory); + } + return this.connection; + } } diff --git a/src/main/java/org/dbunit/DefaultPrepAndExpectedTestCase.java b/src/main/java/org/dbunit/DefaultPrepAndExpectedTestCase.java index 7049b772a..24a75edff 100644 --- a/src/main/java/org/dbunit/DefaultPrepAndExpectedTestCase.java +++ b/src/main/java/org/dbunit/DefaultPrepAndExpectedTestCase.java @@ -20,13 +20,14 @@ */ package org.dbunit; -import static org.junit.jupiter.api.Assertions.assertNotNull; - +import java.sql.SQLException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.concurrent.Callable; import java.util.stream.Collectors; import org.dbunit.assertion.comparer.value.ValueComparer; @@ -52,6 +53,20 @@ * Test case base class supporting prep data and expected data. Prep data is the * data needed for the test to run. Expected data is the data needed to compare * if the test ran successfully. + *

+ * configureTest(), setupData(), verifyData(), and cleanupData() share one + * {@link org.dbunit.database.IDatabaseConnection} for a test's lifecycle, + * acquired lazily on first use and closed once by cleanupData(), instead of + * each acquiring (and often closing) its own. Calling any of those methods + * without an eventual cleanupData() call - e.g. testing them individually + * rather than through preTest()/postTest() - leaves that connection open. + *

+ * If databaseTester is configured with a + * {@link org.dbunit.database.CachingConnectionProvider} shared across test + * methods, set {@link #setCloseConnectionAfterTest(boolean)} to false so + * cleanupData() does not close a connection other tests still expect to + * reuse; the provider's owner is then responsible for closing it once, + * itself, when the whole run finishes. * * @see org.dbunit.DefaultPrepAndExpectedTestCaseDiIT * @see org.dbunit.DefaultPrepAndExpectedTestCaseExtIT @@ -75,11 +90,39 @@ public class DefaultPrepAndExpectedTestCase extends DBTestCase private IDatabaseTester databaseTester; private DataFileLoader dataFileLoader; + /** + * Whether lookupFeatureValue() and cleanupData() close the connection + * they are done with; false when databaseTester shares a + * CachingConnectionProvider across test methods and this instance must + * not close a connection other tests still expect to reuse. + * + * @since 3.4.0 + */ + private boolean closeConnectionAfterTest = true; + // per test data private IDataSet prepDataSet = new DefaultDataSet(); private IDataSet expectedDataSet = new DefaultDataSet(); private VerifyTableDefinition[] verifyTableDefs = {}; + /** + * Connection shared by setupData()/verifyData()/cleanupData() for one + * test's lifecycle instead of each acquiring (and often closing) its own; + * acquired lazily on first use, closed once by cleanupData(). + * + * @since 3.4.0 + */ + private IDatabaseConnection connection; + + /** + * isCaseSensitiveTableNames as resolved by configureTest(), cached so + * cleanupData() does not need a second connection just to re-read this + * same DatabaseConfig feature flag. + * + * @since 3.4.0 + */ + private Boolean cachedIsCaseSensitiveTableNames; + private ExpectedDataSetAndVerifyTableDefinitionVerifier expectedDataSetAndVerifyTableDefinitionVerifier = new DefaultExpectedDataSetAndVerifyTableDefinitionVerifier(); @@ -105,6 +148,27 @@ public DefaultPrepAndExpectedTestCase(final DataFileLoader dataFileLoader, this.databaseTester = databaseTester; } + /** + * Create new instance with specified dataFileLoader and databaseTester. + * + * @param dataFileLoader + * Load to use for loading the data files. + * @param databaseTester + * Tester to use for database manipulation. + * @param closeConnectionAfterTest + * Whether or not to close the database connection after each test. + * + * @since 3.4.0 + */ + public DefaultPrepAndExpectedTestCase(final DataFileLoader dataFileLoader, + final IDatabaseTester databaseTester, + final boolean closeConnectionAfterTest) + { + this.dataFileLoader = dataFileLoader; + this.databaseTester = databaseTester; + this.closeConnectionAfterTest = closeConnectionAfterTest; + } + /** * Create new instance with specified test case name. * @@ -138,6 +202,10 @@ public IDataSet getDataSet() throws Exception /** * {@inheritDoc} + *

+ * Executes against the connection shared with setupData(), verifyData() + * and cleanupData() for this test's lifecycle rather than a fresh one, + * and leaves it open; cleanupData() closes it. See #800. */ @Override public void configureTest( @@ -151,6 +219,7 @@ public void configureTest( DatabaseConfig.FEATURE_CASE_SENSITIVE_TABLE_NAMES); log.debug("configureTest: using case sensitive table names={}", isCaseSensitiveTableNames); + this.cachedIsCaseSensitiveTableNames = isCaseSensitiveTableNames; this.prepDataSet = makeCompositeDataSet(prepDataFiles, "prep", isCaseSensitiveTableNames); @@ -163,23 +232,170 @@ public void configureTest( private boolean lookupFeatureValue(final String featureName) throws Exception { - boolean featureValue; - - IDatabaseConnection connection = null; + final boolean acquiredConnectionHere = connection == null; try + { + final IDatabaseConnection reusableConnection = + getReusableConnection(); + final DatabaseConfig config = reusableConnection.getConfig(); + return config.getFeature(featureName); + } catch (final Exception e) + { + if (acquiredConnectionHere) + { + closeReusableConnectionSuppressing(e); + } + throw e; + } + } + + /** + * Return the connection shared by lookupFeatureValue(), setupData(), + * verifyData() and cleanupData() for the current test's lifecycle, + * acquiring it on first use instead of a fresh connection at each step. + * + * @return The shared connection. + * @throws Exception On dbUnit errors. + * @since 3.4.0 + */ + private IDatabaseConnection getReusableConnection() throws Exception + { + if (connection == null) { connection = getConnection(); - final DatabaseConfig config = connection.getConfig(); - featureValue = config.getFeature(featureName); + } + return connection; + } + + /** + * Release the connection shared by lookupFeatureValue(), setupData(), + * verifyData() and cleanupData(), if one was acquired: closes it and + * forgets it when {@link #closeConnectionAfterTest} is true (the + * default); otherwise leaves it open and keeps the field set, so a later + * lifecycle step's getReusableConnection() call keeps reusing it rather + * than acquiring - and silently orphaning - another one. + * + * @throws SQLException On close errors. + * @since 3.4.0 + */ + private void closeReusableConnection() throws SQLException + { + if (connection == null) + { + return; + } + + if (!closeConnectionAfterTest) + { + // Keep the field set so this instance's own lifecycle keeps + // reusing the same connection even without a + // CachingConnectionProvider backing databaseTester. + return; + } + + try + { + connection.close(); } finally { - if (connection != null) + connection = null; + } + } + + /** + * Close the reusable connection, attaching any close failure to the + * given primary throwable via + * {@link Throwable#addSuppressed(Throwable)} rather than letting it + * replace and hide the primary. Mirrors the exception safety of + * {@link #runTest} and {@code DatabaseTestCase.tearDown(Throwable)}. + * + * @param primary + * The exception already in flight to attach a close failure + * to. + * @since 3.4.0 + */ + private void closeReusableConnectionSuppressing(final Throwable primary) + { + try + { + closeReusableConnection(); + } catch (final SQLException closeFailure) + { + primary.addSuppressed(closeFailure); + } + } + + /** + * Make a {@link ReusableConnectionDatabaseTester} configured with the + * given dataset and databaseTester's setUpOperation and + * tearDownOperation, for setupData() or cleanupData() to run + * {@link IDatabaseTester#onSetup()} or {@link IDatabaseTester#onTearDown()} + * on, respectively. Setting both operations regardless of which one the + * caller uses is safe: {@link AbstractDatabaseTester#onSetup()} only + * reads setUpOperation and {@link AbstractDatabaseTester#onTearDown()} + * only reads tearDownOperation, so the other is simply never consulted. + * + * @param dataSet + * The dataset to run the operation against. + * @return The configured tester. + * @throws Exception On dbUnit errors. + * @since 3.4.0 + */ + private IDatabaseTester makeReusableConnectionDatabaseTester( + final IDataSet dataSet) throws Exception + { + final IDatabaseTester reusableTester = + new ReusableConnectionDatabaseTester( + this::getReusableConnection); + reusableTester.setSetUpOperation(getSetUpOperation()); + reusableTester.setTearDownOperation(getTearDownOperation()); + reusableTester.setDataSet(dataSet); + reusableTester.setOperationListener( + makeConnectionPreservingOperationListener()); + return reusableTester; + } + + /** + * Make an {@link IOperationListener} that forwards + * {@link IOperationListener#connectionRetrieved(IDatabaseConnection)} to + * {@link #getOperationListener()} - so a user-defined listener still runs + * its connection-configuration logic - but never forwards + * operationSetUpFinished/operationTearDownFinished, whose only documented + * purpose is closing the connection. This instance, not the listener, + * owns the shared connection's lifecycle (see + * {@link #getReusableConnection()}/{@link #closeReusableConnection()}), + * so those two notifications must stay no-ops here regardless of which + * listener is configured. + * + * @return The listener to use for the reusable-connection tester. + * @since 3.4.0 + */ + private IOperationListener makeConnectionPreservingOperationListener() + { + final IOperationListener configuredListener = getOperationListener(); + return new IOperationListener() + { + @Override + public void connectionRetrieved( + final IDatabaseConnection connection) { - connection.close(); + configuredListener.connectionRetrieved(connection); } - } - return featureValue; + @Override + public void operationSetUpFinished( + final IDatabaseConnection connection) + { + // no-op: see makeConnectionPreservingOperationListener() + } + + @Override + public void operationTearDownFinished( + final IDatabaseConnection connection) + { + // no-op: see makeConnectionPreservingOperationListener() + } + }; } /** @@ -224,7 +440,15 @@ public Object runTest(final VerifyTableDefinition[] verifyTables, // don't verify table data when test execution has errors as: // * a verify data failure masks the test error exception // * tables in unknown state and therefore probably not accurate - postTest(false); + try + { + postTest(false); + } catch (final Throwable cleanupFailure) + { + // never let a cleanup failure replace and hide the real test + // failure; keep e as the thrown exception, cleanupFailure alongside + e.addSuppressed(cleanupFailure); + } throw e; } @@ -260,32 +484,60 @@ public void postTest() throws Exception @Override public void postTest(final boolean verifyData) throws Exception { + Throwable verifyFailure = null; try { if (verifyData) { verifyData(); } + } catch (final Throwable t) + { + verifyFailure = t; + throw t; } finally { // it is deliberate to have cleanup exceptions shadow verify // failures so user knows db is probably in unknown state (for // those not using an in-memory db or transaction rollback), - // otherwise would mask probable cause of subsequent test failures - cleanupData(); + // otherwise would mask probable cause of subsequent test + // failures; the verify failure rides along as suppressed on the + // cleanup exception so it is not lost entirely + try + { + cleanupData(); + } catch (final Throwable cleanupFailure) + { + if (verifyFailure != null) + { + cleanupFailure.addSuppressed(verifyFailure); + } + throw cleanupFailure; + } } } /** * {@inheritDoc} + *

+ * Runs the tear down operation against the connection shared with + * setupData() and verifyData() for this test's lifecycle, then closes it. + * See #800. */ @Override public void cleanupData() throws Exception { try { - final boolean isCaseSensitiveTableNames = lookupFeatureValue( - DatabaseConfig.FEATURE_CASE_SENSITIVE_TABLE_NAMES); + final boolean isCaseSensitiveTableNames; + if (cachedIsCaseSensitiveTableNames == null) + { + isCaseSensitiveTableNames = lookupFeatureValue( + DatabaseConfig.FEATURE_CASE_SENSITIVE_TABLE_NAMES); + } else + { + isCaseSensitiveTableNames = cachedIsCaseSensitiveTableNames; + } log.debug("cleanupData: using case sensitive table names={}", isCaseSensitiveTableNames); @@ -302,18 +554,26 @@ public void cleanupData() throws Exception throw new IllegalStateException(DATABASE_TESTER_IS_NULL_MSG); } - databaseTester.setTearDownOperation(getTearDownOperation()); - databaseTester.setDataSet(dataset); - databaseTester.setOperationListener(getOperationListener()); - databaseTester.onTearDown(); + final IDatabaseTester reusableTester = + makeReusableConnectionDatabaseTester(dataset); + reusableTester.onTearDown(); log.debug("cleanupData: Clean up done"); + closeReusableConnection(); } catch (final Exception e) { log.error("cleanupData: Exception:", e); + closeReusableConnectionSuppressing(e); throw e; } } + /** + * Legacy JUnit-3-era tear-down hook. Not invoked automatically under JUnit 5; + * kept for subclasses that drive the lifecycle manually. Calling it after a + * full {@link #runTest} or {@link #postTest} cycle cleans up a second time + * (parent tearDown() re-runs the tear down operation on the prep dataset with + * a fresh connection). + */ @Override protected void tearDown() throws Exception { @@ -325,6 +585,10 @@ protected void tearDown() throws Exception /** * Use the provided databaseTester to prep the database with the provided * prep dataset. See {@link org.dbunit.IDatabaseTester#onSetup()}. + *

+ * Executes against the connection shared with verifyData() and + * cleanupData() for this test's lifecycle rather than a fresh one, and + * leaves it open; cleanupData() closes it. See #800. * * @throws Exception */ @@ -338,7 +602,9 @@ public void setupData() throws Exception try { - super.setUp(); + final IDatabaseTester reusableTester = + makeReusableConnectionDatabaseTester(getDataSet()); + reusableTester.onSetup(); } catch (final Exception e) { log.error("setupData: Exception with setting up data:", e); @@ -349,19 +615,27 @@ public void setupData() throws Exception @Override protected DatabaseOperation getSetUpOperation() throws Exception { - assertNotNull(databaseTester, DATABASE_TESTER_IS_NULL_MSG); + if (databaseTester == null) + { + throw new IllegalStateException(DATABASE_TESTER_IS_NULL_MSG); + } return databaseTester.getSetUpOperation(); } @Override protected DatabaseOperation getTearDownOperation() throws Exception { - assertNotNull(databaseTester, DATABASE_TESTER_IS_NULL_MSG); + if (databaseTester == null) + { + throw new IllegalStateException(DATABASE_TESTER_IS_NULL_MSG); + } return databaseTester.getTearDownOperation(); } /** - * {@inheritDoc} Uses the connection from the provided databaseTester. + * {@inheritDoc} Uses the connection from the provided databaseTester, + * shared with setupData() and cleanupData() for this test's lifecycle. + * Left open on return; cleanupData() closes it. See #800. */ @Override public void verifyData() throws Exception @@ -371,9 +645,9 @@ public void verifyData() throws Exception throw new IllegalStateException(DATABASE_TESTER_IS_NULL_MSG); } - final IDatabaseConnection connection = getConnection(); + final IDatabaseConnection reusableConnection = getReusableConnection(); - final DatabaseConfig config = connection.getConfig(); + final DatabaseConfig config = reusableConnection.getConfig(); expectedDataSetAndVerifyTableDefinitionVerifier.verify(verifyTableDefs, expectedDataSet, config); @@ -395,16 +669,12 @@ public void verifyData() throws Exception for (int i = 0; i < tableDefsCount; i++) { final VerifyTableDefinition td = verifyTableDefs[i]; - verifyData(connection, td); + verifyData(reusableConnection, td); } } catch (final Exception e) { log.error("verifyData: Exception:", e); throw e; - } finally - { - log.debug("verifyData: Verification done, closing connection"); - connection.close(); } } @@ -605,11 +875,12 @@ private Column[] makeExpectedTableColumns(final Column[] actualColumns, { final Set expectedColumnNames = Arrays.stream(expectedColumns).map(Column::getColumnName) - .map(String::toLowerCase).collect(Collectors.toSet()); + .map(name -> name.toLowerCase(Locale.ENGLISH)) + .collect(Collectors.toSet()); final List expectedColumnsList = Arrays.stream(actualColumns) - .filter(col -> expectedColumnNames - .contains(col.getColumnName().toLowerCase())) + .filter(col -> expectedColumnNames.contains( + col.getColumnName().toLowerCase(Locale.ENGLISH))) .collect(Collectors.toList()); return expectedColumnsList .toArray(new Column[expectedColumnsList.size()]); @@ -752,14 +1023,14 @@ public IDataSet makeCompositeDataSet(final String[] dataFiles, dataFilesName); } - final List list = new ArrayList(); + final List list = new ArrayList<>(); for (int i = 0; i < count; i++) { final IDataSet ds = dataFileLoader.load(dataFiles[i]); list.add(ds); } - final IDataSet[] dataSet = (IDataSet[]) list.toArray(new IDataSet[] {}); + final IDataSet[] dataSet = list.toArray(new IDataSet[0]); return new CompositeDataSet(dataSet, true, isCaseSensitiveTableNames); } @@ -781,13 +1052,13 @@ public ITable applyColumnFilters(final ITable table, final String[] excludeColumns, final String[] includeColumns) throws DataSetException { - ITable filteredTable = table; - if (table == null) { throw new IllegalArgumentException("table is null"); } + ITable filteredTable = table; + // note: dbunit interprets an empty inclusion filter array as one // not wanting to compare anything! if (includeColumns == null) @@ -796,7 +1067,7 @@ public ITable applyColumnFilters(final ITable table, } else { log.debug("applyColumnFilters: including columns='{}'", - new Object[] {includeColumns}); + Arrays.toString(includeColumns)); filteredTable = DefaultColumnFilter .includedColumnsTable(filteredTable, includeColumns); } @@ -807,7 +1078,7 @@ public ITable applyColumnFilters(final ITable table, } else { log.debug("applyColumnFilters: excluding columns='{}'", - new Object[] {excludeColumns}); + Arrays.toString(excludeColumns)); filteredTable = DefaultColumnFilter .excludedColumnsTable(filteredTable, excludeColumns); } @@ -859,6 +1130,39 @@ public void setDatabaseTester(final IDatabaseTester databaseTester) this.databaseTester = databaseTester; } + /** + * Get whether the connection lookupFeatureValue() and cleanupData() are + * done with is closed. + * + * @see {@link #closeConnectionAfterTest}. + * + * @return True if it is closed, false if not. + * @since 3.4.0 + */ + public boolean isCloseConnectionAfterTest() + { + return closeConnectionAfterTest; + } + + /** + * Set whether the connection lookupFeatureValue() and cleanupData() are + * done with is closed. Default is true. Set to false when databaseTester + * shares a {@link org.dbunit.database.CachingConnectionProvider} across + * test methods, so this instance does not close a connection other tests + * still expect to reuse. + * + * @see {@link #closeConnectionAfterTest}. + * + * @param closeConnectionAfterTest + * True to close it, false to leave it open. + * @since 3.4.0 + */ + public void setCloseConnectionAfterTest( + final boolean closeConnectionAfterTest) + { + this.closeConnectionAfterTest = closeConnectionAfterTest; + } + /** * Get the dataFileLoader. * @@ -947,4 +1251,41 @@ public void setExpectedDataSetAndVerifyTableDefinitionVerifier( this.expectedDataSetAndVerifyTableDefinitionVerifier = expectedDataSetAndVerifyTableDefinitionVerifier; } + + /** + * {@link IDatabaseTester} that runs setUp/tearDown operations against a + * connection supplied by the given {@link Callable} instead of calling + * {@code getConnection()} on a wrapped {@link IDatabaseTester}, so + * repeated {@link #onSetup()}/{@link #onTearDown()} calls driven through + * this instance share whatever connection the supplier itself caches + * (here, {@link DefaultPrepAndExpectedTestCase#getReusableConnection()}) + * rather than each opening a new one. + * + * @since 3.4.0 + */ + private static final class ReusableConnectionDatabaseTester + extends AbstractDatabaseTester + { + private final Callable connectionSupplier; + + /** + * Create new instance with the specified connection supplier. + * + * @param connectionSupplier + * Supplies the connection to use; invoked once per + * {@link #getConnection()} call, so it is responsible for + * any caching of its own. + */ + private ReusableConnectionDatabaseTester( + final Callable connectionSupplier) + { + this.connectionSupplier = connectionSupplier; + } + + @Override + public IDatabaseConnection getConnection() throws Exception + { + return connectionSupplier.call(); + } + } } diff --git a/src/main/java/org/dbunit/JdbcDatabaseTester.java b/src/main/java/org/dbunit/JdbcDatabaseTester.java index 158ff710c..3d948a92f 100644 --- a/src/main/java/org/dbunit/JdbcDatabaseTester.java +++ b/src/main/java/org/dbunit/JdbcDatabaseTester.java @@ -23,6 +23,7 @@ import java.sql.Connection; import java.sql.DriverManager; +import org.dbunit.database.CachingConnectionProvider; import org.dbunit.database.DatabaseConnection; import org.dbunit.database.IDatabaseConnection; import org.slf4j.Logger; @@ -44,6 +45,7 @@ public class JdbcDatabaseTester extends AbstractDatabaseTester */ private static final Logger logger = LoggerFactory.getLogger(JdbcDatabaseTester.class); + private final CachingConnectionProvider connectionProvider; private String connectionUrl; private String driverClass; private String password; @@ -91,7 +93,33 @@ public JdbcDatabaseTester( String driverClass, String connectionUrl, String user * @since 2.4.3 */ public JdbcDatabaseTester( String driverClass, String connectionUrl, String username, - String password, String schema ) + String password, String schema ) + throws ClassNotFoundException + { + this(driverClass, connectionUrl, username, password, schema, null); + } + + /** + * Creates a new JdbcDatabaseTester with the specified properties, reusing one + * {@link IDatabaseConnection} across calls via the given {@link CachingConnectionProvider} + * instead of creating a new one on every call.
+ * Share the same connectionProvider instance across the testers created for each + * test to get reuse across test methods; pair it with {@link IOperationListener#NO_OP_OPERATION_LISTENER} + * (or an equivalent non-closing listener) so the cached connection is not closed after every + * {@link #onSetup()}/{@link #onTearDown()} call. + * + * @param driverClass the classname of the JDBC driver to use + * @param connectionUrl the connection url + * @param username a username that can has access to the database - can be null + * @param password the user's password - can be null + * @param schema the database schema to be tested - can be null + * @param connectionProvider caches and validates the connection across calls - can be + * null, in which case a new connection is created on every call as before + * @throws ClassNotFoundException If the given driverClass was not found + * @since 3.4.0 + */ + public JdbcDatabaseTester( String driverClass, String connectionUrl, String username, + String password, String schema, CachingConnectionProvider connectionProvider ) throws ClassNotFoundException { super(schema); @@ -99,7 +127,8 @@ public JdbcDatabaseTester( String driverClass, String connectionUrl, String user this.connectionUrl = connectionUrl; this.username = username; this.password = password; - + this.connectionProvider = connectionProvider; + assertNotNullNorEmpty( "driverClass", driverClass ); Class.forName( driverClass ); } @@ -108,6 +137,15 @@ public IDatabaseConnection getConnection() throws Exception { logger.debug("getConnection() - start"); + if (connectionProvider != null) + { + return connectionProvider.getConnection(this::createConnection); + } + return createConnection(); + } + + private IDatabaseConnection createConnection() throws Exception + { assertNotNullNorEmpty( "connectionUrl", connectionUrl ); Connection conn = null; if( username == null && password == null ){ @@ -127,6 +165,7 @@ public String toString() sb.append(", username=").append(this.username); sb.append(", password=**********"); sb.append(", schema=").append(super.getSchema()); + sb.append(", connectionProvider=").append(this.connectionProvider); sb.append("]"); return sb.toString(); } diff --git a/src/main/java/org/dbunit/JndiDatabaseTester.java b/src/main/java/org/dbunit/JndiDatabaseTester.java index ffe9608a7..30d8e1b4c 100644 --- a/src/main/java/org/dbunit/JndiDatabaseTester.java +++ b/src/main/java/org/dbunit/JndiDatabaseTester.java @@ -30,6 +30,7 @@ import javax.naming.NamingException; import javax.sql.DataSource; +import org.dbunit.database.CachingConnectionProvider; import org.dbunit.database.DatabaseConnection; import org.dbunit.database.IDatabaseConnection; @@ -49,6 +50,7 @@ public class JndiDatabaseTester extends AbstractDatabaseTester */ private static final Logger logger = LoggerFactory.getLogger(JndiDatabaseTester.class); + private final CachingConnectionProvider connectionProvider; private DataSource dataSource; private Properties environment; private boolean initialized = false; @@ -77,13 +79,35 @@ public JndiDatabaseTester(Properties environment, String lookupName) /** * Creates a JndiDatabaseTester with specific JNDI properties. - * + * * @param environment A Properties object with JNDI properties. Can be null * @param lookupName the name of the resource in the JNDI context * @param schema The schema name to be used for new dbunit connections. Can be null * @since 2.4.5 */ - public JndiDatabaseTester(Properties environment, String lookupName, String schema) + public JndiDatabaseTester(Properties environment, String lookupName, String schema) + { + this(environment, lookupName, schema, null); + } + + /** + * Creates a JndiDatabaseTester with specific JNDI properties, reusing one + * {@link IDatabaseConnection} across calls via the given {@link CachingConnectionProvider} + * instead of creating a new one on every call.
+ * Share the same connectionProvider instance across the testers created for each + * test to get reuse across test methods; pair it with {@link IOperationListener#NO_OP_OPERATION_LISTENER} + * (or an equivalent non-closing listener) so the cached connection is not closed after every + * {@link #onSetup()}/{@link #onTearDown()} call. + * + * @param environment A Properties object with JNDI properties. Can be null + * @param lookupName the name of the resource in the JNDI context + * @param schema The schema name to be used for new dbunit connections. Can be null + * @param connectionProvider caches and validates the connection across calls - can be + * null, in which case a new connection is created on every call as before + * @since 3.4.0 + */ + public JndiDatabaseTester(Properties environment, String lookupName, String schema, + CachingConnectionProvider connectionProvider) { super(schema); @@ -93,6 +117,7 @@ public JndiDatabaseTester(Properties environment, String lookupName, String sche } this.lookupName = lookupName; this.environment = environment; + this.connectionProvider = connectionProvider; } public IDatabaseConnection getConnection() throws Exception @@ -103,6 +128,15 @@ public IDatabaseConnection getConnection() throws Exception initialize(); } + if (connectionProvider != null) + { + return connectionProvider.getConnection(this::createConnection); + } + return createConnection(); + } + + private IDatabaseConnection createConnection() throws Exception + { return new DatabaseConnection( dataSource.getConnection(), getSchema() ); } @@ -136,6 +170,7 @@ public String toString() sb.append(", initialized=").append(this.initialized); sb.append(", dataSource=").append(this.dataSource); sb.append(", schema=").append(super.getSchema()); + sb.append(", connectionProvider=").append(this.connectionProvider); sb.append("]"); return sb.toString(); } diff --git a/src/main/java/org/dbunit/PropertiesBasedJdbcDatabaseTester.java b/src/main/java/org/dbunit/PropertiesBasedJdbcDatabaseTester.java index 5ed34a0c3..362176a5f 100644 --- a/src/main/java/org/dbunit/PropertiesBasedJdbcDatabaseTester.java +++ b/src/main/java/org/dbunit/PropertiesBasedJdbcDatabaseTester.java @@ -21,6 +21,8 @@ package org.dbunit; +import org.dbunit.database.CachingConnectionProvider; + /** * DatabaseTester that configures a DriverManager from environment properties.
* This class defines a set of keys for system properties that need to be @@ -63,14 +65,42 @@ public class PropertiesBasedJdbcDatabaseTester extends JdbcDatabaseTester * values as initialization parameters * @throws Exception */ - public PropertiesBasedJdbcDatabaseTester() throws Exception + public PropertiesBasedJdbcDatabaseTester() throws Exception { - super( System.getProperty(DBUNIT_DRIVER_CLASS), - System.getProperty(DBUNIT_CONNECTION_URL), - System.getProperty(DBUNIT_USERNAME), - System.getProperty(DBUNIT_PASSWORD), + super( System.getProperty(DBUNIT_DRIVER_CLASS), + System.getProperty(DBUNIT_CONNECTION_URL), + System.getProperty(DBUNIT_USERNAME), + System.getProperty(DBUNIT_PASSWORD), System.getProperty(DBUNIT_SCHEMA) ); } + /** + * Creates a new {@link JdbcDatabaseTester} using specific {@link System#getProperty(String)} + * values as initialization parameters, reusing one {@link org.dbunit.database.IDatabaseConnection} + * across calls via the given {@link CachingConnectionProvider} instead of creating a new one on + * every call.
+ * Share the same connectionProvider instance across the testers created for each + * test to get reuse across test methods; pair it with {@link IOperationListener#NO_OP_OPERATION_LISTENER} + * (or an equivalent non-closing listener) so the cached connection is not closed after every + * {@link #onSetup()}/{@link #onTearDown()} call. + * + * @param connectionProvider Caches and validates the connection across + * calls. Can be null, in which case a new + * connection is created on every call as before. + * @throws Exception If the configured driver class was not found + * @since 3.4.0 + */ + public PropertiesBasedJdbcDatabaseTester(final CachingConnectionProvider connectionProvider) + throws Exception + { + super( System.getProperty(DBUNIT_DRIVER_CLASS), + System.getProperty(DBUNIT_CONNECTION_URL), + System.getProperty(DBUNIT_USERNAME), + System.getProperty(DBUNIT_PASSWORD), + System.getProperty(DBUNIT_SCHEMA), + connectionProvider + ); + } + } diff --git a/src/main/java/org/dbunit/ant/Export.java b/src/main/java/org/dbunit/ant/Export.java index 6cda2ab51..b6d906023 100644 --- a/src/main/java/org/dbunit/ant/Export.java +++ b/src/main/java/org/dbunit/ant/Export.java @@ -108,21 +108,26 @@ public void setFormat(String format) { logger.debug("setFormat(format={}) - start", format); - if (format.equalsIgnoreCase(FORMAT_FLAT) - || format.equalsIgnoreCase(FORMAT_XML) - || format.equalsIgnoreCase(FORMAT_DTD) - || format.equalsIgnoreCase(FORMAT_CSV) - || format.equalsIgnoreCase(FORMAT_XLS) - || format.equalsIgnoreCase(FORMAT_YML)) + if (isSupportedFormat(format)) { _format = format; } else { - throw new IllegalArgumentException("Type must be one of: 'flat'(default), 'xml', 'dtd', 'xls' or 'yml' but was: " + format); + throw new IllegalArgumentException("Type must be one of: 'flat'(default), 'xml', 'dtd', 'csv', 'xls' or 'yml' but was: " + format); } } + private static boolean isSupportedFormat(String format) + { + return format.equalsIgnoreCase(FORMAT_FLAT) + || format.equalsIgnoreCase(FORMAT_XML) + || format.equalsIgnoreCase(FORMAT_DTD) + || format.equalsIgnoreCase(FORMAT_CSV) + || format.equalsIgnoreCase(FORMAT_XLS) + || format.equalsIgnoreCase(FORMAT_YML); + } + /** * Encoding for XML-Output * @return Returns the encoding. @@ -182,12 +187,17 @@ public void execute(IDatabaseConnection connection) throws DatabaseUnitException throw new DatabaseUnitException("'_dest' is a required attribute of the step."); } + if (!isSupportedFormat(_format)) + { + throw new IllegalArgumentException("The given format '"+_format+"' is not supported."); + } + IDataSet dataset = getExportDataSet(connection); log("dataset tables: " + Arrays.asList(dataset.getTableNames()), Project.MSG_VERBOSE); // Write the dataset - if (_format.equals(FORMAT_CSV)) + if (_format.equalsIgnoreCase(FORMAT_CSV)) { CsvDataSetWriter.write(dataset, _dest); } diff --git a/src/main/java/org/dbunit/assertion/DbUnitAssertBase.java b/src/main/java/org/dbunit/assertion/DbUnitAssertBase.java index 993c6f762..38679e3b3 100644 --- a/src/main/java/org/dbunit/assertion/DbUnitAssertBase.java +++ b/src/main/java/org/dbunit/assertion/DbUnitAssertBase.java @@ -1,6 +1,7 @@ package org.dbunit.assertion; import java.util.Arrays; +import java.util.Locale; import java.util.Map; import org.dbunit.DatabaseUnitException; @@ -241,12 +242,12 @@ protected String[] getSortedTableNames(final IDataSet dataSet) { log.debug("getSortedTableNames(dataSet={}) - start", dataSet); - final String[] names = dataSet.getTableNames(); + final String[] names = dataSet.getTableNames().clone(); if (!dataSet.isCaseSensitiveTableNames()) { for (int i = 0; i < names.length; i++) { - names[i] = names[i].toUpperCase(); + names[i] = names[i].toUpperCase(Locale.ENGLISH); } } Arrays.sort(names); diff --git a/src/main/java/org/dbunit/database/CachingConnectionProvider.java b/src/main/java/org/dbunit/database/CachingConnectionProvider.java new file mode 100644 index 000000000..3b2a19385 --- /dev/null +++ b/src/main/java/org/dbunit/database/CachingConnectionProvider.java @@ -0,0 +1,224 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2026, DbUnit.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + */ + +package org.dbunit.database; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.concurrent.Callable; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Caches a single {@link IDatabaseConnection} so that it - and the table + * metadata it accumulates - can be reused across many test methods instead of + * being rebuilt on every call. + * + *

+ * A {@link Callable} supplies the connection-creation logic; the factory is + * only invoked when there is no cached connection yet, or when the + * previously cached one is no longer {@linkplain Connection#isValid(int) + * alive}. A dead connection is closed and transparently replaced rather than + * returned to the caller or left for the next call to fail on. + * + *

+ * Usage. Construct one instance per target database and share that + * same instance across the {@link org.dbunit.JdbcDatabaseTester}, + * {@link org.dbunit.DataSourceDatabaseTester}, {@link org.dbunit.JndiDatabaseTester} + * or {@link org.dbunit.DefaultDatabaseTester} instances that are created for + * each test, for example via a {@code static} field on a common test base + * class: + * + *

+ * private static final CachingConnectionProvider CONNECTION_PROVIDER =
+ *         new CachingConnectionProvider();
+ *
+ * @BeforeEach
+ * void setUp() throws Exception
+ * {
+ *     final IDatabaseTester tester = new JdbcDatabaseTester(driverClass,
+ *             connectionUrl, username, password, schema, CONNECTION_PROVIDER);
+ *     // The default IOperationListener closes the connection after every
+ *     // onSetup()/onTearDown() call, which would defeat the cache. Pair it
+ *     // with a listener that leaves the connection open, e.g.:
+ *     tester.setOperationListener(IOperationListener.NO_OP_OPERATION_LISTENER);
+ *     ...
+ * }
+ * 
+ * + *

+ * Thread safety. Access to the cached connection is synchronized, so + * concurrent callers cannot create or replace it at the same time. The + * returned {@link IDatabaseConnection} - and the underlying JDBC + * {@link Connection} it wraps - is however not synchronized, so this class is + * only appropriate for test suites that run sequentially, not for test + * methods executing concurrently against the same cached connection. + * + *

+ * Metadata staleness. Because the whole point of reuse is to avoid + * re-fetching table metadata, a cached connection's {@code DatabaseDataSet} + * does not notice schema changes (new/dropped/altered tables) made after it + * was first cached. Do not share a provider across tests that alter DDL + * mid-run. + * + * @since 3.4.0 + */ +public class CachingConnectionProvider +{ + private static final Logger logger = + LoggerFactory.getLogger(CachingConnectionProvider.class); + + /** + * Default number of seconds {@link #getConnection(Callable)} allows + * {@link Connection#isValid(int)} to take when checking whether the + * cached connection is still alive. + */ + public static final int DEFAULT_VALIDATION_TIMEOUT_SECONDS = 5; + + private final int validationTimeoutSeconds; + + private IDatabaseConnection connection; + + /** + * Creates a provider that validates the cached connection with the + * {@link #DEFAULT_VALIDATION_TIMEOUT_SECONDS default validation timeout}. + */ + public CachingConnectionProvider() + { + this(DEFAULT_VALIDATION_TIMEOUT_SECONDS); + } + + /** + * Creates a provider that validates the cached connection with the given + * timeout. + * + * @param validationTimeoutSeconds + * The number of seconds {@link Connection#isValid(int)} is + * allowed to take when checking whether the cached connection + * is still alive. Zero means no timeout is applied. + */ + public CachingConnectionProvider(final int validationTimeoutSeconds) + { + if (validationTimeoutSeconds < 0) + { + throw new IllegalArgumentException("The parameter " + + "'validationTimeoutSeconds' must not be negative"); + } + this.validationTimeoutSeconds = validationTimeoutSeconds; + } + + /** + * Returns the cached connection, creating it with the given factory on + * the first call and again whenever the previously cached connection is + * no longer alive. + * + * @param connectionFactory + * Creates a new connection. Only invoked when there is no live + * cached connection to reuse. + * @return The cached, live connection. + * @throws Exception + * If {@code connectionFactory} throws while creating a new + * connection. + */ + public synchronized IDatabaseConnection getConnection( + final Callable connectionFactory) throws Exception + { + if (connection != null && isAlive(connection)) + { + logger.debug("getConnection() - reusing cached connection {}", connection); + return connection; + } + + if (connection != null) + { + logger.debug("getConnection() - cached connection {} is no longer" + + " alive, replacing it", connection); + closeQuietly(connection); + connection = null; + } + + connection = connectionFactory.call(); + return connection; + } + + /** + * Closes and discards the cached connection, if any. The next call to + * {@link #getConnection(Callable)} creates a fresh one. + * + * @throws SQLException + * If closing the cached connection fails. + */ + public synchronized void close() throws SQLException + { + if (connection == null) + { + return; + } + + try + { + connection.close(); + } + finally + { + connection = null; + } + } + + private boolean isAlive(final IDatabaseConnection candidate) + { + try + { + final Connection jdbcConnection = candidate.getConnection(); + return !jdbcConnection.isClosed() + && jdbcConnection.isValid(validationTimeoutSeconds); + } catch (final SQLException e) + { + logger.debug("isAlive() - liveness check failed for connection {}", + candidate, e); + return false; + } + } + + private void closeQuietly(final IDatabaseConnection candidate) + { + try + { + candidate.close(); + } catch (final SQLException e) + { + logger.warn("closeQuietly() - exception while closing the stale" + + " cached connection", e); + } + } + + @Override + public synchronized String toString() + { + final StringBuilder sb = new StringBuilder(); + sb.append(getClass().getName()).append("["); + sb.append("validationTimeoutSeconds=").append(validationTimeoutSeconds); + sb.append(", connection=").append(connection); + sb.append("]"); + return sb.toString(); + } +} diff --git a/src/main/java/org/dbunit/database/DatabaseDataSet.java b/src/main/java/org/dbunit/database/DatabaseDataSet.java index 9beecc89d..be5854835 100644 --- a/src/main/java/org/dbunit/database/DatabaseDataSet.java +++ b/src/main/java/org/dbunit/database/DatabaseDataSet.java @@ -175,7 +175,7 @@ private void initialize(String schema) throws DataSetException logger.debug("initialize() - start"); DatabaseConfig config = _connection.getConfig(); - boolean qualifiedTableNamesActive = Boolean.TRUE == config.getProperty(DatabaseConfig.FEATURE_QUALIFIED_TABLE_NAMES); + boolean qualifiedTableNamesActive = config.getFeature(DatabaseConfig.FEATURE_QUALIFIED_TABLE_NAMES); if(schema == null || !qualifiedTableNamesActive) { @@ -316,7 +316,11 @@ public ITableMetaData getTableMetaData(String tableName) throws DataSetException // that validation succeeds on case-sensitive databases when the caller // supplied a differently-cased name (e.g. from a LowerCaseDataSet). String storedTableName = _tableMap.getOriginalTableName(tableName); - metaData = new DatabaseTableMetaData(storedTableName, _connection, true, super.isCaseSensitiveTableNames()); + // validate=false: existence is already proven by the containsTable() + // check above, backed by initialize()'s getTables() enumeration - no + // need for DatabaseTableMetaData to run its own redundant + // tableExists() metadata round trip. + metaData = new DatabaseTableMetaData(storedTableName, _connection, false, super.isCaseSensitiveTableNames()); // Put the metadata object into the cache map _tableMap.update(tableName, metaData); diff --git a/src/main/java/org/dbunit/database/search/TablesDependencyHelper.java b/src/main/java/org/dbunit/database/search/TablesDependencyHelper.java index 68dfdd92f..b84f80906 100644 --- a/src/main/java/org/dbunit/database/search/TablesDependencyHelper.java +++ b/src/main/java/org/dbunit/database/search/TablesDependencyHelper.java @@ -21,6 +21,7 @@ package org.dbunit.database.search; import java.sql.SQLException; +import java.util.Locale; import java.util.Set; import org.dbunit.database.IDatabaseConnection; @@ -280,7 +281,7 @@ private static String[] normalizeToStoredCase(final IDatabaseConnection connecti final String[] normalized = new String[tableNames.length]; for (int i = 0; i < tableNames.length; i++) { - normalized[i] = tableNames[i].toLowerCase(); + normalized[i] = tableNames[i].toLowerCase(Locale.ENGLISH); } return normalized; } diff --git a/src/main/java/org/dbunit/dataset/AbstractTableMetaData.java b/src/main/java/org/dbunit/dataset/AbstractTableMetaData.java index c32c13be1..5f58a83ac 100644 --- a/src/main/java/org/dbunit/dataset/AbstractTableMetaData.java +++ b/src/main/java/org/dbunit/dataset/AbstractTableMetaData.java @@ -239,9 +239,9 @@ String validateDataTypeFactory(IDataTypeFactory dataTypeFactory, DatabaseMetaDat Collection validDbProductCollection = productRelatable.getValidDbProducts(); if (validDbProductCollection != null) { - String lowerCaseDbProductName = databaseProductName.toLowerCase(); + String lowerCaseDbProductName = databaseProductName.toLowerCase(Locale.ENGLISH); for (Iterator iterator = validDbProductCollection.iterator(); iterator.hasNext();) { - String validDbProduct = ((String) iterator.next()).toLowerCase(); + String validDbProduct = ((String) iterator.next()).toLowerCase(Locale.ENGLISH); if(lowerCaseDbProductName.indexOf(validDbProduct) > -1) { logger.debug("The current database '{}' fits to the configured data type factory '{}'. Validation successful.", databaseProductName, dataTypeFactory); diff --git a/src/main/java/org/dbunit/dataset/CaseInsensitiveDataSet.java b/src/main/java/org/dbunit/dataset/CaseInsensitiveDataSet.java index 7ff17a2c2..80c78b12a 100644 --- a/src/main/java/org/dbunit/dataset/CaseInsensitiveDataSet.java +++ b/src/main/java/org/dbunit/dataset/CaseInsensitiveDataSet.java @@ -21,6 +21,8 @@ package org.dbunit.dataset; +import java.util.Locale; + import org.dbunit.database.AmbiguousTableNameException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -57,7 +59,7 @@ public CaseInsensitiveDataSet(IDataSet dataSet) throws AmbiguousTableNameExcepti while(tableIterator.next()) { ITable table = (ITable) tableIterator.getTable(); String tableName = table.getTableMetaData().getTableName(); - orderedTableMap.add(tableName.toUpperCase(), tableName); + orderedTableMap.add(tableName.toUpperCase(Locale.ENGLISH), tableName); } } @@ -65,7 +67,7 @@ private String getInternalTableName(String tableName) throws DataSetException { logger.debug("getInternalTableName(tableName={}) - start", tableName); - String originalTableName = (String)orderedTableMap.get(tableName.toUpperCase()); + String originalTableName = (String)orderedTableMap.get(tableName.toUpperCase(Locale.ENGLISH)); if(originalTableName==null){ throw new NoSuchTableException(tableName); } diff --git a/src/main/java/org/dbunit/dataset/LowerCaseDataSet.java b/src/main/java/org/dbunit/dataset/LowerCaseDataSet.java index 1a09ab5a6..a768c1e5d 100644 --- a/src/main/java/org/dbunit/dataset/LowerCaseDataSet.java +++ b/src/main/java/org/dbunit/dataset/LowerCaseDataSet.java @@ -21,6 +21,8 @@ package org.dbunit.dataset; +import java.util.Locale; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -89,7 +91,7 @@ public String[] getTableNames() throws DataSetException String[] tableNames = super.getTableNames(); for (int i = 0; i < tableNames.length; i++) { - tableNames[i] = tableNames[i].toLowerCase(); + tableNames[i] = tableNames[i].toLowerCase(Locale.ENGLISH); } return tableNames; } diff --git a/src/main/java/org/dbunit/dataset/LowerCaseTableMetaData.java b/src/main/java/org/dbunit/dataset/LowerCaseTableMetaData.java index 51769f858..db01b64fe 100644 --- a/src/main/java/org/dbunit/dataset/LowerCaseTableMetaData.java +++ b/src/main/java/org/dbunit/dataset/LowerCaseTableMetaData.java @@ -21,6 +21,8 @@ package org.dbunit.dataset; +import java.util.Locale; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -67,7 +69,7 @@ public LowerCaseTableMetaData(ITableMetaData metaData) throws DataSetException public LowerCaseTableMetaData(String tableName, Column[] columns, Column[] primaryKeys) //throws DataSetException { - _tableName = tableName.toLowerCase(); + _tableName = tableName.toLowerCase(Locale.ENGLISH); _columns = createLowerColumns(columns); _primaryKeys = createLowerColumns(primaryKeys); } @@ -90,7 +92,7 @@ private Column createLowerColumn(Column column) logger.debug("createLowerColumn(column={}) - start", column); return new Column( - column.getColumnName().toLowerCase(), + column.getColumnName().toLowerCase(Locale.ENGLISH), column.getDataType(), column.getSqlTypeName(), column.getNullable(), diff --git a/src/main/java/org/dbunit/dataset/csv/CsvParserImpl.java b/src/main/java/org/dbunit/dataset/csv/CsvParserImpl.java index 2dbfc1e19..e7b0be4ae 100644 --- a/src/main/java/org/dbunit/dataset/csv/CsvParserImpl.java +++ b/src/main/java/org/dbunit/dataset/csv/CsvParserImpl.java @@ -172,22 +172,19 @@ private List collectExpectedNumberOfColumns(int expectedNumberOfColumns, LineNum String anotherLine = lineNumberReader.readLine(); if(anotherLine == null) return null; - boolean shouldProceed = false; while (columnsCollectedSoFar < expectedNumberOfColumns) { try { buffer.append(anotherLine); columns = parse(buffer.toString()); columnsCollectedSoFar = columns.size(); + break; } catch (IllegalStateException e) { resetThePipeline(); anotherLine = lineNumberReader.readLine(); if(anotherLine == null) break; buffer.append("\n"); - shouldProceed = true; } - if (!shouldProceed) - break; } if (columnsCollectedSoFar != expectedNumberOfColumns) { String message = new StringBuilder("Expected ").append(expectedNumberOfColumns) diff --git a/src/main/java/org/dbunit/dataset/csv/CsvProducer.java b/src/main/java/org/dbunit/dataset/csv/CsvProducer.java index 29e53db65..ad4d31f2f 100644 --- a/src/main/java/org/dbunit/dataset/csv/CsvProducer.java +++ b/src/main/java/org/dbunit/dataset/csv/CsvProducer.java @@ -123,14 +123,15 @@ private void produceFromFile(File theDataFile) throws DataSetException, CsvParse // though each row is redundant the moment _consumer.row() returns it. readData.set(0, null); - String tableName = theDataFile.getName().substring(0, theDataFile.getName().indexOf(".csv")); + String fileName = theDataFile.getName(); + String tableName = fileName.substring(0, fileName.lastIndexOf(".csv")); ITableMetaData metaData = new DefaultTableMetaData(tableName, columns); _consumer.startTable(metaData); for (int i = 1 ; i < readData.size(); i++) { List rowList = (List)readData.get(i); Object[] row = rowList.toArray(); for(int col = 0; col < row.length; col++) { - row[col] = row[col].equals(CsvDataSetWriter.NULL) ? null : row[col]; + row[col] = CsvDataSetWriter.NULL.equals(row[col]) ? null : row[col]; } _consumer.row(row); readData.set(i, null); diff --git a/src/main/java/org/dbunit/dataset/datatype/AbstractDataType.java b/src/main/java/org/dbunit/dataset/datatype/AbstractDataType.java index 1f4305d38..79a735901 100644 --- a/src/main/java/org/dbunit/dataset/datatype/AbstractDataType.java +++ b/src/main/java/org/dbunit/dataset/datatype/AbstractDataType.java @@ -200,6 +200,7 @@ public Object getSqlValue(final int column, final ResultSet resultSet) { logger.debug("getSqlValue(column={}, resultSet={}) - start", column, resultSet); + final Object rawValue = resultSet.getObject(column); final Object value = resultSet.wasNull() ? null : rawValue; logger.debug("getSqlValue: column={}, value={}", column, value); @@ -211,8 +212,12 @@ public void setSqlValue(final Object value, final int column, final PreparedStatement statement) throws SQLException, TypeCastException { - logger.debug("setSqlValue(value={}, column={}, statement={}) - start", - value, column, statement); + if (logger.isDebugEnabled()) + { + logger.debug( + "setSqlValue(value={}, column={}, statement={}) - start", + value, column, statement); + } statement.setObject(column, typeCast(value), getSqlType()); } diff --git a/src/main/java/org/dbunit/dataset/datatype/BigIntegerDataType.java b/src/main/java/org/dbunit/dataset/datatype/BigIntegerDataType.java index eb1122edc..d348d68ab 100644 --- a/src/main/java/org/dbunit/dataset/datatype/BigIntegerDataType.java +++ b/src/main/java/org/dbunit/dataset/datatype/BigIntegerDataType.java @@ -91,6 +91,7 @@ public Object getSqlValue(final int column, final ResultSet resultSet) { logger.debug("getSqlValue(column={}, resultSet={}) - start", column, resultSet); + final BigDecimal rawValue = resultSet.getBigDecimal(column); final BigInteger value = resultSet.wasNull() ? null : rawValue.toBigInteger(); @@ -103,8 +104,12 @@ public void setSqlValue(final Object value, final int column, final PreparedStatement statement) throws SQLException, TypeCastException { - logger.debug("setSqlValue(value={}, column={}, statement={}) - start", - value, column, statement); + if (logger.isDebugEnabled()) + { + logger.debug( + "setSqlValue(value={}, column={}, statement={}) - start", + value, column, statement); + } final BigInteger val = (BigInteger) typeCast(value); final BigDecimal valueBigDecimal = diff --git a/src/main/java/org/dbunit/dataset/datatype/BooleanDataType.java b/src/main/java/org/dbunit/dataset/datatype/BooleanDataType.java index 40dcc1cf2..2c90e3350 100644 --- a/src/main/java/org/dbunit/dataset/datatype/BooleanDataType.java +++ b/src/main/java/org/dbunit/dataset/datatype/BooleanDataType.java @@ -128,6 +128,7 @@ public Object getSqlValue(final int column, final ResultSet resultSet) { logger.debug("getSqlValue(column={}, resultSet={}) - start", column, resultSet); + final boolean rawValue = resultSet.getBoolean(column); final Boolean value = resultSet.wasNull() ? null : rawValue; logger.debug("getSqlValue: column={}, value={}", column, value); @@ -139,8 +140,12 @@ public void setSqlValue(final Object value, final int column, final PreparedStatement statement) throws SQLException, TypeCastException { - logger.debug("setSqlValue(value={}, column={}, statement={}) - start", - value, column, statement); + if (logger.isDebugEnabled()) + { + logger.debug( + "setSqlValue(value={}, column={}, statement={}) - start", + value, column, statement); + } final Boolean castValue = (Boolean) typeCast(value); if (castValue == null) diff --git a/src/main/java/org/dbunit/dataset/datatype/BytesDataType.java b/src/main/java/org/dbunit/dataset/datatype/BytesDataType.java index b4aff6a24..dad6d6dfa 100644 --- a/src/main/java/org/dbunit/dataset/datatype/BytesDataType.java +++ b/src/main/java/org/dbunit/dataset/datatype/BytesDataType.java @@ -21,7 +21,6 @@ package org.dbunit.dataset.datatype; -import java.io.BufferedInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; @@ -31,10 +30,12 @@ import java.net.MalformedURLException; import java.net.URL; import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.sql.Blob; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -63,7 +64,21 @@ public BytesDataType(final String name, final int sqlType) super(name, sqlType, byte[].class, false); } - private byte[] toByteArray(InputStream in, final int length) + /** + * Reads all remaining bytes from the given stream, then closes it. + * Callers transfer ownership of {@code in} to this method: they must not + * use or close it themselves. + * + * @param in + * The stream to read and close. + * @param length + * An estimate of the number of bytes to read, used to size + * the result buffer. + * @return The bytes read from the stream. + * @throws IOException + * On a read failure. + */ + private byte[] toByteArray(final InputStream in, final int length) throws IOException { if (logger.isDebugEnabled()) @@ -72,12 +87,14 @@ private byte[] toByteArray(InputStream in, final int length) } final ByteArrayOutputStream out = new ByteArrayOutputStream(length); - in = new BufferedInputStream(in); - int i = in.read(); - while (i != -1) + try (InputStream inputStream = in) { - out.write(i); - i = in.read(); + final byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) + { + out.write(buffer, 0, bytesRead); + } } return out.toByteArray(); } @@ -136,7 +153,7 @@ public Object typeCast(final Object value) throws TypeCastException final Matcher matcher = inputPattern.matcher(stringValue); if (matcher.matches()) { - final String commandLine = matcher.group(1).toUpperCase(); + final String commandLine = matcher.group(1).toUpperCase(Locale.ENGLISH); stringValue = matcher.group(2); final String[] split = commandLine.split(" "); @@ -159,14 +176,18 @@ public Object typeCast(final Object value) throws TypeCastException return stringValue.getBytes(charset); } catch (final IllegalArgumentException e) { - return "Error: [text " + encoding - + "] has an invalid encoding id."; + throw new TypeCastException(value, this); } } else if ("BASE64".equals(command)) { logger.debug( "Data explicitly states that given string is base46"); - return Base64.decode(stringValue); + final byte[] decoded = Base64.decode(stringValue); + if (decoded == null) + { + throw new TypeCastException(value, this); + } + return decoded; } else if ("FILE".equals(command)) { try @@ -180,7 +201,7 @@ public Object typeCast(final Object value) throws TypeCastException "Could not load file following instruction >>" + value + "<<"; logger.error(errMsg); - return ("Error: " + errMsg).getBytes(); + throw new TypeCastException(errMsg, e); } } else if ("URL".equals(command)) { @@ -195,7 +216,7 @@ public Object typeCast(final Object value) throws TypeCastException "Could not load URL following instruction >>" + value + "<<"; logger.error(errMsg); - return ("Error: " + errMsg).getBytes(); + throw new TypeCastException(errMsg, e); } } } @@ -205,9 +226,25 @@ public Object typeCast(final Object value) throws TypeCastException if (stringValue.length() == 0 || stringValue.length() > MAX_URI_LENGTH) { - logger.debug( - "Assuming given string to be Base64 and not a URI"); - return Base64.decode((String) value); + if (logger.isDebugEnabled()) + { + logger.debug( + "Assuming given string to be Base64 and not a URI"); + } + final byte[] decodedBytes = Base64.decode(stringValue); + if (decodedBytes == null && stringValue.length() > 0) + { + // Same last-resort fallback as the "assume URI" branch + // below: not valid Base64 either, so assume it is the + // literal blob content + if (logger.isDebugEnabled()) + { + logger.debug( + "Assuming given string to be content of the blob, encoded with UTF-8."); + } + return stringValue.getBytes(StandardCharsets.UTF_8); + } + return decodedBytes; } try @@ -240,7 +277,7 @@ public Object typeCast(final Object value) throws TypeCastException // we make a last attempt at doing so. logger.debug( "Assuming given string to be content of the blob, encoded with UTF-8."); - return stringValue.getBytes(); + return stringValue.getBytes(StandardCharsets.UTF_8); } else { return decodedBytes; @@ -364,6 +401,7 @@ public Object getSqlValue(final int column, final ResultSet resultSet) { logger.debug("getSqlValue(column={}, resultSet={}) - start", column, resultSet); + final byte[] rawValue = resultSet.getBytes(column); final byte[] value = resultSet.wasNull() ? null : rawValue; logger.debug("getSqlValue: column={}, value={}", column, value); @@ -375,8 +413,12 @@ public void setSqlValue(final Object value, final int column, final PreparedStatement statement) throws SQLException, TypeCastException { - logger.debug("setSqlValue(value={}, column={}, statement={}) - start", - value, column, statement); + if (logger.isDebugEnabled()) + { + logger.debug( + "setSqlValue(value={}, column={}, statement={}) - start", + value, column, statement); + } super.setSqlValue(value, column, statement); } diff --git a/src/main/java/org/dbunit/dataset/datatype/DateDataType.java b/src/main/java/org/dbunit/dataset/datatype/DateDataType.java index 468f24f47..548a66662 100644 --- a/src/main/java/org/dbunit/dataset/datatype/DateDataType.java +++ b/src/main/java/org/dbunit/dataset/datatype/DateDataType.java @@ -137,6 +137,7 @@ public Object getSqlValue(final int column, final ResultSet resultSet) { logger.debug("getSqlValue(column={}, resultSet={}) - start", column, resultSet); + final java.sql.Date rawValue = resultSet.getDate(column); final java.sql.Date value = resultSet.wasNull() ? null : rawValue; logger.debug("getSqlValue: column={}, value={}", column, value); @@ -148,8 +149,12 @@ public void setSqlValue(final Object value, final int column, final PreparedStatement statement) throws SQLException, TypeCastException { - logger.debug("setSqlValue(value={}, column={}, statement={}) - start", - value, column, statement); + if (logger.isDebugEnabled()) + { + logger.debug( + "setSqlValue(value={}, column={}, statement={}) - start", + value, column, statement); + } statement.setDate(column, (java.sql.Date) typeCast(value)); } diff --git a/src/main/java/org/dbunit/dataset/datatype/DoubleDataType.java b/src/main/java/org/dbunit/dataset/datatype/DoubleDataType.java index fa5628259..ac5aa5ecf 100644 --- a/src/main/java/org/dbunit/dataset/datatype/DoubleDataType.java +++ b/src/main/java/org/dbunit/dataset/datatype/DoubleDataType.java @@ -77,6 +77,7 @@ public Object getSqlValue(final int column, final ResultSet resultSet) { logger.debug("getSqlValue(column={}, resultSet={}) - start", column, resultSet); + final double rawValue = resultSet.getDouble(column); final Double value = resultSet.wasNull() ? null : rawValue; logger.debug("getSqlValue: column={}, value={}", column, value); @@ -88,8 +89,12 @@ public void setSqlValue(final Object value, final int column, final PreparedStatement statement) throws SQLException, TypeCastException { - logger.debug("setSqlValue(value={}, column={}, statement={}) - start", - value, column, statement); + if (logger.isDebugEnabled()) + { + logger.debug( + "setSqlValue(value={}, column={}, statement={}) - start", + value, column, statement); + } statement.setDouble(column, ((Number) typeCast(value)).doubleValue()); } diff --git a/src/main/java/org/dbunit/dataset/datatype/FloatDataType.java b/src/main/java/org/dbunit/dataset/datatype/FloatDataType.java index f4507951a..a97e241ee 100644 --- a/src/main/java/org/dbunit/dataset/datatype/FloatDataType.java +++ b/src/main/java/org/dbunit/dataset/datatype/FloatDataType.java @@ -82,6 +82,7 @@ public Object getSqlValue(final int column, final ResultSet resultSet) { logger.debug("getSqlValue(column={}, resultSet={}) - start", column, resultSet); + final float rawValue = resultSet.getFloat(column); final Float value = resultSet.wasNull() ? null : rawValue; logger.debug("getSqlValue: column={}, value={}", column, value); @@ -93,8 +94,12 @@ public void setSqlValue(final Object value, final int column, final PreparedStatement statement) throws SQLException, TypeCastException { - logger.debug("setSqlValue(value={}, column={}, statement={}) - start", - value, column, statement); + if (logger.isDebugEnabled()) + { + logger.debug( + "setSqlValue(value={}, column={}, statement={}) - start", + value, column, statement); + } statement.setFloat(column, ((Number) typeCast(value)).floatValue()); } diff --git a/src/main/java/org/dbunit/dataset/datatype/IntegerDataType.java b/src/main/java/org/dbunit/dataset/datatype/IntegerDataType.java index 1b2482483..bc93b4efe 100644 --- a/src/main/java/org/dbunit/dataset/datatype/IntegerDataType.java +++ b/src/main/java/org/dbunit/dataset/datatype/IntegerDataType.java @@ -100,6 +100,7 @@ public Object getSqlValue(final int column, final ResultSet resultSet) { logger.debug("getSqlValue(column={}, resultSet={}) - start", column, resultSet); + final int rawValue = resultSet.getInt(column); final Integer value = resultSet.wasNull() ? null : rawValue; logger.debug("getSqlValue: column={}, value={}", column, value); @@ -111,8 +112,12 @@ public void setSqlValue(final Object value, final int column, final PreparedStatement statement) throws SQLException, TypeCastException { - logger.debug("setSqlValue(value={}, column={}, statement={}) - start", - value, column, statement); + if (logger.isDebugEnabled()) + { + logger.debug( + "setSqlValue(value={}, column={}, statement={}) - start", + value, column, statement); + } statement.setInt(column, (Integer) typeCast(value)); } diff --git a/src/main/java/org/dbunit/dataset/datatype/LongDataType.java b/src/main/java/org/dbunit/dataset/datatype/LongDataType.java index 974dbb48a..4017662d0 100644 --- a/src/main/java/org/dbunit/dataset/datatype/LongDataType.java +++ b/src/main/java/org/dbunit/dataset/datatype/LongDataType.java @@ -78,6 +78,7 @@ public Object getSqlValue(final int column, final ResultSet resultSet) { logger.debug("getSqlValue(column={}, resultSet={}) - start", column, resultSet); + final long rawValue = resultSet.getLong(column); final Long value = resultSet.wasNull() ? null : rawValue; logger.debug("getSqlValue: column={}, value={}", column, value); @@ -89,8 +90,12 @@ public void setSqlValue(final Object value, final int column, final PreparedStatement statement) throws SQLException, TypeCastException { - logger.debug("setSqlValue(value={}, column={}, statement={}) - start", - value, column, statement); + if (logger.isDebugEnabled()) + { + logger.debug( + "setSqlValue(value={}, column={}, statement={}) - start", + value, column, statement); + } statement.setLong(column, ((Number) typeCast(value)).longValue()); } diff --git a/src/main/java/org/dbunit/dataset/datatype/NumberDataType.java b/src/main/java/org/dbunit/dataset/datatype/NumberDataType.java index 4719a1371..d9c81c7c7 100644 --- a/src/main/java/org/dbunit/dataset/datatype/NumberDataType.java +++ b/src/main/java/org/dbunit/dataset/datatype/NumberDataType.java @@ -86,6 +86,7 @@ public Object getSqlValue(final int column, final ResultSet resultSet) { logger.debug("getSqlValue(column={}, resultSet={}) - start", column, resultSet); + final BigDecimal rawValue = resultSet.getBigDecimal(column); final BigDecimal value = resultSet.wasNull() ? null : rawValue; logger.debug("getSqlValue: column={}, value={}", column, value); @@ -97,8 +98,12 @@ public void setSqlValue(final Object value, final int column, final PreparedStatement statement) throws SQLException, TypeCastException { - logger.debug("setSqlValue(value={}, column={}, statement={}) - start", - value, column, statement); + if (logger.isDebugEnabled()) + { + logger.debug( + "setSqlValue(value={}, column={}, statement={}) - start", + value, column, statement); + } statement.setBigDecimal(column, (BigDecimal) typeCast(value)); } diff --git a/src/main/java/org/dbunit/dataset/datatype/StringDataType.java b/src/main/java/org/dbunit/dataset/datatype/StringDataType.java index 1e2bae828..f3de19c47 100644 --- a/src/main/java/org/dbunit/dataset/datatype/StringDataType.java +++ b/src/main/java/org/dbunit/dataset/datatype/StringDataType.java @@ -138,6 +138,7 @@ public Object getSqlValue(final int column, final ResultSet resultSet) { logger.debug("getSqlValue(column={}, resultSet={}) - start", column, resultSet); + final String rawValue = resultSet.getString(column); final String value = resultSet.wasNull() ? null : rawValue; logger.debug("getSqlValue: column={}, value={}", column, value); @@ -149,8 +150,12 @@ public void setSqlValue(final Object value, final int column, final PreparedStatement statement) throws SQLException, TypeCastException { - logger.debug("setSqlValue(value={}, column={}, statement={}) - start", - value, column, statement); + if (logger.isDebugEnabled()) + { + logger.debug( + "setSqlValue(value={}, column={}, statement={}) - start", + value, column, statement); + } statement.setString(column, asString(value)); } diff --git a/src/main/java/org/dbunit/dataset/datatype/TimeDataType.java b/src/main/java/org/dbunit/dataset/datatype/TimeDataType.java index 71f02421a..f21b1d4b3 100644 --- a/src/main/java/org/dbunit/dataset/datatype/TimeDataType.java +++ b/src/main/java/org/dbunit/dataset/datatype/TimeDataType.java @@ -125,6 +125,7 @@ public Object getSqlValue(final int column, final ResultSet resultSet) { logger.debug("getSqlValue(column={}, resultSet={}) - start", column, resultSet); + final Time rawValue = resultSet.getTime(column); final Time value = resultSet.wasNull() ? null : rawValue; logger.debug("getSqlValue: column={}, value={}", column, value); @@ -136,8 +137,12 @@ public void setSqlValue(final Object value, final int column, final PreparedStatement statement) throws SQLException, TypeCastException { - logger.debug("setSqlValue(value={}, column={}, statement={}) - start", - value, column, statement); + if (logger.isDebugEnabled()) + { + logger.debug( + "setSqlValue(value={}, column={}, statement={}) - start", + value, column, statement); + } statement.setTime(column, (java.sql.Time) typeCast(value)); } diff --git a/src/main/java/org/dbunit/dataset/datatype/TimestampDataType.java b/src/main/java/org/dbunit/dataset/datatype/TimestampDataType.java index 323b9a099..8ae15354e 100644 --- a/src/main/java/org/dbunit/dataset/datatype/TimestampDataType.java +++ b/src/main/java/org/dbunit/dataset/datatype/TimestampDataType.java @@ -153,7 +153,8 @@ public Object typeCast(final Object value) throws TypeCastException final TimeZone localTZ = java.util.TimeZone.getDefault(); final int offset = localTZ.getOffset(tsTime); final BigInteger localTZOffset = BigInteger.valueOf(offset); - BigInteger time = BigInteger.valueOf(tsTime / 1000 * 1000) + BigInteger time = BigInteger + .valueOf(Math.floorDiv(tsTime, 1000L) * 1000L) .add(localTZOffset).multiply(ONE_BILLION) .add(BigInteger.valueOf(ts.getNanos())); final int hours = Integer.parseInt(zoneValue.substring(1, 3)); @@ -172,8 +173,19 @@ public Object typeCast(final Object value) throws TypeCastException } final BigInteger[] components = time.divideAndRemainder(ONE_BILLION); - ts = new Timestamp(components[0].longValue()); - ts.setNanos(components[1].intValue()); + BigInteger millis = components[0]; + BigInteger nanos = components[1]; + if (nanos.signum() < 0) + { + // BigInteger division truncates toward zero, so a + // negative time produces a negative remainder here; + // normalize to floor-mod so setNanos below never sees + // a negative value. + millis = millis.subtract(BigInteger.ONE); + nanos = nanos.add(ONE_BILLION); + } + ts = new Timestamp(millis.longValue()); + ts.setNanos(nanos.intValue()); } return ts; @@ -221,6 +233,7 @@ public Object getSqlValue(final int column, final ResultSet resultSet) { logger.debug("getSqlValue(column={}, resultSet={}) - start", column, resultSet); + final Timestamp rawValue = resultSet.getTimestamp(column); final Timestamp value = resultSet.wasNull() ? null : rawValue; logger.debug("getSqlValue: column={}, value={}", column, value); @@ -232,8 +245,12 @@ public void setSqlValue(final Object value, final int column, final PreparedStatement statement) throws SQLException, TypeCastException { - logger.debug("setSqlValue(value={}, column={}, statement={}) - start", - value, column, statement); + if (logger.isDebugEnabled()) + { + logger.debug( + "setSqlValue(value={}, column={}, statement={}) - start", + value, column, statement); + } final Timestamp ts = (Timestamp) typeCast(value); if (value instanceof String) { diff --git a/src/main/java/org/dbunit/dataset/excel/XlsTable.java b/src/main/java/org/dbunit/dataset/excel/XlsTable.java index f5b1a51f7..a14c69a96 100644 --- a/src/main/java/org/dbunit/dataset/excel/XlsTable.java +++ b/src/main/java/org/dbunit/dataset/excel/XlsTable.java @@ -132,7 +132,7 @@ public int getRowCount() { logger.debug("getRowCount() - start"); - return _sheet.getLastRowNum(); + return Math.max(_sheet.getLastRowNum(), 0); } public ITableMetaData getTableMetaData() @@ -149,7 +149,13 @@ public Object getValue(int row, String column) throws DataSetException assertValidRowIndex(row); int columnIndex = getColumnIndex(column); - Cell cell = _sheet.getRow(row + 1).getCell(columnIndex); + Row sheetRow = _sheet.getRow(row + 1); + if (sheetRow == null) + { + return null; + } + + Cell cell = sheetRow.getCell(columnIndex); if (cell == null) { return null; diff --git a/src/main/java/org/dbunit/dataset/filter/PatternMatcher.java b/src/main/java/org/dbunit/dataset/filter/PatternMatcher.java index acc57eabd..3579176e3 100644 --- a/src/main/java/org/dbunit/dataset/filter/PatternMatcher.java +++ b/src/main/java/org/dbunit/dataset/filter/PatternMatcher.java @@ -26,6 +26,7 @@ import java.util.Set; import java.util.HashSet; import java.util.Iterator; +import java.util.Locale; /** * @author Manuel Laflamme @@ -59,7 +60,7 @@ public void addPattern(String patternName) } else { - _acceptedNames.add(patternName.toUpperCase()); + _acceptedNames.add(patternName.toUpperCase(Locale.ENGLISH)); } } @@ -79,7 +80,7 @@ public boolean accept(String name) { logger.debug("accept(name={}) - start", name); - if (_acceptedNames.contains(name.toUpperCase())) + if (_acceptedNames.contains(name.toUpperCase(Locale.ENGLISH))) { return true; } diff --git a/src/main/java/org/dbunit/dataset/stream/StreamingIterator.java b/src/main/java/org/dbunit/dataset/stream/StreamingIterator.java index e68a13c9c..6bba4c535 100644 --- a/src/main/java/org/dbunit/dataset/stream/StreamingIterator.java +++ b/src/main/java/org/dbunit/dataset/stream/StreamingIterator.java @@ -35,7 +35,12 @@ /** * Asynchronous table iterator that uses a new Thread for asynchronous processing. - * + *

+ * The producer runs on a daemon thread that communicates with this iterator through a + * bounded channel. If this iterator is abandoned before the producer reaches the end of + * the dataset, the producer thread stays parked waiting to put its next element onto the + * channel until the JVM exits; there is no method to cancel it early. + * * @author Manuel Laflamme * @author Last changed by: $Author$ * @version $Revision$ $Date$ @@ -58,7 +63,7 @@ public class StreamingIterator implements ITableIterator /** * Variable to store an exception that might occur in the asynchronous consumer */ - private Exception _asyncException; + private volatile Exception _asyncException; /** diff --git a/src/main/java/org/dbunit/dataset/xml/FlatDtdDataSet.java b/src/main/java/org/dbunit/dataset/xml/FlatDtdDataSet.java index 6b91cb5d3..a4c586f00 100644 --- a/src/main/java/org/dbunit/dataset/xml/FlatDtdDataSet.java +++ b/src/main/java/org/dbunit/dataset/xml/FlatDtdDataSet.java @@ -28,6 +28,7 @@ import java.io.OutputStreamWriter; import java.io.Reader; import java.io.Writer; +import java.nio.charset.StandardCharsets; import org.dbunit.dataset.AbstractDataSet; import org.dbunit.dataset.DataSetException; @@ -86,14 +87,17 @@ protected void initialize() } /** - * Write the specified dataset to the specified output stream as DTD. + * Writes the specified dataset to the specified output stream as DTD, + * encoded in UTF-8, matching what the {@code InputStream} constructor's + * SAX parsing assumes absent an explicit encoding declaration. * @see FlatDtdWriter */ public static void write(IDataSet dataSet, OutputStream out) throws IOException, DataSetException { logger.debug("write(dataSet={}, out={}) - start", dataSet, out); - write(dataSet, new BufferedWriter(new OutputStreamWriter(out))); + write(dataSet, new BufferedWriter( + new OutputStreamWriter(out, StandardCharsets.UTF_8))); } /** diff --git a/src/main/java/org/dbunit/dataset/xml/FlatXmlWriter.java b/src/main/java/org/dbunit/dataset/xml/FlatXmlWriter.java index ca30b793f..b351a226a 100644 --- a/src/main/java/org/dbunit/dataset/xml/FlatXmlWriter.java +++ b/src/main/java/org/dbunit/dataset/xml/FlatXmlWriter.java @@ -29,6 +29,7 @@ import org.dbunit.dataset.Column; import org.dbunit.dataset.DataSetException; import org.dbunit.dataset.IDataSet; +import org.dbunit.dataset.ITable; import org.dbunit.dataset.ITableMetaData; import org.dbunit.dataset.datatype.DataType; import org.dbunit.dataset.datatype.TypeCastException; @@ -201,8 +202,9 @@ public void row(Object[] values) throws DataSetException String columnName = columns[i].getColumnName(); Object value = values[i]; - // Skip null value - if (value == null) + // Skip null and no-value cells; omitting the attribute is + // flat XML's representation of "no value" + if (value == null || value == ITable.NO_VALUE) { continue; } diff --git a/src/main/java/org/dbunit/dataset/yaml/YamlDataSet.java b/src/main/java/org/dbunit/dataset/yaml/YamlDataSet.java index f4d2cd3bf..e392b1e8c 100644 --- a/src/main/java/org/dbunit/dataset/yaml/YamlDataSet.java +++ b/src/main/java/org/dbunit/dataset/yaml/YamlDataSet.java @@ -32,6 +32,7 @@ import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.Writer; +import java.nio.charset.StandardCharsets; /** * Reads and writes flat YAML-based dataset documents. Contrary to the flat XML layout, @@ -90,12 +91,14 @@ public YamlDataSet(InputStream inputStream) throws DataSetException } /** - * Write the specified dataset to the specified output stream as YAML. + * Writes the specified dataset to the specified output stream as YAML, + * encoded in UTF-8, matching what {@link YamlProducer} decodes. */ public static void write(IDataSet dataSet, OutputStream out) throws DataSetException { - write(dataSet, new BufferedWriter(new OutputStreamWriter(out))); + write(dataSet, new BufferedWriter( + new OutputStreamWriter(out, StandardCharsets.UTF_8))); } /** diff --git a/src/main/java/org/dbunit/dataset/yaml/YamlWriter.java b/src/main/java/org/dbunit/dataset/yaml/YamlWriter.java index 784275d1d..7dfb87060 100644 --- a/src/main/java/org/dbunit/dataset/yaml/YamlWriter.java +++ b/src/main/java/org/dbunit/dataset/yaml/YamlWriter.java @@ -96,7 +96,7 @@ private LinkedHashMap>> dataSetAsMap( { String columnName = column.getColumnName(); Object value = table.getValue(row, columnName); - if (value != null) + if (value != null && value != ITable.NO_VALUE) { rowMap.put(columnName, value); } diff --git a/src/main/java/org/dbunit/ext/oracle/OracleConnection.java b/src/main/java/org/dbunit/ext/oracle/OracleConnection.java index cfb32b466..025328d5f 100644 --- a/src/main/java/org/dbunit/ext/oracle/OracleConnection.java +++ b/src/main/java/org/dbunit/ext/oracle/OracleConnection.java @@ -25,6 +25,7 @@ import org.dbunit.database.DatabaseConnection; import java.sql.Connection; +import java.util.Locale; /** * @@ -43,7 +44,7 @@ public class OracleConnection extends DatabaseConnection */ public OracleConnection(Connection connection, String schema) throws DatabaseUnitException { - super(connection, schema != null ? schema.toUpperCase() : null); + super(connection, schema != null ? schema.toUpperCase(Locale.ENGLISH) : null); getConfig().setProperty(DatabaseConfig.PROPERTY_DATATYPE_FACTORY, new OracleDataTypeFactory()); } diff --git a/src/main/java/org/dbunit/ext/oracle/OracleSdoGeometryDataType.java b/src/main/java/org/dbunit/ext/oracle/OracleSdoGeometryDataType.java index 8a6cf2f6f..82d86ede6 100644 --- a/src/main/java/org/dbunit/ext/oracle/OracleSdoGeometryDataType.java +++ b/src/main/java/org/dbunit/ext/oracle/OracleSdoGeometryDataType.java @@ -26,6 +26,7 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Types; +import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -163,7 +164,7 @@ public Object typeCast(Object value) throws TypeCastException try { // all upper case for parse purposes - String upperVal = ((String) value).toUpperCase().trim(); + String upperVal = ((String) value).toUpperCase(Locale.ENGLISH).trim(); if (NULL.equals(upperVal)) { return null; diff --git a/src/main/java/org/dbunit/ext/oracle/OracleXMLTypeDataType.java b/src/main/java/org/dbunit/ext/oracle/OracleXMLTypeDataType.java index ed2501a1c..741d66862 100644 --- a/src/main/java/org/dbunit/ext/oracle/OracleXMLTypeDataType.java +++ b/src/main/java/org/dbunit/ext/oracle/OracleXMLTypeDataType.java @@ -20,6 +20,7 @@ */ package org.dbunit.ext.oracle; +import java.nio.charset.StandardCharsets; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; @@ -35,8 +36,6 @@ import oracle.jdbc.OracleResultSet; /** - * - * TODO UnitTests are completely missing * * @author Phil Barr * @author Last changed by: $Author$ @@ -52,6 +51,13 @@ public class OracleXMLTypeDataType extends BlobDataType super("SQLXML", Types.SQLXML); } + /** + * {@inheritDoc} + *

+ * The XML content is converted to {@code byte[]} using UTF-8, matching + * {@link #setSqlValue(Object, int, PreparedStatement)}'s decoding of the + * same bytes back to a {@code String}. + */ @Override public Object getSqlValue(final int column, final ResultSet resultSet) throws SQLException, TypeCastException @@ -62,7 +68,7 @@ public Object getSqlValue(final int column, final ResultSet resultSet) final SQLXML sqlXml = oracleResultSet.getSQLXML(column); if (sqlXml != null) { - data = sqlXml.getString().getBytes(); + data = sqlXml.getString().getBytes(StandardCharsets.UTF_8); } // return the byte data (using typeCast to cast it to Base64 notation) @@ -75,6 +81,13 @@ public Object getSqlValue(final int column, final ResultSet resultSet) return typeCast; } + /** + * {@inheritDoc} + *

+ * The decoded {@code byte[]} is converted back to a {@code String} using + * UTF-8, matching {@link #getSqlValue(int, ResultSet)}'s encoding of the + * same bytes. + */ @Override public void setSqlValue(final Object value, final int column, final PreparedStatement statement) @@ -88,7 +101,7 @@ public void setSqlValue(final Object value, final int column, // XML document in the parameter is Base64 encoded (it is entered in XML // parameter) final byte[] typeCast = (byte[]) typeCast(value); - final String string = new String(typeCast); + final String string = new String(typeCast, StandardCharsets.UTF_8); log.trace("setSqlValue: column={}, value={}, typeCast={}, string={}", column, value, typeCast, string); diff --git a/src/main/java/org/dbunit/operation/InsertOperation.java b/src/main/java/org/dbunit/operation/InsertOperation.java index 7139766b4..43a3fd9cd 100644 --- a/src/main/java/org/dbunit/operation/InsertOperation.java +++ b/src/main/java/org/dbunit/operation/InsertOperation.java @@ -121,8 +121,7 @@ protected BitSet getIgnoreMapping(ITable table, int row) throws DataSetException { Column column = columns[i]; Object value = table.getValue(row, column.getColumnName()); - if (value == ITable.NO_VALUE - || (value == null && column.isNotNullable() && column.hasDefaultValue())) + if (wouldIgnore(column, value)) { ignoreMapping.set(i); } @@ -143,9 +142,9 @@ protected boolean equalsIgnoreMapping(BitSet ignoreMapping, ITable table, for (int i = 0; i < columns.length; i++) { - boolean bit = ignoreMapping.get(i); - Object value = table.getValue(row, columns[i].getColumnName()); - if ((bit && value != ITable.NO_VALUE) || (!bit && value == ITable.NO_VALUE)) + Column column = columns[i]; + Object value = table.getValue(row, column.getColumnName()); + if (wouldIgnore(column, value) != ignoreMapping.get(i)) { return false; } @@ -153,4 +152,26 @@ protected boolean equalsIgnoreMapping(BitSet ignoreMapping, ITable table, return true; } + + /** + * Determines whether a column's value would be omitted from the insert + * statement: either because no value was supplied at all, or because the + * value is {@code null} for a not-nullable column that has a database + * default, in which case the column is left out so the database applies + * its default instead of a {@code NULL} insert failing the constraint. + * Used by both {@link #getIgnoreMapping(ITable, int)} and + * {@link #equalsIgnoreMapping(BitSet, ITable, int)} so the two can never + * diverge. + * + * @param column + * The column being evaluated. + * @param value + * The row's value for that column. + * @return {@code true} if the column would be omitted from the insert. + */ + private static boolean wouldIgnore(final Column column, final Object value) + { + return value == ITable.NO_VALUE || (value == null + && column.isNotNullable() && column.hasDefaultValue()); + } } diff --git a/src/main/java/org/dbunit/operation/RefreshOperation.java b/src/main/java/org/dbunit/operation/RefreshOperation.java index d8430b318..c915df85d 100644 --- a/src/main/java/org/dbunit/operation/RefreshOperation.java +++ b/src/main/java/org/dbunit/operation/RefreshOperation.java @@ -105,6 +105,7 @@ public void execute(IDatabaseConnection connection, IDataSet dataSet) RowOperation insertRowOperation = new InsertRowOperation(connection, metaData); + Throwable primaryFailure = null; try { // refresh all rows @@ -127,13 +128,54 @@ public void execute(IDatabaseConnection connection, IDataSet dataSet) { final String msg = "Exception processing table name='" + tableName + "'"; - throw new DatabaseUnitException(msg, e); + final DatabaseUnitException wrapped = + new DatabaseUnitException(msg, e); + primaryFailure = wrapped; + throw wrapped; } finally { - // cleanup - updateRowOperation.close(); - insertRowOperation.close(); + // cleanup: run both closes even if the first fails, combining + // close failures via addSuppressed rather than letting the + // second silently replace the first. If a row-processing + // failure is already propagating (primaryFailure != null), + // attach any close failure to it as suppressed instead of + // letting the close failure replace the real cause. + SQLException closeFailure = null; + try + { + updateRowOperation.close(); + } + catch (final SQLException e) + { + closeFailure = e; + } + try + { + insertRowOperation.close(); + } + catch (final SQLException e) + { + if (closeFailure != null) + { + closeFailure.addSuppressed(e); + } + else + { + closeFailure = e; + } + } + if (closeFailure != null) + { + if (primaryFailure != null) + { + primaryFailure.addSuppressed(closeFailure); + } + else + { + throw closeFailure; + } + } } } @@ -265,8 +307,6 @@ public boolean execute(ITable table, int row) */ private class UpdateRowOperation extends RowOperation { - PreparedStatement _countStatement; - public UpdateRowOperation(IDatabaseConnection connection, ITableMetaData metaData) throws DataSetException, SQLException diff --git a/src/main/java/org/dbunit/operation/TransactionOperation.java b/src/main/java/org/dbunit/operation/TransactionOperation.java index 147b91372..f7dda7702 100644 --- a/src/main/java/org/dbunit/operation/TransactionOperation.java +++ b/src/main/java/org/dbunit/operation/TransactionOperation.java @@ -40,7 +40,6 @@ */ public class TransactionOperation extends DatabaseOperation { - /** * Logger for this class */ @@ -80,17 +79,22 @@ public void execute(IDatabaseConnection connection, IDataSet dataSet) } catch (DatabaseUnitException e) { - jdbcConnection.rollback(); + handleException(jdbcConnection, e); throw e; } catch (SQLException e) { - jdbcConnection.rollback(); + handleException(jdbcConnection, e); throw e; } catch (RuntimeException e) { - jdbcConnection.rollback(); + handleException(jdbcConnection, e); + throw e; + } + catch (Error e) + { + handleException(jdbcConnection, e); throw e; } finally @@ -98,4 +102,26 @@ public void execute(IDatabaseConnection connection, IDataSet dataSet) jdbcConnection.setAutoCommit(true); } } + + /** + * Rolls back the given connection, attaching a rollback failure to the + * given exception as suppressed instead of letting it replace and hide + * the exception that triggered the rollback. + * + * @param jdbcConnection + * The connection to roll back. + * @param e + * The exception that triggered the rollback. + */ + private static void handleException(Connection jdbcConnection, Throwable e) + { + try + { + jdbcConnection.rollback(); + } + catch (SQLException rollbackFailure) + { + e.addSuppressed(rollbackFailure); + } + } } diff --git a/src/main/java/org/dbunit/operation/TruncateTableOperation.java b/src/main/java/org/dbunit/operation/TruncateTableOperation.java index 6429129f3..a7955b858 100644 --- a/src/main/java/org/dbunit/operation/TruncateTableOperation.java +++ b/src/main/java/org/dbunit/operation/TruncateTableOperation.java @@ -30,6 +30,7 @@ import org.dbunit.dataset.IDataSet; import java.sql.SQLException; +import java.util.Locale; /** * Truncate tables present in the specified dataset. If the dataset does not @@ -76,7 +77,7 @@ protected String getDeleteAllCommandSuffix(IDatabaseConnection connection) throw return ""; } String productName = jdbcConnection.getMetaData().getDatabaseProductName(); - if (productName != null && productName.toLowerCase().startsWith("db2")) + if (productName != null && productName.toLowerCase(Locale.ENGLISH).startsWith("db2")) { return " IMMEDIATE"; } diff --git a/src/main/java/org/dbunit/util/Base64.java b/src/main/java/org/dbunit/util/Base64.java index 99e1c283f..6f4f28da9 100644 --- a/src/main/java/org/dbunit/util/Base64.java +++ b/src/main/java/org/dbunit/util/Base64.java @@ -159,8 +159,6 @@ public static void main(String[] args) catch (Exception e) { logger.error("main()", e); - - e.printStackTrace(); } } @@ -311,7 +309,6 @@ public static String encodeObject(java.io.Serializable serializableObject) { logger.error("encodeObject()", e); - e.printStackTrace(); return null; } // end catch finally @@ -581,14 +578,12 @@ public static Object decodeToObject(String encodedObject) { logger.error("decodeToObject()", e); - e.printStackTrace(); return null; } // end catch catch (ClassNotFoundException e) { logger.error("decodeToObject()", e); - e.printStackTrace(); return null; } // end catch finally @@ -662,7 +657,8 @@ public static byte[] decode(byte[] source, int off, int len) } // end if: white space, equals sign or better else { - System.err.println("Bad Base64 input character at " + i + ": " + source[i] + "(decimal)"); + logger.warn("Bad Base64 input character at {}: {}(decimal)", + i, source[i]); return null; } // end else: } // each input character diff --git a/src/main/java/org/dbunit/util/FileHelper.java b/src/main/java/org/dbunit/util/FileHelper.java index 69c64a760..08ec1024f 100644 --- a/src/main/java/org/dbunit/util/FileHelper.java +++ b/src/main/java/org/dbunit/util/FileHelper.java @@ -113,25 +113,19 @@ public static InputSource createInputSource(File file) throws MalformedURLExcept * @param destFile the dest file * @throws IOException */ - public static void copyFile(File srcFile, File destFile) throws IOException + public static void copyFile(File srcFile, File destFile) throws IOException { logger.debug("copyFile(srcFile={}, destFile={}) - start", srcFile, destFile); - // Create channel on the source - FileChannel srcChannel = new FileInputStream(srcFile).getChannel(); + try (FileInputStream srcStream = new FileInputStream(srcFile); + FileOutputStream dstStream = new FileOutputStream(destFile)) + { + FileChannel srcChannel = srcStream.getChannel(); + FileChannel dstChannel = dstStream.getChannel(); - // Create channel on the destination - FileChannel dstChannel = new FileOutputStream(destFile).getChannel(); - - try { // Copy file contents from source to destination dstChannel.transferFrom(srcChannel, 0, srcChannel.size()); } - finally { - // Close the channels - srcChannel.close(); - dstChannel.close(); - } } /** diff --git a/src/main/java/org/dbunit/util/QualifiedTableName.java b/src/main/java/org/dbunit/util/QualifiedTableName.java index 868828f7f..efcbf4f77 100644 --- a/src/main/java/org/dbunit/util/QualifiedTableName.java +++ b/src/main/java/org/dbunit/util/QualifiedTableName.java @@ -196,6 +196,11 @@ private String getQualifiedName(String prefix, String name, name = getEscapedName(name, escapePattern); } + // A 3+-part original name (e.g. "CATALOG.X.TABLE") still contains a + // dot here after escaping its "X.TABLE" remainder, so this branch + // returns it as-is and silently drops prefix ("CATALOG") rather than + // qualifying with it - pre-existing behavior, not full 3-part + // support. if (prefix == null || prefix.equals("") || name.indexOf(".") >= 0) { return name; @@ -230,7 +235,7 @@ private String getEscapedName(String name, String escapePattern) } int split = name.indexOf("."); - if (split > 1) + if (split > 0) { return getEscapedName(name.substring(0, split), escapePattern) + "." + getEscapedName(name.substring(split + 1), escapePattern); } diff --git a/src/main/java/org/dbunit/util/SQLHelper.java b/src/main/java/org/dbunit/util/SQLHelper.java index 90305f08b..bb6b9f3e0 100644 --- a/src/main/java/org/dbunit/util/SQLHelper.java +++ b/src/main/java/org/dbunit/util/SQLHelper.java @@ -28,6 +28,7 @@ import java.sql.SQLException; import java.sql.Statement; import java.util.Locale; +import java.util.Objects; import org.dbunit.DatabaseUnitRuntimeException; import org.dbunit.database.IMetadataHandler; @@ -611,12 +612,12 @@ else if(databaseMetaData.storesUpperCaseIdentifiers()) */ public static final void logInfoIfValueChanged(String oldValue, String newValue, String message, Class source) { - if(logger.isInfoEnabled()) + if(logger.isDebugEnabled()) { - if(oldValue != null && !oldValue.equals(newValue)) + if(!Objects.equals(oldValue, newValue)) logger.debug("{}. {} oldValue={} newValue={}", new Object[] {source, message, oldValue, newValue}); - } } + } /** * Checks whether two given values are unequal and if so print a log message (level DEBUG) @@ -632,8 +633,8 @@ public static final void logDebugIfValueChanged(String oldValue, String newValue { if (oldValue != null && !oldValue.equals(newValue)) logger.debug("{}. {} oldValue={} newValue={}", new Object[] {source, message, oldValue, newValue}); - } } + } /** * @param tableName diff --git a/src/main/java/org/dbunit/util/xml/XmlWriter.java b/src/main/java/org/dbunit/util/xml/XmlWriter.java index c1b71b048..efff184fd 100644 --- a/src/main/java/org/dbunit/util/xml/XmlWriter.java +++ b/src/main/java/org/dbunit/util/xml/XmlWriter.java @@ -64,7 +64,9 @@ import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.Deque; +import java.util.List; /** * Makes writing XML much much easier. Improved from replacedCodePoints = null; + int last = 0; int index = 0; - for (index = 0; index < strLength; index++) + while (index < strLength) { - final char currentChar = str.charAt(index); - final String entity = - convertCharacterToEntity(currentChar, literally); + final int codePoint = str.codePointAt(index); + final int charCount = Character.charCount(codePoint); - // If we found something to substitute, then copy over previous - // data then do the substitution. - if (entity != null) + String replacement; + if (!isValidXmlChar(codePoint)) { - if (block == null) + // Not representable in XML 1.0 at all (control characters + // other than tab/LF/CR, 0xFFFE/0xFFFF, unpaired surrogates): + // substitute the replacement character rather than emitting + // it raw or as a numeric entity, both of which would be + // unparseable. + replacement = REPLACEMENT_CHARACTER; + if (replacedCodePoints == null) { - block = str.toCharArray(); + replacedCodePoints = new ArrayList<>(); } + replacedCodePoints.add(codePoint); + } else if (charCount == 1) + { + // Valid BMP code point: markup entities, or pass through raw + // (still routed through convertCharacterToEntity so existing + // subclass overrides keep taking effect). + replacement = convertCharacterToEntity((char) codePoint, literally); + } else + { + // Valid supplementary code point: no markup character is + // outside the BMP, so it always passes through raw. + replacement = null; + } + + // If we found something to substitute, then copy over previous + // data then do the substitution. + if (replacement != null) + { if (buffer == null) { buffer = new StringBuilder(); } - buffer.append(block, last, index - last); - buffer.append(entity); - last = index + 1; + buffer.append(str, last, index); + buffer.append(replacement); + last = index + charCount; } + + index += charCount; + } + + if (replacedCodePoints != null) + { + logger.warn( + "escapeXml replaced {} code point(s) not representable in XML 1.0 with the U+FFFD replacement character: {}", + replacedCodePoints.size(), replacedCodePoints); } // nothing found, just return source @@ -711,15 +752,7 @@ private String escapeXml(final String str, final boolean literally) if (last < strLength) { - if (block == null) - { - block = str.toCharArray(); - } - if (buffer == null) - { - buffer = new StringBuilder(); - } - buffer.append(block, last, index - last); + buffer.append(str, last, strLength); } return buffer.toString(); @@ -836,58 +869,6 @@ private String replace(final String value, final String original, return buffer == null ? value : buffer.toString(); } - private void setEncoding(String encoding) - { - logger.debug("setEncoding(encoding={}) - start", encoding); - - Charset charset = null; - - if (encoding == null && out instanceof OutputStreamWriter) - { - charset = Charset.forName(((OutputStreamWriter) out).getEncoding()); - } - - if (encoding != null) - { - final String ucEncoding = encoding.toUpperCase(); - - // Use official encoding names where we know them, - // avoiding the Java-only names. When using common - // encodings where we can easily tell if characters - // are out of range, we'll escape out-of-range - // characters using character refs for safety. - - // I _think_ these are all the main synonyms for these! - if ("UTF8".equalsIgnoreCase(ucEncoding)) - { - charset = StandardCharsets.UTF_8; - } else if ("US-ASCII".equalsIgnoreCase(ucEncoding) || "ASCII".equalsIgnoreCase(ucEncoding)) - { - // dangerMask = (short)0xff80; - charset = StandardCharsets.US_ASCII; - } else if ("ISO-8859-1".equalsIgnoreCase(ucEncoding) - || "8859_1".equalsIgnoreCase(ucEncoding) - || "ISO8859_1".equalsIgnoreCase(ucEncoding)) - { - // dangerMask = (short)0xff00; - charset = StandardCharsets.ISO_8859_1; - } else if ("UNICODE".equalsIgnoreCase(ucEncoding) - || "UNICODE-BIG".equalsIgnoreCase(ucEncoding) - || "UNICODE-LITTLE".equalsIgnoreCase(ucEncoding)) - { - charset = StandardCharsets.UTF_16; - - // TODO: UTF-16BE, UTF-16LE ... no BOM; what - // release of JDK supports those Unicode names? - } - - // if (dangerMask != 0) - // stringBuf = new StringBuffer(); - } - - setEncoding(charset); - } - private void setEncoding(Charset charset) { this.encoding = charset; diff --git a/src/site/asciidoc/index.adoc b/src/site/asciidoc/index.adoc index 7fb300bc8..97bf6bd16 100644 --- a/src/site/asciidoc/index.adoc +++ b/src/site/asciidoc/index.adoc @@ -30,13 +30,14 @@ We will gladly help you as needed with your ideas and contributions and look to |=== |Date |News -// |TBD -// |Please try the 3.3.1-SNAPSHOT snapshot build and let us know how it works! -// See link:https://dbunit.github.io/dbunit-extension/repos.html#snapshots[SNAPSHOTS] for how to use them. -// Refer to the link:changes.html#a3.3.1-SNAPSHOT[changes report], -// the link:https://github.com/dbunit/dbunit-extension/issues?q=is%3Aissue+milestone%3A3.3.1%20type%3AFeature[feature list], and -// the link:https://github.com/dbunit/dbunit-extension/issues?q=is%3Aissue+milestone%3A3.3.1%20type%3ABug[bug list] -// for the snapshot contents (and subsequent updates). +|TBD +|Please try the 3.4.0-SNAPSHOT snapshot build and let us know how it works! +It has additional *performance improvements*. +See link:https://dbunit.github.io/dbunit-extension/repos.html#snapshots[SNAPSHOTS] for how to use them. +Refer to the link:changes.html#a3.4.0-SNAPSHOT[changes report], +the link:https://github.com/dbunit/dbunit-extension/issues?q=is%3Aissue+milestone%3A3.4.0%20type%3AFeature[feature list], and +the link:https://github.com/dbunit/dbunit-extension/issues?q=is%3Aissue+milestone%3A3.4.0%20type%3ABug[bug list] +for the snapshot contents (and subsequent updates). |2026-07-22 |Release 3.3.0 available. diff --git a/src/site/fml/faq.fml b/src/site/fml/faq.fml index d9c47117c..d3e0374a1 100644 --- a/src/site/fml/faq.fml +++ b/src/site/fml/faq.fml @@ -348,10 +348,31 @@ IDataSet dataSet = new CachedDataSet(producer); 1. Reuse the same connection thorough your test suite

- Creating a new DbUnit connection every time has a cost. The overhead is much more than just creating a new JDBC connection. - DbUnit needs to fetch the metadata of tables to retrieve the column data types. This information is cached in the DbUnit connection. - So it is highly recommended to reuse the same DbUnit connection throughout your test suite; the more tables you have, - the greater the benefits. + Creating a new DbUnit connection every time has a cost. The overhead is much more than just creating a new JDBC connection. + DbUnit needs to fetch the metadata of tables to retrieve the column data types. This information is cached in the DbUnit connection. + So it is highly recommended to reuse the same DbUnit connection throughout your test suite; the more tables you have, + the greater the benefits. +

+

+ Since DbUnit 3.4.0, CachingConnectionProvider + makes this safe and opt-in: construct one instance per target database, share that same instance across the + JdbcDatabaseTester, + DataSourceDatabaseTester, + JndiDatabaseTester or + DefaultDatabaseTester instances built for each test + (for example via a static field on a common test base class), and pass it to their constructor. + Unlike hand-holding a single connection open for the whole suite, it detects a dead or expired connection on + each call and transparently replaces it, so a transient database blip does not take down the rest of the run. + Pair it with a non-closing IOperationListener, otherwise the + cached connection is closed - and therefore rebuilt from scratch - after every setUp/tearDown. +

+

+ DefaultPrepAndExpectedTestCase + manages its own connection for setupData(), verifyData() and cleanupData() rather than relying on + an IOperationListener, so pairing a shared CachingConnectionProvider with its databaseTester is not + by itself enough: also call + setCloseConnectionAfterTest(false) + so cleanupData() does not close a connection other tests still expect to reuse.

2. Specify the database schema name @@ -701,7 +722,23 @@ public class MyXmlWriter extends org.dbunit.dataset.xml.XmlDataSetWriter { dbTester.onSetup(); ... - + Leaving the connection open like this is only half the story: nothing checks whether it is + still alive, so a single dropped connection can fail every remaining test in the run. Since + DbUnit 3.4.0, pairing NO_OP_OPERATION_LISTENER with + CachingConnectionProvider + makes this pattern safe to opt into: share one CachingConnectionProvider instance + across the testers built for each test, and it detects a dead or expired connection and + transparently replaces it instead of leaving your suite stuck with a broken one. + + private static final CachingConnectionProvider CONNECTION_PROVIDER = + new CachingConnectionProvider(); + ... + IDatabaseTester dbTester = new DataSourceDatabaseTester(ds, schema, CONNECTION_PROVIDER); + dbTester.setOperationListener(IOperationListener.NO_OP_OPERATION_LISTENER); + dbTester.onSetup(); + ... + + See the performance FAQ for more.

diff --git a/src/test/java/org/dbunit/DataSourceDatabaseTesterIT.java b/src/test/java/org/dbunit/DataSourceDatabaseTesterIT.java new file mode 100644 index 000000000..d6d7da277 --- /dev/null +++ b/src/test/java/org/dbunit/DataSourceDatabaseTesterIT.java @@ -0,0 +1,128 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2026, DbUnit.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + */ +package org.dbunit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.concurrent.atomic.AtomicInteger; + +import javax.sql.DataSource; + +import org.dbunit.database.CachingConnectionProvider; +import org.dbunit.database.IDatabaseConnection; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * Integration tests for {@link DataSourceDatabaseTester}, run against a real + * H2 in-memory {@link DataSource}, focused on its opt-in + * {@link CachingConnectionProvider} integration. + * + * @since 3.4.0 + */ +class DataSourceDatabaseTesterIT +{ + private static final AtomicInteger DB_COUNTER = new AtomicInteger(); + + private IDatabaseConnection openedConnection; + + @AfterEach + void closeOpenedConnection() throws Exception + { + if (openedConnection != null) + { + openedConnection.close(); + } + } + + @Test + void testGetConnection_withoutProvider_returnsDifferentInstanceOnEachCall() throws Exception + { + final DataSourceDatabaseTester tester = new DataSourceDatabaseTester(newDataSource()); + + final IDatabaseConnection first = tester.getConnection(); + final IDatabaseConnection second = tester.getConnection(); + + assertThat(second) + .as("Without a CachingConnectionProvider, every call must create a fresh " + + "connection, matching pre-existing behavior.") + .isNotSameAs(first); + first.close(); + second.close(); + } + + @Test + void testGetConnection_withProvider_returnsSameInstanceOnEachCall() throws Exception + { + final CachingConnectionProvider provider = new CachingConnectionProvider(); + final DataSourceDatabaseTester tester = + new DataSourceDatabaseTester(newDataSource(), null, provider); + + final IDatabaseConnection first = tester.getConnection(); + final IDatabaseConnection second = tester.getConnection(); + + openedConnection = second; + assertThat(second) + .as("With a CachingConnectionProvider, calls must reuse the cached connection.") + .isSameAs(first); + } + + @Test + void testGetConnection_withProvider_afterUnderlyingConnectionDies_returnsNewWorkingConnection() + throws Exception + { + final CachingConnectionProvider provider = new CachingConnectionProvider(); + final DataSourceDatabaseTester tester = + new DataSourceDatabaseTester(newDataSource(), null, provider); + + final IDatabaseConnection first = tester.getConnection(); + first.getConnection().close(); + + final IDatabaseConnection second = tester.getConnection(); + + openedConnection = second; + assertThat(second).as("A dead cached connection must be transparently replaced.") + .isNotSameAs(first); + assertThat(second.getConnection().createStatement().execute("SELECT 1")) + .as("The replacement connection must be a real, working JDBC connection.") + .isTrue(); + } + + @Test + void testConstructor_withNullDataSourceAndProvider_throwsNullPointerException() + { + assertThatThrownBy( + () -> new DataSourceDatabaseTester(null, null, new CachingConnectionProvider())) + .as("The 3-arg constructor must reject a null DataSource just like the " + + "pre-existing constructors do.") + .isInstanceOf(NullPointerException.class); + } + + private static DataSource newDataSource() + { + final JdbcDataSource dataSource = new JdbcDataSource(); + dataSource.setUrl( + "jdbc:h2:mem:datasourcetester_" + DB_COUNTER.incrementAndGet() + ";DB_CLOSE_DELAY=-1"); + return dataSource; + } +} diff --git a/src/test/java/org/dbunit/DatabaseTestCaseIT.java b/src/test/java/org/dbunit/DatabaseTestCaseIT.java index 3c247ffbb..f7f0bd61b 100644 --- a/src/test/java/org/dbunit/DatabaseTestCaseIT.java +++ b/src/test/java/org/dbunit/DatabaseTestCaseIT.java @@ -21,12 +21,14 @@ package org.dbunit; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +import java.sql.SQLException; import org.dbunit.database.DatabaseConfig; import org.dbunit.database.IDatabaseConnection; import org.dbunit.dataset.IDataSet; import org.dbunit.operation.DatabaseOperation; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; /** @@ -39,11 +41,108 @@ class DatabaseTestCaseIT { @Test - @Disabled("TODO implement #1087040 tearDownOperation Exception obscures underlying problem") - void testTearDownExceptionDoesNotObscureTestException() + void testTearDownExceptionDoesNotObscureTestException() throws Exception + { + final IDatabaseConnection conn = + DatabaseEnvironment.getInstance().getConnection(); + + final SQLException tearDownFailure = + new SQLException("Simulated tear-down operation failure."); + final DatabaseOperation failingTearDownOperation = + new DatabaseOperation() + { + @Override + public void execute(final IDatabaseConnection connection, + final IDataSet dataSet) throws SQLException + { + throw tearDownFailure; + } + }; + + final DatabaseTestCase testSubject = + makeTestSubject(conn, failingTearDownOperation); + testSubject.setUp(); + + final RuntimeException testFailure = + new RuntimeException("Simulated test body failure."); + + final Throwable propagated = + catchThrowable(() -> testSubject.tearDown(testFailure)); + + assertThat(propagated) + .as("The original test failure must propagate, not the tear-down failure.") + .isSameAs(testFailure); + assertThat(propagated.getSuppressed()) + .as("The tear-down failure must be attached as suppressed, not lost.") + .containsExactly(tearDownFailure); + } + + @Test + void testTearDownErrorDoesNotObscureTestException() throws Exception { - // TODO implement #1087040 tearDownOperation Exception obscures - // underlying problem + final IDatabaseConnection conn = + DatabaseEnvironment.getInstance().getConnection(); + + final AssertionError tearDownFailure = new AssertionError( + "Simulated tear-down operation failure (an Error, not an Exception)."); + final DatabaseOperation failingTearDownOperation = + new DatabaseOperation() + { + @Override + public void execute(final IDatabaseConnection connection, + final IDataSet dataSet) + { + throw tearDownFailure; + } + }; + + final DatabaseTestCase testSubject = + makeTestSubject(conn, failingTearDownOperation); + testSubject.setUp(); + + final RuntimeException testFailure = + new RuntimeException("Simulated test body failure."); + + final Throwable propagated = + catchThrowable(() -> testSubject.tearDown(testFailure)); + + assertThat(propagated) + .as("The original test failure must propagate, not the tear-down Error.") + .isSameAs(testFailure); + assertThat(propagated.getSuppressed()) + .as("The tear-down Error must be attached as suppressed, not lost.") + .containsExactly(tearDownFailure); + } + + private DatabaseTestCase makeTestSubject(final IDatabaseConnection conn, + final DatabaseOperation tearDownOperation) + { + return new DatabaseTestCase() + { + @Override + protected IDatabaseConnection getConnection() throws Exception + { + return conn; + } + + @Override + protected IDataSet getDataSet() throws Exception + { + return null; + } + + @Override + protected DatabaseOperation getSetUpOperation() throws Exception + { + return DatabaseOperation.NONE; + } + + @Override + protected DatabaseOperation getTearDownOperation() throws Exception + { + return tearDownOperation; + } + }; } /** diff --git a/src/test/java/org/dbunit/DatabaseTesterConnectionReuseIT.java b/src/test/java/org/dbunit/DatabaseTesterConnectionReuseIT.java new file mode 100644 index 000000000..228966fa8 --- /dev/null +++ b/src/test/java/org/dbunit/DatabaseTesterConnectionReuseIT.java @@ -0,0 +1,237 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2026, DbUnit.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + */ +package org.dbunit; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; + +import org.dbunit.database.CachingConnectionProvider; +import org.dbunit.database.IDatabaseConnection; +import org.dbunit.dataset.IDataSet; +import org.dbunit.operation.DatabaseOperation; +import org.dbunit.util.fileloader.DataFileLoader; +import org.dbunit.util.fileloader.FlatXmlDataFileLoader; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Integration tests proving that a {@link CachingConnectionProvider}, shared across the fresh + * {@link IDatabaseTester} instances a test harness builds for each test method (exactly as + * {@link DatabaseTestCase} already does - its {@code tester} field is reset to null + * after every {@link DatabaseTestCase#tearDown()}, so {@link DatabaseTestCase#newDatabaseTester()} + * runs again on the next test), delivers the cross-test-method connection reuse issue #799 asks + * for - and only when paired with a non-closing {@link IOperationListener}, staying fully + * backward compatible otherwise. + *

+ * Also proves {@link DefaultPrepAndExpectedTestCase} - which manages its own connection lifecycle + * rather than delegating to a listener (see issue #800) - only joins that cross-test reuse when + * {@link DefaultPrepAndExpectedTestCase#setCloseConnectionAfterTest(boolean)} is set to false; + * left at its default, it must not close a connection a shared provider is still using for + * other test methods (issue #801 code review feedback). + * + * @since 3.4.0 + */ +class DatabaseTesterConnectionReuseIT +{ + private DatabaseProfile profile; + + @BeforeEach + void setUp() throws Exception + { + profile = DatabaseEnvironment.getInstance().getProfile(); + } + + @Test + void testOnSetupAndOnTearDown_acrossFreshTesterInstancesSharingAProviderAndNoOpListener_reuseOneConnection() + throws Exception + { + final CachingConnectionProvider sharedProvider = new CachingConnectionProvider(); + final List connectionsUsed = new ArrayList<>(); + try + { + for (int simulatedTestMethod = 0; simulatedTestMethod < 3; simulatedTestMethod++) + { + final IDatabaseTester tester = newSharedProviderTester(sharedProvider); + tester.setOperationListener(IOperationListener.NO_OP_OPERATION_LISTENER); + tester.setSetUpOperation(capturingOperation(connectionsUsed)); + tester.setTearDownOperation(capturingOperation(connectionsUsed)); + + tester.onSetup(); + tester.onTearDown(); + } + + assertThat(connectionsUsed) + .as("Precondition: setUp+tearDown across 3 simulated test methods must have " + + "executed an operation, and therefore captured a connection, 6 times.") + .hasSize(6); + assertThat(new HashSet<>(connectionsUsed)) + .as("Every onSetup()/onTearDown() call across 3 simulated test methods - each " + + "building its own fresh IDatabaseTester, exactly as DatabaseTestCase " + + "does per test - must share the one connection cached by the " + + "CachingConnectionProvider they all point at, since a " + + "NO_OP_OPERATION_LISTENER keeps it from ever being closed between " + + "calls.") + .hasSize(1); + } finally + { + sharedProvider.close(); + } + } + + @Test + void testOnSetupAndOnTearDown_acrossFreshTesterInstancesSharingAProviderWithDefaultListener_staysOptInAndCreatesFreshConnectionsEveryTime() + throws Exception + { + final CachingConnectionProvider sharedProvider = new CachingConnectionProvider(); + final List connectionsUsed = new ArrayList<>(); + try + { + for (int simulatedTestMethod = 0; simulatedTestMethod < 2; simulatedTestMethod++) + { + final IDatabaseTester tester = newSharedProviderTester(sharedProvider); + // Deliberately not overriding the default DefaultOperationListener, which closes + // the connection after every onSetup()/onTearDown() call. + tester.setSetUpOperation(capturingOperation(connectionsUsed)); + tester.setTearDownOperation(capturingOperation(connectionsUsed)); + + tester.onSetup(); + tester.onTearDown(); + } + + assertThat(connectionsUsed) + .as("Precondition: setUp+tearDown across 2 simulated test methods must have " + + "captured a connection 4 times.") + .hasSize(4); + assertThat(new HashSet<>(connectionsUsed)) + .as("Opting into a CachingConnectionProvider without also switching away from " + + "the library's default closing IOperationListener must stay fully " + + "backward compatible: every call still observes a freshly " + + "(re)created connection, exactly like not using a provider at all.") + .hasSize(4); + } finally + { + sharedProvider.close(); + } + } + + @Test + void testDefaultPrepAndExpectedTestCase_acrossFreshInstancesSharingAProviderWithCloseDisabled_reusesOneConnection() + throws Exception + { + final CachingConnectionProvider sharedProvider = new CachingConnectionProvider(); + final List connectionsUsed = new ArrayList<>(); + try + { + for (int simulatedTestMethod = 0; simulatedTestMethod < 3; simulatedTestMethod++) + { + final IDatabaseTester tester = newSharedProviderTester(sharedProvider); + final DefaultPrepAndExpectedTestCase tc = newTestCase(tester); + tc.setCloseConnectionAfterTest(false); + + tc.configureTest(new VerifyTableDefinition[] {}, new String[] {}, + new String[] {}); + tc.preTest(); + tc.postTest(); + + connectionsUsed.add(tester.getConnection()); + } + + assertThat(new HashSet<>(connectionsUsed)) + .as("With closing disabled, DefaultPrepAndExpectedTestCase must not close the " + + "connection a shared CachingConnectionProvider still has cached, so " + + "3 simulated test methods - each building its own fresh tester and " + + "test case pointed at the same provider - must all observe the same " + + "underlying connection (issue #800/#801).") + .hasSize(1); + } finally + { + sharedProvider.close(); + } + } + + @Test + void testDefaultPrepAndExpectedTestCase_acrossFreshInstancesSharingAProviderWithDefaultConfiguration_createsFreshConnectionsEveryTime() + throws Exception + { + final CachingConnectionProvider sharedProvider = new CachingConnectionProvider(); + final List connectionsUsed = new ArrayList<>(); + try + { + for (int simulatedTestMethod = 0; simulatedTestMethod < 2; simulatedTestMethod++) + { + final IDatabaseTester tester = newSharedProviderTester(sharedProvider); + final DefaultPrepAndExpectedTestCase tc = newTestCase(tester); + // Deliberately not calling setCloseConnectionAfterTest(false). + + tc.configureTest(new VerifyTableDefinition[] {}, new String[] {}, + new String[] {}); + tc.preTest(); + tc.postTest(); + + connectionsUsed.add(tester.getConnection()); + } + + assertThat(new HashSet<>(connectionsUsed)) + .as("Left at its default, DefaultPrepAndExpectedTestCase must stay fully " + + "backward compatible: cleanupData() closes the connection it used, " + + "so the CachingConnectionProvider must hand back a freshly " + + "(re)created one to the next simulated test method, exactly like not " + + "sharing a provider at all.") + .hasSize(2); + } finally + { + sharedProvider.close(); + } + } + + private DefaultPrepAndExpectedTestCase newTestCase(final IDatabaseTester tester) + { + final DataFileLoader dataFileLoader = new FlatXmlDataFileLoader(); + return new DefaultPrepAndExpectedTestCase(dataFileLoader, tester); + } + + private IDatabaseTester newSharedProviderTester(final CachingConnectionProvider sharedProvider) + throws Exception + { + return new JdbcDatabaseTester(profile.getDriverClass(), profile.getConnectionUrl(), + profile.getUser(), profile.getPassword(), profile.getSchema(), sharedProvider); + } + + /** + * Records the connection passed to it by {@code AbstractDatabaseTester.executeOperation()}, + * which only invokes {@link IDatabaseTester#getConnection()} - and therefore only calls this - + * when the configured operation is not {@link DatabaseOperation#NONE}. + */ + private static DatabaseOperation capturingOperation(final List capturedInto) + { + return new DatabaseOperation() + { + @Override + public void execute(final IDatabaseConnection connection, final IDataSet dataSet) + { + capturedInto.add(connection); + } + }; + } +} diff --git a/src/test/java/org/dbunit/DefaultDatabaseTesterTest.java b/src/test/java/org/dbunit/DefaultDatabaseTesterTest.java new file mode 100644 index 000000000..f0e6e8b95 --- /dev/null +++ b/src/test/java/org/dbunit/DefaultDatabaseTesterTest.java @@ -0,0 +1,129 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2026, DbUnit.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + */ +package org.dbunit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.List; + +import org.dbunit.database.CachingConnectionProvider; +import org.dbunit.database.IDatabaseConnection; +import org.dbunit.database.InMemoryDatabaseConnection; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link DefaultDatabaseTester}, run against real H2 in-memory connections (via + * {@link InMemoryDatabaseConnection}). + * + * @since 3.4.0 + */ +class DefaultDatabaseTesterTest +{ + private final List realConnections = new ArrayList<>(); + + @AfterEach + void closeRealConnections() + { + for (final IDatabaseConnection connection : realConnections) + { + try + { + connection.close(); + } catch (final Exception e) + { + // Already closed by the test itself - nothing left to clean up. + } + } + } + + @Test + void testGetConnection_withRawConnectionConstructor_alwaysReturnsSameInstance() + throws Exception + { + final IDatabaseConnection connection = newRealConnection(); + final DefaultDatabaseTester tester = new DefaultDatabaseTester(connection); + + assertThat(tester.getConnection()) + .as("DefaultDatabaseTester's original constructor is a dumb holder: it must " + + "always return the exact connection instance it was given, with no " + + "liveness check.") + .isSameAs(connection); + assertThat(tester.getConnection()).isSameAs(connection); + } + + @Test + void testGetConnection_withProviderConstructor_reusesConnectionWhileAlive() throws Exception + { + final CachingConnectionProvider provider = new CachingConnectionProvider(); + final DefaultDatabaseTester tester = + new DefaultDatabaseTester(provider, this::newRealConnection); + + final IDatabaseConnection first = tester.getConnection(); + final IDatabaseConnection second = tester.getConnection(); + + assertThat(second) + .as("With a CachingConnectionProvider, calls must reuse the cached connection.") + .isSameAs(first); + } + + @Test + void testGetConnection_withProviderConstructor_afterConnectionDies_returnsNewConnection() + throws Exception + { + final CachingConnectionProvider provider = new CachingConnectionProvider(); + final DefaultDatabaseTester tester = + new DefaultDatabaseTester(provider, this::newRealConnection); + + final IDatabaseConnection first = tester.getConnection(); + first.getConnection().close(); + + final IDatabaseConnection second = tester.getConnection(); + + assertThat(second).as("A dead cached connection must be transparently replaced.") + .isNotSameAs(first); + assertThat(second.getConnection().isClosed()) + .as("The replacement connection must be open.").isFalse(); + } + + @Test + void testConstructor_withNullConnectionProvider_throwsNullPointerException() + { + assertThatThrownBy(() -> new DefaultDatabaseTester(null, this::newRealConnection)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void testConstructor_withNullConnectionFactory_throwsNullPointerException() + { + assertThatThrownBy(() -> new DefaultDatabaseTester(new CachingConnectionProvider(), null)) + .isInstanceOf(NullPointerException.class); + } + + private IDatabaseConnection newRealConnection() throws Exception + { + final IDatabaseConnection connection = InMemoryDatabaseConnection.create(); + realConnections.add(connection); + return connection; + } +} diff --git a/src/test/java/org/dbunit/DefaultPrepAndExpectedTestCaseDiIT.java b/src/test/java/org/dbunit/DefaultPrepAndExpectedTestCaseDiIT.java index cbf86dd62..f7ed1d998 100644 --- a/src/test/java/org/dbunit/DefaultPrepAndExpectedTestCaseDiIT.java +++ b/src/test/java/org/dbunit/DefaultPrepAndExpectedTestCaseDiIT.java @@ -61,9 +61,10 @@ void testSuccessRun_withMatchingPrepAndExpectedFiles_doesNotThrowException() thr tc.configureTest(tables, prepDataFiles, expectedDataFiles); - // reopen connection as DefaultPrepAndExpectedTestCase#configureTest - // closes after it obtains feature setting - // maybe we need a KeepConnectionOpenOperationListener class?! + // reopen connection as configureTest() closes its own after + // obtaining the case-sensitivity feature setting; preTest() and + // postTest() then share and close this one connection themselves + // instead of each needing a fresh one (#800) final IDatabaseTester databaseTesterNew1 = makeDatabaseTester(); tc.setDatabaseTester(databaseTesterNew1); @@ -72,11 +73,6 @@ void testSuccessRun_withMatchingPrepAndExpectedFiles_doesNotThrowException() thr // skip modifying data and just verify the insert - // reopen connection as DefaultOperationListener closes it after inserts - // maybe we need a KeepConnectionOpenOperationListener class?! - final IDatabaseTester databaseTesterNew2 = makeDatabaseTester(); - tc.setDatabaseTester(databaseTesterNew2); - assertDoesNotThrow(() -> tc.postTest(), "Did not expcte tc.postTest() to throw, but it did!"); } @@ -96,9 +92,10 @@ void testFailRun_withMismatchedExpectedFile_throwsDbComparisonFailure() throws E tc.configureTest(tables, prepDataFiles, expectedDataFiles); - // reopen connection as DefaultPrepAndExpectedTestCase#configureTest - // closes after it obtains feature setting - // maybe we need a KeepConnectionOpenOperationListener class?! + // reopen connection as configureTest() closes its own after + // obtaining the case-sensitivity feature setting; preTest() and + // postTest() then share and close this one connection themselves + // instead of each needing a fresh one (#800) final IDatabaseTester databaseTesterNew1 = makeDatabaseTester(); tc.setDatabaseTester(databaseTesterNew1); assertDoesNotThrow(() -> tc.preTest(), @@ -106,11 +103,6 @@ void testFailRun_withMismatchedExpectedFile_throwsDbComparisonFailure() throws E // skip modifying data and just verify the insert - // reopen connection as DefaultOperationListener closes it after inserts - // maybe we need a KeepConnectionOpenOperationListener class?! - final IDatabaseTester databaseTesterNew2 = makeDatabaseTester(); - tc.setDatabaseTester(databaseTesterNew2); - assertThrows(DbComparisonFailure.class, () -> tc.postTest(), "Expected tc.postTest() to throw DbComparisonFailure, but it didn't"); } diff --git a/src/test/java/org/dbunit/DefaultPrepAndExpectedTestCaseExtIT.java b/src/test/java/org/dbunit/DefaultPrepAndExpectedTestCaseExtIT.java index 4e6be156d..6e197a41d 100644 --- a/src/test/java/org/dbunit/DefaultPrepAndExpectedTestCaseExtIT.java +++ b/src/test/java/org/dbunit/DefaultPrepAndExpectedTestCaseExtIT.java @@ -70,9 +70,10 @@ void testSuccessRun_withMatchingPrepAndExpectedFiles_doesNotThrowException() thr configureTest(tables, prepDataFiles, expectedDataFiles); - // reopen connection as DefaultPrepAndExpectedTestCase#configureTest - // closes after it obtains feature setting - // maybe we need a KeepConnectionOpenOperationListener class?! + // reopen connection as configureTest() closes its own after + // obtaining the case-sensitivity feature setting; preTest() and + // postTest() then share and close this one connection themselves + // instead of each needing a fresh one (#800) final IDatabaseTester databaseTesterNew1 = makeDatabaseTester(); setDatabaseTester(databaseTesterNew1); assertDoesNotThrow(() -> preTest(), @@ -80,11 +81,6 @@ void testSuccessRun_withMatchingPrepAndExpectedFiles_doesNotThrowException() thr // skip modifying data and just verify the insert - // reopen connection as DefaultOperationListener closes it after inserts - // maybe we need a KeepConnectionOpenOperationListener class?! - final IDatabaseTester databaseTesterNew2 = makeDatabaseTester(); - setDatabaseTester(databaseTesterNew2); - postTest(); } @@ -101,9 +97,10 @@ void testFailRun_withMismatchedExpectedFile_throwsDbComparisonFailure() throws E configureTest(tables, prepDataFiles, expectedDataFiles); - // reopen connection as DefaultPrepAndExpectedTestCase#configureTest - // closes after it obtains feature setting - // maybe we need a KeepConnectionOpenOperationListener class?! + // reopen connection as configureTest() closes its own after + // obtaining the case-sensitivity feature setting; preTest() and + // postTest() then share and close this one connection themselves + // instead of each needing a fresh one (#800) final IDatabaseTester databaseTesterNew1 = makeDatabaseTester(); setDatabaseTester(databaseTesterNew1); @@ -111,11 +108,6 @@ void testFailRun_withMismatchedExpectedFile_throwsDbComparisonFailure() throws E // skip modifying data and just verify the insert - // reopen connection as DefaultOperationListener closes it after inserts - // maybe we need a KeepConnectionOpenOperationListener class?! - final IDatabaseTester databaseTesterNew2 = makeDatabaseTester(); - setDatabaseTester(databaseTesterNew2); - assertThrows(DbComparisonFailure.class, () -> postTest(), "Expected tc.postTest() to throw DbComparisonFailure, but it didn't"); diff --git a/src/test/java/org/dbunit/DefaultPrepAndExpectedTestCaseTest.java b/src/test/java/org/dbunit/DefaultPrepAndExpectedTestCaseTest.java index 5646778ea..5a45822f8 100644 --- a/src/test/java/org/dbunit/DefaultPrepAndExpectedTestCaseTest.java +++ b/src/test/java/org/dbunit/DefaultPrepAndExpectedTestCaseTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.catchThrowable; import java.sql.Connection; @@ -13,6 +14,7 @@ import org.dbunit.database.statement.MockStatementFactory; import org.dbunit.dataset.Column; import org.dbunit.dataset.DataSetException; +import org.dbunit.dataset.DefaultDataSet; import org.dbunit.dataset.DefaultTable; import org.dbunit.dataset.IDataSet; import org.dbunit.dataset.ITable; @@ -24,6 +26,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) @@ -70,6 +73,22 @@ void testConfigureTest_withTablesAndDataFiles_setsConfiguredState() throws Excep Assertion.assertEquals(expExpDs, tc.getExpectedDataset()); } + @Test + void testConfigureTest_calledAlone_leavesConnectionOpen() throws Exception + { + tc.configureTest(new VerifyTableDefinition[] {}, new String[] {}, + new String[] {}); + + final MockDatabaseConnection connection = + (MockDatabaseConnection) databaseTester.getConnection(); + // configureTest() leaves the connection open for setupData()/ + // verifyData()/cleanupData() to reuse, aligning it with those other + // lifecycle methods; called standalone here, cleanupData() never + // runs (#800, #825) + connection.setExpectedCloseCalls(0); + connection.verify(); + } + @Test void testPreTest_withTablesAndDataFiles_configuresDatasetAndExecutesSetUpOperation() throws Exception @@ -86,9 +105,11 @@ void testPreTest_withTablesAndDataFiles_configuresDatasetAndExecutesSetUpOperati final MockDatabaseConnection connection = (MockDatabaseConnection) databaseTester.getConnection(); - // 1 close from configureTest's case-sensitivity feature lookup, - // 1 close from the CLEAN_INSERT set up operation - connection.setExpectedCloseCalls(2); + // configureTest()'s case-sensitivity feature lookup and setupData()'s + // CLEAN_INSERT now share the same connection instead of configureTest + // closing its own and setupData() reacquiring a second one; it stays + // open since cleanupData() has not run yet to close it (#800, #825) + connection.setExpectedCloseCalls(0); connection.verify(); } @@ -109,6 +130,41 @@ void testRunTest_withTestSteps_executesStepsAndReturnsTrueResult() throws Except .isTrue(); } + @Test + void testRunTest_whenTestStepsAndCleanupBothFail_throwsTestFailureWithCleanupSuppressed() + throws Exception + { + final RuntimeException cleanupFailure = + new RuntimeException("cleanup boom"); + final DefaultPrepAndExpectedTestCase throwingTc = + new DefaultPrepAndExpectedTestCase(dataFileLoader, + databaseTester) + { + @Override + public void cleanupData() throws Exception + { + throw cleanupFailure; + } + }; + + final RuntimeException testFailure = new RuntimeException("test boom"); + final PrepAndExpectedTestCaseSteps steps = () -> { + throw testFailure; + }; + + final Throwable thrown = catchThrowable(() -> throwingTc.runTest( + new VerifyTableDefinition[] {}, new String[] {}, new String[] {}, + steps)); + + assertThat(thrown) + .as("runTest() must rethrow the original test failure, not the" + + " cleanup failure.") + .isSameAs(testFailure); + assertThat(thrown.getSuppressed()) + .as("The cleanup failure must be attached as suppressed, not lost.") + .containsExactly(cleanupFailure); + } + @Test void testPostTest_withVerifyDataDefaultTrue_verifiesDataAndClosesConnectionOnce() throws Exception @@ -117,9 +173,12 @@ void testPostTest_withVerifyDataDefaultTrue_verifiesDataAndClosesConnectionOnce( final MockDatabaseConnection connection = (MockDatabaseConnection) databaseTester.getConnection(); - // 1 close from verifyData's own connection use, - // 1 close from cleanupData's case-sensitivity feature lookup - connection.setExpectedCloseCalls(2); + // configureTest() was not called, so verifyData() is the first to + // acquire the shared connection; cleanupData()'s fallback feature + // lookup then finds it already acquired and reuses it rather than + // opening a separate one, so cleanupData()'s own close is the only + // close for the whole lifecycle (#801) + connection.setExpectedCloseCalls(1); connection.verify(); } @@ -131,12 +190,131 @@ void testPostTest_withVerifyDataFalse_skipsVerifyAndOnlyRunsCleanup() final MockDatabaseConnection connection = (MockDatabaseConnection) databaseTester.getConnection(); - // verifyData is skipped; the single close is cleanupData's - // case-sensitivity feature lookup (tearDownOperation defaults to NONE) + // verifyData is skipped, so cleanupData()'s own fallback + // case-sensitivity feature lookup (configureTest() was not called) + // is the first to acquire a connection; its tearDownOperation + // defaults to NONE so no further connection use happens, and + // cleanupData()'s final close is the single close for the lifecycle connection.setExpectedCloseCalls(1); connection.verify(); } + @Test + void testPostTest_whenVerifyFailsAndCleanupFails_throwsCleanupFailureWithVerifySuppressed() + throws Exception + { + final Error verifyFailure = new AssertionError("verify boom"); + final RuntimeException cleanupFailure = + new RuntimeException("cleanup boom"); + final DefaultPrepAndExpectedTestCase throwingTc = + new DefaultPrepAndExpectedTestCase(dataFileLoader, + databaseTester) + { + @Override + public void verifyData() throws Exception + { + throw verifyFailure; + } + + @Override + public void cleanupData() throws Exception + { + throw cleanupFailure; + } + }; + + final Throwable thrown = + catchThrowable(() -> throwingTc.postTest(true)); + + assertThat(thrown) + .as("postTest() must rethrow the cleanup failure, which" + + " deliberately shadows the verify failure to" + + " signal an unknown database state.") + .isSameAs(cleanupFailure); + assertThat(thrown.getSuppressed()) + .as("The shadowed verify failure must be attached as" + + " suppressed instead of lost.") + .containsExactly(verifyFailure); + } + + @Test + void testPostTest_whenVerifyFailsAndCleanupThrowsError_throwsCleanupErrorWithVerifySuppressed() + throws Exception + { + final RuntimeException verifyFailure = + new RuntimeException("verify boom"); + final Error cleanupFailure = new AssertionError("cleanup boom"); + final DefaultPrepAndExpectedTestCase throwingTc = + new DefaultPrepAndExpectedTestCase(dataFileLoader, + databaseTester) + { + @Override + public void verifyData() throws Exception + { + throw verifyFailure; + } + + @Override + public void cleanupData() throws Exception + { + throw cleanupFailure; + } + }; + + final Throwable thrown = + catchThrowable(() -> throwingTc.postTest(true)); + + assertThat(thrown) + .as("postTest() must rethrow the cleanup Error, which" + + " deliberately shadows the verify failure to" + + " signal an unknown database state, the same as" + + " when cleanup throws a checked Exception.") + .isSameAs(cleanupFailure); + assertThat(thrown.getSuppressed()) + .as("The shadowed verify failure must be attached as" + + " suppressed instead of lost, even when the" + + " cleanup failure is an Error rather than an" + + " Exception.") + .containsExactly(verifyFailure); + } + + @Test + void testPostTest_whenVerifyFailsAndCleanupSucceeds_throwsVerifyFailureUnchanged() + throws Exception + { + final Error verifyFailure = new AssertionError("verify boom"); + final DefaultPrepAndExpectedTestCase throwingTc = + new DefaultPrepAndExpectedTestCase(dataFileLoader, + databaseTester) + { + @Override + public void verifyData() throws Exception + { + throw verifyFailure; + } + }; + + final Throwable thrown = + catchThrowable(() -> throwingTc.postTest(true)); + + assertThat(thrown) + .as("postTest() must rethrow the verify failure unchanged" + + " when cleanup succeeds.") + .isSameAs(verifyFailure); + assertThat(thrown.getSuppressed()) + .as("No exception is suppressed when cleanup succeeds.") + .isEmpty(); + } + + @Test + void testPostTest_whenVerifyAndCleanupBothSucceed_doesNotThrow() + { + assertThatCode(() -> tc.postTest(true)) + .as("postTest() must not throw when both verifyData() and" + + " cleanupData() succeed.") + .doesNotThrowAnyException(); + } + @Test void testSetupData_withDefaultConfiguration_executesSetUpOperation() throws Exception @@ -145,7 +323,44 @@ void testSetupData_withDefaultConfiguration_executesSetUpOperation() final MockDatabaseConnection connection = (MockDatabaseConnection) databaseTester.getConnection(); - connection.setExpectedCloseCalls(1); + // setupData() leaves the connection open for verifyData()/ + // cleanupData() to reuse; only cleanupData() closes it (#800) + connection.setExpectedCloseCalls(0); + connection.verify(); + } + + @Test + void testSetupData_withUserDefinedOperationListener_invokesConnectionRetrievedButNotSetUpFinished() + throws Exception + { + final IOperationListener mockOperationListener = + Mockito.mock(IOperationListener.class); + final DefaultPrepAndExpectedTestCase listenerTc = + new DefaultPrepAndExpectedTestCase(dataFileLoader, + databaseTester) + { + @Override + protected IOperationListener getOperationListener() + { + return mockOperationListener; + } + }; + + listenerTc.setupData(); + + Mockito.verify(mockOperationListener) + .connectionRetrieved(Mockito.any(IDatabaseConnection.class)); + Mockito.verify(mockOperationListener, Mockito.never()) + .operationSetUpFinished(Mockito.any(IDatabaseConnection.class)); + + final MockDatabaseConnection connection = + (MockDatabaseConnection) databaseTester.getConnection(); + // A user-defined listener's connectionRetrieved() must still run + // (e.g. for DatabaseConfig setup), but operationSetUpFinished() must + // stay suppressed regardless of what the configured listener would + // otherwise do with it (the default listener closes on it), since + // this instance owns the shared connection's lifecycle (#801) + connection.setExpectedCloseCalls(0); connection.verify(); } @@ -157,7 +372,9 @@ void testVerifyData_withNoVerifyTableDefinitions_completesWithoutThrowing() final MockDatabaseConnection connection = (MockDatabaseConnection) databaseTester.getConnection(); - connection.setExpectedCloseCalls(1); + // verifyData() leaves the connection open for cleanupData() to + // close; called standalone here, cleanupData() never runs (#800) + connection.setExpectedCloseCalls(0); connection.verify(); } @@ -184,6 +401,35 @@ void testVerifyData_withTwoTablesAndColumnFilters_passesWhenEqual() .doesNotThrowAnyException(); } + @Test + @TurkishDefaultLocale + void testVerifyData_withTurkishDefaultLocale_matchesAsciiIColumns() + throws Exception + { + final Column[] actualColumns = {new Column("ID", DataType.VARCHAR)}; + final DefaultTable actualTable = + new DefaultTable("TEST_TABLE", actualColumns); + actualTable.addRow(new Object[] {"1"}); + + // expected column is DataType.UNKNOWN, as expected files normally + // are, so verifyData() must merge in the actual column via + // case-insensitive name matching; a Turkish default locale's + // dotless-i breaks that match ("ID".toLowerCase() becomes "ıd") + // unless the match pins Locale.ENGLISH + final Column[] expectedColumns = + {new Column("id", DataType.UNKNOWN)}; + final DefaultTable expectedTable = + new DefaultTable("TEST_TABLE", expectedColumns); + expectedTable.addRow(new Object[] {"1"}); + + assertThatCode(() -> tc.verifyData(expectedTable, actualTable, + null, null, null, null)) + .as("Column matching must use Locale.ENGLISH so a" + + " Turkish default locale does not break" + + " case-insensitive column matching.") + .doesNotThrowAnyException(); + } + @Test void testCleanupData_withDeleteAllTearDownOperation_executesTearDownOperation() throws Exception @@ -194,12 +440,177 @@ void testCleanupData_withDeleteAllTearDownOperation_executesTearDownOperation() final MockDatabaseConnection connection = (MockDatabaseConnection) databaseTester.getConnection(); - // 1 close from cleanupData's case-sensitivity feature lookup, - // 1 close from the DELETE_ALL tear down operation - connection.setExpectedCloseCalls(2); + // configureTest() was not called, so cleanupData() falls back to its + // own case-sensitivity feature lookup; that connection is left open + // for the DELETE_ALL tear down operation to reuse instead of + // reacquiring a second one, so cleanupData()'s final close is the + // only close for the whole call (#800, #825) + connection.setExpectedCloseCalls(1); connection.verify(); } + @Test + void testCleanupData_withUserDefinedOperationListener_invokesConnectionRetrievedButNotTearDownFinished() + throws Exception + { + // executeOperation() only calls the listener when the tear down + // operation is not NONE (the untouched default) + databaseTester.setTearDownOperation(DatabaseOperation.DELETE_ALL); + final IOperationListener mockOperationListener = + Mockito.mock(IOperationListener.class); + final DefaultPrepAndExpectedTestCase listenerTc = + new DefaultPrepAndExpectedTestCase(dataFileLoader, + databaseTester) + { + @Override + protected IOperationListener getOperationListener() + { + return mockOperationListener; + } + }; + + listenerTc.cleanupData(); + + Mockito.verify(mockOperationListener) + .connectionRetrieved(Mockito.any(IDatabaseConnection.class)); + Mockito.verify(mockOperationListener, Mockito.never()) + .operationTearDownFinished( + Mockito.any(IDatabaseConnection.class)); + + final MockDatabaseConnection connection = + (MockDatabaseConnection) databaseTester.getConnection(); + // cleanupData() itself closes the shared connection exactly once + // (closeReusableConnection()); operationTearDownFinished() must stay + // suppressed so a configured listener cannot also close it (#801) + connection.setExpectedCloseCalls(1); + connection.verify(); + } + + @Test + void testVerifyData_withVerifyTableDefinitions_verifiesActualTableFromConnection() + throws Exception + { + final Column[] columns = {new Column("COL1", DataType.VARCHAR)}; + final DefaultTable table = new DefaultTable("TEST_TABLE", columns); + table.addRow(new Object[] {"a"}); + + final MockDatabaseConnection connection = + (MockDatabaseConnection) databaseTester.getConnection(); + connection.setupDataSet(table); + + tc.setExpectedDs(new DefaultDataSet(table)); + tc.setVerifyTableDefs(new VerifyTableDefinition[] { + new VerifyTableDefinition("TEST_TABLE", new String[] {})}); + + assertThatCode(() -> tc.verifyData()) + .as("verifyData() must verify the actual table read from" + + " the connection.") + .doesNotThrowAnyException(); + } + + @Test + void testRunTest_withNonDefaultTearDown_reusesOneConnectionAcrossLifecycle() + throws Exception + { + final IDatabaseTester spyDatabaseTester = Mockito.spy(databaseTester); + tc.setDatabaseTester(spyDatabaseTester); + spyDatabaseTester.setTearDownOperation(DatabaseOperation.DELETE_ALL); + + final Column[] columns = {new Column("COL1", DataType.VARCHAR)}; + final DefaultTable table = new DefaultTable("TEST_TABLE", columns); + table.addRow(new Object[] {"a"}); + final MockDatabaseConnection connection = + (MockDatabaseConnection) databaseTester.getConnection(); + connection.setupDataSet(table); + + final VerifyTableDefinition[] tables = { + new VerifyTableDefinition("TEST_TABLE", new String[] {})}; + tc.configureTest(tables, new String[] {}, new String[] {}); + // configureTest() built an empty expected dataset from the (empty) + // expectedDataFiles array above; replace it with one that matches + // the actual table so verifyData() passes + tc.setExpectedDs(new DefaultDataSet(table)); + + tc.preTest(); + tc.postTest(); + + // configureTest() acquires the one connection reused by setupData(), + // verifyData(), and by cleanupData()'s DELETE_ALL tear down + // operation, instead of configureTest closing its own and each + // later step reacquiring (#800, #825) + Mockito.verify(spyDatabaseTester, Mockito.times(1)).getConnection(); + // cleanupData()'s final close is the only close across the whole + // configureTest/setupData/verifyData/cleanupData lifecycle + connection.setExpectedCloseCalls(1); + connection.verify(); + } + + @Test + void testIsCloseConnectionAfterTest_withDefaultConfiguration_returnsTrue() + { + assertThat(tc.isCloseConnectionAfterTest()) + .as("Default must close the connection, matching pre-existing" + + " behavior for callers who have not opted into a" + + " shared CachingConnectionProvider.") + .isTrue(); + } + + @Test + void testConfigureTest_withCloseConnectionAfterTestFalse_leavesConnectionOpen() + throws Exception + { + tc.setCloseConnectionAfterTest(false); + + tc.configureTest(new VerifyTableDefinition[] {}, new String[] {}, + new String[] {}); + + final MockDatabaseConnection connection = + (MockDatabaseConnection) databaseTester.getConnection(); + // with closing disabled, configureTest()'s case-sensitivity feature + // lookup must leave a shared CachingConnectionProvider's connection + // open for other tests to keep reusing (#801) + connection.setExpectedCloseCalls(0); + connection.verify(); + } + + @Test + void testCleanupData_withCloseConnectionAfterTestFalse_leavesConnectionOpen() + throws Exception + { + databaseTester.setTearDownOperation(DatabaseOperation.DELETE_ALL); + tc.setCloseConnectionAfterTest(false); + + tc.cleanupData(); + + final MockDatabaseConnection connection = + (MockDatabaseConnection) databaseTester.getConnection(); + // with closing disabled, neither cleanupData's fallback feature + // lookup nor its own closeReusableConnection() may close the + // connection a shared CachingConnectionProvider is still using (#801) + connection.setExpectedCloseCalls(0); + connection.verify(); + } + + @Test + void testConfigureTestThenSetupData_withCloseDisabledNoProvider_doesNotReacquireConnection() + throws Exception + { + final IDatabaseTester spyDatabaseTester = Mockito.spy(databaseTester); + tc.setDatabaseTester(spyDatabaseTester); + tc.setCloseConnectionAfterTest(false); + + tc.configureTest(new VerifyTableDefinition[] {}, new String[] {}, + new String[] {}); + tc.setupData(); + + // closeReusableConnection() must keep the connection field set (not + // null it out) when it skips closing, or setupData() would silently + // orphan the connection configureTest() acquired and open a second + // one - a real leak for anyone who sets this flag without also + // pairing a CachingConnectionProvider (#801) + Mockito.verify(spyDatabaseTester, Mockito.times(1)).getConnection(); + } + @Test void testMakeCompositeDataSet_withDataFiles_returnsDataSetWithMatchingTableNames() throws Exception diff --git a/src/test/java/org/dbunit/JdbcDatabaseTesterTest.java b/src/test/java/org/dbunit/JdbcDatabaseTesterTest.java new file mode 100644 index 000000000..63b8aea8d --- /dev/null +++ b/src/test/java/org/dbunit/JdbcDatabaseTesterTest.java @@ -0,0 +1,135 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2026, DbUnit.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + */ +package org.dbunit; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.dbunit.database.CachingConnectionProvider; +import org.dbunit.database.IDatabaseConnection; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link JdbcDatabaseTester}, run against a real H2 in-memory database, focused on + * its opt-in {@link CachingConnectionProvider} integration. + * + * @since 3.4.0 + */ +class JdbcDatabaseTesterTest +{ + private static final String DRIVER_CLASS = "org.h2.Driver"; + + private static final AtomicInteger DB_COUNTER = new AtomicInteger(); + + private IDatabaseConnection openedConnection; + + @AfterEach + void closeOpenedConnection() throws Exception + { + if (openedConnection != null) + { + openedConnection.close(); + } + } + + @Test + void testGetConnection_withoutProvider_returnsDifferentInstanceOnEachCall() throws Exception + { + final JdbcDatabaseTester tester = + new JdbcDatabaseTester(DRIVER_CLASS, nextConnectionUrl()); + + final IDatabaseConnection first = tester.getConnection(); + final IDatabaseConnection second = tester.getConnection(); + + assertThat(second) + .as("Without a CachingConnectionProvider, every call must create a fresh " + + "connection, matching pre-existing behavior.") + .isNotSameAs(first); + first.close(); + second.close(); + } + + @Test + void testGetConnection_withProvider_returnsSameInstanceOnEachCall() throws Exception + { + final CachingConnectionProvider provider = new CachingConnectionProvider(); + final JdbcDatabaseTester tester = new JdbcDatabaseTester(DRIVER_CLASS, + nextConnectionUrl(), null, null, null, provider); + + final IDatabaseConnection first = tester.getConnection(); + final IDatabaseConnection second = tester.getConnection(); + + openedConnection = second; + assertThat(second) + .as("With a CachingConnectionProvider, calls must reuse the cached connection.") + .isSameAs(first); + } + + @Test + void testGetConnection_withProvider_afterUnderlyingConnectionDies_returnsNewWorkingConnection() + throws Exception + { + final CachingConnectionProvider provider = new CachingConnectionProvider(); + final JdbcDatabaseTester tester = new JdbcDatabaseTester(DRIVER_CLASS, + nextConnectionUrl(), null, null, null, provider); + + final IDatabaseConnection first = tester.getConnection(); + first.getConnection().close(); + + final IDatabaseConnection second = tester.getConnection(); + + openedConnection = second; + assertThat(second).as("A dead cached connection must be transparently replaced.") + .isNotSameAs(first); + assertThat(second.getConnection().createStatement().execute("SELECT 1")) + .as("The replacement connection must be a real, working JDBC connection.") + .isTrue(); + } + + @Test + void testGetConnection_withProviderSharedAcrossTesterInstances_reusesConnection() throws Exception + { + final CachingConnectionProvider sharedProvider = new CachingConnectionProvider(); + final String url = nextConnectionUrl(); + + final JdbcDatabaseTester firstTester = + new JdbcDatabaseTester(DRIVER_CLASS, url, null, null, null, sharedProvider); + final IDatabaseConnection fromFirstTester = firstTester.getConnection(); + + final JdbcDatabaseTester secondTester = + new JdbcDatabaseTester(DRIVER_CLASS, url, null, null, null, sharedProvider); + final IDatabaseConnection fromSecondTester = secondTester.getConnection(); + + openedConnection = fromSecondTester; + assertThat(fromSecondTester) + .as("A CachingConnectionProvider shared across separate tester instances - as " + + "happens when each test method builds a fresh tester - must still hand " + + "back the one cached connection.") + .isSameAs(fromFirstTester); + } + + private static String nextConnectionUrl() + { + return "jdbc:h2:mem:jdbctester_" + DB_COUNTER.incrementAndGet() + ";DB_CLOSE_DELAY=-1"; + } +} diff --git a/src/test/java/org/dbunit/JndiDatabaseTesterIT.java b/src/test/java/org/dbunit/JndiDatabaseTesterIT.java new file mode 100644 index 000000000..56de908c3 --- /dev/null +++ b/src/test/java/org/dbunit/JndiDatabaseTesterIT.java @@ -0,0 +1,154 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2026, DbUnit.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + */ +package org.dbunit; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Properties; +import java.util.concurrent.atomic.AtomicInteger; + +import javax.sql.DataSource; + +import org.dbunit.database.CachingConnectionProvider; +import org.dbunit.database.IDatabaseConnection; +import org.dbunit.database.InMemoryJndiContextFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * Integration tests for {@link JndiDatabaseTester}, run against a real + * {@link javax.naming.InitialContext} (backed by the in-memory + * {@link InMemoryJndiContextFactory}) resolving a real H2 in-memory + * {@link DataSource}, focused on its opt-in + * {@link CachingConnectionProvider} integration. + * + * @since 3.4.0 + */ +class JndiDatabaseTesterIT +{ + private static final AtomicInteger DB_COUNTER = new AtomicInteger(); + + private IDatabaseConnection openedConnection; + private String boundName; + + @AfterEach + void closeOpenedConnection() throws Exception + { + if (openedConnection != null) + { + openedConnection.close(); + } + if (boundName != null) + { + InMemoryJndiContextFactory.unbind(boundName); + } + } + + @Test + void testGetConnection_withoutProvider_returnsDifferentInstanceOnEachCall() throws Exception + { + final JndiDatabaseTester tester = tester(null); + + final IDatabaseConnection first = tester.getConnection(); + final IDatabaseConnection second = tester.getConnection(); + + assertThat(second) + .as("Without a CachingConnectionProvider, every call must create a fresh " + + "connection, matching pre-existing behavior.") + .isNotSameAs(first); + first.close(); + second.close(); + } + + @Test + void testGetConnection_withProvider_returnsSameInstanceOnEachCall() throws Exception + { + final JndiDatabaseTester tester = tester(new CachingConnectionProvider()); + + final IDatabaseConnection first = tester.getConnection(); + final IDatabaseConnection second = tester.getConnection(); + + openedConnection = second; + assertThat(second) + .as("With a CachingConnectionProvider, calls must reuse the cached connection.") + .isSameAs(first); + } + + @Test + void testGetConnection_withProvider_afterUnderlyingConnectionDies_returnsNewWorkingConnection() + throws Exception + { + final JndiDatabaseTester tester = tester(new CachingConnectionProvider()); + + final IDatabaseConnection first = tester.getConnection(); + first.getConnection().close(); + + final IDatabaseConnection second = tester.getConnection(); + + openedConnection = second; + assertThat(second).as("A dead cached connection must be transparently replaced.") + .isNotSameAs(first); + assertThat(second.getConnection().createStatement().execute("SELECT 1")) + .as("The replacement connection must be a real, working JDBC connection.") + .isTrue(); + } + + @Test + void testGetConnection_withProvider_looksUpJndiOnlyOnceEvenAcrossConnectionReplacement() + throws Exception + { + final JdbcDataSource dataSource = newDataSource(); + final String lookupName = InMemoryJndiContextFactory.bind(dataSource); + boundName = lookupName; + final Properties environment = InMemoryJndiContextFactory.environment(); + final JndiDatabaseTester tester = + new JndiDatabaseTester(environment, lookupName, null, new CachingConnectionProvider()); + + tester.getConnection(); + tester.getConnection().getConnection().close(); + final IDatabaseConnection third = tester.getConnection(); + + openedConnection = third; + assertThat(InMemoryJndiContextFactory.lookupCount(lookupName)) + .as("JndiDatabaseTester's pre-existing initialize()/initialized-flag memoization " + + "must still resolve the DataSource via JNDI only once, even across " + + "multiple getConnection() calls and a CachingConnectionProvider " + + "replacing a dead connection in between.") + .isEqualTo(1); + } + + private JndiDatabaseTester tester(final CachingConnectionProvider provider) + { + final String lookupName = InMemoryJndiContextFactory.bind(newDataSource()); + boundName = lookupName; + final Properties environment = InMemoryJndiContextFactory.environment(); + return new JndiDatabaseTester(environment, lookupName, null, provider); + } + + private static JdbcDataSource newDataSource() + { + final JdbcDataSource dataSource = new JdbcDataSource(); + dataSource.setUrl( + "jdbc:h2:mem:jnditester_" + DB_COUNTER.incrementAndGet() + ";DB_CLOSE_DELAY=-1"); + return dataSource; + } +} diff --git a/src/test/java/org/dbunit/PropertiesBasedJdbcDatabaseTesterTest.java b/src/test/java/org/dbunit/PropertiesBasedJdbcDatabaseTesterTest.java new file mode 100644 index 000000000..8dfc4456f --- /dev/null +++ b/src/test/java/org/dbunit/PropertiesBasedJdbcDatabaseTesterTest.java @@ -0,0 +1,124 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2026, DbUnit.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + */ +package org.dbunit; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.dbunit.database.CachingConnectionProvider; +import org.dbunit.database.IDatabaseConnection; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link PropertiesBasedJdbcDatabaseTester}, run against a real H2 in-memory + * database, focused on its opt-in {@link CachingConnectionProvider} integration. + * + * @since 3.4.0 + */ +class PropertiesBasedJdbcDatabaseTesterTest +{ + private static final String[] PROPERTY_KEYS = { + PropertiesBasedJdbcDatabaseTester.DBUNIT_DRIVER_CLASS, + PropertiesBasedJdbcDatabaseTester.DBUNIT_CONNECTION_URL, + PropertiesBasedJdbcDatabaseTester.DBUNIT_USERNAME, + PropertiesBasedJdbcDatabaseTester.DBUNIT_PASSWORD, + PropertiesBasedJdbcDatabaseTester.DBUNIT_SCHEMA}; + + private static final AtomicInteger DB_COUNTER = new AtomicInteger(); + + private final Map savedProperties = new HashMap<>(); + + private IDatabaseConnection openedConnection; + + @BeforeEach + void saveAndSetProperties() + { + for (final String key : PROPERTY_KEYS) + { + savedProperties.put(key, System.getProperty(key)); + } + + System.setProperty(PropertiesBasedJdbcDatabaseTester.DBUNIT_DRIVER_CLASS, "org.h2.Driver"); + System.setProperty(PropertiesBasedJdbcDatabaseTester.DBUNIT_CONNECTION_URL, + "jdbc:h2:mem:propsjdbctester_" + DB_COUNTER.incrementAndGet() + ";DB_CLOSE_DELAY=-1"); + System.clearProperty(PropertiesBasedJdbcDatabaseTester.DBUNIT_USERNAME); + System.clearProperty(PropertiesBasedJdbcDatabaseTester.DBUNIT_PASSWORD); + System.clearProperty(PropertiesBasedJdbcDatabaseTester.DBUNIT_SCHEMA); + } + + @AfterEach + void closeConnectionAndRestoreProperties() throws Exception + { + if (openedConnection != null) + { + openedConnection.close(); + } + + for (final String key : PROPERTY_KEYS) + { + final String savedValue = savedProperties.get(key); + if (savedValue == null) + { + System.clearProperty(key); + } else + { + System.setProperty(key, savedValue); + } + } + } + + @Test + void testGetConnection_withoutProvider_returnsDifferentInstanceOnEachCall() throws Exception + { + final PropertiesBasedJdbcDatabaseTester tester = new PropertiesBasedJdbcDatabaseTester(); + + final IDatabaseConnection first = tester.getConnection(); + final IDatabaseConnection second = tester.getConnection(); + + assertThat(second) + .as("Without a CachingConnectionProvider, every call must create a fresh " + + "connection, matching pre-existing behavior.") + .isNotSameAs(first); + first.close(); + second.close(); + } + + @Test + void testGetConnection_withProvider_returnsSameInstanceOnEachCall() throws Exception + { + final CachingConnectionProvider provider = new CachingConnectionProvider(); + final PropertiesBasedJdbcDatabaseTester tester = + new PropertiesBasedJdbcDatabaseTester(provider); + + final IDatabaseConnection first = tester.getConnection(); + final IDatabaseConnection second = tester.getConnection(); + + openedConnection = second; + assertThat(second) + .as("With a CachingConnectionProvider, calls must reuse the cached connection.") + .isSameAs(first); + } +} diff --git a/src/test/java/org/dbunit/TurkishDefaultLocale.java b/src/test/java/org/dbunit/TurkishDefaultLocale.java new file mode 100644 index 000000000..38a751018 --- /dev/null +++ b/src/test/java/org/dbunit/TurkishDefaultLocale.java @@ -0,0 +1,43 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2026, DbUnit.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + */ + +package org.dbunit; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.Locale; + +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Marks a test method as needing the JVM default {@link Locale} temporarily switched to Turkish + * ({@code tr-TR}) for its duration, via {@link TurkishLocaleExtension}. + * + * @since 3.4.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +@ExtendWith(TurkishLocaleExtension.class) +public @interface TurkishDefaultLocale +{ +} diff --git a/src/test/java/org/dbunit/TurkishLocaleExtension.java b/src/test/java/org/dbunit/TurkishLocaleExtension.java new file mode 100644 index 000000000..9e89efe17 --- /dev/null +++ b/src/test/java/org/dbunit/TurkishLocaleExtension.java @@ -0,0 +1,67 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2026, DbUnit.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + */ + +package org.dbunit; + +import java.util.Locale; + +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; + +/** + * Temporarily switches the JVM default {@link Locale} to Turkish ({@code tr-TR}) for the duration + * of a {@link TurkishDefaultLocale @TurkishDefaultLocale}-annotated test method, restoring the + * original default afterward regardless of the test's outcome. + * + *

+ * Centralizes the save/mutate/restore steps needed to reproduce Turkish-locale-specific bugs - + * e.g. an un-pinned {@code toUpperCase()}/{@code toLowerCase()} folding {@code i}/{@code I} to the + * dotted/dotless variants {@code İ}/{@code ı} - instead of duplicating them in every affected test + * class. + * + * @since 3.4.0 + */ +public class TurkishLocaleExtension implements BeforeEachCallback, AfterEachCallback +{ + private static final String ORIGINAL_LOCALE_KEY = "originalLocale"; + + @Override + public void beforeEach(final ExtensionContext context) + { + getStore(context).put(ORIGINAL_LOCALE_KEY, Locale.getDefault()); + Locale.setDefault(new Locale("tr", "TR")); + } + + @Override + public void afterEach(final ExtensionContext context) + { + final Locale original = + getStore(context).get(ORIGINAL_LOCALE_KEY, Locale.class); + Locale.setDefault(original); + } + + private ExtensionContext.Store getStore(final ExtensionContext context) + { + return context.getStore(ExtensionContext.Namespace + .create(TurkishLocaleExtension.class, context.getRequiredTestMethod())); + } +} diff --git a/src/test/java/org/dbunit/ant/DbUnitTaskIT.java b/src/test/java/org/dbunit/ant/DbUnitTaskIT.java index c912f78eb..8860279e8 100644 --- a/src/test/java/org/dbunit/ant/DbUnitTaskIT.java +++ b/src/test/java/org/dbunit/ant/DbUnitTaskIT.java @@ -22,6 +22,7 @@ package org.dbunit.ant; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.catchThrowable; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -58,7 +59,6 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import org.slf4j.Logger; @@ -423,12 +423,12 @@ public void testExportWithQuerySet_withQuerySetTarget_returnsQuerySetsTablesAndQ assertThat(emptyTable.getName()).as("name").isEqualTo("EMPTY_TABLE"); } - @Disabled("Ant now ignores id errors and refid is always evaluated first") @Test - public void testWithBadQuerySet_withBothIdAndRefid_throwsBuildException() + public void testWithQuerySetIdAndRefid_withBothAttributesSet_resolvesRefidWithoutThrowing() { - expectBuildException("invalid-queryset", - "Cannot specify 'id' and 'refid' attributes together in queryset."); + assertThatCode(() -> rule.executeTarget("invalid-queryset")) + .as("Ant resolves the refid attribute first and no longer errors on the id/refid combination.") + .doesNotThrowAnyException(); } @Test diff --git a/src/test/java/org/dbunit/ant/ExportTest.java b/src/test/java/org/dbunit/ant/ExportTest.java new file mode 100644 index 000000000..0c099363e --- /dev/null +++ b/src/test/java/org/dbunit/ant/ExportTest.java @@ -0,0 +1,114 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2004, DbUnit.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + */ +package org.dbunit.ant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.lang.reflect.Field; + +import org.dbunit.database.DatabaseConfig; +import org.dbunit.database.IDatabaseConnection; +import org.dbunit.dataset.Column; +import org.dbunit.dataset.DefaultDataSet; +import org.dbunit.dataset.DefaultTable; +import org.dbunit.dataset.IDataSet; +import org.dbunit.dataset.datatype.DataType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Unit tests for {@link Export}. + */ +@ExtendWith(MockitoExtension.class) +class ExportTest +{ + @Mock + private IDatabaseConnection connection; + + @Test + void testExecute_withUppercaseCsvFormat_writesCsvOutput( + @TempDir final File tempDir) throws Exception + { + stubConnection(); + + final File dest = new File(tempDir, "csv-out"); + final Export export = new Export(); + export.setFormat("CSV"); + export.setDest(dest); + + export.execute(connection); + + assertThat(new File(dest, "TEST_TABLE.csv")) + .as("CSV export must create a per-table file inside the" + + " destination directory even when the format" + + " attribute is given in upper case.") + .exists(); + } + + @Test + void testExecute_withUnsupportedFormat_throwsWithoutCreatingDestinationFile( + @TempDir final File tempDir) throws Exception + { + final File dest = new File(tempDir, "unsupported-out"); + final Export export = new Export(); + export.setDest(dest); + setFormatField(export, "bogus"); + + assertThatExceptionOfType(IllegalArgumentException.class) + .as("An unrecognized format must be rejected before any" + + " destination file is created.") + .isThrownBy(() -> export.execute(connection)); + + assertThat(dest) + .as("No destination file must be left behind for an" + + " unsupported format.") + .doesNotExist(); + } + + private void stubConnection() throws Exception + { + when(connection.getConfig()).thenReturn(new DatabaseConfig()); + when(connection.createDataSet()).thenReturn(buildDataSet()); + } + + private static IDataSet buildDataSet() throws Exception + { + final Column[] columns = {new Column("COL0", DataType.UNKNOWN)}; + final DefaultTable table = new DefaultTable("TEST_TABLE", columns); + table.addRow(); + table.setValue(0, "COL0", "value"); + return new DefaultDataSet(table); + } + + private static void setFormatField(final Export export, final String format) + throws Exception + { + final Field field = Export.class.getDeclaredField("_format"); + field.setAccessible(true); + field.set(export, format); + } +} diff --git a/src/test/java/org/dbunit/assertion/DbUnitAssertBaseTest.java b/src/test/java/org/dbunit/assertion/DbUnitAssertBaseTest.java new file mode 100644 index 000000000..4d733333f --- /dev/null +++ b/src/test/java/org/dbunit/assertion/DbUnitAssertBaseTest.java @@ -0,0 +1,78 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2008, DbUnit.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + */ +package org.dbunit.assertion; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import org.dbunit.TurkishDefaultLocale; +import org.dbunit.dataset.IDataSet; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Unit tests for {@link DbUnitAssertBase}. + */ +@ExtendWith(MockitoExtension.class) +class DbUnitAssertBaseTest +{ + private final DbUnitAssertBase assertBase = new DbUnitAssertBase(); + + @Mock + private IDataSet dataSet; + + @Test + @TurkishDefaultLocale + void testGetSortedTableNames_turkishLocaleUppercaseI_sortsAsEnglish() throws Exception + { + when(dataSet.getTableNames()) + .thenReturn(new String[] {"island", "beta"}); + when(dataSet.isCaseSensitiveTableNames()).thenReturn(false); + + final String[] actual = assertBase.getSortedTableNames(dataSet); + + assertThat(actual) + .as("Table-name folding must use Locale.ENGLISH so a Turkish" + + " default locale does not turn a lower-case 'i' into" + + " a dotted capital I (U+0130) instead of ASCII 'I'.") + .containsExactly("BETA", "ISLAND"); + } + + @Test + void testGetSortedTableNames_calledTwice_doesNotMutateDataSetArray() throws Exception + { + final String[] backing = {"beta", "island"}; + when(dataSet.getTableNames()).thenReturn(backing); + when(dataSet.isCaseSensitiveTableNames()).thenReturn(false); + + assertBase.getSortedTableNames(dataSet); + assertBase.getSortedTableNames(dataSet); + + assertThat(backing) + .as("getSortedTableNames() must operate on a clone so repeated" + + " calls never mutate the array instance returned by" + + " IDataSet.getTableNames(), since the interface does" + + " not guarantee callers receive a fresh copy.") + .containsExactly("beta", "island"); + } +} diff --git a/src/test/java/org/dbunit/database/CachingConnectionProviderIT.java b/src/test/java/org/dbunit/database/CachingConnectionProviderIT.java new file mode 100644 index 000000000..ca10fa76d --- /dev/null +++ b/src/test/java/org/dbunit/database/CachingConnectionProviderIT.java @@ -0,0 +1,134 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2026, DbUnit.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + */ +package org.dbunit.database; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.Connection; +import java.sql.DriverManager; + +import org.dbunit.DatabaseEnvironment; +import org.dbunit.DatabaseProfile; +import org.dbunit.dataset.IDataSet; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Integration tests for {@link CachingConnectionProvider} against whichever real database is + * configured by the active Maven profile (see {@link DatabaseEnvironment}), proving the liveness + * detection and reconnection logic against a real JDBC driver, not just the H2 in-memory driver + * used by {@link CachingConnectionProviderTest}. + * + *

+ * Builds its own independent JDBC connections directly from the {@link DatabaseProfile} rather + * than going through {@link DatabaseEnvironment#getConnection()}'s shared singleton, so it cannot + * interfere with the connection lifecycle other IT classes depend on. + * + * @since 3.4.0 + */ +class CachingConnectionProviderIT +{ + private DatabaseProfile profile; + + private CachingConnectionProvider provider; + + @BeforeEach + void setUp() throws Exception + { + // assign provider first so tearDown()'s provider.close() is always + // safe to call, even if the profile lookup or driver loading below + // fails partway through setUp() + provider = new CachingConnectionProvider(); + profile = DatabaseEnvironment.getInstance().getProfile(); + Class.forName(profile.getDriverClass()); + } + + @AfterEach + void tearDown() throws Exception + { + provider.close(); + } + + @Test + void testGetConnection_calledTwice_returnsSameConnectionAndPreservesMetadataCache() + throws Exception + { + final IDatabaseConnection first = provider.getConnection(this::createConnection); + final IDataSet firstDataSet = first.createDataSet(); + + final IDatabaseConnection second = provider.getConnection(this::createConnection); + final IDataSet secondDataSet = second.createDataSet(); + + assertThat(second).as("A second call while the cached connection is alive must reuse it.") + .isSameAs(first); + assertThat(secondDataSet) + .as("Reusing the connection must also reuse its accumulated table-metadata " + + "cache (DatabaseDataSet), not re-fetch it - that avoided re-fetch is the " + + "entire point of this feature.") + .isSameAs(firstDataSet); + } + + @Test + void testGetConnection_afterUnderlyingConnectionDies_transparentlyReconnectsAndStaysUsable() + throws Exception + { + final IDatabaseConnection first = provider.getConnection(this::createConnection); + // Simulate a DB blip / idle-timeout kill from the client's point of view. + first.getConnection().close(); + + final IDatabaseConnection second = provider.getConnection(this::createConnection); + + assertThat(second).as("A dead cached connection must be replaced, not returned as-is.") + .isNotSameAs(first); + assertThat(second.getRowCount("TEST_TABLE")) + .as("The replacement connection must be a real, working connection against the " + + "configured database.") + .isGreaterThanOrEqualTo(0); + } + + @Test + void testClose_thenGetConnection_reconnectsSuccessfully() throws Exception + { + final IDatabaseConnection first = provider.getConnection(this::createConnection); + + provider.close(); + + assertThat(first.getConnection().isClosed()) + .as("close() must close the connection it hands back control of.").isTrue(); + + final IDatabaseConnection second = provider.getConnection(this::createConnection); + + assertThat(second) + .as("Closing the provider must create a new connection.") + .isNotSameAs(first); + assertThat(second.getRowCount("TEST_TABLE")) + .as("The connection created after close() must be usable.") + .isGreaterThanOrEqualTo(0); + } + + private IDatabaseConnection createConnection() throws Exception + { + final Connection jdbcConnection = DriverManager.getConnection(profile.getConnectionUrl(), + profile.getUser(), profile.getPassword()); + return new DatabaseConnection(jdbcConnection, profile.getSchema()); + } +} diff --git a/src/test/java/org/dbunit/database/CachingConnectionProviderTest.java b/src/test/java/org/dbunit/database/CachingConnectionProviderTest.java new file mode 100644 index 000000000..7b897996d --- /dev/null +++ b/src/test/java/org/dbunit/database/CachingConnectionProviderTest.java @@ -0,0 +1,470 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2026, DbUnit.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + */ + +package org.dbunit.database; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link CachingConnectionProvider}, run against a real H2 in-memory + * {@link IDatabaseConnection} (via {@link InMemoryDatabaseConnection}) for the caching and + * liveness-detection scenarios, and against Mockito-mocked collaborators for the scenarios that + * need to force a specific {@link Connection#isValid(int)} outcome. + * + * @since 3.4.0 + */ +class CachingConnectionProviderTest +{ + private final List realConnections = new ArrayList<>(); + + @AfterEach + void closeRealConnections() + { + for (final IDatabaseConnection connection : realConnections) + { + try + { + connection.close(); + } catch (final SQLException e) + { + // Already closed by the test itself - nothing left to clean up. + } + } + } + + @Test + void testGetConnection_firstCall_invokesFactoryOnce() throws Exception + { + final CachingConnectionProvider provider = new CachingConnectionProvider(); + @SuppressWarnings("unchecked") + final Callable factory = mock(Callable.class); + when(factory.call()).thenReturn(newRealConnection()); + + provider.getConnection(factory); + + verify(factory, times(1)).call(); + } + + @Test + void testGetConnection_firstCall_returnsFactoryResult() throws Exception + { + final CachingConnectionProvider provider = new CachingConnectionProvider(); + final IDatabaseConnection created = newRealConnection(); + + final IDatabaseConnection returned = provider.getConnection(() -> created); + + assertThat(returned).as("The first call must return the factory's connection.") + .isSameAs(created); + } + + @Test + void testGetConnection_subsequentCallsWhileAlive_returnsSameCachedInstance() throws Exception + { + final CachingConnectionProvider provider = new CachingConnectionProvider(); + final Callable factory = this::newRealConnection; + + final IDatabaseConnection first = provider.getConnection(factory); + final IDatabaseConnection second = provider.getConnection(factory); + + assertThat(second).as("A live cached connection must be reused, not recreated.") + .isSameAs(first); + } + + @Test + void testGetConnection_subsequentCallsWhileAlive_doesNotInvokeFactoryAgain() throws Exception + { + final CachingConnectionProvider provider = new CachingConnectionProvider(); + @SuppressWarnings("unchecked") + final Callable factory = mock(Callable.class); + when(factory.call()).thenReturn(newRealConnection()); + + provider.getConnection(factory); + provider.getConnection(factory); + provider.getConnection(factory); + + verify(factory, times(1)).call(); + } + + @Test + void testGetConnection_afterCachedConnectionClosed_returnsNewConnectionInstance() throws Exception + { + final CachingConnectionProvider provider = new CachingConnectionProvider(); + final Callable factory = this::newRealConnection; + + final IDatabaseConnection first = provider.getConnection(factory); + first.getConnection().close(); + + final IDatabaseConnection second = provider.getConnection(factory); + + assertThat(second).as("A closed cached connection must be replaced by a new one.") + .isNotSameAs(first); + assertThat(second.getConnection().isClosed()) + .as("The replacement connection must be open.").isFalse(); + } + + @Test + void testGetConnection_afterCachedConnectionClosed_closesTheStaleConnection() throws Exception + { + final CachingConnectionProvider provider = new CachingConnectionProvider(); + final IDatabaseConnection stale = spy(newRealConnection()); + final IDatabaseConnection replacement = newRealConnection(); + @SuppressWarnings("unchecked") + final Callable factory = mock(Callable.class); + when(factory.call()).thenReturn(stale, replacement); + + provider.getConnection(factory); + stale.getConnection().close(); + provider.getConnection(factory); + + verify(stale, times(1)).close(); + } + + @Test + void testGetConnection_whenIsValidReturnsFalse_replacesConnection() throws Exception + { + final CachingConnectionProvider provider = new CachingConnectionProvider(); + final IDatabaseConnection notValid = mockConnection(false, false); + final IDatabaseConnection replacement = newRealConnection(); + @SuppressWarnings("unchecked") + final Callable factory = mock(Callable.class); + when(factory.call()).thenReturn(notValid, replacement); + + provider.getConnection(factory); + final IDatabaseConnection second = provider.getConnection(factory); + + assertThat(second) + .as("A connection that reports itself as no longer valid must be replaced, " + + "even though it was never explicitly closed.") + .isSameAs(replacement); + } + + @Test + void testGetConnection_whenIsValidThrows_treatsConnectionAsDeadAndReplacesIt() throws Exception + { + final CachingConnectionProvider provider = new CachingConnectionProvider(); + final Connection jdbcConnection = mock(Connection.class); + when(jdbcConnection.isClosed()).thenReturn(false); + when(jdbcConnection.isValid(anyInt())) + .thenThrow(new SQLException("driver blew up validating the connection")); + final IDatabaseConnection broken = mock(IDatabaseConnection.class); + when(broken.getConnection()).thenReturn(jdbcConnection); + final IDatabaseConnection replacement = newRealConnection(); + @SuppressWarnings("unchecked") + final Callable factory = mock(Callable.class); + when(factory.call()).thenReturn(broken, replacement); + + provider.getConnection(factory); + final IDatabaseConnection second = provider.getConnection(factory); + + assertThat(second) + .as("A connection whose liveness check throws must be treated as dead, not " + + "propagate the SQLException out of getConnection().") + .isSameAs(replacement); + } + + @Test + void testGetConnection_withDefaultConstructor_passesDefaultTimeoutToIsValid() throws Exception + { + final CachingConnectionProvider provider = new CachingConnectionProvider(); + final IDatabaseConnection first = mockConnection(false, true); + + provider.getConnection(() -> first); + provider.getConnection(() -> first); + + verify(first.getConnection()) + .isValid(eq(CachingConnectionProvider.DEFAULT_VALIDATION_TIMEOUT_SECONDS)); + } + + @Test + void testGetConnection_withCustomValidationTimeout_passesItToIsValid() throws Exception + { + final int customTimeoutSeconds = 42; + final CachingConnectionProvider provider = + new CachingConnectionProvider(customTimeoutSeconds); + final IDatabaseConnection first = mockConnection(false, true); + + provider.getConnection(() -> first); + provider.getConnection(() -> first); + + verify(first.getConnection()).isValid(eq(customTimeoutSeconds)); + } + + @Test + void testConstructor_withNegativeValidationTimeout_throwsIllegalArgumentException() + { + assertThatThrownBy(() -> new CachingConnectionProvider(-1)) + .as("A negative validation timeout is nonsensical and must be rejected eagerly, " + + "rather than failing confusingly inside a later liveness check.") + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void testGetConnection_whenFactoryThrowsOnFirstCall_propagatesException() throws Exception + { + final CachingConnectionProvider provider = new CachingConnectionProvider(); + final SQLException factoryFailure = new SQLException("cannot connect"); + @SuppressWarnings("unchecked") + final Callable factory = mock(Callable.class); + when(factory.call()).thenThrow(factoryFailure); + + assertThatThrownBy(() -> provider.getConnection(factory)) + .as("A factory failure on the very first call must propagate, not be swallowed.") + .isSameAs(factoryFailure); + } + + @Test + void testGetConnection_afterFactoryThrowsOnFirstCall_retriesOnNextCall() throws Exception + { + final CachingConnectionProvider provider = new CachingConnectionProvider(); + final IDatabaseConnection created = newRealConnection(); + @SuppressWarnings("unchecked") + final Callable factory = mock(Callable.class); + when(factory.call()).thenThrow(new SQLException("cannot connect")).thenReturn(created); + + assertThatThrownBy(() -> provider.getConnection(factory)) + .as("The first attempt's factory failure must surface as-is.") + .isInstanceOf(SQLException.class); + final IDatabaseConnection second = provider.getConnection(factory); + + assertThat(second) + .as("A failed first attempt must not permanently poison the cache; the next call " + + "must retry via the factory.") + .isSameAs(created); + } + + @Test + void testGetConnection_whenFactoryThrowsWhileReplacingDeadConnection_propagatesException() + throws Exception + { + final CachingConnectionProvider provider = new CachingConnectionProvider(); + final IDatabaseConnection dead = mockConnection(true, false); + final SQLException replacementFailure = new SQLException("still cannot connect"); + @SuppressWarnings("unchecked") + final Callable factory = mock(Callable.class); + when(factory.call()).thenReturn(dead).thenThrow(replacementFailure); + + provider.getConnection(factory); + + assertThatThrownBy(() -> provider.getConnection(factory)) + .as("A factory failure while replacing a dead connection must propagate.") + .isSameAs(replacementFailure); + } + + @Test + void testGetConnection_whenClosingDeadConnectionThrows_stillReturnsReplacement() throws Exception + { + final CachingConnectionProvider provider = new CachingConnectionProvider(); + final IDatabaseConnection dead = mockConnection(true, false); + doThrowOnClose(dead, new SQLException("close failed")); + final IDatabaseConnection replacement = newRealConnection(); + @SuppressWarnings("unchecked") + final Callable factory = mock(Callable.class); + when(factory.call()).thenReturn(dead, replacement); + + provider.getConnection(factory); + + assertThatCode(() -> { + final IDatabaseConnection second = provider.getConnection(factory); + assertThat(second) + .as("Failing to close the stale connection must not prevent the provider " + + "from handing back a working replacement.") + .isSameAs(replacement); + }).as("close() failing on the stale connection must be swallowed, not propagated.") + .doesNotThrowAnyException(); + } + + @Test + void testGetConnection_manyConcurrentCallers_invokesFactoryExactlyOnce() throws Exception + { + final CachingConnectionProvider provider = new CachingConnectionProvider(); + final int callerCount = 16; + final AtomicInteger invocationCount = new AtomicInteger(); + final Callable factory = () -> { + invocationCount.incrementAndGet(); + return newRealConnection(); + }; + final CountDownLatch startingGate = new CountDownLatch(1); + final ExecutorService executor = Executors.newFixedThreadPool(callerCount); + try + { + final List> futures = new ArrayList<>(); + for (int i = 0; i < callerCount; i++) + { + futures.add(executor.submit(() -> { + startingGate.await(); + return provider.getConnection(factory); + })); + } + startingGate.countDown(); + + final Set distinctConnections = new HashSet<>(); + for (final Future future : futures) + { + distinctConnections.add(future.get(10, TimeUnit.SECONDS)); + } + + assertThat(invocationCount.get()) + .as("Concurrent first-time callers must not race each other into creating " + + "more than one connection.") + .isEqualTo(1); + assertThat(distinctConnections) + .as("Every concurrent caller must receive the exact same cached connection.") + .hasSize(1); + } finally + { + executor.shutdownNow(); + } + } + + @Test + void testClose_withCachedConnection_closesIt() throws Exception + { + final CachingConnectionProvider provider = new CachingConnectionProvider(); + final IDatabaseConnection cached = newRealConnection(); + provider.getConnection(() -> cached); + + provider.close(); + + assertThat(cached.getConnection().isClosed()) + .as("close() must close the cached connection.").isTrue(); + } + + @Test + void testClose_whenCloseThrows_stillClearsCacheButPropagatesException() throws Exception + { + final CachingConnectionProvider provider = new CachingConnectionProvider(); + final IDatabaseConnection cached = mockConnection(false, true); + final SQLException closeFailure = new SQLException("close failed"); + doThrowOnClose(cached, closeFailure); + provider.getConnection(() -> cached); + + assertThatThrownBy(provider::close) + .as("Unlike the internal replacement path, the public close() method must " + + "propagate a close failure to the caller rather than swallow it.") + .isSameAs(closeFailure); + + final IDatabaseConnection replacement = newRealConnection(); + final IDatabaseConnection afterFailedClose = provider.getConnection(() -> replacement); + assertThat(afterFailedClose) + .as("Even though close() propagated the failure, the cache must have already " + + "been cleared, so the next call creates a fresh connection rather than " + + "reusing (or re-attempting to close) the one that failed to close.") + .isSameAs(replacement); + } + + @Test + void testClose_withCachedConnection_clearsCacheSoNextCallInvokesFactoryAgain() throws Exception + { + final CachingConnectionProvider provider = new CachingConnectionProvider(); + @SuppressWarnings("unchecked") + final Callable factory = mock(Callable.class); + when(factory.call()).thenReturn(newRealConnection(), newRealConnection()); + + provider.getConnection(factory); + provider.close(); + provider.getConnection(factory); + + verify(factory, times(2)).call(); + } + + @Test + void testClose_withNoCachedConnection_doesNotThrow() + { + final CachingConnectionProvider provider = new CachingConnectionProvider(); + + assertThatCode(provider::close) + .as("close() with nothing cached yet must be a harmless no-op.") + .doesNotThrowAnyException(); + } + + @Test + void testGetConnection_afterClose_doesNotReuseThePreviouslyCachedConnection() throws Exception + { + final CachingConnectionProvider provider = new CachingConnectionProvider(); + final IDatabaseConnection first = newRealConnection(); + provider.getConnection(() -> first); + provider.close(); + + final IDatabaseConnection second = provider.getConnection(this::newRealConnection); + + assertThat(second).as("After close(), the next call must create a fresh connection.") + .isNotSameAs(first); + } + + /** + * Creates a real H2 in-memory connection and registers it for automatic cleanup in + * {@link #closeRealConnections()}. + */ + private IDatabaseConnection newRealConnection() throws Exception + { + final IDatabaseConnection connection = InMemoryDatabaseConnection.create(); + realConnections.add(connection); + return connection; + } + + /** + * Creates a mocked {@link IDatabaseConnection} whose underlying {@link Connection} reports the + * given closed/valid state. + */ + private static IDatabaseConnection mockConnection(final boolean closed, final boolean valid) + throws SQLException + { + final Connection jdbcConnection = mock(Connection.class); + when(jdbcConnection.isClosed()).thenReturn(closed); + when(jdbcConnection.isValid(anyInt())).thenReturn(valid); + final IDatabaseConnection connection = mock(IDatabaseConnection.class); + when(connection.getConnection()).thenReturn(jdbcConnection); + return connection; + } + + private static void doThrowOnClose(final IDatabaseConnection connection, final SQLException failure) + throws SQLException + { + doThrow(failure).when(connection).close(); + } +} diff --git a/src/test/java/org/dbunit/database/DatabaseDataSetIT.java b/src/test/java/org/dbunit/database/DatabaseDataSetIT.java index 42dc43716..72f1984f4 100644 --- a/src/test/java/org/dbunit/database/DatabaseDataSetIT.java +++ b/src/test/java/org/dbunit/database/DatabaseDataSetIT.java @@ -285,12 +285,6 @@ void testGetPrimaryKeysWithColumnFilters_withPrimaryKeyFilter_returnsOnlyInclude } } - // public void testGetTableNamesAndCaseSensitive() throws Exception - // { - // DatabaseMetaData metaData = _connection.getConnection().getMetaData(); - // metaData. - // } - @Override @Test public void testCreateDuplicateDataSet_withDuplicateTableNames_throwsAmbiguousTableNameException() throws Exception diff --git a/src/test/java/org/dbunit/database/DatabaseDataSetTest.java b/src/test/java/org/dbunit/database/DatabaseDataSetTest.java new file mode 100644 index 000000000..d80853578 --- /dev/null +++ b/src/test/java/org/dbunit/database/DatabaseDataSetTest.java @@ -0,0 +1,110 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2004, DbUnit.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + */ +package org.dbunit.database; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.sql.DatabaseMetaData; +import java.sql.Statement; + +import org.dbunit.dataset.ITableMetaData; +import org.dbunit.dataset.NoSuchTableException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link DatabaseDataSet}, using a real in-memory H2 database + * so {@link IMetadataHandler} can be spied on for deterministic call + * counting instead of hand-mocking the JDBC metadata result sets. + */ +class DatabaseDataSetTest +{ + private static final String TABLE_NAME = "TEST_TABLE"; + private static final String SCHEMA_NAME = "PUBLIC"; + + private IDatabaseConnection connection; + private IMetadataHandler metadataHandlerSpy; + + @BeforeEach + void setUp() throws Exception + { + connection = InMemoryDatabaseConnection.create(SCHEMA_NAME); + + final Statement stmt = connection.getConnection().createStatement(); + stmt.execute( + "CREATE TABLE " + TABLE_NAME + " (ID INTEGER PRIMARY KEY)"); + stmt.close(); + + final IMetadataHandler realHandler = (IMetadataHandler) connection + .getConfig().getProperty(DatabaseConfig.PROPERTY_METADATA_HANDLER); + metadataHandlerSpy = spy(realHandler); + connection.getConfig().setProperty( + DatabaseConfig.PROPERTY_METADATA_HANDLER, metadataHandlerSpy); + } + + @AfterEach + void tearDown() throws Exception + { + if (connection != null) + { + connection.close(); + } + } + + @Test + void testGetTableMetaData_afterGetTableNames_doesNotRevalidateTableExistence() + throws Exception + { + final DatabaseDataSet dataSet = new DatabaseDataSet(connection, true); + + dataSet.getTableNames(); + final ITableMetaData metaData = dataSet.getTableMetaData(TABLE_NAME); + + assertThat(metaData.getTableName()).as("table name.") + .isEqualTo(TABLE_NAME); + verify(metadataHandlerSpy, times(1)).getTables( + any(DatabaseMetaData.class), anyString(), any()); + verify(metadataHandlerSpy, never()).tableExists( + any(DatabaseMetaData.class), anyString(), anyString()); + } + + @Test + void testGetTableMetaData_withUnknownTable_throwsNoSuchTableException() + throws Exception + { + final DatabaseDataSet dataSet = new DatabaseDataSet(connection, true); + + assertThatExceptionOfType(NoSuchTableException.class) + .as("An unknown table must still be rejected by the" + + " containsTable() check in getTableMetaData()," + + " even though DatabaseTableMetaData no longer" + + " re-validates existence itself.") + .isThrownBy(() -> dataSet.getTableMetaData("UNKNOWN_TABLE")); + } +} diff --git a/src/test/java/org/dbunit/database/DatabaseTableMetaDataIT.java b/src/test/java/org/dbunit/database/DatabaseTableMetaDataIT.java index 6adcfb2b5..1625fa366 100644 --- a/src/test/java/org/dbunit/database/DatabaseTableMetaDataIT.java +++ b/src/test/java/org/dbunit/database/DatabaseTableMetaDataIT.java @@ -36,6 +36,7 @@ import org.dbunit.DatabaseEnvironment; import org.dbunit.DdlExecutor; import org.dbunit.TestFeature; +import org.dbunit.TurkishDefaultLocale; import org.dbunit.dataset.Column; import org.dbunit.dataset.Columns; import org.dbunit.dataset.IDataSet; @@ -283,42 +284,31 @@ void testGetColumns_withMultitypeTable_returnsCorrectDataTypeForEachColumn() thr * @throws Exception */ @Test + @TurkishDefaultLocale void testGetTable_withTurkishLocaleActive_findsTableIgnoringLocaleSpecificUpperCase() throws Exception { // To test bug report #1537894 where the user has a turkish locale set - // on his box - - // Change the locale for this test - final Locale oldLocale = Locale.getDefault(); - // Set the locale to turkish where "i".toUpperCase() produces an - // "\u0131" ("I" with dot above) which is not equal to "I". - Locale.setDefault(new Locale("tr", "TR")); + // on his box, where "i".toUpperCase() produces an "\u0131" ("I" with + // dot above) which is not equal to "I". + + // Use the "EMPTY_MULTITYPE_TABLE" because it has an "I" in the + // name. + // Use as input a completely lower-case string so that the internal + // "toUpperCase()" has effect + // 2009-11-06 TODO John Hurst: not working in original form with + // MySQL. + // Is it because "internal toUpperCase() mentioned above is actually + // not being called? + // Investigate further. + // String tableName = "empty_multitype_table"; + final String tableName = "EMPTY_MULTITYPE_TABLE"; - try - { - // Use the "EMPTY_MULTITYPE_TABLE" because it has an "I" in the - // name. - // Use as input a completely lower-case string so that the internal - // "toUpperCase()" has effect - // 2009-11-06 TODO John Hurst: not working in original form with - // MySQL. - // Is it because "internal toUpperCase() mentioned above is actually - // not being called? - // Investigate further. - // String tableName = "empty_multitype_table"; - final String tableName = "EMPTY_MULTITYPE_TABLE"; - - final IDataSet dataSet = this._connection.createDataSet(); - final ITable table = dataSet.getTable(tableName); - // Should now find the table, regardless that we gave the tableName - // in lowerCase - assertThat(table).as("Table '" + tableName + "' was not found") - .isNotNull(); - } finally - { - // Reset locale - Locale.setDefault(oldLocale); - } + final IDataSet dataSet = this._connection.createDataSet(); + final ITable table = dataSet.getTable(tableName); + // Should now find the table, regardless that we gave the tableName + // in lowerCase + assertThat(table).as("Table '" + tableName + "' was not found") + .isNotNull(); } /** diff --git a/src/test/java/org/dbunit/database/InMemoryJndiContextFactory.java b/src/test/java/org/dbunit/database/InMemoryJndiContextFactory.java new file mode 100644 index 000000000..81533e73c --- /dev/null +++ b/src/test/java/org/dbunit/database/InMemoryJndiContextFactory.java @@ -0,0 +1,150 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2026, DbUnit.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + */ +package org.dbunit.database; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Proxy; +import java.util.Hashtable; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import javax.naming.Context; +import javax.naming.NameNotFoundException; +import javax.naming.spi.InitialContextFactory; + +/** + * Test-only {@link InitialContextFactory} backed by a simple in-memory name-to-object map, so + * {@link org.dbunit.JndiDatabaseTester} can be unit tested through a real {@link javax.naming.InitialContext} + * without a real JNDI provider or an extra test dependency. + * + *

+ * Register an object under a unique name with {@link #bind(Object)}, then + * pass {@link #environment()} (or an environment built from it) as the + * environment argument of the + * {@link org.dbunit.JndiDatabaseTester} constructors so JNDI resolves this + * factory. Bindings are held in static maps for the JVM's lifetime; call + * {@link #unbind(String)} from test cleanup (e.g. an {@code @AfterEach} + * method) for every name {@link #bind(Object)} returned, so a long test run + * does not accumulate bound objects it no longer needs. + * + * @since 3.4.0 + */ +public final class InMemoryJndiContextFactory implements InitialContextFactory +{ + private static final Map BINDINGS = new ConcurrentHashMap<>(); + + private static final Map LOOKUP_COUNTS = new ConcurrentHashMap<>(); + + private static final AtomicInteger NAME_COUNTER = new AtomicInteger(); + + /** + * Binds the given object under a fresh, unique name. + * + * @param value The object {@link javax.naming.Context#lookup(String)} + * should return. + * @return The unique name the object was bound under. + */ + public static String bind(final Object value) + { + final String name = "test/binding-" + NAME_COUNTER.incrementAndGet(); + BINDINGS.put(name, value); + LOOKUP_COUNTS.put(name, new AtomicInteger()); + return name; + } + + /** + * Removes the given bound name and its lookup counter, so the previously + * bound object - and anything it holds, e.g. a test + * {@link javax.sql.DataSource} - becomes eligible for garbage collection. + * Call from test cleanup for every name {@link #bind(Object)} returned. + * + * @param name A name previously returned by {@link #bind(Object)}. + */ + public static void unbind(final String name) + { + BINDINGS.remove(name); + LOOKUP_COUNTS.remove(name); + } + + /** + * Returns how many times {@link javax.naming.Context#lookup(String)} has + * been called for the given bound name. + * + * @param name A name previously returned by {@link #bind(Object)}. + * @return The number of lookups observed so far. + */ + public static int lookupCount(final String name) + { + return LOOKUP_COUNTS.get(name).get(); + } + + /** + * Returns a JNDI environment that resolves + * {@link javax.naming.InitialContext} to this factory. + * + * @return A fresh, mutable {@link Properties} instance. + */ + public static Properties environment() + { + final Properties environment = new Properties(); + environment.setProperty(Context.INITIAL_CONTEXT_FACTORY, + InMemoryJndiContextFactory.class.getName()); + return environment; + } + + /** + * Returns a proxy {@link Context} whose {@code lookup(String)} resolves + * names against {@link #BINDINGS} (incrementing the matching + * {@link #LOOKUP_COUNTS} entry) and whose {@code close()} is a no-op; any + * other {@link Context} method throws + * {@link UnsupportedOperationException}. + * + * @param environment Ignored; this factory only ever serves the + * in-memory bindings. + * @return The proxy {@link Context}. + */ + @Override + public Context getInitialContext(final Hashtable environment) + { + final InvocationHandler handler = (proxy, method, args) -> { + if ("lookup".equals(method.getName()) && args.length == 1) + { + final String name = (String) args[0]; + if (!BINDINGS.containsKey(name)) + { + throw new NameNotFoundException(name); + } + LOOKUP_COUNTS.get(name).incrementAndGet(); + return BINDINGS.get(name); + } + if ("close".equals(method.getName())) + { + return null; + } + throw new UnsupportedOperationException( + "InMemoryJndiContextFactory's fake Context does not implement " + method.getName()); + }; + return (Context) Proxy.newProxyInstance(getClass().getClassLoader(), + new Class[] {Context.class}, handler); + } +} diff --git a/src/test/java/org/dbunit/database/MockDatabaseConnection.java b/src/test/java/org/dbunit/database/MockDatabaseConnection.java index 1856a3bb4..79bfa1af8 100644 --- a/src/test/java/org/dbunit/database/MockDatabaseConnection.java +++ b/src/test/java/org/dbunit/database/MockDatabaseConnection.java @@ -157,7 +157,11 @@ public ITable createTable(final String tableName, public ITable createTable(final String tableName) throws DataSetException, SQLException { - throw new UnsupportedOperationException(); + if (_dataSet == null) + { + throw new UnsupportedOperationException(); + } + return _dataSet.getTable(tableName); } @Override diff --git a/src/test/java/org/dbunit/database/search/TablesDependencyHelperTest.java b/src/test/java/org/dbunit/database/search/TablesDependencyHelperTest.java new file mode 100644 index 000000000..f9a2eebec --- /dev/null +++ b/src/test/java/org/dbunit/database/search/TablesDependencyHelperTest.java @@ -0,0 +1,109 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2004, DbUnit.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + */ + +package org.dbunit.database.search; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Method; +import java.sql.Connection; +import java.sql.DatabaseMetaData; + +import org.dbunit.TurkishDefaultLocale; +import org.dbunit.database.IDatabaseConnection; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Unit tests for {@link TablesDependencyHelper}'s private + * {@code normalizeToStoredCase(IDatabaseConnection, String[])}, invoked via + * reflection since driving it through a public entry point would require + * mocking the whole {@link DepthFirstSearch}/callback machinery for no + * benefit - the method's own contract is narrow and self-contained. + * + * @since 3.4.0 + */ +@ExtendWith(MockitoExtension.class) +class TablesDependencyHelperTest +{ + @Mock + private IDatabaseConnection connection; + + @Mock + private Connection jdbcConnection; + + @Mock + private DatabaseMetaData databaseMetaData; + + @Test + @TurkishDefaultLocale + void testNormalizeToStoredCase_withTurkishDefaultLocaleAndLowerCaseIdentifierDatabase_normalizesAsciiCorrectly() + throws Exception + { + when(connection.getConnection()).thenReturn(jdbcConnection); + when(jdbcConnection.getMetaData()).thenReturn(databaseMetaData); + when(databaseMetaData.storesLowerCaseIdentifiers()) + .thenReturn(true); + + final String[] actual = invokeNormalizeToStoredCase(connection, + new String[] {"TABLE_ID"}); + + assertThat(actual) + .as("normalizeToStoredCase() must use Locale.ENGLISH so a" + + " Turkish default locale does not turn 'I' into" + + " a dotless 'ı', which would desync the" + + " normalized name from the lower-case FK" + + " metadata names returned by the driver.") + .containsExactly("table_id"); + } + + @Test + void testNormalizeToStoredCase_withUpperCaseIdentifierDatabase_returnsOriginalArray() + throws Exception + { + when(connection.getConnection()).thenReturn(jdbcConnection); + when(jdbcConnection.getMetaData()).thenReturn(databaseMetaData); + when(databaseMetaData.storesLowerCaseIdentifiers()).thenReturn(false); + final String[] tableNames = {"TABLE_ID"}; + + final String[] actual = + invokeNormalizeToStoredCase(connection, tableNames); + + assertThat(actual) + .as("A database that does not store lower-case identifiers" + + " must not have its names normalized at all.") + .isSameAs(tableNames); + } + + private static String[] invokeNormalizeToStoredCase( + final IDatabaseConnection connection, final String[] tableNames) + throws Exception + { + final Method method = TablesDependencyHelper.class.getDeclaredMethod( + "normalizeToStoredCase", IDatabaseConnection.class, + String[].class); + method.setAccessible(true); + return (String[]) method.invoke(null, connection, tableNames); + } +} diff --git a/src/test/java/org/dbunit/dataset/AbstractDataSetTest.java b/src/test/java/org/dbunit/dataset/AbstractDataSetTest.java index c336d09c0..37b49ea92 100644 --- a/src/test/java/org/dbunit/dataset/AbstractDataSetTest.java +++ b/src/test/java/org/dbunit/dataset/AbstractDataSetTest.java @@ -89,6 +89,13 @@ public static IDataSet removeExtraTestTables(final IDataSet dataSet) nameList.remove("IDENTITY_TABLE"); nameList.remove("DBUNIT.TEST_IDENTITY_NOT_PK"); nameList.remove("TEST_IDENTITY_NOT_PK"); + /* + * This table is created specifically for testing a NOT NULL column + * with a database default on HSQLDB. It should be ignored on other + * platforms. + */ + nameList.remove("DBUNIT.DEFAULT_VALUE_TABLE"); + nameList.remove("DEFAULT_VALUE_TABLE"); names = nameList.toArray(new String[0]); diff --git a/src/test/java/org/dbunit/dataset/AbstractTableMetaDataTest.java b/src/test/java/org/dbunit/dataset/AbstractTableMetaDataTest.java index 59d5613ba..15a42e93e 100644 --- a/src/test/java/org/dbunit/dataset/AbstractTableMetaDataTest.java +++ b/src/test/java/org/dbunit/dataset/AbstractTableMetaDataTest.java @@ -27,8 +27,12 @@ import static org.mockito.Mockito.when; import java.sql.DatabaseMetaData; +import java.util.Collection; +import java.util.Collections; +import org.dbunit.TurkishDefaultLocale; import org.dbunit.dataset.datatype.DataType; +import org.dbunit.dataset.datatype.DefaultDataTypeFactory; import org.dbunit.dataset.datatype.IDataTypeFactory; import org.dbunit.ext.mssql.MsSqlDataTypeFactory; import org.junit.jupiter.api.Test; @@ -86,6 +90,58 @@ public String getTableName() verify(mockDatabaseMetData, times(1)).getDatabaseProductName(); } + @Test + @TurkishDefaultLocale + void testValidator_withTurkishDefaultLocaleAndUpperCaseIInProductName_returnsNullMessage() + throws Exception + { + final AbstractTableMetaData metaData = new AbstractTableMetaData() + { + @Override + public Column[] getColumns() throws DataSetException + { + return null; + } + + @Override + public Column[] getPrimaryKeys() throws DataSetException + { + return null; + } + + @Override + public String getTableName() + { + return null; + } + }; + // The valid-products constant is written already-lower-case (as + // real IDataTypeFactory implementations do, e.g. "mssql", + // "oracle"), while the live driver-reported product name below + // has the 'I's upper-case, needing to be folded to match - a + // Turkish default locale folds 'I' to dotless 'ı' instead of + // 'i', which would break the indexOf() match. + final IDataTypeFactory dataTypeFactory = new DefaultDataTypeFactory() + { + @Override + public Collection getValidDbProducts() + { + return Collections.singletonList("productii"); + } + }; + when(mockDatabaseMetData.getDatabaseProductName()) + .thenReturn("ProductII"); + + final String validationMessage = metaData.validateDataTypeFactory( + dataTypeFactory, mockDatabaseMetData); + + assertThat(validationMessage) + .as("validateDataTypeFactory() must use Locale.ENGLISH so" + + " a Turkish default locale does not break the" + + " case-insensitive product-name match.") + .isNull(); + } + @Test void testGetColumnIndex_exactCaseName_returnsIndex() throws Exception { diff --git a/src/test/java/org/dbunit/dataset/CaseInsensitiveDataSetTest.java b/src/test/java/org/dbunit/dataset/CaseInsensitiveDataSetTest.java index 1e7e99cf9..aeb097d06 100644 --- a/src/test/java/org/dbunit/dataset/CaseInsensitiveDataSetTest.java +++ b/src/test/java/org/dbunit/dataset/CaseInsensitiveDataSetTest.java @@ -21,6 +21,10 @@ package org.dbunit.dataset; +import static org.assertj.core.api.Assertions.assertThat; + +import org.dbunit.TurkishDefaultLocale; +import org.dbunit.dataset.datatype.DataType; import org.dbunit.dataset.xml.XmlDataSet; import org.dbunit.testutil.TestUtils; import org.junit.jupiter.api.Test; @@ -76,4 +80,32 @@ public void testCreateMultipleCaseDuplicateDataSet_withDuplicateCaseVariantNames // why duplicates cannot occur. } + @Test + @TurkishDefaultLocale + void testGetTable_withTurkishDefaultLocaleAndAlreadyUpperCaseQuery_findsTable() + throws Exception + { + // The real table name is lower-case (needs folding); the query + // is already upper-case (a no-op fold) - this asymmetry is what + // surfaces the bug: a Turkish default locale folds the real + // name's 'i' to a dotted capital 'İ', which then no longer + // equals the query's plain ASCII 'I'. + final Column[] columns = {new Column("ID", DataType.VARCHAR)}; + final DefaultTable table = + new DefaultTable("products_id", columns); + final IDataSet wrapped = new DefaultDataSet(table); + final IDataSet caseInsensitiveDataSet = + new CaseInsensitiveDataSet(wrapped); + + final ITable actual = + caseInsensitiveDataSet.getTable("PRODUCTS_ID"); + + assertThat(actual.getTableMetaData().getTableName()) + .as("getTable() must use Locale.ENGLISH for both the" + + " stored and queried table name so a Turkish" + + " default locale does not break the" + + " case-insensitive lookup.") + .isEqualTo("products_id"); + } + } diff --git a/src/test/java/org/dbunit/dataset/LowerCaseDataSetTest.java b/src/test/java/org/dbunit/dataset/LowerCaseDataSetTest.java index 7e5d9e260..95c9984a9 100644 --- a/src/test/java/org/dbunit/dataset/LowerCaseDataSetTest.java +++ b/src/test/java/org/dbunit/dataset/LowerCaseDataSetTest.java @@ -21,10 +21,14 @@ package org.dbunit.dataset; +import static org.assertj.core.api.Assertions.assertThat; + import java.io.FileReader; +import org.dbunit.TurkishDefaultLocale; import org.dbunit.dataset.xml.FlatXmlDataSetBuilder; import org.dbunit.dataset.xml.FlatXmlDataSetTest; +import org.junit.jupiter.api.Test; /** * @author Manuel Laflamme @@ -59,4 +63,22 @@ protected String[] getExpectedDuplicateNames() return names; } + @Test + @TurkishDefaultLocale + void testGetTableNames_withTurkishDefaultLocaleAndUpperCaseIName_lowercasesAsciiCorrectly() + throws Exception + { + final ITable table = new DefaultTable("ID"); + final IDataSet lowerCaseDataSet = + new LowerCaseDataSet(new DefaultDataSet(table)); + + final String[] actual = lowerCaseDataSet.getTableNames(); + + assertThat(actual) + .as("getTableNames() must use Locale.ENGLISH so a" + + " Turkish default locale does not turn 'I' into" + + " a dotless 'ı'.") + .containsExactly("id"); + } + } diff --git a/src/test/java/org/dbunit/dataset/LowerCaseTableMetaDataTest.java b/src/test/java/org/dbunit/dataset/LowerCaseTableMetaDataTest.java index 3777a0e83..3cd50c618 100644 --- a/src/test/java/org/dbunit/dataset/LowerCaseTableMetaDataTest.java +++ b/src/test/java/org/dbunit/dataset/LowerCaseTableMetaDataTest.java @@ -22,6 +22,8 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.util.Locale; + import org.dbunit.dataset.datatype.DataType; import org.junit.jupiter.api.Test; @@ -37,7 +39,7 @@ class LowerCaseTableMetaDataTest void testGetTableName_withUpperCaseTableName_returnsLowerCaseName() throws Exception { final String original = "TABLE_NAME"; - final String expected = original.toLowerCase(); + final String expected = original.toLowerCase(Locale.ENGLISH); final ITableMetaData metaData = new LowerCaseTableMetaData( new DefaultTableMetaData(original, new Column[0])); @@ -67,7 +69,7 @@ void testGetColumns_withUpperCaseColumns_returnsLowerCaseColumns() throws Except final Column lowerColumn = lowerColumns[i]; assertThat(lowerColumn.getColumnName()).as("name") - .isEqualTo(column.getColumnName().toLowerCase()); + .isEqualTo(column.getColumnName().toLowerCase(Locale.ENGLISH)); assertThat( column.getColumnName().equals(lowerColumn.getColumnName())) .as("name not equals").isFalse(); @@ -104,7 +106,7 @@ void testGetPrimaryKeys_withUpperCasePrimaryKeys_returnsLowerCaseKeys() throws E assertThat(keyNames[i]).as("name not equals") .isNotEqualTo(keys[i].getColumnName()); assertThat(keys[i].getColumnName()).as("key name") - .isEqualTo(keyNames[i].toLowerCase()); + .isEqualTo(keyNames[i].toLowerCase(Locale.ENGLISH)); } } diff --git a/src/test/java/org/dbunit/dataset/csv/CsvParserTest.java b/src/test/java/org/dbunit/dataset/csv/CsvParserTest.java index ba513ece4..542fd934f 100644 --- a/src/test/java/org/dbunit/dataset/csv/CsvParserTest.java +++ b/src/test/java/org/dbunit/dataset/csv/CsvParserTest.java @@ -21,6 +21,7 @@ package org.dbunit.dataset.csv; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -31,6 +32,7 @@ import java.io.IOException; import java.io.InputStreamReader; import java.io.LineNumberReader; +import java.io.StringReader; import java.util.List; import org.dbunit.dataset.common.handlers.IllegalInputCharacterException; @@ -195,6 +197,39 @@ void testWhitespacePreservedOnQuotedStrings_withQuotedWhitespace_preservesWhites assertThat(parsed.get(1)).isEqualTo(" world "); } + @Test + void testParse_multilineFieldEndingShortRow_throwsWithAccurateOffendingLine() + { + final String csv = "A,B,C\n\"AA\nAAA\",BB"; + + assertThatThrownBy( + () -> parser.parse(new StringReader(csv), "short-continuation")) + .as("A multi-line field completing with too few columns must fail with an accurate, un-duplicated offending line.") + .isInstanceOf(CsvParserException.class) + .hasMessage( + "Expected 3 columns on line 3, got 2. Offending line: \"AA\nAAA\",BB"); + } + + @Test + void testParse_multilineQuotedField_parsesAcrossLines() throws Exception + { + final String csv = "A,B\n\"AA\nAAA\",\"BB\nBBB\""; + + final List rows = + parser.parse(new StringReader(csv), "legitimate-continuation"); + + assertThat(rows) + .as("The header row and the multi-line data row should both be present.") + .hasSize(2); + final List dataRow = (List) rows.get(1); + assertThat(dataRow.get(0)) + .as("The first field should preserve its embedded newline.") + .isEqualTo("AA\nAAA"); + assertThat(dataRow.get(1)) + .as("The second field should preserve its embedded newline.") + .isEqualTo("BB\nBBB"); + } + @BeforeEach protected void setUp() throws Exception { diff --git a/src/test/java/org/dbunit/dataset/csv/CsvProducerTest.java b/src/test/java/org/dbunit/dataset/csv/CsvProducerTest.java index 7d92c4583..b7eccbf6d 100644 --- a/src/test/java/org/dbunit/dataset/csv/CsvProducerTest.java +++ b/src/test/java/org/dbunit/dataset/csv/CsvProducerTest.java @@ -22,13 +22,21 @@ package org.dbunit.dataset.csv; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.when; import java.io.File; import java.io.FileInputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import java.util.Properties; import org.dbunit.DatabaseUnitException; @@ -50,6 +58,8 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedConstruction; class CsvProducerTest { @@ -106,6 +116,63 @@ void testProduceAndInsertFromFolder_withCsvDirectory_insertsRowsIntoDatabase() statement.close(); } + @Test + void testProduce_withTableNameContainingCsvInMiddle_usesFullTableName( + @TempDir final File tempDir) throws Exception + { + Files.write(new File(tempDir, CsvDataSet.TABLE_ORDERING_FILE).toPath(), + "a.csvx\n".getBytes(StandardCharsets.UTF_8)); + Files.write(new File(tempDir, "a.csvx.csv").toPath(), + "ID, DESCRIPTION\n1, \"first row\"\n" + .getBytes(StandardCharsets.UTF_8)); + + final CsvProducer producer = new CsvProducer(tempDir); + final CachedDataSet consumer = new CachedDataSet(); + producer.setConsumer(consumer); + producer.produce(); + + assertThat(consumer.getTableNames()) + .as("A table name containing \".csv\" before the final suffix must not be truncated at the first occurrence.") + .containsExactly("a.csvx"); + } + + @Test + void testProduce_withNullCellValue_doesNotThrowNullPointerException( + @TempDir final File tempDir) throws Exception + { + // CsvParserImpl itself never emits a null cell (Pipeline always adds + // getCurrentProduct().toString()), so a mocked parser stands in here + // to reach the row[col] transform with a genuinely null value rather + // than the literal "null" sentinel string. + final List header = Arrays.asList("ID", "DESCRIPTION"); + final List nullCellRow = Arrays.asList("1", null); + final List parsedRows = new ArrayList(); + parsedRows.add(header); + parsedRows.add(nullCellRow); + + Files.write(new File(tempDir, CsvDataSet.TABLE_ORDERING_FILE).toPath(), + "orders\n".getBytes(StandardCharsets.UTF_8)); + + try (MockedConstruction ignored = mockConstruction( + CsvParserImpl.class, + (mock, context) -> when(mock.parse(any(File.class))) + .thenReturn(parsedRows))) + { + final CsvProducer producer = new CsvProducer(tempDir); + final CachedDataSet consumer = new CachedDataSet(); + producer.setConsumer(consumer); + + producer.produce(); + + final ITable table = consumer.getTable("orders"); + assertThat(table.getValue(0, "DESCRIPTION")) + .as("A null cell value from the parser must not throw a" + + " NullPointerException from the NULL-token" + + " check and must be preserved as null.") + .isNull(); + } + } + private void produceAndInsertToDatabase() throws DatabaseUnitException, SQLException { diff --git a/src/test/java/org/dbunit/dataset/datatype/BytesDataTypeTest.java b/src/test/java/org/dbunit/dataset/datatype/BytesDataTypeTest.java index 7578347ee..64ea05418 100644 --- a/src/test/java/org/dbunit/dataset/datatype/BytesDataTypeTest.java +++ b/src/test/java/org/dbunit/dataset/datatype/BytesDataTypeTest.java @@ -22,19 +22,29 @@ package org.dbunit.dataset.datatype; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.ByteArrayInputStream; import java.io.File; +import java.io.InputStream; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Types; +import java.util.Arrays; +import org.dbunit.TurkishDefaultLocale; import org.dbunit.dataset.ITable; import org.dbunit.testutil.FileAsserts; import org.junit.jupiter.api.Test; @@ -164,6 +174,182 @@ void testTypeCastFileName_withFilePathValue_returnsFileContentsAsBytes() throws } } + @Test + void testLoadFile_afterLoad_fileIsDeletable() throws Exception + { + final File file = File.createTempFile("BytesDataTypeTest", ".bin"); + file.deleteOnExit(); + Files.write(file.toPath(), new byte[] {1, 2, 3}); + + new BytesDataType("BINARY", Types.BINARY).loadFile(file.getPath()); + + assertThat(file.delete()) + .as("loadFile() must close its file handle so the loaded" + + " file can be deleted immediately afterward.") + .isTrue(); + } + + @Test + void testTypeCast_urlValue_closesStream() throws Exception + { + final byte[] expected = {1, 2, 3}; + final InputStream spyStream = + Mockito.spy(new ByteArrayInputStream(expected)); + final URLStreamHandler handler = new URLStreamHandler() + { + @Override + protected URLConnection openConnection(final URL u) + { + return new URLConnection(u) + { + @Override + public void connect() + { + // No connection setup needed for this test double. + } + + @Override + public InputStream getInputStream() + { + return spyStream; + } + }; + } + }; + final URL url = new URL("spy", "test", -1, "/data", handler); + + final Object actual = + new BytesDataType("BINARY", Types.BINARY).typeCast(url); + + assertThat(actual).as("typeCast(URL) result.").isEqualTo(expected); + verify(spyStream, times(1)).close(); + } + + @Test + void testTypeCast_textCommandInvalidEncoding_throwsTypeCastException() + { + final String value = "[text bogus-encoding]hello"; + final BytesDataType dataType = new BytesDataType("BINARY", Types.BINARY); + + assertThatExceptionOfType(TypeCastException.class) + .as("An unrecognized [text ] id must fail fast with" + + " a typed exception instead of returning error text" + + " as if it were the byte[] result.") + .isThrownBy(() -> dataType.typeCast(value)); + } + + @Test + void testTypeCast_base64CommandCorruptInput_throwsTypeCastException() + { + final String value = "[base64]!!!not-valid-base64!!!"; + final BytesDataType dataType = new BytesDataType("BINARY", Types.BINARY); + + assertThatExceptionOfType(TypeCastException.class) + .as("A value explicitly tagged [base64] that is not" + + " decodable must fail fast with a typed exception" + + " instead of silently becoming NULL.") + .isThrownBy(() -> dataType.typeCast(value)); + } + + @Test + void testTypeCast_fileCommandMissingFile_throwsTypeCastException() + { + final String value = "[file]/does/not/exist/dbunit-missing-file.bin"; + final BytesDataType dataType = new BytesDataType("BINARY", Types.BINARY); + + assertThatExceptionOfType(TypeCastException.class) + .as("A value explicitly tagged [file] that cannot be loaded" + + " must fail fast with a typed exception instead of" + + " storing an \"Error: ...\" message as the blob's" + + " bytes.") + .isThrownBy(() -> dataType.typeCast(value)); + } + + @Test + void testTypeCast_urlCommandUnreachable_throwsTypeCastException() + throws Exception + { + final File missingFile = + new File("does-not-exist", "dbunit-missing-file.bin"); + final String value = "[url]" + missingFile.toURI().toURL(); + final BytesDataType dataType = new BytesDataType("BINARY", Types.BINARY); + + assertThatExceptionOfType(TypeCastException.class) + .as("A value explicitly tagged [url] that cannot be loaded" + + " must fail fast with a typed exception instead of" + + " storing an \"Error: ...\" message as the blob's" + + " bytes.") + .isThrownBy(() -> dataType.typeCast(value)); + } + + @Test + void testTypeCast_untaggedTextFallback_usesUtf8Bytes() throws Exception + { + final String nonAsciiValue = "café!"; + final byte[] expected = nonAsciiValue.getBytes(StandardCharsets.UTF_8); + final BytesDataType dataType = new BytesDataType("BINARY", Types.BINARY); + + final Object actual = dataType.typeCast(nonAsciiValue); + + assertThat(actual) + .as("The untagged text fallback must encode with UTF-8" + + " regardless of the platform default charset.") + .isEqualTo(expected); + } + + @Test + void testTypeCast_untaggedLongInvalidBase64_fallsBackToUtf8Bytes() + throws Exception + { + // Longer than BytesDataType's private MAX_URI_LENGTH (256), so this + // skips the "assume URI" guess entirely and goes straight to the + // Base64 attempt; '!' is not a valid Base64 character + final char[] invalidBase64Chars = new char[300]; + Arrays.fill(invalidBase64Chars, '!'); + final String longInvalidBase64Value = new String(invalidBase64Chars); + final byte[] expected = + longInvalidBase64Value.getBytes(StandardCharsets.UTF_8); + final BytesDataType dataType = new BytesDataType("BINARY", Types.BINARY); + + final Object actual = dataType.typeCast(longInvalidBase64Value); + + assertThat(actual) + .as("An untagged value too long to plausibly be a URI, and" + + " not valid Base64 either, must fall back to its" + + " literal UTF-8 bytes like the shorter 'assume URI'" + + " fallback does, instead of silently becoming" + + " NULL.") + .isEqualTo(expected); + } + + @Test + @TurkishDefaultLocale + void testTypeCast_turkishLocaleFileCommand_recognizedAsFileCommand() throws Exception + { + final File file = File.createTempFile("BytesDataTypeTest", ".bin"); + file.deleteOnExit(); + final byte[] expected = + "dbunit turkish locale file command test" + .getBytes(StandardCharsets.UTF_8); + Files.write(file.toPath(), expected); + + final BytesDataType spyType = + Mockito.spy(new BytesDataType("BINARY", Types.BINARY)); + + final Object actual = + spyType.typeCast("[file]" + file.getPath()); + + assertThat(actual) + .as("typeCast() must recognize the lower-case '[file]'" + + " command and load the file's bytes.") + .isEqualTo(expected); + // Only the fallback URI/file/Base64 guesser (taken when the + // "FILE" command is not recognized) ever calls loadURL() first; + // a buggy default-locale fold of "file" to "FİLE" under tr-TR + // would miss the command match and fall through to it. + verify(spyType, never()).loadURL(anyString()); + } + @Override @Test public void testTypeCastNone_withNullInput_returnsNull() throws Exception diff --git a/src/test/java/org/dbunit/dataset/datatype/TimestampDataTypeTest.java b/src/test/java/org/dbunit/dataset/datatype/TimestampDataTypeTest.java index 2ef437286..7c6feabb1 100644 --- a/src/test/java/org/dbunit/dataset/datatype/TimestampDataTypeTest.java +++ b/src/test/java/org/dbunit/dataset/datatype/TimestampDataTypeTest.java @@ -326,6 +326,37 @@ void testTypeCast_withNanosecondPrecisionAndTimezoneOffset_preservesNanoseconds( .isEqualTo(expected); } + @Test + void testTypeCast_preEpochFractionalSecondWithTimezone_correctInstant() + throws Exception + { + final Timestamp expected = + makeTimestamp(1969, 11, 31, 23, 59, 58, 500, "GMT+01:00"); + final String ts = "1969-12-31 23:59:58.5 +0100"; + assertThat(THIS_TYPE.typeCast(ts)) + .as("Truncating (not flooring) division on a negative" + + " pre-epoch millis value shifted the result a full" + + " second late.") + .isEqualTo(expected); + } + + @Test + void testTypeCast_offsetProducesNegativeEncodedTime_normalizesNanos() + throws Exception + { + final Timestamp expected = + makeTimestamp(1969, 11, 31, 20, 0, 0, "GMT+00:00"); + expected.setNanos(500); + final String ts = "1970-01-01 10:00:00.000000500 +1400"; + assertThat(THIS_TYPE.typeCast(ts)) + .as("An extreme positive zone offset applied to a timestamp" + + " with sub-millisecond nanoseconds can drive the" + + " internal encoded value negative; construction" + + " must not throw IllegalArgumentException from a" + + " negative nanos value.") + .isEqualTo(expected); + } + @Override @Test public void testTypeCastInvalid_withIncompatibleInput_throwsTypeCastException() throws Exception diff --git a/src/test/java/org/dbunit/dataset/excel/XlsTableTest.java b/src/test/java/org/dbunit/dataset/excel/XlsTableTest.java index 13fd822d4..fb07916ad 100644 --- a/src/test/java/org/dbunit/dataset/excel/XlsTableTest.java +++ b/src/test/java/org/dbunit/dataset/excel/XlsTableTest.java @@ -183,6 +183,65 @@ void testNumberAsText_withNumericCellsAsText_returnsStringValues() throws Except } } + @Test + void testGetRowCount_emptySheet_returnsZero() throws Exception + { + final Workbook workbook = new XSSFWorkbook(); + final Sheet sheet = workbook.createSheet("EMPTY_NO_HEADER"); + + final XlsTable table = new XlsTable("EMPTY_NO_HEADER", sheet); + + assertThat(table.getRowCount()) + .as("A sheet with no rows at all (not even a header) must" + + " report zero rows, not a negative count.") + .isZero(); + } + + @Test + void testGetRowCount_headerOnlySheet_returnsZero() throws Exception + { + final Workbook workbook = new XSSFWorkbook(); + final Sheet sheet = workbook.createSheet("HEADER_ONLY"); + final Row headerRow = sheet.createRow(0); + headerRow.createCell(0).setCellValue("COLUMN0"); + + final XlsTable table = new XlsTable("HEADER_ONLY", sheet); + + assertThat(table.getRowCount()) + .as("A sheet with only a header row and no data rows must" + + " report zero rows.") + .isZero(); + } + + @Test + void testGetValue_physicallyMissingRow_returnsNull() throws Exception + { + final Workbook workbook = new XSSFWorkbook(); + final Sheet sheet = workbook.createSheet("GAP_ROW"); + final Row headerRow = sheet.createRow(0); + headerRow.createCell(0).setCellValue("COLUMN0"); + + // Create then remove the row backing ITable row 0 (sheet row 1) to + // force a physically-missing row inside the sheet. + final Row gapRow = sheet.createRow(1); + gapRow.createCell(0).setCellValue("will be removed"); + sheet.removeRow(gapRow); + + final Row dataRow = sheet.createRow(2); + dataRow.createCell(0).setCellValue("row1value"); + + final XlsTable table = new XlsTable("GAP_ROW", sheet); + + assertThat(table.getValue(0, "COLUMN0")) + .as("A physically-missing row inside the sheet must produce" + + " a null cell value instead of" + + " NullPointerException.") + .isNull(); + assertThat(table.getValue(1, "COLUMN0")) + .as("A row after the gap must still be read correctly.") + .isEqualTo("row1value"); + } + @Test void testGetValue_manyNumericCellsSameFormat_returnsSameValuesAsUncached() throws Exception { diff --git a/src/test/java/org/dbunit/dataset/filter/PatternMatcherTest.java b/src/test/java/org/dbunit/dataset/filter/PatternMatcherTest.java new file mode 100644 index 000000000..fb211a077 --- /dev/null +++ b/src/test/java/org/dbunit/dataset/filter/PatternMatcherTest.java @@ -0,0 +1,48 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2004, DbUnit.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + */ +package org.dbunit.dataset.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.dbunit.TurkishDefaultLocale; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link PatternMatcher}. + */ +class PatternMatcherTest +{ + @Test + @TurkishDefaultLocale + void testAccept_turkishLocaleDottedIName_matches() + { + final PatternMatcher matcher = new PatternMatcher(); + matcher.addPattern("ISLAND_TABLE"); + + assertThat(matcher.accept("island_table")) + .as("accept() must fold the candidate name with" + + " Locale.ENGLISH so a Turkish default locale's" + + " dotted capital I does not break matching" + + " against an already-uppercase-ASCII accepted" + + " name.") + .isTrue(); + } +} diff --git a/src/test/java/org/dbunit/dataset/stream/StreamingIteratorTest.java b/src/test/java/org/dbunit/dataset/stream/StreamingIteratorTest.java new file mode 100644 index 000000000..71ebcab4b --- /dev/null +++ b/src/test/java/org/dbunit/dataset/stream/StreamingIteratorTest.java @@ -0,0 +1,105 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2004, DbUnit.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + */ +package org.dbunit.dataset.stream; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.dbunit.dataset.Column; +import org.dbunit.dataset.DataSetException; +import org.dbunit.dataset.DefaultTableMetaData; +import org.dbunit.dataset.ITable; +import org.dbunit.dataset.ITableMetaData; +import org.dbunit.dataset.datatype.DataType; +import org.junit.jupiter.api.Test; + +/** + * @since 3.4.0 + */ +class StreamingIteratorTest +{ + private static final ITableMetaData TABLE_META_DATA = + new DefaultTableMetaData("TABLE", + new Column[] {new Column("COLUMN", DataType.VARCHAR)}); + + private static final int ITERATIONS = 20; + + /** + * The producer's failure races the consumer thread's interruption. Depending on + * scheduling, the channel's entry-point interruption check can observe the + * interrupt on either the constructor's first read or the subsequent row read, + * so the whole sequence is wrapped in one assertion; the scenario is repeated + * several times since the field-visibility bug this guards against is not + * guaranteed to reproduce on every single run. + *

+ * This is a best-effort, probabilistic guard, not a proof: strongly-ordered + * architectures (x86/x64, which is what CI runs on) rarely surface the stale + * read this test targets even without the volatile fix, so a regression here + * could pass silently on this hardware. Reliably verifying the happens-before + * edge instead of relying on scheduling luck would need a tool like jcstress, + * which is out of scope for this project. + */ + @Test + void testGetValue_producerThrowsAfterStartTable_reportsProducerExceptionAsCause() + { + for (int i = 0; i < ITERATIONS; i++) + { + final DataSetException producerException = new DataSetException( + "Producer failed after starting the table, iteration " + i + + "."); + + assertThatThrownBy(() -> { + final StreamingIterator iterator = new StreamingIterator( + new FailAfterStartTableProducer(producerException)); + iterator.next(); + final ITable table = iterator.getTable(); + table.getValue(0, "COLUMN"); + }).as("The consumer must surface the producer's real failure instead of a bare interruption message, regardless of which blocking read observes the interrupt.") + .isInstanceOf(DataSetException.class) + .hasCause(producerException); + } + } + + private static final class FailAfterStartTableProducer + implements IDataSetProducer + { + private final DataSetException exceptionToThrow; + private IDataSetConsumer consumer; + + FailAfterStartTableProducer(final DataSetException exceptionToThrow) + { + this.exceptionToThrow = exceptionToThrow; + } + + @Override + public void setConsumer(final IDataSetConsumer consumer) + { + this.consumer = consumer; + } + + @Override + public void produce() throws DataSetException + { + consumer.startDataSet(); + consumer.startTable(TABLE_META_DATA); + throw exceptionToThrow; + } + } +} diff --git a/src/test/java/org/dbunit/dataset/xml/FlatDtdDataSetIT.java b/src/test/java/org/dbunit/dataset/xml/FlatDtdDataSetIT.java index 2a8f3ad40..8995e9363 100644 --- a/src/test/java/org/dbunit/dataset/xml/FlatDtdDataSetIT.java +++ b/src/test/java/org/dbunit/dataset/xml/FlatDtdDataSetIT.java @@ -20,15 +20,20 @@ */ package org.dbunit.dataset.xml; +import static org.assertj.core.api.Assertions.assertThat; + import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileReader; import java.io.FileWriter; import java.io.Writer; +import java.nio.charset.StandardCharsets; import org.dbunit.DatabaseEnvironment; import org.dbunit.database.IDatabaseConnection; import org.dbunit.dataset.AbstractDataSetTest; +import org.dbunit.dataset.DataSetBuilder; import org.dbunit.dataset.FilteredDataSet; import org.dbunit.dataset.IDataSet; import org.dbunit.testutil.FileAsserts; @@ -148,4 +153,23 @@ void testWriteFromDatabase_withDatabaseDataSet_writesEquivalentDtdFile() throws tempFile.delete(); } } + + @Test + void testWrite_withNonAsciiTableName_writesUtf8Bytes() throws Exception + { + final IDataSet dataSet = new DataSetBuilder().table("café_TABLE") + .columns("ID").row(1).build(); + + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + FlatDtdDataSet.write(dataSet, out); + + final String actualUtf8 = + new String(out.toByteArray(), StandardCharsets.UTF_8); + assertThat(actualUtf8) + .as("The DTD output stream must be encoded as UTF-8," + + " matching what the InputStream constructor's SAX" + + " parsing assumes, regardless of the platform" + + " default charset.") + .contains("café_TABLE"); + } } \ No newline at end of file diff --git a/src/test/java/org/dbunit/dataset/xml/FlatXmlDataSetTest.java b/src/test/java/org/dbunit/dataset/xml/FlatXmlDataSetTest.java index d77cf6795..dfa87375b 100644 --- a/src/test/java/org/dbunit/dataset/xml/FlatXmlDataSetTest.java +++ b/src/test/java/org/dbunit/dataset/xml/FlatXmlDataSetTest.java @@ -29,16 +29,20 @@ import java.io.FileReader; import java.io.FileWriter; import java.io.StringReader; +import java.io.StringWriter; import java.io.Writer; import org.dbunit.Assertion; import org.dbunit.dataset.AbstractDataSetTest; import org.dbunit.dataset.Column; import org.dbunit.dataset.DataSetUtils; +import org.dbunit.dataset.DefaultDataSet; +import org.dbunit.dataset.DefaultTable; import org.dbunit.dataset.IDataSet; import org.dbunit.dataset.ITable; import org.dbunit.dataset.ITableIterator; import org.dbunit.dataset.ITableMetaData; +import org.dbunit.dataset.datatype.DataType; import org.dbunit.testutil.TestUtils; import org.junit.jupiter.api.Test; @@ -187,6 +191,32 @@ void testWrite_withValidDataSet_writesAndReadsBackEquivalentData() throws Except } } + @Test + void testWrite_withSupplementaryCharacterCell_writesAndReadsBackEquivalentData() + throws Exception + { + final String emoji = "😀"; // U+1F600 GRINNING FACE + final String col0 = "COL0"; + final Column[] columns = {new Column(col0, DataType.UNKNOWN)}; + final DefaultTable table = new DefaultTable("TABLE1", columns); + table.addRow(); + table.setValue(0, col0, emoji); + final IDataSet expectedDataSet = new DefaultDataSet(table); + + final StringWriter out = new StringWriter(); + FlatXmlDataSet.write(expectedDataSet, out); + + final IDataSet actualDataSet = new FlatXmlDataSetBuilder() + .build(new StringReader(out.toString())); + + assertThat(actualDataSet.getTable("TABLE1").getValue(0, col0)) + .as("A supplementary character (outside the BMP) must" + + " round-trip verbatim through flat XML" + + " export/import instead of being split into two" + + " invalid surrogate numeric entities.") + .isEqualTo(emoji); + } + @Test void testReadFlatXmlWithDifferentCaseInDtd_withMismatchedDtdCase_loadsDataSet() throws Exception { diff --git a/src/test/java/org/dbunit/dataset/xml/FlatXmlProducerTest.java b/src/test/java/org/dbunit/dataset/xml/FlatXmlProducerTest.java index b1e5c28cd..7c468a860 100644 --- a/src/test/java/org/dbunit/dataset/xml/FlatXmlProducerTest.java +++ b/src/test/java/org/dbunit/dataset/xml/FlatXmlProducerTest.java @@ -25,8 +25,8 @@ import java.io.File; import java.io.IOException; import java.io.StringReader; -import java.util.Locale; +import org.dbunit.TurkishDefaultLocale; import org.dbunit.dataset.Column; import org.dbunit.dataset.DataSetException; import org.dbunit.dataset.DefaultDataSet; @@ -275,42 +275,34 @@ void testProduce_columnSensingDisabled_missingColumnIgnored() throws Exception } @Test + @TurkishDefaultLocale void testProduce_turkishLocaleAndCaseVariantColumnName_recognizesSameColumn() throws Exception { - final Locale originalLocale = Locale.getDefault(); - Locale.setDefault(new Locale("tr", "TR")); - try - { - final String tableName = "TABLE_NAME"; - final MockDataSetConsumer consumer = new MockDataSetConsumer(); - consumer.addExpectedStartDataSet(); - consumer.addExpectedStartTable(tableName, - new Column[] {new Column("id", DataType.UNKNOWN)}); - consumer.addExpectedRow(tableName, new Object[] {"1"}); - consumer.addExpectedRow(tableName, new Object[] {"2"}); - consumer.addExpectedEndTable(tableName); - consumer.addExpectedEndDataSet(); - - // Row 2's "ID" is the same logical column as row 1's "id", just differently - // cased. Under the Turkish default locale, "id".toUpperCase() produces a - // dotted capital I ("İD") that does not equal "ID".toUpperCase() ("ID"), - // so a locale-sensitive comparison would wrongly treat row 2's ID as a new, - // unrelated column instead of recognizing it as "id". - final String content = "" + "" - + "" + "" - + ""; - final InputSource source = new InputSource(new StringReader(content)); - final IDataSetProducer producer = - new FlatXmlProducer(source, false, true); - producer.setConsumer(consumer); - - producer.produce(); - consumer.verify(); - } - finally - { - Locale.setDefault(originalLocale); - } + final String tableName = "TABLE_NAME"; + final MockDataSetConsumer consumer = new MockDataSetConsumer(); + consumer.addExpectedStartDataSet(); + consumer.addExpectedStartTable(tableName, + new Column[] {new Column("id", DataType.UNKNOWN)}); + consumer.addExpectedRow(tableName, new Object[] {"1"}); + consumer.addExpectedRow(tableName, new Object[] {"2"}); + consumer.addExpectedEndTable(tableName); + consumer.addExpectedEndDataSet(); + + // Row 2's "ID" is the same logical column as row 1's "id", just differently + // cased. Under the Turkish default locale, "id".toUpperCase() produces a + // dotted capital I ("İD") that does not equal "ID".toUpperCase() ("ID"), + // so a locale-sensitive comparison would wrongly treat row 2's ID as a new, + // unrelated column instead of recognizing it as "id". + final String content = "" + "" + + "" + "" + + ""; + final InputSource source = new InputSource(new StringReader(content)); + final IDataSetProducer producer = + new FlatXmlProducer(source, false, true); + producer.setConsumer(consumer); + + producer.produce(); + consumer.verify(); } } diff --git a/src/test/java/org/dbunit/dataset/xml/FlatXmlWriterTest.java b/src/test/java/org/dbunit/dataset/xml/FlatXmlWriterTest.java index 9828cffd4..e169a4b71 100644 --- a/src/test/java/org/dbunit/dataset/xml/FlatXmlWriterTest.java +++ b/src/test/java/org/dbunit/dataset/xml/FlatXmlWriterTest.java @@ -23,6 +23,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.io.StringReader; import java.io.StringWriter; import org.dbunit.dataset.Column; @@ -30,6 +31,7 @@ import org.dbunit.dataset.DefaultDataSet; import org.dbunit.dataset.DefaultTable; import org.dbunit.dataset.IDataSet; +import org.dbunit.dataset.ITable; import org.dbunit.dataset.datatype.DataType; import org.junit.jupiter.api.Test; @@ -142,6 +144,58 @@ void testWriteNullValue_withNullCellValue_omitsNullAttributeFromRow() throws Exc assertThat(actualOutput).as("output").isEqualTo(expectedOutput); } + @Test + void testWriteNoValueCell_withNoValueCellValue_omitsAttributeFromRow() throws Exception + { + final String expectedOutput = + "\n" + " \n" + + " \n" + "\n"; + + final String col0 = "COL0"; + final String col1 = "COL1"; + final Column[] columns = + new Column[] {new Column(col0, DataType.UNKNOWN), + new Column(col1, DataType.UNKNOWN)}; + + final DefaultTable table = new DefaultTable("TEST_TABLE", columns); + table.addRow(); + table.setValue(0, col0, "c0r0"); + table.setValue(0, col1, "c1r0"); + table.addRow(); + table.setValue(1, col0, "c0r1"); + table.setValue(1, col1, ITable.NO_VALUE); + + final StringWriter stringWriter = new StringWriter(); + final FlatXmlWriter xmlWriter = new FlatXmlWriter(stringWriter); + xmlWriter.write(new DefaultDataSet(table)); + + final String actualOutput = stringWriter.toString(); + assertThat(actualOutput).as("output").isEqualTo(expectedOutput); + } + + @Test + void testWrite_xmlDataSetWithNoneElement_omitsAttributeFromFlatXmlOutput() throws Exception + { + final String xmlDataSetInput = "\n" + + " \n" + + " COL0\n" + " COL1\n" + + " \n" + " c0r0\n" + + " \n" + " \n" + "
\n" + + "
\n"; + + final IDataSet dataSet = + new XmlDataSet(new StringReader(xmlDataSetInput)); + + final StringWriter stringWriter = new StringWriter(); + final FlatXmlWriter xmlWriter = new FlatXmlWriter(stringWriter); + xmlWriter.write(dataSet); + + final String expectedOutput = "\n" + + " \n" + "\n"; + assertThat(stringWriter.toString()).as("output") + .isEqualTo(expectedOutput); + } + @Test void testWritePrettyPrintDisabled_withPrettyPrintFalse_writesCompactOutput() throws Exception { diff --git a/src/test/java/org/dbunit/dataset/yaml/YmlDataSetTest.java b/src/test/java/org/dbunit/dataset/yaml/YmlDataSetTest.java index ff8f84220..79177bef6 100644 --- a/src/test/java/org/dbunit/dataset/yaml/YmlDataSetTest.java +++ b/src/test/java/org/dbunit/dataset/yaml/YmlDataSetTest.java @@ -168,4 +168,26 @@ void testWrite_largeDataSetToMemoryStream_writesCompleteOutput() throws Exceptio Assertion.assertEquals(expectedDataSet, actualDataSet); } + @Test + void testWrite_withNonAsciiValue_roundTripsAsUtf8() throws Exception + { + final String nonAsciiValue = "café"; + final IDataSet expectedDataSet = new DataSetBuilder() + .table("NON_ASCII_TABLE").columns("VALUE").row(nonAsciiValue) + .build(); + + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + YamlDataSet.write(expectedDataSet, out); + + final IDataSet actualDataSet = + new YamlDataSet(new ByteArrayInputStream(out.toByteArray())); + + assertThat(actualDataSet.getTable("NON_ASCII_TABLE").getValue(0, + "VALUE")) + .as("A non-ASCII value must round-trip through" + + " UTF-8-encoded YAML regardless of the" + + " platform default charset.") + .isEqualTo(nonAsciiValue); + } + } diff --git a/src/test/java/org/dbunit/dataset/yaml/YmlWriterTest.java b/src/test/java/org/dbunit/dataset/yaml/YmlWriterTest.java index 15ed455a2..c7f4a6e39 100644 --- a/src/test/java/org/dbunit/dataset/yaml/YmlWriterTest.java +++ b/src/test/java/org/dbunit/dataset/yaml/YmlWriterTest.java @@ -30,6 +30,7 @@ import org.dbunit.dataset.DefaultDataSet; import org.dbunit.dataset.DefaultTable; import org.dbunit.dataset.IDataSet; +import org.dbunit.dataset.ITable; import org.dbunit.dataset.datatype.DataType; import org.junit.jupiter.api.Test; @@ -126,6 +127,34 @@ void testWriteNullValue_withNullCellValue_omitsNullKeyFromYamlRow() throws Excep assertThat(actualOutput).as("output").isEqualTo(expectedOutput); } + @Test + void testWriteNoValueCell_withNoValueCellValue_omitsKeyFromYamlRow() throws Exception + { + final String expectedOutput = "TEST_TABLE:\n" + " - COL0: c0r0\n" + + " COL1: c1r0\n" + " - COL0: c0r1\n"; + + final String col0 = "COL0"; + final String col1 = "COL1"; + final Column[] columns = + new Column[] {new Column(col0, DataType.UNKNOWN), + new Column(col1, DataType.UNKNOWN)}; + + final DefaultTable table = new DefaultTable("TEST_TABLE", columns); + table.addRow(); + table.setValue(0, col0, "c0r0"); + table.setValue(0, col1, "c1r0"); + table.addRow(); + table.setValue(1, col0, "c0r1"); + table.setValue(1, col1, ITable.NO_VALUE); + + final StringWriter stringWriter = new StringWriter(); + final YamlWriter yamlWriter = new YamlWriter(stringWriter); + yamlWriter.write(new DefaultDataSet(table)); + + final String actualOutput = stringWriter.toString(); + assertThat(actualOutput).as("output").isEqualTo(expectedOutput); + } + @Test void testWriteFlow_withFlowStyleEnabled_writesFlowStyleOutput() throws Exception { diff --git a/src/test/java/org/dbunit/ext/oracle/OracleConnectionTest.java b/src/test/java/org/dbunit/ext/oracle/OracleConnectionTest.java new file mode 100644 index 000000000..ae3e3fc4d --- /dev/null +++ b/src/test/java/org/dbunit/ext/oracle/OracleConnectionTest.java @@ -0,0 +1,90 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2004, DbUnit.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + */ + +package org.dbunit.ext.oracle; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; + +import org.dbunit.TurkishDefaultLocale; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Unit tests for {@link OracleConnection}, focused on the + * {@code Locale.ENGLISH}-pinned upper-casing of the schema name passed to the + * constructor. + * + * @since 3.4.0 + */ +@ExtendWith(MockitoExtension.class) +class OracleConnectionTest +{ + @Mock + private Connection connection; + + @Mock + private DatabaseMetaData databaseMetaData; + + @Mock + private ResultSet schemasResultSet; + + @Mock + private ResultSet catalogsResultSet; + + @Test + @TurkishDefaultLocale + void testConstructor_withTurkishDefaultLocaleAndLowerCaseSchema_uppercasesAsciiCorrectly() + throws Exception + { + when(connection.getMetaData()).thenReturn(databaseMetaData); + when(databaseMetaData.getIdentifierQuoteString()) + .thenReturn("\""); + when(databaseMetaData.storesLowerCaseIdentifiers()) + .thenReturn(false); + when(databaseMetaData.storesUpperCaseIdentifiers()) + .thenReturn(false); + when(databaseMetaData.getSchemas()).thenReturn(schemasResultSet); + when(schemasResultSet.next()).thenReturn(false); + when(databaseMetaData.getCatalogs()) + .thenReturn(catalogsResultSet); + when(catalogsResultSet.next()).thenReturn(false); + + // Under a Turkish default locale, an un-pinned toUpperCase() + // would fold 'i' to a dotted capital 'İ' instead of plain ASCII + // 'I', desyncing the schema from what the driver reports. + final OracleConnection oracleConnection = + new OracleConnection(connection, "id"); + + assertThat(oracleConnection.getSchema()) + .as("OracleConnection's constructor must use" + + " Locale.ENGLISH so a Turkish default locale" + + " does not turn 'i' into a dotted capital" + + " 'İ'.") + .isEqualTo("ID"); + } +} diff --git a/src/test/java/org/dbunit/ext/oracle/OracleSdoGeometryDataTypeTest.java b/src/test/java/org/dbunit/ext/oracle/OracleSdoGeometryDataTypeTest.java index 06c075180..3cf97c2e9 100644 --- a/src/test/java/org/dbunit/ext/oracle/OracleSdoGeometryDataTypeTest.java +++ b/src/test/java/org/dbunit/ext/oracle/OracleSdoGeometryDataTypeTest.java @@ -34,6 +34,7 @@ import oracle.sql.ORAData; import oracle.sql.ORADataFactory; +import org.dbunit.TurkishDefaultLocale; import org.dbunit.dataset.ITable; import org.dbunit.dataset.datatype.AbstractDataTypeTest; import org.dbunit.dataset.datatype.DataType; @@ -521,4 +522,35 @@ public void testGetSqlValue_withValidStatement_returnsExpectedValue() throws Exc assertThat(THIS_TYPE.getSqlValue(1, mockedOracleResultSet)).as("non-null ORAData returns its toString()") .isEqualTo(expectedString); } + + @Test + @TurkishDefaultLocale + void testTypeCast_withTurkishDefaultLocaleAndLowerCaseInfoKeyword_parsesAsciiCorrectly() + throws Exception + { + // "sdo_elem_info_array" contains the only lower-case 'i' among + // this parser's keywords (in "info") - under a Turkish default + // locale, an un-pinned toUpperCase() would fold it to a dotted + // capital 'İ' instead of plain ASCII 'I', desyncing it from the + // literal "SDO_ELEM_INFO_ARRAY" in the parser's regex and + // wrongly throwing TypeCastException. + final String value = "sdo_geometry(123, 45.6, mdsys.sdo_point_type" + + " ( 987.34 , 56.3 , 3 ) ," + + " mdsys.sdo_elem_info_array(1,2) , sdo_ordinate_array())"; + + final Object actual = THIS_TYPE.typeCast(value); + + assertThat(actual) + .as("typeCast() must use toUpperCase(Locale.ENGLISH) so a" + + " Turkish default locale does not break the" + + " SDO_ELEM_INFO_ARRAY literal match.") + .isEqualTo(new OracleSdoGeometry(new BigDecimal(123), + new BigDecimal("45.6"), + new OracleSdoPointType(new BigDecimal("987.34"), + new BigDecimal("56.3"), + new BigDecimal("3")), + new OracleSdoElemInfoArray(new BigDecimal[] { + new BigDecimal(1), new BigDecimal(2)}), + new OracleSdoOrdinateArray())); + } } diff --git a/src/test/java/org/dbunit/ext/oracle/OracleXMLTypeDataTypeTest.java b/src/test/java/org/dbunit/ext/oracle/OracleXMLTypeDataTypeTest.java new file mode 100644 index 000000000..506bbacc0 --- /dev/null +++ b/src/test/java/org/dbunit/ext/oracle/OracleXMLTypeDataTypeTest.java @@ -0,0 +1,128 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2004, DbUnit.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + */ + +package org.dbunit.ext.oracle; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLXML; + +import oracle.jdbc.OraclePreparedStatement; +import oracle.jdbc.OracleResultSet; + +import org.dbunit.dataset.datatype.DataType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Unit tests for {@link OracleXMLTypeDataType} using mock objects, focused on + * the UTF-8 charset used to move between the {@code byte[]} DataType value + * and the underlying {@link SQLXML}'s {@code String}. + * + * @since 3.4.0 + */ +@ExtendWith(MockitoExtension.class) +class OracleXMLTypeDataTypeTest +{ + private static final DataType THIS_TYPE = + OracleDataTypeFactory.ORACLE_XMLTYPE; + + private static final String NON_ASCII_XML = + "élève"; + + @Mock + private OracleResultSet mockedOracleResultSet; + + @Mock + private SQLXML mockedSqlXml; + + @Mock + private OraclePreparedStatement mockedOraclePreparedStatement; + + @Mock + private Connection mockedConnection; + + @Mock + private PreparedStatement mockedPreparedStatement; + + @Test + void testGetSqlValue_withNonAsciiContent_decodesSqlXmlStringAsUtf8Bytes() + throws Exception + { + when(mockedOracleResultSet.unwrap(OracleResultSet.class)) + .thenReturn(mockedOracleResultSet); + when(mockedOracleResultSet.getSQLXML(1)).thenReturn(mockedSqlXml); + when(mockedSqlXml.getString()).thenReturn(NON_ASCII_XML); + + final Object actual = + THIS_TYPE.getSqlValue(1, mockedOracleResultSet); + + assertThat(actual) + .as("getSqlValue() must encode the SQLXML's String with UTF-8" + + " so non-ASCII characters round-trip, matching" + + " setSqlValue()'s decoding of the same bytes.") + .isEqualTo(NON_ASCII_XML.getBytes(StandardCharsets.UTF_8)); + } + + @Test + void testGetSqlValue_withNullSqlXml_returnsNull() throws Exception + { + when(mockedOracleResultSet.unwrap(OracleResultSet.class)) + .thenReturn(mockedOracleResultSet); + when(mockedOracleResultSet.getSQLXML(1)).thenReturn(null); + + final Object actual = THIS_TYPE.getSqlValue(1, mockedOracleResultSet); + + assertThat(actual).as("A NULL SQLXML column must type-cast to null.") + .isNull(); + } + + @Test + void testSetSqlValue_withNonAsciiContent_encodesUtf8BytesAsSqlXmlString() + throws Exception + { + final byte[] value = NON_ASCII_XML.getBytes(StandardCharsets.UTF_8); + when(mockedPreparedStatement.unwrap(OraclePreparedStatement.class)) + .thenReturn(mockedOraclePreparedStatement); + when(mockedOraclePreparedStatement.getConnection()) + .thenReturn(mockedConnection); + when(mockedConnection.createSQLXML()).thenReturn(mockedSqlXml); + + THIS_TYPE.setSqlValue(value, 1, mockedPreparedStatement); + + verify(mockedSqlXml) + .setString(NON_ASCII_XML); + verify(mockedOraclePreparedStatement).setSQLXML(1, mockedSqlXml); + } + + @Test + void testGetSqlTypeName_returnsOracleXmlTypeName() + { + assertThat(THIS_TYPE.getSqlTypeName()).isEqualTo("SYS.XMLTYPE"); + } +} diff --git a/src/test/java/org/dbunit/ext/postgresql/PostgresSQLOidIT.java b/src/test/java/org/dbunit/ext/postgresql/PostgresSQLOidIT.java index fbba5fbfc..1017a59ba 100644 --- a/src/test/java/org/dbunit/ext/postgresql/PostgresSQLOidIT.java +++ b/src/test/java/org/dbunit/ext/postgresql/PostgresSQLOidIT.java @@ -1,7 +1,6 @@ package org.dbunit.ext.postgresql; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; import java.io.StringReader; import java.sql.Statement; @@ -71,43 +70,36 @@ protected void tearDown() throws Exception } @Test - void xtestOidDataType() throws Exception + void testOidDataType_withNullAndBinaryValues_roundTripsThroughDatabase() throws Exception { assertThat(_connection).as("didn't get a connection").isNotNull(); final DatabaseConfig config = _connection.getConfig(); config.setProperty(DatabaseConfig.PROPERTY_DATATYPE_FACTORY, new PostgresqlDataTypeFactory()); - try - { - final ReplacementDataSet dataSet = - new ReplacementDataSet(new FlatXmlDataSetBuilder() - .build(new InputSource(new StringReader(xmlData)))); - dataSet.addReplacementObject("[NULL]", null); - dataSet.setStrictReplacement(true); - - IDataSet ids; - ids = _connection.createDataSet(); - final ITableMetaData itmd = ids.getTableMetaData(testTable); - final Column[] cols = itmd.getColumns(); - ids = _connection.createDataSet(); - for (final Column col : cols) - { - assertThat(col.getDataType().getSqlType()) - .isEqualTo(Types.BIGINT); - assertThat(col.getSqlTypeName()).isEqualTo("oid"); - } + final ReplacementDataSet dataSet = + new ReplacementDataSet(new FlatXmlDataSetBuilder() + .build(new InputSource(new StringReader(xmlData)))); + dataSet.addReplacementObject("[NULL]", null); + dataSet.setStrictReplacement(true); - DatabaseOperation.CLEAN_INSERT.execute(_connection, dataSet); - ids = _connection.createDataSet(); - final ITable it = ids.getTable(testTable); - assertThat(it.getValue(0, "DATA")).isNull(); - assertThat("\\[text UTF-8](Anything)".getBytes()) - .isEqualTo(it.getValue(1, "DATA")); - } catch (final Exception e) + IDataSet ids; + ids = _connection.createDataSet(); + final ITableMetaData itmd = ids.getTableMetaData(testTable); + final Column[] cols = itmd.getColumns(); + ids = _connection.createDataSet(); + for (final Column col : cols) { - assertEquals("DatabaseOperation.CLEAN_INSERT... no exception", - "" + e); + assertThat(col.getDataType().getSqlType()) + .isEqualTo(Types.BIGINT); + assertThat(col.getSqlTypeName()).isEqualTo("oid"); } + + DatabaseOperation.CLEAN_INSERT.execute(_connection, dataSet); + ids = _connection.createDataSet(); + final ITable it = ids.getTable(testTable); + assertThat(it.getValue(0, "DATA")).isNull(); + assertThat("\\[text UTF-8](Anything)".getBytes()) + .isEqualTo(it.getValue(1, "DATA")); } } diff --git a/src/test/java/org/dbunit/ext/postgresql/PostgresqlUuidIT.java b/src/test/java/org/dbunit/ext/postgresql/PostgresqlUuidIT.java index 2364dd224..85d0e4b01 100644 --- a/src/test/java/org/dbunit/ext/postgresql/PostgresqlUuidIT.java +++ b/src/test/java/org/dbunit/ext/postgresql/PostgresqlUuidIT.java @@ -1,7 +1,6 @@ package org.dbunit.ext.postgresql; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; import java.io.StringReader; import java.sql.Statement; @@ -76,44 +75,37 @@ protected void tearDown() throws Exception } @Test - void xtestUUidDataType() throws Exception + void testUuidDataType_withUuidColumn_roundTripsThroughDatabase() throws Exception { assertThat(_connection).as("didn't get a connection").isNotNull(); final DatabaseConfig config = _connection.getConfig(); config.setProperty(DatabaseConfig.PROPERTY_DATATYPE_FACTORY, new PostgresqlDataTypeFactory()); - try - { - final ReplacementDataSet dataSet = - new ReplacementDataSet(new FlatXmlDataSetBuilder() - .build(new InputSource(new StringReader(xmlData)))); - dataSet.addReplacementObject("[NULL]", null); - dataSet.setStrictReplacement(true); - - IDataSet ids; - ids = _connection.createDataSet(); - final ITableMetaData itmd = ids.getTableMetaData(testTable); - final Column[] cols = itmd.getColumns(); - ids = _connection.createDataSet(); - for (final Column col : cols) - { - assertThat(col.getDataType().getSqlType()) - .isEqualTo(Types.OTHER); - assertThat(col.getSqlTypeName()).isEqualTo("uuid"); - } + final ReplacementDataSet dataSet = + new ReplacementDataSet(new FlatXmlDataSetBuilder() + .build(new InputSource(new StringReader(xmlData)))); + dataSet.addReplacementObject("[NULL]", null); + dataSet.setStrictReplacement(true); - DatabaseOperation.CLEAN_INSERT.execute(_connection, dataSet); - ids = _connection.createDataSet(); - final ITable it = ids.getTable(testTable); - assertThat(it.getValue(0, "ID")).isNotNull() - .isEqualTo("08004327-3f6c-4335-9738-0b2bf885cc43"); - assertThat(it.getValue(1, "ID")) - .isEqualTo("1a0e342a-02cc-43bf-9643-f9ea312dd349"); - } catch (final Exception e) + IDataSet ids; + ids = _connection.createDataSet(); + final ITableMetaData itmd = ids.getTableMetaData(testTable); + final Column[] cols = itmd.getColumns(); + ids = _connection.createDataSet(); + for (final Column col : cols) { - assertEquals("DatabaseOperation.CLEAN_INSERT... no exception", - "" + e); + assertThat(col.getDataType().getSqlType()) + .isEqualTo(Types.OTHER); + assertThat(col.getSqlTypeName()).isEqualTo("uuid"); } + + DatabaseOperation.CLEAN_INSERT.execute(_connection, dataSet); + ids = _connection.createDataSet(); + final ITable it = ids.getTable(testTable); + assertThat(it.getValue(0, "ID")).isNotNull() + .isEqualTo("08004327-3f6c-4335-9738-0b2bf885cc43"); + assertThat(it.getValue(1, "ID")) + .isEqualTo("1a0e342a-02cc-43bf-9643-f9ea312dd349"); } } diff --git a/src/test/java/org/dbunit/ext/postgresql/SQLHelperDomainPostgreSQLIT.java b/src/test/java/org/dbunit/ext/postgresql/SQLHelperDomainPostgreSQLIT.java index bcfb5169a..0135d872f 100644 --- a/src/test/java/org/dbunit/ext/postgresql/SQLHelperDomainPostgreSQLIT.java +++ b/src/test/java/org/dbunit/ext/postgresql/SQLHelperDomainPostgreSQLIT.java @@ -73,45 +73,37 @@ protected void tearDown() throws Exception } @Test - void xtestDomainDataTypes() throws Exception + void testDomainDataTypes_withCustomSqlDomains_mapsToUnderlyingSqlTypes() throws Exception { assertThat(_connection).as("didn't get a connection").isNotNull(); - try - { - final ReplacementDataSet dataSet = - new ReplacementDataSet(new FlatXmlDataSetBuilder() - .build(new InputSource(new StringReader(xmlData)))); - dataSet.addReplacementObject("[NULL]", null); - dataSet.setStrictReplacement(true); + final ReplacementDataSet dataSet = + new ReplacementDataSet(new FlatXmlDataSetBuilder() + .build(new InputSource(new StringReader(xmlData)))); + dataSet.addReplacementObject("[NULL]", null); + dataSet.setStrictReplacement(true); - // THE TEST -> hopefully with no exception!!! - DatabaseOperation.CLEAN_INSERT.execute(_connection, dataSet); + DatabaseOperation.CLEAN_INSERT.execute(_connection, dataSet); - // Check Types. - for (int i = 0; i < _connection.createDataSet() - .getTableMetaData("T1").getColumns().length; i++) - { - final Column c = _connection.createDataSet() - .getTableMetaData("T1").getColumns()[i]; + // Check Types. + for (int i = 0; i < _connection.createDataSet() + .getTableMetaData("T1").getColumns().length; i++) + { + final Column c = _connection.createDataSet() + .getTableMetaData("T1").getColumns()[i]; - if (c.getSqlTypeName().compareTo("mypk") == 0) - { - assertThat(c.getDataType().getSqlType()) - .isEqualTo(java.sql.Types.INTEGER); - } else if (c.getSqlTypeName().compareTo("mystate") == 0) - { - assertThat(c.getDataType().getSqlType()) - .isEqualTo(java.sql.Types.VARCHAR); - } else - { - fail("we should not be here"); - } + if (c.getSqlTypeName().compareTo("mypk") == 0) + { + assertThat(c.getDataType().getSqlType()) + .isEqualTo(java.sql.Types.INTEGER); + } else if (c.getSqlTypeName().compareTo("mystate") == 0) + { + assertThat(c.getDataType().getSqlType()) + .isEqualTo(java.sql.Types.VARCHAR); + } else + { + fail("we should not be here"); } - } catch (final Exception e) - { - assertThat("" + e).isEqualTo( - "DatabaseOperation.CLEAN_INSERT... no exception"); } } } diff --git a/src/test/java/org/dbunit/operation/InsertOperationIT.java b/src/test/java/org/dbunit/operation/InsertOperationIT.java index 64920eb73..55b0ec546 100644 --- a/src/test/java/org/dbunit/operation/InsertOperationIT.java +++ b/src/test/java/org/dbunit/operation/InsertOperationIT.java @@ -44,6 +44,7 @@ import org.dbunit.dataset.xml.FlatXmlDataSetBuilder; import org.dbunit.dataset.xml.XmlDataSet; import org.dbunit.testutil.TestUtils; +import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.Test; /** @@ -54,60 +55,6 @@ public class InsertOperationIT extends AbstractDatabaseIT { - // public void testExecuteNullAsNone() throws Exception - // { - // String schemaName = "schema"; - // String tableName = "table"; - // String[] expected = { - // "insert into schema.table (c1, c2, c3) values ('toto', 1234, 'false')", - // "insert into schema.table (c2, c3) values (123.45, 'true')", - // "insert into schema.table (c1, c2, c3) values ('qwerty1', 1, 'true')", - // "insert into schema.table (c1, c2, c3) values ('qwerty2', 2, 'false')", - // "insert into schema.table (c3) values ('false')", - // }; - // - // // setup table - // List valueList = new ArrayList(); - // valueList.add(new Object[]{"toto", "1234", Boolean.FALSE}); - // valueList.add(new Object[]{null, new Double("123.45"), "true"}); - // valueList.add(new Object[]{"qwerty1", "1", Boolean.TRUE}); - // valueList.add(new Object[]{"qwerty2", "2", Boolean.FALSE}); - // valueList.add(new Object[]{null, null, Boolean.FALSE}); - // Column[] columns = new Column[]{ - // new Column("c1", DataType.VARCHAR), - // new Column("c2", DataType.NUMERIC), - // new Column("c3", DataType.BOOLEAN), - // }; - // DefaultTable table = new DefaultTable(tableName, columns, valueList); - // IDataSet dataSet = new DefaultDataSet(table); - // - // // setup mock objects - // MockBatchStatement statement = new MockBatchStatement(); - // statement.addExpectedBatchStrings(expected); - // statement.setExpectedExecuteBatchCalls(4); - // statement.setExpectedClearBatchCalls(4); - // statement.setExpectedCloseCalls(4); - // - // MockStatementFactory factory = new MockStatementFactory(); - // factory.setExpectedCreatePreparedStatementCalls(4); - // factory.setupStatement(statement); - // - // MockDatabaseConnection connection = new MockDatabaseConnection(); - // connection.setupDataSet(dataSet); - // connection.setupSchema(schemaName); - // connection.setupStatementFactory(factory); - // connection.setExpectedCloseCalls(0); - // DatabaseConfig config = connection.getConfig(); - // config.setFeature(DatabaseConfig.FEATURE_NULL_AS_NONE, true); - // - // // execute operation - // new InsertOperation().execute(connection, dataSet); - // - // statement.verify(); - // factory.verify(); - // connection.verify(); - // } - @Test void testExecute_withClobData_insertsClobSuccessfully() throws Exception { @@ -387,6 +334,42 @@ void testExecute_emptyStringWithAllowEmptyFields_insertedAsEmptyString() throws assertThat(actual.getValue(0, "COLUMN0")).as("COLUMN0.").isEqualTo("hasValue"); } + @Test + void testExecute_withDefaultValueNotNullColumnTurningNull_appliesDefaultOnSecondRow() + throws Exception + { + // DEFAULT_VALUE_TABLE (ID, STATUS NOT NULL DEFAULT 'PENDING') is + // only defined in the HSQLDB fixture DDL. + Assumptions.assumeTrue( + _connection.getConnection().getMetaData() + .getDatabaseProductName().startsWith("HSQL"), + "Skip: DEFAULT_VALUE_TABLE is only defined in the HSQLDB fixture DDL."); + + final String tableName = "DEFAULT_VALUE_TABLE"; + final Column[] columns = {new Column("ID", DataType.INTEGER), + new Column("STATUS", DataType.VARCHAR, + DataType.VARCHAR.toString(), Column.NO_NULLS, + "'PENDING'")}; + final DefaultTable table = new DefaultTable(tableName, columns); + table.addRow(new Object[] {"1", "ACTIVE"}); + table.addRow(new Object[] {"2", null}); + final IDataSet dataSet = new DefaultDataSet(table); + + DatabaseOperation.CLEAN_INSERT.execute(_connection, dataSet); + + final ITable actual = _connection.createDataSet().getTable(tableName); + assertThat(actual.getRowCount()).as("row count.").isEqualTo(2); + assertThat(actual.getValue(0, "STATUS")).as("row 1 STATUS.") + .isEqualTo("ACTIVE"); + assertThat(actual.getValue(1, "STATUS")) + .as("row 2 STATUS - database default must apply since" + + " equalsIgnoreMapping now regenerates the" + + " statement instead of reusing row 1's, which" + + " would otherwise bind NULL into this NOT NULL" + + " column.") + .isEqualTo("PENDING"); + } + private void testExecute(final IDataSet dataSet) throws Exception, SQLException { diff --git a/src/test/java/org/dbunit/operation/InsertOperationTest.java b/src/test/java/org/dbunit/operation/InsertOperationTest.java index f41a57c77..f2830cdf6 100644 --- a/src/test/java/org/dbunit/operation/InsertOperationTest.java +++ b/src/test/java/org/dbunit/operation/InsertOperationTest.java @@ -450,4 +450,53 @@ void testExecute_withDefaultValueNotNullColumn_excludesColumnFromInsert() throws factory.verify(); connection.verify(); } + + @Test + void testExecute_withDefaultValueNotNullColumnTurningNull_regeneratesStatement() + throws Exception + { + final String schemaName = "schema"; + final String tableName = "table"; + final String[] expected = { + "insert into schema.table (c1, c2) values (1, 'x')", + "insert into schema.table (c1) values (2)",}; + + // setup table: c2 disallows null but has a database default + final Column[] columns = new Column[] { + new Column("c1", DataType.NUMERIC, Column.NO_NULLS), + new Column("c2", DataType.VARCHAR, DataType.VARCHAR.toString(), + Column.NO_NULLS, "'default'"),}; + final DefaultTable table = new DefaultTable(tableName, columns); + table.addRow(new Object[] {"1", "x"}); + table.addRow(new Object[] {"2", null}); + final IDataSet dataSet = new DefaultDataSet(table); + + // setup mock objects: without mirroring the null-with-default rule + // in equalsIgnoreMapping, row 2's differing shape goes undetected, + // the row 1 statement (which includes c2) is reused, and NULL is + // bound into the NOT NULL c2 column instead of leaving it out for + // the database default to apply + final MockBatchStatement statement = new MockBatchStatement(); + statement.addExpectedBatchStrings(expected); + statement.setExpectedExecuteBatchCalls(2); + statement.setExpectedClearBatchCalls(2); + statement.setExpectedCloseCalls(2); + + final MockStatementFactory factory = new MockStatementFactory(); + factory.setExpectedCreatePreparedStatementCalls(2); + factory.setupStatement(statement); + + final MockDatabaseConnection connection = new MockDatabaseConnection(); + connection.setupDataSet(dataSet); + connection.setupSchema(schemaName); + connection.setupStatementFactory(factory); + connection.setExpectedCloseCalls(0); + + // execute operation + new InsertOperation().execute(connection, dataSet); + + statement.verify(); + factory.verify(); + connection.verify(); + } } diff --git a/src/test/java/org/dbunit/operation/RefreshOperationTest.java b/src/test/java/org/dbunit/operation/RefreshOperationTest.java new file mode 100644 index 000000000..ac551671c --- /dev/null +++ b/src/test/java/org/dbunit/operation/RefreshOperationTest.java @@ -0,0 +1,98 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2004, DbUnit.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + */ + +package org.dbunit.operation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; +import static org.mockito.ArgumentMatchers.anyString; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; + +import org.dbunit.DatabaseUnitException; +import org.dbunit.database.MockDatabaseConnection; +import org.dbunit.dataset.Column; +import org.dbunit.dataset.DefaultDataSet; +import org.dbunit.dataset.DefaultTable; +import org.dbunit.dataset.DefaultTableMetaData; +import org.dbunit.dataset.IDataSet; +import org.dbunit.dataset.ITableMetaData; +import org.dbunit.dataset.datatype.DataType; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +/** + * Unit tests for {@link RefreshOperation} using mock objects. + * + * @since 3.4.0 + */ +class RefreshOperationTest +{ + @Test + void testExecute_whenRowProcessingAndStatementCloseBothFail_throwsRowFailureWithCloseSuppressed() + throws Exception + { + final PreparedStatement mockPreparedStatement = + Mockito.mock(PreparedStatement.class); + final SQLException rowProcessingFailure = + new SQLException("row processing boom"); + Mockito.when(mockPreparedStatement.execute()) + .thenThrow(rowProcessingFailure); + final SQLException closeFailure = new SQLException("close boom"); + Mockito.doThrow(closeFailure).when(mockPreparedStatement).close(); + + final Connection mockJdbcConnection = Mockito.mock(Connection.class); + Mockito.when(mockJdbcConnection.prepareStatement(anyString())) + .thenReturn(mockPreparedStatement); + + final MockDatabaseConnection connection = new MockDatabaseConnection(); + connection.setupConnection(mockJdbcConnection); + connection.setExpectedCloseCalls(0); + + final Column[] columns = {new Column("ID", DataType.INTEGER), + new Column("NAME", DataType.VARCHAR)}; + final String[] primaryKeys = {"ID"}; + final ITableMetaData metaData = + new DefaultTableMetaData("MY_TABLE", columns, primaryKeys); + final DefaultTable table = new DefaultTable(metaData); + table.addRow(new Object[] {1, "a"}); + final IDataSet dataSet = new DefaultDataSet(table); + // getOperationMetaData() cross-checks columns against the + // connection's own view of the table + connection.setupDataSet(table); + + final Throwable thrown = catchThrowable( + () -> DatabaseOperation.REFRESH.execute(connection, dataSet)); + + assertThat(thrown) + .as("The row-processing failure must propagate, not the" + + " statement-close failure that happens while" + + " cleaning up after it.") + .isInstanceOf(DatabaseUnitException.class) + .hasCause(rowProcessingFailure); + assertThat(thrown.getSuppressed()) + .as("The close failure must be attached as suppressed" + + " instead of being lost.") + .containsExactly(closeFailure); + } +} diff --git a/src/test/java/org/dbunit/operation/TransactionOperationTest.java b/src/test/java/org/dbunit/operation/TransactionOperationTest.java new file mode 100644 index 000000000..72e0cd89c --- /dev/null +++ b/src/test/java/org/dbunit/operation/TransactionOperationTest.java @@ -0,0 +1,279 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2004, DbUnit.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + */ + +package org.dbunit.operation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.sql.Connection; +import java.sql.SQLException; + +import org.dbunit.DatabaseUnitException; +import org.dbunit.database.IDatabaseConnection; +import org.dbunit.dataset.IDataSet; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Unit tests for {@link TransactionOperation} using Mockito. + */ +@ExtendWith(MockitoExtension.class) +class TransactionOperationTest +{ + @Mock + private DatabaseOperation delegate; + + @Mock + private IDatabaseConnection connection; + + @Mock + private Connection jdbcConnection; + + @Mock + private IDataSet dataSet; + + @Test + void testExecute_whenDelegateSucceeds_commitsThenRestoresAutoCommit() + throws Exception + { + when(connection.getConnection()).thenReturn(jdbcConnection); + when(jdbcConnection.getAutoCommit()).thenReturn(true); + + new TransactionOperation(delegate).execute(connection, dataSet); + + final InOrder inOrder = Mockito.inOrder(jdbcConnection, delegate); + inOrder.verify(jdbcConnection).setAutoCommit(false); + inOrder.verify(delegate).execute(connection, dataSet); + inOrder.verify(jdbcConnection).commit(); + inOrder.verify(jdbcConnection).setAutoCommit(true); + verify(jdbcConnection, never()).rollback(); + } + + @Test + void testExecute_whenDelegateThrowsError_rollsBackWithoutCommittingAndRestoresAutoCommit() + throws Exception + { + when(connection.getConnection()).thenReturn(jdbcConnection); + when(jdbcConnection.getAutoCommit()).thenReturn(true); + final AssertionError failure = new AssertionError("boom"); + doThrow(failure).when(delegate).execute(connection, dataSet); + + final TransactionOperation operation = new TransactionOperation(delegate); + final Throwable thrown = + catchThrowable(() -> operation.execute(connection, dataSet)); + + assertThat(thrown) + .as("An Error propagating from the delegate operation must" + + " still be rethrown unchanged, not swallowed or" + + " wrapped.") + .isSameAs(failure); + verify(jdbcConnection).rollback(); + verify(jdbcConnection, never()).commit(); + verify(jdbcConnection) + .setAutoCommit(true); + } + + @Test + void testExecute_whenDelegateThrowsErrorAndRollbackAlsoFails_suppressesRollbackFailureAndRethrowsOriginalError() + throws Exception + { + when(connection.getConnection()).thenReturn(jdbcConnection); + when(jdbcConnection.getAutoCommit()).thenReturn(true); + final AssertionError failure = new AssertionError("boom"); + doThrow(failure).when(delegate).execute(connection, dataSet); + final SQLException rollbackFailure = new SQLException("rollback boom"); + doThrow(rollbackFailure).when(jdbcConnection).rollback(); + + final TransactionOperation operation = new TransactionOperation(delegate); + final Throwable thrown = + catchThrowable(() -> operation.execute(connection, dataSet)); + + assertThat(thrown) + .as("The original Error must remain the thrown exception even" + + " when rollback also fails.") + .isSameAs(failure); + assertThat(failure.getSuppressed()) + .as("The rollback failure must be attached to the original" + + " Error as suppressed instead of replacing it.") + .containsExactly(rollbackFailure); + verify(jdbcConnection, never()).commit(); + verify(jdbcConnection).setAutoCommit(true); + } + + @Test + void testExecute_whenDelegateThrowsSQLException_rollsBackAndRethrows() + throws Exception + { + when(connection.getConnection()).thenReturn(jdbcConnection); + when(jdbcConnection.getAutoCommit()).thenReturn(true); + final SQLException failure = new SQLException("boom"); + doThrow(failure).when(delegate).execute(connection, dataSet); + + final TransactionOperation operation = new TransactionOperation(delegate); + final Throwable thrown = + catchThrowable(() -> operation.execute(connection, dataSet)); + + assertThat(thrown) + .as("The pre-existing SQLException rollback path must still" + + " roll back and rethrow unchanged.") + .isSameAs(failure); + verify(jdbcConnection).rollback(); + verify(jdbcConnection, never()).commit(); + verify(jdbcConnection).setAutoCommit(true); + } + + @Test + void testExecute_whenDelegateThrowsSQLExceptionAndRollbackAlsoFails_suppressesRollbackFailureAndRethrowsOriginalException() + throws Exception + { + when(connection.getConnection()).thenReturn(jdbcConnection); + when(jdbcConnection.getAutoCommit()).thenReturn(true); + final SQLException failure = new SQLException("boom"); + doThrow(failure).when(delegate).execute(connection, dataSet); + final SQLException rollbackFailure = new SQLException("rollback boom"); + doThrow(rollbackFailure).when(jdbcConnection).rollback(); + + final TransactionOperation operation = new TransactionOperation(delegate); + final Throwable thrown = + catchThrowable(() -> operation.execute(connection, dataSet)); + + assertThat(thrown) + .as("The original SQLException must remain the thrown" + + " exception even when rollback also fails.") + .isSameAs(failure); + assertThat(failure.getSuppressed()) + .as("The rollback failure must be attached to the original" + + " SQLException as suppressed instead of replacing" + + " it.") + .containsExactly(rollbackFailure); + verify(jdbcConnection, never()).commit(); + verify(jdbcConnection).setAutoCommit(true); + } + + @Test + void testExecute_whenDelegateThrowsDatabaseUnitException_rollsBackAndRethrows() + throws Exception + { + when(connection.getConnection()).thenReturn(jdbcConnection); + when(jdbcConnection.getAutoCommit()).thenReturn(true); + final DatabaseUnitException failure = new DatabaseUnitException("boom"); + doThrow(failure).when(delegate).execute(connection, dataSet); + + final TransactionOperation operation = new TransactionOperation(delegate); + final Throwable thrown = + catchThrowable(() -> operation.execute(connection, dataSet)); + + assertThat(thrown) + .as("The DatabaseUnitException rollback path must roll back" + + " and rethrow unchanged.") + .isSameAs(failure); + verify(jdbcConnection).rollback(); + verify(jdbcConnection, never()).commit(); + verify(jdbcConnection).setAutoCommit(true); + } + + @Test + void testExecute_whenDelegateThrowsDatabaseUnitExceptionAndRollbackAlsoFails_suppressesRollbackFailureAndRethrowsOriginalException() + throws Exception + { + when(connection.getConnection()).thenReturn(jdbcConnection); + when(jdbcConnection.getAutoCommit()).thenReturn(true); + final DatabaseUnitException failure = new DatabaseUnitException("boom"); + doThrow(failure).when(delegate).execute(connection, dataSet); + final SQLException rollbackFailure = new SQLException("rollback boom"); + doThrow(rollbackFailure).when(jdbcConnection).rollback(); + + final TransactionOperation operation = new TransactionOperation(delegate); + final Throwable thrown = + catchThrowable(() -> operation.execute(connection, dataSet)); + + assertThat(thrown) + .as("The original DatabaseUnitException must remain the" + + " thrown exception even when rollback also fails.") + .isSameAs(failure); + assertThat(failure.getSuppressed()) + .as("The rollback failure must be attached to the original" + + " DatabaseUnitException as suppressed instead of" + + " replacing it.") + .containsExactly(rollbackFailure); + verify(jdbcConnection, never()).commit(); + verify(jdbcConnection).setAutoCommit(true); + } + + @Test + void testExecute_whenDelegateThrowsRuntimeException_rollsBackAndRethrows() + throws Exception + { + when(connection.getConnection()).thenReturn(jdbcConnection); + when(jdbcConnection.getAutoCommit()).thenReturn(true); + final RuntimeException failure = new RuntimeException("boom"); + doThrow(failure).when(delegate).execute(connection, dataSet); + + final TransactionOperation operation = new TransactionOperation(delegate); + final Throwable thrown = + catchThrowable(() -> operation.execute(connection, dataSet)); + + assertThat(thrown) + .as("The RuntimeException rollback path must roll back and" + + " rethrow unchanged.") + .isSameAs(failure); + verify(jdbcConnection).rollback(); + verify(jdbcConnection, never()).commit(); + verify(jdbcConnection).setAutoCommit(true); + } + + @Test + void testExecute_whenDelegateThrowsRuntimeExceptionAndRollbackAlsoFails_suppressesRollbackFailureAndRethrowsOriginalException() + throws Exception + { + when(connection.getConnection()).thenReturn(jdbcConnection); + when(jdbcConnection.getAutoCommit()).thenReturn(true); + final RuntimeException failure = new RuntimeException("boom"); + doThrow(failure).when(delegate).execute(connection, dataSet); + final SQLException rollbackFailure = new SQLException("rollback boom"); + doThrow(rollbackFailure).when(jdbcConnection).rollback(); + + final TransactionOperation operation = new TransactionOperation(delegate); + final Throwable thrown = + catchThrowable(() -> operation.execute(connection, dataSet)); + + assertThat(thrown) + .as("The original RuntimeException must remain the thrown" + + " exception even when rollback also fails.") + .isSameAs(failure); + assertThat(failure.getSuppressed()) + .as("The rollback failure must be attached to the original" + + " RuntimeException as suppressed instead of" + + " replacing it.") + .containsExactly(rollbackFailure); + verify(jdbcConnection, never()).commit(); + verify(jdbcConnection).setAutoCommit(true); + } +} diff --git a/src/test/java/org/dbunit/operation/TruncateTableOperationTest.java b/src/test/java/org/dbunit/operation/TruncateTableOperationTest.java index fa8ac5da5..6a0b44af3 100644 --- a/src/test/java/org/dbunit/operation/TruncateTableOperationTest.java +++ b/src/test/java/org/dbunit/operation/TruncateTableOperationTest.java @@ -21,6 +21,19 @@ package org.dbunit.operation; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.sql.Connection; +import java.sql.DatabaseMetaData; + +import org.dbunit.TurkishDefaultLocale; +import org.dbunit.database.IDatabaseConnection; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + /** * Unit tests for {@link TruncateTableOperation} using mock objects. * @@ -28,8 +41,18 @@ * @since Apr 13, 2003 * @version $Revision$ */ +@ExtendWith(MockitoExtension.class) class TruncateTableOperationTest extends DeleteAllOperationTest { + @Mock + private IDatabaseConnection connection; + + @Mock + private Connection jdbcConnection; + + @Mock + private DatabaseMetaData databaseMetaData; + @Override protected DatabaseOperation getDeleteAllOperation() { @@ -41,4 +64,27 @@ protected String getExpectedStament(final String tableName) { return "truncate table " + tableName; } + + @Test + @TurkishDefaultLocale + void testGetDeleteAllCommandSuffix_withTurkishDefaultLocaleAndDb2ProductName_appendsImmediate() + throws Exception + { + when(connection.getConnection()).thenReturn(jdbcConnection); + when(jdbcConnection.getMetaData()).thenReturn(databaseMetaData); + when(databaseMetaData.getDatabaseProductName()) + .thenReturn("DB2/NT"); + + final String actual = new TruncateTableOperation() + .getDeleteAllCommandSuffix(connection); + + // "db2" itself contains no I/i, so a Turkish default locale + // cannot actually break this specific match - this is a + // consistency/coverage test for the Locale.ENGLISH pin, not a + // demonstrated-bug regression test. + assertThat(actual) + .as("DB2 product name must still get the IMMEDIATE" + + " suffix under a Turkish default locale.") + .isEqualTo(" IMMEDIATE"); + } } diff --git a/src/test/java/org/dbunit/util/Base64Test.java b/src/test/java/org/dbunit/util/Base64Test.java new file mode 100644 index 000000000..eafa76eda --- /dev/null +++ b/src/test/java/org/dbunit/util/Base64Test.java @@ -0,0 +1,59 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2008, DbUnit.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + */ +package org.dbunit.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; + +class Base64Test +{ + @Test + void testDecode_withInvalidCharacter_logsWarningAndReturnsNull() + { + final Logger base64Logger = + (Logger) LoggerFactory.getLogger(Base64.class); + final ListAppender appender = new ListAppender<>(); + appender.start(); + base64Logger.addAppender(appender); + try + { + final byte[] decoded = Base64.decode("!!!!"); + + assertThat(decoded) + .as("decode() must keep returning null on invalid input instead of throwing.") + .isNull(); + assertThat(appender.list) + .as("The bad-input diagnostic must be logged at WARN instead of printed to stderr.") + .filteredOn(event -> event.getLevel() == Level.WARN) + .hasSize(1); + } finally + { + base64Logger.detachAppender(appender); + } + } +} diff --git a/src/test/java/org/dbunit/util/FileHelperTest.java b/src/test/java/org/dbunit/util/FileHelperTest.java new file mode 100644 index 000000000..a7103684d --- /dev/null +++ b/src/test/java/org/dbunit/util/FileHelperTest.java @@ -0,0 +1,74 @@ +/* + * + * The DbUnit Database Testing Framework + * Copyright (C)2002-2008, DbUnit.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + */ +package org.dbunit.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class FileHelperTest +{ + @Test + void testCopyFile_withRegularFiles_copiesContent( + @TempDir final File tempDir) throws IOException + { + final File srcFile = new File(tempDir, "source.txt"); + Files.write(srcFile.toPath(), + "content".getBytes(StandardCharsets.UTF_8)); + final File destFile = new File(tempDir, "destination.txt"); + + FileHelper.copyFile(srcFile, destFile); + + assertThat(destFile) + .as("The destination file should contain the source file's bytes.") + .hasBinaryContent("content".getBytes(StandardCharsets.UTF_8)); + } + + @Test + void testCopyFile_destinationUnwritable_sourceNotLocked( + @TempDir final File tempDir) throws IOException + { + final File srcFile = new File(tempDir, "source.txt"); + Files.write(srcFile.toPath(), + "content".getBytes(StandardCharsets.UTF_8)); + // A directory is a portable way to make the destination unopenable + // for writing. + final File destDir = new File(tempDir, "destination-is-a-directory"); + assertThat(destDir.mkdir()) + .as("Setup: the destination directory must be created.") + .isTrue(); + + assertThatThrownBy(() -> FileHelper.copyFile(srcFile, destDir)) + .as("Opening a directory for writing must fail.") + .isInstanceOf(IOException.class); + + assertThat(srcFile.delete()) + .as("The source file must not be locked by a leaked channel after the failed copy.") + .isTrue(); + } +} diff --git a/src/test/java/org/dbunit/util/QualifiedTableNameTest.java b/src/test/java/org/dbunit/util/QualifiedTableNameTest.java index 501066d1c..8af9b2668 100644 --- a/src/test/java/org/dbunit/util/QualifiedTableNameTest.java +++ b/src/test/java/org/dbunit/util/QualifiedTableNameTest.java @@ -127,6 +127,46 @@ private static Stream provideQualifiedTableNames() Arguments.of("'DEFAULT_SCHEMA'.'MY_TABLE'", true, "'")); } + @Test + void testGetQualifiedName_singleCharSchemaWithEscapePattern_escapesBothSegments() + { + final String qualifiedName = + new QualifiedTableName("A.TBL", null, "[?]") + .getQualifiedName(); + assertThat(qualifiedName).isEqualTo("[A].[TBL]"); + } + + @Test + void testGetQualifiedName_multiCharSchemaWithEscapePattern_escapesBothSegments() + { + final String qualifiedName = + new QualifiedTableName("MULTI.TBL", null, "[?]") + .getQualifiedName(); + assertThat(qualifiedName).isEqualTo("[MULTI].[TBL]"); + } + + @Test + void testGetQualifiedName_noDotWithEscapePattern_escapesSingleSegment() + { + final String qualifiedName = + new QualifiedTableName("TBL", "SCHEMA", "[?]") + .getQualifiedName(); + assertThat(qualifiedName).isEqualTo("[SCHEMA].[TBL]"); + } + + @Test + void testGetQualifiedName_singleCharMiddleSegmentWithEscapePattern_escapesEachRemainingSegment() + { + // Before the split > 0 fix, getEscapedName's recursive dot-split + // only triggered when the first segment was 2+ characters, so the + // single-character "X" segment here was escaped as one malformed + // token "[X.TABLE]" instead of two: "[X].[TABLE]". + final String qualifiedName = + new QualifiedTableName("CATALOG.X.TABLE", null, "[?]") + .getQualifiedName(); + assertThat(qualifiedName).isEqualTo("[X].[TABLE]"); + } + @Test void testConstructorWithNullTable_withNullTableName_throwsNullPointerException() { diff --git a/src/test/java/org/dbunit/util/SQLHelperTest.java b/src/test/java/org/dbunit/util/SQLHelperTest.java index 34d07ffea..66fc49cd3 100644 --- a/src/test/java/org/dbunit/util/SQLHelperTest.java +++ b/src/test/java/org/dbunit/util/SQLHelperTest.java @@ -39,6 +39,12 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.LoggerFactory; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; /** * @author Felipe Leme (dbunit@felipeal.net) @@ -48,7 +54,6 @@ @ExtendWith(MockitoExtension.class) class SQLHelperTest extends AbstractHSQLTestCase { - @Mock private Connection mockConnection; @@ -193,4 +198,78 @@ void testGetDatabaseInfoWithException_withExceptionOnVersion_returnsNotAvailable verify(mockDatabaseMetaData, times(1)).getDatabaseProductName(); verify(mockDatabaseMetaData, times(1)).getDatabaseMajorVersion(); } + + @Test + void testLogInfoIfValueChanged_valueChangedDebugEnabled_logsAtDebug() + { + final Logger sqlHelperLogger = + (Logger) LoggerFactory.getLogger(SQLHelper.class); + final ListAppender appender = new ListAppender<>(); + appender.start(); + sqlHelperLogger.addAppender(appender); + try + { + SQLHelper.logInfoIfValueChanged("old", "new", "message", + SQLHelperTest.class); + + assertThat(appender.list).as("Should log events for changes.").hasSize(1); + assertThat(appender.list.get(0).getLevel()) + .as("SQLHelper.logInfoIfValueChanged() must log its message" + + " at DEBUG level, matching the isDebugEnabled()" + + " guard it uses.") + .isEqualTo(Level.DEBUG); + } finally + { + sqlHelperLogger.detachAppender(appender); + } + } + + @Test + void testLogInfoIfValueChanged_valueUnchanged_logsNothing() + { + final Logger sqlHelperLogger = + (Logger) LoggerFactory.getLogger(SQLHelper.class); + final ListAppender appender = new ListAppender<>(); + appender.start(); + sqlHelperLogger.addAppender(appender); + try + { + SQLHelper.logInfoIfValueChanged("same", "same", "message", + SQLHelperTest.class); + + assertThat(appender.list) + .as("No logged events when value is unchanged.").isEmpty(); + } finally + { + sqlHelperLogger.detachAppender(appender); + } + } + + @Test + void testLogInfoIfValueChanged_oldValueNullNewValueNonNull_logsAtDebug() + { + final Logger sqlHelperLogger = + (Logger) LoggerFactory.getLogger(SQLHelper.class); + final ListAppender appender = new ListAppender<>(); + appender.start(); + sqlHelperLogger.addAppender(appender); + try + { + SQLHelper.logInfoIfValueChanged(null, "new", "message", + SQLHelperTest.class); + + assertThat(appender.list) + .as("A null-to-value change must still be logged, not" + + " silently skipped by the null-guard on oldValue.") + .hasSize(1); + assertThat(appender.list.get(0).getLevel()) + .as("SQLHelper.logInfoIfValueChanged() must log its message" + + " at DEBUG level, matching the isDebugEnabled()" + + " guard it uses.") + .isEqualTo(Level.DEBUG); + } finally + { + sqlHelperLogger.detachAppender(appender); + } + } } diff --git a/src/test/java/org/dbunit/util/xml/XmlWriterTest.java b/src/test/java/org/dbunit/util/xml/XmlWriterTest.java index 8e405c846..5ae555c1a 100644 --- a/src/test/java/org/dbunit/util/xml/XmlWriterTest.java +++ b/src/test/java/org/dbunit/util/xml/XmlWriterTest.java @@ -24,10 +24,17 @@ import static org.junit.jupiter.api.Assertions.fail; import java.io.ByteArrayOutputStream; +import java.io.StringReader; import java.io.StringWriter; import java.io.Writer; import java.nio.charset.StandardCharsets; +import org.dbunit.dataset.Column; +import org.dbunit.dataset.DefaultDataSet; +import org.dbunit.dataset.DefaultTable; +import org.dbunit.dataset.IDataSet; +import org.dbunit.dataset.datatype.DataType; +import org.dbunit.dataset.xml.XmlDataSet; import org.junit.jupiter.api.Test; /** @@ -207,6 +214,125 @@ void testNonAsciiValidXmlCharactersInAttributeValue_withCyrillicChars_writesChar assertThat(actualXml).isEqualTo(expectedXml); } + @Test + void testEscapeXml_withMarkupCharacters_escapesUnchanged() throws Exception + { + final String text = "a & b < c > d \" e ' f"; + final String expectedText = + "a & b < c > d " e ' f"; + final String expectedXml = "" + + expectedText + "\n"; + + final Writer writer = new StringWriter(); + final XmlWriter xmlWriter = new XmlWriter(writer); + xmlWriter.writeElement("COLUMN1"); + xmlWriter.writeAttribute("ATTR", text); + xmlWriter.writeText(text); + xmlWriter.endElement(); + xmlWriter.close(); + + assertThat(writer.toString()) + .as("Markup characters must still be entity-escaped the same" + + " way after routing escapeXml() through code points.") + .isEqualTo(expectedXml); + } + + @Test + void testWriteText_controlCharacter_replacedWithReplacementChar() + throws Exception + { + final String replacementChar = String.valueOf((char) 0xFFFD); + final String controlChar = String.valueOf((char) 0x01); + final String col0 = "COL0"; + final Column[] columns = {new Column(col0, DataType.UNKNOWN)}; + final DefaultTable table = new DefaultTable("TABLE1", columns); + table.addRow(); + table.setValue(0, col0, "before" + controlChar + "after"); + final IDataSet dataSet = new DefaultDataSet(table); + + final StringWriter out = new StringWriter(); + XmlDataSet.write(dataSet, out); + final String xml = out.toString(); + + assertThat(xml) + .as("A control character not representable in XML 1.0 must" + + " be replaced with U+FFFD instead of written raw.") + .contains("before" + replacementChar + "after") + .doesNotContain(controlChar); + + final IDataSet reread = new XmlDataSet(new StringReader(xml)); + assertThat(reread.getTable("TABLE1").getValue(0, col0)) + .as("The exported document must still be well-formed and" + + " re-parseable, recovering the replacement" + + " character.") + .isEqualTo("before" + replacementChar + "after"); + } + + @Test + void testWriteText_unpairedSurrogate_replacedWithReplacementChar() + throws Exception + { + final String replacementChar = String.valueOf((char) 0xFFFD); + final String loneHighSurrogate = String.valueOf((char) 0xD800); + final String col0 = "COL0"; + final Column[] columns = {new Column(col0, DataType.UNKNOWN)}; + final DefaultTable table = new DefaultTable("TABLE1", columns); + table.addRow(); + table.setValue(0, col0, "before" + loneHighSurrogate + "after"); + final IDataSet dataSet = new DefaultDataSet(table); + + final StringWriter out = new StringWriter(); + XmlDataSet.write(dataSet, out); + final String xml = out.toString(); + + assertThat(xml) + .as("A high surrogate with no matching low surrogate is not" + + " representable in XML 1.0 (escapeXml() iterates" + + " by code point, and codePointAt() returns an" + + " unpaired surrogate as its own \"code point\")," + + " so it must be replaced with U+FFFD instead of" + + " written raw or as a numeric entity to a" + + " forbidden surrogate code point.") + .contains("before" + replacementChar + "after") + .doesNotContain(loneHighSurrogate); + + final IDataSet reread = new XmlDataSet(new StringReader(xml)); + assertThat(reread.getTable("TABLE1").getValue(0, col0)) + .as("The exported document must still be well-formed and" + + " re-parseable, recovering the replacement" + + " character.") + .isEqualTo("before" + replacementChar + "after"); + } + + @Test + void testEscapeXml_withAdjacentControlAndMarkupCharacters_bothSubstitutedCorrectly() + throws Exception + { + final String replacementChar = String.valueOf((char) 0xFFFD); + final String controlChar = String.valueOf((char) 0x01); + // The control char and '&' are adjacent, back-to-back substitutions + // with nothing in between - exactly where index/last bookkeeping + // bugs in escapeXml()'s buffer-copying loop would hide + final String text = "a" + controlChar + "&b"; + final String expectedText = "a" + replacementChar + "&b"; + final String expectedXml = + "" + expectedText + "\n"; + + final Writer writer = new StringWriter(); + final XmlWriter xmlWriter = new XmlWriter(writer); + xmlWriter.writeElement("COLUMN1"); + xmlWriter.writeText(text); + xmlWriter.endElement(); + xmlWriter.close(); + + assertThat(writer.toString()) + .as("Two adjacent substitutions (a replaced control" + + " character immediately followed by an escaped" + + " markup character) must both be applied, with" + + " nothing dropped or duplicated between them.") + .isEqualTo(expectedXml); + } + @Test void testClose_bufferedUnderlyingWriter_flushesAllContent() throws Exception { diff --git a/src/test/resources/sql/hypersonic.sql b/src/test/resources/sql/hypersonic.sql index 43429f8a5..be6605281 100644 --- a/src/test/resources/sql/hypersonic.sql +++ b/src/test/resources/sql/hypersonic.sql @@ -71,3 +71,10 @@ CREATE TABLE IDENTITY_TABLE CREATE TABLE TEST_IDENTITY_NOT_PK (COL01 INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL, COL02 VARCHAR(64)); + +----------------------------------------------------------------------------- +-- DEFAULT_VALUE_TABLE +----------------------------------------------------------------------------- +CREATE TABLE DEFAULT_VALUE_TABLE + (ID INTEGER NOT NULL PRIMARY KEY, + STATUS VARCHAR(20) DEFAULT 'PENDING' NOT NULL);