Skip to content

Commit 0794030

Browse files
sddonneelasticmachinestratouladrewdaemon
authored
[ES|QL] Add support for RRF (#221349)
## Summary Part of #215092 ## Considerations ### `RRF` may change in the future @ioanatia has stated that the command may change in the future as it's not fully implemented yet. ### Licence check Will be addressed at #216791 https://github.com/user-attachments/assets/759470a2-4afa-4d20-adbf-cb3001895e95 ## New command support checklist ### AST support - [x] Make sure that the new command is in the local Kibana grammar definition. The ANTLR lexer and parser files are updated every Monday from the source definition of the language at Elasticsearch (via a manually merged, automatically generated [PR](#213006)). - [x] Create a factory for generating the new node. The new node should satisfy the `ESQLCommand<Name>` interface. If the syntax of your command cannot be decomposed only in parameters, you can hold extra information by extending the `ESQLCommand` interface. I.E., check the Rerank command. - [x] While ANTLR is parsing the text query, we create our own AST by using `onExit` listeners at `kbn-esql-ast/src/parser/esql_ast_builder_listener.ts`. Implement the `onExit<COMMAND_NAME>` method based on the interface autogenerated by ANTLR and push the new node into the AST. - [x] Create unit tests checking that the correct AST nodes are generated when parsing your command. - [x] Add a dedicated [visitor callback](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-esql-ast/src/visitor/README.md) for the new command. - [x] Verify that the `Walker` API can visit the new node. - [x] Verify that the `Synth` API can construct the new node. ### Validating that the command works well when prettifying the query - [x] Validate that the prettifier works correctly. - [ ] Adjust the basic pretty printer and the wrapping pretty printer if needed. - [x] Add unit tests validating that the queries are correctly formatted (even if no adjustment has been done). ### Creating the command definition - [x] Add the definition of the new command at `kbn-esql-validation-autocomplete/src/definitions/commands.ts`. ### Adding the corresponding client-side validations - [x] Add a custom validation if needed. - [x] Add tests checking the behavior of the validation following this [guide](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-esql-validation-autocomplete/README.md#the-new-way). ### Adding the autocomplete suggestions - [x] Add the suggestions to be shown when **positioned at** the new command. - [x] Create a new folder at `kbn-esql-validation-autocomplete/src/autocomplete/commands` for your command. - [x] Export a `suggest` function that should return an array of suggestions and set it up into the command definition. - [ ] Optionally, we must filter or incorporate fields suggestions after a command is executed, this is supported by adding the `fieldsSuggestionsAfter` method to the command definition. Read this documentation to understand how it works. - [x] If the new command must be suggested only in particular situations, modify the corresponding suggestions to make it possible. - [x] Add tests following this [guide](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-esql-validation-autocomplete/README.md#automated-testing). ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The `release_note:breaking` label should be applied in these situations. - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) ### Identify risks Does this PR introduce any risks? For example, consider risks like hard to test bugs, performance regression, potential of data loss. Describe the risk, its severity, and mitigation for each identified risk. Invite stakeholders and evaluate how to proceed before merging. - [ ] [See some risk examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) - [ ] ... --------- Co-authored-by: Elastic Machine <[email protected]> Co-authored-by: Stratoula Kalafateli <[email protected]> Co-authored-by: Drew Tate <[email protected]>
1 parent 4332884 commit 0794030

File tree

16 files changed

+390
-4
lines changed

16 files changed

+390
-4
lines changed
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import { parse } from '../parser';
11+
12+
describe('RRF', () => {
13+
describe('correctly formatted', () => {
14+
it('can parse RRF command without modifiers', () => {
15+
const text = `FROM search-movies METADATA _score, _id, _index
16+
| FORK
17+
( WHERE semantic_title:"Shakespeare" | SORT _score)
18+
( WHERE title:"Shakespeare" | SORT _score)
19+
| RRF
20+
| KEEP title, _score`;
21+
22+
const { root, errors } = parse(text);
23+
24+
expect(errors.length).toBe(0);
25+
expect(root.commands[2]).toMatchObject({
26+
type: 'command',
27+
name: 'rrf',
28+
args: [],
29+
});
30+
});
31+
});
32+
33+
describe('when incorrectly formatted, return errors', () => {
34+
it('when no pipe after', () => {
35+
const text = `FROM search-movies METADATA _score, _id, _index
36+
| FORK
37+
( WHERE semantic_title:"Shakespeare" | SORT _score)
38+
( WHERE title:"Shakespeare" | SORT _score)
39+
| RRF KEEP title, _score`;
40+
41+
const { errors } = parse(text);
42+
43+
expect(errors.length > 0).toBe(true);
44+
});
45+
46+
it('when no pipe between FORK and RRF', () => {
47+
const text = `FROM search-movies METADATA _score, _id, _index
48+
| FORK
49+
( WHERE semantic_title:"Shakespeare" | SORT _score)
50+
( WHERE title:"Shakespeare" | SORT _score) RRF`;
51+
52+
const { errors } = parse(text);
53+
54+
expect(errors.length > 0).toBe(true);
55+
});
56+
57+
it('when RRF is invoked with arguments', () => {
58+
const text = `FROM search-movies METADATA _score, _id, _index
59+
| FORK ( WHERE semantic_title:"Shakespeare" | SORT _score)
60+
( WHERE title:"Shakespeare" | SORT _score)
61+
| RRF text`;
62+
63+
const { errors } = parse(text);
64+
65+
expect(errors.length > 0).toBe(true);
66+
});
67+
});
68+
});

src/platform/packages/shared/kbn-esql-ast/src/parser/esql_ast_builder_listener.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
type TimeSeriesCommandContext,
3333
type WhereCommandContext,
3434
RerankCommandContext,
35+
RrfCommandContext,
3536
} from '../antlr/esql_parser';
3637
import { default as ESQLParserListener } from '../antlr/esql_parser_listener';
3738
import type { ESQLAst } from '../types';
@@ -351,6 +352,20 @@ export class ESQLAstBuilderListener implements ESQLParserListener {
351352
this.ast.push(command);
352353
}
353354

