From da4215fcd4bc0a153dcb5783b662f369c82875d5 Mon Sep 17 00:00:00 2001 From: zio0911 Date: Tue, 14 Apr 2026 18:49:21 +0900 Subject: [PATCH 1/4] Add executeWithKey() to JPAInsertClause and HibernateInsertClause Closes #1692 --- .../querydsl/jpa/JpaInsertNativeHelper.java | 186 ++++++++++++++++++ .../jpa/hibernate/DefaultSessionHolder.java | 6 + .../jpa/hibernate/HibernateInsertClause.java | 68 +++++++ .../jpa/hibernate/NoSessionHolder.java | 6 + .../querydsl/jpa/hibernate/SessionHolder.java | 10 + .../jpa/hibernate/StatelessSessionHolder.java | 6 + .../querydsl/jpa/impl/JPAInsertClause.java | 68 +++++++ .../com/querydsl/jpa/DummySessionHolder.java | 6 + .../jpa/HibernateExecuteWithKeyTest.java | 139 +++++++++++++ .../querydsl/jpa/JPAExecuteWithKeyTest.java | 132 +++++++++++++ .../jpa/JpaInsertNativeHelperTest.java | 79 ++++++++ .../jpa/domain/GeneratedKeyEntity.java | 40 ++++ .../test/resources/META-INF/persistence.xml | 17 ++ 13 files changed, 763 insertions(+) create mode 100644 querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/JpaInsertNativeHelper.java create mode 100644 querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/HibernateExecuteWithKeyTest.java create mode 100644 querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/JPAExecuteWithKeyTest.java create mode 100644 querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/JpaInsertNativeHelperTest.java create mode 100644 querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/domain/GeneratedKeyEntity.java 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 new file mode 100644 index 0000000000..2a6cd528e8 --- /dev/null +++ b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/JpaInsertNativeHelper.java @@ -0,0 +1,186 @@ +/* + * Copyright 2015, The Querydsl Team (http://www.querydsl.com/team) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.querydsl.jpa; + +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.ParamExpression; +import com.querydsl.core.types.ParamNotSetException; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.Param; +import jakarta.persistence.Column; +import jakarta.persistence.Table; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.Nullable; + +/** + * Helper for building native SQL INSERT statements from JPA entity metadata. Used by {@link + * com.querydsl.jpa.impl.JPAInsertClause} and {@link + * com.querydsl.jpa.hibernate.HibernateInsertClause} to support {@code executeWithKey()}. + * + *

