From 9f4819a12f5758dd626e2f8065d04a6437f32851 Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Sun, 9 Aug 2026 08:04:41 -0500 Subject: [PATCH 1/3] refactor(resultset): Extract InMemoryMetadataResultSet from MultiSchemaMySqlMetadataHandler * Move the private nested InMemoryMetadataResultSet proxy out to its own public class in org.dbunit.database so other IMetadataHandler implementations can reuse it, not just MySQL's. * No behavior change; MultiSchemaMySqlMetadataHandler's merge() usage is unaffected. Refs: 923 --- .../database/InMemoryMetadataResultSet.java | 212 ++++++++++++++++++ .../MultiSchemaMySqlMetadataHandler.java | 168 +------------- 2 files changed, 213 insertions(+), 167 deletions(-) create mode 100644 src/main/java/org/dbunit/database/InMemoryMetadataResultSet.java 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..3ff7a7a61 --- /dev/null +++ b/src/main/java/org/dbunit/database/InMemoryMetadataResultSet.java @@ -0,0 +1,212 @@ +/* + * + * 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.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 {@link #getMetaData()} + * returns the proxy itself, the {@link ResultSetMetaData} calls made against its result. + * + * @since 3.4.1 + */ +public 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. + */ + public 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); + } + + /** + * Closes every result set in the given list, null- and already-closed-safe. + * + * @param resultSets The result sets to close. + * @throws SQLException if closing one of them fails. + */ + private static void closeAll(final List resultSets) throws SQLException + { + for (final ResultSet resultSet : resultSets) + { + SQLHelper.close(resultSet); + } + } + + @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/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)); - } - } } From 8382ceaa03088b436d87937f255c0cc41438fb96 Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Sun, 9 Aug 2026 08:14:27 -0500 Subject: [PATCH 2/3] feat(h2): Exclude INFORMATION_SCHEMA tables leaked by H2 2.x Bump h2DriverVersion from 1.4.200 to 2.4.240 and adjust issues caused by it. * H2 2.x rewrote INFORMATION_SCHEMA to be SQL-standard-compliant, and 15 of its tables now report JDBC TABLE_TYPE = "BASE TABLE" instead of the "SYSTEM TABLE" type H2 1.x used. DatabaseConfig#PROPERTY_TABLE_TYPE defaults to {"TABLE"}, so those tables now pass dbunit's default system-table filter and leak into DatabaseDataSet's table listing whenever a query is not scoped to a single schema (schema is null, e.g. an admin/multi-schema connection). * Add H2MetadataHandler, wired into H2Connection, whose getTables() excludes the INFORMATION_SCHEMA schema. It builds the filtered result via InMemoryMetadataResultSet#filter(ResultSet, RowFilter), a new factory alongside the existing merge() one. * Update DatabaseDataSet_MultiSchemaTest and DatabaseSequenceFilterIT, both of which hit the same leak, to exercise/use H2MetadataHandler. * Fix InMemoryMetadataResultSet#closeAll(), it aborted on the first ResultSet that failed to close, leaking every source after it. Track the first failure, attempt every source's close, then rethrow it. Add InMemoryMetadataResultSetTest covering it directly. Refs: 923 --- pom.xml | 2 +- src/changes/changes.xml | 3 + .../database/InMemoryMetadataResultSet.java | 75 ++++++++- .../java/org/dbunit/ext/h2/H2Connection.java | 5 +- .../org/dbunit/ext/h2/H2MetadataHandler.java | 61 +++++++ .../DatabaseDataSet_MultiSchemaTest.java | 79 ++++++++- .../database/DatabaseSequenceFilterIT.java | 4 +- .../InMemoryMetadataResultSetTest.java | 75 +++++++++ .../dbunit/ext/h2/H2MetadataHandlerTest.java | 153 ++++++++++++++++++ 9 files changed, 446 insertions(+), 11 deletions(-) create mode 100644 src/main/java/org/dbunit/ext/h2/H2MetadataHandler.java create mode 100644 src/test/java/org/dbunit/database/InMemoryMetadataResultSetTest.java create mode 100644 src/test/java/org/dbunit/ext/h2/H2MetadataHandlerTest.java 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..ced5975fc 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -252,6 +252,9 @@ 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). + diff --git a/src/main/java/org/dbunit/database/InMemoryMetadataResultSet.java b/src/main/java/org/dbunit/database/InMemoryMetadataResultSet.java index 3ff7a7a61..650a71cb2 100644 --- a/src/main/java/org/dbunit/database/InMemoryMetadataResultSet.java +++ b/src/main/java/org/dbunit/database/InMemoryMetadataResultSet.java @@ -28,6 +28,7 @@ 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; @@ -45,13 +46,32 @@ * {@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()} + * 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; @@ -74,6 +94,27 @@ private InMemoryMetadataResultSet(final List rows, final int columnCou * @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(); @@ -96,6 +137,10 @@ public static ResultSet merge(final List sources) throws SQLException } while (source.next()) { + if (filter != null && !filter.accept(source)) + { + continue; + } final Object[] row = new Object[columnCount]; for (int i = 1; i <= columnCount; i++) { @@ -117,19 +162,41 @@ public static ResultSet merge(final List sources) throws SQLException } /** - * Closes every result set in the given list, null- and already-closed-safe. + * 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 if closing one of them fails. + * @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) { - SQLHelper.close(resultSet); + 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) { 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/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; + } +} From 977e44fcbe7a117d176fbb73836142b491dc162b Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Sun, 9 Aug 2026 09:03:16 -0500 Subject: [PATCH 3/3] docs(site): Add IMetadataHandler Core Components page * Add 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. Cross-referenced from components.adoc, properties.adoc's metadataHandler entry, databases/h2.adoc, and site.xml's Core Components nav. * Update databases.adoc and databases/h2.adoc's IMetadataHandler/Connection Preconfiguration Class/Known Quirks sections for H2MetadataHandler. * Add the previously-undocumented MultiSchemaMySqlMetadataHandler to properties.adoc's metadataHandler entry alongside H2MetadataHandler. * Add a Test Conventions section (codingstandards/testconventions.adoc) on wrapping a delegate instead of subclassing a concrete vendor handler for test doubles, using DatabaseDataSet_MultiSchemaTest's TestMetadataHandler as the worked example. * Re-type changes.xml's existing 923 entry from fix to add, matching the GitHub issue's Bug-to-Feature retype, and add a second 923 entry for this documentation. Refs: 923 --- src/changes/changes.xml | 3 + .../codingstandards/testconventions.adoc | 43 ++++++ src/site/asciidoc/components.adoc | 1 + .../asciidoc/components/imetadatahandler.adoc | 134 ++++++++++++++++++ src/site/asciidoc/databases.adoc | 2 +- src/site/asciidoc/databases/h2.adoc | 20 ++- src/site/asciidoc/properties.adoc | 4 +- src/site/site.xml | 1 + 8 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 src/site/asciidoc/components/imetadatahandler.adoc diff --git a/src/changes/changes.xml b/src/changes/changes.xml index ced5975fc..119a4444b 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -255,6 +255,9 @@ 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/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 @@ +