From 0e9b00c16ae8a2f267d5413f2963e46a28af969e Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Tue, 14 Jul 2026 17:20:51 +0200 Subject: [PATCH 1/8] feat(lapis): add sequence position fields syntax to aggregated endpoint Allow users to request specific sequence positions as group-by fields using `Name[N]` syntax (e.g. `S[501]`). For genomes with a sequence named `main`, the shorthand `[N]` is also accepted. Position fields generate a `.map(...)` step in SaneQL before `.groupBy(...)`, and appear in the response under the alias `Name_N` (e.g. `S_501`). Co-Authored-By: Claude Sonnet 4.6 --- .../controller/ControllerDescriptions.kt | 6 +- .../lapis/controller/LapisController.kt | 5 +- .../genspectrum/lapis/model/SiloQueryModel.kt | 13 ++-- .../org/genspectrum/lapis/request/Field.kt | 75 ++++++++++++++++--- .../PhyloTreeSequenceFiltersRequest.kt | 2 +- .../SequenceFiltersRequestWithFields.kt | 2 +- .../org/genspectrum/lapis/silo/SiloQuery.kt | 51 ++++++++++--- .../SequenceFiltersRequestWithFieldsTest.kt | 72 ++++++++++++++++++ .../lapis/silo/SiloQueryToSaneQlTest.kt | 17 +++++ 9 files changed, 213 insertions(+), 30 deletions(-) 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..835c483c4 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/ControllerDescriptions.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/ControllerDescriptions.kt @@ -68,7 +68,11 @@ such as combinations of mutations ("mutation 1 or mutation 2") and other filters const val AGGREGATED_GROUP_BY_FIELDS_DESCRIPTION = """The fields to stratify by. If empty, only the overall count is returned. -If requesting CSV or TSV data, the columns are ordered in the same order as the fields are specified here.""" +If requesting CSV or TSV data, the columns are ordered in the same order as the fields are specified here. + +Sequence positions can be requested using the syntax `SequenceName[position]` (e.g. `S[501]` for position 501 of gene S). +For single-segmented genomes with a sequence named `main`, the shorthand `[position]` (e.g. `[501]`) can be used. +Position fields appear in the response under the alias `SequenceName_position` (e.g. `S_501`).""" const val AGGREGATED_ORDER_BY_FIELDS_DESCRIPTION = """The fields of the response to order by. Fields specified here must either be \"count\" or also be present in \"fields\". diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt index 82e6fb64b..8bd9c51fa 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt @@ -61,6 +61,7 @@ import org.genspectrum.lapis.openApi.TreeDataFormatParam import org.genspectrum.lapis.request.AminoAcidInsertion import org.genspectrum.lapis.request.AminoAcidMutation import org.genspectrum.lapis.request.CaseInsensitiveFieldConverter +import org.genspectrum.lapis.request.Field import org.genspectrum.lapis.request.DEFAULT_MIN_PROPORTION import org.genspectrum.lapis.request.GetRequestSequenceFilters import org.genspectrum.lapis.request.MRCASequenceFiltersRequest @@ -371,7 +372,7 @@ class LapisController( private fun getAggregatedCollection(request: SequenceFiltersRequestWithFields): AggregatedCollection = AggregatedCollection( records = siloQueryModel.getAggregated(request), - fields = request.fields.map { it.fieldName }, + fields = request.fields.map { it.outputColumnName }, ) @GetMapping(NUCLEOTIDE_MUTATIONS_ROUTE, produces = [MediaType.APPLICATION_JSON_VALUE]) @@ -1490,7 +1491,7 @@ class LapisController( } private fun getDetailsCollection(request: SequenceFiltersRequestWithFields): DetailsCollection { - val fields = request.fields.map { it.fieldName } + val fields = request.fields.filterIsInstance().map { it.fieldName } return DetailsCollection( records = siloQueryModel.getDetails(request), 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..94f2d2f60 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/model/SiloQueryModel.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/model/SiloQueryModel.kt @@ -3,6 +3,7 @@ package org.genspectrum.lapis.model import org.genspectrum.lapis.config.DatabaseConfig import org.genspectrum.lapis.config.ReferenceGenomeSchema import org.genspectrum.lapis.request.CommonSequenceFilters +import org.genspectrum.lapis.request.Field import org.genspectrum.lapis.request.MRCASequenceFiltersRequest import org.genspectrum.lapis.request.MutationProportionsRequest import org.genspectrum.lapis.request.MutationsField @@ -10,6 +11,7 @@ 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.SequencePositionField import org.genspectrum.lapis.response.ExplicitlyNullable import org.genspectrum.lapis.response.InfoData import org.genspectrum.lapis.response.InsertionResponse @@ -38,10 +40,11 @@ class SiloQueryModel( siloClient.sendQuery( SiloQuery( SiloAction.aggregated( - sequenceFilters.fields.map { it.fieldName }, - sequenceFilters.orderByFields, - sequenceFilters.limit, - sequenceFilters.offset, + groupByFields = sequenceFilters.fields.filterIsInstance().map { it.fieldName }, + orderByFields = sequenceFilters.orderByFields, + limit = sequenceFilters.limit, + offset = sequenceFilters.offset, + sequencePositionFields = sequenceFilters.fields.filterIsInstance(), ), siloFilterExpressionMapper.map(sequenceFilters), ), @@ -140,7 +143,7 @@ class SiloQueryModel( siloClient.sendQuery( SiloQuery( SiloAction.details( - sequenceFilters.fields.map { it.fieldName }.ifEmpty { allMetadataFields }, + sequenceFilters.fields.filterIsInstance().map { it.fieldName }.ifEmpty { allMetadataFields }, sequenceFilters.orderByFields, sequenceFilters.limit, sequenceFilters.offset, diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/request/Field.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/request/Field.kt index 2e9c8b6a7..62c5d259f 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/request/Field.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/request/Field.kt @@ -1,12 +1,30 @@ package org.genspectrum.lapis.request import org.genspectrum.lapis.config.DatabaseConfig +import org.genspectrum.lapis.config.ReferenceGenomeSchema import org.genspectrum.lapis.controller.BadRequestException import org.springframework.stereotype.Component +private val SEQUENCE_POSITION_REGEX = Regex("""^([A-Za-z][A-Za-z0-9_]*)\[(\d+)\]$""") +private val SHORTHAND_POSITION_REGEX = Regex("""^\[(\d+)\]$""") + +sealed interface RequestField { + val outputColumnName: String +} + data class Field( val fieldName: String, -) +) : RequestField { + override val outputColumnName: String get() = fieldName +} + +data class SequencePositionField( + val sequenceName: String, + val position: Int, +) : RequestField { + val columnAlias: String get() = "${sequenceName}_${position}" + override val outputColumnName: String get() = columnAlias +} fun interface FieldConverter { fun convert(source: String): T @@ -17,25 +35,59 @@ fun interface FieldConverter { @Component class CaseInsensitiveFieldConverter( private val caseInsensitiveFieldsCleaner: CaseInsensitiveFieldsCleaner, -) : FieldConverter { - override fun convert(source: String): Field { + private val referenceGenomeSchema: ReferenceGenomeSchema, +) : FieldConverter { + override fun convert(source: String): RequestField { + val shorthandMatch = SHORTHAND_POSITION_REGEX.matchEntire(source) + if (shorthandMatch != null) { + val position = shorthandMatch.groupValues[1].toIntOrNull() + ?: throw BadRequestException("Invalid position in '$source': must be a positive integer") + if (position <= 0) { + throw BadRequestException("Invalid position in '$source': must be a positive integer, got $position") + } + val canonicalName = referenceGenomeSchema.getSequenceNameFromCaseInsensitiveName("main") + ?: throw BadRequestException( + "Shorthand position syntax '[N]' requires a sequence named 'main' to exist", + ) + return SequencePositionField(canonicalName, position) + } + + val positionMatch = SEQUENCE_POSITION_REGEX.matchEntire(source) + if (positionMatch != null) { + val name = positionMatch.groupValues[1] + val position = positionMatch.groupValues[2].toIntOrNull() + ?: throw BadRequestException("Invalid position in '$source': must be a positive integer") + if (position <= 0) { + throw BadRequestException("Invalid position in '$source': must be a positive integer, got $position") + } + val canonicalName = referenceGenomeSchema.getSequenceNameFromCaseInsensitiveName(name) + ?: throw BadRequestException( + "Unknown sequence '$name' in '$source', known sequences are: " + + (referenceGenomeSchema.getNucleotideSequenceNames() + referenceGenomeSchema.getGeneNames()) + .joinToString(", "), + ) + return SequencePositionField(canonicalName, position) + } + val cleaned = caseInsensitiveFieldsCleaner.clean(source) ?: throw BadRequestException( "Unknown field: '$source', known values are ${caseInsensitiveFieldsCleaner.getKnownFields()}", ) - return Field(cleaned) } - override fun validatePhyloTreeFields(source: String): Field { + override fun validatePhyloTreeFields(source: String): RequestField { val converted = convert(source) + if (converted !is Field) { + throw BadRequestException( + "Position fields like '$source' cannot be used as phylo tree fields", + ) + } val validFields = caseInsensitiveFieldsCleaner.getPhyloTreeFields() if (converted.fieldName !in validFields) { throw BadRequestException( "Field '${converted.fieldName}' is not a phylo tree field, " + - "known phylo tree fields are [${validFields.joinToString( - ", ", - )}]", + "known phylo tree fields are [${validFields.joinToString(", ")}]", ) } return converted @@ -44,10 +96,15 @@ class CaseInsensitiveFieldConverter( fun validatePhyloTreeField( source: String, - fieldConverter: FieldConverter, + fieldConverter: FieldConverter, databaseConfig: DatabaseConfig, ): Field { val converted = fieldConverter.convert(source) + if (converted !is Field) { + throw BadRequestException( + "Position fields like '$source' cannot be used as phylo tree fields", + ) + } val validFields = databaseConfig.schema.metadata.filter { it.isPhyloTreeField }.map { it.name } if (converted.fieldName !in validFields) { throw BadRequestException( diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/request/PhyloTreeSequenceFiltersRequest.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/request/PhyloTreeSequenceFiltersRequest.kt index 21902f13f..7dfc8de00 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/request/PhyloTreeSequenceFiltersRequest.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/request/PhyloTreeSequenceFiltersRequest.kt @@ -94,7 +94,7 @@ class MRCASequenceFiltersRequestDeserializer( fun parsePhyloTreeProperty( node: JsonNode, - fieldConverter: FieldConverter, + fieldConverter: FieldConverter, databaseConfig: DatabaseConfig, ): Field { val phyloTreeField = node.get(PHYLO_TREE_FIELD_PROPERTY) diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/request/SequenceFiltersRequestWithFields.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/request/SequenceFiltersRequestWithFields.kt index f4e91f0c6..5b55405cb 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/request/SequenceFiltersRequestWithFields.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/request/SequenceFiltersRequestWithFields.kt @@ -14,7 +14,7 @@ data class SequenceFiltersRequestWithFields( override val aminoAcidMutations: List, override val nucleotideInsertions: List, override val aminoAcidInsertions: List, - val fields: List, + val fields: List, override val orderByFields: OrderBySpec = OrderBySpec.EMPTY, override val limit: Int? = null, override val offset: Int? = null, 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 8cd4abe95..8cde88f5e 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuery.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuery.kt @@ -6,6 +6,7 @@ import com.fasterxml.jackson.annotation.JsonProperty import org.genspectrum.lapis.request.Order import org.genspectrum.lapis.request.OrderByField import org.genspectrum.lapis.request.OrderBySpec +import org.genspectrum.lapis.request.SequencePositionField import org.genspectrum.lapis.response.AggregationData import org.genspectrum.lapis.response.DetailsData import org.genspectrum.lapis.response.InsertionData @@ -87,9 +88,11 @@ sealed class SiloAction( orderByFields: OrderBySpec = OrderBySpec.EMPTY, limit: Int? = null, offset: Int? = null, + sequencePositionFields: List = emptyList(), ): SiloAction = AggregatedAction( groupByFields = groupByFields, + sequencePositionFields = sequencePositionFields, orderByFields = getOrderByFieldsList(orderByFields), randomize = getRandomize(orderByFields), limit = limit, @@ -224,6 +227,7 @@ sealed class SiloAction( @JsonInclude(JsonInclude.Include.NON_EMPTY) data class AggregatedAction( val groupByFields: List, + val sequencePositionFields: List = emptyList(), override val orderByFields: List = emptyList(), override val randomize: RandomizeConfig? = null, override val limit: Int? = null, @@ -235,17 +239,42 @@ sealed class SiloAction( val type: String = "Aggregated" override fun ownSaneQlSteps() = - listOf( - SaneQlStep( - "groupBy", - positionalArgs = buildList { - add(SaneQlList(listOf(SaneQlAssignment("count", SaneQlFunctionCall("count"))))) - if (groupByFields.isNotEmpty()) { - add(SaneQlList(groupByFields.map { id(it) })) - } - }, - ), - ) + buildList { + if (sequencePositionFields.isNotEmpty()) { + add( + SaneQlStep( + "map", + positionalArgs = listOf( + SaneQlList( + sequencePositionFields.map { field -> + SaneQlAssignment( + field.columnAlias, + SaneQlMethodCall( + SaneQlIdentifier(field.sequenceName), + "at", + listOf(SaneQlInt(field.position)), + ), + ) + }, + ), + ), + ), + ) + } + val allGroupByColumns = + groupByFields.map { id(it) } + sequencePositionFields.map { id(it.columnAlias) } + add( + SaneQlStep( + "groupBy", + positionalArgs = buildList { + add(SaneQlList(listOf(SaneQlAssignment("count", SaneQlFunctionCall("count"))))) + if (allGroupByColumns.isNotEmpty()) { + add(SaneQlList(allGroupByColumns)) + } + }, + ), + ) + } } @JsonInclude(JsonInclude.Include.NON_EMPTY) diff --git a/lapis/src/test/kotlin/org/genspectrum/lapis/request/SequenceFiltersRequestWithFieldsTest.kt b/lapis/src/test/kotlin/org/genspectrum/lapis/request/SequenceFiltersRequestWithFieldsTest.kt index ff61b7bc2..2c5e902df 100644 --- a/lapis/src/test/kotlin/org/genspectrum/lapis/request/SequenceFiltersRequestWithFieldsTest.kt +++ b/lapis/src/test/kotlin/org/genspectrum/lapis/request/SequenceFiltersRequestWithFieldsTest.kt @@ -31,6 +31,17 @@ class SequenceFiltersRequestWithFieldsTest { assertThat(result, equalTo(expected)) } + @ParameterizedTest + @MethodSource("getSequencePositionFieldTestCases") + fun `SequenceFiltersRequestWithFields correctly parses sequence position fields`( + input: String, + expected: SequenceFiltersRequestWithFields, + ) { + val result = objectMapper.readValue(input) + + assertThat(result, equalTo(expected)) + } + @ParameterizedTest @MethodSource("getInvalidRequests") fun `Given invalid SequenceFiltersRequestWithFields then should throw an error`( @@ -222,9 +233,70 @@ class SequenceFiltersRequestWithFieldsTest { ), ) + @JvmStatic + fun getSequencePositionFieldTestCases() = + listOf( + Arguments.of( + """{"fields": ["gene1[123]"]}""", + SequenceFiltersRequestWithFields( + emptyMap(), + emptyList(), + emptyList(), + emptyList(), + emptyList(), + listOf(SequencePositionField("gene1", 123)), + ), + ), + Arguments.of( + """{"fields": ["[456]"]}""", + SequenceFiltersRequestWithFields( + emptyMap(), + emptyList(), + emptyList(), + emptyList(), + emptyList(), + listOf(SequencePositionField("main", 456)), + ), + ), + Arguments.of( + """{"fields": ["GENE1[1]"]}""", + SequenceFiltersRequestWithFields( + emptyMap(), + emptyList(), + emptyList(), + emptyList(), + emptyList(), + listOf(SequencePositionField("gene1", 1)), + ), + ), + Arguments.of( + """{"fields": ["gene1[7]", "country", "gene2[42]"]}""", + SequenceFiltersRequestWithFields( + emptyMap(), + emptyList(), + emptyList(), + emptyList(), + emptyList(), + listOf( + SequencePositionField("gene1", 7), + Field("country"), + SequencePositionField("gene2", 42), + ), + ), + ), + ) + @JvmStatic fun getInvalidRequests() = listOf( + Arguments.of( + """{"fields": ["unknownSequence[1]"]}""", + "Unknown sequence 'unknownSequence'", + ), + Arguments.of( + """{"fields": ["gene1[0]"]}""", + "Invalid position in 'gene1[0]': must be a positive integer", + ), Arguments.of( """ { 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 d50623fab..931586f8a 100644 --- a/lapis/src/test/kotlin/org/genspectrum/lapis/silo/SiloQueryToSaneQlTest.kt +++ b/lapis/src/test/kotlin/org/genspectrum/lapis/silo/SiloQueryToSaneQlTest.kt @@ -3,6 +3,7 @@ package org.genspectrum.lapis.silo import org.genspectrum.lapis.request.Order import org.genspectrum.lapis.request.OrderByField import org.genspectrum.lapis.request.OrderBySpec +import org.genspectrum.lapis.request.SequencePositionField import org.genspectrum.lapis.request.toOrderBySpec import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo @@ -138,6 +139,22 @@ class SiloQueryToSaneQlTest { ), ".groupBy({count:=count()}).randomize(seed:=42).limit(10)", ), + Arguments.of( + SiloAction.aggregated( + groupByFields = listOf("country"), + sequencePositionFields = listOf(SequencePositionField("S", 501)), + ), + """.map({S_501:="S".at(501)}).groupBy({count:=count()}, {"country", "S_501"})""", + ), + Arguments.of( + SiloAction.aggregated( + sequencePositionFields = listOf( + SequencePositionField("S", 123), + SequencePositionField("main", 456), + ), + ), + """.map({S_123:="S".at(123), main_456:="main".at(456)}).groupBy({count:=count()}, {"S_123", "main_456"})""", + ), // Mutations Arguments.of( SiloAction.mutations(), From 15df66004c01902e943e20b60f7f1fe2791c0ed3 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Tue, 14 Jul 2026 17:42:33 +0200 Subject: [PATCH 2/8] feat(lapis): return position fields under user-facing name in response Echo S[501] back as the response column key instead of the internal SaneQL alias S_501, so input and output are symmetric for callers. Co-Authored-By: Claude Sonnet 4.6 --- .../lapis/controller/ControllerDescriptions.kt | 2 +- .../lapis/controller/LapisController.kt | 17 ++++++++++++++--- .../org/genspectrum/lapis/request/Field.kt | 5 ++++- 3 files changed, 19 insertions(+), 5 deletions(-) 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 835c483c4..bf2ac8e43 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/ControllerDescriptions.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/ControllerDescriptions.kt @@ -72,7 +72,7 @@ If requesting CSV or TSV data, the columns are ordered in the same order as the Sequence positions can be requested using the syntax `SequenceName[position]` (e.g. `S[501]` for position 501 of gene S). For single-segmented genomes with a sequence named `main`, the shorthand `[position]` (e.g. `[501]`) can be used. -Position fields appear in the response under the alias `SequenceName_position` (e.g. `S_501`).""" +Position fields appear in the response under the same name as requested (e.g. `S[501]`).""" const val AGGREGATED_ORDER_BY_FIELDS_DESCRIPTION = """The fields of the response to order by. Fields specified here must either be \"count\" or also be present in \"fields\". diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt index 8bd9c51fa..e7a5ff079 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt @@ -62,6 +62,7 @@ import org.genspectrum.lapis.request.AminoAcidInsertion import org.genspectrum.lapis.request.AminoAcidMutation import org.genspectrum.lapis.request.CaseInsensitiveFieldConverter import org.genspectrum.lapis.request.Field +import org.genspectrum.lapis.request.SequencePositionField import org.genspectrum.lapis.request.DEFAULT_MIN_PROPORTION import org.genspectrum.lapis.request.GetRequestSequenceFilters import org.genspectrum.lapis.request.MRCASequenceFiltersRequest @@ -369,11 +370,21 @@ class LapisController( ) } - private fun getAggregatedCollection(request: SequenceFiltersRequestWithFields): AggregatedCollection = - AggregatedCollection( - records = siloQueryModel.getAggregated(request), + private fun getAggregatedCollection(request: SequenceFiltersRequestWithFields): AggregatedCollection { + val aliasToUserFacingName = request.fields + .filterIsInstance() + .associate { it.columnAlias to it.userFacingName } + val records = siloQueryModel.getAggregated(request).let { stream -> + if (aliasToUserFacingName.isEmpty()) stream + else stream.map { data -> + data.copy(fields = data.fields.mapKeys { aliasToUserFacingName[it.key] ?: it.key }) + } + } + return AggregatedCollection( + records = records, fields = request.fields.map { it.outputColumnName }, ) + } @GetMapping(NUCLEOTIDE_MUTATIONS_ROUTE, produces = [MediaType.APPLICATION_JSON_VALUE]) @LapisNucleotideMutationsResponse diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/request/Field.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/request/Field.kt index 62c5d259f..d6d3636ef 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/request/Field.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/request/Field.kt @@ -22,8 +22,11 @@ data class SequencePositionField( val sequenceName: String, val position: Int, ) : RequestField { + /** Internal SaneQL alias, e.g. `S_501`. Used as the column name inside the query pipeline. */ val columnAlias: String get() = "${sequenceName}_${position}" - override val outputColumnName: String get() = columnAlias + /** User-facing name echoed back in the response, e.g. `S[501]`. */ + val userFacingName: String get() = "${sequenceName}[${position}]" + override val outputColumnName: String get() = userFacingName } fun interface FieldConverter { From 18eaccb51223f6ddc0a51f0866e581dc6bb95309 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Tue, 14 Jul 2026 17:57:33 +0200 Subject: [PATCH 3/8] feat(lapis): use isSingleSegmented() for shorthand syntax; echo [N] back in response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shorthand [N] now validates via isSingleSegmented() (consistent with how the rest of the codebase handles single-segment-only features) rather than checking for a hardcoded 'main' name. SequencePositionField gains isSingleSegment flag so the response key mirrors the input: [501] in → [501] out, S[501] in → S[501] out. Co-Authored-By: Claude Sonnet 4.6 --- .../lapis/controller/ControllerDescriptions.kt | 2 +- .../kotlin/org/genspectrum/lapis/request/Field.kt | 15 +++++++++------ .../SequenceFiltersRequestWithFieldsTest.kt | 15 ++++----------- 3 files changed, 14 insertions(+), 18 deletions(-) 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 bf2ac8e43..1eafb44af 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/ControllerDescriptions.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/ControllerDescriptions.kt @@ -71,7 +71,7 @@ If empty, only the overall count is returned. If requesting CSV or TSV data, the columns are ordered in the same order as the fields are specified here. Sequence positions can be requested using the syntax `SequenceName[position]` (e.g. `S[501]` for position 501 of gene S). -For single-segmented genomes with a sequence named `main`, the shorthand `[position]` (e.g. `[501]`) can be used. +For single-segmented genomes, the shorthand `[position]` (e.g. `[501]`) can be used. Position fields appear in the response under the same name as requested (e.g. `S[501]`).""" const val AGGREGATED_ORDER_BY_FIELDS_DESCRIPTION = """The fields of the response to order by. diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/request/Field.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/request/Field.kt index d6d3636ef..fb3c952d8 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/request/Field.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/request/Field.kt @@ -21,11 +21,12 @@ data class Field( data class SequencePositionField( val sequenceName: String, val position: Int, + val isSingleSegment: Boolean = false, ) : RequestField { /** Internal SaneQL alias, e.g. `S_501`. Used as the column name inside the query pipeline. */ val columnAlias: String get() = "${sequenceName}_${position}" - /** User-facing name echoed back in the response, e.g. `S[501]`. */ - val userFacingName: String get() = "${sequenceName}[${position}]" + /** User-facing name echoed back in the response, e.g. `S[501]` or `[501]` for single-segment shorthand. */ + val userFacingName: String get() = if (isSingleSegment) "[${position}]" else "${sequenceName}[${position}]" override val outputColumnName: String get() = userFacingName } @@ -48,11 +49,13 @@ class CaseInsensitiveFieldConverter( if (position <= 0) { throw BadRequestException("Invalid position in '$source': must be a positive integer, got $position") } - val canonicalName = referenceGenomeSchema.getSequenceNameFromCaseInsensitiveName("main") - ?: throw BadRequestException( - "Shorthand position syntax '[N]' requires a sequence named 'main' to exist", + if (!referenceGenomeSchema.isSingleSegmented()) { + throw BadRequestException( + "Shorthand position syntax '[N]' can only be used for single-segmented genomes", ) - return SequencePositionField(canonicalName, position) + } + val canonicalName = referenceGenomeSchema.nucleotideSequences.first().name + return SequencePositionField(canonicalName, position, isSingleSegment = true) } val positionMatch = SEQUENCE_POSITION_REGEX.matchEntire(source) diff --git a/lapis/src/test/kotlin/org/genspectrum/lapis/request/SequenceFiltersRequestWithFieldsTest.kt b/lapis/src/test/kotlin/org/genspectrum/lapis/request/SequenceFiltersRequestWithFieldsTest.kt index 2c5e902df..6bdcc6fef 100644 --- a/lapis/src/test/kotlin/org/genspectrum/lapis/request/SequenceFiltersRequestWithFieldsTest.kt +++ b/lapis/src/test/kotlin/org/genspectrum/lapis/request/SequenceFiltersRequestWithFieldsTest.kt @@ -247,17 +247,6 @@ class SequenceFiltersRequestWithFieldsTest { listOf(SequencePositionField("gene1", 123)), ), ), - Arguments.of( - """{"fields": ["[456]"]}""", - SequenceFiltersRequestWithFields( - emptyMap(), - emptyList(), - emptyList(), - emptyList(), - emptyList(), - listOf(SequencePositionField("main", 456)), - ), - ), Arguments.of( """{"fields": ["GENE1[1]"]}""", SequenceFiltersRequestWithFields( @@ -289,6 +278,10 @@ class SequenceFiltersRequestWithFieldsTest { @JvmStatic fun getInvalidRequests() = listOf( + Arguments.of( + """{"fields": ["[456]"]}""", + "Shorthand position syntax '[N]' can only be used for single-segmented genomes", + ), Arguments.of( """{"fields": ["unknownSequence[1]"]}""", "Unknown sequence 'unknownSequence'", From 88d9562be5d2e4429687afd4008bb65c6a73140e Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 15 Jul 2026 09:33:31 +0200 Subject: [PATCH 4/8] refactor(lapis): use quoted identifiers in SaneQL; simplify position field aliasing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SaneQlAssignment now quotes its name (consistent with SaneQlIdentifier, safer against injection). Position fields use their user-facing name (e.g. S[501]) directly as the SaneQL alias, so SILO returns the column under that name already — no post-processing remapping needed in the controller. Co-Authored-By: Claude Sonnet 4.6 --- .../lapis/controller/LapisController.kt | 17 +++---------- .../org/genspectrum/lapis/request/Field.kt | 4 +--- .../org/genspectrum/lapis/silo/SaneQlAst.kt | 4 ++-- .../org/genspectrum/lapis/silo/SiloQuery.kt | 4 ++-- .../genspectrum/lapis/silo/SaneQlAstTest.kt | 2 +- .../lapis/silo/SiloQueryToSaneQlTest.kt | 24 +++++++++---------- 6 files changed, 21 insertions(+), 34 deletions(-) diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt index e7a5ff079..6c080e20b 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt @@ -62,7 +62,6 @@ import org.genspectrum.lapis.request.AminoAcidInsertion import org.genspectrum.lapis.request.AminoAcidMutation import org.genspectrum.lapis.request.CaseInsensitiveFieldConverter import org.genspectrum.lapis.request.Field -import org.genspectrum.lapis.request.SequencePositionField import org.genspectrum.lapis.request.DEFAULT_MIN_PROPORTION import org.genspectrum.lapis.request.GetRequestSequenceFilters import org.genspectrum.lapis.request.MRCASequenceFiltersRequest @@ -370,21 +369,11 @@ class LapisController( ) } - private fun getAggregatedCollection(request: SequenceFiltersRequestWithFields): AggregatedCollection { - val aliasToUserFacingName = request.fields - .filterIsInstance() - .associate { it.columnAlias to it.userFacingName } - val records = siloQueryModel.getAggregated(request).let { stream -> - if (aliasToUserFacingName.isEmpty()) stream - else stream.map { data -> - data.copy(fields = data.fields.mapKeys { aliasToUserFacingName[it.key] ?: it.key }) - } - } - return AggregatedCollection( - records = records, + private fun getAggregatedCollection(request: SequenceFiltersRequestWithFields) = + AggregatedCollection( + records = siloQueryModel.getAggregated(request), fields = request.fields.map { it.outputColumnName }, ) - } @GetMapping(NUCLEOTIDE_MUTATIONS_ROUTE, produces = [MediaType.APPLICATION_JSON_VALUE]) @LapisNucleotideMutationsResponse diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/request/Field.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/request/Field.kt index fb3c952d8..2ec92d863 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/request/Field.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/request/Field.kt @@ -23,9 +23,7 @@ data class SequencePositionField( val position: Int, val isSingleSegment: Boolean = false, ) : RequestField { - /** Internal SaneQL alias, e.g. `S_501`. Used as the column name inside the query pipeline. */ - val columnAlias: String get() = "${sequenceName}_${position}" - /** User-facing name echoed back in the response, e.g. `S[501]` or `[501]` for single-segment shorthand. */ + /** Name used both as the SaneQL alias and as the response column key, e.g. `S[501]` or `[501]` for shorthand. */ val userFacingName: String get() = if (isSingleSegment) "[${position}]" else "${sequenceName}[${position}]" override val outputColumnName: String get() = userFacingName } diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/silo/SaneQlAst.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/silo/SaneQlAst.kt index 4322d325c..24df00f84 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/silo/SaneQlAst.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/silo/SaneQlAst.kt @@ -70,12 +70,12 @@ data class SaneQlList( override fun render() = "{" + items.joinToString(", ") { it.render() } + "}" } -/** A `name:=value` assignment, used inside [SaneQlList]s that act as records, e.g. `{count:=count()}`. */ +/** A `"name":=value` assignment, used inside [SaneQlList]s that act as records, e.g. `{"count":=count()}`. */ data class SaneQlAssignment( val name: String, val value: SaneQlExpression, ) : SaneQlExpression { - override fun render() = "$name:=${value.render()}" + override fun render() = "\"${name.replace("\"", "\"\"")}\":=${value.render()}" } /** `"column" = value` */ 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 8cde88f5e..854cd2b47 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuery.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuery.kt @@ -248,7 +248,7 @@ sealed class SiloAction( SaneQlList( sequencePositionFields.map { field -> SaneQlAssignment( - field.columnAlias, + field.userFacingName, SaneQlMethodCall( SaneQlIdentifier(field.sequenceName), "at", @@ -262,7 +262,7 @@ sealed class SiloAction( ) } val allGroupByColumns = - groupByFields.map { id(it) } + sequencePositionFields.map { id(it.columnAlias) } + groupByFields.map { id(it) } + sequencePositionFields.map { id(it.userFacingName) } add( SaneQlStep( "groupBy", diff --git a/lapis/src/test/kotlin/org/genspectrum/lapis/silo/SaneQlAstTest.kt b/lapis/src/test/kotlin/org/genspectrum/lapis/silo/SaneQlAstTest.kt index 63f205076..6075ac9ae 100644 --- a/lapis/src/test/kotlin/org/genspectrum/lapis/silo/SaneQlAstTest.kt +++ b/lapis/src/test/kotlin/org/genspectrum/lapis/silo/SaneQlAstTest.kt @@ -66,7 +66,7 @@ class SaneQlAstTest { @Test fun `GIVEN assignment THEN renders as name colon-equals value`() { val assignment = SaneQlAssignment("count", SaneQlFunctionCall("count")) - assertThat(assignment.render(), equalTo("count:=count()")) + assertThat(assignment.render(), equalTo("\"count\":=count()")) } @Test 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 931586f8a..b9f671015 100644 --- a/lapis/src/test/kotlin/org/genspectrum/lapis/silo/SiloQueryToSaneQlTest.kt +++ b/lapis/src/test/kotlin/org/genspectrum/lapis/silo/SiloQueryToSaneQlTest.kt @@ -25,7 +25,7 @@ class SiloQueryToSaneQlTest { assertThat( result, - equalTo("""default.filter("theColumn" = 'theValue').groupBy({count:=count()})"""), + equalTo("""default.filter("theColumn" = 'theValue').groupBy({"count":=count()})"""), ) } @@ -35,7 +35,7 @@ class SiloQueryToSaneQlTest { val result = query.toSaneQl() - assertThat(result, equalTo("default.filter(true).groupBy({count:=count()})")) + assertThat(result, equalTo("""default.filter(true).groupBy({"count":=count()})""")) } @Test @@ -72,7 +72,7 @@ class SiloQueryToSaneQlTest { assertThat( result, - equalTo("default.filter($expectedPredicate).groupBy({count:=count()})"), + equalTo("""default.filter($expectedPredicate).groupBy({"count":=count()})"""), ) } @@ -93,7 +93,7 @@ class SiloQueryToSaneQlTest { assertThat( result, equalTo( - """default.filter(true).groupBy({count:=count()}, {"country"})""" + + """default.filter(true).groupBy({"count":=count()}, {"country"})""" + """.orderBy({"count}).filter(true).groupBy({evil:=count()"})""", // <- the orderBy field is quoted ), ) @@ -106,11 +106,11 @@ class SiloQueryToSaneQlTest { // Aggregated Arguments.of( SiloAction.aggregated(), - ".groupBy({count:=count()})", + """.groupBy({"count":=count()})""", ), Arguments.of( SiloAction.aggregated(listOf("field1", "field2")), - """.groupBy({count:=count()}, {"field1", "field2"})""", + """.groupBy({"count":=count()}, {"field1", "field2"})""", ), Arguments.of( SiloAction.aggregated( @@ -122,29 +122,29 @@ class SiloQueryToSaneQlTest { 100, 50, ), - """.groupBy({count:=count()}, {"field1", "field2"}).orderBy({"field3", "field4".desc()}).offset(50).limit(100)""", + """.groupBy({"count":=count()}, {"field1", "field2"}).orderBy({"field3", "field4".desc()}).offset(50).limit(100)""", ), Arguments.of( SiloAction.aggregated(orderByFields = OrderBySpec.Random(seed = null)), - ".groupBy({count:=count()}).randomize()", + """.groupBy({"count":=count()}).randomize()""", ), Arguments.of( SiloAction.aggregated(orderByFields = OrderBySpec.Random(seed = 123)), - ".groupBy({count:=count()}).randomize(seed:=123)", + """.groupBy({"count":=count()}).randomize(seed:=123)""", ), Arguments.of( SiloAction.aggregated( orderByFields = OrderBySpec.Random(seed = 42), limit = 10, ), - ".groupBy({count:=count()}).randomize(seed:=42).limit(10)", + """.groupBy({"count":=count()}).randomize(seed:=42).limit(10)""", ), Arguments.of( SiloAction.aggregated( groupByFields = listOf("country"), sequencePositionFields = listOf(SequencePositionField("S", 501)), ), - """.map({S_501:="S".at(501)}).groupBy({count:=count()}, {"country", "S_501"})""", + """.map({"S[501]":="S".at(501)}).groupBy({"count":=count()}, {"country", "S[501]"})""", ), Arguments.of( SiloAction.aggregated( @@ -153,7 +153,7 @@ class SiloQueryToSaneQlTest { SequencePositionField("main", 456), ), ), - """.map({S_123:="S".at(123), main_456:="main".at(456)}).groupBy({count:=count()}, {"S_123", "main_456"})""", + """.map({"S[123]":="S".at(123), "main[456]":="main".at(456)}).groupBy({"count":=count()}, {"S[123]", "main[456]"})""", ), // Mutations Arguments.of( From bf2554e33dad0e7d5366c2b54d2a83b3cefc3c9b Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 15 Jul 2026 09:41:16 +0200 Subject: [PATCH 5/8] chore(lapis): fix ktlint warnings (redundant braces, import ordering) Co-Authored-By: Claude Sonnet 4.6 --- .../kotlin/org/genspectrum/lapis/controller/LapisController.kt | 2 +- lapis/src/main/kotlin/org/genspectrum/lapis/request/Field.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt index 6c080e20b..fde8e6f77 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt @@ -61,8 +61,8 @@ import org.genspectrum.lapis.openApi.TreeDataFormatParam import org.genspectrum.lapis.request.AminoAcidInsertion import org.genspectrum.lapis.request.AminoAcidMutation import org.genspectrum.lapis.request.CaseInsensitiveFieldConverter -import org.genspectrum.lapis.request.Field import org.genspectrum.lapis.request.DEFAULT_MIN_PROPORTION +import org.genspectrum.lapis.request.Field import org.genspectrum.lapis.request.GetRequestSequenceFilters import org.genspectrum.lapis.request.MRCASequenceFiltersRequest import org.genspectrum.lapis.request.MutationProportionsRequest diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/request/Field.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/request/Field.kt index 2ec92d863..8ce343695 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/request/Field.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/request/Field.kt @@ -24,7 +24,7 @@ data class SequencePositionField( val isSingleSegment: Boolean = false, ) : RequestField { /** Name used both as the SaneQL alias and as the response column key, e.g. `S[501]` or `[501]` for shorthand. */ - val userFacingName: String get() = if (isSingleSegment) "[${position}]" else "${sequenceName}[${position}]" + val userFacingName: String get() = if (isSingleSegment) "[$position]" else "$sequenceName[$position]" override val outputColumnName: String get() = userFacingName } From 08df65ef5457a3bac5fb179f0be29bc1c33b3d73 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 15 Jul 2026 08:53:03 +0100 Subject: [PATCH 6/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../genspectrum/lapis/controller/ControllerDescriptions.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 1eafb44af..ec420f3ec 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/ControllerDescriptions.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/ControllerDescriptions.kt @@ -70,9 +70,9 @@ const val AGGREGATED_GROUP_BY_FIELDS_DESCRIPTION = If empty, only the overall count is returned. If requesting CSV or TSV data, the columns are ordered in the same order as the fields are specified here. -Sequence positions can be requested using the syntax `SequenceName[position]` (e.g. `S[501]` for position 501 of gene S). +Sequence positions can be requested using the syntax `SequenceName[position]` (e.g. `S[501]` for position 501 of sequence `S`). For single-segmented genomes, the shorthand `[position]` (e.g. `[501]`) can be used. -Position fields appear in the response under the same name as requested (e.g. `S[501]`).""" +Position field column names in the response use the canonical sequence name from the reference genome (case-insensitive input), e.g. `s[501]` -> `S[501]`. const val AGGREGATED_ORDER_BY_FIELDS_DESCRIPTION = """The fields of the response to order by. Fields specified here must either be \"count\" or also be present in \"fields\". From 097df7d8bf4275e23cbda0577f8eab0cf79f0da5 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 15 Jul 2026 11:15:12 +0200 Subject: [PATCH 7/8] fix(lapis): close unterminated string literal in ControllerDescriptions.kt The AGGREGATED_GROUP_BY_FIELDS_DESCRIPTION const was missing its closing triple-quote, which caused the compiler to swallow the following declaration as part of the string and fail with unresolved reference and syntax errors. --- .../org/genspectrum/lapis/controller/ControllerDescriptions.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ec420f3ec..655e725ca 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/ControllerDescriptions.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/ControllerDescriptions.kt @@ -72,7 +72,7 @@ If requesting CSV or TSV data, the columns are ordered in the same order as the Sequence positions can be requested using the syntax `SequenceName[position]` (e.g. `S[501]` for position 501 of sequence `S`). For single-segmented genomes, the shorthand `[position]` (e.g. `[501]`) can be used. -Position field column names in the response use the canonical sequence name from the reference genome (case-insensitive input), e.g. `s[501]` -> `S[501]`. +Position field column names in the response use the canonical sequence name from the reference genome (case-insensitive input), e.g. `s[501]` -> `S[501]`.""" const val AGGREGATED_ORDER_BY_FIELDS_DESCRIPTION = """The fields of the response to order by. Fields specified here must either be \"count\" or also be present in \"fields\". From 2c4d644d64d55d036f2565cef37481ad47ead885 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 15 Jul 2026 14:24:16 +0200 Subject: [PATCH 8/8] fix(lapis): reject sequence position fields in /details endpoint Previously SequencePositionField entries were silently dropped, causing /details to return a 200 with all metadata fields instead of erroring. Now it returns a 400 with a clear message, per Copilot review feedback. Co-Authored-By: Claude Sonnet 5 --- .../lapis/controller/LapisController.kt | 8 +++++++ .../lapis/controller/LapisControllerTest.kt | 22 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt index fde8e6f77..699601a17 100644 --- a/lapis/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt +++ b/lapis/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt @@ -75,6 +75,7 @@ import org.genspectrum.lapis.request.SPECIAL_REQUEST_PROPERTIES import org.genspectrum.lapis.request.SequenceFiltersRequest import org.genspectrum.lapis.request.SequenceFiltersRequestWithFields import org.genspectrum.lapis.request.SequenceFiltersRequestWithGenes +import org.genspectrum.lapis.request.SequencePositionField import org.genspectrum.lapis.request.toOrderBySpec import org.genspectrum.lapis.request.validatePhyloTreeField import org.genspectrum.lapis.response.AggregatedCollection @@ -1491,6 +1492,13 @@ class LapisController( } private fun getDetailsCollection(request: SequenceFiltersRequestWithFields): DetailsCollection { + val positionFields = request.fields.filterIsInstance() + if (positionFields.isNotEmpty()) { + throw BadRequestException( + "Sequence position fields are not supported for this endpoint: " + + positionFields.joinToString(", ") { it.userFacingName }, + ) + } val fields = request.fields.filterIsInstance().map { it.fieldName } return DetailsCollection( 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..46d06a147 100644 --- a/lapis/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerTest.kt +++ b/lapis/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerTest.kt @@ -533,6 +533,28 @@ class LapisControllerTest( .andExpect(jsonPath("\$.data[0].country").value("Switzerland")) .andExpect(jsonPath("\$.data[0].date").value("a date")) } + + @Test + fun `GET details with a sequence position field returns bad request`() { + mockMvc.perform( + getSample(DETAILS_ROUTE) + .queryParam("country", "Switzerland") + .queryParam("fields", "gene1[501]"), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("\$.error.detail").value(containsString("Sequence position fields are not supported"))) + } + + @Test + fun `POST JSON details with a sequence position field returns bad request`() { + mockMvc.perform( + postSample(DETAILS_ROUTE) + .content("""{"country": "Switzerland", "fields": ["gene1[501]"]}""") + .contentType(MediaType.APPLICATION_JSON), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("\$.error.detail").value(containsString("Sequence position fields are not supported"))) + } } fun getSample(path: String): MockHttpServletRequestBuilder = get("/sample/$path")