
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 } else { 136 // check if the user has configured any paths to ignore 137 Set<String> pathsToIgnore = myStorageSettings.getEnforceReferentialIntegrityOnDeleteDisableForPaths(); 138 if (conflictResourceLinks.stream().anyMatch(link -> pathsToIgnore.contains(link.getSourcePath()))) { 139 return; 140 } 141 } 142 143 ResourceLink firstConflict = conflictResourceLinks.get(0); 144 145 // NB-GGG: We previously instantiated these ID values from firstConflict.getSourceResource().getIdDt(), but in a 146 // situation where we 147 // actually had to run delete conflict checks in multiple partitions, the executor service starts its own 148 // sessions on a per-thread basis, and by the time 149 // we arrive here, those sessions are closed. So instead, we resolve them from PIDs, which are eagerly loaded. 150 String sourceResourceId = myIdHelper 151 .resourceIdFromPidOrThrowException( 152 firstConflict.getSourceResourcePk(), firstConflict.getSourceResourceType()) 153 .toVersionless() 154 .getValue(); 155 String targetResourceId = myIdHelper 156 .resourceIdFromPidOrThrowException( 157 JpaPid.fromId(firstConflict.getTargetResourcePid()), firstConflict.getTargetResourceType()) 158 .toVersionless() 159 .getValue(); 160 161 throw new InvalidRequestException( 162 Msg.code(822) + "DELETE with _expunge=true failed. Unable to delete " + targetResourceId + " because " 163 + sourceResourceId + " refers to it via the path " + firstConflict.getSourcePath()); 164 } 165 166 public void findResourceLinksWithTargetPidIn( 167 List<JpaPid> theAllTargetPids, 168 List<JpaPid> theSomeTargetPids, 169 List<ResourceLink> theConflictResourceLinks) { 170 // We only need to find one conflict, so if we found one already in an earlier partition run, we can skip the 171 // rest of the searches 172 if (theConflictResourceLinks.isEmpty()) { 173 // Chunker is used because theSomeTargetPids can contain list sizes over 100,000, a number that some 174 // databases can't handle as a query parameter count in an IN clause of a query. 175 QueryChunker.chunk(theSomeTargetPids, targetPidsChunk -> { 176 List<ResourceLink> conflictResourceLinks = 177 myResourceLinkDao.findWithTargetPidIn((targetPidsChunk)).stream() 178 // Filter out resource links for which we are planning to delete the source. 179 // theAllTargetPids contains a list of all the pids we are planning to delete. So we 180 // only 181 // want 182 // to consider a link to be a conflict if the source of that link is not in 183 // theAllTargetPids. 184 .filter(link -> !(theAllTargetPids).contains(link.getSourceResourcePk())) 185 .collect(Collectors.toList()); 186 187 // We do this in two steps to avoid lock contention on this synchronized list 188 theConflictResourceLinks.addAll(conflictResourceLinks); 189 }); 190 } 191 } 192 193 private String deleteRecordsByColumnSql(Set<JpaPid> thePids, ResourceForeignKey theResourceForeignKey) { 194 StringBuilder builder = new StringBuilder(); 195 builder.append("DELETE FROM "); 196 builder.append(theResourceForeignKey.myTable); 197 builder.append(" WHERE "); 198 if (myPartitionSettings.isDatabasePartitionMode()) { 199 builder.append("("); 200 builder.append(theResourceForeignKey.myPartitionIdColumn); 201 builder.append(","); 202 builder.append(theResourceForeignKey.myResourceIdColumn); 203 builder.append(")"); 204 } else { 205 builder.append(theResourceForeignKey.myResourceIdColumn); 206 } 207 208 builder.append(" IN ("); 209 for (Iterator<JpaPid> iter = thePids.iterator(); iter.hasNext(); ) { 210 JpaPid pid = iter.next(); 211 if (myPartitionSettings.isDatabasePartitionMode()) { 212 builder.append("("); 213 builder.append(pid.getPartitionId()); 214 builder.append(","); 215 builder.append(pid.getId()); 216 builder.append(")"); 217 } else { 218 builder.append(pid.getId()); 219 } 220 if (iter.hasNext()) { 221 builder.append(","); 222 } 223 } 224 builder.append(")"); 225 return builder.toString(); 226 } 227 228 public static class DeleteExpungeSqlResult { 229 230 private final List<String> mySqlStatements; 231 private final int myRecordCount; 232 233 public DeleteExpungeSqlResult(List<String> theSqlStatements, int theRecordCount) { 234 mySqlStatements = theSqlStatements; 235 myRecordCount = theRecordCount; 236 } 237 238 public List<String> getSqlStatements() { 239 return mySqlStatements; 240 } 241 242 public int getRecordCount() { 243 return myRecordCount; 244 } 245 } 246}