ArchitectureCommitIndex.java

package com.taxonomy.architecture.model;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import org.hibernate.annotations.Nationalized;
import org.hibernate.search.engine.backend.types.Sortable;
import org.hibernate.search.mapper.pojo.mapping.definition.annotation.FullTextField;
import org.hibernate.search.mapper.pojo.mapping.definition.annotation.GenericField;
import org.hibernate.search.mapper.pojo.mapping.definition.annotation.Indexed;
import org.hibernate.search.mapper.pojo.mapping.definition.annotation.KeywordField;

import java.time.Instant;

/**
 * Repository-scoped index entry for one DSL commit on one branch.
 *
 * <p>The same Git commit may legitimately occur in independent central
 * repositories, personal workspaces and multiple branches. The relational and
 * Lucene identity is therefore tenant/branch-local rather than globally keyed by
 * {@code commitId}. Full-text queries must always filter {@code repositoryId}
 * and {@code workspaceScopeKey} before applying content predicates.</p>
 */
@Entity
@Indexed
@Table(name = "architecture_commit_index",
       indexes = {
           @Index(name = "idx_commit_index_repository", columnList = "repository_id"),
           @Index(name = "idx_commit_index_repository_workspace",
                   columnList = "repository_id, workspace_id"),
           @Index(name = "idx_commit_index_scope_branch",
                   columnList = "repository_id, workspace_scope_key, branch")
       },
       uniqueConstraints = @UniqueConstraint(
               name = "uq_commit_index_repository_workspace_branch_commit",
               columnNames = {
                       "repository_id",
                       "workspace_scope_key",
                       "branch",
                       "commit_id"
               }))
public class ArchitectureCommitIndex {

    public static final String CENTRAL_SCOPE_KEY = "__shared__";

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "repository_id", nullable = false, length = 255)
    @KeywordField
    private String repositoryId;

    @Column(name = "workspace_id", length = 255)
    @KeywordField
    private String workspaceId;

    /** Non-null key used for central/workspace uniqueness and search filters. */
    @Column(name = "workspace_scope_key", nullable = false, length = 255)
    @KeywordField
    private String workspaceScopeKey = CENTRAL_SCOPE_KEY;

    @Nationalized
    @Column(name = "commit_id", nullable = false, length = 40)
    @KeywordField
    private String commitId;

    @Nationalized
    @Column
    @KeywordField
    private String author;

    @Column(name = "commit_timestamp", nullable = false)
    @GenericField(sortable = Sortable.YES)
    private Instant commitTimestamp;

    @Nationalized
    @Column(length = 500)
    @FullTextField(analyzer = "english")
    private String message;

    @Nationalized
    @Column(name = "changed_files", length = 2000)
    private String changedFiles;

    @Nationalized
    @Column(name = "tokenized_change_text", length = 10000)
    @FullTextField(analyzer = "dsl")
    private String tokenizedChangeText;

    @Nationalized
    @Column(name = "affected_element_ids", length = 2000)
    @FullTextField(analyzer = "csv-keyword")
    private String affectedElementIds;

    @Nationalized
    @Column(name = "affected_relation_ids", length = 2000)
    @FullTextField(analyzer = "csv-keyword")
    private String affectedRelationIds;

    @Nationalized
    @Column(nullable = false, length = 255)
    @KeywordField
    private String branch;

    @Column(name = "indexed_at", nullable = false)
    private Instant indexedAt;

    @PrePersist
    protected void onCreate() {
        if (indexedAt == null) {
            indexedAt = Instant.now();
        }
        synchronizeTenantKeys();
    }

    @PreUpdate
    protected void onUpdate() {
        synchronizeTenantKeys();
    }

    private void synchronizeTenantKeys() {
        repositoryId = requireText(repositoryId, "repositoryId");
        workspaceId = normalizeOptional(workspaceId);
        workspaceScopeKey = scopeKeyFor(workspaceId);
        commitId = requireText(commitId, "commitId");
        branch = requireText(branch, "branch");
        author = normalizeOptional(author);
    }

    public static String scopeKeyFor(String workspaceId) {
        String normalized = normalizeOptional(workspaceId);
        return normalized == null ? CENTRAL_SCOPE_KEY : normalized;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getRepositoryId() { return repositoryId; }
    public void setRepositoryId(String repositoryId) {
        this.repositoryId = requireText(repositoryId, "repositoryId");
    }

    public String getWorkspaceId() { return workspaceId; }
    public void setWorkspaceId(String workspaceId) {
        this.workspaceId = normalizeOptional(workspaceId);
        this.workspaceScopeKey = scopeKeyFor(this.workspaceId);
    }

    public String getWorkspaceScopeKey() { return workspaceScopeKey; }

    public String getCommitId() { return commitId; }
    public void setCommitId(String commitId) {
        this.commitId = requireText(commitId, "commitId");
    }

    public String getAuthor() { return author; }
    public void setAuthor(String author) { this.author = normalizeOptional(author); }

    public Instant getCommitTimestamp() { return commitTimestamp; }
    public void setCommitTimestamp(Instant commitTimestamp) {
        this.commitTimestamp = commitTimestamp;
    }

    public String getMessage() { return message; }
    public void setMessage(String message) { this.message = message; }

    public String getChangedFiles() { return changedFiles; }
    public void setChangedFiles(String changedFiles) { this.changedFiles = changedFiles; }

    public String getTokenizedChangeText() { return tokenizedChangeText; }
    public void setTokenizedChangeText(String tokenizedChangeText) {
        this.tokenizedChangeText = tokenizedChangeText;
    }

    public String getAffectedElementIds() { return affectedElementIds; }
    public void setAffectedElementIds(String affectedElementIds) {
        this.affectedElementIds = affectedElementIds;
    }

    public String getAffectedRelationIds() { return affectedRelationIds; }
    public void setAffectedRelationIds(String affectedRelationIds) {
        this.affectedRelationIds = affectedRelationIds;
    }

    public String getBranch() { return branch; }
    public void setBranch(String branch) {
        this.branch = requireText(branch, "branch");
    }

    public Instant getIndexedAt() { return indexedAt; }
    public void setIndexedAt(Instant indexedAt) { this.indexedAt = indexedAt; }

    private static String normalizeOptional(String value) {
        return value == null || value.isBlank() ? null : value.strip();
    }

    private static String requireText(String value, String field) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException(field + " must not be blank");
        }
        return value.strip();
    }
}