Skip to content

Commit 1b0ad7e

Browse files
authored
Merge pull request #3018 from ClickHouse/polyglot/jdbcv2-values-list-position-coordinates
Fix jdbc-v2: discard values list positions that do not address the original SQL
2 parents 11cc779 + 9996113 commit 1b0ad7e

4 files changed

Lines changed: 198 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,16 @@
153153

154154
### Bug Fixes
155155

156+
- **[jdbc-v2]** Fixed `Connection#prepareStatement` and `PreparedStatement#addBatch` throwing
157+
`StringIndexOutOfBoundsException` for an `INSERT ... VALUES (...)` statement containing a JDBC escape sequence
158+
(`{d '...'}`, `{ts '...'}`, ...) or a ClickHouse query parameter whose name starts with `d`/`t` (e.g. `{d:Int32}`).
159+
The default `JAVACC` parser records the values list positions as offsets into the SQL it rebuilds from the token
160+
stream, where such sequences are rewritten or dropped, while the driver slices the original SQL with them — so the
161+
slice was taken at the wrong offsets or past the end of the statement. The positions are now checked against the
162+
original SQL and discarded when they do not address its values list, in which case the driver falls back to its
163+
generic parameter substitution path. Such a statement is now prepared without error; the escape sequence itself is
164+
still sent to the server unchanged. The `ANTLR4` parser backends were not affected.
165+
(https://github.com/ClickHouse/clickhouse-java/issues/3017)
156166
- **[jdbc-v2]** Fixed a `?` inside a `//` line comment or inside a heredoc (dollar quoted string, e.g. `$$...$$` or
157167
`$tag$...$tag$`) being counted as a `PreparedStatement` parameter. Such a statement expected a value the application
158168
could not supply, so `executeQuery()` failed with `Parameter at position 'N' is not set` for a query the server

‎jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/SqlParserFacade.java‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,89 @@ public ParsedPreparedStatement parsePreparedStatement(String sql) {
124124

125125
stmt.setUseFunction(parsedStmt.isFuncUsed());
126126
parseParameters(sql, stmt);
127+
discardValuesListPositionsNotMatchingOriginalSql(sql, stmt);
127128
return stmt;
128129
}
129130

131+
/**
132+
* The token manager records keyword positions as offsets into the SQL it rebuilds from the token stream, which
133+
* is not always identical to the SQL it was given: semicolons are dropped and JDBC escape sequences are
134+
* rewritten. Consumers of the values list positions slice the original SQL, so when the two have drifted apart
135+
* the positions address the wrong characters or point past the end of the string. Discard them in that case to
136+
* let the generic parameter substitution path handle the statement.
137+
*/
138+
private void discardValuesListPositionsNotMatchingOriginalSql(String sql, ParsedPreparedStatement stmt) {
139+
int startPosition = stmt.getAssignValuesListStartPosition();
140+
int stopPosition = stmt.getAssignValuesListStopPosition();
141+
if (startPosition < 0 || stopPosition < 0) {
142+
return;
143+
}
144+
145+
boolean matches = stopPosition > startPosition && stopPosition < sql.length()
146+
&& sql.charAt(startPosition) == '(' && closesParenthesizedGroup(sql, startPosition, stopPosition);
147+
if (matches) {
148+
int[] paramPositions = stmt.getParamPositions();
149+
for (int i = 0; i < stmt.getArgCount(); i++) {
150+
if (paramPositions[i] < startPosition || paramPositions[i] > stopPosition) {
151+
matches = false;
152+
break;
153+
}
154+
}
155+
}
156+
157+
if (!matches) {
158+
LOG.debug("Values list positions [{}, {}] do not match the original SQL", startPosition, stopPosition);
159+
stmt.setAssignValuesListStartPosition(-1);
160+
stmt.setAssignValuesListStopPosition(-1);
161+
}
162+
}
163+
164+
/**
165+
* Tells whether the parenthesis opened at {@code startPosition} is closed exactly at {@code stopPosition},
166+
* ignoring parentheses inside quoted text and inside comments. The comment forms recognized here are the ones
167+
* the token manager treats as comments as well: {@code --}, {@code //}, {@code #} (thus also {@code #!}) up to
168+
* the end of the line, and nestable {@code /* ... *}{@code /} blocks.
169+
*/
170+
private boolean closesParenthesizedGroup(String sql, int startPosition, int stopPosition) {
171+
int len = sql.length();
172+
int depth = 0;
173+
int i = startPosition;
174+
try {
175+
while (i <= stopPosition) {
176+
char ch = sql.charAt(i);
177+
int afterSkipped; // index right after quoted text or a comment, -1 when neither starts here
178+
if (ClickHouseUtils.isQuote(ch)) {
179+
afterSkipped = ClickHouseUtils.skipQuotedString(sql, i, len, ch);
180+
} else if (ch == '#' || (i + 1 < len && sql.charAt(i + 1) == ch && (ch == '-' || ch == '/'))) {
181+
// search from the last character of the comment opener: it is never a line separator, and
182+
// skipSingleLineComment() only reports one found strictly after the index it is given
183+
afterSkipped = ClickHouseUtils.skipSingleLineComment(sql, ch == '#' ? i : i + 1, len);
184+
} else if (ch == '/' && i + 1 < len && sql.charAt(i + 1) == '*') {
185+
afterSkipped = ClickHouseUtils.skipMultiLineComment(sql, i + 2, len);
186+
} else {
187+
afterSkipped = -1;
188+
}
189+
190+
if (afterSkipped < 0) {
191+
if (ch == '(') {
192+
depth++;
193+
} else if (ch == ')' && --depth == 0) {
194+
return i == stopPosition;
195+
}
196+
i++;
197+
} else {
198+
if (afterSkipped - 1 > stopPosition) { // quoted text or comment reaching past the values list
199+
return false;
200+
}
201+
i = afterSkipped;
202+
}
203+
}
204+
} catch (IllegalArgumentException e) { // unterminated quoted text or comment
205+
return false;
206+
}
207+
return false;
208+
}
209+
130210
private List<String> processRoles(Map<String, String> settings) {
131211
String rolesCount = settings.get("_ROLES_COUNT");
132212
if (rolesCount != null) {

‎jdbc-v2/src/test/java/com/clickhouse/jdbc/PreparedStatementTest.java‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -879,6 +879,61 @@ void testMetabaseBug01() throws Exception {
879879
}
880880
}
881881

882+
@Test(groups = { "integration" }, dataProvider = "insertWithRewrittenValuesListDP")
883+
void testInsertWithRewrittenValuesList(String valuesList) throws Exception {
884+
final String table = "test_insert_rewritten_values_list";
885+
try (Connection conn = getJdbcConnection()) {
886+
try (Statement stmt = conn.createStatement()) {
887+
stmt.execute("DROP TABLE IF EXISTS " + table);
888+
stmt.execute("CREATE TABLE " + table + " (s String, n Int32) Engine MergeTree ORDER BY ()");
889+
}
890+
try (PreparedStatement stmt = conn.prepareStatement(
891+
"INSERT INTO " + table + " (s, n) VALUES " + valuesList)) {
892+
assertEquals(stmt.getParameterMetaData().getParameterCount(), 1);
893+
stmt.setInt(1, 42);
894+
stmt.addBatch();
895+
}
896+
}
897+
}
898+
899+
@DataProvider(name = "insertWithRewrittenValuesListDP")
900+
public static Object[][] insertWithRewrittenValuesListDP() {
901+
return new Object[][] {
902+
{ "(toDateTime({ts '2024-01-01 00:00:00'}), ?)" },
903+
{ "(toTime({t '10:20:30'}), ?)" },
904+
{ "(toInt32({d:Int32}), ?)" },
905+
};
906+
}
907+
908+
@Test(groups = { "integration" })
909+
void testBatchInsertWithRewrittenValuesList() throws Exception {
910+
final String table = "test_batch_insert_rewritten_values_list";
911+
try (Connection conn = getJdbcConnection(Map.of(ASYNC_INSERT_SETTING_KEY, ServerSettings.OFF))) {
912+
try (Statement stmt = conn.createStatement()) {
913+
stmt.execute("DROP TABLE IF EXISTS " + table);
914+
stmt.execute("CREATE TABLE " + table + " (s DateTime, n Int32) Engine MergeTree ORDER BY ()");
915+
}
916+
try (PreparedStatement stmt = conn.prepareStatement("INSERT INTO " + table
917+
+ " (s, n) VALUES (toDateTime({ts '2024-01-01 00:00:00'}), ?)")) {
918+
stmt.setInt(1, 42);
919+
stmt.addBatch();
920+
stmt.setInt(1, 43);
921+
stmt.addBatch();
922+
assertEquals(stmt.executeBatch().length, 2);
923+
stmt.clearBatch();
924+
}
925+
926+
try (Statement stmt = conn.createStatement();
927+
ResultSet rs = stmt.executeQuery("SELECT n FROM " + table + " ORDER BY n")) {
928+
assertTrue(rs.next());
929+
assertEquals(rs.getInt(1), 42);
930+
assertTrue(rs.next());
931+
assertEquals(rs.getInt(1), 43);
932+
assertFalse(rs.next());
933+
}
934+
}
935+
}
936+
882937
@Test(groups = { "integration" })
883938
void testInsertWithHeredocValue() throws Exception {
884939
final String table = "test_insert_heredoc";

‎jdbc-v2/src/test/java/com/clickhouse/jdbc/internal/BaseSqlParserFacadeTest.java‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,34 @@ public static Object[][] testPreparedStatementInsertSQLDP() {
157157
};
158158
}
159159

160+
@Test(dataProvider = "testValuesListPositionsDP")
161+
public void testValuesListPositions(String sql, boolean positionsExpected) {
162+
ParsedPreparedStatement parsed = parser.parsePreparedStatement(sql);
163+
assertTrue(parsed.isInsert(), "Should be of insert type");
164+
165+
int start = parsed.getAssignValuesListStartPosition();
166+
int stop = parsed.getAssignValuesListStopPosition();
167+
if (parsed.getAssignValuesGroups() == 1 && start > -1 && stop > -1) {
168+
assertTrue(stop > start, "Values list should stop after it starts, but got [" + start + ", " + stop + "]");
169+
assertTrue(stop < sql.length(), "Values list should stop within the statement, but got " + stop
170+
+ " for a statement of " + sql.length() + " characters");
171+
assertEquals(sql.charAt(start), '(', "Values list should start with an opening parenthesis");
172+
assertEquals(sql.charAt(stop), ')', "Values list should end with a closing parenthesis");
173+
174+
int[] paramPositions = parsed.getParamPositions();
175+
for (int i = 0; i < parsed.getArgCount(); i++) {
176+
assertTrue(paramPositions[i] > start && paramPositions[i] < stop, "Parameter " + (i + 1)
177+
+ " at position " + paramPositions[i] + " should be inside the values list '"
178+
+ sql.substring(start, stop + 1) + "'");
179+
}
180+
}
181+
182+
if (javaCcBackend) {
183+
assertEquals(start > -1 && stop > -1, positionsExpected,
184+
"Values list positions should " + (positionsExpected ? "" : "not ") + "be reported");
185+
}
186+
}
187+
160188
@Test(dataProvider = "testInsertWithUnsupportedValuesListDP")
161189
public void testInsertWithUnsupportedValuesList(String sql) {
162190
ParsedPreparedStatement parsed = parser.parsePreparedStatement(sql);
@@ -241,6 +269,31 @@ public void testValuesListOfUnsupportedSyntax(String sql, boolean parseable, int
241269
}
242270
}
243271

272+
@DataProvider
273+
public static Object[][] testValuesListPositionsDP() {
274+
return new Object[][] {
275+
{ "INSERT INTO t (a, b) VALUES (1, ?)", true },
276+
{ "INSERT INTO t (a, b) VALUES (1, ?);", true },
277+
{ "INSERT INTO t (a, b) VALUES ('a)b', ?)", true },
278+
{ "INSERT INTO t (a, b) VALUES (1 /* ) */, ?)", true },
279+
{ "INSERT INTO t (a, b) VALUES (1 -- )\n, ?)", true },
280+
{ "INSERT INTO t (a, b) VALUES (1 // )\n, ?)", true },
281+
{ "INSERT INTO t (a, b) VALUES (1 # )\n, ?)", true },
282+
{ "INSERT INTO t (a, b) VALUES (1 #! )\n, ?)", true },
283+
{ "INSERT INTO t (a, b) VALUES (1 --\n, ?)", true },
284+
{ "INSERT INTO t (a, b) VALUES (1 /* ( */, ?)", true },
285+
{ "INSERT INTO t (a, b) VALUES (1 /* ? */, ?)", true },
286+
{ "INSERT INTO t (a, b) VALUES (1, ? /* ) */)", true },
287+
{ "INSERT INTO t (a, b) VALUES (toDate({d '2024-01-01'}), ?)", true },
288+
{ "INSERT INTO t (a, b) VALUES (toDateTime({ts '2024-01-01 00:00:00'}) /* ) */, ?)", false },
289+
{ "INSERT INTO t (a, b) VALUES (toDateTime({ts '2024-01-01 00:00:00'}), ?)", false },
290+
{ "INSERT INTO t (a, b) VALUES (toTime({t '10:20:30'}), ?)", false },
291+
{ "INSERT INTO t (a, b) VALUES (toInt32({d:Int32}), ?)", false },
292+
{ "INSERT INTO t (a, b) VALUES (?, toDate({d '2024-01-01'}))", false },
293+
{ "INSERT INTO t (a, b) VALUES (?, toString({tt 'temp'}))", false },
294+
};
295+
}
296+
244297
@DataProvider
245298
public static Object[][] testValuesListOfUnsupportedSyntaxDP() {
246299
return new Object[][] {

0 commit comments

Comments
 (0)