355+
/**
356+
* Exit a parse tree produced by `esql_parser.rrfCommand`.
357+
*
358+
* Parse the RRF (Reciprocal Rank Fusion) command:
359+
*
360+
* RRF
361+
*
362+
* @param ctx the parse tree
363+
*/
364+
exitRrfCommand(ctx: RrfCommandContext): void {
365+
const command = createCommand('rrf', ctx);
366+
this.ast.push(command);
367+
}
368+
354369
enterEveryRule(ctx: ParserRuleContext): void {
355370
// method not implemented, added to satisfy interface expectation
356371
}

src/platform/packages/shared/kbn-esql-ast/src/pretty_print/__tests__/basic_pretty_printer.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,32 @@ describe('single line query', () => {
235235
);
236236
});
237237
});
238+
239+
describe('RRF', () => {
240+
test('from single line', () => {
241+
const { text } =
242+
reprint(`FROM search-movies METADATA _score, _id, _index | FORK (WHERE semantic_title : "Shakespeare" | SORT _score) (WHERE title : "Shakespeare" | SORT _score) | RRF | KEEP title, _score
243+
`);
244+
245+
expect(text).toBe(
246+
'FROM search-movies METADATA _score, _id, _index | FORK (WHERE semantic_title : "Shakespeare" | SORT _score) (WHERE title : "Shakespeare" | SORT _score) | RRF | KEEP title, _score'
247+
);
248+
});
249+
250+
test('from multiline', () => {
251+
const { text } = reprint(`FROM search-movies METADATA _score, _id, _index
252+
| FORK
253+
(WHERE semantic_title : "Shakespeare" | SORT _score)
254+
(WHERE title : "Shakespeare" | SORT _score)
255+
| RRF
256+
| KEEP title, _score
257+
`);
258+
259+
expect(text).toBe(
260+
'FROM search-movies METADATA _score, _id, _index | FORK (WHERE semantic_title : "Shakespeare" | SORT _score) (WHERE title : "Shakespeare" | SORT _score) | RRF | KEEP title, _score'
261+
);
262+
});
263+
});
238264
});
239265

240266
describe('expressions', () => {

src/platform/packages/shared/kbn-esql-ast/src/visitor/contexts.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,12 @@ export class ForkCommandVisitorContext<
520520
Data extends SharedData = SharedData
521521
> extends CommandVisitorContext<Methods, Data, ESQLAstCommand> {}
522522

523+
// RRF
524+
export class RrfCommandVisitorContext<
525+
Methods extends VisitorMethods = VisitorMethods,
526+
Data extends SharedData = SharedData
527+
> extends CommandVisitorContext<Methods, Data, ESQLAstCommand> {}
528+
523529
// Expressions -----------------------------------------------------------------
524530

525531
export class ExpressionVisitorContext<

src/platform/packages/shared/kbn-esql-ast/src/visitor/global_visitor_context.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,10 @@ export class GlobalVisitorContext<
197197
if (!this.methods.visitForkCommand) break;
198198
return this.visitForkCommand(parent, commandNode, input as any);
199199
}
200+
case 'rrf': {
201+
if (!this.methods.visitRrfCommand) break;
202+
return this.visitRrfCommand(parent, commandNode, input as any);
203+
}
200204
}
201205
return this.visitCommandGeneric(parent, commandNode, input as any);
202206
}
@@ -417,6 +421,15 @@ export class GlobalVisitorContext<
417421
return this.visitWithSpecificContext('visitForkCommand', context, input);
418422
}
419423

