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:
+ *
+ * - Scalar functions (i.e. {@code {fn random()}}).
+ * - Outer joins (i.e. {@code {oj "dept" left outer join "salary" on "dept_id" = 1412}}).
+ * - Like escape character (i.e. {@code like '_|%_3%' {escape '|'}}).
+ * - Limiting returned rows (i.e. {@code {limit 10 offset 20}}).
+ *
+ *
+ *
+ * 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 escape |
+ * Native |
+ * Comment |
+ *
+ *
+ * ABS(number) |
+ * ABS(number) |
+ * |
+ *
+ *
+ * PI() |
+ * 3.141592653589793 |
+ * Driver 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 escape |
+ * Native |
+ * Comment |
+ *
+ *
+ * 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 escape |
+ * Native |
+ * Comment |
+ *
+ *
+ * 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);