TaxonomyRelationService.java
package com.taxonomy.catalog.service;
import com.taxonomy.catalog.model.TaxonomyNode;
import com.taxonomy.catalog.model.TaxonomyRelation;
import com.taxonomy.catalog.repository.TaxonomyNodeRepository;
import com.taxonomy.catalog.repository.TaxonomyRelationRepository;
import com.taxonomy.dto.TaxonomyRelationDto;
import com.taxonomy.model.RelationType;
import com.taxonomy.workspace.model.SystemRepository;
import com.taxonomy.workspace.service.RepositoryContext;
import com.taxonomy.workspace.service.SystemRepositoryService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
@Service
public class TaxonomyRelationService {
private static final Logger log = LoggerFactory.getLogger(TaxonomyRelationService.class);
private static final String SYSTEM_USER = "system";
private final TaxonomyRelationRepository relationRepository;
private final TaxonomyNodeRepository nodeRepository;
private final SystemRepositoryService systemRepositoryService;
public TaxonomyRelationService(
TaxonomyRelationRepository relationRepository,
TaxonomyNodeRepository nodeRepository,
SystemRepositoryService systemRepositoryService) {
this.relationRepository = relationRepository;
this.nodeRepository = nodeRepository;
this.systemRepositoryService = systemRepositoryService;
}
// ── Explicit repository/workspace tenant reads ─────────────────
@Transactional(readOnly = true)
public List<TaxonomyRelationDto> getRelationsForNodeInContext(
String code, RepositoryContext context) {
RepositoryContext tenant = requireContext(context);
List<TaxonomyRelation> relations = tenant.workspaceId() == null
? relationRepository.findCentralByRepositoryAndNodeCode(
tenant.repositoryId(), code)
: relationRepository.findVisibleByRepositoryAndWorkspaceAndNodeCode(
tenant.repositoryId(), tenant.workspaceId(), code);
return toDtos(relations);
}
@Transactional(readOnly = true)
public List<TaxonomyRelationDto> getRelationsByTypeInContext(
RelationType type, RepositoryContext context) {
RepositoryContext tenant = requireContext(context);
List<TaxonomyRelation> relations = tenant.workspaceId() == null
? relationRepository.findCentralByRepositoryAndRelationType(
tenant.repositoryId(), type)
: relationRepository.findVisibleByRepositoryAndWorkspaceAndRelationType(
tenant.repositoryId(), tenant.workspaceId(), type);
return toDtos(relations);
}
@Transactional(readOnly = true)
public List<TaxonomyRelationDto> getAllRelationsInContext(
RepositoryContext context) {
RepositoryContext tenant = requireContext(context);
List<TaxonomyRelation> relations = tenant.workspaceId() == null
? relationRepository.findCentralByRepository(tenant.repositoryId())
: relationRepository.findVisibleByRepositoryAndWorkspace(
tenant.repositoryId(), tenant.workspaceId());
return toDtos(relations);
}
/**
* Return whether an equivalent relation is visible in the selected tenant.
* Workspace contexts inherit the central baseline of the same repository;
* central contexts never scan another repository or unpublished workspace.
*/
@Transactional(readOnly = true)
public boolean relationExistsVisibleInContext(
String sourceCode,
String targetCode,
RelationType type,
RepositoryContext context) {
RepositoryContext tenant = requireContext(context);
List<TaxonomyRelation> existing = tenant.workspaceId() == null
? relationRepository.findCentralByRepositoryAndSourceTargetType(
tenant.repositoryId(), sourceCode, targetCode, type)
: relationRepository.findVisibleByRepositoryAndWorkspaceAndSourceTargetType(
tenant.repositoryId(),
tenant.workspaceId(),
sourceCode,
targetCode,
type);
return !existing.isEmpty();
}
// ── Explicit repository/workspace tenant writes ────────────────
@Transactional
public TaxonomyRelationDto createRelationInContext(
String sourceCode,
String targetCode,
RelationType type,
String description,
String provenance,
RepositoryContext context) {
RepositoryContext tenant = requireContext(context);
TaxonomyNode source = nodeRepository.findByCode(sourceCode)
.orElseThrow(() -> new IllegalArgumentException(
"Source node not found: " + sourceCode));
TaxonomyNode target = nodeRepository.findByCode(targetCode)
.orElseThrow(() -> new IllegalArgumentException(
"Target node not found: " + targetCode));
if (relationExistsVisibleInContext(sourceCode, targetCode, type, tenant)) {
throw new IllegalArgumentException(String.format(
"Relation already exists: %s --[%s]--> %s "
+ "(repository=%s, workspace=%s)",
sourceCode,
type,
targetCode,
tenant.repositoryId(),
tenant.workspaceId()));
}
TaxonomyRelation relation = new TaxonomyRelation();
relation.setRepositoryId(tenant.repositoryId());
relation.setSourceNode(source);
relation.setTargetNode(target);
relation.setRelationType(type);
relation.setDescription(description);
relation.setProvenance(provenance);
relation.setWorkspaceId(tenant.workspaceId());
relation.setOwnerUsername(tenant.username());
TaxonomyRelation saved = relationRepository.save(relation);
log.info(
"Created relation: {} --[{}]--> {} (repository={}, workspace={})",
sourceCode,
type,
targetCode,
tenant.repositoryId(),
tenant.workspaceId());
return toDto(saved);
}
/** Delete only from the exact repository and exact mutable workspace scope. */
@Transactional
public void deleteRelationInContext(Long id, RepositoryContext context) {
RepositoryContext tenant = requireContext(context);
TaxonomyRelation relation = relationRepository.findByIdInRepositoryWorkspace(
tenant.repositoryId(), id, tenant.workspaceId())
.orElseThrow(() -> new IllegalArgumentException(
"Relation not found in active repository/workspace: " + id));
relationRepository.delete(relation);
log.info(
"Deleted relation with id: {} (repository={}, workspace={})",
id,
tenant.repositoryId(),
tenant.workspaceId());
}
/** Delete matches only from the exact repository and exact workspace scope. */
@Transactional
public void deleteRelationBySourceTargetTypeInContext(
String sourceCode,
String targetCode,
RelationType type,
RepositoryContext context) {
RepositoryContext tenant = requireContext(context);
List<TaxonomyRelation> matches = tenant.workspaceId() == null
? relationRepository.findCentralByRepositoryAndSourceTargetType(
tenant.repositoryId(), sourceCode, targetCode, type)
: relationRepository
.findByRepositoryIdAndWorkspaceIdAndSourceNodeCodeAndTargetNodeCodeAndRelationType(
tenant.repositoryId(),
tenant.workspaceId(),
sourceCode,
targetCode,
type);
if (!matches.isEmpty()) {
relationRepository.deleteAll(matches);
log.info(
"Deleted {} relation(s): {} --[{}]--> {} "
+ "(repository={}, workspace={})",
matches.size(),
sourceCode,
type,
targetCode,
tenant.repositoryId(),
tenant.workspaceId());
}
}
@Transactional(readOnly = true)
public long countRelationsInContext(RepositoryContext context) {
RepositoryContext tenant = requireContext(context);
return tenant.workspaceId() == null
? relationRepository.countCentralByRepository(tenant.repositoryId())
: relationRepository.countVisibleByRepositoryAndWorkspace(
tenant.repositoryId(), tenant.workspaceId());
}
// ── Primary-repository compatibility boundary ──────────────────
// These overloads keep existing internal import/export and analysis callers
// working while they migrate. They resolve the catalog primary explicitly;
// no null workspace is ever interpreted as a global repository scope.
@Transactional(readOnly = true)
public List<TaxonomyRelationDto> getRelationsForNode(
String code, @Nullable String workspaceId) {
return getRelationsForNodeInContext(
code, primaryContext(workspaceId, SYSTEM_USER));
}
@Transactional(readOnly = true)
public List<TaxonomyRelationDto> getRelationsByType(
RelationType type, @Nullable String workspaceId) {
return getRelationsByTypeInContext(
type, primaryContext(workspaceId, SYSTEM_USER));
}
@Transactional(readOnly = true)
public List<TaxonomyRelationDto> getAllRelations(@Nullable String workspaceId) {
return getAllRelationsInContext(primaryContext(workspaceId, SYSTEM_USER));
}
@Transactional(readOnly = true)
public boolean relationExistsVisible(
String sourceCode,
String targetCode,
RelationType type,
@Nullable String workspaceId) {
return relationExistsVisibleInContext(
sourceCode,
targetCode,
type,
primaryContext(workspaceId, SYSTEM_USER));
}
@Transactional(readOnly = true)
public List<TaxonomyRelationDto> getRelationsForNode(String code) {
return getRelationsForNode(code, null);
}
@Transactional(readOnly = true)
public List<TaxonomyRelationDto> getRelationsByType(RelationType type) {
return getRelationsByType(type, null);
}
@Transactional(readOnly = true)
public List<TaxonomyRelationDto> getAllRelations() {
return getAllRelations(null);
}
@Transactional
public TaxonomyRelationDto createRelation(
String sourceCode,
String targetCode,
RelationType type,
String description,
String provenance,
@Nullable String workspaceId,
@Nullable String ownerUsername) {
return createRelationInContext(
sourceCode,
targetCode,
type,
description,
provenance,
primaryContext(workspaceId, ownerUsername));
}
@Transactional
public TaxonomyRelationDto createRelation(
String sourceCode,
String targetCode,
RelationType type,
String description,
String provenance) {
return createRelation(
sourceCode,
targetCode,
type,
description,
provenance,
null,
SYSTEM_USER);
}
@Transactional
public void deleteRelation(Long id, @Nullable String workspaceId) {
deleteRelationInContext(id, primaryContext(workspaceId, SYSTEM_USER));
}
@Transactional
public void deleteRelation(Long id) {
deleteRelation(id, null);
}
@Transactional
public void deleteRelationBySourceTargetType(
String sourceCode,
String targetCode,
RelationType type,
@Nullable String workspaceId) {
deleteRelationBySourceTargetTypeInContext(
sourceCode,
targetCode,
type,
primaryContext(workspaceId, SYSTEM_USER));
}
@Transactional
public void deleteRelationBySourceTargetType(
String sourceCode,
String targetCode,
RelationType type) {
deleteRelationBySourceTargetType(sourceCode, targetCode, type, null);
}
@Transactional(readOnly = true)
public long countRelations(@Nullable String workspaceId) {
return countRelationsInContext(primaryContext(workspaceId, SYSTEM_USER));
}
@Transactional(readOnly = true)
public long countRelations() {
return countRelations(null);
}
public TaxonomyRelationDto toDto(TaxonomyRelation relation) {
TaxonomyRelationDto dto = new TaxonomyRelationDto();
dto.setId(relation.getId());
dto.setSourceCode(relation.getSourceNode().getCode());
dto.setSourceName(relation.getSourceNode().getNameEn());
dto.setTargetCode(relation.getTargetNode().getCode());
dto.setTargetName(relation.getTargetNode().getNameEn());
dto.setRelationType(relation.getRelationType().name());
dto.setDescription(relation.getDescription());
dto.setProvenance(relation.getProvenance());
dto.setWeight(relation.getWeight());
dto.setBidirectional(relation.isBidirectional());
return dto;
}
private RepositoryContext primaryContext(
@Nullable String workspaceId, @Nullable String username) {
SystemRepository primary = systemRepositoryService.getPrimaryRepository();
String user = username == null || username.isBlank()
? SYSTEM_USER
: username.strip();
String workspace = workspaceId == null || workspaceId.isBlank()
? null
: workspaceId.strip();
return workspace == null
? RepositoryContext.centralRead(
primary.getRepositoryId(), primary.getDefaultBranch(), user)
: RepositoryContext.workspace(
primary.getRepositoryId(),
workspace,
primary.getDefaultBranch(),
user);
}
private static RepositoryContext requireContext(RepositoryContext context) {
if (context == null) {
throw new IllegalArgumentException("RepositoryContext must not be null");
}
return context;
}
private List<TaxonomyRelationDto> toDtos(List<TaxonomyRelation> relations) {
List<TaxonomyRelationDto> dtos = new ArrayList<>(relations.size());
for (TaxonomyRelation relation : relations) {
dtos.add(toDto(relation));
}
return dtos;
}
}