Skip to content
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@

### Bug Fixes

- **[jdbc-v2]** Fixed a `?` inside a `//` line comment or inside a heredoc (dollar quoted string, e.g. `$$...$$` or
`$tag$...$tag$`) being counted as a `PreparedStatement` parameter. Such a statement expected a value the application
could not supply, so `executeQuery()` failed with `Parameter at position 'N' is not set` for a query the server
executes fine. The placeholder scan now skips both token kinds, like the server lexer does; a `$` that does not open a
heredoc is still treated as an ordinary character (it is a valid identifier character).
(https://github.com/ClickHouse/clickhouse-java/issues/3009)
- **[jdbc-v2]** Fixed `DatabaseMetaData#getTables` reporting `TABLE_TYPE = TABLE` for a table with the `BigQuery`
engine (present in `system.table_engines` since ClickHouse `26.8`). The engine was missing from the
engine-to-table-type mapping, so it fell back to the default `TABLE`, and `getTables(..., types = {"REMOTE TABLE"})`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -529,15 +529,73 @@
continue;
} else if (i + 1 < len) {
char nextCh = originalQuery.charAt(i + 1);
if ((ch == '-' && nextCh == ch) || (ch == '#')) {
i = ClickHouseUtils.skipSingleLineComment(originalQuery, i + 2, len) - 1;
if ((ch == '-' && nextCh == ch) || (ch == '/' && nextCh == ch) || (ch == '#')) {
Comment thread
polyglotAI-bot marked this conversation as resolved.
i = skipLineComment(originalQuery, i + 1, len) - 1;
} else if (ch == '/' && nextCh == '*') {
i = ClickHouseUtils.skipMultiLineComment(originalQuery, i + 2, len) - 1;
} else if (ch == '$') {
i = skipHeredoc(originalQuery, i, len) - 1;

Check warning on line 537 in jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/SqlParserFacade.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor the code in order to not assign to this loop counter from within the loop body.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ_Jrq8fJ8uLzjRF-h7D&open=AZ_Jrq8fJ8uLzjRF-h7D&pullRequest=3010
}
}
}
}

/**
* Skips a line comment ({@code --}, {@code //}, {@code #} or {@code #!}) up to and including the
* terminating newline. An empty comment is terminated by the newline that directly follows the comment
* marker, so scanning must continue on the next line instead of stopping at the end of the query.
*
* @param query non-null string to scan
* @param startIndex index of the second character of the comment marker, which is never a newline for
* {@code --} and {@code //}, and is the first comment character for {@code #}
* @param len end index, usually length of the given string
* @return index of the start of the next line, or {@code len} when the comment is not terminated
*/
private static int skipLineComment(String query, int startIndex, int len) {
int index = query.indexOf('\n', startIndex);
return index < 0 || index >= len ? len : index + 1;
}

/**
* Skips a heredoc (dollar quoted string) like {@code $$...$$} or {@code $tag$...$tag$}, where the tag
* may only contain word characters. When there is no heredoc at {@code startIndex} the dollar sign is
* treated as an ordinary character, because it is also a valid identifier character: a dollar sign that
* follows a word character continues an identifier (e.g. {@code a$b} or {@code a$x$}) instead of opening
* a heredoc, and a dollar sign without a matching closing tag does not open one either.
*
* @param query non-null string to scan
* @param startIndex index of the dollar sign that may open a heredoc
* @param len end index, usually length of the given string
* @return index next to the closing tag, or {@code startIndex + 1} when there is no heredoc
*/
private static int skipHeredoc(String query, int startIndex, int len) {
if (startIndex > 0 && isWordChar(query.charAt(startIndex - 1))) {
return startIndex + 1;
}

int tagEndIndex = query.indexOf('$', startIndex + 1);
if (tagEndIndex < 0 || tagEndIndex >= len) {
return startIndex + 1;
}

for (int i = startIndex + 1; i < tagEndIndex; i++) {
if (!isWordChar(query.charAt(i))) {
return startIndex + 1;
}
}

String tag = query.substring(startIndex, tagEndIndex + 1);
int closingTagIndex = query.indexOf(tag, tagEndIndex + 1);
if (closingTagIndex < 0 || closingTagIndex + tag.length() > len) {
return startIndex + 1;
}
return closingTagIndex + tag.length();
}

private static boolean isWordChar(char ch) {
return ch == '_' || (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z');
}


public enum SQLParser {
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,33 @@ void testStatementSplit() throws Exception {
}
}

@Test(groups = { "integration" }, dataProvider = "commentsAndHeredocsDP")
void testPlaceholdersWithCommentsAndHeredocs(String sql, String expected) throws Exception {
try (Connection conn = getJdbcConnection()) {
try (PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, "42");
try (ResultSet rs = stmt.executeQuery()) {
assertTrue(rs.next());
assertEquals(rs.getString(1), expected);
assertFalse(rs.next());
}
}
}
}