424+
public visitRrfCommand(
425+
parent: contexts.VisitorContext | null,
426+
node: ESQLAstCommand,
427+
input: types.VisitorInput<Methods, 'visitRrfCommand'>
428+
): types.VisitorOutput<Methods, 'visitRrfCommand'> {
429+
const context = new contexts.RrfCommandVisitorContext(this, node, parent);
430+
return this.visitWithSpecificContext('visitRrfCommand', context, input);
431+
}
432+
420433
// #endregion
421434

422435
// #region Expression visiting -------------------------------------------------------

src/platform/packages/shared/kbn-esql-ast/src/visitor/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ export interface VisitorMethods<
189189
>;
190190
visitForkCommand?: Visitor<contexts.ForkCommandVisitorContext<Visitors, Data>, any, any>;
191191
visitCommandOption?: Visitor<contexts.CommandOptionVisitorContext<Visitors, Data>, any, any>;
192+
visitRrfCommand?: Visitor<contexts.RrfCommandVisitorContext<Visitors, Data>, any, any>;
192193
visitExpression?: Visitor<contexts.ExpressionVisitorContext<Visitors, Data>, any, any>;
193194
visitSourceExpression?: Visitor<
194195
contexts.SourceExpressionVisitorContext<Visitors, Data>,

src/platform/packages/shared/kbn-esql-validation-autocomplete/src/__tests__/helpers.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ import type { IndexAutocompleteItem } from '@kbn/esql-types';
1212
import { ESQLFieldWithMetadata } from '../validation/types';
1313
import { fieldTypes } from '../definitions/types';
1414
import { ESQLCallbacks } from '../shared/types';
15+
import { METADATA_FIELDS } from '../shared/constants';
16+
17+
export const metadataFields: ESQLFieldWithMetadata[] = METADATA_FIELDS.map((field) => ({
18+
name: field,
19+
type: 'keyword',
20+
}));
1521

