Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import kotlin.time.Instant
"organism": "covid",
"description": "A collection of interesting variants",
"variantCount": 1,
"tags": ["europe", "flu"],
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-02T00:00:00Z"
}
Expand All @@ -28,6 +29,7 @@ data class Collection(
val variantCount: Int,
@JsonInclude(JsonInclude.Include.NON_NULL)
val variants: List<Variant>?,
val tags: List<String>,
val createdAt: Instant,
val updatedAt: Instant,
)
Expand All @@ -39,6 +41,7 @@ data class Collection(
"name": "My Collection",
"organism": "covid",
"description": "A collection of interesting variants",
"tags": ["europe", "flu"],
"variants": [
{
"type": "query",
Expand All @@ -55,6 +58,7 @@ data class CollectionRequest(
val name: String,
val organism: String,
val description: String? = null,
val tags: List<String> = emptyList(),
val variants: List<VariantRequest>,
)

Expand Down Expand Up @@ -85,5 +89,9 @@ data class CollectionRequest(
data class CollectionUpdate(
val name: String? = null,
val description: String? = null,
val tags: List<String>? = null,
val variants: List<VariantUpdate>? = null,
)

@Schema(description = "Response containing all distinct tags in use")
data class CollectionTagsResponse(val tags: List<String>)
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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.CollectionTagsResponse
import org.genspectrum.dashboardsbackend.api.CollectionUpdate
import org.genspectrum.dashboardsbackend.model.collection.CollectionModel
import org.springframework.http.HttpStatus
Expand All @@ -25,20 +26,30 @@ class CollectionsController(private val collectionModel: CollectionModel) {
summary = "Get collections",
description = "Returns collections filtered by optional userId and/or organism parameters. " +
"Set includeVariants=true to include the full variant list; by default only variantCount is returned. " +
"Set excludeSystemCollections=true to exclude collections owned by the system user.",
"Set excludeSystemCollections=true to exclude collections owned by the system user. " +
"Repeatable tags parameter filters to collections that have ALL specified tags (AND semantics).",
)
fun getCollections(
@RequestParam(required = false) userId: Long?,
@RequestParam(required = false) organism: String?,
@RequestParam(required = false, defaultValue = "false") includeVariants: Boolean,
@RequestParam(required = false, defaultValue = "false") excludeSystemCollections: Boolean,
@RequestParam(required = false) tags: List<String>?,
): List<Collection> = collectionModel.getCollections(
userId = userId,
organism = organism,
includeVariants = includeVariants,
excludeSystemCollections = excludeSystemCollections,
tags = tags,
)

@GetMapping("/collections/tags", produces = [MediaType.APPLICATION_JSON_VALUE])
@Operation(
summary = "Get all distinct collection tags",
description = "Returns all distinct tags in use across all collections, alphabetically sorted.",
)
fun getCollectionTags(): CollectionTagsResponse = collectionModel.getAllTags()

@GetMapping("/collections/{id}", produces = [MediaType.APPLICATION_JSON_VALUE])
@Operation(
summary = "Get a collection by ID",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package org.genspectrum.dashboardsbackend.model.collection

import org.genspectrum.dashboardsbackend.api.Collection
import org.genspectrum.dashboardsbackend.api.CollectionRequest
import org.genspectrum.dashboardsbackend.api.CollectionTagsResponse
import org.genspectrum.dashboardsbackend.api.CollectionUpdate
import org.genspectrum.dashboardsbackend.api.FilterObject
import org.genspectrum.dashboardsbackend.api.VariantRequest
Expand All @@ -13,28 +14,39 @@ import org.genspectrum.dashboardsbackend.controller.NotFoundException
import org.genspectrum.dashboardsbackend.model.user.UserEntity
import org.genspectrum.dashboardsbackend.model.user.UserModel
import org.genspectrum.dashboardsbackend.util.now
import org.jetbrains.exposed.v1.core.Count
import org.jetbrains.exposed.v1.core.JoinType
import org.jetbrains.exposed.v1.core.Op
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.count
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.inSubQuery
import org.jetbrains.exposed.v1.core.neq
import org.jetbrains.exposed.v1.core.notInList
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.select
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import kotlin.time.Instant

@Service
@Transactional
class CollectionModel(private val dashboardsConfig: DashboardsConfig, private val userModel: UserModel) {
/**
* The core function to fetch collections. Also fetches associated variants and tags (if desired).
*
* @param includeVariants whether to fetch variants as well. Quicker to omit if not needed.
* @param excludeSystemCollections whether to exclude collections belonging to the system user.
* Useful when only community collections are desired, and filtering makes responses smaller.
* @param tags if provided, only collections containing all specified tags are returned.
*/
fun getCollections(
userId: Long?,
organism: String?,
includeVariants: Boolean = false,
excludeSystemCollections: Boolean = false,
tags: List<String>? = null,
): List<Collection> {
if (userId != null) {
UserEntity.findById(userId) ?: throw NotFoundException("User $userId not found")
Expand All @@ -44,25 +56,20 @@ class CollectionModel(private val dashboardsConfig: DashboardsConfig, private va
dashboardsConfig.validateCollectionsEnabled(organism)
}

var collectionConditions: Op<Boolean> = Op.TRUE
if (userId != null) {
collectionConditions = collectionConditions and (CollectionTable.ownedBy eq userId)
}
if (organism != null) {
collectionConditions = collectionConditions and (CollectionTable.organism eq organism)
}
if (excludeSystemCollections) {
val systemUserId = userModel.getSystemUserId()
if (systemUserId != null) {
collectionConditions = collectionConditions and (CollectionTable.ownedBy neq systemUserId)
}
}
val collectionConditions = buildCollectionConditions(userId, organism, excludeSystemCollections, tags)

val join = CollectionTable.join(VariantTable, JoinType.LEFT)
val join = CollectionTable
.join(VariantTable, JoinType.LEFT)
.join(CollectionTagsTable, JoinType.LEFT, CollectionTable.id, CollectionTagsTable.collectionId)

return if (includeVariants) {
join.selectAll()
val tagsExpr = StringAgg(CollectionTagsTable.tag, orderBy = true)
val columns = CollectionTable.columns + VariantTable.columns + listOf(tagsExpr)

join.select(columns)
.where { collectionConditions }
.groupBy(CollectionTable.id, VariantTable.id)
.toList()
.groupBy { it[CollectionTable.id] }
.map { (_, rows) ->
val first = rows.first()
Expand All @@ -77,12 +84,15 @@ class CollectionModel(private val dashboardsConfig: DashboardsConfig, private va
description = first[CollectionTable.description],
variantCount = variants.size,
variants = variants,
tags = first[tagsExpr]?.split(",") ?: emptyList(),
createdAt = first[CollectionTable.createdAt],
Comment on lines 84 to 88
updatedAt = first[CollectionTable.updatedAt],
)
}
} else {
val countExpr = VariantTable.id.count()
val countExpr = Count(VariantTable.id, distinct = true)
val tagsExpr = StringAgg(CollectionTagsTable.tag, distinct = true)

join.select(
CollectionTable.id,
CollectionTable.name,
Expand All @@ -92,17 +102,10 @@ class CollectionModel(private val dashboardsConfig: DashboardsConfig, private va
CollectionTable.createdAt,
CollectionTable.updatedAt,
countExpr,
tagsExpr,
)
.where { collectionConditions }
.groupBy(
CollectionTable.id,
CollectionTable.name,
CollectionTable.ownedBy,
CollectionTable.organism,
CollectionTable.description,
CollectionTable.createdAt,
CollectionTable.updatedAt,
)
.groupBy(CollectionTable.id)
.map { row ->
Collection(
id = row[CollectionTable.id].value,
Expand All @@ -112,13 +115,23 @@ class CollectionModel(private val dashboardsConfig: DashboardsConfig, private va
description = row[CollectionTable.description],
variantCount = row[countExpr].toInt(),
variants = null,
tags = row[tagsExpr]?.split(",")?.sorted() ?: emptyList(),
createdAt = row[CollectionTable.createdAt],
Comment on lines 116 to 119
updatedAt = row[CollectionTable.updatedAt],
)
}
}
}

fun getAllTags(): CollectionTagsResponse {
val tags = CollectionTagsTable
.select(CollectionTagsTable.tag)
.withDistinct(true)
.orderBy(CollectionTagsTable.tag)
.map { it[CollectionTagsTable.tag] }
return CollectionTagsResponse(tags = tags)
}

fun getCollection(id: Long): Collection {
val entity = CollectionEntity.findById(id)
?: throw NotFoundException("Collection $id not found")
Expand Down Expand Up @@ -146,6 +159,14 @@ class CollectionModel(private val dashboardsConfig: DashboardsConfig, private va
variantEntity
}

val insertedTags = request.tags.map { it.lowercase() }.distinct().sorted()
insertedTags.forEach { tag ->
CollectionTagsTable.insert {
it[collectionId] = collectionEntity.id
it[CollectionTagsTable.tag] = tag
}
}

val variants = variantEntities.map { it.toVariant() }
return Collection(
id = collectionEntity.id.value,
Expand All @@ -155,6 +176,7 @@ class CollectionModel(private val dashboardsConfig: DashboardsConfig, private va
description = collectionEntity.description,
variantCount = variants.size,
variants = variants,
tags = insertedTags,
createdAt = collectionEntity.createdAt,
updatedAt = collectionEntity.updatedAt,
)
Expand Down Expand Up @@ -185,6 +207,17 @@ class CollectionModel(private val dashboardsConfig: DashboardsConfig, private va
collectionEntity.description = update.description
}

if (update.tags != null) {
CollectionTagsTable.deleteWhere { CollectionTagsTable.collectionId eq id }
val newTags = update.tags.map { it.lowercase() }.distinct()
newTags.forEach { tag ->
CollectionTagsTable.insert {
it[collectionId] = collectionEntity.id
it[CollectionTagsTable.tag] = tag
}
}
}

if (update.variants != null) {
// Track which variant IDs should be kept
val variantIdsToKeep = mutableSetOf<Long>()
Expand Down Expand Up @@ -239,6 +272,37 @@ class CollectionModel(private val dashboardsConfig: DashboardsConfig, private va
return collectionEntity.toCollection()
}

private fun buildCollectionConditions(
userId: Long?,
organism: String?,
excludeSystemCollections: Boolean,
tags: List<String>?,
): Op<Boolean> {
var conditions: Op<Boolean> = Op.TRUE
if (userId != null) {
conditions = conditions and (CollectionTable.ownedBy eq userId)
}
if (organism != null) {
conditions = conditions and (CollectionTable.organism eq organism)
}
if (excludeSystemCollections) {
val systemUserId = userModel.getSystemUserId()
if (systemUserId != null) {
conditions = conditions and (CollectionTable.ownedBy neq systemUserId)
}
}
if (!tags.isNullOrEmpty()) {
val distinctTags = tags.map { it.lowercase() }.distinct()
val tagSubquery = CollectionTagsTable
.select(CollectionTagsTable.collectionId)
.where { CollectionTagsTable.tag inList distinctTags }
.groupBy(CollectionTagsTable.collectionId)
.having { Count(CollectionTagsTable.tag, distinct = true) eq distinctTags.size.toLong() }
conditions = conditions and (CollectionTable.id inSubQuery tagSubquery)
}
return conditions
}

private fun createVariantEntity(
collectionEntity: CollectionEntity,
variantRequest: VariantRequest,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ package org.genspectrum.dashboardsbackend.model.collection
import org.genspectrum.dashboardsbackend.api.Collection
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.dao.id.LongIdTable
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.dao.LongEntity
import org.jetbrains.exposed.v1.dao.LongEntityClass
import org.jetbrains.exposed.v1.datetime.timestamp
import org.jetbrains.exposed.v1.jdbc.selectAll

const val COLLECTION_TABLE = "collections_table"

Expand Down Expand Up @@ -36,6 +38,11 @@ class CollectionEntity(id: EntityID<Long>) : LongEntity(id) {

fun toCollection(): Collection {
val variantList = variants.map { it.toVariant() }
val tagList = CollectionTagsTable
.selectAll()
.where { CollectionTagsTable.collectionId eq id.value }
.map { it[CollectionTagsTable.tag] }
.sorted()
return Collection(
id = id.value,
name = name,
Expand All @@ -44,6 +51,7 @@ class CollectionEntity(id: EntityID<Long>) : LongEntity(id) {
description = description,
variantCount = variantList.size,
variants = variantList,
tags = tagList,
createdAt = createdAt,
updatedAt = updatedAt,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package org.genspectrum.dashboardsbackend.model.collection

import org.jetbrains.exposed.v1.core.Column
import org.jetbrains.exposed.v1.core.Function
import org.jetbrains.exposed.v1.core.QueryBuilder
import org.jetbrains.exposed.v1.core.ReferenceOption
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.TextColumnType

object CollectionTagsTable : Table("collection_tags") {
val collectionId = reference("collection_id", CollectionTable, onDelete = ReferenceOption.CASCADE)
val tag = text("tag")
override val primaryKey = PrimaryKey(collectionId, tag)
}

class StringAgg(
private val column: Column<String>,
private val distinct: Boolean = false,
private val orderBy: Boolean = false,
) : Function<String?>(TextColumnType()) {
override fun toQueryBuilder(queryBuilder: QueryBuilder) = queryBuilder {
append("string_agg(")
if (distinct) append("DISTINCT ")
append(column)
append(", ','")
if (orderBy && !distinct) {
append(" ORDER BY ")
append(column)
}
append(")")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
create table collection_tags (
collection_id bigint not null,
tag text not null,
primary key (collection_id, tag),
constraint fk_collection_tags_collection foreign key (collection_id) references collections_table(id) on delete cascade,
constraint chk_tag_lowercase check (tag = lower(tag))
);

create index idx_collection_tags_tag on collection_tags(tag);
Loading
Loading