Skip to content

Add executeWithKey() to JPAInsertClause and HibernateInsertClause#1693

Open
zio0911 wants to merge 7 commits into
OpenFeign:masterfrom
zio0911:feature/execute-with-key-jpa-1692
Open

Add executeWithKey() to JPAInsertClause and HibernateInsertClause#1693
zio0911 wants to merge 7 commits into
OpenFeign:masterfrom
zio0911:feature/execute-with-key-jpa-1692

Conversation

@zio0911
Copy link
Copy Markdown

@zio0911 zio0911 commented Apr 14, 2026

Summary

  • Add executeWithKey(Path<T>) and executeWithKey(Class<T>) to JPAInsertClause and HibernateInsertClause
  • Add addRow() and executeWithKeys(Path<T>) / executeWithKeys(Class<T>) → List<T> for multi-row INSERT with batched key return (single statement, single round-trip)
  • Bypass JPQL and execute native SQL INSERT via JDBC with Statement.RETURN_GENERATED_KEYS to retrieve auto-generated keys
  • Introduce JpaNativeInsertSerializer (extends SQLSerializer) for a single-pass SQL + constants build, ensuring function templates, params, and paths serialize correctly
  • Add JpaInsertNativeHelper utility for @Table/@Column resolution and JDBC binding (single- and multi-row execution)
  • Add doReturningWork() to SessionHolder interface and all implementations

Closes #1692

Motivation

The SQL module's SQLInsertClause supports executeWithKey() (and executeWithKeys() for batched inserts), but the JPA module does not. This forces JPA users to fall back to EntityManager.persist() + flush() for single-row inserts, and to a for-loop of N single-row calls for bulk inserts — N statements, N round-trips, no batching.

Using the SQL module in a JPA project requires a separate SQLQueryFactory, SQL-specific Q-classes, and managing two query factories — excessive overhead just for insert key return.

Before / After

Before — must break out of QueryDSL:

// Single row
entityManager.persist(entity);
entityManager.flush();
Long seq = entity.getSeq();

// Bulk — N statements, N round-trips
List<Long> ids = new ArrayList<>();
for (var dto : dtos) {
    entityManager.persist(toEntity(dto));
    entityManager.flush();
    ids.add(toEntity(dto).getSeq());
}

After — stays in QueryDSL:

// Single row
Long seq = queryFactory.insert(role)
    .set(role.name, dto.roleName())
    .set(role.status, status)
    .executeWithKey(role.seq);

// Bulk — 1 statement, 1 round-trip, all keys returned
List<Long> ids = queryFactory.insert(member)
    .columns(member.name, member.email)
    .values("Alice", "a@x.com").addRow()
    .values("Bob",   "b@x.com").addRow()
    .values("Carol", "c@x.com")           // trailing addRow optional
    .executeWithKeys(member.seq);

Implementation

Since JPA's Query.executeUpdate() only returns affected row count, the implementation:

  1. Reads @Table / @Column annotations to build native SQL (same pattern as NativeSQLSerializer)
  2. Multi-row support emits a single INSERT INTO t (...) VALUES (..),(..),... statement; getGeneratedKeys() is iterated to collect all keys in row order
  3. HibernateInsertClause uses Session.doReturningWork() for JDBC access
  4. JPAInsertClause uses EntityManager.unwrap(Session.class).doReturningWork()
  5. executeWithKey() (singular) throws IllegalStateException when called after addRow() to guard single-row contracts from being silently violated

Single-pass serialization (regression fix)

The earlier draft built the SQL via a JpaInsertNativeHelper.buildNativeInsertSQL step while constants were extracted from a separate JPQLSerializer pass. The helper emitted one ? per column without inspecting the value expression tree, so a function template like dbo.encrypt({0}) was dropped from the SQL and only the inner constant was bound — surfacing as plaintext stored in the DB.

This is now a single serialization pass: JpaNativeInsertSerializer extends SQLSerializer and produces the SQL and the constants list together. By reusing the SQLSerializer visitor, function templates, constants, params, and paths all serialize correctly. @Column / @Table resolution follows the same pattern as NativeSQLSerializer, and plain ? placeholders come from SQLSerializer.serializeConstant's default behavior. The broken buildNativeInsertSQL path is removed.

