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}