From e0a18e04cd08abb5db32a3792539f24f5e0419d2 Mon Sep 17 00:00:00 2001 From: Marco Belladelli Date: Wed, 15 Apr 2026 14:28:45 +0200 Subject: [PATCH] HHH-20214 Full `@Audited` entities read/write support in core --- .../org/hibernate/annotations/Audited.java | 173 +++- .../hibernate/annotations/RevisionEntity.java | 113 +++ .../java/org/hibernate/audit/AuditEntry.java | 26 + .../org/hibernate/audit/AuditException.java | 23 + .../java/org/hibernate/audit/AuditLog.java | 330 ++++++++ .../org/hibernate/audit/AuditLogFactory.java | 110 +++ .../audit/DefaultRevisionEntity.java | 36 + ...rackingModifiedEntitiesRevisionEntity.java | 31 + .../audit/EntityTrackingRevisionListener.java | 33 + .../org/hibernate/audit/ModificationType.java | 29 + .../org/hibernate/audit/RevisionListener.java | 25 + .../org/hibernate/audit/RevisionMapping.java | 93 +++ ...ackingModifiedEntitiesRevisionMapping.java | 85 ++ .../audit/internal/AuditColumnFunction.java | 202 +++++ .../audit/internal/AuditEntityLoaderImpl.java | 186 +++++ .../audit/internal/AuditLogImpl.java | 472 +++++++++++ .../org/hibernate/audit/package-info.java | 32 + .../audit/spi/AuditEntityLoader.java | 29 + .../hibernate/audit/spi/AuditWorkQueue.java | 289 +++++++ .../org/hibernate/audit/spi/AuditWriter.java | 36 + .../audit/spi/CollectionAuditWriter.java | 33 + .../audit/spi/RevisionEntitySupplier.java | 171 ++++ .../boot/model/internal/AuditHelper.java | 740 +++++++++++++++++- .../boot/model/internal/CollectionBinder.java | 40 +- .../boot/model/internal/EntityBinder.java | 10 +- .../cfg/StateManagementSettings.java | 29 +- .../spi/AbstractPersistentCollection.java | 23 +- .../java/org/hibernate/dialect/Dialect.java | 12 + .../internal/StatefulPersistenceContext.java | 4 +- .../hibernate/engine/spi/CollectionKey.java | 83 +- .../org/hibernate/engine/spi/EntityKey.java | 72 +- .../engine/spi/LoadQueryInfluencers.java | 5 + .../engine/spi/SessionDelegatorBaseImpl.java | 13 + .../spi/SharedSessionContractImplementor.java | 32 +- .../spi/SharedSessionDelegatorBaseImpl.java | 13 + .../engine/spi/TemporalCollectionKey.java | 69 ++ .../engine/spi/TemporalEntityKey.java | 52 ++ .../DefaultFlushEntityEventListener.java | 2 +- .../internal/CurrentTimestampGeneration.java | 4 + .../AbstractSharedSessionContract.java | 30 +- .../org/hibernate/internal/SessionImpl.java | 6 +- .../AbstractCollectionBatchLoader.java | 8 +- .../internal/AbstractMultiIdEntityLoader.java | 4 +- .../CollectionBatchLoaderArrayParam.java | 6 +- .../internal/CollectionLoaderSingleKey.java | 2 +- .../CollectionLoaderSubSelectFetch.java | 8 +- .../ast/internal/LoaderSelectBuilder.java | 3 +- .../SingleIdEntityLoaderStandardImpl.java | 6 + .../mapping/AuxiliaryTableHolder.java | 25 + .../main/java/org/hibernate/mapping/Join.java | 29 +- .../hibernate/mapping/PersistentClass.java | 29 +- .../java/org/hibernate/mapping/RootClass.java | 23 - .../java/org/hibernate/mapping/Stateful.java | 10 +- .../metamodel/mapping/AuditMapping.java | 62 +- .../metamodel/mapping/AuxiliaryMapping.java | 47 +- .../mapping/internal/AuditMappingImpl.java | 542 +++++++++---- .../internal/OneToManyCollectionPart.java | 79 +- .../internal/PluralAttributeMappingImpl.java | 20 +- .../internal/SoftDeleteMappingImpl.java | 3 +- .../mapping/internal/TemporalMappingImpl.java | 20 +- .../mutation/AuditCollectionHelper.java | 99 ++- .../AuditCollectionRowMutationHelper.java | 150 ++-- .../mutation/DeleteRowsCoordinatorAudit.java | 128 --- .../mutation/InsertRowsCoordinatorAudit.java | 355 ++++++++- .../mutation/RemoveCoordinatorAudit.java | 157 ++++ .../mutation/UpdateRowsCoordinatorAudit.java | 151 ---- .../entity/AbstractEntityPersister.java | 58 +- .../entity/UnionSubclassEntityPersister.java | 109 ++- .../mutation/AbstractAuditCoordinator.java | 508 ++++++++---- .../mutation/AbstractMutationCoordinator.java | 5 +- .../mutation/DeleteCoordinatorAudit.java | 32 +- .../mutation/InsertCoordinatorAudit.java | 6 +- .../mutation/UpdateCoordinatorAudit.java | 4 +- .../internal/AbstractStateManagement.java | 2 +- .../state/internal/AuditStateManagement.java | 349 +++++++-- .../persister/state/spi/StateManagement.java | 2 +- .../proxy/AbstractLazyInitializer.java | 31 +- .../internal/complete/EntityResultImpl.java | 1 + .../sqm/sql/BaseSqmToSqlAstConverter.java | 3 +- .../ast/tree/from/NamedTableReference.java | 2 +- .../JdbcSelectExecutorStandardImpl.java | 4 +- .../AbstractCollectionInitializer.java | 29 +- ...bstractImmediateCollectionInitializer.java | 2 +- .../AbstractNonJoinCollectionInitializer.java | 2 +- .../entity/AbstractEntityResultGraphNode.java | 40 + .../graph/entity/EntityInitializer.java | 2 +- ...ractBatchEntitySelectFetchInitializer.java | 6 +- .../DiscriminatedEntityInitializer.java | 5 +- .../EntityDelayedFetchInitializer.java | 4 +- .../internal/EntityFetchJoinedImpl.java | 1 + .../internal/EntityInitializerImpl.java | 75 +- .../entity/internal/EntityResultImpl.java | 1 + .../EntitySelectFetchInitializer.java | 6 +- .../JoinedDiscriminatedEntityInitializer.java | 1 + .../results/internal/NullValueAssembler.java | 2 +- .../TransactionIdentifierServiceImpl.java | 73 +- .../spi/TransactionIdentifierService.java | 16 + .../spi/TransactionIdentifierSupplier.java | 10 +- .../org/hibernate/type/CollectionType.java | 3 +- .../hibernate/temporal/AuditEntityTest.java | 128 --- .../audit/AuditColumnFunctionTest.java | 189 +++++ .../temporal/audit/AuditCompositeIdTest.java | 198 +++++ .../temporal/audit/AuditCustomTableTest.java | 149 ++++ .../temporal/audit/AuditEntityTest.java | 400 ++++++++++ .../audit/AuditExcludedPropertyTest.java | 205 +++++ .../temporal/audit/AuditLogTest.java | 525 +++++++++++++ .../audit/AuditRevisionEntityTest.java | 350 +++++++++ .../audit/AuditSecondaryTableTest.java | 248 ++++++ .../audit/AuditTargetNotAuditedTest.java | 438 +++++++++++ .../audit/AuditToOneAssociationTest.java | 613 +++++++++++++++ .../audit/DefaultRevisionEntityTest.java | 155 ++++ .../EntityTrackingRevisionListenerTest.java | 177 +++++ .../audit/RevChangesTrackingTest.java | 302 +++++++ .../audit/RevisionEntityAnnotationTest.java | 135 ++++ .../AuditBidirectionalManyToManyTest.java | 319 ++++++++ .../AuditBidirectionalOneToManyTest.java | 310 ++++++++ .../AuditElementCollectionTest.java | 511 ++++++++++++ .../audit/collection/AuditManyToManyTest.java | 296 +++++++ ...UnidirectionalOneToManyJoinColumnTest.java | 354 +++++++++ ...tUnidirectionalOneToManyJoinTableTest.java | 300 +++++++ ...ditJoinedDiscriminatorInheritanceTest.java | 394 ++++++++++ .../AuditJoinedInheritanceTest.java | 389 +++++++++ .../AuditSingleTableInheritanceTest.java | 394 ++++++++++ .../AuditTablePerClassInheritanceTest.java | 388 +++++++++ .../orm/junit/AuditStrategyExtension.java | 85 ++ .../testing/orm/junit/AuditedTest.java | 40 + .../validation/MockSessionFactory.java | 2 +- 127 files changed, 13944 insertions(+), 1206 deletions(-) create mode 100644 hibernate-core/src/main/java/org/hibernate/annotations/RevisionEntity.java create mode 100644 hibernate-core/src/main/java/org/hibernate/audit/AuditEntry.java create mode 100644 hibernate-core/src/main/java/org/hibernate/audit/AuditException.java create mode 100644 hibernate-core/src/main/java/org/hibernate/audit/AuditLog.java create mode 100644 hibernate-core/src/main/java/org/hibernate/audit/AuditLogFactory.java create mode 100644 hibernate-core/src/main/java/org/hibernate/audit/DefaultRevisionEntity.java create mode 100644 hibernate-core/src/main/java/org/hibernate/audit/DefaultTrackingModifiedEntitiesRevisionEntity.java create mode 100644 hibernate-core/src/main/java/org/hibernate/audit/EntityTrackingRevisionListener.java create mode 100644 hibernate-core/src/main/java/org/hibernate/audit/ModificationType.java create mode 100644 hibernate-core/src/main/java/org/hibernate/audit/RevisionListener.java create mode 100644 hibernate-core/src/main/java/org/hibernate/audit/RevisionMapping.java create mode 100644 hibernate-core/src/main/java/org/hibernate/audit/TrackingModifiedEntitiesRevisionMapping.java create mode 100644 hibernate-core/src/main/java/org/hibernate/audit/internal/AuditColumnFunction.java create mode 100644 hibernate-core/src/main/java/org/hibernate/audit/internal/AuditEntityLoaderImpl.java create mode 100644 hibernate-core/src/main/java/org/hibernate/audit/internal/AuditLogImpl.java create mode 100644 hibernate-core/src/main/java/org/hibernate/audit/package-info.java create mode 100644 hibernate-core/src/main/java/org/hibernate/audit/spi/AuditEntityLoader.java create mode 100644 hibernate-core/src/main/java/org/hibernate/audit/spi/AuditWorkQueue.java create mode 100644 hibernate-core/src/main/java/org/hibernate/audit/spi/AuditWriter.java create mode 100644 hibernate-core/src/main/java/org/hibernate/audit/spi/CollectionAuditWriter.java create mode 100644 hibernate-core/src/main/java/org/hibernate/audit/spi/RevisionEntitySupplier.java create mode 100644 hibernate-core/src/main/java/org/hibernate/engine/spi/TemporalCollectionKey.java create mode 100644 hibernate-core/src/main/java/org/hibernate/engine/spi/TemporalEntityKey.java create mode 100644 hibernate-core/src/main/java/org/hibernate/mapping/AuxiliaryTableHolder.java delete mode 100644 hibernate-core/src/main/java/org/hibernate/persister/collection/mutation/DeleteRowsCoordinatorAudit.java create mode 100644 hibernate-core/src/main/java/org/hibernate/persister/collection/mutation/RemoveCoordinatorAudit.java delete mode 100644 hibernate-core/src/main/java/org/hibernate/persister/collection/mutation/UpdateRowsCoordinatorAudit.java delete mode 100644 hibernate-core/src/test/java/org/hibernate/temporal/AuditEntityTest.java create mode 100644 hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditColumnFunctionTest.java create mode 100644 hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditCompositeIdTest.java create mode 100644 hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditCustomTableTest.java create mode 100644 hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditEntityTest.java create mode 100644 hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditExcludedPropertyTest.java create mode 100644 hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditLogTest.java create mode 100644 hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditRevisionEntityTest.java create mode 100644 hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditSecondaryTableTest.java create mode 100644 hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditTargetNotAuditedTest.java create mode 100644 hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditToOneAssociationTest.java create mode 100644 hibernate-core/src/test/java/org/hibernate/temporal/audit/DefaultRevisionEntityTest.java create mode 100644 hibernate-core/src/test/java/org/hibernate/temporal/audit/EntityTrackingRevisionListenerTest.java create mode 100644 hibernate-core/src/test/java/org/hibernate/temporal/audit/RevChangesTrackingTest.java create mode 100644 hibernate-core/src/test/java/org/hibernate/temporal/audit/RevisionEntityAnnotationTest.java create mode 100644 hibernate-core/src/test/java/org/hibernate/temporal/audit/collection/AuditBidirectionalManyToManyTest.java create mode 100644 hibernate-core/src/test/java/org/hibernate/temporal/audit/collection/AuditBidirectionalOneToManyTest.java create mode 100644 hibernate-core/src/test/java/org/hibernate/temporal/audit/collection/AuditElementCollectionTest.java create mode 100644 hibernate-core/src/test/java/org/hibernate/temporal/audit/collection/AuditManyToManyTest.java create mode 100644 hibernate-core/src/test/java/org/hibernate/temporal/audit/collection/AuditUnidirectionalOneToManyJoinColumnTest.java create mode 100644 hibernate-core/src/test/java/org/hibernate/temporal/audit/collection/AuditUnidirectionalOneToManyJoinTableTest.java create mode 100644 hibernate-core/src/test/java/org/hibernate/temporal/audit/inheritance/AuditJoinedDiscriminatorInheritanceTest.java create mode 100644 hibernate-core/src/test/java/org/hibernate/temporal/audit/inheritance/AuditJoinedInheritanceTest.java create mode 100644 hibernate-core/src/test/java/org/hibernate/temporal/audit/inheritance/AuditSingleTableInheritanceTest.java create mode 100644 hibernate-core/src/test/java/org/hibernate/temporal/audit/inheritance/AuditTablePerClassInheritanceTest.java create mode 100644 hibernate-testing/src/main/java/org/hibernate/testing/orm/junit/AuditStrategyExtension.java create mode 100644 hibernate-testing/src/main/java/org/hibernate/testing/orm/junit/AuditedTest.java 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: + *

+ *

+ * 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 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: + *

