mirror of
				https://github.com/open-metadata/OpenMetadata.git
				synced 2025-11-03 20:19:31 +00:00 
			
		
		
		
	* Fix #21533 - Add a tool to openmetadata-ops.sh to delete orphaned relations * Fix #21533 - Add a tool to openmetadata-ops.sh to delete orphaned relations * Add exit 1 and make dry-run default * Add exit 1 and make dry-run default * Fix checkstyle --------- Co-authored-by: Pere Miquel Brull <peremiquelbrull@gmail.com> (cherry picked from commit 1c5772d6f84d86e6c767d475b2ecb7dde1372cac)
This commit is contained in:
		
							parent
							
								
									25ef5a6f4c
								
							
						
					
					
						commit
						4381b7bad0
					
				@ -1346,6 +1346,15 @@ public interface CollectionDAO {
 | 
			
		||||
    List<EntityRelationshipObject> getRecordWithOffset(
 | 
			
		||||
        @Bind("relation") int relation, @Bind("offset") long offset, @Bind("limit") int limit);
 | 
			
		||||
 | 
			
		||||
    @SqlQuery(
 | 
			
		||||
        "SELECT toId, toEntity, fromId, fromEntity, relation, json, jsonSchema FROM entity_relationship ORDER BY fromId, toId LIMIT :limit OFFSET :offset")
 | 
			
		||||
    @RegisterRowMapper(RelationshipObjectMapper.class)
 | 
			
		||||
    List<EntityRelationshipObject> getAllRelationshipsPaginated(
 | 
			
		||||
        @Bind("offset") long offset, @Bind("limit") int limit);
 | 
			
		||||
 | 
			
		||||
    @SqlQuery("SELECT COUNT(*) FROM entity_relationship")
 | 
			
		||||
    long getTotalRelationshipCount();
 | 
			
		||||
 | 
			
		||||
    //
 | 
			
		||||
    // Delete Operations
 | 
			
		||||
    //
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,374 @@
 | 
			
		||||
/*
 | 
			
		||||
 *  Copyright 2021 Collate
 | 
			
		||||
 *  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
 *  you may not use this file except in compliance with the License.
 | 
			
		||||
 *  You may obtain a copy of the License at
 | 
			
		||||
 *  http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
 *  Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
 *  distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
 *  See the License for the specific language governing permissions and
 | 
			
		||||
 *  limitations under the License.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
package org.openmetadata.service.util;
 | 
			
		||||
 | 
			
		||||
import static org.openmetadata.service.util.OpenMetadataOperations.printToAsciiTable;
 | 
			
		||||
 | 
			
		||||
import java.util.ArrayList;
 | 
			
		||||
import java.util.Arrays;
 | 
			
		||||
import java.util.HashMap;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.Map;
 | 
			
		||||
import java.util.UUID;
 | 
			
		||||
import lombok.AllArgsConstructor;
 | 
			
		||||
import lombok.Builder;
 | 
			
		||||
import lombok.Data;
 | 
			
		||||
import lombok.NoArgsConstructor;
 | 
			
		||||
import lombok.extern.slf4j.Slf4j;
 | 
			
		||||
import org.openmetadata.service.Entity;
 | 
			
		||||
import org.openmetadata.service.exception.EntityNotFoundException;
 | 
			
		||||
import org.openmetadata.service.jdbi3.CollectionDAO;
 | 
			
		||||
import org.openmetadata.service.jdbi3.EntityRepository;
 | 
			
		||||
 | 
			
		||||
@Slf4j
 | 
			
		||||
public class EntityRelationshipCleanup {
 | 
			
		||||
 | 
			
		||||
  private final CollectionDAO collectionDAO;
 | 
			
		||||
  private final Map<String, EntityRepository<?>> entityRepositories;
 | 
			
		||||
  private final boolean dryRun;
 | 
			
		||||
 | 
			
		||||
  public EntityRelationshipCleanup(CollectionDAO collectionDAO, boolean dryRun) {
 | 
			
		||||
    this.collectionDAO = collectionDAO;
 | 
			
		||||
    this.dryRun = dryRun;
 | 
			
		||||
    this.entityRepositories = new HashMap<>();
 | 
			
		||||
    initializeEntityRepositories();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Data
 | 
			
		||||
  @Builder
 | 
			
		||||
  @NoArgsConstructor
 | 
			
		||||
  @AllArgsConstructor
 | 
			
		||||
  public static class OrphanedRelationship {
 | 
			
		||||
    private String fromId;
 | 
			
		||||
    private String toId;
 | 
			
		||||
    private String fromEntity;
 | 
			
		||||
    private String toEntity;
 | 
			
		||||
    private int relation;
 | 
			
		||||
    private String reason;
 | 
			
		||||
    private String relationshipName;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Data
 | 
			
		||||
  @Builder
 | 
			
		||||
  @NoArgsConstructor
 | 
			
		||||
  @AllArgsConstructor
 | 
			
		||||
  public static class CleanupResult {
 | 
			
		||||
    private int totalRelationshipsScanned;
 | 
			
		||||
    private int orphanedRelationshipsFound;
 | 
			
		||||
    private int relationshipsDeleted;
 | 
			
		||||
    private List<OrphanedRelationship> orphanedRelationships;
 | 
			
		||||
    private Map<String, Integer> orphansByEntityType;
 | 
			
		||||
    private Map<String, Integer> orphansByRelationType;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private void initializeEntityRepositories() {
 | 
			
		||||
    for (String entityType : Entity.getEntityList()) {
 | 
			
		||||
      try {
 | 
			
		||||
        EntityRepository<?> repository = Entity.getEntityRepository(entityType);
 | 
			
		||||
        entityRepositories.put(entityType, repository);
 | 
			
		||||
      } catch (EntityNotFoundException e) {
 | 
			
		||||
        LOG.error("No repository found for entity type: {}", entityType);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public CleanupResult performCleanup(int batchSize) {
 | 
			
		||||
    LOG.info(
 | 
			
		||||
        "Starting entity relationship cleanup. Dry run: {}, Batch size: {}", dryRun, batchSize);
 | 
			
		||||
 | 
			
		||||
    CleanupResult result =
 | 
			
		||||
        CleanupResult.builder()
 | 
			
		||||
            .orphanedRelationships(new ArrayList<>())
 | 
			
		||||
            .orphansByEntityType(new HashMap<>())
 | 
			
		||||
            .orphansByRelationType(new HashMap<>())
 | 
			
		||||
            .build();
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      long totalRelationships = collectionDAO.relationshipDAO().getTotalRelationshipCount();
 | 
			
		||||
      result.setTotalRelationshipsScanned((int) totalRelationships);
 | 
			
		||||
 | 
			
		||||
      LOG.info(
 | 
			
		||||
          "Found {} total relationships to scan. Processing in batches of {}",
 | 
			
		||||
          totalRelationships,
 | 
			
		||||
          batchSize);
 | 
			
		||||
 | 
			
		||||
      long offset = 0;
 | 
			
		||||
      int processedCount = 0;
 | 
			
		||||
      int batchNumber = 1;
 | 
			
		||||
 | 
			
		||||
      while (offset < totalRelationships) {
 | 
			
		||||
        LOG.info("Processing batch {} (offset: {}, limit: {})", batchNumber, offset, batchSize);
 | 
			
		||||
 | 
			
		||||
        List<CollectionDAO.EntityRelationshipObject> relationshipBatch =
 | 
			
		||||
            collectionDAO.relationshipDAO().getAllRelationshipsPaginated(offset, batchSize);
 | 
			
		||||
 | 
			
		||||
        if (relationshipBatch.isEmpty()) {
 | 
			
		||||
          LOG.info("No more relationships to process");
 | 
			
		||||
          break;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        for (CollectionDAO.EntityRelationshipObject relationship : relationshipBatch) {
 | 
			
		||||
          OrphanedRelationship orphan = validateRelationship(relationship);
 | 
			
		||||
          if (orphan != null) {
 | 
			
		||||
            result.getOrphanedRelationships().add(orphan);
 | 
			
		||||
 | 
			
		||||
            result
 | 
			
		||||
                .getOrphansByEntityType()
 | 
			
		||||
                .merge(orphan.getFromEntity() + "->" + orphan.getToEntity(), 1, Integer::sum);
 | 
			
		||||
            result.getOrphansByRelationType().merge(orphan.getRelationshipName(), 1, Integer::sum);
 | 
			
		||||
          }
 | 
			
		||||
          processedCount++;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        offset += relationshipBatch.size();
 | 
			
		||||
        batchNumber++;
 | 
			
		||||
 | 
			
		||||
        if (processedCount % (batchSize * 10) == 0 || offset >= totalRelationships) {
 | 
			
		||||
          LOG.info(
 | 
			
		||||
              "Progress: {}/{} relationships processed, {} orphaned relationships found",
 | 
			
		||||
              processedCount,
 | 
			
		||||
              totalRelationships,
 | 
			
		||||
              result.getOrphanedRelationships().size());
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      result.setOrphanedRelationshipsFound(result.getOrphanedRelationships().size());
 | 
			
		||||
 | 
			
		||||
      LOG.info(
 | 
			
		||||
          "Completed scanning {} relationships. Found {} orphaned relationships",
 | 
			
		||||
          processedCount,
 | 
			
		||||
          result.getOrphanedRelationshipsFound());
 | 
			
		||||
 | 
			
		||||
      displayOrphanedRelationships(result);
 | 
			
		||||
      if (!dryRun && !result.getOrphanedRelationships().isEmpty()) {
 | 
			
		||||
        result.setRelationshipsDeleted(
 | 
			
		||||
            deleteOrphanedRelationships(result.getOrphanedRelationships()));
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      LOG.info(
 | 
			
		||||
          "Entity relationship cleanup completed. Scanned: {}, Found: {}, Deleted: {}",
 | 
			
		||||
          processedCount,
 | 
			
		||||
          result.getOrphanedRelationshipsFound(),
 | 
			
		||||
          result.getRelationshipsDeleted());
 | 
			
		||||
 | 
			
		||||
    } catch (Exception e) {
 | 
			
		||||
      LOG.error("Error during entity relationship cleanup", e);
 | 
			
		||||
      throw new RuntimeException("Entity relationship cleanup failed", e);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return result;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private OrphanedRelationship validateRelationship(
 | 
			
		||||
      CollectionDAO.EntityRelationshipObject relationship) {
 | 
			
		||||
    try {
 | 
			
		||||
      UUID fromId = UUID.fromString(relationship.getFromId());
 | 
			
		||||
      UUID toId = UUID.fromString(relationship.getToId());
 | 
			
		||||
      String fromEntity = relationship.getFromEntity();
 | 
			
		||||
      String toEntity = relationship.getToEntity();
 | 
			
		||||
      boolean fromExists = entityExists(fromId, fromEntity);
 | 
			
		||||
      boolean toExists = entityExists(toId, toEntity);
 | 
			
		||||
 | 
			
		||||
      if (fromExists && toExists) {
 | 
			
		||||
        return null;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      String reason;
 | 
			
		||||
      if (!fromExists && !toExists) {
 | 
			
		||||
        reason = "Both fromEntity and toEntity do not exist";
 | 
			
		||||
      } else if (!fromExists) {
 | 
			
		||||
        reason = "fromEntity does not exist";
 | 
			
		||||
      } else {
 | 
			
		||||
        reason = "toEntity does not exist";
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return OrphanedRelationship.builder()
 | 
			
		||||
          .fromId(relationship.getFromId())
 | 
			
		||||
          .toId(relationship.getToId())
 | 
			
		||||
          .fromEntity(fromEntity)
 | 
			
		||||
          .toEntity(toEntity)
 | 
			
		||||
          .relation(relationship.getRelation())
 | 
			
		||||
          .reason(reason)
 | 
			
		||||
          .relationshipName(getRelationshipName(relationship.getRelation()))
 | 
			
		||||
          .build();
 | 
			
		||||
 | 
			
		||||
    } catch (Exception e) {
 | 
			
		||||
      LOG.debug(
 | 
			
		||||
          "Error validating relationship {}->{}: {}",
 | 
			
		||||
          relationship.getFromId(),
 | 
			
		||||
          relationship.getToId(),
 | 
			
		||||
          e.getMessage());
 | 
			
		||||
 | 
			
		||||
      return OrphanedRelationship.builder()
 | 
			
		||||
          .fromId(relationship.getFromId())
 | 
			
		||||
          .toId(relationship.getToId())
 | 
			
		||||
          .fromEntity(relationship.getFromEntity())
 | 
			
		||||
          .toEntity(relationship.getToEntity())
 | 
			
		||||
          .relation(relationship.getRelation())
 | 
			
		||||
          .reason("Validation error: " + e.getMessage())
 | 
			
		||||
          .relationshipName(getRelationshipName(relationship.getRelation()))
 | 
			
		||||
          .build();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private boolean entityExists(UUID entityId, String entityType) {
 | 
			
		||||
    try {
 | 
			
		||||
      EntityRepository<?> repository = entityRepositories.get(entityType);
 | 
			
		||||
      if (repository == null) {
 | 
			
		||||
        LOG.debug("No repository found for entity type: {}", entityType);
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
      repository.get(null, entityId, EntityUtil.Fields.EMPTY_FIELDS);
 | 
			
		||||
      return true;
 | 
			
		||||
    } catch (EntityNotFoundException e) {
 | 
			
		||||
      LOG.debug("Entity {}:{} not found", entityType, entityId);
 | 
			
		||||
      return false;
 | 
			
		||||
    } catch (Exception e) {
 | 
			
		||||
      LOG.debug(
 | 
			
		||||
          "Error checking existence of entity {}:{}: {}", entityType, entityId, e.getMessage());
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Deletes orphaned relationships from the database
 | 
			
		||||
   *
 | 
			
		||||
   * @param orphanedRelationships List of orphaned relationships to delete
 | 
			
		||||
   * @return Number of relationships successfully deleted
 | 
			
		||||
   */
 | 
			
		||||
  private int deleteOrphanedRelationships(List<OrphanedRelationship> orphanedRelationships) {
 | 
			
		||||
    LOG.info("Deleting {} orphaned relationships", orphanedRelationships.size());
 | 
			
		||||
    int deletedCount = 0;
 | 
			
		||||
 | 
			
		||||
    for (OrphanedRelationship orphan : orphanedRelationships) {
 | 
			
		||||
      try {
 | 
			
		||||
        UUID fromId = UUID.fromString(orphan.getFromId());
 | 
			
		||||
        UUID toId = UUID.fromString(orphan.getToId());
 | 
			
		||||
 | 
			
		||||
        int deleted =
 | 
			
		||||
            collectionDAO
 | 
			
		||||
                .relationshipDAO()
 | 
			
		||||
                .delete(
 | 
			
		||||
                    fromId,
 | 
			
		||||
                    orphan.getFromEntity(),
 | 
			
		||||
                    toId,
 | 
			
		||||
                    orphan.getToEntity(),
 | 
			
		||||
                    orphan.getRelation());
 | 
			
		||||
 | 
			
		||||
        if (deleted > 0) {
 | 
			
		||||
          deletedCount++;
 | 
			
		||||
          LOG.debug(
 | 
			
		||||
              "Deleted orphaned relationship: {} {} -> {} {}",
 | 
			
		||||
              orphan.getFromEntity(),
 | 
			
		||||
              orphan.getFromId(),
 | 
			
		||||
              orphan.getToEntity(),
 | 
			
		||||
              orphan.getToId());
 | 
			
		||||
        }
 | 
			
		||||
      } catch (Exception e) {
 | 
			
		||||
        LOG.error(
 | 
			
		||||
            "Failed to delete orphaned relationship: {} {} -> {} {}: {}",
 | 
			
		||||
            orphan.getFromEntity(),
 | 
			
		||||
            orphan.getFromId(),
 | 
			
		||||
            orphan.getToEntity(),
 | 
			
		||||
            orphan.getToId(),
 | 
			
		||||
            e.getMessage());
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    LOG.info(
 | 
			
		||||
        "Successfully deleted {} out of {} orphaned relationships",
 | 
			
		||||
        deletedCount,
 | 
			
		||||
        orphanedRelationships.size());
 | 
			
		||||
    return deletedCount;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private void displayOrphanedRelationships(CleanupResult result) {
 | 
			
		||||
    if (result.getOrphanedRelationships().isEmpty()) {
 | 
			
		||||
      LOG.info("No orphaned relationships found. All entity relationships are valid.");
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    LOG.info("Found {} orphaned relationships", result.getOrphanedRelationshipsFound());
 | 
			
		||||
 | 
			
		||||
    // Display detailed table of orphaned relationships
 | 
			
		||||
    List<String> columns =
 | 
			
		||||
        Arrays.asList("From Entity", "From ID", "To Entity", "To ID", "Relation", "Reason");
 | 
			
		||||
 | 
			
		||||
    List<List<String>> rows = new ArrayList<>();
 | 
			
		||||
    for (OrphanedRelationship orphan : result.getOrphanedRelationships()) {
 | 
			
		||||
      rows.add(
 | 
			
		||||
          Arrays.asList(
 | 
			
		||||
              orphan.getFromEntity(),
 | 
			
		||||
              orphan.getFromId(),
 | 
			
		||||
              orphan.getToEntity(),
 | 
			
		||||
              orphan.getToId(),
 | 
			
		||||
              orphan.getRelationshipName(),
 | 
			
		||||
              orphan.getReason()));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    printToAsciiTable(columns, rows, "No orphaned relationships found");
 | 
			
		||||
 | 
			
		||||
    // Display summary statistics
 | 
			
		||||
    displaySummaryStatistics(result);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private void displaySummaryStatistics(CleanupResult result) {
 | 
			
		||||
    if (!result.getOrphansByEntityType().isEmpty()) {
 | 
			
		||||
      LOG.info("Orphaned relationships by entity type:");
 | 
			
		||||
      List<String> entityColumns = Arrays.asList("Entity Type Pair", "Count");
 | 
			
		||||
      List<List<String>> entityRows = new ArrayList<>();
 | 
			
		||||
 | 
			
		||||
      result.getOrphansByEntityType().entrySet().stream()
 | 
			
		||||
          .sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
 | 
			
		||||
          .forEach(
 | 
			
		||||
              entry -> entityRows.add(Arrays.asList(entry.getKey(), entry.getValue().toString())));
 | 
			
		||||
 | 
			
		||||
      printToAsciiTable(entityColumns, entityRows, "No entity type statistics");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!result.getOrphansByRelationType().isEmpty()) {
 | 
			
		||||
      LOG.info("Orphaned relationships by relation type:");
 | 
			
		||||
      List<String> relationColumns = Arrays.asList("Relation Type", "Count");
 | 
			
		||||
      List<List<String>> relationRows = new ArrayList<>();
 | 
			
		||||
 | 
			
		||||
      result.getOrphansByRelationType().entrySet().stream()
 | 
			
		||||
          .sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
 | 
			
		||||
          .forEach(
 | 
			
		||||
              entry ->
 | 
			
		||||
                  relationRows.add(Arrays.asList(entry.getKey(), entry.getValue().toString())));
 | 
			
		||||
 | 
			
		||||
      printToAsciiTable(relationColumns, relationRows, "No relation type statistics");
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private String getRelationshipName(int relation) {
 | 
			
		||||
    // Map common relationship types to names
 | 
			
		||||
    // These constants should ideally be imported from the actual schema
 | 
			
		||||
    return switch (relation) {
 | 
			
		||||
      case 10 -> "CONTAINS";
 | 
			
		||||
      case 11 -> "CREATED_BY";
 | 
			
		||||
      case 12 -> "MENTIONED_IN";
 | 
			
		||||
      case 13 -> "PARENT_OF";
 | 
			
		||||
      case 14 -> "OWNS";
 | 
			
		||||
      case 15 -> "FOLLOWS";
 | 
			
		||||
      case 16 -> "JOINED";
 | 
			
		||||
      case 17 -> "REACTED_TO";
 | 
			
		||||
      case 18 -> "REPLIED_TO";
 | 
			
		||||
      case 19 -> "TESTED_BY";
 | 
			
		||||
      case 20 -> "UPSTREAM";
 | 
			
		||||
      case 21 -> "DOWNSTREAM";
 | 
			
		||||
      default -> "RELATION_" + relation;
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -139,7 +139,8 @@ public class OpenMetadataOperations implements Callable<Integer> {
 | 
			
		||||
  public Integer call() {
 | 
			
		||||
    LOG.info(
 | 
			
		||||
        "Subcommand needed: 'info', 'validate', 'repair', 'check-connection', "
 | 
			
		||||
            + "'drop-create', 'changelog', 'migrate', 'migrate-secrets', 'reindex', 'deploy-pipelines'");
 | 
			
		||||
            + "'drop-create', 'changelog', 'migrate', 'migrate-secrets', 'reindex', 'deploy-pipelines', "
 | 
			
		||||
            + "'dbServiceCleanup', 'relationshipCleanup'");
 | 
			
		||||
    return 0;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -708,6 +709,48 @@ public class OpenMetadataOperations implements Callable<Integer> {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Command(
 | 
			
		||||
      name = "relationshipCleanup",
 | 
			
		||||
      description =
 | 
			
		||||
          "Cleans up orphaned entity relationships where referenced entities no longer exist. By default, runs in dry-run mode to only identify orphaned relationships.")
 | 
			
		||||
  public Integer cleanupOrphanedRelationships(
 | 
			
		||||
      @Option(
 | 
			
		||||
              names = {"--delete"},
 | 
			
		||||
              description =
 | 
			
		||||
                  "Actually delete the orphaned relationships. Without this flag, the command only identifies orphaned relationships (dry-run mode).",
 | 
			
		||||
              defaultValue = "false")
 | 
			
		||||
          boolean delete,
 | 
			
		||||
      @Option(
 | 
			
		||||
              names = {"-b", "--batch-size"},
 | 
			
		||||
              defaultValue = "1000",
 | 
			
		||||
              description = "Number of relationships to process in each batch.")
 | 
			
		||||
          int batchSize) {
 | 
			
		||||
    try {
 | 
			
		||||
      boolean dryRun = !delete;
 | 
			
		||||
      LOG.info(
 | 
			
		||||
          "Running Entity Relationship Cleanup. Dry run: {}, Batch size: {}", dryRun, batchSize);
 | 
			
		||||
      parseConfig();
 | 
			
		||||
 | 
			
		||||
      EntityRelationshipCleanup cleanup = new EntityRelationshipCleanup(collectionDAO, dryRun);
 | 
			
		||||
      EntityRelationshipCleanup.CleanupResult result = cleanup.performCleanup(batchSize);
 | 
			
		||||
 | 
			
		||||
      LOG.info("=== Entity Relationship Cleanup Summary ===");
 | 
			
		||||
      LOG.info("Total relationships scanned: {}", result.getTotalRelationshipsScanned());
 | 
			
		||||
      LOG.info("Orphaned relationships found: {}", result.getOrphanedRelationshipsFound());
 | 
			
		||||
      LOG.info("Relationships deleted: {}", result.getRelationshipsDeleted());
 | 
			
		||||
 | 
			
		||||
      if (dryRun && result.getOrphanedRelationshipsFound() > 0) {
 | 
			
		||||
        LOG.info("To actually delete these orphaned relationships, run with --delete");
 | 
			
		||||
        return 1;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return 0;
 | 
			
		||||
    } catch (Exception e) {
 | 
			
		||||
      LOG.error("Failed to cleanup orphaned relationships due to ", e);
 | 
			
		||||
      return 1;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Command(name = "reindex", description = "Re Indexes data into search engine from command line.")
 | 
			
		||||
  public Integer reIndex(
 | 
			
		||||
      @Option(
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,473 @@
 | 
			
		||||
/*
 | 
			
		||||
 *  Copyright 2025 Collate
 | 
			
		||||
 *  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
 *  you may not use this file except in compliance with the License.
 | 
			
		||||
 *  You may obtain a copy of the License at
 | 
			
		||||
 *  http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
 *  Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
 *  distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
 *  See the License for the specific language governing permissions and
 | 
			
		||||
 *  limitations under the License.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
package org.openmetadata.service.util;
 | 
			
		||||
 | 
			
		||||
import static org.junit.jupiter.api.Assertions.assertEquals;
 | 
			
		||||
import static org.junit.jupiter.api.Assertions.assertFalse;
 | 
			
		||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
 | 
			
		||||
import static org.junit.jupiter.api.Assertions.assertTrue;
 | 
			
		||||
import static org.openmetadata.service.util.TestUtils.ADMIN_AUTH_HEADERS;
 | 
			
		||||
 | 
			
		||||
import java.io.IOException;
 | 
			
		||||
import java.util.ArrayList;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.UUID;
 | 
			
		||||
import lombok.extern.slf4j.Slf4j;
 | 
			
		||||
import org.junit.jupiter.api.BeforeAll;
 | 
			
		||||
import org.junit.jupiter.api.Test;
 | 
			
		||||
import org.junit.jupiter.api.TestInfo;
 | 
			
		||||
import org.junit.jupiter.api.parallel.Execution;
 | 
			
		||||
import org.junit.jupiter.api.parallel.ExecutionMode;
 | 
			
		||||
import org.openmetadata.schema.api.data.CreateTable;
 | 
			
		||||
import org.openmetadata.schema.entity.data.Database;
 | 
			
		||||
import org.openmetadata.schema.entity.data.DatabaseSchema;
 | 
			
		||||
import org.openmetadata.schema.entity.data.Table;
 | 
			
		||||
import org.openmetadata.schema.type.Relationship;
 | 
			
		||||
import org.openmetadata.service.Entity;
 | 
			
		||||
import org.openmetadata.service.OpenMetadataApplicationTest;
 | 
			
		||||
import org.openmetadata.service.jdbi3.CollectionDAO;
 | 
			
		||||
import org.openmetadata.service.resources.EntityResourceTest;
 | 
			
		||||
import org.openmetadata.service.resources.databases.TableResourceTest;
 | 
			
		||||
import org.openmetadata.service.resources.services.DatabaseServiceResourceTest;
 | 
			
		||||
 | 
			
		||||
@Slf4j
 | 
			
		||||
@Execution(ExecutionMode.CONCURRENT)
 | 
			
		||||
class EntityRelationshipCleanupTest extends OpenMetadataApplicationTest {
 | 
			
		||||
 | 
			
		||||
  private static TableResourceTest tableTest;
 | 
			
		||||
 | 
			
		||||
  private static List<Table> testTables;
 | 
			
		||||
 | 
			
		||||
  private static CollectionDAO collectionDAO;
 | 
			
		||||
  private static EntityRelationshipCleanup cleanup;
 | 
			
		||||
 | 
			
		||||
  @BeforeAll
 | 
			
		||||
  static void setup(TestInfo test) throws IOException {
 | 
			
		||||
    DatabaseServiceResourceTest serviceTest = new DatabaseServiceResourceTest();
 | 
			
		||||
    tableTest = new TableResourceTest();
 | 
			
		||||
    testTables = new ArrayList<>();
 | 
			
		||||
 | 
			
		||||
    collectionDAO = Entity.getCollectionDAO();
 | 
			
		||||
    serviceTest.setupDatabaseServices(test);
 | 
			
		||||
    tableTest.setupDatabaseSchemas(test);
 | 
			
		||||
    setupTestEntities(test);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private static void setupTestEntities(TestInfo test) throws IOException {
 | 
			
		||||
    Database testDatabase = EntityResourceTest.DATABASE;
 | 
			
		||||
    DatabaseSchema testSchema = EntityResourceTest.DATABASE_SCHEMA;
 | 
			
		||||
 | 
			
		||||
    for (int i = 0; i < 3; i++) {
 | 
			
		||||
      CreateTable createTable =
 | 
			
		||||
          tableTest.createRequest(test, i).withDatabaseSchema(testSchema.getFullyQualifiedName());
 | 
			
		||||
      Table table = tableTest.createEntity(createTable, ADMIN_AUTH_HEADERS);
 | 
			
		||||
      testTables.add(table);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    LOG.info(
 | 
			
		||||
        "Created test entities: Database={}, Schema={}, Tables={}",
 | 
			
		||||
        testDatabase.getId(),
 | 
			
		||||
        testSchema.getId(),
 | 
			
		||||
        testTables.size());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Test
 | 
			
		||||
  @Execution(ExecutionMode.CONCURRENT)
 | 
			
		||||
  void test_cleanupWithValidRelationships_shouldFindNoOrphans() {
 | 
			
		||||
    cleanup = new EntityRelationshipCleanup(collectionDAO, true); // dry-run mode
 | 
			
		||||
    EntityRelationshipCleanup.CleanupResult result = cleanup.performCleanup(100);
 | 
			
		||||
    assertNotNull(result);
 | 
			
		||||
    assertTrue(result.getTotalRelationshipsScanned() >= 0);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Test
 | 
			
		||||
  @Execution(ExecutionMode.CONCURRENT)
 | 
			
		||||
  void test_cleanupAfterDeletingEntity_shouldDetectOrphans() {
 | 
			
		||||
    Table tableToDelete = testTables.get(0);
 | 
			
		||||
    UUID tableId = tableToDelete.getId();
 | 
			
		||||
    collectionDAO.tableDAO().delete(tableId);
 | 
			
		||||
    cleanup = new EntityRelationshipCleanup(collectionDAO, true);
 | 
			
		||||
    EntityRelationshipCleanup.CleanupResult result = cleanup.performCleanup(100);
 | 
			
		||||
    assertNotNull(result);
 | 
			
		||||
    assertTrue(
 | 
			
		||||
        result.getOrphanedRelationshipsFound() > 0,
 | 
			
		||||
        "Should find orphaned relationships after entity deletion");
 | 
			
		||||
    boolean foundOrphanedTable =
 | 
			
		||||
        result.getOrphanedRelationships().stream()
 | 
			
		||||
            .anyMatch(
 | 
			
		||||
                orphan ->
 | 
			
		||||
                    tableId.toString().equals(orphan.getFromId())
 | 
			
		||||
                        || tableId.toString().equals(orphan.getToId()));
 | 
			
		||||
    assertTrue(foundOrphanedTable, "Should find orphaned relationships for deleted table");
 | 
			
		||||
 | 
			
		||||
    assertFalse(result.getOrphansByEntityType().isEmpty(), "Should have entity type statistics");
 | 
			
		||||
    assertFalse(
 | 
			
		||||
        result.getOrphansByRelationType().isEmpty(), "Should have relation type statistics");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Test
 | 
			
		||||
  @Execution(ExecutionMode.CONCURRENT)
 | 
			
		||||
  void test_actualCleanup_shouldDeleteOrphanedRelationships() {
 | 
			
		||||
    EntityRelationshipCleanup dryRunCleanup = new EntityRelationshipCleanup(collectionDAO, true);
 | 
			
		||||
    EntityRelationshipCleanup.CleanupResult dryRunResult = dryRunCleanup.performCleanup(100);
 | 
			
		||||
 | 
			
		||||
    int orphansFoundInDryRun = dryRunResult.getOrphanedRelationshipsFound();
 | 
			
		||||
 | 
			
		||||
    if (orphansFoundInDryRun > 0) {
 | 
			
		||||
      EntityRelationshipCleanup actualCleanup = new EntityRelationshipCleanup(collectionDAO, false);
 | 
			
		||||
      EntityRelationshipCleanup.CleanupResult actualResult = actualCleanup.performCleanup(100);
 | 
			
		||||
 | 
			
		||||
      assertNotNull(actualResult);
 | 
			
		||||
      assertTrue(
 | 
			
		||||
          actualResult.getRelationshipsDeleted() > 0, "Should have deleted orphaned relationships");
 | 
			
		||||
      assertEquals(
 | 
			
		||||
          orphansFoundInDryRun,
 | 
			
		||||
          actualResult.getRelationshipsDeleted(),
 | 
			
		||||
          "Should delete same number of relationships as found in dry run");
 | 
			
		||||
 | 
			
		||||
      EntityRelationshipCleanup verificationCleanup =
 | 
			
		||||
          new EntityRelationshipCleanup(collectionDAO, true);
 | 
			
		||||
      EntityRelationshipCleanup.CleanupResult verificationResult =
 | 
			
		||||
          verificationCleanup.performCleanup(100);
 | 
			
		||||
 | 
			
		||||
      assertEquals(
 | 
			
		||||
          0,
 | 
			
		||||
          verificationResult.getOrphanedRelationshipsFound(),
 | 
			
		||||
          "Should find no orphaned relationships after cleanup");
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Test
 | 
			
		||||
  @Execution(ExecutionMode.CONCURRENT)
 | 
			
		||||
  void test_paginationWithLargeBatchSize() {
 | 
			
		||||
    cleanup = new EntityRelationshipCleanup(collectionDAO, true);
 | 
			
		||||
    EntityRelationshipCleanup.CleanupResult result = cleanup.performCleanup(10000);
 | 
			
		||||
    assertNotNull(result);
 | 
			
		||||
    assertTrue(result.getTotalRelationshipsScanned() >= 0);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Test
 | 
			
		||||
  @Execution(ExecutionMode.CONCURRENT)
 | 
			
		||||
  void test_paginationWithSmallBatchSize() {
 | 
			
		||||
    cleanup = new EntityRelationshipCleanup(collectionDAO, true);
 | 
			
		||||
    EntityRelationshipCleanup.CleanupResult result = cleanup.performCleanup(10);
 | 
			
		||||
    assertNotNull(result);
 | 
			
		||||
    assertTrue(result.getTotalRelationshipsScanned() >= 0);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Test
 | 
			
		||||
  @Execution(ExecutionMode.CONCURRENT)
 | 
			
		||||
  void test_createOrphanedRelationshipScenario() {
 | 
			
		||||
    UUID nonExistentId1 = UUID.randomUUID();
 | 
			
		||||
    UUID nonExistentId2 = UUID.randomUUID();
 | 
			
		||||
 | 
			
		||||
    collectionDAO
 | 
			
		||||
        .relationshipDAO()
 | 
			
		||||
        .insert(
 | 
			
		||||
            nonExistentId1,
 | 
			
		||||
            nonExistentId2,
 | 
			
		||||
            "table",
 | 
			
		||||
            "databaseSchema",
 | 
			
		||||
            Relationship.CONTAINS.ordinal(),
 | 
			
		||||
            null);
 | 
			
		||||
    cleanup = new EntityRelationshipCleanup(collectionDAO, true);
 | 
			
		||||
    EntityRelationshipCleanup.CleanupResult result = cleanup.performCleanup(100);
 | 
			
		||||
    assertTrue(
 | 
			
		||||
        result.getOrphanedRelationshipsFound() > 0,
 | 
			
		||||
        "Should detect manually created orphaned relationship");
 | 
			
		||||
 | 
			
		||||
    boolean foundSpecificOrphan =
 | 
			
		||||
        result.getOrphanedRelationships().stream()
 | 
			
		||||
            .anyMatch(
 | 
			
		||||
                orphan ->
 | 
			
		||||
                    nonExistentId1.toString().equals(orphan.getFromId())
 | 
			
		||||
                        && nonExistentId2.toString().equals(orphan.getToId()));
 | 
			
		||||
 | 
			
		||||
    assertTrue(foundSpecificOrphan, "Should find the specific orphaned relationship created");
 | 
			
		||||
 | 
			
		||||
    EntityRelationshipCleanup actualCleanup = new EntityRelationshipCleanup(collectionDAO, false);
 | 
			
		||||
    EntityRelationshipCleanup.CleanupResult cleanupResult = actualCleanup.performCleanup(100);
 | 
			
		||||
 | 
			
		||||
    assertTrue(
 | 
			
		||||
        cleanupResult.getRelationshipsDeleted() > 0, "Should delete the orphaned relationship");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Test
 | 
			
		||||
  @Execution(ExecutionMode.CONCURRENT)
 | 
			
		||||
  void test_validationOfExistingRelationships() {
 | 
			
		||||
    long relationshipCountBefore = collectionDAO.relationshipDAO().getTotalRelationshipCount();
 | 
			
		||||
    cleanup = new EntityRelationshipCleanup(collectionDAO, false);
 | 
			
		||||
    EntityRelationshipCleanup.CleanupResult result = cleanup.performCleanup(100);
 | 
			
		||||
    long relationshipCountAfter = collectionDAO.relationshipDAO().getTotalRelationshipCount();
 | 
			
		||||
    long expectedCount = relationshipCountBefore - result.getRelationshipsDeleted();
 | 
			
		||||
    assertEquals(
 | 
			
		||||
        expectedCount,
 | 
			
		||||
        relationshipCountAfter,
 | 
			
		||||
        "Relationship count should match expected after cleanup");
 | 
			
		||||
 | 
			
		||||
    LOG.info(
 | 
			
		||||
        "Validation test - Before: {}, After: {}, Deleted: {}",
 | 
			
		||||
        relationshipCountBefore,
 | 
			
		||||
        relationshipCountAfter,
 | 
			
		||||
        result.getRelationshipsDeleted());
 | 
			
		||||
 | 
			
		||||
    for (Table table : testTables.subList(1, testTables.size())) {
 | 
			
		||||
      try {
 | 
			
		||||
        Table retrievedTable = tableTest.getEntity(table.getId(), ADMIN_AUTH_HEADERS);
 | 
			
		||||
        assertNotNull(retrievedTable, "Valid table should still exist");
 | 
			
		||||
        assertNotNull(
 | 
			
		||||
            retrievedTable.getDatabaseSchema(), "Table should still have schema reference");
 | 
			
		||||
      } catch (Exception e) {
 | 
			
		||||
        // If table was part of orphaned cleanup, that's expected
 | 
			
		||||
        LOG.info("Table {} not found after cleanup (expected if it was orphaned)", table.getId());
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Test
 | 
			
		||||
  @Execution(ExecutionMode.CONCURRENT)
 | 
			
		||||
  void test_relationshipCleanupCommand_dryRun() {
 | 
			
		||||
    EntityRelationshipCleanup dryRunCleanup = new EntityRelationshipCleanup(collectionDAO, true);
 | 
			
		||||
    EntityRelationshipCleanup.CleanupResult result = dryRunCleanup.performCleanup(500);
 | 
			
		||||
    assertNotNull(result, "Cleanup result should not be null");
 | 
			
		||||
    assertTrue(result.getTotalRelationshipsScanned() >= 0, "Should scan some relationships");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Test
 | 
			
		||||
  @Execution(ExecutionMode.CONCURRENT)
 | 
			
		||||
  void test_relationshipCleanupCommand_withOrphanedData() {
 | 
			
		||||
    UUID nonExistentEntityId = UUID.randomUUID();
 | 
			
		||||
 | 
			
		||||
    collectionDAO
 | 
			
		||||
        .relationshipDAO()
 | 
			
		||||
        .insert(
 | 
			
		||||
            testTables.get(1).getId(),
 | 
			
		||||
            nonExistentEntityId,
 | 
			
		||||
            "table",
 | 
			
		||||
            "databaseSchema",
 | 
			
		||||
            Relationship.CONTAINS.ordinal(),
 | 
			
		||||
            null);
 | 
			
		||||
 | 
			
		||||
    EntityRelationshipCleanup dryRunCleanup = new EntityRelationshipCleanup(collectionDAO, true);
 | 
			
		||||
    EntityRelationshipCleanup.CleanupResult dryRunResult = dryRunCleanup.performCleanup(100);
 | 
			
		||||
 | 
			
		||||
    assertNotNull(dryRunResult, "Dry-run result should not be null");
 | 
			
		||||
    assertTrue(dryRunResult.getOrphanedRelationshipsFound() > 0, "Should find orphaned data");
 | 
			
		||||
 | 
			
		||||
    EntityRelationshipCleanup actualCleanup = new EntityRelationshipCleanup(collectionDAO, false);
 | 
			
		||||
    EntityRelationshipCleanup.CleanupResult cleanupResult = actualCleanup.performCleanup(100);
 | 
			
		||||
 | 
			
		||||
    assertNotNull(cleanupResult, "Cleanup result should not be null");
 | 
			
		||||
    assertTrue(cleanupResult.getRelationshipsDeleted() > 0, "Should delete orphaned relationships");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Test
 | 
			
		||||
  @Execution(ExecutionMode.CONCURRENT)
 | 
			
		||||
  void test_relationshipCleanupCommand_noOrphanedData() {
 | 
			
		||||
    EntityRelationshipCleanup cleanup1 = new EntityRelationshipCleanup(collectionDAO, false);
 | 
			
		||||
    EntityRelationshipCleanup.CleanupResult result = cleanup1.performCleanup(100);
 | 
			
		||||
    assertNotNull(result, "Cleanup result should not be null");
 | 
			
		||||
    assertEquals(
 | 
			
		||||
        0,
 | 
			
		||||
        result.getRelationshipsDeleted(),
 | 
			
		||||
        "Should not delete any relationships when none are orphaned");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Test
 | 
			
		||||
  @Execution(ExecutionMode.CONCURRENT)
 | 
			
		||||
  void test_relationshipCleanupCommand_smallBatchSize() {
 | 
			
		||||
    EntityRelationshipCleanup cleanup1 = new EntityRelationshipCleanup(collectionDAO, true);
 | 
			
		||||
    EntityRelationshipCleanup.CleanupResult result = cleanup1.performCleanup(10);
 | 
			
		||||
 | 
			
		||||
    assertNotNull(result, "Cleanup result should not be null");
 | 
			
		||||
    assertTrue(result.getTotalRelationshipsScanned() >= 0, "Should scan relationships");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Test
 | 
			
		||||
  @Execution(ExecutionMode.CONCURRENT)
 | 
			
		||||
  void test_relationshipCleanupCommand_largeBatchSize() {
 | 
			
		||||
    EntityRelationshipCleanup cleanup1 = new EntityRelationshipCleanup(collectionDAO, true);
 | 
			
		||||
    EntityRelationshipCleanup.CleanupResult result = cleanup1.performCleanup(10000);
 | 
			
		||||
 | 
			
		||||
    assertNotNull(result, "Cleanup result should not be null");
 | 
			
		||||
    assertTrue(result.getTotalRelationshipsScanned() >= 0, "Should scan relationships");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Test
 | 
			
		||||
  @Execution(ExecutionMode.CONCURRENT)
 | 
			
		||||
  void test_relationshipCleanupCommand_multipleOrphanedRelationships() {
 | 
			
		||||
    for (int i = 0; i < 5; i++) {
 | 
			
		||||
      UUID nonExistentId1 = UUID.randomUUID();
 | 
			
		||||
      UUID nonExistentId2 = UUID.randomUUID();
 | 
			
		||||
 | 
			
		||||
      collectionDAO
 | 
			
		||||
          .relationshipDAO()
 | 
			
		||||
          .insert(
 | 
			
		||||
              nonExistentId1,
 | 
			
		||||
              nonExistentId2,
 | 
			
		||||
              "table",
 | 
			
		||||
              "databaseSchema",
 | 
			
		||||
              Relationship.CONTAINS.ordinal(),
 | 
			
		||||
              null);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    EntityRelationshipCleanup cleanup1 = new EntityRelationshipCleanup(collectionDAO, false);
 | 
			
		||||
    EntityRelationshipCleanup.CleanupResult result = cleanup1.performCleanup(2);
 | 
			
		||||
 | 
			
		||||
    assertNotNull(result, "Cleanup result should not be null");
 | 
			
		||||
    assertTrue(
 | 
			
		||||
        result.getRelationshipsDeleted() >= 5,
 | 
			
		||||
        "Should delete at least the 5 orphaned relationships created");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Test
 | 
			
		||||
  @Execution(ExecutionMode.CONCURRENT)
 | 
			
		||||
  void test_relationshipCleanupCommand_validationOfParameters() {
 | 
			
		||||
 | 
			
		||||
    EntityRelationshipCleanup minBatchCleanup = new EntityRelationshipCleanup(collectionDAO, true);
 | 
			
		||||
    EntityRelationshipCleanup.CleanupResult minBatchResult = minBatchCleanup.performCleanup(1);
 | 
			
		||||
 | 
			
		||||
    assertNotNull(minBatchResult, "Minimum batch size result should not be null");
 | 
			
		||||
    EntityRelationshipCleanup defaultCleanup = new EntityRelationshipCleanup(collectionDAO, true);
 | 
			
		||||
    EntityRelationshipCleanup.CleanupResult defaultResult = defaultCleanup.performCleanup(1000);
 | 
			
		||||
 | 
			
		||||
    assertNotNull(defaultResult, "Default batch size result should not be null");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Test
 | 
			
		||||
  @Execution(ExecutionMode.CONCURRENT)
 | 
			
		||||
  void test_commandIntegrationValidation() {
 | 
			
		||||
    EntityRelationshipCleanup cleanup1 = new EntityRelationshipCleanup(collectionDAO, true);
 | 
			
		||||
    EntityRelationshipCleanup.CleanupResult result = cleanup1.performCleanup(100);
 | 
			
		||||
 | 
			
		||||
    assertNotNull(result, "Command integration result should not be null");
 | 
			
		||||
    assertTrue(result.getTotalRelationshipsScanned() >= 0, "Should scan relationships");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Test
 | 
			
		||||
  @Execution(ExecutionMode.CONCURRENT)
 | 
			
		||||
  void test_emptyDatabaseScenario() {
 | 
			
		||||
    // Test cleanup behavior when there are minimal relationships
 | 
			
		||||
 | 
			
		||||
    // Run cleanup on current database
 | 
			
		||||
    cleanup = new EntityRelationshipCleanup(collectionDAO, true);
 | 
			
		||||
    EntityRelationshipCleanup.CleanupResult result = cleanup.performCleanup(100);
 | 
			
		||||
 | 
			
		||||
    assertNotNull(result);
 | 
			
		||||
    assertTrue(result.getTotalRelationshipsScanned() >= 0);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Test
 | 
			
		||||
  @Execution(ExecutionMode.CONCURRENT)
 | 
			
		||||
  void test_commandBehavior_defaultDryRunWithNoOrphans() {
 | 
			
		||||
    EntityRelationshipCleanup dryRunCleanup = new EntityRelationshipCleanup(collectionDAO, true);
 | 
			
		||||
    EntityRelationshipCleanup.CleanupResult result = dryRunCleanup.performCleanup(100);
 | 
			
		||||
 | 
			
		||||
    assertNotNull(result);
 | 
			
		||||
 | 
			
		||||
    boolean wouldReturnOne = result.getOrphanedRelationshipsFound() > 0;
 | 
			
		||||
    assertFalse(
 | 
			
		||||
        wouldReturnOne || result.getOrphanedRelationshipsFound() < 0,
 | 
			
		||||
        "Default dry-run with no orphans should indicate success (exit code 0)");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Test
 | 
			
		||||
  @Execution(ExecutionMode.CONCURRENT)
 | 
			
		||||
  void test_commandBehavior_defaultDryRunWithOrphans() {
 | 
			
		||||
    UUID nonExistentId1 = UUID.randomUUID();
 | 
			
		||||
    UUID nonExistentId2 = UUID.randomUUID();
 | 
			
		||||
 | 
			
		||||
    collectionDAO
 | 
			
		||||
        .relationshipDAO()
 | 
			
		||||
        .insert(
 | 
			
		||||
            nonExistentId1,
 | 
			
		||||
            nonExistentId2,
 | 
			
		||||
            "table",
 | 
			
		||||
            "databaseSchema",
 | 
			
		||||
            Relationship.CONTAINS.ordinal(),
 | 
			
		||||
            null);
 | 
			
		||||
 | 
			
		||||
    EntityRelationshipCleanup dryRunCleanup = new EntityRelationshipCleanup(collectionDAO, true);
 | 
			
		||||
    EntityRelationshipCleanup.CleanupResult result = dryRunCleanup.performCleanup(100);
 | 
			
		||||
 | 
			
		||||
    assertNotNull(result);
 | 
			
		||||
 | 
			
		||||
    boolean wouldReturnOne = result.getOrphanedRelationshipsFound() > 0;
 | 
			
		||||
    assertTrue(
 | 
			
		||||
        wouldReturnOne, "Default dry-run with orphans found should indicate failure (exit code 1)");
 | 
			
		||||
 | 
			
		||||
    assertTrue(result.getOrphanedRelationshipsFound() > 0, "Should find the orphaned relationship");
 | 
			
		||||
    assertEquals(0, result.getRelationshipsDeleted(), "Should not delete in dry-run mode");
 | 
			
		||||
 | 
			
		||||
    EntityRelationshipCleanup actualCleanup = new EntityRelationshipCleanup(collectionDAO, false);
 | 
			
		||||
    actualCleanup.performCleanup(100);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Test
 | 
			
		||||
  @Execution(ExecutionMode.CONCURRENT)
 | 
			
		||||
  void test_commandBehavior_explicitDeleteFlag() {
 | 
			
		||||
    UUID nonExistentId1 = UUID.randomUUID();
 | 
			
		||||
    UUID nonExistentId2 = UUID.randomUUID();
 | 
			
		||||
 | 
			
		||||
    collectionDAO
 | 
			
		||||
        .relationshipDAO()
 | 
			
		||||
        .insert(
 | 
			
		||||
            nonExistentId1,
 | 
			
		||||
            nonExistentId2,
 | 
			
		||||
            "table",
 | 
			
		||||
            "databaseSchema",
 | 
			
		||||
            Relationship.CONTAINS.ordinal(),
 | 
			
		||||
            null);
 | 
			
		||||
 | 
			
		||||
    EntityRelationshipCleanup deleteCleanup = new EntityRelationshipCleanup(collectionDAO, false);
 | 
			
		||||
    EntityRelationshipCleanup.CleanupResult result = deleteCleanup.performCleanup(100);
 | 
			
		||||
 | 
			
		||||
    assertNotNull(result);
 | 
			
		||||
    assertTrue(result.getRelationshipsDeleted() > 0, "Should have deleted orphaned relationships");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Test
 | 
			
		||||
  @Execution(ExecutionMode.CONCURRENT)
 | 
			
		||||
  void test_commandBehavior_batchSizeParameter() {
 | 
			
		||||
    int customBatchSize = 50;
 | 
			
		||||
 | 
			
		||||
    EntityRelationshipCleanup cleanup = new EntityRelationshipCleanup(collectionDAO, true);
 | 
			
		||||
    EntityRelationshipCleanup.CleanupResult result = cleanup.performCleanup(customBatchSize);
 | 
			
		||||
 | 
			
		||||
    assertNotNull(result);
 | 
			
		||||
    assertTrue(result.getTotalRelationshipsScanned() >= 0);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Test
 | 
			
		||||
  @Execution(ExecutionMode.CONCURRENT)
 | 
			
		||||
  void test_commandBehavior_flagSemantics() {
 | 
			
		||||
    EntityRelationshipCleanup defaultBehavior = new EntityRelationshipCleanup(collectionDAO, true);
 | 
			
		||||
    EntityRelationshipCleanup.CleanupResult defaultResult = defaultBehavior.performCleanup(100);
 | 
			
		||||
 | 
			
		||||
    EntityRelationshipCleanup deleteBehavior = new EntityRelationshipCleanup(collectionDAO, false);
 | 
			
		||||
    EntityRelationshipCleanup.CleanupResult deleteResult = deleteBehavior.performCleanup(100);
 | 
			
		||||
 | 
			
		||||
    assertNotNull(defaultResult);
 | 
			
		||||
    assertNotNull(deleteResult);
 | 
			
		||||
 | 
			
		||||
    assertEquals(
 | 
			
		||||
        0,
 | 
			
		||||
        defaultResult.getRelationshipsDeleted(),
 | 
			
		||||
        "Default behavior (dry-run) should not delete any relationships");
 | 
			
		||||
 | 
			
		||||
    assertTrue(
 | 
			
		||||
        deleteResult.getRelationshipsDeleted() >= 0,
 | 
			
		||||
        "Delete mode should delete 0 or more relationships");
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user