Skip to content

Commit 391a584

Browse files
Merge remote-tracking branch 'origin/main' into polyglot/jdbc-v2-placeholder-scan-comments-heredocs
2 parents 1b53350 + f41578f commit 391a584

8 files changed

Lines changed: 215 additions & 5 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,14 @@
159159
executes fine. The placeholder scan now skips both token kinds, like the server lexer does; a `$` that does not open a
160160
heredoc is still treated as an ordinary character (it is a valid identifier character).
161161
(https://github.com/ClickHouse/clickhouse-java/issues/3009)
162+
- **[jdbc-v2]** Fixed `INSERT INTO [TABLE] FUNCTION f(...) VALUES (?)` failing with
163+
`Code: 60 ... does not exist. (UNKNOWN_TABLE)` when the `beta.row_binary_for_simple_insert` feature was
164+
enabled. Neither SQL parser reported a table-function insert target as a function, so the statement was
165+
routed to the `RowBinary` writer, which looked the function name (or a placeholder such as `unknown`) up as
166+
a table. Both parsers now report such a statement as using a function, so it stays on the regular SQL path;
167+
additionally the JavaCC grammar no longer mis-parses `INSERT INTO TABLE FUNCTION f(...)` by consuming
168+
`FUNCTION` as the table name. Inserts into a plain table are unaffected and still use the `RowBinary`
169+
writer. (https://github.com/ClickHouse/clickhouse-java/issues/3015)
162170
- **[jdbc-v2]** Fixed `Connection#prepareStatement` throwing a `NullPointerException` for an
163171
`INSERT ... VALUES (...)` statement whose values list the default JavaCC parser cannot parse — most commonly one
164172
containing a heredoc string (`$$...$$`), which the grammar has no token for, but also any other unparsable token
@@ -309,6 +317,11 @@
309317
performs the handshake and runs the post-close action; a concurrent or repeated `close()` returns immediately. A
310318
`close()` which fails while flushing the remaining data also marks the stream closed and runs the post-close
311319
action, so the stream cannot stay half-closed. (https://github.com/ClickHouse/clickhouse-java/issues/3055)
320+
- **[data]** Fixed `NonBlockingPipedOutputStream.close()` not being idempotent under concurrency. Two threads could
321+
both flush and mutate the same pending buffer before the reader consumed it, silently replacing the payload with
322+
an empty buffer and running the post-close action twice. Exactly one caller now flushes the pending data, enqueues
323+
the end-of-stream marker, and runs the post-close action; concurrent or repeated `close()` calls return immediately.
324+
(https://github.com/ClickHouse/clickhouse-java/issues/3057)
312325
- **[jdbc-v2]** Fixed JDBC escape processing rewriting text inside string literals and quoted identifiers. Because
313326
`PreparedStatement` inlines bound parameters into the statement text, a bound value containing `{fn ` (or `{d '...'}`
314327
/ `{ts '...'}`) was re-read as SQL syntax: the `{fn ` was removed together with the next `}` found anywhere in the

‎clickhouse-data/src/main/java/com/clickhouse/data/stream/NonBlockingPipedOutputStream.java‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import java.nio.Buffer;
55
import java.nio.ByteBuffer;
66
import java.util.concurrent.CompletableFuture;
7+
import java.util.concurrent.atomic.AtomicBoolean;
78

89
import com.clickhouse.data.ClickHouseByteBuffer;
910
import com.clickhouse.data.ClickHouseChecker;
@@ -34,6 +35,7 @@ public class NonBlockingPipedOutputStream extends ClickHousePipedOutputStream {
3435
protected final int bufferSize;
3536
protected final CompletableFuture<Void> future;
3637
protected final long timeout;
38+
private final AtomicBoolean closing = new AtomicBoolean(false);
3739

3840
protected ByteBuffer buffer;
3941

@@ -104,7 +106,7 @@ public ClickHouseInputStream getInputStream(Runnable postCloseAction) {
104106

105107
@Override
106108
public void close() throws IOException {
107-
if (closed) {
109+
if (closed || !closing.compareAndSet(false, true)) {
108110
return;
109111
}
110112

‎clickhouse-data/src/test/java/com/clickhouse/data/stream/NonBlockingPipedOutputStreamTest.java‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
import java.util.concurrent.CountDownLatch;
1010
import java.util.concurrent.ExecutorService;
1111
import java.util.concurrent.Executors;
12+
import java.util.concurrent.Future;
1213
import java.util.concurrent.TimeUnit;
14+
import java.util.concurrent.TimeoutException;
15+
import java.util.concurrent.atomic.AtomicBoolean;
1316
import java.util.concurrent.atomic.AtomicInteger;
1417

1518
import com.clickhouse.data.ClickHouseByteBuffer;
@@ -208,6 +211,61 @@ public void testWriteBytes() throws IOException {
208211
}
209212
}
210213

214+
@Test(groups = { "unit" })
215+
public void testConcurrentClose() throws Exception {
216+
final long timeout = 5000L;
217+
final AtomicBoolean acceptBuffer = new AtomicBoolean(false);
218+
final CountDownLatch firstBufferOffer = new CountDownLatch(1);
219+
final AtomicInteger closeCount = new AtomicInteger(0);
220+
final CapacityPolicy policy = current -> {
221+
firstBufferOffer.countDown();
222+
return acceptBuffer.get();
223+
};
224+
final NonBlockingPipedOutputStream stream = new NonBlockingPipedOutputStream(4, 2, timeout * 4L,
225+
policy, (Runnable) closeCount::incrementAndGet);
226+
final byte[] expected = new byte[] { (byte) 1, (byte) 2, (byte) 3 };
227+
stream.write(expected);
228+
229+
final ExecutorService executor = Executors.newFixedThreadPool(2);
230+
try {
231+
Future<?> firstClose = executor.submit(() -> {
232+
stream.close();
233+
return null;
234+
});
235+
Assert.assertTrue(firstBufferOffer.await(timeout, TimeUnit.MILLISECONDS),
236+
"First close did not try to flush the pending buffer");
237+
238+
Future<?> concurrentClose = executor.submit(() -> {
239+
stream.close();
240+
return null;
241+
});
242+
boolean concurrentCloseReturned = true;
243+
try {
244+
concurrentClose.get(timeout, TimeUnit.MILLISECONDS);
245+
} catch (TimeoutException e) {
246+
concurrentCloseReturned = false;
247+
}
248+
249+
acceptBuffer.set(true);
250+
firstClose.get(timeout, TimeUnit.MILLISECONDS);
251+
concurrentClose.get(timeout, TimeUnit.MILLISECONDS);
252+
253+
Assert.assertTrue(concurrentCloseReturned, "Concurrent close should return without waiting");
254+
Assert.assertEquals(closeCount.get(), 1, "Post close action should have been executed exactly once");
255+
try (InputStream in = stream.getInputStream()) {
256+
byte[] actual = new byte[expected.length];
257+
Assert.assertEquals(in.read(actual), expected.length);
258+
Assert.assertEquals(actual, expected);
259+
Assert.assertEquals(in.read(), -1);
260+
}
261+
} finally {
262+
acceptBuffer.set(true);
263+
executor.shutdownNow();
264+
Assert.assertTrue(executor.awaitTermination(timeout, TimeUnit.MILLISECONDS),
265+
"Concurrent close executor did not terminate");
266+
}
267+
}
268+
211269
@Test(groups = { "unit" })
212270
public void testPipedStream() throws InterruptedException, IOException {
213271
final int timeout = 10000;

‎jdbc-v2/src/main/java/com/clickhouse/jdbc/ConnectionImpl.java‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import com.clickhouse.jdbc.internal.JdbcConfiguration;
1515
import com.clickhouse.jdbc.internal.ParsedPreparedStatement;
1616
import com.clickhouse.jdbc.internal.SqlParserFacade;
17+
import com.clickhouse.jdbc.internal.parser.javacc.ClickHouseSqlStatement;
1718
import com.clickhouse.jdbc.metadata.DatabaseMetaDataImpl;
1819
import com.google.common.collect.ImmutableMap;
1920
import org.slf4j.Logger;
@@ -460,10 +461,14 @@ public PreparedStatement prepareStatement(String sql, int resultSetType, int res
460461
* - INSERT INTO t VALUES (now(), ?, ?) !# there is a function in the values
461462
* - INSERT INTO t VALUES (now(), ?, 1), (now(), ?, 2) !# multiple values list
462463
* - INSERT INTO t SELECT ?, ?, ? !# insert from select
464+
* - INSERT INTO [TABLE] FUNCTION f(...) VALUES (?) !# the target is a table function
465+
* - the target table could not be resolved from the statement
463466
*/
467+
String table = parsedStatement.getTable();
464468
if (!parsedStatement.isInsertWithSelect() && parsedStatement.getAssignValuesGroups() == 1
465-
&& !parsedStatement.isUseFunction()) {
466-
TableSchema tableSchema = client.getTableSchema(parsedStatement.getTable(), schema);
469+
&& !parsedStatement.isUseFunction()
470+
&& table != null && !ClickHouseSqlStatement.DEFAULT_TABLE.equals(table)) {
471+
TableSchema tableSchema = client.getTableSchema(table, schema);
467472
return new WriterStatementImpl(this, sql, tableSchema, parsedStatement);
468473
}
469474
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,12 @@ public void enterTableExprIdentifier(ClickHouseParser.TableExprIdentifierContext
366366

367367
@Override
368368
public void enterInsertStmt(ClickHouseParser.InsertStmtContext ctx) {
369+
if (ctx.tableFunctionExpr() != null) {
370+
// INSERT INTO [TABLE] FUNCTION f(...) has no plain table to write into, so the
371+
// parsed target must not be treated as a table name.
372+
parsedStatement.setUseFunction(true);
373+
}
374+
369375
ClickHouseParser.TableIdentifierContext tableId = ctx.tableIdentifier();
370376
if (tableId != null) {
371377
extractAndSetDatabaseAndTable(tableId);

‎jdbc-v2/src/main/javacc/ClickHouseSqlParser.jj‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ TOKEN_MGR_DECLS: {
265265
compressLevel = null;
266266
format = null;
267267
file = null;
268+
funcUsed = false;
268269
parameters.clear();
269270
positions.clear();
270271
settings.clear();
@@ -592,9 +593,12 @@ void grantStmt(): {} { // not interested
592593
void insertStmt(): {} {
593594
<INSERT> <INTO>
594595
(
595-
LOOKAHEAD({ getToken(1).kind == FUNCTION
596+
LOOKAHEAD({ getToken(1).kind == TABLE && getToken(2).kind == FUNCTION
597+
&& !tokenIn(3, VALUES, FORMAT, SETTINGS, SELECT, WITH, INFILE)
598+
&& getToken(4).kind == LPAREN }) <TABLE> <FUNCTION> functionExpr() { token_source.funcUsed = true; }
599+
| LOOKAHEAD({ getToken(1).kind == FUNCTION
596600
&& !tokenIn(2, VALUES, FORMAT, SETTINGS, SELECT, WITH, INFILE)
597-
&& getToken(3).kind == LPAREN }) <FUNCTION> functionExpr()
601+
&& getToken(3).kind == LPAREN }) <FUNCTION> functionExpr() { token_source.funcUsed = true; }
598602
| (
599603
LOOKAHEAD({ getToken(1).kind == TABLE
600604
&& !tokenIn(2, VALUES, FORMAT, SETTINGS, SELECT, WITH, LPAREN) }) <TABLE>

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,76 @@ public void close() throws IOException {
245245
}
246246
}
247247

248+
@DataProvider(name = "insertTargetFunctionForms")
249+
Object[][] insertTargetFunctionForms() {
250+
return new Object[][]{
251+
{"JAVACC", "INSERT INTO FUNCTION null('id UInt32') VALUES (?)"},
252+
{"JAVACC", "INSERT INTO TABLE FUNCTION null('id UInt32') VALUES (?)"},
253+
{"ANTLR4", "INSERT INTO FUNCTION null('id UInt32') VALUES (?)"},
254+
{"ANTLR4", "INSERT INTO TABLE FUNCTION null('id UInt32') VALUES (?)"},
255+
{"ANTLR4_PARAMS_PARSER", "INSERT INTO FUNCTION null('id UInt32') VALUES (?)"},
256+
{"ANTLR4_PARAMS_PARSER", "INSERT INTO TABLE FUNCTION null('id UInt32') VALUES (?)"},
257+
};
258+
}
259+
260+
/**
261+
* An INSERT whose target is a table function has no plain table to fetch a schema for, so it must stay
262+
* on the regular SQL path instead of being routed to the RowBinary writer, which would look the
263+
* function name up as a table and fail with UNKNOWN_TABLE.
264+
*/
265+
@Test(groups = {"integration"}, dataProvider = "insertTargetFunctionForms")
266+
public void testInsertIntoTableFunctionUsesSqlPath(String parser, String sql) throws SQLException {
267+
Properties properties = new Properties();
268+
properties.setProperty(DriverProperties.BETA_ROW_BINARY_WRITER.getKey(), "true");
269+
properties.setProperty(DriverProperties.SQL_PARSER.getKey(), parser);
270+
properties.setProperty(ASYNC_INSERT_SETTING_KEY, ServerSettings.OFF);
271+
try (Connection connection = getJdbcConnection(properties);
272+
PreparedStatement ps = connection.prepareStatement(sql)) {
273+
Assert.assertFalse(ps instanceof WriterStatementImpl,
274+
"INSERT into a table function must not use the RowBinary writer: " + sql);
275+
ps.setInt(1, 42);
276+
Assert.assertEquals(ps.executeUpdate(), 1);
277+
}
278+
}
279+
280+
@DataProvider(name = "insertTargetPlainTableForms")
281+
Object[][] insertTargetPlainTableForms() {
282+
return new Object[][]{
283+
{"JAVACC", "INSERT INTO %s VALUES (?)"},
284+
{"JAVACC", "INSERT INTO TABLE %s VALUES (?)"},
285+
{"ANTLR4", "INSERT INTO %s VALUES (?)"},
286+
{"ANTLR4", "INSERT INTO TABLE %s VALUES (?)"},
287+
{"ANTLR4_PARAMS_PARSER", "INSERT INTO %s VALUES (?)"},
288+
{"ANTLR4_PARAMS_PARSER", "INSERT INTO TABLE %s VALUES (?)"},
289+
};
290+
}
291+
292+
@Test(groups = {"integration"}, dataProvider = "insertTargetPlainTableForms")
293+
public void testInsertIntoPlainTableStillUsesWriter(String parser, String sqlTemplate) throws SQLException {
294+
String table = "bt_writer_plain_table";
295+
Properties properties = new Properties();
296+
properties.setProperty(DriverProperties.BETA_ROW_BINARY_WRITER.getKey(), "true");
297+
properties.setProperty(DriverProperties.SQL_PARSER.getKey(), parser);
298+
properties.setProperty(ASYNC_INSERT_SETTING_KEY, ServerSettings.OFF);
299+
try (Connection connection = getJdbcConnection(properties)) {
300+
try (Statement stmt = connection.createStatement()) {
301+
stmt.execute("DROP TABLE IF EXISTS " + table);
302+
stmt.execute("CREATE TABLE " + table + " (id Int32) Engine MergeTree ORDER BY ()");
303+
}
304+
305+
try (PreparedStatement ps = connection.prepareStatement(String.format(sqlTemplate, table))) {
306+
Assert.assertTrue(ps instanceof WriterStatementImpl,
307+
"INSERT into a plain table must keep using the RowBinary writer: " + sqlTemplate);
308+
ps.setInt(1, 42);
309+
Assert.assertEquals(ps.executeUpdate(), 1);
310+
} finally {
311+
try (Statement stmt = connection.createStatement()) {
312+
stmt.execute("DROP TABLE IF EXISTS " + table);
313+
}
314+
}
315+
}
316+
}
317+
248318
@DataProvider(name = "antlr4ParserBackends")
249319
Object[][] antlr4ParserBackends() {
250320
return new Object[][]{

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,58 @@ private void assertInsertColumns(String sql, String... expectedColumns) {
486486
Assert.assertEquals(actualColumns, expectedColumns, "Insert column names mismatch for: " + sql);
487487
}
488488

489+
@Test(dataProvider = "insertTargetFunctionDP")
490+
public void testInsertTargetTableFunctionIsReportedAsFunction(String sql, boolean expectedUseFunction,
491+
int expectedValuesGroups) {
492+
ParsedPreparedStatement stmt = parser.parsePreparedStatement(sql);
493+
Assert.assertFalse(stmt.isHasErrors(), "Query should parse without errors: " + sql);
494+
Assert.assertTrue(stmt.isInsert(), "Should be an INSERT: " + sql);
495+
Assert.assertEquals(stmt.isUseFunction(), expectedUseFunction, "useFunction mismatch for: " + sql);
496+
Assert.assertEquals(stmt.getAssignValuesGroups(), expectedValuesGroups,
497+
"Values group count mismatch for: " + sql);
498+
}
499+
500+
@DataProvider
501+
public static Object[][] insertTargetFunctionDP() {
502+
return new Object[][] {
503+
// An INSERT whose target is a table function has no plain table behind it
504+
{"INSERT INTO FUNCTION null('id UInt32') VALUES (?)", true, 1},
505+
{"INSERT INTO TABLE FUNCTION null('id UInt32') VALUES (?)", true, 1},
506+
{"INSERT INTO FUNCTION remoteSecure('h', 'db', 't', 'u', 'p') (id, name) VALUES (?, ?)", true, 1},
507+
{"INSERT INTO FUNCTION s3('url', 'key', 'secret', 'CSV') SELECT * FROM t", true, 0},
508+
{"insert into function null('id UInt32') values (?)", true, 1},
509+
{"INSERT INTO\n TABLE FUNCTION null('id UInt32')\n VALUES (?)", true, 1},
510+
{"INSERT INTO /* target */ FUNCTION null('id UInt32') VALUES (?)", true, 1},
511+
// Contrast: plain table targets, including a table literally named "function"
512+
{"INSERT INTO t VALUES (?)", false, 1},
513+
{"INSERT INTO TABLE t VALUES (?)", false, 1},
514+
{"INSERT INTO db.t VALUES (?)", false, 1},
515+
{"INSERT INTO function VALUES (?)", false, 1},
516+
{"INSERT INTO TABLE function VALUES (?)", false, 1},
517+
{"INSERT INTO function (id) VALUES (?)", false, 1},
518+
// Contrast: a function inside the values list is already reported as a function
519+
{"INSERT INTO t VALUES (now(), ?)", true, 1},
520+
};
521+
}
522+
523+
@Test(dataProvider = "insertTargetTableNameDP")
524+
public void testInsertPlainTableTargetKeepsTableName(String sql, String expectedTable) {
525+
ParsedPreparedStatement stmt = parser.parsePreparedStatement(sql);
526+
Assert.assertFalse(stmt.isHasErrors(), "Query should parse without errors: " + sql);
527+
Assert.assertEquals(stmt.getTable(), expectedTable, "Table name mismatch for: " + sql);
528+
}
529+
530+
@DataProvider
531+
public static Object[][] insertTargetTableNameDP() {
532+
return new Object[][] {
533+
{"INSERT INTO t VALUES (?)", "t"},
534+
{"INSERT INTO TABLE t VALUES (?)", "t"},
535+
{"INSERT INTO db.t VALUES (?)", "db.t"},
536+
{"INSERT INTO function VALUES (?)", "function"},
537+
{"INSERT INTO TABLE function VALUES (?)", "function"},
538+
};
539+
}
540+
489541
@Test(dataProvider = "testCreateStmtDP")
490542
public void testCreateStatement(String sql) {
491543
ParsedPreparedStatement stmt = parser.parsePreparedStatement(sql);

0 commit comments

Comments
 (0)