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 org.eclipse.jgit.storage.hibernate.objects;

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.eclipse.jgit.storage.hibernate.entity.GitPackEntity;
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.
 */
public class HibernateObjDatabase extends DfsObjDatabase {

	private final SessionFactory sessionFactory;

	private final String repositoryName;

	private static final AtomicInteger PACK_ID_COUNTER = new AtomicInteger();

	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;
	}

	/**
	 * Extract the base pack name from a DfsPackDescription.
	 * <p>
	 * DfsPackDescription stores the base name internally and
	 * {@code getFileName(ext)} appends the extension. We recover the
	 * base name by stripping the extension from any filename.
	 *
	 * @param desc
	 *            the pack description
	 * @return the base pack name without extension
	 */
	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 FROM GitPackEntity p WHERE p.repositoryName = :repo", //$NON-NLS-1$
					Object[].class)
					.setParameter("repo", repositoryName) //$NON-NLS-1$
					.getResultList();
			LinkedHashMap<String, DfsPackDescription> descMap = new LinkedHashMap<>();
			for (Object[] row : rows) {
				String name = (String) row[0];
				String ext = (String) row[1];
				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);
						break;
					}
				}
			}
			return new ArrayList<>(descMap.values());
		}
	}

	@Override
	protected DfsPackDescription newPack(PackSource source) {
		int id = PACK_ID_COUNTER.incrementAndGet();
		String name = "pack-" + id + "-" + source.name(); //$NON-NLS-1$ //$NON-NLS-2$
		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") //$NON-NLS-1$
							.setParameter("repo", repositoryName) //$NON-NLS-1$
							.setParameter("name", baseName(d)) //$NON-NLS-1$
							.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") //$NON-NLS-1$
						.setParameter("repo", repositoryName) //$NON-NLS-1$
						.setParameter("name", baseName(d)) //$NON-NLS-1$
						.executeUpdate();
			}
			session.getTransaction().commit();
		} catch (Exception e) {
			// Rollback should not throw
		}
	}

	@Override
	protected ReadableChannel openFile(DfsPackDescription desc, PackExt ext)
			throws FileNotFoundException, IOException {
		// TODO: For large repositories, consider streaming the BLOB directly
		// from the database rather than materialising it fully in memory here.
		// The current approach loads the entire pack/idx data into a byte[]
		// which can cause high memory usage for large pack files.
		try (Session session = sessionFactory.openSession()) {
			GitPackEntity entity = session.createQuery(
					"FROM GitPackEntity p WHERE p.repositoryName = :repo AND p.packName = :name AND p.packExtension = :ext", //$NON-NLS-1$
					GitPackEntity.class)
					.setParameter("repo", repositoryName) //$NON-NLS-1$
					.setParameter("name", baseName(desc)) //$NON-NLS-1$
					.setParameter("ext", ext.getExtension()).uniqueResult(); //$NON-NLS-1$
			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() {
		try (Session session = sessionFactory.openSession()) {
			Long count = session.createQuery(
					"SELECT COUNT(o) FROM GitObjectEntity o WHERE o.repositoryName = :repo", //$NON-NLS-1$
					Long.class).setParameter("repo", repositoryName) //$NON-NLS-1$
					.uniqueResult();
			return count != null ? count : 0;
		}
	}

	/**
	 * 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) {
			// Unnecessary on a byte array.
		}
	}
}