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;
021
022import ca.uhn.fhir.context.FhirContext;
023import ca.uhn.fhir.i18n.Msg;
024import ca.uhn.fhir.interceptor.api.IInterceptorBroadcaster;
025import ca.uhn.fhir.interceptor.model.RequestPartitionId;
026import ca.uhn.fhir.jpa.api.model.PersistentIdToForcedIdMap;
027import ca.uhn.fhir.jpa.api.svc.IIdHelperService;
028import ca.uhn.fhir.jpa.model.config.PartitionSettings;
029import ca.uhn.fhir.jpa.model.dao.JpaPid;
030import ca.uhn.fhir.jpa.model.entity.ResourceHistoryTable;
031import ca.uhn.fhir.rest.param.HistorySearchStyleEnum;
032import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
033import com.google.common.collect.ImmutableListMultimap;
034import com.google.common.collect.Multimaps;
035import jakarta.annotation.Nullable;
036import jakarta.persistence.EntityManager;
037import jakarta.persistence.PersistenceContext;
038import jakarta.persistence.PersistenceContextType;
039import jakarta.persistence.TypedQuery;
040import jakarta.persistence.criteria.CriteriaBuilder;
041import jakarta.persistence.criteria.CriteriaQuery;
042import jakarta.persistence.criteria.Expression;
043import jakarta.persistence.criteria.JoinType;
044import jakarta.persistence.criteria.Predicate;
045import jakarta.persistence.criteria.Root;
046import jakarta.persistence.criteria.Subquery;
047import org.slf4j.Logger;
048import org.slf4j.LoggerFactory;
049import org.springframework.beans.factory.annotation.Autowired;
050
051import java.util.ArrayList;
052import java.util.Date;
053import java.util.List;
054import java.util.Optional;
055import java.util.Set;
056import java.util.stream.Collectors;
057
058import static ca.uhn.fhir.jpa.util.QueryParameterUtils.toPredicateArray;
059
060/**
061 * The HistoryBuilder is responsible for building history queries
062 */
063public class HistoryBuilder {
064
065        private static final Logger ourLog = LoggerFactory.getLogger(HistoryBuilder.class);
066        private final String myResourceType;
067        private final Long myResourceId;
068        private final Date myRangeStartInclusive;
069        private final Date myRangeEndInclusive;
070
071        @Autowired
072        protected IInterceptorBroadcaster myInterceptorBroadcaster;
073
074        @PersistenceContext(type = PersistenceContextType.TRANSACTION)
075        protected EntityManager myEntityManager;
076
077        @Autowired
078        private PartitionSettings myPartitionSettings;
079
080        @Autowired
081        private FhirContext myCtx;
082
083        @Autowired
084        private IIdHelperService<JpaPid> myIdHelperService;
085
086        /**
087         * Constructor
088         */
089        public HistoryBuilder(
090                        @Nullable String theResourceType,
091                        @Nullable Long theResourceId,
092                        @Nullable Date theRangeStartInclusive,
093                        @Nullable Date theRangeEndInclusive) {
094                myResourceType = theResourceType;
095                myResourceId = theResourceId;
096                myRangeStartInclusive = theRangeStartInclusive;
097                myRangeEndInclusive = theRangeEndInclusive;
098        }
099
100        public Long fetchCount(RequestPartitionId thePartitionId) {
101                CriteriaBuilder cb = myEntityManager.getCriteriaBuilder();
102                CriteriaQuery<Long> criteriaQuery = cb.createQuery(Long.class);
103                Root<ResourceHistoryTable> from = criteriaQuery.from(ResourceHistoryTable.class);
104                criteriaQuery.select(cb.count(from));
105
106                addPredicatesToQuery(cb, thePartitionId, criteriaQuery, from, null);
107
108                TypedQuery<Long> query = myEntityManager.createQuery(criteriaQuery);
109                return query.getSingleResult();
110        }
111
112        @SuppressWarnings("OptionalIsPresent")
113        public List<ResourceHistoryTable> fetchEntities(
114                        RequestPartitionId thePartitionId,
115                        Integer theOffset,
116                        int theFromIndex,
117                        int theToIndex,
118                        HistorySearchStyleEnum theHistorySearchStyle) {
119                CriteriaBuilder cb = myEntityManager.getCriteriaBuilder();
120                CriteriaQuery<ResourceHistoryTable> criteriaQuery = cb.createQuery(ResourceHistoryTable.class);
121                Root<ResourceHistoryTable> from = criteriaQuery.from(ResourceHistoryTable.class);
122
123                addPredicatesToQuery(cb, thePartitionId, criteriaQuery, from, theHistorySearchStyle);
124
125                from.fetch("myProvenance", JoinType.LEFT);
126
127                /*
128                 * The sort on myUpdated is the important one for _history operations, but there are
129                 * cases where multiple pages of results all have the exact same myUpdated value (e.g.
130                 * if they were all ingested in a single FHIR transaction). So we put a secondary sort
131                 * on the resource PID just to ensure that the sort is stable across queries.
132                 *
133                 * There are indexes supporting the myUpdated sort at each level (system/type/instance)
134                 * but those indexes don't include myResourceId. I don't think that should be an issue
135                 * since myUpdated should generally be unique anyhow. If this ever becomes an issue,
136                 * we might consider adding the resource PID to indexes IDX_RESVER_DATE and
137                 * IDX_RESVER_TYPE_DATE in the future.
138                 * -JA 2024-04-21
139                 */
140                criteriaQuery.orderBy(cb.desc(from.get("myUpdated")), cb.desc(from.get("myResourceId")));
141
142                TypedQuery<ResourceHistoryTable> query = myEntityManager.createQuery(criteriaQuery);
143
144                int startIndex = theFromIndex;
145                if (theOffset != null) {
146                        startIndex += theOffset;
147                }
148                query.setFirstResult(startIndex);
149
150                query.setMaxResults(theToIndex - theFromIndex);
151
152                List<ResourceHistoryTable> tables = query.getResultList();
153                if (!tables.isEmpty()) {
154                        ImmutableListMultimap<Long, ResourceHistoryTable> resourceIdToHistoryEntries =
155                                        Multimaps.index(tables, ResourceHistoryTable::getResourceId);
156                        Set<JpaPid> pids = resourceIdToHistoryEntries.keySet().stream()
157                                        .map(JpaPid::fromId)
158                                        .collect(Collectors.toSet());
159                        PersistentIdToForcedIdMap<JpaPid> pidToForcedId = myIdHelperService.translatePidsToForcedIds(pids);
160                        ourLog.trace("Translated IDs: {}", pidToForcedId.getResourcePersistentIdOptionalMap());
161
162                        for (Long nextResourceId : resourceIdToHistoryEntries.keySet()) {
163                                List<ResourceHistoryTable> historyTables = resourceIdToHistoryEntries.get(nextResourceId);
164
165                                String resourceId;
166
167                                Optional<String> forcedId = pidToForcedId.get(JpaPid.fromId(nextResourceId));
168                                if (forcedId.isPresent()) {
169                                        resourceId = forcedId.get();
170                                        // IdHelperService returns a forcedId with the '<resourceType>/' prefix
171                                        // but the transientForcedId is expected to be just the idPart (without the <resourceType>/ prefix).
172                                        // For that reason, strip the prefix before setting the transientForcedId below.
173                                        // If not stripped this messes up the id of the resource as the resourceType would be repeated
174                                        // twice like Patient/Patient/1234 in the resource constructed
175                                        int slashIdx = resourceId.indexOf('/');
176                                        if (slashIdx != -1) {
177                                                resourceId = resourceId.substring(slashIdx + 1);
178                                        }
179                                } else {
180                                        resourceId = nextResourceId.toString();
181                                }
182
183                                for (ResourceHistoryTable nextHistoryTable : historyTables) {
184                                        nextHistoryTable.setTransientForcedId(resourceId);
185                                }
186                        }
187                }
188
189                return tables;
190        }
191
192        private void addPredicatesToQuery(
193                        CriteriaBuilder theCriteriaBuilder,
194                        RequestPartitionId thePartitionId,
195                        CriteriaQuery<?> theQuery,
196                        Root<ResourceHistoryTable> theFrom,
197                        HistorySearchStyleEnum theHistorySearchStyle) {
198                List<Predicate> predicates = new ArrayList<>();
199
200                if (!thePartitionId.isAllPartitions()) {
201                        if (thePartitionId.isDefaultPartition()) {
202                                predicates.add(theCriteriaBuilder.isNull(theFrom.get("myPartitionIdValue")));
203                        } else if (thePartitionId.hasDefaultPartitionId()) {
204                                predicates.add(theCriteriaBuilder.or(
205                                                theCriteriaBuilder.isNull(theFrom.get("myPartitionIdValue")),
206                                                theFrom.get("myPartitionIdValue").in(thePartitionId.getPartitionIdsWithoutDefault())));
207                        } else {
208                                predicates.add(theFrom.get("myPartitionIdValue").in(thePartitionId.getPartitionIds()));
209                        }
210                }
211
212                if (myResourceId != null) {
213                        predicates.add(theCriteriaBuilder.equal(theFrom.get("myResourceId"), myResourceId));
214                } else if (myResourceType != null) {
215                        validateNotSearchingAllPartitions(thePartitionId);
216                        predicates.add(theCriteriaBuilder.equal(theFrom.get("myResourceType"), myResourceType));
217                } else {
218                        validateNotSearchingAllPartitions(thePartitionId);
219                }
220
221                if (myRangeStartInclusive != null) {
222                        if (HistorySearchStyleEnum.AT == theHistorySearchStyle && myResourceId != null) {
223                                addPredicateForAtQueryParameter(theCriteriaBuilder, theQuery, theFrom, predicates);
224                        } else {
225                                predicates.add(
226                                                theCriteriaBuilder.greaterThanOrEqualTo(theFrom.get("myUpdated"), myRangeStartInclusive));
227                        }
228                }
229                if (myRangeEndInclusive != null) {
230                        predicates.add(theCriteriaBuilder.lessThanOrEqualTo(theFrom.get("myUpdated"), myRangeEndInclusive));
231                }
232
233                if (predicates.size() > 0) {
234                        theQuery.where(toPredicateArray(predicates));
235                }
236        }
237
238        private void addPredicateForAtQueryParameter(
239                        CriteriaBuilder theCriteriaBuilder,
240                        CriteriaQuery<?> theQuery,
241                        Root<ResourceHistoryTable> theFrom,
242                        List<Predicate> thePredicates) {
243                Subquery<Date> pastDateSubQuery = theQuery.subquery(Date.class);
244                Root<ResourceHistoryTable> subQueryResourceHistory = pastDateSubQuery.from(ResourceHistoryTable.class);
245                Expression myUpdatedMostRecent = theCriteriaBuilder.max(subQueryResourceHistory.get("myUpdated"));
246                Expression myUpdatedMostRecentOrDefault =
247                                theCriteriaBuilder.coalesce(myUpdatedMostRecent, theCriteriaBuilder.literal(myRangeStartInclusive));
248
249                pastDateSubQuery
250                                .select(myUpdatedMostRecentOrDefault)
251                                .where(
252                                                theCriteriaBuilder.lessThanOrEqualTo(
253                                                                subQueryResourceHistory.get("myUpdated"), myRangeStartInclusive),
254                                                theCriteriaBuilder.equal(subQueryResourceHistory.get("myResourceId"), myResourceId));
255
256                Predicate updatedDatePredicate =
257                                theCriteriaBuilder.greaterThanOrEqualTo(theFrom.get("myUpdated"), pastDateSubQuery);
258                thePredicates.add(updatedDatePredicate);
259        }
260
261        private void validateNotSearchingAllPartitions(RequestPartitionId thePartitionId) {
262                if (myPartitionSettings.isPartitioningEnabled()) {
263                        if (thePartitionId.isAllPartitions()) {
264                                String msg = myCtx.getLocalizer()
265                                                .getMessage(HistoryBuilder.class, "noSystemOrTypeHistoryForPartitionAwareServer");
266                                throw new InvalidRequestException(Msg.code(953) + msg);
267                        }
268                }
269        }
270}