+`;
+
+/** @ngInject */
+export function sqlPartEditorDirective($compile, templateSrv) {
+ const paramTemplate = '';
+
+ 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) {
+ $('' + partDef.separator + '').appendTo($paramsContainer);
+ }
+
+ const paramValue = templateSrv.highlightVariablesAsHtml(part.params[index]);
+ const $paramLink = $('' + paramValue + '');
+ 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);
diff --git a/public/app/core/core.ts b/public/app/core/core.ts
index d6088283f3b..bff98e1fbb3 100644
--- a/public/app/core/core.ts
+++ b/public/app/core/core.ts
@@ -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,
diff --git a/public/app/plugins/datasource/postgres/config_ctrl.ts b/public/app/plugins/datasource/postgres/config_ctrl.ts
new file mode 100644
index 00000000000..a396b9f9aa4
--- /dev/null
+++ b/public/app/plugins/datasource/postgres/config_ctrl.ts
@@ -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 },
+ ];
+}
diff --git a/public/app/plugins/datasource/postgres/datasource.ts b/public/app/plugins/datasource/postgres/datasource.ts
index 678327a4a86..6522032b39f 100644
--- a/public/app/plugins/datasource/postgres/datasource.ts
+++ b/public/app/plugins/datasource/postgres/datasource.ts
@@ -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 => {
diff --git a/public/app/plugins/datasource/postgres/meta_query.ts b/public/app/plugins/datasource/postgres/meta_query.ts
new file mode 100644
index 00000000000..0c54cc26cad
--- /dev/null
+++ b/public/app/plugins/datasource/postgres/meta_query.ts
@@ -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;
+ }
+}
diff --git a/public/app/plugins/datasource/postgres/module.ts b/public/app/plugins/datasource/postgres/module.ts
index a4266183bcf..f81cdb7c371 100644
--- a/public/app/plugins/datasource/postgres/module.ts
+++ b/public/app/plugins/datasource/postgres/module.ts
@@ -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,
diff --git a/public/app/plugins/datasource/postgres/partials/config.html b/public/app/plugins/datasource/postgres/partials/config.html
index a1783c09dc4..a4df858db7e 100644
--- a/public/app/plugins/datasource/postgres/partials/config.html
+++ b/public/app/plugins/datasource/postgres/partials/config.html
@@ -42,10 +42,36 @@
-
+
+ Version
+
+ This option controls what functions are available in the PostgreSQL query builder.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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.
+
Time series:
- return column named time (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() -> '2017-04-21T05:01:17Z'
- $__unixEpochFrom() -> 1492750877
- $__unixEpochTo() -> 1492750877
-