Skip to content

Commit 6cea5cb

Browse files
authored
Extend multi-row INSERT (addRow) to execute() and loop-friendly call shapes (#1825) (#1826)
* Support multi-row execute() without key return in JPA inserts Previously addRow() only produced a multi-row INSERT when keys were returned via executeWithKeys(); a plain execute() ignored the accumulated rows and inserted only the trailing row. - execute() now emits a single native INSERT INTO t (...) VALUES (..),(..),... when rows were accumulated via addRow(), in both JPAInsertClause and HibernateInsertClause; a trailing un-flushed row is treated as the last row (loop-friendly, no first-row bookkeeping). - Add JpaInsertNativeHelper.requireSqlModule(): the native insert paths depend on the optional querydsl-sql module, so guard them with an actionable IllegalStateException instead of a bare NoClassDefFoundError when querydsl-sql is absent from the classpath. - Clarify addRow() Javadoc: it builds a single multi-row VALUES statement, not JDBC batching. * Capture column paths in addRow() for loop-friendly multi-row inserts (#1825) set()-style + a trailing addRow() at the end of every loop iteration left both the inserts map and the columns list empty after the loop, so executors threw "No columns specified for insert". Callers had to keep a "first row" flag and intentionally leave the last row in the buffer to recover the column list from inserts.keySet(). addRow() now captures the effective column paths on its first call. executeMultiRow() and executeWithKeys() fall back to those captured paths when the current-state inserts/columns are empty. Combined with the prior change that routes execute() to a native multi-row INSERT when rows were accumulated, the loop-friendly shape for (var r : rows) { insert.set(...).set(...).addRow(); } insert.execute(); // or executeWithKeys(...) when keys are needed now works for both JPAInsertClause and HibernateInsertClause with no first-row bookkeeping and no buffer-retention trick.
1 parent 0cb1f12 commit 6cea5cb

5 files changed

Lines changed: 307 additions & 4 deletions

File tree

querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/JpaInsertNativeHelper.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,29 @@ public final class JpaInsertNativeHelper {
3838

3939
private JpaInsertNativeHelper() {}
4040

41+
/**
42+
* Ensure the optional {@code querydsl-sql} module is on the classpath before taking a native SQL
43+
* insert path. {@code querydsl-jpa} declares {@code querydsl-sql} as an <em>optional</em>
44+
* dependency (it is only needed for the {@link JpaNativeInsertSerializer}-based paths such as
45+
* {@code executeWithKey()}, {@code executeWithKeys()} and multi-row {@code execute()}), so a
46+
* JPA-only consumer will not have it transitively. Without this guard the caller would hit an
47+
* opaque {@link NoClassDefFoundError}; instead we fail fast with an actionable message.
48+
*
49+
* @throws IllegalStateException if {@code querydsl-sql} is not available
50+
*/
51+
public static void requireSqlModule() {
52+
try {
53+
Class.forName("com.querydsl.sql.SQLSerializer");
54+
} catch (ClassNotFoundException e) {
55+
throw new IllegalStateException(
56+
"This operation requires the optional querydsl-sql module, which is not on the"
57+
+ " classpath. Add the 'io.github.openfeign.querydsl:querydsl-sql' dependency"
58+
+ " (matching your querydsl-jpa version) to use executeWithKey(), executeWithKeys()"
59+
+ " or multi-row execute() via addRow().",
60+
e);
61+
}
62+
}
63+
4164
/**
4265
* Resolve the effective column paths from either the {@code set()}-style inserts map or the
4366
* {@code columns()}-style list. The {@code set()}-style takes precedence when present.

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

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ public class HibernateInsertClause implements InsertClause<HibernateInsertClause
6161

6262
private final List<List<Expression<?>>> rows = new ArrayList<>();
6363

64+
/**
65+
* Column paths captured at the first {@link #addRow()} call. After {@code addRow()} clears the
66+
* per-row {@code inserts}/{@code values} buffer, this lets executors recover the column list when
67+
* the trailing iteration was also flushed (e.g. {@code for (...) { insert.set(...).addRow(); }}).
68+
* Null until the first {@code addRow()}.
69+
*/
70+
@Nullable private List<Path<?>> rowColumnPaths;
71+
6472
private SubQueryExpression<?> subQuery;
6573

6674
private final SessionHolder session;
@@ -90,6 +98,12 @@ public HibernateInsertClause(
9098

9199
@Override
92100
public long execute() {
101+
// Multi-row insert accumulated via addRow(): emit a single native
102+
// INSERT INTO t (...) VALUES (..),(..),... statement in one round-trip.
103+
if (!rows.isEmpty()) {
104+
return executeMultiRow();
105+
}
106+
93107
if (subQuery != null || !hasTemplateValue()) {
94108
var serializer = new JPQLSerializer(templates, null);
95109
serializer.serializeForInsert(
@@ -116,6 +130,7 @@ public long execute() {
116130

117131
var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType();
118132

133+
JpaInsertNativeHelper.requireSqlModule();
119134
var serializer = new JpaNativeInsertSerializer(new Configuration(SQLTemplates.DEFAULT));
120135
serializer.serializeInsert(entityClass, effectiveColumns, effectiveValues);
121136

@@ -134,6 +149,50 @@ public long execute() {
134149
});
135150
}
136151

152+
/**
153+
* Execute a multi-row INSERT (accumulated via {@link #addRow()}) as a single native {@code INSERT
154+
* INTO t (...) VALUES (..),(..),...} statement, returning the number of affected rows. This path
155+
* does not return generated keys; use {@link #executeWithKeys(Class)} when keys are needed.
156+
*/
157+
private long executeMultiRow() {
158+
if (subQuery != null) {
159+
throw new IllegalStateException("addRow is not supported with INSERT ... SELECT subqueries");
160+
}
161+
162+
var effectiveColumns = JpaInsertNativeHelper.effectiveColumns(inserts, columns);
163+
if (effectiveColumns.isEmpty() && rowColumnPaths != null) {
164+
effectiveColumns = new ArrayList<>(rowColumnPaths);
165+
}
166+
if (effectiveColumns.isEmpty()) {
167+
throw new IllegalStateException("No columns specified for insert");
168+
}
169+
170+
var allRows = new ArrayList<>(rows);
171+
if (!values.isEmpty() || !inserts.isEmpty()) {
172+
allRows.add(JpaInsertNativeHelper.effectiveValues(inserts, values));
173+
}
174+
175+
var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType();
176+
177+
JpaInsertNativeHelper.requireSqlModule();
178+
var serializer = new JpaNativeInsertSerializer(new Configuration(SQLTemplates.DEFAULT));
179+
serializer.serializeInsertRows(entityClass, effectiveColumns, allRows);
180+
181+
var sql = serializer.toString();
182+
var params =
183+
JpaInsertNativeHelper.resolveConstants(
184+
serializer.getConstants(), queryMixin.getMetadata().getParams());
185+
186+
return session.doReturningWork(
187+
connection -> {
188+
try {
189+
return JpaInsertNativeHelper.executeUpdate(connection, sql, params);
190+
} catch (SQLException e) {
191+
throw new QueryException("Failed to execute multi-row insert", e);
192+
}
193+
});
194+
}
195+
137196
/**
138197
* Whether any value expression is a {@link TemplateExpression} — typically a schema-qualified
139198
* function call from {@code SQLExpressions.function/stringFunction/numberFunction} that
@@ -202,6 +261,7 @@ public <T> T executeWithKey(Class<T> type) {
202261

203262
var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType();
204263

264+
JpaInsertNativeHelper.requireSqlModule();
205265
var serializer = new JpaNativeInsertSerializer(new Configuration(SQLTemplates.DEFAULT));
206266
serializer.serializeInsert(entityClass, effectiveColumns, effectiveValues);
207267

@@ -222,8 +282,13 @@ public <T> T executeWithKey(Class<T> type) {
222282

223283
/**
224284
* Append the current {@code values()} (or {@code set()}) state as a row and clear it for the next
225-
* row. Use together with {@link #executeWithKeys(Class)} to issue a multi-row {@code INSERT INTO
226-
* t (...) VALUES (..),(..),...} as a single SQL statement.
285+
* row. Accumulated rows are emitted as a single multi-row {@code INSERT INTO t (...) VALUES
286+
* (..),(..),...} statement by either {@link #execute()} (no keys) or {@link
287+
* #executeWithKeys(Class)} (returning generated keys).
288+
*
289+
* <p><strong>Note:</strong> this is <em>not</em> JDBC batching ({@code
290+
* PreparedStatement.addBatch()}/{@code executeBatch()}, i.e. many statements grouped into one
291+
* round-trip). It builds a single SQL statement with multiple {@code VALUES} tuples.
227292
*
228293
* @return this clause for chaining
229294
* @throws IllegalStateException if no values have been specified for the current row, or if
@@ -236,6 +301,9 @@ public HibernateInsertClause addRow() {
236301
if (values.isEmpty() && inserts.isEmpty()) {
237302
throw new IllegalStateException("No values to add as row");
238303
}
304+
if (rowColumnPaths == null) {
305+
rowColumnPaths = JpaInsertNativeHelper.effectiveColumns(inserts, columns);
306+
}
239307
rows.add(JpaInsertNativeHelper.effectiveValues(inserts, values));
240308
values.clear();
241309
inserts.clear();
@@ -275,6 +343,9 @@ public <T> List<T> executeWithKeys(Class<T> type) {
275343
}
276344

277345
var effectiveColumns = JpaInsertNativeHelper.effectiveColumns(inserts, columns);
346+
if (effectiveColumns.isEmpty() && rowColumnPaths != null) {
347+
effectiveColumns = new ArrayList<>(rowColumnPaths);
348+
}
278349
if (effectiveColumns.isEmpty()) {
279350
throw new IllegalStateException("No columns specified for insert");
280351
}
@@ -289,6 +360,7 @@ public <T> List<T> executeWithKeys(Class<T> type) {
289360

290361
var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType();
291362

363+
JpaInsertNativeHelper.requireSqlModule();
292364
var serializer = new JpaNativeInsertSerializer(new Configuration(SQLTemplates.DEFAULT));
293365
serializer.serializeInsertRows(entityClass, effectiveColumns, allRows);
294366

querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/impl/JPAInsertClause.java

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ public class JPAInsertClause implements InsertClause<JPAInsertClause> {
5656

5757
private final List<List<Expression<?>>> rows = new ArrayList<>();
5858

59+
/**
60+
* Column paths captured at the first {@link #addRow()} call. After {@code addRow()} clears the
61+
* per-row {@code inserts}/{@code values} buffer, this lets executors recover the column list when
62+
* the trailing iteration was also flushed (e.g. {@code for (...) { insert.set(...).addRow(); }}).
63+
* Null until the first {@code addRow()}.
64+
*/
65+
@Nullable private List<Path<?>> rowColumnPaths;
66+
5967
private final EntityManager entityManager;
6068

6169
private final JPQLTemplates templates;
@@ -76,6 +84,12 @@ public JPAInsertClause(EntityManager em, EntityPath<?> entity, JPQLTemplates tem
7684

7785
@Override
7886
public long execute() {
87+
// Multi-row insert accumulated via addRow(): emit a single native
88+
// INSERT INTO t (...) VALUES (..),(..),... statement in one round-trip.
89+
if (!rows.isEmpty()) {
90+
return executeMultiRow();
91+
}
92+
7993
if (subQuery != null || !hasTemplateValue()) {
8094
var serializer = new JPQLSerializer(templates, entityManager);
8195
serializer.serializeForInsert(
@@ -101,6 +115,7 @@ public long execute() {
101115

102116
var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType();
103117

118+
JpaInsertNativeHelper.requireSqlModule();
104119
var serializer = new JpaNativeInsertSerializer(new Configuration(SQLTemplates.DEFAULT));
105120
serializer.serializeInsert(entityClass, effectiveColumns, effectiveValues);
106121

@@ -118,6 +133,47 @@ public long execute() {
118133
return nativeQuery.executeUpdate();
119134
}
120135

136+
/**
137+
* Execute a multi-row INSERT (accumulated via {@link #addRow()}) as a single native {@code INSERT
138+
* INTO t (...) VALUES (..),(..),...} statement, returning the number of affected rows. This path
139+
* does not return generated keys; use {@link #executeWithKeys(Class)} when keys are needed.
140+
*/
141+
private long executeMultiRow() {
142+
if (subQuery != null) {
143+
throw new IllegalStateException("addRow is not supported with INSERT ... SELECT subqueries");
144+
}
145+
146+
var effectiveColumns = JpaInsertNativeHelper.effectiveColumns(inserts, columns);
147+
if (effectiveColumns.isEmpty() && rowColumnPaths != null) {
148+
effectiveColumns = new ArrayList<>(rowColumnPaths);
149+
}
150+
if (effectiveColumns.isEmpty()) {
151+
throw new IllegalStateException("No columns specified for insert");
152+
}
153+
154+
var allRows = new ArrayList<>(rows);
155+
if (!values.isEmpty() || !inserts.isEmpty()) {
156+
allRows.add(JpaInsertNativeHelper.effectiveValues(inserts, values));
157+
}
158+
159+
var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType();
160+
161+
JpaInsertNativeHelper.requireSqlModule();
162+
var serializer = new JpaNativeInsertSerializer(new Configuration(SQLTemplates.DEFAULT));
163+
serializer.serializeInsertRows(entityClass, effectiveColumns, allRows);
164+
165+
var sql = serializer.toString();
166+
var params =
167+
JpaInsertNativeHelper.resolveConstants(
168+
serializer.getConstants(), queryMixin.getMetadata().getParams());
169+
170+
var nativeQuery = entityManager.createNativeQuery(sql);
171+
for (int i = 0; i < params.length; i++) {
172+
nativeQuery.setParameter(i + 1, params[i]);
173+
}
174+
return nativeQuery.executeUpdate();
175+
}
176+
121177
/**
122178
* Whether any value expression is a {@link TemplateExpression} — typically a schema-qualified
123179
* function call from {@code SQLExpressions.function/stringFunction/numberFunction} that
@@ -187,6 +243,7 @@ public <T> T executeWithKey(Class<T> type) {
187243

188244
var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType();
189245

246+
JpaInsertNativeHelper.requireSqlModule();
190247
var serializer = new JpaNativeInsertSerializer(new Configuration(SQLTemplates.DEFAULT));
191248
serializer.serializeInsert(entityClass, effectiveColumns, effectiveValues);
192249

@@ -208,8 +265,13 @@ public <T> T executeWithKey(Class<T> type) {
208265

209266
/**
210267
* Append the current {@code values()} (or {@code set()}) state as a row and clear it for the next
211-
* row. Use together with {@link #executeWithKeys(Class)} to issue a multi-row {@code INSERT INTO
212-
* t (...) VALUES (..),(..),...} as a single SQL statement.
268+
* row. Accumulated rows are emitted as a single multi-row {@code INSERT INTO t (...) VALUES
269+
* (..),(..),...} statement by either {@link #execute()} (no keys) or {@link
270+
* #executeWithKeys(Class)} (returning generated keys).
271+
*
272+
* <p><strong>Note:</strong> this is <em>not</em> JDBC batching ({@code
273+
* PreparedStatement.addBatch()}/{@code executeBatch()}, i.e. many statements grouped into one
274+
* round-trip). It builds a single SQL statement with multiple {@code VALUES} tuples.
213275
*
214276
* @return this clause for chaining
215277
* @throws IllegalStateException if no values have been specified for the current row, or if
@@ -222,6 +284,9 @@ public JPAInsertClause addRow() {
222284
if (values.isEmpty() && inserts.isEmpty()) {
223285
throw new IllegalStateException("No values to add as row");
224286
}
287+
if (rowColumnPaths == null) {
288+
rowColumnPaths = JpaInsertNativeHelper.effectiveColumns(inserts, columns);
289+
}
225290
rows.add(JpaInsertNativeHelper.effectiveValues(inserts, values));
226291
values.clear();
227292
inserts.clear();
@@ -261,6 +326,9 @@ public <T> List<T> executeWithKeys(Class<T> type) {
261326
}
262327

263328
var effectiveColumns = JpaInsertNativeHelper.effectiveColumns(inserts, columns);
329+
if (effectiveColumns.isEmpty() && rowColumnPaths != null) {
330+
effectiveColumns = new ArrayList<>(rowColumnPaths);
331+
}
264332
if (effectiveColumns.isEmpty()) {
265333
throw new IllegalStateException("No columns specified for insert");
266334
}
@@ -275,6 +343,7 @@ public <T> List<T> executeWithKeys(Class<T> type) {
275343

276344
var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType();
277345

346+
JpaInsertNativeHelper.requireSqlModule();
278347
var serializer = new JpaNativeInsertSerializer(new Configuration(SQLTemplates.DEFAULT));
279348
serializer.serializeInsertRows(entityClass, effectiveColumns, allRows);
280349

querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/HibernateExecuteWithKeyTest.java

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,75 @@ public void execute_with_function_template_routes_through_native_sql() {
226226
assertThat(stored).isEqualTo("HELLO");
227227
}
228228

229+
@Test
230+
public void execute_multi_row_without_keys_inserts_all_rows() {
231+
// #1692 follow-up: addRow() must also work without returning keys — a plain
232+
// execute() should emit a single multi-row INSERT and report all affected rows.
233+
var entity = QGeneratedKeyEntity.generatedKeyEntity;
234+
long rows =
235+
insert(entity)
236+
.columns(entity.name)
237+
.values("MR-A")
238+
.addRow()
239+
.values("MR-B")
240+
.addRow()
241+
.values("MR-C")
242+
.execute();
243+
244+
assertThat(rows).isEqualTo(3L);
245+
246+
var count =
247+
session
248+
.createNativeQuery(
249+
"select count(*) from generated_key_entity where name_ like 'MR-%'", Long.class)
250+
.getSingleResult();
251+
assertThat(count).isEqualTo(3L);
252+
}
253+
254+
@Test
255+
public void execute_multi_row_in_a_loop_with_trailing_addRow() {
256+
// The loop-friendly shape: every iteration appends a row, no "is this the first row?"
257+
// bookkeeping, and a trailing addRow() is fine.
258+
var entity = QGeneratedKeyEntity.generatedKeyEntity;
259+
var clause = insert(entity).columns(entity.name);
260+
for (var name : new String[] {"Loop1", "Loop2", "Loop3", "Loop4"}) {
261+
clause.values(name).addRow();
262+
}
263+
long rows = clause.execute();
264+
265+
assertThat(rows).isEqualTo(4L);
266+
}
267+
268+
@Test
269+
public void execute_multi_row_set_style_with_trailing_addRow() {
270+
// The loop-friendly shape for set()-style: every iteration calls set()...addRow().
271+
// After the loop, inserts/columns are both empty (set() goes into inserts which addRow()
272+
// clears), but addRow() captures the column paths on its first call so executors can
273+
// recover the column list. No "first row" flag, no buffer-retention trick.
274+
var entity = QGeneratedKeyEntity.generatedKeyEntity;
275+
var insert = insert(entity);
276+
for (var name : new String[] {"Set1", "Set2", "Set3"}) {
277+
insert.set(entity.name, name).addRow();
278+
}
279+
long rows = insert.execute();
280+
281+
assertThat(rows).isEqualTo(3L);
282+
}
283+
284+
@Test
285+
public void executeWithKeys_multi_row_set_style_with_trailing_addRow() {
286+
// Same loop-friendly shape but routed through executeWithKeys to return generated keys.
287+
var entity = QGeneratedKeyEntity.generatedKeyEntity;
288+
var insert = insert(entity);
289+
for (var name : new String[] {"KSet1", "KSet2"}) {
290+
insert.set(entity.name, name).addRow();
291+
}
292+
var keys = insert.executeWithKeys(entity.id);
293+
294+
assertThat(keys).hasSize(2);
295+
assertThat(keys.get(0)).isLessThan(keys.get(1));
296+
}
297+
229298
@Test
230299
public void execute_without_template_uses_jpql_path() {
231300
// Regression for #1757: plain value INSERTs must keep using the JPQL path so

0 commit comments

Comments
 (0)