Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/*
* 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 {
getFieldCategory,
resolveInputToConcreteFields,
type ResolvedValidField,
} from './get_field_values_handler';

describe('getFieldCategory', () => {
it.each([
['keyword', 'keyword'],
['constant_keyword', 'keyword'],
['ip', 'keyword'],
['long', 'numeric'],
['integer', 'numeric'],
['short', 'numeric'],
['byte', 'numeric'],
['double', 'numeric'],
['float', 'numeric'],
['half_float', 'numeric'],
['scaled_float', 'numeric'],
['unsigned_long', 'numeric'],
['date', 'date'],
['date_nanos', 'date'],
['boolean', 'boolean'],
['text', 'text'],
['match_only_text', 'text'],
] as const)('maps "%s" to "%s"', (fieldType, expectedCategory) => {
expect(getFieldCategory(fieldType)).toBe(expectedCategory);
});

it.each(['geo_point', 'flattened', 'object', 'nested', 'unknown_type'])(
'returns "unsupported" for "%s"',
(fieldType) => {
expect(getFieldCategory(fieldType)).toBe('unsupported');
}
);
});

describe('resolveInputToConcreteFields', () => {
const allFieldNames = [
'service.name',
'service.environment',
'host.name',
'host.ip',
'host.os',
'@timestamp',
];

const fieldNameToTypeMap: Record<string, string | undefined> = {
'service.name': 'keyword',
'service.environment': 'keyword',
'host.name': 'keyword',
'host.ip': 'ip',
'host.os': undefined,
'@timestamp': 'date',
};

describe('literal field name', () => {
it('returns a resolved field when the field exists', () => {
const result = resolveInputToConcreteFields(
'service.name',
allFieldNames,
fieldNameToTypeMap
);

expect(result).toEqual([
{ field: 'service.name', fieldType: 'keyword', category: 'keyword' },
]);
});

it('returns an error when the field does not exist', () => {
const result = resolveInputToConcreteFields(
'missing.field',
allFieldNames,
fieldNameToTypeMap
);

expect(result).toEqual([
{ input: 'missing.field', error: 'Field "missing.field" not found' },
]);
});
});

describe('wildcard pattern', () => {
it('matches multiple fields and returns their categories', () => {
const result = resolveInputToConcreteFields(
'host.*',
allFieldNames,
fieldNameToTypeMap
) as ResolvedValidField[];

expect(result.map((r) => r.field)).toEqual(['host.name', 'host.ip']);
});

it('excludes fields with undefined type (object/nested)', () => {
const result = resolveInputToConcreteFields('host.*', allFieldNames, fieldNameToTypeMap);

const fieldNames = result
.filter((r): r is ResolvedValidField => 'field' in r)
.map((r) => r.field);

expect(fieldNames).not.toContain('host.os');
});

it('returns an error when no fields match', () => {
const result = resolveInputToConcreteFields(
'nonexistent.*',
allFieldNames,
fieldNameToTypeMap
);

expect(result).toEqual([
{ input: 'nonexistent.*', error: 'No fields match pattern "nonexistent.*"' },
]);
});

it('matches across different field categories', () => {
const result = resolveInputToConcreteFields('*', allFieldNames, fieldNameToTypeMap);

const categories = result
.filter((r): r is ResolvedValidField => 'field' in r)
.map((r) => r.category);

expect(categories).toContain('keyword');
expect(categories).toContain('date');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import type { IScopedClusterClient } from '@kbn/core/server';
import { groupBy, keyBy, mapValues } from 'lodash';
import { minimatch } from 'minimatch';
import { getTypedSearch } from '../../utils/get_typed_search';
import { timeRangeFilter, kqlFilter as toKqlFilter } from '../../utils/dsl_filters';
import { parseDatemath } from '../../utils/time';
Expand Down Expand Up @@ -212,13 +213,8 @@ async function getTextFieldSampleValues(
);
}

/** Converts a wildcard pattern to a regex */
function wildcardToRegex(pattern: string): RegExp {
return new RegExp(`^${pattern.replace(/\./g, '\\.').replace(/\*/g, '.*')}$`);
}

