diff --git a/docs/queries.md b/docs/queries.md index 8b42104..5f49de8 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -525,21 +525,25 @@ collation or an operator class. A supported `INSERT`, `UPDATE`, or `DELETE` declared `:one`, `:optional`, or `:many` may end with a `RETURNING` clause that lists either: -- unaliased direct columns of the target table, in the order they are declared, - or +- direct columns of the target table, in the order they are declared, or - a bare `*`, which expands in the target table's schema column order. +A returned column may carry an alias, written as `id AS user_id` or `id user_id`, +which names its result component exactly as a `SELECT` item's alias does, while +its type and nullability still come from the schema column. + A returning write produces the same generated record, positional row mapper, and cardinality behavior as a read, so a database-generated `SERIAL` or `BIGSERIAL` value is read back with its declared type. A clause that is exactly `*` returns the target table's shared `Row` record, while a `RETURNING` column list keeps the query's own `Result` record. -An aliased or computed `RETURNING` item, a qualified `table.*`, an unknown -column, and `RETURNING` on `:exec` are rejected. Multi-row `VALUES`, -`INSERT ... SELECT`, `UPDATE ... FROM`, `DELETE ... USING`, and common table -expressions are rejected in a returning write, while an accepted `ON CONFLICT` -clause is analyzed the same way with and without `RETURNING`. +An aliased `*`, a computed `RETURNING` item with or without an alias, a +qualified `table.*`, an unknown column, and `RETURNING` on `:exec` are rejected. +Multi-row `VALUES`, `INSERT ... SELECT`, `UPDATE ... FROM`, `DELETE ... USING`, +and common table expressions are rejected in a returning write, while an +accepted `ON CONFLICT` clause is analyzed the same way with and without +`RETURNING`. ## Parameters @@ -917,7 +921,7 @@ name, and its header line. unqualified conflict-predicate column that both the inserted table and `EXCLUDED` carry, or a predicate column qualified with a name its own predicate does not read, such as `EXCLUDED` in a conflict-target predicate. -- An aliased, computed, qualified-wildcard, or unknown `RETURNING` item; +- An aliased `*`, a computed, qualified-wildcard, or unknown `RETURNING` item; `RETURNING` on `:exec`; and `UPDATE ... FROM`, `DELETE ... USING`, or a common table expression in a returning write. - Any annotation other than `:one`, `:optional`, `:many`, and `:exec`. @@ -1179,6 +1183,20 @@ Writes and `RETURNING`: `QueryAnalyzerTest.shouldRejectUnknownReturningColumn`, and `QueryAnalyzerTest.shouldRejectExcludedReturningWriteForm` cover the write shapes. +- `QueryAnalyzerTest.shouldNameInsertReturningColumnsFromTheirAliases`, + `QueryAnalyzerTest.shouldNameUpdateReturningColumnFromABareAlias`, + `QueryAnalyzerTest.shouldNameDeleteReturningColumnQualifiedThroughTheTargetAlias`, + and `QueryAnalyzerTest.shouldNameReturningColumnFromAQuotedAlias` cover an + aliased returned column on each write statement, both alias spellings, a + quoted alias, a column qualified through the target alias, and the schema + type, nullability, parameters, and binding order an alias leaves unchanged, + while `QueryAnalyzerTest.shouldRejectUnsupportedReturningItem` covers the + aliased `*` and aliased computed items that stay rejected. + `SqlcjCompilerIntegrationTest.shouldGenerateCompilableJavaForAliasedReturningWrites` + compiles result records named from the aliases, and + `PostgresIntegrationTest.shouldExecuteGeneratedAliasedReturningWritesAgainstPostgres` + executes the three aliased returning writes against PostgreSQL 16 and reads + the aliased components. - `QueryAnalyzerTest.shouldAnalyzeInsertWithNonBindingValues`, `QueryAnalyzerTest.shouldAnalyzeNamedInsertWithNonBindingValues`, `QueryAnalyzerTest.shouldAnalyzeReturningInsertWithNonBindingValues`, diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java index 6e2171e..2b50da3 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java @@ -511,14 +511,12 @@ private List resolveReturningColumns(ReturningClause returningClaus 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) + resolveReturningItem( + returningItem.getExpression(), + returningItem.getAlias(), + source + ) ); } @@ -544,10 +542,11 @@ private String resolveReturningRowTable(ReturningClause returningClause, Source /** * 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. + * column of the write target, whose alias names its result component. A + * computed item has no schema type to generate, so it is rejected instead + * of analyzed. */ - private List resolveReturningItem(Expression expression, Source source) { + private List resolveReturningItem(Expression expression, Alias alias, Source source) { if (expression instanceof AllTableColumns) { throw new UnsupportedOperationException( "Only an unqualified RETURNING * is supported." @@ -555,6 +554,12 @@ private List resolveReturningItem(Expression expression, Source sou } if (expression instanceof AllColumns allColumns) { + if (alias != null) { + throw new UnsupportedOperationException( + "RETURNING * must not be aliased." + ); + } + if (allColumns.getExceptColumns() != null || allColumns.getReplaceExpressions() != null) { throw new UnsupportedOperationException( "RETURNING * must not be modified." @@ -569,7 +574,7 @@ private List resolveReturningItem(Expression expression, Source sou return List.of( new QueryColumn( - schemaColumn.name(), + selectedColumnName(alias, schemaColumn), requireSupportedType(schemaColumn), schemaColumn.nullable(), schemaColumn.enumType(), diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java index dfc99bb..9ccb2a2 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java @@ -2447,6 +2447,7 @@ void shouldResolveRowTableForReturningAllColumns(String sql) { "SELECT *, id FROM users", "SELECT id, * FROM users", "INSERT INTO users (id) VALUES ($1) RETURNING id, name, active", + "INSERT INTO users (id) VALUES ($1) RETURNING id AS a, name AS b, active AS c", "DELETE FROM users WHERE id = $1 RETURNING id" } ) @@ -4273,16 +4274,139 @@ void shouldAnalyzeDeleteReturningAllColumns() { ); } + /** + * An alias on a returned column names its result component, as on a + * projected column, while its type and nullability stay the schema + * column's and the executed SQL keeps the alias. + */ + @Test + void shouldNameInsertReturningColumnsFromTheirAliases() { + Query query = new Query( + "InsertUser", + QueryType.ONE, + """ + INSERT INTO users (id, name) + VALUES ($1, $2) + RETURNING id AS user_id, name + """ + ); + + QueryModel model = analyzer.analyze( + query, + parser.parse(query.sql()), + schema + ); + + assertEquals( + List.of( + new QueryColumn("user_id", ColumnType.BIGINT, false), + new QueryColumn("name", ColumnType.VARCHAR, true) + ), + model.columns() + ); + + assertNull(model.rowTable()); + + assertEquals( + """ + INSERT INTO users (id, name) + VALUES (?, ?) + RETURNING id AS user_id, name + """, + 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 shouldNameUpdateReturningColumnFromABareAlias() { + String sql = "UPDATE users SET name = $2 WHERE id = $1 RETURNING id new_id"; + + QueryModel model = analyzer.analyze( + new Query("UpdateUser", QueryType.ONE, sql), + parser.parse(sql), + schema + ); + + assertEquals( + List.of(new QueryColumn("new_id", ColumnType.BIGINT, false)), + model.columns() + ); + + assertNull(model.rowTable()); + + assertEquals( + List.of( + new QueryParameter(1, "id", ColumnType.BIGINT), + new QueryParameter(2, "name", ColumnType.VARCHAR) + ), + model.parameters() + ); + + assertEquals(List.of(2, 1), model.bindingParameterIndexes()); + } + + @Test + void shouldNameDeleteReturningColumnQualifiedThroughTheTargetAlias() { + String sql = "DELETE FROM users u WHERE u.id = $1 RETURNING u.id AS uid"; + + QueryModel model = analyzer.analyze( + new Query("DeleteUser", QueryType.ONE, sql), + parser.parse(sql), + schema + ); + + assertEquals( + List.of(new QueryColumn("uid", ColumnType.BIGINT, false)), + model.columns() + ); + + assertNull(model.rowTable()); + } + + @Test + void shouldNameReturningColumnFromAQuotedAlias() { + String sql = """ + INSERT INTO users (id) VALUES ($1) RETURNING id AS "User Id" + """; + + QueryModel model = analyzer.analyze( + new Query("InsertUser", QueryType.ONE, sql), + parser.parse(sql), + schema + ); + + assertEquals( + List.of(new QueryColumn("User Id", ColumnType.BIGINT, false)), + model.columns() + ); + } + @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 * AS x" + + "|RETURNING * must not be aliased.", + "INSERT INTO users (id) VALUES ($1) RETURNING * x" + + "|RETURNING * must not be aliased.", "INSERT INTO users (id) VALUES ($1) RETURNING id + 1" + "|Unsupported RETURNING expression: Addition", + "INSERT INTO users (id) VALUES ($1) RETURNING id + 1 AS x" + + "|Unsupported RETURNING expression: Addition", "INSERT INTO users (id) VALUES ($1) RETURNING users.*" + "|Only an unqualified RETURNING * is supported.", + "INSERT INTO users (id) VALUES ($1) RETURNING users.* AS x" + + "|Only an unqualified RETURNING * is supported.", "DELETE FROM users WHERE id = $1 RETURNING count(id)" + "|Unsupported RETURNING expression: Function" } diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java index ec8bffa..6bb4295 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java @@ -3428,6 +3428,76 @@ INSERT INTO users (id, code, name, active) } } + /** + * Covers aliased {@code RETURNING} items end to end for the three write + * statements: PostgreSQL executes the alias, and the generated result + * record names its components from the alias instead of the schema + * column, while the values stay the written ones. + */ + @Test + void shouldExecuteGeneratedAliasedReturningWritesAgainstPostgres() throws Exception { + Path classesDirectory = generateAndCompile(""" + -- name: InsertUser :one + INSERT INTO users (id, code, name) + VALUES ($1, $2, $3) + RETURNING id AS user_id, name; + + -- name: RenameUser :one + UPDATE users + SET name = $2 + WHERE id = $1 + RETURNING id new_id, name AS full_name; + + -- name: DeleteUser :one + DELETE FROM users u + WHERE u.id = $1 + RETURNING u.id AS uid, u.code AS user_code; + """); + + try (URLClassLoader classLoader = classLoader(classesDirectory)) { + Object repository = newRepository(classLoader); + + Object inserted = repository + .getClass() + .getMethod("insertUser", Long.class, Integer.class, String.class) + .invoke(repository, 1L, 42, "Alice"); + + assertEquals( + List.of("userId", "name"), + recordComponentNames(inserted) + ); + + assertEquals(1L, component(inserted, "userId")); + assertEquals("Alice", component(inserted, "name")); + + Object renamed = repository + .getClass() + .getMethod("renameUser", Long.class, String.class) + .invoke(repository, 1L, "Renamed"); + + assertEquals( + List.of("newId", "fullName"), + recordComponentNames(renamed) + ); + + assertEquals(1L, component(renamed, "newId")); + assertEquals("Renamed", component(renamed, "fullName")); + + Object deleted = repository + .getClass() + .getMethod("deleteUser", Long.class) + .invoke(repository, 1L); + + assertEquals( + List.of("uid", "userCode"), + recordComponentNames(deleted) + ); + + assertEquals(1L, component(deleted, "uid")); + assertEquals(42, component(deleted, "userCode")); + } + } + /** * Covers the three result annotations over zero, one, and two matching rows * of a non-unique column, through both executor construction paths: a diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java index 834bb48..300faf0 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java @@ -2614,6 +2614,102 @@ INSERT INTO users (id, name) assertTrue(Files.exists(classesDirectory.resolve("generated/UsersRepository.class"))); } + /** + * An alias on a returned column names the result record's component, for + * every returning write kind, and the executed SQL keeps the alias. + */ + @Test + void shouldGenerateCompilableJavaForAliasedReturningWrites() 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 id AS user_id, name; + + -- name: UpdateUser :one + UPDATE users + SET name = $2 + WHERE id = $1 + RETURNING id new_id; + + -- name: DeleteUsers :many + DELETE FROM users u + WHERE u.active = $1 + RETURNING u.id AS uid, u.name AS full_name; + """ + ); + + Config config = new Config( + List.of( + new SqlConfig( + "Users", + schemaFile.toString(), + queriesFile.toString() + ) + ), + new JavaConfig( + generatedDirectory.toString(), + "generated" + ) + ); + + new SqlcjCompiler().compile(config); + + String repository = Files.readString( + generatedDirectory.resolve("generated/UsersRepository.java") + ); + + assertTrue(repository.contains("public InsertUserResult insertUser(Long id, String name)")); + + assertEquals( + List.of("Long userId", "String name"), + recordComponents(repository, "InsertUserResult") + ); + + assertTrue(repository.contains("RETURNING id AS user_id, name")); + + assertTrue(repository.contains("public UpdateUserResult updateUser(Long id, String name)")); + + assertEquals( + List.of("Long newId"), + recordComponents(repository, "UpdateUserResult") + ); + + assertTrue(repository.contains("RETURNING id new_id")); + + assertTrue(repository.contains("public List deleteUsers(Boolean active)")); + + assertEquals( + List.of("Long uid", "String fullName"), + recordComponents(repository, "DeleteUsersResult") + ); + + assertTrue(repository.contains("RETURNING u.id AS uid, u.name AS full_name")); + + assertCompiles(generatedDirectory, classesDirectory); + + assertTrue(Files.exists(classesDirectory.resolve("generated/UsersRepository.class"))); + } + private void compileUsersQueries(String queries, Path generatedDirectory) throws IOException { Path schemaFile = tempDir.resolve("schema.sql"); Path queriesFile = tempDir.resolve("queries.sql");