1622
export const fields: ESQLFieldWithMetadata[] = [
1723
...fieldTypes.map((type) => ({ name: `${camelCase(type)}Field`, type })),
@@ -107,6 +113,9 @@ export function getCallbackMocks(): ESQLCallbacks {
107113
};
108114
return [field];
109115
}
116+
if (/METADATA/i.test(query)) {
117+
return [...fields, ...metadataFields];
118+
}
110119
return fields;
111120
}),
112121
getSources: jest.fn(async () =>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import { setup } from './helpers';
11+
12+
jest.mock('../../definitions/commands', () => {
13+
const actual = jest.requireActual('../../definitions/commands');
14+
const modifiedDefinitions = actual.commandDefinitions.map((def: any) =>
15+
def.name === 'rrf' ? { ...def, hidden: false } : def
16+
);
17+
return {
18+
...actual,
19+
commandDefinitions: modifiedDefinitions,
20+
};
21+
});
22+
23+
describe('autocomplete.suggest', () => {
24+
describe('RRF', () => {
25+
it('does not suggest RRF in the general list of commands', async () => {
26+
const { suggest } = await setup();
27+
const suggestedCommands = (await suggest('FROM index | /')).map((s) => s.text);
28+
expect(suggestedCommands).not.toContain('RRF ');
29+
});
30+
31+
it('suggests RRF immediately after a FORK command', async () => {
32+
const { suggest } = await setup();
33+
const suggestedCommands = (await suggest('FROM a | FORK (LIMIT 1) (LIMIT 2) | /')).map(
34+
(s) => s.text
35+
);
36+
expect(suggestedCommands).toContain('RRF ');
37+
});
38+
39+
it('does not suggests RRF if FORK is not immediately before', async () => {
40+
const { suggest } = await setup();
41+
const suggestedCommands = (
42+
await suggest('FROM a | FORK (LIMIT 1) (LIMIT 2) | LIMIT 1 | /')
43+
).map((s) => s.text);
44+
expect(suggestedCommands).not.toContain('RRF ');
45+
expect(suggestedCommands).toContain('LIMIT ');
46+
});
47+
48+
it('suggests pipe after complete command', async () => {
49+
const { assertSuggestions } = await setup();
50+
await assertSuggestions('FROM a | FORK (LIMIT 1) (LIMIT 2) | RRF /', ['| ']);
51+
});
52+
});
53+
});

src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,9 @@ export async function suggest(
118118

119119
if (astContext.type === 'newCommand') {
120120
// propose main commands here
121+
// resolve particular commands suggestions after
121122
// filter source commands if already defined
122-
const suggestions = getCommandAutocompleteDefinitions(getAllCommands());
123+
let suggestions = getCommandAutocompleteDefinitions(getAllCommands());
123124
if (!ast.length) {
124125
// Display the recommended queries if there are no commands (empty state)
125126
const recommendedQueriesSuggestions: SuggestionRawDefinition[] = [];
@@ -144,6 +145,12 @@ export async function suggest(
144145
return [...sourceCommandsSuggestions, ...recommendedQueriesSuggestions];
145146
}
146147

148+
// If the last command is not a FORK, RRF should not be suggested.
149+
const lastCommand = root.commands[root.commands.length - 1];
150+
if (lastCommand.name !== 'fork') {
151+
suggestions = suggestions.filter((def) => def.label !== 'RRF');
152+
}
153+
147154
return suggestions.filter((def) => !isSourceCommand(def));
148155
}
149156

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import type { SuggestionRawDefinition } from '../../types';
11+
import { pipeCompleteItem } from '../../complete_items';
12+
13+
export function suggest(): SuggestionRawDefinition[] {
14+
return [pipeCompleteItem];
15+
}

src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/commands.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ import {
6969
suggest as suggestForRename,
7070
fieldsSuggestionsAfter as fieldsSuggestionsAfterRename,
7171
} from '../autocomplete/commands/rename';
72+
import { suggest as suggestForRrf } from '../autocomplete/commands/rrf';
73+
import { validate as validateRrf } from '../validation/commands/rrf';
7274
import { suggest as suggestForRow } from '../autocomplete/commands/row';
7375
import { suggest as suggestForShow } from '../autocomplete/commands/show';
7476
import { suggest as suggestForSort } from '../autocomplete/commands/sort';
@@ -706,4 +708,17 @@ export const commandDefinitions: Array<CommandDefinition<any>> = [
706708

707709
fieldsSuggestionsAfter: fieldsSuggestionsAfterFork,
708710
},
711+
{
712+
hidden: true,
713+
preview: true,
714+
name: 'rrf',
715+
description: i18n.translate('kbn-esql-validation-autocomplete.esql.definitions.rrfDoc', {
716+
defaultMessage:
717+
'Combines multiple result sets with different scoring functions into a single result set.',
718+
}),
719+
declaration: `RRF`,
720+
examples: ['… FORK (LIMIT 1) (LIMIT 2) | RRF'],
721+
suggest: suggestForRrf,
722+
validate: validateRrf,
723+
},
709724
];

src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/types.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@
66
* your election, the "Elastic License 2.0", the "GNU Affero General Public
77
* License v3.0 only", or the "Server Side Public License, v 1".
88
*/
9-
import type {
9+
import {
1010
ESQLAstItem,
1111
ESQLCommand,
1212
ESQLFunction,
1313
ESQLMessage,
1414
ESQLSource,
1515
ESQLAstCommand,
16+
ESQLAst,
1617
} from '@kbn/esql-ast';
1718
import { ESQLControlVariable } from '@kbn/esql-types';
1819
import { GetColumnsByTypeFn, SuggestionRawDefinition } from '../autocomplete/types';
@@ -419,7 +420,11 @@ export interface CommandDefinition<CommandName extends string> {
419420
* prevent the default behavior. If you need a full override, we are currently
420421
* doing those directly in the validateCommand function in the validation module.
421422
*/
422-
validate?: (command: ESQLCommand<CommandName>, references: ReferenceMaps) => ESQLMessage[];
423+
validate?: (
424+
command: ESQLCommand<CommandName>,
425+
references: ReferenceMaps,
426+
ast: ESQLAst
427+
) => ESQLMessage[];
423428

424429
/**
425430
* This method is called to load suggestions when the cursor is within this command.

0 commit comments

Comments
 (0)