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

[SearchBar] Field value selection doesn't work on keyword fields by default #6217

Closed
PhaedrusTheGreek opened this issue Sep 7, 2022 · 1 comment · Fixed by #6220
Closed
Labels

Comments

@PhaedrusTheGreek
Copy link
Contributor

When using SearchBar filters such as:

{
      type: 'field_value_selection',
      field: 'type',
      name: 'Type',
      multiSelect: 'or',
      cache: 10000,
      options
    },

Or a query such as type:(elasticsearch or logstash) will generate ES Query DSL match query:

 "bool": {
    "must": [
        {
            "match": {
                "type": {
                    "query": "elasticsearch logstash",
                    "operator": "or"
                }
            }
        }
    ]
}

However the above query will not work if the type field is a keyword.

  • Typically keywords are used as filters, since they are explicit
  • The keyword field analyzer won't tokenize elasticsearch logstash, and so the only valid match is exactly that.

I did notice that toESQuery has options.fieldValuesToAndQuery, which should provide a workaround

@PhaedrusTheGreek
Copy link
Contributor Author

This is a mess , since importing of some dependencies was not possible, however the following options.fieldValuesToAndQuery customization seems to fix the problem:

...
  if (terms.length > 0) {
    queries.push({
      bool: {
        [andOr === 'and' ? 'must' : 'should']: [
          ...terms.map((value) => ({ match: { [field]: value } })),
        ],
      },
    });
  }
...

Full Change:

import { dateFormatAliases, keysOf } from '@elastic/eui';
import _ from 'lodash';

import { isString } from 'lodash';
import moment, { isDate, isMoment, Moment, MomentInput, utc } from 'moment';

/**
 * By default, the DSL generator doesn't support keyword field mappings
 *
 * https://github.com/elastic/eui/issues/6217
 *
 */
export const fieldValuesToQueryUsingBool = (
  field: string,
  operations: { [x in 'eq' | 'exact' | 'gt' | 'gte' | 'lt' | 'lte']: any[] },
  andOr: 'and' | 'or'
) => {
  const queries: any[] = [];

  keysOf(operations).forEach((operator) => {
    const values = operations[operator];
    switch (operator) {
      case 'eq':
        const terms: any[] = [];
        const phrases: string[] = [];
        const dates: any[] = [];

        values.forEach((value: any) => {
          if (isDateValue(value)) {
            dates.push(value);
          } else if (isDateLike(value)) {
            dates.push(dateValue(value)!);
          } else if (isString(value) && value.match(/\s/)) {
            phrases.push(value);
          } else {
            terms.push(value);
          }
        });

        if (terms.length > 0) {
          queries.push({
            bool: {
              [andOr === 'and' ? 'must' : 'should']: [
                ...terms.map((value) => ({ match: { [field]: value } })),
              ],
            },
          });
        }

        if (phrases.length > 0) {
          queries.push(
            ...phrases.map((phrase) => ({
              match_phrase: {
                [field]: phrase,
              },
            }))
          );
        }

        if (dates.length > 0) {
          queries.push(
            ...dates.map((value) => ({
              match: {
                [field]: processDateOperation(value).expression,
              },
            }))
          );
        }

        break;

      default:
        values.forEach((value: any) => {
          if (isDateValue(value)) {
            const operation = processDateOperation(value, operator);
            queries.push({
              range: {
                [field]: {
                  [operation.operator!]: operation.expression,
                },
              },
            });
          } else {
            queries.push({
              range: {
                [field]: {
                  [operator]: value,
                },
              },
            });
          }
        });
    }
  });

  if (queries.length === 1) {
    return queries[0];
  }

  const key = andOr === 'and' ? 'must' : 'should';
  return {
    bool: {
      [key]: [...queries],
    },
  };
};

const processDateOperation = (value: any, operator?: any) => {
  const { granularity, resolve } = value;
  let expression = printIso8601(resolve());
  if (!granularity) {
    return { operator, expression };
  }
  switch (operator) {
    case 'gt':
      expression = `${expression}||+1${granularity.es}/${granularity.es}`;
      return { operator: 'gte', expression };

    case 'gte':
      expression = `${expression}||/${granularity.es}`;
      return { operator, expression };

    case 'lt':
      expression = `${expression}||/${granularity.es}`;
      return { operator, expression };

    case 'lte':
      expression = `${expression}||+1${granularity.es}/${granularity.es}`;
      return { operator: 'lt', expression };

    default:
      expression = `${expression}||/${granularity.es}`;
      return { expression };
  }
};

