Merge pull request #11081 from svenklemm/postgres-query-builder

postgres: graphical query builder
This commit is contained in:
Marcus Efraimsson
2018-09-04 12:24:10 +02:00
committed by GitHub
17 changed files with 1888 additions and 76 deletions

View File

@@ -155,7 +155,7 @@ Since not all datasources have the same configuration settings we only have the
| tlsSkipVerify | boolean | *All* | Controls whether a client verifies the server's certificate chain and host name. |
| graphiteVersion | string | Graphite | Graphite version |
| timeInterval | string | Elastic, InfluxDB & Prometheus | Lowest interval/step value that should be used for this data source |
| esVersion | number | Elastic | Elasticsearch version as an number (2/5/56) |
| esVersion | number | Elastic | Elasticsearch version as a number (2/5/56) |
| timeField | string | Elastic | Which field that should be used as timestamp |
| interval | string | Elastic | Index date time format |
| authType | string | Cloudwatch | Auth provider. keys/credentials/arn |
@@ -165,6 +165,8 @@ Since not all datasources have the same configuration settings we only have the
| tsdbVersion | string | OpenTSDB | Version |
| tsdbResolution | string | OpenTSDB | Resolution |
| sslmode | string | PostgreSQL | SSLmode. 'disable', 'require', 'verify-ca' or 'verify-full' |
| postgresVersion | number | PostgreSQL | Postgres version as a number (903/904/905/906/1000) meaning v9.3, v9.4, ..., v10 |
| timescaledb | boolean | PostgreSQL | Enable usage of TimescaleDB extension |
#### Secure Json Data

View File

