Skip to content

Commit 11cc779

Browse files
authored
Merge pull request #3010 from ClickHouse/polyglot/jdbc-v2-placeholder-scan-comments-heredocs
Fix jdbc-v2: skip // comments and heredocs when scanning for ? placeholders
2 parents 9f40968 + 391a584 commit 11cc779

4 files changed

Lines changed: 185 additions & 2 deletions

File tree

‎CHANGELOG.md‎

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

154154
### Bug Fixes
155155

156+
- **[jdbc-v2]** Fixed a `?` inside a `//` line comment or inside a heredoc (dollar quoted string, e.g. `$$...$$` or
157+
`$tag$...$tag$`) being counted as a `PreparedStatement` parameter. Such a statement expected a value the application
158+
could not supply, so `executeQuery()` failed with `Parameter at position 'N' is not set` for a query the server
159+
executes fine. The placeholder scan now skips both token kinds, like the server lexer does; a `$` that does not open a
160+
heredoc is still treated as an ordinary character (it is a valid identifier character).
161+
(https://github.com/ClickHouse/clickhouse-java/issues/3009)
156162
- **[jdbc-v2]** Fixed `INSERT INTO [TABLE] FUNCTION f(...) VALUES (?)` failing with
157163
`Code: 60 ... does not exist. (UNKNOWN_TABLE)` when the `beta.row_binary_for_simple_insert` feature was
158164
enabled. Neither SQL parser reported a table-function insert target as a function, so the statement was

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

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -534,17 +534,92 @@ private static void parseParameters(String originalQuery, ParsedPreparedStatemen
534534
}
535535
} else if (ch == ';') {
536536
continue;
537+
} else if (isWordChar(ch)) {
538+
i = skipIdentifier(originalQuery, i, len) - 1;
537539
} else if (i + 1 < len) {
538540
char nextCh = originalQuery.charAt(i + 1);
539-
if ((ch == '-' && nextCh == ch) || (ch == '#')) {
540-
i = ClickHouseUtils.skipSingleLineComment(originalQuery, i + 2, len) - 1;
541+
if ((ch == '-' && nextCh == ch) || (ch == '/' && nextCh == ch) || (ch == '#')) {
542+
i = skipLineComment(originalQuery, i + 1, len) - 1;
541543
} else if (ch == '/' && nextCh == '*') {
542544
i = ClickHouseUtils.skipMultiLineComment(originalQuery, i + 2, len) - 1;
545+
} else if (ch == '$') {
546+
i = skipHeredoc(originalQuery, i, len) - 1;
543547
}
544548
}
545549
}
546550
}
547551