Regression tests cover:

  • Function template with bound constant: upper({0}) + "value" → DB stores "VALUE"
  • Zero-argument templates and multi-argument templates
  • Identifier quoting via a custom SQLTemplates

On adding SQL serialization to the JPA module

A fair concern was raised about expanding SQL-mapping responsibilities in the JPA module. Mitigations:

  • No new dependency. NativeSQLSerializer already lives in this module; the new serializer reuses the same infrastructure.
  • Narrow scope. The serializer is only used on the executeWithKey / executeWithKeys path. execute() and toString() still go through JPQLSerializer unchanged.
  • Fallback option. If this still feels like the wrong tradeoff, the scope can be narrowed so executeWithKey / executeWithKeys only accept Constant / Param / Path values and explicitly reject anything else (no template support).

Limitations

  • INSERT ... SELECT subqueries are not supported (throws UnsupportedOperationException) for both executeWithKey and executeWithKeys
  • Multi-row VALUES (..),(..) uses standard SQL syntax — Oracle's INSERT ALL form would need a dialect branch (follow-up)
  • Multi-row key retrieval relies on the JDBC driver returning all rows from getGeneratedKeys() (verified on H2; MySQL Connector/J 8+, PostgreSQL JDBC 42+ known to support this)
  • Requires explicit @Table / @Column annotations if using a custom Hibernate PhysicalNamingStrategy
  • JPAInsertClause currently relies on Hibernate as the JPA provider

Test plan

  • Unit tests for JpaInsertNativeHelper (table name resolution, column name resolution, SQL generation, multi-row SQL generation)
  • Integration tests for HibernateInsertClause.executeWithKey() (set style, columns/values style, class type, multiple inserts, column annotation, subquery rejection)
  • Integration tests for JPAInsertClause.executeWithKey() (same scenarios)
  • Integration tests for executeWithKeys() on both clauses (multi-row returns all keys in order, single-row returns size-1 list, executeWithKey rejected after addRow, addRow rejected with no pending values)
  • Regression tests for function template handling (upper({0}) + constant, zero-arg templates, custom SQLTemplates identifier quoting)
  • All new tests pass (21 integration tests across both clauses)
  • Existing tests unaffected — single-row executeWithKey signature and behavior unchanged (internal serializer refactor delegates the legacy path to the multi-row implementation)
  • ./mvnw -Pdev verify passes
  • Code formatted (git-code-format pre-commit hook)

@zio0911 zio0911 force-pushed the feature/execute-with-key-jpa-1692 branch from b9a1975 to 2308d3e Compare April 14, 2026 10:10
@kamilkrzywanski
Copy link
Copy Markdown
Contributor

@zio0911 Shouldn't a similar approach to escaping table and column names as in NativeSQLSerializer be used in JpaInsertNativeHelper? See for example: getTemplates().quoteIdentifier(column.name(), precededByDot).

@zio0911
Copy link
Copy Markdown
Author

zio0911 commented Apr 20, 2026

@kamilkrzywanski Good catch, thanks for the review.

You're right — using raw identifiers can break when column/table names contain SQL reserved words, spaces, or dialect-specific characters. I'll update JpaInsertNativeHelper to use SQLTemplates.quoteIdentifier() for proper escaping, consistent with NativeSQLSerializer.

Will push the fix shortly.

Apply dialect-specific identifier quoting to table and column names in
JpaInsertNativeHelper, consistent with NativeSQLSerializer. This avoids
issues when identifiers contain SQL reserved words, spaces, or
dialect-specific characters.

- buildNativeInsertSQL now accepts SQLTemplates parameter
- Schema-qualified table names quote schema and table parts separately
- JPAInsertClause and HibernateInsertClause pass SQLTemplates.DEFAULT
- Deprecate the overload without SQLTemplates
- Add test covering always-quote templates
@zio0911
Copy link
Copy Markdown
Author

zio0911 commented Apr 20, 2026

Pushed the fix in 5decf59.

Summary of changes:

  • JpaInsertNativeHelper.buildNativeInsertSQL now accepts SQLTemplates and applies quoteIdentifier() to table and column names
  • Schema-qualified table names quote schema and table parts separately
  • JPAInsertClause and HibernateInsertClause pass SQLTemplates.DEFAULT
  • Added a test covering always-quote templates to verify the escape behavior
  • Kept the old buildNativeInsertSQL(Class, Collection) as a @Deprecated delegate for backward safety