export const dateValue: (
  raw: MomentInput,
  granularity?: GranularityType,
  dateFormat?: any
) => any = (raw, granularity, dateFormat = defaultDateFormat) => {
  if (!raw) {
    return undefined;
  }

  if (isDateLike(raw)) {
    const dateValue: any = {
      type: DATE_TYPE,
      raw,
      granularity,
      text: dateFormat.print(raw),
      resolve: () => moment(raw),
    };
    return dateValue;
  }
  if (_.isNumber(raw)) {
    return {
      type: DATE_TYPE,
      raw,
      granularity,
      text: raw.toString(),
      resolve: () => moment(raw),
    };
  }
  const text = raw.toString();
  return {
    type: DATE_TYPE,
    raw,
    granularity,
    text,
    resolve: () => dateFormat.parse(text),
  };
};

export const DATE_TYPE = 'date';

export const printIso8601 = (value: MomentInput) => {
  return utc(value).format(moment.defaultFormatUtc);
};

export const isDateValue = (value: any) => {
  return (
    !!value &&
    value.type === DATE_TYPE &&
    !!value.raw &&
    !!value.text &&
    !!value.resolve
  );
};

export const isDateLike = (value: any): value is moment.Moment | Date => {
  return isMoment(value) || isDate(value);
};

const GRANULARITY_KEY = '__eui_granularity';
const FORMAT_KEY = '__eui_format';

export interface EuiMoment extends Moment {
  __eui_granularity?: GranularityType;
  __eui_format?: string;
}

export interface GranularityType {
  es: 'd' | 'w' | 'M' | 'y';
  js: 'day' | 'week' | 'month' | 'year';
  isSame: (d1: Moment, d2: Moment) => boolean;
  start: (date: Moment) => Moment;
  startOfNext: (date: Moment) => Moment;
  iso8601: (date: Moment) => string;
}

export const Granularity: any = Object.freeze({
  DAY: {
    es: 'd',
    js: 'day',
    isSame: (d1: any, d2: any) => d1.isSame(d2, 'day'),
    start: (date: any) => date.startOf('day'),
    startOfNext: (date: any) => date.add(1, 'days').startOf('day'),
    iso8601: (date: any) => date.format('YYYY-MM-DD'),
  },
  WEEK: {
    es: 'w',
    js: 'week',
    isSame: (d1: any, d2: any) => d1.isSame(d2, 'week'),
    start: (date: any) => date.startOf('week'),
    startOfNext: (date: any) => date.add(1, 'weeks').startOf('week'),
    iso8601: (date: any) => date.format('YYYY-MM-DD'),
  },
  MONTH: {
    es: 'M',
    js: 'month',
    isSame: (d1: any, d2: any) => d1.isSame(d2, 'month'),
    start: (date: any) => date.startOf('month'),
    startOfNext: (date: any) => date.add(1, 'months').startOf('month'),
    iso8601: (date: any) => date.format('YYYY-MM'),
  },
  YEAR: {
    es: 'y',
    js: 'year',
    isSame: (d1: any, d2: any) => d1.isSame(d2, 'year'),
    start: (date: any) => date.startOf('year'),
    startOfNext: (date: any) => date.add(1, 'years').startOf('year'),
    iso8601: (date: any) => date.format('YYYY'),
  },
});

const parseTime = (value: string) => {
  const parsed: EuiMoment = utc(
    value,
    ['HH:mm', 'H:mm', 'H:mm', 'h:mm a', 'h:mm A', 'hh:mm a', 'hh:mm A'],
    true
  );
  if (parsed.isValid()) {
    parsed[FORMAT_KEY] = parsed.creationData().format as string;
    return parsed;
  }
};

