TemplateVariables: Introduces $__searchFilter to Query Variables (#19858)

* WIP: Initial hardcoded version

* Feature: Introduces SearchFiltering to Graphite

* Feature: Adds searchFiltering to MySql

* Tests: Adds tests to Graphite and MySql

* Feature: Adds $__searchFilter to TestData

* Refactor: Adds searchFilter to Postgres and extracts function

* Tests: Adds tests to variable

* Refactor: Adds debounce and lodash import optimization

* Docs: Adds documentation

* Refactor: Removes unused function and fixes typo

* Docs: Updates docs

* Fixed issue with UI not updating when no  was used due to async func and no .apply in the non lazy path
This commit is contained in:
Hugo Häggmark 2019-10-18 11:40:08 +02:00 committed by Torkel Ödegaard
parent c674fa1d79
commit cb0e80e7b9
18 changed files with 601 additions and 59 deletions

View File

@ -114,6 +114,19 @@ variable with all possible values that exist in the wildcard position.
You can also create nested variables that use other variables in their definition. For example
`apps.$app.servers.*` uses the variable `$app` in its query definition.
#### Using `$__searchFilter` to filter results in Query Variable
> Available from Grafana 6.5 and above
Using `$__searchFilter` in the query field will filter the query result based on what the user types in the dropdown select box.
When nothing has been entered by the user the default value for `$__searchFilter` is `*`.
The example below shows how to use `$__searchFilter` as part of the query field to enable searching for `server` while the user types in the dropdown select box.
Query
```bash
apps.$app.servers.$__searchFilter
```
### Variable Usage
You can use a variable in a metric node path or as a parameter to a function.

View File

@ -276,6 +276,19 @@ the hosts variable only show hosts from the current selected region with a query
SELECT hostname FROM my_host WHERE region IN($region)
```
#### Using `$__searchFilter` to filter results in Query Variable
> Available from Grafana 6.5 and above
Using `$__searchFilter` in the query field will filter the query result based on what the user types in the dropdown select box.
When nothing has been entered by the user the default value for `$__searchFilter` is `%`.
The example below shows how to use `$__searchFilter` as part of the query field to enable searching for `hostname` while the user types in the dropdown select box.
Query
```sql
SELECT hostname FROM my_host WHERE hostname LIKE $__searchFilter
```
### Using Variables in Queries
From Grafana 4.3.0 to 4.6.0, template variables are always quoted automatically so if it is a string value do not wrap them in quotes in where clauses.

View File

@ -282,6 +282,19 @@ the hosts variable only show hosts from the current selected region with a query
SELECT hostname FROM host WHERE region IN($region)
```
#### Using `$__searchFilter` to filter results in Query Variable
> Available from Grafana 6.5 and above
Using `$__searchFilter` in the query field will filter the query result based on what the user types in the dropdown select box.
When nothing has been entered by the user the default value for `$__searchFilter` is `%`.
The example below shows how to use `$__searchFilter` as part of the query field to enable searching for `hostname` while the user types in the dropdown select box.
Query
```sql
SELECT hostname FROM my_host WHERE hostname LIKE $__searchFilter
```
### Using Variables in Queries
From Grafana 4.3.0 to 4.6.0, template variables are always quoted automatically. If your template variables are strings, do not wrap them in quotes in where clauses.

View File

@ -1,7 +1,14 @@
import angular from 'angular';
import _ from 'lodash';
import angular, { IScope } from 'angular';
import debounce from 'lodash/debounce';
import each from 'lodash/each';
import filter from 'lodash/filter';
import find from 'lodash/find';
import indexOf from 'lodash/indexOf';
import map from 'lodash/map';
import coreModule from '../core_module';
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
import { containsSearchFilter } from '../../features/templating/variable';
export class ValueSelectDropdownCtrl {
dropdownVisible: any;
@ -17,20 +24,25 @@ export class ValueSelectDropdownCtrl {
hide: any;
onUpdated: any;
queryHasSearchFilter: boolean;
debouncedQueryChanged: Function;
/** @ngInject */
constructor(private $q: any) {}
constructor(private $q: any, private $scope: IScope) {
this.queryHasSearchFilter = this.variable ? containsSearchFilter(this.variable.query) : false;
this.debouncedQueryChanged = debounce(this.queryChanged.bind(this), 200);
}
show() {
this.oldVariableText = this.variable.current.text;
this.highlightIndex = -1;
this.options = this.variable.options;
this.selectedValues = _.filter(this.options, { selected: true });
this.selectedValues = filter(this.options, { selected: true });
this.tags = _.map(this.variable.tags, value => {
this.tags = map(this.variable.tags, value => {
let tag = { text: value, selected: false };
_.each(this.variable.current.tags, tagObj => {
each(this.variable.current.tags, tagObj => {
if (tagObj.text === value) {
tag = tagObj;
}
@ -38,8 +50,12 @@ export class ValueSelectDropdownCtrl {
return tag;
});
// new behaviour, if this is a query that uses searchfilter it might be a nicer
// user experience to show the last typed search query in the input field
const query = this.queryHasSearchFilter && this.search && this.search.query ? this.search.query : '';
this.search = {
query: '',
query,
options: this.options.slice(0, Math.min(this.options.length, 1000)),
};
@ -51,13 +67,13 @@ export class ValueSelectDropdownCtrl {
if (current.tags && current.tags.length) {
// filer out values that are in selected tags
const selectedAndNotInTag = _.filter(this.variable.options, option => {
const selectedAndNotInTag = filter(this.variable.options, option => {
if (!option.selected) {
return false;
}
for (let i = 0; i < current.tags.length; i++) {
const tag = current.tags[i];
if (_.indexOf(tag.values, option.value) !== -1) {
if (indexOf(tag.values, option.value) !== -1) {
return false;
}
}
@ -65,7 +81,7 @@ export class ValueSelectDropdownCtrl {
});
// convert values to text
const currentTexts = _.map(selectedAndNotInTag, 'text');
const currentTexts = map(selectedAndNotInTag, 'text');
// join texts
this.linkText = currentTexts.join(' + ');
@ -78,14 +94,14 @@ export class ValueSelectDropdownCtrl {
}
clearSelections() {
this.selectedValues = _.filter(this.options, { selected: true });
this.selectedValues = filter(this.options, { selected: true });
if (this.selectedValues.length) {
_.each(this.options, option => {
each(this.options, option => {
option.selected = false;
});
} else {
_.each(this.search.options, option => {
each(this.search.options, option => {
option.selected = true;
});
}
@ -104,8 +120,8 @@ export class ValueSelectDropdownCtrl {
return tagValuesPromise.then((values: any) => {
tag.values = values;
tag.valuesText = values.join(' + ');
_.each(this.options, option => {
if (_.indexOf(tag.values, option.value) !== -1) {
each(this.options, option => {
if (indexOf(tag.values, option.value) !== -1) {
option.selected = tag.selected;
}
});
@ -128,11 +144,11 @@ export class ValueSelectDropdownCtrl {
if (this.search.options.length === 0) {
this.commitChanges();
} else {
this.selectValue(this.search.options[this.highlightIndex], {}, true, false);
this.selectValue(this.search.options[this.highlightIndex], {}, true);
}
}
if (evt.keyCode === 32) {
this.selectValue(this.search.options[this.highlightIndex], {}, false, false);
this.selectValue(this.search.options[this.highlightIndex], {}, false);
}
}
@ -140,7 +156,7 @@ export class ValueSelectDropdownCtrl {
this.highlightIndex = (this.highlightIndex + direction) % this.search.options.length;
}
selectValue(option: any, event: any, commitChange?: boolean, excludeOthers?: boolean) {
selectValue(option: any, event: any, commitChange?: boolean) {
if (!option) {
return;
}
@ -148,10 +164,9 @@ export class ValueSelectDropdownCtrl {
option.selected = this.variable.multi ? !option.selected : true;
commitChange = commitChange || false;
excludeOthers = excludeOthers || false;
const setAllExceptCurrentTo = (newValue: any) => {
_.each(this.options, other => {
each(this.options, other => {
if (option !== other) {
other.selected = newValue;
}
@ -163,7 +178,9 @@ export class ValueSelectDropdownCtrl {
option.selected = true;
}
if (option.text === 'All' || excludeOthers) {
if (option.text === 'All') {
// always clear search query if all is marked
this.search.query = '';
setAllExceptCurrentTo(false);
commitChange = true;
} else if (!this.variable.multi) {
@ -178,7 +195,7 @@ export class ValueSelectDropdownCtrl {
}
selectionsChanged(commitChange: boolean) {
this.selectedValues = _.filter(this.options, { selected: true });
this.selectedValues = filter(this.options, { selected: true });
if (this.selectedValues.length > 1) {
if (this.selectedValues[0].text === 'All') {
@ -188,19 +205,19 @@ export class ValueSelectDropdownCtrl {
}
// validate selected tags
_.each(this.tags, tag => {
each(this.tags, tag => {
if (tag.selected) {
_.each(tag.values, value => {
if (!_.find(this.selectedValues, { value: value })) {
each(tag.values, value => {
if (!find(this.selectedValues, { value: value })) {
tag.selected = false;
}
});
}
});
this.selectedTags = _.filter(this.tags, { selected: true });
this.variable.current.value = _.map(this.selectedValues, 'value');
this.variable.current.text = _.map(this.selectedValues, 'text').join(' + ');
this.selectedTags = filter(this.tags, { selected: true });
this.variable.current.value = map(this.selectedValues, 'value');
this.variable.current.text = map(this.selectedValues, 'text').join(' + ');
this.variable.current.tags = this.selectedTags;
if (!this.variable.multi) {
@ -224,25 +241,48 @@ export class ValueSelectDropdownCtrl {
this.dropdownVisible = false;
this.updateLinkText();
if (this.queryHasSearchFilter) {
this.updateLazyLoadedOptions();
}
if (this.variable.current.text !== this.oldVariableText) {
this.onUpdated();
}
}
queryChanged() {
this.highlightIndex = -1;
this.search.options = _.filter(this.options, option => {
async queryChanged() {
if (this.queryHasSearchFilter) {
await this.updateLazyLoadedOptions();
return;
}
const options = filter(this.options, option => {
return option.text.toLowerCase().indexOf(this.search.query.toLowerCase()) !== -1;
});
this.search.options = this.search.options.slice(0, Math.min(this.search.options.length, 1000));
this.updateUIBoundOptions(this.$scope, options);
}
init() {
this.selectedTags = this.variable.current.tags || [];
this.updateLinkText();
}
async updateLazyLoadedOptions() {
this.options = await this.lazyLoadOptions(this.search.query);
this.updateUIBoundOptions(this.$scope, this.options);
}
async lazyLoadOptions(query: string): Promise<any[]> {
await this.variable.updateOptions(query);
return this.variable.options;
}
updateUIBoundOptions($scope: IScope, options: any[]) {
this.highlightIndex = -1;
this.search.options = options.slice(0, Math.min(options.length, 1000));
$scope.$apply();
}
}
/** @ngInject */

View File

@ -2,16 +2,18 @@ import 'app/core/directives/value_select_dropdown';
import { ValueSelectDropdownCtrl } from '../directives/value_select_dropdown';
// @ts-ignore
import q from 'q';
import { IScope } from 'angular';
describe('SelectDropdownCtrl', () => {
const tagValuesMap: any = {};
const $scope: IScope = {} as IScope;
ValueSelectDropdownCtrl.prototype.onUpdated = jest.fn();
let ctrl: ValueSelectDropdownCtrl;
describe('Given simple variable', () => {
beforeEach(() => {
ctrl = new ValueSelectDropdownCtrl(q);
ctrl = new ValueSelectDropdownCtrl(q, $scope);
ctrl.variable = {
current: { text: 'hej', value: 'hej' },
getValuesForTag: (key: string) => {
@ -28,7 +30,7 @@ describe('SelectDropdownCtrl', () => {
describe('Given variable with tags and dropdown is opened', () => {
beforeEach(() => {
ctrl = new ValueSelectDropdownCtrl(q);
ctrl = new ValueSelectDropdownCtrl(q, $scope);
ctrl.variable = {
current: { text: 'server-1', value: 'server-1' },
options: [
@ -131,7 +133,7 @@ describe('SelectDropdownCtrl', () => {
describe('Given variable with selected tags', () => {
beforeEach(() => {
ctrl = new ValueSelectDropdownCtrl(q);
ctrl = new ValueSelectDropdownCtrl(q, $scope);
ctrl.variable = {
current: {
text: 'server-1',
@ -158,3 +160,113 @@ describe('SelectDropdownCtrl', () => {
});
});
});
describe('queryChanged', () => {
describe('when called and variable query contains search filter', () => {
it('then it should use lazy loading', async () => {
const $scope = {} as IScope;
const ctrl = new ValueSelectDropdownCtrl(q, $scope);
const options = [
{ text: 'server-1', value: 'server-1' },
{ text: 'server-2', value: 'server-2' },
{ text: 'server-3', value: 'server-3' },
];
ctrl.lazyLoadOptions = jest.fn().mockResolvedValue(options);
ctrl.updateUIBoundOptions = jest.fn();
ctrl.search = {
query: 'alpha',
};
ctrl.queryHasSearchFilter = true;
await ctrl.queryChanged();
expect(ctrl.lazyLoadOptions).toBeCalledTimes(1);
expect(ctrl.lazyLoadOptions).toBeCalledWith('alpha');
expect(ctrl.updateUIBoundOptions).toBeCalledTimes(1);
expect(ctrl.updateUIBoundOptions).toBeCalledWith($scope, options);
});
});
describe('when called and variable query does not contain search filter', () => {
it('then it should not use lazy loading', async () => {
const $scope = {} as IScope;
const ctrl = new ValueSelectDropdownCtrl(q, $scope);
ctrl.lazyLoadOptions = jest.fn().mockResolvedValue([]);
ctrl.updateUIBoundOptions = jest.fn();
ctrl.search = {
query: 'alpha',
};
ctrl.queryHasSearchFilter = false;
await ctrl.queryChanged();
expect(ctrl.lazyLoadOptions).toBeCalledTimes(0);
expect(ctrl.updateUIBoundOptions).toBeCalledTimes(1);
});
});
});
describe('lazyLoadOptions', () => {
describe('when called with a query', () => {
it('then the variables updateOptions should be called with the query', async () => {
const $scope = {} as IScope;
const ctrl = new ValueSelectDropdownCtrl(q, $scope);
ctrl.variable = {
updateOptions: jest.fn(),
options: [
{ text: 'server-1', value: 'server-1' },
{ text: 'server-2', value: 'server-2' },
{ text: 'server-3', value: 'server-3' },
],
};
const query = 'server-1';
const result = await ctrl.lazyLoadOptions(query);
expect(ctrl.variable.updateOptions).toBeCalledTimes(1);
expect(ctrl.variable.updateOptions).toBeCalledWith(query);
expect(result).toEqual(ctrl.variable.options);
});
});
});
describe('updateUIBoundOptions', () => {
describe('when called with options', () => {
let options: any[];
let ctrl: ValueSelectDropdownCtrl;
let $scope: IScope;
beforeEach(() => {
$scope = ({
$apply: jest.fn(),
} as any) as IScope;
options = [];
for (let index = 0; index < 1001; index++) {
options.push({ text: `server-${index}`, value: `server-${index}` });
}
ctrl = new ValueSelectDropdownCtrl(q, $scope);
ctrl.highlightIndex = 0;
ctrl.options = [];
ctrl.search = {
options: [],
};
ctrl.updateUIBoundOptions($scope, options);
});
it('then highlightIndex should be reset', () => {
expect(ctrl.highlightIndex).toEqual(-1);
});
it('then search.options should be same as options but capped to 1000', () => {
expect(ctrl.search.options.length).toEqual(1000);
for (let index = 0; index < 1000; index++) {
expect(ctrl.search.options[index]).toEqual(options[index]);
}
});
it('then scope apply should be called', () => {
expect($scope.$apply).toBeCalledTimes(1);
});
});
});

View File

@ -1,5 +1,5 @@
import _ from 'lodash';
import { Variable, containsVariable, assignModelProperties, variableTypes } from './variable';
import { assignModelProperties, containsVariable, Variable, variableTypes } from './variable';
import { stringToJsRegex } from '@grafana/data';
import DatasourceSrv from '../plugins/datasource_srv';
import { TemplateSrv } from './template_srv';
@ -62,6 +62,7 @@ export class QueryVariable implements Variable {
) {
// copy model properties to this instance
assignModelProperties(this, model, this.defaults);
this.updateOptionsFromMetricFindQuery.bind(this);
}
getSaveModel() {
@ -91,10 +92,10 @@ export class QueryVariable implements Variable {
return this.current.value;
}
updateOptions() {
updateOptions(searchFilter?: string) {
return this.datasourceSrv
.get(this.datasource)
.then(this.updateOptionsFromMetricFindQuery.bind(this))
.then(ds => this.updateOptionsFromMetricFindQuery(ds, searchFilter))
.then(this.updateTags.bind(this))
.then(this.variableSrv.validateVariableSelectionState.bind(this.variableSrv, this));
}
@ -126,8 +127,8 @@ export class QueryVariable implements Variable {
});
}
updateOptionsFromMetricFindQuery(datasource: any) {
return this.metricFindQuery(datasource, this.query).then((results: any) => {
updateOptionsFromMetricFindQuery(datasource: any, searchFilter?: string) {
return this.metricFindQuery(datasource, this.query, searchFilter).then((results: any) => {
this.options = this.metricNamesToVariableValues(results);
if (this.includeAll) {
this.addAllOption();
@ -139,8 +140,8 @@ export class QueryVariable implements Variable {
});
}
metricFindQuery(datasource: any, query: string) {
const options: any = { range: undefined, variable: this };
metricFindQuery(datasource: any, query: string, searchFilter?: string) {
const options: any = { range: undefined, variable: this, searchFilter };
if (this.refresh === 2) {
options.range = this.timeSrv.timeRange();

View File

@ -1,4 +1,10 @@
import { containsVariable, assignModelProperties } from '../variable';
import {
assignModelProperties,
containsSearchFilter,
containsVariable,
interpolateSearchFilter,
SEARCH_FILTER_VARIABLE,
} from '../variable';
describe('containsVariable', () => {
describe('when checking if a string contains a variable', () => {
@ -68,3 +74,104 @@ describe('assignModelProperties', () => {
expect(target.propC).toBe(10);
});
});
describe('containsSearchFilter', () => {
describe('when called without query', () => {
it('then it should return false', () => {
const result = containsSearchFilter(null);
expect(result).toBe(false);
});
});
describe(`when called with a query without ${SEARCH_FILTER_VARIABLE}`, () => {
it('then it should return false', () => {
const result = containsSearchFilter('$app.*');
expect(result).toBe(false);
});
});
describe(`when called with a query with ${SEARCH_FILTER_VARIABLE}`, () => {
it('then it should return false', () => {
const result = containsSearchFilter(`$app.${SEARCH_FILTER_VARIABLE}`);
expect(result).toBe(true);
});
});
});
describe('interpolateSearchFilter', () => {
describe('when called with a query without ${SEARCH_FILTER_VARIABLE}', () => {
it('then it should return query', () => {
const query = '$app.*';
const options = { searchFilter: 'filter' };
const wildcardChar = '*';
const quoteLiteral = false;
const result = interpolateSearchFilter({
query,
options,
wildcardChar,
quoteLiteral,
});
expect(result).toEqual(query);
});
});
describe(`when called with a query with ${SEARCH_FILTER_VARIABLE}`, () => {
const query = `$app.${SEARCH_FILTER_VARIABLE}`;
describe('and no searchFilter is given', () => {
it(`then ${SEARCH_FILTER_VARIABLE} should be replaced by wildchar character`, () => {
const options = {};
const wildcardChar = '*';
const quoteLiteral = false;
const result = interpolateSearchFilter({
query,
options,
wildcardChar,
quoteLiteral,
});
expect(result).toEqual(`$app.*`);
});
});
describe('and searchFilter is given', () => {
const options = { searchFilter: 'filter' };
it(`then ${SEARCH_FILTER_VARIABLE} should be replaced with searchfilter and wildchar character`, () => {
const wildcardChar = '*';
const quoteLiteral = false;
const result = interpolateSearchFilter({
query,
options,
wildcardChar,
quoteLiteral,
});
expect(result).toEqual(`$app.filter*`);
});
describe(`and quoteLiteral is used`, () => {
it(`then the literal should be quoted`, () => {
const wildcardChar = '*';
const quoteLiteral = true;
const result = interpolateSearchFilter({
query,
options,
wildcardChar,
quoteLiteral,
});
expect(result).toEqual(`$app.'filter*'`);
});
});
});
});
});

View File

@ -15,9 +15,36 @@ export const variableRegexExec = (variableString: string) => {
return variableRegex.exec(variableString);
};
export const SEARCH_FILTER_VARIABLE = '$__searchFilter';
export const containsSearchFilter = (query: string): boolean =>
query ? query.indexOf(SEARCH_FILTER_VARIABLE) !== -1 : false;
export interface InterpolateSearchFilterOptions {
query: string;
options: any;
wildcardChar: string;
quoteLiteral: boolean;
}
export const interpolateSearchFilter = (args: InterpolateSearchFilterOptions): string => {
const { query, wildcardChar, quoteLiteral } = args;
let { options } = args;
if (!containsSearchFilter(query)) {
return query;
}
options = options || {};
const filter = options.searchFilter ? `${options.searchFilter}${wildcardChar}` : `${wildcardChar}`;
const replaceValue = quoteLiteral ? `'${filter}'` : filter;
return query.replace(SEARCH_FILTER_VARIABLE, replaceValue);
};
export interface Variable {
setValue(option: any): any;
updateOptions(): any;
updateOptions(searchFilter?: string): any;
dependsOn(variable: any): any;
setValueFromUrl(urlValue: any): any;
getValueForUrl(): any;

View File

@ -10,7 +10,7 @@
<i class="fa fa-caret-down" style="font-size:12px"></i>
</a>
<input type="text" class="gf-form-input" style="display: none" ng-keydown="vm.keyDown($event)" ng-model="vm.search.query" ng-change="vm.queryChanged()" ></input>
<input type="text" class="gf-form-input" style="display: none" ng-keydown="vm.keyDown($event)" ng-model="vm.search.query" ng-change="vm.debouncedQueryChanged()" ></input>
<div class="variable-value-dropdown" ng-if="vm.dropdownVisible" ng-class="{'multi': vm.variable.multi, 'single': !vm.variable.multi}">
<div class="variable-options-wrapper">

View File

@ -5,9 +5,9 @@ import gfunc from './gfunc';
import { IQService } from 'angular';
import { BackendSrv } from 'app/core/services/backend_srv';
import { TemplateSrv } from 'app/features/templating/template_srv';
//Types
import { GraphiteQuery } from './types';
import { interpolateSearchFilter } from '../../../features/templating/variable';
export class GraphiteDatasource {
basicAuth: string;
@ -251,7 +251,12 @@ export class GraphiteDatasource {
metricFindQuery(query: string, optionalOptions: any) {
const options: any = optionalOptions || {};
const interpolatedQuery = this.templateSrv.replace(query);
const interpolatedQuery = interpolateSearchFilter({
query: this.templateSrv.replace(query),
options: optionalOptions,
wildcardChar: '*',
quoteLiteral: false,
});
// special handling for tag_values(<tag>[,<expression>]*), this is used for template variables
let matches = interpolatedQuery.match(/^tag_values\(([^,]+)((, *[^,]+)*)\)$/);

View File

@ -356,6 +356,28 @@ describe('graphiteDatasource', () => {
expect(requestOptions.data).toMatch(`query=bar`);
expect(requestOptions).toHaveProperty('params');
});
it('should interpolate $__searchFilter with searchFilter', () => {
ctx.ds.metricFindQuery('app.$__searchFilter', { searchFilter: 'backend' }).then((data: any) => {
results = data;
});
expect(requestOptions.url).toBe('/api/datasources/proxy/1/metrics/find');
expect(requestOptions.params).toEqual({});
expect(requestOptions.data).toEqual('query=app.backend*');
expect(results).not.toBe(null);
});
it('should interpolate $__searchFilter with default when searchFilter is missing', () => {
ctx.ds.metricFindQuery('app.$__searchFilter', {}).then((data: any) => {
results = data;
});
expect(requestOptions.url).toBe('/api/datasources/proxy/1/metrics/find');
expect(requestOptions.params).toEqual({});
expect(requestOptions.data).toEqual('query=app.*');
expect(results).not.toBe(null);
});
});
});

View File

@ -7,6 +7,7 @@ import { TemplateSrv } from 'app/features/templating/template_srv';
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
//Types
import { MysqlQueryForInterpolation } from './types';
import { interpolateSearchFilter } from '../../../features/templating/variable';
export class MysqlDatasource {
id: any;
@ -130,10 +131,17 @@ export class MysqlDatasource {
refId = optionalOptions.variable.name;
}
const rawSql = interpolateSearchFilter({
query: this.templateSrv.replace(query, {}, this.interpolateVariable),
options: optionalOptions,
wildcardChar: '%',
quoteLiteral: true,
});
const interpolatedQuery = {
refId: refId,
datasourceId: this.id,
rawSql: this.templateSrv.replace(query, {}, this.interpolateVariable),
rawSql,
format: 'table',
};

View File

@ -1,6 +1,6 @@
import { MysqlDatasource } from '../datasource';
import { CustomVariable } from 'app/features/templating/custom_variable';
import { toUtc, dateTime } from '@grafana/data';
import { dateTime, toUtc } from '@grafana/data';
import { BackendSrv } from 'app/core/services/backend_srv';
import { TemplateSrv } from 'app/features/templating/template_srv';
@ -121,6 +121,82 @@ describe('MySQLDatasource', () => {
});
});
describe('When performing metricFindQuery with $__searchFilter and a searchFilter is given', () => {
let results: any;
let calledWith: any = {};
const query = 'select title from atable where title LIKE $__searchFilter';
const response = {
results: {
tempvar: {
meta: {
rowCount: 3,
},
refId: 'tempvar',
tables: [
{
columns: [{ text: 'title' }, { text: 'text' }],
rows: [['aTitle', 'some text'], ['aTitle2', 'some text2'], ['aTitle3', 'some text3']],
},
],
},
},
};
beforeEach(() => {
ctx.backendSrv.datasourceRequest = jest.fn(options => {
calledWith = options;
return Promise.resolve({ data: response, status: 200 });
});
ctx.ds.metricFindQuery(query, { searchFilter: 'aTit' }).then((data: any) => {
results = data;
});
});
it('should return list of all column values', () => {
expect(ctx.backendSrv.datasourceRequest).toBeCalledTimes(1);
expect(calledWith.data.queries[0].rawSql).toBe("select title from atable where title LIKE 'aTit%'");
expect(results.length).toBe(6);
});
});
describe('When performing metricFindQuery with $__searchFilter but no searchFilter is given', () => {
let results: any;
let calledWith: any = {};
const query = 'select title from atable where title LIKE $__searchFilter';
const response = {
results: {
tempvar: {
meta: {
rowCount: 3,
},
refId: 'tempvar',
tables: [
{
columns: [{ text: 'title' }, { text: 'text' }],
rows: [['aTitle', 'some text'], ['aTitle2', 'some text2'], ['aTitle3', 'some text3']],
},
],
},
},
};
beforeEach(() => {
ctx.backendSrv.datasourceRequest = jest.fn(options => {
calledWith = options;
return Promise.resolve({ data: response, status: 200 });
});
ctx.ds.metricFindQuery(query, {}).then((data: any) => {
results = data;
});
});
it('should return list of all column values', () => {
expect(ctx.backendSrv.datasourceRequest).toBeCalledTimes(1);
expect(calledWith.data.queries[0].rawSql).toBe("select title from atable where title LIKE '%'");
expect(results.length).toBe(6);
});
});
describe('When performing metricFindQuery with key, value columns', () => {
let results: any;
const query = 'select * from atable';

View File

@ -7,6 +7,7 @@ import { TemplateSrv } from 'app/features/templating/template_srv';
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
//Types
import { PostgresQueryForInterpolation } from './types';
import { interpolateSearchFilter } from '../../../features/templating/variable';
export class PostgresDatasource {
id: any;
@ -132,10 +133,17 @@ export class PostgresDatasource {
refId = optionalOptions.variable.name;
}
const rawSql = interpolateSearchFilter({
query: this.templateSrv.replace(query, {}, this.interpolateVariable),
options: optionalOptions,
wildcardChar: '%',
quoteLiteral: true,
});
const interpolatedQuery = {
refId: refId,
datasourceId: this.id,
rawSql: this.templateSrv.replace(query, {}, this.interpolateVariable),
rawSql,
format: 'table',
};

View File

@ -1,6 +1,6 @@
import { PostgresDatasource } from '../datasource';
import { CustomVariable } from 'app/features/templating/custom_variable';
import { toUtc, dateTime } from '@grafana/data';
import { dateTime, toUtc } from '@grafana/data';
import { BackendSrv } from 'app/core/services/backend_srv';
import { IQService } from 'angular';
import { TemplateSrv } from 'app/features/templating/template_srv';
@ -128,6 +128,82 @@ describe('PostgreSQLDatasource', () => {
});
});
describe('When performing metricFindQuery with $__searchFilter and a searchFilter is given', () => {
let results: any;
let calledWith: any = {};
const query = 'select title from atable where title LIKE $__searchFilter';
const response = {
results: {
tempvar: {
meta: {
rowCount: 3,
},
refId: 'tempvar',
tables: [
{
columns: [{ text: 'title' }, { text: 'text' }],
rows: [['aTitle', 'some text'], ['aTitle2', 'some text2'], ['aTitle3', 'some text3']],
},
],
},
},
};
beforeEach(() => {
ctx.backendSrv.datasourceRequest = jest.fn(options => {
calledWith = options;
return Promise.resolve({ data: response, status: 200 });
});
ctx.ds.metricFindQuery(query, { searchFilter: 'aTit' }).then((data: any) => {
results = data;
});
});
it('should return list of all column values', () => {
expect(ctx.backendSrv.datasourceRequest).toBeCalledTimes(1);
expect(calledWith.data.queries[0].rawSql).toBe("select title from atable where title LIKE 'aTit%'");
expect(results.length).toBe(6);
});
});
describe('When performing metricFindQuery with $__searchFilter but no searchFilter is given', () => {
let results: any;
let calledWith: any = {};
const query = 'select title from atable where title LIKE $__searchFilter';
const response = {
results: {
tempvar: {
meta: {
rowCount: 3,
},
refId: 'tempvar',
tables: [
{
columns: [{ text: 'title' }, { text: 'text' }],
rows: [['aTitle', 'some text'], ['aTitle2', 'some text2'], ['aTitle3', 'some text3']],
},
],
},
},
};
beforeEach(() => {
ctx.backendSrv.datasourceRequest = jest.fn(options => {
calledWith = options;
return Promise.resolve({ data: response, status: 200 });
});
ctx.ds.metricFindQuery(query, {}).then((data: any) => {
results = data;
});
});
it('should return list of all column values', () => {
expect(ctx.backendSrv.datasourceRequest).toBeCalledTimes(1);
expect(calledWith.data.queries[0].rawSql).toBe("select title from atable where title LIKE '%'");
expect(results.length).toBe(6);
});
});
describe('When performing metricFindQuery with key, value columns', () => {
let results: any;
const query = 'select * from atable';

View File

@ -1,18 +1,18 @@
import _ from 'lodash';
import {
DataSourceApi,
DataQueryRequest,
DataSourceInstanceSettings,
DataQueryResponse,
DataSourceApi,
DataSourceInstanceSettings,
MetricFindValue,
} from '@grafana/ui';
import { TableData, TimeSeries } from '@grafana/data';
import { TestDataQuery, Scenario } from './types';
import { Scenario, TestDataQuery } from './types';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { queryMetricTree } from './metricTree';
import { Observable, from, merge } from 'rxjs';
import { from, merge, Observable } from 'rxjs';
import { runStream } from './runStreams';
import templateSrv from 'app/features/templating/template_srv';
import { interpolateSearchFilter } from '../../../features/templating/variable';
type TestData = TimeSeries | TableData;
@ -119,10 +119,16 @@ export class TestDataDataSource extends DataSourceApi<TestDataQuery> {
return getBackendSrv().get('/api/tsdb/testdata/scenarios');
}
metricFindQuery(query: string) {
metricFindQuery(query: string, options: any) {
return new Promise<MetricFindValue[]>((resolve, reject) => {
setTimeout(() => {
const children = queryMetricTree(templateSrv.replace(query));
const interpolatedQuery = interpolateSearchFilter({
query: templateSrv.replace(query),
options,
wildcardChar: '*',
quoteLiteral: false,
});
const children = queryMetricTree(interpolatedQuery);
const items = children.map(item => ({ value: item.name, text: item.name }));
resolve(items);
}, 100);

View File

@ -16,4 +16,9 @@ describe('MetricTree', () => {
const nodes = queryMetricTree('A.{AB,AC}.*').map(i => i.name);
expect(nodes).toEqual(['ABA', 'ABB', 'ABC', 'ACA', 'ACB', 'ACC']);
});
it('queryMetric tree supports wildcard matching', () => {
const nodes = queryMetricTree('A.AB.AB*').map(i => i.name);
expect(nodes).toEqual(['ABA', 'ABB', 'ABC']);
});
});

View File

@ -35,6 +35,10 @@ function buildMetricTree(parent: string, depth: number): TreeNode[] {
}
function queryTree(children: TreeNode[], query: string[], queryIndex: number): TreeNode[] {
if (queryIndex >= query.length) {
return children;
}
if (query[queryIndex] === '*') {
return children;
}
@ -50,7 +54,13 @@ function queryTree(children: TreeNode[], query: string[], queryIndex: number): T
for (const node of children) {
for (const nameToMatch of namesToMatch) {
if (node.name === nameToMatch) {
if (nameToMatch.indexOf('*') !== -1) {
const pattern = nameToMatch.replace('*', '');
const regex = new RegExp(`^${pattern}.*`, 'gi');
if (regex.test(node.name)) {
result = result.concat(queryTree([node], query, queryIndex + 1));
}
} else if (node.name === nameToMatch) {
result = result.concat(queryTree(node.children, query, queryIndex + 1));
}
}