diff --git a/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java b/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java index 16a26c5..3894b96 100644 --- a/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java +++ b/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java @@ -1,6 +1,7 @@ package dev.sqlcj.analysis; import dev.sqlcj.parser.Query; +import dev.sqlcj.parser.QueryType; import dev.sqlcj.schema.Schema; import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.JdbcParameter; @@ -18,7 +19,9 @@ import net.sf.jsqlparser.statement.select.PlainSelect; import net.sf.jsqlparser.statement.select.Select; import net.sf.jsqlparser.statement.select.SelectItem; +import net.sf.jsqlparser.statement.select.Values; import net.sf.jsqlparser.statement.update.Update; +import net.sf.jsqlparser.statement.update.UpdateSet; import java.util.ArrayList; import java.util.Comparator; @@ -34,16 +37,16 @@ public QueryModel analyze(Query query, Statement statement, Schema schema) { return analyzeSelect(query, select, schema); } - if (statement instanceof Insert) { - throw new UnsupportedOperationException("INSERT is not supported yet"); + if (statement instanceof Insert insert) { + return analyzeInsert(query, insert, schema); } - if (statement instanceof Update) { - throw new UnsupportedOperationException("UPDATE is not supported yet"); + if (statement instanceof Update update) { + return analyzeUpdate(query, update, schema); } - if (statement instanceof Delete) { - throw new UnsupportedOperationException("DELETE is not supported yet"); + if (statement instanceof Delete delete) { + return analyzeDelete(query, delete, schema); } throw new UnsupportedOperationException( @@ -63,6 +66,167 @@ private QueryModel analyzeSelect(Query query, Select select, Schema schema) { List bindingParameters = resolveBindingParameters(plainSelect, schema, table); + return toQueryModel( + query, + table.getUnquotedName(), + columns, + bindingParameters + ); + } + + private QueryModel analyzeInsert(Query query, Insert insert, Schema schema) { + requireExecQueryType(query); + + Table table = insert.getTable(); + dev.sqlcj.schema.Table schemaTable = findTable(schema, table.getUnquotedName()); + + return toQueryModel( + query, + table.getUnquotedName(), + List.of(), + resolveInsertParameters(insert, schemaTable) + ); + } + + private QueryModel analyzeUpdate(Query query, Update update, Schema schema) { + requireExecQueryType(query); + + Table table = update.getTable(); + dev.sqlcj.schema.Table schemaTable = findTable(schema, table.getUnquotedName()); + + List bindingParameters = resolveUpdateSetParameters(update, schemaTable); + + if (update.getWhere() != null) { + resolveParameters(update.getWhere(), schemaTable, bindingParameters); + } + + return toQueryModel( + query, + table.getUnquotedName(), + List.of(), + bindingParameters + ); + } + + private QueryModel analyzeDelete(Query query, Delete delete, Schema schema) { + requireExecQueryType(query); + + Table table = delete.getTable(); + dev.sqlcj.schema.Table schemaTable = findTable(schema, table.getUnquotedName()); + + List bindingParameters = new ArrayList<>(); + + if (delete.getWhere() != null) { + resolveParameters(delete.getWhere(), schemaTable, bindingParameters); + } + + return toQueryModel( + query, + table.getUnquotedName(), + List.of(), + bindingParameters + ); + } + + private void requireExecQueryType(Query query) { + if (query.type() != QueryType.EXEC) { + throw new UnsupportedOperationException( + "Write queries must be declared as :exec" + ); + } + } + + /** + * Resolves the {@code INSERT} parameters by pairing the explicit column + * list with the single values row, which is also their textual order. + */ + private List resolveInsertParameters(Insert insert, dev.sqlcj.schema.Table table) { + ExpressionList columns = insert.getColumns(); + + if (columns == null || columns.isEmpty()) { + throw new UnsupportedOperationException("INSERT requires an explicit column list."); + } + + ParenthesedExpressionList values = resolveInsertValues(insert); + + if (values.size() != columns.size()) { + throw new UnsupportedOperationException( + "INSERT column and value counts must match." + ); + } + + List parameters = new ArrayList<>(); + + for (int index = 0; index < columns.size(); index++) { + if (!(values.get(index) instanceof JdbcParameter parameter)) { + throw new UnsupportedOperationException( + "INSERT values must be indexed placeholders." + ); + } + + addParameter( + parameter, + columns.get(index).getUnquotedColumnName(), + table, + parameters + ); + } + + return parameters; + } + + private ParenthesedExpressionList resolveInsertValues(Insert insert) { + Values values = insert.getValues(); + + if (values == null || !(values.getExpressions() instanceof ParenthesedExpressionList row)) { + throw new UnsupportedOperationException( + "INSERT requires a single VALUES row." + ); + } + + return row; + } + + /** + * Resolves the {@code UPDATE} assignment parameters in source order, which + * precedes any parameter in the {@code WHERE} expression. + */ + private List resolveUpdateSetParameters(Update update, dev.sqlcj.schema.Table table) { + List parameters = new ArrayList<>(); + + for (UpdateSet updateSet : update.getUpdateSets()) { + if ( + updateSet.getColumns().size() != 1 + || updateSet.getValues().size() != 1 + || !(updateSet.getValue(0) instanceof JdbcParameter parameter) + ) { + throw new UnsupportedOperationException( + "UPDATE assignments must set one column to an indexed placeholder." + ); + } + + addParameter( + parameter, + updateSet.getColumn(0).getUnquotedColumnName(), + table, + parameters + ); + } + + return parameters; + } + + /** + * Builds the analyzed model from parameters collected in textual order, + * keeping that order for JDBC binding and exposing the parameters in + * logical placeholder-index order. + */ + private QueryModel toQueryModel( + Query query, + String tableName, + List columns, + List bindingParameters + ) { List bindingParameterIndexes = bindingParameters.stream() .map(QueryParameter::index) .toList(); @@ -74,7 +238,7 @@ private QueryModel analyzeSelect(Query query, Select select, Schema schema) { return new QueryModel( query.name(), query.type(), - table.getUnquotedName(), + tableName, toExecutableSql(query.sql()), bindingParameterIndexes, columns, diff --git a/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java b/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java index 7558a6d..97160ee 100644 --- a/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java +++ b/src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java @@ -17,6 +17,7 @@ import java.util.Set; import java.util.stream.Collectors; import java.util.stream.IntStream; +import java.util.stream.Stream; public final class JavaCodeGenerator implements CodeGenerator { @@ -85,6 +86,9 @@ private String generateImports(QueryModel query) { if (hasResult(query)) { imports.add("dev.sqlcj.runtime.RowMapper"); + } + + if (hasResult(query) || isExec(query)) { imports.add("java.util.List"); } @@ -110,6 +114,15 @@ private boolean hasResult(QueryModel query) { || query.type() == QueryType.MANY; } + private boolean isExec(QueryModel query) { + return query.type() == QueryType.EXEC; + } + + /** A write query has no generated result record. */ + private boolean hasResultType(QueryModel query) { + return !isExec(query); + } + private String resolveImport(ColumnType type) { return switch (type) { case DATE -> "java.time.LocalDate"; @@ -137,31 +150,26 @@ private String generateJavaDoc(QueryModel query) { } private String generateClass(QueryModel query, JavaNames names) { - String rowMapper = hasResult(query) - ? indent(generateRowMapper(query, names)) - : ""; + String members = Stream.of( + generateExecutorField(), + generateConstructor(names), + hasResultType(query) ? generateResultType(query, names) : "", + hasResult(query) ? generateRowMapper(query, names) : "", + generateMethod(query, names) + ) + .filter(member -> !member.isBlank()) + .map(this::indent) + .collect(Collectors.joining("\n\n")); return """ public final class %s { - %s - - %s - - %s - - %s - %s } """ .formatted( names.className(), - indent(generateExecutorField()), - indent(generateConstructor(names)), - indent(generateResultType(query, names)), - rowMapper, - indent(generateMethod(query, names)) + members ); } @@ -218,7 +226,8 @@ private String generateMethodParameters(QueryModel query, JavaNames names) { private String generateMethod(QueryModel query, JavaNames names) { return switch (query.type()) { case ONE, MANY -> generateQueryMethod(query, names); - case EXEC, EXEC_RESULT, BATCH_EXEC, BATCH_MANY, BATCH_ONE -> generateUnsupportedMethod(query, names); + case EXEC -> generateExecMethod(query, names); + case EXEC_RESULT, BATCH_EXEC, BATCH_MANY, BATCH_ONE -> generateUnsupportedMethod(query, names); }; } @@ -242,6 +251,24 @@ private String generateQueryMethod(QueryModel query, JavaNames names) { ); } + private String generateExecMethod(QueryModel query, JavaNames names) { + return """ + public %s %s(%s) { + return executor.execute( + %s, + %s + ); + } + """ + .formatted( + generateReturnType(query, names), + names.methodName(), + generateMethodParameters(query, names), + generateSql(query), + generateParameterList(query, names) + ); + } + private String generateUnsupportedMethod(QueryModel query, JavaNames names) { return """ public %s %s(%s) { @@ -351,7 +378,7 @@ private String generateReturnType(QueryModel query, JavaNames names) { return switch (query.type()) { case ONE -> names.resultTypeName(); case MANY -> "List<" + names.resultTypeName() + ">"; - case EXEC -> "void"; + case EXEC -> "int"; case EXEC_RESULT -> "void"; case BATCH_EXEC -> "void"; case BATCH_MANY -> "void"; diff --git a/src/main/java/dev/sqlcj/runtime/JdbcQueryExecutor.java b/src/main/java/dev/sqlcj/runtime/JdbcQueryExecutor.java index 95eb55a..de6d20f 100644 --- a/src/main/java/dev/sqlcj/runtime/JdbcQueryExecutor.java +++ b/src/main/java/dev/sqlcj/runtime/JdbcQueryExecutor.java @@ -64,6 +64,23 @@ public List queryMany(String sql, List parameters, RowMapper mapper } } + @Override + public int execute(String sql, List parameters) { + try ( + Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql) + ) { + bindParameters(statement, parameters); + + return statement.executeUpdate(); + } catch (SQLException e) { + throw new QueryExecutionException( + "Failed to execute query", + e + ); + } + } + private void bindParameters(PreparedStatement statement, List parameters) throws SQLException { for (int i = 0; i < parameters.size(); i++) { statement.setObject(i + 1, parameters.get(i)); diff --git a/src/main/java/dev/sqlcj/runtime/QueryExecutor.java b/src/main/java/dev/sqlcj/runtime/QueryExecutor.java index 7448615..8004646 100644 --- a/src/main/java/dev/sqlcj/runtime/QueryExecutor.java +++ b/src/main/java/dev/sqlcj/runtime/QueryExecutor.java @@ -7,4 +7,9 @@ public interface QueryExecutor { T query(String sql, List parameters, RowMapper mapper); List queryMany(String sql, List parameters, RowMapper mapper); + + /** + * Executes a write statement and returns its affected-row count. + */ + int execute(String sql, List parameters); } diff --git a/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java b/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java index 69b9879..17b85bc 100644 --- a/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java +++ b/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java @@ -108,62 +108,172 @@ void shouldAnalyzeSelectWithMultipleParameters() { } @Test - void shouldRejectInsertStatements() { + void shouldAnalyzeInsert() { Query query = new Query( "InsertUser", QueryType.EXEC, """ - INSERT INTO users(id) - VALUES ($1); + INSERT INTO users (id, name) + VALUES ($1, $2) """ ); - Statement statement = parser.parse(query.sql()); + QueryModel model = analyzer.analyze( + query, + parser.parse(query.sql()), + schema + ); - UnsupportedOperationException exception = assertThrows( - UnsupportedOperationException.class, - () -> analyzer.analyze(query, statement, schema) + assertEquals("InsertUser", model.name()); + assertEquals(QueryType.EXEC, model.type()); + assertEquals("users", model.table()); + assertTrue(model.columns().isEmpty()); + + assertEquals( + """ + INSERT INTO users (id, name) + VALUES (?, ?) + """, + model.executableSql() ); assertEquals( - "INSERT is not supported yet", - exception.getMessage() + List.of( + new QueryParameter(1, "id", ColumnType.BIGINT), + new QueryParameter(2, "name", ColumnType.VARCHAR) + ), + model.parameters() + ); + + assertEquals( + List.of(1, 2), + model.bindingParameterIndexes() ); } @Test - void shouldRejectUpdateStatements() { + void shouldAnalyzeUpdate() { Query query = new Query( "UpdateUser", QueryType.EXEC, """ UPDATE users - SET username = $2 - WHERE id = $1; + SET name = $2, + active = $3 + WHERE id = $1 """ ); - Statement statement = parser.parse(query.sql()); + QueryModel model = analyzer.analyze( + query, + parser.parse(query.sql()), + schema + ); - UnsupportedOperationException exception = assertThrows( - UnsupportedOperationException.class, - () -> analyzer.analyze(query, statement, schema) + assertEquals("UpdateUser", model.name()); + assertEquals(QueryType.EXEC, model.type()); + assertEquals("users", model.table()); + assertTrue(model.columns().isEmpty()); + + assertEquals( + """ + UPDATE users + SET name = ?, + active = ? + WHERE id = ? + """, + model.executableSql() ); assertEquals( - "UPDATE is not supported yet", - exception.getMessage() + List.of( + new QueryParameter(1, "id", ColumnType.BIGINT), + new QueryParameter(2, "name", ColumnType.VARCHAR), + new QueryParameter(3, "active", ColumnType.BOOLEAN) + ), + model.parameters() + ); + + assertEquals( + List.of(2, 3, 1), + model.bindingParameterIndexes() ); } @Test - void shouldRejectDeleteStatements() { + void shouldAnalyzeDelete() { Query query = new Query( "DeleteUser", QueryType.EXEC, """ - DELETE users - WHERE id = $1; + DELETE FROM users + WHERE id = $1 + AND active = $2 + """ + ); + + QueryModel model = analyzer.analyze( + query, + parser.parse(query.sql()), + schema + ); + + assertEquals("DeleteUser", model.name()); + assertEquals(QueryType.EXEC, model.type()); + assertEquals("users", model.table()); + assertTrue(model.columns().isEmpty()); + + assertEquals( + """ + DELETE FROM users + WHERE id = ? + AND active = ? + """, + model.executableSql() + ); + + assertEquals( + List.of( + new QueryParameter(1, "id", ColumnType.BIGINT), + new QueryParameter(2, "active", ColumnType.BOOLEAN) + ), + model.parameters() + ); + + assertEquals( + List.of(1, 2), + model.bindingParameterIndexes() + ); + } + + @Test + void shouldAnalyzeDeleteWithoutWhere() { + Query query = new Query( + "DeleteUsers", + QueryType.EXEC, + "DELETE FROM users" + ); + + QueryModel model = analyzer.analyze( + query, + parser.parse(query.sql()), + schema + ); + + assertEquals("users", model.table()); + assertTrue(model.columns().isEmpty()); + assertTrue(model.parameters().isEmpty()); + assertTrue(model.bindingParameterIndexes().isEmpty()); + } + + @Test + void shouldRejectWriteWithoutExecQueryType() { + Query query = new Query( + "InsertUser", + QueryType.ONE, + """ + INSERT INTO users (id) + VALUES ($1) """ ); @@ -175,7 +285,7 @@ void shouldRejectDeleteStatements() { ); assertEquals( - "DELETE is not supported yet", + "Write queries must be declared as :exec", exception.getMessage() ); } diff --git a/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java b/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java index 3166172..01cd158 100644 --- a/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java +++ b/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java @@ -28,6 +28,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -628,6 +629,157 @@ void shouldExecuteGeneratedQueryWithoutParameters() throws Exception { } } + @Test + void shouldExecuteGeneratedInsert() throws Exception { + Path classesDirectory = generateAndCompile( + """ + -- name: InsertUser :exec + INSERT INTO users (id, name, active) + VALUES ($1, $2, $3); + """, + "InsertUser" + ); + + String source = Files.readString(tempDir.resolve("generated/generated/InsertUser.java")); + + assertTrue( + source.contains( + "public int insertUser(Long id, String name, Boolean active)" + ) + ); + + assertTrue(source.contains("return executor.execute(")); + assertTrue(source.contains("List.of(id, name, active)")); + assertFalse(source.contains("public record InsertUserResult(")); + assertFalse(source.contains("RowMapper")); + + QueryExecutor executor = new JdbcQueryExecutor(usersDataSource()); + + try (URLClassLoader classLoader = classLoader(classesDirectory)) { + Class generatedClass = Class.forName( + "generated.InsertUser", + true, + classLoader + ); + + Object generatedQuery = generatedClass + .getConstructor(QueryExecutor.class) + .newInstance(executor); + + Object affected = generatedClass + .getMethod("insertUser", Long.class, String.class, Boolean.class) + .invoke(generatedQuery, 3L, "Carol", true); + + assertEquals(1, affected); + + assertEquals( + "Carol", + executor.query( + "SELECT name FROM users WHERE id = ?", + List.of(3L), + resultSet -> resultSet.getString("name") + ) + ); + } + } + + @Test + void shouldExecuteGeneratedUpdateWithOutOfOrderPlaceholders() throws Exception { + Path classesDirectory = generateAndCompile( + """ + -- name: UpdateUserName :exec + UPDATE users + SET name = $2 + WHERE id = $1; + """, + "UpdateUserName" + ); + + String source = Files.readString(tempDir.resolve("generated/generated/UpdateUserName.java")); + + assertTrue( + source.contains( + "public int updateUserName(Long id, String name)" + ) + ); + + assertTrue(source.contains("List.of(name, id)")); + + QueryExecutor executor = new JdbcQueryExecutor(usersDataSource()); + + try (URLClassLoader classLoader = classLoader(classesDirectory)) { + Class generatedClass = Class.forName( + "generated.UpdateUserName", + true, + classLoader + ); + + Object generatedQuery = generatedClass + .getConstructor(QueryExecutor.class) + .newInstance(executor); + + Object affected = generatedClass + .getMethod("updateUserName", Long.class, String.class) + .invoke(generatedQuery, 1L, "Alicia"); + + assertEquals(1, affected); + + assertEquals( + "Alicia", + executor.query( + "SELECT name FROM users WHERE id = ?", + List.of(1L), + resultSet -> resultSet.getString("name") + ) + ); + } + } + + @Test + void shouldExecuteGeneratedDelete() throws Exception { + Path classesDirectory = generateAndCompile( + """ + -- name: DeleteUser :exec + DELETE FROM users + WHERE id = $1; + """, + "DeleteUser" + ); + + String source = Files.readString(tempDir.resolve("generated/generated/DeleteUser.java")); + + assertTrue(source.contains("public int deleteUser(Long id)")); + assertTrue(source.contains("List.of(id)")); + + QueryExecutor executor = new JdbcQueryExecutor(usersDataSource()); + + try (URLClassLoader classLoader = classLoader(classesDirectory)) { + Class generatedClass = Class.forName( + "generated.DeleteUser", + true, + classLoader + ); + + Object generatedQuery = generatedClass + .getConstructor(QueryExecutor.class) + .newInstance(executor); + + Object affected = generatedClass + .getMethod("deleteUser", Long.class) + .invoke(generatedQuery, 2L); + + assertEquals(1, affected); + + assertNull( + executor.query( + "SELECT name FROM users WHERE id = ?", + List.of(2L), + resultSet -> resultSet.getString("name") + ) + ); + } + } + @Test void shouldCompileConfiguredEntriesWithConfiguredPackage() throws IOException { Path usersSchema = tempDir.resolve("users-schema.sql"); diff --git a/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorNamingTest.java b/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorNamingTest.java index 829f271..93ec361 100644 --- a/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorNamingTest.java +++ b/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorNamingTest.java @@ -275,6 +275,13 @@ public List queryMany(String sql, List parameters, RowMapper mapper return List.of(); } + + @Override + public int execute(String sql, List parameters) { + this.sql = sql; + + return 0; + } } private QueryModel query(String name, List columns, List parameters) { diff --git a/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorTest.java b/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorTest.java index 939e1ea..a9df862 100644 --- a/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorTest.java +++ b/src/test/java/dev/sqlcj/generator/JavaCodeGeneratorTest.java @@ -1251,6 +1251,45 @@ void shouldGenerateEmptyArgumentListForQueryWithoutParameters() throws IOExcepti assertCompiles(file); } + @Test + void shouldGenerateExecMethodForWriteQuery() throws IOException { + QueryModel query = new QueryModel( + "InsertUser", + QueryType.EXEC, + "users", + """ + INSERT INTO users (id, name) + VALUES (?, ?) + """, + List.of(1, 2), + List.of(), + List.of( + new QueryParameter(1, "id", ColumnType.BIGINT), + new QueryParameter(2, "name", ColumnType.VARCHAR) + ) + ); + + GeneratedFile file = codeGenerator.generate(query); + + String source = file.content(); + + assertTrue( + source.contains( + "public int insertUser(Long id, String name)" + ) + ); + + assertTrue(source.contains("return executor.execute(")); + assertTrue(source.contains("INSERT INTO users (id, name)")); + assertTrue(source.contains("List.of(id, name)")); + + assertFalse(source.contains("public record InsertUserResult(")); + assertFalse(source.contains("RowMapper")); + assertFalse(source.contains("UnsupportedOperationException")); + + assertCompiles(file); + } + private void assertCompiles(GeneratedFile file) throws IOException { Path sourceDirectory = tempDir.resolve("generated"); Path outputDirectory = tempDir.resolve("classes"); diff --git a/src/test/java/dev/sqlcj/runtime/JdbcQueryExecutorTest.java b/src/test/java/dev/sqlcj/runtime/JdbcQueryExecutorTest.java index a6ff181..1695bc0 100644 --- a/src/test/java/dev/sqlcj/runtime/JdbcQueryExecutorTest.java +++ b/src/test/java/dev/sqlcj/runtime/JdbcQueryExecutorTest.java @@ -172,6 +172,42 @@ void shouldReturnNullWhenQueryFindsNoRows() { assertNull(result); } + @Test + void shouldReturnAffectedRowCountForExecute() { + int affected = executor.execute( + """ + UPDATE users + SET name = ? + WHERE id = ? + """, + List.of("Alicia", 1L) + ); + + assertEquals(1, affected); + + String name = executor.query( + "SELECT name FROM users WHERE id = ?", + List.of(1L), + resultSet -> resultSet.getString("name") + ); + + assertEquals("Alicia", name); + } + + @Test + void shouldWrapSqlExceptionForExecute() { + QueryExecutionException exception = assertThrows( + QueryExecutionException.class, + () -> executor.execute( + "DELETE FROM missing_table", + List.of() + ) + ); + + assertEquals("Failed to execute query", exception.getMessage()); + assertInstanceOf(SQLException.class, exception.getCause()); + } + @Test void shouldWrapSqlException() { QueryExecutionException exception = assertThrows(