Skip to content
This repository was archived by the owner on May 12, 2026. It is now read-only.

Commit a39e92f

Browse files
authored
Merge pull request #182 from xrl/179-fix-array-filter-queries
Fix filter clicks on array-valued JSON fields
2 parents 3403416 + 6ecf039 commit a39e92f

2 files changed

Lines changed: 240 additions & 0 deletions

File tree

src/modifyQuery.test.ts

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import { addAddHocFilter } from './modifyQuery';
2+
3+
describe('addAddHocFilter', () => {
4+
describe('array values', () => {
5+
it('unwraps single-element array into a phrase query', () => {
6+
const result = addAddHocFilter('', {
7+
key: 'attributes.tags',
8+
operator: '=',
9+
value: '["paperclip"]',
10+
});
11+
expect(result).toBe('attributes.tags:"paperclip"');
12+
});
13+
14+
it('unwraps multi-element array into IN set query', () => {
15+
const result = addAddHocFilter('', {
16+
key: 'attributes.tags',
17+
operator: '=',
18+
value: '["paperclip","stapler"]',
19+
});
20+
expect(result).toBe('attributes.tags:IN ["paperclip" "stapler"]');
21+
});
22+
23+
it('negated single-element array produces negated phrase query', () => {
24+
const result = addAddHocFilter('', {
25+
key: 'attributes.tags',
26+
operator: '!=',
27+
value: '["paperclip"]',
28+
});
29+
expect(result).toBe('-attributes.tags:"paperclip"');
30+
});
31+
32+
it('negated multi-element array produces negated IN set query', () => {
33+
const result = addAddHocFilter('', {
34+
key: 'attributes.tags',
35+
operator: '!=',
36+
value: '["paperclip","stapler"]',
37+
});
38+
expect(result).toBe('-attributes.tags:IN ["paperclip" "stapler"]');
39+
});
40+
41+
it('appends array filter to existing query with AND', () => {
42+
const result = addAddHocFilter('status:200', {
43+
key: 'attributes.tags',
44+
operator: '=',
45+
value: '["paperclip"]',
46+
});
47+
expect(result).toBe('status:200 AND attributes.tags:"paperclip"');
48+
});
49+
50+
it('handles single-element array with spaces in value', () => {
51+
const result = addAddHocFilter('', {
52+
key: 'attributes.tags',
53+
operator: '=',
54+
value: '["foo bar"]',
55+
});
56+
expect(result).toBe('attributes.tags:"foo bar"');
57+
});
58+
59+
it('handles single-element array with colons in value', () => {
60+
const result = addAddHocFilter('', {
61+
key: 'attributes.tags',
62+
operator: '=',
63+
value: '["foo:bar"]',
64+
});
65+
expect(result).toBe('attributes.tags:"foo:bar"');
66+
});
67+
68+
it('handles multi-element array with spaces in values', () => {
69+
const result = addAddHocFilter('', {
70+
key: 'attributes.tags',
71+
operator: '=',
72+
value: '["foo bar","baz qux"]',
73+
});
74+
expect(result).toBe('attributes.tags:IN ["foo bar" "baz qux"]');
75+
});
76+
77+
it('handles array values containing double quotes', () => {
78+
const result = addAddHocFilter('', {
79+
key: 'attributes.tags',
80+
operator: '=',
81+
value: '["say \\"hello\\""]',
82+
});
83+
expect(result).toBe('attributes.tags:"say \\"hello\\""');
84+
});
85+
86+
it('passes through non-array bracket strings unchanged', () => {
87+
const result = addAddHocFilter('', {
88+
key: 'attributes.message',
89+
operator: '=',
90+
value: '[not json',
91+
});
92+
expect(result).toBe('attributes.message:"[not json"');
93+
});
94+
95+
it('term operator still produces unquoted query', () => {
96+
const result = addAddHocFilter('', {
97+
key: 'attributes.tags',
98+
operator: 'term',
99+
value: 'paperclip',
100+
});
101+
expect(result).toBe('attributes.tags:paperclip');
102+
});
103+
104+
it('not term operator still produces negated unquoted query', () => {
105+
const result = addAddHocFilter('', {
106+
key: 'attributes.tags',
107+
operator: 'not term',
108+
value: 'paperclip',
109+
});
110+
expect(result).toBe('-attributes.tags:paperclip');
111+
});
112+
});
113+
114+
describe('scalar value filters', () => {
115+
it('equality on simple string value', () => {
116+
const result = addAddHocFilter('', {
117+
key: 'attributes.controller',
118+
operator: '=',
119+
value: 'BlogController',
120+
});
121+
expect(result).toBe('attributes.controller:"BlogController"');
122+
});
123+
124+
it('appends to existing query with AND', () => {
125+
const result = addAddHocFilter('status:200', {
126+
key: 'attributes.controller',
127+
operator: '=',
128+
value: 'BlogController',
129+
});
130+
expect(result).toBe('status:200 AND attributes.controller:"BlogController"');
131+
});
132+
133+
it('exists operator', () => {
134+
const result = addAddHocFilter('', {
135+
key: 'attributes.tags',
136+
operator: 'exists',
137+
value: '',
138+
});
139+
expect(result).toBe('attributes.tags:*');
140+
});
141+
142+
it('not exists operator', () => {
143+
const result = addAddHocFilter('', {
144+
key: 'attributes.tags',
145+
operator: 'not exists',
146+
value: '',
147+
});
148+
expect(result).toBe('-attributes.tags:*');
149+
});
150+
151+
it('regex operator', () => {
152+
const result = addAddHocFilter('', {
153+
key: 'attributes.controller',
154+
operator: '=~',
155+
value: 'Blog.*',
156+
});
157+
expect(result).toBe('attributes.controller:/Blog.*/');
158+
});
159+
160+
it('greater than operator', () => {
161+
const result = addAddHocFilter('', {
162+
key: 'attributes.duration',
163+
operator: '>',
164+
value: '100',
165+
});
166+
expect(result).toBe('attributes.duration:>100');
167+
});
168+
169+
it('less than operator', () => {
170+
const result = addAddHocFilter('', {
171+
key: 'attributes.duration',
172+
operator: '<',
173+
value: '100',
174+
});
175+
expect(result).toBe('attributes.duration:<100');
176+
});
177+
});
178+
179+
describe('edge cases', () => {
180+
it('returns query unchanged when key is empty', () => {
181+
const result = addAddHocFilter('existing', {
182+
key: '',
183+
operator: '=',
184+
value: 'test',
185+
});
186+
expect(result).toBe('existing');
187+
});
188+
189+
it('returns query unchanged when value is empty for non-exists operators', () => {
190+
const result = addAddHocFilter('existing', {
191+
key: 'field',
192+
operator: '=',
193+
value: '',
194+
});
195+
expect(result).toBe('existing');
196+
});
197+
198+
it('treats empty JSON array as no-op', () => {
199+
const result = addAddHocFilter('existing', {
200+
key: 'attributes.tags',
201+
operator: '=',
202+
value: '[]',
203+
});
204+
expect(result).toBe('existing');
205+
});
206+
});
207+
});