/** Determines the category for a field type */
function getFieldCategory(
export function getFieldCategory(
fieldType: string
): 'keyword' | 'numeric' | 'date' | 'boolean' | 'text' | 'unsupported' {
if (KEYWORD_TYPES.includes(fieldType)) return 'keyword';
Expand All @@ -229,7 +225,7 @@ function getFieldCategory(
return 'unsupported';
}

interface ResolvedValidField {
export interface ResolvedValidField {
field: string;
fieldType: string;
category: ReturnType<typeof getFieldCategory>;
Expand All @@ -238,22 +234,20 @@ interface ResolvedErrorField {
input: string;
error: string;
}
type ResolvedField = ResolvedValidField | ResolvedErrorField;
export type ResolvedField = ResolvedValidField | ResolvedErrorField;

/** Resolves an input (field name or wildcard) to concrete fields or an error */
function resolveInputToConcreteFields(
export function resolveInputToConcreteFields(
input: string,
allFieldNames: string[],
fieldNameToTypeMap: Record<string, string | undefined>
): ResolvedField[] {
const isWildcard = input.includes('*');
const matchingFields = isWildcard
? allFieldNames.filter((f) => wildcardToRegex(input).test(f) && fieldNameToTypeMap[f])
: fieldNameToTypeMap[input]
? [input]
: [];
const matchingFields = allFieldNames.filter(
(f) => minimatch(f, input, { dot: true }) && fieldNameToTypeMap[f]
);
Comment on lines +254 to +256

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I like the refactor to have minimatch.

QQ: This change removes the fast path that existed for non-wildcard field names.
Previously, inputs like service.name did a direct map lookup. Now every input (including literals like service.name) scans the entire allFieldNames array through minimatch. For indices with hundreds/thousands of fields and multiple literal field inputs, this introduces a performance regression. Can we reintroduce the fast path lookup?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in d18a4a5


if (matchingFields.length === 0) {
const isWildcard = input.includes('*');
return [
{
input,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* 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 { extractFieldPaths } from './get_index_fields_handler';

describe('extractFieldPaths', () => {
it('extracts top-level keys from a flat object', () => {
expect(extractFieldPaths({ a: 1, b: 'hello' })).toEqual(['a', 'b']);
});

it('extracts dot-notation paths from nested objects', () => {
expect(extractFieldPaths({ a: { b: 1, c: 2 } })).toEqual(['a.b', 'a.c']);
});

it('handles deeply nested objects', () => {
expect(extractFieldPaths({ a: { b: { c: { d: 1 } } } })).toEqual(['a.b.c.d']);
});

it('treats arrays as leaf values', () => {
expect(extractFieldPaths({ tags: ['foo', 'bar'] })).toEqual(['tags']);
});

it('treats null as a leaf value', () => {
expect(extractFieldPaths({ a: null })).toEqual(['a']);
});

it('returns an empty array for an empty object', () => {
expect(extractFieldPaths({})).toEqual([]);
});

it('handles a mix of nested and leaf values', () => {
const result = extractFieldPaths({
service: { name: 'web', environment: 'prod' },
'@timestamp': '2024-01-01',
tags: ['a'],
host: { os: { platform: 'linux' } },
});

expect(result).toEqual([
'service.name',
'service.environment',
'@timestamp',
'tags',
'host.os.platform',
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const SAMPLE_SIZE = 1000;
* Extracts all field paths from a nested object.
* e.g., { a: { b: 1, c: 2 } } -> ['a.b', 'a.c']
*/
function extractFieldPaths(obj: Record<string, unknown>, prefix = ''): string[] {
export function extractFieldPaths(obj: Record<string, unknown>, prefix = ''): string[] {
return Object.entries(obj).flatMap(([key, value]) => {
const path = prefix ? `${prefix}.${key}` : key;
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
Expand Down
Loading