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

Add Wildcard ("Contains") filter #16357

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
37 changes: 37 additions & 0 deletions packages/kbn-es-query/src/filters/contains.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

// Creates an filter where the given field contains the given value
export function buildContainsFilter(field, value, indexPattern) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Could you also add some unit tests for this function?

const index = indexPattern.id;
const type = 'contains';
const key = field.name;

Copy link
Contributor

Choose a reason for hiding this comment

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

We need to take into account scripted fields, similar to how other filters builders do.

const filter = {
meta: { index, type, key, value },
};
filter.query = {
Copy link
Contributor

Choose a reason for hiding this comment

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

One thing we learned when we were working on adding wildcard support to KQL is that text fields don't work well with simple wildcard queries. It's actually best to use a query_string query for wildcard queries on text fields because it does some special handling that makes it work how most users would expect. I'd suggest updating the query building logic to look like this:

  // Text fields should be treated in a special manner because their values are analyzed.
  // For example, a field configured with the standard analyzer with a value of "Foo Bar" would not
  // match with a simple wildcard query on the value "Foo Ba*" because the analyzer tokenized and
  // lowercased the text at index time. The actual values stored in the inverted index are "foo" and
  // "bar" but the wildcard query is literally searching for a token that starts with "Foo Ba".
  // The query_string query attempts to make wildcards more useful with text fields. It will do
  // tokenization and some simple normalization on the term that contains the wildcard, so that the
  // query "Foo Ba*" would actually match. This functionality is not exposed in any other query type
  // so we have to sort of abuse the query_string query here if we're using a text field
  if (field.esTypes && field.esTypes.includes('text')) {
    filter.query = {
      query_string: {
        fields: [field.name],
        query: `*${escapeQueryString(value)}*`,
      }
    };
  }
  else {
    filter.query = {
      wildcard: {
        [field.name]: {
          value: `*${value}*`,
        },
      },
    };
  }

wildcard: {
[field.name]: {
value: `*${value}*`,
},
},
};
return filter;
}
15 changes: 14 additions & 1 deletion packages/kbn-es-query/src/filters/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,26 @@
*/

import { Field, IndexPattern } from 'ui/index_patterns';
import { CustomFilter, ExistsFilter, PhraseFilter, PhrasesFilter, RangeFilter } from './lib';
import {
ContainsFilter,
CustomFilter,
ExistsFilter,
PhraseFilter,
PhrasesFilter,
RangeFilter,
} from './lib';
import { RangeFilterParams } from './lib/range_filter';

export * from './lib';

export function buildExistsFilter(field: Field, indexPattern: IndexPattern): ExistsFilter;

export function buildContainsFilter(
field: Field,
value: string,
indexPattern: IndexPattern
): ContainsFilter;

