Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Creates ES|QL where filters for ordinal charts #184420

Merged
merged 22 commits into from
Jun 10, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -863,7 +863,7 @@ export function XYChart({
xDomain={xDomain}
// enable brushing only for time charts, for both ES|QL and DSL queries
onBrushEnd={interactive ? (brushHandler as BrushEndListener) : undefined}
onElementClick={interactive && !isEsqlMode ? clickHandler : undefined}
onElementClick={interactive ? clickHandler : undefined}
legendAction={
interactive
? getLegendAction(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,97 +9,167 @@
import { DataViewsContract } from '@kbn/data-views-plugin/common';
import { dataPluginMock } from '../../mocks';
import { setIndexPatterns, setSearchService } from '../../services';
import { createFiltersFromValueClickAction } from './create_filters_from_value_click';
import {
createFiltersFromValueClickAction,
appendFilterToESQLQueryFromValueClickAction,
} from './create_filters_from_value_click';
import { FieldFormatsGetConfigFn, BytesFormat } from '@kbn/field-formats-plugin/common';
import { RangeFilter } from '@kbn/es-query';

const mockField = {
name: 'bytes',
filterable: true,
};
describe('createFiltersFromClickEvent', () => {
const dataStart = dataPluginMock.createStartContract();
setSearchService(dataStart.search);
setIndexPatterns({
...dataStart.indexPatterns,
get: async () => ({
id: 'logstash-*',
fields: {
getByName: () => mockField,
filter: () => [mockField],
},
getFormatterForField: () => new BytesFormat({}, (() => {}) as FieldFormatsGetConfigFn),
}),
} as unknown as DataViewsContract);
describe('createFiltersFromValueClick', () => {
let dataPoints: Parameters<typeof createFiltersFromValueClickAction>[0]['data'];

describe('createFiltersFromValueClick', () => {
let dataPoints: Parameters<typeof createFiltersFromValueClickAction>[0]['data'];

beforeEach(() => {
dataPoints = [
{
table: {
columns: [
{
name: 'test',
id: '1-1',
meta: {
type: 'date',
source: 'esaggs',
sourceParams: {
indexPatternId: 'logstash-*',
type: 'histogram',
params: {
field: 'bytes',
interval: 30,
otherBucket: true,
beforeEach(() => {
dataPoints = [
{
table: {
columns: [
{
name: 'test',
id: '1-1',
meta: {
type: 'date',
source: 'esaggs',
sourceParams: {
indexPatternId: 'logstash-*',
type: 'histogram',
params: {
field: 'bytes',
interval: 30,
otherBucket: true,
},
},
},
},
},
],
rows: [
{
'1-1': '2048',
},
],
},
column: 0,
row: 0,
value: 'test',
},
];

const dataStart = dataPluginMock.createStartContract();
setSearchService(dataStart.search);
setIndexPatterns({
...dataStart.indexPatterns,
get: async () => ({
id: 'logstash-*',
fields: {
getByName: () => mockField,
filter: () => [mockField],
],
rows: [
{
'1-1': '2048',
},
],
},
column: 0,
row: 0,
value: 'test',
},
getFormatterForField: () => new BytesFormat({}, (() => {}) as FieldFormatsGetConfigFn),
}),
} as unknown as DataViewsContract);
});
];
});
test('ignores event when value for rows is not provided', async () => {
dataPoints[0].table.rows[0]['1-1'] = null;
const filters = await createFiltersFromValueClickAction({ data: dataPoints });

test('ignores event when value for rows is not provided', async () => {
dataPoints[0].table.rows[0]['1-1'] = null;
const filters = await createFiltersFromValueClickAction({ data: dataPoints });
expect(filters.length).toEqual(0);
});

expect(filters.length).toEqual(0);
});
test('handles an event when aggregations type is a terms', async () => {
(dataPoints[0].table.columns[0].meta.sourceParams as any).type = 'terms';
const filters = await createFiltersFromValueClickAction({ data: dataPoints });

test('handles an event when aggregations type is a terms', async () => {
(dataPoints[0].table.columns[0].meta.sourceParams as any).type = 'terms';
const filters = await createFiltersFromValueClickAction({ data: dataPoints });
expect(filters.length).toEqual(1);
expect(filters[0].query?.match_phrase?.bytes).toEqual('2048');
});

expect(filters.length).toEqual(1);
expect(filters[0].query?.match_phrase?.bytes).toEqual('2048');
});
test('handles an event when aggregations type is not terms', async () => {
const filters = await createFiltersFromValueClickAction({ data: dataPoints });

test('handles an event when aggregations type is not terms', async () => {
const filters = await createFiltersFromValueClickAction({ data: dataPoints });
expect(filters.length).toEqual(1);

expect(filters.length).toEqual(1);
const [rangeFilter] = filters as RangeFilter[];
expect(rangeFilter.query.range.bytes.gte).toEqual(2048);
expect(rangeFilter.query.range.bytes.lt).toEqual(2078);
});

const [rangeFilter] = filters as RangeFilter[];
expect(rangeFilter.query.range.bytes.gte).toEqual(2048);
expect(rangeFilter.query.range.bytes.lt).toEqual(2078);
test('handles non-unique filters', async () => {
const [point] = dataPoints;
const filters = await createFiltersFromValueClickAction({ data: [point, point] });

expect(filters.length).toEqual(1);
});
});
describe('appendFilterToESQLQueryFromValueClickAction', () => {
let dataPoints: Parameters<typeof appendFilterToESQLQueryFromValueClickAction>[0]['data'];
beforeEach(() => {
dataPoints = [
{
table: {
columns: [
{
name: 'columnA',
id: 'columnA',
meta: {
type: 'date',
},
},
],
rows: [
{
columnA: '2048',
},
],
},
column: 0,
row: 0,
value: 'test',
},
];
});
test('should return null for date fields', async () => {
const queryString = await appendFilterToESQLQueryFromValueClickAction({
data: dataPoints,
query: { esql: 'from meow' },
});

expect(queryString).toBeUndefined();
});

test('handles non-unique filters', async () => {
const [point] = dataPoints;
const filters = await createFiltersFromValueClickAction({ data: [point, point] });
test('should return null if no aggregate query is present', async () => {
dataPoints[0].table.columns[0] = {
name: 'test',
id: '1-1',
meta: {
type: 'string',
},
};
const queryString = await appendFilterToESQLQueryFromValueClickAction({
data: dataPoints,
});

expect(queryString).toBeUndefined();
});

test('should return the update query string', async () => {
dataPoints[0].table.columns[0] = {
name: 'columnA',
id: 'columnA',
meta: {
type: 'string',
},
};
const queryString = await appendFilterToESQLQueryFromValueClickAction({
data: dataPoints,
query: { esql: 'from meow' },
});

expect(filters.length).toEqual(1);
expect(queryString).toEqual(`from meow
| where \`columnA\`=="2048"`);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@

import _ from 'lodash';
import { Datatable } from '@kbn/expressions-plugin/public';
import { compareFilters, COMPARE_ALL_OPTIONS, Filter, toggleFilterNegated } from '@kbn/es-query';
import {
compareFilters,
COMPARE_ALL_OPTIONS,
Filter,
toggleFilterNegated,
type AggregateQuery,
} from '@kbn/es-query';
import { appendWhereClauseToESQLQuery } from '@kbn/esql-utils';
import { getIndexPatterns, getSearchService } from '../../services';
import { AggConfigSerialized } from '../../../common/search/aggs';
import { mapAndFlattenFilters } from '../../query';
Expand All @@ -22,6 +29,7 @@ interface ValueClickDataContext {
}>;
timeFieldName?: string;
negate?: boolean;
query?: AggregateQuery;
}

/**
Expand Down Expand Up @@ -148,3 +156,40 @@ export const createFiltersFromValueClickAction = async ({
compareFilters(a, b, COMPARE_ALL_OPTIONS)
);
};

/** @public */
export const appendFilterToESQLQueryFromValueClickAction = async ({
data,
query,
}: ValueClickDataContext) => {
let queryWithFilter: string | undefined;

await Promise.all(
stratoula marked this conversation as resolved.
Show resolved Hide resolved
data
.filter((point) => point)
.map(async (val) => {
const { table, column: columnIndex, row: rowIndex } = val;
if (table && table.columns && table.columns[columnIndex]) {
const column = table.columns[columnIndex];
const value: unknown = rowIndex > -1 ? table.rows[rowIndex][column.id] : null;

// Do not append in case of time series, for now. We need to find a way to compute the interval
// to create the time range filter correctly. The users can brush to update the time filter instead.
const isDate = column.meta?.type === 'date';

if (value === null || value === undefined || isDate || !query) return;
stratoula marked this conversation as resolved.
Show resolved Hide resolved

const queryString = query.esql;
queryWithFilter = appendWhereClauseToESQLQuery(
queryString,
column.id,
stratoula marked this conversation as resolved.
Show resolved Hide resolved
value,
'+',
column.meta?.type
);
}
})
);

return queryWithFilter;
};
32 changes: 25 additions & 7 deletions src/plugins/data/public/actions/value_click_action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@
* Side Public License, v 1.
*/

import type { Filter } from '@kbn/es-query';
import { Filter, AggregateQuery, isOfAggregateQueryType } from '@kbn/es-query';
import { Datatable } from '@kbn/expressions-plugin/public';
import { UiActionsActionDefinition, UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { APPLY_FILTER_TRIGGER } from '../triggers';
import { createFiltersFromValueClickAction } from './filters/create_filters_from_value_click';
import {
createFiltersFromValueClickAction,
appendFilterToESQLQueryFromValueClickAction,
} from './filters/create_filters_from_value_click';

export type ValueClickActionContext = ValueClickContext;
export const ACTION_VALUE_CLICK = 'ACTION_VALUE_CLICK';
Expand All @@ -28,6 +31,7 @@ export interface ValueClickContext {
}>;
timeFieldName?: string;
negate?: boolean;
query?: AggregateQuery;
};
}

Expand All @@ -39,18 +43,32 @@ export function createValueClickActionDefinition(
id: ACTION_VALUE_CLICK,
shouldAutoExecute: async () => true,
isCompatible: async (context: ValueClickContext) => {
if (context.data.query && isOfAggregateQueryType(context.data.query)) {
const queryString = await appendFilterToESQLQueryFromValueClickAction(context.data);
return queryString != null;
}
const filters = await createFiltersFromValueClickAction(context.data);
return filters.length > 0;
},
execute: async (context: ValueClickActionContext) => {
try {
const filters: Filter[] = await createFiltersFromValueClickAction(context.data);
if (filters.length > 0) {
await getStartServices().uiActions.getTrigger(APPLY_FILTER_TRIGGER).exec({
filters,
if (context.data.query && isOfAggregateQueryType(context.data.query)) {
// ES|QL charts have a different way of applying filters,
// they are appending a where clause to the query
const queryString = await appendFilterToESQLQueryFromValueClickAction(context.data);
stratoula marked this conversation as resolved.
Show resolved Hide resolved
await getStartServices().uiActions.getTrigger('UPDATE_ESQL_QUERY_TRIGGER').exec({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Can we use a constant (and export it) for 'UPDATE_ESQL_QUERY_TRIGGER'?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason I didnt do it is because if I export it from the text-based languages plugin (where the trigger is being registered) it will create circular dependencies with the data plugin. I could export it from the esql-utils package but it seems a bit unrelated to me. So I will leave it as it is for now and I will think about it more when another usage will be required.

embeddable: context.embeddable,
timeFieldName: context.data.timeFieldName,
queryString,
});
} else {
const filters: Filter[] = await createFiltersFromValueClickAction(context.data);
if (filters.length > 0) {
await getStartServices().uiActions.getTrigger(APPLY_FILTER_TRIGGER).exec({
filters,
embeddable: context.embeddable,
timeFieldName: context.data.timeFieldName,
});
}
}
} catch (e) {
// eslint-disable-next-line no-console
Expand Down
1 change: 1 addition & 0 deletions src/plugins/data/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"@kbn/react-kibana-mount",
"@kbn/search-types",
"@kbn/safer-lodash-set",
"@kbn/esql-utils",
],
"exclude": [
"target/**/*",
Expand Down
3 changes: 2 additions & 1 deletion src/plugins/text_based_languages/kibana.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"requiredPlugins": [
"data",
"expressions",
"dataViews"
"dataViews",
"uiActions",
],
"requiredBundles": [
"kibanaReact",
Expand Down
Loading