001/*- 002 * #%L 003 * HAPI FHIR JPA Server 004 * %% 005 * Copyright (C) 2014 - 2025 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.config.PartitionSettings; 029import ca.uhn.fhir.jpa.model.dao.JpaPid; 030import ca.uhn.fhir.jpa.model.entity.ResourceLink; 031import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; 032import jakarta.annotation.Nonnull; 033import org.slf4j.Logger; 034import org.slf4j.LoggerFactory; 035 036import java.util.ArrayList; 037import java.util.Collections; 038import java.util.HashSet; 039import java.util.Iterator; 040import java.util.List; 041import java.util.Set; 042import java.util.stream.Collectors; 043 044import static ca.uhn.fhir.jpa.model.entity.PartitionablePartitionId.PARTITION_ID; 045 046public class DeleteExpungeSqlBuilder { 047 private static final Logger ourLog = LoggerFactory.getLogger(DeleteExpungeSqlBuilder.class); 048 private final ResourceTableFKProvider myResourceTableFKProvider; 049 private final JpaStorageSettings myStorageSettings; 050 private final PartitionSettings myPartitionSettings; 051 private final IIdHelperService<JpaPid> myIdHelper; 052 private final IResourceLinkDao myResourceLinkDao; 053 054 public DeleteExpungeSqlBuilder( 055 ResourceTableFKProvider theResourceTableFKProvider, 056 JpaStorageSettings theStorageSettings, 057 IIdHelperService<JpaPid> theIdHelper, 058 IResourceLinkDao theResourceLinkDao, 059 PartitionSettings thePartitionSettings) { 060 myResourceTableFKProvider = theResourceTableFKProvider; 061 myStorageSettings = theStorageSettings; 062 myIdHelper = theIdHelper; 063 myResourceLinkDao = theResourceLinkDao; 064 myPartitionSettings = thePartitionSettings; 065 } 066 067 @Nonnull 068 DeleteExpungeSqlResult convertPidsToDeleteExpungeSql( 069 List<JpaPid> theJpaPids, boolean theCascade, Integer theCascadeMaxRounds) { 070 071 Set<JpaPid> pids = new HashSet<>(theJpaPids); 072 validateOkToDeleteAndExpunge(pids, theCascade, theCascadeMaxRounds); 073 074 List<String> rawSql = new ArrayList<>(); 075 076 List<ResourceForeignKey> resourceForeignKeys = myResourceTableFKProvider.getResourceForeignKeys(); 077 078 for (ResourceForeignKey resourceForeignKey : resourceForeignKeys) { 079 rawSql.add(deleteRecordsByColumnSql(pids, resourceForeignKey)); 080 } 081 082 // Lastly we need to delete records from the resource table all of these other tables link to: 083 ResourceForeignKey resourceTablePk = new ResourceForeignKey("HFJ_RESOURCE", PARTITION_ID, "RES_ID"); 084 rawSql.add(deleteRecordsByColumnSql(pids, resourceTablePk)); 085 return new DeleteExpungeSqlResult(rawSql, pids.size()); 086 } 087 088 public void validateOkToDeleteAndExpunge(Set<JpaPid> thePids, boolean theCascade, Integer theCascadeMaxRounds) { 089 if (!myStorageSettings.isEnforceReferentialIntegrityOnDelete()) { 090 ourLog.info("Referential integrity on delete disabled. Skipping referential integrity check."); 091 return; 092 } 093 094 List<JpaPid> targetPidsAsResourceIds = List.copyOf(thePids); 095 List<ResourceLink> conflictResourceLinks = Collections.synchronizedList(new ArrayList<>()); 096 findResourceLinksWithTargetPidIn(targetPidsAsResourceIds, targetPidsAsResourceIds, conflictResourceLinks); 097 098 if (conflictResourceLinks.isEmpty()) { 099 return; 100 } 101 102 if (theCascade) { 103 int cascadeMaxRounds = Integer.MAX_VALUE; 104 if (theCascadeMaxRounds != null) { 105 cascadeMaxRounds = theCascadeMaxRounds; 106 } 107 if (myStorageSettings.getMaximumDeleteConflictQueryCount() != null) { 108 if (myStorageSettings.getMaximumDeleteConflictQueryCount() < cascadeMaxRounds) { 109 cascadeMaxRounds = myStorageSettings.getMaximumDeleteConflictQueryCount(); 110 } 111 } 112 113 while (true) { 114 List<JpaPid> addedThisRound = new ArrayList<>(); 115 for (ResourceLink next : conflictResourceLinks) { 116 JpaPid nextPid = next.getSourceResourcePk(); 117 if (thePids.add(nextPid)) { 118 addedThisRound.add(nextPid); 119 } 120 } 121 122 if (addedThisRound.isEmpty()) { 123 return; 124 } 125 126 if (--cascadeMaxRounds > 0) { 127 conflictResourceLinks = Collections.synchronizedList(new ArrayList<>()); 128 findResourceLinksWithTargetPidIn(addedThisRound, addedThisRound, conflictResourceLinks); 129 } else { 130 // We'll proceed to below where we throw an exception 131 break; 132 } 133 } 134 } 135 136 ResourceLink firstConflict = conflictResourceLinks.get(0); 137 138 // NB-GGG: We previously instantiated these ID values from firstConflict.getSourceResource().getIdDt(), but in a 139 // situation where we 140 // actually had to run delete conflict checks in multiple partitions, the executor service starts its own 141 // sessions on a per-thread basis, and by the time 142 // we arrive here, those sessions are closed. So instead, we resolve them from PIDs, which are eagerly loaded. 143 String sourceResourceId = myIdHelper 144 .resourceIdFromPidOrThrowException( 145 firstConflict.getSourceResourcePk(), firstConflict.getSourceResourceType()) 146 .toVersionless() 147 .getValue(); 148 String targetResourceId = myIdHelper 149 .resourceIdFromPidOrThrowException( 150 JpaPid.fromId(firstConflict.getTargetResourcePid()), firstConflict.getTargetResourceType()) 151 .toVersionless() 152 .getValue(); 153 154 throw new InvalidRequestException( 155 Msg.code(822) + "DELETE with _expunge=true failed. Unable to delete " + targetResourceId + " because " 156 + sourceResourceId + " refers to it via the path " + firstConflict.getSourcePath()); 157 } 158 159 public void findResourceLinksWithTargetPidIn( 160 List<JpaPid> theAllTargetPids, 161 List<JpaPid> theSomeTargetPids, 162 List<ResourceLink> theConflictResourceLinks) { 163 // We only need to find one conflict, so if we found one already in an earlier partition run, we can skip the 164 // rest of the searches 165 if (theConflictResourceLinks.isEmpty()) { 166 List<ResourceLink> conflictResourceLinks = 167 myResourceLinkDao.findWithTargetPidIn((theSomeTargetPids)).stream() 168 // Filter out resource links for which we are planning to delete the source. 169 // theAllTargetPids contains a list of all the pids we are planning to delete. So we only 170 // want 171 // to consider a link to be a conflict if the source of that link is not in 172 // theAllTargetPids. 173 .filter(link -> !(theAllTargetPids).contains(link.getSourceResourcePk())) 174 .collect(Collectors.toList()); 175 176 // We do this in two steps to avoid lock contention on this synchronized list 177 theConflictResourceLinks.addAll(conflictResourceLinks); 178 } 179 } 180 181 private String deleteRecordsByColumnSql(Set<JpaPid> thePids, ResourceForeignKey theResourceForeignKey) { 182 StringBuilder builder = new StringBuilder(); 183 builder.append("DELETE FROM "); 184 builder.append(theResourceForeignKey.myTable); 185 builder.append(" WHERE "); 186 if (myPartitionSettings.isDatabasePartitionMode()) { 187 builder.append("("); 188 builder.append(theResourceForeignKey.myPartitionIdColumn); 189 builder.append(","); 190 builder.append(theResourceForeignKey.myResourceIdColumn); 191 builder.append(")"); 192 } else { 193 builder.append(theResourceForeignKey.myResourceIdColumn); 194 } 195 196 builder.append(" IN ("); 197 for (Iterator<JpaPid> iter = thePids.iterator(); iter.hasNext(); ) { 198 JpaPid pid = iter.next(); 199 if (myPartitionSettings.isDatabasePartitionMode()) { 200 builder.append("("); 201 builder.append(pid.getPartitionId()); 202 builder.append(","); 203 builder.append(pid.getId()); 204 builder.append(")"); 205 } else { 206 builder.append(pid.getId()); 207 } 208 if (iter.hasNext()) { 209 builder.append(","); 210 } 211 } 212 builder.append(")"); 213 return builder.toString(); 214 } 215 216 public static class DeleteExpungeSqlResult { 217 218 private final List<String> mySqlStatements; 219 private final int myRecordCount; 220 221 public DeleteExpungeSqlResult(List<String> theSqlStatements, int theRecordCount) { 222 mySqlStatements = theSqlStatements; 223 myRecordCount = theRecordCount; 224 } 225 226 public List<String> getSqlStatements() { 227 return mySqlStatements; 228 } 229 230 public int getRecordCount() { 231 return myRecordCount; 232 } 233 } 234}