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.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.slf4j.Logger;
065import org.slf4j.LoggerFactory;
066import org.springframework.beans.factory.annotation.Autowired;
067import org.springframework.stereotype.Service;
068import org.springframework.transaction.support.TransactionSynchronizationManager;
069
070import java.util.ArrayList;
071import java.util.Collection;
072import java.util.Date;
073import java.util.HashMap;
074import java.util.Iterator;
075import java.util.List;
076import java.util.Map;
077import java.util.Objects;
078import java.util.Optional;
079import java.util.Set;
080import java.util.stream.Collectors;
081
082import static ca.uhn.fhir.jpa.search.builder.predicate.BaseJoiningPredicateBuilder.replaceDefaultPartitionIdIfNonNull;
083import static ca.uhn.fhir.model.primitive.IdDt.isValidLong;
084import static org.apache.commons.lang3.StringUtils.isNotBlank;
085
086/**
087 * This class is used to convert between PIDs (the internal primary key for a particular resource as
088 * stored in the {@link ResourceTable HFJ_RESOURCE} table), and the
089 * public ID that a resource has.
090 * <p>
091 * These IDs are sometimes one and the same (by default, a resource that the server assigns the ID of
092 * <code>Patient/1</code> will simply use a PID of 1 and and ID of 1. However, they may also be different
093 * in cases where a forced ID is used (an arbitrary client-assigned ID).
094 * </p>
095 * <p>
096 * This service is highly optimized in order to minimize the number of DB calls as much as possible,
097 * since ID resolution is fundamental to many basic operations. This service returns either
098 * {@link IResourceLookup} or {@link BaseResourcePersistentId} depending on the method being called.
099 * The former involves an extra database join that the latter does not require, so selecting the
100 * right method here is important.
101 * </p>
102 */
103@Service
104public class IdHelperService implements IIdHelperService<JpaPid> {
105        public static final Predicate[] EMPTY_PREDICATE_ARRAY = new Predicate[0];
106        public static final String RESOURCE_PID = "RESOURCE_PID";
107        private static final Logger ourLog = LoggerFactory.getLogger(IdHelperService.class);
108
109        @Autowired
110        protected IResourceTableDao myResourceTableDao;
111
112        @Autowired
113        private JpaStorageSettings myStorageSettings;
114
115        @Autowired
116        private FhirContext myFhirCtx;
117
118        @Autowired
119        private MemoryCacheService myMemoryCacheService;
120
121        @PersistenceContext(type = PersistenceContextType.TRANSACTION)
122        private EntityManager myEntityManager;
123
124        @Autowired
125        private PartitionSettings myPartitionSettings;
126
127        private boolean myDontCheckActiveTransactionForUnitTest;
128
129        @VisibleForTesting
130        protected void setDontCheckActiveTransactionForUnitTest(boolean theDontCheckActiveTransactionForUnitTest) {
131                myDontCheckActiveTransactionForUnitTest = theDontCheckActiveTransactionForUnitTest;
132        }
133
134        /**
135         * Given a forced ID, convert it to its Long value. Since you are allowed to use string IDs for resources, we need to
136         * convert those to the underlying Long values that are stored, for lookup and comparison purposes.
137         * Optionally filters out deleted resources.
138         *
139         * @throws ResourceNotFoundException If the ID can not be found
140         */
141        @Override
142        @Nonnull
143        public IResourceLookup<JpaPid> resolveResourceIdentity(
144                        @Nonnull RequestPartitionId theRequestPartitionId,
145                        @Nullable String theResourceType,
146                        @Nonnull final String theResourceId,
147                        @Nonnull ResolveIdentityMode theMode)
148                        throws ResourceNotFoundException {
149
150                IIdType id;
151                boolean untyped;
152                if (theResourceType != null) {
153                        untyped = false;
154                        id = newIdType(theResourceType + "/" + theResourceId);
155                } else {
156                        /*
157                         * This shouldn't be common, but we need to be able to handle it.
158                         * The only real known use case currently is when handing references
159                         * in searches where the client didn't qualify the ID. E.g.
160                         * /Provenance?target=A,B,C
161                         * We emit a warning in this case that they should be qualfying the
162                         * IDs, but we do stil allow it.
163                         */
164                        untyped = true;
165                        id = newIdType(theResourceId);
166                }
167                List<IIdType> ids = List.of(id);
168                Map<IIdType, IResourceLookup<JpaPid>> outcome = resolveResourceIdentities(theRequestPartitionId, ids, theMode);
169
170                // We only pass 1 input in so only 0..1 will come back
171                Validate.isTrue(outcome.size() <= 1, "Unexpected output size %s for ID: %s", outcome.size(), ids);
172
173                IResourceLookup<JpaPid> retVal;
174                if (untyped) {
175                        if (outcome.isEmpty()) {
176                                retVal = null;
177                        } else {
178                                retVal = outcome.values().iterator().next();
179                        }
180                } else {
181                        retVal = outcome.get(id);
182                }
183
184                if (retVal == null) {
185                        throw new ResourceNotFoundException(Msg.code(2001) + "Resource " + id + " is not known");
186                }
187
188                return retVal;
189        }
190
191        @Nonnull
192        @Override
193        public Map<IIdType, IResourceLookup<JpaPid>> resolveResourceIdentities(
194                        @Nonnull RequestPartitionId theRequestPartitionId,
195                        Collection<IIdType> theIds,
196                        ResolveIdentityMode theMode) {
197                assert myDontCheckActiveTransactionForUnitTest || TransactionSynchronizationManager.isSynchronizationActive()
198                                : "no transaction active";
199
200                if (theIds.isEmpty()) {
201                        return new HashMap<>();
202                }
203
204                Collection<IIdType> ids = new ArrayList<>(theIds);
205                for (IIdType id : theIds) {
206                        if (!id.hasIdPart()) {
207                                throw new InvalidRequestException(Msg.code(1101) + "Parameter value missing in request");
208                        }
209                }
210
211                RequestPartitionId requestPartitionId = replaceDefault(theRequestPartitionId);
212                ListMultimap<IIdType, IResourceLookup<JpaPid>> idToLookup =
213                                MultimapBuilder.hashKeys(theIds.size()).arrayListValues(1).build();
214
215                // Do we have any FHIR ID lookups cached for any of the IDs
216                if (theMode.isUseCache(myStorageSettings.isDeleteEnabled()) && !ids.isEmpty()) {
217                        resolveResourceIdentitiesForFhirIdsUsingCache(requestPartitionId, theMode, ids, idToLookup);
218                }
219
220                // We still haven't found IDs, let's look them up in the DB
221                if (!ids.isEmpty()) {
222                        resolveResourceIdentitiesForFhirIdsUsingDatabase(requestPartitionId, ids, idToLookup);
223                }
224
225                // Convert the multimap into a simple map
226                Map<IIdType, IResourceLookup<JpaPid>> retVal = new HashMap<>(idToLookup.size());
227                for (Map.Entry<IIdType, IResourceLookup<JpaPid>> next : idToLookup.entries()) {
228                        IResourceLookup<JpaPid> nextLookup = next.getValue();
229
230                        IIdType resourceId = myFhirCtx.getVersion().newIdType(nextLookup.getResourceType(), nextLookup.getFhirId());
231                        if (nextLookup.getDeleted() != null) {
232                                if (theMode.isFailOnDeleted()) {
233                                        String msg = myFhirCtx
234                                                        .getLocalizer()
235                                                        .getMessageSanitized(IdHelperService.class, "deletedId", resourceId.getValue());
236                                        throw new ResourceGoneException(Msg.code(2572) + msg);
237                                }
238                                if (!theMode.isIncludeDeleted()) {
239                                        continue;
240                                }
241                        }
242
243                        nextLookup.getPersistentId().setAssociatedResourceId(resourceId);
244
245                        IResourceLookup<JpaPid> previousValue = retVal.put(resourceId, nextLookup);
246                        if (previousValue != null) {
247                                /*
248                                 *  This means that either:
249                                 *  1. There are two resources with the exact same resource type and forced
250                                 *     id. The most likely reason for that is that someone is performing a
251                                 *     multi-partition search and there are resources on each partition
252                                 *     with the same ID.
253                                 *  2. The unique constraint on the FHIR_ID column has been dropped
254                                 */
255                                ourLog.warn("Resource ID[{}] corresponds to lookups: {} and {}", resourceId, previousValue, nextLookup);
256                                String msg = myFhirCtx.getLocalizer().getMessage(IdHelperService.class, "nonUniqueForcedId");
257                                throw new PreconditionFailedException(Msg.code(1099) + msg);
258                        }
259                }
260
261                return retVal;
262        }
263
264        /**
265         * Fetch the resource identity ({@link IResourceLookup}) for a collection of
266         * resource IDs from the internal memory cache if possible. Note that we only
267         * use cached results if deletes are disabled on the server (since it is
268         * therefore not possible that we have an entry in the cache that has since
269         * been deleted but the cache doesn't know about the deletion), or if we
270         * aren't excluding deleted results anyhow.
271         *
272         * @param theRequestPartitionId The partition(s) to search
273         * @param theIdsToResolve       The IDs we should look up. Any IDs that are resolved
274         *                              will be removed from this list. Any IDs remaining in
275         *                              the list after calling this method still haven't
276         *                              been attempted to be resolved.
277         * @param theMapToPopulate      The results will be populated into this map
278         */
279        private void resolveResourceIdentitiesForFhirIdsUsingCache(
280                        @Nonnull RequestPartitionId theRequestPartitionId,
281                        ResolveIdentityMode theMode,
282                        Collection<IIdType> theIdsToResolve,
283                        ListMultimap<IIdType, IResourceLookup<JpaPid>> theMapToPopulate) {
284                for (Iterator<IIdType> idIterator = theIdsToResolve.iterator(); idIterator.hasNext(); ) {
285                        IIdType nextForcedId = idIterator.next();
286                        MemoryCacheService.ForcedIdCacheKey nextKey = new MemoryCacheService.ForcedIdCacheKey(
287                                        nextForcedId.getResourceType(), nextForcedId.getIdPart(), theRequestPartitionId);
288                        if (theMode.isUseCache(myStorageSettings.isDeleteEnabled())) {
289                                List<IResourceLookup<JpaPid>> cachedLookups = myMemoryCacheService.getIfPresent(
290                                                MemoryCacheService.CacheEnum.RESOURCE_LOOKUP_BY_FORCED_ID, nextKey);
291                                if (cachedLookups != null && !cachedLookups.isEmpty()) {
292                                        idIterator.remove();
293                                        for (IResourceLookup<JpaPid> cachedLookup : cachedLookups) {
294                                                if (theMode.isIncludeDeleted() || cachedLookup.getDeleted() == null) {
295                                                        theMapToPopulate.put(nextKey.toIdType(myFhirCtx), cachedLookup);
296                                                }
297                                        }
298                                }
299                        }
300                }
301        }
302
303        /**
304         * Fetch the resource identity ({@link IResourceLookup}) for a collection of
305         * resource IDs from the database
306         *
307         * @param theRequestPartitionId The partition(s) to search
308         * @param theIdsToResolve       The IDs we should look up
309         * @param theMapToPopulate      The results will be populated into this map
310         */
311        private void resolveResourceIdentitiesForFhirIdsUsingDatabase(
312                        RequestPartitionId theRequestPartitionId,
313                        Collection<IIdType> theIdsToResolve,
314                        ListMultimap<IIdType, IResourceLookup<JpaPid>> theMapToPopulate) {
315
316                /*
317                 * If we have more than a threshold of IDs, we need to chunk the execution to
318                 * avoid having too many parameters in one SQL statement
319                 */
320                int maxPageSize = (SearchBuilder.getMaximumPageSize() / 2) - 10;
321                if (theIdsToResolve.size() > maxPageSize) {
322                        TaskChunker.chunk(
323                                        theIdsToResolve,
324                                        maxPageSize,
325                                        chunk -> resolveResourceIdentitiesForFhirIdsUsingDatabase(
326                                                        theRequestPartitionId, chunk, theMapToPopulate));
327                        return;
328                }
329
330                CriteriaBuilder cb = myEntityManager.getCriteriaBuilder();
331                CriteriaQuery<Tuple> criteriaQuery = cb.createTupleQuery();
332                Root<ResourceTable> from = criteriaQuery.from(ResourceTable.class);
333                criteriaQuery.multiselect(
334                                from.get("myPid"),
335                                from.get("myResourceType"),
336                                from.get("myFhirId"),
337                                from.get("myDeleted"),
338                                from.get("myPartitionIdValue"));
339
340                List<Predicate> outerAndPredicates = new ArrayList<>(2);
341                if (!theRequestPartitionId.isAllPartitions()) {
342                        getOptionalPartitionPredicate(theRequestPartitionId, cb, from).ifPresent(outerAndPredicates::add);
343                }
344
345                // one create one clause per id.
346                List<Predicate> innerIdPredicates = new ArrayList<>(theIdsToResolve.size());
347                for (IIdType next : theIdsToResolve) {
348                        List<Predicate> idPredicates = new ArrayList<>(2);
349
350                        if (isNotBlank(next.getResourceType())) {
351                                Predicate typeCriteria = cb.equal(from.get("myResourceType"), next.getResourceType());
352                                idPredicates.add(typeCriteria);
353                        }
354                        Predicate idCriteria = cb.equal(from.get("myFhirId"), next.getIdPart());
355                        idPredicates.add(idCriteria);
356
357                        innerIdPredicates.add(cb.and(idPredicates.toArray(EMPTY_PREDICATE_ARRAY)));
358                }
359                outerAndPredicates.add(cb.or(innerIdPredicates.toArray(EMPTY_PREDICATE_ARRAY)));
360
361                criteriaQuery.where(cb.and(outerAndPredicates.toArray(EMPTY_PREDICATE_ARRAY)));
362                TypedQuery<Tuple> query = myEntityManager.createQuery(criteriaQuery);
363                List<Tuple> results = query.getResultList();
364                for (Tuple nextId : results) {
365                        // Check if the nextId has a resource ID. It may have a null resource ID if a commit is still pending.
366                        JpaPid resourcePid = nextId.get(0, JpaPid.class);
367                        String resourceType = nextId.get(1, String.class);
368                        String fhirId = nextId.get(2, String.class);
369                        Date deletedAd = nextId.get(3, Date.class);
370                        Integer partitionId = nextId.get(4, Integer.class);
371                        if (resourcePid != null) {
372                                if (resourcePid.getPartitionId() == null && partitionId != null) {
373                                        resourcePid.setPartitionId(partitionId);
374                                }
375                                JpaResourceLookup lookup = new JpaResourceLookup(
376                                                resourceType, fhirId, resourcePid, deletedAd, PartitionablePartitionId.with(partitionId, null));
377
378                                MemoryCacheService.ForcedIdCacheKey nextKey =
379                                                new MemoryCacheService.ForcedIdCacheKey(resourceType, fhirId, theRequestPartitionId);
380                                IIdType id = nextKey.toIdType(myFhirCtx);
381                                theMapToPopulate.put(id, lookup);
382
383                                List<IResourceLookup<JpaPid>> valueToCache = theMapToPopulate.get(id);
384                                myMemoryCacheService.putAfterCommit(
385                                                MemoryCacheService.CacheEnum.RESOURCE_LOOKUP_BY_FORCED_ID, nextKey, valueToCache);
386                        }
387                }
388        }
389
390        /**
391         * Returns true if the given resource ID should be stored in a forced ID. Under default config
392         * (meaning client ID strategy is {@link JpaStorageSettings.ClientIdStrategyEnum#ALPHANUMERIC})
393         * this will return true if the ID has any non-digit characters.
394         * <p>
395         * In {@link JpaStorageSettings.ClientIdStrategyEnum#ANY} mode it will always return true.
396         */
397        @Override
398        public boolean idRequiresForcedId(String theId) {
399                return myStorageSettings.getResourceClientIdStrategy() == JpaStorageSettings.ClientIdStrategyEnum.ANY
400                                || !isValidPid(theId);
401        }
402
403        /**
404         * Return optional predicate for searching on forcedId
405         * 1. If the partition mode is ALLOWED_UNQUALIFIED, the return optional predicate will be empty, so search is across all partitions.
406         * 2. If it is default partition and default partition id is null, then return predicate for null partition.
407         * 3. If the requested partition search is not all partition, return the request partition as predicate.
408         */
409        private Optional<Predicate> getOptionalPartitionPredicate(
410                        RequestPartitionId theRequestPartitionId, CriteriaBuilder cb, Root<ResourceTable> from) {
411                if (myPartitionSettings.isAllowUnqualifiedCrossPartitionReference()) {
412                        return Optional.empty();
413                } else if (theRequestPartitionId.isAllPartitions()) {
414                        return Optional.empty();
415                } else {
416                        List<Integer> partitionIds = theRequestPartitionId.getPartitionIds();
417                        partitionIds = replaceDefaultPartitionIdIfNonNull(myPartitionSettings, partitionIds);
418                        if (partitionIds.contains(null)) {
419                                Predicate partitionIdNullCriteria =
420                                                from.get("myPartitionIdValue").isNull();
421                                if (partitionIds.size() == 1) {
422                                        return Optional.of(partitionIdNullCriteria);
423                                } else {
424                                        Predicate partitionIdCriteria = from.get("myPartitionIdValue")
425                                                        .in(partitionIds.stream().filter(Objects::nonNull).collect(Collectors.toList()));
426                                        return Optional.of(cb.or(partitionIdCriteria, partitionIdNullCriteria));
427                                }
428                        } else {
429                                if (partitionIds.size() > 1) {
430                                        Predicate partitionIdCriteria =
431                                                        from.get("myPartitionIdValue").in(partitionIds);
432                                        return Optional.of(partitionIdCriteria);
433                                } else if (partitionIds.size() == 1) {
434                                        Predicate partitionIdCriteria = cb.equal(from.get("myPartitionIdValue"), partitionIds.get(0));
435                                        return Optional.of(partitionIdCriteria);
436                                }
437                        }
438                }
439                return Optional.empty();
440        }
441
442        private void populateAssociatedResourceId(String nextResourceType, String forcedId, JpaPid jpaPid) {
443                IIdType resourceId = myFhirCtx.getVersion().newIdType();
444                resourceId.setValue(nextResourceType + "/" + forcedId);
445                jpaPid.setAssociatedResourceId(resourceId);
446        }
447
448        /**
449         * Given a persistent ID, returns the associated resource ID
450         */
451        @Nonnull
452        @Override
453        public IIdType translatePidIdToForcedId(FhirContext theCtx, String theResourceType, JpaPid theId) {
454                if (theId.getAssociatedResourceId() != null) {
455                        return theId.getAssociatedResourceId();
456                }
457
458                IIdType retVal = theCtx.getVersion().newIdType();
459
460                Optional<String> forcedId = translatePidIdToForcedIdWithCache(theId);
461                if (forcedId.isPresent()) {
462                        retVal.setValue(forcedId.get());
463                } else {
464                        retVal.setValue(theResourceType + '/' + theId.getId());
465                }
466
467                return retVal;
468        }
469
470        @SuppressWarnings("OptionalAssignedToNull")
471        @Override
472        public Optional<String> translatePidIdToForcedIdWithCache(JpaPid theId) {
473                // do getIfPresent and then put to avoid doing I/O inside the cache.
474                Optional<String> forcedId =
475                                myMemoryCacheService.getIfPresent(MemoryCacheService.CacheEnum.PID_TO_FORCED_ID, theId);
476
477                if (forcedId == null) {
478                        // This is only called when we know the resource exists.
479                        // So this optional is only empty when there is no hfj_forced_id table
480                        // note: this is obsolete with the new fhir_id column, and will go away.
481                        forcedId = myResourceTableDao.findById(theId).map(ResourceTable::asTypedFhirResourceId);
482                        myMemoryCacheService.put(MemoryCacheService.CacheEnum.PID_TO_FORCED_ID, theId, forcedId);
483                }
484
485                return forcedId;
486        }
487
488        public RequestPartitionId replaceDefault(RequestPartitionId theRequestPartitionId) {
489                if (myPartitionSettings.getDefaultPartitionId() != null) {
490                        if (!theRequestPartitionId.isAllPartitions() && theRequestPartitionId.hasDefaultPartitionId()) {
491                                List<Integer> partitionIds = theRequestPartitionId.getPartitionIds().stream()
492                                                .map(t -> t == null ? myPartitionSettings.getDefaultPartitionId() : t)
493                                                .collect(Collectors.toList());
494                                return RequestPartitionId.fromPartitionIds(partitionIds);
495                        }
496                }
497                return theRequestPartitionId;
498        }
499
500        @Override
501        public PersistentIdToForcedIdMap<JpaPid> translatePidsToForcedIds(Set<JpaPid> theResourceIds) {
502                assert myDontCheckActiveTransactionForUnitTest || TransactionSynchronizationManager.isSynchronizationActive();
503                HashMap<JpaPid, Optional<String>> retVal = new HashMap<>(
504                                myMemoryCacheService.getAllPresent(MemoryCacheService.CacheEnum.PID_TO_FORCED_ID, theResourceIds));
505
506                List<JpaPid> remainingPids =
507                                theResourceIds.stream().filter(t -> !retVal.containsKey(t)).collect(Collectors.toList());
508
509                new QueryChunker<JpaPid>().chunk(remainingPids, t -> {
510                        List<ResourceTable> resourceEntities = myResourceTableDao.findAllById(t);
511
512                        for (ResourceTable nextResourceEntity : resourceEntities) {
513                                JpaPid nextResourcePid = nextResourceEntity.getPersistentId();
514                                Optional<String> nextForcedId = Optional.of(nextResourceEntity.asTypedFhirResourceId());
515                                retVal.put(nextResourcePid, nextForcedId);
516                                myMemoryCacheService.putAfterCommit(
517                                                MemoryCacheService.CacheEnum.PID_TO_FORCED_ID, nextResourcePid, nextForcedId);
518                        }
519                });
520
521                remainingPids =
522                                theResourceIds.stream().filter(t -> !retVal.containsKey(t)).collect(Collectors.toList());
523                for (JpaPid nextResourcePid : remainingPids) {
524                        retVal.put(nextResourcePid, Optional.empty());
525                        myMemoryCacheService.putAfterCommit(
526                                        MemoryCacheService.CacheEnum.PID_TO_FORCED_ID, nextResourcePid, Optional.empty());
527                }
528                Map<JpaPid, Optional<String>> convertRetVal = new HashMap<>(retVal);
529
530                return new PersistentIdToForcedIdMap<>(convertRetVal);
531        }
532
533        /**
534         * This method can be called to pre-emptively add entries to the ID cache. It should
535         * be called by DAO methods if they are creating or changing the deleted status
536         * of a resource. This method returns immediately, but the data is not
537         * added to the internal caches until the current DB transaction is successfully
538         * committed, and nothing is added if the transaction rolls back.
539         */
540        @Override
541        public void addResolvedPidToFhirIdAfterCommit(
542                        @Nonnull JpaPid theJpaPid,
543                        @Nonnull RequestPartitionId theRequestPartitionId,
544                        @Nonnull String theResourceType,
545                        @Nonnull String theFhirId,
546                        @Nullable Date theDeletedAt) {
547                if (theJpaPid.getAssociatedResourceId() == null) {
548                        populateAssociatedResourceId(theResourceType, theFhirId, theJpaPid);
549                }
550
551                myMemoryCacheService.putAfterCommit(
552                                MemoryCacheService.CacheEnum.PID_TO_FORCED_ID,
553                                theJpaPid,
554                                Optional.of(theResourceType + "/" + theFhirId));
555
556                JpaResourceLookup lookup = new JpaResourceLookup(
557                                theResourceType, theFhirId, theJpaPid.getId(), theDeletedAt, theJpaPid.getPartitionablePartitionId());
558
559                MemoryCacheService.ForcedIdCacheKey fhirIdKey =
560                                new MemoryCacheService.ForcedIdCacheKey(theResourceType, theFhirId, theRequestPartitionId);
561                myMemoryCacheService.putAfterCommit(
562                                MemoryCacheService.CacheEnum.RESOURCE_LOOKUP_BY_FORCED_ID, fhirIdKey, List.of(lookup));
563
564                // If it's a pure-numeric ID, store it in the cache without a type as well
565                // so that we can resolve it this way when loading entities for update
566                if (myStorageSettings.getResourceClientIdStrategy() == JpaStorageSettings.ClientIdStrategyEnum.ALPHANUMERIC
567                                && isValidLong(theFhirId)) {
568                        MemoryCacheService.ForcedIdCacheKey fhirIdKeyWithoutType =
569                                        new MemoryCacheService.ForcedIdCacheKey(null, theFhirId, theRequestPartitionId);
570                        myMemoryCacheService.putAfterCommit(
571                                        MemoryCacheService.CacheEnum.RESOURCE_LOOKUP_BY_FORCED_ID, fhirIdKeyWithoutType, List.of(lookup));
572                }
573        }
574
575        @VisibleForTesting
576        public void setPartitionSettingsForUnitTest(PartitionSettings thePartitionSettings) {
577                myPartitionSettings = thePartitionSettings;
578        }
579
580        @Override
581        @Nullable
582        public JpaPid getPidOrNull(@Nonnull RequestPartitionId theRequestPartitionId, IBaseResource theResource) {
583                Object resourceId = theResource.getUserData(RESOURCE_PID);
584                JpaPid retVal;
585                if (resourceId == null) {
586                        IIdType id = theResource.getIdElement();
587                        try {
588                                retVal = resolveResourceIdentityPid(
589                                                theRequestPartitionId,
590                                                id.getResourceType(),
591                                                id.getIdPart(),
592                                                ResolveIdentityMode.includeDeleted().cacheOk());
593                        } catch (ResourceNotFoundException e) {
594                                retVal = null;
595                        }
596                } else {
597                        retVal = (JpaPid) resourceId;
598                }
599                return retVal;
600        }
601
602        @Override
603        @Nonnull
604        public JpaPid getPidOrThrowException(@Nonnull IAnyResource theResource) {
605                JpaPid theResourcePID = (JpaPid) theResource.getUserData(RESOURCE_PID);
606                if (theResourcePID == null) {
607                        throw new IllegalStateException(Msg.code(2108)
608                                        + String.format(
609                                                        "Unable to find %s in the user data for %s with ID %s",
610                                                        RESOURCE_PID, theResource, theResource.getId()));
611                }
612                return theResourcePID;
613        }
614
615        @Override
616        public IIdType resourceIdFromPidOrThrowException(JpaPid thePid, String theResourceType) {
617                Optional<ResourceTable> optionalResource = myResourceTableDao.findById(thePid);
618                if (optionalResource.isEmpty()) {
619                        throw new ResourceNotFoundException(Msg.code(2124) + "Requested resource not found");
620                }
621                return optionalResource.get().getIdDt().toVersionless();
622        }
623
624        /**
625         * Given a set of PIDs, return a set of public FHIR Resource IDs.
626         * 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
627         * Example:
628         * Let's say we have Patient/1(pid == 1), Patient/pat1 (pid == 2), Patient/3 (pid == 3), their pids would resolve as follows:
629         * <p>
630         * [1,2,3] -> ["1","pat1","3"]
631         *
632         * @param thePids The Set of pids you would like to resolve to external FHIR Resource IDs.
633         * @return A Set of strings representing the FHIR IDs of the pids.
634         */
635        @Override
636        public Set<String> translatePidsToFhirResourceIds(Set<JpaPid> thePids) {
637                assert TransactionSynchronizationManager.isSynchronizationActive();
638
639                PersistentIdToForcedIdMap<JpaPid> pidToForcedIdMap = translatePidsToForcedIds(thePids);
640
641                return pidToForcedIdMap.getResolvedResourceIds();
642        }
643
644        @Override
645        public JpaPid newPid(Object thePid) {
646                return JpaPid.fromId((Long) thePid);
647        }
648
649        @Override
650        public JpaPid newPid(Object thePid, Integer thePartitionId) {
651                return JpaPid.fromId((Long) thePid, thePartitionId);
652        }
653
654        @Override
655        public JpaPid newPidFromStringIdAndResourceName(Integer thePartitionId, String thePid, String theResourceName) {
656                JpaPid retVal = JpaPid.fromId(Long.parseLong(thePid), thePartitionId);
657                retVal.setResourceType(theResourceName);
658                return retVal;
659        }
660
661        private IIdType newIdType(String theValue) {
662                IIdType retVal = myFhirCtx.getVersion().newIdType();
663                retVal.setValue(theValue);
664                return retVal;
665        }
666
667        public static boolean isValidPid(String theIdPart) {
668                return StringUtils.isNumeric(theIdPart);
669        }
670}