Skip to content

Commit

Permalink
[ES|QL] STATS command APIs (elastic#199322)
Browse files Browse the repository at this point in the history
## Summary

Partially addresses elastic#191812

This PR implement higher-level convenience methods for working with
`STATS` commands:

- `commands.stats.list()` - iterates over all `STATS` commands.
- `commands.stats.byIndex()` - retrieves the Nth `STATS` command.
- `commands.stats.summarize()` - returns summary about fields used in
aggregates and grouping for all `STATS` commands in the query.
- `commands.stats.summarizeCommand()` - same as `.summarize()`, but
returns a summary only about the requested command.

Usage:

```ts
const query = EsqlQuery.fromSrc('FROM index | STATS a = max(b)');
const summary = commands.stats.summarize(query); // [ { aggregates: { a: { fields: ['b'] }} ]
```


### Checklist

Delete any items that are not applicable to this PR.


- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
### For maintainers

- [x] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#_add_your_labels)
  • Loading branch information
vadimkibana authored Nov 12, 2024
1 parent a10eb1f commit be1a4bb
Show file tree
Hide file tree
Showing 4 changed files with 557 additions and 1 deletion.
3 changes: 2 additions & 1 deletion packages/kbn-esql-ast/src/mutate/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@
import * as from from './from';
import * as limit from './limit';
import * as sort from './sort';
import * as stats from './stats';

export { from, limit, sort };
export { from, limit, sort, stats };
317 changes: 317 additions & 0 deletions packages/kbn-esql-ast/src/mutate/commands/stats/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import * as commands from '..';
import { EsqlQuery } from '../../../query';

describe('commands.stats', () => {
describe('.list()', () => {
it('lists all "STATS" commands', () => {
const src = 'FROM index | LIMIT 1 | STATS agg() | LIMIT 2 | STATS max()';
const query = EsqlQuery.fromSrc(src);

const nodes = [...commands.stats.list(query.ast)];

expect(nodes).toMatchObject([
{
type: 'command',
name: 'stats',
args: [
{
type: 'function',
name: 'agg',
},
],
},
{
type: 'command',
name: 'stats',
args: [
{
type: 'function',
name: 'max',
},
],
},
]);
});
});

describe('.byIndex()', () => {
it('retrieves the specific "STATS" command by index', () => {
const src = 'FROM index | LIMIT 1 | STATS agg() | LIMIT 2 | STATS max()';
const query = EsqlQuery.fromSrc(src);

const node1 = commands.stats.byIndex(query.ast, 1);
const node2 = commands.stats.byIndex(query.ast, 0);

expect(node1).toMatchObject({
type: 'command',
name: 'stats',
args: [
{
type: 'function',
name: 'max',
},
],
});
expect(node2).toMatchObject({
type: 'command',
name: 'stats',
args: [
{
type: 'function',
name: 'agg',
},
],
});
});
});

describe('.summarizeCommand()', () => {
it('returns summary of a simple field, defined through assignment', () => {
const src = 'FROM index | STATS foo = agg(bar)';
const query = EsqlQuery.fromSrc(src);

const command = commands.stats.byIndex(query.ast, 0)!;
const summary = commands.stats.summarizeCommand(query, command);

expect(summary).toMatchObject({
command,
aggregates: {
foo: {
arg: {
type: 'function',
name: '=',
},
field: 'foo',
column: {
type: 'column',
name: 'foo',
},
definition: {
type: 'function',
name: 'agg',
args: [
{
type: 'column',
name: 'bar',
},
],
},
terminals: [
{
type: 'column',
name: 'bar',
},
],
fields: ['bar'],
},
},
});
});

it('can summarize field defined without assignment', () => {
const src = 'FROM index | STATS agg( /* haha 😅 */ max(foo), bar, baz)';
const query = EsqlQuery.fromSrc(src);

const command = commands.stats.byIndex(query.ast, 0)!;
const summary = commands.stats.summarizeCommand(query, command);

expect(summary).toMatchObject({
command,
aggregates: {
'`agg( /* haha 😅 */ max(foo), bar, baz)`': {
arg: {
type: 'function',
name: 'agg',
},
field: '`agg( /* haha 😅 */ max(foo), bar, baz)`',
column: {
type: 'column',
name: '`agg( /* haha 😅 */ max(foo), bar, baz)`',
},
definition: {
type: 'function',
name: 'agg',
},
terminals: [
{
type: 'column',
name: 'foo',
},
{
type: 'column',
name: 'bar',
},
{
type: 'column',
name: 'baz',
},
],
fields: ['foo', 'bar', 'baz'],
},
},
});
});

it('returns a map of stats about two fields', () => {
const src = 'FROM index | STATS foo = agg(f1) + agg(f2), a.b = agg(f3)';
const query = EsqlQuery.fromSrc(src);

const command = commands.stats.byIndex(query.ast, 0)!;
const summary = commands.stats.summarizeCommand(query, command);

expect(summary).toMatchObject({
aggregates: {
foo: {
field: 'foo',
fields: ['f1', 'f2'],
},
'a.b': {
field: 'a.b',
fields: ['f3'],
},
},
});
expect(summary.fields).toEqual(new Set(['f1', 'f2', 'f3']));
});

it('can get de-duplicated list of used fields', () => {
const src = 'FROM index | STATS foo = agg(f1) + agg(f2), a.b = agg(f1)';
const query = EsqlQuery.fromSrc(src);

const command = commands.stats.byIndex(query.ast, 0)!;
const summary = commands.stats.summarizeCommand(query, command);

expect(summary.fields).toEqual(new Set(['f1', 'f2']));
});

describe('params', () => {
it('can use params as source field names', () => {
const src = 'FROM index | STATS foo = agg(f1.?aha) + ?aha(?nested.?param), a.b = agg(f1)';
const query = EsqlQuery.fromSrc(src);

const command = commands.stats.byIndex(query.ast, 0)!;
const summary = commands.stats.summarizeCommand(query, command);

expect(summary).toMatchObject({
aggregates: {
foo: {
fields: ['f1.?aha', '?nested.?param'],
},
'a.b': {
fields: ['f1'],
},
},
});
expect(summary.fields).toEqual(new Set(['f1.?aha', '?nested.?param', 'f1']));
});

it('can use params as destination field names', () => {
const src = 'FROM index | STATS ?dest = agg(asdf) BY asdf';
const query = EsqlQuery.fromSrc(src);

const command = commands.stats.byIndex(query.ast, 0)!;
const summary = commands.stats.summarizeCommand(query, command);

expect(summary).toMatchObject({
aggregates: {
'?dest': {
fields: ['asdf'],
},
},
});
expect(summary.fields).toEqual(new Set(['asdf']));
});
});

describe('BY option', () => {
it('can collect fields from the BY option', () => {
const src = 'FROM index | STATS max(1) BY abc';
const query = EsqlQuery.fromSrc(src);

const command = commands.stats.byIndex(query.ast, 0)!;
const summary = commands.stats.summarizeCommand(query, command);

expect(summary.aggregates).toEqual({
'`max(1)`': expect.any(Object),
});
expect(summary.fields).toEqual(new Set(['abc']));
});

it('returns all "grouping" fields', () => {
const src = 'FROM index | STATS max(1) BY a, b, c';
const query = EsqlQuery.fromSrc(src);

const command = commands.stats.byIndex(query.ast, 0)!;
const summary = commands.stats.summarizeCommand(query, command);

expect(summary.aggregates).toEqual({
'`max(1)`': expect.any(Object),
});
expect(summary.grouping).toMatchObject({
a: { type: 'column' },
b: { type: 'column' },
c: { type: 'column' },
});
});

it('can have params and quoted fields in grouping', () => {
const src = 'FROM index | STATS max(1) BY `a😎`, ?123, a.?b.?0.`😎`';
const query = EsqlQuery.fromSrc(src);

const command = commands.stats.byIndex(query.ast, 0)!;
const summary = commands.stats.summarizeCommand(query, command);

expect(summary.aggregates).toEqual({
'`max(1)`': expect.any(Object),
});
expect(summary.grouping).toMatchObject({
'`a😎`': { type: 'column' },
// '?123': { type: 'column' },
'a.?b.?0.`😎`': { type: 'column' },
});
});
});
});

describe('.summarize()', () => {
it('can summarize multiple stats commands', () => {
const src = 'FROM index | LIMIT 1 | STATS agg() | LIMIT 2 | STATS max(a, b, c), max2(d.e)';
const query = EsqlQuery.fromSrc(src);
const summary = commands.stats.summarize(query);

expect(summary).toMatchObject([
{
aggregates: {
'`agg()`': {
field: '`agg()`',
fields: [],
},
},
fields: new Set([]),
},
{
aggregates: {
'`max(a, b, c)`': {
field: '`max(a, b, c)`',
fields: ['a', 'b', 'c'],
},
'`max2(d.e)`': {
field: '`max2(d.e)`',
fields: ['d.e'],
},
},
fields: new Set(['a', 'b', 'c', 'd.e']),
},
]);
});
});
});
Loading

0 comments on commit be1a4bb

Please sign in to comment.