diff --git a/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/7_0_0/5633-oracle-hfj-res-ver-clob-migration.yaml b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/7_0_0/5633-oracle-hfj-res-ver-clob-migration.yaml new file mode 100644 index 00000000000..08edbe2b2b7 --- /dev/null +++ b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/7_0_0/5633-oracle-hfj-res-ver-clob-migration.yaml @@ -0,0 +1,5 @@ +--- +type: fix +issue: 5633 +title: "Smile failed to save resources running on Oracle when installed from 2023-02 or earlier. + This has been fixed." diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/HibernatePropertiesProvider.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/HibernatePropertiesProvider.java index 74702646d6b..38d3e6747d8 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/HibernatePropertiesProvider.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/HibernatePropertiesProvider.java @@ -75,4 +75,8 @@ public class HibernatePropertiesProvider { public DataSource getDataSource() { return myEntityManagerFactory.getDataSource(); } + + public boolean isOracleDialect() { + return getDialect() instanceof org.hibernate.dialect.OracleDialect; + } } diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/JpaConfig.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/JpaConfig.java index 422f5a86f01..e60974ae642 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/JpaConfig.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/JpaConfig.java @@ -51,6 +51,7 @@ import ca.uhn.fhir.jpa.dao.IJpaStorageResourceParser; import ca.uhn.fhir.jpa.dao.ISearchBuilder; import ca.uhn.fhir.jpa.dao.JpaStorageResourceParser; import ca.uhn.fhir.jpa.dao.MatchResourceUrlService; +import ca.uhn.fhir.jpa.dao.ResourceHistoryCalculator; import ca.uhn.fhir.jpa.dao.SearchBuilderFactory; import ca.uhn.fhir.jpa.dao.TransactionProcessor; import ca.uhn.fhir.jpa.dao.data.IResourceModifiedDao; @@ -869,4 +870,10 @@ public class JpaConfig { public IMetaTagSorter metaTagSorter() { return new MetaTagSorterAlphabetical(); } + + @Bean + public ResourceHistoryCalculator resourceHistoryCalculator( + FhirContext theFhirContext, HibernatePropertiesProvider theHibernatePropertiesProvider) { + return new ResourceHistoryCalculator(theFhirContext, theHibernatePropertiesProvider.isOracleDialect()); + } } diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirDao.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirDao.java index c8c6a3fc9fb..06200fc852a 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirDao.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirDao.java @@ -85,7 +85,6 @@ import ca.uhn.fhir.model.api.TagList; import ca.uhn.fhir.model.base.composite.BaseCodingDt; import ca.uhn.fhir.model.primitive.IdDt; import ca.uhn.fhir.parser.DataFormatException; -import ca.uhn.fhir.parser.IParser; import ca.uhn.fhir.rest.api.Constants; import ca.uhn.fhir.rest.api.InterceptorInvocationTimingEnum; import ca.uhn.fhir.rest.api.RestOperationTypeEnum; @@ -105,8 +104,6 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Charsets; import com.google.common.collect.Sets; import com.google.common.hash.HashCode; -import com.google.common.hash.HashFunction; -import com.google.common.hash.Hashing; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.annotation.PostConstruct; @@ -264,6 +261,9 @@ public abstract class BaseHapiFhirDao extends BaseStora @Autowired private PlatformTransactionManager myTransactionManager; + @Autowired + protected ResourceHistoryCalculator myResourceHistoryCalculator; + protected final CodingSpy myCodingSpy = new CodingSpy(); @VisibleForTesting @@ -277,6 +277,11 @@ public abstract class BaseHapiFhirDao extends BaseStora mySearchParamPresenceSvc = theSearchParamPresenceSvc; } + @VisibleForTesting + public void setResourceHistoryCalculator(ResourceHistoryCalculator theResourceHistoryCalculator) { + myResourceHistoryCalculator = theResourceHistoryCalculator; + } + @Override protected IInterceptorBroadcaster getInterceptorBroadcaster() { return myInterceptorBroadcaster; @@ -643,6 +648,7 @@ public abstract class BaseHapiFhirDao extends BaseStora theEntity.setResourceType(toResourceName(theResource)); } + byte[] resourceBinary; String resourceText; ResourceEncodingEnum encoding; boolean changed = false; @@ -659,6 +665,7 @@ public abstract class BaseHapiFhirDao extends BaseStora if (address != null) { encoding = ResourceEncodingEnum.ESR; + resourceBinary = null; resourceText = address.getProviderId() + ":" + address.getLocation(); changed = true; @@ -675,10 +682,15 @@ public abstract class BaseHapiFhirDao extends BaseStora theEntity.setFhirVersion(myContext.getVersion().getVersion()); - HashFunction sha256 = Hashing.sha256(); - resourceText = encodeResource(theResource, encoding, excludeElements, myContext); - encoding = ResourceEncodingEnum.JSON; - HashCode hashCode = sha256.hashUnencodedChars(resourceText); + // TODO: LD: Once 2024-02 it out the door we should consider further refactoring here to move + // more of this logic within the calculator and eliminate more local variables + final ResourceHistoryState calculate = myResourceHistoryCalculator.calculateResourceHistoryState( + theResource, encoding, excludeElements); + + resourceText = calculate.getResourceText(); + resourceBinary = calculate.getResourceBinary(); + encoding = calculate.getEncoding(); // This may be a no-op + final HashCode hashCode = calculate.getHashCode(); String hashSha256 = hashCode.toString(); if (!hashSha256.equals(theEntity.getHashSha256())) { @@ -696,6 +708,7 @@ public abstract class BaseHapiFhirDao extends BaseStora } else { encoding = null; + resourceBinary = null; resourceText = null; } @@ -713,6 +726,7 @@ public abstract class BaseHapiFhirDao extends BaseStora changed = true; } + resourceBinary = null; resourceText = null; encoding = ResourceEncodingEnum.DEL; } @@ -737,13 +751,17 @@ public abstract class BaseHapiFhirDao extends BaseStora if (currentHistoryVersion == null || !currentHistoryVersion.hasResource()) { changed = true; } else { - changed = !StringUtils.equals(currentHistoryVersion.getResourceTextVc(), resourceText); + // TODO: LD: Once 2024-02 it out the door we should consider further refactoring here to move + // more of this logic within the calculator and eliminate more local variables + changed = myResourceHistoryCalculator.isResourceHistoryChanged( + currentHistoryVersion, resourceBinary, resourceText); } } } EncodedResource retVal = new EncodedResource(); retVal.setEncoding(encoding); + retVal.setResourceBinary(resourceBinary); retVal.setResourceText(resourceText); retVal.setChanged(changed); @@ -1393,8 +1411,11 @@ public abstract class BaseHapiFhirDao extends BaseStora ResourceEncodingEnum encoding = myStorageSettings.getResourceEncoding(); List excludeElements = new ArrayList<>(8); getExcludedElements(historyEntity.getResourceType(), excludeElements, theResource.getMeta()); - String encodedResourceString = encodeResource(theResource, encoding, excludeElements, myContext); - boolean changed = !StringUtils.equals(historyEntity.getResourceTextVc(), encodedResourceString); + String encodedResourceString = + myResourceHistoryCalculator.encodeResource(theResource, encoding, excludeElements); + byte[] resourceBinary = ResourceHistoryCalculator.getResourceBinary(encoding, encodedResourceString); + final boolean changed = myResourceHistoryCalculator.isResourceHistoryChanged( + historyEntity, resourceBinary, encodedResourceString); historyEntity.setUpdated(theTransactionDetails.getTransactionDate()); @@ -1406,14 +1427,15 @@ public abstract class BaseHapiFhirDao extends BaseStora return historyEntity; } - populateEncodedResource(encodedResource, encodedResourceString, ResourceEncodingEnum.JSON); + myResourceHistoryCalculator.populateEncodedResource( + encodedResource, encodedResourceString, resourceBinary, encoding); } - /* * Save the resource itself to the resourceHistoryTable */ historyEntity = myEntityManager.merge(historyEntity); historyEntity.setEncoding(encodedResource.getEncoding()); + historyEntity.setResource(encodedResource.getResourceBinary()); historyEntity.setResourceTextVc(encodedResource.getResourceText()); myResourceHistoryTableDao.save(historyEntity); @@ -1423,8 +1445,12 @@ public abstract class BaseHapiFhirDao extends BaseStora } private void populateEncodedResource( - EncodedResource encodedResource, String encodedResourceString, ResourceEncodingEnum theEncoding) { + EncodedResource encodedResource, + String encodedResourceString, + byte[] theResourceBinary, + ResourceEncodingEnum theEncoding) { encodedResource.setResourceText(encodedResourceString); + encodedResource.setResourceBinary(theResourceBinary); encodedResource.setEncoding(theEncoding); } @@ -1489,6 +1515,7 @@ public abstract class BaseHapiFhirDao extends BaseStora } historyEntry.setEncoding(theChanged.getEncoding()); + historyEntry.setResource(theChanged.getResourceBinary()); historyEntry.setResourceTextVc(theChanged.getResourceText()); ourLog.debug("Saving history entry ID[{}] for RES_ID[{}]", historyEntry.getId(), historyEntry.getResourceId()); @@ -1926,16 +1953,6 @@ public abstract class BaseHapiFhirDao extends BaseStora return resourceText; } - public static String encodeResource( - IBaseResource theResource, - ResourceEncodingEnum theEncoding, - List theExcludeElements, - FhirContext theContext) { - IParser parser = theEncoding.newParser(theContext); - parser.setDontEncodeElements(theExcludeElements); - return parser.encodeResourceToString(theResource); - } - private static String parseNarrativeTextIntoWords(IBaseResource theResource) { StringBuilder b = new StringBuilder(); diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java index 079f9dc8215..3d7fca9b04a 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java @@ -1709,17 +1709,11 @@ public abstract class BaseHapiFhirResourceDao extends B if (historyEntity.getEncoding() == ResourceEncodingEnum.JSONC || historyEntity.getEncoding() == ResourceEncodingEnum.JSON) { byte[] resourceBytes = historyEntity.getResource(); - - // Always migrate data out of the bytes column if (resourceBytes != null) { String resourceText = decodeResource(resourceBytes, historyEntity.getEncoding()); - ourLog.debug( - "Storing text of resource {} version {} as inline VARCHAR", - entity.getResourceId(), - historyEntity.getVersion()); - historyEntity.setResourceTextVc(resourceText); - historyEntity.setEncoding(ResourceEncodingEnum.JSON); - changed = true; + if (myResourceHistoryCalculator.conditionallyAlterHistoryEntity(entity, historyEntity, resourceText)) { + changed = true; + } } } if (isBlank(historyEntity.getSourceUri()) && isBlank(historyEntity.getRequestId())) { diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/EncodedResource.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/EncodedResource.java index d1d85f77727..cccce26e226 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/EncodedResource.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/EncodedResource.java @@ -24,6 +24,7 @@ import ca.uhn.fhir.jpa.model.entity.ResourceEncodingEnum; class EncodedResource { private boolean myChanged; + private byte[] myResource; private ResourceEncodingEnum myEncoding; private String myResourceText; @@ -35,6 +36,14 @@ class EncodedResource { myEncoding = theEncoding; } + public byte[] getResourceBinary() { + return myResource; + } + + public void setResourceBinary(byte[] theResource) { + myResource = theResource; + } + public boolean isChanged() { return myChanged; } diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/ResourceHistoryCalculator.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/ResourceHistoryCalculator.java new file mode 100644 index 00000000000..fafa3c4ca29 --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/ResourceHistoryCalculator.java @@ -0,0 +1,134 @@ +package ca.uhn.fhir.jpa.dao; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.jpa.model.entity.ResourceEncodingEnum; +import ca.uhn.fhir.jpa.model.entity.ResourceHistoryTable; +import ca.uhn.fhir.jpa.model.entity.ResourceTable; +import ca.uhn.fhir.parser.IParser; +import com.google.common.hash.HashCode; +import com.google.common.hash.HashFunction; +import com.google.common.hash.Hashing; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import org.apache.commons.lang3.StringUtils; +import org.hl7.fhir.instance.model.api.IBaseResource; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; + +/** + * Responsible for various resource history-centric and {@link FhirContext} aware operations called by + * {@link BaseHapiFhirDao} or {@link BaseHapiFhirResourceDao} that require knowledge of whether an Oracle database is + * being used. + */ +public class ResourceHistoryCalculator { + private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(ResourceHistoryCalculator.class); + private static final HashFunction SHA_256 = Hashing.sha256(); + + private final FhirContext myFhirContext; + private final boolean myIsOracleDialect; + + public ResourceHistoryCalculator(FhirContext theFhirContext, boolean theIsOracleDialect) { + myFhirContext = theFhirContext; + myIsOracleDialect = theIsOracleDialect; + } + + ResourceHistoryState calculateResourceHistoryState( + IBaseResource theResource, ResourceEncodingEnum theEncoding, List theExcludeElements) { + final String encodedResource = encodeResource(theResource, theEncoding, theExcludeElements); + final byte[] resourceBinary; + final String resourceText; + final ResourceEncodingEnum encoding; + final HashCode hashCode; + + if (myIsOracleDialect) { + resourceText = null; + resourceBinary = getResourceBinary(theEncoding, encodedResource); + encoding = theEncoding; + hashCode = SHA_256.hashBytes(resourceBinary); + } else { + resourceText = encodedResource; + resourceBinary = null; + encoding = ResourceEncodingEnum.JSON; + hashCode = SHA_256.hashUnencodedChars(encodedResource); + } + + return new ResourceHistoryState(resourceText, resourceBinary, encoding, hashCode); + } + + boolean conditionallyAlterHistoryEntity( + ResourceTable theEntity, ResourceHistoryTable theHistoryEntity, String theResourceText) { + if (!myIsOracleDialect) { + ourLog.debug( + "Storing text of resource {} version {} as inline VARCHAR", + theEntity.getResourceId(), + theHistoryEntity.getVersion()); + theHistoryEntity.setResourceTextVc(theResourceText); + theHistoryEntity.setResource(null); + theHistoryEntity.setEncoding(ResourceEncodingEnum.JSON); + return true; + } + + return false; + } + + boolean isResourceHistoryChanged( + ResourceHistoryTable theCurrentHistoryVersion, + @Nullable byte[] theResourceBinary, + @Nullable String resourceText) { + if (myIsOracleDialect) { + return !Arrays.equals(theCurrentHistoryVersion.getResource(), theResourceBinary); + } + + return !StringUtils.equals(theCurrentHistoryVersion.getResourceTextVc(), resourceText); + } + + String encodeResource( + IBaseResource theResource, ResourceEncodingEnum theEncoding, List theExcludeElements) { + final IParser parser = theEncoding.newParser(myFhirContext); + parser.setDontEncodeElements(theExcludeElements); + return parser.encodeResourceToString(theResource); + } + + /** + * helper for returning the encoded byte array of the input resource string based on the theEncoding. + * + * @param theEncoding the theEncoding to used + * @param theEncodedResource the resource to encode + * @return byte array of the resource + */ + @Nonnull + static byte[] getResourceBinary(ResourceEncodingEnum theEncoding, String theEncodedResource) { + switch (theEncoding) { + case JSON: + return theEncodedResource.getBytes(StandardCharsets.UTF_8); + case JSONC: + return GZipUtil.compress(theEncodedResource); + default: + return new byte[0]; + } + } + + void populateEncodedResource( + EncodedResource theEncodedResource, + String theEncodedResourceString, + @Nullable byte[] theResourceBinary, + ResourceEncodingEnum theEncoding) { + if (myIsOracleDialect) { + populateEncodedResourceInner(theEncodedResource, null, theResourceBinary, theEncoding); + } else { + populateEncodedResourceInner(theEncodedResource, theEncodedResourceString, null, ResourceEncodingEnum.JSON); + } + } + + private void populateEncodedResourceInner( + EncodedResource encodedResource, + String encodedResourceString, + byte[] theResourceBinary, + ResourceEncodingEnum theEncoding) { + encodedResource.setResourceText(encodedResourceString); + encodedResource.setResourceBinary(theResourceBinary); + encodedResource.setEncoding(theEncoding); + } +} diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/ResourceHistoryState.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/ResourceHistoryState.java new file mode 100644 index 00000000000..6da5f9ab594 --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/ResourceHistoryState.java @@ -0,0 +1,86 @@ +package ca.uhn.fhir.jpa.dao; + +import ca.uhn.fhir.jpa.model.entity.ResourceEncodingEnum; +import com.google.common.hash.HashCode; +import jakarta.annotation.Nullable; +import org.hl7.fhir.instance.model.api.IBaseResource; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.StringJoiner; + +/** + * POJO to contain the results of {@link ResourceHistoryCalculator#calculateResourceHistoryState(IBaseResource, ResourceEncodingEnum, List)} + */ +public class ResourceHistoryState { + @Nullable + private final String myResourceText; + + @Nullable + private final byte[] myResourceBinary; + + private final ResourceEncodingEnum myEncoding; + private final HashCode myHashCode; + + public ResourceHistoryState( + @Nullable String theResourceText, + @Nullable byte[] theResourceBinary, + ResourceEncodingEnum theEncoding, + HashCode theHashCode) { + myResourceText = theResourceText; + myResourceBinary = theResourceBinary; + myEncoding = theEncoding; + myHashCode = theHashCode; + } + + @Nullable + public String getResourceText() { + return myResourceText; + } + + @Nullable + public byte[] getResourceBinary() { + return myResourceBinary; + } + + public ResourceEncodingEnum getEncoding() { + return myEncoding; + } + + public HashCode getHashCode() { + return myHashCode; + } + + @Override + public boolean equals(Object theO) { + if (this == theO) { + return true; + } + if (theO == null || getClass() != theO.getClass()) { + return false; + } + ResourceHistoryState that = (ResourceHistoryState) theO; + return Objects.equals(myResourceText, that.myResourceText) + && Arrays.equals(myResourceBinary, that.myResourceBinary) + && myEncoding == that.myEncoding + && Objects.equals(myHashCode, that.myHashCode); + } + + @Override + public int hashCode() { + int result = Objects.hash(myResourceText, myEncoding, myHashCode); + result = 31 * result + Arrays.hashCode(myResourceBinary); + return result; + } + + @Override + public String toString() { + return new StringJoiner(", ", ResourceHistoryState.class.getSimpleName() + "[", "]") + .add("myResourceText='" + myResourceText + "'") + .add("myResourceBinary=" + Arrays.toString(myResourceBinary)) + .add("myEncoding=" + myEncoding) + .add("myHashCode=" + myHashCode) + .toString(); + } +} diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/migrate/tasks/HapiFhirJpaMigrationTasks.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/migrate/tasks/HapiFhirJpaMigrationTasks.java index 636b436746e..340a6cbee2d 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/migrate/tasks/HapiFhirJpaMigrationTasks.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/migrate/tasks/HapiFhirJpaMigrationTasks.java @@ -627,6 +627,9 @@ public class HapiFhirJpaMigrationTasks extends BaseMigrationTasks { version.executeRawSqls("20230402.1", Map.of(DriverTypeEnum.POSTGRES_9_4, postgresTuningStatements)); // Use an unlimited length text column for RES_TEXT_VC + // N.B. This will FAIL SILENTLY on Oracle due to the fact that Oracle does not support an ALTER TABLE from + // VARCHAR to + // CLOB. Because of failureAllowed() this won't halt the migration version.onTable("HFJ_RES_VER") .modifyColumn("20230421.1", "RES_TEXT_VC") .nullable() diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/term/TermReadSvcImpl.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/term/TermReadSvcImpl.java index af712a2e197..4c3420ea4ea 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/term/TermReadSvcImpl.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/term/TermReadSvcImpl.java @@ -649,7 +649,13 @@ public class TermReadSvcImpl implements ITermReadSvc, IHasScheduledJobs { .getMessage(TermReadSvcImpl.class, "valueSetExpandedUsingPreExpansion", expansionTimestamp); theAccumulator.addMessage(msg); expandConcepts( - theExpansionOptions, theAccumulator, termValueSet, theFilter, theAdd, theAddedCodes, isOracleDialect()); + theExpansionOptions, + theAccumulator, + termValueSet, + theFilter, + theAdd, + theAddedCodes, + myHibernatePropertiesProvider.isOracleDialect()); } @Nonnull @@ -664,10 +670,6 @@ public class TermReadSvcImpl implements ITermReadSvc, IHasScheduledJobs { return expansionTimestamp; } - private boolean isOracleDialect() { - return myHibernatePropertiesProvider.getDialect() instanceof org.hibernate.dialect.OracleDialect; - } - private void expandConcepts( ValueSetExpansionOptions theExpansionOptions, IValueSetConceptAccumulator theAccumulator, diff --git a/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/dao/ResourceHistoryCalculatorTest.java b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/dao/ResourceHistoryCalculatorTest.java new file mode 100644 index 00000000000..d2123cacbb5 --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/dao/ResourceHistoryCalculatorTest.java @@ -0,0 +1,326 @@ +package ca.uhn.fhir.jpa.dao; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.jpa.model.entity.ResourceEncodingEnum; +import ca.uhn.fhir.jpa.model.entity.ResourceHistoryTable; +import ca.uhn.fhir.jpa.model.entity.ResourceTable; +import com.google.common.hash.HashCode; +import com.google.common.hash.HashFunction; +import com.google.common.hash.Hashing; +import org.hl7.fhir.dstu3.hapi.ctx.FhirDstu3; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.r4.hapi.ctx.FhirR4; +import org.hl7.fhir.r4.model.Patient; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.Month; +import java.time.ZoneId; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ResourceHistoryCalculatorTest { + private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(ResourceHistoryCalculatorTest.class); + + private static final FhirContext CONTEXT = FhirContext.forR4Cached(); + + private static final ResourceHistoryCalculator CALCULATOR_ORACLE = new ResourceHistoryCalculator(CONTEXT, true); + private static final ResourceHistoryCalculator CALCULATOR_NON_ORACLE = new ResourceHistoryCalculator(CONTEXT, false); + + private static final LocalDate TODAY = LocalDate.of(2024, Month.JANUARY, 25); + private static final String ENCODED_RESOURCE_1 = "1234"; + private static final String ENCODED_RESOURCE_2 = "abcd"; + private static final String RESOURCE_TEXT_VC = "resourceTextVc"; + private static final List EXCLUDED_ELEMENTS_1 = List.of("id"); + private static final List EXCLUDED_ELEMENTS_2 = List.of("resourceType", "birthDate"); + private static final HashFunction SHA_256 = Hashing.sha256(); + + private static Stream calculateResourceHistoryStateArguments() { + return Stream.of( + Arguments.of(FhirContext.forDstu3Cached(), true, ResourceEncodingEnum.JSONC, EXCLUDED_ELEMENTS_1), + Arguments.of(FhirContext.forDstu3Cached(), false, ResourceEncodingEnum.JSONC, EXCLUDED_ELEMENTS_2), + Arguments.of(FhirContext.forDstu3Cached(), true, ResourceEncodingEnum.DEL, EXCLUDED_ELEMENTS_2), + Arguments.of(FhirContext.forDstu3Cached(), false, ResourceEncodingEnum.DEL, EXCLUDED_ELEMENTS_1), + Arguments.of(FhirContext.forDstu3Cached(), true, ResourceEncodingEnum.ESR, EXCLUDED_ELEMENTS_1), + Arguments.of(FhirContext.forDstu3Cached(), false, ResourceEncodingEnum.ESR, EXCLUDED_ELEMENTS_2), + Arguments.of(FhirContext.forDstu3Cached(), true, ResourceEncodingEnum.JSON, EXCLUDED_ELEMENTS_2), + Arguments.of(FhirContext.forDstu3Cached(), false, ResourceEncodingEnum.JSON, EXCLUDED_ELEMENTS_1), + Arguments.of(FhirContext.forR4Cached(), true, ResourceEncodingEnum.JSONC, EXCLUDED_ELEMENTS_1), + Arguments.of(FhirContext.forR4Cached(), false, ResourceEncodingEnum.JSONC, EXCLUDED_ELEMENTS_2), + Arguments.of(FhirContext.forR4Cached(), true, ResourceEncodingEnum.DEL, EXCLUDED_ELEMENTS_2), + Arguments.of(FhirContext.forR4Cached(), false, ResourceEncodingEnum.DEL, EXCLUDED_ELEMENTS_1), + Arguments.of(FhirContext.forR4Cached(), true, ResourceEncodingEnum.ESR, EXCLUDED_ELEMENTS_1), + Arguments.of(FhirContext.forR4Cached(), false, ResourceEncodingEnum.ESR, EXCLUDED_ELEMENTS_2), + Arguments.of(FhirContext.forR4Cached(), true, ResourceEncodingEnum.JSON, EXCLUDED_ELEMENTS_2), + Arguments.of(FhirContext.forR4Cached(), false, ResourceEncodingEnum.JSON, EXCLUDED_ELEMENTS_1) + ); + } + + /** + * The purpose of this test is to ensure that the conditional logic to pre-calculate resource history text or binaries + * is respected. + * If this is for Oracle, the resource text will be driven off a binary with a given encoding with the + * resource text effectively ignored. + * If this is not Oracle, it will be driven off a JSON encoded text field with + * the binary effectively ignored. + */ + @ParameterizedTest + @MethodSource("calculateResourceHistoryStateArguments") + void calculateResourceHistoryState(FhirContext theFhirContext, boolean theIsOracle, ResourceEncodingEnum theResourceEncoding, List theExcludedElements) { + final IBaseResource patient = getPatient(theFhirContext); + + final ResourceHistoryCalculator calculator = getCalculator(theFhirContext, theIsOracle); + final ResourceHistoryState result = calculator.calculateResourceHistoryState(patient, theResourceEncoding, theExcludedElements); + + if (theIsOracle) { + assertNotNull(result.getResourceBinary()); // On Oracle: We use the resource binary to serve up the resource content + assertNull(result.getResourceText()); // On Oracle: We do NOT use the resource text to serve up the resource content + assertEquals(theResourceEncoding, result.getEncoding()); // On Oracle, the resource encoding is what we used to encode the binary + assertEquals(SHA_256.hashBytes(result.getResourceBinary()), result.getHashCode()); // On Oracle, the SHA 256 hash is of the binary + } else { + assertNull(result.getResourceBinary()); // Non-Oracle: We do NOT use the resource binary to serve up the resource content + assertNotNull(result.getResourceText()); // Non-Oracle: We use the resource text to serve up the resource content + assertEquals(ResourceEncodingEnum.JSON, result.getEncoding()); // Non-Oracle, since we didn't encode a binary this is always JSON. + final HashCode expectedHashCode = SHA_256.hashUnencodedChars(calculator.encodeResource(patient, theResourceEncoding, theExcludedElements)); // Non-Oracle, the SHA 256 hash is of the parsed resource object + assertEquals(expectedHashCode, result.getHashCode()); + } + } + + + private static Stream conditionallyAlterHistoryEntityArguments() { + return Stream.of( + Arguments.of(true, ResourceEncodingEnum.JSONC, ENCODED_RESOURCE_1), + Arguments.of(true, ResourceEncodingEnum.JSONC, ENCODED_RESOURCE_2), + Arguments.of(true, ResourceEncodingEnum.DEL, ENCODED_RESOURCE_1), + Arguments.of(true, ResourceEncodingEnum.DEL, ENCODED_RESOURCE_2), + Arguments.of(true, ResourceEncodingEnum.ESR, ENCODED_RESOURCE_1), + Arguments.of(true, ResourceEncodingEnum.ESR, ENCODED_RESOURCE_2), + Arguments.of(true, ResourceEncodingEnum.JSON, ENCODED_RESOURCE_1), + Arguments.of(true, ResourceEncodingEnum.JSON, ENCODED_RESOURCE_2), + Arguments.of(false, ResourceEncodingEnum.JSONC, ENCODED_RESOURCE_1), + Arguments.of(false, ResourceEncodingEnum.JSONC, ENCODED_RESOURCE_2), + Arguments.of(false, ResourceEncodingEnum.DEL, ENCODED_RESOURCE_1), + Arguments.of(false, ResourceEncodingEnum.DEL, ENCODED_RESOURCE_2), + Arguments.of(false, ResourceEncodingEnum.ESR, ENCODED_RESOURCE_1), + Arguments.of(false, ResourceEncodingEnum.ESR, ENCODED_RESOURCE_2), + Arguments.of(false, ResourceEncodingEnum.JSON, ENCODED_RESOURCE_1), + Arguments.of(false, ResourceEncodingEnum.JSON, ENCODED_RESOURCE_2) + ); + } + + @ParameterizedTest + @MethodSource("conditionallyAlterHistoryEntityArguments") + void conditionallyAlterHistoryEntity_usesVarcharForOracle(boolean theIsOracle, ResourceEncodingEnum theResourceEncoding, String theResourceText) { + final ResourceTable resourceTable = new ResourceTable(); + resourceTable.setId(123L); + + final ResourceHistoryTable resourceHistoryTable = new ResourceHistoryTable(); + resourceHistoryTable.setVersion(1); + resourceHistoryTable.setResource("resource".getBytes(StandardCharsets.UTF_8)); + resourceHistoryTable.setEncoding(theResourceEncoding); + resourceHistoryTable.setResourceTextVc(RESOURCE_TEXT_VC); + + final boolean isChanged = + getCalculator(theIsOracle).conditionallyAlterHistoryEntity(resourceTable, resourceHistoryTable, theResourceText); + + if (theIsOracle) { + assertFalse(isChanged); + assertNotNull(resourceHistoryTable.getResource()); + assertEquals(RESOURCE_TEXT_VC, resourceHistoryTable.getResourceTextVc()); + assertEquals(resourceHistoryTable.getEncoding(), resourceHistoryTable.getEncoding()); + } else { + assertTrue(isChanged); + assertNull(resourceHistoryTable.getResource()); + assertEquals(theResourceText, resourceHistoryTable.getResourceTextVc()); + assertEquals(resourceHistoryTable.getEncoding(), ResourceEncodingEnum.JSON); + } + } + + private static Stream encodeResourceArguments() { + return Stream.of( + Arguments.of(FhirContext.forDstu3Cached(), ResourceEncodingEnum.JSONC, EXCLUDED_ELEMENTS_1), + Arguments.of(FhirContext.forDstu3Cached(), ResourceEncodingEnum.JSONC, EXCLUDED_ELEMENTS_2), + Arguments.of(FhirContext.forDstu3Cached(), ResourceEncodingEnum.DEL, EXCLUDED_ELEMENTS_1), + Arguments.of(FhirContext.forDstu3Cached(), ResourceEncodingEnum.DEL, EXCLUDED_ELEMENTS_2), + Arguments.of(FhirContext.forDstu3Cached(), ResourceEncodingEnum.ESR, EXCLUDED_ELEMENTS_1), + Arguments.of(FhirContext.forDstu3Cached(), ResourceEncodingEnum.ESR, EXCLUDED_ELEMENTS_2), + Arguments.of(FhirContext.forDstu3Cached(), ResourceEncodingEnum.JSON, EXCLUDED_ELEMENTS_1), + Arguments.of(FhirContext.forDstu3Cached(), ResourceEncodingEnum.JSON, EXCLUDED_ELEMENTS_2), + Arguments.of(FhirContext.forR4Cached(), ResourceEncodingEnum.JSONC, EXCLUDED_ELEMENTS_1), + Arguments.of(FhirContext.forR4Cached(), ResourceEncodingEnum.JSONC, EXCLUDED_ELEMENTS_2), + Arguments.of(FhirContext.forR4Cached(), ResourceEncodingEnum.DEL, EXCLUDED_ELEMENTS_1), + Arguments.of(FhirContext.forR4Cached(), ResourceEncodingEnum.DEL, EXCLUDED_ELEMENTS_2), + Arguments.of(FhirContext.forR4Cached(), ResourceEncodingEnum.ESR, EXCLUDED_ELEMENTS_1), + Arguments.of(FhirContext.forR4Cached(), ResourceEncodingEnum.ESR, EXCLUDED_ELEMENTS_2), + Arguments.of(FhirContext.forR4Cached(), ResourceEncodingEnum.JSON, EXCLUDED_ELEMENTS_1), + Arguments.of(FhirContext.forR4Cached(), ResourceEncodingEnum.JSON, EXCLUDED_ELEMENTS_2) + ); + } + + @ParameterizedTest + @MethodSource("encodeResourceArguments") + void encodeResource_ensureFhirVersionSpecificAndIntendedElementsExcluded(FhirContext theFhirContext, ResourceEncodingEnum theResourceEncoding, List theExcludedElements) { + final IBaseResource patient = getPatient(theFhirContext); + final String encodedResource = getCalculator(theFhirContext, true).encodeResource(patient, theResourceEncoding, theExcludedElements); + + final String expectedEncoding = + theResourceEncoding.newParser(theFhirContext).setDontEncodeElements(theExcludedElements).encodeResourceToString(patient); + + assertEquals(expectedEncoding, encodedResource); + } + + private static Stream getResourceBinaryArguments() { + return Stream.of( + Arguments.of(ResourceEncodingEnum.JSONC, ENCODED_RESOURCE_1), + Arguments.of(ResourceEncodingEnum.JSONC, ENCODED_RESOURCE_2), + Arguments.of(ResourceEncodingEnum.DEL, ENCODED_RESOURCE_1), + Arguments.of(ResourceEncodingEnum.DEL, ENCODED_RESOURCE_2), + Arguments.of(ResourceEncodingEnum.ESR, ENCODED_RESOURCE_1), + Arguments.of(ResourceEncodingEnum.ESR, ENCODED_RESOURCE_2), + Arguments.of(ResourceEncodingEnum.JSON, ENCODED_RESOURCE_1), + Arguments.of(ResourceEncodingEnum.JSON, ENCODED_RESOURCE_2) + ); + } + + @ParameterizedTest + @MethodSource("getResourceBinaryArguments") + void getResourceBinary(ResourceEncodingEnum theResourceEncoding, String theEncodedResource) { + final byte[] resourceBinary = ResourceHistoryCalculator.getResourceBinary(theResourceEncoding, theEncodedResource); + + switch (theResourceEncoding) { + case JSON: + assertArrayEquals(theEncodedResource.getBytes(StandardCharsets.UTF_8), resourceBinary); + break; + case JSONC: + assertArrayEquals(GZipUtil.compress(theEncodedResource), resourceBinary); + break; + case DEL : + case ESR : + default: + assertArrayEquals(new byte[0], resourceBinary); + } + + ourLog.info("resourceBinary: {}", resourceBinary); + } + + private static Stream isResourceHistoryChangedArguments() { + return Stream.of( + Arguments.of(true, ENCODED_RESOURCE_1.getBytes(StandardCharsets.UTF_8), ENCODED_RESOURCE_1), + Arguments.of(false, ENCODED_RESOURCE_1.getBytes(StandardCharsets.UTF_8), ENCODED_RESOURCE_1), + Arguments.of(true, ENCODED_RESOURCE_2.getBytes(StandardCharsets.UTF_8), ENCODED_RESOURCE_2), + Arguments.of(false, ENCODED_RESOURCE_2.getBytes(StandardCharsets.UTF_8), ENCODED_RESOURCE_2) + ); + } + + @ParameterizedTest + @MethodSource("isResourceHistoryChangedArguments") + void isResourceHistoryChanged(boolean theIsOracle, byte[] theNewBinary, String theNewResourceText) { + final String existngResourceText = ENCODED_RESOURCE_1; + final byte[] existingBytes = existngResourceText.getBytes(StandardCharsets.UTF_8); + + final ResourceHistoryTable resourceHistoryTable = new ResourceHistoryTable(); + resourceHistoryTable.setResource(existingBytes); + resourceHistoryTable.setResourceTextVc(existngResourceText); + + final boolean isChanged = getCalculator(theIsOracle).isResourceHistoryChanged(resourceHistoryTable, theNewBinary, theNewResourceText); + + if (theIsOracle) { + final boolean expectedResult = !Arrays.equals(existingBytes, theNewBinary); + assertEquals(expectedResult, isChanged); + } else { + final boolean expectedResult = ! existngResourceText.equals(theNewResourceText); + assertEquals(expectedResult, isChanged); + } + } + + private static Stream populateEncodedResourceArguments() { + return Stream.of( + Arguments.of(true, ResourceEncodingEnum.JSONC, ENCODED_RESOURCE_1), + Arguments.of(false, ResourceEncodingEnum.JSONC, ENCODED_RESOURCE_2), + Arguments.of(true, ResourceEncodingEnum.DEL, ENCODED_RESOURCE_2), + Arguments.of(false, ResourceEncodingEnum.DEL, ENCODED_RESOURCE_1), + Arguments.of(true, ResourceEncodingEnum.ESR, ENCODED_RESOURCE_1), + Arguments.of(false, ResourceEncodingEnum.ESR, ENCODED_RESOURCE_2), + Arguments.of(true, ResourceEncodingEnum.JSON, ENCODED_RESOURCE_2), + Arguments.of(false, ResourceEncodingEnum.JSON, ENCODED_RESOURCE_1), + Arguments.of(true, ResourceEncodingEnum.JSONC, ENCODED_RESOURCE_1), + Arguments.of(false, ResourceEncodingEnum.JSONC, ENCODED_RESOURCE_2), + Arguments.of(true, ResourceEncodingEnum.DEL, ENCODED_RESOURCE_2), + Arguments.of(false, ResourceEncodingEnum.DEL, ENCODED_RESOURCE_1), + Arguments.of(true, ResourceEncodingEnum.ESR, ENCODED_RESOURCE_1), + Arguments.of(false, ResourceEncodingEnum.ESR, ENCODED_RESOURCE_2), + Arguments.of(true, ResourceEncodingEnum.JSON, ENCODED_RESOURCE_2), + Arguments.of(false, ResourceEncodingEnum.JSON, ENCODED_RESOURCE_1) + ); + } + + @ParameterizedTest + @MethodSource("populateEncodedResourceArguments") + void populateEncodedResource(boolean theIsOracle, ResourceEncodingEnum theResourceEncoding, String theEncodedResourceString) { + final EncodedResource encodedResource = new EncodedResource(); + final byte[] resourceBinary = theEncodedResourceString.getBytes(StandardCharsets.UTF_8); + + getCalculator(theIsOracle) + .populateEncodedResource(encodedResource, theEncodedResourceString, resourceBinary, theResourceEncoding); + + if (theIsOracle) { + assertEquals(resourceBinary, encodedResource.getResourceBinary()); + assertNull(encodedResource.getResourceText()); + assertEquals(theResourceEncoding, encodedResource.getEncoding()); + } else { + assertNull(encodedResource.getResourceBinary()); + assertEquals(theEncodedResourceString, encodedResource.getResourceText()); + assertEquals(ResourceEncodingEnum.JSON, encodedResource.getEncoding()); + } + } + + private ResourceHistoryCalculator getCalculator(boolean theIsOracle) { + return theIsOracle ? CALCULATOR_ORACLE : CALCULATOR_NON_ORACLE; + } + + private ResourceHistoryCalculator getCalculator(FhirContext theFhirContext, boolean theIsOracle) { + return new ResourceHistoryCalculator(theFhirContext, theIsOracle); + } + + private IBaseResource getPatient(FhirContext theFhirContext) { + if (theFhirContext.getVersion() instanceof FhirR4) { + return getPatientR4(); + } + + if (theFhirContext.getVersion() instanceof FhirDstu3) { + return getPatientDstu3(); + } + + return null; + } + + private org.hl7.fhir.dstu3.model.Patient getPatientDstu3() { + final org.hl7.fhir.dstu3.model.Patient patient = new org.hl7.fhir.dstu3.model.Patient(); + + patient.setId("123"); + patient.setBirthDate(Date.from(TODAY.atStartOfDay(ZoneId.of("America/Toronto")).toInstant())); + + return patient; + } + + private Patient getPatientR4() { + final Patient patient = new Patient(); + + patient.setId("123"); + patient.setBirthDate(Date.from(TODAY.atStartOfDay(ZoneId.of("America/Toronto")).toInstant())); + + return patient; + } +} diff --git a/hapi-fhir-jpaserver-model/src/main/java/ca/uhn/fhir/jpa/model/entity/ResourceHistoryTable.java b/hapi-fhir-jpaserver-model/src/main/java/ca/uhn/fhir/jpa/model/entity/ResourceHistoryTable.java index d9199787b72..49ad436112c 100644 --- a/hapi-fhir-jpaserver-model/src/main/java/ca/uhn/fhir/jpa/model/entity/ResourceHistoryTable.java +++ b/hapi-fhir-jpaserver-model/src/main/java/ca/uhn/fhir/jpa/model/entity/ResourceHistoryTable.java @@ -32,8 +32,6 @@ import java.io.Serializable; import java.util.ArrayList; import java.util.Collection; -import static org.apache.commons.lang3.StringUtils.defaultString; - @Entity @Table( name = ResourceHistoryTable.HFJ_RES_VER, @@ -86,15 +84,12 @@ public class ResourceHistoryTable extends BaseHasResource implements Serializabl @OneToMany(mappedBy = "myResourceHistory", cascade = CascadeType.ALL, fetch = FetchType.LAZY, orphanRemoval = true) private Collection myTags; - /** - * Note: No setter for this field because it's only a legacy way of storing data now. - */ @Column(name = "RES_TEXT", length = Integer.MAX_VALUE - 1, nullable = true) @Lob() @OptimisticLock(excluded = true) private byte[] myResource; - @Column(name = "RES_TEXT_VC", nullable = true, length = Length.LONG32) + @Column(name = "RES_TEXT_VC", length = Length.LONG32, nullable = true) @OptimisticLock(excluded = true) private String myResourceTextVc; @@ -155,8 +150,7 @@ public class ResourceHistoryTable extends BaseHasResource implements Serializabl } public void setResourceTextVc(String theResourceTextVc) { - myResource = null; - myResourceTextVc = defaultString(theResourceTextVc); + myResourceTextVc = theResourceTextVc; } public ResourceHistoryProvenanceEntity getProvenance() { @@ -212,6 +206,10 @@ public class ResourceHistoryTable extends BaseHasResource implements Serializabl return myResource; } + public void setResource(byte[] theResource) { + myResource = theResource; + } + @Override public Long getResourceId() { return myResourceId; diff --git a/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/stresstest/GiantTransactionPerfTest.java b/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/stresstest/GiantTransactionPerfTest.java index ec9d5d8a4d5..dcce168fa09 100644 --- a/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/stresstest/GiantTransactionPerfTest.java +++ b/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/stresstest/GiantTransactionPerfTest.java @@ -17,8 +17,10 @@ import ca.uhn.fhir.jpa.cache.ResourceChangeListenerCacheRefresherImpl; import ca.uhn.fhir.jpa.cache.ResourceChangeListenerRegistryImpl; import ca.uhn.fhir.jpa.cache.ResourcePersistentIdMap; import ca.uhn.fhir.jpa.cache.ResourceVersionMap; +import ca.uhn.fhir.jpa.config.HibernatePropertiesProvider; import ca.uhn.fhir.jpa.dao.IJpaStorageResourceParser; import ca.uhn.fhir.jpa.dao.JpaResourceDao; +import ca.uhn.fhir.jpa.dao.ResourceHistoryCalculator; import ca.uhn.fhir.jpa.dao.TransactionProcessor; import ca.uhn.fhir.jpa.dao.data.IResourceHistoryTableDao; import ca.uhn.fhir.jpa.dao.index.DaoSearchParamSynchronizer; @@ -148,6 +150,7 @@ public class GiantTransactionPerfTest { private IIdHelperService myIdHelperService; @Mock private IJpaStorageResourceParser myJpaStorageResourceParser; + private final ResourceHistoryCalculator myResourceHistoryCalculator = new ResourceHistoryCalculator(FhirContext.forR4Cached(), false); private IMetaTagSorter myMetaTagSorter; @AfterEach @@ -271,6 +274,7 @@ public class GiantTransactionPerfTest { myEobDao.setJpaStorageResourceParserForUnitTest(myJpaStorageResourceParser); myEobDao.setExternallyStoredResourceServiceRegistryForUnitTest(new ExternallyStoredResourceServiceRegistry()); myEobDao.setMyMetaTagSorter(myMetaTagSorter); + myEobDao.setResourceHistoryCalculator(myResourceHistoryCalculator); myEobDao.start(); myDaoRegistry.setResourceDaos(Lists.newArrayList(myEobDao));