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.context.FhirContext;
023import ca.uhn.fhir.i18n.Msg;
024import ca.uhn.fhir.interceptor.model.RequestPartitionId;
025import ca.uhn.fhir.jpa.api.config.JpaStorageSettings;
026import ca.uhn.fhir.jpa.api.model.PersistentIdToForcedIdMap;
027import ca.uhn.fhir.jpa.api.svc.IIdHelperService;
028import ca.uhn.fhir.jpa.api.svc.ResolveIdentityMode;
029import ca.uhn.fhir.jpa.dao.data.IResourceTableDao;
030import ca.uhn.fhir.jpa.model.config.PartitionSettings;
031import ca.uhn.fhir.jpa.model.cross.IResourceLookup;
032import ca.uhn.fhir.jpa.model.cross.JpaResourceLookup;
033import ca.uhn.fhir.jpa.model.dao.JpaPid;
034import ca.uhn.fhir.jpa.model.entity.PartitionablePartitionId;
035import ca.uhn.fhir.jpa.model.entity.ResourceTable;
036import ca.uhn.fhir.jpa.search.builder.SearchBuilder;
037import ca.uhn.fhir.jpa.util.MemoryCacheService;
038import ca.uhn.fhir.jpa.util.QueryChunker;
039import ca.uhn.fhir.rest.api.server.storage.BaseResourcePersistentId;
040import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
041import ca.uhn.fhir.rest.server.exceptions.PreconditionFailedException;
042import ca.uhn.fhir.rest.server.exceptions.ResourceGoneException;
043import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException;
044import ca.uhn.fhir.util.TaskChunker;
045import com.google.common.annotations.VisibleForTesting;
046import com.google.common.collect.ListMultimap;
047import com.google.common.collect.MultimapBuilder;
048import jakarta.annotation.Nonnull;
049import jakarta.annotation.Nullable;
050import jakarta.persistence.EntityManager;
051import jakarta.persistence.PersistenceContext;
052import jakarta.persistence.PersistenceContextType;
053import jakarta.persistence.Tuple;
054import jakarta.persistence.TypedQuery;
055import jakarta.persistence.criteria.CriteriaBuilder;
056import jakarta.persistence.criteria.CriteriaQuery;
057import jakarta.persistence.criteria.Predicate;
058import jakarta.persistence.criteria.Root;
059import org.apache.commons.lang3.StringUtils;
060import org.apache.commons.lang3.Validate;
061import org.hl7.fhir.instance.model.api.IAnyResource;
062import org.hl7.fhir.instance.model.api.IBaseResource;
063import org.hl7.fhir.instance.model.api.IIdType;
064import org.hl7.fhir.r4.model.IdType;
065import org.slf4j.Logger;
066import org.slf4j.LoggerFactory;
067import org.springframework.beans.factory.annotation.Autowired;
068import org.springframework.stereotype.Service;
069import org.springframework.transaction.support.TransactionSynchronizationManager;
070
071import java.util.ArrayList;
072import java.util.Collection;
073import java.util.Collections;
074import java.util.Date;
075import java.util.HashMap;
076import java.util.HashSet;
077import java.util.Iterator;
078import java.util.List;
079import java.util.Map;
080import java.util.Optional;
081import java.util.Set;
082import java.util.stream.Collectors;
083
084import static ca.uhn.fhir.jpa.search.builder.predicate.BaseJoiningPredicateBuilder.replaceDefaultPartitionIdIfNonNull;
085import static ca.uhn.fhir.model.primitive.IdDt.isValidLong;
086import static org.apache.commons.lang3.StringUtils.isNotBlank;
087
088/**
089 * This class is used to convert between PIDs (the internal primary key for a particular resource as
090 * stored in the {@link ca.uhn.fhir.jpa.model.entity.ResourceTable HFJ_RESOURCE} table), and the
091 * public ID that a resource has.
092 * <p>
093 * These IDs are sometimes one and the same (by default, a resource that the server assigns the ID of
094 * <code>Patient/1</code> will simply use a PID of 1 and and ID of 1. However, they may also be different
095 * in cases where a forced ID is used (an arbitrary client-assigned ID).
096 * </p>
097 * <p>
098 * This service is highly optimized in order to minimize the number of DB calls as much as possible,
099 * since ID resolution is fundamental to many basic operations. This service returns either
100 * {@link IResourceLookup} or {@link BaseResourcePersistentId} depending on the method being called.
101 * The former involves an extra database join that the latter does not require, so selecting the
102 * right method here is important.
103 * </p>
104 */
105@Service
106public class IdHelperService implements IIdHelperService<JpaPid> {
107        public static final Predicate[] EMPTY_PREDICATE_ARRAY = new Predicate[0];
108        public static final String RESOURCE_PID = "RESOURCE_PID";
109        private static final Logger ourLog = LoggerFactory.getLogger(IdHelperService.class);
110
111        @Autowired
112        protected IResourceTableDao myResourceTableDao;
113
114        @Autowired
115        private JpaStorageSettings myStorageSettings;
116
117        @Autowired
118        private FhirContext myFhirCtx;
119
120        @Autowired
121        private MemoryCacheService myMemoryCacheService;
122
123        @PersistenceContext(type = PersistenceContextType.TRANSACTION)
124        private EntityManager myEntityManager;
125
126        @Autowired
127        private PartitionSettings myPartitionSettings;
128
129        private boolean myDontCheckActiveTransactionForUnitTest;
130
131        @VisibleForTesting
132        protected void setDontCheckActiveTransactionForUnitTest(boolean theDontCheckActiveTransactionForUnitTest) {
133                myDontCheckActiveTransactionForUnitTest = theDontCheckActiveTransactionForUnitTest;
134        }
135
136        /**
137         * Given a forced ID, convert it to its Long value. Since you are allowed to use string IDs for resources, we need to
138         * convert those to the underlying Long values that are stored, for lookup and comparison purposes.
139         * Optionally filters out deleted resources.
140         *
141         * @throws ResourceNotFoundException If the ID can not be found
142         */
143        @Override
144        @Nonnull
145        public IResourceLookup<JpaPid> resolveResourceIdentity(
146                        @Nonnull RequestPartitionId theRequestPartitionId,
147                        @Nullable String theResourceType,
148                        @Nonnull final String theResourceId,
149                        @Nonnull ResolveIdentityMode theMode)
150                        throws ResourceNotFoundException {
151
152                IIdType id;
153                if (theResourceType != null) {
154                        id = newIdType(theResourceType + "/" + theResourceId);
155                } else {
156                        id = newIdType(theResourceId);
157                }
158                List<IIdType> ids = List.of(id);
159                Map<IIdType, IResourceLookup<JpaPid>> outcome = resolveResourceIdentities(theRequestPartitionId, ids, theMode);
160
161                // We only pass 1 input in so only 0..1 will come back
162                if (!outcome.containsKey(id)) {
163                        throw new ResourceNotFoundException(Msg.code(2001) + "Resource " + id + " is not known");
164                }
165
166                return outcome.get(id);
167        }
168
169        @Nonnull
170        @Override
171        public Map<IIdType, IResourceLookup<JpaPid>> resolveResourceIdentities(
172                        @Nonnull RequestPartitionId theRequestPartitionId,
173                        Collection<IIdType> theIds,
174                        ResolveIdentityMode theMode) {
175                assert myDontCheckActiveTransactionForUnitTest || TransactionSynchronizationManager.isSynchronizationActive()
176                                : "no transaction active";
177
178                if (theIds.isEmpty()) {
179                        return new HashMap<>();
180                }
181
182                Collection<IIdType> ids = new ArrayList<>(theIds);
183                ids.forEach(id -> Validate.isTrue(id.hasIdPart()));
184
185                RequestPartitionId requestPartitionId = replaceDefault(theRequestPartitionId);
186                ListMultimap<IIdType, IResourceLookup<JpaPid>> idToLookup =
187                                MultimapBuilder.hashKeys(theIds.size()).arrayListValues(1).build();
188
189                // Do we have any FHIR ID lookups cached for any of the IDs
190                if (theMode.isUseCache(myStorageSettings.isDeleteEnabled()) && !ids.isEmpty()) {
191                        resolveResourceIdentitiesForFhirIdsUsingCache(requestPartitionId, theMode, ids, idToLookup);
192                }
193
194                // We still haven't found IDs, let's look them up in the DB
195                if (!ids.isEmpty()) {
196                        resolveResourceIdentitiesForFhirIdsUsingDatabase(requestPartitionId, ids, idToLookup);
197                }
198
199                // Convert the multimap into a simple map
200                Map<IIdType, IResourceLookup<JpaPid>> retVal = new HashMap<>();
201                for (Map.Entry<IIdType, IResourceLookup<JpaPid>> next : idToLookup.entries()) {
202                        if (next.getValue().getDeleted() != null) {
203                                if (theMode.isFailOnDeleted()) {
204                                        String msg = myFhirCtx
205                                                        .getLocalizer()
206                                                        .getMessageSanitized(
207                                                                        IdHelperService.class,
208                                                                        "deletedId",
209                                                                        next.getKey().getValue());
210                                        throw new ResourceGoneException(Msg.code(2572) + msg);
211                                }
212                                if (!theMode.isIncludeDeleted()) {
213                                        continue;
214                                }
215                        }
216
217                        IResourceLookup previousValue = retVal.put(next.getKey(), next.getValue());
218                        if (previousValue != null) {
219                                /*
220                                 *  This means that either:
221                                 *  1. There are two resources with the exact same resource type and forced
222                                 *     id. The most likely reason for that is that someone is performing a
223                                 *     multi-partition search and there are resources on each partition
224                                 *     with the same ID.
225                                 *  2. The unique constraint on the FHIR_ID column has been dropped
226                                 */
227                                ourLog.warn(
228                                                "Resource ID[{}] corresponds to lookups: {} and {}",
229                                                next.getKey(),
230                                                previousValue,
231                                                next.getValue());
232                                String msg = myFhirCtx.getLocalizer().getMessage(IdHelperService.class, "nonUniqueForcedId");
233                                throw new PreconditionFailedException(Msg.code(1099) + msg);
234                        }
235                }
236
237                return retVal;
238        }
239
240        /**
241         * Fetch the resource identity ({@link IResourceLookup}) for a collection of
242         * resource IDs from the internal memory cache if possible. Note that we only
243         * use cached results if deletes are disabled on the server (since it is
244         * therefore not possible that we have an entry in the cache that has since
245         * been deleted but the cache doesn't know about the deletion), or if we
246         * aren't excluding deleted results anyhow.
247         *
248         * @param theRequestPartitionId The partition(s) to search
249         * @param theIdsToResolve       The IDs we should look up. Any IDs that are resolved
250         *                              will be removed from this list. Any IDs remaining in
251         *                              the list after calling this method still haven't
252         *                              been attempted to be resolved.
253         * @param theMapToPopulate      The results will be populated into this map
254         */
255        private void resolveResourceIdentitiesForFhirIdsUsingCache(
256                        @Nonnull RequestPartitionId theRequestPartitionId,
257                        ResolveIdentityMode theMode,
258                        Collection<IIdType> theIdsToResolve,
259                        ListMultimap<IIdType, IResourceLookup<JpaPid>> theMapToPopulate) {
260                for (Iterator<IIdType> idIterator = theIdsToResolve.iterator(); idIterator.hasNext(); ) {
261                        IIdType nextForcedId = idIterator.next();
262                        MemoryCacheService.ForcedIdCacheKey nextKey = new MemoryCacheService.ForcedIdCacheKey(
263                                        nextForcedId.getResourceType(), nextForcedId.getIdPart(), theRequestPartitionId);
264                        if (theMode.isUseCache(myStorageSettings.isDeleteEnabled())) {
265                                List<IResourceLookup<JpaPid>> cachedLookups = myMemoryCacheService.getIfPresent(
266                                                MemoryCacheService.CacheEnum.RESOURCE_LOOKUP_BY_FORCED_ID, nextKey);
267                                if (cachedLookups != null && !cachedLookups.isEmpty()) {
268                                        idIterator.remove();
269                                        for (IResourceLookup<JpaPid> cachedLookup : cachedLookups) {
270                                                if (theMode.isIncludeDeleted() || cachedLookup.getDeleted() == null) {
271                                                        theMapToPopulate.put(nextKey.toIdType(myFhirCtx), cachedLookup);
272                                                }
273                                        }
274                                }
275                        }
276                }
277        }
278
279        /**
280         * Fetch the resource identity ({@link IResourceLookup}) for a collection of
281         * resource IDs from the database
282         *
283         * @param theRequestPartitionId The partition(s) to search
284         * @param theIdsToResolve       The IDs we should look up
285         * @param theMapToPopulate      The results will be populated into this map
286         */
287        private void resolveResourceIdentitiesForFhirIdsUsingDatabase(
288                        RequestPartitionId theRequestPartitionId,
289                        Collection<IIdType> theIdsToResolve,
290                        ListMultimap<IIdType, IResourceLookup<JpaPid>> theMapToPopulate) {
291
292                /*
293                 * If we have more than a threshold of IDs, we need to chunk the execution to
294                 * avoid having too many parameters in one SQL statement
295                 */
296                int maxPageSize = (SearchBuilder.getMaximumPageSize() / 2) - 10;
297                if (theIdsToResolve.size() > maxPageSize) {
298                        TaskChunker.chunk(
299                                        theIdsToResolve,
300                                        maxPageSize,
301                                        chunk -> resolveResourceIdentitiesForFhirIdsUsingDatabase(
302                                                        theRequestPartitionId, chunk, theMapToPopulate));
303                        return;
304                }
305
306                CriteriaBuilder cb = myEntityManager.getCriteriaBuilder();
307                CriteriaQuery<Tuple> criteriaQuery = cb.createTupleQuery();
308                Root<ResourceTable> from = criteriaQuery.from(ResourceTable.class);
309                criteriaQuery.multiselect(
310                                from.get("myId"),
311                                from.get("myResourceType"),
312                                from.get("myFhirId"),
313                                from.get("myDeleted"),
314                                from.get("myPartitionIdValue"));
315
316                List<Predicate> outerAndPredicates = new ArrayList<>(2);
317                if (!theRequestPartitionId.isAllPartitions()) {
318                        getOptionalPartitionPredicate(theRequestPartitionId, cb, from).ifPresent(outerAndPredicates::add);
319                }
320
321                // one create one clause per id.
322                List<Predicate> innerIdPredicates = new ArrayList<>(theIdsToResolve.size());
323                boolean haveUntypedIds = false;
324                for (IIdType next : theIdsToResolve) {
325                        if (!next.hasResourceType()) {
326                                haveUntypedIds = true;
327                        }
328
329                        List<Predicate> idPredicates = new ArrayList<>(2);
330
331                        if (myStorageSettings.getResourceClientIdStrategy() == JpaStorageSettings.ClientIdStrategyEnum.ALPHANUMERIC
332                                        && next.isIdPartValidLong()) {
333                                Predicate typeCriteria = cb.equal(from.get("myId"), next.getIdPartAsLong());
334                                idPredicates.add(typeCriteria);
335                        } else {
336                                if (isNotBlank(next.getResourceType())) {
337                                        Predicate typeCriteria = cb.equal(from.get("myResourceType"), next.getResourceType());
338                                        idPredicates.add(typeCriteria);
339                                }
340                                Predicate idCriteria = cb.equal(from.get("myFhirId"), next.getIdPart());
341                                idPredicates.add(idCriteria);
342                        }
343
344                        innerIdPredicates.add(cb.and(idPredicates.toArray(EMPTY_PREDICATE_ARRAY)));
345                }
346                outerAndPredicates.add(cb.or(innerIdPredicates.toArray(EMPTY_PREDICATE_ARRAY)));
347
348                criteriaQuery.where(cb.and(outerAndPredicates.toArray(EMPTY_PREDICATE_ARRAY)));
349                TypedQuery<Tuple> query = myEntityManager.createQuery(criteriaQuery);
350                List<Tuple> results = query.getResultList();
351                for (Tuple nextId : results) {
352                        // Check if the nextId has a resource ID. It may have a null resource ID if a commit is still pending.
353                        Long resourcePid = nextId.get(0, Long.class);
354                        String resourceType = nextId.get(1, String.class);
355                        String fhirId = nextId.get(2, String.class);
356                        Date deletedAd = nextId.get(3, Date.class);
357                        Integer partitionId = nextId.get(4, Integer.class);
358                        if (resourcePid != null) {
359                                JpaResourceLookup lookup = new JpaResourceLookup(
360                                                resourceType, resourcePid, deletedAd, PartitionablePartitionId.with(partitionId, null));
361
362                                MemoryCacheService.ForcedIdCacheKey nextKey =
363                                                new MemoryCacheService.ForcedIdCacheKey(resourceType, fhirId, theRequestPartitionId);
364                                IIdType id = nextKey.toIdType(myFhirCtx);
365                                theMapToPopulate.put(id, lookup);
366
367                                if (haveUntypedIds) {
368                                        id = nextKey.toIdTypeWithoutResourceType(myFhirCtx);
369                                        theMapToPopulate.put(id, lookup);
370                                }
371
372                                List<IResourceLookup<JpaPid>> valueToCache = theMapToPopulate.get(id);
373                                myMemoryCacheService.putAfterCommit(
374                                                MemoryCacheService.CacheEnum.RESOURCE_LOOKUP_BY_FORCED_ID, nextKey, valueToCache);
375                        }
376                }
377        }
378
379        /**
380         * Returns a mapping of Id -> IResourcePersistentId.
381         * If any resource is not found, it will throw ResourceNotFound exception (and no map will be returned)
382         * Optionally filters out deleted resources.
383         */
384        @Override
385        @Nonnull
386        public Map<String, JpaPid> resolveResourcePersistentIds(
387                        @Nonnull RequestPartitionId theRequestPartitionId,
388                        String theResourceType,
389                        List<String> theIds,
390                        ResolveIdentityMode theMode) {
391                assert myDontCheckActiveTransactionForUnitTest || TransactionSynchronizationManager.isSynchronizationActive();
392                Validate.notNull(theIds, "theIds cannot be null");
393                Validate.isTrue(!theIds.isEmpty(), "theIds must not be empty");
394
395                Map<String, JpaPid> retVals = new HashMap<>();
396                for (String id : theIds) {
397                        JpaPid retVal;
398                        if (!idRequiresForcedId(id)) {
399                                // is already a PID
400                                retVal = JpaPid.fromId(Long.parseLong(id));
401                                retVals.put(id, retVal);
402                        } else {
403                                // is a forced id
404                                // we must resolve!
405                                if (myStorageSettings.isDeleteEnabled()) {
406                                        retVal = resolveResourceIdentity(theRequestPartitionId, theResourceType, id, theMode)
407                                                        .getPersistentId();
408                                        retVals.put(id, retVal);
409                                } else {
410                                        // fetch from cache... adding to cache if not available
411                                        String key = toForcedIdToPidKey(theRequestPartitionId, theResourceType, id);
412                                        retVal = myMemoryCacheService.getThenPutAfterCommit(
413                                                        MemoryCacheService.CacheEnum.FORCED_ID_TO_PID, key, t -> {
414                                                                List<IIdType> ids = Collections.singletonList(new IdType(theResourceType, id));
415                                                                // fetches from cache using a function that checks cache first...
416                                                                List<JpaPid> resolvedIds =
417                                                                                resolveResourcePersistentIdsWithCache(theRequestPartitionId, ids);
418                                                                if (resolvedIds.isEmpty()) {
419                                                                        throw new ResourceNotFoundException(Msg.code(1100) + ids.get(0));
420                                                                }
421                                                                return resolvedIds.get(0);
422                                                        });
423                                        retVals.put(id, retVal);
424                                }
425                        }
426                }
427
428                return retVals;
429        }
430
431        /**
432         * Given a resource type and ID, determines the internal persistent ID for the resource.
433         * Optionally filters out deleted resources.
434         *
435         * @throws ResourceNotFoundException If the ID can not be found
436         */
437        @Nonnull
438        @Override
439        public JpaPid resolveResourcePersistentIds(
440                        @Nonnull RequestPartitionId theRequestPartitionId,
441                        String theResourceType,
442                        String theId,
443                        ResolveIdentityMode theMode) {
444                Validate.notNull(theId, "theId must not be null");
445
446                Map<String, JpaPid> retVal = resolveResourcePersistentIds(
447                                theRequestPartitionId, theResourceType, Collections.singletonList(theId), theMode);
448                return retVal.get(theId); // should be only one
449        }
450
451        /**
452         * Returns true if the given resource ID should be stored in a forced ID. Under default config
453         * (meaning client ID strategy is {@link JpaStorageSettings.ClientIdStrategyEnum#ALPHANUMERIC})
454         * this will return true if the ID has any non-digit characters.
455         * <p>
456         * In {@link JpaStorageSettings.ClientIdStrategyEnum#ANY} mode it will always return true.
457         */
458        @Override
459        public boolean idRequiresForcedId(String theId) {
460                return myStorageSettings.getResourceClientIdStrategy() == JpaStorageSettings.ClientIdStrategyEnum.ANY
461                                || !isValidPid(theId);
462        }
463
464        @Nonnull
465        private String toForcedIdToPidKey(
466                        @Nonnull RequestPartitionId theRequestPartitionId, String theResourceType, String theId) {
467                return RequestPartitionId.stringifyForKey(theRequestPartitionId) + "/" + theResourceType + "/" + theId;
468        }
469
470        /**
471         * Given a collection of resource IDs (resource type + id), resolves the internal persistent IDs.
472         * <p>
473         * This implementation will always try to use a cache for performance, meaning that it can resolve resources that
474         * are deleted (but note that forced IDs can't change, so the cache can't return incorrect results)
475         */
476        @Override
477        @Nonnull
478        public List<JpaPid> resolveResourcePersistentIdsWithCache(
479                        RequestPartitionId theRequestPartitionId, List<IIdType> theIds) {
480                boolean onlyForcedIds = false;
481                return resolveResourcePersistentIdsWithCache(theRequestPartitionId, theIds, onlyForcedIds);
482        }
483
484        /**
485         * Given a collection of resource IDs (resource type + id), resolves the internal persistent IDs.
486         * <p>
487         * This implementation will always try to use a cache for performance, meaning that it can resolve resources that
488         * are deleted (but note that forced IDs can't change, so the cache can't return incorrect results)
489         *
490         * @param theOnlyForcedIds If <code>true</code>, resources which are not existing forced IDs will not be resolved
491         */
492        @Override
493        @Nonnull
494        public List<JpaPid> resolveResourcePersistentIdsWithCache(
495                        @Nonnull RequestPartitionId theRequestPartitionId, List<IIdType> theIds, boolean theOnlyForcedIds) {
496                assert myDontCheckActiveTransactionForUnitTest || TransactionSynchronizationManager.isSynchronizationActive();
497
498                List<JpaPid> retVal = new ArrayList<>(theIds.size());
499
500                for (IIdType id : theIds) {
501                        if (!id.hasIdPart()) {
502                                throw new InvalidRequestException(Msg.code(1101) + "Parameter value missing in request");
503                        }
504                }
505
506                if (!theIds.isEmpty()) {
507                        Set<IIdType> idsToCheck = new HashSet<>(theIds.size());
508                        for (IIdType nextId : theIds) {
509                                if (myStorageSettings.getResourceClientIdStrategy() != JpaStorageSettings.ClientIdStrategyEnum.ANY) {
510                                        if (nextId.isIdPartValidLong()) {
511                                                if (!theOnlyForcedIds) {
512                                                        JpaPid jpaPid = JpaPid.fromId(nextId.getIdPartAsLong());
513                                                        jpaPid.setAssociatedResourceId(nextId);
514                                                        retVal.add(jpaPid);
515                                                }
516                                                continue;
517                                        }
518                                }
519
520                                String key = toForcedIdToPidKey(theRequestPartitionId, nextId.getResourceType(), nextId.getIdPart());
521                                JpaPid cachedId = myMemoryCacheService.getIfPresent(MemoryCacheService.CacheEnum.FORCED_ID_TO_PID, key);
522                                if (cachedId != null) {
523                                        retVal.add(cachedId);
524                                        continue;
525                                }
526
527                                idsToCheck.add(nextId);
528                        }
529                        new QueryChunker<IIdType>();
530                        TaskChunker.chunk(
531                                        idsToCheck,
532                                        SearchBuilder.getMaximumPageSize() / 2,
533                                        ids -> doResolvePersistentIds(theRequestPartitionId, ids, retVal));
534                }
535
536                return retVal;
537        }
538
539        private void doResolvePersistentIds(
540                        RequestPartitionId theRequestPartitionId, List<IIdType> theIds, List<JpaPid> theOutputListToPopulate) {
541                CriteriaBuilder cb = myEntityManager.getCriteriaBuilder();
542                CriteriaQuery<Tuple> criteriaQuery = cb.createTupleQuery();
543                Root<ResourceTable> from = criteriaQuery.from(ResourceTable.class);
544
545                /*
546                 * IDX_RES_FHIR_ID covers these columns, but RES_ID is only INCLUDEd.
547                 * Only PG, and MSSql support INCLUDE COLUMNS.
548                 * @see AddIndexTask.generateSql
549                 */
550                criteriaQuery.multiselect(from.get("myId"), from.get("myResourceType"), from.get("myFhirId"));
551
552                // one create one clause per id.
553                List<Predicate> predicates = new ArrayList<>(theIds.size());
554                for (IIdType next : theIds) {
555
556                        List<Predicate> andPredicates = new ArrayList<>(3);
557
558                        if (isNotBlank(next.getResourceType())) {
559                                Predicate typeCriteria = cb.equal(from.get("myResourceType"), next.getResourceType());
560                                andPredicates.add(typeCriteria);
561                        }
562
563                        Predicate idCriteria = cb.equal(from.get("myFhirId"), next.getIdPart());
564                        andPredicates.add(idCriteria);
565                        getOptionalPartitionPredicate(theRequestPartitionId, cb, from).ifPresent(andPredicates::add);
566                        predicates.add(cb.and(andPredicates.toArray(EMPTY_PREDICATE_ARRAY)));
567                }
568
569                // join all the clauses as OR
570                criteriaQuery.where(cb.or(predicates.toArray(EMPTY_PREDICATE_ARRAY)));
571
572                TypedQuery<Tuple> query = myEntityManager.createQuery(criteriaQuery);
573                List<Tuple> results = query.getResultList();
574                for (Tuple nextId : results) {
575                        // Check if the nextId has a resource ID. It may have a null resource ID if a commit is still pending.
576                        Long resourceId = nextId.get(0, Long.class);
577                        String resourceType = nextId.get(1, String.class);
578                        String forcedId = nextId.get(2, String.class);
579                        if (resourceId != null) {
580                                JpaPid jpaPid = JpaPid.fromId(resourceId);
581                                populateAssociatedResourceId(resourceType, forcedId, jpaPid);
582                                theOutputListToPopulate.add(jpaPid);
583
584                                String key = toForcedIdToPidKey(theRequestPartitionId, resourceType, forcedId);
585                                myMemoryCacheService.putAfterCommit(MemoryCacheService.CacheEnum.FORCED_ID_TO_PID, key, jpaPid);
586                        }
587                }
588        }
589
590        /**
591         * Return optional predicate for searching on forcedId
592         * 1. If the partition mode is ALLOWED_UNQUALIFIED, the return optional predicate will be empty, so search is across all partitions.
593         * 2. If it is default partition and default partition id is null, then return predicate for null partition.
594         * 3. If the requested partition search is not all partition, return the request partition as predicate.
595         */
596        private Optional<Predicate> getOptionalPartitionPredicate(
597                        RequestPartitionId theRequestPartitionId, CriteriaBuilder cb, Root<ResourceTable> from) {
598                if (myPartitionSettings.isAllowUnqualifiedCrossPartitionReference()) {
599                        return Optional.empty();
600                } else if (theRequestPartitionId.isAllPartitions()) {
601                        return Optional.empty();
602                } else {
603                        List<Integer> partitionIds = theRequestPartitionId.getPartitionIds();
604                        partitionIds = replaceDefaultPartitionIdIfNonNull(myPartitionSettings, partitionIds);
605                        if (partitionIds.contains(null)) {
606                                Predicate partitionIdNullCriteria =
607                                                from.get("myPartitionIdValue").isNull();
608                                if (partitionIds.size() == 1) {
609                                        return Optional.of(partitionIdNullCriteria);
610                                } else {
611                                        Predicate partitionIdCriteria = from.get("myPartitionIdValue")
612                                                        .in(partitionIds.stream().filter(t -> t != null).collect(Collectors.toList()));
613                                        return Optional.of(cb.or(partitionIdCriteria, partitionIdNullCriteria));
614                                }
615                        } else {
616                                if (partitionIds.size() > 1) {
617                                        Predicate partitionIdCriteria =
618                                                        from.get("myPartitionIdValue").in(partitionIds);
619                                        return Optional.of(partitionIdCriteria);
620                                } else if (partitionIds.size() == 1) {
621                                        Predicate partitionIdCriteria = cb.equal(from.get("myPartitionIdValue"), partitionIds.get(0));
622                                        return Optional.of(partitionIdCriteria);
623                                }
624                        }
625                }
626                return Optional.empty();
627        }
628
629        private void populateAssociatedResourceId(String nextResourceType, String forcedId, JpaPid jpaPid) {
630                IIdType resourceId = myFhirCtx.getVersion().newIdType();
631                resourceId.setValue(nextResourceType + "/" + forcedId);
632                jpaPid.setAssociatedResourceId(resourceId);
633        }
634
635        /**
636         * Given a persistent ID, returns the associated resource ID
637         */
638        @Nonnull
639        @Override
640        public IIdType translatePidIdToForcedId(FhirContext theCtx, String theResourceType, JpaPid theId) {
641                if (theId.getAssociatedResourceId() != null) {
642                        return theId.getAssociatedResourceId();
643                }
644
645                IIdType retVal = theCtx.getVersion().newIdType();
646
647                Optional<String> forcedId = translatePidIdToForcedIdWithCache(theId);
648                if (forcedId.isPresent()) {
649                        retVal.setValue(forcedId.get());
650                } else {
651                        retVal.setValue(theResourceType + '/' + theId);
652                }
653
654                return retVal;
655        }
656
657        @SuppressWarnings("OptionalAssignedToNull")
658        @Override
659        public Optional<String> translatePidIdToForcedIdWithCache(JpaPid theId) {
660                // do getIfPresent and then put to avoid doing I/O inside the cache.
661                Optional<String> forcedId =
662                                myMemoryCacheService.getIfPresent(MemoryCacheService.CacheEnum.PID_TO_FORCED_ID, theId.getId());
663
664                if (forcedId == null) {
665                        // This is only called when we know the resource exists.
666                        // So this optional is only empty when there is no hfj_forced_id table
667                        // note: this is obsolete with the new fhir_id column, and will go away.
668                        forcedId = myResourceTableDao.findById(theId.getId()).map(ResourceTable::asTypedFhirResourceId);
669                        myMemoryCacheService.put(MemoryCacheService.CacheEnum.PID_TO_FORCED_ID, theId.getId(), forcedId);
670                }
671
672                return forcedId;
673        }
674
675        public RequestPartitionId replaceDefault(RequestPartitionId theRequestPartitionId) {
676                if (myPartitionSettings.getDefaultPartitionId() != null) {
677                        if (!theRequestPartitionId.isAllPartitions() && theRequestPartitionId.hasDefaultPartitionId()) {
678                                List<Integer> partitionIds = theRequestPartitionId.getPartitionIds().stream()
679                                                .map(t -> t == null ? myPartitionSettings.getDefaultPartitionId() : t)
680                                                .collect(Collectors.toList());
681                                return RequestPartitionId.fromPartitionIds(partitionIds);
682                        }
683                }
684                return theRequestPartitionId;
685        }
686
687        @Override
688        public PersistentIdToForcedIdMap<JpaPid> translatePidsToForcedIds(Set<JpaPid> theResourceIds) {
689                assert myDontCheckActiveTransactionForUnitTest || TransactionSynchronizationManager.isSynchronizationActive();
690                Set<Long> thePids = theResourceIds.stream().map(JpaPid::getId).collect(Collectors.toSet());
691                Map<Long, Optional<String>> retVal = new HashMap<>(
692                                myMemoryCacheService.getAllPresent(MemoryCacheService.CacheEnum.PID_TO_FORCED_ID, thePids));
693
694                List<Long> remainingPids =
695                                thePids.stream().filter(t -> !retVal.containsKey(t)).collect(Collectors.toList());
696
697                QueryChunker.chunk(remainingPids, t -> {
698                        List<ResourceTable> resourceEntities = myResourceTableDao.findAllById(t);
699
700                        for (ResourceTable nextResourceEntity : resourceEntities) {
701                                Long nextResourcePid = nextResourceEntity.getId();
702                                Optional<String> nextForcedId = Optional.of(nextResourceEntity.asTypedFhirResourceId());
703                                retVal.put(nextResourcePid, nextForcedId);
704                                myMemoryCacheService.putAfterCommit(
705                                                MemoryCacheService.CacheEnum.PID_TO_FORCED_ID, nextResourcePid, nextForcedId);
706                        }
707                });
708
709                remainingPids = thePids.stream().filter(t -> !retVal.containsKey(t)).collect(Collectors.toList());
710                for (Long nextResourcePid : remainingPids) {
711                        retVal.put(nextResourcePid, Optional.empty());
712                        myMemoryCacheService.putAfterCommit(
713                                        MemoryCacheService.CacheEnum.PID_TO_FORCED_ID, nextResourcePid, Optional.empty());
714                }
715                Map<JpaPid, Optional<String>> convertRetVal = new HashMap<>();
716                retVal.forEach((k, v) -> convertRetVal.put(JpaPid.fromId(k), v));
717
718                return new PersistentIdToForcedIdMap<>(convertRetVal);
719        }
720
721        /**
722         * Pre-cache a PID-to-Resource-ID mapping for later retrieval by {@link #translatePidsToForcedIds(Set)} and related methods
723         */
724        @Override
725        public void addResolvedPidToFhirId(
726                        @Nonnull JpaPid theJpaPid,
727                        @Nonnull RequestPartitionId theRequestPartitionId,
728                        @Nonnull String theResourceType,
729                        @Nonnull String theFhirId,
730                        @Nullable Date theDeletedAt) {
731                if (theJpaPid.getAssociatedResourceId() == null) {
732                        populateAssociatedResourceId(theResourceType, theFhirId, theJpaPid);
733                }
734
735                myMemoryCacheService.putAfterCommit(
736                                MemoryCacheService.CacheEnum.PID_TO_FORCED_ID,
737                                theJpaPid.getId(),
738                                Optional.of(theResourceType + "/" + theFhirId));
739                String key = toForcedIdToPidKey(theRequestPartitionId, theResourceType, theFhirId);
740                myMemoryCacheService.putAfterCommit(MemoryCacheService.CacheEnum.FORCED_ID_TO_PID, key, theJpaPid);
741
742                JpaResourceLookup lookup = new JpaResourceLookup(
743                                theResourceType, theJpaPid.getId(), theDeletedAt, theJpaPid.getPartitionablePartitionId());
744
745                MemoryCacheService.ForcedIdCacheKey fhirIdKey =
746                                new MemoryCacheService.ForcedIdCacheKey(theResourceType, theFhirId, theRequestPartitionId);
747                myMemoryCacheService.putAfterCommit(
748                                MemoryCacheService.CacheEnum.RESOURCE_LOOKUP_BY_FORCED_ID, fhirIdKey, List.of(lookup));
749
750                // If it's a pure-numeric ID, store it in the cache without a type as well
751                // so that we can resolve it this way when loading entities for update
752                if (myStorageSettings.getResourceClientIdStrategy() == JpaStorageSettings.ClientIdStrategyEnum.ALPHANUMERIC
753                                && isValidLong(theFhirId)) {
754                        MemoryCacheService.ForcedIdCacheKey fhirIdKeyWithoutType =
755                                        new MemoryCacheService.ForcedIdCacheKey(null, theFhirId, theRequestPartitionId);
756                        myMemoryCacheService.putAfterCommit(
757                                        MemoryCacheService.CacheEnum.RESOURCE_LOOKUP_BY_FORCED_ID, fhirIdKeyWithoutType, List.of(lookup));
758                }
759        }
760
761        @VisibleForTesting
762        public void setPartitionSettingsForUnitTest(PartitionSettings thePartitionSettings) {
763                myPartitionSettings = thePartitionSettings;
764        }
765
766        @Override
767        @Nonnull
768        public List<JpaPid> getPidsOrThrowException(
769                        @Nonnull RequestPartitionId theRequestPartitionId, List<IIdType> theIds) {
770                return resolveResourcePersistentIdsWithCache(theRequestPartitionId, theIds);
771        }
772
773        @Override
774        @Nullable
775        public JpaPid getPidOrNull(@Nonnull RequestPartitionId theRequestPartitionId, IBaseResource theResource) {
776                Object resourceId = theResource.getUserData(RESOURCE_PID);
777                JpaPid retVal;
778                if (resourceId == null) {
779                        IIdType id = theResource.getIdElement();
780                        try {
781                                retVal = resolveResourceIdentityPid(
782                                                theRequestPartitionId,
783                                                id.getResourceType(),
784                                                id.getIdPart(),
785                                                ResolveIdentityMode.includeDeleted().cacheOk());
786                        } catch (ResourceNotFoundException e) {
787                                retVal = null;
788                        }
789                } else {
790                        retVal = JpaPid.fromId(Long.parseLong(resourceId.toString()));
791                }
792                return retVal;
793        }
794
795        @Override
796        @Nonnull
797        public JpaPid getPidOrThrowException(@Nonnull RequestPartitionId theRequestPartitionId, IIdType theId) {
798                List<IIdType> ids = Collections.singletonList(theId);
799                List<JpaPid> resourcePersistentIds = resolveResourcePersistentIdsWithCache(theRequestPartitionId, ids);
800                if (resourcePersistentIds.isEmpty()) {
801                        throw new InvalidRequestException(Msg.code(2295) + "Invalid ID was provided: [" + theId.getIdPart() + "]");
802                }
803                return resourcePersistentIds.get(0);
804        }
805
806        @Override
807        @Nonnull
808        public JpaPid getPidOrThrowException(@Nonnull IAnyResource theResource) {
809                Long theResourcePID = (Long) theResource.getUserData(RESOURCE_PID);
810                if (theResourcePID == null) {
811                        throw new IllegalStateException(Msg.code(2108)
812                                        + String.format(
813                                                        "Unable to find %s in the user data for %s with ID %s",
814                                                        RESOURCE_PID, theResource, theResource.getId()));
815                }
816                return JpaPid.fromId(theResourcePID);
817        }
818
819        @Override
820        public IIdType resourceIdFromPidOrThrowException(JpaPid thePid, String theResourceType) {
821                Optional<ResourceTable> optionalResource = myResourceTableDao.findById(thePid.getId());
822                if (optionalResource.isEmpty()) {
823                        throw new ResourceNotFoundException(Msg.code(2124) + "Requested resource not found");
824                }
825                return optionalResource.get().getIdDt().toVersionless();
826        }
827
828        /**
829         * Given a set of PIDs, return a set of public FHIR Resource IDs.
830         * This function will resolve a forced ID if it resolves, and if it fails to resolve to a forced it, will just return the pid
831         * Example:
832         * Let's say we have Patient/1(pid == 1), Patient/pat1 (pid == 2), Patient/3 (pid == 3), their pids would resolve as follows:
833         * <p>
834         * [1,2,3] -> ["1","pat1","3"]
835         *
836         * @param thePids The Set of pids you would like to resolve to external FHIR Resource IDs.
837         * @return A Set of strings representing the FHIR IDs of the pids.
838         */
839        @Override
840        public Set<String> translatePidsToFhirResourceIds(Set<JpaPid> thePids) {
841                assert TransactionSynchronizationManager.isSynchronizationActive();
842
843                PersistentIdToForcedIdMap<JpaPid> pidToForcedIdMap = translatePidsToForcedIds(thePids);
844
845                return pidToForcedIdMap.getResolvedResourceIds();
846        }
847
848        @Override
849        public JpaPid newPid(Object thePid) {
850                return JpaPid.fromId((Long) thePid);
851        }
852
853        @Override
854        public JpaPid newPidFromStringIdAndResourceName(String thePid, String theResourceName) {
855                return JpaPid.fromIdAndResourceType(Long.parseLong(thePid), theResourceName);
856        }
857
858        private IIdType newIdType(String theValue) {
859                IIdType retVal = myFhirCtx.getVersion().newIdType();
860                retVal.setValue(theValue);
861                return retVal;
862        }
863
864        public static boolean isValidPid(IIdType theId) {
865                if (theId == null) {
866                        return false;
867                }
868
869                String idPart = theId.getIdPart();
870                return isValidPid(idPart);
871        }
872
873        public static boolean isValidPid(String theIdPart) {
874                return StringUtils.isNumeric(theIdPart);
875        }
876}