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..a3c668b688
--- /dev/null
+++ b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/JpaInsertNativeHelper.java
@@ -0,0 +1,158 @@
+/*
+ * 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.Expressions;
+import com.querydsl.core.types.dsl.Param;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * 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.
+ */
+public final class JpaInsertNativeHelper {
+
+ private JpaInsertNativeHelper() {}
+
+ /**
+ * 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 List> effectiveColumns(
+ Map, Expression>> inserts, List> columns) {
+ if (!inserts.isEmpty()) {
+ return new ArrayList<>(inserts.keySet());
+ }
+ return new ArrayList<>(columns);
+ }
+
+ /**
+ * 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 List> effectiveValues(
+ Map, Expression>> inserts, List values) {
+ if (!inserts.isEmpty()) {
+ return new ArrayList<>(inserts.values());
+ }
+ var result = new ArrayList>(values.size());
+ for (Object v : values) {
+ if (v instanceof Expression> expression) {
+ result.add(expression);
+ } else {
+ result.add(Expressions.constant(v));
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Resolve constant values from the serializer, unwrapping {@link Param} expressions against the
+ * provided 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(
+ 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 {@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, in positional order
+ * @param keyType the expected key type
+ * @return the generated key, or {@code 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;
+ }
+ }
+ }
+
+ /**
+ * 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
new file mode 100644
index 0000000000..8e14f84dfc
--- /dev/null
+++ b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/JpaNativeInsertSerializer.java
@@ -0,0 +1,159 @@
+/*
+ * 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 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
+ * @param values the value expressions, one per column, in matching order
+ */
+ public void serializeInsert(
+ Class> entityClass, List> columns, List> values) {
+ 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();
+ 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());
+ var firstRow = true;
+ for (var row : rows) {
+ if (!firstRow) {
+ append(", ");
+ }
+ append("(");
+ var firstValue = true;
+ for (Expression> value : row) {
+ if (!firstValue) {
+ append(", ");
+ }
+ handle(value);
+ firstValue = false;
+ }
+ append(")");
+ firstRow = false;
+ }
+ } 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/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..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
@@ -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,11 @@
import com.querydsl.jpa.JPAQueryMixin;
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;
import java.util.Arrays;
import java.util.HashMap;
@@ -35,6 +41,7 @@
import org.hibernate.Session;
import org.hibernate.StatelessSession;
import org.hibernate.query.Query;
+import org.jetbrains.annotations.Nullable;
/**
* UpdateClause implementation for Hibernate
@@ -51,6 +58,8 @@ public class HibernateInsertClause implements InsertClause values = new ArrayList<>();
+ private final List>> rows = new ArrayList<>();
+
private SubQueryExpression> subQuery;
private final SessionHolder session;
@@ -97,6 +106,159 @@ 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");
+ }
+ 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()) {
+ throw new IllegalStateException("No columns specified for insert");
+ }
+ var effectiveValues = JpaInsertNativeHelper.effectiveValues(inserts, values);
+
+ var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType();
+
+ 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());
+
+ 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);
+ }
+ });
+ }
+
+ /**
+ * 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/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..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
@@ -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,10 @@
import com.querydsl.jpa.JPAQueryMixin;
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;
import java.util.ArrayList;
@@ -48,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;
@@ -84,6 +91,162 @@ 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");
+ }
+ 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()) {
+ throw new IllegalStateException("No columns specified for insert");
+ }
+ var effectiveValues = JpaInsertNativeHelper.effectiveValues(inserts, values);
+
+ var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType();
+
+ 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());
+
+ 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);
+ }
+ }
+
+ /**
+ * 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/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..dc4c78a254
--- /dev/null
+++ b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/HibernateExecuteWithKeyTest.java
@@ -0,0 +1,206 @@
+/*
+ * 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.core.types.dsl.Expressions;
+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_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 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;
+ 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..a11cb9a34d
--- /dev/null
+++ b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/JPAExecuteWithKeyTest.java
@@ -0,0 +1,224 @@
+/*
+ * 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.core.types.dsl.Expressions;
+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_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 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;
+ 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/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")));
+ }
+}
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