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;
+ }
+}