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.Predicate;
044import jakarta.persistence.criteria.Root;
045import jakarta.persistence.criteria.Subquery;
046import org.slf4j.Logger;
047import org.slf4j.LoggerFactory;
048import org.springframework.beans.factory.annotation.Autowired;
049
050import java.time.Instant;
051import java.time.ZoneId;
052import java.time.ZonedDateTime;
053import java.util.ArrayList;
054import java.util.Date;
055import java.util.List;
056import java.util.Optional;
057import java.util.Set;
058import java.util.stream.Collectors;
059
060import static ca.uhn.fhir.jpa.util.QueryParameterUtils.toPredicateArray;
061
062/**
063 * The HistoryBuilder is responsible for building history queries
064 */
065public class HistoryBuilder {
066
067        private static final Logger ourLog = LoggerFactory.getLogger(HistoryBuilder.class);
068        private final String myResourceType;
069        private final Long myResourceId;
070        private final Date myRangeStartInclusive;
071        private final Date myRangeEndInclusive;
072
073        @Autowired
074        protected IInterceptorBroadcaster myInterceptorBroadcaster;
075
076        @PersistenceContext(type = PersistenceContextType.TRANSACTION)
077        protected EntityManager myEntityManager;
078
079        @Autowired
080        private PartitionSettings myPartitionSettings;
081
082        @Autowired
083        private FhirContext myCtx;
084
085        @Autowired
086        private IIdHelperService<JpaPid> myIdHelperService;
087
088        /**
089         * Constructor
090         */
091        public HistoryBuilder(
092                        @Nullable String theResourceType,
093                        @Nullable Long theResourceId,
094                        @Nullable Date theRangeStartInclusive,
095                        @Nullable Date theRangeEndInclusive) {
096                myResourceType = theResourceType;
097                myResourceId = theResourceId;
098                myRangeStartInclusive = theRangeStartInclusive;
099                myRangeEndInclusive = theRangeEndInclusive;
100        }
101
102        public Long fetchCount(RequestPartitionId thePartitionId) {
103                CriteriaBuilder cb = myEntityManager.getCriteriaBuilder();
104                CriteriaQuery<Long> criteriaQuery = cb.createQuery(Long.class);
105                Root<ResourceHistoryTable> from = criteriaQuery.from(ResourceHistoryTable.class);
106                criteriaQuery.select(cb.count(from));
107
108                addPredicatesToQuery(cb, thePartitionId, criteriaQuery, from, null);
109
110                TypedQuery<Long> query = myEntityManager.createQuery(criteriaQuery);
111                return query.getSingleResult();
112        }
113
114        @SuppressWarnings("OptionalIsPresent")
115        public List<ResourceHistoryTable> fetchEntities(
116                        RequestPartitionId thePartitionId,
117                        Integer theOffset,
118                        int theFromIndex,
119                        int theToIndex,
120                        HistorySearchStyleEnum theHistorySearchStyle) {
121                CriteriaBuilder cb = myEntityManager.getCriteriaBuilder();
122                CriteriaQuery<ResourceHistoryTable> criteriaQuery = cb.createQuery(ResourceHistoryTable.class);
123                Root<ResourceHistoryTable> from = criteriaQuery.from(ResourceHistoryTable.class);
124
125                addPredicatesToQuery(cb, thePartitionId, criteriaQuery, from, theHistorySearchStyle);
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
247                /*
248                 * This conversion from the Date in myRangeEndInclusive into a ZonedDateTime is an experiment -
249                 * There is an intermittent test failure in testSearchHistoryWithAtAndGtParameters() that I can't
250                 * figure out. But I've added a ton of logging to the error it fails with and I noticed that
251                 * we emit SQL along the lines of
252                 *   select coalesce(max(rht2_0.RES_UPDATED), timestamp with time zone '2024-10-05 18:24:48.172000000Z')
253                 * for this date, and all other dates are in GMT so this is an experiment. If nothing changes,
254                 * we can roll this back to
255                 *   theCriteriaBuilder.literal(myRangeStartInclusive)
256                 * JA 20241005
257                 */
258                ZonedDateTime rangeStart =
259                                ZonedDateTime.ofInstant(Instant.ofEpochMilli(myRangeStartInclusive.getTime()), ZoneId.of("GMT"));
260
261                Expression myUpdatedMostRecentOrDefault =
262                                theCriteriaBuilder.coalesce(myUpdatedMostRecent, theCriteriaBuilder.literal(rangeStart));
263
264                pastDateSubQuery
265                                .select(myUpdatedMostRecentOrDefault)
266                                .where(
267                                                theCriteriaBuilder.lessThanOrEqualTo(
268                                                                subQueryResourceHistory.get("myUpdated"), myRangeStartInclusive),
269                                                theCriteriaBuilder.equal(subQueryResourceHistory.get("myResourceId"), myResourceId));
270
271                Predicate updatedDatePredicate =
272                                theCriteriaBuilder.greaterThanOrEqualTo(theFrom.get("myUpdated"), pastDateSubQuery);
273                thePredicates.add(updatedDatePredicate);
274        }
275
276        private void validateNotSearchingAllPartitions(RequestPartitionId thePartitionId) {
277                if (myPartitionSettings.isPartitioningEnabled()) {
278                        if (thePartitionId.isAllPartitions()) {
279                                String msg = myCtx.getLocalizer()
280                                                .getMessage(HistoryBuilder.class, "noSystemOrTypeHistoryForPartitionAwareServer");
281                                throw new InvalidRequestException(Msg.code(953) + msg);
282                        }
283                }
284        }
285}