+ * + * @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> 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> 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> 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 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 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 stateManagementType; - private Table auxiliaryTable; private boolean partitioned; - private Map auxiliaryColumns; private String auxiliaryColumnInPrimaryKey; private boolean primaryKeyDisabled; @@ -496,23 +492,4 @@ public Class 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 stateManagementType); Class 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> 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> 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> supplierClass( + TransactionIdentifierSupplier supplier) { + return (Class>) 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 add = history.get( 0 ); + assertEquals( "First", add.entity().title ); + assertEquals( ModificationType.ADD, add.modificationType() ); + + // MOD + AuditEntry mod = history.get( 1 ); + assertEquals( "Second", mod.entity().title ); + assertEquals( ModificationType.MOD, mod.modificationType() ); + + // DEL + AuditEntry del = history.get( 2 ); + assertEquals( ModificationType.DEL, del.modificationType() ); + + // Each entity should be a distinct instance + assertTrue( add.entity() != mod.entity() ); + assertTrue( mod.entity() != del.entity() ); + } + } + + @Audited + @Entity(name = "Book") + static class Book { + @Id + long id; + String title; + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditCompositeIdTest.java b/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditCompositeIdTest.java new file mode 100644 index 000000000000..c7fda997e64c --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditCompositeIdTest.java @@ -0,0 +1,198 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.temporal.audit; + +import java.io.Serializable; +import java.util.Objects; + +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import org.hibernate.annotations.Audited; +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 static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests @Audited with composite identifiers: @EmbeddedId, @IdClass, + * and non-aggregated multiple @Id. + */ +@AuditedTest +@SessionFactory +@DomainModel(annotatedClasses = { + AuditCompositeIdTest.EmbeddedIdEntity.class, + AuditCompositeIdTest.IdClassEntity.class, + AuditCompositeIdTest.MultiIdEntity.class +}) +@ServiceRegistry(settings = @Setting(name = StateManagementSettings.TRANSACTION_ID_SUPPLIER, + value = "org.hibernate.temporal.audit.AuditCompositeIdTest$TxIdSupplier")) +class AuditCompositeIdTest { + private static int currentTxId; + + public static class TxIdSupplier implements TransactionIdentifierSupplier { + @Override + public Integer generateTransactionIdentifier(SharedSessionContract session) { + return ++currentTxId; + } + + } + + @Test + void testEmbeddedId(SessionFactoryScope scope) { + currentTxId = 0; + final var key = new CompositeKey( 1L, 2L ); + + scope.inTransaction( session -> { + var e = new EmbeddedIdEntity(); + e.id = key; + e.data = "initial"; + session.persist( e ); + } ); + + scope.inTransaction( session -> + session.find( EmbeddedIdEntity.class, key ).data = "updated" ); + + try (var s = scope.getSessionFactory().withOptions().atTransaction( 1 ).openSession()) { + var e = s.find( EmbeddedIdEntity.class, key ); + assertThat( e ).isNotNull(); + assertThat( e.data ).isEqualTo( "initial" ); + } + + try (var s = scope.getSessionFactory().withOptions().atTransaction( 2 ).openSession()) { + var e = s.find( EmbeddedIdEntity.class, key ); + assertThat( e ).isNotNull(); + assertThat( e.data ).isEqualTo( "updated" ); + } + } + + @Test + void testIdClass(SessionFactoryScope scope) { + currentTxId = 100; + final var key = new CompositeKey( 10L, 20L ); + + scope.inTransaction( session -> { + var e = new IdClassEntity(); + e.part1 = 10L; + e.part2 = 20L; + e.data = "initial"; + session.persist( e ); + } ); + + scope.inTransaction( session -> + session.find( IdClassEntity.class, key ).data = "updated" ); + + try (var s = scope.getSessionFactory().withOptions().atTransaction( 101 ).openSession()) { + var e = s.find( IdClassEntity.class, key ); + assertThat( e ).isNotNull(); + assertThat( e.data ).isEqualTo( "initial" ); + } + + try (var s = scope.getSessionFactory().withOptions().atTransaction( 102 ).openSession()) { + var e = s.find( IdClassEntity.class, key ); + assertThat( e ).isNotNull(); + assertThat( e.data ).isEqualTo( "updated" ); + } + } + + @Test + void testMultipleId(SessionFactoryScope scope) { + currentTxId = 200; + + scope.inTransaction( session -> { + var e = new MultiIdEntity(); + e.pk1 = 10L; + e.pk2 = 20L; + e.data = "initial"; + session.persist( e ); + } ); + + scope.inTransaction( session -> + session.createSelectionQuery( + "from MultiIdEntity where pk1 = 10 and pk2 = 20", MultiIdEntity.class ) + .getSingleResult().data = "updated" ); + + try (var s = scope.getSessionFactory().withOptions().atTransaction( 201 ).openSession()) { + var e = s.createSelectionQuery( + "from MultiIdEntity where pk1 = 10 and pk2 = 20", MultiIdEntity.class ) + .getSingleResultOrNull(); + assertThat( e ).isNotNull(); + assertThat( e.data ).isEqualTo( "initial" ); + } + + try (var s = scope.getSessionFactory().withOptions().atTransaction( 202 ).openSession()) { + var e = s.createSelectionQuery( + "from MultiIdEntity where pk1 = 10 and pk2 = 20", MultiIdEntity.class ) + .getSingleResultOrNull(); + assertThat( e ).isNotNull(); + assertThat( e.data ).isEqualTo( "updated" ); + } + } + + // ---- Entity classes ---- + + @Embeddable + static class CompositeKey implements Serializable { + long part1; + long part2; + + CompositeKey() { + } + + CompositeKey(long part1, long part2) { + this.part1 = part1; + this.part2 = part2; + } + + @Override + public boolean equals(Object o) { + return o instanceof CompositeKey ck && part1 == ck.part1 && part2 == ck.part2; + } + + @Override + public int hashCode() { + return Objects.hash( part1, part2 ); + } + } + + @Audited + @Entity(name = "EmbeddedIdEntity") + static class EmbeddedIdEntity { + @EmbeddedId + CompositeKey id; + String data; + } + + @Audited + @Entity(name = "IdClassEntity") + @IdClass(CompositeKey.class) + static class IdClassEntity { + @Id + long part1; + @Id + long part2; + String data; + } + + @Audited + @Entity(name = "MultiIdEntity") + static class MultiIdEntity { + @Id + long pk1; + @Id + long pk2; + String data; + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditCustomTableTest.java b/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditCustomTableTest.java new file mode 100644 index 000000000000..4e5a05bebd2d --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditCustomTableTest.java @@ -0,0 +1,149 @@ +/* + * 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.SharedSessionContract; +import org.hibernate.annotations.Audited; +import org.hibernate.cfg.StateManagementSettings; +import org.hibernate.boot.model.naming.Identifier; +import org.hibernate.temporal.spi.TransactionIdentifierSupplier; +import org.hibernate.testing.orm.junit.AuditedTest; +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.hibernate.testing.orm.junit.BeforeClassTemplate; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Tests that {@link Audited.Table @Audited.Table} customizes + * the audit table name and column names. + */ +@AuditedTest +@SessionFactory +@DomainModel(annotatedClasses = AuditCustomTableTest.CustomTableEntity.class) +@ServiceRegistry(settings = @Setting(name = StateManagementSettings.TRANSACTION_ID_SUPPLIER, + value = "org.hibernate.temporal.audit.AuditCustomTableTest$TxIdSupplier")) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AuditCustomTableTest { + private static int currentTxId; + + public static class TxIdSupplier implements TransactionIdentifierSupplier { + @Override + public Integer generateTransactionIdentifier(SharedSessionContract session) { + return ++currentTxId; + } + } + + @BeforeClassTemplate + void setupData(SessionFactoryScope scope) { + currentTxId = 0; + + scope.getSessionFactory().inTransaction( session -> { + var e = new CustomTableEntity(); + e.id = 1L; + e.name = "created"; + session.persist( e ); + } ); + + scope.getSessionFactory().inTransaction( session -> { + var e = session.find( CustomTableEntity.class, 1L ); + e.name = "updated"; + } ); + } + + @AfterAll + void cleanupData(SessionFactoryScope scope) { + scope.getSessionFactory().getSchemaManager().truncateMappedObjects(); + } + + @Test + + void testCustomAuditTableName(SessionFactoryScope scope) { + final var auditTable = scope.getMetadataImplementor() + .getEntityBinding( CustomTableEntity.class.getName() ) + .getAuxiliaryTable(); + assertNotNull( auditTable, "Audit table should exist" ); + assertEquals( "MY_AUDIT_LOG", auditTable.getName() ); + } + + @Test + void testCustomColumnNames(SessionFactoryScope scope) { + final var auditTable = scope.getMetadataImplementor() + .getEntityBinding( CustomTableEntity.class.getName() ) + .getAuxiliaryTable(); + assertNotNull( + auditTable.getColumn( Identifier.toIdentifier( "TX_ID" ) ), + "Custom transaction id column TX_ID should exist" + ); + assertNotNull( + auditTable.getColumn( Identifier.toIdentifier( "MOD_TYPE" ) ), + "Custom modification type column MOD_TYPE should exist" + ); + } + + @Test + void testAuditReadWithCustomTable(SessionFactoryScope scope) { + try (var s = scope.getSessionFactory().withOptions().atTransaction( 1 ).open()) { + var e = s.find( CustomTableEntity.class, 1L ); + assertNotNull( e ); + assertEquals( "created", e.name ); + } + + try (var s = scope.getSessionFactory().withOptions().atTransaction( 2 ).open()) { + var e = s.find( CustomTableEntity.class, 1L ); + assertNotNull( e ); + assertEquals( "updated", e.name ); + } + } + + @Test + void testAuditDeleteWithCustomTable(SessionFactoryScope scope) { + currentTxId = 100; + + scope.getSessionFactory().inTransaction( session -> { + var e = new CustomTableEntity(); + e.id = 99L; + e.name = "to-delete"; + session.persist( e ); + } ); + + scope.getSessionFactory().inTransaction( session -> { + var e = session.find( CustomTableEntity.class, 99L ); + session.remove( e ); + } ); + + try (var s = scope.getSessionFactory().withOptions().atTransaction( 101 ).open()) { + var e = s.find( CustomTableEntity.class, 99L ); + assertNotNull( e ); + assertEquals( "to-delete", e.name ); + } + + try (var s = scope.getSessionFactory().withOptions().atTransaction( 102 ).open()) { + var e = s.find( CustomTableEntity.class, 99L ); + assertNull( e ); + } + } + + // ---- Entity ---- + + @Audited + @Audited.Table(name = "MY_AUDIT_LOG", transactionIdColumn = "TX_ID", modificationTypeColumn = "MOD_TYPE") + @Entity(name = "CustomTableEntity") + static class CustomTableEntity { + @Id + long id; + String name; + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditEntityTest.java b/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditEntityTest.java new file mode 100644 index 000000000000..2250f43cef40 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditEntityTest.java @@ -0,0 +1,400 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.temporal.audit; + +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Embeddable; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Version; +import org.hibernate.annotations.Audited; +import org.hibernate.audit.AuditLogFactory; +import org.hibernate.cfg.StateManagementSettings; +import org.hibernate.testing.orm.junit.AuditedTest; +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 org.hibernate.SharedSessionContract; +import org.hibernate.temporal.spi.TransactionIdentifierSupplier; + +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; + +@AuditedTest +@SessionFactory +@DomainModel(annotatedClasses = { + AuditEntityTest.AuditEntity.class, + AuditEntityTest.EmbeddedEntity.class +}) +@ServiceRegistry(settings = @Setting(name = StateManagementSettings.TRANSACTION_ID_SUPPLIER, + value = "org.hibernate.temporal.audit.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) { + currentTxId = 0; + 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 ); + } + } + + /** + * Test that {@code atTransaction().find()} returns the most recent + * snapshot at or before the given revision, even when the entity + * was not modified at that exact revision. + */ + @Test + void testFindAtNonModifiedRevision(SessionFactoryScope scope) { + currentTxId = 700; + final var sf = scope.getSessionFactory(); + + // Rev 701: create entity A + sf.inTransaction( session -> { + var e = new AuditEntity(); + e.id = 701L; + e.text = "A-created"; + session.persist( e ); + } ); + + // Rev 702: create entity B (entity A is NOT modified) + sf.inTransaction( session -> { + var e = new AuditEntity(); + e.id = 702L; + e.text = "B-created"; + session.persist( e ); + } ); + + // Rev 703: update entity B (entity A is still NOT modified) + sf.inTransaction( session -> { + var e = session.find( AuditEntity.class, 702L ); + e.text = "B-updated"; + } ); + + // Entity A was only modified at rev 701, but should be visible + // at rev 702 and 703 with its original state + try (var s = sf.withOptions().atTransaction( 702 ).open()) { + var a = s.find( AuditEntity.class, 701L ); + assertEquals( "A-created", a.text ); + } + try (var s = sf.withOptions().atTransaction( 703 ).open()) { + var a = s.find( AuditEntity.class, 701L ); + assertEquals( "A-created", a.text ); + } + + // Entity B should not be visible at rev 701 (created later) + try (var s = sf.withOptions().atTransaction( 701 ).open()) { + var b = s.find( AuditEntity.class, 702L ); + assertNull( b ); + } + } + + @Test + void testEmbeddedAuditing(SessionFactoryScope scope) { + currentTxId = 100; + + scope.getSessionFactory().inTransaction( session -> { + var e = new EmbeddedEntity(); + e.id = 1L; + e.name = "test"; + e.address = new Address( "123 Main St", "Springfield" ); + session.persist( e ); + } ); + + scope.getSessionFactory().inTransaction( session -> { + var e = session.find( EmbeddedEntity.class, 1L ); + e.address = new Address( "456 Oak Ave", "Shelbyville" ); + } ); + + try (var s = scope.getSessionFactory().withOptions().atTransaction( 101 ).open()) { + var e = s.find( EmbeddedEntity.class, 1L ); + assertEquals( "123 Main St", e.address.street ); + assertEquals( "Springfield", e.address.city ); + } + + try (var s = scope.getSessionFactory().withOptions().atTransaction( 102 ).open()) { + var e = s.find( EmbeddedEntity.class, 1L ); + assertEquals( "456 Oak Ave", e.address.street ); + assertEquals( "Shelbyville", e.address.city ); + } + } + + /** + * Test that multiple flushes within the same transaction produce + * a single audit row with the latest state (MOD+MOD -> MOD). + */ + @Test + void testMultiFlushMerge(SessionFactoryScope scope) { + currentTxId = 200; + + // Revision 201: create entity + scope.getSessionFactory().inTransaction( session -> { + var e = new AuditEntity(); + e.id = 99L; + e.text = "initial"; + session.persist( e ); + } ); + + // Revision 202: modify twice with explicit flush between + scope.getSessionFactory().inTransaction( session -> { + var e = session.find( AuditEntity.class, 99L ); + e.text = "first change"; + session.flush(); + e.text = "second change"; + // second flush happens at commit + } ); + + // Verify: revision 201 = ADD with "initial" + try (var s = scope.getSessionFactory().withOptions().atTransaction( 201 ).open()) { + var e = s.find( AuditEntity.class, 99L ); + assertEquals( "initial", e.text ); + } + + // Verify: revision 202 = single MOD with "second change" (not two rows) + try (var s = scope.getSessionFactory().withOptions().atTransaction( 202 ).open()) { + var e = s.find( AuditEntity.class, 99L ); + assertEquals( "second change", e.text ); + } + + // Verify via AuditLog: exactly 2 revisions for this entity + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var revisions = auditLog.getRevisions( AuditEntity.class, 99L ); + assertEquals( 2, revisions.size() ); + } + } + + /** + * Test that ADD + DEL in the same transaction cancels out (no audit row. + */ + @Test + void testAddDeleteCancellation(SessionFactoryScope scope) { + currentTxId = 300; + + // Single transaction: persist + flush + remove + scope.getSessionFactory().inTransaction( session -> { + var e = new AuditEntity(); + e.id = 88L; + e.text = "ephemeral"; + session.persist( e ); + session.flush(); + session.remove( e ); + } ); + + // Verify: no audit rows exist for this entity + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var revisions = auditLog.getRevisions( AuditEntity.class, 88L ); + assertEquals( 0, revisions.size() ); + } + } + + /** + * Test ADD + MOD merge: insert then modify in same transaction + * should produce a single ADD row with the final state. + */ + @Test + void testAddModMerge(SessionFactoryScope scope) { + currentTxId = 400; + + scope.getSessionFactory().inTransaction( session -> { + var e = new AuditEntity(); + e.id = 77L; + e.text = "before"; + session.persist( e ); + session.flush(); + e.text = "after"; + } ); + + // Verify: single revision, modification type = ADD, final state + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var history = auditLog.getHistory( AuditEntity.class, 77L ); + assertEquals( 1, history.size() ); + assertEquals( org.hibernate.audit.ModificationType.ADD, history.get( 0 ).modificationType() ); + assertEquals( "after", ((AuditEntity) history.get( 0 ).entity()).text ); + } + } + + /** + * Test collection multi-flush: modify the same collection twice + * with explicit flush between. Should produce correct audit rows. + */ + @Test + void testCollectionMultiFlush(SessionFactoryScope scope) { + currentTxId = 500; + + // Revision 501: create entity with collection + scope.getSessionFactory().inTransaction( session -> { + var e = new AuditEntity(); + e.id = 66L; + e.text = "coll-test"; + e.stringSet.add( "a" ); + session.persist( e ); + } ); + + // Revision 502: modify collection twice with flush between + scope.getSessionFactory().inTransaction( session -> { + var e = session.find( AuditEntity.class, 66L ); + e.stringSet.add( "b" ); + session.flush(); + e.stringSet.add( "c" ); + } ); + + // Verify: at revision 502, collection has a, b, c + try (var s = scope.getSessionFactory().withOptions().atTransaction( 502 ).open()) { + var e = s.find( AuditEntity.class, 66L ); + assertEquals( Set.of( "a", "b", "c" ), e.stringSet ); + } + } + + /** + * Test ADD+DEL cancellation for entity with collection: + * no orphaned collection audit rows should exist. + */ + @Test + void testAddDeleteCancellationWithCollection(SessionFactoryScope scope) { + currentTxId = 600; + + scope.getSessionFactory().inTransaction( session -> { + var e = new AuditEntity(); + e.id = 55L; + e.text = "ephemeral-coll"; + e.stringSet.add( "x" ); + session.persist( e ); + session.flush(); + session.remove( e ); + } ); + + // Verify: no entity audit rows + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var revisions = auditLog.getRevisions( AuditEntity.class, 55L ); + assertEquals( 0, revisions.size() ); + } + + // Note: orphaned collection audit rows may remain (orphaned rows are unreachable by any query). + // They are unreachable by any query since the entity has no audit row. + } + + // ---- Entity classes ---- + + @Audited + @Entity(name = "AuditEntity") + static class AuditEntity { + @Id + long id; + String text; + @Version + int version; + @Audited + @ElementCollection + Set stringSet = new HashSet<>(); + } + + @Audited + @Entity(name = "EmbeddedEntity") + static class EmbeddedEntity { + @Id + long id; + String name; + @Embedded + Address address; + } + + @Embeddable + static class Address { + String street; + String city; + + Address() { + } + + Address(String street, String city) { + this.street = street; + this.city = city; + } + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditExcludedPropertyTest.java b/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditExcludedPropertyTest.java new file mode 100644 index 000000000000..ef0df54fd8a2 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditExcludedPropertyTest.java @@ -0,0 +1,205 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.temporal.audit; + +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Embeddable; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; + +import org.hibernate.SharedSessionContract; +import org.hibernate.annotations.Audited; +import org.hibernate.audit.AuditLogFactory; +import org.hibernate.audit.ModificationType; +import org.hibernate.cfg.StateManagementSettings; +import org.hibernate.testing.orm.junit.AuditedTest; +import org.hibernate.testing.orm.junit.BeforeClassTemplate; +import org.hibernate.testing.orm.junit.DomainModel; +import org.hibernate.testing.orm.junit.DomainModelScope; +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.hibernate.temporal.spi.TransactionIdentifierSupplier; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests that {@link Audited.Excluded @Audited.Excluded} properties + * are correctly excluded from audit tables on both the write-side + * (column not in audit table) and read-side (entity loads without + * error, excluded property is null). + */ +@AuditedTest +@SessionFactory +@DomainModel(annotatedClasses = { + AuditExcludedPropertyTest.MyEntity.class +}) +@ServiceRegistry(settings = @Setting(name = StateManagementSettings.TRANSACTION_ID_SUPPLIER, + value = "org.hibernate.temporal.audit.AuditExcludedPropertyTest$TxIdSupplier")) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class AuditExcludedPropertyTest { + private static int currentTxId; + + public static class TxIdSupplier implements TransactionIdentifierSupplier { + @Override + public Integer generateTransactionIdentifier(SharedSessionContract session) { + return ++currentTxId; + } + } + + @Audited + @Entity(name = "MyEntity") + static class MyEntity { + @Id + long id; + String name; + + @Audited.Excluded + String secret; + + @Audited.Excluded + @Embedded + Address address; + + @Audited.Excluded + @ElementCollection + List tags = new ArrayList<>(); + } + + @Embeddable + static class Address { + String city; + String street; + } + + @BeforeClassTemplate + void initData(SessionFactoryScope scope) { + currentTxId = 0; + // Rev 1: create entity + scope.getSessionFactory().inTransaction( session -> { + final var entity = new MyEntity(); + entity.id = 1L; + entity.name = "visible"; + entity.secret = "hidden"; + final var addr = new Address(); + addr.city = "London"; + addr.street = "Baker St"; + entity.address = addr; + entity.tags.add( "java" ); + entity.tags.add( "hibernate" ); + session.persist( entity ); + } ); + // Rev 2: update entity + scope.getSessionFactory().inTransaction( session -> { + final var entity = session.find( MyEntity.class, 1L ); + entity.name = "updated"; + entity.secret = "changed-secret"; + entity.address.city = "Paris"; + } ); + } + + private static final int revCreate = 1; + private static final int revUpdate = 2; + + @Test + @Order(0) + void testNoCollectionAuditTableForExcludedCollection(DomainModelScope scope) { + for ( var table : scope.getDomainModel().collectTableMappings() ) { + assertFalse( table.getName().contains( "tags_AUD" ), + "Excluded @ElementCollection should not have an audit table" ); + } + } + + @Test + @Order(1) + void testAuditTableHasNoExcludedColumns(SessionFactoryScope scope) { + // Read audit data via AuditLog.find (uses AuditEntityLoader / LoaderSelectBuilder) + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var entity = auditLog.find( MyEntity.class, 1L, revCreate ); + assertNotNull( entity ); + assertEquals( "visible", entity.name ); + // Excluded properties should be null/empty when loaded from audit table + assertNull( entity.secret ); + assertNull( entity.address ); + assertNull( entity.tags ); + } + } + + @Test + @Order(2) + void testExcludedPropertiesNullAfterUpdate(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var entity = auditLog.find( MyEntity.class, 1L, revUpdate ); + assertNotNull( entity ); + assertEquals( "updated", entity.name ); + assertNull( entity.secret ); + assertNull( entity.address ); + assertNull( entity.tags ); + } + } + + @Test + @Order(3) + void testPointInTimeReadViaAtTransaction(SessionFactoryScope scope) { + try (var s = scope.getSessionFactory().withOptions() + .atTransaction( revCreate ).openSession()) { + final var entity = s.find( MyEntity.class, 1L ); + assertNotNull( entity ); + assertEquals( "visible", entity.name ); + assertNull( entity.secret ); + assertNull( entity.address ); + assertNull( entity.tags ); + } + } + + @Test + @Order(4) + void testGetHistoryWithExcludedProperties(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var history = auditLog.getHistory( MyEntity.class, 1L ); + assertEquals( 2, history.size() ); + assertEquals( ModificationType.ADD, history.get( 0 ).modificationType() ); + assertNull( history.get( 0 ).entity().secret ); + assertNull( history.get( 0 ).entity().address ); + assertNull( history.get( 0 ).entity().tags ); + assertEquals( ModificationType.MOD, history.get( 1 ).modificationType() ); + assertEquals( "updated", history.get( 1 ).entity().name ); + assertNull( history.get( 1 ).entity().secret ); + assertNull( history.get( 1 ).entity().tags ); + } + } + + @Test + @Order(5) + void testCurrentDataStillHasExcludedProperties(SessionFactoryScope scope) { + // Verify the current (non-audit) data still has the excluded properties + scope.inSession( session -> { + final var entity = session.find( MyEntity.class, 1L ); + assertNotNull( entity ); + assertEquals( "updated", entity.name ); + assertEquals( "changed-secret", entity.secret ); + assertNotNull( entity.address ); + assertEquals( "Paris", entity.address.city ); + assertEquals( 2, entity.tags.size() ); + assertTrue( entity.tags.contains( "java" ) ); + } ); + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditLogTest.java b/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditLogTest.java new file mode 100644 index 000000000000..cf29c238c2b7 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditLogTest.java @@ -0,0 +1,525 @@ +/* + * 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.AuditLog; +import org.hibernate.audit.AuditLogFactory; +import org.hibernate.audit.ModificationType; +import org.hibernate.cfg.StateManagementSettings; + +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.BeforeClassTemplate; +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.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; + +import org.hibernate.SharedSessionContract; +import org.hibernate.temporal.spi.TransactionIdentifierSupplier; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for the {@link AuditLog} service API. + */ +@AuditedTest +@SessionFactory +@DomainModel(annotatedClasses = { + AuditLogTest.AuditedEntity.class, + AuditLogTest.NonAuditedEntity.class +}) +@ServiceRegistry(settings = @Setting(name = StateManagementSettings.TRANSACTION_ID_SUPPLIER, + value = "org.hibernate.temporal.audit.AuditLogTest$TxIdSupplier")) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class AuditLogTest { + private static int currentTxId; + + public static class TxIdSupplier implements TransactionIdentifierSupplier { + @Override + public Integer generateTransactionIdentifier(SharedSessionContract session) { + return ++currentTxId; + } + + } + + @Audited + @Entity(name = "AuditedEntity") + static class AuditedEntity { + @Id + long id; + String name; + } + + @Entity(name = "NonAuditedEntity") + static class NonAuditedEntity { + @Id + long id; + String data; + } + + private int revCreate1; + private int revCreate2; + private int revUpdate; + private int revDelete; + + @BeforeClassTemplate + void setupData(SessionFactoryScope scope) { + currentTxId = 0; + + // Rev 1: create entity id=1 + scope.getSessionFactory().inTransaction( session -> { + final var e = new AuditedEntity(); + e.id = 1L; + e.name = "first"; + session.persist( e ); + } ); + revCreate1 = currentTxId; + + // Rev 2: create entity id=2 + update entity id=1 + scope.getSessionFactory().inTransaction( session -> { + final var e2 = new AuditedEntity(); + e2.id = 2L; + e2.name = "second"; + session.persist( e2 ); + + final var e1 = session.find( AuditedEntity.class, 1L ); + e1.name = "first-updated"; + } ); + revCreate2 = currentTxId; + + // Rev 3: update entity id=2 + scope.getSessionFactory().inTransaction( session -> { + final var e = session.find( AuditedEntity.class, 2L ); + e.name = "second-updated"; + } ); + revUpdate = currentTxId; + + // Rev 4: delete entity id=1 + scope.getSessionFactory().inTransaction( session -> { + final var e = session.find( AuditedEntity.class, 1L ); + session.remove( e ); + } ); + revDelete = currentTxId; + + // Also persist a non-audited entity + scope.getSessionFactory().inTransaction( session -> { + final var e = new NonAuditedEntity(); + e.id = 100L; + e.data = "not tracked"; + session.persist( e ); + } ); + } + + // --- isAudited --- + + @Test + @Order(1) + void testIsAudited(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + assertTrue( auditLog.isAudited( AuditedEntity.class ) ); + assertFalse( auditLog.isAudited( NonAuditedEntity.class ) ); + } + } + + // --- getRevisions --- + + @Test + @Order(2) + void testGetRevisionsForEntity1(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var revisions = auditLog.getRevisions( AuditedEntity.class, 1L ); + + // Entity 1 was: created (rev1), updated (rev2), deleted (rev4) + assertEquals( 3, revisions.size() ); + assertEquals( revCreate1, revisions.get( 0 ) ); + assertEquals( revCreate2, revisions.get( 1 ) ); + assertEquals( revDelete, revisions.get( 2 ) ); + } + } + + @Test + @Order(3) + void testGetRevisionsForEntity2(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var revisions = auditLog.getRevisions( AuditedEntity.class, 2L ); + + // Entity 2 was: created (rev2), updated (rev3) + assertEquals( 2, revisions.size() ); + assertEquals( revCreate2, revisions.get( 0 ) ); + assertEquals( revUpdate, revisions.get( 1 ) ); + } + } + + @Test + @Order(4) + void testGetRevisionsChronologicalOrder(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var revisions = auditLog.getRevisions( AuditedEntity.class, 1L ); + + // Should be in ascending order + for ( int i = 1; i < revisions.size(); i++ ) { + assertTrue( + ( (Comparable) revisions.get( i - 1 ) ).compareTo( revisions.get( i ) ) < 0, + "Revisions should be in ascending order" + ); + } + } + } + + @Test + @Order(5) + void testGetRevisionsForNonExistentEntity(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var revisions = auditLog.getRevisions( AuditedEntity.class, 999L ); + assertTrue( revisions.isEmpty() ); + } + } + + // --- getModificationType --- + + @Test + @Order(6) + void testGetModificationTypeAdd(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + assertEquals( + ModificationType.ADD, + auditLog.getModificationType( AuditedEntity.class, 1L, revCreate1 ) + ); + assertEquals( + ModificationType.ADD, + auditLog.getModificationType( AuditedEntity.class, 2L, revCreate2 ) + ); + } + } + + @Test + @Order(7) + void testGetModificationTypeMod(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // Entity 1 was also updated in rev2 + assertEquals( + ModificationType.MOD, + auditLog.getModificationType( AuditedEntity.class, 1L, revCreate2 ) + ); + assertEquals( + ModificationType.MOD, + auditLog.getModificationType( AuditedEntity.class, 2L, revUpdate ) + ); + } + } + + @Test + @Order(8) + void testGetModificationTypeDel(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + assertEquals( + ModificationType.DEL, + auditLog.getModificationType( AuditedEntity.class, 1L, revDelete ) + ); + } + } + + @Test + @Order(9) + void testGetModificationTypeReturnsNullForUnmodified(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // Entity 2 was not modified at rev1 + assertNull( auditLog.getModificationType( AuditedEntity.class, 2L, revCreate1 ) ); + } + } + + @Test + @Order(10) + void testGetModificationTypeReturnsNullForNonExistentEntity(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + assertNull( auditLog.getModificationType( AuditedEntity.class, 999L, revCreate1 ) ); + } + } + + // --- findEntitiesModifiedAt with ModificationType --- + + @Test + @Order(11) + void testFindEntitiesModifiedAtWithModificationType(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // Rev 1: entity 1 was ADD + final var adds = auditLog.findEntitiesModifiedAt( AuditedEntity.class, revCreate1, ModificationType.ADD ); + assertEquals( 1, adds.size() ); + assertEquals( "first", adds.get( 0 ).name ); + + // No MODs or DELs in rev 1 + assertTrue( auditLog.findEntitiesModifiedAt( AuditedEntity.class, revCreate1, ModificationType.MOD ).isEmpty() ); + assertTrue( auditLog.findEntitiesModifiedAt( AuditedEntity.class, revCreate1, ModificationType.DEL ).isEmpty() ); + } + } + + @Test + @Order(12) + void testFindEntitiesModifiedAtWithDelType(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var dels = auditLog.findEntitiesModifiedAt( AuditedEntity.class, revDelete, ModificationType.DEL ); + assertEquals( 1, dels.size() ); + } + } + + // --- findEntitiesGroupedByModificationType --- + + @Test + @Order(13) + void testFindEntitiesGroupedByModificationType(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // Rev 2: entity 1 was MOD, entity 2 was ADD + final var grouped = auditLog.findEntitiesGroupedByModificationType( AuditedEntity.class, revCreate2 ); + assertEquals( 1, grouped.get( ModificationType.ADD ).size() ); + assertEquals( "second", grouped.get( ModificationType.ADD ).get( 0 ).name ); + assertEquals( 1, grouped.get( ModificationType.MOD ).size() ); + assertEquals( "first-updated", grouped.get( ModificationType.MOD ).get( 0 ).name ); + assertTrue( grouped.get( ModificationType.DEL ).isEmpty() ); + } + } + + // --- empty revisions --- + + @Test + @Order(14) + void testNoEmptyRevisionsForNonAuditedChanges(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // The non-audited entity was persisted in a separate transaction + // but should NOT have created any audit rows + final var revisions1 = auditLog.getRevisions( AuditedEntity.class, 1L ); + final var revisions2 = auditLog.getRevisions( AuditedEntity.class, 2L ); + + // No revision should exist beyond revDelete for entity 1 + // or beyond revUpdate for entity 2 + for ( var rev : revisions1 ) { + assertTrue( ( (Number) rev ).intValue() <= revDelete ); + } + for ( var rev : revisions2 ) { + assertTrue( ( (Number) rev ).intValue() <= revUpdate ); + } + } + } + + // --- atTransaction + AuditLog combined --- + + @Test + @Order(15) + void testAuditLogWithAtTransactionReads(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var sf = scope.getSessionFactory(); + + // Use AuditLog to get revisions, then read entity state via atTransaction + final var revisions = auditLog.getRevisions( AuditedEntity.class, 1L ); + assertEquals( 3, revisions.size() ); + + // First revision: entity was created with name "first" + final int firstRev = ( (Number) revisions.get( 0 ) ).intValue(); + assertEquals( ModificationType.ADD, auditLog.getModificationType( AuditedEntity.class, 1L, firstRev ) ); + try (var s = sf.withOptions().atTransaction( firstRev ).open()) { + final var entity = s.find( AuditedEntity.class, 1L ); + assertNotNull( entity ); + assertEquals( "first", entity.name ); + } + + // Second revision: entity was updated to "first-updated" + final int secondRev = ( (Number) revisions.get( 1 ) ).intValue(); + assertEquals( ModificationType.MOD, auditLog.getModificationType( AuditedEntity.class, 1L, secondRev ) ); + try (var s = sf.withOptions().atTransaction( secondRev ).open()) { + final var entity = s.find( AuditedEntity.class, 1L ); + assertNotNull( entity ); + assertEquals( "first-updated", entity.name ); + } + + // Third revision: entity was deleted + final int thirdRev = ( (Number) revisions.get( 2 ) ).intValue(); + assertEquals( ModificationType.DEL, auditLog.getModificationType( AuditedEntity.class, 1L, thirdRev ) ); + try (var s = sf.withOptions().atTransaction( thirdRev ).open()) { + final var entity = s.find( AuditedEntity.class, 1L ); + assertNull( entity ); + } + } + } + + // --- convenience methods: find + findEntitiesModifiedAt --- + + @Test + @Order(16) + void testFindAtRevision(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // At revCreate1: entity 1 was just created + final var entity1 = auditLog.find( AuditedEntity.class, 1L, revCreate1 ); + assertNotNull( entity1 ); + assertEquals( "first", entity1.name ); + + // At revCreate2: entity 1 was updated + final var entity1Updated = auditLog.find( AuditedEntity.class, 1L, revCreate2 ); + assertNotNull( entity1Updated ); + assertEquals( "first-updated", entity1Updated.name ); + + // At revCreate2: entity 2 was created + final var entity2 = auditLog.find( AuditedEntity.class, 2L, revCreate2 ); + assertNotNull( entity2 ); + assertEquals( "second", entity2.name ); + + // At revDelete: entity 1 was deleted + final var deleted = auditLog.find( AuditedEntity.class, 1L, revDelete ); + assertNull( deleted ); + } + } + + @Test + @Order(16) + void testFindAtRevisionWhereEntityNotModified(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // Entity 2 was NOT modified at revDelete (only entity 1 was deleted), + // but it was last modified at revUpdate. find() should return the + // most recent snapshot at or before the given revision. + final var entity2 = auditLog.find( AuditedEntity.class, 2L, revDelete ); + assertNotNull( entity2 ); + assertEquals( "second-updated", entity2.name ); + + // Entity 1 was NOT modified at revUpdate (only entity 2 was updated), + // but it was last modified at revCreate2. + final var entity1 = auditLog.find( AuditedEntity.class, 1L, revUpdate ); + assertNotNull( entity1 ); + assertEquals( "first-updated", entity1.name ); + + // Entity 2 did not exist at revCreate1, should return null + final var notYet = auditLog.find( AuditedEntity.class, 2L, revCreate1 ); + assertNull( notYet ); + } + } + + @Test + @Order(17) + void testFindEntitiesModifiedAtSingleEntity(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // Rev 1: only entity 1 was created + final var modified = auditLog.findEntitiesModifiedAt( AuditedEntity.class, revCreate1 ); + assertEquals( 1, modified.size() ); + assertEquals( "first", modified.get( 0 ).name ); + } + } + + @Test + @Order(18) + void testFindEntitiesModifiedAtMultipleEntities(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // Rev 2: entity 2 created + entity 1 updated = 2 entities + final var modified = auditLog.findEntitiesModifiedAt( AuditedEntity.class, revCreate2 ); + assertEquals( 2, modified.size() ); + final var names = modified.stream().map( e -> e.name ).sorted().toList(); + assertEquals( List.of( "first-updated", "second" ), names ); + } + } + + @Test + @Order(19) + void testFindEntitiesModifiedAtIncludesDeleted(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // Rev 4: entity 1 was deleted, should be included in results + final var modified = auditLog.findEntitiesModifiedAt( AuditedEntity.class, revDelete ); + assertEquals( 1, modified.size() ); + } + } + + @Test + @Order(20) + void testFindEntitiesModifiedAtNoResults(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var modified = auditLog.findEntitiesModifiedAt( AuditedEntity.class, 999 ); + assertTrue( modified.isEmpty() ); + } + } + + // --- getHistory --- + + @Test + @Order(21) + void testGetHistoryIncludesAllModificationTypes(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // Entity 1: created (rev1), updated (rev2), deleted (rev4) + final var history = auditLog.getHistory( AuditedEntity.class, 1L ); + assertEquals( 3, history.size() ); + + assertEquals( ModificationType.ADD, history.get( 0 ).modificationType() ); + assertEquals( ModificationType.MOD, history.get( 1 ).modificationType() ); + assertEquals( ModificationType.DEL, history.get( 2 ).modificationType() ); + } + } + + @Test + @Order(22) + void testGetHistoryEntityStateAtEachRevision(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var history = auditLog.getHistory( AuditedEntity.class, 1L ); + + // ADD: entity was created with "first" + assertEquals( "first", history.get( 0 ).entity().name ); + + // MOD: entity was updated to "first-updated" + assertEquals( "first-updated", history.get( 1 ).entity().name ); + + // DEL: entity snapshot preserves the last state before deletion + assertNotNull( history.get( 2 ).entity() ); + assertEquals( "first-updated", history.get( 2 ).entity().name ); + } + } + + @Test + @Order(23) + void testGetHistoryRevisionValues(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var history = auditLog.getHistory( AuditedEntity.class, 1L ); + + // Without a RevisionEntitySupplier, revision should be the plain txId + assertEquals( revCreate1, history.get( 0 ).revision() ); + assertEquals( revCreate2, history.get( 1 ).revision() ); + assertEquals( revDelete, history.get( 2 ).revision() ); + } + } + + @Test + @Order(24) + void testGetHistoryForEntityWithNoDeletes(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // Entity 2: created (rev2), updated (rev3), no delete + final var history = auditLog.getHistory( AuditedEntity.class, 2L ); + assertEquals( 2, history.size() ); + + assertEquals( ModificationType.ADD, history.get( 0 ).modificationType() ); + assertEquals( "second", history.get( 0 ).entity().name ); + + assertEquals( ModificationType.MOD, history.get( 1 ).modificationType() ); + assertEquals( "second-updated", history.get( 1 ).entity().name ); + } + } + + @Test + @Order(25) + void testGetHistoryForNonExistentEntity(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var history = auditLog.getHistory( AuditedEntity.class, 999L ); + assertTrue( history.isEmpty() ); + } + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditRevisionEntityTest.java b/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditRevisionEntityTest.java new file mode 100644 index 000000000000..9fb19edd82eb --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditRevisionEntityTest.java @@ -0,0 +1,350 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.temporal.audit; + +import java.time.Instant; +import java.util.Set; + +import org.hibernate.annotations.Audited; +import org.hibernate.annotations.RevisionEntity; +import org.hibernate.audit.AuditException; +import org.hibernate.audit.AuditLogFactory; +import org.hibernate.audit.RevisionListener; + +import org.hibernate.testing.orm.junit.AuditedTest; +import org.hibernate.testing.orm.junit.DomainModel; +import org.hibernate.testing.orm.junit.SessionFactory; +import org.hibernate.testing.orm.junit.SessionFactoryScope; +import org.junit.jupiter.api.Test; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Test demonstrating {@link RevisionEntity @RevisionEntity} + * auto-detection with a custom revision entity and + * {@link RevisionListener}. + */ +@AuditedTest +@SessionFactory +@DomainModel(annotatedClasses = { + AuditRevisionEntityTest.MyEntity.class, + AuditRevisionEntityTest.RevisionInfo.class +}) +class AuditRevisionEntityTest { + + /** + * Custom revision entity with a {@link RevisionListener} + * that populates the {@code username} field. + */ + @RevisionEntity(listener = UsernameRevisionListener.class) + @Entity(name = "RevisionInfo") + @Table(name = "REVINFO") + static class RevisionInfo { + @Id + @GeneratedValue + @RevisionEntity.TransactionId + @Column(name = "REV") + int id; + + @RevisionEntity.Timestamp + @Column(name = "REVTSTMP") + Instant timestamp = Instant.now(); + + @Column(name = "USERNAME") + String username; + } + + public static class UsernameRevisionListener implements RevisionListener { + @Override + public void newRevision(Object revisionEntity) { + ( (RevisionInfo) revisionEntity ).username = "test-user"; + } + } + + @Audited + @Entity(name = "MyEntity") + static class MyEntity { + @Id + long id; + String name; + } + + @Test + void testRevisionEntitySupplier(SessionFactoryScope scope) { + // Create + scope.getSessionFactory().inTransaction( session -> { + final var entity = new MyEntity(); + entity.id = 1L; + entity.name = "original"; + session.persist( entity ); + } ); + + // Update + scope.getSessionFactory().inTransaction( session -> { + final var entity = session.find( MyEntity.class, 1L ); + entity.name = "updated"; + } ); + + // Capture baseline revision count before read-only operations + final long[] baseline = new long[1]; + scope.getSessionFactory().inTransaction( session -> + baseline[0] = session.createSelectionQuery( + "select count(*) from RevisionInfo", Long.class + ).getSingleResult() + ); + + // Read current entity via find(). No revision entity should be created + scope.getSessionFactory().inTransaction( session -> { + final var entity = session.find( MyEntity.class, 1L ); + assertNotNull( entity ); + assertEquals( "updated", entity.name ); + } ); + + // Read current entity via HQL. No revision entity should be created + scope.getSessionFactory().inTransaction( session -> { + final var entity = session.createSelectionQuery( + "from MyEntity where id = 1", MyEntity.class + ).getSingleResult(); + assertEquals( "updated", entity.name ); + } ); + + // Verify no extra REVINFO rows were created by the read-only transactions + scope.getSessionFactory().inTransaction( session -> { + final long revCount = session.createSelectionQuery( + "select count(*) from RevisionInfo", Long.class + ).getSingleResult(); + assertEquals( baseline[0], revCount, "Read-only queries must not create revision entities" ); + } ); + + // Delete + scope.getSessionFactory().inTransaction( session -> { + final var entity = session.find( MyEntity.class, 1L ); + session.remove( entity ); + } ); + + // Verify revision reads via atTransaction + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var revisions = auditLog.getRevisions( MyEntity.class, 1L ); + assertEquals( 3, revisions.size() ); + + final int rev1 = ( (Number) revisions.get( 0 ) ).intValue(); + final int rev2 = ( (Number) revisions.get( 1 ) ).intValue(); + final int rev3 = ( (Number) revisions.get( 2 ) ).intValue(); + + // Read at revision 1: entity was created + try (var s = scope.getSessionFactory().withOptions().atTransaction( rev1 ).open()) { + final var entity = s.find( MyEntity.class, 1L ); + assertNotNull( entity ); + assertEquals( "original", entity.name ); + } + + // Read at revision 2: entity was updated + try (var s = scope.getSessionFactory().withOptions().atTransaction( rev2 ).open()) { + final var entity = s.find( MyEntity.class, 1L ); + assertNotNull( entity ); + assertEquals( "updated", entity.name ); + } + + // Read at revision 3: entity was deleted + try (var s = scope.getSessionFactory().withOptions().atTransaction( rev3 ).open()) { + final var entity = s.find( MyEntity.class, 1L ); + assertNull( entity ); + } + } + } + + @Test + void testFindWithIncludeDeletions(SessionFactoryScope scope) { + scope.getSessionFactory().inTransaction( session -> { + var entity = new MyEntity(); + entity.id = 10L; + entity.name = "to-delete"; + session.persist( entity ); + } ); + scope.getSessionFactory().inTransaction( session -> + session.remove( session.find( MyEntity.class, 10L ) ) + ); + + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var revisions = auditLog.getRevisions( MyEntity.class, 10L ); + assertEquals( 2, revisions.size() ); + final var delRevision = revisions.get( 1 ); + + // Without includeDeletions: null + assertNull( auditLog.find( MyEntity.class, 10L, delRevision ) ); + + // With includeDeletions: entity state at deletion + final var deleted = auditLog.find( MyEntity.class, 10L, delRevision, true ); + assertNotNull( deleted ); + assertEquals( "to-delete", deleted.name ); + } + } + + @Test + void testFindByInstant(SessionFactoryScope scope) throws InterruptedException { + scope.getSessionFactory().inTransaction( session -> { + var entity = new MyEntity(); + entity.id = 20L; + entity.name = "instant-test"; + session.persist( entity ); + } ); + + Thread.sleep( 50 ); + + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var entity = auditLog.find( MyEntity.class, 20L, Instant.now() ); + assertNotNull( entity ); + assertEquals( "instant-test", entity.name ); + } + } + + @Test + void testGetTransactionTimestamp(SessionFactoryScope scope) { + scope.getSessionFactory().inTransaction( session -> { + var entity = new MyEntity(); + entity.id = 30L; + entity.name = "datetime-test"; + session.persist( entity ); + } ); + + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var revisions = auditLog.getRevisions( MyEntity.class, 30L ); + assertEquals( 1, revisions.size() ); + + final Instant timestamp = auditLog.getTransactionTimestamp( revisions.get( 0 ) ); + assertNotNull( timestamp ); + assertTrue( timestamp.isAfter( Instant.now().minusSeconds( 60 ) ) ); + } + } + + @Test + void testGetTransactionTimestampNonExistent(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + assertThrows( + AuditException.class, + () -> auditLog.getTransactionTimestamp( 999999 ) + ); + } + } + + @Test + void testGetTransactionIdForDate(SessionFactoryScope scope) throws InterruptedException { + scope.getSessionFactory().inTransaction( session -> { + var entity = new MyEntity(); + entity.id = 40L; + entity.name = "txid-test"; + session.persist( entity ); + } ); + + Thread.sleep( 50 ); + + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var txId = auditLog.getTransactionId( Instant.now() ); + assertNotNull( txId ); + + final var entity = auditLog.find( MyEntity.class, 40L, txId ); + assertNotNull( entity ); + assertEquals( "txid-test", entity.name ); + } + } + + @Test + void testGetTransactionIdForDateTooEarly(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + assertThrows( + AuditException.class, + () -> auditLog.getTransactionId( Instant.parse( "2000-01-01T00:00:00Z" ) ) + ); + } + } + + @Test + void testFindRevision(SessionFactoryScope scope) { + scope.getSessionFactory().inTransaction( session -> { + var entity = new MyEntity(); + entity.id = 50L; + entity.name = "findrev-test"; + session.persist( entity ); + } ); + + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var revisions = auditLog.getRevisions( MyEntity.class, 50L ); + final var txId = revisions.get( 0 ); + + RevisionInfo revInfo = auditLog.findRevision( txId ); + assertNotNull( revInfo ); + assertEquals( "test-user", revInfo.username ); + assertNotNull( revInfo.timestamp ); + } + } + + @Test + void testFindRevisionNonExistent(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + assertThrows( + AuditException.class, + () -> auditLog.findRevision( 999999 ) + ); + } + } + + @Test + void testFindRevisions(SessionFactoryScope scope) { + scope.getSessionFactory().inTransaction( session -> { + var entity = new MyEntity(); + entity.id = 60L; + entity.name = "findrevs-v1"; + session.persist( entity ); + } ); + scope.getSessionFactory().inTransaction( session -> + session.find( MyEntity.class, 60L ).name = "findrevs-v2" + ); + + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var revisions = auditLog.getRevisions( MyEntity.class, 60L ); + assertEquals( 2, revisions.size() ); + + final var revMap = auditLog.findRevisions( Set.copyOf( revisions ) ); + assertEquals( 2, revMap.size() ); + for ( var entry : revMap.values() ) { + assertEquals( "test-user", entry.username ); + } + } + } + + @Test + void testIsAudited(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + assertTrue( auditLog.isAudited( MyEntity.class ) ); + } + } + + @Test + void testAuditTableForeignKeys(SessionFactoryScope scope) { + // Verify REV -> REVINFO FK exists on the audit table + final var metadata = scope.getMetadataImplementor(); + final var auditTable = metadata.getEntityBinding( MyEntity.class.getName() ).getAuxiliaryTable(); + assertNotNull( auditTable, "Audit table should exist" ); + boolean foundRevFk = false; + for ( var fk : auditTable.getForeignKeyCollection() ) { + final var referencedTable = fk.getReferencedTable(); + if ( referencedTable != null && referencedTable.getName().equalsIgnoreCase( "REVINFO" ) ) { + foundRevFk = true; + assertEquals( 1, fk.getColumnSpan(), "REV FK should have exactly 1 column" ); + } + } + assertTrue( foundRevFk, "Expected FK from MyEntity_aud.REV -> REVINFO" ); + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditSecondaryTableTest.java b/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditSecondaryTableTest.java new file mode 100644 index 000000000000..5eff03c9d6a4 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditSecondaryTableTest.java @@ -0,0 +1,248 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.temporal.audit; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.SecondaryTable; +import org.hibernate.SharedSessionContract; +import org.hibernate.annotations.Audited; +import org.hibernate.audit.AuditLogFactory; +import org.hibernate.audit.ModificationType; +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.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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +@AuditedTest +@SessionFactory +@DomainModel(annotatedClasses = { + AuditSecondaryTableTest.Employee.class, + AuditSecondaryTableTest.Department.class +}) +@ServiceRegistry(settings = @Setting(name = StateManagementSettings.TRANSACTION_ID_SUPPLIER, + value = "org.hibernate.temporal.audit.AuditSecondaryTableTest$TxIdSupplier")) +class AuditSecondaryTableTest { + private static int currentTxId; + + public static class TxIdSupplier implements TransactionIdentifierSupplier { + @Override + public Integer generateTransactionIdentifier(SharedSessionContract session) { + return ++currentTxId; + } + } + + @Test + void testWriteAndPointInTimeRead(SessionFactoryScope scope) { + currentTxId = 0; + + scope.getSessionFactory().inTransaction( session -> { + session.persist( new Department( 1L, "Engineering" ) ); + var emp = new Employee(); + emp.id = 1L; + emp.name = "Alice"; + emp.address = "123 Main St"; + emp.phone = "555-0100"; + emp.department = session.getReference( Department.class, 1L ); + session.persist( emp ); + } ); + + scope.getSessionFactory().inTransaction( session -> { + var emp = session.find( Employee.class, 1L ); + emp.name = "Alice B."; + emp.address = "456 Oak Ave"; + emp.phone = "555-0200"; + } ); + + scope.getSessionFactory().inTransaction( session -> + session.remove( session.find( Employee.class, 1L ) ) + ); + + try (var s = scope.getSessionFactory().withStatelessOptions() + .atTransaction( 1 ).openStatelessSession()) { + var emp = s.get( Employee.class, 1L ); + assertEquals( "Alice", emp.name ); + assertEquals( "123 Main St", emp.address ); + assertEquals( "555-0100", emp.phone ); + assertNotNull( emp.department ); + assertEquals( "Engineering", emp.department.name ); + } + + try (var s = scope.getSessionFactory().withStatelessOptions() + .atTransaction( 2 ).openStatelessSession()) { + var emp = s.get( Employee.class, 1L ); + assertEquals( "Alice B.", emp.name ); + assertEquals( "456 Oak Ave", emp.address ); + assertEquals( "555-0200", emp.phone ); + } + + try (var s = scope.getSessionFactory().withStatelessOptions() + .atTransaction( 3 ).openStatelessSession()) { + assertNull( s.get( Employee.class, 1L ) ); + } + } + + @Test + void testHistory(SessionFactoryScope scope) { + currentTxId = 100; + + scope.getSessionFactory().inTransaction( session -> { + session.persist( new Department( 10L, "Engineering" ) ); + var emp = new Employee(); + emp.id = 10L; + emp.name = "Bob"; + emp.address = "100 First Ave"; + emp.department = session.getReference( Department.class, 10L ); + session.persist( emp ); + } ); + + scope.getSessionFactory().inTransaction( session -> { + var emp = session.find( Employee.class, 10L ); + emp.address = "200 Second Ave"; + } ); + + scope.getSessionFactory().inTransaction( session -> + session.remove( session.find( Employee.class, 10L ) ) + ); + + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + var history = auditLog.getHistory( Employee.class, 10L ); + assertEquals( 3, history.size() ); + + assertEquals( ModificationType.ADD, history.get( 0 ).modificationType() ); + assertEquals( "Bob", history.get( 0 ).entity().name ); + assertEquals( "100 First Ave", history.get( 0 ).entity().address ); + + assertEquals( ModificationType.MOD, history.get( 1 ).modificationType() ); + assertEquals( "200 Second Ave", history.get( 1 ).entity().address ); + + assertEquals( ModificationType.DEL, history.get( 2 ).modificationType() ); + } + } + + @Test + void testAssociationOnSecondaryTable(SessionFactoryScope scope) { + currentTxId = 200; + + scope.getSessionFactory().inTransaction( session -> { + session.persist( new Department( 20L, "Engineering" ) ); + session.persist( new Department( 21L, "Marketing" ) ); + var emp = new Employee(); + emp.id = 20L; + emp.name = "Carol"; + emp.department = session.getReference( Department.class, 20L ); + session.persist( emp ); + } ); + + scope.getSessionFactory().inTransaction( session -> { + var emp = session.find( Employee.class, 20L ); + emp.department = session.getReference( Department.class, 21L ); + } ); + + scope.getSessionFactory().inTransaction( session -> { + var emp = session.find( Employee.class, 20L ); + emp.department = null; + } ); + + try (var s = scope.getSessionFactory().withStatelessOptions() + .atTransaction( 201 ).openStatelessSession()) { + assertEquals( "Engineering", s.get( Employee.class, 20L ).department.name ); + } + + try (var s = scope.getSessionFactory().withStatelessOptions() + .atTransaction( 202 ).openStatelessSession()) { + assertEquals( "Marketing", s.get( Employee.class, 20L ).department.name ); + } + + try (var s = scope.getSessionFactory().withStatelessOptions() + .atTransaction( 203 ).openStatelessSession()) { + assertNull( s.get( Employee.class, 20L ).department ); + } + + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + var history = auditLog.getHistory( Employee.class, 20L ); + assertEquals( 3, history.size() ); + + assertEquals( "Engineering", + history.get( 0 ).entity().department.name ); + assertEquals( "Marketing", + history.get( 1 ).entity().department.name ); + assertNull( history.get( 2 ).entity().department ); + } + } + + @Test + void testGetRevisions(SessionFactoryScope scope) { + currentTxId = 300; + + scope.getSessionFactory().inTransaction( session -> { + var emp = new Employee(); + emp.id = 30L; + emp.name = "Dave"; + session.persist( emp ); + } ); + + scope.getSessionFactory().inTransaction( session -> + session.find( Employee.class, 30L ).address = "123 Main St" + ); + + scope.getSessionFactory().inTransaction( session -> + session.find( Employee.class, 30L ).phone = "555-0100" + ); + + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + var revisions = auditLog.getRevisions( Employee.class, 30L ); + assertEquals( 3, revisions.size() ); + assertEquals( 301, revisions.get( 0 ) ); + assertEquals( 302, revisions.get( 1 ) ); + assertEquals( 303, revisions.get( 2 ) ); + } + } + + @Entity(name = "Employee") + @Audited + @SecondaryTable(name = "employee_contact") + @SecondaryTable(name = "employee_org") + static class Employee { + @Id + Long id; + String name; + @Column(table = "employee_contact") + String address; + @Column(table = "employee_contact") + String phone; + @ManyToOne + @JoinColumn(table = "employee_org") + Department department; + } + + @Entity(name = "Department") + @Audited + static class Department { + @Id + Long id; + String name; + + Department() { + } + + Department(Long id, String name) { + this.id = id; + this.name = name; + } + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditTargetNotAuditedTest.java b/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditTargetNotAuditedTest.java new file mode 100644 index 000000000000..0e08484d27a8 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditTargetNotAuditedTest.java @@ -0,0 +1,438 @@ +/* + * 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 jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import org.hibernate.annotations.Audited; +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.AuditedTest; +import org.hibernate.testing.orm.junit.BeforeClassTemplate; +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.AfterAll; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Tests @Audited entities with associations to non-audited entities. + *

+ * Unlike the old envers module which required explicit {@code @Audited(targetAuditMode = NOT_AUDITED)}, + * core auditing handles this implicitly: the FK is stored in the audit table, + * and the non-audited target is loaded from the current table. + */ +@AuditedTest +@SessionFactory +@DomainModel(annotatedClasses = { + AuditTargetNotAuditedTest.Product.class, + AuditTargetNotAuditedTest.Category.class, + AuditTargetNotAuditedTest.Tag.class, + AuditTargetNotAuditedTest.Store.class, + AuditTargetNotAuditedTest.Item.class +}) +@ServiceRegistry(settings = @Setting(name = StateManagementSettings.TRANSACTION_ID_SUPPLIER, + value = "org.hibernate.temporal.audit.AuditTargetNotAuditedTest$TxIdSupplier")) +class AuditTargetNotAuditedTest { + private static int currentTxId; + + public static class TxIdSupplier implements TransactionIdentifierSupplier { + @Override + public Integer generateTransactionIdentifier(SharedSessionContract session) { + return ++currentTxId; + } + } + + @BeforeClassTemplate + void setupTestData(SessionFactoryScope scope) { + currentTxId = 0; + + // REV 1: create category and product with @ManyToOne association + scope.getSessionFactory().inTransaction( session -> { + var cat = new Category( 1L, "Electronics" ); + session.persist( cat ); + session.persist( new Product( 1L, "Phone", cat ) ); + } ); + + // REV 2: change product's category + scope.getSessionFactory().inTransaction( session -> { + var cat2 = new Category( 2L, "Gadgets" ); + session.persist( cat2 ); + session.find( Product.class, 1L ).category = cat2; + } ); + + // REV 3: update the non-audited category name directly + // (this does NOT create a product audit row) + scope.getSessionFactory().inTransaction( session -> + session.find( Category.class, 2L ).name = "Updated Gadgets" + ); + + // REV 4: clear the association + scope.getSessionFactory().inTransaction( session -> + session.find( Product.class, 1L ).category = null + ); + } + + @AfterAll + void cleanup(SessionFactoryScope scope) { + scope.getSessionFactory().getSchemaManager().truncateMappedObjects(); + } + + // ---- @ManyToOne to non-audited ---- + + @Test + void testManyToOneWriteSide(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + assertEquals( 3, auditLog.getRevisions( Product.class, 1L ).size() ); + } + } + + @Test + void testManyToOnePointInTimeRead(SessionFactoryScope scope) { + // REV 1: product with Electronics category (FK=1) + try (var s = scope.getSessionFactory().withOptions() + .atTransaction( 1 ).openSession()) { + var product = s.find( Product.class, 1L ); + assertNotNull( product ); + assertEquals( "Phone", product.name ); + assertNotNull( product.category ); + // FK from audit row is 1 -> loads Category#1 + assertEquals( 1L, product.category.id ); + assertEquals( "Electronics", product.category.name ); + } + + // REV 2: product switched to Gadgets (FK=2) + try (var s = scope.getSessionFactory().withOptions() + .atTransaction( 2 ).openSession()) { + var product = s.find( Product.class, 1L ); + assertNotNull( product ); + assertNotNull( product.category ); + // FK from audit row is 2 -> loads Category#2 + assertEquals( 2L, product.category.id ); + // Non-audited target loads from current table, + // so name reflects the rev 3 update + assertEquals( "Updated Gadgets", product.category.name ); + } + + // REV 4: category cleared (FK=null) + try (var s = scope.getSessionFactory().withOptions() + .atTransaction( 4 ).openSession()) { + var product = s.find( Product.class, 1L ); + assertNotNull( product ); + assertNull( product.category ); + } + } + + @Test + void testManyToOneDeletedTarget(SessionFactoryScope scope) { + currentTxId = 400; + + scope.getSessionFactory().inTransaction( session -> { + var cat = new Category( 99L, "Ephemeral" ); + session.persist( cat ); + session.persist( new Product( 99L, "Doomed", cat ) ); + } ); + + // Switch to a different category so the FK is no longer 99 + scope.getSessionFactory().inTransaction( session -> { + var cat2 = new Category( 98L, "Replacement" ); + session.persist( cat2 ); + session.find( Product.class, 99L ).category = cat2; + } ); + + // Delete the original non-audited category + scope.getSessionFactory().inTransaction( session -> + session.remove( session.find( Category.class, 99L ) ) + ); + + // REV 1 pointed to Category#99 which no longer exists. + // Loading at rev 1: the audit row has FK=99, but Category#99 + // has been deleted from the current table. Since @ManyToOne is + // eagerly fetched via JOIN, the LEFT JOIN yields null columns + // and the association resolves to null. + try (var s = scope.getSessionFactory().withOptions() + .atTransaction( 401 ).openSession()) { + var product = s.find( Product.class, 99L ); + assertNotNull( product ); + assertEquals( "Doomed", product.name ); + assertNull( product.category, + "Dangling FK to deleted non-audited entity should resolve to null" ); + } + + // REV 2 pointed to Category#98 which still exists + try (var s = scope.getSessionFactory().withOptions() + .atTransaction( 402 ).openSession()) { + var product = s.find( Product.class, 99L ); + assertNotNull( product ); + assertNotNull( product.category ); + assertEquals( 98L, product.category.id ); + } + } + + @Test + void testManyToOneGetHistory(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + var history = auditLog.getHistory( Product.class, 1L ); + assertEquals( 3, history.size() ); + + assertEquals( ModificationType.ADD, history.get( 0 ).modificationType() ); + assertNotNull( history.get( 0 ).entity().category ); + + assertEquals( ModificationType.MOD, history.get( 1 ).modificationType() ); + assertNotNull( history.get( 1 ).entity().category ); + + assertEquals( ModificationType.MOD, history.get( 2 ).modificationType() ); + assertNull( history.get( 2 ).entity().category ); + } + } + + @Test + void testManyToOneJoinFetchAllRevisions(SessionFactoryScope scope) { + try (var session = scope.getSessionFactory().withOptions() + .atTransaction( AuditLog.ALL_REVISIONS ).openSession()) { + final var rows = session.createSelectionQuery( + "select e, transactionId(e), modificationType(e)" + + " from Product e left join fetch e.category" + + " where e.id = :id" + + " order by transactionId(e)", + Object[].class + ).setParameter( "id", 1L ).getResultList(); + + assertEquals( 3, rows.size() ); + assertNotNull( ((Product) rows.get( 0 )[0]).category ); + assertNotNull( ((Product) rows.get( 1 )[0]).category ); + assertNull( ((Product) rows.get( 2 )[0]).category ); + } + } + + // ---- @OneToOne to non-audited ---- + + @Test + void testOneToOneToNonAudited(SessionFactoryScope scope) { + currentTxId = 100; + + scope.getSessionFactory().inTransaction( session -> { + var store = new Store( 1L, "Main Store" ); + session.persist( store ); + var item = new Item( 1L, "Widget" ); + item.store = store; + session.persist( item ); + } ); + + scope.getSessionFactory().inTransaction( session -> { + var store2 = new Store( 2L, "Branch" ); + session.persist( store2 ); + session.find( Item.class, 1L ).store = store2; + } ); + + // Point-in-time: non-audited store loads from current table + try (var s = scope.getSessionFactory().withOptions() + .atTransaction( 101 ).openSession()) { + var item = s.find( Item.class, 1L ); + assertNotNull( item.store ); + assertEquals( "Main Store", item.store.name ); + } + + try (var s = scope.getSessionFactory().withOptions() + .atTransaction( 102 ).openSession()) { + var item = s.find( Item.class, 1L ); + assertNotNull( item.store ); + assertEquals( "Branch", item.store.name ); + } + + // History + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + var history = auditLog.getHistory( Item.class, 1L ); + assertEquals( 2, history.size() ); + assertNotNull( history.get( 0 ).entity().store ); + assertNotNull( history.get( 1 ).entity().store ); + } + } + + // ---- @ManyToMany to non-audited ---- + + @Test + void testManyToManyToNonAudited(SessionFactoryScope scope) { + currentTxId = 200; + + scope.getSessionFactory().inTransaction( session -> { + var tag1 = new Tag( 1L, "Sale" ); + var tag2 = new Tag( 2L, "New" ); + session.persist( tag1 ); + session.persist( tag2 ); + var product = new Product( 10L, "Laptop", null ); + product.tags.add( tag1 ); + session.persist( product ); + } ); + + scope.getSessionFactory().inTransaction( session -> { + var product = session.find( Product.class, 10L ); + product.tags.add( session.find( Tag.class, 2L ) ); + } ); + + scope.getSessionFactory().inTransaction( session -> { + var product = session.find( Product.class, 10L ); + product.tags.removeIf( t -> t.id == 1L ); + } ); + + // Verify audit rows for the entity + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + assertEquals( 3, auditLog.getRevisions( Product.class, 10L ).size() ); + } + + // Point-in-time: tags should be loaded from current table + try (var s = scope.getSessionFactory().withOptions() + .atTransaction( 202 ).openSession()) { + var product = s.find( Product.class, 10L ); + assertNotNull( product ); + assertEquals( "Laptop", product.name ); + } + } + + // ---- @OneToMany to non-audited ---- + + @Test + void testOneToManyToNonAudited(SessionFactoryScope scope) { + currentTxId = 300; + + scope.getSessionFactory().inTransaction( session -> { + var cat = new Category( 10L, "Furniture" ); + session.persist( cat ); + var product = new Product( 20L, "Table", cat ); + product.relatedCategories.add( cat ); + session.persist( product ); + } ); + + scope.getSessionFactory().inTransaction( session -> { + var cat2 = new Category( 11L, "Home" ); + session.persist( cat2 ); + session.find( Product.class, 20L ).relatedCategories.add( cat2 ); + } ); + + // Verify audit rows for the entity + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + assertEquals( 2, auditLog.getRevisions( Product.class, 20L ).size() ); + } + + // Point-in-time + try (var s = scope.getSessionFactory().withOptions() + .atTransaction( 301 ).openSession()) { + var product = s.find( Product.class, 20L ); + assertNotNull( product ); + assertEquals( "Table", product.name ); + } + } + + // ---- Entities ---- + + @Audited + @Entity(name = "Product") + static class Product { + @Id + long id; + String name; + @ManyToOne + Category category; + @ManyToMany + @JoinTable(name = "product_tags") + Set tags = new HashSet<>(); + @OneToMany + @JoinColumn(name = "product_id") + List relatedCategories = new ArrayList<>(); + + Product() { + } + + Product(long id, String name, Category category) { + this.id = id; + this.name = name; + this.category = category; + } + } + + @Entity(name = "Category") + static class Category { + @Id + long id; + String name; + + Category() { + } + + Category(long id, String name) { + this.id = id; + this.name = name; + } + } + + @Entity(name = "Tag") + static class Tag { + @Id + long id; + String name; + + Tag() { + } + + Tag(long id, String name) { + this.id = id; + this.name = name; + } + } + + @Audited + @Entity(name = "Item") + static class Item { + @Id + long id; + String name; + @OneToOne + Store store; + + Item() { + } + + Item(long id, String name) { + this.id = id; + this.name = name; + } + } + + @Entity(name = "Store") + static class Store { + @Id + long id; + String name; + + Store() { + } + + Store(long id, String name) { + this.id = id; + this.name = name; + } + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditToOneAssociationTest.java b/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditToOneAssociationTest.java new file mode 100644 index 000000000000..843abdc0b8e1 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/temporal/audit/AuditToOneAssociationTest.java @@ -0,0 +1,613 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.temporal.audit; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; +import org.hibernate.SharedSessionContract; +import org.hibernate.annotations.Audited; +import org.hibernate.audit.AuditLog; +import org.hibernate.audit.AuditLogFactory; +import org.hibernate.audit.ModificationType; +import org.hibernate.cfg.StateManagementSettings; +import org.hibernate.temporal.spi.TransactionIdentifierSupplier; +import org.hibernate.testing.orm.junit.AuditedTest; +import org.hibernate.testing.orm.junit.BeforeClassTemplate; +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.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Tests @Audited with @ManyToOne and @OneToOne associations. + */ +@AuditedTest +@SessionFactory +@DomainModel(annotatedClasses = { + AuditToOneAssociationTest.Publisher.class, + AuditToOneAssociationTest.Author.class, + AuditToOneAssociationTest.Book.class, + AuditToOneAssociationTest.LazyBook.class, + AuditToOneAssociationTest.Person.class, + AuditToOneAssociationTest.Passport.class +}) +@ServiceRegistry(settings = @Setting(name = StateManagementSettings.TRANSACTION_ID_SUPPLIER, + value = "org.hibernate.temporal.audit.AuditToOneAssociationTest$TxIdSupplier")) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class AuditToOneAssociationTest { + private static int currentTxId; + + public static class TxIdSupplier implements TransactionIdentifierSupplier { + @Override + public Integer generateTransactionIdentifier(SharedSessionContract session) { + return ++currentTxId; + } + } + + // @ManyToOne revision markers + private int revCreate; // Book(1) + Author(1) + Publisher(1) + LazyBook(2) created + private int revUpdate; // all updated to V2 + private int revReassign; // Book(1).author reassigned to Author(2) + private int revNull; // Book(1).author set to null + + // Null association lifecycle markers + private int revNullCreate; // Book(10) created with null author + private int revNullSet; // Book(10).author set to Author(10) + private int revNullClear; // Book(10).author cleared + + // @OneToOne markers + private int revO2oCreate; // Person(1) + Passport(1) + private int revO2oReassign; // Person(1).passport reassigned to Passport(2) + private int revO2oNull; // Person(1).passport set to null + + @BeforeClassTemplate + void initData(SessionFactoryScope scope) { + currentTxId = 0; + final var sf = scope.getSessionFactory(); + + // Rev 1: create Publisher + Author + Book + LazyBook + sf.inTransaction( session -> { + var pub = new Publisher( 1L, "Pub V1" ); + session.persist( pub ); + var author = new Author( 1L, "Author V1", pub ); + session.persist( author ); + session.persist( new Book( 1L, "Book V1", author ) ); + session.persist( new LazyBook( 2L, "Lazy Book V1", author ) ); + } ); + revCreate = currentTxId; + + // Rev 2: update all names + sf.inTransaction( session -> { + session.find( Publisher.class, 1L ).setName( "Pub V2" ); + session.find( Author.class, 1L ).setName( "Author V2" ); + session.find( Book.class, 1L ).setTitle( "Book V2" ); + } ); + revUpdate = currentTxId; + + // Rev 3: reassign Book.author to new Author + sf.inTransaction( session -> { + var author2 = new Author( 2L, "Author B" ); + session.persist( author2 ); + session.find( Book.class, 1L ).setAuthor( author2 ); + } ); + revReassign = currentTxId; + + // Rev 4: null out Book.author + sf.inTransaction( session -> + session.find( Book.class, 1L ).setAuthor( null ) + ); + revNull = currentTxId; + + // --- Null association lifecycle --- + + // Rev 5: create Book with null author + sf.inTransaction( session -> + session.persist( new Book( 10L, "Orphan Book", null ) ) + ); + revNullCreate = currentTxId; + + // Rev 6: set author + sf.inTransaction( session -> { + var author = new Author( 10L, "Late Author" ); + session.persist( author ); + session.find( Book.class, 10L ).setAuthor( author ); + } ); + revNullSet = currentTxId; + + // Rev 7: clear author + sf.inTransaction( session -> + session.find( Book.class, 10L ).setAuthor( null ) + ); + revNullClear = currentTxId; + + // --- @OneToOne --- + + // Rev 8: create Person + Passport + sf.inTransaction( session -> { + var passport = new Passport( 1L, "AB123" ); + session.persist( passport ); + var person = new Person( 1L, "Alice" ); + person.passport = passport; + session.persist( person ); + } ); + revO2oCreate = currentTxId; + + // Rev 9: reassign passport + sf.inTransaction( session -> { + var passport2 = new Passport( 2L, "CD456" ); + session.persist( passport2 ); + session.find( Person.class, 1L ).passport = passport2; + } ); + revO2oReassign = currentTxId; + + // Rev 10: null passport + sf.inTransaction( session -> + session.find( Person.class, 1L ).passport = null + ); + revO2oNull = currentTxId; + } + + // --- Write side --- + + @Test + @Order(1) + void testWriteSideRevisionCounts(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // Book(1): create, update, reassign, null = 4 revisions + assertEquals( 4, auditLog.getRevisions( Book.class, 1L ).size() ); + // Person(1): create, reassign, null = 3 revisions + assertEquals( 3, auditLog.getRevisions( Person.class, 1L ).size() ); + } + } + + // --- @ManyToOne point-in-time reads --- + + @Test + @Order(2) + void testPointInTimeRead(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + // Rev 1: Book->Author->Publisher at creation + try (var s = sf.withStatelessOptions().atTransaction( revCreate ).openStatelessSession()) { + var book = s.get( Book.class, 1L ); + assertEquals( "Book V1", book.getTitle() ); + assertEquals( "Author V1", book.getAuthor().getName() ); + assertEquals( "Pub V1", book.getAuthor().getPublisher().getName() ); + } + + // Rev 2: all updated + try (var s = sf.withStatelessOptions().atTransaction( revUpdate ).openStatelessSession()) { + var book = s.get( Book.class, 1L ); + assertEquals( "Book V2", book.getTitle() ); + assertEquals( "Author V2", book.getAuthor().getName() ); + assertEquals( "Pub V2", book.getAuthor().getPublisher().getName() ); + } + + // Rev 3: FK reassigned + try (var s = sf.withStatelessOptions().atTransaction( revReassign ).openStatelessSession()) { + var book = s.get( Book.class, 1L ); + assertEquals( 2L, book.getAuthor().id ); + assertEquals( "Author B", book.getAuthor().getName() ); + } + + // Rev 4: FK null + try (var s = sf.withStatelessOptions().atTransaction( revNull ).openStatelessSession()) { + assertNull( s.get( Book.class, 1L ).getAuthor() ); + } + } + + @Test + @Order(3) + void testLazyPointInTimeRead(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + try (var s = sf.withOptions().atTransaction( revCreate ).openSession()) { + var author = s.find( LazyBook.class, 2L ).getAuthor(); + assertEquals( "Author V1", author.getName() ); + assertEquals( "Pub V1", author.getPublisher().getName() ); + } + + try (var s = sf.withOptions().atTransaction( revUpdate ).openSession()) { + var author = s.find( LazyBook.class, 2L ).getAuthor(); + assertEquals( "Author V2", author.getName() ); + assertEquals( "Pub V2", author.getPublisher().getName() ); + } + } + + // --- getHistory --- + + @Test + @Order(4) + void testGetHistory(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + var history = auditLog.getHistory( Book.class, 1L ); + assertEquals( 4, history.size() ); + + // Rev 1: created with Author V1 -> Publisher V1 + assertEquals( ModificationType.ADD, history.get( 0 ).modificationType() ); + assertEquals( "Book V1", history.get( 0 ).entity().getTitle() ); + assertEquals( "Author V1", history.get( 0 ).entity().getAuthor().getName() ); + assertEquals( "Pub V1", history.get( 0 ).entity().getAuthor().getPublisher().getName() ); + + // Rev 2: updated + assertEquals( ModificationType.MOD, history.get( 1 ).modificationType() ); + assertEquals( "Book V2", history.get( 1 ).entity().getTitle() ); + assertEquals( "Author V2", history.get( 1 ).entity().getAuthor().getName() ); + + // Rev 3: FK reassigned + assertEquals( ModificationType.MOD, history.get( 2 ).modificationType() ); + assertEquals( "Author B", history.get( 2 ).entity().getAuthor().getName() ); + + // Rev 4: FK null + assertEquals( ModificationType.MOD, history.get( 3 ).modificationType() ); + assertNull( history.get( 3 ).entity().getAuthor() ); + } + } + + // --- HQL join fetch --- + + @Test + @Order(5) + void testJoinFetchAllRevisions(SessionFactoryScope scope) { + try (var session = scope.getSessionFactory().withOptions() + .atTransaction( AuditLog.ALL_REVISIONS ).openSession()) { + // Single-level join fetch: inner join filters out revNull (null author) + final var rows = session.createSelectionQuery( + "select e, transactionId(e), modificationType(e)" + + " from Book e join fetch e.author" + + " where e.id = :id" + + " order by transactionId(e)", + Object[].class + ).setParameter( "id", 1L ).getResultList(); + + assertEquals( 3, rows.size() ); + assertEquals( "Author V1", ((Book) rows.get( 0 )[0]).getAuthor().getName() ); + assertEquals( "Author V2", ((Book) rows.get( 1 )[0]).getAuthor().getName() ); + assertEquals( "Author B", ((Book) rows.get( 2 )[0]).getAuthor().getName() ); + + // Nested join fetch: only revisions where full chain exists (create + update) + final var nestedRows = session.createSelectionQuery( + "select e, transactionId(e)" + + " from Book e" + + " join fetch e.author a" + + " join fetch a.publisher" + + " where e.id = :id" + + " order by transactionId(e)", + Object[].class + ).setParameter( "id", 1L ).getResultList(); + + assertEquals( 2, nestedRows.size() ); + assertEquals( "Pub V1", ((Book) nestedRows.get( 0 )[0]).getAuthor().getPublisher().getName() ); + assertEquals( "Pub V2", ((Book) nestedRows.get( 1 )[0]).getAuthor().getPublisher().getName() ); + } + } + + @Test + @Order(6) + void testJoinFetchPointInTime(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + try (var session = sf.withOptions().atTransaction( revCreate ).openSession()) { + final var book = session.createSelectionQuery( + "from Book e join fetch e.author where e.id = :id", + Book.class + ).setParameter( "id", 1L ).getSingleResult(); + + assertEquals( "Book V1", book.getTitle() ); + assertEquals( "Author V1", book.getAuthor().getName() ); + } + + try (var session = sf.withOptions().atTransaction( revUpdate ).openSession()) { + final var book = session.createSelectionQuery( + "from Book e join fetch e.author where e.id = :id", + Book.class + ).setParameter( "id", 1L ).getSingleResult(); + + assertEquals( "Book V2", book.getTitle() ); + assertEquals( "Author V2", book.getAuthor().getName() ); + } + } + + @Test + @Order(7) + void testExplicitEntityJoinPointInTime(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + // Explicit entity join (not fetch join), hits consumeEntityJoin path + try (var session = sf.withOptions().atTransaction( revCreate ).openSession()) { + final var rows = session.createSelectionQuery( + "select b, a from Book b join Author a on a.id = b.author.id" + + " where b.id = :id", + Object[].class + ).setParameter( "id", 1L ).getResultList(); + + assertEquals( 1, rows.size() ); + assertEquals( "Book V1", ((Book) rows.get( 0 )[0]).getTitle() ); + assertEquals( "Author V1", ((Author) rows.get( 0 )[1]).getName() ); + } + + try (var session = sf.withOptions().atTransaction( revUpdate ).openSession()) { + final var rows = session.createSelectionQuery( + "select b, a from Book b join Author a on a.id = b.author.id" + + " where b.id = :id", + Object[].class + ).setParameter( "id", 1L ).getResultList(); + + assertEquals( 1, rows.size() ); + assertEquals( "Book V2", ((Book) rows.get( 0 )[0]).getTitle() ); + assertEquals( "Author V2", ((Author) rows.get( 0 )[1]).getName() ); + } + } + + // --- Null association lifecycle --- + + @Test + @Order(8) + void testNullAssociationPointInTimeRead(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + try (var s = sf.withStatelessOptions().atTransaction( revNullCreate ).openStatelessSession()) { + assertNull( s.get( Book.class, 10L ).getAuthor() ); + } + + try (var s = sf.withStatelessOptions().atTransaction( revNullSet ).openStatelessSession()) { + assertEquals( "Late Author", s.get( Book.class, 10L ).getAuthor().getName() ); + } + + try (var s = sf.withStatelessOptions().atTransaction( revNullClear ).openStatelessSession()) { + assertNull( s.get( Book.class, 10L ).getAuthor() ); + } + } + + @Test + @Order(9) + void testNullAssociationGetHistory(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + var history = auditLog.getHistory( Book.class, 10L ); + assertEquals( 3, history.size() ); + + assertEquals( ModificationType.ADD, history.get( 0 ).modificationType() ); + assertNull( history.get( 0 ).entity().getAuthor() ); + + assertEquals( ModificationType.MOD, history.get( 1 ).modificationType() ); + assertEquals( "Late Author", history.get( 1 ).entity().getAuthor().getName() ); + + assertEquals( ModificationType.MOD, history.get( 2 ).modificationType() ); + assertNull( history.get( 2 ).entity().getAuthor() ); + } + } + + @Test + @Order(10) + void testLeftJoinFetchNullAssociationAllRevisions(SessionFactoryScope scope) { + try (var session = scope.getSessionFactory().withOptions() + .atTransaction( AuditLog.ALL_REVISIONS ).openSession()) { + final var rows = session.createSelectionQuery( + "select e, transactionId(e), modificationType(e)" + + " from Book e left join fetch e.author" + + " where e.id = :id" + + " order by transactionId(e)", + Object[].class + ).setParameter( "id", 10L ).getResultList(); + + assertEquals( 3, rows.size() ); + assertNull( ((Book) rows.get( 0 )[0]).getAuthor() ); + assertEquals( "Late Author", ((Book) rows.get( 1 )[0]).getAuthor().getName() ); + assertNull( ((Book) rows.get( 2 )[0]).getAuthor() ); + } + } + + // --- @OneToOne --- + + @Test + @Order(11) + void testOneToOnePointInTimeRead(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + try (var s = sf.withStatelessOptions().atTransaction( revO2oCreate ).openStatelessSession()) { + assertEquals( "AB123", s.get( Person.class, 1L ).passport.number ); + } + + try (var s = sf.withStatelessOptions().atTransaction( revO2oReassign ).openStatelessSession()) { + assertEquals( "CD456", s.get( Person.class, 1L ).passport.number ); + } + + try (var s = sf.withStatelessOptions().atTransaction( revO2oNull ).openStatelessSession()) { + assertNull( s.get( Person.class, 1L ).passport ); + } + } + + @Test + @Order(12) + void testOneToOneGetHistory(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + var history = auditLog.getHistory( Person.class, 1L ); + assertEquals( 3, history.size() ); + assertEquals( "AB123", history.get( 0 ).entity().passport.number ); + assertEquals( "CD456", history.get( 1 ).entity().passport.number ); + assertNull( history.get( 2 ).entity().passport ); + } + } + + // ---- Entity classes ---- + + @Audited + @Entity(name = "Publisher") + static class Publisher { + @Id + long id; + @Column(name = "name_col") + String name; + + Publisher() { + } + + Publisher(long id, String name) { + this.id = id; + this.name = name; + } + + String getName() { + return name; + } + + void setName(String name) { + this.name = name; + } + } + + @Audited + @Entity(name = "Author") + static class Author { + @Id + long id; + @Column(name = "name_col") + String name; + @ManyToOne + Publisher publisher; + + Author() { + } + + Author(long id, String name) { + this.id = id; + this.name = name; + } + + Author(long id, String name, Publisher publisher) { + this.id = id; + this.name = name; + this.publisher = publisher; + } + + String getName() { + return name; + } + + void setName(String name) { + this.name = name; + } + + Publisher getPublisher() { + return publisher; + } + } + + @Audited + @Entity(name = "Book") + static class Book { + @Id + long id; + String title; + @ManyToOne + Author author; + + Book() { + } + + Book(long id, String title, Author author) { + this.id = id; + this.title = title; + this.author = author; + } + + String getTitle() { + return title; + } + + void setTitle(String title) { + this.title = title; + } + + Author getAuthor() { + return author; + } + + void setAuthor(Author author) { + this.author = author; + } + } + + @Audited + @Entity(name = "LazyBook") + static class LazyBook { + @Id + long id; + String title; + @ManyToOne(fetch = FetchType.LAZY) + Author author; + + LazyBook() { + } + + LazyBook(long id, String title, Author author) { + this.id = id; + this.title = title; + this.author = author; + } + + String getTitle() { + return title; + } + + void setTitle(String title) { + this.title = title; + } + + Author getAuthor() { + return author; + } + } + + @Audited + @Entity(name = "Person") + static class Person { + @Id + long id; + @Column(name = "name_col") + String name; + @OneToOne + Passport passport; + + Person() { + } + + Person(long id, String name) { + this.id = id; + this.name = name; + } + } + + @Audited + @Entity(name = "Passport") + static class Passport { + @Id + long id; + @Column(name = "number_col") + String number; + + Passport() { + } + + Passport(long id, String number) { + this.id = id; + this.number = number; + } + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/temporal/audit/DefaultRevisionEntityTest.java b/hibernate-core/src/test/java/org/hibernate/temporal/audit/DefaultRevisionEntityTest.java new file mode 100644 index 000000000000..8effbf1a94ae --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/temporal/audit/DefaultRevisionEntityTest.java @@ -0,0 +1,155 @@ +/* + * 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.AuditLogFactory; +import org.hibernate.audit.DefaultRevisionEntity; +import org.hibernate.testing.orm.junit.DomainModel; +import org.hibernate.testing.orm.junit.AuditedTest; +import org.hibernate.testing.orm.junit.SessionFactory; +import org.hibernate.testing.orm.junit.SessionFactoryScope; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests the built-in {@link DefaultRevisionEntity}, which is + * auto-detected via {@link org.hibernate.annotations.RevisionEntity @RevisionEntity} + * with no explicit supplier configuration needed. + */ +@AuditedTest +@SessionFactory +@DomainModel(annotatedClasses = { + DefaultRevisionEntityTest.Book.class, + DefaultRevisionEntity.class +}) +class DefaultRevisionEntityTest { + + @Audited + @Entity(name = "Book") + static class Book { + @Id + long id; + String title; + } + + @Test + void testDefaultRevisionEntity(SessionFactoryScope scope) { + final long beforeTest = System.currentTimeMillis(); + + // Create + scope.getSessionFactory().inTransaction( session -> { + final var book = new Book(); + book.id = 1L; + book.title = "Original Title"; + session.persist( book ); + } ); + + // Update + scope.getSessionFactory().inTransaction( session -> { + final var book = session.find( Book.class, 1L ); + book.title = "Updated Title"; + } ); + + // Delete + scope.getSessionFactory().inTransaction( session -> { + final var book = session.find( Book.class, 1L ); + session.remove( book ); + } ); + + // Verify REVINFO rows for this test (book id=1) + scope.getSessionFactory().inTransaction( session -> { + final var auditLog = AuditLogFactory.create( session ); + final var revisionIds = auditLog.getRevisions( Book.class, 1L ); + assertEquals( 3, revisionIds.size() ); + + final var revisions = session.createSelectionQuery( + "from DefaultRevisionEntity where id in :ids order by id", + DefaultRevisionEntity.class + ).setParameter( "ids", revisionIds ).getResultList(); + assertEquals( 3, revisions.size() ); + + for ( var rev : revisions ) { + assertTrue( rev.getTimestamp() >= beforeTest, + "Timestamp should be >= test start time" ); + assertNotNull( rev.getRevisionInstant() ); + } + + final long rev1 = revisions.get( 0 ).getId(); + final long rev2 = revisions.get( 1 ).getId(); + final long rev3 = revisions.get( 2 ).getId(); + + // Verify sequential revision numbers + assertTrue( rev1 < rev2 ); + assertTrue( rev2 < rev3 ); + + // Read at rev1: entity was created + try (var s = scope.getSessionFactory().withOptions() + .atTransaction( rev1 ).open()) { + final var book = s.find( Book.class, 1L ); + assertNotNull( book ); + assertEquals( "Original Title", book.title ); + } + + // Read at rev2: entity was updated + try (var s = scope.getSessionFactory().withOptions() + .atTransaction( rev2 ).open()) { + final var book = s.find( Book.class, 1L ); + assertNotNull( book ); + assertEquals( "Updated Title", book.title ); + } + + // Read at rev3: entity was deleted + try (var s = scope.getSessionFactory().withOptions() + .atTransaction( rev3 ).open()) { + final var book = s.find( Book.class, 1L ); + assertNull( book ); + } + } ); + } + + @Test + void testGetHistoryReturnsRevisionEntity(SessionFactoryScope scope) { + // Create and update a book + scope.getSessionFactory().inTransaction( session -> { + var book = new Book(); + book.id = 2L; + book.title = "History Book"; + session.persist( book ); + } ); + scope.getSessionFactory().inTransaction( session -> { + var book = session.find( Book.class, 2L ); + book.title = "Updated History Book"; + } ); + + // getHistory() should return DefaultRevisionEntity instances as the revision member + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + var history = auditLog.getHistory( Book.class, 2L ); + + assertEquals( 2, history.size() ); + + // Verify revision is a DefaultRevisionEntity, not a plain Integer + var entry1 = history.get( 0 ); + assertInstanceOf( DefaultRevisionEntity.class, entry1.revision(), + "Revision should be a DefaultRevisionEntity instance" ); + var rev1 = (DefaultRevisionEntity) entry1.revision(); + assertTrue( rev1.getTimestamp() > 0, "Revision should have a timestamp" ); + + var entry2 = history.get( 1 ); + assertInstanceOf( DefaultRevisionEntity.class, entry2.revision() ); + var rev2 = (DefaultRevisionEntity) entry2.revision(); + assertTrue( rev2.getId() > rev1.getId(), + "Revisions should be sequential: rev1=" + rev1.getId() + ", rev2=" + rev2.getId() ); + } + } + +} diff --git a/hibernate-core/src/test/java/org/hibernate/temporal/audit/EntityTrackingRevisionListenerTest.java b/hibernate-core/src/test/java/org/hibernate/temporal/audit/EntityTrackingRevisionListenerTest.java new file mode 100644 index 000000000000..4c5833e93995 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/temporal/audit/EntityTrackingRevisionListenerTest.java @@ -0,0 +1,177 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.temporal.audit; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import org.hibernate.annotations.Audited; +import org.hibernate.audit.EntityTrackingRevisionListener; +import org.hibernate.audit.ModificationType; +import org.hibernate.annotations.RevisionEntity; +import org.hibernate.testing.orm.junit.AuditedTest; +import org.hibernate.testing.orm.junit.DomainModel; +import org.hibernate.testing.orm.junit.SessionFactory; +import org.hibernate.testing.orm.junit.SessionFactoryScope; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Tests {@link EntityTrackingRevisionListener}: per-entity-change callbacks. + */ +@AuditedTest +@SessionFactory +@DomainModel(annotatedClasses = { + EntityTrackingRevisionListenerTest.TrackedEntity.class, + EntityTrackingRevisionListenerTest.TrackingRevisionInfo.class +}) +class EntityTrackingRevisionListenerTest { + + @BeforeEach + void clearTracker() { + TrackingListener.changes.clear(); + } + + @Test + void testEntityChangedCallback(SessionFactoryScope scope) { + // REV 1: insert + scope.getSessionFactory().inTransaction( session -> { + var entity = new TrackedEntity(); + entity.id = 1L; + entity.name = "Created"; + session.persist( entity ); + } ); + + assertEquals( 1, TrackingListener.changes.size() ); + var change = TrackingListener.changes.get( 0 ); + assertEquals( TrackedEntity.class, change.entityClass ); + assertEquals( 1L, change.entityId ); + assertEquals( ModificationType.ADD, change.modificationType ); + assertNotNull( change.revisionEntity ); + + TrackingListener.changes.clear(); + + // REV 2: update + scope.getSessionFactory().inTransaction( session -> + session.find( TrackedEntity.class, 1L ).name = "Updated" + ); + + assertEquals( 1, TrackingListener.changes.size() ); + change = TrackingListener.changes.get( 0 ); + assertEquals( 1L, change.entityId ); + assertEquals( ModificationType.MOD, change.modificationType ); + + TrackingListener.changes.clear(); + + // REV 3: delete + scope.getSessionFactory().inTransaction( session -> + session.remove( session.find( TrackedEntity.class, 1L ) ) + ); + + assertEquals( 1, TrackingListener.changes.size() ); + change = TrackingListener.changes.get( 0 ); + assertEquals( 1L, change.entityId ); + assertEquals( ModificationType.DEL, change.modificationType ); + } + + @Test + void testMultipleEntitiesInOneTransaction(SessionFactoryScope scope) { + scope.getSessionFactory().inTransaction( session -> { + var e1 = new TrackedEntity(); + e1.id = 10L; + e1.name = "First"; + session.persist( e1 ); + var e2 = new TrackedEntity(); + e2.id = 11L; + e2.name = "Second"; + session.persist( e2 ); + } ); + + assertEquals( 2, TrackingListener.changes.size() ); + // Both should have the same revision entity + assertEquals( TrackingListener.changes.get( 0 ).revisionEntity, + TrackingListener.changes.get( 1 ).revisionEntity ); + } + + @Test + void testRevisionEntityAccessible(SessionFactoryScope scope) { + scope.getSessionFactory().inTransaction( session -> { + var entity = new TrackedEntity(); + entity.id = 20L; + entity.name = "RevTest"; + session.persist( entity ); + } ); + + assertEquals( 1, TrackingListener.changes.size() ); + var revEntity = (TrackingRevisionInfo) TrackingListener.changes.get( 0 ).revisionEntity; + assertNotNull( revEntity ); + assertEquals( "tracking-user", revEntity.username ); + } + + // ---- Listener ---- + + record EntityChange( + Class entityClass, + Object entityId, + ModificationType modificationType, + Object revisionEntity) { + } + + public static class TrackingListener implements EntityTrackingRevisionListener { + static final List changes = new ArrayList<>(); + + @Override + public void newRevision(Object revisionEntity) { + ((TrackingRevisionInfo) revisionEntity).username = "tracking-user"; + } + + @Override + public void entityChanged( + Class entityClass, + Object entityId, + ModificationType modificationType, + Object revisionEntity) { + changes.add( new EntityChange( + entityClass, entityId, + modificationType, revisionEntity ) ); + } + } + + // ---- Entities ---- + + @RevisionEntity(listener = TrackingListener.class) + @Entity(name = "TrackingRevisionInfo") + @Table(name = "REVINFO") + static class TrackingRevisionInfo { + @Id + @GeneratedValue + @RevisionEntity.TransactionId + @Column(name = "REV") + int id; + + @RevisionEntity.Timestamp + @Column(name = "REVTSTMP") + long timestamp; + + @Column(name = "USERNAME") + String username; + } + + @Audited + @Entity(name = "TrackedEntity") + static class TrackedEntity { + @Id + long id; + String name; + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/temporal/audit/RevChangesTrackingTest.java b/hibernate-core/src/test/java/org/hibernate/temporal/audit/RevChangesTrackingTest.java new file mode 100644 index 000000000000..af2c0a6e700d --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/temporal/audit/RevChangesTrackingTest.java @@ -0,0 +1,302 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.temporal.audit; + +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.Table; +import org.hibernate.annotations.Audited; +import org.hibernate.annotations.Fetch; +import org.hibernate.annotations.FetchMode; +import org.hibernate.audit.AuditLogFactory; +import org.hibernate.audit.ModificationType; +import org.hibernate.annotations.RevisionEntity; +import org.hibernate.testing.orm.junit.AuditedTest; +import org.hibernate.testing.orm.junit.BeforeClassTemplate; +import org.hibernate.testing.orm.junit.DomainModel; +import org.hibernate.testing.orm.junit.DomainModelScope; +import org.hibernate.testing.orm.junit.SessionFactory; +import org.hibernate.testing.orm.junit.SessionFactoryScope; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests cross-type entity change tracking via a custom + * {@link RevisionEntity @RevisionEntity} with + * {@link RevisionEntity.ModifiedEntities @RevisionEntity.ModifiedEntities}. + *

+ * Exercises the REVCHANGES write-side (via {@code @ElementCollection} + * on the revision entity) and the read-side APIs on {@code AuditLog}. + */ +@AuditedTest +@SessionFactory +@DomainModel(annotatedClasses = { + RevChangesTrackingTest.Book.class, + RevChangesTrackingTest.Author.class, + RevChangesTrackingTest.TrackingRevisionInfo.class +}) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class RevChangesTrackingTest { + + // --- Custom revision entity with @RevisionEntity.ModifiedEntities --- + + @RevisionEntity + @Entity(name = "TrackingRevisionInfo") + @Table(name = "REVINFO") + static class TrackingRevisionInfo { + @Id + @GeneratedValue + @RevisionEntity.TransactionId + @Column(name = "REV") + int id; + + @RevisionEntity.Timestamp + @Column(name = "REVTSTMP") + long timestamp; + + @ElementCollection(fetch = FetchType.EAGER) + @JoinTable(name = "REVCHANGES", joinColumns = @JoinColumn(name = "REV")) + @Column(name = "ENTITYNAME") + @Fetch(FetchMode.JOIN) + @RevisionEntity.ModifiedEntities + Set modifiedEntityNames = new HashSet<>(); + } + + // --- Audited entities --- + + @Audited + @Entity(name = "Book") + static class Book { + @Id + long id; + String title; + } + + @Audited + @Entity(name = "Author") + static class Author { + @Id + long id; + String name; + } + + // --- Test data --- + + /** + * Rev 1: create Book + Author + */ + private Object rev1; + /** + * Rev 2: update Book only + */ + private Object rev2; + /** + * Rev 3: delete Author only + */ + private Object rev3; + + @BeforeClassTemplate + void initData(SessionFactoryScope scope) { + // Rev 1: create both entities + scope.getSessionFactory().inTransaction( session -> { + final var book = new Book(); + book.id = 1L; + book.title = "Hibernate in Action"; + session.persist( book ); + + final var author = new Author(); + author.id = 1L; + author.name = "Gavin King"; + session.persist( author ); + } ); + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + rev1 = auditLog.getRevisions( Book.class, 1L ).get( 0 ); + } + + // Rev 2: update Book only + scope.getSessionFactory().inTransaction( session -> + session.find( Book.class, 1L ).title = "Hibernate in Action 2e" + ); + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var revisions = auditLog.getRevisions( Book.class, 1L ); + rev2 = revisions.get( revisions.size() - 1 ); + } + + // Rev 3: delete Author only + scope.getSessionFactory().inTransaction( session -> + session.remove( session.find( Author.class, 1L ) ) + ); + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var revisions = auditLog.getRevisions( Author.class, 1L ); + rev3 = revisions.get( revisions.size() - 1 ); + } + } + + // --- Schema verification --- + + @Test + @Order(1) + void testRevChangesTableExists(DomainModelScope scope) { + boolean found = false; + for ( var table : scope.getDomainModel().collectTableMappings() ) { + if ( "REVCHANGES".equalsIgnoreCase( table.getName() ) ) { + found = true; + assertNotNull( table.getColumn( new org.hibernate.mapping.Column( "REV" ) ) ); + assertNotNull( table.getColumn( new org.hibernate.mapping.Column( "ENTITYNAME" ) ) ); + break; + } + } + assertTrue( found, "REVCHANGES table not found in metadata" ); + } + + // --- getEntityTypesModifiedAt --- + + @Test + @Order(2) + void testEntityTypesModifiedAtMultiType(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // Rev 1: both Book and Author were created + final var types = auditLog.getEntityTypesModifiedAt( rev1 ); + assertEquals( 2, types.size() ); + assertTrue( types.contains( Book.class ) ); + assertTrue( types.contains( Author.class ) ); + } + } + + @Test + @Order(3) + void testEntityTypesModifiedAtSingleType(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // Rev 2: only Book was modified + final var types = auditLog.getEntityTypesModifiedAt( rev2 ); + assertEquals( 1, types.size() ); + assertTrue( types.contains( Book.class ) ); + } + } + + // --- findAllEntitiesModifiedAt --- + + @Test + @Order(4) + void testFindAllEntitiesModifiedAtMultiType(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // Rev 1: both entities created + final var entities = auditLog.findAllEntitiesModifiedAt( rev1 ); + assertEquals( 2, entities.size() ); + } + } + + @Test + @Order(5) + void testFindAllEntitiesModifiedAtSingleType(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // Rev 2: only Book updated + final var entities = auditLog.findAllEntitiesModifiedAt( rev2 ); + assertEquals( 1, entities.size() ); + assertTrue( entities.get( 0 ) instanceof Book ); + assertEquals( "Hibernate in Action 2e", ((Book) entities.get( 0 )).title ); + } + } + + // --- findAllEntitiesModifiedAt with ModificationType --- + + @Test + @Order(6) + void testFindAllEntitiesModifiedAtWithAddFilter(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // Rev 1: both were ADDs + final var adds = auditLog.findAllEntitiesModifiedAt( rev1, ModificationType.ADD ); + assertEquals( 2, adds.size() ); + + // No MODs or DELs in rev 1 + assertTrue( auditLog.findAllEntitiesModifiedAt( rev1, ModificationType.MOD ).isEmpty() ); + assertTrue( auditLog.findAllEntitiesModifiedAt( rev1, ModificationType.DEL ).isEmpty() ); + } + } + + @Test + @Order(7) + void testFindAllEntitiesModifiedAtWithDelFilter(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // Rev 3: Author was deleted + final var dels = auditLog.findAllEntitiesModifiedAt( rev3, ModificationType.DEL ); + assertEquals( 1, dels.size() ); + } + } + + // --- findAllEntitiesGroupedByModificationType --- + + @Test + @Order(8) + void testGroupedByModificationTypeAllAdds(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // Rev 1: both entities were ADDs + final var grouped = auditLog.findAllEntitiesGroupedByModificationType( rev1 ); + assertEquals( 2, grouped.get( ModificationType.ADD ).size() ); + assertTrue( grouped.get( ModificationType.MOD ).isEmpty() ); + assertTrue( grouped.get( ModificationType.DEL ).isEmpty() ); + } + } + + @Test + @Order(9) + void testGroupedByModificationTypeMod(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // Rev 2: Book was MOD + final var grouped = auditLog.findAllEntitiesGroupedByModificationType( rev2 ); + assertTrue( grouped.get( ModificationType.ADD ).isEmpty() ); + assertEquals( 1, grouped.get( ModificationType.MOD ).size() ); + assertTrue( grouped.get( ModificationType.MOD ).get( 0 ) instanceof Book ); + assertTrue( grouped.get( ModificationType.DEL ).isEmpty() ); + } + } + + @Test + @Order(10) + void testGroupedByModificationTypeDel(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // Rev 3: Author was DEL + final var grouped = auditLog.findAllEntitiesGroupedByModificationType( rev3 ); + assertTrue( grouped.get( ModificationType.ADD ).isEmpty() ); + assertTrue( grouped.get( ModificationType.MOD ).isEmpty() ); + assertEquals( 1, grouped.get( ModificationType.DEL ).size() ); + } + } + + // --- Revision entity verification --- + + @Test + @Order(11) + void testRevisionEntityHasModifiedEntityNames(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // Load the revision entity and verify modifiedEntityNames was populated + TrackingRevisionInfo revInfo = auditLog.findRevision( rev1 ); + assertNotNull( revInfo.modifiedEntityNames ); + assertEquals( 2, revInfo.modifiedEntityNames.size() ); + // Entity names are FQN of the @Entity name mapping + assertTrue( revInfo.modifiedEntityNames.stream() + .anyMatch( n -> n.contains( "Book" ) ) ); + assertTrue( revInfo.modifiedEntityNames.stream() + .anyMatch( n -> n.contains( "Author" ) ) ); + } + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/temporal/audit/RevisionEntityAnnotationTest.java b/hibernate-core/src/test/java/org/hibernate/temporal/audit/RevisionEntityAnnotationTest.java new file mode 100644 index 000000000000..c8c1f8aefb35 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/temporal/audit/RevisionEntityAnnotationTest.java @@ -0,0 +1,135 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.temporal.audit; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import org.hibernate.annotations.Audited; +import org.hibernate.audit.AuditLogFactory; +import org.hibernate.annotations.RevisionEntity; +import org.hibernate.testing.orm.junit.DomainModel; +import org.hibernate.testing.orm.junit.AuditedTest; +import org.hibernate.testing.orm.junit.SessionFactory; +import org.hibernate.testing.orm.junit.SessionFactoryScope; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Tests that {@link RevisionEntity @RevisionEntity} auto-detection + * works without an explicit {@code hibernate.temporal.transaction_id_supplier} + * setting. + */ +@AuditedTest +@SessionFactory +@DomainModel(annotatedClasses = { + RevisionEntityAnnotationTest.Book.class, + RevisionEntityAnnotationTest.MyRevisionInfo.class +}) +class RevisionEntityAnnotationTest { + + @RevisionEntity + @Entity(name = "MyRevisionInfo") + @Table(name = "REVINFO") + static class MyRevisionInfo { + @Id + @GeneratedValue + @RevisionEntity.TransactionId + @Column(name = "REV") + int id; + + @RevisionEntity.Timestamp + @Column(name = "REVTSTMP") + long timestamp; + } + + @Audited + @Entity(name = "RevAnnotBook") + static class Book { + @Id + long id; + String title; + } + + @Test + void testAutoDetectedRevisionEntity(SessionFactoryScope scope) { + // Create + scope.getSessionFactory().inTransaction( session -> { + final var book = new Book(); + book.id = 1L; + book.title = "Auto-detected"; + session.persist( book ); + } ); + + // Update + scope.getSessionFactory().inTransaction( session -> { + final var book = session.find( Book.class, 1L ); + book.title = "Updated"; + } ); + + // Delete + scope.getSessionFactory().inTransaction( session -> { + final var book = session.find( Book.class, 1L ); + session.remove( book ); + } ); + + // Verify revision entity was auto-configured + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var revisions = auditLog.getRevisions( Book.class, 1L ); + assertEquals( 3, revisions.size() ); + + final int rev1 = (int) revisions.get( 0 ); + final int rev2 = (int) revisions.get( 1 ); + final int rev3 = (int) revisions.get( 2 ); + + // Read at each revision + try (var s = scope.getSessionFactory().withOptions() + .atTransaction( rev1 ).open()) { + final var book = s.find( Book.class, 1L ); + assertNotNull( book ); + assertEquals( "Auto-detected", book.title ); + } + + try (var s = scope.getSessionFactory().withOptions() + .atTransaction( rev2 ).open()) { + final var book = s.find( Book.class, 1L ); + assertNotNull( book ); + assertEquals( "Updated", book.title ); + } + + try (var s = scope.getSessionFactory().withOptions() + .atTransaction( rev3 ).open()) { + final var book = s.find( Book.class, 1L ); + assertNull( book ); + } + } + } + + @Test + void testGetHistoryWithAutoDetectedRevisionEntity(SessionFactoryScope scope) { + scope.getSessionFactory().inTransaction( session -> { + final var book = new Book(); + book.id = 2L; + book.title = "History Book"; + session.persist( book ); + } ); + + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + final var history = auditLog.getHistory( Book.class, 2L ); + assertEquals( 1, history.size() ); + + // The revision should be a MyRevisionInfo instance (joined in HQL) + final var entry = history.get( 0 ); + assertNotNull( entry.entity() ); + assertEquals( "History Book", entry.entity().title ); + assertNotNull( entry.revision() ); + } + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/temporal/audit/collection/AuditBidirectionalManyToManyTest.java b/hibernate-core/src/test/java/org/hibernate/temporal/audit/collection/AuditBidirectionalManyToManyTest.java new file mode 100644 index 000000000000..11532baf2f62 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/temporal/audit/collection/AuditBidirectionalManyToManyTest.java @@ -0,0 +1,319 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.temporal.audit.collection; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToMany; +import org.hibernate.annotations.Audited; +import org.hibernate.audit.AuditLog; +import org.hibernate.audit.AuditLogFactory; +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.BeforeClassTemplate; +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.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; + +/** + * Tests bidirectional @ManyToMany auditing. + * The owning side's collection changes are tracked; the inverse (mappedBy) side + * does NOT get extra MOD revisions for relationship changes. + */ +@AuditedTest +@SessionFactory +@DomainModel(annotatedClasses = { + AuditBidirectionalManyToManyTest.OwningEntity.class, + AuditBidirectionalManyToManyTest.OwnedEntity.class +}) +@ServiceRegistry(settings = @Setting(name = StateManagementSettings.TRANSACTION_ID_SUPPLIER, + value = "org.hibernate.temporal.audit.collection.AuditBidirectionalManyToManyTest$TxIdSupplier")) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class AuditBidirectionalManyToManyTest { + private static int currentTxId; + + public static class TxIdSupplier implements TransactionIdentifierSupplier { + @Override + public Integer generateTransactionIdentifier(SharedSessionContract session) { + return ++currentTxId; + } + } + + // Shared lifecycle: ing1 + ing2 with ed1 + ed2, add/remove references, clear + private int revCreate; // ed1, ed2, ing1, ing2 + private int revLink; // ing1={ed1}, ing2={ed1,ed2} + private int revAddRef; // ing1.add(ed2) + private int revRemoveRef; // ing1.remove(ed1) + private int revClear; // ing1.clear() + + // Recreate scenario: ing(10) with ed(10)+ed(11), then recreate + private int revRecCreate; // ing(10) with ed(10)+ed(11) + private int revRecReplace; // clear, re-add ed(11)+new ed(12) + + @BeforeClassTemplate + void initData(SessionFactoryScope scope) { + currentTxId = 0; + final var sf = scope.getSessionFactory(); + + // --- Shared lifecycle --- + + // Rev 1: create all entities, no collection changes + sf.inTransaction( session -> { + session.persist( new OwnedEntity( 1, "ed1" ) ); + session.persist( new OwnedEntity( 2, "ed2" ) ); + session.persist( new OwningEntity( 3, "ing1" ) ); + session.persist( new OwningEntity( 4, "ing2" ) ); + } ); + revCreate = currentTxId; + + // Rev 2: ing1={ed1}, ing2={ed1,ed2} + sf.inTransaction( session -> { + var ing1 = session.find( OwningEntity.class, 3 ); + var ing2 = session.find( OwningEntity.class, 4 ); + var ed1 = session.find( OwnedEntity.class, 1 ); + var ed2 = session.find( OwnedEntity.class, 2 ); + ing1.references.add( ed1 ); + ing2.references.add( ed1 ); + ing2.references.add( ed2 ); + } ); + revLink = currentTxId; + + // Rev 3: ing1.add(ed2) + sf.inTransaction( session -> { + var ing1 = session.find( OwningEntity.class, 3 ); + var ed2 = session.find( OwnedEntity.class, 2 ); + ing1.references.add( ed2 ); + } ); + revAddRef = currentTxId; + + // Rev 4: ing1.remove(ed1) + sf.inTransaction( session -> { + var ing1 = session.find( OwningEntity.class, 3 ); + ing1.references.removeIf( e -> e.id == 1 ); + } ); + revRemoveRef = currentTxId; + + // Rev 5: ing1 clears all references + sf.inTransaction( session -> { + var ing1 = session.find( OwningEntity.class, 3 ); + ing1.references.clear(); + } ); + revClear = currentTxId; + + // --- Recreate scenario --- + + // Rev 6: ing with ed1 + ed2 + sf.inTransaction( session -> { + session.persist( new OwnedEntity( 10, "rec ed1" ) ); + session.persist( new OwnedEntity( 11, "rec ed2" ) ); + var ing = new OwningEntity( 12, "rec ing" ); + ing.references.add( session.find( OwnedEntity.class, 10 ) ); + ing.references.add( session.find( OwnedEntity.class, 11 ) ); + session.persist( ing ); + } ); + revRecCreate = currentTxId; + + // Rev 7: recreate: clear and re-add ed2 + new ed3 + sf.inTransaction( session -> { + session.persist( new OwnedEntity( 13, "rec ed3" ) ); + var ing = session.find( OwningEntity.class, 12 ); + ing.references.clear(); + ing.references.add( session.find( OwnedEntity.class, 11 ) ); + ing.references.add( session.find( OwnedEntity.class, 13 ) ); + } ); + revRecReplace = currentTxId; + } + + // --- Write side verification --- + + @Test + @Order(1) + void testWriteSide(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // Owning side: ing1 at [1, 2, 3, 4, 5], ing2 at [1, 2] + assertEquals( 5, auditLog.getRevisions( OwningEntity.class, 3 ).size(), + "ing1 should have 5 revisions" ); + assertEquals( 2, auditLog.getRevisions( OwningEntity.class, 4 ).size(), + "ing2 should have 2 revisions" ); + + // Inverse side: only ADD revisions, no MOD from relationship changes + assertEquals( 1, auditLog.getRevisions( OwnedEntity.class, 1 ).size(), + "ed1 should have 1 revision (ADD only)" ); + assertEquals( 1, auditLog.getRevisions( OwnedEntity.class, 2 ).size(), + "ed2 should have 1 revision (ADD only)" ); + } + } + + // --- Point-in-time reads --- + + @Test + @Order(2) + void testPointInTimeRead(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + // At revLink: ing1 has 1 reference (ed1) + try (var s = sf.withOptions().atTransaction( revLink ).openSession()) { + var ing = s.find( OwningEntity.class, 3 ); + assertNotNull( ing ); + assertEquals( 1, ing.references.size(), "At revLink, ing1 should have 1 reference" ); + } + + // At revAddRef: ing1 has 2 references (ed1 + ed2) + try (var s = sf.withOptions().atTransaction( revAddRef ).openSession()) { + var ing = s.find( OwningEntity.class, 3 ); + assertNotNull( ing ); + assertEquals( 2, ing.references.size(), "At revAddRef, ing1 should have 2 references" ); + } + + // At revRemoveRef: ing1 has 1 reference (ed2 only) + try (var s = sf.withOptions().atTransaction( revRemoveRef ).openSession()) { + var ing = s.find( OwningEntity.class, 3 ); + assertNotNull( ing ); + assertEquals( 1, ing.references.size(), "At revRemoveRef, ing1 should have 1 reference" ); + assertEquals( "ed2", ing.references.iterator().next().data ); + } + } + + // --- getHistory --- + + @Test + @Order(3) + void testGetHistory(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // ing1: 5 revisions (ADD + 3 collection changes + clear) + var history = auditLog.getHistory( OwningEntity.class, 3 ); + assertEquals( 5, history.size() ); + assertEquals( "ing1", history.get( 0 ).entity().data ); + + // Owned entities: only 1 revision each (ADD) + assertEquals( 1, auditLog.getHistory( OwnedEntity.class, 1 ).size() ); + assertEquals( 1, auditLog.getHistory( OwnedEntity.class, 2 ).size() ); + } + } + + // --- Recreate scenario --- + + @Test + @Order(4) + void testPointInTimeReadAfterRecreate(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + // Owning entity: ADD + recreate = 2 revisions (not more) + try (var auditLog = AuditLogFactory.create( sf )) { + assertEquals( 2, auditLog.getRevisions( OwningEntity.class, 12 ).size(), + "Owning entity should have exactly 2 revisions (ADD + recreate)" ); + } + + // At revRecCreate: 2 references + try (var s = sf.withOptions().atTransaction( revRecCreate ).openSession()) { + var ing = s.find( OwningEntity.class, 12 ); + assertNotNull( ing ); + assertEquals( 2, ing.references.size(), "At revRecCreate, should have 2 references" ); + } + + // At revRecReplace: 2 references (ed2 + ed3, ed1 dropped) + try (var s = sf.withOptions().atTransaction( revRecReplace ).openSession()) { + var ing = s.find( OwningEntity.class, 12 ); + assertNotNull( ing ); + assertEquals( 2, ing.references.size(), "At revRecReplace, should have 2 references" ); + var names = ing.references.stream().map( e -> e.data ).sorted().toList(); + assertEquals( List.of( "rec ed2", "rec ed3" ), names ); + } + } + + // --- ALL_REVISIONS collection isolation --- + + @Test + @Order(5) + void testCollectionRevisionIsolation(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + try (var s = sf.withOptions().atTransaction( AuditLog.ALL_REVISIONS ).openSession()) { + var entities = s.createSelectionQuery( "from OwningEntity where id = :id", OwningEntity.class ) + .setParameter( "id", 3 ) + .getResultList(); + // revCreate(0) + revLink(1) + revAddRef(2) + revRemoveRef(1) + revClear(0) = 5 revisions + assertEquals( 5, entities.size(), "Expected 5 revisions" ); + + // Find revisions with different collection sizes + OwningEntity entityWith2 = null; + OwningEntity entityWith1 = null; + for ( var e : entities ) { + int size = e.references.size(); + if ( size == 2 && entityWith2 == null ) { + entityWith2 = e; + } + else if ( size == 1 && entityWith1 == null ) { + entityWith1 = e; + } + } + assertNotNull( entityWith2, "Should find a revision with 2 references" ); + assertNotNull( entityWith1, "Should find a revision with 1 reference" ); + + // Collections must be distinct instances across revisions + assertNotSame( entityWith1.references, entityWith2.references, + "Collections at different revisions must not be the same instance" ); + + // Verify contents + assertEquals( 2, entityWith2.references.size() ); + assertEquals( 1, entityWith1.references.size() ); + } + } + + // ---- Entity classes ---- + + @Audited + @Entity(name = "OwningEntity") + static class OwningEntity { + @Id + int id; + String data; + @ManyToMany + Set references = new HashSet<>(); + + OwningEntity() { + } + + OwningEntity(int id, String data) { + this.id = id; + this.data = data; + } + } + + @Audited + @Entity(name = "OwnedEntity") + static class OwnedEntity { + @Id + int id; + String data; + @ManyToMany(mappedBy = "references") + Set referencing = new HashSet<>(); + + OwnedEntity() { + } + + OwnedEntity(int id, String data) { + this.id = id; + this.data = data; + } + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/temporal/audit/collection/AuditBidirectionalOneToManyTest.java b/hibernate-core/src/test/java/org/hibernate/temporal/audit/collection/AuditBidirectionalOneToManyTest.java new file mode 100644 index 000000000000..b7a7ec79196d --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/temporal/audit/collection/AuditBidirectionalOneToManyTest.java @@ -0,0 +1,310 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.temporal.audit.collection; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import org.hibernate.annotations.Audited; +import org.hibernate.audit.AuditLogFactory; +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.BeforeClassTemplate; +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.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Tests bidirectional @OneToMany (mappedBy) auditing. + */ +@AuditedTest +@SessionFactory +@DomainModel(annotatedClasses = { + AuditBidirectionalOneToManyTest.Parent.class, + AuditBidirectionalOneToManyTest.Child.class +}) +@ServiceRegistry(settings = @Setting(name = StateManagementSettings.TRANSACTION_ID_SUPPLIER, + value = "org.hibernate.temporal.audit.collection.AuditBidirectionalOneToManyTest$TxIdSupplier")) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class AuditBidirectionalOneToManyTest { + private static int currentTxId; + + public static class TxIdSupplier implements TransactionIdentifierSupplier { + @Override + public Integer generateTransactionIdentifier(SharedSessionContract session) { + return ++currentTxId; + } + } + + // Shared lifecycle: Parent(1) + Child(1) + Child(2), update, remove + private int revCreate; // Parent(1) + Child(1, "Child A") + private int revAddChild; // add Child(2, "Child B") + update Child(1) name + private int revRemove; // remove Child(1) + + // Recreate scenario: Parent(10) + children, bulk recreate + private int revRecCreate; // Parent(10) + Child(10, "A") + Child(11, "B") + private int revRecReplace; // remove Child(10), add Child(12, "C") + + // Property update scenario + private int revUpdCreate; // Parent(20) + Child(20) + private int revUpdMod; // update Child(20) name only + + @BeforeClassTemplate + void initData(SessionFactoryScope scope) { + currentTxId = 0; + final var sf = scope.getSessionFactory(); + + // --- Shared lifecycle --- + + // Rev 1: parent + child A + sf.inTransaction( session -> { + var parent = new Parent( 1L, "Parent" ); + var child = new Child( 1L, "Child A", parent ); + parent.children.add( child ); + session.persist( parent ); + session.persist( child ); + } ); + revCreate = currentTxId; + + // Rev 2: add child B + update child A name + sf.inTransaction( session -> { + var parent = session.find( Parent.class, 1L ); + session.find( Child.class, 1L ).name = "Child A v2"; + var childB = new Child( 2L, "Child B", parent ); + parent.children.add( childB ); + session.persist( childB ); + } ); + revAddChild = currentTxId; + + // Rev 3: remove child A + sf.inTransaction( session -> { + var parent = session.find( Parent.class, 1L ); + var child = session.find( Child.class, 1L ); + parent.children.remove( child ); + session.remove( child ); + } ); + revRemove = currentTxId; + + // --- Recreate scenario --- + + // Rev 4: parent with child A + B + sf.inTransaction( session -> { + var parent = new Parent( 10L, "Rec Parent" ); + var childA = new Child( 10L, "Rec Child A", parent ); + var childB = new Child( 11L, "Rec Child B", parent ); + parent.children.add( childA ); + parent.children.add( childB ); + session.persist( parent ); + session.persist( childA ); + session.persist( childB ); + } ); + revRecCreate = currentTxId; + + // Rev 5: drop A, add C + sf.inTransaction( session -> { + var parent = session.find( Parent.class, 10L ); + var childA = session.find( Child.class, 10L ); + parent.children.remove( childA ); + session.remove( childA ); + var childC = new Child( 12L, "Rec Child C", parent ); + parent.children.add( childC ); + session.persist( childC ); + } ); + revRecReplace = currentTxId; + + // --- Property update scenario --- + + // Rev 6: parent + child + sf.inTransaction( session -> { + var parent = new Parent( 20L, "Upd Parent" ); + var child = new Child( 20L, "Upd Child A", parent ); + parent.children.add( child ); + session.persist( parent ); + session.persist( child ); + } ); + revUpdCreate = currentTxId; + + // Rev 7: update child name only (no collection membership change) + sf.inTransaction( session -> + session.find( Child.class, 20L ).name = "Upd Child A v2" + ); + revUpdMod = currentTxId; + } + + // --- Write side verification --- + + @Test + @Order(1) + void testWriteSideRevisionCounts(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // Parent is inverse side: only 1 revision (initial persist) + assertEquals( 1, auditLog.getRevisions( Parent.class, 1L ).size() ); + // Child A: ADD + MOD (name update) + DEL + assertEquals( 3, auditLog.getRevisions( Child.class, 1L ).size() ); + // Child B: ADD only + assertEquals( 1, auditLog.getRevisions( Child.class, 2L ).size() ); + } + } + + // --- Point-in-time reads --- + + @Test + @Order(2) + void testPointInTimeRead(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + // At revCreate: parent has 1 child (A) + try (var s = sf.withOptions().atTransaction( revCreate ).openSession()) { + var parent = s.find( Parent.class, 1L ); + assertNotNull( parent ); + assertEquals( 1, parent.children.size() ); + assertEquals( "Child A", parent.children.get( 0 ).name ); + } + + // At revAddChild: parent has 2 children (A v2 + B) + try (var s = sf.withOptions().atTransaction( revAddChild ).openSession()) { + var parent = s.find( Parent.class, 1L ); + assertNotNull( parent ); + assertEquals( 2, parent.children.size() ); + var names = parent.children.stream().map( c -> c.name ).sorted().toList(); + assertEquals( List.of( "Child A v2", "Child B" ), names ); + } + + // At revRemove: parent has 1 child (B only) + try (var s = sf.withOptions().atTransaction( revRemove ).openSession()) { + var parent = s.find( Parent.class, 1L ); + assertNotNull( parent ); + assertEquals( 1, parent.children.size() ); + assertEquals( "Child B", parent.children.get( 0 ).name ); + } + } + + // --- getHistory --- + + @Test + @Order(3) + void testGetHistory(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // Child A: 3 revisions (ADD, MOD, DEL) + var history = auditLog.getHistory( Child.class, 1L ); + assertEquals( 3, history.size() ); + assertEquals( "Child A", history.get( 0 ).entity().name ); + assertNotNull( history.get( 0 ).entity().parent ); + assertEquals( "Parent", history.get( 0 ).entity().parent.name ); + assertEquals( "Child A v2", history.get( 1 ).entity().name ); + assertNotNull( history.get( 2 ).entity() ); + + // Parent: only 1 revision (inverse side) + assertEquals( 1, auditLog.getHistory( Parent.class, 1L ).size() ); + } + } + + // --- Recreate scenario --- + + @Test + @Order(4) + void testPointInTimeReadAfterRecreate(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + try (var auditLog = AuditLogFactory.create( sf )) { + assertEquals( 1, auditLog.getRevisions( Parent.class, 10L ).size(), + "Parent should have 1 revision (inverse side)" ); + } + + // At revRecCreate: 2 children (A + B) + try (var s = sf.withOptions().atTransaction( revRecCreate ).openSession()) { + var parent = s.find( Parent.class, 10L ); + assertNotNull( parent ); + assertEquals( 2, parent.children.size() ); + } + + // At revRecReplace: 2 children (B + C, A removed) + try (var s = sf.withOptions().atTransaction( revRecReplace ).openSession()) { + var parent = s.find( Parent.class, 10L ); + assertNotNull( parent ); + assertEquals( 2, parent.children.size() ); + var names = parent.children.stream().map( c -> c.name ).sorted().toList(); + assertEquals( List.of( "Rec Child B", "Rec Child C" ), names ); + } + } + + // --- Property update --- + + @Test + @Order(5) + void testChildPropertyUpdate(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + try (var auditLog = AuditLogFactory.create( sf )) { + assertEquals( 2, auditLog.getRevisions( Child.class, 20L ).size(), + "Child should have 2 revisions (ADD + property update)" ); + assertEquals( 1, auditLog.getRevisions( Parent.class, 20L ).size(), + "Parent should still have 1 revision" ); + } + + try (var s = sf.withOptions().atTransaction( revUpdMod ).openSession()) { + var parent = s.find( Parent.class, 20L ); + assertNotNull( parent ); + assertEquals( 1, parent.children.size() ); + assertEquals( "Upd Child A v2", parent.children.get( 0 ).name ); + } + } + + // ---- Entity classes ---- + + @Audited + @Entity(name = "Parent") + static class Parent { + @Id + long id; + String name; + @OneToMany(mappedBy = "parent") + List children = new ArrayList<>(); + + Parent() { + } + + Parent(long id, String name) { + this.id = id; + this.name = name; + } + } + + @Audited + @Entity(name = "Child") + static class Child { + @Id + long id; + String name; + @ManyToOne + Parent parent; + + Child() { + } + + Child(long id, String name, Parent parent) { + this.id = id; + this.name = name; + this.parent = parent; + } + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/temporal/audit/collection/AuditElementCollectionTest.java b/hibernate-core/src/test/java/org/hibernate/temporal/audit/collection/AuditElementCollectionTest.java new file mode 100644 index 000000000000..7e010dcaa99f --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/temporal/audit/collection/AuditElementCollectionTest.java @@ -0,0 +1,511 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.temporal.audit.collection; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; + +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Embeddable; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.MapKeyColumn; +import jakarta.persistence.OrderColumn; +import jakarta.persistence.Tuple; +import org.hibernate.annotations.Audited; +import org.hibernate.audit.AuditLog; +import org.hibernate.audit.AuditLogFactory; +import org.hibernate.annotations.SortNatural; +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.BeforeClassTemplate; +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.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; + +/** + * Tests @Audited element collections: indexed lists, maps, embeddable sets. + */ +@AuditedTest +@SessionFactory +@DomainModel(annotatedClasses = { + AuditElementCollectionTest.ListEntity.class, + AuditElementCollectionTest.MapEntity.class, + AuditElementCollectionTest.EmbeddableSetEntity.class, + AuditElementCollectionTest.ArrayEntity.class, + AuditElementCollectionTest.SortedSetEntity.class +}) +@ServiceRegistry(settings = @Setting(name = StateManagementSettings.TRANSACTION_ID_SUPPLIER, + value = "org.hibernate.temporal.audit.collection.AuditElementCollectionTest$TxIdSupplier")) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class AuditElementCollectionTest { + private static int currentTxId; + + public static class TxIdSupplier implements TransactionIdentifierSupplier { + @Override + public Integer generateTransactionIdentifier(SharedSessionContract session) { + return ++currentTxId; + } + } + + // List scenario (IDs 1-9) + private int revListCreate; // ListEntity(1) with [alpha, beta] + private int revListMod; // add gamma, remove alpha + + // Map scenario (IDs 1-9, separate entity type) + private int revMapCreate; // MapEntity(1) with {key1=value1, key2=value2} + private int revMapMod; // update key1, add key3, remove key2 + + // Embeddable set scenario (IDs 1-9, separate entity type) + private int revEmbCreate; // EmbeddableSetEntity(1) with {Alice/90, Bob/85} + private int revEmbMod; // remove Alice, add Charlie/95 + + // Array scenario (IDs 1-9, separate entity type) + private int revArrCreate; // ArrayEntity(1) with [alpha, beta] + private int revArrMod; // replace to [gamma, beta, delta] + + // SortedSet scenario (IDs 1-9, separate entity type) + private int revSsCreate; // SortedSetEntity(1) with {alpha, beta} + private int revSsMod; // remove alpha, add gamma + + @BeforeClassTemplate + void initData(SessionFactoryScope scope) { + currentTxId = 0; + final var sf = scope.getSessionFactory(); + + // --- List with @OrderColumn --- + + sf.inTransaction( session -> { + var e = new ListEntity( 1L ); + e.strings.add( "alpha" ); + e.strings.add( "beta" ); + session.persist( e ); + } ); + revListCreate = currentTxId; + + sf.inTransaction( session -> { + var e = session.find( ListEntity.class, 1L ); + e.strings.add( "gamma" ); + e.strings.remove( 0 ); + } ); + revListMod = currentTxId; + + // --- Map --- + + sf.inTransaction( session -> { + var e = new MapEntity( 1L ); + e.strings.put( "key1", "value1" ); + e.strings.put( "key2", "value2" ); + session.persist( e ); + } ); + revMapCreate = currentTxId; + + sf.inTransaction( session -> { + var e = session.find( MapEntity.class, 1L ); + e.strings.put( "key1", "updated1" ); + e.strings.put( "key3", "value3" ); + e.strings.remove( "key2" ); + } ); + revMapMod = currentTxId; + + // --- Set --- + + sf.inTransaction( session -> { + var e = new EmbeddableSetEntity( 1L ); + e.components.add( new Component( "Alice", 90 ) ); + e.components.add( new Component( "Bob", 85 ) ); + session.persist( e ); + } ); + revEmbCreate = currentTxId; + + sf.inTransaction( session -> { + var e = session.find( EmbeddableSetEntity.class, 1L ); + e.components.removeIf( c -> c.name.equals( "Alice" ) ); + e.components.add( new Component( "Charlie", 95 ) ); + } ); + revEmbMod = currentTxId; + + // --- String array with @OrderColumn --- + + sf.inTransaction( session -> { + var e = new ArrayEntity( 1L ); + e.strings = new String[] {"alpha", "beta"}; + session.persist( e ); + } ); + revArrCreate = currentTxId; + + sf.inTransaction( session -> { + var e = session.find( ArrayEntity.class, 1L ); + e.strings = new String[] {"gamma", "beta", "delta"}; + } ); + revArrMod = currentTxId; + + // --- SortedSet with @SortNatural --- + + sf.inTransaction( session -> { + var e = new SortedSetEntity( 1L ); + e.tags.add( "beta" ); + e.tags.add( "alpha" ); + session.persist( e ); + } ); + revSsCreate = currentTxId; + + sf.inTransaction( session -> { + var e = session.find( SortedSetEntity.class, 1L ); + e.tags.remove( "alpha" ); + e.tags.add( "gamma" ); + } ); + revSsMod = currentTxId; + } + + // ---- List with @OrderColumn ---- + + @Test + @Order(1) + void testIndexedList(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + assertThat( auditLog.getRevisions( ListEntity.class, 1L ) ).hasSize( 2 ); + } + + // At revListCreate: [alpha, beta] + try (var s = scope.getSessionFactory().withOptions().atTransaction( revListCreate ).openSession()) { + var e = s.find( ListEntity.class, 1L ); + assertThat( e ).isNotNull(); + assertThat( e.strings ).containsExactly( "alpha", "beta" ); + } + + // At revListMod: [beta, gamma] (alpha removed, gamma added) + try (var s = scope.getSessionFactory().withOptions().atTransaction( revListMod ).openSession()) { + var e = s.find( ListEntity.class, 1L ); + assertThat( e ).isNotNull(); + assertThat( e.strings ).containsExactly( "beta", "gamma" ); + } + + // Verify DEL audit rows store both index and element value + scope.inSession( session -> { + var delRows = session.createNativeQuery( + "select strings, strings_ORDER from ListEntity_strings_AUD" + + " where REVTYPE = 2 order by strings_ORDER", Tuple.class + ).getResultList(); + assertThat( delRows ).hasSizeGreaterThanOrEqualTo( 1 ); + assertThat( delRows ).anySatisfy( row -> { + assertThat( row.get( "strings" ) ).isEqualTo( "alpha" ); + assertThat( row.get( "strings_ORDER" ) ).isEqualTo( 0 ); + } ); + } ); + } + + // ---- Map ---- + + @Test + @Order(2) + void testStringMap(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + assertThat( auditLog.getRevisions( MapEntity.class, 1L ) ).hasSize( 2 ); + } + + // At revMapCreate: {key1=value1, key2=value2} + try (var s = scope.getSessionFactory().withOptions().atTransaction( revMapCreate ).openSession()) { + var e = s.find( MapEntity.class, 1L ); + assertThat( e ).isNotNull(); + assertThat( e.strings ).containsEntry( "key1", "value1" ) + .containsEntry( "key2", "value2" ) + .hasSize( 2 ); + } + + // At revMapMod: {key1=updated1, key3=value3} + try (var s = scope.getSessionFactory().withOptions().atTransaction( revMapMod ).openSession()) { + var e = s.find( MapEntity.class, 1L ); + assertThat( e ).isNotNull(); + assertThat( e.strings ).containsEntry( "key1", "updated1" ) + .containsEntry( "key3", "value3" ) + .hasSize( 2 ); + } + + // Verify DEL audit rows store both key and value + scope.inSession( session -> { + var delRows = session.createNativeQuery( + "select strings_KEY, strings from MapEntity_strings_AUD" + + " where REVTYPE = 2 order by strings_KEY", Tuple.class + ).getResultList(); + // key1 value update -> DEL old + ADD new + assertThat( delRows ).anySatisfy( row -> { + assertThat( row.get( "strings_KEY" ) ).isEqualTo( "key1" ); + assertThat( row.get( "strings" ) ).isEqualTo( "value1" ); + } ); + // key2 removal -> DEL with full entry + assertThat( delRows ).anySatisfy( row -> { + assertThat( row.get( "strings_KEY" ) ).isEqualTo( "key2" ); + assertThat( row.get( "strings" ) ).isEqualTo( "value2" ); + } ); + } ); + } + + // ---- Set ---- + + @Test + @Order(3) + void testEmbeddableSet(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + assertThat( auditLog.getRevisions( EmbeddableSetEntity.class, 1L ) ).hasSize( 2 ); + } + + // At revEmbCreate: {Alice/90, Bob/85} + try (var s = scope.getSessionFactory().withOptions().atTransaction( revEmbCreate ).openSession()) { + var e = s.find( EmbeddableSetEntity.class, 1L ); + assertThat( e ).isNotNull(); + assertThat( e.components ).extracting( c -> c.name ) + .containsExactlyInAnyOrder( "Alice", "Bob" ); + } + + // At revEmbMod: {Bob/85, Charlie/95} + try (var s = scope.getSessionFactory().withOptions().atTransaction( revEmbMod ).openSession()) { + var e = s.find( EmbeddableSetEntity.class, 1L ); + assertThat( e ).isNotNull(); + assertThat( e.components ).extracting( c -> c.name ) + .containsExactlyInAnyOrder( "Bob", "Charlie" ); + } + + // Verify DEL audit rows store the full embeddable (name + score) + scope.inSession( session -> { + var delRows = session.createNativeQuery( + "select name_col as name, score from EmbeddableSetEntity_components_AUD" + + " where REVTYPE = 2", Tuple.class + ).getResultList(); + assertThat( delRows ).hasSize( 1 ); + assertThat( delRows.get( 0 ).get( "name" ) ).isEqualTo( "Alice" ); + assertThat( delRows.get( 0 ).get( "score" ) ).isEqualTo( 90 ); + } ); + } + + // ---- String array with @OrderColumn ---- + + @Test + @Order(4) + void testStringArray(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + assertThat( auditLog.getRevisions( ArrayEntity.class, 1L ) ).hasSize( 2 ); + } + + // At revArrCreate: [alpha, beta] + try (var s = scope.getSessionFactory().withOptions().atTransaction( revArrCreate ).openSession()) { + var e = s.find( ArrayEntity.class, 1L ); + assertThat( e ).isNotNull(); + assertThat( e.strings ).containsExactly( "alpha", "beta" ); + } + + // At revArrMod: [gamma, beta, delta] + try (var s = scope.getSessionFactory().withOptions().atTransaction( revArrMod ).openSession()) { + var e = s.find( ArrayEntity.class, 1L ); + assertThat( e ).isNotNull(); + assertThat( e.strings ).containsExactly( "gamma", "beta", "delta" ); + } + + // Verify diff: "beta" unchanged at index 1, no audit rows for it in revArrMod. + scope.inSession( session -> { + var rev2Rows = session.createNativeQuery( + "select strings, strings_ORDER, REVTYPE from ArrayEntity_strings_AUD" + + " where REV = " + revArrMod + " order by strings_ORDER, REVTYPE", Tuple.class + ).getResultList(); + assertThat( rev2Rows ).noneMatch( row -> "beta".equals( row.get( "strings" ) ) ); + } ); + } + + // ---- SortedSet with @SortNatural ---- + + @Test + @Order(5) + void testSortedSet(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + assertThat( auditLog.getRevisions( SortedSetEntity.class, 1L ) ).hasSize( 2 ); + } + + // At revSsCreate: {alpha, beta} (sorted) + try (var s = scope.getSessionFactory().withOptions().atTransaction( revSsCreate ).openSession()) { + var e = s.find( SortedSetEntity.class, 1L ); + assertThat( e ).isNotNull(); + assertThat( e.tags ).containsExactly( "alpha", "beta" ); + } + + // At revSsMod: {beta, gamma} (sorted) + try (var s = scope.getSessionFactory().withOptions().atTransaction( revSsMod ).openSession()) { + var e = s.find( SortedSetEntity.class, 1L ); + assertThat( e ).isNotNull(); + assertThat( e.tags ).containsExactly( "beta", "gamma" ); + } + } + + // --- ALL_REVISIONS collection isolation --- + + @Test + @Order(6) + void testCollectionRevisionIsolation(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + try (var s = sf.withOptions().atTransaction( AuditLog.ALL_REVISIONS ).openSession()) { + var entities = s.createSelectionQuery( "from ArrayEntity where id = :id", ArrayEntity.class ) + .setParameter( "id", 1L ) + .getResultList(); + // revArrCreate([alpha,beta] size 2) + revArrMod([gamma,beta,delta] size 3) = 2 revisions + assertEquals( 2, entities.size(), "Expected 2 revisions" ); + + // Find revisions with different collection sizes + ArrayEntity entityWith3 = null; + ArrayEntity entityWith2 = null; + for ( var e : entities ) { + int size = e.strings.length; + if ( size == 3 && entityWith3 == null ) { + entityWith3 = e; + } + else if ( size == 2 && entityWith2 == null ) { + entityWith2 = e; + } + } + assertNotNull( entityWith3, "Should find a revision with 3 elements" ); + assertNotNull( entityWith2, "Should find a revision with 2 elements" ); + + // Collections must be distinct instances across revisions + assertNotSame( entityWith2.strings, entityWith3.strings, + "Collections at different revisions must not be the same instance" ); + + // Verify contents + assertEquals( 3, entityWith3.strings.length ); + assertEquals( 2, entityWith2.strings.length ); + } + } + + // ---- Entity classes ---- + + @Audited + @Entity(name = "ListEntity") + static class ListEntity { + @Id + long id; + @ElementCollection + @OrderColumn + List strings = new ArrayList<>(); + + ListEntity() { + } + + ListEntity(long id) { + this.id = id; + } + } + + @Audited + @Entity(name = "MapEntity") + static class MapEntity { + @Id + long id; + @ElementCollection + @MapKeyColumn(nullable = false) + Map strings = new HashMap<>(); + + MapEntity() { + } + + MapEntity(long id) { + this.id = id; + } + } + + @Audited + @Entity(name = "EmbeddableSetEntity") + static class EmbeddableSetEntity { + @Id + long id; + @ElementCollection + Set components = new HashSet<>(); + + EmbeddableSetEntity() { + } + + EmbeddableSetEntity(long id) { + this.id = id; + } + } + + @Audited + @Entity(name = "ArrayEntity") + static class ArrayEntity { + @Id + long id; + @ElementCollection + @OrderColumn + String[] strings; + + ArrayEntity() { + } + + ArrayEntity(long id) { + this.id = id; + } + } + + @Audited + @Entity(name = "SortedSetEntity") + static class SortedSetEntity { + @Id + long id; + @ElementCollection + @SortNatural + SortedSet tags = new TreeSet<>(); + + SortedSetEntity() { + } + + SortedSetEntity(long id) { + this.id = id; + } + } + + @Embeddable + static class Component { + @Column(name = "name_col") + String name; + int score; + + Component() { + } + + Component(String name, int score) { + this.name = name; + this.score = score; + } + + @Override + public boolean equals(Object o) { + return o instanceof Component c && Objects.equals( name, c.name ) && score == c.score; + } + + @Override + public int hashCode() { + return Objects.hash( name, score ); + } + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/temporal/audit/collection/AuditManyToManyTest.java b/hibernate-core/src/test/java/org/hibernate/temporal/audit/collection/AuditManyToManyTest.java new file mode 100644 index 000000000000..e9742bfcfaa9 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/temporal/audit/collection/AuditManyToManyTest.java @@ -0,0 +1,296 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.temporal.audit.collection; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToMany; +import org.hibernate.annotations.Audited; +import org.hibernate.audit.AuditLog; +import org.hibernate.audit.AuditLogFactory; +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.BeforeClassTemplate; +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.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; + +/** + * Tests unidirectional @ManyToMany auditing (join table). + */ +@AuditedTest +@SessionFactory +@DomainModel(annotatedClasses = { + AuditManyToManyTest.Student.class, + AuditManyToManyTest.Course.class +}) +@ServiceRegistry(settings = @Setting(name = StateManagementSettings.TRANSACTION_ID_SUPPLIER, + value = "org.hibernate.temporal.audit.collection.AuditManyToManyTest$TxIdSupplier")) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class AuditManyToManyTest { + private static int currentTxId; + + public static class TxIdSupplier implements TransactionIdentifierSupplier { + @Override + public Integer generateTransactionIdentifier(SharedSessionContract session) { + return ++currentTxId; + } + } + + // Shared lifecycle: Student(1) + courses, add/remove/delete + private int revCreate; // Student(1) + Course(1, "Math") + private int revAdd; // add Course(2, "Physics") + private int revDrop; // drop Course(1) + private int revDelete; // delete Student(1) + + // Recreate scenario (IDs 10-19) + private int revRecCreate; // Student(10) + Course(10)+Course(11) + private int revRecReplace; // clear, re-add Course(11) + new Course(12) + + @BeforeClassTemplate + void initData(SessionFactoryScope scope) { + currentTxId = 0; + final var sf = scope.getSessionFactory(); + + // --- Shared lifecycle --- + + // Rev 1: student enrolled in one course + sf.inTransaction( session -> { + var course = new Course( 1L, "Math" ); + session.persist( course ); + var student = new Student( 1L, "Alice" ); + student.courses.add( course ); + session.persist( student ); + } ); + revCreate = currentTxId; + + // Rev 2: enroll in second course + sf.inTransaction( session -> { + var course = new Course( 2L, "Physics" ); + session.persist( course ); + var student = session.find( Student.class, 1L ); + student.courses.add( course ); + } ); + revAdd = currentTxId; + + // Rev 3: drop first course + sf.inTransaction( session -> { + var student = session.find( Student.class, 1L ); + student.courses.removeIf( c -> c.id == 1L ); + } ); + revDrop = currentTxId; + + // Rev 4: delete student (bulk removal of collection) + sf.inTransaction( session -> { + var student = session.find( Student.class, 1L ); + session.remove( student ); + } ); + revDelete = currentTxId; + + // --- Recreate scenario --- + + // Rev 5: student enrolled in Math + Physics + sf.inTransaction( session -> { + var c1 = new Course( 10L, "Rec Math" ); + var c2 = new Course( 11L, "Rec Physics" ); + session.persist( c1 ); + session.persist( c2 ); + var student = new Student( 10L, "Rec Alice" ); + student.courses.add( c1 ); + student.courses.add( c2 ); + session.persist( student ); + } ); + revRecCreate = currentTxId; + + // Rev 6: recreate: clear and re-add only Physics + new Chemistry + sf.inTransaction( session -> { + var c3 = new Course( 12L, "Rec Chemistry" ); + session.persist( c3 ); + var student = session.find( Student.class, 10L ); + student.courses.clear(); + student.courses.add( session.find( Course.class, 11L ) ); + student.courses.add( c3 ); + } ); + revRecReplace = currentTxId; + } + + // --- Write side verification --- + + @Test + @Order(1) + void testWriteSide(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // Student: ADD + 2 collection changes + DEL = 4 revisions + assertEquals( 4, auditLog.getRevisions( Student.class, 1L ).size(), + "Student should have 4 revisions (ADD + 2 collection changes + DEL)" ); + assertEquals( 1, auditLog.getRevisions( Course.class, 1L ).size() ); + assertEquals( 1, auditLog.getRevisions( Course.class, 2L ).size() ); + } + } + + // --- Point-in-time reads --- + + @Test + @Order(2) + void testPointInTimeRead(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + // At revCreate: 1 course + try (var s = sf.withOptions().atTransaction( revCreate ).openSession()) { + var student = s.find( Student.class, 1L ); + assertNotNull( student ); + assertEquals( 1, student.courses.size(), "At revCreate, student should have 1 course" ); + assertEquals( "Math", student.courses.get( 0 ).name ); + } + + // At revAdd: 2 courses + try (var s = sf.withOptions().atTransaction( revAdd ).openSession()) { + var student = s.find( Student.class, 1L ); + assertNotNull( student ); + assertEquals( 2, student.courses.size(), "At revAdd, student should have 2 courses" ); + } + + // At revDrop: 1 course (Physics only) + try (var s = sf.withOptions().atTransaction( revDrop ).openSession()) { + var student = s.find( Student.class, 1L ); + assertNotNull( student ); + assertEquals( 1, student.courses.size(), "At revDrop, student should have 1 course" ); + assertEquals( "Physics", student.courses.get( 0 ).name ); + } + } + + // --- getHistory --- + + @Test + @Order(3) + void testGetHistory(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + var history = auditLog.getHistory( Student.class, 1L ); + assertEquals( 4, history.size(), "Student has ADD + 2 collection changes + DEL" ); + assertEquals( "Alice", history.get( 0 ).entity().name ); + } + } + + // --- Recreate scenario --- + + @Test + @Order(4) + void testPointInTimeReadAfterRecreate(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + // Student: ADD + recreate = 2 revisions (not more) + try (var auditLog = AuditLogFactory.create( sf )) { + assertEquals( 2, auditLog.getRevisions( Student.class, 10L ).size(), + "Student should have exactly 2 revisions (ADD + recreate)" ); + } + + // At revRecCreate: 2 courses (Math + Physics) + try (var s = sf.withOptions().atTransaction( revRecCreate ).openSession()) { + var student = s.find( Student.class, 10L ); + assertNotNull( student ); + assertEquals( 2, student.courses.size(), "At revRecCreate, student should have 2 courses" ); + } + + // At revRecReplace: 2 courses (Physics + Chemistry, Math dropped) + try (var s = sf.withOptions().atTransaction( revRecReplace ).openSession()) { + var student = s.find( Student.class, 10L ); + assertNotNull( student ); + assertEquals( 2, student.courses.size(), "At revRecReplace, student should have 2 courses" ); + var names = student.courses.stream().map( c -> c.name ).sorted().toList(); + assertEquals( List.of( "Rec Chemistry", "Rec Physics" ), names ); + } + } + + // --- ALL_REVISIONS collection isolation --- + + @Test + @Order(5) + void testCollectionRevisionIsolation(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + try (var s = sf.withOptions().atTransaction( AuditLog.ALL_REVISIONS ).openSession()) { + var students = s.createSelectionQuery( "from Student where id = :id", Student.class ) + .setParameter( "id", 1L ) + .getResultList(); + // revCreate(1 course) + revAdd(2 courses) + revDrop(1 course) + revDelete(DEL) = 4 revisions + assertEquals( 4, students.size(), "Expected 4 revisions including DEL" ); + + // Find revisions with different collection sizes + Student studentWith2 = null; + Student studentWith1 = null; + for ( var st : students ) { + int size = st.courses.size(); + if ( size == 2 && studentWith2 == null ) { + studentWith2 = st; + } + else if ( size == 1 && studentWith1 == null ) { + studentWith1 = st; + } + } + assertNotNull( studentWith2, "Should find a revision with 2 courses" ); + assertNotNull( studentWith1, "Should find a revision with 1 course" ); + + // Collections must be distinct instances across revisions + assertNotSame( studentWith1.courses, studentWith2.courses, + "Collections at different revisions must not be the same instance" ); + + // Verify contents + assertEquals( 2, studentWith2.courses.size() ); + assertEquals( 1, studentWith1.courses.size() ); + } + } + + // ---- Entity classes ---- + + @Audited + @Entity(name = "Student") + static class Student { + @Id + long id; + String name; + @ManyToMany + List courses = new ArrayList<>(); + + Student() { + } + + Student(long id, String name) { + this.id = id; + this.name = name; + } + } + + @Audited + @Entity(name = "Course") + static class Course { + @Id + long id; + String name; + + Course() { + } + + Course(long id, String name) { + this.id = id; + this.name = name; + } + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/temporal/audit/collection/AuditUnidirectionalOneToManyJoinColumnTest.java b/hibernate-core/src/test/java/org/hibernate/temporal/audit/collection/AuditUnidirectionalOneToManyJoinColumnTest.java new file mode 100644 index 000000000000..d22707bf5c34 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/temporal/audit/collection/AuditUnidirectionalOneToManyJoinColumnTest.java @@ -0,0 +1,354 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.temporal.audit.collection; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import org.hibernate.annotations.Audited; +import org.hibernate.audit.AuditLog; +import org.hibernate.audit.AuditLogFactory; +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.BeforeClassTemplate; +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.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; + +/** + * Tests unidirectional @OneToMany with @JoinColumn auditing. + */ +@AuditedTest +@SessionFactory +@DomainModel(annotatedClasses = { + AuditUnidirectionalOneToManyJoinColumnTest.Department.class, + AuditUnidirectionalOneToManyJoinColumnTest.Employee.class +}) +@ServiceRegistry(settings = @Setting(name = StateManagementSettings.TRANSACTION_ID_SUPPLIER, + value = "org.hibernate.temporal.audit.collection.AuditUnidirectionalOneToManyJoinColumnTest$TxIdSupplier")) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class AuditUnidirectionalOneToManyJoinColumnTest { + private static int currentTxId; + + public static class TxIdSupplier implements TransactionIdentifierSupplier { + @Override + public Integer generateTransactionIdentifier(SharedSessionContract session) { + return ++currentTxId; + } + } + + // Shared lifecycle: Department(1) + employees, add/remove/delete + private int revCreate; // Department(1) + Employee(1, "Alice") + private int revAdd; // add Employee(2, "Bob") + private int revRemove; // remove Employee(1) + private int revDelete; // delete Department(1) + + // Recreate scenario (IDs 10-19) + private int revRecCreate; // Department(10) + Employee(10)+Employee(11) + private int revRecReplace; // clear, re-add Employee(11) + new Employee(12) + + // Property update scenario (IDs 20-29) + private int revUpdCreate; // Department(20) + Employee(20) + private int revUpdMod; // update Employee(20) name only + + @BeforeClassTemplate + void initData(SessionFactoryScope scope) { + currentTxId = 0; + final var sf = scope.getSessionFactory(); + + // --- Shared lifecycle --- + + // Rev 1: department + one employee + sf.inTransaction( session -> { + var emp = new Employee( 1L, "Alice" ); + session.persist( emp ); + var dept = new Department( 1L, "Engineering" ); + dept.employees.add( emp ); + session.persist( dept ); + } ); + revCreate = currentTxId; + + // Rev 2: add second employee + sf.inTransaction( session -> { + var emp = new Employee( 2L, "Bob" ); + session.persist( emp ); + var dept = session.find( Department.class, 1L ); + dept.employees.add( emp ); + } ); + revAdd = currentTxId; + + // Rev 3: remove first employee from department + sf.inTransaction( session -> { + var dept = session.find( Department.class, 1L ); + dept.employees.removeIf( e -> e.id == 1L ); + } ); + revRemove = currentTxId; + + // Rev 4: delete department + sf.inTransaction( session -> { + var dept = session.find( Department.class, 1L ); + session.remove( dept ); + } ); + revDelete = currentTxId; + + // --- Recreate scenario --- + + // Rev 5: department with Alice + Bob + sf.inTransaction( session -> { + var e1 = new Employee( 10L, "Rec Alice" ); + var e2 = new Employee( 11L, "Rec Bob" ); + session.persist( e1 ); + session.persist( e2 ); + var dept = new Department( 10L, "Rec Engineering" ); + dept.employees.add( e1 ); + dept.employees.add( e2 ); + session.persist( dept ); + } ); + revRecCreate = currentTxId; + + // Rev 6: recreate: clear and re-add Bob + new Charlie + sf.inTransaction( session -> { + var e3 = new Employee( 12L, "Rec Charlie" ); + session.persist( e3 ); + var dept = session.find( Department.class, 10L ); + dept.employees.clear(); + dept.employees.add( session.find( Employee.class, 11L ) ); + dept.employees.add( e3 ); + } ); + revRecReplace = currentTxId; + + // --- Property update scenario --- + + // Rev 7: department + employee + sf.inTransaction( session -> { + var emp = new Employee( 20L, "Upd Alice" ); + session.persist( emp ); + var dept = new Department( 20L, "Upd Engineering" ); + dept.employees.add( emp ); + session.persist( dept ); + } ); + revUpdCreate = currentTxId; + + // Rev 8: update employee name (no collection change) + sf.inTransaction( session -> { + var emp = session.find( Employee.class, 20L ); + emp.name = "Upd Alice v2"; + } ); + revUpdMod = currentTxId; + } + + // --- Write side verification --- + + @Test + @Order(1) + void testWriteSide(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // Department: REV 1 (ADD) + REV 2 (collection change) + REV 3 (collection change) + REV 4 (DEL) + var deptRevs = auditLog.getRevisions( Department.class, 1L ); + assertEquals( 4, deptRevs.size(), + "Department should have 4 revisions (ADD + 2 collection changes + DEL)" ); + + // Employees: only ADD revisions (FK changes tracked on parent side, not child) + assertEquals( 1, auditLog.getRevisions( Employee.class, 1L ).size(), + "Employee 1 should have 1 revision (ADD only)" ); + assertEquals( 1, auditLog.getRevisions( Employee.class, 2L ).size(), + "Employee 2 should have 1 revision (ADD only)" ); + } + } + + // --- Point-in-time reads --- + + @Test + @Order(2) + void testPointInTimeRead(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + // At revCreate: department should have 1 employee (Alice) + try (var s = sf.withOptions().atTransaction( revCreate ).openSession()) { + var dept = s.find( Department.class, 1L ); + assertNotNull( dept ); + assertEquals( 1, dept.employees.size(), "At revCreate, department should have 1 employee" ); + assertEquals( "Alice", dept.employees.get( 0 ).name ); + } + + // At revAdd: department should have 2 employees + try (var s = sf.withOptions().atTransaction( revAdd ).openSession()) { + var dept = s.find( Department.class, 1L ); + assertNotNull( dept ); + assertEquals( 2, dept.employees.size(), "At revAdd, department should have 2 employees" ); + var names = dept.employees.stream().map( e -> e.name ).sorted().toList(); + assertEquals( List.of( "Alice", "Bob" ), names ); + } + + // At revRemove: department should have 1 employee (Bob only) + try (var s = sf.withOptions().atTransaction( revRemove ).openSession()) { + var dept = s.find( Department.class, 1L ); + assertNotNull( dept ); + assertEquals( 1, dept.employees.size(), "At revRemove, department should have 1 employee" ); + assertEquals( "Bob", dept.employees.get( 0 ).name ); + } + } + + // --- getHistory --- + + @Test + @Order(3) + void testGetHistory(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + var history = auditLog.getHistory( Department.class, 1L ); + // Department: ADD + MOD (collection change) + MOD (collection change) + DEL = 4 revisions + assertEquals( 4, history.size(), "Department should have 4 history entries" ); + assertEquals( "Engineering", history.get( 0 ).entity().name ); + } + } + + // --- Recreate scenario --- + + @Test + @Order(4) + void testPointInTimeReadAfterRecreate(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + // Department: ADD + recreate = 2 revisions (not more) + try (var auditLog = AuditLogFactory.create( sf )) { + assertEquals( 2, auditLog.getRevisions( Department.class, 10L ).size(), + "Department should have exactly 2 revisions (ADD + recreate)" ); + } + + // At revRecCreate: 2 employees + try (var s = sf.withOptions().atTransaction( revRecCreate ).openSession()) { + var dept = s.find( Department.class, 10L ); + assertNotNull( dept ); + assertEquals( 2, dept.employees.size(), "At revRecCreate, department should have 2 employees" ); + } + + // At revRecReplace: 2 employees (Bob + Charlie, Alice dropped) + try (var s = sf.withOptions().atTransaction( revRecReplace ).openSession()) { + var dept = s.find( Department.class, 10L ); + assertNotNull( dept ); + assertEquals( 2, dept.employees.size(), "At revRecReplace, department should have 2 employees" ); + var names = dept.employees.stream().map( e -> e.name ).sorted().toList(); + assertEquals( List.of( "Rec Bob", "Rec Charlie" ), names ); + } + } + + // --- Property update --- + + @Test + @Order(5) + void testChildPropertyUpdate(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + // Employee should have 2 revisions (ADD + MOD) + try (var auditLog = AuditLogFactory.create( sf )) { + assertEquals( 2, auditLog.getRevisions( Employee.class, 20L ).size(), + "Employee should have 2 revisions (ADD + property update)" ); + // Department: only 1 revision (initial persist), no collection change + assertEquals( 1, auditLog.getRevisions( Department.class, 20L ).size(), + "Department should still have 1 revision" ); + } + + // Point-in-time: employee name should reflect the update + try (var s = sf.withOptions().atTransaction( revUpdMod ).openSession()) { + var dept = s.find( Department.class, 20L ); + assertNotNull( dept ); + assertEquals( 1, dept.employees.size() ); + assertEquals( "Upd Alice v2", dept.employees.get( 0 ).name ); + } + } + + // --- ALL_REVISIONS collection isolation --- + + @Test + @Order(6) + void testCollectionRevisionIsolation(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + try (var s = sf.withOptions().atTransaction( AuditLog.ALL_REVISIONS ).openSession()) { + var departments = s.createSelectionQuery( "from Department where id = :id", Department.class ) + .setParameter( "id", 1L ) + .getResultList(); + // revCreate(1 emp) + revAdd(2 emps) + revRemove(1 emp) + revDelete(DEL) = 4 revisions + assertEquals( 4, departments.size(), "Expected 4 revisions including DEL" ); + + // Find revisions with different collection sizes + Department deptWith2 = null; + Department deptWith1 = null; + for ( var d : departments ) { + int size = d.employees.size(); + if ( size == 2 && deptWith2 == null ) { + deptWith2 = d; + } + else if ( size == 1 && deptWith1 == null ) { + deptWith1 = d; + } + } + assertNotNull( deptWith2, "Should find a revision with 2 employees" ); + assertNotNull( deptWith1, "Should find a revision with 1 employee" ); + + // Collections must be distinct instances across revisions + assertNotSame( deptWith1.employees, deptWith2.employees, + "Collections at different revisions must not be the same instance" ); + + // Verify contents + assertEquals( 2, deptWith2.employees.size() ); + assertEquals( 1, deptWith1.employees.size() ); + } + } + + // ---- Entity classes ---- + + @Audited + @Entity(name = "Department") + static class Department { + @Id + long id; + String name; + @OneToMany + @JoinColumn(name = "department_id") + List employees = new ArrayList<>(); + + Department() { + } + + Department(long id, String name) { + this.id = id; + this.name = name; + } + } + + @Audited + @Entity(name = "Employee") + static class Employee { + @Id + long id; + String name; + + Employee() { + } + + Employee(long id, String name) { + this.id = id; + this.name = name; + } + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/temporal/audit/collection/AuditUnidirectionalOneToManyJoinTableTest.java b/hibernate-core/src/test/java/org/hibernate/temporal/audit/collection/AuditUnidirectionalOneToManyJoinTableTest.java new file mode 100644 index 000000000000..35dc4193025d --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/temporal/audit/collection/AuditUnidirectionalOneToManyJoinTableTest.java @@ -0,0 +1,300 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.temporal.audit.collection; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.JoinTable; +import jakarta.persistence.OneToMany; +import org.hibernate.annotations.Audited; +import org.hibernate.audit.AuditLog; +import org.hibernate.audit.AuditLogFactory; +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.BeforeClassTemplate; +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.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; + +/** + * Tests unidirectional @OneToMany with @JoinTable auditing. + */ +@AuditedTest +@SessionFactory +@DomainModel(annotatedClasses = { + AuditUnidirectionalOneToManyJoinTableTest.Team.class, + AuditUnidirectionalOneToManyJoinTableTest.Player.class +}) +@ServiceRegistry(settings = @Setting(name = StateManagementSettings.TRANSACTION_ID_SUPPLIER, + value = "org.hibernate.temporal.audit.collection.AuditUnidirectionalOneToManyJoinTableTest$TxIdSupplier")) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class AuditUnidirectionalOneToManyJoinTableTest { + private static int currentTxId; + + public static class TxIdSupplier implements TransactionIdentifierSupplier { + @Override + public Integer generateTransactionIdentifier(SharedSessionContract session) { + return ++currentTxId; + } + } + + // Shared lifecycle: Team(1) + players, add/remove/delete + private int revCreate; // Team(1) + Player(1, "Alice") + private int revAdd; // add Player(2, "Bob") + private int revRemove; // remove Player(1) + private int revDelete; // delete Team(1) + + // Recreate scenario (IDs 10-19) + private int revRecCreate; // Team(10) + Player(10)+Player(11) + private int revRecReplace; // clear, re-add Player(11) + new Player(12) + + @BeforeClassTemplate + void initData(SessionFactoryScope scope) { + currentTxId = 0; + final var sf = scope.getSessionFactory(); + + // --- Shared lifecycle --- + + // Rev 1: team + one player + sf.inTransaction( session -> { + var player = new Player( 1L, "Alice" ); + session.persist( player ); + var team = new Team( 1L, "Red Team" ); + team.players.add( player ); + session.persist( team ); + } ); + revCreate = currentTxId; + + // Rev 2: add second player + sf.inTransaction( session -> { + var player = new Player( 2L, "Bob" ); + session.persist( player ); + var team = session.find( Team.class, 1L ); + team.players.add( player ); + } ); + revAdd = currentTxId; + + // Rev 3: remove first player from team + sf.inTransaction( session -> { + var team = session.find( Team.class, 1L ); + team.players.removeIf( p -> p.id == 1L ); + } ); + revRemove = currentTxId; + + // Rev 4: delete team (bulk removal of remaining players from collection) + sf.inTransaction( session -> { + var team = session.find( Team.class, 1L ); + session.remove( team ); + } ); + revDelete = currentTxId; + + // --- Recreate scenario --- + + // Rev 5: team with Alice + Bob + sf.inTransaction( session -> { + var p1 = new Player( 10L, "Rec Alice" ); + var p2 = new Player( 11L, "Rec Bob" ); + session.persist( p1 ); + session.persist( p2 ); + var team = new Team( 10L, "Rec Team" ); + team.players.add( p1 ); + team.players.add( p2 ); + session.persist( team ); + } ); + revRecCreate = currentTxId; + + // Rev 6: recreate: clear and re-add Bob + new Charlie + sf.inTransaction( session -> { + var p3 = new Player( 12L, "Rec Charlie" ); + session.persist( p3 ); + var team = session.find( Team.class, 10L ); + team.players.clear(); + team.players.add( session.find( Player.class, 11L ) ); + team.players.add( p3 ); + } ); + revRecReplace = currentTxId; + } + + // --- Write side verification --- + + @Test + @Order(1) + void testWriteSide(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + // Team: ADD + 2 collection changes + DEL = 4 revisions + assertEquals( 4, auditLog.getRevisions( Team.class, 1L ).size(), + "Team should have 4 revisions (ADD + 2 collection changes + DEL)" ); + assertEquals( 1, auditLog.getRevisions( Player.class, 1L ).size() ); + assertEquals( 1, auditLog.getRevisions( Player.class, 2L ).size() ); + } + } + + // --- Point-in-time reads --- + + @Test + @Order(2) + void testPointInTimeRead(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + // At revCreate: team should have 1 player (Alice) + try (var s = sf.withOptions().atTransaction( revCreate ).openSession()) { + var team = s.find( Team.class, 1L ); + assertNotNull( team ); + assertEquals( 1, team.players.size(), "At revCreate, team should have 1 player" ); + assertEquals( "Alice", team.players.get( 0 ).name ); + } + + // At revAdd: team should have 2 players + try (var s = sf.withOptions().atTransaction( revAdd ).openSession()) { + var team = s.find( Team.class, 1L ); + assertNotNull( team ); + assertEquals( 2, team.players.size(), "At revAdd, team should have 2 players" ); + } + + // At revRemove: 1 player (Bob only) + try (var s = sf.withOptions().atTransaction( revRemove ).openSession()) { + var team = s.find( Team.class, 1L ); + assertNotNull( team ); + assertEquals( 1, team.players.size(), "At revRemove, team should have 1 player" ); + assertEquals( "Bob", team.players.get( 0 ).name ); + } + } + + // --- getHistory --- + + @Test + @Order(3) + void testGetHistory(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + var history = auditLog.getHistory( Team.class, 1L ); + assertEquals( 4, history.size(), "Team has ADD + 2 collection changes + DEL" ); + assertEquals( "Red Team", history.get( 0 ).entity().name ); + } + } + + // --- Recreate scenario --- + + @Test + @Order(4) + void testPointInTimeReadAfterRecreate(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + // Team: ADD + recreate = 2 revisions (not more) + try (var auditLog = AuditLogFactory.create( sf )) { + assertEquals( 2, auditLog.getRevisions( Team.class, 10L ).size(), + "Team should have exactly 2 revisions (ADD + recreate)" ); + } + + // At revRecCreate: 2 players + try (var s = sf.withOptions().atTransaction( revRecCreate ).openSession()) { + var team = s.find( Team.class, 10L ); + assertNotNull( team ); + assertEquals( 2, team.players.size(), "At revRecCreate, team should have 2 players" ); + } + + // At revRecReplace: 2 players (Bob + Charlie, Alice dropped) + try (var s = sf.withOptions().atTransaction( revRecReplace ).openSession()) { + var team = s.find( Team.class, 10L ); + assertNotNull( team ); + assertEquals( 2, team.players.size(), "At revRecReplace, team should have 2 players" ); + var names = team.players.stream().map( p -> p.name ).sorted().toList(); + assertEquals( List.of( "Rec Bob", "Rec Charlie" ), names ); + } + } + + // --- ALL_REVISIONS collection isolation --- + + @Test + @Order(5) + void testCollectionRevisionIsolation(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + // Use separate point-in-time sessions within an ALL_REVISIONS session + // to verify collection isolation across revisions + try (var s = sf.withOptions().atTransaction( AuditLog.ALL_REVISIONS ).openSession()) { + var teams = s.createSelectionQuery( "from Team where id = :id", Team.class ) + .setParameter( "id", 1L ) + .getResultList(); + // revCreate + revAdd + revRemove + revDelete(DEL) = 4 revisions + assertEquals( 4, teams.size(), "Expected 4 revisions including DEL" ); + + // Find the revision with 2 players (revAdd) and one of the single-player revisions + Team teamWith2 = null; + Team teamWith1 = null; + for ( var t : teams ) { + int size = t.players.size(); + if ( size == 2 && teamWith2 == null ) { + teamWith2 = t; + } + else if ( size == 1 && teamWith1 == null ) { + teamWith1 = t; + } + } + assertNotNull( teamWith2, "Should find a revision with 2 players" ); + assertNotNull( teamWith1, "Should find a revision with 1 player" ); + + // Collections must be distinct instances across revisions + assertNotSame( teamWith1.players, teamWith2.players, + "Collections at different revisions must not be the same instance" ); + + // Verify contents + assertEquals( 2, teamWith2.players.size() ); + assertEquals( 1, teamWith1.players.size() ); + } + } + + // ---- Entity classes ---- + + @Audited + @Entity(name = "Team") + static class Team { + @Id + long id; + String name; + @OneToMany + @JoinTable + List players = new ArrayList<>(); + + Team() { + } + + Team(long id, String name) { + this.id = id; + this.name = name; + } + } + + @Audited + @Entity(name = "Player") + static class Player { + @Id + long id; + String name; + + Player() { + } + + Player(long id, String name) { + this.id = id; + this.name = name; + } + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/temporal/audit/inheritance/AuditJoinedDiscriminatorInheritanceTest.java b/hibernate-core/src/test/java/org/hibernate/temporal/audit/inheritance/AuditJoinedDiscriminatorInheritanceTest.java new file mode 100644 index 000000000000..69c85be2d5d7 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/temporal/audit/inheritance/AuditJoinedDiscriminatorInheritanceTest.java @@ -0,0 +1,394 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.temporal.audit.inheritance; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.persistence.DiscriminatorColumn; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Inheritance; +import jakarta.persistence.InheritanceType; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.ManyToOne; +import org.hibernate.annotations.Audited; +import org.hibernate.audit.AuditLogFactory; +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.BeforeClassTemplate; +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.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests @Audited with JOINED inheritance and an explicit discriminator column. + */ +@AuditedTest +@SessionFactory +@DomainModel(annotatedClasses = { + AuditJoinedDiscriminatorInheritanceTest.Vehicle.class, + AuditJoinedDiscriminatorInheritanceTest.Car.class, + AuditJoinedDiscriminatorInheritanceTest.SportsCar.class, + AuditJoinedDiscriminatorInheritanceTest.Truck.class, + AuditJoinedDiscriminatorInheritanceTest.Driver.class, + AuditJoinedDiscriminatorInheritanceTest.Team.class +}) +@ServiceRegistry(settings = @Setting(name = StateManagementSettings.TRANSACTION_ID_SUPPLIER, + value = "org.hibernate.temporal.audit.inheritance.AuditJoinedDiscriminatorInheritanceTest$TxIdSupplier")) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class AuditJoinedDiscriminatorInheritanceTest { + private static int currentTxId; + + public static class TxIdSupplier implements TransactionIdentifierSupplier { + @Override + public Integer generateTransactionIdentifier(SharedSessionContract session) { + return ++currentTxId; + } + + } + + // Shared lifecycle: SportsCar(1) + Truck(2), update, delete + private int revCreate; // SportsCar(1) + Truck(2) + private int revUpdate; // update SportsCar name + seatCount + private int revTruckUpd; // update Truck name + private int revDelete; // delete SportsCar(1) + + // Deep hierarchy: Car(30) + SportsCar(31), update + private int revDeepCreate; // Car(30) + SportsCar(31) + private int revDeepUpdate; // update SportsCar(31) + + // ToOne association: SportsCar(40) + Driver(41), update car + private int revToOneCreate; // SportsCar(40) + Driver(41) + private int revToOneUpdate; // update SportsCar(40) name + + // ManyToMany association: SportsCar(50) + Truck(51) + Team(52), update car + private int revM2mCreate; // SportsCar(50) + Truck(51) + Team(52) + private int revM2mUpdate; // update SportsCar(50) name + + @BeforeClassTemplate + void initData(SessionFactoryScope scope) { + currentTxId = 0; + final var sf = scope.getSessionFactory(); + + // --- Shared lifecycle (IDs 1-2) --- + + sf.inTransaction( session -> { + session.persist( new SportsCar( 1L, "Sedan", 5, 200 ) ); + session.persist( new Truck( 2L, "Hauler", 10.5 ) ); + } ); + revCreate = currentTxId; + + sf.inTransaction( session -> { + var car = session.find( SportsCar.class, 1L ); + car.name = "Sports Car"; + car.seatCount = 2; + } ); + revUpdate = currentTxId; + + sf.inTransaction( session -> session.find( Truck.class, 2L ).name = "Big Hauler" ); + revTruckUpd = currentTxId; + + sf.inTransaction( session -> session.remove( session.find( SportsCar.class, 1L ) ) ); + revDelete = currentTxId; + + // --- Deep hierarchy (IDs 30-31) --- + + sf.inTransaction( session -> { + session.persist( new Car( 30L, "Plain Car", 4 ) ); + session.persist( new SportsCar( 31L, "Ferrari", 2, 600 ) ); + } ); + revDeepCreate = currentTxId; + + sf.inTransaction( session -> { + var sc = session.find( SportsCar.class, 31L ); + sc.name = "Lamborghini"; + sc.horsepower = 700; + } ); + revDeepUpdate = currentTxId; + + // --- ToOne association (IDs 40-41) --- + + sf.inTransaction( session -> { + var car = new SportsCar( 40L, "Ferrari", 2, 600 ); + session.persist( car ); + session.persist( new Driver( 41L, "Lewis", car ) ); + } ); + revToOneCreate = currentTxId; + + sf.inTransaction( session -> session.find( SportsCar.class, 40L ).name = "Lamborghini" ); + revToOneUpdate = currentTxId; + + // --- ManyToMany association (IDs 50-52) --- + + sf.inTransaction( session -> { + var car = new SportsCar( 50L, "Ferrari", 2, 600 ); + var truck = new Truck( 51L, "Hauler", 10.5 ); + session.persist( car ); + session.persist( truck ); + var team = new Team( 52L, "Racing" ); + team.vehicles.add( car ); + team.vehicles.add( truck ); + session.persist( team ); + } ); + revM2mCreate = currentTxId; + + sf.inTransaction( session -> session.find( SportsCar.class, 50L ).name = "Lamborghini" ); + revM2mUpdate = currentTxId; + } + + @Test + @Order(1) + void testWriteSide(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + assertThat( auditLog.getRevisions( SportsCar.class, 1L ) ).hasSize( 3 ); + assertThat( auditLog.getRevisions( Truck.class, 2L ) ).hasSize( 2 ); + } + } + + @Test + @Order(2) + void testPointInTimeRead(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + // At revCreate: original values + try (var s = sf.withOptions().atTransaction( revCreate ).openSession()) { + var car = s.find( SportsCar.class, 1L ); + assertThat( car ).isNotNull(); + assertThat( car.name ).isEqualTo( "Sedan" ); + assertThat( car.seatCount ).isEqualTo( 5 ); + + var truck = s.find( Truck.class, 2L ); + assertThat( truck ).isNotNull(); + assertThat( truck.name ).isEqualTo( "Hauler" ); + assertThat( truck.payload ).isEqualTo( 10.5 ); + } + + // At revUpdate: car updated, truck unchanged. Polymorphic lookups + try (var s = sf.withOptions().atTransaction( revUpdate ).openSession()) { + var car = s.find( SportsCar.class, 1L ); + assertThat( car ).isNotNull(); + assertThat( car.name ).isEqualTo( "Sports Car" ); + assertThat( car.seatCount ).isEqualTo( 2 ); + + assertThat( s.find( Car.class, 1L ) ).isNotNull().extracting( v -> v.name ) + .isEqualTo( "Sports Car" ); + assertThat( s.find( Vehicle.class, 1L ) ).isNotNull().extracting( v -> v.name ) + .isEqualTo( "Sports Car" ); + + assertThat( s.find( Truck.class, 2L ) ).isNotNull() + .extracting( v -> v.name ).isEqualTo( "Hauler" ); + } + + // At revTruckUpd: truck name updated + try (var s = sf.withOptions().atTransaction( revTruckUpd ).openSession()) { + var truck = s.find( Truck.class, 2L ); + assertThat( truck ).isNotNull(); + assertThat( truck.name ).isEqualTo( "Big Hauler" ); + assertThat( truck.payload ).isEqualTo( 10.5 ); + } + + // At revDelete: car deleted + try (var s = sf.withOptions().atTransaction( revDelete ).openSession()) { + assertThat( s.find( SportsCar.class, 1L ) ).isNull(); + assertThat( s.find( Truck.class, 2L ) ).isNotNull(); + } + } + + @Test + @Order(3) + void testGetHistory(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + var history = auditLog.getHistory( SportsCar.class, 1L ); + assertThat( history ).hasSize( 3 ); + assertThat( history.get( 0 ).entity().name ).isEqualTo( "Sedan" ); + assertThat( history.get( 0 ).entity().seatCount ).isEqualTo( 5 ); + assertThat( history.get( 1 ).entity().name ).isEqualTo( "Sports Car" ); + assertThat( history.get( 1 ).entity().seatCount ).isEqualTo( 2 ); + assertThat( history.get( 2 ).entity() ).isNotNull(); + } + } + + @Test + @Order(4) + void testDeepHierarchy(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + try (var auditLog = AuditLogFactory.create( sf )) { + assertThat( auditLog.getRevisions( Car.class, 30L ) ).hasSize( 1 ); + assertThat( auditLog.getRevisions( SportsCar.class, 31L ) ).hasSize( 2 ); + } + + try (var s = sf.withOptions().atTransaction( revDeepCreate ).openSession()) { + assertThat( s.find( Car.class, 31L ) ).isNotNull() + .extracting( v -> v.name ).isEqualTo( "Ferrari" ); + } + + try (var s = sf.withOptions().atTransaction( revDeepUpdate ).openSession()) { + assertThat( s.find( Car.class, 31L ) ).isNotNull() + .extracting( v -> v.name ).isEqualTo( "Lamborghini" ); + } + + try (var auditLog = AuditLogFactory.create( sf )) { + var history = auditLog.getHistory( SportsCar.class, 31L ); + assertThat( history ).hasSize( 2 ); + assertThat( history.get( 0 ).entity().name ).isEqualTo( "Ferrari" ); + assertThat( history.get( 0 ).entity().horsepower ).isEqualTo( 600 ); + assertThat( history.get( 1 ).entity().name ).isEqualTo( "Lamborghini" ); + } + } + + @Test + @Order(5) + void testToOneAssociation(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + try (var s = sf.withOptions().atTransaction( revToOneCreate ).openSession()) { + var driver = s.find( Driver.class, 41L ); + assertThat( driver ).isNotNull(); + assertThat( driver.vehicle ).isNotNull(); + assertThat( driver.vehicle.name ).isEqualTo( "Ferrari" ); + } + + try (var s = sf.withOptions().atTransaction( revToOneUpdate ).openSession()) { + var driver = s.find( Driver.class, 41L ); + assertThat( driver ).isNotNull(); + assertThat( driver.vehicle.name ).isEqualTo( "Lamborghini" ); + } + } + + @Test + @Order(6) + void testManyToManyAssociation(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + try (var s = sf.withOptions().atTransaction( revM2mCreate ).openSession()) { + var team = s.find( Team.class, 52L ); + assertThat( team ).isNotNull(); + assertThat( team.vehicles ).extracting( v -> v.name ) + .containsExactlyInAnyOrder( "Ferrari", "Hauler" ); + } + + try (var s = sf.withOptions().atTransaction( revM2mUpdate ).openSession()) { + var team = s.find( Team.class, 52L ); + assertThat( team ).isNotNull(); + assertThat( team.vehicles ).extracting( v -> v.name ) + .containsExactlyInAnyOrder( "Hauler", "Lamborghini" ); + } + } + + // ---- Entity classes ---- + + @Audited + @Entity(name = "Vehicle") + @Inheritance(strategy = InheritanceType.JOINED) + @DiscriminatorColumn(name = "VEHICLE_TYPE") + @DiscriminatorValue("VEHICLE") + static class Vehicle { + @Id + long id; + String name; + + Vehicle() { + } + + Vehicle(long id, String name) { + this.id = id; + this.name = name; + } + } + + @Entity(name = "Car") + @DiscriminatorValue("CAR") + static class Car extends Vehicle { + int seatCount; + + Car() { + } + + Car(long id, String name, int seatCount) { + super( id, name ); + this.seatCount = seatCount; + } + } + + @Entity(name = "SportsCar") + @DiscriminatorValue("SPORTS_CAR") + static class SportsCar extends Car { + int horsepower; + + SportsCar() { + } + + SportsCar(long id, String name, int seatCount, int horsepower) { + super( id, name, seatCount ); + this.horsepower = horsepower; + } + } + + @Entity(name = "Truck") + @DiscriminatorValue("TRUCK") + static class Truck extends Vehicle { + double payload; + + Truck() { + } + + Truck(long id, String name, double payload) { + super( id, name ); + this.payload = payload; + } + } + + @Audited + @Entity(name = "JDDriver") + static class Driver { + @Id + long id; + String driverName; + @ManyToOne + Vehicle vehicle; + + Driver() { + } + + Driver(long id, String driverName, Vehicle vehicle) { + this.id = id; + this.driverName = driverName; + this.vehicle = vehicle; + } + } + + @Audited + @Entity(name = "JDTeam") + static class Team { + @Id + long id; + String teamName; + @ManyToMany + List vehicles = new ArrayList<>(); + + Team() { + } + + Team(long id, String teamName) { + this.id = id; + this.teamName = teamName; + } + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/temporal/audit/inheritance/AuditJoinedInheritanceTest.java b/hibernate-core/src/test/java/org/hibernate/temporal/audit/inheritance/AuditJoinedInheritanceTest.java new file mode 100644 index 000000000000..050dade3c135 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/temporal/audit/inheritance/AuditJoinedInheritanceTest.java @@ -0,0 +1,389 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.temporal.audit.inheritance; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Inheritance; +import jakarta.persistence.InheritanceType; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.ManyToOne; +import org.hibernate.annotations.Audited; +import org.hibernate.audit.AuditLogFactory; +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.BeforeClassTemplate; +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.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests @Audited with JOINED inheritance. + * Each table in the hierarchy has its own audit table with per-table + * temporal predicates. + */ +@AuditedTest +@SessionFactory +@DomainModel(annotatedClasses = { + AuditJoinedInheritanceTest.Vehicle.class, + AuditJoinedInheritanceTest.Car.class, + AuditJoinedInheritanceTest.SportsCar.class, + AuditJoinedInheritanceTest.Truck.class, + AuditJoinedInheritanceTest.Driver.class, + AuditJoinedInheritanceTest.Team.class +}) +@ServiceRegistry(settings = @Setting(name = StateManagementSettings.TRANSACTION_ID_SUPPLIER, + value = "org.hibernate.temporal.audit.inheritance.AuditJoinedInheritanceTest$TxIdSupplier")) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class AuditJoinedInheritanceTest { + private static int currentTxId; + + public static class TxIdSupplier implements TransactionIdentifierSupplier { + @Override + public Integer generateTransactionIdentifier(SharedSessionContract session) { + return ++currentTxId; + } + + } + + // Shared lifecycle: SportsCar(1) + Truck(2), update, delete + private int revCreate; // SportsCar(1) + Truck(2) + private int revUpdate; // update SportsCar name + seatCount + private int revTruckUpd; // update Truck name + private int revDelete; // delete SportsCar(1) + + // Deep hierarchy: Car(30) + SportsCar(31), update + private int revDeepCreate; // Car(30) + SportsCar(31) + private int revDeepUpdate; // update SportsCar(31) + + // ToOne association: SportsCar(40) + Driver(41), update car + private int revToOneCreate; // SportsCar(40) + Driver(41) + private int revToOneUpdate; // update SportsCar(40) name + + // ManyToMany association: SportsCar(50) + Truck(51) + Team(52), update car + private int revM2mCreate; // SportsCar(50) + Truck(51) + Team(52) + private int revM2mUpdate; // update SportsCar(50) name + + @BeforeClassTemplate + void initData(SessionFactoryScope scope) { + currentTxId = 0; + final var sf = scope.getSessionFactory(); + + // --- Shared lifecycle (IDs 1-2) --- + + sf.inTransaction( session -> { + session.persist( new SportsCar( 1L, "Sedan", 5, 200 ) ); + session.persist( new Truck( 2L, "Hauler", 10.5 ) ); + } ); + revCreate = currentTxId; + + sf.inTransaction( session -> { + var car = session.find( SportsCar.class, 1L ); + car.name = "Sports Car"; + car.seatCount = 2; + } ); + revUpdate = currentTxId; + + sf.inTransaction( session -> session.find( Truck.class, 2L ).name = "Big Hauler" ); + revTruckUpd = currentTxId; + + sf.inTransaction( session -> session.remove( session.find( SportsCar.class, 1L ) ) ); + revDelete = currentTxId; + + // --- Deep hierarchy (IDs 30-31) --- + + sf.inTransaction( session -> { + session.persist( new Car( 30L, "Plain Car", 4 ) ); + session.persist( new SportsCar( 31L, "Ferrari", 2, 600 ) ); + } ); + revDeepCreate = currentTxId; + + sf.inTransaction( session -> { + var sc = session.find( SportsCar.class, 31L ); + sc.name = "Lamborghini"; + sc.horsepower = 700; + } ); + revDeepUpdate = currentTxId; + + // --- ToOne association (IDs 40-41) --- + + sf.inTransaction( session -> { + var car = new SportsCar( 40L, "Ferrari", 2, 600 ); + session.persist( car ); + session.persist( new Driver( 41L, "Lewis", car ) ); + } ); + revToOneCreate = currentTxId; + + sf.inTransaction( session -> session.find( SportsCar.class, 40L ).name = "Lamborghini" ); + revToOneUpdate = currentTxId; + + // --- ManyToMany association (IDs 50-52) --- + + sf.inTransaction( session -> { + var car = new SportsCar( 50L, "Ferrari", 2, 600 ); + var truck = new Truck( 51L, "Hauler", 10.5 ); + session.persist( car ); + session.persist( truck ); + var team = new Team( 52L, "Racing" ); + team.vehicles.add( car ); + team.vehicles.add( truck ); + session.persist( team ); + } ); + revM2mCreate = currentTxId; + + sf.inTransaction( session -> session.find( SportsCar.class, 50L ).name = "Lamborghini" ); + revM2mUpdate = currentTxId; + } + + @Test + @Order(1) + void testWriteSide(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + assertThat( auditLog.getRevisions( SportsCar.class, 1L ) ).hasSize( 3 ); + assertThat( auditLog.getRevisions( Truck.class, 2L ) ).hasSize( 2 ); + } + } + + @Test + @Order(2) + void testPointInTimeRead(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + // At revCreate: original values + try (var s = sf.withOptions().atTransaction( revCreate ).openSession()) { + var car = s.find( SportsCar.class, 1L ); + assertThat( car ).isNotNull(); + assertThat( car.name ).isEqualTo( "Sedan" ); + assertThat( car.seatCount ).isEqualTo( 5 ); + + var truck = s.find( Truck.class, 2L ); + assertThat( truck ).isNotNull(); + assertThat( truck.name ).isEqualTo( "Hauler" ); + assertThat( truck.payload ).isEqualTo( 10.5 ); + } + + // At revUpdate: car updated, truck unchanged. Polymorphic lookups + try (var s = sf.withOptions().atTransaction( revUpdate ).openSession()) { + var car = s.find( SportsCar.class, 1L ); + assertThat( car ).isNotNull(); + assertThat( car.name ).isEqualTo( "Sports Car" ); + assertThat( car.seatCount ).isEqualTo( 2 ); + + assertThat( s.find( Car.class, 1L ) ).isNotNull().extracting( v -> v.name ) + .isEqualTo( "Sports Car" ); + assertThat( s.find( Vehicle.class, 1L ) ).isNotNull().extracting( v -> v.name ) + .isEqualTo( "Sports Car" ); + + assertThat( s.find( Truck.class, 2L ) ).isNotNull() + .extracting( v -> v.name ).isEqualTo( "Hauler" ); + } + + // At revTruckUpd: truck name updated + try (var s = sf.withOptions().atTransaction( revTruckUpd ).openSession()) { + var truck = s.find( Truck.class, 2L ); + assertThat( truck ).isNotNull(); + assertThat( truck.name ).isEqualTo( "Big Hauler" ); + assertThat( truck.payload ).isEqualTo( 10.5 ); + } + + // At revDelete: car deleted + try (var s = sf.withOptions().atTransaction( revDelete ).openSession()) { + assertThat( s.find( SportsCar.class, 1L ) ).isNull(); + assertThat( s.find( Truck.class, 2L ) ).isNotNull(); + } + } + + @Test + @Order(3) + void testGetHistory(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + var history = auditLog.getHistory( SportsCar.class, 1L ); + assertThat( history ).hasSize( 3 ); + assertThat( history.get( 0 ).entity().name ).isEqualTo( "Sedan" ); + assertThat( history.get( 0 ).entity().seatCount ).isEqualTo( 5 ); + assertThat( history.get( 1 ).entity().name ).isEqualTo( "Sports Car" ); + assertThat( history.get( 1 ).entity().seatCount ).isEqualTo( 2 ); + assertThat( history.get( 2 ).entity() ).isNotNull(); + } + } + + @Test + @Order(4) + void testDeepHierarchy(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + try (var auditLog = AuditLogFactory.create( sf )) { + assertThat( auditLog.getRevisions( Car.class, 30L ) ).hasSize( 1 ); + assertThat( auditLog.getRevisions( SportsCar.class, 31L ) ).hasSize( 2 ); + } + + try (var s = sf.withOptions().atTransaction( revDeepCreate ).openSession()) { + assertThat( s.find( Car.class, 31L ) ).isNotNull() + .extracting( v -> v.name ).isEqualTo( "Ferrari" ); + } + + try (var s = sf.withOptions().atTransaction( revDeepUpdate ).openSession()) { + assertThat( s.find( Car.class, 31L ) ).isNotNull() + .extracting( v -> v.name ).isEqualTo( "Lamborghini" ); + } + + try (var auditLog = AuditLogFactory.create( sf )) { + var history = auditLog.getHistory( SportsCar.class, 31L ); + assertThat( history ).hasSize( 2 ); + assertThat( history.get( 0 ).entity().name ).isEqualTo( "Ferrari" ); + assertThat( history.get( 0 ).entity().horsepower ).isEqualTo( 600 ); + assertThat( history.get( 1 ).entity().name ).isEqualTo( "Lamborghini" ); + } + } + + @Test + @Order(5) + void testToOneAssociation(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + try (var s = sf.withOptions().atTransaction( revToOneCreate ).openSession()) { + var driver = s.find( Driver.class, 41L ); + assertThat( driver ).isNotNull(); + assertThat( driver.vehicle ).isNotNull(); + assertThat( driver.vehicle.name ).isEqualTo( "Ferrari" ); + } + + try (var s = sf.withOptions().atTransaction( revToOneUpdate ).openSession()) { + var driver = s.find( Driver.class, 41L ); + assertThat( driver ).isNotNull(); + assertThat( driver.vehicle.name ).isEqualTo( "Lamborghini" ); + } + } + + @Test + @Order(6) + void testManyToManyAssociation(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + try (var s = sf.withOptions().atTransaction( revM2mCreate ).openSession()) { + var team = s.find( Team.class, 52L ); + assertThat( team ).isNotNull(); + assertThat( team.vehicles ).extracting( v -> v.name ) + .containsExactlyInAnyOrder( "Ferrari", "Hauler" ); + } + + try (var s = sf.withOptions().atTransaction( revM2mUpdate ).openSession()) { + var team = s.find( Team.class, 52L ); + assertThat( team ).isNotNull(); + assertThat( team.vehicles ).extracting( v -> v.name ) + .containsExactlyInAnyOrder( "Hauler", "Lamborghini" ); + } + } + + // ---- Entity classes ---- + + @Audited + @Entity(name = "Vehicle") + @Inheritance(strategy = InheritanceType.JOINED) + static class Vehicle { + @Id + long id; + String name; + + Vehicle() { + } + + Vehicle(long id, String name) { + this.id = id; + this.name = name; + } + } + + @Entity(name = "Car") + static class Car extends Vehicle { + int seatCount; + + Car() { + } + + Car(long id, String name, int seatCount) { + super( id, name ); + this.seatCount = seatCount; + } + } + + @Entity(name = "SportsCar") + static class SportsCar extends Car { + int horsepower; + + SportsCar() { + } + + SportsCar(long id, String name, int seatCount, int horsepower) { + super( id, name, seatCount ); + this.horsepower = horsepower; + } + } + + @Entity(name = "Truck") + static class Truck extends Vehicle { + double payload; + + Truck() { + } + + Truck(long id, String name, double payload) { + super( id, name ); + this.payload = payload; + } + } + + @Audited + @Entity(name = "Driver") + static class Driver { + @Id + long id; + String driverName; + @ManyToOne + Vehicle vehicle; + + Driver() { + } + + Driver(long id, String driverName, Vehicle vehicle) { + this.id = id; + this.driverName = driverName; + this.vehicle = vehicle; + } + } + + @Audited + @Entity(name = "Team") + static class Team { + @Id + long id; + String teamName; + @ManyToMany + List vehicles = new ArrayList<>(); + + Team() { + } + + Team(long id, String teamName) { + this.id = id; + this.teamName = teamName; + } + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/temporal/audit/inheritance/AuditSingleTableInheritanceTest.java b/hibernate-core/src/test/java/org/hibernate/temporal/audit/inheritance/AuditSingleTableInheritanceTest.java new file mode 100644 index 000000000000..391459b3da62 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/temporal/audit/inheritance/AuditSingleTableInheritanceTest.java @@ -0,0 +1,394 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.temporal.audit.inheritance; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.persistence.DiscriminatorColumn; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Inheritance; +import jakarta.persistence.InheritanceType; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.ManyToOne; +import org.hibernate.annotations.Audited; +import org.hibernate.audit.AuditLogFactory; +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.BeforeClassTemplate; +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.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests @Audited with SINGLE_TABLE inheritance. + */ +@AuditedTest +@SessionFactory +@DomainModel(annotatedClasses = { + AuditSingleTableInheritanceTest.Vehicle.class, + AuditSingleTableInheritanceTest.Car.class, + AuditSingleTableInheritanceTest.SportsCar.class, + AuditSingleTableInheritanceTest.Truck.class, + AuditSingleTableInheritanceTest.Driver.class, + AuditSingleTableInheritanceTest.Team.class +}) +@ServiceRegistry(settings = @Setting(name = StateManagementSettings.TRANSACTION_ID_SUPPLIER, + value = "org.hibernate.temporal.audit.inheritance.AuditSingleTableInheritanceTest$TxIdSupplier")) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class AuditSingleTableInheritanceTest { + private static int currentTxId; + + public static class TxIdSupplier implements TransactionIdentifierSupplier { + @Override + public Integer generateTransactionIdentifier(SharedSessionContract session) { + return ++currentTxId; + } + + } + + // Shared lifecycle: SportsCar(1) + Truck(2), update, delete + private int revCreate; // SportsCar(1) + Truck(2) + private int revUpdate; // update SportsCar name + seatCount + private int revTruckUpd; // update Truck name + private int revDelete; // delete SportsCar(1) + + // Deep hierarchy: Car(30) + SportsCar(31), update + private int revDeepCreate; // Car(30) + SportsCar(31) + private int revDeepUpdate; // update SportsCar(31) + + // ToOne association: SportsCar(40) + Driver(41), update car + private int revToOneCreate; // SportsCar(40) + Driver(41) + private int revToOneUpdate; // update SportsCar(40) name + + // ManyToMany association: SportsCar(50) + Truck(51) + Team(52), update car + private int revM2mCreate; // SportsCar(50) + Truck(51) + Team(52) + private int revM2mUpdate; // update SportsCar(50) name + + @BeforeClassTemplate + void initData(SessionFactoryScope scope) { + currentTxId = 0; + final var sf = scope.getSessionFactory(); + + // --- Shared lifecycle (IDs 1-2) --- + + sf.inTransaction( session -> { + session.persist( new SportsCar( 1L, "Sedan", 5, 200 ) ); + session.persist( new Truck( 2L, "Hauler", 10.5 ) ); + } ); + revCreate = currentTxId; + + sf.inTransaction( session -> { + var car = session.find( SportsCar.class, 1L ); + car.name = "Sports Car"; + car.seatCount = 2; + } ); + revUpdate = currentTxId; + + sf.inTransaction( session -> session.find( Truck.class, 2L ).name = "Big Hauler" ); + revTruckUpd = currentTxId; + + sf.inTransaction( session -> session.remove( session.find( SportsCar.class, 1L ) ) ); + revDelete = currentTxId; + + // --- Deep hierarchy (IDs 30-31) --- + + sf.inTransaction( session -> { + session.persist( new Car( 30L, "Plain Car", 4 ) ); + session.persist( new SportsCar( 31L, "Ferrari", 2, 600 ) ); + } ); + revDeepCreate = currentTxId; + + sf.inTransaction( session -> { + var sc = session.find( SportsCar.class, 31L ); + sc.name = "Lamborghini"; + sc.horsepower = 700; + } ); + revDeepUpdate = currentTxId; + + // --- ToOne association (IDs 40-41) --- + + sf.inTransaction( session -> { + var car = new SportsCar( 40L, "Ferrari", 2, 600 ); + session.persist( car ); + session.persist( new Driver( 41L, "Lewis", car ) ); + } ); + revToOneCreate = currentTxId; + + sf.inTransaction( session -> session.find( SportsCar.class, 40L ).name = "Lamborghini" ); + revToOneUpdate = currentTxId; + + // --- ManyToMany association (IDs 50-52) --- + + sf.inTransaction( session -> { + var car = new SportsCar( 50L, "Ferrari", 2, 600 ); + var truck = new Truck( 51L, "Hauler", 10.5 ); + session.persist( car ); + session.persist( truck ); + var team = new Team( 52L, "Racing" ); + team.vehicles.add( car ); + team.vehicles.add( truck ); + session.persist( team ); + } ); + revM2mCreate = currentTxId; + + sf.inTransaction( session -> session.find( SportsCar.class, 50L ).name = "Lamborghini" ); + revM2mUpdate = currentTxId; + } + + @Test + @Order(1) + void testWriteSide(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + assertThat( auditLog.getRevisions( SportsCar.class, 1L ) ).hasSize( 3 ); + assertThat( auditLog.getRevisions( Truck.class, 2L ) ).hasSize( 2 ); + } + } + + @Test + @Order(2) + void testPointInTimeRead(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + // At revCreate: original values + try (var s = sf.withOptions().atTransaction( revCreate ).openSession()) { + var car = s.find( SportsCar.class, 1L ); + assertThat( car ).isNotNull(); + assertThat( car.name ).isEqualTo( "Sedan" ); + assertThat( car.seatCount ).isEqualTo( 5 ); + + var truck = s.find( Truck.class, 2L ); + assertThat( truck ).isNotNull(); + assertThat( truck.name ).isEqualTo( "Hauler" ); + assertThat( truck.payload ).isEqualTo( 10.5 ); + } + + // At revUpdate: car updated, truck unchanged. Polymorphic lookups + try (var s = sf.withOptions().atTransaction( revUpdate ).openSession()) { + var car = s.find( SportsCar.class, 1L ); + assertThat( car ).isNotNull(); + assertThat( car.name ).isEqualTo( "Sports Car" ); + assertThat( car.seatCount ).isEqualTo( 2 ); + + assertThat( s.find( Car.class, 1L ) ).isNotNull().extracting( v -> v.name ) + .isEqualTo( "Sports Car" ); + assertThat( s.find( Vehicle.class, 1L ) ).isNotNull().extracting( v -> v.name ) + .isEqualTo( "Sports Car" ); + + assertThat( s.find( Truck.class, 2L ) ).isNotNull() + .extracting( v -> v.name ).isEqualTo( "Hauler" ); + } + + // At revTruckUpd: truck name updated + try (var s = sf.withOptions().atTransaction( revTruckUpd ).openSession()) { + var truck = s.find( Truck.class, 2L ); + assertThat( truck ).isNotNull(); + assertThat( truck.name ).isEqualTo( "Big Hauler" ); + assertThat( truck.payload ).isEqualTo( 10.5 ); + } + + // At revDelete: car deleted + try (var s = sf.withOptions().atTransaction( revDelete ).openSession()) { + assertThat( s.find( SportsCar.class, 1L ) ).isNull(); + assertThat( s.find( Truck.class, 2L ) ).isNotNull(); + } + } + + @Test + @Order(3) + void testGetHistory(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + var history = auditLog.getHistory( SportsCar.class, 1L ); + assertThat( history ).hasSize( 3 ); + assertThat( history.get( 0 ).entity().name ).isEqualTo( "Sedan" ); + assertThat( history.get( 0 ).entity().seatCount ).isEqualTo( 5 ); + assertThat( history.get( 1 ).entity().name ).isEqualTo( "Sports Car" ); + assertThat( history.get( 1 ).entity().seatCount ).isEqualTo( 2 ); + assertThat( history.get( 2 ).entity() ).isNotNull(); + } + } + + @Test + @Order(4) + void testDeepHierarchy(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + try (var auditLog = AuditLogFactory.create( sf )) { + assertThat( auditLog.getRevisions( Car.class, 30L ) ).hasSize( 1 ); + assertThat( auditLog.getRevisions( SportsCar.class, 31L ) ).hasSize( 2 ); + } + + try (var s = sf.withOptions().atTransaction( revDeepCreate ).openSession()) { + assertThat( s.find( Car.class, 31L ) ).isNotNull() + .extracting( v -> v.name ).isEqualTo( "Ferrari" ); + } + + try (var s = sf.withOptions().atTransaction( revDeepUpdate ).openSession()) { + assertThat( s.find( Car.class, 31L ) ).isNotNull() + .extracting( v -> v.name ).isEqualTo( "Lamborghini" ); + } + + try (var auditLog = AuditLogFactory.create( sf )) { + var history = auditLog.getHistory( SportsCar.class, 31L ); + assertThat( history ).hasSize( 2 ); + assertThat( history.get( 0 ).entity().name ).isEqualTo( "Ferrari" ); + assertThat( history.get( 0 ).entity().horsepower ).isEqualTo( 600 ); + assertThat( history.get( 1 ).entity().name ).isEqualTo( "Lamborghini" ); + } + } + + @Test + @Order(5) + void testToOneAssociation(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + try (var s = sf.withOptions().atTransaction( revToOneCreate ).openSession()) { + var driver = s.find( Driver.class, 41L ); + assertThat( driver ).isNotNull(); + assertThat( driver.vehicle ).isNotNull(); + assertThat( driver.vehicle.name ).isEqualTo( "Ferrari" ); + } + + try (var s = sf.withOptions().atTransaction( revToOneUpdate ).openSession()) { + var driver = s.find( Driver.class, 41L ); + assertThat( driver ).isNotNull(); + assertThat( driver.vehicle.name ).isEqualTo( "Lamborghini" ); + } + } + + @Test + @Order(6) + void testManyToManyAssociation(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + try (var s = sf.withOptions().atTransaction( revM2mCreate ).openSession()) { + var team = s.find( Team.class, 52L ); + assertThat( team ).isNotNull(); + assertThat( team.vehicles ).extracting( v -> v.name ) + .containsExactlyInAnyOrder( "Ferrari", "Hauler" ); + } + + try (var s = sf.withOptions().atTransaction( revM2mUpdate ).openSession()) { + var team = s.find( Team.class, 52L ); + assertThat( team ).isNotNull(); + assertThat( team.vehicles ).extracting( v -> v.name ) + .containsExactlyInAnyOrder( "Hauler", "Lamborghini" ); + } + } + + // ---- Entity classes ---- + + @Audited + @Entity(name = "Vehicle") + @Inheritance(strategy = InheritanceType.SINGLE_TABLE) + @DiscriminatorColumn(name = "VEHICLE_TYPE") + @DiscriminatorValue("VEHICLE") + static class Vehicle { + @Id + long id; + String name; + + Vehicle() { + } + + Vehicle(long id, String name) { + this.id = id; + this.name = name; + } + } + + @Entity(name = "Car") + @DiscriminatorValue("CAR") + static class Car extends Vehicle { + int seatCount; + + Car() { + } + + Car(long id, String name, int seatCount) { + super( id, name ); + this.seatCount = seatCount; + } + } + + @Entity(name = "SportsCar") + @DiscriminatorValue("SPORTS_CAR") + static class SportsCar extends Car { + int horsepower; + + SportsCar() { + } + + SportsCar(long id, String name, int seatCount, int horsepower) { + super( id, name, seatCount ); + this.horsepower = horsepower; + } + } + + @Entity(name = "Truck") + @DiscriminatorValue("TRUCK") + static class Truck extends Vehicle { + double payload; + + Truck() { + } + + Truck(long id, String name, double payload) { + super( id, name ); + this.payload = payload; + } + } + + @Audited + @Entity(name = "Driver") + static class Driver { + @Id + long id; + String driverName; + @ManyToOne + Vehicle vehicle; + + Driver() { + } + + Driver(long id, String driverName, Vehicle vehicle) { + this.id = id; + this.driverName = driverName; + this.vehicle = vehicle; + } + } + + @Audited + @Entity(name = "Team") + static class Team { + @Id + long id; + String teamName; + @ManyToMany + List vehicles = new ArrayList<>(); + + Team() { + } + + Team(long id, String teamName) { + this.id = id; + this.teamName = teamName; + } + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/temporal/audit/inheritance/AuditTablePerClassInheritanceTest.java b/hibernate-core/src/test/java/org/hibernate/temporal/audit/inheritance/AuditTablePerClassInheritanceTest.java new file mode 100644 index 000000000000..9d01604d97ec --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/temporal/audit/inheritance/AuditTablePerClassInheritanceTest.java @@ -0,0 +1,388 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.temporal.audit.inheritance; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Inheritance; +import jakarta.persistence.InheritanceType; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.ManyToOne; +import org.hibernate.annotations.Audited; +import org.hibernate.audit.AuditLogFactory; +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.BeforeClassTemplate; +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.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests @Audited with TABLE_PER_CLASS inheritance. + * Each concrete class has its own table and audit table. + */ +@AuditedTest +@SessionFactory +@DomainModel(annotatedClasses = { + AuditTablePerClassInheritanceTest.Vehicle.class, + AuditTablePerClassInheritanceTest.Car.class, + AuditTablePerClassInheritanceTest.SportsCar.class, + AuditTablePerClassInheritanceTest.Truck.class, + AuditTablePerClassInheritanceTest.Driver.class, + AuditTablePerClassInheritanceTest.Team.class +}) +@ServiceRegistry(settings = @Setting(name = StateManagementSettings.TRANSACTION_ID_SUPPLIER, + value = "org.hibernate.temporal.audit.inheritance.AuditTablePerClassInheritanceTest$TxIdSupplier")) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class AuditTablePerClassInheritanceTest { + private static int currentTxId; + + public static class TxIdSupplier implements TransactionIdentifierSupplier { + @Override + public Integer generateTransactionIdentifier(SharedSessionContract session) { + return ++currentTxId; + } + + } + + // Shared lifecycle: SportsCar(1) + Truck(2), update, delete + private int revCreate; // SportsCar(1) + Truck(2) + private int revUpdate; // update SportsCar name + seatCount + private int revTruckUpd; // update Truck name + private int revDelete; // delete SportsCar(1) + + // Deep hierarchy: Car(30) + SportsCar(31), update + private int revDeepCreate; // Car(30) + SportsCar(31) + private int revDeepUpdate; // update SportsCar(31) + + // ToOne association: SportsCar(40) + Driver(41), update car + private int revToOneCreate; // SportsCar(40) + Driver(41) + private int revToOneUpdate; // update SportsCar(40) name + + // ManyToMany association: SportsCar(50) + Truck(51) + Team(52), update car + private int revM2mCreate; // SportsCar(50) + Truck(51) + Team(52) + private int revM2mUpdate; // update SportsCar(50) name + + @BeforeClassTemplate + void initData(SessionFactoryScope scope) { + currentTxId = 0; + final var sf = scope.getSessionFactory(); + + // --- Shared lifecycle (IDs 1-2) --- + + sf.inTransaction( session -> { + session.persist( new SportsCar( 1L, "Sedan", 5, 200 ) ); + session.persist( new Truck( 2L, "Hauler", 10.5 ) ); + } ); + revCreate = currentTxId; + + sf.inTransaction( session -> { + var car = session.find( SportsCar.class, 1L ); + car.name = "Sports Car"; + car.seatCount = 2; + } ); + revUpdate = currentTxId; + + sf.inTransaction( session -> session.find( Truck.class, 2L ).name = "Big Hauler" ); + revTruckUpd = currentTxId; + + sf.inTransaction( session -> session.remove( session.find( SportsCar.class, 1L ) ) ); + revDelete = currentTxId; + + // --- Deep hierarchy (IDs 30-31) --- + + sf.inTransaction( session -> { + session.persist( new Car( 30L, "Plain Car", 4 ) ); + session.persist( new SportsCar( 31L, "Ferrari", 2, 600 ) ); + } ); + revDeepCreate = currentTxId; + + sf.inTransaction( session -> { + var sc = session.find( SportsCar.class, 31L ); + sc.name = "Lamborghini"; + sc.horsepower = 700; + } ); + revDeepUpdate = currentTxId; + + // --- ToOne association (IDs 40-41) --- + + sf.inTransaction( session -> { + var car = new SportsCar( 40L, "Ferrari", 2, 600 ); + session.persist( car ); + session.persist( new Driver( 41L, "Lewis", car ) ); + } ); + revToOneCreate = currentTxId; + + sf.inTransaction( session -> session.find( SportsCar.class, 40L ).name = "Lamborghini" ); + revToOneUpdate = currentTxId; + + // --- ManyToMany association (IDs 50-52) --- + + sf.inTransaction( session -> { + var car = new SportsCar( 50L, "Ferrari", 2, 600 ); + var truck = new Truck( 51L, "Hauler", 10.5 ); + session.persist( car ); + session.persist( truck ); + var team = new Team( 52L, "Racing" ); + team.vehicles.add( car ); + team.vehicles.add( truck ); + session.persist( team ); + } ); + revM2mCreate = currentTxId; + + sf.inTransaction( session -> session.find( SportsCar.class, 50L ).name = "Lamborghini" ); + revM2mUpdate = currentTxId; + } + + @Test + @Order(1) + void testWriteSide(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + assertThat( auditLog.getRevisions( SportsCar.class, 1L ) ).hasSize( 3 ); + assertThat( auditLog.getRevisions( Truck.class, 2L ) ).hasSize( 2 ); + } + } + + @Test + @Order(2) + void testPointInTimeRead(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + // At revCreate: original values + try (var s = sf.withOptions().atTransaction( revCreate ).openSession()) { + var car = s.find( SportsCar.class, 1L ); + assertThat( car ).isNotNull(); + assertThat( car.name ).isEqualTo( "Sedan" ); + assertThat( car.seatCount ).isEqualTo( 5 ); + + var truck = s.find( Truck.class, 2L ); + assertThat( truck ).isNotNull(); + assertThat( truck.name ).isEqualTo( "Hauler" ); + assertThat( truck.payload ).isEqualTo( 10.5 ); + } + + // At revUpdate: car updated, truck unchanged. Polymorphic lookups + try (var s = sf.withOptions().atTransaction( revUpdate ).openSession()) { + var car = s.find( SportsCar.class, 1L ); + assertThat( car ).isNotNull(); + assertThat( car.name ).isEqualTo( "Sports Car" ); + assertThat( car.seatCount ).isEqualTo( 2 ); + + assertThat( s.find( Car.class, 1L ) ).isNotNull().extracting( v -> v.name ) + .isEqualTo( "Sports Car" ); + assertThat( s.find( Vehicle.class, 1L ) ).isNotNull().extracting( v -> v.name ) + .isEqualTo( "Sports Car" ); + + assertThat( s.find( Truck.class, 2L ) ).isNotNull() + .extracting( v -> v.name ).isEqualTo( "Hauler" ); + } + + // At revTruckUpd: truck name updated + try (var s = sf.withOptions().atTransaction( revTruckUpd ).openSession()) { + var truck = s.find( Truck.class, 2L ); + assertThat( truck ).isNotNull(); + assertThat( truck.name ).isEqualTo( "Big Hauler" ); + assertThat( truck.payload ).isEqualTo( 10.5 ); + } + + // At revDelete: car deleted + try (var s = sf.withOptions().atTransaction( revDelete ).openSession()) { + assertThat( s.find( SportsCar.class, 1L ) ).isNull(); + assertThat( s.find( Truck.class, 2L ) ).isNotNull(); + } + } + + @Test + @Order(3) + void testGetHistory(SessionFactoryScope scope) { + try (var auditLog = AuditLogFactory.create( scope.getSessionFactory() )) { + var history = auditLog.getHistory( SportsCar.class, 1L ); + assertThat( history ).hasSize( 3 ); + assertThat( history.get( 0 ).entity().name ).isEqualTo( "Sedan" ); + assertThat( history.get( 0 ).entity().seatCount ).isEqualTo( 5 ); + assertThat( history.get( 1 ).entity().name ).isEqualTo( "Sports Car" ); + assertThat( history.get( 1 ).entity().seatCount ).isEqualTo( 2 ); + assertThat( history.get( 2 ).entity() ).isNotNull(); + } + } + + @Test + @Order(4) + void testDeepHierarchy(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + try (var auditLog = AuditLogFactory.create( sf )) { + assertThat( auditLog.getRevisions( Car.class, 30L ) ).hasSize( 1 ); + assertThat( auditLog.getRevisions( SportsCar.class, 31L ) ).hasSize( 2 ); + } + + try (var s = sf.withOptions().atTransaction( revDeepCreate ).openSession()) { + assertThat( s.find( Car.class, 31L ) ).isNotNull() + .extracting( v -> v.name ).isEqualTo( "Ferrari" ); + } + + try (var s = sf.withOptions().atTransaction( revDeepUpdate ).openSession()) { + assertThat( s.find( Car.class, 31L ) ).isNotNull() + .extracting( v -> v.name ).isEqualTo( "Lamborghini" ); + } + + try (var auditLog = AuditLogFactory.create( sf )) { + var history = auditLog.getHistory( SportsCar.class, 31L ); + assertThat( history ).hasSize( 2 ); + assertThat( history.get( 0 ).entity().name ).isEqualTo( "Ferrari" ); + assertThat( history.get( 0 ).entity().horsepower ).isEqualTo( 600 ); + assertThat( history.get( 1 ).entity().name ).isEqualTo( "Lamborghini" ); + } + } + + @Test + @Order(5) + void testToOneAssociation(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + try (var s = sf.withOptions().atTransaction( revToOneCreate ).openSession()) { + var driver = s.find( Driver.class, 41L ); + assertThat( driver ).isNotNull(); + assertThat( driver.vehicle ).isNotNull(); + assertThat( driver.vehicle.name ).isEqualTo( "Ferrari" ); + } + + try (var s = sf.withOptions().atTransaction( revToOneUpdate ).openSession()) { + var driver = s.find( Driver.class, 41L ); + assertThat( driver ).isNotNull(); + assertThat( driver.vehicle.name ).isEqualTo( "Lamborghini" ); + } + } + + @Test + @Order(6) + void testManyToManyAssociation(SessionFactoryScope scope) { + final var sf = scope.getSessionFactory(); + + try (var s = sf.withOptions().atTransaction( revM2mCreate ).openSession()) { + var team = s.find( Team.class, 52L ); + assertThat( team ).isNotNull(); + assertThat( team.vehicles ).extracting( v -> v.name ) + .containsExactlyInAnyOrder( "Ferrari", "Hauler" ); + } + + try (var s = sf.withOptions().atTransaction( revM2mUpdate ).openSession()) { + var team = s.find( Team.class, 52L ); + assertThat( team ).isNotNull(); + assertThat( team.vehicles ).extracting( v -> v.name ) + .containsExactlyInAnyOrder( "Hauler", "Lamborghini" ); + } + } + + // ---- Entity classes ---- + + @Audited + @Entity(name = "Vehicle") + @Inheritance(strategy = InheritanceType.TABLE_PER_CLASS) + static class Vehicle { + @Id + long id; + String name; + + Vehicle() { + } + + Vehicle(long id, String name) { + this.id = id; + this.name = name; + } + } + + @Entity(name = "Car") + static class Car extends Vehicle { + int seatCount; + + Car() { + } + + Car(long id, String name, int seatCount) { + super( id, name ); + this.seatCount = seatCount; + } + } + + @Entity(name = "SportsCar") + static class SportsCar extends Car { + int horsepower; + + SportsCar() { + } + + SportsCar(long id, String name, int seatCount, int horsepower) { + super( id, name, seatCount ); + this.horsepower = horsepower; + } + } + + @Entity(name = "Truck") + static class Truck extends Vehicle { + double payload; + + Truck() { + } + + Truck(long id, String name, double payload) { + super( id, name ); + this.payload = payload; + } + } + + @Audited + @Entity(name = "Driver") + static class Driver { + @Id + long id; + String driverName; + @ManyToOne + Vehicle vehicle; + + Driver() { + } + + Driver(long id, String driverName, Vehicle vehicle) { + this.id = id; + this.driverName = driverName; + this.vehicle = vehicle; + } + } + + @Audited + @Entity(name = "Team") + static class Team { + @Id + long id; + String teamName; + @ManyToMany + List vehicles = new ArrayList<>(); + + Team() { + } + + Team(long id, String teamName) { + this.id = id; + this.teamName = teamName; + } + } +} diff --git a/hibernate-testing/src/main/java/org/hibernate/testing/orm/junit/AuditStrategyExtension.java b/hibernate-testing/src/main/java/org/hibernate/testing/orm/junit/AuditStrategyExtension.java new file mode 100644 index 000000000000..18e1ce31c71b --- /dev/null +++ b/hibernate-testing/src/main/java/org/hibernate/testing/orm/junit/AuditStrategyExtension.java @@ -0,0 +1,85 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.testing.orm.junit; + +import java.lang.reflect.AnnotatedElement; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import org.hibernate.cfg.StateManagementSettings; + +import org.junit.jupiter.api.extension.ClassTemplateInvocationContext; +import org.junit.jupiter.api.extension.ClassTemplateInvocationContextProvider; +import org.junit.jupiter.api.extension.Extension; +import org.junit.jupiter.api.extension.ExtensionContext; + +import static org.hibernate.internal.util.NullnessUtil.castNonNull; + +/** + * JUnit extension that provides class template invocation contexts + * for each audit strategy specified by {@link AuditedTest}. + *

+ * For each strategy, the extension releases the existing session factory, + * domain model, and service registry, then injects the + * {@value StateManagementSettings#AUDIT_STRATEGY} setting so that a + * fresh bootstrap picks up the correct strategy. + */ +public class AuditStrategyExtension implements ClassTemplateInvocationContextProvider { + @Override + public boolean supportsClassTemplate(ExtensionContext context) { + return true; + } + + @Override + public Stream provideClassTemplateInvocationContexts(ExtensionContext context) { + AuditedTest ann = null; + final Optional elementOpt = context.getElement(); + if ( elementOpt.isPresent() ) { + ann = elementOpt.get().getAnnotation( AuditedTest.class ); + } + if ( ann == null ) { + final Optional> testClassOpt = context.getTestClass(); + if ( testClassOpt.isPresent() ) { + ann = testClassOpt.get().getAnnotation( AuditedTest.class ); + } + } + + final String[] strategies = castNonNull( ann ).strategies(); + final List contexts = new ArrayList<>( strategies.length ); + for ( String strategy : strategies ) { + contexts.add( new AuditStrategyInvocationContext( strategy ) ); + } + return contexts.stream(); + } + + private record AuditStrategyInvocationContext(String strategy) implements ClassTemplateInvocationContext { + @Override + public String getDisplayName(int invocationIndex) { + return "[" + strategy + "]"; + } + + @Override + public List getAdditionalExtensions() { + return List.of(); + } + + @Override + public void prepareInvocation(ExtensionContext context) { + final var testInstance = context.getRequiredTestInstance(); + // Release existing SF/DomainModel/ServiceRegistry so they are rebuilt + final var sfScope = SessionFactoryExtension.findSessionFactoryScope( testInstance, context ); + sfScope.releaseSessionFactory(); + final var domainModelScope = DomainModelExtension.findDomainModelScope( testInstance, context ); + domainModelScope.releaseModel(); + final var registryScope = ServiceRegistryExtension.findServiceRegistryScope( testInstance, context ); + registryScope.releaseRegistry(); + // Inject the audit strategy setting + registryScope.getAdditionalSettings() + .put( StateManagementSettings.AUDIT_STRATEGY, strategy ); + } + } +} diff --git a/hibernate-testing/src/main/java/org/hibernate/testing/orm/junit/AuditedTest.java b/hibernate-testing/src/main/java/org/hibernate/testing/orm/junit/AuditedTest.java new file mode 100644 index 000000000000..60106e59878e --- /dev/null +++ b/hibernate-testing/src/main/java/org/hibernate/testing/orm/junit/AuditedTest.java @@ -0,0 +1,40 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.testing.orm.junit; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.junit.jupiter.api.ClassTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Runs the annotated test class once per audit strategy + * ({@code "default"} and {@code "validity"}). + *

+ * Use in conjunction with {@link DomainModel}, {@link SessionFactory}, + * and {@link ServiceRegistry} to bootstrap the environment. + *

+ * Note: since this uses {@link ClassTemplate}, the test class must + * use {@link BeforeClassTemplate} / {@link AfterClassTemplate} + * instead of {@code @BeforeAll} / {@code @AfterAll} for setup + * and teardown that should run per invocation. + */ +@Inherited +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@ClassTemplate +@ExtendWith(AuditStrategyExtension.class) +@ExtendWith(ClassTemplateInvocationListenersExtension.class) +public @interface AuditedTest { + /** + * The audit strategies to test with. + * Defaults to both {@code "default"} and {@code "validity"}. + */ + String[] strategies() default {"default", "validity"}; +} diff --git a/tooling/metamodel-generator/src/main/java/org/hibernate/processor/validation/MockSessionFactory.java b/tooling/metamodel-generator/src/main/java/org/hibernate/processor/validation/MockSessionFactory.java index 6582598c306e..63b1a5171ade 100644 --- a/tooling/metamodel-generator/src/main/java/org/hibernate/processor/validation/MockSessionFactory.java +++ b/tooling/metamodel-generator/src/main/java/org/hibernate/processor/validation/MockSessionFactory.java @@ -448,7 +448,7 @@ public boolean isPreferJavaTimeJdbcTypesEnabled() { @Override public boolean isPreferNativeEnumTypesEnabled() { - return MetadataBuildingContext.super.isPreferNativeEnumTypesEnabled(); + return false; } @Override