From 35e953ecffa2a2a48a9a292baef41d86a7ea98c0 Mon Sep 17 00:00:00 2001 From: Fabian Engelniederhammer Date: Thu, 2 Jul 2026 11:49:29 +0200 Subject: [PATCH 1/9] 1742 vibe wip --- .../AminoAcidCoOccurrenceController.kt | 309 +++++++++++++++++ .../CoOccurrenceControllerSupport.kt | 66 ++++ .../lapis/controller/CoOccurrenceRoute.kt | 4 + .../controller/ControllerDescriptions.kt | 8 + .../MultiSegmentedCoOccurrenceController.kt | 311 ++++++++++++++++++ .../SingleSegmentedCoOccurrenceController.kt | 295 +++++++++++++++++ .../genspectrum/lapis/model/SiloQueryModel.kt | 63 ++++ .../lapis/request/CoOccurrencePosition.kt | 171 ++++++++++ .../lapis/request/CoOccurrenceRequest.kt | 60 ++++ .../lapis/request/SpecialProperties.kt | 2 + .../org/genspectrum/lapis/silo/SiloQuery.kt | 46 +++ .../lapis/silo/SiloQuerySaneQlSerializer.kt | 12 + .../controller/CoOccurrenceControllerTest.kt | 210 ++++++++++++ ...ngleSegmentedCoOccurrenceControllerTest.kt | 74 +++++ .../lapis/model/SiloQueryModelTest.kt | 79 +++++ .../lapis/request/CoOccurrencePositionTest.kt | 219 ++++++++++++ .../lapis/request/CoOccurrenceRequestTest.kt | 101 ++++++ .../silo/SiloQuerySaneQlSerializerTest.kt | 24 ++ 18 files changed, 2054 insertions(+) create mode 100644 lapis/src/main/kotlin/org/genspectrum/lapis/controller/AminoAcidCoOccurrenceController.kt create mode 100644 lapis/src/main/kotlin/org/genspectrum/lapis/controller/CoOccurrenceControllerSupport.kt create mode 100644 lapis/src/main/kotlin/org/genspectrum/lapis/controller/CoOccurrenceRoute.kt create mode 100644 lapis/src/main/kotlin/org/genspectrum/lapis/controller/MultiSegmentedCoOccurrenceController.kt create mode 100644 lapis/src/main/kotlin/org/genspectrum/lapis/controller/SingleSegmentedCoOccurrenceController.kt create mode 100644 lapis/src/main/kotlin/org/genspectrum/lapis/request/CoOccurrencePosition.kt create mode 100644 lapis/src/main/kotlin/org/genspectrum/lapis/request/CoOccurrenceRequest.kt create mode 100644 lapis/src/test/kotlin/org/genspectrum/lapis/controller/CoOccurrenceControllerTest.kt create mode 100644 lapis/src/test/kotlin/org/genspectrum/lapis/controller/SingleSegmentedCoOccurrenceControllerTest.kt create mode 100644 lapis/src/test/kotlin/org/genspectrum/lapis/request/CoOccurrencePositionTest.kt create mode 100644 lapis/src/test/kotlin/org/genspectrum/lapis/request/CoOccurrenceRequestTest.kt diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/AminoAcidCoOccurrenceController.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/AminoAcidCoOccurrenceController.kt new file mode 100644 index 000000000..cb7477683 --- /dev/null +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/AminoAcidCoOccurrenceController.kt @@ -0,0 +1,309 @@ +package org.genspectrum.lapis.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import jakarta.servlet.http.HttpServletResponse +import org.genspectrum.lapis.controller.LapisMediaType.TEXT_CSV_VALUE +import org.genspectrum.lapis.controller.LapisMediaType.TEXT_TSV_VALUE +import org.genspectrum.lapis.model.SiloQueryModel +import org.genspectrum.lapis.openApi.AminoAcidInsertions +import org.genspectrum.lapis.openApi.AminoAcidMutations +import org.genspectrum.lapis.openApi.DataFormat +import org.genspectrum.lapis.openApi.Gene +import org.genspectrum.lapis.openApi.Limit +import org.genspectrum.lapis.openApi.NucleotideInsertions +import org.genspectrum.lapis.openApi.NucleotideMutations +import org.genspectrum.lapis.openApi.Offset +import org.genspectrum.lapis.openApi.PrimitiveFieldFilters +import org.genspectrum.lapis.openApi.StringResponseOperation +import org.genspectrum.lapis.request.AminoAcidInsertion +import org.genspectrum.lapis.request.AminoAcidMutation +import org.genspectrum.lapis.request.CoOccurrenceRequest +import org.genspectrum.lapis.request.GetRequestSequenceFilters +import org.genspectrum.lapis.request.NucleotideInsertion +import org.genspectrum.lapis.request.NucleotideMutation +import org.genspectrum.lapis.request.OrderByField +import org.genspectrum.lapis.response.Delimiter.COMMA +import org.genspectrum.lapis.response.Delimiter.TAB +import org.genspectrum.lapis.response.LapisResponseStreamer +import org.genspectrum.lapis.response.ResponseFormat +import org.springframework.http.HttpHeaders +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.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/component") +class AminoAcidCoOccurrenceController( + private val siloQueryModel: SiloQueryModel, + private val lapisResponseStreamer: LapisResponseStreamer, +) { + @GetMapping( + "$AMINO_ACID_CO_OCCURRENCE_ROUTE/{gene}", + produces = [MediaType.APPLICATION_JSON_VALUE], + ) + @Operation(description = AMINO_ACID_CO_OCCURRENCE_ENDPOINT_DESCRIPTION) + fun getAminoAcidCoOccurrenceAsJson( + @PathVariable(name = "gene", required = true) + @Gene + gene: String, + @PrimitiveFieldFilters + @RequestParam + sequenceFilters: GetRequestSequenceFilters?, + @RequestParam + positions: List, + @RequestParam + orderBy: List?, + @NucleotideMutations + @RequestParam + nucleotideMutations: List?, + @AminoAcidMutations + @RequestParam + aminoAcidMutations: List?, + @NucleotideInsertions + @RequestParam + nucleotideInsertions: List?, + @AminoAcidInsertions + @RequestParam + aminoAcidInsertions: List?, + @Limit + @RequestParam + limit: Int? = null, + @Offset + @RequestParam + offset: Int? = null, + @DataFormat + @RequestParam + dataFormat: String? = null, + response: HttpServletResponse, + ) { + val request = buildCoOccurrenceRequest( + sequenceFilters, + positions, + nucleotideMutations, + aminoAcidMutations, + nucleotideInsertions, + aminoAcidInsertions, + orderBy, + limit, + offset, + ) + + lapisResponseStreamer.streamData( + request = request, + getData = { getCoOccurrenceCollection(siloQueryModel, it, gene) }, + response = response, + responseFormat = ResponseFormat.Json, + ) + } + + @GetMapping( + "$AMINO_ACID_CO_OCCURRENCE_ROUTE/{gene}", + produces = [TEXT_CSV_VALUE], + ) + @StringResponseOperation( + description = AMINO_ACID_CO_OCCURRENCE_ENDPOINT_DESCRIPTION, + operationId = "getAminoAcidCoOccurrenceAsCsv", + ) + fun getAminoAcidCoOccurrenceAsCsv( + @PathVariable(name = "gene", required = true) + @Gene + gene: String, + @PrimitiveFieldFilters + @RequestParam + sequenceFilters: GetRequestSequenceFilters?, + @RequestParam + positions: List, + @RequestParam + orderBy: List?, + @NucleotideMutations + @RequestParam + nucleotideMutations: List?, + @AminoAcidMutations + @RequestParam + aminoAcidMutations: List?, + @NucleotideInsertions + @RequestParam + nucleotideInsertions: List?, + @AminoAcidInsertions + @RequestParam + aminoAcidInsertions: List?, + @Limit + @RequestParam + limit: Int? = null, + @Offset + @RequestParam + offset: Int? = null, + @DataFormat + @RequestParam + dataFormat: String? = null, + @RequestHeader httpHeaders: HttpHeaders, + response: HttpServletResponse, + ) { + val request = buildCoOccurrenceRequest( + sequenceFilters, + positions, + nucleotideMutations, + aminoAcidMutations, + nucleotideInsertions, + aminoAcidInsertions, + orderBy, + limit, + offset, + ) + + lapisResponseStreamer.streamData( + request = request, + getData = { getCoOccurrenceCollection(siloQueryModel, it, gene) }, + response = response, + responseFormat = ResponseFormat.Csv(delimiter = COMMA, acceptHeader = httpHeaders.accept), + ) + } + + @GetMapping( + "$AMINO_ACID_CO_OCCURRENCE_ROUTE/{gene}", + produces = [TEXT_TSV_VALUE], + ) + @StringResponseOperation( + description = AMINO_ACID_CO_OCCURRENCE_ENDPOINT_DESCRIPTION, + operationId = "getAminoAcidCoOccurrenceAsTsv", + ) + fun getAminoAcidCoOccurrenceAsTsv( + @PathVariable(name = "gene", required = true) + @Gene + gene: String, + @PrimitiveFieldFilters + @RequestParam + sequenceFilters: GetRequestSequenceFilters?, + @RequestParam + positions: List, + @RequestParam + orderBy: List?, + @NucleotideMutations + @RequestParam + nucleotideMutations: List?, + @AminoAcidMutations + @RequestParam + aminoAcidMutations: List?, + @NucleotideInsertions + @RequestParam + nucleotideInsertions: List?, + @AminoAcidInsertions + @RequestParam + aminoAcidInsertions: List?, + @Limit + @RequestParam + limit: Int? = null, + @Offset + @RequestParam + offset: Int? = null, + @DataFormat + @RequestParam + dataFormat: String? = null, + @RequestHeader httpHeaders: HttpHeaders, + response: HttpServletResponse, + ) { + val request = buildCoOccurrenceRequest( + sequenceFilters, + positions, + nucleotideMutations, + aminoAcidMutations, + nucleotideInsertions, + aminoAcidInsertions, + orderBy, + limit, + offset, + ) + + lapisResponseStreamer.streamData( + request = request, + getData = { getCoOccurrenceCollection(siloQueryModel, it, gene) }, + response = response, + responseFormat = ResponseFormat.Csv(delimiter = TAB, acceptHeader = httpHeaders.accept), + ) + } + + @PostMapping( + "$AMINO_ACID_CO_OCCURRENCE_ROUTE/{gene}", + produces = [MediaType.APPLICATION_JSON_VALUE], + consumes = [MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_FORM_URLENCODED_VALUE], + ) + @Operation( + description = AMINO_ACID_CO_OCCURRENCE_ENDPOINT_DESCRIPTION, + operationId = "postAminoAcidCoOccurrence", + ) + fun postAminoAcidCoOccurrence( + @PathVariable(name = "gene", required = true) + @Gene + gene: String, + @Parameter(description = "The sequence filters, positions, and other options for the co-occurrence query.") + @RequestBody + request: CoOccurrenceRequest, + response: HttpServletResponse, + ) { + lapisResponseStreamer.streamData( + request = request, + getData = { getCoOccurrenceCollection(siloQueryModel, it, gene) }, + response = response, + responseFormat = ResponseFormat.Json, + ) + } + + @PostMapping( + "$AMINO_ACID_CO_OCCURRENCE_ROUTE/{gene}", + produces = [TEXT_CSV_VALUE], + consumes = [MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_FORM_URLENCODED_VALUE], + ) + @StringResponseOperation( + description = AMINO_ACID_CO_OCCURRENCE_ENDPOINT_DESCRIPTION, + operationId = "postAminoAcidCoOccurrenceAsCsv", + ) + fun postAminoAcidCoOccurrenceAsCsv( + @PathVariable(name = "gene", required = true) + @Gene + gene: String, + @RequestBody + request: CoOccurrenceRequest, + @RequestHeader httpHeaders: HttpHeaders, + response: HttpServletResponse, + ) { + lapisResponseStreamer.streamData( + request = request, + getData = { getCoOccurrenceCollection(siloQueryModel, it, gene) }, + response = response, + responseFormat = ResponseFormat.Csv(delimiter = COMMA, acceptHeader = httpHeaders.accept), + ) + } + + @PostMapping( + "$AMINO_ACID_CO_OCCURRENCE_ROUTE/{gene}", + produces = [TEXT_TSV_VALUE], + consumes = [MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_FORM_URLENCODED_VALUE], + ) + @StringResponseOperation( + description = AMINO_ACID_CO_OCCURRENCE_ENDPOINT_DESCRIPTION, + operationId = "postAminoAcidCoOccurrenceAsTsv", + ) + fun postAminoAcidCoOccurrenceAsTsv( + @PathVariable(name = "gene", required = true) + @Gene + gene: String, + @RequestBody + request: CoOccurrenceRequest, + @RequestHeader httpHeaders: HttpHeaders, + response: HttpServletResponse, + ) { + lapisResponseStreamer.streamData( + request = request, + getData = { getCoOccurrenceCollection(siloQueryModel, it, gene) }, + response = response, + responseFormat = ResponseFormat.Csv(delimiter = TAB, acceptHeader = httpHeaders.accept), + ) + } +} diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/CoOccurrenceControllerSupport.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/CoOccurrenceControllerSupport.kt new file mode 100644 index 000000000..00e0da6ff --- /dev/null +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/CoOccurrenceControllerSupport.kt @@ -0,0 +1,66 @@ +package org.genspectrum.lapis.controller + +import org.genspectrum.lapis.model.SiloQueryModel +import org.genspectrum.lapis.request.AminoAcidInsertion +import org.genspectrum.lapis.request.AminoAcidMutation +import org.genspectrum.lapis.request.CoOccurrenceRequest +import org.genspectrum.lapis.request.GetRequestSequenceFilters +import org.genspectrum.lapis.request.NucleotideInsertion +import org.genspectrum.lapis.request.NucleotideMutation +import org.genspectrum.lapis.request.OrderByField +import org.genspectrum.lapis.request.SPECIAL_REQUEST_PROPERTIES +import org.genspectrum.lapis.request.expandAndValidatePositions +import org.genspectrum.lapis.request.parsePositionToken +import org.genspectrum.lapis.request.toOrderBySpec +import org.genspectrum.lapis.response.AggregatedCollection +import org.genspectrum.lapis.silo.coOccurrenceResponseFieldName + +/** + * Builds a [CoOccurrenceRequest] from the individual GET query parameters of a co-occurrence endpoint. + */ +fun buildCoOccurrenceRequest( + sequenceFilters: GetRequestSequenceFilters?, + positions: List, + nucleotideMutations: List?, + aminoAcidMutations: List?, + nucleotideInsertions: List?, + aminoAcidInsertions: List?, + orderBy: List?, + limit: Int?, + offset: Int?, +): CoOccurrenceRequest { + if (positions.isEmpty()) { + throw BadRequestException("'positions' must not be empty") + } + + return CoOccurrenceRequest( + sequenceFilters?.filter { !SPECIAL_REQUEST_PROPERTIES.contains(it.key) } ?: emptyMap(), + nucleotideMutations ?: emptyList(), + aminoAcidMutations ?: emptyList(), + nucleotideInsertions ?: emptyList(), + aminoAcidInsertions ?: emptyList(), + positions.map { parsePositionToken(it) }, + orderBy.toOrderBySpec(), + limit, + offset, + ) +} + +/** + * Builds the [AggregatedCollection] for a co-occurrence response: it queries SILO for the co-occurrence of + * symbols at the requested positions of [sequenceName], and prepares the dynamic (position-dependent) header + * for the response, e.g. `["S:1", "S:421", "count"]`. + */ +fun getCoOccurrenceCollection( + siloQueryModel: SiloQueryModel, + request: CoOccurrenceRequest, + sequenceName: String, +): AggregatedCollection { + val positions = request.positions.expandAndValidatePositions() + val fields = positions.map { coOccurrenceResponseFieldName(sequenceName, it) } + + return AggregatedCollection( + records = siloQueryModel.getCoOccurrence(request, sequenceName), + fields = fields, + ) +} diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/CoOccurrenceRoute.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/CoOccurrenceRoute.kt new file mode 100644 index 000000000..6b2ca0229 --- /dev/null +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/CoOccurrenceRoute.kt @@ -0,0 +1,4 @@ +package org.genspectrum.lapis.controller + +const val NUCLEOTIDE_CO_OCCURRENCE_ROUTE = "/nucleotideCoOccurrence" +const val AMINO_ACID_CO_OCCURRENCE_ROUTE = "/aminoAcidCoOccurrence" diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/ControllerDescriptions.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/ControllerDescriptions.kt index 04e18cb1b..a22fcca11 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/ControllerDescriptions.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/ControllerDescriptions.kt @@ -56,6 +56,14 @@ const val ALL_UNALIGNED_MULTI_SEGMENTED_NUCLEOTIDE_SEQUENCE_ENDPOINT_DESCRIPTION const val MUTATIONS_OVER_TIME_ENDPOINT_DESCRIPTION = """Returns the number of sequences containing the specified mutations within the requested date ranges, along with the corresponding coverage in a tabular format. The order of the mutations and date ranges is preserved.""" +const val NUCLEOTIDE_CO_OCCURRENCE_ENDPOINT_DESCRIPTION = + """Returns, for the given nucleotide sequence and list of (1-based) positions, every combination of symbols +that occurs at these positions among the sequences matching the specified sequence filters, along with the +number of sequences for each combination.""" +const val AMINO_ACID_CO_OCCURRENCE_ENDPOINT_DESCRIPTION = + """Returns, for the given gene and list of (1-based) positions, every combination of symbols +that occurs at these positions among the sequences matching the specified sequence filters, along with the +number of sequences for each combination.""" const val QUERIES_OVER_TIME_ENDPOINT_DESCRIPTION = """ Returns the number of sequences matching the specified queries within the requested date ranges, along with the corresponding coverage in a tabular format. The order of the queries and date ranges is preserved. diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/MultiSegmentedCoOccurrenceController.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/MultiSegmentedCoOccurrenceController.kt new file mode 100644 index 000000000..623861122 --- /dev/null +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/MultiSegmentedCoOccurrenceController.kt @@ -0,0 +1,311 @@ +package org.genspectrum.lapis.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import jakarta.servlet.http.HttpServletResponse +import org.genspectrum.lapis.controller.LapisMediaType.TEXT_CSV_VALUE +import org.genspectrum.lapis.controller.LapisMediaType.TEXT_TSV_VALUE +import org.genspectrum.lapis.model.SiloQueryModel +import org.genspectrum.lapis.openApi.AminoAcidInsertions +import org.genspectrum.lapis.openApi.AminoAcidMutations +import org.genspectrum.lapis.openApi.DataFormat +import org.genspectrum.lapis.openApi.Limit +import org.genspectrum.lapis.openApi.NucleotideInsertions +import org.genspectrum.lapis.openApi.NucleotideMutations +import org.genspectrum.lapis.openApi.Offset +import org.genspectrum.lapis.openApi.PrimitiveFieldFilters +import org.genspectrum.lapis.openApi.Segment +import org.genspectrum.lapis.openApi.StringResponseOperation +import org.genspectrum.lapis.request.AminoAcidInsertion +import org.genspectrum.lapis.request.AminoAcidMutation +import org.genspectrum.lapis.request.CoOccurrenceRequest +import org.genspectrum.lapis.request.GetRequestSequenceFilters +import org.genspectrum.lapis.request.NucleotideInsertion +import org.genspectrum.lapis.request.NucleotideMutation +import org.genspectrum.lapis.request.OrderByField +import org.genspectrum.lapis.response.Delimiter.COMMA +import org.genspectrum.lapis.response.Delimiter.TAB +import org.genspectrum.lapis.response.LapisResponseStreamer +import org.genspectrum.lapis.response.ResponseFormat +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression +import org.springframework.http.HttpHeaders +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.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@ConditionalOnExpression(IS_MULTI_SEGMENT_SEQUENCE_EXPRESSION) +@RequestMapping("/component") +class MultiSegmentedCoOccurrenceController( + private val siloQueryModel: SiloQueryModel, + private val lapisResponseStreamer: LapisResponseStreamer, +) { + @GetMapping( + "$NUCLEOTIDE_CO_OCCURRENCE_ROUTE/{segment}", + produces = [MediaType.APPLICATION_JSON_VALUE], + ) + @Operation(description = NUCLEOTIDE_CO_OCCURRENCE_ENDPOINT_DESCRIPTION) + fun getNucleotideCoOccurrenceAsJson( + @PathVariable(name = "segment", required = true) + @Segment + segment: String, + @PrimitiveFieldFilters + @RequestParam + sequenceFilters: GetRequestSequenceFilters?, + @RequestParam + positions: List, + @RequestParam + orderBy: List?, + @NucleotideMutations + @RequestParam + nucleotideMutations: List?, + @AminoAcidMutations + @RequestParam + aminoAcidMutations: List?, + @NucleotideInsertions + @RequestParam + nucleotideInsertions: List?, + @AminoAcidInsertions + @RequestParam + aminoAcidInsertions: List?, + @Limit + @RequestParam + limit: Int? = null, + @Offset + @RequestParam + offset: Int? = null, + @DataFormat + @RequestParam + dataFormat: String? = null, + response: HttpServletResponse, + ) { + val request = buildCoOccurrenceRequest( + sequenceFilters, + positions, + nucleotideMutations, + aminoAcidMutations, + nucleotideInsertions, + aminoAcidInsertions, + orderBy, + limit, + offset, + ) + + lapisResponseStreamer.streamData( + request = request, + getData = { getCoOccurrenceCollection(siloQueryModel, it, segment) }, + response = response, + responseFormat = ResponseFormat.Json, + ) + } + + @GetMapping( + "$NUCLEOTIDE_CO_OCCURRENCE_ROUTE/{segment}", + produces = [TEXT_CSV_VALUE], + ) + @StringResponseOperation( + description = NUCLEOTIDE_CO_OCCURRENCE_ENDPOINT_DESCRIPTION, + operationId = "getNucleotideCoOccurrenceAsCsv", + ) + fun getNucleotideCoOccurrenceAsCsv( + @PathVariable(name = "segment", required = true) + @Segment + segment: String, + @PrimitiveFieldFilters + @RequestParam + sequenceFilters: GetRequestSequenceFilters?, + @RequestParam + positions: List, + @RequestParam + orderBy: List?, + @NucleotideMutations + @RequestParam + nucleotideMutations: List?, + @AminoAcidMutations + @RequestParam + aminoAcidMutations: List?, + @NucleotideInsertions + @RequestParam + nucleotideInsertions: List?, + @AminoAcidInsertions + @RequestParam + aminoAcidInsertions: List?, + @Limit + @RequestParam + limit: Int? = null, + @Offset + @RequestParam + offset: Int? = null, + @DataFormat + @RequestParam + dataFormat: String? = null, + @RequestHeader httpHeaders: HttpHeaders, + response: HttpServletResponse, + ) { + val request = buildCoOccurrenceRequest( + sequenceFilters, + positions, + nucleotideMutations, + aminoAcidMutations, + nucleotideInsertions, + aminoAcidInsertions, + orderBy, + limit, + offset, + ) + + lapisResponseStreamer.streamData( + request = request, + getData = { getCoOccurrenceCollection(siloQueryModel, it, segment) }, + response = response, + responseFormat = ResponseFormat.Csv(delimiter = COMMA, acceptHeader = httpHeaders.accept), + ) + } + + @GetMapping( + "$NUCLEOTIDE_CO_OCCURRENCE_ROUTE/{segment}", + produces = [TEXT_TSV_VALUE], + ) + @StringResponseOperation( + description = NUCLEOTIDE_CO_OCCURRENCE_ENDPOINT_DESCRIPTION, + operationId = "getNucleotideCoOccurrenceAsTsv", + ) + fun getNucleotideCoOccurrenceAsTsv( + @PathVariable(name = "segment", required = true) + @Segment + segment: String, + @PrimitiveFieldFilters + @RequestParam + sequenceFilters: GetRequestSequenceFilters?, + @RequestParam + positions: List, + @RequestParam + orderBy: List?, + @NucleotideMutations + @RequestParam + nucleotideMutations: List?, + @AminoAcidMutations + @RequestParam + aminoAcidMutations: List?, + @NucleotideInsertions + @RequestParam + nucleotideInsertions: List?, + @AminoAcidInsertions + @RequestParam + aminoAcidInsertions: List?, + @Limit + @RequestParam + limit: Int? = null, + @Offset + @RequestParam + offset: Int? = null, + @DataFormat + @RequestParam + dataFormat: String? = null, + @RequestHeader httpHeaders: HttpHeaders, + response: HttpServletResponse, + ) { + val request = buildCoOccurrenceRequest( + sequenceFilters, + positions, + nucleotideMutations, + aminoAcidMutations, + nucleotideInsertions, + aminoAcidInsertions, + orderBy, + limit, + offset, + ) + + lapisResponseStreamer.streamData( + request = request, + getData = { getCoOccurrenceCollection(siloQueryModel, it, segment) }, + response = response, + responseFormat = ResponseFormat.Csv(delimiter = TAB, acceptHeader = httpHeaders.accept), + ) + } + + @PostMapping( + "$NUCLEOTIDE_CO_OCCURRENCE_ROUTE/{segment}", + produces = [MediaType.APPLICATION_JSON_VALUE], + consumes = [MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_FORM_URLENCODED_VALUE], + ) + @Operation( + description = NUCLEOTIDE_CO_OCCURRENCE_ENDPOINT_DESCRIPTION, + operationId = "postNucleotideCoOccurrence", + ) + fun postNucleotideCoOccurrence( + @PathVariable(name = "segment", required = true) + @Segment + segment: String, + @Parameter(description = "The sequence filters, positions, and other options for the co-occurrence query.") + @RequestBody + request: CoOccurrenceRequest, + response: HttpServletResponse, + ) { + lapisResponseStreamer.streamData( + request = request, + getData = { getCoOccurrenceCollection(siloQueryModel, it, segment) }, + response = response, + responseFormat = ResponseFormat.Json, + ) + } + + @PostMapping( + "$NUCLEOTIDE_CO_OCCURRENCE_ROUTE/{segment}", + produces = [TEXT_CSV_VALUE], + consumes = [MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_FORM_URLENCODED_VALUE], + ) + @StringResponseOperation( + description = NUCLEOTIDE_CO_OCCURRENCE_ENDPOINT_DESCRIPTION, + operationId = "postNucleotideCoOccurrenceAsCsv", + ) + fun postNucleotideCoOccurrenceAsCsv( + @PathVariable(name = "segment", required = true) + @Segment + segment: String, + @RequestBody + request: CoOccurrenceRequest, + @RequestHeader httpHeaders: HttpHeaders, + response: HttpServletResponse, + ) { + lapisResponseStreamer.streamData( + request = request, + getData = { getCoOccurrenceCollection(siloQueryModel, it, segment) }, + response = response, + responseFormat = ResponseFormat.Csv(delimiter = COMMA, acceptHeader = httpHeaders.accept), + ) + } + + @PostMapping( + "$NUCLEOTIDE_CO_OCCURRENCE_ROUTE/{segment}", + produces = [TEXT_TSV_VALUE], + consumes = [MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_FORM_URLENCODED_VALUE], + ) + @StringResponseOperation( + description = NUCLEOTIDE_CO_OCCURRENCE_ENDPOINT_DESCRIPTION, + operationId = "postNucleotideCoOccurrenceAsTsv", + ) + fun postNucleotideCoOccurrenceAsTsv( + @PathVariable(name = "segment", required = true) + @Segment + segment: String, + @RequestBody + request: CoOccurrenceRequest, + @RequestHeader httpHeaders: HttpHeaders, + response: HttpServletResponse, + ) { + lapisResponseStreamer.streamData( + request = request, + getData = { getCoOccurrenceCollection(siloQueryModel, it, segment) }, + response = response, + responseFormat = ResponseFormat.Csv(delimiter = TAB, acceptHeader = httpHeaders.accept), + ) + } +} diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/SingleSegmentedCoOccurrenceController.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/SingleSegmentedCoOccurrenceController.kt new file mode 100644 index 000000000..1a6194d89 --- /dev/null +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/SingleSegmentedCoOccurrenceController.kt @@ -0,0 +1,295 @@ +package org.genspectrum.lapis.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import jakarta.servlet.http.HttpServletResponse +import org.genspectrum.lapis.config.ReferenceGenomeSchema +import org.genspectrum.lapis.controller.LapisMediaType.TEXT_CSV_VALUE +import org.genspectrum.lapis.controller.LapisMediaType.TEXT_TSV_VALUE +import org.genspectrum.lapis.model.SiloQueryModel +import org.genspectrum.lapis.openApi.AminoAcidInsertions +import org.genspectrum.lapis.openApi.AminoAcidMutations +import org.genspectrum.lapis.openApi.DataFormat +import org.genspectrum.lapis.openApi.Limit +import org.genspectrum.lapis.openApi.NucleotideInsertions +import org.genspectrum.lapis.openApi.NucleotideMutations +import org.genspectrum.lapis.openApi.Offset +import org.genspectrum.lapis.openApi.PrimitiveFieldFilters +import org.genspectrum.lapis.openApi.StringResponseOperation +import org.genspectrum.lapis.request.AminoAcidInsertion +import org.genspectrum.lapis.request.AminoAcidMutation +import org.genspectrum.lapis.request.CoOccurrenceRequest +import org.genspectrum.lapis.request.GetRequestSequenceFilters +import org.genspectrum.lapis.request.NucleotideInsertion +import org.genspectrum.lapis.request.NucleotideMutation +import org.genspectrum.lapis.request.OrderByField +import org.genspectrum.lapis.response.Delimiter.COMMA +import org.genspectrum.lapis.response.Delimiter.TAB +import org.genspectrum.lapis.response.LapisResponseStreamer +import org.genspectrum.lapis.response.ResponseFormat +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression +import org.springframework.http.HttpHeaders +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.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@ConditionalOnExpression(SHOW_SINGLE_SEGMENTED_CONTROLLER) +@RequestMapping("/component") +class SingleSegmentedCoOccurrenceController( + private val siloQueryModel: SiloQueryModel, + private val lapisResponseStreamer: LapisResponseStreamer, + private val referenceGenomeSchema: ReferenceGenomeSchema, +) { + @GetMapping( + NUCLEOTIDE_CO_OCCURRENCE_ROUTE, + produces = [MediaType.APPLICATION_JSON_VALUE], + ) + @Operation(description = NUCLEOTIDE_CO_OCCURRENCE_ENDPOINT_DESCRIPTION) + fun getNucleotideCoOccurrenceAsJson( + @PrimitiveFieldFilters + @RequestParam + sequenceFilters: GetRequestSequenceFilters?, + @RequestParam + positions: List, + @RequestParam + orderBy: List?, + @NucleotideMutations + @RequestParam + nucleotideMutations: List?, + @AminoAcidMutations + @RequestParam + aminoAcidMutations: List?, + @NucleotideInsertions + @RequestParam + nucleotideInsertions: List?, + @AminoAcidInsertions + @RequestParam + aminoAcidInsertions: List?, + @Limit + @RequestParam + limit: Int? = null, + @Offset + @RequestParam + offset: Int? = null, + @DataFormat + @RequestParam + dataFormat: String? = null, + response: HttpServletResponse, + ) { + val request = buildCoOccurrenceRequest( + sequenceFilters, + positions, + nucleotideMutations, + aminoAcidMutations, + nucleotideInsertions, + aminoAcidInsertions, + orderBy, + limit, + offset, + ) + + lapisResponseStreamer.streamData( + request = request, + getData = { getCoOccurrenceCollection(siloQueryModel, it, soleSegmentName()) }, + response = response, + responseFormat = ResponseFormat.Json, + ) + } + + @GetMapping( + NUCLEOTIDE_CO_OCCURRENCE_ROUTE, + produces = [TEXT_CSV_VALUE], + ) + @StringResponseOperation( + description = NUCLEOTIDE_CO_OCCURRENCE_ENDPOINT_DESCRIPTION, + operationId = "getSingleSegmentNucleotideCoOccurrenceAsCsv", + ) + fun getNucleotideCoOccurrenceAsCsv( + @PrimitiveFieldFilters + @RequestParam + sequenceFilters: GetRequestSequenceFilters?, + @RequestParam + positions: List, + @RequestParam + orderBy: List?, + @NucleotideMutations + @RequestParam + nucleotideMutations: List?, + @AminoAcidMutations + @RequestParam + aminoAcidMutations: List?, + @NucleotideInsertions + @RequestParam + nucleotideInsertions: List?, + @AminoAcidInsertions + @RequestParam + aminoAcidInsertions: List?, + @Limit + @RequestParam + limit: Int? = null, + @Offset + @RequestParam + offset: Int? = null, + @DataFormat + @RequestParam + dataFormat: String? = null, + @RequestHeader httpHeaders: HttpHeaders, + response: HttpServletResponse, + ) { + val request = buildCoOccurrenceRequest( + sequenceFilters, + positions, + nucleotideMutations, + aminoAcidMutations, + nucleotideInsertions, + aminoAcidInsertions, + orderBy, + limit, + offset, + ) + + lapisResponseStreamer.streamData( + request = request, + getData = { getCoOccurrenceCollection(siloQueryModel, it, soleSegmentName()) }, + response = response, + responseFormat = ResponseFormat.Csv(delimiter = COMMA, acceptHeader = httpHeaders.accept), + ) + } + + @GetMapping( + NUCLEOTIDE_CO_OCCURRENCE_ROUTE, + produces = [TEXT_TSV_VALUE], + ) + @StringResponseOperation( + description = NUCLEOTIDE_CO_OCCURRENCE_ENDPOINT_DESCRIPTION, + operationId = "getSingleSegmentNucleotideCoOccurrenceAsTsv", + ) + fun getNucleotideCoOccurrenceAsTsv( + @PrimitiveFieldFilters + @RequestParam + sequenceFilters: GetRequestSequenceFilters?, + @RequestParam + positions: List, + @RequestParam + orderBy: List?, + @NucleotideMutations + @RequestParam + nucleotideMutations: List?, + @AminoAcidMutations + @RequestParam + aminoAcidMutations: List?, + @NucleotideInsertions + @RequestParam + nucleotideInsertions: List?, + @AminoAcidInsertions + @RequestParam + aminoAcidInsertions: List?, + @Limit + @RequestParam + limit: Int? = null, + @Offset + @RequestParam + offset: Int? = null, + @DataFormat + @RequestParam + dataFormat: String? = null, + @RequestHeader httpHeaders: HttpHeaders, + response: HttpServletResponse, + ) { + val request = buildCoOccurrenceRequest( + sequenceFilters, + positions, + nucleotideMutations, + aminoAcidMutations, + nucleotideInsertions, + aminoAcidInsertions, + orderBy, + limit, + offset, + ) + + lapisResponseStreamer.streamData( + request = request, + getData = { getCoOccurrenceCollection(siloQueryModel, it, soleSegmentName()) }, + response = response, + responseFormat = ResponseFormat.Csv(delimiter = TAB, acceptHeader = httpHeaders.accept), + ) + } + + @PostMapping( + NUCLEOTIDE_CO_OCCURRENCE_ROUTE, + produces = [MediaType.APPLICATION_JSON_VALUE], + consumes = [MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_FORM_URLENCODED_VALUE], + ) + @Operation( + description = NUCLEOTIDE_CO_OCCURRENCE_ENDPOINT_DESCRIPTION, + operationId = "postSingleSegmentNucleotideCoOccurrence", + ) + fun postNucleotideCoOccurrence( + @Parameter(description = "The sequence filters, positions, and other options for the co-occurrence query.") + @RequestBody + request: CoOccurrenceRequest, + response: HttpServletResponse, + ) { + lapisResponseStreamer.streamData( + request = request, + getData = { getCoOccurrenceCollection(siloQueryModel, it, soleSegmentName()) }, + response = response, + responseFormat = ResponseFormat.Json, + ) + } + + @PostMapping( + NUCLEOTIDE_CO_OCCURRENCE_ROUTE, + produces = [TEXT_CSV_VALUE], + consumes = [MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_FORM_URLENCODED_VALUE], + ) + @StringResponseOperation( + description = NUCLEOTIDE_CO_OCCURRENCE_ENDPOINT_DESCRIPTION, + operationId = "postSingleSegmentNucleotideCoOccurrenceAsCsv", + ) + fun postNucleotideCoOccurrenceAsCsv( + @RequestBody + request: CoOccurrenceRequest, + @RequestHeader httpHeaders: HttpHeaders, + response: HttpServletResponse, + ) { + lapisResponseStreamer.streamData( + request = request, + getData = { getCoOccurrenceCollection(siloQueryModel, it, soleSegmentName()) }, + response = response, + responseFormat = ResponseFormat.Csv(delimiter = COMMA, acceptHeader = httpHeaders.accept), + ) + } + + @PostMapping( + NUCLEOTIDE_CO_OCCURRENCE_ROUTE, + produces = [TEXT_TSV_VALUE], + consumes = [MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_FORM_URLENCODED_VALUE], + ) + @StringResponseOperation( + description = NUCLEOTIDE_CO_OCCURRENCE_ENDPOINT_DESCRIPTION, + operationId = "postSingleSegmentNucleotideCoOccurrenceAsTsv", + ) + fun postNucleotideCoOccurrenceAsTsv( + @RequestBody + request: CoOccurrenceRequest, + @RequestHeader httpHeaders: HttpHeaders, + response: HttpServletResponse, + ) { + lapisResponseStreamer.streamData( + request = request, + getData = { getCoOccurrenceCollection(siloQueryModel, it, soleSegmentName()) }, + response = response, + responseFormat = ResponseFormat.Csv(delimiter = TAB, acceptHeader = httpHeaders.accept), + ) + } + + private fun soleSegmentName() = referenceGenomeSchema.nucleotideSequences[0].name +} diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/model/SiloQueryModel.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/model/SiloQueryModel.kt index ee3ed9000..6973aea7f 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/model/SiloQueryModel.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/model/SiloQueryModel.kt @@ -2,14 +2,18 @@ package org.genspectrum.lapis.model import org.genspectrum.lapis.config.DatabaseConfig import org.genspectrum.lapis.config.ReferenceGenomeSchema +import org.genspectrum.lapis.request.CoOccurrenceRequest import org.genspectrum.lapis.request.CommonSequenceFilters import org.genspectrum.lapis.request.MRCASequenceFiltersRequest import org.genspectrum.lapis.request.MutationProportionsRequest import org.genspectrum.lapis.request.MutationsField +import org.genspectrum.lapis.request.OrderByField import org.genspectrum.lapis.request.OrderBySpec import org.genspectrum.lapis.request.PhyloTreeSequenceFiltersRequest import org.genspectrum.lapis.request.SequenceFiltersRequest import org.genspectrum.lapis.request.SequenceFiltersRequestWithFields +import org.genspectrum.lapis.request.expandAndValidatePositions +import org.genspectrum.lapis.response.AggregationData import org.genspectrum.lapis.response.ExplicitlyNullable import org.genspectrum.lapis.response.InfoData import org.genspectrum.lapis.response.InsertionResponse @@ -20,8 +24,11 @@ import org.genspectrum.lapis.silo.SequenceType import org.genspectrum.lapis.silo.SiloAction import org.genspectrum.lapis.silo.SiloClient import org.genspectrum.lapis.silo.SiloQuery +import org.genspectrum.lapis.silo.coOccurrencePositionColumnName +import org.genspectrum.lapis.silo.coOccurrenceResponseFieldName import org.genspectrum.lapis.util.toUnalignedSequenceName import org.springframework.stereotype.Component +import tools.jackson.databind.node.NullNode import java.util.stream.Stream @Component @@ -47,6 +54,62 @@ class SiloQueryModel( ), ) + fun getCoOccurrence( + sequenceFilters: CoOccurrenceRequest, + sequenceName: String, + ): Stream { + val positions = sequenceFilters.positions.expandAndValidatePositions() + + val responseFieldNameToColumnName = positions.associate { + coOccurrenceResponseFieldName(sequenceName, it) to coOccurrencePositionColumnName(it) + } + + val data = siloClient.sendQuery( + SiloQuery( + SiloAction.coOccurrence( + sequenceName = sequenceName, + positions = positions, + orderByFields = remapOrderByFields(sequenceFilters.orderByFields, responseFieldNameToColumnName), + limit = sequenceFilters.limit, + offset = sequenceFilters.offset, + ), + siloFilterExpressionMapper.map(sequenceFilters), + ), + ) + + return data.map { relabelCoOccurrenceFields(it, sequenceName, positions) } + } + + private fun relabelCoOccurrenceFields( + data: AggregationData, + sequenceName: String, + positions: List, + ): AggregationData { + val relabeledFields = positions.associate { position -> + coOccurrenceResponseFieldName(sequenceName, position) to + (data.fields[coOccurrencePositionColumnName(position)] ?: NullNode.getInstance()) + } + return data.copy(fields = relabeledFields) + } + + /** + * The user-facing `orderBy` for co-occurrence queries refers to the relabeled response field names + * (e.g. "S:123"), but the SILO query needs to refer to the internal groupBy column names (e.g. "pos_123"). + */ + private fun remapOrderByFields( + orderBySpec: OrderBySpec, + responseFieldNameToColumnName: Map, + ): OrderBySpec = + when (orderBySpec) { + is OrderBySpec.ByFields -> OrderBySpec.ByFields( + orderBySpec.fields.map { field -> + OrderByField(responseFieldNameToColumnName[field.field] ?: field.field, field.order) + }, + ) + + is OrderBySpec.Random -> orderBySpec + } + fun computeNucleotideMutationProportions(sequenceFilters: MutationProportionsRequest): Stream { val fields = sequenceFilters.fields .let { diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/request/CoOccurrencePosition.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/request/CoOccurrencePosition.kt new file mode 100644 index 000000000..e9c5f6624 --- /dev/null +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/request/CoOccurrencePosition.kt @@ -0,0 +1,171 @@ +package org.genspectrum.lapis.request + +import org.genspectrum.lapis.controller.BadRequestException +import org.springframework.boot.jackson.JacksonComponent +import tools.jackson.core.JsonParser +import tools.jackson.databind.DeserializationContext +import tools.jackson.databind.JsonNode +import tools.jackson.databind.ValueDeserializer +import tools.jackson.databind.node.JsonNodeType + +/** + * A single 1-based position, or an inclusive range of 1-based positions, requested for a co-occurrence query. + */ +sealed class CoOccurrencePosition { + data class Single( + val position: Int, + ) : CoOccurrencePosition() + + data class Range( + val from: Int, + val to: Int, + ) : CoOccurrencePosition() +} + +/** + * Parses a single token from a GET request (or a form-urlencoded POST body) into a [CoOccurrencePosition]. + * A token is either a plain positive integer (e.g. "5") or an inclusive range (e.g. "100-110"). + */ +fun parsePositionToken(token: String): CoOccurrencePosition { + // TODO: accept number or object, no "range strings" + val trimmed = token.trim() + + val rangeMatch = POSITION_RANGE_REGEX.matchEntire(trimmed) + if (rangeMatch != null) { + val (fromString, toString) = rangeMatch.destructured + val from = fromString.toIntOrNull() + val to = toString.toIntOrNull() + if (from == null || to == null) { + throw BadRequestException( + "Invalid range '$token' in $POSITIONS_PROPERTY: 'from' and 'to' must fit in a 32-bit integer", + ) + } + return CoOccurrencePosition.Range(from, to) + } + + val position = trimmed.toIntOrNull() + ?: throw BadRequestException( + "Invalid entry '$token' in $POSITIONS_PROPERTY: must be a number (e.g. '5') " + + "or an inclusive range (e.g. '100-110')", + ) + return CoOccurrencePosition.Single(position) +} + +private val POSITION_RANGE_REGEX = Regex("""^(\d+)-(\d+)$""") + +/** + * Safety limit on the number of distinct positions a single co-occurrence request may expand to. + * This protects against out-of-memory/denial-of-service from a single huge range (e.g. `1-2000000000`) + * or many ranges/positions combined. No known pathogen genome comes close to this length. + */ +const val MAX_CO_OCCURRENCE_POSITIONS = 1_000_000 + +/** + * Expands a list of [CoOccurrencePosition]s into a sorted list of distinct 1-based positions. + * Validates that: + * - the list is not empty + * - all positions/bounds are >= 1 + * - for ranges, `from` <= `to` + * - the total number of distinct expanded positions does not exceed [MAX_CO_OCCURRENCE_POSITIONS] + */ +fun List.expandAndValidatePositions(): List { + if (this.isEmpty()) { + throw BadRequestException("$POSITIONS_PROPERTY must not be empty") + } + + val result = mutableSetOf() + + for (position in this) { + when (position) { + is CoOccurrencePosition.Single -> { + if (position.position < 1) { + throw BadRequestException( + "Invalid position ${position.position} in $POSITIONS_PROPERTY: must be >= 1", + ) + } + result.add(position.position) + } + + is CoOccurrencePosition.Range -> { + if (position.from < 1 || position.to < 1) { + throw BadRequestException( + "Invalid range ${position.from}-${position.to} in $POSITIONS_PROPERTY: " + + "bounds must be >= 1", + ) + } + if (position.from > position.to) { + throw BadRequestException( + "Invalid range ${position.from}-${position.to} in $POSITIONS_PROPERTY: " + + "'from' must be <= 'to'", + ) + } + + // Compute the span before materializing it, so a huge range (e.g. 1-2000000000) can't + // exhaust memory before we get a chance to reject it. + val rangeSize = position.to.toLong() - position.from.toLong() + 1 + if (rangeSize > MAX_CO_OCCURRENCE_POSITIONS) { + throw BadRequestException( + "Invalid range ${position.from}-${position.to} in $POSITIONS_PROPERTY: " + + "spans $rangeSize positions, which exceeds the maximum of " + + "$MAX_CO_OCCURRENCE_POSITIONS positions per request", + ) + } + + (position.from..position.to).forEach { result.add(it) } + } + } + + if (result.size > MAX_CO_OCCURRENCE_POSITIONS) { + throw BadRequestException( + "$POSITIONS_PROPERTY must not expand to more than $MAX_CO_OCCURRENCE_POSITIONS " + + "distinct positions", + ) + } + } + + return result.sorted() +} + +/** + * Deserializes a single entry of the `positions` array of a co-occurrence request body. + * An entry can be: + * - a JSON number, e.g. `5` + * - a JSON object with `from`/`to`, e.g. `{"from": 100, "to": 110}` + * - a JSON string (used for form-urlencoded requests), e.g. `"5"` or `"100-110"` + */ +@JacksonComponent +class CoOccurrencePositionDeserializer : ValueDeserializer() { + override fun deserialize( + jsonParser: JsonParser, + ctxt: DeserializationContext, + ): CoOccurrencePosition { + val node = jsonParser.readValueAsTree() + + return when (node.nodeType) { + JsonNodeType.NUMBER -> CoOccurrencePosition.Single(node.asInt()) + + JsonNodeType.STRING -> parsePositionToken(node.asString()) + + JsonNodeType.OBJECT -> { + val fromNode = node.get("from") + val toNode = node.get("to") + if (fromNode == null || + toNode == null || + !fromNode.canConvertToInt() || + !toNode.canConvertToInt() + ) { + throw BadRequestException( + "Each object entry in $POSITIONS_PROPERTY must have integer 'from' and 'to' " + + "properties that fit in a 32-bit integer, but was $node", + ) + } + CoOccurrencePosition.Range(fromNode.asInt(), toNode.asInt()) + } + + else -> throw BadRequestException( + "Each entry in $POSITIONS_PROPERTY must be a number, a string, or an object with " + + "'from'/'to', ${butWas(node)}", + ) + } + } +} diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/request/CoOccurrenceRequest.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/request/CoOccurrenceRequest.kt new file mode 100644 index 000000000..073836415 --- /dev/null +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/request/CoOccurrenceRequest.kt @@ -0,0 +1,60 @@ +package org.genspectrum.lapis.request + +import org.genspectrum.lapis.controller.BadRequestException +import org.springframework.boot.jackson.JacksonComponent +import tools.jackson.core.JsonParser +import tools.jackson.databind.DeserializationContext +import tools.jackson.databind.JsonNode +import tools.jackson.databind.ValueDeserializer +import tools.jackson.databind.node.ArrayNode + +data class CoOccurrenceRequest( + override val sequenceFilters: SequenceFilters, + override val nucleotideMutations: List, + override val aminoAcidMutations: List, + override val nucleotideInsertions: List, + override val aminoAcidInsertions: List, + val positions: List, + override val orderByFields: OrderBySpec = OrderBySpec.EMPTY, + override val limit: Int? = null, + override val offset: Int? = null, +) : CommonSequenceFilters + +@JacksonComponent +class CoOccurrenceRequestDeserializer : ValueDeserializer() { + override fun deserialize( + jsonParser: JsonParser, + ctxt: DeserializationContext, + ): CoOccurrenceRequest { + val node = jsonParser.readValueAsTree() + + val positions = parsePositionsProperty(node, ctxt) + val parsedCommonFields = parseCommonFields(node, ctxt) + + return CoOccurrenceRequest( + sequenceFilters = parsedCommonFields.sequenceFilters, + nucleotideMutations = parsedCommonFields.nucleotideMutations, + aminoAcidMutations = parsedCommonFields.aminoAcidMutations, + nucleotideInsertions = parsedCommonFields.nucleotideInsertions, + aminoAcidInsertions = parsedCommonFields.aminoAcidInsertions, + positions = positions, + orderByFields = parsedCommonFields.orderByFields, + limit = parsedCommonFields.limit, + offset = parsedCommonFields.offset, + ) + } +} + +private fun parsePositionsProperty( + node: JsonNode, + ctxt: DeserializationContext, +): List = + when (val positionsNode = node.get(POSITIONS_PROPERTY)) { + null -> throw BadRequestException("$POSITIONS_PROPERTY is required") + is ArrayNode -> positionsNode.toList().map { + ctxt.readTreeAsValue(it, CoOccurrencePosition::class.java) + } + else -> throw BadRequestException( + "$POSITIONS_PROPERTY must be an array, ${butWas(positionsNode)}", + ) + } diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/request/SpecialProperties.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/request/SpecialProperties.kt index b9f78f2d4..d1ad37a7a 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/request/SpecialProperties.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/request/SpecialProperties.kt @@ -20,6 +20,7 @@ const val GENES_PROPERTY = "genes" const val FASTA_HEADER_TEMPLATE_PROPERTY = "fastaHeaderTemplate" const val PRINT_NODES_NOT_IN_TREE_FIELD_PROPERTY = "printNodesNotInTree" const val PHYLO_TREE_FIELD_PROPERTY = "phyloTreeField" +const val POSITIONS_PROPERTY = "positions" val SPECIAL_REQUEST_PROPERTY_TYPES = mapOf( FORMAT_PROPERTY to JsonNodeType.STRING, @@ -40,6 +41,7 @@ val SPECIAL_REQUEST_PROPERTY_TYPES = mapOf( FASTA_HEADER_TEMPLATE_PROPERTY to JsonNodeType.STRING, PRINT_NODES_NOT_IN_TREE_FIELD_PROPERTY to JsonNodeType.BOOLEAN, PHYLO_TREE_FIELD_PROPERTY to JsonNodeType.STRING, + POSITIONS_PROPERTY to JsonNodeType.ARRAY, ) val SPECIAL_REQUEST_PROPERTIES = SPECIAL_REQUEST_PROPERTY_TYPES.keys diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuery.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuery.kt index 510c894cb..535bda5ff 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuery.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuery.kt @@ -139,6 +139,22 @@ sealed class SiloAction( randomize = getRandomize(orderByFields), ) + fun coOccurrence( + sequenceName: String, + positions: List, + orderByFields: OrderBySpec = OrderBySpec.EMPTY, + limit: Int? = null, + offset: Int? = null, + ): SiloAction = + CoOccurrenceAction( + sequenceName = sequenceName, + positions = positions, + orderByFields = getOrderByFieldsList(orderByFields), + randomize = getRandomize(orderByFields), + limit = limit, + offset = offset, + ) + fun genomicSequence( type: SequenceType, sequenceNames: List, @@ -190,6 +206,21 @@ sealed class SiloAction( val type: String = "Aggregated" } + @JsonInclude(JsonInclude.Include.NON_EMPTY) + data class CoOccurrenceAction( + val sequenceName: String, + val positions: List, + override val orderByFields: List = emptyList(), + override val randomize: RandomizeConfig? = null, + override val limit: Int? = null, + override val offset: Int? = null, + ) : SiloAction( + arrowConverter = AGGREGATION_DATA_ARROW_CONVERTER, + cacheable = true, + ) { + val type: String = "CoOccurrence" + } + @JsonInclude(JsonInclude.Include.NON_EMPTY) data class MutationsAction( val minProportion: Double?, @@ -438,6 +469,21 @@ data class IsNotNull( val column: String, ) : SiloFilterExpression("IsNotNull") +/** + * The name of the SILO groupBy/map column used internally for a given 1-based co-occurrence position. + * This is a valid SaneQL identifier - it does not need to be quoted since it's fully controlled by LAPIS + * (as opposed to the user-supplied sequence name). + */ +fun coOccurrencePositionColumnName(position: Int) = "pos_$position" + +/** + * The field name used in the LAPIS API response for a given sequence name and 1-based co-occurrence position. + */ +fun coOccurrenceResponseFieldName( + sequenceName: String, + position: Int, +) = "$sequenceName:$position" + enum class SequenceType { @JsonProperty("Fasta") UNALIGNED, diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuerySaneQlSerializer.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuerySaneQlSerializer.kt index 04e4b6f14..d6621d47c 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuerySaneQlSerializer.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuerySaneQlSerializer.kt @@ -160,6 +160,7 @@ object SiloQuerySaneQlSerializer { private fun serializeAction(action: SiloAction<*>): String = when (action) { is SiloAction.AggregatedAction -> serializeAggregatedAction(action) + is SiloAction.CoOccurrenceAction -> serializeCoOccurrenceAction(action) is SiloAction.DetailsAction -> serializeDetailsAction(action) is SiloAction.MutationsAction -> serializeMutationsAction(action) is SiloAction.AminoAcidMutationsAction -> serializeAminoAcidMutationsAction(action) @@ -180,6 +181,17 @@ object SiloQuerySaneQlSerializer { append(")") } + private fun serializeCoOccurrenceAction(action: SiloAction.CoOccurrenceAction): String { + val sequenceColumn = id(action.sequenceName) + val columnNames = action.positions.map { id(coOccurrencePositionColumnName(it)) } + + val assignments = action.positions.zip(columnNames) + .joinToString(", ") { (position, columnName) -> "$columnName:=$sequenceColumn.at($position)" } + val groupByColumns = columnNames.joinToString(", ") + + return ".map({$assignments}).groupBy({count:=count()}, {$groupByColumns})" + } + private fun serializeDetailsAction(action: SiloAction.DetailsAction): String = if (action.fields.isEmpty()) { "" diff --git a/lapis/src/test/kotlin/org/genspectrum/lapis/controller/CoOccurrenceControllerTest.kt b/lapis/src/test/kotlin/org/genspectrum/lapis/controller/CoOccurrenceControllerTest.kt new file mode 100644 index 000000000..fb1722d4d --- /dev/null +++ b/lapis/src/test/kotlin/org/genspectrum/lapis/controller/CoOccurrenceControllerTest.kt @@ -0,0 +1,210 @@ +package org.genspectrum.lapis.controller + +import com.ninjasquad.springmockk.MockkBean +import io.mockk.every +import io.mockk.verify +import org.genspectrum.lapis.model.SiloQueryModel +import org.genspectrum.lapis.request.CoOccurrencePosition +import org.genspectrum.lapis.request.CoOccurrenceRequest +import org.genspectrum.lapis.request.OrderBySpec +import org.genspectrum.lapis.response.AggregationData +import org.genspectrum.lapis.silo.DataVersion +import org.hamcrest.Matchers.containsString +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc +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.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import tools.jackson.databind.node.StringNode +import java.util.stream.Stream + +/** + * The default test reference genome is multi-segmented (segments: main, other_segment; genes: gene1, gene2), + * see application-test.properties. This covers [MultiSegmentedCoOccurrenceController] and + * [AminoAcidCoOccurrenceController]. [SingleSegmentedCoOccurrenceController] is covered by a dedicated test class. + */ +@SpringBootTest +@AutoConfigureMockMvc +class CoOccurrenceControllerTest( + @param:Autowired val mockMvc: MockMvc, +) { + @MockkBean + lateinit var siloQueryModelMock: SiloQueryModel + + @MockkBean + lateinit var dataVersion: DataVersion + + @BeforeEach + fun setup() { + every { dataVersion.dataVersion } returns "1234" + } + + @Test + fun `GET nucleotideCoOccurrence with segment and positions returns relabeled fields`() { + every { + siloQueryModelMock.getCoOccurrence( + CoOccurrenceRequest( + sequenceFilters = mapOf("country" to listOf("Switzerland")), + nucleotideMutations = emptyList(), + aminoAcidMutations = emptyList(), + nucleotideInsertions = emptyList(), + aminoAcidInsertions = emptyList(), + positions = listOf(CoOccurrencePosition.Single(1), CoOccurrencePosition.Single(421)), + ), + "main", + ) + } returns Stream.of( + AggregationData(48, mapOf("main:1" to StringNode("A"), "main:421" to StringNode("T"))), + ) + + mockMvc.perform(get("/component/nucleotideCoOccurrence/main?positions=1,421&country=Switzerland")) + .andExpect(status().isOk) + .andExpect(jsonPath("\$.data[0].count").value(48)) + .andExpect(jsonPath("\$.data[0]['main:1']").value("A")) + .andExpect(jsonPath("\$.data[0]['main:421']").value("T")) + .andExpect(header().stringValues("Lapis-Data-Version", "1234")) + } + + @Test + fun `GET nucleotideCoOccurrence with a range in positions expands it`() { + every { + siloQueryModelMock.getCoOccurrence( + CoOccurrenceRequest( + sequenceFilters = emptyMap(), + nucleotideMutations = emptyList(), + aminoAcidMutations = emptyList(), + nucleotideInsertions = emptyList(), + aminoAcidInsertions = emptyList(), + positions = listOf(CoOccurrencePosition.Single(1), CoOccurrencePosition.Range(100, 102)), + ), + "main", + ) + } returns Stream.empty() + + mockMvc.perform(get("/component/nucleotideCoOccurrence/main?positions=1,100-102")) + .andExpect(status().isOk) + } + + @Test + fun `GET nucleotideCoOccurrence without positions returns bad request`() { + mockMvc.perform(get("/component/nucleotideCoOccurrence/main")) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("\$.error.detail", containsString("positions"))) + } + + @Test + fun `GET nucleotideCoOccurrence as csv returns csv with dynamic header`() { + every { + siloQueryModelMock.getCoOccurrence( + CoOccurrenceRequest( + sequenceFilters = emptyMap(), + nucleotideMutations = emptyList(), + aminoAcidMutations = emptyList(), + nucleotideInsertions = emptyList(), + aminoAcidInsertions = emptyList(), + positions = listOf(CoOccurrencePosition.Single(1), CoOccurrencePosition.Single(2)), + ), + "main", + ) + } returns Stream.of( + AggregationData(5, mapOf("main:1" to StringNode("A"), "main:2" to StringNode("C"))), + ) + + mockMvc.perform(get("/component/nucleotideCoOccurrence/main?positions=1,2").header("Accept", "text/csv")) + .andExpect(status().isOk) + .andExpect(content().string("main:1,main:2,count\nA,C,5\n")) + } + + @Test + fun `POST aminoAcidCoOccurrence returns relabeled fields`() { + every { + siloQueryModelMock.getCoOccurrence( + CoOccurrenceRequest( + sequenceFilters = mapOf("country" to listOf("Switzerland")), + nucleotideMutations = emptyList(), + aminoAcidMutations = emptyList(), + nucleotideInsertions = emptyList(), + aminoAcidInsertions = emptyList(), + positions = listOf(CoOccurrencePosition.Single(1), CoOccurrencePosition.Single(2)), + ), + "gene1", + ) + } returns Stream.of( + AggregationData(3, mapOf("gene1:1" to StringNode("M"), "gene1:2" to StringNode("K"))), + ) + + mockMvc.perform( + post("/component/aminoAcidCoOccurrence/gene1") + .content( + """{ + "country": "Switzerland", + "positions": [1, 2] + } + """.trimIndent(), + ) + .contentType(MediaType.APPLICATION_JSON), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("\$.data[0].count").value(3)) + .andExpect(jsonPath("\$.data[0]['gene1:1']").value("M")) + .andExpect(jsonPath("\$.data[0]['gene1:2']").value("K")) + } + + @Test + fun `POST aminoAcidCoOccurrence with position range`() { + every { + siloQueryModelMock.getCoOccurrence( + CoOccurrenceRequest( + sequenceFilters = emptyMap(), + nucleotideMutations = emptyList(), + aminoAcidMutations = emptyList(), + nucleotideInsertions = emptyList(), + aminoAcidInsertions = emptyList(), + positions = listOf(CoOccurrencePosition.Range(1, 3)), + orderByFields = OrderBySpec.EMPTY, + ), + "gene1", + ) + } returns Stream.empty() + + mockMvc.perform( + post("/component/aminoAcidCoOccurrence/gene1") + .content("""{"positions": [{"from": 1, "to": 3}]}""") + .contentType(MediaType.APPLICATION_JSON), + ) + .andExpect(status().isOk) + + verify { + siloQueryModelMock.getCoOccurrence( + CoOccurrenceRequest( + sequenceFilters = emptyMap(), + nucleotideMutations = emptyList(), + aminoAcidMutations = emptyList(), + nucleotideInsertions = emptyList(), + aminoAcidInsertions = emptyList(), + positions = listOf(CoOccurrencePosition.Range(1, 3)), + ), + "gene1", + ) + } + } + + @Test + fun `POST aminoAcidCoOccurrence without positions returns bad request`() { + mockMvc.perform( + post("/component/aminoAcidCoOccurrence/gene1") + .content("{}") + .contentType(MediaType.APPLICATION_JSON), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("\$.error.detail", containsString("positions"))) + } +} diff --git a/lapis/src/test/kotlin/org/genspectrum/lapis/controller/SingleSegmentedCoOccurrenceControllerTest.kt b/lapis/src/test/kotlin/org/genspectrum/lapis/controller/SingleSegmentedCoOccurrenceControllerTest.kt new file mode 100644 index 000000000..4efd7eee1 --- /dev/null +++ b/lapis/src/test/kotlin/org/genspectrum/lapis/controller/SingleSegmentedCoOccurrenceControllerTest.kt @@ -0,0 +1,74 @@ +package org.genspectrum.lapis.controller + +import com.ninjasquad.springmockk.MockkBean +import io.mockk.every +import org.genspectrum.lapis.config.REFERENCE_GENOME_GENES_APPLICATION_ARG_PREFIX +import org.genspectrum.lapis.config.REFERENCE_GENOME_SEGMENTS_APPLICATION_ARG_PREFIX +import org.genspectrum.lapis.model.SiloQueryModel +import org.genspectrum.lapis.request.CoOccurrencePosition +import org.genspectrum.lapis.request.CoOccurrenceRequest +import org.genspectrum.lapis.response.AggregationData +import org.genspectrum.lapis.silo.DataVersion +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import tools.jackson.databind.node.StringNode +import java.util.stream.Stream + +private const val SEGMENT_NAME = "otherSegment" + +@SpringBootTest( + properties = [ + "$REFERENCE_GENOME_SEGMENTS_APPLICATION_ARG_PREFIX=$SEGMENT_NAME", + "$REFERENCE_GENOME_GENES_APPLICATION_ARG_PREFIX=gene1,gene2", + ], +) +@AutoConfigureMockMvc +class SingleSegmentedCoOccurrenceControllerTest( + @param:Autowired val mockMvc: MockMvc, +) { + @MockkBean + lateinit var siloQueryModelMock: SiloQueryModel + + @MockkBean + lateinit var dataVersion: DataVersion + + @BeforeEach + fun setup() { + every { dataVersion.dataVersion } returns "1234" + } + + @Test + fun `GET nucleotideCoOccurrence without a segment in the path resolves the sole segment`() { + every { + siloQueryModelMock.getCoOccurrence( + CoOccurrenceRequest( + sequenceFilters = emptyMap(), + nucleotideMutations = emptyList(), + aminoAcidMutations = emptyList(), + nucleotideInsertions = emptyList(), + aminoAcidInsertions = emptyList(), + positions = listOf(CoOccurrencePosition.Single(1)), + ), + SEGMENT_NAME, + ) + } returns Stream.of(AggregationData(1, mapOf("$SEGMENT_NAME:1" to StringNode("A")))) + + mockMvc.perform(get("/component/nucleotideCoOccurrence?positions=1")) + .andExpect(status().isOk) + .andExpect(jsonPath("\$.data[0]['$SEGMENT_NAME:1']").value("A")) + .andExpect(jsonPath("\$.data[0].count").value(1)) + } + + @Test + fun `GET nucleotideCoOccurrence with a segment in the path returns not found`() { + mockMvc.perform(get("/component/nucleotideCoOccurrence/someSegment?positions=1")) + .andExpect(status().isNotFound) + } +} diff --git a/lapis/src/test/kotlin/org/genspectrum/lapis/model/SiloQueryModelTest.kt b/lapis/src/test/kotlin/org/genspectrum/lapis/model/SiloQueryModelTest.kt index 260752cf6..8f7a3e084 100644 --- a/lapis/src/test/kotlin/org/genspectrum/lapis/model/SiloQueryModelTest.kt +++ b/lapis/src/test/kotlin/org/genspectrum/lapis/model/SiloQueryModelTest.kt @@ -13,6 +13,8 @@ import org.genspectrum.lapis.controller.mutationProportionsRequest import org.genspectrum.lapis.controller.sequenceFiltersRequest import org.genspectrum.lapis.databaseConfig import org.genspectrum.lapis.request.CaseInsensitiveFieldsCleaner +import org.genspectrum.lapis.request.CoOccurrencePosition +import org.genspectrum.lapis.request.CoOccurrenceRequest import org.genspectrum.lapis.request.CommonSequenceFilters import org.genspectrum.lapis.request.Field import org.genspectrum.lapis.request.MutationsField @@ -39,6 +41,7 @@ import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import tools.jackson.databind.node.StringNode import java.util.stream.Stream private val someMutationData = MutationData( @@ -528,4 +531,80 @@ class SiloQueryModelTest { ) } } + + @Test + fun `getCoOccurrence calls SILO with a coOccurrence action and relabels response fields`() { + every { siloClientMock.sendQuery(any>()) } returns Stream.of( + AggregationData(48, mapOf("pos_1" to StringNode("A"), "pos_421" to StringNode("T"))), + ) + every { siloFilterExpressionMapperMock.map(any()) } returns True + + val result = underTest.getCoOccurrence( + CoOccurrenceRequest( + sequenceFilters = emptyMap(), + nucleotideMutations = emptyList(), + aminoAcidMutations = emptyList(), + nucleotideInsertions = emptyList(), + aminoAcidInsertions = emptyList(), + positions = listOf(CoOccurrencePosition.Single(1), CoOccurrencePosition.Single(421)), + ), + sequenceName = "segment1", + ).toList() + + verify { + siloClientMock.sendQuery( + SiloQuery(SiloAction.coOccurrence("segment1", listOf(1, 421)), True), + ) + } + + assertThat( + result, + equalTo( + listOf( + AggregationData( + 48, + mapOf("segment1:1" to StringNode("A"), "segment1:421" to StringNode("T")), + ), + ), + ), + ) + } + + @Test + fun `getCoOccurrence remaps orderBy fields from response field names to internal column names`() { + every { siloClientMock.sendQuery(any>()) } returns Stream.empty() + every { siloFilterExpressionMapperMock.map(any()) } returns True + + underTest.getCoOccurrence( + CoOccurrenceRequest( + sequenceFilters = emptyMap(), + nucleotideMutations = emptyList(), + aminoAcidMutations = emptyList(), + nucleotideInsertions = emptyList(), + aminoAcidInsertions = emptyList(), + positions = listOf(CoOccurrencePosition.Single(1)), + orderByFields = listOf( + OrderByField("segment1:1", Order.DESCENDING), + OrderByField("count", Order.ASCENDING), + ).toOrderBySpec(), + ), + sequenceName = "segment1", + ).toList() + + verify { + siloClientMock.sendQuery( + SiloQuery( + SiloAction.coOccurrence( + sequenceName = "segment1", + positions = listOf(1), + orderByFields = listOf( + OrderByField("pos_1", Order.DESCENDING), + OrderByField("count", Order.ASCENDING), + ).toOrderBySpec(), + ), + True, + ), + ) + } + } } diff --git a/lapis/src/test/kotlin/org/genspectrum/lapis/request/CoOccurrencePositionTest.kt b/lapis/src/test/kotlin/org/genspectrum/lapis/request/CoOccurrencePositionTest.kt new file mode 100644 index 000000000..f1f09014a --- /dev/null +++ b/lapis/src/test/kotlin/org/genspectrum/lapis/request/CoOccurrencePositionTest.kt @@ -0,0 +1,219 @@ +package org.genspectrum.lapis.request + +import org.genspectrum.lapis.controller.BadRequestException +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import tools.jackson.databind.ObjectMapper + +class CoOccurrencePositionTest { + @Test + fun `GIVEN a plain number string THEN parses to a Single position`() { + val result = parsePositionToken("5") + + assertThat(result, equalTo(CoOccurrencePosition.Single(5))) + } + + @Test + fun `GIVEN a range string THEN parses to a Range position`() { + val result = parsePositionToken("100-110") + + assertThat(result, equalTo(CoOccurrencePosition.Range(100, 110))) + } + + @Test + fun `GIVEN an invalid token THEN throws BadRequestException`() { + val exception = assertThrows { parsePositionToken("abc") } + + assertThat( + exception.message, + equalTo( + "Invalid entry 'abc' in positions: must be a number (e.g. '5') or an inclusive range (e.g. '100-110')", + ), + ) + } + + @Test + fun `GIVEN a range with digits that overflow Int THEN throws BadRequestException instead of crashing`() { + val exception = assertThrows { parsePositionToken("99999999999-1") } + + assertThat( + exception.message, + equalTo("Invalid range '99999999999-1' in positions: 'from' and 'to' must fit in a 32-bit integer"), + ) + } + + @ParameterizedTest + @MethodSource("getExpandTestCases") + fun `expandAndValidatePositions expands and sorts positions`( + input: List, + expected: List, + ) { + assertThat(input.expandAndValidatePositions(), equalTo(expected)) + } + + @Test + fun `GIVEN empty positions THEN throws BadRequestException`() { + val exception = + assertThrows { emptyList().expandAndValidatePositions() } + + assertThat(exception.message, equalTo("positions must not be empty")) + } + + @Test + fun `GIVEN a single position of 0 THEN throws BadRequestException`() { + val exception = assertThrows { + listOf(CoOccurrencePosition.Single(0)).expandAndValidatePositions() + } + + assertThat(exception.message, equalTo("Invalid position 0 in positions: must be >= 1")) + } + + @Test + fun `GIVEN a range with from greater than to THEN throws BadRequestException`() { + val exception = assertThrows { + listOf(CoOccurrencePosition.Range(10, 5)).expandAndValidatePositions() + } + + assertThat(exception.message, equalTo("Invalid range 10-5 in positions: 'from' must be <= 'to'")) + } + + @Test + fun `GIVEN a range with from less than 1 THEN throws BadRequestException`() { + val exception = assertThrows { + listOf(CoOccurrencePosition.Range(0, 5)).expandAndValidatePositions() + } + + assertThat(exception.message, equalTo("Invalid range 0-5 in positions: bounds must be >= 1")) + } + + @Test + fun `GIVEN a huge range THEN throws BadRequestException instead of exhausting memory`() { + val exception = assertThrows { + listOf(CoOccurrencePosition.Range(1, 2_000_000_000)).expandAndValidatePositions() + } + + assertThat( + exception.message, + equalTo( + "Invalid range 1-2000000000 in positions: spans 2000000000 positions, which exceeds " + + "the maximum of $MAX_CO_OCCURRENCE_POSITIONS positions per request", + ), + ) + } + + @Test + fun `GIVEN a range at exactly the maximum THEN does not throw`() { + val result = listOf( + CoOccurrencePosition.Range(1, MAX_CO_OCCURRENCE_POSITIONS), + ).expandAndValidatePositions() + + assertThat(result.size, equalTo(MAX_CO_OCCURRENCE_POSITIONS)) + } + + @Test + fun `GIVEN many single positions exceeding the maximum THEN throws BadRequestException`() { + val positions = (1..(MAX_CO_OCCURRENCE_POSITIONS + 1)).map { CoOccurrencePosition.Single(it) } + + val exception = assertThrows { positions.expandAndValidatePositions() } + + assertThat( + exception.message, + equalTo("positions must not expand to more than $MAX_CO_OCCURRENCE_POSITIONS distinct positions"), + ) + } + + companion object { + @JvmStatic + fun getExpandTestCases() = + listOf( + Arguments.of(listOf(CoOccurrencePosition.Single(5)), listOf(5)), + Arguments.of( + listOf(CoOccurrencePosition.Single(5), CoOccurrencePosition.Single(1)), + listOf(1, 5), + ), + Arguments.of( + listOf(CoOccurrencePosition.Range(100, 103)), + listOf(100, 101, 102, 103), + ), + Arguments.of( + listOf( + CoOccurrencePosition.Single(1), + CoOccurrencePosition.Range(1, 3), + CoOccurrencePosition.Single(2), + ), + listOf(1, 2, 3), + ), + ) + } +} + +@SpringBootTest +class CoOccurrencePositionDeserializerTest { + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Test + fun `GIVEN a JSON number THEN deserializes to Single`() { + val result = objectMapper.readValue("5", CoOccurrencePosition::class.java) + + assertThat(result, equalTo(CoOccurrencePosition.Single(5))) + } + + @Test + fun `GIVEN a JSON string number THEN deserializes to Single`() { + val result = objectMapper.readValue(""""5"""", CoOccurrencePosition::class.java) + + assertThat(result, equalTo(CoOccurrencePosition.Single(5))) + } + + @Test + fun `GIVEN a JSON string range THEN deserializes to Range`() { + val result = objectMapper.readValue(""""100-110"""", CoOccurrencePosition::class.java) + + assertThat(result, equalTo(CoOccurrencePosition.Range(100, 110))) + } + + @Test + fun `GIVEN a JSON object with from and to THEN deserializes to Range`() { + val result = objectMapper.readValue("""{"from": 100, "to": 110}""", CoOccurrencePosition::class.java) + + assertThat(result, equalTo(CoOccurrencePosition.Range(100, 110))) + } + + @Test + fun `GIVEN a JSON object with a from that overflows Int THEN throws BadRequestException`() { + val exception = assertThrows { + objectMapper.readValue("""{"from": 99999999999, "to": 110}""", CoOccurrencePosition::class.java) + } + + assertThat( + exception.message, + equalTo( + "Each object entry in positions must have integer 'from' and 'to' properties " + + "that fit in a 32-bit integer, but was {\"from\":99999999999,\"to\":110}", + ), + ) + } + + @Test + fun `GIVEN a boolean THEN throws BadRequestException`() { + val exception = assertThrows { + objectMapper.readValue("true", CoOccurrencePosition::class.java) + } + + assertThat( + exception.message, + equalTo( + "Each entry in positions must be a number, a string, or an object with " + + "'from'/'to', but was true (BOOLEAN)", + ), + ) + } +} diff --git a/lapis/src/test/kotlin/org/genspectrum/lapis/request/CoOccurrenceRequestTest.kt b/lapis/src/test/kotlin/org/genspectrum/lapis/request/CoOccurrenceRequestTest.kt new file mode 100644 index 000000000..f1e6e5290 --- /dev/null +++ b/lapis/src/test/kotlin/org/genspectrum/lapis/request/CoOccurrenceRequestTest.kt @@ -0,0 +1,101 @@ +package org.genspectrum.lapis.request + +import org.genspectrum.lapis.controller.BadRequestException +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import tools.jackson.databind.ObjectMapper +import tools.jackson.module.kotlin.readValue + +@SpringBootTest +class CoOccurrenceRequestTest { + @Autowired + private lateinit var objectMapper: ObjectMapper + + @ParameterizedTest + @MethodSource("getTestCoOccurrenceRequests") + fun `CoOccurrenceRequest is correctly deserialized from JSON`( + input: String, + expected: CoOccurrenceRequest, + ) { + val result = objectMapper.readValue(input) + + assertThat(result, equalTo(expected)) + } + + @ParameterizedTest + @MethodSource("getInvalidRequests") + fun `Given invalid CoOccurrenceRequest then should throw an error`( + input: String, + expectedErrorMessage: String, + ) { + val exception = assertThrows(BadRequestException::class.java) { + objectMapper.readValue(input) + } + + assertThat(exception.message, equalTo(expectedErrorMessage)) + } + + companion object { + @JvmStatic + fun getTestCoOccurrenceRequests() = + listOf( + Arguments.of( + """ + { + "country": "Switzerland", + "positions": [1, 2, {"from": 100, "to": 102}] + } + """, + CoOccurrenceRequest( + sequenceFilters = mapOf("country" to listOf("Switzerland")), + nucleotideMutations = emptyList(), + aminoAcidMutations = emptyList(), + nucleotideInsertions = emptyList(), + aminoAcidInsertions = emptyList(), + positions = listOf( + CoOccurrencePosition.Single(1), + CoOccurrencePosition.Single(2), + CoOccurrencePosition.Range(100, 102), + ), + ), + ), + Arguments.of( + """ + { + "positions": ["5", "100-110"] + } + """, + CoOccurrenceRequest( + sequenceFilters = emptyMap(), + nucleotideMutations = emptyList(), + aminoAcidMutations = emptyList(), + nucleotideInsertions = emptyList(), + aminoAcidInsertions = emptyList(), + positions = listOf( + CoOccurrencePosition.Single(5), + CoOccurrencePosition.Range(100, 110), + ), + ), + ), + ) + + @JvmStatic + fun getInvalidRequests() = + listOf( + Arguments.of( + """{"positions": "not an array"}""", + "positions must be an array, but was \"not an array\" (STRING)", + ), + Arguments.of( + "{}", + "positions is required", + ), + ) + } +} diff --git a/lapis/src/test/kotlin/org/genspectrum/lapis/silo/SiloQuerySaneQlSerializerTest.kt b/lapis/src/test/kotlin/org/genspectrum/lapis/silo/SiloQuerySaneQlSerializerTest.kt index 64ff9fae3..a68d0cfa9 100644 --- a/lapis/src/test/kotlin/org/genspectrum/lapis/silo/SiloQuerySaneQlSerializerTest.kt +++ b/lapis/src/test/kotlin/org/genspectrum/lapis/silo/SiloQuerySaneQlSerializerTest.kt @@ -270,6 +270,30 @@ class SiloQuerySaneQlSerializerTest { ), """.project({"field1", "field2", "someSequenceName"}).orderBy({"field3", "field4".desc()}).offset(50).limit(100)""", ), + // CoOccurrence + Arguments.of( + SiloAction.coOccurrence("segment1", listOf(1)), + """.map({"pos_1":="segment1".at(1)}).groupBy({count:=count()}, {"pos_1"})""", + ), + Arguments.of( + SiloAction.coOccurrence("segment1", listOf(1, 421)), + """.map({"pos_1":="segment1".at(1), "pos_421":="segment1".at(421)})""" + + """.groupBy({count:=count()}, {"pos_1", "pos_421"})""", + ), + Arguments.of( + SiloAction.coOccurrence( + sequenceName = "segment1", + positions = listOf(1, 2), + orderByFields = listOf( + OrderByField("count", Order.DESCENDING), + ).toOrderBySpec(), + limit = 100, + offset = 50, + ), + """.map({"pos_1":="segment1".at(1), "pos_2":="segment1".at(2)})""" + + """.groupBy({count:=count()}, {"pos_1", "pos_2"})""" + + """.orderBy({"count".desc()}).offset(50).limit(100)""", + ), // MostRecentCommonAncestor Arguments.of( SiloAction.mostRecentCommonAncestor("phyloTreeField"), From ccb73b65e3c346a7cd9a6a8033948d63cb576f1e Mon Sep 17 00:00:00 2001 From: Fabian Engelniederhammer Date: Mon, 6 Jul 2026 09:54:08 +0200 Subject: [PATCH 2/9] 1742 make positions ints --- .../AminoAcidCoOccurrenceController.kt | 6 ++-- .../CoOccurrenceControllerSupport.kt | 6 ++-- .../MultiSegmentedCoOccurrenceController.kt | 6 ++-- .../SingleSegmentedCoOccurrenceController.kt | 6 ++-- .../lapis/request/CoOccurrencePosition.kt | 24 +++------------ .../controller/CoOccurrenceControllerTest.kt | 20 ++++++++++--- .../lapis/request/CoOccurrencePositionTest.kt | 30 +++++++++++-------- .../lapis/request/CoOccurrenceRequestTest.kt | 8 +++-- 8 files changed, 56 insertions(+), 50 deletions(-) diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/AminoAcidCoOccurrenceController.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/AminoAcidCoOccurrenceController.kt index cb7477683..2556287ae 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/AminoAcidCoOccurrenceController.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/AminoAcidCoOccurrenceController.kt @@ -57,7 +57,7 @@ class AminoAcidCoOccurrenceController( @RequestParam sequenceFilters: GetRequestSequenceFilters?, @RequestParam - positions: List, + positions: List, @RequestParam orderBy: List?, @NucleotideMutations @@ -119,7 +119,7 @@ class AminoAcidCoOccurrenceController( @RequestParam sequenceFilters: GetRequestSequenceFilters?, @RequestParam - positions: List, + positions: List, @RequestParam orderBy: List?, @NucleotideMutations @@ -182,7 +182,7 @@ class AminoAcidCoOccurrenceController( @RequestParam sequenceFilters: GetRequestSequenceFilters?, @RequestParam - positions: List, + positions: List, @RequestParam orderBy: List?, @NucleotideMutations diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/CoOccurrenceControllerSupport.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/CoOccurrenceControllerSupport.kt index 00e0da6ff..ef797879b 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/CoOccurrenceControllerSupport.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/CoOccurrenceControllerSupport.kt @@ -3,6 +3,7 @@ package org.genspectrum.lapis.controller import org.genspectrum.lapis.model.SiloQueryModel import org.genspectrum.lapis.request.AminoAcidInsertion import org.genspectrum.lapis.request.AminoAcidMutation +import org.genspectrum.lapis.request.CoOccurrencePosition import org.genspectrum.lapis.request.CoOccurrenceRequest import org.genspectrum.lapis.request.GetRequestSequenceFilters import org.genspectrum.lapis.request.NucleotideInsertion @@ -10,7 +11,6 @@ import org.genspectrum.lapis.request.NucleotideMutation import org.genspectrum.lapis.request.OrderByField import org.genspectrum.lapis.request.SPECIAL_REQUEST_PROPERTIES import org.genspectrum.lapis.request.expandAndValidatePositions -import org.genspectrum.lapis.request.parsePositionToken import org.genspectrum.lapis.request.toOrderBySpec import org.genspectrum.lapis.response.AggregatedCollection import org.genspectrum.lapis.silo.coOccurrenceResponseFieldName @@ -20,7 +20,7 @@ import org.genspectrum.lapis.silo.coOccurrenceResponseFieldName */ fun buildCoOccurrenceRequest( sequenceFilters: GetRequestSequenceFilters?, - positions: List, + positions: List, nucleotideMutations: List?, aminoAcidMutations: List?, nucleotideInsertions: List?, @@ -39,7 +39,7 @@ fun buildCoOccurrenceRequest( aminoAcidMutations ?: emptyList(), nucleotideInsertions ?: emptyList(), aminoAcidInsertions ?: emptyList(), - positions.map { parsePositionToken(it) }, + positions.map { CoOccurrencePosition.Single(it) }, orderBy.toOrderBySpec(), limit, offset, diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/MultiSegmentedCoOccurrenceController.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/MultiSegmentedCoOccurrenceController.kt index 623861122..ac06d340c 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/MultiSegmentedCoOccurrenceController.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/MultiSegmentedCoOccurrenceController.kt @@ -59,7 +59,7 @@ class MultiSegmentedCoOccurrenceController( @RequestParam sequenceFilters: GetRequestSequenceFilters?, @RequestParam - positions: List, + positions: List, @RequestParam orderBy: List?, @NucleotideMutations @@ -121,7 +121,7 @@ class MultiSegmentedCoOccurrenceController( @RequestParam sequenceFilters: GetRequestSequenceFilters?, @RequestParam - positions: List, + positions: List, @RequestParam orderBy: List?, @NucleotideMutations @@ -184,7 +184,7 @@ class MultiSegmentedCoOccurrenceController( @RequestParam sequenceFilters: GetRequestSequenceFilters?, @RequestParam - positions: List, + positions: List, @RequestParam orderBy: List?, @NucleotideMutations diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/SingleSegmentedCoOccurrenceController.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/SingleSegmentedCoOccurrenceController.kt index 1a6194d89..18c5ec542 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/SingleSegmentedCoOccurrenceController.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/SingleSegmentedCoOccurrenceController.kt @@ -56,7 +56,7 @@ class SingleSegmentedCoOccurrenceController( @RequestParam sequenceFilters: GetRequestSequenceFilters?, @RequestParam - positions: List, + positions: List, @RequestParam orderBy: List?, @NucleotideMutations @@ -115,7 +115,7 @@ class SingleSegmentedCoOccurrenceController( @RequestParam sequenceFilters: GetRequestSequenceFilters?, @RequestParam - positions: List, + positions: List, @RequestParam orderBy: List?, @NucleotideMutations @@ -175,7 +175,7 @@ class SingleSegmentedCoOccurrenceController( @RequestParam sequenceFilters: GetRequestSequenceFilters?, @RequestParam - positions: List, + positions: List, @RequestParam orderBy: List?, @NucleotideMutations diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/request/CoOccurrencePosition.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/request/CoOccurrencePosition.kt index e9c5f6624..445a98eed 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/request/CoOccurrencePosition.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/request/CoOccurrencePosition.kt @@ -24,35 +24,19 @@ sealed class CoOccurrencePosition { /** * Parses a single token from a GET request (or a form-urlencoded POST body) into a [CoOccurrencePosition]. - * A token is either a plain positive integer (e.g. "5") or an inclusive range (e.g. "100-110"). + * A token must be a plain positive integer (e.g. "5"). Ranges are only accepted as JSON objects + * (e.g. `{"from": 100, "to": 110}`), not as strings. */ fun parsePositionToken(token: String): CoOccurrencePosition { - // TODO: accept number or object, no "range strings" val trimmed = token.trim() - val rangeMatch = POSITION_RANGE_REGEX.matchEntire(trimmed) - if (rangeMatch != null) { - val (fromString, toString) = rangeMatch.destructured - val from = fromString.toIntOrNull() - val to = toString.toIntOrNull() - if (from == null || to == null) { - throw BadRequestException( - "Invalid range '$token' in $POSITIONS_PROPERTY: 'from' and 'to' must fit in a 32-bit integer", - ) - } - return CoOccurrencePosition.Range(from, to) - } - val position = trimmed.toIntOrNull() ?: throw BadRequestException( - "Invalid entry '$token' in $POSITIONS_PROPERTY: must be a number (e.g. '5') " + - "or an inclusive range (e.g. '100-110')", + "Invalid entry '$token' in $POSITIONS_PROPERTY: must be a number (e.g. '5')", ) return CoOccurrencePosition.Single(position) } -private val POSITION_RANGE_REGEX = Regex("""^(\d+)-(\d+)$""") - /** * Safety limit on the number of distinct positions a single co-occurrence request may expand to. * This protects against out-of-memory/denial-of-service from a single huge range (e.g. `1-2000000000`) @@ -131,7 +115,7 @@ fun List.expandAndValidatePositions(): List { * An entry can be: * - a JSON number, e.g. `5` * - a JSON object with `from`/`to`, e.g. `{"from": 100, "to": 110}` - * - a JSON string (used for form-urlencoded requests), e.g. `"5"` or `"100-110"` + * - a JSON string with a plain number (used for form-urlencoded requests), e.g. `"5"` */ @JacksonComponent class CoOccurrencePositionDeserializer : ValueDeserializer() { diff --git a/lapis/src/test/kotlin/org/genspectrum/lapis/controller/CoOccurrenceControllerTest.kt b/lapis/src/test/kotlin/org/genspectrum/lapis/controller/CoOccurrenceControllerTest.kt index fb1722d4d..3f9ae194b 100644 --- a/lapis/src/test/kotlin/org/genspectrum/lapis/controller/CoOccurrenceControllerTest.kt +++ b/lapis/src/test/kotlin/org/genspectrum/lapis/controller/CoOccurrenceControllerTest.kt @@ -74,7 +74,7 @@ class CoOccurrenceControllerTest( } @Test - fun `GET nucleotideCoOccurrence with a range in positions expands it`() { + fun `GET nucleotideCoOccurrence with multiple discrete positions`() { every { siloQueryModelMock.getCoOccurrence( CoOccurrenceRequest( @@ -83,21 +83,33 @@ class CoOccurrenceControllerTest( aminoAcidMutations = emptyList(), nucleotideInsertions = emptyList(), aminoAcidInsertions = emptyList(), - positions = listOf(CoOccurrencePosition.Single(1), CoOccurrencePosition.Range(100, 102)), + positions = listOf(CoOccurrencePosition.Single(1), CoOccurrencePosition.Single(100)), ), "main", ) } returns Stream.empty() - mockMvc.perform(get("/component/nucleotideCoOccurrence/main?positions=1,100-102")) + mockMvc.perform(get("/component/nucleotideCoOccurrence/main?positions=1,100")) .andExpect(status().isOk) } + @Test + fun `GET nucleotideCoOccurrence with a range string in positions returns bad request`() { + // 'positions' is now List, so Spring's own type conversion rejects non-integer values before + // reaching our code - see the comment on the "without positions" test below for the resulting shape. + mockMvc.perform(get("/component/nucleotideCoOccurrence/main?positions=1,100-102")) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("\$.detail", containsString("positions"))) + } + @Test fun `GET nucleotideCoOccurrence without positions returns bad request`() { + // 'positions' is a required @RequestParam, so this is rejected by Spring itself before reaching our + // code, which is why the error body has Spring's default ProblemDetail shape instead of LAPIS's usual + // wrapped {error, info} shape. mockMvc.perform(get("/component/nucleotideCoOccurrence/main")) .andExpect(status().isBadRequest) - .andExpect(jsonPath("\$.error.detail", containsString("positions"))) + .andExpect(jsonPath("\$.detail", containsString("positions"))) } @Test diff --git a/lapis/src/test/kotlin/org/genspectrum/lapis/request/CoOccurrencePositionTest.kt b/lapis/src/test/kotlin/org/genspectrum/lapis/request/CoOccurrencePositionTest.kt index f1f09014a..9f6b90429 100644 --- a/lapis/src/test/kotlin/org/genspectrum/lapis/request/CoOccurrencePositionTest.kt +++ b/lapis/src/test/kotlin/org/genspectrum/lapis/request/CoOccurrencePositionTest.kt @@ -21,10 +21,13 @@ class CoOccurrencePositionTest { } @Test - fun `GIVEN a range string THEN parses to a Range position`() { - val result = parsePositionToken("100-110") + fun `GIVEN a range string THEN throws BadRequestException`() { + val exception = assertThrows { parsePositionToken("100-110") } - assertThat(result, equalTo(CoOccurrencePosition.Range(100, 110))) + assertThat( + exception.message, + equalTo("Invalid entry '100-110' in positions: must be a number (e.g. '5')"), + ) } @Test @@ -33,19 +36,17 @@ class CoOccurrencePositionTest { assertThat( exception.message, - equalTo( - "Invalid entry 'abc' in positions: must be a number (e.g. '5') or an inclusive range (e.g. '100-110')", - ), + equalTo("Invalid entry 'abc' in positions: must be a number (e.g. '5')"), ) } @Test - fun `GIVEN a range with digits that overflow Int THEN throws BadRequestException instead of crashing`() { - val exception = assertThrows { parsePositionToken("99999999999-1") } + fun `GIVEN a token that overflows Int THEN throws BadRequestException instead of crashing`() { + val exception = assertThrows { parsePositionToken("99999999999") } assertThat( exception.message, - equalTo("Invalid range '99999999999-1' in positions: 'from' and 'to' must fit in a 32-bit integer"), + equalTo("Invalid entry '99999999999' in positions: must be a number (e.g. '5')"), ) } @@ -174,10 +175,15 @@ class CoOccurrencePositionDeserializerTest { } @Test - fun `GIVEN a JSON string range THEN deserializes to Range`() { - val result = objectMapper.readValue(""""100-110"""", CoOccurrencePosition::class.java) + fun `GIVEN a JSON string range THEN throws BadRequestException`() { + val exception = assertThrows { + objectMapper.readValue(""""100-110"""", CoOccurrencePosition::class.java) + } - assertThat(result, equalTo(CoOccurrencePosition.Range(100, 110))) + assertThat( + exception.message, + equalTo("Invalid entry '100-110' in positions: must be a number (e.g. '5')"), + ) } @Test diff --git a/lapis/src/test/kotlin/org/genspectrum/lapis/request/CoOccurrenceRequestTest.kt b/lapis/src/test/kotlin/org/genspectrum/lapis/request/CoOccurrenceRequestTest.kt index f1e6e5290..1d4ce9bdc 100644 --- a/lapis/src/test/kotlin/org/genspectrum/lapis/request/CoOccurrenceRequestTest.kt +++ b/lapis/src/test/kotlin/org/genspectrum/lapis/request/CoOccurrenceRequestTest.kt @@ -68,7 +68,7 @@ class CoOccurrenceRequestTest { Arguments.of( """ { - "positions": ["5", "100-110"] + "positions": ["5", 7] } """, CoOccurrenceRequest( @@ -79,7 +79,7 @@ class CoOccurrenceRequestTest { aminoAcidInsertions = emptyList(), positions = listOf( CoOccurrencePosition.Single(5), - CoOccurrencePosition.Range(100, 110), + CoOccurrencePosition.Single(7), ), ), ), @@ -96,6 +96,10 @@ class CoOccurrenceRequestTest { "{}", "positions is required", ), + Arguments.of( + """{"positions": ["100-110"]}""", + "Invalid entry '100-110' in positions: must be a number (e.g. '5')", + ), ) } } From f5da7635db8dc1a4fa92dc8259976436d2a74216 Mon Sep 17 00:00:00 2001 From: Fabian Engelniederhammer Date: Tue, 7 Jul 2026 11:49:14 +0200 Subject: [PATCH 3/9] 1742 llms.txt --- lapis/src/main/resources/templates/llms.txt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/lapis/src/main/resources/templates/llms.txt b/lapis/src/main/resources/templates/llms.txt index 149ced3cb..8255dff16 100644 --- a/lapis/src/main/resources/templates/llms.txt +++ b/lapis/src/main/resources/templates/llms.txt @@ -140,6 +140,23 @@ Endpoints: - [sample/aminoAcidInsertions](sample/aminoAcidInsertions): List amino acid insertions. Shows how often which insertion of amino acids occurred in the amino acid sequences for the given filters. +### Co-occurrence Analysis + +These endpoints return, for a given sequence and a list of positions, every combination of symbols that occurs +at those positions among the sequences matching your filters, along with the count of sequences for each +combination. Useful for checking whether mutations at different positions occur together or independently. +These endpoints are available as GET or POST. + +Both require a `positions` parameter: an array where each entry is either a number (a single 1-based position) +or an object `{"from": , "to": }` (an inclusive range of positions). + +[# th:if="${isSingleSegmented}"]- [component/nucleotideCoOccurrence](component/nucleotideCoOccurrence): + Returns symbol co-occurrence counts at the given nucleotide positions.[/] +[# th:unless="${isSingleSegmented}"]- [component/nucleotideCoOccurrence/{segment}](component/nucleotideCoOccurrence/{segment}): + Returns symbol co-occurrence counts at the given nucleotide positions for the given segment.[/] +- [component/aminoAcidCoOccurrence/{gene}](component/aminoAcidCoOccurrence/{gene}): + Returns symbol co-occurrence counts at the given amino acid positions for the given gene. + ### Time Series These endpoints are mainly built for specialized display components that show time series data in a tabular form. From 8b1c6f7b3de6d7fe1482a57b12ede435219d3df5 Mon Sep 17 00:00:00 2001 From: Fabian Engelniederhammer Date: Wed, 8 Jul 2026 09:12:17 +0200 Subject: [PATCH 4/9] 1742 fix openapi docs --- .../AminoAcidCoOccurrenceController.kt | 17 +++- .../controller/ControllerDescriptions.kt | 13 +++ .../MultiSegmentedCoOccurrenceController.kt | 17 +++- .../SingleSegmentedCoOccurrenceController.kt | 17 +++- .../genspectrum/lapis/openApi/OpenApiDocs.kt | 86 +++++++++++++++++++ .../org/genspectrum/lapis/openApi/Schemas.kt | 39 +++++++++ 6 files changed, 180 insertions(+), 9 deletions(-) diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/AminoAcidCoOccurrenceController.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/AminoAcidCoOccurrenceController.kt index 2556287ae..c2bf8cdcb 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/AminoAcidCoOccurrenceController.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/AminoAcidCoOccurrenceController.kt @@ -2,14 +2,19 @@ package org.genspectrum.lapis.controller import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Schema import jakarta.servlet.http.HttpServletResponse import org.genspectrum.lapis.controller.LapisMediaType.TEXT_CSV_VALUE import org.genspectrum.lapis.controller.LapisMediaType.TEXT_TSV_VALUE import org.genspectrum.lapis.model.SiloQueryModel import org.genspectrum.lapis.openApi.AminoAcidInsertions import org.genspectrum.lapis.openApi.AminoAcidMutations +import org.genspectrum.lapis.openApi.CO_OCCURRENCE_REQUEST_SCHEMA +import org.genspectrum.lapis.openApi.CoOccurrenceOrderByFields +import org.genspectrum.lapis.openApi.CoOccurrencePositionsParam import org.genspectrum.lapis.openApi.DataFormat import org.genspectrum.lapis.openApi.Gene +import org.genspectrum.lapis.openApi.LapisCoOccurrenceResponse import org.genspectrum.lapis.openApi.Limit import org.genspectrum.lapis.openApi.NucleotideInsertions import org.genspectrum.lapis.openApi.NucleotideMutations @@ -48,7 +53,7 @@ class AminoAcidCoOccurrenceController( "$AMINO_ACID_CO_OCCURRENCE_ROUTE/{gene}", produces = [MediaType.APPLICATION_JSON_VALUE], ) - @Operation(description = AMINO_ACID_CO_OCCURRENCE_ENDPOINT_DESCRIPTION) + @LapisCoOccurrenceResponse(description = AMINO_ACID_CO_OCCURRENCE_ENDPOINT_DESCRIPTION) fun getAminoAcidCoOccurrenceAsJson( @PathVariable(name = "gene", required = true) @Gene @@ -56,8 +61,10 @@ class AminoAcidCoOccurrenceController( @PrimitiveFieldFilters @RequestParam sequenceFilters: GetRequestSequenceFilters?, + @CoOccurrencePositionsParam @RequestParam positions: List, + @CoOccurrenceOrderByFields @RequestParam orderBy: List?, @NucleotideMutations @@ -118,8 +125,10 @@ class AminoAcidCoOccurrenceController( @PrimitiveFieldFilters @RequestParam sequenceFilters: GetRequestSequenceFilters?, + @CoOccurrencePositionsParam @RequestParam positions: List, + @CoOccurrenceOrderByFields @RequestParam orderBy: List?, @NucleotideMutations @@ -181,8 +190,10 @@ class AminoAcidCoOccurrenceController( @PrimitiveFieldFilters @RequestParam sequenceFilters: GetRequestSequenceFilters?, + @CoOccurrencePositionsParam @RequestParam positions: List, + @CoOccurrenceOrderByFields @RequestParam orderBy: List?, @NucleotideMutations @@ -234,15 +245,15 @@ class AminoAcidCoOccurrenceController( produces = [MediaType.APPLICATION_JSON_VALUE], consumes = [MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_FORM_URLENCODED_VALUE], ) + @LapisCoOccurrenceResponse(description = AMINO_ACID_CO_OCCURRENCE_ENDPOINT_DESCRIPTION) @Operation( - description = AMINO_ACID_CO_OCCURRENCE_ENDPOINT_DESCRIPTION, operationId = "postAminoAcidCoOccurrence", ) fun postAminoAcidCoOccurrence( @PathVariable(name = "gene", required = true) @Gene gene: String, - @Parameter(description = "The sequence filters, positions, and other options for the co-occurrence query.") + @Parameter(schema = Schema(ref = "#/components/schemas/$CO_OCCURRENCE_REQUEST_SCHEMA")) @RequestBody request: CoOccurrenceRequest, response: HttpServletResponse, diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/ControllerDescriptions.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/ControllerDescriptions.kt index a22fcca11..a3fa4144f 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/ControllerDescriptions.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/ControllerDescriptions.kt @@ -1,6 +1,8 @@ package org.genspectrum.lapis.controller import org.genspectrum.lapis.controller.LapisMediaType.TEXT_CSV_WITHOUT_HEADERS_VALUE +import org.genspectrum.lapis.request.MAX_CO_OCCURRENCE_POSITIONS +import org.genspectrum.lapis.silo.ORDER_BY_RANDOM_FIELD_NAME const val DETAILS_ENDPOINT_DESCRIPTION = """Returns the specified metadata fields of sequences matching the filter.""" const val MOST_RECENT_COMMON_ANCESTOR_ENDPOINT_DESCRIPTION = @@ -64,6 +66,17 @@ const val AMINO_ACID_CO_OCCURRENCE_ENDPOINT_DESCRIPTION = """Returns, for the given gene and list of (1-based) positions, every combination of symbols that occurs at these positions among the sequences matching the specified sequence filters, along with the number of sequences for each combination.""" +const val CO_OCCURRENCE_POSITIONS_DESCRIPTION = + """The 1-based positions to compute co-occurrences for. Must not be empty, and must not expand to more than + $MAX_CO_OCCURRENCE_POSITIONS distinct positions. Only single positions are supported here; use the POST + endpoint with a JSON body to request ranges of positions.""" +const val CO_OCCURRENCE_POSITIONS_REQUEST_BODY_DESCRIPTION = + """The 1-based positions to compute co-occurrences for. Each entry is either a single position (a number) + or an inclusive range of positions (an object with 'from' and 'to'). Must not be empty, and must not expand + to more than $MAX_CO_OCCURRENCE_POSITIONS distinct positions.""" +const val CO_OCCURRENCE_ORDER_BY_FIELDS_DESCRIPTION = + """The fields by which the result is ordered. Valid values are 'count', '$ORDER_BY_RANDOM_FIELD_NAME', or one + of the requested positions in the format ':' (as it appears in the response).""" const val QUERIES_OVER_TIME_ENDPOINT_DESCRIPTION = """ Returns the number of sequences matching the specified queries within the requested date ranges, along with the corresponding coverage in a tabular format. The order of the queries and date ranges is preserved. diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/MultiSegmentedCoOccurrenceController.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/MultiSegmentedCoOccurrenceController.kt index ac06d340c..5518654f3 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/MultiSegmentedCoOccurrenceController.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/MultiSegmentedCoOccurrenceController.kt @@ -2,13 +2,18 @@ package org.genspectrum.lapis.controller import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Schema import jakarta.servlet.http.HttpServletResponse import org.genspectrum.lapis.controller.LapisMediaType.TEXT_CSV_VALUE import org.genspectrum.lapis.controller.LapisMediaType.TEXT_TSV_VALUE import org.genspectrum.lapis.model.SiloQueryModel import org.genspectrum.lapis.openApi.AminoAcidInsertions import org.genspectrum.lapis.openApi.AminoAcidMutations +import org.genspectrum.lapis.openApi.CO_OCCURRENCE_REQUEST_SCHEMA +import org.genspectrum.lapis.openApi.CoOccurrenceOrderByFields +import org.genspectrum.lapis.openApi.CoOccurrencePositionsParam import org.genspectrum.lapis.openApi.DataFormat +import org.genspectrum.lapis.openApi.LapisCoOccurrenceResponse import org.genspectrum.lapis.openApi.Limit import org.genspectrum.lapis.openApi.NucleotideInsertions import org.genspectrum.lapis.openApi.NucleotideMutations @@ -50,7 +55,7 @@ class MultiSegmentedCoOccurrenceController( "$NUCLEOTIDE_CO_OCCURRENCE_ROUTE/{segment}", produces = [MediaType.APPLICATION_JSON_VALUE], ) - @Operation(description = NUCLEOTIDE_CO_OCCURRENCE_ENDPOINT_DESCRIPTION) + @LapisCoOccurrenceResponse(description = NUCLEOTIDE_CO_OCCURRENCE_ENDPOINT_DESCRIPTION) fun getNucleotideCoOccurrenceAsJson( @PathVariable(name = "segment", required = true) @Segment @@ -58,8 +63,10 @@ class MultiSegmentedCoOccurrenceController( @PrimitiveFieldFilters @RequestParam sequenceFilters: GetRequestSequenceFilters?, + @CoOccurrencePositionsParam @RequestParam positions: List, + @CoOccurrenceOrderByFields @RequestParam orderBy: List?, @NucleotideMutations @@ -120,8 +127,10 @@ class MultiSegmentedCoOccurrenceController( @PrimitiveFieldFilters @RequestParam sequenceFilters: GetRequestSequenceFilters?, + @CoOccurrencePositionsParam @RequestParam positions: List, + @CoOccurrenceOrderByFields @RequestParam orderBy: List?, @NucleotideMutations @@ -183,8 +192,10 @@ class MultiSegmentedCoOccurrenceController( @PrimitiveFieldFilters @RequestParam sequenceFilters: GetRequestSequenceFilters?, + @CoOccurrencePositionsParam @RequestParam positions: List, + @CoOccurrenceOrderByFields @RequestParam orderBy: List?, @NucleotideMutations @@ -236,15 +247,15 @@ class MultiSegmentedCoOccurrenceController( produces = [MediaType.APPLICATION_JSON_VALUE], consumes = [MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_FORM_URLENCODED_VALUE], ) + @LapisCoOccurrenceResponse(description = NUCLEOTIDE_CO_OCCURRENCE_ENDPOINT_DESCRIPTION) @Operation( - description = NUCLEOTIDE_CO_OCCURRENCE_ENDPOINT_DESCRIPTION, operationId = "postNucleotideCoOccurrence", ) fun postNucleotideCoOccurrence( @PathVariable(name = "segment", required = true) @Segment segment: String, - @Parameter(description = "The sequence filters, positions, and other options for the co-occurrence query.") + @Parameter(schema = Schema(ref = "#/components/schemas/$CO_OCCURRENCE_REQUEST_SCHEMA")) @RequestBody request: CoOccurrenceRequest, response: HttpServletResponse, diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/SingleSegmentedCoOccurrenceController.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/SingleSegmentedCoOccurrenceController.kt index 18c5ec542..07fbe3fa9 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/SingleSegmentedCoOccurrenceController.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/SingleSegmentedCoOccurrenceController.kt @@ -2,6 +2,7 @@ package org.genspectrum.lapis.controller import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Schema import jakarta.servlet.http.HttpServletResponse import org.genspectrum.lapis.config.ReferenceGenomeSchema import org.genspectrum.lapis.controller.LapisMediaType.TEXT_CSV_VALUE @@ -9,7 +10,11 @@ import org.genspectrum.lapis.controller.LapisMediaType.TEXT_TSV_VALUE import org.genspectrum.lapis.model.SiloQueryModel import org.genspectrum.lapis.openApi.AminoAcidInsertions import org.genspectrum.lapis.openApi.AminoAcidMutations +import org.genspectrum.lapis.openApi.CO_OCCURRENCE_REQUEST_SCHEMA +import org.genspectrum.lapis.openApi.CoOccurrenceOrderByFields +import org.genspectrum.lapis.openApi.CoOccurrencePositionsParam import org.genspectrum.lapis.openApi.DataFormat +import org.genspectrum.lapis.openApi.LapisCoOccurrenceResponse import org.genspectrum.lapis.openApi.Limit import org.genspectrum.lapis.openApi.NucleotideInsertions import org.genspectrum.lapis.openApi.NucleotideMutations @@ -50,13 +55,15 @@ class SingleSegmentedCoOccurrenceController( NUCLEOTIDE_CO_OCCURRENCE_ROUTE, produces = [MediaType.APPLICATION_JSON_VALUE], ) - @Operation(description = NUCLEOTIDE_CO_OCCURRENCE_ENDPOINT_DESCRIPTION) + @LapisCoOccurrenceResponse(description = NUCLEOTIDE_CO_OCCURRENCE_ENDPOINT_DESCRIPTION) fun getNucleotideCoOccurrenceAsJson( @PrimitiveFieldFilters @RequestParam sequenceFilters: GetRequestSequenceFilters?, + @CoOccurrencePositionsParam @RequestParam positions: List, + @CoOccurrenceOrderByFields @RequestParam orderBy: List?, @NucleotideMutations @@ -114,8 +121,10 @@ class SingleSegmentedCoOccurrenceController( @PrimitiveFieldFilters @RequestParam sequenceFilters: GetRequestSequenceFilters?, + @CoOccurrencePositionsParam @RequestParam positions: List, + @CoOccurrenceOrderByFields @RequestParam orderBy: List?, @NucleotideMutations @@ -174,8 +183,10 @@ class SingleSegmentedCoOccurrenceController( @PrimitiveFieldFilters @RequestParam sequenceFilters: GetRequestSequenceFilters?, + @CoOccurrencePositionsParam @RequestParam positions: List, + @CoOccurrenceOrderByFields @RequestParam orderBy: List?, @NucleotideMutations @@ -227,12 +238,12 @@ class SingleSegmentedCoOccurrenceController( produces = [MediaType.APPLICATION_JSON_VALUE], consumes = [MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_FORM_URLENCODED_VALUE], ) + @LapisCoOccurrenceResponse(description = NUCLEOTIDE_CO_OCCURRENCE_ENDPOINT_DESCRIPTION) @Operation( - description = NUCLEOTIDE_CO_OCCURRENCE_ENDPOINT_DESCRIPTION, operationId = "postSingleSegmentNucleotideCoOccurrence", ) fun postNucleotideCoOccurrence( - @Parameter(description = "The sequence filters, positions, and other options for the co-occurrence query.") + @Parameter(schema = Schema(ref = "#/components/schemas/$CO_OCCURRENCE_REQUEST_SCHEMA")) @RequestBody request: CoOccurrenceRequest, response: HttpServletResponse, diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/openApi/OpenApiDocs.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/openApi/OpenApiDocs.kt index 62337d24e..b6e5ad9a0 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/openApi/OpenApiDocs.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/openApi/OpenApiDocs.kt @@ -22,6 +22,9 @@ import org.genspectrum.lapis.config.SequenceFilterFields import org.genspectrum.lapis.controller.AGGREGATED_GROUP_BY_FIELDS_DESCRIPTION import org.genspectrum.lapis.controller.AMINO_ACID_FASTA_HEADER_TEMPLATE_DESCRIPTION import org.genspectrum.lapis.controller.AMINO_ACID_MUTATION_DESCRIPTION +import org.genspectrum.lapis.controller.CO_OCCURRENCE_ORDER_BY_FIELDS_DESCRIPTION +import org.genspectrum.lapis.controller.CO_OCCURRENCE_POSITIONS_DESCRIPTION +import org.genspectrum.lapis.controller.CO_OCCURRENCE_POSITIONS_REQUEST_BODY_DESCRIPTION import org.genspectrum.lapis.controller.DATA_FORMAT_DESCRIPTION import org.genspectrum.lapis.controller.DETAILS_FIELDS_DESCRIPTION import org.genspectrum.lapis.controller.LIMIT_DESCRIPTION @@ -59,6 +62,7 @@ import org.genspectrum.lapis.request.OFFSET_PROPERTY import org.genspectrum.lapis.request.ORDER_BY_PROPERTY import org.genspectrum.lapis.request.OrderByField import org.genspectrum.lapis.request.PHYLO_TREE_FIELD_PROPERTY +import org.genspectrum.lapis.request.POSITIONS_PROPERTY import org.genspectrum.lapis.request.PRINT_NODES_NOT_IN_TREE_FIELD_PROPERTY import org.genspectrum.lapis.request.SEGMENTS_PROPERTY import org.genspectrum.lapis.response.COUNT_PROPERTY @@ -218,6 +222,16 @@ fun buildOpenApiSchema( QUERIES_OVER_TIME_REQUEST_SCHEMA, requestSchemaForQueriesOverTime(sequenceFilterFields), ) + .addSchemas( + CO_OCCURRENCE_REQUEST_SCHEMA, + requestSchemaForCommonSequenceFilters( + getSequenceFiltersWithFormat( + sequenceFilterFields = sequenceFilterFields, + orderByFieldsSchema = coOccurrenceOrderByFieldsSchema(), + dataFormatSchema = dataFormatSchema(), + ) + Pair(POSITIONS_PROPERTY, coOccurrencePositionsRequestSchema()), + ), + ) .addSchemas( AGGREGATED_RESPONSE_SCHEMA, lapisArrayResponseSchema( @@ -234,6 +248,31 @@ fun buildOpenApiSchema( ), ), ) + .addSchemas( + CO_OCCURRENCE_RESPONSE_SCHEMA, + lapisArrayResponseSchema( + Schema() + .types(setOf("object")) + .description( + "Co-occurrence data. Each entry represents one combination of symbols observed " + + "at the requested positions, along with the number of sequences that have " + + "this exact combination. The keys for the requested positions have the " + + "format ':', and their values are the nucleotide or " + + "amino acid symbol observed at that position. The key 'count' is always " + + "present.", + ) + .required(listOf(COUNT_PROPERTY)) + .properties( + mapOf( + COUNT_PROPERTY to IntegerSchema() + .format("int64") + .description( + "The number of sequences with this combination of symbols.", + ), + ), + ), + ), + ) .addSchemas( MOST_RECENT_COMMON_ANCESTOR_RESPONSE_SCHEMA, lapisArrayResponseSchema( @@ -369,6 +408,14 @@ fun buildOpenApiSchema( NUCLEOTIDE_SEQUENCES_ORDER_BY_FIELDS_SCHEMA, arraySchema(nucleotideSequenceOrderByFieldsEnum(databaseConfig)), ) + .addSchemas( + CO_OCCURRENCE_ORDER_BY_FIELDS_SCHEMA, + arraySchema(coOccurrenceOrderByFieldsSchema()), + ) + .addSchemas( + CO_OCCURRENCE_POSITIONS_SCHEMA, + coOccurrencePositionsParamSchema(), + ) .addSchemas( SEGMENT_SCHEMA, segmentsEnum(referenceGenomeSchema), @@ -1102,6 +1149,45 @@ private fun mutationsOrderByFieldsEnum() = private fun insertionsOrderByFieldsEnum() = orderByFieldsEnum(emptyList(), listOf("insertion", "count", "position", "sequenceName", "insertedSymbols")) +/** + * Unlike the other `orderBy` fields, valid co-occurrence `orderBy` values (besides "count" and + * "[ORDER_BY_RANDOM_FIELD_NAME]") depend on the positions requested in each individual query + * (e.g. "main:123"), so they cannot be enumerated statically. + */ +private fun coOccurrenceOrderByFieldsSchema() = + Schema() + .types(setOf("string")) + .description(CO_OCCURRENCE_ORDER_BY_FIELDS_DESCRIPTION) + .example(ORDER_BY_RANDOM_FIELD_NAME) + +private fun coOccurrencePositionsParamSchema() = + ArraySchema() + .items(IntegerSchema().example(421)) + .description(CO_OCCURRENCE_POSITIONS_DESCRIPTION) + +private fun coOccurrencePositionsRequestSchema() = + ArraySchema() + .items( + Schema().oneOf( + listOf( + IntegerSchema() + .example(421) + .description("A single 1-based position."), + Schema() + .types(setOf("object")) + .description("An inclusive range of 1-based positions.") + .required(listOf("from", "to")) + .properties( + mapOf( + "from" to IntegerSchema().example(100), + "to" to IntegerSchema().example(110), + ), + ), + ), + ), + ) + .description(CO_OCCURRENCE_POSITIONS_REQUEST_BODY_DESCRIPTION) + private fun aminoAcidSequenceOrderByFieldsEnum(databaseConfig: DatabaseConfig) = orderByFieldsEnum(databaseConfig.schema.metadata) .description(AMINO_ACID_SEQUENCES_ORDER_BY_DESCRIPTION) diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/openApi/Schemas.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/openApi/Schemas.kt index 65c37bbe8..2a1b1216c 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/openApi/Schemas.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/openApi/Schemas.kt @@ -17,6 +17,8 @@ import org.genspectrum.lapis.controller.ALIGNED_AMINO_ACID_SEQUENCE_ENDPOINT_DES import org.genspectrum.lapis.controller.AMINO_ACID_FASTA_HEADER_TEMPLATE_DESCRIPTION import org.genspectrum.lapis.controller.AMINO_ACID_INSERTIONS_ENDPOINT_DESCRIPTION import org.genspectrum.lapis.controller.AMINO_ACID_MUTATIONS_ENDPOINT_DESCRIPTION +import org.genspectrum.lapis.controller.CO_OCCURRENCE_ORDER_BY_FIELDS_DESCRIPTION +import org.genspectrum.lapis.controller.CO_OCCURRENCE_POSITIONS_DESCRIPTION import org.genspectrum.lapis.controller.DATA_FORMAT_DESCRIPTION import org.genspectrum.lapis.controller.DETAILS_ENDPOINT_DESCRIPTION import org.genspectrum.lapis.controller.DETAILS_FIELDS_DESCRIPTION @@ -57,6 +59,7 @@ const val NUCLEOTIDE_SEQUENCE_REQUEST_SCHEMA = "NucleotideSequenceRequest" const val ALL_NUCLEOTIDE_SEQUENCE_REQUEST_SCHEMA = "AllNucleotideSequenceRequest" const val MUTATIONS_OVER_TIME_REQUEST_SCHEMA = "MutationsOverTimeRequest" const val QUERIES_OVER_TIME_REQUEST_SCHEMA = "QueriesOverTimeRequest" +const val CO_OCCURRENCE_REQUEST_SCHEMA = "CoOccurrenceRequest" const val AGGREGATED_RESPONSE_SCHEMA = "AggregatedResponse" const val DETAILS_RESPONSE_SCHEMA = "DetailsResponse" @@ -69,6 +72,7 @@ const val NUCLEOTIDE_SEQUENCES_RESPONSE_SCHEMA = "NucleotideSequencesResponse" const val ALL_NUCLEOTIDE_SEQUENCES_RESPONSE_SCHEMA = "AllNucleotideSequencesResponse" const val AMINO_ACID_SEQUENCES_RESPONSE_SCHEMA = "AminoAcidSequencesResponse" const val ALL_AMINO_ACID_SEQUENCES_RESPONSE_SCHEMA = "AllAminoAcidSequencesResponse" +const val CO_OCCURRENCE_RESPONSE_SCHEMA = "CoOccurrenceResponse" const val NUCLEOTIDE_MUTATIONS_SCHEMA = "NucleotideMutations" const val AMINO_ACID_MUTATIONS_SCHEMA = "AminoAcidMutations" @@ -81,6 +85,8 @@ const val MUTATIONS_ORDER_BY_FIELDS_SCHEMA = "MutationsOrderByFields" const val INSERTIONS_ORDER_BY_FIELDS_SCHEMA = "InsertionsOrderByFields" const val AMINO_ACID_SEQUENCES_ORDER_BY_FIELDS_SCHEMA = "AminoAcidSequencesOrderByFields" const val NUCLEOTIDE_SEQUENCES_ORDER_BY_FIELDS_SCHEMA = "NucleotideSequencesOrderByFields" +const val CO_OCCURRENCE_ORDER_BY_FIELDS_SCHEMA = "CoOccurrenceOrderByFields" +const val CO_OCCURRENCE_POSITIONS_SCHEMA = "CoOccurrencePositions" const val LIMIT_SCHEMA = "Limit" const val OFFSET_SCHEMA = "Offset" const val FORMAT_SCHEMA = "DataFormat" @@ -276,6 +282,22 @@ annotation class LapisNucleotideInsertionsResponse ) annotation class LapisAminoAcidInsertionsResponse +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +@LapisResponseAnnotation( + content = [ + Content( + schema = Schema( + ref = "#/components/schemas/$CO_OCCURRENCE_RESPONSE_SCHEMA", + ), + ), + ], +) +annotation class LapisCoOccurrenceResponse( + @get:AliasFor(annotation = LapisResponseAnnotation::class, attribute = "description") + val description: String = "", +) + @Target(AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) @LapisResponseAnnotation( @@ -460,6 +482,23 @@ annotation class AminoAcidSequencesOrderByFields ) annotation class NucleotideSequencesOrderByFields +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +@Parameter( + schema = Schema(ref = "#/components/schemas/$CO_OCCURRENCE_ORDER_BY_FIELDS_SCHEMA"), + description = CO_OCCURRENCE_ORDER_BY_FIELDS_DESCRIPTION, +) +annotation class CoOccurrenceOrderByFields + +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +@Parameter( + schema = Schema(ref = "#/components/schemas/$CO_OCCURRENCE_POSITIONS_SCHEMA"), + description = CO_OCCURRENCE_POSITIONS_DESCRIPTION, + explode = Explode.TRUE, +) +annotation class CoOccurrencePositionsParam + @Target(AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.RUNTIME) @Parameter( From 1059cb1f47819ef7c2807348465ad9cf0403bd1b Mon Sep 17 00:00:00 2001 From: Fabian Engelniederhammer Date: Wed, 8 Jul 2026 10:12:25 +0200 Subject: [PATCH 5/9] 1742 add tests --- .../middleware/CompressionFilter.kt | 3 +- .../middleware/DownloadAsFileFilter.kt | 22 +++- .../LapisControllerCompressionTest.kt | 111 ++++++++++++++++++ .../LapisControllerDownloadAsFileTest.kt | 19 +++ .../lapis/controller/LapisControllerTest.kt | 4 + .../genspectrum/lapis/controller/MockData.kt | 33 ++++++ 6 files changed, 189 insertions(+), 3 deletions(-) diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/middleware/CompressionFilter.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/middleware/CompressionFilter.kt index baab302f3..a3ee18475 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/middleware/CompressionFilter.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/middleware/CompressionFilter.kt @@ -202,7 +202,8 @@ class CompressionFilter( if (downloadAsFile) { return response } - if (!reReadableRequest.getProxyAwarePath().startsWith("/sample")) { + val path = reReadableRequest.getProxyAwarePath() + if (!path.startsWith("/sample") && !path.startsWith("/component")) { return response } diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/middleware/DownloadAsFileFilter.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/middleware/DownloadAsFileFilter.kt index 4f6af3a5e..f1a8f6803 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/middleware/DownloadAsFileFilter.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/middleware/DownloadAsFileFilter.kt @@ -3,9 +3,11 @@ package org.genspectrum.lapis.controller.middleware import jakarta.servlet.FilterChain import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse +import org.genspectrum.lapis.controller.AMINO_ACID_CO_OCCURRENCE_ROUTE import org.genspectrum.lapis.controller.LapisMediaType.TEXT_CSV import org.genspectrum.lapis.controller.LapisMediaType.TEXT_TSV import org.genspectrum.lapis.controller.LapisMediaType.TEXT_X_FASTA +import org.genspectrum.lapis.controller.NUCLEOTIDE_CO_OCCURRENCE_ROUTE import org.genspectrum.lapis.controller.SampleRoute import org.genspectrum.lapis.controller.ServeType import org.genspectrum.lapis.request.DOWNLOAD_AS_FILE_PROPERTY @@ -20,6 +22,17 @@ import org.springframework.stereotype.Component import org.springframework.web.filter.OncePerRequestFilter import tools.jackson.databind.ObjectMapper +/** + * Maps routes under `/component` to their default download filename. + * Unlike routes under `/sample`, these aren't registered in [SampleRoute] since that enum's other + * consumers (e.g. test scenario builders) assume a route can be requested without any extra + * required parameters, which doesn't hold for e.g. the co-occurrence routes (they require `positions`). + */ +private val COMPONENT_ROUTE_FILENAMES = mapOf( + NUCLEOTIDE_CO_OCCURRENCE_ROUTE to "nucleotideCoOccurrence", + AMINO_ACID_CO_OCCURRENCE_ROUTE to "aminoAcidCoOccurrence", +) + @Component @Order(DOWNLOAD_AS_FILE_FILTER_ORDER) class DownloadAsFileFilter( @@ -43,10 +56,15 @@ class DownloadAsFileFilter( } private fun getFilename(request: CachedBodyHttpServletRequest): String { - val matchingRoute = - SampleRoute.entries.find { request.getProxyAwarePath().startsWith("/sample${it.pathSegment}") } + val path = request.getProxyAwarePath() + val matchingRoute = SampleRoute.entries.find { path.startsWith("/sample${it.pathSegment}") } + val matchingComponentRouteName = COMPONENT_ROUTE_FILENAMES.entries + .find { (routeSegment, _) -> path.startsWith("/component$routeSegment") } + ?.value + val dataName = request.getStringField(DOWNLOAD_FILE_BASENAME_PROPERTY) ?: matchingRoute?.pathSegment?.trim('/') + ?: matchingComponentRouteName ?: "data" val compressionEnding = when (val compressionSource = requestCompression.compressionSource) { diff --git a/lapis/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerCompressionTest.kt b/lapis/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerCompressionTest.kt index c27ec3f0b..807a2edd0 100644 --- a/lapis/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerCompressionTest.kt +++ b/lapis/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerCompressionTest.kt @@ -255,6 +255,30 @@ class LapisControllerCompressionTest( mockDataCollection = MockDataForEndpoints.treeEndpointMockData(), dataFormat = TreeEndpointMockDataCollection.DataFormat.NEWICK, compressionFormat = COMPRESSION_FORMAT_ZSTD, + ) + + getCoOccurrenceRequests( + endpoint = "$NUCLEOTIDE_CO_OCCURRENCE_ROUTE/main", + mockDataCollection = MockDataForEndpoints.coOccurrenceMockData("main"), + dataFormat = CSV, + compressionFormat = COMPRESSION_FORMAT_GZIP, + ) + + getCoOccurrenceRequests( + endpoint = "$NUCLEOTIDE_CO_OCCURRENCE_ROUTE/main", + mockDataCollection = MockDataForEndpoints.coOccurrenceMockData("main"), + dataFormat = TSV, + compressionFormat = COMPRESSION_FORMAT_ZSTD, + ) + + getCoOccurrenceRequests( + endpoint = "$AMINO_ACID_CO_OCCURRENCE_ROUTE/gene1", + mockDataCollection = MockDataForEndpoints.coOccurrenceMockData("gene1"), + dataFormat = NESTED_JSON, + compressionFormat = COMPRESSION_FORMAT_GZIP, + ) + + getCoOccurrenceRequests( + endpoint = "$AMINO_ACID_CO_OCCURRENCE_ROUTE/gene1", + mockDataCollection = MockDataForEndpoints.coOccurrenceMockData("gene1"), + dataFormat = CSV, + compressionFormat = COMPRESSION_FORMAT_ZSTD, ) @JvmStatic @@ -519,6 +543,93 @@ private fun getTreeRequests( ), ) +private fun getCoOccurrenceRequests( + endpoint: String, + mockDataCollection: MockDataCollection, + dataFormat: MockDataCollection.DataFormat, + compressionFormat: String, +): List { + val mockData = mockDataCollection.expecting(dataFormat) + + return listOf( + RequestScenario( + callDescription = "GET $endpoint as $dataFormat with request parameter", + mockData = mockData, + request = getComponent(endpoint) + .queryParam("positions", "1", "2") + .queryParam("dataFormat", dataFormat.fileFormat) + .queryParam("compression", compressionFormat), + compressionFormat = compressionFormat, + expectedContentType = getContentTypeForCompressionFormat(compressionFormat), + expectedContentEncoding = null, + ), + RequestScenario( + callDescription = "GET $endpoint as $dataFormat with accept header", + mockData = mockData, + request = getComponent(endpoint) + .queryParam("positions", "1", "2") + .queryParam("dataFormat", dataFormat.fileFormat) + .header(ACCEPT_ENCODING, compressionFormat), + compressionFormat = compressionFormat, + expectedContentType = getContentTypeForDataFormat(dataFormat), + expectedContentEncoding = compressionFormat, + ), + RequestScenario( + callDescription = "POST JSON $endpoint as $dataFormat with request parameter", + mockData = mockData, + request = postComponent(endpoint) + .content( + """ + { + "positions": [1, 2], + "dataFormat": "${dataFormat.fileFormat}", + "compression": "$compressionFormat" + } + """.trimIndent(), + ) + .contentType(APPLICATION_JSON), + compressionFormat = compressionFormat, + expectedContentType = getContentTypeForCompressionFormat(compressionFormat), + expectedContentEncoding = null, + ), + RequestScenario( + callDescription = "POST JSON $endpoint as $dataFormat with accept header", + mockData = mockData, + request = postComponent(endpoint) + .content("""{"positions": [1, 2], "dataFormat": "${dataFormat.fileFormat}"}""") + .contentType(APPLICATION_JSON) + .header(ACCEPT_ENCODING, compressionFormat), + compressionFormat = compressionFormat, + expectedContentType = getContentTypeForDataFormat(dataFormat), + expectedContentEncoding = compressionFormat, + ), + RequestScenario( + callDescription = "POST form url encoded $endpoint as $dataFormat with request parameter", + mockData = mockData, + request = postComponent(endpoint) + .param("positions", "1", "2") + .param("dataFormat", dataFormat.fileFormat) + .param("compression", compressionFormat) + .contentType(APPLICATION_FORM_URLENCODED), + compressionFormat = compressionFormat, + expectedContentType = getContentTypeForCompressionFormat(compressionFormat), + expectedContentEncoding = null, + ), + RequestScenario( + callDescription = "POST form url encoded $endpoint as $dataFormat with accept header", + mockData = mockData, + request = postComponent(endpoint) + .param("positions", "1", "2") + .param("dataFormat", dataFormat.fileFormat) + .contentType(APPLICATION_FORM_URLENCODED) + .header(ACCEPT_ENCODING, compressionFormat), + compressionFormat = compressionFormat, + expectedContentType = getContentTypeForDataFormat(dataFormat), + expectedContentEncoding = compressionFormat, + ), + ) +} + fun getContentTypeForCompressionFormat(compressionFormat: String) = when (compressionFormat) { COMPRESSION_FORMAT_GZIP -> "application/gzip" diff --git a/lapis/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerDownloadAsFileTest.kt b/lapis/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerDownloadAsFileTest.kt index 009532001..de86ca3b6 100644 --- a/lapis/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerDownloadAsFileTest.kt +++ b/lapis/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerDownloadAsFileTest.kt @@ -215,6 +215,25 @@ class LapisControllerDownloadAsFileTest( ) } + @Test + fun `GIVEN a co-occurrence request WHEN downloading as file THEN the filename is based on the route`() { + val mockData = MockDataForEndpoints.coOccurrenceMockData("main") + .expecting(MockDataCollection.DataFormat.CSV) + mockData.mockWithData(siloQueryModelMock) + + mockMvc.perform( + getComponent( + "$NUCLEOTIDE_CO_OCCURRENCE_ROUTE/main?positions=1,2&$DOWNLOAD_AS_FILE_PROPERTY=true", + ) + .header(ACCEPT, "text/csv"), + ) + .andExpect(status().isOk) + .andExpectAttachmentWithContent( + expectedFilename = "nucleotideCoOccurrence.csv", + assertFileContentMatches = mockData.assertDataMatches, + ) + } + private fun ResultActions.andExpectAttachmentWithContent( expectedFilename: String, assertFileContentMatches: (String) -> Unit, diff --git a/lapis/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerTest.kt b/lapis/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerTest.kt index d03f28c49..e8b7d9a0c 100644 --- a/lapis/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerTest.kt +++ b/lapis/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerTest.kt @@ -539,6 +539,10 @@ fun getSample(path: String): MockHttpServletRequestBuilder = get("/sample/$path" fun postSample(path: String): MockHttpServletRequestBuilder = post("/sample/$path") +fun getComponent(path: String): MockHttpServletRequestBuilder = get("/component$path") + +fun postComponent(path: String): MockHttpServletRequestBuilder = post("/component$path") + private fun mutationRequestsForMinProportion( endpoint: String, ): List MockHttpServletRequestBuilder>> = diff --git a/lapis/src/test/kotlin/org/genspectrum/lapis/controller/MockData.kt b/lapis/src/test/kotlin/org/genspectrum/lapis/controller/MockData.kt index 5371effe3..922124700 100644 --- a/lapis/src/test/kotlin/org/genspectrum/lapis/controller/MockData.kt +++ b/lapis/src/test/kotlin/org/genspectrum/lapis/controller/MockData.kt @@ -9,6 +9,7 @@ import org.genspectrum.lapis.model.FastaHeaderTemplate import org.genspectrum.lapis.model.SequencesResponse import org.genspectrum.lapis.model.SiloQueryModel import org.genspectrum.lapis.model.TemplateField +import org.genspectrum.lapis.request.CoOccurrenceRequest import org.genspectrum.lapis.response.AggregationData import org.genspectrum.lapis.response.DetailsData import org.genspectrum.lapis.response.ExplicitlyNullable @@ -405,6 +406,38 @@ object MockDataForEndpoints { """.trimIndent(), ) + fun coOccurrenceMockData(sequenceName: String = "main") = + MockDataCollection.create( + siloQueryModelMockCall = { modelMock -> + { request: CoOccurrenceRequest -> modelMock.getCoOccurrence(request, sequenceName) } + }, + modelData = listOf( + AggregationData( + 5, + mapOf("$sequenceName:1" to StringNode("A"), "$sequenceName:2" to StringNode("T")), + ), + ), + expectedJson = """ + [ + { + "$sequenceName:1": "A", + "$sequenceName:2": "T", + "count": 5 + } + ] + """.trimIndent(), + expectedCsv = """ + $sequenceName:1,$sequenceName:2,count + A,T,5 + + """.trimIndent(), + expectedTsv = """ + $sequenceName:1 $sequenceName:2 count + A T 5 + + """.trimIndent(), + ) + private val aggregated = MockDataCollection.create( siloQueryModelMockCall = { it::getAggregated }, modelData = listOf( From 94738113d537657c811ea625297d12563448dae0 Mon Sep 17 00:00:00 2001 From: Fabian Engelniederhammer Date: Thu, 9 Jul 2026 10:20:51 +0200 Subject: [PATCH 6/9] 1742 add e2e test --- lapis-e2e/test/coOccurrence.spec.ts | 204 ++++++++++++++++++ lapis-e2e/test/common.ts | 15 ++ .../genspectrum/lapis/openApi/OpenApiDocs.kt | 8 + 3 files changed, 227 insertions(+) create mode 100644 lapis-e2e/test/coOccurrence.spec.ts diff --git a/lapis-e2e/test/coOccurrence.spec.ts b/lapis-e2e/test/coOccurrence.spec.ts new file mode 100644 index 000000000..388597086 --- /dev/null +++ b/lapis-e2e/test/coOccurrence.spec.ts @@ -0,0 +1,204 @@ +import { expect } from 'chai'; +import { + aminoAcidCoOccurrenceClient, + basePath, + expectIsGzipEncoded, + multiSegmentedCoOccurrenceClient, + singleSegmentedCoOccurrenceClient, +} from './common'; +import { Gene } from './lapisClient'; +import { Segment } from './lapisClientMultiSegmented'; + +// Country Switzerland has 100 sequences in total (see aggregatedQueries/aggregrationFields.json). +// A combination's count can never exceed that, regardless of how many sequences have ambiguous +// reads at the requested position(s) (those just end up in their own combination, e.g. with 'N'). +const maxPossibleSwitzerlandCount = 100; + +describe('The /nucleotideCoOccurrence endpoint (single segmented)', () => { + it('should return the co-occurrence of symbols at a single position', async () => { + const result = await singleSegmentedCoOccurrenceClient.postSingleSegmentNucleotideCoOccurrence({ + coOccurrenceRequest: { + country: 'Switzerland', + positions: [28280], + }, + }); + + const totalCount = result.data.reduce((sum, entry) => sum + entry.count, 0); + expect(totalCount).to.be.at.most(maxPossibleSwitzerlandCount); + + const mutatedCombination = result.data.find(entry => entry['main:28280'] === 'C'); + expect(mutatedCombination?.count).to.equal(51); + }); + + it('should return the co-occurrence of symbols at a range of positions', async () => { + const result = await singleSegmentedCoOccurrenceClient.postSingleSegmentNucleotideCoOccurrence({ + coOccurrenceRequest: { + country: 'Switzerland', + positions: [{ from: 28279, to: 28281 }], + }, + }); + + expect(result.data.length).to.be.greaterThan(0); + + result.data.forEach(entry => { + expect(entry).to.have.property('main:28279'); + expect(entry).to.have.property('main:28280'); + expect(entry).to.have.property('main:28281'); + }); + }); + + it('should return the co-occurrence of symbols at multiple discrete positions', async () => { + const result = await singleSegmentedCoOccurrenceClient.postSingleSegmentNucleotideCoOccurrence({ + coOccurrenceRequest: { + country: 'Switzerland', + positions: [19220, 28280], + }, + }); + + expect(result.data.length).to.be.greaterThan(0); + + result.data.forEach(entry => { + expect(entry).to.have.property('main:19220'); + expect(entry).to.have.property('main:28280'); + }); + }); + + it('should support GET requests', async () => { + const urlParams = new URLSearchParams({ country: 'Switzerland', positions: '28280' }); + const response = await fetch(basePath + '/component/nucleotideCoOccurrence?' + urlParams.toString()); + + expect(response.status).to.equal(200); + const resultJson = await response.json(); + + const mutatedCombination = resultJson.data.find( + (entry: { 'main:28280': string }) => entry['main:28280'] === 'C' + ); + expect(mutatedCombination?.count).to.equal(51); + }); + + it('should order by count descending and respect limit', async () => { + const result = await singleSegmentedCoOccurrenceClient.postSingleSegmentNucleotideCoOccurrence({ + coOccurrenceRequest: { + country: 'Switzerland', + positions: [28280], + limit: 1, + orderBy: [{ field: 'count', type: 'descending' }], + }, + }); + + expect(result.data).to.have.lengthOf(1); + expect(result.data[0]['main:28280']).to.equal('C'); + expect(result.data[0].count).to.equal(51); + }); + + it('should order by the relabeled position field', async () => { + const result = await singleSegmentedCoOccurrenceClient.postSingleSegmentNucleotideCoOccurrence({ + coOccurrenceRequest: { + country: 'Switzerland', + positions: [28280], + orderBy: [{ field: 'main:28280', type: 'ascending' }], + }, + }); + + const values = result.data.map(entry => entry['main:28280']); + expect(values).to.deep.equal([...values].sort()); + }); + + it('should return a 400 error when positions is missing', async () => { + const result = await fetch(basePath + '/component/nucleotideCoOccurrence', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ country: 'Switzerland' }), + }); + + expect(result.status).to.equal(400); + }); + + it('should return a 404 for a segment path when the genome is single segmented', async () => { + const result = await fetch(basePath + '/component/nucleotideCoOccurrence/main?positions=1'); + + expect(result.status).to.equal(404); + }); + + it('if downloadAsFile is true, the content disposition is set to attachment', async () => { + const result = await singleSegmentedCoOccurrenceClient.postSingleSegmentNucleotideCoOccurrenceRaw({ + coOccurrenceRequest: { + country: 'Switzerland', + positions: [28280], + downloadAsFile: true, + }, + }); + + expect(result.raw.headers.has('Content-Disposition')).is.true; + expect(result.raw.headers.get('Content-Disposition')).contains('attachment'); + expect(result.raw.headers.get('Content-Disposition')).contains('nucleotideCoOccurrence'); + }); + + it('should return gzip compressed data when requested', async () => { + const response = await fetch(basePath + '/component/nucleotideCoOccurrence', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ country: 'Switzerland', positions: [28280], compression: 'gzip' }), + }); + + expect(response.status).to.equal(200); + expect(response.headers.get('content-type')).to.equal('application/gzip'); + expectIsGzipEncoded(await response.arrayBuffer()); + }); +}); + +describe('The /nucleotideCoOccurrence endpoint (multi segmented)', () => { + it('should return the co-occurrence of symbols for a segment', async () => { + const result = await multiSegmentedCoOccurrenceClient.postNucleotideCoOccurrence({ + segment: Segment.L, + coOccurrenceRequest: { + country: 'Switzerland', + positions: [1], + }, + }); + + const mutatedCombination = result.data.find(entry => entry['L:1'] === 'A'); + expect(mutatedCombination?.count).to.equal(2); + }); + + it('should return the co-occurrence of symbols for a different segment', async () => { + const result = await multiSegmentedCoOccurrenceClient.postNucleotideCoOccurrence({ + segment: Segment.M, + coOccurrenceRequest: { + country: 'Switzerland', + positions: [1], + }, + }); + + const mutatedCombination = result.data.find(entry => entry['M:1'] === 'C'); + expect(mutatedCombination?.count).to.equal(1); + }); +}); + +describe('The /aminoAcidCoOccurrence endpoint', () => { + it('should return the co-occurrence of symbols at a single position', async () => { + const result = await aminoAcidCoOccurrenceClient.postAminoAcidCoOccurrence({ + gene: Gene.S, + coOccurrenceRequest: { + country: 'Switzerland', + positions: [478], + }, + }); + + const totalCount = result.data.reduce((sum, entry) => sum + entry.count, 0); + expect(totalCount).to.be.at.most(maxPossibleSwitzerlandCount); + + const mutatedCombination = result.data.find(entry => entry['S:478'] === 'K'); + expect(mutatedCombination?.count).to.equal(69); + }); + + it('should return a 400 error when the position range is invalid', async () => { + const result = await fetch(basePath + '/component/aminoAcidCoOccurrence/S', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ country: 'Switzerland', positions: [{ from: 10, to: 1 }] }), + }); + + expect(result.status).to.equal(400); + }); +}); diff --git a/lapis-e2e/test/common.ts b/lapis-e2e/test/common.ts index 6102b7792..47e74f4a8 100644 --- a/lapis-e2e/test/common.ts +++ b/lapis-e2e/test/common.ts @@ -1,15 +1,18 @@ import { ActuatorApi, + AminoAcidCoOccurrenceControllerApi, Configuration, InfoControllerApi, LapisControllerApi, Middleware, QueriesOverTimeControllerApi, QueryControllerApi, + SingleSegmentedCoOccurrenceControllerApi, SingleSegmentedSequenceControllerApi, } from './lapisClient'; import { LapisControllerApi as LapisControllerApiMultiSegmented, + MultiSegmentedCoOccurrenceControllerApi, MultiSegmentedSequenceControllerApi, } from './lapisClientMultiSegmented'; import { LapisControllerApi as LapisControllerApiWithAuth } from './lapisClientWithAuth'; @@ -74,6 +77,18 @@ export const lapisMultiSegmentedSequenceController = new MultiSegmentedSequenceC new Configuration({ basePath: basePathMultiSegmented }) ).withMiddleware(middleware); +export const singleSegmentedCoOccurrenceClient = new SingleSegmentedCoOccurrenceControllerApi( + new Configuration({ basePath }) +).withMiddleware(middleware); + +export const multiSegmentedCoOccurrenceClient = new MultiSegmentedCoOccurrenceControllerApi( + new Configuration({ basePath: basePathMultiSegmented }) +).withMiddleware(middleware); + +export const aminoAcidCoOccurrenceClient = new AminoAcidCoOccurrenceControllerApi( + new Configuration({ basePath }) +).withMiddleware(middleware); + export const lapisClientWithAuth = ({ basePath, accessToken }: { basePath: string; accessToken?: string }) => new LapisControllerApiWithAuth(new Configuration({ basePath, accessToken })).withMiddleware(middleware); diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/openApi/OpenApiDocs.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/openApi/OpenApiDocs.kt index b6e5ad9a0..e0a2debc5 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/openApi/OpenApiDocs.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/openApi/OpenApiDocs.kt @@ -270,6 +270,14 @@ fun buildOpenApiSchema( "The number of sequences with this combination of symbols.", ), ), + ) + .additionalProperties( + StringSchema() + .description( + "The symbol observed at the corresponding requested position, " + + "for this combination of symbols.", + ) + .example("A"), ), ), ) From 92d27b698172ea197561e2ac781a6bbcb93a8b53 Mon Sep 17 00:00:00 2001 From: Fabian Engelniederhammer Date: Mon, 13 Jul 2026 08:18:02 +0200 Subject: [PATCH 7/9] 1742 fix single segmented sequence name --- lapis-e2e/test/coOccurrence.spec.ts | 22 +++--- .../AminoAcidCoOccurrenceController.kt | 12 ++-- .../CoOccurrenceControllerSupport.kt | 9 ++- .../MultiSegmentedCoOccurrenceController.kt | 12 ++-- .../SingleSegmentedCoOccurrenceController.kt | 12 ++-- .../genspectrum/lapis/model/SiloQueryModel.kt | 47 ++---------- .../genspectrum/lapis/openApi/OpenApiDocs.kt | 5 +- .../org/genspectrum/lapis/silo/SiloQuery.kt | 33 ++++++--- .../controller/CoOccurrenceControllerTest.kt | 6 ++ .../genspectrum/lapis/controller/MockData.kt | 2 +- ...ngleSegmentedCoOccurrenceControllerTest.kt | 5 +- .../lapis/model/SiloQueryModelTest.kt | 71 +++++++++++++++---- .../lapis/silo/SiloQueryToSaneQlTest.kt | 27 ++++--- 13 files changed, 152 insertions(+), 111 deletions(-) diff --git a/lapis-e2e/test/coOccurrence.spec.ts b/lapis-e2e/test/coOccurrence.spec.ts index 388597086..2948f5ffe 100644 --- a/lapis-e2e/test/coOccurrence.spec.ts +++ b/lapis-e2e/test/coOccurrence.spec.ts @@ -26,7 +26,7 @@ describe('The /nucleotideCoOccurrence endpoint (single segmented)', () => { const totalCount = result.data.reduce((sum, entry) => sum + entry.count, 0); expect(totalCount).to.be.at.most(maxPossibleSwitzerlandCount); - const mutatedCombination = result.data.find(entry => entry['main:28280'] === 'C'); + const mutatedCombination = result.data.find(entry => entry['28280'] === 'C'); expect(mutatedCombination?.count).to.equal(51); }); @@ -41,9 +41,9 @@ describe('The /nucleotideCoOccurrence endpoint (single segmented)', () => { expect(result.data.length).to.be.greaterThan(0); result.data.forEach(entry => { - expect(entry).to.have.property('main:28279'); - expect(entry).to.have.property('main:28280'); - expect(entry).to.have.property('main:28281'); + expect(entry).to.have.property('28279'); + expect(entry).to.have.property('28280'); + expect(entry).to.have.property('28281'); }); }); @@ -58,8 +58,8 @@ describe('The /nucleotideCoOccurrence endpoint (single segmented)', () => { expect(result.data.length).to.be.greaterThan(0); result.data.forEach(entry => { - expect(entry).to.have.property('main:19220'); - expect(entry).to.have.property('main:28280'); + expect(entry).to.have.property('19220'); + expect(entry).to.have.property('28280'); }); }); @@ -70,9 +70,7 @@ describe('The /nucleotideCoOccurrence endpoint (single segmented)', () => { expect(response.status).to.equal(200); const resultJson = await response.json(); - const mutatedCombination = resultJson.data.find( - (entry: { 'main:28280': string }) => entry['main:28280'] === 'C' - ); + const mutatedCombination = resultJson.data.find((entry: { '28280': string }) => entry['28280'] === 'C'); expect(mutatedCombination?.count).to.equal(51); }); @@ -87,7 +85,7 @@ describe('The /nucleotideCoOccurrence endpoint (single segmented)', () => { }); expect(result.data).to.have.lengthOf(1); - expect(result.data[0]['main:28280']).to.equal('C'); + expect(result.data[0]['28280']).to.equal('C'); expect(result.data[0].count).to.equal(51); }); @@ -96,11 +94,11 @@ describe('The /nucleotideCoOccurrence endpoint (single segmented)', () => { coOccurrenceRequest: { country: 'Switzerland', positions: [28280], - orderBy: [{ field: 'main:28280', type: 'ascending' }], + orderBy: [{ field: '28280', type: 'ascending' }], }, }); - const values = result.data.map(entry => entry['main:28280']); + const values = result.data.map(entry => entry['28280']); expect(values).to.deep.equal([...values].sort()); }); diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/AminoAcidCoOccurrenceController.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/AminoAcidCoOccurrenceController.kt index c2bf8cdcb..e183c3a19 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/AminoAcidCoOccurrenceController.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/AminoAcidCoOccurrenceController.kt @@ -104,7 +104,7 @@ class AminoAcidCoOccurrenceController( lapisResponseStreamer.streamData( request = request, - getData = { getCoOccurrenceCollection(siloQueryModel, it, gene) }, + getData = { getCoOccurrenceCollection(siloQueryModel, it, gene, responsePrefix = gene) }, response = response, responseFormat = ResponseFormat.Json, ) @@ -169,7 +169,7 @@ class AminoAcidCoOccurrenceController( lapisResponseStreamer.streamData( request = request, - getData = { getCoOccurrenceCollection(siloQueryModel, it, gene) }, + getData = { getCoOccurrenceCollection(siloQueryModel, it, gene, responsePrefix = gene) }, response = response, responseFormat = ResponseFormat.Csv(delimiter = COMMA, acceptHeader = httpHeaders.accept), ) @@ -234,7 +234,7 @@ class AminoAcidCoOccurrenceController( lapisResponseStreamer.streamData( request = request, - getData = { getCoOccurrenceCollection(siloQueryModel, it, gene) }, + getData = { getCoOccurrenceCollection(siloQueryModel, it, gene, responsePrefix = gene) }, response = response, responseFormat = ResponseFormat.Csv(delimiter = TAB, acceptHeader = httpHeaders.accept), ) @@ -260,7 +260,7 @@ class AminoAcidCoOccurrenceController( ) { lapisResponseStreamer.streamData( request = request, - getData = { getCoOccurrenceCollection(siloQueryModel, it, gene) }, + getData = { getCoOccurrenceCollection(siloQueryModel, it, gene, responsePrefix = gene) }, response = response, responseFormat = ResponseFormat.Json, ) @@ -286,7 +286,7 @@ class AminoAcidCoOccurrenceController( ) { lapisResponseStreamer.streamData( request = request, - getData = { getCoOccurrenceCollection(siloQueryModel, it, gene) }, + getData = { getCoOccurrenceCollection(siloQueryModel, it, gene, responsePrefix = gene) }, response = response, responseFormat = ResponseFormat.Csv(delimiter = COMMA, acceptHeader = httpHeaders.accept), ) @@ -312,7 +312,7 @@ class AminoAcidCoOccurrenceController( ) { lapisResponseStreamer.streamData( request = request, - getData = { getCoOccurrenceCollection(siloQueryModel, it, gene) }, + getData = { getCoOccurrenceCollection(siloQueryModel, it, gene, responsePrefix = gene) }, response = response, responseFormat = ResponseFormat.Csv(delimiter = TAB, acceptHeader = httpHeaders.accept), ) diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/CoOccurrenceControllerSupport.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/CoOccurrenceControllerSupport.kt index ef797879b..a0bc94143 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/CoOccurrenceControllerSupport.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/CoOccurrenceControllerSupport.kt @@ -50,17 +50,22 @@ fun buildCoOccurrenceRequest( * Builds the [AggregatedCollection] for a co-occurrence response: it queries SILO for the co-occurrence of * symbols at the requested positions of [sequenceName], and prepares the dynamic (position-dependent) header * for the response, e.g. `["S:1", "S:421", "count"]`. + * + * [responsePrefix] is the name that response field names are prefixed with (e.g. gene or segment name). + * It is null for single-segmented nucleotide sequences, where the segment name is implicit and thus omitted + * from the response field names (e.g. `["1", "421", "count"]`), mirroring the nucleotide mutation convention. */ fun getCoOccurrenceCollection( siloQueryModel: SiloQueryModel, request: CoOccurrenceRequest, sequenceName: String, + responsePrefix: String?, ): AggregatedCollection { val positions = request.positions.expandAndValidatePositions() - val fields = positions.map { coOccurrenceResponseFieldName(sequenceName, it) } + val fields = positions.map { coOccurrenceResponseFieldName(responsePrefix, it) } return AggregatedCollection( - records = siloQueryModel.getCoOccurrence(request, sequenceName), + records = siloQueryModel.getCoOccurrence(request, sequenceName, responsePrefix), fields = fields, ) } diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/MultiSegmentedCoOccurrenceController.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/MultiSegmentedCoOccurrenceController.kt index 5518654f3..6f8c79919 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/MultiSegmentedCoOccurrenceController.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/MultiSegmentedCoOccurrenceController.kt @@ -106,7 +106,7 @@ class MultiSegmentedCoOccurrenceController( lapisResponseStreamer.streamData( request = request, - getData = { getCoOccurrenceCollection(siloQueryModel, it, segment) }, + getData = { getCoOccurrenceCollection(siloQueryModel, it, segment, responsePrefix = segment) }, response = response, responseFormat = ResponseFormat.Json, ) @@ -171,7 +171,7 @@ class MultiSegmentedCoOccurrenceController( lapisResponseStreamer.streamData( request = request, - getData = { getCoOccurrenceCollection(siloQueryModel, it, segment) }, + getData = { getCoOccurrenceCollection(siloQueryModel, it, segment, responsePrefix = segment) }, response = response, responseFormat = ResponseFormat.Csv(delimiter = COMMA, acceptHeader = httpHeaders.accept), ) @@ -236,7 +236,7 @@ class MultiSegmentedCoOccurrenceController( lapisResponseStreamer.streamData( request = request, - getData = { getCoOccurrenceCollection(siloQueryModel, it, segment) }, + getData = { getCoOccurrenceCollection(siloQueryModel, it, segment, responsePrefix = segment) }, response = response, responseFormat = ResponseFormat.Csv(delimiter = TAB, acceptHeader = httpHeaders.accept), ) @@ -262,7 +262,7 @@ class MultiSegmentedCoOccurrenceController( ) { lapisResponseStreamer.streamData( request = request, - getData = { getCoOccurrenceCollection(siloQueryModel, it, segment) }, + getData = { getCoOccurrenceCollection(siloQueryModel, it, segment, responsePrefix = segment) }, response = response, responseFormat = ResponseFormat.Json, ) @@ -288,7 +288,7 @@ class MultiSegmentedCoOccurrenceController( ) { lapisResponseStreamer.streamData( request = request, - getData = { getCoOccurrenceCollection(siloQueryModel, it, segment) }, + getData = { getCoOccurrenceCollection(siloQueryModel, it, segment, responsePrefix = segment) }, response = response, responseFormat = ResponseFormat.Csv(delimiter = COMMA, acceptHeader = httpHeaders.accept), ) @@ -314,7 +314,7 @@ class MultiSegmentedCoOccurrenceController( ) { lapisResponseStreamer.streamData( request = request, - getData = { getCoOccurrenceCollection(siloQueryModel, it, segment) }, + getData = { getCoOccurrenceCollection(siloQueryModel, it, segment, responsePrefix = segment) }, response = response, responseFormat = ResponseFormat.Csv(delimiter = TAB, acceptHeader = httpHeaders.accept), ) diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/SingleSegmentedCoOccurrenceController.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/SingleSegmentedCoOccurrenceController.kt index 07fbe3fa9..dcb7c25be 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/SingleSegmentedCoOccurrenceController.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/SingleSegmentedCoOccurrenceController.kt @@ -103,7 +103,7 @@ class SingleSegmentedCoOccurrenceController( lapisResponseStreamer.streamData( request = request, - getData = { getCoOccurrenceCollection(siloQueryModel, it, soleSegmentName()) }, + getData = { getCoOccurrenceCollection(siloQueryModel, it, soleSegmentName(), responsePrefix = null) }, response = response, responseFormat = ResponseFormat.Json, ) @@ -165,7 +165,7 @@ class SingleSegmentedCoOccurrenceController( lapisResponseStreamer.streamData( request = request, - getData = { getCoOccurrenceCollection(siloQueryModel, it, soleSegmentName()) }, + getData = { getCoOccurrenceCollection(siloQueryModel, it, soleSegmentName(), responsePrefix = null) }, response = response, responseFormat = ResponseFormat.Csv(delimiter = COMMA, acceptHeader = httpHeaders.accept), ) @@ -227,7 +227,7 @@ class SingleSegmentedCoOccurrenceController( lapisResponseStreamer.streamData( request = request, - getData = { getCoOccurrenceCollection(siloQueryModel, it, soleSegmentName()) }, + getData = { getCoOccurrenceCollection(siloQueryModel, it, soleSegmentName(), responsePrefix = null) }, response = response, responseFormat = ResponseFormat.Csv(delimiter = TAB, acceptHeader = httpHeaders.accept), ) @@ -250,7 +250,7 @@ class SingleSegmentedCoOccurrenceController( ) { lapisResponseStreamer.streamData( request = request, - getData = { getCoOccurrenceCollection(siloQueryModel, it, soleSegmentName()) }, + getData = { getCoOccurrenceCollection(siloQueryModel, it, soleSegmentName(), responsePrefix = null) }, response = response, responseFormat = ResponseFormat.Json, ) @@ -273,7 +273,7 @@ class SingleSegmentedCoOccurrenceController( ) { lapisResponseStreamer.streamData( request = request, - getData = { getCoOccurrenceCollection(siloQueryModel, it, soleSegmentName()) }, + getData = { getCoOccurrenceCollection(siloQueryModel, it, soleSegmentName(), responsePrefix = null) }, response = response, responseFormat = ResponseFormat.Csv(delimiter = COMMA, acceptHeader = httpHeaders.accept), ) @@ -296,7 +296,7 @@ class SingleSegmentedCoOccurrenceController( ) { lapisResponseStreamer.streamData( request = request, - getData = { getCoOccurrenceCollection(siloQueryModel, it, soleSegmentName()) }, + getData = { getCoOccurrenceCollection(siloQueryModel, it, soleSegmentName(), responsePrefix = null) }, response = response, responseFormat = ResponseFormat.Csv(delimiter = TAB, acceptHeader = httpHeaders.accept), ) diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/model/SiloQueryModel.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/model/SiloQueryModel.kt index 6973aea7f..c9f7c09e0 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/model/SiloQueryModel.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/model/SiloQueryModel.kt @@ -7,7 +7,6 @@ import org.genspectrum.lapis.request.CommonSequenceFilters import org.genspectrum.lapis.request.MRCASequenceFiltersRequest import org.genspectrum.lapis.request.MutationProportionsRequest import org.genspectrum.lapis.request.MutationsField -import org.genspectrum.lapis.request.OrderByField import org.genspectrum.lapis.request.OrderBySpec import org.genspectrum.lapis.request.PhyloTreeSequenceFiltersRequest import org.genspectrum.lapis.request.SequenceFiltersRequest @@ -20,15 +19,14 @@ import org.genspectrum.lapis.response.InsertionResponse import org.genspectrum.lapis.response.MutationResponse import org.genspectrum.lapis.response.PhyloSubtreeData import org.genspectrum.lapis.response.SequenceData +import org.genspectrum.lapis.silo.CoOccurrencePositionColumn import org.genspectrum.lapis.silo.SequenceType import org.genspectrum.lapis.silo.SiloAction import org.genspectrum.lapis.silo.SiloClient import org.genspectrum.lapis.silo.SiloQuery -import org.genspectrum.lapis.silo.coOccurrencePositionColumnName import org.genspectrum.lapis.silo.coOccurrenceResponseFieldName import org.genspectrum.lapis.util.toUnalignedSequenceName import org.springframework.stereotype.Component -import tools.jackson.databind.node.NullNode import java.util.stream.Stream @Component @@ -57,59 +55,26 @@ class SiloQueryModel( fun getCoOccurrence( sequenceFilters: CoOccurrenceRequest, sequenceName: String, + responsePrefix: String?, ): Stream { - val positions = sequenceFilters.positions.expandAndValidatePositions() - - val responseFieldNameToColumnName = positions.associate { - coOccurrenceResponseFieldName(sequenceName, it) to coOccurrencePositionColumnName(it) + val positions = sequenceFilters.positions.expandAndValidatePositions().map { position -> + CoOccurrencePositionColumn(position, coOccurrenceResponseFieldName(responsePrefix, position)) } - val data = siloClient.sendQuery( + return siloClient.sendQuery( SiloQuery( SiloAction.coOccurrence( sequenceName = sequenceName, positions = positions, - orderByFields = remapOrderByFields(sequenceFilters.orderByFields, responseFieldNameToColumnName), + orderByFields = sequenceFilters.orderByFields, limit = sequenceFilters.limit, offset = sequenceFilters.offset, ), siloFilterExpressionMapper.map(sequenceFilters), ), ) - - return data.map { relabelCoOccurrenceFields(it, sequenceName, positions) } } - private fun relabelCoOccurrenceFields( - data: AggregationData, - sequenceName: String, - positions: List, - ): AggregationData { - val relabeledFields = positions.associate { position -> - coOccurrenceResponseFieldName(sequenceName, position) to - (data.fields[coOccurrencePositionColumnName(position)] ?: NullNode.getInstance()) - } - return data.copy(fields = relabeledFields) - } - - /** - * The user-facing `orderBy` for co-occurrence queries refers to the relabeled response field names - * (e.g. "S:123"), but the SILO query needs to refer to the internal groupBy column names (e.g. "pos_123"). - */ - private fun remapOrderByFields( - orderBySpec: OrderBySpec, - responseFieldNameToColumnName: Map, - ): OrderBySpec = - when (orderBySpec) { - is OrderBySpec.ByFields -> OrderBySpec.ByFields( - orderBySpec.fields.map { field -> - OrderByField(responseFieldNameToColumnName[field.field] ?: field.field, field.order) - }, - ) - - is OrderBySpec.Random -> orderBySpec - } - fun computeNucleotideMutationProportions(sequenceFilters: MutationProportionsRequest): Stream { val fields = sequenceFilters.fields .let { diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/openApi/OpenApiDocs.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/openApi/OpenApiDocs.kt index e0a2debc5..fa0bd3c85 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/openApi/OpenApiDocs.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/openApi/OpenApiDocs.kt @@ -257,8 +257,9 @@ fun buildOpenApiSchema( "Co-occurrence data. Each entry represents one combination of symbols observed " + "at the requested positions, along with the number of sequences that have " + "this exact combination. The keys for the requested positions have the " + - "format ':', and their values are the nucleotide or " + - "amino acid symbol observed at that position. The key 'count' is always " + + "format ':' (or just '' for " + + "single-segmented nucleotide sequences), and their values are the nucleotide " + + "or amino acid symbol observed at that position. The key 'count' is always " + "present.", ) .required(listOf(COUNT_PROPERTY)) diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuery.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuery.kt index bb6f3b969..cf83324e7 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuery.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuery.kt @@ -186,7 +186,7 @@ sealed class SiloAction( fun coOccurrence( sequenceName: String, - positions: List, + positions: List, orderByFields: OrderBySpec = OrderBySpec.EMPTY, limit: Int? = null, offset: Int? = null, @@ -267,7 +267,7 @@ sealed class SiloAction( @JsonInclude(JsonInclude.Include.NON_EMPTY) data class CoOccurrenceAction( val sequenceName: String, - val positions: List, + val positions: List, override val orderByFields: List = emptyList(), override val randomize: RandomizeConfig? = null, override val limit: Int? = null, @@ -280,12 +280,16 @@ sealed class SiloAction( override fun ownSaneQlSteps(): List { val sequenceColumn = id(sequenceName) - val columnIdentifiers = positions.map { id(coOccurrencePositionColumnName(it)) } + val columnIdentifiers = positions.map { id(it.columnName) } val mapAssignments = positions.zip(columnIdentifiers).map { (position, columnIdentifier) -> SaneQlAssignment( name = columnIdentifier.render(), - value = SaneQlMethodCall(sequenceColumn, "at", positionalArgs = listOf(SaneQlInt(position))), + value = SaneQlMethodCall( + sequenceColumn, + "at", + positionalArgs = listOf(SaneQlInt(position.position)), + ), ) } @@ -762,19 +766,26 @@ private fun intOrNull(value: Int?): SaneQlExpression = if (value == null) SaneQl private fun floatOrNull(value: Double?): SaneQlExpression = if (value == null) SaneQlNull else SaneQlFloat(value) /** - * The name of the SILO groupBy/map column used internally for a given 1-based co-occurrence position. - * This is a valid SaneQL identifier - it does not need to be quoted since it's fully controlled by LAPIS - * (as opposed to the user-supplied sequence name). + * A 1-based co-occurrence [position] together with the [columnName] that SILO should use for it in both + * the groupBy/map step and the response. This is also the field name in the LAPIS API response, so no + * translation is needed on the way back (see [coOccurrenceResponseFieldName]). */ -fun coOccurrencePositionColumnName(position: Int) = "pos_$position" +data class CoOccurrencePositionColumn( + val position: Int, + val columnName: String, +) /** - * The field name used in the LAPIS API response for a given sequence name and 1-based co-occurrence position. + * The field name used in the LAPIS API response (and as the SILO groupBy/map column name) for a given + * 1-based co-occurrence position. + * [responsePrefix] is the sequence/segment/gene name to prefix the position with (e.g. "S" -> "S:123"). + * It is null for single-segmented nucleotide sequences, where the segment name is implicit and thus + * omitted (e.g. -> "123"), mirroring the convention used for nucleotide mutations. */ fun coOccurrenceResponseFieldName( - sequenceName: String, + responsePrefix: String?, position: Int, -) = "$sequenceName:$position" +) = if (responsePrefix == null) "$position" else "$responsePrefix:$position" enum class SequenceType { @JsonProperty("Fasta") diff --git a/lapis/src/test/kotlin/org/genspectrum/lapis/controller/CoOccurrenceControllerTest.kt b/lapis/src/test/kotlin/org/genspectrum/lapis/controller/CoOccurrenceControllerTest.kt index 3f9ae194b..a06b8747f 100644 --- a/lapis/src/test/kotlin/org/genspectrum/lapis/controller/CoOccurrenceControllerTest.kt +++ b/lapis/src/test/kotlin/org/genspectrum/lapis/controller/CoOccurrenceControllerTest.kt @@ -60,6 +60,7 @@ class CoOccurrenceControllerTest( positions = listOf(CoOccurrencePosition.Single(1), CoOccurrencePosition.Single(421)), ), "main", + "main", ) } returns Stream.of( AggregationData(48, mapOf("main:1" to StringNode("A"), "main:421" to StringNode("T"))), @@ -86,6 +87,7 @@ class CoOccurrenceControllerTest( positions = listOf(CoOccurrencePosition.Single(1), CoOccurrencePosition.Single(100)), ), "main", + "main", ) } returns Stream.empty() @@ -125,6 +127,7 @@ class CoOccurrenceControllerTest( positions = listOf(CoOccurrencePosition.Single(1), CoOccurrencePosition.Single(2)), ), "main", + "main", ) } returns Stream.of( AggregationData(5, mapOf("main:1" to StringNode("A"), "main:2" to StringNode("C"))), @@ -148,6 +151,7 @@ class CoOccurrenceControllerTest( positions = listOf(CoOccurrencePosition.Single(1), CoOccurrencePosition.Single(2)), ), "gene1", + "gene1", ) } returns Stream.of( AggregationData(3, mapOf("gene1:1" to StringNode("M"), "gene1:2" to StringNode("K"))), @@ -184,6 +188,7 @@ class CoOccurrenceControllerTest( orderByFields = OrderBySpec.EMPTY, ), "gene1", + "gene1", ) } returns Stream.empty() @@ -205,6 +210,7 @@ class CoOccurrenceControllerTest( positions = listOf(CoOccurrencePosition.Range(1, 3)), ), "gene1", + "gene1", ) } } diff --git a/lapis/src/test/kotlin/org/genspectrum/lapis/controller/MockData.kt b/lapis/src/test/kotlin/org/genspectrum/lapis/controller/MockData.kt index 922124700..701ebc38b 100644 --- a/lapis/src/test/kotlin/org/genspectrum/lapis/controller/MockData.kt +++ b/lapis/src/test/kotlin/org/genspectrum/lapis/controller/MockData.kt @@ -409,7 +409,7 @@ object MockDataForEndpoints { fun coOccurrenceMockData(sequenceName: String = "main") = MockDataCollection.create( siloQueryModelMockCall = { modelMock -> - { request: CoOccurrenceRequest -> modelMock.getCoOccurrence(request, sequenceName) } + { request: CoOccurrenceRequest -> modelMock.getCoOccurrence(request, sequenceName, sequenceName) } }, modelData = listOf( AggregationData( diff --git a/lapis/src/test/kotlin/org/genspectrum/lapis/controller/SingleSegmentedCoOccurrenceControllerTest.kt b/lapis/src/test/kotlin/org/genspectrum/lapis/controller/SingleSegmentedCoOccurrenceControllerTest.kt index 4efd7eee1..f61ccd96c 100644 --- a/lapis/src/test/kotlin/org/genspectrum/lapis/controller/SingleSegmentedCoOccurrenceControllerTest.kt +++ b/lapis/src/test/kotlin/org/genspectrum/lapis/controller/SingleSegmentedCoOccurrenceControllerTest.kt @@ -57,12 +57,13 @@ class SingleSegmentedCoOccurrenceControllerTest( positions = listOf(CoOccurrencePosition.Single(1)), ), SEGMENT_NAME, + null, ) - } returns Stream.of(AggregationData(1, mapOf("$SEGMENT_NAME:1" to StringNode("A")))) + } returns Stream.of(AggregationData(1, mapOf("1" to StringNode("A")))) mockMvc.perform(get("/component/nucleotideCoOccurrence?positions=1")) .andExpect(status().isOk) - .andExpect(jsonPath("\$.data[0]['$SEGMENT_NAME:1']").value("A")) + .andExpect(jsonPath("\$.data[0]['1']").value("A")) .andExpect(jsonPath("\$.data[0].count").value(1)) } diff --git a/lapis/src/test/kotlin/org/genspectrum/lapis/model/SiloQueryModelTest.kt b/lapis/src/test/kotlin/org/genspectrum/lapis/model/SiloQueryModelTest.kt index 8f7a3e084..a43451f2d 100644 --- a/lapis/src/test/kotlin/org/genspectrum/lapis/model/SiloQueryModelTest.kt +++ b/lapis/src/test/kotlin/org/genspectrum/lapis/model/SiloQueryModelTest.kt @@ -32,6 +32,7 @@ import org.genspectrum.lapis.response.InsertionResponse import org.genspectrum.lapis.response.MutationData import org.genspectrum.lapis.response.MutationResponse import org.genspectrum.lapis.response.SequenceData +import org.genspectrum.lapis.silo.CoOccurrencePositionColumn import org.genspectrum.lapis.silo.SequenceType import org.genspectrum.lapis.silo.SiloAction import org.genspectrum.lapis.silo.SiloClient @@ -533,9 +534,9 @@ class SiloQueryModelTest { } @Test - fun `getCoOccurrence calls SILO with a coOccurrence action and relabels response fields`() { + fun `getCoOccurrence calls SILO naming the columns with the prefixed response field names`() { every { siloClientMock.sendQuery(any>()) } returns Stream.of( - AggregationData(48, mapOf("pos_1" to StringNode("A"), "pos_421" to StringNode("T"))), + AggregationData(48, mapOf("segment1:1" to StringNode("A"), "segment1:421" to StringNode("T"))), ) every { siloFilterExpressionMapperMock.map(any()) } returns True @@ -549,11 +550,21 @@ class SiloQueryModelTest { positions = listOf(CoOccurrencePosition.Single(1), CoOccurrencePosition.Single(421)), ), sequenceName = "segment1", + responsePrefix = "segment1", ).toList() verify { siloClientMock.sendQuery( - SiloQuery(SiloAction.coOccurrence("segment1", listOf(1, 421)), True), + SiloQuery( + SiloAction.coOccurrence( + "segment1", + listOf( + CoOccurrencePositionColumn(1, "segment1:1"), + CoOccurrencePositionColumn(421, "segment1:421"), + ), + ), + True, + ), ) } @@ -571,10 +582,49 @@ class SiloQueryModelTest { } @Test - fun `getCoOccurrence remaps orderBy fields from response field names to internal column names`() { + fun `getCoOccurrence with a null responsePrefix names the columns with bare positions`() { every { siloClientMock.sendQuery(any>()) } returns Stream.empty() every { siloFilterExpressionMapperMock.map(any()) } returns True + underTest.getCoOccurrence( + CoOccurrenceRequest( + sequenceFilters = emptyMap(), + nucleotideMutations = emptyList(), + aminoAcidMutations = emptyList(), + nucleotideInsertions = emptyList(), + aminoAcidInsertions = emptyList(), + positions = listOf(CoOccurrencePosition.Single(1), CoOccurrencePosition.Single(421)), + ), + sequenceName = "main", + responsePrefix = null, + ).toList() + + verify { + siloClientMock.sendQuery( + SiloQuery( + SiloAction.coOccurrence( + "main", + listOf( + CoOccurrencePositionColumn(1, "1"), + CoOccurrencePositionColumn(421, "421"), + ), + ), + True, + ), + ) + } + } + + @Test + fun `getCoOccurrence passes orderBy fields to SILO unchanged`() { + every { siloClientMock.sendQuery(any>()) } returns Stream.empty() + every { siloFilterExpressionMapperMock.map(any()) } returns True + + val orderByFields = listOf( + OrderByField("segment1:1", Order.DESCENDING), + OrderByField("count", Order.ASCENDING), + ).toOrderBySpec() + underTest.getCoOccurrence( CoOccurrenceRequest( sequenceFilters = emptyMap(), @@ -583,12 +633,10 @@ class SiloQueryModelTest { nucleotideInsertions = emptyList(), aminoAcidInsertions = emptyList(), positions = listOf(CoOccurrencePosition.Single(1)), - orderByFields = listOf( - OrderByField("segment1:1", Order.DESCENDING), - OrderByField("count", Order.ASCENDING), - ).toOrderBySpec(), + orderByFields = orderByFields, ), sequenceName = "segment1", + responsePrefix = "segment1", ).toList() verify { @@ -596,11 +644,8 @@ class SiloQueryModelTest { SiloQuery( SiloAction.coOccurrence( sequenceName = "segment1", - positions = listOf(1), - orderByFields = listOf( - OrderByField("pos_1", Order.DESCENDING), - OrderByField("count", Order.ASCENDING), - ).toOrderBySpec(), + positions = listOf(CoOccurrencePositionColumn(1, "segment1:1")), + orderByFields = orderByFields, ), True, ), diff --git a/lapis/src/test/kotlin/org/genspectrum/lapis/silo/SiloQueryToSaneQlTest.kt b/lapis/src/test/kotlin/org/genspectrum/lapis/silo/SiloQueryToSaneQlTest.kt index ee0e48796..475d22490 100644 --- a/lapis/src/test/kotlin/org/genspectrum/lapis/silo/SiloQueryToSaneQlTest.kt +++ b/lapis/src/test/kotlin/org/genspectrum/lapis/silo/SiloQueryToSaneQlTest.kt @@ -272,26 +272,35 @@ class SiloQueryToSaneQlTest { ), // CoOccurrence Arguments.of( - SiloAction.coOccurrence("segment1", listOf(1)), - """.map({"pos_1":="segment1".at(1)}).groupBy({count:=count()}, {"pos_1"})""", + SiloAction.coOccurrence("segment1", listOf(CoOccurrencePositionColumn(1, "segment1:1"))), + """.map({"segment1:1":="segment1".at(1)}).groupBy({count:=count()}, {"segment1:1"})""", ), Arguments.of( - SiloAction.coOccurrence("segment1", listOf(1, 421)), - """.map({"pos_1":="segment1".at(1), "pos_421":="segment1".at(421)})""" + - """.groupBy({count:=count()}, {"pos_1", "pos_421"})""", + SiloAction.coOccurrence( + "segment1", + listOf( + CoOccurrencePositionColumn(1, "segment1:1"), + CoOccurrencePositionColumn(421, "segment1:421"), + ), + ), + """.map({"segment1:1":="segment1".at(1), "segment1:421":="segment1".at(421)})""" + + """.groupBy({count:=count()}, {"segment1:1", "segment1:421"})""", ), Arguments.of( SiloAction.coOccurrence( - sequenceName = "segment1", - positions = listOf(1, 2), + sequenceName = "main", + positions = listOf( + CoOccurrencePositionColumn(1, "1"), + CoOccurrencePositionColumn(2, "2"), + ), orderByFields = listOf( OrderByField("count", Order.DESCENDING), ).toOrderBySpec(), limit = 100, offset = 50, ), - """.map({"pos_1":="segment1".at(1), "pos_2":="segment1".at(2)})""" + - """.groupBy({count:=count()}, {"pos_1", "pos_2"})""" + + """.map({"1":="main".at(1), "2":="main".at(2)})""" + + """.groupBy({count:=count()}, {"1", "2"})""" + """.orderBy({"count".desc()}).offset(50).limit(100)""", ), // MostRecentCommonAncestor From d736ab52b7b892c02b0b533c578305412299c960 Mon Sep 17 00:00:00 2001 From: Fabian Engelniederhammer Date: Mon, 13 Jul 2026 08:24:19 +0200 Subject: [PATCH 8/9] 1742 fix openapi docs --- .../genspectrum/lapis/openApi/OpenApiDocs.kt | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/openApi/OpenApiDocs.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/openApi/OpenApiDocs.kt index fa0bd3c85..c2bcebc05 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/openApi/OpenApiDocs.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/openApi/OpenApiDocs.kt @@ -253,15 +253,7 @@ fun buildOpenApiSchema( lapisArrayResponseSchema( Schema() .types(setOf("object")) - .description( - "Co-occurrence data. Each entry represents one combination of symbols observed " + - "at the requested positions, along with the number of sequences that have " + - "this exact combination. The keys for the requested positions have the " + - "format ':' (or just '' for " + - "single-segmented nucleotide sequences), and their values are the nucleotide " + - "or amino acid symbol observed at that position. The key 'count' is always " + - "present.", - ) + .description(coOccurrenceResponseDescription(referenceGenomeSchema)) .required(listOf(COUNT_PROPERTY)) .properties( mapOf( @@ -669,6 +661,21 @@ private fun stringSchema(type: String) = Schema() .types(setOf(type)) +private fun coOccurrenceResponseDescription(referenceGenomeSchema: ReferenceGenomeSchema): String { + val keyFormat = if (referenceGenomeSchema.isSingleSegmented()) { + "For nucleotide co-occurrence the keys are the requested positions (''), and for amino acid " + + "co-occurrence they have the format ':'." + } else { + "The keys for the requested positions have the format ':' (nucleotides) or " + + "':' (amino acids)." + } + + return "Co-occurrence data. Each entry represents one combination of symbols observed at the requested " + + "positions, along with the number of sequences that have this exact combination. $keyFormat " + + "Their values are the nucleotide or amino acid symbol observed at that position. " + + "The key 'count' is always present." +} + private fun requestSchemaForCommonSequenceFilters( requestProperties: Map>, ): Schema<*> = From 1e7cf1e53382e9bbb57ba3ed04d94cef5da51433 Mon Sep 17 00:00:00 2001 From: Fabian Engelniederhammer Date: Thu, 16 Jul 2026 08:51:54 +0200 Subject: [PATCH 9/9] 1742 ? --- .../main/kotlin/org/genspectrum/lapis/silo/SiloQuery.kt | 3 +++ .../org/genspectrum/lapis/silo/SiloQueryToSaneQlTest.kt | 8 +++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuery.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuery.kt index cf83324e7..fe4f73ad3 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuery.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuery.kt @@ -294,6 +294,9 @@ sealed class SiloAction( } return listOf( + // Narrow down to just the sequence column before mapping, so that the map column names + // (e.g. "S:478") can't collide with any other (e.g. metadata) column in the table. + SaneQlStep("project", positionalArgs = listOf(SaneQlList(listOf(sequenceColumn)))), SaneQlStep("map", positionalArgs = listOf(SaneQlList(mapAssignments))), SaneQlStep( "groupBy", diff --git a/lapis/src/test/kotlin/org/genspectrum/lapis/silo/SiloQueryToSaneQlTest.kt b/lapis/src/test/kotlin/org/genspectrum/lapis/silo/SiloQueryToSaneQlTest.kt index 475d22490..ceec3af63 100644 --- a/lapis/src/test/kotlin/org/genspectrum/lapis/silo/SiloQueryToSaneQlTest.kt +++ b/lapis/src/test/kotlin/org/genspectrum/lapis/silo/SiloQueryToSaneQlTest.kt @@ -273,7 +273,8 @@ class SiloQueryToSaneQlTest { // CoOccurrence Arguments.of( SiloAction.coOccurrence("segment1", listOf(CoOccurrencePositionColumn(1, "segment1:1"))), - """.map({"segment1:1":="segment1".at(1)}).groupBy({count:=count()}, {"segment1:1"})""", + """.project({"segment1"}).map({"segment1:1":="segment1".at(1)})""" + + """.groupBy({count:=count()}, {"segment1:1"})""", ), Arguments.of( SiloAction.coOccurrence( @@ -283,7 +284,8 @@ class SiloQueryToSaneQlTest { CoOccurrencePositionColumn(421, "segment1:421"), ), ), - """.map({"segment1:1":="segment1".at(1), "segment1:421":="segment1".at(421)})""" + + """.project({"segment1"})""" + + """.map({"segment1:1":="segment1".at(1), "segment1:421":="segment1".at(421)})""" + """.groupBy({count:=count()}, {"segment1:1", "segment1:421"})""", ), Arguments.of( @@ -299,7 +301,7 @@ class SiloQueryToSaneQlTest { limit = 100, offset = 50, ), - """.map({"1":="main".at(1), "2":="main".at(2)})""" + + """.project({"main"}).map({"1":="main".at(1), "2":="main".at(2)})""" + """.groupBy({count:=count()}, {"1", "2"})""" + """.orderBy({"count".desc()}).offset(50).limit(100)""", ),