diff options
24 files changed, 1019 insertions, 108 deletions
diff --git a/src/main/scala/xyz/driver/pdsuicommon/acl/ACL.scala b/src/main/scala/xyz/driver/pdsuicommon/acl/ACL.scala index f2a0ef0..180ebf9 100644 --- a/src/main/scala/xyz/driver/pdsuicommon/acl/ACL.scala +++ b/src/main/scala/xyz/driver/pdsuicommon/acl/ACL.scala @@ -168,8 +168,10 @@ object ACL extends PhiLogging { object Intervention extends BaseACL( label = "intervention", + create = Set(TrialSummarizer, TrialAdmin), read = Set(TrialSummarizer, TrialAdmin), - update = Set(TrialSummarizer, TrialAdmin) + update = Set(TrialSummarizer, TrialAdmin), + delete = Set(TrialSummarizer, TrialAdmin) ) object InterventionType diff --git a/src/main/scala/xyz/driver/pdsuicommon/db/PostgresContext.scala b/src/main/scala/xyz/driver/pdsuicommon/db/PostgresContext.scala index cbb23d4..7bdfd1b 100644 --- a/src/main/scala/xyz/driver/pdsuicommon/db/PostgresContext.scala +++ b/src/main/scala/xyz/driver/pdsuicommon/db/PostgresContext.scala @@ -28,7 +28,7 @@ object PostgresContext extends PhiLogging { case Success(dataSource) => new PostgresContext(dataSource, settings) case Failure(NonFatal(e)) => logger.error(phi"Can not load dataSource, error: ${Unsafe(e.getClass.getName)}") - throw new IllegalArgumentException("Can not load dataSource from config. Check your database and config") + throw new IllegalArgumentException("Can not load dataSource from config. Check your database and config", e) } } diff --git a/src/main/scala/xyz/driver/pdsuicommon/db/QueryBuilder.scala b/src/main/scala/xyz/driver/pdsuicommon/db/QueryBuilder.scala index aa32166..0bf1ed6 100644 --- a/src/main/scala/xyz/driver/pdsuicommon/db/QueryBuilder.scala +++ b/src/main/scala/xyz/driver/pdsuicommon/db/QueryBuilder.scala @@ -57,10 +57,10 @@ sealed trait QueryBuilderParameters { def toSql(countQuery: Boolean, fields: Set[String], namingStrategy: NamingStrategy): (String, QueryBuilder.Binder) = { val escapedTableName = namingStrategy.table(tableData.tableName) val fieldsSql: String = if (countQuery) { - val suffix: String = (tableData.lastUpdateFieldName match { + val suffix: String = tableData.lastUpdateFieldName match { case Some(lastUpdateField) => s", max($escapedTableName.${namingStrategy.column(lastUpdateField)})" case None => "" - }) + } "count(*)" + suffix } else { if (fields == QueryBuilderParameters.AllFields) { diff --git a/src/main/scala/xyz/driver/pdsuicommon/db/SlickPostgresQueryBuilder.scala b/src/main/scala/xyz/driver/pdsuicommon/db/SlickPostgresQueryBuilder.scala new file mode 100644 index 0000000..f882441 --- /dev/null +++ b/src/main/scala/xyz/driver/pdsuicommon/db/SlickPostgresQueryBuilder.scala @@ -0,0 +1,116 @@ +package xyz.driver.pdsuicommon.db + +import java.time.{LocalDateTime, ZoneOffset} + +import slick.driver.JdbcProfile +import slick.jdbc.GetResult +import xyz.driver.core.database.SlickDal +import xyz.driver.pdsuicommon.logging._ + +import scala.collection.breakOut +import scala.concurrent.ExecutionContext + +object SlickPostgresQueryBuilder extends PhiLogging { + + import xyz.driver.pdsuicommon.db.SlickQueryBuilder._ + + def apply[T](databaseName: String, + tableName: String, + lastUpdateFieldName: Option[String], + nullableFields: Set[String], + links: Set[SlickTableLink], + runner: Runner[T], + countRunner: CountRunner)(implicit sqlContext: SlickDal, + profile: JdbcProfile, + getResult: GetResult[T], + ec: ExecutionContext): SlickPostgresQueryBuilder[T] = { + val parameters = SlickPostgresQueryBuilderParameters( + databaseName = databaseName, + tableData = TableData(tableName, lastUpdateFieldName, nullableFields), + links = links.map(x => x.foreignTableName -> x)(breakOut) + ) + new SlickPostgresQueryBuilder[T](parameters)(runner, countRunner) + } + + def apply[T](databaseName: String, + tableName: String, + lastUpdateFieldName: Option[String], + nullableFields: Set[String], + links: Set[SlickTableLink])(implicit sqlContext: SlickDal, + profile: JdbcProfile, + getResult: GetResult[T], + ec: ExecutionContext): SlickPostgresQueryBuilder[T] = { + apply[T](databaseName, + tableName, + SlickQueryBuilderParameters.AllFields, + lastUpdateFieldName, + nullableFields, + links) + } + + def apply[T](databaseName: String, + tableName: String, + fields: Set[String], + lastUpdateFieldName: Option[String], + nullableFields: Set[String], + links: Set[SlickTableLink])(implicit sqlContext: SlickDal, + profile: JdbcProfile, + getResult: GetResult[T], + ec: ExecutionContext): SlickPostgresQueryBuilder[T] = { + + val runner: Runner[T] = { parameters => + val sql = parameters.toSql(countQuery = false, fields = fields).as[T] + logger.debug(phi"${Unsafe(sql)}") + sqlContext.execute(sql) + } + + val countRunner: CountRunner = { parameters => + implicit val getCountResult: GetResult[(Int, Option[LocalDateTime])] = GetResult({ r => + val count = r.rs.getInt(1) + val lastUpdate = if (parameters.tableData.lastUpdateFieldName.isDefined) { + Option(r.rs.getTimestamp(2)).map(timestampToLocalDateTime) + } else None + (count, lastUpdate) + }) + val sql = parameters.toSql(countQuery = true).as[(Int, Option[LocalDateTime])] + logger.debug(phi"${Unsafe(sql)}") + sqlContext.execute(sql).map(_.head) + } + + apply[T]( + databaseName = databaseName, + tableName = tableName, + lastUpdateFieldName = lastUpdateFieldName, + nullableFields = nullableFields, + links = links, + runner = runner, + countRunner = countRunner + ) + } + + def timestampToLocalDateTime(timestamp: java.sql.Timestamp): LocalDateTime = { + LocalDateTime.ofInstant(timestamp.toInstant, ZoneOffset.UTC) + } +} + +class SlickPostgresQueryBuilder[T](parameters: SlickPostgresQueryBuilderParameters)( + implicit runner: SlickQueryBuilder.Runner[T], + countRunner: SlickQueryBuilder.CountRunner) + extends SlickQueryBuilder[T](parameters) { + + def withFilter(newFilter: SearchFilterExpr): SlickQueryBuilder[T] = { + new SlickPostgresQueryBuilder[T](parameters.copy(filter = newFilter)) + } + + def withSorting(newSorting: Sorting): SlickQueryBuilder[T] = { + new SlickPostgresQueryBuilder[T](parameters.copy(sorting = newSorting)) + } + + def withPagination(newPagination: Pagination): SlickQueryBuilder[T] = { + new SlickPostgresQueryBuilder[T](parameters.copy(pagination = Some(newPagination))) + } + + def resetPagination: SlickQueryBuilder[T] = { + new SlickPostgresQueryBuilder[T](parameters.copy(pagination = None)) + } +} diff --git a/src/main/scala/xyz/driver/pdsuicommon/db/SlickQueryBuilder.scala b/src/main/scala/xyz/driver/pdsuicommon/db/SlickQueryBuilder.scala new file mode 100644 index 0000000..ab2757b --- /dev/null +++ b/src/main/scala/xyz/driver/pdsuicommon/db/SlickQueryBuilder.scala @@ -0,0 +1,358 @@ +package xyz.driver.pdsuicommon.db + +import java.sql.PreparedStatement +import java.time.LocalDateTime + +import slick.driver.JdbcProfile +import slick.jdbc.{PositionedParameters, SQLActionBuilder, SetParameter} +import xyz.driver.pdsuicommon.db.Sorting.{Dimension, Sequential} +import xyz.driver.pdsuicommon.db.SortingOrder.{Ascending, Descending} + +import scala.concurrent.{ExecutionContext, Future} + +object SlickQueryBuilder { + + type Runner[T] = SlickQueryBuilderParameters => Future[Seq[T]] + + type CountResult = Future[(Int, Option[LocalDateTime])] + + type CountRunner = SlickQueryBuilderParameters => CountResult + + /** + * Binder for PreparedStatement + */ + type Binder = PreparedStatement => PreparedStatement + + final case class TableData(tableName: String, + lastUpdateFieldName: Option[String] = None, + nullableFields: Set[String] = Set.empty) + + val AllFields = Set("*") + + implicit class SQLActionBuilderConcat(a: SQLActionBuilder) { + def concat(b: SQLActionBuilder): SQLActionBuilder = { + SQLActionBuilder(a.queryParts ++ b.queryParts, new SetParameter[Unit] { + def apply(p: Unit, pp: PositionedParameters): Unit = { + a.unitPConv.apply(p, pp) + b.unitPConv.apply(p, pp) + } + }) + } + } +} + +final case class SlickTableLink(keyColumnName: String, foreignTableName: String, foreignKeyColumnName: String) + +object SlickQueryBuilderParameters { + val AllFields = Set("*") +} + +sealed trait SlickQueryBuilderParameters { + import SlickQueryBuilder._ + + def databaseName: String + def tableData: SlickQueryBuilder.TableData + def links: Map[String, SlickTableLink] + def filter: SearchFilterExpr + def sorting: Sorting + def pagination: Option[Pagination] + + def qs: String + + def findLink(tableName: String): SlickTableLink = links.get(tableName) match { + case None => throw new IllegalArgumentException(s"Cannot find a link for `$tableName`") + case Some(link) => link + } + + def toSql(countQuery: Boolean = false)(implicit profile: JdbcProfile): SQLActionBuilder = { + toSql(countQuery, QueryBuilderParameters.AllFields) + } + + def toSql(countQuery: Boolean, fields: Set[String])(implicit profile: JdbcProfile): SQLActionBuilder = { + import profile.api._ + val escapedTableName = s"""$qs$databaseName$qs.$qs${tableData.tableName}$qs""" + val fieldsSql: String = if (countQuery) { + val suffix: String = tableData.lastUpdateFieldName match { + case Some(lastUpdateField) => s", max($escapedTableName.$qs$lastUpdateField$qs)" + case None => "" + } + s"count(*) $suffix" + } else { + if (fields == SlickQueryBuilderParameters.AllFields) { + s"$escapedTableName.*" + } else { + fields + .map { field => + s"$escapedTableName.$qs$field$qs" + } + .mkString(", ") + } + } + val where = filterToSql(escapedTableName, filter) + val orderBy = sortingToSql(escapedTableName, sorting) + + val limitSql = limitToSql() + + val sql = sql"""select #$fieldsSql from #$escapedTableName""" + + val filtersTableLinks: Seq[SlickTableLink] = { + import SearchFilterExpr._ + def aux(expr: SearchFilterExpr): Seq[SlickTableLink] = expr match { + case Atom.TableName(tableName) => List(findLink(tableName)) + case Intersection(xs) => xs.flatMap(aux) + case Union(xs) => xs.flatMap(aux) + case _ => Nil + } + aux(filter) + } + + val sortingTableLinks: Seq[SlickTableLink] = Sorting.collect(sorting) { + case Dimension(Some(foreignTableName), _, _) => findLink(foreignTableName) + } + + // Combine links from sorting and filter without duplicates + val foreignTableLinks = (filtersTableLinks ++ sortingTableLinks).distinct + + def fkSql(fkLinksSql: SQLActionBuilder, tableLinks: Seq[SlickTableLink]): SQLActionBuilder = { + if (tableLinks.nonEmpty) { + tableLinks.head match { + case SlickTableLink(keyColumnName, foreignTableName, foreignKeyColumnName) => + val escapedForeignTableName = s"$qs$databaseName$qs.$qs$foreignTableName$qs" + val join = sql""" inner join #$escapedForeignTableName + on #$escapedTableName.#$qs#$keyColumnName#$qs=#$escapedForeignTableName.#$qs#$foreignKeyColumnName#$qs""" + fkSql(fkLinksSql concat join, tableLinks.tail) + } + } else fkLinksSql + } + val foreignTableLinksSql = fkSql(sql"", foreignTableLinks) + + val whereSql = if (where.queryParts.size > 1) { + sql" where " concat where + } else sql"" + + val orderSql = if (orderBy.nonEmpty && !countQuery) { + sql" order by #$orderBy" + } else sql"" + + val limSql = if (limitSql.queryParts.size > 1 && !countQuery) { + sql" " concat limitSql + } else sql"" + + sql concat foreignTableLinksSql concat whereSql concat orderSql concat limSql + } + + /** + * Converts filter expression to SQL expression. + * + * @return Returns SQL string and list of values for binding in prepared statement. + */ + protected def filterToSql(escapedTableName: String, filter: SearchFilterExpr)( + implicit profile: JdbcProfile): SQLActionBuilder = { + import SearchFilterBinaryOperation._ + import SearchFilterExpr._ + import profile.api._ + + def isNull(string: AnyRef) = Option(string).isEmpty || string.toString.toLowerCase == "null" + + def escapeDimension(dimension: SearchFilterExpr.Dimension) = { + s"$escapedTableName.$qs${dimension.name}$qs" + } + + def filterToSqlMultiple(operands: Seq[SearchFilterExpr]) = operands.collect { + case x if !SearchFilterExpr.isEmpty(x) => filterToSql(escapedTableName, x) + } + + def multipleSqlToAction(first: Boolean, + op: String, + conditions: Seq[SQLActionBuilder], + sql: SQLActionBuilder): SQLActionBuilder = { + if (conditions.nonEmpty) { + val condition = conditions.head + if (first) { + multipleSqlToAction(false, op, conditions.tail, condition) + } else { + multipleSqlToAction(false, op, conditions.tail, sql concat sql" #${op} " concat condition) + } + } else sql + } + + filter match { + case x if isEmpty(x) => + sql"" + + case AllowAll => + sql"1" + + case DenyAll => + sql"0" + + case Atom.Binary(dimension, Eq, value) if isNull(value) => + sql"#${escapeDimension(dimension)} is NULL" + + case Atom.Binary(dimension, NotEq, value) if isNull(value) => + sql"#${escapeDimension(dimension)} is not NULL" + + case Atom.Binary(dimension, NotEq, value) if tableData.nullableFields.contains(dimension.name) => + // In MySQL NULL <> Any === NULL + // So, to handle NotEq for nullable fields we need to use more complex SQL expression. + // http://dev.mysql.com/doc/refman/5.7/en/working-with-null.html + val escapedColumn = escapeDimension(dimension) + sql"(#${escapedColumn} is null or #${escapedColumn} != ${value.toString})" + + case Atom.Binary(dimension, op, value) => + val operator = op match { + case Eq => sql"=" + case NotEq => sql"!=" + case Like => sql" like " + case Gt => sql">" + case GtEq => sql">=" + case Lt => sql"<" + case LtEq => sql"<=" + } + sql"#${escapeDimension(dimension)}" concat operator concat sql"""${value.toString}""" + + case Atom.NAry(dimension, op, values) => + val sqlOp = op match { + case SearchFilterNAryOperation.In => sql" in " + case SearchFilterNAryOperation.NotIn => sql" not in " + } + + val formattedValues = if (values.nonEmpty) { + val condition = s"(${values.map(v => "'" + v.toString + "'").mkString(",")})" + sql"#${condition}" + } else sql"NULL" + sql"#${escapeDimension(dimension)}" concat sqlOp concat formattedValues + + case Intersection(operands) => + val filter = multipleSqlToAction(true, "and", filterToSqlMultiple(operands), sql"") + sql"(" concat filter concat sql")" + + case Union(operands) => + val filter = multipleSqlToAction(true, "or", filterToSqlMultiple(operands), sql"") + sql"(" concat filter concat sql")" + } + } + + protected def limitToSql()(implicit profile: JdbcProfile): SQLActionBuilder + + /** + * @param escapedMainTableName Should be escaped + */ + protected def sortingToSql(escapedMainTableName: String, sorting: Sorting)(implicit profile: JdbcProfile): String = { + sorting match { + case Dimension(optSortingTableName, field, order) => + val sortingTableName = + optSortingTableName.map(x => s"$qs$databaseName$qs.$qs$x$qs").getOrElse(escapedMainTableName) + val fullName = s"$sortingTableName.$qs$field$qs" + + s"$fullName ${orderToSql(order)}" + + case Sequential(xs) => + xs.map(sortingToSql(escapedMainTableName, _)).mkString(", ") + } + } + + protected def orderToSql(x: SortingOrder): String = x match { + case Ascending => "asc" + case Descending => "desc" + } + + protected def binder(bindings: List[AnyRef])(bind: PreparedStatement): PreparedStatement = { + bindings.zipWithIndex.foreach { + case (binding, index) => + bind.setObject(index + 1, binding) + } + + bind + } + +} + +final case class SlickPostgresQueryBuilderParameters(databaseName: String, + tableData: SlickQueryBuilder.TableData, + links: Map[String, SlickTableLink] = Map.empty, + filter: SearchFilterExpr = SearchFilterExpr.Empty, + sorting: Sorting = Sorting.Empty, + pagination: Option[Pagination] = None) + extends SlickQueryBuilderParameters { + + def limitToSql()(implicit profile: JdbcProfile): SQLActionBuilder = { + import profile.api._ + pagination.map { pagination => + val startFrom = (pagination.pageNumber - 1) * pagination.pageSize + sql"limit #${pagination.pageSize} OFFSET #$startFrom" + } getOrElse (sql"") + } + + val qs = """"""" + +} + +/** + * @param links Links to another tables grouped by foreignTableName + */ +final case class SlickMysqlQueryBuilderParameters(databaseName: String, + tableData: SlickQueryBuilder.TableData, + links: Map[String, SlickTableLink] = Map.empty, + filter: SearchFilterExpr = SearchFilterExpr.Empty, + sorting: Sorting = Sorting.Empty, + pagination: Option[Pagination] = None) + extends SlickQueryBuilderParameters { + + def limitToSql()(implicit profile: JdbcProfile): SQLActionBuilder = { + import profile.api._ + pagination + .map { pagination => + val startFrom = (pagination.pageNumber - 1) * pagination.pageSize + sql"limit #$startFrom, #${pagination.pageSize}" + } + .getOrElse(sql"") + } + + val qs = """`""" + +} + +abstract class SlickQueryBuilder[T](val parameters: SlickQueryBuilderParameters)( + implicit runner: SlickQueryBuilder.Runner[T], + countRunner: SlickQueryBuilder.CountRunner) { + + def run()(implicit ec: ExecutionContext): Future[Seq[T]] = runner(parameters) + + def runCount()(implicit ec: ExecutionContext): SlickQueryBuilder.CountResult = countRunner(parameters) + + /** + * Runs the query and returns total found rows without considering of pagination. + */ + def runWithCount()(implicit ec: ExecutionContext): Future[(Seq[T], Int, Option[LocalDateTime])] = { + for { + all <- run + (total, lastUpdate) <- runCount + } yield (all, total, lastUpdate) + } + + def withFilter(newFilter: SearchFilterExpr): SlickQueryBuilder[T] + + def withFilter(filter: Option[SearchFilterExpr]): SlickQueryBuilder[T] = { + filter.fold(this)(withFilter) + } + + def resetFilter: SlickQueryBuilder[T] = withFilter(SearchFilterExpr.Empty) + + def withSorting(newSorting: Sorting): SlickQueryBuilder[T] + + def withSorting(sorting: Option[Sorting]): SlickQueryBuilder[T] = { + sorting.fold(this)(withSorting) + } + + def resetSorting: SlickQueryBuilder[T] = withSorting(Sorting.Empty) + + def withPagination(newPagination: Pagination): SlickQueryBuilder[T] + + def withPagination(pagination: Option[Pagination]): SlickQueryBuilder[T] = { + pagination.fold(this)(withPagination) + } + + def resetPagination: SlickQueryBuilder[T] + +} diff --git a/src/main/scala/xyz/driver/pdsuicommon/domain/FuzzyValue.scala b/src/main/scala/xyz/driver/pdsuicommon/domain/FuzzyValue.scala index 4e98f40..36c3de7 100644 --- a/src/main/scala/xyz/driver/pdsuicommon/domain/FuzzyValue.scala +++ b/src/main/scala/xyz/driver/pdsuicommon/domain/FuzzyValue.scala @@ -10,21 +10,31 @@ object FuzzyValue { case object No extends FuzzyValue case object Maybe extends FuzzyValue - val All: Set[FuzzyValue] = Set(Yes, No, Maybe) + private val yes = "Yes" + private val no = "No" + private val maybe = "Maybe" - def fromBoolean(x: Boolean): FuzzyValue = if (x) Yes else No + val All: Set[FuzzyValue] = + Set(Yes, No, Maybe) - implicit def toPhiString(x: FuzzyValue): PhiString = Unsafe(Utils.getClassSimpleName(x.getClass)) + def fromBoolean(x: Boolean): FuzzyValue = + if (x) Yes else No + + implicit def toPhiString(x: FuzzyValue): PhiString = + Unsafe(Utils.getClassSimpleName(x.getClass)) val fromString: PartialFunction[String, FuzzyValue] = { - case "Yes" => Yes - case "No" => No - case "Maybe" => Maybe + case fuzzy => + fuzzy.toLowerCase.capitalize match { + case `yes` => Yes + case `no` => No + case `maybe` => Maybe + } } def valueToString(x: FuzzyValue): String = x match { - case Yes => "Yes" - case No => "No" - case Maybe => "Maybe" + case Yes => `yes` + case No => `no` + case Maybe => `maybe` } } diff --git a/src/main/scala/xyz/driver/pdsuicommon/parsers/SearchFilterParser.scala b/src/main/scala/xyz/driver/pdsuicommon/parsers/SearchFilterParser.scala index 8aff397..e0adeb8 100644 --- a/src/main/scala/xyz/driver/pdsuicommon/parsers/SearchFilterParser.scala +++ b/src/main/scala/xyz/driver/pdsuicommon/parsers/SearchFilterParser.scala @@ -13,10 +13,15 @@ import scala.util.Try object SearchFilterParser { private object BinaryAtomFromTuple { - def unapply(input: (SearchFilterExpr.Dimension, (String, String))): Option[SearchFilterExpr.Atom.Binary] = { + def unapply(input: (SearchFilterExpr.Dimension, (String, Any))): Option[SearchFilterExpr.Atom.Binary] = { val (dimensionName, (strOperation, value)) = input + val updatedValue = value match { + case s: String => s.safeTrim + case a => a + } + parseOperation(strOperation.toLowerCase).map { op => - SearchFilterExpr.Atom.Binary(dimensionName, op, value.safeTrim) + SearchFilterExpr.Atom.Binary(dimensionName, op, updatedValue.asInstanceOf[AnyRef]) } } } @@ -68,7 +73,7 @@ object SearchFilterParser { } private val numericOperatorParser: Parser[String] = { - P((IgnoreCase("gt") | IgnoreCase("lt")) ~ IgnoreCase("eq").?).! + P(IgnoreCase("eq") | ((IgnoreCase("gt") | IgnoreCase("lt")) ~ IgnoreCase("eq").?)).! } private val naryOperatorParser: Parser[String] = P(IgnoreCase("in")).! @@ -91,10 +96,12 @@ object SearchFilterParser { private val nAryValueParser: Parser[String] = P(CharPred(_ != ',').rep(min = 1).!) + private val longParser: Parser[Long] = P(CharIn('0' to '9').rep(1).!.map(_.toLong)) + private val binaryAtomParser: Parser[SearchFilterExpr.Atom.Binary] = P( dimensionParser ~ whitespaceParser ~ ( - (commonOperatorParser.! ~/ whitespaceParser ~/ AnyChar.rep(min = 1).!) - | (numericOperatorParser.! ~/ whitespaceParser ~/ numberParser.!) + (numericOperatorParser.! ~ whitespaceParser ~ (longParser | numberParser.!)) | + (commonOperatorParser.! ~ whitespaceParser ~ AnyChar.rep(min = 1).!) ) ~ End ).map { case BinaryAtomFromTuple(atom) => atom diff --git a/src/main/scala/xyz/driver/pdsuicommon/utils/CustomSwaggerJsonFormats.scala b/src/main/scala/xyz/driver/pdsuicommon/utils/CustomSwaggerJsonFormats.scala index c1a2c7c..6c87858 100644 --- a/src/main/scala/xyz/driver/pdsuicommon/utils/CustomSwaggerJsonFormats.scala +++ b/src/main/scala/xyz/driver/pdsuicommon/utils/CustomSwaggerJsonFormats.scala @@ -42,7 +42,7 @@ object CustomSwaggerJsonFormats { xyz.driver.pdsuidomain.fakes.entities.trialcuration.nextTrialIssue()), classOf[RichCriterion] -> richCriterionFormat.write( xyz.driver.pdsuidomain.fakes.entities.trialcuration.nextRichCriterion()), - classOf[InterventionWithArms] -> interventionWriter.write( + classOf[InterventionWithArms] -> interventionFormat.write( xyz.driver.pdsuidomain.fakes.entities.trialcuration.nextInterventionWithArms()), classOf[InterventionType] -> interventionTypeFormat.write( xyz.driver.pdsuidomain.fakes.entities.trialcuration.nextInterventionType()), diff --git a/src/main/scala/xyz/driver/pdsuidomain/entities/Document.scala b/src/main/scala/xyz/driver/pdsuidomain/entities/Document.scala index 1f73184..839fead 100644 --- a/src/main/scala/xyz/driver/pdsuidomain/entities/Document.scala +++ b/src/main/scala/xyz/driver/pdsuidomain/entities/Document.scala @@ -14,6 +14,8 @@ import xyz.driver.pdsuicommon.validation.Validators import xyz.driver.pdsuicommon.validation.Validators.Validator import xyz.driver.pdsuidomain.entities.Document.Meta +import scalaz.Equal + final case class ProviderType(id: LongId[ProviderType], name: String) object ProviderType { @@ -124,6 +126,8 @@ object DocumentType { } } + implicit def equal: Equal[DocumentType] = Equal.equal[DocumentType](_ == _) + implicit def toPhiString(x: DocumentType): PhiString = { import x._ phi"DocumentType(id=$id, name=${Unsafe(name)})" diff --git a/src/main/scala/xyz/driver/pdsuidomain/entities/Intervention.scala b/src/main/scala/xyz/driver/pdsuidomain/entities/Intervention.scala index cb677cf..9ccc267 100644 --- a/src/main/scala/xyz/driver/pdsuidomain/entities/Intervention.scala +++ b/src/main/scala/xyz/driver/pdsuidomain/entities/Intervention.scala @@ -2,10 +2,186 @@ package xyz.driver.pdsuidomain.entities import xyz.driver.pdsuicommon.domain.{LongId, StringId} import xyz.driver.pdsuicommon.logging._ +import xyz.driver.pdsuidomain.entities.InterventionType.DeliveryMethod +import xyz.driver.pdsuidomain.entities.InterventionType.DeliveryMethod._ -final case class InterventionType(id: LongId[InterventionType], name: String) +sealed trait InterventionType { + val id: LongId[InterventionType] + val name: String + val deliveryMethods: Set[DeliveryMethod] +} object InterventionType { + + final case object RadiationTherapy extends InterventionType { + val id: LongId[InterventionType] = LongId[InterventionType](1) + val name: String = "Radiation therapy" + val deliveryMethods: Set[DeliveryMethod] = commonMethods + } + + final case object Chemotherapy extends InterventionType { + val id: LongId[InterventionType] = LongId[InterventionType](2) + val name: String = "Chemotherapy" + val deliveryMethods: Set[DeliveryMethod] = commonMethods + } + + final case object TargetedTherapy extends InterventionType { + val id: LongId[InterventionType] = LongId[InterventionType](3) + val name: String = "Targeted therapy" + val deliveryMethods: Set[DeliveryMethod] = commonMethods + } + + final case object Immunotherapy extends InterventionType { + val id: LongId[InterventionType] = LongId[InterventionType](4) + val name: String = "Immunotherapy" + val deliveryMethods: Set[DeliveryMethod] = commonMethods + } + + final case object Surgery extends InterventionType { + val id: LongId[InterventionType] = LongId[InterventionType](5) + val name: String = "Surgery" + val deliveryMethods: Set[DeliveryMethod] = commonMethods + } + + final case object HormoneTherapy extends InterventionType { + val id: LongId[InterventionType] = LongId[InterventionType](6) + val name: String = "Hormone therapy" + val deliveryMethods: Set[DeliveryMethod] = commonMethods + } + + final case object Other extends InterventionType { + val id: LongId[InterventionType] = LongId[InterventionType](7) + val name: String = "Other" + val deliveryMethods: Set[DeliveryMethod] = commonMethods + } + + final case object Radiation extends InterventionType { + val id: LongId[InterventionType] = LongId[InterventionType](8) + val name: String = "Radiation" + val deliveryMethods: Set[DeliveryMethod] = Set( + ExternalRadiationTherapy, + Brachytherapy, + SystemicRadiationTherapyIV, + SystemicRadiationTherapyOral, + ProtonBeamTherapy + ) + } + + final case object SurgeryProcedure extends InterventionType { + val id: LongId[InterventionType] = LongId[InterventionType](9) + val name: String = "Surgery/Procedure" + val deliveryMethods: Set[DeliveryMethod] = Set( + RadioFrequencyAblationRFA, + Cryoablation, + TherapeuticConventionalSurgery, + RoboticAssistedLaparoscopicSurgery + ) + } + + def typeFromString: PartialFunction[String, InterventionType] = { + case "Radiation therapy" => RadiationTherapy + case "Chemotherapy" => Chemotherapy + case "Targeted therapy" => TargetedTherapy + case "Immunotherapy" => Immunotherapy + case "Surgery" => Surgery + case "Hormone therapy" => HormoneTherapy + case "Other" => Other + case "Radiation" => Radiation + case "Surgery/Procedure" => SurgeryProcedure + } + + sealed trait DeliveryMethod + object DeliveryMethod { + case object IntravenousInfusionIV extends DeliveryMethod + case object IntramuscularInjection extends DeliveryMethod + case object SubcutaneousInjection extends DeliveryMethod + case object IntradermalInjection extends DeliveryMethod + case object SpinalInjection extends DeliveryMethod + case object Oral extends DeliveryMethod + case object Topical extends DeliveryMethod + case object TransdermalPatch extends DeliveryMethod + case object Inhalation extends DeliveryMethod + case object Rectal extends DeliveryMethod + case object ExternalRadiationTherapy extends DeliveryMethod + case object Brachytherapy extends DeliveryMethod + case object SystemicRadiationTherapyIV extends DeliveryMethod + case object SystemicRadiationTherapyOral extends DeliveryMethod + case object ProtonBeamTherapy extends DeliveryMethod + case object RadioFrequencyAblationRFA extends DeliveryMethod + case object Cryoablation extends DeliveryMethod + case object TherapeuticConventionalSurgery extends DeliveryMethod + case object RoboticAssistedLaparoscopicSurgery extends DeliveryMethod + + def fromString: PartialFunction[String, DeliveryMethod] = { + case "Intravenous Infusion (IV)" => IntravenousInfusionIV + case "Intramuscular Injection" => IntramuscularInjection + case "Subcutaneous Injection" => SubcutaneousInjection + case "Intradermal Injection" => IntradermalInjection + case "Spinal Injection" => SpinalInjection + case "Oral" => Oral + case "Topical" => Topical + case "Transdermal Patch" => TransdermalPatch + case "Inhalation" => Inhalation + case "Rectal" => Rectal + case "External Radiation Therapy" => ExternalRadiationTherapy + case "Brachytherapy" => Brachytherapy + case "Systemic Radiation Therapy (IV)" => SystemicRadiationTherapyIV + case "Systemic Radiation Therapy (Oral)" => SystemicRadiationTherapyOral + case "Proton Beam Therapy" => ProtonBeamTherapy + case "Radio-Frequency Ablation (RFA)" => RadioFrequencyAblationRFA + case "Cryoablation" => Cryoablation + case "Therapeutic Conventional Surgery" => TherapeuticConventionalSurgery + case "Robotic Assisted Laparoscopic Surgery" => RoboticAssistedLaparoscopicSurgery + } + + def methodToString(x: DeliveryMethod): String = x match { + case IntravenousInfusionIV => "Intravenous Infusion (IV)" + case IntramuscularInjection => "Intramuscular Injection" + case SubcutaneousInjection => "Subcutaneous Injection" + case IntradermalInjection => "Intradermal Injection" + case SpinalInjection => "Spinal Injection" + case Oral => "Oral" + case Topical => "Topical" + case TransdermalPatch => "Transdermal Patch" + case Inhalation => "Inhalation" + case Rectal => "Rectal" + case ExternalRadiationTherapy => "External Radiation Therapy" + case Brachytherapy => "Brachytherapy" + case SystemicRadiationTherapyIV => "Systemic Radiation Therapy (IV)" + case SystemicRadiationTherapyOral => "Systemic Radiation Therapy (Oral)" + case ProtonBeamTherapy => "Proton Beam Therapy" + case RadioFrequencyAblationRFA => "Radio-Frequency Ablation (RFA)" + case Cryoablation => "Cryoablation" + case TherapeuticConventionalSurgery => "Therapeutic Conventional Surgery" + case RoboticAssistedLaparoscopicSurgery => "Robotic Assisted Laparoscopic Surgery" + } + } + + val commonMethods = Set[DeliveryMethod]( + IntravenousInfusionIV, + IntramuscularInjection, + SubcutaneousInjection, + IntradermalInjection, + SpinalInjection, + Oral, + Topical, + TransdermalPatch, + Inhalation, + Rectal + ) + + val All: Map[LongId[InterventionType], InterventionType] = Map[LongId[InterventionType], InterventionType]( + LongId[InterventionType](1) -> RadiationTherapy, + LongId[InterventionType](2) -> Chemotherapy, + LongId[InterventionType](3) -> TargetedTherapy, + LongId[InterventionType](4) -> Immunotherapy, + LongId[InterventionType](5) -> Surgery, + LongId[InterventionType](6) -> HormoneTherapy, + LongId[InterventionType](7) -> Other, + LongId[InterventionType](8) -> Radiation, + LongId[InterventionType](9) -> SurgeryProcedure + ) + implicit def toPhiString(x: InterventionType): PhiString = { import x._ phi"InterventionType(id=$id, name=${Unsafe(name)})" @@ -29,12 +205,23 @@ final case class Intervention(id: LongId[Intervention], originalType: Option[String], dosage: String, originalDosage: String, - isActive: Boolean) + isActive: Boolean, + deliveryMethod: Option[String]) { + def deliveryMethodIsCorrect: Boolean = { + if (this.typeId.nonEmpty) { + this.deliveryMethod.nonEmpty && + InterventionType.All + .getOrElse(this.typeId.get, throw new IllegalArgumentException(s"Not found Intervention type ${this.typeId}")) + .deliveryMethods + .contains(DeliveryMethod.fromString(this.deliveryMethod.get)) + } else true + } +} object Intervention { implicit def toPhiString(x: Intervention): PhiString = { import x._ - phi"Intervention(id=$id, trialId=$trialId, name=${Unsafe(name)}, typeId=$typeId, isActive=$isActive)" + phi"Intervention(id=$id, trialId=$trialId, name=${Unsafe(name)}, typeId=$typeId, isActive=$isActive, deliveryMethod=${Unsafe(deliveryMethod)})" } } diff --git a/src/main/scala/xyz/driver/pdsuidomain/entities/export/trial/ExportTrialWithLabels.scala b/src/main/scala/xyz/driver/pdsuidomain/entities/export/trial/ExportTrialWithLabels.scala index 60b74ff..cf55694 100644 --- a/src/main/scala/xyz/driver/pdsuidomain/entities/export/trial/ExportTrialWithLabels.scala +++ b/src/main/scala/xyz/driver/pdsuidomain/entities/export/trial/ExportTrialWithLabels.scala @@ -27,7 +27,7 @@ object ExportTrialWithLabels { def fromRaw(rawData: List[RawTrialLabel]): ExportTrialWithLabels = { val trials: Set[StringId[Trial]] = rawData.map(_.nctId)(breakOut) - assert(trials.size == 1, "There are more than one trials in the rawData") + assert(trials.size == 1, "There are more than one trial in the rawData") val trial = rawData.head ExportTrialWithLabels( @@ -43,9 +43,7 @@ object ExportTrialWithLabels { ExportTrialArm(armId, rawTrials.head.armName) }(breakOut), criteria = rawData - .groupBy { x => - (x.criterionId, x.labelId) - } + .groupBy(x => (x.criterionId, x.labelId)) .map { case (_, rawTrialLabels) => val armIds = rawTrialLabels.map(_.criterionArmId).toSet @@ -53,5 +51,4 @@ object ExportTrialWithLabels { }(breakOut) ) } - } diff --git a/src/main/scala/xyz/driver/pdsuidomain/fakes/entities/common.scala b/src/main/scala/xyz/driver/pdsuidomain/fakes/entities/common.scala index 52d7b98..b259b07 100644 --- a/src/main/scala/xyz/driver/pdsuidomain/fakes/entities/common.scala +++ b/src/main/scala/xyz/driver/pdsuidomain/fakes/entities/common.scala @@ -4,6 +4,7 @@ import java.time.{LocalDate, LocalDateTime, LocalTime} import xyz.driver.pdsuicommon.domain.{LongId, StringId, UuidId} import xyz.driver.pdsuidomain.entities.{Trial, TrialHistory} +import scala.util.Random object common { import xyz.driver.core.generators @@ -20,10 +21,11 @@ object common { def nextLocalDateTime = LocalDateTime.of(nextLocalDate, LocalTime.MIDNIGHT) - def nextLocalDate = { - val date = generators.nextDate() - LocalDate.of(date.year, date.month + 1, date.day + 1) - } + def nextLocalDate = LocalDate.of( + 1970 + Random.nextInt(68), + 1 + Random.nextInt(12), + 1 + Random.nextInt(28) // all months have at least 28 days + ) def nextCondition = generators.oneOf[Trial.Condition](Trial.Condition.All) diff --git a/src/main/scala/xyz/driver/pdsuidomain/fakes/entities/trialcuration.scala b/src/main/scala/xyz/driver/pdsuidomain/fakes/entities/trialcuration.scala index ecb6e0a..fe5bf09 100644 --- a/src/main/scala/xyz/driver/pdsuidomain/fakes/entities/trialcuration.scala +++ b/src/main/scala/xyz/driver/pdsuidomain/fakes/entities/trialcuration.scala @@ -7,6 +7,7 @@ import xyz.driver.pdsuidomain.services.CriterionService.RichCriterion object trialcuration { import xyz.driver.core.generators import common._ + import xyz.driver.pdsuidomain.entities.InterventionType._ def nextTrial(): Trial = Trial( id = nextStringId[Trial], @@ -76,7 +77,8 @@ object trialcuration { originalType = Option(generators.nextString()), dosage = generators.nextString(), originalDosage = generators.nextString(), - isActive = generators.nextBoolean() + isActive = generators.nextBoolean(), + deliveryMethod = Option(generators.nextString()) ) def nextInterventionArm(interventionId: LongId[Intervention]): InterventionArm = InterventionArm( @@ -129,9 +131,16 @@ object trialcuration { name = generators.nextString() ) - def nextInterventionType(): InterventionType = InterventionType( - id = nextLongId[InterventionType], - name = generators.nextString() + def nextInterventionType(): InterventionType = generators.oneOf[InterventionType]( + RadiationTherapy, + Chemotherapy, + TargetedTherapy, + Immunotherapy, + Surgery, + HormoneTherapy, + Other, + Radiation, + SurgeryProcedure ) } diff --git a/src/main/scala/xyz/driver/pdsuidomain/formats/json/intervention/ApiIntervention.scala b/src/main/scala/xyz/driver/pdsuidomain/formats/json/intervention/ApiIntervention.scala index f306a71..072ed25 100644 --- a/src/main/scala/xyz/driver/pdsuidomain/formats/json/intervention/ApiIntervention.scala +++ b/src/main/scala/xyz/driver/pdsuidomain/formats/json/intervention/ApiIntervention.scala @@ -12,6 +12,7 @@ final case class ApiIntervention(id: Long, isActive: Boolean, arms: List[Long], trialId: String, + deliveryMethod: Option[String], originalName: String, originalDosage: String, originalType: Option[String]) { @@ -26,7 +27,8 @@ final case class ApiIntervention(id: Long, originalType = this.originalType.map(id => id.toString), dosage = this.dosage, originalDosage = this.originalDosage, - isActive = this.isActive + isActive = this.isActive, + deliveryMethod = this.deliveryMethod ) InterventionWithArms(intervention, this.arms.map { armId => @@ -47,6 +49,7 @@ object ApiIntervention { (JsPath \ "isActive").format[Boolean] and (JsPath \ "arms").format[List[Long]] and (JsPath \ "trialId").format[String] and + (JsPath \ "deliveryMethod").formatNullable[String] and (JsPath \ "originalName").format[String] and (JsPath \ "originalDosage").format[String] and (JsPath \ "originalType").formatNullable[String] @@ -64,6 +67,7 @@ object ApiIntervention { isActive = intervention.isActive, arms = arms.map(_.armId.id), trialId = intervention.trialId.id, + deliveryMethod = intervention.deliveryMethod, originalName = intervention.originalName, originalDosage = intervention.originalDosage, originalType = intervention.originalType diff --git a/src/main/scala/xyz/driver/pdsuidomain/formats/json/intervention/ApiInterventionType.scala b/src/main/scala/xyz/driver/pdsuidomain/formats/json/intervention/ApiInterventionType.scala index ebef225..3db8bfa 100644 --- a/src/main/scala/xyz/driver/pdsuidomain/formats/json/intervention/ApiInterventionType.scala +++ b/src/main/scala/xyz/driver/pdsuidomain/formats/json/intervention/ApiInterventionType.scala @@ -2,23 +2,25 @@ package xyz.driver.pdsuidomain.formats.json.intervention import play.api.libs.functional.syntax._ import play.api.libs.json.{Format, JsPath} -import xyz.driver.pdsuicommon.domain.LongId import xyz.driver.pdsuidomain.entities.InterventionType +import xyz.driver.pdsuidomain.entities.InterventionType.DeliveryMethod -final case class ApiInterventionType(id: Long, name: String) { +final case class ApiInterventionType(id: Long, name: String, deliveryMethods: List[String]) { - def toDomain = InterventionType(id = LongId[InterventionType](id), name = name) + def toDomain = InterventionType.typeFromString(name) } object ApiInterventionType { implicit val format: Format[ApiInterventionType] = ( (JsPath \ "id").format[Long] and - (JsPath \ "name").format[String] + (JsPath \ "name").format[String] and + (JsPath \ "deliveryMethods").format[List[String]] )(ApiInterventionType.apply, unlift(ApiInterventionType.unapply)) def fromDomain(interventionType: InterventionType) = ApiInterventionType( id = interventionType.id.id, - name = interventionType.name + name = interventionType.name, + deliveryMethods = interventionType.deliveryMethods.map(DeliveryMethod.methodToString).toList ) } diff --git a/src/main/scala/xyz/driver/pdsuidomain/formats/json/intervention/ApiPartialIntervention.scala b/src/main/scala/xyz/driver/pdsuidomain/formats/json/intervention/ApiPartialIntervention.scala index aa55506..28a8555 100644 --- a/src/main/scala/xyz/driver/pdsuidomain/formats/json/intervention/ApiPartialIntervention.scala +++ b/src/main/scala/xyz/driver/pdsuidomain/formats/json/intervention/ApiPartialIntervention.scala @@ -1,42 +1,88 @@ package xyz.driver.pdsuidomain.formats.json.intervention -import xyz.driver.pdsuicommon.domain.LongId -import xyz.driver.pdsuidomain.entities.{InterventionArm, InterventionWithArms} +import play.api.data.validation.Invalid +import xyz.driver.pdsuicommon.domain.{LongId, StringId} +import xyz.driver.pdsuidomain.entities.{Intervention, InterventionArm, InterventionWithArms, Trial} import play.api.libs.functional.syntax._ import play.api.libs.json._ +import xyz.driver.pdsuicommon.json.JsonValidationException +import xyz.driver.pdsuicommon.validation.{AdditionalConstraints, JsonValidationErrors} -final case class ApiPartialIntervention(typeId: Option[Long], +import scala.collection.breakOut +import scala.util.Try + +final case class ApiPartialIntervention(name: Option[String], + trialId: Option[String], + typeId: Option[Long], dosage: Option[String], isActive: Option[Boolean], + deliveryMethod: Option[String], arms: Option[List[Long]]) { def applyTo(orig: InterventionWithArms): InterventionWithArms = { val origIntervention = orig.intervention - val draftArmList = arms.map(_.map(x => InterventionArm(LongId(x), orig.intervention.id))) + val draftArmList = arms.map(_.map(x => InterventionArm(armId = LongId(x), interventionId = orig.intervention.id))) orig.copy( intervention = origIntervention.copy( + name = name.getOrElse(origIntervention.name), typeId = typeId.map(LongId(_)).orElse(origIntervention.typeId), dosage = dosage.getOrElse(origIntervention.dosage), - isActive = isActive.getOrElse(origIntervention.isActive) + isActive = isActive.getOrElse(origIntervention.isActive), + deliveryMethod = deliveryMethod.orElse(origIntervention.deliveryMethod) ), arms = draftArmList.getOrElse(orig.arms) ) } + + def toDomain: Try[InterventionWithArms] = Try { + val validation = Map(JsPath \ "trialId" -> AdditionalConstraints.optionNonEmptyConstraint(trialId)) + + val validationErrors: JsonValidationErrors = validation.collect({ + case (fieldName, e: Invalid) => (fieldName, e.errors) + })(breakOut) + + if (validationErrors.isEmpty) { + InterventionWithArms( + intervention = Intervention( + id = LongId(0), + trialId = trialId.map(StringId[Trial]).get, + name = name.getOrElse(""), + originalName = name.getOrElse(""), + typeId = typeId.map(LongId(_)), + originalType = Option(""), + dosage = dosage.getOrElse(""), + originalDosage = dosage.getOrElse(""), + isActive = isActive.getOrElse(false), + deliveryMethod = deliveryMethod + ), + arms = + arms.map(_.map(x => InterventionArm(armId = LongId(x), interventionId = LongId(0)))).getOrElse(List.empty) + ) + } else { + throw new JsonValidationException(validationErrors) + } + } } object ApiPartialIntervention { private val reads: Reads[ApiPartialIntervention] = ( - (JsPath \ "typeId").readNullable[Long] and + (JsPath \ "name").readNullable[String] and + (JsPath \ "trialId").readNullable[String] and + (JsPath \ "typeId").readNullable[Long] and (JsPath \ "dosage").readNullable[String] and (JsPath \ "isActive").readNullable[Boolean] and + (JsPath \ "deliveryMethod").readNullable[String] and (JsPath \ "arms").readNullable[List[Long]] )(ApiPartialIntervention.apply _) private val writes: Writes[ApiPartialIntervention] = ( - (JsPath \ "typeId").writeNullable[Long] and + (JsPath \ "name").writeNullable[String] and + (JsPath \ "trialId").writeNullable[String] and + (JsPath \ "typeId").writeNullable[Long] and (JsPath \ "dosage").writeNullable[String] and (JsPath \ "isActive").writeNullable[Boolean] and + (JsPath \ "deliveryMethod").writeNullable[String] and (JsPath \ "arms").writeNullable[List[Long]] )(unlift(ApiPartialIntervention.unapply)) diff --git a/src/main/scala/xyz/driver/pdsuidomain/formats/json/record/ApiRecord.scala b/src/main/scala/xyz/driver/pdsuidomain/formats/json/record/ApiRecord.scala index b255892..e7b58cd 100644 --- a/src/main/scala/xyz/driver/pdsuidomain/formats/json/record/ApiRecord.scala +++ b/src/main/scala/xyz/driver/pdsuidomain/formats/json/record/ApiRecord.scala @@ -13,49 +13,11 @@ import play.api.libs.json._ import xyz.driver.pdsuicommon.json.JsonSerializer import xyz.driver.pdsuicommon.domain.{LongId, StringId, TextJson, UuidId} -final case class ApiRecord(id: Long, - patientId: String, - caseId: Option[String], - disease: String, - physician: Option[String], - lastUpdate: ZonedDateTime, - status: String, - previousStatus: Option[String], - assignee: Option[String], - previousAssignee: Option[String], - lastActiveUser: Option[String], - requestId: UUID, - meta: String) { - - private def extractStatus(status: String): Status = - Status - .fromString(status) - .getOrElse( - throw new NoSuchElementException(s"Status $status not found") - ) - - def toDomain = MedicalRecord( - id = LongId(this.id), - status = extractStatus(this.status), - previousStatus = this.previousStatus.map(extractStatus), - assignee = this.assignee.map(StringId(_)), - previousAssignee = this.previousAssignee.map(StringId(_)), - lastActiveUserId = this.lastActiveUser.map(StringId(_)), - patientId = UuidId(patientId), - requestId = RecordRequestId(this.requestId), - disease = this.disease, - caseId = caseId.map(CaseId(_)), - physician = this.physician, - meta = Some(TextJson(JsonSerializer.deserialize[List[MedicalRecord.Meta]](this.meta))), - predictedMeta = None, - predictedDocuments = None, - lastUpdate = this.lastUpdate.toLocalDateTime() - ) - -} - object ApiRecord { + private val emptyMeta: String = + "[]" + private val statusFormat = Format( Reads.StringReads.filter(ValidationError("unknown status")) { case x if MedicalRecordStatus.statusFromString.isDefinedAt(x) => true @@ -95,6 +57,52 @@ object ApiRecord { previousAssignee = record.previousAssignee.map(_.id), lastActiveUser = record.lastActiveUserId.map(_.id), requestId = record.requestId.id, - meta = record.meta.map(x => JsonSerializer.serialize(x.content)).getOrElse("[]") + meta = record.meta.map(x => JsonSerializer.serialize(x.content)).getOrElse(emptyMeta) + ) +} + +final case class ApiRecord(id: Long, + patientId: String, + caseId: Option[String], + disease: String, + physician: Option[String], + lastUpdate: ZonedDateTime, + status: String, + previousStatus: Option[String], + assignee: Option[String], + previousAssignee: Option[String], + lastActiveUser: Option[String], + requestId: UUID, + meta: String) { + + private def extractStatus(status: String): Status = + Status + .fromString(status) + .getOrElse( + throw new NoSuchElementException(s"Status $status not found") + ) + + def toDomain = MedicalRecord( + id = LongId(this.id), + status = extractStatus(this.status), + previousStatus = this.previousStatus.map(extractStatus), + assignee = this.assignee.map(StringId(_)), + previousAssignee = this.previousAssignee.map(StringId(_)), + lastActiveUserId = this.lastActiveUser.map(StringId(_)), + patientId = UuidId(patientId), + requestId = RecordRequestId(this.requestId), + disease = this.disease, + caseId = caseId.map(CaseId(_)), + physician = this.physician, + meta = { + if (this.meta == ApiRecord.emptyMeta) { + None + } else { + Some(TextJson(JsonSerializer.deserialize[List[MedicalRecord.Meta]](this.meta))) + } + }, + predictedMeta = None, + predictedDocuments = None, + lastUpdate = this.lastUpdate.toLocalDateTime() ) } diff --git a/src/main/scala/xyz/driver/pdsuidomain/formats/json/record/ApiUpdateRecord.scala b/src/main/scala/xyz/driver/pdsuidomain/formats/json/record/ApiUpdateRecord.scala index 47bc493..05d5a60 100644 --- a/src/main/scala/xyz/driver/pdsuidomain/formats/json/record/ApiUpdateRecord.scala +++ b/src/main/scala/xyz/driver/pdsuidomain/formats/json/record/ApiUpdateRecord.scala @@ -37,7 +37,7 @@ object ApiUpdateRecord { JsSuccess(Json.stringify(x)) }) .map { - case Tristate.Present("{}") => Tristate.Absent + case Tristate.Present("[]") => Tristate.Absent case x => x } )(ApiUpdateRecord.apply _) diff --git a/src/main/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/intervention.scala b/src/main/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/intervention.scala index 9314391..62cb9fa 100644 --- a/src/main/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/intervention.scala +++ b/src/main/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/intervention.scala @@ -1,14 +1,15 @@ package xyz.driver.pdsuidomain.formats.json.sprayformats import spray.json._ -import xyz.driver.pdsuicommon.domain.LongId +import xyz.driver.pdsuicommon.domain.{LongId, StringId} +import xyz.driver.pdsuidomain.entities.InterventionType.DeliveryMethod import xyz.driver.pdsuidomain.entities._ object intervention { import DefaultJsonProtocol._ import common._ - implicit val interventionWriter: JsonWriter[InterventionWithArms] = new JsonWriter[InterventionWithArms] { + implicit val interventionFormat: JsonFormat[InterventionWithArms] = new RootJsonFormat[InterventionWithArms] { override def write(obj: InterventionWithArms) = JsObject( "id" -> obj.intervention.id.toJson, @@ -18,14 +19,72 @@ object intervention { "isActive" -> obj.intervention.isActive.toJson, "arms" -> obj.arms.map(_.armId).toJson, "trialId" -> obj.intervention.trialId.toJson, + "deliveryMethod" -> obj.intervention.deliveryMethod.toJson, "originalName" -> obj.intervention.originalName.toJson, "originalDosage" -> obj.intervention.originalDosage.toJson, "originalType" -> obj.intervention.originalType.toJson ) + + override def read(json: JsValue): InterventionWithArms = json match { + case JsObject(fields) => + val trialId = fields + .get("trialId") + .map(_.convertTo[StringId[Trial]]) + .getOrElse(deserializationError(s"Intervention json object does not contain `trialId` field: $json")) + + val typeId = fields + .get("typeId") + .map(_.convertTo[LongId[InterventionType]]) + + val name = fields + .get("name") + .map(_.convertTo[String]) + .getOrElse("") + + val dosage = fields + .get("dosage") + .map(_.convertTo[String]) + .getOrElse("") + + val isActive = fields + .get("isActive") + .exists(_.convertTo[Boolean]) + + val deliveryMethod = fields + .get("deliveryMethod") + .map(_.convertTo[String]) + + val arms = fields + .get("arms") + .map(_.convertTo[List[LongId[Arm]]].map(x => InterventionArm(armId = x, interventionId = LongId(0)))) + .getOrElse(List.empty[InterventionArm]) + + InterventionWithArms( + intervention = Intervention( + id = LongId(0), + trialId = trialId, + name = name, + originalName = name, + typeId = typeId, + originalType = None, + dosage = dosage, + originalDosage = dosage, + isActive = isActive, + deliveryMethod = deliveryMethod + ), + arms = arms + ) + + case _ => deserializationError(s"Expected Json Object as create Intervention json, but got $json") + } } def applyUpdateToInterventionWithArms(json: JsValue, orig: InterventionWithArms): InterventionWithArms = json match { case JsObject(fields) => + val name = fields + .get("name") + .map(_.convertTo[String]) + val typeId = fields .get("typeId") .map(_.convertTo[LongId[InterventionType]]) @@ -38,6 +97,10 @@ object intervention { .get("isActive") .map(_.convertTo[Boolean]) + val deliveryMethod = fields + .get("deliveryMethod") + .map(_.convertTo[String]) + val origIntervention = orig.intervention val arms = fields .get("arms") @@ -45,9 +108,11 @@ object intervention { orig.copy( intervention = origIntervention.copy( + name = name.getOrElse(origIntervention.name), typeId = typeId.orElse(origIntervention.typeId), dosage = dosage.getOrElse(origIntervention.dosage), - isActive = isActive.getOrElse(origIntervention.isActive) + isActive = isActive.getOrElse(origIntervention.isActive), + deliveryMethod = deliveryMethod.orElse(origIntervention.deliveryMethod) ), arms = arms.getOrElse(orig.arms) ) @@ -55,6 +120,25 @@ object intervention { case _ => deserializationError(s"Expected Json Object as partial Intervention, but got $json") } - implicit val interventionTypeFormat: RootJsonFormat[InterventionType] = jsonFormat2(InterventionType.apply) + implicit val interventionTypeFormat: JsonFormat[InterventionType] = new RootJsonFormat[InterventionType] { + override def read(json: JsValue) = json match { + case JsObject(fields) => + val name = fields + .get("name") + .map(_.convertTo[String]) + .getOrElse(deserializationError(s"Intervention type json object does not contain `name` field: $json")) + + InterventionType.typeFromString(name) + + case _ => deserializationError(s"Expected Json Object as Intervention type, but got $json") + } + + override def write(obj: InterventionType) = + JsObject( + "id" -> obj.id.toJson, + "name" -> obj.name.toJson, + "deliveryMethods" -> obj.deliveryMethods.map(DeliveryMethod.methodToString).toJson + ) + } } diff --git a/src/main/scala/xyz/driver/pdsuidomain/services/InterventionService.scala b/src/main/scala/xyz/driver/pdsuidomain/services/InterventionService.scala index 439e456..1e7c7f1 100644 --- a/src/main/scala/xyz/driver/pdsuidomain/services/InterventionService.scala +++ b/src/main/scala/xyz/driver/pdsuidomain/services/InterventionService.scala @@ -61,6 +61,37 @@ object InterventionService { final case class CommonError(userMessage: String) extends UpdateReply with DomainError } + sealed trait CreateReply + object CreateReply { + final case class Created(x: InterventionWithArms) extends CreateReply + + type Error = CreateReply with DomainError + + case object AuthorizationError + extends CreateReply with DefaultAccessDeniedError with DomainError.AuthorizationError + + final case class CommonError(userMessage: String) extends CreateReply with DomainError + + implicit def toPhiString(reply: CreateReply): PhiString = reply match { + case Created(x) => phi"Created($x)" + case x: Error => DomainError.toPhiString(x) + } + } + + sealed trait DeleteReply + object DeleteReply { + case object Deleted extends DeleteReply + + type Error = DeleteReply with DomainError + + case object NotFoundError extends DeleteReply with DefaultNotFoundError with DomainError.NotFoundError + + case object AuthorizationError + extends DeleteReply with DefaultAccessDeniedError with DomainError.AuthorizationError + + final case class CommonError(userMessage: String) extends DeleteReply with DomainError + } + } trait InterventionService { @@ -76,4 +107,9 @@ trait InterventionService { def update(origIntervention: InterventionWithArms, draftIntervention: InterventionWithArms)( implicit requestContext: AuthenticatedRequestContext): Future[UpdateReply] + + def create(draftIntervention: InterventionWithArms)( + implicit requestContext: AuthenticatedRequestContext): Future[CreateReply] + + def delete(id: LongId[Intervention])(implicit requestContext: AuthenticatedRequestContext): Future[DeleteReply] } diff --git a/src/main/scala/xyz/driver/pdsuidomain/services/rest/RestInterventionService.scala b/src/main/scala/xyz/driver/pdsuidomain/services/rest/RestInterventionService.scala index e593c3b..025a48a 100644 --- a/src/main/scala/xyz/driver/pdsuidomain/services/rest/RestInterventionService.scala +++ b/src/main/scala/xyz/driver/pdsuidomain/services/rest/RestInterventionService.scala @@ -1,7 +1,6 @@ package xyz.driver.pdsuidomain.services.rest import scala.concurrent.{ExecutionContext, Future} - import akka.http.scaladsl.marshalling.Marshal import akka.http.scaladsl.model._ import akka.stream.Materializer @@ -61,4 +60,26 @@ class RestInterventionService(transport: ServiceTransport, baseUri: Uri)( } } + def create(draftIntervention: InterventionWithArms)( + implicit requestContext: AuthenticatedRequestContext): Future[CreateReply] = { + for { + entity <- Marshal(ApiIntervention.fromDomain(draftIntervention)).to[RequestEntity] + request = HttpRequest(HttpMethods.POST, endpointUri(baseUri, "/v1/intervention")).withEntity(entity) + response <- transport.sendRequestGetResponse(requestContext)(request) + reply <- apiResponse[ApiIntervention](response) + } yield { + CreateReply.Created(reply.toDomain) + } + } + + def delete(id: LongId[Intervention])(implicit requestContext: AuthenticatedRequestContext): Future[DeleteReply] = { + val request = HttpRequest(HttpMethods.DELETE, endpointUri(baseUri, s"/v1/intervention/$id")) + for { + response <- transport.sendRequestGetResponse(requestContext)(request) + _ <- apiResponse[HttpEntity](response) + } yield { + DeleteReply.Deleted + } + } + } diff --git a/src/main/scala/xyz/driver/pdsuidomain/services/rest/RestTrialService.scala b/src/main/scala/xyz/driver/pdsuidomain/services/rest/RestTrialService.scala index f826b98..b77e6df 100644 --- a/src/main/scala/xyz/driver/pdsuidomain/services/rest/RestTrialService.scala +++ b/src/main/scala/xyz/driver/pdsuidomain/services/rest/RestTrialService.scala @@ -34,9 +34,9 @@ class RestTrialService(transport: ServiceTransport, baseUri: Uri)(implicit prote } } - def getTrialWithLabels(trialId: StringId[Trial], condition: String)( + def getTrialWithLabels(trialId: StringId[Trial], disease: String)( implicit requestContext: AuthenticatedRequestContext): Future[GetTrialWithLabelsReply] = { - val request = HttpRequest(HttpMethods.GET, endpointUri(baseUri, s"/v1/export/trial/$trialId")) + val request = HttpRequest(HttpMethods.GET, endpointUri(baseUri, s"/v1/export/trial/$disease/$trialId")) for { response <- transport.sendRequestGetResponse(requestContext)(request) reply <- apiResponse[ApiExportTrialWithLabels](response) @@ -47,7 +47,7 @@ class RestTrialService(transport: ServiceTransport, baseUri: Uri)(implicit prote def getPdfSource(trialId: StringId[Trial])( implicit requestContext: AuthenticatedRequestContext): Future[Source[ByteString, NotUsed]] = { - val request = HttpRequest(HttpMethods.GET, endpointUri(baseUri, s"/v1/trial/${trialId}/source")) + val request = HttpRequest(HttpMethods.GET, endpointUri(baseUri, s"/v1/trial/$trialId/source")) for { response <- transport.sendRequestGetResponse(requestContext)(request) reply <- apiResponse[HttpEntity](response) diff --git a/src/test/scala/xyz/driver/pdsuicommon/parsers/SearchFilterParserSuite.scala b/src/test/scala/xyz/driver/pdsuicommon/parsers/SearchFilterParserSuite.scala index f47f4c2..ba67d13 100644 --- a/src/test/scala/xyz/driver/pdsuicommon/parsers/SearchFilterParserSuite.scala +++ b/src/test/scala/xyz/driver/pdsuicommon/parsers/SearchFilterParserSuite.scala @@ -9,6 +9,7 @@ import org.scalacheck.Arbitrary.arbitrary import org.scalacheck.{Gen, Prop} import org.scalatest.FreeSpecLike import org.scalatest.prop.Checkers +import xyz.driver.pdsuicommon.db.SearchFilterBinaryOperation.Eq import xyz.driver.pdsuicommon.db.SearchFilterNAryOperation.In import xyz.driver.pdsuicommon.utils.Utils import xyz.driver.pdsuicommon.utils.Utils._ @@ -104,6 +105,13 @@ class SearchFilterParserSuite extends FreeSpecLike with Checkers { } } + "actual recordId" - { + "should not be parsed with text values" in { + val filter = SearchFilterParser.parse(Seq("filters" -> "recordId EQ 1")) + assert(filter === Success(SearchFilterExpr.Atom.Binary(Dimension(None, "record_id"), Eq, Long.box(1)))) + } + } + "all operators" - { "should be parsed with numeric values" in check { val testQueryGen = queryGen( @@ -181,7 +189,7 @@ class SearchFilterParserSuite extends FreeSpecLike with Checkers { private val nonEmptyString = arbitrary[String].filter { s => !s.safeTrim.isEmpty } - private val numericBinaryAtomValuesGen: Gen[String] = arbitrary[BigInt].map(_.toString) + private val numericBinaryAtomValuesGen: Gen[String] = arbitrary[Long].map(_.toString) private val inValueGen: Gen[String] = { Gen.nonEmptyContainerOf[Seq, Char](inValueCharsGen).map(_.mkString).filter(_.safeTrim.nonEmpty) } diff --git a/src/test/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/InterventionFormatSuite.scala b/src/test/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/InterventionFormatSuite.scala index 0f01d4a..dad39c8 100644 --- a/src/test/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/InterventionFormatSuite.scala +++ b/src/test/scala/xyz/driver/pdsuidomain/formats/json/sprayformats/InterventionFormatSuite.scala @@ -18,7 +18,8 @@ class InterventionFormatSuite extends FlatSpec with Matchers { originalType = Some("orig type"), dosage = "", originalDosage = "", - isActive = true + isActive = true, + deliveryMethod = Some("pill") ) val arms = List( InterventionArm(interventionId = intervention.id, armId = LongId(20)), @@ -29,11 +30,21 @@ class InterventionFormatSuite extends FlatSpec with Matchers { intervention = intervention, arms = arms ) - val writtenJson = interventionWriter.write(orig) + val writtenJson = interventionFormat.write(orig) writtenJson should be( """{"id":1,"name":"intervention name","typeId":10,"dosage":"","isActive":true,"arms":[20,21,22], - "trialId":"NCT000001","originalName":"orig name","originalDosage":"","originalType":"orig type"}""".parseJson) + "trialId":"NCT000001","deliveryMethod":"pill","originalName":"orig name","originalDosage":"","originalType":"orig type"}""".parseJson) + + val createInterventionJson = + """{"id":1,"name":"intervention name","typeId":10,"dosage":"","isActive":true,"arms":[20,21,22], + "trialId":"NCT000001","deliveryMethod":"pill"}""".parseJson + val parsedCreateIntervention = interventionFormat.read(createInterventionJson) + val expectedCreateIntervention = parsedCreateIntervention.copy( + intervention = intervention.copy(id = LongId(0), originalType = None, originalName = "intervention name"), + arms = arms.map(_.copy(interventionId = LongId(0))) + ) + parsedCreateIntervention should be(expectedCreateIntervention) val updateInterventionJson = """{"dosage":"descr","arms":[21,22]}""".parseJson val expectedUpdatedIntervention = orig.copy( @@ -48,13 +59,12 @@ class InterventionFormatSuite extends FlatSpec with Matchers { } "Json format for InterventionType" should "read and write correct JSON" in { - val interventionType = InterventionType( - id = LongId(10), - name = "type name" - ) + val interventionType = InterventionType.typeFromString("Surgery/Procedure") val writtenJson = interventionTypeFormat.write(interventionType) - writtenJson should be("""{"id":10,"name":"type name"}""".parseJson) + writtenJson should be( + """{"id":9,"name":"Surgery/Procedure","deliveryMethods":["Radio-Frequency Ablation (RFA)", + "Cryoablation","Therapeutic Conventional Surgery","Robotic Assisted Laparoscopic Surgery"]}""".parseJson) val parsedInterventionType = interventionTypeFormat.read(writtenJson) parsedInterventionType should be(interventionType) |