mirror of https://github.com/grafana/grafana
MSSQL: Migrate to React (#51765)
* Fix: sql plugins feature
* SQLDS: Use builtin annotation editor
Plus strict rule fixes
* MSSQL: Migrate query editor to React
* Make code editor work
* Make SQLOptions and SQLQuery in SQLDatasource and in Editor generic
* MSSQL: Fix ts issues
* Fix SQLDatasource refID
* Remove comment
* Revert "Make SQLOptions and SQLQuery in SQLDatasource and in Editor generic"
This reverts commit 1d15b4061a
.
* Fix ts issues without generic
* TS
pull/50343/head
parent
7b40322bbe
commit
35d98104ad
@ -1,5 +0,0 @@ |
|||||||
export interface SQLConnectionLimits { |
|
||||||
maxOpenConns: number; |
|
||||||
maxIdleConns: number; |
|
||||||
connMaxLifetime: number; |
|
||||||
} |
|
@ -0,0 +1,15 @@ |
|||||||
|
export function showDatabases() { |
||||||
|
// Return only user defined databases
|
||||||
|
return `SELECT name FROM sys.databases WHERE name NOT IN ('master', 'tempdb', 'model', 'msdb');`; |
||||||
|
} |
||||||
|
|
||||||
|
export function showTables(dataset?: string) { |
||||||
|
return `SELECT TABLE_NAME as name
|
||||||
|
FROM [${dataset}].INFORMATION_SCHEMA.TABLES |
||||||
|
WHERE TABLE_TYPE = 'BASE TABLE'`;
|
||||||
|
} |
||||||
|
|
||||||
|
export function getSchema(table?: string) { |
||||||
|
return `SELECT COLUMN_NAME as 'column',DATA_TYPE as 'type'
|
||||||
|
FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='${table}';`;
|
||||||
|
} |
@ -0,0 +1,25 @@ |
|||||||
|
import { ScopedVars } from '@grafana/data'; |
||||||
|
import { TemplateSrv } from '@grafana/runtime'; |
||||||
|
import { applyQueryDefaults } from 'app/features/plugins/sql/defaults'; |
||||||
|
import { SQLQuery, SqlQueryModel } from 'app/features/plugins/sql/types'; |
||||||
|
import { FormatRegistryID } from 'app/features/templating/formatRegistry'; |
||||||
|
|
||||||
|
export class MSSqlQueryModel implements SqlQueryModel { |
||||||
|
target: SQLQuery; |
||||||
|
templateSrv?: TemplateSrv; |
||||||
|
scopedVars?: ScopedVars; |
||||||
|
|
||||||
|
constructor(target?: SQLQuery, templateSrv?: TemplateSrv, scopedVars?: ScopedVars) { |
||||||
|
this.target = applyQueryDefaults(target || { refId: 'A' }); |
||||||
|
this.templateSrv = templateSrv; |
||||||
|
this.scopedVars = scopedVars; |
||||||
|
} |
||||||
|
|
||||||
|
interpolate() { |
||||||
|
return this.templateSrv?.replace(this.target.rawSql, this.scopedVars, FormatRegistryID.sqlString) || ''; |
||||||
|
} |
||||||
|
|
||||||
|
quoteLiteral(value: string) { |
||||||
|
return "'" + value.replace(/'/g, "''") + "'"; |
||||||
|
} |
||||||
|
} |
@ -1,191 +1,97 @@ |
|||||||
import { map as _map } from 'lodash'; |
import { DataSourceInstanceSettings, ScopedVars } from '@grafana/data'; |
||||||
import { lastValueFrom, of } from 'rxjs'; |
import { LanguageCompletionProvider } from '@grafana/experimental'; |
||||||
import { catchError, map, mapTo } from 'rxjs/operators'; |
import { TemplateSrv } from '@grafana/runtime'; |
||||||
|
import { SqlDatasource } from 'app/features/plugins/sql/datasource/SqlDatasource'; |
||||||
import { AnnotationEvent, DataSourceInstanceSettings, MetricFindValue, ScopedVars, TimeRange } from '@grafana/data'; |
import { DB, ResponseParser, SQLQuery, SQLSelectableValue } from 'app/features/plugins/sql/types'; |
||||||
import { BackendDataSourceResponse, DataSourceWithBackend, FetchResponse, getBackendSrv } from '@grafana/runtime'; |
|
||||||
import { toTestingStatus } from '@grafana/runtime/src/utils/queryResponse'; |
import { getSchema, showDatabases, showTables } from './MSSqlMetaQuery'; |
||||||
import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv'; |
import { MSSqlQueryModel } from './MSSqlQueryModel'; |
||||||
|
import { MSSqlResponseParser } from './response_parser'; |
||||||
import ResponseParser from './response_parser'; |
import { fetchColumns, fetchTables, getSqlCompletionProvider } from './sqlCompletionProvider'; |
||||||
import { MssqlOptions, MssqlQuery, MssqlQueryForInterpolation } from './types'; |
import { getIcon, getRAQBType, SCHEMA_NAME, toRawSql } from './sqlUtil'; |
||||||
|
import { MssqlOptions } from './types'; |
||||||
export class MssqlDatasource extends DataSourceWithBackend<MssqlQuery, MssqlOptions> { |
|
||||||
id: any; |
export class MssqlDatasource extends SqlDatasource { |
||||||
name: any; |
completionProvider: LanguageCompletionProvider | undefined = undefined; |
||||||
responseParser: ResponseParser; |
constructor(instanceSettings: DataSourceInstanceSettings<MssqlOptions>, templateSrv?: TemplateSrv) { |
||||||
interval: string; |
super(instanceSettings, templateSrv); |
||||||
|
|
||||||
constructor( |
|
||||||
instanceSettings: DataSourceInstanceSettings<MssqlOptions>, |
|
||||||
private readonly templateSrv: TemplateSrv = getTemplateSrv() |
|
||||||
) { |
|
||||||
super(instanceSettings); |
|
||||||
this.name = instanceSettings.name; |
|
||||||
this.id = instanceSettings.id; |
|
||||||
this.responseParser = new ResponseParser(); |
|
||||||
const settingsData = instanceSettings.jsonData || ({} as MssqlOptions); |
|
||||||
this.interval = settingsData.timeInterval || '1m'; |
|
||||||
} |
} |
||||||
|
|
||||||
interpolateVariable(value: any, variable: any) { |
getQueryModel(target?: SQLQuery, templateSrv?: TemplateSrv, scopedVars?: ScopedVars): MSSqlQueryModel { |
||||||
if (typeof value === 'string') { |
return new MSSqlQueryModel(target, templateSrv, scopedVars); |
||||||
if (variable.multi || variable.includeAll) { |
|
||||||
return "'" + value.replace(/'/g, `''`) + "'"; |
|
||||||
} else { |
|
||||||
return value; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if (typeof value === 'number') { |
|
||||||
return value; |
|
||||||
} |
|
||||||
|
|
||||||
const quotedValues = _map(value, (val) => { |
|
||||||
if (typeof value === 'number') { |
|
||||||
return value; |
|
||||||
} |
|
||||||
|
|
||||||
return "'" + val.replace(/'/g, `''`) + "'"; |
|
||||||
}); |
|
||||||
return quotedValues.join(','); |
|
||||||
} |
} |
||||||
|
|
||||||
interpolateVariablesInQueries( |
getResponseParser(): ResponseParser { |
||||||
queries: MssqlQueryForInterpolation[], |
return new MSSqlResponseParser(); |
||||||
scopedVars: ScopedVars |
|
||||||
): MssqlQueryForInterpolation[] { |
|
||||||
let expandedQueries = queries; |
|
||||||
if (queries && queries.length > 0) { |
|
||||||
expandedQueries = queries.map((query) => { |
|
||||||
const expandedQuery = { |
|
||||||
...query, |
|
||||||
datasource: this.getRef(), |
|
||||||
rawSql: this.templateSrv.replace(query.rawSql, scopedVars, this.interpolateVariable), |
|
||||||
rawQuery: true, |
|
||||||
}; |
|
||||||
return expandedQuery; |
|
||||||
}); |
|
||||||
} |
|
||||||
return expandedQueries; |
|
||||||
} |
} |
||||||
|
|
||||||
applyTemplateVariables(target: MssqlQuery, scopedVars: ScopedVars): Record<string, any> { |
async fetchDatasets(): Promise<string[]> { |
||||||
return { |
const datasets = await this.runSql<{ name: string[] }>(showDatabases(), { refId: 'datasets' }); |
||||||
refId: target.refId, |
return datasets.fields.name.values.toArray().flat(); |
||||||
datasource: this.getRef(), |
|
||||||
rawSql: this.templateSrv.replace(target.rawSql, scopedVars, this.interpolateVariable), |
|
||||||
format: target.format, |
|
||||||
}; |
|
||||||
} |
} |
||||||
|
|
||||||
async annotationQuery(options: any): Promise<AnnotationEvent[]> { |
async fetchTables(dataset?: string): Promise<string[]> { |
||||||
if (!options.annotation.rawQuery) { |
const tables = await this.runSql<{ name: string[] }>(showTables(dataset), { refId: 'tables' }); |
||||||
return Promise.reject({ message: 'Query missing in annotation definition' }); |
return tables.fields.name.values.toArray().flat(); |
||||||
} |
|
||||||
|
|
||||||
const query = { |
|
||||||
refId: options.annotation.name, |
|
||||||
datasource: this.getRef(), |
|
||||||
rawSql: this.templateSrv.replace(options.annotation.rawQuery, options.scopedVars, this.interpolateVariable), |
|
||||||
format: 'table', |
|
||||||
}; |
|
||||||
|
|
||||||
return lastValueFrom( |
|
||||||
getBackendSrv() |
|
||||||
.fetch<BackendDataSourceResponse>({ |
|
||||||
url: '/api/ds/query', |
|
||||||
method: 'POST', |
|
||||||
data: { |
|
||||||
from: options.range.from.valueOf().toString(), |
|
||||||
to: options.range.to.valueOf().toString(), |
|
||||||
queries: [query], |
|
||||||
}, |
|
||||||
requestId: options.annotation.name, |
|
||||||
}) |
|
||||||
.pipe( |
|
||||||
map( |
|
||||||
async (res: FetchResponse<BackendDataSourceResponse>) => |
|
||||||
await this.responseParser.transformAnnotationResponse(options, res.data) |
|
||||||
) |
|
||||||
) |
|
||||||
); |
|
||||||
} |
} |
||||||
|
|
||||||
filterQuery(query: MssqlQuery): boolean { |
async fetchFields(query: SQLQuery): Promise<SQLSelectableValue[]> { |
||||||
return !query.hide; |
const schema = await this.runSql<{ column: string; type: string }>(getSchema(query.table), { refId: 'columns' }); |
||||||
|
const result: SQLSelectableValue[] = []; |
||||||
|
for (let i = 0; i < schema.length; i++) { |
||||||
|
const column = schema.fields.column.values.get(i); |
||||||
|
const type = schema.fields.type.values.get(i); |
||||||
|
result.push({ label: column, value: column, type, icon: getIcon(type), raqbFieldType: getRAQBType(type) }); |
||||||
|
} |
||||||
|
return result; |
||||||
} |
} |
||||||
|
|
||||||
metricFindQuery(query: string, optionalOptions: any): Promise<MetricFindValue[]> { |
getSqlCompletionProvider(db: DB): LanguageCompletionProvider { |
||||||
let refId = 'tempvar'; |
if (this.completionProvider !== undefined) { |
||||||
if (optionalOptions && optionalOptions.variable && optionalOptions.variable.name) { |
return this.completionProvider; |
||||||
refId = optionalOptions.variable.name; |
|
||||||
} |
} |
||||||
|
const args = { |
||||||
const range = optionalOptions?.range as TimeRange; |
getColumns: { current: (query: SQLQuery) => fetchColumns(db, query) }, |
||||||
|
getTables: { current: (dataset?: string) => fetchTables(db, dataset) }, |
||||||
const interpolatedQuery = { |
|
||||||
refId: refId, |
|
||||||
datasource: this.getRef(), |
|
||||||
rawSql: this.templateSrv.replace(query, {}, this.interpolateVariable), |
|
||||||
format: 'table', |
|
||||||
}; |
}; |
||||||
|
this.completionProvider = getSqlCompletionProvider(args); |
||||||
return lastValueFrom( |
return this.completionProvider; |
||||||
getBackendSrv() |
|
||||||
.fetch<BackendDataSourceResponse>({ |
|
||||||
url: '/api/ds/query', |
|
||||||
method: 'POST', |
|
||||||
data: { |
|
||||||
from: range?.from?.valueOf()?.toString(), |
|
||||||
to: range?.to?.valueOf()?.toString(), |
|
||||||
queries: [interpolatedQuery], |
|
||||||
}, |
|
||||||
requestId: refId, |
|
||||||
}) |
|
||||||
.pipe( |
|
||||||
map((rsp) => { |
|
||||||
return this.responseParser.transformMetricFindResponse(rsp); |
|
||||||
}), |
|
||||||
catchError((err) => { |
|
||||||
return of([]); |
|
||||||
}) |
|
||||||
) |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
testDatasource(): Promise<any> { |
|
||||||
return lastValueFrom( |
|
||||||
getBackendSrv() |
|
||||||
.fetch({ |
|
||||||
url: '/api/ds/query', |
|
||||||
method: 'POST', |
|
||||||
data: { |
|
||||||
from: '5m', |
|
||||||
to: 'now', |
|
||||||
queries: [ |
|
||||||
{ |
|
||||||
refId: 'A', |
|
||||||
intervalMs: 1, |
|
||||||
maxDataPoints: 1, |
|
||||||
datasource: this.getRef(), |
|
||||||
rawSql: 'SELECT 1', |
|
||||||
format: 'table', |
|
||||||
}, |
|
||||||
], |
|
||||||
}, |
|
||||||
}) |
|
||||||
.pipe( |
|
||||||
mapTo({ status: 'success', message: 'Database Connection OK' }), |
|
||||||
catchError((err) => { |
|
||||||
return of(toTestingStatus(err)); |
|
||||||
}) |
|
||||||
) |
|
||||||
); |
|
||||||
} |
} |
||||||
|
|
||||||
targetContainsTemplate(query: MssqlQuery): boolean { |
getDB(): DB { |
||||||
const rawSql = query.rawSql.replace('$__', ''); |
return { |
||||||
return this.templateSrv.containsTemplate(rawSql); |
init: () => Promise.resolve(true), |
||||||
|
datasets: () => this.fetchDatasets(), |
||||||
|
tables: (dataset?: string) => this.fetchTables(dataset), |
||||||
|
getSqlCompletionProvider: () => this.getSqlCompletionProvider(this.db), |
||||||
|
fields: async (query: SQLQuery) => { |
||||||
|
if (!query?.dataset && !query?.table) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
return this.fetchFields(query); |
||||||
|
}, |
||||||
|
validateQuery: (query) => |
||||||
|
Promise.resolve({ isError: false, isValid: true, query, error: '', rawSql: query.rawSql }), |
||||||
|
dsID: () => this.id, |
||||||
|
dispose: (dsID?: string) => {}, |
||||||
|
toRawSql, |
||||||
|
lookup: async (path?: string) => { |
||||||
|
if (!path) { |
||||||
|
const datasets = await this.fetchDatasets(); |
||||||
|
return datasets.map((d) => ({ name: d, completion: `${d}.${SCHEMA_NAME}.` })); |
||||||
|
} else { |
||||||
|
const parts = path.split('.').filter((s: string) => s); |
||||||
|
if (parts.length > 2) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
if (parts.length === 1) { |
||||||
|
const tables = await this.fetchTables(parts[0]); |
||||||
|
return tables.map((t) => ({ name: t, completion: `${t}` })); |
||||||
|
} else { |
||||||
|
return []; |
||||||
|
} |
||||||
|
} |
||||||
|
}, |
||||||
|
}; |
||||||
} |
} |
||||||
} |
} |
||||||
|
@ -1,34 +1,11 @@ |
|||||||
import { DataSourcePlugin } from '@grafana/data'; |
import { DataSourcePlugin } from '@grafana/data'; |
||||||
|
import { SqlQueryEditor } from 'app/features/plugins/sql/components/QueryEditor'; |
||||||
|
import { SQLQuery } from 'app/features/plugins/sql/types'; |
||||||
|
|
||||||
import { ConfigurationEditor } from './configuration/ConfigurationEditor'; |
import { ConfigurationEditor } from './configuration/ConfigurationEditor'; |
||||||
import { MssqlDatasource } from './datasource'; |
import { MssqlDatasource } from './datasource'; |
||||||
import { MssqlQueryCtrl } from './query_ctrl'; |
import { MssqlOptions } from './types'; |
||||||
import { MssqlQuery } from './types'; |
|
||||||
|
|
||||||
const defaultQuery = `SELECT
|
export const plugin = new DataSourcePlugin<MssqlDatasource, SQLQuery, MssqlOptions>(MssqlDatasource) |
||||||
<time_column> as time, |
.setQueryEditor(SqlQueryEditor) |
||||||
<text_column> as text, |
.setConfigEditor(ConfigurationEditor); |
||||||
<tags_column> as tags |
|
||||||
FROM |
|
||||||
<table name> |
|
||||||
WHERE |
|
||||||
$__timeFilter(time_column) |
|
||||||
ORDER BY |
|
||||||
<time_column> ASC`;
|
|
||||||
|
|
||||||
class MssqlAnnotationsQueryCtrl { |
|
||||||
static templateUrl = 'partials/annotations.editor.html'; |
|
||||||
|
|
||||||
declare annotation: any; |
|
||||||
|
|
||||||
/** @ngInject */ |
|
||||||
constructor($scope: any) { |
|
||||||
this.annotation = $scope.ctrl.annotation; |
|
||||||
this.annotation.rawQuery = this.annotation.rawQuery || defaultQuery; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
export const plugin = new DataSourcePlugin<MssqlDatasource, MssqlQuery>(MssqlDatasource) |
|
||||||
.setQueryCtrl(MssqlQueryCtrl) |
|
||||||
.setConfigEditor(ConfigurationEditor) |
|
||||||
.setAnnotationQueryCtrl(MssqlAnnotationsQueryCtrl); |
|
||||||
|
@ -1,90 +0,0 @@ |
|||||||
<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="sqlserver" textarea-label="Query Editor"> |
|
||||||
</code-editor> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="gf-form-inline"> |
|
||||||
<div class="gf-form"> |
|
||||||
<label class="gf-form-label query-keyword" for="format-select-{{ ctrl.target.refId }}">Format as</label> |
|
||||||
<div class="gf-form-select-wrapper"> |
|
||||||
<select id="format-select-{{ ctrl.target.refId }}" 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"> |
|
||||||
Show Help |
|
||||||
<icon name="'angle-down'" ng-show="ctrl.showHelp" style="margin-top: 3px;"></icon> |
|
||||||
<icon name="'angle-right'" ng-hide="ctrl.showHelp" style="margin-top: 3px;"></icon> |
|
||||||
</label> |
|
||||||
</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 |
|
||||||
<icon name="'angle-down'" ng-show="ctrl.showLastQuerySQL" style="margin-top: 3px;"></icon> |
|
||||||
<icon name="'angle-right'" ng-hide="ctrl.showLastQuerySQL" style="margin-top: 3px;"></icon> |
|
||||||
</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" ng-show="ctrl.showHelp"> |
|
||||||
<pre class="gf-form-pre alert alert-info">Time series: |
|
||||||
- return column named time (in UTC), as a unix time stamp or any sql native date data type. You can use the macros below. |
|
||||||
- any other columns returned will be the time point values. |
|
||||||
Optional: |
|
||||||
- return column named <i>metric</i> to represent the series name. |
|
||||||
- If multiple value columns are returned the metric column is used as prefix. |
|
||||||
- If no column named metric is found the column name of the value column is used as series name |
|
||||||
|
|
||||||
Resultsets of time series queries need to be sorted by time. |
|
||||||
|
|
||||||
Table: |
|
||||||
- return any set of columns |
|
||||||
|
|
||||||
Macros: |
|
||||||
- $__time(column) -> column AS time |
|
||||||
- $__timeEpoch(column) -> DATEDIFF(second, '1970-01-01', column) AS time |
|
||||||
- $__timeFilter(column) -> column BETWEEN '2017-04-21T05:01:17Z' AND '2017-04-21T05:01:17Z' |
|
||||||
- $__unixEpochFilter(column) -> column >= 1492750877 AND column <= 1492750877 |
|
||||||
- $__unixEpochNanoFilter(column) -> column >= 1494410783152415214 AND column <= 1494497183142514872 |
|
||||||
- $__timeGroup(column, '5m'[, fillvalue]) -> CAST(ROUND(DATEDIFF(second, '1970-01-01', column)/300.0, 0) as bigint)*300. |
|
||||||
by setting fillvalue grafana will fill in missing values according to the interval |
|
||||||
fillvalue can be either a literal value, NULL or previous; previous will fill in the previous seen value or NULL if none has been seen yet |
|
||||||
- $__timeGroupAlias(column, '5m'[, fillvalue]) -> CAST(ROUND(DATEDIFF(second, '1970-01-01', column)/300.0, 0) as bigint)*300 AS [time] |
|
||||||
- $__unixEpochGroup(column,'5m') -> FLOOR(column/300)*300 |
|
||||||
- $__unixEpochGroupAlias(column,'5m') -> FLOOR(column/300)*300 AS [time] |
|
||||||
|
|
||||||
Example of group by and order by with $__timeGroup: |
|
||||||
SELECT |
|
||||||
$__timeGroup(date_time_col, '1h') AS time, |
|
||||||
sum(value) as value |
|
||||||
FROM yourtable |
|
||||||
GROUP BY $__timeGroup(date_time_col, '1h') |
|
||||||
ORDER BY 1 |
|
||||||
|
|
||||||
Or build your own conditionals using these macros which just return the values: |
|
||||||
- $__timeFrom() -> '2017-04-21T05:01:17Z' |
|
||||||
- $__timeTo() -> '2017-04-21T05:01:17Z' |
|
||||||
- $__unixEpochFrom() -> 1492750877 |
|
||||||
- $__unixEpochTo() -> 1492750877 |
|
||||||
- $__unixEpochNanoFrom() -> 1494410783152415214 |
|
||||||
- $__unixEpochNanoTo() -> 1494497183142514872 |
|
||||||
</pre> |
|
||||||
</div> |
|
||||||
|
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="gf-form" ng-show="ctrl.showLastQuerySQL"> |
|
||||||
<pre class="gf-form-pre">{{ctrl.lastQueryMeta.executedQueryString}}</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> |
|
@ -1,65 +0,0 @@ |
|||||||
import { auto } from 'angular'; |
|
||||||
|
|
||||||
import { PanelEvents, QueryResultMeta } from '@grafana/data'; |
|
||||||
import { QueryCtrl } from 'app/plugins/sdk'; |
|
||||||
|
|
||||||
import { MssqlQuery } from './types'; |
|
||||||
|
|
||||||
const defaultQuery = `SELECT
|
|
||||||
$__timeEpoch(<time_column>), |
|
||||||
<value column> as value, |
|
||||||
<series name column> as metric |
|
||||||
FROM |
|
||||||
<table name> |
|
||||||
WHERE |
|
||||||
$__timeFilter(time_column) |
|
||||||
ORDER BY |
|
||||||
<time_column> ASC`;
|
|
||||||
|
|
||||||
export class MssqlQueryCtrl extends QueryCtrl<MssqlQuery> { |
|
||||||
static templateUrl = 'partials/query.editor.html'; |
|
||||||
|
|
||||||
formats: any[]; |
|
||||||
lastQueryMeta?: QueryResultMeta; |
|
||||||
lastQueryError?: string; |
|
||||||
showHelp = false; |
|
||||||
|
|
||||||
/** @ngInject */ |
|
||||||
constructor($scope: any, $injector: auto.IInjectorService) { |
|
||||||
super($scope, $injector); |
|
||||||
|
|
||||||
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) { |
|
||||||
// special handling when in table panel
|
|
||||||
if (this.panelCtrl.panel.type === 'table') { |
|
||||||
this.target.format = 'table'; |
|
||||||
this.target.rawSql = 'SELECT 1'; |
|
||||||
} else { |
|
||||||
this.target.rawSql = defaultQuery; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
this.panelCtrl.events.on(PanelEvents.dataReceived, this.onDataReceived.bind(this), $scope); |
|
||||||
this.panelCtrl.events.on(PanelEvents.dataError, this.onDataError.bind(this), $scope); |
|
||||||
} |
|
||||||
|
|
||||||
onDataReceived(dataList: any) { |
|
||||||
this.lastQueryError = undefined; |
|
||||||
this.lastQueryMeta = dataList[0]?.meta; |
|
||||||
} |
|
||||||
|
|
||||||
onDataError(err: any) { |
|
||||||
if (err.data && err.data.results) { |
|
||||||
const queryRes = err.data.results[this.target.refId]; |
|
||||||
if (queryRes) { |
|
||||||
this.lastQueryError = queryRes.error; |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
@ -0,0 +1,136 @@ |
|||||||
|
import { |
||||||
|
ColumnDefinition, |
||||||
|
CompletionItemKind, |
||||||
|
CompletionItemPriority, |
||||||
|
LanguageCompletionProvider, |
||||||
|
LinkedToken, |
||||||
|
StatementPlacementProvider, |
||||||
|
SuggestionKindProvider, |
||||||
|
TableDefinition, |
||||||
|
TokenType, |
||||||
|
} from '@grafana/experimental'; |
||||||
|
import { AGGREGATE_FNS, OPERATORS } from 'app/features/plugins/sql/constants'; |
||||||
|
import { DB, SQLQuery } from 'app/features/plugins/sql/types'; |
||||||
|
|
||||||
|
import { SCHEMA_NAME } from './sqlUtil'; |
||||||
|
|
||||||
|
interface CompletionProviderGetterArgs { |
||||||
|
getColumns: React.MutableRefObject<(t: SQLQuery) => Promise<ColumnDefinition[]>>; |
||||||
|
getTables: React.MutableRefObject<(d?: string) => Promise<TableDefinition[]>>; |
||||||
|
} |
||||||
|
|
||||||
|
export const getSqlCompletionProvider: (args: CompletionProviderGetterArgs) => LanguageCompletionProvider = |
||||||
|
({ getColumns, getTables }) => |
||||||
|
() => ({ |
||||||
|
triggerCharacters: ['.', ' ', '$', ',', '(', "'"], |
||||||
|
tables: { |
||||||
|
resolve: async () => { |
||||||
|
return await getTables.current(); |
||||||
|
}, |
||||||
|
parseName: (token: LinkedToken) => { |
||||||
|
let processedToken = token; |
||||||
|
let tablePath = processedToken.value; |
||||||
|
|
||||||
|
while (processedToken.next && processedToken.next.type !== TokenType.Whitespace) { |
||||||
|
tablePath += processedToken.next.value; |
||||||
|
processedToken = processedToken.next; |
||||||
|
} |
||||||
|
|
||||||
|
const tableName = tablePath.split('.').pop(); |
||||||
|
|
||||||
|
return tableName || tablePath; |
||||||
|
}, |
||||||
|
}, |
||||||
|
|
||||||
|
columns: { |
||||||
|
resolve: async (t: string) => { |
||||||
|
return await getColumns.current({ table: t, refId: 'A' }); |
||||||
|
}, |
||||||
|
}, |
||||||
|
supportedFunctions: () => AGGREGATE_FNS, |
||||||
|
supportedOperators: () => OPERATORS, |
||||||
|
customSuggestionKinds: customSuggestionKinds(getTables, getColumns), |
||||||
|
customStatementPlacement, |
||||||
|
}); |
||||||
|
|
||||||
|
export enum CustomStatementPlacement { |
||||||
|
AfterDatabase = 'afterDatabase', |
||||||
|
} |
||||||
|
|
||||||
|
export enum CustomSuggestionKind { |
||||||
|
TablesWithinDatabase = 'tablesWithinDatabase', |
||||||
|
} |
||||||
|
|
||||||
|
export const customStatementPlacement: StatementPlacementProvider = () => [ |
||||||
|
{ |
||||||
|
id: CustomStatementPlacement.AfterDatabase, |
||||||
|
resolve: (currentToken, previousKeyword) => { |
||||||
|
return Boolean( |
||||||
|
currentToken?.is(TokenType.Delimiter, '.') || |
||||||
|
(currentToken?.is(TokenType.Whitespace) && currentToken?.previous?.is(TokenType.Delimiter, '.')) || |
||||||
|
(currentToken?.isNumber() && currentToken.value.endsWith('.')) |
||||||
|
); |
||||||
|
}, |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
export const customSuggestionKinds: ( |
||||||
|
getTables: CompletionProviderGetterArgs['getTables'], |
||||||
|
getFields: CompletionProviderGetterArgs['getColumns'] |
||||||
|
) => SuggestionKindProvider = (getTables) => () => |
||||||
|
[ |
||||||
|
{ |
||||||
|
id: CustomSuggestionKind.TablesWithinDatabase, |
||||||
|
applyTo: [CustomStatementPlacement.AfterDatabase], |
||||||
|
suggestionsResolver: async (ctx) => { |
||||||
|
const tablePath = ctx.currentToken ? getDatabaseName(ctx.currentToken) : ''; |
||||||
|
const t = await getTables.current(tablePath); |
||||||
|
|
||||||
|
return t.map((table) => ({ |
||||||
|
label: table.name, |
||||||
|
insertText: table.completion ?? table.name, |
||||||
|
command: { id: 'editor.action.triggerSuggest', title: '' }, |
||||||
|
kind: CompletionItemKind.Field, |
||||||
|
sortText: CompletionItemPriority.High, |
||||||
|
range: { |
||||||
|
...ctx.range, |
||||||
|
startColumn: ctx.range.endColumn, |
||||||
|
endColumn: ctx.range.endColumn, |
||||||
|
}, |
||||||
|
})); |
||||||
|
}, |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
export function getDatabaseName(token: LinkedToken) { |
||||||
|
let processedToken = token; |
||||||
|
let database = ''; |
||||||
|
while (processedToken?.previous && !processedToken.previous.isWhiteSpace()) { |
||||||
|
processedToken = processedToken.previous; |
||||||
|
database = processedToken.value + database; |
||||||
|
} |
||||||
|
|
||||||
|
if (database.includes(SCHEMA_NAME)) { |
||||||
|
database = database.replace(SCHEMA_NAME, ''); |
||||||
|
} |
||||||
|
|
||||||
|
database = database.trim(); |
||||||
|
|
||||||
|
return database; |
||||||
|
} |
||||||
|
|
||||||
|
export async function fetchColumns(db: DB, q: SQLQuery) { |
||||||
|
const cols = await db.fields(q); |
||||||
|
if (cols.length > 0) { |
||||||
|
return cols.map((c) => { |
||||||
|
return { name: c.value, type: c.value, description: c.value }; |
||||||
|
}); |
||||||
|
} else { |
||||||
|
return []; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export async function fetchTables(db: DB, dataset?: string) { |
||||||
|
const tables = await db.lookup(dataset); |
||||||
|
return tables; |
||||||
|
} |
@ -0,0 +1,131 @@ |
|||||||
|
import { isEmpty } from 'lodash'; |
||||||
|
|
||||||
|
import { RAQBFieldTypes, SQLExpression, SQLQuery } from 'app/features/plugins/sql/types'; |
||||||
|
import { haveColumns } from 'app/features/plugins/sql/utils/sql.utils'; |
||||||
|
|
||||||
|
export function getIcon(type: string): string | undefined { |
||||||
|
switch (type) { |
||||||
|
case 'datetimeoffset': |
||||||
|
case 'date': |
||||||
|
case 'datetime2': |
||||||
|
case 'smalldatetime': |
||||||
|
case 'datetime': |
||||||
|
case 'time': |
||||||
|
return 'clock-nine'; |
||||||
|
case 'bit': |
||||||
|
return 'toggle-off'; |
||||||
|
case 'tinyint': |
||||||
|
case 'smallint': |
||||||
|
case 'int': |
||||||
|
case 'bigint': |
||||||
|
case 'decimal': |
||||||
|
case 'numeric': |
||||||
|
case 'real': |
||||||
|
case 'float': |
||||||
|
case 'money': |
||||||
|
case 'smallmoney': |
||||||
|
return 'calculator-alt'; |
||||||
|
case 'char': |
||||||
|
case 'varchar': |
||||||
|
case 'text': |
||||||
|
case 'nchar': |
||||||
|
case 'nvarchar': |
||||||
|
case 'ntext': |
||||||
|
case 'binary': |
||||||
|
case 'varbinary': |
||||||
|
case 'image': |
||||||
|
return 'text'; |
||||||
|
default: |
||||||
|
return undefined; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function getRAQBType(type: string): RAQBFieldTypes { |
||||||
|
switch (type) { |
||||||
|
case 'datetimeoffset': |
||||||
|
case 'datetime2': |
||||||
|
case 'smalldatetime': |
||||||
|
case 'datetime': |
||||||
|
return 'datetime'; |
||||||
|
case 'time': |
||||||
|
return 'time'; |
||||||
|
case 'date': |
||||||
|
return 'date'; |
||||||
|
case 'bit': |
||||||
|
return 'boolean'; |
||||||
|
case 'tinyint': |
||||||
|
case 'smallint': |
||||||
|
case 'int': |
||||||
|
case 'bigint': |
||||||
|
case 'decimal': |
||||||
|
case 'numeric': |
||||||
|
case 'real': |
||||||
|
case 'float': |
||||||
|
case 'money': |
||||||
|
case 'smallmoney': |
||||||
|
return 'number'; |
||||||
|
case 'char': |
||||||
|
case 'varchar': |
||||||
|
case 'text': |
||||||
|
case 'nchar': |
||||||
|
case 'nvarchar': |
||||||
|
case 'ntext': |
||||||
|
case 'binary': |
||||||
|
case 'varbinary': |
||||||
|
case 'image': |
||||||
|
return 'text'; |
||||||
|
default: |
||||||
|
return 'text'; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const SCHEMA_NAME = 'dbo'; |
||||||
|
|
||||||
|
export function toRawSql({ sql, dataset, table }: SQLQuery): string { |
||||||
|
let rawQuery = ''; |
||||||
|
|
||||||
|
// Return early with empty string if there is no sql column
|
||||||
|
if (!sql || !haveColumns(sql.columns)) { |
||||||
|
return rawQuery; |
||||||
|
} |
||||||
|
|
||||||
|
rawQuery += createSelectClause(sql.columns, sql.limit); |
||||||
|
|
||||||
|
if (dataset && table) { |
||||||
|
rawQuery += `FROM ${dataset}.${SCHEMA_NAME}.${table} `; |
||||||
|
} |
||||||
|
|
||||||
|
if (sql.whereString) { |
||||||
|
rawQuery += `WHERE ${sql.whereString} `; |
||||||
|
} |
||||||
|
|
||||||
|
if (sql.groupBy?.[0]?.property.name) { |
||||||
|
const groupBy = sql.groupBy.map((g) => g.property.name).filter((g) => !isEmpty(g)); |
||||||
|
rawQuery += `GROUP BY ${groupBy.join(', ')} `; |
||||||
|
} |
||||||
|
|
||||||
|
if (sql.orderBy?.property.name) { |
||||||
|
rawQuery += `ORDER BY ${sql.orderBy.property.name} `; |
||||||
|
} |
||||||
|
|
||||||
|
if (sql.orderBy?.property.name && sql.orderByDirection) { |
||||||
|
rawQuery += `${sql.orderByDirection} `; |
||||||
|
} |
||||||
|
|
||||||
|
return rawQuery; |
||||||
|
} |
||||||
|
|
||||||
|
function createSelectClause(sqlColumns: NonNullable<SQLExpression['columns']>, limit?: number): string { |
||||||
|
const columns = sqlColumns.map((c) => { |
||||||
|
let rawColumn = ''; |
||||||
|
if (c.name) { |
||||||
|
rawColumn += `${c.name}(${c.parameters?.map((p) => `${p.name}`)})`; |
||||||
|
} else { |
||||||
|
rawColumn += `${c.parameters?.map((p) => `${p.name}`)}`; |
||||||
|
} |
||||||
|
return rawColumn; |
||||||
|
}); |
||||||
|
return `SELECT ${isLimit(limit) ? 'TOP(' + limit + ')' : ''} ${columns.join(', ')} `; |
||||||
|
} |
||||||
|
|
||||||
|
const isLimit = (limit: number | undefined): boolean => limit !== undefined && limit >= 0; |
Loading…
Reference in new issue