From 8056f449d63884c392a6738cf998a4d6c046d707 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Zahid Date: Thu, 5 Feb 2026 09:13:44 +0100 Subject: [PATCH] [Lens] Fix KQL character escaping when query is generated from Top values column (breakdown). (#250925) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Problem:** The "Explore in Discover" action fails with a `KQLSyntaxError` when using Top Values breakdown with values containing backslashes or quotes (e.g., Windows paths like `C:\`). **Root cause:** The `extractQueriesFromTerms()` function was using `lodash.escape()` (HTML escaping) instead of `escapeQuotes()` from `@kbn/es-query` (KQL escaping). Additionally, the escaping logic was inverted—it only escaped empty strings instead of non-empty values. **The fix:** Always escape term values using `escapeQuotes()` which properly escapes `\` and `"` characters for KQL quoted values. ## How to test/reproduce 1. Create a test index with a special character value: ``` POST bulk { "index": { "index": "my_windows_index" } } { "@timestamp": "2025-07-31T01:00:00.000Z", "group": "A", "value": "C:\\" } ``` 2. In Lens, create a Bar chart: - Data view: `my_windows_index` (create the data view if needed) - Vertical axis: Count of records (Drag the **Records** field from left sidebar) - Breakdown: Top 5 values of `value.keyword` 3. Click on the Breakdown dimension → Advanced → disable **Group remaining values as "Other"** 4. Save the visualization and click "Explore in Discover" **Before fix:** `KQLSyntaxError: Expected "(", "{", value, whitespace but """ found.` **After fix:** Discover opens with valid KQL query `value.keyword: "C:\\"` and shows the document. --- **Tip**: To reproduce and observe, besides the breakdown, an additional filter on the vertical axis can be added, e.g. image ### Before When a filter with value `C:\\` is also present on the vertical axis. Note that the filter converted from the vertical axis is escaped correctly (`C:\\`) whereas the one converted from breakdown isn't (`C:\`). image ### After image (cherry picked from commit fde2c581f60e9b17019e6ff8802943adc14d3ad9) --- .../datasources/form_based/form_based.test.ts | 53 +++++++++++++++++++ .../public/datasources/form_based/utils.tsx | 11 ++-- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/x-pack/platform/plugins/shared/lens/public/datasources/form_based/form_based.test.ts b/x-pack/platform/plugins/shared/lens/public/datasources/form_based/form_based.test.ts index 990e8a1e30c5e..7814a30769142 100644 --- a/x-pack/platform/plugins/shared/lens/public/datasources/form_based/form_based.test.ts +++ b/x-pack/platform/plugins/shared/lens/public/datasources/form_based/form_based.test.ts @@ -2481,6 +2481,59 @@ describe('IndexPattern Data Source', () => { disabled: { kuery: [], lucene: [] }, }); }); + it('should escape special characters in term values for valid KQL syntax', () => { + publicAPI = FormBasedDatasource.getPublicAPI({ + state: { + ...baseState, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Terms', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + sourceField: 'path.keyword', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 10, + }, + } as TermsIndexPatternColumn, + }, + }, + }, + }, + layerId: 'first', + indexPatterns, + }); + const data = { + first: { + type: 'datatable' as const, + columns: [{ id: 'col1', name: 'path.keyword', meta: { type: 'string' as const } }], + rows: [ + { col1: 'C:\\' }, + { col1: 'path with "quotes"' }, + { col1: 'backslash\\and"quote' }, + ], + }, + }; + expect(publicAPI.getFilters(data)).toEqual({ + enabled: { + kuery: [ + [ + { language: 'kuery', query: 'path.keyword: "C:\\\\"' }, + { language: 'kuery', query: 'path.keyword: "path with \\"quotes\\""' }, + { language: 'kuery', query: 'path.keyword: "backslash\\\\and\\"quote"' }, + ], + ], + lucene: [], + }, + disabled: { kuery: [], lucene: [] }, + }); + }); it('should ignore top values fields if other/missing option is enabled', () => { publicAPI = FormBasedDatasource.getPublicAPI({ state: { diff --git a/x-pack/platform/plugins/shared/lens/public/datasources/form_based/utils.tsx b/x-pack/platform/plugins/shared/lens/public/datasources/form_based/utils.tsx index 1ba8f7ca959c7..75fefd1bc9b7c 100644 --- a/x-pack/platform/plugins/shared/lens/public/datasources/form_based/utils.tsx +++ b/x-pack/platform/plugins/shared/lens/public/datasources/form_based/utils.tsx @@ -11,11 +11,11 @@ import { FormattedMessage } from '@kbn/i18n-react'; import type { DocLinksStart, ThemeServiceStart } from '@kbn/core/public'; import { hasUnsupportedDownsampledAggregationFailure } from '@kbn/search-response-warnings'; import type { DatatableUtilitiesService } from '@kbn/data-plugin/common'; -import type { TimeRange } from '@kbn/es-query'; +import { escapeQuotes, type TimeRange } from '@kbn/es-query'; import { EuiLink, EuiSpacer } from '@elastic/eui'; import type { DatatableColumn } from '@kbn/expressions-plugin/common'; -import { groupBy, escape, uniq, uniqBy } from 'lodash'; +import { groupBy, uniq, uniqBy } from 'lodash'; import type { Query } from '@kbn/data-plugin/common'; import { @@ -747,13 +747,10 @@ function extractQueriesFromTerms( } if (typeof value !== 'string' && Array.isArray(value.keys)) { return value.keys - .map( - (term: string, index: number) => - `${fields[index]}: ${`"${term === '' ? escape(term) : term}"`}` - ) + .map((term: string, index: number) => `${fields[index]}: "${escapeQuotes(term)}"`) .join(' AND '); } - return `${column.sourceField}: ${`"${value === '' ? escape(value) : value}"`}`; + return `${column.sourceField}: "${escapeQuotes(String(value))}"`; }) .filter(Boolean) as string[];