src/modifyQuery.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
import { escapeFilter, escapeFilterValue, concatenate, LuceneQuery } from 'utils/lucene';
22
import { AdHocVariableFilter } from '@grafana/data';
33

4+
function tryParseJsonArray(value: string): string[] | null {
5+
if (!value.startsWith('[')) {
6+
return null;
7+
}
8+
try {
9+
const parsed = JSON.parse(value);
10+
if (Array.isArray(parsed) && parsed.every((el) => typeof el === 'string')) {
11+
return parsed;
12+
}
13+
} catch {
14+
// not valid JSON
15+
}
16+
return null;
17+
}
18+
419
/**
520
* Adds a label:"value" expression to the query.
621
*/
@@ -18,6 +33,24 @@ export function addAddHocFilter(query: string, filter: AdHocVariableFilter): str
1833

1934
const equalityFilters = ['=', '!='];
2035
if (equalityFilters.includes(filter.operator)) {
36+
// Grafana stringifies array values (e.g. ["paperclip","stapler"]) before
37+
// passing them as filter values. Tantivy indexes array elements as
38+
// individual terms — there's no way to match on array length, order, or
39+
// exact composition. For multi-element arrays we use IN (match any),
40+
// which is the most useful behavior for log exploration filters.
41+
const arrayElements = tryParseJsonArray(filter.value);
42+
if (arrayElements !== null) {
43+
if (arrayElements.length === 0) {
44+
return query;
45+
}
46+
const modifier = filter.operator === '=' ? '' : '-';
47+
const key = escapeFilter(filter.key);
48+
if (arrayElements.length === 1) {
49+
return concatenate(query, `${modifier}${key}:"${escapeFilterValue(arrayElements[0])}"`, 'AND');
50+
}
51+
const terms = arrayElements.map((el) => `"${escapeFilterValue(el)}"`).join(' ');
52+
return concatenate(query, `${modifier}${key}:IN [${terms}]`, 'AND');
53+
}
2154
return LuceneQuery.parse(query).addFilter(filter.key, filter.value, filter.operator === '=' ? '' : '-').toString();
2255
}
2356
/**

0 commit comments

Comments
 (0)