SQL data sources: Convert to return data frames (#32257)

Convert SQL data sources to return data frames.

Co-authored-by: Marcus Efraimsson <marcus.efraimsson@gmail.com>
Co-authored-by: Arve Knudsen <arve.knudsen@gmail.com>
Co-authored-by: Will Browne <will.browne@grafana.com>
Co-authored-by: Hugo Häggmark <hugo.haggmark@gmail.com>
This commit is contained in:
ying-jeanne
2021-05-05 22:46:07 +08:00
committed by GitHub
parent 06c24476dc
commit bd66c8dde3
36 changed files with 4947 additions and 3711 deletions

View File

@@ -1,32 +1,34 @@
import { map as _map, filter } from 'lodash';
import { Observable, of } from 'rxjs';
import { map as _map } from 'lodash';
import { of } from 'rxjs';
import { catchError, map, mapTo } from 'rxjs/operators';
import { getBackendSrv } from '@grafana/runtime';
import { ScopedVars } from '@grafana/data';
import MysqlQuery from 'app/plugins/datasource/mysql/mysql_query';
import ResponseParser, { MysqlResponse } from './response_parser';
import { MysqlMetricFindValue, MysqlQueryForInterpolation } from './types';
import { getBackendSrv, DataSourceWithBackend, FetchResponse, BackendDataSourceResponse } from '@grafana/runtime';
import { DataSourceInstanceSettings, ScopedVars, MetricFindValue, AnnotationEvent } from '@grafana/data';
import MySQLQueryModel from 'app/plugins/datasource/mysql/mysql_query_model';
import ResponseParser from './response_parser';
import { MysqlQueryForInterpolation, MySQLOptions, MySQLQuery } from './types';
import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv';
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { getSearchFilterScopedVar } from '../../../features/variables/utils';
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
export class MysqlDatasource {
export class MysqlDatasource extends DataSourceWithBackend<MySQLQuery, MySQLOptions> {
id: any;
name: any;
responseParser: ResponseParser;
queryModel: MysqlQuery;
queryModel: MySQLQueryModel;
interval: string;
constructor(
instanceSettings: any,
instanceSettings: DataSourceInstanceSettings<MySQLOptions>,
private readonly templateSrv: TemplateSrv = getTemplateSrv(),
private readonly timeSrv: TimeSrv = getTimeSrv()
) {
super(instanceSettings);
this.name = instanceSettings.name;
this.id = instanceSettings.id;
this.responseParser = new ResponseParser();
this.queryModel = new MysqlQuery({});
this.interval = (instanceSettings.jsonData || {}).timeInterval || '1m';
this.queryModel = new MySQLQueryModel({});
const settingsData = instanceSettings.jsonData || ({} as MySQLOptions);
this.interval = settingsData.timeInterval || '1m';
}
interpolateVariable = (value: string | string[] | number, variable: any) => {
@@ -68,40 +70,24 @@ export class MysqlDatasource {
return expandedQueries;
}
query(options: any): Observable<MysqlResponse> {
const queries = filter(options.targets, (target) => {
return target.hide !== true;
}).map((target) => {
const queryModel = new MysqlQuery(target, this.templateSrv, options.scopedVars);
return {
refId: target.refId,
intervalMs: options.intervalMs,
maxDataPoints: options.maxDataPoints,
datasourceId: this.id,
rawSql: queryModel.render(this.interpolateVariable as any),
format: target.format,
};
});
if (queries.length === 0) {
return of({ data: [] });
filterQuery(query: MySQLQuery): boolean {
if (query.hide) {
return false;
}
return getBackendSrv()
.fetch({
url: '/api/tsdb/query',
method: 'POST',
data: {
from: options.range.from.valueOf().toString(),
to: options.range.to.valueOf().toString(),
queries: queries,
},
})
.pipe(map(this.responseParser.processQueryResult));
return true;
}
annotationQuery(options: any) {
applyTemplateVariables(target: MySQLQuery, scopedVars: ScopedVars): Record<string, any> {
const queryModel = new MySQLQueryModel(target, this.templateSrv, scopedVars);
return {
refId: target.refId,
datasourceId: this.id,
rawSql: queryModel.render(this.interpolateVariable as any),
format: target.format,
};
}
async annotationQuery(options: any): Promise<AnnotationEvent[]> {
if (!options.annotation.rawQuery) {
return Promise.reject({
message: 'Query missing in annotation definition',
@@ -116,20 +102,26 @@ export class MysqlDatasource {
};
return getBackendSrv()
.fetch({
url: '/api/tsdb/query',
.fetch<BackendDataSourceResponse>({
url: '/api/ds/query',
method: 'POST',
data: {
from: options.range.from.valueOf().toString(),
to: options.range.to.valueOf().toString(),
queries: [query],
},
requestId: options.annotation.name,
})
.pipe(map((data: any) => this.responseParser.transformAnnotationResponse(options, data)))
.pipe(
map(
async (res: FetchResponse<BackendDataSourceResponse>) =>
await this.responseParser.transformAnnotationResponse(options, res.data)
)
)
.toPromise();
}
metricFindQuery(query: string, optionalOptions: any): Promise<MysqlMetricFindValue[]> {
metricFindQuery(query: string, optionalOptions: any): Promise<MetricFindValue[]> {
let refId = 'tempvar';
if (optionalOptions && optionalOptions.variable && optionalOptions.variable.name) {
refId = optionalOptions.variable.name;
@@ -149,33 +141,30 @@ export class MysqlDatasource {
};
const range = this.timeSrv.timeRange();
const data = {
queries: [interpolatedQuery],
from: range.from.valueOf().toString(),
to: range.to.valueOf().toString(),
};
if (optionalOptions && optionalOptions.range && optionalOptions.range.from) {
data['from'] = optionalOptions.range.from.valueOf().toString();
}
if (optionalOptions && optionalOptions.range && optionalOptions.range.to) {
data['to'] = optionalOptions.range.to.valueOf().toString();
}
return getBackendSrv()
.fetch({
url: '/api/tsdb/query',
.fetch<BackendDataSourceResponse>({
url: '/api/ds/query',
method: 'POST',
data: data,
data: {
from: range.from.valueOf().toString(),
to: range.to.valueOf().toString(),
queries: [interpolatedQuery],
},
requestId: refId,
})
.pipe(map((data: any) => this.responseParser.parseMetricFindQueryResult(refId, data)))
.pipe(
map((rsp) => {
return this.responseParser.transformMetricFindResponse(rsp);
})
)
.toPromise();
}
testDatasource() {
testDatasource(): Promise<any> {
return getBackendSrv()
.fetch({
url: '/api/tsdb/query',
url: '/api/ds/query',
method: 'POST',
data: {
from: '5m',
@@ -212,7 +201,7 @@ export class MysqlDatasource {
if (target.rawQuery) {
rawSql = target.rawSql;
} else {
const query = new MysqlQuery(target);
const query = new MySQLQueryModel(target);
rawSql = query.buildQuery();
}

View File

@@ -5,6 +5,8 @@ import {
createResetHandler,
PasswordFieldEnum,
} from '../../../features/datasources/utils/passwordHandlers';
import { MySQLQuery } from './types';
import { DataSourcePlugin } from '@grafana/data';
class MysqlConfigCtrl {
static templateUrl = 'partials/config.html';
@@ -31,10 +33,11 @@ const defaultQuery = `SELECT
class MysqlAnnotationsQueryCtrl {
static templateUrl = 'partials/annotations.editor.html';
annotation: any;
declare annotation: any;
/** @ngInject */
constructor() {
constructor($scope: any) {
this.annotation = $scope.ctrl.annotation;
this.annotation.rawQuery = this.annotation.rawQuery || defaultQuery;
}
}
@@ -46,3 +49,8 @@ export {
MysqlConfigCtrl as ConfigCtrl,
MysqlAnnotationsQueryCtrl as AnnotationsQueryCtrl,
};
export const plugin = new DataSourcePlugin<MysqlDatasource, MySQLQuery>(MysqlDatasource)
.setQueryCtrl(MysqlQueryCtrl)
.setConfigCtrl(MysqlConfigCtrl)
.setAnnotationQueryCtrl(MysqlAnnotationsQueryCtrl);

View File

@@ -2,7 +2,7 @@ import { find, map } from 'lodash';
import { TemplateSrv } from '@grafana/runtime';
import { ScopedVars } from '@grafana/data';
export default class MysqlQuery {
export default class MySQLQueryModel {
target: any;
templateSrv: any;
scopedVars: any;

View File

@@ -3,7 +3,7 @@ import appEvents from 'app/core/app_events';
import { MysqlMetaQuery } from './meta_query';
import { QueryCtrl } from 'app/plugins/sdk';
import { SqlPart } from 'app/core/components/sql_part/sql_part';
import MysqlQuery from './mysql_query';
import MySQLQueryModel from './mysql_query_model';
import sqlPart from './sql_part';
import { auto } from 'angular';
import { PanelEvents, QueryResultMeta } from '@grafana/data';
@@ -27,7 +27,7 @@ export class MysqlQueryCtrl extends QueryCtrl {
lastQueryError?: string;
showHelp!: boolean;
queryModel: MysqlQuery;
queryModel: MySQLQueryModel;
metaBuilder: MysqlMetaQuery;
lastQueryMeta?: QueryResultMeta;
tableSegment: any;
@@ -50,7 +50,7 @@ export class MysqlQueryCtrl extends QueryCtrl {
super($scope, $injector);
this.target = this.target;
this.queryModel = new MysqlQuery(this.target, templateSrv, this.panel.scopedVars);
this.queryModel = new MySQLQueryModel(this.target, templateSrv, this.panel.scopedVars);
this.metaBuilder = new MysqlMetaQuery(this.target, this.queryModel);
this.updateProjection();

View File

@@ -1,91 +1,57 @@
import { map } 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>;
}
import { AnnotationEvent, DataFrame, FieldType, MetricFindValue } from '@grafana/data';
import { BackendDataSourceResponse, FetchResponse, toDataQueryResponse } from '@grafana/runtime';
export default class ResponseParser {
processQueryResult(res: any): MysqlResponse {
const data: any[] = [];
transformMetricFindResponse(raw: FetchResponse<BackendDataSourceResponse>): MetricFindValue[] {
const frames = toDataQueryResponse(raw).data as DataFrame[];
if (!res.data.results) {
return { data: data };
}
for (const key in res.data.results) {
const queryRes = res.data.results[key];
if (queryRes.series) {
for (const series of queryRes.series) {
data.push({
target: series.name,
datapoints: series.points,
refId: queryRes.refId,
meta: queryRes.meta,
});
}
}
if (queryRes.tables) {
for (const table of queryRes.tables) {
table.type = 'table';
table.refId = queryRes.refId;
table.meta = queryRes.meta;
data.push(table);
}
}
}
return { data: data };
}
parseMetricFindQueryResult(refId: string, results: any): MysqlMetricFindValue[] {
if (!results || results.data.length === 0 || results.data.results[refId].meta.rowCount === 0) {
if (!frames || !frames.length) {
return [];
}
const columns = results.data.results[refId].tables[0].columns;
const rows = results.data.results[refId].tables[0].rows;
const textColIndex = this.findColIndex(columns, '__text');
const valueColIndex = this.findColIndex(columns, '__value');
const frame = frames[0];
if (columns.length === 2 && textColIndex !== -1 && valueColIndex !== -1) {
return this.transformToKeyValueList(rows, textColIndex, valueColIndex);
const values: MetricFindValue[] = [];
const textField = frame.fields.find((f) => f.name === '__text');
const valueField = frame.fields.find((f) => f.name === '__value');
if (textField && valueField) {
for (let i = 0; i < textField.values.length; i++) {
values.push({ text: '' + textField.values.get(i), value: '' + valueField.values.get(i) });
}
} else {
const textFields = frame.fields.filter((f) => f.type === FieldType.string);
if (textFields) {
values.push(
...textFields
.flatMap((f) => f.values.toArray())
.map((v) => ({
text: '' + v,
}))
);
}
}
return this.transformToSimpleList(rows);
return Array.from(new Set(values.map((v) => v.text))).map((text) => ({
text,
value: values.find((v) => v.text === text)?.value,
}));
}
transformToKeyValueList(rows: any, textColIndex: number, valueColIndex: number) {
transformToKeyValueList(rows: any, textColIndex: number, valueColIndex: number): MetricFindValue[] {
const res = [];
for (let i = 0; i < rows.length; i++) {
if (!this.containsKey(res, rows[i][textColIndex])) {
res.push({
text: rows[i][textColIndex],
value: rows[i][valueColIndex],
});
res.push({ text: rows[i][textColIndex], value: rows[i][valueColIndex] });
}
}
return res;
}
transformToSimpleList(rows: any) {
transformToSimpleList(rows: any): MetricFindValue[] {
const res = [];
for (let i = 0; i < rows.length; i++) {
@@ -120,47 +86,38 @@ export default class ResponseParser {
return false;
}
transformAnnotationResponse(options: any, data: any) {
const table = data.data.results[options.annotation.name].tables[0];
async transformAnnotationResponse(options: any, data: BackendDataSourceResponse): Promise<AnnotationEvent[]> {
const frames = toDataQueryResponse({ data: data }).data as DataFrame[];
const frame = frames[0];
const timeField = frame.fields.find((f) => f.name === 'time' || f.name === 'time_sec');
let timeColumnIndex = -1;
let timeEndColumnIndex = -1;
let textColumnIndex = -1;
let tagsColumnIndex = -1;
for (let i = 0; i < table.columns.length; i++) {
if (table.columns[i].text === 'time_sec' || table.columns[i].text === 'time') {
timeColumnIndex = i;
} else if (table.columns[i].text === 'timeend') {
timeEndColumnIndex = i;
} else if (table.columns[i].text === 'title') {
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') {
tagsColumnIndex = i;
}
if (!timeField) {
throw new Error('Missing mandatory time column (with time column alias) in annotation query');
}
if (timeColumnIndex === -1) {
throw {
message: 'Missing mandatory time column (with time_sec column alias) in annotation query.',
};
if (frame.fields.find((f) => f.name === 'title')) {
throw new Error('The title column for annotations is deprecated, now only a column named text is returned');
}
const list = [];
for (let i = 0; i < table.rows.length; i++) {
const row = table.rows[i];
const timeEnd =
timeEndColumnIndex !== -1 && row[timeEndColumnIndex] ? Math.floor(row[timeEndColumnIndex]) : undefined;
const timeEndField = frame.fields.find((f) => f.name === 'timeend');
const textField = frame.fields.find((f) => f.name === 'text');
const tagsField = frame.fields.find((f) => f.name === 'tags');
const list: AnnotationEvent[] = [];
for (let i = 0; i < frame.length; i++) {
const timeEnd = timeEndField && timeEndField.values.get(i) ? Math.floor(timeEndField.values.get(i)) : undefined;
list.push({
annotation: options.annotation,
time: Math.floor(row[timeColumnIndex]),
time: Math.floor(timeField.values.get(i)),
timeEnd,
text: row[textColumnIndex] ? row[textColumnIndex].toString() : '',
tags: row[tagsColumnIndex] ? row[tagsColumnIndex].trim().split(/\s*,\s*/) : [],
text: textField && textField.values.get(i) ? textField.values.get(i) : '',
tags:
tagsField && tagsField.values.get(i)
? tagsField.values
.get(i)
.trim()
.split(/\s*,\s*/)
: [],
});
}

View File

@@ -1,22 +1,32 @@
import { of } from 'rxjs';
import { dateTime, toUtc } from '@grafana/data';
import {
dataFrameToJSON,
DataQueryRequest,
DataSourceInstanceSettings,
dateTime,
MutableDataFrame,
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),
getBackendSrv: () => backendSrv,
}));
import { FetchResponse, setBackendSrv } from '@grafana/runtime';
import { MySQLOptions, MySQLQuery } from './../types';
describe('MySQLDatasource', () => {
const fetchMock = jest.spyOn(backendSrv, 'fetch');
const setupTextContext = (response: any) => {
const instanceSettings = { name: 'mysql' };
jest.clearAllMocks();
setBackendSrv(backendSrv);
const fetchMock = jest.spyOn(backendSrv, 'fetch');
const instanceSettings = ({
jsonData: {
defaultProject: 'testproject',
},
} as unknown) as DataSourceInstanceSettings<MySQLOptions>;
const templateSrv: TemplateSrv = new TemplateSrv();
const variable = { ...initialCustomVariableModelState };
const raw = {
from: toUtc('2018-04-25 10:00'),
to: toUtc('2018-04-25 11:00'),
@@ -28,19 +38,44 @@ describe('MySQLDatasource', () => {
raw: raw,
}),
};
const variable = { ...initialCustomVariableModelState };
jest.clearAllMocks();
fetchMock.mockImplementation((options) => of(createFetchResponse(response)));
const ds = new MysqlDatasource(instanceSettings, templateSrv, timeSrvMock);
return { ds, variable, templateSrv };
return { ds, variable, templateSrv, fetchMock };
};
describe('When performing annotationQuery', () => {
const annotationName = 'MyAnno';
describe('When performing a query with hidden target', () => {
it('should return empty result and backendSrv.fetch should not be called', async () => {
const options = ({
range: {
from: dateTime(1432288354),
to: dateTime(1432288401),
},
targets: [
{
format: 'table',
rawQuery: true,
rawSql: 'select time, metric, value from grafana_metric',
refId: 'A',
datasource: 'gdev-ds',
hide: true,
},
],
} as unknown) as DataQueryRequest<MySQLQuery>;
const { ds, fetchMock } = setupTextContext({});
await expect(ds.query(options)).toEmitValuesWith((received) => {
expect(received[0]).toEqual({ data: [] });
expect(fetchMock).not.toHaveBeenCalled();
});
});
});
describe('When performing annotationQuery', () => {
let results: any;
const annotationName = 'MyAnno';
const options = {
annotation: {
name: annotationName,
@@ -51,38 +86,37 @@ describe('MySQLDatasource', () => {
to: dateTime(1432288401),
},
};
const response = {
results: {
MyAnno: {
refId: annotationName,
tables: [
{
columns: [{ text: 'time_sec' }, { text: 'text' }, { text: 'tags' }],
rows: [
[1432288355, 'some text', 'TagA,TagB'],
[1432288390, 'some text2', ' TagB , TagC'],
[1432288400, 'some text3'],
],
},
frames: [
dataFrameToJSON(
new MutableDataFrame({
fields: [
{ name: 'time_sec', values: [1432288355, 1432288390, 1432288400] },
{ name: 'text', values: ['some text', 'some text2', 'some text3'] },
{ name: 'tags', values: ['TagA,TagB', ' TagB , TagC', null] },
],
})
),
],
},
},
};
it('should return annotation list', async () => {
beforeEach(async () => {
const { ds } = setupTextContext(response);
const results = await ds.annotationQuery(options);
const data = await ds.annotationQuery(options);
results = data;
});
it('should return annotation list', async () => {
expect(results.length).toBe(3);
expect(results[0].text).toBe('some text');
expect(results[0].tags[0]).toBe('TagA');
expect(results[0].tags[1]).toBe('TagB');
expect(results[1].tags[0]).toBe('TagB');
expect(results[1].tags[1]).toBe('TagC');
expect(results[2].tags.length).toBe(0);
});
});
@@ -92,19 +126,19 @@ describe('MySQLDatasource', () => {
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'],
],
},
frames: [
dataFrameToJSON(
new MutableDataFrame({
fields: [
{ name: 'title', values: ['aTitle', 'aTitle2', 'aTitle3'] },
{ name: 'text', values: ['some text', 'some text2', 'some text3'] },
],
meta: {
executedQueryString: 'select * from atable',
},
})
),
],
},
},
@@ -125,26 +159,26 @@ describe('MySQLDatasource', () => {
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'],
],
},
frames: [
dataFrameToJSON(
new MutableDataFrame({
fields: [
{ name: 'title', values: ['aTitle', 'aTitle2', 'aTitle3'] },
{ name: 'text', values: ['some text', 'some text2', 'some text3'] },
],
meta: {
executedQueryString: 'select * from atable',
},
})
),
],
},
},
};
it('should return list of all column values', async () => {
const { ds } = setupTextContext(response);
const { ds, fetchMock } = setupTextContext(response);
const results = await ds.metricFindQuery(query, { searchFilter: 'aTit' });
expect(fetchMock).toBeCalledTimes(1);
@@ -160,26 +194,26 @@ describe('MySQLDatasource', () => {
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'],
],
},
frames: [
dataFrameToJSON(
new MutableDataFrame({
fields: [
{ name: 'title', values: ['aTitle', 'aTitle2', 'aTitle3'] },
{ name: 'text', values: ['some text', 'some text2', 'some text3'] },
],
meta: {
executedQueryString: 'select * from atable',
},
})
),
],
},
},
};
it('should return list of all column values', async () => {
const { ds } = setupTextContext(response);
const { ds, fetchMock } = setupTextContext(response);
const results = await ds.metricFindQuery(query, {});
expect(fetchMock).toBeCalledTimes(1);
@@ -193,19 +227,19 @@ describe('MySQLDatasource', () => {
const response = {
results: {
tempvar: {
meta: {
rowCount: 3,
},
refId: 'tempvar',
tables: [
{
columns: [{ text: '__value' }, { text: '__text' }],
rows: [
['value1', 'aTitle'],
['value2', 'aTitle2'],
['value3', 'aTitle3'],
],
},
frames: [
dataFrameToJSON(
new MutableDataFrame({
fields: [
{ name: '__value', values: ['value1', 'value2', 'value3'] },
{ name: '__text', values: ['aTitle', 'aTitle2', 'aTitle3'] },
],
meta: {
executedQueryString: 'select * from atable',
},
})
),
],
},
},
@@ -228,19 +262,19 @@ describe('MySQLDatasource', () => {
const response = {
results: {
tempvar: {
meta: {
rowCount: 3,
},
refId: 'tempvar',
tables: [
{
columns: [{ text: '__text' }, { text: '__value' }],
rows: [
['aTitle', 'same'],
['aTitle', 'same'],
['aTitle', 'diff'],
],
},
frames: [
dataFrameToJSON(
new MutableDataFrame({
fields: [
{ name: '__text', values: ['aTitle', 'aTitle', 'aTitle'] },
{ name: '__value', values: ['same', 'same', 'diff'] },
],
meta: {
executedQueryString: 'select * from atable',
},
})
),
],
},
},

View File

@@ -1,13 +1,20 @@
import { MetricFindValue } from '@grafana/data';
import { DataQuery, DataSourceJsonData } from '@grafana/data';
export interface MysqlQueryForInterpolation {
alias?: any;
format?: any;
rawSql?: any;
refId?: any;
refId: any;
hide?: any;
}
export interface MysqlMetricFindValue extends MetricFindValue {
value?: string;
export interface MySQLOptions extends DataSourceJsonData {
timeInterval: string;
}
export type ResultFormat = 'time_series' | 'table';
export interface MySQLQuery extends DataQuery {
alias?: string;
format?: ResultFormat;
rawSql?: any;
}