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 { lastValueFrom, of } from 'rxjs'; |
||||
import { catchError, map, mapTo } from 'rxjs/operators'; |
||||
|
||||
import { AnnotationEvent, DataSourceInstanceSettings, MetricFindValue, ScopedVars, TimeRange } from '@grafana/data'; |
||||
import { BackendDataSourceResponse, DataSourceWithBackend, FetchResponse, getBackendSrv } from '@grafana/runtime'; |
||||
import { toTestingStatus } from '@grafana/runtime/src/utils/queryResponse'; |
||||
import { getTemplateSrv, TemplateSrv } from 'app/features/templating/template_srv'; |
||||
|
||||
import ResponseParser from './response_parser'; |
||||
import { MssqlOptions, MssqlQuery, MssqlQueryForInterpolation } from './types'; |
||||
|
||||
export class MssqlDatasource extends DataSourceWithBackend<MssqlQuery, MssqlOptions> { |
||||
id: any; |
||||
name: any; |
||||
responseParser: ResponseParser; |
||||
interval: string; |
||||
|
||||
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'; |
||||
import { DataSourceInstanceSettings, ScopedVars } from '@grafana/data'; |
||||
import { LanguageCompletionProvider } from '@grafana/experimental'; |
||||
import { TemplateSrv } from '@grafana/runtime'; |
||||
import { SqlDatasource } from 'app/features/plugins/sql/datasource/SqlDatasource'; |
||||
import { DB, ResponseParser, SQLQuery, SQLSelectableValue } from 'app/features/plugins/sql/types'; |
||||
|
||||
import { getSchema, showDatabases, showTables } from './MSSqlMetaQuery'; |
||||
import { MSSqlQueryModel } from './MSSqlQueryModel'; |
||||
import { MSSqlResponseParser } from './response_parser'; |
||||
import { fetchColumns, fetchTables, getSqlCompletionProvider } from './sqlCompletionProvider'; |
||||
import { getIcon, getRAQBType, SCHEMA_NAME, toRawSql } from './sqlUtil'; |
||||
import { MssqlOptions } from './types'; |
||||
|
||||
export class MssqlDatasource extends SqlDatasource { |
||||
completionProvider: LanguageCompletionProvider | undefined = undefined; |
||||
constructor(instanceSettings: DataSourceInstanceSettings<MssqlOptions>, templateSrv?: TemplateSrv) { |
||||
super(instanceSettings, templateSrv); |
||||
} |
||||
|
||||
interpolateVariable(value: any, variable: any) { |
||||
if (typeof value === 'string') { |
||||
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(','); |
||||
getQueryModel(target?: SQLQuery, templateSrv?: TemplateSrv, scopedVars?: ScopedVars): MSSqlQueryModel { |
||||
return new MSSqlQueryModel(target, templateSrv, scopedVars); |
||||
} |
||||
|
||||
interpolateVariablesInQueries( |
||||
queries: MssqlQueryForInterpolation[], |
||||
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; |
||||
getResponseParser(): ResponseParser { |
||||
return new MSSqlResponseParser(); |
||||
} |
||||
|
||||
applyTemplateVariables(target: MssqlQuery, scopedVars: ScopedVars): Record<string, any> { |
||||
return { |
||||
refId: target.refId, |
||||
datasource: this.getRef(), |
||||
rawSql: this.templateSrv.replace(target.rawSql, scopedVars, this.interpolateVariable), |
||||
format: target.format, |
||||
}; |
||||
async fetchDatasets(): Promise<string[]> { |
||||
const datasets = await this.runSql<{ name: string[] }>(showDatabases(), { refId: 'datasets' }); |
||||
return datasets.fields.name.values.toArray().flat(); |
||||
} |
||||
|
||||
async annotationQuery(options: any): Promise<AnnotationEvent[]> { |
||||
if (!options.annotation.rawQuery) { |
||||
return Promise.reject({ message: 'Query missing in annotation definition' }); |
||||
} |
||||
|
||||
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) |
||||
) |
||||
) |
||||
); |
||||
async fetchTables(dataset?: string): Promise<string[]> { |
||||
const tables = await this.runSql<{ name: string[] }>(showTables(dataset), { refId: 'tables' }); |
||||
return tables.fields.name.values.toArray().flat(); |
||||
} |
||||
|
||||
filterQuery(query: MssqlQuery): boolean { |
||||
return !query.hide; |
||||
async fetchFields(query: SQLQuery): Promise<SQLSelectableValue[]> { |
||||
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[]> { |
||||
let refId = 'tempvar'; |
||||
if (optionalOptions && optionalOptions.variable && optionalOptions.variable.name) { |
||||
refId = optionalOptions.variable.name; |
||||
getSqlCompletionProvider(db: DB): LanguageCompletionProvider { |
||||
if (this.completionProvider !== undefined) { |
||||
return this.completionProvider; |
||||
} |
||||
|
||||
const range = optionalOptions?.range as TimeRange; |
||||
|
||||
const interpolatedQuery = { |
||||
refId: refId, |
||||
datasource: this.getRef(), |
||||
rawSql: this.templateSrv.replace(query, {}, this.interpolateVariable), |
||||
format: 'table', |
||||
const args = { |
||||
getColumns: { current: (query: SQLQuery) => fetchColumns(db, query) }, |
||||
getTables: { current: (dataset?: string) => fetchTables(db, dataset) }, |
||||
}; |
||||
|
||||
return lastValueFrom( |
||||
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)); |
||||
}) |
||||
) |
||||
); |
||||
this.completionProvider = getSqlCompletionProvider(args); |
||||
return this.completionProvider; |
||||
} |
||||
|
||||
targetContainsTemplate(query: MssqlQuery): boolean { |
||||
const rawSql = query.rawSql.replace('$__', ''); |
||||
return this.templateSrv.containsTemplate(rawSql); |
||||
getDB(): DB { |
||||
return { |
||||
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 { SqlQueryEditor } from 'app/features/plugins/sql/components/QueryEditor'; |
||||
import { SQLQuery } from 'app/features/plugins/sql/types'; |
||||
|
||||
import { ConfigurationEditor } from './configuration/ConfigurationEditor'; |
||||
import { MssqlDatasource } from './datasource'; |
||||
import { MssqlQueryCtrl } from './query_ctrl'; |
||||
import { MssqlQuery } from './types'; |
||||
import { MssqlOptions } from './types'; |
||||
|
||||
const defaultQuery = `SELECT
|
||||
<time_column> as time, |
||||
<text_column> as text, |
||||
<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); |
||||
export const plugin = new DataSourcePlugin<MssqlDatasource, SQLQuery, MssqlOptions>(MssqlDatasource) |
||||
.setQueryEditor(SqlQueryEditor) |
||||
.setConfigEditor(ConfigurationEditor); |
||||
|
@ -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