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
34 changes: 26 additions & 8 deletions docs/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<TableName>Row` record, while a `RETURNING` column
list keeps the query's own `<QueryName>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

Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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`,
Expand Down
27 changes: 16 additions & 11 deletions sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java
Original file line number Diff line number Diff line change
Expand Up @@ -511,14 +511,12 @@ private List<QueryColumn> resolveReturningColumns(ReturningClause returningClaus
List<QueryColumn> 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
)
);
}

Expand All @@ -544,17 +542,24 @@ 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<QueryColumn> resolveReturningItem(Expression expression, Source source) {
private List<QueryColumn> resolveReturningItem(Expression expression, Alias alias, Source source) {
if (expression instanceof AllTableColumns) {
throw new UnsupportedOperationException(
"Only an unqualified RETURNING * is supported."
);
}

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."
Expand All @@ -569,7 +574,7 @@ private List<QueryColumn> resolveReturningItem(Expression expression, Source sou

return List.of(
new QueryColumn(
schemaColumn.name(),
selectedColumnName(alias, schemaColumn),
requireSupportedType(schemaColumn),
schemaColumn.nullable(),
schemaColumn.enumType(),
Expand Down
128 changes: 126 additions & 2 deletions sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
)
Expand Down Expand Up @@ -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"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading