SQL Datasources: Fix variable throwing error if query returns no data (#65937)

* Fix SQL query variable throwing error if query returns no data

* Tests to verify that metricFindQuery returns properly and doesn't throw error

* Fix all codepaths that might throw errors because of undefined backendSrv response
This commit is contained in:
Victor Marin 2023-04-11 15:54:55 +03:00 committed by GitHub
parent 0aa301e251
commit 02a8bc76d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 443 additions and 22 deletions

View File

@ -164,7 +164,7 @@ export abstract class SqlDatasource extends DataSourceWithBackend<SQLQuery, SQLO
.pipe(
map((res: FetchResponse<BackendDataSourceResponse>) => {
const rsp = toDataQueryResponse(res, queries);
return rsp.data[0];
return rsp.data[0] ?? { fields: [] };
})
)
);

View File

@ -5,6 +5,7 @@ import {
dataFrameToJSON,
DataSourceInstanceSettings,
dateTime,
FieldType,
MetricFindValue,
MutableDataFrame,
TimeRange,
@ -80,6 +81,133 @@ describe('MSSQLDatasource', () => {
});
});
describe('When runSql returns an empty dataframe', () => {
const response = {
results: {
tempvar: {
refId: 'tempvar',
frames: [],
},
},
};
beforeEach(async () => {
fetchMock.mockImplementation(() => of(createFetchResponse(response)));
});
it('should return an empty array when metricFindQuery is called', async () => {
const query = 'select * from atable';
const results = await ctx.ds.metricFindQuery(query);
expect(results.length).toBe(0);
});
it('should return an empty array when fetchDatasets is called', async () => {
const results = await ctx.ds.fetchDatasets();
expect(results.length).toBe(0);
});
it('should return an empty array when fetchTables is called', async () => {
const results = await ctx.ds.fetchTables();
expect(results.length).toBe(0);
});
it('should return an empty array when fetchFields is called', async () => {
const query: SQLQuery = {
refId: 'refId',
table: 'schema.table',
dataset: 'dataset',
};
const results = await ctx.ds.fetchFields(query);
expect(results.length).toBe(0);
});
});
describe('When runSql returns a populated dataframe', () => {
it('should return a list of datasets when fetchDatasets is called', async () => {
const fetchDatasetsResponse = {
results: {
datasets: {
refId: 'datasets',
frames: [
dataFrameToJSON(
new MutableDataFrame({
fields: [{ name: 'name', type: FieldType.string, values: ['test1', 'test2', 'test3'] }],
})
),
],
},
},
};
fetchMock.mockImplementation(() => of(createFetchResponse(fetchDatasetsResponse)));
const results = await ctx.ds.fetchDatasets();
expect(results.length).toBe(3);
expect(results).toEqual(['test1', 'test2', 'test3']);
});
it('should return a list of tables when fetchTables is called', async () => {
const fetchTableResponse = {
results: {
tables: {
refId: 'tables',
frames: [
dataFrameToJSON(
new MutableDataFrame({
fields: [{ name: 'schemaAndName', type: FieldType.string, values: ['test1', 'test2', 'test3'] }],
})
),
],
},
},
};
fetchMock.mockImplementation(() => of(createFetchResponse(fetchTableResponse)));
const results = await ctx.ds.fetchTables();
expect(results.length).toBe(3);
expect(results).toEqual(['test1', 'test2', 'test3']);
});
it('should return a list of fields when fetchFields is called', async () => {
const fetchFieldsResponse = {
results: {
columns: {
refId: 'columns',
frames: [
dataFrameToJSON(
new MutableDataFrame({
fields: [
{ name: 'column', type: FieldType.string, values: ['test1', 'test2', 'test3'] },
{ name: 'type', type: FieldType.string, values: ['int', 'char', 'bool'] },
],
})
),
],
},
},
};
fetchMock.mockImplementation(() => of(createFetchResponse(fetchFieldsResponse)));
const sqlQuery: SQLQuery = {
refId: 'fields',
table: 'table',
dataset: 'dataset',
};
const results = await ctx.ds.fetchFields(sqlQuery);
expect(results.length).toBe(3);
expect(results[0].label).toBe('test1');
expect(results[0].value).toBe('test1');
expect(results[0].type).toBe('int');
expect(results[1].label).toBe('test2');
expect(results[1].value).toBe('test2');
expect(results[1].type).toBe('char');
expect(results[2].label).toBe('test3');
expect(results[2].value).toBe('test3');
expect(results[2].type).toBe('bool');
});
});
describe('When performing metricFindQuery with key, value columns', () => {
let results: MetricFindValue[];
const query = 'select * from atable';

View File

@ -23,13 +23,13 @@ export class MssqlDatasource extends SqlDatasource {
async fetchDatasets(): Promise<string[]> {
const datasets = await this.runSql<{ name: string[] }>(showDatabases(), { refId: 'datasets' });
return datasets.fields.name.values.toArray().flat();
return datasets.fields.name?.values.toArray().flat() ?? [];
}
async fetchTables(dataset?: string): Promise<string[]> {
// We get back the table name with the schema as well. like dbo.table
const tables = await this.runSql<{ schemaAndName: string[] }>(getSchemaAndName(dataset), { refId: 'tables' });
return tables.fields.schemaAndName.values.toArray().flat();
return tables.fields.schemaAndName?.values.toArray().flat() ?? [];
}
async fetchFields(query: SQLQuery): Promise<SQLSelectableValue[]> {

View File

@ -5,6 +5,7 @@ import {
DataQueryRequest,
DataSourceInstanceSettings,
dateTime,
FieldType,
MutableDataFrame,
} from '@grafana/data';
import { FetchResponse, setBackendSrv } from '@grafana/runtime';
@ -17,7 +18,7 @@ import { MySqlDatasource } from '../MySqlDatasource';
import { MySQLOptions } from '../types';
describe('MySQLDatasource', () => {
const setupTextContext = (response: unknown) => {
const setupTestContext = (response: unknown) => {
jest.clearAllMocks();
setBackendSrv(backendSrv);
const fetchMock = jest.spyOn(backendSrv, 'fetch');
@ -55,7 +56,7 @@ describe('MySQLDatasource', () => {
],
} as unknown as DataQueryRequest<SQLQuery>;
const { ds, fetchMock } = setupTextContext({});
const { ds, fetchMock } = setupTestContext({});
await expect(ds.query(options)).toEmitValuesWith((received) => {
expect(received[0]).toEqual({ data: [] });
@ -64,6 +65,132 @@ describe('MySQLDatasource', () => {
});
});
describe('When runSql returns an empty dataframe', () => {
let ds: MySqlDatasource;
const response = {
results: {
tempvar: {
refId: 'tempvar',
frames: [],
},
},
};
beforeEach(async () => {
ds = setupTestContext(response).ds;
});
it('should return an empty array when metricFindQuery is called', async () => {
const query = 'select * from atable';
const results = await ds.metricFindQuery(query);
expect(results.length).toBe(0);
});
it('should return an empty array when fetchDatasets is called', async () => {
const results = await ds.fetchDatasets();
expect(results.length).toBe(0);
});
it('should return an empty array when fetchTables is called', async () => {
const results = await ds.fetchTables();
expect(results.length).toBe(0);
});
it('should return an empty array when fetchFields is called', async () => {
const query: SQLQuery = {
refId: 'refId',
table: 'schema.table',
dataset: 'dataset',
};
const results = await ds.fetchFields(query);
expect(results.length).toBe(0);
});
});
describe('When runSql returns a populated dataframe', () => {
it('should return a list of datasets when fetchDatasets is called', async () => {
const fetchDatasetsResponse = {
results: {
datasets: {
refId: 'datasets',
frames: [
dataFrameToJSON(
new MutableDataFrame({
fields: [{ name: 'name', type: FieldType.string, values: ['test1', 'test2', 'test3'] }],
})
),
],
},
},
};
const { ds } = setupTestContext(fetchDatasetsResponse);
const results = await ds.fetchDatasets();
expect(results.length).toBe(3);
expect(results).toEqual(['test1', 'test2', 'test3']);
});
it('should return a list of tables when fetchTables is called', async () => {
const fetchTableResponse = {
results: {
tables: {
refId: 'tables',
frames: [
dataFrameToJSON(
new MutableDataFrame({
fields: [{ name: 'table_name', type: FieldType.string, values: ['test1', 'test2', 'test3'] }],
})
),
],
},
},
};
const { ds } = setupTestContext(fetchTableResponse);
const results = await ds.fetchTables();
expect(results.length).toBe(3);
expect(results).toEqual(['test1', 'test2', 'test3']);
});
it('should return a list of fields when fetchFields is called', async () => {
const fetchFieldsResponse = {
results: {
fields: {
refId: 'fields',
frames: [
dataFrameToJSON(
new MutableDataFrame({
fields: [
{ name: 'column_name', type: FieldType.string, values: ['test1', 'test2', 'test3'] },
{ name: 'data_type', type: FieldType.string, values: ['int', 'char', 'bool'] },
],
})
),
],
},
},
};
const { ds } = setupTestContext(fetchFieldsResponse);
const sqlQuery: SQLQuery = {
refId: 'fields',
table: 'table',
dataset: 'dataset',
};
const results = await ds.fetchFields(sqlQuery);
expect(results.length).toBe(3);
expect(results[0].label).toBe('test1');
expect(results[0].value).toBe('test1');
expect(results[0].type).toBe('int');
expect(results[1].label).toBe('test2');
expect(results[1].value).toBe('test2');
expect(results[1].type).toBe('char');
expect(results[2].label).toBe('test3');
expect(results[2].value).toBe('test3');
expect(results[2].type).toBe('bool');
});
});
describe('When performing metricFindQuery that returns multiple string fields', () => {
const query = 'select * from atable';
const response = {
@ -88,7 +215,7 @@ describe('MySQLDatasource', () => {
};
it('should return list of all string field values', async () => {
const { ds } = setupTextContext(response);
const { ds } = setupTestContext(response);
const results = await ds.metricFindQuery(query, {});
expect(results.length).toBe(6);
@ -121,7 +248,7 @@ describe('MySQLDatasource', () => {
};
it('should return list of all column values', async () => {
const { ds, fetchMock } = setupTextContext(response);
const { ds, fetchMock } = setupTestContext(response);
const results = await ds.metricFindQuery(query, { searchFilter: 'aTit' });
expect(fetchMock).toBeCalledTimes(1);
@ -156,7 +283,7 @@ describe('MySQLDatasource', () => {
};
it('should return list of all column values', async () => {
const { ds, fetchMock } = setupTextContext(response);
const { ds, fetchMock } = setupTestContext(response);
const results = await ds.metricFindQuery(query, {});
expect(fetchMock).toBeCalledTimes(1);
@ -189,7 +316,7 @@ describe('MySQLDatasource', () => {
};
it('should return list of as text, value', async () => {
const { ds } = setupTextContext(response);
const { ds } = setupTestContext(response);
const results = await ds.metricFindQuery(query, {});
expect(results.length).toBe(3);
@ -224,7 +351,7 @@ describe('MySQLDatasource', () => {
};
it('should return list of all field values as text', async () => {
const { ds } = setupTextContext(response);
const { ds } = setupTestContext(response);
const results = await ds.metricFindQuery(query, {});
expect(results).toEqual([
@ -262,7 +389,7 @@ describe('MySQLDatasource', () => {
};
it('should return list of unique keys', async () => {
const { ds } = setupTextContext(response);
const { ds } = setupTestContext(response);
const results = await ds.metricFindQuery(query, {});
expect(results.length).toBe(1);
@ -274,28 +401,28 @@ describe('MySQLDatasource', () => {
describe('When interpolating variables', () => {
describe('and value is a string', () => {
it('should return an unquoted value', () => {
const { ds, variable } = setupTextContext({});
const { ds, variable } = setupTestContext({});
expect(ds.interpolateVariable('abc', variable)).toEqual('abc');
});
});
describe('and value is a number', () => {
it('should return an unquoted value', () => {
const { ds, variable } = setupTextContext({});
const { ds, variable } = setupTestContext({});
expect(ds.interpolateVariable(1000, variable)).toEqual(1000);
});
});
describe('and value is an array of strings', () => {
it('should return comma separated quoted values', () => {
const { ds, variable } = setupTextContext({});
const { ds, variable } = setupTestContext({});
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', () => {
const { ds, variable } = setupTextContext({});
const { ds, variable } = setupTestContext({});
variable.multi = true;
expect(ds.interpolateVariable('abc', variable)).toEqual("'abc'");
});
@ -303,7 +430,7 @@ describe('MySQLDatasource', () => {
describe('and variable contains single quote', () => {
it('should return a quoted value', () => {
const { ds, variable } = setupTextContext({});
const { ds, variable } = setupTestContext({});
variable.multi = true;
expect(ds.interpolateVariable("a'bc", variable)).toEqual("'a''bc'");
});
@ -311,7 +438,7 @@ describe('MySQLDatasource', () => {
describe('and variable allows all and value is a string', () => {
it('should return a quoted value', () => {
const { ds, variable } = setupTextContext({});
const { ds, variable } = setupTestContext({});
variable.includeAll = true;
expect(ds.interpolateVariable('abc', variable)).toEqual("'abc'");
});
@ -320,7 +447,7 @@ describe('MySQLDatasource', () => {
describe('targetContainsTemplate', () => {
it('given query that contains template variable it should return true', () => {
const { ds, templateSrv } = setupTextContext({});
const { ds, templateSrv } = setupTestContext({});
const rawSql = `SELECT
$__timeGroup(createdAt,'$summarize') as time_sec,
avg(value) as value,
@ -347,7 +474,7 @@ describe('MySQLDatasource', () => {
});
it('given query that only contains global template variable it should return false', () => {
const { ds, templateSrv } = setupTextContext({});
const { ds, templateSrv } = setupTestContext({});
const rawSql = `SELECT
$__timeGroup(createdAt,'$__interval') as time_sec,
avg(value) as value,

View File

@ -7,6 +7,7 @@ import {
DataQueryResponse,
DataSourceInstanceSettings,
dateTime,
FieldType,
LoadingState,
MutableDataFrame,
} from '@grafana/data';
@ -303,6 +304,161 @@ describe('PostgreSQLDatasource', () => {
});
});
describe('When runSql returns an empty dataframe', () => {
let ds: PostgresDatasource;
const response = {
results: {
tempvar: {
refId: 'tempvar',
frames: [],
},
},
};
beforeEach(async () => {
ds = setupTestContext(response).ds;
});
it('should return an empty array when metricFindQuery is called', async () => {
const query = 'select * from atable';
const results = await ds.metricFindQuery(query);
expect(results.length).toBe(0);
});
it('should return an empty array when fetchTables is called', async () => {
const results = await ds.fetchTables();
expect(results.length).toBe(0);
});
it('should return empty string when getVersion is called', async () => {
const results = await ds.getVersion();
expect(results).toBe('');
});
it('should return undefined when getTimescaleDBVersion is called', async () => {
const results = await ds.getTimescaleDBVersion();
expect(results).toBe(undefined);
});
it('should return an empty array when fetchFields is called', async () => {
const query: SQLQuery = {
refId: 'refId',
table: 'schema.table',
dataset: 'dataset',
};
const results = await ds.fetchFields(query);
expect(results.length).toBe(0);
});
});
describe('When runSql returns a populated dataframe', () => {
it('should return a list of tables when fetchTables is called', async () => {
const fetchTableResponse = {
results: {
tables: {
refId: 'tables',
frames: [
dataFrameToJSON(
new MutableDataFrame({
fields: [{ name: 'table', type: FieldType.string, values: ['test1', 'test2', 'test3'] }],
})
),
],
},
},
};
const { ds } = setupTestContext(fetchTableResponse);
const results = await ds.fetchTables();
expect(results.length).toBe(3);
expect(results).toEqual(['test1', 'test2', 'test3']);
});
it('should return a version string when getVersion is called', async () => {
const fetchVersionResponse = {
results: {
meta: {
refId: 'meta',
frames: [
dataFrameToJSON(
new MutableDataFrame({
fields: [{ name: 'version', type: FieldType.string, values: ['test1'] }],
})
),
],
},
},
};
const { ds } = setupTestContext(fetchVersionResponse);
const version = await ds.getVersion();
expect(version).toBe('test1');
});
it('should return a version string when getTimescaleDBVersion is called', async () => {
const fetchVersionResponse = {
results: {
meta: {
refId: 'meta',
frames: [
dataFrameToJSON(
new MutableDataFrame({
fields: [{ name: 'extversion', type: FieldType.string, values: ['test1'] }],
})
),
],
},
},
};
const { ds } = setupTestContext(fetchVersionResponse);
const version = await ds.getTimescaleDBVersion();
expect(version).toBe('test1');
});
it('should return a list of fields when fetchFields is called', async () => {
const fetchFieldsResponse = {
results: {
columns: {
refId: 'columns',
frames: [
dataFrameToJSON(
new MutableDataFrame({
fields: [
{ name: 'column', type: FieldType.string, values: ['test1', 'test2', 'test3'] },
{ name: 'type', type: FieldType.string, values: ['int', 'char', 'bool'] },
],
})
),
],
},
},
};
const { ds } = setupTestContext(fetchFieldsResponse);
const sqlQuery: SQLQuery = {
refId: 'fields',
table: 'table',
dataset: 'dataset',
};
const results = await ds.fetchFields(sqlQuery);
expect(results.length).toBe(3);
expect(results[0].label).toBe('test1');
expect(results[0].value).toBe('test1');
expect(results[0].type).toBe('int');
expect(results[1].label).toBe('test2');
expect(results[1].value).toBe('test2');
expect(results[1].type).toBe('char');
expect(results[2].label).toBe('test3');
expect(results[2].value).toBe('test3');
expect(results[2].type).toBe('bool');
});
});
describe('When performing metricFindQuery that returns multiple string fields', () => {
it('should return list of all string field values', async () => {
const query = 'select * from atable';

View File

@ -24,19 +24,29 @@ export class PostgresDatasource extends SqlDatasource {
async getVersion(): Promise<string> {
const value = await this.runSql<{ version: number }>(getVersion());
const results = value.fields.version.values.toArray();
const results = value.fields.version?.values.toArray();
if (!results) {
return '';
}
return results[0].toString();
}
async getTimescaleDBVersion(): Promise<string | undefined> {
const value = await this.runSql<{ extversion: string }>(getTimescaleDBVersion());
const results = value.fields.extversion.values.toArray();
const results = value.fields.extversion?.values.toArray();
if (!results) {
return undefined;
}
return results[0];
}
async fetchTables(): Promise<string[]> {
const tables = await this.runSql<{ table: string[] }>(showTables(), { refId: 'tables' });
return tables.fields.table.values.toArray().flat();
return tables.fields.table?.values.toArray().flat() ?? [];
}
getSqlLanguageDefinition(db: DB): LanguageDefinition {