
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}