;
+
export const collectionVariantSchema = z.object({
query: z.discriminatedUnion('type', [variantQuerySchema, detailedMutationsQuerySchema]),
name: z.string(),
diff --git a/website/src/covspectrum/variantConversionUtil.spec.ts b/website/src/covspectrum/variantConversionUtil.spec.ts
new file mode 100644
index 000000000..d44128976
--- /dev/null
+++ b/website/src/covspectrum/variantConversionUtil.spec.ts
@@ -0,0 +1,77 @@
+import { describe, expect, test } from 'vitest';
+
+import type { DetailedMutationsQuery } from './types';
+import { detailedMutationsToQuery } from './variantConversionUtil';
+
+describe('detailedMutationsToQuery', () => {
+ test('should return empty string for empty query', () => {
+ const query: DetailedMutationsQuery = {
+ type: 'detailedMutations',
+ };
+
+ const result = detailedMutationsToQuery(query);
+
+ expect(result).toBe('');
+ });
+
+ test('should convert nucleotide mutations only', () => {
+ const query: DetailedMutationsQuery = {
+ type: 'detailedMutations',
+ nucMutations: ['A123T', 'C456G', 'G789A'],
+ };
+
+ const result = detailedMutationsToQuery(query);
+
+ expect(result).toBe('A123T & C456G & G789A');
+ });
+
+ test('should convert pangoLineage filter', () => {
+ const query: DetailedMutationsQuery = {
+ type: 'detailedMutations',
+ pangoLineage: 'BA.1',
+ };
+
+ const result = detailedMutationsToQuery(query);
+
+ expect(result).toBe('pangoLineage=BA.1');
+ });
+
+ test('should convert nextcladePangoLineage filter', () => {
+ const query: DetailedMutationsQuery = {
+ type: 'detailedMutations',
+ nextcladePangoLineage: 'C.1',
+ };
+
+ const result = detailedMutationsToQuery(query);
+
+ expect(result).toBe('nextcladePangoLineage=C.1');
+ });
+
+ test('should combine lineage, mutations, and insertions', () => {
+ const query: DetailedMutationsQuery = {
+ type: 'detailedMutations',
+ pangoLineage: 'BA.2',
+ nucMutations: ['A123T'],
+ aaMutations: ['S:N501Y'],
+ nucInsertions: ['ins_22204:GAGCCAGAA'],
+ aaInsertions: ['ins_S:214:EPE'],
+ };
+
+ const result = detailedMutationsToQuery(query);
+
+ expect(result).toBe('pangoLineage=BA.2 & A123T & S:N501Y & ins_22204:GAGCCAGAA & ins_S:214:EPE');
+ });
+
+ test('should handle multiple mutations and both lineage types', () => {
+ const query: DetailedMutationsQuery = {
+ type: 'detailedMutations',
+ pangoLineage: 'BA.1',
+ nextcladePangoLineage: '21K',
+ aaMutations: ['S:L452R', 'S:T478K'],
+ };
+
+ const result = detailedMutationsToQuery(query);
+
+ expect(result).toBe('pangoLineage=BA.1 & nextcladePangoLineage=21K & S:L452R & S:T478K');
+ });
+});
diff --git a/website/src/covspectrum/variantConversionUtil.ts b/website/src/covspectrum/variantConversionUtil.ts
new file mode 100644
index 000000000..372deb32c
--- /dev/null
+++ b/website/src/covspectrum/variantConversionUtil.ts
@@ -0,0 +1,29 @@
+import type { DetailedMutationsQuery } from './types';
+
+/**
+ * Converts a detailedMutations query to a LAPIS query string.
+ * Supports lineage filters, mutations, and insertions.
+ * Returns an empty string if the query is empty.
+ */
+export function detailedMutationsToQuery(query: DetailedMutationsQuery): string {
+ const parts: string[] = [];
+
+ // Add lineage filters
+ if (query.pangoLineage) {
+ parts.push(`pangoLineage=${query.pangoLineage}`);
+ }
+ if (query.nextcladePangoLineage) {
+ parts.push(`nextcladePangoLineage=${query.nextcladePangoLineage}`);
+ }
+
+ // Add mutations and insertions
+ parts.push(
+ ...(query.nucMutations ?? []),
+ ...(query.aaMutations ?? []),
+ ...(query.nucInsertions ?? []),
+ ...(query.aaInsertions ?? []),
+ );
+
+ // Join with AND logic (& in LAPIS query syntax), or empty string if no parts
+ return parts.join(' & ');
+}
diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro
index e88ef014a..120275ef4 100644
--- a/website/src/pages/index.astro
+++ b/website/src/pages/index.astro
@@ -48,13 +48,13 @@ import { Page } from '../types/pages';
>.
- *) This project is supported by the National Institute Of Allergy And Infectious Diseases of the
- National Institutes of Health under Award Number U24AI183840. The content is solely the responsibility of the authors and does not necessarily represent the
- official views of the National Institutes of Health.
+ >, awarded to the SIB Swiss Institute of Bioinformatics. The content is solely the responsibility of the
+ authors and does not necessarily represent the official views of the National Institutes of Health.
diff --git a/website/src/util/siloExpressionUtils.spec.ts b/website/src/util/siloExpressionUtils.spec.ts
new file mode 100644
index 000000000..27a0506c3
--- /dev/null
+++ b/website/src/util/siloExpressionUtils.spec.ts
@@ -0,0 +1,373 @@
+import { describe, expect, test } from 'vitest';
+
+import { validateGenomeOnly } from './siloExpressionUtils.ts';
+import type { SiloFilterExpression } from '../lapis/siloFilterExpression.ts';
+
+describe('validateGenomeOnly', () => {
+ test('should validate simple nucleotide equals expression', () => {
+ const expression: SiloFilterExpression = {
+ type: 'NucleotideEquals',
+ sequenceName: 'main',
+ position: 123,
+ symbol: 'A',
+ };
+
+ const result = validateGenomeOnly(expression);
+
+ expect(result.isGenomeOnly).toBe(true);
+ });
+
+ test('should validate amino acid equals expression', () => {
+ const expression: SiloFilterExpression = {
+ type: 'AminoAcidEquals',
+ sequenceName: 'S',
+ position: 484,
+ symbol: 'K',
+ };
+
+ const result = validateGenomeOnly(expression);
+
+ expect(result.isGenomeOnly).toBe(true);
+ });
+
+ test('should validate has mutation expression', () => {
+ const expression: SiloFilterExpression = {
+ type: 'HasNucleotideMutation',
+ sequenceName: 'main',
+ position: 501,
+ };
+
+ const result = validateGenomeOnly(expression);
+
+ expect(result.isGenomeOnly).toBe(true);
+ });
+
+ test('should validate insertion contains expression', () => {
+ const expression: SiloFilterExpression = {
+ type: 'InsertionContains',
+ position: 22204,
+ value: 'GAGCCAGAA',
+ sequenceName: 'main',
+ };
+
+ const result = validateGenomeOnly(expression);
+
+ expect(result.isGenomeOnly).toBe(true);
+ });
+
+ test('should reject non-genome expression', () => {
+ const expression: SiloFilterExpression = {
+ type: 'StringEquals',
+ column: 'country',
+ value: 'USA',
+ };
+
+ const result = validateGenomeOnly(expression);
+
+ expect(result.isGenomeOnly).toBe(false);
+ if (!result.isGenomeOnly) {
+ expect(result.error).toContain('StringEquals');
+ }
+ });
+
+ test('should validate And expression with genome checks', () => {
+ const expression: SiloFilterExpression = {
+ type: 'And',
+ children: [
+ {
+ type: 'NucleotideEquals',
+ sequenceName: 'main',
+ position: 123,
+ symbol: 'A',
+ },
+ {
+ type: 'AminoAcidEquals',
+ sequenceName: 'S',
+ position: 484,
+ symbol: 'K',
+ },
+ ],
+ };
+
+ const result = validateGenomeOnly(expression);
+
+ expect(result.isGenomeOnly).toBe(true);
+ });
+
+ test('should validate Or expression with genome checks', () => {
+ const expression: SiloFilterExpression = {
+ type: 'Or',
+ children: [
+ {
+ type: 'NucleotideEquals',
+ sequenceName: 'main',
+ position: 123,
+ symbol: 'A',
+ },
+ {
+ type: 'NucleotideEquals',
+ sequenceName: 'main',
+ position: 456,
+ symbol: 'T',
+ },
+ ],
+ };
+
+ const result = validateGenomeOnly(expression);
+
+ expect(result.isGenomeOnly).toBe(true);
+ });
+
+ test('should validate Not expression with genome check', () => {
+ const expression: SiloFilterExpression = {
+ type: 'Not',
+ child: {
+ type: 'AminoAcidEquals',
+ sequenceName: 'S',
+ position: 484,
+ symbol: 'K',
+ },
+ };
+
+ const result = validateGenomeOnly(expression);
+
+ expect(result.isGenomeOnly).toBe(true);
+ });
+
+ test('should validate Maybe expression with genome check', () => {
+ const expression: SiloFilterExpression = {
+ type: 'Maybe',
+ child: {
+ type: 'HasNucleotideMutation',
+ sequenceName: 'main',
+ position: 501,
+ },
+ };
+
+ const result = validateGenomeOnly(expression);
+
+ expect(result.isGenomeOnly).toBe(true);
+ });
+
+ test('should validate N-Of expression with genome checks', () => {
+ const expression: SiloFilterExpression = {
+ type: 'N-Of',
+ numberOfMatchers: 2,
+ matchExactly: false,
+ children: [
+ {
+ type: 'NucleotideEquals',
+ sequenceName: 'main',
+ position: 123,
+ symbol: 'A',
+ },
+ {
+ type: 'AminoAcidEquals',
+ sequenceName: 'S',
+ position: 484,
+ symbol: 'K',
+ },
+ {
+ type: 'InsertionContains',
+ position: 22204,
+ value: 'GAGCCAGAA',
+ sequenceName: 'main',
+ },
+ ],
+ };
+
+ const result = validateGenomeOnly(expression);
+
+ expect(result.isGenomeOnly).toBe(true);
+ });
+
+ test('should validate True expression', () => {
+ const expression: SiloFilterExpression = {
+ type: 'True',
+ };
+
+ const result = validateGenomeOnly(expression);
+
+ expect(result.isGenomeOnly).toBe(true);
+ });
+
+ test('should reject Or expression with non-genome check', () => {
+ const expression: SiloFilterExpression = {
+ type: 'Or',
+ children: [
+ {
+ type: 'NucleotideEquals',
+ sequenceName: 'main',
+ position: 123,
+ symbol: 'A',
+ },
+ {
+ type: 'StringEquals',
+ column: 'country',
+ value: 'USA',
+ },
+ ],
+ };
+
+ const result = validateGenomeOnly(expression);
+
+ expect(result.isGenomeOnly).toBe(false);
+ if (!result.isGenomeOnly) {
+ expect(result.error).toContain('StringEquals');
+ }
+ });
+
+ test('should reject Not expression with non-genome check', () => {
+ const expression: SiloFilterExpression = {
+ type: 'Not',
+ child: {
+ type: 'DateBetween',
+ column: 'date',
+ from: '2021-01-01',
+ to: '2021-12-31',
+ },
+ };
+
+ const result = validateGenomeOnly(expression);
+
+ expect(result.isGenomeOnly).toBe(false);
+ if (!result.isGenomeOnly) {
+ expect(result.error).toContain('DateBetween');
+ }
+ });
+
+ test('should validate nested complex expression with only genome checks', () => {
+ const expression: SiloFilterExpression = {
+ type: 'And',
+ children: [
+ {
+ type: 'Or',
+ children: [
+ {
+ type: 'NucleotideEquals',
+ sequenceName: 'main',
+ position: 123,
+ symbol: 'A',
+ },
+ {
+ type: 'NucleotideEquals',
+ sequenceName: 'main',
+ position: 123,
+ symbol: 'T',
+ },
+ ],
+ },
+ {
+ type: 'Maybe',
+ child: {
+ type: 'Not',
+ child: {
+ type: 'AminoAcidEquals',
+ sequenceName: 'S',
+ position: 484,
+ symbol: 'K',
+ },
+ },
+ },
+ {
+ type: 'N-Of',
+ numberOfMatchers: 1,
+ matchExactly: false,
+ children: [
+ {
+ type: 'InsertionContains',
+ position: 22204,
+ value: 'GAGCCAGAA',
+ sequenceName: 'main',
+ },
+ {
+ type: 'HasAminoAcidMutation',
+ sequenceName: 'S',
+ position: 501,
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = validateGenomeOnly(expression);
+
+ expect(result.isGenomeOnly).toBe(true);
+ });
+
+ test('should reject nested complex expression with non-genome checks', () => {
+ const expression: SiloFilterExpression = {
+ type: 'And',
+ children: [
+ {
+ type: 'NucleotideEquals',
+ sequenceName: 'main',
+ position: 123,
+ symbol: 'A',
+ },
+ {
+ type: 'Or',
+ children: [
+ {
+ type: 'AminoAcidEquals',
+ sequenceName: 'S',
+ position: 484,
+ symbol: 'K',
+ },
+ {
+ type: 'StringEquals',
+ column: 'country',
+ value: 'USA',
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = validateGenomeOnly(expression);
+
+ expect(result.isGenomeOnly).toBe(false);
+ if (!result.isGenomeOnly) {
+ expect(result.error).toContain('StringEquals');
+ }
+ });
+
+ test('should list multiple non-genome types in error', () => {
+ const expression: SiloFilterExpression = {
+ type: 'And',
+ children: [
+ {
+ type: 'StringEquals',
+ column: 'country',
+ value: 'USA',
+ },
+ {
+ type: 'DateBetween',
+ column: 'date',
+ from: '2021-01-01',
+ to: '2021-12-31',
+ },
+ {
+ type: 'IntEquals',
+ column: 'age',
+ value: 42,
+ },
+ {
+ type: 'NucleotideEquals',
+ sequenceName: 'main',
+ position: 123,
+ symbol: 'A',
+ },
+ ],
+ };
+
+ const result = validateGenomeOnly(expression);
+
+ expect(result.isGenomeOnly).toBe(false);
+ if (!result.isGenomeOnly) {
+ expect(result.error).toContain('StringEquals');
+ expect(result.error).toContain('DateBetween');
+ expect(result.error).toContain('IntEquals');
+ }
+ });
+});
diff --git a/website/src/util/siloExpressionUtils.ts b/website/src/util/siloExpressionUtils.ts
new file mode 100644
index 000000000..7c0881b9d
--- /dev/null
+++ b/website/src/util/siloExpressionUtils.ts
@@ -0,0 +1,69 @@
+import type { SiloFilterExpression } from '../lapis/siloFilterExpression.ts';
+
+/**
+ * Result of validating a SILO filter expression
+ */
+export type GenomeCheckResult =
+ | {
+ isGenomeOnly: true;
+ }
+ | {
+ isGenomeOnly: false;
+ error: string;
+ };
+
+/**
+ * Set of expression types that are considered genome checks
+ */
+const GENOME_CHECK_TYPES = new Set([
+ 'NucleotideEquals',
+ 'HasNucleotideMutation',
+ 'AminoAcidEquals',
+ 'HasAminoAcidMutation',
+ 'InsertionContains',
+ 'AminoAcidInsertionContains',
+]);
+
+/**
+ * Traverses a SILO filter expression tree and validates that only genome checks are used.
+ *
+ * @param expression The SILO filter expression to traverse
+ * @returns Result containing validation status
+ */
+export function validateGenomeOnly(expression: SiloFilterExpression): GenomeCheckResult {
+ const nonGenomeTypes: string[] = [];
+
+ function traverse(expr: SiloFilterExpression): void {
+ const { type } = expr;
+
+ // Recursion
+ if (type === 'And' || type === 'Or' || type === 'N-Of') {
+ expr.children.forEach(traverse);
+ return;
+ }
+ if (type === 'Not' || type === 'Maybe') {
+ traverse(expr.child);
+ return;
+ }
+
+ if (type === 'True' || GENOME_CHECK_TYPES.has(type)) {
+ return; // allowed query component, do nothing
+ }
+
+ // If we reach here, it's not a genome check or logical operator
+ nonGenomeTypes.push(type);
+ }
+
+ traverse(expression);
+
+ if (nonGenomeTypes.length > 0) {
+ return {
+ isGenomeOnly: false,
+ error: `Expression contains non-genome check types: ${nonGenomeTypes.join(', ')}`,
+ };
+ }
+
+ return {
+ isGenomeOnly: true,
+ };
+}
diff --git a/website/tests/CompareSideBySidePage.ts b/website/tests/CompareSideBySidePage.ts
index bb53ddb41..f65d47fea 100644
--- a/website/tests/CompareSideBySidePage.ts
+++ b/website/tests/CompareSideBySidePage.ts
@@ -6,8 +6,8 @@ import { type OrganismWithViewKey } from '../src/views/routing';
import { compareSideBySideViewKey } from '../src/views/viewKeys';
export class CompareSideBySidePage extends ViewPage {
- public readonly removeColumnButton = this.page.getByRole('link', { name: 'Remove column' });
- public readonly addColumnButton = this.page.getByRole('link', { name: 'Add column' });
+ public readonly removeColumnButton = this.page.getByRole('button', { name: 'Remove column' });
+ public readonly addColumnButton = this.page.getByRole('button', { name: 'Add column' });
public readonly mutationField = this.page.getByRole('combobox', { name: 'Enter a mutation', exact: false });
public async goto(organism: OrganismWithViewKey) {