From 7e1eb9c0fbc13b29d1afcaf76a4eac189623c786 Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Sat, 1 Aug 2026 13:55:17 -0500 Subject: [PATCH 1/8] fix(database): Apply escape pattern in AbstractDatabaseConnection.getRowCount() getRowCount(String, String) built its QualifiedTableName with the 2-arg constructor, which never applies DatabaseConfig.PROPERTY_ESCAPE_PATTERN, so a configured escape pattern was silently dropped from the generated SQL. createTable() already looks up and applies the escape pattern; getRowCount() now does the same. Refs: 492 --- src/changes/changes.xml | 3 + .../database/AbstractDatabaseConnection.java | 7 +- .../AbstractDatabaseConnectionTest.java | 92 +++++++++++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 src/test/java/org/dbunit/database/AbstractDatabaseConnectionTest.java diff --git a/src/changes/changes.xml b/src/changes/changes.xml index ca671d249..e1e02f0ca 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -126,6 +126,9 @@ Update github_actions dependency actions/setup-java from 5 to 5.6.0 (#879). + + Fix AbstractDatabaseConnection.getRowCount(String, String) ignoring DatabaseConfig.PROPERTY_ESCAPE_PATTERN, mirroring the escape-pattern handling createTable() already applies, so a configured escape pattern is no longer silently dropped from the generated "select count(*)" SQL. + diff --git a/src/main/java/org/dbunit/database/AbstractDatabaseConnection.java b/src/main/java/org/dbunit/database/AbstractDatabaseConnection.java index 987418607..9e6533452 100644 --- a/src/main/java/org/dbunit/database/AbstractDatabaseConnection.java +++ b/src/main/java/org/dbunit/database/AbstractDatabaseConnection.java @@ -160,9 +160,12 @@ public int getRowCount(String tableName, String whereClause) final StringBuilder sqlBuffer = new StringBuilder(128); sqlBuffer.append("select count(*) from "); + String escapePattern = (String) getConfig() + .getProperty(DatabaseConfig.PROPERTY_ESCAPE_PATTERN); + // add table name and schema (schema only if available) - QualifiedTableName qualifiedTableName = - new QualifiedTableName(tableName, this.getSchema()); + QualifiedTableName qualifiedTableName = new QualifiedTableName( + tableName, this.getSchema(), escapePattern); String qualifiedName = qualifiedTableName.getQualifiedName(); sqlBuffer.append(qualifiedName); if (whereClause != null) diff --git a/src/test/java/org/dbunit/database/AbstractDatabaseConnectionTest.java b/src/test/java/org/dbunit/database/AbstractDatabaseConnectionTest.java new file mode 100644 index 000000000..4371fd843 --- /dev/null +++ b/src/test/java/org/dbunit/database/AbstractDatabaseConnectionTest.java @@ -0,0 +1,92 @@ +/* + * + * 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., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +package org.dbunit.database; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +/** + * Unit tests for {@link AbstractDatabaseConnection}, run against a {@link DatabaseConnection} + * wrapping a Mockito-mocked JDBC {@link Connection} so the emitted SQL can be captured without a + * real database. + */ +class AbstractDatabaseConnectionTest +{ + @Test + void testGetRowCount_withEscapePatternConfigured_escapesQualifiedTableName() + throws Exception + { + final ResultSet resultSet = mock(ResultSet.class); + when(resultSet.next()).thenReturn(true); + when(resultSet.getInt(1)).thenReturn(6); + final Statement statement = mock(Statement.class); + when(statement.executeQuery(anyString())).thenReturn(resultSet); + final Connection jdbcConnection = mock(Connection.class); + when(jdbcConnection.createStatement()).thenReturn(statement); + + final IDatabaseConnection connection = new DatabaseConnection(jdbcConnection); + connection.getConfig().setProperty(DatabaseConfig.PROPERTY_ESCAPE_PATTERN, "\"?\""); + + final int rowCount = connection.getRowCount("MY_TABLE"); + + assertThat(rowCount).as("Row count read from the mocked result set.").isEqualTo(6); + final ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + verify(statement).executeQuery(sqlCaptor.capture()); + assertThat(sqlCaptor.getValue()) + .as("The emitted SQL must contain the escaped, quoted table name.") + .contains("\"MY_TABLE\""); + } + + @Test + void testGetRowCount_withNoEscapePatternConfigured_usesPlainQualifiedTableName() + throws Exception + { + final ResultSet resultSet = mock(ResultSet.class); + when(resultSet.next()).thenReturn(true); + when(resultSet.getInt(1)).thenReturn(0); + final Statement statement = mock(Statement.class); + when(statement.executeQuery(anyString())).thenReturn(resultSet); + final Connection jdbcConnection = mock(Connection.class); + when(jdbcConnection.createStatement()).thenReturn(statement); + + final IDatabaseConnection connection = new DatabaseConnection(jdbcConnection); + + connection.getRowCount("MY_TABLE"); + + final ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + verify(statement).executeQuery(sqlCaptor.capture()); + assertThat(sqlCaptor.getValue()) + .as("Without a configured escape pattern the table name must stay unescaped.") + .contains("select count(*) from MY_TABLE") + .doesNotContain("\"MY_TABLE\""); + } +} From ecfae548bb1d629a66f0637ab0e1206a86b51aa0 Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Sat, 1 Aug 2026 13:58:40 -0500 Subject: [PATCH 2/8] fix(database): Fall back to DB primary key when filter matches no columns DatabaseTableMetaData.getPrimaryKeys() returned an empty array whenever a configured PROPERTY_PRIMARY_KEY_FILTER matched zero columns for a table (e.g. a naming-convention filter that doesn't recognize that table's PK column), instead of falling back to the table's actual database-declared primary key. Add that fallback, matching the no-filter-configured branch's existing behavior. Behavior change: this makes the fallback unconditional whenever a filter yields zero columns, which now overrides a filter deliberately configured to declare that a table has no PK. Refs: 628 --- src/changes/changes.xml | 3 ++ .../database/DatabaseTableMetaData.java | 4 +++ .../database/DatabaseTableMetaDataIT.java | 31 +++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/src/changes/changes.xml b/src/changes/changes.xml index e1e02f0ca..9e8c713aa 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -129,6 +129,9 @@ Fix AbstractDatabaseConnection.getRowCount(String, String) ignoring DatabaseConfig.PROPERTY_ESCAPE_PATTERN, mirroring the escape-pattern handling createTable() already applies, so a configured escape pattern is no longer silently dropped from the generated "select count(*)" SQL. + + Fix DatabaseTableMetaData.getPrimaryKeys() returning an empty array whenever a configured PROPERTY_PRIMARY_KEY_FILTER matches zero columns for a table, instead of falling back to the table's database-declared primary key. Note: this makes the fallback unconditional whenever a filter yields zero columns for a table, which now overrides a filter deliberately configured to declare "this table has no PK". + diff --git a/src/main/java/org/dbunit/database/DatabaseTableMetaData.java b/src/main/java/org/dbunit/database/DatabaseTableMetaData.java index 36de68d8e..9aa7be0fe 100644 --- a/src/main/java/org/dbunit/database/DatabaseTableMetaData.java +++ b/src/main/java/org/dbunit/database/DatabaseTableMetaData.java @@ -382,6 +382,10 @@ public Column[] getPrimaryKeys() throws DataSetException if (primaryKeysFilter != null) { _primaryKeys = Columns.getColumns(getTableName(), getColumns(), primaryKeysFilter); + if (_primaryKeys.length == 0) { + String[] pkNames = getPrimaryKeyNames(); + _primaryKeys = Columns.getColumns(pkNames, getColumns()); + } } else { String[] pkNames = getPrimaryKeyNames(); _primaryKeys = Columns.getColumns(pkNames, getColumns()); diff --git a/src/test/java/org/dbunit/database/DatabaseTableMetaDataIT.java b/src/test/java/org/dbunit/database/DatabaseTableMetaDataIT.java index 1625fa366..0b8b833cd 100644 --- a/src/test/java/org/dbunit/database/DatabaseTableMetaDataIT.java +++ b/src/test/java/org/dbunit/database/DatabaseTableMetaDataIT.java @@ -47,6 +47,7 @@ import org.dbunit.dataset.datatype.DataTypeException; import org.dbunit.dataset.datatype.DefaultDataTypeFactory; import org.dbunit.dataset.datatype.IDataTypeFactory; +import org.dbunit.dataset.filter.IColumnFilter; import org.dbunit.testutil.TestUtils; import org.junit.jupiter.api.Test; @@ -116,6 +117,36 @@ void testGetPrimaryKeys_withTableHavingNoPk_returnsEmptyArray() throws Exception assertThat(columns).as("pk count").isEmpty(); } + @Test + void testGetPrimaryKeys_withFilterYieldingNoColumns_fallsBackToDatabaseDeclaredPrimaryKeys() + throws Exception + { + final String tableName = "PK_TABLE"; + final String[] expected = {"PK0", "PK1", "PK2"}; + final IColumnFilter noMatchFilter = (filterTableName, column) -> false; + + _connection.getConfig().setProperty( + DatabaseConfig.PROPERTY_PRIMARY_KEY_FILTER, noMatchFilter); + try + { + final ITableMetaData metaData = + createDataSet().getTableMetaData(tableName); + final Column[] columns = metaData.getPrimaryKeys(); + + assertThat(columns).as("pk count").hasSize(expected.length); + for (int i = 0; i < columns.length; i++) + { + assertThat(columns[i].getColumnName()).as("name") + .isEqualTo(convertString(expected[i])); + } + } + finally + { + _connection.getConfig().setProperty( + DatabaseConfig.PROPERTY_PRIMARY_KEY_FILTER, null); + } + } + @Test void testCreation_withUnknownTable_throwsNoSuchTableException() throws Exception { From 66036ec087b3710f1f147303782327b5bc5c2085 Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Sat, 1 Aug 2026 14:04:13 -0500 Subject: [PATCH 3/8] fix(database): Query database info before opening ResultSet in DatabaseDataSet.initialize() The debug-only SQLHelper.getDatabaseInfo() call ran after metadataHandler.getTables() had already opened the table metadata ResultSet. On JDBC drivers that allow only a single active cursor per connection (e.g. SQL Anywhere, some legacy Sybase drivers), those intervening DatabaseMetaData calls can silently invalidate the still-open ResultSet. Move the call above ResultSet creation, reusing the already-held databaseMetaData variable instead of calling jdbcConnection.getMetaData() again. None of this project's 9 CI database profiles have the single-cursor constraint, so this was not reproducible locally; verified instead via a mocked IMetadataHandler/ DatabaseMetaData test asserting call order. Refs: 460 --- src/changes/changes.xml | 3 + .../org/dbunit/database/DatabaseDataSet.java | 6 +- .../dbunit/database/DatabaseDataSetTest.java | 57 +++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/changes/changes.xml b/src/changes/changes.xml index 9e8c713aa..fb352e7b9 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -132,6 +132,9 @@ Fix DatabaseTableMetaData.getPrimaryKeys() returning an empty array whenever a configured PROPERTY_PRIMARY_KEY_FILTER matches zero columns for a table, instead of falling back to the table's database-declared primary key. Note: this makes the fallback unconditional whenever a filter yields zero columns for a table, which now overrides a filter deliberately configured to declare "this table has no PK". + + Fix DatabaseDataSet.initialize() querying the driver/database info for debug logging after already opening the table metadata ResultSet instead of before, which could silently invalidate that still-open ResultSet on JDBC drivers that allow only a single active cursor per connection (e.g. SQL Anywhere, some legacy Sybase drivers). + diff --git a/src/main/java/org/dbunit/database/DatabaseDataSet.java b/src/main/java/org/dbunit/database/DatabaseDataSet.java index be5854835..89547043a 100644 --- a/src/main/java/org/dbunit/database/DatabaseDataSet.java +++ b/src/main/java/org/dbunit/database/DatabaseDataSet.java @@ -204,11 +204,15 @@ private void initialize(String schema) throws DataSetException String[] tableType = (String[])config.getProperty(DatabaseConfig.PROPERTY_TABLE_TYPE); IMetadataHandler metadataHandler = (IMetadataHandler) config.getProperty(DatabaseConfig.PROPERTY_METADATA_HANDLER); + if(logger.isDebugEnabled()) + { + logger.debug(SQLHelper.getDatabaseInfo(databaseMetaData)); + } + ResultSet resultSet = metadataHandler.getTables(databaseMetaData, schema, tableType); if(logger.isDebugEnabled()) { - logger.debug(SQLHelper.getDatabaseInfo(jdbcConnection.getMetaData())); logger.debug("metadata resultset={}", resultSet); } diff --git a/src/test/java/org/dbunit/database/DatabaseDataSetTest.java b/src/test/java/org/dbunit/database/DatabaseDataSetTest.java index d80853578..0832f3623 100644 --- a/src/test/java/org/dbunit/database/DatabaseDataSetTest.java +++ b/src/test/java/org/dbunit/database/DatabaseDataSetTest.java @@ -24,12 +24,17 @@ 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.inOrder; +import static org.mockito.Mockito.mock; 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 static org.mockito.Mockito.when; +import java.sql.Connection; import java.sql.DatabaseMetaData; +import java.sql.ResultSet; import java.sql.Statement; import org.dbunit.dataset.ITableMetaData; @@ -37,6 +42,11 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.slf4j.LoggerFactory; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; /** * Unit tests for {@link DatabaseDataSet}, using a real in-memory H2 database @@ -107,4 +117,51 @@ void testGetTableMetaData_withUnknownTable_throwsNoSuchTableException() + " re-validates existence itself.") .isThrownBy(() -> dataSet.getTableMetaData("UNKNOWN_TABLE")); } + + @Test + void testInitialize_withDebugLoggingEnabled_queriesDatabaseInfoBeforeCreatingResultSet() + throws Exception + { + final Logger dataSetLogger = + (Logger) LoggerFactory.getLogger(DatabaseDataSet.class); + final Level originalLevel = dataSetLogger.getLevel(); + dataSetLogger.setLevel(Level.DEBUG); + try + { + final DatabaseMetaData databaseMetaData = mock(DatabaseMetaData.class); + final Connection jdbcConnection = mock(Connection.class); + when(jdbcConnection.getMetaData()).thenReturn(databaseMetaData); + + final ResultSet tablesResultSet = mock(ResultSet.class); + final IMetadataHandler metadataHandler = mock(IMetadataHandler.class); + when(metadataHandler.getTables(any(DatabaseMetaData.class), anyString(), + any())).thenReturn(tablesResultSet); + + final DatabaseConfig config = new DatabaseConfig(); + config.setProperty(DatabaseConfig.PROPERTY_METADATA_HANDLER, + metadataHandler); + + final IDatabaseConnection mockedConnection = mock(IDatabaseConnection.class); + when(mockedConnection.getConnection()).thenReturn(jdbcConnection); + when(mockedConnection.getConfig()).thenReturn(config); + when(mockedConnection.getSchema()).thenReturn(SCHEMA_NAME); + + final DatabaseDataSet dataSet = new DatabaseDataSet(mockedConnection, true); + + dataSet.getTableNames(); + + // SQLHelper.getDatabaseInfo() must run, and thus query the driver + // name, before the metadata ResultSet is created below - not + // after, where intervening DatabaseMetaData calls could + // invalidate an already-open ResultSet on single-cursor drivers. + final InOrder order = inOrder(databaseMetaData, metadataHandler); + order.verify(databaseMetaData).getDriverName(); + order.verify(metadataHandler).getTables(any(DatabaseMetaData.class), + anyString(), any()); + } + finally + { + dataSetLogger.setLevel(originalLevel); + } + } } From e2f1ec2102211c540165df40c26267c9e8741821 Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Sat, 1 Aug 2026 14:12:40 -0500 Subject: [PATCH 4/8] feat(database): Add opt-in all-column sort for tables without a primary key DatabaseDataSet.getSelectStatement() emitted no ORDER BY clause for a table with no primary key, leaving row order database-defined and thus nondeterministic. Add DatabaseConfig.FEATURE_SORT_ALL_COLUMNS_WHEN_NO_PRIMARY_KEY, defaulting to off to preserve existing behavior; when enabled, such a table is sorted by all of its non-LOB columns instead. CLOB/BLOB columns are always excluded from that fallback sort since some databases (notably Oracle) reject LOB columns in ORDER BY outright. Refs: 171 --- src/changes/changes.xml | 3 + .../database/AbstractResultSetTable.java | 11 ++- .../org/dbunit/database/DatabaseConfig.java | 4 ++ .../org/dbunit/database/DatabaseDataSet.java | 48 +++++++++++-- src/site/asciidoc/properties.adoc | 5 ++ .../dbunit/database/DatabaseDataSetIT.java | 71 +++++++++++++++++++ 6 files changed, 135 insertions(+), 7 deletions(-) diff --git a/src/changes/changes.xml b/src/changes/changes.xml index fb352e7b9..4dd35ae2a 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -135,6 +135,9 @@ Fix DatabaseDataSet.initialize() querying the driver/database info for debug logging after already opening the table metadata ResultSet instead of before, which could silently invalidate that still-open ResultSet on JDBC drivers that allow only a single active cursor per connection (e.g. SQL Anywhere, some legacy Sybase drivers). + + Add opt-in DatabaseConfig.FEATURE_SORT_ALL_COLUMNS_WHEN_NO_PRIMARY_KEY, defaulting to off. When enabled, a table with no primary key is selected with an ORDER BY over all of its non-LOB columns instead of leaving row order database-defined (and thus nondeterministic); CLOB/BLOB columns are always excluded from that fallback sort since some databases (notably Oracle) reject LOB columns in ORDER BY. + diff --git a/src/main/java/org/dbunit/database/AbstractResultSetTable.java b/src/main/java/org/dbunit/database/AbstractResultSetTable.java index 94cb8741e..439b95456 100644 --- a/src/main/java/org/dbunit/database/AbstractResultSetTable.java +++ b/src/main/java/org/dbunit/database/AbstractResultSetTable.java @@ -132,11 +132,14 @@ protected AbstractResultSetTable(ITableMetaData metaData, String escapePattern = (String) connection.getConfig() .getProperty(DatabaseConfig.PROPERTY_ESCAPE_PATTERN); + boolean sortAllColumnsWhenNoPrimaryKey = connection.getConfig().getFeature( + DatabaseConfig.FEATURE_SORT_ALL_COLUMNS_WHEN_NO_PRIMARY_KEY); try { String schema = connection.getSchema(); - String selectStatement = getSelectStatement(schema, metaData, escapePattern); + String selectStatement = getSelectStatement(schema, metaData, escapePattern, + sortAllColumnsWhenNoPrimaryKey); if (logger.isDebugEnabled()) { @@ -181,10 +184,12 @@ protected Statement createStatement(IDatabaseConnection connection, int resultSe return stmt; } - static String getSelectStatement(String schema, ITableMetaData metaData, String escapePattern) + static String getSelectStatement(String schema, ITableMetaData metaData, String escapePattern, + boolean sortAllColumnsWhenNoPrimaryKey) throws DataSetException { - return DatabaseDataSet.getSelectStatement(schema, metaData, escapePattern); + return DatabaseDataSet.getSelectStatement(schema, metaData, escapePattern, + sortAllColumnsWhenNoPrimaryKey); } //////////////////////////////////////////////////////////////////////////// diff --git a/src/main/java/org/dbunit/database/DatabaseConfig.java b/src/main/java/org/dbunit/database/DatabaseConfig.java index e7595f0a0..bf466166f 100644 --- a/src/main/java/org/dbunit/database/DatabaseConfig.java +++ b/src/main/java/org/dbunit/database/DatabaseConfig.java @@ -88,6 +88,8 @@ public class DatabaseConfig "http://www.dbunit.org/features/skipOracleRecycleBinTables"; public static final String FEATURE_ALLOW_EMPTY_FIELDS = "http://www.dbunit.org/features/allowEmptyFields"; + public static final String FEATURE_SORT_ALL_COLUMNS_WHEN_NO_PRIMARY_KEY = + "http://www.dbunit.org/features/sortAllColumnsWhenNoPrimaryKey"; /** * A list of all properties as {@link ConfigProperty} objects. @@ -111,6 +113,7 @@ public class DatabaseConfig new ConfigProperty(FEATURE_SKIP_ORACLE_RECYCLEBIN_TABLES, Boolean.class, false), new ConfigProperty(FEATURE_ALLOW_EMPTY_FIELDS, Boolean.class, false), new ConfigProperty(PROPERTY_ALLOW_VERIFYTABLEDEFINITION_EXPECTEDTABLE_COUNT_MISMATCH, Boolean.class, false), + new ConfigProperty(FEATURE_SORT_ALL_COLUMNS_WHEN_NO_PRIMARY_KEY, Boolean.class, false), }; /** @@ -150,6 +153,7 @@ public DatabaseConfig() setFeature(FEATURE_CASE_SENSITIVE_TABLE_NAMES, false); setFeature(FEATURE_DATATYPE_WARNING, true); setFeature(FEATURE_ALLOW_EMPTY_FIELDS, false); + setFeature(FEATURE_SORT_ALL_COLUMNS_WHEN_NO_PRIMARY_KEY, false); setProperty(PROPERTY_STATEMENT_FACTORY, PREPARED_STATEMENT_FACTORY); setProperty(PROPERTY_RESULTSET_TABLE_FACTORY, RESULT_SET_TABLE_FACTORY); diff --git a/src/main/java/org/dbunit/database/DatabaseDataSet.java b/src/main/java/org/dbunit/database/DatabaseDataSet.java index 89547043a..e34b73f1e 100644 --- a/src/main/java/org/dbunit/database/DatabaseDataSet.java +++ b/src/main/java/org/dbunit/database/DatabaseDataSet.java @@ -25,7 +25,9 @@ import java.sql.DatabaseMetaData; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.ArrayList; import java.util.HashSet; +import java.util.List; import java.util.Locale; import org.dbunit.DatabaseUnitRuntimeException; import org.dbunit.dataset.AbstractDataSet; @@ -38,6 +40,7 @@ import org.dbunit.dataset.ITableMetaData; import org.dbunit.dataset.NoSuchTableException; import org.dbunit.dataset.OrderedTableNameMap; +import org.dbunit.dataset.datatype.DataType; import org.dbunit.dataset.filter.ITableFilterSimple; import org.dbunit.util.QualifiedTableName; import org.dbunit.util.SQLHelper; @@ -115,11 +118,19 @@ public DatabaseDataSet(IDatabaseConnection connection, boolean caseSensitiveTabl static String getSelectStatement(String schema, ITableMetaData metaData, String escapePattern) throws DataSetException + { + return getSelectStatement(schema, metaData, escapePattern, false); + } + + static String getSelectStatement(String schema, ITableMetaData metaData, String escapePattern, + boolean sortAllColumnsWhenNoPrimaryKey) + throws DataSetException { if (logger.isDebugEnabled()) { - logger.debug("getSelectStatement(schema={}, metaData={}, escapePattern={}) - start", - schema, metaData, escapePattern); + logger.debug("getSelectStatement(schema={}, metaData={}, escapePattern={}, " + + "sortAllColumnsWhenNoPrimaryKey={}) - start", + schema, metaData, escapePattern, sortAllColumnsWhenNoPrimaryKey); } Column[] columns = metaData.getColumns(); @@ -130,6 +141,12 @@ static String getSelectStatement(String schema, ITableMetaData metaData, String "Cannot load data for " + metaData); } + Column[] sortColumns = primaryKeys; + if (sortColumns.length == 0 && sortAllColumnsWhenNoPrimaryKey) + { + sortColumns = nonLobColumns(columns); + } + // select final StringBuilder sqlBuffer = new StringBuilder(128); sqlBuffer.append("select "); @@ -150,7 +167,7 @@ static String getSelectStatement(String schema, ITableMetaData metaData, String metaData.getTableName(), schema, escapePattern).getQualifiedName()); // order by - for (int i = 0; i < primaryKeys.length; i++) + for (int i = 0; i < sortColumns.length; i++) { if (i == 0) { @@ -160,13 +177,36 @@ static String getSelectStatement(String schema, ITableMetaData metaData, String { sqlBuffer.append(", "); } - sqlBuffer.append(new QualifiedTableName(primaryKeys[i].getColumnName(), null, escapePattern).getQualifiedName()); + sqlBuffer.append(new QualifiedTableName(sortColumns[i].getColumnName(), null, escapePattern).getQualifiedName()); } return sqlBuffer.toString(); } + /** + * Filters out CLOB/BLOB columns, which many databases (notably Oracle) reject in an + * {@code ORDER BY} clause. + * + * @param columns The columns to filter. + * @return A new array containing every column from columns whose data type is + * not {@link DataType#CLOB} or {@link DataType#BLOB}. + */ + private static Column[] nonLobColumns(Column[] columns) + { + List nonLobColumns = new ArrayList(columns.length); + for (int i = 0; i < columns.length; i++) + { + Column column = columns[i]; + DataType dataType = column.getDataType(); + if (dataType != DataType.CLOB && dataType != DataType.BLOB) + { + nonLobColumns.add(column); + } + } + return nonLobColumns.toArray(new Column[0]); + } + /** * Get all the table names form the database that are not system tables. */ diff --git a/src/site/asciidoc/properties.adoc b/src/site/asciidoc/properties.adoc index 754d0fc7b..aa6f9ca9c 100644 --- a/src/site/asciidoc/properties.adoc +++ b/src/site/asciidoc/properties.adoc @@ -70,6 +70,11 @@ _Note:_ this feature was not compatible with the < |http://www.dbunit.org/features/allowEmptyFields |false |Allow to call INSERT/UPDATE with empty strings (''). + +|anchor:sortallcolumnswhennoprimarykey[]`FEATURE_SORT_ALL_COLUMNS_WHEN_NO_PRIMARY_KEY` +|http://www.dbunit.org/features/sortAllColumnsWhenNoPrimaryKey +|false +|When a table has no primary key, sort its `SELECT` by every non-LOB column instead of leaving row order database-defined (and thus nondeterministic). CLOB/BLOB columns are always excluded from the sort even when this feature is enabled, since some databases (notably Oracle) reject LOB columns in `ORDER BY`. |=== == Properties diff --git a/src/test/java/org/dbunit/database/DatabaseDataSetIT.java b/src/test/java/org/dbunit/database/DatabaseDataSetIT.java index 72f1984f4..aba533605 100644 --- a/src/test/java/org/dbunit/database/DatabaseDataSetIT.java +++ b/src/test/java/org/dbunit/database/DatabaseDataSetIT.java @@ -30,6 +30,7 @@ import org.dbunit.dataset.DataSetException; import org.dbunit.dataset.DefaultTableMetaData; import org.dbunit.dataset.IDataSet; +import org.dbunit.dataset.ITable; import org.dbunit.dataset.ITableMetaData; import org.dbunit.dataset.NoSuchTableException; import org.dbunit.dataset.datatype.DataType; @@ -177,6 +178,76 @@ void testGetSelectStatementWithPrimaryKeys_withPrimaryKeyColumns_appendsOrderByC assertThat(sql).as("select statement").isEqualTo(expected); } + @Test + void testGetSelectStatement_withNoPrimaryKeyAndSortFeatureDisabled_omitsOrderByClause() + throws Exception + { + final String schemaName = "schema"; + final String tableName = "table"; + final Column[] columns = new Column[] {new Column("c1", DataType.UNKNOWN), + new Column("c2", DataType.UNKNOWN),}; + final String expected = "select c1, c2 from schema.table"; + + final ITableMetaData metaData = new DefaultTableMetaData(tableName, columns); + final String sql = + DatabaseDataSet.getSelectStatement(schemaName, metaData, null, false); + assertThat(sql).as("select statement").isEqualTo(expected); + } + + @Test + void testGetSelectStatement_withNoPrimaryKeyAndSortFeatureEnabled_sortsByAllColumns() + throws Exception + { + final String schemaName = "schema"; + final String tableName = "table"; + final Column[] columns = new Column[] {new Column("c1", DataType.UNKNOWN), + new Column("c2", DataType.UNKNOWN),}; + final String expected = "select c1, c2 from schema.table order by c1, c2"; + + final ITableMetaData metaData = new DefaultTableMetaData(tableName, columns); + final String sql = + DatabaseDataSet.getSelectStatement(schemaName, metaData, null, true); + assertThat(sql).as("select statement").isEqualTo(expected); + } + + @Test + void testGetSelectStatement_withNoPrimaryKeyLobColumnAndSortFeatureEnabled_excludesLobColumnsFromOrderBy() + throws Exception + { + final String schemaName = "schema"; + final String tableName = "table"; + final Column[] columns = new Column[] {new Column("c1", DataType.UNKNOWN), + new Column("blob_col", DataType.BLOB), + new Column("clob_col", DataType.CLOB), + new Column("c2", DataType.UNKNOWN),}; + final String expected = + "select c1, blob_col, clob_col, c2 from schema.table order by c1, c2"; + + final ITableMetaData metaData = new DefaultTableMetaData(tableName, columns); + final String sql = + DatabaseDataSet.getSelectStatement(schemaName, metaData, null, true); + assertThat(sql).as("select statement").isEqualTo(expected); + } + + @Test + void testGetTable_withNoPrimaryKeyTableAndSortFeatureEnabled_executesWithoutSqlError() + throws Exception + { + // TEST_TABLE has no primary key. Its row count is not asserted here + // since it depends on fixture state shared with other IT classes; + // this test only confirms the ORDER BY clause the enabled feature + // adds does not itself cause a SQLException. + final String tableName = "TEST_TABLE"; + final IDatabaseConnection connection = new DatabaseConnection( + _connection.getConnection(), _connection.getSchema()); + connection.getConfig().setFeature( + DatabaseConfig.FEATURE_SORT_ALL_COLUMNS_WHEN_NO_PRIMARY_KEY, true); + + final ITable table = connection.createDataSet().getTable(tableName); + + assertThat(table.getRowCount()).as("row count").isGreaterThanOrEqualTo(0); + } + @Test void testGetQualifiedTableNames_withQualifiedNamesEnabled_returnsPrefixedTableNames() throws Exception { From b4346e0771e5d8bbfc8fb71fa0c720c16004a1f0 Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Sat, 1 Aug 2026 14:16:37 -0500 Subject: [PATCH 5/8] fix(dataset): Emit valid DTD ANY content model for an empty dataset FlatDtdWriter.write() emitted "" for a zero-table dataset - an empty parenthesized content model, which is not valid DTD syntax. FlatDtdProducer then failed reading it back with a SAXParseException ("A '(' character or an element type is required..."). Emit "" instead when there are no tables. ANY is preferred over EMPTY because a pretty-printed empty dataset file typically contains whitespace between and , and EMPTY rejects any content including whitespace, while ANY tolerates both. Refs: 542 --- src/changes/changes.xml | 3 +++ .../org/dbunit/dataset/xml/FlatDtdWriter.java | 19 +++++++++++---- .../dataset/xml/FlatDtdProducerTest.java | 24 +++++++++++++++++++ .../dbunit/dataset/xml/FlatDtdWriterTest.java | 13 ++++++++++ 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/src/changes/changes.xml b/src/changes/changes.xml index 4dd35ae2a..39cd3ab08 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -138,6 +138,9 @@ Add opt-in DatabaseConfig.FEATURE_SORT_ALL_COLUMNS_WHEN_NO_PRIMARY_KEY, defaulting to off. When enabled, a table with no primary key is selected with an ORDER BY over all of its non-LOB columns instead of leaving row order database-defined (and thus nondeterministic); CLOB/BLOB columns are always excluded from that fallback sort since some databases (notably Oracle) reject LOB columns in ORDER BY. + + Fix FlatDtdWriter.write() emitting an invalid, unparseable "<!ELEMENT dataset (\n)>" DTD content model for a zero-table dataset. It now emits "<!ELEMENT dataset ANY>" instead, which FlatDtdProducer already parses correctly and which, unlike EMPTY, still tolerates the whitespace a pretty-printed empty dataset file typically contains. + diff --git a/src/main/java/org/dbunit/dataset/xml/FlatDtdWriter.java b/src/main/java/org/dbunit/dataset/xml/FlatDtdWriter.java index a463906c0..d41d9852a 100644 --- a/src/main/java/org/dbunit/dataset/xml/FlatDtdWriter.java +++ b/src/main/java/org/dbunit/dataset/xml/FlatDtdWriter.java @@ -69,12 +69,23 @@ public void write(IDataSet dataSet) throws DataSetException String[] tableNames = dataSet.getTableNames(); // dataset element - printOut.print("" typically contains, + // unlike EMPTY which rejects any content at all. + printOut.print("\n"); + } + else { - _contentModel.write(printOut, tableNames[i], i, tableNames.length); + printOut.print("\n"); } - printOut.print(")>\n"); printOut.print("\n"); // tables diff --git a/src/test/java/org/dbunit/dataset/xml/FlatDtdProducerTest.java b/src/test/java/org/dbunit/dataset/xml/FlatDtdProducerTest.java index 21fbfb683..3773b625a 100644 --- a/src/test/java/org/dbunit/dataset/xml/FlatDtdProducerTest.java +++ b/src/test/java/org/dbunit/dataset/xml/FlatDtdProducerTest.java @@ -26,9 +26,11 @@ import java.io.File; import java.io.FileInputStream; import java.io.StringReader; +import java.io.StringWriter; import org.dbunit.dataset.Column; import org.dbunit.dataset.DataSetException; +import org.dbunit.dataset.DefaultDataSet; import org.dbunit.dataset.stream.AbstractProducerTest; import org.dbunit.dataset.stream.IDataSetProducer; import org.dbunit.dataset.stream.MockDataSetConsumer; @@ -209,4 +211,26 @@ void testANYModel_withANYContentModel_producesTwoTables() throws Exception assertThat(consumer.getTable("SECOND_TABLE")).isNotNull(); } + @Test + void testProduce_withFlatDtdWriterOutputForEmptyDataSet_producesNoTablesWithoutError() + throws Exception + { + // Round-trips FlatDtdWriter's ANY content model for a zero-table + // dataset back through this producer, confirming it does not raise + // a SAXParseException the way the previous "" + // output did. + final StringWriter dtdWriter = new StringWriter(); + new FlatDtdWriter(dtdWriter).write(new DefaultDataSet()); + + final FlatDtdDataSet consumer = new FlatDtdDataSet(); + final InputSource source = + new InputSource(new StringReader(dtdWriter.toString())); + final FlatDtdProducer producer = new FlatDtdProducer(source); + producer.setConsumer(consumer); + + producer.produce(); + + assertThat(consumer.getTables()).isEmpty(); + } + } diff --git a/src/test/java/org/dbunit/dataset/xml/FlatDtdWriterTest.java b/src/test/java/org/dbunit/dataset/xml/FlatDtdWriterTest.java index 43f7faedb..98191608a 100644 --- a/src/test/java/org/dbunit/dataset/xml/FlatDtdWriterTest.java +++ b/src/test/java/org/dbunit/dataset/xml/FlatDtdWriterTest.java @@ -105,4 +105,17 @@ void testWriteChoiceModel_NoInputColumns() throws Exception assertThat(actualOutput).as("output").isEqualTo(expectedOutput); } + @Test + void testWrite_withEmptyDataSet_writesAnyContentModel() throws Exception + { + final String expectedOutput = "\n" + "\n"; + + final StringWriter stringWriter = new StringWriter(); + final FlatDtdWriter dtdWriter = new FlatDtdWriter(stringWriter); + dtdWriter.write(new DefaultDataSet()); + + final String actualOutput = stringWriter.toString(); + assertThat(actualOutput).as("output").isEqualTo(expectedOutput); + } + } From c52b5754989312095ffe60e510e59464890badb0 Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Sat, 1 Aug 2026 14:31:45 -0500 Subject: [PATCH 6/8] feat(mysql): Add MultiSchemaMySqlMetadataHandler for non-single-schema connections MySqlMetadataHandler.getTables()/getColumns()/getPrimaryKeys() pass a null schema straight through as the JDBC catalog argument. Per the JDBC spec catalog=null means "search every catalog", but MySQL Connector/J's default nullCatalogMeansCurrent setting instead treats it as "the connection's current catalog only", so a connection not restricted to a single schema (e.g. connecting as "root" specifically to work across several schemas, with FEATURE_QUALIFIED_TABLE_NAMES enabled) silently sees only one catalog's tables and gets NoSuchTableException for tables in every other one. Add MultiSchemaMySqlMetadataHandler: whenever no single schema is configured, it enumerates the connection's visible catalogs (skipping information_schema/mysql/performance_schema/sys) and unions the per-catalog getTables()/getColumns()/getPrimaryKeys()/tableExists() results itself instead of ever passing a null catalog to the driver. The union is backed by a minimal in-memory ResultSet/ResultSetMetaData Proxy supporting only the handful of methods dbunit itself calls against a metadata-handler result, copied out of and closing each per-catalog result set eagerly. The original reported fix was only ever a SourceForge file attachment and was not retrievable; this is a fresh implementation built from the confirmed diagnosis, verified via mocked multi-catalog DatabaseMetaData unit tests and a full mysql-9-20 regression run (347 tests, all green) rather than a recovered, untested patch. Refs: 533 --- src/changes/changes.xml | 3 + .../MultiSchemaMySqlMetadataHandler.java | 347 ++++++++++++++++++ src/site/asciidoc/databases/mysql.adoc | 12 + .../MultiSchemaMySqlMetadataHandlerTest.java | 278 ++++++++++++++ 4 files changed, 640 insertions(+) create mode 100644 src/main/java/org/dbunit/ext/mysql/MultiSchemaMySqlMetadataHandler.java create mode 100644 src/test/java/org/dbunit/ext/mysql/MultiSchemaMySqlMetadataHandlerTest.java diff --git a/src/changes/changes.xml b/src/changes/changes.xml index 39cd3ab08..e00662707 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -141,6 +141,9 @@ Fix FlatDtdWriter.write() emitting an invalid, unparseable "<!ELEMENT dataset (\n)>" DTD content model for a zero-table dataset. It now emits "<!ELEMENT dataset ANY>" instead, which FlatDtdProducer already parses correctly and which, unlike EMPTY, still tolerates the whitespace a pretty-printed empty dataset file typically contains. + + Add MultiSchemaMySqlMetadataHandler, for a MySQL connection not restricted to a single schema (e.g. connecting as "root" specifically to work across several schemas at once with FEATURE_QUALIFIED_TABLE_NAMES enabled). MySqlMetadataHandler passes a null schema straight through as the JDBC catalog argument, which the JDBC spec defines as "search every catalog" but which MySQL Connector/J instead treats as "the connection's current catalog only" by default, silently hiding every other catalog's tables and producing NoSuchTableException for one of them. The new handler works around this by enumerating the connection's visible catalogs (skipping information_schema/mysql/performance_schema/sys) and unioning the per-catalog getTables()/getColumns()/getPrimaryKeys()/tableExists() results itself whenever no single schema is configured. + diff --git a/src/main/java/org/dbunit/ext/mysql/MultiSchemaMySqlMetadataHandler.java b/src/main/java/org/dbunit/ext/mysql/MultiSchemaMySqlMetadataHandler.java new file mode 100644 index 000000000..8baa39454 --- /dev/null +++ b/src/main/java/org/dbunit/ext/mysql/MultiSchemaMySqlMetadataHandler.java @@ -0,0 +1,347 @@ +/* + * + * 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., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +package org.dbunit.ext.mysql; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A {@link MySqlMetadataHandler} for connections not restricted to a single schema (MySQL + * "catalog"), e.g. a connection made as MySQL's "root" specifically to work across several + * schemas at once with {@link org.dbunit.database.DatabaseConfig#FEATURE_QUALIFIED_TABLE_NAMES} + * enabled. + *

+ * {@link MySqlMetadataHandler}'s {@code getTables()}/{@code getColumns()}/{@code getPrimaryKeys()} + * pass a {@code null} schema straight through as the JDBC {@code catalog} argument. Per the JDBC + * specification {@code catalog=null} means "do not narrow the search by catalog", but MySQL + * Connector/J instead treats it as "the connection's current catalog only" by default (its + * {@code nullCatalogMeansCurrent} connection property), so tables in every other catalog are + * silently invisible and a lookup for one of them fails with {@link org.dbunit.dataset.NoSuchTableException}. + *

+ * This handler works around that driver behavior at the application level: whenever no single + * schema is given, it enumerates the catalogs visible to the connection (skipping MySQL's own + * {@code information_schema}/{@code mysql}/{@code performance_schema}/{@code sys} system + * catalogs) and unions the per-catalog results instead of ever passing a {@code null} catalog to + * the driver. + *

+ * Configure it the same way as any other {@link org.dbunit.database.IMetadataHandler}: + * + *

+ * config.setProperty(DatabaseConfig.FEATURE_QUALIFIED_TABLE_NAMES, true);
+ * config.setProperty(DatabaseConfig.PROPERTY_METADATA_HANDLER, new MultiSchemaMySqlMetadataHandler());
+ * 
+ * + * @since 3.4.1 + */ +public class MultiSchemaMySqlMetadataHandler extends MySqlMetadataHandler +{ + /** + * Logger for this class + */ + private static final Logger logger = + LoggerFactory.getLogger(MultiSchemaMySqlMetadataHandler.class); + + private static final Set SYSTEM_CATALOGS = new HashSet(Arrays.asList( + "information_schema", "mysql", "performance_schema", "sys")); + + @Override + public ResultSet getTables(final DatabaseMetaData metaData, final String schemaName, + final String[] tableType) throws SQLException + { + if (schemaName != null) + { + return super.getTables(metaData, schemaName, tableType); + } + + final List perCatalog = new ArrayList(); + for (final String catalog : listUserCatalogs(metaData)) + { + perCatalog.add(super.getTables(metaData, catalog, tableType)); + } + return InMemoryMetadataResultSet.merge(perCatalog); + } + + @Override + public ResultSet getColumns(final DatabaseMetaData databaseMetaData, final String schemaName, + final String tableName) throws SQLException + { + if (schemaName != null) + { + return super.getColumns(databaseMetaData, schemaName, tableName); + } + + final List perCatalog = new ArrayList(); + for (final String catalog : listUserCatalogs(databaseMetaData)) + { + perCatalog.add(super.getColumns(databaseMetaData, catalog, tableName)); + } + return InMemoryMetadataResultSet.merge(perCatalog); + } + + @Override + public ResultSet getPrimaryKeys(final DatabaseMetaData metaData, final String schemaName, + final String tableName) throws SQLException + { + if (schemaName != null) + { + return super.getPrimaryKeys(metaData, schemaName, tableName); + } + + final List perCatalog = new ArrayList(); + for (final String catalog : listUserCatalogs(metaData)) + { + perCatalog.add(super.getPrimaryKeys(metaData, catalog, tableName)); + } + return InMemoryMetadataResultSet.merge(perCatalog); + } + + @Override + public boolean tableExists(final DatabaseMetaData metaData, final String schema, + final String tableName) throws SQLException + { + if (schema != null) + { + return super.tableExists(metaData, schema, tableName); + } + + for (final String catalog : listUserCatalogs(metaData)) + { + if (super.tableExists(metaData, catalog, tableName)) + { + return true; + } + } + return false; + } + + /** + * Lists the catalogs visible to the connection, excluding MySQL's own system catalogs. + * + * @param metaData The database metadata to list catalogs from. + * @return The visible, non-system catalog names. + * @throws SQLException If the catalog list cannot be read. + */ + private List listUserCatalogs(final DatabaseMetaData metaData) throws SQLException + { + final List catalogs = new ArrayList(); + final ResultSet catalogResultSet = metaData.getCatalogs(); + try + { + while (catalogResultSet.next()) + { + final String catalog = catalogResultSet.getString(1); + if (catalog != null && !SYSTEM_CATALOGS.contains(catalog.toLowerCase(Locale.ENGLISH))) + { + catalogs.add(catalog); + } + } + } + finally + { + catalogResultSet.close(); + } + + if (logger.isDebugEnabled()) + { + logger.debug("listUserCatalogs() - found {}", catalogs); + } + return catalogs; + } + + /** + * An in-memory {@link ResultSet}, backed by rows copied out of one or more source result sets + * ahead of time. It supports only the handful of {@link ResultSet}/{@link ResultSetMetaData} + * methods dbunit itself calls against an {@link org.dbunit.database.IMetadataHandler} result - + * {@code next()}, {@code getString(int/String)}, {@code getInt(int/String)}, + * {@code getMetaData()}/{@code getColumnCount()}, {@code close()}, plus {@code equals()}/ + * {@code hashCode()}/{@code toString()} for safe use as a log argument or map key - since + * implementing the rest of {@link ResultSet}'s ~150 methods would serve no caller. Any other + * method throws {@link UnsupportedOperationException}. + *

+ * A single instance answers both {@link ResultSet} calls and, since {@link #getMetaData()} + * returns the proxy itself, the {@link ResultSetMetaData} calls made against its result. + */ + private static final class InMemoryMetadataResultSet implements InvocationHandler + { + private final List rows; + private final int columnCount; + private final Map columnIndexByLabel; + private int cursor = -1; + + private InMemoryMetadataResultSet(final List rows, final int columnCount, + final Map columnIndexByLabel) + { + this.rows = rows; + this.columnCount = columnCount; + this.columnIndexByLabel = columnIndexByLabel; + } + + /** + * Copies every row of each given result set, closing each as it is consumed, and returns + * a single merged {@link ResultSet} positioned before the first row. + * + * @param sources The result sets to merge, in the order their rows should appear. + * @return The merged result set. + * @throws SQLException If a source result set cannot be read. + */ + static ResultSet merge(final List sources) throws SQLException + { + final List rows = new ArrayList(); + final Map columnIndexByLabel = new HashMap(); + int columnCount = 0; + boolean first = true; + for (final ResultSet source : sources) + { + try + { + if (first) + { + final ResultSetMetaData metaData = source.getMetaData(); + columnCount = metaData.getColumnCount(); + for (int i = 1; i <= columnCount; i++) + { + columnIndexByLabel.put( + metaData.getColumnLabel(i).toUpperCase(Locale.ENGLISH), i); + } + first = false; + } + while (source.next()) + { + final Object[] row = new Object[columnCount]; + for (int i = 1; i <= columnCount; i++) + { + row[i - 1] = source.getObject(i); + } + rows.add(row); + } + } + finally + { + source.close(); + } + } + + final InMemoryMetadataResultSet handler = + new InMemoryMetadataResultSet(rows, columnCount, columnIndexByLabel); + return (ResultSet) Proxy.newProxyInstance( + InMemoryMetadataResultSet.class.getClassLoader(), + new Class[] {ResultSet.class, ResultSetMetaData.class}, handler); + } + + @Override + public Object invoke(final Object proxy, final Method method, final Object[] args) + { + final String name = method.getName(); + if ("next".equals(name)) + { + cursor++; + return cursor < rows.size(); + } + if ("getString".equals(name)) + { + final Object value = currentValue(args[0]); + return value == null ? null : String.valueOf(value); + } + if ("getInt".equals(name)) + { + final Object value = currentValue(args[0]); + return value == null ? 0 : toInt(value); + } + if ("getMetaData".equals(name)) + { + return proxy; + } + if ("getColumnCount".equals(name)) + { + return columnCount; + } + if ("close".equals(name)) + { + return null; + } + if ("toString".equals(name)) + { + return "InMemoryMetadataResultSet[rows=" + rows.size() + "]"; + } + if ("hashCode".equals(name)) + { + return System.identityHashCode(proxy); + } + if ("equals".equals(name)) + { + return proxy == args[0]; + } + throw new UnsupportedOperationException( + "InMemoryMetadataResultSet does not support " + name + "()"); + } + + private Object currentValue(final Object columnArg) + { + if (cursor < 0 || cursor >= rows.size()) + { + throw new IllegalStateException( + "ResultSet is not positioned on a valid row."); + } + return rows.get(cursor)[columnIndex(columnArg) - 1]; + } + + private int columnIndex(final Object columnArg) + { + if (columnArg instanceof Integer) + { + return (Integer) columnArg; + } + final String label = String.valueOf(columnArg).toUpperCase(Locale.ENGLISH); + final Integer index = columnIndexByLabel.get(label); + if (index == null) + { + throw new IllegalArgumentException("Unknown column '" + columnArg + "'."); + } + return index; + } + + private static int toInt(final Object value) + { + if (value instanceof Number) + { + return ((Number) value).intValue(); + } + return Integer.parseInt(String.valueOf(value)); + } + } +} diff --git a/src/site/asciidoc/databases/mysql.adoc b/src/site/asciidoc/databases/mysql.adoc index d795c0e5d..c5c65b739 100644 --- a/src/site/asciidoc/databases/mysql.adoc +++ b/src/site/asciidoc/databases/mysql.adoc @@ -28,6 +28,18 @@ catalog/schema comparison doesn't line up with how MySQL reports them. Symptom without it: a `NoSuchColumnException` for a column you can see exists. +For a connection not restricted to a single schema — e.g. connecting as +`root` specifically to work across several schemas at once — use +link:/dbunit/apidocs/org/dbunit/ext/mysql/MultiSchemaMySqlMetadataHandler.html[MultiSchemaMySqlMetadataHandler] +instead. MySQL Connector/J's `nullCatalogMeansCurrent` default treats a +`null` catalog as "the connection's current catalog only" rather than "every +catalog", so `MySqlMetadataHandler` alone can silently hide tables in every +schema but one, surfacing as `NoSuchTableException` for a table you can see +exists. `MultiSchemaMySqlMetadataHandler` works around this by enumerating +and unioning results across the connection's visible schemas itself; pair it +with link:../properties.html#qualifiedtablenames[the qualified table names +feature] so same-named tables in different schemas stay distinguishable. + == Connection Preconfiguration Class link:/dbunit/apidocs/org/dbunit/ext/mysql/MySqlConnection.html[MySqlConnection] diff --git a/src/test/java/org/dbunit/ext/mysql/MultiSchemaMySqlMetadataHandlerTest.java b/src/test/java/org/dbunit/ext/mysql/MultiSchemaMySqlMetadataHandlerTest.java new file mode 100644 index 000000000..a644c463d --- /dev/null +++ b/src/test/java/org/dbunit/ext/mysql/MultiSchemaMySqlMetadataHandlerTest.java @@ -0,0 +1,278 @@ +/* + * + * 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., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +package org.dbunit.ext.mysql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Types; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link MultiSchemaMySqlMetadataHandler}: verifies the no-single-schema path + * enumerates and unions results across the connection's user catalogs (skipping MySQL's system + * catalogs), while a caller that already supplies a single schema still gets the plain + * {@link MySqlMetadataHandler} single-catalog passthrough. + * + * @since 3.4.1 + */ +class MultiSchemaMySqlMetadataHandlerTest +{ + private final MultiSchemaMySqlMetadataHandler handler = new MultiSchemaMySqlMetadataHandler(); + + @Test + void testGetTables_withNoSchemaConfigured_unionsAcrossUserCatalogsExcludingSystemCatalogs() + throws SQLException + { + final DatabaseMetaData metaData = mock(DatabaseMetaData.class); + final ResultSet catalogs = mockRowsResultSet(new String[] {"TABLE_CAT"}, row("shop1"), + row("information_schema"), row("shop2")); + final ResultSet shop1Tables = mockRowsResultSet( + new String[] {"TABLE_CAT", "TABLE_SCHEM", "TABLE_NAME"}, + row("shop1", null, "ORDERS")); + final ResultSet shop2Tables = mockRowsResultSet( + new String[] {"TABLE_CAT", "TABLE_SCHEM", "TABLE_NAME"}, + row("shop2", null, "PRODUCTS")); + when(metaData.getCatalogs()).thenReturn(catalogs); + when(metaData.getTables("shop1", null, "%", null)).thenReturn(shop1Tables); + when(metaData.getTables("shop2", null, "%", null)).thenReturn(shop2Tables); + + final ResultSet merged = handler.getTables(metaData, null, null); + + assertThat(merged.next()).as("first row present").isTrue(); + assertThat(merged.getString(3)).as("first table name").isEqualTo("ORDERS"); + assertThat(merged.next()).as("second row present").isTrue(); + assertThat(merged.getString(3)).as("second table name").isEqualTo("PRODUCTS"); + assertThat(merged.next()).as("no third row").isFalse(); + verify(metaData, never()).getTables(eq("information_schema"), any(), any(), any()); + } + + @Test + void testGetTables_withSchemaConfigured_delegatesWithoutEnumeratingCatalogs() + throws SQLException + { + final DatabaseMetaData metaData = mock(DatabaseMetaData.class); + final ResultSet expected = mock(ResultSet.class); + when(metaData.getTables("shop1", null, "%", null)).thenReturn(expected); + + final ResultSet actual = handler.getTables(metaData, "shop1", null); + + assertThat(actual).as("single-schema call bypasses catalog enumeration") + .isSameAs(expected); + verify(metaData, never()).getCatalogs(); + } + + @Test + void testGetTables_withOnlySystemCatalogsVisible_returnsEmptyResultSet() throws SQLException + { + final DatabaseMetaData metaData = mock(DatabaseMetaData.class); + final ResultSet catalogs = mockRowsResultSet(new String[] {"TABLE_CAT"}, + row("information_schema"), row("performance_schema")); + when(metaData.getCatalogs()).thenReturn(catalogs); + + final ResultSet merged = handler.getTables(metaData, null, null); + + assertThat(merged.next()).as("no rows when only system catalogs are visible").isFalse(); + } + + @Test + void testGetColumns_withNoSchemaConfigured_unionsAcrossUserCatalogs() throws SQLException + { + final DatabaseMetaData metaData = mock(DatabaseMetaData.class); + final ResultSet catalogs = + mockRowsResultSet(new String[] {"TABLE_CAT"}, row("shop1"), row("shop2")); + final ResultSet shop1Columns = mockRowsResultSet( + new String[] {"TABLE_CAT", "TABLE_SCHEM", "TABLE_NAME", "COLUMN_NAME"}, + row("shop1", null, "ORDERS", "ID")); + final ResultSet shop2Columns = mockRowsResultSet( + new String[] {"TABLE_CAT", "TABLE_SCHEM", "TABLE_NAME", "COLUMN_NAME"}, + row("shop2", null, "ORDERS", "ID")); + when(metaData.getCatalogs()).thenReturn(catalogs); + when(metaData.getColumns("shop1", null, "ORDERS", "%")).thenReturn(shop1Columns); + when(metaData.getColumns("shop2", null, "ORDERS", "%")).thenReturn(shop2Columns); + + final ResultSet merged = handler.getColumns(metaData, null, "ORDERS"); + + assertThat(merged.next()).as("first row present").isTrue(); + assertThat(merged.getString(1)).as("first row catalog").isEqualTo("shop1"); + assertThat(merged.next()).as("second row present").isTrue(); + assertThat(merged.getString(1)).as("second row catalog").isEqualTo("shop2"); + assertThat(merged.next()).as("no third row").isFalse(); + } + + @Test + void testGetColumns_withSchemaConfigured_delegatesWithoutEnumeratingCatalogs() + throws SQLException + { + final DatabaseMetaData metaData = mock(DatabaseMetaData.class); + final ResultSet expected = mock(ResultSet.class); + when(metaData.getColumns("shop1", null, "ORDERS", "%")).thenReturn(expected); + + final ResultSet actual = handler.getColumns(metaData, "shop1", "ORDERS"); + + assertThat(actual).as("single-schema call bypasses catalog enumeration") + .isSameAs(expected); + verify(metaData, never()).getCatalogs(); + } + + @Test + void testGetColumns_mergedResultSet_supportsGetIntByColumnLabel() throws SQLException + { + final DatabaseMetaData metaData = mock(DatabaseMetaData.class); + final ResultSet catalogs = mockRowsResultSet(new String[] {"TABLE_CAT"}, row("shop1")); + final ResultSet columns = mockRowsResultSet( + new String[] {"TABLE_CAT", "DATA_TYPE", "SOURCE_DATA_TYPE"}, + row("shop1", Types.DISTINCT, 12)); + when(metaData.getCatalogs()).thenReturn(catalogs); + when(metaData.getColumns("shop1", null, "ORDERS", "%")).thenReturn(columns); + + final ResultSet merged = handler.getColumns(metaData, null, "ORDERS"); + + assertThat(merged.next()).isTrue(); + assertThat(merged.getInt("SOURCE_DATA_TYPE")).as("named-column lookup").isEqualTo(12); + } + + @Test + void testGetPrimaryKeys_withNoSchemaConfigured_unionsAcrossUserCatalogs() throws SQLException + { + final DatabaseMetaData metaData = mock(DatabaseMetaData.class); + final ResultSet catalogs = + mockRowsResultSet(new String[] {"TABLE_CAT"}, row("shop1"), row("shop2")); + final ResultSet shop1Keys = mockRowsResultSet( + new String[] {"COLUMN_NAME", "KEY_SEQ"}, row("ID", 1)); + final ResultSet shop2Keys = mockRowsResultSet( + new String[] {"COLUMN_NAME", "KEY_SEQ"}, row("ORDER_ID", 1)); + when(metaData.getCatalogs()).thenReturn(catalogs); + when(metaData.getPrimaryKeys("shop1", null, "ORDERS")).thenReturn(shop1Keys); + when(metaData.getPrimaryKeys("shop2", null, "ORDERS")).thenReturn(shop2Keys); + + final ResultSet merged = handler.getPrimaryKeys(metaData, null, "ORDERS"); + + assertThat(merged.next()).as("first row present").isTrue(); + assertThat(merged.getString(1)).as("first PK column").isEqualTo("ID"); + assertThat(merged.next()).as("second row present").isTrue(); + assertThat(merged.getString(1)).as("second PK column").isEqualTo("ORDER_ID"); + assertThat(merged.next()).as("no third row").isFalse(); + } + + @Test + void testTableExists_withNoSchemaConfigured_returnsTrueWhenFoundInAnyUserCatalog() + throws SQLException + { + final DatabaseMetaData metaData = mock(DatabaseMetaData.class); + final ResultSet catalogs = + mockRowsResultSet(new String[] {"TABLE_CAT"}, row("shop1"), row("shop2")); + final ResultSet shop1Tables = mockRowsResultSet(new String[] {"TABLE_NAME"}); + final ResultSet shop2Tables = + mockRowsResultSet(new String[] {"TABLE_NAME"}, row("ORDERS")); + when(metaData.getCatalogs()).thenReturn(catalogs); + when(metaData.getTables("shop1", null, "ORDERS", null)).thenReturn(shop1Tables); + when(metaData.getTables("shop2", null, "ORDERS", null)).thenReturn(shop2Tables); + + final boolean exists = handler.tableExists(metaData, null, "ORDERS"); + + assertThat(exists).as("found in the second catalog").isTrue(); + } + + @Test + void testTableExists_withNoSchemaConfigured_returnsFalseWhenNotFoundInAnyUserCatalog() + throws SQLException + { + final DatabaseMetaData metaData = mock(DatabaseMetaData.class); + final ResultSet catalogs = + mockRowsResultSet(new String[] {"TABLE_CAT"}, row("shop1"), row("shop2")); + final ResultSet shop1Tables = mockRowsResultSet(new String[] {"TABLE_NAME"}); + final ResultSet shop2Tables = mockRowsResultSet(new String[] {"TABLE_NAME"}); + when(metaData.getCatalogs()).thenReturn(catalogs); + when(metaData.getTables("shop1", null, "ORDERS", null)).thenReturn(shop1Tables); + when(metaData.getTables("shop2", null, "ORDERS", null)).thenReturn(shop2Tables); + + final boolean exists = handler.tableExists(metaData, null, "ORDERS"); + + assertThat(exists).as("not found in any catalog").isFalse(); + } + + @Test + void testTableExists_withSchemaConfigured_delegatesWithoutEnumeratingCatalogs() + throws SQLException + { + final DatabaseMetaData metaData = mock(DatabaseMetaData.class); + final ResultSet shop1Tables = + mockRowsResultSet(new String[] {"TABLE_NAME"}, row("ORDERS")); + when(metaData.getTables("shop1", null, "ORDERS", null)).thenReturn(shop1Tables); + + final boolean exists = handler.tableExists(metaData, "shop1", "ORDERS"); + + assertThat(exists).as("single-schema call bypasses catalog enumeration").isTrue(); + verify(metaData, never()).getCatalogs(); + } + + private static Object[] row(final Object... values) + { + return values; + } + + /** + * Mocks a {@link ResultSet} over the given rows, driven purely by {@code next()} and + * {@code getObject(int)}/{@code getMetaData()} - the only members + * {@link MultiSchemaMySqlMetadataHandler}'s merge logic reads from a source result set - plus + * {@code getString(int)} so a test can also read a non-merged result set returned directly by + * a single-schema delegate call. + */ + private static ResultSet mockRowsResultSet(final String[] labels, final Object[]... rows) + throws SQLException + { + final ResultSetMetaData metaData = mock(ResultSetMetaData.class); + when(metaData.getColumnCount()).thenReturn(labels.length); + for (int i = 0; i < labels.length; i++) + { + when(metaData.getColumnLabel(i + 1)).thenReturn(labels[i]); + } + + final ResultSet resultSet = mock(ResultSet.class); + when(resultSet.getMetaData()).thenReturn(metaData); + final AtomicInteger cursor = new AtomicInteger(-1); + when(resultSet.next()).thenAnswer(invocation -> cursor.incrementAndGet() < rows.length); + when(resultSet.getObject(anyInt())).thenAnswer(invocation -> { + final int columnIndex = invocation.getArgument(0); + return rows[cursor.get()][columnIndex - 1]; + }); + when(resultSet.getString(anyInt())).thenAnswer(invocation -> { + final int columnIndex = invocation.getArgument(0); + final Object value = rows[cursor.get()][columnIndex - 1]; + return value == null ? null : String.valueOf(value); + }); + return resultSet; + } +} From ca979a591664cca6a69c545041a7039050f415bf Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Sat, 1 Aug 2026 16:00:58 -0500 Subject: [PATCH 7/8] fix(mysql): Close per-catalog ResultSets on failure, address PR #880 review CodeRabbit's review of PR #880 found one real bug and several style gaps in that PR's new code: * MultiSchemaMySqlMetadataHandler.getTables()/getColumns()/ getPrimaryKeys() leaked already-opened per-catalog ResultSets when a later catalog's metadata call failed, since the failure happened before merge() (whose per-source cleanup never got a chance to run). merge() itself had the same gap: a mid-loop failure left not-yet-reached sources unclosed. Both now close every collected source on failure. * Cache the per-instance catalog list instead of re-querying getCatalogs() on every getTables()/getColumns()/getPrimaryKeys()/ tableExists() call - one handler instance is configured per connection, so the visible catalogs aren't expected to change over its lifetime. * Add the new FEATURE_SORT_ALL_COLUMNS_WHEN_NO_PRIMARY_KEY constant to DatabaseConfig.ALL_FEATURES, which had drifted out of sync with ALL_PROPERTIES. * FlatDtdWriter: use a positive if-condition paired with its else, per this project's own stated code style. * Split a nested constructor-chain call into local variables in FlatDtdProducerTest, and add period-terminated AssertJ .as() failure messages across the new test files, per this project's own stated test style. Verified via the full unit suite (1937 tests) and a full mysql-9-20 Docker regression run (347 tests), both green; added a dedicated regression test proving the leak fix (asserts the first catalog's ResultSet is closed when the second catalog's query throws). --- .../org/dbunit/database/DatabaseConfig.java | 3 +- .../org/dbunit/dataset/xml/FlatDtdWriter.java | 18 ++-- .../MultiSchemaMySqlMetadataHandler.java | 83 +++++++++++++++---- .../dataset/xml/FlatDtdProducerTest.java | 7 +- .../dbunit/dataset/xml/FlatDtdWriterTest.java | 2 +- .../MultiSchemaMySqlMetadataHandlerTest.java | 65 ++++++++++----- 6 files changed, 128 insertions(+), 50 deletions(-) diff --git a/src/main/java/org/dbunit/database/DatabaseConfig.java b/src/main/java/org/dbunit/database/DatabaseConfig.java index bf466166f..882a7117f 100644 --- a/src/main/java/org/dbunit/database/DatabaseConfig.java +++ b/src/main/java/org/dbunit/database/DatabaseConfig.java @@ -126,7 +126,8 @@ public class DatabaseConfig FEATURE_BATCHED_STATEMENTS, FEATURE_DATATYPE_WARNING, FEATURE_SKIP_ORACLE_RECYCLEBIN_TABLES, - FEATURE_ALLOW_EMPTY_FIELDS + FEATURE_ALLOW_EMPTY_FIELDS, + FEATURE_SORT_ALL_COLUMNS_WHEN_NO_PRIMARY_KEY }; private static final DefaultDataTypeFactory DEFAULT_DATA_TYPE_FACTORY = diff --git a/src/main/java/org/dbunit/dataset/xml/FlatDtdWriter.java b/src/main/java/org/dbunit/dataset/xml/FlatDtdWriter.java index d41d9852a..fa029dd25 100644 --- a/src/main/java/org/dbunit/dataset/xml/FlatDtdWriter.java +++ b/src/main/java/org/dbunit/dataset/xml/FlatDtdWriter.java @@ -69,15 +69,7 @@ public void write(IDataSet dataSet) throws DataSetException String[] tableNames = dataSet.getTableNames(); // dataset element - if (tableNames.length == 0) - { - // An empty parenthesized content model, e.g. "(\n)", is not - // valid DTD syntax. ANY tolerates the whitespace that a - // pretty-printed empty "" typically contains, - // unlike EMPTY which rejects any content at all. - printOut.print("\n"); - } - else + if (tableNames.length > 0) { printOut.print("\n"); } + else + { + // An empty parenthesized content model, e.g. "(\n)", is not + // valid DTD syntax. ANY tolerates the whitespace that a + // pretty-printed empty "" typically contains, + // unlike EMPTY which rejects any content at all. + printOut.print("\n"); + } printOut.print("\n"); // tables diff --git a/src/main/java/org/dbunit/ext/mysql/MultiSchemaMySqlMetadataHandler.java b/src/main/java/org/dbunit/ext/mysql/MultiSchemaMySqlMetadataHandler.java index 8baa39454..3280a9544 100644 --- a/src/main/java/org/dbunit/ext/mysql/MultiSchemaMySqlMetadataHandler.java +++ b/src/main/java/org/dbunit/ext/mysql/MultiSchemaMySqlMetadataHandler.java @@ -37,6 +37,7 @@ import java.util.Map; import java.util.Set; +import org.dbunit.util.SQLHelper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -79,6 +80,13 @@ public class MultiSchemaMySqlMetadataHandler extends MySqlMetadataHandler private static final Set SYSTEM_CATALOGS = new HashSet(Arrays.asList( "information_schema", "mysql", "performance_schema", "sys")); + /** + * Lazily-populated cache of {@link #listUserCatalogs}'s result, since one instance of this + * handler is configured per connection (see the class Javadoc) and the visible catalogs are + * not expected to change over that connection's lifetime. + */ + private List userCatalogs; + @Override public ResultSet getTables(final DatabaseMetaData metaData, final String schemaName, final String[] tableType) throws SQLException @@ -89,11 +97,19 @@ public ResultSet getTables(final DatabaseMetaData metaData, final String schemaN } final List perCatalog = new ArrayList(); - for (final String catalog : listUserCatalogs(metaData)) + try + { + for (final String catalog : listUserCatalogs(metaData)) + { + perCatalog.add(super.getTables(metaData, catalog, tableType)); + } + return InMemoryMetadataResultSet.merge(perCatalog); + } + catch (final SQLException e) { - perCatalog.add(super.getTables(metaData, catalog, tableType)); + closeAll(perCatalog); + throw e; } - return InMemoryMetadataResultSet.merge(perCatalog); } @Override @@ -106,11 +122,19 @@ public ResultSet getColumns(final DatabaseMetaData databaseMetaData, final Strin } final List perCatalog = new ArrayList(); - for (final String catalog : listUserCatalogs(databaseMetaData)) + try + { + for (final String catalog : listUserCatalogs(databaseMetaData)) + { + perCatalog.add(super.getColumns(databaseMetaData, catalog, tableName)); + } + return InMemoryMetadataResultSet.merge(perCatalog); + } + catch (final SQLException e) { - perCatalog.add(super.getColumns(databaseMetaData, catalog, tableName)); + closeAll(perCatalog); + throw e; } - return InMemoryMetadataResultSet.merge(perCatalog); } @Override @@ -123,11 +147,19 @@ public ResultSet getPrimaryKeys(final DatabaseMetaData metaData, final String sc } final List perCatalog = new ArrayList(); - for (final String catalog : listUserCatalogs(metaData)) + try { - perCatalog.add(super.getPrimaryKeys(metaData, catalog, tableName)); + for (final String catalog : listUserCatalogs(metaData)) + { + perCatalog.add(super.getPrimaryKeys(metaData, catalog, tableName)); + } + return InMemoryMetadataResultSet.merge(perCatalog); + } + catch (final SQLException e) + { + closeAll(perCatalog); + throw e; } - return InMemoryMetadataResultSet.merge(perCatalog); } @Override @@ -151,6 +183,7 @@ public boolean tableExists(final DatabaseMetaData metaData, final String schema, /** * Lists the catalogs visible to the connection, excluding MySQL's own system catalogs. + * Cached after the first call; see {@link #userCatalogs}. * * @param metaData The database metadata to list catalogs from. * @return The visible, non-system catalog names. @@ -158,6 +191,11 @@ public boolean tableExists(final DatabaseMetaData metaData, final String schema, */ private List listUserCatalogs(final DatabaseMetaData metaData) throws SQLException { + if (userCatalogs != null) + { + return userCatalogs; + } + final List catalogs = new ArrayList(); final ResultSet catalogResultSet = metaData.getCatalogs(); try @@ -180,9 +218,24 @@ private List listUserCatalogs(final DatabaseMetaData metaData) throws SQ { logger.debug("listUserCatalogs() - found {}", catalogs); } + userCatalogs = catalogs; return catalogs; } + /** + * Closes every result set in the given list, null- and already-closed-safe. + * + * @param resultSets The result sets to close. + * @throws SQLException If closing one of them fails. + */ + private static void closeAll(final List resultSets) throws SQLException + { + for (final ResultSet resultSet : resultSets) + { + SQLHelper.close(resultSet); + } + } + /** * An in-memory {@link ResultSet}, backed by rows copied out of one or more source result sets * ahead of time. It supports only the handful of {@link ResultSet}/{@link ResultSetMetaData} @@ -225,9 +278,9 @@ static ResultSet merge(final List sources) throws SQLException final Map columnIndexByLabel = new HashMap(); int columnCount = 0; boolean first = true; - for (final ResultSet source : sources) + try { - try + for (final ResultSet source : sources) { if (first) { @@ -250,10 +303,10 @@ static ResultSet merge(final List sources) throws SQLException rows.add(row); } } - finally - { - source.close(); - } + } + finally + { + closeAll(sources); } final InMemoryMetadataResultSet handler = diff --git a/src/test/java/org/dbunit/dataset/xml/FlatDtdProducerTest.java b/src/test/java/org/dbunit/dataset/xml/FlatDtdProducerTest.java index 3773b625a..137a6ff27 100644 --- a/src/test/java/org/dbunit/dataset/xml/FlatDtdProducerTest.java +++ b/src/test/java/org/dbunit/dataset/xml/FlatDtdProducerTest.java @@ -220,7 +220,9 @@ void testProduce_withFlatDtdWriterOutputForEmptyDataSet_producesNoTablesWithoutE // a SAXParseException the way the previous "" // output did. final StringWriter dtdWriter = new StringWriter(); - new FlatDtdWriter(dtdWriter).write(new DefaultDataSet()); + final DefaultDataSet emptyDataSet = new DefaultDataSet(); + final FlatDtdWriter flatDtdWriter = new FlatDtdWriter(dtdWriter); + flatDtdWriter.write(emptyDataSet); final FlatDtdDataSet consumer = new FlatDtdDataSet(); final InputSource source = @@ -230,7 +232,8 @@ void testProduce_withFlatDtdWriterOutputForEmptyDataSet_producesNoTablesWithoutE producer.produce(); - assertThat(consumer.getTables()).isEmpty(); + assertThat(consumer.getTables()).as("The empty DTD must produce no tables.") + .isEmpty(); } } diff --git a/src/test/java/org/dbunit/dataset/xml/FlatDtdWriterTest.java b/src/test/java/org/dbunit/dataset/xml/FlatDtdWriterTest.java index 98191608a..1a68e60c6 100644 --- a/src/test/java/org/dbunit/dataset/xml/FlatDtdWriterTest.java +++ b/src/test/java/org/dbunit/dataset/xml/FlatDtdWriterTest.java @@ -115,7 +115,7 @@ void testWrite_withEmptyDataSet_writesAnyContentModel() throws Exception dtdWriter.write(new DefaultDataSet()); final String actualOutput = stringWriter.toString(); - assertThat(actualOutput).as("output").isEqualTo(expectedOutput); + assertThat(actualOutput).as("Generated DTD output.").isEqualTo(expectedOutput); } } diff --git a/src/test/java/org/dbunit/ext/mysql/MultiSchemaMySqlMetadataHandlerTest.java b/src/test/java/org/dbunit/ext/mysql/MultiSchemaMySqlMetadataHandlerTest.java index a644c463d..a91481110 100644 --- a/src/test/java/org/dbunit/ext/mysql/MultiSchemaMySqlMetadataHandlerTest.java +++ b/src/test/java/org/dbunit/ext/mysql/MultiSchemaMySqlMetadataHandlerTest.java @@ -22,6 +22,7 @@ package org.dbunit.ext.mysql; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; @@ -70,11 +71,11 @@ void testGetTables_withNoSchemaConfigured_unionsAcrossUserCatalogsExcludingSyste final ResultSet merged = handler.getTables(metaData, null, null); - assertThat(merged.next()).as("first row present").isTrue(); - assertThat(merged.getString(3)).as("first table name").isEqualTo("ORDERS"); - assertThat(merged.next()).as("second row present").isTrue(); - assertThat(merged.getString(3)).as("second table name").isEqualTo("PRODUCTS"); - assertThat(merged.next()).as("no third row").isFalse(); + assertThat(merged.next()).as("first row present.").isTrue(); + assertThat(merged.getString(3)).as("first table name.").isEqualTo("ORDERS"); + assertThat(merged.next()).as("second row present.").isTrue(); + assertThat(merged.getString(3)).as("second table name.").isEqualTo("PRODUCTS"); + assertThat(merged.next()).as("no third row.").isFalse(); verify(metaData, never()).getTables(eq("information_schema"), any(), any(), any()); } @@ -88,11 +89,31 @@ void testGetTables_withSchemaConfigured_delegatesWithoutEnumeratingCatalogs() final ResultSet actual = handler.getTables(metaData, "shop1", null); - assertThat(actual).as("single-schema call bypasses catalog enumeration") + assertThat(actual).as("single-schema call bypasses catalog enumeration.") .isSameAs(expected); verify(metaData, never()).getCatalogs(); } + @Test + void testGetTables_withNoSchemaConfigured_closesAlreadyOpenedResultSetsWhenALaterCatalogFails() + throws SQLException + { + final DatabaseMetaData metaData = mock(DatabaseMetaData.class); + final ResultSet catalogs = + mockRowsResultSet(new String[] {"TABLE_CAT"}, row("shop1"), row("shop2")); + final ResultSet shop1Tables = mockRowsResultSet( + new String[] {"TABLE_CAT", "TABLE_SCHEM", "TABLE_NAME"}, + row("shop1", null, "ORDERS")); + final SQLException shop2Failure = new SQLException("shop2 unreachable"); + when(metaData.getCatalogs()).thenReturn(catalogs); + when(metaData.getTables("shop1", null, "%", null)).thenReturn(shop1Tables); + when(metaData.getTables("shop2", null, "%", null)).thenThrow(shop2Failure); + + assertThatThrownBy(() -> handler.getTables(metaData, null, null)) + .as("The second catalog's failure must propagate.").isSameAs(shop2Failure); + verify(shop1Tables).close(); + } + @Test void testGetTables_withOnlySystemCatalogsVisible_returnsEmptyResultSet() throws SQLException { @@ -103,7 +124,7 @@ void testGetTables_withOnlySystemCatalogsVisible_returnsEmptyResultSet() throws final ResultSet merged = handler.getTables(metaData, null, null); - assertThat(merged.next()).as("no rows when only system catalogs are visible").isFalse(); + assertThat(merged.next()).as("no rows when only system catalogs are visible.").isFalse(); } @Test @@ -124,11 +145,11 @@ void testGetColumns_withNoSchemaConfigured_unionsAcrossUserCatalogs() throws SQL final ResultSet merged = handler.getColumns(metaData, null, "ORDERS"); - assertThat(merged.next()).as("first row present").isTrue(); - assertThat(merged.getString(1)).as("first row catalog").isEqualTo("shop1"); - assertThat(merged.next()).as("second row present").isTrue(); - assertThat(merged.getString(1)).as("second row catalog").isEqualTo("shop2"); - assertThat(merged.next()).as("no third row").isFalse(); + assertThat(merged.next()).as("first row present.").isTrue(); + assertThat(merged.getString(1)).as("first row catalog.").isEqualTo("shop1"); + assertThat(merged.next()).as("second row present.").isTrue(); + assertThat(merged.getString(1)).as("second row catalog.").isEqualTo("shop2"); + assertThat(merged.next()).as("no third row.").isFalse(); } @Test @@ -141,7 +162,7 @@ void testGetColumns_withSchemaConfigured_delegatesWithoutEnumeratingCatalogs() final ResultSet actual = handler.getColumns(metaData, "shop1", "ORDERS"); - assertThat(actual).as("single-schema call bypasses catalog enumeration") + assertThat(actual).as("single-schema call bypasses catalog enumeration.") .isSameAs(expected); verify(metaData, never()).getCatalogs(); } @@ -160,7 +181,7 @@ void testGetColumns_mergedResultSet_supportsGetIntByColumnLabel() throws SQLExce final ResultSet merged = handler.getColumns(metaData, null, "ORDERS"); assertThat(merged.next()).isTrue(); - assertThat(merged.getInt("SOURCE_DATA_TYPE")).as("named-column lookup").isEqualTo(12); + assertThat(merged.getInt("SOURCE_DATA_TYPE")).as("named-column lookup.").isEqualTo(12); } @Test @@ -179,11 +200,11 @@ void testGetPrimaryKeys_withNoSchemaConfigured_unionsAcrossUserCatalogs() throws final ResultSet merged = handler.getPrimaryKeys(metaData, null, "ORDERS"); - assertThat(merged.next()).as("first row present").isTrue(); - assertThat(merged.getString(1)).as("first PK column").isEqualTo("ID"); - assertThat(merged.next()).as("second row present").isTrue(); - assertThat(merged.getString(1)).as("second PK column").isEqualTo("ORDER_ID"); - assertThat(merged.next()).as("no third row").isFalse(); + assertThat(merged.next()).as("first row present.").isTrue(); + assertThat(merged.getString(1)).as("first PK column.").isEqualTo("ID"); + assertThat(merged.next()).as("second row present.").isTrue(); + assertThat(merged.getString(1)).as("second PK column.").isEqualTo("ORDER_ID"); + assertThat(merged.next()).as("no third row.").isFalse(); } @Test @@ -202,7 +223,7 @@ void testTableExists_withNoSchemaConfigured_returnsTrueWhenFoundInAnyUserCatalog final boolean exists = handler.tableExists(metaData, null, "ORDERS"); - assertThat(exists).as("found in the second catalog").isTrue(); + assertThat(exists).as("found in the second catalog.").isTrue(); } @Test @@ -220,7 +241,7 @@ void testTableExists_withNoSchemaConfigured_returnsFalseWhenNotFoundInAnyUserCat final boolean exists = handler.tableExists(metaData, null, "ORDERS"); - assertThat(exists).as("not found in any catalog").isFalse(); + assertThat(exists).as("not found in any catalog.").isFalse(); } @Test @@ -234,7 +255,7 @@ void testTableExists_withSchemaConfigured_delegatesWithoutEnumeratingCatalogs() final boolean exists = handler.tableExists(metaData, "shop1", "ORDERS"); - assertThat(exists).as("single-schema call bypasses catalog enumeration").isTrue(); + assertThat(exists).as("single-schema call bypasses catalog enumeration.").isTrue(); verify(metaData, never()).getCatalogs(); } From 646eafa9ae8c98831fd719cc5489fa450c7871c9 Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Sat, 1 Aug 2026 16:19:54 -0500 Subject: [PATCH 8/8] fix(database): Exclude vendor LOB data types from no-PK fallback sorting DatabaseDataSet.nonLobColumns() excluded a column from the FEATURE_SORT_ALL_COLUMNS_WHEN_NO_PRIMARY_KEY fallback sort only when its DataType was reference-equal to the generic DataType.CLOB/ DataType.BLOB singletons. A vendor data type factory - e.g. OracleDataTypeFactory, whose CLOB/BLOB columns are typed OracleClobDataType/OracleBlobDataType - produces distinct DataType instances that never equal those singletons, so this check missed them: a no-PK Oracle table with those columns still got them in its ORDER BY, causing exactly the SQL error the feature exists to avoid. Check by type instead: OracleClobDataType extends ClobDataType and OracleBlobDataType/OracleXMLTypeDataType extend BlobDataType, so an instanceof check catches the generic types, Oracle's vendor types, and any future subtype without needing a per-vendor identity list. Found by Codex's automated review of PR #880. Refs: 171 --- .../org/dbunit/database/DatabaseDataSet.java | 11 +++++--- .../dbunit/database/DatabaseDataSetIT.java | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/dbunit/database/DatabaseDataSet.java b/src/main/java/org/dbunit/database/DatabaseDataSet.java index e34b73f1e..92c701131 100644 --- a/src/main/java/org/dbunit/database/DatabaseDataSet.java +++ b/src/main/java/org/dbunit/database/DatabaseDataSet.java @@ -40,6 +40,8 @@ import org.dbunit.dataset.ITableMetaData; import org.dbunit.dataset.NoSuchTableException; import org.dbunit.dataset.OrderedTableNameMap; +import org.dbunit.dataset.datatype.BlobDataType; +import org.dbunit.dataset.datatype.ClobDataType; import org.dbunit.dataset.datatype.DataType; import org.dbunit.dataset.filter.ITableFilterSimple; import org.dbunit.util.QualifiedTableName; @@ -186,11 +188,14 @@ static String getSelectStatement(String schema, ITableMetaData metaData, String /** * Filters out CLOB/BLOB columns, which many databases (notably Oracle) reject in an - * {@code ORDER BY} clause. + * {@code ORDER BY} clause. Checks by type rather than identity against {@link DataType#CLOB}/ + * {@link DataType#BLOB} so vendor-specific LOB data types are also excluded, e.g. a + * {@code OracleDataTypeFactory}-produced column typed {@code OracleClobDataType} or + * {@code OracleBlobDataType}, neither of which equals the generic singleton. * * @param columns The columns to filter. * @return A new array containing every column from columns whose data type is - * not {@link DataType#CLOB} or {@link DataType#BLOB}. + * not a {@link ClobDataType} or {@link BlobDataType}. */ private static Column[] nonLobColumns(Column[] columns) { @@ -199,7 +204,7 @@ private static Column[] nonLobColumns(Column[] columns) { Column column = columns[i]; DataType dataType = column.getDataType(); - if (dataType != DataType.CLOB && dataType != DataType.BLOB) + if (!(dataType instanceof ClobDataType) && !(dataType instanceof BlobDataType)) { nonLobColumns.add(column); } diff --git a/src/test/java/org/dbunit/database/DatabaseDataSetIT.java b/src/test/java/org/dbunit/database/DatabaseDataSetIT.java index aba533605..161a0a136 100644 --- a/src/test/java/org/dbunit/database/DatabaseDataSetIT.java +++ b/src/test/java/org/dbunit/database/DatabaseDataSetIT.java @@ -36,6 +36,8 @@ import org.dbunit.dataset.datatype.DataType; import org.dbunit.dataset.filter.DefaultColumnFilter; import org.dbunit.dataset.filter.ITableFilterSimple; +import org.dbunit.ext.oracle.OracleBlobDataType; +import org.dbunit.ext.oracle.OracleClobDataType; import org.dbunit.util.QualifiedTableName; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -229,6 +231,29 @@ void testGetSelectStatement_withNoPrimaryKeyLobColumnAndSortFeatureEnabled_exclu assertThat(sql).as("select statement").isEqualTo(expected); } + @Test + void testGetSelectStatement_withNoPrimaryKeyVendorLobColumnAndSortFeatureEnabled_excludesLobColumnsFromOrderBy() + throws Exception + { + // OracleClobDataType/OracleBlobDataType are distinct DataType instances + // from the generic DataType.CLOB/DataType.BLOB singletons, so this + // guards against a reference-equality check missing vendor-specific + // LOB types. + final String schemaName = "schema"; + final String tableName = "table"; + final Column[] columns = new Column[] {new Column("c1", DataType.UNKNOWN), + new Column("blob_col", new OracleBlobDataType()), + new Column("clob_col", new OracleClobDataType()), + new Column("c2", DataType.UNKNOWN),}; + final String expected = + "select c1, blob_col, clob_col, c2 from schema.table order by c1, c2"; + + final ITableMetaData metaData = new DefaultTableMetaData(tableName, columns); + final String sql = + DatabaseDataSet.getSelectStatement(schemaName, metaData, null, true); + assertThat(sql).as("select statement").isEqualTo(expected); + } + @Test void testGetTable_withNoPrimaryKeyTableAndSortFeatureEnabled_executesWithoutSqlError() throws Exception