diff --git a/website/src/components/genspectrum/AdvancedQueryFilter.browser.spec.tsx b/website/src/components/genspectrum/AdvancedQueryFilter.browser.spec.tsx
index e224cb14..1d6e4b5f 100644
--- a/website/src/components/genspectrum/AdvancedQueryFilter.browser.spec.tsx
+++ b/website/src/components/genspectrum/AdvancedQueryFilter.browser.spec.tsx
@@ -105,6 +105,89 @@ describe('AdvancedQueryFilter', () => {
await expect.element(getByTitle('Validating')).toBeVisible();
});
+ it('allowedFields - shows error when query references a disallowed metadata field', async ({ routeMockers }) => {
+ const onInput = vi.fn();
+
+ routeMockers.lapis.mockPostQueryParse(
+ { queries: ['host:Human'], doFullValidation: true },
+ { data: [{ type: 'success', filter: { type: 'StringEquals', column: 'host', value: 'Human' } }] },
+ );
+
+ const { getByRole, getByLabelText, getByText } = render(
+ ,
+ );
+
+ await userEvent.type(getByRole('textbox'), 'host:Human');
+
+ await expect.element(getByLabelText('Error')).toBeVisible();
+ await expect.element(getByText(/"host"/)).toBeVisible();
+ await expect.element(getByText(/nextcladePangoLineage/)).toBeVisible();
+ expect(onInput).not.toHaveBeenCalled();
+ });
+
+ it('allowedFields - does not show error when all referenced fields are in the allowed list', async ({
+ routeMockers,
+ }) => {
+ const onInput = vi.fn();
+
+ routeMockers.lapis.mockPostQueryParse(
+ { queries: ['BA.1*'], doFullValidation: true },
+ {
+ data: [
+ {
+ type: 'success',
+ filter: {
+ type: 'Lineage',
+ column: 'nextcladePangoLineage',
+ value: 'BA.1',
+ includeSublineages: true,
+ },
+ },
+ ],
+ },
+ );
+
+ const { getByRole, getByTitle } = render(
+ ,
+ );
+
+ await userEvent.type(getByRole('textbox'), 'BA.1*');
+
+ await expect.element(getByTitle('Advanced query is valid')).toBeVisible();
+ await expect.poll(() => onInput).toHaveBeenCalledWith('BA.1*');
+ });
+
+ it('allowedFields - mutation-only query passes even with a restrictive allowedFields list', async ({
+ routeMockers,
+ }) => {
+ routeMockers.lapis.mockPostQueryParse(
+ { queries: ['A123T'], doFullValidation: true },
+ { data: [{ type: 'success', filter: { type: 'NucleotideEquals', position: 123, symbol: 'A' } }] },
+ );
+
+ const { getByRole, getByTitle } = render(
+ ,
+ );
+
+ await userEvent.type(getByRole('textbox'), 'A123T');
+
+ await expect.element(getByTitle('Advanced query is valid')).toBeVisible();
+ });
+
it('shows error icon with network error tooltip when LAPIS is unreachable', async ({ routeMockers }) => {
routeMockers.lapis.mockLapisDown();
diff --git a/website/src/components/genspectrum/AdvancedQueryFilter.tsx b/website/src/components/genspectrum/AdvancedQueryFilter.tsx
index 3a51bf48..b9b3ab2c 100644
--- a/website/src/components/genspectrum/AdvancedQueryFilter.tsx
+++ b/website/src/components/genspectrum/AdvancedQueryFilter.tsx
@@ -3,6 +3,7 @@ import { type FC, type InputEvent, useEffect, useRef, useState } from 'react';
import { getClientLogger } from '../../clientLogger.ts';
import { parseQuery } from '../../lapis/parseQuery.ts';
+import { extractMetadataFields } from '../../lapis/siloFilterExpression.ts';
const logger = getClientLogger('AdvancedQueryFilter');
@@ -19,9 +20,16 @@ type AdvancedQueryFilterProps = {
onInput?: (newValue: string | undefined) => void;
enabled: boolean;
lapisUrl: string;
+ allowedFields?: string[];
};
-export const AdvancedQueryFilter: FC = ({ value, onInput, enabled, lapisUrl }) => {
+export const AdvancedQueryFilter: FC = ({
+ value,
+ onInput,
+ enabled,
+ lapisUrl,
+ allowedFields,
+}) => {
const [inputValue, setInputValue] = useState(value);
const [validationState, setValidationState] = useState({ type: 'idle' });
const userEditedRef = useRef(false);
@@ -31,6 +39,18 @@ export const AdvancedQueryFilter: FC = ({ value, onInp
onSuccess: (results, query) => {
const result = results[0];
if (result.type === 'success') {
+ if (allowedFields !== undefined) {
+ const usedFields = [...new Set(extractMetadataFields(result.filter))];
+ const disallowed = usedFields.filter((col) => !allowedFields.includes(col));
+ if (disallowed.length > 0) {
+ const listed = disallowed.map((col) => `"${col}"`).join(', ');
+ setValidationState({
+ type: 'error',
+ message: `Field ${listed} is not allowed. Allowed fields: ${allowedFields.join(', ')}.`,
+ });
+ return;
+ }
+ }
setValidationState({ type: 'valid' });
onInput?.(query);
} else {
diff --git a/website/src/components/pageStateSelectors/BaselineSelector.tsx b/website/src/components/pageStateSelectors/BaselineSelector.tsx
index f56253ee..ff25f1ec 100644
--- a/website/src/components/pageStateSelectors/BaselineSelector.tsx
+++ b/website/src/components/pageStateSelectors/BaselineSelector.tsx
@@ -34,6 +34,10 @@ export type NumberRangeFilterConfig = {
sliderStep?: number;
};
+export type AdvancedQueryFilterConfig = {
+ allowedFields?: string[];
+};
+
export type BaselineFilterConfig =
| ({
type: 'date';
@@ -41,7 +45,7 @@ export type BaselineFilterConfig =
| ({ type: 'text' } & TextInputConfig)
| ({ type: 'location' } & LocationFilterConfig)
| ({ type: 'number' } & NumberRangeFilterConfig)
- | { type: 'advancedQuery' };
+ | ({ type: 'advancedQuery' } & AdvancedQueryFilterConfig);
export function BaselineSelector({
baselineFilterConfigs,
@@ -177,6 +181,7 @@ export function BaselineSelector({
value={datasetFilter.advancedQuery ?? ''}
enabled={enableAdvancedQueryFilter}
lapisUrl={lapisUrl}
+ allowedFields={config.allowedFields}
/>
);
}
diff --git a/website/src/lapis/siloFilterExpression.spec.ts b/website/src/lapis/siloFilterExpression.spec.ts
index ba3e4c11..709a9c3e 100644
--- a/website/src/lapis/siloFilterExpression.spec.ts
+++ b/website/src/lapis/siloFilterExpression.spec.ts
@@ -1,6 +1,10 @@
import { describe, expect, test } from 'vitest';
-import { siloFilterExpressionSchema } from './siloFilterExpression.ts';
+import {
+ type SiloFilterExpression,
+ extractMetadataFields,
+ siloFilterExpressionSchema,
+} from './siloFilterExpression.ts';
describe('siloFilterExpressionSchema', () => {
test('should parse StringEquals', () => {
@@ -229,7 +233,41 @@ describe('siloFilterExpressionSchema', () => {
expect(result.success).toBe(false);
});
+});
+
+describe('extractMetadataFields', () => {
+ test('extracts column names from nested And/Or expressions', () => {
+ const expr: SiloFilterExpression = {
+ type: 'And',
+ children: [
+ { type: 'Lineage', column: 'nextcladePangoLineage', value: 'BA.1', includeSublineages: true },
+ {
+ type: 'Or',
+ children: [
+ { type: 'StringEquals', column: 'country', value: 'Germany' },
+ { type: 'DateBetween', column: 'date', from: '2024-01-01', to: null },
+ ],
+ },
+ ],
+ };
+
+ expect(extractMetadataFields(expr)).toEqual(['nextcladePangoLineage', 'country', 'date']);
+ });
+
+ test('returns no columns for mutation-only expressions', () => {
+ const expr: SiloFilterExpression = {
+ type: 'And',
+ children: [
+ { type: 'NucleotideEquals', position: 123, symbol: 'A' },
+ { type: 'HasAminoAcidMutation', sequenceName: 'S', position: 501 },
+ ],
+ };
+
+ expect(extractMetadataFields(expr)).toEqual([]);
+ });
+});
+describe('siloFilterExpressionSchema (parse)', () => {
test('should accept nullable values', () => {
const data = {
type: 'StringEquals',
diff --git a/website/src/lapis/siloFilterExpression.ts b/website/src/lapis/siloFilterExpression.ts
index 2b3a1050..6407d15a 100644
--- a/website/src/lapis/siloFilterExpression.ts
+++ b/website/src/lapis/siloFilterExpression.ts
@@ -166,6 +166,35 @@ const nOfSchema: z.ZodType<{
}),
);
+/**
+ * Given an expression, returns a list of all the metadata fields that are referenced
+ * in the expression.
+ */
+export function extractMetadataFields(expr: SiloFilterExpression): string[] {
+ switch (expr.type) {
+ case 'StringEquals':
+ case 'BooleanEquals':
+ case 'Lineage':
+ case 'DateBetween':
+ case 'IntEquals':
+ case 'IntBetween':
+ case 'FloatEquals':
+ case 'FloatBetween':
+ case 'StringSearch':
+ case 'PhyloDescendantOf':
+ return [expr.column];
+ case 'And':
+ case 'Or':
+ case 'N-Of':
+ return expr.children.flatMap(extractMetadataFields);
+ case 'Not':
+ case 'Maybe':
+ return extractMetadataFields(expr.child);
+ default:
+ return [];
+ }
+}
+
// Combined union for all SiloFilterExpression types.
// This schema was initially LLM generated from the LAPIS code.
export const siloFilterExpressionSchema = z.union([