diff --git a/src/platform/packages/shared/kbn-esql-ast/src/ast/helpers.ts b/src/platform/packages/shared/kbn-esql-ast/src/ast/helpers.ts index 3857770edae78..772dafe734966 100644 --- a/src/platform/packages/shared/kbn-esql-ast/src/ast/helpers.ts +++ b/src/platform/packages/shared/kbn-esql-ast/src/ast/helpers.ts @@ -17,6 +17,7 @@ import type { ESQLIdentifier, ESQLIntegerLiteral, ESQLLiteral, + ESQLLocation, ESQLParamLiteral, ESQLProperNode, ESQLSource, @@ -45,6 +46,10 @@ export const isFunctionExpression = (node: unknown): node is ESQLFunction => export const isBinaryExpression = (node: unknown): node is ESQLBinaryExpression => isFunctionExpression(node) && node.subtype === 'binary-expression'; +export const isAssignment = (node: unknown): node is ESQLBinaryExpression<'='> => { + return isBinaryExpression(node) && node.name === '='; +}; + export const isWhereExpression = ( node: unknown ): node is ESQLBinaryExpression => @@ -84,6 +89,21 @@ export const isSource = (node: unknown): node is ESQLSource => export const isIdentifier = (node: unknown): node is ESQLIdentifier => isProperNode(node) && node.type === 'identifier'; +export const isContainedLocation = (container: ESQLLocation, contained: ESQLLocation): boolean => { + return container.min <= contained.min && container.max >= contained.max; +}; + +export const isContained = ( + container: { location?: ESQLLocation }, + contained: { location?: ESQLLocation } +): boolean => { + if (!container.location || !contained.location) { + return false; + } + + return isContainedLocation(container.location, contained.location); +}; + /** * Returns the group of a binary expression: * diff --git a/src/platform/packages/shared/kbn-esql-ast/src/parser/esql_error_listener.ts b/src/platform/packages/shared/kbn-esql-ast/src/parser/esql_error_listener.ts index 7df4ec9e2e83a..ba68b1eacfba4 100644 --- a/src/platform/packages/shared/kbn-esql-ast/src/parser/esql_error_listener.ts +++ b/src/platform/packages/shared/kbn-esql-ast/src/parser/esql_error_listener.ts @@ -43,7 +43,6 @@ export class ESQLErrorListener extends ErrorListener { } const textMessage = `SyntaxError: ${message}`; - const tokenPosition = getPosition(offendingSymbol); const startColumn = offendingSymbol && tokenPosition ? tokenPosition.min + 1 : column + 1; const endColumn = offendingSymbol && tokenPosition ? tokenPosition.max + 1 : column + 2; @@ -54,6 +53,10 @@ export class ESQLErrorListener extends ErrorListener { startColumn, endColumn, message: textMessage, + location: { + min: tokenPosition.min, + max: tokenPosition.max - 1, + }, severity: 'error', }); } diff --git a/src/platform/packages/shared/kbn-esql-ast/src/parser/factories/rerank.ts b/src/platform/packages/shared/kbn-esql-ast/src/parser/factories/rerank.ts index cd1d472f9df74..dac8893fab22c 100644 --- a/src/platform/packages/shared/kbn-esql-ast/src/parser/factories/rerank.ts +++ b/src/platform/packages/shared/kbn-esql-ast/src/parser/factories/rerank.ts @@ -73,7 +73,12 @@ export const createRerankCommand = (ctx: RerankCommandContext): ESQLAstRerankCom const fields = visitRerankFields(fieldsCtx); const inferenceIdCtx = ctx._inferenceId; const maybeInferenceId = inferenceIdCtx ? createIdentifierOrParam(inferenceIdCtx) : undefined; - const inferenceId = maybeInferenceId ?? Builder.identifier('', { incomplete: true }); + const inferenceId = + maybeInferenceId ?? + Builder.identifier('', { + incomplete: true, + location: inferenceIdCtx ? getPosition(inferenceIdCtx.start, inferenceIdCtx.stop) : undefined, + }); const command = createCommand<'rerank', ESQLAstRerankCommand>('rerank', ctx, { query, fields, diff --git a/src/platform/packages/shared/kbn-esql-ast/src/parser/parser.ts b/src/platform/packages/shared/kbn-esql-ast/src/parser/parser.ts index bcab210f3905b..5dc22587ff879 100644 --- a/src/platform/packages/shared/kbn-esql-ast/src/parser/parser.ts +++ b/src/platform/packages/shared/kbn-esql-ast/src/parser/parser.ts @@ -166,6 +166,10 @@ export const parse = (text: string | undefined, options: ParseOptions = {}): Par endColumn: 0, message: `Invalid query [${text}]`, severity: 'error', + location: { + min: 0, + max: 0, + }, }, ], tokens: [], diff --git a/src/platform/packages/shared/kbn-esql-ast/src/pretty_print/basic_pretty_printer.ts b/src/platform/packages/shared/kbn-esql-ast/src/pretty_print/basic_pretty_printer.ts index 47e3b4f33be25..f69547ab51cb6 100644 --- a/src/platform/packages/shared/kbn-esql-ast/src/pretty_print/basic_pretty_printer.ts +++ b/src/platform/packages/shared/kbn-esql-ast/src/pretty_print/basic_pretty_printer.ts @@ -367,7 +367,12 @@ export class BasicPrettyPrinter { args += (args ? ', ' : '') + arg; } - const formatted = `${operator}(${args})`; + const isParentShowCommand = + !args.length && + (ctx.parent?.node as any)?.type === 'command' && + (ctx.parent?.node as any)?.name === 'show'; + + const formatted = isParentShowCommand ? operator : `${operator}(${args})`; return this.decorateWithComments(ctx.node, formatted); } diff --git a/src/platform/packages/shared/kbn-esql-ast/src/types.ts b/src/platform/packages/shared/kbn-esql-ast/src/types.ts index bc8ecedf30be3..86465ea44e974 100644 --- a/src/platform/packages/shared/kbn-esql-ast/src/types.ts +++ b/src/platform/packages/shared/kbn-esql-ast/src/types.ts @@ -508,6 +508,7 @@ export interface EditorError { startColumn: number; endColumn: number; message: string; + location: ESQLLocation; code?: string; severity: 'error' | 'warning' | number; } diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/commands.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/commands.ts index caf3834806e5f..20a437b4d6dd8 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/commands.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/commands.ts @@ -86,6 +86,8 @@ import { METADATA_FIELDS } from '../shared/constants'; import { getMessageFromId } from '../validation/errors'; import { isNumericType } from '../shared/esql_types'; +import { definition as rerankDefinition } from './commands/rerank'; + const statsValidator = (command: ESQLCommand) => { const messages: ESQLMessage[] = []; const commandName = command.name.toUpperCase(); @@ -706,4 +708,5 @@ export const commandDefinitions: Array> = [ fieldsSuggestionsAfter: fieldsSuggestionsAfterFork, }, + rerankDefinition, ]; diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/commands/rerank.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/commands/rerank.ts new file mode 100644 index 0000000000000..cca42bf11b0a1 --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/commands/rerank.ts @@ -0,0 +1,133 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ESQLAstExpression, ESQLAstRerankCommand, EditorError } from '@kbn/esql-ast/src/types'; +import { + isAssignment, + isBinaryExpression, + isColumn, + isContained, + isStringLiteral, +} from '@kbn/esql-ast/src/ast/helpers'; +import { Walker, type ESQLCommand, type ESQLMessage } from '@kbn/esql-ast'; +import { isParam } from '../../shared/helpers'; +import { errors } from '../../validation/errors'; +import type { CommandDefinition } from '../types'; +import { validateColumnForCommand } from '../../validation/validation'; +import { ReferenceMaps } from '../../validation/types'; + +const parsingErrorsToMessages = (parsingErrors: EditorError[], cmd: ESQLCommand): ESQLMessage[] => { + const command = cmd as ESQLAstRerankCommand; + const messages: ESQLMessage[] = []; + + const { inferenceId } = command; + const inferenceIdParsingError = parsingErrors.some((error) => isContained(inferenceId, error)); + + // Check if there is a problem with parsing inference ID. + if (inferenceIdParsingError) { + const error = errors.rerankInferenceIdMustBeIdentifier(inferenceId); + + messages.push(error); + } + + return messages; +}; + +/** + * Returns tru if a field is *named*. Named field is one where a column is + * used directly, e.g. `field.name`, or where a new column is defined using + * an assignment, e.g. `field.name = AVG(1, 2, 4)`. + */ +const isNamedField = (field: ESQLAstExpression) => { + if (isColumn(field)) { + return true; + } + + if (isBinaryExpression(field)) { + if (field.name !== '=') { + return false; + } + + const left = field.args[0]; + + return isColumn(left); + } + + return false; +}; + +const validate = (cmd: ESQLCommand, references: ReferenceMaps) => { + const command = cmd as ESQLAstRerankCommand; + const messages: ESQLMessage[] = []; + + if (command.args.length < 3) { + messages.push({ + location: command.location, + text: i18n.translate('kbn-esql-validation-autocomplete.esql.validation.forkTooFewArguments', { + defaultMessage: '[RERANK] Command is not complete.', + }), + type: 'error', + code: 'rerankTooFewArguments', + }); + } + + const { query, fields } = command; + + // Check that is a string literal or a parameter + if (!isStringLiteral(query) && !isParam(query)) { + const error = errors.rerankQueryMustBeString(query); + + messages.push(error); + } + + const fieldLength = fields.length; + + for (let i = 0; i < fieldLength; i++) { + const field = fields[i]; + + // Check that are either columns or new column definitions + if (!isNamedField(field)) { + const error = errors.rerankFieldMustBeNamed(field); + + messages.push(error); + } + + // Check if all (deeply nested) columns exist. + const columnExpressionToCheck = isAssignment(field) ? field.args[1] : field; + + Walker.walk(columnExpressionToCheck, { + visitColumn: (node) => { + const fieldErrors = validateColumnForCommand(node, 'rerank', references); + + if (fieldErrors.length > 0) { + messages.push(...fieldErrors); + } + }, + }); + } + + return messages; +}; + +export const definition = { + hidden: true, + name: 'rerank', + preview: true, + description: i18n.translate('kbn-esql-validation-autocomplete.esql.definitions.rerankDoc', { + defaultMessage: 'Reorder results using a semantic reranker.', + }), + declaration: 'RERANK ON [, [, ...]] WITH ', + examples: [], + suggest: () => { + throw new Error('Not implemented'); + }, + parsingErrorsToMessages, + validate, + // TODO: implement `.fieldsSuggestionsAfter()` +} satisfies CommandDefinition<'rerank'>; diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/types.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/types.ts index 5b11b3ce4ae0c..f4357d3c515e5 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/types.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/types.ts @@ -13,6 +13,7 @@ import type { ESQLMessage, ESQLSource, ESQLAstCommand, + EditorError, } from '@kbn/esql-ast'; import { ESQLControlVariable } from '@kbn/esql-types'; import { GetColumnsByTypeFn, SuggestionRawDefinition } from '../autocomplete/types'; @@ -414,6 +415,25 @@ export interface CommandDefinition { */ hidden?: boolean; + /** + * Return nicely formatted human-readable out of parser errors. This callback + * lets commands construct their own error messages out of parser errors, + * as parser errors have the following drawbacks: + * + * 1. Not human-readable, even hard to read for developers. + * 2. Not translated to other languages, e.g. Chinese. + * 3. Depend on ANTLR grammar, which is not stable and may change in the future. + * + * @param parsingErrors List of parsing errors returned by the ANTLR parser + * for this command. + * @returns Human-readable, translatable messages for the user. + */ + parsingErrorsToMessages?: ( + parsingErrors: EditorError[], + command: ESQLCommand, + references: ReferenceMaps + ) => ESQLMessage[]; + /** * This method is run when the command is being validated, but it does not * prevent the default behavior. If you need a full override, we are currently diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/helpers.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/helpers.ts index 933833e961623..0ca52bddd1ec6 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/helpers.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/helpers.ts @@ -217,7 +217,9 @@ function buildCommandLookup(): Map> { export function getCommandDefinition( name: CommandName ): CommandDefinition { - return buildCommandLookup().get(name.toLowerCase()) as unknown as CommandDefinition; + const map = buildCommandLookup(); + + return map.get(name.toLowerCase()) as unknown as CommandDefinition; } export function getAllCommands() { diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/__tests__/helpers.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/__tests__/helpers.ts index 23f95ff3325cd..f0a87cb188ef8 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/__tests__/helpers.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/__tests__/helpers.ts @@ -11,7 +11,7 @@ import { EditorError, ESQLMessage } from '@kbn/esql-ast'; import { ESQLCallbacks } from '../../shared/types'; import { getCallbackMocks } from '../../__tests__/helpers'; import { ValidationOptions } from '../types'; -import { validateQuery } from '../validation'; +import * as validation from '../validation'; /** Validation test API factory, can be called at the start of each unit test. */ export type Setup = typeof setup; @@ -29,7 +29,7 @@ export const setup = async () => { opts: ValidationOptions = {}, cb: ESQLCallbacks = callbacks ) => { - return await validateQuery(query, opts, cb); + return await validation.validateQuery(query, opts, cb); }; const assertErrors = (errors: unknown[], expectedErrors: string[], query?: string) => { @@ -66,7 +66,7 @@ export const setup = async () => { opts: ValidationOptions = {}, cb: ESQLCallbacks = callbacks ) => { - const { errors, warnings } = await validateQuery(query, opts, cb); + const { errors, warnings } = await validation.validateQuery(query, opts, cb); assertErrors(errors, expectedErrors, query); if (expectedWarnings) { assertErrors(warnings, expectedWarnings, query); diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/__tests__/test_suites/validation.command.rerank.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/__tests__/test_suites/validation.command.rerank.ts new file mode 100644 index 0000000000000..748730066307a --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/__tests__/test_suites/validation.command.rerank.ts @@ -0,0 +1,173 @@ +/* + * 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 * as helpers from '../helpers'; + +export const validationRerankCommandTestSuite = (setup: helpers.Setup) => { + describe('validation', () => { + describe('command', () => { + describe('RERANK ON [, [, ...]] WITH ', () => { + test('validates the most basic query', async () => { + const { expectErrors } = await setup(); + + await expectErrors('FROM index | RERANK "query" ON a = doubleField WITH id', []); + }); + + describe('RERANK ...', () => { + test('errors is query is not a string', async () => { + const { expectErrors } = await setup(); + + await expectErrors('FROM index | RERANK ["query"] ON a = doubleField WITH id', [ + '[RERANK] a query must be a string, found [list]', + ]); + await expectErrors('FROM index | RERANK TRUE ON a = doubleField WITH id', [ + '[RERANK] a query must be a string, found [boolean]', + ]); + await expectErrors('FROM index | RERANK 123 ON a = doubleField WITH id', [ + '[RERANK] a query must be a string, found [integer]', + ]); + await expectErrors('FROM index | RERANK field.name ON a = doubleField WITH id', [ + expect.any(String), + ]); + }); + + test('allows query to be a param', async () => { + const { expectErrors } = await setup(); + + await expectErrors('FROM index | RERANK ?named_param ON a = doubleField WITH id', []); + await expectErrors('FROM index | RERANK ?123 ON a = doubleField WITH id', []); + await expectErrors('FROM index | RERANK ? ON a = doubleField WITH id', []); + }); + }); + + describe('... ON [, [, ...]] ...', () => { + test('can create new field aliases', async () => { + const { expectErrors } = await setup(); + + await expectErrors( + 'FROM index | RERANK "query" ON a = doubleField, b = doubleField WITH id', + [] + ); + }); + + test('can create new fields using an expression', async () => { + const { expectErrors } = await setup(); + + await expectErrors( + 'FROM index | RERANK "query" ON a = AVG(doubleField), b = AVG(doubleField) WITH id', + [] + ); + }); + + test('can use a field name directly', async () => { + const { expectErrors } = await setup(); + + await expectErrors('FROM index | RERANK "query" ON doubleField WITH id', []); + await expectErrors('FROM index | RERANK "query" ON `doubleField` WITH id', []); + await expectErrors( + 'FROM index | RERANK "query" ON doubleField, doubleField WITH id', + [] + ); + }); + + test('param can be used as field', async () => { + const { expectErrors } = await setup(); + + await expectErrors('FROM index | RERANK "query" ON ? WITH id', []); + await expectErrors('FROM index | RERANK "query" ON ?, ? WITH id', []); + await expectErrors('FROM index | RERANK "query" ON ?? WITH id', []); + await expectErrors('FROM index | RERANK "query" ON ??, ?? WITH id', []); + await expectErrors('FROM index | RERANK "query" ON ?named WITH id', []); + await expectErrors('FROM index | RERANK "query" ON ?named, ?123 WITH id', []); + await expectErrors('FROM index | RERANK "query" ON ?0, ?1 WITH id', []); + }); + + test('errors when function expression used as field', async () => { + const { expectErrors } = await setup(); + + await expectErrors('FROM index | RERANK "query" ON AVG(stringField) WITH id', [ + expect.any(String), + ]); + }); + + test('errors on unknown field', async () => { + const { expectErrors, validate } = await setup(); + + await expectErrors('FROM index | RERANK "query" ON this_is_unknown_field WITH id', [ + 'Unknown column [this_is_unknown_field]', + ]); + + { + const { errors } = await validate( + 'FROM index | RERANK "query" ON this.is.unknown.field WITH id' + ); + expect(errors.filter((e) => e.code === 'unknownColumn')).toMatchObject([ + { text: 'Unknown column [this.is.unknown.field]' }, + ]); + } + + { + const { errors } = await validate( + 'FROM index | RERANK "query" ON this . is . unknown . field WITH id' + ); + expect(errors.filter((e) => e.code === 'unknownColumn')).toMatchObject([ + { text: 'Unknown column [this.is.unknown.field]' }, + ]); + } + + { + const { errors } = await validate( + 'FROM index | RERANK "query" ON this /* comment */ . /* another comment */ is . unknown . field WITH id' + ); + expect(errors.filter((e) => e.code === 'unknownColumn')).toMatchObject([ + { text: 'Unknown column [this.is.unknown.field]' }, + ]); + } + }); + }); + + describe('... WITH ', () => { + test('inference ID can be identifier', async () => { + const { expectErrors } = await setup(); + + await expectErrors('FROM index | RERANK "query" ON doubleField WITH a', []); + await expectErrors('FROM index | RERANK "query" ON doubleField WITH id', []); + await expectErrors('FROM index | RERANK "query" ON doubleField WITH abc123', []); + }); + + test('inference ID can be param or double param', async () => { + const { expectErrors } = await setup(); + + await expectErrors('FROM index | RERANK "query" ON doubleField WITH ?', []); + await expectErrors('FROM index | RERANK "query" ON doubleField WITH ??', []); + + await expectErrors('FROM index | RERANK "query" ON doubleField WITH ?param', []); + await expectErrors('FROM index | RERANK "query" ON doubleField WITH ??param', []); + + await expectErrors('FROM index | RERANK "query" ON doubleField WITH ?123', []); + await expectErrors('FROM index | RERANK "query" ON doubleField WITH ??123', []); + }); + + test('errors when ID is a number', async () => { + const { validate } = await setup(); + + const { errors } = await validate( + 'FROM index | RERANK "query" ON doubleField WITH 123' + ); + const filtered = errors.filter((e) => e.code === 'rerankInferenceIdMustBeIdentifier'); + + expect(filtered).toMatchObject([ + { text: '[RERANK] inference ID must be an identifier or a parameter.' }, + ]); + }); + }); + }); + }); + }); +}; diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/types.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/__tests__/validation.command.rerank.test.ts similarity index 69% rename from src/platform/packages/shared/kbn-esql-validation-autocomplete/src/types.ts rename to src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/__tests__/validation.command.rerank.test.ts index dca24f5f25979..30f50d5361c43 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/types.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/__tests__/validation.command.rerank.test.ts @@ -7,12 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export interface EditorError { - startLineNumber: number; - endLineNumber: number; - startColumn: number; - endColumn: number; - message: string; - code?: string; - severity: 'error' | 'warning' | number; -} +import * as helpers from './helpers'; +import { validationRerankCommandTestSuite } from './test_suites/validation.command.rerank'; + +validationRerankCommandTestSuite(helpers.setup); diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/errors.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/errors.ts index 20b027482cde2..5ba324feb45b8 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/errors.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/errors.ts @@ -8,15 +8,16 @@ */ import { i18n } from '@kbn/i18n'; -import type { - ESQLColumn, - ESQLCommand, - ESQLFunction, - ESQLLocation, - ESQLMessage, - ESQLSource, +import { + isLiteral, + type ESQLColumn, + type ESQLCommand, + type ESQLFunction, + type ESQLLocation, + type ESQLMessage, + type ESQLSource, } from '@kbn/esql-ast'; -import { ESQLIdentifier } from '@kbn/esql-ast/src/types'; +import { ESQLAstExpression, ESQLIdentifier } from '@kbn/esql-ast/src/types'; import type { ErrorTypes, ErrorValues } from './types'; function getMessageAndTypeFromId({ @@ -449,6 +450,35 @@ function getMessageAndTypeFromId({ defaultMessage: '[FORK] a query cannot have more than one FORK command.', }), }; + case 'rerankQueryMustBeString': + return { + message: i18n.translate( + 'kbn-esql-validation-autocomplete.esql.validation.rerankQueryType', + { + defaultMessage: '[RERANK] a query must be a string, found [{foundType}]', + values: { foundType: out.foundType }, + } + ), + }; + case 'rerankFieldMustBeNamed': + return { + message: i18n.translate( + 'kbn-esql-validation-autocomplete.esql.validation.rerankFieldMustBeNamed', + { + defaultMessage: + '[RERANK] a field must be named. Assign a column name to the field, e.g. "field_name = MY_FIELD"', + } + ), + }; + case 'rerankInferenceIdMustBeIdentifier': + return { + message: i18n.translate( + 'kbn-esql-validation-autocomplete.esql.validation.rerankInferenceIdMustBeIdentifier', + { + defaultMessage: '[RERANK] inference ID must be an identifier or a parameter.', + } + ), + }; } return { message: '' }; } @@ -547,6 +577,17 @@ export const errors = { errors.byId('invalidJoinIndex', identifier.location, { identifier: identifier.name, }), + + rerankQueryMustBeString: (node: ESQLAstExpression): ESQLMessage => + errors.byId('rerankQueryMustBeString', node.location, { + foundType: isLiteral(node) ? node.literalType : node.type, + }), + + rerankFieldMustBeNamed: (node: ESQLAstExpression): ESQLMessage => + errors.byId('rerankFieldMustBeNamed', node.location, {}), + + rerankInferenceIdMustBeIdentifier: (node: ESQLAstExpression): ESQLMessage => + errors.byId('rerankInferenceIdMustBeIdentifier', node.location, {}), }; export function getUnknownTypeLabel() { diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/helpers.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/helpers.ts index 41f8a6ebe9727..cf8c9c3e57f49 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/helpers.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/helpers.ts @@ -17,13 +17,13 @@ import type { ESQLSingleAstItem, ESQLSource, } from '@kbn/esql-ast'; -import { mutate, synth } from '@kbn/esql-ast'; +import { BasicPrettyPrinter, mutate, synth } from '@kbn/esql-ast'; import { FunctionDefinition } from '../definitions/types'; import { getAllArrayTypes, getAllArrayValues } from '../shared/helpers'; import { getMessageFromId } from './errors'; import type { ESQLPolicy, ReferenceMaps } from './types'; -export function buildQueryForFieldsFromSource(queryString: string, ast: ESQLAst) { +export function buildQueryForFieldsFromSource(ast: ESQLAst) { const firstCommand = ast[0]; if (!firstCommand) return ''; @@ -55,7 +55,7 @@ export function buildQueryForFieldsFromSource(queryString: string, ast: ESQLAst) } if (sources.length === 0) { - return queryString.substring(0, firstCommand.location.max + 1); + return BasicPrettyPrinter.command(firstCommand); } const from = diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/resources.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/resources.ts index 7418eee39f800..f32b8b49048a8 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/resources.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/resources.ts @@ -23,7 +23,6 @@ import { import type { ESQLFieldWithMetadata, ESQLPolicy } from './types'; export async function retrieveFields( - queryString: string, commands: ESQLCommand[], callbacks?: ESQLCallbacks ): Promise> { @@ -44,7 +43,7 @@ export async function retrieveFields( if (commands[0].name === 'row') { return new Map(); } - const customQuery = buildQueryForFieldsFromSource(queryString, commands); + const customQuery = buildQueryForFieldsFromSource(commands); return await getFieldsByTypeHelper(customQuery, callbacks).getFieldsMap(); } diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/types.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/types.ts index 3a12bb6edca2c..ac24bab4c046c 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/types.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/types.ts @@ -7,10 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { ESQLMessage, ESQLLocation } from '@kbn/esql-ast'; +import type { ESQLMessage, ESQLLocation, EditorError } from '@kbn/esql-ast'; import type { IndexAutocompleteItem } from '@kbn/esql-types'; import { FieldType, SupportedDataType } from '../definitions/types'; -import type { EditorError } from '../types'; export interface ESQLUserDefinedColumn { name: string; @@ -211,6 +210,20 @@ export interface ValidationErrors { message: string; type: {}; }; + rerankQueryMustBeString: { + message: string; + type: { + foundType: string; + }; + }; + rerankFieldMustBeNamed: { + message: string; + type: {}; + }; + rerankInferenceIdMustBeIdentifier: { + message: string; + type: {}; + }; } export type ErrorTypes = keyof ValidationErrors; diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.test.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.test.ts index 67a267f1b1838..48240135c27eb 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.test.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.test.ts @@ -1586,7 +1586,7 @@ describe('validation logic', () => { expect(callbackMocks.getPolicies).not.toHaveBeenCalled(); expect(callbackMocks.getColumnsFor).toHaveBeenCalledTimes(1); expect(callbackMocks.getColumnsFor).toHaveBeenLastCalledWith({ - query: 'show info', + query: 'SHOW INFO', }); }); diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.ts index 2f267e8002126..72d5a87847133 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.ts @@ -14,11 +14,13 @@ import { ESQLCommandOption, ESQLMessage, ESQLSource, + ParseResult, isIdentifier, parse, walk, } from '@kbn/esql-ast'; -import type { ESQLAstJoinCommand, ESQLIdentifier } from '@kbn/esql-ast/src/types'; +import type { ESQLAstJoinCommand, ESQLIdentifier, EditorError } from '@kbn/esql-ast/src/types'; +import { isContained } from '@kbn/esql-ast/src/ast/helpers'; import { areFieldAndUserDefinedColumnTypesCompatible, getColumnExists, @@ -52,7 +54,6 @@ import type { ValidationOptions, ValidationResult, } from './types'; - import { validate as validateJoinCommand } from './commands/join'; /** @@ -67,13 +68,13 @@ export async function validateQuery( options: ValidationOptions = {}, callbacks?: ESQLCallbacks ): Promise { - const result = await validateAst(queryString, callbacks); + const result = await validateQueryString(queryString, callbacks); // early return if we do not want to ignore errors if (!options.ignoreOnMissingCallbacks) { return result; } const finalCallbacks = callbacks || {}; - const errorTypoesToIgnore = Object.entries(ignoreErrorsMap).reduce((acc, [key, errorCodes]) => { + const errorTypesToIgnore = Object.entries(ignoreErrorsMap).reduce((acc, [key, errorCodes]) => { if ( !(key in finalCallbacks) || (key in finalCallbacks && finalCallbacks[key as keyof ESQLCallbacks] == null) @@ -89,7 +90,7 @@ export async function validateQuery( if ('severity' in error) { return true; } - return !errorTypoesToIgnore[error.code as ErrorTypes]; + return !errorTypesToIgnore[error.code as ErrorTypes]; }) .map((error) => 'severity' in error @@ -119,42 +120,54 @@ export const ignoreErrorsMap: Record = { getTimeseriesIndices: [], }; -/** - * This function will perform an high level validation of the - * query AST. An initial syntax validation is already performed by the parser - * while here it can detect things like function names, types correctness and potential warnings - * @param ast A valid AST data structure - */ -async function validateAst( +const validateQueryString = async ( queryString: string, callbacks?: ESQLCallbacks -): Promise { - const messages: ESQLMessage[] = []; - +): Promise => { const parsingResult = parse(queryString); + const messages = await validateAst(parsingResult, queryString, callbacks); - const { ast } = parsingResult; + return { + errors: [...parsingResult.errors, ...messages.filter(({ type }) => type === 'error')], + warnings: messages.filter(({ type }) => type === 'warning'), + }; +}; + +const validateAst = async ( + parsingResult: ParseResult, + queryString: string, + callbacks?: ESQLCallbacks +): Promise => { + const messages: ESQLMessage[] = []; + + const { + root: { commands }, + } = parsingResult; const [sources, availableFields, availablePolicies, joinIndices] = await Promise.all([ // retrieve the list of available sources - retrieveSources(ast, callbacks), + retrieveSources(commands, callbacks), // retrieve available fields (if a source command has been defined) - retrieveFields(queryString, ast, callbacks), + retrieveFields(commands, callbacks), // retrieve available policies (if an enrich command has been defined) - retrievePolicies(ast, callbacks), + retrievePolicies(commands, callbacks), // retrieve indices for join command callbacks?.getJoinIndices?.(), ]); if (availablePolicies.size) { - const fieldsFromPoliciesMap = await retrievePoliciesFields(ast, availablePolicies, callbacks); + const fieldsFromPoliciesMap = await retrievePoliciesFields( + commands, + availablePolicies, + callbacks + ); fieldsFromPoliciesMap.forEach((value, key) => availableFields.set(key, value)); } - if (ast.some(({ name }) => ['grok', 'dissect'].includes(name))) { + if (commands.some(({ name }) => ['grok', 'dissect'].includes(name))) { const fieldsFromGrokOrDissect = await retrieveFieldsFromStringSources( queryString, - ast, + commands, callbacks ); fieldsFromGrokOrDissect.forEach((value, key) => { @@ -166,10 +179,10 @@ async function validateAst( }); } - const userDefinedColumns = collectUserDefinedColumns(ast, availableFields, queryString); + const userDefinedColumns = collectUserDefinedColumns(commands, availableFields, queryString); // notify if the user is rewriting a column as userDefinedColumn with another type messages.push(...validateFieldsShadowing(availableFields, userDefinedColumns)); - messages.push(...validateUnsupportedTypeFields(availableFields, ast)); + messages.push(...validateUnsupportedTypeFields(availableFields, commands)); const references: ReferenceMaps = { sources, @@ -180,7 +193,10 @@ async function validateAst( joinIndices: joinIndices?.indices || [], }; let seenFork = false; - for (const [index, command] of ast.entries()) { + + const length = commands.length; + for (let index = 0; index < length; index++) { + const command = commands[index]; if (command.name === 'fork') { if (seenFork) { messages.push(errors.tooManyForks(command)); @@ -188,44 +204,57 @@ async function validateAst( seenFork = true; } } - const commandMessages = validateCommand(command, references, ast, index); + const parsingErrors = parsingResult.errors.filter((error) => isContained(command, error)); + const commandMessages = validateCommand(command, references, commands, index, parsingErrors); messages.push(...commandMessages); } - return { - errors: [...parsingResult.errors, ...messages.filter(({ type }) => type === 'error')], - warnings: messages.filter(({ type }) => type === 'warning'), - }; -} + return messages; +}; function validateCommand( command: ESQLCommand, references: ReferenceMaps, ast: ESQLAst, - currentCommandIndex: number + currentCommandIndex: number, + parsingErrors: EditorError[] ): ESQLMessage[] { const messages: ESQLMessage[] = []; - if (command.incomplete) { + const definition = getCommandDefinition(command.name); + + if (!definition) { return messages; } - // do not check the command exists, the grammar is already picking that up - const commandDef = getCommandDefinition(command.name); - if (!commandDef) { + if (parsingErrors.length && definition.parsingErrorsToMessages) { + const messagesFromParsingErrors = definition.parsingErrorsToMessages( + parsingErrors, + command, + references + ); + + messages.push(...messagesFromParsingErrors); + } + + if (command.incomplete) { return messages; } - if (commandDef.validate) { - messages.push(...commandDef.validate(command, references)); + if (definition.validate) { + messages.push(...definition.validate(command, references)); } - switch (commandDef.name) { + switch (definition.name) { case 'join': { const join = command as ESQLAstJoinCommand; const joinCommandErrors = validateJoinCommand(join, references); messages.push(...joinCommandErrors); break; } + case 'rerank': { + // Do nothing, validation is implemented in the RERANK command definition. + break; + } case 'fork': { references.fields.set('_fork', { name: '_fork', @@ -236,7 +265,21 @@ function validateCommand( if (isSingleItem(arg) && arg.type === 'query') { // all the args should be commands arg.commands.forEach((subCommand) => { - messages.push(...validateCommand(subCommand, references, ast, currentCommandIndex)); + const subCommandParsingErrors = parsingErrors.filter( + (error) => + error.location.min >= subCommand.location.min && + error.location.max <= subCommand.location.max + ); + + messages.push( + ...validateCommand( + subCommand, + references, + ast, + currentCommandIndex, + subCommandParsingErrors + ) + ); }); } } diff --git a/x-pack/platform/plugins/shared/inference/server/tasks/nl_to_esql/validate_esql_query.ts b/x-pack/platform/plugins/shared/inference/server/tasks/nl_to_esql/validate_esql_query.ts index 57f5ec7360df5..fe55c96214622 100644 --- a/x-pack/platform/plugins/shared/inference/server/tasks/nl_to_esql/validate_esql_query.ts +++ b/x-pack/platform/plugins/shared/inference/server/tasks/nl_to_esql/validate_esql_query.ts @@ -32,14 +32,16 @@ export async function runAndValidateEsqlQuery({ const asCommands = splitIntoCommands(query); const errorMessages = errors?.map((error) => { + const message = 'text' in error ? error.text : error.message; + if ('location' in error) { const commandsUntilEndOfError = splitIntoCommands(query.substring(0, error.location.max)); const lastCompleteCommand = asCommands[commandsUntilEndOfError.length - 1]; if (lastCompleteCommand) { - return `Error in ${lastCompleteCommand.command}\n: ${error.text}`; + return `Error in ${lastCompleteCommand.command}\n: ${message}`; } } - return 'text' in error ? error.text : error.message; + return message; }); return client.transport diff --git a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/server/functions/query/get_errors_with_commands.ts b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/server/functions/query/get_errors_with_commands.ts index 636a37ba14fe2..4f567a0ed7d43 100644 --- a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/server/functions/query/get_errors_with_commands.ts +++ b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/server/functions/query/get_errors_with_commands.ts @@ -12,14 +12,16 @@ export function getErrorsWithCommands(query: string, errors: Array { + const message = 'text' in error ? error.text : error.message; + if ('location' in error) { const commandsUntilEndOfError = splitIntoCommands(query.substring(0, error.location.max)); const lastCompleteCommand = asCommands[commandsUntilEndOfError.length - 1]; if (lastCompleteCommand) { - return `Error in \`| ${lastCompleteCommand.command}\`:\n ${error.text}`; + return `Error in \`| ${lastCompleteCommand.command}\`:\n ${message}`; } } - return 'text' in error ? error.text : error.message; + return message; }); return errorMessages; diff --git a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/server/functions/query/validate_esql_query.ts b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/server/functions/query/validate_esql_query.ts index 45f4f962ab91e..9dc4f2f4b20cd 100644 --- a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/server/functions/query/validate_esql_query.ts +++ b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/server/functions/query/validate_esql_query.ts @@ -36,16 +36,18 @@ export async function runAndValidateEsqlQuery({ const asCommands = splitIntoCommands(queryWithoutLineBreaks); const errorMessages = errors?.map((error) => { + const message = 'text' in error ? error.text : error.message; + if ('location' in error) { const commandsUntilEndOfError = splitIntoCommands( queryWithoutLineBreaks.substring(0, error.location.max) ); const lastCompleteCommand = asCommands[commandsUntilEndOfError.length - 1]; if (lastCompleteCommand) { - return `Error in ${lastCompleteCommand.command}\n: ${error.text}`; + return `Error in ${lastCompleteCommand.command}\n: ${message}`; } } - return 'text' in error ? error.text : error.message; + return message; }); try {