From 28ae2a94245dfd3edac2615ccfba57a40d84349e Mon Sep 17 00:00:00 2001 From: Carlos Crespo Date: Thu, 19 Dec 2024 10:04:57 +0100 Subject: [PATCH 1/4] esql-composer ideas --- packages/kbn-esql-composer/index.ts | 9 +- packages/kbn-esql-composer/jest.config.js | 9 +- packages/kbn-esql-composer/src/builder.ts | 56 ++++++ .../kbn-esql-composer/src/commands/append.ts | 26 ++- .../src/commands/drop.test.ts | 10 +- .../kbn-esql-composer/src/commands/drop.ts | 23 +-- .../src/commands/eval.test.ts | 57 +++++++ .../kbn-esql-composer/src/commands/eval.ts | 44 ++++- .../src/commands/from.test.ts | 10 +- .../kbn-esql-composer/src/commands/from.ts | 11 +- .../src/commands/keep.test.ts | 10 +- .../kbn-esql-composer/src/commands/keep.ts | 21 +-- .../kbn-esql-composer/src/commands/limit.ts | 11 +- .../src/commands/sort.test.ts | 65 +++++-- .../kbn-esql-composer/src/commands/sort.ts | 30 ++-- .../src/commands/stats.test.ts | 134 +++++++++++++++ .../kbn-esql-composer/src/commands/stats.ts | 65 ++++++- .../src/commands/where.test.ts | 159 ++++++++++++++++++ .../kbn-esql-composer/src/commands/where.ts | 132 ++++++++++++++- .../kbn-esql-composer/src/create_pipeline.ts | 36 +++- packages/kbn-esql-composer/src/index.test.ts | 69 ++++++-- packages/kbn-esql-composer/src/types.ts | 39 ++++- .../src/utils/escape_identifier.ts | 9 +- 23 files changed, 904 insertions(+), 131 deletions(-) create mode 100644 packages/kbn-esql-composer/src/builder.ts create mode 100644 packages/kbn-esql-composer/src/commands/eval.test.ts create mode 100644 packages/kbn-esql-composer/src/commands/stats.test.ts create mode 100644 packages/kbn-esql-composer/src/commands/where.test.ts diff --git a/packages/kbn-esql-composer/index.ts b/packages/kbn-esql-composer/index.ts index 5c93e9a2fe70b..ce1709d720285 100644 --- a/packages/kbn-esql-composer/index.ts +++ b/packages/kbn-esql-composer/index.ts @@ -1,9 +1,10 @@ /* * 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 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 or the Server - * Side Public License, v 1. + * 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". */ export { from } from './src/commands/from'; diff --git a/packages/kbn-esql-composer/jest.config.js b/packages/kbn-esql-composer/jest.config.js index 088320f1a5671..af98fd0083e4d 100644 --- a/packages/kbn-esql-composer/jest.config.js +++ b/packages/kbn-esql-composer/jest.config.js @@ -1,9 +1,10 @@ /* * 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 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 or the Server - * Side Public License, v 1. + * 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 = { diff --git a/packages/kbn-esql-composer/src/builder.ts b/packages/kbn-esql-composer/src/builder.ts new file mode 100644 index 0000000000000..ecbef9e63eb5d --- /dev/null +++ b/packages/kbn-esql-composer/src/builder.ts @@ -0,0 +1,56 @@ +/* + * 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 { append } from './commands/append'; +import { + QueryOperator, + BuilderCommand, + Params, + ChainedCommand, + QueryBuilderToOperator, +} from './types'; + +export abstract class QueryBuilder implements QueryBuilderToOperator { + protected readonly commands: BuilderCommand[] = []; + + public abstract build(): ChainedCommand; + + public toQueryOperator(): QueryOperator { + return append(this.build()); + } + + protected buildChain(): ChainedCommand { + const commandParts: string[] = []; + const bindingParts: Params[] = []; + + for (let i = 0; i < this.commands.length; i++) { + const currentCondition = this.commands[i]; + + if (i > 0) { + commandParts.push(currentCondition.type); + } + + if (typeof currentCondition.command === 'function') { + const innerCommand = currentCondition.command().buildChain(); + commandParts.push( + currentCondition.nested ? `(${innerCommand.command})` : innerCommand.command + ); + bindingParts.push(innerCommand.bindings ?? []); + } else { + commandParts.push(currentCondition.command); + bindingParts.push(currentCondition.bindings ?? []); + } + } + + return { + command: commandParts.join(' '), + bindings: bindingParts.flatMap((binding) => binding), + }; + } +} diff --git a/packages/kbn-esql-composer/src/commands/append.ts b/packages/kbn-esql-composer/src/commands/append.ts index 4d45bba13c63d..55aa7957a9c3b 100644 --- a/packages/kbn-esql-composer/src/commands/append.ts +++ b/packages/kbn-esql-composer/src/commands/append.ts @@ -1,19 +1,31 @@ /* * 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 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 or the Server - * Side Public License, v 1. + * 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 { Command, QueryOperator } from '../types'; +import { isObject } from 'lodash'; +import { Command, QueryOperator, Params, Query } from '../types'; -export function append(command: Command | string): QueryOperator { - return (source) => { +export function append({ + command, + bindings, +}: { + command: Command | string; + bindings?: Params; +}): QueryOperator { + return (source): Query => { const nextCommand = typeof command === 'string' ? { body: command } : command; + return { ...source, commands: source.commands.concat(nextCommand), + bindings: !!bindings + ? source.bindings.concat(isObject(bindings) ? bindings : [bindings]) + : source.bindings, }; }; } diff --git a/packages/kbn-esql-composer/src/commands/drop.test.ts b/packages/kbn-esql-composer/src/commands/drop.test.ts index 9e2eb4c214cd7..660dfb464c951 100644 --- a/packages/kbn-esql-composer/src/commands/drop.test.ts +++ b/packages/kbn-esql-composer/src/commands/drop.test.ts @@ -1,10 +1,12 @@ /* * 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 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 or the Server - * Side Public License, v 1. + * 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 { drop } from './drop'; import { from } from './from'; diff --git a/packages/kbn-esql-composer/src/commands/drop.ts b/packages/kbn-esql-composer/src/commands/drop.ts index ccc2a043e2976..e8077aa1886a8 100644 --- a/packages/kbn-esql-composer/src/commands/drop.ts +++ b/packages/kbn-esql-composer/src/commands/drop.ts @@ -1,19 +1,22 @@ /* * 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 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 or the Server - * Side Public License, v 1. + * 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 { escapeIdentifier } from '../utils/escape_identifier'; import { append } from './append'; export function drop(...columns: Array) { - return append( - `DROP ${columns - .flatMap((column) => column) - .map((column) => escapeIdentifier(column)) - .join(', ')}` - ); + const command = `DROP ${columns + .flatMap((column) => column) + .map((column) => escapeIdentifier(column)) + .join(', ')}`; + + return append({ + command, + }); } diff --git a/packages/kbn-esql-composer/src/commands/eval.test.ts b/packages/kbn-esql-composer/src/commands/eval.test.ts new file mode 100644 index 0000000000000..ee4bb959b264e --- /dev/null +++ b/packages/kbn-esql-composer/src/commands/eval.test.ts @@ -0,0 +1,57 @@ +/* + * 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 { evaluate } from './eval'; +import { from } from './from'; + +describe('evaluate', () => { + const source = from('logs-*'); + + it('handles single strings', () => { + const pipeline = source.pipe( + evaluate('type = CASE(languages <= 1, "monolingual",languages <= 2, "bilingual","polyglot")') + ); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| EVAL type = CASE(languages <= 1, "monolingual",languages <= 2, "bilingual","polyglot")' + ); + expect(pipeline.getBindings()).toEqual([]); + }); + + it('handles chained EVAL', () => { + const ids = ['host1', 'host2', 'host3']; + const pipeline = source.pipe( + evaluate('entity.type = ?', 'host') + .concat('entity.display_name = COALESCE(?, entity.id)', 'some_host') + .concat(`entity.id = CONCAT(${ids.map(() => '?').join()})`, ids) + ); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| EVAL entity.type = ?, entity.display_name = COALESCE(?, entity.id), entity.id = CONCAT(?,?,?)' + ); + expect(pipeline.getBindings()).toEqual(['host', 'some_host', 'host1', 'host2', 'host3']); + }); + + it('handles EVAL with params', () => { + const pipeline = source.pipe( + evaluate('hour = DATE_TRUNC(1 hour, ?ts)', { + ts: { + identifier: '@timestamp', + }, + }) + ); + + expect(pipeline.asString()).toEqual('FROM `logs-*`\n\t| EVAL hour = DATE_TRUNC(1 hour, ?ts)'); + expect(pipeline.getBindings()).toEqual([ + { + ts: { + identifier: '@timestamp', + }, + }, + ]); + }); +}); diff --git a/packages/kbn-esql-composer/src/commands/eval.ts b/packages/kbn-esql-composer/src/commands/eval.ts index ea10e1403366a..516ee490db763 100644 --- a/packages/kbn-esql-composer/src/commands/eval.ts +++ b/packages/kbn-esql-composer/src/commands/eval.ts @@ -1,14 +1,42 @@ /* * 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 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 or the Server - * Side Public License, v 1. + * 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 { QueryOperator } from '../types'; -import { append } from './append'; +import { QueryBuilder } from '../builder'; +import { ChainedCommand, Params } from '../types'; -export function evaluate(body: string): QueryOperator { - return append(`EVAL ${body}`); +const EVAL = 'EVAL'; + +class EvalBuilder extends QueryBuilder { + private constructor(body: string, bindings?: Params) { + super(); + this.commands.push({ command: body, bindings, type: EVAL }); + } + + public static create(body: string, bindings?: Params) { + return new EvalBuilder(body, bindings); + } + + public concat(body: string, bindings?: Params) { + this.commands.push({ command: body, bindings, type: EVAL }); + return this; + } + + public build(): ChainedCommand { + const { command, bindings } = this.buildChain(); + + return { + command: `${EVAL} ${command.replace(/\s+EVAL/g, ',')}`, + bindings, + }; + } +} + +export function evaluate(body: string, bindings?: Params) { + return EvalBuilder.create(body, bindings); } diff --git a/packages/kbn-esql-composer/src/commands/from.test.ts b/packages/kbn-esql-composer/src/commands/from.test.ts index e15809238aeab..a3cab67c45466 100644 --- a/packages/kbn-esql-composer/src/commands/from.test.ts +++ b/packages/kbn-esql-composer/src/commands/from.test.ts @@ -1,10 +1,12 @@ /* * 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 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 or the Server - * Side Public License, v 1. + * 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 { from } from './from'; describe('from', () => { diff --git a/packages/kbn-esql-composer/src/commands/from.ts b/packages/kbn-esql-composer/src/commands/from.ts index e82cac51ac16f..f4dc153b056d4 100644 --- a/packages/kbn-esql-composer/src/commands/from.ts +++ b/packages/kbn-esql-composer/src/commands/from.ts @@ -1,10 +1,12 @@ /* * 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 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 or the Server - * Side Public License, v 1. + * 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 { createPipeline } from '../create_pipeline'; import type { QueryPipeline } from '../types'; import { escapeIdentifier } from '../utils/escape_identifier'; @@ -18,5 +20,6 @@ export function from(...patterns: Array): QueryPipeline { body: `FROM ${allPatterns.map((pattern) => escapeIdentifier(pattern)).join(',')}`, }, ], + bindings: [], }); } diff --git a/packages/kbn-esql-composer/src/commands/keep.test.ts b/packages/kbn-esql-composer/src/commands/keep.test.ts index b95920e89fcb6..8c727b9030421 100644 --- a/packages/kbn-esql-composer/src/commands/keep.test.ts +++ b/packages/kbn-esql-composer/src/commands/keep.test.ts @@ -1,10 +1,12 @@ /* * 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 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 or the Server - * Side Public License, v 1. + * 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 { from } from './from'; import { keep } from './keep'; diff --git a/packages/kbn-esql-composer/src/commands/keep.ts b/packages/kbn-esql-composer/src/commands/keep.ts index ff4c872d7f185..755817265f69f 100644 --- a/packages/kbn-esql-composer/src/commands/keep.ts +++ b/packages/kbn-esql-composer/src/commands/keep.ts @@ -1,19 +1,20 @@ /* * 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 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 or the Server - * Side Public License, v 1. + * 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 { escapeIdentifier } from '../utils/escape_identifier'; import { append } from './append'; export function keep(...columns: Array) { - return append( - `KEEP ${columns - .flatMap((column) => column) - .map((column) => escapeIdentifier(column)) - .join(', ')}` - ); + const command = `KEEP ${columns + .flatMap((column) => column) + .map((column) => escapeIdentifier(column)) + .join(', ')}`; + + return append({ command }); } diff --git a/packages/kbn-esql-composer/src/commands/limit.ts b/packages/kbn-esql-composer/src/commands/limit.ts index 7d17f81174257..f9c061669ea99 100644 --- a/packages/kbn-esql-composer/src/commands/limit.ts +++ b/packages/kbn-esql-composer/src/commands/limit.ts @@ -1,13 +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 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 or the Server - * Side Public License, v 1. + * 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 { append } from './append'; export function limit(value: number) { - return append(`LIMIT ${value}`); + return append({ command: `LIMIT ${value}` }); } diff --git a/packages/kbn-esql-composer/src/commands/sort.test.ts b/packages/kbn-esql-composer/src/commands/sort.test.ts index 20504fb39a7bc..e6be6c14a881c 100644 --- a/packages/kbn-esql-composer/src/commands/sort.test.ts +++ b/packages/kbn-esql-composer/src/commands/sort.test.ts @@ -1,32 +1,67 @@ /* * 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 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 or the Server - * Side Public License, v 1. + * 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 { from } from './from'; -import { sort, SortOrder } from './sort'; +import { sort, SortOrder, sortRaw } from './sort'; describe('sort', () => { const source = from('logs-*'); it('handles single strings', () => { - expect(source.pipe(sort('@timestamp', 'log.level')).asString()).toEqual( - 'FROM `logs-*`\n\t| SORT @timestamp ASC, log.level ASC' - ); + const pipeline = source.pipe(sort('@timestamp', 'log.level')); + expect(pipeline.asString()).toEqual('FROM `logs-*`\n\t| SORT @timestamp ASC, log.level ASC'); + expect(pipeline.getBindings()).toEqual([]); }); - it('handles an array of strings', () => { - expect(source.pipe(sort(['@timestamp', 'log.level'])).asString()).toEqual( - 'FROM `logs-*`\n\t| SORT @timestamp ASC, log.level ASC' - ); + it('handles SORT with SortOrder', () => { + const pipeline = source.pipe(sort({ '@timestamp': SortOrder.Desc })); + expect(pipeline.asString()).toEqual('FROM `logs-*`\n\t| SORT @timestamp DESC'); + expect(pipeline.getBindings()).toEqual([]); + }); + + it('handles a mix of strings and SortOrder instructions', () => { + const pipeline = source.pipe(sort('@timestamp', { 'log.level': SortOrder.Desc })); + + expect(pipeline.asString()).toEqual('FROM `logs-*`\n\t| SORT @timestamp ASC, log.level DESC'); + expect(pipeline.getBindings()).toEqual([]); + }); + + it('handles nested sort arrays', () => { + const pipeline = source.pipe(sort(['@timestamp', { 'log.level': SortOrder.Asc }])); + expect(pipeline.asString()).toEqual('FROM `logs-*`\n\t| SORT @timestamp ASC, log.level ASC'); + expect(pipeline.getBindings()).toEqual([]); }); - it('handles sort instructions', () => { - expect(source.pipe(sort({ '@timestamp': SortOrder.Desc })).asString()).toEqual( - 'FROM `logs-*`\n\t| SORT @timestamp DESC' + it('handles SORT with params', () => { + const pipeline = source.pipe( + sortRaw('?timestamp DESC, ?logLevel ASC', { + env: { + identifier: '@timestamp', + }, + logLevel: { + identifier: 'log.level', + }, + }) ); + + expect(pipeline.asString()).toEqual('FROM `logs-*`\n\t| SORT ?timestamp DESC, ?logLevel ASC'); + expect(pipeline.getBindings()).toEqual([ + { + env: { + identifier: '@timestamp', + }, + }, + { + logLevel: { + identifier: 'log.level', + }, + }, + ]); }); }); diff --git a/packages/kbn-esql-composer/src/commands/sort.ts b/packages/kbn-esql-composer/src/commands/sort.ts index 18dc0c44e9097..d353978da1b3c 100644 --- a/packages/kbn-esql-composer/src/commands/sort.ts +++ b/packages/kbn-esql-composer/src/commands/sort.ts @@ -1,12 +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 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 or the Server - * Side Public License, v 1. + * 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 { QueryOperator } from '../types'; +import { NamedParameterWithIdentifier, QueryOperator } from '../types'; import { append } from './append'; export enum SortOrder { @@ -16,7 +17,14 @@ export enum SortOrder { type Sort = Record; -export function sort(...sorts: Array>): QueryOperator { +type SortArgs = Sort | string | Array; + +// TODO: a better name? +export function sortRaw(body: string, bindings?: NamedParameterWithIdentifier): QueryOperator { + return append({ command: `SORT ${body}`, bindings }); +} + +export function sort(...sorts: SortArgs[]): QueryOperator { const allSorts = sorts .flatMap((sortInstruction) => sortInstruction) .map((sortInstruction): { column: string; order: 'ASC' | 'DESC' } => { @@ -31,9 +39,9 @@ export function sort(...sorts: Array>): Que }; }); - return append( - `SORT ${allSorts - .map((sortInstruction) => `${sortInstruction.column} ${sortInstruction.order}`) - .join(', ')}` - ); + const command = `SORT ${allSorts + .map((sortInstruction) => `${sortInstruction.column} ${sortInstruction.order}`) + .join(', ')}`; + + return append({ command }); } diff --git a/packages/kbn-esql-composer/src/commands/stats.test.ts b/packages/kbn-esql-composer/src/commands/stats.test.ts new file mode 100644 index 0000000000000..aa50e72896c22 --- /dev/null +++ b/packages/kbn-esql-composer/src/commands/stats.test.ts @@ -0,0 +1,134 @@ +/* + * 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 { from } from './from'; +import { stats } from './stats'; + +describe('stats', () => { + const source = from('logs-*'); + + it('handles a basic STATS command', () => { + const pipeline = source.pipe( + stats('avg_duration = AVG(transaction.duration.us) BY service.name') + ); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| STATS avg_duration = AVG(transaction.duration.us) BY service.name' + ); + expect(pipeline.getBindings()).toEqual([]); + }); + + it('handles STATS with bindings', () => { + const pipeline = source.pipe( + stats('AVG(?duration), COUNT(?svcName) BY ?env', { + duration: { + identifier: 'transaction.duration.us', + }, + svcName: { + identifier: 'service.name', + }, + env: { + identifier: 'service.environment', + }, + }) + ); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| STATS AVG(?duration), COUNT(?svcName) BY ?env' + ); + expect(pipeline.getBindings()).toEqual([ + { + duration: { + identifier: 'transaction.duration.us', + }, + }, + { + svcName: { + identifier: 'service.name', + }, + }, + { + env: { + identifier: 'service.environment', + }, + }, + ]); + }); + + it('handles STATS with WHERE and BY', () => { + const pipeline = source.pipe( + stats('avg_duration = AVG(transaction.duration.us)') + .where({ + 'log.level': 'error', + }) + .by('service.name') + ); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| STATS avg_duration = AVG(transaction.duration.us) WHERE log.level == ? BY service.name' + ); + expect(pipeline.getBindings()).toEqual(['error']); + }); + + it('handles STATS and BY with bindings', () => { + const pipeline = source.pipe( + stats('avg_duration = AVG(transaction.duration.us)').by('?svcName', { + svcName: { identifier: 'service.name' }, + }) + ); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| STATS avg_duration = AVG(transaction.duration.us) BY ?svcName' + ); + expect(pipeline.getBindings()).toEqual([ + { + svcName: { + identifier: 'service.name', + }, + }, + ]); + }); + + it('handles STATS and BY with multiple fields', () => { + const pipeline = source.pipe( + stats('avg_duration = AVG(transaction.duration.us)').by(['?svcName', '?svcEnv'], { + svcName: { identifier: 'service.name' }, + svcEnv: { identifier: 'service.environment' }, + }) + ); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| STATS avg_duration = AVG(transaction.duration.us) BY ?svcName, ?svcEnv' + ); + expect(pipeline.getBindings()).toEqual([ + { + svcName: { + identifier: 'service.name', + }, + }, + { + svcEnv: { + identifier: 'service.environment', + }, + }, + ]); + }); + + it('handles multiple chained STATS', () => { + const pipeline = source.pipe( + stats('avg_duration = AVG(transaction.duration.us)') + .concat('max_duration = MAX(transaction.duration.us)') + .where('@timestamp > ?', '2021-01-01') + .concat('min_duration = MIN(transaction.duration.us)') + .where({ + 'service.name': 'service2', + }) + .by('service.environment') + ); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| STATS avg_duration = AVG(transaction.duration.us), max_duration = MAX(transaction.duration.us) WHERE @timestamp > ?, min_duration = MIN(transaction.duration.us) WHERE service.name == ? BY service.environment' + ); + expect(pipeline.getBindings()).toEqual(['2021-01-01', 'service2']); + }); +}); diff --git a/packages/kbn-esql-composer/src/commands/stats.ts b/packages/kbn-esql-composer/src/commands/stats.ts index 4c91c0fd1dff0..5ed1c239b4951 100644 --- a/packages/kbn-esql-composer/src/commands/stats.ts +++ b/packages/kbn-esql-composer/src/commands/stats.ts @@ -1,13 +1,64 @@ /* * 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 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 or the Server - * Side Public License, v 1. + * 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 { append } from './append'; +import { QueryBuilder } from '../builder'; +import { ChainedCommand, NamedParameterWithIdentifier } from '../types'; +import { where } from './where'; -export function stats(body: string) { - return append(`STATS ${body}`); +const STATS = 'STATS'; + +type StatsAfterWhere = Omit; +type StatsAfterBy = Omit; +class StatsBuilder extends QueryBuilder { + private constructor(body: string, bindings?: NamedParameterWithIdentifier) { + super(); + this.commands.push({ command: body, bindings, type: STATS }); + } + + public static create(body: string, bindings?: NamedParameterWithIdentifier) { + return new StatsBuilder(body, bindings); + } + + public concat(body: string, bindings?: NamedParameterWithIdentifier) { + this.commands.push({ command: body, bindings, type: STATS }); + return this; + } + + public by(criteria: string | string[], bindings?: NamedParameterWithIdentifier) { + this.commands.push({ + command: Array.isArray(criteria) ? criteria.join(', ') : criteria, + bindings, + type: 'BY', + }); + + return this as StatsAfterBy; + } + + public where(...args: Parameters) { + this.commands.push({ + command: () => where(...args), + type: 'WHERE', + }); + + return this as StatsAfterWhere; + } + + public build(): ChainedCommand { + const { command, bindings } = this.buildChain(); + + return { + command: `${STATS} ${command.replace(/\s+STATS/g, ',')}`, + bindings, + }; + } +} + +export function stats(body: string, bindings?: NamedParameterWithIdentifier) { + return StatsBuilder.create(body, bindings); } diff --git a/packages/kbn-esql-composer/src/commands/where.test.ts b/packages/kbn-esql-composer/src/commands/where.test.ts new file mode 100644 index 0000000000000..81df38ec91263 --- /dev/null +++ b/packages/kbn-esql-composer/src/commands/where.test.ts @@ -0,0 +1,159 @@ +/* + * 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 { where } from './where'; +import { from } from './from'; + +describe('where', () => { + const source = from('logs-*'); + + it('appends a basic WHERE clause', () => { + const pipeline = source.pipe( + where(`@timestamp <= NOW() AND @timestamp > NOW() - 24 hours`), + where('log.level', '==', 'error') + ); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| WHERE @timestamp <= NOW() AND @timestamp > NOW() - 24 hours\n\t| WHERE log.level == ?' + ); + expect(pipeline.getBindings()).toEqual(['error']); + }); + + it('appends a WHERE clause with positional parameters', () => { + const pipeline = source.pipe(where('host.id == ?', 'host')); + expect(pipeline.asString()).toEqual('FROM `logs-*`\n\t| WHERE host.id == ?'); + expect(pipeline.getBindings()).toEqual(['host']); + }); + + it('appends a WHERE clause with named parameters', () => { + const params = { hostName: 'host', serviceName: 'service' }; + const pipeline = source.pipe( + where('host.name = ?hostName AND service.name = ?serviceName', params) + ); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| WHERE host.name = ?hostName AND service.name = ?serviceName' + ); + expect(pipeline.getBindings()).toEqual([{ hostName: 'host' }, { serviceName: 'service' }]); + }); + + it('handles WHERE clause with IN operator and positional parameters', () => { + const hosts = ['host1', 'host2', 'host3']; + const pipeline = source.pipe(where(`host.name IN (${hosts.map(() => '?').join(',')})`, hosts)); + expect(pipeline.asString()).toEqual('FROM `logs-*`\n\t| WHERE host.name IN (?,?,?)'); + expect(pipeline.getBindings()).toEqual(['host1', 'host2', 'host3']); + }); + + it('handles nested WHERE clauses with OR conditions', () => { + const pipeline = source.pipe( + where(() => where('host.name == ?', 'host4').or('host.name', '==', 'host5')) + ); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| WHERE (host.name == ? OR host.name == ?)' + ); + expect(pipeline.getBindings()).toEqual(['host4', 'host5']); + }); + + it('handles nested WHERE clauses with AND and OR combinations', () => { + const hosts = ['host1', 'host2', 'host3']; + const pipeline = source.pipe( + where(() => where('host.name == ?', 'host4').or('host.name', '==', 'host5')).and( + `host.name IN (${hosts.map(() => '?').join(',')})`, + hosts + ) + ); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| WHERE (host.name == ? OR host.name == ?) AND host.name IN (?,?,?)' + ); + expect(pipeline.getBindings()).toEqual(['host4', 'host5', 'host1', 'host2', 'host3']); + }); + + it('handles multiple nested WHERE clauses', () => { + const pipeline = source.pipe( + where(() => where('host.name == ?', 'host4').or('host.name', '==', 'host5')).and( + 'service.name == ?', + 'service1' + ) + ); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| WHERE (host.name == ? OR host.name == ?) AND service.name == ?' + ); + expect(pipeline.getBindings()).toEqual(['host4', 'host5', 'service1']); + }); + + it('handles empty parameters gracefully', () => { + const pipeline = source.pipe(where('host.name == ?', '')); + expect(pipeline.asString()).toEqual('FROM `logs-*`\n\t| WHERE host.name == ?'); + expect(pipeline.getBindings()).toEqual(['']); + }); + + it('handles deeply nested queries', () => { + const pipeline = source.pipe( + where(() => where('host.name == ?', 'host1').or('host.name == ?', 'host2')).and(() => + where('service.name == ?', 'service1').or('service.name == ?', 'service2') + ) + ); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| WHERE (host.name == ? OR host.name == ?) AND (service.name == ? OR service.name == ?)' + ); + expect(pipeline.getBindings()).toEqual(['host1', 'host2', 'service1', 'service2']); + }); + + it('handles WHERE clause with object', () => { + const pipeline = source.pipe(where({ 'host.name': 'host', 'service.name': 'service' })); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| WHERE host.name == ? AND service.name == ?' + ); + expect(pipeline.getBindings()).toEqual(['host', 'service']); + }); + + it('handles WHERE clause with object with AND and OR combinations', () => { + const pipeline = source.pipe( + where({ + 'log.level': 'error', + }) + .and(() => where('host.name == ?', 'host4').or('host.name', '==', 'host5')) + .and({ + 'log.message': 'error message', + }) + ); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| WHERE log.level == ? AND (host.name == ? OR host.name == ?) AND log.message == ?' + ); + expect(pipeline.getBindings()).toEqual(['error', 'host4', 'host5', 'error message']); + }); + + it('handles WHERE clause with with many OR groups and nested conditions', () => { + const pipeline = source.pipe( + where('host.name == ?', 'host2') + .and(() => + where({ 'log.level': 'warning' }) + .or({ 'log.message': 'debug' }) + .or({ 'log.message': 'info' }) + .or({ 'log.level': 'error' }) + ) + .or(() => + where(() => where('host.name == ?', 'host1').or('host.name == ?', 'host2')).and(() => + where('service.name == ?', 'service1').or('service.name == ?', 'service2') + ) + ) + ); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| WHERE host.name == ? AND (log.level == ? OR log.message == ? OR log.message == ? OR log.level == ?) OR ((host.name == ? OR host.name == ?) AND (service.name == ? OR service.name == ?))' + ); + expect(pipeline.getBindings()).toEqual([ + 'host2', + 'warning', + 'debug', + 'info', + 'error', + 'host1', + 'host2', + 'service1', + 'service2', + ]); + }); +}); diff --git a/packages/kbn-esql-composer/src/commands/where.ts b/packages/kbn-esql-composer/src/commands/where.ts index 683c663869ae4..9a134e69c5963 100644 --- a/packages/kbn-esql-composer/src/commands/where.ts +++ b/packages/kbn-esql-composer/src/commands/where.ts @@ -1,14 +1,130 @@ /* * 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 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 or the Server - * Side Public License, v 1. + * 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 { QueryOperator } from '../types'; -import { append } from './append'; +import { QueryBuilder } from '../builder'; +import { Params, FieldValue, ChainedCommand } from '../types'; -export function where(body: string): QueryOperator { - return append(`WHERE ${body}`); +const operators = ['==', '>', '<', '!=', '>=', '<='] as const; + +const WHERE = 'WHERE'; + +type WhereCriteria = string | (() => WhereBuilder) | Record; +type Operators = (typeof operators)[number]; +type LogicalOperator = 'AND' | 'OR'; + +function isOperator(value?: Params | Operators): value is Operators { + return !!value && operators.includes(value as Operators); +} + +class WhereBuilder extends QueryBuilder { + private constructor( + criteria: WhereCriteria, + operatorOrBindings?: Operators | Params, + params?: Params + ) { + super(); + this.push(criteria, operatorOrBindings, params); + } + + public static create( + criteria: WhereCriteria, + operatorOrBindings?: Operators | Params, + params?: Params + ) { + return new WhereBuilder(criteria, operatorOrBindings, params); + } + + public and( + criteria: WhereCriteria, + operatorOrParams?: Operators | Params, + bindings?: Params + ): WhereBuilder { + return this.addCondition('AND', criteria, operatorOrParams, bindings); + } + + public or( + criteria: WhereCriteria, + operatorOrParams?: Operators | Params, + bindings?: Params + ): WhereBuilder { + return this.addCondition('OR', criteria, operatorOrParams, bindings); + } + + public build(): ChainedCommand { + const { command, bindings } = this.buildChain(); + + return { + command: `${WHERE} ${command}`, + bindings, + }; + } + + private addCondition( + logicalOperator: LogicalOperator, + body: WhereCriteria, + operatorOrParams?: Operators | Params, + bindings?: Params + ): WhereBuilder { + return this.push(body, operatorOrParams, bindings, logicalOperator); + } + + private push( + criteria: WhereCriteria, + operatorOrBindings?: Operators | Params, + params?: Params, + type: LogicalOperator | typeof WHERE = 'WHERE' + ) { + if (typeof criteria === 'function') { + // Handle nested conditions + this.commands.push({ + command: criteria, + type, + nested: true, + }); + } else if (typeof criteria === 'object') { + // Handle object with named parameters + const keys = Object.keys(criteria); + this.commands.push({ + command: keys.map((key) => `${key} == ?`).join(' AND '), + bindings: keys.map((key) => criteria[key]), + type, + }); + } else if ( + !Array.isArray(operatorOrBindings) && + isOperator(operatorOrBindings) && + params !== undefined + ) { + // Handle explicit operator and parameter + const operator = operatorOrBindings; + this.commands.push({ + command: `${criteria} ${operator} ?`, + bindings: params, + type, + }); + } else { + // Handle raw and nested conditions + const bindings = operatorOrBindings; + this.commands.push({ + command: criteria, + bindings, + type, + }); + } + + return this; + } +} + +export function where( + criteria: WhereCriteria, + operatorOrParams?: Operators | Params, + bindings?: Params +): WhereBuilder { + return WhereBuilder.create(criteria, operatorOrParams, bindings); } diff --git a/packages/kbn-esql-composer/src/create_pipeline.ts b/packages/kbn-esql-composer/src/create_pipeline.ts index 76b861e55e18d..ee88ec2e0ef77 100644 --- a/packages/kbn-esql-composer/src/create_pipeline.ts +++ b/packages/kbn-esql-composer/src/create_pipeline.ts @@ -1,17 +1,29 @@ /* * 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 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 or the Server - * Side Public License, v 1. + * 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 { Query, QueryPipeline } from './types'; +import { isObject } from 'lodash'; +import { Query, QueryBuilderToOperator, QueryOperator, QueryPipeline } from './types'; + +function isQueryBuilderOperator( + value: QueryOperator | QueryBuilderToOperator +): value is QueryBuilderToOperator { + return (value as QueryBuilderToOperator).toQueryOperator !== undefined; +} export function createPipeline(source: Query): QueryPipeline { return { pipe: (...operators) => { const nextSource = operators.reduce((previousQuery, operator) => { + if (isQueryBuilderOperator(operator)) { + return operator.toQueryOperator()(previousQuery); + } + return operator(previousQuery); }, source); @@ -20,5 +32,19 @@ export function createPipeline(source: Query): QueryPipeline { asString: () => { return source.commands.map((command) => command.body).join('\n\t| '); }, + getBindings: () => { + return source.bindings.flatMap((binding) => { + if (isObject(binding)) { + return Object.entries(binding).map(([key, value]) => ({ + [key]: value, + })); + } + if (Array.isArray(binding)) { + return binding.map((p) => p); + } + + return binding; + }); + }, }; } diff --git a/packages/kbn-esql-composer/src/index.test.ts b/packages/kbn-esql-composer/src/index.test.ts index b3df929e24550..bb51e97230fed 100644 --- a/packages/kbn-esql-composer/src/index.test.ts +++ b/packages/kbn-esql-composer/src/index.test.ts @@ -1,25 +1,70 @@ /* * 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 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 or the Server - * Side Public License, v 1. + * 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 { from } from './commands/from'; +import { keep } from './commands/keep'; +import { SortOrder, sort } from './commands/sort'; import { stats } from './commands/stats'; import { where } from './commands/where'; describe('composer', () => { + const source = from('logs-*'); + it('applies operators in order', () => { - expect( - from('logs-*') - .pipe( - where(`@timestamp <= NOW() AND @timestamp > NOW() - 24 hours`), - stats(`avg_duration = AVG(transaction.duration.us) BY service.name`) - ) - .asString() - ).toEqual( + const pipeline = source.pipe( + where(`@timestamp <= NOW() AND @timestamp > NOW() - 24 hours`), + stats(`avg_duration = AVG(transaction.duration.us) BY service.name`) + ); + + expect(pipeline.asString()).toEqual( `FROM \`logs-*\`\n\t| WHERE @timestamp <= NOW() AND @timestamp > NOW() - 24 hours\n\t| STATS avg_duration = AVG(transaction.duration.us) BY service.name` ); }); + + it('applies many operators', () => { + const pipeline = source.pipe( + where(`@timestamp <= NOW() AND @timestamp > NOW() - 24 hours`), + stats(`avg_duration = AVG(transaction.duration.us) BY service.name`), + keep('@timestamp', 'avg_duration', 'service.name'), + sort('avg_duration', { '@timestamp': SortOrder.Desc }) + ); + + expect(pipeline.asString()).toEqual( + `FROM \`logs-*\`\n\t| WHERE @timestamp <= NOW() AND @timestamp > NOW() - 24 hours\n\t| STATS avg_duration = AVG(transaction.duration.us) BY service.name\n\t| KEEP \`@timestamp\`, \`avg_duration\`, \`service.name\`\n\t| SORT avg_duration ASC, @timestamp DESC` + ); + }); + + it('applies many operators with fluent', () => { + const pipeline = source.pipe( + where('host.name == ?', 'host2') + .and(() => + where({ 'log.level': 'warning' }) + .or({ 'log.message': 'debug' }) + .or({ 'log.message': 'info' }) + .or({ 'log.level': 'error' }) + ) + .or(() => + where(() => where('host.name == ?', 'host1').or('host.name == ?', 'host2')).and(() => + where('service.name == ?', 'service1').or('service.name == ?', 'service2') + ) + ), + stats('avg_duration = AVG(transaction.duration.us)') + .where({ + 'log.level': 'error', + }) + .concat('min_duration = MIN(transaction.duration.us)') + .by('service.name'), + sort('avg_duration', { '@timestamp': SortOrder.Desc }) + ); + + expect(pipeline.asString()).toEqual( + `FROM \`logs-*\`\n\t| WHERE host.name == ? AND (log.level == ? OR log.message == ? OR log.message == ? OR log.level == ?) OR ((host.name == ? OR host.name == ?) AND (service.name == ? OR service.name == ?))\n\t| STATS avg_duration = AVG(transaction.duration.us) WHERE log.level == ?, min_duration = MIN(transaction.duration.us) BY service.name\n\t| SORT avg_duration ASC, @timestamp DESC` + ); + }); }); diff --git a/packages/kbn-esql-composer/src/types.ts b/packages/kbn-esql-composer/src/types.ts index 9f0fe693b0100..b704d85a4d032 100644 --- a/packages/kbn-esql-composer/src/types.ts +++ b/packages/kbn-esql-composer/src/types.ts @@ -1,21 +1,50 @@ /* * 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 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 or the Server - * Side Public License, v 1. + * 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 { QueryBuilder } from './builder'; + export interface Command { body: string; } +// Replace +export type FieldValue = number | string | boolean | null; +export type NamedParameterWithIdentifier = Record< + string, + { identifier: string } | { pattern: string } +>; +export type NamedParameter = Record | NamedParameterWithIdentifier; + +export type Params = NamedParameter | FieldValue | Array; export interface QueryPipeline { - pipe: (...args: QueryOperator[]) => QueryPipeline; + pipe: (...args: Array) => QueryPipeline; asString: () => string; + getBindings: () => Params[]; } export interface Query { commands: Command[]; + bindings: Params[]; } export type QueryOperator = (sourceQuery: Query) => Query; +export interface BuilderCommand { + command: string | (() => QueryBuilder); + bindings?: Params; + type: TType; + nested?: boolean; +} + +export interface ChainedCommand { + command: string; + bindings?: Params; +} + +export interface QueryBuilderToOperator { + toQueryOperator(): QueryOperator; +} diff --git a/packages/kbn-esql-composer/src/utils/escape_identifier.ts b/packages/kbn-esql-composer/src/utils/escape_identifier.ts index c0ca065198489..12e3ab7099299 100644 --- a/packages/kbn-esql-composer/src/utils/escape_identifier.ts +++ b/packages/kbn-esql-composer/src/utils/escape_identifier.ts @@ -1,9 +1,10 @@ /* * 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 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 or the Server - * Side Public License, v 1. + * 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". */ export function escapeIdentifier(identifier: string) { From a0c8859946bae75041b841b0806003d6d2923021 Mon Sep 17 00:00:00 2001 From: Carlos Crespo Date: Fri, 27 Dec 2024 17:55:21 +0100 Subject: [PATCH 2/4] asString draft --- .../src/commands/drop.test.ts | 4 +- .../src/commands/eval.test.ts | 6 +- .../src/commands/from.test.ts | 4 +- .../src/commands/keep.test.ts | 4 +- .../src/commands/sort.test.ts | 10 +-- .../src/commands/stats.test.ts | 12 ++-- .../src/commands/where.test.ts | 27 +++---- .../kbn-esql-composer/src/create_pipeline.ts | 71 ++++++++++++++----- packages/kbn-esql-composer/src/index.test.ts | 12 ++-- packages/kbn-esql-composer/src/types.ts | 3 +- 10 files changed, 99 insertions(+), 54 deletions(-) diff --git a/packages/kbn-esql-composer/src/commands/drop.test.ts b/packages/kbn-esql-composer/src/commands/drop.test.ts index 660dfb464c951..fec38f23b0b5a 100644 --- a/packages/kbn-esql-composer/src/commands/drop.test.ts +++ b/packages/kbn-esql-composer/src/commands/drop.test.ts @@ -13,13 +13,13 @@ import { from } from './from'; describe('drop', () => { const source = from('logs-*'); it('handles single strings', () => { - expect(source.pipe(drop('log.level', 'service.name')).asString()).toEqual( + expect(source.pipe(drop('log.level', 'service.name')).asQuery()).toEqual( 'FROM `logs-*`\n\t| DROP `log.level`, `service.name`' ); }); it('handles arrays of strings', () => { - expect(source.pipe(drop(['log.level', 'service.name'])).asString()).toEqual( + expect(source.pipe(drop(['log.level', 'service.name'])).asQuery()).toEqual( 'FROM `logs-*`\n\t| DROP `log.level`, `service.name`' ); }); diff --git a/packages/kbn-esql-composer/src/commands/eval.test.ts b/packages/kbn-esql-composer/src/commands/eval.test.ts index ee4bb959b264e..da911f58bee7e 100644 --- a/packages/kbn-esql-composer/src/commands/eval.test.ts +++ b/packages/kbn-esql-composer/src/commands/eval.test.ts @@ -17,7 +17,7 @@ describe('evaluate', () => { const pipeline = source.pipe( evaluate('type = CASE(languages <= 1, "monolingual",languages <= 2, "bilingual","polyglot")') ); - expect(pipeline.asString()).toEqual( + expect(pipeline.asQuery()).toEqual( 'FROM `logs-*`\n\t| EVAL type = CASE(languages <= 1, "monolingual",languages <= 2, "bilingual","polyglot")' ); expect(pipeline.getBindings()).toEqual([]); @@ -30,7 +30,7 @@ describe('evaluate', () => { .concat('entity.display_name = COALESCE(?, entity.id)', 'some_host') .concat(`entity.id = CONCAT(${ids.map(() => '?').join()})`, ids) ); - expect(pipeline.asString()).toEqual( + expect(pipeline.asQuery()).toEqual( 'FROM `logs-*`\n\t| EVAL entity.type = ?, entity.display_name = COALESCE(?, entity.id), entity.id = CONCAT(?,?,?)' ); expect(pipeline.getBindings()).toEqual(['host', 'some_host', 'host1', 'host2', 'host3']); @@ -45,7 +45,7 @@ describe('evaluate', () => { }) ); - expect(pipeline.asString()).toEqual('FROM `logs-*`\n\t| EVAL hour = DATE_TRUNC(1 hour, ?ts)'); + expect(pipeline.asQuery()).toEqual('FROM `logs-*`\n\t| EVAL hour = DATE_TRUNC(1 hour, ?ts)'); expect(pipeline.getBindings()).toEqual([ { ts: { diff --git a/packages/kbn-esql-composer/src/commands/from.test.ts b/packages/kbn-esql-composer/src/commands/from.test.ts index a3cab67c45466..15203d67ef5bb 100644 --- a/packages/kbn-esql-composer/src/commands/from.test.ts +++ b/packages/kbn-esql-composer/src/commands/from.test.ts @@ -11,10 +11,10 @@ import { from } from './from'; describe('from', () => { it('handles single strings', () => { - expect(from('logs-*', 'traces-*').asString()).toEqual('FROM `logs-*`,`traces-*`'); + expect(from('logs-*', 'traces-*').asQuery()).toEqual('FROM `logs-*`,`traces-*`'); }); it('handles arrays of strings', () => { - expect(from(['logs-*', 'traces-*']).asString()).toEqual('FROM `logs-*`,`traces-*`'); + expect(from(['logs-*', 'traces-*']).asQuery()).toEqual('FROM `logs-*`,`traces-*`'); }); }); diff --git a/packages/kbn-esql-composer/src/commands/keep.test.ts b/packages/kbn-esql-composer/src/commands/keep.test.ts index 8c727b9030421..9d40446979b97 100644 --- a/packages/kbn-esql-composer/src/commands/keep.test.ts +++ b/packages/kbn-esql-composer/src/commands/keep.test.ts @@ -13,13 +13,13 @@ import { keep } from './keep'; describe('keep', () => { const source = from('logs-*'); it('handles single strings', () => { - expect(source.pipe(keep('log.level', 'service.name')).asString()).toEqual( + expect(source.pipe(keep('log.level', 'service.name')).asQuery()).toEqual( 'FROM `logs-*`\n\t| KEEP `log.level`, `service.name`' ); }); it('handles arrays of strings', () => { - expect(source.pipe(keep(['log.level', 'service.name'])).asString()).toEqual( + expect(source.pipe(keep(['log.level', 'service.name'])).asQuery()).toEqual( 'FROM `logs-*`\n\t| KEEP `log.level`, `service.name`' ); }); diff --git a/packages/kbn-esql-composer/src/commands/sort.test.ts b/packages/kbn-esql-composer/src/commands/sort.test.ts index e6be6c14a881c..ca90a70f63540 100644 --- a/packages/kbn-esql-composer/src/commands/sort.test.ts +++ b/packages/kbn-esql-composer/src/commands/sort.test.ts @@ -15,26 +15,26 @@ describe('sort', () => { it('handles single strings', () => { const pipeline = source.pipe(sort('@timestamp', 'log.level')); - expect(pipeline.asString()).toEqual('FROM `logs-*`\n\t| SORT @timestamp ASC, log.level ASC'); + expect(pipeline.asQuery()).toEqual('FROM `logs-*`\n\t| SORT @timestamp ASC, log.level ASC'); expect(pipeline.getBindings()).toEqual([]); }); it('handles SORT with SortOrder', () => { const pipeline = source.pipe(sort({ '@timestamp': SortOrder.Desc })); - expect(pipeline.asString()).toEqual('FROM `logs-*`\n\t| SORT @timestamp DESC'); + expect(pipeline.asQuery()).toEqual('FROM `logs-*`\n\t| SORT @timestamp DESC'); expect(pipeline.getBindings()).toEqual([]); }); it('handles a mix of strings and SortOrder instructions', () => { const pipeline = source.pipe(sort('@timestamp', { 'log.level': SortOrder.Desc })); - expect(pipeline.asString()).toEqual('FROM `logs-*`\n\t| SORT @timestamp ASC, log.level DESC'); + expect(pipeline.asQuery()).toEqual('FROM `logs-*`\n\t| SORT @timestamp ASC, log.level DESC'); expect(pipeline.getBindings()).toEqual([]); }); it('handles nested sort arrays', () => { const pipeline = source.pipe(sort(['@timestamp', { 'log.level': SortOrder.Asc }])); - expect(pipeline.asString()).toEqual('FROM `logs-*`\n\t| SORT @timestamp ASC, log.level ASC'); + expect(pipeline.asQuery()).toEqual('FROM `logs-*`\n\t| SORT @timestamp ASC, log.level ASC'); expect(pipeline.getBindings()).toEqual([]); }); @@ -50,7 +50,7 @@ describe('sort', () => { }) ); - expect(pipeline.asString()).toEqual('FROM `logs-*`\n\t| SORT ?timestamp DESC, ?logLevel ASC'); + expect(pipeline.asQuery()).toEqual('FROM `logs-*`\n\t| SORT ?timestamp DESC, ?logLevel ASC'); expect(pipeline.getBindings()).toEqual([ { env: { diff --git a/packages/kbn-esql-composer/src/commands/stats.test.ts b/packages/kbn-esql-composer/src/commands/stats.test.ts index aa50e72896c22..0f557e17556eb 100644 --- a/packages/kbn-esql-composer/src/commands/stats.test.ts +++ b/packages/kbn-esql-composer/src/commands/stats.test.ts @@ -17,7 +17,7 @@ describe('stats', () => { const pipeline = source.pipe( stats('avg_duration = AVG(transaction.duration.us) BY service.name') ); - expect(pipeline.asString()).toEqual( + expect(pipeline.asQuery()).toEqual( 'FROM `logs-*`\n\t| STATS avg_duration = AVG(transaction.duration.us) BY service.name' ); expect(pipeline.getBindings()).toEqual([]); @@ -37,7 +37,7 @@ describe('stats', () => { }, }) ); - expect(pipeline.asString()).toEqual( + expect(pipeline.asQuery()).toEqual( 'FROM `logs-*`\n\t| STATS AVG(?duration), COUNT(?svcName) BY ?env' ); expect(pipeline.getBindings()).toEqual([ @@ -67,7 +67,7 @@ describe('stats', () => { }) .by('service.name') ); - expect(pipeline.asString()).toEqual( + expect(pipeline.asQuery()).toEqual( 'FROM `logs-*`\n\t| STATS avg_duration = AVG(transaction.duration.us) WHERE log.level == ? BY service.name' ); expect(pipeline.getBindings()).toEqual(['error']); @@ -79,7 +79,7 @@ describe('stats', () => { svcName: { identifier: 'service.name' }, }) ); - expect(pipeline.asString()).toEqual( + expect(pipeline.asQuery()).toEqual( 'FROM `logs-*`\n\t| STATS avg_duration = AVG(transaction.duration.us) BY ?svcName' ); expect(pipeline.getBindings()).toEqual([ @@ -98,7 +98,7 @@ describe('stats', () => { svcEnv: { identifier: 'service.environment' }, }) ); - expect(pipeline.asString()).toEqual( + expect(pipeline.asQuery()).toEqual( 'FROM `logs-*`\n\t| STATS avg_duration = AVG(transaction.duration.us) BY ?svcName, ?svcEnv' ); expect(pipeline.getBindings()).toEqual([ @@ -126,7 +126,7 @@ describe('stats', () => { }) .by('service.environment') ); - expect(pipeline.asString()).toEqual( + expect(pipeline.asQuery()).toEqual( 'FROM `logs-*`\n\t| STATS avg_duration = AVG(transaction.duration.us), max_duration = MAX(transaction.duration.us) WHERE @timestamp > ?, min_duration = MIN(transaction.duration.us) WHERE service.name == ? BY service.environment' ); expect(pipeline.getBindings()).toEqual(['2021-01-01', 'service2']); diff --git a/packages/kbn-esql-composer/src/commands/where.test.ts b/packages/kbn-esql-composer/src/commands/where.test.ts index 81df38ec91263..e7db1d60039f8 100644 --- a/packages/kbn-esql-composer/src/commands/where.test.ts +++ b/packages/kbn-esql-composer/src/commands/where.test.ts @@ -17,7 +17,7 @@ describe('where', () => { where(`@timestamp <= NOW() AND @timestamp > NOW() - 24 hours`), where('log.level', '==', 'error') ); - expect(pipeline.asString()).toEqual( + expect(pipeline.asQuery()).toEqual( 'FROM `logs-*`\n\t| WHERE @timestamp <= NOW() AND @timestamp > NOW() - 24 hours\n\t| WHERE log.level == ?' ); expect(pipeline.getBindings()).toEqual(['error']); @@ -25,7 +25,7 @@ describe('where', () => { it('appends a WHERE clause with positional parameters', () => { const pipeline = source.pipe(where('host.id == ?', 'host')); - expect(pipeline.asString()).toEqual('FROM `logs-*`\n\t| WHERE host.id == ?'); + expect(pipeline.asQuery()).toEqual('FROM `logs-*`\n\t| WHERE host.id == ?'); expect(pipeline.getBindings()).toEqual(['host']); }); @@ -34,16 +34,19 @@ describe('where', () => { const pipeline = source.pipe( where('host.name = ?hostName AND service.name = ?serviceName', params) ); - expect(pipeline.asString()).toEqual( + expect(pipeline.asQuery()).toEqual( 'FROM `logs-*`\n\t| WHERE host.name = ?hostName AND service.name = ?serviceName' ); expect(pipeline.getBindings()).toEqual([{ hostName: 'host' }, { serviceName: 'service' }]); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| WHERE host.name = "host" AND service.name = "service"' + ); }); it('handles WHERE clause with IN operator and positional parameters', () => { const hosts = ['host1', 'host2', 'host3']; const pipeline = source.pipe(where(`host.name IN (${hosts.map(() => '?').join(',')})`, hosts)); - expect(pipeline.asString()).toEqual('FROM `logs-*`\n\t| WHERE host.name IN (?,?,?)'); + expect(pipeline.asQuery()).toEqual('FROM `logs-*`\n\t| WHERE host.name IN (?,?,?)'); expect(pipeline.getBindings()).toEqual(['host1', 'host2', 'host3']); }); @@ -51,7 +54,7 @@ describe('where', () => { const pipeline = source.pipe( where(() => where('host.name == ?', 'host4').or('host.name', '==', 'host5')) ); - expect(pipeline.asString()).toEqual( + expect(pipeline.asQuery()).toEqual( 'FROM `logs-*`\n\t| WHERE (host.name == ? OR host.name == ?)' ); expect(pipeline.getBindings()).toEqual(['host4', 'host5']); @@ -65,7 +68,7 @@ describe('where', () => { hosts ) ); - expect(pipeline.asString()).toEqual( + expect(pipeline.asQuery()).toEqual( 'FROM `logs-*`\n\t| WHERE (host.name == ? OR host.name == ?) AND host.name IN (?,?,?)' ); expect(pipeline.getBindings()).toEqual(['host4', 'host5', 'host1', 'host2', 'host3']); @@ -78,7 +81,7 @@ describe('where', () => { 'service1' ) ); - expect(pipeline.asString()).toEqual( + expect(pipeline.asQuery()).toEqual( 'FROM `logs-*`\n\t| WHERE (host.name == ? OR host.name == ?) AND service.name == ?' ); expect(pipeline.getBindings()).toEqual(['host4', 'host5', 'service1']); @@ -86,7 +89,7 @@ describe('where', () => { it('handles empty parameters gracefully', () => { const pipeline = source.pipe(where('host.name == ?', '')); - expect(pipeline.asString()).toEqual('FROM `logs-*`\n\t| WHERE host.name == ?'); + expect(pipeline.asQuery()).toEqual('FROM `logs-*`\n\t| WHERE host.name == ?'); expect(pipeline.getBindings()).toEqual(['']); }); @@ -96,7 +99,7 @@ describe('where', () => { where('service.name == ?', 'service1').or('service.name == ?', 'service2') ) ); - expect(pipeline.asString()).toEqual( + expect(pipeline.asQuery()).toEqual( 'FROM `logs-*`\n\t| WHERE (host.name == ? OR host.name == ?) AND (service.name == ? OR service.name == ?)' ); expect(pipeline.getBindings()).toEqual(['host1', 'host2', 'service1', 'service2']); @@ -104,7 +107,7 @@ describe('where', () => { it('handles WHERE clause with object', () => { const pipeline = source.pipe(where({ 'host.name': 'host', 'service.name': 'service' })); - expect(pipeline.asString()).toEqual( + expect(pipeline.asQuery()).toEqual( 'FROM `logs-*`\n\t| WHERE host.name == ? AND service.name == ?' ); expect(pipeline.getBindings()).toEqual(['host', 'service']); @@ -120,7 +123,7 @@ describe('where', () => { 'log.message': 'error message', }) ); - expect(pipeline.asString()).toEqual( + expect(pipeline.asQuery()).toEqual( 'FROM `logs-*`\n\t| WHERE log.level == ? AND (host.name == ? OR host.name == ?) AND log.message == ?' ); expect(pipeline.getBindings()).toEqual(['error', 'host4', 'host5', 'error message']); @@ -141,7 +144,7 @@ describe('where', () => { ) ) ); - expect(pipeline.asString()).toEqual( + expect(pipeline.asQuery()).toEqual( 'FROM `logs-*`\n\t| WHERE host.name == ? AND (log.level == ? OR log.message == ? OR log.message == ? OR log.level == ?) OR ((host.name == ? OR host.name == ?) AND (service.name == ? OR service.name == ?))' ); expect(pipeline.getBindings()).toEqual([ diff --git a/packages/kbn-esql-composer/src/create_pipeline.ts b/packages/kbn-esql-composer/src/create_pipeline.ts index ee88ec2e0ef77..ab02beead2d83 100644 --- a/packages/kbn-esql-composer/src/create_pipeline.ts +++ b/packages/kbn-esql-composer/src/create_pipeline.ts @@ -16,7 +16,58 @@ function isQueryBuilderOperator( return (value as QueryBuilderToOperator).toQueryOperator !== undefined; } +const formatValue = (value: any) => { + return typeof value === 'string' ? `"${value}"` : value; +}; + export function createPipeline(source: Query): QueryPipeline { + const asQuery = () => { + return source.commands.map((command) => command.body).join('\n\t| '); + }; + const getBindings = () => { + return source.bindings.flatMap((binding) => { + if (isObject(binding)) { + return Object.entries(binding).map(([key, value]) => ({ + [key]: value, + })); + } + if (Array.isArray(binding)) { + return binding.map((p) => p); + } + + return binding; + }); + }; + + const asString = () => { + const query = asQuery(); + const bindings = getBindings(); + + let index = 0; + return query.replace(/\\?\?([a-zA-Z0-9_]+)?/g, (match, namedParam) => { + if (match === '\\?') { + return '?'; + } + + if (index < bindings.length) { + if (namedParam) { + const value = bindings[index++]; + if (typeof value === 'object') { + return 'identifier' in value[namedParam] + ? value[namedParam].identifier + : formatValue(value[namedParam]); + } + return value; + } + + const value = bindings[index++]; + return formatValue(value); + } + + return match; + }); + }; + return { pipe: (...operators) => { const nextSource = operators.reduce((previousQuery, operator) => { @@ -29,22 +80,8 @@ export function createPipeline(source: Query): QueryPipeline { return createPipeline(nextSource); }, - asString: () => { - return source.commands.map((command) => command.body).join('\n\t| '); - }, - getBindings: () => { - return source.bindings.flatMap((binding) => { - if (isObject(binding)) { - return Object.entries(binding).map(([key, value]) => ({ - [key]: value, - })); - } - if (Array.isArray(binding)) { - return binding.map((p) => p); - } - - return binding; - }); - }, + asQuery, + asString, + getBindings, }; } diff --git a/packages/kbn-esql-composer/src/index.test.ts b/packages/kbn-esql-composer/src/index.test.ts index bb51e97230fed..0f36338e44add 100644 --- a/packages/kbn-esql-composer/src/index.test.ts +++ b/packages/kbn-esql-composer/src/index.test.ts @@ -22,7 +22,7 @@ describe('composer', () => { stats(`avg_duration = AVG(transaction.duration.us) BY service.name`) ); - expect(pipeline.asString()).toEqual( + expect(pipeline.asQuery()).toEqual( `FROM \`logs-*\`\n\t| WHERE @timestamp <= NOW() AND @timestamp > NOW() - 24 hours\n\t| STATS avg_duration = AVG(transaction.duration.us) BY service.name` ); }); @@ -35,7 +35,7 @@ describe('composer', () => { sort('avg_duration', { '@timestamp': SortOrder.Desc }) ); - expect(pipeline.asString()).toEqual( + expect(pipeline.asQuery()).toEqual( `FROM \`logs-*\`\n\t| WHERE @timestamp <= NOW() AND @timestamp > NOW() - 24 hours\n\t| STATS avg_duration = AVG(transaction.duration.us) BY service.name\n\t| KEEP \`@timestamp\`, \`avg_duration\`, \`service.name\`\n\t| SORT avg_duration ASC, @timestamp DESC` ); }); @@ -59,12 +59,16 @@ describe('composer', () => { 'log.level': 'error', }) .concat('min_duration = MIN(transaction.duration.us)') - .by('service.name'), + .by('?svcName', { svcName: { identifier: 'service.name' } }), sort('avg_duration', { '@timestamp': SortOrder.Desc }) ); + expect(pipeline.asQuery()).toEqual( + `FROM \`logs-*\`\n\t| WHERE host.name == ? AND (log.level == ? OR log.message == ? OR log.message == ? OR log.level == ?) OR ((host.name == ? OR host.name == ?) AND (service.name == ? OR service.name == ?))\n\t| STATS avg_duration = AVG(transaction.duration.us) WHERE log.level == ?, min_duration = MIN(transaction.duration.us) BY ?svcName\n\t| SORT avg_duration ASC, @timestamp DESC` + ); + expect(pipeline.asString()).toEqual( - `FROM \`logs-*\`\n\t| WHERE host.name == ? AND (log.level == ? OR log.message == ? OR log.message == ? OR log.level == ?) OR ((host.name == ? OR host.name == ?) AND (service.name == ? OR service.name == ?))\n\t| STATS avg_duration = AVG(transaction.duration.us) WHERE log.level == ?, min_duration = MIN(transaction.duration.us) BY service.name\n\t| SORT avg_duration ASC, @timestamp DESC` + `FROM \`logs-*\`\n\t| WHERE host.name == "host2" AND (log.level == "warning" OR log.message == "debug" OR log.message == "info" OR log.level == "error") OR ((host.name == "host1" OR host.name == "host2") AND (service.name == "service1" OR service.name == "service2"))\n\t| STATS avg_duration = AVG(transaction.duration.us) WHERE log.level == "error", min_duration = MIN(transaction.duration.us) BY service.name\n\t| SORT avg_duration ASC, @timestamp DESC` ); }); }); diff --git a/packages/kbn-esql-composer/src/types.ts b/packages/kbn-esql-composer/src/types.ts index b704d85a4d032..996eaf9a3d733 100644 --- a/packages/kbn-esql-composer/src/types.ts +++ b/packages/kbn-esql-composer/src/types.ts @@ -24,8 +24,9 @@ export type NamedParameter = Record | NamedParameterWithIdentifi export type Params = NamedParameter | FieldValue | Array; export interface QueryPipeline { pipe: (...args: Array) => QueryPipeline; - asString: () => string; + asQuery: () => string; getBindings: () => Params[]; + asString: () => string; } export interface Query { commands: Command[]; From e4358bd0a2782768c6d0b1206b36f239bfc1afc6 Mon Sep 17 00:00:00 2001 From: Carlos Crespo Date: Mon, 30 Dec 2024 14:52:12 +0100 Subject: [PATCH 3/4] asString and adjustments --- packages/kbn-esql-composer/src/builder.ts | 12 +- .../kbn-esql-composer/src/commands/append.ts | 8 +- .../src/commands/drop.test.ts | 14 +- .../kbn-esql-composer/src/commands/drop.ts | 2 +- .../src/commands/eval.test.ts | 43 +++-- .../kbn-esql-composer/src/commands/eval.ts | 20 +-- .../src/commands/from.test.ts | 4 +- .../kbn-esql-composer/src/commands/from.ts | 4 +- .../src/commands/keep.test.ts | 18 +- .../kbn-esql-composer/src/commands/keep.ts | 2 +- .../src/commands/sort.test.ts | 37 ++-- .../kbn-esql-composer/src/commands/sort.ts | 7 +- .../src/commands/stats.test.ts | 56 ++++-- .../kbn-esql-composer/src/commands/stats.ts | 29 ++-- .../src/commands/where.test.ts | 159 ++++++------------ .../kbn-esql-composer/src/commands/where.ts | 77 ++------- .../kbn-esql-composer/src/create_pipeline.ts | 76 ++++----- packages/kbn-esql-composer/src/index.test.ts | 45 +---- packages/kbn-esql-composer/src/types.ts | 23 +-- .../{escape_identifier.ts => formatters.ts} | 8 + 20 files changed, 280 insertions(+), 364 deletions(-) rename packages/kbn-esql-composer/src/utils/{escape_identifier.ts => formatters.ts} (70%) diff --git a/packages/kbn-esql-composer/src/builder.ts b/packages/kbn-esql-composer/src/builder.ts index ecbef9e63eb5d..c20bdbca8322b 100644 --- a/packages/kbn-esql-composer/src/builder.ts +++ b/packages/kbn-esql-composer/src/builder.ts @@ -13,10 +13,10 @@ import { BuilderCommand, Params, ChainedCommand, - QueryBuilderToOperator, + QueryOperatorConvertible, } from './types'; -export abstract class QueryBuilder implements QueryBuilderToOperator { +export abstract class QueryBuilder implements QueryOperatorConvertible { protected readonly commands: BuilderCommand[] = []; public abstract build(): ChainedCommand; @@ -27,7 +27,7 @@ export abstract class QueryBuilder implements QueryBuilderToOperator { protected buildChain(): ChainedCommand { const commandParts: string[] = []; - const bindingParts: Params[] = []; + const paramsParts: Params[] = []; for (let i = 0; i < this.commands.length; i++) { const currentCondition = this.commands[i]; @@ -41,16 +41,16 @@ export abstract class QueryBuilder implements QueryBuilderToOperator { commandParts.push( currentCondition.nested ? `(${innerCommand.command})` : innerCommand.command ); - bindingParts.push(innerCommand.bindings ?? []); + paramsParts.push(innerCommand.params ?? []); } else { commandParts.push(currentCondition.command); - bindingParts.push(currentCondition.bindings ?? []); + paramsParts.push(currentCondition.params ?? []); } } return { command: commandParts.join(' '), - bindings: bindingParts.flatMap((binding) => binding), + params: paramsParts.flatMap((params) => params), }; } } diff --git a/packages/kbn-esql-composer/src/commands/append.ts b/packages/kbn-esql-composer/src/commands/append.ts index 55aa7957a9c3b..89f7413e2dea2 100644 --- a/packages/kbn-esql-composer/src/commands/append.ts +++ b/packages/kbn-esql-composer/src/commands/append.ts @@ -12,10 +12,10 @@ import { Command, QueryOperator, Params, Query } from '../types'; export function append({ command, - bindings, + params, }: { command: Command | string; - bindings?: Params; + params?: Params; }): QueryOperator { return (source): Query => { const nextCommand = typeof command === 'string' ? { body: command } : command; @@ -23,9 +23,7 @@ export function append({ return { ...source, commands: source.commands.concat(nextCommand), - bindings: !!bindings - ? source.bindings.concat(isObject(bindings) ? bindings : [bindings]) - : source.bindings, + params: !!params ? source.params.concat(isObject(params) ? params : [params]) : source.params, }; }; } diff --git a/packages/kbn-esql-composer/src/commands/drop.test.ts b/packages/kbn-esql-composer/src/commands/drop.test.ts index fec38f23b0b5a..6aabd95f03fbb 100644 --- a/packages/kbn-esql-composer/src/commands/drop.test.ts +++ b/packages/kbn-esql-composer/src/commands/drop.test.ts @@ -13,14 +13,16 @@ import { from } from './from'; describe('drop', () => { const source = from('logs-*'); it('handles single strings', () => { - expect(source.pipe(drop('log.level', 'service.name')).asQuery()).toEqual( - 'FROM `logs-*`\n\t| DROP `log.level`, `service.name`' - ); + const pipeline = source.pipe(drop('log.level', 'service.name')); + const queryRequest = pipeline.asRequest(); + + expect(queryRequest.query).toEqual('FROM `logs-*`\n\t| DROP `log.level`, `service.name`'); }); it('handles arrays of strings', () => { - expect(source.pipe(drop(['log.level', 'service.name'])).asQuery()).toEqual( - 'FROM `logs-*`\n\t| DROP `log.level`, `service.name`' - ); + const pipeline = source.pipe(drop(['log.level', 'service.name'])); + const queryRequest = pipeline.asRequest(); + + expect(queryRequest.query).toEqual('FROM `logs-*`\n\t| DROP `log.level`, `service.name`'); }); }); diff --git a/packages/kbn-esql-composer/src/commands/drop.ts b/packages/kbn-esql-composer/src/commands/drop.ts index e8077aa1886a8..14c6114a0457a 100644 --- a/packages/kbn-esql-composer/src/commands/drop.ts +++ b/packages/kbn-esql-composer/src/commands/drop.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { escapeIdentifier } from '../utils/escape_identifier'; +import { escapeIdentifier } from '../utils/formatters'; import { append } from './append'; export function drop(...columns: Array) { diff --git a/packages/kbn-esql-composer/src/commands/eval.test.ts b/packages/kbn-esql-composer/src/commands/eval.test.ts index da911f58bee7e..ace5d608cd6f1 100644 --- a/packages/kbn-esql-composer/src/commands/eval.test.ts +++ b/packages/kbn-esql-composer/src/commands/eval.test.ts @@ -17,23 +17,10 @@ describe('evaluate', () => { const pipeline = source.pipe( evaluate('type = CASE(languages <= 1, "monolingual",languages <= 2, "bilingual","polyglot")') ); - expect(pipeline.asQuery()).toEqual( - 'FROM `logs-*`\n\t| EVAL type = CASE(languages <= 1, "monolingual",languages <= 2, "bilingual","polyglot")' - ); - expect(pipeline.getBindings()).toEqual([]); - }); - it('handles chained EVAL', () => { - const ids = ['host1', 'host2', 'host3']; - const pipeline = source.pipe( - evaluate('entity.type = ?', 'host') - .concat('entity.display_name = COALESCE(?, entity.id)', 'some_host') - .concat(`entity.id = CONCAT(${ids.map(() => '?').join()})`, ids) - ); - expect(pipeline.asQuery()).toEqual( - 'FROM `logs-*`\n\t| EVAL entity.type = ?, entity.display_name = COALESCE(?, entity.id), entity.id = CONCAT(?,?,?)' + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| EVAL type = CASE(languages <= 1, "monolingual",languages <= 2, "bilingual","polyglot")' ); - expect(pipeline.getBindings()).toEqual(['host', 'some_host', 'host1', 'host2', 'host3']); }); it('handles EVAL with params', () => { @@ -44,14 +31,36 @@ describe('evaluate', () => { }, }) ); + const queryRequest = pipeline.asRequest(); - expect(pipeline.asQuery()).toEqual('FROM `logs-*`\n\t| EVAL hour = DATE_TRUNC(1 hour, ?ts)'); - expect(pipeline.getBindings()).toEqual([ + expect(queryRequest.query).toEqual('FROM `logs-*`\n\t| EVAL hour = DATE_TRUNC(1 hour, ?ts)'); + expect(queryRequest.params).toEqual([ { ts: { identifier: '@timestamp', }, }, ]); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| EVAL hour = DATE_TRUNC(1 hour, `@timestamp`)' + ); + }); + + it('handles chained EVAL with params', () => { + const ids = ['aws', 'host1']; + const pipeline = source.pipe( + evaluate('entity.type = ?', 'host') + .concat('entity.display_name = COALESCE(?, entity.id)', 'some_host') + .concat(`entity.id = CONCAT(${ids.map(() => '?').join()})`, ids) + ); + const queryRequest = pipeline.asRequest(); + + expect(queryRequest.query).toEqual( + 'FROM `logs-*`\n\t| EVAL entity.type = ?, entity.display_name = COALESCE(?, entity.id), entity.id = CONCAT(?,?)' + ); + expect(queryRequest.params).toEqual(['host', 'some_host', 'aws', 'host1']); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| EVAL entity.type = "host", entity.display_name = COALESCE("some_host", entity.id), entity.id = CONCAT("aws","host1")' + ); }); }); diff --git a/packages/kbn-esql-composer/src/commands/eval.ts b/packages/kbn-esql-composer/src/commands/eval.ts index 516ee490db763..309c362d31ca7 100644 --- a/packages/kbn-esql-composer/src/commands/eval.ts +++ b/packages/kbn-esql-composer/src/commands/eval.ts @@ -13,30 +13,30 @@ import { ChainedCommand, Params } from '../types'; const EVAL = 'EVAL'; class EvalBuilder extends QueryBuilder { - private constructor(body: string, bindings?: Params) { + private constructor(body: string, params?: Params) { super(); - this.commands.push({ command: body, bindings, type: EVAL }); + this.commands.push({ command: body, params, type: EVAL }); } - public static create(body: string, bindings?: Params) { - return new EvalBuilder(body, bindings); + public static create(body: string, params?: Params) { + return new EvalBuilder(body, params); } - public concat(body: string, bindings?: Params) { - this.commands.push({ command: body, bindings, type: EVAL }); + public concat(body: string, params?: Params) { + this.commands.push({ command: body, params, type: EVAL }); return this; } public build(): ChainedCommand { - const { command, bindings } = this.buildChain(); + const { command, params } = this.buildChain(); return { command: `${EVAL} ${command.replace(/\s+EVAL/g, ',')}`, - bindings, + params, }; } } -export function evaluate(body: string, bindings?: Params) { - return EvalBuilder.create(body, bindings); +export function evaluate(body: string, params?: Params) { + return EvalBuilder.create(body, params); } diff --git a/packages/kbn-esql-composer/src/commands/from.test.ts b/packages/kbn-esql-composer/src/commands/from.test.ts index 15203d67ef5bb..a3cab67c45466 100644 --- a/packages/kbn-esql-composer/src/commands/from.test.ts +++ b/packages/kbn-esql-composer/src/commands/from.test.ts @@ -11,10 +11,10 @@ import { from } from './from'; describe('from', () => { it('handles single strings', () => { - expect(from('logs-*', 'traces-*').asQuery()).toEqual('FROM `logs-*`,`traces-*`'); + expect(from('logs-*', 'traces-*').asString()).toEqual('FROM `logs-*`,`traces-*`'); }); it('handles arrays of strings', () => { - expect(from(['logs-*', 'traces-*']).asQuery()).toEqual('FROM `logs-*`,`traces-*`'); + expect(from(['logs-*', 'traces-*']).asString()).toEqual('FROM `logs-*`,`traces-*`'); }); }); diff --git a/packages/kbn-esql-composer/src/commands/from.ts b/packages/kbn-esql-composer/src/commands/from.ts index f4dc153b056d4..3cda58111da47 100644 --- a/packages/kbn-esql-composer/src/commands/from.ts +++ b/packages/kbn-esql-composer/src/commands/from.ts @@ -9,7 +9,7 @@ import { createPipeline } from '../create_pipeline'; import type { QueryPipeline } from '../types'; -import { escapeIdentifier } from '../utils/escape_identifier'; +import { escapeIdentifier } from '../utils/formatters'; export function from(...patterns: Array): QueryPipeline { const allPatterns = patterns.flatMap((pattern) => pattern); @@ -20,6 +20,6 @@ export function from(...patterns: Array): QueryPipeline { body: `FROM ${allPatterns.map((pattern) => escapeIdentifier(pattern)).join(',')}`, }, ], - bindings: [], + params: [], }); } diff --git a/packages/kbn-esql-composer/src/commands/keep.test.ts b/packages/kbn-esql-composer/src/commands/keep.test.ts index 9d40446979b97..6ef1bf07ab622 100644 --- a/packages/kbn-esql-composer/src/commands/keep.test.ts +++ b/packages/kbn-esql-composer/src/commands/keep.test.ts @@ -12,15 +12,17 @@ import { keep } from './keep'; describe('keep', () => { const source = from('logs-*'); - it('handles single strings', () => { - expect(source.pipe(keep('log.level', 'service.name')).asQuery()).toEqual( - 'FROM `logs-*`\n\t| KEEP `log.level`, `service.name`' - ); + it('should build KEEP from single strings', () => { + const pipeline = source.pipe(keep('log.level', 'service.name')); + const queryRequest = pipeline.asRequest(); + + expect(queryRequest.query).toEqual('FROM `logs-*`\n\t| KEEP `log.level`, `service.name`'); }); - it('handles arrays of strings', () => { - expect(source.pipe(keep(['log.level', 'service.name'])).asQuery()).toEqual( - 'FROM `logs-*`\n\t| KEEP `log.level`, `service.name`' - ); + it('should build KEEP from array of strings', () => { + const pipeline = source.pipe(keep(['log.level', 'service.name'])); + const queryRequest = pipeline.asRequest(); + + expect(queryRequest.query).toEqual('FROM `logs-*`\n\t| KEEP `log.level`, `service.name`'); }); }); diff --git a/packages/kbn-esql-composer/src/commands/keep.ts b/packages/kbn-esql-composer/src/commands/keep.ts index 755817265f69f..29f8e224d5f21 100644 --- a/packages/kbn-esql-composer/src/commands/keep.ts +++ b/packages/kbn-esql-composer/src/commands/keep.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { escapeIdentifier } from '../utils/escape_identifier'; +import { escapeIdentifier } from '../utils/formatters'; import { append } from './append'; export function keep(...columns: Array) { diff --git a/packages/kbn-esql-composer/src/commands/sort.test.ts b/packages/kbn-esql-composer/src/commands/sort.test.ts index ca90a70f63540..0e73c5ecdeb44 100644 --- a/packages/kbn-esql-composer/src/commands/sort.test.ts +++ b/packages/kbn-esql-composer/src/commands/sort.test.ts @@ -15,33 +15,38 @@ describe('sort', () => { it('handles single strings', () => { const pipeline = source.pipe(sort('@timestamp', 'log.level')); - expect(pipeline.asQuery()).toEqual('FROM `logs-*`\n\t| SORT @timestamp ASC, log.level ASC'); - expect(pipeline.getBindings()).toEqual([]); + + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| SORT `@timestamp` ASC, `log.level` ASC' + ); }); it('handles SORT with SortOrder', () => { const pipeline = source.pipe(sort({ '@timestamp': SortOrder.Desc })); - expect(pipeline.asQuery()).toEqual('FROM `logs-*`\n\t| SORT @timestamp DESC'); - expect(pipeline.getBindings()).toEqual([]); + + expect(pipeline.asString()).toEqual('FROM `logs-*`\n\t| SORT `@timestamp` DESC'); }); it('handles a mix of strings and SortOrder instructions', () => { const pipeline = source.pipe(sort('@timestamp', { 'log.level': SortOrder.Desc })); - expect(pipeline.asQuery()).toEqual('FROM `logs-*`\n\t| SORT @timestamp ASC, log.level DESC'); - expect(pipeline.getBindings()).toEqual([]); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| SORT `@timestamp` ASC, `log.level` DESC' + ); }); - it('handles nested sort arrays', () => { + it('handles sort arrays', () => { const pipeline = source.pipe(sort(['@timestamp', { 'log.level': SortOrder.Asc }])); - expect(pipeline.asQuery()).toEqual('FROM `logs-*`\n\t| SORT @timestamp ASC, log.level ASC'); - expect(pipeline.getBindings()).toEqual([]); + + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| SORT `@timestamp` ASC, `log.level` ASC' + ); }); it('handles SORT with params', () => { const pipeline = source.pipe( sortRaw('?timestamp DESC, ?logLevel ASC', { - env: { + timestamp: { identifier: '@timestamp', }, logLevel: { @@ -49,11 +54,11 @@ describe('sort', () => { }, }) ); - - expect(pipeline.asQuery()).toEqual('FROM `logs-*`\n\t| SORT ?timestamp DESC, ?logLevel ASC'); - expect(pipeline.getBindings()).toEqual([ + const queryRequest = pipeline.asRequest(); + expect(queryRequest.query).toEqual('FROM `logs-*`\n\t| SORT ?timestamp DESC, ?logLevel ASC'); + expect(queryRequest.params).toEqual([ { - env: { + timestamp: { identifier: '@timestamp', }, }, @@ -63,5 +68,9 @@ describe('sort', () => { }, }, ]); + + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| SORT `@timestamp` DESC, `log.level` ASC' + ); }); }); diff --git a/packages/kbn-esql-composer/src/commands/sort.ts b/packages/kbn-esql-composer/src/commands/sort.ts index d353978da1b3c..d6bf76202046c 100644 --- a/packages/kbn-esql-composer/src/commands/sort.ts +++ b/packages/kbn-esql-composer/src/commands/sort.ts @@ -8,6 +8,7 @@ */ import { NamedParameterWithIdentifier, QueryOperator } from '../types'; +import { formatColumn } from '../utils/formatters'; import { append } from './append'; export enum SortOrder { @@ -20,8 +21,8 @@ type Sort = Record; type SortArgs = Sort | string | Array; // TODO: a better name? -export function sortRaw(body: string, bindings?: NamedParameterWithIdentifier): QueryOperator { - return append({ command: `SORT ${body}`, bindings }); +export function sortRaw(body: string, params?: NamedParameterWithIdentifier): QueryOperator { + return append({ command: `SORT ${body}`, params }); } export function sort(...sorts: SortArgs[]): QueryOperator { @@ -40,7 +41,7 @@ export function sort(...sorts: SortArgs[]): QueryOperator { }); const command = `SORT ${allSorts - .map((sortInstruction) => `${sortInstruction.column} ${sortInstruction.order}`) + .map((sortInstruction) => `${formatColumn(sortInstruction.column)} ${sortInstruction.order}`) .join(', ')}`; return append({ command }); diff --git a/packages/kbn-esql-composer/src/commands/stats.test.ts b/packages/kbn-esql-composer/src/commands/stats.test.ts index 0f557e17556eb..c9dbed1f11049 100644 --- a/packages/kbn-esql-composer/src/commands/stats.test.ts +++ b/packages/kbn-esql-composer/src/commands/stats.test.ts @@ -17,13 +17,13 @@ describe('stats', () => { const pipeline = source.pipe( stats('avg_duration = AVG(transaction.duration.us) BY service.name') ); - expect(pipeline.asQuery()).toEqual( + + expect(pipeline.asString()).toEqual( 'FROM `logs-*`\n\t| STATS avg_duration = AVG(transaction.duration.us) BY service.name' ); - expect(pipeline.getBindings()).toEqual([]); }); - it('handles STATS with bindings', () => { + it('handles STATS with params', () => { const pipeline = source.pipe( stats('AVG(?duration), COUNT(?svcName) BY ?env', { duration: { @@ -37,10 +37,12 @@ describe('stats', () => { }, }) ); - expect(pipeline.asQuery()).toEqual( + const queryRequest = pipeline.asRequest(); + + expect(queryRequest.query).toEqual( 'FROM `logs-*`\n\t| STATS AVG(?duration), COUNT(?svcName) BY ?env' ); - expect(pipeline.getBindings()).toEqual([ + expect(queryRequest.params).toEqual([ { duration: { identifier: 'transaction.duration.us', @@ -57,6 +59,9 @@ describe('stats', () => { }, }, ]); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| STATS AVG(`transaction.duration.us`), COUNT(`service.name`) BY `service.environment`' + ); }); it('handles STATS with WHERE and BY', () => { @@ -67,22 +72,29 @@ describe('stats', () => { }) .by('service.name') ); - expect(pipeline.asQuery()).toEqual( - 'FROM `logs-*`\n\t| STATS avg_duration = AVG(transaction.duration.us) WHERE log.level == ? BY service.name' + const queryRequest = pipeline.asRequest(); + + expect(queryRequest.query).toEqual( + 'FROM `logs-*`\n\t| STATS avg_duration = AVG(transaction.duration.us) WHERE `log.level` == ? BY `service.name`' + ); + expect(queryRequest.params).toEqual(['error']); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| STATS avg_duration = AVG(transaction.duration.us) WHERE `log.level` == "error" BY `service.name`' ); - expect(pipeline.getBindings()).toEqual(['error']); }); - it('handles STATS and BY with bindings', () => { + it('handles STATS and BY with params', () => { const pipeline = source.pipe( stats('avg_duration = AVG(transaction.duration.us)').by('?svcName', { svcName: { identifier: 'service.name' }, }) ); - expect(pipeline.asQuery()).toEqual( + const queryRequest = pipeline.asRequest(); + + expect(queryRequest.query).toEqual( 'FROM `logs-*`\n\t| STATS avg_duration = AVG(transaction.duration.us) BY ?svcName' ); - expect(pipeline.getBindings()).toEqual([ + expect(queryRequest.params).toEqual([ { svcName: { identifier: 'service.name', @@ -98,10 +110,12 @@ describe('stats', () => { svcEnv: { identifier: 'service.environment' }, }) ); - expect(pipeline.asQuery()).toEqual( + const queryRequest = pipeline.asRequest(); + + expect(queryRequest.query).toEqual( 'FROM `logs-*`\n\t| STATS avg_duration = AVG(transaction.duration.us) BY ?svcName, ?svcEnv' ); - expect(pipeline.getBindings()).toEqual([ + expect(queryRequest.params).toEqual([ { svcName: { identifier: 'service.name', @@ -113,22 +127,30 @@ describe('stats', () => { }, }, ]); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| STATS avg_duration = AVG(transaction.duration.us) BY `service.name`, `service.environment`' + ); }); it('handles multiple chained STATS', () => { const pipeline = source.pipe( stats('avg_duration = AVG(transaction.duration.us)') .concat('max_duration = MAX(transaction.duration.us)') - .where('@timestamp > ?', '2021-01-01') + .where('@timestamp > ?', new Date('2025-01-01').toISOString()) .concat('min_duration = MIN(transaction.duration.us)') .where({ 'service.name': 'service2', }) .by('service.environment') ); - expect(pipeline.asQuery()).toEqual( - 'FROM `logs-*`\n\t| STATS avg_duration = AVG(transaction.duration.us), max_duration = MAX(transaction.duration.us) WHERE @timestamp > ?, min_duration = MIN(transaction.duration.us) WHERE service.name == ? BY service.environment' + const queryRequest = pipeline.asRequest(); + + expect(queryRequest.query).toEqual( + 'FROM `logs-*`\n\t| STATS avg_duration = AVG(transaction.duration.us), max_duration = MAX(transaction.duration.us) WHERE @timestamp > ?, min_duration = MIN(transaction.duration.us) WHERE `service.name` == ? BY `service.environment`' + ); + expect(queryRequest.params).toEqual(['2025-01-01T00:00:00.000Z', 'service2']); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| STATS avg_duration = AVG(transaction.duration.us), max_duration = MAX(transaction.duration.us) WHERE @timestamp > "2025-01-01T00:00:00.000Z", min_duration = MIN(transaction.duration.us) WHERE `service.name` == "service2" BY `service.environment`' ); - expect(pipeline.getBindings()).toEqual(['2021-01-01', 'service2']); }); }); diff --git a/packages/kbn-esql-composer/src/commands/stats.ts b/packages/kbn-esql-composer/src/commands/stats.ts index 5ed1c239b4951..bf977bcae9e09 100644 --- a/packages/kbn-esql-composer/src/commands/stats.ts +++ b/packages/kbn-esql-composer/src/commands/stats.ts @@ -9,6 +9,7 @@ import { QueryBuilder } from '../builder'; import { ChainedCommand, NamedParameterWithIdentifier } from '../types'; +import { formatColumn } from '../utils/formatters'; import { where } from './where'; const STATS = 'STATS'; @@ -16,24 +17,26 @@ const STATS = 'STATS'; type StatsAfterWhere = Omit; type StatsAfterBy = Omit; class StatsBuilder extends QueryBuilder { - private constructor(body: string, bindings?: NamedParameterWithIdentifier) { + private constructor(body: string, params?: NamedParameterWithIdentifier) { super(); - this.commands.push({ command: body, bindings, type: STATS }); + this.commands.push({ command: body, params, type: STATS }); } - public static create(body: string, bindings?: NamedParameterWithIdentifier) { - return new StatsBuilder(body, bindings); + public static create(body: string, params?: NamedParameterWithIdentifier) { + return new StatsBuilder(body, params); } - public concat(body: string, bindings?: NamedParameterWithIdentifier) { - this.commands.push({ command: body, bindings, type: STATS }); + public concat(body: string, params?: NamedParameterWithIdentifier) { + this.commands.push({ command: body, params, type: STATS }); return this; } - public by(criteria: string | string[], bindings?: NamedParameterWithIdentifier) { + public by(column: string | string[], params?: NamedParameterWithIdentifier) { this.commands.push({ - command: Array.isArray(criteria) ? criteria.join(', ') : criteria, - bindings, + command: (Array.isArray(column) ? column : [column]) + .map((columnName) => formatColumn(columnName)) + .join(', '), + params, type: 'BY', }); @@ -50,15 +53,15 @@ class StatsBuilder extends QueryBuilder { } public build(): ChainedCommand { - const { command, bindings } = this.buildChain(); + const { command, params } = this.buildChain(); return { command: `${STATS} ${command.replace(/\s+STATS/g, ',')}`, - bindings, + params, }; } } -export function stats(body: string, bindings?: NamedParameterWithIdentifier) { - return StatsBuilder.create(body, bindings); +export function stats(body: string, params?: NamedParameterWithIdentifier) { + return StatsBuilder.create(body, params); } diff --git a/packages/kbn-esql-composer/src/commands/where.test.ts b/packages/kbn-esql-composer/src/commands/where.test.ts index e7db1d60039f8..ec38d2077b162 100644 --- a/packages/kbn-esql-composer/src/commands/where.test.ts +++ b/packages/kbn-esql-composer/src/commands/where.test.ts @@ -13,150 +13,97 @@ describe('where', () => { const source = from('logs-*'); it('appends a basic WHERE clause', () => { - const pipeline = source.pipe( - where(`@timestamp <= NOW() AND @timestamp > NOW() - 24 hours`), - where('log.level', '==', 'error') - ); - expect(pipeline.asQuery()).toEqual( - 'FROM `logs-*`\n\t| WHERE @timestamp <= NOW() AND @timestamp > NOW() - 24 hours\n\t| WHERE log.level == ?' + const pipeline = source.pipe(where(`@timestamp <= NOW() AND @timestamp > NOW() - 24 hours`)); + + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| WHERE @timestamp <= NOW() AND @timestamp > NOW() - 24 hours' ); - expect(pipeline.getBindings()).toEqual(['error']); }); it('appends a WHERE clause with positional parameters', () => { - const pipeline = source.pipe(where('host.id == ?', 'host')); - expect(pipeline.asQuery()).toEqual('FROM `logs-*`\n\t| WHERE host.id == ?'); - expect(pipeline.getBindings()).toEqual(['host']); + const pipeline = source.pipe(where('timestamp.us >= ?', 1704892605838000)); + const queryRequest = pipeline.asRequest(); + + expect(queryRequest.query).toEqual('FROM `logs-*`\n\t| WHERE timestamp.us >= ?'); + expect(queryRequest.params).toEqual([1704892605838000]); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| WHERE timestamp.us >= 1704892605838000' + ); + }); + + it('handles WHERE clause with object', () => { + const pipeline = source.pipe(where({ 'host.name': 'host', 'service.name': 'service' })); + const queryRequest = pipeline.asRequest(); + + expect(queryRequest.query).toEqual( + 'FROM `logs-*`\n\t| WHERE `host.name` == ? AND `service.name` == ?' + ); + expect(queryRequest.params).toEqual(['host', 'service']); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| WHERE `host.name` == "host" AND `service.name` == "service"' + ); }); it('appends a WHERE clause with named parameters', () => { const params = { hostName: 'host', serviceName: 'service' }; const pipeline = source.pipe( - where('host.name = ?hostName AND service.name = ?serviceName', params) + where('host.name == ?hostName AND service.name == ?serviceName', params) ); - expect(pipeline.asQuery()).toEqual( - 'FROM `logs-*`\n\t| WHERE host.name = ?hostName AND service.name = ?serviceName' + const queryRequest = pipeline.asRequest(); + + expect(queryRequest.query).toEqual( + 'FROM `logs-*`\n\t| WHERE host.name == ?hostName AND service.name == ?serviceName' ); - expect(pipeline.getBindings()).toEqual([{ hostName: 'host' }, { serviceName: 'service' }]); + expect(queryRequest.params).toEqual([{ hostName: 'host' }, { serviceName: 'service' }]); expect(pipeline.asString()).toEqual( - 'FROM `logs-*`\n\t| WHERE host.name = "host" AND service.name = "service"' + 'FROM `logs-*`\n\t| WHERE host.name == "host" AND service.name == "service"' ); }); it('handles WHERE clause with IN operator and positional parameters', () => { const hosts = ['host1', 'host2', 'host3']; const pipeline = source.pipe(where(`host.name IN (${hosts.map(() => '?').join(',')})`, hosts)); - expect(pipeline.asQuery()).toEqual('FROM `logs-*`\n\t| WHERE host.name IN (?,?,?)'); - expect(pipeline.getBindings()).toEqual(['host1', 'host2', 'host3']); - }); - - it('handles nested WHERE clauses with OR conditions', () => { - const pipeline = source.pipe( - where(() => where('host.name == ?', 'host4').or('host.name', '==', 'host5')) - ); - expect(pipeline.asQuery()).toEqual( - 'FROM `logs-*`\n\t| WHERE (host.name == ? OR host.name == ?)' - ); - expect(pipeline.getBindings()).toEqual(['host4', 'host5']); - }); + const queryRequest = pipeline.asRequest(); - it('handles nested WHERE clauses with AND and OR combinations', () => { - const hosts = ['host1', 'host2', 'host3']; - const pipeline = source.pipe( - where(() => where('host.name == ?', 'host4').or('host.name', '==', 'host5')).and( - `host.name IN (${hosts.map(() => '?').join(',')})`, - hosts - ) - ); - expect(pipeline.asQuery()).toEqual( - 'FROM `logs-*`\n\t| WHERE (host.name == ? OR host.name == ?) AND host.name IN (?,?,?)' + expect(queryRequest.query).toEqual('FROM `logs-*`\n\t| WHERE host.name IN (?,?,?)'); + expect(queryRequest.params).toEqual(['host1', 'host2', 'host3']); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| WHERE host.name IN ("host1","host2","host3")' ); - expect(pipeline.getBindings()).toEqual(['host4', 'host5', 'host1', 'host2', 'host3']); }); - it('handles multiple nested WHERE clauses', () => { + it('handles WHERE with nested OR conditions', () => { const pipeline = source.pipe( - where(() => where('host.name == ?', 'host4').or('host.name', '==', 'host5')).and( + where(() => where('host.name == ?', 'host4').or('host.name == ?', 'host5')).and( 'service.name == ?', 'service1' ) ); - expect(pipeline.asQuery()).toEqual( + const queryRequest = pipeline.asRequest(); + + expect(queryRequest.query).toEqual( 'FROM `logs-*`\n\t| WHERE (host.name == ? OR host.name == ?) AND service.name == ?' ); - expect(pipeline.getBindings()).toEqual(['host4', 'host5', 'service1']); - }); - - it('handles empty parameters gracefully', () => { - const pipeline = source.pipe(where('host.name == ?', '')); - expect(pipeline.asQuery()).toEqual('FROM `logs-*`\n\t| WHERE host.name == ?'); - expect(pipeline.getBindings()).toEqual(['']); + expect(queryRequest.params).toEqual(['host4', 'host5', 'service1']); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| WHERE (host.name == "host4" OR host.name == "host5") AND service.name == "service1"' + ); }); - it('handles deeply nested queries', () => { + it('handles WHERE with multiple nested clauses', () => { const pipeline = source.pipe( where(() => where('host.name == ?', 'host1').or('host.name == ?', 'host2')).and(() => where('service.name == ?', 'service1').or('service.name == ?', 'service2') ) ); - expect(pipeline.asQuery()).toEqual( - 'FROM `logs-*`\n\t| WHERE (host.name == ? OR host.name == ?) AND (service.name == ? OR service.name == ?)' - ); - expect(pipeline.getBindings()).toEqual(['host1', 'host2', 'service1', 'service2']); - }); - - it('handles WHERE clause with object', () => { - const pipeline = source.pipe(where({ 'host.name': 'host', 'service.name': 'service' })); - expect(pipeline.asQuery()).toEqual( - 'FROM `logs-*`\n\t| WHERE host.name == ? AND service.name == ?' - ); - expect(pipeline.getBindings()).toEqual(['host', 'service']); - }); - - it('handles WHERE clause with object with AND and OR combinations', () => { - const pipeline = source.pipe( - where({ - 'log.level': 'error', - }) - .and(() => where('host.name == ?', 'host4').or('host.name', '==', 'host5')) - .and({ - 'log.message': 'error message', - }) - ); - expect(pipeline.asQuery()).toEqual( - 'FROM `logs-*`\n\t| WHERE log.level == ? AND (host.name == ? OR host.name == ?) AND log.message == ?' - ); - expect(pipeline.getBindings()).toEqual(['error', 'host4', 'host5', 'error message']); - }); + const queryRequest = pipeline.asRequest(); - it('handles WHERE clause with with many OR groups and nested conditions', () => { - const pipeline = source.pipe( - where('host.name == ?', 'host2') - .and(() => - where({ 'log.level': 'warning' }) - .or({ 'log.message': 'debug' }) - .or({ 'log.message': 'info' }) - .or({ 'log.level': 'error' }) - ) - .or(() => - where(() => where('host.name == ?', 'host1').or('host.name == ?', 'host2')).and(() => - where('service.name == ?', 'service1').or('service.name == ?', 'service2') - ) - ) + expect(queryRequest.query).toEqual( + 'FROM `logs-*`\n\t| WHERE (host.name == ? OR host.name == ?) AND (service.name == ? OR service.name == ?)' ); - expect(pipeline.asQuery()).toEqual( - 'FROM `logs-*`\n\t| WHERE host.name == ? AND (log.level == ? OR log.message == ? OR log.message == ? OR log.level == ?) OR ((host.name == ? OR host.name == ?) AND (service.name == ? OR service.name == ?))' + expect(queryRequest.params).toEqual(['host1', 'host2', 'service1', 'service2']); + expect(pipeline.asString()).toEqual( + 'FROM `logs-*`\n\t| WHERE (host.name == "host1" OR host.name == "host2") AND (service.name == "service1" OR service.name == "service2")' ); - expect(pipeline.getBindings()).toEqual([ - 'host2', - 'warning', - 'debug', - 'info', - 'error', - 'host1', - 'host2', - 'service1', - 'service2', - ]); }); }); diff --git a/packages/kbn-esql-composer/src/commands/where.ts b/packages/kbn-esql-composer/src/commands/where.ts index 9a134e69c5963..45e0553d093ba 100644 --- a/packages/kbn-esql-composer/src/commands/where.ts +++ b/packages/kbn-esql-composer/src/commands/where.ts @@ -9,74 +9,50 @@ import { QueryBuilder } from '../builder'; import { Params, FieldValue, ChainedCommand } from '../types'; - -const operators = ['==', '>', '<', '!=', '>=', '<='] as const; +import { formatColumn } from '../utils/formatters'; const WHERE = 'WHERE'; type WhereCriteria = string | (() => WhereBuilder) | Record; -type Operators = (typeof operators)[number]; type LogicalOperator = 'AND' | 'OR'; -function isOperator(value?: Params | Operators): value is Operators { - return !!value && operators.includes(value as Operators); -} - class WhereBuilder extends QueryBuilder { - private constructor( - criteria: WhereCriteria, - operatorOrBindings?: Operators | Params, - params?: Params - ) { + private constructor(criteria: WhereCriteria, params?: Params) { super(); - this.push(criteria, operatorOrBindings, params); + this.push(criteria, params); } - public static create( - criteria: WhereCriteria, - operatorOrBindings?: Operators | Params, - params?: Params - ) { - return new WhereBuilder(criteria, operatorOrBindings, params); + public static create(criteria: WhereCriteria, params?: Params) { + return new WhereBuilder(criteria, params); } - public and( - criteria: WhereCriteria, - operatorOrParams?: Operators | Params, - bindings?: Params - ): WhereBuilder { - return this.addCondition('AND', criteria, operatorOrParams, bindings); + public and(criteria: WhereCriteria, params?: Params): WhereBuilder { + return this.addCondition('AND', criteria, params); } - public or( - criteria: WhereCriteria, - operatorOrParams?: Operators | Params, - bindings?: Params - ): WhereBuilder { - return this.addCondition('OR', criteria, operatorOrParams, bindings); + public or(criteria: WhereCriteria, params?: Params): WhereBuilder { + return this.addCondition('OR', criteria, params); } public build(): ChainedCommand { - const { command, bindings } = this.buildChain(); + const { command, params } = this.buildChain(); return { command: `${WHERE} ${command}`, - bindings, + params, }; } private addCondition( logicalOperator: LogicalOperator, body: WhereCriteria, - operatorOrParams?: Operators | Params, - bindings?: Params + params?: Params ): WhereBuilder { - return this.push(body, operatorOrParams, bindings, logicalOperator); + return this.push(body, params, logicalOperator); } private push( criteria: WhereCriteria, - operatorOrBindings?: Operators | Params, params?: Params, type: LogicalOperator | typeof WHERE = 'WHERE' ) { @@ -91,28 +67,15 @@ class WhereBuilder extends QueryBuilder { // Handle object with named parameters const keys = Object.keys(criteria); this.commands.push({ - command: keys.map((key) => `${key} == ?`).join(' AND '), - bindings: keys.map((key) => criteria[key]), - type, - }); - } else if ( - !Array.isArray(operatorOrBindings) && - isOperator(operatorOrBindings) && - params !== undefined - ) { - // Handle explicit operator and parameter - const operator = operatorOrBindings; - this.commands.push({ - command: `${criteria} ${operator} ?`, - bindings: params, + command: keys.map((key) => `${formatColumn(key)} == ?`).join(' AND '), + params: keys.map((key) => criteria[key]), type, }); } else { // Handle raw and nested conditions - const bindings = operatorOrBindings; this.commands.push({ command: criteria, - bindings, + params, type, }); } @@ -121,10 +84,6 @@ class WhereBuilder extends QueryBuilder { } } -export function where( - criteria: WhereCriteria, - operatorOrParams?: Operators | Params, - bindings?: Params -): WhereBuilder { - return WhereBuilder.create(criteria, operatorOrParams, bindings); +export function where(criteria: WhereCriteria, params?: Params): WhereBuilder { + return WhereBuilder.create(criteria, params); } diff --git a/packages/kbn-esql-composer/src/create_pipeline.ts b/packages/kbn-esql-composer/src/create_pipeline.ts index ab02beead2d83..2afeee63115bd 100644 --- a/packages/kbn-esql-composer/src/create_pipeline.ts +++ b/packages/kbn-esql-composer/src/create_pipeline.ts @@ -8,59 +8,58 @@ */ import { isObject } from 'lodash'; -import { Query, QueryBuilderToOperator, QueryOperator, QueryPipeline } from './types'; +import { + Query, + QueryOperatorConvertible, + QueryOperator, + QueryPipeline, + QueryRequest, + NamedParameter, + Params, +} from './types'; +import { escapeIdentifier, formatValue } from './utils/formatters'; -function isQueryBuilderOperator( - value: QueryOperator | QueryBuilderToOperator -): value is QueryBuilderToOperator { - return (value as QueryBuilderToOperator).toQueryOperator !== undefined; +function isQueryOperatorConvertible( + value: QueryOperator | QueryOperatorConvertible +): value is QueryOperatorConvertible { + return (value as QueryOperatorConvertible).toQueryOperator !== undefined; } -const formatValue = (value: any) => { - return typeof value === 'string' ? `"${value}"` : value; -}; - export function createPipeline(source: Query): QueryPipeline { - const asQuery = () => { - return source.commands.map((command) => command.body).join('\n\t| '); - }; - const getBindings = () => { - return source.bindings.flatMap((binding) => { - if (isObject(binding)) { - return Object.entries(binding).map(([key, value]) => ({ - [key]: value, - })); + const asRequest = (): QueryRequest => { + const params = source.params.flatMap((param) => { + if (isObject(param)) { + return Object.entries(param).map(([key, value]) => ({ [key]: value })); } - if (Array.isArray(binding)) { - return binding.map((p) => p); + if (Array.isArray(param)) { + return param.map((p) => p); } - - return binding; + return param; }); + + return { + query: source.commands.map((command) => command.body).join('\n\t| '), + params, + }; }; const asString = () => { - const query = asQuery(); - const bindings = getBindings(); + const { query, params } = asRequest(); let index = 0; - return query.replace(/\\?\?([a-zA-Z0-9_]+)?/g, (match, namedParam) => { - if (match === '\\?') { - return '?'; - } + return query.replace(/\?([a-zA-Z0-9_]+)?/g, (match, namedParam) => { + if (index < params.length) { + const value = params[index++]; - if (index < bindings.length) { if (namedParam) { - const value = bindings[index++]; - if (typeof value === 'object') { - return 'identifier' in value[namedParam] - ? value[namedParam].identifier - : formatValue(value[namedParam]); + if (isObject(value)) { + const paramValue = (value as NamedParameter)[namedParam]; + return isObject(paramValue) && 'identifier' in paramValue + ? escapeIdentifier(paramValue.identifier) + : formatValue(paramValue); } - return value; } - const value = bindings[index++]; return formatValue(value); } @@ -71,7 +70,7 @@ export function createPipeline(source: Query): QueryPipeline { return { pipe: (...operators) => { const nextSource = operators.reduce((previousQuery, operator) => { - if (isQueryBuilderOperator(operator)) { + if (isQueryOperatorConvertible(operator)) { return operator.toQueryOperator()(previousQuery); } @@ -80,8 +79,7 @@ export function createPipeline(source: Query): QueryPipeline { return createPipeline(nextSource); }, - asQuery, + asRequest, asString, - getBindings, }; } diff --git a/packages/kbn-esql-composer/src/index.test.ts b/packages/kbn-esql-composer/src/index.test.ts index 0f36338e44add..5fa6db9c5ff56 100644 --- a/packages/kbn-esql-composer/src/index.test.ts +++ b/packages/kbn-esql-composer/src/index.test.ts @@ -17,17 +17,6 @@ describe('composer', () => { const source = from('logs-*'); it('applies operators in order', () => { - const pipeline = source.pipe( - where(`@timestamp <= NOW() AND @timestamp > NOW() - 24 hours`), - stats(`avg_duration = AVG(transaction.duration.us) BY service.name`) - ); - - expect(pipeline.asQuery()).toEqual( - `FROM \`logs-*\`\n\t| WHERE @timestamp <= NOW() AND @timestamp > NOW() - 24 hours\n\t| STATS avg_duration = AVG(transaction.duration.us) BY service.name` - ); - }); - - it('applies many operators', () => { const pipeline = source.pipe( where(`@timestamp <= NOW() AND @timestamp > NOW() - 24 hours`), stats(`avg_duration = AVG(transaction.duration.us) BY service.name`), @@ -35,40 +24,8 @@ describe('composer', () => { sort('avg_duration', { '@timestamp': SortOrder.Desc }) ); - expect(pipeline.asQuery()).toEqual( - `FROM \`logs-*\`\n\t| WHERE @timestamp <= NOW() AND @timestamp > NOW() - 24 hours\n\t| STATS avg_duration = AVG(transaction.duration.us) BY service.name\n\t| KEEP \`@timestamp\`, \`avg_duration\`, \`service.name\`\n\t| SORT avg_duration ASC, @timestamp DESC` - ); - }); - - it('applies many operators with fluent', () => { - const pipeline = source.pipe( - where('host.name == ?', 'host2') - .and(() => - where({ 'log.level': 'warning' }) - .or({ 'log.message': 'debug' }) - .or({ 'log.message': 'info' }) - .or({ 'log.level': 'error' }) - ) - .or(() => - where(() => where('host.name == ?', 'host1').or('host.name == ?', 'host2')).and(() => - where('service.name == ?', 'service1').or('service.name == ?', 'service2') - ) - ), - stats('avg_duration = AVG(transaction.duration.us)') - .where({ - 'log.level': 'error', - }) - .concat('min_duration = MIN(transaction.duration.us)') - .by('?svcName', { svcName: { identifier: 'service.name' } }), - sort('avg_duration', { '@timestamp': SortOrder.Desc }) - ); - - expect(pipeline.asQuery()).toEqual( - `FROM \`logs-*\`\n\t| WHERE host.name == ? AND (log.level == ? OR log.message == ? OR log.message == ? OR log.level == ?) OR ((host.name == ? OR host.name == ?) AND (service.name == ? OR service.name == ?))\n\t| STATS avg_duration = AVG(transaction.duration.us) WHERE log.level == ?, min_duration = MIN(transaction.duration.us) BY ?svcName\n\t| SORT avg_duration ASC, @timestamp DESC` - ); - expect(pipeline.asString()).toEqual( - `FROM \`logs-*\`\n\t| WHERE host.name == "host2" AND (log.level == "warning" OR log.message == "debug" OR log.message == "info" OR log.level == "error") OR ((host.name == "host1" OR host.name == "host2") AND (service.name == "service1" OR service.name == "service2"))\n\t| STATS avg_duration = AVG(transaction.duration.us) WHERE log.level == "error", min_duration = MIN(transaction.duration.us) BY service.name\n\t| SORT avg_duration ASC, @timestamp DESC` + 'FROM `logs-*`\n\t| WHERE @timestamp <= NOW() AND @timestamp > NOW() - 24 hours\n\t| STATS avg_duration = AVG(transaction.duration.us) BY service.name\n\t| KEEP `@timestamp`, `avg_duration`, `service.name`\n\t| SORT `avg_duration` ASC, `@timestamp` DESC' ); }); }); diff --git a/packages/kbn-esql-composer/src/types.ts b/packages/kbn-esql-composer/src/types.ts index 996eaf9a3d733..319cc54e6aadb 100644 --- a/packages/kbn-esql-composer/src/types.ts +++ b/packages/kbn-esql-composer/src/types.ts @@ -15,37 +15,38 @@ export interface Command { // Replace export type FieldValue = number | string | boolean | null; -export type NamedParameterWithIdentifier = Record< - string, - { identifier: string } | { pattern: string } ->; +export type NamedParameterWithIdentifier = Record; export type NamedParameter = Record | NamedParameterWithIdentifier; export type Params = NamedParameter | FieldValue | Array; export interface QueryPipeline { - pipe: (...args: Array) => QueryPipeline; - asQuery: () => string; - getBindings: () => Params[]; + pipe: (...args: Array) => QueryPipeline; + asRequest: () => QueryRequest; asString: () => string; } export interface Query { commands: Command[]; - bindings: Params[]; + params: Params[]; +} + +export interface QueryRequest { + query: string; + params: Params[]; } export type QueryOperator = (sourceQuery: Query) => Query; export interface BuilderCommand { command: string | (() => QueryBuilder); - bindings?: Params; + params?: Params; type: TType; nested?: boolean; } export interface ChainedCommand { command: string; - bindings?: Params; + params?: Params; } -export interface QueryBuilderToOperator { +export interface QueryOperatorConvertible { toQueryOperator(): QueryOperator; } diff --git a/packages/kbn-esql-composer/src/utils/escape_identifier.ts b/packages/kbn-esql-composer/src/utils/formatters.ts similarity index 70% rename from packages/kbn-esql-composer/src/utils/escape_identifier.ts rename to packages/kbn-esql-composer/src/utils/formatters.ts index 12e3ab7099299..6e35e86b37a47 100644 --- a/packages/kbn-esql-composer/src/utils/escape_identifier.ts +++ b/packages/kbn-esql-composer/src/utils/formatters.ts @@ -10,3 +10,11 @@ export function escapeIdentifier(identifier: string) { return `\`${identifier}\``; } + +export const formatValue = (value: any) => { + return typeof value === 'string' ? `"${value}"` : value; +}; + +export const formatColumn = (column: string) => { + return column.startsWith('?') ? column : escapeIdentifier(column); +}; From 1e326bc99cd2e1d78798e6e9c1f129e1b2e9ab03 Mon Sep 17 00:00:00 2001 From: Carlos Crespo Date: Fri, 3 Jan 2025 10:45:08 +0100 Subject: [PATCH 4/4] Remove fluent and simplify the MVP --- packages/kbn-esql-composer/src/builder.ts | 56 ----------- .../src/commands/eval.test.ts | 18 ---- .../kbn-esql-composer/src/commands/eval.ts | 33 +------ .../src/commands/stats.test.ts | 96 +------------------ .../kbn-esql-composer/src/commands/stats.ts | 58 +---------- .../src/commands/where.test.ts | 48 ---------- .../kbn-esql-composer/src/commands/where.ts | 82 +--------------- .../kbn-esql-composer/src/create_pipeline.ts | 20 +--- packages/kbn-esql-composer/src/types.ts | 19 +--- 9 files changed, 15 insertions(+), 415 deletions(-) delete mode 100644 packages/kbn-esql-composer/src/builder.ts diff --git a/packages/kbn-esql-composer/src/builder.ts b/packages/kbn-esql-composer/src/builder.ts deleted file mode 100644 index c20bdbca8322b..0000000000000 --- a/packages/kbn-esql-composer/src/builder.ts +++ /dev/null @@ -1,56 +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 { append } from './commands/append'; -import { - QueryOperator, - BuilderCommand, - Params, - ChainedCommand, - QueryOperatorConvertible, -} from './types'; - -export abstract class QueryBuilder implements QueryOperatorConvertible { - protected readonly commands: BuilderCommand[] = []; - - public abstract build(): ChainedCommand; - - public toQueryOperator(): QueryOperator { - return append(this.build()); - } - - protected buildChain(): ChainedCommand { - const commandParts: string[] = []; - const paramsParts: Params[] = []; - - for (let i = 0; i < this.commands.length; i++) { - const currentCondition = this.commands[i]; - - if (i > 0) { - commandParts.push(currentCondition.type); - } - - if (typeof currentCondition.command === 'function') { - const innerCommand = currentCondition.command().buildChain(); - commandParts.push( - currentCondition.nested ? `(${innerCommand.command})` : innerCommand.command - ); - paramsParts.push(innerCommand.params ?? []); - } else { - commandParts.push(currentCondition.command); - paramsParts.push(currentCondition.params ?? []); - } - } - - return { - command: commandParts.join(' '), - params: paramsParts.flatMap((params) => params), - }; - } -} diff --git a/packages/kbn-esql-composer/src/commands/eval.test.ts b/packages/kbn-esql-composer/src/commands/eval.test.ts index ace5d608cd6f1..b4a7245b1f660 100644 --- a/packages/kbn-esql-composer/src/commands/eval.test.ts +++ b/packages/kbn-esql-composer/src/commands/eval.test.ts @@ -45,22 +45,4 @@ describe('evaluate', () => { 'FROM `logs-*`\n\t| EVAL hour = DATE_TRUNC(1 hour, `@timestamp`)' ); }); - - it('handles chained EVAL with params', () => { - const ids = ['aws', 'host1']; - const pipeline = source.pipe( - evaluate('entity.type = ?', 'host') - .concat('entity.display_name = COALESCE(?, entity.id)', 'some_host') - .concat(`entity.id = CONCAT(${ids.map(() => '?').join()})`, ids) - ); - const queryRequest = pipeline.asRequest(); - - expect(queryRequest.query).toEqual( - 'FROM `logs-*`\n\t| EVAL entity.type = ?, entity.display_name = COALESCE(?, entity.id), entity.id = CONCAT(?,?)' - ); - expect(queryRequest.params).toEqual(['host', 'some_host', 'aws', 'host1']); - expect(pipeline.asString()).toEqual( - 'FROM `logs-*`\n\t| EVAL entity.type = "host", entity.display_name = COALESCE("some_host", entity.id), entity.id = CONCAT("aws","host1")' - ); - }); }); diff --git a/packages/kbn-esql-composer/src/commands/eval.ts b/packages/kbn-esql-composer/src/commands/eval.ts index 309c362d31ca7..4222b538219e1 100644 --- a/packages/kbn-esql-composer/src/commands/eval.ts +++ b/packages/kbn-esql-composer/src/commands/eval.ts @@ -7,36 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { QueryBuilder } from '../builder'; -import { ChainedCommand, Params } from '../types'; - -const EVAL = 'EVAL'; - -class EvalBuilder extends QueryBuilder { - private constructor(body: string, params?: Params) { - super(); - this.commands.push({ command: body, params, type: EVAL }); - } - - public static create(body: string, params?: Params) { - return new EvalBuilder(body, params); - } - - public concat(body: string, params?: Params) { - this.commands.push({ command: body, params, type: EVAL }); - return this; - } - - public build(): ChainedCommand { - const { command, params } = this.buildChain(); - - return { - command: `${EVAL} ${command.replace(/\s+EVAL/g, ',')}`, - params, - }; - } -} +import { Params } from '../types'; +import { append } from './append'; export function evaluate(body: string, params?: Params) { - return EvalBuilder.create(body, params); + return append({ command: `EVAL ${body}`, params }); } diff --git a/packages/kbn-esql-composer/src/commands/stats.test.ts b/packages/kbn-esql-composer/src/commands/stats.test.ts index c9dbed1f11049..5ade160fbaa96 100644 --- a/packages/kbn-esql-composer/src/commands/stats.test.ts +++ b/packages/kbn-esql-composer/src/commands/stats.test.ts @@ -25,7 +25,7 @@ describe('stats', () => { it('handles STATS with params', () => { const pipeline = source.pipe( - stats('AVG(?duration), COUNT(?svcName) BY ?env', { + stats('AVG(?duration), COUNT(?svcName) WHERE agent.name == "java" BY ?env', { duration: { identifier: 'transaction.duration.us', }, @@ -40,7 +40,7 @@ describe('stats', () => { const queryRequest = pipeline.asRequest(); expect(queryRequest.query).toEqual( - 'FROM `logs-*`\n\t| STATS AVG(?duration), COUNT(?svcName) BY ?env' + 'FROM `logs-*`\n\t| STATS AVG(?duration), COUNT(?svcName) WHERE agent.name == "java" BY ?env' ); expect(queryRequest.params).toEqual([ { @@ -60,97 +60,7 @@ describe('stats', () => { }, ]); expect(pipeline.asString()).toEqual( - 'FROM `logs-*`\n\t| STATS AVG(`transaction.duration.us`), COUNT(`service.name`) BY `service.environment`' - ); - }); - - it('handles STATS with WHERE and BY', () => { - const pipeline = source.pipe( - stats('avg_duration = AVG(transaction.duration.us)') - .where({ - 'log.level': 'error', - }) - .by('service.name') - ); - const queryRequest = pipeline.asRequest(); - - expect(queryRequest.query).toEqual( - 'FROM `logs-*`\n\t| STATS avg_duration = AVG(transaction.duration.us) WHERE `log.level` == ? BY `service.name`' - ); - expect(queryRequest.params).toEqual(['error']); - expect(pipeline.asString()).toEqual( - 'FROM `logs-*`\n\t| STATS avg_duration = AVG(transaction.duration.us) WHERE `log.level` == "error" BY `service.name`' - ); - }); - - it('handles STATS and BY with params', () => { - const pipeline = source.pipe( - stats('avg_duration = AVG(transaction.duration.us)').by('?svcName', { - svcName: { identifier: 'service.name' }, - }) - ); - const queryRequest = pipeline.asRequest(); - - expect(queryRequest.query).toEqual( - 'FROM `logs-*`\n\t| STATS avg_duration = AVG(transaction.duration.us) BY ?svcName' - ); - expect(queryRequest.params).toEqual([ - { - svcName: { - identifier: 'service.name', - }, - }, - ]); - }); - - it('handles STATS and BY with multiple fields', () => { - const pipeline = source.pipe( - stats('avg_duration = AVG(transaction.duration.us)').by(['?svcName', '?svcEnv'], { - svcName: { identifier: 'service.name' }, - svcEnv: { identifier: 'service.environment' }, - }) - ); - const queryRequest = pipeline.asRequest(); - - expect(queryRequest.query).toEqual( - 'FROM `logs-*`\n\t| STATS avg_duration = AVG(transaction.duration.us) BY ?svcName, ?svcEnv' - ); - expect(queryRequest.params).toEqual([ - { - svcName: { - identifier: 'service.name', - }, - }, - { - svcEnv: { - identifier: 'service.environment', - }, - }, - ]); - expect(pipeline.asString()).toEqual( - 'FROM `logs-*`\n\t| STATS avg_duration = AVG(transaction.duration.us) BY `service.name`, `service.environment`' - ); - }); - - it('handles multiple chained STATS', () => { - const pipeline = source.pipe( - stats('avg_duration = AVG(transaction.duration.us)') - .concat('max_duration = MAX(transaction.duration.us)') - .where('@timestamp > ?', new Date('2025-01-01').toISOString()) - .concat('min_duration = MIN(transaction.duration.us)') - .where({ - 'service.name': 'service2', - }) - .by('service.environment') - ); - const queryRequest = pipeline.asRequest(); - - expect(queryRequest.query).toEqual( - 'FROM `logs-*`\n\t| STATS avg_duration = AVG(transaction.duration.us), max_duration = MAX(transaction.duration.us) WHERE @timestamp > ?, min_duration = MIN(transaction.duration.us) WHERE `service.name` == ? BY `service.environment`' - ); - expect(queryRequest.params).toEqual(['2025-01-01T00:00:00.000Z', 'service2']); - expect(pipeline.asString()).toEqual( - 'FROM `logs-*`\n\t| STATS avg_duration = AVG(transaction.duration.us), max_duration = MAX(transaction.duration.us) WHERE @timestamp > "2025-01-01T00:00:00.000Z", min_duration = MIN(transaction.duration.us) WHERE `service.name` == "service2" BY `service.environment`' + 'FROM `logs-*`\n\t| STATS AVG(`transaction.duration.us`), COUNT(`service.name`) WHERE agent.name == "java" BY `service.environment`' ); }); }); diff --git a/packages/kbn-esql-composer/src/commands/stats.ts b/packages/kbn-esql-composer/src/commands/stats.ts index bf977bcae9e09..5339f532cd53f 100644 --- a/packages/kbn-esql-composer/src/commands/stats.ts +++ b/packages/kbn-esql-composer/src/commands/stats.ts @@ -7,61 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { QueryBuilder } from '../builder'; -import { ChainedCommand, NamedParameterWithIdentifier } from '../types'; -import { formatColumn } from '../utils/formatters'; -import { where } from './where'; - -const STATS = 'STATS'; - -type StatsAfterWhere = Omit; -type StatsAfterBy = Omit; -class StatsBuilder extends QueryBuilder { - private constructor(body: string, params?: NamedParameterWithIdentifier) { - super(); - this.commands.push({ command: body, params, type: STATS }); - } - - public static create(body: string, params?: NamedParameterWithIdentifier) { - return new StatsBuilder(body, params); - } - - public concat(body: string, params?: NamedParameterWithIdentifier) { - this.commands.push({ command: body, params, type: STATS }); - return this; - } - - public by(column: string | string[], params?: NamedParameterWithIdentifier) { - this.commands.push({ - command: (Array.isArray(column) ? column : [column]) - .map((columnName) => formatColumn(columnName)) - .join(', '), - params, - type: 'BY', - }); - - return this as StatsAfterBy; - } - - public where(...args: Parameters) { - this.commands.push({ - command: () => where(...args), - type: 'WHERE', - }); - - return this as StatsAfterWhere; - } - - public build(): ChainedCommand { - const { command, params } = this.buildChain(); - - return { - command: `${STATS} ${command.replace(/\s+STATS/g, ',')}`, - params, - }; - } -} +import { NamedParameterWithIdentifier } from '../types'; +import { append } from './append'; export function stats(body: string, params?: NamedParameterWithIdentifier) { - return StatsBuilder.create(body, params); + return append({ command: `STATS ${body}`, params }); } diff --git a/packages/kbn-esql-composer/src/commands/where.test.ts b/packages/kbn-esql-composer/src/commands/where.test.ts index ec38d2077b162..cc8275bf8cfd7 100644 --- a/packages/kbn-esql-composer/src/commands/where.test.ts +++ b/packages/kbn-esql-composer/src/commands/where.test.ts @@ -31,19 +31,6 @@ describe('where', () => { ); }); - it('handles WHERE clause with object', () => { - const pipeline = source.pipe(where({ 'host.name': 'host', 'service.name': 'service' })); - const queryRequest = pipeline.asRequest(); - - expect(queryRequest.query).toEqual( - 'FROM `logs-*`\n\t| WHERE `host.name` == ? AND `service.name` == ?' - ); - expect(queryRequest.params).toEqual(['host', 'service']); - expect(pipeline.asString()).toEqual( - 'FROM `logs-*`\n\t| WHERE `host.name` == "host" AND `service.name` == "service"' - ); - }); - it('appends a WHERE clause with named parameters', () => { const params = { hostName: 'host', serviceName: 'service' }; const pipeline = source.pipe( @@ -71,39 +58,4 @@ describe('where', () => { 'FROM `logs-*`\n\t| WHERE host.name IN ("host1","host2","host3")' ); }); - - it('handles WHERE with nested OR conditions', () => { - const pipeline = source.pipe( - where(() => where('host.name == ?', 'host4').or('host.name == ?', 'host5')).and( - 'service.name == ?', - 'service1' - ) - ); - const queryRequest = pipeline.asRequest(); - - expect(queryRequest.query).toEqual( - 'FROM `logs-*`\n\t| WHERE (host.name == ? OR host.name == ?) AND service.name == ?' - ); - expect(queryRequest.params).toEqual(['host4', 'host5', 'service1']); - expect(pipeline.asString()).toEqual( - 'FROM `logs-*`\n\t| WHERE (host.name == "host4" OR host.name == "host5") AND service.name == "service1"' - ); - }); - - it('handles WHERE with multiple nested clauses', () => { - const pipeline = source.pipe( - where(() => where('host.name == ?', 'host1').or('host.name == ?', 'host2')).and(() => - where('service.name == ?', 'service1').or('service.name == ?', 'service2') - ) - ); - const queryRequest = pipeline.asRequest(); - - expect(queryRequest.query).toEqual( - 'FROM `logs-*`\n\t| WHERE (host.name == ? OR host.name == ?) AND (service.name == ? OR service.name == ?)' - ); - expect(queryRequest.params).toEqual(['host1', 'host2', 'service1', 'service2']); - expect(pipeline.asString()).toEqual( - 'FROM `logs-*`\n\t| WHERE (host.name == "host1" OR host.name == "host2") AND (service.name == "service1" OR service.name == "service2")' - ); - }); }); diff --git a/packages/kbn-esql-composer/src/commands/where.ts b/packages/kbn-esql-composer/src/commands/where.ts index 45e0553d093ba..98fa03e2f7daf 100644 --- a/packages/kbn-esql-composer/src/commands/where.ts +++ b/packages/kbn-esql-composer/src/commands/where.ts @@ -7,83 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { QueryBuilder } from '../builder'; -import { Params, FieldValue, ChainedCommand } from '../types'; -import { formatColumn } from '../utils/formatters'; +import { Params } from '../types'; +import { append } from './append'; -const WHERE = 'WHERE'; - -type WhereCriteria = string | (() => WhereBuilder) | Record; -type LogicalOperator = 'AND' | 'OR'; - -class WhereBuilder extends QueryBuilder { - private constructor(criteria: WhereCriteria, params?: Params) { - super(); - this.push(criteria, params); - } - - public static create(criteria: WhereCriteria, params?: Params) { - return new WhereBuilder(criteria, params); - } - - public and(criteria: WhereCriteria, params?: Params): WhereBuilder { - return this.addCondition('AND', criteria, params); - } - - public or(criteria: WhereCriteria, params?: Params): WhereBuilder { - return this.addCondition('OR', criteria, params); - } - - public build(): ChainedCommand { - const { command, params } = this.buildChain(); - - return { - command: `${WHERE} ${command}`, - params, - }; - } - - private addCondition( - logicalOperator: LogicalOperator, - body: WhereCriteria, - params?: Params - ): WhereBuilder { - return this.push(body, params, logicalOperator); - } - - private push( - criteria: WhereCriteria, - params?: Params, - type: LogicalOperator | typeof WHERE = 'WHERE' - ) { - if (typeof criteria === 'function') { - // Handle nested conditions - this.commands.push({ - command: criteria, - type, - nested: true, - }); - } else if (typeof criteria === 'object') { - // Handle object with named parameters - const keys = Object.keys(criteria); - this.commands.push({ - command: keys.map((key) => `${formatColumn(key)} == ?`).join(' AND '), - params: keys.map((key) => criteria[key]), - type, - }); - } else { - // Handle raw and nested conditions - this.commands.push({ - command: criteria, - params, - type, - }); - } - - return this; - } -} - -export function where(criteria: WhereCriteria, params?: Params): WhereBuilder { - return WhereBuilder.create(criteria, params); +export function where(body: string, params?: Params) { + return append({ command: `WHERE ${body}`, params }); } diff --git a/packages/kbn-esql-composer/src/create_pipeline.ts b/packages/kbn-esql-composer/src/create_pipeline.ts index 2afeee63115bd..5ed328f971163 100644 --- a/packages/kbn-esql-composer/src/create_pipeline.ts +++ b/packages/kbn-esql-composer/src/create_pipeline.ts @@ -8,23 +8,9 @@ */ import { isObject } from 'lodash'; -import { - Query, - QueryOperatorConvertible, - QueryOperator, - QueryPipeline, - QueryRequest, - NamedParameter, - Params, -} from './types'; +import { Query, QueryPipeline, QueryRequest, NamedParameter, Params } from './types'; import { escapeIdentifier, formatValue } from './utils/formatters'; -function isQueryOperatorConvertible( - value: QueryOperator | QueryOperatorConvertible -): value is QueryOperatorConvertible { - return (value as QueryOperatorConvertible).toQueryOperator !== undefined; -} - export function createPipeline(source: Query): QueryPipeline { const asRequest = (): QueryRequest => { const params = source.params.flatMap((param) => { @@ -70,10 +56,6 @@ export function createPipeline(source: Query): QueryPipeline { return { pipe: (...operators) => { const nextSource = operators.reduce((previousQuery, operator) => { - if (isQueryOperatorConvertible(operator)) { - return operator.toQueryOperator()(previousQuery); - } - return operator(previousQuery); }, source); diff --git a/packages/kbn-esql-composer/src/types.ts b/packages/kbn-esql-composer/src/types.ts index 319cc54e6aadb..e9153ef4416bd 100644 --- a/packages/kbn-esql-composer/src/types.ts +++ b/packages/kbn-esql-composer/src/types.ts @@ -7,8 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { QueryBuilder } from './builder'; - export interface Command { body: string; } @@ -20,7 +18,7 @@ export type NamedParameter = Record | NamedParameterWithIdentifi export type Params = NamedParameter | FieldValue | Array; export interface QueryPipeline { - pipe: (...args: Array) => QueryPipeline; + pipe: (...args: QueryOperator[]) => QueryPipeline; asRequest: () => QueryRequest; asString: () => string; } @@ -35,18 +33,3 @@ export interface QueryRequest { } export type QueryOperator = (sourceQuery: Query) => Query; -export interface BuilderCommand { - command: string | (() => QueryBuilder); - params?: Params; - type: TType; - nested?: boolean; -} - -export interface ChainedCommand { - command: string; - params?: Params; -} - -export interface QueryOperatorConvertible { - toQueryOperator(): QueryOperator; -}