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..655e725ca 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 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]`.""" 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..699601a17 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.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 @@ -74,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 @@ -368,10 +370,10 @@ class LapisController( ) } - private fun getAggregatedCollection(request: SequenceFiltersRequestWithFields): AggregatedCollection = + private fun getAggregatedCollection(request: SequenceFiltersRequestWithFields) = 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 +1492,14 @@ class LapisController( } private fun getDetailsCollection(request: SequenceFiltersRequestWithFields): DetailsCollection { - val fields = request.fields.map { it.fieldName } + 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( 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..8ce343695 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,32 @@ 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, + 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]" + override val outputColumnName: String get() = userFacingName +} fun interface FieldConverter { fun convert(source: String): T @@ -17,25 +37,61 @@ 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") + } + if (!referenceGenomeSchema.isSingleSegmented()) { + throw BadRequestException( + "Shorthand position syntax '[N]' can only be used for single-segmented genomes", + ) + } + val canonicalName = referenceGenomeSchema.nucleotideSequences.first().name + return SequencePositionField(canonicalName, position, isSingleSegment = true) + } + + 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 +100,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/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 8cd4abe95..854cd2b47 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.userFacingName, + SaneQlMethodCall( + SaneQlIdentifier(field.sequenceName), + "at", + listOf(SaneQlInt(field.position)), + ), + ) + }, + ), + ), + ), + ) + } + val allGroupByColumns = + groupByFields.map { id(it) } + sequencePositionFields.map { id(it.userFacingName) } + 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/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") 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..6bdcc6fef 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,63 @@ class SequenceFiltersRequestWithFieldsTest { ), ) + @JvmStatic + fun getSequencePositionFieldTestCases() = + listOf( + Arguments.of( + """{"fields": ["gene1[123]"]}""", + SequenceFiltersRequestWithFields( + emptyMap(), + emptyList(), + emptyList(), + emptyList(), + emptyList(), + listOf(SequencePositionField("gene1", 123)), + ), + ), + 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": ["[456]"]}""", + "Shorthand position syntax '[N]' can only be used for single-segmented genomes", + ), + 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/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 d50623fab..b9f671015 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 @@ -24,7 +25,7 @@ class SiloQueryToSaneQlTest { assertThat( result, - equalTo("""default.filter("theColumn" = 'theValue').groupBy({count:=count()})"""), + equalTo("""default.filter("theColumn" = 'theValue').groupBy({"count":=count()})"""), ) } @@ -34,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 @@ -71,7 +72,7 @@ class SiloQueryToSaneQlTest { assertThat( result, - equalTo("default.filter($expectedPredicate).groupBy({count:=count()})"), + equalTo("""default.filter($expectedPredicate).groupBy({"count":=count()})"""), ) } @@ -92,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 ), ) @@ -105,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( @@ -121,22 +122,38 @@ 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]"})""", + ), + 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(