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 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.size() > 0) {
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 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                                        if (resourceId.startsWith(myResourceType + "/")) {
176                                                resourceId = resourceId.substring(myResourceType.length() + 1);
177                                        }
178                                } else {
179                                        resourceId = nextResourceId.toString();
180                                }
181
182                                for (ResourceHistoryTable nextHistoryTable : historyTables) {
183                                        nextHistoryTable.setTransientForcedId(resourceId);
184                                }
185                        }
186                }
187
188                return tables;
189        }
190
191        private void addPredicatesToQuery(
192                        CriteriaBuilder theCriteriaBuilder,
193                        RequestPartitionId thePartitionId,
194                        CriteriaQuery<?> theQuery,
195                        Root<ResourceHistoryTable> theFrom,
196                        HistorySearchStyleEnum theHistorySearchStyle) {
197                List<Predicate> predicates = new ArrayList<>();
198
199                if (!thePartitionId.isAllPartitions()) {
200                        if (thePartitionId.isDefaultPartition()) {
201                                predicates.add(theCriteriaBuilder.isNull(theFrom.get("myPartitionIdValue")));
202                        } else if (thePartitionId.hasDefaultPartitionId()) {
203                                predicates.add(theCriteriaBuilder.or(
204                                                theCriteriaBuilder.isNull(theFrom.get("myPartitionIdValue")),
205                                                theFrom.get("myPartitionIdValue").in(thePartitionId.getPartitionIdsWithoutDefault())));
206                        } else {
207                                predicates.add(theFrom.get("myPartitionIdValue").in(thePartitionId.getPartitionIds()));
208                        }
209                }
210
211                if (myResourceId != null) {
212                        predicates.add(theCriteriaBuilder.equal(theFrom.get("myResourceId"), myResourceId));
213                } else if (myResourceType != null) {
214                        validateNotSearchingAllPartitions(thePartitionId);
215                        predicates.add(theCriteriaBuilder.equal(theFrom.get("myResourceType"), myResourceType));
216                } else {
217                        validateNotSearchingAllPartitions(thePartitionId);
218                }
219
220                if (myRangeStartInclusive != null) {
221                        if (HistorySearchStyleEnum.AT == theHistorySearchStyle && myResourceId != null) {
222                                addPredicateForAtQueryParameter(theCriteriaBuilder, theQuery, theFrom, predicates);
223                        } else {
224                                predicates.add(
225                                                theCriteriaBuilder.greaterThanOrEqualTo(theFrom.get("myUpdated"), myRangeStartInclusive));
226                        }
227                }
228                if (myRangeEndInclusive != null) {
229                        predicates.add(theCriteriaBuilder.lessThanOrEqualTo(theFrom.get("myUpdated"), myRangeEndInclusive));
230                }
231
232                if (predicates.size() > 0) {
233                        theQuery.where(toPredicateArray(predicates));
234                }
235        }
236
237        private void addPredicateForAtQueryParameter(
238                        CriteriaBuilder theCriteriaBuilder,
239                        CriteriaQuery<?> theQuery,
240                        Root<ResourceHistoryTable> theFrom,
241                        List<Predicate> thePredicates) {
242                Subquery<Date> pastDateSubQuery = theQuery.subquery(Date.class);
243                Root<ResourceHistoryTable> subQueryResourceHistory = pastDateSubQuery.from(ResourceHistoryTable.class);
244                Expression myUpdatedMostRecent = theCriteriaBuilder.max(subQueryResourceHistory.get("myUpdated"));
245                Expression myUpdatedMostRecentOrDefault =
246                                theCriteriaBuilder.coalesce(myUpdatedMostRecent, theCriteriaBuilder.literal(myRangeStartInclusive));
247
248                pastDateSubQuery
249                                .select(myUpdatedMostRecentOrDefault)
250                                .where(
251                                                theCriteriaBuilder.lessThanOrEqualTo(
252                                                                subQueryResourceHistory.get("myUpdated"), myRangeStartInclusive),
253                                                theCriteriaBuilder.equal(subQueryResourceHistory.get("myResourceId"), myResourceId));
254
255                Predicate updatedDatePredicate =
256                                theCriteriaBuilder.greaterThanOrEqualTo(theFrom.get("myUpdated"), pastDateSubQuery);
257                thePredicates.add(updatedDatePredicate);
258        }
259
260        private void validateNotSearchingAllPartitions(RequestPartitionId thePartitionId) {
261                if (myPartitionSettings.isPartitioningEnabled()) {
262                        if (thePartitionId.isAllPartitions()) {
263                                String msg = myCtx.getLocalizer()
264                                                .getMessage(HistoryBuilder.class, "noSystemOrTypeHistoryForPartitionAwareServer");
265                                throw new InvalidRequestException(Msg.code(953) + msg);
266                        }
267                }
268        }
269}