HibernateObjDatabase.java

/*******************************************************************************
 * Copyright (c) 2026 Carsten Hammer.
 *
 * This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * which accompanies this distribution, and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *     Carsten Hammer
 *******************************************************************************/
package com.taxonomy.dsl.storage.jgit;

import java.io.ByteArrayOutputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;

import org.eclipse.jgit.internal.storage.dfs.DfsObjDatabase;
import org.eclipse.jgit.internal.storage.dfs.DfsOutputStream;
import org.eclipse.jgit.internal.storage.dfs.DfsPackDescription;
import org.eclipse.jgit.internal.storage.dfs.DfsReaderOptions;
import org.eclipse.jgit.internal.storage.dfs.DfsRepository;
import org.eclipse.jgit.internal.storage.dfs.ReadableChannel;
import org.eclipse.jgit.internal.storage.pack.PackExt;
import org.eclipse.jgit.lib.ObjectId;
import org.hibernate.Session;
import org.hibernate.SessionFactory;

/**
 * A {@link DfsObjDatabase} implementation backed by Hibernate/JPA for
 * database-based Git object storage.
 * <p>
 * Pack data is stored in the {@code git_packs} table as BLOBs. Each pack
 * extension (PACK, IDX, REFTABLE, etc.) is stored as a separate row, keyed
 * by the pack base name and the extension name.
 * <p>
 * Adapted from {@code sandbox-jgit-storage-hibernate} module
 * in the {@code carstenartur/sandbox} repository.
 */
public class HibernateObjDatabase extends DfsObjDatabase {

    private final SessionFactory sessionFactory;

    private final String repositoryName;

    /**
     * Per-instance counter for unique pack names. Uses a high initial offset
     * derived from the current time to avoid collisions with the JVM-global
     * {@link org.eclipse.jgit.internal.storage.dfs.DfsBlockCache} singleton,
     * which caches pack data keyed by (repository description + pack file name).
     * Without unique names, a second {@code HibernateObjDatabase} instance in
     * the same JVM (e.g., after a Spring context restart in tests) would hit
     * stale cached data from a previous instance.
     */
    private final AtomicInteger packIdCounter = new AtomicInteger(
            (int) (System.nanoTime() & 0x7FFF_FFFF));

    private Set<ObjectId> shallowCommits = Collections.emptySet();

    /**
     * Create a new Hibernate-backed object database.
     *
     * @param repo            the repository
     * @param options         reader options
     * @param sessionFactory  Hibernate session factory
     * @param repositoryName  name of the repository in the database
     */
    public HibernateObjDatabase(DfsRepository repo, DfsReaderOptions options,
                                SessionFactory sessionFactory, String repositoryName) {
        super(repo, options);
        this.sessionFactory = sessionFactory;
        this.repositoryName = repositoryName;
    }

    /**
     * Clear all pack data for this repository from the database.
     * <p>
     * This is useful on startup to ensure a clean state, avoiding
     * stale reftable data from a previous JVM or test context.
     */
    public void clearAll() {
        try (Session session = sessionFactory.openSession()) {
            session.beginTransaction();
            session.createMutationQuery(
                            "DELETE FROM GitPackEntity p WHERE p.repositoryName = :repo")
                    .setParameter("repo", repositoryName)
                    .executeUpdate();
            session.getTransaction().commit();
        }
        clearCache();
    }

    private static String baseName(DfsPackDescription desc) {
        String fn = desc.getFileName(PackExt.PACK);
        int dot = fn.lastIndexOf('.');
        return dot > 0 ? fn.substring(0, dot) : fn;
    }

    @Override
    protected List<DfsPackDescription> listPacks() throws IOException {
        try (Session session = sessionFactory.openSession()) {
            List<Object[]> rows = session.createQuery(
                            "SELECT p.packName, p.packExtension, p.fileSize FROM GitPackEntity p WHERE p.repositoryName = :repo",
                            Object[].class)
                    .setParameter("repo", repositoryName)
                    .getResultList();
            LinkedHashMap<String, DfsPackDescription> descMap = new LinkedHashMap<>();
            for (Object[] row : rows) {
                String name = (String) row[0];
                String ext = (String) row[1];
                long size = (Long) row[2];
                DfsPackDescription desc = descMap.computeIfAbsent(name,
                        n -> new DfsPackDescription(
                                getRepository().getDescription(), n,
                                PackSource.INSERT));
                for (PackExt pe : PackExt.values()) {
                    if (pe.getExtension().equals(ext)) {
                        desc.addFileExt(pe);
                        desc.setFileSize(pe, size);
                        break;
                    }
                }
            }
            return new ArrayList<>(descMap.values());
        }
    }

    @Override
    protected DfsPackDescription newPack(PackSource source) {
        int id = packIdCounter.incrementAndGet();
        String name = "pack-" + id + "-" + source.name();
        return new DfsPackDescription(getRepository().getDescription(), name, source);
    }