const parseDay = (value: string) => {
  let parsed: EuiMoment;

  switch (value.toLowerCase()) {
    case 'today':
      parsed = utc().startOf('day');
      parsed[GRANULARITY_KEY] = Granularity.DAY;
      parsed[FORMAT_KEY] = value;
      return parsed;

    case 'yesterday':
      parsed = utc().subtract(1, 'days').startOf('day');
      parsed[GRANULARITY_KEY] = Granularity.DAY;
      parsed[FORMAT_KEY] = value;
      return parsed;

    case 'tomorrow':
      parsed = utc().add(1, 'days').startOf('day');
      parsed[GRANULARITY_KEY] = Granularity.DAY;
      parsed[FORMAT_KEY] = value;
      return parsed;

    default:
      parsed = utc(
        value,
        [
          'ddd',
          'dddd',
          'D MMM YY',
          'Do MMM YY',
          'D MMM YYYY',
          'Do MMM YYYY',
          'DD MMM YY',
          'DD MMM YYYY',
          'D MMMM YY',
          'Do MMMM YY',
          'D MMMM YYYY',
          'Do MMMM YYYY',
          'DD MMMM YY',
          'DD MMMM YYYY',
          'YYYY-MM-DD',
        ],
        true
      );
      if (parsed.isValid()) {
        try {
          parsed[GRANULARITY_KEY] = Granularity.DAY;
          parsed[FORMAT_KEY] = parsed.creationData().format as string;
          return parsed;
        } catch (e) {
          console.error(e);
        }
      }
  }
};

const parseWeek = (value: string) => {
  let parsed: EuiMoment;
  switch (value.toLowerCase()) {
    case 'this week':
      parsed = utc();
      break;
    case 'last week':
      parsed = utc().subtract(1, 'weeks');
      break;
    case 'next week':
      parsed = utc().add(1, 'weeks');
      break;
    default:
      const match = value.match(/week (\d+)/i);
      if (match) {
        const weekNr = Number(match[1]);
        parsed = utc().weeks(weekNr);
      } else {
        return;
      }
  }
  if (parsed != null && parsed.isValid()) {
    parsed = parsed.startOf('week');
    parsed[GRANULARITY_KEY] = Granularity.WEEK;
    parsed[FORMAT_KEY] = parsed.creationData().format as string;
    return parsed;
  }
};

const parseMonth = (value: string) => {
  let parsed: EuiMoment;
  switch (value.toLowerCase()) {
    case 'this month':
      parsed = utc();
      break;
    case 'next month':
      parsed = utc().endOf('month').add(2, 'days');
      break;
    case 'last month':
      parsed = utc().startOf('month').subtract(2, 'days');
      break;
    default:
      parsed = utc(value, ['MMM', 'MMMM'], true);
      if (parsed.isValid()) {
        const now = utc();
        parsed.year(now.year());
      } else {
        parsed = utc(
          value,
          [
            'MMM YY',
            'MMMM YY',
            'MMM YYYY',
            'MMMM YYYY',
            'YYYY MMM',
            'YYYY MMMM',
            'YYYY-MM',
          ],
          true
        );
      }
  }
  if (parsed.isValid()) {
    parsed.startOf('month');
    parsed[GRANULARITY_KEY] = Granularity.MONTH;
    parsed[FORMAT_KEY] = parsed.creationData().format as string;
    return parsed;
  }
};

const parseYear = (value: string) => {
  let parsed: EuiMoment;
  switch (value.toLowerCase()) {
    case 'this year':
      parsed = utc().startOf('year');
      parsed[GRANULARITY_KEY] = Granularity.YEAR;
      parsed[FORMAT_KEY] = value;
      return parsed;
    case 'next year':
      parsed = utc().endOf('year').add(2, 'months').startOf('year');
      parsed[GRANULARITY_KEY] = Granularity.YEAR;
      parsed[FORMAT_KEY] = value;
      return parsed;
    case 'last year':
      parsed = utc().startOf('year').subtract(2, 'months').startOf('year');
      parsed[GRANULARITY_KEY] = Granularity.YEAR;
      parsed[FORMAT_KEY] = value;
      return parsed;
    default:
      parsed = utc(value, ['YY', 'YYYY'], true);
      if (parsed.isValid()) {
        parsed[GRANULARITY_KEY] = Granularity.YEAR;
        parsed[FORMAT_KEY] = parsed.creationData().format as string;
        return parsed;
      }
  }
};

