From e9f592315af6a2fe7091c8184a59cab1c6f4a616 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Thu, 16 Feb 2023 12:47:51 +0100 Subject: [PATCH 01/37] feat: add a search bar component Features: - Supports multiple query languages - Decouple the business logic of query languages of the search bar component - Ability of query language to interact with the search bar Query language implementations - AQL: custom implementation of the Wazuh Query Language. Include suggestions. - UIQL: simple implementation (as another example) --- public/components/search-bar/README.md | 99 ++++ public/components/search-bar/index.tsx | 186 ++++++ .../search-bar/query-language/aql.test.tsx | 88 +++ .../search-bar/query-language/aql.tsx | 538 ++++++++++++++++++ .../search-bar/query-language/index.ts | 26 + .../search-bar/query-language/uiql.tsx | 71 +++ .../agent/components/agents-table.js | 90 ++- 7 files changed, 1095 insertions(+), 3 deletions(-) create mode 100644 public/components/search-bar/README.md create mode 100644 public/components/search-bar/index.tsx create mode 100644 public/components/search-bar/query-language/aql.test.tsx create mode 100644 public/components/search-bar/query-language/aql.tsx create mode 100644 public/components/search-bar/query-language/index.ts create mode 100644 public/components/search-bar/query-language/uiql.tsx diff --git a/public/components/search-bar/README.md b/public/components/search-bar/README.md new file mode 100644 index 0000000000..764b1848ba --- /dev/null +++ b/public/components/search-bar/README.md @@ -0,0 +1,99 @@ +# Component + +The `SearchBar` component is a base component of a search bar. + +It is designed to be extensible through the self-contained query language implementations. This means +the behavior of the search bar depends on the business logic of each query language. For example, a +query language can display suggestions according to the user input or prepend some buttons to the search bar. + +It is based on a custom `EuiSuggest` component defined in `public/components/eui-suggest/suggest.js`. So the +abilities are restricted by this one. + +## Features + +- Supports multiple query languages. +- Switch the selected query language. +- Self-contained query language implementation and ability to interact with the search bar component +- React to external changes to set the new input. This enables to change the input from external components. + +# Usage + +Basic usage: + +```tsx + +``` + +# Query languages + +The built-in query languages are: + +- AQL: API Query Language. Based on https://documentation.wazuh.com/current/user-manual/api/queries.html. + +## How to add a new query language + +### Definition + +The language expects to take the interface: + +```ts +type SearchBarQueryLanguage = { + description: string; + documentationLink?: string; + id: string; + label: string; + getConfiguration?: () => any; + run: (input: string | undefined, params: any) => any; + transformUnifiedQuery: (unifiedQuery) => any; +}; +``` + +where: + +- `description`: It is the description of the query language. This is displayed in a query language popover + on the right side of the search bar. Required. +- `documentationLink`: URL to the documentation link. Optional. +- `id`: identification of the query language. +- `label`: name +- `getConfiguration`: method that returns the configuration of the language. This allows custom behavior. +- `run`: method that returns the properties that will used by the base search bar component and the output used when searching + +Create a new file located in `public/components/search-bar/query-language` and define the expected interface; + +### Register + +Go to `public/components/search-bar/query-language/index.ts` and add the new query language: + +```ts +import { AQL } from './aql'; + +// Import the custom query language +import { CustomQL } from './custom'; + +// [...] + +// Register the query languages +export const searchBarQueryLanguages: { + [key: string]: SearchBarQueryLanguage; +} = [ + AQL, + CustomQL, // Add the new custom query language +].reduce((accum, item) => { + if (accum[item.id]) { + throw new Error(`Query language with id: ${item.id} already registered.`); + } + return { + ...accum, + [item.id]: item, + }; +}, {}); +``` diff --git a/public/components/search-bar/index.tsx b/public/components/search-bar/index.tsx new file mode 100644 index 0000000000..5497ac9965 --- /dev/null +++ b/public/components/search-bar/index.tsx @@ -0,0 +1,186 @@ +import React, { useEffect, useState } from 'react'; +import { + EuiButtonEmpty, + EuiLink, + EuiPopover, + EuiSpacer, + EuiSelect, + EuiText, +} from '@elastic/eui'; +import { EuiSuggest } from '../eui-suggest'; +import { searchBarQueryLanguages } from './query-language'; + +type Props = { + defaultMode?: string; + modes: { id: string; [key: string]: any }[]; + onChange?: (params: any) => void; + onSearch: (params: any) => void; + input?: string; +}; + +export const SearchBar = ({ + defaultMode, + modes, + onChange, + onSearch, + ...rest +}: Props) => { + // Query language ID and configuration + const [queryLanguage, setQueryLanguage] = useState<{ + id: string; + configuration: any; + }>({ + id: defaultMode || modes[0].id, + configuration: + searchBarQueryLanguages[ + defaultMode || modes[0].id + ]?.getConfiguration?.() || {}, + }); + // Popover query language is open + const [isOpenPopoverQueryLanguage, setIsOpenPopoverQueryLanguage] = + useState(false); + // Input field + const [input, setInput] = useState(''); + // Query language output of run method + const [queryLanguageOutputRun, setQueryLanguageOutputRun] = useState({ + searchBarProps: { suggestions: [] }, + output: undefined, + }); + // Controls when the suggestion popover is open/close + const [isOpenSuggestionPopover, setIsOpenSuggestionPopover] = + useState(false); + // Reference to the input + const [inputRef, setInputRef] = useState(); + + // Handler when searching + const _onSearch = (output: any) => { + // TODO: fix when searching + inputRef && inputRef.blur(); + setIsOpenSuggestionPopover(false); + onSearch(output); + }; + + // Handler on change the input field text + const onChangeInput = (event: React.ChangeEvent) => + setInput(event.target.value); + + // Handler when pressing a key + const onKeyPressHandler = event => { + if (event.key === 'Enter') { + _onSearch(queryLanguageOutputRun.output); + } + }; + + useEffect(() => { + // React to external changes and set the internal input text. Use the `transformUnifiedQuery` of + // the query language in use + setInput( + searchBarQueryLanguages[queryLanguage.id]?.transformUnifiedQuery?.( + rest.input, + ), + ); + }, [rest.input]); + + useEffect(() => { + (async () => { + // Set the query language output + setQueryLanguageOutputRun( + await searchBarQueryLanguages[queryLanguage.id].run(input, { + onSearch: _onSearch, + setInput, + closeSuggestionPopover: () => setIsOpenSuggestionPopover(false), + openSuggestionPopover: () => setIsOpenSuggestionPopover(true), + queryLanguage: { + configuration: queryLanguage.configuration, + parameters: modes.find(({ id }) => id === queryLanguage.id), + }, + setQueryLanguageConfiguration: (configuration: any) => + setQueryLanguage(state => ({ + ...state, + configuration: + configuration?.(state.configuration) || configuration, + })), + }), + ); + })(); + }, [input, queryLanguage]); + + useEffect(() => { + onChange && onChange(queryLanguageOutputRun.output); + }, [queryLanguageOutputRun.output]); + + const onQueryLanguagePopoverSwitch = () => + setIsOpenPopoverQueryLanguage(state => !state); + + return ( + {}} + isPopoverOpen={ + queryLanguageOutputRun.searchBarProps.suggestions.length > 0 && + isOpenSuggestionPopover + } + onClosePopover={() => setIsOpenSuggestionPopover(false)} + onPopoverFocus={() => setIsOpenSuggestionPopover(true)} + placeholder={'Search'} + append={ + + {searchBarQueryLanguages[queryLanguage.id].label} + + } + isOpen={isOpenPopoverQueryLanguage} + closePopover={onQueryLanguagePopoverSwitch} + > + + {searchBarQueryLanguages[queryLanguage.id].description} + + {searchBarQueryLanguages[queryLanguage.id].documentationLink && ( + <> + +
+ + Documentation + +
+ + )} + {modes?.length && modes.length > 1 && ( + <> + + ({ + value: id, + text: searchBarQueryLanguages[id].label, + }))} + value={queryLanguage.id} + onChange={(event: React.ChangeEvent) => { + const queryLanguageID: string = event.target.value; + setQueryLanguage({ + id: queryLanguageID, + configuration: + searchBarQueryLanguages[ + queryLanguageID + ]?.getConfiguration?.() || {}, + }); + setInput(''); + }} + aria-label='query-language-selector' + /> + + )} +
+ } + {...queryLanguageOutputRun.searchBarProps} + /> + ); +}; diff --git a/public/components/search-bar/query-language/aql.test.tsx b/public/components/search-bar/query-language/aql.test.tsx new file mode 100644 index 0000000000..c2af8efd9d --- /dev/null +++ b/public/components/search-bar/query-language/aql.test.tsx @@ -0,0 +1,88 @@ +import { getSuggestions, tokenizer } from './aql'; + +describe('Query language - AQL', () => { + // Tokenize the input + it.each` + input | tokens + ${''} | ${[]} + ${'f'} | ${[{ type: 'field', value: 'f' }]} + ${'field'} | ${[{ type: 'field', value: 'field' }]} + ${'field.subfield'} | ${[{ type: 'field', value: 'field.subfield' }]} + ${'field='} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }]} + ${'field!='} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '!=' }]} + ${'field>'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }]} + ${'field<'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '<' }]} + ${'field~'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '~' }]} + ${'field=value'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }]} + ${'field=value;'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ';' }]} + ${'field=value,'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ',' }]} + ${'field=value,field2'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ',' }, { type: 'field', value: 'field2' }]} + ${'field=value,field2.subfield'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ',' }, { type: 'field', value: 'field2.subfield' }]} + ${'('} | ${[{ type: 'operator_group', value: '(' }]} + ${'(f'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'f' }]} + ${'(field'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }]} + ${'(field.subfield'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field.subfield' }]} + ${'(field='} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }]} + ${'(field!='} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '!=' }]} + ${'(field>'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }]} + ${'(field<'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '<' }]} + ${'(field~'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '~' }]} + ${'(field=value'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }]} + ${'(field=value,field2=value2)'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ',' }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value2' }, { type: 'operator_group', value: ')' }]} + ${'(field=value;field2=value2)'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ';' }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value2' }, { type: 'operator_group', value: ')' }]} + ${'(field=value;field2=value2),field3=value3'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ';' }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value2' }, { type: 'operator_group', value: ')' }, { type: 'conjunction', value: ',' }, { type: 'field', value: 'field3' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value3' }]} + `('Tokenize the input: $input', ({ input, tokens }) => { + expect(tokenizer({ input })).toEqual(tokens); + }); + + // Get suggestions + it.each` + input | suggestions + ${''} | ${[]} + ${'w'} | ${[]} + ${'f'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }]} + ${'field'} | ${[{ description: 'Field2', label: 'field2', type: 'field' }, { description: 'equality', label: '=', type: 'operator_compare' }, { description: 'not equality', label: '!=', type: 'operator_compare' }, { description: 'bigger', label: '>', type: 'operator_compare' }, { description: 'smaller', label: '<', type: 'operator_compare' }, { description: 'like as', label: '~', type: 'operator_compare' }]} + ${'field='} | ${[{ label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }]} + ${'field=v'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { description: 'Current value', label: 'v', type: 'value' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=value'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { description: 'Current value', label: 'value', type: 'value' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=value;'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }, { description: 'open group', label: '(', type: 'operator_group' }]} + ${'field=value;field2'} | ${[{ description: 'equality', label: '=', type: 'operator_compare' }, { description: 'not equality', label: '!=', type: 'operator_compare' }, { description: 'bigger', label: '>', type: 'operator_compare' }, { description: 'smaller', label: '<', type: 'operator_compare' }, { description: 'like as', label: '~', type: 'operator_compare' }]} + ${'field=value;field2='} | ${[{ label: '127.0.0.1', type: 'value' }, { label: '127.0.0.2', type: 'value' }, { label: '190.0.0.1', type: 'value' }, { label: '190.0.0.2', type: 'value' }]} + ${'field=value;field2=127'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { description: 'Current value', label: '127', type: 'value' }, { label: '127.0.0.1', type: 'value' }, { label: '127.0.0.2', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + `('Get suggestion from the input: $input', async ({ input, suggestions }) => { + expect( + await getSuggestions(tokenizer({ input }), { + id: 'aql', + suggestions: { + field(currentValue) { + return [ + { label: 'field', description: 'Field' }, + { label: 'field2', description: 'Field2' }, + ].map(({ label, description }) => ({ + type: 'field', + label, + description, + })); + }, + value(currentValue = '', { previousField }) { + switch (previousField) { + case 'field': + return ['value', 'value2', 'value3', 'value4'] + .filter(value => value.startsWith(currentValue)) + .map(value => ({ type: 'value', label: value })); + break; + case 'field2': + return ['127.0.0.1', '127.0.0.2', '190.0.0.1', '190.0.0.2'] + .filter(value => value.startsWith(currentValue)) + .map(value => ({ type: 'value', label: value })); + break; + default: + return []; + break; + } + }, + }, + }), + ).toEqual(suggestions); + }); +}); diff --git a/public/components/search-bar/query-language/aql.tsx b/public/components/search-bar/query-language/aql.tsx new file mode 100644 index 0000000000..d96f31ead8 --- /dev/null +++ b/public/components/search-bar/query-language/aql.tsx @@ -0,0 +1,538 @@ +import React from 'react'; +import { EuiButtonEmpty, EuiPopover, EuiText, EuiCode } from '@elastic/eui'; +import { webDocumentationLink } from '../../../../common/services/web_documentation'; + +type ITokenType = + | 'field' + | 'operator_compare' + | 'operator_group' + | 'value' + | 'conjunction'; +type IToken = { type: ITokenType; value: string }; +type ITokens = IToken[]; + +/* API Query Language +https://documentation.wazuh.com/current/user-manual/api/queries.html + +Syntax schema: +??? +*/ + +// Language definition +const language = { + // Tokens + tokens: { + field: { + regex: /[\w.]/, + }, + // eslint-disable-next-line camelcase + operator_compare: { + literal: { + '=': 'equality', + '!=': 'not equality', + '>': 'bigger', + '<': 'smaller', + '~': 'like as', + }, + }, + conjunction: { + literal: { + ';': 'and', + ',': 'or', + }, + }, + // eslint-disable-next-line camelcase + operator_group: { + literal: { + '(': 'open group', + ')': 'close group', + }, + }, + }, +}; + +// Suggestion mapper by language token type +const suggestionMappingLanguageTokenType = { + field: { iconType: 'kqlField', color: 'tint4' }, + // eslint-disable-next-line camelcase + operator_compare: { iconType: 'kqlOperand', color: 'tint1' }, + value: { iconType: 'kqlValue', color: 'tint0' }, + conjunction: { iconType: 'kqlSelector', color: 'tint3' }, + // eslint-disable-next-line camelcase + operator_group: { iconType: 'tokenDenseVector', color: 'tint3' }, + // eslint-disable-next-line camelcase + function_search: { iconType: 'search', color: 'tint5' }, +}; + +type ITokenizerInput = { input: string; output?: ITokens }; + +/** + * Tokenize the input string. Returns an array with the tokens. + * @param param0 + * @returns + */ +export function tokenizer({ input, output = [] }: ITokenizerInput): ITokens { + if (!input) { + return output; + } + const character = input[0]; + + // When there is no tokens, the first expected values are: + if (!output.length) { + // A literal `(` + if (character === '(') { + output.push({ type: 'operator_group', value: '(' }); + } + + // Any character that matches the regex for the field + if (language.tokens.field.regex.test(character)) { + output.push({ type: 'field', value: character }); + } + } else { + // Get the last token + const lastToken = output[output.length - 1]; + + switch (lastToken.type) { + // Token: field + case 'field': { + if ( + Object.keys(language.tokens.operator_compare.literal) + .map(str => str[0]) + .includes(character) + ) { + // If the character is the first character of an operator_compare token, + // add a new token with the input character + output.push({ type: 'operator_compare', value: character }); + } else if ( + Object.keys(language.tokens.operator_compare.literal).includes( + character, + ) + ) { + // If the character matches with an operator_compare token, + // add a new token with the input character + output.push({ type: 'operator_compare', value: character }); + } else if (language.tokens.field.regex.test(character)) { + // If the character matches with a character of field, + // appends the character to the current field token + lastToken.value = lastToken.value + character; + } + break; + } + + // Token: operator_compare + case 'operator_compare': { + if ( + Object.keys(language.tokens.operator_compare.literal) + .map(str => str[lastToken.value.length]) + .includes(character) + ) { + // If the character is included in the operator_compare token, + // appends the character to the current operator_compare token + lastToken.value = lastToken.value + character; + } else { + // If the character is not a operator_compare token, + // add a new value token with the character + output.push({ type: 'value', value: character }); + } + break; + } + + // Token: value + case 'value': { + if ( + Object.keys(language.tokens.conjunction.literal).includes(character) + ) { + // If the character is a conjunction, add a new conjunction token with the character + output.push({ type: 'conjunction', value: character }); + } else if (character === ')') { + // If the character is the ")" literal, then add a new operator_group token + output.push({ type: 'operator_group', value: character }); + } else { + // Else appends the character to the current value token + lastToken.value = lastToken.value + character; + } + break; + } + + // Token: conjunction + case 'conjunction': { + if (character === '(') { + // If the character is the "(" literal, then add a new operator_group token + output.push({ type: 'operator_group', value: character }); + } else if (language.tokens.field.regex.test(character)) { + // If the character matches with a character of field, + // appends the character to the current field token + output.push({ type: 'field', value: character }); + } + break; + } + + // Token: operator_group + case 'operator_group': { + if (lastToken.value === '(') { + // If the character is the "(" literal + if (language.tokens.field.regex.test(character)) { + // If the character matches with a character of field, + // appends the character to the current field token + output.push({ type: 'field', value: character }); + } + } else if (lastToken.value === ')') { + if ( + Object.keys(language.tokens.conjunction.literal).includes(character) + ) { + // If the character is a conjunction, add a new conjunction token with the character + output.push({ type: 'conjunction', value: character }); + } + } + break; + } + + default: + } + } + + // Split the string from the second character + const substring = input.substring(1); + + // Call recursively + return tokenizer({ input: substring, output }, language); +} + +/** + * Check the + * @param tokens + * @returns + */ +function validate(tokens: ITokens): boolean { + // TODO: enhance the validation + return tokens.every( + ({ type }, index) => + type === ['field', 'operator_compare', 'value', 'conjunction'][index % 4], + ); +} + +type OptionSuggestionHandler = ( + currentValue: string | undefined, + { + previousField, + previousOperatorCompare, + }: { previousField: string; previousOperatorCompare: string }, +) => Promise<{ description?: string; label: string; type: string }[]>; + +type optionsQL = { + suggestions: { + field: OptionSuggestionHandler; + value: OptionSuggestionHandler; + }; +}; + +/** + * Get the last token by type + * @param tokens Tokens + * @param tokenType token type to search + * @returns + */ +function getLastTokenByType( + tokens: ITokens, + tokenType: ITokenType, +): IToken | undefined { + // Find the last token by type + // Reverse the tokens array and use the Array.protorype.find method + const shallowCopyArray = Array.from([...tokens]); + const shallowCopyArrayReversed = shallowCopyArray.reverse(); + const tokenFound = shallowCopyArrayReversed.find( + ({ type }) => type === tokenType, + ); + return tokenFound; +} + +/** + * Get the suggestions from the tokens + * @param tokens + * @param language + * @param options + * @returns + */ +export async function getSuggestions(tokens: ITokens, options: optionsQL) { + if (!tokens.length) { + return []; + } + + // Get last token + const lastToken = tokens[tokens.length - 1]; + + switch (lastToken.type) { + case 'field': + return [ + // fields that starts with the input but is not equals + ...(await options.suggestions.field()).filter( + ({ label }) => + label.startsWith(lastToken.value) && label !== lastToken.value, + ), + // operators if the input field is exact + ...((await options.suggestions.field()).some( + ({ label }) => label === lastToken.value, + ) + ? [ + ...Object.keys(language.tokens.operator_compare.literal).map( + operator => ({ + type: 'operator_compare', + label: operator, + description: + language.tokens.operator_compare.literal[operator], + }), + ), + ] + : []), + ]; + break; + case 'operator_compare': + return [ + ...Object.keys(language.tokens.operator_compare.literal) + .filter( + operator => + operator.startsWith(lastToken.value) && + operator !== lastToken.value, + ) + .map(operator => ({ + type: 'operator_compare', + label: operator, + description: language.tokens.operator_compare.literal[operator], + })), + ...(Object.keys(language.tokens.operator_compare.literal).some( + operator => operator === lastToken.value, + ) + ? [ + ...(await options.suggestions.value(undefined, { + previousField: getLastTokenByType(tokens, 'field')!.value, + previousOperatorCompare: getLastTokenByType( + tokens, + 'operator_compare', + )!.value, + })), + ] + : []), + ]; + break; + case 'value': + return [ + ...(lastToken.value + ? [ + { + type: 'function_search', + label: 'Search', + description: 'Run the search query', + }, + ] + : []), + { + type: 'value', + label: lastToken.value, + description: 'Current value', + }, + ...(await options.suggestions.value(lastToken.value, { + previousField: getLastTokenByType(tokens, 'field')!.value, + previousOperatorCompare: getLastTokenByType( + tokens, + 'operator_compare', + )!.value, + })), + ...Object.entries(language.tokens.conjunction.literal).map( + ([conjunction, description]) => ({ + type: 'conjunction', + label: conjunction, + description, + }), + ), + { + type: 'operator_group', + label: ')', + description: language.tokens.operator_group.literal[')'], + }, + ]; + break; + case 'conjunction': + return [ + ...Object.keys(language.tokens.conjunction.literal) + .filter( + conjunction => + conjunction.startsWith(lastToken.value) && + conjunction !== lastToken.value, + ) + .map(conjunction => ({ + type: 'conjunction', + label: conjunction, + description: language.tokens.conjunction.literal[conjunction], + })), + // fields if the input field is exact + ...(Object.keys(language.tokens.conjunction.literal).some( + conjunction => conjunction === lastToken.value, + ) + ? [ + ...(await options.suggestions.field()).map( + ({ label, description }) => ({ + type: 'field', + label, + description, + }), + ), + ] + : []), + { + type: 'operator_group', + label: '(', + description: language.tokens.operator_group.literal['('], + }, + ]; + break; + case 'operator_group': + if (lastToken.value === '(') { + return [ + // fields + ...(await options.suggestions.field()).map( + ({ label, description }) => ({ type: 'field', label, description }), + ), + ]; + } else if (lastToken.value === ')') { + return [ + // conjunction + ...Object.keys(language.tokens.conjunction.literal).map( + conjunction => ({ + type: 'conjunction', + label: conjunction, + description: language.tokens.conjunction.literal[conjunction], + }), + ), + ]; + } + break; + default: + return []; + break; + } + + return []; +} + +/** + * Transform the suggestion object to the expected object by EuiSuggestItem + * @param suggestions + * @param options + * @returns + */ +function transformSuggestionsToUI( + suggestions: { type: string; label: string; description?: string }[], + mapSuggestionByLanguageToken: any, +) { + return suggestions.map(({ type, ...rest }) => ({ + type: { ...mapSuggestionByLanguageToken[type] }, + ...rest, + })); +} + +/** + * Get the output from the input + * @param input + * @returns + */ +function getOutput(input: string, options: {implicitQuery?: string} = {}) { + return { + language: AQL.id, + query: `${options?.implicitQuery ?? ''}${input}`, + }; +}; + +export const AQL = { + id: 'aql', + label: 'AQL', + description: 'API Query Language (AQL) allows to do queries.', + documentationLink: webDocumentationLink('user-manual/api/queries.html'), + getConfiguration() { + return { + isOpenPopoverImplicitFilter: false, + }; + }, + async run(input, params) { + // Get the tokens from the input + const tokens: ITokens = tokenizer({ input }, language); + + return { + searchBarProps: { + // Props that will be used by the EuiSuggest component + // Suggestions + suggestions: transformSuggestionsToUI( + await getSuggestions(tokens, params.queryLanguage.parameters), + suggestionMappingLanguageTokenType, + ), + // Handler to manage when clicking in a suggestion item + onItemClick: item => { + // When the clicked item has the `search` iconType, run the `onSearch` function + if (item.type.iconType === 'search') { + // Execute the search action + params.onSearch(getOutput(input, params.queryLanguage.parameters)); + } else { + // When the clicked item has another iconType + const lastToken: IToken = tokens[tokens.length - 1]; + // if the clicked suggestion is of same type of last token + if ( + suggestionMappingLanguageTokenType[lastToken.type].iconType === + item.type.iconType + ) { + // replace the value of last token + lastToken.value = item.label; + } else { + // add a new token of the selected type and value + tokens.push({ + type: Object.entries(suggestionMappingLanguageTokenType).find( + ([, { iconType }]) => iconType === item.type.iconType, + )[0], + value: item.label, + }); + } + } + // Change the input + params.setInput(tokens.map(({ value }) => value).join('')); + }, + prepend: params.queryLanguage.parameters.implicitQuery ? ( + + params.setQueryLanguageConfiguration(state => ({ + ...state, + isOpenPopoverImplicitFilter: + !state.isOpenPopoverImplicitFilter, + })) + } + iconType='filter' + > + + {params.queryLanguage.parameters.implicitQuery} + + + } + isOpen={ + params.queryLanguage.configuration.isOpenPopoverImplicitFilter + } + closePopover={() => + params.setQueryLanguageConfiguration(state => ({ + ...state, + isOpenPopoverImplicitFilter: false, + })) + } + > + + Implicit query:{' '} + {params.queryLanguage.parameters.implicitQuery} + + This query is added to the input. + + ) : null, + }, + output: getOutput(input, params.queryLanguage.parameters), + }; + }, + transformUnifiedQuery(unifiedQuery) { + return unifiedQuery; + }, +}; diff --git a/public/components/search-bar/query-language/index.ts b/public/components/search-bar/query-language/index.ts new file mode 100644 index 0000000000..1895aae4fb --- /dev/null +++ b/public/components/search-bar/query-language/index.ts @@ -0,0 +1,26 @@ +import { AQL } from './aql'; +import { UIQL } from './uiql'; + +type SearchBarQueryLanguage = { + description: string; + documentationLink?: string; + id: string; + label: string; + getConfiguration?: () => any; + run: (input: string | undefined, params: any) => any; + transformUnifiedQuery: (unifiedQuery) => any; +}; + +// Register the query languages +export const searchBarQueryLanguages: { + [key: string]: SearchBarQueryLanguage; +} = [AQL, UIQL].reduce((accum, item) => { + if (accum[item.id]) { + throw new Error(`Query language with id: ${item.id} already registered.`); + } + return { + ...accum, + [item.id]: item, + }; + ['hola']; +}, {}); diff --git a/public/components/search-bar/query-language/uiql.tsx b/public/components/search-bar/query-language/uiql.tsx new file mode 100644 index 0000000000..97b1927f54 --- /dev/null +++ b/public/components/search-bar/query-language/uiql.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { EuiButtonEmpty, EuiPopover, EuiText, EuiCode } from '@elastic/eui'; + +/* UI Query language +https://documentation.wazuh.com/current/user-manual/api/queries.html + +// Example of another query language definition +*/ + +/** + * Get the output from the input + * @param input + * @returns + */ +function getOutput(input: string, options: {implicitQuery?: string} = {}) { + return { + language: UIQL.id, + query: `${options?.implicitQuery ?? ''}${input}`, + }; +}; + +export const UIQL = { + id: 'uiql', + label: 'UIQL', + description: 'UIQL allows to do queries.', + documentationLink: '', + getConfiguration() { + return { + anotherProp: false, + }; + }, + async run(input, params) { + // Get the tokens from the input + return { + searchBarProps: { + // Props that will be used by the EuiSuggest component + // Suggestions + suggestions: [], + // Handler to manage when clicking in a suggestion item + prepend: params.queryLanguage.parameters.implicitQuery ? ( + + params.setQueryLanguageConfiguration(state => ({ + ...state, + anotherProp: !state.anotherProp, + })) + } + iconType='filter' + > + } + isOpen={params.queryLanguage.configuration.anotherProp} + closePopover={() => + params.setQueryLanguageConfiguration(state => ({ + ...state, + anotherProp: false, + })) + } + > + + Implicit UIQL query:{' '} + {params.queryLanguage.parameters.implicitQuery} + + + ) : null, + }, + output: getOutput(input, params.queryLanguage.parameters), + }; + }, +}; diff --git a/public/controllers/agent/components/agents-table.js b/public/controllers/agent/components/agents-table.js index 55ce4d48d9..ddc7e68195 100644 --- a/public/controllers/agent/components/agents-table.js +++ b/public/controllers/agent/components/agents-table.js @@ -41,6 +41,7 @@ import { UI_ERROR_SEVERITIES } from '../../../react-services/error-orchestrator/ import { getErrorOrchestrator } from '../../../react-services/common-services'; import { AgentStatus } from '../../../components/agents/agent_status'; import { AgentSynced } from '../../../components/agents/agent-synced'; +import { SearchBar } from '../../../components/search-bar'; export const AgentsTable = withErrorBoundary( class AgentsTable extends Component { @@ -611,12 +612,95 @@ export const AgentsTable = withErrorBoundary( noDeleteFiltersOnUpdateSuggests filters={this.state.filters} suggestions={this.suggestions} - onFiltersChange={(filters) => this.setState({ filters, pageIndex: 0 })} - placeholder="Filter or search agent" + onFiltersChange={filters => + this.setState({ filters, pageIndex: 0 }) + } + placeholder='Filter or search agent' + /> + {/** Example implementation */} + ({ type: 'field', ...field })); + }, + value: async (currentValue, { previousField }) => { + switch (previousField) { + case 'status': + try { + const results = await this.props.wzReq( + 'GET', + `/agents/stats/distinct`, + { + params: { + fields: 'status', + limit: 30, + }, + }, + ); + + return results.data.data.affected_items.map( + ({ status }) => ({ + type: 'value', + label: status, + }), + ); + } catch (error) { + console.log({ error }); + return []; + } + break; + case 'ip': + try { + const results = await this.props.wzReq( + 'GET', + `/agents/stats/distinct`, + { + params: { + fields: 'ip', + limit: 30, + }, + }, + ); + + console.log({ results }); + return results.data.data.affected_items.map( + ({ ip }) => ({ type: 'value', label: ip }), + ); + } catch (error) { + console.log({ error }); + return []; + } + break; + default: + return []; + break; + } + }, + }, + }, + { + id: 'uiql', + implicitQuery: 'id!=000;', + }, + ]} + onChange={console.log} + onSearch={console.log} /> - this.reloadAgents()}> + this.reloadAgents()} + > Refresh From eff5d2b3d8181bd12c05d919ac73a3ea4294f2a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Wed, 1 Mar 2023 10:00:44 +0100 Subject: [PATCH 02/37] feat(search-bar): change the AQL implemenation to use the regular expression used in the Wazuh manager API - Change the implementation of AQL query language to use the regular expression decomposition defined in the Wazuh manager API - Adapt the tests for the tokenizer and getting the suggestions - Enchance documentation of search bar - Add documentation of AQL query language - Add more fields and values for the use example in Agents section - Add description to the query language select input --- public/components/search-bar/README.md | 131 ++++++- public/components/search-bar/index.tsx | 43 +- .../search-bar/query-language/aql.md | 178 +++++++++ .../search-bar/query-language/aql.test.tsx | 61 ++- .../search-bar/query-language/aql.tsx | 367 +++++++++--------- .../agent/components/agents-table.js | 168 +++++--- 6 files changed, 669 insertions(+), 279 deletions(-) create mode 100644 public/components/search-bar/query-language/aql.md diff --git a/public/components/search-bar/README.md b/public/components/search-bar/README.md index 764b1848ba..046157a9a0 100644 --- a/public/components/search-bar/README.md +++ b/public/components/search-bar/README.md @@ -13,7 +13,7 @@ abilities are restricted by this one. - Supports multiple query languages. - Switch the selected query language. -- Self-contained query language implementation and ability to interact with the search bar component +- Self-contained query language implementation and ability to interact with the search bar component. - React to external changes to set the new input. This enables to change the input from external components. # Usage @@ -28,6 +28,135 @@ Basic usage: { id: 'aql', // specific query language parameters + implicitQuery: 'id!=000;', + suggestions: { + field(currentValue) { + return [ + { label: 'configSum', description: 'Config sum' }, + { label: 'dateAdd', description: 'Date add' }, + { label: 'id', description: 'ID' }, + { label: 'ip', description: 'IP address' }, + { label: 'group', description: 'Group' }, + { label: 'group_config_status', description: 'Synced configuration status' }, + { label: 'lastKeepAline', description: 'Date add' }, + { label: 'manager', description: 'Manager' }, + { label: 'mergedSum', description: 'Merged sum' }, + { label: 'name', description: 'Agent name' }, + { label: 'node_name', description: 'Node name' }, + { label: 'os.platform', description: 'Operating system platform' }, + { label: 'status', description: 'Status' }, + { label: 'version', description: 'Version' }, + ] + .map(field => ({ type: 'field', ...field })); + }, + value: async (currentValue, { previousField }) => { + switch (previousField) { + case 'configSum': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'dateAdd': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'id': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'ip': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'group': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'group_config_status': + return [AGENT_SYNCED_STATUS.SYNCED, AGENT_SYNCED_STATUS.NOT_SYNCED].map( + (status) => ({ + type: 'value', + label: status, + }), + ); + break; + case 'lastKeepAline': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'manager': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'mergedSum': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'name': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'node_name': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'os.platform': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'status': + return UI_ORDER_AGENT_STATUS.map( + (status) => ({ + type: 'value', + label: status, + }), + ); + break; + case 'version': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + default: + return []; + break; + } + }, + } }, ]} > diff --git a/public/components/search-bar/index.tsx b/public/components/search-bar/index.tsx index 5497ac9965..01a3764e14 100644 --- a/public/components/search-bar/index.tsx +++ b/public/components/search-bar/index.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import { EuiButtonEmpty, + EuiFormRow, EuiLink, EuiPopover, EuiSpacer, @@ -156,26 +157,28 @@ export const SearchBar = ({ {modes?.length && modes.length > 1 && ( <> - ({ - value: id, - text: searchBarQueryLanguages[id].label, - }))} - value={queryLanguage.id} - onChange={(event: React.ChangeEvent) => { - const queryLanguageID: string = event.target.value; - setQueryLanguage({ - id: queryLanguageID, - configuration: - searchBarQueryLanguages[ - queryLanguageID - ]?.getConfiguration?.() || {}, - }); - setInput(''); - }} - aria-label='query-language-selector' - /> + + ({ + value: id, + text: searchBarQueryLanguages[id].label, + }))} + value={queryLanguage.id} + onChange={(event: React.ChangeEvent) => { + const queryLanguageID: string = event.target.value; + setQueryLanguage({ + id: queryLanguageID, + configuration: + searchBarQueryLanguages[ + queryLanguageID + ]?.getConfiguration?.() || {}, + }); + setInput(''); + }} + aria-label='query-language-selector' + /> + )} diff --git a/public/components/search-bar/query-language/aql.md b/public/components/search-bar/query-language/aql.md new file mode 100644 index 0000000000..637e8c9147 --- /dev/null +++ b/public/components/search-bar/query-language/aql.md @@ -0,0 +1,178 @@ +# Query Language - AQL + +AQL (API Query Language) is a query language based in the `q` query parameters of the Wazuh API +endpoints. + +Documentation: https://wazuh.com/./user-manual/api/queries.html + +The implementation is adapted to work with the search bar component defined +`public/components/search-bar/index.tsx`. + +## Features +- Suggestions for `fields` (configurable), `operators` and `values` (configurable) +- Support implicit query + +## Options + +- `implicitQuery`: add an implicit query that is added to the user input. This can't be changed by +the user. If this is defined, will be displayed as a prepend of the search bar. + +```ts +// language options +implicitQuery: 'id!=000;' // ID is not 000 and +``` + +- `suggestions`: define the suggestion handlers. This is required. + + - `field`: method that returns the suggestions for the fields + + ```ts + // language options + field(currentValue) { + return [ + { label: 'configSum', description: 'Config sum' }, + { label: 'dateAdd', description: 'Date add' }, + { label: 'id', description: 'ID' }, + { label: 'ip', description: 'IP address' }, + { label: 'group', description: 'Group' }, + { label: 'group_config_status', description: 'Synced configuration status' }, + { label: 'lastKeepAline', description: 'Date add' }, + { label: 'manager', description: 'Manager' }, + { label: 'mergedSum', description: 'Merged sum' }, + { label: 'name', description: 'Agent name' }, + { label: 'node_name', description: 'Node name' }, + { label: 'os.platform', description: 'Operating system platform' }, + { label: 'status', description: 'Status' }, + { label: 'version', description: 'Version' }, + ] + .map(field => ({ type: 'field', ...field })); + } + ``` + + - `value`: method that returns the suggestion for the values + ```ts + // language options + value: async (currentValue, { previousField }) => { + switch (previousField) { + case 'configSum': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'dateAdd': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'id': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'ip': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'group': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'group_config_status': + return [AGENT_SYNCED_STATUS.SYNCED, AGENT_SYNCED_STATUS.NOT_SYNCED].map( + (status) => ({ + type: 'value', + label: status, + }), + ); + break; + case 'lastKeepAline': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'manager': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'mergedSum': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'name': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'node_name': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'os.platform': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'status': + return UI_ORDER_AGENT_STATUS.map( + (status) => ({ + type: 'value', + label: status, + }), + ); + break; + case 'version': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + default: + return []; + break; + } + } + ``` + +## How to get the suggestions + +```mermaid +graph TD; + user_input[User input]-->tokenize; + subgraph tokenize + tokenize_regex + end + + tokenize-->suggestions[Get suggestions]; + subgraph suggestions[Get suggestions]; + get_last_token_with_value-->get_suggestions[Get suggestions] + end + suggestions-->EuiSuggestItem +``` \ No newline at end of file diff --git a/public/components/search-bar/query-language/aql.test.tsx b/public/components/search-bar/query-language/aql.test.tsx index c2af8efd9d..37c34904bc 100644 --- a/public/components/search-bar/query-language/aql.test.tsx +++ b/public/components/search-bar/query-language/aql.test.tsx @@ -1,38 +1,35 @@ -import { getSuggestions, tokenizer } from './aql'; +import { getSuggestionsAPI, tokenizerAPI, validate } from './aql'; describe('Query language - AQL', () => { // Tokenize the input it.each` - input | tokens - ${''} | ${[]} - ${'f'} | ${[{ type: 'field', value: 'f' }]} - ${'field'} | ${[{ type: 'field', value: 'field' }]} - ${'field.subfield'} | ${[{ type: 'field', value: 'field.subfield' }]} - ${'field='} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }]} - ${'field!='} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '!=' }]} - ${'field>'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }]} - ${'field<'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '<' }]} - ${'field~'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '~' }]} - ${'field=value'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }]} - ${'field=value;'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ';' }]} - ${'field=value,'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ',' }]} - ${'field=value,field2'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ',' }, { type: 'field', value: 'field2' }]} - ${'field=value,field2.subfield'} | ${[{ type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ',' }, { type: 'field', value: 'field2.subfield' }]} - ${'('} | ${[{ type: 'operator_group', value: '(' }]} - ${'(f'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'f' }]} - ${'(field'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }]} - ${'(field.subfield'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field.subfield' }]} - ${'(field='} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }]} - ${'(field!='} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '!=' }]} - ${'(field>'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }]} - ${'(field<'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '<' }]} - ${'(field~'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '~' }]} - ${'(field=value'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }]} - ${'(field=value,field2=value2)'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ',' }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value2' }, { type: 'operator_group', value: ')' }]} - ${'(field=value;field2=value2)'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ';' }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value2' }, { type: 'operator_group', value: ')' }]} - ${'(field=value;field2=value2),field3=value3'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'conjunction', value: ';' }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value2' }, { type: 'operator_group', value: ')' }, { type: 'conjunction', value: ',' }, { type: 'field', value: 'field3' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value3' }]} - `('Tokenize the input: $input', ({ input, tokens }) => { - expect(tokenizer({ input })).toEqual(tokens); + input | tokens + ${''} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'f'} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'f' }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field'} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field='} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field=value'} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field=value with spaces'} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value with spaces' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field=value with spaces<'} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value with spaces<' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field=value with (parenthesis)'} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value with (parenthesis)' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field=value;'} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field=value;field2'} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field=value;field2!='} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '!=' }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field=value;field2!=value2'} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '!=' }, { type: 'value', value: 'value2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'('} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>2'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>2)'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: ')' }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>2);'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: ')' }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>2;'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>2;field2'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>2;field2='} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>2;field2=value2'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>2;field2=value2)'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value2' }, { type: 'operator_group', value: ')' }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>2;field2=custom value())'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'custom value()' }, { type: 'operator_group', value: ')' }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + `(`Tokenizer API input $input`, ({input, tokens}) => { + expect(tokenizerAPI(input)).toEqual(tokens); }); // Get suggestions @@ -51,7 +48,7 @@ describe('Query language - AQL', () => { ${'field=value;field2=127'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { description: 'Current value', label: '127', type: 'value' }, { label: '127.0.0.1', type: 'value' }, { label: '127.0.0.2', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} `('Get suggestion from the input: $input', async ({ input, suggestions }) => { expect( - await getSuggestions(tokenizer({ input }), { + await getSuggestionsAPI(tokenizerAPI(input), { id: 'aql', suggestions: { field(currentValue) { diff --git a/public/components/search-bar/query-language/aql.tsx b/public/components/search-bar/query-language/aql.tsx index d96f31ead8..9569b807d0 100644 --- a/public/components/search-bar/query-language/aql.tsx +++ b/public/components/search-bar/query-language/aql.tsx @@ -12,10 +12,18 @@ type IToken = { type: ITokenType; value: string }; type ITokens = IToken[]; /* API Query Language +Define the API Query Language to use in the search bar. +It is based in the language used by the q query parameter. https://documentation.wazuh.com/current/user-manual/api/queries.html -Syntax schema: +Use the regular expression of API with some modifications to allow the decomposition of +input in entities that doesn't compose a valid query. It allows get not-completed queries. + +API schema: ??? + +Implemented schema: +?????? */ // Language definition @@ -64,151 +72,127 @@ const suggestionMappingLanguageTokenType = { function_search: { iconType: 'search', color: 'tint5' }, }; -type ITokenizerInput = { input: string; output?: ITokens }; /** * Tokenize the input string. Returns an array with the tokens. - * @param param0 + * @param input * @returns */ -export function tokenizer({ input, output = [] }: ITokenizerInput): ITokens { - if (!input) { - return output; - } - const character = input[0]; - - // When there is no tokens, the first expected values are: - if (!output.length) { - // A literal `(` - if (character === '(') { - output.push({ type: 'operator_group', value: '(' }); - } - - // Any character that matches the regex for the field - if (language.tokens.field.regex.test(character)) { - output.push({ type: 'field', value: character }); - } - } else { - // Get the last token - const lastToken = output[output.length - 1]; - - switch (lastToken.type) { - // Token: field - case 'field': { - if ( - Object.keys(language.tokens.operator_compare.literal) - .map(str => str[0]) - .includes(character) - ) { - // If the character is the first character of an operator_compare token, - // add a new token with the input character - output.push({ type: 'operator_compare', value: character }); - } else if ( - Object.keys(language.tokens.operator_compare.literal).includes( - character, - ) - ) { - // If the character matches with an operator_compare token, - // add a new token with the input character - output.push({ type: 'operator_compare', value: character }); - } else if (language.tokens.field.regex.test(character)) { - // If the character matches with a character of field, - // appends the character to the current field token - lastToken.value = lastToken.value + character; - } - break; - } - - // Token: operator_compare - case 'operator_compare': { - if ( - Object.keys(language.tokens.operator_compare.literal) - .map(str => str[lastToken.value.length]) - .includes(character) - ) { - // If the character is included in the operator_compare token, - // appends the character to the current operator_compare token - lastToken.value = lastToken.value + character; - } else { - // If the character is not a operator_compare token, - // add a new value token with the character - output.push({ type: 'value', value: character }); - } - break; - } - - // Token: value - case 'value': { - if ( - Object.keys(language.tokens.conjunction.literal).includes(character) - ) { - // If the character is a conjunction, add a new conjunction token with the character - output.push({ type: 'conjunction', value: character }); - } else if (character === ')') { - // If the character is the ")" literal, then add a new operator_group token - output.push({ type: 'operator_group', value: character }); - } else { - // Else appends the character to the current value token - lastToken.value = lastToken.value + character; - } - break; - } +export function tokenizerAPI(input: string): ITokens{ + // API regular expression + // https://github.com/wazuh/wazuh/blob/v4.4.0-rc1/framework/wazuh/core/utils.py#L1242-L1257 + // self.query_regex = re.compile( + // # A ( character. + // r"(\()?" + + // # Field name: name of the field to look on DB. + // r"([\w.]+)" + + // # Operator: looks for '=', '!=', '<', '>' or '~'. + // rf"([{''.join(self.query_operators.keys())}]{{1,2}})" + + // # Value: A string. + // r"((?:(?:\((?:\[[\[\]\w _\-.,:?\\/'\"=@%<>{}]*]|[\[\]\w _\-.:?\\/'\"=@%<>{}]*)\))*" + // r"(?:\[[\[\]\w _\-.,:?\\/'\"=@%<>{}]*]|[\[\]\w _\-.:?\\/'\"=@%<>{}]+)" + // r"(?:\((?:\[[\[\]\w _\-.,:?\\/'\"=@%<>{}]*]|[\[\]\w _\-.:?\\/'\"=@%<>{}]*)\))*)+)" + + // # A ) character. + // r"(\))?" + + // # Separator: looks for ';', ',' or nothing. + // rf"([{''.join(self.query_separators.keys())}])?" + // ) - // Token: conjunction - case 'conjunction': { - if (character === '(') { - // If the character is the "(" literal, then add a new operator_group token - output.push({ type: 'operator_group', value: character }); - } else if (language.tokens.field.regex.test(character)) { - // If the character matches with a character of field, - // appends the character to the current field token - output.push({ type: 'field', value: character }); - } - break; - } - - // Token: operator_group - case 'operator_group': { - if (lastToken.value === '(') { - // If the character is the "(" literal - if (language.tokens.field.regex.test(character)) { - // If the character matches with a character of field, - // appends the character to the current field token - output.push({ type: 'field', value: character }); - } - } else if (lastToken.value === ')') { - if ( - Object.keys(language.tokens.conjunction.literal).includes(character) - ) { - // If the character is a conjunction, add a new conjunction token with the character - output.push({ type: 'conjunction', value: character }); - } - } - break; - } - - default: - } - } - - // Split the string from the second character - const substring = input.substring(1); + const re = new RegExp( + // The following regular expression is based in API one but was modified to use named groups + // and added the optional operator to allow matching the entities when the query is not + // completed. This helps to tokenize the query and manage when the input is not completed. + // A ( character. + '(?\\()?' + + // Field name: name of the field to look on DB. + '(?[\\w.]+)?' + // Added a optional find + // Operator: looks for '=', '!=', '<', '>' or '~'. + // This seems to be a bug because is not searching the literal valid operators. + // I guess the operator is validated after the regular expression matches + `(?[${Object.keys(language.tokens.operator_compare.literal)}]{1,2})?` + // Added a optional find + // Value: A string. + '(?(?:(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\/\'"=@%<>{}]*)\\))*' + + '(?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]+)' + + '(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]*)\\))*)+)?' + // Added a optional find + // A ) character. + '(?\\))?' + + `(?[${Object.keys(language.tokens.conjunction.literal)}])?`, + 'g' + ); - // Call recursively - return tokenizer({ input: substring, output }, language); -} + return [ + ...input.matchAll(re)] + .map( + ({groups}) => Object.entries(groups) + .map(([key, value]) => ({ + type: key.startsWith('operator_group') ? 'operator_group' : key, + value}) + ) + ).flat(); +}; /** - * Check the + * Check if the input is valid * @param tokens * @returns */ -function validate(tokens: ITokens): boolean { +export function validate(input: string, options): boolean { // TODO: enhance the validation - return tokens.every( - ({ type }, index) => - type === ['field', 'operator_compare', 'value', 'conjunction'][index % 4], + + // API regular expression + // self.query_regex = re.compile( + // # A ( character. + // r"(\()?" + + // # Field name: name of the field to look on DB. + // r"([\w.]+)" + + // # Operator: looks for '=', '!=', '<', '>' or '~'. + // rf"([{''.join(self.query_operators.keys())}]{{1,2}})" + + // # Value: A string. + // r"((?:(?:\((?:\[[\[\]\w _\-.,:?\\/'\"=@%<>{}]*]|[\[\]\w _\-.:?\\/'\"=@%<>{}]*)\))*" + // r"(?:\[[\[\]\w _\-.,:?\\/'\"=@%<>{}]*]|[\[\]\w _\-.:?\\/'\"=@%<>{}]+)" + // r"(?:\((?:\[[\[\]\w _\-.,:?\\/'\"=@%<>{}]*]|[\[\]\w _\-.:?\\/'\"=@%<>{}]*)\))*)+)" + + // # A ) character. + // r"(\))?" + + // # Separator: looks for ';', ',' or nothing. + // rf"([{''.join(self.query_separators.keys())}])?" + // ) + + const re = new RegExp( + // A ( character. + '(\\()?' + + // Field name: name of the field to look on DB. + '([\\w.]+)?' + + // Operator: looks for '=', '!=', '<', '>' or '~'. + // This seems to be a bug because is not searching the literal valid operators. + // I guess the operator is validated after the regular expression matches + `([${Object.keys(language.tokens.operator_compare.literal)}]{1,2})?` + + // Value: A string. + '((?:(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\/\'"=@%<>{}]*)\\))*' + + '(?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]+)' + + '(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]*)\\))*)+)?' + + // '([\\w]+)'+ + // A ) character. + '(\\))?' + + `([${Object.keys(language.tokens.conjunction.literal)}])?`, + 'g' ); + + [...input.matchAll(re)].reduce((accum, [_, operator_group_open, field, operator, value, operator_group_close, conjunction ]) => { + if(!accum){ + return accum; + }; + + return [operator_group_open, field, operator, value, operator_group_close, conjunction] + }, true); + + const errors = []; + + for (let [_, operator_group_open, field, operator, value, operator_group_close, conjunction ] in input.matchAll(re)) { + if(!options.fields.includes(field)){ + errors.push(`Field ${field} is not valid.`) + }; + } + return errors.length === 0; } type OptionSuggestionHandler = ( @@ -227,12 +211,30 @@ type optionsQL = { }; /** - * Get the last token by type + * Get the last token with value * @param tokens Tokens * @param tokenType token type to search * @returns */ -function getLastTokenByType( +function getLastTokenWithValue( + tokens: ITokens +): IToken | undefined { + // Reverse the tokens array and use the Array.protorype.find method + const shallowCopyArray = Array.from([...tokens]); + const shallowCopyArrayReversed = shallowCopyArray.reverse(); + const tokenFound = shallowCopyArrayReversed.find( + ({ value }) => value, + ); + return tokenFound; +} + +/** + * Get the last token with value by type + * @param tokens Tokens + * @param tokenType token type to search + * @returns + */ +function getLastTokenWithValueByType( tokens: ITokens, tokenType: ITokenType, ): IToken | undefined { @@ -241,7 +243,7 @@ function getLastTokenByType( const shallowCopyArray = Array.from([...tokens]); const shallowCopyArrayReversed = shallowCopyArray.reverse(); const tokenFound = shallowCopyArrayReversed.find( - ({ type }) => type === tokenType, + ({ type, value }) => type === tokenType && value, ); return tokenFound; } @@ -253,13 +255,18 @@ function getLastTokenByType( * @param options * @returns */ -export async function getSuggestions(tokens: ITokens, options: optionsQL) { +export async function getSuggestionsAPI(tokens: ITokens, options: optionsQL) { if (!tokens.length) { return []; } // Get last token - const lastToken = tokens[tokens.length - 1]; + const lastToken = getLastTokenWithValue(tokens); + + // If it can't get a token with value, then no return suggestions + if(!lastToken?.type){ + return []; + }; switch (lastToken.type) { case 'field': @@ -274,15 +281,15 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL) { ({ label }) => label === lastToken.value, ) ? [ - ...Object.keys(language.tokens.operator_compare.literal).map( - operator => ({ - type: 'operator_compare', - label: operator, - description: - language.tokens.operator_compare.literal[operator], - }), - ), - ] + ...Object.keys(language.tokens.operator_compare.literal).map( + operator => ({ + type: 'operator_compare', + label: operator, + description: + language.tokens.operator_compare.literal[operator], + }), + ), + ] : []), ]; break; @@ -303,14 +310,14 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL) { operator => operator === lastToken.value, ) ? [ - ...(await options.suggestions.value(undefined, { - previousField: getLastTokenByType(tokens, 'field')!.value, - previousOperatorCompare: getLastTokenByType( - tokens, - 'operator_compare', - )!.value, - })), - ] + ...(await options.suggestions.value(undefined, { + previousField: getLastTokenWithValueByType(tokens, 'field')!.value, + previousOperatorCompare: getLastTokenWithValueByType( + tokens, + 'operator_compare', + )!.value, + })), + ] : []), ]; break; @@ -318,27 +325,22 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL) { return [ ...(lastToken.value ? [ - { - type: 'function_search', - label: 'Search', - description: 'Run the search query', - }, - ] + { + type: 'function_search', + label: 'Search', + description: 'Run the search query', + }, + ] : []), - { - type: 'value', - label: lastToken.value, - description: 'Current value', - }, ...(await options.suggestions.value(lastToken.value, { - previousField: getLastTokenByType(tokens, 'field')!.value, - previousOperatorCompare: getLastTokenByType( + previousField: getLastTokenWithValueByType(tokens, 'field')!.value, + previousOperatorCompare: getLastTokenWithValueByType( tokens, 'operator_compare', )!.value, })), ...Object.entries(language.tokens.conjunction.literal).map( - ([conjunction, description]) => ({ + ([ conjunction, description]) => ({ type: 'conjunction', label: conjunction, description, @@ -369,14 +371,14 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL) { conjunction => conjunction === lastToken.value, ) ? [ - ...(await options.suggestions.field()).map( - ({ label, description }) => ({ - type: 'field', - label, - description, - }), - ), - ] + ...(await options.suggestions.field()).map( + ({ label, description }) => ({ + type: 'field', + label, + description, + }), + ), + ] : []), { type: 'operator_group', @@ -454,14 +456,14 @@ export const AQL = { }, async run(input, params) { // Get the tokens from the input - const tokens: ITokens = tokenizer({ input }, language); + const tokens: ITokens = tokenizerAPI(input); return { searchBarProps: { // Props that will be used by the EuiSuggest component // Suggestions suggestions: transformSuggestionsToUI( - await getSuggestions(tokens, params.queryLanguage.parameters), + await getSuggestionsAPI(tokens, params.queryLanguage.parameters), suggestionMappingLanguageTokenType, ), // Handler to manage when clicking in a suggestion item @@ -472,7 +474,7 @@ export const AQL = { params.onSearch(getOutput(input, params.queryLanguage.parameters)); } else { // When the clicked item has another iconType - const lastToken: IToken = tokens[tokens.length - 1]; + const lastToken: IToken = getLastTokenWithValue(tokens); // if the clicked suggestion is of same type of last token if ( suggestionMappingLanguageTokenType[lastToken.type].iconType === @@ -491,7 +493,12 @@ export const AQL = { } } // Change the input - params.setInput(tokens.map(({ value }) => value).join('')); + params.setInput(tokens + .filter(value => value) // Ensure the input is rebuilt using tokens with value. + // The input tokenization can contain tokens with no value due to the used + // regular expression. + .map(({ value }) => value) + .join('')); }, prepend: params.queryLanguage.parameters.implicitQuery ? ( ({ type: 'field', ...field })); + { label: 'group', description: 'Group' }, + { label: 'group_config_status', description: 'Synced configuration status' }, + { label: 'lastKeepAline', description: 'Date add' }, + { label: 'manager', description: 'Manager' }, + { label: 'mergedSum', description: 'Merged sum' }, + { label: 'name', description: 'Agent name' }, + { label: 'node_name', description: 'Node name' }, + { label: 'os.platform', description: 'Operating system platform' }, + { label: 'status', description: 'Status' }, + { label: 'version', description: 'Version' }, + ] + .map(field => ({ type: 'field', ...field })); }, value: async (currentValue, { previousField }) => { switch (previousField) { - case 'status': - try { - const results = await this.props.wzReq( - 'GET', - `/agents/stats/distinct`, - { - params: { - fields: 'status', - limit: 30, - }, - }, - ); - - return results.data.data.affected_items.map( - ({ status }) => ({ - type: 'value', - label: status, - }), - ); - } catch (error) { - console.log({ error }); - return []; - } + case 'configSum': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'dateAdd': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'id': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); break; case 'ip': - try { - const results = await this.props.wzReq( - 'GET', - `/agents/stats/distinct`, - { - params: { - fields: 'ip', - limit: 30, - }, - }, - ); - - console.log({ results }); - return results.data.data.affected_items.map( - ({ ip }) => ({ type: 'value', label: ip }), - ); - } catch (error) { - console.log({ error }); - return []; - } + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'group': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'group_config_status': + return [AGENT_SYNCED_STATUS.SYNCED, AGENT_SYNCED_STATUS.NOT_SYNCED].map( + (status) => ({ + type: 'value', + label: status, + }), + ); + break; + case 'lastKeepAline': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'manager': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'mergedSum': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'name': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'node_name': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'os.platform': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'status': + return UI_ORDER_AGENT_STATUS.map( + (status) => ({ + type: 'value', + label: status, + }), + ); + break; + case 'version': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); break; default: return []; @@ -879,3 +946,12 @@ AgentsTable.propTypes = { timeService: PropTypes.func, reload: PropTypes.func, }; + + +const getAgentFilterValuesMapToSearchBarSuggestion = async (key, value, params) => { + try{ + return (await getAgentFilterValues(key, value, params)).map(label => ({type: 'value', label})); + }catch(error){ + return []; + }; +}; From 0923569685105ba124bd05ce7037a8719ca774f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Thu, 2 Mar 2023 16:48:24 +0100 Subject: [PATCH 03/37] fix(search-bar): fixes a problem hidding the suggestion popover when using the Search suggestion in AQL - Fixes a problem hidding the suggestion popover when using the Search suggestion in AQL - Fixes a problem of input text with undefined value - Minor fixes - Remove `syntax` property of SearchBar component - Add disableFocusTrap property to the custom EuiSuggestInput component to be forwarded to the EuiInputPopover - Replace the inputRef by a reference instead of a state and pass as a parameter in the query language run function - Move the rebuiding of input text when using some suggestion that changes the input to be done when a related suggestion was clicked instead of any suggestion (exclude Search). --- .../components/eui-suggest/suggest_input.js | 2 ++ public/components/search-bar/index.tsx | 20 ++++++++--------- .../search-bar/query-language/aql.tsx | 22 ++++++++++++------- .../agent/components/agents-table.js | 1 - 4 files changed, 26 insertions(+), 19 deletions(-) diff --git a/public/components/eui-suggest/suggest_input.js b/public/components/eui-suggest/suggest_input.js index 55393ba820..f67149d489 100644 --- a/public/components/eui-suggest/suggest_input.js +++ b/public/components/eui-suggest/suggest_input.js @@ -53,6 +53,7 @@ export class EuiSuggestInput extends Component { onPopoverFocus, isPopoverOpen, onClosePopover, + disableFocusTrap, ...rest } = this.props; @@ -108,6 +109,7 @@ export class EuiSuggestInput extends Component { panelPaddingSize="none" fullWidth closePopover={onClosePopover} + disableFocusTrap={disableFocusTrap} >
{suggestions}
diff --git a/public/components/search-bar/index.tsx b/public/components/search-bar/index.tsx index 01a3764e14..25a11c0be4 100644 --- a/public/components/search-bar/index.tsx +++ b/public/components/search-bar/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { EuiButtonEmpty, EuiFormRow, @@ -51,14 +51,13 @@ export const SearchBar = ({ const [isOpenSuggestionPopover, setIsOpenSuggestionPopover] = useState(false); // Reference to the input - const [inputRef, setInputRef] = useState(); + const inputRef = useRef(); // Handler when searching const _onSearch = (output: any) => { // TODO: fix when searching - inputRef && inputRef.blur(); - setIsOpenSuggestionPopover(false); onSearch(output); + setIsOpenSuggestionPopover(false); }; // Handler on change the input field text @@ -75,7 +74,7 @@ export const SearchBar = ({ useEffect(() => { // React to external changes and set the internal input text. Use the `transformUnifiedQuery` of // the query language in use - setInput( + rest.input && setInput( searchBarQueryLanguages[queryLanguage.id]?.transformUnifiedQuery?.( rest.input, ), @@ -91,16 +90,17 @@ export const SearchBar = ({ setInput, closeSuggestionPopover: () => setIsOpenSuggestionPopover(false), openSuggestionPopover: () => setIsOpenSuggestionPopover(true), - queryLanguage: { - configuration: queryLanguage.configuration, - parameters: modes.find(({ id }) => id === queryLanguage.id), - }, setQueryLanguageConfiguration: (configuration: any) => setQueryLanguage(state => ({ ...state, configuration: configuration?.(state.configuration) || configuration, })), + inputRef, + queryLanguage: { + configuration: queryLanguage.configuration, + parameters: modes.find(({ id }) => id === queryLanguage.id), + }, }), ); })(); @@ -115,7 +115,7 @@ export const SearchBar = ({ return ( value) // Ensure the input is rebuilt using tokens with value. + // The input tokenization can contain tokens with no value due to the used + // regular expression. + .map(({ value }) => value) + .join('')); } - // Change the input - params.setInput(tokens - .filter(value => value) // Ensure the input is rebuilt using tokens with value. - // The input tokenization can contain tokens with no value due to the used - // regular expression. - .map(({ value }) => value) - .join('')); }, prepend: params.queryLanguage.parameters.implicitQuery ? ( This query is added to the input. ) : null, + // Disable the focus trap in the EuiInputPopover. + // This causes when using the Search suggestion, the suggestion popover can be closed. + // If this is disabled, then the suggestion popover is open after a short time for this + // use case. + disableFocusTrap: true }, output: getOutput(input, params.queryLanguage.parameters), }; diff --git a/public/controllers/agent/components/agents-table.js b/public/controllers/agent/components/agents-table.js index 5f3d01a2b5..7d01d14d7a 100644 --- a/public/controllers/agent/components/agents-table.js +++ b/public/controllers/agent/components/agents-table.js @@ -619,7 +619,6 @@ export const AgentsTable = withErrorBoundary( /> {/** Example implementation */} Date: Fri, 3 Mar 2023 15:45:53 +0100 Subject: [PATCH 04/37] feat(search-bar): add the ability to update the input of example implemenation - Add the ability to update the input of the search bar in the example implementation - Enhance the component documentation --- public/components/search-bar/README.md | 9 +++++- .../agent/components/agents-table.js | 28 +++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/public/components/search-bar/README.md b/public/components/search-bar/README.md index 046157a9a0..7729aa8d70 100644 --- a/public/components/search-bar/README.md +++ b/public/components/search-bar/README.md @@ -22,7 +22,7 @@ Basic usage: ```tsx ``` diff --git a/public/controllers/agent/components/agents-table.js b/public/controllers/agent/components/agents-table.js index 7d01d14d7a..1fef9c9eb5 100644 --- a/public/controllers/agent/components/agents-table.js +++ b/public/controllers/agent/components/agents-table.js @@ -63,6 +63,7 @@ export const AgentsTable = withErrorBoundary( filters: sessionStorage.getItem('agents_preview_selected_options') ? JSON.parse(sessionStorage.getItem('agents_preview_selected_options')) : [], + query: '' }; this.suggestions = [ { @@ -210,7 +211,7 @@ export const AgentsTable = withErrorBoundary( this.props.filters && this.props.filters.length ) { - this.setState({ filters: this.props.filters, pageIndex: 0 }); + this.setState({ filters: this.props.filters, pageIndex: 0, query: this.props.filters.find(({field}) => field === 'q')?.value || '' }); this.props.removeFilters(); } } @@ -619,6 +620,7 @@ export const AgentsTable = withErrorBoundary( /> {/** Example implementation */} { + try{ + this.setState({isLoading: true}); + const response = await this.props.wzReq('GET', '/agents', { params: { + limit: this.state.pageSize, + offset: 0, + q: query, + sort: this.buildSortFilter() + }}); + + const formatedAgents = response?.data?.data?.affected_items?.map( + this.formatAgent.bind(this) + ); + + this._isMount && this.setState({ + agents: formatedAgents, + totalItems: response?.data?.data?.total_affected_items, + isLoading: false, + }); + }catch(error){ + this.setState({isLoading: false}); + }; + }} /> From 0b0b4519668483c5783d7a6b9503db820dc2c782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Mon, 6 Mar 2023 10:21:24 +0100 Subject: [PATCH 05/37] feat(search-bar): add initial suggestions to AQL - (AQL) Add the fields and an open operator group when there is no input text --- .../components/search-bar/query-language/aql.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/public/components/search-bar/query-language/aql.tsx b/public/components/search-bar/query-language/aql.tsx index 0d240a0482..41902517e1 100644 --- a/public/components/search-bar/query-language/aql.tsx +++ b/public/components/search-bar/query-language/aql.tsx @@ -263,9 +263,17 @@ export async function getSuggestionsAPI(tokens: ITokens, options: optionsQL) { // Get last token const lastToken = getLastTokenWithValue(tokens); - // If it can't get a token with value, then no return suggestions + // If it can't get a token with value, then returns fields and open operator group if(!lastToken?.type){ - return []; + return [ + // fields + ...(await options.suggestions.field()), + { + type: 'operator_group', + label: '(', + description: language.tokens.operator_group.literal['('], + } + ]; }; switch (lastToken.type) { @@ -477,7 +485,7 @@ export const AQL = { const lastToken: IToken = getLastTokenWithValue(tokens); // if the clicked suggestion is of same type of last token if ( - suggestionMappingLanguageTokenType[lastToken.type].iconType === + lastToken && suggestionMappingLanguageTokenType[lastToken.type].iconType === item.type.iconType ) { // replace the value of last token From ab8a555c0342700acd55a4d1077a182312a86a20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Tue, 7 Mar 2023 16:39:50 +0100 Subject: [PATCH 06/37] feat(search-bar): add target and rel attributes to the documentation link of query language displayed in the popover --- public/components/search-bar/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/public/components/search-bar/index.tsx b/public/components/search-bar/index.tsx index 25a11c0be4..8b0ebcd1b7 100644 --- a/public/components/search-bar/index.tsx +++ b/public/components/search-bar/index.tsx @@ -148,13 +148,15 @@ export const SearchBar = ({ href={ searchBarQueryLanguages[queryLanguage.id].documentationLink } + target='__blank' + rel='noopener noreferrer' > Documentation )} - {modes?.length && modes.length > 1 && ( + {modes?.length > 1 && ( <> From 8d9f7461155478a3c284117ffb286399808f3582 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Tue, 7 Mar 2023 16:43:54 +0100 Subject: [PATCH 07/37] feat(search-bar): enhancements in AQL and search bar documentation - AQL enhancements: - documentation: - Enhance some descriptions - Enhance input processing - Remove intermetiate interface of EuiSuggestItem - Remove the intermediate interface of EuiSuggestItem. Now it is managed in the internal of query language instead of be built by the suggestion handler - Display suggestions when the input text is empty - Add the unifiedQuery field to the query language output - Adapt tests - Search Bar component: - Enhance documentation --- public/components/search-bar/README.md | 56 +++++++- .../search-bar/query-language/aql.md | 54 +++++--- .../search-bar/query-language/aql.test.tsx | 8 +- .../search-bar/query-language/aql.tsx | 130 +++++------------- 4 files changed, 128 insertions(+), 120 deletions(-) diff --git a/public/components/search-bar/README.md b/public/components/search-bar/README.md index 7729aa8d70..df007a4ea0 100644 --- a/public/components/search-bar/README.md +++ b/public/components/search-bar/README.md @@ -28,6 +28,10 @@ Basic usage: { id: 'aql', // specific query language parameters + // implicit query. Optional + // Set a implicit query that can't be changed by the user. + // Use the UQL (Unified Query Language) syntax. + // Each query language implementation must interpret implicitQuery: 'id!=000;', suggestions: { field(currentValue) { @@ -46,8 +50,7 @@ Basic usage: { label: 'os.platform', description: 'Operating system platform' }, { label: 'status', description: 'Status' }, { label: 'version', description: 'Version' }, - ] - .map(field => ({ type: 'field', ...field })); + ]; }, value: async (currentValue, { previousField }) => { switch (previousField) { @@ -165,7 +168,10 @@ Basic usage: onSearch={onSearch} // Used to define the internal input. Optional. // This could be used to change the input text from the external components. - input="" + // Use the UQL (Unified Query Language) syntax. + input="" + // Define the default mode. Optional. If not defined, it will use the first one mode. + defaultMode="" > ``` @@ -188,20 +194,37 @@ type SearchBarQueryLanguage = { id: string; label: string; getConfiguration?: () => any; - run: (input: string | undefined, params: any) => any; - transformUnifiedQuery: (unifiedQuery) => any; + run: (input: string | undefined, params: any) => Promise<{ + searchBarProps: any, + output: { + language: string, + unifiedQuery: string, + query: string + } + }>; + transformUnifiedQuery: (unifiedQuery: string) => string; }; ``` where: -- `description`: It is the description of the query language. This is displayed in a query language popover +- `description`: is the description of the query language. This is displayed in a query language popover on the right side of the search bar. Required. - `documentationLink`: URL to the documentation link. Optional. - `id`: identification of the query language. - `label`: name - `getConfiguration`: method that returns the configuration of the language. This allows custom behavior. -- `run`: method that returns the properties that will used by the base search bar component and the output used when searching +- `run`: method that returns: + - `searchBarProps`: properties to be passed to the search bar component. This allows the + customization the properties that will used by the base search bar component and the output used when searching + - `output`: + - `language`: query language ID + - `unifiedQuery`: query in unified query syntax + - `query`: current query in the specified language +- `transformUnifiedQuery`: method that transform the Unified Query Language to the specific query + language. This is used when receives a external input in the Unified Query Language, the returned + value is converted to the specific query language to set the new input text of the search bar + component. Create a new file located in `public/components/search-bar/query-language` and define the expected interface; @@ -233,3 +256,22 @@ export const searchBarQueryLanguages: { }; }, {}); ``` + +## Unified Query Language - UQL + +This is an unified syntax used by the search bar component that provides a way to communicate +with the different query language implementations. + +The input and output parameters of the search bar component must use this syntax. + +This is used in: +- input: + - `input` component property +- output: + - `onChange` component handler + - `onSearch` component handler + +Its syntax is equal to Wazuh API Query Language +https://wazuh.com/./user-manual/api/queries.html + +> The AQL query language is a implementation of this syntax. \ No newline at end of file diff --git a/public/components/search-bar/query-language/aql.md b/public/components/search-bar/query-language/aql.md index 637e8c9147..af0d2ccee9 100644 --- a/public/components/search-bar/query-language/aql.md +++ b/public/components/search-bar/query-language/aql.md @@ -12,14 +12,20 @@ The implementation is adapted to work with the search bar component defined - Suggestions for `fields` (configurable), `operators` and `values` (configurable) - Support implicit query +## Language syntax + +Documentation: https://wazuh.com/./user-manual/api/queries.html + ## Options -- `implicitQuery`: add an implicit query that is added to the user input. This can't be changed by -the user. If this is defined, will be displayed as a prepend of the search bar. +- `implicitQuery`: add an implicit query that is added to the user input. Optional. +Use UQL (Unified Query Language). +This can't be changed by the user. If this is defined, will be displayed as a prepend of the search bar. ```ts // language options -implicitQuery: 'id!=000;' // ID is not 000 and +// ID is not equal to 000 and . This is defined in UQL that is transformed internally to the specific query language. +implicitQuery: 'id!=000;' ``` - `suggestions`: define the suggestion handlers. This is required. @@ -44,8 +50,7 @@ implicitQuery: 'id!=000;' // ID is not 000 and { label: 'os.platform', description: 'Operating system platform' }, { label: 'status', description: 'Status' }, { label: 'version', description: 'Version' }, - ] - .map(field => ({ type: 'field', ...field })); + ]; } ``` @@ -161,18 +166,35 @@ implicitQuery: 'id!=000;' // ID is not 000 and } ``` -## How to get the suggestions +## Language workflow ```mermaid graph TD; - user_input[User input]-->tokenize; - subgraph tokenize - tokenize_regex - end - - tokenize-->suggestions[Get suggestions]; - subgraph suggestions[Get suggestions]; - get_last_token_with_value-->get_suggestions[Get suggestions] - end - suggestions-->EuiSuggestItem + user_input[User input]-->tokenizerAPI; + subgraph tokenizerAPI + tokenize_regex[Wazuh API `q` regular expression] + end + + tokenizerAPI-->tokens; + + tokens-->searchBarProps; + subgraph searchBarProps; + searchBarProps_suggestions-->searchBarProps_suggestions_get_last_token_with_value[Get last token with value] + searchBarProps_suggestions_get_last_token_with_value[Get last token with value]-->searchBarProps_suggestions__result[Suggestions] + searchBarProps_suggestions__result[Suggestions]-->EuiSuggestItem + searchBarProps_prepend[prepend]-->searchBarProps_prepend_implicitQuery{implicitQuery} + searchBarProps_prepend_implicitQuery{implicitQuery}-->searchBarProps_prepend_implicitQuery_yes[Yes]-->EuiButton + searchBarProps_prepend_implicitQuery{implicitQuery}-->searchBarProps_prepend_implicitQuery_no[No]-->null + searchBarProps_disableFocusTrap:true[disableFocusTrap = true] + searchBarProps_onItemClick[onItemClickSuggestion onclick handler]-->searchBarProps_onItemClick_suggestion_search[Search suggestion] + searchBarProps_onItemClick[onItemClickSuggestion onclick handler]-->searchBarProps_onItemClick_suggestion_edit_current_token[Edit current token]-->searchBarProps_onItemClick_build_input[Build input] + searchBarProps_onItemClick[onItemClickSuggestion onclick handler]-->searchBarProps_onItemClick_suggestion_add_new_token[Add new token]-->searchBarProps_onItemClick_build_input[Build input] + end + + tokens-->output; + subgraph output[output]; + output_result[implicitFilter + user input] + end + + output-->output_search_bar[Output] ``` \ No newline at end of file diff --git a/public/components/search-bar/query-language/aql.test.tsx b/public/components/search-bar/query-language/aql.test.tsx index 37c34904bc..a165ef5b75 100644 --- a/public/components/search-bar/query-language/aql.test.tsx +++ b/public/components/search-bar/query-language/aql.test.tsx @@ -35,17 +35,17 @@ describe('Query language - AQL', () => { // Get suggestions it.each` input | suggestions - ${''} | ${[]} + ${''} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }, { description: 'open group', label: '(', type: 'operator_group' }]} ${'w'} | ${[]} ${'f'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }]} ${'field'} | ${[{ description: 'Field2', label: 'field2', type: 'field' }, { description: 'equality', label: '=', type: 'operator_compare' }, { description: 'not equality', label: '!=', type: 'operator_compare' }, { description: 'bigger', label: '>', type: 'operator_compare' }, { description: 'smaller', label: '<', type: 'operator_compare' }, { description: 'like as', label: '~', type: 'operator_compare' }]} ${'field='} | ${[{ label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }]} - ${'field=v'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { description: 'Current value', label: 'v', type: 'value' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} - ${'field=value'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { description: 'Current value', label: 'value', type: 'value' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=v'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=value'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} ${'field=value;'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }, { description: 'open group', label: '(', type: 'operator_group' }]} ${'field=value;field2'} | ${[{ description: 'equality', label: '=', type: 'operator_compare' }, { description: 'not equality', label: '!=', type: 'operator_compare' }, { description: 'bigger', label: '>', type: 'operator_compare' }, { description: 'smaller', label: '<', type: 'operator_compare' }, { description: 'like as', label: '~', type: 'operator_compare' }]} ${'field=value;field2='} | ${[{ label: '127.0.0.1', type: 'value' }, { label: '127.0.0.2', type: 'value' }, { label: '190.0.0.1', type: 'value' }, { label: '190.0.0.2', type: 'value' }]} - ${'field=value;field2=127'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { description: 'Current value', label: '127', type: 'value' }, { label: '127.0.0.1', type: 'value' }, { label: '127.0.0.2', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=value;field2=127'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { label: '127.0.0.1', type: 'value' }, { label: '127.0.0.2', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} `('Get suggestion from the input: $input', async ({ input, suggestions }) => { expect( await getSuggestionsAPI(tokenizerAPI(input), { diff --git a/public/components/search-bar/query-language/aql.tsx b/public/components/search-bar/query-language/aql.tsx index 41902517e1..22d678844f 100644 --- a/public/components/search-bar/query-language/aql.tsx +++ b/public/components/search-bar/query-language/aql.tsx @@ -27,12 +27,9 @@ Implemented schema: */ // Language definition -const language = { +export const language = { // Tokens tokens: { - field: { - regex: /[\w.]/, - }, // eslint-disable-next-line camelcase operator_compare: { literal: { @@ -72,6 +69,23 @@ const suggestionMappingLanguageTokenType = { function_search: { iconType: 'search', color: 'tint5' }, }; +/** + * Creator of intermediate interfacte of EuiSuggestItem + * @param type + * @returns + */ +function mapSuggestionCreator(type: ITokenType ){ + return function({...params}){ + return { + type, + ...params + }; + }; +}; + +const mapSuggestionCreatorField = mapSuggestionCreator('field'); +const mapSuggestionCreatorValue = mapSuggestionCreator('value'); + /** * Tokenize the input string. Returns an array with the tokens. @@ -105,15 +119,15 @@ export function tokenizerAPI(input: string): ITokens{ // A ( character. '(?\\()?' + // Field name: name of the field to look on DB. - '(?[\\w.]+)?' + // Added a optional find + '(?[\\w.]+)?' + // Added an optional find // Operator: looks for '=', '!=', '<', '>' or '~'. // This seems to be a bug because is not searching the literal valid operators. // I guess the operator is validated after the regular expression matches - `(?[${Object.keys(language.tokens.operator_compare.literal)}]{1,2})?` + // Added a optional find + `(?[${Object.keys(language.tokens.operator_compare.literal)}]{1,2})?` + // Added an optional find // Value: A string. '(?(?:(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\/\'"=@%<>{}]*)\\))*' + '(?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]+)' + - '(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]*)\\))*)+)?' + // Added a optional find + '(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]*)\\))*)+)?' + // Added an optional find // A ) character. '(?\\))?' + `(?[${Object.keys(language.tokens.conjunction.literal)}])?`, @@ -124,84 +138,20 @@ export function tokenizerAPI(input: string): ITokens{ ...input.matchAll(re)] .map( ({groups}) => Object.entries(groups) - .map(([key, value]) => ({ - type: key.startsWith('operator_group') ? 'operator_group' : key, - value}) - ) + .map(([key, value]) => ({ + type: key.startsWith('operator_group') ? 'operator_group' : key, + value}) + ) ).flat(); }; -/** - * Check if the input is valid - * @param tokens - * @returns - */ -export function validate(input: string, options): boolean { - // TODO: enhance the validation - - // API regular expression - // self.query_regex = re.compile( - // # A ( character. - // r"(\()?" + - // # Field name: name of the field to look on DB. - // r"([\w.]+)" + - // # Operator: looks for '=', '!=', '<', '>' or '~'. - // rf"([{''.join(self.query_operators.keys())}]{{1,2}})" + - // # Value: A string. - // r"((?:(?:\((?:\[[\[\]\w _\-.,:?\\/'\"=@%<>{}]*]|[\[\]\w _\-.:?\\/'\"=@%<>{}]*)\))*" - // r"(?:\[[\[\]\w _\-.,:?\\/'\"=@%<>{}]*]|[\[\]\w _\-.:?\\/'\"=@%<>{}]+)" - // r"(?:\((?:\[[\[\]\w _\-.,:?\\/'\"=@%<>{}]*]|[\[\]\w _\-.:?\\/'\"=@%<>{}]*)\))*)+)" + - // # A ) character. - // r"(\))?" + - // # Separator: looks for ';', ',' or nothing. - // rf"([{''.join(self.query_separators.keys())}])?" - // ) - - const re = new RegExp( - // A ( character. - '(\\()?' + - // Field name: name of the field to look on DB. - '([\\w.]+)?' + - // Operator: looks for '=', '!=', '<', '>' or '~'. - // This seems to be a bug because is not searching the literal valid operators. - // I guess the operator is validated after the regular expression matches - `([${Object.keys(language.tokens.operator_compare.literal)}]{1,2})?` + - // Value: A string. - '((?:(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\/\'"=@%<>{}]*)\\))*' + - '(?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]+)' + - '(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]*)\\))*)+)?' + - // '([\\w]+)'+ - // A ) character. - '(\\))?' + - `([${Object.keys(language.tokens.conjunction.literal)}])?`, - 'g' - ); - - [...input.matchAll(re)].reduce((accum, [_, operator_group_open, field, operator, value, operator_group_close, conjunction ]) => { - if(!accum){ - return accum; - }; - - return [operator_group_open, field, operator, value, operator_group_close, conjunction] - }, true); - - const errors = []; - - for (let [_, operator_group_open, field, operator, value, operator_group_close, conjunction ] in input.matchAll(re)) { - if(!options.fields.includes(field)){ - errors.push(`Field ${field} is not valid.`) - }; - } - return errors.length === 0; -} - type OptionSuggestionHandler = ( currentValue: string | undefined, { previousField, previousOperatorCompare, }: { previousField: string; previousOperatorCompare: string }, -) => Promise<{ description?: string; label: string; type: string }[]>; +) => Promise<{ description?: string; label: string }[]>; type optionsQL = { suggestions: { @@ -267,7 +217,7 @@ export async function getSuggestionsAPI(tokens: ITokens, options: optionsQL) { if(!lastToken?.type){ return [ // fields - ...(await options.suggestions.field()), + ...(await options.suggestions.field()).map(mapSuggestionCreatorField), { type: 'operator_group', label: '(', @@ -283,7 +233,7 @@ export async function getSuggestionsAPI(tokens: ITokens, options: optionsQL) { ...(await options.suggestions.field()).filter( ({ label }) => label.startsWith(lastToken.value) && label !== lastToken.value, - ), + ).map(mapSuggestionCreatorField), // operators if the input field is exact ...((await options.suggestions.field()).some( ({ label }) => label === lastToken.value, @@ -324,7 +274,7 @@ export async function getSuggestionsAPI(tokens: ITokens, options: optionsQL) { tokens, 'operator_compare', )!.value, - })), + })).map(mapSuggestionCreatorValue), ] : []), ]; @@ -346,7 +296,7 @@ export async function getSuggestionsAPI(tokens: ITokens, options: optionsQL) { tokens, 'operator_compare', )!.value, - })), + })).map(mapSuggestionCreatorValue), ...Object.entries(language.tokens.conjunction.literal).map( ([ conjunction, description]) => ({ type: 'conjunction', @@ -379,13 +329,7 @@ export async function getSuggestionsAPI(tokens: ITokens, options: optionsQL) { conjunction => conjunction === lastToken.value, ) ? [ - ...(await options.suggestions.field()).map( - ({ label, description }) => ({ - type: 'field', - label, - description, - }), - ), + ...(await options.suggestions.field()).map(mapSuggestionCreatorField), ] : []), { @@ -399,9 +343,7 @@ export async function getSuggestionsAPI(tokens: ITokens, options: optionsQL) { if (lastToken.value === '(') { return [ // fields - ...(await options.suggestions.field()).map( - ({ label, description }) => ({ type: 'field', label, description }), - ), + ...(await options.suggestions.field()).map(mapSuggestionCreatorField), ]; } else if (lastToken.value === ')') { return [ @@ -446,9 +388,11 @@ function transformSuggestionsToUI( * @returns */ function getOutput(input: string, options: {implicitQuery?: string} = {}) { + const unifiedQuery = `${options?.implicitQuery ?? ''}${input}`; return { language: AQL.id, - query: `${options?.implicitQuery ?? ''}${input}`, + query: unifiedQuery, + unifiedQuery }; }; @@ -502,7 +446,7 @@ export const AQL = { // Change the input params.setInput(tokens - .filter(value => value) // Ensure the input is rebuilt using tokens with value. + .filter(({ value }) => value) // Ensure the input is rebuilt using tokens with value. // The input tokenization can contain tokens with no value due to the used // regular expression. .map(({ value }) => value) @@ -553,7 +497,7 @@ export const AQL = { output: getOutput(input, params.queryLanguage.parameters), }; }, - transformUnifiedQuery(unifiedQuery) { + transformUnifiedQuery(unifiedQuery: string): string { return unifiedQuery; }, }; From 9c01c8e02c144e0c9e6632fd1ac1c5bdfc3d4312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Tue, 7 Mar 2023 16:50:58 +0100 Subject: [PATCH 08/37] feat(search-bar): Add HAQL - Remove UIQL - Add HAQL query language that is a high-level implementation of AQL - Add the query language interface - Add tests for tokenizer, get suggestions and transformSpecificQLToUnifiedQL method - Add documentation about the language - Syntax - Options - Workflow --- .../search-bar/query-language/haql.md | 279 +++++++++ .../search-bar/query-language/haql.test.tsx | 139 +++++ .../search-bar/query-language/haql.tsx | 572 ++++++++++++++++++ .../search-bar/query-language/index.ts | 16 +- .../search-bar/query-language/uiql.tsx | 71 --- .../agent/components/agents-table.js | 136 ++++- 6 files changed, 1133 insertions(+), 80 deletions(-) create mode 100644 public/components/search-bar/query-language/haql.md create mode 100644 public/components/search-bar/query-language/haql.test.tsx create mode 100644 public/components/search-bar/query-language/haql.tsx delete mode 100644 public/components/search-bar/query-language/uiql.tsx diff --git a/public/components/search-bar/query-language/haql.md b/public/components/search-bar/query-language/haql.md new file mode 100644 index 0000000000..cf1d2b1166 --- /dev/null +++ b/public/components/search-bar/query-language/haql.md @@ -0,0 +1,279 @@ +# Query Language - HAQL + +HAQL (Human API Query Language) is a query language based in the `q` query parameters of the Wazuh API +endpoints. + +Documentation: https://wazuh.com/./user-manual/api/queries.html + +The implementation is adapted to work with the search bar component defined +`public/components/search-bar/index.tsx`. + +## Features +- Suggestions for `fields` (configurable), `operators` and `values` (configurable) +- Support implicit query + +## Language syntax + +### Schema + +``` +???????????? +``` + +### Fields + +Regular expression: /[\\w.]+/ + +Examples: + +``` +field +field.custom +``` + +### Operators + +#### Compare + +- `=` equal to +- `!=` not equal to +- `>` bigger +- `<` smaller +- `~` like + +#### Group + +- `(` open +- `)` close + +#### Logical + +- `and` +- `or` + +### Values + +- Value without spaces can be literal +- Value with spaces should be wrapped by `"`. The `"` can be escaped using `\"`. + +Examples: +``` +value +"custom value" +"custom \" value" +``` + +### Notes + +- The entities can be separated by whitespaces. + +### Examples + +- Simple query + +``` +id=001 +id = 001 +``` + +- Complex query +``` +status=active and os.platform~linux +status = active and os.platform ~ linux +``` + +``` +status!=never_connected and ip~240 or os.platform~linux +status != never_connected and ip ~ 240 or os.platform ~ linux +``` + +- Complex query with group operator +``` +(status!=never_connected and ip~240) or id=001 +( status != never_connected and ip ~ 240 ) or id = 001 +``` + +## Options + +- `implicitQuery`: add an implicit query that is added to the user input. Optional. +Use UQL (Unified Query Language). +This can't be changed by the user. If this is defined, will be displayed as a prepend of the search bar. + +```ts +// language options +// ID is not equal to 000 and . This is defined in UQL that is transformed internally to +// the specific query language. +implicitQuery: 'id!=000;' +``` + +- `suggestions`: define the suggestion handlers. This is required. + + - `field`: method that returns the suggestions for the fields + + ```ts + // language options + field(currentValue) { + return [ + { label: 'configSum', description: 'Config sum' }, + { label: 'dateAdd', description: 'Date add' }, + { label: 'id', description: 'ID' }, + { label: 'ip', description: 'IP address' }, + { label: 'group', description: 'Group' }, + { label: 'group_config_status', description: 'Synced configuration status' }, + { label: 'lastKeepAline', description: 'Date add' }, + { label: 'manager', description: 'Manager' }, + { label: 'mergedSum', description: 'Merged sum' }, + { label: 'name', description: 'Agent name' }, + { label: 'node_name', description: 'Node name' }, + { label: 'os.platform', description: 'Operating system platform' }, + { label: 'status', description: 'Status' }, + { label: 'version', description: 'Version' }, + ] + .map(field => ({ type: 'field', ...field })); + } + ``` + + - `value`: method that returns the suggestion for the values + ```ts + // language options + value: async (currentValue, { previousField }) => { + switch (previousField) { + case 'configSum': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'dateAdd': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'id': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'ip': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'group': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'group_config_status': + return [AGENT_SYNCED_STATUS.SYNCED, AGENT_SYNCED_STATUS.NOT_SYNCED].map( + (status) => ({ + type: 'value', + label: status, + }), + ); + break; + case 'lastKeepAline': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'manager': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'mergedSum': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'name': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'node_name': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'os.platform': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'status': + return UI_ORDER_AGENT_STATUS.map( + (status) => ({ + type: 'value', + label: status, + }), + ); + break; + case 'version': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + default: + return []; + break; + } + } + ``` + +## Language workflow + +```mermaid +graph TD; + user_input[User input]-->tokenizer; + subgraph tokenizer + tokenize_regex[Query language regular expression] + end + + tokenizer-->tokens; + + tokens-->searchBarProps; + subgraph searchBarProps; + searchBarProps_suggestions-->searchBarProps_suggestions_get_last_token_with_value[Get last token with value] + searchBarProps_suggestions_get_last_token_with_value[Get last token with value]-->searchBarProps_suggestions__result[Suggestions] + searchBarProps_suggestions__result[Suggestions]-->EuiSuggestItem + searchBarProps_prepend[prepend]-->searchBarProps_prepend_implicitQuery{implicitQuery} + searchBarProps_prepend_implicitQuery{implicitQuery}-->searchBarProps_prepend_implicitQuery_yes[Yes]-->EuiButton + searchBarProps_prepend_implicitQuery{implicitQuery}-->searchBarProps_prepend_implicitQuery_no[No]-->null + searchBarProps_disableFocusTrap:true[disableFocusTrap = true] + searchBarProps_onItemClick[onItemClickSuggestion onclick handler]-->searchBarProps_onItemClick_suggestion_search[Search suggestion] + searchBarProps_onItemClick[onItemClickSuggestion onclick handler]-->searchBarProps_onItemClick_suggestion_edit_current_token[Edit current token]-->searchBarProps_onItemClick_build_input[Build input] + searchBarProps_onItemClick[onItemClickSuggestion onclick handler]-->searchBarProps_onItemClick_suggestion_add_new_token[Add new token]-->searchBarProps_onItemClick_build_input[Build input] + end + + tokens-->output; + subgraph output[output]; + output_result[implicitFilter + user input] + end + + output-->output_search_bar[Output] +``` \ No newline at end of file diff --git a/public/components/search-bar/query-language/haql.test.tsx b/public/components/search-bar/query-language/haql.test.tsx new file mode 100644 index 0000000000..7193bc8239 --- /dev/null +++ b/public/components/search-bar/query-language/haql.test.tsx @@ -0,0 +1,139 @@ +import { getSuggestions, tokenizer, transformSpecificQLToUnifiedQL } from './haql'; + +describe('Query language - HAQL', () => { + // Tokenize the input + function tokenCreator({type, value}){ + return {type, value}; + }; + + const t = { + opGroup: (value = undefined) => tokenCreator({type: 'operator_group', value}), + opCompare: (value = undefined) => tokenCreator({type: 'operator_compare', value}), + field: (value = undefined) => tokenCreator({type: 'field', value}), + value: (value = undefined) => tokenCreator({type: 'value', value}), + whitespace: (value = undefined) => tokenCreator({type: 'whitespace', value}), + conjunction: (value = undefined) => tokenCreator({type: 'conjunction', value}) + }; + + // Token undefined + const tu = { + opGroup: tokenCreator({type: 'operator_group', value: undefined}), + opCompare: tokenCreator({type: 'operator_compare', value: undefined}), + whitespace: tokenCreator({type: 'whitespace', value: undefined}), + field: tokenCreator({type: 'field', value: undefined}), + value: tokenCreator({type: 'value', value: undefined}), + conjunction: tokenCreator({type: 'conjunction', value: undefined}) + }; + + const tuBlankSerie = [ + tu.opGroup, + tu.whitespace, + tu.field, + tu.whitespace, + tu.opCompare, + tu.whitespace, + tu.value, + tu.whitespace, + tu.opGroup, + tu.whitespace, + tu.conjunction, + tu.whitespace + ]; + + + it.each` + input | tokens + ${''} | ${tuBlankSerie} + ${'f'} | ${[tu.opGroup, tu.whitespace, t.field('f'), tu.whitespace, tu.opCompare, tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, tu.opCompare, tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field='} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value and'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), tu.whitespace, ...tuBlankSerie]} + ${'field=value and '} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), ...tuBlankSerie]} + ${'field=value and field2'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, tu.opCompare, tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value and field2!='} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value and field2!="'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, t.value('"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value and field2!="value'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, t.value('"value'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value and field2!="value"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, t.value('"value"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value and field2!=value2 and'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, t.value('value2'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), tu.whitespace, ...tuBlankSerie]} + `(`Tokenizer API input $input`, ({input, tokens}) => { + expect(tokenizer(input)).toEqual(tokens); + }); + + // Get suggestions + it.each` + input | suggestions + ${''} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }, { description: 'open group', label: '(', type: 'operator_group' }]} + ${'w'} | ${[]} + ${'f'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }]} + ${'field'} | ${[{ description: 'Field2', label: 'field2', type: 'field' }, { description: 'equality', label: '=', type: 'operator_compare' }, { description: 'not equality', label: '!=', type: 'operator_compare' }, { description: 'bigger', label: '>', type: 'operator_compare' }, { description: 'smaller', label: '<', type: 'operator_compare' }, { description: 'like as', label: '~', type: 'operator_compare' }]} + ${'field='} | ${[{ label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }]} + ${'field=v'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: 'and', type: 'conjunction' }, { description: 'or', label: 'or', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=value'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: 'and', type: 'conjunction' }, { description: 'or', label: 'or', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=value and'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }, { description: 'open group', label: '(', type: 'operator_group' }]} + `('Get suggestion from the input: $input', async ({ input, suggestions }) => { + expect( + await getSuggestions(tokenizer(input), { + id: 'aql', + suggestions: { + field(currentValue) { + return [ + { label: 'field', description: 'Field' }, + { label: 'field2', description: 'Field2' }, + ].map(({ label, description }) => ({ + type: 'field', + label, + description, + })); + }, + value(currentValue = '', { previousField }) { + switch (previousField) { + case 'field': + return ['value', 'value2', 'value3', 'value4'] + .filter(value => value.startsWith(currentValue)) + .map(value => ({ type: 'value', label: value })); + break; + case 'field2': + return ['127.0.0.1', '127.0.0.2', '190.0.0.1', '190.0.0.2'] + .filter(value => value.startsWith(currentValue)) + .map(value => ({ type: 'value', label: value })); + break; + default: + return []; + break; + } + }, + }, + }), + ).toEqual(suggestions); + }); + + it.each` + HAQL | UQL + ${'field'} | ${'field'} + ${'field='} | ${'field='} + ${'field=value'} | ${'field=value'} + ${'field=value()'} | ${'field=value()'} + ${'field="custom value"'} | ${'field=custom value'} + ${'field="custom \\"value"'} | ${'field=custom "value'} + ${'field="custom \\"value\\""'} | ${'field=custom "value"'} + ${'field=value and'} | ${'field=value;'} + ${'field="custom value" and'} | ${'field=custom value;'} + ${'(field=value'} | ${'(field=value'} + ${'(field=value)'} | ${'(field=value)'} + ${'(field=value) and'} | ${'(field=value);'} + ${'(field=value) and field2'} | ${'(field=value);field2'} + ${'(field=value) and field2>'} | ${'(field=value);field2>'} + ${'(field=value) and field2>"wrappedcommas"'} | ${'(field=value);field2>wrappedcommas'} + ${'(field=value) and field2>"value with spaces"'} | ${'(field=value);field2>value with spaces'} + ${'field ='} | ${'field='} + ${'field = value'} | ${'field=value'} + ${'field = value or'} | ${'field=value,'} + ${'field = value or field2'} | ${'field=value,field2'} + ${'field = value or field2 <'} | ${'field=value,field2<'} + ${'field = value or field2 < value2'} | ${'field=value,field2 "custom value" '} | ${'(field=value);field2>custom value'} + `('transformSpecificQLToUnifiedQL - HAQL $HAQL', ({HAQL, UQL}) => { + expect(transformSpecificQLToUnifiedQL(HAQL)).toEqual(UQL); + }) +}); diff --git a/public/components/search-bar/query-language/haql.tsx b/public/components/search-bar/query-language/haql.tsx new file mode 100644 index 0000000000..bf45cc9afc --- /dev/null +++ b/public/components/search-bar/query-language/haql.tsx @@ -0,0 +1,572 @@ +import React from 'react'; +import { EuiButtonEmpty, EuiPopover, EuiText, EuiCode } from '@elastic/eui'; +import { tokenizerAPI as tokenizerUQL } from './aql'; +import { pluginPlatform } from '../../../../package.json'; + +/* UI Query language +https://documentation.wazuh.com/current/user-manual/api/queries.html + +// Example of another query language definition +*/ + +type ITokenType = + | 'field' + | 'operator_compare' + | 'operator_group' + | 'value' + | 'conjunction' + | 'whitespace'; +type IToken = { type: ITokenType; value: string }; +type ITokens = IToken[]; + +/* API Query Language +Define the API Query Language to use in the search bar. +It is based in the language used by the q query parameter. +https://documentation.wazuh.com/current/user-manual/api/queries.html + +Use the regular expression of API with some modifications to allow the decomposition of +input in entities that doesn't compose a valid query. It allows get not-completed queries. + +API schema: +??? + +Implemented schema: +???????????? +*/ + +// Language definition +const language = { + // Tokens + tokens: { + field: { + regex: /[\w.]/, + }, + // eslint-disable-next-line camelcase + operator_compare: { + literal: { + '=': 'equality', + '!=': 'not equality', + '>': 'bigger', + '<': 'smaller', + '~': 'like as', + }, + }, + conjunction: { + literal: { + 'and': 'and', + 'or': 'or', + }, + }, + // eslint-disable-next-line camelcase + operator_group: { + literal: { + '(': 'open group', + ')': 'close group', + }, + }, + }, +}; + +// Suggestion mapper by language token type +const suggestionMappingLanguageTokenType = { + field: { iconType: 'kqlField', color: 'tint4' }, + // eslint-disable-next-line camelcase + operator_compare: { iconType: 'kqlOperand', color: 'tint1' }, + value: { iconType: 'kqlValue', color: 'tint0' }, + conjunction: { iconType: 'kqlSelector', color: 'tint3' }, + // eslint-disable-next-line camelcase + operator_group: { iconType: 'tokenDenseVector', color: 'tint3' }, + // eslint-disable-next-line camelcase + function_search: { iconType: 'search', color: 'tint5' }, +}; + + +/** + * Tokenize the input string. Returns an array with the tokens. + * @param input + * @returns + */ +export function tokenizer(input: string): ITokens{ + const re = new RegExp( + // A ( character. + '(?\\()?' + + // Whitespace + '(?\\s+)?' + + // Field name: name of the field to look on DB. + '(?[\\w.]+)?' + // Added an optional find + // Whitespace + '(?\\s+)?' + + // Operator: looks for '=', '!=', '<', '>' or '~'. + // This seems to be a bug because is not searching the literal valid operators. + // I guess the operator is validated after the regular expression matches + `(?[${Object.keys(language.tokens.operator_compare.literal)}]{1,2})?` + // Added an optional find + // Whitespace + '(?\\s+)?' + + // Value: A string. + // Simple value + // Quoted ", "value, "value", "escaped \"quote" + // Escape quoted string with escaping quotes: https://stackoverflow.com/questions/249791/regex-for-quoted-string-with-escaping-quotes + // '(?(?:(?:[^"\\s]+|(?:"(?:[^"\\\\]|\\\\")*"))))?' + + '(?(?:(?:[^"\\s]+|(?:"(?:[^"\\\\]|\\\\")*")|(?:"(?:[^"\\\\]|\\\\")*)|")))?' + + // Whitespace + '(?\\s+)?' + + // A ) character. + '(?\\))?' + + // Whitespace + '(?\\s+)?' + + `(?${Object.keys(language.tokens.conjunction.literal).join('|')})?` + + // Whitespace + '(?\\s+)?', + 'g' + ); + + return [ + ...input.matchAll(re)] + .map( + ({groups}) => Object.entries(groups) + .map(([key, value]) => ({ + type: key.startsWith('operator_group') // Transform operator_group group match + ? 'operator_group' + : (key.startsWith('whitespace') // Transform whitespace group match + ? 'whitespace' + : key), + value}) + ) + ).flat(); +}; + +type OptionSuggestionHandler = ( + currentValue: string | undefined, + { + previousField, + previousOperatorCompare, + }: { previousField: string; previousOperatorCompare: string }, +) => Promise<{ description?: string; label: string; type: string }[]>; + +type optionsQL = { + suggestions: { + field: OptionSuggestionHandler; + value: OptionSuggestionHandler; + }; +}; + +/** + * Get the last token with value + * @param tokens Tokens + * @param tokenType token type to search + * @returns + */ +function getLastTokenWithValue( + tokens: ITokens +): IToken | undefined { + // Reverse the tokens array and use the Array.protorype.find method + const shallowCopyArray = Array.from([...tokens]); + const shallowCopyArrayReversed = shallowCopyArray.reverse(); + const tokenFound = shallowCopyArrayReversed.find( + ({ type, value }) => type !== 'whitespace' && value, + ); + return tokenFound; +} + +/** + * Get the last token with value by type + * @param tokens Tokens + * @param tokenType token type to search + * @returns + */ +function getLastTokenWithValueByType( + tokens: ITokens, + tokenType: ITokenType, +): IToken | undefined { + // Find the last token by type + // Reverse the tokens array and use the Array.protorype.find method + const shallowCopyArray = Array.from([...tokens]); + const shallowCopyArrayReversed = shallowCopyArray.reverse(); + const tokenFound = shallowCopyArrayReversed.find( + ({ type, value }) => type === tokenType && value, + ); + return tokenFound; +} + +/** + * Get the suggestions from the tokens + * @param tokens + * @param language + * @param options + * @returns + */ +export async function getSuggestions(tokens: ITokens, options: optionsQL) { + if (!tokens.length) { + return []; + } + + // Get last token + const lastToken = getLastTokenWithValue(tokens); + + // If it can't get a token with value, then returns fields and open operator group + if(!lastToken?.type){ + return [ + // fields + ...(await options.suggestions.field()), + { + type: 'operator_group', + label: '(', + description: language.tokens.operator_group.literal['('], + } + ]; + }; + + switch (lastToken.type) { + case 'field': + return [ + // fields that starts with the input but is not equals + ...(await options.suggestions.field()).filter( + ({ label }) => + label.startsWith(lastToken.value) && label !== lastToken.value, + ), + // operators if the input field is exact + ...((await options.suggestions.field()).some( + ({ label }) => label === lastToken.value, + ) + ? [ + ...Object.keys(language.tokens.operator_compare.literal).map( + operator => ({ + type: 'operator_compare', + label: operator, + description: + language.tokens.operator_compare.literal[operator], + }), + ), + ] + : []), + ]; + break; + case 'operator_compare': + return [ + ...Object.keys(language.tokens.operator_compare.literal) + .filter( + operator => + operator.startsWith(lastToken.value) && + operator !== lastToken.value, + ) + .map(operator => ({ + type: 'operator_compare', + label: operator, + description: language.tokens.operator_compare.literal[operator], + })), + ...(Object.keys(language.tokens.operator_compare.literal).some( + operator => operator === lastToken.value, + ) + ? [ + ...(await options.suggestions.value(undefined, { + previousField: getLastTokenWithValueByType(tokens, 'field')!.value, + previousOperatorCompare: getLastTokenWithValueByType( + tokens, + 'operator_compare', + )!.value, + })), + ] + : []), + ]; + break; + case 'value': + return [ + ...(lastToken.value + ? [ + { + type: 'function_search', + label: 'Search', + description: 'Run the search query', + }, + ] + : []), + ...(await options.suggestions.value(lastToken.value, { + previousField: getLastTokenWithValueByType(tokens, 'field')!.value, + previousOperatorCompare: getLastTokenWithValueByType( + tokens, + 'operator_compare', + )!.value, + })), + ...Object.entries(language.tokens.conjunction.literal).map( + ([ conjunction, description]) => ({ + type: 'conjunction', + label: conjunction, + description, + }), + ), + { + type: 'operator_group', + label: ')', + description: language.tokens.operator_group.literal[')'], + }, + ]; + break; + case 'conjunction': + return [ + ...Object.keys(language.tokens.conjunction.literal) + .filter( + conjunction => + conjunction.startsWith(lastToken.value) && + conjunction !== lastToken.value, + ) + .map(conjunction => ({ + type: 'conjunction', + label: conjunction, + description: language.tokens.conjunction.literal[conjunction], + })), + // fields if the input field is exact + ...(Object.keys(language.tokens.conjunction.literal).some( + conjunction => conjunction === lastToken.value, + ) + ? [ + ...(await options.suggestions.field()).map( + ({ label, description }) => ({ + type: 'field', + label, + description, + }), + ), + ] + : []), + { + type: 'operator_group', + label: '(', + description: language.tokens.operator_group.literal['('], + }, + ]; + break; + case 'operator_group': + if (lastToken.value === '(') { + return [ + // fields + ...(await options.suggestions.field()).map( + ({ label, description }) => ({ type: 'field', label, description }), + ), + ]; + } else if (lastToken.value === ')') { + return [ + // conjunction + ...Object.keys(language.tokens.conjunction.literal).map( + conjunction => ({ + type: 'conjunction', + label: conjunction, + description: language.tokens.conjunction.literal[conjunction], + }), + ), + ]; + } + break; + default: + return []; + break; + } + + return []; +} + +/** + * Transform the suggestion object to the expected object by EuiSuggestItem + * @param suggestions + * @param options + * @returns + */ +function transformSuggestionsToUI( + suggestions: { type: string; label: string; description?: string }[], + mapSuggestionByLanguageToken: any, +) { + return suggestions.map(({ type, ...rest }) => ({ + type: { ...mapSuggestionByLanguageToken[type] }, + ...rest, + })); +}; + +/** + * Transform the UQL (Unified Query Language) to SpecificQueryLanguage + * @param input + * @returns + */ +export function transformUnifiedQueryToSpecificQueryLanguage(input: string){ + const tokens = tokenizerUQL(input); + return tokens + .filter(({value}) => value) + .map(({type, value}) => type === 'conjunction' + ? value === ';' + ? ' and ' + : ' or ' + : value + ).join(''); +}; + +/** + * Transform the input in SpecificQueryLanguage to UQL (Unified Query Language) + * @param input + * @returns + */ +export function transformSpecificQLToUnifiedQL(input: string){ + const tokens = tokenizer(input); + return tokens + .filter(({type, value}) => type !== 'whitespace' && value) + .map(({type, value}) => { + switch (type) { + case 'value':{ + // Value is wrapped with " + let [ _, extractedValue ] = value.match(/^"(.+)"$/) || [ null, null ]; + // Replace the escape commas (\") by comma (") + extractedValue && (extractedValue = extractedValue.replace(/\\"/g, '"')); + return extractedValue || value; + break; + } + case 'conjunction': + return value === 'and' + ? ';' + : ','; + break; + default: + return value; + break; + } + } + ).join(''); +}; + +/** + * Get the output from the input + * @param input + * @returns + */ +function getOutput(input: string, options: {implicitQuery?: string} = {}) { + const query = `${transformUnifiedQueryToSpecificQueryLanguage(options?.implicitQuery ?? '')}${input}`; + return { + language: HAQL.id, + unifiedQuery: transformSpecificQLToUnifiedQL(query), + query + }; +}; + +export const HAQL = { + id: 'haql', + label: 'HAQL', + description: 'HAQL allows to do queries.', + documentationLink: `https://github.com/wazuh/wazuh-kibana-app/blob/v${pluginPlatform.version.split('.').splice(0,2).join('.')}/public/components/search-bar/query-language/haql.md`, + getConfiguration() { + return { + isOpenPopoverImplicitFilter: false, + }; + }, + async run(input, params) { + // Get the tokens from the input + const tokens: ITokens = tokenizer(input); + + // + const implicitQueryAsSpecificQueryLanguage = params.queryLanguage.parameters.implicitQuery + ? transformUnifiedQueryToSpecificQueryLanguage(params.queryLanguage.parameters.implicitQuery) + : ''; + + return { + searchBarProps: { + // Props that will be used by the EuiSuggest component + // Suggestions + suggestions: transformSuggestionsToUI( + await getSuggestions(tokens, params.queryLanguage.parameters), + suggestionMappingLanguageTokenType, + ), + // Handler to manage when clicking in a suggestion item + onItemClick: item => { + // When the clicked item has the `search` iconType, run the `onSearch` function + if (item.type.iconType === 'search') { + // Execute the search action + params.onSearch(getOutput(input, params.queryLanguage.parameters)); + } else { + // When the clicked item has another iconType + const lastToken: IToken | undefined = getLastTokenWithValue(tokens); + // if the clicked suggestion is of same type of last token + if ( + lastToken && suggestionMappingLanguageTokenType[lastToken.type].iconType === + item.type.iconType + ) { + // replace the value of last token + lastToken.value = item.label; + } else { + // add a whitespace for conjunction + input.length + && !(/\s$/.test(input)) + && item.type.iconType === suggestionMappingLanguageTokenType.conjunction.iconType + && tokens.push({ + type: 'whitespace', + value: ' ' + }); + + // add a new token of the selected type and value + tokens.push({ + type: Object.entries(suggestionMappingLanguageTokenType).find( + ([, { iconType }]) => iconType === item.type.iconType, + )[0], + value: item.type.iconType === suggestionMappingLanguageTokenType.value.iconType + && /\s/.test(item.label) + ? `"${item.label}"` + : item.label, + }); + + // add a whitespace for conjunction + item.type.iconType === suggestionMappingLanguageTokenType.conjunction.iconType + && tokens.push({ + type: 'whitespace', + value: ' ' + }); + }; + + // Change the input + params.setInput(tokens + .filter(value => value) // Ensure the input is rebuilt using tokens with value. + // The input tokenization can contain tokens with no value due to the used + // regular expression. + .map(({ value }) => value) + .join('')); + } + }, + prepend: implicitQueryAsSpecificQueryLanguage ? ( + + params.setQueryLanguageConfiguration(state => ({ + ...state, + isOpenPopoverImplicitFilter: + !state.isOpenPopoverImplicitFilter, + })) + } + iconType='filter' + > + + {implicitQueryAsSpecificQueryLanguage} + + + } + isOpen={ + params.queryLanguage.configuration.isOpenPopoverImplicitFilter + } + closePopover={() => + params.setQueryLanguageConfiguration(state => ({ + ...state, + isOpenPopoverImplicitFilter: false, + })) + } + > + + Implicit query:{' '} + {implicitQueryAsSpecificQueryLanguage} + + This query is added to the input. + + ) : null, + // Disable the focus trap in the EuiInputPopover. + // This causes when using the Search suggestion, the suggestion popover can be closed. + // If this is disabled, then the suggestion popover is open after a short time for this + // use case. + disableFocusTrap: true + }, + output: getOutput(input, params.queryLanguage.parameters), + }; + }, + transformUnifiedQuery: transformUnifiedQueryToSpecificQueryLanguage, +}; diff --git a/public/components/search-bar/query-language/index.ts b/public/components/search-bar/query-language/index.ts index 1895aae4fb..22608fd9f5 100644 --- a/public/components/search-bar/query-language/index.ts +++ b/public/components/search-bar/query-language/index.ts @@ -1,5 +1,5 @@ import { AQL } from './aql'; -import { UIQL } from './uiql'; +import { HAQL } from './haql'; type SearchBarQueryLanguage = { description: string; @@ -7,14 +7,21 @@ type SearchBarQueryLanguage = { id: string; label: string; getConfiguration?: () => any; - run: (input: string | undefined, params: any) => any; - transformUnifiedQuery: (unifiedQuery) => any; + run: (input: string | undefined, params: any) => Promise<{ + searchBarProps: any, + output: { + language: string, + unifiedQuery: string, + query: string + } + }>; + transformUnifiedQuery: (unifiedQuery: string) => string; }; // Register the query languages export const searchBarQueryLanguages: { [key: string]: SearchBarQueryLanguage; -} = [AQL, UIQL].reduce((accum, item) => { +} = [AQL, HAQL].reduce((accum, item) => { if (accum[item.id]) { throw new Error(`Query language with id: ${item.id} already registered.`); } @@ -22,5 +29,4 @@ export const searchBarQueryLanguages: { ...accum, [item.id]: item, }; - ['hola']; }, {}); diff --git a/public/components/search-bar/query-language/uiql.tsx b/public/components/search-bar/query-language/uiql.tsx deleted file mode 100644 index 97b1927f54..0000000000 --- a/public/components/search-bar/query-language/uiql.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React from 'react'; -import { EuiButtonEmpty, EuiPopover, EuiText, EuiCode } from '@elastic/eui'; - -/* UI Query language -https://documentation.wazuh.com/current/user-manual/api/queries.html - -// Example of another query language definition -*/ - -/** - * Get the output from the input - * @param input - * @returns - */ -function getOutput(input: string, options: {implicitQuery?: string} = {}) { - return { - language: UIQL.id, - query: `${options?.implicitQuery ?? ''}${input}`, - }; -}; - -export const UIQL = { - id: 'uiql', - label: 'UIQL', - description: 'UIQL allows to do queries.', - documentationLink: '', - getConfiguration() { - return { - anotherProp: false, - }; - }, - async run(input, params) { - // Get the tokens from the input - return { - searchBarProps: { - // Props that will be used by the EuiSuggest component - // Suggestions - suggestions: [], - // Handler to manage when clicking in a suggestion item - prepend: params.queryLanguage.parameters.implicitQuery ? ( - - params.setQueryLanguageConfiguration(state => ({ - ...state, - anotherProp: !state.anotherProp, - })) - } - iconType='filter' - > - } - isOpen={params.queryLanguage.configuration.anotherProp} - closePopover={() => - params.setQueryLanguageConfiguration(state => ({ - ...state, - anotherProp: false, - })) - } - > - - Implicit UIQL query:{' '} - {params.queryLanguage.parameters.implicitQuery} - - - ) : null, - }, - output: getOutput(input, params.queryLanguage.parameters), - }; - }, -}; diff --git a/public/controllers/agent/components/agents-table.js b/public/controllers/agent/components/agents-table.js index 1fef9c9eb5..0f9ff7640a 100644 --- a/public/controllers/agent/components/agents-table.js +++ b/public/controllers/agent/components/agents-table.js @@ -620,6 +620,7 @@ export const AgentsTable = withErrorBoundary( /> {/** Example implementation */} { + switch (previousField) { + case 'configSum': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'dateAdd': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'id': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'ip': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'group': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'group_config_status': + return [AGENT_SYNCED_STATUS.SYNCED, AGENT_SYNCED_STATUS.NOT_SYNCED].map( + (status) => ({ + type: 'value', + label: status, + }), + ); + break; + case 'lastKeepAline': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'manager': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'mergedSum': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'name': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'node_name': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'os.platform': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + case 'status': + return UI_ORDER_AGENT_STATUS.map( + (status) => ({ + type: 'value', + label: status, + }), + ); + break; + case 'version': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + {q: 'id!=000'} + ); + break; + default: + return []; + break; + } + }, + }, }, ]} onChange={console.log} - onSearch={async ({language, query}) => { + onSearch={async ({language, unifiedQuery}) => { try{ this.setState({isLoading: true}); const response = await this.props.wzReq('GET', '/agents', { params: { limit: this.state.pageSize, offset: 0, - q: query, + q: unifiedQuery, sort: this.buildSortFilter() }}); @@ -973,7 +1101,7 @@ AgentsTable.propTypes = { const getAgentFilterValuesMapToSearchBarSuggestion = async (key, value, params) => { try{ - return (await getAgentFilterValues(key, value, params)).map(label => ({type: 'value', label})); + return (await getAgentFilterValues(key, value, params)).map(label => ({label})); }catch(error){ return []; }; From b6f0d6d76963555704f0e3c5db46f07cc32d34af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Tue, 14 Mar 2023 14:32:49 +0100 Subject: [PATCH 09/37] feat(search-bar): add test to HAQL and AQL query languages - Add tests to HAQL and AQL query languages - Fix suggestions for HAQL when typing as first element a value entity. Now there are no suggestions because the field and operator_compare are missing. - Enhance documentation of HAQL and AQL - Removed unnecesary returns of suggestion handler in the example implementation of search bar on Agents section --- .../__snapshots__/index.test.tsx.snap | 59 ++++++ public/components/search-bar/index.test.tsx | 54 +++++ public/components/search-bar/index.tsx | 6 +- .../__snapshots__/aql.test.tsx.snap | 99 +++++++++ .../__snapshots__/haql.test.tsx.snap | 99 +++++++++ .../search-bar/query-language/aql.md | 6 +- .../search-bar/query-language/aql.test.tsx | 126 +++++++++++- .../search-bar/query-language/aql.tsx | 62 ++++-- .../search-bar/query-language/haql.md | 3 +- .../search-bar/query-language/haql.test.tsx | 127 +++++++++++- .../search-bar/query-language/haql.tsx | 193 ++++++++++++------ .../agent/components/agents-table.js | 7 +- 12 files changed, 745 insertions(+), 96 deletions(-) create mode 100644 public/components/search-bar/__snapshots__/index.test.tsx.snap create mode 100644 public/components/search-bar/index.test.tsx create mode 100644 public/components/search-bar/query-language/__snapshots__/aql.test.tsx.snap create mode 100644 public/components/search-bar/query-language/__snapshots__/haql.test.tsx.snap diff --git a/public/components/search-bar/__snapshots__/index.test.tsx.snap b/public/components/search-bar/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..007974639e --- /dev/null +++ b/public/components/search-bar/__snapshots__/index.test.tsx.snap @@ -0,0 +1,59 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SearchBar component Renders correctly the initial render 1`] = ` +
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+`; diff --git a/public/components/search-bar/index.test.tsx b/public/components/search-bar/index.test.tsx new file mode 100644 index 0000000000..b77cb2baa8 --- /dev/null +++ b/public/components/search-bar/index.test.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { SearchBar } from './index'; + +describe('SearchBar component', () => { + const componentProps = { + defaultMode: 'haql', + input: '', + modes: [ + { + id: 'aql', + implicitQuery: 'id!=000;', + suggestions: { + field(currentValue) { + return []; + }, + value(currentValue, { previousField }){ + return []; + }, + }, + }, + { + id: 'haql', + implicitQuery: 'id!=000;', + suggestions: { + field(currentValue) { + return []; + }, + value(currentValue, { previousField }){ + return []; + }, + }, + }, + ], + /* eslint-disable @typescript-eslint/no-empty-function */ + onChange: () => {}, + onSearch: () => {} + /* eslint-enable @typescript-eslint/no-empty-function */ + }; + + it('Renders correctly the initial render', async () => { + const wrapper = render( + + ); + + /* This test causes a warning about act. This is intentional, because the test pretends to get + the first rendering of the component that doesn't have the component properties coming of the + selected query language */ + expect(wrapper.container).toMatchSnapshot(); + }); +}); \ No newline at end of file diff --git a/public/components/search-bar/index.tsx b/public/components/search-bar/index.tsx index 8b0ebcd1b7..63b1d5a49a 100644 --- a/public/components/search-bar/index.tsx +++ b/public/components/search-bar/index.tsx @@ -119,7 +119,11 @@ export const SearchBar = ({ value={input} onChange={onChangeInput} onKeyPress={onKeyPressHandler} - onInputChange={() => {}} + // eslint-disable-next-line @typescript-eslint/no-empty-function + onInputChange={() => {}} /* This method is run by EuiSuggest when there is a change in + a div wrapper of the input and should be defined. Defining this + property prevents an error. */ + suggestions={[]} isPopoverOpen={ queryLanguageOutputRun.searchBarProps.suggestions.length > 0 && isOpenSuggestionPopover diff --git a/public/components/search-bar/query-language/__snapshots__/aql.test.tsx.snap b/public/components/search-bar/query-language/__snapshots__/aql.test.tsx.snap new file mode 100644 index 0000000000..0ef68d2e9e --- /dev/null +++ b/public/components/search-bar/query-language/__snapshots__/aql.test.tsx.snap @@ -0,0 +1,99 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SearchBar component Renders correctly to match the snapshot of query language 1`] = ` +
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+`; diff --git a/public/components/search-bar/query-language/__snapshots__/haql.test.tsx.snap b/public/components/search-bar/query-language/__snapshots__/haql.test.tsx.snap new file mode 100644 index 0000000000..8636885205 --- /dev/null +++ b/public/components/search-bar/query-language/__snapshots__/haql.test.tsx.snap @@ -0,0 +1,99 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SearchBar component Renders correctly to match the snapshot of query language 1`] = ` +
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+`; diff --git a/public/components/search-bar/query-language/aql.md b/public/components/search-bar/query-language/aql.md index af0d2ccee9..9dcb5bb0e0 100644 --- a/public/components/search-bar/query-language/aql.md +++ b/public/components/search-bar/query-language/aql.md @@ -170,12 +170,12 @@ implicitQuery: 'id!=000;' ```mermaid graph TD; - user_input[User input]-->tokenizerAPI; - subgraph tokenizerAPI + user_input[User input]-->tokenizer; + subgraph tokenizer tokenize_regex[Wazuh API `q` regular expression] end - tokenizerAPI-->tokens; + tokenizer-->tokens; tokens-->searchBarProps; subgraph searchBarProps; diff --git a/public/components/search-bar/query-language/aql.test.tsx b/public/components/search-bar/query-language/aql.test.tsx index a165ef5b75..597d31188a 100644 --- a/public/components/search-bar/query-language/aql.test.tsx +++ b/public/components/search-bar/query-language/aql.test.tsx @@ -1,4 +1,46 @@ -import { getSuggestionsAPI, tokenizerAPI, validate } from './aql'; +import { AQL, getSuggestions, tokenizer } from './aql'; +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { SearchBar } from '../index'; + +describe('SearchBar component', () => { + const componentProps = { + defaultMode: AQL.id, + input: '', + modes: [ + { + id: AQL.id, + implicitQuery: 'id!=000;', + suggestions: { + field(currentValue) { + return []; + }, + value(currentValue, { previousField }){ + return []; + }, + }, + } + ], + /* eslint-disable @typescript-eslint/no-empty-function */ + onChange: () => {}, + onSearch: () => {} + /* eslint-enable @typescript-eslint/no-empty-function */ + }; + + it('Renders correctly to match the snapshot of query language', async () => { + const wrapper = render( + + ); + + await waitFor(() => { + const elementImplicitQuery = wrapper.container.querySelector('.euiCodeBlock__code'); + expect(elementImplicitQuery?.innerHTML).toEqual('id!=000;'); + expect(wrapper.container).toMatchSnapshot(); + }); + }); +}); describe('Query language - AQL', () => { // Tokenize the input @@ -29,7 +71,7 @@ describe('Query language - AQL', () => { ${'(field>2;field2=value2)'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value2' }, { type: 'operator_group', value: ')' }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} ${'(field>2;field2=custom value())'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'custom value()' }, { type: 'operator_group', value: ')' }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} `(`Tokenizer API input $input`, ({input, tokens}) => { - expect(tokenizerAPI(input)).toEqual(tokens); + expect(tokenizer(input)).toEqual(tokens); }); // Get suggestions @@ -48,7 +90,7 @@ describe('Query language - AQL', () => { ${'field=value;field2=127'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { label: '127.0.0.1', type: 'value' }, { label: '127.0.0.2', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} `('Get suggestion from the input: $input', async ({ input, suggestions }) => { expect( - await getSuggestionsAPI(tokenizerAPI(input), { + await getSuggestions(tokenizer(input), { id: 'aql', suggestions: { field(currentValue) { @@ -82,4 +124,82 @@ describe('Query language - AQL', () => { }), ).toEqual(suggestions); }); + + // When a suggestion is clicked, change the input text + it.each` + AQL | clikedSuggestion | changedInput + ${''} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field'}} | ${'field'} + ${'field'} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'field2'} + ${'field'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '='}} | ${'field='} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value'}} | ${'field=value'} + ${'field='} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '!='}} | ${'field!='} + ${'field=value'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2'}} | ${'field=value2'} + ${'field=value'} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: ';'}} | ${'field=value;'} + ${'field=value;'} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'field=value;field2'} + ${'field=value;field2'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>'}} | ${'field=value;field2>'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with spaces'}} | ${'field=with spaces'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with "spaces'}} | ${'field=with "spaces'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: '"value'}} | ${'field="value'} + ${''} | ${{type: { iconType: 'tokenDenseVector', color: 'tint3' }, label: '('}} | ${'('} + ${'('} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field'}} | ${'(field'} + ${'(field'} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'(field2'} + ${'(field'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '='}} | ${'(field='} + ${'(field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value'}} | ${'(field=value'} + ${'(field=value'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2'}} | ${'(field=value2'} + ${'(field=value'} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: ','}} | ${'(field=value,'} + ${'(field=value,'} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'(field=value,field2'} + ${'(field=value,field2'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>'}} | ${'(field=value,field2>'} + ${'(field=value,field2>'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>'}} | ${'(field=value,field2>'} + ${'(field=value,field2>'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '~'}} | ${'(field=value,field2~'} + ${'(field=value,field2>'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2'}} | ${'(field=value,field2>value2'} + ${'(field=value,field2>value2'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value3'}} | ${'(field=value,field2>value3'} + ${'(field=value,field2>value2'} | ${{type: { iconType: 'tokenDenseVector', color: 'tint3' }, label: ')'}} | ${'(field=value,field2>value2)'} + `('click suggestion - AQL $AQL => $changedInput', async ({AQL: currentInput, clikedSuggestion, changedInput}) => { + // Mock input + let input = currentInput; + + const qlOutput = await AQL.run(input, { + setInput: (value: string): void => { input = value; }, + queryLanguage: { + parameters: { + implicitQuery: '', + suggestions: { + field: () => ([]), + value: () => ([]) + } + } + } + }); + qlOutput.searchBarProps.onItemClick(clikedSuggestion); + expect(input).toEqual(changedInput); + }); + + // Transform the external input in UQL (Unified Query Language) to QL + it.each` + UQL | AQL + ${''} | ${''} + ${'field'} | ${'field'} + ${'field='} | ${'field='} + ${'field!='} | ${'field!='} + ${'field>'} | ${'field>'} + ${'field<'} | ${'field<'} + ${'field~'} | ${'field~'} + ${'field=value'} | ${'field=value'} + ${'field=value;'} | ${'field=value;'} + ${'field=value;field2'} | ${'field=value;field2'} + ${'field="'} | ${'field="'} + ${'field=with spaces'} | ${'field=with spaces'} + ${'field=with "spaces'} | ${'field=with "spaces'} + ${'('} | ${'('} + ${'(field'} | ${'(field'} + ${'(field='} | ${'(field='} + ${'(field=value'} | ${'(field=value'} + ${'(field=value,'} | ${'(field=value,'} + ${'(field=value,field2'} | ${'(field=value,field2'} + ${'(field=value,field2>'} | ${'(field=value,field2>'} + ${'(field=value,field2>value2'} | ${'(field=value,field2>value2'} + ${'(field=value,field2>value2)'} | ${'(field=value,field2>value2)'} + `('Transform the external input UQL to QL - UQL $UQL => $AQL', async ({UQL, AQL: changedInput}) => { + expect(AQL.transformUnifiedQuery(UQL)).toEqual(changedInput); + }); }); diff --git a/public/components/search-bar/query-language/aql.tsx b/public/components/search-bar/query-language/aql.tsx index 22d678844f..a93f33df19 100644 --- a/public/components/search-bar/query-language/aql.tsx +++ b/public/components/search-bar/query-language/aql.tsx @@ -70,7 +70,7 @@ const suggestionMappingLanguageTokenType = { }; /** - * Creator of intermediate interfacte of EuiSuggestItem + * Creator of intermediate interface of EuiSuggestItem * @param type * @returns */ @@ -92,7 +92,7 @@ const mapSuggestionCreatorValue = mapSuggestionCreator('value'); * @param input * @returns */ -export function tokenizerAPI(input: string): ITokens{ +export function tokenizer(input: string): ITokens{ // API regular expression // https://github.com/wazuh/wazuh/blob/v4.4.0-rc1/framework/wazuh/core/utils.py#L1242-L1257 // self.query_regex = re.compile( @@ -145,18 +145,31 @@ export function tokenizerAPI(input: string): ITokens{ ).flat(); }; -type OptionSuggestionHandler = ( +type QLOptionSuggestionEntityItem = { + description?: string + label: string +}; + +type QLOptionSuggestionEntityItemTyped = + QLOptionSuggestionEntityItem + & { type: 'operator_group'|'field'|'operator_compare'|'value'|'conjunction' }; + +type SuggestItem = QLOptionSuggestionEntityItem & { + type: { iconType: string, color: string } +}; + +type QLOptionSuggestionHandler = ( currentValue: string | undefined, { previousField, previousOperatorCompare, }: { previousField: string; previousOperatorCompare: string }, -) => Promise<{ description?: string; label: string }[]>; +) => Promise; type optionsQL = { suggestions: { - field: OptionSuggestionHandler; - value: OptionSuggestionHandler; + field: QLOptionSuggestionHandler; + value: QLOptionSuggestionHandler; }; }; @@ -205,7 +218,7 @@ function getLastTokenWithValueByType( * @param options * @returns */ -export async function getSuggestionsAPI(tokens: ITokens, options: optionsQL) { +export async function getSuggestions(tokens: ITokens, options: optionsQL): Promise { if (!tokens.length) { return []; } @@ -366,21 +379,29 @@ export async function getSuggestionsAPI(tokens: ITokens, options: optionsQL) { return []; } +/** + * Transform the suggestion object to the expected object by EuiSuggestItem + * @param param0 + * @returns + */ +export function transformSuggestionToEuiSuggestItem(suggestion: QLOptionSuggestionEntityItemTyped): SuggestItem{ + const { type, ...rest} = suggestion; + return { + type: { ...suggestionMappingLanguageTokenType[type] }, + ...rest + }; +}; + /** * Transform the suggestion object to the expected object by EuiSuggestItem * @param suggestions - * @param options * @returns */ -function transformSuggestionsToUI( - suggestions: { type: string; label: string; description?: string }[], - mapSuggestionByLanguageToken: any, -) { - return suggestions.map(({ type, ...rest }) => ({ - type: { ...mapSuggestionByLanguageToken[type] }, - ...rest, - })); -} +function transformSuggestionsToEuiSuggestItem( + suggestions: QLOptionSuggestionEntityItemTyped[] +): SuggestItem[] { + return suggestions.map(transformSuggestionToEuiSuggestItem); +}; /** * Get the output from the input @@ -408,15 +429,14 @@ export const AQL = { }, async run(input, params) { // Get the tokens from the input - const tokens: ITokens = tokenizerAPI(input); + const tokens: ITokens = tokenizer(input); return { searchBarProps: { // Props that will be used by the EuiSuggest component // Suggestions - suggestions: transformSuggestionsToUI( - await getSuggestionsAPI(tokens, params.queryLanguage.parameters), - suggestionMappingLanguageTokenType, + suggestions: transformSuggestionsToEuiSuggestItem( + await getSuggestions(tokens, params.queryLanguage.parameters) ), // Handler to manage when clicking in a suggestion item onItemClick: item => { diff --git a/public/components/search-bar/query-language/haql.md b/public/components/search-bar/query-language/haql.md index cf1d2b1166..8f5253eda0 100644 --- a/public/components/search-bar/query-language/haql.md +++ b/public/components/search-bar/query-language/haql.md @@ -128,8 +128,7 @@ implicitQuery: 'id!=000;' { label: 'os.platform', description: 'Operating system platform' }, { label: 'status', description: 'Status' }, { label: 'version', description: 'Version' }, - ] - .map(field => ({ type: 'field', ...field })); + ]; } ``` diff --git a/public/components/search-bar/query-language/haql.test.tsx b/public/components/search-bar/query-language/haql.test.tsx index 7193bc8239..eeef8eb91a 100644 --- a/public/components/search-bar/query-language/haql.test.tsx +++ b/public/components/search-bar/query-language/haql.test.tsx @@ -1,5 +1,48 @@ -import { getSuggestions, tokenizer, transformSpecificQLToUnifiedQL } from './haql'; +import { getSuggestions, tokenizer, transformSpecificQLToUnifiedQL, HAQL } from './haql'; +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { SearchBar } from '../index'; +describe('SearchBar component', () => { + const componentProps = { + defaultMode: HAQL.id, + input: '', + modes: [ + { + id: HAQL.id, + implicitQuery: 'id!=000;', + suggestions: { + field(currentValue) { + return []; + }, + value(currentValue, { previousField }){ + return []; + }, + }, + } + ], + /* eslint-disable @typescript-eslint/no-empty-function */ + onChange: () => {}, + onSearch: () => {} + /* eslint-enable @typescript-eslint/no-empty-function */ + }; + + it('Renders correctly to match the snapshot of query language', async () => { + const wrapper = render( + + ); + + await waitFor(() => { + const elementImplicitQuery = wrapper.container.querySelector('.euiCodeBlock__code'); + expect(elementImplicitQuery?.innerHTML).toEqual('id!=000 and '); + expect(wrapper.container).toMatchSnapshot(); + }); + }); +}); + +/* eslint-disable max-len */ describe('Query language - HAQL', () => { // Tokenize the input function tokenCreator({type, value}){ @@ -108,6 +151,7 @@ describe('Query language - HAQL', () => { ).toEqual(suggestions); }); + // Transform specific query language to UQL (Unified Query Language) it.each` HAQL | UQL ${'field'} | ${'field'} @@ -135,5 +179,84 @@ describe('Query language - HAQL', () => { ${'( field = value ) and field2 > "custom value" '} | ${'(field=value);field2>custom value'} `('transformSpecificQLToUnifiedQL - HAQL $HAQL', ({HAQL, UQL}) => { expect(transformSpecificQLToUnifiedQL(HAQL)).toEqual(UQL); - }) + }); + + // When a suggestion is clicked, change the input text + it.each` + HAQL | clikedSuggestion | changedInput + ${''} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field'}} | ${'field'} + ${'field'} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'field2'} + ${'field'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '='}} | ${'field='} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value'}} | ${'field=value'} + ${'field='} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '!='}} | ${'field!='} + ${'field=value'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2'}} | ${'field=value2'} + ${'field=value'} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'and'}} | ${'field=value and '} + ${'field=value and '} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'field=value and field2'} + ${'field=value and field2'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>'}} | ${'field=value and field2>'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with spaces'}} | ${'field="with spaces"'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with "spaces'}} | ${'field="with \\"spaces"'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: '"value'}} | ${'field="\\"value"'} + ${''} | ${{type: { iconType: 'tokenDenseVector', color: 'tint3' }, label: '('}} | ${'('} + ${'('} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field'}} | ${'(field'} + ${'(field'} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'(field2'} + ${'(field'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '='}} | ${'(field='} + ${'(field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value'}} | ${'(field=value'} + ${'(field=value'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2'}} | ${'(field=value2'} + ${'(field=value'} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'or'}} | ${'(field=value or '} + ${'(field=value or '} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'(field=value or field2'} + ${'(field=value or field2'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>'}} | ${'(field=value or field2>'} + ${'(field=value or field2>'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>'}} | ${'(field=value or field2>'} + ${'(field=value or field2>'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '~'}} | ${'(field=value or field2~'} + ${'(field=value or field2>'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2'}} | ${'(field=value or field2>value2'} + ${'(field=value or field2>value2'}| ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value3'}} | ${'(field=value or field2>value3'} + ${'(field=value or field2>value2'}| ${{type: { iconType: 'tokenDenseVector', color: 'tint3' }, label: ')'}} | ${'(field=value or field2>value2)'} + `('click suggestion - HAQL $HAQL => $changedInput', async ({HAQL: currentInput, clikedSuggestion, changedInput}) => { + // Mock input + let input = currentInput; + + const qlOutput = await HAQL.run(input, { + setInput: (value: string): void => { input = value; }, + queryLanguage: { + parameters: { + implicitQuery: '', + suggestions: { + field: () => ([]), + value: () => ([]) + } + } + } + }); + qlOutput.searchBarProps.onItemClick(clikedSuggestion); + expect(input).toEqual(changedInput); + }); + + // Transform the external input in UQL (Unified Query Language) to QL + it.each` + UQL | HAQL + ${''} | ${''} + ${'field'} | ${'field'} + ${'field='} | ${'field='} + ${'field!='} | ${'field!='} + ${'field>'} | ${'field>'} + ${'field<'} | ${'field<'} + ${'field~'} | ${'field~'} + ${'field=value'} | ${'field=value'} + ${'field=value;'} | ${'field=value and '} + ${'field=value;field2'} | ${'field=value and field2'} + ${'field="'} | ${'field="\\""'} + ${'field=with spaces'} | ${'field="with spaces"'} + ${'field=with "spaces'} | ${'field="with \\"spaces"'} + ${'('} | ${'('} + ${'(field'} | ${'(field'} + ${'(field='} | ${'(field='} + ${'(field=value'} | ${'(field=value'} + ${'(field=value,'} | ${'(field=value or '} + ${'(field=value,field2'} | ${'(field=value or field2'} + ${'(field=value,field2>'} | ${'(field=value or field2>'} + ${'(field=value,field2>value2'} | ${'(field=value or field2>value2'} + ${'(field=value,field2>value2)'} | ${'(field=value or field2>value2)'} + `('Transform the external input UQL to QL - UQL $UQL => $HAQL', async ({UQL, HAQL: changedInput}) => { + expect(HAQL.transformUnifiedQuery(UQL)).toEqual(changedInput); + }); + }); diff --git a/public/components/search-bar/query-language/haql.tsx b/public/components/search-bar/query-language/haql.tsx index bf45cc9afc..cda8a12dfc 100644 --- a/public/components/search-bar/query-language/haql.tsx +++ b/public/components/search-bar/query-language/haql.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { EuiButtonEmpty, EuiPopover, EuiText, EuiCode } from '@elastic/eui'; -import { tokenizerAPI as tokenizerUQL } from './aql'; +import { tokenizer as tokenizerUQL } from './aql'; import { pluginPlatform } from '../../../../package.json'; /* UI Query language @@ -38,9 +38,6 @@ Implemented schema: const language = { // Tokens tokens: { - field: { - regex: /[\w.]/, - }, // eslint-disable-next-line camelcase operator_compare: { literal: { @@ -80,6 +77,48 @@ const suggestionMappingLanguageTokenType = { function_search: { iconType: 'search', color: 'tint5' }, }; +/** + * Creator of intermediate interface of EuiSuggestItem + * @param type + * @returns + */ +function mapSuggestionCreator(type: ITokenType ){ + return function({...params}){ + return { + type, + ...params + }; + }; +}; + +const mapSuggestionCreatorField = mapSuggestionCreator('field'); +const mapSuggestionCreatorValue = mapSuggestionCreator('value'); + +/** + * Transform the conjunction to the query language syntax + * @param conjunction + * @returns + */ +function transformQLConjunction(conjunction: string): string{ + // If the value has a whitespace or comma, then + return conjunction === ';' + ? ' and ' + : ' or '; +}; + +/** + * Transform the value to the query language syntax + * @param value + * @returns + */ +function transformQLValue(value: string): string{ + // If the value has a whitespace or comma, then + return /[\s|"]/.test(value) + // Escape the commas (") => (\") and wraps the string with commas ("") + ? `"${value.replace(/"/, '\\"')}"` + // Raw value + : value; +}; /** * Tokenize the input string. Returns an array with the tokens. @@ -135,18 +174,31 @@ export function tokenizer(input: string): ITokens{ ).flat(); }; -type OptionSuggestionHandler = ( +type QLOptionSuggestionEntityItem = { + description?: string + label: string +}; + +type QLOptionSuggestionEntityItemTyped = + QLOptionSuggestionEntityItem + & { type: 'operator_group'|'field'|'operator_compare'|'value'|'conjunction' }; + +type SuggestItem = QLOptionSuggestionEntityItem & { + type: { iconType: string, color: string } +}; + +type QLOptionSuggestionHandler = ( currentValue: string | undefined, { previousField, previousOperatorCompare, }: { previousField: string; previousOperatorCompare: string }, -) => Promise<{ description?: string; label: string; type: string }[]>; +) => Promise; type optionsQL = { suggestions: { - field: OptionSuggestionHandler; - value: OptionSuggestionHandler; + field: QLOptionSuggestionHandler; + value: QLOptionSuggestionHandler; }; }; @@ -195,7 +247,7 @@ function getLastTokenWithValueByType( * @param options * @returns */ -export async function getSuggestions(tokens: ITokens, options: optionsQL) { +export async function getSuggestions(tokens: ITokens, options: optionsQL): Promise { if (!tokens.length) { return []; } @@ -207,7 +259,7 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL) { if(!lastToken?.type){ return [ // fields - ...(await options.suggestions.field()), + ...(await options.suggestions.field()).map(mapSuggestionCreatorField), { type: 'operator_group', label: '(', @@ -223,7 +275,7 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL) { ...(await options.suggestions.field()).filter( ({ label }) => label.startsWith(lastToken.value) && label !== lastToken.value, - ), + ).map(mapSuggestionCreatorField), // operators if the input field is exact ...((await options.suggestions.field()).some( ({ label }) => label === lastToken.value, @@ -241,7 +293,19 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL) { : []), ]; break; - case 'operator_compare': + case 'operator_compare':{ + const previousField = getLastTokenWithValueByType(tokens, 'field')?.value; + const previousOperatorCompare = getLastTokenWithValueByType( + tokens, + 'operator_compare', + )?.value; + + // If there is no a previous field, then no return suggestions because it would be an syntax + // error + if(!previousField){ + return []; + }; + return [ ...Object.keys(language.tokens.operator_compare.literal) .filter( @@ -259,17 +323,27 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL) { ) ? [ ...(await options.suggestions.value(undefined, { - previousField: getLastTokenWithValueByType(tokens, 'field')!.value, - previousOperatorCompare: getLastTokenWithValueByType( - tokens, - 'operator_compare', - )!.value, - })), + previousField, + previousOperatorCompare, + })).map(mapSuggestionCreatorValue), ] : []), ]; break; - case 'value': + } + case 'value':{ + const previousField = getLastTokenWithValueByType(tokens, 'field')?.value; + const previousOperatorCompare = getLastTokenWithValueByType( + tokens, + 'operator_compare', + )?.value; + + // If there is no a previous field or operator_compar, then no return suggestions because + //it would be an syntax error + if(!previousField || !previousOperatorCompare){ + return []; + }; + return [ ...(lastToken.value ? [ @@ -281,12 +355,9 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL) { ] : []), ...(await options.suggestions.value(lastToken.value, { - previousField: getLastTokenWithValueByType(tokens, 'field')!.value, - previousOperatorCompare: getLastTokenWithValueByType( - tokens, - 'operator_compare', - )!.value, - })), + previousField, + previousOperatorCompare, + })).map(mapSuggestionCreatorValue), ...Object.entries(language.tokens.conjunction.literal).map( ([ conjunction, description]) => ({ type: 'conjunction', @@ -301,6 +372,7 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL) { }, ]; break; + } case 'conjunction': return [ ...Object.keys(language.tokens.conjunction.literal) @@ -319,13 +391,7 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL) { conjunction => conjunction === lastToken.value, ) ? [ - ...(await options.suggestions.field()).map( - ({ label, description }) => ({ - type: 'field', - label, - description, - }), - ), + ...(await options.suggestions.field()).map(mapSuggestionCreatorField), ] : []), { @@ -339,9 +405,7 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL) { if (lastToken.value === '(') { return [ // fields - ...(await options.suggestions.field()).map( - ({ label, description }) => ({ type: 'field', label, description }), - ), + ...(await options.suggestions.field()).map(mapSuggestionCreatorField), ]; } else if (lastToken.value === ')') { return [ @@ -364,20 +428,28 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL) { return []; } +/** + * Transform the suggestion object to the expected object by EuiSuggestItem + * @param param0 + * @returns + */ +export function transformSuggestionToEuiSuggestItem(suggestion: QLOptionSuggestionEntityItemTyped): SuggestItem{ + const { type, ...rest} = suggestion; + return { + type: { ...suggestionMappingLanguageTokenType[type] }, + ...rest + }; +}; + /** * Transform the suggestion object to the expected object by EuiSuggestItem * @param suggestions - * @param options * @returns */ -function transformSuggestionsToUI( - suggestions: { type: string; label: string; description?: string }[], - mapSuggestionByLanguageToken: any, -) { - return suggestions.map(({ type, ...rest }) => ({ - type: { ...mapSuggestionByLanguageToken[type] }, - ...rest, - })); +function transformSuggestionsToEuiSuggestItem( + suggestions: QLOptionSuggestionEntityItemTyped[] +): SuggestItem[] { + return suggestions.map(transformSuggestionToEuiSuggestItem); }; /** @@ -389,11 +461,19 @@ export function transformUnifiedQueryToSpecificQueryLanguage(input: string){ const tokens = tokenizerUQL(input); return tokens .filter(({value}) => value) - .map(({type, value}) => type === 'conjunction' - ? value === ';' - ? ' and ' - : ' or ' - : value + .map(({type, value}) => { + switch (type) { + case 'conjunction': + return transformQLConjunction(value); + break; + case 'value': + return transformQLValue(value); + break; + default: + return value; + break; + } + } ).join(''); }; @@ -466,9 +546,8 @@ export const HAQL = { searchBarProps: { // Props that will be used by the EuiSuggest component // Suggestions - suggestions: transformSuggestionsToUI( - await getSuggestions(tokens, params.queryLanguage.parameters), - suggestionMappingLanguageTokenType, + suggestions: transformSuggestionsToEuiSuggestItem( + await getSuggestions(tokens, params.queryLanguage.parameters) ), // Handler to manage when clicking in a suggestion item onItemClick: item => { @@ -487,9 +566,8 @@ export const HAQL = { // replace the value of last token lastToken.value = item.label; } else { - // add a whitespace for conjunction - input.length - && !(/\s$/.test(input)) + // add a whitespace for conjunction + !(/\s$/.test(input)) && item.type.iconType === suggestionMappingLanguageTokenType.conjunction.iconType && tokens.push({ type: 'whitespace', @@ -502,12 +580,11 @@ export const HAQL = { ([, { iconType }]) => iconType === item.type.iconType, )[0], value: item.type.iconType === suggestionMappingLanguageTokenType.value.iconType - && /\s/.test(item.label) - ? `"${item.label}"` + ? transformQLValue(item.label) : item.label, }); - // add a whitespace for conjunction + // add a whitespace for conjunction item.type.iconType === suggestionMappingLanguageTokenType.conjunction.iconType && tokens.push({ type: 'whitespace', diff --git a/public/controllers/agent/components/agents-table.js b/public/controllers/agent/components/agents-table.js index 0f9ff7640a..3de71e4a32 100644 --- a/public/controllers/agent/components/agents-table.js +++ b/public/controllers/agent/components/agents-table.js @@ -643,8 +643,7 @@ export const AgentsTable = withErrorBoundary( { label: 'os.platform', description: 'Operating system platform' }, { label: 'status', description: 'Status' }, { label: 'version', description: 'Version' }, - ] - .map(field => ({ type: 'field', ...field })); + ]; }, value: async (currentValue, { previousField }) => { switch (previousField) { @@ -686,7 +685,6 @@ export const AgentsTable = withErrorBoundary( case 'group_config_status': return [AGENT_SYNCED_STATUS.SYNCED, AGENT_SYNCED_STATUS.NOT_SYNCED].map( (status) => ({ - type: 'value', label: status, }), ); @@ -736,7 +734,6 @@ export const AgentsTable = withErrorBoundary( case 'status': return UI_ORDER_AGENT_STATUS.map( (status) => ({ - type: 'value', label: status, }), ); @@ -817,7 +814,6 @@ export const AgentsTable = withErrorBoundary( case 'group_config_status': return [AGENT_SYNCED_STATUS.SYNCED, AGENT_SYNCED_STATUS.NOT_SYNCED].map( (status) => ({ - type: 'value', label: status, }), ); @@ -867,7 +863,6 @@ export const AgentsTable = withErrorBoundary( case 'status': return UI_ORDER_AGENT_STATUS.map( (status) => ({ - type: 'value', label: status, }), ); From 22676d4ddc7e8ef6412d0e15f86a31836be6c0ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Wed, 15 Mar 2023 12:48:24 +0100 Subject: [PATCH 10/37] feat(search-bar): Rename HAQL query language to WQL - Rename query language HAQL to WQL - Update tests - Remove AQL usage from the implementation in the agents section --- .../__snapshots__/index.test.tsx.snap | 2 +- public/components/search-bar/index.test.tsx | 6 +- .../{haql.test.tsx.snap => wql.test.tsx.snap} | 2 +- .../search-bar/query-language/index.ts | 4 +- .../query-language/{haql.md => wql.md} | 4 +- .../{haql.test.tsx => wql.test.tsx} | 26 ++-- .../query-language/{haql.tsx => wql.tsx} | 12 +- .../agent/components/agents-table.js | 133 +----------------- 8 files changed, 30 insertions(+), 159 deletions(-) rename public/components/search-bar/query-language/__snapshots__/{haql.test.tsx.snap => wql.test.tsx.snap} (99%) rename public/components/search-bar/query-language/{haql.md => wql.md} (98%) rename public/components/search-bar/query-language/{haql.test.tsx => wql.test.tsx} (95%) rename public/components/search-bar/query-language/{haql.tsx => wql.tsx} (99%) diff --git a/public/components/search-bar/__snapshots__/index.test.tsx.snap b/public/components/search-bar/__snapshots__/index.test.tsx.snap index 007974639e..5602512bd0 100644 --- a/public/components/search-bar/__snapshots__/index.test.tsx.snap +++ b/public/components/search-bar/__snapshots__/index.test.tsx.snap @@ -43,7 +43,7 @@ exports[`SearchBar component Renders correctly the initial render 1`] = ` - HAQL + WQL diff --git a/public/components/search-bar/index.test.tsx b/public/components/search-bar/index.test.tsx index b77cb2baa8..be6d332b6b 100644 --- a/public/components/search-bar/index.test.tsx +++ b/public/components/search-bar/index.test.tsx @@ -4,7 +4,7 @@ import { SearchBar } from './index'; describe('SearchBar component', () => { const componentProps = { - defaultMode: 'haql', + defaultMode: 'wql', input: '', modes: [ { @@ -20,7 +20,7 @@ describe('SearchBar component', () => { }, }, { - id: 'haql', + id: 'wql', implicitQuery: 'id!=000;', suggestions: { field(currentValue) { @@ -42,7 +42,7 @@ describe('SearchBar component', () => { const wrapper = render( ); diff --git a/public/components/search-bar/query-language/__snapshots__/haql.test.tsx.snap b/public/components/search-bar/query-language/__snapshots__/wql.test.tsx.snap similarity index 99% rename from public/components/search-bar/query-language/__snapshots__/haql.test.tsx.snap rename to public/components/search-bar/query-language/__snapshots__/wql.test.tsx.snap index 8636885205..f1bad4e5d4 100644 --- a/public/components/search-bar/query-language/__snapshots__/haql.test.tsx.snap +++ b/public/components/search-bar/query-language/__snapshots__/wql.test.tsx.snap @@ -83,7 +83,7 @@ exports[`SearchBar component Renders correctly to match the snapshot of query la - HAQL + WQL diff --git a/public/components/search-bar/query-language/index.ts b/public/components/search-bar/query-language/index.ts index 22608fd9f5..9c0d6e2b9b 100644 --- a/public/components/search-bar/query-language/index.ts +++ b/public/components/search-bar/query-language/index.ts @@ -1,5 +1,5 @@ import { AQL } from './aql'; -import { HAQL } from './haql'; +import { WQL } from './wql'; type SearchBarQueryLanguage = { description: string; @@ -21,7 +21,7 @@ type SearchBarQueryLanguage = { // Register the query languages export const searchBarQueryLanguages: { [key: string]: SearchBarQueryLanguage; -} = [AQL, HAQL].reduce((accum, item) => { +} = [AQL, WQL].reduce((accum, item) => { if (accum[item.id]) { throw new Error(`Query language with id: ${item.id} already registered.`); } diff --git a/public/components/search-bar/query-language/haql.md b/public/components/search-bar/query-language/wql.md similarity index 98% rename from public/components/search-bar/query-language/haql.md rename to public/components/search-bar/query-language/wql.md index 8f5253eda0..7ff72ddc1f 100644 --- a/public/components/search-bar/query-language/haql.md +++ b/public/components/search-bar/query-language/wql.md @@ -1,6 +1,6 @@ -# Query Language - HAQL +# Query Language - WQL -HAQL (Human API Query Language) is a query language based in the `q` query parameters of the Wazuh API +WQL (Wazuh Query Language) is a query language based in the `q` query parameters of the Wazuh API endpoints. Documentation: https://wazuh.com/./user-manual/api/queries.html diff --git a/public/components/search-bar/query-language/haql.test.tsx b/public/components/search-bar/query-language/wql.test.tsx similarity index 95% rename from public/components/search-bar/query-language/haql.test.tsx rename to public/components/search-bar/query-language/wql.test.tsx index eeef8eb91a..be2e42a707 100644 --- a/public/components/search-bar/query-language/haql.test.tsx +++ b/public/components/search-bar/query-language/wql.test.tsx @@ -1,15 +1,15 @@ -import { getSuggestions, tokenizer, transformSpecificQLToUnifiedQL, HAQL } from './haql'; +import { getSuggestions, tokenizer, transformSpecificQLToUnifiedQL, WQL } from './wql'; import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { SearchBar } from '../index'; describe('SearchBar component', () => { const componentProps = { - defaultMode: HAQL.id, + defaultMode: WQL.id, input: '', modes: [ { - id: HAQL.id, + id: WQL.id, implicitQuery: 'id!=000;', suggestions: { field(currentValue) { @@ -43,7 +43,7 @@ describe('SearchBar component', () => { }); /* eslint-disable max-len */ -describe('Query language - HAQL', () => { +describe('Query language - WQL', () => { // Tokenize the input function tokenCreator({type, value}){ return {type, value}; @@ -153,7 +153,7 @@ describe('Query language - HAQL', () => { // Transform specific query language to UQL (Unified Query Language) it.each` - HAQL | UQL + WQL | UQL ${'field'} | ${'field'} ${'field='} | ${'field='} ${'field=value'} | ${'field=value'} @@ -177,13 +177,13 @@ describe('Query language - HAQL', () => { ${'field = value or field2 <'} | ${'field=value,field2<'} ${'field = value or field2 < value2'} | ${'field=value,field2 "custom value" '} | ${'(field=value);field2>custom value'} - `('transformSpecificQLToUnifiedQL - HAQL $HAQL', ({HAQL, UQL}) => { - expect(transformSpecificQLToUnifiedQL(HAQL)).toEqual(UQL); + `('transformSpecificQLToUnifiedQL - WQL $WQL', ({WQL, UQL}) => { + expect(transformSpecificQLToUnifiedQL(WQL)).toEqual(UQL); }); // When a suggestion is clicked, change the input text it.each` - HAQL | clikedSuggestion | changedInput + WQL | clikedSuggestion | changedInput ${''} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field'}} | ${'field'} ${'field'} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'field2'} ${'field'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '='}} | ${'field='} @@ -210,11 +210,11 @@ describe('Query language - HAQL', () => { ${'(field=value or field2>'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2'}} | ${'(field=value or field2>value2'} ${'(field=value or field2>value2'}| ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value3'}} | ${'(field=value or field2>value3'} ${'(field=value or field2>value2'}| ${{type: { iconType: 'tokenDenseVector', color: 'tint3' }, label: ')'}} | ${'(field=value or field2>value2)'} - `('click suggestion - HAQL $HAQL => $changedInput', async ({HAQL: currentInput, clikedSuggestion, changedInput}) => { + `('click suggestion - WQL $WQL => $changedInput', async ({WQL: currentInput, clikedSuggestion, changedInput}) => { // Mock input let input = currentInput; - const qlOutput = await HAQL.run(input, { + const qlOutput = await WQL.run(input, { setInput: (value: string): void => { input = value; }, queryLanguage: { parameters: { @@ -232,7 +232,7 @@ describe('Query language - HAQL', () => { // Transform the external input in UQL (Unified Query Language) to QL it.each` - UQL | HAQL + UQL | WQL ${''} | ${''} ${'field'} | ${'field'} ${'field='} | ${'field='} @@ -255,8 +255,8 @@ describe('Query language - HAQL', () => { ${'(field=value,field2>'} | ${'(field=value or field2>'} ${'(field=value,field2>value2'} | ${'(field=value or field2>value2'} ${'(field=value,field2>value2)'} | ${'(field=value or field2>value2)'} - `('Transform the external input UQL to QL - UQL $UQL => $HAQL', async ({UQL, HAQL: changedInput}) => { - expect(HAQL.transformUnifiedQuery(UQL)).toEqual(changedInput); + `('Transform the external input UQL to QL - UQL $UQL => $WQL', async ({UQL, WQL: changedInput}) => { + expect(WQL.transformUnifiedQuery(UQL)).toEqual(changedInput); }); }); diff --git a/public/components/search-bar/query-language/haql.tsx b/public/components/search-bar/query-language/wql.tsx similarity index 99% rename from public/components/search-bar/query-language/haql.tsx rename to public/components/search-bar/query-language/wql.tsx index cda8a12dfc..970d25020c 100644 --- a/public/components/search-bar/query-language/haql.tsx +++ b/public/components/search-bar/query-language/wql.tsx @@ -517,17 +517,17 @@ export function transformSpecificQLToUnifiedQL(input: string){ function getOutput(input: string, options: {implicitQuery?: string} = {}) { const query = `${transformUnifiedQueryToSpecificQueryLanguage(options?.implicitQuery ?? '')}${input}`; return { - language: HAQL.id, + language: WQL.id, unifiedQuery: transformSpecificQLToUnifiedQL(query), query }; }; -export const HAQL = { - id: 'haql', - label: 'HAQL', - description: 'HAQL allows to do queries.', - documentationLink: `https://github.com/wazuh/wazuh-kibana-app/blob/v${pluginPlatform.version.split('.').splice(0,2).join('.')}/public/components/search-bar/query-language/haql.md`, +export const WQL = { + id: 'wql', + label: 'WQL', + description: 'WQL (Wazuh Query language) allows to do queries.', + documentationLink: `https://github.com/wazuh/wazuh-kibana-app/blob/v${pluginPlatform.version.split('.').splice(0,2).join('.')}/public/components/search-bar/query-language/wql.md`, getConfiguration() { return { isOpenPopoverImplicitFilter: false, diff --git a/public/controllers/agent/components/agents-table.js b/public/controllers/agent/components/agents-table.js index 3de71e4a32..272d8b9c0f 100644 --- a/public/controllers/agent/components/agents-table.js +++ b/public/controllers/agent/components/agents-table.js @@ -620,140 +620,11 @@ export const AgentsTable = withErrorBoundary( /> {/** Example implementation */} { - switch (previousField) { - case 'configSum': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'dateAdd': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'id': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'ip': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'group': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'group_config_status': - return [AGENT_SYNCED_STATUS.SYNCED, AGENT_SYNCED_STATUS.NOT_SYNCED].map( - (status) => ({ - label: status, - }), - ); - break; - case 'lastKeepAline': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'manager': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'mergedSum': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'name': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'node_name': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'os.platform': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'status': - return UI_ORDER_AGENT_STATUS.map( - (status) => ({ - label: status, - }), - ); - break; - case 'version': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - default: - return []; - break; - } - }, - }, - }, - { - id: 'haql', + id: 'wql', implicitQuery: 'id!=000;', suggestions: { field(currentValue) { From 459a9329a8910b32a802ba4d1b20bced340ec308 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Wed, 15 Mar 2023 14:34:13 +0100 Subject: [PATCH 11/37] feat(search-bar): Add more use cases to the tests of WQL query language - Add more use cases to the test of WQL query language - Replace some literals by constants in the WQL query language implementation --- .../search-bar/query-language/wql.test.tsx | 93 ++++++++++++++++++- .../search-bar/query-language/wql.tsx | 21 +++-- 2 files changed, 105 insertions(+), 9 deletions(-) diff --git a/public/components/search-bar/query-language/wql.test.tsx b/public/components/search-bar/query-language/wql.test.tsx index be2e42a707..e30402390d 100644 --- a/public/components/search-bar/query-language/wql.test.tsx +++ b/public/components/search-bar/query-language/wql.test.tsx @@ -91,6 +91,24 @@ describe('Query language - WQL', () => { ${'field'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, tu.opCompare, tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} ${'field='} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} ${'field=value'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=and'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('and'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=or'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('or'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=valueand'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('valueand'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=valueor'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('valueor'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value='} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value='), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value!='} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value!='), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value>'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value>'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value<'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value<'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value~'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value~'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie] /** ~ character is not supported as value in the q query parameter */} + ${'field="'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value and'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value and'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value and value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value and value2"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value or value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value or value2"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value = value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value = value2"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value != value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value != value2"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value > value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value > value2"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value < value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value < value2"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value ~ value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value ~ value2"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie] /** ~ character is not supported as value in the q query parameter */} ${'field=value and'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), tu.whitespace, ...tuBlankSerie]} ${'field=value and '} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), ...tuBlankSerie]} ${'field=value and field2'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, tu.opCompare, tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} @@ -153,12 +171,27 @@ describe('Query language - WQL', () => { // Transform specific query language to UQL (Unified Query Language) it.each` - WQL | UQL + WQL | UQL ${'field'} | ${'field'} ${'field='} | ${'field='} ${'field=value'} | ${'field=value'} ${'field=value()'} | ${'field=value()'} + ${'field=valueand'} | ${'field=valueand'} + ${'field=valueor'} | ${'field=valueor'} + ${'field=value='} | ${'field=value='} + ${'field=value!='} | ${'field=value!='} + ${'field=value>'} | ${'field=value>'} + ${'field=value<'} | ${'field=value<'} + ${'field=value~'} | ${'field=value~' /** ~ character is not supported as value in the q query parameter */} ${'field="custom value"'} | ${'field=custom value'} + ${'field="custom value()"'} | ${'field=custom value()'} + ${'field="value and value2"'} | ${'field=value and value2'} + ${'field="value or value2"'} | ${'field=value or value2'} + ${'field="value = value2"'} | ${'field=value = value2'} + ${'field="value != value2"'} | ${'field=value != value2'} + ${'field="value > value2"'} | ${'field=value > value2'} + ${'field="value < value2"'} | ${'field=value < value2'} + ${'field="value ~ value2"'} | ${'field=value ~ value2' /** ~ character is not supported as value in the q query parameter */} ${'field="custom \\"value"'} | ${'field=custom "value'} ${'field="custom \\"value\\""'} | ${'field=custom "value"'} ${'field=value and'} | ${'field=value;'} @@ -172,22 +205,47 @@ describe('Query language - WQL', () => { ${'(field=value) and field2>"value with spaces"'} | ${'(field=value);field2>value with spaces'} ${'field ='} | ${'field='} ${'field = value'} | ${'field=value'} + ${'field = value()'} | ${'field=value()'} + ${'field = valueand'} | ${'field=valueand'} + ${'field = valueor'} | ${'field=valueor'} + ${'field = value='} | ${'field=value='} + ${'field = value!='} | ${'field=value!='} + ${'field = value>'} | ${'field=value>'} + ${'field = value<'} | ${'field=value<'} + ${'field = value~'} | ${'field=value~' /** ~ character is not supported as value in the q query parameter */} + ${'field = "custom value"'} | ${'field=custom value'} + ${'field = "custom value()"'} | ${'field=custom value()'} + ${'field = "value and value2"'} | ${'field=value and value2'} + ${'field = "value or value2"'} | ${'field=value or value2'} + ${'field = "value = value2"'} | ${'field=value = value2'} + ${'field = "value != value2"'} | ${'field=value != value2'} + ${'field = "value > value2"'} | ${'field=value > value2'} + ${'field = "value < value2"'} | ${'field=value < value2'} + ${'field = "value ~ value2"'} | ${'field=value ~ value2' /** ~ character is not supported as value in the q query parameter */} ${'field = value or'} | ${'field=value,'} ${'field = value or field2'} | ${'field=value,field2'} ${'field = value or field2 <'} | ${'field=value,field2<'} ${'field = value or field2 < value2'} | ${'field=value,field2 "custom value" '} | ${'(field=value);field2>custom value'} - `('transformSpecificQLToUnifiedQL - WQL $WQL', ({WQL, UQL}) => { + `('transformSpecificQLToUnifiedQL - WQL $WQL TO UQL $UQL', ({WQL, UQL}) => { expect(transformSpecificQLToUnifiedQL(WQL)).toEqual(UQL); }); // When a suggestion is clicked, change the input text it.each` - WQL | clikedSuggestion | changedInput + WQL | clikedSuggestion | changedInput ${''} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field'}} | ${'field'} ${'field'} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'field2'} ${'field'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '='}} | ${'field='} ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value'}} | ${'field=value'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value()'}} | ${'field=value()'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'valueand'}} | ${'field=valueand'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'valueor'}} | ${'field=valueor'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value='}} | ${'field=value='} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value!='}} | ${'field=value!='} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value>'}} | ${'field=value>'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value<'}} | ${'field=value<'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value~'}} | ${'field=value~' /** ~ character is not supported as value in the q query parameter */} ${'field='} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '!='}} | ${'field!='} ${'field=value'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2'}} | ${'field=value2'} ${'field=value'} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'and'}} | ${'field=value and '} @@ -195,6 +253,14 @@ describe('Query language - WQL', () => { ${'field=value and field2'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>'}} | ${'field=value and field2>'} ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with spaces'}} | ${'field="with spaces"'} ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with "spaces'}} | ${'field="with \\"spaces"'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with value()'}} | ${'field="with value()"'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with and value'}}| ${'field="with and value"'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with or value'}} | ${'field="with or value"'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with = value'}} | ${'field="with = value"'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with != value'}} | ${'field="with != value"'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with > value'}} | ${'field="with > value"'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with < value'}} | ${'field="with < value"'} + ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with ~ value'}} | ${'field="with ~ value"' /** ~ character is not supported as value in the q query parameter */} ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: '"value'}} | ${'field="\\"value"'} ${''} | ${{type: { iconType: 'tokenDenseVector', color: 'tint3' }, label: '('}} | ${'('} ${'('} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field'}} | ${'(field'} @@ -236,6 +302,14 @@ describe('Query language - WQL', () => { ${''} | ${''} ${'field'} | ${'field'} ${'field='} | ${'field='} + ${'field=()'} | ${'field=()'} + ${'field=valueand'} | ${'field=valueand'} + ${'field=valueor'} | ${'field=valueor'} + ${'field=value='} | ${'field=value='} + ${'field=value!='} | ${'field=value!='} + ${'field=value>'} | ${'field=value>'} + ${'field=value<'} | ${'field=value<'} + ${'field=value~'} | ${'field=value~'} ${'field!='} | ${'field!='} ${'field>'} | ${'field>'} ${'field<'} | ${'field<'} @@ -246,6 +320,12 @@ describe('Query language - WQL', () => { ${'field="'} | ${'field="\\""'} ${'field=with spaces'} | ${'field="with spaces"'} ${'field=with "spaces'} | ${'field="with \\"spaces"'} + ${'field=value ()'} | ${'field="value ()"'} + ${'field=with and value'} | ${'field="with and value"'} + ${'field=with or value'} | ${'field="with or value"'} + ${'field=with = value'} | ${'field="with = value"'} + ${'field=with > value'} | ${'field="with > value"'} + ${'field=with < value'} | ${'field="with < value"'} ${'('} | ${'('} ${'(field'} | ${'(field'} ${'(field='} | ${'(field='} @@ -259,4 +339,11 @@ describe('Query language - WQL', () => { expect(WQL.transformUnifiedQuery(UQL)).toEqual(changedInput); }); + /* The ! and ~ characters can't be part of a value that contains examples. The tests doesn't + include these cases. + + Value examples: + - with != value + - with ~ value + */ }); diff --git a/public/components/search-bar/query-language/wql.tsx b/public/components/search-bar/query-language/wql.tsx index 970d25020c..1202a7cc81 100644 --- a/public/components/search-bar/query-language/wql.tsx +++ b/public/components/search-bar/query-language/wql.tsx @@ -62,6 +62,14 @@ const language = { }, }, }, + equivalencesToUQL:{ + conjunction:{ + literal:{ + 'and': ';', + 'or': ',', + } + } + } }; // Suggestion mapper by language token type @@ -101,9 +109,9 @@ const mapSuggestionCreatorValue = mapSuggestionCreator('value'); */ function transformQLConjunction(conjunction: string): string{ // If the value has a whitespace or comma, then - return conjunction === ';' - ? ' and ' - : ' or '; + return conjunction === language.equivalencesToUQL.conjunction.literal['and'] + ? ` ${language.tokens.conjunction.literal['and']} ` + : ` ${language.tokens.conjunction.literal['or']} `; }; /** @@ -491,15 +499,16 @@ export function transformSpecificQLToUnifiedQL(input: string){ case 'value':{ // Value is wrapped with " let [ _, extractedValue ] = value.match(/^"(.+)"$/) || [ null, null ]; - // Replace the escape commas (\") by comma (") + // Replace the escaped comma (\") by comma (") + // WARN: This could cause a problem with value that contains this sequence \" extractedValue && (extractedValue = extractedValue.replace(/\\"/g, '"')); return extractedValue || value; break; } case 'conjunction': return value === 'and' - ? ';' - : ','; + ? language.equivalencesToUQL.conjunction.literal['and'] + : language.equivalencesToUQL.conjunction.literal['or']; break; default: return value; From 315932a9900d929911d2696a9193c2f2746f2dd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Wed, 15 Mar 2023 14:39:38 +0100 Subject: [PATCH 12/37] feat(search-bar): enhance the documenation of query languages --- .../search-bar/query-language/aql.md | 4 ++- .../search-bar/query-language/wql.md | 30 ++++++++++++------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/public/components/search-bar/query-language/aql.md b/public/components/search-bar/query-language/aql.md index 9dcb5bb0e0..293d2a049e 100644 --- a/public/components/search-bar/query-language/aql.md +++ b/public/components/search-bar/query-language/aql.md @@ -12,10 +12,12 @@ The implementation is adapted to work with the search bar component defined - Suggestions for `fields` (configurable), `operators` and `values` (configurable) - Support implicit query -## Language syntax +# Language syntax Documentation: https://wazuh.com/./user-manual/api/queries.html +# Developer notes + ## Options - `implicitQuery`: add an implicit query that is added to the user input. Optional. diff --git a/public/components/search-bar/query-language/wql.md b/public/components/search-bar/query-language/wql.md index 7ff72ddc1f..4c19b731ca 100644 --- a/public/components/search-bar/query-language/wql.md +++ b/public/components/search-bar/query-language/wql.md @@ -12,15 +12,15 @@ The implementation is adapted to work with the search bar component defined - Suggestions for `fields` (configurable), `operators` and `values` (configurable) - Support implicit query -## Language syntax +# Language syntax -### Schema +## Schema ``` ???????????? ``` -### Fields +## Fields Regular expression: /[\\w.]+/ @@ -31,9 +31,9 @@ field field.custom ``` -### Operators +## Operators -#### Compare +### Compare - `=` equal to - `!=` not equal to @@ -41,15 +41,15 @@ field.custom - `<` smaller - `~` like -#### Group +### Group - `(` open - `)` close -#### Logical +### Conjunction (logical) -- `and` -- `or` +- `and` intersection +- `or` union ### Values @@ -93,6 +93,8 @@ status != never_connected and ip ~ 240 or os.platform ~ linux ( status != never_connected and ip ~ 240 ) or id = 001 ``` +## Developer notes + ## Options - `implicitQuery`: add an implicit query that is added to the user input. Optional. @@ -275,4 +277,12 @@ graph TD; end output-->output_search_bar[Output] -``` \ No newline at end of file +``` + +## Notes + +- The value that contains the following characters: `!`, `~` are not supported by the AQL and this +could cause problems when do the request to the API. +- The value with spaces are wrapped with `"`. If the value contains the `\"` sequence this is +replaced by `"`. This could cause a problem with values that are intended to have the mentioned +sequence. \ No newline at end of file From 4303a1af4b0bf72d790c4aa4b47750c2653db914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Wed, 15 Mar 2023 14:50:05 +0100 Subject: [PATCH 13/37] feat(search-bar): Add a popover title to replicate similar UI to the platform search bar --- public/components/search-bar/index.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/public/components/search-bar/index.tsx b/public/components/search-bar/index.tsx index 63b1d5a49a..fd1be3cdab 100644 --- a/public/components/search-bar/index.tsx +++ b/public/components/search-bar/index.tsx @@ -4,6 +4,7 @@ import { EuiFormRow, EuiLink, EuiPopover, + EuiPopoverTitle, EuiSpacer, EuiSelect, EuiText, @@ -141,6 +142,7 @@ export const SearchBar = ({ isOpen={isOpenPopoverQueryLanguage} closePopover={onQueryLanguagePopoverSwitch} > + SYNTAX OPTIONS {searchBarQueryLanguages[queryLanguage.id].description} From 5e1485c330e1204769aca21bc9ab2cfabbaa4473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Wed, 15 Mar 2023 14:51:46 +0100 Subject: [PATCH 14/37] feat(search-bar): wrap the user input with group operators when there is an implicit query --- public/components/search-bar/query-language/aql.tsx | 2 +- public/components/search-bar/query-language/wql.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/components/search-bar/query-language/aql.tsx b/public/components/search-bar/query-language/aql.tsx index a93f33df19..9cb38211d5 100644 --- a/public/components/search-bar/query-language/aql.tsx +++ b/public/components/search-bar/query-language/aql.tsx @@ -409,7 +409,7 @@ function transformSuggestionsToEuiSuggestItem( * @returns */ function getOutput(input: string, options: {implicitQuery?: string} = {}) { - const unifiedQuery = `${options?.implicitQuery ?? ''}${input}`; + const unifiedQuery = `${options?.implicitQuery ?? ''}${options?.implicitQuery ? `(${input})` : input}`; return { language: AQL.id, query: unifiedQuery, diff --git a/public/components/search-bar/query-language/wql.tsx b/public/components/search-bar/query-language/wql.tsx index 1202a7cc81..346569c5ec 100644 --- a/public/components/search-bar/query-language/wql.tsx +++ b/public/components/search-bar/query-language/wql.tsx @@ -524,7 +524,7 @@ export function transformSpecificQLToUnifiedQL(input: string){ * @returns */ function getOutput(input: string, options: {implicitQuery?: string} = {}) { - const query = `${transformUnifiedQueryToSpecificQueryLanguage(options?.implicitQuery ?? '')}${input}`; + const query = `${transformUnifiedQueryToSpecificQueryLanguage(options?.implicitQuery ?? '')}${options?.implicitQuery ? `(${input})` : input}`; return { language: WQL.id, unifiedQuery: transformSpecificQLToUnifiedQL(query), From 9983153383cd94165d62929173a434aaf6fc9253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Thu, 16 Mar 2023 16:42:30 +0100 Subject: [PATCH 15/37] feat(search-bar): add implicit query mode to WQL - WQL - add implicit query mode to WQL - enhance query language documentation - renamed transformUnifiedQuery to transformUQLToQL - now wraps the user input if this is defined and there a implicit query string - fix a problem with the value suggestions if there is a previous conjunction - add tests cases - update tests - AQL - enhance query language documentation - renamed transformUnifiedQuery to transformUQLToQL - add warning about the query language implementation is not updated to the last changes in the search bar component - update tests - Search Bar - renamed transformUnifiedQuery to transformUQLToQL --- public/components/search-bar/README.md | 12 +- public/components/search-bar/index.test.tsx | 5 +- public/components/search-bar/index.tsx | 8 +- .../search-bar/query-language/aql.md | 2 + .../search-bar/query-language/aql.test.tsx | 2 +- .../search-bar/query-language/aql.tsx | 2 +- .../search-bar/query-language/index.ts | 2 +- .../search-bar/query-language/wql.md | 85 +++++++++++-- .../search-bar/query-language/wql.test.tsx | 13 +- .../search-bar/query-language/wql.tsx | 119 +++++++++++++----- .../agent/components/agents-table.js | 20 ++- 11 files changed, 210 insertions(+), 60 deletions(-) diff --git a/public/components/search-bar/README.md b/public/components/search-bar/README.md index df007a4ea0..58e557a26c 100644 --- a/public/components/search-bar/README.md +++ b/public/components/search-bar/README.md @@ -26,13 +26,15 @@ Basic usage: // The ID of them should be registered previously. See How to add a new query language documentation. modes={[ { - id: 'aql', + id: 'wql', // specific query language parameters // implicit query. Optional // Set a implicit query that can't be changed by the user. // Use the UQL (Unified Query Language) syntax. - // Each query language implementation must interpret - implicitQuery: 'id!=000;', + implicitQuery: { + query: 'id!=000', + conjunction: ';' + }, suggestions: { field(currentValue) { return [ @@ -202,7 +204,7 @@ type SearchBarQueryLanguage = { query: string } }>; - transformUnifiedQuery: (unifiedQuery: string) => string; + transformUQLToQL: (unifiedQuery: string) => string; }; ``` @@ -221,7 +223,7 @@ where: - `language`: query language ID - `unifiedQuery`: query in unified query syntax - `query`: current query in the specified language -- `transformUnifiedQuery`: method that transform the Unified Query Language to the specific query +- `transformUQLToQL`: method that transforms the UQL (Unified Query Language) to the specific query language. This is used when receives a external input in the Unified Query Language, the returned value is converted to the specific query language to set the new input text of the search bar component. diff --git a/public/components/search-bar/index.test.tsx b/public/components/search-bar/index.test.tsx index be6d332b6b..2f8f2366a4 100644 --- a/public/components/search-bar/index.test.tsx +++ b/public/components/search-bar/index.test.tsx @@ -21,7 +21,10 @@ describe('SearchBar component', () => { }, { id: 'wql', - implicitQuery: 'id!=000;', + implicitQuery: { + query: 'id!=000', + conjunction: ';' + }, suggestions: { field(currentValue) { return []; diff --git a/public/components/search-bar/index.tsx b/public/components/search-bar/index.tsx index fd1be3cdab..e902f553f4 100644 --- a/public/components/search-bar/index.tsx +++ b/public/components/search-bar/index.tsx @@ -12,7 +12,7 @@ import { import { EuiSuggest } from '../eui-suggest'; import { searchBarQueryLanguages } from './query-language'; -type Props = { +type SearchBarProps = { defaultMode?: string; modes: { id: string; [key: string]: any }[]; onChange?: (params: any) => void; @@ -26,7 +26,7 @@ export const SearchBar = ({ onChange, onSearch, ...rest -}: Props) => { +}: SearchBarProps) => { // Query language ID and configuration const [queryLanguage, setQueryLanguage] = useState<{ id: string; @@ -73,10 +73,10 @@ export const SearchBar = ({ }; useEffect(() => { - // React to external changes and set the internal input text. Use the `transformUnifiedQuery` of + // React to external changes and set the internal input text. Use the `transformUQLToQL` of // the query language in use rest.input && setInput( - searchBarQueryLanguages[queryLanguage.id]?.transformUnifiedQuery?.( + searchBarQueryLanguages[queryLanguage.id]?.transformUQLToQL?.( rest.input, ), ); diff --git a/public/components/search-bar/query-language/aql.md b/public/components/search-bar/query-language/aql.md index 293d2a049e..9d144e3b15 100644 --- a/public/components/search-bar/query-language/aql.md +++ b/public/components/search-bar/query-language/aql.md @@ -1,3 +1,5 @@ +**WARNING: The search bar was changed and this language needs some adaptations to work.** + # Query Language - AQL AQL (API Query Language) is a query language based in the `q` query parameters of the Wazuh API diff --git a/public/components/search-bar/query-language/aql.test.tsx b/public/components/search-bar/query-language/aql.test.tsx index 597d31188a..86fc9de5c9 100644 --- a/public/components/search-bar/query-language/aql.test.tsx +++ b/public/components/search-bar/query-language/aql.test.tsx @@ -200,6 +200,6 @@ describe('Query language - AQL', () => { ${'(field=value,field2>value2'} | ${'(field=value,field2>value2'} ${'(field=value,field2>value2)'} | ${'(field=value,field2>value2)'} `('Transform the external input UQL to QL - UQL $UQL => $AQL', async ({UQL, AQL: changedInput}) => { - expect(AQL.transformUnifiedQuery(UQL)).toEqual(changedInput); + expect(AQL.transformUQLToQL(UQL)).toEqual(changedInput); }); }); diff --git a/public/components/search-bar/query-language/aql.tsx b/public/components/search-bar/query-language/aql.tsx index 9cb38211d5..4256cd4540 100644 --- a/public/components/search-bar/query-language/aql.tsx +++ b/public/components/search-bar/query-language/aql.tsx @@ -517,7 +517,7 @@ export const AQL = { output: getOutput(input, params.queryLanguage.parameters), }; }, - transformUnifiedQuery(unifiedQuery: string): string { + transformUQLToQL(unifiedQuery: string): string { return unifiedQuery; }, }; diff --git a/public/components/search-bar/query-language/index.ts b/public/components/search-bar/query-language/index.ts index 9c0d6e2b9b..fc95717fca 100644 --- a/public/components/search-bar/query-language/index.ts +++ b/public/components/search-bar/query-language/index.ts @@ -15,7 +15,7 @@ type SearchBarQueryLanguage = { query: string } }>; - transformUnifiedQuery: (unifiedQuery: string) => string; + transformUQLToQL: (unifiedQuery: string) => string; }; // Register the query languages diff --git a/public/components/search-bar/query-language/wql.md b/public/components/search-bar/query-language/wql.md index 4c19b731ca..b99538b9c7 100644 --- a/public/components/search-bar/query-language/wql.md +++ b/public/components/search-bar/query-language/wql.md @@ -8,19 +8,24 @@ Documentation: https://wazuh.com/./user-manual/api The implementation is adapted to work with the search bar component defined `public/components/search-bar/index.tsx`. -## Features -- Suggestions for `fields` (configurable), `operators` and `values` (configurable) -- Support implicit query - # Language syntax -## Schema +It supports 2 modes: + +- `explicit`: define the field, operator and value +- `implicit`: use a term to search in the available fields + +Theses modes can not be combined. + +## Mode: explicit + +### Schema ``` ???????????? ``` -## Fields +### Fields Regular expression: /[\\w.]+/ @@ -31,9 +36,9 @@ field field.custom ``` -## Operators +### Operators -### Compare +#### Compare - `=` equal to - `!=` not equal to @@ -41,17 +46,17 @@ field.custom - `<` smaller - `~` like -### Group +#### Group - `(` open - `)` close -### Conjunction (logical) +#### Conjunction (logical) - `and` intersection - `or` union -### Values +#### Values - Value without spaces can be literal - Value with spaces should be wrapped by `"`. The `"` can be escaped using `\"`. @@ -93,19 +98,73 @@ status != never_connected and ip ~ 240 or os.platform ~ linux ( status != never_connected and ip ~ 240 ) or id = 001 ``` +## Mode: implicit + +Search the term in the available fields. + +This mode is used when there is no a `field` and `operator` attending to the regular expression +of the **explicit** mode. + +### Examples: + +``` +linux +``` + +If the available fields are `id` and `ip`, then the input will be translated under the hood to the +following UQL syntax: + +``` +id~linux,ip~linux +``` + ## Developer notes +## Features +- Support suggestions for each token entity. `fields` and `values` are customizable. +- Support implicit query. +- Support for search term mode. It enables to search a term in multiple fields. + The query is built under the hoods. This mode requires there are `field` and `operator_compare`. + +### Implicit query + +This a query that can't be added, edited or removed by the user. It is added to the user input. + +### Search term mode + +This mode enables to search in multiple fields. The fields to use must be defined. + +Use an union expression of each field with the like as operation `~`. + +The user input is transformed to something as: +``` +field1~user_input,field2~user_input,field3~user_input +``` + ## Options - `implicitQuery`: add an implicit query that is added to the user input. Optional. + This can't be changed by the user. If this is defined, will be displayed as a prepend of the search bar. + - `query`: query string in UQL (Unified Query Language) Use UQL (Unified Query Language). -This can't be changed by the user. If this is defined, will be displayed as a prepend of the search bar. + - `conjunction`: query string of the conjunction in UQL (Unified Query Language) + ```ts // language options // ID is not equal to 000 and . This is defined in UQL that is transformed internally to // the specific query language. -implicitQuery: 'id!=000;' +implicitQuery: { + query: 'id!=000', + conjunction: ';' +} +``` + +- `searchTermFields`: define the fields used to build the query for the search term mode + +```ts +// language options +searchTermFields: ['id', 'ip'] ``` - `suggestions`: define the suggestion handlers. This is required. diff --git a/public/components/search-bar/query-language/wql.test.tsx b/public/components/search-bar/query-language/wql.test.tsx index e30402390d..89ac5c50ae 100644 --- a/public/components/search-bar/query-language/wql.test.tsx +++ b/public/components/search-bar/query-language/wql.test.tsx @@ -10,7 +10,10 @@ describe('SearchBar component', () => { modes: [ { id: WQL.id, - implicitQuery: 'id!=000;', + implicitQuery: { + query: 'id!=000', + conjunction: ';' + }, suggestions: { field(currentValue) { return []; @@ -249,6 +252,9 @@ describe('Query language - WQL', () => { ${'field='} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '!='}} | ${'field!='} ${'field=value'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2'}} | ${'field=value2'} ${'field=value'} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'and'}} | ${'field=value and '} + ${'field=value and'} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'or'}} | ${'field=value or'} + ${'field=value and'} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'field=value and field2'} + ${'field=value and '} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'or'}} | ${'field=value or '} ${'field=value and '} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'field=value and field2'} ${'field=value and field2'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>'}} | ${'field=value and field2>'} ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with spaces'}} | ${'field="with spaces"'} @@ -269,6 +275,9 @@ describe('Query language - WQL', () => { ${'(field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value'}} | ${'(field=value'} ${'(field=value'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2'}} | ${'(field=value2'} ${'(field=value'} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'or'}} | ${'(field=value or '} + ${'(field=value or'} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'and'}} | ${'(field=value and'} + ${'(field=value or'} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'(field=value or field2'} + ${'(field=value or '} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'and'}} | ${'(field=value and '} ${'(field=value or '} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'(field=value or field2'} ${'(field=value or field2'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>'}} | ${'(field=value or field2>'} ${'(field=value or field2>'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>'}} | ${'(field=value or field2>'} @@ -336,7 +345,7 @@ describe('Query language - WQL', () => { ${'(field=value,field2>value2'} | ${'(field=value or field2>value2'} ${'(field=value,field2>value2)'} | ${'(field=value or field2>value2)'} `('Transform the external input UQL to QL - UQL $UQL => $WQL', async ({UQL, WQL: changedInput}) => { - expect(WQL.transformUnifiedQuery(UQL)).toEqual(changedInput); + expect(WQL.transformUQLToQL(UQL)).toEqual(changedInput); }); /* The ! and ~ characters can't be part of a value that contains examples. The tests doesn't diff --git a/public/components/search-bar/query-language/wql.tsx b/public/components/search-bar/query-language/wql.tsx index 346569c5ec..795417e7d1 100644 --- a/public/components/search-bar/query-language/wql.tsx +++ b/public/components/search-bar/query-language/wql.tsx @@ -203,7 +203,13 @@ type QLOptionSuggestionHandler = ( }: { previousField: string; previousOperatorCompare: string }, ) => Promise; -type optionsQL = { +type OptionsQLImplicitQuery = { + query: string + conjunction: string +} +type OptionsQL = { + implicitQuery?: OptionsQLImplicitQuery + searchTermFields?: string[] suggestions: { field: QLOptionSuggestionHandler; value: QLOptionSuggestionHandler; @@ -216,7 +222,7 @@ type optionsQL = { * @param tokenType token type to search * @returns */ -function getLastTokenWithValue( +function getLastTokenDefined( tokens: ITokens ): IToken | undefined { // Reverse the tokens array and use the Array.protorype.find method @@ -234,7 +240,7 @@ function getLastTokenWithValue( * @param tokenType token type to search * @returns */ -function getLastTokenWithValueByType( +function getLastTokenDefinedByType( tokens: ITokens, tokenType: ITokenType, ): IToken | undefined { @@ -255,13 +261,13 @@ function getLastTokenWithValueByType( * @param options * @returns */ -export async function getSuggestions(tokens: ITokens, options: optionsQL): Promise { +export async function getSuggestions(tokens: ITokens, options: OptionsQL): Promise { if (!tokens.length) { return []; } // Get last token - const lastToken = getLastTokenWithValue(tokens); + const lastToken = getLastTokenDefined(tokens); // If it can't get a token with value, then returns fields and open operator group if(!lastToken?.type){ @@ -302,8 +308,8 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL): Promi ]; break; case 'operator_compare':{ - const previousField = getLastTokenWithValueByType(tokens, 'field')?.value; - const previousOperatorCompare = getLastTokenWithValueByType( + const previousField = getLastTokenDefinedByType(tokens, 'field')?.value; + const previousOperatorCompare = getLastTokenDefinedByType( tokens, 'operator_compare', )?.value; @@ -340,14 +346,14 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL): Promi break; } case 'value':{ - const previousField = getLastTokenWithValueByType(tokens, 'field')?.value; - const previousOperatorCompare = getLastTokenWithValueByType( + const previousField = getLastTokenDefinedByType(tokens, 'field')?.value; + const previousOperatorCompare = getLastTokenDefinedByType( tokens, 'operator_compare', )?.value; - // If there is no a previous field or operator_compar, then no return suggestions because - //it would be an syntax error + /* If there is no a previous field or operator_compare, then no return suggestions because + it would be an syntax error */ if(!previousField || !previousOperatorCompare){ return []; }; @@ -461,11 +467,11 @@ function transformSuggestionsToEuiSuggestItem( }; /** - * Transform the UQL (Unified Query Language) to SpecificQueryLanguage + * Transform the UQL (Unified Query Language) to QL * @param input * @returns */ -export function transformUnifiedQueryToSpecificQueryLanguage(input: string){ +export function transformUQLToQL(input: string){ const tokens = tokenizerUQL(input); return tokens .filter(({value}) => value) @@ -485,13 +491,29 @@ export function transformUnifiedQueryToSpecificQueryLanguage(input: string){ ).join(''); }; +export function shouldUseSearchTerm(tokens: ITokens): boolean{ + return !( + tokens.some(({type, value}) => type === 'operator_compare' && value ) + && tokens.some(({type, value}) => type === 'field' && value ) + ); +}; + +export function transformToSearchTerm(searchTermFields: string[], input: string): string{ + return searchTermFields.map(searchTermField => `${searchTermField}~${input}`).join(','); +}; + /** - * Transform the input in SpecificQueryLanguage to UQL (Unified Query Language) + * Transform the input in QL to UQL (Unified Query Language) * @param input * @returns */ -export function transformSpecificQLToUnifiedQL(input: string){ +export function transformSpecificQLToUnifiedQL(input: string, searchTermFields: string[]){ const tokens = tokenizer(input); + + if(input && searchTermFields && shouldUseSearchTerm(tokens)){ + return transformToSearchTerm(searchTermFields, input); + }; + return tokens .filter(({type, value}) => type !== 'whitespace' && value) .map(({type, value}) => { @@ -523,19 +545,45 @@ export function transformSpecificQLToUnifiedQL(input: string){ * @param input * @returns */ -function getOutput(input: string, options: {implicitQuery?: string} = {}) { - const query = `${transformUnifiedQueryToSpecificQueryLanguage(options?.implicitQuery ?? '')}${options?.implicitQuery ? `(${input})` : input}`; +function getOutput(input: string, options: OptionsQL) { + // Implicit query + const implicitQueryAsUQL = options?.implicitQuery?.query ?? ''; + const implicitQueryAsQL = transformUQLToQL( + implicitQueryAsUQL + ); + + // Implicit query conjunction + const implicitQueryConjunctionAsUQL = options?.implicitQuery?.conjunction ?? ''; + const implicitQueryConjunctionAsQL = transformUQLToQL( + implicitQueryConjunctionAsUQL + ); + + // User input query + const inputQueryAsQL = input; + const inputQueryAsUQL = transformSpecificQLToUnifiedQL( + inputQueryAsQL, + options?.searchTermFields ?? [] + ); + return { language: WQL.id, - unifiedQuery: transformSpecificQLToUnifiedQL(query), - query + unifiedQuery: [ + implicitQueryAsUQL, + implicitQueryAsUQL && inputQueryAsUQL ? implicitQueryConjunctionAsUQL : '', + implicitQueryAsUQL && inputQueryAsUQL ? `(${inputQueryAsUQL})`: inputQueryAsUQL + ].join(''), + query: [ + implicitQueryAsQL, + implicitQueryAsQL && inputQueryAsQL ? implicitQueryConjunctionAsQL : '', + implicitQueryAsQL && inputQueryAsQL ? `(${inputQueryAsQL})`: inputQueryAsQL + ].join('') }; }; export const WQL = { id: 'wql', label: 'WQL', - description: 'WQL (Wazuh Query language) allows to do queries.', + description: 'WQL (Wazuh Query Language) allows to do queries.', documentationLink: `https://github.com/wazuh/wazuh-kibana-app/blob/v${pluginPlatform.version.split('.').splice(0,2).join('.')}/public/components/search-bar/query-language/wql.md`, getConfiguration() { return { @@ -546,11 +594,17 @@ export const WQL = { // Get the tokens from the input const tokens: ITokens = tokenizer(input); - // - const implicitQueryAsSpecificQueryLanguage = params.queryLanguage.parameters.implicitQuery - ? transformUnifiedQueryToSpecificQueryLanguage(params.queryLanguage.parameters.implicitQuery) + // Get the implicit query as query language syntax + const implicitQueryAsQL = params.queryLanguage.parameters.implicitQuery + ? transformUQLToQL( + params.queryLanguage.parameters.implicitQuery.query + + params.queryLanguage.parameters.implicitQuery.conjunction + ) : ''; + // Get the output of query language + const output = getOutput(input, params.queryLanguage.parameters); + return { searchBarProps: { // Props that will be used by the EuiSuggest component @@ -563,10 +617,10 @@ export const WQL = { // When the clicked item has the `search` iconType, run the `onSearch` function if (item.type.iconType === 'search') { // Execute the search action - params.onSearch(getOutput(input, params.queryLanguage.parameters)); + params.onSearch(output); } else { // When the clicked item has another iconType - const lastToken: IToken | undefined = getLastTokenWithValue(tokens); + const lastToken: IToken | undefined = getLastTokenDefined(tokens); // if the clicked suggestion is of same type of last token if ( lastToken && suggestionMappingLanguageTokenType[lastToken.type].iconType === @@ -577,7 +631,10 @@ export const WQL = { } else { // add a whitespace for conjunction !(/\s$/.test(input)) - && item.type.iconType === suggestionMappingLanguageTokenType.conjunction.iconType + && ( + item.type.iconType === suggestionMappingLanguageTokenType.conjunction.iconType + || lastToken?.type === 'conjunction' + ) && tokens.push({ type: 'whitespace', value: ' ' @@ -610,7 +667,7 @@ export const WQL = { .join('')); } }, - prepend: implicitQueryAsSpecificQueryLanguage ? ( + prepend: implicitQueryAsQL ? ( - {implicitQueryAsSpecificQueryLanguage} + {implicitQueryAsQL} } @@ -640,7 +697,7 @@ export const WQL = { > Implicit query:{' '} - {implicitQueryAsSpecificQueryLanguage} + {implicitQueryAsQL} This query is added to the input. @@ -651,8 +708,8 @@ export const WQL = { // use case. disableFocusTrap: true }, - output: getOutput(input, params.queryLanguage.parameters), + output, }; }, - transformUnifiedQuery: transformUnifiedQueryToSpecificQueryLanguage, + transformUQLToQL: transformUQLToQL, }; diff --git a/public/controllers/agent/components/agents-table.js b/public/controllers/agent/components/agents-table.js index 272d8b9c0f..82ce73d88d 100644 --- a/public/controllers/agent/components/agents-table.js +++ b/public/controllers/agent/components/agents-table.js @@ -625,7 +625,25 @@ export const AgentsTable = withErrorBoundary( modes={[ { id: 'wql', - implicitQuery: 'id!=000;', + implicitQuery: { + query: 'id!=000', + conjunction: ';' + }, + searchTermFields: [ + 'configSum', + 'dateAdd', + 'id', + 'group', + 'group_config_status', + 'lastKeepAline', + 'manager', + 'mergedSum', + 'name', + 'node_name', + 'os.platform', + 'status', + 'version' + ], suggestions: { field(currentValue) { return [ From 1eaf94400c83181550a71d6dea07c8d2e5e69937 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Thu, 23 Mar 2023 09:12:07 +0100 Subject: [PATCH 16/37] feat(search-bar): set the width of the syntax options popover --- public/components/search-bar/index.tsx | 94 +++++++++++++------------- 1 file changed, 48 insertions(+), 46 deletions(-) diff --git a/public/components/search-bar/index.tsx b/public/components/search-bar/index.tsx index e902f553f4..2ecf57f858 100644 --- a/public/components/search-bar/index.tsx +++ b/public/components/search-bar/index.tsx @@ -143,52 +143,54 @@ export const SearchBar = ({ closePopover={onQueryLanguagePopoverSwitch} > SYNTAX OPTIONS - - {searchBarQueryLanguages[queryLanguage.id].description} - - {searchBarQueryLanguages[queryLanguage.id].documentationLink && ( - <> - -
- - Documentation - -
- - )} - {modes?.length > 1 && ( - <> - - - ({ - value: id, - text: searchBarQueryLanguages[id].label, - }))} - value={queryLanguage.id} - onChange={(event: React.ChangeEvent) => { - const queryLanguageID: string = event.target.value; - setQueryLanguage({ - id: queryLanguageID, - configuration: - searchBarQueryLanguages[ - queryLanguageID - ]?.getConfiguration?.() || {}, - }); - setInput(''); - }} - aria-label='query-language-selector' - /> - - - )} +
+ + {searchBarQueryLanguages[queryLanguage.id].description} + + {searchBarQueryLanguages[queryLanguage.id].documentationLink && ( + <> + +
+ + Documentation + +
+ + )} + {modes?.length > 1 && ( + <> + + + ({ + value: id, + text: searchBarQueryLanguages[id].label, + }))} + value={queryLanguage.id} + onChange={(event: React.ChangeEvent) => { + const queryLanguageID: string = event.target.value; + setQueryLanguage({ + id: queryLanguageID, + configuration: + searchBarQueryLanguages[ + queryLanguageID + ]?.getConfiguration?.() || {}, + }); + setInput(''); + }} + aria-label='query-language-selector' + /> + + + )} +
} {...queryLanguageOutputRun.searchBarProps} From d9abea0d47bb9707f9e5c2de9187617f532d1edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Thu, 23 Mar 2023 10:47:11 +0100 Subject: [PATCH 17/37] feat(search-bar): unify suggestion descriptions in WQL - Set a width for the syntax options popover - Unify the description in the suggestions of WQL example implementation - Update tests - Fix minor bugs in the WQL example implementation in Agents --- .../search-bar/query-language/aql.test.tsx | 6 ++-- .../search-bar/query-language/aql.tsx | 2 +- .../search-bar/query-language/wql.test.tsx | 4 +-- .../search-bar/query-language/wql.tsx | 4 +-- .../agent/components/agents-table.js | 31 ++++++++++--------- 5 files changed, 24 insertions(+), 23 deletions(-) diff --git a/public/components/search-bar/query-language/aql.test.tsx b/public/components/search-bar/query-language/aql.test.tsx index 86fc9de5c9..a5f7c7d36c 100644 --- a/public/components/search-bar/query-language/aql.test.tsx +++ b/public/components/search-bar/query-language/aql.test.tsx @@ -82,12 +82,12 @@ describe('Query language - AQL', () => { ${'f'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }]} ${'field'} | ${[{ description: 'Field2', label: 'field2', type: 'field' }, { description: 'equality', label: '=', type: 'operator_compare' }, { description: 'not equality', label: '!=', type: 'operator_compare' }, { description: 'bigger', label: '>', type: 'operator_compare' }, { description: 'smaller', label: '<', type: 'operator_compare' }, { description: 'like as', label: '~', type: 'operator_compare' }]} ${'field='} | ${[{ label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }]} - ${'field=v'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} - ${'field=value'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=v'} | ${[{ description: 'run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=value'} | ${[{ description: 'run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} ${'field=value;'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }, { description: 'open group', label: '(', type: 'operator_group' }]} ${'field=value;field2'} | ${[{ description: 'equality', label: '=', type: 'operator_compare' }, { description: 'not equality', label: '!=', type: 'operator_compare' }, { description: 'bigger', label: '>', type: 'operator_compare' }, { description: 'smaller', label: '<', type: 'operator_compare' }, { description: 'like as', label: '~', type: 'operator_compare' }]} ${'field=value;field2='} | ${[{ label: '127.0.0.1', type: 'value' }, { label: '127.0.0.2', type: 'value' }, { label: '190.0.0.1', type: 'value' }, { label: '190.0.0.2', type: 'value' }]} - ${'field=value;field2=127'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { label: '127.0.0.1', type: 'value' }, { label: '127.0.0.2', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=value;field2=127'} | ${[{ description: 'run the search query', label: 'Search', type: 'function_search' }, { label: '127.0.0.1', type: 'value' }, { label: '127.0.0.2', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} `('Get suggestion from the input: $input', async ({ input, suggestions }) => { expect( await getSuggestions(tokenizer(input), { diff --git a/public/components/search-bar/query-language/aql.tsx b/public/components/search-bar/query-language/aql.tsx index 4256cd4540..8c898af3e2 100644 --- a/public/components/search-bar/query-language/aql.tsx +++ b/public/components/search-bar/query-language/aql.tsx @@ -299,7 +299,7 @@ export async function getSuggestions(tokens: ITokens, options: optionsQL): Promi { type: 'function_search', label: 'Search', - description: 'Run the search query', + description: 'run the search query', }, ] : []), diff --git a/public/components/search-bar/query-language/wql.test.tsx b/public/components/search-bar/query-language/wql.test.tsx index 89ac5c50ae..752b9b8451 100644 --- a/public/components/search-bar/query-language/wql.test.tsx +++ b/public/components/search-bar/query-language/wql.test.tsx @@ -132,8 +132,8 @@ describe('Query language - WQL', () => { ${'f'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }]} ${'field'} | ${[{ description: 'Field2', label: 'field2', type: 'field' }, { description: 'equality', label: '=', type: 'operator_compare' }, { description: 'not equality', label: '!=', type: 'operator_compare' }, { description: 'bigger', label: '>', type: 'operator_compare' }, { description: 'smaller', label: '<', type: 'operator_compare' }, { description: 'like as', label: '~', type: 'operator_compare' }]} ${'field='} | ${[{ label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }]} - ${'field=v'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: 'and', type: 'conjunction' }, { description: 'or', label: 'or', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} - ${'field=value'} | ${[{ description: 'Run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: 'and', type: 'conjunction' }, { description: 'or', label: 'or', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=v'} | ${[{ description: 'run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: 'and', type: 'conjunction' }, { description: 'or', label: 'or', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=value'} | ${[{ description: 'run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: 'and', type: 'conjunction' }, { description: 'or', label: 'or', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} ${'field=value and'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }, { description: 'open group', label: '(', type: 'operator_group' }]} `('Get suggestion from the input: $input', async ({ input, suggestions }) => { expect( diff --git a/public/components/search-bar/query-language/wql.tsx b/public/components/search-bar/query-language/wql.tsx index 795417e7d1..84758b3ec3 100644 --- a/public/components/search-bar/query-language/wql.tsx +++ b/public/components/search-bar/query-language/wql.tsx @@ -364,7 +364,7 @@ export async function getSuggestions(tokens: ITokens, options: OptionsQL): Promi { type: 'function_search', label: 'Search', - description: 'Run the search query', + description: 'run the search query', }, ] : []), @@ -583,7 +583,7 @@ function getOutput(input: string, options: OptionsQL) { export const WQL = { id: 'wql', label: 'WQL', - description: 'WQL (Wazuh Query Language) allows to do queries.', + description: 'WQL (Wazuh Query Language) offers a human query syntax based on the Wazuh API query language.', documentationLink: `https://github.com/wazuh/wazuh-kibana-app/blob/v${pluginPlatform.version.split('.').splice(0,2).join('.')}/public/components/search-bar/query-language/wql.md`, getConfiguration() { return { diff --git a/public/controllers/agent/components/agents-table.js b/public/controllers/agent/components/agents-table.js index 82ce73d88d..84ac881f60 100644 --- a/public/controllers/agent/components/agents-table.js +++ b/public/controllers/agent/components/agents-table.js @@ -633,9 +633,10 @@ export const AgentsTable = withErrorBoundary( 'configSum', 'dateAdd', 'id', + 'ip', 'group', 'group_config_status', - 'lastKeepAline', + 'lastKeepAlive', 'manager', 'mergedSum', 'name', @@ -647,20 +648,20 @@ export const AgentsTable = withErrorBoundary( suggestions: { field(currentValue) { return [ - { label: 'configSum', description: 'Config sum' }, - { label: 'dateAdd', description: 'Date add' }, - { label: 'id', description: 'ID' }, - { label: 'ip', description: 'IP address' }, - { label: 'group', description: 'Group' }, - { label: 'group_config_status', description: 'Synced configuration status' }, - { label: 'lastKeepAline', description: 'Date add' }, - { label: 'manager', description: 'Manager' }, - { label: 'mergedSum', description: 'Merged sum' }, - { label: 'name', description: 'Agent name' }, - { label: 'node_name', description: 'Node name' }, - { label: 'os.platform', description: 'Operating system platform' }, - { label: 'status', description: 'Status' }, - { label: 'version', description: 'Version' }, + { label: 'configSum', description: 'filter by config sum' }, + { label: 'dateAdd', description: 'filter by date add' }, + { label: 'id', description: 'filter by ID' }, + { label: 'ip', description: 'filter by IP address' }, + { label: 'group', description: 'filter by Group' }, + { label: 'group_config_status', description: 'filter by Synced configuration status' }, + { label: 'lastKeepAlive', description: 'filter by date add' }, + { label: 'manager', description: 'filter by manager' }, + { label: 'mergedSum', description: 'filter by merged sum' }, + { label: 'name', description: 'filter by name' }, + { label: 'node_name', description: 'filter by manager node name' }, + { label: 'os.platform', description: 'filter by operating system platform' }, + { label: 'status', description: 'filter by status' }, + { label: 'version', description: 'filter by version' }, ]; }, value: async (currentValue, { previousField }) => { From c221f97b3f7044282f83a0daf84fdf8b9a343743 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Mon, 10 Apr 2023 16:56:36 +0200 Subject: [PATCH 18/37] feat(search-bar): add enhancements to WQL - WQL - Enhance documentation - Add partial and "expanded" input validation - Add tests --- .../components/eui-suggest/suggest_input.js | 2 +- public/components/search-bar/index.tsx | 3 +- .../search-bar/query-language/wql.md | 171 +++----------- .../search-bar/query-language/wql.test.tsx | 52 +++++ .../search-bar/query-language/wql.tsx | 211 +++++++++++++++++- .../agent/components/agents-table.js | 11 +- 6 files changed, 300 insertions(+), 150 deletions(-) diff --git a/public/components/eui-suggest/suggest_input.js b/public/components/eui-suggest/suggest_input.js index f67149d489..7a4f5df6f2 100644 --- a/public/components/eui-suggest/suggest_input.js +++ b/public/components/eui-suggest/suggest_input.js @@ -53,7 +53,7 @@ export class EuiSuggestInput extends Component { onPopoverFocus, isPopoverOpen, onClosePopover, - disableFocusTrap, + disableFocusTrap = false, ...rest } = this.props; diff --git a/public/components/search-bar/index.tsx b/public/components/search-bar/index.tsx index 2ecf57f858..f4e7fc4038 100644 --- a/public/components/search-bar/index.tsx +++ b/public/components/search-bar/index.tsx @@ -97,6 +97,7 @@ export const SearchBar = ({ configuration: configuration?.(state.configuration) || configuration, })), + setQueryLanguageOutput: setQueryLanguageOutputRun, inputRef, queryLanguage: { configuration: queryLanguage.configuration, @@ -126,7 +127,7 @@ export const SearchBar = ({ property prevents an error. */ suggestions={[]} isPopoverOpen={ - queryLanguageOutputRun.searchBarProps.suggestions.length > 0 && + queryLanguageOutputRun?.searchBarProps?.suggestions?.length > 0 && isOpenSuggestionPopover } onClosePopover={() => setIsOpenSuggestionPopover(false)} diff --git a/public/components/search-bar/query-language/wql.md b/public/components/search-bar/query-language/wql.md index b99538b9c7..2f8c4bb9f6 100644 --- a/public/components/search-bar/query-language/wql.md +++ b/public/components/search-bar/query-language/wql.md @@ -1,6 +1,6 @@ # Query Language - WQL -WQL (Wazuh Query Language) is a query language based in the `q` query parameters of the Wazuh API +WQL (Wazuh Query Language) is a query language based in the `q` query parameter of the Wazuh API endpoints. Documentation: https://wazuh.com/./user-manual/api/queries.html @@ -13,10 +13,12 @@ The implementation is adapted to work with the search bar component defined It supports 2 modes: - `explicit`: define the field, operator and value -- `implicit`: use a term to search in the available fields +- `search term`: use a term to search in the available fields Theses modes can not be combined. +`explicit` mode is enabled when it finds a field and operator tokens. + ## Mode: explicit ### Schema @@ -63,14 +65,14 @@ field.custom Examples: ``` -value -"custom value" -"custom \" value" +value_without_whitespace +"value with whitespaces" +"value with whitespaces and escaped \"quotes\"" ``` ### Notes -- The entities can be separated by whitespaces. +- The tokens can be separated by whitespaces. ### Examples @@ -81,7 +83,7 @@ id=001 id = 001 ``` -- Complex query +- Complex query (logical operator) ``` status=active and os.platform~linux status = active and os.platform ~ linux @@ -92,17 +94,17 @@ status!=never_connected and ip~240 or os.platform~linux status != never_connected and ip ~ 240 or os.platform ~ linux ``` -- Complex query with group operator +- Complex query (logical operators and group operator) ``` (status!=never_connected and ip~240) or id=001 ( status != never_connected and ip ~ 240 ) or id = 001 ``` -## Mode: implicit +## Mode: search term Search the term in the available fields. -This mode is used when there is no a `field` and `operator` attending to the regular expression +This mode is used when there is no a `field` and `operator` according to the regular expression of the **explicit** mode. ### Examples: @@ -132,7 +134,7 @@ This a query that can't be added, edited or removed by the user. It is added to ### Search term mode -This mode enables to search in multiple fields. The fields to use must be defined. +This mode enables to search in multiple fields using a search term. The fields to use must be defined. Use an union expression of each field with the like as operation `~`. @@ -174,21 +176,10 @@ searchTermFields: ['id', 'ip'] ```ts // language options field(currentValue) { + // static or async fetching is allowed return [ - { label: 'configSum', description: 'Config sum' }, - { label: 'dateAdd', description: 'Date add' }, - { label: 'id', description: 'ID' }, - { label: 'ip', description: 'IP address' }, - { label: 'group', description: 'Group' }, - { label: 'group_config_status', description: 'Synced configuration status' }, - { label: 'lastKeepAline', description: 'Date add' }, - { label: 'manager', description: 'Manager' }, - { label: 'mergedSum', description: 'Merged sum' }, - { label: 'name', description: 'Agent name' }, - { label: 'node_name', description: 'Node name' }, - { label: 'os.platform', description: 'Operating system platform' }, - { label: 'status', description: 'Status' }, - { label: 'version', description: 'Version' }, + { label: 'field1', description: 'Description' }, + { label: 'field2', description: 'Description' } ]; } ``` @@ -197,111 +188,13 @@ searchTermFields: ['id', 'ip'] ```ts // language options value: async (currentValue, { previousField }) => { - switch (previousField) { - case 'configSum': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'dateAdd': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'id': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'ip': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'group': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'group_config_status': - return [AGENT_SYNCED_STATUS.SYNCED, AGENT_SYNCED_STATUS.NOT_SYNCED].map( - (status) => ({ - type: 'value', - label: status, - }), - ); - break; - case 'lastKeepAline': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'manager': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'mergedSum': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'name': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'node_name': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'os.platform': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'status': - return UI_ORDER_AGENT_STATUS.map( - (status) => ({ - type: 'value', - label: status, - }), - ); - break; - case 'version': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - default: - return []; - break; - } + // static or async fetching is allowed + // async fetching data + // const response = await fetchData(); + return [ + { label: 'value1' }, + { label: 'value2' } + ] } ``` @@ -315,19 +208,27 @@ graph TD; end tokenizer-->tokens; + tokens-->validate; tokens-->searchBarProps; subgraph searchBarProps; - searchBarProps_suggestions-->searchBarProps_suggestions_get_last_token_with_value[Get last token with value] - searchBarProps_suggestions_get_last_token_with_value[Get last token with value]-->searchBarProps_suggestions__result[Suggestions] + searchBarProps_suggestions[suggestions]-->searchBarProps_suggestions_input_isvalid{Input is valid} + searchBarProps_suggestions_input_isvalid{Is input valid?}-->searchBarProps_suggestions_input_isvalid_success[Yes] + searchBarProps_suggestions_input_isvalid{Is input valid?}-->searchBarProps_suggestions_input_isvalid_fail[No] + searchBarProps_suggestions_input_isvalid_success[Yes]--->searchBarProps_suggestions_get_last_token_with_value[Get last token with value]-->searchBarProps_suggestions__result[Suggestions] searchBarProps_suggestions__result[Suggestions]-->EuiSuggestItem + searchBarProps_suggestions_input_isvalid_fail[No]-->searchBarProps_suggestions_invalid[Invalid with error message] + searchBarProps_suggestions_invalid[Invalid with error message]-->EuiSuggestItem searchBarProps_prepend[prepend]-->searchBarProps_prepend_implicitQuery{implicitQuery} searchBarProps_prepend_implicitQuery{implicitQuery}-->searchBarProps_prepend_implicitQuery_yes[Yes]-->EuiButton searchBarProps_prepend_implicitQuery{implicitQuery}-->searchBarProps_prepend_implicitQuery_no[No]-->null searchBarProps_disableFocusTrap:true[disableFocusTrap = true] - searchBarProps_onItemClick[onItemClickSuggestion onclick handler]-->searchBarProps_onItemClick_suggestion_search[Search suggestion] - searchBarProps_onItemClick[onItemClickSuggestion onclick handler]-->searchBarProps_onItemClick_suggestion_edit_current_token[Edit current token]-->searchBarProps_onItemClick_build_input[Build input] - searchBarProps_onItemClick[onItemClickSuggestion onclick handler]-->searchBarProps_onItemClick_suggestion_add_new_token[Add new token]-->searchBarProps_onItemClick_build_input[Build input] + searchBarProps_onItemClick[onItemClickSuggestion]-->searchBarProps_onItemClick_suggestion_search[Search suggestion] + searchBarProps_onItemClick_suggestion_search[Search suggestion]-->searchBarProps_onItemClick_suggestion_search_run[Run search] + searchBarProps_onItemClick[onItemClickSuggestion]-->searchBarProps_onItemClick_suggestion_edit_current_token[Edit current token]-->searchBarProps_onItemClick_build_input[Build input] + searchBarProps_onItemClick[onItemClickSuggestion]-->searchBarProps_onItemClick_suggestion_add_new_token[Add new token]-->searchBarProps_onItemClick_build_input[Build input] + searchBarProps_onItemClick[onItemClickSuggestion]-->searchBarProps_onItemClick_suggestion_error[Error] + searchBarProps_isInvalid[isInvalid] end tokens-->output; diff --git a/public/components/search-bar/query-language/wql.test.tsx b/public/components/search-bar/query-language/wql.test.tsx index 752b9b8451..03095d54c3 100644 --- a/public/components/search-bar/query-language/wql.test.tsx +++ b/public/components/search-bar/query-language/wql.test.tsx @@ -355,4 +355,56 @@ describe('Query language - WQL', () => { - with != value - with ~ value */ + + // Validate the tokens + it.only.each` + WQL | validationError + ${''} | ${undefined} + ${'field1'} | ${undefined} + ${'field2'} | ${undefined} + ${'field1='} | ${['The value for field "field1" is missing.']} + ${'field2='} | ${['The value for field "field2" is missing.']} + ${'field='} | ${['"field" is not a valid field.']} + ${'custom='} | ${['"custom" is not a valid field.']} + ${'field1=value'} | ${undefined} + ${'field2=value'} | ${undefined} + ${'field=value'} | ${['"field" is not a valid field.']} + ${'custom=value'} | ${['"custom" is not a valid field.']} + ${'field1=value and'} | ${['There is no sentence after conjunction "and".']} + ${'field2=value and'} | ${['There is no sentence after conjunction "and".']} + ${'field=value and'} | ${['"field" is not a valid field.', 'There is no sentence after conjunction "and".']} + ${'custom=value and'} | ${['"custom" is not a valid field.', 'There is no sentence after conjunction "and".']} + ${'field1=value and '} | ${['There is no sentence after conjunction "and".']} + ${'field2=value and '} | ${['There is no sentence after conjunction "and".']} + ${'field=value and '} | ${['"field" is not a valid field.', 'There is no sentence after conjunction "and".']} + ${'custom=value and '} | ${['"custom" is not a valid field.', 'There is no sentence after conjunction "and".']} + ${'field1=value and field2'} | ${['The operator for field \"field2\" is missing.']} + ${'field2=value and field1'} | ${['The operator for field \"field1\" is missing.']} + ${'field1=value and field'} | ${['"field" is not a valid field.']} + ${'field2=value and field'} | ${['"field" is not a valid field.']} + ${'field=value and custom'} | ${['"field" is not a valid field.', '"custom" is not a valid field.']} + ${'('} | ${undefined} + ${'(field'} | ${undefined} + ${'(field='} | ${['"field" is not a valid field.']} + ${'(field=value'} | ${['"field" is not a valid field.']} + ${'(field=value or'} | ${['"field" is not a valid field.', 'There is no sentence after conjunction or.']} + ${'(field=value or '} | ${['"field" is not a valid field.', 'There is no sentence after conjunction or.']} + ${'(field=value or field2'} | ${['"field" is not a valid field.', 'The operator for field \"field2\" is missing.']} + ${'(field=value or field2>'} | ${['"field" is not a valid field.', 'The value for field "field2" is missing.']} + ${'(field=value or field2>value2'}| ${['"field" is not a valid field.']} + `('validate the tokens - WQL $WQL => $validationError', async ({WQL: currentInput, validationError}) => { + + const qlOutput = await WQL.run(currentInput, { + queryLanguage: { + parameters: { + implicitQuery: '', + suggestions: { + field: () => (['field1', 'field2'].map(label => ({label}))), + value: () => ([]) + } + } + } + }); + expect(qlOutput.output.error).toEqual(validationError); + }); }); diff --git a/public/components/search-bar/query-language/wql.tsx b/public/components/search-bar/query-language/wql.tsx index 84758b3ec3..2c59ad2f66 100644 --- a/public/components/search-bar/query-language/wql.tsx +++ b/public/components/search-bar/query-language/wql.tsx @@ -83,6 +83,8 @@ const suggestionMappingLanguageTokenType = { operator_group: { iconType: 'tokenDenseVector', color: 'tint3' }, // eslint-disable-next-line camelcase function_search: { iconType: 'search', color: 'tint5' }, + // eslint-disable-next-line camelcase + validation_error: { iconType: 'alert', color: 'tint2' } }; /** @@ -153,7 +155,6 @@ export function tokenizer(input: string): ITokens{ // Simple value // Quoted ", "value, "value", "escaped \"quote" // Escape quoted string with escaping quotes: https://stackoverflow.com/questions/249791/regex-for-quoted-string-with-escaping-quotes - // '(?(?:(?:[^"\\s]+|(?:"(?:[^"\\\\]|\\\\")*"))))?' + '(?(?:(?:[^"\\s]+|(?:"(?:[^"\\\\]|\\\\")*")|(?:"(?:[^"\\\\]|\\\\")*)|")))?' + // Whitespace '(?\\s+)?' + @@ -168,8 +169,8 @@ export function tokenizer(input: string): ITokens{ ); return [ - ...input.matchAll(re)] - .map( + ...input.matchAll(re) + ].map( ({groups}) => Object.entries(groups) .map(([key, value]) => ({ type: key.startsWith('operator_group') // Transform operator_group group match @@ -252,7 +253,32 @@ function getLastTokenDefinedByType( ({ type, value }) => type === tokenType && value, ); return tokenFound; -} +}; + +/** + * Get the token that is near to a token position of the token type. + * @param tokens + * @param tokenReferencePosition + * @param tokenType + * @param mode + * @returns + */ +function getTokenNearTo( + tokens: ITokens, + tokenType: ITokenType, + mode : 'previous' | 'next' = 'previous', + options : {tokenReferencePosition?: number, tokenFoundShouldHaveValue?: boolean} = {} +): IToken | undefined { + const shallowCopyTokens = Array.from([...tokens]); + const computedShallowCopyTokens = mode === 'previous' + ? shallowCopyTokens.slice(0, options?.tokenReferencePosition || tokens.length).reverse() + : shallowCopyTokens.slice(options?.tokenReferencePosition || 0); + return computedShallowCopyTokens + .find(({type, value}) => + type === tokenType + && (options?.tokenFoundShouldHaveValue ? value : true) + ); +}; /** * Get the suggestions from the tokens @@ -580,6 +606,128 @@ function getOutput(input: string, options: OptionsQL) { }; }; +/** + * Validate the tokens while the user is building the query + * @param tokens + * @param options + * @returns + */ +function validatePartial(tokens: ITokens, options: {field: string[]}): undefined | string{ + // Ensure is not in search term mode + if (!shouldUseSearchTerm(tokens)){ + return tokens.map((token: IToken, index) => { + if(token.value){ + if(token.type === 'field'){ + // Ensure there is a operator next to field to check if the fields is valid or not. + // This allows the user can type the field token and get the suggestions for the field. + const tokenOperatorNearToField = getTokenNearTo( + tokens, + 'operator_compare', + 'next', + { tokenReferencePosition: index, tokenFoundShouldHaveValue: true } + ); + return tokenOperatorNearToField + && !options.field.includes(token.value) + ? `"${token.value}" is not a valid field.` + : undefined; + }; + // Check if the value is allowed + if(token.type === 'value'){ + // Usage of API regular expression + const re = new RegExp( + // Value: A string. + '^(?(?:(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\/\'"=@%<>{}]*)\\))*' + + '(?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]+)' + + '(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]*)\\))*)+)$' + ); + + const match = token.value.match(re); + return match?.groups?.value === token.value + ? undefined + : `"${token.value}" is not a valid value222.`; + } + }; + }) + .filter(t => typeof t !== 'undefined') + .join('\n') || undefined; + } +}; + +/** + * Validate the tokens if they are a valid syntax + * @param tokens + * @param options + * @returns + */ +function validate(tokens: ITokens, options: {field: string[]}): undefined | string[]{ + if (!shouldUseSearchTerm(tokens)){ + const errors = tokens.map((token: IToken, index) => { + const errors = []; + if(token.value){ + if(token.type === 'field'){ + const tokenOperatorNearToField = getTokenNearTo( + tokens, + 'operator_compare', + 'next', + { tokenReferencePosition: index, tokenFoundShouldHaveValue: true } + ); + const tokenValueNearToField = getTokenNearTo( + tokens, + 'value', + 'next', + { tokenReferencePosition: index, tokenFoundShouldHaveValue: true } + ); + if(!options.field.includes(token.value)){ + errors.push(`"${token.value}" is not a valid field.`); + }else if(!tokenOperatorNearToField){ + errors.push(`The operator for field "${token.value}" is missing.`); + }else if(!tokenValueNearToField){ + errors.push(`The value for field "${token.value}" is missing.`); + } + }; + // Check if the value is allowed + if(token.type === 'value'){ + // Usage of API regular expression + const re = new RegExp( + // Value: A string. + '^(?(?:(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\/\'"=@%<>{}]*)\\))*' + + '(?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]+)' + + '(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]*)\\))*)+)$' + ); + + const match = token.value.match(re); + match?.groups?.value !== token.value && errors.push(`"${token.value}" is not a valid value.`); + }; + + // Check if the value is allowed + if(token.type === 'conjunction'){ + + const tokenWhitespaceNearToFieldNext = getTokenNearTo( + tokens, + 'whitespace', + 'next', + { tokenReferencePosition: index } + ); + const tokenFieldNearToFieldNext = getTokenNearTo( + tokens, + 'field', + 'next', + { tokenReferencePosition: index, tokenFoundShouldHaveValue: true } + ); + !tokenWhitespaceNearToFieldNext?.value?.length + && errors.push(`There is no whitespace after conjunction "${token.value}".`); + !tokenFieldNearToFieldNext?.value?.length + && errors.push(`There is no sentence after conjunction "${token.value}".`); + }; + }; + return errors.length ? errors : undefined; + }).filter(errors => errors) + .flat() + return errors.length ? errors : undefined; + }; + return undefined; +}; + export const WQL = { id: 'wql', label: 'WQL', @@ -602,22 +750,59 @@ export const WQL = { ) : ''; + // Validate the user input + const fieldsSuggestion: string[] = await params.queryLanguage.parameters.suggestions.field() + .map(({label}) => label); + + const validationPartial = validatePartial(tokens, { + field: fieldsSuggestion + }); + + const validationStrict = validate(tokens, { + field: fieldsSuggestion + }); + // Get the output of query language - const output = getOutput(input, params.queryLanguage.parameters); + const output = { + ...getOutput(input, params.queryLanguage.parameters), + error: validationStrict + }; + + const onSearch = () => { + if(output?.error){ + params.setQueryLanguageOutput((state) => ({ + ...state, + searchBarProps: { + ...state.searchBarProps, + suggestions: transformSuggestionsToEuiSuggestItem( + output.error.map(error => ({type: 'validation_error', label: 'Invalid', description: error})) + ) + } + })); + }else{ + params.onSearch(output); + }; + }; return { searchBarProps: { // Props that will be used by the EuiSuggest component // Suggestions suggestions: transformSuggestionsToEuiSuggestItem( - await getSuggestions(tokens, params.queryLanguage.parameters) + validationPartial + ? [{ type: 'validation_error', label: 'Invalid', description: validationPartial}] + : await getSuggestions(tokens, params.queryLanguage.parameters) ), // Handler to manage when clicking in a suggestion item onItemClick: item => { + // There is an error, clicking on the item does nothing + if (item.type.iconType === 'alert'){ + return; + }; // When the clicked item has the `search` iconType, run the `onSearch` function if (item.type.iconType === 'search') { // Execute the search action - params.onSearch(output); + onSearch(); } else { // When the clicked item has another iconType const lastToken: IToken | undefined = getLastTokenDefined(tokens); @@ -706,9 +891,17 @@ export const WQL = { // This causes when using the Search suggestion, the suggestion popover can be closed. // If this is disabled, then the suggestion popover is open after a short time for this // use case. - disableFocusTrap: true + disableFocusTrap: true, + // Show the input is invalid + isInvalid: Boolean(validationStrict), + // Define the handler when the a key is pressed while the input is focused + onKeyPress: (event) => { + if (event.key === 'Enter') { + onSearch(); + }; + } }, - output, + output }; }, transformUQLToQL: transformUQLToQL, diff --git a/public/controllers/agent/components/agents-table.js b/public/controllers/agent/components/agents-table.js index 84ac881f60..bee79d1cc0 100644 --- a/public/controllers/agent/components/agents-table.js +++ b/public/controllers/agent/components/agents-table.js @@ -652,8 +652,8 @@ export const AgentsTable = withErrorBoundary( { label: 'dateAdd', description: 'filter by date add' }, { label: 'id', description: 'filter by ID' }, { label: 'ip', description: 'filter by IP address' }, - { label: 'group', description: 'filter by Group' }, - { label: 'group_config_status', description: 'filter by Synced configuration status' }, + { label: 'group', description: 'filter by group' }, + { label: 'group_config_status', description: 'filter by synced configuration status' }, { label: 'lastKeepAlive', description: 'filter by date add' }, { label: 'manager', description: 'filter by manager' }, { label: 'mergedSum', description: 'filter by merged sum' }, @@ -708,7 +708,7 @@ export const AgentsTable = withErrorBoundary( }), ); break; - case 'lastKeepAline': + case 'lastKeepAlive': return await getAgentFilterValuesMapToSearchBarSuggestion( previousField, currentValue, @@ -773,7 +773,10 @@ export const AgentsTable = withErrorBoundary( }, ]} onChange={console.log} - onSearch={async ({language, unifiedQuery}) => { + onSearch={async ({language, unifiedQuery, error}) => { + if(error){ + return; + } try{ this.setState({isLoading: true}); const response = await this.props.wzReq('GET', '/agents', { params: { From cdbbb6d4a3206bc8832278bc3070a91051cfec2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Mon, 10 Apr 2023 17:28:00 +0200 Subject: [PATCH 19/37] feat(search-bar): rename previousField and previousOperatorCompare in WQL --- public/components/search-bar/README.md | 101 ++---------------- public/components/search-bar/index.test.tsx | 4 +- .../search-bar/query-language/wql.md | 2 +- .../search-bar/query-language/wql.test.tsx | 18 ++-- .../search-bar/query-language/wql.tsx | 26 ++--- .../agent/components/agents-table.js | 28 ++--- 6 files changed, 49 insertions(+), 130 deletions(-) diff --git a/public/components/search-bar/README.md b/public/components/search-bar/README.md index 58e557a26c..b017a5871d 100644 --- a/public/components/search-bar/README.md +++ b/public/components/search-bar/README.md @@ -54,108 +54,27 @@ Basic usage: { label: 'version', description: 'Version' }, ]; }, - value: async (currentValue, { previousField }) => { - switch (previousField) { + value: async (currentValue, { field }) => { + switch (field) { case 'configSum': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); + return [ + { label: 'configSum1' }, + { label: 'configSum2' }, + ]; break; case 'dateAdd': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'id': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'ip': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'group': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'group_config_status': - return [AGENT_SYNCED_STATUS.SYNCED, AGENT_SYNCED_STATUS.NOT_SYNCED].map( - (status) => ({ - type: 'value', - label: status, - }), - ); - break; - case 'lastKeepAline': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'manager': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'mergedSum': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'name': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'node_name': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; - case 'os.platform': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); + return [ + { label: 'dateAdd1' }, + { label: 'dateAdd2' }, + ]; break; case 'status': return UI_ORDER_AGENT_STATUS.map( (status) => ({ - type: 'value', label: status, }), ); break; - case 'version': - return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, - currentValue, - {q: 'id!=000'} - ); - break; default: return []; break; diff --git a/public/components/search-bar/index.test.tsx b/public/components/search-bar/index.test.tsx index 2f8f2366a4..31f18f6dda 100644 --- a/public/components/search-bar/index.test.tsx +++ b/public/components/search-bar/index.test.tsx @@ -14,7 +14,7 @@ describe('SearchBar component', () => { field(currentValue) { return []; }, - value(currentValue, { previousField }){ + value(currentValue, { field }){ return []; }, }, @@ -29,7 +29,7 @@ describe('SearchBar component', () => { field(currentValue) { return []; }, - value(currentValue, { previousField }){ + value(currentValue, { field }){ return []; }, }, diff --git a/public/components/search-bar/query-language/wql.md b/public/components/search-bar/query-language/wql.md index 2f8c4bb9f6..ce70e7e6c6 100644 --- a/public/components/search-bar/query-language/wql.md +++ b/public/components/search-bar/query-language/wql.md @@ -187,7 +187,7 @@ searchTermFields: ['id', 'ip'] - `value`: method that returns the suggestion for the values ```ts // language options - value: async (currentValue, { previousField }) => { + value: async (currentValue, { field }) => { // static or async fetching is allowed // async fetching data // const response = await fetchData(); diff --git a/public/components/search-bar/query-language/wql.test.tsx b/public/components/search-bar/query-language/wql.test.tsx index 03095d54c3..4c645a8939 100644 --- a/public/components/search-bar/query-language/wql.test.tsx +++ b/public/components/search-bar/query-language/wql.test.tsx @@ -18,7 +18,7 @@ describe('SearchBar component', () => { field(currentValue) { return []; }, - value(currentValue, { previousField }){ + value(currentValue, { field }){ return []; }, }, @@ -150,8 +150,8 @@ describe('Query language - WQL', () => { description, })); }, - value(currentValue = '', { previousField }) { - switch (previousField) { + value(currentValue = '', { field }) { + switch (field) { case 'field': return ['value', 'value2', 'value3', 'value4'] .filter(value => value.startsWith(currentValue)) @@ -370,10 +370,10 @@ describe('Query language - WQL', () => { ${'field2=value'} | ${undefined} ${'field=value'} | ${['"field" is not a valid field.']} ${'custom=value'} | ${['"custom" is not a valid field.']} - ${'field1=value and'} | ${['There is no sentence after conjunction "and".']} - ${'field2=value and'} | ${['There is no sentence after conjunction "and".']} - ${'field=value and'} | ${['"field" is not a valid field.', 'There is no sentence after conjunction "and".']} - ${'custom=value and'} | ${['"custom" is not a valid field.', 'There is no sentence after conjunction "and".']} + ${'field1=value and'} | ${['There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} + ${'field2=value and'} | ${['There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} + ${'field=value and'} | ${['"field" is not a valid field.', 'There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} + ${'custom=value and'} | ${['"custom" is not a valid field.', 'There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} ${'field1=value and '} | ${['There is no sentence after conjunction "and".']} ${'field2=value and '} | ${['There is no sentence after conjunction "and".']} ${'field=value and '} | ${['"field" is not a valid field.', 'There is no sentence after conjunction "and".']} @@ -387,8 +387,8 @@ describe('Query language - WQL', () => { ${'(field'} | ${undefined} ${'(field='} | ${['"field" is not a valid field.']} ${'(field=value'} | ${['"field" is not a valid field.']} - ${'(field=value or'} | ${['"field" is not a valid field.', 'There is no sentence after conjunction or.']} - ${'(field=value or '} | ${['"field" is not a valid field.', 'There is no sentence after conjunction or.']} + ${'(field=value or'} | ${['"field" is not a valid field.', 'There is no whitespace after conjunction "or".', 'There is no sentence after conjunction "or".']} + ${'(field=value or '} | ${['"field" is not a valid field.', 'There is no sentence after conjunction "or".']} ${'(field=value or field2'} | ${['"field" is not a valid field.', 'The operator for field \"field2\" is missing.']} ${'(field=value or field2>'} | ${['"field" is not a valid field.', 'The value for field "field2" is missing.']} ${'(field=value or field2>value2'}| ${['"field" is not a valid field.']} diff --git a/public/components/search-bar/query-language/wql.tsx b/public/components/search-bar/query-language/wql.tsx index 2c59ad2f66..984ece8c01 100644 --- a/public/components/search-bar/query-language/wql.tsx +++ b/public/components/search-bar/query-language/wql.tsx @@ -199,9 +199,9 @@ type SuggestItem = QLOptionSuggestionEntityItem & { type QLOptionSuggestionHandler = ( currentValue: string | undefined, { - previousField, - previousOperatorCompare, - }: { previousField: string; previousOperatorCompare: string }, + field, + operatorCompare, + }: { field: string; operatorCompare: string }, ) => Promise; type OptionsQLImplicitQuery = { @@ -334,15 +334,15 @@ export async function getSuggestions(tokens: ITokens, options: OptionsQL): Promi ]; break; case 'operator_compare':{ - const previousField = getLastTokenDefinedByType(tokens, 'field')?.value; - const previousOperatorCompare = getLastTokenDefinedByType( + const field = getLastTokenDefinedByType(tokens, 'field')?.value; + const operatorCompare = getLastTokenDefinedByType( tokens, 'operator_compare', )?.value; // If there is no a previous field, then no return suggestions because it would be an syntax // error - if(!previousField){ + if(!field){ return []; }; @@ -363,8 +363,8 @@ export async function getSuggestions(tokens: ITokens, options: OptionsQL): Promi ) ? [ ...(await options.suggestions.value(undefined, { - previousField, - previousOperatorCompare, + field, + operatorCompare, })).map(mapSuggestionCreatorValue), ] : []), @@ -372,15 +372,15 @@ export async function getSuggestions(tokens: ITokens, options: OptionsQL): Promi break; } case 'value':{ - const previousField = getLastTokenDefinedByType(tokens, 'field')?.value; - const previousOperatorCompare = getLastTokenDefinedByType( + const field = getLastTokenDefinedByType(tokens, 'field')?.value; + const operatorCompare = getLastTokenDefinedByType( tokens, 'operator_compare', )?.value; /* If there is no a previous field or operator_compare, then no return suggestions because it would be an syntax error */ - if(!previousField || !previousOperatorCompare){ + if(!field || !operatorCompare){ return []; }; @@ -395,8 +395,8 @@ export async function getSuggestions(tokens: ITokens, options: OptionsQL): Promi ] : []), ...(await options.suggestions.value(lastToken.value, { - previousField, - previousOperatorCompare, + field, + operatorCompare, })).map(mapSuggestionCreatorValue), ...Object.entries(language.tokens.conjunction.literal).map( ([ conjunction, description]) => ({ diff --git a/public/controllers/agent/components/agents-table.js b/public/controllers/agent/components/agents-table.js index 374d357bef..d71608441a 100644 --- a/public/controllers/agent/components/agents-table.js +++ b/public/controllers/agent/components/agents-table.js @@ -655,39 +655,39 @@ export const AgentsTable = withErrorBoundary( { label: 'version', description: 'filter by version' }, ]; }, - value: async (currentValue, { previousField }) => { - switch (previousField) { + value: async (currentValue, { field }) => { + switch (field) { case 'configSum': return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, + field, currentValue, {q: 'id!=000'} ); break; case 'dateAdd': return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, + field, currentValue, {q: 'id!=000'} ); break; case 'id': return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, + field, currentValue, {q: 'id!=000'} ); break; case 'ip': return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, + field, currentValue, {q: 'id!=000'} ); break; case 'group': return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, + field, currentValue, {q: 'id!=000'} ); @@ -701,42 +701,42 @@ export const AgentsTable = withErrorBoundary( break; case 'lastKeepAlive': return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, + field, currentValue, {q: 'id!=000'} ); break; case 'manager': return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, + field, currentValue, {q: 'id!=000'} ); break; case 'mergedSum': return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, + field, currentValue, {q: 'id!=000'} ); break; case 'name': return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, + field, currentValue, {q: 'id!=000'} ); break; case 'node_name': return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, + field, currentValue, {q: 'id!=000'} ); break; case 'os.platform': return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, + field, currentValue, {q: 'id!=000'} ); @@ -750,7 +750,7 @@ export const AgentsTable = withErrorBoundary( break; case 'version': return await getAgentFilterValuesMapToSearchBarSuggestion( - previousField, + field, currentValue, {q: 'id!=000'} ); From c300fa36e15ec90ae992f584f515eb5e2edf1c42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Tue, 11 Apr 2023 08:44:56 +0200 Subject: [PATCH 20/37] fix(tests): update snapshot --- .../tables/__snapshots__/table-with-search-bar.test.tsx.snap | 1 + 1 file changed, 1 insertion(+) diff --git a/public/components/common/tables/__snapshots__/table-with-search-bar.test.tsx.snap b/public/components/common/tables/__snapshots__/table-with-search-bar.test.tsx.snap index 43dbaa0096..0c57fd5b26 100644 --- a/public/components/common/tables/__snapshots__/table-with-search-bar.test.tsx.snap +++ b/public/components/common/tables/__snapshots__/table-with-search-bar.test.tsx.snap @@ -108,6 +108,7 @@ exports[`Table With Search Bar component renders correctly to match the snapshot anchorPosition="downLeft" attachToAnchor={true} closePopover={[Function]} + disableFocusTrap={false} display="block" fullWidth={true} id="popover" From ef85c548986f47c2e8defae81b7f52833a24b157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Tue, 11 Apr 2023 10:12:16 +0200 Subject: [PATCH 21/37] fix(search-bar): fix documentation link for WQL --- public/components/search-bar/query-language/wql.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/components/search-bar/query-language/wql.tsx b/public/components/search-bar/query-language/wql.tsx index 984ece8c01..b6a9bbe14e 100644 --- a/public/components/search-bar/query-language/wql.tsx +++ b/public/components/search-bar/query-language/wql.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { EuiButtonEmpty, EuiPopover, EuiText, EuiCode } from '@elastic/eui'; import { tokenizer as tokenizerUQL } from './aql'; -import { pluginPlatform } from '../../../../package.json'; +import { PLUGIN_VERSION_SHORT } from '../../../../common/constants'; /* UI Query language https://documentation.wazuh.com/current/user-manual/api/queries.html @@ -732,7 +732,7 @@ export const WQL = { id: 'wql', label: 'WQL', description: 'WQL (Wazuh Query Language) offers a human query syntax based on the Wazuh API query language.', - documentationLink: `https://github.com/wazuh/wazuh-kibana-app/blob/v${pluginPlatform.version.split('.').splice(0,2).join('.')}/public/components/search-bar/query-language/wql.md`, + documentationLink: `https://github.com/wazuh/wazuh-kibana-app/blob/v${PLUGIN_VERSION_SHORT}/public/components/search-bar/query-language/wql.md`, getConfiguration() { return { isOpenPopoverImplicitFilter: false, From 17053c037ce24efc88144dbf788c2421fae0b73d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Tue, 11 Apr 2023 10:57:31 +0200 Subject: [PATCH 22/37] fix(search-bar): remove example usage of SearchBar component in Agents --- .../agent/components/agents-table.js | 196 +----------------- 1 file changed, 2 insertions(+), 194 deletions(-) diff --git a/public/controllers/agent/components/agents-table.js b/public/controllers/agent/components/agents-table.js index d71608441a..432269d1d3 100644 --- a/public/controllers/agent/components/agents-table.js +++ b/public/controllers/agent/components/agents-table.js @@ -41,7 +41,6 @@ import { UI_ERROR_SEVERITIES } from '../../../react-services/error-orchestrator/ import { getErrorOrchestrator } from '../../../react-services/common-services'; import { AgentStatus } from '../../../components/agents/agent_status'; import { AgentSynced } from '../../../components/agents/agent-synced'; -import { SearchBar } from '../../../components/search-bar'; import { compressIPv6 } from '../../../services/ipv6-services'; export const AgentsTable = withErrorBoundary( @@ -63,8 +62,7 @@ export const AgentsTable = withErrorBoundary( isFilterColumnOpen: false, filters: sessionStorage.getItem('agents_preview_selected_options') ? JSON.parse(sessionStorage.getItem('agents_preview_selected_options')) - : [], - query: '' + : [] }; this.suggestions = [ { @@ -212,7 +210,7 @@ export const AgentsTable = withErrorBoundary( this.props.filters && this.props.filters.length ) { - this.setState({ filters: this.props.filters, pageIndex: 0, query: this.props.filters.find(({field}) => field === 'q')?.value || '' }); + this.setState({ filters: this.props.filters, pageIndex: 0 }); this.props.removeFilters(); } } @@ -609,188 +607,6 @@ export const AgentsTable = withErrorBoundary( } placeholder='Filter or search agent' /> - {/** Example implementation */} - { - switch (field) { - case 'configSum': - return await getAgentFilterValuesMapToSearchBarSuggestion( - field, - currentValue, - {q: 'id!=000'} - ); - break; - case 'dateAdd': - return await getAgentFilterValuesMapToSearchBarSuggestion( - field, - currentValue, - {q: 'id!=000'} - ); - break; - case 'id': - return await getAgentFilterValuesMapToSearchBarSuggestion( - field, - currentValue, - {q: 'id!=000'} - ); - break; - case 'ip': - return await getAgentFilterValuesMapToSearchBarSuggestion( - field, - currentValue, - {q: 'id!=000'} - ); - break; - case 'group': - return await getAgentFilterValuesMapToSearchBarSuggestion( - field, - currentValue, - {q: 'id!=000'} - ); - break; - case 'group_config_status': - return [AGENT_SYNCED_STATUS.SYNCED, AGENT_SYNCED_STATUS.NOT_SYNCED].map( - (status) => ({ - label: status, - }), - ); - break; - case 'lastKeepAlive': - return await getAgentFilterValuesMapToSearchBarSuggestion( - field, - currentValue, - {q: 'id!=000'} - ); - break; - case 'manager': - return await getAgentFilterValuesMapToSearchBarSuggestion( - field, - currentValue, - {q: 'id!=000'} - ); - break; - case 'mergedSum': - return await getAgentFilterValuesMapToSearchBarSuggestion( - field, - currentValue, - {q: 'id!=000'} - ); - break; - case 'name': - return await getAgentFilterValuesMapToSearchBarSuggestion( - field, - currentValue, - {q: 'id!=000'} - ); - break; - case 'node_name': - return await getAgentFilterValuesMapToSearchBarSuggestion( - field, - currentValue, - {q: 'id!=000'} - ); - break; - case 'os.platform': - return await getAgentFilterValuesMapToSearchBarSuggestion( - field, - currentValue, - {q: 'id!=000'} - ); - break; - case 'status': - return UI_ORDER_AGENT_STATUS.map( - (status) => ({ - label: status, - }), - ); - break; - case 'version': - return await getAgentFilterValuesMapToSearchBarSuggestion( - field, - currentValue, - {q: 'id!=000'} - ); - break; - default: - return []; - break; - } - }, - }, - }, - ]} - onChange={console.log} - onSearch={async ({language, unifiedQuery, error}) => { - if(error){ - return; - } - try{ - this.setState({isLoading: true}); - const response = await this.props.wzReq('GET', '/agents', { params: { - limit: this.state.pageSize, - offset: 0, - q: unifiedQuery, - sort: this.buildSortFilter() - }}); - - const formatedAgents = response?.data?.data?.affected_items?.map( - this.formatAgent.bind(this) - ); - - this._isMount && this.setState({ - agents: formatedAgents, - totalItems: response?.data?.data?.total_affected_items, - isLoading: false, - }); - }catch(error){ - this.setState({isLoading: false}); - }; - }} - /> { - try{ - return (await getAgentFilterValues(key, value, params)).map(label => ({label})); - }catch(error){ - return []; - }; -}; From cfbf19b04b049ba57d832f2dad90e6c730ba6637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Wed, 12 Apr 2023 09:10:28 +0200 Subject: [PATCH 23/37] fix(search-bar): fix an error using the value suggestions in WQL Fix an error when the last token in the input was a value and used a value suggestion whose label contains whitespaces, the value was not wrapped with quotes. --- public/components/search-bar/query-language/wql.test.tsx | 4 +++- public/components/search-bar/query-language/wql.tsx | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/public/components/search-bar/query-language/wql.test.tsx b/public/components/search-bar/query-language/wql.test.tsx index 4c645a8939..f40d81bf22 100644 --- a/public/components/search-bar/query-language/wql.test.tsx +++ b/public/components/search-bar/query-language/wql.test.tsx @@ -268,6 +268,8 @@ describe('Query language - WQL', () => { ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with < value'}} | ${'field="with < value"'} ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with ~ value'}} | ${'field="with ~ value"' /** ~ character is not supported as value in the q query parameter */} ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: '"value'}} | ${'field="\\"value"'} + ${'field="with spaces"'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value'}} | ${'field=value'} + ${'field="with spaces"'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'other spaces'}} | ${'field="other spaces"'} ${''} | ${{type: { iconType: 'tokenDenseVector', color: 'tint3' }, label: '('}} | ${'('} ${'('} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field'}} | ${'(field'} ${'(field'} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'(field2'} @@ -357,7 +359,7 @@ describe('Query language - WQL', () => { */ // Validate the tokens - it.only.each` + it.each` WQL | validationError ${''} | ${undefined} ${'field1'} | ${undefined} diff --git a/public/components/search-bar/query-language/wql.tsx b/public/components/search-bar/query-language/wql.tsx index b6a9bbe14e..c5ea2d0839 100644 --- a/public/components/search-bar/query-language/wql.tsx +++ b/public/components/search-bar/query-language/wql.tsx @@ -811,8 +811,11 @@ export const WQL = { lastToken && suggestionMappingLanguageTokenType[lastToken.type].iconType === item.type.iconType ) { - // replace the value of last token - lastToken.value = item.label; + // replace the value of last token with the current one. + // if the current token is a value, then transform it + lastToken.value = item.type.iconType === suggestionMappingLanguageTokenType.value.iconType + ? transformQLValue(item.label) + : item.label; } else { // add a whitespace for conjunction !(/\s$/.test(input)) From 57d11d64430c4ef7913950ded87aa001b2cd026e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Tue, 25 Apr 2023 11:02:54 +0200 Subject: [PATCH 24/37] feat(search-bar): add search function suggestion when the input is empty --- public/components/search-bar/query-language/wql.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/public/components/search-bar/query-language/wql.tsx b/public/components/search-bar/query-language/wql.tsx index c5ea2d0839..e9b9c2ba16 100644 --- a/public/components/search-bar/query-language/wql.tsx +++ b/public/components/search-bar/query-language/wql.tsx @@ -190,7 +190,7 @@ type QLOptionSuggestionEntityItem = { type QLOptionSuggestionEntityItemTyped = QLOptionSuggestionEntityItem - & { type: 'operator_group'|'field'|'operator_compare'|'value'|'conjunction' }; + & { type: 'operator_group'|'field'|'operator_compare'|'value'|'conjunction'|'function_search' }; type SuggestItem = QLOptionSuggestionEntityItem & { type: { iconType: string, color: string } @@ -298,6 +298,12 @@ export async function getSuggestions(tokens: ITokens, options: OptionsQL): Promi // If it can't get a token with value, then returns fields and open operator group if(!lastToken?.type){ return [ + // Search function + { + type: 'function_search', + label: 'Search', + description: 'run the search query', + }, // fields ...(await options.suggestions.field()).map(mapSuggestionCreatorField), { From ce4bc92af7e44cf0e73401441310980b9f353cc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Tue, 25 Apr 2023 11:04:08 +0200 Subject: [PATCH 25/37] fix(search-bar): ensure the query language output changed to trigger the onChange handler --- public/components/search-bar/index.tsx | 50 +++++++++++++++----------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/public/components/search-bar/index.tsx b/public/components/search-bar/index.tsx index f4e7fc4038..a2b771d89e 100644 --- a/public/components/search-bar/index.tsx +++ b/public/components/search-bar/index.tsx @@ -11,6 +11,7 @@ import { } from '@elastic/eui'; import { EuiSuggest } from '../eui-suggest'; import { searchBarQueryLanguages } from './query-language'; +import _ from 'lodash'; type SearchBarProps = { defaultMode?: string; @@ -48,6 +49,8 @@ export const SearchBar = ({ searchBarProps: { suggestions: [] }, output: undefined, }); + // Cache the previous output + const queryLanguageOutputRunPreviousOutput = useRef(queryLanguageOutputRun.output); // Controls when the suggestion popover is open/close const [isOpenSuggestionPopover, setIsOpenSuggestionPopover] = useState(false); @@ -85,31 +88,36 @@ export const SearchBar = ({ useEffect(() => { (async () => { // Set the query language output - setQueryLanguageOutputRun( - await searchBarQueryLanguages[queryLanguage.id].run(input, { - onSearch: _onSearch, - setInput, - closeSuggestionPopover: () => setIsOpenSuggestionPopover(false), - openSuggestionPopover: () => setIsOpenSuggestionPopover(true), - setQueryLanguageConfiguration: (configuration: any) => - setQueryLanguage(state => ({ - ...state, - configuration: - configuration?.(state.configuration) || configuration, - })), - setQueryLanguageOutput: setQueryLanguageOutputRun, - inputRef, - queryLanguage: { - configuration: queryLanguage.configuration, - parameters: modes.find(({ id }) => id === queryLanguage.id), - }, - }), - ); + const queryLanguageOutput = await searchBarQueryLanguages[queryLanguage.id].run(input, { + onSearch: _onSearch, + setInput, + closeSuggestionPopover: () => setIsOpenSuggestionPopover(false), + openSuggestionPopover: () => setIsOpenSuggestionPopover(true), + setQueryLanguageConfiguration: (configuration: any) => + setQueryLanguage(state => ({ + ...state, + configuration: + configuration?.(state.configuration) || configuration, + })), + setQueryLanguageOutput: setQueryLanguageOutputRun, + inputRef, + queryLanguage: { + configuration: queryLanguage.configuration, + parameters: modes.find(({ id }) => id === queryLanguage.id), + }, + }); + queryLanguageOutputRunPreviousOutput.current = { + ...queryLanguageOutputRun.output + }; + setQueryLanguageOutputRun(queryLanguageOutput); })(); }, [input, queryLanguage]); useEffect(() => { - onChange && onChange(queryLanguageOutputRun.output); + onChange + // Ensure the previous output is different to the new one + && !_.isEqual(queryLanguageOutputRun.output, queryLanguageOutputRunPreviousOutput.current) + && onChange(queryLanguageOutputRun.output); }, [queryLanguageOutputRun.output]); const onQueryLanguagePopoverSwitch = () => From b547d985f239dbb26bc39d712e50c8848276c428 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Thu, 27 Apr 2023 08:53:38 +0200 Subject: [PATCH 26/37] feat(search-bar): allow the API query output can be redone when the search term fields changed - Search bar: - Add a dependency to run the query language output - Adapt search bar documentation to the changes - WQL - Create a new parameter called `options` - Moved the `implicitFilter` and `searchTerm` settings to `options` - Update tests - Update documentation --- public/components/search-bar/README.md | 23 +++++++------ public/components/search-bar/index.tsx | 6 ++-- .../search-bar/query-language/wql.md | 29 ++++++++--------- .../search-bar/query-language/wql.test.tsx | 14 ++++---- .../search-bar/query-language/wql.tsx | 32 +++++++++++-------- 5 files changed, 57 insertions(+), 47 deletions(-) diff --git a/public/components/search-bar/README.md b/public/components/search-bar/README.md index b017a5871d..b8e95cef2a 100644 --- a/public/components/search-bar/README.md +++ b/public/components/search-bar/README.md @@ -28,12 +28,15 @@ Basic usage: { id: 'wql', // specific query language parameters - // implicit query. Optional - // Set a implicit query that can't be changed by the user. - // Use the UQL (Unified Query Language) syntax. - implicitQuery: { - query: 'id!=000', - conjunction: ';' + options: { + // implicit query. Optional + // Set a implicit query that can't be changed by the user. + // Use the UQL (Unified Query Language) syntax. + implicitQuery: { + query: 'id!=000', + conjunction: ';' + }, + searchTermFields: ['id', 'ip'] }, suggestions: { field(currentValue) { @@ -90,9 +93,9 @@ Basic usage: // Used to define the internal input. Optional. // This could be used to change the input text from the external components. // Use the UQL (Unified Query Language) syntax. - input="" + input='' // Define the default mode. Optional. If not defined, it will use the first one mode. - defaultMode="" + defaultMode='' > ``` @@ -119,7 +122,7 @@ type SearchBarQueryLanguage = { searchBarProps: any, output: { language: string, - unifiedQuery: string, + apiQuery: string, query: string } }>; @@ -140,7 +143,7 @@ where: customization the properties that will used by the base search bar component and the output used when searching - `output`: - `language`: query language ID - - `unifiedQuery`: query in unified query syntax + - `apiQuery`: API query. - `query`: current query in the specified language - `transformUQLToQL`: method that transforms the UQL (Unified Query Language) to the specific query language. This is used when receives a external input in the Unified Query Language, the returned diff --git a/public/components/search-bar/index.tsx b/public/components/search-bar/index.tsx index a2b771d89e..eb2be4bbd1 100644 --- a/public/components/search-bar/index.tsx +++ b/public/components/search-bar/index.tsx @@ -75,6 +75,8 @@ export const SearchBar = ({ } }; + const selectedQueryLanguageParameters = modes.find(({ id }) => id === queryLanguage.id); + useEffect(() => { // React to external changes and set the internal input text. Use the `transformUQLToQL` of // the query language in use @@ -103,7 +105,7 @@ export const SearchBar = ({ inputRef, queryLanguage: { configuration: queryLanguage.configuration, - parameters: modes.find(({ id }) => id === queryLanguage.id), + parameters: selectedQueryLanguageParameters, }, }); queryLanguageOutputRunPreviousOutput.current = { @@ -111,7 +113,7 @@ export const SearchBar = ({ }; setQueryLanguageOutputRun(queryLanguageOutput); })(); - }, [input, queryLanguage]); + }, [input, queryLanguage, selectedQueryLanguageParameters?.options]); useEffect(() => { onChange diff --git a/public/components/search-bar/query-language/wql.md b/public/components/search-bar/query-language/wql.md index ce70e7e6c6..ef44107efb 100644 --- a/public/components/search-bar/query-language/wql.md +++ b/public/components/search-bar/query-language/wql.md @@ -145,30 +145,29 @@ field1~user_input,field2~user_input,field3~user_input ## Options -- `implicitQuery`: add an implicit query that is added to the user input. Optional. +- `options`: options + + - `implicitQuery`: add an implicit query that is added to the user input. Optional. This can't be changed by the user. If this is defined, will be displayed as a prepend of the search bar. - - `query`: query string in UQL (Unified Query Language) + - `query`: query string in UQL (Unified Query Language) Use UQL (Unified Query Language). - - `conjunction`: query string of the conjunction in UQL (Unified Query Language) + - `conjunction`: query string of the conjunction in UQL (Unified Query Language) + - `searchTermFields`: define the fields used to build the query for the search term mode ```ts // language options -// ID is not equal to 000 and . This is defined in UQL that is transformed internally to -// the specific query language. -implicitQuery: { - query: 'id!=000', - conjunction: ';' +options: { + // ID is not equal to 000 and . This is defined in UQL that is transformed internally to + // the specific query language. + implicitQuery: { + query: 'id!=000', + conjunction: ';' + } + searchTermFields: ['id', 'ip'] } ``` -- `searchTermFields`: define the fields used to build the query for the search term mode - -```ts -// language options -searchTermFields: ['id', 'ip'] -``` - - `suggestions`: define the suggestion handlers. This is required. - `field`: method that returns the suggestions for the fields diff --git a/public/components/search-bar/query-language/wql.test.tsx b/public/components/search-bar/query-language/wql.test.tsx index f40d81bf22..781c24c335 100644 --- a/public/components/search-bar/query-language/wql.test.tsx +++ b/public/components/search-bar/query-language/wql.test.tsx @@ -10,9 +10,11 @@ describe('SearchBar component', () => { modes: [ { id: WQL.id, - implicitQuery: { - query: 'id!=000', - conjunction: ';' + options: { + implicitQuery: { + query: 'id!=000', + conjunction: ';' + }, }, suggestions: { field(currentValue) { @@ -127,7 +129,7 @@ describe('Query language - WQL', () => { // Get suggestions it.each` input | suggestions - ${''} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }, { description: 'open group', label: '(', type: 'operator_group' }]} + ${''} | ${[{ description: 'run the search query', label: 'Search', type: 'function_search' }, { description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }, { description: 'open group', label: '(', type: 'operator_group' }]} ${'w'} | ${[]} ${'f'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }]} ${'field'} | ${[{ description: 'Field2', label: 'field2', type: 'field' }, { description: 'equality', label: '=', type: 'operator_compare' }, { description: 'not equality', label: '!=', type: 'operator_compare' }, { description: 'bigger', label: '>', type: 'operator_compare' }, { description: 'smaller', label: '<', type: 'operator_compare' }, { description: 'like as', label: '~', type: 'operator_compare' }]} @@ -295,7 +297,7 @@ describe('Query language - WQL', () => { setInput: (value: string): void => { input = value; }, queryLanguage: { parameters: { - implicitQuery: '', + options: {}, suggestions: { field: () => ([]), value: () => ([]) @@ -399,7 +401,7 @@ describe('Query language - WQL', () => { const qlOutput = await WQL.run(currentInput, { queryLanguage: { parameters: { - implicitQuery: '', + options: {}, suggestions: { field: () => (['field1', 'field2'].map(label => ({label}))), value: () => ([]) diff --git a/public/components/search-bar/query-language/wql.tsx b/public/components/search-bar/query-language/wql.tsx index e9b9c2ba16..e63db6fa99 100644 --- a/public/components/search-bar/query-language/wql.tsx +++ b/public/components/search-bar/query-language/wql.tsx @@ -209,8 +209,10 @@ type OptionsQLImplicitQuery = { conjunction: string } type OptionsQL = { - implicitQuery?: OptionsQLImplicitQuery - searchTermFields?: string[] + options?: { + implicitQuery?: OptionsQLImplicitQuery + searchTermFields?: string[] + } suggestions: { field: QLOptionSuggestionHandler; value: QLOptionSuggestionHandler; @@ -579,13 +581,13 @@ export function transformSpecificQLToUnifiedQL(input: string, searchTermFields: */ function getOutput(input: string, options: OptionsQL) { // Implicit query - const implicitQueryAsUQL = options?.implicitQuery?.query ?? ''; + const implicitQueryAsUQL = options?.options?.implicitQuery?.query ?? ''; const implicitQueryAsQL = transformUQLToQL( implicitQueryAsUQL ); // Implicit query conjunction - const implicitQueryConjunctionAsUQL = options?.implicitQuery?.conjunction ?? ''; + const implicitQueryConjunctionAsUQL = options?.options?.implicitQuery?.conjunction ?? ''; const implicitQueryConjunctionAsQL = transformUQLToQL( implicitQueryConjunctionAsUQL ); @@ -594,16 +596,18 @@ function getOutput(input: string, options: OptionsQL) { const inputQueryAsQL = input; const inputQueryAsUQL = transformSpecificQLToUnifiedQL( inputQueryAsQL, - options?.searchTermFields ?? [] + options?.options?.searchTermFields ?? [] ); return { language: WQL.id, - unifiedQuery: [ - implicitQueryAsUQL, - implicitQueryAsUQL && inputQueryAsUQL ? implicitQueryConjunctionAsUQL : '', - implicitQueryAsUQL && inputQueryAsUQL ? `(${inputQueryAsUQL})`: inputQueryAsUQL - ].join(''), + apiQuery: { + q: [ + implicitQueryAsUQL, + implicitQueryAsUQL && inputQueryAsUQL ? implicitQueryConjunctionAsUQL : '', + implicitQueryAsUQL && inputQueryAsUQL ? `(${inputQueryAsUQL})`: inputQueryAsUQL + ].join(''), + }, query: [ implicitQueryAsQL, implicitQueryAsQL && inputQueryAsQL ? implicitQueryConjunctionAsQL : '', @@ -749,17 +753,17 @@ export const WQL = { const tokens: ITokens = tokenizer(input); // Get the implicit query as query language syntax - const implicitQueryAsQL = params.queryLanguage.parameters.implicitQuery + const implicitQueryAsQL = params.queryLanguage.parameters?.options?.implicitQuery ? transformUQLToQL( - params.queryLanguage.parameters.implicitQuery.query - + params.queryLanguage.parameters.implicitQuery.conjunction + params.queryLanguage.parameters.options.implicitQuery.query + + params.queryLanguage.parameters.options.implicitQuery.conjunction ) : ''; - // Validate the user input const fieldsSuggestion: string[] = await params.queryLanguage.parameters.suggestions.field() .map(({label}) => label); + // Validate the user input const validationPartial = validatePartial(tokens, { field: fieldsSuggestion }); From a3c3681dae1cabd3bf23e188a19f266f75eb5e46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Thu, 27 Apr 2023 16:44:09 +0200 Subject: [PATCH 27/37] feat(search-bar): enhance the validation of value token in WQL --- .../search-bar/query-language/wql.test.tsx | 5 ++ .../search-bar/query-language/wql.tsx | 71 +++++++++++++------ 2 files changed, 54 insertions(+), 22 deletions(-) diff --git a/public/components/search-bar/query-language/wql.test.tsx b/public/components/search-bar/query-language/wql.test.tsx index 781c24c335..3ec1f1d61f 100644 --- a/public/components/search-bar/query-language/wql.test.tsx +++ b/public/components/search-bar/query-language/wql.test.tsx @@ -374,6 +374,11 @@ describe('Query language - WQL', () => { ${'field2=value'} | ${undefined} ${'field=value'} | ${['"field" is not a valid field.']} ${'custom=value'} | ${['"custom" is not a valid field.']} + ${'field1=value!test'} | ${['"value!test" is not a valid value. Invalid characters found: !']} + ${'field1=value&test'} | ${['"value&test" is not a valid value. Invalid characters found: &']} + ${'field1=value!value&test'} | ${['"value!value&test" is not a valid value. Invalid characters found: !, &']} + ${'field1=value!value!test'} | ${['"value!value!test" is not a valid value. Invalid characters found: !']} + ${'field1=value!value!t$&st'} | ${['"value!value!t$&st" is not a valid value. Invalid characters found: !, $, &']} ${'field1=value and'} | ${['There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} ${'field2=value and'} | ${['There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} ${'field=value and'} | ${['"field" is not a valid field.', 'There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} diff --git a/public/components/search-bar/query-language/wql.tsx b/public/components/search-bar/query-language/wql.tsx index e63db6fa99..6935f50102 100644 --- a/public/components/search-bar/query-language/wql.tsx +++ b/public/components/search-bar/query-language/wql.tsx @@ -616,6 +616,51 @@ function getOutput(input: string, options: OptionsQL) { }; }; +/** + * Validate the token value + * @param token + * @returns + */ +function validateTokenValue(token: IToken): string | undefined { + // Usage of API regular expression + const re = new RegExp( + // Value: A string. + '^(?(?:(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\/\'"=@%<>{}]*)\\))*' + + '(?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]+)' + + '(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]*)\\))*)+)$' + ); + + /* WARN: the validation for the value token is complex, this supports some characters in + certain circumstances. + + Ideally a character validation helps to the user to identify the problem in the query, but + as the original regular expression is so complex, the logic to get this can be + complicated. + + The original regular expression has a common schema of allowed characters, these and other + characters of the original regular expression can be used to check each character. This + approach can identify some invalid characters despite this is not the ideal way. + + The ideal solution will be check each subset of the complex regex against the allowed + characters. + */ + + const invalidCharacters: string[] = token.value.split('') + .filter((character) => !(new RegExp('[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}\\(\\)]').test(character))) + .filter((value, index, array) => array.indexOf(value) === index); + + const match = token.value.match(re); + return match?.groups?.value === token.value + ? undefined + : [ + `"${token.value}" is not a valid value.`, + ...(invalidCharacters.length + ? [`Invalid characters found: ${invalidCharacters.join(', ')}`] + : [] + ) + ].join(' '); +}; + /** * Validate the tokens while the user is building the query * @param tokens @@ -643,18 +688,7 @@ function validatePartial(tokens: ITokens, options: {field: string[]}): undefined }; // Check if the value is allowed if(token.type === 'value'){ - // Usage of API regular expression - const re = new RegExp( - // Value: A string. - '^(?(?:(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\/\'"=@%<>{}]*)\\))*' + - '(?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]+)' + - '(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]*)\\))*)+)$' - ); - - const match = token.value.match(re); - return match?.groups?.value === token.value - ? undefined - : `"${token.value}" is not a valid value222.`; + return validateTokenValue(token); } }; }) @@ -697,21 +731,14 @@ function validate(tokens: ITokens, options: {field: string[]}): undefined | stri }; // Check if the value is allowed if(token.type === 'value'){ - // Usage of API regular expression - const re = new RegExp( - // Value: A string. - '^(?(?:(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\/\'"=@%<>{}]*)\\))*' + - '(?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]+)' + - '(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]*)\\))*)+)$' - ); + const validationError = validateTokenValue(token); - const match = token.value.match(re); - match?.groups?.value !== token.value && errors.push(`"${token.value}" is not a valid value.`); + validationError && errors.push(validationError); }; // Check if the value is allowed if(token.type === 'conjunction'){ - + const tokenWhitespaceNearToFieldNext = getTokenNearTo( tokens, 'whitespace', From 1b390bece053e6f2dae441fedb4de0493a50309e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Fri, 12 May 2023 12:51:36 +0200 Subject: [PATCH 28/37] feat(search-bar): enhance search bar and WQL Search bar: - Add the possibility to render buttons to the right of the input - Minor changes WQL: - Add options.filterButtons to render filter buttons and component rendering - Extract the quoted value for the quoted token values - Add the `validate` parameter to validate the tokens (only available for `value` token) - Enhance language description - Add test related to value token validation - Update language documentation with this changes --- public/components/search-bar/index.tsx | 179 ++++++++++-------- .../search-bar/query-language/wql.md | 92 +++++---- .../search-bar/query-language/wql.test.tsx | 34 ++-- .../search-bar/query-language/wql.tsx | 113 ++++++++--- 4 files changed, 264 insertions(+), 154 deletions(-) diff --git a/public/components/search-bar/index.tsx b/public/components/search-bar/index.tsx index eb2be4bbd1..0b4ce9909a 100644 --- a/public/components/search-bar/index.tsx +++ b/public/components/search-bar/index.tsx @@ -8,6 +8,8 @@ import { EuiSpacer, EuiSelect, EuiText, + EuiFlexGroup, + EuiFlexItem } from '@elastic/eui'; import { EuiSuggest } from '../eui-suggest'; import { searchBarQueryLanguages } from './query-language'; @@ -43,7 +45,7 @@ export const SearchBar = ({ const [isOpenPopoverQueryLanguage, setIsOpenPopoverQueryLanguage] = useState(false); // Input field - const [input, setInput] = useState(''); + const [input, setInput] = useState(rest.input || ''); // Query language output of run method const [queryLanguageOutputRun, setQueryLanguageOutputRun] = useState({ searchBarProps: { suggestions: [] }, @@ -80,7 +82,7 @@ export const SearchBar = ({ useEffect(() => { // React to external changes and set the internal input text. Use the `transformUQLToQL` of // the query language in use - rest.input && setInput( + rest.input && searchBarQueryLanguages[queryLanguage.id]?.transformUQLToQL && setInput( searchBarQueryLanguages[queryLanguage.id]?.transformUQLToQL?.( rest.input, ), @@ -125,86 +127,97 @@ export const SearchBar = ({ const onQueryLanguagePopoverSwitch = () => setIsOpenPopoverQueryLanguage(state => !state); - return ( - {}} /* This method is run by EuiSuggest when there is a change in - a div wrapper of the input and should be defined. Defining this - property prevents an error. */ - suggestions={[]} - isPopoverOpen={ - queryLanguageOutputRun?.searchBarProps?.suggestions?.length > 0 && - isOpenSuggestionPopover - } - onClosePopover={() => setIsOpenSuggestionPopover(false)} - onPopoverFocus={() => setIsOpenSuggestionPopover(true)} - placeholder={'Search'} - append={ - - {searchBarQueryLanguages[queryLanguage.id].label} - - } - isOpen={isOpenPopoverQueryLanguage} - closePopover={onQueryLanguagePopoverSwitch} - > - SYNTAX OPTIONS -
- - {searchBarQueryLanguages[queryLanguage.id].description} - - {searchBarQueryLanguages[queryLanguage.id].documentationLink && ( - <> - -
- - Documentation - -
- - )} - {modes?.length > 1 && ( - <> - - - ({ - value: id, - text: searchBarQueryLanguages[id].label, - }))} - value={queryLanguage.id} - onChange={(event: React.ChangeEvent) => { - const queryLanguageID: string = event.target.value; - setQueryLanguage({ - id: queryLanguageID, - configuration: - searchBarQueryLanguages[ - queryLanguageID - ]?.getConfiguration?.() || {}, - }); - setInput(''); - }} - aria-label='query-language-selector' - /> - - - )} -
-
- } - {...queryLanguageOutputRun.searchBarProps} - /> + const searchBar = ( + <> + {}} /* This method is run by EuiSuggest when there is a change in + a div wrapper of the input and should be defined. Defining this + property prevents an error. */ + suggestions={[]} + isPopoverOpen={ + queryLanguageOutputRun?.searchBarProps?.suggestions?.length > 0 && + isOpenSuggestionPopover + } + onClosePopover={() => setIsOpenSuggestionPopover(false)} + onPopoverFocus={() => setIsOpenSuggestionPopover(true)} + placeholder={'Search'} + append={ + + {searchBarQueryLanguages[queryLanguage.id].label} + + } + isOpen={isOpenPopoverQueryLanguage} + closePopover={onQueryLanguagePopoverSwitch} + > + SYNTAX OPTIONS +
+ + {searchBarQueryLanguages[queryLanguage.id].description} + + {searchBarQueryLanguages[queryLanguage.id].documentationLink && ( + <> + +
+ + Documentation + +
+ + )} + {modes?.length > 1 && ( + <> + + + ({ + value: id, + text: searchBarQueryLanguages[id].label, + }))} + value={queryLanguage.id} + onChange={(event: React.ChangeEvent) => { + const queryLanguageID: string = event.target.value; + setQueryLanguage({ + id: queryLanguageID, + configuration: + searchBarQueryLanguages[ + queryLanguageID + ]?.getConfiguration?.() || {}, + }); + setInput(''); + }} + aria-label='query-language-selector' + /> + + + )} +
+
+ } + {...queryLanguageOutputRun.searchBarProps} + /> + ); + return rest.buttonsRender || queryLanguageOutputRun.filterButtons + ? ( + + {searchBar} + {rest.buttonsRender && {rest.buttonsRender()}} + {queryLanguageOutputRun.filterButtons && {queryLanguageOutputRun.filterButtons}} + + ) + : searchBar; }; diff --git a/public/components/search-bar/query-language/wql.md b/public/components/search-bar/query-language/wql.md index ef44107efb..108c942d32 100644 --- a/public/components/search-bar/query-language/wql.md +++ b/public/components/search-bar/query-language/wql.md @@ -153,6 +153,7 @@ field1~user_input,field2~user_input,field3~user_input Use UQL (Unified Query Language). - `conjunction`: query string of the conjunction in UQL (Unified Query Language) - `searchTermFields`: define the fields used to build the query for the search term mode + - `filterButtons`: define a list of buttons to filter in the search bar ```ts @@ -165,6 +166,9 @@ options: { conjunction: ';' } searchTermFields: ['id', 'ip'] + filterButtons: [ + {id: 'status-active', input: 'status=active', label: 'Active'} + ] } ``` @@ -197,45 +201,63 @@ options: { } ``` +- `validate`: define validation methods for the field types. Optional + - `value`: method to validate the value token + + ```ts + validate: { + value: (token, {field, operator_compare}) => { + if(field === 'field1'){ + const value = token.formattedValue || token.value + return /\d+/ ? undefined : `Invalid value for field ${field}, only digits are supported: "${value}"` + } + } + } + ``` + ## Language workflow ```mermaid graph TD; - user_input[User input]-->tokenizer; - subgraph tokenizer - tokenize_regex[Query language regular expression] - end - - tokenizer-->tokens; - tokens-->validate; - - tokens-->searchBarProps; - subgraph searchBarProps; - searchBarProps_suggestions[suggestions]-->searchBarProps_suggestions_input_isvalid{Input is valid} - searchBarProps_suggestions_input_isvalid{Is input valid?}-->searchBarProps_suggestions_input_isvalid_success[Yes] - searchBarProps_suggestions_input_isvalid{Is input valid?}-->searchBarProps_suggestions_input_isvalid_fail[No] - searchBarProps_suggestions_input_isvalid_success[Yes]--->searchBarProps_suggestions_get_last_token_with_value[Get last token with value]-->searchBarProps_suggestions__result[Suggestions] - searchBarProps_suggestions__result[Suggestions]-->EuiSuggestItem - searchBarProps_suggestions_input_isvalid_fail[No]-->searchBarProps_suggestions_invalid[Invalid with error message] - searchBarProps_suggestions_invalid[Invalid with error message]-->EuiSuggestItem - searchBarProps_prepend[prepend]-->searchBarProps_prepend_implicitQuery{implicitQuery} - searchBarProps_prepend_implicitQuery{implicitQuery}-->searchBarProps_prepend_implicitQuery_yes[Yes]-->EuiButton - searchBarProps_prepend_implicitQuery{implicitQuery}-->searchBarProps_prepend_implicitQuery_no[No]-->null - searchBarProps_disableFocusTrap:true[disableFocusTrap = true] - searchBarProps_onItemClick[onItemClickSuggestion]-->searchBarProps_onItemClick_suggestion_search[Search suggestion] - searchBarProps_onItemClick_suggestion_search[Search suggestion]-->searchBarProps_onItemClick_suggestion_search_run[Run search] - searchBarProps_onItemClick[onItemClickSuggestion]-->searchBarProps_onItemClick_suggestion_edit_current_token[Edit current token]-->searchBarProps_onItemClick_build_input[Build input] - searchBarProps_onItemClick[onItemClickSuggestion]-->searchBarProps_onItemClick_suggestion_add_new_token[Add new token]-->searchBarProps_onItemClick_build_input[Build input] - searchBarProps_onItemClick[onItemClickSuggestion]-->searchBarProps_onItemClick_suggestion_error[Error] - searchBarProps_isInvalid[isInvalid] - end - - tokens-->output; - subgraph output[output]; - output_result[implicitFilter + user input] - end - - output-->output_search_bar[Output] + user_input[User input]-->ql_run; + ql_run-->filterButtons[filterButtons]; + ql_run-->tokenizer-->tokens; + tokens-->searchBarProps; + tokens-->output; + + subgraph tokenizer + tokenize_regex[Query language regular expression: decomposition and extract quoted values] + end + + subgraph searchBarProps; + searchBarProps_suggestions[suggestions]-->searchBarProps_suggestions_input_isvalid{Input is valid} + searchBarProps_suggestions_input_isvalid{Is input valid?}-->searchBarProps_suggestions_input_isvalid_success[Yes] + searchBarProps_suggestions_input_isvalid{Is input valid?}-->searchBarProps_suggestions_input_isvalid_fail[No] + searchBarProps_suggestions_input_isvalid_success[Yes]--->searchBarProps_suggestions_get_last_token_with_value[Get last token with value]-->searchBarProps_suggestions__result[Suggestions] + searchBarProps_suggestions__result[Suggestions]-->EuiSuggestItem + searchBarProps_suggestions_input_isvalid_fail[No]-->searchBarProps_suggestions_invalid[Invalid with error message] + searchBarProps_suggestions_invalid[Invalid with error message]-->EuiSuggestItem + searchBarProps_prepend[prepend]-->searchBarProps_prepend_implicitQuery{options.implicitQuery} + searchBarProps_prepend_implicitQuery{options.implicitQuery}-->searchBarProps_prepend_implicitQuery_yes[Yes]-->EuiButton + searchBarProps_prepend_implicitQuery{options.implicitQuery}-->searchBarProps_prepend_implicitQuery_no[No]-->null + searchBarProps_disableFocusTrap:true[disableFocusTrap = true] + searchBarProps_onItemClick[onItemClickSuggestion]-->searchBarProps_onItemClick_suggestion_search[Search suggestion] + searchBarProps_onItemClick_suggestion_search[Search suggestion]-->searchBarProps_onItemClick_suggestion_search_run[Run search] + searchBarProps_onItemClick[onItemClickSuggestion]-->searchBarProps_onItemClick_suggestion_edit_current_token[Edit current token]-->searchBarProps_onItemClick_build_input[Build input] + searchBarProps_onItemClick[onItemClickSuggestion]-->searchBarProps_onItemClick_suggestion_add_new_token[Add new token]-->searchBarProps_onItemClick_build_input[Build input] + searchBarProps_onItemClick[onItemClickSuggestion]-->searchBarProps_onItemClick_suggestion_error[Error] + searchBarProps_isInvalid[isInvalid]-->searchBarProps_validate_input[validate input] + end + + subgraph output[output]; + output_input_options_implicitFilter[options.implicitFilter]-->output_input_options_result["{apiQuery: { q }, error, language: 'wql', query}"] + output_input_user_input_QL[User input in QL]-->output_input_user_input_UQL[User input in UQL]-->output_input_options_result["{apiQuery: { q }, error, language: 'wql', query}"] + end + + subgraph filterButtons; + filterButtons_optional{options.filterButtons}-->filterButtons_optional_yes[Yes]-->filterButtons_optional_yes_component[Render fitter button] + filterButtons_optional{options.filterButtons}-->filterButtons_optional_no[No]-->filterButtons_optional_no_null[null] + end ``` ## Notes diff --git a/public/components/search-bar/query-language/wql.test.tsx b/public/components/search-bar/query-language/wql.test.tsx index 3ec1f1d61f..56f11d9256 100644 --- a/public/components/search-bar/query-language/wql.test.tsx +++ b/public/components/search-bar/query-language/wql.test.tsx @@ -50,15 +50,15 @@ describe('SearchBar component', () => { /* eslint-disable max-len */ describe('Query language - WQL', () => { // Tokenize the input - function tokenCreator({type, value}){ - return {type, value}; + function tokenCreator({type, value, formattedValue}){ + return {type, value, ...(formattedValue ? { formattedValue } : {})}; }; const t = { opGroup: (value = undefined) => tokenCreator({type: 'operator_group', value}), opCompare: (value = undefined) => tokenCreator({type: 'operator_compare', value}), field: (value = undefined) => tokenCreator({type: 'field', value}), - value: (value = undefined) => tokenCreator({type: 'value', value}), + value: (value = undefined, formattedValue = undefined) => tokenCreator({type: 'value', value, formattedValue}), whitespace: (value = undefined) => tokenCreator({type: 'whitespace', value}), conjunction: (value = undefined) => tokenCreator({type: 'conjunction', value}) }; @@ -107,20 +107,20 @@ describe('Query language - WQL', () => { ${'field=value~'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value~'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie] /** ~ character is not supported as value in the q query parameter */} ${'field="'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} ${'field="value and'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value and'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field="value and value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value and value2"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field="value or value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value or value2"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field="value = value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value = value2"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field="value != value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value != value2"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field="value > value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value > value2"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field="value < value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value < value2"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field="value ~ value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value ~ value2"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie] /** ~ character is not supported as value in the q query parameter */} + ${'field="value and value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value and value2"', 'value and value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value or value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value or value2"', 'value or value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value = value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value = value2"', 'value = value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value != value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value != value2"', 'value != value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value > value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value > value2"', 'value > value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value < value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value < value2"', 'value < value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value ~ value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value ~ value2"', 'value ~ value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie] /** ~ character is not supported as value in the q query parameter */} ${'field=value and'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), tu.whitespace, ...tuBlankSerie]} ${'field=value and '} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), ...tuBlankSerie]} ${'field=value and field2'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, tu.opCompare, tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} ${'field=value and field2!='} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} ${'field=value and field2!="'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, t.value('"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} ${'field=value and field2!="value'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, t.value('"value'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field=value and field2!="value"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, t.value('"value"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value and field2!="value"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, t.value('"value"', 'value'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} ${'field=value and field2!=value2 and'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, t.value('value2'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), tu.whitespace, ...tuBlankSerie]} `(`Tokenizer API input $input`, ({input, tokens}) => { expect(tokenizer(input)).toEqual(tokens); @@ -371,6 +371,8 @@ describe('Query language - WQL', () => { ${'field='} | ${['"field" is not a valid field.']} ${'custom='} | ${['"custom" is not a valid field.']} ${'field1=value'} | ${undefined} + ${'field1=1'} | ${['Numbers are not valid for field1']} + ${'field1=value1'} | ${['Numbers are not valid for field1']} ${'field2=value'} | ${undefined} ${'field=value'} | ${['"field" is not a valid field.']} ${'custom=value'} | ${['"custom" is not a valid field.']} @@ -410,6 +412,16 @@ describe('Query language - WQL', () => { suggestions: { field: () => (['field1', 'field2'].map(label => ({label}))), value: () => ([]) + }, + validate: { + value: (token, {field, operator_compare}) => { + if(field === 'field1'){ + const value = token.formattedValue || token.value; + return /\d/.test(value) + ? `Numbers are not valid for ${field}` + : undefined + } + } } } } diff --git a/public/components/search-bar/query-language/wql.tsx b/public/components/search-bar/query-language/wql.tsx index 6935f50102..17fda7ec48 100644 --- a/public/components/search-bar/query-language/wql.tsx +++ b/public/components/search-bar/query-language/wql.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { EuiButtonEmpty, EuiPopover, EuiText, EuiCode } from '@elastic/eui'; +import { EuiButtonEmpty, EuiButtonGroup, EuiPopover, EuiText, EuiCode } from '@elastic/eui'; import { tokenizer as tokenizerUQL } from './aql'; import { PLUGIN_VERSION_SHORT } from '../../../../common/constants'; @@ -171,15 +171,20 @@ export function tokenizer(input: string): ITokens{ return [ ...input.matchAll(re) ].map( - ({groups}) => Object.entries(groups) - .map(([key, value]) => ({ - type: key.startsWith('operator_group') // Transform operator_group group match - ? 'operator_group' - : (key.startsWith('whitespace') // Transform whitespace group match - ? 'whitespace' - : key), - value}) + ({groups}) => Object.entries(groups) + .map(([key, value]) => ({ + type: key.startsWith('operator_group') // Transform operator_group group match + ? 'operator_group' + : (key.startsWith('whitespace') // Transform whitespace group match + ? 'whitespace' + : key), + value, + ...( key === 'value' && value && /^"(.+)"$/.test(value) + ? { formattedValue: value.match(/^"(.+)"$/)[1]} + : {} ) + }) + ) ).flat(); }; @@ -661,13 +666,14 @@ function validateTokenValue(token: IToken): string | undefined { ].join(' '); }; +type ITokenValidator = (tokenValue: IToken, proximityTokens: any) => string | undefined; /** * Validate the tokens while the user is building the query * @param tokens - * @param options + * @param validate * @returns */ -function validatePartial(tokens: ITokens, options: {field: string[]}): undefined | string{ +function validatePartial(tokens: ITokens, validate: {field: ITokenValidator, value: ITokenValidator}): undefined | string{ // Ensure is not in search term mode if (!shouldUseSearchTerm(tokens)){ return tokens.map((token: IToken, index) => { @@ -682,13 +688,28 @@ function validatePartial(tokens: ITokens, options: {field: string[]}): undefined { tokenReferencePosition: index, tokenFoundShouldHaveValue: true } ); return tokenOperatorNearToField - && !options.field.includes(token.value) - ? `"${token.value}" is not a valid field.` + ? validate.field(token) : undefined; }; // Check if the value is allowed if(token.type === 'value'){ - return validateTokenValue(token); + const tokenFieldNearToValue = getTokenNearTo( + tokens, + 'field', + 'previous', + { tokenReferencePosition: index, tokenFoundShouldHaveValue: true } + ); + const tokenOperatorCompareNearToValue = getTokenNearTo( + tokens, + 'operator_compare', + 'previous', + { tokenReferencePosition: index, tokenFoundShouldHaveValue: true } + ); + return validateTokenValue(token) + || (tokenFieldNearToValue && tokenOperatorCompareNearToValue && validate.value ? validate.value(token, { + field: tokenFieldNearToValue?.value, + operator: tokenOperatorCompareNearToValue?.value + }) : undefined); } }; }) @@ -700,10 +721,10 @@ function validatePartial(tokens: ITokens, options: {field: string[]}): undefined /** * Validate the tokens if they are a valid syntax * @param tokens - * @param options + * @param validate * @returns */ -function validate(tokens: ITokens, options: {field: string[]}): undefined | string[]{ +function validate(tokens: ITokens, validate: {field: ITokenValidator, value: ITokenValidator}): undefined | string[]{ if (!shouldUseSearchTerm(tokens)){ const errors = tokens.map((token: IToken, index) => { const errors = []; @@ -721,7 +742,7 @@ function validate(tokens: ITokens, options: {field: string[]}): undefined | stri 'next', { tokenReferencePosition: index, tokenFoundShouldHaveValue: true } ); - if(!options.field.includes(token.value)){ + if(validate.field(token)){ errors.push(`"${token.value}" is not a valid field.`); }else if(!tokenOperatorNearToField){ errors.push(`The operator for field "${token.value}" is missing.`); @@ -731,7 +752,23 @@ function validate(tokens: ITokens, options: {field: string[]}): undefined | stri }; // Check if the value is allowed if(token.type === 'value'){ - const validationError = validateTokenValue(token); + const tokenFieldNearToValue = getTokenNearTo( + tokens, + 'field', + 'previous', + { tokenReferencePosition: index, tokenFoundShouldHaveValue: true } + ); + const tokenOperatorCompareNearToValue = getTokenNearTo( + tokens, + 'operator_compare', + 'previous', + { tokenReferencePosition: index, tokenFoundShouldHaveValue: true } + ); + const validationError = validateTokenValue(token) + || (tokenFieldNearToValue && tokenOperatorCompareNearToValue && validate.value ? validate.value(token, { + field: tokenFieldNearToValue?.value, + operator: tokenOperatorCompareNearToValue?.value + }) : undefined);; validationError && errors.push(validationError); }; @@ -768,7 +805,7 @@ function validate(tokens: ITokens, options: {field: string[]}): undefined | stri export const WQL = { id: 'wql', label: 'WQL', - description: 'WQL (Wazuh Query Language) offers a human query syntax based on the Wazuh API query language.', + description: 'WQL (Wazuh Query Language) provides a human query syntax based on the Wazuh API query language.', documentationLink: `https://github.com/wazuh/wazuh-kibana-app/blob/v${PLUGIN_VERSION_SHORT}/public/components/search-bar/query-language/wql.md`, getConfiguration() { return { @@ -790,14 +827,17 @@ export const WQL = { const fieldsSuggestion: string[] = await params.queryLanguage.parameters.suggestions.field() .map(({label}) => label); + const validators = { + field: ({value}) => fieldsSuggestion.includes(value) ? undefined : `"${value}" is not valid field.`, + ...(params.queryLanguage.parameters?.validate?.value ? { + value: params.queryLanguage.parameters?.validate?.value + } : {}) + }; + // Validate the user input - const validationPartial = validatePartial(tokens, { - field: fieldsSuggestion - }); + const validationPartial = validatePartial(tokens, validators); - const validationStrict = validate(tokens, { - field: fieldsSuggestion - }); + const validationStrict = validate(tokens, validators); // Get the output of query language const output = { @@ -822,6 +862,29 @@ export const WQL = { }; return { + filterButtons: params.queryLanguage.parameters?.options?.filterButtons + ? ( + { id, label } + ))} + idToSelectedMap={{}} + type="multi" + onChange={(id: string) => { + const buttonParams = params.queryLanguage.parameters?.options?.filterButtons.find(({id: buttonID}) => buttonID === id); + if(buttonParams){ + params.setInput(buttonParams.input); + const output = { + ...getOutput(buttonParams.input, params.queryLanguage.parameters), + error: undefined + }; + params.onSearch(output); + } + }} + /> + : null, searchBarProps: { // Props that will be used by the EuiSuggest component // Suggestions From 06893adb850f1037d2e7a0a4a99686367317a85e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Wed, 17 May 2023 11:55:29 +0200 Subject: [PATCH 29/37] feat(search-bar): enhace search bar and WQL Search bar: - rename method from `transformUQLtoQL` to `transformInput` - add a options paramenter to `transformInput` to manage when there is an implicit filter, that this methods can remove the filter from the query - update documentation WQL: - rename method from `transformUQLtoQL` to `transformInput` - add a options paramenter to `transformInput` to manage when there is an implicit filter, that this methods can remove the filter from the query - add tests --- public/components/search-bar/README.md | 4 ++-- public/components/search-bar/index.tsx | 10 +++++++--- .../components/search-bar/query-language/index.ts | 2 +- .../search-bar/query-language/wql.test.tsx | 15 +++++++++++++-- .../components/search-bar/query-language/wql.tsx | 11 ++++++++++- 5 files changed, 33 insertions(+), 9 deletions(-) diff --git a/public/components/search-bar/README.md b/public/components/search-bar/README.md index b8e95cef2a..ce9fd0d65b 100644 --- a/public/components/search-bar/README.md +++ b/public/components/search-bar/README.md @@ -126,7 +126,7 @@ type SearchBarQueryLanguage = { query: string } }>; - transformUQLToQL: (unifiedQuery: string) => string; + transformInput: (unifiedQuery: string, options: {configuration: any, parameters: any}) => string; }; ``` @@ -145,7 +145,7 @@ where: - `language`: query language ID - `apiQuery`: API query. - `query`: current query in the specified language -- `transformUQLToQL`: method that transforms the UQL (Unified Query Language) to the specific query +- `transformInput`: method that transforms the UQL (Unified Query Language) to the specific query language. This is used when receives a external input in the Unified Query Language, the returned value is converted to the specific query language to set the new input text of the search bar component. diff --git a/public/components/search-bar/index.tsx b/public/components/search-bar/index.tsx index 0b4ce9909a..10b17b3703 100644 --- a/public/components/search-bar/index.tsx +++ b/public/components/search-bar/index.tsx @@ -80,11 +80,15 @@ export const SearchBar = ({ const selectedQueryLanguageParameters = modes.find(({ id }) => id === queryLanguage.id); useEffect(() => { - // React to external changes and set the internal input text. Use the `transformUQLToQL` of + // React to external changes and set the internal input text. Use the `transformInput` of // the query language in use - rest.input && searchBarQueryLanguages[queryLanguage.id]?.transformUQLToQL && setInput( - searchBarQueryLanguages[queryLanguage.id]?.transformUQLToQL?.( + rest.input && searchBarQueryLanguages[queryLanguage.id]?.transformInput && setInput( + searchBarQueryLanguages[queryLanguage.id]?.transformInput?.( rest.input, + { + configuration: queryLanguage.configuration, + parameters: selectedQueryLanguageParameters, + } ), ); }, [rest.input]); diff --git a/public/components/search-bar/query-language/index.ts b/public/components/search-bar/query-language/index.ts index fc95717fca..5a897d1d34 100644 --- a/public/components/search-bar/query-language/index.ts +++ b/public/components/search-bar/query-language/index.ts @@ -15,7 +15,7 @@ type SearchBarQueryLanguage = { query: string } }>; - transformUQLToQL: (unifiedQuery: string) => string; + transformInput: (unifiedQuery: string, options: {configuration: any, parameters: any}) => string; }; // Register the query languages diff --git a/public/components/search-bar/query-language/wql.test.tsx b/public/components/search-bar/query-language/wql.test.tsx index 56f11d9256..bfe284b03d 100644 --- a/public/components/search-bar/query-language/wql.test.tsx +++ b/public/components/search-bar/query-language/wql.test.tsx @@ -347,9 +347,20 @@ describe('Query language - WQL', () => { ${'(field=value,field2'} | ${'(field=value or field2'} ${'(field=value,field2>'} | ${'(field=value or field2>'} ${'(field=value,field2>value2'} | ${'(field=value or field2>value2'} - ${'(field=value,field2>value2)'} | ${'(field=value or field2>value2)'} + ${'(field=value,field2>value2)'} | ${'(field=value or field2>value2)'} + ${'implicit=value;'} | ${''} + ${'implicit=value;field'} | ${'field'} `('Transform the external input UQL to QL - UQL $UQL => $WQL', async ({UQL, WQL: changedInput}) => { - expect(WQL.transformUQLToQL(UQL)).toEqual(changedInput); + expect(WQL.transformInput(UQL, { + parameters: { + options: { + implicitQuery: { + query: 'implicit=value', + conjunction: ';' + } + } + } + })).toEqual(changedInput); }); /* The ! and ~ characters can't be part of a value that contains examples. The tests doesn't diff --git a/public/components/search-bar/query-language/wql.tsx b/public/components/search-bar/query-language/wql.tsx index 17fda7ec48..f3163a8147 100644 --- a/public/components/search-bar/query-language/wql.tsx +++ b/public/components/search-bar/query-language/wql.tsx @@ -1007,5 +1007,14 @@ export const WQL = { output }; }, - transformUQLToQL: transformUQLToQL, + transformInput: (unifiedQuery: string, {parameters}) => { + const input = unifiedQuery && parameters?.options?.implicitQuery + ? unifiedQuery.replace( + new RegExp(`^${parameters.options.implicitQuery.query}${parameters.options.implicitQuery.conjunction}`), + '' + ) + : unifiedQuery; + + return transformUQLToQL(input); + }, }; From d774a48f313419dadde396d7370e262b53d4035d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Fri, 19 May 2023 10:18:36 +0200 Subject: [PATCH 30/37] fix: enhance search bar and WQL types --- public/components/search-bar/index.tsx | 10 ++++++---- public/components/search-bar/query-language/wql.tsx | 10 ++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/public/components/search-bar/index.tsx b/public/components/search-bar/index.tsx index 10b17b3703..4a82d5d360 100644 --- a/public/components/search-bar/index.tsx +++ b/public/components/search-bar/index.tsx @@ -14,12 +14,14 @@ import { import { EuiSuggest } from '../eui-suggest'; import { searchBarQueryLanguages } from './query-language'; import _ from 'lodash'; +import { ISearchBarModeWQL } from './query-language/wql'; -type SearchBarProps = { +export interface SearchBarProps{ defaultMode?: string; - modes: { id: string; [key: string]: any }[]; + modes: ISearchBarModeWQL[]; onChange?: (params: any) => void; onSearch: (params: any) => void; + buttonsRender?: () => React.ReactNode input?: string; }; @@ -71,7 +73,7 @@ export const SearchBar = ({ setInput(event.target.value); // Handler when pressing a key - const onKeyPressHandler = event => { + const onKeyPressHandler = (event: React.KeyboardEvent) => { if (event.key === 'Enter') { _onSearch(queryLanguageOutputRun.output); } @@ -192,7 +194,7 @@ export const SearchBar = ({ text: searchBarQueryLanguages[id].label, }))} value={queryLanguage.id} - onChange={(event: React.ChangeEvent) => { + onChange={(event: React.ChangeEvent) => { const queryLanguageID: string = event.target.value; setQueryLanguage({ id: queryLanguageID, diff --git a/public/components/search-bar/query-language/wql.tsx b/public/components/search-bar/query-language/wql.tsx index f3163a8147..4776c141e7 100644 --- a/public/components/search-bar/query-language/wql.tsx +++ b/public/components/search-bar/query-language/wql.tsx @@ -217,11 +217,21 @@ type OptionsQL = { options?: { implicitQuery?: OptionsQLImplicitQuery searchTermFields?: string[] + filterButtons: {id: string, label: string, input: string}[] } suggestions: { field: QLOptionSuggestionHandler; value: QLOptionSuggestionHandler; }; + validate?: { + value?: { + [key: string]: (token: IToken, nearTokens: {field: string, operator: string}) => string | undefined + } + } +}; + +export interface ISearchBarModeWQL extends OptionsQL{ + id: 'wql' }; /** From 0cfe1d543ed97ce076fc64f7fa2550424d3e0a2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Tue, 1 Aug 2023 12:02:52 +0200 Subject: [PATCH 31/37] fix: remove exact validation for the token value due to performance problems --- .../search-bar/query-language/wql.tsx | 1014 +++++++++-------- 1 file changed, 562 insertions(+), 452 deletions(-) diff --git a/plugins/main/public/components/search-bar/query-language/wql.tsx b/plugins/main/public/components/search-bar/query-language/wql.tsx index 4776c141e7..8830ff0175 100644 --- a/plugins/main/public/components/search-bar/query-language/wql.tsx +++ b/plugins/main/public/components/search-bar/query-language/wql.tsx @@ -1,5 +1,11 @@ import React from 'react'; -import { EuiButtonEmpty, EuiButtonGroup, EuiPopover, EuiText, EuiCode } from '@elastic/eui'; +import { + EuiButtonEmpty, + EuiButtonGroup, + EuiPopover, + EuiText, + EuiCode, +} from '@elastic/eui'; import { tokenizer as tokenizerUQL } from './aql'; import { PLUGIN_VERSION_SHORT } from '../../../../common/constants'; @@ -50,8 +56,8 @@ const language = { }, conjunction: { literal: { - 'and': 'and', - 'or': 'or', + and: 'and', + or: 'or', }, }, // eslint-disable-next-line camelcase @@ -62,14 +68,14 @@ const language = { }, }, }, - equivalencesToUQL:{ - conjunction:{ - literal:{ - 'and': ';', - 'or': ',', - } - } - } + equivalencesToUQL: { + conjunction: { + literal: { + and: ';', + or: ',', + }, + }, + }, }; // Suggestion mapper by language token type @@ -84,155 +90,162 @@ const suggestionMappingLanguageTokenType = { // eslint-disable-next-line camelcase function_search: { iconType: 'search', color: 'tint5' }, // eslint-disable-next-line camelcase - validation_error: { iconType: 'alert', color: 'tint2' } + validation_error: { iconType: 'alert', color: 'tint2' }, }; /** * Creator of intermediate interface of EuiSuggestItem - * @param type - * @returns + * @param type + * @returns */ -function mapSuggestionCreator(type: ITokenType ){ - return function({...params}){ +function mapSuggestionCreator(type: ITokenType) { + return function ({ ...params }) { return { type, - ...params + ...params, }; }; -}; +} const mapSuggestionCreatorField = mapSuggestionCreator('field'); const mapSuggestionCreatorValue = mapSuggestionCreator('value'); /** * Transform the conjunction to the query language syntax - * @param conjunction - * @returns + * @param conjunction + * @returns */ -function transformQLConjunction(conjunction: string): string{ +function transformQLConjunction(conjunction: string): string { // If the value has a whitespace or comma, then return conjunction === language.equivalencesToUQL.conjunction.literal['and'] ? ` ${language.tokens.conjunction.literal['and']} ` : ` ${language.tokens.conjunction.literal['or']} `; -}; +} /** * Transform the value to the query language syntax - * @param value - * @returns + * @param value + * @returns */ -function transformQLValue(value: string): string{ +function transformQLValue(value: string): string { // If the value has a whitespace or comma, then return /[\s|"]/.test(value) - // Escape the commas (") => (\") and wraps the string with commas ("") - ? `"${value.replace(/"/, '\\"')}"` - // Raw value - : value; -}; + ? // Escape the commas (") => (\") and wraps the string with commas ("") + `"${value.replace(/"/, '\\"')}"` + : // Raw value + value; +} /** * Tokenize the input string. Returns an array with the tokens. * @param input * @returns */ -export function tokenizer(input: string): ITokens{ +export function tokenizer(input: string): ITokens { const re = new RegExp( // A ( character. '(?\\()?' + - // Whitespace - '(?\\s+)?' + - // Field name: name of the field to look on DB. - '(?[\\w.]+)?' + // Added an optional find - // Whitespace - '(?\\s+)?' + - // Operator: looks for '=', '!=', '<', '>' or '~'. - // This seems to be a bug because is not searching the literal valid operators. - // I guess the operator is validated after the regular expression matches - `(?[${Object.keys(language.tokens.operator_compare.literal)}]{1,2})?` + // Added an optional find - // Whitespace - '(?\\s+)?' + - // Value: A string. - // Simple value - // Quoted ", "value, "value", "escaped \"quote" - // Escape quoted string with escaping quotes: https://stackoverflow.com/questions/249791/regex-for-quoted-string-with-escaping-quotes - '(?(?:(?:[^"\\s]+|(?:"(?:[^"\\\\]|\\\\")*")|(?:"(?:[^"\\\\]|\\\\")*)|")))?' + - // Whitespace - '(?\\s+)?' + - // A ) character. - '(?\\))?' + - // Whitespace - '(?\\s+)?' + - `(?${Object.keys(language.tokens.conjunction.literal).join('|')})?` + - // Whitespace - '(?\\s+)?', - 'g' + // Whitespace + '(?\\s+)?' + + // Field name: name of the field to look on DB. + '(?[\\w.]+)?' + // Added an optional find + // Whitespace + '(?\\s+)?' + + // Operator: looks for '=', '!=', '<', '>' or '~'. + // This seems to be a bug because is not searching the literal valid operators. + // I guess the operator is validated after the regular expression matches + `(?[${Object.keys( + language.tokens.operator_compare.literal, + )}]{1,2})?` + // Added an optional find + // Whitespace + '(?\\s+)?' + + // Value: A string. + // Simple value + // Quoted ", "value, "value", "escaped \"quote" + // Escape quoted string with escaping quotes: https://stackoverflow.com/questions/249791/regex-for-quoted-string-with-escaping-quotes + '(?(?:(?:[^"\\s]+|(?:"(?:[^"\\\\]|\\\\")*")|(?:"(?:[^"\\\\]|\\\\")*)|")))?' + + // Whitespace + '(?\\s+)?' + + // A ) character. + '(?\\))?' + + // Whitespace + '(?\\s+)?' + + `(?${Object.keys(language.tokens.conjunction.literal).join( + '|', + )})?` + + // Whitespace + '(?\\s+)?', + 'g', ); - return [ - ...input.matchAll(re) - ].map( - ({groups}) => Object.entries(groups) - .map(([key, value]) => ({ - type: key.startsWith('operator_group') // Transform operator_group group match - ? 'operator_group' - : (key.startsWith('whitespace') // Transform whitespace group match - ? 'whitespace' - : key), - value, - ...( key === 'value' && value && /^"(.+)"$/.test(value) - ? { formattedValue: value.match(/^"(.+)"$/)[1]} - : {} - ) - }) - ) - ).flat(); -}; + return [...input.matchAll(re)] + .map(({ groups }) => + Object.entries(groups).map(([key, value]) => ({ + type: key.startsWith('operator_group') // Transform operator_group group match + ? 'operator_group' + : key.startsWith('whitespace') // Transform whitespace group match + ? 'whitespace' + : key, + value, + ...(key === 'value' && value && /^"(.+)"$/.test(value) + ? { formattedValue: value.match(/^"(.+)"$/)[1] } + : {}), + })), + ) + .flat(); +} type QLOptionSuggestionEntityItem = { - description?: string - label: string + description?: string; + label: string; }; -type QLOptionSuggestionEntityItemTyped = - QLOptionSuggestionEntityItem - & { type: 'operator_group'|'field'|'operator_compare'|'value'|'conjunction'|'function_search' }; +type QLOptionSuggestionEntityItemTyped = QLOptionSuggestionEntityItem & { + type: + | 'operator_group' + | 'field' + | 'operator_compare' + | 'value' + | 'conjunction' + | 'function_search'; +}; type SuggestItem = QLOptionSuggestionEntityItem & { - type: { iconType: string, color: string } + type: { iconType: string; color: string }; }; type QLOptionSuggestionHandler = ( currentValue: string | undefined, - { - field, - operatorCompare, - }: { field: string; operatorCompare: string }, + { field, operatorCompare }: { field: string; operatorCompare: string }, ) => Promise; type OptionsQLImplicitQuery = { - query: string - conjunction: string -} + query: string; + conjunction: string; +}; type OptionsQL = { options?: { - implicitQuery?: OptionsQLImplicitQuery - searchTermFields?: string[] - filterButtons: {id: string, label: string, input: string}[] - } + implicitQuery?: OptionsQLImplicitQuery; + searchTermFields?: string[]; + filterButtons: { id: string; label: string; input: string }[]; + }; suggestions: { field: QLOptionSuggestionHandler; value: QLOptionSuggestionHandler; }; validate?: { value?: { - [key: string]: (token: IToken, nearTokens: {field: string, operator: string}) => string | undefined - } - } + [key: string]: ( + token: IToken, + nearTokens: { field: string; operator: string }, + ) => string | undefined; + }; + }; }; -export interface ISearchBarModeWQL extends OptionsQL{ - id: 'wql' -}; +export interface ISearchBarModeWQL extends OptionsQL { + id: 'wql'; +} /** * Get the last token with value @@ -240,9 +253,7 @@ export interface ISearchBarModeWQL extends OptionsQL{ * @param tokenType token type to search * @returns */ -function getLastTokenDefined( - tokens: ITokens -): IToken | undefined { +function getLastTokenDefined(tokens: ITokens): IToken | undefined { // Reverse the tokens array and use the Array.protorype.find method const shallowCopyArray = Array.from([...tokens]); const shallowCopyArrayReversed = shallowCopyArray.reverse(); @@ -270,32 +281,37 @@ function getLastTokenDefinedByType( ({ type, value }) => type === tokenType && value, ); return tokenFound; -}; +} /** * Get the token that is near to a token position of the token type. - * @param tokens - * @param tokenReferencePosition - * @param tokenType - * @param mode - * @returns + * @param tokens + * @param tokenReferencePosition + * @param tokenType + * @param mode + * @returns */ function getTokenNearTo( tokens: ITokens, tokenType: ITokenType, - mode : 'previous' | 'next' = 'previous', - options : {tokenReferencePosition?: number, tokenFoundShouldHaveValue?: boolean} = {} + mode: 'previous' | 'next' = 'previous', + options: { + tokenReferencePosition?: number; + tokenFoundShouldHaveValue?: boolean; + } = {}, ): IToken | undefined { const shallowCopyTokens = Array.from([...tokens]); - const computedShallowCopyTokens = mode === 'previous' - ? shallowCopyTokens.slice(0, options?.tokenReferencePosition || tokens.length).reverse() - : shallowCopyTokens.slice(options?.tokenReferencePosition || 0); - return computedShallowCopyTokens - .find(({type, value}) => - type === tokenType - && (options?.tokenFoundShouldHaveValue ? value : true) - ); -}; + const computedShallowCopyTokens = + mode === 'previous' + ? shallowCopyTokens + .slice(0, options?.tokenReferencePosition || tokens.length) + .reverse() + : shallowCopyTokens.slice(options?.tokenReferencePosition || 0); + return computedShallowCopyTokens.find( + ({ type, value }) => + type === tokenType && (options?.tokenFoundShouldHaveValue ? value : true), + ); +} /** * Get the suggestions from the tokens @@ -304,7 +320,10 @@ function getTokenNearTo( * @param options * @returns */ -export async function getSuggestions(tokens: ITokens, options: OptionsQL): Promise { +export async function getSuggestions( + tokens: ITokens, + options: OptionsQL, +): Promise { if (!tokens.length) { return []; } @@ -313,8 +332,8 @@ export async function getSuggestions(tokens: ITokens, options: OptionsQL): Promi const lastToken = getLastTokenDefined(tokens); // If it can't get a token with value, then returns fields and open operator group - if(!lastToken?.type){ - return [ + if (!lastToken?.type) { + return [ // Search function { type: 'function_search', @@ -327,36 +346,38 @@ export async function getSuggestions(tokens: ITokens, options: OptionsQL): Promi type: 'operator_group', label: '(', description: language.tokens.operator_group.literal['('], - } + }, ]; - }; + } switch (lastToken.type) { case 'field': return [ // fields that starts with the input but is not equals - ...(await options.suggestions.field()).filter( - ({ label }) => - label.startsWith(lastToken.value) && label !== lastToken.value, - ).map(mapSuggestionCreatorField), + ...(await options.suggestions.field()) + .filter( + ({ label }) => + label.startsWith(lastToken.value) && label !== lastToken.value, + ) + .map(mapSuggestionCreatorField), // operators if the input field is exact ...((await options.suggestions.field()).some( ({ label }) => label === lastToken.value, ) ? [ - ...Object.keys(language.tokens.operator_compare.literal).map( - operator => ({ - type: 'operator_compare', - label: operator, - description: - language.tokens.operator_compare.literal[operator], - }), - ), - ] + ...Object.keys(language.tokens.operator_compare.literal).map( + operator => ({ + type: 'operator_compare', + label: operator, + description: + language.tokens.operator_compare.literal[operator], + }), + ), + ] : []), ]; break; - case 'operator_compare':{ + case 'operator_compare': { const field = getLastTokenDefinedByType(tokens, 'field')?.value; const operatorCompare = getLastTokenDefinedByType( tokens, @@ -365,9 +386,9 @@ export async function getSuggestions(tokens: ITokens, options: OptionsQL): Promi // If there is no a previous field, then no return suggestions because it would be an syntax // error - if(!field){ + if (!field) { return []; - }; + } return [ ...Object.keys(language.tokens.operator_compare.literal) @@ -385,16 +406,18 @@ export async function getSuggestions(tokens: ITokens, options: OptionsQL): Promi operator => operator === lastToken.value, ) ? [ - ...(await options.suggestions.value(undefined, { - field, - operatorCompare, - })).map(mapSuggestionCreatorValue), - ] + ...( + await options.suggestions.value(undefined, { + field, + operatorCompare, + }) + ).map(mapSuggestionCreatorValue), + ] : []), ]; break; } - case 'value':{ + case 'value': { const field = getLastTokenDefinedByType(tokens, 'field')?.value; const operatorCompare = getLastTokenDefinedByType( tokens, @@ -403,26 +426,28 @@ export async function getSuggestions(tokens: ITokens, options: OptionsQL): Promi /* If there is no a previous field or operator_compare, then no return suggestions because it would be an syntax error */ - if(!field || !operatorCompare){ + if (!field || !operatorCompare) { return []; - }; + } return [ ...(lastToken.value ? [ - { - type: 'function_search', - label: 'Search', - description: 'run the search query', - }, - ] + { + type: 'function_search', + label: 'Search', + description: 'run the search query', + }, + ] : []), - ...(await options.suggestions.value(lastToken.value, { - field, - operatorCompare, - })).map(mapSuggestionCreatorValue), + ...( + await options.suggestions.value(lastToken.value, { + field, + operatorCompare, + }) + ).map(mapSuggestionCreatorValue), ...Object.entries(language.tokens.conjunction.literal).map( - ([ conjunction, description]) => ({ + ([conjunction, description]) => ({ type: 'conjunction', label: conjunction, description, @@ -454,8 +479,10 @@ export async function getSuggestions(tokens: ITokens, options: OptionsQL): Promi conjunction => conjunction === lastToken.value, ) ? [ - ...(await options.suggestions.field()).map(mapSuggestionCreatorField), - ] + ...(await options.suggestions.field()).map( + mapSuggestionCreatorField, + ), + ] : []), { type: 'operator_group', @@ -493,16 +520,18 @@ export async function getSuggestions(tokens: ITokens, options: OptionsQL): Promi /** * Transform the suggestion object to the expected object by EuiSuggestItem - * @param param0 - * @returns + * @param param0 + * @returns */ -export function transformSuggestionToEuiSuggestItem(suggestion: QLOptionSuggestionEntityItemTyped): SuggestItem{ - const { type, ...rest} = suggestion; +export function transformSuggestionToEuiSuggestItem( + suggestion: QLOptionSuggestionEntityItemTyped, +): SuggestItem { + const { type, ...rest } = suggestion; return { type: { ...suggestionMappingLanguageTokenType[type] }, - ...rest + ...rest, }; -}; +} /** * Transform the suggestion object to the expected object by EuiSuggestItem @@ -510,21 +539,21 @@ export function transformSuggestionToEuiSuggestItem(suggestion: QLOptionSuggesti * @returns */ function transformSuggestionsToEuiSuggestItem( - suggestions: QLOptionSuggestionEntityItemTyped[] + suggestions: QLOptionSuggestionEntityItemTyped[], ): SuggestItem[] { return suggestions.map(transformSuggestionToEuiSuggestItem); -}; +} /** * Transform the UQL (Unified Query Language) to QL - * @param input - * @returns + * @param input + * @returns */ -export function transformUQLToQL(input: string){ +export function transformUQLToQL(input: string) { const tokens = tokenizerUQL(input); return tokens - .filter(({value}) => value) - .map(({type, value}) => { + .filter(({ value }) => value) + .map(({ type, value }) => { switch (type) { case 'conjunction': return transformQLConjunction(value); @@ -536,43 +565,52 @@ export function transformUQLToQL(input: string){ return value; break; } - } - ).join(''); -}; + }) + .join(''); +} -export function shouldUseSearchTerm(tokens: ITokens): boolean{ +export function shouldUseSearchTerm(tokens: ITokens): boolean { return !( - tokens.some(({type, value}) => type === 'operator_compare' && value ) - && tokens.some(({type, value}) => type === 'field' && value ) + tokens.some(({ type, value }) => type === 'operator_compare' && value) && + tokens.some(({ type, value }) => type === 'field' && value) ); -}; +} -export function transformToSearchTerm(searchTermFields: string[], input: string): string{ - return searchTermFields.map(searchTermField => `${searchTermField}~${input}`).join(','); -}; +export function transformToSearchTerm( + searchTermFields: string[], + input: string, +): string { + return searchTermFields + .map(searchTermField => `${searchTermField}~${input}`) + .join(','); +} /** * Transform the input in QL to UQL (Unified Query Language) - * @param input - * @returns + * @param input + * @returns */ -export function transformSpecificQLToUnifiedQL(input: string, searchTermFields: string[]){ +export function transformSpecificQLToUnifiedQL( + input: string, + searchTermFields: string[], +) { const tokens = tokenizer(input); - if(input && searchTermFields && shouldUseSearchTerm(tokens)){ + if (input && searchTermFields && shouldUseSearchTerm(tokens)) { return transformToSearchTerm(searchTermFields, input); - }; + } return tokens - .filter(({type, value}) => type !== 'whitespace' && value) - .map(({type, value}) => { + .filter(({ type, value }) => type !== 'whitespace' && value) + .map(({ type, value }) => { switch (type) { - case 'value':{ + case 'value': { // Value is wrapped with " - let [ _, extractedValue ] = value.match(/^"(.+)"$/) || [ null, null ]; + let [_, extractedValue] = value.match(/^"(.+)"$/) || [null, null]; // Replace the escaped comma (\") by comma (") // WARN: This could cause a problem with value that contains this sequence \" - extractedValue && (extractedValue = extractedValue.replace(/\\"/g, '"')); + extractedValue && + (extractedValue = extractedValue.replace(/\\"/g, '"')); return extractedValue || value; break; } @@ -585,9 +623,9 @@ export function transformSpecificQLToUnifiedQL(input: string, searchTermFields: return value; break; } - } - ).join(''); -}; + }) + .join(''); +} /** * Get the output from the input @@ -597,21 +635,20 @@ export function transformSpecificQLToUnifiedQL(input: string, searchTermFields: function getOutput(input: string, options: OptionsQL) { // Implicit query const implicitQueryAsUQL = options?.options?.implicitQuery?.query ?? ''; - const implicitQueryAsQL = transformUQLToQL( - implicitQueryAsUQL - ); + const implicitQueryAsQL = transformUQLToQL(implicitQueryAsUQL); // Implicit query conjunction - const implicitQueryConjunctionAsUQL = options?.options?.implicitQuery?.conjunction ?? ''; + const implicitQueryConjunctionAsUQL = + options?.options?.implicitQuery?.conjunction ?? ''; const implicitQueryConjunctionAsQL = transformUQLToQL( - implicitQueryConjunctionAsUQL + implicitQueryConjunctionAsUQL, ); // User input query const inputQueryAsQL = input; const inputQueryAsUQL = transformSpecificQLToUnifiedQL( inputQueryAsQL, - options?.options?.searchTermFields ?? [] + options?.options?.searchTermFields ?? [], ); return { @@ -619,203 +656,245 @@ function getOutput(input: string, options: OptionsQL) { apiQuery: { q: [ implicitQueryAsUQL, - implicitQueryAsUQL && inputQueryAsUQL ? implicitQueryConjunctionAsUQL : '', - implicitQueryAsUQL && inputQueryAsUQL ? `(${inputQueryAsUQL})`: inputQueryAsUQL + implicitQueryAsUQL && inputQueryAsUQL + ? implicitQueryConjunctionAsUQL + : '', + implicitQueryAsUQL && inputQueryAsUQL + ? `(${inputQueryAsUQL})` + : inputQueryAsUQL, ].join(''), }, query: [ implicitQueryAsQL, implicitQueryAsQL && inputQueryAsQL ? implicitQueryConjunctionAsQL : '', - implicitQueryAsQL && inputQueryAsQL ? `(${inputQueryAsQL})`: inputQueryAsQL - ].join('') + implicitQueryAsQL && inputQueryAsQL + ? `(${inputQueryAsQL})` + : inputQueryAsQL, + ].join(''), }; -}; +} /** * Validate the token value - * @param token - * @returns + * @param token + * @returns */ function validateTokenValue(token: IToken): string | undefined { - // Usage of API regular expression - const re = new RegExp( - // Value: A string. - '^(?(?:(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\/\'"=@%<>{}]*)\\))*' + - '(?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]+)' + - '(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]*)\\))*)+)$' - ); - - /* WARN: the validation for the value token is complex, this supports some characters in - certain circumstances. - - Ideally a character validation helps to the user to identify the problem in the query, but - as the original regular expression is so complex, the logic to get this can be - complicated. - - The original regular expression has a common schema of allowed characters, these and other - characters of the original regular expression can be used to check each character. This - approach can identify some invalid characters despite this is not the ideal way. - - The ideal solution will be check each subset of the complex regex against the allowed - characters. - */ - - const invalidCharacters: string[] = token.value.split('') - .filter((character) => !(new RegExp('[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}\\(\\)]').test(character))) - .filter((value, index, array) => array.indexOf(value) === index); + const invalidCharacters: string[] = token.value + .split('') + .filter((value, index, array) => array.indexOf(value) === index) + .filter( + character => + !new RegExp('[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}\\(\\)]').test( + character, + ), + ); - const match = token.value.match(re); - return match?.groups?.value === token.value + return invalidCharacters.length ? undefined : [ - `"${token.value}" is not a valid value.`, - ...(invalidCharacters.length - ? [`Invalid characters found: ${invalidCharacters.join(', ')}`] - : [] - ) - ].join(' '); -}; + `"${token.value}" is not a valid value.`, + ...(invalidCharacters.length + ? [`Invalid characters found: ${invalidCharacters.join(', ')}`] + : []), + ].join(' '); +} -type ITokenValidator = (tokenValue: IToken, proximityTokens: any) => string | undefined; +type ITokenValidator = ( + tokenValue: IToken, + proximityTokens: any, +) => string | undefined; /** * Validate the tokens while the user is building the query - * @param tokens - * @param validate - * @returns + * @param tokens + * @param validate + * @returns */ -function validatePartial(tokens: ITokens, validate: {field: ITokenValidator, value: ITokenValidator}): undefined | string{ +function validatePartial( + tokens: ITokens, + validate: { field: ITokenValidator; value: ITokenValidator }, +): undefined | string { // Ensure is not in search term mode - if (!shouldUseSearchTerm(tokens)){ - return tokens.map((token: IToken, index) => { - if(token.value){ - if(token.type === 'field'){ - // Ensure there is a operator next to field to check if the fields is valid or not. - // This allows the user can type the field token and get the suggestions for the field. - const tokenOperatorNearToField = getTokenNearTo( - tokens, - 'operator_compare', - 'next', - { tokenReferencePosition: index, tokenFoundShouldHaveValue: true } - ); - return tokenOperatorNearToField - ? validate.field(token) - : undefined; - }; - // Check if the value is allowed - if(token.type === 'value'){ - const tokenFieldNearToValue = getTokenNearTo( - tokens, - 'field', - 'previous', - { tokenReferencePosition: index, tokenFoundShouldHaveValue: true } - ); - const tokenOperatorCompareNearToValue = getTokenNearTo( - tokens, - 'operator_compare', - 'previous', - { tokenReferencePosition: index, tokenFoundShouldHaveValue: true } - ); - return validateTokenValue(token) - || (tokenFieldNearToValue && tokenOperatorCompareNearToValue && validate.value ? validate.value(token, { - field: tokenFieldNearToValue?.value, - operator: tokenOperatorCompareNearToValue?.value - }) : undefined); - } - }; - }) - .filter(t => typeof t !== 'undefined') - .join('\n') || undefined; + if (!shouldUseSearchTerm(tokens)) { + return ( + tokens + .map((token: IToken, index) => { + if (token.value) { + if (token.type === 'field') { + // Ensure there is a operator next to field to check if the fields is valid or not. + // This allows the user can type the field token and get the suggestions for the field. + const tokenOperatorNearToField = getTokenNearTo( + tokens, + 'operator_compare', + 'next', + { + tokenReferencePosition: index, + tokenFoundShouldHaveValue: true, + }, + ); + return tokenOperatorNearToField + ? validate.field(token) + : undefined; + } + // Check if the value is allowed + if (token.type === 'value') { + const tokenFieldNearToValue = getTokenNearTo( + tokens, + 'field', + 'previous', + { + tokenReferencePosition: index, + tokenFoundShouldHaveValue: true, + }, + ); + const tokenOperatorCompareNearToValue = getTokenNearTo( + tokens, + 'operator_compare', + 'previous', + { + tokenReferencePosition: index, + tokenFoundShouldHaveValue: true, + }, + ); + return ( + validateTokenValue(token) || + (tokenFieldNearToValue && + tokenOperatorCompareNearToValue && + validate.value + ? validate.value(token, { + field: tokenFieldNearToValue?.value, + operator: tokenOperatorCompareNearToValue?.value, + }) + : undefined) + ); + } + } + }) + .filter(t => typeof t !== 'undefined') + .join('\n') || undefined + ); } -}; +} /** - * Validate the tokens if they are a valid syntax - * @param tokens - * @param validate - * @returns + * Validate the tokens if they are a valid syntax + * @param tokens + * @param validate + * @returns */ -function validate(tokens: ITokens, validate: {field: ITokenValidator, value: ITokenValidator}): undefined | string[]{ - if (!shouldUseSearchTerm(tokens)){ - const errors = tokens.map((token: IToken, index) => { - const errors = []; - if(token.value){ - if(token.type === 'field'){ - const tokenOperatorNearToField = getTokenNearTo( - tokens, - 'operator_compare', - 'next', - { tokenReferencePosition: index, tokenFoundShouldHaveValue: true } - ); - const tokenValueNearToField = getTokenNearTo( - tokens, - 'value', - 'next', - { tokenReferencePosition: index, tokenFoundShouldHaveValue: true } - ); - if(validate.field(token)){ - errors.push(`"${token.value}" is not a valid field.`); - }else if(!tokenOperatorNearToField){ - errors.push(`The operator for field "${token.value}" is missing.`); - }else if(!tokenValueNearToField){ - errors.push(`The value for field "${token.value}" is missing.`); +function validate( + tokens: ITokens, + validate: { field: ITokenValidator; value: ITokenValidator }, +): undefined | string[] { + if (!shouldUseSearchTerm(tokens)) { + const errors = tokens + .map((token: IToken, index) => { + const errors = []; + if (token.value) { + if (token.type === 'field') { + const tokenOperatorNearToField = getTokenNearTo( + tokens, + 'operator_compare', + 'next', + { + tokenReferencePosition: index, + tokenFoundShouldHaveValue: true, + }, + ); + const tokenValueNearToField = getTokenNearTo( + tokens, + 'value', + 'next', + { + tokenReferencePosition: index, + tokenFoundShouldHaveValue: true, + }, + ); + if (validate.field(token)) { + errors.push(`"${token.value}" is not a valid field.`); + } else if (!tokenOperatorNearToField) { + errors.push( + `The operator for field "${token.value}" is missing.`, + ); + } else if (!tokenValueNearToField) { + errors.push(`The value for field "${token.value}" is missing.`); + } } - }; - // Check if the value is allowed - if(token.type === 'value'){ - const tokenFieldNearToValue = getTokenNearTo( - tokens, - 'field', - 'previous', - { tokenReferencePosition: index, tokenFoundShouldHaveValue: true } - ); - const tokenOperatorCompareNearToValue = getTokenNearTo( - tokens, - 'operator_compare', - 'previous', - { tokenReferencePosition: index, tokenFoundShouldHaveValue: true } - ); - const validationError = validateTokenValue(token) - || (tokenFieldNearToValue && tokenOperatorCompareNearToValue && validate.value ? validate.value(token, { - field: tokenFieldNearToValue?.value, - operator: tokenOperatorCompareNearToValue?.value - }) : undefined);; - - validationError && errors.push(validationError); - }; - - // Check if the value is allowed - if(token.type === 'conjunction'){ - - const tokenWhitespaceNearToFieldNext = getTokenNearTo( - tokens, - 'whitespace', - 'next', - { tokenReferencePosition: index } - ); - const tokenFieldNearToFieldNext = getTokenNearTo( - tokens, - 'field', - 'next', - { tokenReferencePosition: index, tokenFoundShouldHaveValue: true } - ); - !tokenWhitespaceNearToFieldNext?.value?.length - && errors.push(`There is no whitespace after conjunction "${token.value}".`); - !tokenFieldNearToFieldNext?.value?.length - && errors.push(`There is no sentence after conjunction "${token.value}".`); - }; - }; - return errors.length ? errors : undefined; - }).filter(errors => errors) - .flat() + // Check if the value is allowed + if (token.type === 'value') { + const tokenFieldNearToValue = getTokenNearTo( + tokens, + 'field', + 'previous', + { + tokenReferencePosition: index, + tokenFoundShouldHaveValue: true, + }, + ); + const tokenOperatorCompareNearToValue = getTokenNearTo( + tokens, + 'operator_compare', + 'previous', + { + tokenReferencePosition: index, + tokenFoundShouldHaveValue: true, + }, + ); + const validationError = + validateTokenValue(token) || + (tokenFieldNearToValue && + tokenOperatorCompareNearToValue && + validate.value + ? validate.value(token, { + field: tokenFieldNearToValue?.value, + operator: tokenOperatorCompareNearToValue?.value, + }) + : undefined); + + validationError && errors.push(validationError); + } + + // Check if the value is allowed + if (token.type === 'conjunction') { + const tokenWhitespaceNearToFieldNext = getTokenNearTo( + tokens, + 'whitespace', + 'next', + { tokenReferencePosition: index }, + ); + const tokenFieldNearToFieldNext = getTokenNearTo( + tokens, + 'field', + 'next', + { + tokenReferencePosition: index, + tokenFoundShouldHaveValue: true, + }, + ); + !tokenWhitespaceNearToFieldNext?.value?.length && + errors.push( + `There is no whitespace after conjunction "${token.value}".`, + ); + !tokenFieldNearToFieldNext?.value?.length && + errors.push( + `There is no sentence after conjunction "${token.value}".`, + ); + } + } + return errors.length ? errors : undefined; + }) + .filter(errors => errors) + .flat(); return errors.length ? errors : undefined; - }; + } return undefined; -}; +} export const WQL = { id: 'wql', label: 'WQL', - description: 'WQL (Wazuh Query Language) provides a human query syntax based on the Wazuh API query language.', + description: + 'WQL (Wazuh Query Language) provides a human query syntax based on the Wazuh API query language.', documentationLink: `https://github.com/wazuh/wazuh-kibana-app/blob/v${PLUGIN_VERSION_SHORT}/public/components/search-bar/query-language/wql.md`, getConfiguration() { return { @@ -827,21 +906,29 @@ export const WQL = { const tokens: ITokens = tokenizer(input); // Get the implicit query as query language syntax - const implicitQueryAsQL = params.queryLanguage.parameters?.options?.implicitQuery + const implicitQueryAsQL = params.queryLanguage.parameters?.options + ?.implicitQuery ? transformUQLToQL( - params.queryLanguage.parameters.options.implicitQuery.query - + params.queryLanguage.parameters.options.implicitQuery.conjunction - ) + params.queryLanguage.parameters.options.implicitQuery.query + + params.queryLanguage.parameters.options.implicitQuery.conjunction, + ) : ''; - const fieldsSuggestion: string[] = await params.queryLanguage.parameters.suggestions.field() - .map(({label}) => label); + const fieldsSuggestion: string[] = + await params.queryLanguage.parameters.suggestions + .field() + .map(({ label }) => label); const validators = { - field: ({value}) => fieldsSuggestion.includes(value) ? undefined : `"${value}" is not valid field.`, - ...(params.queryLanguage.parameters?.validate?.value ? { - value: params.queryLanguage.parameters?.validate?.value - } : {}) + field: ({ value }) => + fieldsSuggestion.includes(value) + ? undefined + : `"${value}" is not valid field.`, + ...(params.queryLanguage.parameters?.validate?.value + ? { + value: params.queryLanguage.parameters?.validate?.value, + } + : {}), }; // Validate the user input @@ -852,63 +939,79 @@ export const WQL = { // Get the output of query language const output = { ...getOutput(input, params.queryLanguage.parameters), - error: validationStrict + error: validationStrict, }; const onSearch = () => { - if(output?.error){ - params.setQueryLanguageOutput((state) => ({ + if (output?.error) { + params.setQueryLanguageOutput(state => ({ ...state, searchBarProps: { ...state.searchBarProps, suggestions: transformSuggestionsToEuiSuggestItem( - output.error.map(error => ({type: 'validation_error', label: 'Invalid', description: error})) - ) - } + output.error.map(error => ({ + type: 'validation_error', + label: 'Invalid', + description: error, + })), + ), + }, })); - }else{ + } else { params.onSearch(output); - }; + } }; return { - filterButtons: params.queryLanguage.parameters?.options?.filterButtons - ? ( - { id, label } - ))} + filterButtons: params.queryLanguage.parameters?.options?.filterButtons ? ( + ({ id, label }), + )} idToSelectedMap={{}} - type="multi" + type='multi' onChange={(id: string) => { - const buttonParams = params.queryLanguage.parameters?.options?.filterButtons.find(({id: buttonID}) => buttonID === id); - if(buttonParams){ + const buttonParams = + params.queryLanguage.parameters?.options?.filterButtons.find( + ({ id: buttonID }) => buttonID === id, + ); + if (buttonParams) { params.setInput(buttonParams.input); const output = { - ...getOutput(buttonParams.input, params.queryLanguage.parameters), - error: undefined + ...getOutput( + buttonParams.input, + params.queryLanguage.parameters, + ), + error: undefined, }; params.onSearch(output); } }} /> - : null, + ) : null, searchBarProps: { // Props that will be used by the EuiSuggest component // Suggestions suggestions: transformSuggestionsToEuiSuggestItem( validationPartial - ? [{ type: 'validation_error', label: 'Invalid', description: validationPartial}] - : await getSuggestions(tokens, params.queryLanguage.parameters) + ? [ + { + type: 'validation_error', + label: 'Invalid', + description: validationPartial, + }, + ] + : await getSuggestions(tokens, params.queryLanguage.parameters), ), // Handler to manage when clicking in a suggestion item onItemClick: item => { // There is an error, clicking on the item does nothing - if (item.type.iconType === 'alert'){ + if (item.type.iconType === 'alert') { return; - }; + } // When the clicked item has the `search` iconType, run the `onSearch` function if (item.type.iconType === 'search') { // Execute the search action @@ -918,51 +1021,58 @@ export const WQL = { const lastToken: IToken | undefined = getLastTokenDefined(tokens); // if the clicked suggestion is of same type of last token if ( - lastToken && suggestionMappingLanguageTokenType[lastToken.type].iconType === - item.type.iconType + lastToken && + suggestionMappingLanguageTokenType[lastToken.type].iconType === + item.type.iconType ) { // replace the value of last token with the current one. // if the current token is a value, then transform it - lastToken.value = item.type.iconType === suggestionMappingLanguageTokenType.value.iconType - ? transformQLValue(item.label) - : item.label; + lastToken.value = + item.type.iconType === + suggestionMappingLanguageTokenType.value.iconType + ? transformQLValue(item.label) + : item.label; } else { // add a whitespace for conjunction - !(/\s$/.test(input)) - && ( - item.type.iconType === suggestionMappingLanguageTokenType.conjunction.iconType - || lastToken?.type === 'conjunction' - ) - && tokens.push({ - type: 'whitespace', - value: ' ' - }); + !/\s$/.test(input) && + (item.type.iconType === + suggestionMappingLanguageTokenType.conjunction.iconType || + lastToken?.type === 'conjunction') && + tokens.push({ + type: 'whitespace', + value: ' ', + }); // add a new token of the selected type and value tokens.push({ type: Object.entries(suggestionMappingLanguageTokenType).find( ([, { iconType }]) => iconType === item.type.iconType, )[0], - value: item.type.iconType === suggestionMappingLanguageTokenType.value.iconType - ? transformQLValue(item.label) - : item.label, + value: + item.type.iconType === + suggestionMappingLanguageTokenType.value.iconType + ? transformQLValue(item.label) + : item.label, }); // add a whitespace for conjunction - item.type.iconType === suggestionMappingLanguageTokenType.conjunction.iconType - && tokens.push({ - type: 'whitespace', - value: ' ' - }); - }; + item.type.iconType === + suggestionMappingLanguageTokenType.conjunction.iconType && + tokens.push({ + type: 'whitespace', + value: ' ', + }); + } // Change the input - params.setInput(tokens - .filter(value => value) // Ensure the input is rebuilt using tokens with value. - // The input tokenization can contain tokens with no value due to the used - // regular expression. - .map(({ value }) => value) - .join('')); + params.setInput( + tokens + .filter(value => value) // Ensure the input is rebuilt using tokens with value. + // The input tokenization can contain tokens with no value due to the used + // regular expression. + .map(({ value }) => value) + .join(''), + ); } }, prepend: implicitQueryAsQL ? ( @@ -978,9 +1088,7 @@ export const WQL = { } iconType='filter' > - - {implicitQueryAsQL} - + {implicitQueryAsQL} } isOpen={ @@ -994,8 +1102,7 @@ export const WQL = { } > - Implicit query:{' '} - {implicitQueryAsQL} + Implicit query: {implicitQueryAsQL} This query is added to the input. @@ -1008,22 +1115,25 @@ export const WQL = { // Show the input is invalid isInvalid: Boolean(validationStrict), // Define the handler when the a key is pressed while the input is focused - onKeyPress: (event) => { + onKeyPress: event => { if (event.key === 'Enter') { onSearch(); - }; - } + } + }, }, - output + output, }; }, - transformInput: (unifiedQuery: string, {parameters}) => { - const input = unifiedQuery && parameters?.options?.implicitQuery - ? unifiedQuery.replace( - new RegExp(`^${parameters.options.implicitQuery.query}${parameters.options.implicitQuery.conjunction}`), - '' - ) - : unifiedQuery; + transformInput: (unifiedQuery: string, { parameters }) => { + const input = + unifiedQuery && parameters?.options?.implicitQuery + ? unifiedQuery.replace( + new RegExp( + `^${parameters.options.implicitQuery.query}${parameters.options.implicitQuery.conjunction}`, + ), + '', + ) + : unifiedQuery; return transformUQLToQL(input); }, From 3502d0b1f341b9739e5db9cf9907943a3554cfce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Tue, 1 Aug 2023 13:49:05 +0200 Subject: [PATCH 32/37] fix: fix token value validation --- .../main/public/components/search-bar/query-language/wql.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/main/public/components/search-bar/query-language/wql.tsx b/plugins/main/public/components/search-bar/query-language/wql.tsx index 8830ff0175..a8f136b3ff 100644 --- a/plugins/main/public/components/search-bar/query-language/wql.tsx +++ b/plugins/main/public/components/search-bar/query-language/wql.tsx @@ -690,7 +690,7 @@ function validateTokenValue(token: IToken): string | undefined { ), ); - return invalidCharacters.length + return !invalidCharacters.length ? undefined : [ `"${token.value}" is not a valid value.`, From bc26f9ae982f511b60a3d2cfa1a1a9e423da7e48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Wed, 2 Aug 2023 13:53:21 +0200 Subject: [PATCH 33/37] fix: update the link to the documentation of WQL --- .../main/public/components/search-bar/query-language/wql.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/main/public/components/search-bar/query-language/wql.tsx b/plugins/main/public/components/search-bar/query-language/wql.tsx index a8f136b3ff..067ac6bf20 100644 --- a/plugins/main/public/components/search-bar/query-language/wql.tsx +++ b/plugins/main/public/components/search-bar/query-language/wql.tsx @@ -7,7 +7,7 @@ import { EuiCode, } from '@elastic/eui'; import { tokenizer as tokenizerUQL } from './aql'; -import { PLUGIN_VERSION_SHORT } from '../../../../common/constants'; +import { PLUGIN_VERSION } from '../../../../common/constants'; /* UI Query language https://documentation.wazuh.com/current/user-manual/api/queries.html @@ -895,7 +895,7 @@ export const WQL = { label: 'WQL', description: 'WQL (Wazuh Query Language) provides a human query syntax based on the Wazuh API query language.', - documentationLink: `https://github.com/wazuh/wazuh-kibana-app/blob/v${PLUGIN_VERSION_SHORT}/public/components/search-bar/query-language/wql.md`, + documentationLink: `https://github.com/wazuh/wazuh-kibana-app/blob/v${PLUGIN_VERSION}/plugins/main/public/components/search-bar/query-language/wql.md`, getConfiguration() { return { isOpenPopoverImplicitFilter: false, From cd89aa5e30e62681a7a537026c36c710b93514aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Wed, 2 Aug 2023 13:55:13 +0200 Subject: [PATCH 34/37] fix(search-bar): use value of value token as the value used to get the value suggestions in the search bar instead of raw token that could include " character --- .../public/components/search-bar/query-language/wql.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/main/public/components/search-bar/query-language/wql.tsx b/plugins/main/public/components/search-bar/query-language/wql.tsx index 067ac6bf20..db85ca55b2 100644 --- a/plugins/main/public/components/search-bar/query-language/wql.tsx +++ b/plugins/main/public/components/search-bar/query-language/wql.tsx @@ -189,7 +189,7 @@ export function tokenizer(input: string): ITokens { value, ...(key === 'value' && value && /^"(.+)"$/.test(value) ? { formattedValue: value.match(/^"(.+)"$/)[1] } - : {}), + : { formattedValue: value }), })), ) .flat(); @@ -431,7 +431,7 @@ export async function getSuggestions( } return [ - ...(lastToken.value + ...(lastToken.formattedValue ? [ { type: 'function_search', @@ -441,7 +441,7 @@ export async function getSuggestions( ] : []), ...( - await options.suggestions.value(lastToken.value, { + await options.suggestions.value(lastToken.formattedValue, { field, operatorCompare, }) From d961094e5a63451158057d3d7e62d230610c2ed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Thu, 3 Aug 2023 12:34:46 +0200 Subject: [PATCH 35/37] fix(search-bar): fix a problem extracting value for value tokens wrapped by double quotation marks that contains the new line character and remove separation of invalid characters in the value token - Fix tests --- .../search-bar/query-language/wql.test.tsx | 655 +++++++++--------- .../search-bar/query-language/wql.tsx | 29 +- 2 files changed, 358 insertions(+), 326 deletions(-) diff --git a/plugins/main/public/components/search-bar/query-language/wql.test.tsx b/plugins/main/public/components/search-bar/query-language/wql.test.tsx index bfe284b03d..5cdecb968b 100644 --- a/plugins/main/public/components/search-bar/query-language/wql.test.tsx +++ b/plugins/main/public/components/search-bar/query-language/wql.test.tsx @@ -1,4 +1,9 @@ -import { getSuggestions, tokenizer, transformSpecificQLToUnifiedQL, WQL } from './wql'; +import { + getSuggestions, + tokenizer, + transformSpecificQLToUnifiedQL, + WQL, +} from './wql'; import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { SearchBar } from '../index'; @@ -13,34 +18,32 @@ describe('SearchBar component', () => { options: { implicitQuery: { query: 'id!=000', - conjunction: ';' + conjunction: ';', }, }, suggestions: { field(currentValue) { return []; }, - value(currentValue, { field }){ + value(currentValue, { field }) { return []; }, }, - } + }, ], /* eslint-disable @typescript-eslint/no-empty-function */ onChange: () => {}, - onSearch: () => {} + onSearch: () => {}, /* eslint-enable @typescript-eslint/no-empty-function */ }; it('Renders correctly to match the snapshot of query language', async () => { - const wrapper = render( - - ); + const wrapper = render(); await waitFor(() => { - const elementImplicitQuery = wrapper.container.querySelector('.euiCodeBlock__code'); + const elementImplicitQuery = wrapper.container.querySelector( + '.euiCodeBlock__code', + ); expect(elementImplicitQuery?.innerHTML).toEqual('id!=000 and '); expect(wrapper.container).toMatchSnapshot(); }); @@ -50,27 +53,40 @@ describe('SearchBar component', () => { /* eslint-disable max-len */ describe('Query language - WQL', () => { // Tokenize the input - function tokenCreator({type, value, formattedValue}){ - return {type, value, ...(formattedValue ? { formattedValue } : {})}; - }; + function tokenCreator({ type, value, formattedValue }) { + return { type, value, ...(formattedValue ? { formattedValue } : {}) }; + } const t = { - opGroup: (value = undefined) => tokenCreator({type: 'operator_group', value}), - opCompare: (value = undefined) => tokenCreator({type: 'operator_compare', value}), - field: (value = undefined) => tokenCreator({type: 'field', value}), - value: (value = undefined, formattedValue = undefined) => tokenCreator({type: 'value', value, formattedValue}), - whitespace: (value = undefined) => tokenCreator({type: 'whitespace', value}), - conjunction: (value = undefined) => tokenCreator({type: 'conjunction', value}) + opGroup: (value = undefined) => + tokenCreator({ type: 'operator_group', value }), + opCompare: (value = undefined) => + tokenCreator({ type: 'operator_compare', value }), + field: (value = undefined) => tokenCreator({ type: 'field', value }), + value: (value = undefined, formattedValue = undefined) => + tokenCreator({ + type: 'value', + value, + formattedValue: formattedValue ?? value, + }), + whitespace: (value = undefined) => + tokenCreator({ type: 'whitespace', value }), + conjunction: (value = undefined) => + tokenCreator({ type: 'conjunction', value }), }; // Token undefined const tu = { - opGroup: tokenCreator({type: 'operator_group', value: undefined}), - opCompare: tokenCreator({type: 'operator_compare', value: undefined}), - whitespace: tokenCreator({type: 'whitespace', value: undefined}), - field: tokenCreator({type: 'field', value: undefined}), - value: tokenCreator({type: 'value', value: undefined}), - conjunction: tokenCreator({type: 'conjunction', value: undefined}) + opGroup: tokenCreator({ type: 'operator_group', value: undefined }), + opCompare: tokenCreator({ type: 'operator_compare', value: undefined }), + whitespace: tokenCreator({ type: 'whitespace', value: undefined }), + field: tokenCreator({ type: 'field', value: undefined }), + value: tokenCreator({ + type: 'value', + value: undefined, + formattedValue: undefined, + }), + conjunction: tokenCreator({ type: 'conjunction', value: undefined }), }; const tuBlankSerie = [ @@ -85,58 +101,57 @@ describe('Query language - WQL', () => { tu.opGroup, tu.whitespace, tu.conjunction, - tu.whitespace + tu.whitespace, ]; - it.each` - input | tokens - ${''} | ${tuBlankSerie} - ${'f'} | ${[tu.opGroup, tu.whitespace, t.field('f'), tu.whitespace, tu.opCompare, tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, tu.opCompare, tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field='} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field=value'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field=and'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('and'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field=or'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('or'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field=valueand'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('valueand'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field=valueor'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('valueor'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field=value='} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value='), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field=value!='} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value!='), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field=value>'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value>'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field=value<'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value<'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field=value~'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value~'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie] /** ~ character is not supported as value in the q query parameter */} - ${'field="'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field="value and'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value and'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field="value and value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value and value2"', 'value and value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field="value or value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value or value2"', 'value or value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field="value = value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value = value2"', 'value = value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field="value != value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value != value2"', 'value != value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field="value > value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value > value2"', 'value > value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field="value < value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value < value2"', 'value < value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field="value ~ value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value ~ value2"', 'value ~ value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie] /** ~ character is not supported as value in the q query parameter */} - ${'field=value and'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), tu.whitespace, ...tuBlankSerie]} - ${'field=value and '} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), ...tuBlankSerie]} - ${'field=value and field2'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, tu.opCompare, tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field=value and field2!='} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field=value and field2!="'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, t.value('"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field=value and field2!="value'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, t.value('"value'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field=value and field2!="value"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, t.value('"value"', 'value'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} - ${'field=value and field2!=value2 and'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, t.value('value2'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), tu.whitespace, ...tuBlankSerie]} - `(`Tokenizer API input $input`, ({input, tokens}) => { + input | tokens + ${''} | ${tuBlankSerie} + ${'f'} | ${[tu.opGroup, tu.whitespace, t.field('f'), tu.whitespace, tu.opCompare, tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, tu.opCompare, tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field='} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=and'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('and'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=or'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('or'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=valueand'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('valueand'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=valueor'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('valueor'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value='} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value='), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value!='} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value!='), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value>'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value>'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value<'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value<'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value~'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value~'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie] /** ~ character is not supported as value in the q query parameter */} + ${'field="'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value and'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value and'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value and value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value and value2"', 'value and value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value or value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value or value2"', 'value or value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value = value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value = value2"', 'value = value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value != value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value != value2"', 'value != value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value > value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value > value2"', 'value > value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value < value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value < value2"', 'value < value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value ~ value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value ~ value2"', 'value ~ value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie] /** ~ character is not supported as value in the q query parameter */} + ${'field=value and'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), tu.whitespace, ...tuBlankSerie]} + ${'field=value and '} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), ...tuBlankSerie]} + ${'field=value and field2'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, tu.opCompare, tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value and field2!='} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value and field2!="'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, t.value('"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value and field2!="value'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, t.value('"value'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value and field2!="value"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, t.value('"value"', 'value'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value and field2!=value2 and'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, t.value('value2'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), tu.whitespace, ...tuBlankSerie]} + `(`Tokenizer API input $input`, ({ input, tokens }) => { expect(tokenizer(input)).toEqual(tokens); }); // Get suggestions it.each` - input | suggestions - ${''} | ${[{ description: 'run the search query', label: 'Search', type: 'function_search' }, { description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }, { description: 'open group', label: '(', type: 'operator_group' }]} - ${'w'} | ${[]} - ${'f'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }]} - ${'field'} | ${[{ description: 'Field2', label: 'field2', type: 'field' }, { description: 'equality', label: '=', type: 'operator_compare' }, { description: 'not equality', label: '!=', type: 'operator_compare' }, { description: 'bigger', label: '>', type: 'operator_compare' }, { description: 'smaller', label: '<', type: 'operator_compare' }, { description: 'like as', label: '~', type: 'operator_compare' }]} - ${'field='} | ${[{ label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }]} - ${'field=v'} | ${[{ description: 'run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: 'and', type: 'conjunction' }, { description: 'or', label: 'or', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} - ${'field=value'} | ${[{ description: 'run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: 'and', type: 'conjunction' }, { description: 'or', label: 'or', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} - ${'field=value and'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }, { description: 'open group', label: '(', type: 'operator_group' }]} + input | suggestions + ${''} | ${[{ description: 'run the search query', label: 'Search', type: 'function_search' }, { description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }, { description: 'open group', label: '(', type: 'operator_group' }]} + ${'w'} | ${[]} + ${'f'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }]} + ${'field'} | ${[{ description: 'Field2', label: 'field2', type: 'field' }, { description: 'equality', label: '=', type: 'operator_compare' }, { description: 'not equality', label: '!=', type: 'operator_compare' }, { description: 'bigger', label: '>', type: 'operator_compare' }, { description: 'smaller', label: '<', type: 'operator_compare' }, { description: 'like as', label: '~', type: 'operator_compare' }]} + ${'field='} | ${[{ label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }]} + ${'field=v'} | ${[{ description: 'run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: 'and', type: 'conjunction' }, { description: 'or', label: 'or', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=value'} | ${[{ description: 'run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: 'and', type: 'conjunction' }, { description: 'or', label: 'or', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=value and'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }, { description: 'open group', label: '(', type: 'operator_group' }]} `('Get suggestion from the input: $input', async ({ input, suggestions }) => { expect( await getSuggestions(tokenizer(input), { @@ -176,267 +191,279 @@ describe('Query language - WQL', () => { // Transform specific query language to UQL (Unified Query Language) it.each` - WQL | UQL - ${'field'} | ${'field'} - ${'field='} | ${'field='} - ${'field=value'} | ${'field=value'} - ${'field=value()'} | ${'field=value()'} - ${'field=valueand'} | ${'field=valueand'} - ${'field=valueor'} | ${'field=valueor'} - ${'field=value='} | ${'field=value='} - ${'field=value!='} | ${'field=value!='} - ${'field=value>'} | ${'field=value>'} - ${'field=value<'} | ${'field=value<'} - ${'field=value~'} | ${'field=value~' /** ~ character is not supported as value in the q query parameter */} - ${'field="custom value"'} | ${'field=custom value'} - ${'field="custom value()"'} | ${'field=custom value()'} - ${'field="value and value2"'} | ${'field=value and value2'} - ${'field="value or value2"'} | ${'field=value or value2'} - ${'field="value = value2"'} | ${'field=value = value2'} - ${'field="value != value2"'} | ${'field=value != value2'} - ${'field="value > value2"'} | ${'field=value > value2'} - ${'field="value < value2"'} | ${'field=value < value2'} - ${'field="value ~ value2"'} | ${'field=value ~ value2' /** ~ character is not supported as value in the q query parameter */} - ${'field="custom \\"value"'} | ${'field=custom "value'} - ${'field="custom \\"value\\""'} | ${'field=custom "value"'} - ${'field=value and'} | ${'field=value;'} - ${'field="custom value" and'} | ${'field=custom value;'} - ${'(field=value'} | ${'(field=value'} - ${'(field=value)'} | ${'(field=value)'} - ${'(field=value) and'} | ${'(field=value);'} - ${'(field=value) and field2'} | ${'(field=value);field2'} - ${'(field=value) and field2>'} | ${'(field=value);field2>'} - ${'(field=value) and field2>"wrappedcommas"'} | ${'(field=value);field2>wrappedcommas'} - ${'(field=value) and field2>"value with spaces"'} | ${'(field=value);field2>value with spaces'} - ${'field ='} | ${'field='} - ${'field = value'} | ${'field=value'} - ${'field = value()'} | ${'field=value()'} - ${'field = valueand'} | ${'field=valueand'} - ${'field = valueor'} | ${'field=valueor'} - ${'field = value='} | ${'field=value='} - ${'field = value!='} | ${'field=value!='} - ${'field = value>'} | ${'field=value>'} - ${'field = value<'} | ${'field=value<'} - ${'field = value~'} | ${'field=value~' /** ~ character is not supported as value in the q query parameter */} - ${'field = "custom value"'} | ${'field=custom value'} - ${'field = "custom value()"'} | ${'field=custom value()'} - ${'field = "value and value2"'} | ${'field=value and value2'} - ${'field = "value or value2"'} | ${'field=value or value2'} - ${'field = "value = value2"'} | ${'field=value = value2'} - ${'field = "value != value2"'} | ${'field=value != value2'} - ${'field = "value > value2"'} | ${'field=value > value2'} - ${'field = "value < value2"'} | ${'field=value < value2'} - ${'field = "value ~ value2"'} | ${'field=value ~ value2' /** ~ character is not supported as value in the q query parameter */} - ${'field = value or'} | ${'field=value,'} - ${'field = value or field2'} | ${'field=value,field2'} - ${'field = value or field2 <'} | ${'field=value,field2<'} - ${'field = value or field2 < value2'} | ${'field=value,field2 "custom value" '} | ${'(field=value);field2>custom value'} - `('transformSpecificQLToUnifiedQL - WQL $WQL TO UQL $UQL', ({WQL, UQL}) => { + WQL | UQL + ${'field'} | ${'field'} + ${'field='} | ${'field='} + ${'field=value'} | ${'field=value'} + ${'field=value()'} | ${'field=value()'} + ${'field=valueand'} | ${'field=valueand'} + ${'field=valueor'} | ${'field=valueor'} + ${'field=value='} | ${'field=value='} + ${'field=value!='} | ${'field=value!='} + ${'field=value>'} | ${'field=value>'} + ${'field=value<'} | ${'field=value<'} + ${'field=value~'} | ${'field=value~' /** ~ character is not supported as value in the q query parameter */} + ${'field="custom value"'} | ${'field=custom value'} + ${'field="custom value()"'} | ${'field=custom value()'} + ${'field="value and value2"'} | ${'field=value and value2'} + ${'field="value or value2"'} | ${'field=value or value2'} + ${'field="value = value2"'} | ${'field=value = value2'} + ${'field="value != value2"'} | ${'field=value != value2'} + ${'field="value > value2"'} | ${'field=value > value2'} + ${'field="value < value2"'} | ${'field=value < value2'} + ${'field="value ~ value2"'} | ${'field=value ~ value2' /** ~ character is not supported as value in the q query parameter */} + ${'field="custom \\"value"'} | ${'field=custom "value'} + ${'field="custom \\"value\\""'} | ${'field=custom "value"'} + ${'field=value and'} | ${'field=value;'} + ${'field="custom value" and'} | ${'field=custom value;'} + ${'(field=value'} | ${'(field=value'} + ${'(field=value)'} | ${'(field=value)'} + ${'(field=value) and'} | ${'(field=value);'} + ${'(field=value) and field2'} | ${'(field=value);field2'} + ${'(field=value) and field2>'} | ${'(field=value);field2>'} + ${'(field=value) and field2>"wrappedcommas"'} | ${'(field=value);field2>wrappedcommas'} + ${'(field=value) and field2>"value with spaces"'} | ${'(field=value);field2>value with spaces'} + ${'field ='} | ${'field='} + ${'field = value'} | ${'field=value'} + ${'field = value()'} | ${'field=value()'} + ${'field = valueand'} | ${'field=valueand'} + ${'field = valueor'} | ${'field=valueor'} + ${'field = value='} | ${'field=value='} + ${'field = value!='} | ${'field=value!='} + ${'field = value>'} | ${'field=value>'} + ${'field = value<'} | ${'field=value<'} + ${'field = value~'} | ${'field=value~' /** ~ character is not supported as value in the q query parameter */} + ${'field = "custom value"'} | ${'field=custom value'} + ${'field = "custom value()"'} | ${'field=custom value()'} + ${'field = "value and value2"'} | ${'field=value and value2'} + ${'field = "value or value2"'} | ${'field=value or value2'} + ${'field = "value = value2"'} | ${'field=value = value2'} + ${'field = "value != value2"'} | ${'field=value != value2'} + ${'field = "value > value2"'} | ${'field=value > value2'} + ${'field = "value < value2"'} | ${'field=value < value2'} + ${'field = "value ~ value2"'} | ${'field=value ~ value2' /** ~ character is not supported as value in the q query parameter */} + ${'field = value or'} | ${'field=value,'} + ${'field = value or field2'} | ${'field=value,field2'} + ${'field = value or field2 <'} | ${'field=value,field2<'} + ${'field = value or field2 < value2'} | ${'field=value,field2 "custom value" '} | ${'(field=value);field2>custom value'} + `('transformSpecificQLToUnifiedQL - WQL $WQL TO UQL $UQL', ({ WQL, UQL }) => { expect(transformSpecificQLToUnifiedQL(WQL)).toEqual(UQL); }); // When a suggestion is clicked, change the input text it.each` - WQL | clikedSuggestion | changedInput - ${''} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field'}} | ${'field'} - ${'field'} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'field2'} - ${'field'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '='}} | ${'field='} - ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value'}} | ${'field=value'} - ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value()'}} | ${'field=value()'} - ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'valueand'}} | ${'field=valueand'} - ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'valueor'}} | ${'field=valueor'} - ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value='}} | ${'field=value='} - ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value!='}} | ${'field=value!='} - ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value>'}} | ${'field=value>'} - ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value<'}} | ${'field=value<'} - ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value~'}} | ${'field=value~' /** ~ character is not supported as value in the q query parameter */} - ${'field='} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '!='}} | ${'field!='} - ${'field=value'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2'}} | ${'field=value2'} - ${'field=value'} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'and'}} | ${'field=value and '} - ${'field=value and'} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'or'}} | ${'field=value or'} - ${'field=value and'} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'field=value and field2'} - ${'field=value and '} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'or'}} | ${'field=value or '} - ${'field=value and '} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'field=value and field2'} - ${'field=value and field2'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>'}} | ${'field=value and field2>'} - ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with spaces'}} | ${'field="with spaces"'} - ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with "spaces'}} | ${'field="with \\"spaces"'} - ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with value()'}} | ${'field="with value()"'} - ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with and value'}}| ${'field="with and value"'} - ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with or value'}} | ${'field="with or value"'} - ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with = value'}} | ${'field="with = value"'} - ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with != value'}} | ${'field="with != value"'} - ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with > value'}} | ${'field="with > value"'} - ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with < value'}} | ${'field="with < value"'} - ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with ~ value'}} | ${'field="with ~ value"' /** ~ character is not supported as value in the q query parameter */} - ${'field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: '"value'}} | ${'field="\\"value"'} - ${'field="with spaces"'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value'}} | ${'field=value'} - ${'field="with spaces"'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'other spaces'}} | ${'field="other spaces"'} - ${''} | ${{type: { iconType: 'tokenDenseVector', color: 'tint3' }, label: '('}} | ${'('} - ${'('} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field'}} | ${'(field'} - ${'(field'} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'(field2'} - ${'(field'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '='}} | ${'(field='} - ${'(field='} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value'}} | ${'(field=value'} - ${'(field=value'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2'}} | ${'(field=value2'} - ${'(field=value'} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'or'}} | ${'(field=value or '} - ${'(field=value or'} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'and'}} | ${'(field=value and'} - ${'(field=value or'} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'(field=value or field2'} - ${'(field=value or '} | ${{type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'and'}} | ${'(field=value and '} - ${'(field=value or '} | ${{type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2'}} | ${'(field=value or field2'} - ${'(field=value or field2'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>'}} | ${'(field=value or field2>'} - ${'(field=value or field2>'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>'}} | ${'(field=value or field2>'} - ${'(field=value or field2>'} | ${{type: { iconType: 'kqlOperand', color: 'tint1' }, label: '~'}} | ${'(field=value or field2~'} - ${'(field=value or field2>'} | ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2'}} | ${'(field=value or field2>value2'} - ${'(field=value or field2>value2'}| ${{type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value3'}} | ${'(field=value or field2>value3'} - ${'(field=value or field2>value2'}| ${{type: { iconType: 'tokenDenseVector', color: 'tint3' }, label: ')'}} | ${'(field=value or field2>value2)'} - `('click suggestion - WQL $WQL => $changedInput', async ({WQL: currentInput, clikedSuggestion, changedInput}) => { - // Mock input - let input = currentInput; + WQL | clikedSuggestion | changedInput + ${''} | ${{ type: { iconType: 'kqlField', color: 'tint4' }, label: 'field' }} | ${'field'} + ${'field'} | ${{ type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2' }} | ${'field2'} + ${'field'} | ${{ type: { iconType: 'kqlOperand', color: 'tint1' }, label: '=' }} | ${'field='} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value' }} | ${'field=value'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value()' }} | ${'field=value()'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'valueand' }} | ${'field=valueand'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'valueor' }} | ${'field=valueor'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value=' }} | ${'field=value='} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value!=' }} | ${'field=value!='} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value>' }} | ${'field=value>'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value<' }} | ${'field=value<'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value~' }} | ${'field=value~' /** ~ character is not supported as value in the q query parameter */} + ${'field='} | ${{ type: { iconType: 'kqlOperand', color: 'tint1' }, label: '!=' }} | ${'field!='} + ${'field=value'} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2' }} | ${'field=value2'} + ${'field=value'} | ${{ type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'and' }} | ${'field=value and '} + ${'field=value and'} | ${{ type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'or' }} | ${'field=value or'} + ${'field=value and'} | ${{ type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2' }} | ${'field=value and field2'} + ${'field=value and '} | ${{ type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'or' }} | ${'field=value or '} + ${'field=value and '} | ${{ type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2' }} | ${'field=value and field2'} + ${'field=value and field2'} | ${{ type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>' }} | ${'field=value and field2>'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with spaces' }} | ${'field="with spaces"'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with "spaces' }} | ${'field="with \\"spaces"'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with value()' }} | ${'field="with value()"'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with and value' }} | ${'field="with and value"'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with or value' }} | ${'field="with or value"'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with = value' }} | ${'field="with = value"'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with != value' }} | ${'field="with != value"'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with > value' }} | ${'field="with > value"'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with < value' }} | ${'field="with < value"'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with ~ value' }} | ${'field="with ~ value"' /** ~ character is not supported as value in the q query parameter */} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: '"value' }} | ${'field="\\"value"'} + ${'field="with spaces"'} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value' }} | ${'field=value'} + ${'field="with spaces"'} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'other spaces' }} | ${'field="other spaces"'} + ${''} | ${{ type: { iconType: 'tokenDenseVector', color: 'tint3' }, label: '(' }} | ${'('} + ${'('} | ${{ type: { iconType: 'kqlField', color: 'tint4' }, label: 'field' }} | ${'(field'} + ${'(field'} | ${{ type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2' }} | ${'(field2'} + ${'(field'} | ${{ type: { iconType: 'kqlOperand', color: 'tint1' }, label: '=' }} | ${'(field='} + ${'(field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value' }} | ${'(field=value'} + ${'(field=value'} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2' }} | ${'(field=value2'} + ${'(field=value'} | ${{ type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'or' }} | ${'(field=value or '} + ${'(field=value or'} | ${{ type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'and' }} | ${'(field=value and'} + ${'(field=value or'} | ${{ type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2' }} | ${'(field=value or field2'} + ${'(field=value or '} | ${{ type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'and' }} | ${'(field=value and '} + ${'(field=value or '} | ${{ type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2' }} | ${'(field=value or field2'} + ${'(field=value or field2'} | ${{ type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>' }} | ${'(field=value or field2>'} + ${'(field=value or field2>'} | ${{ type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>' }} | ${'(field=value or field2>'} + ${'(field=value or field2>'} | ${{ type: { iconType: 'kqlOperand', color: 'tint1' }, label: '~' }} | ${'(field=value or field2~'} + ${'(field=value or field2>'} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2' }} | ${'(field=value or field2>value2'} + ${'(field=value or field2>value2'} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value3' }} | ${'(field=value or field2>value3'} + ${'(field=value or field2>value2'} | ${{ type: { iconType: 'tokenDenseVector', color: 'tint3' }, label: ')' }} | ${'(field=value or field2>value2)'} + `( + 'click suggestion - WQL $WQL => $changedInput', + async ({ WQL: currentInput, clikedSuggestion, changedInput }) => { + // Mock input + let input = currentInput; - const qlOutput = await WQL.run(input, { - setInput: (value: string): void => { input = value; }, - queryLanguage: { - parameters: { - options: {}, - suggestions: { - field: () => ([]), - value: () => ([]) - } - } - } - }); - qlOutput.searchBarProps.onItemClick(clikedSuggestion); - expect(input).toEqual(changedInput); - }); + const qlOutput = await WQL.run(input, { + setInput: (value: string): void => { + input = value; + }, + queryLanguage: { + parameters: { + options: {}, + suggestions: { + field: () => [], + value: () => [], + }, + }, + }, + }); + qlOutput.searchBarProps.onItemClick(clikedSuggestion); + expect(input).toEqual(changedInput); + }, + ); // Transform the external input in UQL (Unified Query Language) to QL it.each` - UQL | WQL - ${''} | ${''} - ${'field'} | ${'field'} - ${'field='} | ${'field='} - ${'field=()'} | ${'field=()'} - ${'field=valueand'} | ${'field=valueand'} - ${'field=valueor'} | ${'field=valueor'} - ${'field=value='} | ${'field=value='} - ${'field=value!='} | ${'field=value!='} - ${'field=value>'} | ${'field=value>'} - ${'field=value<'} | ${'field=value<'} - ${'field=value~'} | ${'field=value~'} - ${'field!='} | ${'field!='} - ${'field>'} | ${'field>'} - ${'field<'} | ${'field<'} - ${'field~'} | ${'field~'} - ${'field=value'} | ${'field=value'} - ${'field=value;'} | ${'field=value and '} - ${'field=value;field2'} | ${'field=value and field2'} - ${'field="'} | ${'field="\\""'} - ${'field=with spaces'} | ${'field="with spaces"'} - ${'field=with "spaces'} | ${'field="with \\"spaces"'} - ${'field=value ()'} | ${'field="value ()"'} - ${'field=with and value'} | ${'field="with and value"'} - ${'field=with or value'} | ${'field="with or value"'} - ${'field=with = value'} | ${'field="with = value"'} - ${'field=with > value'} | ${'field="with > value"'} - ${'field=with < value'} | ${'field="with < value"'} - ${'('} | ${'('} - ${'(field'} | ${'(field'} - ${'(field='} | ${'(field='} - ${'(field=value'} | ${'(field=value'} - ${'(field=value,'} | ${'(field=value or '} - ${'(field=value,field2'} | ${'(field=value or field2'} - ${'(field=value,field2>'} | ${'(field=value or field2>'} - ${'(field=value,field2>value2'} | ${'(field=value or field2>value2'} - ${'(field=value,field2>value2)'} | ${'(field=value or field2>value2)'} - ${'implicit=value;'} | ${''} - ${'implicit=value;field'} | ${'field'} - `('Transform the external input UQL to QL - UQL $UQL => $WQL', async ({UQL, WQL: changedInput}) => { - expect(WQL.transformInput(UQL, { - parameters: { - options: { - implicitQuery: { - query: 'implicit=value', - conjunction: ';' - } - } - } - })).toEqual(changedInput); - }); + UQL | WQL + ${''} | ${''} + ${'field'} | ${'field'} + ${'field='} | ${'field='} + ${'field=()'} | ${'field=()'} + ${'field=valueand'} | ${'field=valueand'} + ${'field=valueor'} | ${'field=valueor'} + ${'field=value='} | ${'field=value='} + ${'field=value!='} | ${'field=value!='} + ${'field=value>'} | ${'field=value>'} + ${'field=value<'} | ${'field=value<'} + ${'field=value~'} | ${'field=value~'} + ${'field!='} | ${'field!='} + ${'field>'} | ${'field>'} + ${'field<'} | ${'field<'} + ${'field~'} | ${'field~'} + ${'field=value'} | ${'field=value'} + ${'field=value;'} | ${'field=value and '} + ${'field=value;field2'} | ${'field=value and field2'} + ${'field="'} | ${'field="\\""'} + ${'field=with spaces'} | ${'field="with spaces"'} + ${'field=with "spaces'} | ${'field="with \\"spaces"'} + ${'field=value ()'} | ${'field="value ()"'} + ${'field=with and value'} | ${'field="with and value"'} + ${'field=with or value'} | ${'field="with or value"'} + ${'field=with = value'} | ${'field="with = value"'} + ${'field=with > value'} | ${'field="with > value"'} + ${'field=with < value'} | ${'field="with < value"'} + ${'('} | ${'('} + ${'(field'} | ${'(field'} + ${'(field='} | ${'(field='} + ${'(field=value'} | ${'(field=value'} + ${'(field=value,'} | ${'(field=value or '} + ${'(field=value,field2'} | ${'(field=value or field2'} + ${'(field=value,field2>'} | ${'(field=value or field2>'} + ${'(field=value,field2>value2'} | ${'(field=value or field2>value2'} + ${'(field=value,field2>value2)'} | ${'(field=value or field2>value2)'} + ${'implicit=value;'} | ${''} + ${'implicit=value;field'} | ${'field'} + `( + 'Transform the external input UQL to QL - UQL $UQL => $WQL', + async ({ UQL, WQL: changedInput }) => { + expect( + WQL.transformInput(UQL, { + parameters: { + options: { + implicitQuery: { + query: 'implicit=value', + conjunction: ';', + }, + }, + }, + }), + ).toEqual(changedInput); + }, + ); /* The ! and ~ characters can't be part of a value that contains examples. The tests doesn't include these cases. - + Value examples: - with != value - with ~ value */ - + // Validate the tokens it.each` - WQL | validationError - ${''} | ${undefined} - ${'field1'} | ${undefined} - ${'field2'} | ${undefined} - ${'field1='} | ${['The value for field "field1" is missing.']} - ${'field2='} | ${['The value for field "field2" is missing.']} - ${'field='} | ${['"field" is not a valid field.']} - ${'custom='} | ${['"custom" is not a valid field.']} - ${'field1=value'} | ${undefined} - ${'field1=1'} | ${['Numbers are not valid for field1']} - ${'field1=value1'} | ${['Numbers are not valid for field1']} - ${'field2=value'} | ${undefined} - ${'field=value'} | ${['"field" is not a valid field.']} - ${'custom=value'} | ${['"custom" is not a valid field.']} - ${'field1=value!test'} | ${['"value!test" is not a valid value. Invalid characters found: !']} - ${'field1=value&test'} | ${['"value&test" is not a valid value. Invalid characters found: &']} - ${'field1=value!value&test'} | ${['"value!value&test" is not a valid value. Invalid characters found: !, &']} - ${'field1=value!value!test'} | ${['"value!value!test" is not a valid value. Invalid characters found: !']} - ${'field1=value!value!t$&st'} | ${['"value!value!t$&st" is not a valid value. Invalid characters found: !, $, &']} - ${'field1=value and'} | ${['There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} - ${'field2=value and'} | ${['There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} - ${'field=value and'} | ${['"field" is not a valid field.', 'There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} - ${'custom=value and'} | ${['"custom" is not a valid field.', 'There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} - ${'field1=value and '} | ${['There is no sentence after conjunction "and".']} - ${'field2=value and '} | ${['There is no sentence after conjunction "and".']} - ${'field=value and '} | ${['"field" is not a valid field.', 'There is no sentence after conjunction "and".']} - ${'custom=value and '} | ${['"custom" is not a valid field.', 'There is no sentence after conjunction "and".']} - ${'field1=value and field2'} | ${['The operator for field \"field2\" is missing.']} - ${'field2=value and field1'} | ${['The operator for field \"field1\" is missing.']} - ${'field1=value and field'} | ${['"field" is not a valid field.']} - ${'field2=value and field'} | ${['"field" is not a valid field.']} - ${'field=value and custom'} | ${['"field" is not a valid field.', '"custom" is not a valid field.']} - ${'('} | ${undefined} - ${'(field'} | ${undefined} - ${'(field='} | ${['"field" is not a valid field.']} - ${'(field=value'} | ${['"field" is not a valid field.']} - ${'(field=value or'} | ${['"field" is not a valid field.', 'There is no whitespace after conjunction "or".', 'There is no sentence after conjunction "or".']} - ${'(field=value or '} | ${['"field" is not a valid field.', 'There is no sentence after conjunction "or".']} - ${'(field=value or field2'} | ${['"field" is not a valid field.', 'The operator for field \"field2\" is missing.']} - ${'(field=value or field2>'} | ${['"field" is not a valid field.', 'The value for field "field2" is missing.']} - ${'(field=value or field2>value2'}| ${['"field" is not a valid field.']} - `('validate the tokens - WQL $WQL => $validationError', async ({WQL: currentInput, validationError}) => { - - const qlOutput = await WQL.run(currentInput, { - queryLanguage: { - parameters: { - options: {}, - suggestions: { - field: () => (['field1', 'field2'].map(label => ({label}))), - value: () => ([]) + WQL | validationError + ${''} | ${undefined} + ${'field1'} | ${undefined} + ${'field2'} | ${undefined} + ${'field1='} | ${['The value for field "field1" is missing.']} + ${'field2='} | ${['The value for field "field2" is missing.']} + ${'field='} | ${['"field" is not a valid field.']} + ${'custom='} | ${['"custom" is not a valid field.']} + ${'field1=value'} | ${undefined} + ${'field1=1'} | ${['Numbers are not valid for field1']} + ${'field1=value1'} | ${['Numbers are not valid for field1']} + ${'field2=value'} | ${undefined} + ${'field=value'} | ${['"field" is not a valid field.']} + ${'custom=value'} | ${['"custom" is not a valid field.']} + ${'field1=value!test'} | ${['"value!test" is not a valid value. Invalid characters found: !']} + ${'field1=value&test'} | ${['"value&test" is not a valid value. Invalid characters found: &']} + ${'field1=value!value&test'} | ${['"value!value&test" is not a valid value. Invalid characters found: !&']} + ${'field1=value!value!test'} | ${['"value!value!test" is not a valid value. Invalid characters found: !']} + ${'field1=value!value!t$&st'} | ${['"value!value!t$&st" is not a valid value. Invalid characters found: !$&']} + ${'field1=value and'} | ${['There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} + ${'field2=value and'} | ${['There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} + ${'field=value and'} | ${['"field" is not a valid field.', 'There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} + ${'custom=value and'} | ${['"custom" is not a valid field.', 'There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} + ${'field1=value and '} | ${['There is no sentence after conjunction "and".']} + ${'field2=value and '} | ${['There is no sentence after conjunction "and".']} + ${'field=value and '} | ${['"field" is not a valid field.', 'There is no sentence after conjunction "and".']} + ${'custom=value and '} | ${['"custom" is not a valid field.', 'There is no sentence after conjunction "and".']} + ${'field1=value and field2'} | ${['The operator for field "field2" is missing.']} + ${'field2=value and field1'} | ${['The operator for field "field1" is missing.']} + ${'field1=value and field'} | ${['"field" is not a valid field.']} + ${'field2=value and field'} | ${['"field" is not a valid field.']} + ${'field=value and custom'} | ${['"field" is not a valid field.', '"custom" is not a valid field.']} + ${'('} | ${undefined} + ${'(field'} | ${undefined} + ${'(field='} | ${['"field" is not a valid field.']} + ${'(field=value'} | ${['"field" is not a valid field.']} + ${'(field=value or'} | ${['"field" is not a valid field.', 'There is no whitespace after conjunction "or".', 'There is no sentence after conjunction "or".']} + ${'(field=value or '} | ${['"field" is not a valid field.', 'There is no sentence after conjunction "or".']} + ${'(field=value or field2'} | ${['"field" is not a valid field.', 'The operator for field "field2" is missing.']} + ${'(field=value or field2>'} | ${['"field" is not a valid field.', 'The value for field "field2" is missing.']} + ${'(field=value or field2>value2'} | ${['"field" is not a valid field.']} + `( + 'validate the tokens - WQL $WQL => $validationError', + async ({ WQL: currentInput, validationError }) => { + const qlOutput = await WQL.run(currentInput, { + queryLanguage: { + parameters: { + options: {}, + suggestions: { + field: () => ['field1', 'field2'].map(label => ({ label })), + value: () => [], + }, + validate: { + value: (token, { field, operator_compare }) => { + if (field === 'field1') { + const value = token.formattedValue || token.value; + return /\d/.test(value) + ? `Numbers are not valid for ${field}` + : undefined; + } + }, + }, }, - validate: { - value: (token, {field, operator_compare}) => { - if(field === 'field1'){ - const value = token.formattedValue || token.value; - return /\d/.test(value) - ? `Numbers are not valid for ${field}` - : undefined - } - } - } - } - } - }); - expect(qlOutput.output.error).toEqual(validationError); - }); + }, + }); + expect(qlOutput.output.error).toEqual(validationError); + }, + ); }); diff --git a/plugins/main/public/components/search-bar/query-language/wql.tsx b/plugins/main/public/components/search-bar/query-language/wql.tsx index db85ca55b2..7470587f45 100644 --- a/plugins/main/public/components/search-bar/query-language/wql.tsx +++ b/plugins/main/public/components/search-bar/query-language/wql.tsx @@ -22,7 +22,7 @@ type ITokenType = | 'value' | 'conjunction' | 'whitespace'; -type IToken = { type: ITokenType; value: string }; +type IToken = { type: ITokenType; value: string; formattedValue?: string }; type ITokens = IToken[]; /* API Query Language @@ -187,9 +187,10 @@ export function tokenizer(input: string): ITokens { ? 'whitespace' : key, value, - ...(key === 'value' && value && /^"(.+)"$/.test(value) - ? { formattedValue: value.match(/^"(.+)"$/)[1] } - : { formattedValue: value }), + ...(key === 'value' && + (value && /^"([\s\S]+)"$/.test(value) + ? { formattedValue: value.match(/^"([\s\S]+)"$/)[1] } + : { formattedValue: value })), })), ) .flat(); @@ -601,16 +602,20 @@ export function transformSpecificQLToUnifiedQL( } return tokens - .filter(({ type, value }) => type !== 'whitespace' && value) - .map(({ type, value }) => { + .filter( + ({ type, value, formattedValue }) => + type !== 'whitespace' && (formattedValue ?? value), + ) + .map(({ type, value, formattedValue }) => { switch (type) { case 'value': { - // Value is wrapped with " - let [_, extractedValue] = value.match(/^"(.+)"$/) || [null, null]; - // Replace the escaped comma (\") by comma (") + // If the value is wrapped with ", then replace the escaped double quotation mark (\") + // by double quotation marks (") // WARN: This could cause a problem with value that contains this sequence \" - extractedValue && - (extractedValue = extractedValue.replace(/\\"/g, '"')); + const extractedValue = + formattedValue !== value + ? formattedValue.replace(/\\"/g, '"') + : formattedValue; return extractedValue || value; break; } @@ -695,7 +700,7 @@ function validateTokenValue(token: IToken): string | undefined { : [ `"${token.value}" is not a valid value.`, ...(invalidCharacters.length - ? [`Invalid characters found: ${invalidCharacters.join(', ')}`] + ? [`Invalid characters found: ${invalidCharacters.join('')}`] : []), ].join(' '); } From 579d30fe52c0f7d6ff08795fb2bc95f544b0313c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Fri, 4 Aug 2023 13:49:43 +0200 Subject: [PATCH 36/37] fix(search-bar): add validation for value token in WQL --- .../search-bar/query-language/wql.test.tsx | 93 ++++++++++--------- .../search-bar/query-language/wql.tsx | 28 ++++-- 2 files changed, 70 insertions(+), 51 deletions(-) diff --git a/plugins/main/public/components/search-bar/query-language/wql.test.tsx b/plugins/main/public/components/search-bar/query-language/wql.test.tsx index 5cdecb968b..4de5de790b 100644 --- a/plugins/main/public/components/search-bar/query-language/wql.test.tsx +++ b/plugins/main/public/components/search-bar/query-language/wql.test.tsx @@ -397,48 +397,52 @@ describe('Query language - WQL', () => { */ // Validate the tokens + // Some examples of value tokens are based on this API test: https://github.com/wazuh/wazuh/blob/813595cf58d753c1066c3e7c2018dbb4708df088/framework/wazuh/core/tests/test_utils.py#L987-L1050 it.each` - WQL | validationError - ${''} | ${undefined} - ${'field1'} | ${undefined} - ${'field2'} | ${undefined} - ${'field1='} | ${['The value for field "field1" is missing.']} - ${'field2='} | ${['The value for field "field2" is missing.']} - ${'field='} | ${['"field" is not a valid field.']} - ${'custom='} | ${['"custom" is not a valid field.']} - ${'field1=value'} | ${undefined} - ${'field1=1'} | ${['Numbers are not valid for field1']} - ${'field1=value1'} | ${['Numbers are not valid for field1']} - ${'field2=value'} | ${undefined} - ${'field=value'} | ${['"field" is not a valid field.']} - ${'custom=value'} | ${['"custom" is not a valid field.']} - ${'field1=value!test'} | ${['"value!test" is not a valid value. Invalid characters found: !']} - ${'field1=value&test'} | ${['"value&test" is not a valid value. Invalid characters found: &']} - ${'field1=value!value&test'} | ${['"value!value&test" is not a valid value. Invalid characters found: !&']} - ${'field1=value!value!test'} | ${['"value!value!test" is not a valid value. Invalid characters found: !']} - ${'field1=value!value!t$&st'} | ${['"value!value!t$&st" is not a valid value. Invalid characters found: !$&']} - ${'field1=value and'} | ${['There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} - ${'field2=value and'} | ${['There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} - ${'field=value and'} | ${['"field" is not a valid field.', 'There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} - ${'custom=value and'} | ${['"custom" is not a valid field.', 'There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} - ${'field1=value and '} | ${['There is no sentence after conjunction "and".']} - ${'field2=value and '} | ${['There is no sentence after conjunction "and".']} - ${'field=value and '} | ${['"field" is not a valid field.', 'There is no sentence after conjunction "and".']} - ${'custom=value and '} | ${['"custom" is not a valid field.', 'There is no sentence after conjunction "and".']} - ${'field1=value and field2'} | ${['The operator for field "field2" is missing.']} - ${'field2=value and field1'} | ${['The operator for field "field1" is missing.']} - ${'field1=value and field'} | ${['"field" is not a valid field.']} - ${'field2=value and field'} | ${['"field" is not a valid field.']} - ${'field=value and custom'} | ${['"field" is not a valid field.', '"custom" is not a valid field.']} - ${'('} | ${undefined} - ${'(field'} | ${undefined} - ${'(field='} | ${['"field" is not a valid field.']} - ${'(field=value'} | ${['"field" is not a valid field.']} - ${'(field=value or'} | ${['"field" is not a valid field.', 'There is no whitespace after conjunction "or".', 'There is no sentence after conjunction "or".']} - ${'(field=value or '} | ${['"field" is not a valid field.', 'There is no sentence after conjunction "or".']} - ${'(field=value or field2'} | ${['"field" is not a valid field.', 'The operator for field "field2" is missing.']} - ${'(field=value or field2>'} | ${['"field" is not a valid field.', 'The value for field "field2" is missing.']} - ${'(field=value or field2>value2'} | ${['"field" is not a valid field.']} + WQL | validationError + ${''} | ${undefined} + ${'field1'} | ${undefined} + ${'field2'} | ${undefined} + ${'field1='} | ${['The value for field "field1" is missing.']} + ${'field2='} | ${['The value for field "field2" is missing.']} + ${'field='} | ${['"field" is not a valid field.']} + ${'custom='} | ${['"custom" is not a valid field.']} + ${'field1=value'} | ${undefined} + ${'field_not_number=1'} | ${['Numbers are not valid for field_not_number']} + ${'field_not_number=value1'} | ${['Numbers are not valid for field_not_number']} + ${'field2=value'} | ${undefined} + ${'field=value'} | ${['"field" is not a valid field.']} + ${'custom=value'} | ${['"custom" is not a valid field.']} + ${'field1=value!test'} | ${['"value!test" is not a valid value. Invalid characters found: !']} + ${'field1=value&test'} | ${['"value&test" is not a valid value. Invalid characters found: &']} + ${'field1=value!value&test'} | ${['"value!value&test" is not a valid value. Invalid characters found: !&']} + ${'field1=value!value!test'} | ${['"value!value!test" is not a valid value. Invalid characters found: !']} + ${'field1=value!value!t$&st'} | ${['"value!value!t$&st" is not a valid value. Invalid characters found: !$&']} + ${'field1=value,'} | ${['"value," is not a valid value.']} + ${'field1="Mozilla Firefox 53.0 (x64 en-US)"'} | ${undefined} + ${'field1="[\\"https://example-link@<>=,%?\\"]"'} | ${undefined} + ${'field1=value and'} | ${['There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} + ${'field2=value and'} | ${['There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} + ${'field=value and'} | ${['"field" is not a valid field.', 'There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} + ${'custom=value and'} | ${['"custom" is not a valid field.', 'There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} + ${'field1=value and '} | ${['There is no sentence after conjunction "and".']} + ${'field2=value and '} | ${['There is no sentence after conjunction "and".']} + ${'field=value and '} | ${['"field" is not a valid field.', 'There is no sentence after conjunction "and".']} + ${'custom=value and '} | ${['"custom" is not a valid field.', 'There is no sentence after conjunction "and".']} + ${'field1=value and field2'} | ${['The operator for field "field2" is missing.']} + ${'field2=value and field1'} | ${['The operator for field "field1" is missing.']} + ${'field1=value and field'} | ${['"field" is not a valid field.']} + ${'field2=value and field'} | ${['"field" is not a valid field.']} + ${'field=value and custom'} | ${['"field" is not a valid field.', '"custom" is not a valid field.']} + ${'('} | ${undefined} + ${'(field'} | ${undefined} + ${'(field='} | ${['"field" is not a valid field.']} + ${'(field=value'} | ${['"field" is not a valid field.']} + ${'(field=value or'} | ${['"field" is not a valid field.', 'There is no whitespace after conjunction "or".', 'There is no sentence after conjunction "or".']} + ${'(field=value or '} | ${['"field" is not a valid field.', 'There is no sentence after conjunction "or".']} + ${'(field=value or field2'} | ${['"field" is not a valid field.', 'The operator for field "field2" is missing.']} + ${'(field=value or field2>'} | ${['"field" is not a valid field.', 'The value for field "field2" is missing.']} + ${'(field=value or field2>value2'} | ${['"field" is not a valid field.']} `( 'validate the tokens - WQL $WQL => $validationError', async ({ WQL: currentInput, validationError }) => { @@ -447,12 +451,15 @@ describe('Query language - WQL', () => { parameters: { options: {}, suggestions: { - field: () => ['field1', 'field2'].map(label => ({ label })), + field: () => + ['field1', 'field2', 'field_not_number'].map(label => ({ + label, + })), value: () => [], }, validate: { value: (token, { field, operator_compare }) => { - if (field === 'field1') { + if (field === 'field_not_number') { const value = token.formattedValue || token.value; return /\d/.test(value) ? `Numbers are not valid for ${field}` diff --git a/plugins/main/public/components/search-bar/query-language/wql.tsx b/plugins/main/public/components/search-bar/query-language/wql.tsx index 7470587f45..06bddbc1c5 100644 --- a/plugins/main/public/components/search-bar/query-language/wql.tsx +++ b/plugins/main/public/components/search-bar/query-language/wql.tsx @@ -685,6 +685,20 @@ function getOutput(input: string, options: OptionsQL) { * @returns */ function validateTokenValue(token: IToken): string | undefined { + const re = new RegExp( + // Value: A string. + '^(?(?:(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\/\'"=@%<>{}]*)\\))*' + + '(?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|^[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]+)' + + '(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]*)\\))*)+)$', + ); + + const value = token.formattedValue ?? token.value; + const match = value.match(re); + + if (match?.groups?.value === value) { + return undefined; + } + const invalidCharacters: string[] = token.value .split('') .filter((value, index, array) => array.indexOf(value) === index) @@ -695,14 +709,12 @@ function validateTokenValue(token: IToken): string | undefined { ), ); - return !invalidCharacters.length - ? undefined - : [ - `"${token.value}" is not a valid value.`, - ...(invalidCharacters.length - ? [`Invalid characters found: ${invalidCharacters.join('')}`] - : []), - ].join(' '); + return [ + `"${token.value}" is not a valid value.`, + ...(invalidCharacters.length + ? [`Invalid characters found: ${invalidCharacters.join('')}`] + : []), + ].join(' '); } type ITokenValidator = ( From f8b004484a0b43dbf54f317a000e405526a989b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Guti=C3=A9rrez?= Date: Mon, 7 Aug 2023 16:15:27 +0200 Subject: [PATCH 37/37] fix(search-bar): value token in message related to this is invalid --- .../main/public/components/search-bar/query-language/wql.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/main/public/components/search-bar/query-language/wql.tsx b/plugins/main/public/components/search-bar/query-language/wql.tsx index 06bddbc1c5..9df7dbbf01 100644 --- a/plugins/main/public/components/search-bar/query-language/wql.tsx +++ b/plugins/main/public/components/search-bar/query-language/wql.tsx @@ -710,7 +710,7 @@ function validateTokenValue(token: IToken): string | undefined { ); return [ - `"${token.value}" is not a valid value.`, + `"${value}" is not a valid value.`, ...(invalidCharacters.length ? [`Invalid characters found: ${invalidCharacters.join('')}`] : []),