@DataProvider(name = "commentsAndHeredocsDP")
public static Object[][] commentsAndHeredocsDP() {
return new Object[][] {
{"SELECT ? AS v // ?", "42"},
{"SELECT ? AS v // ?\nUNION ALL SELECT NULL WHERE 0", "42"},
{"SELECT //\n? AS v", "42"},
{"SELECT --\n? AS v", "42"},
{"SELECT concat($$?$$, ?) AS v", "?42"},
{"SELECT concat($tag$ ? $tag$, ?) AS v", " ? 42"},
{"SELECT ? AS a$x$, 1 AS b$x$", "42"},
};
}

@Test(groups = {"integration"})
void testClearParameters() throws Exception {
final String sql = "insert into `test_issue_2299` (`id`, `name`, `age`) values (?, ?, ?)";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,71 @@ public static Object[][] testCTEStmtsDP() {
};
}

@Test(dataProvider = "testCommentsAndHeredocsDP")
public void testCommentsAndHeredocs(String sql, int args) {
// The ANTLR4_PARAMS_PARSER backend collects placeholders from the grammar, whose lexer has no
// token for '//' comments and heredocs, so it is not covered by this scan. The other backends
// must agree with the server on which '?' is a placeholder.
if (grammarParamsBackend) {
return;
}
ParsedPreparedStatement stmt = parser.parsePreparedStatement(sql);
Assert.assertEquals(stmt.getArgCount(), args, "Args mismatch for: " + sql);
}

@DataProvider
public static Object[][] testCommentsAndHeredocsDP() {
return new Object[][] {
// '//' line comments
{"SELECT 1 // ?", 0},
{"SELECT 1 //", 0},
{"SELECT ? // ?\n, ?", 2},
{"SELECT 1 // ? -- ? /* ? */ $$?$$\n, ?", 1},
// an empty line comment ends at its own newline, so later placeholders are still counted
{"SELECT ? //\n, ?", 2},
{"SELECT ? //\n// ?\n, ?", 2},
{"SELECT ? //\n?", 2},
{"SELECT ? --\n, ?", 2},
{"SELECT ? -- ?\n--\n, ?", 2},
{"SELECT ? #\n, ?", 2},
{"SELECT ? #!\n, ?", 2},
{"SELECT ? //\n--\n#\n, ?", 2},
{"//\nSELECT ?", 1},
// a comment that is never terminated still ends the scan
{"SELECT ? //\n", 1},
{"SELECT ? --", 1},
// a comment marker inside a string, a heredoc or a block comment does not start a comment
{"SELECT '--\n' AS v, ?", 1},
{"SELECT $$//\n$$ AS v, ?", 1},
{"SELECT ? /* --\n */, ?", 2},
// heredocs (dollar quoted strings)
{"SELECT $$?$$ AS v", 0},
{"SELECT $tag$ ? $tag$ AS v", 0},
{"SELECT $1$ ? $1$ AS v", 0},
{"SELECT $$$$ AS v, ?", 1},
{"SELECT $$a$b$$ AS v, ?", 1},
{"SELECT $t$ ?\n -- ?\n // ?\n /* ? */ $t$ AS v, ?", 1},
{"SELECT $$?$$, ?, $$?$$", 1},
{"SELECT $$it's ?$$ AS v, ?", 1},
{"SELECT $$ /* ? $$ AS v, ?", 1},
// '//' and heredoc markers that are not comments or heredocs
{"SELECT '// ?' AS v, ?", 1},
{"SELECT '$$?$$' AS v, ?", 1},
{"SELECT -- '// ?'\n?", 1},
{"SELECT /* $$?$$ */ ?", 1},
{"SELECT 4 / 2 AS v, ?", 1},
{"SELECT ? AS a$b, ? AS c$d, 3", 2},
{"SELECT ? AS a$x$, ? AS b$x$", 2},
{"SELECT 1 AS a$x$, ?", 1},
{"SELECT $$ ? AS v, ?", 2},
// already supported comment styles keep working
{"SELECT 1 -- ?", 0},
{"SELECT 1 # ?", 0},
{"SELECT 1 #! ?", 0},
{"SELECT /* ? /* ? */ ? */ ?", 1},
};
}

@Test(dataProvider = "testDoubleSlashLineCommentDp")
public void testDoubleSlashLineComments(String sql, int args, boolean insert, boolean hasResultSet) {
ParsedPreparedStatement prepared = parser.parsePreparedStatement(sql);
Expand Down
Loading