const parseDefault = (value: string) => {
  let parsed: EuiMoment = utc(
    value,
    [
      moment.ISO_8601,
      moment.RFC_2822,
      'DD MMM YY HH:mm',
      'DD MMM YY HH:mm:ss',
      'DD MMM YYYY HH:mm',
      'DD MMM YYYY HH:mm:ss',
      'DD MMMM YYYY HH:mm',
      'DD MMMM YYYY HH:mm:ss',
    ],
    true
  );
  if (!parsed.isValid()) {
    const time = Date.parse(value);
    const offset = moment(time).utcOffset();
    parsed = utc(time);
    parsed.add(offset, 'minutes');
  }
  if (parsed.isValid()) {
    parsed[FORMAT_KEY] = parsed.creationData().format as string;
  }
  return parsed;
};

const printDay = (now: Moment, date: Moment, format: string) => {
  if (format.match(/yesterday|tomorrow|today/i)) {
    if (now.isSame(date, 'day')) {
      return 'today';
    }
    if (now.subtract(1, 'day').isSame(date, 'day')) {
      return 'yesterday';
    }
    if (now.add(1, 'day').isSame(date, 'day')) {
      return 'tomorrow';
    }
    if (now.isSame(date, 'week')) {
      return date.format('dddd');
    }
  }
  return date.format(format);
};

const printWeek = (now: Moment, date: Moment, format: string) => {
  if (format.match(/(?:this|next|last) week/i)) {
    if (now.isSame(date, 'week')) {
      return 'This Week';
    }
    if (now.startOf('week').subtract(2, 'days').isSame(date, 'week')) {
      return 'Last Week';
    }
    if (now.endOf('week').add(2, 'days').isSame(date, 'week')) {
      return 'Next Week';
    }
  }
  return date.format(format);
};

const printMonth = (now: Moment, date: Moment, format: string) => {
  if (format.match(/(?:this|next|last) month/i)) {
    if (now.isSame(date, 'month')) {
      return 'This Month';
    }
    if (now.startOf('month').subtract(2, 'days').isSame(date, 'month')) {
      return 'Last Month';
    }
    if (now.endOf('month').add(2, 'days').isSame(date, 'month')) {
      return 'Next Month';
    }
  }
  return date.format(format);
};

const printYear = (now: Moment, date: Moment, format: string) => {
  if (format.match(/(?:this|next|last) year/i)) {
    if (now.isSame(date, 'year')) {
      return 'This Year';
    }
    if (now.startOf('year').subtract(2, 'months').isSame(date, 'year')) {
      return 'Last Year';
    }
    if (now.endOf('year').add(2, 'months').isSame(date, 'year')) {
      return 'Next Year';
    }
  }
  return date.format(format);
};

export const dateGranularity = (parsedDate: EuiMoment) => {
  return parsedDate[GRANULARITY_KEY]!;
};

export const defaultDateFormat = Object.freeze({
  parse(value: string) {
    const parsed =
      parseDay(value) ||
      parseMonth(value) ||
      parseYear(value) ||
      parseWeek(value) ||
      parseTime(value) ||
      parseDefault(value);
    if (!parsed) {
      throw new Error(`could not parse [${value}] as date`);
    }
    return parsed;
  },

  print(date: EuiMoment | MomentInput, defaultGranularity = undefined) {
    date = moment.isMoment(date) ? date : utc(date);
    const euiDate: EuiMoment = date as EuiMoment;
    const now = utc();
    const format = euiDate[FORMAT_KEY];
    if (!format) {
      return date.format(dateFormatAliases.iso8601);
    }
    const granularity = euiDate[GRANULARITY_KEY] || defaultGranularity;
    switch (granularity) {
      case Granularity.DAY:
        return printDay(now, date, format);
      case Granularity.WEEK:
        return printWeek(now, date, format);
      case Granularity.MONTH:
        return printMonth(now, date, format);
      case Granularity.YEAR:
        return printYear(now, date, format);
      default:
        return date.format(format);
    }
  },
});

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging a pull request may close this issue.

1 participant