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..8df120f6e6f38 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.trim()]; + 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); +};