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}