diff --git a/pom.xml b/pom.xml index 48fd28c25..0edb5958d 100644 --- a/pom.xml +++ b/pom.xml @@ -63,7 +63,7 @@ 12.1.5.0 10.14.2.0 - 1.4.200 + 2.4.240 2.7.4 3.5.3 8.0.31 diff --git a/src/changes/changes.xml b/src/changes/changes.xml index dd87d49bd..119a4444b 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -252,6 +252,12 @@ Add IsActualEqualToExpectedJsonValueComparer, a ValueComparer that parses expected and actual column values as JSON and compares the resulting document trees instead of their raw text: object member order is ignored while array element order stays significant, matching JSON's own equality semantics. Prompted by reviewing a Stack Overflow report of DbUnit failing on a MySQL JSON column; MySQL Connector/J already reports native JSON columns as Types.LONGVARCHAR, which DbUnit's existing StringDataType handles for reads/writes with no DataTypeFactory change needed, but MySQL (like PostgreSQL jsonb and H2 JSON) reformats the text on storage - sorting object keys and stripping insignificant whitespace - so a literal string comparison against an expected dataset value spuriously fails even when the JSON is semantically identical. Not exposed as a ValueComparers constant, since that class eagerly instantiates every constant it declares and would force the optional jackson-databind dependency onto every consumer, not only those comparing JSON columns. + + Add support for H2 2.x, whose rewritten, SQL-standard-compliant INFORMATION_SCHEMA reports most of its internal tables with the same JDBC TABLE_TYPE ("BASE TABLE") as real user tables instead of the "SYSTEM TABLE" type H2 1.x used, so those 15 tables (CONSTANTS, ENUM_VALUES, INDEXES, INDEX_COLUMNS, INFORMATION_SCHEMA_CATALOG_NAME, IN_DOUBT, LOCKS, QUERY_STATISTICS, RIGHTS, ROLES, SESSIONS, SESSION_STATE, SETTINGS, SYNONYMS, USERS) now pass DatabaseConfig#PROPERTY_TABLE_TYPE's default {"TABLE"} filter and leak into DatabaseDataSet#getTableNames()/createDataSet() whenever a connection queries without a fixed schema (schema is null, e.g. an admin/multi-schema connection with FEATURE_QUALIFIED_TABLE_NAMES). Bump h2DriverVersion from 1.4.200 to 2.4.240 and add H2MetadataHandler (wired into H2Connection) whose getTables() excludes the INFORMATION_SCHEMA schema; it builds the filtered result via a new InMemoryMetadataResultSet#filter(ResultSet, RowFilter) factory, generalized from the merge() factory MultiSchemaMySqlMetadataHandler already used (now a standalone org.dbunit.database class rather than that handler's private nested one). + + + Add the IMetadataHandler Core Components page (components/imetadatahandler.adoc): the interface's method groups, a built-in-implementations table (DefaultMetadataHandler, Db2/MySql/MultiSchemaMySql/Netezza/H2MetadataHandler), and InMemoryMetadataResultSet's two factories (merge() for combining several real result sets, filter() for dropping rows out of one) with guidance on writing a custom handler. Update databases.adoc, databases/h2.adoc, properties.adoc, and components.adoc to reflect H2MetadataHandler and cross-reference the new page; properties.adoc's metadataHandler entry also gains the previously-undocumented MultiSchemaMySqlMetadataHandler. Add a Test Conventions section (codingstandards/testconventions.adoc) on preferring a delegating wrapper over subclassing a concrete vendor handler for test doubles, using DatabaseDataSet_MultiSchemaTest's TestMetadataHandler as the worked example. + diff --git a/src/main/java/org/dbunit/database/InMemoryMetadataResultSet.java b/src/main/java/org/dbunit/database/InMemoryMetadataResultSet.java new file mode 100644 index 000000000..650a71cb2 --- /dev/null +++ b/src/main/java/org/dbunit/database/InMemoryMetadataResultSet.java @@ -0,0 +1,279 @@ +/* + * + * 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 java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.dbunit.util.SQLHelper; + +/** + * 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 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 {@code getMetaData()} + * returns the proxy itself, the {@link ResultSetMetaData} calls made against its result. + * + * @since 3.4.1 + */ +public final class InMemoryMetadataResultSet implements InvocationHandler +{ + /** + * Tests whether a source result set's current row, as positioned by a preceding + * {@link ResultSet#next()}, should be kept. + * + * @since 3.4.1 + */ + @FunctionalInterface + public interface RowFilter + { + /** + * Tests the row the given result set is currently positioned on. + * + * @param resultSet The source result set, positioned on the row to test. + * @return {@code true} if the row should be kept. + * @throws SQLException if a database access error occurs. + */ + boolean accept(ResultSet resultSet) throws SQLException; + } + + 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. + */ + public static ResultSet merge(final List sources) throws SQLException + { + return copy(sources, null); + } + + /** + * Copies the rows of the given result set that match the given filter, closing it as it is + * consumed, and returns a filtered {@link ResultSet} positioned before the first row. + * + * @param source The result set to filter. + * @param filter The filter a row must match to be kept. + * @return The filtered result set. + * @throws SQLException if the source result set cannot be read. + */ + public static ResultSet filter(final ResultSet source, final RowFilter filter) + throws SQLException + { + return copy(Collections.singletonList(source), filter); + } + + private static ResultSet copy(final List sources, final RowFilter filter) + 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()) + { + if (filter != null && !filter.accept(source)) + { + continue; + } + 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); + } + + /** + * Closes every result set in the given list, null- and already-closed-safe. Closing one + * result set is attempted even if closing an earlier one in the list failed, so a single + * failure does not leak the rest. + * + * @param resultSets The result sets to close. + * @throws SQLException the first failure encountered while closing, if any. + */ + private static void closeAll(final List resultSets) throws SQLException + { + SQLException firstFailure = null; + for (final ResultSet resultSet : resultSets) + { + try + { + SQLHelper.close(resultSet); + } + catch (final SQLException e) + { + if (firstFailure == null) + { + firstFailure = e; + } + } + } + if (firstFailure != null) + { + throw firstFailure; + } + } + + /** + * {@inheritDoc} + * Answers exactly the methods documented on this class; any other method throws + * {@link UnsupportedOperationException}. + */ + @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/main/java/org/dbunit/ext/h2/H2Connection.java b/src/main/java/org/dbunit/ext/h2/H2Connection.java index c46418a04..f0b7dec35 100644 --- a/src/main/java/org/dbunit/ext/h2/H2Connection.java +++ b/src/main/java/org/dbunit/ext/h2/H2Connection.java @@ -38,7 +38,8 @@ public class H2Connection extends DatabaseConnection { /** - * Creates an H2 connection, pre-configuring the H2-specific data type factory. + * Creates an H2 connection, pre-configuring the H2-specific data type factory and metadata + * handler. * * @param connection the adapted JDBC connection. * @param schema the database schema. @@ -49,5 +50,7 @@ public H2Connection(Connection connection, String schema) throws DatabaseUnitExc super(connection, schema); getConfig().setProperty(DatabaseConfig.PROPERTY_DATATYPE_FACTORY, new H2DataTypeFactory()); + getConfig().setProperty(DatabaseConfig.PROPERTY_METADATA_HANDLER, + new H2MetadataHandler()); } } \ No newline at end of file diff --git a/src/main/java/org/dbunit/ext/h2/H2MetadataHandler.java b/src/main/java/org/dbunit/ext/h2/H2MetadataHandler.java new file mode 100644 index 000000000..9c4990a5c --- /dev/null +++ b/src/main/java/org/dbunit/ext/h2/H2MetadataHandler.java @@ -0,0 +1,61 @@ +/* + * + * 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.h2; + +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.SQLException; + +import org.dbunit.database.DefaultMetadataHandler; +import org.dbunit.database.InMemoryMetadataResultSet; + +/** + * Special metadata handler for H2. + *

