diff --git a/hibernate-core/src/main/java/org/hibernate/annotations/Audited.java b/hibernate-core/src/main/java/org/hibernate/annotations/Audited.java
index 8580257a0cbf..b8fe37d7a470 100644
--- a/hibernate-core/src/main/java/org/hibernate/annotations/Audited.java
+++ b/hibernate-core/src/main/java/org/hibernate/annotations/Audited.java
@@ -8,6 +8,7 @@
import org.hibernate.cfg.StateManagementSettings;
import java.lang.annotation.Documented;
+import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
@@ -54,9 +55,13 @@
* the {@linkplain java.time.Instant#now() current JVM instant} is
* used as the transaction identifier, but relying on this default
* behavior is not recommended.
+ *
+ * Use the nested {@link Table @Audited.Table} annotation to
+ * customize the audit log's table name, schema, catalog, or
+ * column names for the audit log.
*
* @author Gavin King
- *
+ * @author Marco Belladelli
* @since 7.4
*/
@Documented
@@ -64,36 +69,166 @@
@Retention(RUNTIME)
@Incubating
public @interface Audited {
+
/**
- * The name of the audit log table. Defaults to the
- * name of the main table holding currently effective
- * data, with the suffix {@code _aud}.
+ * Excludes the annotated attribute from auditing.
+ * Updates to an excluded attribute modify the current
+ * row directly without creating a new revision of the
+ * entity instance. The audit log table does not contain
+ * columns mapped by excluded attributes.
*/
- String tableName() default "";
+ @Documented
+ @Target({FIELD, METHOD})
+ @Retention(RUNTIME)
+ @interface Excluded {
+ }
/**
- * The name of the column holding the transaction identifier.
- * @see org.hibernate.engine.spi.SharedSessionContractImplementor#getCurrentTransactionIdentifier()
+ * Specifies the audit log's table mapping for an audited
+ * entity. Placed on the entity class alongside
+ * {@link Audited @Audited} to customize the audit table
+ * name, schema, catalog, and column names.
+ *
+ * This annotation may also be placed on a subclass entity
+ * in a JOINED or TABLE_PER_CLASS hierarchy to override
+ * the audit table name, schema, or catalog for that
+ * subclass. The subclass inherits auditing from the root
+ * entity; the annotation on the subclass only customizes
+ * table mapping.
+ *
+ * @since 7.4
*/
- String transactionId() default "REV";
+ @Documented
+ @Target({TYPE, PACKAGE, ANNOTATION_TYPE})
+ @Retention(RUNTIME)
+ @interface Table {
+ String DEFAULT_TRANSACTION_ID = "REV";
+ String DEFAULT_MODIFICATION_TYPE = "REVTYPE";
+ String DEFAULT_TRANSACTION_END_ID = "REVEND";
+
+ /**
+ * The name of the audit log table. Defaults to the
+ * name of the main table with the suffix {@code _AUD}.
+ */
+ String name() default "";
+
+ /**
+ * The schema of the audit log table. Defaults to the
+ * schema of the main table.
+ */
+ String schema() default "";
+
+ /**
+ * The catalog of the audit log table. Defaults to the
+ * catalog of the main table.
+ */
+ String catalog() default "";
+
+ /**
+ * The name of the column holding the transaction identifier.
+ *
+ * @see org.hibernate.engine.spi.SharedSessionContractImplementor#getCurrentTransactionIdentifier()
+ */
+ String transactionIdColumn() default DEFAULT_TRANSACTION_ID;
+
+ /**
+ * The name of the column holding the modification type,
+ * encoded as 0 for creation, 1 for modification, and 2
+ * for deletion.
+ *
+ * @see org.hibernate.audit.ModificationType
+ */
+ String modificationTypeColumn() default DEFAULT_MODIFICATION_TYPE;
+
+ /**
+ * The name of the column holding the end transaction
+ * identifier, used only when the
+ * {@linkplain StateManagementSettings#AUDIT_STRATEGY
+ * audit strategy} is set to {@code "validity"}. When a
+ * new audit row is written, the previous row's end
+ * transaction id column is updated with the current
+ * transaction identifier, marking it as superseded.
+ * A {@code null} value indicates the row is current
+ * (not yet superseded).
+ */
+ String transactionEndIdColumn() default DEFAULT_TRANSACTION_END_ID;
+
+ /**
+ * The name of the column holding the timestamp of the
+ * end transaction. Only used when the
+ * {@linkplain StateManagementSettings#AUDIT_STRATEGY
+ * audit strategy} is set to {@code "validity"} and this
+ * attribute is set to a non-empty value. Stores the
+ * timestamp of when the audit row was superseded.
+ */
+ String transactionEndTimestampColumn() default "";
+ }
/**
- * The name of the column holding the modification type,
- * encoded as 0 for creation, 1 for modification, and 2
- * for deletion
+ * Specifies a custom audit table name for a
+ * {@link jakarta.persistence.SecondaryTable @SecondaryTable}.
+ * Placed on the entity class alongside
+ * {@link Audited @Audited}.
+ *
+ * @since 7.4
*/
- String modificationType() default "REVTYPE";
+ @Documented
+ @Target(TYPE)
+ @Retention(RUNTIME)
+ @Repeatable(SecondaryTables.class)
+ @interface SecondaryTable {
+ /**
+ * The name of the secondary table being overridden.
+ */
+ String secondaryTableName();
+
+ /**
+ * The custom audit table name for this secondary table.
+ */
+ String secondaryAuditTableName();
+ }
/**
- * Excludes the annotated attribute from auditing.
- * Updates to an excluded attribute modify the current
- * row directly without creating a new revision of the
- * entity instance. The audit log table does not contain
- * columns mapped by excluded attributes.
+ * Container for repeatable {@link SecondaryTable} annotations.
+ *
+ * @see SecondaryTable
+ * @since 7.4
*/
@Documented
- @Target({FIELD, METHOD})
+ @Target(TYPE)
@Retention(RUNTIME)
- @interface Excluded {
+ @interface SecondaryTables {
+ SecondaryTable[] value();
+ }
+
+ /**
+ * Specifies a custom audit table name (and optionally schema
+ * and catalog) for an audited collection.
+ * Placed on the collection field or property.
+ *
+ * @since 7.4
+ */
+ @Documented
+ @Retention(RUNTIME)
+ @Target({FIELD, METHOD})
+ @interface CollectionTable {
+ /**
+ * The name of the collection audit table.
+ */
+ String name();
+
+ /**
+ * The schema of the collection audit table.
+ * Defaults to the schema of the owning entity's
+ * audit table.
+ */
+ String schema() default "";
+
+ /**
+ * The catalog of the collection audit table.
+ * Defaults to the catalog of the owning entity's
+ * audit table.
+ */
+ String catalog() default "";
}
}
diff --git a/hibernate-core/src/main/java/org/hibernate/annotations/RevisionEntity.java b/hibernate-core/src/main/java/org/hibernate/annotations/RevisionEntity.java
new file mode 100644
index 000000000000..928bccd34ffa
--- /dev/null
+++ b/hibernate-core/src/main/java/org/hibernate/annotations/RevisionEntity.java
@@ -0,0 +1,113 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.annotations;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import org.hibernate.Incubating;
+import org.hibernate.audit.RevisionListener;
+
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+/**
+ * Marks an entity as the revision entity for audit logging.
+ * The annotated class must also be annotated
+ * {@link jakarta.persistence.Entity @Entity} and must have:
+ *
+ * a field annotated with {@link TransactionId} (typically
+ * the {@code @Id} with {@code @GeneratedValue})
+ * a field annotated with {@link Timestamp}
+ *
+ *
+ * The revision entity is responsible for initializing its own
+ * {@link Timestamp @Timestamp} field, for example, via a field
+ * initializer, in the constructor, or in a
+ * {@link org.hibernate.audit.RevisionListener}.
+ *
+ * When a class annotated with {@code @RevisionEntity} is found
+ * in the domain model, it is automatically configured as the
+ * {@link org.hibernate.temporal.spi.TransactionIdentifierSupplier},
+ * and no {@code hibernate.temporal.transaction_id_supplier} setting
+ * is required.
+ *
+ * Only one entity may be annotated with {@code @RevisionEntity}.
+ *
+ * @see Audited
+ * @see TransactionId
+ * @see Timestamp
+ * @see RevisionListener
+ * @since 7.4
+ */
+@Documented
+@Incubating
+@Retention(RUNTIME)
+@Target(TYPE)
+public @interface RevisionEntity {
+ /**
+ * An optional {@link RevisionListener} implementation that
+ * will be called after the revision entity is created, to
+ * populate custom fields (e.g. user, comment).
+ */
+ Class extends RevisionListener> listener() default RevisionListener.class;
+
+ /**
+ * Marks the property that holds the transaction identifier
+ * in a {@link RevisionEntity @RevisionEntity}. This should
+ * typically be the auto-generated primary key
+ * ({@code @Id @GeneratedValue}).
+ *
+ * The value is set by the persistence layer when the
+ * revision entity is inserted.
+ *
+ * @see RevisionEntity
+ * @since 7.4
+ */
+ @Documented
+ @Retention(RUNTIME)
+ @Target({ METHOD, FIELD })
+ @interface TransactionId {
+ }
+
+ /**
+ * Marks the property that holds the revision timestamp in a
+ * {@link RevisionEntity @RevisionEntity}. The value must be
+ * initialized by the revision entity itself, for example,
+ * via a field initializer, in the constructor, or in a
+ * {@link org.hibernate.audit.RevisionListener}.
+ *
+ * @see RevisionEntity
+ * @since 7.4
+ */
+ @Documented
+ @Retention(RUNTIME)
+ @Target({ METHOD, FIELD })
+ @interface Timestamp {
+ }
+
+ /**
+ * Marks a {@code Set} property on a
+ * {@link RevisionEntity @RevisionEntity} that holds the
+ * names of entity types modified in each revision. The
+ * property is typically mapped as an
+ * {@code @ElementCollection}.
+ *
+ * When this annotation is present on a revision entity,
+ * cross-type revision queries are automatically enabled
+ * via {@link org.hibernate.audit.AuditLog}.
+ *
+ * @see RevisionEntity
+ * @since 7.4
+ */
+ @Documented
+ @Retention(RUNTIME)
+ @Target({ METHOD, FIELD })
+ @interface ModifiedEntities {
+ }
+}
diff --git a/hibernate-core/src/main/java/org/hibernate/audit/AuditEntry.java b/hibernate-core/src/main/java/org/hibernate/audit/AuditEntry.java
new file mode 100644
index 000000000000..1f481b6601d4
--- /dev/null
+++ b/hibernate-core/src/main/java/org/hibernate/audit/AuditEntry.java
@@ -0,0 +1,26 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.audit;
+
+
+/**
+ * A tuple representing an entity at a specific revision in
+ * the audit history.
+ *
+ * The {@link #revision} field is:
+ *
+ * the revision entity instance (e.g. {@link DefaultRevisionEntity}), if one is configured
+ * the plain transaction identifier (e.g. {@code Instant}, {@code Integer}) otherwise
+ *
+ *
+ * @param entity the entity snapshot at this revision
+ * @param revision the revision entity (if configured) or transaction identifier
+ * @param modificationType the type of modification (ADD/MOD/DEL)
+ * @param the entity type
+ * @author Marco Belladelli
+ * @since 7.4
+ */
+public record AuditEntry(T entity, Object revision, ModificationType modificationType) {
+}
diff --git a/hibernate-core/src/main/java/org/hibernate/audit/AuditException.java b/hibernate-core/src/main/java/org/hibernate/audit/AuditException.java
new file mode 100644
index 000000000000..0a4125706f1c
--- /dev/null
+++ b/hibernate-core/src/main/java/org/hibernate/audit/AuditException.java
@@ -0,0 +1,23 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.audit;
+
+import org.hibernate.HibernateException;
+
+/**
+ * Indicates a problem related to audit functionality.
+ *
+ * @author Marco Belladelli
+ * @since 7.4
+ */
+public class AuditException extends HibernateException {
+ public AuditException(String message) {
+ super( message );
+ }
+
+ public AuditException(String message, Throwable cause) {
+ super( message, cause );
+ }
+}
diff --git a/hibernate-core/src/main/java/org/hibernate/audit/AuditLog.java b/hibernate-core/src/main/java/org/hibernate/audit/AuditLog.java
new file mode 100644
index 000000000000..b842c5373d69
--- /dev/null
+++ b/hibernate-core/src/main/java/org/hibernate/audit/AuditLog.java
@@ -0,0 +1,330 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.audit;
+
+import org.hibernate.annotations.RevisionEntity;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * A service for querying audit metadata. Provides access
+ * to revision history and modification types for
+ * {@linkplain org.hibernate.annotations.Audited audited}
+ * entities, complementing the transparent point-in-time
+ * reads available via
+ * {@link org.hibernate.SessionBuilder#atTransaction(Object)
+ * atTransaction()} sessions.
+ *
+ * Obtain an instance via {@link AuditLogFactory#create}.
+ * The instance manages an internal session for audit queries;
+ * close it when done to release the session and its JDBC
+ * connection.
+ *
+ * @author Marco Belladelli
+ * @see AuditLogFactory
+ * @since 7.4
+ */
+public interface AuditLog extends AutoCloseable {
+
+ /**
+ * Close this audit log and release its internal session.
+ */
+ @Override
+ void close();
+
+ /**
+ * A special transaction identifier that selects all
+ * revisions from the audit table without filtering.
+ * Pass this to
+ * {@link org.hibernate.SessionBuilder#atTransaction(Object)
+ * atTransaction()} to open a session that reads all audit
+ * rows, including deletions.
+ *
+ * Usage:
+ *
+ * try (var s = sf.withOptions()
+ * .atTransaction(AuditLog.ALL_REVISIONS).open()) {
+ * var history = s.createSelectionQuery(
+ * "from MyEntity where id = :id",
+ * MyEntity.class)
+ * .setParameter("id", entityId)
+ * .getResultList();
+ * }
+ *
+ *
+ * @see #getHistory(Class, Object)
+ */
+ Object ALL_REVISIONS = new Object();
+
+ /**
+ * List all transaction identifiers where the given entity
+ * was modified, ordered chronologically.
+ *
+ * @param entityClass the audited entity class
+ * @param id the entity identifier
+ *
+ * @return the list of transaction identifiers
+ */
+ List getRevisions(Class> entityClass, Object id);
+
+ /**
+ * Get the {@linkplain ModificationType modification type}
+ * (ADD/MOD/DEL) for an entity at a specific transaction.
+ *
+ * @param entityClass the audited entity class
+ * @param id the entity identifier
+ * @param transactionId the transaction identifier
+ *
+ * @return the modification type, or {@code null} if the
+ * entity was not modified at that transaction
+ */
+ ModificationType getModificationType(Class> entityClass, Object id, Object transactionId);
+
+ /**
+ * Check if an entity type is audited.
+ *
+ * @param entityClass the entity class
+ *
+ * @return {@code true} if the entity is audited
+ */
+ boolean isAudited(Class> entityClass);
+
+ /**
+ * Find an entity snapshot as of a specific transaction.
+ * Returns the state at the most recent revision at or
+ * before the given transaction.
+ *
+ * @param entityClass the audited entity class
+ * @param id the entity identifier
+ * @param transactionId the transaction identifier
+ * @param the entity type
+ *
+ * @return the entity state at that transaction, or
+ * {@code null} if the entity did not exist
+ * (e.g. before creation or after deletion)
+ */
+ T find(Class entityClass, Object id, Object transactionId);
+
+ /**
+ * Find an entity snapshot as of a specific transaction,
+ * optionally including deleted entities.
+ *
+ * When {@code includeDeletions} is {@code false}, this
+ * behaves identically to {@link #find(Class, Object, Object)},
+ * returning {@code null} for DEL revisions. When {@code true},
+ * the entity state at deletion is returned instead of
+ * {@code null}.
+ *
+ * @param entityClass the audited entity class
+ * @param id the entity identifier
+ * @param transactionId the transaction identifier
+ * @param includeDeletions whether to include deleted entities
+ * @param the entity type
+ *
+ * @return the entity state at that transaction
+ */
+ T find(Class entityClass, Object id, Object transactionId, boolean includeDeletions);
+
+ /**
+ * Find an entity snapshot as of the given instant. Returns
+ * the state at the highest revision on or before the instant.
+ *
+ * @param entityClass the audited entity class
+ * @param id the entity identifier
+ * @param instant the point in time
+ * @param the entity type
+ *
+ * @return the entity state, or {@code null}
+ */
+ T find(Class entityClass, Object id, Instant instant);
+
+ /**
+ * Find all entity snapshots of the given type that
+ * were modified at a specific transaction.
+ *
+ * @param entityClass the audited entity class
+ * @param transactionId the transaction identifier
+ * @param the entity type
+ *
+ * @return the entity snapshots at that transaction
+ */
+ List findEntitiesModifiedAt(Class entityClass, Object transactionId);
+
+ /**
+ * Find all entity snapshots of the given type that
+ * were modified at a specific transaction with the
+ * specified modification type.
+ *
+ * @param entityClass the audited entity class
+ * @param transactionId the transaction identifier
+ * @param modificationType the modification type filter
+ * @param the entity type
+ *
+ * @return the matching entity snapshots
+ */
+ List findEntitiesModifiedAt(Class entityClass, Object transactionId, ModificationType modificationType);
+
+ /**
+ * Find all entity snapshots of the given type that
+ * were modified at a specific transaction, grouped
+ * by modification type (ADD, MOD, DEL).
+ *
+ * @param entityClass the audited entity class
+ * @param transactionId the transaction identifier
+ * @param the entity type
+ *
+ * @return entity snapshots grouped by modification type
+ */
+ Map> findEntitiesGroupedByModificationType(
+ Class entityClass,
+ Object transactionId);
+
+ /**
+ * Get the full audit history for an entity, ordered
+ * chronologically by transaction identifier.
+ *
+ * Each entry contains the entity snapshot, the transaction
+ * identifier (or revision entity), and the
+ * {@linkplain ModificationType modification type}
+ * (ADD/MOD/DEL).
+ *
+ * For DEL entries, the entity snapshot reflects the state
+ * at the moment of deletion.
+ *
+ * @param entityClass the audited entity class
+ * @param id the entity identifier
+ * @param the entity type
+ *
+ * @return the audit history as a list of {@link AuditEntry}
+ */
+ List> getHistory(Class entityClass, Object id);
+
+ // --- Cross-type revision queries ---
+
+ /**
+ * Get the set of entity types that were modified at the
+ * given transaction.
+ *
+ * Requires a {@link RevisionEntity @RevisionEntity} with a
+ * {@link RevisionEntity.ModifiedEntities @ModifiedEntities} property
+ * (e.g. {@link DefaultTrackingModifiedEntitiesRevisionEntity}).
+ *
+ * @param transactionId the transaction identifier
+ *
+ * @return the set of entity classes modified at that transaction
+ *
+ * @throws AuditException if entity change tracking is not enabled
+ */
+ Set> getEntityTypesModifiedAt(Object transactionId);
+
+ /**
+ * Find all entity snapshots across all audited types that
+ * were modified at the given transaction.
+ *
+ * Requires a {@link RevisionEntity @RevisionEntity} with a
+ * {@link RevisionEntity.ModifiedEntities @ModifiedEntities} property
+ * (e.g. {@link DefaultTrackingModifiedEntitiesRevisionEntity}).
+ *
+ * @param transactionId the transaction identifier
+ *
+ * @return all entity snapshots modified at that transaction
+ *
+ * @throws AuditException if entity change tracking is not enabled
+ */
+ List findAllEntitiesModifiedAt(Object transactionId);
+
+ /**
+ * Find all entity snapshots across all audited types that
+ * were modified at the given transaction with the specified
+ * modification type.
+ *
+ * Requires a {@link RevisionEntity @RevisionEntity} with a
+ * {@link RevisionEntity.ModifiedEntities @ModifiedEntities} property
+ * (e.g. {@link DefaultTrackingModifiedEntitiesRevisionEntity}).
+ *
+ * @param transactionId the transaction identifier
+ * @param modificationType the modification type filter
+ *
+ * @return the matching entity snapshots
+ *
+ * @throws AuditException if entity change tracking is not enabled
+ */
+ List findAllEntitiesModifiedAt(Object transactionId, ModificationType modificationType);
+
+ /**
+ * Find all entity snapshots modified at the given transaction,
+ * grouped by modification type (ADD, MOD, DEL).
+ *
+ * Requires a {@link RevisionEntity @RevisionEntity} with a
+ * {@link RevisionEntity.ModifiedEntities @ModifiedEntities} property
+ * (e.g. {@link DefaultTrackingModifiedEntitiesRevisionEntity}).
+ *
+ * @param transactionId the transaction identifier
+ *
+ * @return entity snapshots grouped by modification type
+ *
+ * @throws AuditException if entity change tracking is not enabled
+ */
+ Map> findAllEntitiesGroupedByModificationType(Object transactionId);
+
+ /**
+ * Get the timestamp of a specific revision. Requires
+ * a {@link RevisionEntity @RevisionEntity} with a
+ * {@link RevisionEntity.Timestamp @Timestamp} field.
+ *
+ * @param transactionId the transaction identifier
+ *
+ * @return the revision timestamp
+ *
+ * @throws AuditException if no revision entity is configured
+ * or the transaction does not exist
+ */
+ Instant getTransactionTimestamp(Object transactionId);
+
+ /**
+ * Get the transaction identifier that was current at or
+ * before the given instant. Requires a
+ * {@link RevisionEntity @RevisionEntity} with a
+ * {@link RevisionEntity.Timestamp @Timestamp} field.
+ *
+ * @param instant the point in time
+ *
+ * @return the most recent transaction identifier at or
+ * before the given instant
+ *
+ * @throws AuditException if no transaction exists at or
+ * before the given instant
+ */
+ Object getTransactionId(Instant instant);
+
+ /**
+ * Load the revision entity for the given transaction identifier.
+ * Requires a {@link RevisionEntity @RevisionEntity}.
+ *
+ * @param transactionId the transaction identifier
+ * @param the revision entity type
+ *
+ * @return the revision entity
+ *
+ * @throws AuditException if no revision entity is configured
+ * or the revision does not exist
+ */
+ T findRevision(Object transactionId);
+
+ /**
+ * Load revision entities for multiple transaction identifiers.
+ * Requires a {@link RevisionEntity @RevisionEntity}.
+ *
+ * @param transactionIds the transaction identifiers
+ * @param the revision entity type
+ *
+ * @return a map from transaction identifier to revision entity
+ */
+ Map findRevisions(Set> transactionIds);
+
+}
diff --git a/hibernate-core/src/main/java/org/hibernate/audit/AuditLogFactory.java b/hibernate-core/src/main/java/org/hibernate/audit/AuditLogFactory.java
new file mode 100644
index 000000000000..3c91c256654b
--- /dev/null
+++ b/hibernate-core/src/main/java/org/hibernate/audit/AuditLogFactory.java
@@ -0,0 +1,110 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.audit;
+
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.EntityManagerFactory;
+
+import org.hibernate.Session;
+import org.hibernate.SessionFactory;
+import org.hibernate.SharedSessionContract;
+import org.hibernate.StatelessSession;
+import org.hibernate.audit.internal.AuditLogImpl;
+import org.hibernate.engine.spi.SessionFactoryImplementor;
+import org.hibernate.engine.spi.SharedSessionContractImplementor;
+
+/**
+ * Factory for obtaining {@link AuditLog} instances.
+ *
+ * Two creation modes are supported:
+ *
+ * Standalone (from {@link SessionFactory} or
+ * {@link EntityManagerFactory}): opens its own JDBC
+ * connection. Use when no session is available.
+ * Connection-sharing (from {@link Session},
+ * {@link StatelessSession}, or {@link EntityManager}):
+ * shares the session's JDBC connection. Use when a
+ * session is already open.
+ *
+ * The returned {@link AuditLog} is {@link AutoCloseable} and
+ * should be closed by the caller to release the internal session.
+ * For connection-sharing instances, the audit log is also
+ * automatically closed when the parent session closes.
+ *
+ * @author Marco Belladelli
+ * @see AuditLog
+ * @since 7.4
+ */
+public final class AuditLogFactory {
+
+ private AuditLogFactory() {
+ }
+
+ /**
+ * Create a standalone audit log.
+ *
+ * @param sessionFactory the session factory
+ * @return a new audit log (must be closed by the caller)
+ */
+ public static AuditLog create(SessionFactory sessionFactory) {
+ final var sf = (SessionFactoryImplementor) sessionFactory;
+ final var session = (SharedSessionContractImplementor)
+ sf.withOptions()
+ .atTransaction( AuditLog.ALL_REVISIONS )
+ .openSession();
+ return new AuditLogImpl( session );
+ }
+
+ /**
+ * Create a standalone audit log.
+ *
+ * @param entityManagerFactory the entity manager factory
+ * @return a new audit log (must be closed by the caller)
+ */
+ public static AuditLog create(EntityManagerFactory entityManagerFactory) {
+ return create( entityManagerFactory.unwrap( SessionFactory.class ) );
+ }
+
+ /**
+ * Create an audit log sharing the session's JDBC connection.
+ *
+ * @param session the session whose connection to share
+ * @return a new audit log (must be closed by the caller)
+ */
+ public static AuditLog create(Session session) {
+ return createFromSession( session );
+ }
+
+ /**
+ * Create an audit log sharing the stateless session's
+ * JDBC connection.
+ *
+ * @param session the stateless session whose connection to share
+ * @return a new audit log (must be closed by the caller)
+ */
+ public static AuditLog create(StatelessSession session) {
+ return createFromSession( session );
+ }
+
+ /**
+ * Create an audit log sharing the entity manager's
+ * JDBC connection.
+ *
+ * @param entityManager the entity manager whose connection to share
+ * @return a new audit log (must be closed by the caller)
+ */
+ public static AuditLog create(EntityManager entityManager) {
+ return createFromSession( entityManager.unwrap( Session.class ) );
+ }
+
+ private static AuditLog createFromSession(SharedSessionContract session) {
+ final var childSession = (SharedSessionContractImplementor)
+ session.sessionWithOptions()
+ .connection()
+ .atTransaction( AuditLog.ALL_REVISIONS )
+ .openSession();
+ return new AuditLogImpl( childSession );
+ }
+}
diff --git a/hibernate-core/src/main/java/org/hibernate/audit/DefaultRevisionEntity.java b/hibernate-core/src/main/java/org/hibernate/audit/DefaultRevisionEntity.java
new file mode 100644
index 000000000000..e7af8252fe4b
--- /dev/null
+++ b/hibernate-core/src/main/java/org/hibernate/audit/DefaultRevisionEntity.java
@@ -0,0 +1,36 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.audit;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+
+import org.hibernate.annotations.RevisionEntity;
+
+
+/**
+ * A built-in revision entity which maps to the {@code REVINFO} table.
+ *
+ * Maps to the {@code REVINFO} table with columns:
+ *
+ * {@code REV}: auto-generated integer primary key
+ * {@code REVTSTMP}: Unix epoch timestamp in milliseconds
+ *
+ *
+ * To use this entity, add it to the domain model of your application.
+ *
+ * For entity change tracking (cross-type revision queries),
+ * use {@link DefaultTrackingModifiedEntitiesRevisionEntity} instead.
+ *
+ * @author Marco Belladelli
+ * @see RevisionEntity
+ * @see DefaultTrackingModifiedEntitiesRevisionEntity
+ * @since 7.4
+ */
+@RevisionEntity
+@Entity(name = "DefaultRevisionEntity")
+@Table(name = "REVINFO")
+public final class DefaultRevisionEntity extends RevisionMapping {
+}
diff --git a/hibernate-core/src/main/java/org/hibernate/audit/DefaultTrackingModifiedEntitiesRevisionEntity.java b/hibernate-core/src/main/java/org/hibernate/audit/DefaultTrackingModifiedEntitiesRevisionEntity.java
new file mode 100644
index 000000000000..2d75f6f1478b
--- /dev/null
+++ b/hibernate-core/src/main/java/org/hibernate/audit/DefaultTrackingModifiedEntitiesRevisionEntity.java
@@ -0,0 +1,31 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.audit;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+
+import org.hibernate.annotations.RevisionEntity;
+
+
+/**
+ * A built-in revision entity with entity change tracking.
+ * Drop-in replacement for {@link DefaultRevisionEntity} that
+ * additionally creates a {@code REVCHANGES} table recording
+ * which entity types were modified in each revision.
+ *
+ * Use this entity instead of {@link DefaultRevisionEntity}
+ * when cross-type revision queries are needed.
+ *
+ * @author Marco Belladelli
+ * @see TrackingModifiedEntitiesRevisionMapping
+ * @see RevisionEntity.ModifiedEntities
+ * @since 7.4
+ */
+@RevisionEntity
+@Entity(name = "DefaultTrackingModifiedEntitiesRevisionEntity")
+@Table(name = "REVINFO")
+public final class DefaultTrackingModifiedEntitiesRevisionEntity extends TrackingModifiedEntitiesRevisionMapping {
+}
diff --git a/hibernate-core/src/main/java/org/hibernate/audit/EntityTrackingRevisionListener.java b/hibernate-core/src/main/java/org/hibernate/audit/EntityTrackingRevisionListener.java
new file mode 100644
index 000000000000..d3e0ba27239a
--- /dev/null
+++ b/hibernate-core/src/main/java/org/hibernate/audit/EntityTrackingRevisionListener.java
@@ -0,0 +1,33 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.audit;
+
+
+/**
+ * A callback invoked for each entity change within a transaction,
+ * in addition to the {@link RevisionListener#newRevision} callback
+ * invoked once when the revision entity is created.
+ *
+ * @author Marco Belladelli
+ * @since 7.4
+ */
+public interface EntityTrackingRevisionListener extends RevisionListener {
+ /**
+ * Called for each entity change within a transaction, after
+ * audit rows are written. The revision entity has already
+ * been persisted at this point.
+ *
+ * @param entityClass the entity class
+ * @param entityId the entity identifier
+ * @param modificationType the type of change (ADD, MOD, DEL)
+ * @param revisionEntity the revision entity instance, or
+ * {@code null} if no revision entity is configured
+ */
+ void entityChanged(
+ Class> entityClass,
+ Object entityId,
+ ModificationType modificationType,
+ Object revisionEntity);
+}
diff --git a/hibernate-core/src/main/java/org/hibernate/audit/ModificationType.java b/hibernate-core/src/main/java/org/hibernate/audit/ModificationType.java
new file mode 100644
index 000000000000..beb3e39a23cc
--- /dev/null
+++ b/hibernate-core/src/main/java/org/hibernate/audit/ModificationType.java
@@ -0,0 +1,29 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.audit;
+
+
+/**
+ * The type of modification recorded in the
+ * {@linkplain org.hibernate.annotations.Audited.Table#modificationTypeColumn
+ * modification type column} of the audit log.
+ *
+ * @author Marco Belladelli
+ * @since 7.4
+ */
+public enum ModificationType {
+ /**
+ * Creation, encoded as 0
+ */
+ ADD,
+ /**
+ * Modification, encoded as 1
+ */
+ MOD,
+ /**
+ * Deletion, encoded as 2
+ */
+ DEL
+}
diff --git a/hibernate-core/src/main/java/org/hibernate/audit/RevisionListener.java b/hibernate-core/src/main/java/org/hibernate/audit/RevisionListener.java
new file mode 100644
index 000000000000..4ce6daf18dd5
--- /dev/null
+++ b/hibernate-core/src/main/java/org/hibernate/audit/RevisionListener.java
@@ -0,0 +1,25 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.audit;
+
+
+/**
+ * A callback invoked when a new revision entity is created,
+ * allowing the application to populate custom fields such as
+ * the current user or a comment.
+ *
+ * @author Marco Belladelli
+ * @since 7.4
+ */
+public interface RevisionListener {
+ /**
+ * Called when a new revision entity is created, before it
+ * is persisted. The implementation should set any custom
+ * properties on the revision entity.
+ *
+ * @param revisionEntity the revision entity instance
+ */
+ void newRevision(Object revisionEntity);
+}
diff --git a/hibernate-core/src/main/java/org/hibernate/audit/RevisionMapping.java b/hibernate-core/src/main/java/org/hibernate/audit/RevisionMapping.java
new file mode 100644
index 000000000000..2381543c3059
--- /dev/null
+++ b/hibernate-core/src/main/java/org/hibernate/audit/RevisionMapping.java
@@ -0,0 +1,93 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.audit;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.Id;
+import jakarta.persistence.MappedSuperclass;
+import jakarta.persistence.Transient;
+import org.hibernate.annotations.CreationTimestamp;
+import org.hibernate.annotations.RevisionEntity;
+
+import java.io.Serializable;
+import java.time.Instant;
+
+/**
+ * Base {@link MappedSuperclass @MappedSuperclass} for revision
+ * entities, providing the standard {@code REV} (auto-generated
+ * integer primary key) and {@code REVTSTMP} (Unix epoch
+ * timestamp) columns. The timestamp is initialized automatically
+ * via {@link CreationTimestamp}.
+ *
+ * Extend this class to create a custom
+ * {@link RevisionEntity @RevisionEntity}. For entity change
+ * tracking, extend {@link TrackingModifiedEntitiesRevisionMapping}
+ * instead.
+ *
+ * @author Marco Belladelli
+ * @see DefaultRevisionEntity
+ * @see TrackingModifiedEntitiesRevisionMapping
+ * @since 7.4
+ */
+@MappedSuperclass
+public class RevisionMapping implements Serializable {
+ @Id
+ @GeneratedValue
+ @RevisionEntity.TransactionId
+ @Column(name = "REV")
+ private long id;
+
+ @CreationTimestamp
+ @RevisionEntity.Timestamp
+ @Column(name = "REVTSTMP")
+ private long timestamp;
+
+ public long getId() {
+ return id;
+ }
+
+ public void setId(long id) {
+ this.id = id;
+ }
+
+ @Transient
+ public Instant getRevisionInstant() {
+ return Instant.ofEpochMilli( timestamp );
+ }
+
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ public void setTimestamp(long timestamp) {
+ this.timestamp = timestamp;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if ( this == o ) {
+ return true;
+ }
+ if ( !(o instanceof RevisionMapping that) ) {
+ return false;
+ }
+ return id == that.id
+ && timestamp == that.timestamp;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = Long.hashCode( id );
+ result = 31 * result + Long.hashCode( timestamp );
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ return "RevisionMapping(id = " + id
+ + ", timestamp = " + getRevisionInstant() + ")";
+ }
+}
diff --git a/hibernate-core/src/main/java/org/hibernate/audit/TrackingModifiedEntitiesRevisionMapping.java b/hibernate-core/src/main/java/org/hibernate/audit/TrackingModifiedEntitiesRevisionMapping.java
new file mode 100644
index 000000000000..5505d17de263
--- /dev/null
+++ b/hibernate-core/src/main/java/org/hibernate/audit/TrackingModifiedEntitiesRevisionMapping.java
@@ -0,0 +1,85 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.audit;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.ElementCollection;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.JoinTable;
+import jakarta.persistence.MappedSuperclass;
+import org.hibernate.annotations.Fetch;
+import org.hibernate.annotations.FetchMode;
+import org.hibernate.annotations.RevisionEntity;
+
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Extension of {@link RevisionMapping} that tracks which
+ * entity types were modified in each revision. The entity names
+ * are stored in a {@code REVCHANGES} table as an
+ * {@link ElementCollection @ElementCollection}.
+ *
+ * When a revision entity extends this class (or has a property
+ * annotated with {@link RevisionEntity.ModifiedEntities @ModifiedEntities}),
+ * cross-type revision queries are automatically enabled via
+ * {@link AuditLog#getEntityTypesModifiedAt},
+ * {@link AuditLog#findAllEntitiesModifiedAt}, and
+ * {@link AuditLog#findAllEntitiesGroupedByModificationType}.
+ *
+ * Extend this class to create a custom tracking revision entity,
+ * or use the ready-made {@link DefaultTrackingModifiedEntitiesRevisionEntity}.
+ *
+ * @author Marco Belladelli
+ * @see DefaultTrackingModifiedEntitiesRevisionEntity
+ * @see RevisionEntity.ModifiedEntities
+ * @since 7.4
+ */
+@MappedSuperclass
+public class TrackingModifiedEntitiesRevisionMapping extends RevisionMapping {
+ @ElementCollection(fetch = FetchType.EAGER)
+ @JoinTable(name = "REVCHANGES", joinColumns = @JoinColumn(name = "REV"))
+ @Column(name = "ENTITYNAME")
+ @Fetch(FetchMode.JOIN)
+ @RevisionEntity.ModifiedEntities
+ private Set modifiedEntityNames = new HashSet<>();
+
+ public Set getModifiedEntityNames() {
+ return modifiedEntityNames;
+ }
+
+ public void setModifiedEntityNames(Set modifiedEntityNames) {
+ this.modifiedEntityNames = modifiedEntityNames;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if ( this == o ) {
+ return true;
+ }
+ if ( !(o instanceof TrackingModifiedEntitiesRevisionMapping that) ) {
+ return false;
+ }
+ if ( !super.equals( o ) ) {
+ return false;
+ }
+ return Objects.equals( modifiedEntityNames, that.modifiedEntityNames );
+ }
+
+ @Override
+ public int hashCode() {
+ int result = super.hashCode();
+ result = 31 * result + (modifiedEntityNames != null ? modifiedEntityNames.hashCode() : 0);
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ return "TrackingModifiedEntitiesRevisionMapping(" + super.toString()
+ + ", modifiedEntityNames = " + modifiedEntityNames + ")";
+ }
+}
diff --git a/hibernate-core/src/main/java/org/hibernate/audit/internal/AuditColumnFunction.java b/hibernate-core/src/main/java/org/hibernate/audit/internal/AuditColumnFunction.java
new file mode 100644
index 000000000000..832e038c1016
--- /dev/null
+++ b/hibernate-core/src/main/java/org/hibernate/audit/internal/AuditColumnFunction.java
@@ -0,0 +1,202 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.audit.internal;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.hibernate.audit.ModificationType;
+import org.hibernate.metamodel.mapping.EntityMappingType;
+import org.hibernate.metamodel.mapping.SelectableMapping;
+import org.hibernate.metamodel.model.domain.ReturnableType;
+import org.hibernate.query.spi.QueryEngine;
+import org.hibernate.query.sqm.NodeBuilder;
+import org.hibernate.query.sqm.function.AbstractSqmFunctionDescriptor;
+import org.hibernate.query.sqm.function.FunctionRenderer;
+import org.hibernate.query.sqm.function.SelfRenderingFunctionSqlAstExpression;
+import org.hibernate.query.sqm.function.SelfRenderingSqmFunction;
+import org.hibernate.query.sqm.function.SqmFunctionDescriptor;
+import org.hibernate.query.sqm.produce.function.ArgumentsValidator;
+import org.hibernate.query.sqm.produce.function.FunctionReturnTypeResolver;
+import org.hibernate.query.sqm.produce.function.StandardArgumentsValidators;
+import org.hibernate.query.sqm.produce.function.StandardFunctionReturnTypeResolvers;
+import org.hibernate.query.sqm.sql.SqmToSqlAstConverter;
+import org.hibernate.query.sqm.tree.SqmCopyContext;
+import org.hibernate.query.sqm.tree.SqmTypedNode;
+import org.hibernate.query.sqm.tree.domain.SqmPath;
+import org.hibernate.sql.ast.tree.expression.ColumnReference;
+import org.hibernate.sql.ast.tree.expression.Expression;
+import org.hibernate.type.descriptor.java.spi.UnknownBasicJavaType;
+import org.hibernate.type.descriptor.jdbc.ObjectJdbcType;
+import org.hibernate.type.internal.BasicTypeImpl;
+import org.hibernate.type.spi.TypeConfiguration;
+
+import static java.util.Collections.singletonList;
+
+/**
+ * HQL function for accessing audit columns of temporal entities.
+ *
+ * {@code transactionId(e)}: returns the transaction identifier
+ * {@code modificationType(e)}: returns the modification type
+ *
+ * These functions are only valid for
+ * {@linkplain org.hibernate.annotations.Audited audited} entities
+ * queried in
+ * {@linkplain org.hibernate.audit.AuditLog#ALL_REVISIONS all-revisions}
+ * mode.
+ *
+ * @author Marco Belladelli
+ * @since 7.4
+ */
+public class AuditColumnFunction extends AbstractSqmFunctionDescriptor {
+
+ public static final String TRANSACTION_ID_FUNCTION = "transactionId";
+ public static final String MODIFICATION_TYPE_FUNCTION = "modificationType";
+
+ private static final FunctionRenderer PASSTHROUGH_RENDERER =
+ (sqlAppender, sqlAstArguments, returnType, walker)
+ -> sqlAstArguments.get( 0 ).accept( walker );
+
+ private final boolean transactionId;
+
+ public AuditColumnFunction(String name, boolean transactionId, TypeConfiguration typeConfiguration) {
+ super(
+ name,
+ StandardArgumentsValidators.exactly( 1 ),
+ transactionId
+ // transactionId: type is unknown at registration time, resolved at SQL AST conversion
+ ? StandardFunctionReturnTypeResolvers.invariant(
+ new BasicTypeImpl<>( new UnknownBasicJavaType<>( Object.class ), ObjectJdbcType.INSTANCE )
+ )
+ // modificationType: proper enum type
+ : StandardFunctionReturnTypeResolvers.invariant(
+ typeConfiguration.standardBasicTypeForJavaType( ModificationType.class )
+ ),
+ null
+ );
+ this.transactionId = transactionId;
+ }
+
+ @Override
+ protected SelfRenderingSqmFunction generateSqmFunctionExpression(
+ List extends SqmTypedNode>> arguments,
+ ReturnableType impliedResultType,
+ QueryEngine queryEngine) {
+ return new AuditColumnSqmFunction<>(
+ this,
+ transactionId,
+ arguments,
+ impliedResultType,
+ queryEngine
+ );
+ }
+
+ private static class AuditColumnSqmFunction extends SelfRenderingSqmFunction {
+
+ private final boolean transactionId;
+
+ AuditColumnSqmFunction(
+ AuditColumnFunction descriptor,
+ boolean transactionId,
+ List extends SqmTypedNode>> arguments,
+ ReturnableType impliedResultType,
+ QueryEngine queryEngine) {
+ super(
+ descriptor,
+ PASSTHROUGH_RENDERER,
+ arguments,
+ impliedResultType,
+ descriptor.getArgumentsValidator(),
+ descriptor.getReturnTypeResolver(),
+ queryEngine.getCriteriaBuilder(),
+ descriptor.getName()
+ );
+ this.transactionId = transactionId;
+ }
+
+ private AuditColumnSqmFunction(
+ SqmFunctionDescriptor descriptor,
+ FunctionRenderer renderer,
+ boolean transactionId,
+ List extends SqmTypedNode>> arguments,
+ ReturnableType impliedResultType,
+ ArgumentsValidator argumentsValidator,
+ FunctionReturnTypeResolver returnTypeResolver,
+ NodeBuilder nodeBuilder,
+ String name) {
+ super(
+ descriptor, renderer, arguments, impliedResultType,
+ argumentsValidator, returnTypeResolver, nodeBuilder, name
+ );
+ this.transactionId = transactionId;
+ }
+
+ @Override
+ public AuditColumnSqmFunction copy(SqmCopyContext context) {
+ final var existing = context.getCopy( this );
+ if ( existing != null ) {
+ return existing;
+ }
+ final var arguments = new ArrayList>( getArguments().size() );
+ for ( var argument : getArguments() ) {
+ arguments.add( argument.copy( context ) );
+ }
+ return context.registerCopy(
+ this,
+ new AuditColumnSqmFunction<>(
+ getFunctionDescriptor(),
+ getFunctionRenderer(),
+ transactionId,
+ arguments,
+ getImpliedResultType(),
+ getArgumentsValidator(),
+ getReturnTypeResolver(),
+ nodeBuilder(),
+ getFunctionName()
+ )
+ );
+ }
+
+ @Override
+ public Expression convertToSqlAst(SqmToSqlAstConverter walker) {
+ final var entityPath = (SqmPath>) getArguments().get( 0 );
+
+ final var tableGroup = walker.getFromClauseAccess()
+ .findTableGroup( entityPath.getNavigablePath() );
+
+ final var entityMapping = (EntityMappingType) tableGroup.getModelPart();
+ final var auditMapping = entityMapping.getAuditMapping();
+ if ( auditMapping == null ) {
+ throw new IllegalArgumentException(
+ "Entity '" + entityMapping.getEntityName()
+ + "' is not audited"
+ );
+ }
+
+ // modificationType lives on the root (identifier) table, not subclass tables
+ final String originalTable = transactionId
+ ? entityMapping.getMappedTableDetails().getTableName()
+ : entityMapping.getIdentifierTableDetails().getTableName();
+ final SelectableMapping selectableMapping = transactionId
+ ? auditMapping.getTransactionIdMapping( originalTable )
+ : auditMapping.getModificationTypeMapping( originalTable );
+
+ final var tableReference = tableGroup.resolveTableReference(
+ entityPath.getNavigablePath(),
+ originalTable
+ );
+
+ final var columnReference =
+ new ColumnReference( tableReference, selectableMapping );
+ return new SelfRenderingFunctionSqlAstExpression<>(
+ getFunctionName(),
+ getFunctionRenderer(),
+ singletonList( columnReference ),
+ null,
+ selectableMapping.getJdbcMapping()
+ );
+ }
+ }
+}
diff --git a/hibernate-core/src/main/java/org/hibernate/audit/internal/AuditEntityLoaderImpl.java b/hibernate-core/src/main/java/org/hibernate/audit/internal/AuditEntityLoaderImpl.java
new file mode 100644
index 000000000000..169c5bf093b7
--- /dev/null
+++ b/hibernate-core/src/main/java/org/hibernate/audit/internal/AuditEntityLoaderImpl.java
@@ -0,0 +1,186 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.audit.internal;
+
+import org.hibernate.LockOptions;
+import org.hibernate.audit.AuditLog;
+import org.hibernate.audit.ModificationType;
+import org.hibernate.audit.spi.AuditEntityLoader;
+import org.hibernate.engine.spi.LoadQueryInfluencers;
+import org.hibernate.engine.spi.SessionFactoryImplementor;
+import org.hibernate.engine.spi.SharedSessionContractImplementor;
+import org.hibernate.loader.ast.internal.LoaderSelectBuilder;
+import org.hibernate.metamodel.mapping.EntityMappingType;
+import org.hibernate.metamodel.mapping.SelectableMapping;
+import org.hibernate.query.spi.QueryOptions;
+import org.hibernate.sql.ast.spi.SqlAliasBaseManager;
+import org.hibernate.sql.ast.tree.expression.ColumnReference;
+import org.hibernate.sql.ast.tree.expression.JdbcLiteral;
+import org.hibernate.sql.ast.tree.predicate.ComparisonPredicate;
+import org.hibernate.sql.ast.tree.select.SelectStatement;
+import org.hibernate.sql.exec.internal.BaseExecutionContext;
+import org.hibernate.sql.exec.internal.CallbackImpl;
+import org.hibernate.sql.exec.internal.JdbcParameterBindingImpl;
+import org.hibernate.sql.exec.internal.JdbcParameterBindingsImpl;
+import org.hibernate.sql.exec.internal.SqlTypedMappingJdbcParameter;
+import org.hibernate.sql.exec.spi.Callback;
+import org.hibernate.sql.exec.spi.JdbcParametersList;
+import org.hibernate.sql.exec.spi.JdbcSelect;
+import org.hibernate.sql.results.internal.RowTransformerStandardImpl;
+import org.hibernate.sql.results.spi.ListResultsConsumer;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.hibernate.query.sqm.ComparisonOperator.NOT_EQUAL;
+
+/**
+ * Pre-built SQL AST load plan for {@code AuditLog.find()}.
+ *
+ * Two {@link JdbcSelect} variants are built at construction time:
+ * one excluding deletions ({@code REVTYPE <> DEL}), one including all.
+ */
+public class AuditEntityLoaderImpl implements AuditEntityLoader {
+ private final EntityMappingType entityMappingType;
+ private final SelectableMapping revMapping;
+ private final JdbcParametersList jdbcParams;
+ private final JdbcSelect excludingDeletions;
+ private final JdbcSelect includingDeletions;
+
+ public AuditEntityLoaderImpl(EntityMappingType entityMappingType, SessionFactoryImplementor sessionFactory) {
+ this.entityMappingType = entityMappingType;
+
+ final var auditMapping = entityMappingType.getAuditMapping();
+ final var revTableName = entityMappingType.getMappedTableDetails().getTableName();
+ final var revTypeTableName = entityMappingType.getIdentifierTableDetails().getTableName();
+ this.revMapping = auditMapping.getTransactionIdMapping( revTableName );
+ final var revTypeMapping = auditMapping.getModificationTypeMapping( revTypeTableName );
+
+ // Build SQL AST: SELECT ... WHERE id = ? AND REV = (SELECT MAX(REV) ... WHERE REV <= ?)
+ // Uses ALL_REVISIONS so LoaderSelectBuilder doesn't add its own temporal restriction,
+ // then applies the audit mapping's restriction with our own JDBC parameter.
+ final var influencers = new LoadQueryInfluencers( sessionFactory );
+ influencers.setTemporalIdentifier( AuditLog.ALL_REVISIONS );
+ final var paramsBuilder = JdbcParametersList.newBuilder();
+ final var sqlAliasBaseManager = new SqlAliasBaseManager();
+ final var sqlAst = LoaderSelectBuilder.createSelect(
+ entityMappingType,
+ null,
+ entityMappingType.getIdentifierMapping(),
+ null,
+ 1,
+ influencers,
+ LockOptions.NONE,
+ paramsBuilder::add,
+ sqlAliasBaseManager,
+ sessionFactory
+ );
+ final var querySpec = sqlAst.getQueryPart().getFirstQuerySpec();
+ final var rootTableGroup = querySpec.getFromClause().getRoots().get( 0 );
+ final var navPath = rootTableGroup.getNavigablePath();
+ final var revTableRef = rootTableGroup.resolveTableReference( navPath, revTableName );
+
+ // Build MAX(REV) WHERE REV <= ? restriction without REVTYPE filter
+ final var revParam = new SqlTypedMappingJdbcParameter( revMapping );
+ paramsBuilder.add( revParam );
+ final var keySelectables = new ArrayList();
+ entityMappingType.getIdentifierMapping()
+ .forEachSelectable( (index, mapping) -> keySelectables.add( mapping ) );
+ querySpec.applyPredicate( auditMapping.createRestriction(
+ entityMappingType,
+ revTableRef,
+ keySelectables,
+ sqlAliasBaseManager,
+ revTableName,
+ revParam,
+ true // includeDeletions: no REVTYPE filter
+ ) );
+
+ // Translate once: including deletions (no REVTYPE filter)
+ this.jdbcParams = paramsBuilder.build();
+ this.includingDeletions = translate( sqlAst, sessionFactory );
+
+ // Add REVTYPE <> DEL predicate and translate again: excluding deletions
+ // (REVTYPE may be null for JOINED subclass tables where it only exists on root)
+ if ( revTypeMapping != null ) {
+ final var revTypeTableRef = rootTableGroup.resolveTableReference( navPath, revTypeTableName );
+ querySpec.applyPredicate( new ComparisonPredicate(
+ new ColumnReference( revTypeTableRef, revTypeMapping ),
+ NOT_EQUAL,
+ new JdbcLiteral<>( ModificationType.DEL, revTypeMapping.getJdbcMapping() )
+ ) );
+ }
+ this.excludingDeletions = translate( sqlAst, sessionFactory );
+ }
+
+ @Override
+ public T find(
+ Object id,
+ Object transactionId,
+ boolean includeDeletions,
+ SharedSessionContractImplementor session) {
+ final var select = includeDeletions ? includingDeletions : excludingDeletions;
+ return execute( select, id, transactionId, session );
+ }
+
+ private static JdbcSelect translate(SelectStatement sqlAst, SessionFactoryImplementor sessionFactory) {
+ return sessionFactory.getJdbcServices().getJdbcEnvironment()
+ .getSqlAstTranslatorFactory()
+ .buildSelectTranslator( sessionFactory, sqlAst )
+ .translate( null, QueryOptions.NONE );
+ }
+
+ // --- execution ---
+
+ private T execute(
+ JdbcSelect select,
+ Object id,
+ Object transactionId,
+ SharedSessionContractImplementor session) {
+ final var bindings = new JdbcParameterBindingsImpl( jdbcParams.size() );
+ int offset = bindings.registerParametersForEachJdbcValue(
+ id, 0,
+ entityMappingType.getIdentifierMapping(),
+ jdbcParams, session
+ );
+ bindings.addBinding(
+ jdbcParams.get( offset ),
+ new JdbcParameterBindingImpl( revMapping.getJdbcMapping(), transactionId )
+ );
+
+ final var callback = new CallbackImpl();
+ final List list = session.getJdbcServices().getJdbcSelectExecutor().list(
+ select,
+ bindings,
+ new BaseExecutionContext( session ) {
+ @Override
+ public Object getEntityId() {
+ return id;
+ }
+
+ @Override
+ public EntityMappingType getRootEntityDescriptor() {
+ return entityMappingType.getRootEntityDescriptor();
+ }
+
+ @Override
+ public Callback getCallback() {
+ return callback;
+ }
+ },
+ RowTransformerStandardImpl.instance(),
+ null,
+ ListResultsConsumer.UniqueSemantic.FILTER,
+ 1
+ );
+
+ if ( list.isEmpty() ) {
+ return null;
+ }
+ final T entity = list.get( 0 );
+ callback.invokeAfterLoadActions( entity, entityMappingType, session );
+ return entity;
+ }
+}
diff --git a/hibernate-core/src/main/java/org/hibernate/audit/internal/AuditLogImpl.java b/hibernate-core/src/main/java/org/hibernate/audit/internal/AuditLogImpl.java
new file mode 100644
index 000000000000..375607e9a558
--- /dev/null
+++ b/hibernate-core/src/main/java/org/hibernate/audit/internal/AuditLogImpl.java
@@ -0,0 +1,472 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.audit.internal;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+import org.hibernate.audit.AuditEntry;
+import org.hibernate.audit.AuditException;
+import org.hibernate.audit.AuditLog;
+import org.hibernate.audit.ModificationType;
+import org.hibernate.audit.spi.RevisionEntitySupplier;
+import org.hibernate.engine.spi.SessionFactoryImplementor;
+import org.hibernate.engine.spi.SharedSessionContractImplementor;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * Session-scoped implementation of {@link AuditLog} that queries
+ * audit tables using HQL with {@code transactionId()} and
+ * {@code modificationType()} functions.
+ *
+ * Obtained via {@link org.hibernate.audit.AuditLogFactory#create}.
+ * Uses an internal session with {@code ALL_REVISIONS} temporal
+ * context for querying audit tables.
+ *
+ * @author Marco Belladelli
+ * @since 7.4
+ */
+public class AuditLogImpl implements AuditLog {
+ private final SessionFactoryImplementor sessionFactory;
+ private final SharedSessionContractImplementor auditSession;
+ private final @Nullable RevisionEntitySupplier> revisionEntitySupplier;
+ private final @Nullable String revisionEntityName;
+ private final @Nullable String transactionIdProperty;
+ private final @Nullable String timestampProperty;
+ private final @Nullable String modifiedEntitiesProperty;
+ private final @Nullable Class> timestampFieldType;
+
+ /**
+ * @param auditSession a session configured with {@link AuditLog#ALL_REVISIONS}
+ * temporal context for reading audit tables
+ */
+ public AuditLogImpl(SharedSessionContractImplementor auditSession) {
+ this.auditSession = auditSession;
+ this.sessionFactory = auditSession.getSessionFactory();
+ final var supplier = RevisionEntitySupplier.resolve( sessionFactory.getServiceRegistry() );
+ if ( supplier != null ) {
+ this.revisionEntitySupplier = supplier;
+ this.revisionEntityName = sessionFactory.getMappingMetamodel()
+ .getEntityDescriptor( supplier.getRevisionEntityClass() )
+ .getEntityName();
+ this.transactionIdProperty = supplier.getTransactionIdProperty();
+ this.timestampProperty = supplier.getTimestampProperty();
+ this.modifiedEntitiesProperty = supplier.getModifiedEntitiesProperty();
+ this.timestampFieldType = sessionFactory.getMappingMetamodel()
+ .getEntityDescriptor( supplier.getRevisionEntityClass() )
+ .findAttributeMapping( supplier.getTimestampProperty() )
+ .getJavaType().getJavaTypeClass();
+ }
+ else {
+ this.modifiedEntitiesProperty = null;
+ this.revisionEntitySupplier = null;
+ this.revisionEntityName = null;
+ this.transactionIdProperty = null;
+ this.timestampProperty = null;
+ this.timestampFieldType = null;
+ }
+ }
+
+ @Override
+ public List getRevisions(Class> entityClass, Object id) {
+ requireNonNull( entityClass, "Entity class" );
+ requireNonNull( id, "Primary key" );
+ final var entityName = requireAuditedEntityName( entityClass );
+ return auditSession.createSelectionQuery(
+ "select transactionId(e) from " + entityName + " e"
+ + " where e.id = :id"
+ + " order by transactionId(e)",
+ Object.class
+ ).setParameter( "id", id ).getResultList();
+ }
+
+ @Override
+ public ModificationType getModificationType(Class> entityClass, Object id, Object transactionId) {
+ requireNonNull( entityClass, "Entity class" );
+ requireNonNull( id, "Primary key" );
+ requireNonNull( transactionId, "Transaction identifier" );
+ final var entityName = requireAuditedEntityName( entityClass );
+ return auditSession.createSelectionQuery(
+ "select modificationType(e) from " + entityName + " e"
+ + " where e.id = :id"
+ + " and transactionId(e) = :txId",
+ ModificationType.class
+ ).setParameter( "id", id )
+ .setParameter( "txId", transactionId )
+ .getSingleResultOrNull();
+ }
+
+ @Override
+ public boolean isAudited(Class> entityClass) {
+ requireNonNull( entityClass, "Entity class" );
+ return sessionFactory.getMappingMetamodel()
+ .getEntityDescriptor( entityClass )
+ .getAuditMapping() != null;
+ }
+
+ @Override
+ public T find(Class entityClass, Object id, Object transactionId) {
+ return find( entityClass, id, transactionId, false );
+ }
+
+ @Override
+ public T find(Class entityClass, Object id, Object transactionId, boolean includeDeletions) {
+ requireNonNull( entityClass, "Entity class" );
+ return doFind( requireAuditedEntityName( entityClass ), id, transactionId, includeDeletions );
+ }
+
+ private T doFind(String entityName, Object id, Object transactionId, boolean includeDeletions) {
+ requireNonNull( id, "Primary key" );
+ requireNonNull( transactionId, "Transaction identifier" );
+ final var persister = sessionFactory.getMappingMetamodel().getEntityDescriptor( entityName );
+ return persister.getAuditMapping()
+ .getEntityLoader()
+ .find( id, transactionId, includeDeletions, auditSession );
+ }
+
+ @Override
+ public T find(Class entityClass, Object id, Instant instant) {
+ return find( entityClass, id, getTransactionId( instant ) );
+ }
+
+ @Override
+ public List findEntitiesModifiedAt(Class entityClass, Object transactionId) {
+ requireNonNull( entityClass, "Entity class" );
+ requireNonNull( transactionId, "Transaction identifier" );
+ return doFindEntitiesModifiedAt(
+ requireAuditedEntityName( entityClass ),
+ transactionId,
+ null,
+ entityClass
+ );
+ }
+
+ @Override
+ public List findEntitiesModifiedAt(
+ Class entityClass,
+ Object transactionId,
+ ModificationType modificationType) {
+ requireNonNull( entityClass, "Entity class" );
+ requireNonNull( transactionId, "Transaction identifier" );
+ requireNonNull( modificationType, "Modification type" );
+ return doFindEntitiesModifiedAt(
+ requireAuditedEntityName( entityClass ),
+ transactionId,
+ modificationType,
+ entityClass
+ );
+ }
+
+ @Override
+ public Map> findEntitiesGroupedByModificationType(
+ Class entityClass,
+ Object transactionId) {
+ requireNonNull( entityClass, "Entity class" );
+ requireNonNull( transactionId, "Transaction identifier" );
+ return doFindEntitiesGroupedByModificationType(
+ requireAuditedEntityName( entityClass ),
+ transactionId,
+ entityClass
+ );
+ }
+
+ private List doFindEntitiesModifiedAt(
+ String entityName,
+ Object transactionId,
+ @Nullable ModificationType modificationType,
+ Class resultType) {
+ var hql = "from " + entityName + " e where transactionId(e) = :txId";
+ if ( modificationType != null ) {
+ hql += " and modificationType(e) = :modType";
+ }
+ final var query = auditSession
+ .createSelectionQuery( hql, resultType )
+ .setParameter( "txId", transactionId );
+ if ( modificationType != null ) {
+ query.setParameter( "modType", modificationType );
+ }
+ return query.getResultList();
+ }
+
+ @SuppressWarnings("unchecked")
+ private Map> doFindEntitiesGroupedByModificationType(
+ String entityName, Object transactionId, Class resultType) {
+ final var values = ModificationType.values();
+ final Map> result = new HashMap<>( values.length );
+ for ( ModificationType mt : values ) {
+ result.put( mt, new ArrayList<>() );
+ }
+ final List rows = auditSession.createSelectionQuery(
+ "select e, modificationType(e) from " + entityName + " e"
+ + " where transactionId(e) = :txId",
+ Object[].class
+ ).setParameter( "txId", transactionId ).getResultList();
+ for ( var row : rows ) {
+ result.get( (ModificationType) row[1] ).add( (T) row[0] );
+ }
+ return result;
+ }
+
+ @Override
+ public List> getHistory(Class entityClass, Object id) {
+ requireNonNull( entityClass, "Entity class" );
+ return doGetHistory( requireAuditedEntityName( entityClass ), id );
+ }
+
+ private List> doGetHistory(String entityName, Object id) {
+ requireNonNull( id, "Primary key" );
+
+ final String hql;
+ if ( revisionEntityName != null ) {
+ hql = "select e, r, modificationType(e)"
+ + " from " + entityName + " e"
+ + " join " + revisionEntityName + " r"
+ + " on r." + transactionIdProperty + " = transactionId(e)"
+ + " where e.id = :id"
+ + " order by transactionId(e)";
+ }
+ else {
+ hql = "select e, transactionId(e), modificationType(e)"
+ + " from " + entityName + " e"
+ + " where e.id = :id"
+ + " order by transactionId(e)";
+ }
+
+ final List rows = auditSession
+ .createSelectionQuery( hql, Object[].class )
+ .setParameter( "id", id ).getResultList();
+
+ final List> result = new ArrayList<>( rows.size() );
+ for ( var row : rows ) {
+ //noinspection unchecked
+ final var entity = (T) row[0];
+ result.add( new AuditEntry<>( entity, row[1], (ModificationType) row[2] ) );
+ }
+ return result;
+ }
+
+ // --- Cross-type revision queries ---
+
+ @Override
+ public Set> getEntityTypesModifiedAt(Object transactionId) {
+ requireNonNull( transactionId, "Transaction identifier" );
+ requireEntityChangeTracking();
+ final var entityNames = queryRevChangesEntityNames( transactionId );
+ final Set> result = new HashSet<>();
+ for ( String entityName : entityNames ) {
+ result.add( sessionFactory.getMappingMetamodel().getEntityDescriptor( entityName ).getMappedClass() );
+ }
+ return result;
+ }
+
+ @Override
+ public List findAllEntitiesModifiedAt(Object transactionId) {
+ requireNonNull( transactionId, "Transaction identifier" );
+ requireEntityChangeTracking();
+ final var entityNames = queryRevChangesEntityNames( transactionId );
+ final List result = new ArrayList<>();
+ for ( String entityName : entityNames ) {
+ result.addAll( doFindEntitiesModifiedAt( entityName, transactionId, null, Object.class ) );
+ }
+ return result;
+ }
+
+ @Override
+ public List findAllEntitiesModifiedAt(Object transactionId, ModificationType modificationType) {
+ requireNonNull( transactionId, "Transaction identifier" );
+ requireNonNull( modificationType, "Modification type" );
+ requireEntityChangeTracking();
+ final var entityNames = queryRevChangesEntityNames( transactionId );
+ final List result = new ArrayList<>();
+ for ( String entityName : entityNames ) {
+ result.addAll( doFindEntitiesModifiedAt( entityName, transactionId, modificationType, Object.class ) );
+ }
+ return result;
+ }
+
+ @Override
+ public Map> findAllEntitiesGroupedByModificationType(Object transactionId) {
+ requireNonNull( transactionId, "Transaction identifier" );
+ requireEntityChangeTracking();
+ final var values = ModificationType.values();
+ final Map> result = new HashMap<>( values.length );
+ for ( ModificationType mt : values ) {
+ result.put( mt, new ArrayList<>() );
+ }
+ for ( String entityName : queryRevChangesEntityNames( transactionId ) ) {
+ doFindEntitiesGroupedByModificationType( entityName, transactionId, Object.class )
+ .forEach( (mt, entities) -> result.get( mt ).addAll( entities ) );
+ }
+ return result;
+ }
+
+ private List queryRevChangesEntityNames(Object transactionId) {
+ return auditSession.createSelectionQuery(
+ "select element(r." + modifiedEntitiesProperty + ")"
+ + " from " + revisionEntityName + " r"
+ + " where r." + transactionIdProperty + " = :txId",
+ String.class
+ ).setParameter( "txId", transactionId ).getResultList();
+ }
+
+ private void requireEntityChangeTracking() {
+ if ( modifiedEntitiesProperty == null ) {
+ throw new AuditException(
+ "Entity change tracking is not enabled. "
+ + "Use a @RevisionEntity with a @RevisionEntity.ModifiedEntities property "
+ + "(e.g. DefaultTrackingModifiedEntitiesRevisionEntity)."
+ );
+ }
+ }
+
+ // --- Revision entity queries ---
+
+ @Override
+ public Instant getTransactionTimestamp(Object transactionId) {
+ requireNonNull( transactionId, "Transaction identifier" );
+ requireRevisionEntity();
+ final String hql = "select e." + timestampProperty
+ + " from " + revisionEntityName + " e"
+ + " where e." + transactionIdProperty + " = :rev";
+ final var result = auditSession
+ .createSelectionQuery( hql, Object.class )
+ .setParameter( "rev", transactionId )
+ .getSingleResultOrNull();
+ if ( result == null ) {
+ throw new AuditException( "Revision does not exist: " + transactionId );
+ }
+ return toInstant( result );
+ }
+
+ @Override
+ public Object getTransactionId(Instant instant) {
+ requireNonNull( instant, "Instant" );
+ return resolveTransactionIdForTimestamp( resolveTimestampValue( instant ) );
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public T findRevision(Object transactionId) {
+ requireRevisionEntity();
+ final var result = auditSession.createSelectionQuery(
+ "from " + revisionEntityName + " where " + transactionIdProperty + " = :rev",
+ Object.class
+ ).setParameter( "rev", transactionId ).getSingleResultOrNull();
+ if ( result == null ) {
+ throw new AuditException( "Revision does not exist: " + transactionId );
+ }
+ return (T) result;
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public Map findRevisions(Set> transactionIds) {
+ requireRevisionEntity();
+ final var results = auditSession.createSelectionQuery(
+ "select r." + transactionIdProperty + ", r"
+ + " from " + revisionEntityName + " r"
+ + " where r." + transactionIdProperty + " in :revs"
+ + " order by r." + transactionIdProperty,
+ Object[].class
+ ).setParameter( "revs", transactionIds ).getResultList();
+ final Map map = new LinkedHashMap<>();
+ for ( var row : results ) {
+ //noinspection unchecked
+ map.put( row[0], (T) row[1] );
+ }
+ return map;
+ }
+
+ // --- Helpers ---
+
+ private Object resolveTransactionIdForTimestamp(Object timestampValue) {
+ requireRevisionEntity();
+ final String hql = "select max(e." + transactionIdProperty + ")"
+ + " from " + revisionEntityName + " e"
+ + " where e." + timestampProperty + " <= :ts";
+ final var result = auditSession
+ .createSelectionQuery( hql, Object.class )
+ .setParameter( "ts", timestampValue )
+ .getSingleResultOrNull();
+ if ( result == null ) {
+ throw new AuditException( "No revision exists at or before the given date" );
+ }
+ return result;
+ }
+
+ /**
+ * Convert an {@link Instant} to match the revision entity's
+ * timestamp field type.
+ */
+ private Object resolveTimestampValue(Instant instant) {
+ if ( timestampFieldType == Date.class ) {
+ return Date.from( instant );
+ }
+ else if ( timestampFieldType == LocalDateTime.class ) {
+ return LocalDateTime.ofInstant( instant, ZoneId.systemDefault() );
+ }
+ else if ( timestampFieldType == Instant.class ) {
+ return instant;
+ }
+ else {
+ return instant.toEpochMilli();
+ }
+ }
+
+ private void requireRevisionEntity() {
+ if ( revisionEntitySupplier == null ) {
+ throw new AuditException(
+ "No @RevisionEntity configured. "
+ + "This operation requires a revision entity with "
+ + "@RevisionEntity.TransactionId and @RevisionEntity.Timestamp fields."
+ );
+ }
+ }
+
+ @Override
+ public void close() {
+ if ( auditSession.isOpen() ) {
+ auditSession.close();
+ }
+ }
+
+ private String requireAuditedEntityName(Class> entityClass) {
+ final var persister = sessionFactory.getMappingMetamodel().getEntityDescriptor( entityClass );
+ if ( persister.getAuditMapping() == null ) {
+ throw new IllegalArgumentException(
+ "Entity '" + persister.getEntityName() + "' is not audited"
+ );
+ }
+ return persister.getEntityName();
+ }
+
+ private static Instant toInstant(Object value) {
+ if ( value instanceof Instant instant ) {
+ return instant;
+ }
+ else if ( value instanceof LocalDateTime localDateTime ) {
+ return localDateTime.atZone( ZoneId.systemDefault() ).toInstant();
+ }
+ else if ( value instanceof Date date ) {
+ return date.toInstant();
+ }
+ else if ( value instanceof Long millis ) {
+ return Instant.ofEpochMilli( millis );
+ }
+ throw new AuditException( "Cannot convert revision timestamp to Instant: " + value );
+ }
+}
diff --git a/hibernate-core/src/main/java/org/hibernate/audit/package-info.java b/hibernate-core/src/main/java/org/hibernate/audit/package-info.java
new file mode 100644
index 000000000000..7cc1f12aeea9
--- /dev/null
+++ b/hibernate-core/src/main/java/org/hibernate/audit/package-info.java
@@ -0,0 +1,32 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+
+/**
+ * API for querying the history of
+ * {@linkplain org.hibernate.annotations.Audited audited} entities.
+ *
+ * Audited entities are transparently versioned: every insert, update,
+ * and delete is recorded in a companion audit table. Point-in-time
+ * reads are available via
+ * {@link org.hibernate.SessionBuilder#atTransaction(Object)
+ * atTransaction()} sessions, while the {@link org.hibernate.audit.AuditLog}
+ * interface provides programmatic access to revision history,
+ * modification types, and cross-entity change queries.
+ *
+ * This package also contains the base classes and contracts for
+ * defining custom
+ * {@linkplain org.hibernate.annotations.RevisionEntity revision
+ * entities} and {@linkplain org.hibernate.audit.RevisionListener
+ * revision listeners}.
+ *
+ * @see org.hibernate.annotations.Audited
+ * @see org.hibernate.annotations.RevisionEntity
+ * @see org.hibernate.audit.AuditLog
+ * @see org.hibernate.audit.AuditLogFactory
+ */
+@Incubating
+package org.hibernate.audit;
+
+import org.hibernate.Incubating;
diff --git a/hibernate-core/src/main/java/org/hibernate/audit/spi/AuditEntityLoader.java b/hibernate-core/src/main/java/org/hibernate/audit/spi/AuditEntityLoader.java
new file mode 100644
index 000000000000..28d6b9a76fd0
--- /dev/null
+++ b/hibernate-core/src/main/java/org/hibernate/audit/spi/AuditEntityLoader.java
@@ -0,0 +1,29 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.audit.spi;
+
+import org.hibernate.engine.spi.SharedSessionContractImplementor;
+
+/**
+ * Contract for loading entity snapshots from audit tables at a specific transaction
+ *
+ * @author Marco Belladelli
+ * @see org.hibernate.metamodel.mapping.AuditMapping#getEntityLoader
+ * @since 7.4
+ */
+public interface AuditEntityLoader {
+
+ /**
+ * Load an entity snapshot at the given transaction.
+ *
+ * @param id the entity identifier
+ * @param transactionId the transaction identifier
+ * @param includeDeletions whether to include DEL revisions
+ * @param session the session to use for loading
+ *
+ * @return the entity instance, or {@code null}
+ */
+ T find(Object id, Object transactionId, boolean includeDeletions, SharedSessionContractImplementor session);
+}
diff --git a/hibernate-core/src/main/java/org/hibernate/audit/spi/AuditWorkQueue.java b/hibernate-core/src/main/java/org/hibernate/audit/spi/AuditWorkQueue.java
new file mode 100644
index 000000000000..f788834e3382
--- /dev/null
+++ b/hibernate-core/src/main/java/org/hibernate/audit/spi/AuditWorkQueue.java
@@ -0,0 +1,289 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.audit.spi;
+
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+import org.hibernate.Session;
+import org.hibernate.annotations.RevisionEntity;
+import org.hibernate.audit.EntityTrackingRevisionListener;
+import org.hibernate.audit.ModificationType;
+import org.hibernate.collection.spi.PersistentCollection;
+import org.hibernate.engine.spi.CollectionKey;
+import org.hibernate.engine.spi.EntityKey;
+import org.hibernate.engine.spi.SharedSessionContractImplementor;
+import org.hibernate.engine.spi.TransactionCompletionCallbacks;
+import org.hibernate.persister.collection.CollectionPersister;
+
+import static org.hibernate.internal.util.NullnessUtil.castNonNull;
+
+/**
+ * Transaction-scoped queue for deferred audit row writes.
+ *
+ * Mutation coordinators enqueue audit entries instead of writing
+ * them inline during flush. Entries are keyed by
+ * {@link EntityKey}. When the same entity is modified
+ * multiple times within a transaction, entries are merged
+ * according to the following merge rules:
+ *
+ * ADD + MOD -> ADD (with latest state)
+ * ADD + DEL -> entries cancel out (no audit row)
+ * MOD + MOD -> MOD (with latest state)
+ * MOD + DEL -> DEL
+ * DEL + ADD -> MOD (entity re-created with potentially different state)
+ *
+ * All audit rows (INSERT + optional REVEND UPDATE) are written
+ * at {@code beforeTransactionCompletion}.
+ *
+ * @see AuditWriter
+ * @since 7.4
+ */
+public class AuditWorkQueue implements TransactionCompletionCallbacks.BeforeCompletionCallback {
+
+ /**
+ * A queued audit entry, holding the entity state and
+ * modification type, plus a writer callback.
+ */
+ private static class QueuedEntry {
+ Object entity;
+ Object[] values;
+ ModificationType modificationType;
+ final AuditWriter writer;
+
+ QueuedEntry(
+ Object entity, Object[] values,
+ ModificationType modificationType, AuditWriter writer) {
+ this.entity = entity;
+ this.values = values;
+ this.modificationType = modificationType;
+ this.writer = writer;
+ }
+ }
+
+ /**
+ * A queued collection audit entry, holding the original snapshot
+ * captured before the first flush.
+ */
+ private record QueuedCollectionEntry(
+ PersistentCollection> collection,
+ Object ownerId,
+ Object originalSnapshot,
+ CollectionAuditWriter writer) {
+ }
+
+ private final Map entries = new LinkedHashMap<>();
+ private final Map collectionEntries = new LinkedHashMap<>();
+ private EntityTrackingRevisionListener trackingListener;
+ private Object revisionEntity;
+ private @Nullable Session revisionSession;
+ private boolean registered;
+
+ /**
+ * Enqueue an audit entry for deferred writing. If an entry
+ * for the same entity already exists, the entries are merged.
+ *
+ * @param entityKey the entity key (reused from the persistence context)
+ * @param entity the entity instance (may be null for delete)
+ * @param values the entity state
+ * @param modificationType the modification type (ADD/MOD/DEL)
+ * @param writer callback to perform the actual write
+ * @param session the current session
+ */
+ public void enqueue(
+ EntityKey entityKey,
+ Object entity,
+ Object[] values,
+ ModificationType modificationType,
+ AuditWriter writer,
+ SharedSessionContractImplementor session) {
+ if ( !registered ) {
+ session.getTransactionCompletionCallbacks().registerCallback( this );
+ trackingListener = resolveTrackingListener( session );
+ registered = true;
+ }
+
+ final var existing = entries.get( entityKey );
+ if ( existing == null ) {
+ entries.put( entityKey, new QueuedEntry( entity, values, modificationType, writer ) );
+ }
+ else {
+ merge( entityKey, existing, entity, values, modificationType );
+ }
+ }
+
+ /**
+ * Enqueue a collection for deferred audit row writing.
+ * On first enqueue, the current snapshot is captured. Subsequent
+ * enqueues for the same collection are ignored; the diff will be
+ * computed at transaction completion against the original snapshot.
+ *
+ * @param collectionPersister the collection persister
+ * @param collection the persistent collection
+ * @param ownerId the owning entity's identifier
+ * @param originalSnapshot the collection snapshot before this flush
+ * @param writer callback to compute diff and write audit rows
+ * @param session the current session
+ */
+ public void enqueueCollection(
+ CollectionPersister collectionPersister,
+ PersistentCollection> collection,
+ Object ownerId,
+ Object originalSnapshot,
+ CollectionAuditWriter writer,
+ SharedSessionContractImplementor session) {
+ if ( !registered ) {
+ session.getTransactionCompletionCallbacks().registerCallback( this );
+ registered = true;
+ }
+
+ final var key = new CollectionKey( collectionPersister, ownerId );
+ // Only store the first snapshot; subsequent flushes are ignored,
+ // the diff at completion will use original vs final state
+ collectionEntries.putIfAbsent(
+ key,
+ new QueuedCollectionEntry( collection, ownerId, originalSnapshot, writer )
+ );
+ }
+
+ private void merge(
+ EntityKey key,
+ QueuedEntry existing,
+ Object entity,
+ Object[] newValues,
+ ModificationType incoming) {
+ final var merged = mergeModificationType( existing.modificationType, incoming );
+ if ( merged == null ) {
+ // ADD + DEL = cancel out, no entity audit row.
+ // Collection entries may remain (orphaned)
+ // and the orphaned rows are unreachable by any query.
+ entries.remove( key );
+ }
+ else {
+ existing.modificationType = merged;
+ existing.values = newValues;
+ if ( entity != null ) {
+ existing.entity = entity;
+ }
+ }
+ }
+
+ /**
+ * Merge two modification types according to the merge matrix.
+ *
+ * @return the merged type, or {@code null} if the entries cancel out
+ */
+ private static ModificationType mergeModificationType(
+ ModificationType existing,
+ ModificationType incoming) {
+ return switch ( existing ) {
+ case ADD -> switch ( incoming ) {
+ case ADD -> ModificationType.ADD;
+ case MOD -> ModificationType.ADD; // ADD + MOD -> ADD (with latest state)
+ case DEL -> null; // ADD + DEL -> cancel
+ };
+ case MOD -> switch ( incoming ) {
+ case ADD -> ModificationType.MOD;
+ case MOD -> ModificationType.MOD; // MOD + MOD -> MOD (with latest state)
+ case DEL -> ModificationType.DEL; // MOD + DEL -> DEL
+ };
+ case DEL -> switch ( incoming ) {
+ case ADD -> ModificationType.MOD; // DEL + ADD -> MOD (re-created)
+ case MOD, DEL -> ModificationType.DEL;
+ };
+ };
+ }
+
+ /**
+ * Store the revision entity and the child session used to
+ * persist it. The child session is kept open for deferred
+ * flush of {@code @ElementCollection} changes (e.g.
+ * {@link RevisionEntity.ModifiedEntities @ModifiedEntities}).
+ * Called from {@link RevisionEntitySupplier#generateTransactionIdentifier}.
+ */
+ public void setRevisionContext(Object revisionEntity, Session revisionSession) {
+ this.revisionEntity = revisionEntity;
+ this.revisionSession = revisionSession;
+ }
+
+ @Override
+ public void doBeforeTransactionCompletion(SharedSessionContractImplementor session) {
+ try {
+ // Entity audit rows first
+ for ( var mapEntry : entries.entrySet() ) {
+ final var entityKey = mapEntry.getKey();
+ final var entry = mapEntry.getValue();
+ entry.writer.writeAuditRow(
+ entityKey,
+ entry.entity,
+ entry.values,
+ entry.modificationType,
+ session
+ );
+ if ( trackingListener != null ) {
+ trackingListener.entityChanged(
+ entityKey.getPersister().getMappedClass(),
+ entityKey.getIdentifier(),
+ entry.modificationType,
+ revisionEntity
+ );
+ }
+ }
+ // Collection audit rows (diff original snapshot vs final state)
+ for ( var entry : collectionEntries.values() ) {
+ entry.writer.writeCollectionAuditRows(
+ entry.collection,
+ entry.ownerId,
+ entry.originalSnapshot,
+ session
+ );
+ }
+ // Populate @ModifiedEntities on the revision entity
+ populateModifiedEntityNames( session );
+ }
+ finally {
+ entries.clear();
+ collectionEntries.clear();
+ trackingListener = null;
+ revisionEntity = null;
+ revisionSession = null;
+ registered = false;
+ }
+ }
+
+ private void populateModifiedEntityNames(SharedSessionContractImplementor session) {
+ final var supplier = RevisionEntitySupplier.resolve( session.getFactory().getServiceRegistry() );
+ if ( supplier != null && supplier.getModifiedEntitiesProperty() != null ) {
+ final var persister = session.getEntityPersister(
+ supplier.getRevisionEntityClass().getName(),
+ revisionEntity
+ );
+ final var attr = persister.findAttributeMapping( supplier.getModifiedEntitiesProperty() );
+ //noinspection unchecked
+ var entityNames = (Set) persister.getValue( revisionEntity, attr.getStateArrayPosition() );
+ if ( entityNames == null ) {
+ entityNames = new HashSet<>();
+ persister.setValue( revisionEntity, attr.getStateArrayPosition(), entityNames );
+ }
+ for ( var entityKey : entries.keySet() ) {
+ entityNames.add( entityKey.getEntityName() );
+ }
+ castNonNull( revisionSession ).flush();
+ }
+ }
+
+ private static EntityTrackingRevisionListener resolveTrackingListener(
+ SharedSessionContractImplementor session) {
+ final var supplier = RevisionEntitySupplier.resolve( session.getFactory().getServiceRegistry() );
+ if ( supplier != null && supplier.getListener() instanceof EntityTrackingRevisionListener etrl ) {
+ return etrl;
+ }
+ return null;
+ }
+}
diff --git a/hibernate-core/src/main/java/org/hibernate/audit/spi/AuditWriter.java b/hibernate-core/src/main/java/org/hibernate/audit/spi/AuditWriter.java
new file mode 100644
index 000000000000..2135e4d206e1
--- /dev/null
+++ b/hibernate-core/src/main/java/org/hibernate/audit/spi/AuditWriter.java
@@ -0,0 +1,36 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.audit.spi;
+
+import org.hibernate.audit.ModificationType;
+import org.hibernate.engine.spi.EntityKey;
+import org.hibernate.engine.spi.SharedSessionContractImplementor;
+
+/**
+ * Contract for writing a single entity audit row to the
+ * audit table at transaction completion.
+ *
+ * @see AuditWorkQueue
+ * @since 7.4
+ */
+@FunctionalInterface
+public interface AuditWriter {
+ /**
+ * Write an audit row for the given entity state and modification type.
+ * Called by the {@link AuditWorkQueue} at transaction completion.
+ *
+ * @param entityKey the entity key
+ * @param entity the entity instance (may be null)
+ * @param values the entity state
+ * @param modificationType the modification type (ADD/MOD/DEL)
+ * @param session the current session
+ */
+ void writeAuditRow(
+ EntityKey entityKey,
+ Object entity,
+ Object[] values,
+ ModificationType modificationType,
+ SharedSessionContractImplementor session);
+}
diff --git a/hibernate-core/src/main/java/org/hibernate/audit/spi/CollectionAuditWriter.java b/hibernate-core/src/main/java/org/hibernate/audit/spi/CollectionAuditWriter.java
new file mode 100644
index 000000000000..9404adfaa223
--- /dev/null
+++ b/hibernate-core/src/main/java/org/hibernate/audit/spi/CollectionAuditWriter.java
@@ -0,0 +1,33 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.audit.spi;
+
+import org.hibernate.collection.spi.PersistentCollection;
+import org.hibernate.engine.spi.SharedSessionContractImplementor;
+
+/**
+ * Contract for writing collection audit rows to the audit
+ * table at transaction completion.
+ *
+ * @see AuditWorkQueue
+ * @since 7.4
+ */
+@FunctionalInterface
+public interface CollectionAuditWriter {
+ /**
+ * Write audit rows for the given collection.
+ *
+ * @param collection the persistent collection
+ * @param ownerId the owning entity's identifier
+ * @param originalSnapshot the collection snapshot before the first flush,
+ * or {@code null} for new collections
+ * @param session the current session
+ */
+ void writeCollectionAuditRows(
+ PersistentCollection> collection,
+ Object ownerId,
+ Object originalSnapshot,
+ SharedSessionContractImplementor session);
+}
diff --git a/hibernate-core/src/main/java/org/hibernate/audit/spi/RevisionEntitySupplier.java b/hibernate-core/src/main/java/org/hibernate/audit/spi/RevisionEntitySupplier.java
new file mode 100644
index 000000000000..60e0757a15fa
--- /dev/null
+++ b/hibernate-core/src/main/java/org/hibernate/audit/spi/RevisionEntitySupplier.java
@@ -0,0 +1,171 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.audit.spi;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+import org.hibernate.SharedSessionContract;
+import org.hibernate.Session;
+import org.hibernate.audit.AuditException;
+import org.hibernate.annotations.RevisionEntity;
+import org.hibernate.audit.RevisionListener;
+import org.hibernate.engine.spi.SharedSessionContractImplementor;
+import org.hibernate.persister.entity.EntityPersister;
+import org.hibernate.service.ServiceRegistry;
+import org.hibernate.temporal.spi.TransactionIdentifierService;
+import org.hibernate.temporal.spi.TransactionIdentifierSupplier;
+
+
+/**
+ * A built-in {@link TransactionIdentifierSupplier} that persists
+ * a user-defined revision entity and returns the
+ * {@link RevisionEntity.TransactionId @RevisionEntity.TransactionId}
+ * property value as the transaction id for audit rows.
+ *
+ * An optional {@link RevisionListener} callback can be
+ * configured for populating custom fields.
+ *
+ * @param the type of the transaction identifier
+ * (the {@link RevisionEntity.TransactionId @TransactionId}
+ * property type)
+ *
+ * @author Marco Belladelli
+ * @since 7.4
+ */
+public class RevisionEntitySupplier implements TransactionIdentifierSupplier {
+ private final Class> revisionEntityClass;
+ private final String transactionIdProperty;
+ private final String timestampProperty;
+ private final @Nullable String modifiedEntitiesProperty;
+ private final @Nullable RevisionListener listener;
+
+ /**
+ * @param revisionEntityClass the revision entity class
+ * @param transactionIdProperty the name of the {@link RevisionEntity.TransactionId @TransactionId} property
+ * @param timestampProperty the name of the {@link RevisionEntity.Timestamp @Timestamp} property
+ * @param modifiedEntitiesProperty the name of the {@link RevisionEntity.ModifiedEntities @ModifiedEntities}
+ * property, or {@code null} if entity change tracking is not configured
+ * @param listener optional callback for populating custom fields
+ */
+ public RevisionEntitySupplier(
+ Class> revisionEntityClass,
+ String transactionIdProperty,
+ String timestampProperty,
+ @Nullable String modifiedEntitiesProperty, @Nullable RevisionListener listener) {
+ this.revisionEntityClass = revisionEntityClass;
+ this.transactionIdProperty = transactionIdProperty;
+ this.timestampProperty = timestampProperty;
+ this.modifiedEntitiesProperty = modifiedEntitiesProperty;
+ this.listener = listener;
+ }
+
+ /**
+ * The revision entity class.
+ */
+ public Class> getRevisionEntityClass() {
+ return revisionEntityClass;
+ }
+
+ /**
+ * The name of the {@link RevisionEntity.TransactionId @TransactionId} property.
+ */
+ public String getTransactionIdProperty() {
+ return transactionIdProperty;
+ }
+
+ /**
+ * The name of the {@link RevisionEntity.Timestamp @Timestamp} property.
+ */
+ public String getTimestampProperty() {
+ return timestampProperty;
+ }
+
+ /**
+ * The configured revision listener, or {@code null}.
+ */
+ public @Nullable RevisionListener getListener() {
+ return listener;
+ }
+
+ /**
+ * The name of the {@link RevisionEntity.ModifiedEntities @ModifiedEntities}
+ * property, or {@code null} if entity change tracking is not configured.
+ */
+ public @Nullable String getModifiedEntitiesProperty() {
+ return modifiedEntitiesProperty;
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public T generateTransactionIdentifier(SharedSessionContract session) {
+ final var sessionImpl = (SharedSessionContractImplementor) session;
+ final EntityPersister persister = sessionImpl.getEntityPersister( revisionEntityClass.getName(), null );
+ final Object revisionEntity = persister.instantiate( null, sessionImpl );
+ if ( listener != null ) {
+ listener.newRevision( revisionEntity );
+ }
+ final var childSession = persistRevisionEntity( session, revisionEntity );
+ sessionImpl.getAuditWorkQueue().setRevisionContext( revisionEntity, childSession );
+ return (T) readTransactionId( revisionEntity, persister, sessionImpl );
+ }
+
+ /**
+ * Read the {@link RevisionEntity.TransactionId @TransactionId} property value
+ * from the revision entity after persistence.
+ *
+ * Handles both regular properties and {@code @Id} properties.
+ */
+ private Object readTransactionId(
+ Object revisionEntity,
+ EntityPersister persister,
+ SharedSessionContractImplementor session) {
+ final Object txId;
+ final var txIdAttr = persister.findAttributeMapping( transactionIdProperty );
+ if ( txIdAttr != null ) {
+ txId = persister.getValue( revisionEntity, txIdAttr.getStateArrayPosition() );
+ }
+ else {
+ // @TransactionId is the @Id
+ txId = persister.getIdentifier( revisionEntity, session );
+ }
+ if ( txId == null ) {
+ throw new AuditException(
+ "@RevisionEntity.TransactionId property '" + transactionIdProperty
+ + "' is null after persisting revision entity '"
+ + revisionEntityClass.getName() + "'"
+ );
+ }
+ return txId;
+ }
+
+ /**
+ * Persist the revision entity using a child {@link Session}
+ * that shares the parent session's JDBC connection.
+ * The child session is returned so it can be kept open
+ * for deferred flush of {@code @ElementCollection} changes.
+ */
+ private static Session persistRevisionEntity(
+ SharedSessionContract session,
+ Object revisionEntity) {
+ final var childSession = session.sessionWithOptions()
+ .connection()
+ .openSession();
+ childSession.persist( revisionEntity );
+ childSession.flush();
+ return childSession;
+ }
+
+ /**
+ * Resolve the {@link RevisionEntitySupplier} from the given
+ * service registry, or return {@code null} if no
+ * {@code @RevisionEntity} is configured.
+ */
+ public static @Nullable RevisionEntitySupplier> resolve(ServiceRegistry registry) {
+ final var service = registry.getService( TransactionIdentifierService.class );
+ return service != null && service.getIdentifierSupplier() instanceof RevisionEntitySupplier> supplier
+ ? supplier
+ : null;
+ }
+}
diff --git a/hibernate-core/src/main/java/org/hibernate/boot/model/internal/AuditHelper.java b/hibernate-core/src/main/java/org/hibernate/boot/model/internal/AuditHelper.java
index 1c720a3e8afa..39179d5763e5 100644
--- a/hibernate-core/src/main/java/org/hibernate/boot/model/internal/AuditHelper.java
+++ b/hibernate-core/src/main/java/org/hibernate/boot/model/internal/AuditHelper.java
@@ -4,25 +4,52 @@
*/
package org.hibernate.boot.model.internal;
+import java.lang.annotation.Annotation;
import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashMap;
import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
import java.util.Set;
+import org.hibernate.MappingException;
import org.hibernate.annotations.Audited;
+import org.hibernate.annotations.RevisionEntity;
+import org.hibernate.audit.RevisionListener;
+import org.hibernate.audit.spi.RevisionEntitySupplier;
import org.hibernate.boot.model.naming.Identifier;
import org.hibernate.boot.model.naming.PhysicalNamingStrategy;
import org.hibernate.boot.model.relational.Database;
import org.hibernate.boot.spi.MetadataBuildingContext;
+import org.hibernate.cfg.StateManagementSettings;
+import org.hibernate.engine.config.spi.ConfigurationService;
+import org.hibernate.engine.spi.LoadQueryInfluencers;
+import org.hibernate.mapping.AuxiliaryTableHolder;
+import org.hibernate.mapping.Backref;
import org.hibernate.mapping.BasicValue;
import org.hibernate.mapping.Collection;
import org.hibernate.mapping.Column;
+import org.hibernate.mapping.PersistentClass;
+import org.hibernate.mapping.PrimaryKey;
+import org.hibernate.mapping.Property;
import org.hibernate.mapping.RootClass;
import org.hibernate.mapping.Stateful;
import org.hibernate.mapping.Table;
+import org.hibernate.mapping.TableOwner;
+import org.hibernate.mapping.UnionSubclass;
+import org.hibernate.metamodel.mapping.EntityMappingType;
+import org.hibernate.models.spi.ClassDetails;
+import org.hibernate.models.spi.MemberDetails;
import org.hibernate.persister.state.internal.AuditStateManagement;
+import org.hibernate.resource.beans.spi.ManagedBeanRegistry;
+import org.hibernate.sql.results.graph.Fetchable;
import org.hibernate.temporal.spi.TransactionIdentifierService;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
import static org.hibernate.internal.util.StringHelper.isBlank;
+import static org.hibernate.internal.util.StringHelper.nullIfBlank;
/**
* Helper for building audit log tables in the boot model.
@@ -30,41 +57,60 @@
public final class AuditHelper {
public static final String TRANSACTION_ID = "transactionId";
public static final String MODIFICATION_TYPE = "modificationType";
+ public static final String TRANSACTION_END_ID = "transactionEndId";
+ public static final String TRANSACTION_END_TIMESTAMP = "transactionEndTimestamp";
- // defaults for backward compatibility with envers
-
- private static final String DEFAULT_TABLE_SUFFIX = "_aud";
+ private static final String DEFAULT_TABLE_SUFFIX = "_AUD";
private AuditHelper() {
}
static void bindAuditTable(
- Audited audited,
+ Audited.@Nullable Table auditTable,
RootClass rootClass,
+ ClassDetails classDetails,
MetadataBuildingContext context) {
- bindAuditTable( audited, rootClass, context,
- resolveExcludedColumns( rootClass ) );
+ bindAuditTable( auditTable, rootClass, context );
+ bindSecondaryAuditTables( auditTable, rootClass, classDetails, context );
+ bindSubclassAuditTables( auditTable, rootClass, context );
}
static void bindAuditTable(
- Audited audited,
+ Audited.@Nullable Table auditTable,
Collection collection,
MetadataBuildingContext context) {
- bindAuditTable( audited, collection, context, Set.of() );
+ bindAuditTable( auditTable, (Stateful) collection, context );
}
private static void bindAuditTable(
- Audited audited,
+ Audited.@Nullable Table auditTable,
Stateful auditable,
- MetadataBuildingContext context,
- Set excludedColumns) {
+ MetadataBuildingContext context) {
final var collector = context.getMetadataCollector();
final var table = auditable.getMainTable();
- final String explicitAuditTableName = audited.tableName();
+ final String explicitAuditTableName;
+ final String auditSchema;
+ final String auditCatalog;
+ final String txIdColumnName;
+ final String modTypeColumnName;
+ if ( auditTable != null ) {
+ explicitAuditTableName = auditTable.name();
+ auditSchema = auditTable.schema();
+ auditCatalog = auditTable.catalog();
+ txIdColumnName = auditTable.transactionIdColumn();
+ modTypeColumnName = auditTable.modificationTypeColumn();
+ }
+ else {
+ explicitAuditTableName = "";
+ auditSchema = "";
+ auditCatalog = "";
+ txIdColumnName = Audited.Table.DEFAULT_TRANSACTION_ID;
+ modTypeColumnName = Audited.Table.DEFAULT_MODIFICATION_TYPE;
+ }
final boolean hasExplicitAuditTableName = !isBlank( explicitAuditTableName );
- final var auditTable = collector.addTable(
- table.getSchema(),
- table.getCatalog(),
+ final var auditLogTable = collector.addTable(
+ isBlank( auditSchema ) ? table.getSchema() : auditSchema,
+ isBlank( auditCatalog ) ? table.getCatalog() : auditCatalog,
hasExplicitAuditTableName
? explicitAuditTableName
: collector.getLogicalTableName( table )
@@ -75,20 +121,165 @@ private static void bindAuditTable(
hasExplicitAuditTableName
|| table.getNameIdentifier().isExplicit()
);
- collector.addTableNameBinding( table.getNameIdentifier(), auditTable );
- copyTableColumns( table, auditTable, excludedColumns );
- final var transactionIdColumn =
- createAuditColumn( audited.transactionId(),
- getTransactionIdType( context ), auditTable, context );
- final var modificationTypeColumn =
- createAuditColumn( audited.modificationType(),
- Byte.class, auditTable, context );
- auditTable.addColumn( transactionIdColumn );
- auditTable.addColumn( modificationTypeColumn );
- enableAudit( auditable, auditTable, transactionIdColumn, modificationTypeColumn );
+ collector.addTableNameBinding( table.getNameIdentifier(), auditLogTable );
- collector.addSecondPass( (OptionalDeterminationSecondPass) ignored ->
- copyTableColumns( table, auditTable, excludedColumns ) );
+ // Defer audit column creation to a second pass so the transaction
+ // ID type is resolved after all entities are bound, including any
+ // @RevisionEntity contributed by mapping contributors
+ collector.addSecondPass( (OptionalDeterminationSecondPass) ignored -> {
+ // Auto-exclude @Version property from audit tables
+ if ( auditable instanceof RootClass rootClass && rootClass.isVersioned() ) {
+ rootClass.getVersion().setAuditedExcluded( true );
+ }
+ // Resolve exclusions at second-pass time so collection-managed FK columns
+ // (added during collection binding) are detected
+ final var excludedColumns = auditable instanceof RootClass rootClass
+ ? resolveExcludedColumns( rootClass )
+ : Set.of();
+ copyTableColumns( table, auditLogTable, excludedColumns );
+ final var transactionIdColumn =
+ createAuditColumn( txIdColumnName,
+ getTransactionIdType( context ), auditLogTable, context );
+ final var modificationTypeColumn =
+ createAuditColumn( modTypeColumnName,
+ Byte.class, auditLogTable, context );
+ auditLogTable.addColumn( transactionIdColumn );
+ auditLogTable.addColumn( modificationTypeColumn );
+ if ( auditable instanceof Collection ) {
+ // Collection audit PK: (REV, all_source_cols)
+ createAuditPrimaryKey( auditLogTable, transactionIdColumn, table.getColumns() );
+ }
+ else {
+ // Entity audit PK: (REV, entity_id_cols) from source table's PK
+ createAuditPrimaryKey( auditLogTable, transactionIdColumn, table.getPrimaryKey().getColumns() );
+ }
+ enableAudit( auditable, auditLogTable, transactionIdColumn, modificationTypeColumn );
+ createRevisionForeignKey( auditLogTable, transactionIdColumn, context );
+ addTransactionEndColumns( auditTable, auditable, auditLogTable, context );
+ } );
+ }
+
+ private static void bindSecondaryAuditTables(
+ Audited.@Nullable Table auditTable,
+ RootClass rootClass,
+ ClassDetails classDetails,
+ MetadataBuildingContext context) {
+ final String txIdColumnName;
+ final String auditSchema;
+ final String auditCatalog;
+ if ( auditTable != null ) {
+ txIdColumnName = auditTable.transactionIdColumn();
+ auditSchema = auditTable.schema();
+ auditCatalog = auditTable.catalog();
+ }
+ else {
+ txIdColumnName = Audited.Table.DEFAULT_TRANSACTION_ID;
+ auditSchema = null;
+ auditCatalog = null;
+ }
+ final Map secondaryAuditTableNames = new HashMap<>();
+ classDetails.forEachAnnotationUsage(
+ Audited.SecondaryTable.class,
+ context.getBootstrapContext().getModelsContext(),
+ sat -> secondaryAuditTableNames.put( sat.secondaryTableName(), sat.secondaryAuditTableName() )
+ );
+ context.getMetadataCollector().addSecondPass( (OptionalDeterminationSecondPass) ignored -> {
+ for ( var join : rootClass.getJoins() ) {
+ final var sourceTable = join.getTable();
+ final String customName = secondaryAuditTableNames.get( sourceTable.getName() );
+ final var secondaryAuditTable = createAuditTable(
+ sourceTable,
+ txIdColumnName,
+ resolveExcludedColumns( join.getProperties() ),
+ nullIfBlank( auditSchema ),
+ nullIfBlank( auditCatalog ),
+ customName,
+ context
+ );
+ createAuditTableForeignKey( secondaryAuditTable, rootClass.getEntityName(), rootClass.getAuxiliaryTable() );
+ // Secondary tables only get tx-id (no mod type, no REVEND)
+ join.setAuxiliaryTable( secondaryAuditTable );
+ join.addAuxiliaryColumn( TRANSACTION_ID, secondaryAuditTable.getPrimaryKey().getColumn( 0 ) );
+ }
+ } );
+ }
+
+ private static void bindSubclassAuditTables(
+ Audited.@Nullable Table auditTable,
+ RootClass rootClass,
+ MetadataBuildingContext context) {
+ final String txIdColumnName;
+ final String modTypeColumnName;
+ if ( auditTable != null ) {
+ txIdColumnName = auditTable.transactionIdColumn();
+ modTypeColumnName = auditTable.modificationTypeColumn();
+ }
+ else {
+ txIdColumnName = Audited.Table.DEFAULT_TRANSACTION_ID;
+ modTypeColumnName = Audited.Table.DEFAULT_MODIFICATION_TYPE;
+ }
+ // Defer to second pass since subclasses haven't been added to rootClass yet
+ context.getMetadataCollector().addSecondPass( (OptionalDeterminationSecondPass) ignored ->
+ bindSubclassAuditTables(
+ rootClass,
+ auditTable,
+ txIdColumnName,
+ modTypeColumnName,
+ context
+ )
+ );
+ }
+
+ /**
+ * Create audit tables for direct subclasses of {@code parent},
+ * then recurse into their children.
+ */
+ private static void bindSubclassAuditTables(
+ PersistentClass parent,
+ Audited.@Nullable Table auditTable,
+ String txIdColumnName,
+ String modTypeColumnName,
+ MetadataBuildingContext context) {
+ final var modelsContext = context.getBootstrapContext().getModelsContext();
+ for ( var subclass : parent.getDirectSubclasses() ) {
+ if ( subclass instanceof TableOwner ) {
+ // Check if the subclass has its own @Audited.Table for table name/schema/catalog override
+ final var subclassDetails = modelsContext.getClassDetailsRegistry()
+ .getClassDetails( subclass.getClassName() );
+ final var subclassTable = subclassDetails.getDirectAnnotationUsage( Audited.Table.class );
+ final var effective = subclassTable != null ? subclassTable : auditTable;
+ final var subclassAuditTable = createAuditTable(
+ subclass.getTable(),
+ txIdColumnName,
+ resolveExcludedColumns( subclass.getProperties() ),
+ effective != null ? nullIfBlank( effective.schema() ) : null,
+ effective != null ? nullIfBlank( effective.catalog() ) : null,
+ effective != null ? nullIfBlank( effective.name() ) : null,
+ context
+ );
+ subclass.addAuxiliaryColumn( TRANSACTION_ID, subclassAuditTable.getPrimaryKey().getColumn( 0 ) );
+ if ( subclass instanceof UnionSubclass ) {
+ // TABLE_PER_CLASS: each table is self-contained, needs its own REVTYPE and REVEND
+ final var modificationTypeColumn =
+ createAuditColumn( modTypeColumnName,
+ Byte.class, subclassAuditTable, context );
+ subclassAuditTable.addColumn( modificationTypeColumn );
+ subclass.addAuxiliaryColumn( MODIFICATION_TYPE, modificationTypeColumn );
+ addTransactionEndColumns( auditTable, subclass, subclassAuditTable, context );
+ }
+ else {
+ // JOINED: REVTYPE/REVEND only on root table; FK to parent audit table
+ createAuditTableForeignKey(
+ subclassAuditTable,
+ parent.getEntityName(),
+ parent.getAuxiliaryTable()
+ );
+ }
+ subclass.setAuxiliaryTable( subclassAuditTable );
+ // Recurse into this subclass's children
+ bindSubclassAuditTables( subclass, auditTable, txIdColumnName, modTypeColumnName, context );
+ }
+ }
}
static void enableAudit(
@@ -100,6 +291,335 @@ static void enableAudit(
model.setStateManagementType( AuditStateManagement.class );
}
+ /**
+ * Create a middle audit table for unidirectional @OneToMany @JoinColumn.
+ * The table tracks collection membership with (parent_key, child_key, REV, REVTYPE)
+ *
+ * The child entity's FK column is on the child table, but from an entity model
+ * perspective the collection is part of the parent entity's state.
+ */
+ static void bindOneToManyAuditTable(
+ Audited.@Nullable Table auditTable,
+ Collection collection,
+ String referencedEntityName,
+ Audited.@Nullable CollectionTable collectionAuditTable,
+ MetadataBuildingContext context) {
+ final var collector = context.getMetadataCollector();
+ final var ownerTable = collection.getOwner().getTable();
+
+ // Table name: @Audited.CollectionTable name, or {OwnerJpaEntityName}_{ChildJpaEntityName}_AUD
+ final var referencedEntity = collector.getEntityBinding( referencedEntityName );
+ final String auditTableName;
+ if ( collectionAuditTable != null && !isBlank( collectionAuditTable.name() ) ) {
+ auditTableName = collectionAuditTable.name();
+ }
+ else {
+ final String ownerSimpleName = collection.getOwner().getJpaEntityName();
+ final String childSimpleName = referencedEntity.getJpaEntityName();
+ auditTableName = ownerSimpleName + "_" + childSimpleName + DEFAULT_TABLE_SUFFIX;
+ }
+
+ final String auditSchema;
+ final String auditCatalog;
+ final String txIdColumnName;
+ final String modTypeColumnName;
+ if ( auditTable != null ) {
+ auditSchema = auditTable.schema();
+ auditCatalog = auditTable.catalog();
+ txIdColumnName = auditTable.transactionIdColumn();
+ modTypeColumnName = auditTable.modificationTypeColumn();
+ }
+ else {
+ auditSchema = "";
+ auditCatalog = "";
+ txIdColumnName = Audited.Table.DEFAULT_TRANSACTION_ID;
+ modTypeColumnName = Audited.Table.DEFAULT_MODIFICATION_TYPE;
+ }
+ final String schema = collectionAuditTable != null && !isBlank( collectionAuditTable.schema() )
+ ? collectionAuditTable.schema()
+ : !isBlank( auditSchema ) ? auditSchema : ownerTable.getSchema();
+ final String catalog = collectionAuditTable != null && !isBlank( collectionAuditTable.catalog() )
+ ? collectionAuditTable.catalog()
+ : !isBlank( auditCatalog ) ? auditCatalog : ownerTable.getCatalog();
+ final var middleAuditTable = collector.addTable(
+ schema,
+ catalog,
+ auditTableName,
+ null,
+ false,
+ context,
+ false
+ );
+ collector.addSecondPass( (OptionalDeterminationSecondPass) ignored -> {
+ final var keyColumns = new ArrayList();
+ // Copy the FK columns (parent key) from the collection's key
+ for ( var column : collection.getKey().getColumns() ) {
+ final var copy = column.clone();
+ copy.setUnique( false );
+ copy.setUniqueKeyName( null );
+ middleAuditTable.addColumn( copy );
+ keyColumns.add( copy );
+ }
+ // Copy the child identifier columns from the referenced entity
+ for ( var column : referencedEntity.getKey().getColumns() ) {
+ final var copy = column.clone();
+ copy.setUnique( false );
+ copy.setUniqueKeyName( null );
+ middleAuditTable.addColumn( copy );
+ keyColumns.add( copy );
+ }
+ // Audit columns
+ final var transactionIdColumn = createAuditColumn(
+ txIdColumnName,
+ getTransactionIdType( context ),
+ middleAuditTable,
+ context
+ );
+ final var modificationTypeColumn = createAuditColumn(
+ modTypeColumnName,
+ Byte.class,
+ middleAuditTable,
+ context
+ );
+ middleAuditTable.addColumn( transactionIdColumn );
+ middleAuditTable.addColumn( modificationTypeColumn );
+ createAuditPrimaryKey( middleAuditTable, transactionIdColumn, keyColumns );
+ createRevisionForeignKey( middleAuditTable, transactionIdColumn, context );
+ enableAudit( collection, middleAuditTable, transactionIdColumn, modificationTypeColumn );
+ addTransactionEndColumns( auditTable, collection, middleAuditTable, context );
+ } );
+ }
+
+ static void bindRevisionEntity(
+ RevisionEntity revisionEntity,
+ RootClass rootClass,
+ ClassDetails classDetails,
+ MetadataBuildingContext context) {
+ final var modelsContext = context.getBootstrapContext().getModelsContext();
+
+ // todo : @RevisionEntity currently requires @Entity;
+ // could we automatically imply @Entity for @RevisionEntity classes
+ // so users don't need both annotations?
+
+ // The entity must not be audited
+ if ( classDetails.hasAnnotationUsage( Audited.class, modelsContext ) ) {
+ throw new MappingException( "The @RevisionEntity entity cannot be audited" );
+ }
+
+ // Scan class members (including supertypes) for @TransactionId,
+ // @Timestamp, and @ModifiedEntities. We need the names
+ // and type eagerly to configure the supplier before audit table
+ // second passes create the REV column.
+ MemberDetails revNumberMember = null;
+ MemberDetails revTimestampMember = null;
+ MemberDetails modifiedEntityNamesMember = null;
+ for ( var current = classDetails; current != null; current = current.getSuperClass() ) {
+ for ( var member : current.getFields() ) {
+ revNumberMember = checkAnnotation(
+ member,
+ revNumberMember,
+ RevisionEntity.TransactionId.class,
+ classDetails
+ );
+ revTimestampMember = checkAnnotation(
+ member,
+ revTimestampMember,
+ RevisionEntity.Timestamp.class,
+ classDetails
+ );
+ modifiedEntityNamesMember = checkAnnotation(
+ member,
+ modifiedEntityNamesMember,
+ RevisionEntity.ModifiedEntities.class,
+ classDetails
+ );
+ }
+ for ( var member : current.getMethods() ) {
+ revNumberMember = checkAnnotation(
+ member,
+ revNumberMember,
+ RevisionEntity.TransactionId.class,
+ classDetails
+ );
+ revTimestampMember = checkAnnotation(
+ member,
+ revTimestampMember,
+ RevisionEntity.Timestamp.class,
+ classDetails
+ );
+ modifiedEntityNamesMember = checkAnnotation(
+ member,
+ modifiedEntityNamesMember,
+ RevisionEntity.ModifiedEntities.class,
+ classDetails
+ );
+ }
+ }
+
+ if ( revNumberMember == null ) {
+ throw new MappingException(
+ "@RevisionEntity '" + classDetails.getName()
+ + "' must have a property annotated with @RevisionEntity.TransactionId"
+ );
+ }
+ if ( revTimestampMember == null ) {
+ throw new MappingException(
+ "@RevisionEntity '" + classDetails.getName()
+ + "' must have a property annotated with @RevisionEntity.Timestamp"
+ );
+ }
+
+ // Configure the supplier eagerly
+ final var serviceRegistry = context.getBootstrapContext().getServiceRegistry();
+ final Class extends RevisionListener> listenerClass = revisionEntity.listener();
+ final RevisionListener listener = listenerClass != RevisionListener.class
+ ? serviceRegistry.requireService( ManagedBeanRegistry.class ).getBean( listenerClass ).getBeanInstance()
+ : null;
+ final var supplier = new RevisionEntitySupplier<>(
+ classDetails.toJavaClass(),
+ revNumberMember.resolveAttributeName(),
+ revTimestampMember.resolveAttributeName(),
+ modifiedEntityNamesMember != null
+ ? modifiedEntityNamesMember.resolveAttributeName()
+ : null, listener
+ );
+ final var revNumberType = revNumberMember.getType().determineRawClass().toJavaClass();
+ serviceRegistry.requireService( TransactionIdentifierService.class )
+ .contributeIdentifierSupplier( supplier, revNumberType );
+
+ // Defer validation (basic type, mapped as Hibernate property) and
+ // unique constraint to second pass when entity properties are fully bound
+ final String entityName = rootClass.getEntityName();
+ final String revNumberName = revNumberMember.resolveAttributeName();
+ final String revTimestampName = revTimestampMember.resolveAttributeName();
+ context.getMetadataCollector().addSecondPass( (OptionalDeterminationSecondPass) ignored ->
+ validateRevisionEntity( entityName, revNumberName, revTimestampName, context )
+ );
+ }
+
+ /**
+ * Check if a member has the given annotation. If found, validate no
+ * duplicate and return the member; otherwise return the existing value.
+ */
+ private static MemberDetails checkAnnotation(
+ MemberDetails member,
+ @Nullable MemberDetails existing,
+ Class extends Annotation> annotationType,
+ ClassDetails classDetails) {
+ if ( member.hasDirectAnnotationUsage( annotationType ) ) {
+ if ( existing != null ) {
+ throw new MappingException(
+ "@RevisionEntity '" + classDetails.getName()
+ + "' has multiple members annotated with @"
+ + annotationType.getSimpleName()
+ );
+ }
+ return member;
+ }
+ return existing;
+ }
+
+ /**
+ * Second-pass validation: verify {@code @RevisionEntity.TransactionId}
+ * and {@code @RevisionEntity.Timestamp} are mapped as basic properties,
+ * and add a unique constraint on non-ID {@code @TransactionId}.
+ */
+ private static void validateRevisionEntity(
+ String entityName,
+ String revNumberName,
+ String revTimestampName,
+ MetadataBuildingContext context) {
+ final var entityBinding = context.getMetadataCollector().getEntityBinding( entityName );
+ if ( entityBinding == null ) {
+ return;
+ }
+ final var revNumberProperty = requireBasicProperty(
+ entityBinding,
+ revNumberName,
+ "@RevisionEntity.TransactionId"
+ );
+ requireBasicProperty( entityBinding, revTimestampName, "@RevisionEntity.Timestamp" );
+ // Add unique constraint on non-ID @TransactionId
+ if ( revNumberProperty != entityBinding.getIdentifierProperty() ) {
+ for ( var column : revNumberProperty.getColumns() ) {
+ column.setUnique( true );
+ }
+ }
+ }
+
+ /**
+ * Validate that a named property exists and is mapped as a {@link BasicValue}.
+ */
+ private static Property requireBasicProperty(
+ PersistentClass entityBinding,
+ String propertyName,
+ String annotationName) {
+ final Property property;
+ try {
+ property = entityBinding.getProperty( propertyName );
+ }
+ catch (MappingException e) {
+ throw new MappingException(
+ annotationName + " member '" + propertyName
+ + "' is not mapped as a property on @RevisionEntity '"
+ + entityBinding.getEntityName() + "'"
+ );
+ }
+ if ( !( property.getValue() instanceof BasicValue ) ) {
+ throw new MappingException(
+ annotationName + " property '" + entityBinding.getEntityName()
+ + "." + propertyName + "' must be a basic attribute"
+ );
+ }
+ return property;
+ }
+
+ /**
+ * Create an audit table for the given source table: copy columns,
+ * add the REV column, create the composite PK, and add the
+ * REV -> REVINFO FK (if a revision entity is configured).
+ */
+ private static Table createAuditTable(
+ Table sourceTable,
+ String txIdColumnName,
+ Set excludedColumns,
+ @Nullable String schemaOverride,
+ @Nullable String catalogOverride,
+ @Nullable String customAuditTableName,
+ MetadataBuildingContext context) {
+ final var collector = context.getMetadataCollector();
+ final String auditTableName = customAuditTableName != null
+ ? customAuditTableName
+ : collector.getLogicalTableName( sourceTable ) + DEFAULT_TABLE_SUFFIX;
+ final var auditTable = collector.addTable(
+ schemaOverride != null ? schemaOverride : sourceTable.getSchema(),
+ catalogOverride != null ? catalogOverride : sourceTable.getCatalog(),
+ auditTableName,
+ sourceTable.getSubselect(),
+ sourceTable.isAbstract(),
+ context,
+ sourceTable.getNameIdentifier().isExplicit()
+ );
+ copyTableColumns( sourceTable, auditTable, excludedColumns );
+ final var revColumn = createAuditColumn( txIdColumnName, getTransactionIdType( context ), auditTable, context );
+ auditTable.addColumn( revColumn );
+ createAuditPrimaryKey( auditTable, revColumn, sourceTable.getPrimaryKey().getColumns() );
+ createRevisionForeignKey( auditTable, revColumn, context );
+ return auditTable;
+ }
+
+ private static void createAuditPrimaryKey(
+ Table auditTable,
+ Column transactionIdColumn,
+ Iterable sourceKeyColumns) {
+ final var pk = new PrimaryKey( auditTable );
+ pk.addColumn( transactionIdColumn );
+ for ( var sourceCol : sourceKeyColumns ) {
+ pk.addColumn( auditTable.getColumn( sourceCol ) );
+ }
+ auditTable.setPrimaryKey( pk );
+ }
+
private static Class> getTransactionIdType(MetadataBuildingContext context) {
return context.getBootstrapContext().getServiceRegistry()
.requireService( TransactionIdentifierService.class )
@@ -109,7 +629,12 @@ private static Class> getTransactionIdType(MetadataBuildingContext context) {
private static void copyTableColumns(Table sourceTable, Table targetTable, Set excludedColumns) {
for ( var column : sourceTable.getColumns() ) {
if ( !excludedColumns.contains( column.getCanonicalName() ) ) {
- targetTable.addColumn( column.clone() );
+ final var copy = column.clone();
+ // Audit tables must not inherit unique constraints from the source,
+ // since the same value can appear at different revisions
+ copy.setUnique( false );
+ copy.setUniqueKeyName( null );
+ targetTable.addColumn( copy );
}
}
}
@@ -151,17 +676,96 @@ private static void setColumnName(
PhysicalNamingStrategy physicalNamingStrategy) {
final Identifier physicalColumnName =
physicalNamingStrategy.toPhysicalColumnName(
- database.toIdentifier( name ),
- database.getJdbcEnvironment()
+ database.toIdentifier( name ),
+ database.getJdbcEnvironment()
);
column.setName( physicalColumnName.render( database.getDialect() ) );
}
- private static Set resolveExcludedColumns(RootClass rootClass) {
+ private static boolean isValidityStrategy(MetadataBuildingContext context) {
+ final var value = context.getBootstrapContext().getServiceRegistry()
+ .requireService( ConfigurationService.class )
+ .getSetting( StateManagementSettings.AUDIT_STRATEGY, String.class, "default" );
+ return "validity".equalsIgnoreCase( value );
+ }
+
+ private static void addTransactionEndColumns(
+ Audited.@Nullable Table auditTableAnnotation,
+ AuxiliaryTableHolder holder,
+ Table auditTable,
+ MetadataBuildingContext context) {
+ if ( !isValidityStrategy( context ) ) {
+ return;
+ }
+ final var revEndColumn =
+ createAuditColumn(
+ auditTableAnnotation != null ? auditTableAnnotation.transactionEndIdColumn() : Audited.Table.DEFAULT_TRANSACTION_END_ID,
+ getTransactionIdType( context ), auditTable, context );
+ revEndColumn.setNullable( true );
+ auditTable.addColumn( revEndColumn );
+ holder.addAuxiliaryColumn( TRANSACTION_END_ID, revEndColumn );
+ createRevisionForeignKey( auditTable, revEndColumn, context );
+
+ final String revEndTsName = auditTableAnnotation != null
+ ? auditTableAnnotation.transactionEndTimestampColumn()
+ : "";
+ if ( !isBlank( revEndTsName ) ) {
+ final var revEndTsColumn = createAuditColumn( revEndTsName, Instant.class, auditTable, context );
+ revEndTsColumn.setNullable( true );
+ auditTable.addColumn( revEndTsColumn );
+ holder.addAuxiliaryColumn( TRANSACTION_END_TIMESTAMP, revEndTsColumn );
+ }
+ }
+
+ /**
+ * Create a FK from the audit table's REV (or REVEND) column to the
+ * revision entity's PK. Only applies when {@code @RevisionEntity}
+ * is configured.
+ */
+ private static void createRevisionForeignKey(
+ Table auditTable,
+ Column revColumn,
+ MetadataBuildingContext context) {
+ final String revisionEntityName = getRevisionEntityName( context );
+ if ( revisionEntityName != null ) {
+ auditTable.createForeignKey(
+ null,
+ List.of( revColumn ),
+ revisionEntityName,
+ null,
+ null
+ );
+ }
+ }
+
+ /**
+ * Create a FK from one audit table's PK to another audit table's PK.
+ * Used for JOINED inheritance (child_aud -> parent_aud) and
+ * {@code @SecondaryTable} (secondary_aud -> primary_aud).
+ */
+ private static void createAuditTableForeignKey(
+ Table sourceAuditTable,
+ String rootEntityName,
+ Table referencedAuditTable) {
+ final var fk = sourceAuditTable.createForeignKey(
+ null,
+ new ArrayList<>( sourceAuditTable.getPrimaryKey().getColumns() ),
+ rootEntityName,
+ null,
+ null
+ );
+ fk.setReferencedTable( referencedAuditTable );
+ }
+
+ private static @Nullable String getRevisionEntityName(MetadataBuildingContext context) {
+ final var supplier = RevisionEntitySupplier.resolve( context.getBootstrapContext().getServiceRegistry() );
+ return supplier != null ? supplier.getRevisionEntityClass().getName() : null;
+ }
+
+ private static Set resolveExcludedColumns(Iterable properties) {
final Set excluded = new HashSet<>();
- final var properties = rootClass.getProperties();
for ( var property : properties ) {
- if ( property.isAuditedExcluded() ) {
+ if ( property.isAuditedExcluded() || property instanceof Backref ) {
for ( var column : property.getColumns() ) {
excluded.add( column.getCanonicalName() );
}
@@ -169,4 +773,72 @@ private static Set resolveExcludedColumns(RootClass rootClass) {
}
return excluded;
}
+
+ private static Set resolveExcludedColumns(RootClass rootClass) {
+ final Set excluded = new HashSet<>();
+ final Set mappedColumns = new HashSet<>();
+ // Identifier columns
+ for ( var column : rootClass.getIdentifier().getColumns() ) {
+ mappedColumns.add( column.getCanonicalName() );
+ }
+ // Discriminator column
+ if ( rootClass.getDiscriminator() != null ) {
+ for ( var column : rootClass.getDiscriminator().getColumns() ) {
+ mappedColumns.add( column.getCanonicalName() );
+ }
+ }
+ // All properties in the hierarchy (root + subclasses for SINGLE_TABLE)
+ collectPropertyColumns( rootClass, mappedColumns, excluded );
+ for ( var subclass : rootClass.getSubclasses() ) {
+ collectPropertyColumns( subclass, mappedColumns, excluded );
+ }
+ // Exclude unmapped columns (e.g. FK from unidirectional @OneToMany @JoinColumn)
+ for ( var column : rootClass.getMainTable().getColumns() ) {
+ if ( !mappedColumns.contains( column.getCanonicalName() ) ) {
+ excluded.add( column.getCanonicalName() );
+ }
+ }
+ return excluded;
+ }
+
+ private static void collectPropertyColumns(
+ PersistentClass persistentClass,
+ Set mappedColumns,
+ Set excluded) {
+ for ( var property : persistentClass.getProperties() ) {
+ if ( property.isAuditedExcluded() || property instanceof Backref ) {
+ for ( var column : property.getColumns() ) {
+ excluded.add( column.getCanonicalName() );
+ }
+ }
+ else {
+ for ( var column : property.getColumns() ) {
+ mappedColumns.add( column.getCanonicalName() );
+ }
+ }
+ }
+ }
+
+ // --- Runtime helpers ---
+
+ /**
+ * Whether the given fetchable is excluded from auditing and the
+ * current context is loading from an audit table. Returns
+ * {@code false} immediately when there is no temporal identifier
+ * (the common case for non-audit queries).
+ */
+ public static boolean isFetchableAuditExcluded(Fetchable fetchable, LoadQueryInfluencers influencers) {
+ if ( influencers.getTemporalIdentifier() == null ) {
+ return false;
+ }
+ final var attr = fetchable.asAttributeMapping();
+ if ( attr != null
+ && attr.getStateArrayPosition() >= 0
+ && attr.getDeclaringType() instanceof EntityMappingType entityMappingType ) {
+ final var persister = entityMappingType.getEntityPersister();
+ return persister.getAuditMapping() != null
+ && persister.isPropertyAuditedExcluded( attr.getStateArrayPosition() );
+ }
+ return false;
+ }
}
diff --git a/hibernate-core/src/main/java/org/hibernate/boot/model/internal/CollectionBinder.java b/hibernate-core/src/main/java/org/hibernate/boot/model/internal/CollectionBinder.java
index 39b7577c3e8e..03918a90a422 100644
--- a/hibernate-core/src/main/java/org/hibernate/boot/model/internal/CollectionBinder.java
+++ b/hibernate-core/src/main/java/org/hibernate/boot/model/internal/CollectionBinder.java
@@ -1569,6 +1569,21 @@ protected void bindOneToManySecondPass(Map persistentCl
: foreignJoinColumns.getTable();
collection.setCollectionTable( collectionTable );
+ // For @OneToMany @JoinColumn on an @Audited entity, create a middle audit table
+ // to track collection membership changes (same approach as @ManyToMany / @JoinTable)
+ if ( !collection.isInverse() ) {
+ final var audited = extract( Audited.class, property, buildingContext );
+ if ( audited != null && !property.hasDirectAnnotationUsage( Audited.Excluded.class ) ) {
+ AuditHelper.bindOneToManyAuditTable(
+ extract( Audited.Table.class, property, buildingContext ),
+ collection,
+ oneToMany.getReferencedEntityName(),
+ extract( Audited.CollectionTable.class, property, buildingContext ),
+ buildingContext
+ );
+ }
+ }
+
bindSynchronize();
bindFilters( false );
handleWhere( false );
@@ -2439,18 +2454,33 @@ private void processTemporal() {
private void processAudited() {
assert collection.getCollectionTable() != null;
+ // Skip inverse collections (mappedBy): the FK is on the child entity's table,
+ // and auditing is handled by the owning side
+ if ( collection.isInverse() ) {
+ return;
+ }
final var audited = extract( Audited.class, property, buildingContext );
- if ( audited != null ) {
- AuditHelper.bindAuditTable( audited, collection, buildingContext );
+ if ( audited != null && !property.hasDirectAnnotationUsage( Audited.Excluded.class ) ) {
+ AuditHelper.bindAuditTable(
+ extract( Audited.Table.class, property, buildingContext ),
+ collection,
+ buildingContext
+ );
}
}
private static T extract(
Class annotationClass, MemberDetails property, MetadataBuildingContext context) {
final var fromProperty = property.getDirectAnnotationUsage( annotationClass );
- return fromProperty == null
- ? extractFromPackage( annotationClass, property.getDeclaringType(), context )
- : fromProperty;
+ if ( fromProperty != null ) {
+ return fromProperty;
+ }
+ // Check the owning entity class hierarchy (class-level annotation propagates to collections)
+ final var modelsContext = context.getBootstrapContext().getModelsContext();
+ final var fromClass = property.getDeclaringType().getAnnotationUsage( annotationClass, modelsContext );
+ return fromClass == null ?
+ extractFromPackage( annotationClass, property.getDeclaringType(), context ) :
+ fromClass;
}
private void handleUnownedManyToMany(
diff --git a/hibernate-core/src/main/java/org/hibernate/boot/model/internal/EntityBinder.java b/hibernate-core/src/main/java/org/hibernate/boot/model/internal/EntityBinder.java
index deb23b12721b..c7df43edc45c 100644
--- a/hibernate-core/src/main/java/org/hibernate/boot/model/internal/EntityBinder.java
+++ b/hibernate-core/src/main/java/org/hibernate/boot/model/internal/EntityBinder.java
@@ -65,6 +65,7 @@
import org.hibernate.annotations.Synchronize;
import org.hibernate.annotations.TypeBinderType;
import org.hibernate.annotations.View;
+import org.hibernate.annotations.RevisionEntity;
import org.hibernate.boot.model.NamedEntityGraphDefinition;
import org.hibernate.boot.model.internal.InheritanceState.ElementsToProcess;
import org.hibernate.boot.model.naming.EntityNaming;
@@ -360,7 +361,14 @@ private static void bindAudited(
MetadataBuildingContext context) {
final var audited = extract( Audited.class, classDetails, context );
if ( audited != null ) {
- AuditHelper.bindAuditTable( audited, rootClass, context );
+ final var auditTable = extract( Audited.Table.class, classDetails, context );
+ AuditHelper.bindAuditTable( auditTable, rootClass, classDetails, context );
+ }
+ else {
+ final var revisionEntity = extract( RevisionEntity.class, classDetails, context );
+ if ( revisionEntity != null ) {
+ AuditHelper.bindRevisionEntity( revisionEntity, rootClass, classDetails, context );
+ }
}
}
diff --git a/hibernate-core/src/main/java/org/hibernate/cfg/StateManagementSettings.java b/hibernate-core/src/main/java/org/hibernate/cfg/StateManagementSettings.java
index 11dfaad318eb..ebd7d505e61d 100644
--- a/hibernate-core/src/main/java/org/hibernate/cfg/StateManagementSettings.java
+++ b/hibernate-core/src/main/java/org/hibernate/cfg/StateManagementSettings.java
@@ -70,7 +70,8 @@ public interface StateManagementSettings {
* when using the {@link TemporalTableStrategy#SINGLE_TABLE} or
* {@link TemporalTableStrategy#HISTORY_TABLE} mapping strategy
* or for {@linkplain org.hibernate.annotations.Audited audited
- * data}.
+ * data}. A plain {@link java.util.function.Supplier Supplier}
+ * is also accepted for backward compatibility.
*
* The Java type of the transaction id is inferred from the type
* argument {@code T} in the instantiation of {@code TransactionIdentifierSupplier}
@@ -86,8 +87,34 @@ public interface StateManagementSettings {
* @see org.hibernate.annotations.Temporal
* @see org.hibernate.annotations.Audited
* @see org.hibernate.temporal.spi.TransactionIdentifierService
+ * @see org.hibernate.temporal.spi.TransactionIdentifierSupplier
*
* @since 7.4
*/
String TRANSACTION_ID_SUPPLIER = "hibernate.temporal.transaction_id_supplier";
+
+ /**
+ * Specifies the audit strategy for
+ * {@linkplain org.hibernate.annotations.Audited audited} entities.
+ *
+ * Accepts:
+ *
+ * {@code "default"}: each point-in-time query uses a
+ * {@code MAX(REV)} subquery to find the current audit row
+ * (no additional schema requirements), or
+ * {@code "validity"}: each audit row carries a
+ * {@code REVEND} column marking when it was superseded;
+ * point-in-time queries use a simple range predicate
+ * ({@code REV <= :txId AND (REVEND > :txId OR REVEND IS NULL)})
+ * instead of a subquery, which is significantly faster for
+ * large audit tables.
+ *
+ *
+ * @settingDefault {@code "default"}
+ * @see org.hibernate.annotations.Audited
+ *
+ * @since 7.4
+ */
+ @Incubating
+ String AUDIT_STRATEGY = "hibernate.audit.strategy";
}
diff --git a/hibernate-core/src/main/java/org/hibernate/collection/spi/AbstractPersistentCollection.java b/hibernate-core/src/main/java/org/hibernate/collection/spi/AbstractPersistentCollection.java
index 0cedce2820f5..23479ca6757a 100644
--- a/hibernate-core/src/main/java/org/hibernate/collection/spi/AbstractPersistentCollection.java
+++ b/hibernate-core/src/main/java/org/hibernate/collection/spi/AbstractPersistentCollection.java
@@ -17,6 +17,7 @@
import java.util.UUID;
import org.hibernate.AssertionFailure;
+import org.hibernate.audit.AuditLog;
import org.hibernate.HibernateException;
import org.hibernate.LazyInitializationException;
import org.hibernate.engine.spi.CollectionEntry;
@@ -74,6 +75,7 @@ public abstract class AbstractPersistentCollection implements Serializable, P
private String sessionFactoryUuid;
private boolean allowLoadOutsideTransaction;
+ private @Nullable Object temporalIdentifier;
private transient int instanceId;
@@ -86,6 +88,8 @@ public AbstractPersistentCollection() {
protected AbstractPersistentCollection(SharedSessionContractImplementor session) {
this.session = session;
+ final Object tempId = session.getLoadQueryInfluencers().getTemporalIdentifier();
+ this.temporalIdentifier = tempId != AuditLog.ALL_REVISIONS ? tempId : null;
}
@Override
@@ -98,6 +102,10 @@ protected AbstractPersistentCollection(SharedSessionContractImplementor session)
return key;
}
+ public @Nullable Object getTemporalIdentifier() {
+ return temporalIdentifier;
+ }
+
@Override
public final boolean isUnreferenced() {
return role == null;
@@ -615,7 +623,20 @@ protected final void initialize(final boolean writing) {
if ( !initialized ) {
withTemporarySessionIfNeeded(
() -> {
- session.initializeCollection( this, writing );
+ if ( temporalIdentifier != null ) {
+ final var influencers = session.getLoadQueryInfluencers();
+ final Object previous = influencers.getTemporalIdentifier();
+ influencers.setTemporalIdentifier( temporalIdentifier );
+ try {
+ session.initializeCollection( this, writing );
+ }
+ finally {
+ influencers.setTemporalIdentifier( previous );
+ }
+ }
+ else {
+ session.initializeCollection( this, writing );
+ }
return null;
}
);
diff --git a/hibernate-core/src/main/java/org/hibernate/dialect/Dialect.java b/hibernate-core/src/main/java/org/hibernate/dialect/Dialect.java
index 4965f57bf6d6..b02462a41fad 100644
--- a/hibernate-core/src/main/java/org/hibernate/dialect/Dialect.java
+++ b/hibernate-core/src/main/java/org/hibernate/dialect/Dialect.java
@@ -25,6 +25,7 @@
import org.hibernate.boot.model.relational.AuxiliaryDatabaseObject;
import org.hibernate.boot.model.relational.Sequence;
import org.hibernate.boot.spi.SessionFactoryOptions;
+import org.hibernate.audit.internal.AuditColumnFunction;
import org.hibernate.dialect.aggregate.AggregateSupport;
import org.hibernate.dialect.aggregate.AggregateSupportImpl;
import org.hibernate.dialect.function.CastFunction;
@@ -1402,6 +1403,17 @@ public void initializeFunctionRegistry(FunctionContributions functionContributio
functionRegistry.registerAlternateKey( "current_instant", "instant" ); //deprecated legacy!
functionRegistry.register( "sql", new SqlFunction() );
+
+ //audit column accessor functions for @Audited entities
+
+ functionRegistry.register(
+ AuditColumnFunction.TRANSACTION_ID_FUNCTION,
+ new AuditColumnFunction( AuditColumnFunction.TRANSACTION_ID_FUNCTION, true, typeConfiguration )
+ );
+ functionRegistry.register(
+ AuditColumnFunction.MODIFICATION_TYPE_FUNCTION,
+ new AuditColumnFunction( AuditColumnFunction.MODIFICATION_TYPE_FUNCTION, false, typeConfiguration )
+ );
}
/**
diff --git a/hibernate-core/src/main/java/org/hibernate/engine/internal/StatefulPersistenceContext.java b/hibernate-core/src/main/java/org/hibernate/engine/internal/StatefulPersistenceContext.java
index ee74c85bb8e3..0cece455f2bb 100644
--- a/hibernate-core/src/main/java/org/hibernate/engine/internal/StatefulPersistenceContext.java
+++ b/hibernate-core/src/main/java/org/hibernate/engine/internal/StatefulPersistenceContext.java
@@ -1041,7 +1041,7 @@ public void replaceCollection(CollectionPersister persister, PersistentCollectio
putCollectionEntry( collection, entry );
final Object key = collection.getKey();
if ( key != null ) {
- final var collectionKey = new CollectionKey( entry.getLoadedPersister(), key );
+ final var collectionKey = session.generateCollectionKey( entry.getLoadedPersister(), key );
final var old = addCollectionByKey( collectionKey, collection );
if ( old == null ) {
throw new HibernateException( "No collection for replacement found: " + collectionKey.getRole() );
@@ -1058,7 +1058,7 @@ public void replaceCollection(CollectionPersister persister, PersistentCollectio
*/
private void addCollection(PersistentCollection> coll, CollectionEntry entry, Object key) {
putCollectionEntry( coll, entry );
- final var collectionKey = new CollectionKey( entry.getLoadedPersister(), key );
+ final var collectionKey = session.generateCollectionKey( entry.getLoadedPersister(), key );
final var old = addCollectionByKey( collectionKey, coll );
if ( old != null ) {
if ( old == coll ) {
diff --git a/hibernate-core/src/main/java/org/hibernate/engine/spi/CollectionKey.java b/hibernate-core/src/main/java/org/hibernate/engine/spi/CollectionKey.java
index ff1f83ca8c28..21f6f894b330 100644
--- a/hibernate-core/src/main/java/org/hibernate/engine/spi/CollectionKey.java
+++ b/hibernate-core/src/main/java/org/hibernate/engine/spi/CollectionKey.java
@@ -19,10 +19,13 @@
/**
* Uniquely identifies a collection instance in a particular session.
+ *
+ * For temporal collections, use {@link TemporalCollectionKey} which includes a transaction identifier
+ * to isolate historical snapshots in the persistence context.
*
* @author Gavin King
*/
-public final class CollectionKey implements Serializable {
+public sealed class CollectionKey implements Serializable permits TemporalCollectionKey {
private final String role;
private final Object key;
private final @Nullable Type keyType;
@@ -34,15 +37,20 @@ public CollectionKey(CollectionPersister persister, Object key) {
persister.getRole(),
key,
persister.getKeyType().getTypeForEqualsHashCode(),
- persister.getFactory()
+ persister.getFactory(),
+ 0
);
}
- private CollectionKey(
+ /**
+ * @param txIdHashCode hash code contribution from the transaction identifier
+ */
+ CollectionKey(
String role,
@Nullable Object key,
@Nullable Type keyType,
- SessionFactoryImplementor factory) {
+ SessionFactoryImplementor factory,
+ int txIdHashCode) {
this.role = role;
if ( key == null ) {
throw new AssertionFailure( "null identifier for collection of role (" + role + ")" );
@@ -51,13 +59,19 @@ private CollectionKey(
this.keyType = keyType;
this.factory = factory;
//cache the hash-code
- this.hashCode = generateHashCode();
+ this.hashCode = generateHashCode( role, key, keyType, factory, txIdHashCode );
}
- private int generateHashCode() {
+ private static int generateHashCode(
+ String role,
+ Object key,
+ @Nullable Type keyType,
+ SessionFactoryImplementor factory,
+ int txIdHashCode) {
int result = 17;
result = 37 * result + role.hashCode();
- result = 37 * result + ( keyType == null ? key.hashCode() : keyType.getHashCode( key, factory ) );
+ result = 37 * result + (keyType == null ? key.hashCode() : keyType.getHashCode( key, factory ));
+ result = 37 * result + txIdHashCode;
return result;
}
@@ -69,6 +83,21 @@ public Object getKey() {
return key;
}
+ /**
+ * The audit transaction identifier for this key, or {@code null} for
+ * non-temporal collections.
+ */
+ public @Nullable Object getTransactionId() {
+ return null;
+ }
+
+ /**
+ * Whether this key refers to a temporal (historical) collection snapshot.
+ */
+ public boolean isTemporal() {
+ return false;
+ }
+
@Override
public String toString() {
final CollectionPersister collectionDescriptor =
@@ -82,14 +111,30 @@ public boolean equals(final @Nullable Object other) {
if ( this == other ) {
return true;
}
- if ( other == null || CollectionKey.class != other.getClass() ) {
+ if ( other == null || !(other instanceof CollectionKey that) ) {
return false;
}
- final CollectionKey that = (CollectionKey) other;
return that.role.equals( role )
- && ( this.key == that.key ||
- keyType == null ? this.key.equals( that.key ) : keyType.isEqual( this.key, that.key, factory ) );
+ && sameKey( that )
+ && sameTransactionId( that );
+ }
+
+ private boolean sameKey(final CollectionKey that) {
+ return this.key == that.key
+ || (keyType == null ? this.key.equals( that.key ) : keyType.isEqual( this.key, that.key, factory ));
+ }
+
+ /**
+ * Compare transaction identifiers without virtual dispatch, using
+ * instanceof on the sealed hierarchy for optimal JIT performance.
+ */
+ private boolean sameTransactionId(final CollectionKey otherKey) {
+ if ( this instanceof TemporalCollectionKey t1 ) {
+ return otherKey instanceof TemporalCollectionKey t2
+ && t1.getTransactionId().equals( t2.getTransactionId() );
+ }
+ return !(otherKey instanceof TemporalCollectionKey);
}
@Override
@@ -109,6 +154,7 @@ public void serialize(ObjectOutputStream oos) throws IOException {
oos.writeObject( role );
oos.writeObject( key );
oos.writeObject( keyType );
+ oos.writeObject( getTransactionId() );
}
/**
@@ -124,12 +170,13 @@ public void serialize(ObjectOutputStream oos) throws IOException {
public static CollectionKey deserialize(
ObjectInputStream ois,
SessionImplementor session) throws IOException, ClassNotFoundException {
- return new CollectionKey(
- (String) ois.readObject(),
- ois.readObject(),
- (Type) ois.readObject(),
- // Should never be able to be null
- session.getFactory()
- );
+ final String role = (String) ois.readObject();
+ final Object key = ois.readObject();
+ final Type keyType = (Type) ois.readObject();
+ final Object txId = ois.readObject();
+ final SessionFactoryImplementor factory = session.getFactory();
+ return txId != null
+ ? new TemporalCollectionKey( role, key, keyType, factory, txId )
+ : new CollectionKey( role, key, keyType, factory, 0 );
}
}
diff --git a/hibernate-core/src/main/java/org/hibernate/engine/spi/EntityKey.java b/hibernate-core/src/main/java/org/hibernate/engine/spi/EntityKey.java
index b7032632dc07..987218452732 100644
--- a/hibernate-core/src/main/java/org/hibernate/engine/spi/EntityKey.java
+++ b/hibernate-core/src/main/java/org/hibernate/engine/spi/EntityKey.java
@@ -26,11 +26,14 @@
*
* Performance considerations: lots of instances of this type are created at runtime. Make sure each one is as small as possible
* by storing just the essential needed.
+ *
+ * For temporal entities, use {@link TemporalEntityKey} which includes a transaction identifier
+ * to isolate historical snapshots in the persistence context.
*
* @author Gavin King
* @author Sanne Grinovero
*/
-public final class EntityKey implements Serializable {
+public sealed class EntityKey implements Serializable permits TemporalEntityKey {
private final Object identifier;
private final int hashCode;
@@ -38,29 +41,38 @@ public final class EntityKey implements Serializable {
/**
* Construct a unique identifier for an entity class instance.
- *
- * @apiNote This signature has changed to accommodate both entity mode and multi-tenancy, both of which relate to
- * the session to which this key belongs. To help minimize the impact of these changes in the future, the
- * {@link SessionImplementor#generateEntityKey} method was added to hide the session-specific changes.
+ *
+ * For temporal (audit) contexts, prefer
+ * {@link SharedSessionContractImplementor#generateEntityKey} which
+ * automatically creates a {@link TemporalEntityKey} when operating
+ * in a temporal context.
*
* @param id The entity id
* @param persister The entity persister
*/
public EntityKey(@Nullable Object id, EntityPersister persister) {
+ this( id, persister, 0 );
+ }
+
+ /**
+ * @param txIdHashCode hash code contribution from the transaction identifier
+ */
+ EntityKey(@Nullable Object id, EntityPersister persister, int txIdHashCode) {
this.persister = persister;
if ( id == null ) {
throw new AssertionFailure( "null identifier (" + persister.getEntityName() + ")" );
}
this.identifier = id;
- this.hashCode = generateHashCode();
+ this.hashCode = generateHashCode( id, persister, txIdHashCode );
}
- private int generateHashCode() {
+ private static int generateHashCode(Object id, EntityPersister persister, int txIdHashCode) {
int result = 17;
final String rootEntityName = persister.getRootEntityName();
result = 37 * result + rootEntityName.hashCode();
final Type identifierType = persister.getIdentifierType().getTypeForEqualsHashCode();
- result = 37 * result + ( identifierType == null ? identifier.hashCode() : identifierType.getHashCode( identifier, persister.getFactory() ) );
+ result = 37 * result + ( identifierType == null ? id.hashCode() : identifierType.getHashCode( id, persister.getFactory() ) );
+ result = 37 * result + txIdHashCode;
return result;
}
@@ -84,18 +96,34 @@ public EntityPersister getPersister() {
return persister;
}
+ /**
+ * The audit transaction identifier for this key, or {@code null} for
+ * non-temporal entities.
+ * When non-null, this entity is a read-only historical snapshot.
+ */
+ public @Nullable Object getTransactionId() {
+ return null;
+ }
+
+ /**
+ * Whether this key refers to a temporal (historical) snapshot.
+ */
+ public boolean isTemporal() {
+ return false;
+ }
+
@Override
public boolean equals(@Nullable Object other) {
if ( this == other ) {
return true;
}
- if ( other == null || EntityKey.class != other.getClass() ) {
+ if ( other == null || !( other instanceof EntityKey otherKey ) ) {
return false;
}
- final EntityKey otherKey = (EntityKey) other;
return samePersistentType( otherKey )
- && sameIdentifier( otherKey );
+ && sameIdentifier( otherKey )
+ && sameTransactionId( otherKey );
}
@@ -106,6 +134,18 @@ private boolean sameIdentifier(final EntityKey otherKey) {
|| identifierType != null && identifierType.isEqual( otherKey.identifier, this.identifier, persister.getFactory() ) );
}
+ /**
+ * Compare transaction identifiers without virtual dispatch, using
+ * instanceof on the sealed hierarchy for optimal JIT performance.
+ */
+ private boolean sameTransactionId(final EntityKey otherKey) {
+ if ( this instanceof TemporalEntityKey t1 ) {
+ return otherKey instanceof TemporalEntityKey t2
+ && t1.getTransactionId().equals( t2.getTransactionId() );
+ }
+ return !( otherKey instanceof TemporalEntityKey );
+ }
+
private boolean samePersistentType(final EntityKey otherKey) {
return otherKey.persister == persister
|| otherKey.persister.getRootEntityName().equals( persister.getRootEntityName() );
@@ -132,6 +172,7 @@ public String toString() {
public void serialize(ObjectOutputStream oos) throws IOException {
oos.writeObject( identifier );
oos.writeObject( persister.getEntityName() );
+ oos.writeObject( getTransactionId() );
}
/**
@@ -141,7 +182,7 @@ public void serialize(ObjectOutputStream oos) throws IOException {
* @param ois The stream from which to read the entry.
* @param sessionFactory The SessionFactory owning the Session being deserialized.
*
- * @return The deserialized EntityEntry
+ * @return The deserialized EntityKey
*
* @throws IOException Thrown by Java I/O
* @throws ClassNotFoundException Thrown by Java I/O
@@ -149,9 +190,12 @@ public void serialize(ObjectOutputStream oos) throws IOException {
public static EntityKey deserialize(ObjectInputStream ois, SessionFactoryImplementor sessionFactory) throws IOException, ClassNotFoundException {
final Object id = ois.readObject();
final String entityName = (String) ois.readObject();
+ final Object txId = ois.readObject();
final EntityPersister entityPersister =
sessionFactory.getMappingMetamodel()
- .getEntityDescriptor( entityName);
- return new EntityKey( id, entityPersister );
+ .getEntityDescriptor( entityName );
+ return txId != null
+ ? new TemporalEntityKey( id, entityPersister, txId )
+ : new EntityKey( id, entityPersister );
}
}
diff --git a/hibernate-core/src/main/java/org/hibernate/engine/spi/LoadQueryInfluencers.java b/hibernate-core/src/main/java/org/hibernate/engine/spi/LoadQueryInfluencers.java
index 2e7277842826..5c55f18d1de1 100644
--- a/hibernate-core/src/main/java/org/hibernate/engine/spi/LoadQueryInfluencers.java
+++ b/hibernate-core/src/main/java/org/hibernate/engine/spi/LoadQueryInfluencers.java
@@ -14,6 +14,7 @@
import org.hibernate.Filter;
import org.hibernate.Internal;
import org.hibernate.UnknownProfileException;
+import org.hibernate.audit.AuditLog;
import org.hibernate.graph.GraphSemantic;
import org.hibernate.graph.spi.RootGraphImplementor;
import org.hibernate.internal.FilterImpl;
@@ -106,6 +107,10 @@ public void setTemporalIdentifier(Object temporalIdentifier) {
this.temporalIdentifier = temporalIdentifier;
}
+ public boolean isAllRevisions() {
+ return temporalIdentifier == AuditLog.ALL_REVISIONS;
+ }
+
// internal fetch profile support ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/hibernate-core/src/main/java/org/hibernate/engine/spi/SessionDelegatorBaseImpl.java b/hibernate-core/src/main/java/org/hibernate/engine/spi/SessionDelegatorBaseImpl.java
index bf7d28366cce..ffa00d344601 100644
--- a/hibernate-core/src/main/java/org/hibernate/engine/spi/SessionDelegatorBaseImpl.java
+++ b/hibernate-core/src/main/java/org/hibernate/engine/spi/SessionDelegatorBaseImpl.java
@@ -4,6 +4,8 @@
*/
package org.hibernate.engine.spi;
+import org.hibernate.audit.spi.AuditWorkQueue;
+
import jakarta.persistence.CacheRetrieveMode;
import jakarta.persistence.CacheStoreMode;
import jakarta.persistence.EntityGraph;
@@ -53,6 +55,7 @@
import org.hibernate.graph.spi.RootGraphImplementor;
import org.hibernate.jdbc.ReturningWork;
import org.hibernate.jdbc.Work;
+import org.hibernate.persister.collection.CollectionPersister;
import org.hibernate.persister.entity.EntityPersister;
import org.hibernate.procedure.ProcedureCall;
import org.hibernate.query.MutationQuery;
@@ -112,6 +115,11 @@ public T execute(Callback callback) {
return delegate.execute( callback );
}
+ @Override
+ public AuditWorkQueue getAuditWorkQueue() {
+ return delegate.getAuditWorkQueue();
+ }
+
@Override
public SharedStatelessSessionBuilder statelessWithOptions() {
return delegate.statelessWithOptions();
@@ -142,6 +150,11 @@ public EntityKey generateEntityKey(Object id, EntityPersister persister) {
return delegate.generateEntityKey( id, persister );
}
+ @Override
+ public CollectionKey generateCollectionKey(CollectionPersister persister, Object key) {
+ return delegate.generateCollectionKey( persister, key );
+ }
+
@Override
public Interceptor getInterceptor() {
return delegate.getInterceptor();
diff --git a/hibernate-core/src/main/java/org/hibernate/engine/spi/SharedSessionContractImplementor.java b/hibernate-core/src/main/java/org/hibernate/engine/spi/SharedSessionContractImplementor.java
index 6e60a15f657e..722557d50d99 100644
--- a/hibernate-core/src/main/java/org/hibernate/engine/spi/SharedSessionContractImplementor.java
+++ b/hibernate-core/src/main/java/org/hibernate/engine/spi/SharedSessionContractImplementor.java
@@ -9,6 +9,7 @@
import jakarta.persistence.TransactionRequiredException;
import org.checkerframework.checker.nullness.qual.Nullable;
+import org.hibernate.audit.spi.AuditWorkQueue;
import org.hibernate.FlushMode;
import org.hibernate.HibernateException;
import org.hibernate.Incubating;
@@ -28,6 +29,7 @@
import org.hibernate.engine.jdbc.LobCreationContext;
import org.hibernate.engine.jdbc.spi.JdbcCoordinator;
import org.hibernate.engine.jdbc.spi.JdbcServices;
+import org.hibernate.persister.collection.CollectionPersister;
import org.hibernate.persister.entity.EntityPersister;
import org.hibernate.query.spi.QueryParameterBindings;
import org.hibernate.query.spi.QueryProducerImplementor;
@@ -221,7 +223,7 @@ default void checkOpen() {
* A transaction id representing the beginning of the current transaction,
* for use with {@linkplain org.hibernate.annotations.Temporal temporal}
* effectivity columns and with
- * {@linkplain org.hibernate.annotations.Audited#transactionId audit log
+ * {@linkplain org.hibernate.annotations.Audited.Table#transactionIdColumn audit log
* transaction id columns}.
*/
Object getCurrentTransactionIdentifier();
@@ -282,9 +284,22 @@ default void checkTransactionNeededForUpdateOperation(String exceptionMessage) {
@Incubating
TransactionCompletionCallbacksImplementor getTransactionCompletionCallbacksImplementor();
+ /**
+ * Access the transaction-scoped audit work queue for deferred
+ * audit row writes. Lazily initialized on first access.
+ *
+ * @since 7.4
+ */
+ @Incubating
+ AuditWorkQueue getAuditWorkQueue();
+
/**
* Instantiate an {@link EntityKey} with the given id and for the
* entity represented by the given {@link EntityPersister}.
+ *
+ * When operating in a temporal context, this will automatically
+ * create a {@link TemporalEntityKey} that includes the transaction
+ * identifier.
*
* @param id The entity id
* @param persister The entity persister
@@ -293,6 +308,21 @@ default void checkTransactionNeededForUpdateOperation(String exceptionMessage) {
*/
EntityKey generateEntityKey(Object id, EntityPersister persister);
+ /**
+ * Instantiate a {@link CollectionKey} with the given key and for the
+ * collection represented by the given {@link CollectionPersister}.
+ *
+ * When operating in a temporal context, this will automatically
+ * create a {@link TemporalCollectionKey} that includes the transaction
+ * identifier.
+ *
+ * @param persister The collection persister
+ * @param key The collection key (owner FK)
+ *
+ * @return The collection key
+ */
+ CollectionKey generateCollectionKey(CollectionPersister persister, Object key);
+
/**
* Retrieves the {@link Interceptor} associated with this session.
*/
diff --git a/hibernate-core/src/main/java/org/hibernate/engine/spi/SharedSessionDelegatorBaseImpl.java b/hibernate-core/src/main/java/org/hibernate/engine/spi/SharedSessionDelegatorBaseImpl.java
index a7567b46f3f7..0a61156850cc 100644
--- a/hibernate-core/src/main/java/org/hibernate/engine/spi/SharedSessionDelegatorBaseImpl.java
+++ b/hibernate-core/src/main/java/org/hibernate/engine/spi/SharedSessionDelegatorBaseImpl.java
@@ -4,6 +4,8 @@
*/
package org.hibernate.engine.spi;
+import org.hibernate.audit.spi.AuditWorkQueue;
+
import jakarta.persistence.EntityGraph;
import jakarta.persistence.TypedQueryReference;
import jakarta.persistence.criteria.CriteriaDelete;
@@ -33,6 +35,7 @@
import org.hibernate.graph.spi.RootGraphImplementor;
import org.hibernate.jdbc.ReturningWork;
import org.hibernate.jdbc.Work;
+import org.hibernate.persister.collection.CollectionPersister;
import org.hibernate.persister.entity.EntityPersister;
import org.hibernate.procedure.ProcedureCall;
import org.hibernate.query.MutationQuery;
@@ -77,6 +80,11 @@ protected SharedSessionContract delegate() {
return delegate;
}
+ @Override
+ public AuditWorkQueue getAuditWorkQueue() {
+ return delegate.getAuditWorkQueue();
+ }
+
@Override
public SharedStatelessSessionBuilder statelessWithOptions() {
return delegate.statelessWithOptions();
@@ -433,6 +441,11 @@ public EntityKey generateEntityKey(Object id, EntityPersister persister) {
return delegate.generateEntityKey( id, persister );
}
+ @Override
+ public CollectionKey generateCollectionKey(CollectionPersister persister, Object key) {
+ return delegate.generateCollectionKey( persister, key );
+ }
+
@Override
public Interceptor getInterceptor() {
return delegate.getInterceptor();
diff --git a/hibernate-core/src/main/java/org/hibernate/engine/spi/TemporalCollectionKey.java b/hibernate-core/src/main/java/org/hibernate/engine/spi/TemporalCollectionKey.java
new file mode 100644
index 000000000000..e27fbd7b6e88
--- /dev/null
+++ b/hibernate-core/src/main/java/org/hibernate/engine/spi/TemporalCollectionKey.java
@@ -0,0 +1,69 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.engine.spi;
+
+import org.hibernate.persister.collection.CollectionPersister;
+import org.hibernate.type.Type;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * A {@link CollectionKey} for a temporal (historical) snapshot of a collection,
+ * loaded from an audit table at a specific transaction identifier.
+ *
+ * The transaction identifier is included in {@code equals()}/{@code hashCode()}
+ * so that the persistence context naturally isolates collections at different
+ * points in time.
+ *
+ * @author Marco Belladelli
+ * @see CollectionKey
+ * @since 7.4
+ */
+public final class TemporalCollectionKey extends CollectionKey {
+ private final Object txId;
+
+ /**
+ * Construct a unique identifier for a temporal snapshot of a collection.
+ *
+ * @param persister The collection persister
+ * @param key The collection key (owner FK)
+ * @param txId The audit transaction identifier (must not be null)
+ */
+ public TemporalCollectionKey(CollectionPersister persister, Object key, Object txId) {
+ super(
+ persister.getRole(),
+ key,
+ persister.getKeyType().getTypeForEqualsHashCode(),
+ persister.getFactory(),
+ txId.hashCode()
+ );
+ this.txId = txId;
+ }
+
+ TemporalCollectionKey(
+ String role,
+ @Nullable Object key,
+ @Nullable Type keyType,
+ SessionFactoryImplementor factory,
+ Object txId) {
+ super( role, key, keyType, factory, txId.hashCode() );
+ this.txId = txId;
+ }
+
+ @Override
+ public Object getTransactionId() {
+ return txId;
+ }
+
+ @Override
+ public boolean isTemporal() {
+ return true;
+ }
+
+ @Override
+ public String toString() {
+ return super.toString() + "@tx" + txId;
+ }
+}
diff --git a/hibernate-core/src/main/java/org/hibernate/engine/spi/TemporalEntityKey.java b/hibernate-core/src/main/java/org/hibernate/engine/spi/TemporalEntityKey.java
new file mode 100644
index 000000000000..344f1d7ec59c
--- /dev/null
+++ b/hibernate-core/src/main/java/org/hibernate/engine/spi/TemporalEntityKey.java
@@ -0,0 +1,52 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.engine.spi;
+
+import org.hibernate.persister.entity.EntityPersister;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * An {@link EntityKey} for a temporal (historical) snapshot of an entity,
+ * loaded from an audit table at a specific transaction identifier.
+ *
+ * The transaction identifier is included in {@code equals()}/{@code hashCode()}
+ * so that the persistence context naturally isolates entities at different
+ * points in time. Entities with a temporal key are always read-only.
+ *
+ * @author Marco Belladelli
+ * @see EntityKey
+ * @since 7.4
+ */
+public final class TemporalEntityKey extends EntityKey {
+ private final Object txId;
+
+ /**
+ * Construct a unique identifier for a temporal snapshot of an entity.
+ *
+ * @param id The entity id
+ * @param persister The entity persister
+ * @param txId The audit transaction identifier (must not be null)
+ */
+ public TemporalEntityKey(@Nullable Object id, EntityPersister persister, Object txId) {
+ super( id, persister, txId.hashCode() );
+ this.txId = txId;
+ }
+
+ @Override
+ public Object getTransactionId() {
+ return txId;
+ }
+
+ @Override
+ public boolean isTemporal() {
+ return true;
+ }
+
+ @Override
+ public String toString() {
+ return super.toString() + "@tx" + txId;
+ }
+}
diff --git a/hibernate-core/src/main/java/org/hibernate/event/internal/DefaultFlushEntityEventListener.java b/hibernate-core/src/main/java/org/hibernate/event/internal/DefaultFlushEntityEventListener.java
index 80b16701cd1c..ed5b709fc0fa 100644
--- a/hibernate-core/src/main/java/org/hibernate/event/internal/DefaultFlushEntityEventListener.java
+++ b/hibernate-core/src/main/java/org/hibernate/event/internal/DefaultFlushEntityEventListener.java
@@ -448,7 +448,7 @@ private static boolean hasDirtyCollections(FlushEntityEvent event, EntityPersist
private boolean isCollectionDirtyCheckNecessary(EntityPersister persister, Status status) {
return ( status == Status.MANAGED || status == Status.READ_ONLY )
- && persister.isVersioned()
+ && ( persister.isVersioned() || persister.getAuditMapping() != null )
&& persister.hasCollections();
}
diff --git a/hibernate-core/src/main/java/org/hibernate/generator/internal/CurrentTimestampGeneration.java b/hibernate-core/src/main/java/org/hibernate/generator/internal/CurrentTimestampGeneration.java
index 032ad835e931..b4d52a2daee2 100644
--- a/hibernate-core/src/main/java/org/hibernate/generator/internal/CurrentTimestampGeneration.java
+++ b/hibernate-core/src/main/java/org/hibernate/generator/internal/CurrentTimestampGeneration.java
@@ -318,6 +318,10 @@ else if ( clazz == YearMonth.class ) {
else if ( clazz == MonthDay.class ) {
return () -> MonthDay.now( baseClock == null ? Clock.systemDefaultZone() : baseClock );
}
+ else if ( clazz == Long.class || clazz == long.class ) {
+ final var clock = baseClock == null ? Clock.systemDefaultZone() : baseClock;
+ return clock::millis;
+ }
// DEPRECATED:
else if ( clazz == Date.class ) {
final var clock = ClockHelper.forPrecision( baseClock, precision, 3 );
diff --git a/hibernate-core/src/main/java/org/hibernate/internal/AbstractSharedSessionContract.java b/hibernate-core/src/main/java/org/hibernate/internal/AbstractSharedSessionContract.java
index 39b5248113af..d7f5a331d1f1 100644
--- a/hibernate-core/src/main/java/org/hibernate/internal/AbstractSharedSessionContract.java
+++ b/hibernate-core/src/main/java/org/hibernate/internal/AbstractSharedSessionContract.java
@@ -13,6 +13,8 @@
import org.checkerframework.checker.nullness.qual.Nullable;
import org.hibernate.CacheMode;
import org.hibernate.EntityNameResolver;
+import org.hibernate.audit.AuditLog;
+import org.hibernate.audit.spi.AuditWorkQueue;
import org.hibernate.Filter;
import org.hibernate.HibernateException;
import org.hibernate.Interceptor;
@@ -39,7 +41,10 @@
import org.hibernate.engine.jdbc.internal.JdbcCoordinatorImpl;
import org.hibernate.engine.jdbc.spi.JdbcCoordinator;
import org.hibernate.engine.jdbc.spi.JdbcServices;
+import org.hibernate.engine.spi.CollectionKey;
import org.hibernate.engine.spi.EntityKey;
+import org.hibernate.engine.spi.TemporalCollectionKey;
+import org.hibernate.engine.spi.TemporalEntityKey;
import org.hibernate.engine.spi.ExceptionConverter;
import org.hibernate.engine.spi.LoadQueryInfluencers;
import org.hibernate.engine.spi.SessionEventListenerManager;
@@ -49,6 +54,7 @@
import org.hibernate.engine.spi.StatelessSessionImplementor;
import org.hibernate.engine.transaction.internal.TransactionImpl;
import org.hibernate.event.monitor.spi.EventMonitor;
+import org.hibernate.temporal.spi.TransactionIdentifierSupplier;
import org.hibernate.graph.GraphSemantic;
import org.hibernate.graph.RootGraph;
import org.hibernate.graph.internal.RootGraphImpl;
@@ -640,7 +646,6 @@ private Object generateCurrentTransactionIdentifier() {
@Override
public void afterTransactionBegin() {
- initializeCurrentTransactionIdentifier();
}
protected void initializeCurrentTransactionIdentifier() {
@@ -851,9 +856,30 @@ public void beforeReleaseConnection(Connection connection) throws SQLException {
}
}
+ private AuditWorkQueue auditWorkQueue;
+
+ @Override
+ public AuditWorkQueue getAuditWorkQueue() {
+ if ( auditWorkQueue == null ) {
+ auditWorkQueue = new AuditWorkQueue();
+ }
+ return auditWorkQueue;
+ }
+
@Override
public EntityKey generateEntityKey(Object id, EntityPersister persister) {
- return new EntityKey( id, persister );
+ final Object temporalId = getLoadQueryInfluencers().getTemporalIdentifier();
+ return temporalId != null && temporalId != AuditLog.ALL_REVISIONS
+ ? new TemporalEntityKey( id, persister, temporalId )
+ : new EntityKey( id, persister );
+ }
+
+ @Override
+ public CollectionKey generateCollectionKey(CollectionPersister persister, Object key) {
+ final Object temporalId = getLoadQueryInfluencers().getTemporalIdentifier();
+ return temporalId != null && temporalId != AuditLog.ALL_REVISIONS
+ ? new TemporalCollectionKey( persister, key, temporalId )
+ : new CollectionKey( persister, key );
}
@Override
diff --git a/hibernate-core/src/main/java/org/hibernate/internal/SessionImpl.java b/hibernate-core/src/main/java/org/hibernate/internal/SessionImpl.java
index dd6c6a6904fc..b224b5fe07c3 100644
--- a/hibernate-core/src/main/java/org/hibernate/internal/SessionImpl.java
+++ b/hibernate-core/src/main/java/org/hibernate/internal/SessionImpl.java
@@ -2746,9 +2746,8 @@ private void writeObject(ObjectOutputStream oos) throws IOException {
oos.defaultWriteObject();
PersistenceContexts.serialize( persistenceContext, oos );
- actionQueue.serialize( oos );
-
oos.writeObject( loadQueryInfluencers );
+ actionQueue.serialize( oos );
}
/**
@@ -2768,9 +2767,8 @@ private void readObject(ObjectInputStream ois) throws IOException, ClassNotFound
ois.defaultReadObject();
persistenceContext = PersistenceContexts.deserialize( ois, this );
- actionQueue = ActionQueue.deserialize( ois, this );
-
loadQueryInfluencers = (LoadQueryInfluencers) ois.readObject();
+ actionQueue = ActionQueue.deserialize( ois, this );
// LoadQueryInfluencers#getEnabledFilters() tries to validate each enabled
// filter, which will fail when called before FilterImpl#afterDeserialize( factory );
diff --git a/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/AbstractCollectionBatchLoader.java b/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/AbstractCollectionBatchLoader.java
index e7756a3e585b..d6e6565afc55 100644
--- a/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/AbstractCollectionBatchLoader.java
+++ b/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/AbstractCollectionBatchLoader.java
@@ -72,8 +72,8 @@ public int getKeyJdbcCount() {
abstract void initializeKeys(Object key, Object[] keysToInitialize, SharedSessionContractImplementor session);
- private CollectionKey collectionKey(Object key) {
- return new CollectionKey( getLoadable().getCollectionDescriptor(), key );
+ private CollectionKey collectionKey(Object key, SharedSessionContractImplementor session) {
+ return session.generateCollectionKey( getLoadable().getCollectionDescriptor(), key );
}
@Override
@@ -90,7 +90,7 @@ public PersistentCollection> load(Object key, SharedSessionContractImplementor
initializeKeys( key, keys, session );
finishInitializingKeys( keys, session );
- return session.getPersistenceContext().getCollection( collectionKey( key ) );
+ return session.getPersistenceContext().getCollection( collectionKey( key, session ) );
}
abstract void finishInitializingKeys(Object[] key, SharedSessionContractImplementor session);
@@ -103,7 +103,7 @@ protected void finishInitializingKey(Object key, SharedSessionContractImplemento
}
final var persistenceContext = session.getPersistenceContext();
- final var collection = persistenceContext.getCollection( collectionKey( key ) );
+ final var collection = persistenceContext.getCollection( collectionKey( key, session ) );
if ( !collection.wasInitialized() ) {
final var entry = persistenceContext.getCollectionEntry( collection );
collection.initializeEmptyCollection( entry.getLoadedPersister() );
diff --git a/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/AbstractMultiIdEntityLoader.java b/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/AbstractMultiIdEntityLoader.java
index d32aa47b0929..a462740a698a 100644
--- a/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/AbstractMultiIdEntityLoader.java
+++ b/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/AbstractMultiIdEntityLoader.java
@@ -133,7 +133,7 @@ private List orderedMultiLoad(
for ( int i = 0; i < ids.length; i++ ) {
final Object id = coerce( idType, ids[i] );
- final var entityKey = new EntityKey( id, persister );
+ final var entityKey = session.generateEntityKey( id, persister );
if ( !loadFromEnabledCaches( loadOptions, session, lockOptions, entityKey, results, i ) ) {
// if we did not hit any of the continues above,
// then we need to batch load the entity state.
@@ -352,7 +352,7 @@ private List unresolvedIds(
lockOptions,
resolutionConsumer,
id,
- new EntityKey( id, persister ),
+ session.generateEntityKey( id, persister ),
unresolvedIds,
i,
session
diff --git a/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/CollectionBatchLoaderArrayParam.java b/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/CollectionBatchLoaderArrayParam.java
index 679ddd0aa794..238dcbdc0f45 100644
--- a/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/CollectionBatchLoaderArrayParam.java
+++ b/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/CollectionBatchLoaderArrayParam.java
@@ -169,11 +169,11 @@ private PersistentCollection> loadWithConversion(
}
}
return session.getPersistenceContext()
- .getCollection( collectionKey( keyBeingLoaded ) );
+ .getCollection( collectionKey( keyBeingLoaded, session ) );
}
- private CollectionKey collectionKey(Object keyBeingLoaded) {
- return new CollectionKey( getLoadable().getCollectionDescriptor(), keyBeingLoaded );
+ private CollectionKey collectionKey(Object keyBeingLoaded, SharedSessionContractImplementor session) {
+ return session.generateCollectionKey( getLoadable().getCollectionDescriptor(), keyBeingLoaded );
}
@Override
diff --git a/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/CollectionLoaderSingleKey.java b/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/CollectionLoaderSingleKey.java
index 8dc4589f64a9..fef334c8eabe 100644
--- a/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/CollectionLoaderSingleKey.java
+++ b/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/CollectionLoaderSingleKey.java
@@ -99,7 +99,7 @@ public JdbcParametersList getJdbcParameters() {
@Override
public PersistentCollection> load(Object key, SharedSessionContractImplementor session) {
- final var collectionKey = new CollectionKey( attributeMapping.getCollectionDescriptor(), key );
+ final var collectionKey = session.generateCollectionKey( attributeMapping.getCollectionDescriptor(), key );
final var jdbcParameterBindings = new JdbcParameterBindingsImpl( keyJdbcCount );
int offset = jdbcParameterBindings.registerParametersForEachJdbcValue(
diff --git a/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/CollectionLoaderSubSelectFetch.java b/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/CollectionLoaderSubSelectFetch.java
index dbef2e7ca33f..9becd017c395 100644
--- a/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/CollectionLoaderSubSelectFetch.java
+++ b/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/CollectionLoaderSubSelectFetch.java
@@ -78,7 +78,7 @@ protected SelectStatement getSqlAst() {
@Override
public PersistentCollection> load(Object triggerKey, SharedSessionContractImplementor session) {
- final var collectionKey = new CollectionKey( attributeMapping.getCollectionDescriptor(), triggerKey );
+ final var collectionKey = session.generateCollectionKey( attributeMapping.getCollectionDescriptor(), triggerKey );
final var sessionFactory = session.getFactory();
final var jdbcServices = sessionFactory.getJdbcServices();
@@ -99,7 +99,7 @@ public PersistentCollection> load(Object triggerKey, SharedSessionContractImpl
// there was one, so we want to make sure to prepare the corresponding collection
// reference for reading
for ( var key : registeredFetch.getResultingEntityKeys() ) {
- final var containedCollection = persistenceContext.getCollection( collectionKey( key ) );
+ final var containedCollection = persistenceContext.getCollection( collectionKey( key, session ) );
if ( containedCollection != null && containedCollection != collection ) {
containedCollection.beginRead();
containedCollection.beforeInitialize( getLoadable().getCollectionDescriptor(), -1 );
@@ -151,8 +151,8 @@ public PersistentCollection> load(Object triggerKey, SharedSessionContractImpl
return collection;
}
- private CollectionKey collectionKey(EntityKey key) {
- return new CollectionKey( attributeMapping.getCollectionDescriptor(), key.getIdentifier() );
+ private CollectionKey collectionKey(EntityKey key, SharedSessionContractImplementor session) {
+ return session.generateCollectionKey( attributeMapping.getCollectionDescriptor(), key.getIdentifier() );
}
}
diff --git a/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/LoaderSelectBuilder.java b/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/LoaderSelectBuilder.java
index be37a2849b8f..ac5f9ba959eb 100644
--- a/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/LoaderSelectBuilder.java
+++ b/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/LoaderSelectBuilder.java
@@ -72,6 +72,7 @@
import static java.util.Collections.singletonList;
+import static org.hibernate.boot.model.internal.AuditHelper.isFetchableAuditExcluded;
import static org.hibernate.query.results.internal.ResultsHelper.attributeName;
/**
@@ -805,7 +806,7 @@ private FetchableConsumer createFetchableConsumer(
LoaderSqlAstCreationState creationState,
ImmutableFetchList.Builder fetches) {
return (fetchable, isKeyFetchable, isABag) -> {
- if ( !fetchable.isSelectable() ) {
+ if ( !fetchable.isSelectable() || isFetchableAuditExcluded( fetchable, loadQueryInfluencers ) ) {
return;
}
diff --git a/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/SingleIdEntityLoaderStandardImpl.java b/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/SingleIdEntityLoaderStandardImpl.java
index 4971c747004b..539711711897 100644
--- a/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/SingleIdEntityLoaderStandardImpl.java
+++ b/hibernate-core/src/main/java/org/hibernate/loader/ast/internal/SingleIdEntityLoaderStandardImpl.java
@@ -83,6 +83,12 @@ public SingleIdLoadPlan resolveLoadPlan(LockOptions lockOptions, LoadQueryInf
// precedence than even "internal" fetch profiles.
return loadPlanCreator.apply( lockOptions, influencers );
}
+ else if ( influencers.getTemporalIdentifier() != null
+ && getLoadable().getEntityPersister().getAuditMapping() != null ) {
+ // Audit context requires a fresh plan that excludes @Audited.Excluded
+ // columns (which don't exist in the audit table)
+ return loadPlanCreator.apply( lockOptions, influencers );
+ }
else if ( influencers.hasEnabledCascadingFetchProfile()
// and if it's a non-exclusive (optimistic) lock
&& LockMode.PESSIMISTIC_READ.greaterThan( lockOptions.getLockMode() ) ) {
diff --git a/hibernate-core/src/main/java/org/hibernate/mapping/AuxiliaryTableHolder.java b/hibernate-core/src/main/java/org/hibernate/mapping/AuxiliaryTableHolder.java
new file mode 100644
index 000000000000..6523307530e4
--- /dev/null
+++ b/hibernate-core/src/main/java/org/hibernate/mapping/AuxiliaryTableHolder.java
@@ -0,0 +1,25 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.mapping;
+
+/**
+ * Something that can have an associated auxiliary table,
+ * for example, an audit table or a temporal history table.
+ *
+ * @author Gavin King
+ * @author Marco Belladelli
+ * @see Stateful
+ * @since 7.4
+ */
+public interface AuxiliaryTableHolder {
+
+ Table getAuxiliaryTable();
+
+ void setAuxiliaryTable(Table auxiliaryTable);
+
+ Column getAuxiliaryColumn(String name);
+
+ void addAuxiliaryColumn(String name, Column column);
+}
diff --git a/hibernate-core/src/main/java/org/hibernate/mapping/Join.java b/hibernate-core/src/main/java/org/hibernate/mapping/Join.java
index dfe48165b9a7..0007c8bf40c5 100644
--- a/hibernate-core/src/main/java/org/hibernate/mapping/Join.java
+++ b/hibernate-core/src/main/java/org/hibernate/mapping/Join.java
@@ -6,7 +6,9 @@
import java.io.Serializable;
import java.util.ArrayList;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.function.Supplier;
import org.hibernate.MappingException;
@@ -25,13 +27,15 @@
*
* @author Gavin King
*/
-public class Join implements AttributeContainer, Serializable {
+public class Join implements AttributeContainer, AuxiliaryTableHolder, Serializable {
private static final Alias PK_ALIAS = new Alias(15, "PK");
private final ArrayList properties = new ArrayList<>();
private final ArrayList declaredProperties = new ArrayList<>();
private Table table;
+ private Table auxiliaryTable;
+ private Map auxiliaryColumns;
private KeyValue key;
private PersistentClass persistentClass;
private boolean inverse;
@@ -93,6 +97,29 @@ public void setTable(Table table) {
this.table = table;
}
+ @Override
+ public Table getAuxiliaryTable() {
+ return auxiliaryTable;
+ }
+
+ @Override
+ public void setAuxiliaryTable(Table auxiliaryTable) {
+ this.auxiliaryTable = auxiliaryTable;
+ }
+
+ @Override
+ public Column getAuxiliaryColumn(String name) {
+ return auxiliaryColumns == null ? null : auxiliaryColumns.get( name );
+ }
+
+ @Override
+ public void addAuxiliaryColumn(String name, Column column) {
+ if ( auxiliaryColumns == null ) {
+ auxiliaryColumns = new HashMap<>();
+ }
+ auxiliaryColumns.put( name, column );
+ }
+
public KeyValue getKey() {
return key;
}
diff --git a/hibernate-core/src/main/java/org/hibernate/mapping/PersistentClass.java b/hibernate-core/src/main/java/org/hibernate/mapping/PersistentClass.java
index fee402e687f4..8c028cc78408 100644
--- a/hibernate-core/src/main/java/org/hibernate/mapping/PersistentClass.java
+++ b/hibernate-core/src/main/java/org/hibernate/mapping/PersistentClass.java
@@ -7,6 +7,7 @@
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collection;
+import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@@ -48,7 +49,8 @@
* @author Gavin King
*/
public abstract sealed class PersistentClass
- implements IdentifiableTypeClass, AttributeContainer, Filterable, MetaAttributable, Contributable, Serializable
+ implements IdentifiableTypeClass, AttributeContainer, AuxiliaryTableHolder,
+ Filterable, MetaAttributable, Contributable, Serializable
permits RootClass, Subclass {
private static final Alias PK_ALIAS = new Alias( 15, "PK" );
@@ -100,6 +102,8 @@ public abstract sealed class PersistentClass
private boolean hasSubselectLoadableCollections;
private Component identifierMapper;
private List callbackDefinitions;
+ private Table auxiliaryTable;
+ private Map auxiliaryColumns;
private final List checkConstraints = new ArrayList<>();
@@ -301,6 +305,29 @@ public boolean contains(Property property) {
public abstract Table getTable();
+ @Override
+ public Table getAuxiliaryTable() {
+ return auxiliaryTable;
+ }
+
+ @Override
+ public void setAuxiliaryTable(Table auxiliaryTable) {
+ this.auxiliaryTable = auxiliaryTable;
+ }
+
+ @Override
+ public Column getAuxiliaryColumn(String name) {
+ return auxiliaryColumns == null ? null : auxiliaryColumns.get( name );
+ }
+
+ @Override
+ public void addAuxiliaryColumn(String name, Column column) {
+ if ( auxiliaryColumns == null ) {
+ auxiliaryColumns = new HashMap<>();
+ }
+ auxiliaryColumns.put( name, column );
+ }
+
public String getEntityName() {
return entityName;
}
diff --git a/hibernate-core/src/main/java/org/hibernate/mapping/RootClass.java b/hibernate-core/src/main/java/org/hibernate/mapping/RootClass.java
index fdf3a04f39e9..834770ca5678 100644
--- a/hibernate-core/src/main/java/org/hibernate/mapping/RootClass.java
+++ b/hibernate-core/src/main/java/org/hibernate/mapping/RootClass.java
@@ -4,10 +4,8 @@
*/
package org.hibernate.mapping;
-import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
-import java.util.Map;
import java.util.Set;
import org.hibernate.MappingException;
@@ -56,9 +54,7 @@ public final class RootClass extends PersistentClass implements TableOwner, Soft
private SoftDeleteType softDeleteStrategy;
private Class extends StateManagement> stateManagementType;
- private Table auxiliaryTable;
private boolean partitioned;
- private Map auxiliaryColumns;
private String auxiliaryColumnInPrimaryKey;
private boolean primaryKeyDisabled;
@@ -496,23 +492,4 @@ public Class extends StateManagement> getStateManagementType() {
return stateManagementType;
}
- public Table getAuxiliaryTable() {
- return auxiliaryTable;
- }
-
- public void setAuxiliaryTable(Table auxiliaryTable) {
- this.auxiliaryTable = auxiliaryTable;
- }
-
- public Column getAuxiliaryColumn(String column) {
- return auxiliaryColumns == null ? null
- : auxiliaryColumns.get( column );
- }
-
- public void addAuxiliaryColumn(String name, Column column) {
- if ( auxiliaryColumns == null ) {
- auxiliaryColumns = new HashMap<>();
- }
- auxiliaryColumns.put( name, column );
- }
}
diff --git a/hibernate-core/src/main/java/org/hibernate/mapping/Stateful.java b/hibernate-core/src/main/java/org/hibernate/mapping/Stateful.java
index 22d9c3ef2df7..d2811cc52475 100644
--- a/hibernate-core/src/main/java/org/hibernate/mapping/Stateful.java
+++ b/hibernate-core/src/main/java/org/hibernate/mapping/Stateful.java
@@ -21,26 +21,18 @@
*
* @since 7.4
*/
-public interface Stateful {
+public interface Stateful extends AuxiliaryTableHolder {
void setStateManagementType(Class extends StateManagement> stateManagementType);
Class extends StateManagement> getStateManagementType();
- Table getAuxiliaryTable();
-
- void setAuxiliaryTable(Table auxiliaryTable);
-
Table getMainTable();
boolean isMainTablePartitioned();
void setMainTablePartitioned(boolean partitioned);
- Column getAuxiliaryColumn(String column);
-
- void addAuxiliaryColumn(String name, Column column);
-
boolean isAuxiliaryColumnInPrimaryKey();
void setAuxiliaryColumnInPrimaryKey(String key);
diff --git a/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/AuditMapping.java b/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/AuditMapping.java
index 5c1761fb19ed..2ab4b3fffe8f 100644
--- a/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/AuditMapping.java
+++ b/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/AuditMapping.java
@@ -4,22 +4,74 @@
*/
package org.hibernate.metamodel.mapping;
+import java.util.List;
+
import org.hibernate.Incubating;
+import org.hibernate.audit.spi.AuditEntityLoader;
+import org.hibernate.sql.ast.spi.SqlAliasBaseGenerator;
+import org.hibernate.sql.ast.tree.expression.Expression;
+import org.hibernate.sql.ast.tree.from.TableGroupProducer;
+import org.hibernate.sql.ast.tree.from.TableReference;
+import org.hibernate.sql.ast.tree.predicate.Predicate;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
/**
* Metadata about audit log tables for entities and collections enabled for audit logging.
*
- * @see org.hibernate.annotations.Audited
- *
* @author Gavin King
- *
+ * @see org.hibernate.annotations.Audited
* @since 7.4
*/
@Incubating
public interface AuditMapping extends AuxiliaryMapping {
- SelectableMapping getTransactionIdMapping();
+ /**
+ * Get the transaction ID selectable mapping for the given original table.
+ */
+ SelectableMapping getTransactionIdMapping(String originalTableName);
+
+ /**
+ * Get the modification type selectable mapping for the given original table,
+ * or {@code null} if the table does not carry a modification type column.
+ */
+ @Nullable
+ SelectableMapping getModificationTypeMapping(String originalTableName);
+
+ /**
+ * Get the transaction end selectable mapping for the given original table,
+ * or {@code null} if the validity audit strategy is not active.
+ */
+ @Nullable
+ SelectableMapping getTransactionEndMapping(String originalTableName);
+
+ /**
+ * Get the transaction end timestamp selectable mapping for the given original table,
+ * or {@code null} if not configured.
+ */
+ @Nullable
+ SelectableMapping getTransactionEndTimestampMapping(String originalTableName);
- SelectableMapping getModificationTypeMapping();
+ /**
+ * Get the entity loader for single-entity audit queries.
+ */
+ AuditEntityLoader getEntityLoader();
+ /**
+ * Build the temporal restriction predicate for the given table
+ * with an explicit upper bound expression.
+ *
+ * Used by {@link org.hibernate.audit.spi.AuditEntityLoader}
+ * implementations to build audit-specific load plans.
+ *
+ * @param includeDeletions if {@code true}, omit the {@code REVTYPE <> DEL} filter
+ */
+ Predicate createRestriction(
+ TableGroupProducer tableGroupProducer,
+ TableReference tableReference,
+ List keySelectables,
+ SqlAliasBaseGenerator sqlAliasBaseGenerator,
+ String originalTableName,
+ Expression upperBound,
+ boolean includeDeletions);
}
diff --git a/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/AuxiliaryMapping.java b/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/AuxiliaryMapping.java
index 8ff3e391a6d8..0cee4023185a 100644
--- a/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/AuxiliaryMapping.java
+++ b/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/AuxiliaryMapping.java
@@ -4,6 +4,10 @@
*/
package org.hibernate.metamodel.mapping;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+
import org.hibernate.Incubating;
import org.hibernate.engine.spi.LoadQueryInfluencers;
import org.hibernate.persister.entity.EntityPersister;
@@ -12,15 +16,12 @@
import org.hibernate.sql.ast.spi.SqlAstCreationState;
import org.hibernate.sql.ast.tree.from.LazyTableGroup;
import org.hibernate.sql.ast.tree.from.NamedTableReference;
-import org.hibernate.sql.ast.tree.from.StandardTableGroup;
import org.hibernate.sql.ast.tree.from.TableGroup;
import org.hibernate.sql.ast.tree.from.TableGroupJoin;
+import org.hibernate.sql.ast.tree.from.TableReferenceJoin;
import org.hibernate.sql.ast.tree.predicate.Predicate;
import org.hibernate.sql.model.ast.builder.MutationGroupBuilder;
-import java.util.function.Consumer;
-import java.util.function.Supplier;
-
/**
* Unified mapping contract for state management strategies (soft-delete, temporal, audit).
*
@@ -31,10 +32,20 @@
@Incubating
public interface AuxiliaryMapping {
/**
- * The name of the table to which this auxiliary mapping applies.
+ * The name of the primary auxiliary table.
+ * For multi-table strategies (e.g. audit), use {@link #resolveTableName(String)} to resolve per-table instead.
*/
String getTableName();
+ /**
+ * Resolve the auxiliary table name for the given original table.
+ * For multi-table inheritance or {@code @SecondaryTable}, each table
+ * may have its own auxiliary table. Defaults to {@link #getTableName()}.
+ */
+ default String resolveTableName(String originalTableName) {
+ return getTableName();
+ }
+
default void addToInsertGroup(MutationGroupBuilder insertGroupBuilder, EntityPersister persister) {}
void applyPredicate(
@@ -63,9 +74,33 @@ void applyPredicate(
void applyPredicate(
Supplier> predicateCollector,
SqlAstCreationState creationState,
- StandardTableGroup tableGroup,
+ TableGroup tableGroup,
NamedTableReference rootTableReference, EntityMappingType entityMappingType);
+ /**
+ * Apply the auxiliary restriction to a joined table reference.
+ * Used for JOINED inheritance where each table in the hierarchy
+ * has its own auxiliary table.
+ *
+ * @param originalTableName the original (non-auxiliary) table name
+ */
+ default void applyPredicate(
+ TableReferenceJoin tableReferenceJoin,
+ NamedTableReference primaryTableReference,
+ String originalTableName,
+ EntityMappingType entityMappingType,
+ SqlAliasBaseGenerator sqlAliasBaseGenerator,
+ LoadQueryInfluencers influencers) {
+ }
+
+ /**
+ * Additional column expressions to include in each SELECT of a
+ * TABLE_PER_CLASS union subquery (e.g. REV, REVTYPE for audit).
+ */
+ default List getExtraSelectExpressions() {
+ return List.of();
+ }
+
JdbcMapping getJdbcMapping();
boolean useAuxiliaryTable(LoadQueryInfluencers influencers);
diff --git a/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/internal/AuditMappingImpl.java b/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/internal/AuditMappingImpl.java
index 68bfb80a6f82..36183c700522 100644
--- a/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/internal/AuditMappingImpl.java
+++ b/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/internal/AuditMappingImpl.java
@@ -6,26 +6,31 @@
import java.util.ArrayList;
import java.util.List;
+import java.util.Map;
import java.util.function.Consumer;
import java.util.function.Supplier;
+import org.hibernate.audit.ModificationType;
+import org.hibernate.audit.internal.AuditEntityLoaderImpl;
+import org.hibernate.audit.spi.AuditEntityLoader;
import org.hibernate.engine.spi.LoadQueryInfluencers;
import org.hibernate.engine.spi.SessionFactoryImplementor;
-import org.hibernate.mapping.Stateful;
import org.hibernate.metamodel.mapping.AuditMapping;
import org.hibernate.metamodel.mapping.EntityMappingType;
+import org.hibernate.metamodel.mapping.EntityValuedModelPart;
import org.hibernate.metamodel.mapping.JdbcMapping;
import org.hibernate.metamodel.mapping.PluralAttributeMapping;
import org.hibernate.metamodel.mapping.SelectableMapping;
-import org.hibernate.persister.state.internal.AuditStateManagement;
import org.hibernate.query.sqm.function.AbstractSqmSelfRenderingFunctionDescriptor;
import org.hibernate.query.sqm.function.FunctionRenderer;
import org.hibernate.query.sqm.function.SelfRenderingAggregateFunctionSqlAstExpression;
import org.hibernate.spi.NavigablePath;
import org.hibernate.sql.ast.spi.SqlAliasBaseGenerator;
+import org.hibernate.sql.ast.spi.SqlAliasBaseManager;
import org.hibernate.sql.ast.spi.SqlAstCreationState;
import org.hibernate.sql.ast.tree.expression.AggregateFunctionExpression;
import org.hibernate.sql.ast.tree.expression.ColumnReference;
+import org.hibernate.sql.ast.tree.expression.Expression;
import org.hibernate.sql.ast.tree.expression.JdbcLiteral;
import org.hibernate.sql.ast.tree.expression.SelfRenderingSqlFragmentExpression;
import org.hibernate.sql.ast.tree.from.LazyTableGroup;
@@ -35,8 +40,10 @@
import org.hibernate.sql.ast.tree.from.TableGroupJoin;
import org.hibernate.sql.ast.tree.from.TableGroupProducer;
import org.hibernate.sql.ast.tree.from.TableReference;
+import org.hibernate.sql.ast.tree.from.TableReferenceJoin;
import org.hibernate.sql.ast.tree.predicate.ComparisonPredicate;
import org.hibernate.sql.ast.tree.predicate.Junction;
+import org.hibernate.sql.ast.tree.predicate.NullnessPredicate;
import org.hibernate.sql.ast.tree.predicate.Predicate;
import org.hibernate.sql.ast.tree.select.QuerySpec;
import org.hibernate.sql.ast.tree.select.SelectStatement;
@@ -45,10 +52,11 @@
import org.hibernate.type.BasicType;
import org.hibernate.type.spi.TypeConfiguration;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
import static java.util.Collections.singletonList;
-import static org.hibernate.boot.model.internal.AuditHelper.MODIFICATION_TYPE;
-import static org.hibernate.boot.model.internal.AuditHelper.TRANSACTION_ID;
import static org.hibernate.query.sqm.ComparisonOperator.EQUAL;
+import static org.hibernate.query.sqm.ComparisonOperator.GREATER_THAN;
import static org.hibernate.query.sqm.ComparisonOperator.LESS_THAN_OR_EQUAL;
import static org.hibernate.query.sqm.ComparisonOperator.NOT_EQUAL;
@@ -56,70 +64,57 @@
* Audit mapping implementation.
*
* @author Gavin King
- *
* @since 7.4
*/
public class AuditMappingImpl implements AuditMapping {
private static final String SUBQUERY_ALIAS_STEM = "audit";
public static final String MAX = "max";
- private final String tableName;
- private final SelectableMapping transactionIdMapping;
- private final SelectableMapping modificationTypeMapping;
+ /**
+ * Per-table audit info.
+ */
+ public record TableAuditInfo(
+ String auditTableName,
+ SelectableMapping transactionIdMapping,
+ @Nullable SelectableMapping modificationTypeMapping,
+ @Nullable SelectableMapping transactionEndMapping,
+ @Nullable SelectableMapping transactionEndTimestampMapping
+ ) {
+ public TableAuditInfo(
+ String auditTableName,
+ SelectableMapping transactionIdMapping,
+ @Nullable SelectableMapping modificationTypeMapping) {
+ this( auditTableName, transactionIdMapping, modificationTypeMapping, null, null );
+ }
+ }
+
+ private final Map tableAuditInfoMap;
+
private final JdbcMapping jdbcMapping;
private final BasicType> transactionIdBasicType;
private final String currentTimestampFunctionName;
private final FunctionRenderer maxFunctionDescriptor;
+ private final EntityMappingType entityMappingType;
+ private final SessionFactoryImplementor sessionFactory;
+ private AuditEntityLoader entityLoader;
+
public AuditMappingImpl(
- Stateful auditable,
- String tableName,
+ Map tableAuditInfoMap,
+ EntityMappingType entityMappingType,
MappingModelCreationProcess creationProcess) {
- this.tableName = tableName;
-
- final var transactionIdColumnName = auditable.getAuxiliaryColumn( TRANSACTION_ID );
- final var modificationTypeColumnName = auditable.getAuxiliaryColumn( MODIFICATION_TYPE );
+ this.tableAuditInfoMap = Map.copyOf( tableAuditInfoMap );
+ this.entityMappingType = entityMappingType;
final var creationContext = creationProcess.getCreationContext();
final var typeConfiguration = creationContext.getTypeConfiguration();
- final var dialect = creationContext.getDialect();
- final var sessionFactory = creationContext.getSessionFactory();
+ this.sessionFactory = creationContext.getSessionFactory();
final var transactionIdJavaType = sessionFactory.getTransactionIdentifierService().getIdentifierType();
- final var sqmFunctionRegistry = sessionFactory.getQueryEngine().getSqmFunctionRegistry();
jdbcMapping = resolveJdbcMapping( typeConfiguration, transactionIdJavaType );
transactionIdBasicType = resolveBasicType( typeConfiguration, transactionIdJavaType );
- transactionIdMapping = SelectableMappingImpl.from(
- tableName,
- transactionIdColumnName,
- null,
- null,
- jdbcMapping,
- typeConfiguration,
- true,
- false,
- false,
- false,
- dialect,
- creationContext
- );
-
- modificationTypeMapping = SelectableMappingImpl.from(
- tableName,
- modificationTypeColumnName,
- null,
- null,
- jdbcMapping,
- typeConfiguration,
- true,
- false,
- false,
- false,
- dialect,
- creationContext
- );
-
+ final var dialect = sessionFactory.getJdbcServices().getDialect();
currentTimestampFunctionName =
sessionFactory.getTransactionIdentifierService().useServerTimestamp( dialect )
? dialect.currentTimestamp()
@@ -128,19 +123,73 @@ public AuditMappingImpl(
maxFunctionDescriptor = resolveMaxFunction( sessionFactory );
}
+ private TableAuditInfo resolveInfo(String originalTableName) {
+ final var info = tableAuditInfoMap.get( originalTableName );
+ if ( info == null ) {
+ throw new IllegalArgumentException(
+ "No audit table info for table '" + originalTableName
+ + "' (known tables: " + tableAuditInfoMap.keySet() + ")" );
+ }
+ return info;
+ }
+
+ @Override
+ public AuditEntityLoader getEntityLoader() {
+ if ( entityLoader == null ) {
+ if ( entityMappingType == null ) {
+ throw new IllegalStateException( "getEntityLoader() is not available for collection audit mappings" );
+ }
+ entityLoader = new AuditEntityLoaderImpl( entityMappingType, sessionFactory );
+ }
+ return entityLoader;
+ }
+
@Override
public String getTableName() {
- return tableName;
+ throw new UnsupportedOperationException(
+ "Invalid call to getTableName() for multi-table aware AuditMapping implementation"
+ );
}
@Override
- public SelectableMapping getTransactionIdMapping() {
- return transactionIdMapping;
+ public String resolveTableName(String originalTableName) {
+ return resolveInfo( originalTableName ).auditTableName;
}
@Override
- public SelectableMapping getModificationTypeMapping() {
- return modificationTypeMapping;
+ public SelectableMapping getTransactionIdMapping(String originalTableName) {
+ return resolveInfo( originalTableName ).transactionIdMapping;
+ }
+
+ @Override
+ public SelectableMapping getModificationTypeMapping(String originalTableName) {
+ return resolveInfo( originalTableName ).modificationTypeMapping;
+ }
+
+ @Override
+ public SelectableMapping getTransactionEndMapping(String originalTableName) {
+ return resolveInfo( originalTableName ).transactionEndMapping;
+ }
+
+ @Override
+ public SelectableMapping getTransactionEndTimestampMapping(String originalTableName) {
+ return resolveInfo( originalTableName ).transactionEndTimestampMapping;
+ }
+
+ @Override
+ public List getExtraSelectExpressions() {
+ final var anyInfo = tableAuditInfoMap.values().iterator().next();
+ final var exprs = new ArrayList<>( List.of(
+ anyInfo.transactionIdMapping.getSelectionExpression(),
+ anyInfo.modificationTypeMapping.getSelectionExpression()
+ ) );
+ if ( anyInfo.transactionEndMapping != null ) {
+ exprs.add( anyInfo.transactionEndMapping.getSelectionExpression() );
+ }
+ if ( anyInfo.transactionEndTimestampMapping != null ) {
+ exprs.add( anyInfo.transactionEndTimestampMapping.getSelectionExpression() );
+ }
+ return exprs;
}
@Override
@@ -148,47 +197,61 @@ public JdbcMapping getJdbcMapping() {
return jdbcMapping;
}
- private Predicate createRestriction(
+ private Expression resolveDefaultUpperBound(TableAuditInfo info) {
+ return currentTimestampFunctionName != null
+ ? new SelfRenderingSqlFragmentExpression( currentTimestampFunctionName, jdbcMapping )
+ : new TemporalJdbcParameter( info.transactionIdMapping );
+ }
+
+ @Override
+ public Predicate createRestriction(
TableGroupProducer tableGroupProducer,
TableReference tableReference,
List keySelectables,
- SqlAliasBaseGenerator sqlAliasBaseGenerator) {
- final var subQueryExpression =
- new SelectStatement( buildSubquery( tableGroupProducer, tableReference, keySelectables, sqlAliasBaseGenerator ) );
- final var revisionPredicate =
- new ComparisonPredicate(
- new ColumnReference( tableReference, transactionIdMapping ),
- EQUAL,
- subQueryExpression
- );
- final var modificationTypePredicate =
- new ComparisonPredicate(
- new ColumnReference( tableReference, modificationTypeMapping ),
- NOT_EQUAL,
- new JdbcLiteral<>(
- AuditStateManagement.ModificationType.DEL.ordinal(),
- modificationTypeMapping.getJdbcMapping()
- )
- );
-
- final var auditPredicate = new Junction( Junction.Nature.CONJUNCTION );
- auditPredicate.add( revisionPredicate );
- auditPredicate.add( modificationTypePredicate );
- return auditPredicate;
+ SqlAliasBaseGenerator sqlAliasBaseGenerator,
+ String originalTableName,
+ Expression upperBound,
+ boolean includeDeletions) {
+ return createRestriction(
+ tableGroupProducer,
+ tableReference,
+ keySelectables,
+ sqlAliasBaseGenerator,
+ resolveInfo( originalTableName ),
+ upperBound,
+ includeDeletions
+ );
}
- private QuerySpec buildSubquery(
+ /**
+ * Build the temporal restriction predicate.
+ *
+ * For the default strategy:
+ * {@code REV = (SELECT MAX(REV) ... WHERE REV <= upperBound) AND REVTYPE <> 2}
+ *
+ * For the validity strategy:
+ * {@code REV <= upperBound AND (REVEND > upperBound OR REVEND IS NULL) AND REVTYPE <> 2}
+ *
+ * @param includeDeletions if {@code true}, omit the {@code REVTYPE <> DEL} filter
+ */
+ private Predicate createRestriction(
TableGroupProducer tableGroupProducer,
TableReference tableReference,
List keySelectables,
- SqlAliasBaseGenerator sqlAliasBaseGenerator) {
+ SqlAliasBaseGenerator sqlAliasBaseGenerator,
+ TableAuditInfo info,
+ Expression upperBound,
+ boolean includeDeletions) {
+ if ( info.transactionEndMapping != null ) {
+ return createValidityRestriction( tableReference, info, upperBound, includeDeletions );
+ }
final var subQuerySpec = new QuerySpec( false, 1 );
final String stem = tableGroupProducer.getSqlAliasStem();
final String aliasStem = stem == null ? SUBQUERY_ALIAS_STEM : stem;
- final var subTableReference =
- new NamedTableReference( tableName,
- sqlAliasBaseGenerator.createSqlAliasBase( aliasStem )
- .generateNewAlias() );
+ final var subTableReference = new NamedTableReference(
+ info.auditTableName,
+ sqlAliasBaseGenerator.createSqlAliasBase( aliasStem ).generateNewAlias()
+ );
final var subTableGroup = new StandardTableGroup(
true,
new NavigablePath( stem == null ? "audit-subquery" : stem + "#audit" ),
@@ -200,37 +263,72 @@ private QuerySpec buildSubquery(
);
subQuerySpec.getFromClause().addRoot( subTableGroup );
- final var transactionId =
- new ColumnReference( subTableReference, transactionIdMapping );
+ final var transactionId = new ColumnReference( subTableReference, info.transactionIdMapping );
subQuerySpec.getSelectClause()
.addSqlSelection( new SqlSelectionImpl( buildMaxExpression( transactionId ) ) );
- subQuerySpec.applyPredicate(
- buildSubqueryPredicate( tableReference, keySelectables, subTableReference, transactionId ) );
-
- return subQuerySpec;
- }
-
- private Junction buildSubqueryPredicate(
- TableReference tableReference,
- List keySelectables,
- NamedTableReference subTableReference,
- ColumnReference transactionId) {
- final var predicate = new Junction( Junction.Nature.CONJUNCTION );
+ // Subquery WHERE: id columns match + REV <= upperBound
+ final var subPredicate = new Junction( Junction.Nature.CONJUNCTION );
for ( var selectableMapping : keySelectables ) {
- predicate.add( new ComparisonPredicate(
+ subPredicate.add( new ComparisonPredicate(
new ColumnReference( subTableReference, selectableMapping ),
EQUAL,
new ColumnReference( tableReference, selectableMapping )
) );
}
+ subPredicate.add( new ComparisonPredicate( transactionId, LESS_THAN_OR_EQUAL, upperBound ) );
+ subQuerySpec.applyPredicate( subPredicate );
+
+ // Main predicate: REV = (subquery) AND optionally REVTYPE <> DEL
+ final var auditPredicate = new Junction( Junction.Nature.CONJUNCTION );
+ auditPredicate.add( new ComparisonPredicate(
+ new ColumnReference( tableReference, info.transactionIdMapping ),
+ EQUAL,
+ new SelectStatement( subQuerySpec )
+ ) );
+ if ( !includeDeletions && info.modificationTypeMapping != null ) {
+ auditPredicate.add( new ComparisonPredicate(
+ new ColumnReference( tableReference, info.modificationTypeMapping ),
+ NOT_EQUAL,
+ new JdbcLiteral<>( ModificationType.DEL, info.modificationTypeMapping.getJdbcMapping() )
+ ) );
+ }
+ return auditPredicate;
+ }
+
+ /**
+ * Build the validity strategy restriction:
+ * {@code REV <= upperBound AND (REVEND > upperBound OR REVEND IS NULL) AND REVTYPE <> DEL}
+ */
+ private static Predicate createValidityRestriction(
+ TableReference tableReference,
+ TableAuditInfo info,
+ Expression upperBound,
+ boolean includeDeletions) {
+ final var predicate = new Junction( Junction.Nature.CONJUNCTION );
+
+ // REV <= upperBound
predicate.add( new ComparisonPredicate(
- transactionId,
+ new ColumnReference( tableReference, info.transactionIdMapping ),
LESS_THAN_OR_EQUAL,
- currentTimestampFunctionName != null
- ? new SelfRenderingSqlFragmentExpression( currentTimestampFunctionName, jdbcMapping )
- : new TemporalJdbcParameter( transactionIdMapping )
+ upperBound
) );
+
+ // (REVEND > upperBound OR REVEND IS NULL)
+ final var revEndRef = new ColumnReference( tableReference, info.transactionEndMapping );
+ final var revEndDisjunction = new Junction( Junction.Nature.DISJUNCTION );
+ revEndDisjunction.add( new ComparisonPredicate( revEndRef, GREATER_THAN, upperBound ) );
+ revEndDisjunction.add( new NullnessPredicate( revEndRef ) );
+ predicate.add( revEndDisjunction );
+
+ // REVTYPE <> DEL (when applicable)
+ if ( !includeDeletions && info.modificationTypeMapping != null ) {
+ predicate.add( new ComparisonPredicate(
+ new ColumnReference( tableReference, info.modificationTypeMapping ),
+ NOT_EQUAL,
+ new JdbcLiteral<>( ModificationType.DEL, info.modificationTypeMapping.getJdbcMapping() )
+ ) );
+ }
return predicate;
}
@@ -273,62 +371,6 @@ private static BasicType resolveBasicType(
: basicType;
}
- @Override
- public void applyPredicate(
- EntityMappingType associatedEntityMappingType,
- Consumer predicateConsumer,
- LazyTableGroup lazyTableGroup,
- NavigablePath navigablePath,
- SqlAstCreationState creationState) {
- if ( creationState.getLoadQueryInfluencers().getTemporalIdentifier() != null ) {
- predicateConsumer.accept( createRestriction(
- associatedEntityMappingType.getEntityPersister(),
- lazyTableGroup.resolveTableReference( navigablePath, getTableName() ),
- collectEntityKeySelectables( associatedEntityMappingType ),
- creationState.getSqlAliasBaseGenerator()
- ) );
- }
- }
-
- @Override
- public void applyPredicate(
- EntityMappingType associatedEntityDescriptor,
- Consumer predicateConsumer,
- TableGroup tableGroup,
- SqlAliasBaseGenerator sqlAliasBaseGenerator,
- LoadQueryInfluencers influencers) {
- if ( influencers.getTemporalIdentifier() != null ) {
- predicateConsumer.accept( createRestriction(
- associatedEntityDescriptor.getEntityPersister(),
- tableGroup.resolveTableReference( getTableName() ),
- collectEntityKeySelectables( associatedEntityDescriptor ),
- sqlAliasBaseGenerator
- ) );
- }
- }
-
- @Override
- public void applyPredicate(
- PluralAttributeMapping collectionDescriptor,
- Consumer predicateConsumer,
- TableGroup tableGroup,
- SqlAliasBaseGenerator sqlAliasBaseGenerator,
- LoadQueryInfluencers influencers) {
- if ( influencers.getTemporalIdentifier() != null ) {
- predicateConsumer.accept( createRestriction(
- collectionDescriptor,
- tableGroup.resolveTableReference( getTableName() ),
- collectCollectionRowKeySelectables( collectionDescriptor ),
- sqlAliasBaseGenerator
- ) );
- }
- }
-
- @Override
- public void applyPredicate(TableGroupJoin tableGroupJoin, LoadQueryInfluencers loadQueryInfluencers) {
- //TODO!!
- }
-
private static List collectEntityKeySelectables(EntityMappingType entityDescriptor) {
final var keySelectables = new ArrayList();
entityDescriptor.getIdentifierMapping().forEachSelectable(
@@ -373,6 +415,15 @@ private List collectCollectionRowKeySelectables(PluralAttribu
}
);
}
+ else if ( collectionDescriptor.getElementDescriptor() instanceof OneToManyCollectionPart oneToMany ) {
+ oneToMany.getAssociatedEntityMappingType().getIdentifierMapping().forEachSelectable(
+ (selectionIndex, selectableMapping) -> {
+ if ( !selectableMapping.isFormula() ) {
+ keySelectables.add( selectableMapping );
+ }
+ }
+ );
+ }
else {
collectionDescriptor.getElementDescriptor().forEachSelectable(
(selectionIndex, selectableMapping) -> {
@@ -385,23 +436,185 @@ private List collectCollectionRowKeySelectables(PluralAttribu
return keySelectables;
}
+ @Override
+ public void applyPredicate(
+ EntityMappingType associatedEntityMappingType,
+ Consumer predicateConsumer,
+ LazyTableGroup lazyTableGroup,
+ NavigablePath navigablePath,
+ SqlAstCreationState creationState) {
+ final var influencers = creationState.getLoadQueryInfluencers();
+ final var persister = associatedEntityMappingType.getEntityPersister();
+ final var info = resolveInfo( persister.getTableName() );
+ if ( hasTemporalPredicate( influencers ) ) {
+ predicateConsumer.accept( createRestriction(
+ persister,
+ lazyTableGroup.resolveTableReference( navigablePath, info.auditTableName ),
+ collectEntityKeySelectables( associatedEntityMappingType ),
+ creationState.getSqlAliasBaseGenerator(),
+ info,
+ resolveDefaultUpperBound( info ),
+ false
+ ) );
+ }
+ else if ( influencers.isAllRevisions() ) {
+ final var parentRevColumn = findParentRevColumn( navigablePath, creationState );
+ if ( parentRevColumn != null ) {
+ predicateConsumer.accept( createRestriction(
+ persister,
+ lazyTableGroup.resolveTableReference( navigablePath, info.auditTableName ),
+ collectEntityKeySelectables( associatedEntityMappingType ),
+ creationState.getSqlAliasBaseGenerator(),
+ info,
+ parentRevColumn,
+ false
+ ) );
+ }
+ }
+ }
+
+ @Override
+ public void applyPredicate(
+ EntityMappingType associatedEntityDescriptor,
+ Consumer predicateConsumer,
+ TableGroup tableGroup,
+ SqlAliasBaseGenerator sqlAliasBaseGenerator,
+ LoadQueryInfluencers influencers) {
+ if ( hasTemporalPredicate( influencers ) ) {
+ final var persister = associatedEntityDescriptor.getEntityPersister();
+ final var info = resolveInfo( persister.getTableName() );
+ predicateConsumer.accept( createRestriction(
+ persister,
+ tableGroup.resolveTableReference( info.auditTableName ),
+ collectEntityKeySelectables( associatedEntityDescriptor ),
+ sqlAliasBaseGenerator,
+ info,
+ resolveDefaultUpperBound( info ),
+ false
+ ) );
+ }
+ }
+
+ @Override
+ public void applyPredicate(
+ PluralAttributeMapping collectionDescriptor,
+ Consumer predicateConsumer,
+ TableGroup tableGroup,
+ SqlAliasBaseGenerator sqlAliasBaseGenerator,
+ LoadQueryInfluencers influencers) {
+ if ( hasTemporalPredicate( influencers ) ) {
+ final String collectionTable = collectionDescriptor.getCollectionDescriptor().getTableName();
+ final var info = resolveInfo( collectionTable );
+ predicateConsumer.accept( createRestriction(
+ collectionDescriptor,
+ tableGroup.resolveTableReference( info.auditTableName ),
+ collectCollectionRowKeySelectables( collectionDescriptor ),
+ sqlAliasBaseGenerator,
+ info,
+ resolveDefaultUpperBound( info ),
+ false
+ ) );
+ }
+ }
+
+ @Override
+ public void applyPredicate(TableGroupJoin tableGroupJoin, LoadQueryInfluencers loadQueryInfluencers) {
+ if ( hasTemporalPredicate( loadQueryInfluencers )
+ && tableGroupJoin.getJoinedGroup().getModelPart() instanceof EntityValuedModelPart entityPart ) {
+ final var entityDescriptor = entityPart.getEntityMappingType();
+ final var persister = entityDescriptor.getEntityPersister();
+ final var info = resolveInfo( persister.getTableName() );
+ tableGroupJoin.applyPredicate( createRestriction(
+ persister,
+ tableGroupJoin.getJoinedGroup().resolveTableReference( info.auditTableName ),
+ collectEntityKeySelectables( entityDescriptor ),
+ new SqlAliasBaseManager(),
+ info,
+ resolveDefaultUpperBound( info ),
+ false
+ ) );
+ }
+ }
+
@Override
public void applyPredicate(
Supplier> predicateCollector,
SqlAstCreationState creationState,
- StandardTableGroup tableGroup,
+ TableGroup tableGroup,
NamedTableReference rootTableReference,
EntityMappingType entityMappingType) {
- if ( creationState.getLoadQueryInfluencers().getTemporalIdentifier() != null ) {
+ if ( hasTemporalPredicate( creationState.getLoadQueryInfluencers() ) ) {
+ final String originalTable = entityMappingType.getEntityPersister().getTableName();
+ final var info = resolveInfo( originalTable );
predicateCollector.get().accept( createRestriction(
- entityMappingType,
- tableGroup.resolveTableReference( getTableName() ),
+ entityMappingType.getEntityPersister(),
+ rootTableReference,
collectEntityKeySelectables( entityMappingType ),
- creationState.getSqlAliasBaseGenerator()
+ creationState.getSqlAliasBaseGenerator(),
+ info,
+ resolveDefaultUpperBound( info ),
+ false
) );
}
}
+ @Override
+ public void applyPredicate(
+ TableReferenceJoin tableReferenceJoin,
+ NamedTableReference primaryTableReference,
+ String originalTableName,
+ EntityMappingType entityMappingType,
+ SqlAliasBaseGenerator sqlAliasBaseGenerator,
+ LoadQueryInfluencers influencers) {
+ if ( influencers.getTemporalIdentifier() != null ) {
+ // Correlate REV between primary and joined tables
+ final String primaryTable = entityMappingType.getMappedTableDetails().getTableName();
+ final var primaryInfo = resolveInfo( primaryTable );
+ final var joinedInfo = resolveInfo( originalTableName );
+ tableReferenceJoin.applyPredicate( new ComparisonPredicate(
+ new ColumnReference( primaryTableReference, primaryInfo.transactionIdMapping() ),
+ EQUAL,
+ new ColumnReference( tableReferenceJoin.getJoinedTableReference(), joinedInfo.transactionIdMapping() )
+ ) );
+ // If the joined table carries REVTYPE (i.e. the root table), apply the DEL filter
+ if ( joinedInfo.modificationTypeMapping() != null && hasTemporalPredicate( influencers ) ) {
+ tableReferenceJoin.applyPredicate( new ComparisonPredicate(
+ new ColumnReference( tableReferenceJoin.getJoinedTableReference(), joinedInfo.modificationTypeMapping() ),
+ NOT_EQUAL,
+ new JdbcLiteral<>( ModificationType.DEL, joinedInfo.modificationTypeMapping().getJdbcMapping() )
+ ) );
+ }
+ }
+ }
+
+ /**
+ * Walk up the navigable path to find a parent table group with an
+ * audit mapping, and return a column reference to its REV column.
+ */
+ private static ColumnReference findParentRevColumn(
+ NavigablePath navigablePath,
+ SqlAstCreationState creationState) {
+ final var parentPath = navigablePath.getParent();
+ if ( parentPath == null ) {
+ return null;
+ }
+ final var parentTableGroup = creationState.getFromClauseAccess()
+ .findTableGroup( parentPath );
+ if ( parentTableGroup != null
+ && parentTableGroup.getModelPart() instanceof EntityValuedModelPart entityPart ) {
+ final var parentAuditMapping = entityPart.getEntityMappingType().getAuditMapping();
+ if ( parentAuditMapping != null ) {
+ final String parentTable = entityPart.getEntityMappingType().getMappedTableDetails().getTableName();
+ final String parentAuditTable = parentAuditMapping.resolveTableName( parentTable );
+ return new ColumnReference(
+ parentTableGroup.resolveTableReference( parentAuditTable ),
+ parentAuditMapping.getTransactionIdMapping( parentTable )
+ );
+ }
+ }
+ return null;
+ }
+
@Override
public boolean useAuxiliaryTable(LoadQueryInfluencers influencers) {
return influencers.getTemporalIdentifier() != null;
@@ -411,4 +624,9 @@ public boolean useAuxiliaryTable(LoadQueryInfluencers influencers) {
public boolean isAffectedByInfluencers(LoadQueryInfluencers influencers) {
return influencers.getTemporalIdentifier() != null;
}
+
+ private static boolean hasTemporalPredicate(LoadQueryInfluencers influencers) {
+ return influencers.getTemporalIdentifier() != null
+ && !influencers.isAllRevisions();
+ }
}
diff --git a/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/internal/OneToManyCollectionPart.java b/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/internal/OneToManyCollectionPart.java
index 855ab62424e2..82fae7c2182c 100644
--- a/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/internal/OneToManyCollectionPart.java
+++ b/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/internal/OneToManyCollectionPart.java
@@ -21,8 +21,12 @@
import org.hibernate.sql.ast.SqlAstJoinType;
import org.hibernate.sql.ast.spi.SqlAliasBase;
import org.hibernate.sql.ast.spi.SqlAstCreationState;
+import org.hibernate.query.sqm.ComparisonOperator;
+import org.hibernate.sql.ast.tree.expression.ColumnReference;
+import org.hibernate.sql.ast.tree.from.CollectionTableGroup;
import org.hibernate.sql.ast.tree.from.OneToManyTableGroup;
import org.hibernate.sql.ast.tree.from.TableGroup;
+import org.hibernate.sql.ast.tree.predicate.ComparisonPredicate;
import org.hibernate.sql.ast.tree.from.TableGroupJoin;
import org.hibernate.sql.ast.tree.from.TableGroupJoinProducer;
import org.hibernate.sql.ast.tree.predicate.Predicate;
@@ -154,18 +158,78 @@ public TableGroupJoin createTableGroupJoin(
boolean addsPredicate,
SqlAstCreationState creationState) {
final var joinType = requireNonNullElse( requestedJoinType, SqlAstJoinType.INNER );
- final var elementTableGroup = ( (OneToManyTableGroup) lhs ).getElementTableGroup();
+ if ( lhs instanceof CollectionTableGroup
+ && getCollectionDescriptor().getAttributeMapping().getAuditMapping() != null ) {
+ // Temporal OTM with middle audit table: the collection uses a CollectionTableGroup
+ // instead of OneToManyTableGroup, so we create the entity as a joined table group
+ final var sqlAliasBase = SqlAliasBase.from(
+ explicitSqlAliasBase,
+ explicitSourceAlias,
+ this,
+ creationState.getSqlAliasBaseGenerator()
+ );
+ final var entityTableGroup = createAssociatedTableGroup(
+ lhs.canUseInnerJoins(),
+ navigablePath,
+ fetched,
+ explicitSourceAlias,
+ sqlAliasBase,
+ creationState
+ );
+ final var tableGroupJoin = new TableGroupJoin( navigablePath, joinType, entityTableGroup );
+ // Join on entity identifier: collection_table.child_id = entity.id
+ getAssociatedEntityMappingType().getIdentifierMapping().forEachSelectable(
+ (i, sel) -> tableGroupJoin.applyPredicate(
+ new ComparisonPredicate(
+ new ColumnReference( lhs.getPrimaryTableReference(), sel ),
+ ComparisonOperator.EQUAL,
+ new ColumnReference( entityTableGroup.getPrimaryTableReference(), sel )
+ )
+ )
+ );
+ return withMapKeyJoin(
+ tableGroupJoin,
+ entityTableGroup,
+ navigablePath,
+ fetched,
+ addsPredicate,
+ creationState
+ );
+ }
+ else if ( lhs instanceof OneToManyTableGroup otmTableGroup ) {
+ final var elementTableGroup = otmTableGroup.getElementTableGroup();
+ final var tableGroupJoin = new TableGroupJoin( navigablePath, joinType, elementTableGroup );
+ return withMapKeyJoin( tableGroupJoin,
+ elementTableGroup,
+ navigablePath,
+ fetched,
+ addsPredicate,
+ creationState
+ );
+ }
+ else {
+ throw new IllegalStateException(
+ "Unexpected table group type for OneToManyCollectionPart: " + lhs.getClass().getName()
+ );
+ }
+ }
- // INDEX is implied if mapKeyPropertyName is not null
+ private TableGroupJoin withMapKeyJoin(
+ TableGroupJoin tableGroupJoin,
+ TableGroup elementTableGroup,
+ NavigablePath navigablePath,
+ boolean fetched,
+ boolean addsPredicate,
+ SqlAstCreationState creationState) {
if ( mapKeyPropertyName != null ) {
final var elementPart =
(EntityCollectionPart)
getCollectionDescriptor().getAttributeMapping()
.getElementDescriptor();
if ( elementPart.getAssociatedEntityMappingType().findAttributeMapping( mapKeyPropertyName )
- instanceof ToOneAttributeMapping toOne ) {
+ instanceof ToOneAttributeMapping toOne ) {
final var mapKeyPropertyPath = navigablePath.append( mapKeyPropertyName );
- final var tableGroupJoin = toOne.createTableGroupJoin(
+ final var mapKeyJoin = toOne.createTableGroupJoin(
mapKeyPropertyPath,
elementTableGroup,
null,
@@ -176,12 +240,11 @@ public TableGroupJoin createTableGroupJoin(
creationState
);
creationState.getFromClauseAccess()
- .registerTableGroup( mapKeyPropertyPath, tableGroupJoin.getJoinedGroup() );
- return tableGroupJoin;
+ .registerTableGroup( mapKeyPropertyPath, mapKeyJoin.getJoinedGroup() );
+ return mapKeyJoin;
}
}
-
- return new TableGroupJoin( navigablePath, joinType, elementTableGroup );
+ return tableGroupJoin;
}
@Override
diff --git a/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/internal/PluralAttributeMappingImpl.java b/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/internal/PluralAttributeMappingImpl.java
index e56ccf0a89bb..0c69e65faa40 100644
--- a/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/internal/PluralAttributeMappingImpl.java
+++ b/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/internal/PluralAttributeMappingImpl.java
@@ -996,6 +996,15 @@ private TableGroup createRootTableGroupJoin(
return tableGroup;
}
+ private boolean useCollectionTableGroup(SqlAstCreationState creationState) {
+ // For temporal OTM @JoinColumn reads, use CollectionTableGroup with the middle
+ // audit table as primary (like M2M). The FK column isn't in the entity audit table,
+ // so the key must resolve from the middle audit table instead.
+ return !getCollectionDescriptor().isOneToMany()
+ || auxiliaryMapping instanceof AuditMapping auditMapping
+ && auditMapping.useAuxiliaryTable( creationState.getLoadQueryInfluencers() );
+ }
+
private TableGroup rootTableGroup(
NavigablePath navigablePath,
TableGroup lhs,
@@ -1005,8 +1014,8 @@ private TableGroup rootTableGroup(
SqlAstCreationState creationState,
SqlAstJoinType joinType,
SqlAliasBase sqlAliasBase) {
- return getCollectionDescriptor().isOneToMany()
- ? createOneToManyTableGroup(
+ return useCollectionTableGroup( creationState )
+ ? createCollectionTableGroup(
lhs.canUseInnerJoins()
&& joinType == SqlAstJoinType.INNER,
joinType,
@@ -1017,7 +1026,7 @@ private TableGroup rootTableGroup(
sqlAliasBase,
creationState
)
- : createCollectionTableGroup(
+ : createOneToManyTableGroup(
lhs.canUseInnerJoins()
&& joinType == SqlAstJoinType.INNER,
joinType,
@@ -1103,7 +1112,6 @@ private TableGroup createCollectionTableGroup(
String sourceAlias,
SqlAliasBase explicitSqlAliasBase,
SqlAstCreationState creationState) {
- assert !getCollectionDescriptor().isOneToMany();
final var sqlAliasBase = SqlAliasBase.from(
explicitSqlAliasBase,
sourceAlias,
@@ -1168,7 +1176,7 @@ private TableGroup createCollectionTableGroup(
private NamedTableReference collectionTableReference(SqlAstCreationState creationState, String tableName, String alias) {
return auxiliaryMapping != null && auxiliaryMapping.useAuxiliaryTable( creationState.getLoadQueryInfluencers() )
- ? new AuxiliaryTableReference( auxiliaryMapping.getTableName(), tableName, alias, true )
+ ? new AuxiliaryTableReference( auxiliaryMapping.resolveTableName( tableName ), tableName, alias, true )
: new NamedTableReference( tableName, alias, true );
}
@@ -1180,7 +1188,7 @@ public TableGroup createRootTableGroup(
SqlAliasBase explicitSqlAliasBase,
Supplier> additionalPredicateCollectorAccess,
SqlAstCreationState creationState) {
- if ( getCollectionDescriptor().isOneToMany() ) {
+ if ( !useCollectionTableGroup( creationState ) ) {
return createOneToManyTableGroup(
canUseInnerJoins,
SqlAstJoinType.INNER,
diff --git a/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/internal/SoftDeleteMappingImpl.java b/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/internal/SoftDeleteMappingImpl.java
index 047f31c48bc3..6fbf83078f6e 100644
--- a/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/internal/SoftDeleteMappingImpl.java
+++ b/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/internal/SoftDeleteMappingImpl.java
@@ -31,7 +31,6 @@
import org.hibernate.sql.ast.tree.expression.JdbcLiteral;
import org.hibernate.sql.ast.tree.from.LazyTableGroup;
import org.hibernate.sql.ast.tree.from.NamedTableReference;
-import org.hibernate.sql.ast.tree.from.StandardTableGroup;
import org.hibernate.sql.ast.tree.from.TableGroup;
import org.hibernate.sql.ast.tree.from.TableGroupJoin;
import org.hibernate.sql.ast.tree.from.TableReference;
@@ -423,7 +422,7 @@ public void applyPredicate(TableGroupJoin tableGroupJoin, LoadQueryInfluencers l
public void applyPredicate(
Supplier> predicateCollector,
SqlAstCreationState creationState,
- StandardTableGroup tableGroup,
+ TableGroup tableGroup,
NamedTableReference rootTableReference,
EntityMappingType entityMappingType) {
final var tableReference =
diff --git a/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/internal/TemporalMappingImpl.java b/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/internal/TemporalMappingImpl.java
index 2a8d97bf055b..d6fa2ae9a52e 100644
--- a/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/internal/TemporalMappingImpl.java
+++ b/hibernate-core/src/main/java/org/hibernate/metamodel/mapping/internal/TemporalMappingImpl.java
@@ -24,7 +24,6 @@
import org.hibernate.sql.ast.tree.expression.SelfRenderingSqlFragmentExpression;
import org.hibernate.sql.ast.tree.from.LazyTableGroup;
import org.hibernate.sql.ast.tree.from.NamedTableReference;
-import org.hibernate.sql.ast.tree.from.StandardTableGroup;
import org.hibernate.sql.ast.tree.from.TableGroup;
import org.hibernate.sql.ast.tree.from.TableGroupJoin;
import org.hibernate.sql.ast.tree.from.TableReference;
@@ -316,7 +315,7 @@ public void applyPredicate(TableGroupJoin tableGroupJoin, LoadQueryInfluencers l
public void applyPredicate(
Supplier> predicateCollector,
SqlAstCreationState creationState,
- StandardTableGroup tableGroup,
+ TableGroup tableGroup,
NamedTableReference rootTableReference,
EntityMappingType entityMappingType) {
if ( useTemporalRestriction( creationState ) ) {
@@ -337,23 +336,28 @@ public void applyPredicate(
}
private static boolean useTemporalRestriction(LoadQueryInfluencers influencers) {
- return influencers.getSessionFactory().getJdbcServices().getDialect().getTemporalTableSupport()
- .useTemporalRestriction( influencers );
+ return !influencers.isAllRevisions()
+ && influencers.getSessionFactory().getJdbcServices().getDialect().getTemporalTableSupport()
+ .useTemporalRestriction( influencers );
}
private boolean useTemporalRestriction(SqlAstCreationState creationState) {
- return creationState.getCreationContext().getDialect().getTemporalTableSupport()
- .useTemporalRestriction( creationState.getLoadQueryInfluencers() );
+ final var influencers = creationState.getLoadQueryInfluencers();
+ return !influencers.isAllRevisions()
+ && creationState.getCreationContext().getDialect().getTemporalTableSupport()
+ .useTemporalRestriction( influencers );
}
@Override
public boolean useAuxiliaryTable(LoadQueryInfluencers influencers) {
return temporalTableStrategy == TemporalTableStrategy.HISTORY_TABLE
- && influencers.getTemporalIdentifier() != null;
+ && influencers.getTemporalIdentifier() != null
+ && !influencers.isAllRevisions();
}
@Override
public boolean isAffectedByInfluencers(LoadQueryInfluencers influencers) {
- return influencers.getTemporalIdentifier() != null;
+ return influencers.getTemporalIdentifier() != null
+ && !influencers.isAllRevisions();
}
}
diff --git a/hibernate-core/src/main/java/org/hibernate/persister/collection/mutation/AuditCollectionHelper.java b/hibernate-core/src/main/java/org/hibernate/persister/collection/mutation/AuditCollectionHelper.java
index cd462913f021..9400414c3312 100644
--- a/hibernate-core/src/main/java/org/hibernate/persister/collection/mutation/AuditCollectionHelper.java
+++ b/hibernate-core/src/main/java/org/hibernate/persister/collection/mutation/AuditCollectionHelper.java
@@ -4,26 +4,33 @@
*/
package org.hibernate.persister.collection.mutation;
+import java.util.List;
import java.util.function.UnaryOperator;
import org.hibernate.engine.spi.SessionFactoryImplementor;
import org.hibernate.metamodel.mapping.AuditMapping;
import org.hibernate.metamodel.mapping.SelectableMapping;
+import org.hibernate.metamodel.mapping.internal.OneToManyCollectionPart;
+import org.hibernate.sql.ast.tree.expression.ColumnReference;
import org.hibernate.sql.model.MutationOperationGroup;
import org.hibernate.sql.model.MutationType;
+import org.hibernate.sql.model.ast.ColumnValueBinding;
+import org.hibernate.sql.model.ast.ColumnWriteFragment;
import org.hibernate.sql.model.ast.builder.TableInsertBuilderStandard;
+import org.hibernate.sql.model.ast.builder.TableUpdateBuilderStandard;
import static org.hibernate.sql.model.internal.MutationOperationGroupFactory.singleOperation;
/**
* Support for building audit log mutations for collections.
*/
-final class AuditCollectionHelper {
+public final class AuditCollectionHelper {
private final CollectionMutationTarget mutationTarget;
private final SessionFactoryImplementor sessionFactory;
private final CollectionTableMapping auditTableMapping;
private final SelectableMapping transactionIdMapping;
private final SelectableMapping modificationTypeMapping;
+ private final SelectableMapping transactionEndMapping;
private final boolean useServerTransactionTimestamps;
private final String currentTimestampFunctionName;
private final boolean[] indexColumnIsSettable;
@@ -31,6 +38,7 @@ final class AuditCollectionHelper {
private final UnaryOperator indexIncrementer;
private MutationOperationGroup auditInsertOperationGroup;
+ private MutationOperationGroup transactionEndUpdateGroup;
private AuditCollectionRowMutationHelper rowMutationHelper;
AuditCollectionHelper(
@@ -45,11 +53,14 @@ final class AuditCollectionHelper {
this.indexColumnIsSettable = indexColumnIsSettable;
this.elementColumnIsSettable = elementColumnIsSettable;
this.indexIncrementer = indexIncrementer;
- this.auditTableMapping =
- new CollectionTableMapping( mutationTarget.getCollectionTableMapping(),
- auditMapping.getTableName() );
- this.transactionIdMapping = auditMapping.getTransactionIdMapping();
- this.modificationTypeMapping = auditMapping.getModificationTypeMapping();
+ final String collectionTableName = mutationTarget.getCollectionTableMapping().getTableName();
+ this.auditTableMapping = new CollectionTableMapping(
+ mutationTarget.getCollectionTableMapping(),
+ auditMapping.resolveTableName( collectionTableName )
+ );
+ this.transactionIdMapping = auditMapping.getTransactionIdMapping( collectionTableName );
+ this.modificationTypeMapping = auditMapping.getModificationTypeMapping( collectionTableName );
+ this.transactionEndMapping = auditMapping.getTransactionEndMapping( collectionTableName );
final var dialect = sessionFactory.getJdbcServices().getDialect();
this.useServerTransactionTimestamps =
@@ -87,6 +98,13 @@ AuditCollectionRowMutationHelper getRowMutationHelper() {
return rowMutationHelper;
}
+ MutationOperationGroup getTransactionEndUpdateGroup() {
+ if ( transactionEndUpdateGroup == null && transactionEndMapping != null ) {
+ transactionEndUpdateGroup = buildTransactionEndUpdateGroup();
+ }
+ return transactionEndUpdateGroup;
+ }
+
private MutationOperationGroup buildAuditInsertOperationGroup() {
final var insertBuilder =
new TableInsertBuilderStandard( mutationTarget, auditTableMapping, sessionFactory );
@@ -111,7 +129,15 @@ private void applyAuditInsertDetails(TableInsertBuilderStandard insertBuilder) {
}
}
- attributeMapping.getElementDescriptor().forEachInsertable( insertBuilder );
+ final var elementDescriptor = attributeMapping.getElementDescriptor();
+ if ( elementDescriptor instanceof OneToManyCollectionPart oneToMany ) {
+ // For @OneToMany @JoinColumn, the middle audit table stores the child entity's ID,
+ // not the FK columns (which are the element's selectables for OneToManyCollectionPart)
+ oneToMany.getAssociatedEntityMappingType().getIdentifierMapping().forEachInsertable( insertBuilder );
+ }
+ else {
+ elementDescriptor.forEachInsertable( insertBuilder );
+ }
if ( useServerTransactionTimestamps ) {
insertBuilder.addValueColumn( currentTimestampFunctionName, transactionIdMapping );
@@ -121,4 +147,63 @@ private void applyAuditInsertDetails(TableInsertBuilderStandard insertBuilder) {
}
insertBuilder.addValueColumn( "?", modificationTypeMapping );
}
+
+ private MutationOperationGroup buildTransactionEndUpdateGroup() {
+ final var updateBuilder =
+ new TableUpdateBuilderStandard<>( mutationTarget, auditTableMapping, sessionFactory );
+ final var attributeMapping = mutationTarget.getTargetPart();
+
+ // SET REVEND = ?
+ if ( useServerTransactionTimestamps ) {
+ updateBuilder.addValueColumn( currentTimestampFunctionName, transactionEndMapping );
+ }
+ else {
+ updateBuilder.addValueColumn( "?", transactionEndMapping );
+ }
+
+ // SET REVEND_TSTMP = ? (if configured)
+ final var revEndTsMapping = mutationTarget.getTargetPart().getAuditMapping()
+ .getTransactionEndTimestampMapping( mutationTarget.getCollectionTableMapping().getTableName() );
+ if ( revEndTsMapping != null ) {
+ updateBuilder.addValueColumn( "?", revEndTsMapping );
+ }
+
+ // WHERE: same identity columns as the INSERT (key + index/identifier + element)
+ attributeMapping.getKeyDescriptor()
+ .getKeyPart()
+ .forEachSelectable( (index, selectable) -> updateBuilder.addKeyRestrictionBinding( selectable ) );
+
+ final var identifierDescriptor = attributeMapping.getIdentifierDescriptor();
+ if ( identifierDescriptor != null ) {
+ identifierDescriptor.forEachSelectable( (index, selectable) -> updateBuilder.addKeyRestrictionBinding( selectable ) );
+ }
+ else {
+ final var indexDescriptor = attributeMapping.getIndexDescriptor();
+ if ( indexDescriptor != null ) {
+ indexDescriptor.forEachSelectable( (index, selectable) -> updateBuilder.addKeyRestrictionBinding( selectable ) );
+ }
+ }
+
+ final var elementDescriptor = attributeMapping.getElementDescriptor();
+ if ( elementDescriptor instanceof OneToManyCollectionPart oneToMany ) {
+ oneToMany.getAssociatedEntityMappingType()
+ .getIdentifierMapping()
+ .forEachSelectable( (index, selectable) -> updateBuilder.addKeyRestrictionBinding( selectable ) );
+ }
+ else {
+ elementDescriptor.forEachSelectable( (index, selectable) -> updateBuilder.addKeyRestrictionBinding( selectable ) );
+ }
+
+ // WHERE REVEND IS NULL
+ final var revEndColumnRef = new ColumnReference(
+ updateBuilder.getMutatingTable(), transactionEndMapping );
+ updateBuilder.addNonKeyRestriction( new ColumnValueBinding(
+ revEndColumnRef,
+ new ColumnWriteFragment( null, List.of(), transactionEndMapping )
+ ) );
+
+ final var tableUpdate = updateBuilder.buildMutation();
+ final var operation = tableUpdate.createMutationOperation( null, sessionFactory );
+ return operation == null ? null : singleOperation( MutationType.UPDATE, mutationTarget, operation );
+ }
}
diff --git a/hibernate-core/src/main/java/org/hibernate/persister/collection/mutation/AuditCollectionRowMutationHelper.java b/hibernate-core/src/main/java/org/hibernate/persister/collection/mutation/AuditCollectionRowMutationHelper.java
index a21516e92c1f..692c16aa3eaa 100644
--- a/hibernate-core/src/main/java/org/hibernate/persister/collection/mutation/AuditCollectionRowMutationHelper.java
+++ b/hibernate-core/src/main/java/org/hibernate/persister/collection/mutation/AuditCollectionRowMutationHelper.java
@@ -6,13 +6,13 @@
import java.util.function.UnaryOperator;
+import org.hibernate.audit.ModificationType;
import org.hibernate.collection.spi.PersistentCollection;
import org.hibernate.engine.jdbc.mutation.JdbcValueBindings;
import org.hibernate.engine.jdbc.mutation.ParameterUsage;
import org.hibernate.engine.spi.SharedSessionContractImplementor;
import org.hibernate.metamodel.mapping.PluralAttributeMapping;
import org.hibernate.metamodel.mapping.SelectableMapping;
-import org.hibernate.persister.state.internal.AuditStateManagement;
/**
* Binds collection row values for audit table mutations.
@@ -53,19 +53,78 @@ void bindInsertValues(
Object key,
Object rowValue,
int rowPosition,
- AuditStateManagement.ModificationType modificationType,
+ ModificationType modificationType,
SharedSessionContractImplementor session,
JdbcValueBindings jdbcValueBindings) {
if ( key == null ) {
throw new IllegalArgumentException( "null key for collection: " + mutationTarget.getRolePath() );
}
- attributeMapping.getKeyDescriptor().getKeyPart().decompose(
+ decomposeRowIdentity(
+ collection,
key,
- 0,
+ rowValue,
+ rowPosition,
+ session,
jdbcValueBindings,
- null,
- this::bindSetValue,
+ ParameterUsage.SET
+ );
+
+ if ( !useServerTransactionTimestamps ) {
+ jdbcValueBindings.bindValue(
+ session.getCurrentTransactionIdentifier(),
+ auditTableName,
+ transactionIdMapping.getSelectionExpression(),
+ ParameterUsage.SET
+ );
+ }
+
+ jdbcValueBindings.bindValue(
+ modificationType,
+ auditTableName,
+ modificationTypeMapping.getSelectionExpression(),
+ ParameterUsage.SET
+ );
+ }
+
+ /**
+ * Bind values for a REVEND UPDATE WHERE clause - same identity columns
+ * as the INSERT, but with {@link ParameterUsage#RESTRICT}.
+ */
+ void bindRestrictValues(
+ PersistentCollection> collection,
+ Object key,
+ Object rowValue,
+ int rowPosition,
+ SharedSessionContractImplementor session,
+ JdbcValueBindings jdbcValueBindings) {
+ decomposeRowIdentity(
+ collection,
+ key,
+ rowValue,
+ rowPosition,
+ session,
+ jdbcValueBindings,
+ ParameterUsage.RESTRICT
+ );
+ }
+
+ /**
+ * Decompose the collection row's identity columns (key + identifier/index + element)
+ * into JDBC value bindings with the given {@link ParameterUsage}.
+ */
+ private void decomposeRowIdentity(
+ PersistentCollection> collection,
+ Object key,
+ Object rowValue,
+ int rowPosition,
+ SharedSessionContractImplementor session,
+ JdbcValueBindings jdbcValueBindings,
+ ParameterUsage parameterUsage) {
+ attributeMapping.getKeyDescriptor().getKeyPart().decompose(
+ key, 0, jdbcValueBindings, null,
+ (valueIndex, bindings, unused, jdbcValue, mapping) ->
+ bindValue( bindings, jdbcValue, mapping, parameterUsage ),
session
);
@@ -73,10 +132,9 @@ void bindInsertValues(
if ( identifierDescriptor != null ) {
identifierDescriptor.decompose(
collection.getIdentifier( rowValue, rowPosition ),
- 0,
- jdbcValueBindings,
- null,
- this::bindSetValue,
+ 0, jdbcValueBindings, null,
+ (valueIndex, bindings, unused, jdbcValue, mapping) ->
+ bindValue( bindings, jdbcValue, mapping, parameterUsage ),
session
);
}
@@ -87,22 +145,9 @@ void bindInsertValues(
collection.getIndex( rowValue, rowPosition, attributeMapping.getCollectionDescriptor() )
);
indexDescriptor.decompose(
- index,
- 0,
- indexColumnIsSettable,
- jdbcValueBindings,
- (valueIndex, settable, bindings, jdbcValue, jdbcValueMapping) -> {
- if ( settable[valueIndex]
- && auditTableName.equals( jdbcValueMapping.getContainingTableExpression() )
- && !jdbcValueMapping.isFormula() ) {
- bindings.bindValue(
- jdbcValue,
- auditTableName,
- jdbcValueMapping.getSelectionExpression(),
- ParameterUsage.SET
- );
- }
- },
+ index, 0, indexColumnIsSettable, jdbcValueBindings,
+ (valueIndex, settable, bindings, jdbcValue, mapping) ->
+ bindSettableValue( valueIndex, settable, bindings, jdbcValue, mapping, parameterUsage ),
session
);
}
@@ -113,50 +158,31 @@ void bindInsertValues(
0,
elementColumnIsSettable,
jdbcValueBindings,
- (valueIndex, settable, bindings, jdbcValue, jdbcValueMapping) -> {
- if ( settable[valueIndex] && !jdbcValueMapping.isFormula() ) {
- bindings.bindValue(
- jdbcValue,
- auditTableName,
- jdbcValueMapping.getSelectionExpression(),
- ParameterUsage.SET
- );
- }
- },
+ (valueIndex, settable, bindings, jdbcValue, mapping) ->
+ bindSettableValue( valueIndex, settable, bindings, jdbcValue, mapping, parameterUsage ),
session
);
+ }
- if ( !useServerTransactionTimestamps ) {
- jdbcValueBindings.bindValue(
- session.getCurrentTransactionIdentifier(),
- auditTableName,
- transactionIdMapping.getSelectionExpression(),
- ParameterUsage.SET
- );
+ private void bindValue(
+ JdbcValueBindings bindings,
+ Object jdbcValue,
+ SelectableMapping mapping,
+ ParameterUsage parameterUsage) {
+ if ( !mapping.isFormula() ) {
+ bindings.bindValue( jdbcValue, auditTableName, mapping.getSelectionExpression(), parameterUsage );
}
-
- jdbcValueBindings.bindValue(
- Integer.valueOf( modificationType.ordinal() ),
- auditTableName,
- modificationTypeMapping.getSelectionExpression(),
- ParameterUsage.SET
- );
}
- private void bindSetValue(
+ private void bindSettableValue(
int valueIndex,
- JdbcValueBindings jdbcValueBindings,
- Object unused,
+ boolean[] settable,
+ JdbcValueBindings bindings,
Object jdbcValue,
- SelectableMapping selectableMapping) {
- if ( selectableMapping.isFormula() ) {
- return;
+ SelectableMapping mapping,
+ ParameterUsage parameterUsage) {
+ if ( settable[valueIndex] && !mapping.isFormula() ) {
+ bindings.bindValue( jdbcValue, auditTableName, mapping.getSelectionExpression(), parameterUsage );
}
- jdbcValueBindings.bindValue(
- jdbcValue,
- auditTableName,
- selectableMapping.getSelectionExpression(),
- ParameterUsage.SET
- );
}
}
diff --git a/hibernate-core/src/main/java/org/hibernate/persister/collection/mutation/DeleteRowsCoordinatorAudit.java b/hibernate-core/src/main/java/org/hibernate/persister/collection/mutation/DeleteRowsCoordinatorAudit.java
deleted file mode 100644
index 2a963cc2d7cb..000000000000
--- a/hibernate-core/src/main/java/org/hibernate/persister/collection/mutation/DeleteRowsCoordinatorAudit.java
+++ /dev/null
@@ -1,128 +0,0 @@
-/*
- * SPDX-License-Identifier: Apache-2.0
- * Copyright Red Hat Inc. and Hibernate Authors
- */
-package org.hibernate.persister.collection.mutation;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.function.UnaryOperator;
-
-import org.hibernate.collection.spi.PersistentCollection;
-import org.hibernate.engine.jdbc.batch.internal.BasicBatchKey;
-import org.hibernate.engine.jdbc.mutation.spi.MutationExecutorService;
-import org.hibernate.engine.spi.SessionFactoryImplementor;
-import org.hibernate.engine.spi.SharedSessionContractImplementor;
-import org.hibernate.persister.collection.CollectionPersister;
-import org.hibernate.persister.state.internal.AuditStateManagement;
-import org.hibernate.sql.model.MutationOperationGroup;
-
-/**
- * DeleteRowsCoordinator for audited collections.
- */
-public class DeleteRowsCoordinatorAudit implements DeleteRowsCoordinator {
- private final CollectionMutationTarget mutationTarget;
- private final DeleteRowsCoordinator currentDeleteCoordinator;
- private final SessionFactoryImplementor sessionFactory;
- private final boolean deleteByIndex;
- private final MutationExecutorService mutationExecutorService;
- private final BasicBatchKey auditBatchKey;
- private final boolean[] indexColumnIsSettable;
- private final boolean[] elementColumnIsSettable;
- private final UnaryOperator indexIncrementer;
-
- private MutationOperationGroup auditOperationGroup;
- private AuditCollectionHelper auditHelper;
-
- public DeleteRowsCoordinatorAudit(
- CollectionMutationTarget mutationTarget,
- DeleteRowsCoordinator currentDeleteCoordinator,
- boolean deleteByIndex,
- boolean[] indexColumnIsSettable,
- boolean[] elementColumnIsSettable,
- UnaryOperator indexIncrementer,
- SessionFactoryImplementor sessionFactory) {
- this.mutationTarget = mutationTarget;
- this.currentDeleteCoordinator = currentDeleteCoordinator;
- this.sessionFactory = sessionFactory;
- this.deleteByIndex = deleteByIndex;
- this.indexColumnIsSettable = indexColumnIsSettable;
- this.elementColumnIsSettable = elementColumnIsSettable;
- this.indexIncrementer = indexIncrementer;
- this.auditBatchKey = new BasicBatchKey( mutationTarget.getRolePath() + "#AUDIT_INSERT" );
- this.mutationExecutorService = sessionFactory.getServiceRegistry().getService( MutationExecutorService.class );
- }
-
- @Override
- public CollectionMutationTarget getMutationTarget() {
- return mutationTarget;
- }
-
- @Override
- public void deleteRows(
- PersistentCollection> collection,
- Object key,
- SharedSessionContractImplementor session) {
- final var collectionDescriptor = mutationTarget.getTargetPart().getCollectionDescriptor();
- final var deletions = collectDeletions( collection, collectionDescriptor );
-
- currentDeleteCoordinator.deleteRows( collection, key, session );
-
- if ( !deletions.isEmpty() ) {
- if ( auditOperationGroup == null ) {
- auditOperationGroup = getAuditHelper().getAuditInsertOperationGroup();
- }
- if ( auditOperationGroup != null ) {
- final var auditExecutor = mutationExecutorService.createExecutor(
- () -> auditBatchKey,
- auditOperationGroup,
- session
- );
- try {
- final var bindings = getAuditHelper().getRowMutationHelper();
- for ( int i = 0; i < deletions.size(); i++ ) {
- final Object removal = deletions.get( i );
- bindings.bindInsertValues(
- collection,
- key,
- removal,
- i,
- AuditStateManagement.ModificationType.DEL,
- session,
- auditExecutor.getJdbcValueBindings()
- );
- auditExecutor.execute( removal, null, null, null, session );
- }
- }
- finally {
- auditExecutor.release();
- }
- }
- }
- }
-
- private AuditCollectionHelper getAuditHelper() {
- if ( auditHelper == null ) {
- auditHelper = new AuditCollectionHelper(
- mutationTarget,
- sessionFactory,
- indexColumnIsSettable,
- elementColumnIsSettable,
- indexIncrementer,
- mutationTarget.getTargetPart().getAuditMapping()
- );
- }
- return auditHelper;
- }
-
- private List collectDeletions(
- PersistentCollection> collection,
- CollectionPersister persister) {
- final List deletions = new ArrayList<>();
- final var deletes = collection.getDeletes( persister, !deleteByIndex );
- while ( deletes.hasNext() ) {
- deletions.add( deletes.next() );
- }
- return deletions;
- }
-}
diff --git a/hibernate-core/src/main/java/org/hibernate/persister/collection/mutation/InsertRowsCoordinatorAudit.java b/hibernate-core/src/main/java/org/hibernate/persister/collection/mutation/InsertRowsCoordinatorAudit.java
index 751c57ad5735..2db13f7bdd4b 100644
--- a/hibernate-core/src/main/java/org/hibernate/persister/collection/mutation/InsertRowsCoordinatorAudit.java
+++ b/hibernate-core/src/main/java/org/hibernate/persister/collection/mutation/InsertRowsCoordinatorAudit.java
@@ -4,20 +4,31 @@
*/
package org.hibernate.persister.collection.mutation;
+import java.lang.reflect.Array;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
import java.util.function.UnaryOperator;
+import org.hibernate.audit.ModificationType;
+import org.hibernate.audit.spi.AuditWorkQueue;
+import org.hibernate.audit.spi.CollectionAuditWriter;
+import org.hibernate.collection.spi.PersistentArrayHolder;
import org.hibernate.collection.spi.PersistentCollection;
import org.hibernate.engine.jdbc.batch.internal.BasicBatchKey;
+import org.hibernate.engine.jdbc.mutation.ParameterUsage;
import org.hibernate.engine.jdbc.mutation.spi.MutationExecutorService;
+import org.hibernate.engine.spi.CollectionKey;
import org.hibernate.engine.spi.SessionFactoryImplementor;
import org.hibernate.engine.spi.SharedSessionContractImplementor;
-import org.hibernate.persister.state.internal.AuditStateManagement;
-import org.hibernate.sql.model.MutationOperationGroup;
+import org.hibernate.persister.collection.CollectionPersister;
+import org.hibernate.type.Type;
/**
* InsertRowsCoordinator for audited collections.
*/
-public class InsertRowsCoordinatorAudit implements InsertRowsCoordinator {
+public class InsertRowsCoordinatorAudit implements InsertRowsCoordinator, CollectionAuditWriter {
private final CollectionMutationTarget mutationTarget;
private final InsertRowsCoordinator currentInsertCoordinator;
private final SessionFactoryImplementor sessionFactory;
@@ -27,12 +38,10 @@ public class InsertRowsCoordinatorAudit implements InsertRowsCoordinator {
private final boolean[] elementColumnIsSettable;
private final UnaryOperator indexIncrementer;
- private MutationOperationGroup auditOperationGroup;
private AuditCollectionHelper auditHelper;
public InsertRowsCoordinatorAudit(
CollectionMutationTarget mutationTarget,
- RowMutationOperations rowMutationOperations,
InsertRowsCoordinator currentInsertCoordinator,
boolean[] indexColumnIsSettable,
boolean[] elementColumnIsSettable,
@@ -61,48 +70,326 @@ public void insertRows(
SharedSessionContractImplementor session) {
currentInsertCoordinator.insertRows( collection, id, entryChecker, session );
- if ( auditOperationGroup == null ) {
- auditOperationGroup = getAuditHelper().getAuditInsertOperationGroup();
+ // Capture the snapshot before it's replaced by the flush, and enqueue
+ final var snapshot = resolveSnapshot( collection, id, session );
+ final var collectionDescriptor = mutationTarget.getTargetPart().getCollectionDescriptor();
+ session.getAuditWorkQueue().enqueueCollection(
+ collectionDescriptor,
+ collection,
+ id,
+ snapshot,
+ this,
+ session
+ );
+ }
+
+ private Object resolveSnapshot(
+ PersistentCollection> collection,
+ Object id,
+ SharedSessionContractImplementor session) {
+ final var persistenceContext = session.getPersistenceContextInternal();
+ final var collectionEntry = persistenceContext.getCollectionEntry( collection );
+ if ( collectionEntry != null && collectionEntry.getLoadedPersister() != null ) {
+ return collection.getStoredSnapshot();
}
- if ( auditOperationGroup == null ) {
+ else if ( collection instanceof PersistentArrayHolder> ) {
+ // Array holders are always newly wrapped references, need to retrieve the old instance from the PC
+ final var collectionDescriptor = mutationTarget.getTargetPart().getCollectionDescriptor();
+ final var oldCollection = persistenceContext.getCollection( new CollectionKey( collectionDescriptor, id ) );
+ return oldCollection != null ? oldCollection.getStoredSnapshot() : null;
+ }
+ return null;
+ }
+
+ /**
+ * Called by {@link AuditWorkQueue} at transaction completion.
+ * Computes the diff between the original snapshot and the current
+ * collection state, then writes ADD/DEL audit rows.
+ */
+ @Override
+ public void writeCollectionAuditRows(
+ PersistentCollection> collection,
+ Object id,
+ Object originalSnapshot,
+ SharedSessionContractImplementor session) {
+ final var operationGroup = getAuditHelper().getAuditInsertOperationGroup();
+ if ( operationGroup == null ) {
return;
}
- final var pluralAttribute = mutationTarget.getTargetPart();
- final var collectionDescriptor = pluralAttribute.getCollectionDescriptor();
- final var entries = collection.entries( collectionDescriptor );
- if ( entries.hasNext() ) {
- final var mutationExecutor = mutationExecutorService.createExecutor(
- () -> auditBatchKey,
- auditOperationGroup,
- session
- );
+ final var collectionDescriptor = mutationTarget.getTargetPart().getCollectionDescriptor();
+ final var mutationExecutor = mutationExecutorService.createExecutor(
+ () -> auditBatchKey,
+ operationGroup,
+ session
+ );
- try {
+ try {
+ final var bindings = getAuditHelper().getRowMutationHelper();
+ if ( originalSnapshot == null ) {
+ // New collection: write ADD for all current entries
+ final var entries = collection.entries( collectionDescriptor );
int entryCount = 0;
- final var bindings = getAuditHelper().getRowMutationHelper();
while ( entries.hasNext() ) {
final Object entry = entries.next();
- if ( entryChecker == null || entryChecker.include( entry, entryCount, collection,
- pluralAttribute ) ) {
- bindings.bindInsertValues(
- collection,
- id,
- entry,
- entryCount,
- AuditStateManagement.ModificationType.ADD,
- session,
- mutationExecutor.getJdbcValueBindings()
- );
- mutationExecutor.execute( entry, null, null, null, session );
- }
+ bindings.bindInsertValues(
+ collection,
+ id,
+ entry,
+ entryCount,
+ ModificationType.ADD,
+ session,
+ mutationExecutor.getJdbcValueBindings()
+ );
+ mutationExecutor.execute( entry, null, null, null, session );
entryCount++;
}
}
- finally {
- mutationExecutor.release();
+ else {
+ // Diff original snapshot vs final collection state
+ final var changes = computeCollectionChanges( collection, collectionDescriptor, originalSnapshot );
+ // Close previous rows' transaction end for elements being removed/replaced
+ updateElementTransactionEnd( collection, id, changes, session );
+ // Write ADD/DEL audit rows
+ for ( var change : changes ) {
+ bindings.bindInsertValues(
+ collection,
+ id,
+ change.rawEntry,
+ change.position,
+ change.modificationType,
+ session,
+ mutationExecutor.getJdbcValueBindings()
+ );
+ mutationExecutor.execute( change.rawEntry, null, null, null, session );
+ }
}
}
+ finally {
+ mutationExecutor.release();
+ }
+ }
+
+ /**
+ * For each DEL entry in the diff, update the corresponding previous
+ * audit row's REVEND to mark it as superseded.
+ */
+ private void updateElementTransactionEnd(
+ PersistentCollection> collection,
+ Object ownerId,
+ List changes,
+ SharedSessionContractImplementor session) {
+ final var updateGroup = getAuditHelper().getTransactionEndUpdateGroup();
+ if ( updateGroup == null ) {
+ return;
+ }
+ final var mutationExecutor = mutationExecutorService.createExecutor(
+ () -> auditBatchKey,
+ updateGroup,
+ session
+ );
+ try {
+ final var tableName = getAuditHelper().getAuditTableMapping().getTableName();
+ final var txId = session.getCurrentTransactionIdentifier();
+ final var auditMapping = mutationTarget.getTargetPart().getAuditMapping();
+ final var collectionTableName = mutationTarget.getCollectionTableMapping().getTableName();
+ final var revEndMapping = auditMapping.getTransactionEndMapping( collectionTableName );
+ final var bindings = getAuditHelper().getRowMutationHelper();
+
+ // Update REVEND for ALL changes (not just DEL) to cover both removed and replaced elements
+ for ( var change : changes ) {
+ final var jdbcValueBindings = mutationExecutor.getJdbcValueBindings();
+
+ // SET REVEND = :txId
+ jdbcValueBindings.bindValue(
+ txId,
+ tableName,
+ revEndMapping.getSelectionExpression(),
+ ParameterUsage.SET
+ );
+
+ // SET REVEND_TSTMP = :tstmp (if configured)
+ final var revEndTsMapping = auditMapping.getTransactionEndTimestampMapping( collectionTableName );
+ if ( revEndTsMapping != null ) {
+ jdbcValueBindings.bindValue(
+ java.time.Instant.now(),
+ tableName,
+ revEndTsMapping.getSelectionExpression(),
+ ParameterUsage.SET
+ );
+ }
+
+ // WHERE: bind key + index/element columns (same as INSERT but RESTRICT)
+ bindings.bindRestrictValues(
+ collection,
+ ownerId,
+ change.rawEntry,
+ change.position,
+ session,
+ jdbcValueBindings
+ );
+
+ // 0 rows is valid (element might not have a previous audit row)
+ mutationExecutor.execute( null, null, null, (s, c, b) -> true, session );
+ }
+ }
+ finally {
+ mutationExecutor.release();
+ }
+ }
+
+ /**
+ * An audit change to write: the raw entry, its position, and the modification type.
+ */
+ private record AuditChange(Object rawEntry, int position, ModificationType modificationType) {
+ }
+
+ /**
+ * Compute the set of ADD/DEL changes between the collection's snapshot and current
+ * state.
+ *
+ * For indexed collections (maps, lists with {@code @OrderColumn}), uses direct
+ * snapshot lookups by index/key. For non-indexed collections (sets, bags), uses
+ * linear scan.
+ */
+ private List computeCollectionChanges(
+ PersistentCollection> collection,
+ CollectionPersister collectionDescriptor,
+ Object snapshot) {
+ final Type elementType = collectionDescriptor.getElementType();
+ if ( collectionDescriptor.hasIndex() ) {
+ return snapshot instanceof Map, ?> ?
+ computeMapChanges( collection, collectionDescriptor, (Map, ?>) snapshot, elementType ) :
+ computeListChanges( collection, collectionDescriptor, snapshot, elementType );
+ }
+ else {
+ // Non-indexed (sets, bags): extract snapshot elements into a mutable list
+ final Collection> snapshotElements = snapshot instanceof Map, ?> snapshotMap
+ ? snapshotMap.values()
+ : (Collection>) snapshot;
+ return computeUnindexedChanges( collection, collectionDescriptor, snapshotElements, elementType );
+ }
+ }
+
+ /**
+ * Diff for maps: direct lookup by key in snapshot.
+ *
+ * @implNote Uses {@code Map.get()} for O(1) key lookup rather than linear scan. Safe because the map's
+ * own contract requires {@code equals()}/{@code hashCode()} consistency for keys, if a
+ * key is in the map, {@code get()} must find it.
+ */
+ private List computeMapChanges(
+ PersistentCollection> collection,
+ CollectionPersister collectionDescriptor,
+ Map, ?> snapshot,
+ Type elementType) {
+ final List changes = new ArrayList<>();
+ final var currentMap = (Map, ?>) collection;
+
+ // Current entries not matching snapshot: ADD
+ final var entries = collection.entries( collectionDescriptor );
+ int i = 0;
+ while ( entries.hasNext() ) {
+ final var entry = (Map.Entry, ?>) entries.next();
+ if ( entry.getValue() != null ) {
+ final Object snapshotValue = snapshot.get( entry.getKey() );
+ if ( snapshotValue == null || !elementType.isSame( entry.getValue(), snapshotValue ) ) {
+ changes.add( new AuditChange( entry, i, ModificationType.ADD ) );
+ }
+ }
+ i++;
+ }
+
+ // Snapshot entries not in current (or value changed): DEL
+ for ( var entry : snapshot.entrySet() ) {
+ if ( entry.getValue() != null ) {
+ final Object currentValue = currentMap.get( entry.getKey() );
+ if ( currentValue == null || !elementType.isSame( entry.getValue(), currentValue ) ) {
+ changes.add( new AuditChange( entry, i++, ModificationType.DEL ) );
+ }
+ }
+ }
+
+ return changes;
+ }
+
+ /**
+ * Diff for indexed lists and arrays: positional comparison against the snapshot.
+ */
+ private List computeListChanges(
+ PersistentCollection> collection,
+ CollectionPersister collectionDescriptor,
+ Object snapshot,
+ Type elementType) {
+ final List changes = new ArrayList<>();
+ final List> snapshotList = snapshot instanceof List> list ? list : null;
+ final int snapshotSize = snapshotList != null ? snapshotList.size() : Array.getLength( snapshot );
+
+ final var entries = collection.entries( collectionDescriptor );
+ int i = 0;
+ while ( entries.hasNext() ) {
+ final Object current = collection.getElement( entries.next() );
+ final Object old = i < snapshotSize ? ( snapshotList != null ? snapshotList.get( i ) : Array.get(
+ snapshot,
+ i
+ ) ) : null;
+ final boolean same = current != null && old != null && elementType.isSame( current, old );
+ if ( current != null && !same ) {
+ changes.add( new AuditChange( current, i, ModificationType.ADD ) );
+ }
+ if ( old != null && !same ) {
+ changes.add( new AuditChange( old, i, ModificationType.DEL ) );
+ }
+ i++;
+ }
+
+ // Snapshot positions beyond current size are all DELs
+ for ( ; i < snapshotSize; i++ ) {
+ final Object old = snapshotList != null ? snapshotList.get( i ) : Array.get( snapshot, i );
+ if ( old != null ) {
+ changes.add( new AuditChange( old, i, ModificationType.DEL ) );
+ }
+ }
+
+ return changes;
+ }
+
+ /**
+ * Diff for non-indexed collections (sets, bags): linear scan with mutable snapshot copy.
+ */
+ private List computeUnindexedChanges(
+ PersistentCollection> collection,
+ CollectionPersister collectionDescriptor,
+ Collection> snapshotElements,
+ Type elementType) {
+ final var remaining = new ArrayList<>( snapshotElements );
+ final List changes = new ArrayList<>();
+
+ final var entries = collection.entries( collectionDescriptor );
+ int i = 0;
+ while ( entries.hasNext() ) {
+ final Object element = collection.getElement( entries.next() );
+ if ( element != null ) {
+ boolean matched = false;
+ for ( var it = remaining.iterator(); it.hasNext(); ) {
+ if ( elementType.isSame( element, it.next() ) ) {
+ it.remove();
+ matched = true;
+ break;
+ }
+ }
+ if ( !matched ) {
+ changes.add( new AuditChange( element, i, ModificationType.ADD ) );
+ }
+ }
+ i++;
+ }
+
+ for ( var element : remaining ) {
+ changes.add( new AuditChange( element, i++, ModificationType.DEL ) );
+ }
+
+ return changes;
}
private AuditCollectionHelper getAuditHelper() {
diff --git a/hibernate-core/src/main/java/org/hibernate/persister/collection/mutation/RemoveCoordinatorAudit.java b/hibernate-core/src/main/java/org/hibernate/persister/collection/mutation/RemoveCoordinatorAudit.java
new file mode 100644
index 000000000000..1eb12eb0a0b7
--- /dev/null
+++ b/hibernate-core/src/main/java/org/hibernate/persister/collection/mutation/RemoveCoordinatorAudit.java
@@ -0,0 +1,157 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.persister.collection.mutation;
+
+import java.util.function.UnaryOperator;
+
+import org.hibernate.audit.spi.CollectionAuditWriter;
+import org.hibernate.collection.spi.PersistentCollection;
+import org.hibernate.engine.jdbc.batch.internal.BasicBatchKey;
+import org.hibernate.engine.jdbc.mutation.spi.MutationExecutorService;
+import org.hibernate.engine.spi.CollectionKey;
+import org.hibernate.engine.spi.SessionFactoryImplementor;
+import org.hibernate.engine.spi.SharedSessionContractImplementor;
+import org.hibernate.audit.ModificationType;
+
+/**
+ * RemoveCoordinator for audited collections.
+ * Delegates the bulk removal to the standard coordinator, then writes
+ * DEL audit rows to the collection's audit table for each entry that
+ * was in the collection.
+ */
+public class RemoveCoordinatorAudit implements RemoveCoordinator, CollectionAuditWriter {
+ private final RemoveCoordinator standardCoordinator;
+ private final CollectionMutationTarget mutationTarget;
+ private final SessionFactoryImplementor sessionFactory;
+ private final BasicBatchKey auditBatchKey;
+ private final MutationExecutorService mutationExecutorService;
+ private final boolean[] indexColumnIsSettable;
+ private final boolean[] elementColumnIsSettable;
+ private final UnaryOperator indexIncrementer;
+
+ private AuditCollectionHelper auditHelper;
+
+ public RemoveCoordinatorAudit(
+ CollectionMutationTarget mutationTarget,
+ RemoveCoordinator standardCoordinator,
+ boolean[] indexColumnIsSettable,
+ boolean[] elementColumnIsSettable,
+ UnaryOperator indexIncrementer,
+ SessionFactoryImplementor sessionFactory) {
+ this.mutationTarget = mutationTarget;
+ this.standardCoordinator = standardCoordinator;
+ this.sessionFactory = sessionFactory;
+ this.indexColumnIsSettable = indexColumnIsSettable;
+ this.elementColumnIsSettable = elementColumnIsSettable;
+ this.indexIncrementer = indexIncrementer;
+ this.auditBatchKey = new BasicBatchKey( mutationTarget.getRolePath() + "#AUDIT_REMOVE" );
+ this.mutationExecutorService = sessionFactory.getServiceRegistry()
+ .getService( MutationExecutorService.class );
+ }
+
+ @Override
+ public CollectionMutationTarget getMutationTarget() {
+ return mutationTarget;
+ }
+
+ @Override
+ public String getSqlString() {
+ return standardCoordinator.getSqlString();
+ }
+
+ @Override
+ public void deleteAllRows(Object key, SharedSessionContractImplementor session) {
+ final var collectionDescriptor = mutationTarget.getTargetPart().getCollectionDescriptor();
+
+ // Get the collection from the persistence context BEFORE bulk removal
+ final var collectionKey = new CollectionKey( collectionDescriptor, key );
+ final var collection = session.getPersistenceContextInternal().getCollection( collectionKey );
+ if ( collection != null && !collection.wasInitialized() ) {
+ collection.forceInitialization();
+ }
+
+ // Delegate to standard coordinator
+ standardCoordinator.deleteAllRows( key, session );
+
+ // Only write DEL audit rows for entity deletion (all snapshot elements are truly removed)
+ if ( collection != null && isEntityDeletion( key, session ) ) {
+ // Enqueue with snapshot = null to signal "write DEL for all current entries"
+ session.getAuditWorkQueue().enqueueCollection(
+ collectionDescriptor,
+ collection,
+ key,
+ null,
+ this,
+ session
+ );
+ }
+ }
+
+ @Override
+ public void writeCollectionAuditRows(
+ PersistentCollection> collection,
+ Object ownerId,
+ Object originalSnapshot,
+ SharedSessionContractImplementor session) {
+ final var operationGroup = getAuditHelper().getAuditInsertOperationGroup();
+ if ( operationGroup == null ) {
+ return;
+ }
+
+ final var collectionDescriptor = mutationTarget.getTargetPart().getCollectionDescriptor();
+ final var mutationExecutor = mutationExecutorService.createExecutor(
+ () -> auditBatchKey,
+ operationGroup,
+ session
+ );
+ try {
+ final var bindings = getAuditHelper().getRowMutationHelper();
+ final var entries = collection.entries( collectionDescriptor );
+ int entryCount = 0;
+ while ( entries.hasNext() ) {
+ final Object entry = entries.next();
+ bindings.bindInsertValues(
+ collection,
+ ownerId,
+ entry,
+ entryCount,
+ ModificationType.DEL,
+ session,
+ mutationExecutor.getJdbcValueBindings()
+ );
+ mutationExecutor.execute( entry, null, null, null, session );
+ entryCount++;
+ }
+ }
+ finally {
+ mutationExecutor.release();
+ }
+ }
+
+ private boolean isEntityDeletion(Object key, SharedSessionContractImplementor session) {
+ final var pc = session.getPersistenceContextInternal();
+ final var collectionDescriptor = mutationTarget.getTargetPart().getCollectionDescriptor();
+ final var owner = pc.getCollectionOwner( key, collectionDescriptor );
+ if ( owner != null ) {
+ final var ownerEntry = pc.getEntry( owner );
+ return ownerEntry != null && ownerEntry.getStatus().isDeletedOrGone();
+ }
+ return true;
+ }
+
+ private AuditCollectionHelper getAuditHelper() {
+ if ( auditHelper == null ) {
+ auditHelper = new AuditCollectionHelper(
+ mutationTarget,
+ sessionFactory,
+ indexColumnIsSettable,
+ elementColumnIsSettable,
+ indexIncrementer,
+ mutationTarget.getTargetPart().getAuditMapping()
+ );
+ }
+ return auditHelper;
+ }
+}
diff --git a/hibernate-core/src/main/java/org/hibernate/persister/collection/mutation/UpdateRowsCoordinatorAudit.java b/hibernate-core/src/main/java/org/hibernate/persister/collection/mutation/UpdateRowsCoordinatorAudit.java
deleted file mode 100644
index 288f96b73fe6..000000000000
--- a/hibernate-core/src/main/java/org/hibernate/persister/collection/mutation/UpdateRowsCoordinatorAudit.java
+++ /dev/null
@@ -1,151 +0,0 @@
-/*
- * SPDX-License-Identifier: Apache-2.0
- * Copyright Red Hat Inc. and Hibernate Authors
- */
-package org.hibernate.persister.collection.mutation;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.function.UnaryOperator;
-
-import org.hibernate.collection.spi.PersistentCollection;
-import org.hibernate.engine.jdbc.batch.internal.BasicBatchKey;
-import org.hibernate.engine.jdbc.mutation.spi.MutationExecutorService;
-import org.hibernate.engine.spi.SessionFactoryImplementor;
-import org.hibernate.engine.spi.SharedSessionContractImplementor;
-import org.hibernate.metamodel.mapping.PluralAttributeMapping;
-import org.hibernate.persister.collection.CollectionPersister;
-import org.hibernate.persister.state.internal.AuditStateManagement;
-import org.hibernate.sql.model.MutationOperationGroup;
-
-/**
- * UpdateRowsCoordinator for audited collections.
- */
-public class UpdateRowsCoordinatorAudit implements UpdateRowsCoordinator {
- private final CollectionMutationTarget mutationTarget;
- private final UpdateRowsCoordinator currentUpdateCoordinator;
- private final SessionFactoryImplementor sessionFactory;
- private final MutationExecutorService mutationExecutorService;
- private final BasicBatchKey auditBatchKey;
- private final boolean[] indexColumnIsSettable;
- private final boolean[] elementColumnIsSettable;
- private final UnaryOperator indexIncrementer;
-
- private MutationOperationGroup auditOperationGroup;
- private AuditCollectionHelper auditHelper;
-
- public UpdateRowsCoordinatorAudit(
- CollectionMutationTarget mutationTarget,
- UpdateRowsCoordinator currentUpdateCoordinator,
- boolean[] indexColumnIsSettable,
- boolean[] elementColumnIsSettable,
- UnaryOperator indexIncrementer,
- SessionFactoryImplementor sessionFactory) {
- this.mutationTarget = mutationTarget;
- this.currentUpdateCoordinator = currentUpdateCoordinator;
- this.sessionFactory = sessionFactory;
- this.indexColumnIsSettable = indexColumnIsSettable;
- this.elementColumnIsSettable = elementColumnIsSettable;
- this.indexIncrementer = indexIncrementer;
- this.auditBatchKey = new BasicBatchKey( mutationTarget.getRolePath() + "#AUDIT_INSERT" );
- this.mutationExecutorService = sessionFactory.getServiceRegistry().getService( MutationExecutorService.class );
- }
-
- @Override
- public CollectionMutationTarget getMutationTarget() {
- return mutationTarget;
- }
-
- @Override
- public void updateRows(Object key, PersistentCollection> collection, SharedSessionContractImplementor session) {
- final var attribute = mutationTarget.getTargetPart();
- final var collectionDescriptor = attribute.getCollectionDescriptor();
- final var rowsToAudit = collectUpdatedRows( collection, attribute, collectionDescriptor );
-
- currentUpdateCoordinator.updateRows( key, collection, session );
-
- if ( !rowsToAudit.isEmpty() ) {
- if ( auditOperationGroup == null ) {
- auditOperationGroup = getAuditHelper().getAuditInsertOperationGroup();
- }
- if ( auditOperationGroup != null ) {
- final var auditExecutor = mutationExecutorService.createExecutor(
- () -> auditBatchKey,
- auditOperationGroup,
- session
- );
- try {
- final var bindings = getAuditHelper().getRowMutationHelper();
- for ( var row : rowsToAudit ) {
- bindings.bindInsertValues(
- collection,
- key,
- row.entry,
- row.position,
- AuditStateManagement.ModificationType.MOD,
- session,
- auditExecutor.getJdbcValueBindings()
- );
- auditExecutor.execute( row.entry, null, null, null, session );
- }
- }
- finally {
- auditExecutor.release();
- }
- }
- }
- }
-
- private AuditCollectionHelper getAuditHelper() {
- if ( auditHelper == null ) {
- auditHelper = new AuditCollectionHelper(
- mutationTarget,
- sessionFactory,
- indexColumnIsSettable,
- elementColumnIsSettable,
- indexIncrementer,
- mutationTarget.getTargetPart().getAuditMapping()
- );
- }
- return auditHelper;
- }
-
- private List collectUpdatedRows(
- PersistentCollection> collection,
- PluralAttributeMapping attribute,
- CollectionPersister persister) {
- final List rows = new ArrayList<>();
- final var entries = collection.entries( persister );
- if ( !entries.hasNext() ) {
- return rows;
- }
-
- if ( collection.isElementRemoved() ) {
- final List elements = new ArrayList<>();
- while ( entries.hasNext() ) {
- elements.add( entries.next() );
- }
- for ( int i = elements.size() - 1; i >= 0; i-- ) {
- final Object entry = elements.get( i );
- if ( collection.needsUpdating( entry, i, attribute ) ) {
- rows.add( new RowReference( entry, i ) );
- }
- }
- }
- else {
- int position = 0;
- while ( entries.hasNext() ) {
- final Object entry = entries.next();
- if ( collection.needsUpdating( entry, position, attribute ) ) {
- rows.add( new RowReference( entry, position ) );
- }
- position++;
- }
- }
-
- return rows;
- }
-
- private record RowReference(Object entry, int position) {
- }
-}
diff --git a/hibernate-core/src/main/java/org/hibernate/persister/entity/AbstractEntityPersister.java b/hibernate-core/src/main/java/org/hibernate/persister/entity/AbstractEntityPersister.java
index f0ecc8e1464e..c8b963121f71 100644
--- a/hibernate-core/src/main/java/org/hibernate/persister/entity/AbstractEntityPersister.java
+++ b/hibernate-core/src/main/java/org/hibernate/persister/entity/AbstractEntityPersister.java
@@ -48,7 +48,6 @@
import org.hibernate.engine.profile.internal.FetchProfileAffectee;
import org.hibernate.engine.spi.CachedNaturalIdValueSource;
import org.hibernate.engine.spi.CascadeStyle;
-import org.hibernate.engine.spi.CollectionKey;
import org.hibernate.engine.spi.EntityEntry;
import org.hibernate.engine.spi.EntityEntryFactory;
import org.hibernate.engine.spi.EntityKey;
@@ -1411,7 +1410,7 @@ public TableReference createPrimaryTableReference(
&& auxiliaryMapping.useAuxiliaryTable( loadQueryInfluencers );
final String primaryTableName =
useAuxiliaryTable
- ? auxiliaryMapping.getTableName()
+ ? auxiliaryMapping.resolveTableName( getTableName() )
: getTableName();
final String primaryAlias = sqlAliasBase.generateNewAlias();
final var tableReference =
@@ -1633,7 +1632,7 @@ private static PersistentCollection> getCollection(
final var entry = persistenceContext.getEntry( entity );
final Object key = getCollectionKey( persister, entity, entry, session );
assert key != null;
- final var collection = persistenceContext.getCollection( new CollectionKey( persister, key ) );
+ final var collection = persistenceContext.getCollection( session.generateCollectionKey( persister, key ) );
if ( collection == null ) {
final var newCollection = collectionType.instantiate( session, persister, key );
newCollection.setOwner( entity );
@@ -2918,18 +2917,17 @@ public TableGroup createRootTableGroup(
final boolean useAuxiliaryTable =
auxiliaryMapping != null
&& auxiliaryMapping.useAuxiliaryTable( loadQueryInfluencers );
+ final String originalTableName = needsDiscriminator() ? getRootTableName() : getTableName();
final String rootTableName =
useAuxiliaryTable
- ? auxiliaryMapping.getTableName()
- : needsDiscriminator() ? getRootTableName() : getTableName();
+ ? auxiliaryMapping.resolveTableName( originalTableName )
+ : originalTableName;
final String rootAlias = sqlAliasBase.generateNewAlias();
final var rootTableReference =
useAuxiliaryTable
? new AuxiliaryTableReference(
rootTableName,
- needsDiscriminator()
- ? getRootTableName()
- : getTableName(),
+ originalTableName,
rootAlias
)
: new NamedTableReference( rootTableName, rootAlias );
@@ -2947,14 +2945,24 @@ public TableGroup createRootTableGroup(
(tableExpression, group) -> {
final var subclassTableNames = getSubclassTableNames();
for ( int i = 0; i < subclassTableNames.length; i++ ) {
- if ( tableExpression.equals( subclassTableNames[ i ] ) ) {
- final var joinedTableReference = new NamedTableReference(
- tableExpression,
- sqlAliasBase.generateNewAlias(),
- isNullableSubclassTable( i )
- );
+ if ( tableExpression.equals( subclassTableNames[i] ) ) {
+ final String auxiliaryTableName = useAuxiliaryTable
+ ? auxiliaryMapping.resolveTableName( tableExpression )
+ : null;
+ final var joinedTableReference = auxiliaryTableName != null
+ ? new AuxiliaryTableReference(
+ auxiliaryTableName,
+ tableExpression,
+ sqlAliasBase.generateNewAlias(),
+ isNullableSubclassTable( i )
+ )
+ : new NamedTableReference(
+ tableExpression,
+ sqlAliasBase.generateNewAlias(),
+ isNullableSubclassTable( i )
+ );
joinedTableReference.applyAuxiliaryTable( auxiliaryMapping, loadQueryInfluencers );
- return new TableReferenceJoin(
+ final var tableReferenceJoin = new TableReferenceJoin(
shouldInnerJoinSubclassTable( i, emptySet() ),
joinedTableReference,
additionalPredicateCollector == null
@@ -2969,6 +2977,17 @@ public TableGroup createRootTableGroup(
creationState
)
);
+ if ( auxiliaryTableName != null ) {
+ auxiliaryMapping.applyPredicate(
+ tableReferenceJoin,
+ rootTableReference,
+ tableExpression,
+ AbstractEntityPersister.this,
+ creationState.getSqlAliasBaseGenerator(),
+ loadQueryInfluencers
+ );
+ }
+ return tableReferenceJoin;
}
}
return null;
@@ -3303,6 +3322,11 @@ public void prepareLoaders() {
lazyLoadPlanByFetchGroup = getLazyLoadPlanByFetchGroup();
+ final var auditMapping = getAuditMapping();
+ if ( auditMapping != null ) {
+ auditMapping.getEntityLoader();
+ }
+
logStaticSQL();
}
@@ -4983,7 +5007,9 @@ private void prepareMappingModel(MappingModelCreationProcess creationProcess, Pe
(role, process) -> new EntityRowIdMappingImpl( rowIdName, getTableName(), this ) );
discriminatorMapping = generateDiscriminatorMapping( bootEntityDescriptor );
final var rootClass = bootEntityDescriptor.getRootClass();
- auxiliaryMapping = stateManagement.createAuxiliaryMapping( this, rootClass, creationProcess );
+ auxiliaryMapping = rootClass == bootEntityDescriptor ?
+ stateManagement.createAuxiliaryMapping( this, rootClass, creationProcess ) :
+ superMappingType.getAuxiliaryMapping();
if ( auxiliaryMapping instanceof SoftDeleteMapping && rootClass.getCustomSQLDelete() != null ) {
throw new UnsupportedMappingException( "Entity may not define both @SoftDelete and @SQLDelete" );
}
diff --git a/hibernate-core/src/main/java/org/hibernate/persister/entity/UnionSubclassEntityPersister.java b/hibernate-core/src/main/java/org/hibernate/persister/entity/UnionSubclassEntityPersister.java
index 9c9ec3cb7591..8384c7c1b863 100644
--- a/hibernate-core/src/main/java/org/hibernate/persister/entity/UnionSubclassEntityPersister.java
+++ b/hibernate-core/src/main/java/org/hibernate/persister/entity/UnionSubclassEntityPersister.java
@@ -14,12 +14,14 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
+
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
import org.hibernate.AssertionFailure;
import org.hibernate.HibernateException;
+
import org.hibernate.Internal;
import org.hibernate.MappingException;
import org.hibernate.cache.spi.access.EntityDataAccess;
@@ -230,9 +232,30 @@ public boolean containsTableReference(String tableExpression) {
public UnionTableReference createPrimaryTableReference(
SqlAliasBase sqlAliasBase,
SqlAstCreationState creationState) {
- return new UnionTableReference(
- getTableName(),
- subclassTableExpressions,
+ final var loadQueryInfluencers = creationState.getLoadQueryInfluencers();
+ final var auxMapping = getAuxiliaryMapping();
+ final boolean useAuxiliaryTable =
+ auxMapping != null
+ && auxMapping.useAuxiliaryTable( loadQueryInfluencers );
+ final String resolvedTableName = useAuxiliaryTable
+ ? auxMapping.resolveTableName( getTableName() )
+ : getTableName();
+ final String[] resolvedTableExpressions;
+ if ( useAuxiliaryTable ) {
+ // Include both original and audit table expressions for resolution
+ final var resolved = new ArrayList( subclassTableExpressions.length * 2 );
+ for ( String expr : subclassTableExpressions ) {
+ resolved.add( expr );
+ resolved.add( auxMapping.resolveTableName( expr ) );
+ }
+ resolvedTableExpressions = resolved.toArray( String[]::new );
+ }
+ else {
+ resolvedTableExpressions = subclassTableExpressions;
+ }
+ final var tableReference = new UnionTableReference(
+ resolvedTableName,
+ resolvedTableExpressions,
SqlAliasBase.from(
sqlAliasBase,
null,
@@ -240,6 +263,8 @@ public UnionTableReference createPrimaryTableReference(
creationState.getSqlAliasBaseGenerator()
).generateNewAlias()
);
+ tableReference.applyAuxiliaryTable( auxMapping, loadQueryInfluencers );
+ return tableReference;
}
@Override
@@ -257,16 +282,17 @@ public TableGroup createRootTableGroup(
this,
explicitSourceAlias
);
- final var softDeleteMapping = getSoftDeleteMapping();
- if ( additionalPredicateCollectorAccess != null && softDeleteMapping != null ) {
- final var tableReference =
- tableGroup.resolveTableReference( getSoftDeleteTableDetails().getTableName() );
- additionalPredicateCollectorAccess.get().accept(
- softDeleteMapping.createNonDeletedRestriction(
- tableReference,
- creationState.getSqlExpressionResolver()
- )
- );
+ if ( additionalPredicateCollectorAccess != null ) {
+ final var auxMapping = getAuxiliaryMapping();
+ if ( auxMapping != null ) {
+ auxMapping.applyPredicate(
+ additionalPredicateCollectorAccess,
+ creationState,
+ tableGroup,
+ tableGroup.getPrimaryTableReference(),
+ this
+ );
+ }
}
return tableGroup;
}
@@ -386,8 +412,10 @@ public void pruneForSubclasses(TableGroup tableGroup, Map
if ( tableReference == null ) {
throw new UnknownTableReferenceException( getRootTableName(), "Couldn't find table reference" );
}
- // Replace the default union sub-query with a specially created one that only selects the tables for the treated entity names
- tableReference.setPrunedTableExpression( generateSubquery( entityNameUses ) );
+ tableReference.setPrunedTableExpression( generateSubquery(
+ entityNameUses,
+ tableReference.getTableExpression()
+ ) );
}
@Override
@@ -434,16 +462,33 @@ public int getTableSpan() {
return 1;
}
+
@Override
protected int[] getPropertyTableNumbers() {
return new int[getPropertySpan()];
}
protected String generateSubquery(PersistentClass model) {
+ return generateSubquery( model, null, null );
+ }
+
+ /**
+ * Generate a union subquery for the given model.
+ *
+ * @param tableNameResolver when non-null, resolves original table names to
+ * alternative names (e.g. audit table names)
+ * @param extraSelectExpressions additional column expressions to include in
+ * each SELECT of the union (e.g. REV, REVTYPE)
+ */
+ public String generateSubquery(
+ PersistentClass model,
+ Function tableNameResolver,
+ List extraSelectExpressions) {
final var factory = getFactory();
final var sqlStringGenerationContext = factory.getSqlStringGenerationContext();
if ( !model.hasSubclasses() ) {
- return model.getTable().getQualifiedName( sqlStringGenerationContext );
+ final String qualifiedName = model.getTable().getQualifiedName( sqlStringGenerationContext );
+ return tableNameResolver != null ? tableNameResolver.apply( qualifiedName ) : qualifiedName;
}
else {
final Set columns = new LinkedHashSet<>();
@@ -476,9 +521,18 @@ protected String generateSubquery(PersistentClass model) {
subquery.append( column.getQuotedName( dialect ) )
.append( ", " );
}
+ if ( extraSelectExpressions != null ) {
+ for ( var expr : extraSelectExpressions ) {
+ subquery.append( expr ).append( ", " );
+ }
+ }
subquery.append( persistentClass.getSubclassId() )
- .append( " as clazz_ from " )
- .append( table.getQualifiedName( sqlStringGenerationContext ) );
+ .append( " as clazz_" );
+ final String qualifiedName = table.getQualifiedName( sqlStringGenerationContext );
+ subquery.append( " from " )
+ .append( tableNameResolver != null
+ ? tableNameResolver.apply( qualifiedName )
+ : qualifiedName );
}
}
return subquery.append( ")" ).toString();
@@ -499,9 +553,10 @@ private String getSelectClauseNullString(Column column, Dialect dialect) {
);
}
- protected String generateSubquery(Map entityNameUses) {
+ protected String generateSubquery(Map entityNameUses, String currentTableExpression) {
+ final var auxMapping = currentTableExpression.equals( getTableName() ) ? null : getAuxiliaryMapping();
if ( !hasSubclasses() ) {
- return getTableName();
+ return currentTableExpression;
}
final var factory = getFactory();
@@ -531,7 +586,7 @@ protected String generateSubquery(Map entityNameUses) {
}
if ( tablesToUnion.isEmpty() ) {
// If there are only projection or expression uses, we can't optimize anything
- return getTableName();
+ return currentTableExpression;
}
}
@@ -573,9 +628,17 @@ protected String generateSubquery(Map entityNameUses) {
unionSubquery.append( selectable )
.append( ", " );
}
+ if ( auxMapping != null ) {
+ for ( var expr : auxMapping.getExtraSelectExpressions() ) {
+ unionSubquery.append( expr ).append( ", " );
+ }
+ }
unionSubquery.append( persister.getDiscriminatorSQLValue() )
- .append( " as clazz_ from " )
- .append( subclassTableName );
+ .append( " as clazz_" );
+ unionSubquery.append( " from " )
+ .append( auxMapping != null
+ ? auxMapping.resolveTableName( subclassTableName )
+ : subclassTableName );
}
}
return unionSubquery.append( ")" ).toString();
diff --git a/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/AbstractAuditCoordinator.java b/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/AbstractAuditCoordinator.java
index 4f6ff8741384..5d5893bb88c9 100644
--- a/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/AbstractAuditCoordinator.java
+++ b/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/AbstractAuditCoordinator.java
@@ -5,60 +5,70 @@
package org.hibernate.persister.entity.mutation;
import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.List;
+import org.hibernate.MappingException;
+import org.hibernate.audit.AuditException;
+import org.hibernate.audit.ModificationType;
+import org.hibernate.audit.spi.AuditWriter;
import org.hibernate.engine.jdbc.batch.internal.BasicBatchKey;
import org.hibernate.engine.jdbc.batch.spi.BatchKey;
import org.hibernate.engine.jdbc.mutation.JdbcValueBindings;
import org.hibernate.engine.jdbc.mutation.ParameterUsage;
import org.hibernate.engine.jdbc.mutation.group.PreparedStatementDetails;
+import org.hibernate.engine.spi.EntityKey;
import org.hibernate.engine.spi.SessionFactoryImplementor;
import org.hibernate.engine.spi.SharedSessionContractImplementor;
-import org.hibernate.MappingException;
import org.hibernate.generator.Generator;
import org.hibernate.generator.OnExecutionGenerator;
import org.hibernate.metamodel.mapping.AttributeMapping;
+import org.hibernate.metamodel.mapping.AuditMapping;
+import org.hibernate.metamodel.mapping.DiscriminatorValue;
import org.hibernate.metamodel.mapping.PluralAttributeMapping;
-import org.hibernate.metamodel.mapping.SelectableMapping;
import org.hibernate.persister.entity.EntityPersister;
-import org.hibernate.persister.state.internal.AuditStateManagement;
+import org.hibernate.sql.ast.tree.expression.ColumnReference;
import org.hibernate.sql.model.MutationOperationGroup;
import org.hibernate.sql.model.MutationType;
+import org.hibernate.sql.model.ast.ColumnValueBinding;
+import org.hibernate.sql.model.ast.ColumnWriteFragment;
+import org.hibernate.sql.model.ast.TableMutation;
import org.hibernate.sql.model.ast.builder.TableInsertBuilderStandard;
+import org.hibernate.sql.model.ast.builder.TableUpdateBuilderStandard;
import org.hibernate.sql.model.internal.MutationGroupSingle;
+import org.hibernate.sql.model.internal.MutationGroupStandard;
import static org.hibernate.persister.entity.mutation.InsertCoordinatorStandard.getPropertiesToInsert;
import static org.hibernate.sql.model.internal.MutationOperationGroupFactory.singleOperation;
/**
* Base support for audit log insert coordinators.
+ *
+ * Supports all inheritance strategies: for SINGLE_TABLE / TABLE_PER_CLASS
+ * there is one audit table; for JOINED there is one per entity table.
+ * The static operation group is cached for reuse.
*/
-abstract class AbstractAuditCoordinator extends AbstractMutationCoordinator {
- protected final EntityTableMapping identifierTableMapping;
- protected final EntityTableMapping auditTableMapping;
- protected final String auditTableName;
+abstract class AbstractAuditCoordinator extends AbstractMutationCoordinator implements AuditWriter {
+ private final AuditMapping auditMapping;
+ private final EntityTableMapping[] auditTableMappings;
protected final boolean[] auditedPropertyMask;
- private final SelectableMapping transactionIdMapping;
- private final SelectableMapping modificationTypeMapping;
protected final BasicBatchKey auditBatchKey;
private final boolean useServerTransactionTimestamps;
private final String currentTimestampFunctionName;
private final MutationOperationGroup staticAuditInsertGroup;
+ private final MutationOperationGroup transactionEndUpdateGroup;
protected AbstractAuditCoordinator(EntityPersister entityPersister, SessionFactoryImplementor factory) {
super( entityPersister, factory );
- this.identifierTableMapping = entityPersister.getIdentifierTableMapping();
- final var auditMapping = entityPersister.getAuditMapping();
+ this.auditMapping = entityPersister.getAuditMapping();
if ( auditMapping == null ) {
throw new MappingException( "No audit mapping available for " + entityPersister.getEntityName() );
}
- this.auditTableName = auditMapping.getTableName();
- this.auditTableMapping = createAuxiliaryTableMapping( identifierTableMapping, entityPersister, auditTableName );
+ this.auditTableMappings = buildAuditTableMappings( entityPersister, auditMapping );
this.auditedPropertyMask = new boolean[entityPersister.getPropertySpan()];
for ( int i = 0; i < this.auditedPropertyMask.length; i++ ) {
this.auditedPropertyMask[i] = !entityPersister.isPropertyAuditedExcluded( i );
}
- this.transactionIdMapping = auditMapping.getTransactionIdMapping();
- this.modificationTypeMapping = auditMapping.getModificationTypeMapping();
this.useServerTransactionTimestamps =
factory.getTransactionIdentifierService().useServerTimestamp( dialect() );
this.currentTimestampFunctionName = useServerTransactionTimestamps ? dialect().currentTimestamp() : null;
@@ -66,50 +76,88 @@ protected AbstractAuditCoordinator(EntityPersister entityPersister, SessionFacto
this.staticAuditInsertGroup = entityPersister.isDynamicInsert()
? null
: buildAuditInsertGroup( applyAuditMask( entityPersister.getPropertyInsertability() ), null, null );
+ this.transactionEndUpdateGroup = buildTransactionEndUpdateGroup();
+ }
+
+ private EntityTableMapping[] buildAuditTableMappings(EntityPersister persister, AuditMapping auditMapping) {
+ final EntityTableMapping[] sourceMappings = persister.getTableMappings();
+ final EntityTableMapping[] result = new EntityTableMapping[sourceMappings.length];
+ for ( int i = 0; i < sourceMappings.length; i++ ) {
+ final EntityTableMapping source = sourceMappings[i];
+ if ( source.isInverse() ) {
+ continue;
+ }
+ final String auditTableName = auditMapping.resolveTableName( source.getTableName() );
+ result[i] = createAuxiliaryTableMapping( source, persister, auditTableName );
+ }
+ return result;
}
- protected void insertAuditRow(
+ /**
+ * Enqueue an audit entry for deferred writing at transaction completion.
+ */
+ protected void enqueueAuditEntry(
Object entity,
- Object id,
Object[] values,
- AuditStateManagement.ModificationType modificationType,
+ ModificationType modificationType,
SharedSessionContractImplementor session) {
- if ( values != null ) {
- final boolean dynamicInsert = entityPersister().isDynamicInsert();
- final boolean[] propertyInclusions = applyAuditMask(
- dynamicInsert
- ? getPropertiesToInsert( entityPersister(), values )
- : entityPersister().getPropertyInsertability()
- );
- final MutationOperationGroup operationGroup = dynamicInsert
- ? buildAuditInsertGroup( propertyInclusions, entity, session )
- : staticAuditInsertGroup;
-
- final Object resolvedId = id != null
- ? id
- : entity != null ? entityPersister().getIdentifier( entity, session ) : null;
- if ( resolvedId == null || operationGroup == null ) {
- return;
- }
+ final var entityEntry = session.getPersistenceContextInternal().getEntry( entity );
+ session.getAuditWorkQueue().enqueue(
+ entityEntry.getEntityKey(),
+ entity,
+ values,
+ modificationType,
+ this,
+ session
+ );
+ }
- final var mutationExecutor = mutationExecutorService.createExecutor(
- resolveBatchKeyAccess( dynamicInsert, session ),
- operationGroup,
- session
- );
- try {
- bindAuditValues( resolvedId, values, propertyInclusions, modificationType, session,
- mutationExecutor.getJdbcValueBindings() );
- mutationExecutor.execute( entity, null, null, AbstractAuditCoordinator::verifyOutcome, session );
- }
- finally {
- mutationExecutor.release();
- }
+ /**
+ * Write an audit row, called by {@link org.hibernate.audit.spi.AuditWorkQueue}
+ * at transaction completion.
+ */
+ @Override
+ public void writeAuditRow(
+ EntityKey entityKey,
+ Object entity,
+ Object[] values,
+ ModificationType modificationType,
+ SharedSessionContractImplementor session) {
+ final var id = entityKey.getIdentifier();
+ updatePreviousTransactionEnd( id, modificationType, session );
+
+ final boolean dynamicInsert = entityPersister().isDynamicInsert();
+ final boolean[] propertyInclusions = applyAuditMask(
+ dynamicInsert
+ ? getPropertiesToInsert( entityPersister(), values )
+ : entityPersister().getPropertyInsertability()
+ );
+ final MutationOperationGroup operationGroup = dynamicInsert
+ ? buildAuditInsertGroup( propertyInclusions, entity, session )
+ : staticAuditInsertGroup;
+ if ( operationGroup == null ) {
+ return;
}
- }
- protected BatchKey getAuditBatchKey() {
- return auditBatchKey;
+ final var mutationExecutor = mutationExecutorService.createExecutor(
+ resolveBatchKeyAccess( dynamicInsert, session ),
+ operationGroup,
+ session
+ );
+ try {
+ bindAuditValues(
+ id,
+ values,
+ propertyInclusions,
+ modificationType,
+ session,
+ mutationExecutor.getJdbcValueBindings()
+ );
+ mutationExecutor.execute( entity, null, null, AbstractAuditCoordinator::verifyOutcome, session );
+ }
+ finally {
+ mutationExecutor.release();
+ }
}
@Override
@@ -117,115 +165,309 @@ protected BatchKey getBatchKey() {
return auditBatchKey;
}
+ /**
+ * Build a {@link MutationOperationGroup} with one INSERT per audit table.
+ * Each table's insert builder gets only the attributes belonging to that
+ * table via the source table mapping's {@code getAttributeIndexes()}.
+ */
private MutationOperationGroup buildAuditInsertGroup(
boolean[] propertyInclusions,
Object entity,
SharedSessionContractImplementor session) {
- final var insertBuilder =
- new TableInsertBuilderStandard( entityPersister(), auditTableMapping, factory() );
- applyAuditInsertDetails( insertBuilder, propertyInclusions, entity, session );
- final var tableMutation = insertBuilder.buildMutation();
- return singleOperation(
- new MutationGroupSingle( MutationType.INSERT, entityPersister(), tableMutation ),
- tableMutation.createMutationOperation( null, factory() )
- );
- }
-
- private void applyAuditInsertDetails(
- TableInsertBuilderStandard insertBuilder,
- boolean[] propertyInclusions,
- Object entity,
- SharedSessionContractImplementor session) {
+ final EntityTableMapping[] sourceMappings = entityPersister().getTableMappings();
final var attributeMappings = entityPersister().getAttributeMappings();
- for ( final int attributeIndex : identifierTableMapping.getAttributeIndexes() ) {
- final var attributeMapping = attributeMappings.get( attributeIndex );
- if ( propertyInclusions[attributeIndex] ) {
- attributeMapping.forEachInsertable( insertBuilder );
+ final List> mutations = new ArrayList<>( auditTableMappings.length );
+
+ for ( int i = 0; i < auditTableMappings.length; i++ ) {
+ if ( auditTableMappings[i] == null ) {
+ continue;
}
- else {
- final var generator = attributeMapping.getGenerator();
- if ( isValueGenerated( generator ) ) {
- if ( entity != null && generator.generatedBeforeExecution( entity, session ) ) {
- propertyInclusions[attributeIndex] = true;
- attributeMapping.forEachInsertable( insertBuilder );
- }
- else if ( isValueGenerationInSql( generator ) ) {
- addSqlGeneratedValue( insertBuilder, attributeMapping, (OnExecutionGenerator) generator );
+ final var insertBuilder =
+ new TableInsertBuilderStandard( entityPersister(), auditTableMappings[i], factory() );
+
+ // Route attributes to this builder using the source table's attribute indexes
+ final var sourceMapping = sourceMappings[i];
+ for ( final int attributeIndex : sourceMapping.getAttributeIndexes() ) {
+ final var attributeMapping = attributeMappings.get( attributeIndex );
+ if ( propertyInclusions[attributeIndex] ) {
+ attributeMapping.forEachInsertable( insertBuilder );
+ }
+ else {
+ final var generator = attributeMapping.getGenerator();
+ if ( isValueGenerated( generator ) ) {
+ if ( entity != null && generator.generatedBeforeExecution( entity, session ) ) {
+ propertyInclusions[attributeIndex] = true;
+ attributeMapping.forEachInsertable( insertBuilder );
+ }
+ else if ( isValueGenerationInSql( generator ) ) {
+ addSqlGeneratedValue( insertBuilder, attributeMapping, (OnExecutionGenerator) generator );
+ }
}
}
}
- }
- if ( useServerTransactionTimestamps ) {
- insertBuilder.addValueColumn( currentTimestampFunctionName, getTransactionIdMapping() );
- }
- else {
- insertBuilder.addValueColumn( "?", getTransactionIdMapping() );
+ // Discriminator belongs to the identifier table only
+ if ( sourceMapping.isIdentifierTable() ) {
+ final var discriminatorMapping = entityPersister().getDiscriminatorMapping();
+ if ( discriminatorMapping != null && discriminatorMapping.hasPhysicalColumn()
+ && entityPersister().getDiscriminatorValue() instanceof DiscriminatorValue.Literal ) {
+ insertBuilder.addValueColumn(
+ entityPersister().getDiscriminatorSQLValue(),
+ discriminatorMapping
+ );
+ }
+ }
+
+ // Audit columns (on every audit table)
+ final var sourceTableName = sourceMapping.getTableName();
+ final var txIdMapping = auditMapping.getTransactionIdMapping( sourceTableName );
+ final var modTypeMapping = auditMapping.getModificationTypeMapping( sourceTableName );
+ if ( useServerTransactionTimestamps ) {
+ insertBuilder.addValueColumn( currentTimestampFunctionName, txIdMapping );
+ }
+ else {
+ insertBuilder.addValueColumn( "?", txIdMapping );
+ }
+ if ( modTypeMapping != null ) {
+ insertBuilder.addValueColumn( "?", modTypeMapping );
+ }
+
+ // Key columns
+ sourceMapping.getKeyMapping().forEachKeyColumn( insertBuilder::addKeyColumn );
+
+ mutations.add( insertBuilder.buildMutation() );
}
- insertBuilder.addValueColumn( "?", getModificationTypeMapping() );
- identifierTableMapping.getKeyMapping().forEachKeyColumn( insertBuilder::addKeyColumn );
+ if ( mutations.size() == 1 ) {
+ return singleOperation(
+ new MutationGroupSingle( MutationType.INSERT, entityPersister(), mutations.get( 0 ) ),
+ mutations.get( 0 ).createMutationOperation( null, factory() )
+ );
+ }
+ return createOperationGroup(
+ null,
+ new MutationGroupStandard( MutationType.INSERT, entityPersister(), mutations )
+ );
}
private void bindAuditValues(
Object id,
Object[] values,
boolean[] propertyInclusions,
- AuditStateManagement.ModificationType modificationType,
+ ModificationType modificationType,
SharedSessionContractImplementor session,
JdbcValueBindings jdbcValueBindings) {
- final String tableName = auditTableName;
- auditTableMapping.getKeyMapping().breakDownKeyJdbcValues(
- id,
- (jdbcValue, columnMapping) -> jdbcValueBindings.bindValue(
- jdbcValue,
+ final var attributeMappings = entityPersister().getAttributeMappings();
+ final EntityTableMapping[] sourceMappings = entityPersister().getTableMappings();
+
+ for ( int tableIndex = 0; tableIndex < auditTableMappings.length; tableIndex++ ) {
+ if ( auditTableMappings[tableIndex] == null ) {
+ continue;
+ }
+ final String tableName = auditTableMappings[tableIndex].getTableName();
+
+ // Key columns
+ sourceMappings[tableIndex].getKeyMapping().breakDownKeyJdbcValues(
+ id,
+ (jdbcValue, columnMapping) -> jdbcValueBindings.bindValue(
+ jdbcValue, tableName, columnMapping.getColumnName(), ParameterUsage.SET
+ ),
+ session
+ );
+
+ // Attribute values for this table
+ for ( final int attributeIndex : sourceMappings[tableIndex].getAttributeIndexes() ) {
+ if ( propertyInclusions[attributeIndex] ) {
+ final var attributeMapping = attributeMappings.get( attributeIndex );
+ if ( !( attributeMapping instanceof PluralAttributeMapping ) ) {
+ attributeMapping.decompose(
+ values[attributeIndex], 0, jdbcValueBindings, null,
+ (valueIndex, bindings, noop, jdbcValue, selectableMapping) -> {
+ if ( selectableMapping.isInsertable() && !selectableMapping.isFormula() ) {
+ bindings.bindValue(
+ jdbcValue, tableName,
+ selectableMapping.getSelectionExpression(), ParameterUsage.SET
+ );
+ }
+ },
+ session
+ );
+ }
+ }
+ }
+
+ // Audit columns
+ final String sourceTableName = sourceMappings[tableIndex].getTableName();
+ if ( !useServerTransactionTimestamps ) {
+ jdbcValueBindings.bindValue(
+ session.getCurrentTransactionIdentifier(),
tableName,
- columnMapping.getColumnName(),
+ auditMapping.getTransactionIdMapping( sourceTableName ).getSelectionExpression(),
ParameterUsage.SET
- ),
+ );
+ }
+ final var modTypeMapping = auditMapping.getModificationTypeMapping( sourceTableName );
+ if ( modTypeMapping != null ) {
+ jdbcValueBindings.bindValue(
+ modificationType,
+ tableName,
+ modTypeMapping.getSelectionExpression(),
+ ParameterUsage.SET
+ );
+ }
+ }
+ }
+
+ /**
+ * Update the previous audit row's transaction end column for the validity strategy.
+ * Sets {@code REVEND = :currentTxId} on the row with
+ * {@code REVEND IS NULL} for the given entity ID.
+ *
+ * Called before the new audit row INSERT, so the just-inserted row
+ * does not exist yet and there's no risk of self-update.
+ *
+ * @param id the entity identifier
+ * @param modificationType the modification type of the new audit row
+ * @param session the current session
+ */
+ private void updatePreviousTransactionEnd(
+ Object id,
+ ModificationType modificationType,
+ SharedSessionContractImplementor session) {
+ if ( transactionEndUpdateGroup == null ) {
+ return;
+ }
+ final var mutationExecutor = mutationExecutorService.createExecutor(
+ () -> auditBatchKey,
+ transactionEndUpdateGroup,
session
);
+ try {
+ final var jdbcValueBindings = mutationExecutor.getJdbcValueBindings();
+ final EntityTableMapping[] sourceMappings = entityPersister().getTableMappings();
+ for ( int tableIndex = 0; tableIndex < auditTableMappings.length; tableIndex++ ) {
+ if ( auditTableMappings[tableIndex] == null ) {
+ continue;
+ }
+ final String tableName = auditTableMappings[tableIndex].getTableName();
+ final String sourceTableName = sourceMappings[tableIndex].getTableName();
+ final var revEndMapping = auditMapping.getTransactionEndMapping( sourceTableName );
+ if ( revEndMapping == null ) {
+ continue;
+ }
- final var attributeMappings = entityPersister().getAttributeMappings();
- for ( final int attributeIndex : identifierTableMapping.getAttributeIndexes() ) {
- if ( propertyInclusions[attributeIndex] ) {
- final var attributeMapping = attributeMappings.get( attributeIndex );
- if ( !(attributeMapping instanceof PluralAttributeMapping) ) {
- attributeMapping.decompose(
- values[attributeIndex],
- 0,
- jdbcValueBindings,
- null,
- (valueIndex, bindings, noop, jdbcValue, selectableMapping) -> {
- if ( selectableMapping.isInsertable() && !selectableMapping.isFormula() ) {
- bindings.bindValue(
- jdbcValue,
- tableName,
- selectableMapping.getSelectionExpression(),
- ParameterUsage.SET
- );
- }
- },
- session
+ // SET REVEND = :txId
+ jdbcValueBindings.bindValue(
+ session.getCurrentTransactionIdentifier(),
+ tableName,
+ revEndMapping.getSelectionExpression(),
+ ParameterUsage.SET
+ );
+
+ // SET REVEND_TSTMP = :tstmp (if configured)
+ final var revEndTsMapping = auditMapping.getTransactionEndTimestampMapping( sourceTableName );
+ if ( revEndTsMapping != null ) {
+ jdbcValueBindings.bindValue(
+ java.time.Instant.now(), tableName,
+ revEndTsMapping.getSelectionExpression(),
+ ParameterUsage.SET
);
}
+
+ // WHERE id = :id
+ sourceMappings[tableIndex].getKeyMapping().breakDownKeyJdbcValues(
+ id,
+ (jdbcValue, columnMapping) -> jdbcValueBindings.bindValue(
+ jdbcValue, tableName, columnMapping.getColumnName(), ParameterUsage.RESTRICT
+ ),
+ session
+ );
}
+ final String entityName = entityPersister().getEntityName();
+ mutationExecutor.execute(
+ null, null, null,
+ (statementDetails, affectedRowCount, batchPosition) ->
+ verifyTransactionEndOutcome( affectedRowCount, modificationType, entityName, id ),
+ session
+ );
+ }
+ finally {
+ mutationExecutor.release();
}
+ }
- if ( !useServerTransactionTimestamps ) {
- jdbcValueBindings.bindValue(
- session.getCurrentTransactionIdentifier(),
- tableName,
- getTransactionIdMapping().getSelectionExpression(),
- ParameterUsage.SET
+ private static boolean verifyTransactionEndOutcome(
+ int affectedRowCount,
+ ModificationType modificationType,
+ String entityName,
+ Object id) {
+ // ADD allows 0 (new entity) or 1 (ID reuse); MOD/DEL requires exactly 1
+ if ( affectedRowCount > 1
+ || affectedRowCount == 0 && modificationType != ModificationType.ADD ) {
+ throw new AuditException(
+ "Cannot update previous revision for entity "
+ + entityName + " and id " + id
+ + " (" + affectedRowCount + " rows modified)."
);
}
+ return true;
+ }
+
+ private MutationOperationGroup buildTransactionEndUpdateGroup() {
+ final EntityTableMapping[] sourceMappings = entityPersister().getTableMappings();
+ final List> mutations = new ArrayList<>();
+ for ( int i = 0; i < auditTableMappings.length; i++ ) {
+ if ( auditTableMappings[i] == null ) {
+ continue;
+ }
+ final String sourceTableName = sourceMappings[i].getTableName();
+ final var revEndMapping = auditMapping.getTransactionEndMapping( sourceTableName );
+ if ( revEndMapping == null ) {
+ continue;
+ }
+ final var updateBuilder =
+ new TableUpdateBuilderStandard<>( entityPersister(), auditTableMappings[i], factory() );
+
+ // SET REVEND = ?
+ if ( useServerTransactionTimestamps ) {
+ updateBuilder.addValueColumn( currentTimestampFunctionName, revEndMapping );
+ }
+ else {
+ updateBuilder.addValueColumn( "?", revEndMapping );
+ }
+
+ // SET REVEND_TSTMP = ? (if configured)
+ final var revEndTsMapping = auditMapping.getTransactionEndTimestampMapping( sourceTableName );
+ if ( revEndTsMapping != null ) {
+ updateBuilder.addValueColumn( "?", revEndTsMapping );
+ }
- jdbcValueBindings.bindValue(
- Integer.valueOf( modificationType.ordinal() ),
- tableName,
- getModificationTypeMapping().getSelectionExpression(),
- ParameterUsage.SET
+ // WHERE id columns
+ sourceMappings[i].getKeyMapping().forEachKeyColumn(
+ (position, keyColumn) -> updateBuilder.addKeyRestrictionBinding( keyColumn )
+ );
+
+ // WHERE REVEND IS NULL (only update the current row)
+ final var revEndColumnRef = new ColumnReference( updateBuilder.getMutatingTable(), revEndMapping );
+ updateBuilder.addNonKeyRestriction( new ColumnValueBinding(
+ revEndColumnRef,
+ new ColumnWriteFragment( null, List.of(), revEndMapping )
+ ) );
+
+ mutations.add( updateBuilder.buildMutation() );
+ }
+ if ( mutations.isEmpty() ) {
+ return null;
+ }
+ if ( mutations.size() == 1 ) {
+ return singleOperation(
+ new MutationGroupSingle( MutationType.UPDATE, entityPersister(), mutations.get( 0 ) ),
+ mutations.get( 0 ).createMutationOperation( null, factory() )
+ );
+ }
+ return createOperationGroup(
+ null,
+ new MutationGroupStandard( MutationType.UPDATE, entityPersister(), mutations )
);
}
@@ -244,8 +486,8 @@ private boolean[] applyAuditMask(boolean[] propertyInclusions) {
private static boolean isValueGenerated(Generator generator) {
return generator != null
- && generator.generatesOnInsert()
- && generator.generatedOnExecution();
+ && generator.generatesOnInsert()
+ && generator.generatedOnExecution();
}
private boolean isValueGenerationInSql(Generator generator) {
@@ -266,14 +508,6 @@ private void addSqlGeneratedValue(
insertBuilder.addValueColumn( writePropertyValue ? "?" : columnValues[j], mapping ) );
}
- protected SelectableMapping getTransactionIdMapping() {
- return transactionIdMapping;
- }
-
- protected SelectableMapping getModificationTypeMapping() {
- return modificationTypeMapping;
- }
-
private static boolean verifyOutcome(
PreparedStatementDetails statementDetails,
int affectedRowCount,
diff --git a/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/AbstractMutationCoordinator.java b/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/AbstractMutationCoordinator.java
index 6336fbe95fb4..608dae45124b 100644
--- a/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/AbstractMutationCoordinator.java
+++ b/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/AbstractMutationCoordinator.java
@@ -334,7 +334,7 @@ void applyPartitionKeyRestriction(RestrictedTableMutationBuilder, ?> tableMuta
/**
* For temporal history tables and audit log tables.
*/
- static EntityTableMapping createAuxiliaryTableMapping(
+ public static EntityTableMapping createAuxiliaryTableMapping(
EntityTableMapping identifierTableMapping,
EntityPersister persister,
String tableName) {
@@ -359,4 +359,5 @@ static EntityTableMapping createAuxiliaryTableMapping(
persister.isDynamicUpdate(),
persister.isDynamicInsert()
);
- }}
+ }
+}
diff --git a/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/DeleteCoordinatorAudit.java b/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/DeleteCoordinatorAudit.java
index f4b2ce87468f..b4f25b4bbe57 100644
--- a/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/DeleteCoordinatorAudit.java
+++ b/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/DeleteCoordinatorAudit.java
@@ -4,10 +4,10 @@
*/
package org.hibernate.persister.entity.mutation;
+import org.hibernate.audit.ModificationType;
import org.hibernate.engine.spi.SessionFactoryImplementor;
import org.hibernate.engine.spi.SharedSessionContractImplementor;
import org.hibernate.persister.entity.EntityPersister;
-import org.hibernate.persister.state.internal.AuditStateManagement;
import org.hibernate.sql.model.MutationOperationGroup;
/**
@@ -35,24 +35,20 @@ public void delete(
Object id,
Object version,
SharedSessionContractImplementor session) {
+ final var entityEntry = session.getPersistenceContextInternal().getEntry( entity );
+ final var state = entityEntry.getLoadedState() != null
+ ? entityEntry.getLoadedState()
+ : entityPersister().getValues( entity );
+
currentDeleteCoordinator.delete( entity, id, version, session );
- final var state = resolveDeleteState( entity, session );
- if ( state != null ) {
- insertAuditRow( entity, id, state, AuditStateManagement.ModificationType.DEL, session );
- }
- }
- private Object[] resolveDeleteState(Object entity, SharedSessionContractImplementor session) {
- if ( entity == null ) {
- return null;
- }
- else {
- final var persistenceContext = session.getPersistenceContextInternal();
- final var entry = persistenceContext.getEntry( entity );
- return entry != null
- && entry.getLoadedState() != null
- ? entry.getLoadedState()
- : entityPersister().getValues( entity );
- }
+ session.getAuditWorkQueue().enqueue(
+ entityEntry.getEntityKey(),
+ entity,
+ state,
+ ModificationType.DEL,
+ this,
+ session
+ );
}
}
diff --git a/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/InsertCoordinatorAudit.java b/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/InsertCoordinatorAudit.java
index 96d0bed60f45..15c2b7d11723 100644
--- a/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/InsertCoordinatorAudit.java
+++ b/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/InsertCoordinatorAudit.java
@@ -8,7 +8,7 @@
import org.hibernate.engine.spi.SharedSessionContractImplementor;
import org.hibernate.generator.values.GeneratedValues;
import org.hibernate.persister.entity.EntityPersister;
-import org.hibernate.persister.state.internal.AuditStateManagement;
+import org.hibernate.audit.ModificationType;
import org.hibernate.sql.model.MutationOperationGroup;
/**
@@ -36,7 +36,7 @@ public GeneratedValues insert(
Object[] values,
SharedSessionContractImplementor session) {
final var generatedValues = currentInsertCoordinator.insert( entity, values, session );
- insertAuditRow( entity, null, values, AuditStateManagement.ModificationType.ADD, session );
+ enqueueAuditEntry( entity, values, ModificationType.ADD, session );
return generatedValues;
}
@@ -47,7 +47,7 @@ public GeneratedValues insert(
Object[] values,
SharedSessionContractImplementor session) {
final var generatedValues = currentInsertCoordinator.insert( entity, id, values, session );
- insertAuditRow( entity, id, values, AuditStateManagement.ModificationType.ADD, session );
+ enqueueAuditEntry( entity, values, ModificationType.ADD, session );
return generatedValues;
}
}
diff --git a/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/UpdateCoordinatorAudit.java b/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/UpdateCoordinatorAudit.java
index 41f0d5e0a3b4..965557b6ca27 100644
--- a/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/UpdateCoordinatorAudit.java
+++ b/hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/UpdateCoordinatorAudit.java
@@ -8,7 +8,7 @@
import org.hibernate.engine.spi.SharedSessionContractImplementor;
import org.hibernate.generator.values.GeneratedValues;
import org.hibernate.persister.entity.EntityPersister;
-import org.hibernate.persister.state.internal.AuditStateManagement;
+import org.hibernate.audit.ModificationType;
import org.hibernate.sql.model.MutationOperationGroup;
/**
@@ -53,7 +53,7 @@ public GeneratedValues update(
session
);
if ( shouldAuditUpdate( dirtyAttributeIndexes, hasDirtyCollection ) ) {
- insertAuditRow( entity, id, values, AuditStateManagement.ModificationType.MOD, session );
+ enqueueAuditEntry( entity, values, ModificationType.MOD, session );
}
return generatedValues;
}
diff --git a/hibernate-core/src/main/java/org/hibernate/persister/state/internal/AbstractStateManagement.java b/hibernate-core/src/main/java/org/hibernate/persister/state/internal/AbstractStateManagement.java
index dc4a73c9c4ae..e9d847cdca4c 100644
--- a/hibernate-core/src/main/java/org/hibernate/persister/state/internal/AbstractStateManagement.java
+++ b/hibernate-core/src/main/java/org/hibernate/persister/state/internal/AbstractStateManagement.java
@@ -210,7 +210,7 @@ protected static CollectionMutationTarget resolveMutationTarget(CollectionPersis
@Override
public AuxiliaryMapping createAuxiliaryMapping(
EntityPersister persister,
- RootClass rootClass,
+ RootClass bootDescriptor,
MappingModelCreationProcess creationProcess) {
return null;
}
diff --git a/hibernate-core/src/main/java/org/hibernate/persister/state/internal/AuditStateManagement.java b/hibernate-core/src/main/java/org/hibernate/persister/state/internal/AuditStateManagement.java
index a196b3375adf..1f5574043f85 100644
--- a/hibernate-core/src/main/java/org/hibernate/persister/state/internal/AuditStateManagement.java
+++ b/hibernate-core/src/main/java/org/hibernate/persister/state/internal/AuditStateManagement.java
@@ -4,26 +4,38 @@
*/
package org.hibernate.persister.state.internal;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+
+import org.hibernate.audit.ModificationType;
+import org.hibernate.mapping.AuxiliaryTableHolder;
import org.hibernate.mapping.Collection;
+import org.hibernate.mapping.Column;
+import org.hibernate.mapping.PersistentClass;
import org.hibernate.mapping.RootClass;
+import org.hibernate.mapping.Table;
import org.hibernate.metamodel.mapping.AuditMapping;
+import org.hibernate.metamodel.mapping.JdbcMapping;
import org.hibernate.metamodel.mapping.PluralAttributeMapping;
+import org.hibernate.metamodel.mapping.SelectableMapping;
import org.hibernate.metamodel.mapping.internal.AuditMappingImpl;
import org.hibernate.metamodel.mapping.internal.MappingModelCreationProcess;
+import org.hibernate.metamodel.mapping.internal.SelectableMappingImpl;
import org.hibernate.persister.collection.CollectionPersister;
import org.hibernate.persister.collection.mutation.DeleteRowsCoordinator;
-import org.hibernate.persister.collection.mutation.DeleteRowsCoordinatorNoOp;
-import org.hibernate.persister.collection.mutation.DeleteRowsCoordinatorAudit;
import org.hibernate.persister.collection.mutation.InsertRowsCoordinator;
import org.hibernate.persister.collection.mutation.InsertRowsCoordinatorAudit;
import org.hibernate.persister.collection.mutation.InsertRowsCoordinatorNoOp;
import org.hibernate.persister.collection.mutation.RemoveCoordinator;
+import org.hibernate.persister.collection.mutation.RemoveCoordinatorAudit;
import org.hibernate.persister.collection.mutation.RemoveCoordinatorNoOp;
import org.hibernate.persister.collection.mutation.UpdateRowsCoordinator;
-import org.hibernate.persister.collection.mutation.UpdateRowsCoordinatorAudit;
-import org.hibernate.persister.collection.mutation.UpdateRowsCoordinatorNoOp;
import org.hibernate.persister.entity.AbstractEntityPersister;
import org.hibernate.persister.entity.EntityPersister;
+import org.hibernate.persister.entity.UnionSubclassEntityPersister;
import org.hibernate.persister.entity.mutation.DeleteCoordinator;
import org.hibernate.persister.entity.mutation.DeleteCoordinatorAudit;
import org.hibernate.persister.entity.mutation.InsertCoordinator;
@@ -32,7 +44,14 @@
import org.hibernate.persister.entity.mutation.UpdateCoordinator;
import org.hibernate.persister.entity.mutation.UpdateCoordinatorAudit;
import org.hibernate.persister.state.spi.StateManagement;
+import org.hibernate.type.spi.TypeConfiguration;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+import static org.hibernate.boot.model.internal.AuditHelper.MODIFICATION_TYPE;
+import static org.hibernate.boot.model.internal.AuditHelper.TRANSACTION_END_ID;
+import static org.hibernate.boot.model.internal.AuditHelper.TRANSACTION_END_TIMESTAMP;
+import static org.hibernate.boot.model.internal.AuditHelper.TRANSACTION_ID;
import static org.hibernate.metamodel.mapping.internal.MappingModelCreationHelper.getTableIdentifierExpression;
import static org.hibernate.persister.state.internal.AbstractStateManagement.resolveMutationTarget;
@@ -41,7 +60,6 @@
* entities and collections.
*
* @author Gavin King
- *
* @since 7.4
*/
public class AuditStateManagement implements StateManagement {
@@ -50,20 +68,6 @@ public class AuditStateManagement implements StateManagement {
private AuditStateManagement() {
}
- /**
- * The modification type stored in the
- * {@linkplain org.hibernate.annotations.Audited#modificationType
- * modification type column}.
- */
- public enum ModificationType {
- /** Creation, encoded as 0 */
- ADD,
- /** Modification, encoded as 1 */
- MOD,
- /** Deletion, encoded as 2 */
- DEL
- }
-
@Override
public InsertCoordinator createInsertCoordinator(EntityPersister persister) {
return new InsertCoordinatorAudit( persister, persister.getFactory(),
@@ -94,13 +98,9 @@ public InsertRowsCoordinator createInsertRowsCoordinator(CollectionPersister per
if ( !AbstractStateManagement.isInsertAllowed( persister ) ) {
return new InsertRowsCoordinatorNoOp( mutationTarget );
}
- else if ( persister.isOneToMany() ) {
- throw new UnsupportedOperationException();
- }
else {
return new InsertRowsCoordinatorAudit(
mutationTarget,
- persister.getRowMutationOperations(),
StandardStateManagement.INSTANCE.createInsertRowsCoordinator( persister ),
persister.getIndexColumnIsSettable(),
persister.getElementColumnIsSettable(),
@@ -112,59 +112,31 @@ else if ( persister.isOneToMany() ) {
@Override
public UpdateRowsCoordinator createUpdateRowsCoordinator(CollectionPersister persister) {
- final var mutationTarget = resolveMutationTarget( persister );
- if ( !AbstractStateManagement.isUpdatePossible( persister ) ) {
- return new UpdateRowsCoordinatorNoOp( mutationTarget );
- }
- else if ( persister.isOneToMany() ) {
- throw new UnsupportedOperationException();
- }
- else {
- return new UpdateRowsCoordinatorAudit(
- mutationTarget,
- StandardStateManagement.INSTANCE.createUpdateRowsCoordinator( persister ),
- persister.getIndexColumnIsSettable(),
- persister.getElementColumnIsSettable(),
- persister.getIndexIncrementer(),
- persister.getFactory()
- );
- }
+ // Collection audit rows are always ADD/DEL (never MOD).
+ // The semantic diff in InsertRowsCoordinatorAudit handles all audit writes.
+ return StandardStateManagement.INSTANCE.createUpdateRowsCoordinator( persister );
}
@Override
public DeleteRowsCoordinator createDeleteRowsCoordinator(CollectionPersister persister) {
- final var mutationTarget = resolveMutationTarget( persister );
- if ( !persister.needsRemove() ) {
- return new DeleteRowsCoordinatorNoOp( mutationTarget );
- }
- else if ( persister.isOneToMany() ) {
- throw new UnsupportedOperationException();
- }
- else {
- return new DeleteRowsCoordinatorAudit(
- mutationTarget,
- StandardStateManagement.INSTANCE.createDeleteRowsCoordinator( persister ),
- mutationTarget.hasPhysicalIndexColumn(),
- persister.getIndexColumnIsSettable(),
- persister.getElementColumnIsSettable(),
- persister.getIndexIncrementer(),
- persister.getFactory()
- );
- }
+ // Collection audit rows are always ADD/DEL (never MOD).
+ // The semantic diff in InsertRowsCoordinatorAudit handles all audit writes.
+ return StandardStateManagement.INSTANCE.createDeleteRowsCoordinator( persister );
}
@Override
public RemoveCoordinator createRemoveCoordinator(CollectionPersister persister) {
- final var mutationTarget = resolveMutationTarget( persister );
if ( !persister.needsRemove() ) {
- return new RemoveCoordinatorNoOp( mutationTarget );
- }
- else if ( persister.isOneToMany() ) {
- throw new UnsupportedOperationException();
- }
- else {
- return StandardStateManagement.INSTANCE.createRemoveCoordinator( persister );
+ return new RemoveCoordinatorNoOp( resolveMutationTarget( persister ) );
}
+ return new RemoveCoordinatorAudit(
+ resolveMutationTarget( persister ),
+ StandardStateManagement.INSTANCE.createRemoveCoordinator( persister ),
+ persister.getIndexColumnIsSettable(),
+ persister.getElementColumnIsSettable(),
+ persister.getIndexIncrementer(),
+ persister.getFactory()
+ );
}
@Override
@@ -172,12 +144,213 @@ public AuditMapping createAuxiliaryMapping(
EntityPersister persister,
RootClass rootClass,
MappingModelCreationProcess creationProcess) {
- final var auditTable = rootClass.getAuxiliaryTable();
- final String tableName = auditTable == null
- ? persister.getIdentifierTableName()
- : ( (AbstractEntityPersister) persister )
- .determineTableName( auditTable );
- return new AuditMappingImpl( rootClass, tableName, creationProcess );
+ final var aep = (AbstractEntityPersister) persister;
+ final var tableAuditInfoMap = buildTableAuditInfoMap( rootClass, aep, creationProcess );
+ return new AuditMappingImpl( tableAuditInfoMap, persister, creationProcess );
+ }
+
+ private static Map buildTableAuditInfoMap(
+ RootClass rootClass,
+ AbstractEntityPersister persister,
+ MappingModelCreationProcess creationProcess) {
+ final var typeConfiguration = creationProcess.getCreationContext().getTypeConfiguration();
+ final var sessionFactory = creationProcess.getCreationContext().getSessionFactory();
+ final var txIdJdbcMapping = resolveJdbcMapping(
+ typeConfiguration,
+ sessionFactory.getTransactionIdentifierService().getIdentifierType()
+ );
+ final var modTypeJdbcMapping = resolveJdbcMapping( typeConfiguration, ModificationType.class );
+
+ final var map = new HashMap();
+
+ // Root table entry
+ addTableAuditInfo(
+ map,
+ rootClass.getTable(),
+ rootClass.getAuxiliaryTable(),
+ rootClass,
+ persister,
+ txIdJdbcMapping,
+ modTypeJdbcMapping,
+ creationProcess
+ );
+
+ // For TABLE_PER_CLASS, prepare audit subquery generation context
+ // (the tableNameResolver lambda captures the map, so it resolves lazily)
+ final Function tableNameResolver;
+ final List extraColumns;
+ if ( persister instanceof UnionSubclassEntityPersister ) {
+ tableNameResolver = originalName -> map.get( originalName ).auditTableName();
+ final var rootInfo = map.values().iterator().next();
+ final var extras = new ArrayList<>( List.of(
+ rootInfo.transactionIdMapping().getSelectionExpression(),
+ rootInfo.modificationTypeMapping().getSelectionExpression()
+ ) );
+ if ( rootInfo.transactionEndMapping() != null ) {
+ extras.add( rootInfo.transactionEndMapping().getSelectionExpression() );
+ }
+ if ( rootInfo.transactionEndTimestampMapping() != null ) {
+ extras.add( rootInfo.transactionEndTimestampMapping().getSelectionExpression() );
+ }
+ extraColumns = extras;
+ }
+ else {
+ tableNameResolver = null;
+ extraColumns = null;
+ }
+
+ // Secondary table entries (@SecondaryTable joins)
+ for ( var join : rootClass.getJoins() ) {
+ if ( join.getAuxiliaryTable() != null ) {
+ addTableAuditInfo(
+ map,
+ join.getTable(),
+ join.getAuxiliaryTable(),
+ join,
+ persister,
+ txIdJdbcMapping,
+ modTypeJdbcMapping,
+ creationProcess
+ );
+ }
+ }
+
+ // Subclass table entries (JOINED / TABLE_PER_CLASS)
+ for ( var subclass : rootClass.getSubclasses() ) {
+ if ( subclass.getAuxiliaryTable() != null ) {
+ addTableAuditInfo(
+ map,
+ subclass.getTable(),
+ subclass.getAuxiliaryTable(),
+ subclass,
+ persister,
+ txIdJdbcMapping,
+ modTypeJdbcMapping,
+ creationProcess
+ );
+ }
+ // For TABLE_PER_CLASS intermediate classes, build audit subquery inline
+ // (getSubclasses() is depth-first, so subtypes' entries are already in the map)
+ if ( tableNameResolver != null && subclass.hasSubclasses() ) {
+ addAuditSubquery( map, subclass, tableNameResolver, extraColumns, creationProcess );
+ }
+ }
+
+ // Root's audit subquery (needs all subclass entries, so must come last)
+ if ( tableNameResolver != null && rootClass.hasSubclasses() ) {
+ addAuditSubquery( map, rootClass, tableNameResolver, extraColumns, creationProcess );
+ }
+
+ return map;
+ }
+
+ private static void addAuditSubquery(
+ Map map,
+ PersistentClass bootClass,
+ Function tableNameResolver,
+ List extraColumns,
+ MappingModelCreationProcess creationProcess) {
+ assert !bootClass.getSubclasses().isEmpty();
+ final var unionPersister = (UnionSubclassEntityPersister)
+ creationProcess.getEntityPersister( bootClass.getEntityName() );
+ final var rootInfo = map.values().iterator().next();
+ final String originalSubquery = unionPersister.getTableName();
+ final String auditSubquery = unionPersister.generateSubquery(
+ bootClass,
+ tableNameResolver,
+ extraColumns
+ );
+ map.put(
+ originalSubquery, new AuditMappingImpl.TableAuditInfo(
+ auditSubquery,
+ rootInfo.transactionIdMapping(),
+ rootInfo.modificationTypeMapping(),
+ rootInfo.transactionEndMapping(),
+ rootInfo.transactionEndTimestampMapping()
+ )
+ );
+ }
+
+ private static void addTableAuditInfo(
+ Map map,
+ Table originalTable,
+ Table auditTable,
+ AuxiliaryTableHolder holder,
+ AbstractEntityPersister persister,
+ JdbcMapping txIdJdbcMapping,
+ JdbcMapping modTypeJdbcMapping,
+ MappingModelCreationProcess creationProcess) {
+ final String originalTableName = persister.determineTableName( originalTable );
+ final String auditTableName = persister.determineTableName( auditTable );
+ map.put(
+ originalTableName,
+ createTableAuditInfo( auditTableName, holder, txIdJdbcMapping, modTypeJdbcMapping, creationProcess )
+ );
+ }
+
+ private static AuditMappingImpl.TableAuditInfo createTableAuditInfo(
+ String auditTableName,
+ AuxiliaryTableHolder holder,
+ JdbcMapping txIdJdbcMapping,
+ JdbcMapping modTypeJdbcMapping,
+ MappingModelCreationProcess creationProcess) {
+ final var creationContext = creationProcess.getCreationContext();
+ final var typeConfiguration = creationContext.getTypeConfiguration();
+ return new AuditMappingImpl.TableAuditInfo(
+ auditTableName,
+ toSelectableMapping(
+ auditTableName,
+ holder.getAuxiliaryColumn( TRANSACTION_ID ),
+ txIdJdbcMapping,
+ creationProcess
+ ),
+ toSelectableMapping(
+ auditTableName,
+ holder.getAuxiliaryColumn( MODIFICATION_TYPE ),
+ modTypeJdbcMapping,
+ creationProcess
+ ),
+ toSelectableMapping(
+ auditTableName,
+ holder.getAuxiliaryColumn( TRANSACTION_END_ID ),
+ txIdJdbcMapping,
+ creationProcess
+ ),
+ toSelectableMapping(
+ auditTableName,
+ holder.getAuxiliaryColumn( TRANSACTION_END_TIMESTAMP ),
+ resolveJdbcMapping( typeConfiguration, java.time.Instant.class ),
+ creationProcess
+ )
+ );
+ }
+
+ private static @Nullable SelectableMapping toSelectableMapping(
+ String tableName,
+ @Nullable Column column,
+ JdbcMapping jdbcMapping,
+ MappingModelCreationProcess creationProcess) {
+ if ( column == null ) {
+ return null;
+ }
+ final var creationContext = creationProcess.getCreationContext();
+ return SelectableMappingImpl.from(
+ tableName,
+ column,
+ jdbcMapping,
+ creationContext.getTypeConfiguration(),
+ true,
+ false,
+ false,
+ creationContext.getDialect(),
+ creationContext.getSessionFactory().getQueryEngine().getSqmFunctionRegistry(),
+ creationContext
+ );
+ }
+
+ private static JdbcMapping resolveJdbcMapping(TypeConfiguration typeConfiguration, Class> javaType) {
+ final var basicType = typeConfiguration.getBasicTypeForJavaType( javaType );
+ return basicType != null ? basicType : typeConfiguration.standardBasicTypeForJavaType( javaType );
}
@Override
@@ -186,9 +359,35 @@ public AuditMapping createAuxiliaryMapping(
Collection bootDescriptor,
MappingModelCreationProcess creationProcess) {
final var auditTable = bootDescriptor.getAuxiliaryTable();
- final String tableName = auditTable == null ? null
- : getTableIdentifierExpression( auditTable, creationProcess );
- return new AuditMappingImpl( bootDescriptor, tableName, creationProcess );
+ if ( auditTable == null ) {
+ // No audit table for this collection (e.g. @OneToMany @JoinColumn --
+ // the child entity's audit table handles FK auditing)
+ return null;
+ }
+ final String originalTableName = getTableIdentifierExpression(
+ bootDescriptor.getCollectionTable(), creationProcess );
+ final String auditTableName = getTableIdentifierExpression( auditTable, creationProcess );
+ final var typeConfiguration = creationProcess.getCreationContext().getTypeConfiguration();
+ final var sessionFactory = creationProcess.getCreationContext().getSessionFactory();
+ final var txIdJdbcMapping = resolveJdbcMapping(
+ typeConfiguration,
+ sessionFactory.getTransactionIdentifierService().getIdentifierType()
+ );
+ final var modTypeJdbcMapping = resolveJdbcMapping( typeConfiguration, ModificationType.class );
+ return new AuditMappingImpl(
+ Map.of(
+ originalTableName,
+ createTableAuditInfo(
+ auditTableName,
+ bootDescriptor,
+ txIdJdbcMapping,
+ modTypeJdbcMapping,
+ creationProcess
+ )
+ ),
+ null,
+ creationProcess
+ );
}
}
diff --git a/hibernate-core/src/main/java/org/hibernate/persister/state/spi/StateManagement.java b/hibernate-core/src/main/java/org/hibernate/persister/state/spi/StateManagement.java
index 3654a2fe17f1..e29a021a8052 100644
--- a/hibernate-core/src/main/java/org/hibernate/persister/state/spi/StateManagement.java
+++ b/hibernate-core/src/main/java/org/hibernate/persister/state/spi/StateManagement.java
@@ -62,7 +62,7 @@ public interface StateManagement {
AuxiliaryMapping createAuxiliaryMapping(
EntityPersister persister,
- RootClass rootClass,
+ RootClass bootDescriptor,
MappingModelCreationProcess creationProcess);
AuxiliaryMapping createAuxiliaryMapping(
diff --git a/hibernate-core/src/main/java/org/hibernate/proxy/AbstractLazyInitializer.java b/hibernate-core/src/main/java/org/hibernate/proxy/AbstractLazyInitializer.java
index d0241e34b98a..f8e7e3cd8aaa 100644
--- a/hibernate-core/src/main/java/org/hibernate/proxy/AbstractLazyInitializer.java
+++ b/hibernate-core/src/main/java/org/hibernate/proxy/AbstractLazyInitializer.java
@@ -4,8 +4,10 @@
*/
package org.hibernate.proxy;
+import org.checkerframework.checker.nullness.qual.Nullable;
import org.hibernate.FlushMode;
import org.hibernate.HibernateException;
+import org.hibernate.audit.AuditLog;
import org.hibernate.LazyInitializationException;
import org.hibernate.SessionException;
import org.hibernate.boot.spi.SessionFactoryOptions;
@@ -35,6 +37,7 @@ public abstract class AbstractLazyInitializer implements LazyInitializer {
private transient SharedSessionContractImplementor session;
private Boolean readOnlyBeforeAttachedToSession;
+ private @Nullable Object temporalIdentifier;
private String sessionFactoryUuid;
private String sessionFactoryName;
private boolean allowLoadOutsideTransaction;
@@ -49,6 +52,11 @@ public abstract class AbstractLazyInitializer implements LazyInitializer {
protected AbstractLazyInitializer(String entityName, Object id, SharedSessionContractImplementor session) {
this.entityName = entityName;
this.id = id;
+ // Capture the temporal identifier for revision-aware proxy initialization
+ if ( session != null ) {
+ final Object tempId = session.getLoadQueryInfluencers().getTemporalIdentifier();
+ this.temporalIdentifier = tempId != AuditLog.ALL_REVISIONS ? tempId : null;
+ }
// initialize other fields depending on session state
if ( session == null ) {
unsetSession();
@@ -179,7 +187,7 @@ else if ( !session.isConnected() ) {
+ entityName + "#" + id + "] - the owning session is disconnected" );
}
else {
- target = session.immediateLoad( entityName, id );
+ target = immediateLoad( session );
initialized = true;
checkTargetState( session );
}
@@ -223,7 +231,7 @@ protected void permissiveInitialization() {
}
try {
- target = session.immediateLoad( entityName, id );
+ target = immediateLoad( session );
initialized = true;
checkTargetState( session );
}
@@ -246,7 +254,7 @@ protected void permissiveInitialization() {
}
}
else if ( session.isOpenOrWaitingForAutoClose() && session.isConnected() ) {
- target = session.immediateLoad( entityName, id );
+ target = immediateLoad( session );
initialized = true;
checkTargetState( session );
}
@@ -256,6 +264,23 @@ else if ( session.isOpenOrWaitingForAutoClose() && session.isConnected() ) {
}
}
+ private Object immediateLoad(SharedSessionContractImplementor session) {
+ if ( temporalIdentifier != null ) {
+ final var influencers = session.getLoadQueryInfluencers();
+ final Object previous = influencers.getTemporalIdentifier();
+ influencers.setTemporalIdentifier( temporalIdentifier );
+ try {
+ return session.immediateLoad( entityName, id );
+ }
+ finally {
+ influencers.setTemporalIdentifier( previous );
+ }
+ }
+ else {
+ return session.immediateLoad( entityName, id );
+ }
+ }
+
/**
* Attempt to initialize the proxy without loading anything from the database.
*
diff --git a/hibernate-core/src/main/java/org/hibernate/query/results/internal/complete/EntityResultImpl.java b/hibernate-core/src/main/java/org/hibernate/query/results/internal/complete/EntityResultImpl.java
index 7d761d3c0519..8d69ed2f334b 100644
--- a/hibernate-core/src/main/java/org/hibernate/query/results/internal/complete/EntityResultImpl.java
+++ b/hibernate-core/src/main/java/org/hibernate/query/results/internal/complete/EntityResultImpl.java
@@ -163,6 +163,7 @@ public Initializer> createInitializer(
discriminatorFetch,
null,
null,
+ null,
NotFoundAction.EXCEPTION,
false,
null,
diff --git a/hibernate-core/src/main/java/org/hibernate/query/sqm/sql/BaseSqmToSqlAstConverter.java b/hibernate-core/src/main/java/org/hibernate/query/sqm/sql/BaseSqmToSqlAstConverter.java
index 5aabfaabf720..2f3e566dc01d 100644
--- a/hibernate-core/src/main/java/org/hibernate/query/sqm/sql/BaseSqmToSqlAstConverter.java
+++ b/hibernate-core/src/main/java/org/hibernate/query/sqm/sql/BaseSqmToSqlAstConverter.java
@@ -422,6 +422,7 @@
import static java.util.Collections.emptySet;
import static java.util.Collections.singleton;
import static java.util.Collections.singletonList;
+import static org.hibernate.boot.model.internal.AuditHelper.isFetchableAuditExcluded;
import static org.hibernate.boot.model.process.internal.InferredBasicValueResolver.resolveSqlTypeIndicators;
import static org.hibernate.generator.EventType.INSERT;
import static org.hibernate.internal.util.NullnessHelper.coalesceSuppliedValues;
@@ -8684,7 +8685,7 @@ public Fetch visitIdentifierFetch(EntityResultGraphNode fetchParent) {
}
private Fetch createFetch(FetchParent fetchParent, Fetchable fetchable, Boolean isKeyFetchable) {
- if ( !fetchable.isSelectable() ) {
+ if ( !fetchable.isSelectable() || isFetchableAuditExcluded( fetchable, loadQueryInfluencers ) ) {
return null;
}
final var resolvedNavigablePath = fetchParent.resolveNavigablePath( fetchable );
diff --git a/hibernate-core/src/main/java/org/hibernate/sql/ast/tree/from/NamedTableReference.java b/hibernate-core/src/main/java/org/hibernate/sql/ast/tree/from/NamedTableReference.java
index a51644dbd369..1ce23f050352 100644
--- a/hibernate-core/src/main/java/org/hibernate/sql/ast/tree/from/NamedTableReference.java
+++ b/hibernate-core/src/main/java/org/hibernate/sql/ast/tree/from/NamedTableReference.java
@@ -67,7 +67,7 @@ else if ( mapping instanceof TemporalMapping temporalMapping ) {
asOfTransactionIdentifier = new TemporalJdbcParameter( temporalMapping.getEndingColumnMapping() );
}
else if ( mapping instanceof AuditMapping auditMapping ) {
- asOfTransactionIdentifier = new TemporalJdbcParameter( auditMapping.getTransactionIdMapping() );
+ asOfTransactionIdentifier = new TemporalJdbcParameter( auditMapping.getTransactionIdMapping( getTableExpression() ) );
}
else {
asOfTransactionIdentifier = null;
diff --git a/hibernate-core/src/main/java/org/hibernate/sql/exec/internal/JdbcSelectExecutorStandardImpl.java b/hibernate-core/src/main/java/org/hibernate/sql/exec/internal/JdbcSelectExecutorStandardImpl.java
index 06be0ca6dbce..08a8f0779498 100644
--- a/hibernate-core/src/main/java/org/hibernate/sql/exec/internal/JdbcSelectExecutorStandardImpl.java
+++ b/hibernate-core/src/main/java/org/hibernate/sql/exec/internal/JdbcSelectExecutorStandardImpl.java
@@ -396,10 +396,10 @@ private static AbstractJdbcValues resolveJdbcValues(
if ( cacheHit.isCacheCompatible() ) {
return cacheHit;
}
- // Cached data incompatible with the resolved mapping — fall through to re-execute
+ // Cached data incompatible with the resolved mapping, fall through to re-execute
}
catch (CachedJdbcValuesMetadata.CacheMetadataIncompleteException e) {
- // Cached metadata doesn't cover all required columns — fall through to re-execute
+ // Cached metadata doesn't cover all required columns, fall through to re-execute
}
}
// Execute query (cache miss or insufficient cached data)
diff --git a/hibernate-core/src/main/java/org/hibernate/sql/results/graph/collection/internal/AbstractCollectionInitializer.java b/hibernate-core/src/main/java/org/hibernate/sql/results/graph/collection/internal/AbstractCollectionInitializer.java
index f8dcef26c0c8..7e20d7071994 100644
--- a/hibernate-core/src/main/java/org/hibernate/sql/results/graph/collection/internal/AbstractCollectionInitializer.java
+++ b/hibernate-core/src/main/java/org/hibernate/sql/results/graph/collection/internal/AbstractCollectionInitializer.java
@@ -4,6 +4,7 @@
*/
package org.hibernate.sql.results.graph.collection.internal;
+import java.util.Objects;
import java.util.function.BiConsumer;
import org.hibernate.collection.spi.PersistentCollection;
@@ -170,21 +171,34 @@ protected void resolveCollectionKey(Data data, boolean checkPreviousRow) {
return;
}
}
- final var persister = collectionAttributeMapping.getCollectionDescriptor();
// Try to reuse the previous collection key and collection if possible
- if ( checkPreviousRow && oldKey != null && areKeysEqual( oldKey.getKey(), data.collectionKeyValue ) ) {
+ if ( checkPreviousRow && oldKey != null && areKeysEqual( oldKey, data ) ) {
data.collectionKey = oldKey;
data.setCollectionInstance( oldCollectionInstance );
data.setState( oldCollectionInstance == null ? State.MISSING : State.RESOLVED );
}
else {
- data.collectionKey = new CollectionKey( persister, data.collectionKeyValue );
+ final var persister = collectionAttributeMapping.getCollectionDescriptor();
+ final var session = data.getRowProcessingState().getSession();
+ data.collectionKey = session.generateCollectionKey( persister, data.collectionKeyValue );
data.setState( State.KEY_RESOLVED );
}
}
- private boolean areKeysEqual(Object key1, Object key2) {
- return keyTypeForEqualsHashCode == null ? key1.equals( key2 ) : keyTypeForEqualsHashCode.isEqual( key1, key2 );
+ private boolean areKeysEqual(CollectionKey oldKey, Data data) {
+ final var oldFk = oldKey.getKey();
+ final var newFk = data.collectionKeyValue;
+ final var sameFk = keyTypeForEqualsHashCode == null
+ ? oldFk.equals( newFk )
+ : keyTypeForEqualsHashCode.isEqual( oldFk, newFk );
+ if ( sameFk ) {
+ final var currentTxId = data.getRowProcessingState()
+ .getLoadQueryInfluencers().getTemporalIdentifier();
+ return Objects.equals( oldKey.getTransactionId(), currentTxId );
+ }
+ else {
+ return false;
+ }
}
PersistentCollection> getCollection(CollectionInitializerData data, Object instance) {
@@ -251,7 +265,10 @@ public boolean isResultInitializer() {
return isResultInitializer;
}
- boolean isReadOnly(RowProcessingState rowProcessingState, SharedSessionContractImplementor session) {
+ boolean isReadOnly(CollectionKey collectionKey, RowProcessingState rowProcessingState, SharedSessionContractImplementor session) {
+ if ( collectionKey.isTemporal() ) {
+ return true;
+ }
final Boolean readOnly = rowProcessingState.getQueryOptions().isReadOnly();
return readOnly == null ? session.isDefaultReadOnly() : readOnly;
}
diff --git a/hibernate-core/src/main/java/org/hibernate/sql/results/graph/collection/internal/AbstractImmediateCollectionInitializer.java b/hibernate-core/src/main/java/org/hibernate/sql/results/graph/collection/internal/AbstractImmediateCollectionInitializer.java
index f85a71cf7e06..1fe78f0895bc 100644
--- a/hibernate-core/src/main/java/org/hibernate/sql/results/graph/collection/internal/AbstractImmediateCollectionInitializer.java
+++ b/hibernate-core/src/main/java/org/hibernate/sql/results/graph/collection/internal/AbstractImmediateCollectionInitializer.java
@@ -311,7 +311,7 @@ else if ( !data.shallowCached ) {
collectionDescriptor,
persistentCollection,
collectionKey.getKey(),
- isReadOnly( rowProcessingState, session )
+ isReadOnly( collectionKey, rowProcessingState, session )
);
if ( !data.shallowCached ) {
diff --git a/hibernate-core/src/main/java/org/hibernate/sql/results/graph/collection/internal/AbstractNonJoinCollectionInitializer.java b/hibernate-core/src/main/java/org/hibernate/sql/results/graph/collection/internal/AbstractNonJoinCollectionInitializer.java
index 27ac128fe314..4ca9e308f1f0 100644
--- a/hibernate-core/src/main/java/org/hibernate/sql/results/graph/collection/internal/AbstractNonJoinCollectionInitializer.java
+++ b/hibernate-core/src/main/java/org/hibernate/sql/results/graph/collection/internal/AbstractNonJoinCollectionInitializer.java
@@ -92,7 +92,7 @@ protected void resolveInstance(Data data, boolean isEager) {
assert targetInstance != null;
collection.setOwner( targetInstance );
persistenceContext.addUninitializedCollection( collectionDescriptor, collection, key,
- isReadOnly( rowProcessingState, session ) );
+ isReadOnly( collectionKey, rowProcessingState, session ) );
if ( isEager ) {
persistenceContext.addNonLazyCollection( collection );
}
diff --git a/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/AbstractEntityResultGraphNode.java b/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/AbstractEntityResultGraphNode.java
index 1bb2e8795dd7..18b960f886e0 100644
--- a/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/AbstractEntityResultGraphNode.java
+++ b/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/AbstractEntityResultGraphNode.java
@@ -18,6 +18,7 @@
import org.hibernate.sql.results.graph.FetchParent;
import org.hibernate.sql.results.graph.FetchableContainer;
import org.hibernate.sql.results.graph.basic.BasicFetch;
+import org.hibernate.sql.results.graph.basic.BasicResult;
import org.hibernate.type.descriptor.java.JavaType;
import org.checkerframework.checker.nullness.qual.Nullable;
@@ -31,6 +32,7 @@ public abstract class AbstractEntityResultGraphNode extends AbstractFetchParent
private @Nullable Fetch identifierFetch;
private BasicFetch> discriminatorFetch;
private DomainResult rowIdResult;
+ private @Nullable DomainResult> auditTransactionIdResult;
private final EntityValuedModelPart fetchContainer;
public AbstractEntityResultGraphNode(EntityValuedModelPart referencedModelPart, NavigablePath navigablePath) {
@@ -50,6 +52,9 @@ public void afterInitialize(FetchParent fetchParent, DomainResultCreationState c
discriminatorFetch = creationState.visitDiscriminatorFetch( entityResultGraphNode );
rowIdResult = rowIdResult( creationState, navigablePath, entityTableGroup );
+ if ( fetchParent == this ) {
+ auditTransactionIdResult = auditTransactionIdResult( creationState, entityTableGroup );
+ }
super.afterInitialize( fetchParent, creationState );
}
@@ -89,6 +94,37 @@ private Fetch identifierFetch(
}
}
+ private @Nullable DomainResult> auditTransactionIdResult(
+ DomainResultCreationState creationState,
+ TableGroup entityTableGroup) {
+ final var entityMappingType = getEntityValuedModelPart().getEntityMappingType();
+ final var auditMapping = entityMappingType.getAuditMapping();
+ if ( auditMapping != null ) {
+ final var influencers = creationState.getSqlAstCreationState().getLoadQueryInfluencers();
+ if ( influencers.isAllRevisions() && auditMapping.useAuxiliaryTable( influencers ) ) {
+ final String originalTable = entityMappingType.getMappedTableDetails().getTableName();
+ final var transactionIdMapping = auditMapping.getTransactionIdMapping( originalTable );
+ final var sqlAstCreationState = creationState.getSqlAstCreationState();
+ final var expressionResolver = sqlAstCreationState.getSqlExpressionResolver();
+ final var tableReference = entityTableGroup.resolveTableReference(
+ auditMapping.resolveTableName( originalTable ) );
+ final var expression = expressionResolver.resolveSqlExpression( tableReference, transactionIdMapping );
+ final var sqlSelection = expressionResolver.resolveSqlSelection(
+ expression,
+ transactionIdMapping.getJdbcMapping().getJdbcJavaType(),
+ null,
+ sqlAstCreationState.getCreationContext().getTypeConfiguration()
+ );
+ return new BasicResult<>(
+ sqlSelection.getValuesArrayPosition(),
+ "audit_txn_id",
+ transactionIdMapping.getJdbcMapping()
+ );
+ }
+ }
+ return null;
+ }
+
@Override
public EntityMappingType getReferencedMappingContainer() {
return getEntityValuedModelPart().getEntityMappingType();
@@ -121,6 +157,10 @@ public DomainResult getRowIdResult() {
return rowIdResult;
}
+ public @Nullable DomainResult> getAuditTransactionIdResult() {
+ return auditTransactionIdResult;
+ }
+
@Override
public void collectValueIndexesToCache(BitSet valueIndexes) {
final var entityPersister = fetchContainer.getEntityMappingType().getEntityPersister();
diff --git a/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/EntityInitializer.java b/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/EntityInitializer.java
index 732c2fb4dbfe..978aeb7a137a 100644
--- a/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/EntityInitializer.java
+++ b/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/EntityInitializer.java
@@ -47,7 +47,7 @@ default Object getTargetInstance(RowProcessingState rowProcessingState) {
default @Nullable EntityKey resolveEntityKeyOnly(RowProcessingState rowProcessingState) {
final Data data = getData( rowProcessingState );
resolveKey( data );
- final EntityKey entityKey = new EntityKey(
+ final EntityKey entityKey = rowProcessingState.getSession().generateEntityKey(
getEntityIdentifier( data ),
getConcreteDescriptor( data )
);
diff --git a/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/AbstractBatchEntitySelectFetchInitializer.java b/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/AbstractBatchEntitySelectFetchInitializer.java
index 9b9894c307f0..f0d2b6d19c16 100644
--- a/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/AbstractBatchEntitySelectFetchInitializer.java
+++ b/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/AbstractBatchEntitySelectFetchInitializer.java
@@ -117,7 +117,7 @@ protected void resolveInstanceFromIdentifier(Data data) {
initialize( data );
}
else {
- data.entityKey = new EntityKey( data.entityIdentifier, concreteDescriptor );
+ data.entityKey = data.getRowProcessingState().getSession().generateEntityKey( data.entityIdentifier, concreteDescriptor );
data.setInstance( getExistingInitializedInstance( data ) );
if ( data.getInstance() == null ) {
// need to add the key to the batch queue only when the entity has not been already loaded or
@@ -182,7 +182,7 @@ else if ( lazyInitializer.isUninitialized() ) {
data.setInstance( lazyInitializer.getImplementation() );
}
- data.entityKey = new EntityKey( data.entityIdentifier, concreteDescriptor );
+ data.entityKey = data.getRowProcessingState().getSession().generateEntityKey( data.entityIdentifier, concreteDescriptor );
final var entityHolder = persistenceContext.getEntityHolder(
data.entityKey
);
@@ -279,7 +279,7 @@ public void initializeInstanceFromParent(Object parentInstance, Data data) {
else {
final var lazyInitializer = extractLazyInitializer( instance );
if ( lazyInitializer != null && lazyInitializer.isUninitialized() ) {
- data.entityKey = new EntityKey( lazyInitializer.getInternalIdentifier(), concreteDescriptor );
+ data.entityKey = data.getRowProcessingState().getSession().generateEntityKey( lazyInitializer.getInternalIdentifier(), concreteDescriptor );
registerToBatchFetchQueue( data );
}
data.setState( State.INITIALIZED );
diff --git a/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/DiscriminatedEntityInitializer.java b/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/DiscriminatedEntityInitializer.java
index 645ad6417c6c..5c06d9fbfd25 100644
--- a/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/DiscriminatedEntityInitializer.java
+++ b/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/DiscriminatedEntityInitializer.java
@@ -8,7 +8,6 @@
import org.hibernate.Hibernate;
import org.hibernate.engine.spi.EntityHolder;
-import org.hibernate.engine.spi.EntityKey;
import org.hibernate.metamodel.mapping.DiscriminatedAssociationModelPart;
import org.hibernate.metamodel.mapping.ModelPart;
import org.hibernate.persister.entity.EntityPersister;
@@ -155,7 +154,7 @@ public void resolveInstance(DiscriminatedEntityInitializerData data) {
final var session = data.getRowProcessingState().getSession();
final Object identifier = data.entityIdentifier;
final var concreteDescriptor = data.concreteDescriptor;
- final var entityKey = new EntityKey( identifier, concreteDescriptor );
+ final var entityKey = data.getRowProcessingState().getSession().generateEntityKey( identifier, concreteDescriptor );
final var persistenceContext = session.getPersistenceContextInternal();
final var holder = persistenceContext.getEntityHolder( entityKey );
final Object instance;
@@ -250,7 +249,7 @@ else if ( lazyInitializer.isUninitialized() ) {
data.entityIdentifier = lazyInitializer.getInternalIdentifier();
}
- final var entityKey = new EntityKey( data.entityIdentifier, data.concreteDescriptor );
+ final var entityKey = session.generateEntityKey( data.entityIdentifier, data.concreteDescriptor );
final var entityHolder = session.getPersistenceContextInternal().getEntityHolder(
entityKey
);
diff --git a/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/EntityDelayedFetchInitializer.java b/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/EntityDelayedFetchInitializer.java
index b7b25be88a27..68ae4590e83d 100644
--- a/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/EntityDelayedFetchInitializer.java
+++ b/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/EntityDelayedFetchInitializer.java
@@ -195,7 +195,7 @@ private Object instanceWithId(
final var persistenceContext = session.getPersistenceContextInternal();
final var ek = entityKey == null ?
- new EntityKey( data.entityIdentifier, concreteDescriptor ) :
+ session.generateEntityKey( data.entityIdentifier, concreteDescriptor ) :
entityKey;
final var holder = persistenceContext.getEntityHolder( ek );
if ( holder != null && holder.getEntity() != null ) {
@@ -326,7 +326,7 @@ public void resolveInstance(Object instance, EntityDelayedFetchInitializerData d
final var entityDescriptor = getEntityDescriptor();
data.entityIdentifier = entityDescriptor.getIdentifier( instance, session );
- final var entityKey = new EntityKey( data.entityIdentifier, entityDescriptor );
+ final var entityKey = session.generateEntityKey( data.entityIdentifier, entityDescriptor );
final var entityHolder = session.getPersistenceContextInternal().getEntityHolder(
entityKey
);
diff --git a/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/EntityFetchJoinedImpl.java b/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/EntityFetchJoinedImpl.java
index 982435d3f4e0..83d2c7fcf9f1 100644
--- a/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/EntityFetchJoinedImpl.java
+++ b/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/EntityFetchJoinedImpl.java
@@ -154,6 +154,7 @@ public EntityInitializer> createInitializer(InitializerParent> parent, Assem
entityResult.getDiscriminatorFetch(),
keyResult,
entityResult.getRowIdResult(),
+ entityResult.getAuditTransactionIdResult(),
notFoundAction,
isAffectedByFilter,
parent,
diff --git a/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/EntityInitializerImpl.java b/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/EntityInitializerImpl.java
index 1cbecc9365d2..2738c87fa8b5 100644
--- a/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/EntityInitializerImpl.java
+++ b/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/EntityInitializerImpl.java
@@ -29,6 +29,7 @@
import org.hibernate.engine.spi.EntityEntry;
import org.hibernate.engine.spi.EntityHolder;
import org.hibernate.engine.spi.EntityKey;
+import org.hibernate.engine.spi.TemporalEntityKey;
import org.hibernate.engine.spi.EntityUniqueKey;
import org.hibernate.engine.spi.PersistenceContext;
import org.hibernate.engine.spi.PersistentAttributeInterceptor;
@@ -72,6 +73,7 @@
import org.checkerframework.checker.nullness.qual.EnsuresNonNull;
import org.checkerframework.checker.nullness.qual.Nullable;
+import static org.hibernate.audit.AuditLog.ALL_REVISIONS;
import static org.hibernate.bytecode.enhance.spi.LazyPropertyInitializer.UNFETCHED_PROPERTY;
import static org.hibernate.engine.internal.ManagedTypeHelper.asPersistentAttributeInterceptable;
import static org.hibernate.engine.internal.ManagedTypeHelper.isPersistentAttributeInterceptable;
@@ -122,6 +124,7 @@ public class EntityInitializerImpl
private final @Nullable BasicResultAssembler> discriminatorAssembler;
private final @Nullable DomainResultAssembler> versionAssembler;
private final @Nullable DomainResultAssembler rowIdAssembler;
+ private final @Nullable DomainResultAssembler> auditTransactionIdAssembler;
private final DomainResultAssembler>[][] assemblers;
private final @Nullable Initializer>[] allInitializers;
@@ -143,6 +146,7 @@ public static class EntityInitializerData extends InitializerData {
protected final boolean canUseEmbeddedIdentifierInstanceAsEntity;
protected final boolean hasCallbackActions;
protected final @Nullable EntityPersister defaultConcreteDescriptor;
+ protected final boolean allRevisions;
// per-row state
protected @Nullable EntityPersister concreteDescriptor;
@@ -171,6 +175,8 @@ public EntityInitializerData(EntityInitializerImpl initializer, RowProcessingSta
canUseEmbeddedIdentifierInstanceAsEntity = false;
}
hasCallbackActions = rowProcessingState.hasCallbackActions();
+ allRevisions = initializer.auditTransactionIdAssembler != null
+ && rowProcessingState.getSession().getLoadQueryInfluencers().isAllRevisions();
defaultConcreteDescriptor =
hasConcreteDescriptor( rowProcessingState, initializer.discriminatorAssembler, entityDescriptor )
? entityDescriptor
@@ -188,6 +194,7 @@ public EntityInitializerData(EntityInitializerData original) {
this.uniqueKeyPropertyTypes = original.uniqueKeyPropertyTypes;
this.canUseEmbeddedIdentifierInstanceAsEntity = original.canUseEmbeddedIdentifierInstanceAsEntity;
this.hasCallbackActions = original.hasCallbackActions;
+ this.allRevisions = original.allRevisions;
this.defaultConcreteDescriptor = original.defaultConcreteDescriptor;
this.concreteDescriptor = original.concreteDescriptor;
this.entityKey = original.entityKey;
@@ -213,6 +220,7 @@ public EntityInitializerImpl(
@Nullable Fetch discriminatorFetch,
@Nullable DomainResult> keyResult,
@Nullable DomainResult rowIdResult,
+ @Nullable DomainResult> auditTransactionIdResult,
NotFoundAction notFoundAction,
boolean affectedByFilter,
@Nullable InitializerParent> parent,
@@ -272,9 +280,17 @@ public EntityInitializerImpl(
final var versionMapping = entityDescriptor.getVersionMapping();
if ( versionMapping != null ) {
final var versionFetch = resultDescriptor.findFetch( versionMapping );
- // If there is a version mapping, there must be a fetch for it
- assert versionFetch != null;
- versionAssembler = versionFetch.createAssembler( this, creationState );
+ if ( versionFetch != null ) {
+ versionAssembler = versionFetch.createAssembler( this, creationState );
+ }
+ else {
+ // Version fetch is only expected to be null when the version
+ // property is excluded from audit tables
+ assert entityDescriptor.getAuditMapping() != null
+ && entityDescriptor.isPropertyAuditedExcluded(
+ versionMapping.getVersionAttribute().getStateArrayPosition() );
+ versionAssembler = null;
+ }
}
else {
versionAssembler = null;
@@ -285,6 +301,11 @@ public EntityInitializerImpl(
? null :
rowIdResult.createResultAssembler( this, creationState );
+ auditTransactionIdAssembler =
+ auditTransactionIdResult == null
+ ? null
+ : auditTransactionIdResult.createResultAssembler( this, creationState );
+
final int fetchableCount = entityDescriptor.getNumberOfFetchables();
final var subMappingTypes = rootEntityDescriptor.getSubMappingTypes();
final int size = subMappingTypes.size() + 1;
@@ -620,6 +641,7 @@ protected void resolveKey(EntityInitializerData data, boolean entityKeyOnly) {
if ( oldEntityKey != null
&& previousRowReuse
&& oldEntityInstance != null
+ && !data.allRevisions
&& areKeysEqual( oldEntityKey.getIdentifier(), id )
&& !oldEntityHolder.isDetached() ) {
data.setState( State.INITIALIZED );
@@ -830,7 +852,26 @@ protected void resolveEntityKey(EntityInitializerData data, Object id) {
discriminatorAssembler, entityDescriptor );
assert concreteDescriptor != null;
}
- data.entityKey = new EntityKey( id, concreteDescriptor );
+ final Object txId = resolveTransactionId( data );
+ data.entityKey = txId != null
+ ? new TemporalEntityKey( id, concreteDescriptor, txId )
+ : new EntityKey( id, concreteDescriptor );
+ }
+
+ protected Object resolveTransactionId(EntityInitializerData data) {
+ // For audited entities, include the per-row transaction identifier in the key
+ // so the PC distinguishes the same entity at different points in time
+ final var temporalIdentifier = data.getRowProcessingState().getLoadQueryInfluencers().getTemporalIdentifier();
+ if ( auditTransactionIdAssembler != null ) {
+ return auditTransactionIdAssembler.assemble( data.getRowProcessingState() );
+ }
+ else if ( entityDescriptor.getAuditMapping() != null
+ && temporalIdentifier != null && temporalIdentifier != ALL_REVISIONS ) {
+ return temporalIdentifier;
+ }
+ else {
+ return null;
+ }
}
protected void setMissing(EntityInitializerData data) {
@@ -1212,6 +1253,13 @@ public void resolveInstance(EntityInitializerData data) {
this
);
+ if ( data.allRevisions && data.entityKey.isTemporal() ) {
+ // Set the per-row temporal identifier so that association loads use the correct revision
+ data.getRowProcessingState().getSession()
+ .getLoadQueryInfluencers()
+ .setTemporalIdentifier( data.entityKey.getTransactionId() );
+ }
+
if ( useEmbeddedIdentifierInstanceAsEntity( data ) ) {
data.setInstance( data.entityInstanceForNotify = rowProcessingState.getEntityId() );
}
@@ -1536,7 +1584,7 @@ protected void registerReloadedEntity(EntityInitializerData data) {
public void initializeInstance(EntityInitializerData data) {
if ( data.getState() == State.RESOLVED ) {
if ( !skipInitialization( data ) ) {
- assert consistentInstance( data );
+ assert data.allRevisions || consistentInstance( data );
initializeEntityInstance( data );
}
else {
@@ -1555,6 +1603,15 @@ public void initializeInstance(EntityInitializerData data) {
}
}
+ @Override
+ public void endLoading(EntityInitializerData data) {
+ if ( data.allRevisions ) {
+ data.getRowProcessingState().getSession()
+ .getLoadQueryInfluencers()
+ .setTemporalIdentifier( ALL_REVISIONS );
+ }
+ }
+
protected void updateInitializedEntityInstance(EntityInitializerData data) {
assert rootEntityDescriptor.getBytecodeEnhancementMetadata().isEnhancedForLazyLoading();
@@ -1724,7 +1781,9 @@ protected void updateCaches(
if ( data.concreteDescriptor.canWriteToCache()
// No need to put into the entity cache if this is coming from the query cache already
&& !data.getRowProcessingState().isQueryCacheHit()
- && session.getCacheMode().isPutEnabled() ) {
+ && session.getCacheMode().isPutEnabled()
+ // Don't cache temporal snapshots in the 2LC
+ && ( data.entityKey == null || !data.entityKey.isTemporal() ) ) {
final var cacheAccess = data.concreteDescriptor.getCacheAccessStrategy();
if ( cacheAccess != null ) {
putInCache( data, session, persistenceContext, resolvedEntityState, version, cacheAccess );
@@ -1773,6 +1832,10 @@ private boolean isReallyReadOnly(
if ( !data.concreteDescriptor.isMutable() ) {
return true;
}
+ else if ( data.entityKey != null && data.entityKey.isTemporal() ) {
+ // Temporal entities (loaded from audit tables) are always read-only snapshots
+ return true;
+ }
else if ( previousEntityEntry != null ) {
return previousEntityEntry.isReadOnly();
}
diff --git a/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/EntityResultImpl.java b/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/EntityResultImpl.java
index 8cd64cd79952..5f74ed349838 100644
--- a/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/EntityResultImpl.java
+++ b/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/EntityResultImpl.java
@@ -110,6 +110,7 @@ public Initializer> createInitializer(InitializerParent> parent, AssemblerCr
getDiscriminatorFetch(),
null,
getRowIdResult(),
+ getAuditTransactionIdResult(),
NotFoundAction.EXCEPTION,
false,
null,
diff --git a/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/EntitySelectFetchInitializer.java b/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/EntitySelectFetchInitializer.java
index c13dc3fd144e..4e585f7ea534 100644
--- a/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/EntitySelectFetchInitializer.java
+++ b/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/EntitySelectFetchInitializer.java
@@ -167,7 +167,7 @@ else if ( lazyInitializer.isUninitialized() ) {
data.entityIdentifier = lazyInitializer.getInternalIdentifier();
}
- final var entityKey = new EntityKey( data.entityIdentifier, concreteDescriptor );
+ final var entityKey = data.getRowProcessingState().getSession().generateEntityKey( data.entityIdentifier, concreteDescriptor );
final var entityHolder = persistenceContext.getEntityHolder(
entityKey
);
@@ -212,7 +212,7 @@ protected void initialize(EntitySelectFetchInitializerData data) {
final var rowProcessingState = data.getRowProcessingState();
final var session = rowProcessingState.getSession();
final var persistenceContext = session.getPersistenceContextInternal();
- final EntityKey entityKey = new EntityKey( data.entityIdentifier, concreteDescriptor );
+ final EntityKey entityKey = data.getRowProcessingState().getSession().generateEntityKey( data.entityIdentifier, concreteDescriptor );
initialize( data, persistenceContext.getEntityHolder( entityKey ), session, persistenceContext );
}
@@ -257,7 +257,7 @@ else if ( data.getInstance() == null ) {
if ( instance == null ) {
checkNotFound( data );
persistenceContext.claimEntityHolderIfPossible(
- new EntityKey( data.entityIdentifier, concreteDescriptor ),
+ data.getRowProcessingState().getSession().generateEntityKey( data.entityIdentifier, concreteDescriptor ),
null,
data.getRowProcessingState().getJdbcValuesSourceProcessingState(),
this
diff --git a/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/JoinedDiscriminatedEntityInitializer.java b/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/JoinedDiscriminatedEntityInitializer.java
index 4ce5fb3d989b..23361f7fa230 100644
--- a/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/JoinedDiscriminatedEntityInitializer.java
+++ b/hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/JoinedDiscriminatedEntityInitializer.java
@@ -122,6 +122,7 @@ private EntityInitializer> resolveEntityInitializer(
entityResult.getDiscriminatorFetch(),
null,
entityResult.getRowIdResult(),
+ entityResult.getAuditTransactionIdResult(),
NotFoundAction.EXCEPTION,
false,
this,
diff --git a/hibernate-core/src/main/java/org/hibernate/sql/results/internal/NullValueAssembler.java b/hibernate-core/src/main/java/org/hibernate/sql/results/internal/NullValueAssembler.java
index 19a4a6fc5e80..469e529233ad 100644
--- a/hibernate-core/src/main/java/org/hibernate/sql/results/internal/NullValueAssembler.java
+++ b/hibernate-core/src/main/java/org/hibernate/sql/results/internal/NullValueAssembler.java
@@ -20,7 +20,7 @@ public NullValueAssembler(JavaType javaType) {
@Override
public J assemble(RowProcessingState rowProcessingState) {
- return null;
+ return javaType.getDefaultValue();
}
@Override
diff --git a/hibernate-core/src/main/java/org/hibernate/temporal/internal/TransactionIdentifierServiceImpl.java b/hibernate-core/src/main/java/org/hibernate/temporal/internal/TransactionIdentifierServiceImpl.java
index 8f1dde450af0..4a8153a6e2d7 100644
--- a/hibernate-core/src/main/java/org/hibernate/temporal/internal/TransactionIdentifierServiceImpl.java
+++ b/hibernate-core/src/main/java/org/hibernate/temporal/internal/TransactionIdentifierServiceImpl.java
@@ -37,10 +37,11 @@
*
* @since 7.4
*/
-public class TransactionIdentifierServiceImpl implements TransactionIdentifierService, TransactionIdentifierSupplier {
+public class TransactionIdentifierServiceImpl
+ implements TransactionIdentifierService, TransactionIdentifierSupplier {
- private final Class> identifierValueType;
- private final TransactionIdentifierSupplier> identifierValueSupplier;
+ private Class> identifierValueType;
+ private TransactionIdentifierSupplier> identifierSupplier;
private final boolean useServerTransactionTimestamps;
public TransactionIdentifierServiceImpl(ServiceRegistry serviceRegistry) {
@@ -57,21 +58,25 @@ public TransactionIdentifierServiceImpl(ServiceRegistry serviceRegistry) {
);
}
final var dialect = serviceRegistry.requireService( JdbcServices.class ).getDialect();
- identifierValueSupplier = dialect.isCurrentTimestampStable()
+ identifierSupplier = dialect.isCurrentTimestampStable()
? null
: new CurrentTimestampTransactionIdentifierSupplier();
identifierValueType = Instant.class;
}
else {
- identifierValueSupplier =
- transactionIdSupplier( settings,
+ identifierSupplier =
+ resolveSupplier( settings,
serviceRegistry.requireService( StrategySelector.class ) );
- @SuppressWarnings( "unchecked" ) // completely safe
- final var supplierClass = (Class>) identifierValueSupplier.getClass();
- identifierValueType = resolveSuppliedType( supplierClass );
+ identifierValueType = resolveSuppliedType( supplierClass( identifierSupplier ) );
}
}
+ @Override
+ public void contributeIdentifierSupplier(TransactionIdentifierSupplier supplier, Class identifierType) {
+ this.identifierSupplier = supplier;
+ this.identifierValueType = identifierType;
+ }
+
@Override
public Instant generateTransactionIdentifier(SharedSessionContract session) {
return Instant.now();
@@ -79,7 +84,7 @@ public Instant generateTransactionIdentifier(SharedSessionContract session) {
@Override
public boolean isIdentifierTypeInstant() {
- return identifierValueType == Instant.class;
+ return getIdentifierType() == Instant.class;
}
@Override
@@ -89,7 +94,7 @@ public Class> getIdentifierType() {
@Override
public TransactionIdentifierSupplier> getIdentifierSupplier() {
- return identifierValueSupplier;
+ return identifierSupplier;
}
@Override
@@ -102,18 +107,7 @@ public boolean useServerTimestamp(Dialect dialect) {
&& dialect.isCurrentTimestampStable();
}
- private static Class> resolveSuppliedType(Class extends TransactionIdentifierSupplier>> supplierClass) {
- final var supplierInstantiation = supertypeInstantiation( TransactionIdentifierSupplier.class, supplierClass );
- if ( supplierInstantiation == null ) {
- return null;
- }
- else {
- final var typeArguments = supplierInstantiation.getActualTypeArguments();
- return typeArguments.length == 0 ? null : erasedType( typeArguments[0] );
- }
- }
-
- public TransactionIdentifierSupplier> transactionIdSupplier(
+ private TransactionIdentifierSupplier> resolveSupplier(
Map settings,
StrategySelector strategySelector) {
final Object setting = settings.get( TRANSACTION_ID_SUPPLIER );
@@ -124,13 +118,14 @@ else if ( setting instanceof TransactionIdentifierSupplier> supplier ) {
return supplier;
}
else if ( setting instanceof Class> clazz ) {
- if ( !TransactionIdentifierSupplier.class.isAssignableFrom( clazz ) ) {
- throw new HibernateException(
- "Setting '" + TRANSACTION_ID_SUPPLIER + "' must specify a '"
- + TransactionIdentifierSupplier.class.getName() + "' or a class name"
- );
+ if ( TransactionIdentifierSupplier.class.isAssignableFrom( clazz ) ) {
+ return strategySelector.resolveStrategy( TransactionIdentifierSupplier.class, clazz );
}
- return strategySelector.resolveStrategy( TransactionIdentifierSupplier.class, clazz );
+ throw new HibernateException(
+ "Setting '" + TRANSACTION_ID_SUPPLIER + "' must specify a '"
+ + TransactionIdentifierSupplier.class.getName()
+ + "' implementation"
+ );
}
else if ( setting instanceof String name ) {
return strategySelector.resolveStrategy( TransactionIdentifierSupplier.class, name );
@@ -138,8 +133,26 @@ else if ( setting instanceof String name ) {
else {
throw new HibernateException(
"Setting '" + TRANSACTION_ID_SUPPLIER + "' must specify a '"
- + TransactionIdentifierSupplier.class.getName() + "' or a class name"
+ + TransactionIdentifierSupplier.class.getName()
+ + "' instance, class, or class name"
);
}
}
+
+ private static Class> resolveSuppliedType(Class extends TransactionIdentifierSupplier>> supplierClass) {
+ final var supplierInstantiation = supertypeInstantiation( TransactionIdentifierSupplier.class, supplierClass );
+ if ( supplierInstantiation == null ) {
+ return null;
+ }
+ else {
+ final var typeArguments = supplierInstantiation.getActualTypeArguments();
+ return typeArguments.length == 0 ? null : erasedType( typeArguments[0] );
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Class extends TransactionIdentifierSupplier>> supplierClass(
+ TransactionIdentifierSupplier> supplier) {
+ return (Class extends TransactionIdentifierSupplier>>) supplier.getClass();
+ }
}
diff --git a/hibernate-core/src/main/java/org/hibernate/temporal/spi/TransactionIdentifierService.java b/hibernate-core/src/main/java/org/hibernate/temporal/spi/TransactionIdentifierService.java
index f297b1638f59..7f1924c9392d 100644
--- a/hibernate-core/src/main/java/org/hibernate/temporal/spi/TransactionIdentifierService.java
+++ b/hibernate-core/src/main/java/org/hibernate/temporal/spi/TransactionIdentifierService.java
@@ -47,6 +47,7 @@ public interface TransactionIdentifierService extends Service {
/**
* A supplier of transaction identifiers or timestamps.
+ * The current session is passed to the supplier.
*
* @see StateManagementSettings#TRANSACTION_ID_SUPPLIER
*/
@@ -68,4 +69,19 @@ public interface TransactionIdentifierService extends Service {
* Whether the transaction identifiers are actually timestamps.
*/
boolean isIdentifierTypeInstant();
+
+ /**
+ * Programmatically contribute a {@link TransactionIdentifierSupplier},
+ * overriding any previously configured supplier.
+ *
+ * Called during bootstrap (e.g. from the session factory) when
+ * a supplier is derived from metadata rather than from explicit
+ * configuration.
+ *
+ * @param supplier the supplier to use
+ * @param identifierType the Java type of transaction identifiers
+ * produced by the supplier
+ */
+ default void contributeIdentifierSupplier(TransactionIdentifierSupplier supplier, Class identifierType) {
+ }
}
diff --git a/hibernate-core/src/main/java/org/hibernate/temporal/spi/TransactionIdentifierSupplier.java b/hibernate-core/src/main/java/org/hibernate/temporal/spi/TransactionIdentifierSupplier.java
index 752d0bf109a6..b53acea222b7 100644
--- a/hibernate-core/src/main/java/org/hibernate/temporal/spi/TransactionIdentifierSupplier.java
+++ b/hibernate-core/src/main/java/org/hibernate/temporal/spi/TransactionIdentifierSupplier.java
@@ -5,8 +5,8 @@
package org.hibernate.temporal.spi;
import org.hibernate.Incubating;
-import org.hibernate.SharedSessionContract;
import org.hibernate.cfg.StateManagementSettings;
+import org.hibernate.SharedSessionContract;
/**
@@ -33,9 +33,12 @@
* to ensure that the {@linkplain #generateTransactionIdentifier supplier}
* is called no more than once in a transaction.
*
+ * @param the type of transaction identifier produced
+ *
* @see StateManagementSettings#TRANSACTION_ID_SUPPLIER
*
* @author Gavin King
+ * @author Marco Belladelli
*
* @since 7.4
*/
@@ -43,7 +46,10 @@
public interface TransactionIdentifierSupplier {
/**
- * Generates the transaction identifier or timestamp for a transaction.
+ * Called once per transaction to obtain the transaction identifier
+ *
+ * @param session the current session
+ * @return the transaction identifier
*/
T generateTransactionIdentifier(SharedSessionContract session);
}
diff --git a/hibernate-core/src/main/java/org/hibernate/type/CollectionType.java b/hibernate-core/src/main/java/org/hibernate/type/CollectionType.java
index d6fbfdad154e..551f49387114 100644
--- a/hibernate-core/src/main/java/org/hibernate/type/CollectionType.java
+++ b/hibernate-core/src/main/java/org/hibernate/type/CollectionType.java
@@ -24,7 +24,6 @@
import org.hibernate.collection.spi.AbstractPersistentCollection;
import org.hibernate.collection.spi.PersistentArrayHolder;
import org.hibernate.collection.spi.PersistentCollection;
-import org.hibernate.engine.spi.CollectionKey;
import org.hibernate.engine.spi.SessionFactoryImplementor;
import org.hibernate.engine.spi.SharedSessionContractImplementor;
import org.hibernate.metamodel.CollectionClassification;
@@ -774,7 +773,7 @@ public String toString() {
public Object getCollection(Object key, SharedSessionContractImplementor session, Object owner, Boolean overridingEager) {
final var persister = getPersister( session );
final var persistenceContext = session.getPersistenceContextInternal();
- final var collectionKey = new CollectionKey( persister, key );
+ final var collectionKey = session.generateCollectionKey( persister, key );
// check if collection is currently being loaded
final var loadingCollectionEntry =
persistenceContext.getLoadContexts().findLoadingCollectionEntry( collectionKey );
diff --git a/hibernate-core/src/test/java/org/hibernate/temporal/AuditEntityTest.java b/hibernate-core/src/test/java/org/hibernate/temporal/AuditEntityTest.java
deleted file mode 100644
index 47ffaf2550ef..000000000000
--- a/hibernate-core/src/test/java/org/hibernate/temporal/AuditEntityTest.java
+++ /dev/null
@@ -1,128 +0,0 @@
-/*
- * SPDX-License-Identifier: Apache-2.0
- * Copyright Red Hat Inc. and Hibernate Authors
- */
-package org.hibernate.temporal;
-
-import jakarta.persistence.ElementCollection;
-import jakarta.persistence.Entity;
-import jakarta.persistence.Id;
-import jakarta.persistence.Version;
-import org.hibernate.SharedSessionContract;
-import org.hibernate.annotations.Audited;
-import org.hibernate.cfg.StateManagementSettings;
-import org.hibernate.temporal.spi.TransactionIdentifierSupplier;
-import org.hibernate.testing.orm.junit.DomainModel;
-import org.hibernate.testing.orm.junit.ServiceRegistry;
-import org.hibernate.testing.orm.junit.SessionFactory;
-import org.hibernate.testing.orm.junit.SessionFactoryScope;
-import org.hibernate.testing.orm.junit.Setting;
-import org.junit.jupiter.api.Test;
-
-import java.util.HashSet;
-import java.util.Set;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertNull;
-import static org.junit.jupiter.api.Assertions.assertSame;
-
-@SessionFactory
-@DomainModel(annotatedClasses = AuditEntityTest.AuditEntity.class)
-@ServiceRegistry(settings = @Setting(name = StateManagementSettings.TRANSACTION_ID_SUPPLIER,
- value = "org.hibernate.temporal.AuditEntityTest$TxIdSupplier"))
-class AuditEntityTest {
- private static int currentTxId;
-
- public static class TxIdSupplier implements TransactionIdentifierSupplier {
- @Override
- public Integer generateTransactionIdentifier(SharedSessionContract session) {
- return ++currentTxId;
- }
- }
-
- @Test
- void test(SessionFactoryScope scope) {
- scope.getSessionFactory().inTransaction(
- session -> {
- AuditEntity entity = new AuditEntity();
- entity.id = 1L;
- entity.text = "hello";
- entity.stringSet.add( "hello" );
- session.persist( entity );
- }
- );
- scope.getSessionFactory().inTransaction(
- session -> {
- AuditEntity entity = session.find( AuditEntity.class, 1L );
- entity.text = "goodbye";
- entity.stringSet.add( "goodbye" );
- }
- );
- scope.getSessionFactory().inTransaction(
- session -> {
- AuditEntity entity = session.find( AuditEntity.class, 1L );
- session.remove( entity );
- }
- );
- scope.getSessionFactory().inTransaction(
- session -> {
- AuditEntity entity = session.find( AuditEntity.class, 1L );
- assertNull( entity );
- }
- );
- try ( var s = scope.getSessionFactory().withOptions().atTransaction(0).open() ) {
- AuditEntity entity = s.find( AuditEntity.class, 1L );
- assertNull( entity );
- AuditEntity result =
- s.createSelectionQuery( "from AuditEntity where id = 1", AuditEntity.class )
- .getSingleResultOrNull();
- assertNull( result );
- }
- try ( var s = scope.getSessionFactory().withOptions().atTransaction(1).open() ) {
- AuditEntity entity = s.find( AuditEntity.class, 1L );
- assertEquals( "hello", entity.text);
- assertEquals( Set.of("hello"), entity.stringSet);
- AuditEntity result =
- s.createSelectionQuery( "from AuditEntity where id = 1", AuditEntity.class )
- .getSingleResultOrNull();
- assertSame( entity, result );
- }
- try ( var s = scope.getSessionFactory().withOptions().atTransaction(2).open() ) {
- AuditEntity entity = s.find( AuditEntity.class, 1L );
- assertEquals( "goodbye", entity.text);
- assertEquals( Set.of("hello","goodbye"), entity.stringSet );
- AuditEntity result =
- s.createSelectionQuery( "from AuditEntity where id = 1", AuditEntity.class )
- .getSingleResultOrNull();
- assertSame( entity, result );
- }
- try ( var s = scope.getSessionFactory().withOptions().atTransaction(3).open() ) {
- AuditEntity entity = s.find( AuditEntity.class, 1L );
- assertNull( entity );
- AuditEntity result =
- s.createSelectionQuery( "from AuditEntity where id = 1", AuditEntity.class )
- .getSingleResultOrNull();
- assertNull( result );
- }
- try ( var s = scope.getSessionFactory().withOptions().atTransaction(4).open() ) {
- AuditEntity entity = s.find( AuditEntity.class, 1L );
- assertNull( entity );
- AuditEntity result =
- s.createSelectionQuery( "from AuditEntity where id = 1", AuditEntity.class )
- .getSingleResultOrNull();
- assertNull( result );
- }
- }
- @Audited
- @Entity(name = "AuditEntity")
- static class AuditEntity {
- @Id
- long id;
- String text;
- @Version
- int version;
- @Audited
- @ElementCollection
- Set stringSet = new HashSet<>();
- }
-}
diff --git a/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditColumnFunctionTest.java b/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditColumnFunctionTest.java
new file mode 100644
index 000000000000..b57aa211ddc4
--- /dev/null
+++ b/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditColumnFunctionTest.java
@@ -0,0 +1,189 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright Red Hat Inc. and Hibernate Authors
+ */
+package org.hibernate.temporal.audit;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import org.hibernate.annotations.Audited;
+import org.hibernate.audit.AuditEntry;
+import org.hibernate.audit.AuditLog;
+import org.hibernate.audit.AuditLogFactory;
+import org.hibernate.audit.ModificationType;
+import org.hibernate.cfg.StateManagementSettings;
+import org.hibernate.SharedSessionContract;
+import org.hibernate.temporal.spi.TransactionIdentifierSupplier;
+import org.hibernate.testing.orm.junit.DomainModel;
+import org.hibernate.testing.orm.junit.ServiceRegistry;
+import org.hibernate.testing.orm.junit.AuditedTest;
+import org.hibernate.testing.orm.junit.SessionFactory;
+import org.hibernate.testing.orm.junit.SessionFactoryScope;
+import org.hibernate.testing.orm.junit.Setting;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@AuditedTest
+@SessionFactory
+@DomainModel(annotatedClasses = AuditColumnFunctionTest.Book.class)
+@ServiceRegistry(settings = @Setting(name = StateManagementSettings.TRANSACTION_ID_SUPPLIER,
+ value = "org.hibernate.temporal.audit.AuditColumnFunctionTest$TxIdSupplier"))
+class AuditColumnFunctionTest {
+ private static int currentTxId;
+
+ public static class TxIdSupplier implements TransactionIdentifierSupplier {
+ @Override
+ public Integer generateTransactionIdentifier(SharedSessionContract session) {
+ return ++currentTxId;
+ }
+
+ }
+
+ @Test
+ void testAuditColumnFunctions(SessionFactoryScope scope) {
+ currentTxId = 0;
+
+ // tx 1: insert
+ scope.getSessionFactory().inTransaction( session -> {
+ var book = new Book();
+ book.id = 1L;
+ book.title = "Original";
+ session.persist( book );
+ } );
+
+ // tx 2: update
+ scope.getSessionFactory().inTransaction( session -> {
+ var book = session.find( Book.class, 1L );
+ book.title = "Updated";
+ } );
+
+ // tx 3: delete
+ scope.getSessionFactory().inTransaction( session -> {
+ var book = session.find( Book.class, 1L );
+ session.remove( book );
+ } );
+
+ // Query all revisions using the HQL functions with scalar projections
+ // (avoids entity identity caching issues with duplicate PKs)
+ try (var s = scope.getSessionFactory().withStatelessOptions()
+ .atTransaction( AuditLog.ALL_REVISIONS ).openStatelessSession()) {
+
+ List results = s.createSelectionQuery(
+ "select e.title, transactionId(e), modificationType(e) " +
+ "from Book e where e.id = :id " +
+ "order by transactionId(e)",
+ Object[].class
+ ).setParameter( "id", 1L ).getResultList();
+
+ assertEquals( 3, results.size(), "Should have 3 audit rows (ADD, MOD, DEL)" );
+
+ // First revision: ADD
+ assertEquals( "Original", results.get( 0 )[0] );
+ assertEquals( 1, results.get( 0 )[1] );
+ assertEquals( ModificationType.ADD, results.get( 0 )[2] );
+
+ // Second revision: MOD
+ assertEquals( "Updated", results.get( 1 )[0] );
+ assertEquals( 2, results.get( 1 )[1] );
+ assertEquals( ModificationType.MOD, results.get( 1 )[2] );
+
+ // Third revision: DEL
+ assertEquals( 3, results.get( 2 )[1] );
+ assertEquals( ModificationType.DEL, results.get( 2 )[2] );
+
+ // Test transactionId() in WHERE clause
+ List titles = s.createSelectionQuery(
+ "select e.title from Book e " +
+ "where e.id = :id and transactionId(e) = :txId",
+ String.class
+ ).setParameter( "id", 1L ).setParameter( "txId", 2 ).getResultList();
+
+ assertEquals( 1, titles.size() );
+ assertEquals( "Updated", titles.get( 0 ) );
+
+ // Test plain entity select: each row should be a distinct snapshot
+ List allBooks = s.createSelectionQuery(
+ "from Book e where e.id = :id order by transactionId(e)",
+ Book.class
+ ).setParameter( "id", 1L ).getResultList();
+ assertEquals( 3, allBooks.size() );
+ // Each row should be a distinct entity instance (not deduplicated)
+ assertTrue( allBooks.get( 0 ) != allBooks.get( 1 ),
+ "All-revisions entities should be distinct instances" );
+ assertTrue( allBooks.get( 1 ) != allBooks.get( 2 ),
+ "All-revisions entities should be distinct instances" );
+ // Each instance should reflect the state at its revision
+ assertEquals( "Original", allBooks.get( 0 ).title );
+ assertEquals( "Updated", allBooks.get( 1 ).title );
+
+ // Test modificationType() in WHERE clause to find deletions
+ long delCount = s.createSelectionQuery(
+ "select count(e) from Book e " +
+ "where e.id = :id and modificationType(e) = :delType",
+ Long.class
+ ).setParameter( "id", 1L )
+ .setParameter( "delType", ModificationType.DEL )
+ .getSingleResult();
+
+ assertEquals( 1, delCount );
+ }
+ }
+
+ @Test
+ void testGetHistory(SessionFactoryScope scope) {
+ currentTxId = 0;
+
+ scope.getSessionFactory().inTransaction( session -> {
+ var book = new Book();
+ book.id = 2L;
+ book.title = "First";
+ session.persist( book );
+ } );
+
+ scope.getSessionFactory().inTransaction( session -> {
+ var book = session.find( Book.class, 2L );
+ book.title = "Second";
+ } );
+
+ scope.getSessionFactory().inTransaction( session -> {
+ var book = session.find( Book.class, 2L );
+ session.remove( book );
+ } );
+
+ try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) {
+ var history = auditLog.getHistory( Book.class, 2L );
+
+ assertEquals( 3, history.size() );
+
+ // ADD
+ AuditEntry