Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,29 @@ public final class JpaInsertNativeHelper {

private JpaInsertNativeHelper() {}

/**
* Ensure the optional {@code querydsl-sql} module is on the classpath before taking a native SQL
* insert path. {@code querydsl-jpa} declares {@code querydsl-sql} as an <em>optional</em>
* dependency (it is only needed for the {@link JpaNativeInsertSerializer}-based paths such as
* {@code executeWithKey()}, {@code executeWithKeys()} and multi-row {@code execute()}), so a
* JPA-only consumer will not have it transitively. Without this guard the caller would hit an
* opaque {@link NoClassDefFoundError}; instead we fail fast with an actionable message.
*
* @throws IllegalStateException if {@code querydsl-sql} is not available
*/
public static void requireSqlModule() {
try {
Class.forName("com.querydsl.sql.SQLSerializer");
} catch (ClassNotFoundException e) {
throw new IllegalStateException(
"This operation requires the optional querydsl-sql module, which is not on the"
+ " classpath. Add the 'io.github.openfeign.querydsl:querydsl-sql' dependency"
+ " (matching your querydsl-jpa version) to use executeWithKey(), executeWithKeys()"
+ " or multi-row execute() via addRow().",
e);
}
}

/**
* Resolve the effective column paths from either the {@code set()}-style inserts map or the
* {@code columns()}-style list. The {@code set()}-style takes precedence when present.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ public class HibernateInsertClause implements InsertClause<HibernateInsertClause

private final List<List<Expression<?>>> rows = new ArrayList<>();

/**
* Column paths captured at the first {@link #addRow()} call. After {@code addRow()} clears the
* per-row {@code inserts}/{@code values} buffer, this lets executors recover the column list when
* the trailing iteration was also flushed (e.g. {@code for (...) { insert.set(...).addRow(); }}).
* Null until the first {@code addRow()}.
*/
@Nullable private List<Path<?>> rowColumnPaths;

private SubQueryExpression<?> subQuery;

private final SessionHolder session;
Expand Down Expand Up @@ -90,6 +98,12 @@ public HibernateInsertClause(

@Override
public long execute() {
// Multi-row insert accumulated via addRow(): emit a single native
// INSERT INTO t (...) VALUES (..),(..),... statement in one round-trip.
if (!rows.isEmpty()) {
return executeMultiRow();
}

if (subQuery != null || !hasTemplateValue()) {
var serializer = new JPQLSerializer(templates, null);
serializer.serializeForInsert(
Expand All @@ -116,6 +130,7 @@ public long execute() {

var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType();

JpaInsertNativeHelper.requireSqlModule();
var serializer = new JpaNativeInsertSerializer(new Configuration(SQLTemplates.DEFAULT));
serializer.serializeInsert(entityClass, effectiveColumns, effectiveValues);

Expand All @@ -134,6 +149,50 @@ public long execute() {
});
}

/**
* Execute a multi-row INSERT (accumulated via {@link #addRow()}) as a single native {@code INSERT
* INTO t (...) VALUES (..),(..),...} statement, returning the number of affected rows. This path
* does not return generated keys; use {@link #executeWithKeys(Class)} when keys are needed.
*/
private long executeMultiRow() {
if (subQuery != null) {
throw new IllegalStateException("addRow is not supported with INSERT ... SELECT subqueries");
}

var effectiveColumns = JpaInsertNativeHelper.effectiveColumns(inserts, columns);
if (effectiveColumns.isEmpty() && rowColumnPaths != null) {
effectiveColumns = new ArrayList<>(rowColumnPaths);
}
if (effectiveColumns.isEmpty()) {
throw new IllegalStateException("No columns specified for insert");
}

var allRows = new ArrayList<>(rows);
if (!values.isEmpty() || !inserts.isEmpty()) {
allRows.add(JpaInsertNativeHelper.effectiveValues(inserts, values));
}

var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType();

JpaInsertNativeHelper.requireSqlModule();
var serializer = new JpaNativeInsertSerializer(new Configuration(SQLTemplates.DEFAULT));
serializer.serializeInsertRows(entityClass, effectiveColumns, allRows);

var sql = serializer.toString();
var params =
JpaInsertNativeHelper.resolveConstants(
serializer.getConstants(), queryMixin.getMetadata().getParams());

return session.doReturningWork(
connection -> {
try {
return JpaInsertNativeHelper.executeUpdate(connection, sql, params);
} catch (SQLException e) {
throw new QueryException("Failed to execute multi-row insert", e);
}
});
}

/**
* Whether any value expression is a {@link TemplateExpression} — typically a schema-qualified
* function call from {@code SQLExpressions.function/stringFunction/numberFunction} that
Expand Down Expand Up @@ -202,6 +261,7 @@ public <T> T executeWithKey(Class<T> type) {

var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType();

JpaInsertNativeHelper.requireSqlModule();
var serializer = new JpaNativeInsertSerializer(new Configuration(SQLTemplates.DEFAULT));
serializer.serializeInsert(entityClass, effectiveColumns, effectiveValues);

Expand All @@ -222,8 +282,13 @@ public <T> T executeWithKey(Class<T> type) {

/**
* Append the current {@code values()} (or {@code set()}) state as a row and clear it for the next
* row. Use together with {@link #executeWithKeys(Class)} to issue a multi-row {@code INSERT INTO
* t (...) VALUES (..),(..),...} as a single SQL statement.
* row. Accumulated rows are emitted as a single multi-row {@code INSERT INTO t (...) VALUES
* (..),(..),...} statement by either {@link #execute()} (no keys) or {@link
* #executeWithKeys(Class)} (returning generated keys).
*
* <p><strong>Note:</strong> this is <em>not</em> JDBC batching ({@code
* PreparedStatement.addBatch()}/{@code executeBatch()}, i.e. many statements grouped into one
* round-trip). It builds a single SQL statement with multiple {@code VALUES} tuples.
*
* @return this clause for chaining
* @throws IllegalStateException if no values have been specified for the current row, or if
Expand All @@ -236,6 +301,9 @@ public HibernateInsertClause addRow() {
if (values.isEmpty() && inserts.isEmpty()) {
throw new IllegalStateException("No values to add as row");
}
if (rowColumnPaths == null) {
rowColumnPaths = JpaInsertNativeHelper.effectiveColumns(inserts, columns);
}
rows.add(JpaInsertNativeHelper.effectiveValues(inserts, values));
values.clear();
inserts.clear();
Expand Down Expand Up @@ -275,6 +343,9 @@ public <T> List<T> executeWithKeys(Class<T> type) {
}

var effectiveColumns = JpaInsertNativeHelper.effectiveColumns(inserts, columns);
if (effectiveColumns.isEmpty() && rowColumnPaths != null) {
effectiveColumns = new ArrayList<>(rowColumnPaths);
}
if (effectiveColumns.isEmpty()) {
throw new IllegalStateException("No columns specified for insert");
}
Expand All @@ -289,6 +360,7 @@ public <T> List<T> executeWithKeys(Class<T> type) {

var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType();

JpaInsertNativeHelper.requireSqlModule();
var serializer = new JpaNativeInsertSerializer(new Configuration(SQLTemplates.DEFAULT));
serializer.serializeInsertRows(entityClass, effectiveColumns, allRows);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ public class JPAInsertClause implements InsertClause<JPAInsertClause> {

private final List<List<Expression<?>>> rows = new ArrayList<>();

/**
* Column paths captured at the first {@link #addRow()} call. After {@code addRow()} clears the
* per-row {@code inserts}/{@code values} buffer, this lets executors recover the column list when
* the trailing iteration was also flushed (e.g. {@code for (...) { insert.set(...).addRow(); }}).
* Null until the first {@code addRow()}.
*/
@Nullable private List<Path<?>> rowColumnPaths;

private final EntityManager entityManager;

private final JPQLTemplates templates;
Expand All @@ -76,6 +84,12 @@ public JPAInsertClause(EntityManager em, EntityPath<?> entity, JPQLTemplates tem

@Override
public long execute() {
// Multi-row insert accumulated via addRow(): emit a single native
// INSERT INTO t (...) VALUES (..),(..),... statement in one round-trip.
if (!rows.isEmpty()) {
return executeMultiRow();
}

if (subQuery != null || !hasTemplateValue()) {
var serializer = new JPQLSerializer(templates, entityManager);
serializer.serializeForInsert(
Expand All @@ -101,6 +115,7 @@ public long execute() {

var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType();

JpaInsertNativeHelper.requireSqlModule();
var serializer = new JpaNativeInsertSerializer(new Configuration(SQLTemplates.DEFAULT));
serializer.serializeInsert(entityClass, effectiveColumns, effectiveValues);

Expand All @@ -118,6 +133,47 @@ public long execute() {
return nativeQuery.executeUpdate();
}

/**
* Execute a multi-row INSERT (accumulated via {@link #addRow()}) as a single native {@code INSERT
* INTO t (...) VALUES (..),(..),...} statement, returning the number of affected rows. This path
* does not return generated keys; use {@link #executeWithKeys(Class)} when keys are needed.
*/
private long executeMultiRow() {
if (subQuery != null) {
throw new IllegalStateException("addRow is not supported with INSERT ... SELECT subqueries");
}

var effectiveColumns = JpaInsertNativeHelper.effectiveColumns(inserts, columns);
if (effectiveColumns.isEmpty() && rowColumnPaths != null) {
effectiveColumns = new ArrayList<>(rowColumnPaths);
}
if (effectiveColumns.isEmpty()) {
throw new IllegalStateException("No columns specified for insert");
}

var allRows = new ArrayList<>(rows);
if (!values.isEmpty() || !inserts.isEmpty()) {
allRows.add(JpaInsertNativeHelper.effectiveValues(inserts, values));
}

var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType();

JpaInsertNativeHelper.requireSqlModule();
var serializer = new JpaNativeInsertSerializer(new Configuration(SQLTemplates.DEFAULT));
serializer.serializeInsertRows(entityClass, effectiveColumns, allRows);

var sql = serializer.toString();
var params =
JpaInsertNativeHelper.resolveConstants(
serializer.getConstants(), queryMixin.getMetadata().getParams());

var nativeQuery = entityManager.createNativeQuery(sql);
for (int i = 0; i < params.length; i++) {
nativeQuery.setParameter(i + 1, params[i]);
}
return nativeQuery.executeUpdate();
}

/**
* Whether any value expression is a {@link TemplateExpression} — typically a schema-qualified
* function call from {@code SQLExpressions.function/stringFunction/numberFunction} that
Expand Down Expand Up @@ -187,6 +243,7 @@ public <T> T executeWithKey(Class<T> type) {

var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType();

JpaInsertNativeHelper.requireSqlModule();
var serializer = new JpaNativeInsertSerializer(new Configuration(SQLTemplates.DEFAULT));
serializer.serializeInsert(entityClass, effectiveColumns, effectiveValues);

Expand All @@ -208,8 +265,13 @@ public <T> T executeWithKey(Class<T> type) {

/**
* Append the current {@code values()} (or {@code set()}) state as a row and clear it for the next
* row. Use together with {@link #executeWithKeys(Class)} to issue a multi-row {@code INSERT INTO
* t (...) VALUES (..),(..),...} as a single SQL statement.
* row. Accumulated rows are emitted as a single multi-row {@code INSERT INTO t (...) VALUES
* (..),(..),...} statement by either {@link #execute()} (no keys) or {@link
* #executeWithKeys(Class)} (returning generated keys).
*
* <p><strong>Note:</strong> this is <em>not</em> JDBC batching ({@code
* PreparedStatement.addBatch()}/{@code executeBatch()}, i.e. many statements grouped into one
* round-trip). It builds a single SQL statement with multiple {@code VALUES} tuples.
*
* @return this clause for chaining
* @throws IllegalStateException if no values have been specified for the current row, or if
Expand All @@ -222,6 +284,9 @@ public JPAInsertClause addRow() {
if (values.isEmpty() && inserts.isEmpty()) {
throw new IllegalStateException("No values to add as row");
}
if (rowColumnPaths == null) {
rowColumnPaths = JpaInsertNativeHelper.effectiveColumns(inserts, columns);
}
rows.add(JpaInsertNativeHelper.effectiveValues(inserts, values));
values.clear();
inserts.clear();
Expand Down Expand Up @@ -261,6 +326,9 @@ public <T> List<T> executeWithKeys(Class<T> type) {
}

var effectiveColumns = JpaInsertNativeHelper.effectiveColumns(inserts, columns);
if (effectiveColumns.isEmpty() && rowColumnPaths != null) {
effectiveColumns = new ArrayList<>(rowColumnPaths);
}
if (effectiveColumns.isEmpty()) {
throw new IllegalStateException("No columns specified for insert");
}
Expand All @@ -275,6 +343,7 @@ public <T> List<T> executeWithKeys(Class<T> type) {

var entityClass = queryMixin.getMetadata().getJoins().get(0).getTarget().getType();

JpaInsertNativeHelper.requireSqlModule();
var serializer = new JpaNativeInsertSerializer(new Configuration(SQLTemplates.DEFAULT));
serializer.serializeInsertRows(entityClass, effectiveColumns, allRows);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,75 @@ public void execute_with_function_template_routes_through_native_sql() {
assertThat(stored).isEqualTo("HELLO");
}

@Test
public void execute_multi_row_without_keys_inserts_all_rows() {
// #1692 follow-up: addRow() must also work without returning keys — a plain
// execute() should emit a single multi-row INSERT and report all affected rows.
var entity = QGeneratedKeyEntity.generatedKeyEntity;
long rows =
insert(entity)
.columns(entity.name)
.values("MR-A")
.addRow()
.values("MR-B")
.addRow()
.values("MR-C")
.execute();

assertThat(rows).isEqualTo(3L);

var count =
session
.createNativeQuery(
"select count(*) from generated_key_entity where name_ like 'MR-%'", Long.class)
.getSingleResult();
assertThat(count).isEqualTo(3L);
}

@Test
public void execute_multi_row_in_a_loop_with_trailing_addRow() {
// The loop-friendly shape: every iteration appends a row, no "is this the first row?"
// bookkeeping, and a trailing addRow() is fine.
var entity = QGeneratedKeyEntity.generatedKeyEntity;
var clause = insert(entity).columns(entity.name);
for (var name : new String[] {"Loop1", "Loop2", "Loop3", "Loop4"}) {
clause.values(name).addRow();
}
long rows = clause.execute();

assertThat(rows).isEqualTo(4L);
}

@Test
public void execute_multi_row_set_style_with_trailing_addRow() {
// The loop-friendly shape for set()-style: every iteration calls set()...addRow().
// After the loop, inserts/columns are both empty (set() goes into inserts which addRow()
// clears), but addRow() captures the column paths on its first call so executors can
// recover the column list. No "first row" flag, no buffer-retention trick.
var entity = QGeneratedKeyEntity.generatedKeyEntity;
var insert = insert(entity);
for (var name : new String[] {"Set1", "Set2", "Set3"}) {
insert.set(entity.name, name).addRow();
}
long rows = insert.execute();

assertThat(rows).isEqualTo(3L);
}

@Test
public void executeWithKeys_multi_row_set_style_with_trailing_addRow() {
// Same loop-friendly shape but routed through executeWithKeys to return generated keys.
var entity = QGeneratedKeyEntity.generatedKeyEntity;
var insert = insert(entity);
for (var name : new String[] {"KSet1", "KSet2"}) {
insert.set(entity.name, name).addRow();
}
var keys = insert.executeWithKeys(entity.id);

assertThat(keys).hasSize(2);
assertThat(keys.get(0)).isLessThan(keys.get(1));
}

@Test
public void execute_without_template_uses_jpql_path() {
// Regression for #1757: plain value INSERTs must keep using the JPQL path so
Expand Down
Loading
Loading