001/*- 002 * #%L 003 * HAPI FHIR JPA Server 004 * %% 005 * Copyright (C) 2014 - 2024 Smile CDR, Inc. 006 * %% 007 * Licensed under the Apache License, Version 2.0 (the "License"); 008 * you may not use this file except in compliance with the License. 009 * You may obtain a copy of the License at 010 * 011 * http://www.apache.org/licenses/LICENSE-2.0 012 * 013 * Unless required by applicable law or agreed to in writing, software 014 * distributed under the License is distributed on an "AS IS" BASIS, 015 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 016 * See the License for the specific language governing permissions and 017 * limitations under the License. 018 * #L% 019 */ 020package ca.uhn.fhir.jpa.delete.batch2; 021 022import ca.uhn.fhir.i18n.Msg; 023import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; 024import ca.uhn.fhir.jpa.api.svc.IIdHelperService; 025import ca.uhn.fhir.jpa.dao.data.IResourceLinkDao; 026import ca.uhn.fhir.jpa.dao.expunge.ResourceForeignKey; 027import ca.uhn.fhir.jpa.dao.expunge.ResourceTableFKProvider; 028import ca.uhn.fhir.jpa.model.dao.JpaPid; 029import ca.uhn.fhir.jpa.model.entity.ResourceLink; 030import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; 031import jakarta.annotation.Nonnull; 032import org.slf4j.Logger; 033import org.slf4j.LoggerFactory; 034 035import java.util.ArrayList; 036import java.util.Collections; 037import java.util.List; 038import java.util.Set; 039import java.util.stream.Collectors; 040 041public class DeleteExpungeSqlBuilder { 042 private static final Logger ourLog = LoggerFactory.getLogger(DeleteExpungeSqlBuilder.class); 043 private final ResourceTableFKProvider myResourceTableFKProvider; 044 private final JpaStorageSettings myStorageSettings; 045 private final IIdHelperService myIdHelper; 046 private final IResourceLinkDao myResourceLinkDao; 047 048 public DeleteExpungeSqlBuilder( 049 ResourceTableFKProvider theResourceTableFKProvider, 050 JpaStorageSettings theStorageSettings, 051 IIdHelperService theIdHelper, 052 IResourceLinkDao theResourceLinkDao) { 053 myResourceTableFKProvider = theResourceTableFKProvider; 054 myStorageSettings = theStorageSettings; 055 myIdHelper = theIdHelper; 056 myResourceLinkDao = theResourceLinkDao; 057 } 058 059 @Nonnull 060 DeleteExpungeSqlResult convertPidsToDeleteExpungeSql( 061 List<JpaPid> theJpaPids, boolean theCascade, Integer theCascadeMaxRounds) { 062 063 Set<Long> pids = JpaPid.toLongSet(theJpaPids); 064 validateOkToDeleteAndExpunge(pids, theCascade, theCascadeMaxRounds); 065 066 List<String> rawSql = new ArrayList<>(); 067 068 String pidListString = pids.toString().replace("[", "(").replace("]", ")"); 069 List<ResourceForeignKey> resourceForeignKeys = myResourceTableFKProvider.getResourceForeignKeys(); 070 071 for (ResourceForeignKey resourceForeignKey : resourceForeignKeys) { 072 rawSql.add(deleteRecordsByColumnSql(pidListString, resourceForeignKey)); 073 } 074 075 // Lastly we need to delete records from the resource table all of these other tables link to: 076 ResourceForeignKey resourceTablePk = new ResourceForeignKey("HFJ_RESOURCE", "RES_ID"); 077 rawSql.add(deleteRecordsByColumnSql(pidListString, resourceTablePk)); 078 return new DeleteExpungeSqlResult(rawSql, pids.size()); 079 } 080 081 public void validateOkToDeleteAndExpunge(Set<Long> thePids, boolean theCascade, Integer theCascadeMaxRounds) { 082 if (!myStorageSettings.isEnforceReferentialIntegrityOnDelete()) { 083 ourLog.info("Referential integrity on delete disabled. Skipping referential integrity check."); 084 return; 085 } 086 087 List<JpaPid> targetPidsAsResourceIds = JpaPid.fromLongList(thePids); 088 List<ResourceLink> conflictResourceLinks = Collections.synchronizedList(new ArrayList<>()); 089 findResourceLinksWithTargetPidIn(targetPidsAsResourceIds, targetPidsAsResourceIds, conflictResourceLinks); 090 091 if (conflictResourceLinks.isEmpty()) { 092 return; 093 } 094 095 if (theCascade) { 096 int cascadeMaxRounds = Integer.MAX_VALUE; 097 if (theCascadeMaxRounds != null) { 098 cascadeMaxRounds = theCascadeMaxRounds; 099 } 100 if (myStorageSettings.getMaximumDeleteConflictQueryCount() != null) { 101 if (myStorageSettings.getMaximumDeleteConflictQueryCount() < cascadeMaxRounds) { 102 cascadeMaxRounds = myStorageSettings.getMaximumDeleteConflictQueryCount(); 103 } 104 } 105 106 while (true) { 107 List<JpaPid> addedThisRound = new ArrayList<>(); 108 for (ResourceLink next : conflictResourceLinks) { 109 Long nextPid = next.getSourceResourcePid(); 110 if (thePids.add(nextPid)) { 111 addedThisRound.add(JpaPid.fromId(nextPid)); 112 } 113 } 114 115 if (addedThisRound.isEmpty()) { 116 return; 117 } 118 119 if (--cascadeMaxRounds > 0) { 120 conflictResourceLinks = Collections.synchronizedList(new ArrayList<>()); 121 findResourceLinksWithTargetPidIn(addedThisRound, addedThisRound, conflictResourceLinks); 122 } else { 123 // We'll proceed to below where we throw an exception 124 break; 125 } 126 } 127 } 128 129 ResourceLink firstConflict = conflictResourceLinks.get(0); 130 131 // NB-GGG: We previously instantiated these ID values from firstConflict.getSourceResource().getIdDt(), but in a 132 // situation where we 133 // actually had to run delete conflict checks in multiple partitions, the executor service starts its own 134 // sessions on a per thread basis, and by the time 135 // we arrive here, those sessions are closed. So instead, we resolve them from PIDs, which are eagerly loaded. 136 String sourceResourceId = myIdHelper 137 .resourceIdFromPidOrThrowException( 138 JpaPid.fromId(firstConflict.getSourceResourcePid()), firstConflict.getSourceResourceType()) 139 .toVersionless() 140 .getValue(); 141 String targetResourceId = myIdHelper 142 .resourceIdFromPidOrThrowException( 143 JpaPid.fromId(firstConflict.getTargetResourcePid()), firstConflict.getTargetResourceType()) 144 .toVersionless() 145 .getValue(); 146 147 throw new InvalidRequestException( 148 Msg.code(822) + "DELETE with _expunge=true failed. Unable to delete " + targetResourceId + " because " 149 + sourceResourceId + " refers to it via the path " + firstConflict.getSourcePath()); 150 } 151 152 public void findResourceLinksWithTargetPidIn( 153 List<JpaPid> theAllTargetPids, 154 List<JpaPid> theSomeTargetPids, 155 List<ResourceLink> theConflictResourceLinks) { 156 List<Long> allTargetPidsAsLongs = JpaPid.toLongList(theAllTargetPids); 157 List<Long> someTargetPidsAsLongs = JpaPid.toLongList(theSomeTargetPids); 158 // We only need to find one conflict, so if we found one already in an earlier partition run, we can skip the 159 // rest of the searches 160 if (theConflictResourceLinks.isEmpty()) { 161 List<ResourceLink> conflictResourceLinks = 162 myResourceLinkDao.findWithTargetPidIn(someTargetPidsAsLongs).stream() 163 // Filter out resource links for which we are planning to delete the source. 164 // theAllTargetPids contains a list of all the pids we are planning to delete. So we only 165 // want 166 // to consider a link to be a conflict if the source of that link is not in 167 // theAllTargetPids. 168 .filter(link -> !allTargetPidsAsLongs.contains(link.getSourceResourcePid())) 169 .collect(Collectors.toList()); 170 171 // We do this in two steps to avoid lock contention on this synchronized list 172 theConflictResourceLinks.addAll(conflictResourceLinks); 173 } 174 } 175 176 private String deleteRecordsByColumnSql(String thePidListString, ResourceForeignKey theResourceForeignKey) { 177 return "DELETE FROM " + theResourceForeignKey.table + " WHERE " + theResourceForeignKey.key + " IN " 178 + thePidListString; 179 } 180 181 public static class DeleteExpungeSqlResult { 182 183 private final List<String> mySqlStatements; 184 private final int myRecordCount; 185 186 public DeleteExpungeSqlResult(List<String> theSqlStatments, int theRecordCount) { 187 mySqlStatements = theSqlStatments; 188 myRecordCount = theRecordCount; 189 } 190 191 public List<String> getSqlStatements() { 192 return mySqlStatements; 193 } 194 195 public int getRecordCount() { 196 return myRecordCount; 197 } 198 } 199}