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.dao.index;
021
022import ca.uhn.fhir.jpa.model.entity.BaseResourceIndex;
023import ca.uhn.fhir.jpa.model.entity.ResourceTable;
024import ca.uhn.fhir.jpa.searchparam.extractor.ResourceIndexedSearchParams;
025import ca.uhn.fhir.jpa.util.AddRemoveCount;
026import com.google.common.annotations.VisibleForTesting;
027import jakarta.persistence.EntityManager;
028import jakarta.persistence.PersistenceContext;
029import jakarta.persistence.PersistenceContextType;
030import org.slf4j.Logger;
031import org.slf4j.LoggerFactory;
032import org.springframework.stereotype.Service;
033
034import java.util.ArrayList;
035import java.util.Collection;
036import java.util.HashSet;
037import java.util.Iterator;
038import java.util.List;
039import java.util.Set;
040
041@Service
042public class DaoSearchParamSynchronizer {
043        private static final Logger ourLog = LoggerFactory.getLogger(DaoSearchParamSynchronizer.class);
044
045        @PersistenceContext(type = PersistenceContextType.TRANSACTION)
046        protected EntityManager myEntityManager;
047
048        public AddRemoveCount synchronizeSearchParamsToDatabase(
049                        ResourceIndexedSearchParams theParams,
050                        ResourceTable theEntity,
051                        ResourceIndexedSearchParams existingParams) {
052                AddRemoveCount retVal = new AddRemoveCount();
053
054                synchronize(theEntity, retVal, theParams.myStringParams, existingParams.myStringParams);
055                synchronize(theEntity, retVal, theParams.myTokenParams, existingParams.myTokenParams);
056                synchronize(theEntity, retVal, theParams.myNumberParams, existingParams.myNumberParams);
057                synchronize(theEntity, retVal, theParams.myQuantityParams, existingParams.myQuantityParams);
058                synchronize(theEntity, retVal, theParams.myQuantityNormalizedParams, existingParams.myQuantityNormalizedParams);
059                synchronize(theEntity, retVal, theParams.myDateParams, existingParams.myDateParams);
060                synchronize(theEntity, retVal, theParams.myUriParams, existingParams.myUriParams);
061                synchronize(theEntity, retVal, theParams.myCoordsParams, existingParams.myCoordsParams);
062                synchronize(theEntity, retVal, theParams.myLinks, existingParams.myLinks);
063                synchronize(theEntity, retVal, theParams.myComboTokenNonUnique, existingParams.myComboTokenNonUnique);
064
065                // make sure links are indexed
066                theEntity.setResourceLinks(theParams.myLinks);
067
068                return retVal;
069        }
070
071        @VisibleForTesting
072        public void setEntityManager(EntityManager theEntityManager) {
073                myEntityManager = theEntityManager;
074        }
075
076        private <T extends BaseResourceIndex> void synchronize(
077                        ResourceTable theEntity,
078                        AddRemoveCount theAddRemoveCount,
079                        Collection<T> theNewParams,
080                        Collection<T> theExistingParams) {
081                Collection<T> newParams = theNewParams;
082                for (T next : newParams) {
083                        next.setPartitionId(theEntity.getPartitionId());
084                        next.calculateHashes();
085                }
086
087                /*
088                 * It's technically possible that the existing index collection
089                 * contains duplicates. Duplicates don't actually cause any
090                 * issues for searching since we always deduplicate the PIDs we
091                 * get back from the search, but they are wasteful. We don't
092                 * enforce uniqueness in the DB for the index tables for
093                 * performance reasons (no sense adding a constraint that slows
094                 * down writes when dupes don't actually hurt anything other than
095                 * a bit of wasted space).
096                 *
097                 * So we check if there are any dupes, and if we find any we
098                 * remove them.
099                 */
100                Set<T> existingParamsAsSet = new HashSet<>(theExistingParams.size());
101                for (Iterator<T> iterator = theExistingParams.iterator(); iterator.hasNext(); ) {
102                        T next = iterator.next();
103                        if (!existingParamsAsSet.add(next)) {
104                                iterator.remove();
105                                myEntityManager.remove(next);
106                        }
107                }
108
109                /*
110                 * HashCodes may have changed as a result of setting the partition ID, so
111                 * create a new set that will reflect the new hashcodes
112                 */
113                newParams = new HashSet<>(newParams);
114
115                List<T> paramsToRemove = subtract(theExistingParams, newParams);
116                List<T> paramsToAdd = subtract(newParams, theExistingParams);
117                tryToReuseIndexEntities(paramsToRemove, paramsToAdd);
118
119                for (T next : paramsToRemove) {
120                        if (!myEntityManager.contains(next)) {
121                                // If a resource is created and deleted in the same transaction, we can end up
122                                // in a state where we're deleting entities that don't actually exist. Hibernate
123                                // 6 is stricter about this, so we skip here.
124                                continue;
125                        }
126                        myEntityManager.remove(next);
127                }
128                for (T next : paramsToAdd) {
129                        myEntityManager.merge(next);
130                }
131
132                // TODO:  are there any unintended consequences to fixing this bug?
133                theAddRemoveCount.addToAddCount(paramsToAdd.size());
134                theAddRemoveCount.addToRemoveCount(paramsToRemove.size());
135        }
136
137        /**
138         * The logic here is that often times when we update a resource we are dropping
139         * one index row and adding another. This method tries to reuse rows that would otherwise
140         * have been deleted by updating them with the contents of rows that would have
141         * otherwise been added. In other words, we're trying to replace
142         * "one delete + one insert" with "one update"
143         *
144         * @param theIndexesToRemove The rows that would be removed
145         * @param theIndexesToAdd    The rows that would be added
146         */
147        private <T extends BaseResourceIndex> void tryToReuseIndexEntities(
148                        List<T> theIndexesToRemove, List<T> theIndexesToAdd) {
149                for (int addIndex = 0; addIndex < theIndexesToAdd.size(); addIndex++) {
150
151                        // If there are no more rows to remove, there's nothing we can reuse
152                        if (theIndexesToRemove.isEmpty()) {
153                                break;
154                        }
155
156                        T targetEntity = theIndexesToAdd.get(addIndex);
157                        if (targetEntity.getId() != null) {
158                                continue;
159                        }
160
161                        // Take a row we were going to remove, and repurpose its ID
162                        T entityToReuse = theIndexesToRemove.remove(theIndexesToRemove.size() - 1);
163                        entityToReuse.copyMutableValuesFrom(targetEntity);
164                        theIndexesToAdd.set(addIndex, entityToReuse);
165                }
166        }
167
168        public static <T> List<T> subtract(Collection<T> theSubtractFrom, Collection<T> theToSubtract) {
169                assert theSubtractFrom != theToSubtract || (theSubtractFrom.isEmpty());
170
171                if (theSubtractFrom.isEmpty()) {
172                        return new ArrayList<>();
173                }
174
175                ArrayList<T> retVal = new ArrayList<>();
176                for (T next : theSubtractFrom) {
177                        if (!theToSubtract.contains(next)) {
178                                retVal.add(next);
179                        }
180                }
181                return retVal;
182        }
183}