552+
/**
553+
* Skips a line comment ({@code --}, {@code //}, {@code #} or {@code #!}) up to and including the
554+
* terminating newline. An empty comment is terminated by the newline that directly follows the comment
555+
* marker, so scanning must continue on the next line instead of stopping at the end of the query.
556+
*
557+
* @param query non-null string to scan
558+
* @param startIndex index of the second character of the comment marker, which is never a newline for
559+
* {@code --} and {@code //}, and is the first comment character for {@code #}
560+
* @param len end index, usually length of the given string
561+
* @return index of the start of the next line, or {@code len} when the comment is not terminated
562+
*/
563+
private static int skipLineComment(String query, int startIndex, int len) {
564+
int index = query.indexOf('\n', startIndex);
565+
return index < 0 || index >= len ? len : index + 1;
566+
}
567+
568+
/**
569+
* Skips an identifier, which the server reads as a run of word characters and dollar signs (e.g.
570+
* {@code a$b}, {@code a$x$} or {@code a$$b$}). A dollar sign inside such a run continues the
571+
* identifier and never opens a heredoc, so the whole run must be consumed before the scan looks
572+
* for a heredoc again.
573+
*
574+
* @param query non-null string to scan
575+
* @param startIndex index of the first character of the identifier
576+
* @param len end index, usually length of the given string
577+
* @return index next to the last character of the identifier
578+
*/
579+
private static int skipIdentifier(String query, int startIndex, int len) {
580+
int index = startIndex + 1;
581+
while (index < len && (isWordChar(query.charAt(index)) || query.charAt(index) == '$')) {
582+
index++;
583+
}
584+
return index;
585+
}
586+
587+
/**
588+
* Skips a heredoc (dollar quoted string) like {@code $$...$$} or {@code $tag$...$tag$}, where the tag
589+
* may only contain word characters. When there is no heredoc at {@code startIndex} the dollar sign is
590+
* treated as an ordinary character: a dollar sign without a matching closing tag does not open a
591+
* heredoc. A dollar sign that belongs to an identifier never reaches this method, because
592+
* {@link #skipIdentifier(String, int, int)} consumes the identifier first.
593+
*
594+
* @param query non-null string to scan
595+
* @param startIndex index of the dollar sign that may open a heredoc
596+
* @param len end index, usually length of the given string
597+
* @return index next to the closing tag, or {@code startIndex + 1} when there is no heredoc
598+
*/
599+
private static int skipHeredoc(String query, int startIndex, int len) {
600+
int tagEndIndex = query.indexOf('$', startIndex + 1);
601+
if (tagEndIndex < 0 || tagEndIndex >= len) {
602+
return startIndex + 1;
603+
}
604+
605+
for (int i = startIndex + 1; i < tagEndIndex; i++) {
606+
if (!isWordChar(query.charAt(i))) {
607+
return startIndex + 1;
608+
}
609+
}
610+
611+
String tag = query.substring(startIndex, tagEndIndex + 1);
612+
int closingTagIndex = query.indexOf(tag, tagEndIndex + 1);
613+
if (closingTagIndex < 0 || closingTagIndex + tag.length() > len) {
614+
return startIndex + 1;
615+
}
616+
return closingTagIndex + tag.length();
617+
}
618+
619+
private static boolean isWordChar(char ch) {
620+
return ch == '_' || (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z');
621+
}
622+
548623

549624
public enum SQLParser {
550625
/**

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -959,6 +959,34 @@ void testStatementSplit() throws Exception {
959959
}
960960
}
961961

962+
@Test(groups = { "integration" }, dataProvider = "commentsAndHeredocsDP")
963+
void testPlaceholdersWithCommentsAndHeredocs(String sql, String expected) throws Exception {
964+
try (Connection conn = getJdbcConnection()) {
965+
try (PreparedStatement stmt = conn.prepareStatement(sql)) {
966+
stmt.setString(1, "42");
967+
try (ResultSet rs = stmt.executeQuery()) {
968+
assertTrue(rs.next());
969+
assertEquals(rs.getString(1), expected);
970+
assertFalse(rs.next());
971+
}
972+
}
973+
}
974+
}
975+
976+
@DataProvider(name = "commentsAndHeredocsDP")
977+
public static Object[][] commentsAndHeredocsDP() {
978+
return new Object[][] {
979+
{"SELECT ? AS v // ?", "42"},
980+
{"SELECT ? AS v // ?\nUNION ALL SELECT NULL WHERE 0", "42"},
981+
{"SELECT //\n? AS v", "42"},
982+
{"SELECT --\n? AS v", "42"},
983+
{"SELECT concat($$?$$, ?) AS v", "?42"},
984+
{"SELECT concat($tag$ ? $tag$, ?) AS v", " ? 42"},
985+
{"SELECT ? AS a$x$, 1 AS b$x$", "42"},
986+
{"SELECT ? AS a$$b$, 1 AS x$$b$", "42"},
987+
};
988+
}
989+
962990
@Test(groups = {"integration"})
963991
void testClearParameters() throws Exception {
964992
final String sql = "insert into `test_issue_2299` (`id`, `name`, `age`) values (?, ?, ?)";

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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,80 @@ public static Object[][] testCTEStmtsDP() {
736736
};
737737
}
738738

739+
@Test(dataProvider = "testCommentsAndHeredocsDP")
740+
public void testCommentsAndHeredocs(String sql, int args) {
741+
// The ANTLR4_PARAMS_PARSER backend collects placeholders from the grammar, whose lexer has no
742+
// token for '//' comments and heredocs, so it is not covered by this scan. The other backends
743+
// must agree with the server on which '?' is a placeholder.
744+
if (grammarParamsBackend) {
745+
return;
746+
}
747+
ParsedPreparedStatement stmt = parser.parsePreparedStatement(sql);
748+
Assert.assertEquals(stmt.getArgCount(), args, "Args mismatch for: " + sql);
749+
}
750+
751+
@DataProvider
752+
public static Object[][] testCommentsAndHeredocsDP() {
753+
return new Object[][] {
754+
// '//' line comments
755+
{"SELECT 1 // ?", 0},
756+
{"SELECT 1 //", 0},
757+
{"SELECT ? // ?\n, ?", 2},
758+
{"SELECT 1 // ? -- ? /* ? */ $$?$$\n, ?", 1},
759+
// an empty line comment ends at its own newline, so later placeholders are still counted
760+
{"SELECT ? //\n, ?", 2},
761+
{"SELECT ? //\n// ?\n, ?", 2},
762+
{"SELECT ? //\n?", 2},
763+
{"SELECT ? --\n, ?", 2},
764+
{"SELECT ? -- ?\n--\n, ?", 2},
765+
{"SELECT ? #\n, ?", 2},
766+
{"SELECT ? #!\n, ?", 2},
767+
{"SELECT ? //\n--\n#\n, ?", 2},
768+
{"//\nSELECT ?", 1},
769+
// a comment that is never terminated still ends the scan
770+
{"SELECT ? //\n", 1},
771+
{"SELECT ? --", 1},
772+
// a comment marker inside a string, a heredoc or a block comment does not start a comment
773+
{"SELECT '--\n' AS v, ?", 1},
774+
{"SELECT $$//\n$$ AS v, ?", 1},
775+
{"SELECT ? /* --\n */, ?", 2},
776+
// heredocs (dollar quoted strings)
777+
{"SELECT $$?$$ AS v", 0},
778+
{"SELECT $tag$ ? $tag$ AS v", 0},
779+
{"SELECT $1$ ? $1$ AS v", 0},
780+
{"SELECT $$$$ AS v, ?", 1},
781+
{"SELECT $$a$b$$ AS v, ?", 1},
782+
{"SELECT $t$ ?\n -- ?\n // ?\n /* ? */ $t$ AS v, ?", 1},
783+
{"SELECT $$?$$, ?, $$?$$", 1},
784+
{"SELECT $$it's ?$$ AS v, ?", 1},
785+
{"SELECT $$ /* ? $$ AS v, ?", 1},
786+
// '//' and heredoc markers that are not comments or heredocs
787+
{"SELECT '// ?' AS v, ?", 1},
788+
{"SELECT '$$?$$' AS v, ?", 1},
789+
{"SELECT -- '// ?'\n?", 1},
790+
{"SELECT /* $$?$$ */ ?", 1},
791+
{"SELECT 4 / 2 AS v, ?", 1},
792+
{"SELECT ? AS a$b, ? AS c$d, 3", 2},
793+
{"SELECT ? AS a$x$, ? AS b$x$", 2},
794+
{"SELECT 1 AS a$x$, ?", 1},
795+
// a dollar sign is an identifier character too, so a pair of them inside a name does not
796+
// open a heredoc, even when the same character sequence occurs again later
797+
{"SELECT ? AS a$$b$, ? AS x$$b$", 2},
798+
{"SELECT a$$b$, ?, x$$b$ FROM t", 1},
799+
{"SELECT ? AS a$$b$$c, ? AS x$$b$$c", 2},
800+
{"SELECT ? AS a$$b$", 1},
801+
// an identifier ending with a dollar sign does not swallow the heredoc that follows it
802+
{"SELECT 1 AS a$$b$, $$?$$ AS v, ?", 1},
803+
{"SELECT 1 AS a$$b$,$$?$$ AS v, ?", 1},
804+
{"SELECT $$ ? AS v, ?", 2},
805+
// already supported comment styles keep working
806+
{"SELECT 1 -- ?", 0},
807+
{"SELECT 1 # ?", 0},
808+
{"SELECT 1 #! ?", 0},
809+
{"SELECT /* ? /* ? */ ? */ ?", 1},
810+
};
811+
}
812+
739813
@Test(dataProvider = "testDoubleSlashLineCommentDp")
740814
public void testDoubleSlashLineComments(String sql, int args, boolean insert, boolean hasResultSet) {
741815
ParsedPreparedStatement prepared = parser.parsePreparedStatement(sql);

0 commit comments

Comments
 (0)