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.
Summary
JPAInsertClause/HibernateInsertClauseroute through a native SQL path forexecuteWithKey(...),executeWithKeys(...), and multi-rowexecute()(accumulated viaaddRow()). On that path, enum values passed toset(EnumPath<E>, E)orvalues(E)are handed toPreparedStatement.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
@Enumeratedannotation and convert enum values automatically:@Enumerated(EnumType.STRING)→enum.name()@Enumerated(EnumType.ORDINAL)→enum.ordinal()@Enumeratedon 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):The natural, type-safe call using the generated
EnumPath<E>fields would be: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 callstoString(), which for enums returnsname()), 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
Every call site loses type safety (the double cast forces
Path<String>) and repeats the.name()conversion. ForEnumType.ORDINALcolumns the workaround is even more error-prone: every call site has to remember to sendenum.ordinal()instead, and there is no compiler help to distinguish the two.Proposed behavior
On the native INSERT path (
executeWithKey,executeWithKeys, native-routedexecuteincluding the multi-row addRow() path), for eachEnumvalue being bound the executor inspects the target column's@Enumeratedannotation and converts the value before binding:@Enumerated(EnumType.STRING)→ bindenum.name()@Enumerated(EnumType.ORDINAL)→ bindenum.ordinal()@Enumeratedannotation → bindenum.ordinal()(JPA specification default)@Convert(converter = ...)on an enum field → throwIllegalStateExceptionwith 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
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(...)execute()when it routes to the native SQL path (multi-row viaaddRow(), and single-row when the JPQL path is bypassed for template-value INSERTs)columns(...).values(...)-style andset(path, value)-style call shapesaddRow()Out of scope
@Convert(converter = ...)on enum fields — will produce a clear runtime error steering the caller to convert the value at the call siteDesign notes
Path.getAnnotatedElement()for@Enumeratedand@Convert, using the same reflection surfaceJpaNativeInsertSerializeralready uses to resolve@Columnnames. No new dependency on Hibernate-specific APIs.PreparedStatement.setObject(...)), so all three executors share a single code path.Expressions.constant(...)or expression templates that don't resolve to a bareEnumare left alone — the fallback stays identical to today's behavior for non-enum values.Test plan
New tests in
JPAExecuteWithKeyTestandHibernateExecuteWithKeyTestcovering:@Enumerated(EnumType.STRING)— value stored asname(), and read back through aSELECT name_ FROM ... WHERE id = ?native query for verification@Enumerated(EnumType.ORDINAL)— value stored asordinal(), verified the same way@Enumerated— stored asordinal()(JPA default)@Convert(converter = ...)on an enum field →IllegalStateExceptionwith an actionable messageset(path, value)andcolumns(...).values(value)stylesaddRow()) with mixed enum + non-enum columnsEnvironment
JPAInsertClauseandHibernateInsertClauseexecution paths, with entity fields covering bothEnumType.STRINGandEnumType.ORDINAL.