TaxonomyRelation.java

package com.taxonomy.catalog.model;

import com.taxonomy.model.RelationType;
import com.taxonomy.search.RelationEmbeddingBinder;
import com.taxonomy.shared.model.FloatArrayConverter;
import jakarta.persistence.Column;
import jakarta.persistence.Convert;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.Lob;
import jakarta.persistence.ManyToOne;
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.mapper.pojo.bridge.mapping.annotation.TypeBinderRef;
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.IndexedEmbedded;
import org.hibernate.search.mapper.pojo.mapping.definition.annotation.KeywordField;
import org.hibernate.search.mapper.pojo.mapping.definition.annotation.TypeBinding;

@Entity
@EntityListeners(PrimaryRepositorySeedRelationListener.class)
@Table(name = "taxonomy_relation",
        uniqueConstraints = @UniqueConstraint(
                name = "uk_taxonomy_relation_scope",
                columnNames = {
                        "repository_id",
                        "source_node_id",
                        "target_node_id",
                        "relation_type",
                        "workspace_scope_key"
                }),
        indexes = {
                @Index(name = "idx_rel_repository", columnList = "repository_id"),
                @Index(
                        name = "idx_rel_repository_workspace",
                        columnList = "repository_id, workspace_id"),
                @Index(name = "idx_rel_owner", columnList = "owner_username")
        })
@Indexed
@TypeBinding(binder = @TypeBinderRef(type = RelationEmbeddingBinder.class))
public class TaxonomyRelation {

    public static final String SHARED_SCOPE_KEY = "__shared__";

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

    /** Mandatory logical tenant key. */
    @Column(name = "repository_id", nullable = false, length = 255)
    @KeywordField
    private String repositoryId;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "source_node_id", nullable = false)
    @IndexedEmbedded(includePaths = {"code", "nameEn"})
    private TaxonomyNode sourceNode;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "target_node_id", nullable = false)
    @IndexedEmbedded(includePaths = {"code", "nameEn"})
    private TaxonomyNode targetNode;

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

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

    /** Non-null uniqueness key for both central and personal workspace rows. */
    @Column(name = "workspace_scope_key", nullable = false, length = 255)
    @KeywordField
    private String workspaceScopeKey = SHARED_SCOPE_KEY;

    @Column(name = "owner_username")
    @KeywordField
    private String ownerUsername;

    @Nationalized
    @Column(length = 2000)
    @FullTextField(analyzer = "english")
    private String description;

    @Nationalized
    private String provenance;

    private Integer weight;

    private boolean bidirectional = false;

    @Lob
    @Convert(converter = FloatArrayConverter.class)
    @Column(name = "semantic_embedding")
    private float[] semanticEmbedding;

    @GenericField
    @Column(name = "has_embedding")
    private boolean hasEmbedding;

    @PrePersist
    @PreUpdate
    void synchronizeTenantKeys() {
        repositoryId = requireText(repositoryId, "repositoryId");
        workspaceId = normalizeOptional(workspaceId);
        workspaceScopeKey = scopeKeyFor(workspaceId);
    }

    public static String scopeKeyFor(String workspaceId) {
        String normalized = normalizeOptional(workspaceId);
        return normalized == null ? SHARED_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 TaxonomyNode getSourceNode() { return sourceNode; }
    public void setSourceNode(TaxonomyNode sourceNode) { this.sourceNode = sourceNode; }

    public TaxonomyNode getTargetNode() { return targetNode; }
    public void setTargetNode(TaxonomyNode targetNode) { this.targetNode = targetNode; }

    public RelationType getRelationType() { return relationType; }
    public void setRelationType(RelationType relationType) { this.relationType = relationType; }

    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }

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

    public Integer getWeight() { return weight; }
    public void setWeight(Integer weight) { this.weight = weight; }

    public boolean isBidirectional() { return bidirectional; }
    public void setBidirectional(boolean bidirectional) { this.bidirectional = bidirectional; }

    public float[] getSemanticEmbedding() { return semanticEmbedding; }
    public void setSemanticEmbedding(float[] semanticEmbedding) {
        this.semanticEmbedding = semanticEmbedding;
        this.hasEmbedding = semanticEmbedding != null;
    }

    public boolean isHasEmbedding() { return hasEmbedding; }
    private void setHasEmbedding(boolean hasEmbedding) { this.hasEmbedding = hasEmbedding; }

    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 getOwnerUsername() { return ownerUsername; }
    public void setOwnerUsername(String ownerUsername) {
        this.ownerUsername = normalizeOptional(ownerUsername);
    }

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