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}