diff --git a/doc/changes/changelog.md b/doc/changes/changelog.md index 77f1ed1..1b384a7 100644 --- a/doc/changes/changelog.md +++ b/doc/changes/changelog.md @@ -1,5 +1,6 @@ # Changes +* [10.3.0](changes_10.3.0.md) * [10.2.0](changes_10.2.0.md) * [10.1.0](changes_10.1.0.md) * [10.0.2](changes_10.0.2.md) diff --git a/doc/changes/changes_10.3.0.md b/doc/changes/changes_10.3.0.md new file mode 100644 index 0000000..508466f --- /dev/null +++ b/doc/changes/changes_10.3.0.md @@ -0,0 +1,27 @@ +# Virtual Schema Common JDBC 10.3.0, released 2023-03-10 + +Code name: Escape Wildcards + +## Summary + +This release fixes ambiguous results by escaping SQL wildcards such as underscore `_` and percent `%` in names of catalogs, schemas, and tables when retrieving column metadata from JDBC driver. + +The release also adds a constructor enabling derived SQL dialects to add additional validators for adapter properties, hence removing the need to override method `AbstractSqlDialect.validateProperties()`. + +## Bugfixes + +* #136: Fixed column lookup for tables not escaping wildcards +* #138: Enabled SQL dialects to add property validators + +## Dependency Updates + +### Compile Dependency Updates + +* Updated `com.exasol:error-reporting-java:1.0.0` to `1.0.1` + +### Test Dependency Updates + +* Updated `com.exasol:java-util-logging-testing:2.0.2` to `2.0.3` +* Updated `nl.jqno.equalsverifier:equalsverifier:3.11.1` to `3.14` +* Updated `org.junit.jupiter:junit-jupiter:5.9.1` to `5.9.2` +* Updated `org.mockito:mockito-junit-jupiter:4.9.0` to `5.1.1` diff --git a/pk_generated_parent.pom b/pk_generated_parent.pom index df5d69b..14585a2 100644 --- a/pk_generated_parent.pom +++ b/pk_generated_parent.pom @@ -3,7 +3,7 @@ 4.0.0 com.exasol virtual-schema-common-jdbc-generated-parent - 10.2.0 + 10.3.0 pom UTF-8 diff --git a/pom.xml b/pom.xml index 94ba4bc..6ad5098 100644 --- a/pom.xml +++ b/pom.xml @@ -2,7 +2,7 @@ 4.0.0 virtual-schema-common-jdbc - 10.2.0 + 10.3.0 Virtual Schema Common JDBC Common module for JDBC-based data access from Virtual Schemas. https://github.com/exasol/virtual-schema-common-jdbc/ @@ -15,12 +15,12 @@ com.exasol error-reporting-java - 1.0.0 + 1.0.1 com.exasol java-util-logging-testing - 2.0.2 + 2.0.3 test @@ -38,19 +38,19 @@ org.junit.jupiter junit-jupiter - 5.9.1 + 5.9.2 test nl.jqno.equalsverifier equalsverifier - 3.11.1 + 3.14 test org.mockito mockito-junit-jupiter - 4.9.0 + 5.1.1 test @@ -122,7 +122,7 @@ virtual-schema-common-jdbc-generated-parent com.exasol - 10.2.0 + 10.3.0 pk_generated_parent.pom diff --git a/src/main/java/com/exasol/adapter/dialects/AbstractSqlDialect.java b/src/main/java/com/exasol/adapter/dialects/AbstractSqlDialect.java index 31b5c5e..72a7ed9 100644 --- a/src/main/java/com/exasol/adapter/dialects/AbstractSqlDialect.java +++ b/src/main/java/com/exasol/adapter/dialects/AbstractSqlDialect.java @@ -46,6 +46,22 @@ public abstract class AbstractSqlDialect implements SqlDialect { */ protected AbstractSqlDialect(final ConnectionFactory connectionFactory, final AdapterProperties properties, final Set dialectSpecificProperties) { + this(connectionFactory, properties, dialectSpecificProperties, List.of()); + } + + /** + * Create a new instance of an {@link AbstractSqlDialect}. + * + * @param connectionFactory factory for JDBC connection to remote data source + * @param properties user properties + * @param dialectSpecificProperties a set of properties that dialect supports additionally to the common set + * {@link com.exasol.adapter.dialects.AbstractSqlDialect#COMMON_SUPPORTED_PROPERTIES} + * @param dialectSpecificPropertyValidators a collection of property validators the dialect wants to apply + * additionally to the common validators + */ + protected AbstractSqlDialect(final ConnectionFactory connectionFactory, final AdapterProperties properties, + final Set dialectSpecificProperties, + final Collection dialectSpecificPropertyValidators) { this.connectionFactory = connectionFactory; this.properties = properties; this.supportedProperties = new SupportedPropertiesValidator() // @@ -59,7 +75,8 @@ protected AbstractSqlDialect(final ConnectionFactory connectionFactory, final Ad .add(PropertyValidator.forStructureElement(supportsJdbcSchemas(), "schemas", SCHEMA_NAME_PROPERTY)) .add(ExceptionHandlingProperty.validator()) // .add(DataTypeDetection.getValidator()) // - .add(TableCountLimit.getValidator()); + .add(TableCountLimit.getValidator()) // + .addAll(dialectSpecificPropertyValidators); } /** diff --git a/src/main/java/com/exasol/adapter/jdbc/BaseColumnMetadataReader.java b/src/main/java/com/exasol/adapter/jdbc/BaseColumnMetadataReader.java index 138e76e..6429da0 100644 --- a/src/main/java/com/exasol/adapter/jdbc/BaseColumnMetadataReader.java +++ b/src/main/java/com/exasol/adapter/jdbc/BaseColumnMetadataReader.java @@ -75,7 +75,7 @@ public List mapColumns(final String tableName) { /** * Read the columns metadata from a result set. - * + * * @param catalogName catalog name * @param schemaName schema name * @param tableName table name @@ -83,8 +83,7 @@ public List mapColumns(final String tableName) { */ protected List mapColumns(final String catalogName, final String schemaName, final String tableName) { - try (final ResultSet remoteColumns = this.connection.getMetaData().getColumns(catalogName, schemaName, - tableName, ANY_COLUMN)) { + try (final ResultSet remoteColumns = getColumnMetadata(catalogName, schemaName, tableName)) { return getColumnsFromResultSet(remoteColumns); } catch (final SQLException exception) { throw new RemoteMetadataReaderException(ExaError.messageBuilder("E-VSCJDBC-1").message( @@ -93,9 +92,18 @@ protected List mapColumns(final String catalogName, final String } } + private ResultSet getColumnMetadata(final String catalogName, final String schemaName, final String tableName) + throws SQLException { + return this.connection.getMetaData().getColumns( // + catalogName == null ? null : Wildcards.escape(catalogName), // + schemaName == null ? null : Wildcards.escape(schemaName), // + Wildcards.escape(tableName), // + ANY_COLUMN); + } + /** * Read the columns result set. - * + * * @param remoteColumns column result set. * @return list of column metadata * @throws SQLException if read fails @@ -110,7 +118,7 @@ protected List getColumnsFromResultSet(final ResultSet remoteCol /** * Read the column metadata from result set if supported. Otherwise, skip. - * + * * @param remoteColumns column result set * @param columns list to append column to * @throws SQLException if read fails @@ -150,7 +158,7 @@ private ColumnMetadata mapColumn(final ResultSet remoteColumn) throws SQLExcepti /** * Read the JDBC type description of a column. - * + * * @param remoteColumn result set column * @return JDBC type description * @throws SQLException if read fails @@ -186,7 +194,7 @@ private String readTypeName(final ResultSet remoteColumn) throws SQLException { /** * Check if a column a nullable. - * + * * @param remoteColumn column result set * @param columnName column name * @return {@code true} if remote column is nullable @@ -256,7 +264,7 @@ private String readColumnTypeName(final ResultSet remoteColumn) throws SQLExcept /** * Read the column name form result set. - * + * * @param columns column result set * @return column name * @throws SQLException if read fails @@ -332,7 +340,7 @@ private static DataType convertBigInteger(final int jdbcPrecision) { /** * Build a data type for a decimal value. - * + * * @param jdbcPrecision precision * @param scale scale * @return built data type @@ -374,7 +382,7 @@ private static DataType convertChar(final int size, final int octetLength) { /** * Convert the column name. - * + * * @param columnName column name * @return column name */ @@ -404,7 +412,7 @@ protected DataType mapJdbcTypeNumericToDecimalWithFallbackToDouble(final JDBCTyp /** * Parse a number type property. - * + * * @param property formatted string: {@code .} * @return data type */ diff --git a/src/main/java/com/exasol/adapter/jdbc/Wildcards.java b/src/main/java/com/exasol/adapter/jdbc/Wildcards.java new file mode 100644 index 0000000..5f29e6d --- /dev/null +++ b/src/main/java/com/exasol/adapter/jdbc/Wildcards.java @@ -0,0 +1,25 @@ +package com.exasol.adapter.jdbc; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Escape SQL wildcards in string to enable to request column metadata from JDBC driver with exact match for names of + * catalog, schema, and table. + */ +public class Wildcards { + private static final Pattern PATTERN = Pattern.compile("[_%]"); + + /** + * @param input Name of catalog, schema, or table + * @return name with potential wildcards escaped. + */ + public static String escape(final String input) { + final Matcher matcher = PATTERN.matcher(input); + return matcher.find() ? matcher.replaceAll("\\\\$0") : input; + } + + private Wildcards() { + // only static usage + } +} diff --git a/src/main/java/com/exasol/adapter/properties/ValidatorChain.java b/src/main/java/com/exasol/adapter/properties/ValidatorChain.java index bcde36d..0ba499e 100644 --- a/src/main/java/com/exasol/adapter/properties/ValidatorChain.java +++ b/src/main/java/com/exasol/adapter/properties/ValidatorChain.java @@ -1,7 +1,6 @@ package com.exasol.adapter.properties; -import java.util.ArrayList; -import java.util.List; +import java.util.*; import com.exasol.adapter.AdapterProperties; @@ -23,6 +22,17 @@ public ValidatorChain add(final PropertyValidator validator) { return this; } + /** + * Add a list of property validators to the validator chain. + * + * @param validators validators to add to the current chain of validators + * @return this for fluent programming + */ + public ValidatorChain addAll(final Collection validators) { + this.propertyValidators.addAll(validators); + return this; + } + @Override public void validate(final AdapterProperties properties) throws PropertyValidationException { for (final PropertyValidator validator : this.propertyValidators) { diff --git a/src/test/java/com/exasol/adapter/jdbc/BaseRemoteMetadataReaderTest.java b/src/test/java/com/exasol/adapter/jdbc/BaseRemoteMetadataReaderTest.java index 46f0554..3adbca4 100644 --- a/src/test/java/com/exasol/adapter/jdbc/BaseRemoteMetadataReaderTest.java +++ b/src/test/java/com/exasol/adapter/jdbc/BaseRemoteMetadataReaderTest.java @@ -4,8 +4,10 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.sql.*; @@ -86,7 +88,8 @@ private void mockTableA(final DatabaseMetaData remoteMetadataMock) throws SQLExc when(tableAColumns.getString(BaseColumnMetadataReader.TYPE_NAME_COLUMN)).thenReturn("BOOLEAN", "DATE"); when(tableAColumns.getString(BaseColumnMetadataReader.NAME_COLUMN)).thenReturn("COLUMN_A1", "COLUMN_A2"); when(tableAColumns.getInt(BaseColumnMetadataReader.DATA_TYPE_COLUMN)).thenReturn(Types.BOOLEAN, Types.DATE); - when(remoteMetadataMock.getColumns(any(), any(), eq(TABLE_A), any())).thenReturn(tableAColumns); + when(remoteMetadataMock.getColumns(any(), any(), eq(Wildcards.escape(TABLE_A)), any())) + .thenReturn(tableAColumns); } private void mockTableB(final DatabaseMetaData remoteMetadataMock) throws SQLException { @@ -96,7 +99,8 @@ private void mockTableB(final DatabaseMetaData remoteMetadataMock) throws SQLExc when(tableBColumns.getInt(BaseColumnMetadataReader.DATA_TYPE_COLUMN)).thenReturn(Types.BOOLEAN, Types.DOUBLE); when(tableBColumns.getString(BaseColumnMetadataReader.NAME_COLUMN)).thenReturn("COLUMN_B1", "COLUMN_B2", "COLUMN_B3"); - when(remoteMetadataMock.getColumns(any(), any(), eq(TABLE_B), any())).thenReturn(tableBColumns); + when(remoteMetadataMock.getColumns(any(), any(), eq(Wildcards.escape(TABLE_B)), any())) + .thenReturn(tableBColumns); } private void mockGetTableCalls(final DatabaseMetaData remoteMetadataMock) throws SQLException { @@ -252,4 +256,29 @@ void testGetSupportedTableTypes() { assertThat(new BaseRemoteMetadataReader(null, AdapterProperties.emptyProperties()).getSupportedTableTypes(), containsInAnyOrder("TABLE", "VIEW", "SYSTEM TABLE")); } + + @Test + void testEscapeSchemaName() throws SQLException { + verifyEscapeSchemaOrCatalog(null, "THE\\_SCHEMA", Map.of("SCHEMA_NAME", "THE_SCHEMA")); + } + + @Test + void testEscapeCatalogName() throws SQLException { + verifyEscapeSchemaOrCatalog("THE\\_CATALOG", null, Map.of("CATALOG_NAME", "THE_CATALOG")); + } + + void verifyEscapeSchemaOrCatalog(final String cat, final String schema, final Map properties) + throws SQLException { + final DatabaseMetaData metadataMock = mock(DatabaseMetaData.class); + final Connection connection = mock(Connection.class); + when(connection.getMetaData()).thenReturn(metadataMock); + when(metadataMock.getColumns(cat, schema, "THE\\_TABLE", "%")).thenThrow(new SpecialException()); + final BaseColumnMetadataReader testee = new BaseColumnMetadataReader(connection, + new AdapterProperties(properties), null); + assertThrows(SpecialException.class, () -> testee.mapColumns("THE_TABLE")); + } + + static class SpecialException extends RuntimeException { + private static final long serialVersionUID = 1L; + } } \ No newline at end of file diff --git a/src/test/java/com/exasol/adapter/jdbc/ColumnMetadataReaderTest.java b/src/test/java/com/exasol/adapter/jdbc/ColumnMetadataReaderTest.java index 5bade85..fe7abbc 100644 --- a/src/test/java/com/exasol/adapter/jdbc/ColumnMetadataReaderTest.java +++ b/src/test/java/com/exasol/adapter/jdbc/ColumnMetadataReaderTest.java @@ -65,14 +65,14 @@ private ColumnMetadata mapSingleMockedColumn(final String originalTypeName) thro when(this.columnsMock.next()).thenReturn(true, false); when(this.columnsMock.getString(NAME_COLUMN)).thenReturn(originalTypeName + "_COLUMN"); when(this.columnsMock.getString(TYPE_NAME_COLUMN)).thenReturn(originalTypeName); - when(this.remoteMetadataMock.getColumns(null, null, "THE_TABLE", "%")).thenReturn(this.columnsMock); + when(this.remoteMetadataMock.getColumns(null, null, "THE\\_TABLE", "%")).thenReturn(this.columnsMock); final List columns = mapMockedColumns(this.columnsMock); return columns.get(0); } private List mapMockedColumns(final ResultSet columnsMock) throws RemoteMetadataReaderException, SQLException { - when(this.remoteMetadataMock.getColumns(null, null, "THE_TABLE", "%")).thenReturn(columnsMock); + when(this.remoteMetadataMock.getColumns(null, null, "THE\\_TABLE", "%")).thenReturn(columnsMock); return createDefaultColumnMetadataReader().mapColumns("THE_TABLE"); } @@ -348,7 +348,7 @@ void testMapColumnWithTypeNameNull() throws SQLException { when(this.columnsMock.next()).thenReturn(true, false); when(this.columnsMock.getString(NAME_COLUMN)).thenReturn("DOUBLE_COLUMN"); when(this.columnsMock.getString(TYPE_NAME_COLUMN)).thenReturn(null); - when(this.remoteMetadataMock.getColumns(null, null, "THE_TABLE", "%")).thenReturn(this.columnsMock); + when(this.remoteMetadataMock.getColumns(null, null, "THE\\_TABLE", "%")).thenReturn(this.columnsMock); final List columns = mapMockedColumns(this.columnsMock); final ColumnMetadata columnMetadata = columns.get(0); assertThat(columnMetadata.getOriginalTypeName(), equalTo("")); diff --git a/src/test/java/com/exasol/adapter/jdbc/WildcardsTest.java b/src/test/java/com/exasol/adapter/jdbc/WildcardsTest.java new file mode 100644 index 0000000..72a5542 --- /dev/null +++ b/src/test/java/com/exasol/adapter/jdbc/WildcardsTest.java @@ -0,0 +1,27 @@ +package com.exasol.adapter.jdbc; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class WildcardsTest { + @ParameterizedTest + @CsvSource(value = { // + "abc, abc", // + "a _ a, a \\_ a", // + "a % a, a \\% a", // + "a _ b _ c, a \\_ b \\_ c", // + "a_b%c, a\\_b\\%c", // + }) + void test(final String input, final String expected) { + assertThat(Wildcards.escape(input), equalTo(expected)); + } + + @Test + void testEmpty() { + assertThat(Wildcards.escape(""), equalTo("")); + } +}