diff --git a/commons/protocol/src/main/scala/net/shrine/protocol/QueryResult.scala b/commons/protocol/src/main/scala/net/shrine/protocol/QueryResult.scala index abf3290e4..ab8c71161 100644 --- a/commons/protocol/src/main/scala/net/shrine/protocol/QueryResult.scala +++ b/commons/protocol/src/main/scala/net/shrine/protocol/QueryResult.scala @@ -1,383 +1,405 @@ package net.shrine.protocol import javax.xml.datatype.XMLGregorianCalendar import net.shrine.log.Loggable import net.shrine.problem.{ProblemSources, AbstractProblem, Problem, ProblemDigest} import net.shrine.protocol.QueryResult.StatusType import scala.xml.NodeSeq import net.shrine.util.{Tries, XmlUtil, NodeSeqEnrichments, SEnum, XmlDateHelper, OptionEnrichments} import net.shrine.serialization.{ I2b2Marshaller, XmlMarshaller } import scala.util.Try /** * @author Bill Simons * @since 4/15/11 * @see http://cbmi.med.harvard.edu * @see http://chip.org * <p/> * NOTICE: This software comes with NO guarantees whatsoever and is * licensed as Lgpl Open Source * @see http://www.gnu.org/licenses/lgpl.html * * NB: this is a case class to get a structural equality contract in hashCode and equals, mostly for testing */ final case class QueryResult ( resultId: Long, instanceId: Long, resultType: Option[ResultOutputType], setSize: Long, startDate: Option[XMLGregorianCalendar], endDate: Option[XMLGregorianCalendar], description: Option[String], statusType: StatusType, statusMessage: Option[String], problemDigest: Option[ProblemDigest] = None, breakdowns: Map[ResultOutputType,I2b2ResultEnvelope] = Map.empty ) extends XmlMarshaller with I2b2Marshaller with Loggable { //only used in tests def this( resultId: Long, instanceId: Long, resultType: ResultOutputType, setSize: Long, startDate: XMLGregorianCalendar, endDate: XMLGregorianCalendar, statusType: QueryResult.StatusType) = { this( resultId, instanceId, Option(resultType), setSize, Option(startDate), Option(endDate), None, //description statusType, None) //statusMessage } def this( resultId: Long, instanceId: Long, resultType: ResultOutputType, setSize: Long, startDate: XMLGregorianCalendar, endDate: XMLGregorianCalendar, description: String, statusType: QueryResult.StatusType) = { this( resultId, instanceId, Option(resultType), setSize, Option(startDate), Option(endDate), Option(description), statusType, None) //statusMessage } def resultTypeIs(testedResultType: ResultOutputType): Boolean = resultType match { case Some(rt) => rt == testedResultType case _ => false } import QueryResult._ //NB: Fragile, non-type-safe == def isError = statusType == StatusType.Error def elapsed: Option[Long] = { def inMillis(xmlGc: XMLGregorianCalendar) = xmlGc.toGregorianCalendar.getTimeInMillis for { start <- startDate end <- endDate } yield inMillis(end) - inMillis(start) } //Sorting isn't strictly necessary, but makes deterministic unit testing easier. //The number of breakdowns will be at most 4, so performance should not be an issue. private def sortedBreakdowns: Seq[I2b2ResultEnvelope] = { breakdowns.values.toSeq.sortBy(_.resultType.name) } override def toI2b2: NodeSeq = { import OptionEnrichments._ XmlUtil.stripWhitespace { <query_result_instance> <result_instance_id>{ resultId }</result_instance_id> <query_instance_id>{ instanceId }</query_instance_id> { description.toXml(<description/>) } { resultType.fold( ResultOutputType.ERROR.toI2b2NameOnly("") ){ rt => if(rt.isBreakdown) rt.toI2b2NameOnly() else if (rt.isError) rt.toI2b2NameOnly() //The result type can be an error else if (statusType.isError) rt.toI2b2NameOnly() //Or the status type can be an error else rt.toI2b2 } } <set_size>{ setSize }</set_size> { startDate.toXml(<start_date/>) } { endDate.toXml(<end_date/>) } <query_status_type> <name>{ statusType }</name> { statusType.toI2b2(this) } </query_status_type> { //NB: Deliberately use Shrine XML format instead of the i2b2 one. Adding breakdowns to i2b2-format XML here is deviating from the i2b2 XSD schema in any case, //so if we're going to do that, let's produce saner XML. sortedBreakdowns.map(_.toXml.head).map(XmlUtil.renameRootTag("breakdown_data")) } </query_result_instance> } } override def toXml: NodeSeq = XmlUtil.stripWhitespace { import OptionEnrichments._ <queryResult> <resultId>{ resultId }</resultId> <instanceId>{ instanceId }</instanceId> { resultType.toXml(_.toXml) } <setSize>{ setSize }</setSize> { startDate.toXml(<startDate/>) } { endDate.toXml(<endDate/>) } { description.toXml(<description/>) } <status>{ statusType }</status> { statusMessage.toXml(<statusMessage/>) } { //Sorting isn't strictly necessary, but makes deterministic unit testing easier. //The number of breakdowns will be at most 4, so performance should not be an issue. sortedBreakdowns.map(_.toXml) } { problemDigest.map(_.toXml).getOrElse("") } </queryResult> } def withId(id: Long): QueryResult = copy(resultId = id) def withInstanceId(id: Long): QueryResult = copy(instanceId = id) def modifySetSize(f: Long => Long): QueryResult = withSetSize(f(setSize)) def withSetSize(size: Long): QueryResult = copy(setSize = size) def withDescription(desc: String): QueryResult = copy(description = Option(desc)) def withResultType(resType: ResultOutputType): QueryResult = copy(resultType = Option(resType)) def withBreakdown(breakdownData: I2b2ResultEnvelope) = copy(breakdowns = breakdowns + (breakdownData.resultType -> breakdownData)) def withBreakdowns(newBreakdowns: Map[ResultOutputType, I2b2ResultEnvelope]) = copy(breakdowns = newBreakdowns) } object QueryResult { final case class StatusType( name: String, isDone: Boolean, i2b2Id: Option[Int] = Some(-1), private val doToI2b2:(QueryResult => NodeSeq) = StatusType.defaultToI2b2) extends StatusType.Value { def isError = this == StatusType.Error def toI2b2(queryResult: QueryResult): NodeSeq = doToI2b2(queryResult) } object StatusType extends SEnum[StatusType] { private val defaultToI2b2: QueryResult => NodeSeq = { queryResult => val i2b2Id: Int = queryResult.statusType.i2b2Id.getOrElse{ throw new IllegalStateException(s"queryResult.statusType ${queryResult.statusType} has no i2b2Id") } <status_type_id>{ i2b2Id }</status_type_id><description>{ queryResult.statusType.name }</description> } val noMessage:NodeSeq = null val Error = StatusType("ERROR", isDone = true, None, { queryResult => (queryResult.statusMessage, queryResult.problemDigest) match { case (Some(msg),Some(pd)) => <description>{ msg }</description> ++ pd.toXml case (Some(msg),None) => <description>{ msg }</description> case (None,Some(pd)) => pd.toXml case (None, None) => noMessage } }) /* msg => <codec>net.shrine.something.is.Broken</codec> <summary>Something is borked</summary> <description>{ msg }</description> <details>Herein is a stack trace, multiple lines</details> )) */ val Finished = StatusType("FINISHED", isDone = true, Some(3)) //TODO: Can we use the same <status_type_id> for Queued, Processing, and Incomplete? val Processing = StatusType("PROCESSING", isDone = false, Some(2)) //todo only used in tests val Queued = StatusType("QUEUED", isDone = false, Some(2)) val Incomplete = StatusType("INCOMPLETE", isDone = false, Some(2)) //TODO: What <status_type_id>s should these have? Does anyone care? val Held = StatusType("HELD", isDone = false) val SmallQueue = StatusType("SMALL_QUEUE", isDone = false) val MediumQueue = StatusType("MEDIUM_QUEUE", isDone = false) val LargeQueue = StatusType("LARGE_QUEUE", isDone = false) val NoMoreQueue = StatusType("NO_MORE_QUEUE", isDone = false) } def extractLong(nodeSeq: NodeSeq)(elemName: String): Long = (nodeSeq \ elemName).text.toLong private def parseDate(lexicalRep: String): Option[XMLGregorianCalendar] = XmlDateHelper.parseXmlTime(lexicalRep).toOption def elemAt(path: String*)(xml: NodeSeq): NodeSeq = path.foldLeft(xml)(_ \ _) def asText(path: String*)(xml: NodeSeq): String = elemAt(path: _*)(xml).text.trim def asResultOutputTypeOption(elemNames: String*)(breakdownTypes: Set[ResultOutputType], xml: NodeSeq): Option[ResultOutputType] = { import ResultOutputType.valueOf val typeName = asText(elemNames: _*)(xml) valueOf(typeName) orElse valueOf(breakdownTypes)(typeName) } def extractResultOutputType(xml: NodeSeq)(parse: NodeSeq => Try[ResultOutputType]): Option[ResultOutputType] = { val attempt = parse(xml) attempt.toOption } def extractProblemDigest(xml: NodeSeq):Option[ProblemDigest] = { val subXml = xml \ "problem" if(subXml.nonEmpty) Some(ProblemDigest.fromXml(xml)) else None } def fromXml(breakdownTypes: Set[ResultOutputType])(xml: NodeSeq): QueryResult = { def extract(elemName: String): Option[String] = { Option((xml \ elemName).text.trim).filter(!_.isEmpty) } def extractDate(elemName: String): Option[XMLGregorianCalendar] = extract(elemName).flatMap(parseDate) val asLong = extractLong(xml) _ import NodeSeqEnrichments.Strictness._ import Tries.sequence def extractBreakdowns(elemName: String): Map[ResultOutputType, I2b2ResultEnvelope] = { //noinspection ScalaUnnecessaryParentheses val mapAttempt = for { subXml <- xml.withChild(elemName) envelopes <- sequence(subXml.map(I2b2ResultEnvelope.fromXml(breakdownTypes))) mappings = envelopes.map(envelope => (envelope.resultType -> envelope)) } yield Map.empty ++ mappings mapAttempt.getOrElse(Map.empty) } QueryResult( resultId = asLong("resultId"), instanceId = asLong("instanceId"), resultType = extractResultOutputType(xml \ "resultType")(ResultOutputType.fromXml), setSize = asLong("setSize"), startDate = extractDate("startDate"), endDate = extractDate("endDate"), description = extract("description"), statusType = StatusType.valueOf(asText("status")(xml)).get, //TODO: Avoid fragile .get call statusMessage = extract("statusMessage"), problemDigest = extractProblemDigest(xml), breakdowns = extractBreakdowns("resultEnvelope") ) } def fromI2b2(breakdownTypes: Set[ResultOutputType])(xml: NodeSeq): QueryResult = { def asLong = extractLong(xml) _ def asTextOption(path: String*): Option[String] = elemAt(path: _*)(xml).headOption.map(_.text.trim) def asXmlGcOption(path: String): Option[XMLGregorianCalendar] = asTextOption(path).filter(!_.isEmpty).flatMap(parseDate) val statusType = StatusType.valueOf(asText("query_status_type", "name")(xml)).get //TODO: Avoid fragile .get call val statusMessage: Option[String] = asTextOption("query_status_type", "description") val encodedProblemDigest = extractProblemDigest(xml \ "query_status_type") val problemDigest = if (encodedProblemDigest.isDefined) encodedProblemDigest else if (statusType.isError) Some(ErrorStatusFromCrc(statusMessage,xml.text).toDigest) else None + case class Filling( + resultType:Option[ResultOutputType], + setSize:Long, + startDate:Option[XMLGregorianCalendar], + endDate:Option[XMLGregorianCalendar] + ) + + val filling = if(!statusType.isError) { + val resultType: Option[ResultOutputType] = extractResultOutputType(xml \ "query_result_type")(ResultOutputType.fromI2b2) + val setSize = asLong("set_size") + val startDate = asXmlGcOption("start_date") + val endDate = asXmlGcOption("end_date") + Filling(resultType,setSize,startDate,endDate) + } + else { + val resultType = None + val setSize = 0L + val startDate = None + val endDate = None + Filling(resultType,setSize,startDate,endDate) + } + QueryResult( resultId = asLong("result_instance_id"), instanceId = asLong("query_instance_id"), - resultType = extractResultOutputType(xml \ "query_result_type")(ResultOutputType.fromI2b2), - setSize = asLong("set_size"), - startDate = asXmlGcOption("start_date"), - endDate = asXmlGcOption("end_date"), + resultType = filling.resultType, + setSize = filling.setSize, + startDate = filling.startDate, + endDate = filling.endDate, description = asTextOption("description"), statusType = statusType, statusMessage = statusMessage, problemDigest = problemDigest ) } def errorResult(description: Option[String], statusMessage: String,problemDigest:ProblemDigest):QueryResult = { QueryResult( resultId = 0L, instanceId = 0L, resultType = None, setSize = 0L, startDate = None, endDate = None, description = description, statusType = StatusType.Error, statusMessage = Option(statusMessage), problemDigest = Option(problemDigest)) } def errorResult(description: Option[String], statusMessage: String,problem:Problem):QueryResult = { val problemDigest = problem.toDigest QueryResult( resultId = 0L, instanceId = 0L, resultType = None, setSize = 0L, startDate = None, endDate = None, description = description, statusType = StatusType.Error, statusMessage = Option(statusMessage), problemDigest = Option(problemDigest)) } /** * For reconstituting errorResults from a database */ //todo remove and replace with real Problems def errorResult(description:Option[String], statusMessage:String, codec:String,stampText:String, summary:String, digestDescription:String,detailsXml:NodeSeq): QueryResult = { val problemDigest = ProblemDigest(codec,stampText,summary,digestDescription,detailsXml) QueryResult( resultId = 0L, instanceId = 0L, resultType = None, setSize = 0L, startDate = None, endDate = None, description = description, statusType = StatusType.Error, statusMessage = Option(statusMessage), problemDigest = Option(problemDigest)) } } case class ErrorStatusFromCrc(messageFromCrC:Option[String], xmlResponseFromCrc: String) extends AbstractProblem(ProblemSources.Adapter) { override val summary: String = "The I2B2 CRC reported an internal error." override val description:String = s"The I2B2 CRC responded with status type ERROR ${messageFromCrC.fold(" but no message")(message => s"and a message of '$message'")}" override val detailsXml = <details> CRC's Response is {xmlResponseFromCrc} </details> } diff --git a/commons/protocol/src/test/scala/net/shrine/protocol/RunQueryResponseTest.scala b/commons/protocol/src/test/scala/net/shrine/protocol/RunQueryResponseTest.scala index 95e079cce..30a2c1cdc 100644 --- a/commons/protocol/src/test/scala/net/shrine/protocol/RunQueryResponseTest.scala +++ b/commons/protocol/src/test/scala/net/shrine/protocol/RunQueryResponseTest.scala @@ -1,254 +1,254 @@ package net.shrine.protocol import net.shrine.problem.ProblemDigest import scala.xml.NodeSeq import org.junit.Test import net.shrine.protocol.query.QueryDefinition import net.shrine.protocol.query.Term import net.shrine.util.XmlDateHelper import net.shrine.util.XmlUtil /** * * * @author Justin Quan * @see http://chip.org * Date: 8/12/11 */ //noinspection EmptyParenMethodOverridenAsParameterless,EmptyParenMethodAccessedAsParameterless,UnitMethodIsParameterless final class RunQueryResponseTest extends ShrineResponseI2b2SerializableValidator { private val queryId = 1L private val queryName = "queryName" private val userId = "user" private val groupId = "group" private val createDate = XmlDateHelper.now private val requestQueryDef = QueryDefinition(queryName, Term("""\\i2b2\i2b2\Demographics\Age\0-9 years old\""")) private val queryInstanceId = 2L private val resultId = 3L private val setSize = 10L private val startDate = createDate private val endDate = createDate private val resultId2 = 4L private val resultType1 = ResultOutputType.PATIENT_COUNT_XML private val resultType2 = ResultOutputType.PATIENT_COUNT_XML private val statusType = QueryResult.StatusType.Finished override def messageBody: NodeSeq = { <message_body> <ns5:response xmlns="" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="ns5:master_instance_result_responseType"> <status> <condition type="DONE">DONE</condition> </status> <query_master> <query_master_id>{ queryId }</query_master_id> <name>{ queryName }</name> <user_id>{ userId }</user_id> <group_id>{ groupId }</group_id> <create_date>{ createDate }</create_date> <request_xml>{ requestQueryDef.toI2b2 }</request_xml> </query_master> <query_instance> <query_instance_id>{ queryInstanceId }</query_instance_id> <query_master_id>{ queryId }</query_master_id> <user_id>{ userId }</user_id> <group_id>{ groupId }</group_id> <query_status_type> <status_type_id>6</status_type_id> <name>COMPLETED</name> <description>COMPLETED</description> </query_status_type> </query_instance> <query_result_instance> <result_instance_id>{ resultId }</result_instance_id> <query_instance_id>{ queryInstanceId }</query_instance_id> <query_result_type> <result_type_id>4</result_type_id> <name>{ resultType1 }</name> <display_type>CATNUM</display_type> <visual_attribute_type>LA</visual_attribute_type> <description>Number of patients</description> </query_result_type> <set_size>{ setSize }</set_size> <start_date>{ startDate }</start_date> <end_date>{ endDate }</end_date> <query_status_type> <name>{ statusType }</name> <status_type_id>3</status_type_id> <description>FINISHED</description> </query_status_type> </query_result_instance> </ns5:response> </message_body> } private val qr1 = QueryResult( resultId = resultId, instanceId = queryInstanceId, resultType = Option(resultType1), setSize = setSize, startDate = Option(createDate), endDate = Option(createDate), description = None, statusType = statusType, statusMessage = Some(statusType.name), problemDigest = None ) private val runQueryResponse = XmlUtil.stripWhitespace { <runQueryResponse> <queryId>{ queryId }</queryId> <instanceId>{ queryInstanceId }</instanceId> <userId>{ userId }</userId> <groupId>{ groupId }</groupId> <requestXml>{ requestQueryDef.toXml }</requestXml> <createDate>{ createDate }</createDate> <queryResults> { qr1.toXml } </queryResults> </runQueryResponse> } import DefaultBreakdownResultOutputTypes.{ values => breakdownTypes } @Test def testFromXml: Unit = { val actual = RunQueryResponse.fromXml(breakdownTypes.toSet)(runQueryResponse).get actual.queryId should equal(queryId) actual.createDate should equal(createDate) actual.userId should equal(userId) actual.groupId should equal(groupId) actual.requestXml should equal(requestQueryDef) actual.queryInstanceId should equal(queryInstanceId) actual.results should equal(Seq(qr1)) actual.queryName should equal(queryName) } @Test def testToXml: Unit = { RunQueryResponse(queryId, createDate, userId, groupId, requestQueryDef, queryInstanceId, qr1).toXmlString should equal(runQueryResponse.toString) } @Test def testFromI2b2: Unit = { val translatedResponse = RunQueryResponse.fromI2b2(breakdownTypes.toSet)(response).get translatedResponse.queryId should equal(queryId) translatedResponse.createDate should equal(createDate) translatedResponse.userId should equal(userId) translatedResponse.groupId should equal(groupId) translatedResponse.requestXml should equal(requestQueryDef) translatedResponse.queryInstanceId should equal(queryInstanceId) translatedResponse.results should equal(Seq(qr1)) translatedResponse.queryName should equal(queryName) } @Test def testFromI2b2StringRequestXml: Unit = { def hackToProduceXml(statusType: QueryResult.StatusType): HasResponse = new HasResponse { //Produces a message body where the <request_xml> tag contains escaped XML as a String, as is produced by the CRC override def messageBody: NodeSeq = { <message_body> <ns5:response xmlns="" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="ns5:master_instance_result_responseType"> <status> <condition type="DONE">DONE</condition> </status> <query_master> <query_master_id>{ queryId }</query_master_id> <name>{ queryName }</name> <user_id>{ userId }</user_id> <group_id>{ groupId }</group_id> <create_date>{ createDate }</create_date> <!-- Because requestQueryDef is turned to a String, it will be escaped in the XML, --> <!-- the handling of which is what we want to test. --> <request_xml>{ requestQueryDef.toI2b2String }</request_xml> </query_master> <query_instance> <query_instance_id>{ queryInstanceId }</query_instance_id> <query_master_id>{ queryId }</query_master_id> <user_id>{ userId }</user_id> <group_id>{ groupId }</group_id> <query_status_type> <status_type_id>6</status_type_id> <name>COMPLETED</name> <description>COMPLETED</description> </query_status_type> </query_instance> <query_result_instance> <result_instance_id>{ resultId }</result_instance_id> <query_instance_id>{ queryInstanceId }</query_instance_id> <query_result_type> <name>{ resultType1 }</name> <result_type_id>1</result_type_id> <display_type>LIST</display_type> <visual_attribute_type>LA</visual_attribute_type> <description>Timeline</description> </query_result_type> <set_size>{ setSize }</set_size> <start_date>{ startDate }</start_date> <end_date>{ endDate }</end_date> <query_status_type> <name>{ statusType }</name> <status_type_id>3</status_type_id> <description>FINISHED</description> </query_status_type> </query_result_instance> <query_result_instance> <result_instance_id>{ resultId2 }</result_instance_id> <query_instance_id>{ queryInstanceId }</query_instance_id> <query_result_type> <name>{ resultType2 }</name> <result_type_id>4</result_type_id> <display_type>CATNUM</display_type> <visual_attribute_type>LA</visual_attribute_type> <description>Number of patients</description> </query_result_type> <set_size>{ setSize }</set_size> <start_date>{ startDate }</start_date> <end_date>{ endDate }</end_date> <query_status_type> <name>{ statusType }</name> <status_type_id>3</status_type_id> <description>FINISHED</description> </query_status_type> </query_result_instance> </ns5:response> </message_body> } } for { statusType <- QueryResult.StatusType.values } { doTestFromI2b2(hackToProduceXml(statusType).response, requestQueryDef, statusType) } } private def doTestFromI2b2(i2b2Response: NodeSeq, expectedQueryDef: AnyRef, expectedStatusType: QueryResult.StatusType, expectedProblemDigest:Option[ProblemDigest] = None) { val translatedResponse = RunQueryResponse.fromI2b2(breakdownTypes.toSet)(i2b2Response).get translatedResponse.queryId should equal(queryId) translatedResponse.createDate should equal(createDate) translatedResponse.userId should equal(userId) translatedResponse.groupId should equal(groupId) translatedResponse.requestXml should equal(expectedQueryDef) translatedResponse.queryInstanceId should equal(queryInstanceId) if(!expectedStatusType.isError) translatedResponse.results should equal(Seq(qr1.copy(statusType = expectedStatusType,problemDigest = expectedProblemDigest))) else { translatedResponse.results.size should equal(1) val result: QueryResult = translatedResponse.results.head - result.copy(problemDigest = None) should equal(qr1.copy(statusType = expectedStatusType)) + result.copy(problemDigest = None) should equal(qr1.copy(statusType = expectedStatusType,resultType = None,setSize = 0,startDate = None,endDate = None)) result.problemDigest.get.codec should equal(classOf[ErrorStatusFromCrc].getName) } translatedResponse.queryName should equal(queryName) translatedResponse.singleNodeResult.statusType should be(expectedStatusType) } @Test def testToI2b2 { RunQueryResponse(queryId, createDate, userId, groupId, requestQueryDef, queryInstanceId, qr1).toI2b2String should equal(response.toString) } } \ No newline at end of file