From fb2e568285fd8dfaaa2b14c3a3abab7b7953dda5 Mon Sep 17 00:00:00 2001 From: nicktorwald Date: Mon, 13 Jan 2020 16:21:34 +0700 Subject: [PATCH 1/2] metadata: Tarantool instance version info The driver can connect to different Tarantool instances within a range of server versions. Sometimes, it's required to set a driver behaviour depending on those versions (i.e. in scope of #213 the driver needs to generate the functions for JDBC C4 LTRIM/RTRIM that are recognizable by a particular Tarantool version). This commit also includes implementation of two public DatabaseMetaData methods getDatabaseMajorVersion and getDatabaseMinorVersion. Affects: #213 Closes: #106 --- .../java/org/tarantool/jdbc/SQLConstant.java | 3 + .../tarantool/jdbc/SQLDatabaseMetadata.java | 23 ++- .../jdbc/TarantoolDatabaseMetaData.java | 20 +++ .../org/tarantool/protocol/ProtoUtils.java | 46 +++--- .../tarantool/protocol/TarantoolGreeting.java | 27 +++- .../org/tarantool/util/ServerVersion.java | 141 ++++++++++++++++++ .../org/tarantool/ClientOperationsIT.java | 1 + .../java/org/tarantool/ServerVersion.java | 80 ---------- .../org/tarantool/TarantoolClientOpsIT.java | 2 + .../java/org/tarantool/TarantoolSQLOpsIT.java | 2 + .../java/org/tarantool/TestAssumptions.java | 2 + .../jdbc/JdbcClosedConnectionIT.java | 2 +- .../org/tarantool/jdbc/JdbcConnectionIT.java | 2 +- .../jdbc/JdbcConnectionTimeoutIT.java | 2 +- .../jdbc/JdbcDatabaseMetaDataIT.java | 36 ++++- .../jdbc/JdbcPreparedStatementIT.java | 2 +- .../org/tarantool/jdbc/JdbcResultSetIT.java | 2 +- .../jdbc/JdbcResultSetMetaDataIT.java | 2 +- .../org/tarantool/jdbc/JdbcStatementIT.java | 2 +- .../java/org/tarantool/jdbc/JdbcTypesIT.java | 2 +- .../tarantool/jdbc/ds/JdbcDataSourceIT.java | 2 +- .../org/tarantool/schema/ClientSchemaIT.java | 2 +- 22 files changed, 278 insertions(+), 125 deletions(-) create mode 100644 src/main/java/org/tarantool/jdbc/TarantoolDatabaseMetaData.java create mode 100644 src/main/java/org/tarantool/util/ServerVersion.java delete mode 100644 src/test/java/org/tarantool/ServerVersion.java diff --git a/src/main/java/org/tarantool/jdbc/SQLConstant.java b/src/main/java/org/tarantool/jdbc/SQLConstant.java index a367cf2e..9bb57a62 100644 --- a/src/main/java/org/tarantool/jdbc/SQLConstant.java +++ b/src/main/java/org/tarantool/jdbc/SQLConstant.java @@ -6,5 +6,8 @@ private SQLConstant() { } public static final String DRIVER_NAME = "Tarantool JDBC Driver"; + public static final String PRODUCT_NAME = "Tarantool"; + public static final int DRIVER_MAJOR_VERSION = 4; + public static final int DRIVER_MINOR_VERSION = 2; } diff --git a/src/main/java/org/tarantool/jdbc/SQLDatabaseMetadata.java b/src/main/java/org/tarantool/jdbc/SQLDatabaseMetadata.java index 98d2ea1d..5e5a7b47 100644 --- a/src/main/java/org/tarantool/jdbc/SQLDatabaseMetadata.java +++ b/src/main/java/org/tarantool/jdbc/SQLDatabaseMetadata.java @@ -5,10 +5,10 @@ import org.tarantool.SqlProtoUtils; import org.tarantool.Version; import org.tarantool.jdbc.type.TarantoolSqlType; +import org.tarantool.util.ServerVersion; import org.tarantool.util.TupleTwo; import java.sql.Connection; -import java.sql.DatabaseMetaData; import java.sql.ResultSet; import java.sql.RowIdLifetime; import java.sql.SQLException; @@ -21,7 +21,7 @@ import java.util.Map; import java.util.stream.Collectors; -public class SQLDatabaseMetadata implements DatabaseMetaData { +public class SQLDatabaseMetadata implements TarantoolDatabaseMetaData { protected static final int _VSPACE = 281; protected static final int _VINDEX = 289; @@ -89,7 +89,7 @@ public boolean nullsAreSortedAtEnd() throws SQLException { @Override public String getDatabaseProductName() throws SQLException { - return "Tarantool"; + return SQLConstant.PRODUCT_NAME; } @Override @@ -1017,22 +1017,31 @@ public int getResultSetHoldability() throws SQLException { @Override public int getDatabaseMajorVersion() throws SQLException { - return 0; + return getDatabaseVersion().getMajorVersion(); } @Override public int getDatabaseMinorVersion() throws SQLException { - return 0; + return getDatabaseVersion().getMinorVersion(); + } + + @Override + public ServerVersion getDatabaseVersion() throws SQLException { + try { + return new ServerVersion(connection.getServerVersion()); + } catch (Exception cause) { + throw new SQLException("Could not get the current server version number", cause); + } } @Override public int getJDBCMajorVersion() throws SQLException { - return 2; + return SQLConstant.DRIVER_MAJOR_VERSION; } @Override public int getJDBCMinorVersion() throws SQLException { - return 1; + return SQLConstant.DRIVER_MINOR_VERSION; } @Override diff --git a/src/main/java/org/tarantool/jdbc/TarantoolDatabaseMetaData.java b/src/main/java/org/tarantool/jdbc/TarantoolDatabaseMetaData.java new file mode 100644 index 00000000..fafb5a20 --- /dev/null +++ b/src/main/java/org/tarantool/jdbc/TarantoolDatabaseMetaData.java @@ -0,0 +1,20 @@ +package org.tarantool.jdbc; + +import org.tarantool.util.ServerVersion; + +import java.sql.DatabaseMetaData; +import java.sql.SQLException; + +/** + * Tarantool specific database meta data extension. + */ +public interface TarantoolDatabaseMetaData extends DatabaseMetaData { + + /** + * Gets the current Tarantool version. + * + * @return version of active connected database. + */ + ServerVersion getDatabaseVersion() throws SQLException; + +} diff --git a/src/main/java/org/tarantool/protocol/ProtoUtils.java b/src/main/java/org/tarantool/protocol/ProtoUtils.java index 51481d38..4fff0907 100644 --- a/src/main/java/org/tarantool/protocol/ProtoUtils.java +++ b/src/main/java/org/tarantool/protocol/ProtoUtils.java @@ -32,7 +32,6 @@ public abstract class ProtoUtils { public static final int LENGTH_OF_SIZE_MESSAGE = 5; private static final int DEFAULT_INITIAL_REQUEST_SIZE = 4096; - private static final String WELCOME = "Tarantool "; /** * Reads tarantool binary protocol's packet from {@code inputStream}. @@ -65,7 +64,7 @@ public static TarantoolPacket readPacket(InputStream inputStream, MsgPackLite ms * * @param bufferReader readable channel that have to be in blocking mode * or instance of {@link ReadableViaSelectorChannel} - * @param msgPackLite MessagePack decoder instance + * @param msgPackLite MessagePack decoder instance * * @return tarantool binary protocol message wrapped by instance of {@link TarantoolPacket} * @@ -120,9 +119,9 @@ public static TarantoolPacket readPacket(ReadableByteChannel bufferReader, MsgPa /** * Connects to a tarantool node described by {@code socket}. Performs an authentication if required * - * @param socket a socket channel to a tarantool node - * @param username auth username - * @param password auth password + * @param socket a socket channel to a tarantool node + * @param username auth username + * @param password auth password * @param msgPackLite MessagePack encoder / decoder instance * * @return object with information about a connection/ @@ -141,8 +140,7 @@ public static TarantoolGreeting connect(Socket socket, inputStream.read(inputBytes); String firstLine = new String(inputBytes); - assertCorrectWelcome(firstLine, socket.getRemoteSocketAddress()); - String serverVersion = firstLine.substring(WELCOME.length()); + final TarantoolGreeting greeting = parseGreetingLine(firstLine, socket.getRemoteSocketAddress()); inputStream.read(inputBytes); String salt = new String(inputBytes); @@ -157,15 +155,15 @@ public static TarantoolGreeting connect(Socket socket, assertNoErrCode(responsePacket); } - return new TarantoolGreeting(serverVersion); + return greeting; } /** * Connects to a tarantool node described by {@code socketChannel}. Performs an authentication if required. * - * @param channel a socket channel to tarantool node. The channel have to be in blocking mode - * @param username auth username - * @param password auth password + * @param channel a socket channel to tarantool node. The channel have to be in blocking mode + * @param username auth username + * @param password auth password * @param msgPackLite MessagePack encoder / decoder instance * * @return object with information about a connection/ @@ -182,10 +180,9 @@ public static TarantoolGreeting connect(SocketChannel channel, channel.read(welcomeBytes); String firstLine = new String(welcomeBytes.array()); - assertCorrectWelcome(firstLine, channel.getRemoteAddress()); - final String serverVersion = firstLine.substring(WELCOME.length()); + final TarantoolGreeting greeting = parseGreetingLine(firstLine, channel.getRemoteAddress()); - ((Buffer)welcomeBytes).clear(); + ((Buffer) welcomeBytes).clear(); channel.read(welcomeBytes); String salt = new String(welcomeBytes.array()); @@ -197,17 +194,7 @@ public static TarantoolGreeting connect(SocketChannel channel, assertNoErrCode(authResponse); } - return new TarantoolGreeting(serverVersion); - } - - private static void assertCorrectWelcome(String firstLine, SocketAddress remoteAddress) { - if (!firstLine.startsWith(WELCOME)) { - String errMsg = "Failed to connect to node " + remoteAddress.toString() + - ": Welcome message should starts with tarantool but starts with '" + - firstLine + - "'"; - throw new CommunicationException(errMsg, new IllegalStateException("Invalid welcome packet")); - } + return greeting; } private static void assertNoErrCode(TarantoolPacket authResponse) { @@ -331,4 +318,13 @@ ByteBuffer toByteBuffer() { } + private static TarantoolGreeting parseGreetingLine(String line, SocketAddress remoteAddress) { + try { + return new TarantoolGreeting(line); + } catch (Exception cause) { + String message = "Failed to connect to node " + remoteAddress.toString(); + throw new CommunicationException(message, cause); + } + } + } diff --git a/src/main/java/org/tarantool/protocol/TarantoolGreeting.java b/src/main/java/org/tarantool/protocol/TarantoolGreeting.java index 0b5598a4..528313e8 100644 --- a/src/main/java/org/tarantool/protocol/TarantoolGreeting.java +++ b/src/main/java/org/tarantool/protocol/TarantoolGreeting.java @@ -1,13 +1,36 @@ package org.tarantool.protocol; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + public class TarantoolGreeting { + + private static final Pattern GREETING_LINE = + Pattern.compile("Tarantool\\s+(?[-.0-9a-g]+)\\s+\\((?.*)\\)\\s+(?[-0-9a-f]*)"); + private final String serverVersion; + private final String protocolType; + private final String instanceUuid; - public TarantoolGreeting(String serverVersion) { - this.serverVersion = serverVersion; + public TarantoolGreeting(String greetingLine) { + Matcher matcher = GREETING_LINE.matcher(greetingLine); + if (!matcher.find()) { + throw new IllegalArgumentException("Welcome message '" + greetingLine + "' is incorrect "); + } + serverVersion = matcher.group("version"); + protocolType = matcher.group("protocol"); + instanceUuid = matcher.group("uuid"); } public String getServerVersion() { return serverVersion; } + + public String getProtocolType() { + return protocolType; + } + + public String getInstanceUuid() { + return instanceUuid; + } } diff --git a/src/main/java/org/tarantool/util/ServerVersion.java b/src/main/java/org/tarantool/util/ServerVersion.java new file mode 100644 index 00000000..7f172aeb --- /dev/null +++ b/src/main/java/org/tarantool/util/ServerVersion.java @@ -0,0 +1,141 @@ +package org.tarantool.util; + +import java.util.Objects; + +/** + * Server version holder. + */ +public class ServerVersion implements Comparable { + + public static final ServerVersion V_1_9 = new ServerVersion(1, 9, 0); + public static final ServerVersion V_1_10 = new ServerVersion(1, 10, 0); + public static final ServerVersion V_2_1 = new ServerVersion(2, 1, 0); + public static final ServerVersion V_2_2 = new ServerVersion(2, 2, 0); + public static final ServerVersion V_2_2_1 = new ServerVersion(2, 2, 1); + public static final ServerVersion V_2_3 = new ServerVersion(2, 3, 0); + + private final int majorVersion; + private final int minorVersion; + private final int patchVersion; + + /** + * Makes a parsed server version container from + * a string in format like {@code MAJOR.MINOR.PATCH[-BUILD-gCOMMIT]}. + * + * @param version string in the Tarantool version format. + */ + public ServerVersion(String version) { + String[] parts = splitVersionParts(version); + if (parts.length < 3) { + throw new IllegalArgumentException("Expected at least major, minor, and patch version parts"); + } + this.majorVersion = Integer.parseInt(parts[0]); + this.minorVersion = Integer.parseInt(parts[1]); + this.patchVersion = Integer.parseInt(parts[2]); + } + + public ServerVersion(int majorVersion, + int minorVersion, + int patchVersion) { + this.majorVersion = majorVersion; + this.minorVersion = minorVersion; + this.patchVersion = patchVersion; + } + + public int getMajorVersion() { + return majorVersion; + } + + public int getMinorVersion() { + return minorVersion; + } + + public int getPatchVersion() { + return patchVersion; + } + + public boolean isEqual(String versionString) { + return isEqual(new ServerVersion(versionString)); + } + + public boolean isEqual(ServerVersion version) { + return compareTo(version) == 0; + } + + public boolean isLessOrEqualThan(String versionString) { + return isLessOrEqualThan(new ServerVersion(versionString)); + } + + public boolean isLessOrEqualThan(ServerVersion version) { + return compareTo(version) <= 0; + } + + public boolean isGreaterOrEqualThan(String versionString) { + return isGreaterOrEqualThan(new ServerVersion(versionString)); + } + + public boolean isGreaterOrEqualThan(ServerVersion version) { + return compareTo(version) >= 0; + } + + public boolean isGreaterThan(String versionString) { + return isGreaterThan(new ServerVersion(versionString)); + } + + public boolean isGreaterThan(ServerVersion version) { + return compareTo(version) > 0; + } + + public boolean isLessThan(String versionString) { + return isLessThan(new ServerVersion(versionString)); + } + + public boolean isLessThan(ServerVersion version) { + return compareTo(version) < 0; + } + + @Override + public int compareTo(ServerVersion that) { + return Integer.compare(this.toNumber(), that.toNumber()); + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (object == null || getClass() != object.getClass()) { + return false; + } + ServerVersion that = (ServerVersion) object; + return majorVersion == that.majorVersion && + minorVersion == that.minorVersion && + patchVersion == that.patchVersion; + } + + @Override + public int hashCode() { + return Objects.hash(majorVersion, minorVersion, patchVersion); + } + + /** + * Translates version parts to format XXXYYYZZZ. + * For example, {@code 1.2.3} translates to number {@code 1002003} + * + * @return version as number + */ + private int toNumber() { + return (majorVersion * 1000 + minorVersion) * 1000 + patchVersion; + } + + /** + * Splits Tarantool version string into parts. + * For example, {@code 2.1.1-423-g4007436aa} => {@code [2, 1, 1, 423, g4007436aa]}. + * + * @param version Tarantool version string + * @return split parts + */ + private String[] splitVersionParts(String version) { + return version.split("[.\\-]"); + } +} diff --git a/src/test/java/org/tarantool/ClientOperationsIT.java b/src/test/java/org/tarantool/ClientOperationsIT.java index 80e795b3..7cdf18b7 100644 --- a/src/test/java/org/tarantool/ClientOperationsIT.java +++ b/src/test/java/org/tarantool/ClientOperationsIT.java @@ -8,6 +8,7 @@ import org.tarantool.schema.TarantoolIndexNotFoundException; import org.tarantool.schema.TarantoolSpaceNotFoundException; +import org.tarantool.util.ServerVersion; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; diff --git a/src/test/java/org/tarantool/ServerVersion.java b/src/test/java/org/tarantool/ServerVersion.java deleted file mode 100644 index 42e472fe..00000000 --- a/src/test/java/org/tarantool/ServerVersion.java +++ /dev/null @@ -1,80 +0,0 @@ -package org.tarantool; - -import java.util.function.BiFunction; - -public enum ServerVersion { - - V_1_9("1", "9", "0"), - V_1_10("1", "10", "0"), - V_2_1("2", "1", "0"), - V_2_2("2", "2", "0"), - V_2_2_1("2", "2", "1"), - V_2_3("2", "3", "0"); - - private final String majorVersion; - private final String minorVersion; - private final String patchVersion; - - ServerVersion(String majorVersion, - String minorVersion, String patchVersion) { - this.majorVersion = majorVersion; - this.minorVersion = minorVersion; - this.patchVersion = patchVersion; - } - - public String getMajorVersion() { - return majorVersion; - } - - public String getMinorVersion() { - return minorVersion; - } - - public String getPatchVersion() { - return patchVersion; - } - - public boolean isLessOrEqualThan(String versionString) { - return compareVersions(versionString, (server, minimal) -> server >= minimal); - } - - public boolean isGreaterOrEqualThan(String versionString) { - return compareVersions(versionString, (server, maximal) -> server <= maximal); - } - - public boolean isGreaterThan(String versionString) { - return compareVersions(versionString, (server, maximal) -> server < maximal); - } - - private boolean compareVersions(String versionString, BiFunction comparator) { - int parsedVersion = toNumber(splitVersionParts(versionString)); - int thisVersion = toNumber(new String[] { majorVersion, minorVersion, patchVersion }); - return comparator.apply(parsedVersion, thisVersion); - } - - /** - * Translates version parts to format XXXYYYZZZ. - * For example, {@code 1.2.1} translates to number {@code 1002001} - * - * @param parts version parts - * @return version as number - */ - private int toNumber(String[] parts) { - int version = 0; - for (int i = 0; i < 3; i++) { - version = (version + Integer.parseInt(parts[i])) * 1000; - } - return version / 1000; - } - - /** - * Splits Tarantool version string into parts. - * For example, {@code 2.1.1-423-g4007436aa} => {@code [2, 1, 1, 423, g4007436aa]}. - * - * @param version Tarantool version string - * @return split parts - */ - private String[] splitVersionParts(String version) { - return version.split("[.\\-]"); - } -} diff --git a/src/test/java/org/tarantool/TarantoolClientOpsIT.java b/src/test/java/org/tarantool/TarantoolClientOpsIT.java index a8964b7b..18ade5a9 100644 --- a/src/test/java/org/tarantool/TarantoolClientOpsIT.java +++ b/src/test/java/org/tarantool/TarantoolClientOpsIT.java @@ -12,6 +12,8 @@ import static org.tarantool.TestUtils.toLuaDelete; import static org.tarantool.TestUtils.toLuaSelect; +import org.tarantool.util.ServerVersion; + import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; diff --git a/src/test/java/org/tarantool/TarantoolSQLOpsIT.java b/src/test/java/org/tarantool/TarantoolSQLOpsIT.java index b1c66992..ae8e5d3d 100644 --- a/src/test/java/org/tarantool/TarantoolSQLOpsIT.java +++ b/src/test/java/org/tarantool/TarantoolSQLOpsIT.java @@ -8,6 +8,8 @@ import static org.tarantool.TestUtils.makeTestClient; import static org.tarantool.TestUtils.openConnection; +import org.tarantool.util.ServerVersion; + import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; diff --git a/src/test/java/org/tarantool/TestAssumptions.java b/src/test/java/org/tarantool/TestAssumptions.java index 65c235eb..4823b24f 100644 --- a/src/test/java/org/tarantool/TestAssumptions.java +++ b/src/test/java/org/tarantool/TestAssumptions.java @@ -1,5 +1,7 @@ package org.tarantool; +import org.tarantool.util.ServerVersion; + import org.junit.jupiter.api.Assumptions; public class TestAssumptions { diff --git a/src/test/java/org/tarantool/jdbc/JdbcClosedConnectionIT.java b/src/test/java/org/tarantool/jdbc/JdbcClosedConnectionIT.java index d25a0851..bdee3894 100644 --- a/src/test/java/org/tarantool/jdbc/JdbcClosedConnectionIT.java +++ b/src/test/java/org/tarantool/jdbc/JdbcClosedConnectionIT.java @@ -6,9 +6,9 @@ import static org.tarantool.TestAssumptions.assumeMinimalServerVersion; import static org.tarantool.jdbc.SqlAssertions.assertSqlExceptionHasStatus; -import org.tarantool.ServerVersion; import org.tarantool.TarantoolTestHelper; import org.tarantool.util.SQLStates; +import org.tarantool.util.ServerVersion; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; diff --git a/src/test/java/org/tarantool/jdbc/JdbcConnectionIT.java b/src/test/java/org/tarantool/jdbc/JdbcConnectionIT.java index afda6205..35b08f9f 100644 --- a/src/test/java/org/tarantool/jdbc/JdbcConnectionIT.java +++ b/src/test/java/org/tarantool/jdbc/JdbcConnectionIT.java @@ -7,8 +7,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.tarantool.TestAssumptions.assumeMinimalServerVersion; -import org.tarantool.ServerVersion; import org.tarantool.TarantoolTestHelper; +import org.tarantool.util.ServerVersion; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; diff --git a/src/test/java/org/tarantool/jdbc/JdbcConnectionTimeoutIT.java b/src/test/java/org/tarantool/jdbc/JdbcConnectionTimeoutIT.java index 9f503f79..d2b48921 100644 --- a/src/test/java/org/tarantool/jdbc/JdbcConnectionTimeoutIT.java +++ b/src/test/java/org/tarantool/jdbc/JdbcConnectionTimeoutIT.java @@ -5,11 +5,11 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.tarantool.TestAssumptions.assumeMinimalServerVersion; -import org.tarantool.ServerVersion; import org.tarantool.TarantoolClientConfig; import org.tarantool.TarantoolOperation; import org.tarantool.TarantoolTestHelper; import org.tarantool.protocol.TarantoolPacket; +import org.tarantool.util.ServerVersion; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; diff --git a/src/test/java/org/tarantool/jdbc/JdbcDatabaseMetaDataIT.java b/src/test/java/org/tarantool/jdbc/JdbcDatabaseMetaDataIT.java index 3c4ba0f7..c621ae98 100644 --- a/src/test/java/org/tarantool/jdbc/JdbcDatabaseMetaDataIT.java +++ b/src/test/java/org/tarantool/jdbc/JdbcDatabaseMetaDataIT.java @@ -8,8 +8,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.tarantool.TestAssumptions.assumeMinimalServerVersion; -import org.tarantool.ServerVersion; import org.tarantool.TarantoolTestHelper; +import org.tarantool.util.ServerVersion; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; @@ -284,12 +284,14 @@ public void testSupportsResultSetHoldability() throws SQLException { @Test public void testUnwrap() throws SQLException { assertEquals(meta, meta.unwrap(SQLDatabaseMetadata.class)); + assertEquals(meta, meta.unwrap(TarantoolDatabaseMetaData.class)); assertThrows(SQLException.class, () -> meta.unwrap(Integer.class)); } @Test public void testIsWrapperFor() throws SQLException { assertTrue(meta.isWrapperFor(SQLDatabaseMetadata.class)); + assertTrue(meta.isWrapperFor(TarantoolDatabaseMetaData.class)); assertFalse(meta.isWrapperFor(Integer.class)); } @@ -390,4 +392,36 @@ public void testDeleteDetectionSupport() throws SQLException { } } + @Test + void testDatabaseVersion() throws SQLException { + ServerVersion version = new ServerVersion(testHelper.getInstanceVersion()); + assertEquals(version.getMajorVersion(), meta.getDatabaseMajorVersion()); + assertEquals(version.getMinorVersion(), meta.getDatabaseMinorVersion()); + } + + @Test + void testVendorDatabaseVersion() throws SQLException { + ServerVersion version = new ServerVersion(testHelper.getInstanceVersion()); + TarantoolDatabaseMetaData vendorMeta = meta.unwrap(TarantoolDatabaseMetaData.class); + assertEquals(version, vendorMeta.getDatabaseVersion()); + } + + @Test + void testJdbcVersion() throws SQLException { + assertEquals(4, meta.getJDBCMajorVersion()); + assertEquals(2, meta.getJDBCMinorVersion()); + } + + @Test + void testDatabaseProductName() throws SQLException { + assertEquals("Tarantool", meta.getDatabaseProductName()); + } + + @Test + void testDatabaseProductVersion() throws SQLException { + ServerVersion version = new ServerVersion(testHelper.getInstanceVersion()); + ServerVersion databaseProductVersion = new ServerVersion(meta.getDatabaseProductVersion()); + assertEquals(version, databaseProductVersion); + } + } diff --git a/src/test/java/org/tarantool/jdbc/JdbcPreparedStatementIT.java b/src/test/java/org/tarantool/jdbc/JdbcPreparedStatementIT.java index 46adb74b..d2c01553 100644 --- a/src/test/java/org/tarantool/jdbc/JdbcPreparedStatementIT.java +++ b/src/test/java/org/tarantool/jdbc/JdbcPreparedStatementIT.java @@ -14,10 +14,10 @@ import static org.mockito.Mockito.when; import static org.tarantool.TestAssumptions.assumeMinimalServerVersion; -import org.tarantool.ServerVersion; import org.tarantool.TarantoolTestHelper; import org.tarantool.TestUtils; import org.tarantool.util.SQLStates; +import org.tarantool.util.ServerVersion; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; diff --git a/src/test/java/org/tarantool/jdbc/JdbcResultSetIT.java b/src/test/java/org/tarantool/jdbc/JdbcResultSetIT.java index e9b2884b..2f495a7c 100644 --- a/src/test/java/org/tarantool/jdbc/JdbcResultSetIT.java +++ b/src/test/java/org/tarantool/jdbc/JdbcResultSetIT.java @@ -8,9 +8,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.tarantool.TestAssumptions.assumeMinimalServerVersion; -import org.tarantool.ServerVersion; import org.tarantool.TarantoolTestHelper; import org.tarantool.util.SQLStates; +import org.tarantool.util.ServerVersion; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; diff --git a/src/test/java/org/tarantool/jdbc/JdbcResultSetMetaDataIT.java b/src/test/java/org/tarantool/jdbc/JdbcResultSetMetaDataIT.java index 62139cdc..e1ef3d32 100644 --- a/src/test/java/org/tarantool/jdbc/JdbcResultSetMetaDataIT.java +++ b/src/test/java/org/tarantool/jdbc/JdbcResultSetMetaDataIT.java @@ -9,8 +9,8 @@ import static org.tarantool.TestAssumptions.assumeServerVersionLessThan; import static org.tarantool.TestAssumptions.assumeServerVersionOutOfRange; -import org.tarantool.ServerVersion; import org.tarantool.TarantoolTestHelper; +import org.tarantool.util.ServerVersion; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; diff --git a/src/test/java/org/tarantool/jdbc/JdbcStatementIT.java b/src/test/java/org/tarantool/jdbc/JdbcStatementIT.java index 171eaddc..d8d5e0ad 100644 --- a/src/test/java/org/tarantool/jdbc/JdbcStatementIT.java +++ b/src/test/java/org/tarantool/jdbc/JdbcStatementIT.java @@ -8,10 +8,10 @@ import static org.tarantool.TestAssumptions.assumeMinimalServerVersion; import static org.tarantool.jdbc.SqlAssertions.assertSqlExceptionHasStatus; -import org.tarantool.ServerVersion; import org.tarantool.TarantoolTestHelper; import org.tarantool.TestUtils; import org.tarantool.util.SQLStates; +import org.tarantool.util.ServerVersion; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; diff --git a/src/test/java/org/tarantool/jdbc/JdbcTypesIT.java b/src/test/java/org/tarantool/jdbc/JdbcTypesIT.java index f84d4367..8d0b8e50 100644 --- a/src/test/java/org/tarantool/jdbc/JdbcTypesIT.java +++ b/src/test/java/org/tarantool/jdbc/JdbcTypesIT.java @@ -8,9 +8,9 @@ import static org.tarantool.TestAssumptions.assumeServerVersionLessThan; import static org.tarantool.TestUtils.fromHex; -import org.tarantool.ServerVersion; import org.tarantool.TarantoolTestHelper; import org.tarantool.jdbc.type.TarantoolSqlType; +import org.tarantool.util.ServerVersion; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; diff --git a/src/test/java/org/tarantool/jdbc/ds/JdbcDataSourceIT.java b/src/test/java/org/tarantool/jdbc/ds/JdbcDataSourceIT.java index 4d9297a0..dc0786e4 100644 --- a/src/test/java/org/tarantool/jdbc/ds/JdbcDataSourceIT.java +++ b/src/test/java/org/tarantool/jdbc/ds/JdbcDataSourceIT.java @@ -9,9 +9,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.tarantool.TestAssumptions.assumeMinimalServerVersion; -import org.tarantool.ServerVersion; import org.tarantool.TarantoolTestHelper; import org.tarantool.jdbc.SQLProperty; +import org.tarantool.util.ServerVersion; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; diff --git a/src/test/java/org/tarantool/schema/ClientSchemaIT.java b/src/test/java/org/tarantool/schema/ClientSchemaIT.java index 214c599c..10a11687 100644 --- a/src/test/java/org/tarantool/schema/ClientSchemaIT.java +++ b/src/test/java/org/tarantool/schema/ClientSchemaIT.java @@ -6,13 +6,13 @@ import static org.junit.jupiter.api.Assertions.fail; import static org.tarantool.TestUtils.makeDefaultClientConfig; -import org.tarantool.ServerVersion; import org.tarantool.TarantoolClientConfig; import org.tarantool.TarantoolClientImpl; import org.tarantool.TarantoolTestHelper; import org.tarantool.TestAssumptions; import org.tarantool.schema.TarantoolIndexMeta.IndexOptions; import org.tarantool.schema.TarantoolIndexMeta.IndexPart; +import org.tarantool.util.ServerVersion; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; From 939a61768ad67658c20c6be8dc77de22d0ff1293 Mon Sep 17 00:00:00 2001 From: nicktorwald Date: Sat, 3 Aug 2019 11:02:06 +0700 Subject: [PATCH 2/2] jdbc: support for sub-set of JDBC escape syntax Add a driver SQL pre-processing before sending it to the server. The driver supports sub-set of scalar functions defined by the spec (appendix C), outer joins, escape clause for SQL LIKE operator, and limit/offset clause. The processed result can be received using Connection.nativeSQL() method. Closes #79 Closes #76 Closes #81 Closes #83 Closes #84 Affects: #108 --- .../tarantool/jdbc/EscapeSyntaxParser.java | 416 ++++++++++++++++ .../org/tarantool/jdbc/EscapedFunctions.java | 444 ++++++++++++++++++ .../org/tarantool/jdbc/SQLConnection.java | 5 +- .../tarantool/jdbc/SQLDatabaseMetadata.java | 9 +- .../tarantool/jdbc/SQLPreparedStatement.java | 4 +- .../java/org/tarantool/jdbc/SQLStatement.java | 14 +- .../java/org/tarantool/util/SQLStates.java | 3 +- .../java/org/tarantool/util/StringUtils.java | 9 + .../tarantool/util/ThrowingBiFunction.java | 27 ++ .../org/tarantool/jdbc/JdbcConnectionIT.java | 271 ++++++++++- .../jdbc/JdbcDatabaseMetaDataIT.java | 37 ++ .../jdbc/JdbcPreparedStatementIT.java | 201 ++++++++ .../org/tarantool/jdbc/JdbcStatementIT.java | 158 +++++++ 13 files changed, 1585 insertions(+), 13 deletions(-) create mode 100644 src/main/java/org/tarantool/jdbc/EscapeSyntaxParser.java create mode 100644 src/main/java/org/tarantool/jdbc/EscapedFunctions.java create mode 100644 src/main/java/org/tarantool/util/ThrowingBiFunction.java diff --git a/src/main/java/org/tarantool/jdbc/EscapeSyntaxParser.java b/src/main/java/org/tarantool/jdbc/EscapeSyntaxParser.java new file mode 100644 index 00000000..2dae919f --- /dev/null +++ b/src/main/java/org/tarantool/jdbc/EscapeSyntaxParser.java @@ -0,0 +1,416 @@ +package org.tarantool.jdbc; + +import static org.tarantool.jdbc.EscapeSyntaxParser.Comment.BLOCK; +import static org.tarantool.jdbc.EscapeSyntaxParser.Comment.LINE; +import static org.tarantool.jdbc.EscapedFunctions.Expression; +import static org.tarantool.jdbc.EscapedFunctions.FunctionExpression; +import static org.tarantool.jdbc.EscapedFunctions.FunctionSignatureKey; +import static org.tarantool.jdbc.EscapedFunctions.functionMappings; + +import org.tarantool.util.SQLStates; +import org.tarantool.util.ThrowingBiFunction; + +import java.sql.Connection; +import java.sql.SQLSyntaxErrorException; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.regex.Pattern; + +/** + * Set of utils to work with JDBC escape processing. + *

+ * Supported escape syntax: + *

    + *
  1. Scalar functions (i.e. {@code {fn random()}}).
  2. + *
  3. Outer joins (i.e. {@code {oj "dept" left outer join "salary" on "dept_id" = 1412}}).
  4. + *
  5. Like escape character (i.e. {@code like '_|%_3%' {escape '|'}}).
  6. + *
  7. Limiting returned rows (i.e. {@code {limit 10 offset 20}}).
  8. + *
+ * + *

+ * Most of the supported expressions translates directly omitting escape borders. + * In this way, {@code {fn abs(-5)}} becomes {@code abs(-5)}} or {@code {limit 10 offset 50}} + * becomes {@code limit 10 offset 50} and so on. There are exceptions in case of scalar + * functions where JDBC functions may not match exactly with Tarantool ones (for example, + * JDBC {@code {fn rand()}} function becomes {@code random()} supported by Tarantool. + * + *

+ * Escape syntax explicitly do not allow or deny SQL comments within an escape expression. + * To avoid undefined behaviours when processing is performed the parser always replaces + * a comment with one whitespace. + */ +public class EscapeSyntaxParser { + + enum Comment { + BLOCK("/*", "*/"), + LINE("--", "\n"); + + final String start; + final String end; + + Comment(String start, String end) { + this.start = start; + this.end = end; + } + + public String getStart() { + return start; + } + + public String getEnd() { + return end; + } + } + + /** + * Pattern that covers function names described in JDBC Spec + * Appendix C. Scalar functions. + */ + private static final Pattern IDENTIFIER = Pattern.compile("[_a-zA-Z][_a-zA-Z0-9]+"); + + private final SQLConnection jdbcContext; + + public EscapeSyntaxParser(SQLConnection jdbcContext) { + this.jdbcContext = jdbcContext; + } + + /** + * Performs escape processing for SQL queries. It translates + * sql text with optional escape expressions such as {@code {fn abs(-1)}}. + * + *

+ * Comments inside SQL text can be eliminated as parsing goes using preserveComments + * flag. Hence, Comments inside escape syntax are always omitted regardless of + * the flag, though. + * + * @param sql SQL text to be processed + * + * @return native SQL query + * + * @throws SQLSyntaxErrorException if any syntax error happened + */ + public String translate(String sql, boolean preserveComments) throws SQLSyntaxErrorException { + StringBuilder nativeSql = new StringBuilder(sql.length()); + StringBuilder escapeBuffer = new StringBuilder(); + StringBuilder activeBuffer = nativeSql; + LinkedList escapeStartPositions = new LinkedList<>(); + + int i = 0; + while (i < sql.length()) { + char currentChar = sql.charAt(i); + switch (currentChar) { + case '\'': + case '"': + int endOfString = seekEndOfRegion(sql, i, "" + currentChar, "" + currentChar); + if (endOfString == -1) { + throw new SQLSyntaxErrorException( + "Not enclosed string literal or quoted identifier at position " + i, + SQLStates.SYNTAX_ERROR.getSqlState() + ); + } + activeBuffer.append(sql, i, endOfString + 1); + i = endOfString + 1; + break; + + case '/': + case '-': + int endOfComment; + if (currentChar == '/') { + endOfComment = seekEndOfRegion(sql, i, BLOCK.getStart(), BLOCK.getEnd()); + if (endOfComment == -1) { + throw new SQLSyntaxErrorException( + "Open block comment at position " + i, SQLStates.SYNTAX_ERROR.getSqlState() + ); + } + } else { + endOfComment = seekEndOfRegion(sql, i, LINE.getStart(), LINE.getEnd()); + if (endOfComment == -1) { + endOfComment = sql.length() - 1; + } + } + if (i == endOfComment) { + activeBuffer.append(currentChar); + i++; + } else { + if (preserveComments) { + activeBuffer.append(sql, i, endOfComment + 1); + } else { + activeBuffer.append(' '); + } + i = endOfComment + 1; + } + break; + + case '{': + escapeStartPositions.addFirst(escapeBuffer.length()); + escapeBuffer.append(currentChar); + activeBuffer = escapeBuffer; + i++; + break; + + case '}': + Integer startPosition = escapeStartPositions.pollFirst(); + if (startPosition == null) { + throw new SQLSyntaxErrorException( + "Unexpected '}' at position " + i, + SQLStates.SYNTAX_ERROR.getSqlState() + ); + } + escapeBuffer.append(currentChar); + processEscapeExpression(escapeBuffer, startPosition, escapeBuffer.length()); + if (escapeStartPositions.isEmpty()) { + nativeSql.append(escapeBuffer); + escapeBuffer.setLength(0); + activeBuffer = nativeSql; + } + i++; + break; + + default: + activeBuffer.append(currentChar); + i++; + break; + } + } + + if (!escapeStartPositions.isEmpty()) { + throw new SQLSyntaxErrorException( + "Not enclosed escape expression at position " + escapeStartPositions.pollFirst(), + SQLStates.SYNTAX_ERROR.getSqlState() + ); + } + return nativeSql.toString(); + } + + /** + * Parses text like {@code functionName([arg[,args...]])}. + * Arguments are not parsed recursively and saved as-is. + * + *

+ * In contrast to SQL where function name can be enclosed by double quotes, + * it is not supported within escape syntax. + * + * @param functionString text to be parsed + * + * @return parsed result containing function name and its parameters, if any + * + * @throws SQLSyntaxErrorException if any syntax errors happened + */ + private FunctionExpression parseFunction(String functionString) throws SQLSyntaxErrorException { + int braceNestLevel = 0; + String functionName = null; + List functionParameters = new ArrayList<>(); + int parameterStartPosition = 0; + + int i = 0; + boolean completed = false; + boolean wasComment = false; + while (i < functionString.length() && !completed) { + char currentChar = functionString.charAt(i); + switch (currentChar) { + case '\'': + case '"': + i = seekEndOfRegion(functionString, i, "" + currentChar, "" + currentChar) + 1; + break; + + case '/': + case '-': + int endOfComment = (currentChar == '/') + ? seekEndOfRegion(functionString, i, BLOCK.getStart(), BLOCK.getEnd()) + : seekEndOfRegion(functionString, i, LINE.getStart(), LINE.getEnd()); + wasComment = (i != endOfComment); + i = endOfComment == -1 ? functionString.length() : endOfComment + 1; + break; + + case '(': + if (braceNestLevel++ == 0) { + functionName = trimExpression(functionString.substring(0, i), wasComment).toUpperCase(); + if (!IDENTIFIER.matcher(functionName).matches()) { + throw new SQLSyntaxErrorException( + "Invalid function identifier '" + functionName + "'", SQLStates.SYNTAX_ERROR.getSqlState() + ); + } + parameterStartPosition = i + 1; + wasComment = false; + } + i++; + break; + + case ')': + if (--braceNestLevel == 0) { + // reach the function closing brace + // parse the last possible function parameter + String param = functionString.substring(parameterStartPosition, i); + String clearParam = trimExpression(param, wasComment); + if (!clearParam.isEmpty()) { + functionParameters.add(param.trim()); + } else if (!functionParameters.isEmpty()) { + throw new SQLSyntaxErrorException( + "Empty function argument at " + (functionParameters.size() + 1) + " position", + SQLStates.SYNTAX_ERROR.getSqlState() + ); + } + completed = true; + wasComment = false; + } + i++; + break; + + case ',': + if (braceNestLevel == 1) { + // reach the function argument delimiter + // parse the argument before this comma + String param = functionString.substring(parameterStartPosition, i); + String clearParam = trimExpression(param, wasComment); + if (clearParam.isEmpty()) { + throw new SQLSyntaxErrorException( + "Empty function argument at " + (functionParameters.size() + 1) + " position", + SQLStates.SYNTAX_ERROR.getSqlState() + ); + } + functionParameters.add(param.trim()); + parameterStartPosition = i + 1; + wasComment = false; + } + i++; + break; + + default: + i++; + break; + } + } + + if (functionName == null || !completed) { + throw new SQLSyntaxErrorException( + "Malformed function expression '" + functionString + "'", SQLStates.SYNTAX_ERROR.getSqlState() + ); + } + if (i < functionString.length()) { + String tail = trimExpression(functionString.substring(i), true); + if (!tail.isEmpty()) { + throw new SQLSyntaxErrorException( + "Unexpected expression '" + tail + "' after a function declaration", + SQLStates.SYNTAX_ERROR.getSqlState() + ); + } + } + return new FunctionExpression(functionName, functionParameters); + } + + /** + * Handles an escape expression. All expression substitutes are applied to + * the passed {@code buffer} parameter. In case of {@code fn}, the function + * name is case-insensitive. + * + * @param buffer buffer containing current escape expression + * @param start start position of the escape syntax in the buffer, inclusive + * @param end end position of the escape syntax in the buffer, exclusive + * + * @throws SQLSyntaxErrorException if any syntax error happen + */ + private void processEscapeExpression(StringBuilder buffer, int start, int end) + throws SQLSyntaxErrorException { + if (buffer.charAt(start) != '{' || buffer.charAt(end - 1) != '}') { + return; + } + int startExpression = seekFirstNonSpaceSymbol(buffer, start + 1); + int endExpression = seekLastNonSpaceSymbol(buffer, end - 2) + 1; + + if (substringMatches(buffer, "fn ", startExpression)) { + FunctionExpression expression = parseFunction(buffer.substring(startExpression + 3, endExpression)); + ThrowingBiFunction mapper = + functionMappings.get(FunctionSignatureKey.of(expression.getName(), expression.getParameters().size())); + if (mapper == null) { + throw new SQLSyntaxErrorException( + "Unknown function " + expression.getName(), + SQLStates.SYNTAX_ERROR.getSqlState() + ); + } + buffer.replace(start, end, mapper.apply(expression, jdbcContext).toString()); + } else if (substringMatches(buffer, "oj ", startExpression)) { + buffer.replace(start, end, buffer.substring(startExpression + 3, endExpression)); + } else if (substringMatches(buffer, "escape ", startExpression)) { + buffer.replace(start, end, buffer.substring(startExpression, endExpression)); + } else if (substringMatches(buffer, "limit ", startExpression)) { + buffer.replace(start, end, buffer.substring(startExpression, endExpression)); + } else { + throw new SQLSyntaxErrorException("Unrecognizable escape expression", SQLStates.SYNTAX_ERROR.getSqlState()); + } + } + + /** + * Looks for the end of the region defined by its start and end + * substring patterns. + * + * @param text search text + * @param position start position in text to search the region, inclusive + * @param startRegion pattern of the region start + * @param endRegion pattern of the region end + * + * @return found position of the region end, inclusive. Start position if the region start + * pattern does not match the text start position and {@literal -1} if the + * region end is not found. + */ + private int seekEndOfRegion(String text, int position, String startRegion, String endRegion) { + if (!text.regionMatches(position, startRegion, 0, startRegion.length())) { + return position; + } + int end = text.indexOf(endRegion, position + startRegion.length()); + return end == -1 ? end : end + endRegion.length() - 1; + } + + private boolean substringMatches(StringBuilder text, String substring, int start) { + return text.indexOf(substring, start) == start; + } + + private int seekFirstNonSpaceSymbol(CharSequence text, int position) { + while (position < text.length() && Character.isWhitespace(text.charAt(position))) { + position++; + } + return position; + } + + private int seekLastNonSpaceSymbol(CharSequence text, int position) { + while (position > 0 && Character.isWhitespace(text.charAt(position))) { + position--; + } + return position; + } + + /** + * Returns a string where all leading and trailing + * skippable parts such as whitespaces or optional + * comments removed. + * + * @param expression source string + * @param includeComments flag indication should comments be removed + * + * @return trimmed source string without trailing + * and leading comments and whitespaces + */ + private String trimExpression(String expression, boolean includeComments) { + if (!includeComments) { + return expression.trim(); + } + + int position = 0; + StringBuilder clearExpression = new StringBuilder(expression.length()); + while (position < expression.length()) { + char currentChar = expression.charAt(position); + if (currentChar == '/') { + int ahead = seekEndOfRegion(expression, position, BLOCK.getStart(), BLOCK.getEnd()); + position = ahead == -1 ? expression.length() : ahead + 1; + } else if (currentChar == '-') { + int ahead = seekEndOfRegion(expression, position, LINE.getStart(), LINE.getEnd()); + position = ahead == -1 ? expression.length() : ahead + 1; + } else { + clearExpression.append(expression.charAt(position)); + position++; + } + } + return clearExpression.toString().trim(); + } + +} diff --git a/src/main/java/org/tarantool/jdbc/EscapedFunctions.java b/src/main/java/org/tarantool/jdbc/EscapedFunctions.java new file mode 100644 index 00000000..d9012065 --- /dev/null +++ b/src/main/java/org/tarantool/jdbc/EscapedFunctions.java @@ -0,0 +1,444 @@ +package org.tarantool.jdbc; + +import org.tarantool.util.ServerVersion; +import org.tarantool.util.ThrowingBiFunction; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.SQLSyntaxErrorException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Random; + +/** + * Supported escaped function by Tarantool JDBC driver. + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
Supported numeric scalar functions
JDBC escapeNativeComment
ABS(number)ABS(number)
PI()3.141592653589793Driver replaces the function to Math.PI constant
RAND(seed)0.6005595572679824 + * The driver replaces the function to the decimal value + * 0 <= x < 1 using Random.nextDouble(). Seed parameter is ignored + *
ROUND(number, places)ROUND(number, places)
+ *

+ * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
Supported string scalar functions
JDBC escapeNativeComment
CHAR(code)CHAR(code)
CHAR_LENGTH(code [, CHARACTERS | OCTETS])CHAR_LENGTH(code)Last optional parameters is not supported
CHARACTER_LENGTH(code [, CHARACTERS | OCTETS])CHARACTER_LENGTH(code)Last optional parameters is not supported
CONCAT(string1, string2)(string1 || string2)
LCASE(string)LOWER(string)
LEFT(string, count)SUBSTR(string, 1, count)
LENGTH(string, [, CHARACTERS | OCTETS])LENGTH(TRIM(TRAILING FROM string))Last optional parameters is not supported
LTRIM(string) + * LTRIM(string) for Tarantool < 2.2 + * TRIM(LEADING FROM string) for Tarantool >= 2.2 + * + * Tarantool 2.1 supports SQLite compatible function LTRIM, + * since 2.2 Tarantool supports ANSI SQL standard using TRIM(LEADING FROM) expression + *
REPLACE(string1, string2, string3)REPLACE(string1, string2, string3)
RIGHT(string, count)SUBSTR(string, -(count))
RTRIM(string) + * LTRIM(string) (for Tarantool < 2.2) + * TRIM(TRAILING FROM string) (for Tarantool >= 2.2) + * + * Tarantool 2.1 supports SQLite compatible function LTRIM, + * since 2.2 Tarantool supports ANSI SQL standard using TRIM(TRAILING FROM) expression + *
SOUNDEX(string)SOUNDEX(string)
SUBSTRING(string, start, length [, CHARACTERS | OCTETS])SUBSTR(string, start, length)Last optional parameters is not supported
UCASE(string)UPPER(string)
+ *

+ * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
Supported system scalar functions
JDBC escapeNativeComment
DATABASE()'universe'Tarantool does not support databases. Driver always replaces it to 'universe'.
IFNULL(expression1, expression2)IFNULL(expression1, expression2)
USER()'guest'Driver replaces the function to the current user name.
+ */ +public class EscapedFunctions { + + private static Random cachedRandom = new Random(); + + /** + * Supported numeric scalar functions. + */ + public enum NumericFunction { + ABS, PI, RAND, ROUND + } + + /** + * Supported string scalar functions. + */ + public enum StringFunction { + CHAR, + CHAR_LENGTH, + CHARACTER_LENGTH, + CONCAT, + LCASE, + LEFT, + LENGTH, + LTRIM, + REPLACE, + RIGHT, + RTRIM, + SOUNDEX, + SUBSTRING, + UCASE + } + + /** + * Supported system scalar functions. + */ + public enum SystemFunction { + DATABASE, IFNULL, USER + } + + static Map functionMappings; + + static { + functionMappings = new HashMap<>(128); + // C.1 numeric scalar function + functionMappings.put( + FunctionSignatureKey.of(NumericFunction.ABS.name(), 1), + (exp, context) -> exp + ); + functionMappings.put( + FunctionSignatureKey.of(NumericFunction.PI.name(), 0), + (exp, context) -> new NumericLiteral(Math.PI) + ); + functionMappings.put( + FunctionSignatureKey.of(NumericFunction.RAND.name(), 1), + (exp, context) -> new NumericLiteral(cachedRandom.nextDouble()) + ); + functionMappings.put( + FunctionSignatureKey.of(NumericFunction.ROUND.name(), 2), + (exp, context) -> exp + ); + + // C.2 string scalar function + functionMappings.put( + FunctionSignatureKey.of(StringFunction.CHAR.name(), 1), + (exp, context) -> exp + ); + functionMappings.put( + FunctionSignatureKey.of(StringFunction.CHAR_LENGTH.name(), 1), + (exp, context) -> exp + ); + functionMappings.put( + FunctionSignatureKey.of(StringFunction.CHARACTER_LENGTH.name(), 1), + (exp, context) -> exp + ); + functionMappings.put( + FunctionSignatureKey.of(StringFunction.CONCAT.name(), 2), + (exp, context) -> { + List parameters = exp.getParameters(); + return new FunctionExpression( + "", + Collections.singletonList(parameters.get(0) + " || " + parameters.get(1)) + ); + } + ); + functionMappings.put( + FunctionSignatureKey.of(StringFunction.LCASE.name(), 1), + (exp, context) -> new FunctionExpression("LOWER", exp.getParameters()) + ); + functionMappings.put( + FunctionSignatureKey.of(StringFunction.LEFT.name(), 2), + (exp, context) -> { + List parameters = exp.getParameters(); + return new FunctionExpression("SUBSTR", Arrays.asList(parameters.get(0), "1", parameters.get(1))); + } + ); + functionMappings.put( + FunctionSignatureKey.of(StringFunction.LENGTH.name(), 1), + (exp, context) -> { + String string = "TRIM(TRAILING FROM " + exp.getParameters().get(0) + ")"; + return new FunctionExpression("LENGTH", Collections.singletonList(string)); + } + ); + functionMappings.put( + FunctionSignatureKey.of(StringFunction.LTRIM.name(), 1), + (exp, context) -> { + try { + TarantoolDatabaseMetaData metaData = context.getMetaData().unwrap(TarantoolDatabaseMetaData.class); + if (metaData.getDatabaseVersion().isLessThan(ServerVersion.V_2_2)) { + return exp; + } + String string = "LEADING FROM " + exp.getParameters().get(0); + return new FunctionExpression("TRIM", Collections.singletonList(string)); + } catch (SQLException cause) { + throw new SQLSyntaxErrorException("Unresolvable LTRIM function", cause); + } + } + ); + functionMappings.put( + FunctionSignatureKey.of(StringFunction.REPLACE.name(), 3), + (exp, context) -> exp + ); + functionMappings.put( + FunctionSignatureKey.of(StringFunction.RIGHT.name(), 2), + (exp, context) -> { + String string = exp.getParameters().get(0); + String count = exp.getParameters().get(1); + return new FunctionExpression("SUBSTR", Arrays.asList(string, "-(" + count + ")")); + } + ); + functionMappings.put( + FunctionSignatureKey.of(StringFunction.RTRIM.name(), 1), + (exp, context) -> { + try { + TarantoolDatabaseMetaData metaData = context.getMetaData().unwrap(TarantoolDatabaseMetaData.class); + ServerVersion databaseVersion = metaData.getDatabaseVersion(); + if (databaseVersion.isLessThan(ServerVersion.V_2_2)) { + return exp; + } + String string = "TRAILING FROM " + exp.getParameters().get(0); + return new FunctionExpression("TRIM", Collections.singletonList(string)); + } catch (SQLException cause) { + throw new SQLSyntaxErrorException("Unresolvable RTRIM function", cause); + } + } + ); + functionMappings.put( + FunctionSignatureKey.of(StringFunction.SOUNDEX.name(), 1), + (exp, context) -> exp + ); + functionMappings.put( + FunctionSignatureKey.of(StringFunction.SUBSTRING.name(), 3), + (exp, context) -> new FunctionExpression("SUBSTR", exp.getParameters()) + ); + functionMappings.put( + FunctionSignatureKey.of(StringFunction.UCASE.name(), 1), + (exp, context) -> new FunctionExpression("UPPER", exp.getParameters()) + ); + + // C.4 system scalar functions + functionMappings.put( + FunctionSignatureKey.of(SystemFunction.DATABASE.name(), 0), + (exp, context) -> new StringLiteral("universe") + ); + functionMappings.put( + FunctionSignatureKey.of(SystemFunction.IFNULL.name(), 2), + (exp, context) -> exp + ); + functionMappings.put( + FunctionSignatureKey.of(SystemFunction.USER.name(), 0), + (exp, context) -> { + try { + return new StringLiteral(context.getMetaData().getUserName()); + } catch (SQLException e) { + throw new SQLSyntaxErrorException("User cannot be resolved", e.getSQLState(), e); + } + } + ); + } + + interface TranslationFunction + extends ThrowingBiFunction { + + } + + static class FunctionSignatureKey { + + String name; + int parametersCount; + + static FunctionSignatureKey of(String name, int parametersCount) { + FunctionSignatureKey key = new FunctionSignatureKey(); + key.name = name.toUpperCase(); + key.parametersCount = parametersCount; + return key; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + FunctionSignatureKey that = (FunctionSignatureKey) o; + return parametersCount == that.parametersCount && + Objects.equals(name, that.name); + } + + @Override + public int hashCode() { + return Objects.hash(name, parametersCount); + } + + } + + interface Expression { + + } + + static class StringLiteral implements Expression { + + final String value; + + public StringLiteral(String value) { + this.value = value; + } + + @Override + public String toString() { + return "'" + value + "'"; + } + + } + + static class NumericLiteral implements Expression { + + final double number; + + public NumericLiteral(double number) { + this.number = number; + } + + @Override + public String toString() { + return Double.toString(number); + } + + } + + static class FunctionExpression implements Expression { + + String name; + List parameters; + + FunctionExpression(String name, List parameters) { + this.name = name; + this.parameters = parameters; + } + + public String getName() { + return name; + } + + public List getParameters() { + return parameters; + } + + @Override + public String toString() { + return name + + "(" + + String.join(", ", parameters) + + ')'; + } + + } + +} diff --git a/src/main/java/org/tarantool/jdbc/SQLConnection.java b/src/main/java/org/tarantool/jdbc/SQLConnection.java index 327d0d69..9b5d5bb8 100644 --- a/src/main/java/org/tarantool/jdbc/SQLConnection.java +++ b/src/main/java/org/tarantool/jdbc/SQLConnection.java @@ -63,12 +63,15 @@ public class SQLConnection implements TarantoolConnection { private DatabaseMetaData cachedMetadata; private int resultSetHoldability = UNSET_HOLDABILITY; + private final EscapeSyntaxParser escapeSyntaxParser; + public SQLConnection(String url, Properties properties) throws SQLException { this.url = url; this.properties = properties; try { client = makeSqlClient(makeAddress(properties), makeConfigFromProperties(properties)); + escapeSyntaxParser = new EscapeSyntaxParser(this); } catch (Exception e) { throw new SQLException("Couldn't initiate connection using " + SQLDriver.diagProperties(properties), e); } @@ -189,7 +192,7 @@ public CallableStatement prepareCall(String sql, @Override public String nativeSQL(String sql) throws SQLException { checkNotClosed(); - throw new SQLFeatureNotSupportedException(); + return escapeSyntaxParser.translate(sql, true); } @Override diff --git a/src/main/java/org/tarantool/jdbc/SQLDatabaseMetadata.java b/src/main/java/org/tarantool/jdbc/SQLDatabaseMetadata.java index 5e5a7b47..d6801104 100644 --- a/src/main/java/org/tarantool/jdbc/SQLDatabaseMetadata.java +++ b/src/main/java/org/tarantool/jdbc/SQLDatabaseMetadata.java @@ -6,6 +6,7 @@ import org.tarantool.Version; import org.tarantool.jdbc.type.TarantoolSqlType; import org.tarantool.util.ServerVersion; +import org.tarantool.util.StringUtils; import org.tarantool.util.TupleTwo; import java.sql.Connection; @@ -179,17 +180,17 @@ public String getSQLKeywords() throws SQLException { @Override public String getNumericFunctions() throws SQLException { - return ""; + return StringUtils.toCsvList(EscapedFunctions.NumericFunction.values()); } @Override public String getStringFunctions() throws SQLException { - return ""; + return StringUtils.toCsvList(EscapedFunctions.StringFunction.values()); } @Override public String getSystemFunctions() throws SQLException { - return ""; + return StringUtils.toCsvList(EscapedFunctions.SystemFunction.values()); } @Override @@ -274,7 +275,7 @@ public boolean supportsGroupByBeyondSelect() throws SQLException { @Override public boolean supportsLikeEscapeClause() throws SQLException { - return false; + return true; } @Override diff --git a/src/main/java/org/tarantool/jdbc/SQLPreparedStatement.java b/src/main/java/org/tarantool/jdbc/SQLPreparedStatement.java index 342bb74d..1d1b9e27 100644 --- a/src/main/java/org/tarantool/jdbc/SQLPreparedStatement.java +++ b/src/main/java/org/tarantool/jdbc/SQLPreparedStatement.java @@ -43,7 +43,7 @@ public class SQLPreparedStatement extends SQLStatement implements PreparedStatem public SQLPreparedStatement(SQLConnection connection, String sql, int autoGeneratedKeys) throws SQLException { super(connection); - this.sql = sql; + this.sql = translateQuery(sql); this.parameters = new HashMap<>(); this.autoGeneratedKeys = autoGeneratedKeys; setPoolable(true); @@ -55,7 +55,7 @@ public SQLPreparedStatement(SQLConnection connection, int resultSetConcurrency, int resultSetHoldability) throws SQLException { super(connection, resultSetType, resultSetConcurrency, resultSetHoldability); - this.sql = sql; + this.sql = translateQuery(sql); this.parameters = new HashMap<>(); this.autoGeneratedKeys = NO_GENERATED_KEYS; setPoolable(true); diff --git a/src/main/java/org/tarantool/jdbc/SQLStatement.java b/src/main/java/org/tarantool/jdbc/SQLStatement.java index e8959234..c18aa893 100644 --- a/src/main/java/org/tarantool/jdbc/SQLStatement.java +++ b/src/main/java/org/tarantool/jdbc/SQLStatement.java @@ -45,6 +45,7 @@ public class SQLStatement implements TarantoolStatement { private List batchQueries = new ArrayList<>(); private boolean isCloseOnCompletion; + private boolean useEscapeProcessing = true; private final int resultSetType; private final int resultSetConcurrency; @@ -91,7 +92,7 @@ protected SQLStatement(SQLConnection sqlConnection, @Override public ResultSet executeQuery(String sql) throws SQLException { checkNotClosed(); - if (!executeInternal(NO_GENERATED_KEYS, sql)) { + if (!executeInternal(NO_GENERATED_KEYS, translateQuery(sql))) { throw new SQLException("No results were returned", SQLStates.NO_DATA.getSqlState()); } return resultSet; @@ -106,7 +107,7 @@ public int executeUpdate(String sql) throws SQLException { public int executeUpdate(String sql, int autoGeneratedKeys) throws SQLException { checkNotClosed(); JdbcConstants.checkGeneratedKeysConstant(autoGeneratedKeys); - if (executeInternal(autoGeneratedKeys, sql)) { + if (executeInternal(autoGeneratedKeys, translateQuery(sql))) { throw new SQLException( "Result was returned but nothing was expected", SQLStates.TOO_MANY_RESULTS.getSqlState() @@ -166,7 +167,8 @@ public void setMaxRows(int maxRows) throws SQLException { @Override public void setEscapeProcessing(boolean enable) throws SQLException { - throw new SQLFeatureNotSupportedException(); + checkNotClosed(); + useEscapeProcessing = enable; } @Override @@ -208,7 +210,7 @@ public void setCursorName(String name) throws SQLException { @Override public boolean execute(String sql) throws SQLException { checkNotClosed(); - return executeInternal(NO_GENERATED_KEYS, sql); + return executeInternal(NO_GENERATED_KEYS, translateQuery(sql)); } @Override @@ -511,4 +513,8 @@ protected SQLResultSet executeGeneratedKeys(List generatedKeys) throws return createResultSet(SQLResultHolder.ofQuery(Collections.singletonList(sqlMetaData), rows)); } + protected String translateQuery(String sql) throws SQLException { + return useEscapeProcessing ? connection.nativeSQL(sql) : sql; + } + } diff --git a/src/main/java/org/tarantool/util/SQLStates.java b/src/main/java/org/tarantool/util/SQLStates.java index 89ac309d..39bfae91 100644 --- a/src/main/java/org/tarantool/util/SQLStates.java +++ b/src/main/java/org/tarantool/util/SQLStates.java @@ -7,7 +7,8 @@ public enum SQLStates { CONNECTION_DOES_NOT_EXIST("08003"), INVALID_PARAMETER_VALUE("22023"), INVALID_CURSOR_STATE("24000"), - INVALID_TRANSACTION_STATE("25000"); + INVALID_TRANSACTION_STATE("25000"), + SYNTAX_ERROR("42000"); private final String sqlState; diff --git a/src/main/java/org/tarantool/util/StringUtils.java b/src/main/java/org/tarantool/util/StringUtils.java index 7a289a3f..b0ccc09c 100644 --- a/src/main/java/org/tarantool/util/StringUtils.java +++ b/src/main/java/org/tarantool/util/StringUtils.java @@ -1,5 +1,8 @@ package org.tarantool.util; +import java.util.stream.Collectors; +import java.util.stream.Stream; + public class StringUtils { public static boolean isEmpty(String string) { @@ -18,4 +21,10 @@ public static boolean isNotBlank(String string) { return !isBlank(string); } + public static String toCsvList(Enum[] values) { + return Stream.of(values) + .map(Enum::name) + .collect(Collectors.joining(",")); + } + } diff --git a/src/main/java/org/tarantool/util/ThrowingBiFunction.java b/src/main/java/org/tarantool/util/ThrowingBiFunction.java new file mode 100644 index 00000000..e69d09e0 --- /dev/null +++ b/src/main/java/org/tarantool/util/ThrowingBiFunction.java @@ -0,0 +1,27 @@ +package org.tarantool.util; + +/** + * Represents a function that accepts two arguments and + * produces a result or throws an exception. + * + * @param type of the first argument to the function + * @param type of the second argument to the function + * @param type of the result of the function + * @param type of the exception in case of error + */ +@FunctionalInterface +public interface ThrowingBiFunction { + + /** + * Applies this function to the given arguments. + * + * @param argument1 first argument + * @param argument2 second argument + * + * @return function result + * + * @throws E if any error occurs + */ + R apply(T argument1, U argument2) throws E; + +} diff --git a/src/test/java/org/tarantool/jdbc/JdbcConnectionIT.java b/src/test/java/org/tarantool/jdbc/JdbcConnectionIT.java index 35b08f9f..86b73c60 100644 --- a/src/test/java/org/tarantool/jdbc/JdbcConnectionIT.java +++ b/src/test/java/org/tarantool/jdbc/JdbcConnectionIT.java @@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.tarantool.TestAssumptions.assumeMinimalServerVersion; +import static org.tarantool.TestAssumptions.assumeServerVersionLessThan; import org.tarantool.TarantoolTestHelper; import org.tarantool.util.ServerVersion; @@ -26,6 +27,7 @@ import java.sql.SQLClientInfoException; import java.sql.SQLException; import java.sql.SQLFeatureNotSupportedException; +import java.sql.SQLSyntaxErrorException; import java.sql.Statement; import java.util.Map; @@ -456,5 +458,272 @@ void testSetClientInfoProperties() { assertEquals(ClientInfoStatus.REASON_UNKNOWN_PROPERTY, failedProperties.get(targetProperty)); } -} + @Test + void testLimitEscapeProcessing() throws SQLException { + String[][] expressions = { + { "select * from table {limit 10}", "select * from table limit 10" }, + { "select * from table {limit 10 offset 20}", "select * from table limit 10 offset 20" }, + { + "select * from table where val = 'val {limit 10}' {limit 15}", + "select * from table where val = 'val {limit 10}' limit 15" + }, + { "select * from table {limit 10}", "select * from table limit 10" }, + { "select * from table /*{limit 10}*/ {limit 25}", "select * from table /*{limit 10}*/ limit 25" }, + { "select * from table {limit 25} -- {limit 45}", "select * from table limit 25 -- {limit 45}" }, + { "select * from table -- {limit 45}\n{limit 10}", "select * from table -- {limit 45}\nlimit 10" }, + { "select * from table {limit (10) offset (((20)))}", "select * from table limit (10) offset (((20)))" } + }; + + for (String[] pair : expressions) { + assertEquals(pair[1], conn.nativeSQL(pair[0])); + } + } + + @Test + void testLikeEscapeProcessing() throws SQLException { + String[][] expressions = { + { + "select * from table where val like '|%type' {escape '|'}", + "select * from table where val like '|%type' escape '|'" + }, + { + "select * from table where val like '|%type' -- {escape '|'}", + "select * from table where val like '|%type' -- {escape '|'}" + }, + { + "select * from table where /* use {escape '&'} */ val like '|&type&&' {escape '&'}", + "select * from table where /* use {escape '&'} */ val like '|&type&&' escape '&'", + }, + { + "select * from table where /* use {escape '&'} */ val like '|&type&&' {escape '&'}", + "select * from table where /* use {escape '&'} */ val like '|&type&&' escape '&'", + }, + { + "select * from \"TABLE\" where val like '|&type&&' {escape {fn char(38)}}", + "select * from \"TABLE\" where val like '|&type&&' escape CHAR(38)", + } + }; + + for (String[] pair : expressions) { + assertEquals(pair[1], conn.nativeSQL(pair[0])); + } + } + + @Test + void testOuterJoinEscapeProcessing() throws SQLException { + String[][] expressions = { + { + "select * from {oj table1 left outer join table2 on type = 4} {limit 5}", + "select * from table1 left outer join table2 on type = 4 limit 5", + }, + { + "select * from /* {oj} */ {oj table1 left outer join table2 on type = 4} {limit 5}", + "select * from /* {oj} */ table1 left outer join table2 on type = 4 limit 5", + }, + { + "select * from {oj t1 left outer join (select id from {oj t2 right outer join t3 on 1 = 1}) on id = 4}", + "select * from t1 left outer join (select id from t2 right outer join t3 on 1 = 1) on id = 4", + } + }; + + for (String[] pair : expressions) { + assertEquals(pair[1], conn.nativeSQL(pair[0])); + } + } + @Test + void testSystemFunctionsEscapeProcessing() throws SQLException { + String[][] expressions = { + { "select {fn database()}", "select 'universe'" }, + { "select {fn user()}", "select 'test_admin'" }, + { "select {fn ifnull(null, 'non null string')}", "select IFNULL(null, 'non null string')" }, + { "select {fn ifnull({fn user()}, {fn database()})}", "select IFNULL('test_admin', 'universe')" } + }; + + for (String[] pair : expressions) { + assertEquals(pair[1], conn.nativeSQL(pair[0])); + } + } + + @Test + void testNumericFunctionsEscapeProcessing() throws SQLException { + String[][] expressions = { + { "select {fn abs(-10)}", "select ABS(-10)" }, + { "select {fn pi()}", "select 3.141592653589793" }, + { "select {fn round(-3.14, 1)}", "select ROUND(-3.14, 1)" }, + { + "select 2 * {fn pi()} * {fn pi()} / {fn abs(4 - {fn round({fn pi()}, 4)})}", + "select 2 * 3.141592653589793 * 3.141592653589793 / ABS(4 - ROUND(3.141592653589793, 4))" + } + }; + + for (String[] pair : expressions) { + assertEquals(pair[1], conn.nativeSQL(pair[0])); + } + } + + @Test + void testStringFunctionsEscapeProcessing() throws SQLException { + String[][] expressions = { + { "select {fn char(32)}", "select CHAR(32)" }, + { "select {fn char_length(val)}", "select CHAR_LENGTH(val)" }, + { "select {fn character_length(val)}", "select CHARACTER_LENGTH(val)" }, + { "select {fn concat('abc', '123')}", "select ('abc' || '123')" }, + { "select {fn lcase('aBc')}", "select LOWER('aBc')" }, + { "select {fn left('abcdfgh', 3)}", "select SUBSTR('abcdfgh', 1, 3)" }, + { "select {fn length('value')}", "select LENGTH(TRIM(TRAILING FROM 'value'))" }, + { "select {fn replace('value', 'a', 'o')}", "select REPLACE('value', 'a', 'o')" }, + { "select {fn right('value', 2)}", "select SUBSTR('value', -(2))" }, + { "select {fn soundex('one')}", "select SOUNDEX('one')" }, + { "select {fn substring('value', 2, len)}", "select SUBSTR('value', 2, len)" }, + { "select {fn ucase('value')}", "select UPPER('value')" }, + { + "select {fn lcase({fn substring({fn concat('value', '12345')}, 1, {fn abs(num)})})}", + "select LOWER(SUBSTR(('value' || '12345'), 1, ABS(num)))" + } + }; + + for (String[] pair : expressions) { + assertEquals(pair[1], conn.nativeSQL(pair[0])); + } + } + + @Test + void testStringFunctionsEscapeProcessingBefore22() throws SQLException { + assumeServerVersionLessThan(testHelper.getInstanceVersion(), ServerVersion.V_2_2); + + String[][] expressions = { + { "select {fn ltrim(' value')}", "select LTRIM(' value')" }, + { "select {fn rtrim('value ')}", "select RTRIM('value ')" }, + }; + + for (String[] pair : expressions) { + assertEquals(pair[1], conn.nativeSQL(pair[0])); + } + } + + @Test + void testStringFunctionsEscapeProcessingFrom22() throws SQLException { + assumeMinimalServerVersion(testHelper.getInstanceVersion(), ServerVersion.V_2_2); + + String[][] expressions = { + { "select {fn ltrim(' value')}", "select TRIM(LEADING FROM ' value')" }, + { "select {fn rtrim('value ')}", "select TRIM(TRAILING FROM 'value ')" }, + }; + + for (String[] pair : expressions) { + assertEquals(pair[1], conn.nativeSQL(pair[0])); + } + } + + @Test + void testNoFunctionsEscapeProcessing() throws SQLException { + String[] expressions = { + "select * from table /* {fn abs(-10)} */", + "select * from table", + "select 1 -- ping", + "select 3 -- {fn round(3.14, 0)}", + "select '{fn pi()}'" + }; + + for (String expression : expressions) { + assertEquals(expression, conn.nativeSQL(expression)); + } + } + + @Test + void testEscapeWithExtraWhitespaces() throws SQLException { + String[][] expressions = { + { "select {fn database( )}", "select 'universe'" }, + { "select { fn user()}", "select 'test_admin'" }, + { "select {fn user() }", "select 'test_admin'" }, + { "select { fn user() }", "select 'test_admin'" }, + }; + + for (String[] pair : expressions) { + assertEquals(pair[1], conn.nativeSQL(pair[0])); + } + } + + @Test + void testEscapeWithComments() throws SQLException { + String[][] expressions = { + { + "select * from {oj table1 left outer join table2 /* join */ on type = 4} {limit 5 /*no more than 5*/}", + "select * from table1 left outer join table2 /* join */ on type = 4 limit 5 /*no more than 5*/", + }, + { + "select {fn ucase(-- string in any case\n'ram')}", + "select UPPER(-- string in any case\n'ram')" }, + { + "select {fn round(/* number */ val, /* places */ 3)}", + "select ROUND(/* number */ val, /* places */ 3)" + }, + { + "select {fn database(/* get db name */)}", + "select 'universe'" + }, + { + "select {fn database(-- get db name\n)}", + "select 'universe'" + }, + { + "select {fn soundex(/* 12 */ 'apple')}", + "select SOUNDEX(/* 12 */ 'apple')", + }, + { + "select {fn lcase /* to lower case */ ('ORaNGE')}", + "select LOWER('ORaNGE')", + }, + { + "select {fn /* get char */ char(32) /* end */}", + "select CHAR(32)", + }, + { + "select {fn concat(/*first*/'abc', /*second*/'def')}", + "select (/*first*/'abc' || /*second*/'def')", + }, + { + "select /* 2 * pi * abs(round(-6, 0)) */ 2 * {fn pi(/*3.14*/)} * " + + "{fn abs(/*abs*/{fn round(/*todo*/-6, /*check*/0)})}", + "select /* 2 * pi * abs(round(-6, 0)) */ 2 * 3.141592653589793 * " + + "ABS(/*abs*/ROUND(/*todo*/-6, /*check*/0))" + }, + { + "select * FROM test /* limit rows */ {limit 10 /* ten should be enough */}", + "select * FROM test /* limit rows */ limit 10 /* ten should be enough */", + }, + }; + + for (String[] pair : expressions) { + assertEquals(pair[1], conn.nativeSQL(pair[0])); + } + } + + @Test + void testWrongFunctionsEscapeProcessing() throws SQLException { + String[] expressions = { + "select {fn char(48)", // open escape expression + "select /* {fn char_length(val)}", // open block comment + "select {fn character_length('asd)}", // open string literal + "select }fn concat('abc', '123')}", // bad '}' + "select {fn lcase('aBc')}}", // extra } + "select * from \"TABLE where val = {fn left('abcdfgh', 3)}", // open quoted identifier + "select {fn ('value')}", // missed function name + "select {fn ltrim((' value')}", // extra ( + "select {fn 0replace('value', 'a', 'o')}", // wrong identifier + "select {fn right_part('value', 2)}", // unsupported/unknown function name + "select {comment 'your comment here'}", // unsupported escape syntax + "select {fn soundex('one', 3)}", // unsupported function signature (2 args) + "select {fn soundex('one')2'string' }", // extra non-blank symbols after a function declaration + "select {fn ucase}", // missed function braces + "select {fn substring('abc', 1, )}", // missed last function braces + "select {fn substring(, 1, 2)}", // missed first function braces + }; + + for (String badExpression : expressions) { + assertThrows(SQLSyntaxErrorException.class, () -> conn.nativeSQL(badExpression)); + } + } + +} diff --git a/src/test/java/org/tarantool/jdbc/JdbcDatabaseMetaDataIT.java b/src/test/java/org/tarantool/jdbc/JdbcDatabaseMetaDataIT.java index c621ae98..6fbaef28 100644 --- a/src/test/java/org/tarantool/jdbc/JdbcDatabaseMetaDataIT.java +++ b/src/test/java/org/tarantool/jdbc/JdbcDatabaseMetaDataIT.java @@ -23,8 +23,12 @@ import java.sql.ResultSet; import java.sql.ResultSetMetaData; import java.sql.SQLException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Collectors; public class JdbcDatabaseMetaDataIT { @@ -424,4 +428,37 @@ void testDatabaseProductVersion() throws SQLException { assertEquals(version, databaseProductVersion); } + @Test + void testStringFunctionSupport() throws SQLException { + String[] systemFunctions = meta.getStringFunctions().split(","); + assertEquals(EscapedFunctions.StringFunction.values().length, systemFunctions.length); + Set actualSet = new HashSet<>(Arrays.asList(systemFunctions)); + Set expectedSet = Arrays.stream(EscapedFunctions.StringFunction.values()) + .map(Enum::toString) + .collect(Collectors.toSet()); + assertEquals(expectedSet, actualSet); + } + + @Test + void testNumericFunctionSupport() throws SQLException { + String[] systemFunctions = meta.getNumericFunctions().split(","); + assertEquals(EscapedFunctions.NumericFunction.values().length, systemFunctions.length); + Set actualSet = new HashSet<>(Arrays.asList(systemFunctions)); + Set expectedSet = Arrays.stream(EscapedFunctions.NumericFunction.values()) + .map(Enum::toString) + .collect(Collectors.toSet()); + assertEquals(expectedSet, actualSet); + } + + @Test + void testSystemFunctionSupport() throws SQLException { + String[] systemFunctions = meta.getSystemFunctions().split(","); + assertEquals(EscapedFunctions.SystemFunction.values().length, systemFunctions.length); + Set actualSet = new HashSet<>(Arrays.asList(systemFunctions)); + Set expectedSet = Arrays.stream(EscapedFunctions.SystemFunction.values()) + .map(Enum::toString) + .collect(Collectors.toSet()); + assertEquals(expectedSet, actualSet); + } + } diff --git a/src/test/java/org/tarantool/jdbc/JdbcPreparedStatementIT.java b/src/test/java/org/tarantool/jdbc/JdbcPreparedStatementIT.java index d2c01553..c04be6bc 100644 --- a/src/test/java/org/tarantool/jdbc/JdbcPreparedStatementIT.java +++ b/src/test/java/org/tarantool/jdbc/JdbcPreparedStatementIT.java @@ -719,6 +719,207 @@ public void testSetBadCharacterStream() throws Exception { assertEquals(SQLStates.INVALID_PARAMETER_VALUE.getSqlState(), error.getSQLState()); } + @Test + public void testDisabledEscapeSyntax() throws Exception { + testHelper.executeSql("INSERT INTO test(id, val) VALUES (1, 'one'), (2, 'two'), (3, 'three'), (4, 'four')"); + + prep = conn.prepareStatement("SELECT val FROM test ORDER BY id {limit ?}"); + // according to JDBC API this call has no effect on escape processing + // for prepared statements + prep.setEscapeProcessing(false); + + prep.setInt(1, 1); + prep.execute(); + + try (ResultSet resultSet = prep.getResultSet()) { + assertTrue(resultSet.next()); + assertEquals("one", resultSet.getString(1)); + assertFalse(resultSet.next()); + } + + } + + @Test + public void testLimitEscapeSyntax() throws Exception { + testHelper.executeSql("INSERT INTO test(id, val) VALUES (1, 'one'), (2, 'two'), (3, 'three'), (4, 'four')"); + + prep = conn.prepareStatement("SELECT val FROM test ORDER BY id {limit ? offset ?}"); + prep.setInt(1, 2); + prep.setInt(2, 0); + + prep.execute(); + + try (ResultSet resultSet = prep.getResultSet()) { + assertTrue(resultSet.next()); + assertEquals("one", resultSet.getString(1)); + assertTrue(resultSet.next()); + assertEquals("two", resultSet.getString(1)); + assertFalse(resultSet.next()); + } + } + + @Test + public void testLikeEscapeSyntax() throws Exception { + testHelper.executeSql("INSERT INTO test(id, val) VALUES (1, 'one%'), (2, 'two'), (3, 'three%'), (4, 'four')"); + + prep = conn.prepareStatement("SELECT val FROM test WHERE val LIKE '%|%' {escape ?}"); + prep.setString(1, "|"); + + prep.execute(); + + try (ResultSet resultSet = prep.getResultSet()) { + assertTrue(resultSet.next()); + assertEquals("one%", resultSet.getString(1)); + assertTrue(resultSet.next()); + assertEquals("three%", resultSet.getString(1)); + assertFalse(resultSet.next()); + } + } + + @Test + public void testOuterJoinEscapeSyntax() throws Exception { + testHelper.executeSql("INSERT INTO test(id, val) VALUES (1, 'one')"); + + prep = conn.prepareStatement( + "SELECT {fn concat('t1-', t1.val)}, {fn concat('t2-', t2.val)} " + + "FROM {oj test t1 LEFT OUTER JOIN test t2 ON t1.id = ?}" + ); + prep.setInt(1, 1); + + prep.execute(); + + try (ResultSet resultSet = prep.getResultSet()) { + assertTrue(resultSet.next()); + assertEquals("t1-one", resultSet.getString(1)); + assertEquals("t2-one", resultSet.getString(2)); + assertFalse(resultSet.next()); + } + } + + @Test + public void testSystemFunctionEscapeSyntax() throws Exception { + testHelper.executeSql("INSERT INTO test(id, val) VALUES (1, NULL)"); + + prep = conn.prepareStatement("SELECT {fn ifnull(val, ?)} FROM test WHERE id = 1"); + prep.setString(1, "one-one"); + + prep.execute(); + + try (ResultSet resultSet = prep.getResultSet()) { + assertTrue(resultSet.next()); + assertEquals("one-one", resultSet.getString(1)); + assertFalse(resultSet.next()); + } + } + + @Test + public void testNumericFunctionEscapeSyntax() throws Exception { + testHelper.executeSql("INSERT INTO test(id, val) VALUES (1, NULL)"); + + prep = conn.prepareStatement("SELECT {fn abs(5 - ?)}, {fn round({fn pi()}, ?)}"); + prep.setInt(1, 10); + prep.setInt(2, 0); + + prep.execute(); + + try (ResultSet resultSet = prep.getResultSet()) { + assertTrue(resultSet.next()); + assertEquals(5, resultSet.getInt(1)); + assertEquals(3, resultSet.getInt(2)); + assertFalse(resultSet.next()); + } + } + + @Test + public void testStringFunctionEscapeSyntax() throws Exception { + testHelper.executeSql("INSERT INTO test(id, val) VALUES (1, 'one'), (2, 'TWO'), (3, 'three'), (4, ' four ')"); + + prep = conn.prepareStatement( + "SELECT {fn char(?)}, " + + "{fn ucase(val)}, " + + "{fn right(val, 2)}, " + + "{fn concat(?, val)} " + + "FROM test WHERE id = 3" + ); + prep.setInt(1, 0x20); + prep.setString(2, "3 "); + + prep.execute(); + + try (ResultSet resultSet = prep.getResultSet()) { + assertTrue(resultSet.next()); + assertEquals(" ", resultSet.getString(1)); + assertEquals("THREE", resultSet.getString(2)); + assertEquals("ee", resultSet.getString(3)); + assertEquals("3 three", resultSet.getString(4)); + assertFalse(resultSet.next()); + } + prep.close(); + + prep = conn.prepareStatement( + "SELECT {fn lcase(val)}, " + + "{fn left(val, ?)}, " + + "{fn replace({fn lcase(val)}, 'two', ?)}, " + + "{fn substring(val, ?, 2)} " + + "FROM test WHERE id = 2" + ); + prep.setInt(1, 2); + prep.setString(2, "2"); + prep.setInt(3, 1); + + prep.execute(); + + try (ResultSet resultSet = prep.getResultSet()) { + assertTrue(resultSet.next()); + assertEquals("two", resultSet.getString(1)); + assertEquals("TW", resultSet.getString(2)); + assertEquals("2", resultSet.getString(3)); + assertEquals("TW", resultSet.getString(4)); + assertFalse(resultSet.next()); + } + + prep = conn.prepareStatement( + "SELECT {fn rtrim(val)}, " + + "{fn ltrim(val)} " + + "FROM test WHERE id = ?" + ); + prep.setInt(1, 4); + + prep.execute(); + + try (ResultSet resultSet = prep.getResultSet()) { + assertTrue(resultSet.next()); + assertEquals(" four", resultSet.getString(1)); + assertEquals("four ", resultSet.getString(2)); + assertFalse(resultSet.next()); + } + } + + /** + * Test length and soundex functions + * which became available since 2.2.0 + */ + @Test + void testStringFunctionFrom22() throws SQLException { + assumeMinimalServerVersion(testHelper.getInstanceVersion(), ServerVersion.V_2_2); + + testHelper.executeSql("INSERT INTO test(id, val) VALUES (1, ' one ')"); + + prep = conn.prepareStatement( + "SELECT {fn length(val)}, {fn soundex(val)} FROM test" + ); + + prep.execute(); + + try (ResultSet resultSet = prep.getResultSet()) { + assertTrue(resultSet.next()); + assertEquals(4, resultSet.getInt(1)); + assertEquals("O500", resultSet.getString(2)); + assertFalse(resultSet.next()); + } + } + + private List consoleSelect(Object key) { List list = testHelper.evaluate(TestUtils.toLuaSelect("TEST", key)); return list == null ? Collections.emptyList() : (List) list.get(0); diff --git a/src/test/java/org/tarantool/jdbc/JdbcStatementIT.java b/src/test/java/org/tarantool/jdbc/JdbcStatementIT.java index d8d5e0ad..777a76b5 100644 --- a/src/test/java/org/tarantool/jdbc/JdbcStatementIT.java +++ b/src/test/java/org/tarantool/jdbc/JdbcStatementIT.java @@ -570,6 +570,164 @@ void testPoolableStatus() throws SQLException { assertTrue(stmt.isPoolable()); } + @Test + public void testDisabledEscapeSyntax() throws Exception { + stmt.setEscapeProcessing(false); + assertThrows(SQLException.class, () -> stmt.executeQuery("SELECT val FROM test ORDER BY id {limit 2}")); + } + + @Test + public void testLimitEscapeSyntax() throws Exception { + testHelper.executeSql("INSERT INTO test(id, val) VALUES (1, 'one'), (2, 'two'), (3, 'three'), (4, 'four')"); + + try (ResultSet resultSet = stmt.executeQuery("SELECT val FROM test ORDER BY id {limit 2}")) { + assertTrue(resultSet.next()); + assertEquals("one", resultSet.getString(1)); + assertTrue(resultSet.next()); + assertEquals("two", resultSet.getString(1)); + assertFalse(resultSet.next()); + } + try (ResultSet resultSet = stmt.executeQuery("SELECT val FROM test ORDER BY id {limit 2 offset 2}")) { + assertTrue(resultSet.next()); + assertEquals("three", resultSet.getString(1)); + assertTrue(resultSet.next()); + assertEquals("four", resultSet.getString(1)); + assertFalse(resultSet.next()); + } + } + + @Test + public void testLikeEscapeSyntax() throws Exception { + testHelper.executeSql("INSERT INTO test(id, val) VALUES (1, 'one%'), (2, 't_wo'), (3, 'three%'), (4, 'four')"); + + try (ResultSet resultSet = stmt.executeQuery("SELECT val FROM test WHERE val LIKE '%|%' {escape '|'}")) { + assertTrue(resultSet.next()); + assertEquals("one%", resultSet.getString(1)); + assertTrue(resultSet.next()); + assertEquals("three%", resultSet.getString(1)); + assertFalse(resultSet.next()); + } + try (ResultSet resultSet = stmt.executeQuery("SELECT val FROM test WHERE val LIKE '_>_%' {escape '>'}")) { + assertTrue(resultSet.next()); + assertEquals("t_wo", resultSet.getString(1)); + assertFalse(resultSet.next()); + } + } + + @Test + public void testOuterJoinEscapeSyntax() throws Exception { + testHelper.executeSql("INSERT INTO test(id, val) VALUES (1, 'one')"); + + try (ResultSet resultSet = stmt.executeQuery( + "SELECT {fn concat('t1-', t1.val)}, {fn concat('t2-', t2.val)} " + + "FROM {oj test t1 LEFT OUTER JOIN test t2 ON t1.id = 1}" + )) { + assertTrue(resultSet.next()); + assertEquals("t1-one", resultSet.getString(1)); + assertEquals("t2-one", resultSet.getString(2)); + assertFalse(resultSet.next()); + } + } + + @Test + public void testSystemFunctionEscapeSyntax() throws Exception { + testHelper.executeSql("INSERT INTO test(id, val) VALUES (1, NULL)"); + + try (ResultSet resultSet = stmt.executeQuery("SELECT {fn user()}, {fn database()}")) { + assertTrue(resultSet.next()); + assertEquals("test_admin", resultSet.getString(1)); + assertEquals("universe", resultSet.getString(2)); + assertFalse(resultSet.next()); + } + try (ResultSet resultSet = stmt.executeQuery("SELECT {fn ifnull(val, 'one-one')} FROM test WHERE id = 1")) { + assertTrue(resultSet.next()); + assertEquals("one-one", resultSet.getString(1)); + assertFalse(resultSet.next()); + } + } + + @Test + public void testNumericFunctionEscapeSyntax() throws Exception { + testHelper.executeSql("INSERT INTO test(id, val) VALUES (1, NULL)"); + + try (ResultSet resultSet = stmt.executeQuery("SELECT {fn abs(5 - 10)}, {fn round({fn pi()}, 0)}")) { + assertTrue(resultSet.next()); + assertEquals(5, resultSet.getInt(1)); + assertEquals(3, resultSet.getInt(2)); + assertFalse(resultSet.next()); + } + try (ResultSet resultSet = stmt.executeQuery("SELECT {fn rand(123)}")) { + assertTrue(resultSet.next()); + assertTrue(resultSet.getDouble(1) >= 0.0); + assertTrue(resultSet.getDouble(1) < 1.0); + assertFalse(resultSet.next()); + } + } + + @Test + public void testStringFunctionEscapeSyntax() throws Exception { + testHelper.executeSql("INSERT INTO test(id, val) VALUES (1, 'one'), (2, 'TWO'), (3, 'three'), (4, ' four ')"); + + try (ResultSet resultSet = stmt.executeQuery( + "SELECT {fn /* space */ char(32)}, " + + "{fn ucase(val)}, " + + "{fn right(val, 1)}, " + + "{fn concat('3 ', val)} " + + "FROM test WHERE id = 3" + )) { + assertTrue(resultSet.next()); + assertEquals(" ", resultSet.getString(1)); + assertEquals("THREE", resultSet.getString(2)); + assertEquals("e", resultSet.getString(3)); + assertEquals("3 three", resultSet.getString(4)); + assertFalse(resultSet.next()); + } + try (ResultSet resultSet = stmt.executeQuery( + "SELECT {fn lcase(val)}, " + + "{fn left(val, 2)}, " + + "{fn replace({fn lcase(val)}, 'two', '2')}, " + + "{fn substring(val, 1, 2)} " + + "FROM test WHERE id = 2" + )) { + assertTrue(resultSet.next()); + assertEquals("two", resultSet.getString(1)); + assertEquals("TW", resultSet.getString(2)); + assertEquals("2", resultSet.getString(3)); + assertEquals("TW", resultSet.getString(4)); + assertFalse(resultSet.next()); + } + try (ResultSet resultSet = stmt.executeQuery( + "SELECT {fn ltrim(val)}, " + + "{fn rtrim(val)} " + + "FROM test WHERE id = 4" + )) { + assertTrue(resultSet.next()); + assertEquals("four ", resultSet.getString(1)); + assertEquals(" four", resultSet.getString(2)); + assertFalse(resultSet.next()); + } + } + + /** + * Test length and soundex functions + * which became available since 2.2.0 + */ + @Test + void testStringFunctionFrom22() throws SQLException { + assumeMinimalServerVersion(testHelper.getInstanceVersion(), ServerVersion.V_2_2); + + testHelper.executeSql("INSERT INTO test(id, val) VALUES (1, ' one ')"); + + try (ResultSet resultSet = stmt.executeQuery( + "SELECT {fn length(val)}, {fn soundex(val)} FROM test" + )) { + assertTrue(resultSet.next()); + assertEquals(4, resultSet.getInt(1)); + assertEquals("O500", resultSet.getString(2)); + assertFalse(resultSet.next()); + } + } + private List consoleSelect(Object key) { List list = testHelper.evaluate(TestUtils.toLuaSelect("TEST", key)); return list == null ? Collections.emptyList() : (List) list.get(0);