RelationDecisionProjection.java

package com.taxonomy.relations.model;

import com.taxonomy.model.RelationType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
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 jakarta.persistence.Version;

import java.time.Instant;
import java.util.Objects;

/**
 * Rebuildable branch-local projection of one relation decision from authoritative Git.
 *
 * <p>This table is deliberately separate from the legacy relation read model. A
 * workspace or variant can therefore record a removal tombstone without making an
 * inherited central relation visible again, and independent branches can project the
 * same relation identity without overwriting each other.</p>
 */
@Entity
@Table(name = "relation_decision_projection",
        indexes = {
                @Index(
                        name = "idx_rel_decision_repository",
                        columnList = "repository_id"),
                @Index(
                        name = "idx_rel_decision_scope_branch",
                        columnList = "repository_id, workspace_scope_key, branch"),
                @Index(
                        name = "idx_rel_decision_commit",
                        columnList = "authoritative_commit_id")
        },
        uniqueConstraints = @UniqueConstraint(
                name = "uq_rel_decision_scope_identity",
                columnNames = {
                        "repository_id",
                        "workspace_scope_key",
                        "branch",
                        "source_code",
                        "relation_type",
                        "target_code"
                }))
public class RelationDecisionProjection {

    public static final String CENTRAL_SCOPE_KEY = "__shared__";

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

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

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

    @Column(name = "workspace_scope_key", nullable = false, length = 255)
    private String workspaceScopeKey = CENTRAL_SCOPE_KEY;

    @Column(name = "branch", nullable = false, length = 255)
    private String branch;

    @Column(name = "source_code", nullable = false, length = 255)
    private String sourceCode;

    @Enumerated(EnumType.STRING)
    @Column(name = "relation_type", nullable = false, length = 64)
    private RelationType relationType;

    @Column(name = "target_code", nullable = false, length = 255)
    private String targetCode;

    @Column(name = "relation_present", nullable = false)
    private boolean relationPresent;

    @Column(name = "status", length = 255)
    private String status;

    @Column(name = "confidence")
    private Double confidence;

    @Column(name = "provenance", length = 255)
    private String provenance;

    @Column(name = "authoritative_commit_id", nullable = false, length = 40)
    private String authoritativeCommitId;

    @Column(name = "causation_id", nullable = false, length = 255)
    private String causationId;

    @Column(name = "updated_at", nullable = false)
    private Instant updatedAt;

    @Version
    @Column(name = "version", nullable = false)
    private long version;

    @PrePersist
    void onCreate() {
        synchronize();
        if (updatedAt == null) {
            updatedAt = Instant.now();
        }
    }

    @PreUpdate
    void onUpdate() {
        synchronize();
        updatedAt = Instant.now();
    }

    private void synchronize() {
        repositoryId = requireText(repositoryId, "repositoryId");
        workspaceId = normalizeOptional(workspaceId);
        workspaceScopeKey = scopeKeyFor(workspaceId);
        branch = requireText(branch, "branch");
        sourceCode = requireText(sourceCode, "sourceCode");
        relationType = Objects.requireNonNull(relationType, "relationType");
        targetCode = requireText(targetCode, "targetCode");
        status = normalizeOptional(status);
        confidence = normalizeConfidence(confidence);
        provenance = normalizeOptional(provenance);
        authoritativeCommitId = requireText(
                authoritativeCommitId, "authoritativeCommitId");
        causationId = requireText(causationId, "causationId");
    }

    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 getBranch() {
        return branch;
    }

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

    public String getSourceCode() {
        return sourceCode;
    }

    public void setSourceCode(String sourceCode) {
        this.sourceCode = requireText(sourceCode, "sourceCode");
    }

    public RelationType getRelationType() {
        return relationType;
    }

    public void setRelationType(RelationType relationType) {
        this.relationType = Objects.requireNonNull(relationType, "relationType");
    }

    public String getTargetCode() {
        return targetCode;
    }

    public void setTargetCode(String targetCode) {
        this.targetCode = requireText(targetCode, "targetCode");
    }

    public boolean isRelationPresent() {
        return relationPresent;
    }

    public void setRelationPresent(boolean relationPresent) {
        this.relationPresent = relationPresent;
    }

    public String getStatus() {
        return status;
    }

    public void setStatus(String status) {
        this.status = normalizeOptional(status);
    }

    public Double getConfidence() {
        return confidence;
    }

    public void setConfidence(Double confidence) {
        this.confidence = normalizeConfidence(confidence);
    }

    public String getProvenance() {
        return provenance;
    }

    public void setProvenance(String provenance) {
        this.provenance = normalizeOptional(provenance);
    }

    public String getAuthoritativeCommitId() {
        return authoritativeCommitId;
    }

    public void setAuthoritativeCommitId(String authoritativeCommitId) {
        this.authoritativeCommitId = requireText(
                authoritativeCommitId, "authoritativeCommitId");
    }

    public String getCausationId() {
        return causationId;
    }

    public void setCausationId(String causationId) {
        this.causationId = requireText(causationId, "causationId");
    }

    public Instant getUpdatedAt() {
        return updatedAt;
    }

    public long getVersion() {
        return version;
    }

    private static Double normalizeConfidence(Double value) {
        if (value == null) {
            return null;
        }
        if (!Double.isFinite(value) || value < 0.0 || value > 1.0) {
            throw new IllegalArgumentException(
                    "confidence must be finite and between 0.0 and 1.0");
        }
        return value;
    }

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