Skip to content

Commit 3f059ab

Browse files
committed
Fix issue #50
When a statement contains several queries separated by a semicolon character and at least one of these queries contains a string value including a semicolon the list of queries to execute was incorrect resulting into a syntax error during the execution of the split statements. The statements splitting has been refactored and is applied in any compliance mode.
1 parent b63a232 commit 3f059ab

File tree

6 files changed

+112
-31
lines changed

6 files changed

+112
-31
lines changed

Diff for: CHANGELOG.md

+7
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to
55
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [4.11.1] - 2023-12-30
8+
### Fixed
9+
- Fix issue [#50](https://github.com/ing-bank/cassandra-jdbc-wrapper/issues/50) preventing a correct execution of
10+
multiple statements separated by semicolon characters (`;`) when at least one of the CQL queries contains a semicolon
11+
character which is not a query separator.
12+
713
## [4.11.0] - 2023-12-03
814
### Added
915
- Add support for connections with multiple contact points using different ports (see feature request
@@ -219,6 +225,7 @@ For this version, the changelog lists the main changes comparatively to the late
219225
- Fix logs in `CassandraConnection` constructor.
220226

221227
[original project]: https://github.com/adejanovski/cassandra-jdbc-wrapper/
228+
[4.11.1]: https://github.com/ing-bank/cassandra-jdbc-wrapper/compare/v4.11.0...v4.11.1
222229
[4.11.0]: https://github.com/ing-bank/cassandra-jdbc-wrapper/compare/v4.10.2...v4.11.0
223230
[4.10.2]: https://github.com/ing-bank/cassandra-jdbc-wrapper/compare/v4.10.1...v4.10.2
224231
[4.10.1]: https://github.com/ing-bank/cassandra-jdbc-wrapper/compare/v4.10.0...v4.10.1

Diff for: pom.xml

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
<groupId>com.ing.data</groupId>
77
<artifactId>cassandra-jdbc-wrapper</artifactId>
8-
<version>4.11.0</version>
8+
<version>4.11.1</version>
99
<packaging>jar</packaging>
1010

1111
<name>Cassandra JDBC Wrapper</name>

Diff for: src/main/java/com/ing/data/cassandra/jdbc/CassandraPreparedStatement.java

+2-2
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
import static com.ing.data.cassandra.jdbc.utils.ConversionsUtil.convertToLocalDate;
8080
import static com.ing.data.cassandra.jdbc.utils.ConversionsUtil.convertToLocalTime;
8181
import static com.ing.data.cassandra.jdbc.utils.ErrorConstants.NO_RESULT_SET;
82+
import static com.ing.data.cassandra.jdbc.utils.ErrorConstants.TOO_MANY_QUERIES;
8283
import static com.ing.data.cassandra.jdbc.utils.ErrorConstants.UNSUPPORTED_JDBC_TYPE;
8384
import static com.ing.data.cassandra.jdbc.utils.ErrorConstants.VECTOR_ELEMENTS_NOT_NUMBERS;
8485
import static com.ing.data.cassandra.jdbc.utils.JsonUtil.getObjectMapper;
@@ -233,8 +234,7 @@ public void addBatch() throws SQLException {
233234
this.batchStatements.add(this.boundStatement);
234235
this.boundStatement = this.preparedStatement.boundStatementBuilder().build();
235236
if (this.batchStatements.size() > MAX_ASYNC_QUERIES) {
236-
throw new SQLNonTransientException("Too many queries at once (" + batchStatements.size() + "). You must "
237-
+ "split your queries into more batches!");
237+
throw new SQLNonTransientException(String.format(TOO_MANY_QUERIES, batchStatements.size()));
238238
}
239239
}
240240

Diff for: src/main/java/com/ing/data/cassandra/jdbc/CassandraStatement.java

+48-28
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
import com.datastax.oss.driver.internal.core.cql.MultiPageResultSet;
2525
import com.datastax.oss.driver.internal.core.cql.SinglePageResultSet;
2626
import com.datastax.oss.driver.internal.core.util.concurrent.CompletableFutures;
27-
import org.apache.commons.lang3.StringUtils;
2827
import org.slf4j.Logger;
2928
import org.slf4j.LoggerFactory;
3029

@@ -58,7 +57,9 @@
5857
import static com.ing.data.cassandra.jdbc.utils.ErrorConstants.NO_GEN_KEYS;
5958
import static com.ing.data.cassandra.jdbc.utils.ErrorConstants.NO_MULTIPLE;
6059
import static com.ing.data.cassandra.jdbc.utils.ErrorConstants.NO_RESULT_SET;
60+
import static com.ing.data.cassandra.jdbc.utils.ErrorConstants.TOO_MANY_QUERIES;
6161
import static com.ing.data.cassandra.jdbc.utils.ErrorConstants.WAS_CLOSED_STMT;
62+
import static org.apache.commons.lang3.StringUtils.countMatches;
6263

6364
/**
6465
* Cassandra statement: implementation class for {@link Statement}.
@@ -292,21 +293,50 @@ public int compareTo(@Nonnull final Object target) {
292293
return 1;
293294
}
294295

296+
private List<String> splitStatements(final String cql) {
297+
final String[] cqlQueries = cql.split(STATEMENTS_SEPARATOR_REGEX);
298+
final List<String> cqlQueriesToExecute = new ArrayList<>(cqlQueries.length);
299+
300+
// If a query contains a semicolon character in a string value (for example 'abc;xyz'), re-merge queries
301+
// wrongly split.
302+
final String singleQuote = "'";
303+
StringBuilder prevCqlQuery = new StringBuilder();
304+
for (final String cqlQuery : cqlQueries) {
305+
final boolean hasStringValues = cqlQuery.contains(singleQuote);
306+
final boolean isFirstQueryPartWithIncompleteStringValue =
307+
countMatches(cqlQuery, singleQuote) % 2 == 1 && prevCqlQuery.length() == 0;
308+
final boolean isNotFirstQueryPartWithCompleteStringValue =
309+
countMatches(cqlQuery, singleQuote) % 2 == 0 && prevCqlQuery.length() > 0;
310+
final boolean isNotFirstQueryPartWithoutStringValue =
311+
!prevCqlQuery.toString().isEmpty() && !cqlQuery.contains(singleQuote);
312+
313+
if ((hasStringValues && (isFirstQueryPartWithIncompleteStringValue
314+
|| isNotFirstQueryPartWithCompleteStringValue)) || isNotFirstQueryPartWithoutStringValue) {
315+
prevCqlQuery.append(cqlQuery).append(";");
316+
} else {
317+
prevCqlQuery.append(cqlQuery);
318+
cqlQueriesToExecute.add(prevCqlQuery.toString());
319+
prevCqlQuery = new StringBuilder();
320+
}
321+
}
322+
return cqlQueriesToExecute;
323+
}
324+
295325
private void doExecute(final String cql) throws SQLException {
296326
final List<CompletionStage<AsyncResultSet>> futures = new ArrayList<>();
297327

298328
try {
299-
final String[] cqlQueries = cql.split(STATEMENTS_SEPARATOR_REGEX);
300-
if (cqlQueries.length > 1
329+
final List<String> cqlQueries = splitStatements(cql);
330+
final int nbQueriesToExecute = cqlQueries.size();
331+
if (nbQueriesToExecute > 1
301332
&& !(cql.trim().toLowerCase().startsWith("begin")
302333
&& cql.toLowerCase().contains("batch") && cql.toLowerCase().contains("apply"))) {
303334
final ArrayList<com.datastax.oss.driver.api.core.cql.ResultSet> results = new ArrayList<>();
304335

305-
// Several statements in the query to execute asynchronously...
306-
if (cqlQueries.length > MAX_ASYNC_QUERIES * 1.1) {
336+
// Several statements in the query to execute potentially asynchronously...
337+
if (nbQueriesToExecute > MAX_ASYNC_QUERIES * 1.1) {
307338
// Protect the cluster from receiving too many queries at once and force the dev to split the load
308-
throw new SQLNonTransientException("Too many queries at once (" + cqlQueries.length
309-
+ "). You must split your queries into more batches !");
339+
throw new SQLNonTransientException(String.format(TOO_MANY_QUERIES, nbQueriesToExecute));
310340
}
311341

312342
// If we should not execute the queries asynchronously, for example if they must be executed in the
@@ -318,29 +348,19 @@ private void doExecute(final String cql) throws SQLException {
318348
results.add(rs);
319349
}
320350
} else {
321-
StringBuilder prevCqlQuery = new StringBuilder();
322351
for (final String cqlQuery : cqlQueries) {
323-
if ((cqlQuery.contains("'") && ((StringUtils.countMatches(cqlQuery, "'") % 2 == 1
324-
&& prevCqlQuery.length() == 0)
325-
|| (StringUtils.countMatches(cqlQuery, "'") % 2 == 0 && prevCqlQuery.length() > 0)))
326-
|| (!prevCqlQuery.toString().isEmpty() && !cqlQuery.contains("'"))) {
327-
prevCqlQuery.append(cqlQuery).append(";");
328-
} else {
329-
prevCqlQuery.append(cqlQuery);
330-
if (LOG.isTraceEnabled() || this.connection.isDebugMode()) {
331-
LOG.debug("CQL: {}", prevCqlQuery);
332-
}
333-
SimpleStatement stmt = SimpleStatement.newInstance(prevCqlQuery.toString())
334-
.setConsistencyLevel(this.connection.getDefaultConsistencyLevel())
335-
.setPageSize(this.fetchSize);
336-
if (this.customTimeoutProfile != null) {
337-
stmt = stmt.setExecutionProfile(this.customTimeoutProfile);
338-
}
339-
final CompletionStage<AsyncResultSet> resultSetFuture =
340-
((CqlSession) this.connection.getSession()).executeAsync(stmt);
341-
futures.add(resultSetFuture);
342-
prevCqlQuery = new StringBuilder();
352+
if (LOG.isDebugEnabled() || this.connection.isDebugMode()) {
353+
LOG.debug("CQL: {}", cqlQuery);
354+
}
355+
SimpleStatement stmt = SimpleStatement.newInstance(cqlQuery)
356+
.setConsistencyLevel(this.connection.getDefaultConsistencyLevel())
357+
.setPageSize(this.fetchSize);
358+
if (this.customTimeoutProfile != null) {
359+
stmt = stmt.setExecutionProfile(this.customTimeoutProfile);
343360
}
361+
final CompletionStage<AsyncResultSet> resultSetFuture =
362+
((CqlSession) this.connection.getSession()).executeAsync(stmt);
363+
futures.add(resultSetFuture);
344364
}
345365

346366
for (final CompletionStage<AsyncResultSet> future : futures) {

Diff for: src/main/java/com/ing/data/cassandra/jdbc/utils/ErrorConstants.java

+11
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import com.ing.data.cassandra.jdbc.CassandraPreparedStatement;
1919
import com.ing.data.cassandra.jdbc.CassandraResultSet;
20+
import com.ing.data.cassandra.jdbc.CassandraStatement;
2021
import com.ing.data.cassandra.jdbc.metadata.MetadataRow;
2122

2223
import java.net.URI;
@@ -278,6 +279,16 @@ public final class ErrorConstants {
278279
*/
279280
public static final String UNABLE_TO_POPULATE_METADATA_ROW = "Unable to populate a metadata row.";
280281

282+
/**
283+
* Error message used in any SQL exception thrown when the number of CQL queries included in a single batch of
284+
* statements is greater than the allowed limit ({@value CassandraStatement#MAX_ASYNC_QUERIES} for a prepared batch
285+
* statement or 1.1 {@code *} {@value CassandraStatement#MAX_ASYNC_QUERIES} for a split single statement).
286+
* This message is a template expecting the number of CQL queries to execute as placeholder (example:
287+
* {@code String.format(TOO_MANY_QUERIES, 10000)}).
288+
*/
289+
public static final String TOO_MANY_QUERIES =
290+
"Too many queries at once (%d). You must split your queries into more batches!";
291+
281292
private ErrorConstants() {
282293
// Private constructor to hide the public one.
283294
}

Diff for: src/test/java/com/ing/data/cassandra/jdbc/BatchStatementsUnitTest.java

+43
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import java.sql.Statement;
2929
import java.util.ArrayList;
3030
import java.util.Arrays;
31+
import java.util.Set;
3132

3233
import static org.junit.jupiter.api.Assertions.assertEquals;
3334
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -247,4 +248,46 @@ void givenBatchSimpleSplitStatementWithErrors_whenExecute_throwException() throw
247248
}
248249
assertThrows(SQLTransientException.class, () -> stmt2.execute(queryBuilder.toString()));
249250
}
251+
252+
@SuppressWarnings("unchecked")
253+
@ParameterizedTest
254+
@Order(7)
255+
@ValueSource(strings = {"Default", "Liquibase"})
256+
void givenStatementWithValuesIncludingSemicolons_whenExecute_returnExpectedResult(final String complianceMode)
257+
throws Exception {
258+
sqlConnection2 = newConnection(KEYSPACE, "localdatacenter=datacenter1", "compliancemode=" + complianceMode);
259+
final Statement truncateStmt = sqlConnection2.createStatement();
260+
truncateStmt.execute("TRUNCATE collections_test");
261+
262+
final Statement statement = sqlConnection2.createStatement();
263+
final StringBuilder queryBuilder = new StringBuilder();
264+
for (int i = 0; i < 20; i++) {
265+
queryBuilder.append("INSERT INTO collections_test (keyValue, setValue) VALUES( ").append(i)
266+
.append(", {'test;0', 'val;").append(i).append("'} );");
267+
}
268+
statement.execute(queryBuilder.toString());
269+
statement.close();
270+
271+
final StringBuilder query = new StringBuilder();
272+
for (int i = 0; i < 20; i++) {
273+
query.append("SELECT * FROM collections_test WHERE keyValue = ").append(i).append(";");
274+
}
275+
final Statement selectStatement = sqlConnection2.createStatement();
276+
final ResultSet result = selectStatement.executeQuery(query.toString());
277+
int nbRowsInResult = 0;
278+
final ArrayList<Integer> foundKeyValues = new ArrayList<>();
279+
final ArrayList<String> foundSetValues = new ArrayList<>();
280+
while (result.next()) {
281+
nbRowsInResult++;
282+
foundKeyValues.add(result.getInt("keyValue"));
283+
final Set<String> setValues = (Set<String>) result.getObject("setValue");
284+
foundSetValues.addAll(setValues);
285+
}
286+
assertEquals(20, nbRowsInResult);
287+
for (int i = 0; i < 20; i++) {
288+
assertTrue(foundKeyValues.contains(i));
289+
assertTrue(foundSetValues.contains("val;" + i));
290+
}
291+
selectStatement.close();
292+
}
250293
}

0 commit comments

Comments
 (0)