Skip to content

Commit b375ac3

Browse files
committed
**feat(genomics): Add instrument observation domain and related services**
- Introduced `InstrumentObservation` model, repository, services, and table schema for genomic instrument observations. - Added migration to create `instrument_observation` table with necessary indices for optimized queries. - Implemented CRUD operations in `InstrumentObservationRepository`. - Updated `AtmosphereEventHandler` to handle `InstrumentObservation` events for create, update, and delete actions. - Wrote unit tests to ensure correctness of repository methods, event handling, and confidence field mapping.
1 parent b6362eb commit b375ac3

9 files changed

Lines changed: 479 additions & 19 deletions

File tree

app/models/dal/DatabaseSchema.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ object DatabaseSchema {
7676
val sequenceLibraries = TableQuery[SequenceLibrariesTable]
7777
val sequencingLabs = TableQuery[SequencingLabsTable]
7878
val sequencerInstruments = TableQuery[SequencerInstrumentsTable]
79+
val instrumentObservations = TableQuery[InstrumentObservationTable]
7980
val specimenDonors = TableQuery[SpecimenDonorsTable]
8081
val validationServices = TableQuery[ValidationServicesTable]
8182
val testTypeDefinition = TableQuery[TestTypeTable]
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package models.dal.domain.genomics
2+
3+
import models.dal.MyPostgresProfile.api.*
4+
import models.domain.genomics.{InstrumentObservation, ObservationConfidence}
5+
import slick.ast.BaseTypedType
6+
7+
import java.time.LocalDateTime
8+
9+
class InstrumentObservationTable(tag: Tag) extends Table[InstrumentObservation](tag, "instrument_observation") {
10+
11+
implicit private val confidenceMapper: BaseTypedType[ObservationConfidence] =
12+
MappedColumnType.base[ObservationConfidence, String](_.dbValue, ObservationConfidence.fromString)
13+
14+
def id = column[Int]("id", O.PrimaryKey, O.AutoInc)
15+
def atUri = column[String]("at_uri", O.Unique)
16+
def atCid = column[Option[String]]("at_cid")
17+
def instrumentId = column[String]("instrument_id")
18+
def labName = column[String]("lab_name")
19+
def biosampleRef = column[String]("biosample_ref")
20+
def sequenceRunRef = column[Option[String]]("sequence_run_ref")
21+
def platform = column[Option[String]]("platform")
22+
def instrumentModel = column[Option[String]]("instrument_model")
23+
def flowcellId = column[Option[String]]("flowcell_id")
24+
def runDate = column[Option[LocalDateTime]]("run_date")
25+
def confidence = column[ObservationConfidence]("confidence")
26+
def createdAt = column[LocalDateTime]("created_at")
27+
def updatedAt = column[Option[LocalDateTime]]("updated_at")
28+
29+
def * = (
30+
id.?, atUri, atCid, instrumentId, labName, biosampleRef,
31+
sequenceRunRef, platform, instrumentModel, flowcellId,
32+
runDate, confidence, createdAt, updatedAt
33+
).mapTo[InstrumentObservation]
34+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package models.domain.genomics
2+
3+
import play.api.libs.json.{Json, OFormat}
4+
5+
import java.time.LocalDateTime
6+
7+
case class InstrumentObservation(
8+
id: Option[Int] = None,
9+
atUri: String,
10+
atCid: Option[String] = None,
11+
instrumentId: String,
12+
labName: String,
13+
biosampleRef: String,
14+
sequenceRunRef: Option[String] = None,
15+
platform: Option[String] = None,
16+
instrumentModel: Option[String] = None,
17+
flowcellId: Option[String] = None,
18+
runDate: Option[LocalDateTime] = None,
19+
confidence: ObservationConfidence = ObservationConfidence.Inferred,
20+
createdAt: LocalDateTime = LocalDateTime.now(),
21+
updatedAt: Option[LocalDateTime] = None
22+
)
23+
24+
object InstrumentObservation {
25+
implicit val format: OFormat[InstrumentObservation] = Json.format[InstrumentObservation]
26+
}
27+
28+
sealed trait ObservationConfidence {
29+
def dbValue: String
30+
}
31+
32+
object ObservationConfidence {
33+
case object Known extends ObservationConfidence { val dbValue = "KNOWN" }
34+
case object Inferred extends ObservationConfidence { val dbValue = "INFERRED" }
35+
case object Guessed extends ObservationConfidence { val dbValue = "GUESSED" }
36+
37+
def fromString(s: String): ObservationConfidence = s.toUpperCase match {
38+
case "KNOWN" => Known
39+
case "INFERRED" => Inferred
40+
case "GUESSED" => Guessed
41+
case other => throw new IllegalArgumentException(s"Unknown ObservationConfidence: $other")
42+
}
43+
44+
implicit val format: play.api.libs.json.Format[ObservationConfidence] = new play.api.libs.json.Format[ObservationConfidence] {
45+
def reads(json: play.api.libs.json.JsValue) = json match {
46+
case play.api.libs.json.JsString(s) => play.api.libs.json.JsSuccess(fromString(s))
47+
case _ => play.api.libs.json.JsError("String value expected")
48+
}
49+
def writes(c: ObservationConfidence) = play.api.libs.json.JsString(c.dbValue)
50+
}
51+
}

app/modules/BaseModule.scala

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ class BaseModule extends AbstractModule {
5454
.to(classOf[BiosampleOriginalHaplogroupRepositoryImpl])
5555
.asEagerSingleton()
5656

57+
bind(classOf[InstrumentObservationRepository])
58+
.to(classOf[InstrumentObservationRepositoryImpl])
59+
.asEagerSingleton()
5760

5861
bind(classOf[SequenceFileRepository])
5962
.to(classOf[SequenceFileRepositoryImpl])
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package repositories
2+
3+
import jakarta.inject.{Inject, Singleton}
4+
import models.dal.DatabaseSchema
5+
import models.domain.genomics.{InstrumentObservation, ObservationConfidence}
6+
import play.api.db.slick.DatabaseConfigProvider
7+
import slick.ast.BaseTypedType
8+
9+
import java.time.LocalDateTime
10+
import scala.concurrent.{ExecutionContext, Future}
11+
12+
trait InstrumentObservationRepository {
13+
def create(observation: InstrumentObservation): Future[InstrumentObservation]
14+
def findByAtUri(atUri: String): Future[Option[InstrumentObservation]]
15+
def findByInstrumentId(instrumentId: String): Future[Seq[InstrumentObservation]]
16+
def findByLabName(labName: String): Future[Seq[InstrumentObservation]]
17+
def findByBiosampleRef(biosampleRef: String): Future[Seq[InstrumentObservation]]
18+
def update(observation: InstrumentObservation): Future[Boolean]
19+
def deleteByAtUri(atUri: String): Future[Boolean]
20+
}
21+
22+
@Singleton
23+
class InstrumentObservationRepositoryImpl @Inject()(
24+
override protected val dbConfigProvider: DatabaseConfigProvider
25+
)(implicit override protected val ec: ExecutionContext)
26+
extends BaseRepository(dbConfigProvider)
27+
with InstrumentObservationRepository {
28+
29+
import models.dal.MyPostgresProfile.api.*
30+
31+
implicit private val confidenceMapper: BaseTypedType[ObservationConfidence] =
32+
MappedColumnType.base[ObservationConfidence, String](_.dbValue, ObservationConfidence.fromString)
33+
34+
private val observations = DatabaseSchema.domain.genomics.instrumentObservations
35+
36+
override def create(observation: InstrumentObservation): Future[InstrumentObservation] = {
37+
db.run(
38+
(observations returning observations.map(_.id)
39+
into ((o, id) => o.copy(id = Some(id)))) += observation
40+
)
41+
}
42+
43+
override def findByAtUri(atUri: String): Future[Option[InstrumentObservation]] = {
44+
db.run(observations.filter(_.atUri === atUri).result.headOption)
45+
}
46+
47+
override def findByInstrumentId(instrumentId: String): Future[Seq[InstrumentObservation]] = {
48+
db.run(observations.filter(_.instrumentId === instrumentId).result)
49+
}
50+
51+
override def findByLabName(labName: String): Future[Seq[InstrumentObservation]] = {
52+
db.run(observations.filter(_.labName === labName).result)
53+
}
54+
55+
override def findByBiosampleRef(biosampleRef: String): Future[Seq[InstrumentObservation]] = {
56+
db.run(observations.filter(_.biosampleRef === biosampleRef).result)
57+
}
58+
59+
override def update(observation: InstrumentObservation): Future[Boolean] = {
60+
db.run(
61+
observations.filter(_.atUri === observation.atUri)
62+
.map(o => (o.atCid, o.instrumentId, o.labName, o.biosampleRef, o.sequenceRunRef,
63+
o.platform, o.instrumentModel, o.flowcellId, o.runDate, o.confidence, o.updatedAt))
64+
.update((observation.atCid, observation.instrumentId, observation.labName,
65+
observation.biosampleRef, observation.sequenceRunRef, observation.platform,
66+
observation.instrumentModel, observation.flowcellId, observation.runDate,
67+
observation.confidence, Some(LocalDateTime.now())))
68+
).map(_ > 0)
69+
}
70+
71+
override def deleteByAtUri(atUri: String): Future[Boolean] = {
72+
db.run(observations.filter(_.atUri === atUri).delete.map(_ > 0))
73+
}
74+
}

app/services/firehose/AtmosphereEventHandler.scala

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ class AtmosphereEventHandler @Inject()(
2727
testTypeService: TestTypeService,
2828
genotypeDataRepository: GenotypeDataRepository,
2929
populationBreakdownRepository: PopulationBreakdownRepository,
30-
haplogroupReconciliationRepository: HaplogroupReconciliationRepository
30+
haplogroupReconciliationRepository: HaplogroupReconciliationRepository,
31+
instrumentObservationRepository: InstrumentObservationRepository
3132
)(implicit ec: ExecutionContext) extends Logging {
3233

3334
def handle(event: FirehoseEvent): Future[FirehoseResult] = {
@@ -39,7 +40,7 @@ class AtmosphereEventHandler @Inject()(
3940
case e: GenotypeEvent => handleGenotype(e)
4041
case e: PopulationBreakdownEvent => handlePopulationBreakdown(e)
4142
case e: HaplogroupReconciliationEvent => handleHaplogroupReconciliation(e)
42-
// Add other handlers as needed
43+
case e: InstrumentObservationEvent => handleInstrumentObservation(e)
4344
case _ =>
4445
logger.warn(s"Unhandled event type: ${event.getClass.getSimpleName} for ${event.atUri}")
4546
Future.successful(FirehoseResult.Success(event.atUri, "", None, "Ignored (Not Implemented)"))
@@ -817,4 +818,84 @@ class AtmosphereEventHandler @Inject()(
817818
}
818819
}
819820

821+
// --- Instrument Observation Handling ---
822+
823+
private def handleInstrumentObservation(event: InstrumentObservationEvent): Future[FirehoseResult] = {
824+
event.action match {
825+
case FirehoseAction.Create => createInstrumentObservation(event)
826+
case FirehoseAction.Update => updateInstrumentObservation(event)
827+
case FirehoseAction.Delete => deleteInstrumentObservation(event)
828+
}
829+
}
830+
831+
private def createInstrumentObservation(event: InstrumentObservationEvent): Future[FirehoseResult] = {
832+
event.payload match {
833+
case Some(record) =>
834+
instrumentObservationRepository.findByAtUri(record.atUri).flatMap {
835+
case Some(_) =>
836+
Future.successful(FirehoseResult.Conflict(event.atUri, "Instrument observation already exists"))
837+
case None =>
838+
val observation = InstrumentObservation(
839+
atUri = record.atUri,
840+
atCid = event.atCid,
841+
instrumentId = record.instrumentId,
842+
labName = record.labName,
843+
biosampleRef = record.biosampleRef,
844+
sequenceRunRef = record.sequenceRunRef,
845+
platform = record.platform,
846+
instrumentModel = record.instrumentModel,
847+
flowcellId = record.flowcellId,
848+
runDate = record.runDate.map(i => LocalDateTime.ofInstant(i, ZoneId.systemDefault())),
849+
confidence = record.confidence
850+
.map(ObservationConfidence.fromString)
851+
.getOrElse(ObservationConfidence.Inferred)
852+
)
853+
instrumentObservationRepository.create(observation).map { created =>
854+
logger.info(s"Created instrument observation for instrument ${record.instrumentId} at lab ${record.labName}")
855+
FirehoseResult.Success(event.atUri, UUID.randomUUID().toString, None, "Instrument observation created")
856+
}
857+
}
858+
case None =>
859+
Future.successful(FirehoseResult.ValidationError(event.atUri, "Missing payload for InstrumentObservationEvent"))
860+
}
861+
}
862+
863+
private def updateInstrumentObservation(event: InstrumentObservationEvent): Future[FirehoseResult] = {
864+
event.payload match {
865+
case Some(record) =>
866+
instrumentObservationRepository.findByAtUri(event.atUri).flatMap {
867+
case Some(existing) =>
868+
val updated = existing.copy(
869+
atCid = event.atCid,
870+
instrumentId = record.instrumentId,
871+
labName = record.labName,
872+
biosampleRef = record.biosampleRef,
873+
sequenceRunRef = record.sequenceRunRef,
874+
platform = record.platform,
875+
instrumentModel = record.instrumentModel,
876+
flowcellId = record.flowcellId,
877+
runDate = record.runDate.map(i => LocalDateTime.ofInstant(i, ZoneId.systemDefault())),
878+
confidence = record.confidence
879+
.map(ObservationConfidence.fromString)
880+
.getOrElse(existing.confidence)
881+
)
882+
instrumentObservationRepository.update(updated).map { success =>
883+
if (success) FirehoseResult.Success(event.atUri, UUID.randomUUID().toString, None, "Instrument observation updated")
884+
else FirehoseResult.Error(event.atUri, "Failed to update instrument observation")
885+
}
886+
case None =>
887+
Future.successful(FirehoseResult.NotFound(event.atUri))
888+
}
889+
case None =>
890+
Future.successful(FirehoseResult.ValidationError(event.atUri, "Missing payload for InstrumentObservationEvent"))
891+
}
892+
}
893+
894+
private def deleteInstrumentObservation(event: InstrumentObservationEvent): Future[FirehoseResult] = {
895+
instrumentObservationRepository.deleteByAtUri(event.atUri).map { deleted =>
896+
if (deleted) FirehoseResult.Success(event.atUri, "", None, "Instrument observation deleted")
897+
else FirehoseResult.NotFound(event.atUri)
898+
}
899+
}
900+
820901
}

conf/evolutions/default/67.sql

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
-- # --- !Ups
2+
3+
CREATE TABLE public.instrument_observation (
4+
id SERIAL PRIMARY KEY,
5+
at_uri VARCHAR(512) UNIQUE NOT NULL,
6+
at_cid VARCHAR(128),
7+
instrument_id VARCHAR(255) NOT NULL,
8+
lab_name VARCHAR(255) NOT NULL,
9+
biosample_ref VARCHAR(512) NOT NULL,
10+
sequence_run_ref VARCHAR(512),
11+
platform VARCHAR(100),
12+
instrument_model VARCHAR(255),
13+
flowcell_id VARCHAR(255),
14+
run_date TIMESTAMP,
15+
confidence VARCHAR(20) DEFAULT 'INFERRED' CHECK (confidence IN ('KNOWN', 'INFERRED', 'GUESSED')),
16+
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
17+
updated_at TIMESTAMP
18+
);
19+
20+
CREATE INDEX idx_instrument_obs_instrument ON public.instrument_observation (instrument_id);
21+
CREATE INDEX idx_instrument_obs_lab ON public.instrument_observation (lab_name);
22+
CREATE INDEX idx_instrument_obs_biosample ON public.instrument_observation (biosample_ref);
23+
CREATE INDEX idx_instrument_obs_at_uri ON public.instrument_observation (at_uri);
24+
25+
26+
-- # --- !Downs
27+
28+
DROP TABLE IF EXISTS public.instrument_observation;

0 commit comments

Comments
 (0)