Added FHIRPathResource Generator for R4 including Test. (#2310)

Signed-off-by: Marcel Parciak <marcel.parciak@gmail.com>
This commit is contained in:
Marcel P 2021-01-29 17:05:05 +01:00 committed by GitHub
parent 589eb3158e
commit d11515599a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 760 additions and 0 deletions

View File

@ -0,0 +1,609 @@
/*
Copyright 2021 Marcel Parciak <marcel.parciak@med.uni-goettingen.de>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package org.hl7.fhir.common.hapi.validation.validator;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Stack;
import org.hl7.fhir.r4.utils.FHIRPathEngine;
import org.hl7.fhir.instance.model.api.IBase;
import org.hl7.fhir.instance.model.api.ICompositeType;
import org.hl7.fhir.instance.model.api.IPrimitiveType;
import org.hl7.fhir.r4.hapi.ctx.HapiWorkerContext;
import org.hl7.fhir.r4.model.ExpressionNode;
import org.hl7.fhir.r4.model.Resource;
import ca.uhn.fhir.context.BaseRuntimeChildDefinition;
import ca.uhn.fhir.context.BaseRuntimeElementDefinition;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.context.RuntimeCompositeDatatypeDefinition;
import ca.uhn.fhir.context.RuntimePrimitiveDatatypeDefinition;
public class FHIRPathResourceGeneratorR4<T extends Resource> {
private FhirContext ctx;
private FHIRPathEngine engine;
private Map<String, String> pathMapping;
private T resource = null;
private String valueToSet = null;
private Stack<GenerationTier> nodeStack = null;
/**
* The GenerationTier summarizes some variables that are needed to create FHIR
* elements later on.
*/
class GenerationTier {
// The RuntimeDefinition of nodes
public BaseRuntimeElementDefinition<?> nodeDefinition = null;
// The actual nodes, i.e. the instances that hold the values
public List<IBase> nodes = new ArrayList<>();
// The ChildDefinition applied to the parent (i.e. one of the nodes from a lower
// GenerationTier) to create nodes
public BaseRuntimeChildDefinition childDefinition = null;
// The path segment name of nodes
public String fhirPathName = null;
public GenerationTier() {
}
public GenerationTier(BaseRuntimeElementDefinition<?> nodeDef, IBase firstNode) {
this.nodeDefinition = nodeDef;
this.nodes.add(firstNode);
}
}
/**
* Constructor without parameters, needs a call to `setMapping` later on in
* order to generate any Resources.
*/
public FHIRPathResourceGeneratorR4() {
this.pathMapping = new HashMap<String, String>();
this.ctx = FhirContext.forR4();
this.engine = new FHIRPathEngine(new HapiWorkerContext(ctx, ctx.getValidationSupport()));
}
/**
* Constructor that allows to provide a mapping right away.
*
* @param mapping Map<String, String> a mapping of FHIRPath to value Strings
* that will be used to create a Resource.
*/
public FHIRPathResourceGeneratorR4(Map<String, String> mapping) {
this();
this.setMapping(mapping);
}
/**
* Setter for the FHIRPath mapping Map instance.
*
* @param mapping Map<String, String> a mapping of FHIRPath to value Strings
* that will be used to create a Resource.
*/
public void setMapping(Map<String, String> mapping) {
this.pathMapping = mapping;
}
/**
* Getter for a generated Resource. null if no Resource has been generated yet.
*
* @return T the generated Resource or null.
*/
public T getResource() {
return this.resource;
}
/**
* Prepares the internal state prior to generating a FHIR Resource. Called once
* upon generation at the start.
*
* @param resourceClass Class<T> The class of the Resource that shall be created
* (an empty Resource will be created in this method).
*/
@SuppressWarnings("unchecked")
private void prepareInternalState(Class<T> resourceClass) {
this.resource = (T) this.ctx.getResourceDefinition(resourceClass).newInstance();
}
/**
* The generation method that yields a new instance of class `resourceClass`
* with every value set in the FHIRPath mapping.
*
* @param resourceClass Class<T> The class of the Resource that shall be
* created.
* @return T a new FHIR Resource instance of class `resourceClass`.
*/
public T generateResource(Class<T> resourceClass) {
this.prepareInternalState(resourceClass);
for (String fhirPath : this.sortedPaths()) {
// prepare the next fhirPath iteration: create a new nodeStack and set the value
this.nodeStack = new Stack<>();
this.nodeStack.push(new GenerationTier(this.ctx.getResourceDefinition(this.resource), this.resource));
this.valueToSet = this.pathMapping.get(fhirPath);
// pathNode is the part of the FHIRPath we are processing
ExpressionNode pathNode = this.engine.parse(fhirPath);
while (pathNode != null) {
switch (pathNode.getKind()) {
case Name:
this.handleNameNode(pathNode);
break;
case Function:
this.handleFunctionNode(pathNode);
break;
case Constant:
case Group:
case Unary:
// TODO: unimplmemented, what to do?
break;
}
pathNode = pathNode.getInner();
}
}
this.nodeStack = null;
return this.resource;
}
/*
* Handling Named nodes
*/
/**
* Handles a named node, either adding a new layer to the `nodeStack` when
* reaching a Composite Node or adding the value for Primitive Nodes.
*
* @param fhirPath String the FHIRPath section for the next GenerationTier.
* @param value String the value that shall be set upon reaching a
* PrimitiveNode.
*/
private void handleNameNode(ExpressionNode fhirPath) {
BaseRuntimeChildDefinition childDef = this.nodeStack.peek().nodeDefinition.getChildByName(fhirPath.getName());
if (childDef == null) {
// nothing to do
return;
}
// identify the type of named node we need to handle here by getting the runtime
// definition type
switch (childDef.getChildByName(fhirPath.getName()).getChildType()) {
case COMPOSITE_DATATYPE:
handleCompositeNode(fhirPath);
break;
case PRIMITIVE_DATATYPE:
handlePrimitiveNode(fhirPath);
break;
case ID_DATATYPE:
case RESOURCE:
case CONTAINED_RESOURCE_LIST:
case CONTAINED_RESOURCES:
case EXTENSION_DECLARED:
case PRIMITIVE_XHTML:
case PRIMITIVE_XHTML_HL7ORG:
case RESOURCE_BLOCK:
case UNDECL_EXT:
// TODO: not implemented. What to do?
}
}
/**
* Handles primitive nodes with regards to the current latest tier of the
* nodeStack. Sets a primitive value to all nodes.
*
* @param fhirPath ExpressionNode segment of the fhirPath that specifies the
* primitive value to set.
*/
private void handlePrimitiveNode(ExpressionNode fhirPath) {
// Get the child definition from the parent
BaseRuntimeChildDefinition childDefinition = this.nodeStack.peek().nodeDefinition
.getChildByName(fhirPath.getName());
// Get the primitive type definition from the childDeftinion
RuntimePrimitiveDatatypeDefinition primitiveTarget = (RuntimePrimitiveDatatypeDefinition) childDefinition
.getChildByName(fhirPath.getName());
for (IBase nodeElement : this.nodeStack.peek().nodes) {
// add the primitive value to each parent node
IPrimitiveType<?> primitive = primitiveTarget
.newInstance(childDefinition.getInstanceConstructorArguments());
primitive.setValueAsString(this.valueToSet);
childDefinition.getMutator().addValue(nodeElement, primitive);
}
}
/**
* Handles a composite node with regards to the current latest tier of the
* nodeStack. Creates a new node based on fhirPath if none are available.
*
* @param fhirPath ExpressionNode the segment of the FHIRPath that is being
* handled right now.
*/
private void handleCompositeNode(ExpressionNode fhirPath) {
GenerationTier nextTier = new GenerationTier();
// get the name of the FHIRPath for the next tier
nextTier.fhirPathName = fhirPath.getName();
// get the child definition from the parent nodePefinition
nextTier.childDefinition = this.nodeStack.peek().nodeDefinition.getChildByName(fhirPath.getName());
// create a nodeDefinition for the next tier
nextTier.nodeDefinition = nextTier.childDefinition.getChildByName(nextTier.fhirPathName);
RuntimeCompositeDatatypeDefinition compositeTarget = (RuntimeCompositeDatatypeDefinition) nextTier.nodeDefinition;
// iterate through all parent nodes
for (IBase nodeElement : this.nodeStack.peek().nodes) {
List<IBase> containedNodes = nextTier.childDefinition.getAccessor().getValues(nodeElement);
if (containedNodes.size() > 0) {
// check if sister nodes are already available
nextTier.nodes.addAll(containedNodes);
} else {
// if not nodes are available, create a new node
ICompositeType compositeNode = compositeTarget
.newInstance(nextTier.childDefinition.getInstanceConstructorArguments());
nextTier.childDefinition.getMutator().addValue(nodeElement, compositeNode);
nextTier.nodes.add(compositeNode);
}
}
// push the created nextTier to the nodeStack
this.nodeStack.push(nextTier);
}
/*
* Handling Function Nodes
*/
/**
* Handles a function node of a FHIRPath.
*
* @param fhirPath ExpressionNode the segment of the FHIRPath that is being
* handled right now.
*/
private void handleFunctionNode(ExpressionNode fhirPath) {
switch(fhirPath.getFunction()) {
case Where:
this.handleWhereFunctionNode(fhirPath);
case Aggregate:
case Alias:
case AliasAs:
case All:
case AllFalse:
case AllTrue:
case AnyFalse:
case AnyTrue:
case As:
case Check:
case Children:
case Combine:
case ConformsTo:
case Contains:
case ConvertsToBoolean:
case ConvertsToDateTime:
case ConvertsToDecimal:
case ConvertsToInteger:
case ConvertsToQuantity:
case ConvertsToString:
case ConvertsToTime:
case Count:
case Custom:
case Descendants:
case Distinct:
case Empty:
case EndsWith:
case Exclude:
case Exists:
case Extension:
case First:
case HasValue:
case HtmlChecks:
case Iif:
case IndexOf:
case Intersect:
case Is:
case IsDistinct:
case Item:
case Last:
case Length:
case Lower:
case Matches:
case MemberOf:
case Not:
case Now:
case OfType:
case Repeat:
case Replace:
case ReplaceMatches:
case Resolve:
case Select:
case Single:
case Skip:
case StartsWith:
case SubsetOf:
case Substring:
case SupersetOf:
case Tail:
case Take:
case ToBoolean:
case ToChars:
case ToDateTime:
case ToDecimal:
case ToInteger:
case ToQuantity:
case ToString:
case ToTime:
case Today:
case Trace:
case Type:
case Union:
case Upper:
// TODO: unimplemented, what to do?
}
}
/**
* Handles a function node of a `where`-function. Iterates through all params
* and handle where functions for primitive datatypes (others are not
* implemented and yield errors.)
*
* @param fhirPath ExpressionNode the segment of the FHIRPath that contains the
* where function
*/
private void handleWhereFunctionNode(ExpressionNode fhirPath) {
// iterate through all where parameters
for (ExpressionNode param : fhirPath.getParameters()) {
BaseRuntimeChildDefinition wherePropertyChild = this.nodeStack.peek().nodeDefinition
.getChildByName(param.getName());
BaseRuntimeElementDefinition<?> wherePropertyDefinition = wherePropertyChild
.getChildByName(param.getName());
// only primitive nodes can be checked using the where function
switch(wherePropertyDefinition.getChildType()) {
case PRIMITIVE_DATATYPE:
this.handleWhereFunctionParam(param);
break;
case COMPOSITE_DATATYPE:
case CONTAINED_RESOURCES:
case CONTAINED_RESOURCE_LIST:
case EXTENSION_DECLARED:
case ID_DATATYPE:
case PRIMITIVE_XHTML:
case PRIMITIVE_XHTML_HL7ORG:
case RESOURCE:
case RESOURCE_BLOCK:
case UNDECL_EXT:
// TODO: unimplemented. What to do?
}
}
}
/**
* Filter the latest nodeStack tier using `param`.
*
* @param param ExpressionNode parameter type ExpressionNode that provides the
* where clause that is used to filter nodes from the nodeStack.
*/
private void handleWhereFunctionParam(ExpressionNode param) {
BaseRuntimeChildDefinition wherePropertyChild = this.nodeStack.peek().nodeDefinition
.getChildByName(param.getName());
BaseRuntimeElementDefinition<?> wherePropertyDefinition = wherePropertyChild.getChildByName(param.getName());
String matchingValue = param.getOpNext().getConstant().toString();
List<IBase> matchingNodes = new ArrayList<>();
List<IBase> unlabeledNodes = new ArrayList<>();
// sort all nodes from the nodeStack into matching nodes and unlabeled nodes
for (IBase node : this.nodeStack.peek().nodes) {
List<IBase> operationValues = wherePropertyChild.getAccessor().getValues(node);
if (operationValues.size() == 0) {
unlabeledNodes.add(node);
} else {
for (IBase operationValue : operationValues) {
IPrimitiveType<?> primitive = (IPrimitiveType<?>) operationValue;
switch (param.getOperation()) {
case Equals:
if (primitive.getValueAsString().equals(matchingValue)) {
matchingNodes.add(node);
}
break;
case NotEquals:
if (!primitive.getValueAsString().equals(matchingValue)) {
matchingNodes.add(node);
}
break;
case And:
case As:
case Concatenate:
case Contains:
case Div:
case DivideBy:
case Equivalent:
case Greater:
case GreaterOrEqual:
case Implies:
case In:
case Is:
case LessOrEqual:
case LessThan:
case MemberOf:
case Minus:
case Mod:
case NotEquivalent:
case Or:
case Plus:
case Times:
case Union:
case Xor:
// TODO: unimplemented, what to do?
}
}
}
}
if (matchingNodes.size() == 0) {
if (unlabeledNodes.size() == 0) {
// no nodes were matched and no unlabeled nodes are available. We need to add a
// sister node to the nodeStack
GenerationTier latestTier = this.nodeStack.pop();
GenerationTier previousTier = this.nodeStack.peek();
this.nodeStack.push(latestTier);
RuntimeCompositeDatatypeDefinition compositeTarget = (RuntimeCompositeDatatypeDefinition) latestTier.nodeDefinition;
ICompositeType compositeNode = compositeTarget
.newInstance(latestTier.childDefinition.getInstanceConstructorArguments());
latestTier.childDefinition.getMutator().addValue(previousTier.nodes.get(0), compositeNode);
unlabeledNodes.add(compositeNode);
}
switch(param.getOperation()) {
case Equals:
// if we are checking for equality, we need to set the property we looked for on
// the unlabeled node(s)
RuntimePrimitiveDatatypeDefinition equalsPrimitive = (RuntimePrimitiveDatatypeDefinition) wherePropertyDefinition;
IPrimitiveType<?> primitive = equalsPrimitive
.newInstance(wherePropertyChild.getInstanceConstructorArguments());
primitive.setValueAsString(param.getOpNext().getConstant().toString());
for (IBase node : unlabeledNodes) {
wherePropertyChild.getMutator().addValue(node, primitive);
matchingNodes.add(node);
}
break;
case NotEquals:
// if we are checking for inequality, we need to pass all unlabeled (or created
// if none were available)
matchingNodes.addAll(unlabeledNodes);
break;
case And:
case As:
case Concatenate:
case Contains:
case Div:
case DivideBy:
case Equivalent:
case Greater:
case GreaterOrEqual:
case Implies:
case In:
case Is:
case LessOrEqual:
case LessThan:
case MemberOf:
case Minus:
case Mod:
case NotEquivalent:
case Or:
case Plus:
case Times:
case Union:
case Xor:
// TODO: need to implement above first
}
}
// set the nodes to the filtered ones
this.nodeStack.peek().nodes = matchingNodes;
}
/**
* Creates a list all FHIRPaths from the mapping ordered by paths with where
* equals, where unequals and the rest.
*
* @return List<String> a List of FHIRPaths ordered by the type.
*/
private List<String> sortedPaths() {
List<String> whereEquals = new ArrayList<String>();
List<String> whereUnequals = new ArrayList<String>();
List<String> withoutWhere = new ArrayList<String>();
for (String fhirPath : this.pathMapping.keySet()) {
switch (this.getTypeOfFhirPath(fhirPath)) {
case WHERE_EQUALS:
whereEquals.add(fhirPath);
break;
case WHERE_UNEQUALS:
whereUnequals.add(fhirPath);
break;
case WITHOUT_WHERE:
withoutWhere.add(fhirPath);
break;
}
}
List<String> ret = new ArrayList<String>();
ret.addAll(whereEquals);
ret.addAll(whereUnequals);
ret.addAll(withoutWhere);
return ret;
}
/**
* Returns the type of path based on the FHIRPath String.
*
* @param fhirPath String representation of a FHIRPath.
* @return PathType the type of path supplied as `fhirPath`.
*/
private PathType getTypeOfFhirPath(String fhirPath) {
ExpressionNode fhirPathExpression = this.engine.parse(fhirPath);
while (fhirPathExpression != null) {
if (fhirPathExpression.getKind() == ExpressionNode.Kind.Function) {
if (fhirPathExpression.getFunction() == ExpressionNode.Function.Where) {
for (ExpressionNode params : fhirPathExpression.getParameters()) {
switch (params.getOperation()) {
case Equals:
return PathType.WHERE_EQUALS;
case NotEquals:
return PathType.WHERE_UNEQUALS;
case And:
case As:
case Concatenate:
case Contains:
case Div:
case DivideBy:
case Equivalent:
case Greater:
case GreaterOrEqual:
case Implies:
case In:
case Is:
case LessOrEqual:
case LessThan:
case MemberOf:
case Minus:
case Mod:
case NotEquivalent:
case Or:
case Plus:
case Times:
case Union:
case Xor:
// TODO: need to implement above first
}
}
}
}
fhirPathExpression = fhirPathExpression.getInner();
}
return PathType.WITHOUT_WHERE;
}
/**
* A simple enum to diffirentiate between types of FHIRPaths in the special use
* case of generating FHIR Resources.
*/
public enum PathType {
WHERE_EQUALS, WHERE_UNEQUALS, WITHOUT_WHERE
}
}