@zio0911 zio0911 force-pushed the feature/execute-with-key-jpa-1692 branch from 5decf59 to 19ff4ce Compare April 20, 2026 02:48
@kamilkrzywanski
Copy link
Copy Markdown
Contributor

kamilkrzywanski commented Apr 21, 2026

What about situations like the one shown on the screen, where instead of a binding function we just have a raw constant? Personally, I’m not convinced that introducing SQL mapping into the JPA/Hibernate module is a good or consistent idea.
image
image

Previously JpaInsertNativeHelper.buildNativeInsertSQL emitted one ? per
column without inspecting the value expression tree, so function
templates like dbo.encrypt({0}) were dropped from the generated SQL and
only the inner constant got bound. SQL and constants were also produced
by separate serialization passes (helper for SQL, JPQLSerializer for
constants), so the two sides could diverge.

Replace this with a single JpaNativeInsertSerializer extending
SQLSerializer that produces the SQL and constants together. Function
templates, paths, params, and constants now serialize correctly via the
SQLSerializer visitor, with plain ? placeholders for JDBC binding and
@Column/@table resolution following the NativeSQLSerializer pattern.

Add regression coverage in JpaNativeInsertSerializerTest plus
integration tests in JPAExecuteWithKeyTest and HibernateExecuteWithKeyTest
that verify the screenshot case (upper({0}) + "value" -> DB stores
"VALUE"), zero-arg and multi-arg templates, and identifier quoting.
@zio0911
Copy link
Copy Markdown
Author

zio0911 commented Apr 28, 2026

@kamilkrzywanski

The root cause was that the SQL was built separately by JpaInsertNativeHelper.buildNativeInsertSQL while constants were extracted from a different JPQLSerializer pass. The helper only emitted one ? per column without inspecting the value expression tree, so a function template like dbo.encrypt({0}) was dropped from the SQL and only the inner constant got bound — exactly what your debugger snapshot shows.

I refactored this to a single serialization pass: a new JpaNativeInsertSerializer extends SQLSerializer produces the SQL and the constants list together. Because it reuses the SQLSerializer visitor, function templates, constants, params, and paths all serialize correctly. @Column/@table resolution follows the same pattern as NativeSQLSerializer, and plain ? placeholders come from SQLSerializer.serializeConstant's default behavior. The broken buildNativeInsertSQL is removed.

Regression tests cover the screenshot case (upper({0}) + "value" → DB stores "VALUE"), zero-arg and multi-arg templates, and identifier quoting via custom SQLTemplates.

On the broader concern about introducing SQL mapping into the JPA module: fair point. The mitigation is that no new dependency was added — NativeSQLSerializer already lives in this module — and the new serializer is only used on the executeWithKey path; execute() and toString() still go through JPQLSerializer. If you still feel it's the wrong tradeoff, I can narrow the scope so executeWithKey only accepts Constant/Param/Path values and explicitly rejects anything else.

ziolee and others added 2 commits April 29, 2026 12:16
Extends executeWithKey() (single-row) with batched-key support so JPA
users can issue a single multi-row INSERT and receive all generated
keys, mirroring the SQL module's executeWithKeys().

JPAInsertClause / HibernateInsertClause:
- addRow() finalizes the current values()/set() state as one row and
  clears it for the next row
- executeWithKeys(Path<T>) and executeWithKeys(Class<T>) return
  List<T> of generated keys in row order; trailing values are
  auto-flushed
- executeWithKey() now throws IllegalStateException when called after
  addRow() to guard the single-row contract

JpaNativeInsertSerializer:
- New serializeInsertRows() emits a single
  INSERT INTO t (...) VALUES (..),(..),...
  statement; legacy single-row serializeInsert() delegates to it

JpaInsertNativeHelper:
- executeAndReturnKeys() iterates the full getGeneratedKeys()
  ResultSet to collect every key in row order

Tests cover multi-row key return, single-row List<T> path, the
post-addRow guard on executeWithKey, and the empty-row guard on
addRow.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add executeWithKey() support to JPAInsertClause and HibernateInsertClause

3 participants