Bring support of conditional updates into alignment with the R4 spec (#4602)

* first draft changes

* small fixes

* rename config

* allow placeholders

* fix tests

* fix concurrency test (messy fix)

* add temp comment

* test pipeline

* add placeholder check

* remove configurability

* cleanup comment

* code review suggestions

* condition changes for test

* extract conditional expression into method

* fix conditional expression
This commit is contained in:
samguntersmilecdr 2023-03-15 18:35:25 -04:00 committed by GitHub
parent 733259a13b
commit 32d224b7bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 261 additions and 13 deletions

View File

@ -81,6 +81,7 @@ ca.uhn.fhir.jpa.dao.BaseStorageDao.invalidMatchUrlNoMatches=Invalid match URL "{
ca.uhn.fhir.jpa.dao.BaseStorageDao.invalidMatchUrlMultipleMatches=Invalid match URL "{0}" - Multiple resources match this search
ca.uhn.fhir.jpa.dao.BaseStorageDao.inlineMatchNotSupported=Inline match URLs are not supported on this server. Cannot process reference: "{0}"
ca.uhn.fhir.jpa.dao.BaseStorageDao.transactionOperationWithMultipleMatchFailure=Failed to {0} resource with match URL "{1}" because this search matched {2} resources
ca.uhn.fhir.jpa.dao.BaseStorageDao.transactionOperationWithIdNotMatchFailure=Failed to {0} resource with match URL "{1}" because the matching resource does not match the provided ID
ca.uhn.fhir.jpa.dao.BaseHapiFhirDao.transactionOperationFailedNoId=Failed to {0} resource in transaction because no ID was provided
ca.uhn.fhir.jpa.dao.BaseHapiFhirDao.transactionOperationFailedUnknownId=Failed to {0} resource in transaction because no resource could be found with ID {1}
ca.uhn.fhir.jpa.dao.BaseHapiFhirDao.uniqueIndexConflictFailure=Can not create resource of type {0} as it would create a duplicate unique index matching query: {1} (existing index belongs to {2}, new unique index created by {3})

View File

@ -0,0 +1,5 @@
---
type: fix
issue: 3231
title: "Previously, conditional updates always followed the STU3 specifications.
Now, conditional updates in an R4 server follow the R4 specifications."

View File

@ -147,6 +147,7 @@ import java.util.Date;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
@ -1720,6 +1721,12 @@ public abstract class BaseHapiFhirResourceDao<T extends IBaseResource> extends B
JpaPid pid = match.iterator().next();
entity = myEntityManager.find(ResourceTable.class, pid.getId());
resourceId = entity.getIdDt();
if (myFhirContext.getVersion().getVersion().isEqualOrNewerThan(FhirVersionEnum.R4) && resource.getIdElement().getIdPart() != null) {
if (!Objects.equals(resource.getIdElement().getIdPart(), resourceId.getIdPart())) {
String msg = getContext().getLocalizer().getMessageSanitized(BaseStorageDao.class, "transactionOperationWithIdNotMatchFailure", "UPDATE", theMatchUrl);
throw new InvalidRequestException(Msg.code(2279) + msg);
}
}
} else {
DaoMethodOutcome outcome = doCreateForPostOrPut(theRequest, resource, theMatchUrl, false, thePerformIndexing, theRequestPartitionId, update, theTransactionDetails);

View File

@ -1114,7 +1114,7 @@ public class StorageSettings {
}
/**
* Should indexing and searching on contained resources be enabled on this server.
* Should recursive indexing and searching on contained resources be enabled on this server.
* This may have performance impacts, and should be enabled only if it is needed. Default is <code>false</code>.
*
* @since 5.6.0

View File

@ -547,12 +547,10 @@ public class FhirResourceDaoR4UpdateTest extends BaseJpaR4Test {
p = new Patient();
p.addIdentifier().setSystem("urn:system").setValue(methodName);
p.addName().setFamily("Hello");
p.setId("Patient/" + methodName);
myPatientDao.update(p, "Patient?identifier=urn%3Asystem%7C" + methodName, mySrd);
p = myPatientDao.read(id.toVersionless(), mySrd);
assertThat(p.getIdElement().toVersionless().toString(), not(containsString("test")));
assertEquals(id.toVersionless(), p.getIdElement().toVersionless());
assertNotEquals(id, p.getIdElement());
assertThat(p.getIdElement().toString(), endsWith("/_history/2"));
@ -581,20 +579,52 @@ public class FhirResourceDaoR4UpdateTest extends BaseJpaR4Test {
p = new Patient();
p.addIdentifier().setSystem("urn:system").setValue(methodName);
p.addName().setFamily("Hello");
p.setId("Patient/" + methodName);
String matchUrl = "Patient?_lastUpdated=gt" + start.getValueAsString();
ourLog.info("URL is: {}", matchUrl);
myPatientDao.update(p, matchUrl, mySrd);
p = myPatientDao.read(id.toVersionless(), mySrd);
assertThat(p.getIdElement().toVersionless().toString(), not(containsString("test")));
assertEquals(id.toVersionless(), p.getIdElement().toVersionless());
assertNotEquals(id, p.getIdElement());
assertThat(p.getIdElement().toString(), endsWith("/_history/2"));
}
@Test
public void testUpdateConditionalByLastUpdatedWithId() throws Exception {
String methodName = "testUpdateByUrl";
Patient p = new Patient();
p.addIdentifier().setSystem("urn:system").setValue(methodName + "2");
myPatientDao.create(p, mySrd).getId();
InstantDt start = InstantDt.withCurrentTime();
ourLog.info("First time: {}", start.getValueAsString());
Thread.sleep(100);
p = new Patient();
p.addIdentifier().setSystem("urn:system").setValue(methodName);
IIdType id = myPatientDao.create(p, mySrd).getId();
ourLog.info("Created patient, got ID: {}", id);
Thread.sleep(100);
p = new Patient();
p.addIdentifier().setSystem("urn:system").setValue(methodName);
p.addName().setFamily("Hello");
p.setId("Patient/" + methodName);
String matchUrl = "Patient?_lastUpdated=gt" + start.getValueAsString();
ourLog.info("URL is: {}", matchUrl);
try {
myPatientDao.update(p, matchUrl, mySrd);
} catch (InvalidRequestException e) {
assertThat(e.getMessage(), containsString("2279"));
}
}
@Test
public void testUpdateResourceCreatedWithConditionalUrl_willRemoveEntryInSearchUrlTable(){
String identifierCode = "20210427133226.4440+800";
@ -648,12 +678,10 @@ public class FhirResourceDaoR4UpdateTest extends BaseJpaR4Test {
p = new Patient();
p.addIdentifier().setSystem("urn:system").setValue(methodName);
p.addName().setFamily("Hello");
p.setId("Patient/" + methodName);
myPatientDao.update(p, "Patient?_lastUpdated=gt" + start.getValueAsString(), mySrd);
p = myPatientDao.read(id.toVersionless(), mySrd);
assertThat(p.getIdElement().toVersionless().toString(), not(containsString("test")));
assertEquals(id.toVersionless(), p.getIdElement().toVersionless());
assertNotEquals(id, p.getIdElement());
assertThat(p.getIdElement().toString(), endsWith("/_history/2"));

View File

@ -3,6 +3,7 @@ package ca.uhn.fhir.jpa.dao.r4;
import ca.uhn.fhir.i18n.Msg;
import ca.uhn.fhir.interceptor.api.Pointcut;
import ca.uhn.fhir.jpa.api.config.JpaStorageSettings;
import ca.uhn.fhir.jpa.api.model.DaoMethodOutcome;
import ca.uhn.fhir.jpa.dao.BaseHapiFhirDao;
import ca.uhn.fhir.jpa.model.entity.NormalizedQuantitySearchLevel;
import ca.uhn.fhir.jpa.model.entity.ResourceEncodingEnum;
@ -19,6 +20,7 @@ import ca.uhn.fhir.rest.api.Constants;
import ca.uhn.fhir.rest.api.RestOperationTypeEnum;
import ca.uhn.fhir.rest.api.server.IBundleProvider;
import ca.uhn.fhir.rest.api.server.RequestDetails;
import ca.uhn.fhir.rest.api.server.SystemRequestDetails;
import ca.uhn.fhir.rest.param.ReferenceParam;
import ca.uhn.fhir.rest.param.StringParam;
import ca.uhn.fhir.rest.param.TokenParam;
@ -1711,6 +1713,82 @@ public class FhirSystemDaoR4Test extends BaseJpaR4SystemTest {
assertThat(responseTypes.toString(), responseTypes, contains("Practitioner", "Observation", "Observation"));
}
@Test
public void testTransactionWithConditionalUpdatesDoesExist() {
Practitioner newP = new Practitioner();
newP.addIdentifier().setSystem("http://foo").setValue("bar");
DaoMethodOutcome daoMethodOutcome = myPractitionerDao.create(newP, new SystemRequestDetails());
IIdType id = daoMethodOutcome.getId();
Bundle request = new Bundle();
request.setType(BundleType.TRANSACTION);
Practitioner p = new Practitioner();
p.setId(IdType.newRandomUuid());
p.addIdentifier().setSystem("http://foo").setValue("bar");
request.addEntry()
.setFullUrl(p.getId())
.setResource(p)
.getRequest()
.setMethod(HTTPVerb.PUT)
.setUrl("Practitioner?identifier=http://foo|bar");
Observation o = new Observation();
o.setId(IdType.newRandomUuid());
o.getPerformerFirstRep().setReference(p.getId());
request.addEntry()
.setFullUrl(o.getId())
.setResource(o)
.getRequest()
.setMethod(HTTPVerb.POST)
.setUrl("Observation/");
Bundle response = mySystemDao.transaction(null, request);
List<String> responseTypes = response
.getEntry()
.stream()
.map(t -> new IdType(t.getResponse().getLocation()).getResourceType())
.collect(Collectors.toList());
assertThat(responseTypes.toString(), responseTypes, contains("Practitioner", "Observation"));
}
@Test
public void testTransactionWithConditionalUpdatesDoesNotExist() {
Bundle request = new Bundle();
request.setType(BundleType.TRANSACTION);
Practitioner p = new Practitioner();
p.setId(IdType.newRandomUuid());
p.addIdentifier().setSystem("http://foo").setValue("bar");
request.addEntry()
.setFullUrl(p.getId())
.setResource(p)
.getRequest()
.setMethod(HTTPVerb.PUT)
.setUrl("Practitioner?identifier=http://foo|bar");
Observation o = new Observation();
o.setId(IdType.newRandomUuid());
o.getPerformerFirstRep().setReference(p.getId());
request.addEntry()
.setFullUrl(o.getId())
.setResource(o)
.getRequest()
.setMethod(HTTPVerb.POST)
.setUrl("Observation/");
Bundle response = mySystemDao.transaction(null, request);
List<String> responseTypes = response
.getEntry()
.stream()
.map(t -> new IdType(t.getResponse().getLocation()).getResourceType())
.collect(Collectors.toList());
assertThat(responseTypes.toString(), responseTypes, contains("Practitioner", "Observation"));
}
@Test
public void testTransactionWithDuplicateConditionalUpdates() {
Bundle request = new Bundle();
@ -3320,8 +3398,8 @@ public class FhirSystemDaoR4Test extends BaseJpaR4SystemTest {
}
@Test
public void testTransactionUpdateMatchUrlWithOneMatch() {
String methodName = "testTransactionUpdateMatchUrlWithOneMatch";
public void testTransactionUpdateMatchUrlWithOneMatchNoId() {
String methodName = "testTransactionUpdateMatchUrlWithOneMatchNoId";
Bundle request = new Bundle();
Patient p = new Patient();
@ -3332,12 +3410,12 @@ public class FhirSystemDaoR4Test extends BaseJpaR4SystemTest {
p = new Patient();
p.addIdentifier().setSystem("urn:system").setValue(methodName);
p.addName().setFamily("Hello");
p.setId("Patient/" + methodName);
p.setId(IdType.newRandomUuid());
request.addEntry().setResource(p).getRequest().setMethod(HTTPVerb.PUT).setUrl("Patient?identifier=urn%3Asystem%7C" + methodName);
Observation o = new Observation();
o.getCode().setText("Some Observation");
o.getSubject().setReference("Patient/" + methodName);
o.getSubject().setReference(id.getValue());
request.addEntry().setResource(o).getRequest().setMethod(HTTPVerb.POST);
Bundle resp = mySystemDao.transaction(mySrd, request);
@ -3360,6 +3438,72 @@ public class FhirSystemDaoR4Test extends BaseJpaR4SystemTest {
}
@Test
public void testTransactionUpdateMatchUrlWithOneMatchWithIdMatch() {
String methodName = "testTransactionUpdateMatchUrlWithOneMatchWithIdMatch";
Bundle request = new Bundle();
Patient p = new Patient();
p.addIdentifier().setSystem("urn:system").setValue(methodName);
IIdType id = myPatientDao.create(p, mySrd).getId();
ourLog.info("Created patient, got it: {}", id);
p = new Patient();
p.addIdentifier().setSystem("urn:system").setValue(methodName);
p.addName().setFamily("Hello");
p.setId(id);
request.addEntry().setResource(p).getRequest().setMethod(HTTPVerb.PUT).setUrl("Patient?identifier=urn%3Asystem%7C" + methodName);
Observation o = new Observation();
o.getCode().setText("Some Observation");
o.getSubject().setReference(id.getValue());
request.addEntry().setResource(o).getRequest().setMethod(HTTPVerb.POST);
Bundle resp = mySystemDao.transaction(mySrd, request);
assertEquals(2, resp.getEntry().size());
BundleEntryComponent nextEntry = resp.getEntry().get(0);
assertEquals("200 OK", nextEntry.getResponse().getStatus());
assertThat(nextEntry.getResponse().getLocation(), not(containsString("test")));
assertEquals(id.toVersionless(), p.getIdElement().toVersionless());
assertNotEquals(id, p.getId());
assertThat(p.getId(), endsWith("/_history/2"));
nextEntry = resp.getEntry().get(0);
assertEquals(Constants.STATUS_HTTP_200_OK + " OK", nextEntry.getResponse().getStatus());
assertThat(nextEntry.getResponse().getLocation(), not(emptyString()));
nextEntry = resp.getEntry().get(1);
o = myObservationDao.read(new IdType(nextEntry.getResponse().getLocation()), mySrd);
assertEquals(id.toVersionless().getValue(), o.getSubject().getReference());
}
@Test
public void testTransactionUpdateMatchUrlWithOneMatchWithNoIdMatch() {
String methodName = "testTransactionUpdateMatchUrlWithOneMatchWithNoIdMatch";
Bundle request = new Bundle();
Patient p = new Patient();
p.addIdentifier().setSystem("urn:system").setValue(methodName);
IIdType id = myPatientDao.create(p, mySrd).getId();
ourLog.info("Created patient, got it: {}", id);
p = new Patient();
p.addIdentifier().setSystem("urn:system").setValue(methodName);
p.addName().setFamily("Hello");
p.setId("Patient/" + methodName);
request.addEntry().setResource(p).getRequest().setMethod(HTTPVerb.PUT).setUrl("Patient?identifier=urn%3Asystem%7C" + methodName);
try {
mySystemDao.transaction(mySrd, request);
fail();
} catch (InvalidRequestException e) {
assertThat(e.getMessage(), containsString("2279"));
}
}
@Test
public void testTransactionUpdateMatchUrlWithTwoMatch() {
String methodName = "testTransactionUpdateMatchUrlWithTwoMatch";
@ -3399,6 +3543,37 @@ public class FhirSystemDaoR4Test extends BaseJpaR4SystemTest {
String methodName = "testTransactionUpdateMatchUrlWithZeroMatch";
Bundle request = new Bundle();
Patient p = new Patient();
p.addIdentifier().setSystem("urn:system").setValue(methodName);
p.addName().setFamily("Hello");
p.setId(IdType.newRandomUuid());
request.addEntry().setResource(p).getRequest().setMethod(HTTPVerb.PUT).setUrl("Patient?identifier=urn%3Asystem%7C" + methodName);
Observation o = new Observation();
o.getCode().setText("Some Observation");
o.getSubject().setReference("Patient/" + p.getId());
request.addEntry().setResource(o).getRequest().setMethod(HTTPVerb.POST);
Bundle resp = mySystemDao.transaction(mySrd, request);
assertEquals(2, resp.getEntry().size());
BundleEntryComponent nextEntry = resp.getEntry().get(0);
assertEquals(Constants.STATUS_HTTP_201_CREATED + " Created", nextEntry.getResponse().getStatus());
IdType patientId = new IdType(nextEntry.getResponse().getLocation());
assertThat(patientId.getValue(), endsWith("/_history/1"));
nextEntry = resp.getEntry().get(1);
o = myObservationDao.read(new IdType(nextEntry.getResponse().getLocation()), mySrd);
assertEquals(patientId.toVersionless().getValue(), o.getSubject().getReference());
}
@Test
public void testTransactionUpdateMatchUrlWithZeroMatchWithId() {
String methodName = "testTransactionUpdateMatchUrlWithZeroMatch";
Bundle request = new Bundle();
Patient p = new Patient();
p.addName().setFamily("Hello");
IIdType id = myPatientDao.create(p, mySrd).getId();
@ -3421,7 +3596,7 @@ public class FhirSystemDaoR4Test extends BaseJpaR4SystemTest {
assertEquals(Constants.STATUS_HTTP_201_CREATED + " Created", nextEntry.getResponse().getStatus());
IdType patientId = new IdType(nextEntry.getResponse().getLocation());
assertThat(nextEntry.getResponse().getLocation(), not(containsString("test")));
assertThat(nextEntry.getResponse().getLocation(), containsString(methodName));
assertNotEquals(id.toVersionless(), patientId.toVersionless());
assertThat(patientId.getValue(), endsWith("/_history/1"));

View File

@ -152,6 +152,16 @@ public class TransactionDetails {
return false;
}
/**
* Was the given resource ID resolved previously in this transaction
*/
public boolean hasResolvedResourceId(IIdType theId) {
if (myResolvedResourceIds != null) {
return myResolvedResourceIds.containsKey(theId.toVersionless().getValue());
}
return false;
}
/**
* A <b>Resolved Resource ID</b> is a mapping between a resource ID (e.g. "<code>Patient/ABC</code>" or

View File

@ -21,6 +21,7 @@ package ca.uhn.fhir.jpa.dao;
*/
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.context.FhirVersionEnum;
import ca.uhn.fhir.context.RuntimeResourceDefinition;
import ca.uhn.fhir.i18n.Msg;
import ca.uhn.fhir.interceptor.api.HookParams;
@ -1009,7 +1010,9 @@ public abstract class BaseTransactionProcessor {
res.setId(newIdType(parts.getResourceType(), parts.getResourceId(), version));
outcome = resourceDao.update(res, null, false, false, theRequest, theTransactionDetails);
} else {
res.setId((String) null);
if (!shouldConditionalUpdateMatchId(theTransactionDetails, res.getIdElement())) {
res.setId((String) null);
}
String matchUrl;
if (isNotBlank(parts.getParams())) {
matchUrl = parts.getResourceType() + '?' + parts.getParams();
@ -1198,6 +1201,25 @@ public abstract class BaseTransactionProcessor {
}
}
/**
* Check for if a resource id should be matched in a conditional update
* If the FHIR version is older than R4, it follows the old specifications and does not match
* If the resource id has been resolved, then it is an existing resource and does not need to be matched
* If the resource id is local or a placeholder, the id is temporary and should not be matched
*/
private boolean shouldConditionalUpdateMatchId(TransactionDetails theTransactionDetails, IIdType theId) {
if (myContext.getVersion().getVersion().isOlderThan(FhirVersionEnum.R4)) {
return false;
}
if (theTransactionDetails.hasResolvedResourceId(theId) && !theTransactionDetails.isResolvedResourceIdEmpty(theId)) {
return false;
}
if (theId != null && theId.getValue() != null) {
return !(theId.getValue().startsWith("urn:") || theId.getValue().startsWith("#"));
}
return true;
}
private boolean shouldSwapBinaryToActualResource(IBaseResource theResource, String theResourceType, IIdType theNextResourceId) {
if ("Binary".equalsIgnoreCase(theResourceType) && theNextResourceId.getResourceType() != null && !theNextResourceId.getResourceType().equalsIgnoreCase("Binary")) {
return true;