Mysql: Support request cancellation properly (Uses new backendSrv.fetch Observable request API) (#27649)

This commit is contained in:
Hugo Häggmark 2020-09-25 12:46:28 +02:00 committed by GitHub
parent 3ae6ba034d
commit 0bb8b32821
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 138 additions and 130 deletions

View File

@ -1,12 +1,14 @@
import _ from 'lodash';
import ResponseParser from './response_parser';
import MysqlQuery from 'app/plugins/datasource/mysql/mysql_query';
import { Observable, of } from 'rxjs';
import { catchError, map, mapTo } from 'rxjs/operators';
import { getBackendSrv } from '@grafana/runtime';
import { ScopedVars } from '@grafana/data';
import { TemplateSrv } from 'app/features/templating/template_srv';
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
//Types
import { MysqlQueryForInterpolation } from './types';
import MysqlQuery from 'app/plugins/datasource/mysql/mysql_query';
import ResponseParser, { MysqlResponse } from './response_parser';
import { MysqlMetricFindValue, MysqlQueryForInterpolation } from './types';
import { getSearchFilterScopedVar } from '../../../features/variables/utils';
export class MysqlDatasource {
@ -25,7 +27,7 @@ export class MysqlDatasource {
this.interval = (instanceSettings.jsonData || {}).timeInterval || '1m';
}
interpolateVariable = (value: string, variable: any) => {
interpolateVariable = (value: string | string[] | number, variable: any) => {
if (typeof value === 'string') {
if (variable.multi || variable.includeAll) {
const result = this.queryModel.quoteLiteral(value);
@ -64,7 +66,7 @@ export class MysqlDatasource {
return expandedQueries;
}
query(options: any) {
query(options: any): Observable<MysqlResponse> {
const queries = _.filter(options.targets, target => {
return target.hide !== true;
}).map(target => {
@ -81,11 +83,11 @@ export class MysqlDatasource {
});
if (queries.length === 0) {
return Promise.resolve({ data: [] });
return of({ data: [] });
}
return getBackendSrv()
.datasourceRequest({
.fetch({
url: '/api/tsdb/query',
method: 'POST',
data: {
@ -94,7 +96,7 @@ export class MysqlDatasource {
queries: queries,
},
})
.then(this.responseParser.processQueryResult);
.pipe(map(this.responseParser.processQueryResult));
}
annotationQuery(options: any) {
@ -112,7 +114,7 @@ export class MysqlDatasource {
};
return getBackendSrv()
.datasourceRequest({
.fetch({
url: '/api/tsdb/query',
method: 'POST',
data: {
@ -121,10 +123,11 @@ export class MysqlDatasource {
queries: [query],
},
})
.then((data: any) => this.responseParser.transformAnnotationResponse(options, data));
.pipe(map((data: any) => this.responseParser.transformAnnotationResponse(options, data)))
.toPromise();
}
metricFindQuery(query: string, optionalOptions: any) {
metricFindQuery(query: string, optionalOptions: any): Promise<MysqlMetricFindValue[]> {
let refId = 'tempvar';
if (optionalOptions && optionalOptions.variable && optionalOptions.variable.name) {
refId = optionalOptions.variable.name;
@ -158,17 +161,18 @@ export class MysqlDatasource {
}
return getBackendSrv()
.datasourceRequest({
.fetch({
url: '/api/tsdb/query',
method: 'POST',
data: data,
})
.then((data: any) => this.responseParser.parseMetricFindQueryResult(refId, data));
.pipe(map((data: any) => this.responseParser.parseMetricFindQueryResult(refId, data)))
.toPromise();
}
testDatasource() {
return getBackendSrv()
.datasourceRequest({
.fetch({
url: '/api/tsdb/query',
method: 'POST',
data: {
@ -186,17 +190,18 @@ export class MysqlDatasource {
],
},
})
.then((res: any) => {
return { status: 'success', message: 'Database Connection OK' };
})
.catch((err: any) => {
console.error(err);
if (err.data && err.data.message) {
return { status: 'error', message: err.data.message };
} else {
return { status: 'error', message: err.status };
}
});
.pipe(
mapTo({ status: 'success', message: 'Database Connection OK' }),
catchError(err => {
console.error(err);
if (err.data && err.data.message) {
return of({ status: 'error', message: err.data.message });
} else {
return of({ status: 'error', message: err.status });
}
})
)
.toPromise();
}
targetContainsTemplate(target: any) {

View File

@ -1,7 +1,25 @@
import _ from 'lodash';
import { MysqlMetricFindValue } from './types';
interface TableResponse extends Record<string, any> {
type: string;
refId: string;
meta: any;
}
interface SeriesResponse extends Record<string, any> {
target: string;
refId: string;
meta: any;
datapoints: [any[]];
}
export interface MysqlResponse {
data: Array<TableResponse | SeriesResponse>;
}
export default class ResponseParser {
processQueryResult(res: any) {
processQueryResult(res: any): MysqlResponse {
const data: any[] = [];
if (!res.data.results) {
@ -35,7 +53,7 @@ export default class ResponseParser {
return { data: data };
}
parseMetricFindQueryResult(refId: string, results: any) {
parseMetricFindQueryResult(refId: string, results: any): MysqlMetricFindValue[] {
if (!results || results.data.length === 0 || results.data.results[refId].meta.rowCount === 0) {
return [];
}
@ -117,9 +135,9 @@ export default class ResponseParser {
} else if (table.columns[i].text === 'timeend') {
timeEndColumnIndex = i;
} else if (table.columns[i].text === 'title') {
return Promise.reject({
throw {
message: 'The title column for annotations is deprecated, now only a column named text is returned',
});
};
} else if (table.columns[i].text === 'text') {
textColumnIndex = i;
} else if (table.columns[i].text === 'tags') {
@ -128,9 +146,9 @@ export default class ResponseParser {
}
if (timeColumnIndex === -1) {
return Promise.reject({
throw {
message: 'Missing mandatory time column (with time_sec column alias) in annotation query.',
});
};
}
const list = [];

View File

@ -1,8 +1,11 @@
import { MysqlDatasource } from '../datasource';
import { of } from 'rxjs';
import { dateTime, toUtc } from '@grafana/data';
import { MysqlDatasource } from '../datasource';
import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__
import { TemplateSrv } from 'app/features/templating/template_srv';
import { initialCustomVariableModelState } from '../../../../features/variables/custom/reducer';
import { FetchResponse } from '@grafana/runtime';
jest.mock('@grafana/runtime', () => ({
...((jest.requireActual('@grafana/runtime') as unknown) as object),
@ -10,35 +13,32 @@ jest.mock('@grafana/runtime', () => ({
}));
describe('MySQLDatasource', () => {
const instanceSettings = { name: 'mysql' };
const templateSrv: TemplateSrv = new TemplateSrv();
const datasourceRequestMock = jest.spyOn(backendSrv, 'datasourceRequest');
beforeEach(() => {
jest.clearAllMocks();
});
const raw = {
from: toUtc('2018-04-25 10:00'),
to: toUtc('2018-04-25 11:00'),
};
const ctx = {
timeSrvMock: {
const fetchMock = jest.spyOn(backendSrv, 'fetch');
const setupTextContext = (response: any) => {
const instanceSettings = { name: 'mysql' };
const templateSrv: TemplateSrv = new TemplateSrv();
const raw = {
from: toUtc('2018-04-25 10:00'),
to: toUtc('2018-04-25 11:00'),
};
const timeSrvMock: any = {
timeRange: () => ({
from: raw.from,
to: raw.to,
raw: raw,
}),
},
} as any;
};
const variable = { ...initialCustomVariableModelState };
beforeEach(() => {
ctx.ds = new MysqlDatasource(instanceSettings, templateSrv, ctx.timeSrvMock);
});
jest.clearAllMocks();
fetchMock.mockImplementation(options => of(createFetchResponse(response)));
const ds = new MysqlDatasource(instanceSettings, templateSrv, timeSrvMock);
return { ds, variable, templateSrv };
};
describe('When performing annotationQuery', () => {
let results: any;
const annotationName = 'MyAnno';
const options = {
@ -70,15 +70,10 @@ describe('MySQLDatasource', () => {
},
};
beforeEach(() => {
datasourceRequestMock.mockImplementation(options => Promise.resolve({ data: response, status: 200 }));
it('should return annotation list', async () => {
const { ds } = setupTextContext(response);
const results = await ds.annotationQuery(options);
ctx.ds.annotationQuery(options).then((data: any) => {
results = data;
});
});
it('should return annotation list', () => {
expect(results.length).toBe(3);
expect(results[0].text).toBe('some text');
@ -93,7 +88,6 @@ describe('MySQLDatasource', () => {
});
describe('When performing metricFindQuery', () => {
let results: any;
const query = 'select * from atable';
const response = {
results: {
@ -116,15 +110,10 @@ describe('MySQLDatasource', () => {
},
};
beforeEach(() => {
datasourceRequestMock.mockImplementation(options => Promise.resolve({ data: response, status: 200 }));
it('should return list of all column values', async () => {
const { ds } = setupTextContext(response);
const results = await ds.metricFindQuery(query, {});
ctx.ds.metricFindQuery(query).then((data: any) => {
results = data;
});
});
it('should return list of all column values', () => {
expect(results.length).toBe(6);
expect(results[0].text).toBe('aTitle');
expect(results[5].text).toBe('some text3');
@ -132,8 +121,6 @@ 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: {
@ -156,26 +143,19 @@ describe('MySQLDatasource', () => {
},
};
beforeEach(() => {
datasourceRequestMock.mockImplementation(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', async () => {
const { ds } = setupTextContext(response);
const results = await ds.metricFindQuery(query, { searchFilter: 'aTit' });
it('should return list of all column values', () => {
expect(datasourceRequestMock).toBeCalledTimes(1);
expect(calledWith.data.queries[0].rawSql).toBe("select title from atable where title LIKE 'aTit%'");
expect(fetchMock).toBeCalledTimes(1);
expect(fetchMock.mock.calls[0][0].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: {
@ -198,25 +178,17 @@ describe('MySQLDatasource', () => {
},
};
beforeEach(() => {
datasourceRequestMock.mockImplementation(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', async () => {
const { ds } = setupTextContext(response);
const results = await ds.metricFindQuery(query, {});
it('should return list of all column values', () => {
expect(datasourceRequestMock).toBeCalledTimes(1);
expect(calledWith.data.queries[0].rawSql).toBe("select title from atable where title LIKE '%'");
expect(fetchMock).toBeCalledTimes(1);
expect(fetchMock.mock.calls[0][0].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';
const response = {
results: {
@ -239,14 +211,10 @@ describe('MySQLDatasource', () => {
},
};
beforeEach(() => {
datasourceRequestMock.mockImplementation(() => Promise.resolve({ data: response, status: 200 }));
ctx.ds.metricFindQuery(query).then((data: any) => {
results = data;
});
});
it('should return list of as text, value', async () => {
const { ds } = setupTextContext(response);
const results = await ds.metricFindQuery(query, {});
it('should return list of as text, value', () => {
expect(results.length).toBe(3);
expect(results[0].text).toBe('aTitle');
expect(results[0].value).toBe('value1');
@ -256,7 +224,6 @@ describe('MySQLDatasource', () => {
});
describe('When performing metricFindQuery with key, value columns and with duplicate keys', () => {
let results: any;
const query = 'select * from atable';
const response = {
results: {
@ -279,14 +246,10 @@ describe('MySQLDatasource', () => {
},
};
beforeEach(() => {
datasourceRequestMock.mockImplementation(() => Promise.resolve({ data: response, status: 200 }));
ctx.ds.metricFindQuery(query).then((data: any) => {
results = data;
});
});
it('should return list of unique keys', async () => {
const { ds } = setupTextContext(response);
const results = await ds.metricFindQuery(query, {});
it('should return list of unique keys', () => {
expect(results.length).toBe(1);
expect(results[0].text).toBe('aTitle');
expect(results[0].value).toBe('same');
@ -294,52 +257,55 @@ describe('MySQLDatasource', () => {
});
describe('When interpolating variables', () => {
beforeEach(() => {
ctx.variable = { ...initialCustomVariableModelState };
});
describe('and value is a string', () => {
it('should return an unquoted value', () => {
expect(ctx.ds.interpolateVariable('abc', ctx.variable)).toEqual('abc');
const { ds, variable } = setupTextContext({});
expect(ds.interpolateVariable('abc', variable)).toEqual('abc');
});
});
describe('and value is a number', () => {
it('should return an unquoted value', () => {
expect(ctx.ds.interpolateVariable(1000, ctx.variable)).toEqual(1000);
const { ds, variable } = setupTextContext({});
expect(ds.interpolateVariable(1000, variable)).toEqual(1000);
});
});
describe('and value is an array of strings', () => {
it('should return comma separated quoted values', () => {
expect(ctx.ds.interpolateVariable(['a', 'b', 'c'], ctx.variable)).toEqual("'a','b','c'");
const { ds, variable } = setupTextContext({});
expect(ds.interpolateVariable(['a', 'b', 'c'], variable)).toEqual("'a','b','c'");
});
});
describe('and variable allows multi-value and value is a string', () => {
it('should return a quoted value', () => {
ctx.variable.multi = true;
expect(ctx.ds.interpolateVariable('abc', ctx.variable)).toEqual("'abc'");
const { ds, variable } = setupTextContext({});
variable.multi = true;
expect(ds.interpolateVariable('abc', variable)).toEqual("'abc'");
});
});
describe('and variable contains single quote', () => {
it('should return a quoted value', () => {
ctx.variable.multi = true;
expect(ctx.ds.interpolateVariable("a'bc", ctx.variable)).toEqual("'a''bc'");
const { ds, variable } = setupTextContext({});
variable.multi = true;
expect(ds.interpolateVariable("a'bc", variable)).toEqual("'a''bc'");
});
});
describe('and variable allows all and value is a string', () => {
it('should return a quoted value', () => {
ctx.variable.includeAll = true;
expect(ctx.ds.interpolateVariable('abc', ctx.variable)).toEqual("'abc'");
const { ds, variable } = setupTextContext({});
variable.includeAll = true;
expect(ds.interpolateVariable('abc', variable)).toEqual("'abc'");
});
});
});
describe('targetContainsTemplate', () => {
it('given query that contains template variable it should return true', () => {
const { ds, templateSrv } = setupTextContext({});
const rawSql = `SELECT
$__timeGroup(createdAt,'$summarize') as time_sec,
avg(value) as value,
@ -360,10 +326,11 @@ describe('MySQLDatasource', () => {
{ type: 'query', name: 'summarize', current: { value: '1m' } },
{ type: 'query', name: 'host', current: { value: 'a' } },
]);
expect(ctx.ds.targetContainsTemplate(query)).toBeTruthy();
expect(ds.targetContainsTemplate(query)).toBeTruthy();
});
it('given query that only contains global template variable it should return false', () => {
const { ds, templateSrv } = setupTextContext({});
const rawSql = `SELECT
$__timeGroup(createdAt,'$__interval') as time_sec,
avg(value) as value,
@ -383,7 +350,19 @@ describe('MySQLDatasource', () => {
{ type: 'query', name: 'summarize', current: { value: '1m' } },
{ type: 'query', name: 'host', current: { value: 'a' } },
]);
expect(ctx.ds.targetContainsTemplate(query)).toBeFalsy();
expect(ds.targetContainsTemplate(query)).toBeFalsy();
});
});
});
const createFetchResponse = <T>(data: T): FetchResponse<T> => ({
data,
status: 200,
url: 'http://localhost:3000/api/query',
config: { url: 'http://localhost:3000/api/query' },
type: 'basic',
statusText: 'Ok',
redirected: false,
headers: ({} as unknown) as Headers,
ok: true,
});

View File

@ -1,3 +1,5 @@
import { MetricFindValue } from '@grafana/data';
export interface MysqlQueryForInterpolation {
alias?: any;
format?: any;
@ -5,3 +7,7 @@ export interface MysqlQueryForInterpolation {
refId?: any;
hide?: any;
}
export interface MysqlMetricFindValue extends MetricFindValue {
value?: string;
}