Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
import com.github.magnusp.libsql.client.LibsqlException;
import com.github.magnusp.libsql.client.LibsqlHttpClient;
import com.github.magnusp.libsql.hrana.codec.Batch;
import com.github.magnusp.libsql.hrana.codec.BatchCond;
import com.github.magnusp.libsql.hrana.codec.BatchStep;
import com.github.magnusp.libsql.hrana.codec.HranaError;
import com.github.magnusp.libsql.hrana.codec.PipelineReqBody;
import com.github.magnusp.libsql.hrana.codec.PipelineRespBody;
import com.github.magnusp.libsql.hrana.codec.Stmt;
Expand All @@ -15,6 +17,7 @@

import java.net.URI;
import java.sql.Array;
import java.sql.BatchUpdateException;
import java.sql.Blob;
import java.sql.ResultSet;
import java.sql.CallableStatement;
Expand All @@ -32,6 +35,7 @@
import java.sql.Statement;
import java.sql.Struct;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Properties;
Expand Down Expand Up @@ -135,8 +139,9 @@ public int[] executeBatch(List<Stmt> statements) throws SQLException {

try {
List<BatchStep> steps = new ArrayList<>(statements.size());
for (Stmt stmt : statements) {
steps.add(new BatchStep(stmt));
for (int i = 0; i < statements.size(); i++) {
BatchCond condition = i == 0 ? null : new BatchCond.StepOk(i - 1);
steps.add(new BatchStep(condition, statements.get(i)));
}
Batch batch = new Batch(steps);

Expand Down Expand Up @@ -175,14 +180,19 @@ private int[] extractBatchResults(PipelineRespBody resp, int expectedSize) throw

int[] updateCounts = new int[expectedSize];
for (int i = 0; i < expectedSize; i++) {
if (batchResp.result().stepErrors().containsKey(i)) {
updateCounts[i] = Statement.EXECUTE_FAILED;
} else if (batchResp.result().stepResults().containsKey(i)) {
StmtResult r = batchResp.result().stepResults().get(i);
updateCounts[i] = (int) r.affectedRowCount();
} else {
updateCounts[i] = Statement.SUCCESS_NO_INFO;
HranaError error = batchResp.result().stepErrors().get(i);
if (error != null) {
// JDBC stops the batch at the first failure and reports only the steps that ran.
throw new BatchUpdateException(error.message(), Arrays.copyOf(updateCounts, i));
}
StmtResult result = batchResp.result().stepResults().get(i);
if (result == null) {
// A skipped step is legitimate only after a failed step, which the loop has
// already thrown on. Here the server executed the step but reported nothing.
throw new SQLException(
"Protocol error: batch step " + i + " has neither a result nor an error");
}
updateCounts[i] = (int) result.affectedRowCount();
}
return updateCounts;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import com.github.magnusp.libsql.client.LibsqlException;

import java.sql.BatchUpdateException;
import java.sql.SQLClientInfoException;
import java.sql.SQLException;
import java.sql.SQLFeatureNotSupportedException;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
import com.github.magnusp.libsql.client.LibsqlClientConfig;
import com.github.magnusp.libsql.client.LibsqlException;
import com.github.magnusp.libsql.client.LibsqlHttpClient;
import com.github.magnusp.libsql.hrana.codec.BatchCond;
import com.github.magnusp.libsql.hrana.codec.BatchResult;
import com.github.magnusp.libsql.hrana.codec.BatchStep;
import com.github.magnusp.libsql.hrana.codec.HranaError;
import com.github.magnusp.libsql.hrana.codec.PipelineReqBody;
import com.github.magnusp.libsql.hrana.codec.PipelineRespBody;
Expand All @@ -13,13 +16,17 @@
import com.github.magnusp.libsql.hrana.codec.StreamResult;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.ValueSource;

import java.net.URI;
import java.sql.BatchUpdateException;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;

import static org.assertj.core.api.Assertions.assertThat;
Expand All @@ -31,6 +38,7 @@ class LibsqlConnectionTest {
private static final String BATON = "baton-1";
private static final String SERVER_ERROR = "transaction end failed on server";
private static final String TRANSPORT_ERROR = "Connection reset by peer";
private static final String BATCH_FAILURE = "UNIQUE constraint failed: sample.id";

@Test
void getMetaDataIsCachedPerConnection() throws SQLException {
Expand Down Expand Up @@ -197,6 +205,140 @@ void transportErrorIsPropagatedAndTransactionStateIsKept(String command) throws
assertCommandRetriedOnSameStream(client, command);
}

@ParameterizedTest(name = "fail at step {0}, autoCommit={1}")
@CsvSource({"0,true", "1,true", "2,true", "0,false", "1,false", "2,false"})
void batchStopsAtFirstFailureAndThrowsWithTheCountsOfStepsThatRan(int failAt, boolean autoCommit)
throws SQLException {
List<String> executed = new ArrayList<>();
ScriptedClient client = new ScriptedClient(
(request, index) -> batchResponse(request, failAt, executed));
LibsqlConnection connection = new LibsqlConnection(CONFIG, client);
connection.setAutoCommit(autoCommit);
List<String> sql = List.of(
"INSERT INTO sample VALUES (1)",
"INSERT INTO sample VALUES (2)",
"INSERT INTO sample VALUES (3)");

assertThatThrownBy(() -> connection.executeBatch(sql.stream().map(Stmt::new).toList()))
.isInstanceOf(BatchUpdateException.class)
.hasMessageContaining(BATCH_FAILURE)
.satisfies(thrown -> assertThat(((BatchUpdateException) thrown).getUpdateCounts())
.containsExactly(countsOfStepsThatRan(failAt)));

assertThat(executed).containsExactlyElementsOf(sql.subList(0, failAt));

List<BatchStep> steps = batchSteps(client);
assertThat(steps).hasSize(sql.size());
assertThat(steps.get(0).condition()).isNull();
for (int i = 1; i < steps.size(); i++) {
assertThat(steps.get(i).condition())
.as("step %d runs only when step %d succeeded", i, i - 1)
.isEqualTo(new BatchCond.StepOk(i - 1));
}
}

@Test
void successfulBatchReturnsOneCountPerStep() throws SQLException {
ScriptedClient client = new ScriptedClient((request, index) -> batchResponse(request, -1, new ArrayList<>()));
LibsqlConnection connection = new LibsqlConnection(CONFIG, client);

int[] counts = connection.executeBatch(List.of(
new Stmt("INSERT INTO sample VALUES (1)"),
new Stmt("INSERT INTO sample VALUES (2)"),
new Stmt("INSERT INTO sample VALUES (3)")));

assertThat(counts).containsExactly(1, 1, 1);
}

@Test
void batchStepWithNeitherResultNorErrorIsAProtocolError() throws SQLException {
ScriptedClient client = new ScriptedClient((request, index) -> responseWithMissingBatchStep(request));
LibsqlConnection connection = new LibsqlConnection(CONFIG, client);

assertThatThrownBy(() -> connection.executeBatch(List.of(
new Stmt("INSERT INTO sample VALUES (1)"),
new Stmt("INSERT INTO sample VALUES (2)"))))
.isInstanceOf(SQLException.class)
.isNotInstanceOf(BatchUpdateException.class)
.hasMessageContaining("neither a result nor an error");
}

private static int[] countsOfStepsThatRan(int failAt) {
int[] counts = new int[failAt];
for (int i = 0; i < counts.length; i++) {
counts[i] = 1;
}
return counts;
}

private static List<BatchStep> batchSteps(ScriptedClient client) {
return client.requests.stream()
.flatMap(request -> request.requests().stream())
.filter(StreamRequest.BatchReq.class::isInstance)
.map(StreamRequest.BatchReq.class::cast)
.findFirst()
.map(batchReq -> batchReq.batch().steps())
.orElseThrow();
}

private static PipelineRespBody batchResponse(PipelineReqBody request, int failAt, List<String> executed) {
List<StreamResult> results = new ArrayList<>();
for (StreamRequest streamRequest : request.requests()) {
if (streamRequest instanceof StreamRequest.BatchReq batchReq) {
results.add(new StreamResult.Ok(new StreamResponse.BatchResp(
runBatch(batchReq.batch().steps(), failAt, executed))));
} else if (streamRequest instanceof StreamRequest.Close) {
results.add(new StreamResult.Ok(StreamResponse.Close.INSTANCE));
} else {
results.add(new StreamResult.Ok(
new StreamResponse.Execute(new StmtResult(List.of(), List.of(), 0, null))));
}
}
return new PipelineRespBody(null, null, results);
}

private static BatchResult runBatch(List<BatchStep> steps, int failAt, List<String> executed) {
Map<Integer, StmtResult> stepResults = new HashMap<>();
Map<Integer, HranaError> stepErrors = new HashMap<>();
for (int i = 0; i < steps.size(); i++) {
if (!conditionHolds(steps.get(i).condition(), stepResults)) {
continue;
}
if (i == failAt) {
stepErrors.put(i, new HranaError(BATCH_FAILURE, "SQLITE_CONSTRAINT"));
} else {
executed.add(steps.get(i).stmt().sql());
stepResults.put(i, new StmtResult(List.of(), List.of(), 1, null));
}
}
return new BatchResult(stepResults, stepErrors);
}

private static boolean conditionHolds(BatchCond condition, Map<Integer, StmtResult> stepResults) {
if (condition == null) {
return true;
}
if (condition instanceof BatchCond.StepOk ok) {
return stepResults.containsKey(ok.step());
}
throw new AssertionError("Unexpected batch condition: " + condition);
}

private static PipelineRespBody responseWithMissingBatchStep(PipelineReqBody request) {
List<StreamResult> results = new ArrayList<>();
for (StreamRequest streamRequest : request.requests()) {
if (streamRequest instanceof StreamRequest.BatchReq) {
Map<Integer, StmtResult> stepResults = new HashMap<>();
stepResults.put(0, new StmtResult(List.of(), List.of(), 1, null));
results.add(new StreamResult.Ok(new StreamResponse.BatchResp(
new BatchResult(stepResults, Map.of()))));
} else {
results.add(new StreamResult.Ok(StreamResponse.Close.INSTANCE));
}
}
return new PipelineRespBody(null, null, results);
}

private static void assertCommandRetriedOnSameStream(ScriptedClient client, String command) {
List<PipelineReqBody> commandRequests = client.requests.stream()
.filter(request -> command.equals(executedSql(request)))
Expand Down
Loading