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..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; @@ -90,6 +98,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 +130,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 +149,50 @@ 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() && 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()); + + 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 +261,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 +282,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 @@ -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