diff --git a/src/platform/packages/shared/kbn-esql-language/jest.integration.config.js b/src/platform/packages/shared/kbn-esql-language/jest.integration.config.js new file mode 100644 index 0000000000000..6058d987acbc7 --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-language/jest.integration.config.js @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = { + preset: '@kbn/test/jest_integration_node', + rootDir: '../../../../..', + roots: ['/src/platform/packages/shared/kbn-esql-language'], +}; diff --git a/src/platform/packages/shared/kbn-esql-language/moon.yml b/src/platform/packages/shared/kbn-esql-language/moon.yml index 95d08177d3da8..bf0ac1b6bd31e 100644 --- a/src/platform/packages/shared/kbn-esql-language/moon.yml +++ b/src/platform/packages/shared/kbn-esql-language/moon.yml @@ -24,6 +24,8 @@ dependsOn: - '@kbn/core-pricing-common' - '@kbn/licensing-types' - '@kbn/field-types' + - '@kbn/test-es-server' + - '@kbn/tooling-log' - '@kbn/esql-scripts' tags: - shared-common @@ -36,6 +38,7 @@ fileGroups: src: - src/**/* - '**/*.ts' + - jest.integration.config.js - '!target/**/*' jest-config: - jest.config.js diff --git a/src/platform/packages/shared/kbn-esql-language/src/language/README.md b/src/platform/packages/shared/kbn-esql-language/src/language/README.md index a6fc74488b0a9..2519999262ed0 100644 --- a/src/platform/packages/shared/kbn-esql-language/src/language/README.md +++ b/src/platform/packages/shared/kbn-esql-language/src/language/README.md @@ -171,7 +171,7 @@ While AST requires the grammar to be compiled to be updated, definitions are sta Validation takes an AST as input and generates a list of messages to show to the user. The validation function leverages the definition files to check if the current AST is respecting the defined behaviour. Most of the logic rely purely on the definitions, but in some specific cases some ad-hoc conditions are defined within the code for specific commands/options. -The validation test suite generates a set of fixtures at the end of its execution, which are then re-used for other test suites (i.e. some FTR integration tests) as `esql_validation_meta_tests.json`. +The validation suites run as Jest unit tests and are also reused by Jest integration tests that compare client-side validation with a real Elasticsearch cluster. #### Autocomplete @@ -201,21 +201,21 @@ The older pattern is - a single test file for each engine, one for validation, one for autocomplete. These were always large files and have only grown. - custom test methods: `testSuggestions` / `testErrorsAndWarnings` -- validation cases are recorded in a JSON file which is then used to check our results against a live Elasticsearch instance in a functional test +- validation cases were recorded in a JSON file and checked against Elasticsearch from FTR The newer pattern is - splitting the tests into multiple smaller files, all found in `__tests__` directories - standard test methods (`it`, `test`) with custom _assertion_ helpers -- validation cases are checked against Elasticsearch by injecting assertion helpers run API integration tests. This does not require a JSON file. +- validation suites are reused by Jest integration tests in the package. This does not require a JSON file. #### Validation ##### The new way -Validation test logic is found in `src/platform/packages/shared/kbn-esql-language/src/validation/__tests__`. +Validation test logic is found in `src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__`. -Tests are found in files named with the following convention: `validation.some-description.test.ts`. +Unit test wrappers live in `*.test.ts` files. Reusable suite logic lives in matching `*_suite.ts` files so the same tests can also run as Jest integration tests. Here is an example of a block in the new test format. @@ -236,6 +236,10 @@ describe('TS [ [ BY ]]', () => { `expectErrors` is created in the `setup()` factory. It has a very similar API to `testErrorsAndWarnings` however it is not itself a Jest test case. It is simply an assertion that is wrapped in a test case defined with the standard `test` or `it` function. +The integration wrapper lives in `src/platform/packages/shared/kbn-esql-language/src/language/validation/integration_tests/validation_suites.test.ts`. It reuses the shared validation suites and checks client-side false positives against Elasticsearch. Run it with: + +`node scripts/jest_integration --config src/platform/packages/shared/kbn-esql-language/jest.integration.config.js` + ##### The old way The old validation tests look like this @@ -246,7 +250,7 @@ testErrorsAndWarnings(`ROW var = NOT 5 LIKE "?a"`, [ ]); ``` -and are found in `src/platform/packages/shared/kbn-esql-language/src/validation/validation.test.ts`. +and are found in `src/platform/packages/shared/kbn-esql-language/src/language/validation/validation.test.ts`. `testErrorsAndWarnings` supports `skip` and `only` modifiers e.g. `testErrorsAndWarnings.only('...')`. @@ -256,8 +260,6 @@ It accepts 2. a list of expected errors (can be empty) 3. a list of expected warnings (can be empty or omitted) -Running the tests in `validation.test.ts` populates `src/platform/packages/shared/kbn-esql-language/src/validation/esql_validation_meta_tests.json` which is then used in `src/platform/test/api_integration/apis/esql/errors.ts` to make sure our validator isn't giving users false positives. Therefore, the validation test suite should always be run after any changes have been made to it so that the JSON file stays in sync. - #### Autocomplete ##### The new way @@ -320,4 +322,4 @@ Its parameters are as follows 1. the query 2. the expected suggestions (can be strings or `Partial`) 3. the trigger character. This should only be included if the test is intended to validate a "Trigger Character" trigger kind from Monaco ([ref](https://microsoft.github.io/monaco-editor/typedoc/enums/languages.CompletionTriggerKind.html#TriggerCharacter)) -4. custom callback data such as a list of indicies or a field list \ No newline at end of file +4. custom callback data such as a list of indicies or a field list diff --git a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/column_existence.test.ts b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/column_existence.test.ts index 2d72a015b9c12..a50d423f4b8b4 100644 --- a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/column_existence.test.ts +++ b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/column_existence.test.ts @@ -7,52 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { times } from 'lodash'; import { setup } from './helpers'; +import { runColumnExistenceValidationSuite } from './column_existence_suite'; -describe('column existence checks', () => { - it('looks behind current command', async () => { - const { expectErrors } = await setup(); - await expectErrors('FROM index | DROP keywordField | KEEP keywordField', [ - 'Unknown column "keywordField"', - ]); - }); - - it('treats FORK branches separately', async () => { - const { expectErrors } = await setup(); - await expectErrors('FROM index | FORK (DROP keywordField) (KEEP keywordField)', []); - }); - - it('makes STATS generated columns available after inline WHERE filters', async () => { - const { expectErrors } = await setup(); - await expectErrors( - 'FROM index | STATS COUNT() WHERE integerField > 0 | EVAL result = `COUNT() WHERE integerField > 0` + 1', - [] - ); - }); - - it('returns a warning instead of an error when unmapped_fields is LOAD or NULLIFY', async () => { - const { expectErrors } = await setup(); - await expectErrors( - 'SET unmapped_fields = "LOAD"; FROM index | WHERE unmapped == ""', - [], - [ - `"unmapped" column isn't mapped in any searched indices.\nIf you are not intentionally referencing an unmapped field,\ncheck that the field exists or that it is spelled correctly in your query.`, - ] - ); - }); - - it('returns one warning for each instance of the same unmapped column', async () => { - const { expectErrors } = await setup(); - await expectErrors( - 'SET unmapped_fields = "LOAD"; FROM index | WHERE unmapped == "" | KEEP unmapped', - [], - - times( - 2, - () => - `"unmapped" column isn't mapped in any searched indices.\nIf you are not intentionally referencing an unmapped field,\ncheck that the field exists or that it is spelled correctly in your query.` - ) - ); - }); -}); +runColumnExistenceValidationSuite(setup); diff --git a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/column_existence_suite.ts b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/column_existence_suite.ts new file mode 100644 index 0000000000000..2c4767674dbea --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/column_existence_suite.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { times } from 'lodash'; +import type { Setup } from './helpers'; + +export const runColumnExistenceValidationSuite = (setup: Setup) => { + describe('column existence checks', () => { + it('looks behind current command', async () => { + const { expectErrors } = await setup(); + await expectErrors('FROM index | DROP keywordField | KEEP keywordField', [ + 'Unknown column "keywordField"', + ]); + }); + + it('treats FORK branches separately', async () => { + const { expectErrors } = await setup(); + await expectErrors('FROM index | FORK (DROP keywordField) (KEEP keywordField)', []); + }); + + it('makes STATS generated columns available after inline WHERE filters', async () => { + const { expectErrors } = await setup(); + await expectErrors( + 'FROM index | STATS COUNT() WHERE integerField > 0 | EVAL result = `COUNT() WHERE integerField > 0` + 1', + [] + ); + }); + + it('returns a warning instead of an error when unmapped_fields is LOAD or NULLIFY', async () => { + const { expectErrors } = await setup(); + await expectErrors( + 'SET unmapped_fields = "LOAD"; FROM index | WHERE unmapped == ""', + [], + [ + `"unmapped" column isn't mapped in any searched indices.\nIf you are not intentionally referencing an unmapped field,\ncheck that the field exists or that it is spelled correctly in your query.`, + ] + ); + }); + + it('returns one warning for each instance of the same unmapped column', async () => { + const { expectErrors } = await setup(); + await expectErrors( + 'SET unmapped_fields = "LOAD"; FROM index | WHERE unmapped == "" | KEEP unmapped', + [], + + times( + 2, + () => + `"unmapped" column isn't mapped in any searched indices.\nIf you are not intentionally referencing an unmapped field,\ncheck that the field exists or that it is spelled correctly in your query.` + ) + ); + }); + }); +}; diff --git a/src/platform/test/api_integration/apis/esql/index.ts b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/commands.test.ts similarity index 67% rename from src/platform/test/api_integration/apis/esql/index.ts rename to src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/commands.test.ts index 6cc185f40b9b9..2b0ff647f6223 100644 --- a/src/platform/test/api_integration/apis/esql/index.ts +++ b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/commands.test.ts @@ -7,10 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { FtrProviderContext } from '../../ftr_provider_context'; +import { setup } from './helpers'; +import { runCommandsValidationSuite } from './commands_suite'; -export default function ({ loadTestFile }: FtrProviderContext) { - describe('ESQL sync', () => { - loadTestFile(require.resolve('./errors')); - }); -} +runCommandsValidationSuite(setup); diff --git a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/commands_license.test.ts b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/commands_license.test.ts new file mode 100644 index 0000000000000..530ede17706cb --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/commands_license.test.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { setup } from './helpers'; +import { runValidationCommandsLicenseSuite } from './commands_license_suite'; + +runValidationCommandsLicenseSuite(setup); diff --git a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/commands_license_suite.ts b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/commands_license_suite.ts new file mode 100644 index 0000000000000..0261224de890e --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/commands_license_suite.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { Setup } from './helpers'; + +export const runValidationCommandsLicenseSuite = (setup: Setup) => { + describe('Command license validation', () => { + it('Should allows licensed commands when user has required license', async () => { + const { expectErrors, callbacks } = await setup(); + + callbacks.getLicense = jest.fn(async () => ({ + hasAtLeast: (license?: string) => license?.toLowerCase() === 'platinum', + })); + + await expectErrors('FROM a_index | CHANGE_POINT doubleField', []); + }); + + it('should blocks licensed commands when user lacks required license', async () => { + const { expectErrors, callbacks } = await setup(); + + callbacks.getLicense = jest.fn(async () => ({ + hasAtLeast: (license?: string) => license?.toLowerCase() !== 'platinum', + })); + + await expectErrors('FROM a_index | CHANGE_POINT doubleField', [ + 'CHANGE_POINT requires a PLATINUM license.', + ]); + }); + }); +}; diff --git a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/commands_suite.ts b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/commands_suite.ts new file mode 100644 index 0000000000000..d505f0c67fce4 --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/commands_suite.ts @@ -0,0 +1,221 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { Setup } from './helpers'; + +export const runCommandsValidationSuite = (setup: Setup) => { + const testErrorsAndWarnings = ( + statement: string, + expectedErrors: string[] = [], + expectedWarnings: string[] = [] + ) => { + it(`${statement} => ${expectedErrors.length} errors, ${expectedWarnings.length} warnings`, async () => { + const { validate } = await setup(); + const { errors, warnings } = await validate(statement); + expect(errors.map((error) => ('message' in error ? error.message : error.text))).toEqual( + expectedErrors + ); + expect(warnings.map((warning) => warning.text)).toEqual(expectedWarnings); + }); + }; + + describe('row', () => { + testErrorsAndWarnings('row', [expect.stringContaining('SyntaxError:')]); + + test('syntax error', async () => { + const { expectErrors } = await setup(); + + await expectErrors('row var = 1 in ', [expect.stringContaining('SyntaxError:')]); + await expectErrors('row var = 1 in (', [expect.stringContaining('SyntaxError:')]); + await expectErrors('row var = 1 not in ', [expect.stringContaining('SyntaxError:')]); + }); + }); + + describe('limit', () => { + testErrorsAndWarnings('from index | limit ', [ + `SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, '['}`, + ]); + testErrorsAndWarnings('from index | limit 4 ', []); + testErrorsAndWarnings('from index | limit a', [ + "SyntaxError: mismatched input 'a' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, '['}", + ]); + testErrorsAndWarnings('from index | limit doubleField', [ + "SyntaxError: mismatched input 'doubleField' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, '['}", + ]); + testErrorsAndWarnings('from index | limit textField', [ + "SyntaxError: mismatched input 'textField' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, '['}", + ]); + testErrorsAndWarnings('from index | limit 4', []); + }); + + describe('join', () => { + testErrorsAndWarnings('ROW a=1::LONG | LOOKUP JOIN t ON a', [ + '"t" is not a valid JOIN index. Please use a "lookup" mode index.', + ]); + + testErrorsAndWarnings( + 'FROM a_index | LEFT JOIN join_index ON textField == keywordField, booleanField', + ['JOIN ON clause must be a comma separated list of fields or a single expression'] + ); + }); + + describe('drop', () => { + testErrorsAndWarnings('from index | drop ', [ + expect.stringContaining('SyntaxError: mismatched input'), + ]); + testErrorsAndWarnings('from index | drop 4.5', [ + expect.stringContaining('SyntaxError:'), + expect.stringContaining('SyntaxError:'), + expect.stringContaining('SyntaxError:'), + 'Unknown column "."', + ]); + testErrorsAndWarnings('from index | drop missingField, doubleField, dateField', [ + 'Unknown column "missingField"', + ]); + }); + + describe('mv_expand', () => { + testErrorsAndWarnings('from a_index | mv_expand ', [expect.stringContaining('SyntaxError:')]); + + testErrorsAndWarnings('from a_index | mv_expand doubleField, b', [ + expect.stringContaining('SyntaxError:'), + expect.stringContaining('SyntaxError:'), + ]); + }); + + describe('rename', () => { + testErrorsAndWarnings('from a_index | rename', [expect.stringContaining('SyntaxError:')]); + testErrorsAndWarnings('from a_index | rename textField', [ + "SyntaxError: no viable alternative at input 'textField'", + ]); + testErrorsAndWarnings('from a_index | rename a', [ + "SyntaxError: no viable alternative at input 'a'", + ]); + testErrorsAndWarnings('from a_index | rename textField as', [ + expect.stringContaining('SyntaxError:'), + 'AS expected 2 arguments, but got 1.', + ]); + testErrorsAndWarnings('row a = 10 | rename a as this is fine', [ + "SyntaxError: mismatched input 'is' expecting ", + ]); + }); + + describe('dissect', () => { + testErrorsAndWarnings('from a_index | dissect', [expect.stringContaining('SyntaxError:')]); + testErrorsAndWarnings('from a_index | dissect textField', [ + "SyntaxError: missing QUOTED_STRING at ''", + ]); + testErrorsAndWarnings('from a_index | dissect textField 2', [ + "SyntaxError: mismatched input '2' expecting QUOTED_STRING", + ]); + testErrorsAndWarnings('from a_index | dissect textField .', [ + "SyntaxError: mismatched input '' expecting {'?', '??', NAMED_OR_POSITIONAL_PARAM, NAMED_OR_POSITIONAL_DOUBLE_PARAMS, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", + ]); + testErrorsAndWarnings('from a_index | dissect textField %a', [ + "SyntaxError: mismatched input '%' expecting QUOTED_STRING", + "SyntaxError: mismatched input '' expecting '='", + ]); + }); + + describe('grok', () => { + testErrorsAndWarnings('from a_index | grok', [expect.stringContaining('SyntaxError:')]); + testErrorsAndWarnings('from a_index | grok textField', [ + expect.stringContaining('SyntaxError:'), + ]); + testErrorsAndWarnings('from a_index | grok textField 2', [ + expect.stringContaining('SyntaxError:'), + ]); + testErrorsAndWarnings('from a_index | grok textField .', [ + expect.stringContaining('SyntaxError:'), + ]); + testErrorsAndWarnings('from a_index | grok textField %a', [ + expect.stringContaining('SyntaxError:'), + ]); + }); + + describe('where', () => { + for (const wrongOp of ['*', '/', '%']) { + testErrorsAndWarnings(`from a_index | where ${wrongOp}+ doubleField`, [ + expect.stringContaining('SyntaxError:'), + ]); + } + }); + + describe('eval', () => { + testErrorsAndWarnings('from a_index | eval ', [expect.stringContaining('SyntaxError:')]); + testErrorsAndWarnings('from a_index | eval doubleField + ', [ + expect.stringContaining('SyntaxError:'), + ]); + + testErrorsAndWarnings('from a_index | eval a=round(', [ + expect.stringContaining('SyntaxError:'), + ]); + testErrorsAndWarnings('from a_index | eval a=round(doubleField) ', []); + testErrorsAndWarnings('from a_index | eval a=round(doubleField), ', [ + expect.stringContaining('SyntaxError:'), + ]); + + for (const wrongOp of ['*', '/', '%']) { + testErrorsAndWarnings(`from a_index | eval ${wrongOp}+ doubleField`, [ + expect.stringContaining('SyntaxError:'), + ]); + } + }); + + describe('sort', () => { + testErrorsAndWarnings('from a_index | sort ', [expect.stringContaining('SyntaxError:')]); + testErrorsAndWarnings('from a_index | sort doubleField, ', [ + expect.stringContaining('SyntaxError:'), + ]); + + for (const dir of ['desc', 'asc']) { + testErrorsAndWarnings(`from a_index | sort doubleField ${dir} nulls `, [ + "SyntaxError: missing {'first', 'last'} at ''", + ]); + for (const nullDir of ['first', 'last']) { + testErrorsAndWarnings(`from a_index | sort doubleField ${dir} ${nullDir}`, [ + `SyntaxError: extraneous input '${nullDir}' expecting `, + ]); + } + } + for (const nullDir of ['first', 'last']) { + testErrorsAndWarnings(`from a_index | sort doubleField ${nullDir}`, [ + `SyntaxError: extraneous input '${nullDir}' expecting `, + ]); + } + }); + + describe('enrich', () => { + testErrorsAndWarnings(`from a_index | enrich`, [ + "SyntaxError: missing {ENRICH_POLICY_NAME, QUOTED_STRING} at ''", + ]); + testErrorsAndWarnings(`from a_index | enrich _:`, [ + "SyntaxError: token recognition error at: ':'", + 'Unknown policy "_"', + ]); + testErrorsAndWarnings(`from a_index | enrich :policy`, [ + "SyntaxError: token recognition error at: ':'", + ]); + + testErrorsAndWarnings(`from a_index | enrich policy on textField with `, [ + expect.stringContaining('SyntaxError:'), + ]); + testErrorsAndWarnings(`from a_index | enrich policy with `, [ + expect.stringContaining('SyntaxError:'), + ]); + }); + + describe('settings', () => { + // Should return error if there is no query following SET + testErrorsAndWarnings(`SET time_zone = "CEST";`, [expect.stringContaining('SyntaxError:')]); + testErrorsAndWarnings(`SET invalid_setting = "_alias:_origin"; FROM index`, [ + expect.stringContaining('Unknown setting invalid_setting'), + ]); + }); +}; diff --git a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/fields_and_variables.test.ts b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/fields_and_variables.test.ts index c5d4fba3b90c1..f03caea0bea6d 100644 --- a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/fields_and_variables.test.ts +++ b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/fields_and_variables.test.ts @@ -7,217 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { - type FunctionParameterType, - FunctionDefinitionTypes, -} from '../../../commands/definitions/types'; -import { getNoValidCallSignatureError } from '../../../commands/definitions/utils/validation/utils'; -import { Location } from '../../../commands/registry/types'; -import { setTestFunctions } from '../../../commands/definitions/utils/test_functions'; import { setup } from './helpers'; +import { runFieldsAndVariablesValidationSuite } from './fields_and_variables_suite'; -describe('column escaping', () => { - it('recognizes escaped fields', async () => { - const { expectErrors } = await setup(); - // command level - await expectErrors( - 'FROM index | KEEP `kubernetes`.`something`.`something` | EVAL `kubernetes.something.something` + 12', - [] - ); - // function argument - await expectErrors('FROM index | EVAL ABS(`kubernetes`.`something`.`something`)', []); - }); - - it('recognizes field names with spaces and comments', async () => { - const { expectErrors } = await setup(); - // command level - await expectErrors('FROM index | KEEP kubernetes . something . /* gotcha! */ something', []); - // function argument - await expectErrors( - 'FROM index | EVAL ABS(kubernetes . something . /* gotcha! */ something)', - [] - ); - }); - - it('recognizes escaped user-defined columns', async () => { - const { expectErrors } = await setup(); - // command level - await expectErrors('ROW `var$iable` = 1 | EVAL `var$iable`', []); - - // command level, different escaping in declaration - await expectErrors( - 'ROW variable.`wi#th`.separator = "lolz" | EVAL `variable`.`wi#th`.`separator`', - [] - ); - - // function arguments - await expectErrors( - 'ROW `var$iable` = 1, variable.`wi#th`.separator = "lolz" | EVAL ABS(`var$iable`), TRIM(variable.`wi#th`.`separator`)', - [] - ); - - // expression user-defined column - await expectErrors('FROM index | EVAL doubleField + 20 | EVAL `doubleField + 20`', []); - await expectErrors('ROW 21 + 20 | STATS AVG(`21 + 20`)', []); - }); - - it('recognizes user-defined columns with spaces and comments', async () => { - const { expectErrors } = await setup(); - // command level - await expectErrors( - 'ROW variable.`wi#th`.separator = "lolz" | RENAME variable . /* lolz */ `wi#th` . separator AS foo', - [] - ); - // function argument - await expectErrors( - 'ROW variable.`wi#th`.separator = "lolz" | EVAL TRIM(variable . /* lolz */ `wi#th` . separator)', - [] - ); - }); - - describe('as part of various commands', () => { - const cases = [ - { name: 'ROW', command: 'ROW `var$iable` = 1, variable.`wi#th`.separator = "lolz"' }, - { - name: 'DISSECT', - command: 'ROW `funky`.`stri#$ng` = "lolz" | DISSECT `funky`.`stri#$ng` "%{WORD:firstWord}"', - }, - { name: 'DROP', command: 'FROM index | DROP kubernetes.`something`.`something`' }, - { - name: 'ENRICH', - command: - 'FROM index | ENRICH policy WITH `new`.name1 = `otherField`, `new.name2` = `yetAnotherField`', - }, - { name: 'EVAL', command: 'FROM index | EVAL kubernetes.`something`.`something` + 12' }, - { - name: 'GROK', - command: 'ROW `funky`.`stri#$ng` = "lolz" | GROK `funky`.`stri#$ng` "%{WORD:firstWord}"', - }, - { name: 'KEEP', command: 'FROM index | KEEP kubernetes.`something`.`something`' }, - { - name: 'RENAME', - command: 'FROM index | RENAME kubernetes.`something`.`something` as foobar', - }, - { name: 'SORT', command: 'FROM index | SORT kubernetes.`something`.`something` DESC' }, - { - name: 'STATS ... BY', - command: - 'FROM index | STATS AVG(kubernetes.`something`.`something`) BY `kubernetes`.`something`.`something`', - }, - { name: 'WHERE', command: 'FROM index | WHERE kubernetes.`something`.`something` == 12' }, - ]; - - it.each(cases)('$name accepts escaped fields', async ({ command }) => { - const { expectErrors } = await setup(); - await expectErrors(command, []); - }); - }); -}); - -describe('user-defined column support', () => { - describe('user-defined column data type detection', () => { - beforeAll(() => { - setTestFunctions([ - // this test function is just used to test the type of the user-defined column - { - type: FunctionDefinitionTypes.SCALAR, - description: 'Test function', - locationsAvailable: [Location.EVAL], - name: 'test', - signatures: [ - { params: [{ name: 'arg', type: 'cartesian_point' }], returnType: 'cartesian_point' }, - ], - }, - // this test function is used to check that the correct return type is used - // when determining user-defined column types - { - type: FunctionDefinitionTypes.SCALAR, - description: 'Test function', - locationsAvailable: [Location.EVAL], - name: 'return_value', - signatures: [ - { params: [{ name: 'arg', type: 'text' }], returnType: 'text' }, - { params: [{ name: 'arg', type: 'double' }], returnType: 'double' }, - { - params: [ - { name: 'arg', type: 'double' }, - { name: 'arg', type: 'text' }, - ], - returnType: 'long', - }, - ], - }, - ]); - }); - - afterAll(() => { - setTestFunctions([]); - }); - - const expectType = (type: FunctionParameterType) => - getNoValidCallSignatureError('test', [type]); - - test('literals', async () => { - const { expectErrors } = await setup(); - // literal assignment - await expectErrors('FROM index | EVAL var = 1, TEST(var)', [expectType('integer')]); - // literal expression - await expectErrors('FROM index | EVAL 1, TEST(`1`)', [expectType('integer')]); - }); - - test('fields', async () => { - const { expectErrors } = await setup(); - // field assignment - await expectErrors('FROM index | EVAL var = textField, TEST(var)', [ - getNoValidCallSignatureError('test', ['text']), - ]); - }); - - test('user-defined columns', async () => { - const { expectErrors } = await setup(); - await expectErrors('FROM index | EVAL var = textField, col2 = var, TEST(col2)', [ - getNoValidCallSignatureError('test', ['text']), - ]); - }); - - test('inline casting', async () => { - const { expectErrors } = await setup(); - // inline cast assignment - await expectErrors('FROM index | EVAL var = doubleField::long, TEST(var)', [ - expectType('long'), - ]); - // inline cast expression - await expectErrors('FROM index | EVAL doubleField::long, TEST(`doubleField::long`)', [ - expectType('long'), - ]); - }); - - test('function results', async () => { - const { expectErrors } = await setup(); - // function assignment - await expectErrors('FROM index | EVAL var = RETURN_VALUE(doubleField), TEST(var)', [ - expectType('double'), - ]); - await expectErrors('FROM index | EVAL var = RETURN_VALUE(textField), TEST(var)', [ - expectType('text'), - ]); - await expectErrors( - 'FROM index | EVAL var = RETURN_VALUE(doubleField, textField), TEST(var)', - [expectType('long')] - ); - // function expression - await expectErrors( - 'FROM index | EVAL RETURN_VALUE(doubleField), TEST(`RETURN_VALUE(doubleField)`)', - [expectType('double')] - ); - await expectErrors( - 'FROM index | EVAL RETURN_VALUE(textField), TEST(`RETURN_VALUE(textField)`)', - [expectType('text')] - ); - await expectErrors( - 'FROM index | EVAL RETURN_VALUE(doubleField, textField), TEST(`RETURN_VALUE(doubleField, textField)`)', - [expectType('long')] - ); - }); - }); -}); +runFieldsAndVariablesValidationSuite(setup); diff --git a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/fields_and_variables_suite.ts b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/fields_and_variables_suite.ts new file mode 100644 index 0000000000000..b1b5895b44139 --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/fields_and_variables_suite.ts @@ -0,0 +1,226 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { + type FunctionParameterType, + FunctionDefinitionTypes, +} from '../../../commands/definitions/types'; +import { getNoValidCallSignatureError } from '../../../commands/definitions/utils/validation/utils'; +import { Location } from '../../../commands/registry/types'; +import { setTestFunctions } from '../../../commands/definitions/utils/test_functions'; +import type { Setup } from './helpers'; + +export const runFieldsAndVariablesValidationSuite = (setup: Setup) => { + describe('column escaping', () => { + it('recognizes escaped fields', async () => { + const { expectErrors } = await setup(); + // command level + await expectErrors( + 'FROM index | KEEP `kubernetes`.`something`.`something` | EVAL `kubernetes.something.something` + 12', + [] + ); + // function argument + await expectErrors('FROM index | EVAL ABS(`kubernetes`.`something`.`something`)', []); + }); + + it('recognizes field names with spaces and comments', async () => { + const { expectErrors } = await setup(); + // command level + await expectErrors('FROM index | KEEP kubernetes . something . /* gotcha! */ something', []); + // function argument + await expectErrors( + 'FROM index | EVAL ABS(kubernetes . something . /* gotcha! */ something)', + [] + ); + }); + + it('recognizes escaped user-defined columns', async () => { + const { expectErrors } = await setup(); + // command level + await expectErrors('ROW `var$iable` = 1 | EVAL `var$iable`', []); + + // command level, different escaping in declaration + await expectErrors( + 'ROW variable.`wi#th`.separator = "lolz" | EVAL `variable`.`wi#th`.`separator`', + [] + ); + + // function arguments + await expectErrors( + 'ROW `var$iable` = 1, variable.`wi#th`.separator = "lolz" | EVAL ABS(`var$iable`), TRIM(variable.`wi#th`.`separator`)', + [] + ); + + // expression user-defined column + await expectErrors('FROM index | EVAL doubleField + 20 | EVAL `doubleField + 20`', []); + await expectErrors('ROW 21 + 20 | STATS AVG(`21 + 20`)', []); + }); + + it('recognizes user-defined columns with spaces and comments', async () => { + const { expectErrors } = await setup(); + // command level + await expectErrors( + 'ROW variable.`wi#th`.separator = "lolz" | RENAME variable . /* lolz */ `wi#th` . separator AS foo', + [] + ); + // function argument + await expectErrors( + 'ROW variable.`wi#th`.separator = "lolz" | EVAL TRIM(variable . /* lolz */ `wi#th` . separator)', + [] + ); + }); + + describe('as part of various commands', () => { + const cases = [ + { name: 'ROW', command: 'ROW `var$iable` = 1, variable.`wi#th`.separator = "lolz"' }, + { + name: 'DISSECT', + command: + 'ROW `funky`.`stri#$ng` = "lolz" | DISSECT `funky`.`stri#$ng` "%{WORD:firstWord}"', + }, + { name: 'DROP', command: 'FROM index | DROP kubernetes.`something`.`something`' }, + { + name: 'ENRICH', + command: + 'FROM index | ENRICH policy WITH `new`.name1 = `otherField`, `new.name2` = `yetAnotherField`', + }, + { name: 'EVAL', command: 'FROM index | EVAL kubernetes.`something`.`something` + 12' }, + { + name: 'GROK', + command: 'ROW `funky`.`stri#$ng` = "lolz" | GROK `funky`.`stri#$ng` "%{WORD:firstWord}"', + }, + { name: 'KEEP', command: 'FROM index | KEEP kubernetes.`something`.`something`' }, + { + name: 'RENAME', + command: 'FROM index | RENAME kubernetes.`something`.`something` as foobar', + }, + { name: 'SORT', command: 'FROM index | SORT kubernetes.`something`.`something` DESC' }, + { + name: 'STATS ... BY', + command: + 'FROM index | STATS AVG(kubernetes.`something`.`something`) BY `kubernetes`.`something`.`something`', + }, + { name: 'WHERE', command: 'FROM index | WHERE kubernetes.`something`.`something` == 12' }, + ]; + + it.each(cases)('$name accepts escaped fields', async ({ command }) => { + const { expectErrors } = await setup(); + await expectErrors(command, []); + }); + }); + }); + + describe('user-defined column support', () => { + describe('user-defined column data type detection', () => { + beforeAll(() => { + setTestFunctions([ + // this test function is just used to test the type of the user-defined column + { + type: FunctionDefinitionTypes.SCALAR, + description: 'Test function', + locationsAvailable: [Location.EVAL], + name: 'test', + signatures: [ + { params: [{ name: 'arg', type: 'cartesian_point' }], returnType: 'cartesian_point' }, + ], + }, + // this test function is used to check that the correct return type is used + // when determining user-defined column types + { + type: FunctionDefinitionTypes.SCALAR, + description: 'Test function', + locationsAvailable: [Location.EVAL], + name: 'return_value', + signatures: [ + { params: [{ name: 'arg', type: 'text' }], returnType: 'text' }, + { params: [{ name: 'arg', type: 'double' }], returnType: 'double' }, + { + params: [ + { name: 'arg', type: 'double' }, + { name: 'arg', type: 'text' }, + ], + returnType: 'long', + }, + ], + }, + ]); + }); + + afterAll(() => { + setTestFunctions([]); + }); + + const expectType = (type: FunctionParameterType) => + getNoValidCallSignatureError('test', [type]); + + test('literals', async () => { + const { expectErrors } = await setup(); + // literal assignment + await expectErrors('FROM index | EVAL var = 1, TEST(var)', [expectType('integer')]); + // literal expression + await expectErrors('FROM index | EVAL 1, TEST(`1`)', [expectType('integer')]); + }); + + test('fields', async () => { + const { expectErrors } = await setup(); + // field assignment + await expectErrors('FROM index | EVAL var = textField, TEST(var)', [ + getNoValidCallSignatureError('test', ['text']), + ]); + }); + + test('user-defined columns', async () => { + const { expectErrors } = await setup(); + await expectErrors('FROM index | EVAL var = textField, col2 = var, TEST(col2)', [ + getNoValidCallSignatureError('test', ['text']), + ]); + }); + + test('inline casting', async () => { + const { expectErrors } = await setup(); + // inline cast assignment + await expectErrors('FROM index | EVAL var = doubleField::long, TEST(var)', [ + expectType('long'), + ]); + // inline cast expression + await expectErrors('FROM index | EVAL doubleField::long, TEST(`doubleField::long`)', [ + expectType('long'), + ]); + }); + + test('function results', async () => { + const { expectErrors } = await setup(); + // function assignment + await expectErrors('FROM index | EVAL var = RETURN_VALUE(doubleField), TEST(var)', [ + expectType('double'), + ]); + await expectErrors('FROM index | EVAL var = RETURN_VALUE(textField), TEST(var)', [ + expectType('text'), + ]); + await expectErrors( + 'FROM index | EVAL var = RETURN_VALUE(doubleField, textField), TEST(var)', + [expectType('long')] + ); + // function expression + await expectErrors( + 'FROM index | EVAL RETURN_VALUE(doubleField), TEST(`RETURN_VALUE(doubleField)`)', + [expectType('double')] + ); + await expectErrors( + 'FROM index | EVAL RETURN_VALUE(textField), TEST(`RETURN_VALUE(textField)`)', + [expectType('text')] + ); + await expectErrors( + 'FROM index | EVAL RETURN_VALUE(doubleField, textField), TEST(`RETURN_VALUE(doubleField, textField)`)', + [expectType('long')] + ); + }); + }); + }); +}; diff --git a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/functions.test.ts b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/functions.test.ts index 648849ade3c09..b06771fde6eef 100644 --- a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/functions.test.ts +++ b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/functions.test.ts @@ -7,1271 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { - type FunctionDefinition, - FunctionDefinitionTypes, -} from '../../../commands/definitions/types'; -import { getNoValidCallSignatureError } from '../../../commands/definitions/utils/validation/utils'; -import { Location } from '../../../commands/registry/types'; -import { setTestFunctions } from '../../../commands/definitions/utils/test_functions'; import { setup } from './helpers'; -import { PARAM_TYPES_THAT_SUPPORT_IMPLICIT_STRING_CASTING } from '../../../commands/definitions/utils/signatures'; +import { runFunctionsValidationSuite } from './functions_suite'; -describe('function validation', () => { - afterEach(() => { - setTestFunctions([]); - }); - - describe('parameter validation', () => { - describe('type validation', () => { - describe('basic checks', () => { - beforeEach(() => { - const definitions: FunctionDefinition[] = [ - { - name: 'test', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.EVAL], - signatures: [ - { - params: [{ name: 'arg1', type: 'integer' }], - returnType: 'integer', - }, - { - params: [{ name: 'arg1', type: 'date' }], - returnType: 'date', - }, - ], - }, - { - name: 'returns_integer', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.EVAL], - signatures: [ - { - params: [], - returnType: 'integer', - }, - ], - }, - { - name: 'returns_double', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.EVAL], - signatures: [ - { - params: [], - returnType: 'double', - }, - ], - }, - ]; - - setTestFunctions(definitions); - }); - - it('accepts arguments of the correct type', async () => { - const { expectErrors } = await setup(); - - // straight call - await expectErrors('FROM a_index | EVAL TEST(1)', []); - await expectErrors('FROM a_index | EVAL TEST(NOW())', []); - - // assignment - await expectErrors('FROM a_index | EVAL var = TEST(1)', []); - await expectErrors('FROM a_index | EVAL var = TEST(NOW())', []); - - // nested function - await expectErrors('FROM a_index | EVAL TEST(RETURNS_INTEGER())', []); - - // inline cast - await expectErrors('FROM a_index | EVAL TEST(1.::INT)', []); - - // field - await expectErrors('FROM a_index | EVAL TEST(integerField)', []); - await expectErrors('FROM a_index | EVAL TEST(dateField)', []); - - // userDefinedColumns - await expectErrors('FROM a_index | EVAL col1 = 1 | EVAL TEST(col1)', []); - await expectErrors('FROM a_index | EVAL col1 = NOW() | EVAL TEST(col1)', []); - - // multiple instances - await expectErrors('FROM a_index | EVAL TEST(1) | EVAL TEST(1)', []); - }); - - it('rejects arguments of an incorrect type', async () => { - const { expectErrors } = await setup(); - - // straight call - await expectErrors('FROM a_index | EVAL TEST(1.1)', [ - getNoValidCallSignatureError('test', ['double']), - ]); - - // assignment - await expectErrors('FROM a_index | EVAL var = TEST(1.1)', [ - getNoValidCallSignatureError('test', ['double']), - ]); - - // nested function - await expectErrors('FROM a_index | EVAL TEST(RETURNS_DOUBLE())', [ - getNoValidCallSignatureError('test', ['double']), - ]); - - // inline cast - await expectErrors('FROM a_index | EVAL TEST(1::DOUBLE)', [ - getNoValidCallSignatureError('test', ['double']), - ]); - - // field - await expectErrors('FROM a_index | EVAL TEST(doubleField)', [ - getNoValidCallSignatureError('test', ['double']), - ]); - - // userDefinedColumns - await expectErrors('FROM a_index | EVAL col1 = 1. | EVAL TEST(col1)', [ - getNoValidCallSignatureError('test', ['double']), - ]); - - // multiple instances - await expectErrors('FROM a_index | EVAL TEST(1.1) | EVAL TEST(1.1)', [ - getNoValidCallSignatureError('test', ['double']), - getNoValidCallSignatureError('test', ['double']), - ]); - }); - - it('accepts nulls by default', async () => { - const { expectErrors } = await setup(); - await expectErrors('FROM a_index | EVAL TEST(NULL)', []); - }); - }); - - describe('special scenarios', () => { - it('any type', async () => { - const testFn: FunctionDefinition = { - name: 'test', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.EVAL], - signatures: [ - { - params: [{ name: 'arg1', type: 'any' }], - returnType: 'integer', - }, - ], - }; - - setTestFunctions([testFn]); - - const { expectErrors } = await setup(); - - await expectErrors('FROM a_index | EVAL TEST(1)', []); - await expectErrors('FROM a_index | EVAL TEST("keyword")', []); - await expectErrors('FROM a_index | EVAL TEST(2.)', []); - await expectErrors('FROM a_index | EVAL TEST(to_cartesianpoint(""))', []); - await expectErrors('FROM a_index | EVAL TEST(NOW())', []); - }); - - it('list type', async () => { - const testFn: FunctionDefinition = { - name: 'in', - type: FunctionDefinitionTypes.OPERATOR, - description: '', - locationsAvailable: [Location.ROW], - signatures: [ - { - params: [ - { name: 'arg1', type: 'keyword' }, - { name: 'arg2', type: 'keyword[]' }, - ], - returnType: 'boolean', - }, - ], - }; - - setTestFunctions([testFn]); - - const { expectErrors } = await setup(); - - await expectErrors('ROW "a" IN ("a", "b", "c")', []); - await expectErrors('ROW "a" IN (1, 2)', [ - getNoValidCallSignatureError('in', ['keyword', 'integer']), // @TODO look at reporting array type - ]); - }); - - describe('implicit string casting', () => { - it.each(PARAM_TYPES_THAT_SUPPORT_IMPLICIT_STRING_CASTING)( - 'accepts string arguments for %s', - async (paramType) => { - setTestFunctions([ - { - name: 'test', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.EVAL], - signatures: [ - { - params: [{ name: 'arg1', type: paramType }], - returnType: 'date', - }, - ], - }, - ]); - - const { expectErrors } = await setup(); - - await expectErrors('FROM a_index | EVAL TEST("")', []); - } - ); - }); - - it('treats text and keyword as interchangeable', async () => { - setTestFunctions([ - { - name: 'accepts_text', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.EVAL], - signatures: [ - { - params: [{ name: 'arg1', type: 'text' }], - returnType: 'keyword', - }, - ], - }, - { - name: 'accepts_keyword', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.EVAL], - signatures: [ - { - params: [{ name: 'arg1', type: 'keyword' }], - returnType: 'keyword', - }, - ], - }, - { - name: 'returns_keyword', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.EVAL], - signatures: [ - { - params: [], - returnType: 'keyword', - }, - ], - }, - ]); - - const { expectErrors } = await setup(); - - // literals — all string literals are keywords - await expectErrors('FROM a_index | EVAL ACCEPTS_TEXT("keyword literal")', []); - - // fields - await expectErrors('FROM a_index | EVAL ACCEPTS_KEYWORD(textField)', []); - await expectErrors('FROM a_index | EVAL ACCEPTS_TEXT(keywordField)', []); - - // functions - // no need to test a function that returns text, because they no longer exist: https://github.com/elastic/elasticsearch/pull/114334 - await expectErrors('FROM a_index | EVAL ACCEPTS_TEXT(RETURNS_KEYWORD())', []); - }); - - it('detects a missing column', async () => { - setTestFunctions([ - { - name: 'test', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.EVAL], - signatures: [ - { - params: [{ name: 'arg1', type: 'keyword' }], - returnType: 'keyword', - }, - ], - }, - ]); - - const { expectErrors } = await setup(); - - await expectErrors('FROM a_index | EVAL TEST(missingColumn)', [ - 'Unknown column "missingColumn"', - ]); - - await expectErrors('FROM a_index | EVAL foo=missingColumn', [ - 'Unknown column "missingColumn"', - ]); - }); - - describe('inline casts', () => { - it('validates a nested function within an inline cast', async () => { - setTestFunctions([ - { - name: 'test', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.EVAL], - signatures: [ - { - params: [{ name: 'arg1', type: 'integer' }], - returnType: 'integer', - }, - ], - }, - ]); - - const { expectErrors } = await setup(); - - await expectErrors('FROM a_index | EVAL TEST(TEST("")::integer)', [ - getNoValidCallSignatureError('test', ['keyword']), - ]); - // deep nesting - await expectErrors('FROM a_index | EVAL TEST(TEST("")::double::keyword::integer)', [ - getNoValidCallSignatureError('test', ['keyword']), - ]); - }); - - it.skip('validates a top-level function within an inline cast', async () => { - setTestFunctions([ - { - name: 'test', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.EVAL], - signatures: [ - { - params: [{ name: 'arg1', type: 'integer' }], - returnType: 'integer', - }, - ], - }, - ]); - - const { expectErrors } = await setup(); - - await expectErrors('FROM a_index | EVAL TEST("")::cartesian_point', [ - getNoValidCallSignatureError('test', ['keyword']), - ]); - }); - - // This test may seem obvious, but it is here to guard against - // erroring because we are checking the types of signatures that - // haven't yet been correctly filtered by arity, which leads to - // a thrown error and no validation error message - it('correctly handles signatures of different lengths', async () => { - setTestFunctions([ - { - name: 'test', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.EVAL], - signatures: [ - // the order of these signatures is important. - // the shorter one is first so that if they aren't - // filtered down properly, the 2 arguments in the invocation - // will run off the 1 argument signature - { - params: [{ name: 'arg1', type: 'keyword' }], - returnType: 'keyword', - }, - { - params: [ - { name: 'arg1', type: 'integer' }, - { name: 'arg2', type: 'integer' }, - ], - returnType: 'integer', - }, - ], - }, - ]); - - const { expectErrors } = await setup(); - - await expectErrors('FROM a_index | EVAL TEST("", "")', [ - getNoValidCallSignatureError('test', ['keyword', 'keyword']), - ]); - }); - }); - - it('skips column validation for left assignment arg', async () => { - const { expectErrors } = await setup(); - - await expectErrors('FROM a_index | EVAL lolz = 2', []); - await expectErrors('FROM a_index | EVAL lolz = nonexistent', [ - 'Unknown column "nonexistent"', - ]); - }); - - it('skips column validation for right arg to AS', async () => { - const { expectErrors } = await setup(); - - await expectErrors('FROM a_index | RENAME keywordField AS lolz', []); - await expectErrors('FROM a_index | RENAME nonexistent AS lolz', [ - 'Unknown column "nonexistent"', - ]); - }); - }); - }); - - it('validates argument count (arity)', async () => { - const testFns: FunctionDefinition[] = [ - { - name: 'test', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.EVAL], - signatures: [ - { - params: [{ name: 'arg1', type: 'keyword' }], - returnType: 'keyword', - }, - { - params: [ - { name: 'arg1', type: 'integer' }, - { name: 'arg2', type: 'integer' }, - ], - returnType: 'integer', - }, - { - params: [ - { name: 'arg1', type: 'integer' }, - { name: 'arg2', type: 'integer' }, - { name: 'arg3', type: 'integer' }, - ], - returnType: 'integer', - }, - ], - }, - { - name: 'expects_1_arg_fn', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.EVAL], - signatures: [ - { - params: [{ name: 'arg1', type: 'integer' }], - returnType: 'integer', - }, - ], - }, - { - name: 'expects_2_args_fn', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.EVAL], - signatures: [ - { - params: [ - { name: 'arg1', type: 'integer' }, - { name: 'arg2', type: 'integer' }, - ], - returnType: 'integer', - }, - ], - }, - { - name: 'variadic_fn', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.EVAL], - signatures: [ - { - params: [{ name: 'arg1', type: 'integer' }], - minParams: 2, - returnType: 'integer', - }, - ], - }, - ]; - - setTestFunctions(testFns); - - const { expectErrors } = await setup(); - - // several signatures, different arities - await expectErrors('FROM a_index | EVAL TEST()', [ - 'TEST expected 1, 2, or 3 arguments, but got 0.', - ]); - await expectErrors('FROM a_index | EVAL TEST(1, 1, 1, 1)', [ - 'TEST expected 1, 2, or 3 arguments, but got 4.', - ]); - - // exact number of arguments - await expectErrors('FROM a_index | EVAL EXPECTS_1_ARG_FN(1, 1, 2)', [ - 'EXPECTS_1_ARG_FN expected one argument, but got 3.', - ]); - - await expectErrors('FROM a_index | EVAL EXPECTS_2_ARGS_FN(1, 1, 2)', [ - 'EXPECTS_2_ARGS_FN expected 2 arguments, but got 3.', - ]); - - // minimum number of arguments - await expectErrors(`FROM a_index | EVAL VARIADIC_FN(1)`, [ - 'VARIADIC_FN expected at least 2 arguments, but got 1.', - ]); - await expectErrors( - `FROM a_index | EVAL VARIADIC_FN(${new Array(100).fill(1).join(', ')})`, - [] - ); - }); - - it('allows for optional arguments', async () => { - const testFns: FunctionDefinition[] = [ - { - name: 'test', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.EVAL], - signatures: [ - { - params: [ - { name: 'arg1', type: 'keyword' }, - { name: 'arg2', type: 'keyword', optional: true }, - ], - returnType: 'keyword', - }, - ], - }, - ]; - - setTestFunctions(testFns); - - const { expectErrors } = await setup(); - - await expectErrors('FROM a_index | EVAL TEST("")', []); - await expectErrors('FROM a_index | EVAL TEST("", "")', []); - }); - - describe('values of unknown type', () => { - it('doesnt validate user-defined columns of type unknown', async () => { - setTestFunctions([ - { - name: 'test1', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.EVAL], - signatures: [ - { - params: [{ name: 'arg1', type: 'keyword' }], - returnType: 'keyword', - }, - ], - }, - { - name: 'test2', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.EVAL], - signatures: [ - { - params: [{ name: 'arg1', type: 'keyword' }], - returnType: 'keyword', - }, - ], - }, - { - name: 'test3', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.EVAL], - signatures: [ - { - params: [{ name: 'arg1', type: 'long' }], - returnType: 'keyword', - }, - ], - }, - ]); - - const { validate } = await setup(); - const errors = ( - await validate( - `FROM a_index - | EVAL foo = TEST1(1.) // creates foo as unknown value - | EVAL TEST2(foo) // shouldn't error, foo is unknown - | EVAL TEST3(foo) // shouldn't error, foo is unknown` - ) - ).errors; - - expect(errors).toHaveLength(1); - }); - }); - - describe('nested functions', () => { - it('supports deep nesting', async () => { - setTestFunctions([ - { - name: 'test', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.EVAL], - signatures: [ - { - params: [{ name: 'arg1', type: 'keyword' }], - returnType: 'integer', - }, - ], - }, - { - name: 'test2', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.EVAL], - signatures: [ - { - params: [{ name: 'arg1', type: 'integer' }], - returnType: 'keyword', - }, - ], - }, - ]); - - const { expectErrors } = await setup(); - - await expectErrors('FROM a_index | EVAL TEST(TEST2(TEST(TEST2(1))))', []); - }); - - // @TODO — test function aliases - }); - }); - - describe('checks locations allowed', () => { - it('validates command support', async () => { - setTestFunctions([ - { - name: 'eval_fn', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.EVAL], - signatures: [ - { - params: [], - returnType: 'keyword', - }, - ], - }, - { - name: 'stats_fn', - type: FunctionDefinitionTypes.AGG, - description: '', - locationsAvailable: [Location.STATS], - signatures: [ - { - params: [], - returnType: 'keyword', - }, - ], - }, - { - name: 'row_fn', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.ROW], - signatures: [ - { - params: [], - returnType: 'keyword', - }, - ], - }, - { - name: 'where_fn', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.WHERE], - signatures: [ - { - params: [], - returnType: 'keyword', - }, - ], - }, - { - name: 'sort_fn', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.SORT], - signatures: [ - { - params: [], - returnType: 'keyword', - }, - ], - }, - ]); - - const { expectErrors } = await setup(); - - await expectErrors('FROM a_index | EVAL EVAL_FN()', []); - await expectErrors('FROM a_index | SORT SORT_FN()', []); - await expectErrors('FROM a_index | STATS max(doubleField)', []); - await expectErrors('ROW ROW_FN()', []); - await expectErrors('FROM a_index | WHERE WHERE_FN()', []); - - await expectErrors('FROM a_index | EVAL SORT_FN()', ['Function SORT_FN not allowed in EVAL']); - await expectErrors('FROM a_index | SORT STATS_FN()', [ - 'Function STATS_FN not allowed in SORT', - ]); - await expectErrors('FROM a_index | STATS ROW_FN()', ['Function ROW_FN not allowed in STATS']); - await expectErrors('ROW WHERE_FN()', ['Function WHERE_FN not allowed in ROW']); - await expectErrors('FROM a_index | WHERE EVAL_FN()', [ - 'Function EVAL_FN not allowed in WHERE', - ]); - }); - - it('validates option support', async () => { - setTestFunctions([ - { - name: 'supports_by_option', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.EVAL, Location.STATS_BY], - signatures: [ - { - params: [], - returnType: 'keyword', - }, - ], - }, - { - name: 'does_not_support_by_option', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.EVAL], - signatures: [ - { - params: [], - returnType: 'keyword', - }, - ], - }, - - { - name: 'agg_fn', - type: FunctionDefinitionTypes.AGG, - description: '', - locationsAvailable: [Location.STATS], - signatures: [ - { - params: [], - returnType: 'keyword', - }, - ], - }, - ]); - const { expectErrors } = await setup(); - await expectErrors('FROM a_index | STATS AGG_FN() BY SUPPORTS_BY_OPTION()', []); - await expectErrors('FROM a_index | STATS AGG_FN() BY DOES_NOT_SUPPORT_BY_OPTION()', [ - 'Function DOES_NOT_SUPPORT_BY_OPTION not allowed in BY', - ]); - }); - - it('validates timeseries function locations', async () => { - setTestFunctions([ - { - name: 'ts_function', - type: FunctionDefinitionTypes.TIME_SERIES_AGG, - description: '', - locationsAvailable: [Location.STATS_TIMESERIES], - signatures: [ - { - params: [], - returnType: 'double', - }, - ], - }, - { - name: 'agg_function', - type: FunctionDefinitionTypes.AGG, - description: '', - locationsAvailable: [Location.STATS], - signatures: [ - { - params: [{ name: 'field', type: 'double', optional: false }], - returnType: 'keyword', - }, - ], - }, - ]); - - const { expectErrors } = await setup(); - - await expectErrors('TS a_index | STATS AGG_FUNCTION(TS_FUNCTION())', []); - await expectErrors('FROM a_index | STATS AGG_FUNCTION(TS_FUNCTION())', [ - 'Function TS_FUNCTION not allowed in STATS', - ]); - - // TIME_SERIES_AGG functions are also allowed at the top level of STATS with TS source - await expectErrors('TS a_index | STATS TS_FUNCTION()', []); - await expectErrors('FROM a_index | STATS TS_FUNCTION()', [ - 'Function TS_FUNCTION not allowed in STATS', - ]); - }); - - it('rejects tsdbCompatible:false functions in TS pipelines', async () => { - setTestFunctions([ - { - name: 'tsdb_incompatible_agg', - type: FunctionDefinitionTypes.AGG, - description: '', - locationsAvailable: [Location.STATS], - tsdbCompatible: false, - signatures: [{ params: [], returnType: 'double' }], - }, - ]); - - const { expectErrors } = await setup(); - - await expectErrors('TS a_index | STATS TSDB_INCOMPATIBLE_AGG()', [ - 'Function TSDB_INCOMPATIBLE_AGG is not supported in time series (TS) pipelines', - ]); - await expectErrors('FROM a_index | STATS TSDB_INCOMPATIBLE_AGG()', []); - }); - }); - - it('should flag nested aggregation functions', async () => { - setTestFunctions([ - { - name: 'agg_function_1', - type: FunctionDefinitionTypes.AGG, - description: '', - locationsAvailable: [Location.STATS], - signatures: [ - { - params: [{ name: 'field', type: 'keyword', optional: false }], - returnType: 'keyword', - }, - ], - }, - { - name: 'scalar_function', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.STATS], - signatures: [ - { - params: [{ name: 'field', type: 'keyword', optional: false }], - returnType: 'keyword', - }, - ], - }, - { - name: 'agg_function_2', - type: FunctionDefinitionTypes.AGG, - description: '', - locationsAvailable: [Location.STATS], - signatures: [ - { - params: [], - returnType: 'keyword', - }, - ], - }, - ]); - - const { expectErrors } = await setup(); - - await expectErrors('FROM a_index | STATS AGG_FUNCTION_1(AGG_FUNCTION_2())', [ - 'Aggregation functions cannot be nested. Found AGG_FUNCTION_2 in AGG_FUNCTION_1.', - ]); - - await expectErrors('FROM a_index | STATS AGG_FUNCTION_1(SCALAR_FUNCTION(AGG_FUNCTION_2()))', [ - 'Aggregation functions cannot be nested. Found AGG_FUNCTION_2 in AGG_FUNCTION_1.', - ]); - }); - - describe('param with hint.kind === "aggregation"', () => { - beforeEach(() => { - setTestFunctions([ - { - name: 'agg_outer', - type: FunctionDefinitionTypes.AGG, - description: '', - locationsAvailable: [Location.STATS], - signatures: [ - { - params: [ - { - name: 'aggregation', - type: 'double', - optional: false, - hint: { kind: 'aggregation' }, - }, - ], - returnType: 'double', - }, - ], - }, - { - name: 'agg_inner', - type: FunctionDefinitionTypes.AGG, - description: '', - locationsAvailable: [Location.STATS], - signatures: [ - { - params: [{ name: 'field', type: 'double', optional: false }], - returnType: 'double', - }, - ], - }, - { - name: 'scalar_inner', - type: FunctionDefinitionTypes.SCALAR, - description: '', - locationsAvailable: [Location.STATS], - signatures: [ - { - params: [{ name: 'field', type: 'double', optional: false }], - returnType: 'double', - }, - ], - }, - ]); - }); - - it('allows a nested aggregation at the hint-marked position', async () => { - const { expectErrors } = await setup(); - await expectErrors('FROM a_index | STATS AGG_OUTER(AGG_INNER(doubleField))', []); - }); - - it('reports an error when the hint-marked position receives a non-aggregation function', async () => { - const { expectErrors } = await setup(); - await expectErrors('FROM a_index | STATS AGG_OUTER(SCALAR_INNER(doubleField))', [ - 'This argument of AGG_OUTER must be an aggregation function.', - ]); - }); - - it('reports an error when the hint-marked position receives a column', async () => { - const { expectErrors } = await setup(); - await expectErrors('FROM a_index | STATS AGG_OUTER(doubleField)', [ - 'This argument of AGG_OUTER must be an aggregation function.', - ]); - }); - - it('still forbids nested aggregations inside the inner aggregation (the rule is only lifted at the hint position)', async () => { - const { expectErrors } = await setup(); - await expectErrors('FROM a_index | STATS AGG_OUTER(AGG_INNER(AGG_INNER(doubleField)))', [ - 'Aggregation functions cannot be nested. Found AGG_INNER in AGG_INNER.', - ]); - }); - }); - - it('should ignore a function whose name is defined by a parameter', async () => { - const { expectErrors } = await setup(); - - await expectErrors('FROM a_index | EVAL ??param(arg1)', []); - }); - - describe('License-based validation', () => { - beforeEach(() => { - setTestFunctions([ - { - type: FunctionDefinitionTypes.GROUPING, - name: 'platinum_function_mock', - description: '', - signatures: [ - { - params: [ - { - name: 'field', - type: 'keyword', - optional: false, - }, - ], - license: 'platinum', - returnType: 'keyword', - }, - { - params: [ - { - name: 'field', - type: 'text', - optional: false, - }, - ], - license: 'platinum', - returnType: 'keyword', - }, - ], - locationsAvailable: [Location.STATS, Location.STATS_BY], - license: 'platinum', - }, - { - type: FunctionDefinitionTypes.AGG, - name: 'platinum_partial_function_mock', - description: '', - signatures: [ - { - params: [ - { - name: 'field', - type: 'cartesian_point', - optional: false, - }, - ], - returnType: 'cartesian_shape', - }, - { - params: [ - { - name: 'field', - type: 'cartesian_shape', - optional: false, - }, - ], - license: 'platinum', - returnType: 'cartesian_shape', - }, - ], - locationsAvailable: [Location.STATS, Location.STATS_BY], - }, - ]); - }); - - describe('function-level licensing', () => { - it('should allow licensed function when license IS available', async () => { - const { expectErrors, callbacks } = await setup(); - - callbacks.getLicense = jest.fn(async () => ({ - hasAtLeast: () => true, - })); - - await expectErrors( - 'FROM a_index | STATS col0 = AVG(doubleField) BY PLATINUM_FUNCTION_MOCK(keywordField)', - [] - ); - }); - - it('should disallow licensed function when license NOT available', async () => { - const { expectErrors, callbacks } = await setup(); - - callbacks.getLicense = jest.fn(async () => ({ - hasAtLeast: () => false, - })); - - await expectErrors( - 'FROM a_index | STATS col0 = AVG(doubleField) BY PLATINUM_FUNCTION_MOCK()', - [ - 'PLATINUM_FUNCTION_MOCK requires a PLATINUM license.', - 'PLATINUM_FUNCTION_MOCK expected one argument, but got 0.', - ] - ); - - await expectErrors( - 'FROM a_index | STATS col0 = AVG(doubleField) BY PLATINUM_FUNCTION_MOCK(keywordField)', - ['PLATINUM_FUNCTION_MOCK requires a PLATINUM license.'] - ); - - await expectErrors('FROM a_index | STATS col0 = PLATINUM_FUNCTION_MOCK(keywordField)', [ - 'PLATINUM_FUNCTION_MOCK requires a PLATINUM license.', - ]); - }); - - it('should show license error even when nested functions also have errors', async () => { - const { expectErrors, callbacks } = await setup(); - - callbacks.getLicense = jest.fn(async () => ({ - hasAtLeast: () => false, - })); - - await expectErrors('FROM index | STATS extent = PLATINUM_FUNCTION_MOCK(FLOOR(""))', [ - 'PLATINUM_FUNCTION_MOCK requires a PLATINUM license.', - getNoValidCallSignatureError('floor', ['keyword']), - ]); - }); - }); - - describe('signature-level licensing', () => { - it('should allow licensed signature when license IS available', async () => { - const { expectErrors, callbacks } = await setup(); - - callbacks.getLicense = jest.fn(async () => ({ - hasAtLeast: () => true, - })); - - await expectErrors( - 'FROM index | STATS extent = PLATINUM_PARTIAL_FUNCTION_MOCK(TO_CARTESIANSHAPE("0,0"))', - [] - ); - }); - - it('should allow ambiguous invocation when it could match an available signature', async () => { - setTestFunctions([ - { - type: FunctionDefinitionTypes.SCALAR, - name: 'test', - description: '', - signatures: [ - { - params: [ - { - name: 'field', - type: 'integer', - optional: false, - }, - ], - license: 'platinum', // licensed signature - returnType: 'keyword', - }, - { - params: [ - { - name: 'field', - type: 'keyword', - optional: false, - }, - ], - license: undefined, // no license required - returnType: 'keyword', - }, - ], - locationsAvailable: [Location.EVAL], - }, - ]); - - const { expectErrors, callbacks } = await setup(); - - // make license unavailable - callbacks.getLicense = jest.fn(async () => ({ - hasAtLeast: () => false, - })); - - // make ambiguous call using a parameter "?param" that could match either signature - await expectErrors('FROM index | EVAL extent = TEST(?param)', []); - }); - - it('should disallow licensed signature when license NOT available', async () => { - const { expectErrors, callbacks } = await setup(); - - callbacks.getLicense = jest.fn(async () => ({ - hasAtLeast: () => false, - })); - - await expectErrors( - 'FROM index | STATS extent = PLATINUM_PARTIAL_FUNCTION_MOCK(TO_CARTESIANSHAPE("0,0"))', - [ - "PLATINUM_PARTIAL_FUNCTION_MOCK with 'field' of type 'cartesian_shape' requires a PLATINUM license.", - ] - ); - }); - - it("Should report various non-license errors even when the function isn't allowed", async () => { - const { expectErrors, callbacks } = await setup(); - - callbacks.getLicense = jest.fn(async () => ({ - hasAtLeast: () => false, - })); - - await expectErrors('FROM index | STATS result = PLATINUM_PARTIAL_FUNCTION_MOCK()', [ - 'PLATINUM_PARTIAL_FUNCTION_MOCK expected one argument, but got 0.', - ]); - - await expectErrors('FROM index | STATS result = PLATINUM_PARTIAL_FUNCTION_MOCK(0)', [ - getNoValidCallSignatureError('platinum_partial_function_mock', ['integer']), - ]); - - await expectErrors( - 'FROM index | STATS result = PLATINUM_PARTIAL_FUNCTION_MOCK(WrongField)', - ['Unknown column "WrongField"'] - ); - }); - - describe('operators in STATS WHERE context', () => { - it('should allow IS NOT NULL operator in STATS WHERE clause when validation is applied', async () => { - const { expectErrors } = await setup(); - - await expectErrors('FROM index | STATS COUNT() WHERE unknownField IS NOT NULL', [ - 'Unknown column "unknownField"', - ]); - }); - }); - }); - }); - - describe('conditional function validation', () => { - beforeEach(() => { - const definitions: FunctionDefinition[] = [ - { - name: 'conditional_mock', - type: FunctionDefinitionTypes.SCALAR, - description: 'Mock function with isSignatureRepeating', - locationsAvailable: [Location.EVAL], - signatures: [ - { - params: [ - { name: 'condition', type: 'boolean' }, - { name: 'value', type: 'any' }, - ], - returnType: 'unknown', - minParams: 2, - isSignatureRepeating: true, - }, - ], - }, - ]; - - setTestFunctions(definitions); - }); - - it('accepts compatible value types (text + keyword)', async () => { - const { expectErrors } = await setup(); - - await expectErrors( - 'FROM index | EVAL result = CONDITIONAL_MOCK(booleanField, textField, booleanField, keywordField)', - [] - ); - }); - - it('rejects incompatible value types', async () => { - const { expectErrors } = await setup(); - - await expectErrors( - 'FROM index | EVAL result = CONDITIONAL_MOCK(booleanField, longField, booleanField, "d")', - [ - getNoValidCallSignatureError('conditional_mock', [ - 'boolean', - 'long', - 'boolean', - 'keyword', - ]), - ] - ); - }); - - it('rejects string literal at condition position', async () => { - const { expectErrors } = await setup(); - - await expectErrors('FROM index | EVAL result = CONDITIONAL_MOCK("string", keywordField)', [ - getNoValidCallSignatureError('conditional_mock', ['keyword', 'keyword']), - ]); - }); - - it('allows null as a result value in combination with other types', async () => { - const { expectErrors } = await setup(); - - await expectErrors( - 'FROM index | EVAL result = CONDITIONAL_MOCK(booleanField, "text", booleanField, null)', - [] - ); - }); - - it('allows null as a result value in combination with other types, being null in the first position', async () => { - const { expectErrors } = await setup(); - - await expectErrors( - 'FROM index | EVAL result = CONDITIONAL_MOCK(booleanField, null, booleanField, "text")', - [] - ); - }); - - it('allows null as the elseValue in combination with other types', async () => { - const { expectErrors } = await setup(); - - await expectErrors('FROM index | EVAL result = CONDITIONAL_MOCK(booleanField, 42, null)', []); - }); - }); -}); +runFunctionsValidationSuite(setup); diff --git a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/functions_suite.ts b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/functions_suite.ts new file mode 100644 index 0000000000000..d9d64ea327629 --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/functions_suite.ts @@ -0,0 +1,1286 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { + type FunctionDefinition, + FunctionDefinitionTypes, +} from '../../../commands/definitions/types'; +import { getNoValidCallSignatureError } from '../../../commands/definitions/utils/validation/utils'; +import { Location } from '../../../commands/registry/types'; +import { setTestFunctions } from '../../../commands/definitions/utils/test_functions'; +import type { Setup } from './helpers'; +import { PARAM_TYPES_THAT_SUPPORT_IMPLICIT_STRING_CASTING } from '../../../commands/definitions/utils/signatures'; + +export const runFunctionsValidationSuite = (setup: Setup) => { + describe('function validation', () => { + afterEach(() => { + setTestFunctions([]); + }); + + describe('parameter validation', () => { + describe('type validation', () => { + describe('basic checks', () => { + beforeEach(() => { + const definitions: FunctionDefinition[] = [ + { + name: 'test', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.EVAL], + signatures: [ + { + params: [{ name: 'arg1', type: 'integer' }], + returnType: 'integer', + }, + { + params: [{ name: 'arg1', type: 'date' }], + returnType: 'date', + }, + ], + }, + { + name: 'returns_integer', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.EVAL], + signatures: [ + { + params: [], + returnType: 'integer', + }, + ], + }, + { + name: 'returns_double', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.EVAL], + signatures: [ + { + params: [], + returnType: 'double', + }, + ], + }, + ]; + + setTestFunctions(definitions); + }); + + it('accepts arguments of the correct type', async () => { + const { expectErrors } = await setup(); + + // straight call + await expectErrors('FROM a_index | EVAL TEST(1)', []); + await expectErrors('FROM a_index | EVAL TEST(NOW())', []); + + // assignment + await expectErrors('FROM a_index | EVAL var = TEST(1)', []); + await expectErrors('FROM a_index | EVAL var = TEST(NOW())', []); + + // nested function + await expectErrors('FROM a_index | EVAL TEST(RETURNS_INTEGER())', []); + + // inline cast + await expectErrors('FROM a_index | EVAL TEST(1.::INT)', []); + + // field + await expectErrors('FROM a_index | EVAL TEST(integerField)', []); + await expectErrors('FROM a_index | EVAL TEST(dateField)', []); + + // userDefinedColumns + await expectErrors('FROM a_index | EVAL col1 = 1 | EVAL TEST(col1)', []); + await expectErrors('FROM a_index | EVAL col1 = NOW() | EVAL TEST(col1)', []); + + // multiple instances + await expectErrors('FROM a_index | EVAL TEST(1) | EVAL TEST(1)', []); + }); + + it('rejects arguments of an incorrect type', async () => { + const { expectErrors } = await setup(); + + // straight call + await expectErrors('FROM a_index | EVAL TEST(1.1)', [ + getNoValidCallSignatureError('test', ['double']), + ]); + + // assignment + await expectErrors('FROM a_index | EVAL var = TEST(1.1)', [ + getNoValidCallSignatureError('test', ['double']), + ]); + + // nested function + await expectErrors('FROM a_index | EVAL TEST(RETURNS_DOUBLE())', [ + getNoValidCallSignatureError('test', ['double']), + ]); + + // inline cast + await expectErrors('FROM a_index | EVAL TEST(1::DOUBLE)', [ + getNoValidCallSignatureError('test', ['double']), + ]); + + // field + await expectErrors('FROM a_index | EVAL TEST(doubleField)', [ + getNoValidCallSignatureError('test', ['double']), + ]); + + // userDefinedColumns + await expectErrors('FROM a_index | EVAL col1 = 1. | EVAL TEST(col1)', [ + getNoValidCallSignatureError('test', ['double']), + ]); + + // multiple instances + await expectErrors('FROM a_index | EVAL TEST(1.1) | EVAL TEST(1.1)', [ + getNoValidCallSignatureError('test', ['double']), + getNoValidCallSignatureError('test', ['double']), + ]); + }); + + it('accepts nulls by default', async () => { + const { expectErrors } = await setup(); + await expectErrors('FROM a_index | EVAL TEST(NULL)', []); + }); + }); + + describe('special scenarios', () => { + it('any type', async () => { + const testFn: FunctionDefinition = { + name: 'test', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.EVAL], + signatures: [ + { + params: [{ name: 'arg1', type: 'any' }], + returnType: 'integer', + }, + ], + }; + + setTestFunctions([testFn]); + + const { expectErrors } = await setup(); + + await expectErrors('FROM a_index | EVAL TEST(1)', []); + await expectErrors('FROM a_index | EVAL TEST("keyword")', []); + await expectErrors('FROM a_index | EVAL TEST(2.)', []); + await expectErrors('FROM a_index | EVAL TEST(to_cartesianpoint(""))', []); + await expectErrors('FROM a_index | EVAL TEST(NOW())', []); + }); + + it('list type', async () => { + const testFn: FunctionDefinition = { + name: 'in', + type: FunctionDefinitionTypes.OPERATOR, + description: '', + locationsAvailable: [Location.ROW], + signatures: [ + { + params: [ + { name: 'arg1', type: 'keyword' }, + { name: 'arg2', type: 'keyword[]' }, + ], + returnType: 'boolean', + }, + ], + }; + + setTestFunctions([testFn]); + + const { expectErrors } = await setup(); + + await expectErrors('ROW "a" IN ("a", "b", "c")', []); + await expectErrors('ROW "a" IN (1, 2)', [ + getNoValidCallSignatureError('in', ['keyword', 'integer']), // @TODO look at reporting array type + ]); + }); + + describe('implicit string casting', () => { + it.each(PARAM_TYPES_THAT_SUPPORT_IMPLICIT_STRING_CASTING)( + 'accepts string arguments for %s', + async (paramType) => { + setTestFunctions([ + { + name: 'test', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.EVAL], + signatures: [ + { + params: [{ name: 'arg1', type: paramType }], + returnType: 'date', + }, + ], + }, + ]); + + const { expectErrors } = await setup(); + + await expectErrors('FROM a_index | EVAL TEST("")', []); + } + ); + }); + + it('treats text and keyword as interchangeable', async () => { + setTestFunctions([ + { + name: 'accepts_text', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.EVAL], + signatures: [ + { + params: [{ name: 'arg1', type: 'text' }], + returnType: 'keyword', + }, + ], + }, + { + name: 'accepts_keyword', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.EVAL], + signatures: [ + { + params: [{ name: 'arg1', type: 'keyword' }], + returnType: 'keyword', + }, + ], + }, + { + name: 'returns_keyword', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.EVAL], + signatures: [ + { + params: [], + returnType: 'keyword', + }, + ], + }, + ]); + + const { expectErrors } = await setup(); + + // literals — all string literals are keywords + await expectErrors('FROM a_index | EVAL ACCEPTS_TEXT("keyword literal")', []); + + // fields + await expectErrors('FROM a_index | EVAL ACCEPTS_KEYWORD(textField)', []); + await expectErrors('FROM a_index | EVAL ACCEPTS_TEXT(keywordField)', []); + + // functions + // no need to test a function that returns text, because they no longer exist: https://github.com/elastic/elasticsearch/pull/114334 + await expectErrors('FROM a_index | EVAL ACCEPTS_TEXT(RETURNS_KEYWORD())', []); + }); + + it('detects a missing column', async () => { + setTestFunctions([ + { + name: 'test', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.EVAL], + signatures: [ + { + params: [{ name: 'arg1', type: 'keyword' }], + returnType: 'keyword', + }, + ], + }, + ]); + + const { expectErrors } = await setup(); + + await expectErrors('FROM a_index | EVAL TEST(missingColumn)', [ + 'Unknown column "missingColumn"', + ]); + + await expectErrors('FROM a_index | EVAL foo=missingColumn', [ + 'Unknown column "missingColumn"', + ]); + }); + + describe('inline casts', () => { + it('validates a nested function within an inline cast', async () => { + setTestFunctions([ + { + name: 'test', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.EVAL], + signatures: [ + { + params: [{ name: 'arg1', type: 'integer' }], + returnType: 'integer', + }, + ], + }, + ]); + + const { expectErrors } = await setup(); + + await expectErrors('FROM a_index | EVAL TEST(TEST("")::integer)', [ + getNoValidCallSignatureError('test', ['keyword']), + ]); + // deep nesting + await expectErrors('FROM a_index | EVAL TEST(TEST("")::double::keyword::integer)', [ + getNoValidCallSignatureError('test', ['keyword']), + ]); + }); + + it.skip('validates a top-level function within an inline cast', async () => { + setTestFunctions([ + { + name: 'test', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.EVAL], + signatures: [ + { + params: [{ name: 'arg1', type: 'integer' }], + returnType: 'integer', + }, + ], + }, + ]); + + const { expectErrors } = await setup(); + + await expectErrors('FROM a_index | EVAL TEST("")::cartesian_point', [ + getNoValidCallSignatureError('test', ['keyword']), + ]); + }); + + // This test may seem obvious, but it is here to guard against + // erroring because we are checking the types of signatures that + // haven't yet been correctly filtered by arity, which leads to + // a thrown error and no validation error message + it('correctly handles signatures of different lengths', async () => { + setTestFunctions([ + { + name: 'test', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.EVAL], + signatures: [ + // the order of these signatures is important. + // the shorter one is first so that if they aren't + // filtered down properly, the 2 arguments in the invocation + // will run off the 1 argument signature + { + params: [{ name: 'arg1', type: 'keyword' }], + returnType: 'keyword', + }, + { + params: [ + { name: 'arg1', type: 'integer' }, + { name: 'arg2', type: 'integer' }, + ], + returnType: 'integer', + }, + ], + }, + ]); + + const { expectErrors } = await setup(); + + await expectErrors('FROM a_index | EVAL TEST("", "")', [ + getNoValidCallSignatureError('test', ['keyword', 'keyword']), + ]); + }); + }); + + it('skips column validation for left assignment arg', async () => { + const { expectErrors } = await setup(); + + await expectErrors('FROM a_index | EVAL lolz = 2', []); + await expectErrors('FROM a_index | EVAL lolz = nonexistent', [ + 'Unknown column "nonexistent"', + ]); + }); + + it('skips column validation for right arg to AS', async () => { + const { expectErrors } = await setup(); + + await expectErrors('FROM a_index | RENAME keywordField AS lolz', []); + await expectErrors('FROM a_index | RENAME nonexistent AS lolz', [ + 'Unknown column "nonexistent"', + ]); + }); + }); + }); + + it('validates argument count (arity)', async () => { + const testFns: FunctionDefinition[] = [ + { + name: 'test', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.EVAL], + signatures: [ + { + params: [{ name: 'arg1', type: 'keyword' }], + returnType: 'keyword', + }, + { + params: [ + { name: 'arg1', type: 'integer' }, + { name: 'arg2', type: 'integer' }, + ], + returnType: 'integer', + }, + { + params: [ + { name: 'arg1', type: 'integer' }, + { name: 'arg2', type: 'integer' }, + { name: 'arg3', type: 'integer' }, + ], + returnType: 'integer', + }, + ], + }, + { + name: 'expects_1_arg_fn', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.EVAL], + signatures: [ + { + params: [{ name: 'arg1', type: 'integer' }], + returnType: 'integer', + }, + ], + }, + { + name: 'expects_2_args_fn', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.EVAL], + signatures: [ + { + params: [ + { name: 'arg1', type: 'integer' }, + { name: 'arg2', type: 'integer' }, + ], + returnType: 'integer', + }, + ], + }, + { + name: 'variadic_fn', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.EVAL], + signatures: [ + { + params: [{ name: 'arg1', type: 'integer' }], + minParams: 2, + returnType: 'integer', + }, + ], + }, + ]; + + setTestFunctions(testFns); + + const { expectErrors } = await setup(); + + // several signatures, different arities + await expectErrors('FROM a_index | EVAL TEST()', [ + 'TEST expected 1, 2, or 3 arguments, but got 0.', + ]); + await expectErrors('FROM a_index | EVAL TEST(1, 1, 1, 1)', [ + 'TEST expected 1, 2, or 3 arguments, but got 4.', + ]); + + // exact number of arguments + await expectErrors('FROM a_index | EVAL EXPECTS_1_ARG_FN(1, 1, 2)', [ + 'EXPECTS_1_ARG_FN expected one argument, but got 3.', + ]); + + await expectErrors('FROM a_index | EVAL EXPECTS_2_ARGS_FN(1, 1, 2)', [ + 'EXPECTS_2_ARGS_FN expected 2 arguments, but got 3.', + ]); + + // minimum number of arguments + await expectErrors(`FROM a_index | EVAL VARIADIC_FN(1)`, [ + 'VARIADIC_FN expected at least 2 arguments, but got 1.', + ]); + await expectErrors( + `FROM a_index | EVAL VARIADIC_FN(${new Array(100).fill(1).join(', ')})`, + [] + ); + }); + + it('allows for optional arguments', async () => { + const testFns: FunctionDefinition[] = [ + { + name: 'test', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.EVAL], + signatures: [ + { + params: [ + { name: 'arg1', type: 'keyword' }, + { name: 'arg2', type: 'keyword', optional: true }, + ], + returnType: 'keyword', + }, + ], + }, + ]; + + setTestFunctions(testFns); + + const { expectErrors } = await setup(); + + await expectErrors('FROM a_index | EVAL TEST("")', []); + await expectErrors('FROM a_index | EVAL TEST("", "")', []); + }); + + describe('values of unknown type', () => { + it('doesnt validate user-defined columns of type unknown', async () => { + setTestFunctions([ + { + name: 'test1', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.EVAL], + signatures: [ + { + params: [{ name: 'arg1', type: 'keyword' }], + returnType: 'keyword', + }, + ], + }, + { + name: 'test2', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.EVAL], + signatures: [ + { + params: [{ name: 'arg1', type: 'keyword' }], + returnType: 'keyword', + }, + ], + }, + { + name: 'test3', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.EVAL], + signatures: [ + { + params: [{ name: 'arg1', type: 'long' }], + returnType: 'keyword', + }, + ], + }, + ]); + + const { validate } = await setup(); + const errors = ( + await validate( + `FROM a_index + | EVAL foo = TEST1(1.) // creates foo as unknown value + | EVAL TEST2(foo) // shouldn't error, foo is unknown + | EVAL TEST3(foo) // shouldn't error, foo is unknown` + ) + ).errors; + + expect(errors).toHaveLength(1); + }); + }); + + describe('nested functions', () => { + it('supports deep nesting', async () => { + setTestFunctions([ + { + name: 'test', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.EVAL], + signatures: [ + { + params: [{ name: 'arg1', type: 'keyword' }], + returnType: 'integer', + }, + ], + }, + { + name: 'test2', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.EVAL], + signatures: [ + { + params: [{ name: 'arg1', type: 'integer' }], + returnType: 'keyword', + }, + ], + }, + ]); + + const { expectErrors } = await setup(); + + await expectErrors('FROM a_index | EVAL TEST(TEST2(TEST(TEST2(1))))', []); + }); + + // @TODO — test function aliases + }); + }); + + describe('checks locations allowed', () => { + it('validates command support', async () => { + setTestFunctions([ + { + name: 'eval_fn', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.EVAL], + signatures: [ + { + params: [], + returnType: 'keyword', + }, + ], + }, + { + name: 'stats_fn', + type: FunctionDefinitionTypes.AGG, + description: '', + locationsAvailable: [Location.STATS], + signatures: [ + { + params: [], + returnType: 'keyword', + }, + ], + }, + { + name: 'row_fn', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.ROW], + signatures: [ + { + params: [], + returnType: 'keyword', + }, + ], + }, + { + name: 'where_fn', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.WHERE], + signatures: [ + { + params: [], + returnType: 'keyword', + }, + ], + }, + { + name: 'sort_fn', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.SORT], + signatures: [ + { + params: [], + returnType: 'keyword', + }, + ], + }, + ]); + + const { expectErrors } = await setup(); + + await expectErrors('FROM a_index | EVAL EVAL_FN()', []); + await expectErrors('FROM a_index | SORT SORT_FN()', []); + await expectErrors('FROM a_index | STATS max(doubleField)', []); + await expectErrors('ROW ROW_FN()', []); + await expectErrors('FROM a_index | WHERE WHERE_FN()', []); + + await expectErrors('FROM a_index | EVAL SORT_FN()', [ + 'Function SORT_FN not allowed in EVAL', + ]); + await expectErrors('FROM a_index | SORT STATS_FN()', [ + 'Function STATS_FN not allowed in SORT', + ]); + await expectErrors('FROM a_index | STATS ROW_FN()', [ + 'Function ROW_FN not allowed in STATS', + ]); + await expectErrors('ROW WHERE_FN()', ['Function WHERE_FN not allowed in ROW']); + await expectErrors('FROM a_index | WHERE EVAL_FN()', [ + 'Function EVAL_FN not allowed in WHERE', + ]); + }); + + it('validates option support', async () => { + setTestFunctions([ + { + name: 'supports_by_option', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.EVAL, Location.STATS_BY], + signatures: [ + { + params: [], + returnType: 'keyword', + }, + ], + }, + { + name: 'does_not_support_by_option', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.EVAL], + signatures: [ + { + params: [], + returnType: 'keyword', + }, + ], + }, + + { + name: 'agg_fn', + type: FunctionDefinitionTypes.AGG, + description: '', + locationsAvailable: [Location.STATS], + signatures: [ + { + params: [], + returnType: 'keyword', + }, + ], + }, + ]); + const { expectErrors } = await setup(); + await expectErrors('FROM a_index | STATS AGG_FN() BY SUPPORTS_BY_OPTION()', []); + await expectErrors('FROM a_index | STATS AGG_FN() BY DOES_NOT_SUPPORT_BY_OPTION()', [ + 'Function DOES_NOT_SUPPORT_BY_OPTION not allowed in BY', + ]); + }); + + it('validates timeseries function locations', async () => { + setTestFunctions([ + { + name: 'ts_function', + type: FunctionDefinitionTypes.TIME_SERIES_AGG, + description: '', + locationsAvailable: [Location.STATS_TIMESERIES], + signatures: [ + { + params: [], + returnType: 'double', + }, + ], + }, + { + name: 'agg_function', + type: FunctionDefinitionTypes.AGG, + description: '', + locationsAvailable: [Location.STATS], + signatures: [ + { + params: [{ name: 'field', type: 'double', optional: false }], + returnType: 'keyword', + }, + ], + }, + ]); + + const { expectErrors } = await setup(); + + await expectErrors('TS a_index | STATS AGG_FUNCTION(TS_FUNCTION())', []); + await expectErrors('FROM a_index | STATS AGG_FUNCTION(TS_FUNCTION())', [ + 'Function TS_FUNCTION not allowed in STATS', + ]); + + // TIME_SERIES_AGG functions are also allowed at the top level of STATS with TS source + await expectErrors('TS a_index | STATS TS_FUNCTION()', []); + await expectErrors('FROM a_index | STATS TS_FUNCTION()', [ + 'Function TS_FUNCTION not allowed in STATS', + ]); + }); + + it('rejects tsdbCompatible:false functions in TS pipelines', async () => { + setTestFunctions([ + { + name: 'tsdb_incompatible_agg', + type: FunctionDefinitionTypes.AGG, + description: '', + locationsAvailable: [Location.STATS], + tsdbCompatible: false, + signatures: [{ params: [], returnType: 'double' }], + }, + ]); + + const { expectErrors } = await setup(); + + await expectErrors('TS a_index | STATS TSDB_INCOMPATIBLE_AGG()', [ + 'Function TSDB_INCOMPATIBLE_AGG is not supported in time series (TS) pipelines', + ]); + await expectErrors('FROM a_index | STATS TSDB_INCOMPATIBLE_AGG()', []); + }); + }); + + it('should flag nested aggregation functions', async () => { + setTestFunctions([ + { + name: 'agg_function_1', + type: FunctionDefinitionTypes.AGG, + description: '', + locationsAvailable: [Location.STATS], + signatures: [ + { + params: [{ name: 'field', type: 'keyword', optional: false }], + returnType: 'keyword', + }, + ], + }, + { + name: 'scalar_function', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.STATS], + signatures: [ + { + params: [{ name: 'field', type: 'keyword', optional: false }], + returnType: 'keyword', + }, + ], + }, + { + name: 'agg_function_2', + type: FunctionDefinitionTypes.AGG, + description: '', + locationsAvailable: [Location.STATS], + signatures: [ + { + params: [], + returnType: 'keyword', + }, + ], + }, + ]); + + const { expectErrors } = await setup(); + + await expectErrors('FROM a_index | STATS AGG_FUNCTION_1(AGG_FUNCTION_2())', [ + 'Aggregation functions cannot be nested. Found AGG_FUNCTION_2 in AGG_FUNCTION_1.', + ]); + + await expectErrors('FROM a_index | STATS AGG_FUNCTION_1(SCALAR_FUNCTION(AGG_FUNCTION_2()))', [ + 'Aggregation functions cannot be nested. Found AGG_FUNCTION_2 in AGG_FUNCTION_1.', + ]); + }); + + describe('param with hint.kind === "aggregation"', () => { + beforeEach(() => { + setTestFunctions([ + { + name: 'agg_outer', + type: FunctionDefinitionTypes.AGG, + description: '', + locationsAvailable: [Location.STATS], + signatures: [ + { + params: [ + { + name: 'aggregation', + type: 'double', + optional: false, + hint: { kind: 'aggregation' }, + }, + ], + returnType: 'double', + }, + ], + }, + { + name: 'agg_inner', + type: FunctionDefinitionTypes.AGG, + description: '', + locationsAvailable: [Location.STATS], + signatures: [ + { + params: [{ name: 'field', type: 'double', optional: false }], + returnType: 'double', + }, + ], + }, + { + name: 'scalar_inner', + type: FunctionDefinitionTypes.SCALAR, + description: '', + locationsAvailable: [Location.STATS], + signatures: [ + { + params: [{ name: 'field', type: 'double', optional: false }], + returnType: 'double', + }, + ], + }, + ]); + }); + + it('allows a nested aggregation at the hint-marked position', async () => { + const { expectErrors } = await setup(); + await expectErrors('FROM a_index | STATS AGG_OUTER(AGG_INNER(doubleField))', []); + }); + + it('reports an error when the hint-marked position receives a non-aggregation function', async () => { + const { expectErrors } = await setup(); + await expectErrors('FROM a_index | STATS AGG_OUTER(SCALAR_INNER(doubleField))', [ + 'This argument of AGG_OUTER must be an aggregation function.', + ]); + }); + + it('reports an error when the hint-marked position receives a column', async () => { + const { expectErrors } = await setup(); + await expectErrors('FROM a_index | STATS AGG_OUTER(doubleField)', [ + 'This argument of AGG_OUTER must be an aggregation function.', + ]); + }); + + it('still forbids nested aggregations inside the inner aggregation (the rule is only lifted at the hint position)', async () => { + const { expectErrors } = await setup(); + await expectErrors('FROM a_index | STATS AGG_OUTER(AGG_INNER(AGG_INNER(doubleField)))', [ + 'Aggregation functions cannot be nested. Found AGG_INNER in AGG_INNER.', + ]); + }); + }); + + it('should ignore a function whose name is defined by a parameter', async () => { + const { expectErrors } = await setup(); + + await expectErrors('FROM a_index | EVAL ??param(arg1)', []); + }); + + describe('License-based validation', () => { + beforeEach(() => { + setTestFunctions([ + { + type: FunctionDefinitionTypes.GROUPING, + name: 'platinum_function_mock', + description: '', + signatures: [ + { + params: [ + { + name: 'field', + type: 'keyword', + optional: false, + }, + ], + license: 'platinum', + returnType: 'keyword', + }, + { + params: [ + { + name: 'field', + type: 'text', + optional: false, + }, + ], + license: 'platinum', + returnType: 'keyword', + }, + ], + locationsAvailable: [Location.STATS, Location.STATS_BY], + license: 'platinum', + }, + { + type: FunctionDefinitionTypes.AGG, + name: 'platinum_partial_function_mock', + description: '', + signatures: [ + { + params: [ + { + name: 'field', + type: 'cartesian_point', + optional: false, + }, + ], + returnType: 'cartesian_shape', + }, + { + params: [ + { + name: 'field', + type: 'cartesian_shape', + optional: false, + }, + ], + license: 'platinum', + returnType: 'cartesian_shape', + }, + ], + locationsAvailable: [Location.STATS, Location.STATS_BY], + }, + ]); + }); + + describe('function-level licensing', () => { + it('should allow licensed function when license IS available', async () => { + const { expectErrors, callbacks } = await setup(); + + callbacks.getLicense = jest.fn(async () => ({ + hasAtLeast: () => true, + })); + + await expectErrors( + 'FROM a_index | STATS col0 = AVG(doubleField) BY PLATINUM_FUNCTION_MOCK(keywordField)', + [] + ); + }); + + it('should disallow licensed function when license NOT available', async () => { + const { expectErrors, callbacks } = await setup(); + + callbacks.getLicense = jest.fn(async () => ({ + hasAtLeast: () => false, + })); + + await expectErrors( + 'FROM a_index | STATS col0 = AVG(doubleField) BY PLATINUM_FUNCTION_MOCK()', + [ + 'PLATINUM_FUNCTION_MOCK requires a PLATINUM license.', + 'PLATINUM_FUNCTION_MOCK expected one argument, but got 0.', + ] + ); + + await expectErrors( + 'FROM a_index | STATS col0 = AVG(doubleField) BY PLATINUM_FUNCTION_MOCK(keywordField)', + ['PLATINUM_FUNCTION_MOCK requires a PLATINUM license.'] + ); + + await expectErrors('FROM a_index | STATS col0 = PLATINUM_FUNCTION_MOCK(keywordField)', [ + 'PLATINUM_FUNCTION_MOCK requires a PLATINUM license.', + ]); + }); + + it('should show license error even when nested functions also have errors', async () => { + const { expectErrors, callbacks } = await setup(); + + callbacks.getLicense = jest.fn(async () => ({ + hasAtLeast: () => false, + })); + + await expectErrors('FROM index | STATS extent = PLATINUM_FUNCTION_MOCK(FLOOR(""))', [ + 'PLATINUM_FUNCTION_MOCK requires a PLATINUM license.', + getNoValidCallSignatureError('floor', ['keyword']), + ]); + }); + }); + + describe('signature-level licensing', () => { + it('should allow licensed signature when license IS available', async () => { + const { expectErrors, callbacks } = await setup(); + + callbacks.getLicense = jest.fn(async () => ({ + hasAtLeast: () => true, + })); + + await expectErrors( + 'FROM index | STATS extent = PLATINUM_PARTIAL_FUNCTION_MOCK(TO_CARTESIANSHAPE("0,0"))', + [] + ); + }); + + it('should allow ambiguous invocation when it could match an available signature', async () => { + setTestFunctions([ + { + type: FunctionDefinitionTypes.SCALAR, + name: 'test', + description: '', + signatures: [ + { + params: [ + { + name: 'field', + type: 'integer', + optional: false, + }, + ], + license: 'platinum', // licensed signature + returnType: 'keyword', + }, + { + params: [ + { + name: 'field', + type: 'keyword', + optional: false, + }, + ], + license: undefined, // no license required + returnType: 'keyword', + }, + ], + locationsAvailable: [Location.EVAL], + }, + ]); + + const { expectErrors, callbacks } = await setup(); + + // make license unavailable + callbacks.getLicense = jest.fn(async () => ({ + hasAtLeast: () => false, + })); + + // make ambiguous call using a parameter "?param" that could match either signature + await expectErrors('FROM index | EVAL extent = TEST(?param)', []); + }); + + it('should disallow licensed signature when license NOT available', async () => { + const { expectErrors, callbacks } = await setup(); + + callbacks.getLicense = jest.fn(async () => ({ + hasAtLeast: () => false, + })); + + await expectErrors( + 'FROM index | STATS extent = PLATINUM_PARTIAL_FUNCTION_MOCK(TO_CARTESIANSHAPE("0,0"))', + [ + "PLATINUM_PARTIAL_FUNCTION_MOCK with 'field' of type 'cartesian_shape' requires a PLATINUM license.", + ] + ); + }); + + it("Should report various non-license errors even when the function isn't allowed", async () => { + const { expectErrors, callbacks } = await setup(); + + callbacks.getLicense = jest.fn(async () => ({ + hasAtLeast: () => false, + })); + + await expectErrors('FROM index | STATS result = PLATINUM_PARTIAL_FUNCTION_MOCK()', [ + 'PLATINUM_PARTIAL_FUNCTION_MOCK expected one argument, but got 0.', + ]); + + await expectErrors('FROM index | STATS result = PLATINUM_PARTIAL_FUNCTION_MOCK(0)', [ + getNoValidCallSignatureError('platinum_partial_function_mock', ['integer']), + ]); + + await expectErrors( + 'FROM index | STATS result = PLATINUM_PARTIAL_FUNCTION_MOCK(WrongField)', + ['Unknown column "WrongField"'] + ); + }); + + describe('operators in STATS WHERE context', () => { + it('should allow IS NOT NULL operator in STATS WHERE clause when validation is applied', async () => { + const { expectErrors } = await setup(); + + await expectErrors('FROM index | STATS COUNT() WHERE unknownField IS NOT NULL', [ + 'Unknown column "unknownField"', + ]); + }); + }); + }); + }); + + describe('conditional function validation', () => { + beforeEach(() => { + const definitions: FunctionDefinition[] = [ + { + name: 'conditional_mock', + type: FunctionDefinitionTypes.SCALAR, + description: 'Mock function with isSignatureRepeating', + locationsAvailable: [Location.EVAL], + signatures: [ + { + params: [ + { name: 'condition', type: 'boolean' }, + { name: 'value', type: 'any' }, + ], + returnType: 'unknown', + minParams: 2, + isSignatureRepeating: true, + }, + ], + }, + ]; + + setTestFunctions(definitions); + }); + + it('accepts compatible value types (text + keyword)', async () => { + const { expectErrors } = await setup(); + + await expectErrors( + 'FROM index | EVAL result = CONDITIONAL_MOCK(booleanField, textField, booleanField, keywordField)', + [] + ); + }); + + it('rejects incompatible value types', async () => { + const { expectErrors } = await setup(); + + await expectErrors( + 'FROM index | EVAL result = CONDITIONAL_MOCK(booleanField, longField, booleanField, "d")', + [ + getNoValidCallSignatureError('conditional_mock', [ + 'boolean', + 'long', + 'boolean', + 'keyword', + ]), + ] + ); + }); + + it('rejects string literal at condition position', async () => { + const { expectErrors } = await setup(); + + await expectErrors('FROM index | EVAL result = CONDITIONAL_MOCK("string", keywordField)', [ + getNoValidCallSignatureError('conditional_mock', ['keyword', 'keyword']), + ]); + }); + + it('allows null as a result value in combination with other types', async () => { + const { expectErrors } = await setup(); + + await expectErrors( + 'FROM index | EVAL result = CONDITIONAL_MOCK(booleanField, "text", booleanField, null)', + [] + ); + }); + + it('allows null as a result value in combination with other types, being null in the first position', async () => { + const { expectErrors } = await setup(); + + await expectErrors( + 'FROM index | EVAL result = CONDITIONAL_MOCK(booleanField, null, booleanField, "text")', + [] + ); + }); + + it('allows null as the elseValue in combination with other types', async () => { + const { expectErrors } = await setup(); + + await expectErrors( + 'FROM index | EVAL result = CONDITIONAL_MOCK(booleanField, 42, null)', + [] + ); + }); + }); + }); +}; diff --git a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/helpers.ts b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/helpers.ts index 88d9c26e851a3..e9369a7ce95f2 100644 --- a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/helpers.ts +++ b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/helpers.ts @@ -11,6 +11,7 @@ import type { EditorError } from '@elastic/esql/types'; import { getCallbackMocks } from '../../../__tests__/language/helpers'; import { validateQuery } from '../validation'; import type { ESQLMessage } from '../../../commands'; +import type { ValidationResult } from '../types'; /** * Wraps a promise to ensure it is awaited. If the promise is not awaited @@ -53,66 +54,109 @@ const mustBeAwaited = (promise: Promise, fnName: string): Promise => { } as Promise; }; -/** Validation test API factory, can be called at the start of each unit test. */ -export type Setup = typeof setup; +interface ValidationTestSetup { + callbacks: ESQLCallbacks; + validate: (query: string, cb?: ESQLCallbacks) => Promise; + expectErrors: ( + query: string, + expectedErrors: string[], + expectedWarnings?: string[], + cb?: ESQLCallbacks + ) => Promise; +} + +export type Setup = () => Promise; + +interface CreateValidationTestSetupOptions { + afterValidate?: (context: { + query: string; + result: ValidationResult; + hasUnmodifiedDefaultCallbacks: boolean; + }) => Promise | void; +} /** * Sets up an API for ES|QL query validation testing. * * @returns API for testing validation logic. */ -export const setup = async () => { - const callbacks = getCallbackMocks(); +export const createValidationTestSetup = + ({ afterValidate }: CreateValidationTestSetupOptions = {}): Setup => + async () => { + const callbacks = getCallbackMocks(); + const defaultCallbacks = { ...callbacks }; + const defaultCallbackKeys = Object.keys(defaultCallbacks) as Array; + const hasUnmodifiedDefaultCallbacks = (cb: ESQLCallbacks) => { + const callbackKeys = Object.keys(cb) as Array; + return ( + cb === callbacks && + callbackKeys.length === defaultCallbackKeys.length && + defaultCallbackKeys.every((key) => cb[key] === defaultCallbacks[key]) + ); + }; - const validate = (query: string, cb: ESQLCallbacks = callbacks) => { - return mustBeAwaited(validateQuery(query, cb), 'validate'); - }; + const validate = (query: string, cb: ESQLCallbacks = callbacks) => { + // Integration suites use this hook to compare the same validation run with ES. + // Unit suites leave it undefined, so their behavior stays unchanged. + const promise = validateQuery(query, cb).then(async (result) => { + await afterValidate?.({ + query, + result, + hasUnmodifiedDefaultCallbacks: hasUnmodifiedDefaultCallbacks(cb), + }); + return result; + }); + return mustBeAwaited(promise, 'validate'); + }; - const assertErrors = (errors: unknown[], expectedErrors: string[], query?: string) => { - const errorMessages: string[] = []; - for (const error of errors) { - if (error && typeof error === 'object') { - const message = - typeof (error as ESQLMessage).text === 'string' - ? (error as ESQLMessage).text - : typeof (error as EditorError).message === 'string' - ? (error as EditorError).message - : String(error); - errorMessages.push(message); - } else { - errorMessages.push(String(error)); + const assertErrors = (errors: unknown[], expectedErrors: string[], query?: string) => { + const errorMessages: string[] = []; + for (const error of errors) { + if (error && typeof error === 'object') { + const message = + typeof (error as ESQLMessage).text === 'string' + ? (error as ESQLMessage).text + : typeof (error as EditorError).message === 'string' + ? (error as EditorError).message + : String(error); + errorMessages.push(message); + } else { + errorMessages.push(String(error)); + } } - } - try { - expect(errorMessages.sort()).toStrictEqual(expectedErrors.sort()); - } catch (error) { - throw Error(`${query}\n + try { + expect(errorMessages.sort()).toStrictEqual(expectedErrors.sort()); + } catch (error) { + throw Error(`${query}\n Received: '${errorMessages.sort()}' Expected: ${expectedErrors.sort()}`); - } - }; - - const expectErrors = ( - query: string, - expectedErrors: string[], - expectedWarnings?: string[], - cb: ESQLCallbacks = callbacks - ) => { - const promise = validateQuery(query, cb).then(({ errors, warnings }) => { - assertErrors(errors, expectedErrors, query); - if (expectedWarnings) { - assertErrors(warnings, expectedWarnings, query); } - }); - return mustBeAwaited(promise, 'expectErrors'); - }; + }; - return { - callbacks, - validate, - expectErrors, + const expectErrors = ( + query: string, + expectedErrors: string[], + expectedWarnings?: string[], + cb: ESQLCallbacks = callbacks + ) => { + const promise = validate(query, cb).then(({ errors, warnings }) => { + assertErrors(errors, expectedErrors, query); + if (expectedWarnings) { + assertErrors(warnings, expectedWarnings, query); + } + }); + return mustBeAwaited(promise, 'expectErrors'); + }; + + return { + callbacks, + validate, + expectErrors, + }; }; -}; + +/** Validation test API factory, can be called at the start of each unit test. */ +export const setup = createValidationTestSetup(); diff --git a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/inline_cast.test.ts b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/inline_cast.test.ts index cacbabf26bfcd..e78b944ecae18 100644 --- a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/inline_cast.test.ts +++ b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/inline_cast.test.ts @@ -8,79 +8,6 @@ */ import { setup } from './helpers'; +import { runInlineCastValidationSuite } from './inline_cast_suite'; -describe('Inline cast validation', () => { - it('should not return any errors for a valid inline cast', async () => { - const { expectErrors } = await setup(); - - // simple literal cast - await expectErrors('FROM index | EVAL col0 = 5::string', []); - // multiple literal cast - await expectErrors('FROM index | EVAL col0 = 5::string::int', []); - - // cast inside function - await expectErrors('FROM index | EVAL col0 = CONCAT(5::string, "string")', []); - // multiple casts inside functions - await expectErrors( - 'FROM index | EVAL col0 = CONCAT(5::string, CONCAT(5::string, 6::string))', - [] - ); - - // cast of a column - await expectErrors('FROM index | WHERE keywordField::string == "5"', []); - // cast of an unknown column (if we don't known its type, don't throw an error) - await expectErrors('FROM index | EVAL col0 = some_field::string', []); - // cast of user defined column - await expectErrors('FROM index | EVAL col0 = 5 | EVAL col1 = col0::string', []); - - // cast of a command option - await expectErrors( - 'FROM index | EVAL col0 = 5 | COMPLETION col0::string WITH { "inference_id": "id" }', - [] - ); - - // ignores cases - await expectErrors('FROM index | EVAL col0 = "value"::String', []); - }); - - it('should return an error for an unknown casting type', async () => { - const { expectErrors } = await setup(); - await expectErrors('FROM index | EVAL col0 = "value"::intt', [ - `Unknown inline cast type "::intt"`, - ]); - }); - - it('should return an error for an invalid cast value', async () => { - const { expectErrors } = await setup(); - - // On a literal - await expectErrors('FROM index | EVAL col0 = true::date', [ - `Cannot cast value of type "boolean" to type "date"`, - ]); - - // On a column - await expectErrors('FROM index | WHERE booleanField::date > "2012"', [ - `Cannot cast value of type "boolean" to type "date"`, - ]); - - // On user defined column - await expectErrors('FROM index | EVAL col0 = true | EVAL col1 = col0::dense_vector', [ - `Cannot cast value of type "boolean" to type "dense_vector"`, - ]); - - // On nested casts - await expectErrors('FROM index | EVAL col0 = "2012"::date::dense_vector', [ - `Cannot cast value of type "date" to type "dense_vector"`, - ]); - - // Multiple cast errors - await expectErrors('FROM index | EVAL col0 = true::date::intt', [ - `Cannot cast value of type "boolean" to type "date"`, - `Unknown inline cast type "::intt"`, - ]); - await expectErrors('FROM index | EVAL col0 = true::date::geo_shape', [ - `Cannot cast value of type "boolean" to type "date"`, - `Cannot cast value of type "date" to type "geo_shape"`, - ]); - }); -}); +runInlineCastValidationSuite(setup); diff --git a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/inline_cast_suite.ts b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/inline_cast_suite.ts new file mode 100644 index 0000000000000..a9fc921b907ef --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/inline_cast_suite.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { Setup } from './helpers'; + +export const runInlineCastValidationSuite = (setup: Setup) => { + describe('Inline cast validation', () => { + it('should not return any errors for a valid inline cast', async () => { + const { expectErrors } = await setup(); + + // simple literal cast + await expectErrors('FROM index | EVAL col0 = 5::string', []); + // multiple literal cast + await expectErrors('FROM index | EVAL col0 = 5::string::int', []); + + // cast inside function + await expectErrors('FROM index | EVAL col0 = CONCAT(5::string, "string")', []); + // multiple casts inside functions + await expectErrors( + 'FROM index | EVAL col0 = CONCAT(5::string, CONCAT(5::string, 6::string))', + [] + ); + + // cast of a column + await expectErrors('FROM index | WHERE keywordField::string == "5"', []); + // cast of an unknown column (if we don't known its type, don't throw an error) + await expectErrors('FROM index | EVAL col0 = some_field::string', []); + // cast of user defined column + await expectErrors('FROM index | EVAL col0 = 5 | EVAL col1 = col0::string', []); + + // cast of a command option + await expectErrors( + 'FROM index | EVAL col0 = 5 | COMPLETION col0::string WITH { "inference_id": "id" }', + [] + ); + + // ignores cases + await expectErrors('FROM index | EVAL col0 = "value"::String', []); + }); + + it('should return an error for an unknown casting type', async () => { + const { expectErrors } = await setup(); + await expectErrors('FROM index | EVAL col0 = "value"::intt', [ + `Unknown inline cast type "::intt"`, + ]); + }); + + it('should return an error for an invalid cast value', async () => { + const { expectErrors } = await setup(); + + // On a literal + await expectErrors('FROM index | EVAL col0 = true::date', [ + `Cannot cast value of type "boolean" to type "date"`, + ]); + + // On a column + await expectErrors('FROM index | WHERE booleanField::date > "2012"', [ + `Cannot cast value of type "boolean" to type "date"`, + ]); + + // On user defined column + await expectErrors('FROM index | EVAL col0 = true | EVAL col1 = col0::dense_vector', [ + `Cannot cast value of type "boolean" to type "dense_vector"`, + ]); + + // On nested casts + await expectErrors('FROM index | EVAL col0 = "2012"::date::dense_vector', [ + `Cannot cast value of type "date" to type "dense_vector"`, + ]); + + // Multiple cast errors + await expectErrors('FROM index | EVAL col0 = true::date::intt', [ + `Cannot cast value of type "boolean" to type "date"`, + `Unknown inline cast type "::intt"`, + ]); + await expectErrors('FROM index | EVAL col0 = true::date::geo_shape', [ + `Cannot cast value of type "boolean" to type "date"`, + `Cannot cast value of type "date" to type "geo_shape"`, + ]); + }); + }); +}; diff --git a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/params.test.ts b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/params.test.ts new file mode 100644 index 0000000000000..3b07c50e5fb7b --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/params.test.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { setup } from './helpers'; +import { runValidationParamsSuite } from './params_suite'; + +runValidationParamsSuite(setup); diff --git a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/params_suite.ts b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/params_suite.ts new file mode 100644 index 0000000000000..8378e518c9d52 --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/params_suite.ts @@ -0,0 +1,210 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { Setup } from './helpers'; + +export const runValidationParamsSuite = (setup: Setup) => { + test('should allow param inside agg function argument', async () => { + const { validate } = await setup(); + + const res1 = await validate('FROM index | STATS avg(?)'); + const res2 = await validate('FROM index | STATS avg(?named)'); + const res3 = await validate('FROM index | STATS avg(?123)'); + + expect(res1).toMatchObject({ errors: [], warnings: [] }); + expect(res2).toMatchObject({ errors: [], warnings: [] }); + expect(res3).toMatchObject({ errors: [], warnings: [] }); + }); + + test('allow params in WHERE command expressions', async () => { + const { validate } = await setup(); + + const res1 = await validate('FROM index | WHERE textField >= ?_tstart'); + const res2 = await validate(` + FROM index + | WHERE textField >= ?_tstart + | WHERE textField <= ?0 + | WHERE textField == ? + `); + const res3 = await validate(` + FROM index + | WHERE textField >= ?_tstart + AND textField <= ?0 + AND textField == ? + `); + + expect(res1).toMatchObject({ errors: [], warnings: [] }); + expect(res2).toMatchObject({ errors: [], warnings: [] }); + expect(res3).toMatchObject({ errors: [], warnings: [] }); + }); + + describe('allows named params', () => { + test('WHERE boolean expression can contain a param', async () => { + const { validate } = await setup(); + + const res0 = await validate('FROM index | STATS var = ??func(??field) | WHERE var >= ?value'); + expect(res0).toMatchObject({ errors: [], warnings: [] }); + + const res1 = await validate('FROM index | WHERE textField >= ?value'); + expect(res1).toMatchObject({ errors: [], warnings: [] }); + + const res2 = await validate('FROM index | WHERE ??field >= ?value'); + expect(res2).toMatchObject({ errors: [], warnings: [] }); + }); + + test('in column names', async () => { + const { validate } = await setup(); + + const res1 = await validate('ROW ?test'); + expect(res1).toMatchObject({ errors: [], warnings: [] }); + + const res2 = await validate('ROW ?test, ?one_more, ?asldfkjasldkfjasldkfj'); + expect(res2).toMatchObject({ errors: [], warnings: [] }); + }); + + test('in nested column names', async () => { + const { validate } = await setup(); + + const res1 = await validate('ROW ?test.?test2'); + expect(res1).toMatchObject({ errors: [], warnings: [] }); + + const res2 = await validate('ROW ?test, ?test.?test2.?test3'); + expect(res2).toMatchObject({ errors: [], warnings: [] }); + }); + + test('in nested column names, where first part is not a param', async () => { + const { validate } = await setup(); + + const res1 = await validate('ROW not_a_param.?test2'); + expect(res1).toMatchObject({ errors: [], warnings: [] }); + + const res2 = await validate('ROW not_a_param.?asdfasdfasdf, not_a_param.?test2.?test3'); + expect(res2).toMatchObject({ errors: [], warnings: [] }); + }); + + test('in function name, function arg, and column name in STATS command', async () => { + const { validate } = await setup(); + + const res1 = await validate('FROM index | STATS x = max(doubleField) BY textField'); + expect(res1).toMatchObject({ errors: [], warnings: [] }); + + const res2 = await validate('FROM index | STATS x = max(?param1) BY textField'); + expect(res2).toMatchObject({ errors: [], warnings: [] }); + + const res3 = await validate('FROM index | STATS x = max(?param1) BY ?param2'); + expect(res3).toMatchObject({ errors: [], warnings: [] }); + + const res4 = await validate('FROM index | STATS x = ?param3(?param1) BY ?param2'); + expect(res4).toMatchObject({ errors: [], warnings: [] }); + + const res5 = await validate( + 'FROM index | STATS x = ?param3(?param1, ?param4), y = ?param4(?param4, ?param4, ?param4) BY ?param2, ?param5' + ); + expect(res5).toMatchObject({ errors: [], warnings: [] }); + }); + }); + + describe('allows unnamed params', () => { + test('in column names', async () => { + const { validate } = await setup(); + + const res1 = await validate('ROW ?'); + expect(res1).toMatchObject({ errors: [], warnings: [] }); + }); + + test('in nested column names', async () => { + const { validate } = await setup(); + + const res1 = await validate('ROW ?.?'); + expect(res1).toMatchObject({ errors: [], warnings: [] }); + + const res2 = await validate('ROW ?, ?.?.?'); + expect(res2).toMatchObject({ errors: [], warnings: [] }); + }); + + test('in nested column names, where first part is not a param', async () => { + const { validate } = await setup(); + + const res1 = await validate('ROW not_a_param.?'); + expect(res1).toMatchObject({ errors: [], warnings: [] }); + + const res2 = await validate('ROW not_a_param.?, not_a_param.?.?'); + expect(res2).toMatchObject({ errors: [], warnings: [] }); + }); + + test('in function name, function arg, and column name in STATS command', async () => { + const { validate } = await setup(); + + const res1 = await validate('FROM index | STATS x = max(doubleField) BY textField'); + expect(res1).toMatchObject({ errors: [], warnings: [] }); + + const res2 = await validate('FROM index | STATS x = max(?) BY textField'); + expect(res2).toMatchObject({ errors: [], warnings: [] }); + + const res3 = await validate('FROM index | STATS x = max(?) BY ?'); + expect(res3).toMatchObject({ errors: [], warnings: [] }); + + const res4 = await validate('FROM index | STATS x = ?(?) BY ?'); + expect(res4).toMatchObject({ errors: [], warnings: [] }); + + const res5 = await validate('FROM index | STATS x = ?(?, ?), y = ?(?, ?, ?) BY ?, ?'); + expect(res5).toMatchObject({ errors: [], warnings: [] }); + }); + }); + + describe('allows positional params', () => { + test('in column names', async () => { + const { validate } = await setup(); + + const res1 = await validate('ROW ?0'); + expect(res1).toMatchObject({ errors: [], warnings: [] }); + }); + + test('in nested column names', async () => { + const { validate } = await setup(); + + const res1 = await validate('ROW ?0.?0'); + expect(res1).toMatchObject({ errors: [], warnings: [] }); + + const res2 = await validate('ROW ?0, ?0.?0.?0'); + expect(res2).toMatchObject({ errors: [], warnings: [] }); + }); + + test('in nested column names, where first part is not a param', async () => { + const { validate } = await setup(); + + const res1 = await validate('ROW not_a_param.?1'); + expect(res1).toMatchObject({ errors: [], warnings: [] }); + + const res2 = await validate('ROW not_a_param.?2, not_a_param.?3.?4'); + expect(res2).toMatchObject({ errors: [], warnings: [] }); + }); + + test('in function name, function arg, and column name in STATS command', async () => { + const { validate } = await setup(); + + const res1 = await validate('FROM index | STATS x = max(doubleField) BY textField'); + expect(res1).toMatchObject({ errors: [], warnings: [] }); + + const res2 = await validate('FROM index | STATS x = max(?0) BY textField'); + expect(res2).toMatchObject({ errors: [], warnings: [] }); + + const res3 = await validate('FROM index | STATS x = max(?0) BY ?0'); + expect(res3).toMatchObject({ errors: [], warnings: [] }); + + const res4 = await validate('FROM index | STATS x = ?1(?1) BY ?1'); + expect(res4).toMatchObject({ errors: [], warnings: [] }); + + const res5 = await validate( + 'FROM index | STATS x = ?0(?0, ?0), y = ?2(?2, ?2, ?2) BY ?3, ?3' + ); + expect(res5).toMatchObject({ errors: [], warnings: [] }); + }); + }); +}; diff --git a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/sources.test.ts b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/sources.test.ts new file mode 100644 index 0000000000000..acfe7ace1c35b --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/sources.test.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { setup } from './helpers'; +import { runSourcesValidationSuite } from './sources_suite'; + +runSourcesValidationSuite(setup); diff --git a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/sources_suite.ts b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/sources_suite.ts new file mode 100644 index 0000000000000..0fdd8e65031e1 --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/sources_suite.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { Setup } from './helpers'; + +export const runSourcesValidationSuite = (setup: Setup) => { + // The following block tests a case that is allowed in Kibana + // by suppressing the parser error in https://github.com/elastic/esql-js/blob/main/src/parser/core/esql_error_listener.ts + describe('EMPTY query does NOT produce syntax error', () => { + it('does not produce a syntax error for empty or whitespace-only queries', async () => { + const { expectErrors } = await setup(); + await expectErrors('', []); + await expectErrors(' ', []); + await expectErrors(' ', []); + }); + }); + + describe('FROM [ METADATA ]', () => { + test('errors on invalid command start', async () => { + const { expectErrors } = await setup(); + + await expectErrors('f', [expect.any(String)]); + await expectErrors('from ', [ + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, '(', UNQUOTED_SOURCE}", + ]); + }); + + describe('... ...', () => { + test('errors on trailing comma', async () => { + const { expectErrors } = await setup(); + + await expectErrors('from index,', [ + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, '(', UNQUOTED_SOURCE}", + ]); + await expectErrors(`FROM index\n, \tother_index\t,\n \t `, [ + "SyntaxError: mismatched input '' expecting {QUOTED_STRING, '(', UNQUOTED_SOURCE}", + ]); + + await expectErrors(`from assignment = 1`, [ + "SyntaxError: mismatched input '=' expecting ", + 'Unknown data source "assignment"', + ]); + }); + + test('errors on invalid syntax', async () => { + const { expectErrors } = await setup(); + + await expectErrors('FROM `index`', ['Unknown data source "`index`"']); + await expectErrors(`from assignment = 1`, [ + "SyntaxError: mismatched input '=' expecting ", + 'Unknown data source "assignment"', + ]); + }); + }); + + describe('hidden sources', () => { + test('does not error on dot-prefixed backing index', async () => { + const { expectErrors } = await setup(); + await expectErrors('FROM .ds-log-elasticsearch-default-2025.09.11-000006', []); + }); + + test('does not error on mix of backing index and known index', async () => { + const { expectErrors } = await setup(); + await expectErrors('FROM .ds-foo,index', []); + }); + + test('does not error on CCS backing index', async () => { + const { expectErrors } = await setup(); + await expectErrors('FROM "mycluster:.ds-foo"', []); + }); + + test('still errors on truly unknown non-dot sources', async () => { + const { expectErrors } = await setup(); + await expectErrors('FROM truly_unknown', ['Unknown data source "truly_unknown"']); + }); + + test('still errors on mix where one part is unknown and not dot-prefixed', async () => { + const { expectErrors } = await setup(); + await expectErrors('FROM truly_unknown,index', ['Unknown data source "truly_unknown"']); + }); + }); + + describe('... METADATA ', () => { + test('errors when wrapped in parentheses', async () => { + const { expectErrors } = await setup(); + + await expectErrors(`from index (metadata _id)`, [ + "SyntaxError: mismatched input '(' expecting ", + ]); + }); + + describe('validates fields', () => { + test('validates fields', async () => { + const { expectErrors } = await setup(); + await expectErrors(`from index metadata _id, _source METADATA _id2`, [ + "SyntaxError: mismatched input 'METADATA' expecting ", + ]); + }); + }); + }); + }); +}; diff --git a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/subqueries.test.ts b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/subqueries.test.ts index 4572a4b75c143..f6bb274e30c50 100644 --- a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/subqueries.test.ts +++ b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/subqueries.test.ts @@ -8,132 +8,6 @@ */ import { setup } from './helpers'; +import { runSubqueriesValidationSuite } from './subqueries_suite'; -describe('Subqueries Validation', () => { - describe('FROM subqueries', () => { - it('should validate commands inside subqueries', async () => { - const { expectErrors } = await setup(); - - await expectErrors('FROM index, (FROM other_index | KEEP missingField)', [ - 'Unknown column "missingField"', - ]); - }); - - it('should validate nested subqueries', async () => { - const { expectErrors } = await setup(); - - await expectErrors('FROM index, (FROM other_index, (FROM missingIndex))', [ - 'Unknown index "missingIndex"', - ]); - }); - - it('should validate multiple errors in subqueries', async () => { - const { expectErrors } = await setup(); - - await expectErrors( - 'FROM index, (FROM other_index | KEEP keywordField, missingField1, missingField2)', - ['Unknown column "missingField1"', 'Unknown column "missingField2"'] - ); - }); - - it('should validate METADATA inside subqueries', async () => { - const { expectErrors } = await setup(); - - await expectErrors('FROM index, (FROM other_index METADATA _invalidField)', [ - 'Metadata field "_invalidField" is not available. Available metadata fields are: [_version, _id, _index, _source, _ignored, _index_mode, _score]', - ]); - }); - - it('should validate CCS indices inside subqueries', async () => { - const { expectErrors } = await setup(); - - await expectErrors('FROM index, (FROM remote-ccs:indexes)', [ - 'Unknown index "remote-ccs:indexes"', - ]); - await expectErrors('FROM index, (FROM remote-*:indexes*)', []); - }); - - it('should validate custom command validation inside deeply nested subqueries', async () => { - const { expectErrors } = await setup(); - - await expectErrors( - 'FROM index, (FROM other_index, (FROM a_index | RERANK "query" ON keywordField WITH {}))', - ['"inference_id" parameter is required for RERANK.'] - ); - }); - }); - - describe('WHERE IN subqueries', () => { - it('accepts a valid IN subquery with no errors', async () => { - const { expectErrors } = await setup(); - - await expectErrors( - 'FROM index | WHERE keywordField IN (FROM other_index | KEEP keywordField)', - [] - ); - }); - - it('validates sources inside IN subqueries', async () => { - const { expectErrors } = await setup(); - - await expectErrors('FROM index | WHERE keywordField IN (FROM missing_index)', [ - 'Unknown index "missing_index"', - ]); - }); - - it.each(['IN', 'NOT IN'])('validates commands inside %s subqueries', async (operator) => { - const { expectErrors } = await setup(); - - await expectErrors( - `FROM index | WHERE keywordField ${operator} (FROM other_index | KEEP missingField)`, - ['Unknown column "missingField"'] - ); - }); - - it('validates nested IN subqueries', async () => { - const { expectErrors } = await setup(); - - await expectErrors( - 'FROM index | WHERE keywordField IN (FROM other_index | WHERE keywordField IN (FROM missing_index))', - ['Unknown index "missing_index"'] - ); - }); - - it('does not resolve outer query fields inside IN subqueries', async () => { - const { expectErrors } = await setup(); - - await expectErrors( - 'FROM index | EVAL outerField = keywordField | WHERE keywordField IN (FROM other_index | WHERE outerField IS NOT NULL | KEEP keywordField)', - ['Unknown column "outerField"'] - ); - }); - - it('validates multiple IN subqueries in the same WHERE expression', async () => { - const { callbacks, expectErrors } = await setup(); - const query = - 'FROM kibana_sample_data_ecommerce | WHERE currency IN (FROM kibana_sample_dat_ecommerce | KEEP category) AND category.keyword IN (FROM kibana_sample_ata_logs | KEEP agent)'; - - await expectErrors( - query, - ['Unknown index "kibana_sample_dat_ecommerce"', 'Unknown index "kibana_sample_ata_logs"'], - undefined, - { - ...callbacks, - getSources: jest.fn(async () => [ - { - name: 'kibana_sample_data_ecommerce', - hidden: false, - type: 'Index', - }, - ]), - getColumnsFor: jest.fn(async () => [ - { name: 'currency', type: 'keyword', userDefined: false }, - { name: 'category.keyword', type: 'keyword', userDefined: false }, - { name: 'category', type: 'keyword', userDefined: false }, - { name: 'agent', type: 'keyword', userDefined: false }, - ]), - } - ); - }); - }); -}); +runSubqueriesValidationSuite(setup); diff --git a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/subqueries_suite.ts b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/subqueries_suite.ts new file mode 100644 index 0000000000000..fc4650316d991 --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/subqueries_suite.ts @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { Setup } from './helpers'; + +export const runSubqueriesValidationSuite = (setup: Setup) => { + describe('Subqueries Validation', () => { + describe('FROM subqueries', () => { + it('should validate commands inside subqueries', async () => { + const { expectErrors } = await setup(); + + await expectErrors('FROM index, (FROM other_index | KEEP missingField)', [ + 'Unknown column "missingField"', + ]); + }); + + it('should validate nested subqueries', async () => { + const { expectErrors } = await setup(); + + await expectErrors('FROM index, (FROM other_index, (FROM missingIndex))', [ + 'Unknown index "missingIndex"', + ]); + }); + + it('should validate multiple errors in subqueries', async () => { + const { expectErrors } = await setup(); + + await expectErrors( + 'FROM index, (FROM other_index | KEEP keywordField, missingField1, missingField2)', + ['Unknown column "missingField1"', 'Unknown column "missingField2"'] + ); + }); + + it('should validate METADATA inside subqueries', async () => { + const { expectErrors } = await setup(); + + await expectErrors('FROM index, (FROM other_index METADATA _invalidField)', [ + 'Metadata field "_invalidField" is not available. Available metadata fields are: [_version, _id, _index, _source, _ignored, _index_mode, _score]', + ]); + }); + + it('should validate CCS indices inside subqueries', async () => { + const { expectErrors } = await setup(); + + await expectErrors('FROM index, (FROM remote-ccs:indexes)', [ + 'Unknown index "remote-ccs:indexes"', + ]); + await expectErrors('FROM index, (FROM remote-*:indexes*)', []); + }); + + it('should validate custom command validation inside deeply nested subqueries', async () => { + const { expectErrors } = await setup(); + + await expectErrors( + 'FROM index, (FROM other_index, (FROM a_index | RERANK "query" ON keywordField WITH {}))', + ['"inference_id" parameter is required for RERANK.'] + ); + }); + }); + + describe('WHERE IN subqueries', () => { + it('accepts a valid IN subquery with no errors', async () => { + const { expectErrors } = await setup(); + + await expectErrors( + 'FROM index | WHERE keywordField IN (FROM other_index | KEEP keywordField)', + [] + ); + }); + + it('validates sources inside IN subqueries', async () => { + const { expectErrors } = await setup(); + + await expectErrors('FROM index | WHERE keywordField IN (FROM missing_index)', [ + 'Unknown index "missing_index"', + ]); + }); + + it.each(['IN', 'NOT IN'])('validates commands inside %s subqueries', async (operator) => { + const { expectErrors } = await setup(); + + await expectErrors( + `FROM index | WHERE keywordField ${operator} (FROM other_index | KEEP missingField)`, + ['Unknown column "missingField"'] + ); + }); + + it('validates nested IN subqueries', async () => { + const { expectErrors } = await setup(); + + await expectErrors( + 'FROM index | WHERE keywordField IN (FROM other_index | WHERE keywordField IN (FROM missing_index))', + ['Unknown index "missing_index"'] + ); + }); + + it('does not resolve outer query fields inside IN subqueries', async () => { + const { expectErrors } = await setup(); + + await expectErrors( + 'FROM index | EVAL outerField = keywordField | WHERE keywordField IN (FROM other_index | WHERE outerField IS NOT NULL | KEEP keywordField)', + ['Unknown column "outerField"'] + ); + }); + + it('validates multiple IN subqueries in the same WHERE expression', async () => { + const { callbacks, expectErrors } = await setup(); + const query = + 'FROM kibana_sample_data_ecommerce | WHERE currency IN (FROM kibana_sample_dat_ecommerce | KEEP category) AND category.keyword IN (FROM kibana_sample_ata_logs | KEEP agent)'; + + await expectErrors( + query, + ['Unknown index "kibana_sample_dat_ecommerce"', 'Unknown index "kibana_sample_ata_logs"'], + undefined, + { + ...callbacks, + getSources: jest.fn(async () => [ + { + name: 'kibana_sample_data_ecommerce', + hidden: false, + type: 'Index', + }, + ]), + getColumnsFor: jest.fn(async () => [ + { name: 'currency', type: 'keyword', userDefined: false }, + { name: 'category.keyword', type: 'keyword', userDefined: false }, + { name: 'category', type: 'keyword', userDefined: false }, + { name: 'agent', type: 'keyword', userDefined: false }, + ]), + } + ); + }); + }); + }); +}; diff --git a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/validation.commands.license.test.ts b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/validation.commands.license.test.ts deleted file mode 100644 index 1708c489e7fbd..0000000000000 --- a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/validation.commands.license.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { setup } from './helpers'; - -describe('Command license validation', () => { - it('Should allows licensed commands when user has required license', async () => { - const { expectErrors, callbacks } = await setup(); - - callbacks.getLicense = jest.fn(async () => ({ - hasAtLeast: (license?: string) => license?.toLowerCase() === 'platinum', - })); - - await expectErrors('FROM a_index | CHANGE_POINT doubleField', []); - }); - - it('should blocks licensed commands when user lacks required license', async () => { - const { expectErrors, callbacks } = await setup(); - - callbacks.getLicense = jest.fn(async () => ({ - hasAtLeast: (license?: string) => license?.toLowerCase() !== 'platinum', - })); - - await expectErrors('FROM a_index | CHANGE_POINT doubleField', [ - 'CHANGE_POINT requires a PLATINUM license.', - ]); - }); -}); diff --git a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/validation.params.test.ts b/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/validation.params.test.ts deleted file mode 100644 index 4c7cc90fc5e28..0000000000000 --- a/src/platform/packages/shared/kbn-esql-language/src/language/validation/__tests__/validation.params.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { setup } from './helpers'; - -test('should allow param inside agg function argument', async () => { - const { validate } = await setup(); - - const res1 = await validate('FROM index | STATS avg(?)'); - const res2 = await validate('FROM index | STATS avg(?named)'); - const res3 = await validate('FROM index | STATS avg(?123)'); - - expect(res1).toMatchObject({ errors: [], warnings: [] }); - expect(res2).toMatchObject({ errors: [], warnings: [] }); - expect(res3).toMatchObject({ errors: [], warnings: [] }); -}); - -test('allow params in WHERE command expressions', async () => { - const { validate } = await setup(); - - const res1 = await validate('FROM index | WHERE textField >= ?_tstart'); - const res2 = await validate(` - FROM index - | WHERE textField >= ?_tstart - | WHERE textField <= ?0 - | WHERE textField == ? - `); - const res3 = await validate(` - FROM index - | WHERE textField >= ?_tstart - AND textField <= ?0 - AND textField == ? - `); - - expect(res1).toMatchObject({ errors: [], warnings: [] }); - expect(res2).toMatchObject({ errors: [], warnings: [] }); - expect(res3).toMatchObject({ errors: [], warnings: [] }); -}); - -describe('allows named params', () => { - test('WHERE boolean expression can contain a param', async () => { - const { validate } = await setup(); - - const res0 = await validate('FROM index | STATS var = ??func(??field) | WHERE var >= ?value'); - expect(res0).toMatchObject({ errors: [], warnings: [] }); - - const res1 = await validate('FROM index | WHERE textField >= ?value'); - expect(res1).toMatchObject({ errors: [], warnings: [] }); - - const res2 = await validate('FROM index | WHERE ??field >= ?value'); - expect(res2).toMatchObject({ errors: [], warnings: [] }); - }); - - test('in column names', async () => { - const { validate } = await setup(); - - const res1 = await validate('ROW ?test'); - expect(res1).toMatchObject({ errors: [], warnings: [] }); - - const res2 = await validate('ROW ?test, ?one_more, ?asldfkjasldkfjasldkfj'); - expect(res2).toMatchObject({ errors: [], warnings: [] }); - }); - - test('in nested column names', async () => { - const { validate } = await setup(); - - const res1 = await validate('ROW ?test.?test2'); - expect(res1).toMatchObject({ errors: [], warnings: [] }); - - const res2 = await validate('ROW ?test, ?test.?test2.?test3'); - expect(res2).toMatchObject({ errors: [], warnings: [] }); - }); - - test('in nested column names, where first part is not a param', async () => { - const { validate } = await setup(); - - const res1 = await validate('ROW not_a_param.?test2'); - expect(res1).toMatchObject({ errors: [], warnings: [] }); - - const res2 = await validate('ROW not_a_param.?asdfasdfasdf, not_a_param.?test2.?test3'); - expect(res2).toMatchObject({ errors: [], warnings: [] }); - }); - - test('in function name, function arg, and column name in STATS command', async () => { - const { validate } = await setup(); - - const res1 = await validate('FROM index | STATS x = max(doubleField) BY textField'); - expect(res1).toMatchObject({ errors: [], warnings: [] }); - - const res2 = await validate('FROM index | STATS x = max(?param1) BY textField'); - expect(res2).toMatchObject({ errors: [], warnings: [] }); - - const res3 = await validate('FROM index | STATS x = max(?param1) BY ?param2'); - expect(res3).toMatchObject({ errors: [], warnings: [] }); - - const res4 = await validate('FROM index | STATS x = ?param3(?param1) BY ?param2'); - expect(res4).toMatchObject({ errors: [], warnings: [] }); - - const res5 = await validate( - 'FROM index | STATS x = ?param3(?param1, ?param4), y = ?param4(?param4, ?param4, ?param4) BY ?param2, ?param5' - ); - expect(res5).toMatchObject({ errors: [], warnings: [] }); - }); -}); - -describe('allows unnamed params', () => { - test('in column names', async () => { - const { validate } = await setup(); - - const res1 = await validate('ROW ?'); - expect(res1).toMatchObject({ errors: [], warnings: [] }); - }); - - test('in nested column names', async () => { - const { validate } = await setup(); - - const res1 = await validate('ROW ?.?'); - expect(res1).toMatchObject({ errors: [], warnings: [] }); - - const res2 = await validate('ROW ?, ?.?.?'); - expect(res2).toMatchObject({ errors: [], warnings: [] }); - }); - - test('in nested column names, where first part is not a param', async () => { - const { validate } = await setup(); - - const res1 = await validate('ROW not_a_param.?'); - expect(res1).toMatchObject({ errors: [], warnings: [] }); - - const res2 = await validate('ROW not_a_param.?, not_a_param.?.?'); - expect(res2).toMatchObject({ errors: [], warnings: [] }); - }); - - test('in function name, function arg, and column name in STATS command', async () => { - const { validate } = await setup(); - - const res1 = await validate('FROM index | STATS x = max(doubleField) BY textField'); - expect(res1).toMatchObject({ errors: [], warnings: [] }); - - const res2 = await validate('FROM index | STATS x = max(?) BY textField'); - expect(res2).toMatchObject({ errors: [], warnings: [] }); - - const res3 = await validate('FROM index | STATS x = max(?) BY ?'); - expect(res3).toMatchObject({ errors: [], warnings: [] }); - - const res4 = await validate('FROM index | STATS x = ?(?) BY ?'); - expect(res4).toMatchObject({ errors: [], warnings: [] }); - - const res5 = await validate('FROM index | STATS x = ?(?, ?), y = ?(?, ?, ?) BY ?, ?'); - expect(res5).toMatchObject({ errors: [], warnings: [] }); - }); -}); - -describe('allows positional params', () => { - test('in column names', async () => { - const { validate } = await setup(); - - const res1 = await validate('ROW ?0'); - expect(res1).toMatchObject({ errors: [], warnings: [] }); - }); - - test('in nested column names', async () => { - const { validate } = await setup(); - - const res1 = await validate('ROW ?0.?0'); - expect(res1).toMatchObject({ errors: [], warnings: [] }); - - const res2 = await validate('ROW ?0, ?0.?0.?0'); - expect(res2).toMatchObject({ errors: [], warnings: [] }); - }); - - test('in nested column names, where first part is not a param', async () => { - const { validate } = await setup(); - - const res1 = await validate('ROW not_a_param.?1'); - expect(res1).toMatchObject({ errors: [], warnings: [] }); - - const res2 = await validate('ROW not_a_param.?2, not_a_param.?3.?4'); - expect(res2).toMatchObject({ errors: [], warnings: [] }); - }); - - test('in function name, function arg, and column name in STATS command', async () => { - const { validate } = await setup(); - - const res1 = await validate('FROM index | STATS x = max(doubleField) BY textField'); - expect(res1).toMatchObject({ errors: [], warnings: [] }); - - const res2 = await validate('FROM index | STATS x = max(?0) BY textField'); - expect(res2).toMatchObject({ errors: [], warnings: [] }); - - const res3 = await validate('FROM index | STATS x = max(?0) BY ?0'); - expect(res3).toMatchObject({ errors: [], warnings: [] }); - - const res4 = await validate('FROM index | STATS x = ?1(?1) BY ?1'); - expect(res4).toMatchObject({ errors: [], warnings: [] }); - - const res5 = await validate('FROM index | STATS x = ?0(?0, ?0), y = ?2(?2, ?2, ?2) BY ?3, ?3'); - expect(res5).toMatchObject({ errors: [], warnings: [] }); - }); -}); diff --git a/src/platform/packages/shared/kbn-esql-language/src/language/validation/esql_validation_meta_tests.json b/src/platform/packages/shared/kbn-esql-language/src/language/validation/esql_validation_meta_tests.json deleted file mode 100644 index 74e604202b617..0000000000000 --- a/src/platform/packages/shared/kbn-esql-language/src/language/validation/esql_validation_meta_tests.json +++ /dev/null @@ -1,899 +0,0 @@ -{ - "indexes": [ - "a_index", - "index", - "other_index", - ".secret_index", - "my-index", - "unsupported_index" - ], - "fields": [ - { - "name": "booleanField", - "type": "boolean", - "userDefined": false - }, - { - "name": "dateField", - "type": "date", - "userDefined": false - }, - { - "name": "doubleField", - "type": "double", - "userDefined": false - }, - { - "name": "ipField", - "type": "ip", - "userDefined": false - }, - { - "name": "keywordField", - "type": "keyword", - "userDefined": false - }, - { - "name": "integerField", - "type": "integer", - "userDefined": false - }, - { - "name": "longField", - "type": "long", - "userDefined": false - }, - { - "name": "textField", - "type": "text", - "userDefined": false - }, - { - "name": "unsignedLongField", - "type": "unsigned_long", - "userDefined": false - }, - { - "name": "versionField", - "type": "version", - "userDefined": false - }, - { - "name": "cartesianPointField", - "type": "cartesian_point", - "userDefined": false - }, - { - "name": "cartesianShapeField", - "type": "cartesian_shape", - "userDefined": false - }, - { - "name": "geoPointField", - "type": "geo_point", - "userDefined": false - }, - { - "name": "geoShapeField", - "type": "geo_shape", - "userDefined": false - }, - { - "name": "counterIntegerField", - "type": "counter_integer", - "userDefined": false - }, - { - "name": "counterLongField", - "type": "counter_long", - "userDefined": false - }, - { - "name": "counterDoubleField", - "type": "counter_double", - "userDefined": false - }, - { - "name": "unsupportedField", - "type": "unsupported", - "userDefined": false - }, - { - "name": "dateNanosField", - "type": "date_nanos", - "userDefined": false - }, - { - "name": "functionNamedParametersField", - "type": "function_named_parameters", - "userDefined": false - }, - { - "name": "aggregateMetricDoubleField", - "type": "aggregate_metric_double", - "userDefined": false - }, - { - "name": "denseVectorField", - "type": "dense_vector", - "userDefined": false - }, - { - "name": "histogramField", - "type": "histogram", - "userDefined": false - }, - { - "name": "exponentialHistogramField", - "type": "exponential_histogram", - "userDefined": false - }, - { - "name": "tdigestField", - "type": "tdigest", - "userDefined": false - }, - { - "name": "flattenedField", - "type": "flattened", - "userDefined": false - }, - { - "name": "any#Char$Field", - "type": "double", - "userDefined": false - }, - { - "name": "kubernetes.something.something", - "type": "double", - "userDefined": false - }, - { - "name": "@timestamp", - "type": "date", - "userDefined": false - }, - { - "name": "otherStringField", - "type": "keyword", - "userDefined": false - } - ], - "enrichFields": [ - { - "name": "otherField", - "type": "text", - "userDefined": false - }, - { - "name": "yetAnotherField", - "type": "double", - "userDefined": false - }, - { - "name": "otherStringField", - "type": "keyword", - "userDefined": false - } - ], - "policies": [ - { - "name": "policy", - "sourceIndices": [ - "enrich_index" - ], - "matchField": "otherStringField", - "enrichFields": [ - "otherField", - "yetAnotherField" - ] - }, - { - "name": "policy$", - "sourceIndices": [ - "enrich_index" - ], - "matchField": "otherStringField", - "enrichFields": [ - "otherField", - "yetAnotherField" - ] - } - ], - "unsupported_field": [ - { - "name": "unsupported_field", - "type": "unsupported", - "userDefined": false - } - ], - "testCases": [ - { - "query": "", - "error": [], - "warning": [] - }, - { - "query": " ", - "error": [], - "warning": [] - }, - { - "query": " ", - "error": [], - "warning": [] - }, - { - "query": "row", - "error": [ - { - "sample": "SyntaxError:", - "inverse": false - } - ], - "warning": [] - }, - { - "query": "from index | limit ", - "error": [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, '['}" - ], - "warning": [] - }, - { - "query": "from index | limit 4 ", - "error": [], - "warning": [] - }, - { - "query": "from index | limit a", - "error": [ - "SyntaxError: mismatched input 'a' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, '['}" - ], - "warning": [] - }, - { - "query": "from index | limit doubleField", - "error": [ - "SyntaxError: mismatched input 'doubleField' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, '['}" - ], - "warning": [] - }, - { - "query": "from index | limit textField", - "error": [ - "SyntaxError: mismatched input 'textField' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, '['}" - ], - "warning": [] - }, - { - "query": "from index | limit 4", - "error": [], - "warning": [] - }, - { - "query": "ROW a=1::LONG | LOOKUP JOIN t ON a", - "error": [ - "\"t\" is not a valid JOIN index. Please use a \"lookup\" mode index." - ], - "warning": [] - }, - { - "query": "FROM a_index | LEFT JOIN join_index ON textField == keywordField, booleanField", - "error": [ - "JOIN ON clause must be a comma separated list of fields or a single expression" - ], - "warning": [] - }, - { - "query": "from index | drop ", - "error": [ - { - "sample": "SyntaxError: mismatched input", - "inverse": false - } - ], - "warning": [] - }, - { - "query": "from index | drop 4.5", - "error": [ - { - "sample": "SyntaxError:", - "inverse": false - }, - { - "sample": "SyntaxError:", - "inverse": false - }, - { - "sample": "SyntaxError:", - "inverse": false - }, - "Unknown column \".\"" - ], - "warning": [] - }, - { - "query": "from index | drop missingField, doubleField, dateField", - "error": [ - "Unknown column \"missingField\"" - ], - "warning": [] - }, - { - "query": "from a_index | mv_expand ", - "error": [ - { - "sample": "SyntaxError:", - "inverse": false - } - ], - "warning": [] - }, - { - "query": "from a_index | mv_expand doubleField, b", - "error": [ - { - "sample": "SyntaxError:", - "inverse": false - }, - { - "sample": "SyntaxError:", - "inverse": false - } - ], - "warning": [] - }, - { - "query": "from a_index | rename", - "error": [ - { - "sample": "SyntaxError:", - "inverse": false - } - ], - "warning": [] - }, - { - "query": "from a_index | rename textField", - "error": [ - "SyntaxError: no viable alternative at input 'textField'" - ], - "warning": [] - }, - { - "query": "from a_index | rename a", - "error": [ - "SyntaxError: no viable alternative at input 'a'" - ], - "warning": [] - }, - { - "query": "from a_index | rename textField as", - "error": [ - { - "sample": "SyntaxError:", - "inverse": false - }, - "AS expected 2 arguments, but got 1." - ], - "warning": [] - }, - { - "query": "row a = 10 | rename a as this is fine", - "error": [ - "SyntaxError: mismatched input 'is' expecting " - ], - "warning": [] - }, - { - "query": "from a_index | dissect", - "error": [ - { - "sample": "SyntaxError:", - "inverse": false - } - ], - "warning": [] - }, - { - "query": "from a_index | dissect textField", - "error": [ - "SyntaxError: missing QUOTED_STRING at ''" - ], - "warning": [] - }, - { - "query": "from a_index | dissect textField 2", - "error": [ - "SyntaxError: mismatched input '2' expecting QUOTED_STRING" - ], - "warning": [] - }, - { - "query": "from a_index | dissect textField .", - "error": [ - "SyntaxError: mismatched input '' expecting {'?', '??', NAMED_OR_POSITIONAL_PARAM, NAMED_OR_POSITIONAL_DOUBLE_PARAMS, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}" - ], - "warning": [] - }, - { - "query": "from a_index | dissect textField %a", - "error": [ - "SyntaxError: mismatched input '%' expecting QUOTED_STRING", - "SyntaxError: mismatched input '' expecting '='" - ], - "warning": [] - }, - { - "query": "from a_index | grok", - "error": [ - { - "sample": "SyntaxError:", - "inverse": false - } - ], - "warning": [] - }, - { - "query": "from a_index | grok textField", - "error": [ - { - "sample": "SyntaxError:", - "inverse": false - } - ], - "warning": [] - }, - { - "query": "from a_index | grok textField 2", - "error": [ - { - "sample": "SyntaxError:", - "inverse": false - } - ], - "warning": [] - }, - { - "query": "from a_index | grok textField .", - "error": [ - { - "sample": "SyntaxError:", - "inverse": false - } - ], - "warning": [] - }, - { - "query": "from a_index | grok textField %a", - "error": [ - { - "sample": "SyntaxError:", - "inverse": false - } - ], - "warning": [] - }, - { - "query": "from a_index | where *+ doubleField", - "error": [ - { - "sample": "SyntaxError:", - "inverse": false - } - ], - "warning": [] - }, - { - "query": "from a_index | where /+ doubleField", - "error": [ - { - "sample": "SyntaxError:", - "inverse": false - } - ], - "warning": [] - }, - { - "query": "from a_index | where %+ doubleField", - "error": [ - { - "sample": "SyntaxError:", - "inverse": false - } - ], - "warning": [] - }, - { - "query": "from a_index | eval ", - "error": [ - { - "sample": "SyntaxError:", - "inverse": false - } - ], - "warning": [] - }, - { - "query": "from a_index | eval doubleField + ", - "error": [ - { - "sample": "SyntaxError:", - "inverse": false - } - ], - "warning": [] - }, - { - "query": "from a_index | eval a=round(", - "error": [ - { - "sample": "SyntaxError:", - "inverse": false - } - ], - "warning": [] - }, - { - "query": "from a_index | eval a=round(doubleField) ", - "error": [], - "warning": [] - }, - { - "query": "from a_index | eval a=round(doubleField), ", - "error": [ - { - "sample": "SyntaxError:", - "inverse": false - } - ], - "warning": [] - }, - { - "query": "from a_index | eval *+ doubleField", - "error": [ - { - "sample": "SyntaxError:", - "inverse": false - } - ], - "warning": [] - }, - { - "query": "from a_index | eval /+ doubleField", - "error": [ - { - "sample": "SyntaxError:", - "inverse": false - } - ], - "warning": [] - }, - { - "query": "from a_index | eval %+ doubleField", - "error": [ - { - "sample": "SyntaxError:", - "inverse": false - } - ], - "warning": [] - }, - { - "query": "from a_index | sort ", - "error": [ - { - "sample": "SyntaxError:", - "inverse": false - } - ], - "warning": [] - }, - { - "query": "from a_index | sort doubleField, ", - "error": [ - { - "sample": "SyntaxError:", - "inverse": false - } - ], - "warning": [] - }, - { - "query": "from a_index | sort doubleField desc nulls ", - "error": [ - "SyntaxError: missing {'first', 'last'} at ''" - ], - "warning": [] - }, - { - "query": "from a_index | sort doubleField desc first", - "error": [ - "SyntaxError: extraneous input 'first' expecting " - ], - "warning": [] - }, - { - "query": "from a_index | sort doubleField desc last", - "error": [ - "SyntaxError: extraneous input 'last' expecting " - ], - "warning": [] - }, - { - "query": "from a_index | sort doubleField asc nulls ", - "error": [ - "SyntaxError: missing {'first', 'last'} at ''" - ], - "warning": [] - }, - { - "query": "from a_index | sort doubleField asc first", - "error": [ - "SyntaxError: extraneous input 'first' expecting " - ], - "warning": [] - }, - { - "query": "from a_index | sort doubleField asc last", - "error": [ - "SyntaxError: extraneous input 'last' expecting " - ], - "warning": [] - }, - { - "query": "from a_index | sort doubleField first", - "error": [ - "SyntaxError: extraneous input 'first' expecting " - ], - "warning": [] - }, - { - "query": "from a_index | sort doubleField last", - "error": [ - "SyntaxError: extraneous input 'last' expecting " - ], - "warning": [] - }, - { - "query": "from a_index | enrich", - "error": [ - "SyntaxError: missing {ENRICH_POLICY_NAME, QUOTED_STRING} at ''" - ], - "warning": [] - }, - { - "query": "from a_index | enrich _:", - "error": [ - "SyntaxError: token recognition error at: ':'", - "Unknown policy \"_\"" - ], - "warning": [] - }, - { - "query": "from a_index | enrich :policy", - "error": [ - "SyntaxError: token recognition error at: ':'" - ], - "warning": [] - }, - { - "query": "from a_index | enrich policy on textField with ", - "error": [ - { - "sample": "SyntaxError:", - "inverse": false - } - ], - "warning": [] - }, - { - "query": "from a_index | enrich policy with ", - "error": [ - { - "sample": "SyntaxError:", - "inverse": false - } - ], - "warning": [] - }, - { - "query": "SET time_zone = \"CEST\";", - "error": [ - { - "sample": "SyntaxError:", - "inverse": false - } - ], - "warning": [] - }, - { - "query": "SET invalid_setting = \"_alias:_origin\"; FROM index", - "error": [ - { - "sample": "Unknown setting invalid_setting", - "inverse": false - } - ], - "warning": [] - }, - { - "query": "from a_index | eval round(doubleField) + 1 | eval `round(doubleField) + 1` + 1 | keep ```round(doubleField) + 1`` + 1`", - "error": [], - "warning": [] - }, - { - "query": "from a_index | eval round(doubleField) + 1 | eval `round(doubleField) + 1` + 1 | eval ```round(doubleField) + 1`` + 1` + 1 | keep ```````round(doubleField) + 1```` + 1`` + 1`", - "error": [], - "warning": [] - }, - { - "query": "from a_index | eval round(doubleField) + 1 | eval `round(doubleField) + 1` + 1 | eval ```round(doubleField) + 1`` + 1` + 1 | eval ```````round(doubleField) + 1```` + 1`` + 1` + 1 | keep ```````````````round(doubleField) + 1```````` + 1```` + 1`` + 1`", - "error": [], - "warning": [] - }, - { - "query": "from a_index | eval round(doubleField) + 1 | eval `round(doubleField) + 1` + 1 | eval ```round(doubleField) + 1`` + 1` + 1 | eval ```````round(doubleField) + 1```` + 1`` + 1` + 1 | eval ```````````````round(doubleField) + 1```````` + 1```` + 1`` + 1` + 1 | keep ```````````````````````````````round(doubleField) + 1```````````````` + 1```````` + 1```` + 1`` + 1`", - "error": [], - "warning": [] - }, - { - "query": "from a_index | eval 1::keyword", - "error": [], - "warning": [] - }, - { - "query": "from a_index | eval 1::keyword::long::double", - "error": [], - "warning": [] - }, - { - "query": "from a_index | where 1::string==\"keyword\"", - "error": [], - "warning": [] - }, - { - "query": "from a_index | eval trim(\"23\"::double)", - "error": [ - "Invalid input types for TRIM.\n\nReceived (double).\n\nExpected one of:\n - (keyword)\n - (text)" - ], - "warning": [] - }, - { - "query": "from a_index | eval trim(23::keyword)", - "error": [], - "warning": [] - }, - { - "query": "from a_index | eval 1 + \"2\"::long", - "error": [], - "warning": [] - }, - { - "query": "from a_index | eval 1 + \"2\"::LONG", - "error": [], - "warning": [] - }, - { - "query": "from a_index | eval 1 + \"2\"::Long", - "error": [], - "warning": [] - }, - { - "query": "from a_index | eval 1 + \"2\"::LoNg", - "error": [], - "warning": [] - }, - { - "query": "from a_index | eval 1 + \"2\"", - "error": [ - "Invalid input types for +.\n\nReceived (integer, keyword).\n\nExpected one of:\n - (date, date_period)\n - (date, time_duration)\n - (date_nanos, date_period)\n - (date_nanos, time_duration)\n - (date_period, date)\n - (date_period, date_nanos)\n - (date_period, date_period)\n - (dense_vector, dense_vector)\n - (dense_vector, double)\n - (dense_vector, integer)\n - (dense_vector, long)\n - (double, dense_vector)\n - (double, double)\n - (double, integer)\n - (double, long)\n - (integer, dense_vector)\n - (integer, double)\n - (integer, integer)\n - (integer, long)\n - (long, dense_vector)\n - (long, double)\n - (long, integer)\n - (long, long)\n - (time_duration, date)\n - (time_duration, date_nanos)\n - (time_duration, time_duration)\n - (unsigned_long, unsigned_long)\n - (time_duration, date)\n - (date, time_duration)" - ], - "warning": [] - }, - { - "query": "from a_index | eval trim(to_double(\"23\")::keyword::double::long::keyword::double)", - "error": [ - "Invalid input types for TRIM.\n\nReceived (double).\n\nExpected one of:\n - (keyword)\n - (text)" - ], - "warning": [] - }, - { - "query": "from a_index | eval CEIL(23::long)", - "error": [], - "warning": [] - }, - { - "query": "from a_index | eval CEIL(23::unsigned_long)", - "error": [], - "warning": [] - }, - { - "query": "from a_index | eval CEIL(23::int)", - "error": [], - "warning": [] - }, - { - "query": "from a_index | eval CEIL(23::integer)", - "error": [], - "warning": [] - }, - { - "query": "from a_index | eval CEIL(23::Integer)", - "error": [], - "warning": [] - }, - { - "query": "from a_index | eval CEIL(23::double)", - "error": [], - "warning": [] - }, - { - "query": "from a_index | eval CEIL(23::DOUBLE)", - "error": [], - "warning": [] - }, - { - "query": "from a_index | eval TRIM(23::keyword)", - "error": [], - "warning": [] - }, - { - "query": "from a_index | eval TRIM(23::string)", - "error": [], - "warning": [] - }, - { - "query": "from a_index | eval TRIM(23::keyword)", - "error": [], - "warning": [] - }, - { - "query": "from a_index | eval true AND 0::boolean", - "error": [], - "warning": [] - }, - { - "query": "from a_index | eval true AND 0::bool", - "error": [], - "warning": [] - }, - { - "query": "from a_index | eval true AND 0", - "error": [ - "Invalid input types for AND.\n\nReceived (boolean, integer).\n\nExpected one of:\n - (boolean, boolean)" - ], - "warning": [] - }, - { - "query": "from a_index | eval to_lower(trim(doubleField)::keyword)", - "error": [ - "Invalid input types for TRIM.\n\nReceived (double).\n\nExpected one of:\n - (keyword)\n - (text)" - ], - "warning": [] - }, - { - "query": "from a_index | eval to_upper(trim(doubleField)::keyword::keyword::keyword::keyword)", - "error": [ - "Invalid input types for TRIM.\n\nReceived (double).\n\nExpected one of:\n - (keyword)\n - (text)" - ], - "warning": [] - }, - { - "query": "from a_index | eval to_lower(to_upper(trim(doubleField)::keyword)::keyword)", - "error": [ - "Invalid input types for TRIM.\n\nReceived (double).\n\nExpected one of:\n - (keyword)\n - (text)" - ], - "warning": [] - }, - { - "query": "from a_index | keep unsupportedField", - "error": [], - "warning": [ - "Field \"unsupportedField\" cannot be retrieved, it is unsupported or not indexed; returning null" - ] - } - ] -} \ No newline at end of file diff --git a/src/platform/packages/shared/kbn-esql-language/src/language/validation/integration_tests/helpers.ts b/src/platform/packages/shared/kbn-esql-language/src/language/validation/integration_tests/helpers.ts new file mode 100644 index 0000000000000..a3994d9ab2566 --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-language/src/language/validation/integration_tests/helpers.ts @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { EsqlQueryResponse, MappingProperty } from '@elastic/elasticsearch/lib/api/types'; +import { createTestEsCluster } from '@kbn/test-es-server'; +import { ToolingLog } from '@kbn/tooling-log'; +import { ESQL_NAMED_PARAMS_TYPE } from '../../../commands/definitions/types'; +import { + enrichFields as enrichFieldsHelper, + fields as fieldsHelper, + indexes, + policies, + unsupported_field, +} from '../../../__tests__/language/helpers'; + +export type EsqlEnv = Awaited>; + +interface EsqlErrorResponse { + meta?: { + body?: { + error?: { + reason?: string; + root_cause?: Array<{ reason?: string }>; + }; + }; + }; +} + +const nonIndexableFieldTypes = new Set(['date_period', 'null', 'time_duration', 'time_literal']); +const fields = [...fieldsHelper, { name: policies[0].matchField, type: 'keyword' }]; +const enrichIndexFields = [ + ...enrichFieldsHelper, + { name: policies[0].matchField, type: 'keyword' }, +]; +const isIndexableField = ({ type }: { type: string }) => + !type.startsWith('counter_') && !nonIndexableFieldTypes.has(type); + +const getEsqlErrorReason = (error: unknown): string => { + const responseError = error as EsqlErrorResponse; + return ( + responseError.meta?.body?.error?.root_cause?.[0]?.reason ?? + responseError.meta?.body?.error?.reason ?? + (error instanceof Error ? error.message : String(error)) + ); +}; + +const createIndexRequest = (index: string, fieldList: Array<{ name: string; type: string }>) => { + return { + index, + mappings: { + properties: fieldList.reduce((memo: Record, { name, type }) => { + let esType = type; + + if (type === 'cartesian_point') { + esType = 'point'; + } + if (type === 'cartesian_shape') { + esType = 'shape'; + } + if (type === 'aggregate_metric_double') { + esType = 'double'; + } + if (type === ESQL_NAMED_PARAMS_TYPE || type === 'unsupported') { + esType = 'integer_range'; + } + + memo[name] = { type: esType } as MappingProperty; + return memo; + }, {}), + }, + }; +}; + +const setupIntegrationEnv = async () => { + // ES-only: we spin up a local ES test cluster without Kibana. + // Faster startup and fewer moving parts, while still validating against real ES responses. + const es = createTestEsCluster({ + license: 'basic', + log: new ToolingLog({ + level: 'warning', + writeTo: process.stdout, + }), + }); + + await es.start(); + + const esClient = es.getClient(); + const stop = async () => { + // Remove the ES test install after the suite to avoid leaving test artifacts behind. + await es.cleanup(); + }; + + return { + es, + esClient, + stop, + }; +}; + +export const setupEsqlEnv = async () => { + const integrationEnv = await setupIntegrationEnv(); + const es = integrationEnv.esClient; + const uniqueSourceIndices = Array.from( + new Set(policies.flatMap((policy) => policy.sourceIndices)) + ); + + const cleanup = async () => { + await es.indices.delete({ index: indexes, ignore_unavailable: true }, { ignore: [404] }); + await es.indices.delete( + { index: uniqueSourceIndices, ignore_unavailable: true }, + { ignore: [404] } + ); + for (const policy of policies) { + await es.enrich.deletePolicy({ name: policy.name }, { ignore: [404] }); + } + }; + + const setupIndicesPolicies = async () => { + await cleanup(); + + const indexableFields = fields.filter(isIndexableField); + const indexableEnrichFields = enrichIndexFields.filter(isIndexableField); + + for (const index of indexes) { + await es.indices.create( + createIndexRequest(index, /unsupported/.test(index) ? unsupported_field : indexableFields), + { ignore: [409] } + ); + } + + for (const sourceIndex of uniqueSourceIndices) { + await es.indices.create(createIndexRequest(sourceIndex, indexableEnrichFields), { + ignore: [409], + }); + } + + for (const { + name, + sourceIndices, + matchField: policyMatchField, + enrichFields: policyEnrichFields, + } of policies) { + await es.enrich.putPolicy( + { + name, + match: { + indices: sourceIndices, + match_field: policyMatchField, + enrich_fields: policyEnrichFields, + }, + }, + { ignore: [409] } + ); + await es.enrich.executePolicy({ name }); + } + }; + + const sendEsqlQuery = async ( + query: string + ): Promise<{ + resp: EsqlQueryResponse | undefined; + error: { message: string } | undefined; + }> => { + try { + const resp = await es.esql.query({ + query, + }); + return { resp, error: undefined }; + } catch (error) { + return { resp: undefined, error: { message: getEsqlErrorReason(error) } }; + } + }; + + return { + integrationEnv, + cleanup, + setupIndicesPolicies, + sendEsqlQuery, + }; +}; diff --git a/src/platform/packages/shared/kbn-esql-language/src/language/validation/integration_tests/validation_suites.test.ts b/src/platform/packages/shared/kbn-esql-language/src/language/validation/integration_tests/validation_suites.test.ts new file mode 100644 index 0000000000000..6158ad8d50851 --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-language/src/language/validation/integration_tests/validation_suites.test.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { EditorError } from '@elastic/esql/types'; +import type { ESQLMessage } from '../../../commands'; +import { createValidationTestSetup, type Setup } from '../__tests__/helpers'; +import { runColumnExistenceValidationSuite } from '../__tests__/column_existence_suite'; +import { runCommandsValidationSuite } from '../__tests__/commands_suite'; +import { runFieldsAndVariablesValidationSuite } from '../__tests__/fields_and_variables_suite'; +import { runFunctionsValidationSuite } from '../__tests__/functions_suite'; +import { runInlineCastValidationSuite } from '../__tests__/inline_cast_suite'; +import { runSourcesValidationSuite } from '../__tests__/sources_suite'; +import { runSubqueriesValidationSuite } from '../__tests__/subqueries_suite'; +import { runValidationCommandsLicenseSuite } from '../__tests__/commands_license_suite'; +import { runValidationParamsSuite } from '../__tests__/params_suite'; +import { setupEsqlEnv, type EsqlEnv } from './helpers'; + +const getValidationErrorMessage = (error: ESQLMessage | EditorError) => + 'message' in error ? error.message : error.text; + +describe('ES|QL validation integration suites', () => { + let esqlEnv: EsqlEnv | undefined; + const clientErrorsWhenEsAccepts: string[] = []; + const setup: Setup = createValidationTestSetup({ + afterValidate: async ({ query, result, hasUnmodifiedDefaultCallbacks }) => { + // Integration tests compare with real ES, while validateQuery still uses unit-test mocks. + // This flag lets us skip ES checks when a unit test overrides those mocks. + if (!hasUnmodifiedDefaultCallbacks) { + return; + } + + if (!esqlEnv) { + throw new Error('ES|QL integration environment has not been initialized.'); + } + + const clientHasError = result.errors.length > 0; + if (!clientHasError) { + return; + } + + const esqlResponse = await esqlEnv.sendEsqlQuery(query); + + // Only client false positives are blocking because they reject queries ES would accept. + // False negatives are weaker signals here because they do not block valid user queries. + if (!esqlResponse.error) { + clientErrorsWhenEsAccepts.push( + `Elasticsearch accepted the query but client validation reported errors: ${JSON.stringify( + query + )}; errors: ${JSON.stringify(result.errors.map(getValidationErrorMessage))}` + ); + } + }, + }); + + beforeAll(async () => { + esqlEnv = await setupEsqlEnv(); + await esqlEnv.setupIndicesPolicies(); + }); + + afterAll(async () => { + // Delete the test data first, then stop the ES cluster started for this suite. + await esqlEnv?.cleanup(); + await esqlEnv?.integrationEnv.stop(); + }); + + runColumnExistenceValidationSuite(setup); + runCommandsValidationSuite(setup); + runFieldsAndVariablesValidationSuite(setup); + runSourcesValidationSuite(setup); + runFunctionsValidationSuite(setup); + runInlineCastValidationSuite(setup); + runSubqueriesValidationSuite(setup); + runValidationCommandsLicenseSuite(setup); + runValidationParamsSuite(setup); + + it('when Elasticsearch accepts a query, the client validator does not report errors', () => { + expect(clientErrorsWhenEsAccepts).toEqual([]); + }); +}); diff --git a/src/platform/packages/shared/kbn-esql-language/src/language/validation/validation.test.ts b/src/platform/packages/shared/kbn-esql-language/src/language/validation/validation.test.ts index f1442115db0bb..3e2d88621baba 100644 --- a/src/platform/packages/shared/kbn-esql-language/src/language/validation/validation.test.ts +++ b/src/platform/packages/shared/kbn-esql-language/src/language/validation/validation.test.ts @@ -6,25 +6,15 @@ * your election, the "Elastic License 2.0", the "GNU Affero General Public * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { EsqlFieldType } from '@kbn/esql-types'; +import type { EsqlFieldType, ESQLCallbacks } from '@kbn/esql-types'; import type { EditorError } from '@elastic/esql/types'; import type { SupportedDataType, FunctionDefinition, ESQLMessage } from '../../..'; import { timeUnitsToSuggest, dataTypes, getNoValidCallSignatureError } from '../../..'; import { getFunctionSignatures } from '../../commands/definitions/utils'; import { scalarFunctionDefinitions } from '../../commands/definitions/generated/scalar_functions'; import { aggFunctionDefinitions } from '../../commands/definitions/generated/aggregation_functions'; -import { readFile, writeFile } from 'fs/promises'; import { camelCase } from 'lodash'; -import { join } from 'path'; -import { - enrichFields, - fields, - getCallbackMocks, - indexes, - policies, - unsupported_field, -} from '../../__tests__/language/helpers'; -import { setup } from './__tests__/helpers'; +import { getCallbackMocks } from '../../__tests__/language/helpers'; import { validateQuery } from './validation'; const NESTING_LEVELS = 4; @@ -168,36 +158,7 @@ function getFieldMapping( } describe('validation logic', () => { - const testCases: Array<{ query: string; error: string[]; warning: string[] }> = []; - describe('Full validation performed', () => { - afterAll(async () => { - const targetFolder = join(__dirname, 'esql_validation_meta_tests.json'); - try { - await writeFile( - targetFolder, - JSON.stringify( - { - indexes, - fields: fields.concat([ - { name: policies[0].matchField, type: 'keyword', userDefined: false }, - ]), - enrichFields: enrichFields.concat([ - { name: policies[0].matchField, type: 'keyword', userDefined: false }, - ]), - policies, - unsupported_field, - testCases, - }, - null, - 2 - ) - ); - } catch (e) { - throw new Error(`Error writing test cases to ${targetFolder}: ${e.message}`); - } - }); - function testErrorsAndWarningsFn( statement: string, expectedErrors: string[] = [], @@ -205,11 +166,6 @@ describe('validation logic', () => { { only, skip }: { only?: boolean; skip?: boolean } = {} ) { const testFn = only ? it.only : skip ? it.skip : it; - testCases.push({ - query: statement, - error: expectedErrors, - warning: expectedWarnings, - }); testFn( `${statement} => ${expectedErrors.length} errors, ${expectedWarnings.length} warnings`, @@ -250,296 +206,6 @@ describe('validation logic', () => { }, }); - // The following block tests a case that is allowed in Kibana - // by suppressing the parser error in https://github.com/elastic/esql-js/blob/main/src/parser/core/esql_error_listener.ts - describe('EMPTY query does NOT produce syntax error', () => { - testErrorsAndWarnings('', []); - testErrorsAndWarnings(' ', []); - testErrorsAndWarnings(' ', []); - }); - - describe('FROM [ METADATA ]', () => { - test('errors on invalid command start', async () => { - const { expectErrors } = await setup(); - - await expectErrors('f', [expect.any(String)]); - await expectErrors('from ', [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, '(', UNQUOTED_SOURCE}", - ]); - }); - - describe('... ...', () => { - test('errors on trailing comma', async () => { - const { expectErrors } = await setup(); - - await expectErrors('from index,', [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, '(', UNQUOTED_SOURCE}", - ]); - await expectErrors(`FROM index\n, \tother_index\t,\n \t `, [ - "SyntaxError: mismatched input '' expecting {QUOTED_STRING, '(', UNQUOTED_SOURCE}", - ]); - - await expectErrors(`from assignment = 1`, [ - "SyntaxError: mismatched input '=' expecting ", - 'Unknown data source "assignment"', - ]); - }); - - test('errors on invalid syntax', async () => { - const { expectErrors } = await setup(); - - await expectErrors('FROM `index`', ['Unknown data source "`index`"']); - await expectErrors(`from assignment = 1`, [ - "SyntaxError: mismatched input '=' expecting ", - 'Unknown data source "assignment"', - ]); - }); - }); - - describe('hidden sources', () => { - test('does not error on dot-prefixed backing index', async () => { - const { expectErrors } = await setup(); - await expectErrors('FROM .ds-log-elasticsearch-default-2025.09.11-000006', []); - }); - - test('does not error on mix of backing index and known index', async () => { - const { expectErrors } = await setup(); - await expectErrors('FROM .ds-foo,index', []); - }); - - test('does not error on CCS backing index', async () => { - const { expectErrors } = await setup(); - await expectErrors('FROM "mycluster:.ds-foo"', []); - }); - - test('still errors on truly unknown non-dot sources', async () => { - const { expectErrors } = await setup(); - await expectErrors('FROM truly_unknown', ['Unknown data source "truly_unknown"']); - }); - - test('still errors on mix where one part is unknown and not dot-prefixed', async () => { - const { expectErrors } = await setup(); - await expectErrors('FROM truly_unknown,index', ['Unknown data source "truly_unknown"']); - }); - }); - - describe('... METADATA ', () => { - test('errors when wrapped in parentheses', async () => { - const { expectErrors } = await setup(); - - await expectErrors(`from index (metadata _id)`, [ - "SyntaxError: mismatched input '(' expecting ", - ]); - }); - - describe('validates fields', () => { - test('validates fields', async () => { - const { expectErrors } = await setup(); - await expectErrors(`from index metadata _id, _source METADATA _id2`, [ - "SyntaxError: mismatched input 'METADATA' expecting ", - ]); - }); - }); - }); - }); - - describe('row', () => { - testErrorsAndWarnings('row', [expect.stringContaining('SyntaxError:')]); - - test('syntax error', async () => { - const { expectErrors } = await setup(); - - await expectErrors('row var = 1 in ', [expect.stringContaining('SyntaxError:')]); - await expectErrors('row var = 1 in (', [expect.stringContaining('SyntaxError:')]); - await expectErrors('row var = 1 not in ', [expect.stringContaining('SyntaxError:')]); - }); - }); - - describe('limit', () => { - testErrorsAndWarnings('from index | limit ', [ - `SyntaxError: mismatched input '' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, '['}`, - ]); - testErrorsAndWarnings('from index | limit 4 ', []); - testErrorsAndWarnings('from index | limit a', [ - "SyntaxError: mismatched input 'a' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, '['}", - ]); - testErrorsAndWarnings('from index | limit doubleField', [ - "SyntaxError: mismatched input 'doubleField' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, '['}", - ]); - testErrorsAndWarnings('from index | limit textField', [ - "SyntaxError: mismatched input 'textField' expecting {QUOTED_STRING, INTEGER_LITERAL, DECIMAL_LITERAL, 'false', 'null', '?', 'true', '+', '-', NAMED_OR_POSITIONAL_PARAM, '['}", - ]); - testErrorsAndWarnings('from index | limit 4', []); - }); - - describe('join', () => { - testErrorsAndWarnings('ROW a=1::LONG | LOOKUP JOIN t ON a', [ - '"t" is not a valid JOIN index. Please use a "lookup" mode index.', - ]); - - testErrorsAndWarnings( - 'FROM a_index | LEFT JOIN join_index ON textField == keywordField, booleanField', - ['JOIN ON clause must be a comma separated list of fields or a single expression'] - ); - }); - - describe('drop', () => { - testErrorsAndWarnings('from index | drop ', [ - expect.stringContaining('SyntaxError: mismatched input'), - ]); - testErrorsAndWarnings('from index | drop 4.5', [ - expect.stringContaining('SyntaxError:'), - expect.stringContaining('SyntaxError:'), - expect.stringContaining('SyntaxError:'), - 'Unknown column "."', - ]); - testErrorsAndWarnings('from index | drop missingField, doubleField, dateField', [ - 'Unknown column "missingField"', - ]); - }); - - describe('mv_expand', () => { - testErrorsAndWarnings('from a_index | mv_expand ', [expect.stringContaining('SyntaxError:')]); - - testErrorsAndWarnings('from a_index | mv_expand doubleField, b', [ - expect.stringContaining('SyntaxError:'), - expect.stringContaining('SyntaxError:'), - ]); - }); - - describe('rename', () => { - testErrorsAndWarnings('from a_index | rename', [expect.stringContaining('SyntaxError:')]); - testErrorsAndWarnings('from a_index | rename textField', [ - "SyntaxError: no viable alternative at input 'textField'", - ]); - testErrorsAndWarnings('from a_index | rename a', [ - "SyntaxError: no viable alternative at input 'a'", - ]); - testErrorsAndWarnings('from a_index | rename textField as', [ - expect.stringContaining('SyntaxError:'), - 'AS expected 2 arguments, but got 1.', - ]); - testErrorsAndWarnings('row a = 10 | rename a as this is fine', [ - "SyntaxError: mismatched input 'is' expecting ", - ]); - }); - - describe('dissect', () => { - testErrorsAndWarnings('from a_index | dissect', [expect.stringContaining('SyntaxError:')]); - testErrorsAndWarnings('from a_index | dissect textField', [ - "SyntaxError: missing QUOTED_STRING at ''", - ]); - testErrorsAndWarnings('from a_index | dissect textField 2', [ - "SyntaxError: mismatched input '2' expecting QUOTED_STRING", - ]); - testErrorsAndWarnings('from a_index | dissect textField .', [ - "SyntaxError: mismatched input '' expecting {'?', '??', NAMED_OR_POSITIONAL_PARAM, NAMED_OR_POSITIONAL_DOUBLE_PARAMS, UNQUOTED_IDENTIFIER, QUOTED_IDENTIFIER}", - ]); - testErrorsAndWarnings('from a_index | dissect textField %a', [ - "SyntaxError: mismatched input '%' expecting QUOTED_STRING", - "SyntaxError: mismatched input '' expecting '='", - ]); - }); - - describe('grok', () => { - testErrorsAndWarnings('from a_index | grok', [expect.stringContaining('SyntaxError:')]); - testErrorsAndWarnings('from a_index | grok textField', [ - expect.stringContaining('SyntaxError:'), - ]); - testErrorsAndWarnings('from a_index | grok textField 2', [ - expect.stringContaining('SyntaxError:'), - ]); - testErrorsAndWarnings('from a_index | grok textField .', [ - expect.stringContaining('SyntaxError:'), - ]); - testErrorsAndWarnings('from a_index | grok textField %a', [ - expect.stringContaining('SyntaxError:'), - ]); - // testErrorsAndWarnings('from a_index | grok s* "%{a}"', [ - // 'Using wildcards (*) in grok is not allowed [s*]', - // ]); - }); - - describe('where', () => { - for (const wrongOp of ['*', '/', '%']) { - testErrorsAndWarnings(`from a_index | where ${wrongOp}+ doubleField`, [ - expect.stringContaining('SyntaxError:'), - ]); - } - }); - - describe('eval', () => { - testErrorsAndWarnings('from a_index | eval ', [expect.stringContaining('SyntaxError:')]); - testErrorsAndWarnings('from a_index | eval doubleField + ', [ - expect.stringContaining('SyntaxError:'), - ]); - - testErrorsAndWarnings('from a_index | eval a=round(', [ - expect.stringContaining('SyntaxError:'), - ]); - testErrorsAndWarnings('from a_index | eval a=round(doubleField) ', []); - testErrorsAndWarnings('from a_index | eval a=round(doubleField), ', [ - expect.stringContaining('SyntaxError:'), - ]); - - for (const wrongOp of ['*', '/', '%']) { - testErrorsAndWarnings(`from a_index | eval ${wrongOp}+ doubleField`, [ - expect.stringContaining('SyntaxError:'), - ]); - } - }); - - describe('sort', () => { - testErrorsAndWarnings('from a_index | sort ', [expect.stringContaining('SyntaxError:')]); - testErrorsAndWarnings('from a_index | sort doubleField, ', [ - expect.stringContaining('SyntaxError:'), - ]); - - for (const dir of ['desc', 'asc']) { - testErrorsAndWarnings(`from a_index | sort doubleField ${dir} nulls `, [ - "SyntaxError: missing {'first', 'last'} at ''", - ]); - for (const nullDir of ['first', 'last']) { - testErrorsAndWarnings(`from a_index | sort doubleField ${dir} ${nullDir}`, [ - `SyntaxError: extraneous input '${nullDir}' expecting `, - ]); - } - } - for (const nullDir of ['first', 'last']) { - testErrorsAndWarnings(`from a_index | sort doubleField ${nullDir}`, [ - `SyntaxError: extraneous input '${nullDir}' expecting `, - ]); - } - }); - - describe('enrich', () => { - testErrorsAndWarnings(`from a_index | enrich`, [ - "SyntaxError: missing {ENRICH_POLICY_NAME, QUOTED_STRING} at ''", - ]); - testErrorsAndWarnings(`from a_index | enrich _:`, [ - "SyntaxError: token recognition error at: ':'", - 'Unknown policy "_"', - ]); - testErrorsAndWarnings(`from a_index | enrich :policy`, [ - "SyntaxError: token recognition error at: ':'", - ]); - - testErrorsAndWarnings(`from a_index | enrich policy on textField with `, [ - expect.stringContaining('SyntaxError:'), - ]); - testErrorsAndWarnings(`from a_index | enrich policy with `, [ - expect.stringContaining('SyntaxError:'), - ]); - }); - - describe('settings', () => { - // Should return error if there is no query following SET - testErrorsAndWarnings(`SET time_zone = "CEST";`, [expect.stringContaining('SyntaxError:')]); - testErrorsAndWarnings(`SET invalid_setting = "_alias:_origin"; FROM index`, [ - expect.stringContaining('Unknown setting invalid_setting'), - ]); - }); - describe('shadowing', () => { // fields shadowing validation removed }); @@ -731,120 +397,72 @@ describe('validation logic', () => { }); describe('Ignoring errors based on callbacks', () => { - interface Fixtures { - testCases: Array<{ query: string; error: string[] }>; - } - - async function loadFixtures() { - // early exit if the testCases are already defined locally - const localTestCases: Array<{ query: string; error: string[] }> = []; - if (localTestCases.length) { - return { testCases: localTestCases }; - } - const json = await readFile(join(__dirname, 'esql_validation_meta_tests.json'), 'utf8'); - const esqlPackage = JSON.parse(json); - return esqlPackage as Fixtures; - } - - function excludeErrorsByContent(excludedCallback: string[]) { - const contentByCallback = { - getSources: /Unknown (index|data source)/, - getPolicies: /Unknown policy/, - getColumnsFor: /Unknown column|Argument of|it is unsupported or not indexed/, - getPreferences: /Unknown/, - getFieldsMetadata: /Unknown/, - getVariables: /Unknown/, - canSuggestVariables: /Unknown/, - }; - return excludedCallback.map((callback) => (contentByCallback as any)[callback]) || []; - } - - function getPartialCallbackMocks(exclude?: string) { - return { - ...getCallbackMocks(), - ...(exclude ? { [exclude]: undefined } : {}), - }; - } - - let fixtures: Fixtures; - - beforeAll(async () => { - fixtures = await loadFixtures(); + const getPartialCallbackMocks = (exclude?: string): ESQLCallbacks => ({ + ...getCallbackMocks(), + ...(exclude ? { [exclude]: undefined } : {}), }); - it('should basically work when all callbacks are passed', async () => { - const allErrors = await Promise.all( - fixtures.testCases - .filter(({ query }) => query === 'from index METADATA _id, _source2') - .map(({ query }) => validateQuery(query, getCallbackMocks())) - ); - for (const [index, { errors }] of Object.entries(allErrors)) { - expect(errors.map((e) => ('severity' in e ? e.message : e.text))).toEqual( - fixtures.testCases.filter(({ query }) => query === 'from index METADATA _id, _source2')[ - Number(index) - ].error - ); - } - }); + const collectErrorCodes = async (query: string, callbacks: ESQLCallbacks) => { + const { errors } = await validateQuery(query, callbacks); + return errors.map((error) => error.code); + }; - // test excluding one callback at the time - it.each(['getSources', 'getColumnsFor', 'getPolicies'])( - `should not error if %s is missing`, - async (excludedCallback) => { - const filteredTestCases = fixtures.testCases.filter((t) => - t.error.some((message) => - excludeErrorsByContent([excludedCallback]).every((regexp) => regexp?.test(message)) - ) - ); - const allErrors = await Promise.all( - filteredTestCases.map(({ query }) => - validateQuery(query, getPartialCallbackMocks(excludedCallback)) + // Queries that produce errors which depend on a specific callback being available. + // When that callback is missing, the validator must suppress the related error codes. + const callbackScenarios = [ + { + callback: 'getSources', + codes: ['unknownIndex', 'unknownDataSource'], + queries: ['FROM unknown_index', 'FROM index, unknown_index'], + }, + { + callback: 'getColumnsFor', + codes: ['unknownColumn', 'wrongArgumentType', 'unsupportedFieldType'], + queries: [ + 'FROM index | KEEP unknownColumn', + 'FROM index | EVAL rounded = ROUND(keywordField)', + 'FROM index | KEEP unsupportedField', + ], + }, + { + callback: 'getPolicies', + codes: ['unknownPolicy'], + queries: ['FROM index | ENRICH unknown_policy'], + }, + ]; + + it.each(callbackScenarios)( + 'suppresses $callback-dependent errors when $callback is missing', + async ({ callback, codes, queries }) => { + // Sanity: with all callbacks present, the scenario actually exercises its error codes. + const codesWithAllCallbacks = ( + await Promise.all(queries.map((query) => collectErrorCodes(query, getCallbackMocks()))) + ).flat(); + expect(codes.some((code) => codesWithAllCallbacks.includes(code))).toBe(true); + + // With the callback removed, none of the related codes must be reported. + const codesWithoutCallback = ( + await Promise.all( + queries.map((query) => collectErrorCodes(query, getPartialCallbackMocks(callback))) ) - ); - for (const { errors } of allErrors) { - const errorCodes = errors.map((e) => e.code); - // Verify errors related to excluded callback are not present - if (excludedCallback === 'getSources') { - expect( - errorCodes.every((code) => code !== 'unknownIndex' && code !== 'unknownDataSource') - ).toBe(true); - } else if (excludedCallback === 'getColumnsFor') { - expect( - errorCodes.every( - (code) => - code !== 'unknownColumn' && - code !== 'wrongArgumentType' && - code !== 'unsupportedFieldType' - ) - ).toBe(true); - } else if (excludedCallback === 'getPolicies') { - expect(errorCodes.every((code) => code !== 'unknownPolicy')).toBe(true); - } + ).flat(); + for (const code of codes) { + expect(codesWithoutCallback).not.toContain(code); } } ); - it('should work if no callback passed', async () => { - const excludedCallbacks = ['getSources', 'getPolicies', 'getColumnsFor']; - for (const testCase of fixtures.testCases.filter((t) => - t.error.some((message) => - excludeErrorsByContent(excludedCallbacks).every((regexp) => regexp?.test(message)) - ) - )) { - const { errors } = await validateQuery(testCase.query, {}); - // Verify no callback-dependent errors are present - const errorCodes = errors.map((e) => e.code); - expect( - errorCodes.every( - (code) => - code !== 'unknownIndex' && - code !== 'unknownDataSource' && - code !== 'unknownColumn' && - code !== 'wrongArgumentType' && - code !== 'unsupportedFieldType' && - code !== 'unknownPolicy' + it('suppresses all callback-dependent errors when no callback is passed', async () => { + const allCodes = callbackScenarios.flatMap(({ codes }) => codes); + const reportedCodes = ( + await Promise.all( + callbackScenarios.flatMap(({ queries }) => + queries.map((query) => collectErrorCodes(query, {})) ) - ).toBe(true); + ) + ).flat(); + for (const code of allCodes) { + expect(reportedCodes).not.toContain(code); } }); }); diff --git a/src/platform/packages/shared/kbn-esql-language/tsconfig.json b/src/platform/packages/shared/kbn-esql-language/tsconfig.json index 8b47c399cf30a..f239b93c202fd 100644 --- a/src/platform/packages/shared/kbn-esql-language/tsconfig.json +++ b/src/platform/packages/shared/kbn-esql-language/tsconfig.json @@ -2,24 +2,18 @@ "extends": "@kbn/tsconfig-base/tsconfig.json", "compilerOptions": { "outDir": "target/types", - "types": [ - "jest", - "node" - ] + "types": ["jest", "node"] }, - "include": [ - "src/**/*", - "**/*.ts", - ], + "include": ["src/**/*", "**/*.ts", "jest.integration.config.js"], "kbn_references": [ "@kbn/i18n", "@kbn/esql-types", "@kbn/core-pricing-common", "@kbn/licensing-types", "@kbn/field-types", + "@kbn/test-es-server", + "@kbn/tooling-log", "@kbn/esql-scripts", ], - "exclude": [ - "target/**/*", - ] + "exclude": ["target/**/*"] } diff --git a/src/platform/test/api_integration/apis/esql/errors.ts b/src/platform/test/api_integration/apis/esql/errors.ts deleted file mode 100644 index fabf89935f13b..0000000000000 --- a/src/platform/test/api_integration/apis/esql/errors.ts +++ /dev/null @@ -1,288 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import Fs from 'fs'; -import Path from 'path'; -import expect from '@kbn/expect'; -import type { MappingProperty } from '@elastic/elasticsearch/lib/api/types'; -import { hasStartEndParams } from '@kbn/esql-utils'; -import { REPO_ROOT } from '@kbn/repo-info'; -import { groupBy, mapValues, uniqBy } from 'lodash'; -import type { FtrProviderContext } from '../../ftr_provider_context'; - -function getConfigPath() { - return Path.resolve( - REPO_ROOT, - 'src/platform/packages/shared/kbn-esql-language/src/language/validation' - ); -} - -function getSetupPath() { - return Path.resolve(getConfigPath(), 'esql_validation_meta_tests.json'); -} - -function getMissmatchedPath() { - return Path.resolve(getConfigPath(), 'esql_validation_missmatches.json'); -} - -function readSetupFromESQLPackage() { - const esqlPackagePath = getSetupPath(); - const json = Fs.readFileSync(esqlPackagePath, 'utf8'); - const esqlPackage = JSON.parse(json); - return esqlPackage; -} - -function createIndexRequest( - index: string, - fields: Array<{ name: string; type: string }>, - stringType: 'text' | 'keyword', - numberType: 'integer' | 'double' | 'long' | 'unsigned_long' -) { - return { - index, - mappings: { - properties: fields.reduce( - (memo: Record, { name, type }: { name: string; type: string }) => { - let esType = type; - if (type === 'string') { - esType = stringType; - } - if (type === 'number') { - esType = numberType; - } - if (type === 'cartesian_point') { - esType = 'point'; - } - if (type === 'cartesian_shape') { - esType = 'shape'; - } - if (type === 'aggregate_metric_double') { - esType = 'double'; - } - if (type === 'unsupported' || type === 'function_named_parameters') { - esType = 'integer_range'; - } - memo[name] = { type: esType } as MappingProperty; - return memo; - }, - {} - ), - }, - }; -} - -interface JSONConfig { - testCases: Array<{ query: string; error: string[] }>; - indexes: string[]; - policies: Array<{ - name: string; - sourceIndices: string[]; - matchField: string; - enrichFields: string[]; - }>; - unsupported_field: Array<{ name: string; type: string }>; - fields: Array<{ name: string; type: string }>; - enrichFields: Array<{ name: string; type: string }>; -} - -export interface EsqlResultColumn { - name: string; - type: string; -} - -export type EsqlResultRow = Array; - -export interface EsqlTable { - columns: EsqlResultColumn[]; - values: EsqlResultRow[]; -} - -function parseConfig(config: JSONConfig) { - return { - queryToErrors: config.testCases, - indexes: config.indexes, - policies: config.policies.map(({ name }: { name: string }) => name), - }; -} - -export default function ({ getService }: FtrProviderContext) { - const es = getService('es'); - const log = getService('log'); - - // Send raw ES|QL query directly to ES endpoint bypassing Kibana - // as we do not need more overhead here - async function sendESQLQuery(query: string): Promise<{ - resp: EsqlTable | undefined; - error: { message: string } | undefined; - }> { - try { - const params = hasStartEndParams(query) - ? [ - { - _tstart: '2025-02-23T23:00:00.000Z', - }, - { - _tend: '2025-03-26T09:09:08.139Z', - }, - ] - : []; - const resp = await es.transport.request({ - method: 'POST', - path: '/_query', - body: { - query, - // testing the kibana time variables in case they are used in the query - params, - }, - }); - return { resp, error: undefined }; - } catch (e) { - return { resp: undefined, error: { message: e.meta.body.error.root_cause[0].reason } }; - } - } - - describe('error messages', () => { - const config = readSetupFromESQLPackage(); - const { queryToErrors, indexes, policies } = parseConfig(config); - - const missmatches: Array<{ query: string; error: string }> = []; - // Swap these for DEBUG/further investigation on ES bugs - const stringVariants = ['text', 'keyword'] as const; - const numberVariants = ['integer', 'long', 'double', 'long'] as const; - - async function cleanup() { - // clean it up all indexes and policies - log.info(`cleaning up all indexes: ${indexes.join(', ')}`); - await es.indices.delete({ index: indexes, ignore_unavailable: true }, { ignore: [404] }); - await es.indices.delete( - { index: config.policies[0].sourceIndices[0], ignore_unavailable: true }, - { ignore: [404] } - ); - for (const policy of policies) { - log.info(`deleting policy "${policy}"...`); - // TODO: Maybe `policy` -> `policy.name`? - await es.enrich.deletePolicy({ name: policy }, { ignore: [404] }); - } - } - - after(async () => { - if (missmatches.length) { - const distinctMissmatches = uniqBy( - missmatches, - (missmatch) => missmatch.query + missmatch.error - ); - const missmatchesGrouped = mapValues( - groupBy(distinctMissmatches, (missmatch) => missmatch.error), - (list) => list.map(({ query }) => query) - ); - log.info(`writing ${Object.keys(missmatchesGrouped).length} missmatches to file...`); - Fs.writeFileSync(getMissmatchedPath(), JSON.stringify(missmatchesGrouped, null, 2)); - } - }); - - for (const stringFieldType of stringVariants) { - for (const numberFieldType of numberVariants) { - describe(`Using string field type: ${stringFieldType} and number field type: ${numberFieldType}`, () => { - before(async () => { - await cleanup(); - - log.info(`creating ${indexes.length} indexes...`); - - for (const index of indexes) { - // setup all indexes, mappings and policies here - log.info( - `creating a index "${index}" with mapping...\n${JSON.stringify(config.fields)}` - ); - const fieldsExcludingCounterType = config.fields.filter( - // ES|QL supports counter_integer, counter_long, counter_double, date_period, etc. - // but they are not types suitable for Elasticsearch indices - (c: { type: string }) => - !c.type.startsWith('counter_') && - c.type !== 'date_period' && - c.type !== 'time_duration' && - c.type !== 'null' && - c.type !== 'time_literal' - ); - await es.indices.create( - createIndexRequest( - index, - /unsupported/.test(index) ? config.unsupported_field : fieldsExcludingCounterType, - stringFieldType, - numberFieldType - ), - { ignore: [409] } - ); - } - - for (const { sourceIndices, matchField } of config.policies.slice(0, 1)) { - const enrichFields = [{ name: matchField, type: 'string' }].concat( - config.enrichFields - ); - log.info(`creating a index "${sourceIndices[0]}" for policy with mapping...`); - await es.indices.create( - createIndexRequest( - sourceIndices[0], - enrichFields, - stringFieldType, - numberFieldType - ), - { - ignore: [409], - } - ); - } - - log.info(`creating ${policies.length} policies...`); - for (const { name, sourceIndices, matchField, enrichFields } of config.policies) { - log.info(`creating a policy "${name}"...`); - await es.enrich.putPolicy( - { - name, - match: { - indices: sourceIndices, - match_field: matchField, - enrich_fields: enrichFields, - }, - }, - { ignore: [409] } - ); - log.info(`executing policy "${name}"...`); - await es.enrich.executePolicy({ name }); - } - }); - - after(async () => { - await cleanup(); - }); - - it(`Checking error messages`, async () => { - for (const { query, error } of queryToErrors) { - const jsonBody = await sendESQLQuery(query); - - const clientSideHasError = Boolean(error.length); - const serverSideHasError = Boolean(jsonBody.error); - - if (clientSideHasError !== serverSideHasError) { - if (clientSideHasError) { - // in this case it's a problem, so fail the test - expect().fail(`Client side errored but ES server did not: ${query}`); - } - if (serverSideHasError) { - // in this case client side validator can improve, but it's not hard failure - // rather log it as it can be a useful to investigate a bug on the ES implementation side for some type combination - missmatches.push({ query, error: jsonBody.error!.message }); - } - } - } - }); - }); - } - } - }); -} diff --git a/src/platform/test/api_integration/apis/index.ts b/src/platform/test/api_integration/apis/index.ts index cdbd49c27e58b..8cc925db72785 100644 --- a/src/platform/test/api_integration/apis/index.ts +++ b/src/platform/test/api_integration/apis/index.ts @@ -29,6 +29,5 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./ui_metric')); loadTestFile(require.resolve('./ui_counters')); loadTestFile(require.resolve('./telemetry')); - loadTestFile(require.resolve('./esql')); }); } diff --git a/src/platform/test/moon.yml b/src/platform/test/moon.yml index 4194458b94ed0..34de5e60170b7 100644 --- a/src/platform/test/moon.yml +++ b/src/platform/test/moon.yml @@ -61,7 +61,6 @@ dependsOn: - '@kbn/core-deprecations-common' - '@kbn/data-grid-in-table-search' - '@kbn/scout-info' - - '@kbn/esql-utils' - '@kbn/synthtrace-client' - '@kbn/synthtrace' - '@kbn/controls-constants' diff --git a/src/platform/test/tsconfig.json b/src/platform/test/tsconfig.json index 5b3bbc2e48312..1458f1677799a 100644 --- a/src/platform/test/tsconfig.json +++ b/src/platform/test/tsconfig.json @@ -64,7 +64,6 @@ "@kbn/core-deprecations-common", "@kbn/data-grid-in-table-search", "@kbn/scout-info", - "@kbn/esql-utils", "@kbn/synthtrace-client", "@kbn/synthtrace", "@kbn/controls-constants",