Skip to content
Merged
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
207 changes: 207 additions & 0 deletions src/modifyQuery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { addAddHocFilter } from './modifyQuery';

describe('addAddHocFilter', () => {
describe('array values', () => {
it('unwraps single-element array into a phrase query', () => {
const result = addAddHocFilter('', {
key: 'attributes.tags',
operator: '=',
value: '["paperclip"]',
});
expect(result).toBe('attributes.tags:"paperclip"');
});

it('unwraps multi-element array into IN set query', () => {
const result = addAddHocFilter('', {
key: 'attributes.tags',
operator: '=',
value: '["paperclip","stapler"]',
});
expect(result).toBe('attributes.tags:IN ["paperclip" "stapler"]');
});

it('negated single-element array produces negated phrase query', () => {
const result = addAddHocFilter('', {
key: 'attributes.tags',
operator: '!=',
value: '["paperclip"]',
});
expect(result).toBe('-attributes.tags:"paperclip"');
});

it('negated multi-element array produces negated IN set query', () => {
const result = addAddHocFilter('', {
key: 'attributes.tags',
operator: '!=',
value: '["paperclip","stapler"]',
});
expect(result).toBe('-attributes.tags:IN ["paperclip" "stapler"]');
});

it('appends array filter to existing query with AND', () => {
const result = addAddHocFilter('status:200', {
key: 'attributes.tags',
operator: '=',
value: '["paperclip"]',
});
expect(result).toBe('status:200 AND attributes.tags:"paperclip"');
});

it('handles single-element array with spaces in value', () => {
const result = addAddHocFilter('', {
key: 'attributes.tags',
operator: '=',
value: '["foo bar"]',
});
expect(result).toBe('attributes.tags:"foo bar"');
});

it('handles single-element array with colons in value', () => {
const result = addAddHocFilter('', {
key: 'attributes.tags',
operator: '=',
value: '["foo:bar"]',
});
expect(result).toBe('attributes.tags:"foo:bar"');
});

it('handles multi-element array with spaces in values', () => {
const result = addAddHocFilter('', {
key: 'attributes.tags',
operator: '=',
value: '["foo bar","baz qux"]',
});
expect(result).toBe('attributes.tags:IN ["foo bar" "baz qux"]');
});

it('handles array values containing double quotes', () => {
const result = addAddHocFilter('', {
key: 'attributes.tags',
operator: '=',
value: '["say \\"hello\\""]',
});
expect(result).toBe('attributes.tags:"say \\"hello\\""');
});

it('passes through non-array bracket strings unchanged', () => {
const result = addAddHocFilter('', {
key: 'attributes.message',
operator: '=',
value: '[not json',
});
expect(result).toBe('attributes.message:"[not json"');
});

it('term operator still produces unquoted query', () => {
const result = addAddHocFilter('', {
key: 'attributes.tags',
operator: 'term',
value: 'paperclip',
});
expect(result).toBe('attributes.tags:paperclip');
});

it('not term operator still produces negated unquoted query', () => {
const result = addAddHocFilter('', {
key: 'attributes.tags',
operator: 'not term',
value: 'paperclip',
});
expect(result).toBe('-attributes.tags:paperclip');
});
});

describe('scalar value filters', () => {
it('equality on simple string value', () => {
const result = addAddHocFilter('', {
key: 'attributes.controller',
operator: '=',
value: 'BlogController',
});
expect(result).toBe('attributes.controller:"BlogController"');
});

it('appends to existing query with AND', () => {
const result = addAddHocFilter('status:200', {
key: 'attributes.controller',
operator: '=',
value: 'BlogController',
});
expect(result).toBe('status:200 AND attributes.controller:"BlogController"');
});

it('exists operator', () => {
const result = addAddHocFilter('', {
key: 'attributes.tags',
operator: 'exists',
value: '',
});
expect(result).toBe('attributes.tags:*');
});

it('not exists operator', () => {
const result = addAddHocFilter('', {
key: 'attributes.tags',
operator: 'not exists',
value: '',
});
expect(result).toBe('-attributes.tags:*');
});

it('regex operator', () => {
const result = addAddHocFilter('', {
key: 'attributes.controller',
operator: '=~',
value: 'Blog.*',
});
expect(result).toBe('attributes.controller:/Blog.*/');
});

it('greater than operator', () => {
const result = addAddHocFilter('', {
key: 'attributes.duration',
operator: '>',
value: '100',
});
expect(result).toBe('attributes.duration:>100');
});

it('less than operator', () => {
const result = addAddHocFilter('', {
key: 'attributes.duration',
operator: '<',
value: '100',
});
expect(result).toBe('attributes.duration:<100');
});
});

describe('edge cases', () => {
it('returns query unchanged when key is empty', () => {
const result = addAddHocFilter('existing', {
key: '',
operator: '=',
value: 'test',
});
expect(result).toBe('existing');
});

it('returns query unchanged when value is empty for non-exists operators', () => {
const result = addAddHocFilter('existing', {
key: 'field',
operator: '=',
value: '',
});
expect(result).toBe('existing');
});

it('treats empty JSON array as no-op', () => {
const result = addAddHocFilter('existing', {
key: 'attributes.tags',
operator: '=',
value: '[]',
});
expect(result).toBe('existing');
});
});
});
33 changes: 33 additions & 0 deletions src/modifyQuery.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
import { escapeFilter, escapeFilterValue, concatenate, LuceneQuery } from 'utils/lucene';
import { AdHocVariableFilter } from '@grafana/data';

function tryParseJsonArray(value: string): string[] | null {
if (!value.startsWith('[')) {
return null;
}
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed) && parsed.every((el) => typeof el === 'string')) {
return parsed;
}
} catch {
// not valid JSON
}
return null;
}

/**
* Adds a label:"value" expression to the query.
*/
Expand All @@ -18,6 +33,24 @@ export function addAddHocFilter(query: string, filter: AdHocVariableFilter): str

const equalityFilters = ['=', '!='];
if (equalityFilters.includes(filter.operator)) {
// Grafana stringifies array values (e.g. ["paperclip","stapler"]) before
// passing them as filter values. Tantivy indexes array elements as
// individual terms — there's no way to match on array length, order, or
// exact composition. For multi-element arrays we use IN (match any),
// which is the most useful behavior for log exploration filters.
const arrayElements = tryParseJsonArray(filter.value);
if (arrayElements !== null) {
if (arrayElements.length === 0) {
return query;
}
const modifier = filter.operator === '=' ? '' : '-';
const key = escapeFilter(filter.key);
if (arrayElements.length === 1) {
return concatenate(query, `${modifier}${key}:"${escapeFilterValue(arrayElements[0])}"`, 'AND');
}
const terms = arrayElements.map((el) => `"${escapeFilterValue(el)}"`).join(' ');
return concatenate(query, `${modifier}${key}:IN [${terms}]`, 'AND');
}
return LuceneQuery.parse(query).addFilter(filter.key, filter.value, filter.operator === '=' ? '' : '-').toString();
}
/**
Expand Down
Loading