Skip to content

Auto-convert @Enumerated values in native JPA INSERT paths (executeWithKey, executeWithKeys, multi-row execute) #1883

Description

@zio0911
  • I am willing to put in the work and submit a PR to resolve this issue.

Summary

JPAInsertClause / HibernateInsertClause route through a native SQL path for executeWithKey(...), executeWithKeys(...), and multi-row execute() (accumulated via addRow()). On that path, enum values passed to set(EnumPath<E>, E) or values(E) are handed to PreparedStatement.setObject(...) as-is, so how they get stored depends entirely on the JDBC driver — MySQL usually calls .toString() and happens to store the correct name, but H2 / HSQLDB / other drivers may fail or store the wrong representation.

This change makes the native INSERT paths honor the target column's @Enumerated annotation and convert enum values automatically:

  • @Enumerated(EnumType.STRING)enum.name()
  • @Enumerated(EnumType.ORDINAL)enum.ordinal()
  • No @Enumerated on an enum field → enum.ordinal() (JPA default)
  • @Convert(converter = ...) on an enum field → fail-fast with an actionable message (custom converters cannot be honored on the native path)

The JPQL path (plain execute() without templates, UPDATE, DELETE, SELECT) already delegates to Hibernate and is not affected — this issue is scoped to the native INSERT path only.

Motivation

Consider an entity with @Enumerated(EnumType.STRING) columns (a common JPA pattern):

@Entity
@Table(name = "log_export_job")
public class LogExportJobEntity {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column(name = "job_id")
  private Long jobId;

  @Enumerated(EnumType.STRING)
  @Column(name = "job_type", nullable = false, length = 30)
  private LogExportType jobType;

  @Enumerated(EnumType.STRING)
  @Column(name = "status", nullable = false, length = 20)
  private LogExportStatus status;

  // ... other fields
}

The natural, type-safe call using the generated EnumPath<E> fields would be:

QLogExportJobEntity q = QLogExportJobEntity.logExportJobEntity;
Long id = queryFactory.insert(q)
    .set(q.jobType, entity.getJobType())    // enum value → EnumPath<LogExportType>
    .set(q.status, entity.getStatus())      // enum value → EnumPath<LogExportStatus>
    .set(q.requestedBy, entity.getRequestedBy())
    .set(q.filterJson, entity.getFilterJson())
    .set(q.requestedAt, entity.getRequestedAt())
    .executeWithKey(q.jobId);

Today this compiles but produces driver-dependent behavior on the native path: the enum object is passed straight to PreparedStatement.setObject(...). On MySQL it usually works by accident (the driver calls toString(), which for enums returns name()), but on other drivers it can throw, silently store the fully-qualified enum class name, or store an unexpected value.

Because of this, users end up with defensive workarounds like the one below.

Current workaround

public Long insert(LogExportJobEntity entity) {
  QLogExportJobEntity q = QLogExportJobEntity.logExportJobEntity;
  // Lie to the compiler: cast EnumPath<LogExportType> to Path<String>...
  Path<String> jobType = (Path<String>) (Path<?>) q.jobType;
  Path<String> status = (Path<String>) (Path<?>) q.status;
  return queryFactory.insert(q)
      // ...and manually convert every enum to its String form
      .set(jobType, entity.getJobType().name())
      .set(status, entity.getStatus().name())
      .set(q.requestedBy, entity.getRequestedBy())
      .set(q.filterJson, entity.getFilterJson())
      .set(q.requestedAt, entity.getRequestedAt())
      .executeWithKey(q.jobId);
}

Every call site loses type safety (the double cast forces Path<String>) and repeats the .name() conversion. For EnumType.ORDINAL columns the workaround is even more error-prone: every call site has to remember to send enum.ordinal() instead, and there is no compiler help to distinguish the two.

Proposed behavior

