MissingVersionAttributeAudit.java

package io.github.databaseaudits.audit.jpa;

import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.function.Supplier;
import java.util.stream.Collectors;

import org.hibernate.boot.Metadata;
import org.hibernate.mapping.PersistentClass;
import org.jspecify.annotations.Nullable;

import io.github.databaseaudits.audit.finding.Finding;
import io.github.databaseaudits.audit.finding.MissingVersionAttributeFinding;

import jakarta.persistence.EntityManagerFactory;

/**
 * Every mutable root entity should carry a {@code @Version} attribute.
 *
 * <p>
 * A mutable entity with no optimistic-locking version lets concurrent
 * transactions silently overwrite each other's changes (a lost update):
 * transaction A reads a row, transaction B reads and updates the same row,
 * then A's later update blindly clobbers B's change with no conflict
 * detection. Versioning is declared on the inheritance hierarchy's root, so
 * only root {@link PersistentClass} bindings are checked; an {@code @Immutable}
 * entity is skipped, since it cannot lose an update it never receives. Reads
 * the boot mapping model from a {@link MappingMetadataIntegrator} captured
 * during bootstrap, exactly like {@link SchemaEntityValidationAudit}.
 * Advisory: append-only or single-writer entities are legitimate exclusions —
 * pass them (fully-qualified name, simple name, or physical table name) as
 * {@code excludedEntities}.
 *
 * <p>
 * Fix: add a {@code @Version} attribute, or exclude the entity if it is
 * genuinely append-only or single-writer.
 */
public class MissingVersionAttributeAudit {
    private final Supplier<@Nullable Metadata> metadataSupplier;

    /**
     * Constructs the audit around the boot mapping model.
     *
     * @param metadataSupplier
     *                             supplies the boot {@link Metadata} for the
     *                             persistence unit, resolved when
     *                             {@link #audit(Set)} runs; see
     *                             {@link #forEntityManagerFactory(EntityManagerFactory)}.
     */
    public MissingVersionAttributeAudit(
            final Supplier<@Nullable Metadata> metadataSupplier) {
        this.metadataSupplier = metadataSupplier;
    }

    /**
     * Builds an audit for a JPA {@link EntityManagerFactory}, resolving its boot
     * {@link Metadata} from {@link MappingMetadataIntegrator} when the audit
     * runs.
     *
     * @param entityManagerFactory
     *                                 the factory whose mappings to validate.
     * @return the audit.
     */
    public static MissingVersionAttributeAudit forEntityManagerFactory(
            final EntityManagerFactory entityManagerFactory) {
        return new MissingVersionAttributeAudit(() -> MappingMetadataIntegrator
                .metadataFor(entityManagerFactory));
    }

    /**
     * Returns one {@link Finding} for every mutable root entity with no
     * {@code @Version} attribute, except the excluded ones; an empty list when
     * every mutable root entity is versioned.
     *
     * @param excludedEntities
     *                             the entities to skip, matched
     *                             case-insensitively against the entity's
     *                             fully-qualified name, its simple name, or its
     *                             physical table name.
     * @return one {@link Finding} per unversioned mutable root entity — its
     *         {@link Finding#description() description} is the reported line;
     *         an empty list when every mutable root entity is versioned.
     * @throws IllegalStateException
     *                                   if the boot mapping model was never
     *                                   captured, so the audit cannot run.
     */
    public List<Finding> audit(final Set<String> excludedEntities) {
        final Metadata metadata = metadataSupplier.get();
        if (metadata == null) {
            throw new IllegalStateException(
                    "JPA mapping metadata was not captured for this EntityManagerFactory; ensure "
                            + "database-audits-core is on the test classpath (it registers a Hibernate "
                            + "Integrator that records the boot metadata) and that the EntityManagerFactory "
                            + "has been built.");
        }
        final Set<String> excludes = excludedEntities.stream()
                .map(e -> e.toLowerCase(Locale.ROOT))
                .collect(Collectors.toSet());

        return metadata.getEntityBindings().stream()
                .filter(persistentClass -> persistentClass
                        .getSuperclass() == null)
                .filter(PersistentClass::isMutable)
                .filter(persistentClass -> !persistentClass.isVersioned())
                .filter(persistentClass -> !isExcluded(persistentClass,
                        excludes))
                .sorted(Comparator.comparing(PersistentClass::getEntityName))
                .<Finding>map(
                        persistentClass -> new MissingVersionAttributeFinding(
                                persistentClass.getEntityName(),
                                persistentClass.getTable().getName()))
                .toList();
    }

    private boolean isExcluded(final PersistentClass persistentClass,
            final Set<String> excludes) {
        final String entityName =
                persistentClass.getEntityName().toLowerCase(Locale.ROOT);
        // split on the last '.' or '$' - a nested class's binary name uses
        // '$' before its own simple name, not '.'
        final int lastSeparator = Math.max(entityName.lastIndexOf('.'),
                entityName.lastIndexOf('$'));
        final String simpleName = lastSeparator < 0 ? entityName
                : entityName.substring(lastSeparator + 1);
        final String tableName = persistentClass.getTable().getName()
                .toLowerCase(Locale.ROOT);
        return excludes.contains(entityName) || excludes.contains(simpleName)
                || excludes.contains(tableName);
    }
}