Skip to content

Commit fd29953

Browse files
authored
Add executeWithKey() to JPAInsertClause and HibernateInsertClause (#1693)
## Summary - Add `executeWithKey(Path<T>)` and `executeWithKey(Class<T>)` to `JPAInsertClause` and `HibernateInsertClause` - Add `addRow()` and `executeWithKeys(Path<T>) / executeWithKeys(Class<T>) → List<T>` for **multi-row INSERT with batched key return** (single statement, single round-trip) - Bypass JPQL and execute native SQL INSERT via JDBC with `Statement.RETURN_GENERATED_KEYS` to retrieve auto-generated keys - Introduce `JpaNativeInsertSerializer` (extends `SQLSerializer`) for a single-pass SQL + constants build, ensuring function templates, params, and paths serialize correctly - Add `JpaInsertNativeHelper` utility for `@Table`/`@Column` resolution and JDBC binding (single- and multi-row execution) - Add `doReturningWork()` to `SessionHolder` interface and all implementations Closes #1692 ## Motivation The SQL module's `SQLInsertClause` supports `executeWithKey()` (and `executeWithKeys()` for batched inserts), but the JPA module does not. This forces JPA users to fall back to `EntityManager.persist()` + `flush()` for single-row inserts, and to a `for`-loop of N single-row calls for bulk inserts — N statements, N round-trips, no batching. Using the SQL module in a JPA project requires a separate `SQLQueryFactory`, SQL-specific Q-classes, and managing two query factories — excessive overhead just for insert key return. ## Before / After **Before** — must break out of QueryDSL: ```java // Single row entityManager.persist(entity); entityManager.flush(); Long seq = entity.getSeq(); // Bulk — N statements, N round-trips List<Long> ids = new ArrayList<>(); for (var dto : dtos) { entityManager.persist(toEntity(dto)); entityManager.flush(); ids.add(toEntity(dto).getSeq()); } ``` **After** — stays in QueryDSL: ```java // Single row Long seq = queryFactory.insert(role) .set(role.name, dto.roleName()) .set(role.status, status) .executeWithKey(role.seq); // Bulk — 1 statement, 1 round-trip, all keys returned List<Long> ids = queryFactory.insert(member) .columns(member.name, member.email) .values("Alice", "a@x.com").addRow() .values("Bob", "b@x.com").addRow() .values("Carol", "c@x.com") // trailing addRow optional .executeWithKeys(member.seq); ``` ## Implementation Since JPA's `Query.executeUpdate()` only returns affected row count, the implementation: 1. Reads `@Table` / `@Column` annotations to build native SQL (same pattern as `NativeSQLSerializer`) 2. Multi-row support emits a single `INSERT INTO t (...) VALUES (..),(..),...` statement; `getGeneratedKeys()` is iterated to collect all keys in row order 3. `HibernateInsertClause` uses `Session.doReturningWork()` for JDBC access 4. `JPAInsertClause` uses `EntityManager.unwrap(Session.class).doReturningWork()` 5. `executeWithKey()` (singular) throws `IllegalStateException` when called after `addRow()` to guard single-row contracts from being silently violated ### Single-pass serialization (regression fix) The earlier draft built the SQL via a `JpaInsertNativeHelper.buildNativeInsertSQL` step while constants were extracted from a separate `JPQLSerializer` pass. The helper emitted one `?` per column without inspecting the value expression tree, so a function template like `dbo.encrypt({0})` was dropped from the SQL and only the inner constant was bound — surfacing as plaintext stored in the DB. This is now a **single serialization pass**: `JpaNativeInsertSerializer` extends `SQLSerializer` and produces the SQL and the constants list together. By reusing the `SQLSerializer` visitor, function templates, constants, params, and paths all serialize correctly. `@Column` / `@Table` resolution follows the same pattern as `NativeSQLSerializer`, and plain `?` placeholders come from `SQLSerializer.serializeConstant`'s default behavior. The broken `buildNativeInsertSQL` path is removed. Regression tests cover: - Function template with bound constant: `upper({0})` + `"value"` → DB stores `"VALUE"` - Zero-argument templates and multi-argument templates - Identifier quoting via a custom `SQLTemplates` ### On adding SQL serialization to the JPA module A fair concern was raised about expanding SQL-mapping responsibilities in the JPA module. Mitigations: - **No new dependency.** `NativeSQLSerializer` already lives in this module; the new serializer reuses the same infrastructure. - **Narrow scope.** The serializer is only used on the `executeWithKey` / `executeWithKeys` path. `execute()` and `toString()` still go through `JPQLSerializer` unchanged. - **Fallback option.** If this still feels like the wrong tradeoff, the scope can be narrowed so `executeWithKey` / `executeWithKeys` only accept `Constant` / `Param` / `Path` values and explicitly reject anything else (no template support). ## Limitations - `INSERT ... SELECT` subqueries are not supported (throws `UnsupportedOperationException`) for both `executeWithKey` and `executeWithKeys` - Multi-row `VALUES (..),(..)` uses standard SQL syntax — Oracle's `INSERT ALL` form would need a dialect branch (follow-up) - Multi-row key retrieval relies on the JDBC driver returning all rows from `getGeneratedKeys()` (verified on H2; MySQL Connector/J 8+, PostgreSQL JDBC 42+ known to support this) - Requires explicit `@Table` / `@Column` annotations if using a custom Hibernate `PhysicalNamingStrategy` - `JPAInsertClause` currently relies on Hibernate as the JPA provider ## Test plan - [x] Unit tests for `JpaInsertNativeHelper` (table name resolution, column name resolution, SQL generation, multi-row SQL generation) - [x] Integration tests for `HibernateInsertClause.executeWithKey()` (set style, columns/values style, class type, multiple inserts, column annotation, subquery rejection) - [x] Integration tests for `JPAInsertClause.executeWithKey()` (same scenarios) - [x] Integration tests for `executeWithKeys()` on both clauses (multi-row returns all keys in order, single-row returns size-1 list, `executeWithKey` rejected after `addRow`, `addRow` rejected with no pending values) - [x] Regression tests for function template handling (`upper({0})` + constant, zero-arg templates, custom `SQLTemplates` identifier quoting) - [x] All new tests pass (21 integration tests across both clauses) - [x] Existing tests unaffected — single-row `executeWithKey` signature and behavior unchanged (internal serializer refactor delegates the legacy path to the multi-row implementation) - [x] `./mvnw -Pdev verify` passes - [x] Code formatted (`git-code-format` pre-commit hook)
2 parents 8df8d84 + c0c0108 commit fd29953

14 files changed

Lines changed: 1310 additions & 0 deletions
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/*
2+
* Copyright 2015, The Querydsl Team (http://www.querydsl.com/team)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
package com.querydsl.jpa;
15+
16+
import com.querydsl.core.types.Expression;
17+
import com.querydsl.core.types.ParamExpression;
18+
import com.querydsl.core.types.ParamNotSetException;
19+
import com.querydsl.core.types.Path;
20+
import com.querydsl.core.types.dsl.Expressions;
21+
import com.querydsl.core.types.dsl.Param;
22+
import java.sql.PreparedStatement;
23+
import java.sql.ResultSet;
24+
import java.sql.SQLException;
25+
import java.sql.Statement;
26+
import java.util.ArrayList;
27+
import java.util.List;
28+
import java.util.Map;
29+
import org.jetbrains.annotations.Nullable;
30+
31+
/**
32+
* Helpers shared by {@link com.querydsl.jpa.impl.JPAInsertClause} and {@link
33+
* com.querydsl.jpa.hibernate.HibernateInsertClause} to support {@code executeWithKey()} via native
34+
* SQL INSERT.
35+
*
36+
* <p>This is an internal API and not intended for direct use by application code.
37+
*/
38+
public final class JpaInsertNativeHelper {
39+
40+
private JpaInsertNativeHelper() {}
41+
42+
/**
43+
* Resolve the effective column paths from either the {@code set()}-style inserts map or the
44+
* {@code columns()}-style list. The {@code set()}-style takes precedence when present.
45+
*/
46+
public static List<Path<?>> effectiveColumns(
47+
Map<Path<?>, Expression<?>> inserts, List<Path<?>> columns) {
48+
if (!inserts.isEmpty()) {
49+
return new ArrayList<>(inserts.keySet());
50+
}
51+
return new ArrayList<>(columns);
52+
}
53+
54+
/**
55+
* Resolve the effective value expressions, in the order matching {@link #effectiveColumns(Map,
56+
* List)}. Raw values from the {@code values()}-style call are wrapped as constants; expressions
57+
* are passed through unchanged.
58+
*/
59+
public static List<Expression<?>> effectiveValues(
60+
Map<Path<?>, Expression<?>> inserts, List<Object> values) {
61+
if (!inserts.isEmpty()) {
62+
return new ArrayList<>(inserts.values());
63+
}
64+
var result = new ArrayList<Expression<?>>(values.size());
65+
for (Object v : values) {
66+
if (v instanceof Expression<?> expression) {
67+
result.add(expression);
68+
} else {
69+
result.add(Expressions.constant(v));
70+
}
71+
}
72+
return result;
73+
}
74+
75+
/**
76+
* Resolve constant values from the serializer, unwrapping {@link Param} expressions against the
77+
* provided parameter bindings.
78+
*
79+
* @param constants the constants accumulated by the serializer
80+
* @param params the parameter bindings collected from {@link
81+
* com.querydsl.core.QueryMetadata#getParams()}
82+
* @return resolved values ready for JDBC binding
83+
*/
84+
public static Object[] resolveConstants(
85+
List<Object> constants, Map<ParamExpression<?>, Object> params) {
86+
var result = new Object[constants.size()];
87+
for (var i = 0; i < constants.size(); i++) {
88+
var val = constants.get(i);
89+
if (val instanceof Param<?> param) {
90+
val = params.get(val);
91+
if (val == null) {
92+
throw new ParamNotSetException(param);
93+
}
94+
}
95+
result[i] = val;
96+
}
97+
return result;
98+
}
99+
100+
/**
101+
* Execute a native SQL INSERT with {@code RETURN_GENERATED_KEYS} and return the generated key.
102+
*
103+
* @param <T> the key type
104+
* @param conn the JDBC connection (not closed by this method)
105+
* @param sql the native SQL INSERT string
106+
* @param params the parameter values to bind, in positional order
107+
* @param keyType the expected key type
108+
* @return the generated key, or {@code null} if no rows were inserted
109+
* @throws SQLException if a database error occurs
110+
*/
111+
@Nullable
112+
public static <T> T executeAndReturnKey(
113+
java.sql.Connection conn, String sql, Object[] params, Class<T> keyType) throws SQLException {
114+
try (PreparedStatement stmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
115+
for (int i = 0; i < params.length; i++) {
116+
stmt.setObject(i + 1, params[i]);
117+
}
118+
stmt.executeUpdate();
119+
120+
try (ResultSet rs = stmt.getGeneratedKeys()) {
121+
if (rs.next()) {
122+
return rs.getObject(1, keyType);
123+
}
124+
return null;
125+
}
126+
}
127+
}
128+
129+
/**
130+
* Execute a native SQL multi-row INSERT with {@code RETURN_GENERATED_KEYS} and return all
131+
* generated keys.
132+
*
133+
* @param <T> the key type
134+
* @param conn the JDBC connection (not closed by this method)
135+
* @param sql the native SQL INSERT string (typically multi-row {@code VALUES (..),(..)})
136+
* @param params the parameter values to bind, in positional order
137+
* @param keyType the expected key type
138+
* @return the generated keys in row order; empty list if no rows were inserted
139+
* @throws SQLException if a database error occurs
140+
*/
141+
public static <T> List<T> executeAndReturnKeys(
142+
java.sql.Connection conn, String sql, Object[] params, Class<T> keyType) throws SQLException {
143+
try (PreparedStatement stmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
144+
for (int i = 0; i < params.length; i++) {
145+
stmt.setObject(i + 1, params[i]);
146+
}
147+
stmt.executeUpdate();
148+
149+
var keys = new ArrayList<T>();
150+
try (ResultSet rs = stmt.getGeneratedKeys()) {
151+
while (rs.next()) {
152+
keys.add(rs.getObject(1, keyType));
153+
}
154+
}
155+
return keys;
156+
}
157+
}
158+
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/*
2+
* Copyright 2015, The Querydsl Team (http://www.querydsl.com/team)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
package com.querydsl.jpa;
15+
16+
import com.querydsl.core.types.Expression;
17+
import com.querydsl.core.types.Path;
18+
import com.querydsl.sql.Configuration;
19+
import com.querydsl.sql.SQLSerializer;
20+
import com.querydsl.sql.SQLTemplates;
21+
import jakarta.persistence.Column;
22+
import jakarta.persistence.Table;
23+
import java.util.List;
24+
25+
/**
26+
* Serializer that emits a native SQL {@code INSERT} statement from JPA entity metadata
27+
* ({@code @Table}/{@code @Column} annotations) and a list of column/value expressions.
28+
*
29+
* <p>Unlike {@link NativeSQLSerializer}, which targets Hibernate native queries with positional
30+
* {@code ?N} placeholders, this serializer emits plain {@code ?} placeholders for direct binding to
31+
* a JDBC {@link java.sql.PreparedStatement}, and dispatches each value expression through the
32+
* visitor pattern so function templates, paths, parameters and other non-trivial expressions
33+
* serialize into SQL correctly.
34+
*
35+
* <p>This is an internal API used by {@link com.querydsl.jpa.impl.JPAInsertClause} and {@link
36+
* com.querydsl.jpa.hibernate.HibernateInsertClause} to support {@code executeWithKey()}.
37+
*/
38+
public final class JpaNativeInsertSerializer extends SQLSerializer {
39+
40+
public JpaNativeInsertSerializer(Configuration configuration) {
41+
super(configuration);
42+
}
43+
44+
@Override
45+
protected void appendAsColumnName(Path<?> path, boolean precededByDot) {
46+
if (path.getAnnotatedElement() != null
47+
&& path.getAnnotatedElement().isAnnotationPresent(Column.class)) {
48+
var column = path.getAnnotatedElement().getAnnotation(Column.class);
49+
if (!column.name().isEmpty()) {
50+
append(getTemplates().quoteIdentifier(column.name(), precededByDot));
51+
return;
52+
}
53+
}
54+
super.appendAsColumnName(path, precededByDot);
55+
}
56+
57+
/**
58+
* Serialize a single-row {@code INSERT} statement for the given entity, columns, and value
59+
* expressions. Each value expression is dispatched through the visitor pattern, so function
60+
* templates, paths, parameters, and other expressions are serialized into SQL with positional
61+
* {@code ?} placeholders. Bound values are accumulated and accessible via {@link
62+
* #getConstants()}.
63+
*
64+
* @param entityClass the JPA entity class (used to resolve the table name via {@link Table})
65+
* @param columns the column paths to insert into
66+
* @param values the value expressions, one per column, in matching order
67+
*/
68+
public void serializeInsert(
69+
Class<?> entityClass, List<Path<?>> columns, List<Expression<?>> values) {
70+
serializeInsertRows(entityClass, columns, List.of(values));
71+
}
72+
73+
/**
74+
* Serialize a multi-row {@code INSERT} statement for the given entity, columns, and rows of value
75+
* expressions. Emits {@code INSERT INTO t (c1, c2) VALUES (?, ?), (?, ?), ...}.
76+
*
77+
* @param entityClass the JPA entity class (used to resolve the table name via {@link Table})
78+
* @param columns the column paths to insert into
79+
* @param rows the rows of value expressions; each row's size must match the column count
80+
*/
81+
public void serializeInsertRows(
82+
Class<?> entityClass, List<Path<?>> columns, List<List<Expression<?>>> rows) {
83+
if (rows.isEmpty()) {
84+
throw new IllegalArgumentException("No rows specified for insert");
85+
}
86+
for (var row : rows) {
87+
if (columns.size() != row.size()) {
88+
throw new IllegalArgumentException(
89+
"Column count ("
90+
+ columns.size()
91+
+ ") does not match value count ("
92+
+ row.size()
93+
+ ")");
94+
}
95+
}
96+
97+
var templates = getTemplates();
98+
append(templates.getInsertInto());
99+
appendTable(entityClass);
100+
101+
if (!columns.isEmpty()) {
102+
append(" (");
103+
var first = true;
104+
for (Path<?> col : columns) {
105+
if (!first) {
106+
append(", ");
107+
}
108+
appendAsColumnName(col, false);
109+
first = false;
110+
}
111+
append(")");
112+
}
113+
114+
var oldSkipParent = skipParent;
115+
skipParent = true;
116+
try {
117+
append(templates.getValues());
118+
var firstRow = true;
119+
for (var row : rows) {
120+
if (!firstRow) {
121+
append(", ");
122+
}
123+
append("(");
124+
var firstValue = true;
125+
for (Expression<?> value : row) {
126+
if (!firstValue) {
127+
append(", ");
128+
}
129+
handle(value);
130+
firstValue = false;
131+
}
132+
append(")");
133+
firstRow = false;
134+
}
135+
} finally {
136+
skipParent = oldSkipParent;
137+
}
138+
}
139+
140+
private void appendTable(Class<?> entityClass) {
141+
SQLTemplates templates = getTemplates();
142+
String schema = "";
143+
String tableName = entityClass.getSimpleName();
144+
if (entityClass.isAnnotationPresent(Table.class)) {
145+
var table = entityClass.getAnnotation(Table.class);
146+
if (!table.name().isEmpty()) {
147+
tableName = table.name();
148+
}
149+
schema = table.schema();
150+
}
151+
if (!schema.isEmpty()) {
152+
append(templates.quoteIdentifier(schema));
153+
append(".");
154+
append(templates.quoteIdentifier(tableName, true));
155+
} else {
156+
append(templates.quoteIdentifier(tableName));
157+
}
158+
}
159+
}

querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/DefaultSessionHolder.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
package com.querydsl.jpa.hibernate;
1515

1616
import org.hibernate.Session;
17+
import org.hibernate.jdbc.ReturningWork;
1718
import org.hibernate.query.NativeQuery;
1819
import org.hibernate.query.Query;
1920

@@ -39,4 +40,9 @@ public Query<?> createQuery(String queryString) {
3940
public NativeQuery<?> createSQLQuery(String queryString) {
4041
return session.createNativeQuery(queryString);
4142
}
43+
44+
@Override
45+
public <T> T doReturningWork(ReturningWork<T> work) {
46+
return session.doReturningWork(work);
47+
}
4248
}

0 commit comments

Comments
 (0)