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