    @Override
    protected void commitPackImpl(Collection<DfsPackDescription> desc,
                                  Collection<DfsPackDescription> replace) throws IOException {
        try (Session session = sessionFactory.openSession()) {
            session.beginTransaction();
            if (replace != null) {
                for (DfsPackDescription d : replace) {
                    session.createMutationQuery(
                                    "DELETE FROM GitPackEntity p WHERE p.repositoryName = :repo AND p.packName = :name")
                            .setParameter("repo", repositoryName)
                            .setParameter("name", baseName(d))
                            .executeUpdate();
                }
            }
            session.getTransaction().commit();
        }
        clearCache();
    }

    @Override
    protected void rollbackPack(Collection<DfsPackDescription> desc) {
        try (Session session = sessionFactory.openSession()) {
            session.beginTransaction();
            for (DfsPackDescription d : desc) {
                session.createMutationQuery(
                                "DELETE FROM GitPackEntity p WHERE p.repositoryName = :repo AND p.packName = :name")
                        .setParameter("repo", repositoryName)
                        .setParameter("name", baseName(d))
                        .executeUpdate();
            }
            session.getTransaction().commit();
        } catch (Exception e) {
            // Rollback should not throw
        }
    }

    @Override
    protected ReadableChannel openFile(DfsPackDescription desc, PackExt ext)
            throws FileNotFoundException, IOException {
        String queryName = baseName(desc);
        String queryExt = ext.getExtension();
        try (Session session = sessionFactory.openSession()) {
            GitPackEntity entity = session.createQuery(
                            "FROM GitPackEntity p WHERE p.repositoryName = :repo AND p.packName = :name AND p.packExtension = :ext",
                            GitPackEntity.class)
                    .setParameter("repo", repositoryName)
                    .setParameter("name", queryName)
                    .setParameter("ext", queryExt).uniqueResult();
            if (entity == null) {
                throw new FileNotFoundException(desc.getFileName(ext));
            }
            return new ByteArrayReadableChannel(entity.getData());
        }
    }

    @Override
    protected DfsOutputStream writeFile(DfsPackDescription desc, PackExt ext)
            throws IOException {
        return new HibernatePackOutputStream(sessionFactory, repositoryName,
                baseName(desc), ext.getExtension());
    }

    @Override
    public Set<ObjectId> getShallowCommits() throws IOException {
        return shallowCommits;
    }

    @Override
    public void setShallowCommits(Set<ObjectId> shallowCommits) {
        this.shallowCommits = shallowCommits;
    }

    @Override
    public long getApproximateObjectCount() {
        return 0; // Simplified; full version queries GitObjectEntity
    }

    /** DfsOutputStream that writes pack data to the database. */
    static class HibernatePackOutputStream extends DfsOutputStream {
        private final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
        private final SessionFactory sessionFactory;
        private final String repositoryName;
        private final String packName;
        private final String packExtension;
        private byte[] data;
        private boolean flushed;

        HibernatePackOutputStream(SessionFactory sessionFactory,
                                  String repositoryName, String packName, String packExtension) {
            this.sessionFactory = sessionFactory;
            this.repositoryName = repositoryName;
            this.packName = packName;
            this.packExtension = packExtension;
        }

        @Override
        public void write(byte[] buf, int off, int len) {
            data = null;
            buffer.write(buf, off, len);
        }

        @Override
        public int read(long position, ByteBuffer buf) {
            byte[] d = getData();
            int n = Math.min(buf.remaining(), d.length - (int) position);
            if (n == 0) return -1;
            buf.put(d, (int) position, n);
            return n;
        }

        byte[] getData() {
            if (data == null) data = buffer.toByteArray();
            return data;
        }

        @Override
        public void flush() {
            if (flushed) return;
            flushed = true;
            byte[] d = getData();
            try (Session session = sessionFactory.openSession()) {
                session.beginTransaction();
                GitPackEntity entity = new GitPackEntity();
                entity.setRepositoryName(repositoryName);
                entity.setPackName(packName);
                entity.setPackExtension(packExtension);
                entity.setData(d);
                entity.setFileSize(d.length);
                entity.setCreatedAt(Instant.now());
                session.persist(entity);
                session.getTransaction().commit();
            }
        }

        @Override
        public void close() {
            flush();
        }
    }

    /** ReadableChannel backed by a byte array. */
    static class ByteArrayReadableChannel implements ReadableChannel {
        private final byte[] data;
        private int position;
        private boolean open = true;

        ByteArrayReadableChannel(byte[] buf) { data = buf; }

        @Override public int read(ByteBuffer dst) {
            int n = Math.min(dst.remaining(), data.length - position);
            if (n == 0) return -1;
            dst.put(data, position, n);
            position += n;
            return n;
        }

        @Override public void close() { open = false; }
        @Override public boolean isOpen() { return open; }
        @Override public long position() { return position; }
        @Override public void position(long newPosition) { position = (int) newPosition; }
        @Override public long size() { return data.length; }
        @Override public int blockSize() { return 0; }
        @Override public void setReadAheadBytes(int b) { /* no-op */ }
    }
}