From bb3a8ff372506fa22c1fa78fd4acbb0716c01fe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Mon, 21 Sep 2026 22:54:11 +0300 Subject: [PATCH] feat(analysis): support typed PostgreSQL returning writes --- .../dev/sqlcj/analysis/QueryAnalyzer.java | 190 ++++++++++++- .../dev/sqlcj/analysis/QueryAnalyzerTest.java | 256 +++++++++++++++++- .../compiler/PostgresIntegrationTest.java | 199 ++++++++++++++ .../SqlcjCompilerIntegrationTest.java | 113 ++++++++ 4 files changed, 742 insertions(+), 16 deletions(-) diff --git a/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java b/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java index 535e557..e887024 100644 --- a/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java +++ b/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java @@ -110,26 +110,46 @@ private QueryModel analyzeSelect(Query query, ParsedSql parsedSql, Select select } private QueryModel analyzeInsert(Query query, ParsedSql parsedSql, Insert insert, Schema schema) { - requireExecWrite(query, insert.getReturningClause()); + ReturningClause returningClause = insert.getReturningClause(); + + requireWriteQueryType(query, returningClause); Table table = insert.getTable(); - dev.sqlcj.schema.Table schemaTable = findTable(schema, table.getUnquotedName()); + Source source = toSource(table, schema); + + List columns = List.of(); + + if (returningClause != null) { + requireSupportedReturningInsert(insert); + + columns = resolveReturningColumns(returningClause, source); + } return toQueryModel( query, parsedSql, table.getUnquotedName(), - List.of(), - resolveInsertParameters(insert, schemaTable) + columns, + resolveInsertParameters(insert, source.table()) ); } private QueryModel analyzeUpdate(Query query, ParsedSql parsedSql, Update update, Schema schema) { - requireExecWrite(query, update.getReturningClause()); + ReturningClause returningClause = update.getReturningClause(); + + requireWriteQueryType(query, returningClause); Table table = update.getTable(); Source source = toSource(table, schema); + List columns = List.of(); + + if (returningClause != null) { + requireSupportedReturningUpdate(update); + + columns = resolveReturningColumns(returningClause, source); + } + List bindingParameters = resolveUpdateSetParameters(update, source.table()); if (update.getWhere() != null) { @@ -140,17 +160,27 @@ private QueryModel analyzeUpdate(Query query, ParsedSql parsedSql, Update update query, parsedSql, table.getUnquotedName(), - List.of(), + columns, bindingParameters ); } private QueryModel analyzeDelete(Query query, ParsedSql parsedSql, Delete delete, Schema schema) { - requireExecWrite(query, delete.getReturningClause()); + ReturningClause returningClause = delete.getReturningClause(); + + requireWriteQueryType(query, returningClause); Table table = delete.getTable(); Source source = toSource(table, schema); + List columns = List.of(); + + if (returningClause != null) { + requireSupportedReturningDelete(delete); + + columns = resolveReturningColumns(returningClause, source); + } + List bindingParameters = new ArrayList<>(); if (delete.getWhere() != null) { @@ -161,7 +191,7 @@ private QueryModel analyzeDelete(Query query, ParsedSql parsedSql, Delete delete query, parsedSql, table.getUnquotedName(), - List.of(), + columns, bindingParameters ); } @@ -174,16 +204,150 @@ private void requireResultQueryType(Query query) { } } - private void requireExecWrite(Query query, ReturningClause returningClause) { - if (query.type() != QueryType.EXEC) { + /** + * Requires the annotation that matches the write shape: a write without + * {@code RETURNING} reports an affected-row count, and a returning write + * produces rows like a read. + */ + private void requireWriteQueryType(Query query, ReturningClause returningClause) { + if (returningClause == null) { + if (query.type() != QueryType.EXEC) { + throw new UnsupportedOperationException( + "Write queries without RETURNING must be declared as :exec" + ); + } + + return; + } + + if (query.type() != QueryType.ONE && query.type() != QueryType.MANY) { throw new UnsupportedOperationException( - "Write queries must be declared as :exec" + "Write queries with RETURNING must be declared as :one or :many" ); } + } - if (returningClause != null) { + /** + * Resolves the returned columns in declared order against the single write + * target, expanding a bare {@code *} in schema column order. + */ + private List resolveReturningColumns(ReturningClause returningClause, Source source) { + if ( + returningClause.getKeyword() != ReturningClause.Keyword.RETURNING + || returningClause.getDataItems() != null + ) { + throw new UnsupportedOperationException( + "Only a RETURNING clause without data items is supported." + ); + } + + List columns = new ArrayList<>(); + + for (SelectItem returningItem : returningClause) { + if (returningItem.getAlias() != null) { + throw new UnsupportedOperationException( + "RETURNING items must not be aliased." + ); + } + + columns.addAll( + resolveReturningItem(returningItem.getExpression(), source) + ); + } + + return List.copyOf(columns); + } + + /** + * Resolves one returned item, which is either a bare {@code *} or a direct + * column of the write target. A computed item has no schema type to + * generate, so it is rejected instead of analyzed. + */ + private List resolveReturningItem(Expression expression, Source source) { + if (expression instanceof AllTableColumns) { + throw new UnsupportedOperationException( + "Only an unqualified RETURNING * is supported." + ); + } + + if (expression instanceof AllColumns allColumns) { + if (allColumns.getExceptColumns() != null || allColumns.getReplaceExpressions() != null) { + throw new UnsupportedOperationException( + "RETURNING * must not be modified." + ); + } + + return resolveAllColumns(source); + } + + if (expression instanceof net.sf.jsqlparser.schema.Column column) { + dev.sqlcj.schema.Column schemaColumn = resolveColumn(column, List.of(source)).column(); + + return List.of( + new QueryColumn( + schemaColumn.name(), + schemaColumn.type(), + schemaColumn.nullable() + ) + ); + } + + throw new UnsupportedOperationException( + "Unsupported RETURNING expression: " + + expression.getClass().getSimpleName() + ); + } + + /** + * Rejects the {@code INSERT} forms whose returned rows this subset does not + * model, so a returning insert never silently analyzes a wider statement. + */ + private void requireSupportedReturningInsert(Insert insert) { + if (!(insert.getSelect() instanceof Values)) { + throw new UnsupportedOperationException( + "RETURNING requires an INSERT with a single VALUES row." + ); + } + + if (insert.getConflictTarget() != null || insert.getConflictAction() != null) { + throw new UnsupportedOperationException( + "INSERT ... ON CONFLICT is not supported." + ); + } + + requireNoCommonTableExpressions(insert.getWithItemsList()); + } + + private void requireSupportedReturningUpdate(Update update) { + if ( + update.getFromItem() != null + || update.getJoins() != null + || update.getStartJoins() != null + ) { + throw new UnsupportedOperationException( + "UPDATE ... FROM and joined updates are not supported." + ); + } + + requireNoCommonTableExpressions(update.getWithItemsList()); + } + + private void requireSupportedReturningDelete(Delete delete) { + boolean using = delete.getUsingList() != null && !delete.getUsingList().isEmpty(); + + if (using || delete.getJoins() != null) { + throw new UnsupportedOperationException( + "DELETE ... USING and joined deletes are not supported." + ); + } + + requireNoCommonTableExpressions(delete.getWithItemsList()); + } + + private void requireNoCommonTableExpressions(List withItems) { + if (withItems != null && !withItems.isEmpty()) { throw new UnsupportedOperationException( - "RETURNING is not supported" + "Common table expressions are not supported in a returning write." ); } } diff --git a/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java b/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java index 03b7785..280a6fa 100644 --- a/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java +++ b/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java @@ -10,6 +10,7 @@ import dev.sqlcj.sql.SqlParser; 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.ValueSource; import java.util.List; @@ -316,7 +317,7 @@ INSERT INTO users (id) ); assertEquals( - "Write queries must be declared as :exec", + "Write queries without RETURNING must be declared as :exec", exception.getMessage() ); } @@ -1597,7 +1598,7 @@ void shouldRejectSelectWithoutResultQueryType() { "DELETE FROM users WHERE id = $1 RETURNING id" } ) - void shouldRejectReturningWrite(String sql) { + void shouldRejectReturningWriteDeclaredAsExec(String sql) { Query query = new Query("WriteUser", QueryType.EXEC, sql); ParsedSql parsedSql = parser.parse(sql); @@ -1606,7 +1607,256 @@ void shouldRejectReturningWrite(String sql) { () -> analyzer.analyze(query, parsedSql, schema) ); - assertEquals("RETURNING is not supported", exception.getMessage()); + assertEquals( + "Write queries with RETURNING must be declared as :one or :many", + exception.getMessage() + ); + } + + @ParameterizedTest + @ValueSource( + strings = { + "INSERT INTO users (id) VALUES ($1) RETURNING id", + "UPDATE users SET name = $2 WHERE id = $1 RETURNING id", + "DELETE FROM users WHERE id = $1 RETURNING id" + } + ) + void shouldAnalyzeReturningWriteForBothResultQueryTypes(String sql) { + for (QueryType type : List.of(QueryType.ONE, QueryType.MANY)) { + QueryModel model = analyzer.analyze( + new Query("WriteUser", type, sql), + parser.parse(sql), + schema + ); + + assertEquals(type, model.type()); + assertEquals("users", model.table()); + + assertEquals( + List.of(new QueryColumn("id", ColumnType.BIGINT, false)), + model.columns() + ); + } + } + + @Test + void shouldAnalyzeInsertReturningColumnsInDeclaredOrder() { + Query query = new Query( + "InsertUser", + QueryType.ONE, + """ + INSERT INTO users (id, name) + VALUES ($1, $2) + RETURNING name, id + """ + ); + + QueryModel model = analyzer.analyze( + query, + parser.parse(query.sql()), + schema + ); + + assertEquals(QueryType.ONE, model.type()); + assertEquals("users", model.table()); + + assertEquals( + List.of( + new QueryColumn("name", ColumnType.VARCHAR, true), + new QueryColumn("id", ColumnType.BIGINT, false) + ), + model.columns() + ); + + assertEquals( + """ + INSERT INTO users (id, name) + VALUES (?, ?) + RETURNING name, id + """, + model.executableSql() + ); + + assertEquals( + 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 shouldExpandInsertReturningAllColumnsInSchemaOrder() { + Query query = new Query( + "InsertUser", + QueryType.ONE, + "INSERT INTO users (id) VALUES ($1) RETURNING *" + ); + + QueryModel model = analyzer.analyze( + query, + parser.parse(query.sql()), + schema + ); + + assertEquals( + List.of( + new QueryColumn("id", ColumnType.BIGINT, false), + new QueryColumn("name", ColumnType.VARCHAR, true), + new QueryColumn("active", ColumnType.BOOLEAN, true) + ), + model.columns() + ); + } + + @Test + void shouldAnalyzeUpdateReturningWithRepeatedAndOutOfOrderParameters() { + Query query = new Query( + "UpdateUser", + QueryType.ONE, + """ + UPDATE users + SET name = $2, + active = $3 + WHERE id = $1 + AND name = $2 + RETURNING active, id, name + """ + ); + + QueryModel model = analyzer.analyze( + query, + parser.parse(query.sql()), + schema + ); + + assertEquals( + List.of( + new QueryColumn("active", ColumnType.BOOLEAN, true), + new QueryColumn("id", ColumnType.BIGINT, false), + new QueryColumn("name", ColumnType.VARCHAR, true) + ), + model.columns() + ); + + assertEquals( + 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, 2), model.bindingParameterIndexes()); + } + + @Test + void shouldAnalyzeDeleteReturningAllColumns() { + Query query = new Query( + "DeleteUsers", + QueryType.MANY, + "DELETE FROM users WHERE active = $1 RETURNING *" + ); + + QueryModel model = analyzer.analyze( + query, + parser.parse(query.sql()), + schema + ); + + assertEquals(QueryType.MANY, model.type()); + + assertEquals( + List.of( + new QueryColumn("id", ColumnType.BIGINT, false), + new QueryColumn("name", ColumnType.VARCHAR, true), + new QueryColumn("active", ColumnType.BOOLEAN, true) + ), + model.columns() + ); + + assertEquals( + List.of(new QueryParameter(1, "active", ColumnType.BOOLEAN)), + model.parameters() + ); + } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "INSERT INTO users (id) VALUES ($1) RETURNING id AS user_id" + + "|RETURNING items must not be aliased.", + "INSERT INTO users (id) VALUES ($1) RETURNING id + 1" + + "|Unsupported RETURNING expression: Addition", + "INSERT INTO users (id) VALUES ($1) RETURNING users.*" + + "|Only an unqualified RETURNING * is supported.", + "DELETE FROM users WHERE id = $1 RETURNING count(id)" + + "|Unsupported RETURNING expression: Function" + } + ) + void shouldRejectUnsupportedReturningItem(String sql, String message) { + Query query = new Query("WriteUser", QueryType.ONE, sql); + ParsedSql parsedSql = parser.parse(sql); + + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> analyzer.analyze(query, parsedSql, schema) + ); + + assertEquals(message, exception.getMessage()); + } + + @Test + void shouldRejectUnknownReturningColumn() { + String sql = "INSERT INTO users (id) VALUES ($1) RETURNING missing"; + + Query query = new Query("InsertUser", QueryType.ONE, sql); + ParsedSql parsedSql = parser.parse(sql); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> analyzer.analyze(query, parsedSql, schema) + ); + + assertEquals( + "Column not found in sources users: missing", + exception.getMessage() + ); + } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING id" + + "|INSERT ... ON CONFLICT is not supported.", + "INSERT INTO users (id) SELECT id FROM users RETURNING id" + + "|RETURNING requires an INSERT with a single VALUES row.", + "INSERT INTO users (id) VALUES ($1), ($2) RETURNING id" + + "|INSERT requires a single VALUES row.", + "WITH known AS (SELECT id FROM users) INSERT INTO users (id) VALUES ($1) RETURNING id" + + "|Common table expressions are not supported in a returning write.", + "UPDATE users SET name = $2 FROM profiles WHERE users.id = $1 RETURNING id" + + "|UPDATE ... FROM and joined updates are not supported.", + "DELETE FROM users USING profiles WHERE users.id = $1 RETURNING id" + + "|DELETE ... USING and joined deletes are not supported." + } + ) + void shouldRejectExcludedReturningWriteForm(String sql, String message) { + Query query = new Query("WriteUser", QueryType.ONE, sql); + ParsedSql parsedSql = parser.parse(sql); + + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> analyzer.analyze(query, parsedSql, schema) + ); + + assertEquals(message, exception.getMessage()); } @Test diff --git a/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java b/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java index 527a720..319cfa1 100644 --- a/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java +++ b/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java @@ -36,6 +36,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Set; import java.util.UUID; import java.util.stream.Stream; @@ -527,6 +528,204 @@ void shouldExecuteGeneratedQueryForSnapshotWithIgnoredTableConstraints() throws } } + /** + * Covers a returning insert: the database-generated serial values and the + * target-table order of {@code RETURNING *} are read through the generated + * typed result record. + */ + @Test + void shouldExecuteGeneratedInsertReturningAgainstPostgres() throws Exception { + Path classesDirectory = generateAndCompile(""" + -- name: InsertUserReturningSerialId :one + INSERT INTO users (id, code, name) + VALUES ($1, $2, $3) + RETURNING serial_id; + + -- name: InsertUserReturningRow :one + INSERT INTO users (id, code, name) + VALUES ($1, $2, $3) + RETURNING *; + """); + + try (URLClassLoader classLoader = classLoader(classesDirectory)) { + Object insertSerialId = newQuery(classLoader, "InsertUserReturningSerialId"); + + Method serialIdMethod = insertSerialId.getClass().getMethod( + "insertUserReturningSerialId", + Long.class, + Integer.class, + String.class + ); + + Object serialIdResult = serialIdMethod.invoke(insertSerialId, 1L, 42, "Alice"); + + assertNotNull(serialIdResult); + + assertEquals(List.of("serial_id"), recordComponentNames(serialIdResult)); + assertEquals(1, component(serialIdResult, "serial_id")); + + Object insertRow = newQuery(classLoader, "InsertUserReturningRow"); + + Method rowMethod = insertRow.getClass().getMethod( + "insertUserReturningRow", + Long.class, + Integer.class, + String.class + ); + + Object row = rowMethod.invoke(insertRow, 2L, 43, "Bob"); + + assertNotNull(row); + + assertEquals( + List.of( + "id", + "code", + "score", + "name", + "bio", + "active", + "birth_date", + "created_at", + "balance", + "serial_id", + "revision", + "external_id", + "updated_at" + ), + recordComponentNames(row) + ); + + assertEquals(2L, component(row, "id")); + assertEquals(43, component(row, "code")); + assertEquals("Bob", component(row, "name")); + assertEquals(2, component(row, "serial_id")); + assertEquals(2L, component(row, "revision")); + assertNull(component(row, "score")); + assertNull(component(row, "bio")); + assertNull(component(row, "external_id")); + } + } + + /** + * Covers a returning update: the repeated and out-of-order placeholders are + * bound in textual order, the returned columns keep their declared order, + * and a no-row update produces the {@code :one} null result. + */ + @Test + void shouldExecuteGeneratedUpdateReturningAgainstPostgres() throws Exception { + Path classesDirectory = generateAndCompile(""" + -- name: RenameUser :one + UPDATE users + SET name = $2, + bio = $2 + WHERE id = $1 + AND code = $3 + RETURNING bio, id, name, score; + """); + + execute(""" + INSERT INTO users (id, code, name, bio) + VALUES (1, 42, 'Alice', 'first user') + """); + + try (URLClassLoader classLoader = classLoader(classesDirectory)) { + Object update = newQuery(classLoader, "RenameUser"); + + Method method = update.getClass().getMethod( + "renameUser", + Long.class, + String.class, + Integer.class + ); + + Object result = method.invoke(update, 1L, "Renamed", 42); + + assertNotNull(result); + + assertEquals( + List.of("bio", "id", "name", "score"), + recordComponentNames(result) + ); + + assertEquals("Renamed", component(result, "bio")); + assertEquals(1L, component(result, "id")); + assertEquals("Renamed", component(result, "name")); + assertNull(component(result, "score")); + + assertNull(method.invoke(update, 404L, "Missing", 42)); + } + } + + /** + * Covers a returning delete: every deleted row is returned to the + * {@code :many} result, and a delete that matches no row returns an empty + * list. PostgreSQL does not guarantee the returned row order, so the rows + * are compared as a set. + */ + @Test + void shouldExecuteGeneratedDeleteReturningAgainstPostgres() throws Exception { + Path classesDirectory = generateAndCompile(""" + -- name: DeleteUsersByActive :many + DELETE FROM users + WHERE active = $1 + RETURNING id, name; + """); + + execute(""" + INSERT INTO users (id, code, name, active) + VALUES + (1, 1, 'Alice', TRUE), + (2, 2, 'Bob', FALSE), + (3, 3, 'Carol', TRUE) + """); + + try (URLClassLoader classLoader = classLoader(classesDirectory)) { + Object delete = newQuery(classLoader, "DeleteUsersByActive"); + + Method method = delete.getClass().getMethod( + "deleteUsersByActive", + Boolean.class + ); + + List deleted = assertInstanceOf( + List.class, + method.invoke(delete, Boolean.TRUE) + ); + + assertEquals(2, deleted.size()); + + assertEquals( + List.of("id", "name"), + recordComponentNames(deleted.getFirst()) + ); + + List> rows = new ArrayList<>(); + + for (Object deletedRow : deleted) { + rows.add( + List.of( + component(deletedRow, "id"), + component(deletedRow, "name") + ) + ); + } + + assertEquals( + Set.of( + List.of(1L, "Alice"), + List.of(3L, "Carol") + ), + Set.copyOf(rows) + ); + + assertEquals( + List.of(), + method.invoke(delete, Boolean.TRUE) + ); + } + } + private Path generateAndCompile(String queries) throws Exception { return generateAndCompile(SCHEMA, queries); } diff --git a/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java b/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java index 325905b..6da1de0 100644 --- a/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java +++ b/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java @@ -1534,6 +1534,119 @@ void shouldGenerateCompilableJavaForQuotedSqlIdentifiers() throws IOException { ); } + /** + * A returning write reuses the typed result record, positional row mapper, + * and result-producing executor call of a read, so the generated Java for + * every returning write kind is verified by compiling it. + */ + @Test + void shouldGenerateCompilableJavaForReturningWrites() throws IOException { + Path schemaFile = tempDir.resolve("schema.sql"); + Path queriesFile = tempDir.resolve("queries.sql"); + Path generatedDirectory = tempDir.resolve("generated"); + Path classesDirectory = tempDir.resolve("classes"); + + Files.writeString( + schemaFile, + """ + CREATE TABLE users + ( + id BIGINT NOT NULL, + name VARCHAR(255), + active BOOLEAN + ); + """ + ); + + Files.writeString( + queriesFile, + """ + -- name: InsertUser :one + INSERT INTO users (id, name) + VALUES ($1, $2) + RETURNING *; + + -- name: UpdateUser :one + UPDATE users + SET name = $2 + WHERE id = $1 + RETURNING name, id; + + -- name: DeleteUsers :many + DELETE FROM users + WHERE active = $1 + RETURNING id, name; + """ + ); + + Config config = new Config( + List.of( + new SqlConfig( + schemaFile.toString(), + queriesFile.toString() + ) + ), + new JavaConfig( + generatedDirectory.toString(), + "generated" + ) + ); + + new SqlcjCompiler().compile(config); + + Path insertUserFile = generatedDirectory.resolve("generated/InsertUser.java"); + Path updateUserFile = generatedDirectory.resolve("generated/UpdateUser.java"); + Path deleteUsersFile = generatedDirectory.resolve("generated/DeleteUsers.java"); + + String insertUser = Files.readString(insertUserFile); + assertTrue(insertUser.contains("public record InsertUserResult(")); + assertTrue(insertUser.contains("Long id")); + assertTrue(insertUser.contains("String name")); + assertTrue(insertUser.contains("Boolean active")); + assertTrue(insertUser.contains("public InsertUserResult insertUser(Long id, String name)")); + assertTrue(insertUser.contains("return executor.query(")); + assertTrue(insertUser.contains("VALUES (?, ?)")); + assertTrue(insertUser.contains("RETURNING *")); + + String updateUser = Files.readString(updateUserFile); + assertTrue(updateUser.contains("public UpdateUserResult updateUser(Long id, String name)")); + assertTrue(updateUser.contains("resultSet.getObject(1, String.class)")); + assertTrue(updateUser.contains("resultSet.getObject(2, Long.class)")); + assertTrue(updateUser.contains("java.util.Arrays.asList(name, id)")); + assertTrue(updateUser.contains("RETURNING name, id")); + + String deleteUsers = Files.readString(deleteUsersFile); + assertTrue(deleteUsers.contains("public List deleteUsers(Boolean active)")); + assertTrue(deleteUsers.contains("return executor.queryMany(")); + assertTrue(deleteUsers.contains("RETURNING id, name")); + + Files.createDirectories(classesDirectory); + + JavaCompiler compilerApi = ToolProvider.getSystemJavaCompiler(); + + assertNotNull(compilerApi); + + assertEquals( + 0, + compilerApi.run( + null, + null, + null, + "-classpath", + System.getProperty("java.class.path"), + "-d", + classesDirectory.toString(), + insertUserFile.toString(), + updateUserFile.toString(), + deleteUsersFile.toString() + ) + ); + + assertTrue(Files.exists(classesDirectory.resolve("generated/InsertUser.class"))); + assertTrue(Files.exists(classesDirectory.resolve("generated/UpdateUser.class"))); + assertTrue(Files.exists(classesDirectory.resolve("generated/DeleteUsers.class"))); + } + private void compileUsersQueries(String queries, Path generatedDirectory) throws IOException { Path schemaFile = tempDir.resolve("schema.sql"); Path queriesFile = tempDir.resolve("queries.sql");