On the native INSERT path (executeWithKey, executeWithKeys, native-routed execute including the multi-row addRow() path), for each Enum value being bound the executor inspects the target column's @Enumerated annotation and converts the value before binding:

  • @Enumerated(EnumType.STRING) → bind enum.name()
  • @Enumerated(EnumType.ORDINAL) → bind enum.ordinal()
  • Enum field with no @Enumerated annotation → bind enum.ordinal() (JPA specification default)
  • @Convert(converter = ...) on an enum field → throw IllegalStateException with a message pointing the user to convert the value themselves at the call site (custom AttributeConverters cannot be honored on a native path that bypasses JPA)

The JPQL path (plain single-row execute() without template values, all UPDATE / DELETE / SELECT clauses) is unchanged — Hibernate already handles enum conversion there.

Expected call site after the fix

public Long insert(LogExportJobEntity entity) {
  QLogExportJobEntity q = QLogExportJobEntity.logExportJobEntity;
  return queryFactory.insert(q)
      .set(q.jobType, entity.getJobType())    // strong-typed enum, auto-converted to name()
      .set(q.status, entity.getStatus())      // strong-typed enum, auto-converted to name()
      .set(q.requestedBy, entity.getRequestedBy())
      .set(q.filterJson, entity.getFilterJson())
      .set(q.requestedAt, entity.getRequestedAt())
      .executeWithKey(q.jobId);
}

No cast tricks, no .name() / .ordinal() bookkeeping at the call site. Enum semantics are driven by the entity's mapping annotations, which is the same source of truth JPA / Hibernate uses elsewhere.

Scope

In scope

  • JPAInsertClause.executeWithKey(...) / executeWithKeys(...)
  • HibernateInsertClause.executeWithKey(...) / executeWithKeys(...)
  • Both clauses' execute() when it routes to the native SQL path (multi-row via addRow(), and single-row when the JPQL path is bypassed for template-value INSERTs)
  • Both columns(...).values(...)-style and set(path, value)-style call shapes
  • Multi-row inserts accumulated via addRow()

Out of scope

  • JPQL INSERT / UPDATE / DELETE / SELECT — Hibernate already handles enum conversion there
  • Reading enum values back from result sets (all read paths use JPQL / Hibernate)
  • @Convert(converter = ...) on enum fields — will produce a clear runtime error steering the caller to convert the value at the call site
  • querydsl-sql (non-JPA) — separate module, separate binding pipeline

Design notes

  • The mapping is resolved by inspecting Path.getAnnotatedElement() for @Enumerated and @Convert, using the same reflection surface JpaNativeInsertSerializer already uses to resolve @Column names. No new dependency on Hibernate-specific APIs.
  • Conversion happens once, at the point where the constant list is materialized for JDBC binding (before PreparedStatement.setObject(...)), so all three executors share a single code path.
  • Enum values passed via Expressions.constant(...) or expression templates that don't resolve to a bare Enum are left alone — the fallback stays identical to today's behavior for non-enum values.

Test plan

New tests in JPAExecuteWithKeyTest and HibernateExecuteWithKeyTest covering:

  • @Enumerated(EnumType.STRING) — value stored as name(), and read back through a SELECT name_ FROM ... WHERE id = ? native query for verification
  • @Enumerated(EnumType.ORDINAL) — value stored as ordinal(), verified the same way
  • Enum field without @Enumerated — stored as ordinal() (JPA default)
  • @Convert(converter = ...) on an enum field → IllegalStateException with an actionable message
  • Both set(path, value) and columns(...).values(value) styles
  • Multi-row INSERT (via addRow()) with mixed enum + non-enum columns
  • Regression: non-enum columns and template-value INSERTs continue to work unchanged

Environment

  • querydsl-jpa: 7.x (current master)
  • Hibernate: 6.x / 7.x
  • Java: 17+
  • Tested against H2 (in-memory) for both JPAInsertClause and HibernateInsertClause execution paths, with entity fields covering both EnumType.STRING and EnumType.ORDINAL.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions