feat(lapis): add sequence position fields syntax to aggregated endpoint#1768
feat(lapis): add sequence position fields syntax to aggregated endpoint#1768fhennig wants to merge 8 commits into
Conversation
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 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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 <noreply@anthropic.com>
…ack in response 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 <noreply@anthropic.com>
…field aliasing 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 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds support for requesting specific sequence positions (e.g. S[501], and [501] for single-segment genomes) as group-by columns in the /aggregated endpoint by parsing them from the fields request parameter and generating the appropriate SaneQL .map(...).groupBy(...) pipeline.
Changes:
- Introduces
RequestFieldwith a newSequencePositionFieldtype and updates request parsing to recognizeName[N]/[N]syntax. - Extends aggregated SaneQL generation to add a
.map(...)step that computes requested positions and includes them in.groupBy(...). - Updates SaneQL AST rendering/tests to quote assignment keys (e.g.
{"count":=count()}) and adds unit coverage for new parsing/generation.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| lapis/src/main/kotlin/org/genspectrum/lapis/request/Field.kt | Adds RequestField + SequencePositionField, and extends field conversion to parse/validate position syntax against the reference genome schema. |
| lapis/src/main/kotlin/org/genspectrum/lapis/request/SequenceFiltersRequestWithFields.kt | Updates request model to store fields as List<RequestField> and keeps deserialization via the field converter. |
| lapis/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuery.kt | Adds sequencePositionFields to aggregated actions and emits .map(...) before .groupBy(...) when present. |
| lapis/src/main/kotlin/org/genspectrum/lapis/silo/SaneQlAst.kt | Changes assignment rendering to quote/escape keys (e.g. "name":=value) to align with SaneQL expectations and harden output. |
| lapis/src/main/kotlin/org/genspectrum/lapis/model/SiloQueryModel.kt | Splits RequestField values into metadata fields vs. sequence-position fields for aggregated query construction. |
| lapis/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt | Ensures response headers/field lists use outputColumnName (aggregated) and filters non-metadata fields out of details fields handling. |
| lapis/src/main/kotlin/org/genspectrum/lapis/controller/ControllerDescriptions.kt | Updates aggregated endpoint parameter description to document the new position-field syntax. |
| lapis/src/main/kotlin/org/genspectrum/lapis/request/PhyloTreeSequenceFiltersRequest.kt | Adjusts phylo-tree parsing to work with the generalized FieldConverter<RequestField> while enforcing a Field result. |
| lapis/src/test/kotlin/org/genspectrum/lapis/request/SequenceFiltersRequestWithFieldsTest.kt | Adds unit tests for parsing valid/invalid Name[N] / [N] field forms. |
| lapis/src/test/kotlin/org/genspectrum/lapis/silo/SiloQueryToSaneQlTest.kt | Updates expected SaneQL strings and adds coverage for aggregated SaneQL with sequence position fields. |
| lapis/src/test/kotlin/org/genspectrum/lapis/silo/SaneQlAstTest.kt | Updates AST rendering assertions to match quoted assignment output. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
|
|
…ns.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.
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 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (2)
lapis/src/main/kotlin/org/genspectrum/lapis/request/Field.kt:35
FieldConverter.validatePhyloTreeFields(...)is currently unused (no call sites in the repo) and duplicates the already-usedvalidatePhyloTreeField(...)function below. Keeping two parallel validation APIs (one DB-config based, one converter based) risks divergence and makes it unclear which one should be used.
fun interface FieldConverter<T> {
fun convert(source: String): T
fun validatePhyloTreeFields(source: String): T = convert(source)
}
lapis/src/main/kotlin/org/genspectrum/lapis/request/Field.kt:98
- With
FieldConverter.validatePhyloTreeFields(...)removed, this override becomes dead code as well. All phylo-tree validation currently goes throughvalidatePhyloTreeField(...)(used by controllers and request deserializers).
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(", ")}]",
)
}
return converted
}
| 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]`.""" |
fengelniederhammer
left a comment
There was a problem hiding this comment.
I'm also missing an e2e test ;)
| 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]`.""" |
There was a problem hiding this comment.
We should probably also add something in the llms.txt?
| 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]`.""" |
There was a problem hiding this comment.
Usually we tried to avoid those formulations (if single segmented ...). Instead, we generate them at run time: then we know in which case we are.
| } | ||
|
|
||
| override fun validatePhyloTreeFields(source: String): Field { | ||
| override fun validatePhyloTreeFields(source: String): RequestField { |
There was a problem hiding this comment.
Isn't this function actually duplicate? Below, we have another validatePhyloTreeField that looks like basically the same.
There was a problem hiding this comment.
I think this one isn't even used anywhere...
| private val SEQUENCE_POSITION_REGEX = Regex("""^([A-Za-z][A-Za-z0-9_]*)\[(\d+)\]$""") | ||
| private val SHORTHAND_POSITION_REGEX = Regex("""^\[(\d+)\]$""") |
There was a problem hiding this comment.
| private val SEQUENCE_POSITION_REGEX = Regex("""^([A-Za-z][A-Za-z0-9_]*)\[(\d+)\]$""") | |
| private val SHORTHAND_POSITION_REGEX = Regex("""^\[(\d+)\]$""") | |
| private val SEQUENCE_POSITION_REGEX = Regex("""^([A-Za-z][A-Za-z0-9_]*)\[(\d+)]$""") | |
| private val SHORTHAND_POSITION_REGEX = Regex("""^\[(\d+)]$""") |
apparently those closing brackets don't need to be escaped.
| 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 | ||
| } |
There was a problem hiding this comment.
| 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 | |
| } | |
| data class SequencePositionField( | |
| val sequenceName: String?, | |
| val position: Int, | |
| ) : RequestField { | |
| override val outputColumnName: String = if (sequenceName == null) { | |
| "[$position]" | |
| } else { | |
| "$sequenceName[$position]" | |
| } | |
| } |
We can encode isSingleSegment into sequenceName by making it nullable.
| 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) | ||
| } |
There was a problem hiding this comment.
Could we collapse those two branches by making the sequence name in the regex optional? The position parsing is the same, and we could streamline the sequence name parsing.
| throw BadRequestException( | ||
| "Sequence position fields are not supported for this endpoint: " + | ||
| positionFields.joinToString(", ") { it.userFacingName }, | ||
| ) |
There was a problem hiding this comment.
I find it a bit weird to first allow a SequencePositionField while parsing and then reject it. Wouldn't it be better to differentiate already in the types that the controller methods accept?
| SaneQlAssignment( | ||
| field.userFacingName, | ||
| SaneQlMethodCall( | ||
| SaneQlIdentifier(field.sequenceName), | ||
| "at", | ||
| listOf(SaneQlInt(field.position)), | ||
| ), | ||
| ) |
There was a problem hiding this comment.
Do we need to deduplicate those somewhere?
Summary
Name[N]syntax (e.g.S[501]) to thefieldsparameter of/aggregated, allowing users to request specific sequence positions as group-by columns[N]is supported for single-segmented genomes and resolves to the single nucleotide sequence.map({"S[501]":="S".at(501)})SaneQL step before.groupBy(...)when position fields are present — the user-facing name is used directly as the SaneQL alias, so no post-processing is neededS[501]in →S[501]out,[501]in →[501]outExample
Generates:
Test plan
Name[N]parsing inSequenceFiltersRequestWithFieldsTestSiloQueryToSaneQlTestfor position-only and mixed fields🤖 Generated with Claude Code