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}: + * + *

+ * 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")); + + /** + * 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 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; + try + { + for (final ResultSet source : sources) + { + 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 + { + closeAll(sources); + } + + 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/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/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\""); + } +} diff --git a/src/test/java/org/dbunit/database/DatabaseDataSetIT.java b/src/test/java/org/dbunit/database/DatabaseDataSetIT.java index 72f1984f4..161a0a136 100644 --- a/src/test/java/org/dbunit/database/DatabaseDataSetIT.java +++ b/src/test/java/org/dbunit/database/DatabaseDataSetIT.java @@ -30,11 +30,14 @@ 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; 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; @@ -177,6 +180,99 @@ 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 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 + { + // 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 { 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); + } + } } 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 { diff --git a/src/test/java/org/dbunit/dataset/xml/FlatDtdProducerTest.java b/src/test/java/org/dbunit/dataset/xml/FlatDtdProducerTest.java index 21fbfb683..137a6ff27 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,29 @@ 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(); + final DefaultDataSet emptyDataSet = new DefaultDataSet(); + final FlatDtdWriter flatDtdWriter = new FlatDtdWriter(dtdWriter); + flatDtdWriter.write(emptyDataSet); + + 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()).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 43f7faedb..1a68e60c6 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("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 new file mode 100644 index 000000000..a91481110 --- /dev/null +++ b/src/test/java/org/dbunit/ext/mysql/MultiSchemaMySqlMetadataHandlerTest.java @@ -0,0 +1,299 @@ +/* + * + * 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.assertj.core.api.Assertions.assertThatThrownBy; +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_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 + { + 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; + } +}