diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index ca671d249..e00662707 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -126,6 +126,24 @@
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.
+
+
+ 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).
+
+
+ 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.
+
+
+ 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/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/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..882a7117f 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),
};
/**
@@ -123,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 =
@@ -150,6 +154,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 be5854835..92c701131 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,9 @@
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;
import org.dbunit.util.SQLHelper;
@@ -115,11 +120,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 +143,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 +169,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 +179,39 @@ 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. 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 a {@link ClobDataType} or {@link BlobDataType}.
+ */
+ 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 instanceof ClobDataType) && !(dataType instanceof BlobDataType))
+ {
+ nonLobColumns.add(column);
+ }
+ }
+ return nonLobColumns.toArray(new Column[0]);
+ }
+
/**
* Get all the table names form the database that are not system tables.
*/
@@ -204,11 +249,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/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/main/java/org/dbunit/dataset/xml/FlatDtdWriter.java b/src/main/java/org/dbunit/dataset/xml/FlatDtdWriter.java
index a463906c0..fa029dd25 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(" 0)
+ {
+ printOut.print("\n");
+ }
+ else
{
- _contentModel.write(printOut, tableNames[i], i, tableNames.length);
+ // 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");
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
new file mode 100644
index 000000000..3280a9544
--- /dev/null
+++ b/src/main/java/org/dbunit/ext/mysql/MultiSchemaMySqlMetadataHandler.java
@@ -0,0 +1,400 @@
+/*
+ *
+ * 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.dbunit.util.SQLHelper;
+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}:
+ *
+ *
+ *
+ * @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"));
+
+ /**
+ * 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
+ {
+ if (schemaName != null)
+ {
+ return super.getTables(metaData, schemaName, tableType);
+ }
+
+ final List perCatalog = new ArrayList();
+ try
+ {
+ for (final String catalog : listUserCatalogs(metaData))
+ {
+ perCatalog.add(super.getTables(metaData, catalog, tableType));
+ }
+ return InMemoryMetadataResultSet.merge(perCatalog);
+ }
+ catch (final SQLException e)
+ {
+ closeAll(perCatalog);
+ throw e;
+ }
+ }
+
+ @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();
+ try
+ {
+ for (final String catalog : listUserCatalogs(databaseMetaData))
+ {
+ perCatalog.add(super.getColumns(databaseMetaData, catalog, tableName));
+ }
+ return InMemoryMetadataResultSet.merge(perCatalog);
+ }
+ catch (final SQLException e)
+ {
+ closeAll(perCatalog);
+ throw e;
+ }
+ }
+
+ @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();
+ try
+ {
+ 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;
+ }
+ }
+
+ @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.
+ * Cached after the first call; see {@link #userCatalogs}.
+ *
+ * @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
+ {
+ if (userCatalogs != null)
+ {
+ return userCatalogs;
+ }
+
+ 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);
+ }
+ 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}
+ * 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