+ * H2 2.x rewrote {@code INFORMATION_SCHEMA} to be SQL-standard-compliant, and most of its internal + * tables now report a JDBC {@code TABLE_TYPE} of {@code "BASE TABLE"} instead of the + * {@code "SYSTEM TABLE"} type H2 1.x used. Since + * {@link org.dbunit.database.DatabaseConfig#PROPERTY_TABLE_TYPE} defaults to {@code {"TABLE"}}, + * those tables now pass dbunit's default system-table filter and leak into table listings that are + * not scoped to a single schema (i.e. {@code schemaName} is {@code null}). This handler excludes + * the {@code INFORMATION_SCHEMA} schema from {@link #getTables} results to restore the pre-2.x + * behavior. + * + * @since 3.4.1 + */ +public class H2MetadataHandler extends DefaultMetadataHandler +{ + private static final String INFORMATION_SCHEMA = "INFORMATION_SCHEMA"; + + /** + * {@inheritDoc} + * Excludes tables belonging to the {@code INFORMATION_SCHEMA} schema. + */ + @Override + public ResultSet getTables(final DatabaseMetaData metaData, final String schemaName, + final String[] tableType) throws SQLException + { + final ResultSet resultSet = super.getTables(metaData, schemaName, tableType); + return InMemoryMetadataResultSet.filter(resultSet, + row -> !INFORMATION_SCHEMA.equalsIgnoreCase(getSchema(row))); + } +} diff --git a/src/main/java/org/dbunit/ext/mysql/MultiSchemaMySqlMetadataHandler.java b/src/main/java/org/dbunit/ext/mysql/MultiSchemaMySqlMetadataHandler.java index 3280a9544..4285861cd 100644 --- a/src/main/java/org/dbunit/ext/mysql/MultiSchemaMySqlMetadataHandler.java +++ b/src/main/java/org/dbunit/ext/mysql/MultiSchemaMySqlMetadataHandler.java @@ -21,22 +21,17 @@ 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.database.InMemoryMetadataResultSet; import org.dbunit.util.SQLHelper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -236,165 +231,4 @@ private static void closeAll(final List resultSets) throws SQLExcepti } } - /** - * 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/codingstandards/testconventions.adoc b/src/site/asciidoc/codingstandards/testconventions.adoc index aba849ebe..2bdd58757 100644 --- a/src/site/asciidoc/codingstandards/testconventions.adoc +++ b/src/site/asciidoc/codingstandards/testconventions.adoc @@ -66,6 +66,49 @@ and end the message with a period so it doesn't combine with the subsequent JUnit message, e.g. `assertThat(actual.getMessage()).as("Should have null message.").isNull();`. +[#test-doubles] +== Test Doubles: Composition Over Inheritance + +When a test needs to spy on or intercept calls to a real collaborator — most often an +`link:../components/imetadatahandler.html[IMetadataHandler]`, `IDataTypeFactory`, or +similar dbUnit extension-point interface — wrap it, don't subclass a concrete +implementation. A test double that `extends` a specific vendor class (e.g. +`H2MetadataHandler`) only works while the connection under test uses that one vendor, +and Java's single inheritance means it cannot also extend a second vendor's class if +that ever changes. A test double that `implements` the interface and holds a `delegate` +field, forwarding every method to it except the one or two being spied on, stays correct +regardless of which concrete implementation the connection under test actually uses: + +[source,java] +---- +private static class TestMetadataHandler implements IMetadataHandler +{ + private final IMetadataHandler delegate; + private final Set schemaSet = new HashSet<>(); + + TestMetadataHandler(final IMetadataHandler delegate) + { + this.delegate = delegate; + } + + @Override + public ResultSet getTables(final DatabaseMetaData metaData, + final String schemaName, final String[] tableType) throws SQLException + { + schemaSet.add(schemaName); + return delegate.getTables(metaData, schemaName, tableType); + } + + // ...every other IMetadataHandler method delegates to `delegate` unchanged. +} +---- + +See `DatabaseDataSet_MultiSchemaTest` for the full example: it wraps whichever +`IMetadataHandler` the connection under test is actually configured with +(`H2MetadataHandler`, in that test's case) instead of extending it, so the test stays +correct even if H2 support's implementation changes, and would work unmodified if the +test ever moved to a different vendor. + [#fixed-timezone] == Fixed Test Timezone diff --git a/src/site/asciidoc/components.adoc b/src/site/asciidoc/components.adoc index 9eefe5cb5..b73d825b6 100644 --- a/src/site/asciidoc/components.adoc +++ b/src/site/asciidoc/components.adoc @@ -21,6 +21,7 @@ A few — `ITable`, `VerifyTableDefinition`, `IOperationListener`, and |anchor:databasedataset[]`DatabaseDataSet` |link:connections.html#databasedataset[Connections & Configuration] — the `IDataSet` adapter over a live database connection. |`CachingConnectionProvider` |link:connections.html#cachingconnectionprovider[Connections & Configuration] — caches/reuses one `IDatabaseConnection` across test methods. |`DatabaseConfig` |link:properties.html[Properties & Features] — the feature-flag/property configuration object for a connection. +|`link:components/imetadatahandler.html[IMetadataHandler]`, `InMemoryMetadataResultSet` |*New:* link:components/imetadatahandler.html[IMetadataHandler] — corrects a vendor driver's `DatabaseMetaData` quirks; register via `DatabaseConfig.PROPERTY_METADATA_HANDLER`. |=== [#datasets-tables] diff --git a/src/site/asciidoc/components/imetadatahandler.adoc b/src/site/asciidoc/components/imetadatahandler.adoc new file mode 100644 index 000000000..74ac56632 --- /dev/null +++ b/src/site/asciidoc/components/imetadatahandler.adoc @@ -0,0 +1,134 @@ += IMetadataHandler + +[#overview] +== Overview + +link:/dbunit/apidocs/org/dbunit/database/IMetadataHandler.html[IMetadataHandler] +(`org.dbunit.database`, since 2.4.4) controls how a connection queries +`java.sql.DatabaseMetaData` for table, column, and primary-key metadata. dbUnit's +own table-discovery logic (`DatabaseDataSet`) never calls `DatabaseMetaData` +directly — it always goes through the configured handler, which is what lets a +handler correct a vendor driver's metadata quirks without touching core code. +Register one via +link:../properties.html#metadatahandler[`DatabaseConfig.PROPERTY_METADATA_HANDLER`]; +unset, `DefaultMetadataHandler` applies. + +[#methods] +== Interface Shape + +Every method takes the real `DatabaseMetaData` (or a `ResultSet` it already produced) +plus the schema/table/column dbUnit is searching for, and returns either a `ResultSet` +to iterate or a `boolean`/`String` answer. Full signatures are in the +link:/dbunit/apidocs/org/dbunit/database/IMetadataHandler.html[JavaDoc]; grouped by +purpose: + +[cols="1,3", options="header"] +|=== +|Group |Methods + +|Metadata lookups |`getTables()`, `getColumns()`, `getPrimaryKeys()`, `tableExists()` — +run the actual `DatabaseMetaData` query. This is the seam a handler overrides to change +*which* rows come back — see `H2MetadataHandler`/`MultiSchemaMySqlMetadataHandler` below. +|Row interpretation |`getSchema(ResultSet)` extracts the schema name from a +`getTables()` row — some vendors (MySQL) report it in the catalog column instead. +|Matching |`matches(...)` (two overloads) compares a `getColumns()` row against a +searched catalog/schema/table/column. This is the seam a handler overrides to fix +catalog/schema mismatches — see MySQL's `NoSuchColumnException` fix in +link:../databases/mysql.html[MySQL]. `matchesColumn(...)` (since 3.2.1) is a value-based +counterpart so a caller that already extracted and cached a row's values can replay the +same comparison without re-querying or holding a `ResultSet` open; +`supportsColumnCache()` opts a handler into that fast path — return `true` only if +`matchesColumn(...)` fully replicates the handler's `matches(...)` override. +|=== + +[#implementations] +== Built-in Implementations + +[cols="1,3", options="header"] +|=== +|Handler |Use it when + +|`DefaultMetadataHandler` |No vendor-specific quirk to correct for — applied +automatically when `PROPERTY_METADATA_HANDLER` is left unset. +|link:../databases/db2.html[`Db2MetadataHandler`] |DB2, fixing a catalog/schema +column-matching bug. +|link:../databases/mysql.html[`MySqlMetadataHandler`] |MySQL/MariaDB, fixing +catalog/schema comparison so qualified-table-name lookups don't spuriously miss. +|link:../databases/mysql.html[`MultiSchemaMySqlMetadataHandler`] |A MySQL connection +(e.g. as `root`) that must see tables across every catalog it can access, not just its +current one. +|link:../databases/netezza.html[`NetezzaMetadataHandler`] |Netezza, which reports schema +information via the catalog column. +|link:../databases/h2.html[`H2MetadataHandler`] |H2 2.x, excluding `INFORMATION_SCHEMA` +from unscoped table listings — see below. +|=== + +[#inmemorymetadataresultset] +== InMemoryMetadataResultSet + +A `java.sql.ResultSet` is a forward-only cursor over one query — rows can't be filtered +out of it, or several combined, after the fact. That's a problem for a handler that +needs to correct *which* rows come back, not just how they're compared: + +* MySQL Connector/J's `nullCatalogMeansCurrent` default treats a `null` catalog as "the +connection's current catalog only" instead of "every catalog," per the JDBC spec. +Correcting that means issuing one real `getTables()` call *per* catalog and combining +the results. +* H2 2.x's `INFORMATION_SCHEMA` now reports its own internal tables with the same JDBC +`TABLE_TYPE` as real user tables (see link:../databases/h2.html[H2]). Correcting that +means running the real `getTables()` call once and dropping the rows that don't belong. + +link:/dbunit/apidocs/org/dbunit/database/InMemoryMetadataResultSet.html[InMemoryMetadataResultSet] +(`org.dbunit.database`, since 3.4.1) is the shared building block behind both: it copies +the rows a handler wants to keep into memory ahead of time, then hands back a +`java.lang.reflect.Proxy`-based `ResultSet` that answers just the handful of methods +dbUnit itself calls against a metadata result — `next()`, `getString(int/String)`, +`getInt(int/String)`, `getMetaData()`/`getColumnCount()`, `close()` — throwing +`UnsupportedOperationException` on anything else, deliberately, rather than silently +returning a wrong answer to a method it was never taught. + +[cols="1,3", options="header"] +|=== +|Factory |Use it to + +|`merge(List sources)` |Concatenate rows from several real result sets into +one, in order. `MultiSchemaMySqlMetadataHandler` uses this to union one `getTables()` +call per visible catalog. +|`filter(ResultSet source, RowFilter filter)` |Copy only the rows of one real result set +that pass a predicate. `H2MetadataHandler` uses this to drop `INFORMATION_SCHEMA` rows: ++ +[source,java] +---- +@Override +public ResultSet getTables(DatabaseMetaData metaData, String schemaName, + String[] tableType) throws SQLException +{ + ResultSet resultSet = super.getTables(metaData, schemaName, tableType); + return InMemoryMetadataResultSet.filter(resultSet, + row -> !"INFORMATION_SCHEMA".equalsIgnoreCase(getSchema(row))); +} +---- +|=== + +Both factories close every source result set they read, so a handler using them does not +need its own `finally`/close handling. + +[#custom] +== Writing a Custom Handler + +Reach for a custom `IMetadataHandler` when a vendor's JDBC driver misreports metadata in +a way that makes real tables/columns invisible or wrongly matched — not for filtering +*data* you don't want in a dataset (that's link:../filters.html[Filters]) or mapping SQL +types (that's link:../datatypes.html[Data Types]). + +Extend `DefaultMetadataHandler` and override only the method(s) whose default behavior +is wrong for the driver in question, then register the instance: + +[source,java] +---- +config.setProperty(DatabaseConfig.PROPERTY_METADATA_HANDLER, new MyVendorMetadataHandler()); +---- + +If the fix needs to change *which rows* a real query returns — dropping some, or +combining several real queries into one — use `InMemoryMetadataResultSet` as shown +above rather than hand-rolling a `ResultSet` implementation. diff --git a/src/site/asciidoc/databases.adoc b/src/site/asciidoc/databases.adoc index f095c3b6e..1cda813ba 100644 --- a/src/site/asciidoc/databases.adoc +++ b/src/site/asciidoc/databases.adoc @@ -11,7 +11,7 @@ registration mechanism. |Database |What's special here |link:databases/db2.html[DB2] |A metadata handler fixing a catalog/schema column-matching bug. -|link:databases/h2.html[H2] |`BOOLEAN`/`UUID` type recognition. +|link:databases/h2.html[H2] |`BOOLEAN`/`UUID` type recognition; a metadata handler excluding H2 2.x's `INFORMATION_SCHEMA` system tables. |link:databases/hsqldb.html[HSQLDB] |`BOOLEAN` type recognition. |link:databases/mariadb.html[MariaDB] |A factory (extending MySQL's) for `UUID`/`INET4`/`INET6` types. |link:databases/mckoi.html[Mckoi] |SQL type name recognition for this niche/legacy database. diff --git a/src/site/asciidoc/databases/h2.adoc b/src/site/asciidoc/databases/h2.adoc index b35a67444..cc6945c70 100644 --- a/src/site/asciidoc/databases/h2.adoc +++ b/src/site/asciidoc/databases/h2.adoc @@ -15,12 +15,22 @@ link:../connections.html[Connections & Configuration]. == IMetadataHandler -Not overridden — the default handler applies. +link:/dbunit/apidocs/org/dbunit/ext/h2/H2MetadataHandler.html[H2MetadataHandler] +excludes the `INFORMATION_SCHEMA` schema from `getTables()` results. H2 2.x rewrote +`INFORMATION_SCHEMA` to be SQL-standard-compliant, and most of its internal tables now +report the same JDBC `TABLE_TYPE` (`BASE TABLE`) as real user tables instead of the +`SYSTEM TABLE` type H2 1.x used, so dbUnit's default table-type filter +(link:../properties.html#tabletype[`PROPERTY_TABLE_TYPE`], `{"TABLE"}`) no longer screens +them out on its own. Without this handler, a connection queried without a fixed single +schema (an admin user, or link:../properties.html#qualifiedtablenames[qualified table +names] with no schema configured) gets 15 bogus system tables mixed into its table +listing. See link:../components/imetadatahandler.html[IMetadataHandler] for how the fix +works and why it's implemented this way rather than centrally in `DatabaseDataSet`. == Connection Preconfiguration Class link:/dbunit/apidocs/org/dbunit/ext/h2/H2Connection.html[H2Connection] wraps -a JDBC `Connection` and pre-registers `H2DataTypeFactory`: +a JDBC `Connection` and pre-registers both `H2DataTypeFactory` and `H2MetadataHandler`: [source,java] ---- @@ -33,5 +43,7 @@ None beyond the `BOOLEAN`/`UUID` mapping above. == Known Quirks -None specific to dbUnit beyond the standard -link:../faq.html[FAQ] entries. +Connecting to H2 2.x with a hand-assembled `DatabaseConnection` instead of +`H2Connection` loses the `INFORMATION_SCHEMA` exclusion above — register +`H2MetadataHandler` on `PROPERTY_METADATA_HANDLER` explicitly if `H2Connection` isn't an +option. This is new as of dbUnit's H2 2.x support; H2 1.x never needed it. diff --git a/src/site/asciidoc/properties.adoc b/src/site/asciidoc/properties.adoc index 561e5a46e..cc048bfe2 100644 --- a/src/site/asciidoc/properties.adoc +++ b/src/site/asciidoc/properties.adoc @@ -154,9 +154,11 @@ To create your own data type factory, see the generic base implementation at lin |Used to configure the handler used to control database metadata related methods. The Object must implement link:apidocs/org/dbunit/database/IMetadataHandler.html[org.dbunit.database.IMetadataHandler]. |The following RDBMS specific handlers are currently available: + link:apidocs/org/dbunit/ext/db2/Db2MetadataHandler.html[org.dbunit.ext.db2.Db2MetadataHandler] + +link:apidocs/org/dbunit/ext/h2/H2MetadataHandler.html[org.dbunit.ext.h2.H2MetadataHandler] + link:apidocs/org/dbunit/ext/mysql/MySqlMetadataHandler.html[org.dbunit.ext.mysql.MySqlMetadataHandler] + +link:apidocs/org/dbunit/ext/mysql/MultiSchemaMySqlMetadataHandler.html[org.dbunit.ext.mysql.MultiSchemaMySqlMetadataHandler] + link:apidocs/org/dbunit/ext/netezza/NetezzaMetadataHandler.html[org.dbunit.ext.netezza.NetezzaMetadataHandler] + -For all others the default handler should do the job: link:apidocs/org/dbunit/database/DefaultMetadataHandler.html[org.dbunit.database.DefaultMetadataHandler]. +For all others the default handler should do the job: link:apidocs/org/dbunit/database/DefaultMetadataHandler.html[org.dbunit.database.DefaultMetadataHandler]. See link:components/imetadatahandler.html[IMetadataHandler] for the full reference. |anchor:allowverifytabledefinitionexpectedtablecountmismatch[]http://www.dbunit.org/properties/allowVerifytabledefinitionExpectedtableCountMismatch |false diff --git a/src/site/site.xml b/src/site/site.xml index dd8466ea4..816a15837 100644 --- a/src/site/site.xml +++ b/src/site/site.xml @@ -111,6 +111,7 @@ + diff --git a/src/test/java/org/dbunit/database/DatabaseDataSet_MultiSchemaTest.java b/src/test/java/org/dbunit/database/DatabaseDataSet_MultiSchemaTest.java index 7a887acc2..b44e9439c 100644 --- a/src/test/java/org/dbunit/database/DatabaseDataSet_MultiSchemaTest.java +++ b/src/test/java/org/dbunit/database/DatabaseDataSet_MultiSchemaTest.java @@ -19,6 +19,7 @@ import org.dbunit.dataset.ITable; import org.dbunit.dataset.NoSuchTableException; import org.dbunit.ext.h2.H2DataTypeFactory; +import org.dbunit.ext.h2.H2MetadataHandler; import org.dbunit.testutil.TestUtils; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; @@ -65,7 +66,7 @@ public class DatabaseDataSet_MultiSchemaTest private IDatabaseConnection connectionTest; private final TestMetadataHandler testMetadataHandler = - new TestMetadataHandler(); + new TestMetadataHandler(new H2MetadataHandler()); @BeforeAll public static void setUpClass() throws Exception @@ -305,17 +306,89 @@ private void makeDatabaseConnection(final String databaseName, testMetadataHandler); } - private static class TestMetadataHandler extends DefaultMetadataHandler + /** + * Spies on {@link #getTables} while delegating everything else to a real + * {@link IMetadataHandler}, so this test does not need to bind itself to any particular + * database vendor's handler class - it wraps whichever one the connection under test is + * actually configured with. + */ + private static class TestMetadataHandler implements IMetadataHandler { + private final IMetadataHandler delegate; private final Set schemaSet = new HashSet<>(); + TestMetadataHandler(final IMetadataHandler delegate) + { + this.delegate = delegate; + } + @Override public ResultSet getTables(final DatabaseMetaData metaData, final String schemaName, final String[] tableType) throws SQLException { schemaSet.add(schemaName); - return super.getTables(metaData, schemaName, tableType); + return delegate.getTables(metaData, schemaName, tableType); + } + + @Override + public ResultSet getColumns(final DatabaseMetaData databaseMetaData, + final String schemaName, final String tableName) throws SQLException + { + return delegate.getColumns(databaseMetaData, schemaName, tableName); + } + + @Override + public boolean matches(final ResultSet resultSet, final String schema, + final String table, final boolean caseSensitive) throws SQLException + { + return delegate.matches(resultSet, schema, table, caseSensitive); + } + + @Override + public boolean matches(final ResultSet resultSet, final String catalog, + final String schema, final String table, final String column, + final boolean caseSensitive) throws SQLException + { + return delegate.matches(resultSet, catalog, schema, table, column, + caseSensitive); + } + + @Override + public String getSchema(final ResultSet resultSet) throws SQLException + { + return delegate.getSchema(resultSet); + } + + @Override + public boolean tableExists(final DatabaseMetaData databaseMetaData, + final String schemaName, final String tableName) throws SQLException + { + return delegate.tableExists(databaseMetaData, schemaName, tableName); + } + + @Override + public ResultSet getPrimaryKeys(final DatabaseMetaData databaseMetaData, + final String schemaName, final String tableName) throws SQLException + { + return delegate.getPrimaryKeys(databaseMetaData, schemaName, tableName); + } + + @Override + public boolean matchesColumn(final String searchCatalog, final String actualCatalog, + final String searchSchema, final String actualSchema, final String searchTable, + final String actualTable, final String searchColumn, final String actualColumn, + final boolean caseSensitive) + { + return delegate.matchesColumn(searchCatalog, actualCatalog, searchSchema, + actualSchema, searchTable, actualTable, searchColumn, actualColumn, + caseSensitive); + } + + @Override + public boolean supportsColumnCache() + { + return delegate.supportsColumnCache(); } public int getSchemaCount() diff --git a/src/test/java/org/dbunit/database/DatabaseSequenceFilterIT.java b/src/test/java/org/dbunit/database/DatabaseSequenceFilterIT.java index d0a1b1cb7..18ce38ebe 100644 --- a/src/test/java/org/dbunit/database/DatabaseSequenceFilterIT.java +++ b/src/test/java/org/dbunit/database/DatabaseSequenceFilterIT.java @@ -34,6 +34,7 @@ import org.dbunit.dataset.IDataSet; import org.dbunit.dataset.filter.ITableFilter; import org.dbunit.dataset.filter.IncludeTableFilter; +import org.dbunit.ext.h2.H2Connection; import org.dbunit.testutil.TestUtils; import org.junit.jupiter.api.Test; @@ -274,8 +275,7 @@ void testGetTableNames_withMultiSchemaTables_respectsForeignKeyOrder() throws Ex DdlExecutor.executeDdlFile( TestUtils.getFile("sql/h2_multischema_fk_test.sql"), jdbcConnection); - final IDatabaseConnection connection = - new DatabaseConnection(jdbcConnection); + final IDatabaseConnection connection = new H2Connection(jdbcConnection, null); connection.getConfig().setProperty( DatabaseConfig.FEATURE_QUALIFIED_TABLE_NAMES, Boolean.TRUE); diff --git a/src/test/java/org/dbunit/database/InMemoryMetadataResultSetTest.java b/src/test/java/org/dbunit/database/InMemoryMetadataResultSetTest.java new file mode 100644 index 000000000..37430058f --- /dev/null +++ b/src/test/java/org/dbunit/database/InMemoryMetadataResultSetTest.java @@ -0,0 +1,75 @@ +/* + * + * 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.assertThatThrownBy; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.util.Arrays; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link InMemoryMetadataResultSet}, focused on {@code merge()}'s handling of + * closing multiple source result sets - {@code filter()}'s single-source row-selection behavior + * is already exercised via {@link org.dbunit.ext.h2.H2MetadataHandlerTest}. + * + * @since 3.4.1 + */ +class InMemoryMetadataResultSetTest +{ + @Test + void testMerge_withOneSourceFailingToClose_stillClosesTheOthersAndRethrowsTheFirstFailure() + throws SQLException + { + final ResultSet first = mockEmptyResultSet(); + final ResultSet second = mockEmptyResultSet(); + final SQLException closeFailure = new SQLException("boom"); + doThrow(closeFailure).when(second).close(); + final ResultSet third = mockEmptyResultSet(); + + assertThatThrownBy( + () -> InMemoryMetadataResultSet.merge(Arrays.asList(first, second, third))) + .as("the failure closing the middle source must still propagate.") + .isSameAs(closeFailure); + + verify(first).close(); + verify(second).close(); + verify(third).close(); + } + + private static ResultSet mockEmptyResultSet() throws SQLException + { + final ResultSetMetaData metaData = mock(ResultSetMetaData.class); + when(metaData.getColumnCount()).thenReturn(0); + final ResultSet resultSet = mock(ResultSet.class); + when(resultSet.getMetaData()).thenReturn(metaData); + when(resultSet.next()).thenReturn(false); + return resultSet; + } +} diff --git a/src/test/java/org/dbunit/ext/h2/H2MetadataHandlerTest.java b/src/test/java/org/dbunit/ext/h2/H2MetadataHandlerTest.java new file mode 100644 index 000000000..7e79523cd --- /dev/null +++ b/src/test/java/org/dbunit/ext/h2/H2MetadataHandlerTest.java @@ -0,0 +1,153 @@ +/* + * + * 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.h2; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +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.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link H2MetadataHandler}: verifies {@link H2MetadataHandler#getTables} excludes + * the {@code INFORMATION_SCHEMA} schema that H2 2.x reports with a JDBC {@code TABLE_TYPE} dbunit's + * default table-type filter no longer excludes on its own. + * + * @since 3.4.1 + */ +class H2MetadataHandlerTest +{ + private final H2MetadataHandler handler = new H2MetadataHandler(); + + @Test + void testGetTables_withInformationSchemaRowsPresent_excludesThemButKeepsUserTables() + throws SQLException + { + final DatabaseMetaData metaData = mock(DatabaseMetaData.class); + final ResultSet tables = mockRowsResultSet( + new String[] {"TABLE_CAT", "TABLE_SCHEM", "TABLE_NAME", "TABLE_TYPE"}, + row(null, "INFORMATION_SCHEMA", "CONSTANTS", "BASE TABLE"), + row(null, "DBUNITUSER", "BAR", "TABLE"), + row(null, "INFORMATION_SCHEMA", "USERS", "BASE TABLE"), + row(null, "DEFAULTUSER", "FOO", "TABLE")); + when(metaData.getTables(null, null, "%", null)).thenReturn(tables); + + final ResultSet filtered = handler.getTables(metaData, null, null); + + assertThat(filtered.next()).as("first row present.").isTrue(); + assertThat(filtered.getString(3)).as("first surviving table.").isEqualTo("BAR"); + assertThat(filtered.next()).as("second row present.").isTrue(); + assertThat(filtered.getString(3)).as("second surviving table.").isEqualTo("FOO"); + assertThat(filtered.next()).as("no third row.").isFalse(); + } + + @Test + void testGetTables_withInformationSchemaInLowerCase_excludesItToo() throws SQLException + { + final DatabaseMetaData metaData = mock(DatabaseMetaData.class); + final ResultSet tables = mockRowsResultSet( + new String[] {"TABLE_CAT", "TABLE_SCHEM", "TABLE_NAME", "TABLE_TYPE"}, + row(null, "information_schema", "settings", "BASE TABLE"), + row(null, "public", "foo", "TABLE")); + when(metaData.getTables(null, null, "%", null)).thenReturn(tables); + + final ResultSet filtered = handler.getTables(metaData, null, null); + + assertThat(filtered.next()).as("only the non-system row survives.").isTrue(); + assertThat(filtered.getString(3)).as("surviving table.").isEqualTo("foo"); + assertThat(filtered.next()).as("no second row.").isFalse(); + } + + @Test + void testGetTables_withOnlyInformationSchemaRows_returnsEmptyResultSet() throws SQLException + { + final DatabaseMetaData metaData = mock(DatabaseMetaData.class); + final ResultSet tables = mockRowsResultSet( + new String[] {"TABLE_CAT", "TABLE_SCHEM", "TABLE_NAME", "TABLE_TYPE"}, + row(null, "INFORMATION_SCHEMA", "CONSTANTS", "BASE TABLE")); + when(metaData.getTables(null, "INFORMATION_SCHEMA", "%", null)).thenReturn(tables); + + final ResultSet filtered = handler.getTables(metaData, "INFORMATION_SCHEMA", null); + + assertThat(filtered.next()).as("no rows survive.").isFalse(); + } + + @Test + void testGetTables_withRows_closesTheUnderlyingResultSet() throws SQLException + { + final DatabaseMetaData metaData = mock(DatabaseMetaData.class); + final ResultSet tables = mockRowsResultSet( + new String[] {"TABLE_CAT", "TABLE_SCHEM", "TABLE_NAME", "TABLE_TYPE"}, + row(null, "PUBLIC", "FOO", "TABLE")); + when(metaData.getTables(null, null, "%", null)).thenReturn(tables); + + handler.getTables(metaData, null, null); + + verify(tables).close(); + } + + private static Object[] row(final Object... values) + { + return values; + } + + /** + * Mocks a {@link ResultSet} over the given rows, driven purely by {@code next()}, + * {@code getObject(int)}/{@code getMetaData()} - the members + * {@link org.dbunit.database.InMemoryMetadataResultSet}'s copy logic reads from a source + * result set - plus {@code getString(int)} so the filter predicate and the filtered result can + * both read column values by index. + */ + 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; + } +}