@@ -31,7 +31,9 @@ Name | Description
*User* | Database user's login/username
*Password* | Database user's password
*SSL Mode* | This option determines whether or with what priority a secure SSL TCP/IP connection will be negotiated with the server.
*TimescaleDB* | With this option enabled Grafana will use TimescaleDB features, e.g. use ```time_bucket``` for grouping by time (only available in Grafana 5.3+).
*Version* | This option determines which functions are available in the query builder (only available in Grafana 5.3+).
*TimescaleDB* | TimescaleDB is a time-series database built as a PostgreSQL extension. If enabled, Grafana will use `time_bucket` in the `$__timeGroup` macro and display TimescaleDB specific aggregate functions in the query builder (only available in Grafana 5.3+).
### Database User Permissions (Important!)
@@ -292,5 +294,6 @@ datasources:
password: "Password!"
jsonData:
sslmode: "disable" # disable/require/verify-ca/verify-full
postgresVersion: 903 # 903=9.3, 904=9.4, 905=9.5, 906=9.6, 1000=10
timescaledb: false
```

View File

@@ -0,0 +1,74 @@
import _ from 'lodash';
export class SqlPartDef {
type: string;
style: string;
label: string;
params: any[];
defaultParams: any[];
wrapOpen: string;
wrapClose: string;
separator: string;
constructor(options: any) {
this.type = options.type;
if (options.label) {
this.label = options.label;
} else {
this.label = this.type[0].toUpperCase() + this.type.substring(1) + ':';
}
this.style = options.style;
if (this.style === 'function') {
this.wrapOpen = '(';
this.wrapClose = ')';
this.separator = ', ';
} else {
this.wrapOpen = ' ';
this.wrapClose = ' ';
this.separator = ' ';
}
this.params = options.params;
this.defaultParams = options.defaultParams;
}
}
export class SqlPart {
part: any;
def: SqlPartDef;
params: any[];
label: string;
name: string;
datatype: string;
constructor(part: any, def: any) {
this.part = part;
this.def = def;
if (!this.def) {
throw { message: 'Could not find sql part ' + part.type };
}
this.datatype = part.datatype;
if (part.name) {
this.name = part.name;
this.label = def.label + ' ' + part.name;
} else {
this.name = '';
this.label = def.label;
}
part.params = part.params || _.clone(this.def.defaultParams);
this.params = part.params;
}
updateParam(strValue, index) {
// handle optional parameters
if (strValue === '' && this.def.params[index].optional) {
this.params.splice(index, 1);
} else {
this.params[index] = strValue;
}
this.part.params = this.params;
}
}

View File

@@ -0,0 +1,199 @@
import _ from 'lodash';
import $ from 'jquery';
import coreModule from 'app/core/core_module';
const template = `
<div class="dropdown cascade-open">
<a ng-click="showActionsMenu()" class="query-part-name pointer dropdown-toggle" data-toggle="dropdown">{{part.label}}</a>
<span>{{part.def.wrapOpen}}</span><span class="query-part-parameters"></span><span>{{part.def.wrapClose}}</span>
<ul class="dropdown-menu">
<li ng-repeat="action in partActions">
<a ng-click="triggerPartAction(action)">{{action.text}}</a>
</li>
</ul>
`;
/** @ngInject */
export function sqlPartEditorDirective($compile, templateSrv) {
const paramTemplate = '<input type="text" class="hide input-mini"></input>';
return {
restrict: 'E',
template: template,
scope: {
part: '=',
handleEvent: '&',
debounce: '@',
},
link: function postLink($scope, elem) {
const part = $scope.part;
const partDef = part.def;
const $paramsContainer = elem.find('.query-part-parameters');
const debounceLookup = $scope.debounce;
let cancelBlur = null;
$scope.partActions = [];
function clickFuncParam(this: any, paramIndex) {
/*jshint validthis:true */
const $link = $(this);
const $input = $link.next();
$input.val(part.params[paramIndex]);
$input.css('width', $link.width() + 16 + 'px');
$link.hide();
$input.show();
$input.focus();
$input.select();
const typeahead = $input.data('typeahead');
if (typeahead) {
$input.val('');
typeahead.lookup();
}
}
function inputBlur($input, paramIndex) {
cancelBlur = setTimeout(function() {
switchToLink($input, paramIndex);
}, 200);
}
function switchToLink($input, paramIndex) {
/*jshint validthis:true */
const $link = $input.prev();
const newValue = $input.val();
if (newValue !== '' || part.def.params[paramIndex].optional) {
$link.html(templateSrv.highlightVariablesAsHtml(newValue));
part.updateParam($input.val(), paramIndex);
$scope.$apply(() => {
$scope.handleEvent({ $event: { name: 'part-param-changed' } });
});
}
$input.hide();
$link.show();
}
function inputKeyPress(this: any, paramIndex, e) {
/*jshint validthis:true */
if (e.which === 13) {
switchToLink($(this), paramIndex);
}
}
function inputKeyDown(this: any) {
/*jshint validthis:true */
this.style.width = (3 + this.value.length) * 8 + 'px';
}
function addTypeahead($input, param, paramIndex) {
if (!param.options && !param.dynamicLookup) {
return;
}
const typeaheadSource = function(query, callback) {
if (param.options) {
let options = param.options;
if (param.type === 'int') {
options = _.map(options, function(val) {
return val.toString();
});
}
return options;
}
$scope.$apply(function() {
$scope.handleEvent({ $event: { name: 'get-param-options', param: param } }).then(function(result) {
const dynamicOptions = _.map(result, function(op) {
return op.value;
});
// add current value to dropdown if it's not in dynamicOptions
if (_.indexOf(dynamicOptions, part.params[paramIndex]) === -1) {
dynamicOptions.unshift(part.params[paramIndex]);
}
callback(dynamicOptions);
});
});
};
$input.attr('data-provide', 'typeahead');
$input.typeahead({
source: typeaheadSource,
minLength: 0,
items: 1000,
updater: function(value) {
if (value === part.params[paramIndex]) {
clearTimeout(cancelBlur);
$input.focus();
return value;
}
return value;
},
});
const typeahead = $input.data('typeahead');
typeahead.lookup = function() {
this.query = this.$element.val() || '';
const items = this.source(this.query, $.proxy(this.process, this));
return items ? this.process(items) : items;
};
if (debounceLookup) {
typeahead.lookup = _.debounce(typeahead.lookup, 500, { leading: true });
}
}
$scope.showActionsMenu = function() {
$scope.handleEvent({ $event: { name: 'get-part-actions' } }).then(res => {
$scope.partActions = res;
});
};
$scope.triggerPartAction = function(action) {
$scope.handleEvent({ $event: { name: 'action', action: action } });
};
function addElementsAndCompile() {
_.each(partDef.params, function(param, index) {
if (param.optional && part.params.length <= index) {
return;
}
if (index > 0) {
$('<span>' + partDef.separator + '</span>').appendTo($paramsContainer);
}
const paramValue = templateSrv.highlightVariablesAsHtml(part.params[index]);
const $paramLink = $('<a class="graphite-func-param-link pointer">' + paramValue + '</a>');
const $input = $(paramTemplate);
$paramLink.appendTo($paramsContainer);
$input.appendTo($paramsContainer);
$input.blur(_.partial(inputBlur, $input, index));
$input.keyup(inputKeyDown);
$input.keypress(_.partial(inputKeyPress, index));
$paramLink.click(_.partial(clickFuncParam, index));
addTypeahead($input, param, index);
});
}
function relink() {
$paramsContainer.empty();
addElementsAndCompile();
}
relink();
},
};
}
coreModule.directive('sqlPartEditor', sqlPartEditorDirective);

View File

@@ -31,6 +31,7 @@ import { layoutSelector } from './components/layout_selector/layout_selector';
import { switchDirective } from './components/switch';
import { dashboardSelector } from './components/dashboard_selector';
import { queryPartEditorDirective } from './components/query_part/query_part_editor';
import { sqlPartEditorDirective } from './components/sql_part/sql_part_editor';
import { formDropdownDirective } from './components/form_dropdown/form_dropdown';
import 'app/core/controllers/all';
import 'app/core/services/all';
@@ -72,6 +73,7 @@ export {
appEvents,
dashboardSelector,
queryPartEditorDirective,
sqlPartEditorDirective,
colors,
formDropdownDirective,
assignModelProperties,

View File

@@ -0,0 +1,63 @@
import _ from 'lodash';
export class PostgresConfigCtrl {
static templateUrl = 'partials/config.html';
current: any;
datasourceSrv: any;
showTimescaleDBHelp: boolean;
/** @ngInject */
constructor($scope, datasourceSrv) {
this.datasourceSrv = datasourceSrv;
this.current.jsonData.sslmode = this.current.jsonData.sslmode || 'verify-full';
this.current.jsonData.postgresVersion = this.current.jsonData.postgresVersion || 903;
this.showTimescaleDBHelp = false;
this.autoDetectFeatures();
}
autoDetectFeatures() {
if (!this.current.id) {
return;
}
this.datasourceSrv.loadDatasource(this.current.name).then(ds => {
return ds.getVersion().then(version => {
version = Number(version[0].text);
// timescaledb is only available for 9.6+
if (version >= 906) {
ds.getTimescaleDBVersion().then(version => {
if (version.length === 1) {
this.current.jsonData.timescaledb = true;
}
});
}
const major = Math.trunc(version / 100);
const minor = version % 100;
let name = String(major);
if (version < 1000) {
name = String(major) + '.' + String(minor);
}
if (!_.find(this.postgresVersions, (p: any) => p.value === version)) {
this.postgresVersions.push({ name: name, value: version });
}
this.current.jsonData.postgresVersion = version;
});
});
}
toggleTimescaleDBHelp() {
this.showTimescaleDBHelp = !this.showTimescaleDBHelp;
}
// the value portion is derived from postgres server_version_num/100
postgresVersions = [
{ name: '9.3', value: 903 },
{ name: '9.4', value: 904 },
{ name: '9.5', value: 905 },
{ name: '9.6', value: 906 },
{ name: '10', value: 1000 },
];
}

View File

@@ -1,22 +1,27 @@
import _ from 'lodash';
import ResponseParser from './response_parser';
import PostgresQuery from 'app/plugins/datasource/postgres/postgres_query';
export class PostgresDatasource {
id: any;
name: any;
jsonData: any;
responseParser: ResponseParser;
queryModel: PostgresQuery;
/** @ngInject */
constructor(instanceSettings, private backendSrv, private $q, private templateSrv) {
constructor(instanceSettings, private backendSrv, private $q, private templateSrv, private timeSrv) {
this.name = instanceSettings.name;
this.id = instanceSettings.id;
this.jsonData = instanceSettings.jsonData;
this.responseParser = new ResponseParser(this.$q);
this.queryModel = new PostgresQuery({});
}
interpolateVariable(value, variable) {
if (typeof value === 'string') {
if (variable.multi || variable.includeAll) {
return "'" + value.replace(/'/g, `''`) + "'";
return this.queryModel.quoteLiteral(value);
} else {
return value;
}
@@ -26,23 +31,25 @@ export class PostgresDatasource {
return value;
}
const quotedValues = _.map(value, function(val) {
return "'" + val.replace(/'/g, `''`) + "'";
const quotedValues = _.map(value, v => {
return this.queryModel.quoteLiteral(v);
});
return quotedValues.join(',');
}
query(options) {
const queries = _.filter(options.targets, item => {
return item.hide !== true;
}).map(item => {
const queries = _.filter(options.targets, target => {
return target.hide !== true;
}).map(target => {
const queryModel = new PostgresQuery(target, this.templateSrv, options.scopedVars);
return {
refId: item.refId,
refId: target.refId,
intervalMs: options.intervalMs,
maxDataPoints: options.maxDataPoints,
datasourceId: this.id,
rawSql: this.templateSrv.replace(item.rawSql, options.scopedVars, this.interpolateVariable),
format: item.format,
rawSql: queryModel.render(this.interpolateVariable),
format: target.format,
};
});
@@ -103,17 +110,13 @@ export class PostgresDatasource {
format: 'table',
};
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 this.backendSrv
.datasourceRequest({
url: '/api/tsdb/query',
@@ -123,6 +126,14 @@ export class PostgresDatasource {
.then(data => this.responseParser.parseMetricFindQueryResult(refId, data));
}
getVersion() {
return this.metricFindQuery("SELECT current_setting('server_version_num')::int/100", {});
}
getTimescaleDBVersion() {
return this.metricFindQuery("SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'", {});
}
testDatasource() {
return this.metricFindQuery('SELECT 1', {})
.then(res => {

View File

@@ -0,0 +1,176 @@
export class PostgresMetaQuery {
constructor(private target, private queryModel) {}
getOperators(datatype: string) {
switch (datatype) {
case 'float4':
case 'float8': {
return ['=', '!=', '<', '<=', '>', '>='];
}
case 'text':
case 'varchar':
case 'char': {
return ['=', '!=', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE', '~', '~*', '!~', '!~*'];
}
default: {
return ['=', '!=', '<', '<=', '>', '>=', 'IN', 'NOT IN'];
}
}
}
// quote identifier as literal to use in metadata queries
quoteIdentAsLiteral(value) {
return this.queryModel.quoteLiteral(this.queryModel.unquoteIdentifier(value));
}
findMetricTable() {
// query that returns first table found that has a timestamp(tz) column and a float column
const query = `
SELECT
quote_ident(table_name) as table_name,
( SELECT
quote_ident(column_name) as column_name
FROM information_schema.columns c
WHERE
c.table_schema = t.table_schema AND
c.table_name = t.table_name AND
udt_name IN ('timestamptz','timestamp')
ORDER BY ordinal_position LIMIT 1
) AS time_column,
( SELECT
quote_ident(column_name) AS column_name
FROM information_schema.columns c
WHERE
c.table_schema = t.table_schema AND
c.table_name = t.table_name AND
udt_name='float8'
ORDER BY ordinal_position LIMIT 1
) AS value_column
FROM information_schema.tables t
WHERE
table_schema IN (
SELECT CASE WHEN trim(unnest) = '"$user"' THEN user ELSE trim(unnest) END
FROM unnest(string_to_array(current_setting('search_path'),','))
) AND
EXISTS
( SELECT 1
FROM information_schema.columns c
WHERE
c.table_schema = t.table_schema AND
c.table_name = t.table_name AND
udt_name IN ('timestamptz','timestamp')
) AND
EXISTS
( SELECT 1
FROM information_schema.columns c
WHERE
c.table_schema = t.table_schema AND
c.table_name = t.table_name AND
udt_name='float8'
)
LIMIT 1
;`;
return query;
}
buildSchemaConstraint() {
const query = `
table_schema IN (
SELECT CASE WHEN trim(unnest) = \'"$user"\' THEN user ELSE trim(unnest) END
FROM unnest(string_to_array(current_setting(\'search_path\'),\',\'))
)`;
return query;
}
buildTableConstraint(table: string) {
let query = '';
// check for schema qualified table
if (table.includes('.')) {
const parts = table.split('.');
query = 'table_schema = ' + this.quoteIdentAsLiteral(parts[0]);
query += ' AND table_name = ' + this.quoteIdentAsLiteral(parts[1]);
return query;
} else {
query = `
table_schema IN (
SELECT CASE WHEN trim(unnest) = \'"$user"\' THEN user ELSE trim(unnest) END
FROM unnest(string_to_array(current_setting(\'search_path\'),\',\'))
)`;
query += ' AND table_name = ' + this.quoteIdentAsLiteral(table);
return query;
}
}
buildTableQuery() {
let query = 'SELECT quote_ident(table_name) FROM information_schema.tables WHERE ';
query += this.buildSchemaConstraint();
query += ' ORDER BY table_name';
return query;
}
buildColumnQuery(type?: string) {
let query = 'SELECT quote_ident(column_name) FROM information_schema.columns WHERE ';
query += this.buildTableConstraint(this.target.table);
switch (type) {
case 'time': {
query +=
" AND data_type IN ('timestamp without time zone','timestamp with time zone','bigint','integer','double precision','real')";
break;
}
case 'metric': {
query += " AND data_type IN ('text','character','character varying')";
break;
}
case 'value': {
query += " AND data_type IN ('bigint','integer','double precision','real')";
query += ' AND column_name <> ' + this.quoteIdentAsLiteral(this.target.timeColumn);
break;
}
case 'group': {
query += " AND data_type IN ('text','character','character varying')";
break;
}
}
query += ' ORDER BY column_name';
return query;
}
buildValueQuery(column: string) {
let query = 'SELECT DISTINCT quote_literal(' + column + ')';
query += ' FROM ' + this.target.table;
query += ' WHERE $__timeFilter(' + this.target.timeColumn + ')';
query += ' ORDER BY 1 LIMIT 100';
return query;
}
buildDatatypeQuery(column: string) {
let query = `
SELECT udt_name
FROM information_schema.columns
WHERE
table_schema IN (
SELECT schema FROM (
SELECT CASE WHEN trim(unnest) = \'"$user"\' THEN user ELSE trim(unnest) END as schema
FROM unnest(string_to_array(current_setting(\'search_path\'),\',\'))
) s
WHERE EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = s.schema)
)
`;
query += ' AND table_name = ' + this.quoteIdentAsLiteral(this.target.table);
query += ' AND column_name = ' + this.quoteIdentAsLiteral(column);
return query;
}
buildAggregateQuery() {
let query = 'SELECT DISTINCT proname FROM pg_aggregate ';
query += 'INNER JOIN pg_proc ON pg_aggregate.aggfnoid = pg_proc.oid ';
query += 'INNER JOIN pg_type ON pg_type.oid=pg_proc.prorettype ';
query += "WHERE pronargs=1 AND typname IN ('float8') AND aggkind='n' ORDER BY 1";
return query;
}
}

View File

@@ -1,16 +1,6 @@
import { PostgresDatasource } from './datasource';
import { PostgresQueryCtrl } from './query_ctrl';
class PostgresConfigCtrl {
static templateUrl = 'partials/config.html';
current: any;
/** @ngInject */
constructor($scope) {
this.current.jsonData.sslmode = this.current.jsonData.sslmode || 'verify-full';
}
}
import { PostgresConfigCtrl } from './config_ctrl';
const defaultQuery = `SELECT
extract(epoch from time_column) AS time,

View File

@@ -42,10 +42,36 @@
<div class="gf-form-group">
<div class="gf-form">
<gf-form-switch class="gf-form" label="TimescaleDB" tooltip="Use TimescaleDB features (e.g., time_bucket) in Grafana" label-class="width-9" checked="ctrl.current.jsonData.timescaledb" switch-class="max-width-6"></gf-form-switch>
<span class="gf-form-label width-9">
Version
<info-popover mode="right-normal" position="top center">
This option controls what functions are available in the PostgreSQL query builder.
</info-popover>
</span>
<span class="gf-form-select-wrapper">
<select class="gf-form-input gf-size-auto" ng-model="ctrl.current.jsonData.postgresVersion" ng-options="f.value as f.name for f in ctrl.postgresVersions"></select>
</span>
</div>
<div class="gf-form">
<gf-form-switch class="gf-form" label="TimescaleDB" label-class="width-9" checked="ctrl.current.jsonData.timescaledb" switch-class="max-width-6"></gf-form-switch>
<label class="gf-form-label query-keyword pointer" ng-click="ctrl.toggleTimescaleDBHelp()">
Help&nbsp;
<i class="fa fa-caret-down" ng-show="ctrl.showTimescaleDBHelp"></i>
<i class="fa fa-caret-right" ng-hide="ctrl.showTimescaleDBHelp">&nbsp;</i>
</label>
</div>
<div class="grafana-info-box alert alert-info" ng-show="ctrl.showTimescaleDBHelp">
<div class="alert-body">
<p>
<a href="https://github.com/timescale/timescaledb" class="pointer" target="_blank">TimescaleDB</a> is a time-series database built as a PostgreSQL extension. If enabled, Grafana will use <code>time_bucket</code> in the <code>$__timeGroup</code> macro and display TimescaleDB specific aggregate functions in the query builder.
</p>
</div>
</div>
</div>
<div class="gf-form-group">
<div class="grafana-info-box">
<h5>User Permission</h5>

View File

@@ -1,43 +1,141 @@
<query-editor-row query-ctrl="ctrl" can-collapse="false">
<div class="gf-form-inline">
<div class="gf-form gf-form--grow">
<code-editor content="ctrl.target.rawSql" datasource="ctrl.datasource" on-change="ctrl.panelCtrl.refresh()" data-mode="sql">
</code-editor>
</div>
</div>
<query-editor-row query-ctrl="ctrl" has-text-edit-mode="true">
<div ng-if="ctrl.target.rawQuery">
<div class="gf-form-inline">
<div class="gf-form gf-form--grow">
<code-editor content="ctrl.target.rawSql" datasource="ctrl.datasource" on-change="ctrl.panelCtrl.refresh()" data-mode="sql">
</code-editor>
</div>
</div>
</div>
<div ng-if="!ctrl.target.rawQuery">
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label query-keyword width-6">FROM</label>
<metric-segment segment="ctrl.tableSegment" get-options="ctrl.getTableSegments()" on-change="ctrl.tableChanged()"></metric-segment>
<label class="gf-form-label query-keyword width-7">Time column</label>
<metric-segment segment="ctrl.timeColumnSegment" get-options="ctrl.getTimeColumnSegments()" on-change="ctrl.timeColumnChanged()"></metric-segment>
<label class="gf-form-label query-keyword width-9">
Metric column
<info-popover mode="right-normal">Column to be used as metric name for the value column.</info-popover>
</label>
<metric-segment segment="ctrl.metricColumnSegment" get-options="ctrl.getMetricColumnSegments()" on-change="ctrl.metricColumnChanged()"></metric-segment>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form-inline" ng-repeat="selectParts in ctrl.selectParts">
<div class="gf-form">
<label class="gf-form-label query-keyword width-6">
<span ng-show="$index === 0">SELECT</span>&nbsp;
</label>
</div>
<div class="gf-form" ng-repeat="part in selectParts">
<sql-part-editor class="gf-form-label sql-part" part="part" handle-event="ctrl.handleSelectPartEvent(selectParts, part, $event)">
</sql-part-editor>
</div>
<div class="gf-form">
<label class="dropdown"
dropdown-typeahead="ctrl.selectMenu"
dropdown-typeahead-on-select="ctrl.addSelectPart(selectParts, $item, $subItem)">
</label>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label query-keyword width-6">WHERE</label>
</div>
<div class="gf-form" ng-repeat="part in ctrl.whereParts">
<sql-part-editor class="gf-form-label sql-part" part="part" handle-event="ctrl.handleWherePartEvent(ctrl.whereParts, part, $event, $index)">
</sql-part-editor>
</div>
<div class="gf-form">
<metric-segment segment="ctrl.whereAdd" get-options="ctrl.getWhereOptions()" on-change="ctrl.addWhereAction(part, $index)"></metric-segment>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label query-keyword width-6">
<span>GROUP BY</span>
</label>
<sql-part-editor ng-repeat="part in ctrl.groupParts"
part="part" class="gf-form-label sql-part"
handle-event="ctrl.handleGroupPartEvent(part, $index, $event)">
</sql-part-editor>
</div>
<div class="gf-form">
<metric-segment segment="ctrl.groupAdd" get-options="ctrl.getGroupOptions()" on-change="ctrl.addGroupAction(part, $index)"></metric-segment>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
</div>
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label query-keyword">Format as</label>
<div class="gf-form-select-wrapper">
<select class="gf-form-input gf-size-auto" ng-model="ctrl.target.format" ng-options="f.value as f.text for f in ctrl.formats" ng-change="ctrl.refresh()"></select>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label query-keyword" ng-click="ctrl.showHelp = !ctrl.showHelp">
<label class="gf-form-label query-keyword">Format as</label>
<div class="gf-form-select-wrapper">
<select class="gf-form-input gf-size-auto" ng-model="ctrl.target.format" ng-options="f.value as f.text for f in ctrl.formats" ng-change="ctrl.refresh()"></select>
</div>
</div>
<div class="gf-form">
<label class="gf-form-label query-keyword pointer" ng-click="ctrl.toggleEditorMode()" ng-show="ctrl.panelCtrl.panel.type !== 'table'">
<span ng-show="ctrl.target.rawQuery">Query Builder</span>
<span ng-hide="ctrl.target.rawQuery">Edit SQL</span>
</label>
</div>
<div class="gf-form">
<label class="gf-form-label query-keyword pointer" ng-click="ctrl.showHelp = !ctrl.showHelp">
Show Help
<i class="fa fa-caret-down" ng-show="ctrl.showHelp"></i>
<i class="fa fa-caret-right" ng-hide="ctrl.showHelp"></i>
</label>
</div>
<div class="gf-form" ng-show="ctrl.lastQueryMeta">
<label class="gf-form-label query-keyword" ng-click="ctrl.showLastQuerySQL = !ctrl.showLastQuerySQL">
</div>
<div class="gf-form" ng-show="ctrl.lastQueryMeta">
<label class="gf-form-label query-keyword pointer" ng-click="ctrl.showLastQuerySQL = !ctrl.showLastQuerySQL">
Generated SQL
<i class="fa fa-caret-down" ng-show="ctrl.showLastQuerySQL"></i>
<i class="fa fa-caret-right" ng-hide="ctrl.showLastQuerySQL"></i>
</label>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form" ng-show="ctrl.showLastQuerySQL">
<pre class="gf-form-pre">{{ctrl.lastQueryMeta.sql}}</pre>
</div>
<div class="gf-form" ng-show="ctrl.showLastQuerySQL">
<pre class="gf-form-pre">{{ctrl.lastQueryMeta.sql}}</pre>
</div>
<div class="gf-form" ng-show="ctrl.showHelp">
<pre class="gf-form-pre alert alert-info">Time series:
<div class="gf-form" ng-show="ctrl.showHelp">
<pre class="gf-form-pre alert alert-info">Time series:
- return column named <i>time</i> (UTC in seconds or timestamp)
- return column(s) with numeric datatype as values
Optional:
@@ -73,13 +171,13 @@ Or build your own conditionals using these macros which just return the values:
- $__timeTo() -&gt; '2017-04-21T05:01:17Z'
- $__unixEpochFrom() -&gt; 1492750877
- $__unixEpochTo() -&gt; 1492750877
</pre>
</div>
</pre>
</div>
</div>
</div>
<div class="gf-form" ng-show="ctrl.lastQueryError">
<pre class="gf-form-pre alert alert-error">{{ctrl.lastQueryError}}</pre>
</div>
<div class="gf-form" ng-show="ctrl.lastQueryError">
<pre class="gf-form-pre alert alert-error">{{ctrl.lastQueryError}}</pre>
</div>
</query-editor-row>

View File

@@ -19,4 +19,5 @@
"alerting": true,
"annotations": true,
"metrics": true
}

View File

@@ -0,0 +1,285 @@
import _ from 'lodash';
export default class PostgresQuery {
target: any;
templateSrv: any;
scopedVars: any;
/** @ngInject */
constructor(target, templateSrv?, scopedVars?) {
this.target = target;
this.templateSrv = templateSrv;
this.scopedVars = scopedVars;
target.format = target.format || 'time_series';
target.timeColumn = target.timeColumn || 'time';
target.metricColumn = target.metricColumn || 'none';
target.group = target.group || [];
target.where = target.where || [{ type: 'macro', name: '$__timeFilter', params: [] }];
target.select = target.select || [[{ type: 'column', params: ['value'] }]];
// handle pre query gui panels gracefully
if (!('rawQuery' in this.target)) {
if ('rawSql' in target) {
// pre query gui panel
target.rawQuery = true;
} else {
// new panel
target.rawQuery = false;
}
}
// give interpolateQueryStr access to this
this.interpolateQueryStr = this.interpolateQueryStr.bind(this);
}
// remove identifier quoting from identifier to use in metadata queries
unquoteIdentifier(value) {
if (value[0] === '"' && value[value.length - 1] === '"') {
return value.substring(1, value.length - 1).replace(/""/g, '"');
} else {
return value;
}
}
quoteIdentifier(value) {
return '"' + value.replace(/"/g, '""') + '"';
}
quoteLiteral(value) {
return "'" + value.replace(/'/g, "''") + "'";
}
escapeLiteral(value) {
return value.replace(/'/g, "''");
}
hasTimeGroup() {
return _.find(this.target.group, (g: any) => g.type === 'time');
}
hasMetricColumn() {
return this.target.metricColumn !== 'none';
}
interpolateQueryStr(value, variable, defaultFormatFn) {
// if no multi or include all do not regexEscape
if (!variable.multi && !variable.includeAll) {
return this.escapeLiteral(value);
}
if (typeof value === 'string') {
return this.quoteLiteral(value);
}
const escapedValues = _.map(value, this.quoteLiteral);
return escapedValues.join(',');
}
render(interpolate?) {
const target = this.target;
// new query with no table set yet
if (!this.target.rawQuery && !('table' in this.target)) {
return '';
}
if (!target.rawQuery) {
target.rawSql = this.buildQuery();
}
if (interpolate) {
return this.templateSrv.replace(target.rawSql, this.scopedVars, this.interpolateQueryStr);
} else {
return target.rawSql;
}
}
hasUnixEpochTimecolumn() {
return ['int4', 'int8', 'float4', 'float8', 'numeric'].indexOf(this.target.timeColumnType) > -1;
}
buildTimeColumn(alias = true) {
const timeGroup = this.hasTimeGroup();
let query;
let macro = '$__timeGroup';
if (timeGroup) {
let args;
if (timeGroup.params.length > 1 && timeGroup.params[1] !== 'none') {
args = timeGroup.params.join(',');
} else {
args = timeGroup.params[0];
}
if (this.hasUnixEpochTimecolumn()) {
macro = '$__unixEpochGroup';
}
if (alias) {
macro += 'Alias';
}
query = macro + '(' + this.target.timeColumn + ',' + args + ')';
} else {
query = this.target.timeColumn;
if (alias) {
query += ' AS "time"';
}
}
return query;
}
buildMetricColumn() {
if (this.hasMetricColumn()) {
return this.target.metricColumn + ' AS metric';
}
return '';
}
buildValueColumns() {
let query = '';
for (const column of this.target.select) {
query += ',\n ' + this.buildValueColumn(column);
}
return query;
}
buildValueColumn(column) {
let query = '';
const columnName = _.find(column, (g: any) => g.type === 'column');
query = columnName.params[0];
const aggregate = _.find(column, (g: any) => g.type === 'aggregate' || g.type === 'percentile');
const windows = _.find(column, (g: any) => g.type === 'window' || g.type === 'moving_window');
if (aggregate) {
const func = aggregate.params[0];
switch (aggregate.type) {
case 'aggregate':
if (func === 'first' || func === 'last') {
query = func + '(' + query + ',' + this.target.timeColumn + ')';
} else {
query = func + '(' + query + ')';
}
break;
case 'percentile':
query = func + '(' + aggregate.params[1] + ') WITHIN GROUP (ORDER BY ' + query + ')';
break;
}
}
if (windows) {
const overParts = [];
if (this.hasMetricColumn()) {
overParts.push('PARTITION BY ' + this.target.metricColumn);
}
overParts.push('ORDER BY ' + this.buildTimeColumn(false));
const over = overParts.join(' ');
let curr: string;
let prev: string;
switch (windows.type) {
case 'window':
switch (windows.params[0]) {
case 'increase':
curr = query;
prev = 'lag(' + curr + ') OVER (' + over + ')';
query = '(CASE WHEN ' + curr + ' >= ' + prev + ' THEN ' + curr + ' - ' + prev + ' ELSE ' + curr + ' END)';
break;
case 'rate':
let timeColumn = this.target.timeColumn;
if (aggregate) {
timeColumn = 'min(' + timeColumn + ')';
}
curr = query;
prev = 'lag(' + curr + ') OVER (' + over + ')';
query = '(CASE WHEN ' + curr + ' >= ' + prev + ' THEN ' + curr + ' - ' + prev + ' ELSE ' + curr + ' END)';
query += '/extract(epoch from ' + timeColumn + ' - lag(' + timeColumn + ') OVER (' + over + '))';
break;
default:
query = windows.params[0] + '(' + query + ') OVER (' + over + ')';
break;
}
break;
case 'moving_window':
query = windows.params[0] + '(' + query + ') OVER (' + over + ' ROWS ' + windows.params[1] + ' PRECEDING)';
break;
}
}
const alias = _.find(column, (g: any) => g.type === 'alias');
if (alias) {
query += ' AS ' + this.quoteIdentifier(alias.params[0]);
}
return query;
}
buildWhereClause() {
let query = '';
const conditions = _.map(this.target.where, (tag, index) => {
switch (tag.type) {
case 'macro':
return tag.name + '(' + this.target.timeColumn + ')';
break;
case 'expression':
return tag.params.join(' ');
break;
}
});
if (conditions.length > 0) {
query = '\nWHERE\n ' + conditions.join(' AND\n ');
}
return query;
}
buildGroupClause() {
let query = '';
let groupSection = '';
for (let i = 0; i < this.target.group.length; i++) {
const part = this.target.group[i];
if (i > 0) {
groupSection += ', ';
}
if (part.type === 'time') {
groupSection += '1';
} else {
groupSection += part.params[0];
}
}
if (groupSection.length) {
query = '\nGROUP BY ' + groupSection;
if (this.hasMetricColumn()) {
query += ',2';
}
}
return query;
}
buildQuery() {
let query = 'SELECT';
query += '\n ' + this.buildTimeColumn();
if (this.hasMetricColumn()) {
query += ',\n ' + this.buildMetricColumn();
}
query += this.buildValueColumns();
query += '\nFROM ' + this.target.table;
query += this.buildWhereClause();
query += this.buildGroupClause();
query += '\nORDER BY 1';
return query;
}
}

View File

@@ -1,12 +1,10 @@
import _ from 'lodash';
import appEvents from 'app/core/app_events';
import { PostgresMetaQuery } from './meta_query';
import { QueryCtrl } from 'app/plugins/sdk';
export interface PostgresQuery {
refId: string;
format: string;
alias: string;
rawSql: string;
}
import { SqlPart } from 'app/core/components/sql_part/sql_part';
import PostgresQuery from './postgres_query';
import sqlPart from './sql_part';
export interface QueryMeta {
sql: string;
@@ -26,17 +24,29 @@ export class PostgresQueryCtrl extends QueryCtrl {
showLastQuerySQL: boolean;
formats: any[];
target: PostgresQuery;
queryModel: PostgresQuery;
metaBuilder: PostgresMetaQuery;
lastQueryMeta: QueryMeta;
lastQueryError: string;
showHelp: boolean;
tableSegment: any;
whereAdd: any;
timeColumnSegment: any;
metricColumnSegment: any;
selectMenu: any[];
selectParts: SqlPart[][];
groupParts: SqlPart[];
whereParts: SqlPart[];
groupAdd: any;
/** @ngInject */
constructor($scope, $injector) {
constructor($scope, $injector, private templateSrv, private $q, private uiSegmentSrv) {
super($scope, $injector);
this.target = this.target;
this.queryModel = new PostgresQuery(this.target, templateSrv, this.panel.scopedVars);
this.metaBuilder = new PostgresMetaQuery(this.target, this.queryModel);
this.updateProjection();
this.target.format = this.target.format || 'time_series';
this.target.alias = '';
this.formats = [{ text: 'Time series', value: 'time_series' }, { text: 'Table', value: 'table' }];
if (!this.target.rawSql) {
@@ -44,15 +54,231 @@ export class PostgresQueryCtrl extends QueryCtrl {
if (this.panelCtrl.panel.type === 'table') {
this.target.format = 'table';
this.target.rawSql = 'SELECT 1';
this.target.rawQuery = true;
} else {
this.target.rawSql = defaultQuery;
this.datasource.metricFindQuery(this.metaBuilder.findMetricTable()).then(result => {
if (result.length > 0) {
this.target.table = result[0].text;
let segment = this.uiSegmentSrv.newSegment(this.target.table);
this.tableSegment.html = segment.html;
this.tableSegment.value = segment.value;
this.target.timeColumn = result[1].text;
segment = this.uiSegmentSrv.newSegment(this.target.timeColumn);
this.timeColumnSegment.html = segment.html;
this.timeColumnSegment.value = segment.value;
this.target.timeColumnType = 'timestamp';
this.target.select = [[{ type: 'column', params: [result[2].text] }]];
this.updateProjection();
this.panelCtrl.refresh();
}
});
}
}
if (!this.target.table) {
this.tableSegment = uiSegmentSrv.newSegment({ value: 'select table', fake: true });
} else {
this.tableSegment = uiSegmentSrv.newSegment(this.target.table);
}
this.timeColumnSegment = uiSegmentSrv.newSegment(this.target.timeColumn);
this.metricColumnSegment = uiSegmentSrv.newSegment(this.target.metricColumn);
this.buildSelectMenu();
this.whereAdd = this.uiSegmentSrv.newPlusButton();
this.groupAdd = this.uiSegmentSrv.newPlusButton();
this.panelCtrl.events.on('data-received', this.onDataReceived.bind(this), $scope);
this.panelCtrl.events.on('data-error', this.onDataError.bind(this), $scope);
}
updateProjection() {
this.selectParts = _.map(this.target.select, function(parts: any) {
return _.map(parts, sqlPart.create).filter(n => n);
});
this.whereParts = _.map(this.target.where, sqlPart.create).filter(n => n);
this.groupParts = _.map(this.target.group, sqlPart.create).filter(n => n);
}
updatePersistedParts() {
this.target.select = _.map(this.selectParts, function(selectParts) {
return _.map(selectParts, function(part: any) {
return { type: part.def.type, datatype: part.datatype, params: part.params };
});
});
this.target.where = _.map(this.whereParts, function(part: any) {
return { type: part.def.type, datatype: part.datatype, name: part.name, params: part.params };
});
this.target.group = _.map(this.groupParts, function(part: any) {
return { type: part.def.type, datatype: part.datatype, params: part.params };
});
}
buildSelectMenu() {
this.selectMenu = [];
const aggregates = {
text: 'Aggregate Functions',
value: 'aggregate',
submenu: [
{ text: 'Average', value: 'avg' },
{ text: 'Count', value: 'count' },
{ text: 'Maximum', value: 'max' },
{ text: 'Minimum', value: 'min' },
{ text: 'Sum', value: 'sum' },
{ text: 'Standard deviation', value: 'stddev' },
{ text: 'Variance', value: 'variance' },
],
};
// first and last aggregate are timescaledb specific
if (this.datasource.jsonData.timescaledb === true) {
aggregates.submenu.push({ text: 'First', value: 'first' });
aggregates.submenu.push({ text: 'Last', value: 'last' });
}
this.selectMenu.push(aggregates);
// ordered set aggregates require postgres 9.4+
if (this.datasource.jsonData.postgresVersion >= 904) {
const aggregates2 = {
text: 'Ordered-Set Aggregate Functions',
value: 'percentile',
submenu: [
{ text: 'Percentile (continuous)', value: 'percentile_cont' },
{ text: 'Percentile (discrete)', value: 'percentile_disc' },
],
};
this.selectMenu.push(aggregates2);
}
const windows = {
text: 'Window Functions',
value: 'window',
submenu: [
{ text: 'Increase', value: 'increase' },
{ text: 'Rate', value: 'rate' },
{ text: 'Sum', value: 'sum' },
{ text: 'Moving Average', value: 'avg', type: 'moving_window' },
],
};
this.selectMenu.push(windows);
this.selectMenu.push({ text: 'Alias', value: 'alias' });
this.selectMenu.push({ text: 'Column', value: 'column' });
}
toggleEditorMode() {
if (this.target.rawQuery) {
appEvents.emit('confirm-modal', {
title: 'Warning',
text2: 'Switching to query builder may overwrite your raw SQL.',
icon: 'fa-exclamation',
yesText: 'Switch',
onConfirm: () => {
this.target.rawQuery = !this.target.rawQuery;
},
});
} else {
this.target.rawQuery = !this.target.rawQuery;
}
}
resetPlusButton(button) {
const plusButton = this.uiSegmentSrv.newPlusButton();
button.html = plusButton.html;
button.value = plusButton.value;
}
getTableSegments() {
return this.datasource
.metricFindQuery(this.metaBuilder.buildTableQuery())
.then(this.transformToSegments({}))
.catch(this.handleQueryError.bind(this));
}
tableChanged() {
this.target.table = this.tableSegment.value;
this.target.where = [];
this.target.group = [];
this.updateProjection();
const segment = this.uiSegmentSrv.newSegment('none');
this.metricColumnSegment.html = segment.html;
this.metricColumnSegment.value = segment.value;
this.target.metricColumn = 'none';
const task1 = this.datasource.metricFindQuery(this.metaBuilder.buildColumnQuery('time')).then(result => {
// check if time column is still valid
if (result.length > 0 && !_.find(result, (r: any) => r.text === this.target.timeColumn)) {
const segment = this.uiSegmentSrv.newSegment(result[0].text);
this.timeColumnSegment.html = segment.html;
this.timeColumnSegment.value = segment.value;
}
return this.timeColumnChanged(false);
});
const task2 = this.datasource.metricFindQuery(this.metaBuilder.buildColumnQuery('value')).then(result => {
if (result.length > 0) {
this.target.select = [[{ type: 'column', params: [result[0].text] }]];
this.updateProjection();
}
});
this.$q.all([task1, task2]).then(() => {
this.panelCtrl.refresh();
});
}
getTimeColumnSegments() {
return this.datasource
.metricFindQuery(this.metaBuilder.buildColumnQuery('time'))
.then(this.transformToSegments({}))
.catch(this.handleQueryError.bind(this));
}
timeColumnChanged(refresh?: boolean) {
this.target.timeColumn = this.timeColumnSegment.value;
return this.datasource.metricFindQuery(this.metaBuilder.buildDatatypeQuery(this.target.timeColumn)).then(result => {
if (result.length === 1) {
if (this.target.timeColumnType !== result[0].text) {
this.target.timeColumnType = result[0].text;
}
let partModel;
if (this.queryModel.hasUnixEpochTimecolumn()) {
partModel = sqlPart.create({ type: 'macro', name: '$__unixEpochFilter', params: [] });
} else {
partModel = sqlPart.create({ type: 'macro', name: '$__timeFilter', params: [] });
}
if (this.whereParts.length >= 1 && this.whereParts[0].def.type === 'macro') {
// replace current macro
this.whereParts[0] = partModel;
} else {
this.whereParts.splice(0, 0, partModel);
}
}
this.updatePersistedParts();
if (refresh !== false) {
this.panelCtrl.refresh();
}
});
}
getMetricColumnSegments() {
return this.datasource
.metricFindQuery(this.metaBuilder.buildColumnQuery('metric'))
.then(this.transformToSegments({ addNone: true }))
.catch(this.handleQueryError.bind(this));
}
metricColumnChanged() {
this.target.metricColumn = this.metricColumnSegment.value;
this.panelCtrl.refresh();
}
onDataReceived(dataList) {
this.lastQueryMeta = null;
this.lastQueryError = null;
@@ -72,4 +298,356 @@ export class PostgresQueryCtrl extends QueryCtrl {
}
}
}
transformToSegments(config) {
return results => {
const segments = _.map(results, segment => {
return this.uiSegmentSrv.newSegment({
value: segment.text,
expandable: segment.expandable,
});
});
if (config.addTemplateVars) {
for (const variable of this.templateSrv.variables) {
let value;
value = '$' + variable.name;
if (config.templateQuoter && variable.multi === false) {
value = config.templateQuoter(value);
}
segments.unshift(
this.uiSegmentSrv.newSegment({
type: 'template',
value: value,
expandable: true,
})
);
}
}
if (config.addNone) {
segments.unshift(this.uiSegmentSrv.newSegment({ type: 'template', value: 'none', expandable: true }));
}
return segments;
};
}
findAggregateIndex(selectParts) {
return _.findIndex(selectParts, (p: any) => p.def.type === 'aggregate' || p.def.type === 'percentile');
}
findWindowIndex(selectParts) {
return _.findIndex(selectParts, (p: any) => p.def.type === 'window' || p.def.type === 'moving_window');
}
addSelectPart(selectParts, item, subItem) {
let partType = item.value;
if (subItem && subItem.type) {
partType = subItem.type;
}
let partModel = sqlPart.create({ type: partType });
if (subItem) {
partModel.params[0] = subItem.value;
}
let addAlias = false;
switch (partType) {
case 'column':
const parts = _.map(selectParts, function(part: any) {
return sqlPart.create({ type: part.def.type, params: _.clone(part.params) });
});
this.selectParts.push(parts);
break;
case 'percentile':
case 'aggregate':
// add group by if no group by yet
if (this.target.group.length === 0) {
this.addGroup('time', '$__interval');
}
const aggIndex = this.findAggregateIndex(selectParts);
if (aggIndex !== -1) {
// replace current aggregation
selectParts[aggIndex] = partModel;
} else {
selectParts.splice(1, 0, partModel);
}
if (!_.find(selectParts, (p: any) => p.def.type === 'alias')) {
addAlias = true;
}
break;
case 'moving_window':
case 'window':
const windowIndex = this.findWindowIndex(selectParts);
if (windowIndex !== -1) {
// replace current window function
selectParts[windowIndex] = partModel;
} else {
const aggIndex = this.findAggregateIndex(selectParts);
if (aggIndex !== -1) {
selectParts.splice(aggIndex + 1, 0, partModel);
} else {
selectParts.splice(1, 0, partModel);
}
}
if (!_.find(selectParts, (p: any) => p.def.type === 'alias')) {
addAlias = true;
}
break;
case 'alias':
addAlias = true;
break;
}
if (addAlias) {
// set initial alias name to column name
partModel = sqlPart.create({ type: 'alias', params: [selectParts[0].params[0].replace(/"/g, '')] });
if (selectParts[selectParts.length - 1].def.type === 'alias') {
selectParts[selectParts.length - 1] = partModel;
} else {
selectParts.push(partModel);
}
}
this.updatePersistedParts();
this.panelCtrl.refresh();
}
removeSelectPart(selectParts, part) {
if (part.def.type === 'column') {
// remove all parts of column unless its last column
if (this.selectParts.length > 1) {
const modelsIndex = _.indexOf(this.selectParts, selectParts);
this.selectParts.splice(modelsIndex, 1);
}
} else {
const partIndex = _.indexOf(selectParts, part);
selectParts.splice(partIndex, 1);
}
this.updatePersistedParts();
}
handleSelectPartEvent(selectParts, part, evt) {
switch (evt.name) {
case 'get-param-options': {
switch (part.def.type) {
case 'aggregate':
return this.datasource
.metricFindQuery(this.metaBuilder.buildAggregateQuery())
.then(this.transformToSegments({}))
.catch(this.handleQueryError.bind(this));
case 'column':
return this.datasource
.metricFindQuery(this.metaBuilder.buildColumnQuery('value'))
.then(this.transformToSegments({}))
.catch(this.handleQueryError.bind(this));
}
}
case 'part-param-changed': {
this.updatePersistedParts();
this.panelCtrl.refresh();
break;
}
case 'action': {
this.removeSelectPart(selectParts, part);
this.panelCtrl.refresh();
break;
}
case 'get-part-actions': {
return this.$q.when([{ text: 'Remove', value: 'remove-part' }]);
}
}
}
handleGroupPartEvent(part, index, evt) {
switch (evt.name) {
case 'get-param-options': {
return this.datasource
.metricFindQuery(this.metaBuilder.buildColumnQuery())
.then(this.transformToSegments({}))
.catch(this.handleQueryError.bind(this));
}
case 'part-param-changed': {
this.updatePersistedParts();
this.panelCtrl.refresh();
break;
}
case 'action': {
this.removeGroup(part, index);
this.panelCtrl.refresh();
break;
}
case 'get-part-actions': {
return this.$q.when([{ text: 'Remove', value: 'remove-part' }]);
}
}
}
addGroup(partType, value) {
let params = [value];
if (partType === 'time') {
params = ['$__interval', 'none'];
}
const partModel = sqlPart.create({ type: partType, params: params });
if (partType === 'time') {
// put timeGroup at start
this.groupParts.splice(0, 0, partModel);
} else {
this.groupParts.push(partModel);
}
// add aggregates when adding group by
for (const selectParts of this.selectParts) {
if (!selectParts.some(part => part.def.type === 'aggregate')) {
const aggregate = sqlPart.create({ type: 'aggregate', params: ['avg'] });
selectParts.splice(1, 0, aggregate);
if (!selectParts.some(part => part.def.type === 'alias')) {
const alias = sqlPart.create({ type: 'alias', params: [selectParts[0].part.params[0]] });
selectParts.push(alias);
}
}
}
this.updatePersistedParts();
}
removeGroup(part, index) {
if (part.def.type === 'time') {
// remove aggregations
this.selectParts = _.map(this.selectParts, (s: any) => {
return _.filter(s, (part: any) => {
if (part.def.type === 'aggregate' || part.def.type === 'percentile') {
return false;
}
return true;
});
});
}
this.groupParts.splice(index, 1);
this.updatePersistedParts();
}
handleWherePartEvent(whereParts, part, evt, index) {
switch (evt.name) {
case 'get-param-options': {
switch (evt.param.name) {
case 'left':
return this.datasource
.metricFindQuery(this.metaBuilder.buildColumnQuery())
.then(this.transformToSegments({}))
.catch(this.handleQueryError.bind(this));
case 'right':
if (['int4', 'int8', 'float4', 'float8', 'timestamp', 'timestamptz'].indexOf(part.datatype) > -1) {
// don't do value lookups for numerical fields
return this.$q.when([]);
} else {
return this.datasource
.metricFindQuery(this.metaBuilder.buildValueQuery(part.params[0]))
.then(
this.transformToSegments({
addTemplateVars: true,
templateQuoter: (v: string) => {
return this.queryModel.quoteLiteral(v);
},
})
)
.catch(this.handleQueryError.bind(this));
}
case 'op':
return this.$q.when(this.uiSegmentSrv.newOperators(this.metaBuilder.getOperators(part.datatype)));
default:
return this.$q.when([]);
}
}
case 'part-param-changed': {
this.updatePersistedParts();
this.datasource.metricFindQuery(this.metaBuilder.buildDatatypeQuery(part.params[0])).then((d: any) => {
if (d.length === 1) {
part.datatype = d[0].text;
}
});
this.panelCtrl.refresh();
break;
}
case 'action': {
// remove element
whereParts.splice(index, 1);
this.updatePersistedParts();
this.panelCtrl.refresh();
break;
}
case 'get-part-actions': {
return this.$q.when([{ text: 'Remove', value: 'remove-part' }]);
}
}
}
getWhereOptions() {
const options = [];
if (this.queryModel.hasUnixEpochTimecolumn()) {
options.push(this.uiSegmentSrv.newSegment({ type: 'macro', value: '$__unixEpochFilter' }));
} else {
options.push(this.uiSegmentSrv.newSegment({ type: 'macro', value: '$__timeFilter' }));
}
options.push(this.uiSegmentSrv.newSegment({ type: 'expression', value: 'Expression' }));
return this.$q.when(options);
}
addWhereAction(part, index) {
switch (this.whereAdd.type) {
case 'macro': {
const partModel = sqlPart.create({ type: 'macro', name: this.whereAdd.value, params: [] });
if (this.whereParts.length >= 1 && this.whereParts[0].def.type === 'macro') {
// replace current macro
this.whereParts[0] = partModel;
} else {
this.whereParts.splice(0, 0, partModel);
}
break;
}
default: {
this.whereParts.push(sqlPart.create({ type: 'expression', params: ['value', '=', 'value'] }));
}
}
this.updatePersistedParts();
this.resetPlusButton(this.whereAdd);
this.panelCtrl.refresh();
}
getGroupOptions() {
return this.datasource
.metricFindQuery(this.metaBuilder.buildColumnQuery('group'))
.then(tags => {
const options = [];
if (!this.queryModel.hasTimeGroup()) {
options.push(this.uiSegmentSrv.newSegment({ type: 'time', value: 'time($__interval,none)' }));
}
for (const tag of tags) {
options.push(this.uiSegmentSrv.newSegment({ type: 'column', value: tag.text }));
}
return options;
})
.catch(this.handleQueryError.bind(this));
}
addGroupAction() {
switch (this.groupAdd.value) {
default: {
this.addGroup(this.groupAdd.type, this.groupAdd.value);
}
}
this.resetPlusButton(this.groupAdd);
this.panelCtrl.refresh();
}
handleQueryError(err) {
this.error = err.message || 'Failed to issue metric query';
return [];
}
}

View File

@@ -9,12 +9,23 @@ describe('PostgreSQLDatasource', function() {
const templateSrv = {
replace: jest.fn(text => text),
};
const raw = {
from: moment.utc('2018-04-25 10:00'),
to: moment.utc('2018-04-25 11:00'),
};
const ctx = {
backendSrv,
timeSrvMock: {
timeRange: () => ({
from: raw.from,
to: raw.to,
raw: raw,
}),
},
} as any;
beforeEach(() => {
ctx.ds = new PostgresDatasource(instanceSettings, backendSrv, {}, templateSrv);
ctx.ds = new PostgresDatasource(instanceSettings, backendSrv, {}, templateSrv, ctx.timeSrvMock);
});
describe('When performing annotationQuery', function() {
@@ -219,6 +230,7 @@ describe('PostgreSQLDatasource', function() {
it('should return a quoted value', () => {
ctx.variable.multi = true;
expect(ctx.ds.interpolateVariable("a'bc", ctx.variable)).toEqual("'a''bc'");
expect(ctx.ds.interpolateVariable("a'b'c", ctx.variable)).toEqual("'a''b''c'");
});
});

View File

@@ -0,0 +1,155 @@
import PostgresQuery from '../postgres_query';
describe('PostgresQuery', function() {
const templateSrv = {
replace: jest.fn(text => text),
};
describe('When initializing', function() {
it('should not be in SQL mode', function() {
const query = new PostgresQuery({}, templateSrv);
expect(query.target.rawQuery).toBe(false);
});
it('should be in SQL mode for pre query builder queries', function() {
const query = new PostgresQuery({ rawSql: 'SELECT 1' }, templateSrv);
expect(query.target.rawQuery).toBe(true);
});
});
describe('When generating time column SQL', function() {
const query = new PostgresQuery({}, templateSrv);
query.target.timeColumn = 'time';
expect(query.buildTimeColumn()).toBe('time AS "time"');
query.target.timeColumn = '"time"';
expect(query.buildTimeColumn()).toBe('"time" AS "time"');
});
describe('When generating time column SQL with group by time', function() {
let query = new PostgresQuery(
{ timeColumn: 'time', group: [{ type: 'time', params: ['5m', 'none'] }] },
templateSrv
);
expect(query.buildTimeColumn()).toBe('$__timeGroupAlias(time,5m)');
expect(query.buildTimeColumn(false)).toBe('$__timeGroup(time,5m)');
query = new PostgresQuery({ timeColumn: 'time', group: [{ type: 'time', params: ['5m', 'NULL'] }] }, templateSrv);
expect(query.buildTimeColumn()).toBe('$__timeGroupAlias(time,5m,NULL)');
query = new PostgresQuery(
{ timeColumn: 'time', timeColumnType: 'int4', group: [{ type: 'time', params: ['5m', 'none'] }] },
templateSrv
);
expect(query.buildTimeColumn()).toBe('$__unixEpochGroupAlias(time,5m)');
expect(query.buildTimeColumn(false)).toBe('$__unixEpochGroup(time,5m)');
});
describe('When generating metric column SQL', function() {
const query = new PostgresQuery({}, templateSrv);
query.target.metricColumn = 'host';
expect(query.buildMetricColumn()).toBe('host AS metric');
query.target.metricColumn = '"host"';
expect(query.buildMetricColumn()).toBe('"host" AS metric');
});
describe('When generating value column SQL', function() {
const query = new PostgresQuery({}, templateSrv);
let column = [{ type: 'column', params: ['value'] }];
expect(query.buildValueColumn(column)).toBe('value');
column = [{ type: 'column', params: ['value'] }, { type: 'alias', params: ['alias'] }];
expect(query.buildValueColumn(column)).toBe('value AS "alias"');
column = [
{ type: 'column', params: ['v'] },
{ type: 'alias', params: ['a'] },
{ type: 'aggregate', params: ['max'] },
];
expect(query.buildValueColumn(column)).toBe('max(v) AS "a"');
column = [
{ type: 'column', params: ['v'] },
{ type: 'alias', params: ['a'] },
{ type: 'window', params: ['increase'] },
];
expect(query.buildValueColumn(column)).toBe(
'(CASE WHEN v >= lag(v) OVER (ORDER BY time) THEN v - lag(v) OVER (ORDER BY time) ELSE v END) AS "a"'
);
});
describe('When generating value column SQL with metric column', function() {
const query = new PostgresQuery({}, templateSrv);
query.target.metricColumn = 'host';
let column = [{ type: 'column', params: ['value'] }];
expect(query.buildValueColumn(column)).toBe('value');
column = [{ type: 'column', params: ['value'] }, { type: 'alias', params: ['alias'] }];
expect(query.buildValueColumn(column)).toBe('value AS "alias"');
column = [
{ type: 'column', params: ['v'] },
{ type: 'alias', params: ['a'] },
{ type: 'aggregate', params: ['max'] },
];
expect(query.buildValueColumn(column)).toBe('max(v) AS "a"');
column = [
{ type: 'column', params: ['v'] },
{ type: 'alias', params: ['a'] },
{ type: 'window', params: ['increase'] },
];
expect(query.buildValueColumn(column)).toBe(
'(CASE WHEN v >= lag(v) OVER (PARTITION BY host ORDER BY time) THEN v - lag(v) OVER (PARTITION BY host ORDER BY time) ELSE v END) AS "a"'
);
column = [
{ type: 'column', params: ['v'] },
{ type: 'alias', params: ['a'] },
{ type: 'aggregate', params: ['max'] },
{ type: 'window', params: ['increase'] },
];
expect(query.buildValueColumn(column)).toBe(
'(CASE WHEN max(v) >= lag(max(v)) OVER (PARTITION BY host ORDER BY time) ' +
'THEN max(v) - lag(max(v)) OVER (PARTITION BY host ORDER BY time) ELSE max(v) END) AS "a"'
);
});
describe('When generating WHERE clause', function() {
const query = new PostgresQuery({ where: [] }, templateSrv);
expect(query.buildWhereClause()).toBe('');
query.target.timeColumn = 't';
query.target.where = [{ type: 'macro', name: '$__timeFilter' }];
expect(query.buildWhereClause()).toBe('\nWHERE\n $__timeFilter(t)');
query.target.where = [{ type: 'expression', params: ['v', '=', '1'] }];
expect(query.buildWhereClause()).toBe('\nWHERE\n v = 1');
query.target.where = [{ type: 'macro', name: '$__timeFilter' }, { type: 'expression', params: ['v', '=', '1'] }];
expect(query.buildWhereClause()).toBe('\nWHERE\n $__timeFilter(t) AND\n v = 1');
});
describe('When generating GROUP BY clause', function() {
const query = new PostgresQuery({ group: [], metricColumn: 'none' }, templateSrv);
expect(query.buildGroupClause()).toBe('');
query.target.group = [{ type: 'time', params: ['5m'] }];
expect(query.buildGroupClause()).toBe('\nGROUP BY 1');
query.target.metricColumn = 'm';
expect(query.buildGroupClause()).toBe('\nGROUP BY 1,2');
});
describe('When generating complete statement', function() {
const target = {
timeColumn: 't',
table: 'table',
select: [[{ type: 'column', params: ['value'] }]],
where: [],
};
let result = 'SELECT\n t AS "time",\n value\nFROM table\nORDER BY 1';
const query = new PostgresQuery(target, templateSrv);
expect(query.buildQuery()).toBe(result);
query.target.metricColumn = 'm';
result = 'SELECT\n t AS "time",\n m AS metric,\n value\nFROM table\nORDER BY 1';
expect(query.buildQuery()).toBe(result);
});
});

View File

@@ -0,0 +1,137 @@
import { SqlPartDef, SqlPart } from 'app/core/components/sql_part/sql_part';
const index = [];
function createPart(part): any {
const def = index[part.type];
if (!def) {
return null;
}
return new SqlPart(part, def);
}
function register(options: any) {
index[options.type] = new SqlPartDef(options);
}
register({
type: 'column',
style: 'label',
params: [{ type: 'column', dynamicLookup: true }],
defaultParams: ['value'],
});
register({
type: 'expression',
style: 'expression',
label: 'Expr:',
params: [
{ name: 'left', type: 'string', dynamicLookup: true },
{ name: 'op', type: 'string', dynamicLookup: true },
{ name: 'right', type: 'string', dynamicLookup: true },
],
defaultParams: ['value', '=', 'value'],
});
register({
type: 'macro',
style: 'label',
label: 'Macro:',
params: [],
defaultParams: [],
});
register({
type: 'aggregate',
style: 'label',
params: [
{
name: 'name',
type: 'string',
options: ['avg', 'count', 'min', 'max', 'sum', 'stddev', 'variance'],
},
],
defaultParams: ['avg'],
});
register({
type: 'percentile',
label: 'Aggregate:',
style: 'label',
params: [
{
name: 'name',
type: 'string',
options: ['percentile_cont', 'percentile_disc'],
},
{
name: 'fraction',
type: 'number',
options: ['0.5', '0.75', '0.9', '0.95', '0.99'],
},
],
defaultParams: ['percentile_cont', '0.95'],
});
register({
type: 'alias',
style: 'label',
params: [{ name: 'name', type: 'string', quote: 'double' }],
defaultParams: ['alias'],
});
register({
type: 'time',
style: 'function',
label: 'time',
params: [
{
name: 'interval',
type: 'interval',
options: ['$__interval', '1s', '10s', '1m', '5m', '10m', '15m', '1h'],
},
{
name: 'fill',
type: 'string',
options: ['none', 'NULL', 'previous', '0'],
},
],
defaultParams: ['$__interval', 'none'],
});
register({
type: 'window',
style: 'label',
params: [
{
name: 'function',
type: 'string',
options: ['increase', 'rate', 'sum'],
},
],
defaultParams: ['increase'],
});
register({
type: 'moving_window',
style: 'label',
label: 'Moving Window:',
params: [
{
name: 'function',
type: 'string',
options: ['avg'],
},
{
name: 'window_size',
type: 'number',
options: ['3', '5', '7', '10', '20'],
},
],
defaultParams: ['avg', '5'],
});
export default {
create: createPart,
};