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