-
Notifications
You must be signed in to change notification settings - Fork 6
feat(lapis): support scalar functions in aggregation fields via dot notation #1780
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
bb66630
03889d7
7e1c0cd
9dc7cdd
8d0dab1
78e4454
2092281
c018450
b7745e0
fb28838
1572f8b
421d832
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| --- | ||
| title: Computed fields | ||
| description: Computed fields | ||
| --- | ||
|
|
||
| In addition to plain metadata field names, the `fields` parameter of `/sample/aggregated` accepts computed fields | ||
| using the syntax `<field>.<function>`, e.g. `date.isoWeek`. | ||
| A computed field applies a scalar function to a metadata field and groups by the result, | ||
| instead of grouping by the raw field value. | ||
|
|
||
| **Example:** to count sequences per ISO week instead of per exact date: | ||
|
|
||
| ``` | ||
| [URL to LAPIS instance]/sample/aggregated?fields=date.isoWeek | ||
| ``` | ||
|
|
||
| ```json | ||
| { | ||
| "fields": ["date.isoWeek"] | ||
| } | ||
| ``` | ||
|
|
||
| The response uses the full `<field>.<function>` string as the column/property key, e.g. `date.isoWeek`. | ||
|
|
||
| ## Available functions | ||
|
|
||
| | Function | Applicable field types | Description | | ||
| | --------- | ---------------------- | ------------------------------------ | | ||
| | `isoWeek` | `date` | The ISO 8601 week of the date field. | | ||
|
Comment on lines
+25
to
+29
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| :::note | ||
| Computed fields are only supported in the `fields` parameter of `/sample/aggregated` | ||
| (and the analogous endpoints for other sequence types). | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. which other endpoint? |
||
| They are not supported by endpoints that return the underlying data rows, such as `/sample/details`. | ||
| ::: | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -172,6 +172,56 @@ describe('The /aggregated endpoint', () => { | |
| expect(resultJson.error.detail).to.include("Unknown field: 'notAField', known values are [primaryKey,"); | ||
| }); | ||
|
|
||
| it('should stratify by a computed field using dot notation', async () => { | ||
| const result = await lapisClient.postAggregated({ | ||
| aggregatedPostRequest: { | ||
| date: '2021-06-05', | ||
| fields: ['date.isoWeek'], | ||
| }, | ||
| }); | ||
|
|
||
| expect(result.data).to.have.length(1); | ||
| expect(result.data[0]).to.have.property('count', 1); | ||
| expect(result.data[0]).to.have.property('date.isoWeek', 22); | ||
| }); | ||
|
|
||
| it('should order by a computed field using dot notation', async () => { | ||
| const result = await lapisClient.postAggregated({ | ||
| aggregatedPostRequest: { | ||
| date: '2021-06-05', | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. really filter by date, too? I think the test would be more meaningful without the filter, but still deterministic due to the orderBy. |
||
| fields: ['date.isoWeek'], | ||
| orderBy: [{ field: 'date.isoWeek', type: 'ascending' }], | ||
| }, | ||
| }); | ||
|
|
||
| expect(result.data).to.have.length(1); | ||
| expect(result.data[0]).to.have.property('date.isoWeek', 22); | ||
| }); | ||
|
|
||
| it('should return bad request for an unknown scalar function', async () => { | ||
| const urlParams = new URLSearchParams({ | ||
| fields: 'date.notAFunction', | ||
| }); | ||
|
|
||
| const result = await getAggregated(urlParams); | ||
|
|
||
| expect(result.status).equals(400); | ||
| const resultJson = await result.json(); | ||
| expect(resultJson.error.detail).to.include("Unknown scalar function 'notAFunction'"); | ||
| }); | ||
|
|
||
| it('should return bad request for a scalar function applied to a field of the wrong type', async () => { | ||
| const urlParams = new URLSearchParams({ | ||
| fields: 'country.isoWeek', | ||
| }); | ||
|
|
||
| const result = await getAggregated(urlParams); | ||
|
|
||
| expect(result.status).equals(400); | ||
| const resultJson = await result.json(); | ||
| expect(resultJson.error.detail).to.include("is not valid for field 'country'"); | ||
| }); | ||
|
|
||
| it('should return bad request for invalid variant query', async () => { | ||
| const urlParams = new URLSearchParams({ | ||
| variantQuery: 'not a valid variant query', | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package org.genspectrum.lapis.request | ||
|
|
||
| import org.genspectrum.lapis.config.MetadataType | ||
|
|
||
| /** | ||
| * An enum of scalar functions supported by SILO. | ||
| * New functions need to be whitelisted here explicitly. | ||
| */ | ||
| enum class ScalarFunction( | ||
| val saneQlMethodName: String, | ||
| val validForTypes: Set<MetadataType>, | ||
| ) { | ||
| ISO_WEEK("isoWeek", setOf(MetadataType.DATE)), | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,19 @@ | ||
| package org.genspectrum.lapis.request.converter | ||
|
|
||
| import org.genspectrum.lapis.controller.BadRequestException | ||
| import org.springframework.stereotype.Component | ||
|
|
||
| @Component | ||
| class OrderByFieldsCleaner( | ||
| private val caseInsensitiveFieldsCleaner: CaseInsensitiveFieldsCleaner, | ||
| private val scalarFunctionFieldConverter: ScalarFunctionFieldConverter, | ||
| ) { | ||
| fun clean(fieldName: String): String = caseInsensitiveFieldsCleaner.clean(fieldName) ?: fieldName | ||
| fun clean(fieldName: String): String = | ||
| try { | ||
| scalarFunctionFieldConverter.tryConvert(fieldName)?.outputColumnName | ||
| ?: caseInsensitiveFieldsCleaner.clean(fieldName) | ||
| ?: fieldName | ||
| } catch (e: BadRequestException) { | ||
| fieldName | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,46 @@ | ||||||
| package org.genspectrum.lapis.request.converter | ||||||
|
|
||||||
| import org.genspectrum.lapis.config.DatabaseConfig | ||||||
| import org.genspectrum.lapis.config.MetadataType | ||||||
| import org.genspectrum.lapis.controller.BadRequestException | ||||||
| import org.genspectrum.lapis.request.ComputedField | ||||||
| import org.genspectrum.lapis.request.ScalarFunction | ||||||
| import org.springframework.stereotype.Component | ||||||
|
|
||||||
| @Component | ||||||
| class ScalarFunctionFieldConverter( | ||||||
| private val caseInsensitiveFieldsCleaner: CaseInsensitiveFieldsCleaner, | ||||||
| private val databaseConfig: DatabaseConfig, | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| ) { | ||||||
| private val fieldTypesByLowercaseName: Map<String, MetadataType> = | ||||||
| databaseConfig.schema.metadata.associateBy({ it.name.lowercase() }, { it.type }) | ||||||
|
|
||||||
| fun tryConvert(source: String): ComputedField? { | ||||||
| if ('.' !in source) return null | ||||||
| val dotIndex = source.lastIndexOf('.') | ||||||
| val rawField = source.substring(0, dotIndex) | ||||||
| val rawFunction = source.substring(dotIndex + 1) | ||||||
|
|
||||||
| val cleanedField = caseInsensitiveFieldsCleaner.clean(rawField) | ||||||
| ?: throw BadRequestException( | ||||||
| "Unknown field '$rawField' in '$source'. " + | ||||||
| "Known fields: ${caseInsensitiveFieldsCleaner.getKnownFields()}", | ||||||
| ) | ||||||
|
|
||||||
| val function = ScalarFunction.entries.find { it.saneQlMethodName.equals(rawFunction, ignoreCase = true) } | ||||||
| ?: throw BadRequestException( | ||||||
| "Unknown scalar function '$rawFunction' in '$source'. " + | ||||||
| "Available functions: ${ScalarFunction.entries.joinToString { it.saneQlMethodName }}", | ||||||
| ) | ||||||
|
|
||||||
| val fieldType = fieldTypesByLowercaseName[cleanedField.lowercase()]!! | ||||||
| if (fieldType !in function.validForTypes) { | ||||||
| throw BadRequestException( | ||||||
| "Scalar function '${function.saneQlMethodName}' is not valid for field '$cleanedField' of type " + | ||||||
| "$fieldType. Valid types: ${function.validForTypes.joinToString()}", | ||||||
| ) | ||||||
| } | ||||||
|
|
||||||
| return ComputedField(cleanedField, function) | ||||||
| } | ||||||
| } | ||||||

There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think other examples also already use a format like this?