diff --git a/docs/sources/features/datasources/graphite.md b/docs/sources/features/datasources/graphite.md index fe0c7da2c48..fe9f2a73f7b 100644 --- a/docs/sources/features/datasources/graphite.md +++ b/docs/sources/features/datasources/graphite.md @@ -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. diff --git a/docs/sources/features/datasources/mysql.md b/docs/sources/features/datasources/mysql.md index 8001307cdd8..fd73693f483 100644 --- a/docs/sources/features/datasources/mysql.md +++ b/docs/sources/features/datasources/mysql.md @@ -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. diff --git a/docs/sources/features/datasources/postgres.md b/docs/sources/features/datasources/postgres.md index 138e3d6517b..59a5c880c54 100644 --- a/docs/sources/features/datasources/postgres.md +++ b/docs/sources/features/datasources/postgres.md @@ -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. diff --git a/public/app/core/directives/value_select_dropdown.ts b/public/app/core/directives/value_select_dropdown.ts index 145463407e2..65e0d6137d9 100644 --- a/public/app/core/directives/value_select_dropdown.ts +++ b/public/app/core/directives/value_select_dropdown.ts @@ -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 { + 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 */ diff --git a/public/app/core/specs/value_select_dropdown.test.ts b/public/app/core/specs/value_select_dropdown.test.ts index 98f2467306b..48c867f5792 100644 --- a/public/app/core/specs/value_select_dropdown.test.ts +++ b/public/app/core/specs/value_select_dropdown.test.ts @@ -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); + }); + }); +}); diff --git a/public/app/features/templating/query_variable.ts b/public/app/features/templating/query_variable.ts index 9e57f489658..5e64f235d67 100644 --- a/public/app/features/templating/query_variable.ts +++ b/public/app/features/templating/query_variable.ts @@ -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(); diff --git a/public/app/features/templating/specs/variable.test.ts b/public/app/features/templating/specs/variable.test.ts index 83f4af8bca9..3023006d54d 100644 --- a/public/app/features/templating/specs/variable.test.ts +++ b/public/app/features/templating/specs/variable.test.ts @@ -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*'`); + }); + }); + }); + }); +}); diff --git a/public/app/features/templating/variable.ts b/public/app/features/templating/variable.ts index 7010e548132..f7a0b1b06e8 100644 --- a/public/app/features/templating/variable.ts +++ b/public/app/features/templating/variable.ts @@ -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; diff --git a/public/app/partials/valueSelectDropdown.html b/public/app/partials/valueSelectDropdown.html index 486b0ab1d6e..164308876b2 100644 --- a/public/app/partials/valueSelectDropdown.html +++ b/public/app/partials/valueSelectDropdown.html @@ -10,7 +10,7 @@ - +
diff --git a/public/app/plugins/datasource/graphite/datasource.ts b/public/app/plugins/datasource/graphite/datasource.ts index a78a892efcb..87aac2048ce 100644 --- a/public/app/plugins/datasource/graphite/datasource.ts +++ b/public/app/plugins/datasource/graphite/datasource.ts @@ -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([,]*), this is used for template variables let matches = interpolatedQuery.match(/^tag_values\(([^,]+)((, *[^,]+)*)\)$/); diff --git a/public/app/plugins/datasource/graphite/specs/datasource.test.ts b/public/app/plugins/datasource/graphite/specs/datasource.test.ts index 70f086aa433..871e07dc20d 100644 --- a/public/app/plugins/datasource/graphite/specs/datasource.test.ts +++ b/public/app/plugins/datasource/graphite/specs/datasource.test.ts @@ -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); + }); }); }); diff --git a/public/app/plugins/datasource/mysql/datasource.ts b/public/app/plugins/datasource/mysql/datasource.ts index d035a3633f2..63230de37c8 100644 --- a/public/app/plugins/datasource/mysql/datasource.ts +++ b/public/app/plugins/datasource/mysql/datasource.ts @@ -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', }; diff --git a/public/app/plugins/datasource/mysql/specs/datasource.test.ts b/public/app/plugins/datasource/mysql/specs/datasource.test.ts index 8aab0abe048..295b96b077d 100644 --- a/public/app/plugins/datasource/mysql/specs/datasource.test.ts +++ b/public/app/plugins/datasource/mysql/specs/datasource.test.ts @@ -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'; diff --git a/public/app/plugins/datasource/postgres/datasource.ts b/public/app/plugins/datasource/postgres/datasource.ts index cc0df228b1e..13b80558bb6 100644 --- a/public/app/plugins/datasource/postgres/datasource.ts +++ b/public/app/plugins/datasource/postgres/datasource.ts @@ -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', }; diff --git a/public/app/plugins/datasource/postgres/specs/datasource.test.ts b/public/app/plugins/datasource/postgres/specs/datasource.test.ts index 27f448e3c2f..4625e0be12a 100644 --- a/public/app/plugins/datasource/postgres/specs/datasource.test.ts +++ b/public/app/plugins/datasource/postgres/specs/datasource.test.ts @@ -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'; diff --git a/public/app/plugins/datasource/testdata/datasource.ts b/public/app/plugins/datasource/testdata/datasource.ts index 737db5df4a0..0176335aee7 100644 --- a/public/app/plugins/datasource/testdata/datasource.ts +++ b/public/app/plugins/datasource/testdata/datasource.ts @@ -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 { return getBackendSrv().get('/api/tsdb/testdata/scenarios'); } - metricFindQuery(query: string) { + metricFindQuery(query: string, options: any) { return new Promise((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); diff --git a/public/app/plugins/datasource/testdata/metricTree.test.ts b/public/app/plugins/datasource/testdata/metricTree.test.ts index ead0363b7cf..737bdf57ca9 100644 --- a/public/app/plugins/datasource/testdata/metricTree.test.ts +++ b/public/app/plugins/datasource/testdata/metricTree.test.ts @@ -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']); + }); }); diff --git a/public/app/plugins/datasource/testdata/metricTree.ts b/public/app/plugins/datasource/testdata/metricTree.ts index 7f72977366e..1d6ca8b8806 100644 --- a/public/app/plugins/datasource/testdata/metricTree.ts +++ b/public/app/plugins/datasource/testdata/metricTree.ts @@ -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)); } }