mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
* 🐛 Fix fields validation for multi terms
* 🐛 Fix unit tests + add some new ones
* 🌐 Removed unused translations
* Revert "🌐 Removed unused translations"
This reverts commit a94ee699c0
.
* 🌐 Removed unused translations
* 🐛 Extends deeper validation to drag and drop as well + more tests
* 🐛 Fix last issue with refactored function
* ✨ Filtered fields with unsupported types when in multi mode
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
439 lines
16 KiB
TypeScript
439 lines
16 KiB
TypeScript
/*
|
|
* 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; you may not use this file except in compliance with the Elastic License
|
|
* 2.0.
|
|
*/
|
|
|
|
import React from 'react';
|
|
import { i18n } from '@kbn/i18n';
|
|
import { FormattedMessage } from '@kbn/i18n-react';
|
|
import type { DocLinksStart } from 'kibana/public';
|
|
import { EuiLink, EuiTextColor, EuiButton, EuiSpacer } from '@elastic/eui';
|
|
|
|
import { DatatableColumn } from 'src/plugins/expressions';
|
|
import { groupBy, escape } from 'lodash';
|
|
import type { FramePublicAPI, StateSetter } from '../types';
|
|
import type { IndexPattern, IndexPatternLayer, IndexPatternPrivateState } from './types';
|
|
import type { ReferenceBasedIndexPatternColumn } from './operations/definitions/column_types';
|
|
|
|
import {
|
|
operationDefinitionMap,
|
|
GenericIndexPatternColumn,
|
|
TermsIndexPatternColumn,
|
|
CountIndexPatternColumn,
|
|
updateColumnParam,
|
|
updateDefaultLabels,
|
|
RangeIndexPatternColumn,
|
|
FormulaIndexPatternColumn,
|
|
} from './operations';
|
|
|
|
import { getInvalidFieldMessage, isColumnOfType } from './operations/definitions/helpers';
|
|
import { FiltersIndexPatternColumn, isQueryValid } from './operations/definitions/filters';
|
|
import { checkColumnForPrecisionError, Query } from '../../../../../src/plugins/data/common';
|
|
import { hasField } from './pure_utils';
|
|
import { mergeLayer } from './state_helpers';
|
|
import { supportsRarityRanking } from './operations/definitions/terms';
|
|
import { DEFAULT_MAX_DOC_COUNT } from './operations/definitions/terms/constants';
|
|
import { getOriginalId } from '../../common/expressions';
|
|
|
|
export function isColumnInvalid(
|
|
layer: IndexPatternLayer,
|
|
columnId: string,
|
|
indexPattern: IndexPattern
|
|
) {
|
|
const column: GenericIndexPatternColumn | undefined = layer.columns[columnId];
|
|
if (!column || !indexPattern) return;
|
|
|
|
const operationDefinition = column.operationType && operationDefinitionMap[column.operationType];
|
|
// check also references for errors
|
|
const referencesHaveErrors =
|
|
true &&
|
|
'references' in column &&
|
|
Boolean(getReferencesErrors(layer, column, indexPattern).filter(Boolean).length);
|
|
|
|
const operationErrorMessages =
|
|
operationDefinition &&
|
|
operationDefinition.getErrorMessage?.(layer, columnId, indexPattern, operationDefinitionMap);
|
|
|
|
const filterHasError = column.filter ? !isQueryValid(column.filter, indexPattern) : false;
|
|
|
|
return (
|
|
(operationErrorMessages && operationErrorMessages.length > 0) ||
|
|
referencesHaveErrors ||
|
|
filterHasError
|
|
);
|
|
}
|
|
|
|
function getReferencesErrors(
|
|
layer: IndexPatternLayer,
|
|
column: ReferenceBasedIndexPatternColumn,
|
|
indexPattern: IndexPattern
|
|
) {
|
|
return column.references?.map((referenceId: string) => {
|
|
const referencedOperation = layer.columns[referenceId]?.operationType;
|
|
const referencedDefinition = operationDefinitionMap[referencedOperation];
|
|
return referencedDefinition?.getErrorMessage?.(
|
|
layer,
|
|
referenceId,
|
|
indexPattern,
|
|
operationDefinitionMap
|
|
);
|
|
});
|
|
}
|
|
|
|
export function fieldIsInvalid(
|
|
column: GenericIndexPatternColumn | undefined,
|
|
indexPattern: IndexPattern
|
|
) {
|
|
if (!column || !hasField(column)) {
|
|
return false;
|
|
}
|
|
return !!getInvalidFieldMessage(column, indexPattern)?.length;
|
|
}
|
|
|
|
export function getPrecisionErrorWarningMessages(
|
|
state: IndexPatternPrivateState,
|
|
{ activeData }: FramePublicAPI,
|
|
docLinks: DocLinksStart,
|
|
setState: StateSetter<IndexPatternPrivateState>
|
|
) {
|
|
const warningMessages: React.ReactNode[] = [];
|
|
|
|
if (state && activeData) {
|
|
Object.entries(activeData)
|
|
.reduce(
|
|
(acc, [layerId, { columns }]) => [
|
|
...acc,
|
|
...columns.map((column) => ({ layerId, column })),
|
|
],
|
|
[] as Array<{ layerId: string; column: DatatableColumn }>
|
|
)
|
|
.forEach(({ layerId, column }) => {
|
|
const currentLayer = state.layers[layerId];
|
|
const currentColumn = currentLayer?.columns[column.id];
|
|
if (currentLayer && currentColumn && checkColumnForPrecisionError(column)) {
|
|
const indexPattern = state.indexPatterns[currentLayer.indexPatternId];
|
|
const isAscendingCountSorting =
|
|
isColumnOfType<TermsIndexPatternColumn>('terms', currentColumn) &&
|
|
currentColumn.params.orderBy.type === 'column' &&
|
|
currentColumn.params.orderDirection === 'asc' &&
|
|
isColumnOfType<CountIndexPatternColumn>(
|
|
'count',
|
|
currentLayer.columns[currentColumn.params.orderBy.columnId]
|
|
);
|
|
const usesFloatingPointField =
|
|
isColumnOfType<TermsIndexPatternColumn>('terms', currentColumn) &&
|
|
!supportsRarityRanking(indexPattern.getFieldByName(currentColumn.sourceField));
|
|
const usesMultipleFields =
|
|
isColumnOfType<TermsIndexPatternColumn>('terms', currentColumn) &&
|
|
(currentColumn.params.secondaryFields || []).length > 0;
|
|
if (!isAscendingCountSorting || usesFloatingPointField || usesMultipleFields) {
|
|
warningMessages.push(
|
|
<FormattedMessage
|
|
id="xpack.lens.indexPattern.precisionErrorWarning"
|
|
defaultMessage="{name} for this visualization may be approximate due to how the data is indexed. Try increasing the number of {topValues} or use {filters} instead of {topValues} for precise results. To learn more about this limit, {link}."
|
|
values={{
|
|
name: <EuiTextColor color="accent">{column.name}</EuiTextColor>,
|
|
topValues: (
|
|
<EuiTextColor color="subdued">
|
|
<FormattedMessage
|
|
id="xpack.lens.indexPattern.precisionErrorWarning.topValues"
|
|
defaultMessage="Top values"
|
|
/>
|
|
</EuiTextColor>
|
|
),
|
|
filters: (
|
|
<EuiTextColor color="subdued">
|
|
<FormattedMessage
|
|
id="xpack.lens.indexPattern.precisionErrorWarning.filters"
|
|
defaultMessage="Filters"
|
|
/>
|
|
</EuiTextColor>
|
|
),
|
|
link: (
|
|
<EuiLink
|
|
href={docLinks.links.aggs.terms_doc_count_error}
|
|
color="text"
|
|
target="_blank"
|
|
external={true}
|
|
>
|
|
<FormattedMessage
|
|
defaultMessage="visit the documentation"
|
|
id="xpack.lens.indexPattern.precisionErrorWarning.link"
|
|
/>
|
|
</EuiLink>
|
|
),
|
|
}}
|
|
/>
|
|
);
|
|
} else {
|
|
warningMessages.push(
|
|
<>
|
|
<FormattedMessage
|
|
id="xpack.lens.indexPattern.ascendingCountPrecisionErrorWarning"
|
|
defaultMessage="{name} for this visualization may be approximate due to how the data is indexed. Try sorting by rarity instead of ascending count of records. To learn more about this limit, {link}."
|
|
values={{
|
|
name: <EuiTextColor color="accent">{column.name}</EuiTextColor>,
|
|
link: (
|
|
<EuiLink
|
|
href={docLinks.links.aggs.rare_terms}
|
|
color="text"
|
|
target="_blank"
|
|
external={true}
|
|
>
|
|
<FormattedMessage
|
|
defaultMessage="visit the documentation"
|
|
id="xpack.lens.indexPattern.ascendingCountPrecisionErrorWarning.link"
|
|
/>
|
|
</EuiLink>
|
|
),
|
|
}}
|
|
/>
|
|
<EuiSpacer size="s" />
|
|
<EuiButton
|
|
onClick={() => {
|
|
setState((prevState) =>
|
|
mergeLayer({
|
|
state: prevState,
|
|
layerId,
|
|
newLayer: updateDefaultLabels(
|
|
updateColumnParam({
|
|
layer: currentLayer,
|
|
columnId: column.id,
|
|
paramName: 'orderBy',
|
|
value: {
|
|
type: 'rare',
|
|
maxDocCount: DEFAULT_MAX_DOC_COUNT,
|
|
},
|
|
}),
|
|
indexPattern
|
|
),
|
|
})
|
|
);
|
|
}}
|
|
>
|
|
{i18n.translate('xpack.lens.indexPattern.switchToRare', {
|
|
defaultMessage: 'Rank by rarity',
|
|
})}
|
|
</EuiButton>
|
|
</>
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
return warningMessages;
|
|
}
|
|
|
|
export function getVisualDefaultsForLayer(layer: IndexPatternLayer) {
|
|
return Object.keys(layer.columns).reduce<Record<string, Record<string, unknown>>>(
|
|
(memo, columnId) => {
|
|
const column = layer.columns[columnId];
|
|
if (column?.operationType) {
|
|
const opDefinition = operationDefinitionMap[column.operationType];
|
|
const params = opDefinition.getDefaultVisualSettings?.(column);
|
|
if (params) {
|
|
memo[columnId] = params;
|
|
}
|
|
}
|
|
return memo;
|
|
},
|
|
{}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Some utilities to extract queries/filters from specific column types
|
|
*/
|
|
|
|
/**
|
|
* Given a Filters column, extract and filter useful queries from it
|
|
*/
|
|
function extractQueriesFromFilters(
|
|
queries: FiltersIndexPatternColumn['params']['filters'] | undefined
|
|
) {
|
|
return queries?.map(({ input }) => input).filter(({ query }) => query?.trim() && query !== '*');
|
|
}
|
|
|
|
/**
|
|
* Given an Interval column in range mode transform the ranges into KQL queries
|
|
*/
|
|
function extractQueriesFromRanges(column: RangeIndexPatternColumn) {
|
|
return column.params.ranges
|
|
.map(({ from, to }) => {
|
|
let rangeQuery = '';
|
|
if (from != null && isFinite(from)) {
|
|
rangeQuery += `${column.sourceField} >= ${from}`;
|
|
}
|
|
if (to != null && isFinite(to)) {
|
|
if (rangeQuery.length) {
|
|
rangeQuery += ' AND ';
|
|
}
|
|
rangeQuery += `${column.sourceField} <= ${to}`;
|
|
}
|
|
return {
|
|
query: rangeQuery,
|
|
language: 'kuery',
|
|
};
|
|
})
|
|
.filter(({ query }) => query?.trim());
|
|
}
|
|
|
|
/**
|
|
* Given an Terms/Top values column transform each entry into a "field: term" KQL query
|
|
* This works also for multi-terms variant
|
|
*/
|
|
function extractQueriesFromTerms(
|
|
column: TermsIndexPatternColumn,
|
|
colId: string,
|
|
data: NonNullable<FramePublicAPI['activeData']>[string]
|
|
): Query[] {
|
|
const fields = [column.sourceField]
|
|
.concat(column.params.secondaryFields || [])
|
|
.filter(Boolean) as string[];
|
|
|
|
// extract the filters from the columns of the activeData
|
|
const queries = data.rows
|
|
.map(({ [colId]: value }) => {
|
|
if (value == null) {
|
|
return;
|
|
}
|
|
if (typeof value !== 'string' && Array.isArray(value.keys)) {
|
|
return value.keys
|
|
.map(
|
|
(term: string, index: number) =>
|
|
`${fields[index]}: ${`"${term === '' ? escape(term) : term}"`}`
|
|
)
|
|
.join(' AND ');
|
|
}
|
|
return `${column.sourceField}: ${`"${value === '' ? escape(value) : value}"`}`;
|
|
})
|
|
.filter(Boolean) as string[];
|
|
|
|
// dedup queries before returning
|
|
return [...new Set(queries)].map((query) => ({ language: 'kuery', query }));
|
|
}
|
|
|
|
/**
|
|
* Used for a Terms column to decide whether to use a simple existence query (fallback) instead
|
|
* of more specific queries.
|
|
* The check targets the scenarios where no data is available, or when there's a transposed table
|
|
* and it's not yet possible to track it back to the original table
|
|
*/
|
|
function shouldUseTermsFallback(
|
|
data: NonNullable<FramePublicAPI['activeData']>[string] | undefined,
|
|
colId: string
|
|
) {
|
|
const dataId = data?.columns.find(({ id }) => getOriginalId(id) === colId)?.id;
|
|
return !dataId || dataId !== colId;
|
|
}
|
|
|
|
/**
|
|
* Collect filters from metrics:
|
|
* * if there's at least one unfiltered metric, then just return an empty list of filters
|
|
* * otherwise get all the filters, with the only exception of those from formula (referenced columns will have it anyway)
|
|
*/
|
|
function collectFiltersFromMetrics(layer: IndexPatternLayer, columnIds: string[]) {
|
|
// Isolate filtered metrics first
|
|
// mind to ignore non-filterable columns and formula columns
|
|
const metricColumns = Object.keys(layer.columns).filter((colId) => {
|
|
const column = layer.columns[colId];
|
|
const operationDefinition = operationDefinitionMap[column?.operationType];
|
|
return (
|
|
!column?.isBucketed &&
|
|
// global filters for formulas are picked up by referenced columns
|
|
!isColumnOfType<FormulaIndexPatternColumn>('formula', column) &&
|
|
operationDefinition?.filterable
|
|
);
|
|
});
|
|
const { filtered = [], unfiltered = [] } = groupBy(metricColumns, (colId) =>
|
|
layer.columns[colId]?.filter ? 'filtered' : 'unfiltered'
|
|
);
|
|
|
|
// extract filters from filtered metrics
|
|
// consider all the columns, included referenced ones to cover also the formula case
|
|
return (
|
|
filtered
|
|
// if there are metric columns not filtered, then ignore filtered columns completely
|
|
.filter(() => !unfiltered.length)
|
|
.map((colId) => layer.columns[colId]?.filter)
|
|
// filter out empty filters as well
|
|
.filter((filter) => filter?.query?.trim()) as Query[]
|
|
);
|
|
}
|
|
|
|
interface GroupedQueries {
|
|
kuery?: Query[];
|
|
lucene?: Query[];
|
|
}
|
|
|
|
function collectOnlyValidQueries(
|
|
filteredQueries: GroupedQueries,
|
|
operationQueries: GroupedQueries[],
|
|
queryLanguage: 'kuery' | 'lucene'
|
|
) {
|
|
return [
|
|
filteredQueries[queryLanguage],
|
|
...operationQueries.map(({ [queryLanguage]: filter }) => filter),
|
|
].filter((filters) => filters?.length) as Query[][];
|
|
}
|
|
|
|
export function getFiltersInLayer(
|
|
layer: IndexPatternLayer,
|
|
columnIds: string[],
|
|
layerData: NonNullable<FramePublicAPI['activeData']>[string] | undefined
|
|
) {
|
|
const filtersFromMetricsByLanguage = groupBy(
|
|
collectFiltersFromMetrics(layer, columnIds),
|
|
'language'
|
|
) as unknown as GroupedQueries;
|
|
|
|
const filterOperation = columnIds
|
|
.map((colId) => {
|
|
const column = layer.columns[colId];
|
|
|
|
if (isColumnOfType<FiltersIndexPatternColumn>('filters', column)) {
|
|
const groupsByLanguage = groupBy(
|
|
column.params.filters,
|
|
({ input }) => input.language
|
|
) as Record<'lucene' | 'kuery', FiltersIndexPatternColumn['params']['filters']>;
|
|
|
|
return {
|
|
kuery: extractQueriesFromFilters(groupsByLanguage.kuery),
|
|
lucene: extractQueriesFromFilters(groupsByLanguage.lucene),
|
|
};
|
|
}
|
|
|
|
if (isColumnOfType<RangeIndexPatternColumn>('range', column) && column.sourceField) {
|
|
return {
|
|
kuery: extractQueriesFromRanges(column),
|
|
};
|
|
}
|
|
|
|
if (
|
|
isColumnOfType<TermsIndexPatternColumn>('terms', column) &&
|
|
!(column.params.otherBucket || column.params.missingBucket)
|
|
) {
|
|
if (!layerData || shouldUseTermsFallback(layerData, colId)) {
|
|
const fields = operationDefinitionMap[column.operationType]!.getCurrentFields!(column);
|
|
return {
|
|
kuery: fields.map((field) => ({
|
|
query: `${field}: *`,
|
|
language: 'kuery',
|
|
})),
|
|
};
|
|
}
|
|
|
|
return {
|
|
kuery: extractQueriesFromTerms(column, colId, layerData),
|
|
};
|
|
}
|
|
})
|
|
.filter(Boolean) as GroupedQueries[];
|
|
return {
|
|
kuery: collectOnlyValidQueries(filtersFromMetricsByLanguage, filterOperation, 'kuery'),
|
|
lucene: collectOnlyValidQueries(filtersFromMetricsByLanguage, filterOperation, 'lucene'),
|
|
};
|
|
}
|