View File

@ -0,0 +1,151 @@
package org.hl7.fhir.r4.validation;
import java.util.HashMap;
import java.util.Map;
import org.hl7.fhir.common.hapi.validation.validator.FHIRPathResourceGeneratorR4;
import org.hl7.fhir.r4.model.Address;
import org.hl7.fhir.r4.model.HumanName;
import org.hl7.fhir.r4.model.Patient;
import org.hl7.fhir.r4.model.Address.AddressType;
import org.hl7.fhir.r4.model.Address.AddressUse;
import org.hl7.fhir.r4.model.Enumerations.AdministrativeGender;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class FHIRPathResourceGeneratorR4Test {
public Map<String, String> createFhirMapping() {
Map<String, String> mapping = new HashMap<>();
mapping.put("Patient.name.given", "Marcel");
mapping.put("Patient.name.family", "Parciak");
mapping.put("Patient.gender", "male");
mapping.put("Patient.address.where(use = 'work').city", "Göttingen");
mapping.put("Patient.address.where(use = 'work').postalCode", "37075");
mapping.put("Patient.address.where(use = 'billing').city", "Göttingen");
mapping.put("Patient.address.where(use = 'billing').postalCode", "37099");
return mapping;
}
public Map<String, String> createFhirMapping_2() {
Map<String, String> mapping = new HashMap<>();
mapping.put("Patient.name.where(use = 'official').family", "Parciak");
mapping.put("Patient.name.where(use = 'maiden').family", "ParciakMaiden");
mapping.put("Patient.name.given", "Marcel");
mapping.put("Patient.gender", "male");
mapping.put("Patient.address.where(use = 'work').city", "Göttingen");
mapping.put("Patient.address.where(use = 'work').postalCode", "37075");
return mapping;
}
@Test
public void createFhirGenerator() {
FHIRPathResourceGeneratorR4<Patient> test = new FHIRPathResourceGeneratorR4<>();
assertNotNull(test);
}
@Test
public void createFhirGeneratorWithMapping() {
Map<String, String> mapping = this.createFhirMapping();
FHIRPathResourceGeneratorR4<Patient> test = new FHIRPathResourceGeneratorR4<>(mapping);
assertNotNull(test);
}
@Test
public void generatePatientResource() {
Map<String, String> mapping = this.createFhirMapping();
FHIRPathResourceGeneratorR4<Patient> resourceGenerator = new FHIRPathResourceGeneratorR4<>(mapping);
Patient patient = resourceGenerator.generateResource(Patient.class);
assertNotNull(patient);
assertEquals(patient.getName().size(), 1);
assertEquals(patient.getNameFirstRep().getFamily(), "Parciak");
assertEquals(patient.getNameFirstRep().getGiven().size(), 1);
assertEquals(patient.getNameFirstRep().getGiven().get(0).asStringValue(), "Marcel");
// note that we have parsed a String here
assertEquals(patient.getGender(), AdministrativeGender.MALE);
assertEquals(patient.getAddress().size(), 2);
for(Address address: patient.getAddress()) {
assertEquals(address.getCity(), "Göttingen");
if(address.getUse() == AddressUse.WORK) {
assertEquals(address.getPostalCode(), "37075");
} else if(address.getUse() == AddressUse.BILLING) {
assertEquals(address.getPostalCode(), "37099");
} else {
// an address that has no use should not be created based on the test data
assertTrue(false);
}
}
}
@Test
public void generateMainzellisteDefaultPatient() {
Map<String, String> mapping = this.createFhirMapping_2();
FHIRPathResourceGeneratorR4<Patient> resourceGenerator = new FHIRPathResourceGeneratorR4<>(mapping);
Patient patient = resourceGenerator.generateResource(Patient.class);
assertNotNull(patient);
assertEquals(patient.getName().size(), 2);
for(HumanName name: patient.getName()) {
assertEquals(name.getGiven().size(), 1);
assertEquals(name.getGiven().get(0).asStringValue(), "Marcel");
if(name.getUse() == HumanName.NameUse.OFFICIAL) {
assertEquals(name.getFamily(), "Parciak");
} else if (name.getUse() == HumanName.NameUse.MAIDEN) {
assertEquals(name.getFamily(), "ParciakMaiden");
} else {
assertTrue(false);
}
}
// note that we have parsed a String here
assertEquals(patient.getGender(), AdministrativeGender.MALE);
assertEquals(patient.getAddress().size(), 1);
assertEquals(patient.getAddressFirstRep().getUse(), AddressUse.WORK);
assertEquals(patient.getAddressFirstRep().getPostalCode(), "37075");
assertEquals(patient.getAddressFirstRep().getCity(), "Göttingen");
}
@Test
public void checkGenerationWithWhereUnequal() {
Map<String, String> mapping = this.createFhirMapping();
mapping.put("Patient.address.where(use = 'billing').type", "postal");
mapping.put("Patient.address.where(use != 'billing').type", "physical");
FHIRPathResourceGeneratorR4<Patient> resourceGenerator = new FHIRPathResourceGeneratorR4<>(mapping);
Patient patient = resourceGenerator.generateResource(Patient.class);
assertNotNull(patient);
assertEquals(patient.getName().size(), 1);
assertEquals(patient.getNameFirstRep().getFamily(), "Parciak");
assertEquals(patient.getNameFirstRep().getGiven().size(), 1);
assertEquals(patient.getNameFirstRep().getGiven().get(0).asStringValue(), "Marcel");
// note that we have parsed a String here
assertEquals(patient.getGender(), AdministrativeGender.MALE);
assertEquals(patient.getAddress().size(), 2);
for(Address address: patient.getAddress()) {
assertEquals(address.getCity(), "Göttingen");
if(address.getUse() == AddressUse.WORK) {
assertEquals(address.getPostalCode(), "37075");
assertEquals(address.getType(), AddressType.PHYSICAL);
} else if(address.getUse() == AddressUse.BILLING) {
assertEquals(address.getPostalCode(), "37099");
assertEquals(address.getType(), AddressType.POSTAL);
} else {
// an address that has no use should not be created based on the test data
assertTrue(false);
}
}
}
}