export function buildPhraseFilter(
field: Field,
value: string,
Expand Down
1 change: 1 addition & 0 deletions packages/kbn-es-query/src/filters/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
* under the License.
*/

export * from './contains';
export * from './exists';
export * from './phrase';
export * from './phrases';
Expand Down
30 changes: 30 additions & 0 deletions packages/kbn-es-query/src/filters/lib/contains_filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { Filter, FilterMeta } from './meta_filter';

export type ContainsFilterMeta = FilterMeta & {
params: {
value: string;
};
};

export type ContainsFilter = Filter & {
meta: ContainsFilterMeta;
};
3 changes: 3 additions & 0 deletions packages/kbn-es-query/src/filters/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
export * from './meta_filter';

// The actual filter types
import { ContainsFilter } from './contains_filter';
import { CustomFilter } from './custom_filter';
import { ExistsFilter } from './exists_filter';
import { GeoBoundingBoxFilter } from './geo_bounding_box_filter';
Expand All @@ -30,6 +31,7 @@ import { PhrasesFilter } from './phrases_filter';
import { QueryStringFilter } from './query_string_filter';
import { RangeFilter } from './range_filter';
export {
ContainsFilter,
CustomFilter,
ExistsFilter,
GeoBoundingBoxFilter,
Expand All @@ -42,6 +44,7 @@ export {

// Any filter associated with a field (used in the filter bar/editor)
export type FieldFilter =
| ContainsFilter
| ExistsFilter
| GeoBoundingBoxFilter
| GeoPolygonFilter
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { EuiFormRow } from '@elastic/eui';
import { InjectedIntl, injectI18n } from '@kbn/i18n/react';
import React, { Component } from 'react';
import { Field } from 'ui/index_patterns';
import { ValueInputType } from './value_input_type';

interface Props {
field?: Field;
value?: string;
onChange: (value: string | number | boolean) => void;
intl: InjectedIntl;
}

class ContainsValueInputUI extends Component<Props> {
public render() {
return (
<EuiFormRow
label={this.props.intl.formatMessage({
id: 'data.filter.filterEditor.valueInputLabel',
defaultMessage: 'Value',
})}
>
<ValueInputType
placeholder={this.props.intl.formatMessage({
id: 'data.filter.filterEditor.valueInputPlaceholder',
defaultMessage: 'Enter a value',
})}
value={this.props.value}
onChange={this.props.onChange}
type={this.props.field ? this.props.field.type : 'string'}
/>
</EuiFormRow>
);
}
}

export const ContainsValueInput = injectI18n(ContainsValueInputUI);
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
isFilterValid,
} from './lib/filter_editor_utils';
import { Operator } from './lib/filter_operators';
import { ContainsValueInput } from './contains_value_input';
import { PhraseValueInput } from './phrase_value_input';
import { PhrasesValuesInput } from './phrases_values_input';
import { RangeValueInput } from './range_value_input';
Expand Down Expand Up @@ -314,6 +315,14 @@ class FilterEditorUI extends Component<Props, State> {
switch (this.state.selectedOperator.type) {
case 'exists':
return '';
case 'contains':
return (
<ContainsValueInput
field={this.state.selectedField}
value={this.state.params}
onChange={this.onParamsChange}
/>
);
case 'phrase':
return (
<PhraseValueInput
Expand Down Expand Up @@ -351,7 +360,7 @@ class FilterEditorUI extends Component<Props, State> {

private isUnknownFilterType() {
const { type } = this.props.filter.meta;
return !!type && !['phrase', 'phrases', 'range', 'exists'].includes(type);
return !!type && !['phrase', 'phrases', 'range', 'exists', 'contains'].includes(type);
}

private getIndexPatternFromFilter() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@

import dateMath from '@elastic/datemath';
import {
buildContainsFilter,
buildExistsFilter,
buildPhraseFilter,
buildPhrasesFilter,
buildRangeFilter,
ContainsFilter,
FieldFilter,
Filter,
FilterMeta,
Expand Down Expand Up @@ -70,6 +72,8 @@ export function getOperatorOptions(field: Field) {

export function getFilterParams(filter: Filter) {
switch (filter.meta.type) {
case 'contains':
return (filter as ContainsFilter).meta.value;
case 'phrase':
return (filter as PhraseFilter).meta.params.query;
case 'phrases':
Expand Down Expand Up @@ -122,6 +126,11 @@ export function isFilterValid(
return validateParams(params.from, field.type) || validateParams(params.to, field.type);
case 'exists':
return true;
case 'contains':
if (typeof params !== 'string') {
return false;
}
return true;
default:
throw new Error(`Unknown operator type: ${operator.type}`);
}
Expand Down Expand Up @@ -158,6 +167,8 @@ function buildBaseFilter(
return buildRangeFilter(field, newParams, indexPattern);
case 'exists':
return buildExistsFilter(field, indexPattern);
case 'contains':
return buildContainsFilter(field, params, indexPattern);
default:
throw new Error(`Unknown operator type: ${operator.type}`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@ export const doesNotExistOperator = {
negate: true,
};

export const containsOperator = {
message: i18n.translate('data.filter.filterEditor.containsOperatorOptionLabel', {
defaultMessage: 'contains',
}),
type: 'contains',
negate: false,
fieldTypes: ['string'],
};

export const FILTER_OPERATORS: Operator[] = [
isOperator,
isNotOperator,
Expand All @@ -103,4 +112,5 @@ export const FILTER_OPERATORS: Operator[] = [
isNotBetweenOperator,
existsOperator,
doesNotExistOperator,
containsOperator,
];
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ import { EuiBadge } from '@elastic/eui';
import { Filter, isFilterPinned } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import React, { SFC } from 'react';
import { existsOperator, isOneOfOperator } from '../filter_editor/lib/filter_operators';
import {
containsOperator,
existsOperator,
isOneOfOperator,
} from '../filter_editor/lib/filter_operators';

interface Props {
filter: Filter;
Expand Down Expand Up @@ -90,6 +94,8 @@ export function getFilterDisplayText(filter: Filter) {
return `${prefix}${filter.meta.key}: ${filter.meta.value}`;
case 'geo_polygon':
return `${prefix}${filter.meta.key}: ${filter.meta.value}`;
case 'contains':
return `${prefix}${filter.meta.key} ${containsOperator.message} ${filter.meta.value}`;
case 'phrase':
return `${prefix}${filter.meta.key}: ${filter.meta.value}`;
case 'phrases':
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

export async function mapContains(filter) {
const { type, key, value, params } = filter.meta;
if (type !== 'contains') {
throw filter;
} else {
return { type, key, value, params };
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { mapMissing } from './map_missing';
import { mapQueryString } from './map_query_string';
import { mapGeoBoundingBox } from './map_geo_bounding_box';
import { mapGeoPolygon } from './map_geo_polygon';
import { mapContains } from './map_contains';
import { mapDefault } from './map_default';
import { generateMappingChain } from './generate_mapping_chain';

Expand All @@ -35,7 +36,7 @@ export async function mapFilter(indexPatterns, filter) {

// Each mapper is a simple promise function that test if the mapper can
// handle the mapping or not. If it handles it then it will resolve with
// and object that has the key and value for the filter. Otherwise it will
// an object that has the key and value for the filter. Otherwise it will
// reject it with the original filter. We had to go down the promise interface
// because mapTerms and mapRange need access to the indexPatterns to format
// the values and that's only available through the field formatters.
Expand All @@ -57,6 +58,7 @@ export async function mapFilter(indexPatterns, filter) {
mapQueryString,
mapGeoBoundingBox(indexPatterns),
mapGeoPolygon(indexPatterns),
mapContains,
mapDefault,
];

Expand Down