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.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; 058import java.util.stream.Collectors; 059 060import static ca.uhn.fhir.jpa.util.QueryParameterUtils.toPredicateArray; 061 062/** 063 * The HistoryBuilder is responsible for building history queries 064 */ 065public class HistoryBuilder { 066 067 private static final Logger ourLog = LoggerFactory.getLogger(HistoryBuilder.class); 068 private final String myResourceType; 069 private final Long myResourceId; 070 private final Date myRangeStartInclusive; 071 private final Date myRangeEndInclusive; 072 073 @Autowired 074 protected IInterceptorBroadcaster myInterceptorBroadcaster; 075 076 @PersistenceContext(type = PersistenceContextType.TRANSACTION) 077 protected EntityManager myEntityManager; 078 079 @Autowired 080 private PartitionSettings myPartitionSettings; 081 082 @Autowired 083 private FhirContext myCtx; 084 085 @Autowired 086 private IIdHelperService<JpaPid> myIdHelperService; 087 088 /** 089 * Constructor 090 */ 091 public HistoryBuilder( 092 @Nullable String theResourceType, 093 @Nullable Long theResourceId, 094 @Nullable Date theRangeStartInclusive, 095 @Nullable Date theRangeEndInclusive) { 096 myResourceType = theResourceType; 097 myResourceId = theResourceId; 098 myRangeStartInclusive = theRangeStartInclusive; 099 myRangeEndInclusive = theRangeEndInclusive; 100 } 101 102 public Long fetchCount(RequestPartitionId thePartitionId) { 103 CriteriaBuilder cb = myEntityManager.getCriteriaBuilder(); 104 CriteriaQuery<Long> criteriaQuery = cb.createQuery(Long.class); 105 Root<ResourceHistoryTable> from = criteriaQuery.from(ResourceHistoryTable.class); 106 criteriaQuery.select(cb.count(from)); 107 108 addPredicatesToQuery(cb, thePartitionId, criteriaQuery, from, null); 109 110 TypedQuery<Long> query = myEntityManager.createQuery(criteriaQuery); 111 return query.getSingleResult(); 112 } 113 114 @SuppressWarnings("OptionalIsPresent") 115 public List<ResourceHistoryTable> fetchEntities( 116 RequestPartitionId thePartitionId, 117 Integer theOffset, 118 int theFromIndex, 119 int theToIndex, 120 HistorySearchStyleEnum theHistorySearchStyle) { 121 CriteriaBuilder cb = myEntityManager.getCriteriaBuilder(); 122 CriteriaQuery<ResourceHistoryTable> criteriaQuery = cb.createQuery(ResourceHistoryTable.class); 123 Root<ResourceHistoryTable> from = criteriaQuery.from(ResourceHistoryTable.class); 124 125 addPredicatesToQuery(cb, thePartitionId, criteriaQuery, from, theHistorySearchStyle); 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 247 /* 248 * This conversion from the Date in myRangeEndInclusive into a ZonedDateTime is an experiment - 249 * There is an intermittent test failure in testSearchHistoryWithAtAndGtParameters() that I can't 250 * figure out. But I've added a ton of logging to the error it fails with and I noticed that 251 * we emit SQL along the lines of 252 * select coalesce(max(rht2_0.RES_UPDATED), timestamp with time zone '2024-10-05 18:24:48.172000000Z') 253 * for this date, and all other dates are in GMT so this is an experiment. If nothing changes, 254 * we can roll this back to 255 * theCriteriaBuilder.literal(myRangeStartInclusive) 256 * JA 20241005 257 */ 258 ZonedDateTime rangeStart = 259 ZonedDateTime.ofInstant(Instant.ofEpochMilli(myRangeStartInclusive.getTime()), ZoneId.of("GMT")); 260 261 Expression myUpdatedMostRecentOrDefault = 262 theCriteriaBuilder.coalesce(myUpdatedMostRecent, theCriteriaBuilder.literal(rangeStart)); 263 264 pastDateSubQuery 265 .select(myUpdatedMostRecentOrDefault) 266 .where( 267 theCriteriaBuilder.lessThanOrEqualTo( 268 subQueryResourceHistory.get("myUpdated"), myRangeStartInclusive), 269 theCriteriaBuilder.equal(subQueryResourceHistory.get("myResourceId"), myResourceId)); 270 271 Predicate updatedDatePredicate = 272 theCriteriaBuilder.greaterThanOrEqualTo(theFrom.get("myUpdated"), pastDateSubQuery); 273 thePredicates.add(updatedDatePredicate); 274 } 275 276 private void validateNotSearchingAllPartitions(RequestPartitionId thePartitionId) { 277 if (myPartitionSettings.isPartitioningEnabled()) { 278 if (thePartitionId.isAllPartitions()) { 279 String msg = myCtx.getLocalizer() 280 .getMessage(HistoryBuilder.class, "noSystemOrTypeHistoryForPartitionAwareServer"); 281 throw new InvalidRequestException(Msg.code(953) + msg); 282 } 283 } 284 } 285}