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.dao.index;
021
022import ca.uhn.fhir.context.FhirContext;
023import ca.uhn.fhir.i18n.Msg;
024import ca.uhn.fhir.jpa.api.config.JpaStorageSettings;
025import ca.uhn.fhir.jpa.cache.ISearchParamIdentityCacheSvc;
026import ca.uhn.fhir.jpa.dao.BaseHapiFhirDao;
027import ca.uhn.fhir.jpa.dao.data.IResourceIndexedComboStringUniqueDao;
028import ca.uhn.fhir.jpa.model.entity.BaseResourceIndex;
029import ca.uhn.fhir.jpa.model.entity.BaseResourceIndexedSearchParam;
030import ca.uhn.fhir.jpa.model.entity.IndexedSearchParamIdentity;
031import ca.uhn.fhir.jpa.model.entity.ResourceIndexedComboStringUnique;
032import ca.uhn.fhir.jpa.model.entity.ResourceTable;
033import ca.uhn.fhir.jpa.model.entity.StorageSettings;
034import ca.uhn.fhir.jpa.searchparam.extractor.ResourceIndexedSearchParams;
035import ca.uhn.fhir.jpa.sp.SearchParamIdentityCacheSvcImpl;
036import ca.uhn.fhir.jpa.util.AddRemoveCount;
037import ca.uhn.fhir.rest.server.exceptions.ResourceVersionConflictException;
038import com.google.common.annotations.VisibleForTesting;
039import jakarta.annotation.Nullable;
040import jakarta.persistence.EntityManager;
041import jakarta.persistence.PersistenceContext;
042import jakarta.persistence.PersistenceContextType;
043import org.springframework.beans.factory.annotation.Autowired;
044import org.springframework.stereotype.Service;
045
046import java.util.ArrayList;
047import java.util.Collection;
048import java.util.Date;
049import java.util.HashSet;
050import java.util.Iterator;
051import java.util.List;
052import java.util.Set;
053
054@Service
055public class DaoSearchParamSynchronizer {
056
057        @PersistenceContext(type = PersistenceContextType.TRANSACTION)
058        protected EntityManager myEntityManager;
059
060        @Autowired
061        private JpaStorageSettings myStorageSettings;
062
063        @Autowired
064        private IResourceIndexedComboStringUniqueDao myResourceIndexedCompositeStringUniqueDao;
065
066        @Autowired
067        private ISearchParamIdentityCacheSvc mySearchParamIdentityCacheSvc;
068
069        @Autowired
070        private FhirContext myFhirContext;
071
072        public AddRemoveCount synchronizeSearchParamsToDatabase(
073                        ResourceIndexedSearchParams theParams,
074                        ResourceTable theEntity,
075                        ResourceIndexedSearchParams existingParams) {
076                AddRemoveCount retVal = new AddRemoveCount();
077
078                synchronize(theEntity, retVal, theParams.myStringParams, existingParams.myStringParams, null);
079                synchronize(theEntity, retVal, theParams.myTokenParams, existingParams.myTokenParams, null);
080                synchronize(theEntity, retVal, theParams.myNumberParams, existingParams.myNumberParams, null);
081                synchronize(theEntity, retVal, theParams.myQuantityParams, existingParams.myQuantityParams, null);
082                synchronize(
083                                theEntity,
084                                retVal,
085                                theParams.myQuantityNormalizedParams,
086                                existingParams.myQuantityNormalizedParams,
087                                null);
088                synchronize(theEntity, retVal, theParams.myDateParams, existingParams.myDateParams, null);
089                synchronize(theEntity, retVal, theParams.myUriParams, existingParams.myUriParams, null);
090                synchronize(theEntity, retVal, theParams.myCoordsParams, existingParams.myCoordsParams, null);
091                synchronize(theEntity, retVal, theParams.myLinks, existingParams.myLinks, null);
092                synchronize(theEntity, retVal, theParams.myComboTokenNonUnique, existingParams.myComboTokenNonUnique, null);
093                synchronize(
094                                theEntity,
095                                retVal,
096                                theParams.myComboStringUniques,
097                                existingParams.myComboStringUniques,
098                                new UniqueIndexPreExistenceChecker());
099
100                // make sure links are indexed
101                theEntity.setResourceLinks(theParams.myLinks);
102
103                return retVal;
104        }
105
106        @VisibleForTesting
107        public void setEntityManager(EntityManager theEntityManager) {
108                myEntityManager = theEntityManager;
109        }
110
111        @VisibleForTesting
112        public void setSearchParamIdentityCacheSvc(ISearchParamIdentityCacheSvc theSearchParamIdentityCacheSvc) {
113                mySearchParamIdentityCacheSvc = theSearchParamIdentityCacheSvc;
114        }
115
116        @VisibleForTesting
117        public void setStorageSettings(JpaStorageSettings theStorageSettings) {
118                myStorageSettings = theStorageSettings;
119        }
120
121        private <T extends BaseResourceIndex> void synchronize(
122                        ResourceTable theEntity,
123                        AddRemoveCount theAddRemoveCount,
124                        Collection<T> theNewParams,
125                        Collection<T> theExistingParams,
126                        @Nullable IPreSaveHook<T> theAddParamPreSaveHook) {
127                Collection<T> newParams = theNewParams;
128                for (T next : newParams) {
129                        next.setResourceId(theEntity.getId().getId());
130                        next.setPartitionId(theEntity.getPartitionId());
131                        next.calculateHashes();
132                }
133
134                /*
135                 * It's technically possible that the existing index collection
136                 * contains duplicates. Duplicates don't actually cause any
137                 * issues for searching since we always deduplicate the PIDs we
138                 * get back from the search, but they are wasteful. We don't
139                 * enforce uniqueness in the DB for the index tables for
140                 * performance reasons (no sense adding a constraint that slows
141                 * down writes when dupes don't actually hurt anything other than
142                 * a bit of wasted space).
143                 *
144                 * So we check if there are any dupes, and if we find any we
145                 * remove them.
146                 */
147                Set<T> existingParamsAsSet = new HashSet<>(theExistingParams.size());
148                for (Iterator<T> iterator = theExistingParams.iterator(); iterator.hasNext(); ) {
149                        T next = iterator.next();
150                        next.setPlaceholderHashesIfMissing();
151                        if (!existingParamsAsSet.add(next)) {
152                                iterator.remove();
153                                myEntityManager.remove(next);
154                        }
155                }
156
157                /*
158                 * HashCodes may have changed as a result of setting the partition ID, so
159                 * create a new set that will reflect the new hashcodes
160                 */
161                newParams = new HashSet<>(newParams);
162
163                List<T> paramsToRemove = subtract(theExistingParams, newParams);
164                List<T> paramsToAdd = subtract(newParams, theExistingParams);
165
166                if (theAddParamPreSaveHook != null) {
167                        theAddParamPreSaveHook.preSave(paramsToRemove, paramsToAdd);
168                }
169
170                tryToReuseIndexEntities(paramsToRemove, paramsToAdd);
171                updateExistingParamsIfRequired(theExistingParams, paramsToAdd, newParams, paramsToRemove);
172
173                for (T next : paramsToRemove) {
174                        if (!myEntityManager.contains(next)) {
175                                // If a resource is created and deleted in the same transaction, we can end up
176                                // in a state where we're deleting entities that don't actually exist. Hibernate
177                                // 6 is stricter about this, so we skip here.
178                                continue;
179                        }
180                        myEntityManager.remove(next);
181                }
182
183                for (T next : paramsToAdd) {
184                        findOrCreateSearchParamIdentity(next);
185                        if (next.getId() == null) {
186                                myEntityManager.persist(next);
187                        } else {
188                                myEntityManager.merge(next);
189                        }
190                }
191
192                // TODO:  are there any unintended consequences to fixing this bug?
193                theAddRemoveCount.addToAddCount(paramsToAdd.size());
194                theAddRemoveCount.addToRemoveCount(paramsToRemove.size());
195
196                // Replace the existing "new set" with the set of params we should be adding.
197                // We're going to add them back into the entity just in case it gets updated
198                // a second time within the same transaction
199                theNewParams.clear();
200                theNewParams.addAll(theExistingParams);
201                theNewParams.addAll(paramsToAdd);
202                theNewParams.removeAll(paramsToRemove);
203        }
204
205        /**
206         * Checks whether the Indexed Search Parameter hash identity exists in the cache.
207         * If the identity is missing, a new {@link IndexedSearchParamIdentity} will be
208         * created asynchronously in a separate thread.
209         *
210         * <p>For details, see:
211         * {@link SearchParamIdentityCacheSvcImpl#findOrCreateSearchParamIdentity(Long, String, String)}</p>
212         */
213        private <T extends BaseResourceIndex> void findOrCreateSearchParamIdentity(T theNewParam) {
214                if (theNewParam instanceof BaseResourceIndexedSearchParam) {
215                        BaseResourceIndexedSearchParam indexedSearchParam = ((BaseResourceIndexedSearchParam) theNewParam);
216                        mySearchParamIdentityCacheSvc.findOrCreateSearchParamIdentity(
217                                        indexedSearchParam.getHashIdentity(),
218                                        indexedSearchParam.getResourceType(),
219                                        indexedSearchParam.getParamName());
220                }
221        }
222
223        /**
224         * <p>
225         * This method performs an update of Search Parameter's fields in the case of
226         * <code>$reindex</code> or update operation by:
227         * 1. Marking existing entities for updating to apply index storage optimization,
228         * if it is enabled (disabled by default).
229         * 2. Recovering <code>SP_NAME</code>, <code>RES_TYPE</code> values of Search Parameter's fields
230         * for existing entities in case if index storage optimization is disabled (but was enabled previously).
231         * </p>
232         * For details, see: {@link StorageSettings#isIndexStorageOptimized()}
233         */
234        private <T extends BaseResourceIndex> void updateExistingParamsIfRequired(
235                        Collection<T> theExistingParams,
236                        List<T> theParamsToAdd,
237                        Collection<T> theNewParams,
238                        List<T> theParamsToRemove) {
239
240                theExistingParams.stream()
241                                .filter(BaseResourceIndexedSearchParam.class::isInstance)
242                                .map(BaseResourceIndexedSearchParam.class::cast)
243                                .filter(this::isSearchParameterUpdateRequired)
244                                .filter(sp -> !theParamsToAdd.contains(sp))
245                                .filter(sp -> !theParamsToRemove.contains(sp))
246                                .forEach(sp -> {
247                                        // force hibernate to update Search Parameter entity by resetting SP_UPDATED value
248                                        sp.setUpdated(new Date());
249                                        recoverExistingSearchParameterIfRequired(sp, theNewParams);
250                                        theParamsToAdd.add((T) sp);
251                                });
252        }
253
254        /**
255         * Search parameters should be updated after changing IndexStorageOptimized setting.
256         * If IndexStorageOptimized is disabled (and was enabled previously), this method copies paramName
257         * and Resource Type from extracted to existing search parameter.
258         */
259        private <T extends BaseResourceIndex> void recoverExistingSearchParameterIfRequired(
260                        BaseResourceIndexedSearchParam theSearchParamToRecover, Collection<T> theNewParams) {
261                if (!myStorageSettings.isIndexStorageOptimized()) {
262                        theNewParams.stream()
263                                        .filter(BaseResourceIndexedSearchParam.class::isInstance)
264                                        .map(BaseResourceIndexedSearchParam.class::cast)
265                                        .filter(paramToAdd -> paramToAdd.equals(theSearchParamToRecover))
266                                        .findFirst()
267                                        .ifPresent(newParam -> {
268                                                theSearchParamToRecover.restoreParamName(newParam.getParamName());
269                                                theSearchParamToRecover.setResourceType(newParam.getResourceType());
270                                        });
271                }
272        }
273
274        private boolean isSearchParameterUpdateRequired(BaseResourceIndexedSearchParam theSearchParameter) {
275                return (myStorageSettings.isIndexStorageOptimized() && !theSearchParameter.isIndexStorageOptimized())
276                                || (!myStorageSettings.isIndexStorageOptimized() && theSearchParameter.isIndexStorageOptimized());
277        }
278
279        /**
280         * The logic here is that often times when we update a resource we are dropping
281         * one index row and adding another. This method tries to reuse rows that would otherwise
282         * have been deleted by updating them with the contents of rows that would have
283         * otherwise been added. In other words, we're trying to replace
284         * "one delete + one insert" with "one update"
285         *
286         * @param theIndexesToRemove The rows that would be removed
287         * @param theIndexesToAdd    The rows that would be added
288         */
289        private <T extends BaseResourceIndex> void tryToReuseIndexEntities(
290                        List<T> theIndexesToRemove, List<T> theIndexesToAdd) {
291                for (int addIndex = 0; addIndex < theIndexesToAdd.size(); addIndex++) {
292
293                        // If there are no more rows to remove, there's nothing we can reuse
294                        if (theIndexesToRemove.isEmpty()) {
295                                break;
296                        }
297
298                        T targetEntity = theIndexesToAdd.get(addIndex);
299                        if (targetEntity.getId() != null) {
300                                continue;
301                        }
302
303                        // Take a row we were going to remove, and repurpose its ID
304                        T entityToReuse = theIndexesToRemove.remove(theIndexesToRemove.size() - 1);
305                        entityToReuse.copyMutableValuesFrom(targetEntity);
306                        theIndexesToAdd.set(addIndex, entityToReuse);
307                }
308        }
309
310        public static <T> List<T> subtract(Collection<T> theSubtractFrom, Collection<T> theToSubtract) {
311                assert theSubtractFrom != theToSubtract || (theSubtractFrom.isEmpty());
312
313                if (theSubtractFrom.isEmpty()) {
314                        return new ArrayList<>();
315                }
316
317                ArrayList<T> retVal = new ArrayList<>();
318                for (T next : theSubtractFrom) {
319                        if (!theToSubtract.contains(next)) {
320                                retVal.add(next);
321                        }
322                }
323                return retVal;
324        }
325
326        private interface IPreSaveHook<T> {
327
328                void preSave(Collection<T> theParamsToRemove, Collection<T> theParamsToAdd);
329        }
330
331        private class UniqueIndexPreExistenceChecker implements IPreSaveHook<ResourceIndexedComboStringUnique> {
332
333                @Override
334                public void preSave(
335                                Collection<ResourceIndexedComboStringUnique> theParamsToRemove,
336                                Collection<ResourceIndexedComboStringUnique> theParamsToAdd) {
337                        if (myStorageSettings.isUniqueIndexesCheckedBeforeSave()) {
338                                for (ResourceIndexedComboStringUnique theIndex : theParamsToAdd) {
339                                        ResourceIndexedComboStringUnique existing =
340                                                        myResourceIndexedCompositeStringUniqueDao.findByQueryString(theIndex.getIndexString());
341                                        if (existing != null) {
342
343                                                /*
344                                                 * If we're reindexing, and the previous index row is being updated
345                                                 * to add previously missing hashes, we may falsely detect that the index
346                                                 * creation is going to fail.
347                                                 */
348                                                boolean existingIndexIsScheduledForRemoval = false;
349                                                for (var next : theParamsToRemove) {
350                                                        if (existing == next) {
351                                                                existingIndexIsScheduledForRemoval = true;
352                                                                break;
353                                                        }
354                                                }
355                                                if (existingIndexIsScheduledForRemoval) {
356                                                        continue;
357                                                }
358
359                                                String searchParameterId = "(unknown)";
360                                                if (theIndex.getSearchParameterId() != null) {
361                                                        searchParameterId = theIndex.getSearchParameterId().getValue();
362                                                }
363
364                                                String msg = myFhirContext
365                                                                .getLocalizer()
366                                                                .getMessage(
367                                                                                BaseHapiFhirDao.class,
368                                                                                "uniqueIndexConflictFailure",
369                                                                                existing.getResource().getResourceType(),
370                                                                                theIndex.getIndexString(),
371                                                                                existing.getResource()
372                                                                                                .getIdDt()
373                                                                                                .toUnqualifiedVersionless()
374                                                                                                .getValue(),
375                                                                                searchParameterId);
376
377                                                // Use ResourceVersionConflictException here because the HapiTransactionService
378                                                // catches this and can retry it if needed
379                                                throw new ResourceVersionConflictException(Msg.code(1093) + msg);
380                                        }
381                                }
382                        }
383                }
384        }
385}