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