This is an internal API and not intended for direct use by application code. + */ +public final class JpaInsertNativeHelper { + + private JpaInsertNativeHelper() {} + + /** + * Resolve the SQL table name for an entity class. + * + * @param entityClass the JPA entity class + * @return the SQL table name + */ + public static String resolveTableName(Class entityClass) { + if (entityClass.isAnnotationPresent(Table.class)) { + var table = entityClass.getAnnotation(Table.class); + if (!table.name().isEmpty()) { + var sb = new StringBuilder(); + if (!table.schema().isEmpty()) { + sb.append(table.schema()).append('.'); + } + sb.append(table.name()); + return sb.toString(); + } + } + return entityClass.getSimpleName(); + } + + /** + * Resolve the SQL column name for a path. Reads {@code @Column} annotation if present, otherwise + * falls back to the path metadata name. + * + * @param path the query path + * @return the SQL column name + */ + public static String resolveColumnName(Path path) { + if (path.getAnnotatedElement() != null + && path.getAnnotatedElement().isAnnotationPresent(Column.class)) { + var column = path.getAnnotatedElement().getAnnotation(Column.class); + if (!column.name().isEmpty()) { + return column.name(); + } + } + return path.getMetadata().getName(); + } + + /** + * Build a native SQL INSERT statement from entity metadata and column paths. + * + * @param entityClass the entity class (for table name resolution) + * @param columns the columns to insert + * @return the native SQL INSERT string with positional parameters + */ + public static String buildNativeInsertSQL(Class entityClass, Collection> columns) { + var tableName = resolveTableName(entityClass); + var sb = new StringBuilder(); + sb.append("INSERT INTO ").append(tableName).append(" ("); + + var first = true; + for (Path col : columns) { + if (!first) { + sb.append(", "); + } + sb.append(resolveColumnName(col)); + first = false; + } + + sb.append(") VALUES ("); + first = true; + for (int i = 0; i < columns.size(); i++) { + if (!first) { + sb.append(", "); + } + sb.append('?'); + first = false; + } + sb.append(')'); + + return sb.toString(); + } + + /** + * Resolve constant values from the serializer, unwrapping {@link Param} expressions. + * + * @param constants the constants from the serializer + * @param params the parameter bindings + * @return resolved values ready for JDBC binding + */ + public static Object[] resolveConstants( + List constants, Map, Object> params) { + var result = new Object[constants.size()]; + for (var i = 0; i < constants.size(); i++) { + var val = constants.get(i); + if (val instanceof Param param) { + val = params.get(val); + if (val == null) { + throw new ParamNotSetException(param); + } + } + result[i] = val; + } + return result; + } + + /** + * Execute a native SQL INSERT with RETURN_GENERATED_KEYS and return the generated key. + * + * @param the key type + * @param conn the JDBC connection (not closed by this method) + * @param sql the native SQL INSERT string + * @param params the parameter values to bind + * @param keyType the expected key type + * @return the generated key, or null if no rows were inserted + * @throws SQLException if a database error occurs + */ + @Nullable + public static T executeAndReturnKey( + java.sql.Connection conn, String sql, Object[] params, Class keyType) throws SQLException { + try (PreparedStatement stmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { + for (int i = 0; i < params.length; i++) { + stmt.setObject(i + 1, params[i]); + } + stmt.executeUpdate(); + + try (ResultSet rs = stmt.getGeneratedKeys()) { + if (rs.next()) { + return rs.getObject(1, keyType); + } + return null; + } + } + } + + /** + * Collect effective columns and values from either the set-style inserts map or the + * columns/values lists. + * + * @param inserts the set-style inserts (path to expression mapping) + * @param columns the columns list (from columns().values() style) + * @param values the values list + * @param serializer used to extract constant values from expressions + * @return the effective column paths + */ + public static Collection> effectiveColumns( + Map, Expression> inserts, List> columns) { + if (!inserts.isEmpty()) { + return inserts.keySet(); + } + return columns; + } +} diff --git a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/DefaultSessionHolder.java b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/DefaultSessionHolder.java index 4cb48e055a..fde50f51c0 100644 --- a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/DefaultSessionHolder.java +++ b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/DefaultSessionHolder.java @@ -14,6 +14,7 @@ package com.querydsl.jpa.hibernate; import org.hibernate.Session; +import org.hibernate.jdbc.ReturningWork; import org.hibernate.query.NativeQuery; import org.hibernate.query.Query; @@ -39,4 +40,9 @@ public Query createQuery(String queryString) { public NativeQuery createSQLQuery(String queryString) { return session.createNativeQuery(queryString); } + + @Override + public T doReturningWork(ReturningWork work) { + return session.doReturningWork(work); + } } 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 ee2b6801bc..b0e1d90f50 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 @@ -14,6 +14,7 @@ package com.querydsl.jpa.hibernate; import com.querydsl.core.JoinType; +import com.querydsl.core.QueryException; import com.querydsl.core.dml.InsertClause; import com.querydsl.core.support.QueryMixin; import com.querydsl.core.types.EntityPath; @@ -25,6 +26,8 @@ import com.querydsl.jpa.JPAQueryMixin; import com.querydsl.jpa.JPQLSerializer; import com.querydsl.jpa.JPQLTemplates; +import com.querydsl.jpa.JpaInsertNativeHelper; +import java.sql.SQLException; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -35,6 +38,7 @@ import org.hibernate.Session; import org.hibernate.StatelessSession; import org.hibernate.query.Query; +import org.jetbrains.annotations.Nullable; /** * UpdateClause implementation for Hibernate @@ -97,6 +101,70 @@ public long execute() { return query.executeUpdate(); } + /** + * Execute the clause and return the generated key with the type of the given path. If no rows + * were created, null is returned, otherwise the key of the first row is returned. + * + *

This method bypasses JPQL and executes a native SQL INSERT via JDBC to retrieve the + * generated key using Hibernate's {@code Session.doReturningWork()}. + * + *

Note: {@code INSERT ... SELECT} subqueries are not supported by this method. + * + * @param key type + * @param path path for key (used to determine return type) + * @return generated key, or null if no rows were created + * @throws QueryException if a database error occurs + */ + @SuppressWarnings("unchecked") + @Nullable + public T executeWithKey(Path path) { + return executeWithKey((Class) path.getType()); + } + + /** + * Execute the clause and return the generated key cast to the given type. If no rows were + * created, null is returned, otherwise the key of the first row is returned. + * + * @param key type + * @param type class of the key type + * @return generated key, or null if no rows were created + * @throws QueryException if a database error occurs + */ + @Nullable + public T executeWithKey(Class type) { + if (subQuery != null) { + throw new UnsupportedOperationException( + "executeWithKey is not supported for INSERT ... SELECT subqueries"); + } + + var effectiveColumns = JpaInsertNativeHelper.effectiveColumns(inserts, columns); + if (effectiveColumns.isEmpty()) { + throw new IllegalStateException("No columns specified for insert"); + } + + var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType(); + + // Serialize to collect constant values + var serializer = new JPQLSerializer(templates, null); + serializer.serializeForInsert( + queryMixin.getMetadata(), effectiveColumns, values, subQuery, inserts); + + var params = + JpaInsertNativeHelper.resolveConstants( + serializer.getConstants(), queryMixin.getMetadata().getParams()); + + var sql = JpaInsertNativeHelper.buildNativeInsertSQL(entityClass, effectiveColumns); + + return session.doReturningWork( + connection -> { + try { + return JpaInsertNativeHelper.executeAndReturnKey(connection, sql, params, type); + } catch (SQLException e) { + throw new QueryException("Failed to execute insert with generated key", e); + } + }); + } + @Override public HibernateInsertClause columns(Path... columns) { this.columns.addAll(Arrays.asList(columns)); diff --git a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/NoSessionHolder.java b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/NoSessionHolder.java index 8df92b7e7e..1215694209 100644 --- a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/NoSessionHolder.java +++ b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/NoSessionHolder.java @@ -13,6 +13,7 @@ */ package com.querydsl.jpa.hibernate; +import org.hibernate.jdbc.ReturningWork; import org.hibernate.query.NativeQuery; import org.hibernate.query.Query; @@ -36,4 +37,9 @@ public Query createQuery(String queryString) { public NativeQuery createSQLQuery(String queryString) { throw new UnsupportedOperationException("No session in detached Query available"); } + + @Override + public T doReturningWork(ReturningWork work) { + throw new UnsupportedOperationException("No session in detached Query available"); + } } diff --git a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/SessionHolder.java b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/SessionHolder.java index ecfd9b5999..df526c1d02 100644 --- a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/SessionHolder.java +++ b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/SessionHolder.java @@ -13,6 +13,7 @@ */ package com.querydsl.jpa.hibernate; +import org.hibernate.jdbc.ReturningWork; import org.hibernate.query.NativeQuery; import org.hibernate.query.Query; @@ -38,4 +39,13 @@ public interface SessionHolder { * @return query */ NativeQuery createSQLQuery(String queryString); + + /** + * Execute a {@link ReturningWork} within the session's transaction context. + * + * @param the return type + * @param work the work to execute with a JDBC Connection + * @return the result of the work + */ + T doReturningWork(ReturningWork work); } diff --git a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/StatelessSessionHolder.java b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/StatelessSessionHolder.java index d94c0c6d29..7e11188d2d 100644 --- a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/StatelessSessionHolder.java +++ b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/StatelessSessionHolder.java @@ -14,6 +14,7 @@ package com.querydsl.jpa.hibernate; import org.hibernate.StatelessSession; +import org.hibernate.jdbc.ReturningWork; import org.hibernate.query.NativeQuery; import org.hibernate.query.Query; @@ -41,4 +42,9 @@ public Query createQuery(String queryString) { public NativeQuery createSQLQuery(String queryString) { return session.createNativeQuery(queryString); } + + @Override + public T doReturningWork(ReturningWork work) { + return session.doReturningWork(work); + } } 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 def564d7d9..e2e625959c 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 @@ -14,6 +14,7 @@ package com.querydsl.jpa.impl; import com.querydsl.core.JoinType; +import com.querydsl.core.QueryException; import com.querydsl.core.dml.InsertClause; import com.querydsl.core.support.QueryMixin; import com.querydsl.core.types.EntityPath; @@ -24,6 +25,7 @@ import com.querydsl.jpa.JPAQueryMixin; import com.querydsl.jpa.JPQLSerializer; import com.querydsl.jpa.JPQLTemplates; +import com.querydsl.jpa.JpaInsertNativeHelper; import jakarta.persistence.EntityManager; import jakarta.persistence.LockModeType; import java.util.ArrayList; @@ -84,6 +86,72 @@ public long execute() { return query.executeUpdate(); } + /** + * Execute the clause and return the generated key with the type of the given path. If no rows + * were created, null is returned, otherwise the key of the first row is returned. + * + *

This method bypasses JPQL and executes a native SQL INSERT via JDBC to retrieve the + * generated key. It requires that the JPA provider supports {@code + * EntityManager.unwrap(Connection.class)}. + * + *

Note: {@code INSERT ... SELECT} subqueries are not supported by this method. + * + * @param key type + * @param path path for key (used to determine return type) + * @return generated key, or null if no rows were created + * @throws QueryException if a database error occurs or the operation is not supported + */ + @SuppressWarnings("unchecked") + @Nullable + public T executeWithKey(Path path) { + return executeWithKey((Class) path.getType()); + } + + /** + * Execute the clause and return the generated key cast to the given type. If no rows were + * created, null is returned, otherwise the key of the first row is returned. + * + * @param key type + * @param type class of the key type + * @return generated key, or null if no rows were created + * @throws QueryException if a database error occurs or the operation is not supported + */ + @Nullable + public T executeWithKey(Class type) { + if (subQuery != null) { + throw new UnsupportedOperationException( + "executeWithKey is not supported for INSERT ... SELECT subqueries"); + } + + var effectiveColumns = JpaInsertNativeHelper.effectiveColumns(inserts, columns); + if (effectiveColumns.isEmpty()) { + throw new IllegalStateException("No columns specified for insert"); + } + + var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType(); + + // Serialize to collect constant values + var serializer = new JPQLSerializer(templates, entityManager); + serializer.serializeForInsert( + queryMixin.getMetadata(), effectiveColumns, values, subQuery, inserts); + + var params = + JpaInsertNativeHelper.resolveConstants( + serializer.getConstants(), queryMixin.getMetadata().getParams()); + + var sql = JpaInsertNativeHelper.buildNativeInsertSQL(entityClass, effectiveColumns); + + try { + return entityManager + .unwrap(org.hibernate.Session.class) + .doReturningWork( + connection -> + JpaInsertNativeHelper.executeAndReturnKey(connection, sql, params, type)); + } catch (Exception e) { + throw new QueryException("Failed to execute insert with generated key", e); + } + } + public JPAInsertClause setLockMode(LockModeType lockMode) { this.lockMode = lockMode; return this; diff --git a/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/DummySessionHolder.java b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/DummySessionHolder.java index f44f4e9ad6..07703e7ef7 100644 --- a/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/DummySessionHolder.java +++ b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/DummySessionHolder.java @@ -14,6 +14,7 @@ package com.querydsl.jpa; import com.querydsl.jpa.hibernate.SessionHolder; +import org.hibernate.jdbc.ReturningWork; import org.hibernate.query.NativeQuery; import org.hibernate.query.Query; @@ -28,4 +29,9 @@ public Query createQuery(String queryString) { public NativeQuery createSQLQuery(String queryString) { throw new UnsupportedOperationException(); } + + @Override + public T doReturningWork(ReturningWork work) { + throw new UnsupportedOperationException(); + } } 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 new file mode 100644 index 0000000000..5b91de7c46 --- /dev/null +++ b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/HibernateExecuteWithKeyTest.java @@ -0,0 +1,139 @@ +/* + * Copyright 2015, The Querydsl Team (http://www.querydsl.com/team) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.querydsl.jpa; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.querydsl.core.types.EntityPath; +import com.querydsl.jpa.domain.GeneratedKeyEntity; +import com.querydsl.jpa.domain.QGeneratedKeyEntity; +import com.querydsl.jpa.hibernate.HibernateInsertClause; +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.hibernate.Transaction; +import org.hibernate.cfg.Configuration; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +public class HibernateExecuteWithKeyTest { + + private static SessionFactory sessionFactory; + private Session session; + private Transaction tx; + + @BeforeClass + public static void setUpClass() { + var cfg = new Configuration(); + cfg.addAnnotatedClass(GeneratedKeyEntity.class); + cfg.setProperty("hibernate.connection.driver_class", "org.h2.Driver"); + cfg.setProperty("hibernate.connection.url", "jdbc:h2:mem:hib_ewk_test;DB_CLOSE_DELAY=-1"); + cfg.setProperty("hibernate.connection.username", "sa"); + cfg.setProperty("hibernate.connection.password", ""); + cfg.setProperty("hibernate.hbm2ddl.auto", "create-drop"); + cfg.setProperty("hibernate.show_sql", "false"); + sessionFactory = cfg.buildSessionFactory(); + } + + @AfterClass + public static void tearDownClass() { + if (sessionFactory != null) { + sessionFactory.close(); + } + } + + @Before + public void setUp() { + session = sessionFactory.openSession(); + tx = session.beginTransaction(); + } + + @After + public void tearDown() { + if (tx != null && tx.isActive()) { + tx.rollback(); + } + if (session != null) { + session.close(); + } + } + + private HibernateInsertClause insert(EntityPath entity) { + return new HibernateInsertClause(session, entity); + } + + @Test + public void executeWithKey_set_style() { + var entity = QGeneratedKeyEntity.generatedKeyEntity; + Long id = insert(entity).set(entity.name, "TestName").executeWithKey(entity.id); + + assertThat(id).isNotNull(); + assertThat(id).isPositive(); + } + + @Test + public void executeWithKey_columns_values_style() { + var entity = QGeneratedKeyEntity.generatedKeyEntity; + Long id = insert(entity).columns(entity.name).values("TestName2").executeWithKey(entity.id); + + assertThat(id).isNotNull(); + assertThat(id).isPositive(); + } + + @Test + public void executeWithKey_with_class_type() { + var entity = QGeneratedKeyEntity.generatedKeyEntity; + Long id = insert(entity).set(entity.name, "TestName3").executeWithKey(Long.class); + + assertThat(id).isNotNull(); + assertThat(id).isPositive(); + } + + @Test + public void executeWithKey_multiple_inserts_return_different_keys() { + var entity = QGeneratedKeyEntity.generatedKeyEntity; + Long id1 = insert(entity).set(entity.name, "Name1").executeWithKey(entity.id); + Long id2 = insert(entity).set(entity.name, "Name2").executeWithKey(entity.id); + + assertThat(id1).isNotNull(); + assertThat(id2).isNotNull(); + assertThat(id2).isGreaterThan(id1); + } + + @Test + public void executeWithKey_with_column_annotation() { + var entity = QGeneratedKeyEntity.generatedKeyEntity; + Long id = insert(entity).set(entity.name, "ColumnTest").executeWithKey(entity.id); + + assertThat(id).isNotNull(); + assertThat(id).isPositive(); + } + + @Test + public void executeWithKey_rejects_subquery() { + var entity = QGeneratedKeyEntity.generatedKeyEntity; + var other = new QGeneratedKeyEntity("other"); + + assertThatThrownBy( + () -> + insert(entity) + .columns(entity.name) + .select(JPAExpressions.select(other.name).from(other)) + .executeWithKey(entity.id)) + .isInstanceOf(UnsupportedOperationException.class); + } +} 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 new file mode 100644 index 0000000000..9721a2bdb3 --- /dev/null +++ b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/JPAExecuteWithKeyTest.java @@ -0,0 +1,132 @@ +/* + * Copyright 2015, The Querydsl Team (http://www.querydsl.com/team) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.querydsl.jpa; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.querydsl.core.types.EntityPath; +import com.querydsl.jpa.domain.QGeneratedKeyEntity; +import com.querydsl.jpa.impl.JPAInsertClause; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.EntityTransaction; +import jakarta.persistence.Persistence; +import java.util.Map; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +public class JPAExecuteWithKeyTest { + + private static EntityManagerFactory emf; + private EntityManager entityManager; + private EntityTransaction tx; + + @BeforeClass + public static void setUpClass() { + emf = + Persistence.createEntityManagerFactory( + "executeWithKeyTest", + Map.of( + "jakarta.persistence.jdbc.driver", "org.h2.Driver", + "jakarta.persistence.jdbc.url", "jdbc:h2:mem:jpa_ewk_test;DB_CLOSE_DELAY=-1", + "jakarta.persistence.jdbc.user", "sa", + "jakarta.persistence.jdbc.password", "", + "hibernate.hbm2ddl.auto", "create-drop", + "hibernate.show_sql", "false")); + } + + @AfterClass + public static void tearDownClass() { + if (emf != null) { + emf.close(); + } + } + + @Before + public void setUp() { + entityManager = emf.createEntityManager(); + tx = entityManager.getTransaction(); + tx.begin(); + } + + @After + public void tearDown() { + if (tx != null && tx.isActive()) { + tx.rollback(); + } + if (entityManager != null) { + entityManager.close(); + } + } + + private JPAInsertClause insert(EntityPath entity) { + return new JPAInsertClause(entityManager, entity); + } + + @Test + public void executeWithKey_set_style() { + var entity = QGeneratedKeyEntity.generatedKeyEntity; + Long id = insert(entity).set(entity.name, "TestName").executeWithKey(entity.id); + + assertThat(id).isNotNull(); + assertThat(id).isPositive(); + } + + @Test + public void executeWithKey_columns_values_style() { + var entity = QGeneratedKeyEntity.generatedKeyEntity; + Long id = insert(entity).columns(entity.name).values("TestName2").executeWithKey(entity.id); + + assertThat(id).isNotNull(); + assertThat(id).isPositive(); + } + + @Test + public void executeWithKey_with_class_type() { + var entity = QGeneratedKeyEntity.generatedKeyEntity; + Long id = insert(entity).set(entity.name, "TestName3").executeWithKey(Long.class); + + assertThat(id).isNotNull(); + assertThat(id).isPositive(); + } + + @Test + public void executeWithKey_multiple_inserts_return_different_keys() { + var entity = QGeneratedKeyEntity.generatedKeyEntity; + Long id1 = insert(entity).set(entity.name, "Name1").executeWithKey(entity.id); + Long id2 = insert(entity).set(entity.name, "Name2").executeWithKey(entity.id); + + assertThat(id1).isNotNull(); + assertThat(id2).isNotNull(); + assertThat(id2).isGreaterThan(id1); + } + + @Test + public void executeWithKey_rejects_subquery() { + var entity = QGeneratedKeyEntity.generatedKeyEntity; + var other = new QGeneratedKeyEntity("other"); + + assertThatThrownBy( + () -> + insert(entity) + .columns(entity.name) + .select(JPAExpressions.select(other.name).from(other)) + .executeWithKey(entity.id)) + .isInstanceOf(UnsupportedOperationException.class); + } +} diff --git a/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/JpaInsertNativeHelperTest.java b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/JpaInsertNativeHelperTest.java new file mode 100644 index 0000000000..61157b58fa --- /dev/null +++ b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/JpaInsertNativeHelperTest.java @@ -0,0 +1,79 @@ +/* + * Copyright 2015, The Querydsl Team (http://www.querydsl.com/team) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.querydsl.jpa; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.querydsl.jpa.domain.Author; +import com.querydsl.jpa.domain.GeneratedKeyEntity; +import com.querydsl.jpa.domain.Numeric; +import com.querydsl.jpa.domain.QAuthor; +import com.querydsl.jpa.domain.QGeneratedKeyEntity; +import com.querydsl.jpa.domain.QNumeric; +import java.util.List; +import org.junit.Test; + +public class JpaInsertNativeHelperTest { + + @Test + public void resolveTableName_with_table_annotation() { + // Author has @Table(name = "author_") + assertThat(JpaInsertNativeHelper.resolveTableName(Author.class)).isEqualTo("author_"); + } + + @Test + public void resolveTableName_with_generated_key_entity() { + // GeneratedKeyEntity has @Table(name = "generated_key_entity") + assertThat(JpaInsertNativeHelper.resolveTableName(GeneratedKeyEntity.class)) + .isEqualTo("generated_key_entity"); + } + + @Test + public void resolveColumnName_with_column_annotation() { + // Numeric.value has @Column(name = "value_") + var numeric = QNumeric.numeric; + assertThat(JpaInsertNativeHelper.resolveColumnName(numeric.value)).isEqualTo("value_"); + } + + @Test + public void resolveColumnName_with_name_column() { + // GeneratedKeyEntity.name has @Column(name = "name_") + var entity = QGeneratedKeyEntity.generatedKeyEntity; + assertThat(JpaInsertNativeHelper.resolveColumnName(entity.name)).isEqualTo("name_"); + } + + @Test + public void resolveColumnName_without_column_annotation() { + // Author.name has no @Column annotation + var author = QAuthor.author; + assertThat(JpaInsertNativeHelper.resolveColumnName(author.name)).isEqualTo("name"); + } + + @Test + public void buildNativeInsertSQL() { + var entity = QGeneratedKeyEntity.generatedKeyEntity; + var sql = + JpaInsertNativeHelper.buildNativeInsertSQL(GeneratedKeyEntity.class, List.of(entity.name)); + + assertThat(sql).isEqualTo("INSERT INTO generated_key_entity (name_) VALUES (?)"); + } + + @Test + public void buildNativeInsertSQL_multiple_columns() { + var numeric = QNumeric.numeric; + var sql = JpaInsertNativeHelper.buildNativeInsertSQL(Numeric.class, List.of(numeric.value)); + + assertThat(sql).isEqualTo("INSERT INTO numeric_ (value_) VALUES (?)"); + } +} diff --git a/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/domain/GeneratedKeyEntity.java b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/domain/GeneratedKeyEntity.java new file mode 100644 index 0000000000..a616e374fd --- /dev/null +++ b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/domain/GeneratedKeyEntity.java @@ -0,0 +1,40 @@ +package com.querydsl.jpa.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.io.Serial; +import java.io.Serializable; + +@Entity +@Table(name = "generated_key_entity") +public class GeneratedKeyEntity implements Serializable { + + @Serial private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "name_") + private String name; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/querydsl-libraries/querydsl-jpa/src/test/resources/META-INF/persistence.xml b/querydsl-libraries/querydsl-jpa/src/test/resources/META-INF/persistence.xml index 60ca31c002..06c5f59c47 100644 --- a/querydsl-libraries/querydsl-jpa/src/test/resources/META-INF/persistence.xml +++ b/querydsl-libraries/querydsl-jpa/src/test/resources/META-INF/persistence.xml @@ -198,6 +198,23 @@ + + + + org.hibernate.jpa.HibernatePersistenceProvider + com.querydsl.jpa.domain.GeneratedKeyEntity + true + + + + + + + + + + + org.hibernate.jpa.HibernatePersistenceProvider From 19ff4ceffd1a0eb678a51031938b17cf3198f2e5 Mon Sep 17 00:00:00 2001 From: zio0911 Date: Mon, 20 Apr 2026 11:42:30 +0900 Subject: [PATCH 2/4] Use SQLTemplates.quoteIdentifier for native SQL identifiers Apply dialect-specific identifier quoting to table and column names in JpaInsertNativeHelper, consistent with NativeSQLSerializer. This avoids issues when identifiers contain SQL reserved words, spaces, or dialect-specific characters. - buildNativeInsertSQL now accepts SQLTemplates parameter - Schema-qualified table names quote schema and table parts separately - JPAInsertClause and HibernateInsertClause pass SQLTemplates.DEFAULT - Deprecate the overload without SQLTemplates - Add test covering always-quote templates --- .../querydsl/jpa/JpaInsertNativeHelper.java | 57 +++++++++++++++---- .../jpa/hibernate/HibernateInsertClause.java | 5 +- .../querydsl/jpa/impl/JPAInsertClause.java | 5 +- .../jpa/JpaInsertNativeHelperTest.java | 22 ++++++- 4 files changed, 75 insertions(+), 14 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 2a6cd528e8..64694a2e6b 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 @@ -18,6 +18,7 @@ import com.querydsl.core.types.ParamNotSetException; import com.querydsl.core.types.Path; import com.querydsl.core.types.dsl.Param; +import com.querydsl.sql.SQLTemplates; import jakarta.persistence.Column; import jakarta.persistence.Table; import java.sql.PreparedStatement; @@ -41,10 +42,10 @@ public final class JpaInsertNativeHelper { private JpaInsertNativeHelper() {} /** - * Resolve the SQL table name for an entity class. + * Resolve the SQL table name for an entity class (unquoted). * * @param entityClass the JPA entity class - * @return the SQL table name + * @return the raw SQL table name (schema.table if schema is set) */ public static String resolveTableName(Class entityClass) { if (entityClass.isAnnotationPresent(Table.class)) { @@ -62,11 +63,11 @@ public static String resolveTableName(Class entityClass) { } /** - * Resolve the SQL column name for a path. Reads {@code @Column} annotation if present, otherwise - * falls back to the path metadata name. + * Resolve the SQL column name for a path (unquoted). Reads {@code @Column} annotation if present, + * otherwise falls back to the path metadata name. * * @param path the query path - * @return the SQL column name + * @return the raw SQL column name */ public static String resolveColumnName(Path path) { if (path.getAnnotatedElement() != null @@ -80,23 +81,45 @@ public static String resolveColumnName(Path path) { } /** - * Build a native SQL INSERT statement from entity metadata and column paths. + * Quote a table name using the given {@link SQLTemplates}. Handles schema-qualified names by + * quoting schema and table parts separately. * + * @param templates the SQL templates providing dialect-specific quoting rules + * @param tableName the raw table name (may be schema-qualified as "schema.table") + * @return the properly quoted table name + */ + private static String quoteTableName(SQLTemplates templates, String tableName) { + var dotIndex = tableName.indexOf('.'); + if (dotIndex > 0) { + var schema = tableName.substring(0, dotIndex); + var table = tableName.substring(dotIndex + 1); + return templates.quoteIdentifier(schema) + "." + templates.quoteIdentifier(table, true); + } + return templates.quoteIdentifier(tableName); + } + + /** + * Build a native SQL INSERT statement from entity metadata and column paths, with identifiers + * properly quoted using the given {@link SQLTemplates}. + * + * @param templates the SQL templates providing dialect-specific quoting rules * @param entityClass the entity class (for table name resolution) * @param columns the columns to insert * @return the native SQL INSERT string with positional parameters */ - public static String buildNativeInsertSQL(Class entityClass, Collection> columns) { - var tableName = resolveTableName(entityClass); + public static String buildNativeInsertSQL( + SQLTemplates templates, Class entityClass, Collection> columns) { var sb = new StringBuilder(); - sb.append("INSERT INTO ").append(tableName).append(" ("); + sb.append("INSERT INTO ") + .append(quoteTableName(templates, resolveTableName(entityClass))) + .append(" ("); var first = true; for (Path col : columns) { if (!first) { sb.append(", "); } - sb.append(resolveColumnName(col)); + sb.append(templates.quoteIdentifier(resolveColumnName(col))); first = false; } @@ -114,6 +137,20 @@ public static String buildNativeInsertSQL(Class entityClass, Collection entityClass, Collection> columns) { + return buildNativeInsertSQL(SQLTemplates.DEFAULT, entityClass, columns); + } + /** * Resolve constant values from the serializer, unwrapping {@link Param} expressions. * 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 b0e1d90f50..5ee945f6b1 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 @@ -27,6 +27,7 @@ import com.querydsl.jpa.JPQLSerializer; import com.querydsl.jpa.JPQLTemplates; import com.querydsl.jpa.JpaInsertNativeHelper; +import com.querydsl.sql.SQLTemplates; import java.sql.SQLException; import java.util.ArrayList; import java.util.Arrays; @@ -153,7 +154,9 @@ public T executeWithKey(Class type) { JpaInsertNativeHelper.resolveConstants( serializer.getConstants(), queryMixin.getMetadata().getParams()); - var sql = JpaInsertNativeHelper.buildNativeInsertSQL(entityClass, effectiveColumns); + var sql = + JpaInsertNativeHelper.buildNativeInsertSQL( + SQLTemplates.DEFAULT, entityClass, effectiveColumns); return session.doReturningWork( connection -> { 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 e2e625959c..531b93ad47 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 @@ -26,6 +26,7 @@ import com.querydsl.jpa.JPQLSerializer; import com.querydsl.jpa.JPQLTemplates; import com.querydsl.jpa.JpaInsertNativeHelper; +import com.querydsl.sql.SQLTemplates; import jakarta.persistence.EntityManager; import jakarta.persistence.LockModeType; import java.util.ArrayList; @@ -139,7 +140,9 @@ public T executeWithKey(Class type) { JpaInsertNativeHelper.resolveConstants( serializer.getConstants(), queryMixin.getMetadata().getParams()); - var sql = JpaInsertNativeHelper.buildNativeInsertSQL(entityClass, effectiveColumns); + var sql = + JpaInsertNativeHelper.buildNativeInsertSQL( + SQLTemplates.DEFAULT, entityClass, effectiveColumns); try { return entityManager diff --git a/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/JpaInsertNativeHelperTest.java b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/JpaInsertNativeHelperTest.java index 61157b58fa..d8930ef19e 100644 --- a/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/JpaInsertNativeHelperTest.java +++ b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/JpaInsertNativeHelperTest.java @@ -21,6 +21,7 @@ import com.querydsl.jpa.domain.QAuthor; import com.querydsl.jpa.domain.QGeneratedKeyEntity; import com.querydsl.jpa.domain.QNumeric; +import com.querydsl.sql.SQLTemplates; import java.util.List; import org.junit.Test; @@ -64,16 +65,33 @@ public void resolveColumnName_without_column_annotation() { public void buildNativeInsertSQL() { var entity = QGeneratedKeyEntity.generatedKeyEntity; var sql = - JpaInsertNativeHelper.buildNativeInsertSQL(GeneratedKeyEntity.class, List.of(entity.name)); + JpaInsertNativeHelper.buildNativeInsertSQL( + SQLTemplates.DEFAULT, GeneratedKeyEntity.class, List.of(entity.name)); + // SQLTemplates.DEFAULT uses double quotes only when required (reserved words or special chars) assertThat(sql).isEqualTo("INSERT INTO generated_key_entity (name_) VALUES (?)"); } @Test public void buildNativeInsertSQL_multiple_columns() { var numeric = QNumeric.numeric; - var sql = JpaInsertNativeHelper.buildNativeInsertSQL(Numeric.class, List.of(numeric.value)); + var sql = + JpaInsertNativeHelper.buildNativeInsertSQL( + SQLTemplates.DEFAULT, Numeric.class, List.of(numeric.value)); assertThat(sql).isEqualTo("INSERT INTO numeric_ (value_) VALUES (?)"); } + + @Test + public void buildNativeInsertSQL_quotes_reserved_words() { + // Custom SQLTemplates with useQuotes=true always quotes identifiers + var alwaysQuote = new SQLTemplates("\"", '\\', true) {}; + + var entity = QGeneratedKeyEntity.generatedKeyEntity; + var sql = + JpaInsertNativeHelper.buildNativeInsertSQL( + alwaysQuote, GeneratedKeyEntity.class, List.of(entity.name)); + + assertThat(sql).isEqualTo("INSERT INTO \"generated_key_entity\" (\"name_\") VALUES (?)"); + } } From 596f9709ba9850efa0820815d35479257835fd70 Mon Sep 17 00:00:00 2001 From: zio0911 Date: Tue, 28 Apr 2026 15:56:20 +0900 Subject: [PATCH 3/4] Serialize executeWithKey value expressions through SQLSerializer Previously JpaInsertNativeHelper.buildNativeInsertSQL emitted one ? per column without inspecting the value expression tree, so function templates like dbo.encrypt({0}) were dropped from the generated SQL and only the inner constant got bound. SQL and constants were also produced by separate serialization passes (helper for SQL, JPQLSerializer for constants), so the two sides could diverge. Replace this with a single JpaNativeInsertSerializer extending SQLSerializer that produces the SQL and constants together. Function templates, paths, params, and constants now serialize correctly via the SQLSerializer visitor, with plain ? placeholders for JDBC binding and @Column/@Table resolution following the NativeSQLSerializer pattern. Add regression coverage in JpaNativeInsertSerializerTest plus integration tests in JPAExecuteWithKeyTest and HibernateExecuteWithKeyTest that verify the screenshot case (upper({0}) + "value" -> DB stores "VALUE"), zero-arg and multi-arg templates, and identifier quoting. --- .../querydsl/jpa/JpaInsertNativeHelper.java | 163 ++++-------------- .../jpa/JpaNativeInsertSerializer.java | 133 ++++++++++++++ .../jpa/hibernate/HibernateInsertClause.java | 14 +- .../querydsl/jpa/impl/JPAInsertClause.java | 14 +- .../jpa/HibernateExecuteWithKeyTest.java | 25 +++ .../querydsl/jpa/JPAExecuteWithKeyTest.java | 43 +++++ .../jpa/JpaInsertNativeHelperTest.java | 97 ----------- .../jpa/JpaNativeInsertSerializerTest.java | 147 ++++++++++++++++ 8 files changed, 394 insertions(+), 242 deletions(-) create mode 100644 querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/JpaNativeInsertSerializer.java delete mode 100644 querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/JpaInsertNativeHelperTest.java create mode 100644 querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/JpaNativeInsertSerializerTest.java 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 64694a2e6b..4edbd20528 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 @@ -17,23 +17,21 @@ import com.querydsl.core.types.ParamExpression; import com.querydsl.core.types.ParamNotSetException; import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.Expressions; import com.querydsl.core.types.dsl.Param; -import com.querydsl.sql.SQLTemplates; -import jakarta.persistence.Column; -import jakarta.persistence.Table; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; -import java.util.Collection; +import java.util.ArrayList; import java.util.List; import java.util.Map; import org.jetbrains.annotations.Nullable; /** - * Helper for building native SQL INSERT statements from JPA entity metadata. Used by {@link - * com.querydsl.jpa.impl.JPAInsertClause} and {@link - * com.querydsl.jpa.hibernate.HibernateInsertClause} to support {@code executeWithKey()}. + * Helpers shared by {@link com.querydsl.jpa.impl.JPAInsertClause} and {@link + * com.querydsl.jpa.hibernate.HibernateInsertClause} to support {@code executeWithKey()} via native + * SQL INSERT. * *

This is an internal API and not intended for direct use by application code. */ @@ -42,120 +40,45 @@ public final class JpaInsertNativeHelper { private JpaInsertNativeHelper() {} /** - * Resolve the SQL table name for an entity class (unquoted). - * - * @param entityClass the JPA entity class - * @return the raw SQL table name (schema.table if schema is set) - */ - public static String resolveTableName(Class entityClass) { - if (entityClass.isAnnotationPresent(Table.class)) { - var table = entityClass.getAnnotation(Table.class); - if (!table.name().isEmpty()) { - var sb = new StringBuilder(); - if (!table.schema().isEmpty()) { - sb.append(table.schema()).append('.'); - } - sb.append(table.name()); - return sb.toString(); - } - } - return entityClass.getSimpleName(); - } - - /** - * Resolve the SQL column name for a path (unquoted). Reads {@code @Column} annotation if present, - * otherwise falls back to the path metadata name. - * - * @param path the query path - * @return the raw SQL column name + * 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. */ - public static String resolveColumnName(Path path) { - if (path.getAnnotatedElement() != null - && path.getAnnotatedElement().isAnnotationPresent(Column.class)) { - var column = path.getAnnotatedElement().getAnnotation(Column.class); - if (!column.name().isEmpty()) { - return column.name(); - } - } - return path.getMetadata().getName(); - } - - /** - * Quote a table name using the given {@link SQLTemplates}. Handles schema-qualified names by - * quoting schema and table parts separately. - * - * @param templates the SQL templates providing dialect-specific quoting rules - * @param tableName the raw table name (may be schema-qualified as "schema.table") - * @return the properly quoted table name - */ - private static String quoteTableName(SQLTemplates templates, String tableName) { - var dotIndex = tableName.indexOf('.'); - if (dotIndex > 0) { - var schema = tableName.substring(0, dotIndex); - var table = tableName.substring(dotIndex + 1); - return templates.quoteIdentifier(schema) + "." + templates.quoteIdentifier(table, true); + public static List> effectiveColumns( + Map, Expression> inserts, List> columns) { + if (!inserts.isEmpty()) { + return new ArrayList<>(inserts.keySet()); } - return templates.quoteIdentifier(tableName); + return new ArrayList<>(columns); } /** - * Build a native SQL INSERT statement from entity metadata and column paths, with identifiers - * properly quoted using the given {@link SQLTemplates}. - * - * @param templates the SQL templates providing dialect-specific quoting rules - * @param entityClass the entity class (for table name resolution) - * @param columns the columns to insert - * @return the native SQL INSERT string with positional parameters + * Resolve the effective value expressions, in the order matching {@link #effectiveColumns(Map, + * List)}. Raw values from the {@code values()}-style call are wrapped as constants; expressions + * are passed through unchanged. */ - public static String buildNativeInsertSQL( - SQLTemplates templates, Class entityClass, Collection> columns) { - var sb = new StringBuilder(); - sb.append("INSERT INTO ") - .append(quoteTableName(templates, resolveTableName(entityClass))) - .append(" ("); - - var first = true; - for (Path col : columns) { - if (!first) { - sb.append(", "); - } - sb.append(templates.quoteIdentifier(resolveColumnName(col))); - first = false; + public static List> effectiveValues( + Map, Expression> inserts, List values) { + if (!inserts.isEmpty()) { + return new ArrayList<>(inserts.values()); } - - sb.append(") VALUES ("); - first = true; - for (int i = 0; i < columns.size(); i++) { - if (!first) { - sb.append(", "); + var result = new ArrayList>(values.size()); + for (Object v : values) { + if (v instanceof Expression expression) { + result.add(expression); + } else { + result.add(Expressions.constant(v)); } - sb.append('?'); - first = false; } - sb.append(')'); - - return sb.toString(); - } - - /** - * Build a native SQL INSERT statement using {@link SQLTemplates#DEFAULT} for identifier quoting. - * - * @param entityClass the entity class (for table name resolution) - * @param columns the columns to insert - * @return the native SQL INSERT string with positional parameters - * @deprecated prefer {@link #buildNativeInsertSQL(SQLTemplates, Class, Collection)} with explicit - * templates so dialect-specific quoting is applied - */ - @Deprecated - public static String buildNativeInsertSQL(Class entityClass, Collection> columns) { - return buildNativeInsertSQL(SQLTemplates.DEFAULT, entityClass, columns); + return result; } /** - * Resolve constant values from the serializer, unwrapping {@link Param} expressions. + * Resolve constant values from the serializer, unwrapping {@link Param} expressions against the + * provided parameter bindings. * - * @param constants the constants from the serializer - * @param params the parameter bindings + * @param constants the constants accumulated by the serializer + * @param params the parameter bindings collected from {@link + * com.querydsl.core.QueryMetadata#getParams()} * @return resolved values ready for JDBC binding */ public static Object[] resolveConstants( @@ -175,14 +98,14 @@ public static Object[] resolveConstants( } /** - * Execute a native SQL INSERT with RETURN_GENERATED_KEYS and return the generated key. + * Execute a native SQL INSERT with {@code RETURN_GENERATED_KEYS} and return the generated key. * * @param the key type * @param conn the JDBC connection (not closed by this method) * @param sql the native SQL INSERT string - * @param params the parameter values to bind + * @param params the parameter values to bind, in positional order * @param keyType the expected key type - * @return the generated key, or null if no rows were inserted + * @return the generated key, or {@code null} if no rows were inserted * @throws SQLException if a database error occurs */ @Nullable @@ -202,22 +125,4 @@ public static T executeAndReturnKey( } } } - - /** - * Collect effective columns and values from either the set-style inserts map or the - * columns/values lists. - * - * @param inserts the set-style inserts (path to expression mapping) - * @param columns the columns list (from columns().values() style) - * @param values the values list - * @param serializer used to extract constant values from expressions - * @return the effective column paths - */ - public static Collection> effectiveColumns( - Map, Expression> inserts, List> columns) { - if (!inserts.isEmpty()) { - return inserts.keySet(); - } - return columns; - } } diff --git a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/JpaNativeInsertSerializer.java b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/JpaNativeInsertSerializer.java new file mode 100644 index 0000000000..db77d6ad2e --- /dev/null +++ b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/JpaNativeInsertSerializer.java @@ -0,0 +1,133 @@ +/* + * Copyright 2015, The Querydsl Team (http://www.querydsl.com/team) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.querydsl.jpa; + +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.Path; +import com.querydsl.sql.Configuration; +import com.querydsl.sql.SQLSerializer; +import com.querydsl.sql.SQLTemplates; +import jakarta.persistence.Column; +import jakarta.persistence.Table; +import java.util.List; + +/** + * Serializer that emits a native SQL {@code INSERT} statement from JPA entity metadata + * ({@code @Table}/{@code @Column} annotations) and a list of column/value expressions. + * + *

Unlike {@link NativeSQLSerializer}, which targets Hibernate native queries with positional + * {@code ?N} placeholders, this serializer emits plain {@code ?} placeholders for direct binding to + * a JDBC {@link java.sql.PreparedStatement}, and dispatches each value expression through the + * visitor pattern so function templates, paths, parameters and other non-trivial expressions + * serialize into SQL correctly. + * + *

This is an internal API used by {@link com.querydsl.jpa.impl.JPAInsertClause} and {@link + * com.querydsl.jpa.hibernate.HibernateInsertClause} to support {@code executeWithKey()}. + */ +public final class JpaNativeInsertSerializer extends SQLSerializer { + + public JpaNativeInsertSerializer(Configuration configuration) { + super(configuration); + } + + @Override + protected void appendAsColumnName(Path path, boolean precededByDot) { + if (path.getAnnotatedElement() != null + && path.getAnnotatedElement().isAnnotationPresent(Column.class)) { + var column = path.getAnnotatedElement().getAnnotation(Column.class); + if (!column.name().isEmpty()) { + append(getTemplates().quoteIdentifier(column.name(), precededByDot)); + return; + } + } + super.appendAsColumnName(path, precededByDot); + } + + /** + * Serialize an {@code INSERT} statement for the given entity, columns, and value expressions. + * Each value expression is dispatched through the visitor pattern, so function templates, paths, + * parameters, and other expressions are serialized into SQL with positional {@code ?} + * placeholders. Bound values are accumulated and accessible via {@link #getConstants()}. + * + * @param entityClass the JPA entity class (used to resolve the table name via {@link Table}) + * @param columns the column paths to insert into + * @param values the value expressions, one per column, in matching order + */ + public void serializeInsert( + Class entityClass, List> columns, List> values) { + if (columns.size() != values.size()) { + throw new IllegalArgumentException( + "Column count (" + + columns.size() + + ") does not match value count (" + + values.size() + + ")"); + } + + var templates = getTemplates(); + append(templates.getInsertInto()); + appendTable(entityClass); + + if (!columns.isEmpty()) { + append(" ("); + var first = true; + for (Path col : columns) { + if (!first) { + append(", "); + } + appendAsColumnName(col, false); + first = false; + } + append(")"); + } + + var oldSkipParent = skipParent; + skipParent = true; + try { + append(templates.getValues()); + append("("); + var first = true; + for (Expression value : values) { + if (!first) { + append(", "); + } + handle(value); + first = false; + } + append(")"); + } finally { + skipParent = oldSkipParent; + } + } + + private void appendTable(Class entityClass) { + SQLTemplates templates = getTemplates(); + String schema = ""; + String tableName = entityClass.getSimpleName(); + if (entityClass.isAnnotationPresent(Table.class)) { + var table = entityClass.getAnnotation(Table.class); + if (!table.name().isEmpty()) { + tableName = table.name(); + } + schema = table.schema(); + } + if (!schema.isEmpty()) { + append(templates.quoteIdentifier(schema)); + append("."); + append(templates.quoteIdentifier(tableName, true)); + } else { + append(templates.quoteIdentifier(tableName)); + } + } +} 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 5ee945f6b1..662b0a430e 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 @@ -27,6 +27,8 @@ import com.querydsl.jpa.JPQLSerializer; import com.querydsl.jpa.JPQLTemplates; import com.querydsl.jpa.JpaInsertNativeHelper; +import com.querydsl.jpa.JpaNativeInsertSerializer; +import com.querydsl.sql.Configuration; import com.querydsl.sql.SQLTemplates; import java.sql.SQLException; import java.util.ArrayList; @@ -142,22 +144,18 @@ public T executeWithKey(Class type) { if (effectiveColumns.isEmpty()) { throw new IllegalStateException("No columns specified for insert"); } + var effectiveValues = JpaInsertNativeHelper.effectiveValues(inserts, values); var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType(); - // Serialize to collect constant values - var serializer = new JPQLSerializer(templates, null); - serializer.serializeForInsert( - queryMixin.getMetadata(), effectiveColumns, values, subQuery, inserts); + var serializer = new JpaNativeInsertSerializer(new Configuration(SQLTemplates.DEFAULT)); + serializer.serializeInsert(entityClass, effectiveColumns, effectiveValues); + var sql = serializer.toString(); var params = JpaInsertNativeHelper.resolveConstants( serializer.getConstants(), queryMixin.getMetadata().getParams()); - var sql = - JpaInsertNativeHelper.buildNativeInsertSQL( - SQLTemplates.DEFAULT, entityClass, effectiveColumns); - return session.doReturningWork( connection -> { try { 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 531b93ad47..1e120ee925 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 @@ -26,6 +26,8 @@ import com.querydsl.jpa.JPQLSerializer; import com.querydsl.jpa.JPQLTemplates; import com.querydsl.jpa.JpaInsertNativeHelper; +import com.querydsl.jpa.JpaNativeInsertSerializer; +import com.querydsl.sql.Configuration; import com.querydsl.sql.SQLTemplates; import jakarta.persistence.EntityManager; import jakarta.persistence.LockModeType; @@ -128,22 +130,18 @@ public T executeWithKey(Class type) { if (effectiveColumns.isEmpty()) { throw new IllegalStateException("No columns specified for insert"); } + var effectiveValues = JpaInsertNativeHelper.effectiveValues(inserts, values); var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType(); - // Serialize to collect constant values - var serializer = new JPQLSerializer(templates, entityManager); - serializer.serializeForInsert( - queryMixin.getMetadata(), effectiveColumns, values, subQuery, inserts); + var serializer = new JpaNativeInsertSerializer(new Configuration(SQLTemplates.DEFAULT)); + serializer.serializeInsert(entityClass, effectiveColumns, effectiveValues); + var sql = serializer.toString(); var params = JpaInsertNativeHelper.resolveConstants( serializer.getConstants(), queryMixin.getMetadata().getParams()); - var sql = - JpaInsertNativeHelper.buildNativeInsertSQL( - SQLTemplates.DEFAULT, entityClass, effectiveColumns); - try { return entityManager .unwrap(org.hibernate.Session.class) 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 5b91de7c46..c292c92c88 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 @@ -17,6 +17,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.querydsl.core.types.EntityPath; +import com.querydsl.core.types.dsl.Expressions; import com.querydsl.jpa.domain.GeneratedKeyEntity; import com.querydsl.jpa.domain.QGeneratedKeyEntity; import com.querydsl.jpa.hibernate.HibernateInsertClause; @@ -123,6 +124,30 @@ public void executeWithKey_with_column_annotation() { assertThat(id).isPositive(); } + @Test + public void executeWithKey_with_function_template_applies_function() { + // Regression: a function template like dbo.encrypt({0}) used to be silently dropped, + // and only the inner constant was bound, leading to plaintext being inserted. + var entity = QGeneratedKeyEntity.generatedKeyEntity; + Long id = + insert(entity) + .set( + entity.name, + Expressions.stringTemplate("upper({0})", Expressions.constant("value"))) + .executeWithKey(entity.id); + + assertThat(id).isNotNull(); + + var stored = + (String) + session + .createNativeQuery( + "select name_ from generated_key_entity where id = ?1", String.class) + .setParameter(1, id) + .getSingleResult(); + assertThat(stored).isEqualTo("VALUE"); + } + @Test public void executeWithKey_rejects_subquery() { var entity = QGeneratedKeyEntity.generatedKeyEntity; 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 9721a2bdb3..ceb19d6cc8 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 @@ -17,6 +17,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.querydsl.core.types.EntityPath; +import com.querydsl.core.types.dsl.Expressions; import com.querydsl.jpa.domain.QGeneratedKeyEntity; import com.querydsl.jpa.impl.JPAInsertClause; import jakarta.persistence.EntityManager; @@ -116,6 +117,48 @@ public void executeWithKey_multiple_inserts_return_different_keys() { assertThat(id2).isGreaterThan(id1); } + @Test + public void executeWithKey_with_function_template_applies_function() { + // Regression: a function template like dbo.encrypt({0}) used to be silently dropped, + // and only the inner constant was bound, leading to plaintext being inserted. + var entity = QGeneratedKeyEntity.generatedKeyEntity; + Long id = + insert(entity) + .set( + entity.name, + Expressions.stringTemplate("upper({0})", Expressions.constant("value"))) + .executeWithKey(entity.id); + + assertThat(id).isNotNull(); + + var stored = + (String) + entityManager + .createNativeQuery("select name_ from generated_key_entity where id = ?1") + .setParameter(1, id) + .getSingleResult(); + assertThat(stored).isEqualTo("VALUE"); + } + + @Test + public void executeWithKey_with_zero_arg_function_template() { + var entity = QGeneratedKeyEntity.generatedKeyEntity; + Long id = + insert(entity) + .set(entity.name, Expressions.stringTemplate("'fixed_' || current_user")) + .executeWithKey(entity.id); + + assertThat(id).isNotNull(); + + var stored = + (String) + entityManager + .createNativeQuery("select name_ from generated_key_entity where id = ?1") + .setParameter(1, id) + .getSingleResult(); + assertThat(stored).startsWith("fixed_"); + } + @Test public void executeWithKey_rejects_subquery() { var entity = QGeneratedKeyEntity.generatedKeyEntity; diff --git a/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/JpaInsertNativeHelperTest.java b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/JpaInsertNativeHelperTest.java deleted file mode 100644 index d8930ef19e..0000000000 --- a/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/JpaInsertNativeHelperTest.java +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright 2015, The Querydsl Team (http://www.querydsl.com/team) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.querydsl.jpa; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.querydsl.jpa.domain.Author; -import com.querydsl.jpa.domain.GeneratedKeyEntity; -import com.querydsl.jpa.domain.Numeric; -import com.querydsl.jpa.domain.QAuthor; -import com.querydsl.jpa.domain.QGeneratedKeyEntity; -import com.querydsl.jpa.domain.QNumeric; -import com.querydsl.sql.SQLTemplates; -import java.util.List; -import org.junit.Test; - -public class JpaInsertNativeHelperTest { - - @Test - public void resolveTableName_with_table_annotation() { - // Author has @Table(name = "author_") - assertThat(JpaInsertNativeHelper.resolveTableName(Author.class)).isEqualTo("author_"); - } - - @Test - public void resolveTableName_with_generated_key_entity() { - // GeneratedKeyEntity has @Table(name = "generated_key_entity") - assertThat(JpaInsertNativeHelper.resolveTableName(GeneratedKeyEntity.class)) - .isEqualTo("generated_key_entity"); - } - - @Test - public void resolveColumnName_with_column_annotation() { - // Numeric.value has @Column(name = "value_") - var numeric = QNumeric.numeric; - assertThat(JpaInsertNativeHelper.resolveColumnName(numeric.value)).isEqualTo("value_"); - } - - @Test - public void resolveColumnName_with_name_column() { - // GeneratedKeyEntity.name has @Column(name = "name_") - var entity = QGeneratedKeyEntity.generatedKeyEntity; - assertThat(JpaInsertNativeHelper.resolveColumnName(entity.name)).isEqualTo("name_"); - } - - @Test - public void resolveColumnName_without_column_annotation() { - // Author.name has no @Column annotation - var author = QAuthor.author; - assertThat(JpaInsertNativeHelper.resolveColumnName(author.name)).isEqualTo("name"); - } - - @Test - public void buildNativeInsertSQL() { - var entity = QGeneratedKeyEntity.generatedKeyEntity; - var sql = - JpaInsertNativeHelper.buildNativeInsertSQL( - SQLTemplates.DEFAULT, GeneratedKeyEntity.class, List.of(entity.name)); - - // SQLTemplates.DEFAULT uses double quotes only when required (reserved words or special chars) - assertThat(sql).isEqualTo("INSERT INTO generated_key_entity (name_) VALUES (?)"); - } - - @Test - public void buildNativeInsertSQL_multiple_columns() { - var numeric = QNumeric.numeric; - var sql = - JpaInsertNativeHelper.buildNativeInsertSQL( - SQLTemplates.DEFAULT, Numeric.class, List.of(numeric.value)); - - assertThat(sql).isEqualTo("INSERT INTO numeric_ (value_) VALUES (?)"); - } - - @Test - public void buildNativeInsertSQL_quotes_reserved_words() { - // Custom SQLTemplates with useQuotes=true always quotes identifiers - var alwaysQuote = new SQLTemplates("\"", '\\', true) {}; - - var entity = QGeneratedKeyEntity.generatedKeyEntity; - var sql = - JpaInsertNativeHelper.buildNativeInsertSQL( - alwaysQuote, GeneratedKeyEntity.class, List.of(entity.name)); - - assertThat(sql).isEqualTo("INSERT INTO \"generated_key_entity\" (\"name_\") VALUES (?)"); - } -} diff --git a/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/JpaNativeInsertSerializerTest.java b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/JpaNativeInsertSerializerTest.java new file mode 100644 index 0000000000..19f6627cb4 --- /dev/null +++ b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/JpaNativeInsertSerializerTest.java @@ -0,0 +1,147 @@ +/* + * Copyright 2015, The Querydsl Team (http://www.querydsl.com/team) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.querydsl.jpa; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.domain.Author; +import com.querydsl.jpa.domain.GeneratedKeyEntity; +import com.querydsl.jpa.domain.Numeric; +import com.querydsl.jpa.domain.QAuthor; +import com.querydsl.jpa.domain.QGeneratedKeyEntity; +import com.querydsl.jpa.domain.QNumeric; +import com.querydsl.sql.Configuration; +import com.querydsl.sql.SQLTemplates; +import java.util.List; +import org.junit.Test; + +public class JpaNativeInsertSerializerTest { + + private JpaNativeInsertSerializer newSerializer(SQLTemplates templates) { + return new JpaNativeInsertSerializer(new Configuration(templates)); + } + + @Test + public void serializeInsert_with_table_and_column_annotations() { + var entity = QGeneratedKeyEntity.generatedKeyEntity; + var serializer = newSerializer(SQLTemplates.DEFAULT); + + serializer.serializeInsert( + GeneratedKeyEntity.class, List.of(entity.name), List.of(Expressions.constant("value"))); + + assertThat(serializer.toString()) + .isEqualToIgnoringCase("insert into generated_key_entity (name_)\nvalues (?)"); + assertThat(serializer.getConstants()).containsExactly("value"); + } + + @Test + public void serializeInsert_falls_back_to_simple_class_name() { + // Author has @Table(name = "author_") + var author = QAuthor.author; + var serializer = newSerializer(SQLTemplates.DEFAULT); + + serializer.serializeInsert( + Author.class, List.of(author.name), List.of(Expressions.constant("Tolkien"))); + + assertThat(serializer.toString()) + .isEqualToIgnoringCase("insert into author_ (name)\nvalues (?)"); + } + + @Test + public void serializeInsert_multiple_columns() { + var numeric = QNumeric.numeric; + var entity = QGeneratedKeyEntity.generatedKeyEntity; + var serializer = newSerializer(SQLTemplates.DEFAULT); + + serializer.serializeInsert( + Numeric.class, + List.of(numeric.value), + List.of(Expressions.constant(new java.math.BigDecimal("1.23")))); + + assertThat(serializer.toString()) + .isEqualToIgnoringCase("insert into numeric_ (value_)\nvalues (?)"); + assertThat(serializer.getConstants()).hasSize(1); + } + + @Test + public void serializeInsert_preserves_function_template() { + // Regression: function templates (e.g. UPPER({0}), dbo.encrypt({0})) used to be silently + // dropped, leaving only the inner constant bound as a plain ? placeholder. + var entity = QGeneratedKeyEntity.generatedKeyEntity; + var serializer = newSerializer(SQLTemplates.DEFAULT); + + Expression wrapped = + Expressions.stringTemplate("upper({0})", Expressions.constant("value")); + serializer.serializeInsert(GeneratedKeyEntity.class, List.of(entity.name), List.of(wrapped)); + + assertThat(serializer.toString()) + .isEqualToIgnoringCase("insert into generated_key_entity (name_)\nvalues (upper(?))"); + assertThat(serializer.getConstants()).containsExactly("value"); + } + + @Test + public void serializeInsert_supports_zero_arg_function_template() { + var entity = QGeneratedKeyEntity.generatedKeyEntity; + var serializer = newSerializer(SQLTemplates.DEFAULT); + + Expression nowFn = Expressions.stringTemplate("current_timestamp"); + serializer.serializeInsert(GeneratedKeyEntity.class, List.of(entity.name), List.of(nowFn)); + + assertThat(serializer.toString()) + .isEqualToIgnoringCase( + "insert into generated_key_entity (name_)\nvalues (current_timestamp)"); + // No bind values needed for a literal function call + assertThat(serializer.getConstants()).isEmpty(); + } + + @Test + public void serializeInsert_with_quoting_templates() { + // Custom SQLTemplates that always quotes identifiers + var alwaysQuote = new SQLTemplates("\"", '\\', true) {}; + var entity = QGeneratedKeyEntity.generatedKeyEntity; + var serializer = newSerializer(alwaysQuote); + + serializer.serializeInsert( + GeneratedKeyEntity.class, List.of(entity.name), List.of(Expressions.constant("value"))); + + assertThat(serializer.toString()) + .isEqualToIgnoringCase("insert into \"generated_key_entity\" (\"name_\")\nvalues (?)"); + } + + @Test + public void serializeInsert_collects_constants_in_order() { + var entity = QGeneratedKeyEntity.generatedKeyEntity; + var serializer = newSerializer(SQLTemplates.DEFAULT); + + Expression first = + Expressions.stringTemplate( + "concat({0}, {1})", Expressions.constant("a"), Expressions.constant("b")); + serializer.serializeInsert(GeneratedKeyEntity.class, List.of(entity.name), List.of(first)); + + assertThat(serializer.getConstants()).containsExactly("a", "b"); + } + + @Test(expected = IllegalArgumentException.class) + public void serializeInsert_rejects_mismatched_column_value_counts() { + var entity = QGeneratedKeyEntity.generatedKeyEntity; + var serializer = newSerializer(SQLTemplates.DEFAULT); + + serializer.serializeInsert( + GeneratedKeyEntity.class, + List.of(entity.name), + List.of(Expressions.constant("a"), Expressions.constant("b"))); + } +} From 5cb67cfa0985e2abbf6b9aa452234ab2c4a737e0 Mon Sep 17 00:00:00 2001 From: zio0911 Date: Wed, 29 Apr 2026 12:16:00 +0900 Subject: [PATCH 4/4] Add addRow() and executeWithKeys() for multi-row INSERT key return Extends executeWithKey() (single-row) with batched-key support so JPA users can issue a single multi-row INSERT and receive all generated keys, mirroring the SQL module's executeWithKeys(). JPAInsertClause / HibernateInsertClause: - addRow() finalizes the current values()/set() state as one row and clears it for the next row - executeWithKeys(Path) and executeWithKeys(Class) return List of generated keys in row order; trailing values are auto-flushed - executeWithKey() now throws IllegalStateException when called after addRow() to guard the single-row contract JpaNativeInsertSerializer: - New serializeInsertRows() emits a single INSERT INTO t (...) VALUES (..),(..),... statement; legacy single-row serializeInsert() delegates to it JpaInsertNativeHelper: - executeAndReturnKeys() iterates the full getGeneratedKeys() ResultSet to collect every key in row order Tests cover multi-row key return, single-row List path, the post-addRow guard on executeWithKey, and the empty-row guard on addRow. --- .../querydsl/jpa/JpaInsertNativeHelper.java | 30 ++++++ .../jpa/JpaNativeInsertSerializer.java | 62 ++++++++---- .../jpa/hibernate/HibernateInsertClause.java | 93 ++++++++++++++++++ .../querydsl/jpa/impl/JPAInsertClause.java | 94 +++++++++++++++++++ .../jpa/HibernateExecuteWithKeyTest.java | 42 +++++++++ .../querydsl/jpa/JPAExecuteWithKeyTest.java | 49 ++++++++++ 6 files changed, 352 insertions(+), 18 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 4edbd20528..a3c668b688 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 @@ -125,4 +125,34 @@ public static T executeAndReturnKey( } } } + + /** + * Execute a native SQL multi-row INSERT with {@code RETURN_GENERATED_KEYS} and return all + * generated keys. + * + * @param the key type + * @param conn the JDBC connection (not closed by this method) + * @param sql the native SQL INSERT string (typically multi-row {@code VALUES (..),(..)}) + * @param params the parameter values to bind, in positional order + * @param keyType the expected key type + * @return the generated keys in row order; empty list if no rows were inserted + * @throws SQLException if a database error occurs + */ + public static List executeAndReturnKeys( + java.sql.Connection conn, String sql, Object[] params, Class keyType) throws SQLException { + try (PreparedStatement stmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { + for (int i = 0; i < params.length; i++) { + stmt.setObject(i + 1, params[i]); + } + stmt.executeUpdate(); + + var keys = new ArrayList(); + try (ResultSet rs = stmt.getGeneratedKeys()) { + while (rs.next()) { + keys.add(rs.getObject(1, keyType)); + } + } + return keys; + } + } } diff --git a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/JpaNativeInsertSerializer.java b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/JpaNativeInsertSerializer.java index db77d6ad2e..8e14f84dfc 100644 --- a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/JpaNativeInsertSerializer.java +++ b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/JpaNativeInsertSerializer.java @@ -55,10 +55,11 @@ protected void appendAsColumnName(Path path, boolean precededByDot) { } /** - * Serialize an {@code INSERT} statement for the given entity, columns, and value expressions. - * Each value expression is dispatched through the visitor pattern, so function templates, paths, - * parameters, and other expressions are serialized into SQL with positional {@code ?} - * placeholders. Bound values are accumulated and accessible via {@link #getConstants()}. + * Serialize a single-row {@code INSERT} statement for the given entity, columns, and value + * expressions. Each value expression is dispatched through the visitor pattern, so function + * templates, paths, parameters, and other expressions are serialized into SQL with positional + * {@code ?} placeholders. Bound values are accumulated and accessible via {@link + * #getConstants()}. * * @param entityClass the JPA entity class (used to resolve the table name via {@link Table}) * @param columns the column paths to insert into @@ -66,13 +67,31 @@ protected void appendAsColumnName(Path path, boolean precededByDot) { */ public void serializeInsert( Class entityClass, List> columns, List> values) { - if (columns.size() != values.size()) { - throw new IllegalArgumentException( - "Column count (" - + columns.size() - + ") does not match value count (" - + values.size() - + ")"); + serializeInsertRows(entityClass, columns, List.of(values)); + } + + /** + * Serialize a multi-row {@code INSERT} statement for the given entity, columns, and rows of value + * expressions. Emits {@code INSERT INTO t (c1, c2) VALUES (?, ?), (?, ?), ...}. + * + * @param entityClass the JPA entity class (used to resolve the table name via {@link Table}) + * @param columns the column paths to insert into + * @param rows the rows of value expressions; each row's size must match the column count + */ + public void serializeInsertRows( + Class entityClass, List> columns, List>> rows) { + if (rows.isEmpty()) { + throw new IllegalArgumentException("No rows specified for insert"); + } + for (var row : rows) { + if (columns.size() != row.size()) { + throw new IllegalArgumentException( + "Column count (" + + columns.size() + + ") does not match value count (" + + row.size() + + ")"); + } } var templates = getTemplates(); @@ -96,16 +115,23 @@ public void serializeInsert( skipParent = true; try { append(templates.getValues()); - append("("); - var first = true; - for (Expression value : values) { - if (!first) { + var firstRow = true; + for (var row : rows) { + if (!firstRow) { append(", "); } - handle(value); - first = false; + append("("); + var firstValue = true; + for (Expression value : row) { + if (!firstValue) { + append(", "); + } + handle(value); + firstValue = false; + } + append(")"); + firstRow = false; } - append(")"); } finally { skipParent = oldSkipParent; } 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 662b0a430e..ffb286283a 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 @@ -58,6 +58,8 @@ public class HibernateInsertClause implements InsertClause values = new ArrayList<>(); + private final List>> rows = new ArrayList<>(); + private SubQueryExpression subQuery; private final SessionHolder session; @@ -139,6 +141,10 @@ public T executeWithKey(Class type) { throw new UnsupportedOperationException( "executeWithKey is not supported for INSERT ... SELECT subqueries"); } + if (!rows.isEmpty()) { + throw new IllegalStateException( + "executeWithKey expects a single row; use executeWithKeys for multiple rows"); + } var effectiveColumns = JpaInsertNativeHelper.effectiveColumns(inserts, columns); if (effectiveColumns.isEmpty()) { @@ -166,6 +172,93 @@ 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. + * + * @return this clause for chaining + * @throws IllegalStateException if no values have been specified for the current row, or if + * mixing with {@code INSERT ... SELECT} + */ + public HibernateInsertClause addRow() { + if (subQuery != null) { + throw new IllegalStateException("addRow is not supported with INSERT ... SELECT subqueries"); + } + if (values.isEmpty() && inserts.isEmpty()) { + throw new IllegalStateException("No values to add as row"); + } + rows.add(JpaInsertNativeHelper.effectiveValues(inserts, values)); + values.clear(); + inserts.clear(); + return this; + } + + /** + * Execute the clause and return all generated keys with the type of the given path. Supports both + * single-row inserts and multi-row inserts accumulated via {@link #addRow()}. + * + * @param key type + * @param path path for key (used to determine return type) + * @return generated keys in row order; empty list if no rows were inserted + * @throws QueryException if a database error occurs or the operation is not supported + */ + @SuppressWarnings("unchecked") + public List executeWithKeys(Path path) { + return executeWithKeys((Class) path.getType()); + } + + /** + * Execute the clause and return all generated keys cast to the given type. Supports both + * single-row inserts and multi-row inserts accumulated via {@link #addRow()}. + * + *

If the current row has unflushed values (i.e. {@code addRow()} was not called after the last + * {@code values()}/{@code set()}), they are treated as the trailing row. + * + * @param key type + * @param type class of the key type + * @return generated keys in row order; empty list if no rows were inserted + * @throws QueryException if a database error occurs or the operation is not supported + */ + public List executeWithKeys(Class type) { + if (subQuery != null) { + throw new UnsupportedOperationException( + "executeWithKeys is not supported for 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)); + } + if (allRows.isEmpty()) { + throw new IllegalStateException("No values specified for insert"); + } + + var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType(); + + 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.executeAndReturnKeys(connection, sql, params, type); + } catch (SQLException e) { + throw new QueryException("Failed to execute insert with generated keys", e); + } + }); + } + @Override public HibernateInsertClause columns(Path... columns) { this.columns.addAll(Arrays.asList(columns)); 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 1e120ee925..47eb0b53cf 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 @@ -53,6 +53,8 @@ public class JPAInsertClause implements InsertClause { private final List values = new ArrayList<>(); + private final List>> rows = new ArrayList<>(); + private final EntityManager entityManager; private final JPQLTemplates templates; @@ -125,6 +127,10 @@ public T executeWithKey(Class type) { throw new UnsupportedOperationException( "executeWithKey is not supported for INSERT ... SELECT subqueries"); } + if (!rows.isEmpty()) { + throw new IllegalStateException( + "executeWithKey expects a single row; use executeWithKeys for multiple rows"); + } var effectiveColumns = JpaInsertNativeHelper.effectiveColumns(inserts, columns); if (effectiveColumns.isEmpty()) { @@ -153,6 +159,94 @@ 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. + * + * @return this clause for chaining + * @throws IllegalStateException if no values have been specified for the current row, or if + * mixing with {@code INSERT ... SELECT} + */ + public JPAInsertClause addRow() { + if (subQuery != null) { + throw new IllegalStateException("addRow is not supported with INSERT ... SELECT subqueries"); + } + if (values.isEmpty() && inserts.isEmpty()) { + throw new IllegalStateException("No values to add as row"); + } + rows.add(JpaInsertNativeHelper.effectiveValues(inserts, values)); + values.clear(); + inserts.clear(); + return this; + } + + /** + * Execute the clause and return all generated keys with the type of the given path. Supports both + * single-row inserts and multi-row inserts accumulated via {@link #addRow()}. + * + * @param key type + * @param path path for key (used to determine return type) + * @return generated keys in row order; empty list if no rows were inserted + * @throws QueryException if a database error occurs or the operation is not supported + */ + @SuppressWarnings("unchecked") + public List executeWithKeys(Path path) { + return executeWithKeys((Class) path.getType()); + } + + /** + * Execute the clause and return all generated keys cast to the given type. Supports both + * single-row inserts and multi-row inserts accumulated via {@link #addRow()}. + * + *

If the current row has unflushed values (i.e. {@code addRow()} was not called after the last + * {@code values()}/{@code set()}), they are treated as the trailing row. + * + * @param key type + * @param type class of the key type + * @return generated keys in row order; empty list if no rows were inserted + * @throws QueryException if a database error occurs or the operation is not supported + */ + public List executeWithKeys(Class type) { + if (subQuery != null) { + throw new UnsupportedOperationException( + "executeWithKeys is not supported for 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)); + } + if (allRows.isEmpty()) { + throw new IllegalStateException("No values specified for insert"); + } + + var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType(); + + 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()); + + try { + return entityManager + .unwrap(org.hibernate.Session.class) + .doReturningWork( + connection -> + JpaInsertNativeHelper.executeAndReturnKeys(connection, sql, params, type)); + } catch (Exception e) { + throw new QueryException("Failed to execute insert with generated keys", e); + } + } + public JPAInsertClause setLockMode(LockModeType lockMode) { this.lockMode = lockMode; return this; 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 c292c92c88..dc4c78a254 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 @@ -148,6 +148,48 @@ public void executeWithKey_with_function_template_applies_function() { assertThat(stored).isEqualTo("VALUE"); } + @Test + public void executeWithKeys_multi_row_returns_all_keys_in_order() { + var entity = QGeneratedKeyEntity.generatedKeyEntity; + var keys = + insert(entity) + .columns(entity.name) + .values("RowA") + .addRow() + .values("RowB") + .addRow() + .values("RowC") + .executeWithKeys(entity.id); + + assertThat(keys).hasSize(3); + assertThat(keys.get(0)).isLessThan(keys.get(1)); + assertThat(keys.get(1)).isLessThan(keys.get(2)); + } + + @Test + public void executeWithKeys_single_row_returns_size_one_list() { + var entity = QGeneratedKeyEntity.generatedKeyEntity; + var keys = insert(entity).columns(entity.name).values("Solo").executeWithKeys(entity.id); + + assertThat(keys).hasSize(1); + assertThat(keys.get(0)).isPositive(); + } + + @Test + public void executeWithKey_rejects_after_addRow() { + var entity = QGeneratedKeyEntity.generatedKeyEntity; + assertThatThrownBy( + () -> + insert(entity) + .columns(entity.name) + .values("First") + .addRow() + .values("Second") + .executeWithKey(entity.id)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("executeWithKeys"); + } + @Test public void executeWithKey_rejects_subquery() { var entity = QGeneratedKeyEntity.generatedKeyEntity; 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 ceb19d6cc8..a11cb9a34d 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 @@ -159,6 +159,55 @@ public void executeWithKey_with_zero_arg_function_template() { assertThat(stored).startsWith("fixed_"); } + @Test + public void executeWithKeys_multi_row_returns_all_keys_in_order() { + var entity = QGeneratedKeyEntity.generatedKeyEntity; + var keys = + insert(entity) + .columns(entity.name) + .values("RowA") + .addRow() + .values("RowB") + .addRow() + .values("RowC") + .executeWithKeys(entity.id); + + assertThat(keys).hasSize(3); + assertThat(keys.get(0)).isLessThan(keys.get(1)); + assertThat(keys.get(1)).isLessThan(keys.get(2)); + } + + @Test + public void executeWithKeys_single_row_returns_size_one_list() { + var entity = QGeneratedKeyEntity.generatedKeyEntity; + var keys = insert(entity).columns(entity.name).values("Solo").executeWithKeys(entity.id); + + assertThat(keys).hasSize(1); + assertThat(keys.get(0)).isPositive(); + } + + @Test + public void executeWithKey_rejects_after_addRow() { + var entity = QGeneratedKeyEntity.generatedKeyEntity; + assertThatThrownBy( + () -> + insert(entity) + .columns(entity.name) + .values("First") + .addRow() + .values("Second") + .executeWithKey(entity.id)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("executeWithKeys"); + } + + @Test + public void addRow_rejects_with_no_values() { + var entity = QGeneratedKeyEntity.generatedKeyEntity; + assertThatThrownBy(() -> insert(entity).columns(entity.name).addRow()) + .isInstanceOf(IllegalStateException.class); + } + @Test public void executeWithKey_rejects_subquery() { var entity = QGeneratedKeyEntity.generatedKeyEntity;