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}