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}