From d468adc39251fc6a79bf9b2622ad04b419af918d Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 11 Mar 2026 10:48:07 +0100 Subject: [PATCH 01/19] Add tables --- .../model/collection/CollectionTable.kt | 43 +++++++ .../model/collection/VariantTable.kt | 106 ++++++++++++++++++ .../V1.1__collections_and_variants.sql | 34 ++++++ 3 files changed, 183 insertions(+) create mode 100644 backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionTable.kt create mode 100644 backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/VariantTable.kt create mode 100644 backend/src/main/resources/db/migration/V1.1__collections_and_variants.sql diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionTable.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionTable.kt new file mode 100644 index 00000000..57aa1fc2 --- /dev/null +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionTable.kt @@ -0,0 +1,43 @@ +package org.genspectrum.dashboardsbackend.model.collection + +import org.genspectrum.dashboardsbackend.api.Collection +import org.jetbrains.exposed.dao.UUIDEntity +import org.jetbrains.exposed.dao.UUIDEntityClass +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.dao.id.UUIDTable +import java.util.UUID + +const val COLLECTION_TABLE = "collections_table" + +object CollectionTable : UUIDTable(COLLECTION_TABLE) { + val name = text("name") + val ownedBy = varchar("owned_by", 255) + val organism = varchar("organism", 255) + val description = text("description").nullable() +} + +class CollectionEntity(id: EntityID) : UUIDEntity(id) { + companion object : UUIDEntityClass(CollectionTable) { + fun findForUser(id: UUID, userId: String) = findById(id) + ?.takeIf { it.ownedBy == userId } + + // TODO we probably want to have a 'find by organism' as well + } + + var name by CollectionTable.name + var ownedBy by CollectionTable.ownedBy + var organism by CollectionTable.organism + var description by CollectionTable.description + + // Navigation property to access variants + val variants by VariantEntity referrersOn VariantTable.collectionId + + fun toCollection() = Collection( + id = id.value.toString(), + name = name, + ownedBy = ownedBy, + organism = organism, + description = description, + variants = variants.map { it.toVariant() }, + ) +} diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/VariantTable.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/VariantTable.kt new file mode 100644 index 00000000..f19dcbab --- /dev/null +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/VariantTable.kt @@ -0,0 +1,106 @@ +package org.genspectrum.dashboardsbackend.model.collection + +import org.genspectrum.dashboardsbackend.api.Variant +import org.genspectrum.dashboardsbackend.model.subscription.jacksonSerializableJsonb +import org.jetbrains.exposed.dao.UUIDEntity +import org.jetbrains.exposed.dao.UUIDEntityClass +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.dao.id.UUIDTable +import org.jetbrains.exposed.sql.ReferenceOption +import java.util.UUID + +const val VARIANT_TABLE = "variants_table" + +enum class VariantType { + QUERY, + MUTATION_LIST, + ; + + fun toDatabaseValue(): String = when (this) { + QUERY -> "query" + MUTATION_LIST -> "mutationList" + } + + companion object { + fun fromDatabaseValue(value: String): VariantType = when (value) { + "query" -> QUERY + "mutationList" -> MUTATION_LIST + else -> throw IllegalArgumentException("Unknown variant type: $value") + } + } +} + +object VariantTable : UUIDTable(VARIANT_TABLE) { + val collectionId = reference( + "collection_id", + CollectionTable, + onDelete = ReferenceOption.CASCADE, + ) + val variantType = varchar("variant_type", 50) + val name = text("name") + val description = text("description").nullable() + + val countQuery = text("count_query").nullable() + val coverageQuery = text("coverage_query").nullable() + + // TODO - the List isn't correct, the type is more complex than that + val mutationList = jacksonSerializableJsonb>("mutation_list").nullable() +} + +class VariantEntity(id: EntityID) : UUIDEntity(id) { + companion object : UUIDEntityClass(VariantTable) { + fun findForCollection(collectionId: UUID): List = + find { VariantTable.collectionId eq collectionId }.toList() + } + + var collectionId by VariantTable.collectionId + private var variantTypeString by VariantTable.variantType + var name by VariantTable.name + var description by VariantTable.description + + // Polymorphic property access + var countQuery by VariantTable.countQuery + var coverageQuery by VariantTable.coverageQuery + var mutationList by VariantTable.mutationList + + // Type-safe variant type accessor + var variantType: VariantType + get() = VariantType.fromDatabaseValue(variantTypeString) + set(value) { + variantTypeString = value.toDatabaseValue() + } + + // Validation helper + fun validate() { + when (variantType) { + VariantType.QUERY -> { + require(countQuery != null) { "Query variant must have count_query" } + require(mutationList == null) { "Query variant must not have mutation_list" } + } + VariantType.MUTATION_LIST -> { + require(mutationList != null) { "MutationList variant must have mutation_list" } + require(countQuery == null && coverageQuery == null) { + "MutationList variant must not have query columns" + } + } + } + } + + fun toVariant(): Variant = when (variantType) { + VariantType.QUERY -> Variant.QueryVariant( + id = id.value.toString(), + collectionId = collectionId.value.toString(), + name = name, + description = description, + countQuery = countQuery!!, + coverageQuery = coverageQuery, + ) + VariantType.MUTATION_LIST -> Variant.MutationListVariant( + id = id.value.toString(), + collectionId = collectionId.value.toString(), + name = name, + description = description, + mutationList = mutationList!!, + ) + } +} diff --git a/backend/src/main/resources/db/migration/V1.1__collections_and_variants.sql b/backend/src/main/resources/db/migration/V1.1__collections_and_variants.sql new file mode 100644 index 00000000..a07f9e4c --- /dev/null +++ b/backend/src/main/resources/db/migration/V1.1__collections_and_variants.sql @@ -0,0 +1,34 @@ +-- Collections table +create table collections_table ( + id uuid primary key, + name text not null, + owned_by varchar(255) not null, + organism varchar(255) not null, + description text +); + +-- Variants table with polymorphic data storage +create table variants_table ( + id uuid primary key, + collection_id uuid not null, + variant_type varchar(50) not null, + name text not null, + description text, + -- Query variant columns (nullable, only used when variant_type='query') + count_query text, + coverage_query text, + -- MutationList variant column (nullable, only used when variant_type='mutationList') + mutation_list jsonb, + -- Constraints + constraint fk_collection foreign key (collection_id) references collections_table(id) on delete cascade, + constraint chk_variant_type check (variant_type in ('query', 'mutationList')), + -- Ensure correct columns are populated based on type + constraint chk_query_columns check ( + (variant_type = 'query' and count_query is not null and mutation_list is null) or + (variant_type = 'mutationList' and mutation_list is not null and count_query is null and coverage_query is null) + ) +); + +create index idx_collections_owned_by on collections_table(owned_by); +create index idx_collections_organism on collections_table(organism); +create index idx_variants_collection_id on variants_table(collection_id); From 1ce6a4dd18f7aabc0b478ee98315e0a93a52738c Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 11 Mar 2026 11:19:36 +0100 Subject: [PATCH 02/19] add a 'create collection' endpoint --- .../dashboardsbackend/api/Collection.kt | 182 ++++++++++++++++++ .../config/DashboardsConfig.kt | 7 + .../controller/CollectionsController.kt | 29 +++ .../model/collection/CollectionModel.kt | 68 +++++++ .../model/subscription/SubscriptionModel.kt | 20 +- .../dashboardsbackend/util/ConvertToUuid.kt | 10 + 6 files changed, 300 insertions(+), 16 deletions(-) create mode 100644 backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Collection.kt create mode 100644 backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt create mode 100644 backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt create mode 100644 backend/src/main/kotlin/org/genspectrum/dashboardsbackend/util/ConvertToUuid.kt diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Collection.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Collection.kt new file mode 100644 index 00000000..773aef58 --- /dev/null +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Collection.kt @@ -0,0 +1,182 @@ +package org.genspectrum.dashboardsbackend.api + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo +import io.swagger.v3.oas.annotations.media.Schema +import org.genspectrum.dashboardsbackend.api.Variant.MutationListVariant +import org.genspectrum.dashboardsbackend.api.Variant.QueryVariant + +@Schema( + description = "A collection of variants", + example = """ +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "My Collection", + "ownedBy": "user123", + "organism": "covid", + "description": "A collection of interesting variants", + "variants": [] +} +""", +) +data class Collection( + val id: String, + val name: String, + val ownedBy: String, + val organism: String, + val description: String?, + val variants: List, +) + +@Schema( + description = "Request to create a collection", + example = """ +{ + "name": "My Collection", + "organism": "covid", + "description": "A collection of interesting variants", + "variants": [ + { + "type": "query", + "name": "BA.2 in USA", + "description": "BA.2 lineage cases in USA", + "countQuery": "country='USA' & lineage='BA.2'", + "coverageQuery": "country='USA'" + } + ] +} +""", +) +data class CollectionRequest( + val name: String, + val organism: String, + val description: String? = null, + val variants: List, +) + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type", +) +@JsonSubTypes( + JsonSubTypes.Type(value = QueryVariant::class, name = "query"), + JsonSubTypes.Type(value = MutationListVariant::class, name = "mutationList"), +) +@Schema( + description = "Base interface for different variant types", +) +sealed interface Variant { + val id: String + val collectionId: String + + enum class QueryVariantType { + @JsonProperty("query") + QUERY, + } + + enum class MutationListVariantType { + @JsonProperty("mutationList") + MUTATION_LIST, + } + + @Schema( + description = "A variant defined by LAPIS queries", + example = """ +{ + "type": "query", + "id": "550e8400-e29b-41d4-a716-446655440000", + "collectionId": "660e8400-e29b-41d4-a716-446655440000", + "name": "BA.2 in USA", + "description": "BA.2 lineage cases in USA", + "countQuery": "country='USA' & lineage='BA.2'", + "coverageQuery": "country='USA'" +} +""", + ) + data class QueryVariant @JsonCreator constructor( + override val id: String, + override val collectionId: String, + val name: String, + val description: String?, + val countQuery: String, + val coverageQuery: String? = null, + ) : Variant { + val type: QueryVariantType = QueryVariantType.QUERY + } + + @Schema( + description = "A variant defined by a list of mutations", + example = """ +{ + "type": "mutationList", + "id": "550e8400-e29b-41d4-a716-446655440000", + "collectionId": "660e8400-e29b-41d4-a716-446655440000", + "name": "Omicron mutations", + "description": "Key mutations for Omicron", + "mutationList": ["S:N501Y", "S:E484K", "S:K417N"] +} +""", + ) + data class MutationListVariant @JsonCreator constructor( + override val id: String, + override val collectionId: String, + val name: String, + val description: String?, + val mutationList: List, + ) : Variant { + val type: MutationListVariantType = MutationListVariantType.MUTATION_LIST + } +} + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type", +) +@JsonSubTypes( + JsonSubTypes.Type(value = VariantRequest.QueryVariantRequest::class, name = "query"), + JsonSubTypes.Type(value = VariantRequest.MutationListVariantRequest::class, name = "mutationList"), +) +@Schema( + description = "Request to create a variant", +) +sealed interface VariantRequest { + @Schema( + description = "Request to create a query variant", + example = """ +{ + "type": "query", + "name": "BA.2 in USA", + "description": "BA.2 lineage cases in USA", + "countQuery": "country='USA' & lineage='BA.2'", + "coverageQuery": "country='USA'" +} +""", + ) + data class QueryVariantRequest( + val name: String, + val description: String? = null, + val countQuery: String, + val coverageQuery: String? = null, + ) : VariantRequest + + @Schema( + description = "Request to create a mutation list variant", + example = """ +{ + "type": "mutationList", + "name": "Omicron mutations", + "description": "Key mutations for Omicron", + "mutationList": ["S:N501Y", "S:E484K", "S:K417N"] +} +""", + ) + data class MutationListVariantRequest( + val name: String, + val description: String? = null, + val mutationList: List, + ) : VariantRequest +} diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/config/DashboardsConfig.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/config/DashboardsConfig.kt index 6db05404..bd72a908 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/config/DashboardsConfig.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/config/DashboardsConfig.kt @@ -1,5 +1,6 @@ package org.genspectrum.dashboardsbackend.config +import org.genspectrum.dashboardsbackend.controller.BadRequestException import org.springframework.boot.context.properties.ConfigurationProperties @ConfigurationProperties(prefix = "dashboards") @@ -13,3 +14,9 @@ data class OrganismConfig(val lapis: LapisConfig, val externalNavigationLinks: L data class LapisConfig(val url: String, val mainDateField: String, val additionalFilters: Map?) data class ExternalNavigationLink(val url: String, val label: String, val menuIcon: String, val description: String) + +fun DashboardsConfig.validateIsValidOrganism(organism: String) { + if (!organisms.containsKey(organism)) { + throw BadRequestException("Organism '$organism' is not supported") + } +} diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt new file mode 100644 index 00000000..82da87fb --- /dev/null +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt @@ -0,0 +1,29 @@ +package org.genspectrum.dashboardsbackend.controller + +import io.swagger.v3.oas.annotations.Operation +import org.genspectrum.dashboardsbackend.api.Collection +import org.genspectrum.dashboardsbackend.api.CollectionRequest +import org.genspectrum.dashboardsbackend.model.collection.CollectionModel +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@RestController +class CollectionsController(private val collectionModel: CollectionModel) { + @PostMapping("/collections") + @ResponseStatus(HttpStatus.CREATED) + @Operation( + summary = "Create a new collection", + description = "Creates a new collection with variants for a user.", + ) + fun postCollection( + @RequestBody collection: CollectionRequest, + @UserIdParameter @RequestParam userId: String, + ): Collection = collectionModel.createCollection( + request = collection, + userId = userId, + ) +} diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt new file mode 100644 index 00000000..253cd17b --- /dev/null +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt @@ -0,0 +1,68 @@ +package org.genspectrum.dashboardsbackend.model.collection + +import org.genspectrum.dashboardsbackend.api.Collection +import org.genspectrum.dashboardsbackend.api.CollectionRequest +import org.genspectrum.dashboardsbackend.api.VariantRequest +import org.genspectrum.dashboardsbackend.config.DashboardsConfig +import org.genspectrum.dashboardsbackend.config.validateIsValidOrganism +import org.jetbrains.exposed.sql.Database +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import javax.sql.DataSource + +@Service +@Transactional +class CollectionModel(pool: DataSource, private val dashboardsConfig: DashboardsConfig) { + init { + Database.connect(pool) + } + + fun createCollection(request: CollectionRequest, userId: String): Collection { + dashboardsConfig.validateIsValidOrganism(request.organism) + + val collectionEntity = CollectionEntity.new { + name = request.name + organism = request.organism + description = request.description + ownedBy = userId + } + + val variantEntities = request.variants.map { variantRequest -> + val variantEntity = when (variantRequest) { + is VariantRequest.QueryVariantRequest -> { + VariantEntity.new { + this.collectionId = collectionEntity.id + this.variantType = VariantType.QUERY + this.name = variantRequest.name + this.description = variantRequest.description + this.countQuery = variantRequest.countQuery + this.coverageQuery = variantRequest.coverageQuery + this.mutationList = null + } + } + is VariantRequest.MutationListVariantRequest -> { + VariantEntity.new { + this.collectionId = collectionEntity.id + this.variantType = VariantType.MUTATION_LIST + this.name = variantRequest.name + this.description = variantRequest.description + this.mutationList = variantRequest.mutationList + this.countQuery = null + this.coverageQuery = null + } + } + } + variantEntity.validate() + variantEntity + } + + return Collection( + id = collectionEntity.id.value.toString(), + name = collectionEntity.name, + ownedBy = collectionEntity.ownedBy, + organism = collectionEntity.organism, + description = collectionEntity.description, + variants = variantEntities.map { it.toVariant() }, + ) + } +} diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/subscription/SubscriptionModel.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/subscription/SubscriptionModel.kt index 55c6fa4d..2aa12e38 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/subscription/SubscriptionModel.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/subscription/SubscriptionModel.kt @@ -4,12 +4,12 @@ import org.genspectrum.dashboardsbackend.api.Subscription import org.genspectrum.dashboardsbackend.api.SubscriptionRequest import org.genspectrum.dashboardsbackend.api.SubscriptionUpdate import org.genspectrum.dashboardsbackend.config.DashboardsConfig -import org.genspectrum.dashboardsbackend.controller.BadRequestException +import org.genspectrum.dashboardsbackend.config.validateIsValidOrganism import org.genspectrum.dashboardsbackend.controller.NotFoundException +import org.genspectrum.dashboardsbackend.util.convertToUuid import org.jetbrains.exposed.sql.Database import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import java.util.UUID import javax.sql.DataSource @Service @@ -31,7 +31,7 @@ class SubscriptionModel(pool: DataSource, private val dashboardsConfig: Dashboar } fun postSubscriptions(request: SubscriptionRequest, userId: String): Subscription { - validateIsValidOrganism(request.organism) + dashboardsConfig.validateIsValidOrganism(request.organism) return SubscriptionEntity .new { @@ -54,7 +54,7 @@ class SubscriptionModel(pool: DataSource, private val dashboardsConfig: Dashboar } fun putSubscription(subscriptionId: String, subscriptionUpdate: SubscriptionUpdate, userId: String): Subscription { - subscriptionUpdate.organism?.also { validateIsValidOrganism(it) } + subscriptionUpdate.organism?.also { dashboardsConfig.validateIsValidOrganism(it) } val subscription = SubscriptionEntity.findForUser(convertToUuid(subscriptionId), userId) ?: throw NotFoundException("Subscription $subscriptionId not found") @@ -80,16 +80,4 @@ class SubscriptionModel(pool: DataSource, private val dashboardsConfig: Dashboar return subscription.toSubscription() } - - private fun validateIsValidOrganism(organism: String) { - if (!dashboardsConfig.organisms.containsKey(organism)) { - throw BadRequestException("Organism '$organism' is not supported") - } - } - - private fun convertToUuid(id: String) = try { - UUID.fromString(id) - } catch (_: IllegalArgumentException) { - throw BadRequestException("Invalid UUID $id") - } } diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/util/ConvertToUuid.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/util/ConvertToUuid.kt new file mode 100644 index 00000000..91e79929 --- /dev/null +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/util/ConvertToUuid.kt @@ -0,0 +1,10 @@ +package org.genspectrum.dashboardsbackend.util + +import org.genspectrum.dashboardsbackend.controller.BadRequestException +import java.util.UUID + +fun convertToUuid(id: String): UUID = try { + UUID.fromString(id) +} catch (_: IllegalArgumentException) { + throw BadRequestException("Invalid UUID $id") +} From dea93dea04289779b8d1df30eaae6156f7e36e98 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 11 Mar 2026 13:54:21 +0100 Subject: [PATCH 03/19] Add 'GET' for collections; add tests --- .../controller/CollectionsController.kt | 15 + .../model/collection/CollectionModel.kt | 21 ++ .../dashboardsbackend/TestHelpers.kt | 22 ++ .../controller/CollectionsClient.kt | 55 ++++ .../controller/CollectionsControllerTest.kt | 271 ++++++++++++++++++ 5 files changed, 384 insertions(+) create mode 100644 backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt create mode 100644 backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt index 82da87fb..9416027b 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt @@ -5,6 +5,8 @@ import org.genspectrum.dashboardsbackend.api.Collection import org.genspectrum.dashboardsbackend.api.CollectionRequest import org.genspectrum.dashboardsbackend.model.collection.CollectionModel import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestParam @@ -13,6 +15,19 @@ import org.springframework.web.bind.annotation.RestController @RestController class CollectionsController(private val collectionModel: CollectionModel) { + @GetMapping("/collections", produces = [MediaType.APPLICATION_JSON_VALUE]) + @Operation( + summary = "Get collections", + description = "Returns collections filtered by optional userId and/or organism parameters.", + ) + fun getCollections( + @RequestParam(required = false) userId: String?, + @RequestParam(required = false) organism: String?, + ): List = collectionModel.getCollections( + userId = userId, + organism = organism, + ) + @PostMapping("/collections") @ResponseStatus(HttpStatus.CREATED) @Operation( diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt index 253cd17b..79f5eac5 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt @@ -6,6 +6,8 @@ import org.genspectrum.dashboardsbackend.api.VariantRequest import org.genspectrum.dashboardsbackend.config.DashboardsConfig import org.genspectrum.dashboardsbackend.config.validateIsValidOrganism import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.Op +import org.jetbrains.exposed.sql.and import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import javax.sql.DataSource @@ -17,6 +19,25 @@ class CollectionModel(pool: DataSource, private val dashboardsConfig: Dashboards Database.connect(pool) } + fun getCollections(userId: String?, organism: String?): List { + val query = if (userId == null && organism == null) { + CollectionEntity.all() + } else { + CollectionEntity.find { + var conditions: Op = Op.TRUE + if (userId != null) { + conditions = conditions and (CollectionTable.ownedBy eq userId) + } + if (organism != null) { + conditions = conditions and (CollectionTable.organism eq organism) + } + conditions + } + } + + return query.map { it.toCollection() } + } + fun createCollection(request: CollectionRequest, userId: String): Collection { dashboardsConfig.validateIsValidOrganism(request.organism) diff --git a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/TestHelpers.kt b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/TestHelpers.kt index 33902030..1f65abae 100644 --- a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/TestHelpers.kt +++ b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/TestHelpers.kt @@ -1,9 +1,11 @@ package org.genspectrum.dashboardsbackend +import org.genspectrum.dashboardsbackend.api.CollectionRequest import org.genspectrum.dashboardsbackend.api.DateWindow import org.genspectrum.dashboardsbackend.api.EvaluationInterval import org.genspectrum.dashboardsbackend.api.SubscriptionRequest import org.genspectrum.dashboardsbackend.api.Trigger +import org.genspectrum.dashboardsbackend.api.VariantRequest enum class KnownTestOrganisms { Covid, @@ -26,3 +28,23 @@ val dummySubscriptionRequest = SubscriptionRequest( organism = KnownTestOrganisms.Covid.name, active = true, ) + +val dummyQueryVariantRequest = VariantRequest.QueryVariantRequest( + name = "BA.2 in USA", + description = "BA.2 lineage cases in USA", + countQuery = "country='USA' & lineage='BA.2'", + coverageQuery = "country='USA'", +) + +val dummyMutationListVariantRequest = VariantRequest.MutationListVariantRequest( + name = "Omicron mutations", + description = "Key mutations", + mutationList = listOf("S:N501Y", "S:E484K", "S:K417N"), +) + +val dummyCollectionRequest = CollectionRequest( + name = "Test Collection", + organism = KnownTestOrganisms.Covid.name, + description = "Test collection description", + variants = listOf(dummyQueryVariantRequest, dummyMutationListVariantRequest), +) diff --git a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt new file mode 100644 index 00000000..de6cbe93 --- /dev/null +++ b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt @@ -0,0 +1,55 @@ +package org.genspectrum.dashboardsbackend.controller + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.genspectrum.dashboardsbackend.api.Collection +import org.genspectrum.dashboardsbackend.api.CollectionRequest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +class CollectionsClient(private val mockMvc: MockMvc, private val objectMapper: ObjectMapper) { + fun postCollectionRaw(collection: CollectionRequest, userId: String): ResultActions = mockMvc.perform( + post("/collections?userId=$userId") + .content(objectMapper.writeValueAsString(collection)) + .contentType(MediaType.APPLICATION_JSON), + ) + + fun postCollection(collection: CollectionRequest, userId: String): Collection = deserializeJsonResponse( + postCollectionRaw(collection, userId) + .andExpect(status().isCreated), + ) + + fun getCollectionsRaw(userId: String? = null, organism: String? = null): ResultActions { + val params = buildString { + val queryParams = mutableListOf() + if (userId != null) queryParams.add("userId=$userId") + if (organism != null) queryParams.add("organism=$organism") + if (queryParams.isNotEmpty()) { + append("?") + append(queryParams.joinToString("&")) + } + } + return mockMvc.perform(get("/collections$params")) + } + + fun getCollections(userId: String? = null, organism: String? = null): List = + deserializeJsonResponse( + getCollectionsRaw(userId, organism) + .andExpect(status().isOk), + ) + + private inline fun deserializeJsonResponse(resultActions: ResultActions): T { + val content = + resultActions + .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andReturn() + .response + .contentAsString + return objectMapper.readValue(content) + } +} diff --git a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt new file mode 100644 index 00000000..9f213ef8 --- /dev/null +++ b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt @@ -0,0 +1,271 @@ +package org.genspectrum.dashboardsbackend.controller + +import org.genspectrum.dashboardsbackend.KnownTestOrganisms +import org.genspectrum.dashboardsbackend.api.CollectionRequest +import org.genspectrum.dashboardsbackend.api.Variant +import org.genspectrum.dashboardsbackend.api.VariantRequest +import org.genspectrum.dashboardsbackend.dummyCollectionRequest +import org.genspectrum.dashboardsbackend.dummyMutationListVariantRequest +import org.genspectrum.dashboardsbackend.dummyQueryVariantRequest +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.empty +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.hasItem +import org.hamcrest.Matchers.hasSize +import org.hamcrest.Matchers.not +import org.hamcrest.Matchers.notNullValue +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +@SpringBootTest +@AutoConfigureMockMvc +@Import(CollectionsClient::class) +class CollectionsControllerTest(@param:Autowired private val collectionsClient: CollectionsClient) { + + @Test + fun `GIVEN I create a collection with variants WHEN getting collection THEN returns collection with variants and generated IDs`() { + val userId = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, userId) + + assertThat(createdCollection.id, notNullValue()) + assertThat(createdCollection.name, equalTo(dummyCollectionRequest.name)) + assertThat(createdCollection.ownedBy, equalTo(userId)) + assertThat(createdCollection.organism, equalTo(dummyCollectionRequest.organism)) + assertThat(createdCollection.description, equalTo(dummyCollectionRequest.description)) + assertThat(createdCollection.variants, hasSize(2)) + assertThat(createdCollection.variants[0].id, notNullValue()) + assertThat(createdCollection.variants[1].id, notNullValue()) + } + + @Test + fun `WHEN creating collection with only query variants THEN succeeds`() { + val userId = getNewUserId() + val request = dummyCollectionRequest.copy( + variants = listOf(dummyQueryVariantRequest), + ) + + val createdCollection = collectionsClient.postCollection(request, userId) + + assertThat(createdCollection.variants, hasSize(1)) + assertThat(createdCollection.variants[0], org.hamcrest.Matchers.instanceOf(Variant.QueryVariant::class.java)) + } + + @Test + fun `WHEN creating collection with only mutation list variants THEN succeeds`() { + val userId = getNewUserId() + val request = dummyCollectionRequest.copy( + variants = listOf(dummyMutationListVariantRequest), + ) + + val createdCollection = collectionsClient.postCollection(request, userId) + + assertThat(createdCollection.variants, hasSize(1)) + assertThat( + createdCollection.variants[0], + org.hamcrest.Matchers.instanceOf(Variant.MutationListVariant::class.java), + ) + } + + @Test + fun `WHEN creating collection with no variants THEN succeeds`() { + val userId = getNewUserId() + val request = dummyCollectionRequest.copy(variants = emptyList()) + + val createdCollection = collectionsClient.postCollection(request, userId) + + assertThat(createdCollection.variants, empty()) + } + + @Test + fun `WHEN creating collection with unknown organism THEN returns 400`() { + val userId = getNewUserId() + collectionsClient.postCollectionRaw(dummyCollectionRequest.copy(organism = "unknown organism"), userId) + .andExpect(status().isBadRequest) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("\$.detail").value("Organism 'unknown organism' is not supported")) + } + + @Test + fun `GIVEN collections for multiple users WHEN getting collections THEN users get separate collections`() { + val userA = getNewUserId() + val userB = getNewUserId() + + val collectionA = collectionsClient.postCollection(dummyCollectionRequest.copy(name = "User A Collection"), userA) + val collectionB = collectionsClient.postCollection(dummyCollectionRequest.copy(name = "User B Collection"), userB) + + val collectionsForUserA = collectionsClient.getCollections(userId = userA) + val collectionsForUserB = collectionsClient.getCollections(userId = userB) + + assertThat(collectionsForUserA, hasItem(collectionA)) + assertThat(collectionsForUserA, not(hasItem(collectionB))) + + assertThat(collectionsForUserB, hasItem(collectionB)) + assertThat(collectionsForUserB, not(hasItem(collectionA))) + } + + @Test + fun `GIVEN collections for multiple users and organisms WHEN getting all collections THEN returns all collections`() { + val userA = getNewUserId() + val userB = getNewUserId() + + val covidCollectionA = collectionsClient.postCollection( + dummyCollectionRequest.copy(name = "Covid A", organism = KnownTestOrganisms.Covid.name), + userA, + ) + val mpoxCollectionA = collectionsClient.postCollection( + dummyCollectionRequest.copy(name = "Mpox A", organism = KnownTestOrganisms.Mpox.name), + userA, + ) + val covidCollectionB = collectionsClient.postCollection( + dummyCollectionRequest.copy(name = "Covid B", organism = KnownTestOrganisms.Covid.name), + userB, + ) + + val allCollections = collectionsClient.getCollections() + + assertThat(allCollections, hasItem(covidCollectionA)) + assertThat(allCollections, hasItem(mpoxCollectionA)) + assertThat(allCollections, hasItem(covidCollectionB)) + } + + @Test + fun `GIVEN collections for multiple users WHEN getting by userId THEN returns only that user's collections`() { + val userA = getNewUserId() + val userB = getNewUserId() + + val collectionA = collectionsClient.postCollection(dummyCollectionRequest.copy(name = "User A"), userA) + val collectionB = collectionsClient.postCollection(dummyCollectionRequest.copy(name = "User B"), userB) + + val collectionsForUserA = collectionsClient.getCollections(userId = userA) + + assertThat(collectionsForUserA, hasItem(collectionA)) + assertThat(collectionsForUserA, not(hasItem(collectionB))) + } + + @Test + fun `WHEN getting collections for user with no collections THEN returns empty array`() { + val nonexistentUserId = getNewUserId() + + val collections = collectionsClient.getCollections(userId = nonexistentUserId) + + assertThat(collections, empty()) + } + + @Test + fun `GIVEN covid and mpox collections WHEN getting by organism THEN returns only that organism's collections`() { + val userId = getNewUserId() + + val covidCollection = collectionsClient.postCollection( + dummyCollectionRequest.copy(name = "Covid", organism = KnownTestOrganisms.Covid.name), + userId, + ) + val mpoxCollection = collectionsClient.postCollection( + dummyCollectionRequest.copy(name = "Mpox", organism = KnownTestOrganisms.Mpox.name), + userId, + ) + + val covidCollections = collectionsClient.getCollections(organism = KnownTestOrganisms.Covid.name) + + assertThat(covidCollections, hasItem(covidCollection)) + assertThat(covidCollections, not(hasItem(mpoxCollection))) + } + + @Test + fun `WHEN getting collections for organism with no collections THEN returns empty array`() { + val collections = collectionsClient.getCollections(organism = KnownTestOrganisms.WestNile.name) + + assertThat(collections, empty()) + } + + @Test + fun `GIVEN collections for multiple users and organisms WHEN filtering by userId AND organism THEN returns correct subset`() { + val userA = getNewUserId() + val userB = getNewUserId() + + val covidCollectionA = collectionsClient.postCollection( + dummyCollectionRequest.copy(name = "Covid A", organism = KnownTestOrganisms.Covid.name), + userA, + ) + val mpoxCollectionA = collectionsClient.postCollection( + dummyCollectionRequest.copy(name = "Mpox A", organism = KnownTestOrganisms.Mpox.name), + userA, + ) + val covidCollectionB = collectionsClient.postCollection( + dummyCollectionRequest.copy(name = "Covid B", organism = KnownTestOrganisms.Covid.name), + userB, + ) + + val filteredCollections = collectionsClient.getCollections( + userId = userA, + organism = KnownTestOrganisms.Covid.name, + ) + + assertThat(filteredCollections, hasItem(covidCollectionA)) + assertThat(filteredCollections, not(hasItem(mpoxCollectionA))) + assertThat(filteredCollections, not(hasItem(covidCollectionB))) + } + + @Test + fun `GIVEN collections WHEN filtering by userId and organism with no matches THEN returns empty array`() { + val userA = getNewUserId() + collectionsClient.postCollection( + dummyCollectionRequest.copy(organism = KnownTestOrganisms.Covid.name), + userA, + ) + + val collections = collectionsClient.getCollections(userId = userA, organism = KnownTestOrganisms.Mpox.name) + + assertThat(collections, empty()) + } + + @Test + fun `GIVEN collection with variants WHEN getting collection THEN all variant fields are present`() { + val userId = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, userId) + + val collections = collectionsClient.getCollections(userId = userId) + val retrievedCollection = collections.first { it.id == createdCollection.id } + + assertThat(retrievedCollection.variants, hasSize(2)) + + val queryVariant = retrievedCollection.variants.first { it is Variant.QueryVariant } as Variant.QueryVariant + assertThat(queryVariant.name, equalTo("BA.2 in USA")) + assertThat(queryVariant.description, equalTo("BA.2 lineage cases in USA")) + assertThat(queryVariant.countQuery, equalTo("country='USA' & lineage='BA.2'")) + assertThat(queryVariant.coverageQuery, equalTo("country='USA'")) + + val mutationListVariant = + retrievedCollection.variants.first { it is Variant.MutationListVariant } as Variant.MutationListVariant + assertThat(mutationListVariant.name, equalTo("Omicron mutations")) + assertThat(mutationListVariant.description, equalTo("Key mutations")) + assertThat(mutationListVariant.mutationList, equalTo(listOf("S:N501Y", "S:E484K", "S:K417N"))) + } + + @Test + fun `GIVEN collection with both variant types WHEN getting collection THEN variant types are correctly discriminated`() { + val userId = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, userId) + + val collections = collectionsClient.getCollections(userId = userId) + val retrievedCollection = collections.first { it.id == createdCollection.id } + + val queryVariants = retrievedCollection.variants.filterIsInstance() + val mutationListVariants = retrievedCollection.variants.filterIsInstance() + + assertThat(queryVariants, hasSize(1)) + assertThat(mutationListVariants, hasSize(1)) + + // Verify QueryVariant has query fields + assertThat(queryVariants[0].countQuery, notNullValue()) + + // Verify MutationListVariant has mutationList field + assertThat(mutationListVariants[0].mutationList, notNullValue()) + } +} From 47a909ea9e0abfdbfe4d01c699220a08d8bfd885 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 11 Mar 2026 14:13:15 +0100 Subject: [PATCH 04/19] Add setup to spin up local postgres instance for testing --- backend/README.md | 28 ++++++++++++++++++- backend/docker-compose.dev.yaml | 20 +++++++++++++ .../main/resources/application-local-db.yaml | 2 +- 3 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 backend/docker-compose.dev.yaml diff --git a/backend/README.md b/backend/README.md index a2915631..0cc4b0ee 100644 --- a/backend/README.md +++ b/backend/README.md @@ -12,14 +12,40 @@ You have to provide config information to the backend: * Dashboards configuration, e.g. the LAPIS instances of the organisms. We have profiles available that only need to be activated via `spring.profiles.active`. * Database connection configuration: values need to be passed via [external properties](https://docs.spring.io/spring-boot/reference/features/external-config.html). - For local development, we have a `local-db` profile available. + For local development, we have a `local-db` profile available. You can also check that for required properties. +### Start local database + +Start the local PostgreSQL database using Docker Compose: + +```bash +docker-compose -f docker-compose.dev.yaml up -d +``` + +Stop the database: + +```bash +docker-compose -f docker-compose.dev.yaml down +``` + +Stop and remove data volumes: + +```bash +docker-compose -f docker-compose.dev.yaml down -v +``` + +### Run the backend + To run the backend locally, you can use the following command: ```bash ./gradlew bootRun --args='--spring.profiles.active=local-db,dashboards-prod' ``` +The backend will be available at: +- Base URL: `http://localhost:8080` +- Swagger UI: `http://localhost:8080/swagger-ui/index.html` + Run tests: ```bash diff --git a/backend/docker-compose.dev.yaml b/backend/docker-compose.dev.yaml new file mode 100644 index 00000000..6ba7ac0f --- /dev/null +++ b/backend/docker-compose.dev.yaml @@ -0,0 +1,20 @@ +services: + postgres: + image: postgres:16-alpine + container_name: dashboards-backend-db + environment: + POSTGRES_DB: dashboards-backend-db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: unsecure + ports: + - "9022:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + postgres_data: diff --git a/backend/src/main/resources/application-local-db.yaml b/backend/src/main/resources/application-local-db.yaml index 902be2b2..4d2f9d0b 100644 --- a/backend/src/main/resources/application-local-db.yaml +++ b/backend/src/main/resources/application-local-db.yaml @@ -1,5 +1,5 @@ spring: datasource: - url: "jdbc:postgresql://localhost:9022/subscriptions" + url: "jdbc:postgresql://localhost:9022/dashboards-backend-db" username: "postgres" password: "unsecure" From 602b2139312494a8dcdbfc44607505c36ababef1 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 11 Mar 2026 14:26:48 +0100 Subject: [PATCH 05/19] Add 'GET' for collections by ID --- .../controller/CollectionsController.kt | 10 ++++ .../model/collection/CollectionModel.kt | 7 +++ .../controller/CollectionsClient.kt | 7 +++ .../controller/CollectionsControllerTest.kt | 46 +++++++++++++++++++ 4 files changed, 70 insertions(+) diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt index 9416027b..607cf3fb 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt @@ -7,6 +7,7 @@ import org.genspectrum.dashboardsbackend.model.collection.CollectionModel import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestParam @@ -28,6 +29,15 @@ class CollectionsController(private val collectionModel: CollectionModel) { organism = organism, ) + @GetMapping("/collections/{id}", produces = [MediaType.APPLICATION_JSON_VALUE]) + @Operation( + summary = "Get a collection by ID", + description = "Returns a single collection with all its variants.", + ) + fun getCollection( + @IdParameter @PathVariable id: String, + ): Collection = collectionModel.getCollection(id) + @PostMapping("/collections") @ResponseStatus(HttpStatus.CREATED) @Operation( diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt index 79f5eac5..8f146814 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt @@ -5,6 +5,8 @@ import org.genspectrum.dashboardsbackend.api.CollectionRequest import org.genspectrum.dashboardsbackend.api.VariantRequest import org.genspectrum.dashboardsbackend.config.DashboardsConfig import org.genspectrum.dashboardsbackend.config.validateIsValidOrganism +import org.genspectrum.dashboardsbackend.controller.NotFoundException +import org.genspectrum.dashboardsbackend.util.convertToUuid import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.Op import org.jetbrains.exposed.sql.and @@ -38,6 +40,11 @@ class CollectionModel(pool: DataSource, private val dashboardsConfig: Dashboards return query.map { it.toCollection() } } + fun getCollection(id: String): Collection = + CollectionEntity.findById(convertToUuid(id)) + ?.toCollection() + ?: throw NotFoundException("Collection $id not found") + fun createCollection(request: CollectionRequest, userId: String): Collection { dashboardsConfig.validateIsValidOrganism(request.organism) diff --git a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt index de6cbe93..210e76ef 100644 --- a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt +++ b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt @@ -43,6 +43,13 @@ class CollectionsClient(private val mockMvc: MockMvc, private val objectMapper: .andExpect(status().isOk), ) + fun getCollectionRaw(id: String): ResultActions = mockMvc.perform(get("/collections/$id")) + + fun getCollection(id: String): Collection = deserializeJsonResponse( + getCollectionRaw(id) + .andExpect(status().isOk), + ) + private inline fun deserializeJsonResponse(resultActions: ResultActions): T { val content = resultActions diff --git a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt index 9f213ef8..95860504 100644 --- a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt +++ b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt @@ -268,4 +268,50 @@ class CollectionsControllerTest(@param:Autowired private val collectionsClient: // Verify MutationListVariant has mutationList field assertThat(mutationListVariants[0].mutationList, notNullValue()) } + + @Test + fun `GIVEN collection exists WHEN getting collection by ID THEN returns collection with all fields and variants`() { + val userId = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, userId) + + val retrievedCollection = collectionsClient.getCollection(createdCollection.id) + + assertThat(retrievedCollection.id, equalTo(createdCollection.id)) + assertThat(retrievedCollection.name, equalTo(dummyCollectionRequest.name)) + assertThat(retrievedCollection.ownedBy, equalTo(userId)) + assertThat(retrievedCollection.organism, equalTo(dummyCollectionRequest.organism)) + assertThat(retrievedCollection.description, equalTo(dummyCollectionRequest.description)) + assertThat(retrievedCollection.variants, hasSize(2)) + } + + @Test + fun `GIVEN collection created by different user WHEN getting collection by ID THEN returns collection (public access)`() { + val userA = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest.copy(name = "User A Collection"), userA) + + val retrievedCollection = collectionsClient.getCollection(createdCollection.id) + + assertThat(retrievedCollection.id, equalTo(createdCollection.id)) + assertThat(retrievedCollection.ownedBy, equalTo(userA)) + } + + @Test + fun `WHEN getting collection with non-existent ID THEN returns 404`() { + val nonExistentId = "00000000-0000-0000-0000-000000000000" + + collectionsClient.getCollectionRaw(nonExistentId) + .andExpect(status().isNotFound) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.detail").value("Collection $nonExistentId not found")) + } + + @Test + fun `WHEN getting collection with invalid UUID format THEN returns 400`() { + val invalidId = "not-a-uuid" + + collectionsClient.getCollectionRaw(invalidId) + .andExpect(status().isBadRequest) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.detail").value("Invalid UUID $invalidId")) + } } From 96d212691ca87a139e7a65ade2c8bf0a61106c11 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 11 Mar 2026 14:38:03 +0100 Subject: [PATCH 06/19] foo --- .../config/DashboardsConfig.kt | 7 ++++- .../controller/CollectionsController.kt | 4 +-- .../model/collection/CollectionModel.kt | 7 +++-- .../application-dashboards-prod.yaml | 3 +++ .../controller/CollectionsClient.kt | 9 +++---- .../controller/CollectionsControllerTest.kt | 27 ++++++++++++------- 6 files changed, 34 insertions(+), 23 deletions(-) diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/config/DashboardsConfig.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/config/DashboardsConfig.kt index bd72a908..68814c8f 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/config/DashboardsConfig.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/config/DashboardsConfig.kt @@ -11,7 +11,12 @@ data class DashboardsConfig(val organisms: Map) { data class OrganismConfig(val lapis: LapisConfig, val externalNavigationLinks: List?) -data class LapisConfig(val url: String, val mainDateField: String, val additionalFilters: Map?) +data class LapisConfig( + val url: String, + val mainDateField: String, + val lineageFields: List?, + val additionalFilters: Map?, +) data class ExternalNavigationLink(val url: String, val label: String, val menuIcon: String, val description: String) diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt index 607cf3fb..ac35be68 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt @@ -34,9 +34,7 @@ class CollectionsController(private val collectionModel: CollectionModel) { summary = "Get a collection by ID", description = "Returns a single collection with all its variants.", ) - fun getCollection( - @IdParameter @PathVariable id: String, - ): Collection = collectionModel.getCollection(id) + fun getCollection(@IdParameter @PathVariable id: String): Collection = collectionModel.getCollection(id) @PostMapping("/collections") @ResponseStatus(HttpStatus.CREATED) diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt index 8f146814..0b92bd3b 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt @@ -40,10 +40,9 @@ class CollectionModel(pool: DataSource, private val dashboardsConfig: Dashboards return query.map { it.toCollection() } } - fun getCollection(id: String): Collection = - CollectionEntity.findById(convertToUuid(id)) - ?.toCollection() - ?: throw NotFoundException("Collection $id not found") + fun getCollection(id: String): Collection = CollectionEntity.findById(convertToUuid(id)) + ?.toCollection() + ?: throw NotFoundException("Collection $id not found") fun createCollection(request: CollectionRequest, userId: String): Collection { dashboardsConfig.validateIsValidOrganism(request.organism) diff --git a/backend/src/main/resources/application-dashboards-prod.yaml b/backend/src/main/resources/application-dashboards-prod.yaml index c5510dcf..f170d623 100644 --- a/backend/src/main/resources/application-dashboards-prod.yaml +++ b/backend/src/main/resources/application-dashboards-prod.yaml @@ -4,6 +4,9 @@ dashboards: lapis: url: "https://lapis.cov-spectrum.org/open/v2" mainDateField: "date" + lineageFields: + - "pangoLineage" + - "nextcladePangoLineage" externalNavigationLinks: - url: "https://cov-spectrum.org" label: "CoV-Spectrum" diff --git a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt index 210e76ef..818eb8c5 100644 --- a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt +++ b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt @@ -37,11 +37,10 @@ class CollectionsClient(private val mockMvc: MockMvc, private val objectMapper: return mockMvc.perform(get("/collections$params")) } - fun getCollections(userId: String? = null, organism: String? = null): List = - deserializeJsonResponse( - getCollectionsRaw(userId, organism) - .andExpect(status().isOk), - ) + fun getCollections(userId: String? = null, organism: String? = null): List = deserializeJsonResponse( + getCollectionsRaw(userId, organism) + .andExpect(status().isOk), + ) fun getCollectionRaw(id: String): ResultActions = mockMvc.perform(get("/collections/$id")) diff --git a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt index 95860504..df6d7fa4 100644 --- a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt +++ b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt @@ -1,9 +1,7 @@ package org.genspectrum.dashboardsbackend.controller import org.genspectrum.dashboardsbackend.KnownTestOrganisms -import org.genspectrum.dashboardsbackend.api.CollectionRequest import org.genspectrum.dashboardsbackend.api.Variant -import org.genspectrum.dashboardsbackend.api.VariantRequest import org.genspectrum.dashboardsbackend.dummyCollectionRequest import org.genspectrum.dashboardsbackend.dummyMutationListVariantRequest import org.genspectrum.dashboardsbackend.dummyQueryVariantRequest @@ -30,7 +28,7 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status class CollectionsControllerTest(@param:Autowired private val collectionsClient: CollectionsClient) { @Test - fun `GIVEN I create a collection with variants WHEN getting collection THEN returns collection with variants and generated IDs`() { + fun `GIVEN collection with variants WHEN creating THEN returns with generated IDs`() { val userId = getNewUserId() val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, userId) @@ -97,8 +95,14 @@ class CollectionsControllerTest(@param:Autowired private val collectionsClient: val userA = getNewUserId() val userB = getNewUserId() - val collectionA = collectionsClient.postCollection(dummyCollectionRequest.copy(name = "User A Collection"), userA) - val collectionB = collectionsClient.postCollection(dummyCollectionRequest.copy(name = "User B Collection"), userB) + val collectionA = collectionsClient.postCollection( + dummyCollectionRequest.copy(name = "User A Collection"), + userA, + ) + val collectionB = collectionsClient.postCollection( + dummyCollectionRequest.copy(name = "User B Collection"), + userB, + ) val collectionsForUserA = collectionsClient.getCollections(userId = userA) val collectionsForUserB = collectionsClient.getCollections(userId = userB) @@ -111,7 +115,7 @@ class CollectionsControllerTest(@param:Autowired private val collectionsClient: } @Test - fun `GIVEN collections for multiple users and organisms WHEN getting all collections THEN returns all collections`() { + fun `GIVEN multiple collections WHEN getting all THEN returns all`() { val userA = getNewUserId() val userB = getNewUserId() @@ -185,7 +189,7 @@ class CollectionsControllerTest(@param:Autowired private val collectionsClient: } @Test - fun `GIVEN collections for multiple users and organisms WHEN filtering by userId AND organism THEN returns correct subset`() { + fun `GIVEN multiple collections WHEN filtering by userId AND organism THEN returns subset`() { val userA = getNewUserId() val userB = getNewUserId() @@ -249,7 +253,7 @@ class CollectionsControllerTest(@param:Autowired private val collectionsClient: } @Test - fun `GIVEN collection with both variant types WHEN getting collection THEN variant types are correctly discriminated`() { + fun `GIVEN both variant types WHEN getting collection THEN types are discriminated`() { val userId = getNewUserId() val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, userId) @@ -285,9 +289,12 @@ class CollectionsControllerTest(@param:Autowired private val collectionsClient: } @Test - fun `GIVEN collection created by different user WHEN getting collection by ID THEN returns collection (public access)`() { + fun `GIVEN different user's collection WHEN getting by ID THEN returns (public access)`() { val userA = getNewUserId() - val createdCollection = collectionsClient.postCollection(dummyCollectionRequest.copy(name = "User A Collection"), userA) + val createdCollection = collectionsClient.postCollection( + dummyCollectionRequest.copy(name = "User A Collection"), + userA, + ) val retrievedCollection = collectionsClient.getCollection(createdCollection.id) From 45b35a9b736795c274f1266f8ee11822aca95a29 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 11 Mar 2026 15:19:11 +0100 Subject: [PATCH 07/19] change mutation list defintion --- .../dashboardsbackend/api/Collection.kt | 12 +- .../api/MutationListDefinition.kt | 51 ++++++++ .../model/collection/CollectionModel.kt | 18 +++ .../model/collection/VariantTable.kt | 5 +- .../dashboardsbackend/TestHelpers.kt | 4 +- .../controller/CollectionsControllerTest.kt | 117 +++++++++++++++++- backend/src/test/resources/application.yaml | 3 + 7 files changed, 202 insertions(+), 8 deletions(-) create mode 100644 backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/MutationListDefinition.kt diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Collection.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Collection.kt index 773aef58..c7abda7f 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Collection.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Collection.kt @@ -116,7 +116,9 @@ sealed interface Variant { "collectionId": "660e8400-e29b-41d4-a716-446655440000", "name": "Omicron mutations", "description": "Key mutations for Omicron", - "mutationList": ["S:N501Y", "S:E484K", "S:K417N"] + "mutationList": { + "aaMutations": ["S:N501Y", "S:E484K", "S:K417N"] + } } """, ) @@ -125,7 +127,7 @@ sealed interface Variant { override val collectionId: String, val name: String, val description: String?, - val mutationList: List, + val mutationList: MutationListDefinition, ) : Variant { val type: MutationListVariantType = MutationListVariantType.MUTATION_LIST } @@ -170,13 +172,15 @@ sealed interface VariantRequest { "type": "mutationList", "name": "Omicron mutations", "description": "Key mutations for Omicron", - "mutationList": ["S:N501Y", "S:E484K", "S:K417N"] + "mutationList": { + "aaMutations": ["S:N501Y", "S:E484K", "S:K417N"] + } } """, ) data class MutationListVariantRequest( val name: String, val description: String? = null, - val mutationList: List, + val mutationList: MutationListDefinition, ) : VariantRequest } diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/MutationListDefinition.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/MutationListDefinition.kt new file mode 100644 index 00000000..6c8debcd --- /dev/null +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/MutationListDefinition.kt @@ -0,0 +1,51 @@ +package org.genspectrum.dashboardsbackend.api + +import com.fasterxml.jackson.annotation.JsonAnyGetter +import com.fasterxml.jackson.annotation.JsonAnySetter +import com.fasterxml.jackson.annotation.JsonIgnore + +/** + * A JSON object with mutation lists (keys: aaMutations, nucMutations, ...) + * as well as lineage filtering (keys are defined by the organism config) + */ +data class MutationListDefinition( + val aaMutations: List? = null, + val nucMutations: List? = null, + val aaInsertions: List? = null, + val nucInsertions: List? = null, +) { + @JsonIgnore + private val lineageFiltersInternal: MutableMap = mutableMapOf() + + val lineageFilters: Map + get() = lineageFiltersInternal + + @get:JsonAnyGetter + val additionalProperties: Map + get() = lineageFiltersInternal + + @JsonAnySetter + fun put(key: String, value: Any) { + if (key !in KNOWN_FIELDS && value is String) { + lineageFiltersInternal[key] = value + } + } + + companion object { + private val KNOWN_FIELDS = setOf("aaMutations", "nucMutations", "aaInsertions", "nucInsertions") + + fun create( + aaMutations: List? = null, + nucMutations: List? = null, + aaInsertions: List? = null, + nucInsertions: List? = null, + lineageFilters: Map = emptyMap(), + ): MutationListDefinition { + val definition = MutationListDefinition(aaMutations, nucMutations, aaInsertions, nucInsertions) + lineageFilters.forEach { (key, value) -> + definition.put(key, value) + } + return definition + } + } +} diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt index 0b92bd3b..4459f0f7 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt @@ -68,6 +68,24 @@ class CollectionModel(pool: DataSource, private val dashboardsConfig: Dashboards } } is VariantRequest.MutationListVariantRequest -> { + // Validate lineage filters + val organismConfig = dashboardsConfig.getOrganismConfig(request.organism) + val validLineageFields = organismConfig.lapis.lineageFields ?: emptyList() + + val invalidFields = variantRequest.mutationList.lineageFilters.keys - validLineageFields.toSet() + if (invalidFields.isNotEmpty()) { + val validFieldsStr = if (validLineageFields.isEmpty()) { + "no lineage fields are configured" + } else { + "valid fields are: ${validLineageFields.joinToString(", ")}" + } + throw org.genspectrum.dashboardsbackend.controller.BadRequestException( + "Invalid lineage fields for organism '${request.organism}': ${invalidFields.joinToString( + ", ", + )}. $validFieldsStr", + ) + } + VariantEntity.new { this.collectionId = collectionEntity.id this.variantType = VariantType.MUTATION_LIST diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/VariantTable.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/VariantTable.kt index f19dcbab..20e76b0e 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/VariantTable.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/VariantTable.kt @@ -43,8 +43,9 @@ object VariantTable : UUIDTable(VARIANT_TABLE) { val countQuery = text("count_query").nullable() val coverageQuery = text("coverage_query").nullable() - // TODO - the List isn't correct, the type is more complex than that - val mutationList = jacksonSerializableJsonb>("mutation_list").nullable() + val mutationList = jacksonSerializableJsonb( + "mutation_list", + ).nullable() } class VariantEntity(id: EntityID) : UUIDEntity(id) { diff --git a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/TestHelpers.kt b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/TestHelpers.kt index 1f65abae..3aabc373 100644 --- a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/TestHelpers.kt +++ b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/TestHelpers.kt @@ -39,7 +39,9 @@ val dummyQueryVariantRequest = VariantRequest.QueryVariantRequest( val dummyMutationListVariantRequest = VariantRequest.MutationListVariantRequest( name = "Omicron mutations", description = "Key mutations", - mutationList = listOf("S:N501Y", "S:E484K", "S:K417N"), + mutationList = org.genspectrum.dashboardsbackend.api.MutationListDefinition( + aaMutations = listOf("S:N501Y", "S:E484K", "S:K417N"), + ), ) val dummyCollectionRequest = CollectionRequest( diff --git a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt index df6d7fa4..abac06a0 100644 --- a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt +++ b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt @@ -2,6 +2,7 @@ package org.genspectrum.dashboardsbackend.controller import org.genspectrum.dashboardsbackend.KnownTestOrganisms import org.genspectrum.dashboardsbackend.api.Variant +import org.genspectrum.dashboardsbackend.api.VariantRequest import org.genspectrum.dashboardsbackend.dummyCollectionRequest import org.genspectrum.dashboardsbackend.dummyMutationListVariantRequest import org.genspectrum.dashboardsbackend.dummyQueryVariantRequest @@ -249,7 +250,7 @@ class CollectionsControllerTest(@param:Autowired private val collectionsClient: retrievedCollection.variants.first { it is Variant.MutationListVariant } as Variant.MutationListVariant assertThat(mutationListVariant.name, equalTo("Omicron mutations")) assertThat(mutationListVariant.description, equalTo("Key mutations")) - assertThat(mutationListVariant.mutationList, equalTo(listOf("S:N501Y", "S:E484K", "S:K417N"))) + assertThat(mutationListVariant.mutationList.aaMutations, equalTo(listOf("S:N501Y", "S:E484K", "S:K417N"))) } @Test @@ -321,4 +322,118 @@ class CollectionsControllerTest(@param:Autowired private val collectionsClient: .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.detail").value("Invalid UUID $invalidId")) } + + // MutationListDefinition Tests + + @Test + fun `WHEN creating variant with lineage filter THEN succeeds`() { + val userId = getNewUserId() + val variantWithLineage = VariantRequest.MutationListVariantRequest( + name = "BA.2 lineage", + description = "BA.2 variant", + mutationList = org.genspectrum.dashboardsbackend.api.MutationListDefinition.create( + aaMutations = listOf("S:N501Y"), + lineageFilters = mapOf("pangoLineage" to "BA.2*"), + ), + ) + val request = dummyCollectionRequest.copy(variants = listOf(variantWithLineage)) + + val createdCollection = collectionsClient.postCollection(request, userId) + + assertThat(createdCollection.variants, hasSize(1)) + val variant = createdCollection.variants[0] as Variant.MutationListVariant + assertThat(variant.mutationList.aaMutations, equalTo(listOf("S:N501Y"))) + assertThat(variant.mutationList.lineageFilters["pangoLineage"], equalTo("BA.2*")) + } + + @Test + fun `WHEN creating variant with invalid lineage field THEN returns 400`() { + val userId = getNewUserId() + val variantWithInvalidLineage = VariantRequest.MutationListVariantRequest( + name = "Invalid lineage", + description = "Has invalid lineage field", + mutationList = org.genspectrum.dashboardsbackend.api.MutationListDefinition.create( + aaMutations = emptyList(), + lineageFilters = mapOf("invalidLineageField" to "value"), + ), + ) + val request = dummyCollectionRequest.copy(variants = listOf(variantWithInvalidLineage)) + + collectionsClient.postCollectionRaw(request, userId) + .andExpect(status().isBadRequest) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect( + jsonPath("$.detail").value( + org.hamcrest.Matchers.containsString("Invalid lineage fields for organism 'Covid'"), + ), + ) + .andExpect( + jsonPath("$.detail").value( + org.hamcrest.Matchers.containsString("invalidLineageField"), + ), + ) + } + + @Test + fun `WHEN creating variant with multiple lineage filters THEN succeeds`() { + val userId = getNewUserId() + val variantWithMultipleLineages = VariantRequest.MutationListVariantRequest( + name = "Multiple lineages", + description = "Has multiple lineage filters", + mutationList = org.genspectrum.dashboardsbackend.api.MutationListDefinition.create( + aaMutations = listOf("S:K417N"), + lineageFilters = mapOf( + "pangoLineage" to "BA.2*", + "nextcladePangoLineage" to "BA.2.75*", + ), + ), + ) + val request = dummyCollectionRequest.copy(variants = listOf(variantWithMultipleLineages)) + + val createdCollection = collectionsClient.postCollection(request, userId) + + val variant = createdCollection.variants[0] as Variant.MutationListVariant + assertThat(variant.mutationList.lineageFilters["pangoLineage"], equalTo("BA.2*")) + assertThat(variant.mutationList.lineageFilters["nextcladePangoLineage"], equalTo("BA.2.75*")) + } + + @Test + fun `WHEN creating variant with only aaMutations THEN succeeds`() { + val userId = getNewUserId() + val variantWithOnlyAaMutations = VariantRequest.MutationListVariantRequest( + name = "Only AA mutations", + description = "Only has amino acid mutations", + mutationList = org.genspectrum.dashboardsbackend.api.MutationListDefinition( + aaMutations = listOf("S:N501Y", "S:E484K"), + ), + ) + val request = dummyCollectionRequest.copy(variants = listOf(variantWithOnlyAaMutations)) + + val createdCollection = collectionsClient.postCollection(request, userId) + + val variant = createdCollection.variants[0] as Variant.MutationListVariant + assertThat(variant.mutationList.aaMutations, equalTo(listOf("S:N501Y", "S:E484K"))) + assertThat(variant.mutationList.nucMutations, org.hamcrest.Matchers.nullValue()) + } + + @Test + fun `WHEN creating variant with insertions THEN succeeds`() { + val userId = getNewUserId() + val variantWithInsertions = VariantRequest.MutationListVariantRequest( + name = "With insertions", + description = "Has insertions", + mutationList = org.genspectrum.dashboardsbackend.api.MutationListDefinition( + aaMutations = listOf("S:N501Y"), + aaInsertions = listOf("ins_S:214:EPE"), + nucInsertions = listOf("ins_22204:GAG"), + ), + ) + val request = dummyCollectionRequest.copy(variants = listOf(variantWithInsertions)) + + val createdCollection = collectionsClient.postCollection(request, userId) + + val variant = createdCollection.variants[0] as Variant.MutationListVariant + assertThat(variant.mutationList.aaInsertions, equalTo(listOf("ins_S:214:EPE"))) + assertThat(variant.mutationList.nucInsertions, equalTo(listOf("ins_22204:GAG"))) + } } diff --git a/backend/src/test/resources/application.yaml b/backend/src/test/resources/application.yaml index ada076c8..afafa819 100644 --- a/backend/src/test/resources/application.yaml +++ b/backend/src/test/resources/application.yaml @@ -11,6 +11,9 @@ dashboards: lapis: url: "https://covid.lapis.dummy" mainDateField: "covid_date" + lineageFields: + - "pangoLineage" + - "nextcladePangoLineage" additionalFilters: someAdditionalFilter: "covid_additional_filter" Mpox: From ab9058bf24b50ba883710561ae5532659915fc06 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Thu, 12 Mar 2026 09:24:53 +0100 Subject: [PATCH 08/19] Add delete implementation --- .../controller/CollectionsController.kt | 10 +++++ .../controller/ExceptionHandler.kt | 12 +++++ .../model/collection/CollectionModel.kt | 12 +++++ .../controller/CollectionsClient.kt | 8 ++++ .../controller/CollectionsControllerTest.kt | 45 +++++++++++++++++++ 5 files changed, 87 insertions(+) diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt index ac35be68..77a031ef 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt @@ -6,6 +6,7 @@ import org.genspectrum.dashboardsbackend.api.CollectionRequest import org.genspectrum.dashboardsbackend.model.collection.CollectionModel import org.springframework.http.HttpStatus import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping @@ -49,4 +50,13 @@ class CollectionsController(private val collectionModel: CollectionModel) { request = collection, userId = userId, ) + + @DeleteMapping("/collections/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "Delete a collection", + description = "Deletes a collection. Only the owner can delete their collection.", + ) + fun deleteCollection(@IdParameter @PathVariable id: String, @UserIdParameter @RequestParam userId: String) = + collectionModel.deleteCollection(id, userId) } diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/ExceptionHandler.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/ExceptionHandler.kt index cc89c9e4..363490cc 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/ExceptionHandler.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/ExceptionHandler.kt @@ -47,6 +47,17 @@ class ExceptionHandler : ResponseEntityExceptionHandler() { ) } + @ExceptionHandler(ForbiddenException::class) + @ResponseStatus(HttpStatus.FORBIDDEN) + fun handleForbiddenException(e: Exception): ResponseEntity { + log.info { "Caught ${e.javaClass}: ${e.message}" } + + return responseEntity( + HttpStatus.FORBIDDEN, + e.message, + ) + } + private fun responseEntity(httpStatus: HttpStatus, detail: String?): ResponseEntity = responseEntity(httpStatus, httpStatus.reasonPhrase, detail) @@ -80,3 +91,4 @@ class ExceptionHandler : ResponseEntityExceptionHandler() { class BadRequestException(message: String) : RuntimeException(message) class NotFoundException(message: String) : RuntimeException(message) +class ForbiddenException(message: String) : RuntimeException(message) diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt index 4459f0f7..bc120345 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt @@ -5,6 +5,7 @@ import org.genspectrum.dashboardsbackend.api.CollectionRequest import org.genspectrum.dashboardsbackend.api.VariantRequest import org.genspectrum.dashboardsbackend.config.DashboardsConfig import org.genspectrum.dashboardsbackend.config.validateIsValidOrganism +import org.genspectrum.dashboardsbackend.controller.ForbiddenException import org.genspectrum.dashboardsbackend.controller.NotFoundException import org.genspectrum.dashboardsbackend.util.convertToUuid import org.jetbrains.exposed.sql.Database @@ -110,4 +111,15 @@ class CollectionModel(pool: DataSource, private val dashboardsConfig: Dashboards variants = variantEntities.map { it.toVariant() }, ) } + + fun deleteCollection(id: String, userId: String) { + val uuid = convertToUuid(id) + + // Find with ownership check + val entity = CollectionEntity.findForUser(uuid, userId) + ?: throw ForbiddenException("Collection $id not found or you don't have permission to delete it") + + // Delete (variants cascade automatically via DB constraint) + entity.delete() + } } diff --git a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt index 818eb8c5..b2f264b8 100644 --- a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt +++ b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt @@ -7,6 +7,7 @@ import org.genspectrum.dashboardsbackend.api.CollectionRequest import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content @@ -49,6 +50,13 @@ class CollectionsClient(private val mockMvc: MockMvc, private val objectMapper: .andExpect(status().isOk), ) + fun deleteCollectionRaw(id: String, userId: String): ResultActions = + mockMvc.perform(delete("/collections/$id?userId=$userId")) + + fun deleteCollection(id: String, userId: String) { + deleteCollectionRaw(id, userId).andExpect(status().isNoContent) + } + private inline fun deserializeJsonResponse(resultActions: ResultActions): T { val content = resultActions diff --git a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt index abac06a0..81305bde 100644 --- a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt +++ b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt @@ -22,6 +22,7 @@ import org.springframework.http.MediaType import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.util.UUID @SpringBootTest @AutoConfigureMockMvc @@ -436,4 +437,48 @@ class CollectionsControllerTest(@param:Autowired private val collectionsClient: assertThat(variant.mutationList.aaInsertions, equalTo(listOf("ins_S:214:EPE"))) assertThat(variant.mutationList.nucInsertions, equalTo(listOf("ins_22204:GAG"))) } + + @Test + fun `WHEN owner deletes collection THEN succeeds and collection is removed`() { + val userId = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, userId) + + collectionsClient.deleteCollection(createdCollection.id, userId) + + collectionsClient.getCollectionRaw(createdCollection.id) + .andExpect(status().isNotFound) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.detail").value("Collection ${createdCollection.id} not found")) + } + + @Test + fun `WHEN non-owner deletes collection THEN returns 403 forbidden`() { + val owner = getNewUserId() + val nonOwner = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, owner) + + collectionsClient.deleteCollectionRaw(createdCollection.id, nonOwner) + .andExpect(status().isForbidden) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect( + jsonPath("$.detail").value( + org.hamcrest.Matchers.containsString("you don't have permission to delete it"), + ), + ) + } + + @Test + fun `WHEN deleting non-existent collection THEN returns 403`() { + val userId = getNewUserId() + val nonExistentId = UUID.randomUUID().toString() + + collectionsClient.deleteCollectionRaw(nonExistentId, userId) + .andExpect(status().isForbidden) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect( + jsonPath("$.detail").value( + org.hamcrest.Matchers.containsString("not found or you don't have permission"), + ), + ) + } } From 0adfe3977444d8e90366fa627cefdcf47dea98d0 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Thu, 12 Mar 2026 14:01:17 +0100 Subject: [PATCH 09/19] put pt1 --- .../dashboardsbackend/api/Collection.kt | 88 ++++++ .../controller/CollectionsController.kt | 14 + .../model/collection/CollectionModel.kt | 160 +++++++++++ .../controller/CollectionsClient.kt | 13 + .../controller/CollectionsControllerTest.kt | 253 ++++++++++++++++++ 5 files changed, 528 insertions(+) diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Collection.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Collection.kt index c7abda7f..98c4fc70 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Collection.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Collection.kt @@ -56,6 +56,36 @@ data class CollectionRequest( val variants: List, ) +@Schema( + description = "Request to update a collection", + example = """ +{ + "name": "Updated Collection Name", + "description": "Updated description", + "variants": [ + { + "type": "query", + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "BA.2 in USA", + "description": "BA.2 lineage cases in USA", + "countQuery": "country='USA' & lineage='BA.2'", + "coverageQuery": "country='USA'" + }, + { + "type": "query", + "name": "New Variant Without ID", + "countQuery": "country='Germany'" + } + ] +} +""", +) +data class CollectionUpdate( + val name: String? = null, + val description: String? = null, + val variants: List? = null, +) + @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, @@ -184,3 +214,61 @@ sealed interface VariantRequest { val mutationList: MutationListDefinition, ) : VariantRequest } + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type", +) +@JsonSubTypes( + JsonSubTypes.Type(value = VariantUpdate.QueryVariantUpdate::class, name = "query"), + JsonSubTypes.Type(value = VariantUpdate.MutationListVariantUpdate::class, name = "mutationList"), +) +@Schema( + description = "Request to update or create a variant", +) +sealed interface VariantUpdate { + val id: String? + + @Schema( + description = "Request to update or create a query variant", + example = """ +{ + "type": "query", + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "BA.2 in USA", + "description": "BA.2 lineage cases in USA", + "countQuery": "country='USA' & lineage='BA.2'", + "coverageQuery": "country='USA'" +} +""", + ) + data class QueryVariantUpdate( + override val id: String? = null, + val name: String, + val description: String? = null, + val countQuery: String, + val coverageQuery: String? = null, + ) : VariantUpdate + + @Schema( + description = "Request to update or create a mutation list variant", + example = """ +{ + "type": "mutationList", + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Omicron mutations", + "description": "Key mutations for Omicron", + "mutationList": { + "aaMutations": ["S:N501Y", "S:E484K", "S:K417N"] + } +} +""", + ) + data class MutationListVariantUpdate( + override val id: String? = null, + val name: String, + val description: String? = null, + val mutationList: MutationListDefinition, + ) : VariantUpdate +} diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt index 77a031ef..174accd5 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt @@ -3,6 +3,7 @@ package org.genspectrum.dashboardsbackend.controller import io.swagger.v3.oas.annotations.Operation import org.genspectrum.dashboardsbackend.api.Collection import org.genspectrum.dashboardsbackend.api.CollectionRequest +import org.genspectrum.dashboardsbackend.api.CollectionUpdate import org.genspectrum.dashboardsbackend.model.collection.CollectionModel import org.springframework.http.HttpStatus import org.springframework.http.MediaType @@ -10,6 +11,7 @@ import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.ResponseStatus @@ -51,6 +53,18 @@ class CollectionsController(private val collectionModel: CollectionModel) { userId = userId, ) + @PutMapping("/collections/{id}") + @Operation( + summary = "Update a collection", + description = "Updates a collection. Only the owner can update their collection. " + + "Provide only the fields you want to update.", + ) + fun putCollection( + @RequestBody collection: CollectionUpdate, + @IdParameter @PathVariable id: String, + @UserIdParameter @RequestParam userId: String, + ): Collection = collectionModel.putCollection(id, collection, userId) + @DeleteMapping("/collections/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) @Operation( diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt index bc120345..57f4c0a1 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt @@ -2,7 +2,9 @@ package org.genspectrum.dashboardsbackend.model.collection import org.genspectrum.dashboardsbackend.api.Collection import org.genspectrum.dashboardsbackend.api.CollectionRequest +import org.genspectrum.dashboardsbackend.api.CollectionUpdate import org.genspectrum.dashboardsbackend.api.VariantRequest +import org.genspectrum.dashboardsbackend.api.VariantUpdate import org.genspectrum.dashboardsbackend.config.DashboardsConfig import org.genspectrum.dashboardsbackend.config.validateIsValidOrganism import org.genspectrum.dashboardsbackend.controller.ForbiddenException @@ -13,6 +15,7 @@ import org.jetbrains.exposed.sql.Op import org.jetbrains.exposed.sql.and import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.util.UUID import javax.sql.DataSource @Service @@ -122,4 +125,161 @@ class CollectionModel(pool: DataSource, private val dashboardsConfig: Dashboards // Delete (variants cascade automatically via DB constraint) entity.delete() } + + fun putCollection(id: String, update: CollectionUpdate, userId: String): Collection { + val uuid = convertToUuid(id) + + val collectionEntity = CollectionEntity.findForUser(uuid, userId) + ?: throw ForbiddenException("Collection $id not found or you don't have permission to update it") + + if (update.name != null) { + collectionEntity.name = update.name + } + + if (update.description != null) { + collectionEntity.description = update.description + } + + if (update.variants != null) { + // Track which variant IDs should be kept + val variantIdsToKeep = mutableSetOf() + + update.variants.forEach { variantUpdate -> + when { + variantUpdate.id == null -> { + val variantEntity = createVariantEntity( + collectionEntity, + variantUpdate + ) + variantEntity.validate() + variantIdsToKeep.add(variantEntity.id.value) + } + // Case 2: Has ID = Update existing variant + else -> { + val idString = variantUpdate.id!! + val variantId = convertToUuid(idString) + val variantEntity = VariantEntity.findById(variantId) + ?: throw org.genspectrum.dashboardsbackend.controller.BadRequestException( + "Variant $idString not found", + ) + + // Verify the variant belongs to this collection + if (variantEntity.collectionId.value != uuid) { + throw org.genspectrum.dashboardsbackend.controller.BadRequestException( + "Variant ${variantUpdate.id} does not belong to collection $id", + ) + } + + // Update the variant fields + updateVariantEntity(variantEntity, variantUpdate, collectionEntity) + variantEntity.validate() + variantIdsToKeep.add(variantId) + } + } + } + + // Case 3: Delete variants not in the update list + VariantEntity.find { VariantTable.collectionId eq uuid } + .filter { it.id.value !in variantIdsToKeep } + .forEach { it.delete() } + } + + return collectionEntity.toCollection() + } + + private fun createVariantEntity( + collectionEntity: CollectionEntity, + variantUpdate: VariantUpdate + ): VariantEntity = when (variantUpdate) { + is VariantUpdate.QueryVariantUpdate -> { + VariantEntity.new { + this.collectionId = collectionEntity.id + this.variantType = VariantType.QUERY + this.name = variantUpdate.name + this.description = variantUpdate.description + this.countQuery = variantUpdate.countQuery + this.coverageQuery = variantUpdate.coverageQuery + this.mutationList = null + } + } + is VariantUpdate.MutationListVariantUpdate -> { + // Validate lineage filters + val organismConfig = dashboardsConfig.getOrganismConfig(collectionEntity.organism) + val validLineageFields = organismConfig.lapis.lineageFields ?: emptyList() + + val invalidFields = variantUpdate.mutationList.lineageFilters.keys - validLineageFields.toSet() + if (invalidFields.isNotEmpty()) { + val validFieldsStr = if (validLineageFields.isEmpty()) { + "no lineage fields are configured" + } else { + "valid fields are: ${validLineageFields.joinToString(", ")}" + } + throw org.genspectrum.dashboardsbackend.controller.BadRequestException( + "Invalid lineage fields for organism '${collectionEntity.organism}': ${invalidFields.joinToString( + ", ", + )}. $validFieldsStr", + ) + } + + VariantEntity.new { + this.collectionId = collectionEntity.id + this.variantType = VariantType.MUTATION_LIST + this.name = variantUpdate.name + this.description = variantUpdate.description + this.mutationList = variantUpdate.mutationList + this.countQuery = null + this.coverageQuery = null + } + } + } + + private fun updateVariantEntity( + variantEntity: VariantEntity, + variantUpdate: VariantUpdate, + collectionEntity: CollectionEntity + ) { + when (variantUpdate) { + is VariantUpdate.QueryVariantUpdate -> { + // Verify type matches + if (variantEntity.variantType != VariantType.QUERY) { + throw org.genspectrum.dashboardsbackend.controller.BadRequestException( + "Cannot change variant type from ${variantEntity.variantType} to QUERY", + ) + } + variantEntity.name = variantUpdate.name + variantEntity.description = variantUpdate.description + variantEntity.countQuery = variantUpdate.countQuery + variantEntity.coverageQuery = variantUpdate.coverageQuery + } + is VariantUpdate.MutationListVariantUpdate -> { + // Verify type matches + if (variantEntity.variantType != VariantType.MUTATION_LIST) { + throw org.genspectrum.dashboardsbackend.controller.BadRequestException( + "Cannot change variant type from ${variantEntity.variantType} to MUTATION_LIST", + ) + } + + // Validate lineage filters + val organismConfig = dashboardsConfig.getOrganismConfig(collectionEntity.organism) + val validLineageFields = organismConfig.lapis.lineageFields ?: emptyList() + + val invalidFields = variantUpdate.mutationList.lineageFilters.keys - validLineageFields.toSet() + if (invalidFields.isNotEmpty()) { + val validFieldsStr = if (validLineageFields.isEmpty()) { + "no lineage fields are configured" + } else { + "valid fields are: ${validLineageFields.joinToString(", ")}" + } + throw org.genspectrum.dashboardsbackend.controller.BadRequestException( + "Invalid lineage fields for organism '${collectionEntity.organism}': " + + "${invalidFields.joinToString(", ")}. $validFieldsStr", + ) + } + + variantEntity.name = variantUpdate.name + variantEntity.description = variantUpdate.description + variantEntity.mutationList = variantUpdate.mutationList + } + } + } } diff --git a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt index b2f264b8..945d24da 100644 --- a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt +++ b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt @@ -4,12 +4,14 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import org.genspectrum.dashboardsbackend.api.Collection import org.genspectrum.dashboardsbackend.api.CollectionRequest +import org.genspectrum.dashboardsbackend.api.CollectionUpdate import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.ResultActions import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status @@ -50,6 +52,17 @@ class CollectionsClient(private val mockMvc: MockMvc, private val objectMapper: .andExpect(status().isOk), ) + fun putCollectionRaw(collection: CollectionUpdate, id: String, userId: String): ResultActions = mockMvc.perform( + put("/collections/$id?userId=$userId") + .content(objectMapper.writeValueAsString(collection)) + .contentType(MediaType.APPLICATION_JSON), + ) + + fun putCollection(collection: CollectionUpdate, id: String, userId: String): Collection = deserializeJsonResponse( + putCollectionRaw(collection, id, userId) + .andExpect(status().isOk), + ) + fun deleteCollectionRaw(id: String, userId: String): ResultActions = mockMvc.perform(delete("/collections/$id?userId=$userId")) diff --git a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt index 81305bde..89e1a568 100644 --- a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt +++ b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt @@ -1,8 +1,11 @@ package org.genspectrum.dashboardsbackend.controller import org.genspectrum.dashboardsbackend.KnownTestOrganisms +import org.genspectrum.dashboardsbackend.api.CollectionUpdate +import org.genspectrum.dashboardsbackend.api.MutationListDefinition import org.genspectrum.dashboardsbackend.api.Variant import org.genspectrum.dashboardsbackend.api.VariantRequest +import org.genspectrum.dashboardsbackend.api.VariantUpdate import org.genspectrum.dashboardsbackend.dummyCollectionRequest import org.genspectrum.dashboardsbackend.dummyMutationListVariantRequest import org.genspectrum.dashboardsbackend.dummyQueryVariantRequest @@ -11,6 +14,7 @@ import org.hamcrest.Matchers.empty import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.hasItem import org.hamcrest.Matchers.hasSize +import org.hamcrest.Matchers.instanceOf import org.hamcrest.Matchers.not import org.hamcrest.Matchers.notNullValue import org.junit.jupiter.api.Test @@ -481,4 +485,253 @@ class CollectionsControllerTest(@param:Autowired private val collectionsClient: ), ) } + + @Test + fun `WHEN owner updates all fields THEN collection is updated`() { + val userId = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, userId) + + val updatedVariants = listOf( + VariantUpdate.QueryVariantUpdate( + name = "New Variant", + description = "New description", + countQuery = "country='USA'", + coverageQuery = "country='USA'", + ), + ) + + val update = CollectionUpdate( + name = "Updated Name", + description = "Updated Description", + variants = updatedVariants, + ) + + val updated = collectionsClient.putCollection(update, createdCollection.id, userId) + + assertThat(updated.name, equalTo("Updated Name")) + assertThat(updated.description, equalTo("Updated Description")) + assertThat(updated.variants.size, equalTo(1)) + val firstVariant = updated.variants[0] as Variant.QueryVariant + assertThat(firstVariant.name, equalTo("New Variant")) + } + + @Test + fun `WHEN owner updates only name THEN only name changes`() { + val userId = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, userId) + val originalVariantCount = createdCollection.variants.size + + val update = CollectionUpdate(name = "Just Name Change") + + val updated = collectionsClient.putCollection(update, createdCollection.id, userId) + + assertThat(updated.name, equalTo("Just Name Change")) + assertThat(updated.description, equalTo(createdCollection.description)) + assertThat(updated.variants.size, equalTo(originalVariantCount)) + } + + @Test + fun `WHEN owner sends empty update THEN nothing changes`() { + val userId = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, userId) + + val update = CollectionUpdate() + + val updated = collectionsClient.putCollection(update, createdCollection.id, userId) + + assertThat(updated, equalTo(createdCollection)) + } + + @Test + fun `WHEN owner adds new variant without ID THEN variant is created`() { + val userId = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, userId) + val originalVariantCount = createdCollection.variants.size + + val newVariant = VariantUpdate.MutationListVariantUpdate( + id = null, + name = "New Variant", + mutationList = MutationListDefinition( + aaMutations = listOf("S:N501Y"), + ), + ) + + val existingVariants = createdCollection.variants.map { variant -> + when (variant) { + is Variant.QueryVariant -> VariantUpdate.QueryVariantUpdate( + id = variant.id, + name = variant.name, + description = variant.description, + countQuery = variant.countQuery, + coverageQuery = variant.coverageQuery, + ) + is Variant.MutationListVariant -> VariantUpdate.MutationListVariantUpdate( + id = variant.id, + name = variant.name, + description = variant.description, + mutationList = variant.mutationList, + ) + } + } + + val update = CollectionUpdate(variants = existingVariants + newVariant) + + val updated = collectionsClient.putCollection(update, createdCollection.id, userId) + + assertThat(updated.variants.size, equalTo(originalVariantCount + 1)) + assertThat( + updated.variants.any { variant -> + when (variant) { + is Variant.QueryVariant -> variant.name == "New Variant" + is Variant.MutationListVariant -> variant.name == "New Variant" + } + }, + equalTo(true), + ) + } + + @Test + fun `WHEN non-owner updates collection THEN returns 403 forbidden`() { + val owner = getNewUserId() + val nonOwner = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, owner) + + collectionsClient.putCollectionRaw( + CollectionUpdate(name = "Hacked Name"), + createdCollection.id, + nonOwner, + ) + .andExpect(status().isForbidden) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect( + jsonPath("$.detail").value( + org.hamcrest.Matchers.containsString("you don't have permission to update it"), + ), + ) + } + + @Test + fun `WHEN owner updates existing variant with ID THEN variant is updated in place`() { + val userId = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, userId) + val firstVariant = createdCollection.variants[0] as Variant.QueryVariant + + val updatedVariant = VariantUpdate.QueryVariantUpdate( + id = firstVariant.id, + name = "Updated Name", + description = "Updated Description", + countQuery = "country='France'", + coverageQuery = "country='France'", + ) + + val update = CollectionUpdate(variants = listOf(updatedVariant)) + + val updated = collectionsClient.putCollection(update, createdCollection.id, userId) + + assertThat(updated.variants.size, equalTo(1)) + assertThat(updated.variants[0].id, equalTo(firstVariant.id)) + val queryVariant = updated.variants[0] as Variant.QueryVariant + assertThat(queryVariant.name, equalTo("Updated Name")) + assertThat(queryVariant.countQuery, equalTo("country='France'")) + } + + @Test + fun `WHEN owner omits variant from update THEN variant is deleted`() { + val userId = getNewUserId() + val originalVariants = listOf( + VariantRequest.QueryVariantRequest( + name = "Variant 1", + countQuery = "country='USA'", + ), + VariantRequest.QueryVariantRequest( + name = "Variant 2", + countQuery = "country='Germany'", + ), + ) + val collectionRequest = dummyCollectionRequest.copy(variants = originalVariants) + val createdCollection = collectionsClient.postCollection(collectionRequest, userId) + + val firstVariant = createdCollection.variants[0] + val keepVariant = VariantUpdate.QueryVariantUpdate( + id = firstVariant.id, + name = (firstVariant as Variant.QueryVariant).name, + countQuery = firstVariant.countQuery, + ) + + val update = CollectionUpdate(variants = listOf(keepVariant)) + + val updated = collectionsClient.putCollection(update, createdCollection.id, userId) + + assertThat(updated.variants.size, equalTo(1)) + assertThat(updated.variants[0].id, equalTo(firstVariant.id)) + } + + @Test + fun `WHEN updating with invalid lineage fields THEN returns 400`() { + val userId = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, userId) + + val invalidVariant = VariantUpdate.MutationListVariantUpdate( + name = "Invalid Variant", + mutationList = MutationListDefinition.create( + aaMutations = listOf("S:N501Y"), + lineageFilters = mapOf("invalidField" to "value"), + ), + ) + + val update = CollectionUpdate(variants = listOf(invalidVariant)) + + collectionsClient.putCollectionRaw(update, createdCollection.id, userId) + .andExpect(status().isBadRequest) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.detail").value(org.hamcrest.Matchers.containsString("Invalid lineage fields"))) + } + + @Test + fun `WHEN updating variant type THEN returns 400`() { + val userId = getNewUserId() + val createdCollection = collectionsClient.postCollection(dummyCollectionRequest, userId) + val firstVariant = createdCollection.variants[0] as Variant.QueryVariant + + val invalidUpdate = VariantUpdate.MutationListVariantUpdate( + id = firstVariant.id, + name = "Changed Type", + mutationList = MutationListDefinition( + aaMutations = listOf("S:N501Y"), + ), + ) + + val update = CollectionUpdate(variants = listOf(invalidUpdate)) + + collectionsClient.putCollectionRaw(update, createdCollection.id, userId) + .andExpect(status().isBadRequest) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.detail").value(org.hamcrest.Matchers.containsString("Cannot change variant type"))) + } + + @Test + fun `WHEN updating with variant ID from different collection THEN returns 400`() { + val userId = getNewUserId() + val collection1 = collectionsClient.postCollection(dummyCollectionRequest, userId) + val collection2 = collectionsClient.postCollection( + dummyCollectionRequest.copy(name = "Collection 2"), + userId, + ) + + val variantFromCollection2 = collection2.variants[0] as Variant.QueryVariant + val invalidUpdate = VariantUpdate.QueryVariantUpdate( + id = variantFromCollection2.id, + name = variantFromCollection2.name, + countQuery = variantFromCollection2.countQuery, + ) + + val update = CollectionUpdate(variants = listOf(invalidUpdate)) + + collectionsClient.putCollectionRaw(update, collection1.id, userId) + .andExpect(status().isBadRequest) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect( + jsonPath("$.detail").value(org.hamcrest.Matchers.containsString("does not belong to collection")), + ) + } } From 0e11bdbb8dca18c4aa64f33040beddf06692e7e3 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Thu, 12 Mar 2026 14:06:41 +0100 Subject: [PATCH 10/19] dedicated Variant.kt api file --- .../dashboardsbackend/api/Collection.kt | 193 ----------------- .../dashboardsbackend/api/Variant.kt | 196 ++++++++++++++++++ .../model/collection/CollectionModel.kt | 80 ++++--- 3 files changed, 235 insertions(+), 234 deletions(-) create mode 100644 backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Variant.kt diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Collection.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Collection.kt index 98c4fc70..2606a4ff 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Collection.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Collection.kt @@ -1,12 +1,6 @@ package org.genspectrum.dashboardsbackend.api -import com.fasterxml.jackson.annotation.JsonCreator -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.annotation.JsonSubTypes -import com.fasterxml.jackson.annotation.JsonTypeInfo import io.swagger.v3.oas.annotations.media.Schema -import org.genspectrum.dashboardsbackend.api.Variant.MutationListVariant -import org.genspectrum.dashboardsbackend.api.Variant.QueryVariant @Schema( description = "A collection of variants", @@ -85,190 +79,3 @@ data class CollectionUpdate( val description: String? = null, val variants: List? = null, ) - -@JsonTypeInfo( - use = JsonTypeInfo.Id.NAME, - include = JsonTypeInfo.As.PROPERTY, - property = "type", -) -@JsonSubTypes( - JsonSubTypes.Type(value = QueryVariant::class, name = "query"), - JsonSubTypes.Type(value = MutationListVariant::class, name = "mutationList"), -) -@Schema( - description = "Base interface for different variant types", -) -sealed interface Variant { - val id: String - val collectionId: String - - enum class QueryVariantType { - @JsonProperty("query") - QUERY, - } - - enum class MutationListVariantType { - @JsonProperty("mutationList") - MUTATION_LIST, - } - - @Schema( - description = "A variant defined by LAPIS queries", - example = """ -{ - "type": "query", - "id": "550e8400-e29b-41d4-a716-446655440000", - "collectionId": "660e8400-e29b-41d4-a716-446655440000", - "name": "BA.2 in USA", - "description": "BA.2 lineage cases in USA", - "countQuery": "country='USA' & lineage='BA.2'", - "coverageQuery": "country='USA'" -} -""", - ) - data class QueryVariant @JsonCreator constructor( - override val id: String, - override val collectionId: String, - val name: String, - val description: String?, - val countQuery: String, - val coverageQuery: String? = null, - ) : Variant { - val type: QueryVariantType = QueryVariantType.QUERY - } - - @Schema( - description = "A variant defined by a list of mutations", - example = """ -{ - "type": "mutationList", - "id": "550e8400-e29b-41d4-a716-446655440000", - "collectionId": "660e8400-e29b-41d4-a716-446655440000", - "name": "Omicron mutations", - "description": "Key mutations for Omicron", - "mutationList": { - "aaMutations": ["S:N501Y", "S:E484K", "S:K417N"] - } -} -""", - ) - data class MutationListVariant @JsonCreator constructor( - override val id: String, - override val collectionId: String, - val name: String, - val description: String?, - val mutationList: MutationListDefinition, - ) : Variant { - val type: MutationListVariantType = MutationListVariantType.MUTATION_LIST - } -} - -@JsonTypeInfo( - use = JsonTypeInfo.Id.NAME, - include = JsonTypeInfo.As.PROPERTY, - property = "type", -) -@JsonSubTypes( - JsonSubTypes.Type(value = VariantRequest.QueryVariantRequest::class, name = "query"), - JsonSubTypes.Type(value = VariantRequest.MutationListVariantRequest::class, name = "mutationList"), -) -@Schema( - description = "Request to create a variant", -) -sealed interface VariantRequest { - @Schema( - description = "Request to create a query variant", - example = """ -{ - "type": "query", - "name": "BA.2 in USA", - "description": "BA.2 lineage cases in USA", - "countQuery": "country='USA' & lineage='BA.2'", - "coverageQuery": "country='USA'" -} -""", - ) - data class QueryVariantRequest( - val name: String, - val description: String? = null, - val countQuery: String, - val coverageQuery: String? = null, - ) : VariantRequest - - @Schema( - description = "Request to create a mutation list variant", - example = """ -{ - "type": "mutationList", - "name": "Omicron mutations", - "description": "Key mutations for Omicron", - "mutationList": { - "aaMutations": ["S:N501Y", "S:E484K", "S:K417N"] - } -} -""", - ) - data class MutationListVariantRequest( - val name: String, - val description: String? = null, - val mutationList: MutationListDefinition, - ) : VariantRequest -} - -@JsonTypeInfo( - use = JsonTypeInfo.Id.NAME, - include = JsonTypeInfo.As.PROPERTY, - property = "type", -) -@JsonSubTypes( - JsonSubTypes.Type(value = VariantUpdate.QueryVariantUpdate::class, name = "query"), - JsonSubTypes.Type(value = VariantUpdate.MutationListVariantUpdate::class, name = "mutationList"), -) -@Schema( - description = "Request to update or create a variant", -) -sealed interface VariantUpdate { - val id: String? - - @Schema( - description = "Request to update or create a query variant", - example = """ -{ - "type": "query", - "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "BA.2 in USA", - "description": "BA.2 lineage cases in USA", - "countQuery": "country='USA' & lineage='BA.2'", - "coverageQuery": "country='USA'" -} -""", - ) - data class QueryVariantUpdate( - override val id: String? = null, - val name: String, - val description: String? = null, - val countQuery: String, - val coverageQuery: String? = null, - ) : VariantUpdate - - @Schema( - description = "Request to update or create a mutation list variant", - example = """ -{ - "type": "mutationList", - "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "Omicron mutations", - "description": "Key mutations for Omicron", - "mutationList": { - "aaMutations": ["S:N501Y", "S:E484K", "S:K417N"] - } -} -""", - ) - data class MutationListVariantUpdate( - override val id: String? = null, - val name: String, - val description: String? = null, - val mutationList: MutationListDefinition, - ) : VariantUpdate -} diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Variant.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Variant.kt new file mode 100644 index 00000000..d18bf82a --- /dev/null +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Variant.kt @@ -0,0 +1,196 @@ +package org.genspectrum.dashboardsbackend.api + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo +import io.swagger.v3.oas.annotations.media.Schema +import org.genspectrum.dashboardsbackend.api.Variant.MutationListVariant +import org.genspectrum.dashboardsbackend.api.Variant.QueryVariant + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type", +) +@JsonSubTypes( + JsonSubTypes.Type(value = QueryVariant::class, name = "query"), + JsonSubTypes.Type(value = MutationListVariant::class, name = "mutationList"), +) +@Schema( + description = "Base interface for different variant types", +) +sealed interface Variant { + val id: String + val collectionId: String + + enum class QueryVariantType { + @JsonProperty("query") + QUERY, + } + + enum class MutationListVariantType { + @JsonProperty("mutationList") + MUTATION_LIST, + } + + @Schema( + description = "A variant defined by LAPIS queries", + example = """ +{ + "type": "query", + "id": "550e8400-e29b-41d4-a716-446655440000", + "collectionId": "660e8400-e29b-41d4-a716-446655440000", + "name": "BA.2 in USA", + "description": "BA.2 lineage cases in USA", + "countQuery": "country='USA' & lineage='BA.2'", + "coverageQuery": "country='USA'" +} +""", + ) + data class QueryVariant @JsonCreator constructor( + override val id: String, + override val collectionId: String, + val name: String, + val description: String?, + val countQuery: String, + val coverageQuery: String? = null, + ) : Variant { + val type: QueryVariantType = QueryVariantType.QUERY + } + + @Schema( + description = "A variant defined by a list of mutations", + example = """ +{ + "type": "mutationList", + "id": "550e8400-e29b-41d4-a716-446655440000", + "collectionId": "660e8400-e29b-41d4-a716-446655440000", + "name": "Omicron mutations", + "description": "Key mutations for Omicron", + "mutationList": { + "aaMutations": ["S:N501Y", "S:E484K", "S:K417N"] + } +} +""", + ) + data class MutationListVariant @JsonCreator constructor( + override val id: String, + override val collectionId: String, + val name: String, + val description: String?, + val mutationList: MutationListDefinition, + ) : Variant { + val type: MutationListVariantType = MutationListVariantType.MUTATION_LIST + } +} + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type", +) +@JsonSubTypes( + JsonSubTypes.Type(value = VariantRequest.QueryVariantRequest::class, name = "query"), + JsonSubTypes.Type(value = VariantRequest.MutationListVariantRequest::class, name = "mutationList"), +) +@Schema( + description = "Request to create a variant", +) +sealed interface VariantRequest { + @Schema( + description = "Request to create a query variant", + example = """ +{ + "type": "query", + "name": "BA.2 in USA", + "description": "BA.2 lineage cases in USA", + "countQuery": "country='USA' & lineage='BA.2'", + "coverageQuery": "country='USA'" +} +""", + ) + data class QueryVariantRequest( + val name: String, + val description: String? = null, + val countQuery: String, + val coverageQuery: String? = null, + ) : VariantRequest + + @Schema( + description = "Request to create a mutation list variant", + example = """ +{ + "type": "mutationList", + "name": "Omicron mutations", + "description": "Key mutations for Omicron", + "mutationList": { + "aaMutations": ["S:N501Y", "S:E484K", "S:K417N"] + } +} +""", + ) + data class MutationListVariantRequest( + val name: String, + val description: String? = null, + val mutationList: MutationListDefinition, + ) : VariantRequest +} + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type", +) +@JsonSubTypes( + JsonSubTypes.Type(value = VariantUpdate.QueryVariantUpdate::class, name = "query"), + JsonSubTypes.Type(value = VariantUpdate.MutationListVariantUpdate::class, name = "mutationList"), +) +@Schema( + description = "Request to update or create a variant", +) +sealed interface VariantUpdate { + val id: String? + + @Schema( + description = "Request to update or create a query variant", + example = """ +{ + "type": "query", + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "BA.2 in USA", + "description": "BA.2 lineage cases in USA", + "countQuery": "country='USA' & lineage='BA.2'", + "coverageQuery": "country='USA'" +} +""", + ) + data class QueryVariantUpdate( + override val id: String? = null, + val name: String, + val description: String? = null, + val countQuery: String, + val coverageQuery: String? = null, + ) : VariantUpdate + + @Schema( + description = "Request to update or create a mutation list variant", + example = """ +{ + "type": "mutationList", + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Omicron mutations", + "description": "Key mutations for Omicron", + "mutationList": { + "aaMutations": ["S:N501Y", "S:E484K", "S:K417N"] + } +} +""", + ) + data class MutationListVariantUpdate( + override val id: String? = null, + val name: String, + val description: String? = null, + val mutationList: MutationListDefinition, + ) : VariantUpdate +} diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt index 57f4c0a1..12af3b7c 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt @@ -149,7 +149,7 @@ class CollectionModel(pool: DataSource, private val dashboardsConfig: Dashboards variantUpdate.id == null -> { val variantEntity = createVariantEntity( collectionEntity, - variantUpdate + variantUpdate, ) variantEntity.validate() variantIdsToKeep.add(variantEntity.id.value) @@ -187,56 +187,54 @@ class CollectionModel(pool: DataSource, private val dashboardsConfig: Dashboards return collectionEntity.toCollection() } - private fun createVariantEntity( - collectionEntity: CollectionEntity, - variantUpdate: VariantUpdate - ): VariantEntity = when (variantUpdate) { - is VariantUpdate.QueryVariantUpdate -> { - VariantEntity.new { - this.collectionId = collectionEntity.id - this.variantType = VariantType.QUERY - this.name = variantUpdate.name - this.description = variantUpdate.description - this.countQuery = variantUpdate.countQuery - this.coverageQuery = variantUpdate.coverageQuery - this.mutationList = null + private fun createVariantEntity(collectionEntity: CollectionEntity, variantUpdate: VariantUpdate): VariantEntity = + when (variantUpdate) { + is VariantUpdate.QueryVariantUpdate -> { + VariantEntity.new { + this.collectionId = collectionEntity.id + this.variantType = VariantType.QUERY + this.name = variantUpdate.name + this.description = variantUpdate.description + this.countQuery = variantUpdate.countQuery + this.coverageQuery = variantUpdate.coverageQuery + this.mutationList = null + } } - } - is VariantUpdate.MutationListVariantUpdate -> { - // Validate lineage filters - val organismConfig = dashboardsConfig.getOrganismConfig(collectionEntity.organism) - val validLineageFields = organismConfig.lapis.lineageFields ?: emptyList() + is VariantUpdate.MutationListVariantUpdate -> { + // Validate lineage filters + val organismConfig = dashboardsConfig.getOrganismConfig(collectionEntity.organism) + val validLineageFields = organismConfig.lapis.lineageFields ?: emptyList() - val invalidFields = variantUpdate.mutationList.lineageFilters.keys - validLineageFields.toSet() - if (invalidFields.isNotEmpty()) { - val validFieldsStr = if (validLineageFields.isEmpty()) { - "no lineage fields are configured" - } else { - "valid fields are: ${validLineageFields.joinToString(", ")}" + val invalidFields = + variantUpdate.mutationList.lineageFilters.keys - validLineageFields.toSet() + if (invalidFields.isNotEmpty()) { + val validFieldsStr = if (validLineageFields.isEmpty()) { + "no lineage fields are configured" + } else { + "valid fields are: ${validLineageFields.joinToString(", ")}" + } + throw org.genspectrum.dashboardsbackend.controller.BadRequestException( + "Invalid lineage fields for organism '${collectionEntity.organism}': " + + "${invalidFields.joinToString(", ")}. $validFieldsStr", + ) } - throw org.genspectrum.dashboardsbackend.controller.BadRequestException( - "Invalid lineage fields for organism '${collectionEntity.organism}': ${invalidFields.joinToString( - ", ", - )}. $validFieldsStr", - ) - } - VariantEntity.new { - this.collectionId = collectionEntity.id - this.variantType = VariantType.MUTATION_LIST - this.name = variantUpdate.name - this.description = variantUpdate.description - this.mutationList = variantUpdate.mutationList - this.countQuery = null - this.coverageQuery = null + VariantEntity.new { + this.collectionId = collectionEntity.id + this.variantType = VariantType.MUTATION_LIST + this.name = variantUpdate.name + this.description = variantUpdate.description + this.mutationList = variantUpdate.mutationList + this.countQuery = null + this.coverageQuery = null + } } } - } private fun updateVariantEntity( variantEntity: VariantEntity, variantUpdate: VariantUpdate, - collectionEntity: CollectionEntity + collectionEntity: CollectionEntity, ) { when (variantUpdate) { is VariantUpdate.QueryVariantUpdate -> { From e5ed63511a3aa520af22fdfa338ffab48c81d091 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Mon, 16 Mar 2026 14:28:27 +0100 Subject: [PATCH 11/19] Change collection & variant IDs from UUID to Long Collections are unreleased, so we can change in-place with no migration or backwards-compatibility concerns. Switches DB schema to bigserial, ORM tables to LongIdTable, and all API/model/test types from String/UUID to Long. Co-Authored-By: Claude Sonnet 4.6 --- .../dashboardsbackend/api/Collection.kt | 6 ++-- .../dashboardsbackend/api/Variant.kt | 30 +++++++++---------- .../controller/CollectionsController.kt | 13 +++++--- .../model/collection/CollectionModel.kt | 29 +++++++----------- .../model/collection/CollectionTable.kt | 17 +++++------ .../model/collection/VariantTable.kt | 23 +++++++------- .../V1.1__collections_and_variants.sql | 6 ++-- .../controller/CollectionsClient.kt | 12 ++++---- .../controller/CollectionsControllerTest.kt | 20 ++++++------- 9 files changed, 76 insertions(+), 80 deletions(-) diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Collection.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Collection.kt index 2606a4ff..ac026160 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Collection.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Collection.kt @@ -6,7 +6,7 @@ import io.swagger.v3.oas.annotations.media.Schema description = "A collection of variants", example = """ { - "id": "550e8400-e29b-41d4-a716-446655440000", + "id": 1, "name": "My Collection", "ownedBy": "user123", "organism": "covid", @@ -16,7 +16,7 @@ import io.swagger.v3.oas.annotations.media.Schema """, ) data class Collection( - val id: String, + val id: Long, val name: String, val ownedBy: String, val organism: String, @@ -59,7 +59,7 @@ data class CollectionRequest( "variants": [ { "type": "query", - "id": "550e8400-e29b-41d4-a716-446655440000", + "id": 1, "name": "BA.2 in USA", "description": "BA.2 lineage cases in USA", "countQuery": "country='USA' & lineage='BA.2'", diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Variant.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Variant.kt index d18bf82a..93768271 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Variant.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/Variant.kt @@ -21,8 +21,8 @@ import org.genspectrum.dashboardsbackend.api.Variant.QueryVariant description = "Base interface for different variant types", ) sealed interface Variant { - val id: String - val collectionId: String + val id: Long + val collectionId: Long enum class QueryVariantType { @JsonProperty("query") @@ -39,8 +39,8 @@ sealed interface Variant { example = """ { "type": "query", - "id": "550e8400-e29b-41d4-a716-446655440000", - "collectionId": "660e8400-e29b-41d4-a716-446655440000", + "id": 1, + "collectionId": 2, "name": "BA.2 in USA", "description": "BA.2 lineage cases in USA", "countQuery": "country='USA' & lineage='BA.2'", @@ -49,8 +49,8 @@ sealed interface Variant { """, ) data class QueryVariant @JsonCreator constructor( - override val id: String, - override val collectionId: String, + override val id: Long, + override val collectionId: Long, val name: String, val description: String?, val countQuery: String, @@ -64,8 +64,8 @@ sealed interface Variant { example = """ { "type": "mutationList", - "id": "550e8400-e29b-41d4-a716-446655440000", - "collectionId": "660e8400-e29b-41d4-a716-446655440000", + "id": 1, + "collectionId": 2, "name": "Omicron mutations", "description": "Key mutations for Omicron", "mutationList": { @@ -75,8 +75,8 @@ sealed interface Variant { """, ) data class MutationListVariant @JsonCreator constructor( - override val id: String, - override val collectionId: String, + override val id: Long, + override val collectionId: Long, val name: String, val description: String?, val mutationList: MutationListDefinition, @@ -150,14 +150,14 @@ sealed interface VariantRequest { description = "Request to update or create a variant", ) sealed interface VariantUpdate { - val id: String? + val id: Long? @Schema( description = "Request to update or create a query variant", example = """ { "type": "query", - "id": "550e8400-e29b-41d4-a716-446655440000", + "id": 1, "name": "BA.2 in USA", "description": "BA.2 lineage cases in USA", "countQuery": "country='USA' & lineage='BA.2'", @@ -166,7 +166,7 @@ sealed interface VariantUpdate { """, ) data class QueryVariantUpdate( - override val id: String? = null, + override val id: Long? = null, val name: String, val description: String? = null, val countQuery: String, @@ -178,7 +178,7 @@ sealed interface VariantUpdate { example = """ { "type": "mutationList", - "id": "550e8400-e29b-41d4-a716-446655440000", + "id": 1, "name": "Omicron mutations", "description": "Key mutations for Omicron", "mutationList": { @@ -188,7 +188,7 @@ sealed interface VariantUpdate { """, ) data class MutationListVariantUpdate( - override val id: String? = null, + override val id: Long? = null, val name: String, val description: String? = null, val mutationList: MutationListDefinition, diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt index 174accd5..99798118 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsController.kt @@ -1,6 +1,7 @@ package org.genspectrum.dashboardsbackend.controller import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter import org.genspectrum.dashboardsbackend.api.Collection import org.genspectrum.dashboardsbackend.api.CollectionRequest import org.genspectrum.dashboardsbackend.api.CollectionUpdate @@ -37,7 +38,9 @@ class CollectionsController(private val collectionModel: CollectionModel) { summary = "Get a collection by ID", description = "Returns a single collection with all its variants.", ) - fun getCollection(@IdParameter @PathVariable id: String): Collection = collectionModel.getCollection(id) + fun getCollection( + @Parameter(description = "The ID of the collection", example = "1") @PathVariable id: Long, + ): Collection = collectionModel.getCollection(id) @PostMapping("/collections") @ResponseStatus(HttpStatus.CREATED) @@ -61,7 +64,7 @@ class CollectionsController(private val collectionModel: CollectionModel) { ) fun putCollection( @RequestBody collection: CollectionUpdate, - @IdParameter @PathVariable id: String, + @Parameter(description = "The ID of the collection", example = "1") @PathVariable id: Long, @UserIdParameter @RequestParam userId: String, ): Collection = collectionModel.putCollection(id, collection, userId) @@ -71,6 +74,8 @@ class CollectionsController(private val collectionModel: CollectionModel) { summary = "Delete a collection", description = "Deletes a collection. Only the owner can delete their collection.", ) - fun deleteCollection(@IdParameter @PathVariable id: String, @UserIdParameter @RequestParam userId: String) = - collectionModel.deleteCollection(id, userId) + fun deleteCollection( + @Parameter(description = "The ID of the collection", example = "1") @PathVariable id: Long, + @UserIdParameter @RequestParam userId: String, + ) = collectionModel.deleteCollection(id, userId) } diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt index 12af3b7c..9ab83fc3 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt @@ -9,13 +9,11 @@ import org.genspectrum.dashboardsbackend.config.DashboardsConfig import org.genspectrum.dashboardsbackend.config.validateIsValidOrganism import org.genspectrum.dashboardsbackend.controller.ForbiddenException import org.genspectrum.dashboardsbackend.controller.NotFoundException -import org.genspectrum.dashboardsbackend.util.convertToUuid import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.Op import org.jetbrains.exposed.sql.and import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import java.util.UUID import javax.sql.DataSource @Service @@ -44,7 +42,7 @@ class CollectionModel(pool: DataSource, private val dashboardsConfig: Dashboards return query.map { it.toCollection() } } - fun getCollection(id: String): Collection = CollectionEntity.findById(convertToUuid(id)) + fun getCollection(id: Long): Collection = CollectionEntity.findById(id) ?.toCollection() ?: throw NotFoundException("Collection $id not found") @@ -106,7 +104,7 @@ class CollectionModel(pool: DataSource, private val dashboardsConfig: Dashboards } return Collection( - id = collectionEntity.id.value.toString(), + id = collectionEntity.id.value, name = collectionEntity.name, ownedBy = collectionEntity.ownedBy, organism = collectionEntity.organism, @@ -115,21 +113,17 @@ class CollectionModel(pool: DataSource, private val dashboardsConfig: Dashboards ) } - fun deleteCollection(id: String, userId: String) { - val uuid = convertToUuid(id) - + fun deleteCollection(id: Long, userId: String) { // Find with ownership check - val entity = CollectionEntity.findForUser(uuid, userId) + val entity = CollectionEntity.findForUser(id, userId) ?: throw ForbiddenException("Collection $id not found or you don't have permission to delete it") // Delete (variants cascade automatically via DB constraint) entity.delete() } - fun putCollection(id: String, update: CollectionUpdate, userId: String): Collection { - val uuid = convertToUuid(id) - - val collectionEntity = CollectionEntity.findForUser(uuid, userId) + fun putCollection(id: Long, update: CollectionUpdate, userId: String): Collection { + val collectionEntity = CollectionEntity.findForUser(id, userId) ?: throw ForbiddenException("Collection $id not found or you don't have permission to update it") if (update.name != null) { @@ -142,7 +136,7 @@ class CollectionModel(pool: DataSource, private val dashboardsConfig: Dashboards if (update.variants != null) { // Track which variant IDs should be kept - val variantIdsToKeep = mutableSetOf() + val variantIdsToKeep = mutableSetOf() update.variants.forEach { variantUpdate -> when { @@ -156,15 +150,14 @@ class CollectionModel(pool: DataSource, private val dashboardsConfig: Dashboards } // Case 2: Has ID = Update existing variant else -> { - val idString = variantUpdate.id!! - val variantId = convertToUuid(idString) + val variantId = variantUpdate.id!! val variantEntity = VariantEntity.findById(variantId) ?: throw org.genspectrum.dashboardsbackend.controller.BadRequestException( - "Variant $idString not found", + "Variant $variantId not found", ) // Verify the variant belongs to this collection - if (variantEntity.collectionId.value != uuid) { + if (variantEntity.collectionId.value != id) { throw org.genspectrum.dashboardsbackend.controller.BadRequestException( "Variant ${variantUpdate.id} does not belong to collection $id", ) @@ -179,7 +172,7 @@ class CollectionModel(pool: DataSource, private val dashboardsConfig: Dashboards } // Case 3: Delete variants not in the update list - VariantEntity.find { VariantTable.collectionId eq uuid } + VariantEntity.find { VariantTable.collectionId eq id } .filter { it.id.value !in variantIdsToKeep } .forEach { it.delete() } } diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionTable.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionTable.kt index 57aa1fc2..d55efd75 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionTable.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionTable.kt @@ -1,24 +1,23 @@ package org.genspectrum.dashboardsbackend.model.collection import org.genspectrum.dashboardsbackend.api.Collection -import org.jetbrains.exposed.dao.UUIDEntity -import org.jetbrains.exposed.dao.UUIDEntityClass +import org.jetbrains.exposed.dao.LongEntity +import org.jetbrains.exposed.dao.LongEntityClass import org.jetbrains.exposed.dao.id.EntityID -import org.jetbrains.exposed.dao.id.UUIDTable -import java.util.UUID +import org.jetbrains.exposed.dao.id.LongIdTable const val COLLECTION_TABLE = "collections_table" -object CollectionTable : UUIDTable(COLLECTION_TABLE) { +object CollectionTable : LongIdTable(COLLECTION_TABLE) { val name = text("name") val ownedBy = varchar("owned_by", 255) val organism = varchar("organism", 255) val description = text("description").nullable() } -class CollectionEntity(id: EntityID) : UUIDEntity(id) { - companion object : UUIDEntityClass(CollectionTable) { - fun findForUser(id: UUID, userId: String) = findById(id) +class CollectionEntity(id: EntityID) : LongEntity(id) { + companion object : LongEntityClass(CollectionTable) { + fun findForUser(id: Long, userId: String) = findById(id) ?.takeIf { it.ownedBy == userId } // TODO we probably want to have a 'find by organism' as well @@ -33,7 +32,7 @@ class CollectionEntity(id: EntityID) : UUIDEntity(id) { val variants by VariantEntity referrersOn VariantTable.collectionId fun toCollection() = Collection( - id = id.value.toString(), + id = id.value, name = name, ownedBy = ownedBy, organism = organism, diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/VariantTable.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/VariantTable.kt index 20e76b0e..40d5609a 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/VariantTable.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/VariantTable.kt @@ -2,12 +2,11 @@ package org.genspectrum.dashboardsbackend.model.collection import org.genspectrum.dashboardsbackend.api.Variant import org.genspectrum.dashboardsbackend.model.subscription.jacksonSerializableJsonb -import org.jetbrains.exposed.dao.UUIDEntity -import org.jetbrains.exposed.dao.UUIDEntityClass +import org.jetbrains.exposed.dao.LongEntity +import org.jetbrains.exposed.dao.LongEntityClass import org.jetbrains.exposed.dao.id.EntityID -import org.jetbrains.exposed.dao.id.UUIDTable +import org.jetbrains.exposed.dao.id.LongIdTable import org.jetbrains.exposed.sql.ReferenceOption -import java.util.UUID const val VARIANT_TABLE = "variants_table" @@ -30,7 +29,7 @@ enum class VariantType { } } -object VariantTable : UUIDTable(VARIANT_TABLE) { +object VariantTable : LongIdTable(VARIANT_TABLE) { val collectionId = reference( "collection_id", CollectionTable, @@ -48,9 +47,9 @@ object VariantTable : UUIDTable(VARIANT_TABLE) { ).nullable() } -class VariantEntity(id: EntityID) : UUIDEntity(id) { - companion object : UUIDEntityClass(VariantTable) { - fun findForCollection(collectionId: UUID): List = +class VariantEntity(id: EntityID) : LongEntity(id) { + companion object : LongEntityClass(VariantTable) { + fun findForCollection(collectionId: Long): List = find { VariantTable.collectionId eq collectionId }.toList() } @@ -89,16 +88,16 @@ class VariantEntity(id: EntityID) : UUIDEntity(id) { fun toVariant(): Variant = when (variantType) { VariantType.QUERY -> Variant.QueryVariant( - id = id.value.toString(), - collectionId = collectionId.value.toString(), + id = id.value, + collectionId = collectionId.value, name = name, description = description, countQuery = countQuery!!, coverageQuery = coverageQuery, ) VariantType.MUTATION_LIST -> Variant.MutationListVariant( - id = id.value.toString(), - collectionId = collectionId.value.toString(), + id = id.value, + collectionId = collectionId.value, name = name, description = description, mutationList = mutationList!!, diff --git a/backend/src/main/resources/db/migration/V1.1__collections_and_variants.sql b/backend/src/main/resources/db/migration/V1.1__collections_and_variants.sql index a07f9e4c..7af98772 100644 --- a/backend/src/main/resources/db/migration/V1.1__collections_and_variants.sql +++ b/backend/src/main/resources/db/migration/V1.1__collections_and_variants.sql @@ -1,6 +1,6 @@ -- Collections table create table collections_table ( - id uuid primary key, + id bigserial primary key, name text not null, owned_by varchar(255) not null, organism varchar(255) not null, @@ -9,8 +9,8 @@ create table collections_table ( -- Variants table with polymorphic data storage create table variants_table ( - id uuid primary key, - collection_id uuid not null, + id bigserial primary key, + collection_id bigint not null, variant_type varchar(50) not null, name text not null, description text, diff --git a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt index 945d24da..2dd8f35b 100644 --- a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt +++ b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsClient.kt @@ -45,28 +45,28 @@ class CollectionsClient(private val mockMvc: MockMvc, private val objectMapper: .andExpect(status().isOk), ) - fun getCollectionRaw(id: String): ResultActions = mockMvc.perform(get("/collections/$id")) + fun getCollectionRaw(id: Long): ResultActions = mockMvc.perform(get("/collections/$id")) - fun getCollection(id: String): Collection = deserializeJsonResponse( + fun getCollection(id: Long): Collection = deserializeJsonResponse( getCollectionRaw(id) .andExpect(status().isOk), ) - fun putCollectionRaw(collection: CollectionUpdate, id: String, userId: String): ResultActions = mockMvc.perform( + fun putCollectionRaw(collection: CollectionUpdate, id: Long, userId: String): ResultActions = mockMvc.perform( put("/collections/$id?userId=$userId") .content(objectMapper.writeValueAsString(collection)) .contentType(MediaType.APPLICATION_JSON), ) - fun putCollection(collection: CollectionUpdate, id: String, userId: String): Collection = deserializeJsonResponse( + fun putCollection(collection: CollectionUpdate, id: Long, userId: String): Collection = deserializeJsonResponse( putCollectionRaw(collection, id, userId) .andExpect(status().isOk), ) - fun deleteCollectionRaw(id: String, userId: String): ResultActions = + fun deleteCollectionRaw(id: Long, userId: String): ResultActions = mockMvc.perform(delete("/collections/$id?userId=$userId")) - fun deleteCollection(id: String, userId: String) { + fun deleteCollection(id: Long, userId: String) { deleteCollectionRaw(id, userId).andExpect(status().isNoContent) } diff --git a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt index 89e1a568..f2bf3e28 100644 --- a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt +++ b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt @@ -23,15 +23,19 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock import org.springframework.boot.test.context.SpringBootTest import org.springframework.context.annotation.Import import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import java.util.UUID @SpringBootTest @AutoConfigureMockMvc @Import(CollectionsClient::class) -class CollectionsControllerTest(@param:Autowired private val collectionsClient: CollectionsClient) { +class CollectionsControllerTest( + @param:Autowired private val collectionsClient: CollectionsClient, + @param:Autowired private val mockMvc: MockMvc, +) { @Test fun `GIVEN collection with variants WHEN creating THEN returns with generated IDs`() { @@ -310,7 +314,7 @@ class CollectionsControllerTest(@param:Autowired private val collectionsClient: @Test fun `WHEN getting collection with non-existent ID THEN returns 404`() { - val nonExistentId = "00000000-0000-0000-0000-000000000000" + val nonExistentId = 999999L collectionsClient.getCollectionRaw(nonExistentId) .andExpect(status().isNotFound) @@ -319,13 +323,9 @@ class CollectionsControllerTest(@param:Autowired private val collectionsClient: } @Test - fun `WHEN getting collection with invalid UUID format THEN returns 400`() { - val invalidId = "not-a-uuid" - - collectionsClient.getCollectionRaw(invalidId) + fun `WHEN getting collection with non-numeric ID THEN returns 400`() { + mockMvc.perform(get("/collections/not-a-number")) .andExpect(status().isBadRequest) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.detail").value("Invalid UUID $invalidId")) } // MutationListDefinition Tests @@ -474,7 +474,7 @@ class CollectionsControllerTest(@param:Autowired private val collectionsClient: @Test fun `WHEN deleting non-existent collection THEN returns 403`() { val userId = getNewUserId() - val nonExistentId = UUID.randomUUID().toString() + val nonExistentId = 999999L collectionsClient.deleteCollectionRaw(nonExistentId, userId) .andExpect(status().isForbidden) From ccacf1d1a2f1b67269c12477f58a2bea7c1c0da2 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Mon, 16 Mar 2026 16:33:55 +0100 Subject: [PATCH 12/19] review --- .../config/BackendSpringConfig.kt | 5 ++++ .../model/collection/CollectionModel.kt | 23 ++++++++----------- .../model/collection/CollectionTable.kt | 2 -- .../model/subscription/SubscriptionModel.kt | 8 +------ 4 files changed, 15 insertions(+), 23 deletions(-) diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/config/BackendSpringConfig.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/config/BackendSpringConfig.kt index 49a8a23a..c0cb9ea0 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/config/BackendSpringConfig.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/config/BackendSpringConfig.kt @@ -7,6 +7,7 @@ import org.flywaydb.core.Flyway import org.genspectrum.dashboardsbackend.logging.REQUEST_ID_HEADER import org.genspectrum.dashboardsbackend.logging.REQUEST_ID_HEADER_DESCRIPTION import org.jetbrains.exposed.spring.autoconfigure.ExposedAutoConfiguration +import org.jetbrains.exposed.sql.Database import org.springdoc.core.customizers.OperationCustomizer import org.springframework.boot.autoconfigure.ImportAutoConfiguration import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration @@ -31,6 +32,10 @@ class BackendSpringConfig { .validateMigrationNaming(true) val flyway = Flyway(configuration) flyway.migrate() + + // Set up exposed database connection after migration is done + Database.connect(dataSource) + return flyway } diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt index 9ab83fc3..7e53ea22 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt @@ -7,22 +7,17 @@ import org.genspectrum.dashboardsbackend.api.VariantRequest import org.genspectrum.dashboardsbackend.api.VariantUpdate import org.genspectrum.dashboardsbackend.config.DashboardsConfig import org.genspectrum.dashboardsbackend.config.validateIsValidOrganism +import org.genspectrum.dashboardsbackend.controller.BadRequestException import org.genspectrum.dashboardsbackend.controller.ForbiddenException import org.genspectrum.dashboardsbackend.controller.NotFoundException -import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.Op import org.jetbrains.exposed.sql.and import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import javax.sql.DataSource @Service @Transactional -class CollectionModel(pool: DataSource, private val dashboardsConfig: DashboardsConfig) { - init { - Database.connect(pool) - } - +class CollectionModel(private val dashboardsConfig: DashboardsConfig) { fun getCollections(userId: String?, organism: String?): List { val query = if (userId == null && organism == null) { CollectionEntity.all() @@ -81,7 +76,7 @@ class CollectionModel(pool: DataSource, private val dashboardsConfig: Dashboards } else { "valid fields are: ${validLineageFields.joinToString(", ")}" } - throw org.genspectrum.dashboardsbackend.controller.BadRequestException( + throw BadRequestException( "Invalid lineage fields for organism '${request.organism}': ${invalidFields.joinToString( ", ", )}. $validFieldsStr", @@ -152,13 +147,13 @@ class CollectionModel(pool: DataSource, private val dashboardsConfig: Dashboards else -> { val variantId = variantUpdate.id!! val variantEntity = VariantEntity.findById(variantId) - ?: throw org.genspectrum.dashboardsbackend.controller.BadRequestException( + ?: throw BadRequestException( "Variant $variantId not found", ) // Verify the variant belongs to this collection if (variantEntity.collectionId.value != id) { - throw org.genspectrum.dashboardsbackend.controller.BadRequestException( + throw BadRequestException( "Variant ${variantUpdate.id} does not belong to collection $id", ) } @@ -206,7 +201,7 @@ class CollectionModel(pool: DataSource, private val dashboardsConfig: Dashboards } else { "valid fields are: ${validLineageFields.joinToString(", ")}" } - throw org.genspectrum.dashboardsbackend.controller.BadRequestException( + throw BadRequestException( "Invalid lineage fields for organism '${collectionEntity.organism}': " + "${invalidFields.joinToString(", ")}. $validFieldsStr", ) @@ -233,7 +228,7 @@ class CollectionModel(pool: DataSource, private val dashboardsConfig: Dashboards is VariantUpdate.QueryVariantUpdate -> { // Verify type matches if (variantEntity.variantType != VariantType.QUERY) { - throw org.genspectrum.dashboardsbackend.controller.BadRequestException( + throw BadRequestException( "Cannot change variant type from ${variantEntity.variantType} to QUERY", ) } @@ -245,7 +240,7 @@ class CollectionModel(pool: DataSource, private val dashboardsConfig: Dashboards is VariantUpdate.MutationListVariantUpdate -> { // Verify type matches if (variantEntity.variantType != VariantType.MUTATION_LIST) { - throw org.genspectrum.dashboardsbackend.controller.BadRequestException( + throw BadRequestException( "Cannot change variant type from ${variantEntity.variantType} to MUTATION_LIST", ) } @@ -261,7 +256,7 @@ class CollectionModel(pool: DataSource, private val dashboardsConfig: Dashboards } else { "valid fields are: ${validLineageFields.joinToString(", ")}" } - throw org.genspectrum.dashboardsbackend.controller.BadRequestException( + throw BadRequestException( "Invalid lineage fields for organism '${collectionEntity.organism}': " + "${invalidFields.joinToString(", ")}. $validFieldsStr", ) diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionTable.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionTable.kt index d55efd75..8b9d1eb1 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionTable.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionTable.kt @@ -19,8 +19,6 @@ class CollectionEntity(id: EntityID) : LongEntity(id) { companion object : LongEntityClass(CollectionTable) { fun findForUser(id: Long, userId: String) = findById(id) ?.takeIf { it.ownedBy == userId } - - // TODO we probably want to have a 'find by organism' as well } var name by CollectionTable.name diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/subscription/SubscriptionModel.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/subscription/SubscriptionModel.kt index 2aa12e38..77b18085 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/subscription/SubscriptionModel.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/subscription/SubscriptionModel.kt @@ -7,18 +7,12 @@ import org.genspectrum.dashboardsbackend.config.DashboardsConfig import org.genspectrum.dashboardsbackend.config.validateIsValidOrganism import org.genspectrum.dashboardsbackend.controller.NotFoundException import org.genspectrum.dashboardsbackend.util.convertToUuid -import org.jetbrains.exposed.sql.Database import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import javax.sql.DataSource @Service @Transactional -class SubscriptionModel(pool: DataSource, private val dashboardsConfig: DashboardsConfig) { - init { - Database.connect(pool) - } - +class SubscriptionModel(private val dashboardsConfig: DashboardsConfig) { fun getSubscription(subscriptionId: String, userId: String): Subscription = SubscriptionEntity.findForUser(convertToUuid(subscriptionId), userId) ?.toSubscription() From 7cc2cf739c520bb5084c6f87c29d27f62926844b Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Tue, 17 Mar 2026 09:22:14 +0100 Subject: [PATCH 13/19] Consolidate DB setup: use root docker-compose for local dev Remove backend/docker-compose.dev.yaml and update README to use the root compose file's database service. Also align POSTGRES_DB name to dashboards-backend-db across docker-compose.yml. Co-Authored-By: Claude Sonnet 4.6 --- backend/README.md | 8 ++++---- backend/docker-compose.dev.yaml | 20 -------------------- docker-compose.yml | 4 ++-- 3 files changed, 6 insertions(+), 26 deletions(-) delete mode 100644 backend/docker-compose.dev.yaml diff --git a/backend/README.md b/backend/README.md index 0cc4b0ee..b48ec157 100644 --- a/backend/README.md +++ b/backend/README.md @@ -17,22 +17,22 @@ You have to provide config information to the backend: ### Start local database -Start the local PostgreSQL database using Docker Compose: +Start the local PostgreSQL database using Docker Compose (from the repo root): ```bash -docker-compose -f docker-compose.dev.yaml up -d +docker compose up -d database ``` Stop the database: ```bash -docker-compose -f docker-compose.dev.yaml down +docker compose down database ``` Stop and remove data volumes: ```bash -docker-compose -f docker-compose.dev.yaml down -v +docker compose down -v database ``` ### Run the backend diff --git a/backend/docker-compose.dev.yaml b/backend/docker-compose.dev.yaml deleted file mode 100644 index 6ba7ac0f..00000000 --- a/backend/docker-compose.dev.yaml +++ /dev/null @@ -1,20 +0,0 @@ -services: - postgres: - image: postgres:16-alpine - container_name: dashboards-backend-db - environment: - POSTGRES_DB: dashboards-backend-db - POSTGRES_USER: postgres - POSTGRES_PASSWORD: unsecure - ports: - - "9022:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 10s - timeout: 5s - retries: 5 - -volumes: - postgres_data: diff --git a/docker-compose.yml b/docker-compose.yml index 9d20579c..377df34f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,7 +19,7 @@ services: depends_on: - database command: - - --spring.datasource.url=jdbc:postgresql://database:5432/subscriptions + - --spring.datasource.url=jdbc:postgresql://database:5432/dashboards-backend-db - --spring.datasource.username=postgres - --spring.datasource.password=unsecure - --spring.profiles.active=dashboards-prod @@ -29,6 +29,6 @@ services: environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: unsecure - POSTGRES_DB: subscriptions + POSTGRES_DB: dashboards-backend-db ports: - "127.0.0.1:9022:5432" From de30a5f49f035190a5a39ebcc6fe9beeeb888e71 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Tue, 17 Mar 2026 09:31:04 +0100 Subject: [PATCH 14/19] Move validateIsValidOrganism into DashboardsConfig class No need for an extension function when it can be a regular member. Co-Authored-By: Claude Sonnet 4.6 --- .../dashboardsbackend/config/DashboardsConfig.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/config/DashboardsConfig.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/config/DashboardsConfig.kt index 68814c8f..1ea0a5bb 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/config/DashboardsConfig.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/config/DashboardsConfig.kt @@ -7,6 +7,12 @@ import org.springframework.boot.context.properties.ConfigurationProperties data class DashboardsConfig(val organisms: Map) { fun getOrganismConfig(organism: String) = organisms[organism] ?: throw IllegalArgumentException("No configuration found for organism $organism") + + fun validateIsValidOrganism(organism: String) { + if (!organisms.containsKey(organism)) { + throw BadRequestException("Organism '$organism' is not supported") + } + } } data class OrganismConfig(val lapis: LapisConfig, val externalNavigationLinks: List?) @@ -19,9 +25,3 @@ data class LapisConfig( ) data class ExternalNavigationLink(val url: String, val label: String, val menuIcon: String, val description: String) - -fun DashboardsConfig.validateIsValidOrganism(organism: String) { - if (!organisms.containsKey(organism)) { - throw BadRequestException("Organism '$organism' is not supported") - } -} From cf825ae1f43dde30b6645b2b15350b7736a1ac34 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Tue, 17 Mar 2026 10:15:57 +0100 Subject: [PATCH 15/19] Fix variant deletion in putCollection and clean up imports Use VariantTable.deleteWhere with explicit column references to avoid ambiguity between the 'id' function parameter and the table's id column. Add exposed-jdbc dependency and required imports. Remove stale validateIsValidOrganism extension function imports. Co-Authored-By: Claude Sonnet 4.6 --- backend/build.gradle.kts | 1 + .../model/collection/CollectionModel.kt | 12 +++++++++++- .../model/subscription/SubscriptionModel.kt | 1 - 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 669a3747..92cd4d5c 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -32,6 +32,7 @@ dependencies { implementation("org.postgresql:postgresql:42.7.9") implementation("org.jetbrains.exposed:exposed-spring-boot-starter:0.56.0") implementation("org.jetbrains.exposed:exposed-json:0.56.0") + implementation("org.jetbrains.exposed:exposed-jdbc:0.56.0") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1-0.6.x-compat") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt index 7e53ea22..e48ab2ac 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt @@ -6,12 +6,14 @@ import org.genspectrum.dashboardsbackend.api.CollectionUpdate import org.genspectrum.dashboardsbackend.api.VariantRequest import org.genspectrum.dashboardsbackend.api.VariantUpdate import org.genspectrum.dashboardsbackend.config.DashboardsConfig -import org.genspectrum.dashboardsbackend.config.validateIsValidOrganism import org.genspectrum.dashboardsbackend.controller.BadRequestException import org.genspectrum.dashboardsbackend.controller.ForbiddenException import org.genspectrum.dashboardsbackend.controller.NotFoundException import org.jetbrains.exposed.sql.Op +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.SqlExpressionBuilder.notInList import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.deleteWhere import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -167,6 +169,14 @@ class CollectionModel(private val dashboardsConfig: DashboardsConfig) { } // Case 3: Delete variants not in the update list + if (variantIdsToKeep.isEmpty()) { + VariantTable.deleteWhere { collectionId eq id } + } else { + // Delete all variants for this collection whose IDs are not in the keep-set + VariantTable.deleteWhere { + (collectionId eq id) and (VariantTable.id notInList variantIdsToKeep.toList()) + } + } VariantEntity.find { VariantTable.collectionId eq id } .filter { it.id.value !in variantIdsToKeep } .forEach { it.delete() } diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/subscription/SubscriptionModel.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/subscription/SubscriptionModel.kt index 77b18085..31656316 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/subscription/SubscriptionModel.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/subscription/SubscriptionModel.kt @@ -4,7 +4,6 @@ import org.genspectrum.dashboardsbackend.api.Subscription import org.genspectrum.dashboardsbackend.api.SubscriptionRequest import org.genspectrum.dashboardsbackend.api.SubscriptionUpdate import org.genspectrum.dashboardsbackend.config.DashboardsConfig -import org.genspectrum.dashboardsbackend.config.validateIsValidOrganism import org.genspectrum.dashboardsbackend.controller.NotFoundException import org.genspectrum.dashboardsbackend.util.convertToUuid import org.springframework.stereotype.Service From 8ae90d22dc4945603b726ca60765642570400bf2 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Tue, 17 Mar 2026 10:21:20 +0100 Subject: [PATCH 16/19] Extract duplicated lineage filter validation into a single method Co-Authored-By: Claude Sonnet 4.6 --- .../model/collection/CollectionModel.kt | 69 +++++-------------- 1 file changed, 19 insertions(+), 50 deletions(-) diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt index e48ab2ac..0816299e 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt @@ -3,6 +3,7 @@ package org.genspectrum.dashboardsbackend.model.collection import org.genspectrum.dashboardsbackend.api.Collection import org.genspectrum.dashboardsbackend.api.CollectionRequest import org.genspectrum.dashboardsbackend.api.CollectionUpdate +import org.genspectrum.dashboardsbackend.api.MutationListDefinition import org.genspectrum.dashboardsbackend.api.VariantRequest import org.genspectrum.dashboardsbackend.api.VariantUpdate import org.genspectrum.dashboardsbackend.config.DashboardsConfig @@ -67,23 +68,7 @@ class CollectionModel(private val dashboardsConfig: DashboardsConfig) { } } is VariantRequest.MutationListVariantRequest -> { - // Validate lineage filters - val organismConfig = dashboardsConfig.getOrganismConfig(request.organism) - val validLineageFields = organismConfig.lapis.lineageFields ?: emptyList() - - val invalidFields = variantRequest.mutationList.lineageFilters.keys - validLineageFields.toSet() - if (invalidFields.isNotEmpty()) { - val validFieldsStr = if (validLineageFields.isEmpty()) { - "no lineage fields are configured" - } else { - "valid fields are: ${validLineageFields.joinToString(", ")}" - } - throw BadRequestException( - "Invalid lineage fields for organism '${request.organism}': ${invalidFields.joinToString( - ", ", - )}. $validFieldsStr", - ) - } + validateLineageFilters(request.organism, variantRequest.mutationList) VariantEntity.new { this.collectionId = collectionEntity.id @@ -199,23 +184,7 @@ class CollectionModel(private val dashboardsConfig: DashboardsConfig) { } } is VariantUpdate.MutationListVariantUpdate -> { - // Validate lineage filters - val organismConfig = dashboardsConfig.getOrganismConfig(collectionEntity.organism) - val validLineageFields = organismConfig.lapis.lineageFields ?: emptyList() - - val invalidFields = - variantUpdate.mutationList.lineageFilters.keys - validLineageFields.toSet() - if (invalidFields.isNotEmpty()) { - val validFieldsStr = if (validLineageFields.isEmpty()) { - "no lineage fields are configured" - } else { - "valid fields are: ${validLineageFields.joinToString(", ")}" - } - throw BadRequestException( - "Invalid lineage fields for organism '${collectionEntity.organism}': " + - "${invalidFields.joinToString(", ")}. $validFieldsStr", - ) - } + validateLineageFilters(collectionEntity.organism, variantUpdate.mutationList) VariantEntity.new { this.collectionId = collectionEntity.id @@ -229,6 +198,21 @@ class CollectionModel(private val dashboardsConfig: DashboardsConfig) { } } + private fun validateLineageFilters(organism: String, mutationList: MutationListDefinition) { + val validLineageFields = dashboardsConfig.getOrganismConfig(organism).lapis.lineageFields ?: emptyList() + val invalidFields = mutationList.lineageFilters.keys - validLineageFields.toSet() + if (invalidFields.isNotEmpty()) { + val validFieldsStr = if (validLineageFields.isEmpty()) { + "no lineage fields are configured" + } else { + "valid fields are: ${validLineageFields.joinToString(", ")}" + } + throw BadRequestException( + "Invalid lineage fields for organism '$organism': ${invalidFields.joinToString(", ")}. $validFieldsStr", + ) + } + } + private fun updateVariantEntity( variantEntity: VariantEntity, variantUpdate: VariantUpdate, @@ -255,22 +239,7 @@ class CollectionModel(private val dashboardsConfig: DashboardsConfig) { ) } - // Validate lineage filters - val organismConfig = dashboardsConfig.getOrganismConfig(collectionEntity.organism) - val validLineageFields = organismConfig.lapis.lineageFields ?: emptyList() - - val invalidFields = variantUpdate.mutationList.lineageFilters.keys - validLineageFields.toSet() - if (invalidFields.isNotEmpty()) { - val validFieldsStr = if (validLineageFields.isEmpty()) { - "no lineage fields are configured" - } else { - "valid fields are: ${validLineageFields.joinToString(", ")}" - } - throw BadRequestException( - "Invalid lineage fields for organism '${collectionEntity.organism}': " + - "${invalidFields.joinToString(", ")}. $validFieldsStr", - ) - } + validateLineageFilters(collectionEntity.organism, variantUpdate.mutationList) variantEntity.name = variantUpdate.name variantEntity.description = variantUpdate.description From d1f7caa4a2fa3c1fe1cd22dc6978c714d9b4ac37 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Tue, 17 Mar 2026 11:27:48 +0100 Subject: [PATCH 17/19] optimize collection loading --- .../model/collection/CollectionModel.kt | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt index 0816299e..2993ce9f 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/model/collection/CollectionModel.kt @@ -37,7 +37,27 @@ class CollectionModel(private val dashboardsConfig: DashboardsConfig) { } } - return query.map { it.toCollection() } + // Materialize the collections first to avoid re-running the query + val collectionEntities = query.toList() + if (collectionEntities.isEmpty()) { + return emptyList() + } + // Batch-load all variants + val allCollectionIds = collectionEntities.map { it.id } + val variantsByCollectionId = VariantEntity + .find { VariantTable.collectionId inList allCollectionIds } + .groupBy { it.collectionId } + return collectionEntities.map { collectionEntity -> + val variants = variantsByCollectionId[collectionEntity.id].orEmpty().map { it.toVariant() } + Collection( + id = collectionEntity.id.value, + name = collectionEntity.name, + ownedBy = collectionEntity.ownedBy, + organism = collectionEntity.organism, + description = collectionEntity.description, + variants = variants, + ) + } } fun getCollection(id: Long): Collection = CollectionEntity.findById(id) From 797c1936c8ba091217b93052117398150b1e464f Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Tue, 17 Mar 2026 11:32:29 +0100 Subject: [PATCH 18/19] better MutationListDefinition --- .../dashboardsbackend/api/MutationListDefinition.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/MutationListDefinition.kt b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/MutationListDefinition.kt index 6c8debcd..b5022637 100644 --- a/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/MutationListDefinition.kt +++ b/backend/src/main/kotlin/org/genspectrum/dashboardsbackend/api/MutationListDefinition.kt @@ -3,6 +3,7 @@ package org.genspectrum.dashboardsbackend.api import com.fasterxml.jackson.annotation.JsonAnyGetter import com.fasterxml.jackson.annotation.JsonAnySetter import com.fasterxml.jackson.annotation.JsonIgnore +import org.genspectrum.dashboardsbackend.controller.BadRequestException /** * A JSON object with mutation lists (keys: aaMutations, nucMutations, ...) @@ -26,9 +27,15 @@ data class MutationListDefinition( @JsonAnySetter fun put(key: String, value: Any) { - if (key !in KNOWN_FIELDS && value is String) { - lineageFiltersInternal[key] = value + if (key in KNOWN_FIELDS) { + return } + if (value !is String) { + throw BadRequestException( + "Invalid value for lineage filter '$key': expected a string but got ${value::class.qualifiedName}", + ) + } + lineageFiltersInternal[key] = value } companion object { From 73f3b82a9820a4e8be35de1ec7830c19f32f8bbc Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Tue, 17 Mar 2026 15:14:17 +0000 Subject: [PATCH 19/19] Update backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt Co-authored-by: Fabian Engelniederhammer <92720311+fengelniederhammer@users.noreply.github.com> --- .../dashboardsbackend/controller/CollectionsControllerTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt index f2bf3e28..e6270e3d 100644 --- a/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt +++ b/backend/src/test/kotlin/org/genspectrum/dashboardsbackend/controller/CollectionsControllerTest.kt @@ -192,7 +192,7 @@ class CollectionsControllerTest( } @Test - fun `WHEN getting collections for organism with no collections THEN returns empty array`() { + fun `GIVEN no collections for organism WHEN getting collections for organism THEN returns empty array`() { val collections = collectionsClient.getCollections(organism = KnownTestOrganisms.WestNile.name) assertThat(collections, empty())