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}