diff --git a/CHANGELOG.md b/CHANGELOG.md index 696f1c0..2138914 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [PR #45](https://github.com/itsallcode/simple-jdbc/pull/45): Rename `executeStatement()` to `executeUpdate()` and return row count (**Breaking change**) - [PR #46](https://github.com/itsallcode/simple-jdbc/pull/46): Close `Statement` / `PreparedStatement` when closing the result set. - [PR #47](https://github.com/itsallcode/simple-jdbc/pull/47): Rename `BatchInsert` to `PreparedStatementBatch`, allow specifying a custom SQL statement (**Breaking change**) +- [PR #48](https://github.com/itsallcode/simple-jdbc/pull/48): Add support for database metadata ## [0.9.0] - 2024-12-23 diff --git a/settings.gradle b/settings.gradle index d32e037..9b5f2c8 100644 --- a/settings.gradle +++ b/settings.gradle @@ -10,13 +10,13 @@ dependencyResolutionManagement { } versionCatalogs { libs { - version('mockito', '5.14.2') + version('mockito', '5.15.2') version('junitJupiter', '5.11.2') - library('assertj', 'org.assertj:assertj-core:3.27.0') + library('assertj', 'org.assertj:assertj-core:3.27.2') library('h2', 'com.h2database:h2:2.3.232') library('junitPioneer', 'org.junit-pioneer:junit-pioneer:2.2.0') - library('equalsverifier', 'nl.jqno.equalsverifier:equalsverifier:3.18') + library('equalsverifier', 'nl.jqno.equalsverifier:equalsverifier:3.18.1') library('tostringverifier', 'com.jparams:to-string-verifier:1.4.8') library('hamcrest', 'org.hamcrest:hamcrest:3.0') library('hamcrestResultSetMatcher', 'com.exasol:hamcrest-resultset-matcher:1.6.3') diff --git a/src/integrationTest/java/org/itsallcode/jdbc/ExasolTypeTest.java b/src/integrationTest/java/org/itsallcode/jdbc/ExasolTypeTest.java index dee131a..4df6bef 100644 --- a/src/integrationTest/java/org/itsallcode/jdbc/ExasolTypeTest.java +++ b/src/integrationTest/java/org/itsallcode/jdbc/ExasolTypeTest.java @@ -13,7 +13,8 @@ import java.util.stream.Stream; import org.itsallcode.jdbc.resultset.SimpleResultSet; -import org.itsallcode.jdbc.resultset.generic.*; +import org.itsallcode.jdbc.resultset.generic.ColumnValue; +import org.itsallcode.jdbc.resultset.generic.Row; import org.junit.jupiter.api.*; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -155,8 +156,7 @@ void countStarResultType() { final SimpleResultSet result = connection .query("select count(*) from (select 1 from dual)")) { final Row row = result.toList().get(0); - final ColumnMetaData columnMetaData = row.columns().get(0); - assertThat(columnMetaData.type().jdbcType()).isEqualTo(JDBCType.BIGINT); + assertThat(row.columns().get(0).type().jdbcType()).isEqualTo(JDBCType.BIGINT); } } diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 90fb6ee..3ed42ce 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -74,6 +74,7 @@ exports org.itsallcode.jdbc.resultset; exports org.itsallcode.jdbc.resultset.generic; exports org.itsallcode.jdbc.dialect; + exports org.itsallcode.jdbc.metadata; requires java.logging; requires transitive java.sql; diff --git a/src/main/java/org/itsallcode/jdbc/ConnectionWrapper.java b/src/main/java/org/itsallcode/jdbc/ConnectionWrapper.java index 342cbf9..28c5712 100644 --- a/src/main/java/org/itsallcode/jdbc/ConnectionWrapper.java +++ b/src/main/java/org/itsallcode/jdbc/ConnectionWrapper.java @@ -8,6 +8,7 @@ import org.itsallcode.jdbc.batch.*; import org.itsallcode.jdbc.dialect.DbDialect; +import org.itsallcode.jdbc.metadata.DbMetaData; import org.itsallcode.jdbc.resultset.*; import org.itsallcode.jdbc.resultset.generic.Row; import org.itsallcode.jdbc.statement.ConvertingPreparedStatement; @@ -109,6 +110,18 @@ private Statement createStatement() { } } + DbMetaData getMetaData() { + return new DbMetaData(this.context, getMetaDataInternal()); + } + + private DatabaseMetaData getMetaDataInternal() { + try { + return connection.getMetaData(); + } catch (final SQLException e) { + throw new UncheckedSQLException("Failed to get metadata", e); + } + } + void setAutoCommit(final boolean autoCommit) { try { connection.setAutoCommit(autoCommit); diff --git a/src/main/java/org/itsallcode/jdbc/SimpleConnection.java b/src/main/java/org/itsallcode/jdbc/SimpleConnection.java index cead7f9..4a4ecc8 100644 --- a/src/main/java/org/itsallcode/jdbc/SimpleConnection.java +++ b/src/main/java/org/itsallcode/jdbc/SimpleConnection.java @@ -4,6 +4,7 @@ import org.itsallcode.jdbc.batch.*; import org.itsallcode.jdbc.dialect.DbDialect; +import org.itsallcode.jdbc.metadata.DbMetaData; import org.itsallcode.jdbc.resultset.RowMapper; import org.itsallcode.jdbc.resultset.SimpleResultSet; import org.itsallcode.jdbc.resultset.generic.Row; @@ -117,6 +118,16 @@ public RowPreparedStatementBatchBuilder preparedStatementBatch(final Clas return connection.rowPreparedStatementBatch(); } + /** + * Get database metadata. + * + * @return metadata + */ + public DbMetaData getMetaData() { + checkOperationAllowed(); + return connection.getMetaData(); + } + public Connection getOriginalConnection() { checkOperationAllowed(); return connection.getOriginalConnection(); diff --git a/src/main/java/org/itsallcode/jdbc/metadata/ColumnMetaData.java b/src/main/java/org/itsallcode/jdbc/metadata/ColumnMetaData.java new file mode 100644 index 0000000..232576b --- /dev/null +++ b/src/main/java/org/itsallcode/jdbc/metadata/ColumnMetaData.java @@ -0,0 +1,195 @@ +package org.itsallcode.jdbc.metadata; + +import java.sql.*; +import java.util.Arrays; + +/** + * Description of a column. + * + * @param tableCatalog table catalog (may be {@code null}) + * @param tableSchema table schema (may be {@code null}) + * @param tableName table name + * @param columnName column name + * @param dataType SQL type + * @param typeName Data source dependent type name, for a UDT the type + * name is fully qualified + * @param columnSize column size. + * @param decimalDigits the number of fractional digits. {@code null} is + * returned for data types where DECIMAL_DIGITS is not + * applicable. + * @param numPrecisionRadix Radix (typically either 10 or 2) + * @param nullable is {@code NULL} allowed. + * @param remarks comment describing column (may be {@code null}) + * @param columnDef default value for the column, which should be + * interpreted as a string when the value is enclosed + * in single quotes (may be {@code null}) + * @param charOctetLength for char types the maximum number of bytes in the + * column + * @param ordinalPosition index of column in table (starting at 1) + * @param isNullable ISO rules are used to determine the nullability for + * a column. + * @param scopeCatalog catalog of table that is the scope of a reference + * attribute ({@code null} if DATA_TYPE isn't REF) + * @param scopeSchema schema of table that is the scope of a reference + * attribute ({@code null} if the DATA_TYPE isn't REF) + * @param scopeTable table name that this the scope of a reference + * attribute ({@code null} if the DATA_TYPE isn't REF) + * @param sourceDataType source type of a distinct type or user-generated Ref + * type, SQL type from java.sql.Types ({@code null} if + * DATA_TYPE isn't DISTINCT or user-generated REF) + * @param isAutoIncrement Indicates whether this column is auto incremented + * @param isGeneratedColumn Indicates whether this is a generated column + * @see DatabaseMetaData#getColumns(String, String, String, String) + */ +public record ColumnMetaData( + String tableCatalog, + String tableSchema, + String tableName, + String columnName, + JDBCType dataType, + String typeName, + int columnSize, + int decimalDigits, + int numPrecisionRadix, + Nullability nullable, + String remarks, + String columnDef, + int charOctetLength, + int ordinalPosition, + ISONullability isNullable, + String scopeCatalog, + String scopeSchema, + String scopeTable, + short sourceDataType, + AutoIncrement isAutoIncrement, + Generated isGeneratedColumn) { + + static ColumnMetaData create(final ResultSet rs) throws SQLException { + return new ColumnMetaData( + rs.getString("TABLE_CAT"), + rs.getString("TABLE_SCHEM"), + rs.getString("TABLE_NAME"), + rs.getString("COLUMN_NAME"), + JDBCType.valueOf(rs.getInt("DATA_TYPE")), + rs.getString("TYPE_NAME"), + rs.getInt("COLUMN_SIZE"), + rs.getInt("DECIMAL_DIGITS"), + rs.getInt("NUM_PREC_RADIX"), + Nullability.valueOf(rs.getInt("NULLABLE")), + rs.getString("REMARKS"), + rs.getString("COLUMN_DEF"), + rs.getInt("CHAR_OCTET_LENGTH"), + rs.getInt("ORDINAL_POSITION"), + ISONullability.valueOfNullability(rs.getString("IS_NULLABLE")), + rs.getString("SCOPE_CATALOG"), + rs.getString("SCOPE_SCHEMA"), + rs.getString("SCOPE_TABLE"), + rs.getShort("SOURCE_DATA_TYPE"), + AutoIncrement.valueOfAutoIncrement(rs.getString("IS_AUTOINCREMENT")), + Generated.valueOfGenerated(rs.getString("IS_GENERATEDCOLUMN"))); + } + + /** + * Column nullability. + */ + public enum Nullability { + /** Column might not allow {@code NULL} values. */ + NO_NULLS(DatabaseMetaData.columnNoNulls), + /** Column definitely allows {@code NULL} values. */ + NULLABLE(DatabaseMetaData.columnNullable), + /** nullability unknown */ + NULLABLE_UNKNOWN(DatabaseMetaData.columnNullableUnknown); + + private final int value; + + Nullability(final int value) { + this.value = value; + } + + static Nullability valueOf(final int value) { + return Arrays.stream(Nullability.values()) + .filter(n -> n.value == value) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException( + "Unknown value %d for nullability".formatted(value))); + } + } + + /** + * Column ISO nullability. + */ + public enum ISONullability { + /** Column can include NULLs. */ + NO_NULLS("YES"), + /** Column cannot include NULLs. */ + NULLABLE("NO"), + /** Nullability for the column is unknown */ + NULLABLE_UNKNOWN(""); + + private final String value; + + ISONullability(final String value) { + this.value = value; + } + + static ISONullability valueOfNullability(final String value) { + return Arrays.stream(ISONullability.values()) + .filter(n -> n.value.equals(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException( + "Unknown value '%s' for ISO nullability".formatted(value))); + } + } + + /** + * Indicates whether a column is auto incremented. + */ + public enum AutoIncrement { + /** Column is auto incremented. */ + AUTO_INCREMENT("YES"), + /** Column is not auto incremented. */ + NO_AUTO_INCREMENT("NO"), + /** It cannot be determined whether the column is auto incremented. */ + UNKNOWN(""); + + private final String value; + + AutoIncrement(final String value) { + this.value = value; + } + + static AutoIncrement valueOfAutoIncrement(final String value) { + return Arrays.stream(AutoIncrement.values()) + .filter(n -> n.value.equals(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException( + "Unknown value '%s' for auto increment".formatted(value))); + } + } + + /** + * Indicates whether this is a generated column. + */ + public enum Generated { + /** This a generated column. */ + GENERATED("YES"), + /** This not a generated column. */ + NOT_GENERATED("NO"), + /** It cannot be determined whether this is a generated column. */ + UNKNOWN(""); + + private final String value; + + Generated(final String value) { + this.value = value; + } + + static Generated valueOfGenerated(final String value) { + return Arrays.stream(Generated.values()) + .filter(n -> n.value.equals(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException( + "Unknown value '%s' for column generated".formatted(value))); + } + } +} diff --git a/src/main/java/org/itsallcode/jdbc/metadata/DbMetaData.java b/src/main/java/org/itsallcode/jdbc/metadata/DbMetaData.java new file mode 100644 index 0000000..bdaea70 --- /dev/null +++ b/src/main/java/org/itsallcode/jdbc/metadata/DbMetaData.java @@ -0,0 +1,108 @@ +package org.itsallcode.jdbc.metadata; + +import java.sql.*; + +import org.itsallcode.jdbc.Context; +import org.itsallcode.jdbc.UncheckedSQLException; +import org.itsallcode.jdbc.resultset.*; + +/** + * A simple wrapper for {@link DatabaseMetaData}. + */ +public class DbMetaData { + + private final DatabaseMetaData metaData; + private final Context context; + + /** + * Create a new instance. + * + * @param context DB context. + * @param metaData metaData object. + */ + public DbMetaData(final Context context, final DatabaseMetaData metaData) { + this.context = context; + this.metaData = metaData; + } + + /** + * Retrieves a description of the tables available in the given catalog. Only + * table descriptions matching the catalog, schema, table name and type criteria + * are returned. They are ordered by {@code TABLE_TYPE}, {@code TABLE_CAT}, + * {@code TABLE_SCHEM} and {@code TABLE_NAME}. + * + * @param catalog a catalog name; must match the catalog name as it is + * stored in the database; "" retrieves those without a + * catalog; {@code null} means that the catalog name + * should not be used to narrow the search + * @param schemaPattern a schema name pattern; must match the schema name as + * it is stored in the database; "" retrieves those + * without a schema; {@code null} means that the schema + * name should not be used to narrow the search + * @param tableNamePattern a table name pattern; must match the table name as it + * is stored in the database + * @param types a list of table types, which must be from the list of + * table types returned from + * {@link DatabaseMetaData#getTableTypes()},to include; + * {@code null} returns all types + * @return table descriptions + * @see DatabaseMetaData#getTables(String, String, String, String[]) + */ + public SimpleResultSet getTables(final String catalog, final String schemaPattern, + final String tableNamePattern, final String[] types) { + return wrapResultSet(getTablesInternal(catalog, schemaPattern, tableNamePattern, types), + TableMetaData::create); + } + + private ResultSet getTablesInternal(final String catalog, final String schemaPattern, final String tableNamePattern, + final String[] types) { + try { + return metaData.getTables(catalog, schemaPattern, tableNamePattern, types); + } catch (final SQLException e) { + throw new UncheckedSQLException("Error getting tables", e); + } + } + + /** + * Retrieves a description of table columns available in the specified catalog. + *

+ * Only column descriptions matching the catalog, schema, table and column name + * criteria are returned. They are ordered by + * {@code TABLE_CAT},{@code TABLE_SCHEM}, {@code TABLE_NAME}, and + * {@code ORDINAL_POSITION}. + * + * @param catalog a catalog name; must match the catalog name as it is + * stored in the database; "" retrieves those without a + * catalog; {@code null} means that the catalog name + * should not be used to narrow the search + * @param schemaPattern a schema name pattern; must match the schema name as + * it is stored in the database; "" retrieves those + * without a schema; {@code null} means that the schema + * name should not be used to narrow the search + * @param tableNamePattern a table name pattern; must match the table name as + * it is stored in the database + * @param columnNamePattern a column name pattern; must match the column name as + * it is stored in the database + * @return column descriptions + * @see DatabaseMetaData#getColumns(String, String, String, String) + */ + public SimpleResultSet getColumns(final String catalog, final String schemaPattern, + final String tableNamePattern, final String columnNamePattern) { + return wrapResultSet(getColumnsInternal(catalog, schemaPattern, tableNamePattern, columnNamePattern), + ColumnMetaData::create); + } + + private ResultSet getColumnsInternal(final String catalog, final String schemaPattern, + final String tableNamePattern, final String columnNamePattern) { + try { + return metaData.getColumns(catalog, schemaPattern, tableNamePattern, columnNamePattern); + } catch (final SQLException e) { + throw new UncheckedSQLException("Error getting columns", e); + } + } + + private SimpleResultSet wrapResultSet(final ResultSet resultSet, final SimpleRowMapper rowMapper) { + return new SimpleResultSet<>(context, resultSet, ContextRowMapper.create(rowMapper), () -> { + }); + } +} diff --git a/src/main/java/org/itsallcode/jdbc/metadata/TableMetaData.java b/src/main/java/org/itsallcode/jdbc/metadata/TableMetaData.java new file mode 100644 index 0000000..ca3d6a2 --- /dev/null +++ b/src/main/java/org/itsallcode/jdbc/metadata/TableMetaData.java @@ -0,0 +1,43 @@ +package org.itsallcode.jdbc.metadata; + +import java.sql.*; + +/** + * Description of a table. + * + * @param tableCatalog table catalog (may be {@code null}) + * @param tableSchema table schema (may be {@code null}) + * @param tableName table name + * @param tableType table type. Typical types are "TABLE", + * "VIEW", "SYSTEM TABLE", "GLOBAL TEMPORARY", + * "LOCAL TEMPORARY", "ALIAS", "SYNONYM". + * @param remarks explanatory comment on the table (may be + * {@code null}) + * @param typeCatalog the types catalog (may be {@code null}) + * @param typeSchema the types schema (may be {@code null}) + * @param typeName type name (may be {@code null}) + * @param selfReferencingColumnName name of the designated "identifier" column + * of a typed table (may be {@code null}) + * @param refGeneration specifies how values in + * SELF_REFERENCING_COL_NAME are created. + * Values are "SYSTEM", "USER", "DERIVED". (may + * be {@code null}) + * @see DatabaseMetaData#getTables(String, String, String, String[]) + */ +public record TableMetaData(String tableCatalog, String tableSchema, String tableName, String tableType, + String remarks, String typeCatalog, String typeSchema, String typeName, String selfReferencingColumnName, + String refGeneration) { + static TableMetaData create(final ResultSet rs) throws SQLException { + return new TableMetaData( + rs.getString("TABLE_CAT"), + rs.getString("TABLE_SCHEM"), + rs.getString("TABLE_NAME"), + rs.getString("TABLE_TYPE"), + rs.getString("REMARKS"), + rs.getString("TYPE_CAT"), + rs.getString("TYPE_SCHEM"), + rs.getString("TYPE_NAME"), + rs.getString("SELF_REFERENCING_COL_NAME"), + rs.getString("REF_GENERATION")); + } +} diff --git a/src/main/java/org/itsallcode/jdbc/resultset/ContextRowMapper.java b/src/main/java/org/itsallcode/jdbc/resultset/ContextRowMapper.java index d4c0f2b..3cac805 100644 --- a/src/main/java/org/itsallcode/jdbc/resultset/ContextRowMapper.java +++ b/src/main/java/org/itsallcode/jdbc/resultset/ContextRowMapper.java @@ -53,6 +53,19 @@ private static RowMapper generic(final DbDialect dialect, final ColumnVal return new GenericRowMapper<>(dialect, converter); } + /** + * Creates a new new {@link ContextRowMapper} from a {@link SimpleRowMapper}. + *

+ * Use this if the mapper doesn't need the {@link Context}. + * + * @param generic row type + * @param mapper the simple row mapper + * @return a new {@link ContextRowMapper} + */ + static ContextRowMapper create(final SimpleRowMapper mapper) { + return (context, resultSet, rowNum) -> mapper.mapRow(resultSet); + } + /** * Creates a new new {@link ContextRowMapper} from a {@link RowMapper}. *

diff --git a/src/main/java/org/itsallcode/jdbc/resultset/SimpleResultSet.java b/src/main/java/org/itsallcode/jdbc/resultset/SimpleResultSet.java index a4131c2..941d3c6 100644 --- a/src/main/java/org/itsallcode/jdbc/resultset/SimpleResultSet.java +++ b/src/main/java/org/itsallcode/jdbc/resultset/SimpleResultSet.java @@ -8,6 +8,7 @@ import org.itsallcode.jdbc.Context; import org.itsallcode.jdbc.UncheckedSQLException; +import org.itsallcode.jdbc.resultset.generic.SimpleMetaData; /** * This class wraps a {@link ResultSet} and allows easy iteration via @@ -39,6 +40,15 @@ public SimpleResultSet(final Context context, final ResultSet resultSet, final C this.statement = statement; } + /** + * Get result set metadata. + * + * @return metadata + */ + public SimpleMetaData getMetaData() { + return SimpleMetaData.create(this.resultSet); + } + /** * Get in {@link Iterator} of all rows. * diff --git a/src/main/java/org/itsallcode/jdbc/resultset/SimpleRowMapper.java b/src/main/java/org/itsallcode/jdbc/resultset/SimpleRowMapper.java new file mode 100644 index 0000000..a5135df --- /dev/null +++ b/src/main/java/org/itsallcode/jdbc/resultset/SimpleRowMapper.java @@ -0,0 +1,21 @@ +package org.itsallcode.jdbc.resultset; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Converts a single row from a {@link ResultSet} to a generic row type. + * + * @param generic row type + */ +@FunctionalInterface +public interface SimpleRowMapper { + /** + * Converts a single row from a {@link ResultSet} to a generic row type. + * + * @param resultSet result set + * @return the converted row + * @throws SQLException if accessing the result set fails + */ + T mapRow(ResultSet resultSet) throws SQLException; +} diff --git a/src/test/java/org/itsallcode/jdbc/ConnectionWrapperTest.java b/src/test/java/org/itsallcode/jdbc/ConnectionWrapperTest.java index 747a49e..377e7ac 100644 --- a/src/test/java/org/itsallcode/jdbc/ConnectionWrapperTest.java +++ b/src/test/java/org/itsallcode/jdbc/ConnectionWrapperTest.java @@ -280,6 +280,20 @@ void isClosedFails() throws SQLException { .hasMessage("Failed to get closed state: expected"); } + @Test + void getMetaData() { + assertThat(testee().getMetaData()).isNotNull(); + } + + @Test + void getMetaDataFails() throws SQLException { + when(connectionMock.getMetaData()).thenThrow(new SQLException("expected")); + final ConnectionWrapper testee = testee(); + assertThatThrownBy(testee::getMetaData) + .isInstanceOf(UncheckedSQLException.class) + .hasMessage("Failed to get metadata: expected"); + } + @Test void getOriginalConnection() { assertThat(testee().getOriginalConnection()).isSameAs(connectionMock); diff --git a/src/test/java/org/itsallcode/jdbc/SimpleConnectionTest.java b/src/test/java/org/itsallcode/jdbc/SimpleConnectionTest.java index 10435c5..7f61fc7 100644 --- a/src/test/java/org/itsallcode/jdbc/SimpleConnectionTest.java +++ b/src/test/java/org/itsallcode/jdbc/SimpleConnectionTest.java @@ -74,7 +74,8 @@ static Stream operations() { operation(con -> con.getOriginalConnection()), operation(con -> con.statementBatch()), operation(con -> con.preparedStatementBatch()), - operation(con -> con.preparedStatementBatch(null))); + operation(con -> con.preparedStatementBatch(null)), + operation(con -> con.getMetaData())); } static Arguments operation(final Consumer operation) { diff --git a/src/test/java/org/itsallcode/jdbc/metadata/ColumnMetaDataTest.java b/src/test/java/org/itsallcode/jdbc/metadata/ColumnMetaDataTest.java new file mode 100644 index 0000000..312b1fa --- /dev/null +++ b/src/test/java/org/itsallcode/jdbc/metadata/ColumnMetaDataTest.java @@ -0,0 +1,64 @@ +package org.itsallcode.jdbc.metadata; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.itsallcode.jdbc.metadata.ColumnMetaData.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class ColumnMetaDataTest { + + @ParameterizedTest + @CsvSource({ "0,NO_NULLS", "1,NULLABLE", "2,NULLABLE_UNKNOWN" }) + void nullability(final int value, final Nullability expected) { + assertThat(Nullability.valueOf(value)).isEqualTo(expected); + } + + @Test + void nullabilityInvalid() { + assertThatThrownBy(() -> Nullability.valueOf(3)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unknown value 3 for nullability"); + } + + @ParameterizedTest + @CsvSource({ "YES,NO_NULLS", "NO,NULLABLE", "'',NULLABLE_UNKNOWN" }) + void isoNullability(final String value, final ISONullability expected) { + assertThat(ISONullability.valueOfNullability(value)).isEqualTo(expected); + } + + @Test + void isoNullabilityInvalid() { + assertThatThrownBy(() -> ISONullability.valueOfNullability("unknown")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unknown value 'unknown' for ISO nullability"); + } + + @ParameterizedTest + @CsvSource({ "YES,AUTO_INCREMENT", "NO,NO_AUTO_INCREMENT", "'',UNKNOWN" }) + void autoIncrement(final String value, final AutoIncrement expected) { + assertThat(AutoIncrement.valueOfAutoIncrement(value)).isEqualTo(expected); + } + + @Test + void autoIncrementInvalid() { + assertThatThrownBy(() -> AutoIncrement.valueOfAutoIncrement("unknown")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unknown value 'unknown' for auto increment"); + } + + @ParameterizedTest + @CsvSource({ "YES,GENERATED", "NO,NOT_GENERATED", "'',UNKNOWN" }) + void generated(final String value, final Generated expected) { + assertThat(Generated.valueOfGenerated(value)).isEqualTo(expected); + } + + @Test + void generatedUnknown() { + assertThatThrownBy(() -> Generated.valueOfGenerated("unknown")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unknown value 'unknown' for column generated"); + } +} diff --git a/src/test/java/org/itsallcode/jdbc/metadata/DbMetaDataITest.java b/src/test/java/org/itsallcode/jdbc/metadata/DbMetaDataITest.java new file mode 100644 index 0000000..7774ad1 --- /dev/null +++ b/src/test/java/org/itsallcode/jdbc/metadata/DbMetaDataITest.java @@ -0,0 +1,104 @@ +package org.itsallcode.jdbc.metadata; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.JDBCType; +import java.util.List; + +import org.itsallcode.jdbc.H2TestFixture; +import org.itsallcode.jdbc.SimpleConnection; +import org.itsallcode.jdbc.metadata.ColumnMetaData.*; +import org.itsallcode.jdbc.resultset.SimpleResultSet; +import org.junit.jupiter.api.Test; + +class DbMetaDataITest { + + @Test + void getTablesNoResult() { + try (final SimpleConnection connection = H2TestFixture.createMemConnection()) { + final List tables = connection.getMetaData().getTables("unknown", null, null, null).toList(); + assertThat(tables).isEmpty(); + } + } + + @Test + void getTables() { + try (final SimpleConnection connection = H2TestFixture.createMemConnection()) { + final List tables = connection.getMetaData().getTables(null, null, null, null).toList(); + assertThat(tables) + .hasSize(35) + .first() + .isEqualTo(new TableMetaData("UNNAMED", "INFORMATION_SCHEMA", "CONSTANTS", "BASE TABLE", + null, null, null, null, null, null)); + } + } + + @Test + void getTablesMetaData() { + try (final SimpleConnection connection = H2TestFixture.createMemConnection(); + final SimpleResultSet result = connection.getMetaData().getTables(null, null, null, + null)) { + assertThat(result.getMetaData().columns()) + .extracting(org.itsallcode.jdbc.resultset.generic.ColumnMetaData::name).containsExactly("TABLE_CAT", + "TABLE_SCHEM", "TABLE_NAME", "TABLE_TYPE", "REMARKS", "TYPE_CAT", "TYPE_SCHEM", "TYPE_NAME", + "SELF_REFERENCING_COL_NAME", "REF_GENERATION"); + } + } + + @Test + void getColumnsNoResult() { + try (final SimpleConnection connection = H2TestFixture.createMemConnection()) { + final List columns = connection.getMetaData().getColumns("unknown", null, null, null) + .toList(); + assertThat(columns).isEmpty(); + } + } + + @Test + void getColumns() { + try (final SimpleConnection connection = H2TestFixture.createMemConnection()) { + final List columns = connection.getMetaData().getColumns(null, null, null, null).toList(); + assertThat(columns) + .hasSize(451) + .first() + .isEqualTo(new ColumnMetaData("UNNAMED", "INFORMATION_SCHEMA", "CHECK_CONSTRAINTS", + "CONSTRAINT_CATALOG", JDBCType.VARCHAR, "CHARACTER VARYING", 1000000000, 0, 0, + Nullability.NULLABLE, null, + null, 1000000000, 1, ISONullability.NO_NULLS, null, null, null, (short) 0, + AutoIncrement.NO_AUTO_INCREMENT, Generated.NOT_GENERATED)); + } + } + + @Test + void getColumnsFilterByTable() { + try (final SimpleConnection connection = H2TestFixture.createMemConnection()) { + final List columns = connection.getMetaData() + .getColumns(null, null, "CHECK_CONSTRAINTS", null).toList(); + assertThat(columns).hasSize(4); + } + } + + @Test + void getColumnsFilterByColumn() { + try (final SimpleConnection connection = H2TestFixture.createMemConnection()) { + final List columns = connection.getMetaData() + .getColumns(null, null, "CHECK_CONSTRAINTS", "CONSTRAINT_CATALOG").toList(); + assertThat(columns).hasSize(1); + } + } + + @Test + void getColumnsMetaData() { + try (final SimpleConnection connection = H2TestFixture.createMemConnection(); + final SimpleResultSet result = connection.getMetaData().getColumns(null, null, null, + null)) { + assertThat(result.getMetaData().columns()) + .extracting(org.itsallcode.jdbc.resultset.generic.ColumnMetaData::name) + .containsExactly("TABLE_CAT", "TABLE_SCHEM", "TABLE_NAME", "COLUMN_NAME", "DATA_TYPE", + "TYPE_NAME", "COLUMN_SIZE", "BUFFER_LENGTH", "DECIMAL_DIGITS", "NUM_PREC_RADIX", + "NULLABLE", "REMARKS", "COLUMN_DEF", "SQL_DATA_TYPE", "SQL_DATETIME_SUB", + "CHAR_OCTET_LENGTH", "ORDINAL_POSITION", "IS_NULLABLE", "SCOPE_CATALOG", "SCOPE_SCHEMA", + "SCOPE_TABLE", "SOURCE_DATA_TYPE", "IS_AUTOINCREMENT", "IS_GENERATEDCOLUMN"); + } + } +} diff --git a/src/test/java/org/itsallcode/jdbc/metadata/DbMetaDataTest.java b/src/test/java/org/itsallcode/jdbc/metadata/DbMetaDataTest.java new file mode 100644 index 0000000..de6f417 --- /dev/null +++ b/src/test/java/org/itsallcode/jdbc/metadata/DbMetaDataTest.java @@ -0,0 +1,55 @@ +package org.itsallcode.jdbc.metadata; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +import java.sql.DatabaseMetaData; +import java.sql.SQLException; + +import org.itsallcode.jdbc.Context; +import org.itsallcode.jdbc.UncheckedSQLException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DbMetaDataTest { + @Mock + DatabaseMetaData metaDataMock; + + @Test + void getTables() { + assertThat(testee().getTables("catalog", "schema", "table", new String[] { "type" })).isNotNull(); + } + + @Test + void getTablesFails() throws SQLException { + when(metaDataMock.getTables("catalog", "schema", "table", new String[] { "type" })) + .thenThrow(new SQLException("expected")); + final DbMetaData testee = testee(); + assertThatThrownBy(() -> testee.getTables("catalog", "schema", "table", new String[] { "type" })) + .isInstanceOf(UncheckedSQLException.class) + .hasMessage("Error getting tables: expected"); + } + + @Test + void getColumns() { + assertThat(testee().getColumns("catalog", "schema", "table", "column")).isNotNull(); + } + + @Test + void getColumnsFails() throws SQLException { + when(metaDataMock.getColumns("catalog", "schema", "table", "column")) + .thenThrow(new SQLException("expected")); + final DbMetaData testee = testee(); + assertThatThrownBy(() -> testee.getColumns("catalog", "schema", "table", "column")) + .isInstanceOf(UncheckedSQLException.class) + .hasMessage("Error getting columns: expected"); + } + + DbMetaData testee() { + return new DbMetaData(Context.builder().build(), metaDataMock); + } +}