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([