Skip to content

Commit d29b3d1

Browse files
committed
Support JDBC escape syntax
Add a driver SQL pre-processing before sending it to the server. The driver supports sub-set of scalar functions defined by the spec (appendix C), outer joins, escape clause for SQL LIKE operator, and limit/offset clause. The processed result can be received using Connection.nativeSQL() method. Closes: #79, #76, #80, #81, #83, #84 Affects: #108
1 parent b53e0ba commit d29b3d1

13 files changed

+1271
-14
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
package org.tarantool.jdbc;
2+
3+
import static org.tarantool.jdbc.EscapedFunctions.Expression;
4+
import static org.tarantool.jdbc.EscapedFunctions.FunctionExpression;
5+
import static org.tarantool.jdbc.EscapedFunctions.FunctionSignatureKey;
6+
import static org.tarantool.jdbc.EscapedFunctions.functionMappings;
7+
8+
import org.tarantool.util.SQLStates;
9+
import org.tarantool.util.ThrowingBiFunction;
10+
11+
import java.sql.Connection;
12+
import java.sql.SQLSyntaxErrorException;
13+
import java.util.ArrayList;
14+
import java.util.LinkedList;
15+
import java.util.List;
16+
import java.util.regex.Pattern;
17+
18+
/**
19+
* Set of utils to work with JDBC escape processing.
20+
* <p>
21+
* Supported escape syntax:
22+
* <ol>
23+
* <li>Scalar functions (i.e. {@code {fn random()}}).</li>
24+
* <li>Outer joins (i.e. {@code {oj "dept" left outer join "salary" on "dept_id" = 1412}}).</li>
25+
* <li>Like escape character (i.e. {@code like '_|%_3%' {escape '|'}}).</li>
26+
* <li>Limiting returned rows (i.e. {@code {limit 10 offset 20}}).</li>
27+
* </ol>
28+
* <p>
29+
* Most of the supported expressions translates directly omitting escape borders.
30+
* In this way, {@code {fn abs(-5)}} becomes {@code abs(-5)}} or {@code {limit 10 offset 50}}
31+
* becomes {@code limit 10 offset 50} and so on. There are exceptions in case of scalar
32+
* functions where JDBC functions may not match exactly with Tarantool ones (for example,
33+
* JDBC {@code {fn rand()}} function becomes {@code random()} supported by Tarantool.
34+
*/
35+
public class EscapeSyntaxParser {
36+
37+
private static final Pattern IDENTIFIER = Pattern.compile("[_a-zA-Z][_a-zA-Z0-9]+");
38+
39+
private final SQLConnection jdbcContext;
40+
41+
public EscapeSyntaxParser(SQLConnection jdbcContext) {
42+
this.jdbcContext = jdbcContext;
43+
}
44+
45+
/**
46+
* Performs escape processing for SQL queries. It translates
47+
* sql text with optional escape expressions such as {@code {fn abs(-1)}}.
48+
*
49+
* <p>
50+
* Comments inside SQL text can be eliminated as parsing goes using preserveComments
51+
* flag. Hence, Comments inside escape syntax are always omitted regardless of
52+
* the flag, though.
53+
*
54+
* @param sql SQL text to be processed
55+
* @param preserveComments flag indicating should comments be kept
56+
*
57+
* @return native SQL query
58+
*
59+
* @throws SQLSyntaxErrorException if any syntax error happened
60+
*/
61+
public String translate(String sql, boolean preserveComments) throws SQLSyntaxErrorException {
62+
StringBuilder nativeSql = new StringBuilder(sql.length());
63+
StringBuilder escapeBuffer = new StringBuilder();
64+
StringBuilder activeBuffer = nativeSql;
65+
LinkedList<Integer> escapeStartPositions = new LinkedList<>();
66+
67+
int i = 0;
68+
while (i < sql.length()) {
69+
char currentChar = sql.charAt(i);
70+
switch (currentChar) {
71+
case '\'':
72+
case '"':
73+
int endOfString = seekEndOfRegion(sql, i, "" + currentChar, "" + currentChar);
74+
if (endOfString == -1) {
75+
throw new SQLSyntaxErrorException(
76+
"Not enclosed string literal or quoted identifier at position " + i,
77+
SQLStates.SYNTAX_ERROR.getSqlState()
78+
);
79+
}
80+
activeBuffer.append(sql, i, endOfString + 1);
81+
i = endOfString + 1;
82+
break;
83+
84+
case '/':
85+
case '-':
86+
int endOfComment;
87+
if (currentChar == '/') {
88+
endOfComment = seekEndOfRegion(sql, i, "/*", "*/");
89+
if (endOfComment == -1) {
90+
throw new SQLSyntaxErrorException(
91+
"Open block comment at position " + i, SQLStates.SYNTAX_ERROR.getSqlState()
92+
);
93+
}
94+
} else {
95+
endOfComment = seekEndOfRegion(sql, i, "--", "\n");
96+
if (endOfComment == -1) {
97+
endOfComment = sql.length() - 1;
98+
}
99+
}
100+
if (i == endOfComment) {
101+
activeBuffer.append(currentChar);
102+
i++;
103+
} else {
104+
if (activeBuffer == nativeSql && preserveComments) {
105+
nativeSql.append(sql, i, endOfComment + 1);
106+
}
107+
i = endOfComment + 1;
108+
}
109+
break;
110+
111+
case '{':
112+
escapeStartPositions.addFirst(escapeBuffer.length());
113+
escapeBuffer.append(currentChar);
114+
activeBuffer = escapeBuffer;
115+
i++;
116+
break;
117+
118+
case '}':
119+
Integer startPosition = escapeStartPositions.pollFirst();
120+
if (startPosition == null) {
121+
throw new SQLSyntaxErrorException(
122+
"Unexpected '}' at position " + i,
123+
SQLStates.SYNTAX_ERROR.getSqlState()
124+
);
125+
}
126+
escapeBuffer.append(currentChar);
127+
processEscapeExpression(escapeBuffer, startPosition, escapeBuffer.length());
128+
if (escapeStartPositions.isEmpty()) {
129+
nativeSql.append(escapeBuffer);
130+
escapeBuffer.setLength(0);
131+
activeBuffer = nativeSql;
132+
}
133+
i++;
134+
break;
135+
136+
default:
137+
activeBuffer.append(currentChar);
138+
i++;
139+
break;
140+
}
141+
}
142+
143+
if (!escapeStartPositions.isEmpty()) {
144+
throw new SQLSyntaxErrorException(
145+
"Not enclosed escape expression at position " + escapeStartPositions.pollFirst(),
146+
SQLStates.SYNTAX_ERROR.getSqlState()
147+
);
148+
}
149+
return nativeSql.toString();
150+
}
151+
152+
/**
153+
* Parses text like {@code functionName([arg[,args...]])}.
154+
* Arguments are not parsed recursively and saved as are.
155+
*
156+
* @param functionString text to be parsed
157+
*
158+
* @return parsing result containing function name and its parameters, if any
159+
*
160+
* @throws SQLSyntaxErrorException if any syntax errors happened
161+
*/
162+
private FunctionExpression parseFunction(String functionString) throws SQLSyntaxErrorException {
163+
int braceNestLevel = 0;
164+
String functionName = null;
165+
List<String> functionParameters = new ArrayList<>();
166+
int parameterStartPosition = 0;
167+
168+
int i = 0;
169+
while (i < functionString.length()) {
170+
char currentChar = functionString.charAt(i);
171+
switch (currentChar) {
172+
case '\'':
173+
case '"':
174+
i = seekEndOfRegion(functionString, i, "" + currentChar, "" + currentChar) + 1;
175+
break;
176+
177+
case '/':
178+
case '-':
179+
int endOfComment = (currentChar == '/')
180+
? seekEndOfRegion(functionString, i, "/*", "*/")
181+
: seekEndOfRegion(functionString, i, "--", "\n");
182+
i = endOfComment == -1 ? functionString.length() : endOfComment + 1;
183+
break;
184+
185+
case '(':
186+
if (braceNestLevel++ == 0) {
187+
// it's possible only one function opening brace
188+
if (functionName != null) {
189+
throw new SQLSyntaxErrorException(
190+
"Malformed function expression " + functionString, SQLStates.SYNTAX_ERROR.getSqlState()
191+
);
192+
}
193+
functionName = functionString.substring(0, i).trim().toUpperCase();
194+
if (!IDENTIFIER.matcher(functionName).matches()) {
195+
throw new SQLSyntaxErrorException(
196+
"Invalid function identifier '" + functionName + "'", SQLStates.SYNTAX_ERROR.getSqlState()
197+
);
198+
}
199+
parameterStartPosition = i + 1;
200+
}
201+
i++;
202+
break;
203+
204+
case ')':
205+
if (--braceNestLevel == 0) {
206+
// reach a function closing brace
207+
// parse the last possible function parameter
208+
String param = functionString.substring(parameterStartPosition, i).trim();
209+
if (!functionParameters.isEmpty() || !param.isEmpty()) {
210+
functionParameters.add(param);
211+
}
212+
}
213+
i++;
214+
break;
215+
216+
case ',':
217+
if (braceNestLevel == 1) {
218+
// reach the function argument delimiter
219+
// parse the argument before this comma
220+
String param = functionString.substring(parameterStartPosition, i).trim();
221+
parameterStartPosition = i + 1;
222+
functionParameters.add(param);
223+
}
224+
i++;
225+
break;
226+
227+
default:
228+
i++;
229+
break;
230+
}
231+
}
232+
233+
if (functionName == null || braceNestLevel != 0) {
234+
throw new SQLSyntaxErrorException(
235+
"Malformed function expression '" + functionString + "'", SQLStates.SYNTAX_ERROR.getSqlState()
236+
);
237+
}
238+
return new FunctionExpression(functionName, functionParameters);
239+
}
240+
241+
/**
242+
* Handles an escape expression.
243+
*
244+
* @param buffer buffer containing current escape expression, inclusive
245+
* @param start start position of the escape syntax in the buffer, exclusive
246+
* @param end end position of the escape syntax in the buffer
247+
*
248+
* @throws SQLSyntaxErrorException if any syntax error happened
249+
*/
250+
private void processEscapeExpression(StringBuilder buffer, int start, int end)
251+
throws SQLSyntaxErrorException {
252+
if (buffer.charAt(start) != '{' || buffer.charAt(end - 1) != '}') {
253+
return;
254+
}
255+
int startExpression = seekFirstNonSpaceSymbol(buffer, start + 1);
256+
int endExpression = seekLastNonSpaceSymbol(buffer, end - 2);
257+
258+
if (substringMatches(buffer, "fn ", startExpression)) {
259+
FunctionExpression expression = parseFunction(buffer.substring(startExpression + 3, endExpression));
260+
ThrowingBiFunction<FunctionExpression, Connection, Expression, SQLSyntaxErrorException> mapper =
261+
functionMappings.get(FunctionSignatureKey.of(expression.getName(), expression.getParameters().size()));
262+
if (mapper == null) {
263+
throw new SQLSyntaxErrorException(
264+
"Unknown function " + expression.getName(),
265+
SQLStates.SYNTAX_ERROR.getSqlState()
266+
);
267+
}
268+
buffer.replace(start, end, mapper.apply(expression, jdbcContext).toString());
269+
} else if (substringMatches(buffer, "oj ", startExpression)) {
270+
buffer.replace(start, end, buffer.substring(startExpression + 3, endExpression));
271+
} else if (substringMatches(buffer, "escape ", startExpression)) {
272+
buffer.replace(start, end, buffer.substring(startExpression, endExpression));
273+
} else if (substringMatches(buffer, "limit ", startExpression)) {
274+
buffer.replace(start, end, buffer.substring(startExpression, endExpression));
275+
} else {
276+
throw new SQLSyntaxErrorException("Unrecognizable escape expression", SQLStates.SYNTAX_ERROR.getSqlState());
277+
}
278+
}
279+
280+
/**
281+
* Looks for the end of the region defined by its start and end
282+
* substring patterns.
283+
*
284+
* @param text search text
285+
* @param position start position in text to search the region, inclusive
286+
* @param startRegion pattern of the region start
287+
* @param endRegion pattern of the region end
288+
*
289+
* @return found position of the region end, inclusive. Start position if the region start
290+
* pattern does not match the text start position and {@literal -1} if the
291+
* region end is not found.
292+
*/
293+
private int seekEndOfRegion(String text, int position, String startRegion, String endRegion) {
294+
if (!text.regionMatches(position, startRegion, 0, startRegion.length())) {
295+
return position;
296+
}
297+
int end = text.indexOf(endRegion, position + startRegion.length());
298+
return end == -1 ? end : end + endRegion.length() - 1;
299+
}
300+
301+
private boolean substringMatches(StringBuilder text, String substring, int start) {
302+
return text.indexOf(substring, start) == start;
303+
}
304+
305+
private int seekFirstNonSpaceSymbol(StringBuilder text, int position) {
306+
while (position < text.length() && Character.isWhitespace(text.charAt(position))) {
307+
position++;
308+
}
309+
return position;
310+
}
311+
312+
private int seekLastNonSpaceSymbol(StringBuilder text, int position) {
313+
while (position > 0 && Character.isWhitespace(text.charAt(position))) {
314+
position--;
315+
}
316+
return position + 1;
317+
}
318+
319+
}

0 commit comments

Comments
 (0)