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;
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.model.primitive.IdDt;
032import ca.uhn.fhir.rest.param.HistorySearchStyleEnum;
033import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
034import com.google.common.collect.ImmutableListMultimap;
035import com.google.common.collect.Multimaps;
036import jakarta.annotation.Nonnull;
037import jakarta.annotation.Nullable;
038import jakarta.persistence.EntityManager;
039import jakarta.persistence.PersistenceContext;
040import jakarta.persistence.PersistenceContextType;
041import jakarta.persistence.TypedQuery;
042import jakarta.persistence.criteria.CriteriaBuilder;
043import jakarta.persistence.criteria.CriteriaQuery;
044import jakarta.persistence.criteria.Expression;
045import jakarta.persistence.criteria.Predicate;
046import jakarta.persistence.criteria.Root;
047import jakarta.persistence.criteria.Subquery;
048import org.apache.commons.collections4.CollectionUtils;
049import org.slf4j.Logger;
050import org.slf4j.LoggerFactory;
051import org.springframework.beans.factory.annotation.Autowired;
052
053import java.time.Instant;
054import java.time.ZoneId;
055import java.time.ZonedDateTime;
056import java.util.ArrayList;
057import java.util.Date;
058import java.util.List;
059import java.util.Optional;
060import java.util.Set;
061
062import static ca.uhn.fhir.jpa.util.QueryParameterUtils.toPredicateArray;
063
064/**
065 * The HistoryBuilder is responsible for building history queries
066 */
067public class HistoryBuilder {
068
069        private static final Logger ourLog = LoggerFactory.getLogger(HistoryBuilder.class);
070        private final String myResourceType;
071        private final JpaPid myResourceId;
072        private final List<String> myResourceIds;
073        private final Date myRangeStartInclusive;
074        private final Date myRangeEndInclusive;
075
076        @Autowired
077        protected IInterceptorBroadcaster myInterceptorBroadcaster;
078
079        @PersistenceContext(type = PersistenceContextType.TRANSACTION)
080        protected EntityManager myEntityManager;
081
082        @Autowired
083        private PartitionSettings myPartitionSettings;
084
085        @Autowired
086        private FhirContext myCtx;
087
088        @Autowired
089        private IIdHelperService<JpaPid> myIdHelperService;
090
091        /**
092         * Constructor
093         */
094        public HistoryBuilder(
095                        @Nullable String theResourceType,
096                        @Nullable JpaPid theResourceId,
097                        @Nullable Date theRangeStartInclusive,
098                        @Nullable Date theRangeEndInclusive) {
099                myResourceType = theResourceType;
100                myResourceId = theResourceId;
101                myResourceIds = null;
102                myRangeStartInclusive = theRangeStartInclusive;
103                myRangeEndInclusive = theRangeEndInclusive;
104        }
105
106        /**
107         * Constructor for multiple resource IDs
108         */
109        public HistoryBuilder(
110                        @Nonnull String theResourceType,
111                        @Nonnull List<String> theResourceIds,
112                        @Nullable Date theRangeStartInclusive,
113                        @Nonnull Date theRangeEndInclusive) {
114                myResourceType = theResourceType;
115                myResourceIds = theResourceIds;
116                myResourceId = null;
117                myRangeStartInclusive = theRangeStartInclusive;
118                myRangeEndInclusive = theRangeEndInclusive;
119        }
120
121        public Long fetchCount(RequestPartitionId thePartitionId) {
122                CriteriaBuilder cb = myEntityManager.getCriteriaBuilder();
123                CriteriaQuery<Long> criteriaQuery = cb.createQuery(Long.class);
124                Root<ResourceHistoryTable> from = criteriaQuery.from(ResourceHistoryTable.class);
125                criteriaQuery.select(cb.count(from));
126
127                addPredicatesToQuery(cb, thePartitionId, criteriaQuery, from, null);
128
129                TypedQuery<Long> query = myEntityManager.createQuery(criteriaQuery);
130                return query.getSingleResult();
131        }
132
133        public List<ResourceHistoryTable> fetchEntities(
134                        RequestPartitionId thePartitionId,
135                        Integer theOffset,
136                        int theFromIndex,
137                        int theToIndex,
138                        HistorySearchStyleEnum theHistorySearchStyle) {
139                CriteriaBuilder cb = myEntityManager.getCriteriaBuilder();
140                CriteriaQuery<ResourceHistoryTable> criteriaQuery = cb.createQuery(ResourceHistoryTable.class);
141                Root<ResourceHistoryTable> from = criteriaQuery.from(ResourceHistoryTable.class);
142
143                addPredicatesToQuery(cb, thePartitionId, criteriaQuery, from, theHistorySearchStyle);
144
145                /*
146                 * The sort on myUpdated is the important one for _history operations, but there are
147                 * cases where multiple pages of results all have the exact same myUpdated value (e.g.
148                 * if they were all ingested in a single FHIR transaction). So we put a secondary sort
149                 * on the resource PID just to ensure that the sort is stable across queries.
150                 *
151                 * There are indexes supporting the myUpdated sort at each level (system/type/instance)
152                 * but those indexes don't include myResourceId. I don't think that should be an issue
153                 * since myUpdated should generally be unique anyhow. If this ever becomes an issue,
154                 * we might consider adding the resource PID to indexes IDX_RESVER_DATE and
155                 * IDX_RESVER_TYPE_DATE in the future.
156                 * -JA 2024-04-21
157                 */
158
159                if (CollectionUtils.isNotEmpty(myResourceIds)) {
160                        criteriaQuery.orderBy(cb.asc(from.get("myResourceId")), cb.desc(from.get("myUpdated")));
161                } else {
162                        criteriaQuery.orderBy(cb.desc(from.get("myUpdated")), cb.desc(from.get("myResourceId")));
163                }
164
165                TypedQuery<ResourceHistoryTable> query = myEntityManager.createQuery(criteriaQuery);
166
167                int startIndex = theFromIndex;
168                if (theOffset != null) {
169                        startIndex += theOffset;
170                }
171                query.setFirstResult(startIndex);
172
173                query.setMaxResults(theToIndex - theFromIndex);
174
175                List<ResourceHistoryTable> tables = query.getResultList();
176                if (!tables.isEmpty()) {
177                        ImmutableListMultimap<JpaPid, ResourceHistoryTable> resourceIdToHistoryEntries =
178                                        Multimaps.index(tables, ResourceHistoryTable::getResourceId);
179                        Set<JpaPid> pids = resourceIdToHistoryEntries.keySet();
180                        PersistentIdToForcedIdMap<JpaPid> pidToForcedId = myIdHelperService.translatePidsToForcedIds(pids);
181                        ourLog.trace("Translated IDs: {}", pidToForcedId.getResourcePersistentIdOptionalMap());
182
183                        for (JpaPid nextResourceId : resourceIdToHistoryEntries.keySet()) {
184                                List<ResourceHistoryTable> historyTables = resourceIdToHistoryEntries.get(nextResourceId);
185
186                                String resourceId;
187
188                                Optional<String> forcedId = pidToForcedId.get(nextResourceId);
189                                if (forcedId.isPresent()) {
190                                        resourceId = forcedId.get();
191                                        // IdHelperService returns a forcedId with the '<resourceType>/' prefix
192                                        // but the transientForcedId is expected to be just the idPart (without the <resourceType>/ prefix).
193                                        // For that reason, strip the prefix before setting the transientForcedId below.
194                                        // If not stripped this messes up the id of the resource as the resourceType would be repeated
195                                        // twice like Patient/Patient/1234 in the resource constructed
196                                        int slashIdx = resourceId.indexOf('/');
197                                        if (slashIdx != -1) {
198                                                resourceId = resourceId.substring(slashIdx + 1);
199                                        }
200                                } else {
201                                        resourceId = Long.toString(nextResourceId.getId());
202                                }
203
204                                for (ResourceHistoryTable nextHistoryTable : historyTables) {
205                                        nextHistoryTable.setTransientForcedId(resourceId);
206                                }
207                        }
208                }
209
210                return tables;
211        }
212
213        private void addPredicatesToQuery(
214                        CriteriaBuilder theCriteriaBuilder,
215                        RequestPartitionId thePartitionId,
216                        CriteriaQuery<?> theQuery,
217                        Root<ResourceHistoryTable> theFrom,
218                        HistorySearchStyleEnum theHistorySearchStyle) {
219                Integer defaultPartitionId = myPartitionSettings.getDefaultPartitionId();
220                List<Predicate> predicates = new ArrayList<>();
221
222                if (myResourceId != null) {
223
224                        predicates.add(theCriteriaBuilder.equal(theFrom.get("myResourceId"), myResourceId.getId()));
225                        if (myPartitionSettings.isPartitioningEnabled()) {
226                                if (myResourceId.getPartitionId() != null) {
227                                        predicates.add(
228                                                        theCriteriaBuilder.equal(theFrom.get("myPartitionIdValue"), myResourceId.getPartitionId()));
229                                } else {
230                                        predicates.add(theCriteriaBuilder.isNull(theFrom.get("myPartitionIdValue")));
231                                }
232                        }
233
234                } else if (CollectionUtils.isNotEmpty(myResourceIds)) {
235
236                        addResourceIdsFiltering(myResourceIds, thePartitionId, theFrom, predicates);
237
238                } else {
239
240                        if (!thePartitionId.isAllPartitions()) {
241                                if (thePartitionId.isPartition(defaultPartitionId)) {
242                                        predicates.add(theCriteriaBuilder.isNull(theFrom.get("myPartitionIdValue")));
243                                } else if (thePartitionId.hasDefaultPartitionId(defaultPartitionId)) {
244                                        predicates.add(theCriteriaBuilder.or(
245                                                        theCriteriaBuilder.isNull(theFrom.get("myPartitionIdValue")),
246                                                        theFrom.get("myPartitionIdValue").in(thePartitionId.getPartitionIdsWithoutDefault())));
247                                } else {
248                                        predicates.add(theFrom.get("myPartitionIdValue").in(thePartitionId.getPartitionIds()));
249                                }
250                        }
251
252                        if (myResourceType != null) {
253                                validateNotSearchingAllPartitions(thePartitionId);
254                                predicates.add(theCriteriaBuilder.equal(theFrom.get("myResourceType"), myResourceType));
255                        } else {
256                                validateNotSearchingAllPartitions(thePartitionId);
257                        }
258                }
259
260                if (myRangeStartInclusive != null) {
261                        if (HistorySearchStyleEnum.AT == theHistorySearchStyle && myResourceId != null) {
262                                addPredicateForAtQueryParameter(theCriteriaBuilder, theQuery, theFrom, predicates);
263                        } else {
264                                predicates.add(
265                                                theCriteriaBuilder.greaterThanOrEqualTo(theFrom.get("myUpdated"), myRangeStartInclusive));
266                        }
267                }
268                if (myRangeEndInclusive != null) {
269                        predicates.add(theCriteriaBuilder.lessThanOrEqualTo(theFrom.get("myUpdated"), myRangeEndInclusive));
270                }
271
272                if (!predicates.isEmpty()) {
273                        theQuery.where(toPredicateArray(predicates));
274                }
275        }
276
277        private void addResourceIdsFiltering(
278                        @Nonnull List<String> theResourceIds,
279                        RequestPartitionId thePartitionId,
280                        Root<ResourceHistoryTable> theFrom,
281                        List<Predicate> predicates) {
282
283                List<Long> ids = theResourceIds.stream()
284                                .map(id -> new IdDt(id).getIdPartAsLong())
285                                .toList();
286
287                // Create a predicate to filter by resource IDs
288                if (!ids.isEmpty()) {
289                        predicates.add(theFrom.get("myResourceId").in(ids));
290
291                        if (myPartitionSettings.isPartitioningEnabled()) {
292                                if (thePartitionId != null && thePartitionId.getFirstPartitionIdOrNull() != null) {
293                                        predicates.add(theFrom.get("myPartitionIdValue").in(thePartitionId.getFirstPartitionIdOrNull()));
294                                }
295                        }
296                }
297        }
298
299        private void addPredicateForAtQueryParameter(
300                        CriteriaBuilder theCriteriaBuilder,
301                        CriteriaQuery<?> theQuery,
302                        Root<ResourceHistoryTable> theFrom,
303                        List<Predicate> thePredicates) {
304                Subquery<Date> pastDateSubQuery = theQuery.subquery(Date.class);
305                Root<ResourceHistoryTable> subQueryResourceHistory = pastDateSubQuery.from(ResourceHistoryTable.class);
306                Expression myUpdatedMostRecent = theCriteriaBuilder.max(subQueryResourceHistory.get("myUpdated"));
307
308                /*
309                 * This conversion from the Date in myRangeEndInclusive into a ZonedDateTime is an experiment -
310                 * There is an intermittent test failure in testSearchHistoryWithAtAndGtParameters() that I can't
311                 * figure out. But I've added a ton of logging to the error it fails with and I noticed that
312                 * we emit SQL along the lines of
313                 *   select coalesce(max(rht2_0.RES_UPDATED), timestamp with time zone '2024-10-05 18:24:48.172000000Z')
314                 * for this date, and all other dates are in GMT so this is an experiment. If nothing changes,
315                 * we can roll this back to
316                 *   theCriteriaBuilder.literal(myRangeStartInclusive)
317                 * JA 20241005
318                 */
319                ZonedDateTime rangeStart =
320                                ZonedDateTime.ofInstant(Instant.ofEpochMilli(myRangeStartInclusive.getTime()), ZoneId.of("GMT"));
321
322                Expression myUpdatedMostRecentOrDefault =
323                                theCriteriaBuilder.coalesce(myUpdatedMostRecent, theCriteriaBuilder.literal(rangeStart));
324
325                pastDateSubQuery
326                                .select(myUpdatedMostRecentOrDefault)
327                                .where(
328                                                theCriteriaBuilder.lessThanOrEqualTo(
329                                                                subQueryResourceHistory.get("myUpdated"), myRangeStartInclusive),
330                                                theCriteriaBuilder.equal(subQueryResourceHistory.get("myResourcePid"), myResourceId.toFk()));
331
332                Predicate updatedDatePredicate =
333                                theCriteriaBuilder.greaterThanOrEqualTo(theFrom.get("myUpdated"), pastDateSubQuery);
334                thePredicates.add(updatedDatePredicate);
335        }
336
337        private void validateNotSearchingAllPartitions(RequestPartitionId thePartitionId) {
338                if (myPartitionSettings.isPartitioningEnabled()) {
339                        if (thePartitionId.isAllPartitions()) {
340                                String msg = myCtx.getLocalizer()
341                                                .getMessage(HistoryBuilder.class, "noSystemOrTypeHistoryForPartitionAwareServer");
342                                throw new InvalidRequestException(Msg.code(953) + msg);
343                        }
344                }
345        }
346}