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
@@ -236,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();
@@ -275,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");
}
@@ -289,6 +360,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..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;
@@ -76,6 +84,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 +115,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 +133,47 @@ 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() && rowColumnPaths != null) {
+ effectiveColumns = new ArrayList<>(rowColumnPaths);
+ }
+ 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 +243,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 +265,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
@@ -222,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();
@@ -261,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");
}
@@ -275,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/test/java/com/querydsl/jpa/HibernateExecuteWithKeyTest.java b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/HibernateExecuteWithKeyTest.java
index 0cab9b12cf..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
@@ -226,6 +226,75 @@ 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_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 9f254b84f3..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
@@ -244,6 +244,76 @@ 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_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