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);