diff --git a/README.md b/README.md index 2ed9e38..2e7c2e1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Virtual Schema Common Module for JDBC-based Data Access [![Build Status](https://github.com/exasol/virtual-schema-common-jdbc/actions/workflows/ci-build.yml/badge.svg)](https://github.com/exasol/virtual-schema-common-jdbc/actions/workflows/ci-build.yml) -[![Maven Central – Virtual Schema Common JDBC](https://img.shields.io/maven-central/v/com.exasol/virtual-schema-common-jdbc)](https://search.maven.org/artifact/com.exasol/virtual-schema-common-jdbc) +[![Maven Central – Virtual Schema Common JDBC](https://img.shields.io/maven-central/v/com.exasol/virtual-schema-common-jdbc)](https://search.maven.org/artifact/com.exasol/virtual-schema-common-jdbc) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=com.exasol%3Avirtual-schema-common-jdbc&metric=alert_status)](https://sonarcloud.io/dashboard?id=com.exasol%3Avirtual-schema-common-jdbc) diff --git a/dependencies.md b/dependencies.md index d8ffc61..a134512 100644 --- a/dependencies.md +++ b/dependencies.md @@ -26,12 +26,12 @@ | ------------------------------------------------------- | --------------------------------- | | [SonarQube Scanner for Maven][17] | [GNU LGPL 3][18] | | [Apache Maven Compiler Plugin][19] | [Apache License, Version 2.0][13] | -| [Apache Maven Enforcer Plugin][20] | [Apache License, Version 2.0][13] | +| [Apache Maven Enforcer Plugin][20] | [Apache-2.0][13] | | [Maven Flatten Plugin][21] | [Apache Software Licenese][13] | | [org.sonatype.ossindex.maven:ossindex-maven-plugin][22] | [ASL2][9] | | [Maven Surefire Plugin][23] | [Apache License, Version 2.0][13] | | [Versions Maven Plugin][24] | [Apache License, Version 2.0][13] | -| [Apache Maven Deploy Plugin][25] | [Apache License, Version 2.0][13] | +| [Apache Maven Deploy Plugin][25] | [Apache-2.0][13] | | [Apache Maven GPG Plugin][26] | [Apache License, Version 2.0][13] | | [Apache Maven Source Plugin][27] | [Apache License, Version 2.0][13] | | [Apache Maven Javadoc Plugin][28] | [Apache License, Version 2.0][13] | diff --git a/doc/changes/changelog.md b/doc/changes/changelog.md index c017d76..f8e26b5 100644 --- a/doc/changes/changelog.md +++ b/doc/changes/changelog.md @@ -1,5 +1,6 @@ # Changes +* [10.5.0](changes_10.5.0.md) * [10.4.0](changes_10.4.0.md) * [10.3.0](changes_10.3.0.md) * [10.2.0](changes_10.2.0.md) diff --git a/doc/changes/changes_10.5.0.md b/doc/changes/changes_10.5.0.md new file mode 100644 index 0000000..66e4f10 --- /dev/null +++ b/doc/changes/changes_10.5.0.md @@ -0,0 +1,38 @@ +# Virtual Schema Common JDBC 10.5.0, released 2023-03-15 + +Code name: Escape SQL Wild Cards Optionally + +## Summary + +Release 10.3.0 introduced escaping wild cards in the names of database schemas and tables when retrieving column metadata from JDBC. + +The current release fixes two problems in this area + +| Problem | Fix | +|------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------| +| VSCJDBC also escaped wild cards in the name of the database catalog, conflicting with the parameter's documentation as literal string. | Do not escape potential wild cards in the name of the database catalog. | +| VSCJDBC always used the backslash as escape string, while there are SQL dialects with different escape string, e.g. VSORA using a forward slash `/`. | Use `java.sql.DatabaseMetaData.getSearchStringEscape()` to inquire the escape string for the specific SQL dialect. | + +Additionally the current release makes wild card escaping optional. In case of problems SQL dialects then can simply override `BaseColumnMetadataReader.getColumnMetadata`: +```java +@Override +protected ResultSet getColumnMetadata(String catalogName, String schemaName, String tableName) throws SQLException { + return getColumnMetadataAllowingPatterns(catalogName, schemaName, tableName); +} +``` + +## Bugfixes + +* #142: Fixed escaping wildcards in column lookup and made escaping optional + +## Dependency Updates + +### Test Dependency Updates + +* Updated `org.mockito:mockito-junit-jupiter:5.1.1` to `5.2.0` + +### Plugin Dependency Updates + +* Updated `com.exasol:project-keeper-maven-plugin:2.9.3` to `2.9.4` +* Updated `org.apache.maven.plugins:maven-deploy-plugin:3.0.0` to `3.1.0` +* Updated `org.apache.maven.plugins:maven-enforcer-plugin:3.1.0` to `3.2.1` diff --git a/pk_generated_parent.pom b/pk_generated_parent.pom index 9a8030e..d65f9e0 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.4.0 + 10.5.0 pom UTF-8 @@ -62,7 +62,7 @@ org.apache.maven.plugins maven-enforcer-plugin - 3.1.0 + 3.2.1 enforce-maven @@ -150,7 +150,7 @@ org.apache.maven.plugins maven-deploy-plugin - 3.0.0 + 3.1.0 true diff --git a/pom.xml b/pom.xml index 5687e28..8ba6d54 100644 --- a/pom.xml +++ b/pom.xml @@ -2,7 +2,7 @@ 4.0.0 virtual-schema-common-jdbc - 10.4.0 + 10.5.0 Virtual Schema Common JDBC Common module for JDBC-based data access from Virtual Schemas. https://github.com/exasol/virtual-schema-common-jdbc/ @@ -50,7 +50,7 @@ org.mockito mockito-junit-jupiter - 5.1.1 + 5.2.0 test @@ -84,7 +84,7 @@ com.exasol project-keeper-maven-plugin - 2.9.3 + 2.9.4 @@ -122,7 +122,7 @@ virtual-schema-common-jdbc-generated-parent com.exasol - 10.4.0 + 10.5.0 pk_generated_parent.pom diff --git a/src/main/java/com/exasol/adapter/jdbc/BaseColumnMetadataReader.java b/src/main/java/com/exasol/adapter/jdbc/BaseColumnMetadataReader.java index 6429da0..9a4652c 100644 --- a/src/main/java/com/exasol/adapter/jdbc/BaseColumnMetadataReader.java +++ b/src/main/java/com/exasol/adapter/jdbc/BaseColumnMetadataReader.java @@ -92,15 +92,40 @@ protected List mapColumns(final String catalogName, final String } } - private ResultSet getColumnMetadata(final String catalogName, final String schemaName, final String tableName) + /** + * Read column metadata from JDBC driver escaping potential SQL wild cards in the names of schema and table. + * + * @param catalogName catalog name + * @param schemaName schema name, potential SQL wildcards will be escaped + * @param tableName table name, potential SQL wildcards will be escaped + * @return list with metadata for all columns of the respective catalog, schema, and table + * @throws SQLException in case of failures + */ + protected 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), // + final DatabaseMetaData metadata = this.connection.getMetaData(); + final WildcardEscaper wildcards = WildcardEscaper.instance(metadata.getSearchStringEscape()); + return metadata.getColumns(catalogName, // + schemaName == null ? null : wildcards.escape(schemaName), // + tableName == null ? null : wildcards.escape(tableName), // ANY_COLUMN); } + /** + * Read column metadata from JDBC driver without escaping potential SQL wild cards in the names of schema and table. + * + * @param catalogName catalog name + * @param schemaNamePattern schema name pattern, may contain SQL wildcards + * @param tableNamePattern table name pattern, may contain SQL wildcards + * @return list with metadata for all columns of the respective catalog, matching schema name pattern, and table + * name pattern + * @throws SQLException in case of failures + */ + protected ResultSet getColumnMetadataAllowingPatterns(final String catalogName, final String schemaNamePattern, + final String tableNamePattern) throws SQLException { + return this.connection.getMetaData().getColumns(catalogName, schemaNamePattern, tableNamePattern, ANY_COLUMN); + } + /** * Read the columns result set. * diff --git a/src/main/java/com/exasol/adapter/jdbc/BaseTableMetadataReader.java b/src/main/java/com/exasol/adapter/jdbc/BaseTableMetadataReader.java index f41ab85..95601ad 100644 --- a/src/main/java/com/exasol/adapter/jdbc/BaseTableMetadataReader.java +++ b/src/main/java/com/exasol/adapter/jdbc/BaseTableMetadataReader.java @@ -100,7 +100,6 @@ private Optional getTableMetadata(final ResultSet remoteTables, f throws SQLException { final TableMetadata tableMetadata = mapTable(remoteTables, tableName); if (tableHasColumns(tableMetadata)) { - LOGGER.finer(() -> "Read table metadata: " + tableMetadata.describe()); return Optional.of(tableMetadata); } else { logSkippingTableWithEmptyColumns(tableName); diff --git a/src/main/java/com/exasol/adapter/jdbc/JDBCAdapter.java b/src/main/java/com/exasol/adapter/jdbc/JDBCAdapter.java index 0c2525f..7021bd6 100644 --- a/src/main/java/com/exasol/adapter/jdbc/JDBCAdapter.java +++ b/src/main/java/com/exasol/adapter/jdbc/JDBCAdapter.java @@ -61,7 +61,7 @@ public CreateVirtualSchemaResponse createVirtualSchema(final ExaMetadata exasolM * @param request create request */ protected void logCreateVirtualSchemaRequestReceived(final CreateVirtualSchemaRequest request) { - LOGGER.fine(() -> "Received request to create Virutal Schema \"" + request.getVirtualSchemaName() + "\"."); + LOGGER.fine(() -> "Received request to create Virtual Schema \"" + request.getVirtualSchemaName() + "\"."); } private AdapterProperties getPropertiesFromRequest(final AdapterRequest request) { diff --git a/src/main/java/com/exasol/adapter/jdbc/WildcardEscaper.java b/src/main/java/com/exasol/adapter/jdbc/WildcardEscaper.java new file mode 100644 index 0000000..6c26118 --- /dev/null +++ b/src/main/java/com/exasol/adapter/jdbc/WildcardEscaper.java @@ -0,0 +1,45 @@ +package com.exasol.adapter.jdbc; + +import java.sql.DatabaseMetaData; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Escape SQL wild cards in string to enable to request column metadata from JDBC driver with exact match for names of + * catalog, schema, and table. + */ +public class WildcardEscaper { + + private static final Pattern REGEX_WILDCARDS = Pattern.compile("[\\\\$]"); + private static final Pattern SQL_WINDCARDS = Pattern.compile("[_%]"); + + /** + * Create a new instance of the {@link WildcardEscaper}. + *

+ * Use {@link DatabaseMetaData#getSearchStringEscape()} to get the escape string for wild card characters. + * + * @param searchStringEscape string that should be used to escape SQL wild cards + * @return new instance of the {@link WildcardEscaper} + */ + public static WildcardEscaper instance(final String searchStringEscape) { + final String escaped = new WildcardEscaper(REGEX_WILDCARDS, "\\\\").escape(searchStringEscape); + return new WildcardEscaper(SQL_WINDCARDS, escaped); + } + + private final Pattern pattern; + private final String replacement; + + WildcardEscaper(final Pattern pattern, final String replacement) { + this.pattern = pattern; + this.replacement = replacement + "$0"; + } + + /** + * @param input Name of catalog, schema, or table + * @return name with potential wild cards escaped. + */ + public String escape(final String input) { + final Matcher matcher = this.pattern.matcher(input); + return matcher.find() ? matcher.replaceAll(this.replacement) : input; + } +} diff --git a/src/main/java/com/exasol/adapter/jdbc/Wildcards.java b/src/main/java/com/exasol/adapter/jdbc/Wildcards.java deleted file mode 100644 index 5f29e6d..0000000 --- a/src/main/java/com/exasol/adapter/jdbc/Wildcards.java +++ /dev/null @@ -1,25 +0,0 @@ -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/test/java/com/exasol/adapter/jdbc/BaseRemoteMetadataReaderTest.java b/src/test/java/com/exasol/adapter/jdbc/BaseRemoteMetadataReaderTest.java index 3adbca4..8f0270a 100644 --- a/src/test/java/com/exasol/adapter/jdbc/BaseRemoteMetadataReaderTest.java +++ b/src/test/java/com/exasol/adapter/jdbc/BaseRemoteMetadataReaderTest.java @@ -27,10 +27,11 @@ class BaseRemoteMetadataReaderTest { private static final String IDENTIFIER_QUOTE_STRING = "identifier-quote-string"; private static final String CATALOG_SEPARATOR = "catalog-separator"; + private static final String ESCAPE_STRING = "\\"; @Test void testReadEmptyRemoteMetadata() throws RemoteMetadataReaderException, SQLException { - final DatabaseMetaData remoteMetadataMock = mockSupportingMetadata(); + final DatabaseMetaData remoteMetadataMock = mockSupportingMetadata(false); final Connection connectionMock = mockConnection(remoteMetadataMock); mockGetAllTablesReturnsEmptyList(remoteMetadataMock); assertThat(readMockedSchemaMetadata(connectionMock).getTables(), emptyIterableOf(TableMetadata.class)); @@ -60,7 +61,7 @@ private SchemaMetadata readMockedSchemaMetadataWithProperties(final Connection c @Test void testReadRemoteMetadata() throws RemoteMetadataReaderException, SQLException { - final DatabaseMetaData remoteMetadataMock = mockSupportingMetadata(); + final DatabaseMetaData remoteMetadataMock = mockSupportingMetadata(true); final Connection connectionMock = mockConnection(remoteMetadataMock); mockGetColumnsCalls(remoteMetadataMock); mockGetTableCalls(remoteMetadataMock); @@ -88,7 +89,7 @@ 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(Wildcards.escape(TABLE_A)), any())) + when(remoteMetadataMock.getColumns(any(), any(), eq(escapeSqlWildCards(TABLE_A)), any())) .thenReturn(tableAColumns); } @@ -99,10 +100,14 @@ 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(Wildcards.escape(TABLE_B)), any())) + when(remoteMetadataMock.getColumns(any(), any(), eq(escapeSqlWildCards(TABLE_B)), any())) .thenReturn(tableBColumns); } + private String escapeSqlWildCards(final String string) { + return WildcardEscaper.instance(ESCAPE_STRING).escape(string); + } + private void mockGetTableCalls(final DatabaseMetaData remoteMetadataMock) throws SQLException { final ResultSet tablesMock = Mockito.mock(ResultSet.class); mockTableCount(tablesMock, 2); @@ -115,7 +120,7 @@ private void mockGetAllTables(final DatabaseMetaData remoteMetadataMock, final R when(remoteMetadataMock.getTables(any(), any(), any(), any())).thenReturn(tables); } - protected DatabaseMetaData mockSupportingMetadata() throws SQLException { + protected DatabaseMetaData mockSupportingMetadata(final boolean mockGetSearchStringEscape) throws SQLException { final DatabaseMetaData remoteMetadataMock = Mockito.mock(DatabaseMetaData.class); when(remoteMetadataMock.getCatalogSeparator()).thenReturn(CATALOG_SEPARATOR); when(remoteMetadataMock.getIdentifierQuoteString()).thenReturn(IDENTIFIER_QUOTE_STRING); @@ -131,12 +136,15 @@ protected DatabaseMetaData mockSupportingMetadata() throws SQLException { when(remoteMetadataMock.nullsAreSortedAtStart()).thenReturn(true); when(remoteMetadataMock.nullsAreSortedHigh()).thenReturn(true); when(remoteMetadataMock.nullsAreSortedLow()).thenReturn(true); + if (mockGetSearchStringEscape) { + when(remoteMetadataMock.getSearchStringEscape()).thenReturn(ESCAPE_STRING); + } return remoteMetadataMock; } @Test void testReadRemoteDataSkippingFilteredTables() throws SQLException { - final DatabaseMetaData remoteMetadataMock = mockSupportingMetadata(); + final DatabaseMetaData remoteMetadataMock = mockSupportingMetadata(true); final Connection connectionMock = mockConnection(remoteMetadataMock); final Map rawProperties = new HashMap<>(); rawProperties.put(AdapterProperties.TABLE_FILTER_PROPERTY, TABLE_B); @@ -152,7 +160,7 @@ void testReadRemoteDataSkippingFilteredTables() throws SQLException { @Test void testCreateSchemaAdapterNotes() throws SQLException { - final DatabaseMetaData remoteMetadataMock = mockSupportingMetadata(); + final DatabaseMetaData remoteMetadataMock = mockSupportingMetadata(false); final Connection connectionMock = mockConnection(remoteMetadataMock); final RemoteMetadataReader reader = new BaseRemoteMetadataReader(connectionMock, AdapterProperties.emptyProperties()); @@ -175,7 +183,7 @@ void testCreateSchemaAdapterNotes() throws SQLException { @Test void testReadRemoteMetadataWithAdapterNotes() throws RemoteMetadataReaderException, SQLException { - final DatabaseMetaData remoteMetadataMock = mockSupportingMetadata(); + final DatabaseMetaData remoteMetadataMock = mockSupportingMetadata(true); final Connection connectionMock = mockConnection(remoteMetadataMock); final ResultSet tablesMock = Mockito.mock(ResultSet.class); mockTableCount(tablesMock, 1); @@ -227,7 +235,7 @@ void testGetSchemaNameFilter() { // work together. @Test void testReadRemoteDataSkippingForSelectedTablesOnly() throws SQLException { - final DatabaseMetaData remoteMetadataMock = mockSupportingMetadata(); + final DatabaseMetaData remoteMetadataMock = mockSupportingMetadata(true); final Connection connectionMock = mockConnection(remoteMetadataMock); mockGetTableCalls(remoteMetadataMock); mockTableA(remoteMetadataMock); @@ -259,20 +267,23 @@ void testGetSupportedTableTypes() { @Test void testEscapeSchemaName() throws SQLException { - verifyEscapeSchemaOrCatalog(null, "THE\\_SCHEMA", Map.of("SCHEMA_NAME", "THE_SCHEMA")); + verifyEscapeSchemaOrCatalog(null, escapeSqlWildCards("THE_SCHEMA"), Map.of("SCHEMA_NAME", "THE_SCHEMA")); } @Test void testEscapeCatalogName() throws SQLException { - verifyEscapeSchemaOrCatalog("THE\\_CATALOG", null, Map.of("CATALOG_NAME", "THE_CATALOG")); + 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); + when(metadataMock.getSearchStringEscape()).thenReturn(ESCAPE_STRING); final Connection connection = mock(Connection.class); when(connection.getMetaData()).thenReturn(metadataMock); - when(metadataMock.getColumns(cat, schema, "THE\\_TABLE", "%")).thenThrow(new SpecialException()); + String tableName = escapeSqlWildCards("THE_TABLE"); + when(metadataMock.getColumns(cat, schema, tableName, "%")) + .thenThrow(new SpecialException()); final BaseColumnMetadataReader testee = new BaseColumnMetadataReader(connection, new AdapterProperties(properties), null); assertThrows(SpecialException.class, () -> testee.mapColumns("THE_TABLE")); diff --git a/src/test/java/com/exasol/adapter/jdbc/ColumnMetadataReaderTest.java b/src/test/java/com/exasol/adapter/jdbc/ColumnMetadataReaderTest.java index fe7abbc..fa23dec 100644 --- a/src/test/java/com/exasol/adapter/jdbc/ColumnMetadataReaderTest.java +++ b/src/test/java/com/exasol/adapter/jdbc/ColumnMetadataReaderTest.java @@ -6,6 +6,7 @@ import static org.hamcrest.Matchers.equalTo; import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.when; import java.sql.*; @@ -35,6 +36,8 @@ class ColumnMetadataReaderTest { ExaCharset.UTF8); private static final DataType TYPE_MAX_VARCHAR_ASCII = DataType.createVarChar(DataType.MAX_EXASOL_VARCHAR_SIZE, ExaCharset.ASCII); + private static final String ESCAPE_STRING = "\\"; + @Mock private Connection connectionMock; @Mock @@ -44,7 +47,8 @@ class ColumnMetadataReaderTest { @BeforeEach void beforeEach() throws SQLException { - when(this.connectionMock.getMetaData()).thenReturn(this.remoteMetadataMock); + lenient().when(this.connectionMock.getMetaData()).thenReturn(this.remoteMetadataMock); + lenient().when(this.remoteMetadataMock.getSearchStringEscape()).thenReturn(ESCAPE_STRING); } @Test diff --git a/src/test/java/com/exasol/adapter/jdbc/WildcardsTest.java b/src/test/java/com/exasol/adapter/jdbc/WildcardEscaperTest.java similarity index 51% rename from src/test/java/com/exasol/adapter/jdbc/WildcardsTest.java rename to src/test/java/com/exasol/adapter/jdbc/WildcardEscaperTest.java index 72a5542..bcae92c 100644 --- a/src/test/java/com/exasol/adapter/jdbc/WildcardsTest.java +++ b/src/test/java/com/exasol/adapter/jdbc/WildcardEscaperTest.java @@ -6,8 +6,9 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; -class WildcardsTest { +class WildcardEscaperTest { @ParameterizedTest @CsvSource(value = { // "abc, abc", // @@ -17,11 +18,21 @@ class WildcardsTest { "a_b%c, a\\_b\\%c", // }) void test(final String input, final String expected) { - assertThat(Wildcards.escape(input), equalTo(expected)); + assertThat(testee("\\").escape(input), equalTo(expected)); } @Test void testEmpty() { - assertThat(Wildcards.escape(""), equalTo("")); + assertThat(testee("\\").escape(""), equalTo("")); + } + + @ParameterizedTest + @ValueSource(strings = { "\\", "$", "/" }) + void testEscapeCharacter(final String escapeCharacter) { + assertThat(testee(escapeCharacter).escape("a_b"), equalTo("a" + escapeCharacter + "_b")); + } + + private WildcardEscaper testee(final String escapeCharacter) { + return WildcardEscaper.instance(escapeCharacter); } }