From 8809f166da5b3994cebcc2dbe0791cb247db439f Mon Sep 17 00:00:00 2001 From: zio0911 Date: Thu, 25 Jun 2026 10:19:10 +0900 Subject: [PATCH 1/2] 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. --- .../querydsl/jpa/JpaInsertNativeHelper.java | 23 ++++++++ .../jpa/hibernate/HibernateInsertClause.java | 59 ++++++++++++++++++- .../querydsl/jpa/impl/JPAInsertClause.java | 56 +++++++++++++++++- .../jpa/HibernateExecuteWithKeyTest.java | 39 ++++++++++++ .../querydsl/jpa/JPAExecuteWithKeyTest.java | 40 +++++++++++++ 5 files changed, 213 insertions(+), 4 deletions(-) diff --git a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/JpaInsertNativeHelper.java b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/JpaInsertNativeHelper.java index a63be87053..48916e1314 100644 --- a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/JpaInsertNativeHelper.java +++ b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/JpaInsertNativeHelper.java @@ -38,6 +38,29 @@ public final class JpaInsertNativeHelper { private JpaInsertNativeHelper() {} + /** + * Ensure the optional {@code querydsl-sql} module is on the classpath before taking a native SQL + * insert path. {@code querydsl-jpa} declares {@code querydsl-sql} as an optional + * dependency (it is only needed for the {@link JpaNativeInsertSerializer}-based paths such as + * {@code executeWithKey()}, {@code executeWithKeys()} and multi-row {@code execute()}), so a + * JPA-only consumer will not have it transitively. Without this guard the caller would hit an + * opaque {@link NoClassDefFoundError}; instead we fail fast with an actionable message. + * + * @throws IllegalStateException if {@code querydsl-sql} is not available + */ + public static void requireSqlModule() { + try { + Class.forName("com.querydsl.sql.SQLSerializer"); + } catch (ClassNotFoundException e) { + throw new IllegalStateException( + "This operation requires the optional querydsl-sql module, which is not on the" + + " classpath. Add the 'io.github.openfeign.querydsl:querydsl-sql' dependency" + + " (matching your querydsl-jpa version) to use executeWithKey(), executeWithKeys()" + + " or multi-row execute() via addRow().", + e); + } + } + /** * Resolve the effective column paths from either the {@code set()}-style inserts map or the * {@code columns()}-style list. The {@code set()}-style takes precedence when present. diff --git a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/HibernateInsertClause.java b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/HibernateInsertClause.java index 577d1fe87b..edfbd4aede 100644 --- a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/HibernateInsertClause.java +++ b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/HibernateInsertClause.java @@ -90,6 +90,12 @@ public HibernateInsertClause( @Override public long execute() { + // Multi-row insert accumulated via addRow(): emit a single native + // INSERT INTO t (...) VALUES (..),(..),... statement in one round-trip. + if (!rows.isEmpty()) { + return executeMultiRow(); + } + if (subQuery != null || !hasTemplateValue()) { var serializer = new JPQLSerializer(templates, null); serializer.serializeForInsert( @@ -116,6 +122,7 @@ public long execute() { var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType(); + JpaInsertNativeHelper.requireSqlModule(); var serializer = new JpaNativeInsertSerializer(new Configuration(SQLTemplates.DEFAULT)); serializer.serializeInsert(entityClass, effectiveColumns, effectiveValues); @@ -134,6 +141,47 @@ public long execute() { }); } + /** + * Execute a multi-row INSERT (accumulated via {@link #addRow()}) as a single native {@code INSERT + * INTO t (...) VALUES (..),(..),...} statement, returning the number of affected rows. This path + * does not return generated keys; use {@link #executeWithKeys(Class)} when keys are needed. + */ + private long executeMultiRow() { + if (subQuery != null) { + throw new IllegalStateException("addRow is not supported with INSERT ... SELECT subqueries"); + } + + var effectiveColumns = JpaInsertNativeHelper.effectiveColumns(inserts, columns); + if (effectiveColumns.isEmpty()) { + throw new IllegalStateException("No columns specified for insert"); + } + + var allRows = new ArrayList<>(rows); + if (!values.isEmpty() || !inserts.isEmpty()) { + allRows.add(JpaInsertNativeHelper.effectiveValues(inserts, values)); + } + + var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType(); + + JpaInsertNativeHelper.requireSqlModule(); + var serializer = new JpaNativeInsertSerializer(new Configuration(SQLTemplates.DEFAULT)); + serializer.serializeInsertRows(entityClass, effectiveColumns, allRows); + + var sql = serializer.toString(); + var params = + JpaInsertNativeHelper.resolveConstants( + serializer.getConstants(), queryMixin.getMetadata().getParams()); + + return session.doReturningWork( + connection -> { + try { + return JpaInsertNativeHelper.executeUpdate(connection, sql, params); + } catch (SQLException e) { + throw new QueryException("Failed to execute multi-row insert", e); + } + }); + } + /** * Whether any value expression is a {@link TemplateExpression} — typically a schema-qualified * function call from {@code SQLExpressions.function/stringFunction/numberFunction} that @@ -202,6 +250,7 @@ public T executeWithKey(Class type) { var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType(); + JpaInsertNativeHelper.requireSqlModule(); var serializer = new JpaNativeInsertSerializer(new Configuration(SQLTemplates.DEFAULT)); serializer.serializeInsert(entityClass, effectiveColumns, effectiveValues); @@ -222,8 +271,13 @@ public T executeWithKey(Class type) { /** * Append the current {@code values()} (or {@code set()}) state as a row and clear it for the next - * row. Use together with {@link #executeWithKeys(Class)} to issue a multi-row {@code INSERT INTO - * t (...) VALUES (..),(..),...} as a single SQL statement. + * row. Accumulated rows are emitted as a single multi-row {@code INSERT INTO t (...) VALUES + * (..),(..),...} statement by either {@link #execute()} (no keys) or {@link + * #executeWithKeys(Class)} (returning generated keys). + * + *

Note: this is not JDBC batching ({@code + * PreparedStatement.addBatch()}/{@code executeBatch()}, i.e. many statements grouped into one + * round-trip). It builds a single SQL statement with multiple {@code VALUES} tuples. * * @return this clause for chaining * @throws IllegalStateException if no values have been specified for the current row, or if @@ -289,6 +343,7 @@ public List executeWithKeys(Class type) { var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType(); + JpaInsertNativeHelper.requireSqlModule(); var serializer = new JpaNativeInsertSerializer(new Configuration(SQLTemplates.DEFAULT)); serializer.serializeInsertRows(entityClass, effectiveColumns, allRows); diff --git a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/impl/JPAInsertClause.java b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/impl/JPAInsertClause.java index 39e83c6d5e..353cb827d3 100644 --- a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/impl/JPAInsertClause.java +++ b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/impl/JPAInsertClause.java @@ -76,6 +76,12 @@ public JPAInsertClause(EntityManager em, EntityPath entity, JPQLTemplates tem @Override public long execute() { + // Multi-row insert accumulated via addRow(): emit a single native + // INSERT INTO t (...) VALUES (..),(..),... statement in one round-trip. + if (!rows.isEmpty()) { + return executeMultiRow(); + } + if (subQuery != null || !hasTemplateValue()) { var serializer = new JPQLSerializer(templates, entityManager); serializer.serializeForInsert( @@ -101,6 +107,7 @@ public long execute() { var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType(); + JpaInsertNativeHelper.requireSqlModule(); var serializer = new JpaNativeInsertSerializer(new Configuration(SQLTemplates.DEFAULT)); serializer.serializeInsert(entityClass, effectiveColumns, effectiveValues); @@ -118,6 +125,44 @@ public long execute() { return nativeQuery.executeUpdate(); } + /** + * Execute a multi-row INSERT (accumulated via {@link #addRow()}) as a single native {@code INSERT + * INTO t (...) VALUES (..),(..),...} statement, returning the number of affected rows. This path + * does not return generated keys; use {@link #executeWithKeys(Class)} when keys are needed. + */ + private long executeMultiRow() { + if (subQuery != null) { + throw new IllegalStateException("addRow is not supported with INSERT ... SELECT subqueries"); + } + + var effectiveColumns = JpaInsertNativeHelper.effectiveColumns(inserts, columns); + if (effectiveColumns.isEmpty()) { + throw new IllegalStateException("No columns specified for insert"); + } + + var allRows = new ArrayList<>(rows); + if (!values.isEmpty() || !inserts.isEmpty()) { + allRows.add(JpaInsertNativeHelper.effectiveValues(inserts, values)); + } + + var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType(); + + JpaInsertNativeHelper.requireSqlModule(); + var serializer = new JpaNativeInsertSerializer(new Configuration(SQLTemplates.DEFAULT)); + serializer.serializeInsertRows(entityClass, effectiveColumns, allRows); + + var sql = serializer.toString(); + var params = + JpaInsertNativeHelper.resolveConstants( + serializer.getConstants(), queryMixin.getMetadata().getParams()); + + var nativeQuery = entityManager.createNativeQuery(sql); + for (int i = 0; i < params.length; i++) { + nativeQuery.setParameter(i + 1, params[i]); + } + return nativeQuery.executeUpdate(); + } + /** * Whether any value expression is a {@link TemplateExpression} — typically a schema-qualified * function call from {@code SQLExpressions.function/stringFunction/numberFunction} that @@ -187,6 +232,7 @@ public T executeWithKey(Class type) { var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType(); + JpaInsertNativeHelper.requireSqlModule(); var serializer = new JpaNativeInsertSerializer(new Configuration(SQLTemplates.DEFAULT)); serializer.serializeInsert(entityClass, effectiveColumns, effectiveValues); @@ -208,8 +254,13 @@ public T executeWithKey(Class type) { /** * Append the current {@code values()} (or {@code set()}) state as a row and clear it for the next - * row. Use together with {@link #executeWithKeys(Class)} to issue a multi-row {@code INSERT INTO - * t (...) VALUES (..),(..),...} as a single SQL statement. + * row. Accumulated rows are emitted as a single multi-row {@code INSERT INTO t (...) VALUES + * (..),(..),...} statement by either {@link #execute()} (no keys) or {@link + * #executeWithKeys(Class)} (returning generated keys). + * + *

Note: this is not JDBC batching ({@code + * PreparedStatement.addBatch()}/{@code executeBatch()}, i.e. many statements grouped into one + * round-trip). It builds a single SQL statement with multiple {@code VALUES} tuples. * * @return this clause for chaining * @throws IllegalStateException if no values have been specified for the current row, or if @@ -275,6 +326,7 @@ public List executeWithKeys(Class type) { var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType(); + JpaInsertNativeHelper.requireSqlModule(); var serializer = new JpaNativeInsertSerializer(new Configuration(SQLTemplates.DEFAULT)); serializer.serializeInsertRows(entityClass, effectiveColumns, allRows); diff --git a/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/HibernateExecuteWithKeyTest.java b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/HibernateExecuteWithKeyTest.java index 0cab9b12cf..29f41063fb 100644 --- a/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/HibernateExecuteWithKeyTest.java +++ b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/HibernateExecuteWithKeyTest.java @@ -226,6 +226,45 @@ public void execute_with_function_template_routes_through_native_sql() { assertThat(stored).isEqualTo("HELLO"); } + @Test + public void execute_multi_row_without_keys_inserts_all_rows() { + // #1692 follow-up: addRow() must also work without returning keys — a plain + // execute() should emit a single multi-row INSERT and report all affected rows. + var entity = QGeneratedKeyEntity.generatedKeyEntity; + long rows = + insert(entity) + .columns(entity.name) + .values("MR-A") + .addRow() + .values("MR-B") + .addRow() + .values("MR-C") + .execute(); + + assertThat(rows).isEqualTo(3L); + + var count = + session + .createNativeQuery( + "select count(*) from generated_key_entity where name_ like 'MR-%'", Long.class) + .getSingleResult(); + assertThat(count).isEqualTo(3L); + } + + @Test + public void execute_multi_row_in_a_loop_with_trailing_addRow() { + // The loop-friendly shape: every iteration appends a row, no "is this the first row?" + // bookkeeping, and a trailing addRow() is fine. + var entity = QGeneratedKeyEntity.generatedKeyEntity; + var clause = insert(entity).columns(entity.name); + for (var name : new String[] {"Loop1", "Loop2", "Loop3", "Loop4"}) { + clause.values(name).addRow(); + } + long rows = clause.execute(); + + assertThat(rows).isEqualTo(4L); + } + @Test public void execute_without_template_uses_jpql_path() { // Regression for #1757: plain value INSERTs must keep using the JPQL path so diff --git a/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/JPAExecuteWithKeyTest.java b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/JPAExecuteWithKeyTest.java index 9f254b84f3..e33e93d2db 100644 --- a/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/JPAExecuteWithKeyTest.java +++ b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/JPAExecuteWithKeyTest.java @@ -244,6 +244,46 @@ public void execute_with_function_template_routes_through_native_sql() { assertThat(stored).isEqualTo("HELLO"); } + @Test + public void execute_multi_row_without_keys_inserts_all_rows() { + // #1692 follow-up: addRow() must also work without returning keys — a plain + // execute() should emit a single multi-row INSERT and report all affected rows. + var entity = QGeneratedKeyEntity.generatedKeyEntity; + long rows = + insert(entity) + .columns(entity.name) + .values("MR-A") + .addRow() + .values("MR-B") + .addRow() + .values("MR-C") + .execute(); + + assertThat(rows).isEqualTo(3L); + + var count = + (Number) + entityManager + .createNativeQuery( + "select count(*) from generated_key_entity where name_ like 'MR-%'") + .getSingleResult(); + assertThat(count.longValue()).isEqualTo(3L); + } + + @Test + public void execute_multi_row_in_a_loop_with_trailing_addRow() { + // The loop-friendly shape: every iteration appends a row, no "is this the first row?" + // bookkeeping, and a trailing addRow() is fine. + var entity = QGeneratedKeyEntity.generatedKeyEntity; + var clause = insert(entity).columns(entity.name); + for (var name : new String[] {"Loop1", "Loop2", "Loop3", "Loop4"}) { + clause.values(name).addRow(); + } + long rows = clause.execute(); + + assertThat(rows).isEqualTo(4L); + } + @Test public void execute_without_template_uses_jpql_path() { // Regression for #1757: plain value INSERTs must keep using the JPQL path so From 077fe6a3f0904ac8d88e4025447ef1e75f4eab60 Mon Sep 17 00:00:00 2001 From: zio0911 Date: Thu, 25 Jun 2026 14:49:04 +0900 Subject: [PATCH 2/2] 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. --- .../jpa/hibernate/HibernateInsertClause.java | 17 +++++++++++ .../querydsl/jpa/impl/JPAInsertClause.java | 17 +++++++++++ .../jpa/HibernateExecuteWithKeyTest.java | 30 +++++++++++++++++++ .../querydsl/jpa/JPAExecuteWithKeyTest.java | 30 +++++++++++++++++++ 4 files changed, 94 insertions(+) diff --git a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/HibernateInsertClause.java b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/HibernateInsertClause.java index edfbd4aede..7a0fd8b2ee 100644 --- a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/HibernateInsertClause.java +++ b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/HibernateInsertClause.java @@ -61,6 +61,14 @@ public class HibernateInsertClause implements InsertClause>> rows = new ArrayList<>(); + /** + * Column paths captured at the first {@link #addRow()} call. After {@code addRow()} clears the + * per-row {@code inserts}/{@code values} buffer, this lets executors recover the column list when + * the trailing iteration was also flushed (e.g. {@code for (...) { insert.set(...).addRow(); }}). + * Null until the first {@code addRow()}. + */ + @Nullable private List> rowColumnPaths; + private SubQueryExpression subQuery; private final SessionHolder session; @@ -152,6 +160,9 @@ private long executeMultiRow() { } var effectiveColumns = JpaInsertNativeHelper.effectiveColumns(inserts, columns); + if (effectiveColumns.isEmpty() && rowColumnPaths != null) { + effectiveColumns = new ArrayList<>(rowColumnPaths); + } if (effectiveColumns.isEmpty()) { throw new IllegalStateException("No columns specified for insert"); } @@ -290,6 +301,9 @@ public HibernateInsertClause addRow() { if (values.isEmpty() && inserts.isEmpty()) { throw new IllegalStateException("No values to add as row"); } + if (rowColumnPaths == null) { + rowColumnPaths = JpaInsertNativeHelper.effectiveColumns(inserts, columns); + } rows.add(JpaInsertNativeHelper.effectiveValues(inserts, values)); values.clear(); inserts.clear(); @@ -329,6 +343,9 @@ public List executeWithKeys(Class type) { } var effectiveColumns = JpaInsertNativeHelper.effectiveColumns(inserts, columns); + if (effectiveColumns.isEmpty() && rowColumnPaths != null) { + effectiveColumns = new ArrayList<>(rowColumnPaths); + } if (effectiveColumns.isEmpty()) { throw new IllegalStateException("No columns specified for insert"); } diff --git a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/impl/JPAInsertClause.java b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/impl/JPAInsertClause.java index 353cb827d3..576dd61670 100644 --- a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/impl/JPAInsertClause.java +++ b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/impl/JPAInsertClause.java @@ -56,6 +56,14 @@ public class JPAInsertClause implements InsertClause { private final List>> rows = new ArrayList<>(); + /** + * Column paths captured at the first {@link #addRow()} call. After {@code addRow()} clears the + * per-row {@code inserts}/{@code values} buffer, this lets executors recover the column list when + * the trailing iteration was also flushed (e.g. {@code for (...) { insert.set(...).addRow(); }}). + * Null until the first {@code addRow()}. + */ + @Nullable private List> rowColumnPaths; + private final EntityManager entityManager; private final JPQLTemplates templates; @@ -136,6 +144,9 @@ private long executeMultiRow() { } var effectiveColumns = JpaInsertNativeHelper.effectiveColumns(inserts, columns); + if (effectiveColumns.isEmpty() && rowColumnPaths != null) { + effectiveColumns = new ArrayList<>(rowColumnPaths); + } if (effectiveColumns.isEmpty()) { throw new IllegalStateException("No columns specified for insert"); } @@ -273,6 +284,9 @@ public JPAInsertClause addRow() { if (values.isEmpty() && inserts.isEmpty()) { throw new IllegalStateException("No values to add as row"); } + if (rowColumnPaths == null) { + rowColumnPaths = JpaInsertNativeHelper.effectiveColumns(inserts, columns); + } rows.add(JpaInsertNativeHelper.effectiveValues(inserts, values)); values.clear(); inserts.clear(); @@ -312,6 +326,9 @@ public List executeWithKeys(Class type) { } var effectiveColumns = JpaInsertNativeHelper.effectiveColumns(inserts, columns); + if (effectiveColumns.isEmpty() && rowColumnPaths != null) { + effectiveColumns = new ArrayList<>(rowColumnPaths); + } if (effectiveColumns.isEmpty()) { throw new IllegalStateException("No columns specified for insert"); } diff --git a/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/HibernateExecuteWithKeyTest.java b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/HibernateExecuteWithKeyTest.java index 29f41063fb..f893f1b4f2 100644 --- a/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/HibernateExecuteWithKeyTest.java +++ b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/HibernateExecuteWithKeyTest.java @@ -265,6 +265,36 @@ public void execute_multi_row_in_a_loop_with_trailing_addRow() { assertThat(rows).isEqualTo(4L); } + @Test + public void execute_multi_row_set_style_with_trailing_addRow() { + // The loop-friendly shape for set()-style: every iteration calls set()...addRow(). + // After the loop, inserts/columns are both empty (set() goes into inserts which addRow() + // clears), but addRow() captures the column paths on its first call so executors can + // recover the column list. No "first row" flag, no buffer-retention trick. + var entity = QGeneratedKeyEntity.generatedKeyEntity; + var insert = insert(entity); + for (var name : new String[] {"Set1", "Set2", "Set3"}) { + insert.set(entity.name, name).addRow(); + } + long rows = insert.execute(); + + assertThat(rows).isEqualTo(3L); + } + + @Test + public void executeWithKeys_multi_row_set_style_with_trailing_addRow() { + // Same loop-friendly shape but routed through executeWithKeys to return generated keys. + var entity = QGeneratedKeyEntity.generatedKeyEntity; + var insert = insert(entity); + for (var name : new String[] {"KSet1", "KSet2"}) { + insert.set(entity.name, name).addRow(); + } + var keys = insert.executeWithKeys(entity.id); + + assertThat(keys).hasSize(2); + assertThat(keys.get(0)).isLessThan(keys.get(1)); + } + @Test public void execute_without_template_uses_jpql_path() { // Regression for #1757: plain value INSERTs must keep using the JPQL path so diff --git a/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/JPAExecuteWithKeyTest.java b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/JPAExecuteWithKeyTest.java index e33e93d2db..34d6d3cd26 100644 --- a/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/JPAExecuteWithKeyTest.java +++ b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/JPAExecuteWithKeyTest.java @@ -284,6 +284,36 @@ public void execute_multi_row_in_a_loop_with_trailing_addRow() { assertThat(rows).isEqualTo(4L); } + @Test + public void execute_multi_row_set_style_with_trailing_addRow() { + // The loop-friendly shape for set()-style: every iteration calls set()...addRow(). + // After the loop, inserts/columns are both empty (set() goes into inserts which addRow() + // clears), but addRow() captures the column paths on its first call so executors can + // recover the column list. No "first row" flag, no buffer-retention trick. + var entity = QGeneratedKeyEntity.generatedKeyEntity; + var insert = insert(entity); + for (var name : new String[] {"Set1", "Set2", "Set3"}) { + insert.set(entity.name, name).addRow(); + } + long rows = insert.execute(); + + assertThat(rows).isEqualTo(3L); + } + + @Test + public void executeWithKeys_multi_row_set_style_with_trailing_addRow() { + // Same loop-friendly shape but routed through executeWithKeys to return generated keys. + var entity = QGeneratedKeyEntity.generatedKeyEntity; + var insert = insert(entity); + for (var name : new String[] {"KSet1", "KSet2"}) { + insert.set(entity.name, name).addRow(); + } + var keys = insert.executeWithKeys(entity.id); + + assertThat(keys).hasSize(2); + assertThat(keys.get(0)).isLessThan(keys.get(1)); + } + @Test public void execute_without_template_uses_jpql_path() { // Regression for #1757: plain value INSERTs must keep using the JPQL path so