diff --git a/CHANGELOG.md b/CHANGELOG.md index d0f1714..b2dea47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # Changelog -## 1.1.8 +## 1.2.0 + +- Feature: Add support for auto complete suggestions in query editor + - Experimental Feature, disabled by default (Can be enabled in Datasource Settings) + - Could not find a nice library to generate suggestions for Databricks SQL, so I wrote my own. Feels a bit spaghetti, but it works quite well. Suggestion Model is far from complete but covers most of the use cases. +- Feature: Add support to run multi statement queries (i.e. `USE .; SELECT * FROM `) +- Refactor: Cleanup unused code in backend & upgrade legacy form components in config editor + +--- +### 1.1.8 - Update grafana-plugin-sdk-go to v0.176.0 - Migrate to @grafana/create-plugin diff --git a/README.md b/README.md index 7bba0f7..5d01ba0 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,13 @@ To configure the plugin use the values provided under JDBC/ODBC in the advanced Available configuration fields are as follows: -| Name | Description | -|-----------------|-----------------------------------------------------------------------------------------| -| Server Hostname | Databricks Server Hostname (without http). i.e. `XXX.cloud.databricks.com` | - | HTTP Path | HTTP Path value for the existing cluster or SQL warehouse. i.e. `sql/1.0/endpoints/XXX` | - | Access Token | Personal Access Token for Databricks. | +| Name | Description | +|----------------------|--------------------------------------------------------------------------------------------------------------| +| Server Hostname | Databricks Server Hostname (without http). i.e. `XXX.cloud.databricks.com` | +| Server Port | Databricks Server Port (default `443`) | +| HTTP Path | HTTP Path value for the existing cluster or SQL warehouse. i.e. `sql/1.0/endpoints/XXX` | +| Access Token | Personal Access Token for Databricks. | +| Code Auto Completion | If enabled the SQL editor will fetch catalogs/schemas/tables/columns from Databricks to provide suggestions. | ### Supported Macros @@ -83,6 +85,20 @@ By default, the plugin will return the results in wide format. This behavior can ![img.png](img/advanced_options.png) +#### Code Auto Completion + +Auto Completion for the code editor is still in development. Basic functionality is implemented, +but might not always work perfectly. When enabled, the editor will make requests to Databricks +while typing to get the available catalogs, schemas, tables and columns. Only the tables present +in the current query will be fetched. +Additionally, the editor will also make suggestions for +Databricks SQL functions & keywords and Grafana macros. + +The feature can be enabled in the Datasource Settings. + +img.png +img.png + ### Examples #### Single Value Time Series diff --git a/img/autocomplete-01.png b/img/autocomplete-01.png new file mode 100644 index 0000000..34d2a33 Binary files /dev/null and b/img/autocomplete-01.png differ diff --git a/img/autocomplete-02.png b/img/autocomplete-02.png new file mode 100644 index 0000000..4f0b6ff Binary files /dev/null and b/img/autocomplete-02.png differ diff --git a/img/config_editor.png b/img/config_editor.png index 162c8f4..1642bd5 100644 Binary files a/img/config_editor.png and b/img/config_editor.png differ diff --git a/package.json b/package.json index 2749461..72233b4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mullerpeter-databricks-datasource", "private": true, - "version": "1.1.8", + "version": "1.2.0", "description": "Databricks SQL Connector", "scripts": { "build": "webpack -c ./.config/webpack/webpack.config.ts --env production", diff --git a/pkg/main.go b/pkg/main.go index 2271d39..e0a34f9 100644 --- a/pkg/main.go +++ b/pkg/main.go @@ -14,7 +14,7 @@ func main() { // to exit by itself using os.Exit. Manage automatically manages life cycle // of datasource instances. It accepts datasource instance factory as first // argument. This factory will be automatically called on incoming request - // from Grafana to create different instances of SampleDatasource (per datasource + // from Grafana to create different instances of Datasource (per datasource // ID). When datasource configuration changed Dispose method will be called and // new datasource instance created using NewSampleDatasource factory. if err := datasource.Manage("databricks-community", plugin.NewSampleDatasource, datasource.ManageOpts{}); err != nil { diff --git a/pkg/plugin/helper.go b/pkg/plugin/helper.go new file mode 100644 index 0000000..fdb0b12 --- /dev/null +++ b/pkg/plugin/helper.go @@ -0,0 +1,226 @@ +package plugin + +import ( + "database/sql" + "encoding/json" + "fmt" + _ "github.com/databricks/databricks-sql-go" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" +) + +type schemaRequestBody struct { + Catalog string `json:"catalog"` + Schema string `json:"schema"` + Table string `json:"table"` +} + +type columnsResponseBody struct { + ColumnName string `json:"name"` + ColumnType string `json:"type"` +} + +type defaultsResponseBody struct { + DefaultCatalog string `json:"defaultCatalog"` + DefaultSchema string `json:"defaultSchema"` +} + +func autocompletionQueries(req *backend.CallResourceRequest, sender backend.CallResourceResponseSender, db *sql.DB) error { + path := req.Path + log.DefaultLogger.Info("CallResource called", "path", path) + var body schemaRequestBody + err := json.Unmarshal(req.Body, &body) + if err != nil { + log.DefaultLogger.Error("CallResource Error", "err", err) + return err + } + switch path { + case "catalogs": + rows, err := db.Query("SHOW CATALOGS") + if err != nil { + log.DefaultLogger.Error("CallResource Error", "err", err) + return err + } + defer rows.Close() + catalogs := make([]string, 0) + for rows.Next() { + var catalog string + err := rows.Scan(&catalog) + if err != nil { + log.DefaultLogger.Error("CallResource Error", "err", err) + return err + } + catalogs = append(catalogs, catalog) + } + err = rows.Err() + if err != nil { + log.DefaultLogger.Error("CallResource Error", "err", err) + return err + } + jsonBody, err := json.Marshal(catalogs) + if err != nil { + log.DefaultLogger.Error("CallResource Error", "err", err) + return err + } + err = sender.Send(&backend.CallResourceResponse{ + Status: 200, + Body: jsonBody, + }) + return err + case "schemas": + queryString := "SHOW SCHEMAS" + + if body.Catalog != "" { + queryString = fmt.Sprintf("SHOW SCHEMAS IN %s", body.Catalog) + } + log.DefaultLogger.Info("CallResource called", "queryString", queryString) + rows, err := db.Query(queryString) + if err != nil { + log.DefaultLogger.Error("CallResource Error", "err", err) + return err + } + defer rows.Close() + schemas := make([]string, 0) + for rows.Next() { + var schema string + err := rows.Scan(&schema) + if err != nil { + log.DefaultLogger.Error("CallResource Error", "err", err) + return err + } + schemas = append(schemas, schema) + } + err = rows.Err() + if err != nil { + log.DefaultLogger.Error("CallResource Error", "err", err) + return err + } + jsonBody, err := json.Marshal(schemas) + if err != nil { + log.DefaultLogger.Error("CallResource Error", "err", err) + return err + } + err = sender.Send(&backend.CallResourceResponse{ + Status: 200, + Body: jsonBody, + }) + return err + case "tables": + queryString := "SHOW TABLES" + if body.Schema != "" { + queryString = fmt.Sprintf("SHOW TABLES IN %s", body.Schema) + if body.Catalog != "" { + queryString = fmt.Sprintf("SHOW TABLES IN %s.%s", body.Catalog, body.Schema) + } + } + log.DefaultLogger.Info("CallResource called", "queryString", queryString) + rows, err := db.Query(queryString) + if err != nil { + log.DefaultLogger.Error("CallResource Error", "err", err) + return err + } + defer rows.Close() + tables := make([]string, 0) + for rows.Next() { + var database string + var tableName string + var isTemporary bool + err := rows.Scan(&database, &tableName, &isTemporary) + if err != nil { + log.DefaultLogger.Error("CallResource Error", "err", err) + return err + } + tables = append(tables, tableName) + } + err = rows.Err() + if err != nil { + log.DefaultLogger.Error("CallResource Error", "err", err) + return err + } + jsonBody, err := json.Marshal(tables) + if err != nil { + log.DefaultLogger.Error("CallResource Error", "err", err) + return err + } + err = sender.Send(&backend.CallResourceResponse{ + Status: 200, + Body: jsonBody, + }) + return err + case "columns": + queryString := fmt.Sprintf("DESCRIBE TABLE %s", body.Table) + log.DefaultLogger.Info("CallResource called", "queryString", queryString) + rows, err := db.Query(queryString) + if err != nil { + log.DefaultLogger.Error("CallResource Error", "err", err) + return err + } + defer rows.Close() + columnsResponse := make([]columnsResponseBody, 0) + for rows.Next() { + var colName sql.NullString + var colType sql.NullString + var comment sql.NullString + err := rows.Scan(&colName, &colType, &comment) + if err != nil { + log.DefaultLogger.Error("CallResource Error", "err", err) + return err + } + columnsResponse = append(columnsResponse, columnsResponseBody{ + ColumnName: colName.String, + ColumnType: colType.String, + }) + } + err = rows.Err() + if err != nil { + log.DefaultLogger.Error("CallResource Error", "err", err) + return err + } + + jsonBody, err := json.Marshal(columnsResponse) + if err != nil { + log.DefaultLogger.Error("CallResource Error", "err", err) + return err + } + err = sender.Send(&backend.CallResourceResponse{ + Status: 200, + Body: jsonBody, + }) + return err + case "defaults": + queryString := "SELECT current_catalog(), current_schema();" + log.DefaultLogger.Info("CallResource called", "queryString", queryString) + row := db.QueryRow(queryString) + var currentCatalog sql.NullString + var currentSchema sql.NullString + + err := row.Scan(¤tCatalog, ¤tSchema) + if err != nil { + log.DefaultLogger.Error("CallResource Error", "err", err) + return err + } + + defaultsResponse := defaultsResponseBody{ + DefaultCatalog: currentCatalog.String, + DefaultSchema: currentSchema.String, + } + + jsonBody, err := json.Marshal(defaultsResponse) + if err != nil { + log.DefaultLogger.Error("CallResource Error", "err", err) + return err + } + err = sender.Send(&backend.CallResourceResponse{ + Status: 200, + Body: jsonBody, + }) + return err + default: + log.DefaultLogger.Error("CallResource Error", "err", "Unknown URL") + err := sender.Send(&backend.CallResourceResponse{ + Status: 404, + Body: []byte("Unknown URL"), + }) + return err + } +} diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go index 00eb858..686574a 100644 --- a/pkg/plugin/plugin.go +++ b/pkg/plugin/plugin.go @@ -12,10 +12,11 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" "reflect" + "strings" "time" ) -// Make sure SampleDatasource implements required interfaces. This is important to do +// Make sure Datasource implements required interfaces. This is important to do // since otherwise we will only get a not implemented error response from plugin in // runtime. In this example datasource instance implements backend.QueryDataHandler, // backend.CheckHealthHandler, backend.StreamHandler interfaces. Plugin should not @@ -25,10 +26,10 @@ import ( // is useful to clean up resources used by previous datasource instance when a new datasource // instance created upon datasource settings changed. var ( - _ backend.QueryDataHandler = (*SampleDatasource)(nil) - _ backend.CheckHealthHandler = (*SampleDatasource)(nil) - _ backend.StreamHandler = (*SampleDatasource)(nil) - _ instancemgmt.InstanceDisposer = (*SampleDatasource)(nil) + _ backend.QueryDataHandler = (*Datasource)(nil) + _ backend.CheckHealthHandler = (*Datasource)(nil) + _ instancemgmt.InstanceDisposer = (*Datasource)(nil) + _ backend.CallResourceHandler = (*Datasource)(nil) ) type DatasourceSettings struct { @@ -62,23 +63,27 @@ func NewSampleDatasource(settings backend.DataSourceInstanceSettings) (instancem } } - return &SampleDatasource{ + return &Datasource{ databricksConnectionsString: databricksConnectionsString, databricksDB: databricksDB, }, nil } -// SampleDatasource is an example datasource which can respond to data queries, reports +// Datasource is an example datasource which can respond to data queries, reports // its health and has streaming skills. -type SampleDatasource struct { +type Datasource struct { databricksConnectionsString string databricksDB *sql.DB } +func (d *Datasource) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { + return autocompletionQueries(req, sender, d.databricksDB) +} + // Dispose here tells plugin SDK that plugin wants to clean up resources when a new instance // created. As soon as datasource settings change detected by SDK old datasource instance will // be disposed and a new one will be created using NewSampleDatasource factory function. -func (d *SampleDatasource) Dispose() { +func (d *Datasource) Dispose() { // Clean up datasource instance resources. } @@ -86,7 +91,7 @@ func (d *SampleDatasource) Dispose() { // req contains the queries []DataQuery (where each query contains RefID as a unique identifier). // The QueryDataResponse contains a map of RefID to the response for each query, and each response // contains Frames ([]*Frame). -func (d *SampleDatasource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { +func (d *Datasource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { log.DefaultLogger.Info("QueryData called", "request", req) // create response struct @@ -115,7 +120,7 @@ type queryModel struct { QuerySettings querySettings `json:"querySettings"` } -func (d *SampleDatasource) query(_ context.Context, pCtx backend.PluginContext, query backend.DataQuery) backend.DataResponse { +func (d *Datasource) query(_ context.Context, pCtx backend.PluginContext, query backend.DataQuery) backend.DataResponse { response := backend.DataResponse{} // Unmarshal the JSON into our queryModel. @@ -131,6 +136,32 @@ func (d *SampleDatasource) query(_ context.Context, pCtx backend.PluginContext, queryString := replaceMacros(qm.RawSqlQuery, query) + // Check if multiple statements are present in the query + // If so, split them and execute them individually + if strings.Contains(queryString, ";") { + // Split the query string into multiple statements + queries := strings.Split(queryString, ";") + // Check if the last statement is empty or just whitespace and newlines + if strings.TrimSpace(queries[len(queries)-1]) == "" { + // Remove the last statement + queries = queries[:len(queries)-1] + } + // Check if there are stil multiple statements + if len(queries) > 1 { + // Execute all but the last statement without returning any data + for _, query := range queries[:len(queries)-1] { + _, err := d.databricksDB.Exec(query) + if err != nil { + response.Error = err + log.DefaultLogger.Info("Error", "err", err) + return response + } + } + // Set the query string to the last statement + queryString = queries[len(queries)-1] + } + } + log.DefaultLogger.Info("Query", "query", queryString) frame := data.NewFrame("response") @@ -192,7 +223,7 @@ func (d *SampleDatasource) query(_ context.Context, pCtx backend.PluginContext, // The main use case for these health checks is the test button on the // datasource configuration page which allows users to verify that // a datasource is working as expected. -func (d *SampleDatasource) CheckHealth(_ context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { +func (d *Datasource) CheckHealth(_ context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { log.DefaultLogger.Info("CheckHealth called", "request", req) dsn := d.databricksConnectionsString @@ -220,66 +251,3 @@ func (d *SampleDatasource) CheckHealth(_ context.Context, req *backend.CheckHeal Message: "Data source is working", }, nil } - -// SubscribeStream is called when a client wants to connect to a stream. This callback -// allows sending the first message. -func (d *SampleDatasource) SubscribeStream(_ context.Context, req *backend.SubscribeStreamRequest) (*backend.SubscribeStreamResponse, error) { - log.DefaultLogger.Info("SubscribeStream called", "request", req) - - status := backend.SubscribeStreamStatusPermissionDenied - if req.Path == "stream" { - // Allow subscribing only on expected path. - status = backend.SubscribeStreamStatusOK - } - return &backend.SubscribeStreamResponse{ - Status: status, - }, nil -} - -// RunStream is called once for any open channel. Results are shared with everyone -// subscribed to the same channel. -func (d *SampleDatasource) RunStream(ctx context.Context, req *backend.RunStreamRequest, sender *backend.StreamSender) error { - log.DefaultLogger.Info("RunStream called", "request", req) - - // Create the same data frame as for query data. - frame := data.NewFrame("response") - - // Add fields (matching the same schema used in QueryData). - frame.Fields = append(frame.Fields, - data.NewField("time", nil, make([]time.Time, 1)), - data.NewField("values", nil, make([]int64, 1)), - ) - - counter := 0 - - // Stream data frames periodically till stream closed by Grafana. - for { - select { - case <-ctx.Done(): - log.DefaultLogger.Info("Context done, finish streaming", "path", req.Path) - return nil - case <-time.After(time.Second): - // Send new data periodically. - frame.Fields[0].Set(0, time.Now()) - frame.Fields[1].Set(0, int64(10*(counter%2+1))) - - counter++ - - err := sender.SendFrame(frame, data.IncludeAll) - if err != nil { - log.DefaultLogger.Error("Error sending frame", "error", err) - continue - } - } - } -} - -// PublishStream is called when a client sends a message to the stream. -func (d *SampleDatasource) PublishStream(_ context.Context, req *backend.PublishStreamRequest) (*backend.PublishStreamResponse, error) { - log.DefaultLogger.Info("PublishStream called", "request", req) - - // Do not allow publishing at all. - return &backend.PublishStreamResponse{ - Status: backend.PublishStreamStatusPermissionDenied, - }, nil -} diff --git a/src/ConfigEditor.tsx b/src/ConfigEditor.tsx deleted file mode 100644 index 439b7a6..0000000 --- a/src/ConfigEditor.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import React, { ChangeEvent, PureComponent } from 'react'; -import { LegacyForms } from '@grafana/ui'; -import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; -import { MyDataSourceOptions, MySecureJsonData } from './types'; - -const { SecretFormField, FormField } = LegacyForms; - -interface Props extends DataSourcePluginOptionsEditorProps {} - -interface State {} - -export class ConfigEditor extends PureComponent { - - // Secure field (only sent to the backend) - onTokenChange = (event: ChangeEvent) => { - const { onOptionsChange, options } = this.props; - onOptionsChange({ - ...options, - secureJsonData: { - ...options.secureJsonData, - token: event.target.value, - }, - }); - }; - - onHostnameChange = (event: ChangeEvent) => { - const { onOptionsChange, options } = this.props; - onOptionsChange({ - ...options, - jsonData: { - ...options.jsonData, - hostname: event.target.value, - }, - }); - }; - - onPortChange = (event: ChangeEvent) => { - const { onOptionsChange, options } = this.props; - onOptionsChange({ - ...options, - jsonData: { - ...options.jsonData, - port: event.target.value, - }, - }); - }; - - onPathChange = (event: ChangeEvent) => { - const { onOptionsChange, options } = this.props; - onOptionsChange({ - ...options, - jsonData: { - ...options.jsonData, - path: event.target.value.replace(/^\//, ''), - }, - }); - }; - - onResetDBConfig = () => { - const { onOptionsChange, options } = this.props; - onOptionsChange({ - ...options, - secureJsonFields: { - ...options.secureJsonFields, - token: false - }, - secureJsonData: { - ...options.secureJsonData, - token: '', - }, - }); - }; - - render() { - const { options } = this.props; - const { secureJsonFields } = options; - const secureJsonData = (options.secureJsonData || {}) as MySecureJsonData; - const jsonData = (options.jsonData || {}) as MyDataSourceOptions; - - return ( -
-
-
- -
-
- -
-
- -
-
- -
-
-
- ); - } -} diff --git a/src/components/ConfigEditor/ConfigEditor.tsx b/src/components/ConfigEditor/ConfigEditor.tsx new file mode 100644 index 0000000..d03fc81 --- /dev/null +++ b/src/components/ConfigEditor/ConfigEditor.tsx @@ -0,0 +1,146 @@ +import React, {ChangeEvent, FormEvent, PureComponent} from 'react'; +import { InlineField, Input, SecretInput, InlineSwitch, Alert } from '@grafana/ui'; +import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; +import { MyDataSourceOptions, MySecureJsonData } from '../../types'; + +interface Props extends DataSourcePluginOptionsEditorProps {} + +interface State {} + +export class ConfigEditor extends PureComponent { + + // Secure field (only sent to the backend) + onTokenChange = (event: ChangeEvent) => { + const { onOptionsChange, options } = this.props; + onOptionsChange({ + ...options, + secureJsonData: { + ...options.secureJsonData, + token: event.target.value, + }, + }); + }; + + onHostnameChange = (event: ChangeEvent) => { + const { onOptionsChange, options } = this.props; + onOptionsChange({ + ...options, + jsonData: { + ...options.jsonData, + hostname: event.target.value, + }, + }); + }; + + onPortChange = (event: ChangeEvent) => { + const { onOptionsChange, options } = this.props; + onOptionsChange({ + ...options, + jsonData: { + ...options.jsonData, + port: event.target.value, + }, + }); + }; + + onPathChange = (event: ChangeEvent) => { + const { onOptionsChange, options } = this.props; + onOptionsChange({ + ...options, + jsonData: { + ...options.jsonData, + path: event.target.value.replace(/^\//, ''), + }, + }); + }; + + onAutoCompletionChange = (event: FormEvent) => { + const { onOptionsChange, options } = this.props; + onOptionsChange({ + ...options, + jsonData: { + ...options.jsonData, + autoCompletion: event.currentTarget.checked, + }, + }); + }; + + onResetDBConfig = () => { + const { onOptionsChange, options } = this.props; + onOptionsChange({ + ...options, + secureJsonFields: { + ...options.secureJsonFields, + token: false + }, + secureJsonData: { + ...options.secureJsonData, + token: '', + }, + }); + }; + + render() { + const { options } = this.props; + const { secureJsonFields } = options; + const secureJsonData = (options.secureJsonData || {}) as MySecureJsonData; + const jsonData = (options.jsonData || {}) as MyDataSourceOptions; + + return ( + <> +
+ + + + + + + + + + + + +
+
+ +
+ Auto Completion for the code editor is still in development. Basic functionality is implemented, + but might not always work perfectly. When enabled, the editor will make requests to Databricks + while typing to get the available catalogs, schemas, tables and columns. Only the tables present + in the current query will be fetched. +
+
+ + + +
+ + ); + } +} diff --git a/src/QueryEditor.tsx b/src/components/QueryEditor/QueryEditor.tsx similarity index 76% rename from src/QueryEditor.tsx rename to src/components/QueryEditor/QueryEditor.tsx index 652746e..0ff04c4 100644 --- a/src/QueryEditor.tsx +++ b/src/components/QueryEditor/QueryEditor.tsx @@ -1,20 +1,22 @@ -import { defaults } from 'lodash'; +import {defaults} from 'lodash'; -import React, {FormEvent, useState } from 'react'; +import React, {FormEvent, useEffect, useState} from 'react'; import { + ActionMeta, AutoSizeInput, - InlineFieldRow, - InlineField, - InlineSwitch, CodeEditor, Collapse, + InlineField, + InlineFieldRow, + InlineSwitch, Monaco, Select, - ActionMeta, } from '@grafana/ui'; import {QueryEditorProps, SelectableValue} from '@grafana/data'; -import { DataSource } from './datasource'; -import { defaultQuery, MyDataSourceOptions, MyQuery } from './types'; +import { editor } from 'monaco-editor/esm/vs/editor/editor.api'; + +import {DataSource} from '../../datasource'; +import {defaultQuery, MyDataSourceOptions, MyQuery} from '../../types'; type Props = QueryEditorProps; @@ -26,16 +28,32 @@ export function QueryEditor(props: Props) { { label: 'Value', value: 2, description: 'fills with a specific value' }, ]; + const [cursorPosition, setCursorPosition] = useState({lineNumber: 0, column: 0}); + const { datasource } = props; + + const codeAutoCompletion = datasource.autoCompletionEnabled; const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); const query = defaults(props.query, defaultQuery); const { rawSqlQuery, querySettings } = query; + const [queryValue, setQueryValue] = useState(rawSqlQuery || ""); + const onSQLQueryChange = (value: string) => { const { onChange, query } = props; onChange({ ...query, rawSqlQuery: value }); }; + const onQueryValueChange = (value: string) => { + setQueryValue(value); + } + + useEffect(() => { + if (codeAutoCompletion) { + datasource.suggestionProvider.updateSuggestions(queryValue, cursorPosition); + } + }, [queryValue, cursorPosition, datasource.suggestionProvider, codeAutoCompletion]); + const onLongToWideSwitchChange = (event: any) => { const { onChange, query } = props; @@ -56,6 +74,15 @@ export function QueryEditor(props: Props) { onChange({ ...query, querySettings: { ...querySettings, fillMode: value.value} }); }; + const getSuggestions = () => { + return datasource.suggestionProvider.getSuggestions(); + } + const editorDidMount = async (e: editor.IStandaloneCodeEditor, m: Monaco) => { + e.onDidChangeCursorPosition((e) => { + setCursorPosition(e.position); + }) + }; + // @ts-ignore return ( @@ -70,6 +97,9 @@ export function QueryEditor(props: Props) { onSave={onSQLQueryChange} showMiniMap={false} showLineNumbers={false} + getSuggestions={codeAutoCompletion ? getSuggestions : undefined} + onChange={onQueryValueChange} + onEditorDidMount={editorDidMount} /> setIsAdvancedOpen(!isAdvancedOpen)} > diff --git a/src/QueryEditorHelp.tsx b/src/components/QueryEditor/QueryEditorHelp.tsx similarity index 98% rename from src/QueryEditorHelp.tsx rename to src/components/QueryEditor/QueryEditorHelp.tsx index 56a4ecb..dd2dea3 100644 --- a/src/QueryEditorHelp.tsx +++ b/src/components/QueryEditor/QueryEditorHelp.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { QueryEditorHelpProps} from '@grafana/data'; -import { MyQuery } from './types'; +import { MyQuery } from '../../types'; const examples = [ { diff --git a/src/components/Suggestions/QuerySuggestions.ts b/src/components/Suggestions/QuerySuggestions.ts new file mode 100644 index 0000000..45ea8d7 --- /dev/null +++ b/src/components/Suggestions/QuerySuggestions.ts @@ -0,0 +1,412 @@ +import {CodeEditorSuggestionItem, CodeEditorSuggestionItemKind} from "@grafana/ui"; +import {DataSource} from "../../datasource"; +import { + Clause, + defaultSuggestionConfig, + functions, + templateVariables +} from "./constants"; +import {Column, Suggestions} from "../../types"; +import {getCursorPositionClause, positionToIndex} from "./utils"; + +type ClauseSuggestionsType = { + [clause in Clause]: CodeEditorSuggestionItem[] +} + +interface ConstantSuggestions { + templateVariables: CodeEditorSuggestionItem[] + functions: CodeEditorSuggestionItem[] +} +export class QuerySuggestions { + public suggestions: Suggestions = { + catalogs: [], + schemas: [], + tables: [], + columns: [], + } + private clauseSuggestions: ClauseSuggestionsType = { + "START": [], + "SELECT": [], + "USE": [], + "FROM": [], + "WHERE": [], + "GROUP BY": [], + "ORDER BY": [], + } + private constantSuggestions: ConstantSuggestions = { + templateVariables: [], + functions: [], + } + private dataSource: DataSource; + + private loadedSchemas: string[] = []; + private loadedTables: string[] = []; + + private fetchedTableColumns = ""; + private tableColumnsCache: Map = new Map(); + + private currentClause = ""; + private currentClauseIndex = 0; + + private currentCatalog = "hive_metastore"; + private currentSchema = "default"; + + private tableSuggestions: CodeEditorSuggestionItem[] = []; + private columnSuggestions: CodeEditorSuggestionItem[] = []; + + constructor(dataSource: DataSource) { + this.dataSource = dataSource; + this.initConstantSuggestions(); + this.catalogSchemaTableInit(); + } + // ts-ignore are used since the grafana-ui types for the suggestions do not contain all the fields of the + // underlying monaco editor suggestions, additional fields are needed to insert snippets & sort the suggestions + private initConstantSuggestions(): void { + this.constantSuggestions.functions = functions.map((func) => { + return { + label: func + "()", + kind: CodeEditorSuggestionItemKind.Property, + detail: "Function", + insertText: func + "(${0})", + // @ts-ignore + insertTextRules: 4, + // @ts-ignore + sortText: "d", + } + }); + this.constantSuggestions.templateVariables = templateVariables.map((templateVar) => { + return { + label: templateVar, + kind: CodeEditorSuggestionItemKind.Constant, + detail: "Template Variable", + // @ts-ignore + sortText: "d", + } + }); + // Special template variables with insertText + this.constantSuggestions.templateVariables.push({ + label: "$__timeFilter(timeColumn)", + kind: CodeEditorSuggestionItemKind.Constant, + detail: "Template Variable", + insertText: "\\\$__timeFilter(${0:timeColumn})", + // @ts-ignore + insertTextRules: 4, + // @ts-ignore + sortText: "d", + }); + this.constantSuggestions.templateVariables.push({ + label: "$__timeWindow(timeColumn)", + kind: CodeEditorSuggestionItemKind.Constant, + detail: "Template Variable", + insertText: "\\\$__timeWindow(${1:timeColumn})", + // @ts-ignore + insertTextRules: 4, + // @ts-ignore + sortText: "d", + }); + } + + private async tryFetchTable(table: string): Promise { + const tableNameComponents = table.split("."); + if (tableNameComponents.length === 3) { + // Case where table name in format catalog.schema.table + await this.getSchemas(tableNameComponents[0]); + await this.getTables(tableNameComponents[0], tableNameComponents[1]); + await this.getColumns(table); + } + if (tableNameComponents.length === 2) { + // Case where table name in format catalog.schema.table, but user is still typing table name + // so only catalog and schema are known and need to be fetched + await this.getSchemas(tableNameComponents[0]); + await this.getTables(tableNameComponents[0], tableNameComponents[1]); + + // Case where default catalog is used and table name is in format schema.table + await this.getTables(this.currentCatalog, tableNameComponents[0]); + await this.getColumns(this.currentCatalog + "." + table); + } + if (tableNameComponents.length === 1) { + // Case where table name in format catalog.schema.table, but user is still typing table name + // so only catalog is known and need to be fetched + await this.getSchemas(tableNameComponents[0]); + + // Case where default catalog is used and table name is in format schema.table, user is still typing + // table name so only schema is known and need to be fetched + await this.getTables(this.currentCatalog, tableNameComponents[0]); + + // Case where default catalog and schema are used and table name is in format table + await this.getColumns(this.currentCatalog + "." + this.currentSchema + "." + tableNameComponents[0]); + } + } + + private checkMetaDataRefresh(value: string, cursorPosition: {lineNumber: number, column: number}): void { + // Check if fetch of catalog/schema/table/column metadata from databricks is needed + + const cursorIndex = positionToIndex(value, cursorPosition); + const matchIndex = this.currentClause === "SELECT" ? cursorIndex : this.currentClauseIndex; + + // Extract table name from query + const pattern = new RegExp("from\\s+([\\w\.]+)", "i"); + const match = pattern.exec(value.substring(matchIndex)); + if (match) { + // Check if table name contains catalog and schema by counting dots + this.tryFetchTable(match[1]); + } + } + + private rebuildClauseSuggestions(): void { + for (const clauseKey of Object.values(Clause)) { + this.clauseSuggestions[clauseKey] = [] + // Keywords + defaultSuggestionConfig[clauseKey].keywords.forEach((keyword) => { + this.clauseSuggestions[clauseKey].push({ + label: keyword, + kind: CodeEditorSuggestionItemKind.Text, + detail: "Keyword", + // @ts-ignore + sortText: "b", + }) + }); + // Template Variables + if (defaultSuggestionConfig[clauseKey].templateVariables) { + this.clauseSuggestions[clauseKey] = this.clauseSuggestions[clauseKey].concat(this.constantSuggestions.templateVariables); + } + // Functions + if (defaultSuggestionConfig[clauseKey].functions) { + this.clauseSuggestions[clauseKey] = this.clauseSuggestions[clauseKey].concat(this.constantSuggestions.functions); + } + // Tables + if (defaultSuggestionConfig[clauseKey].tables) { + this.clauseSuggestions[clauseKey] = this.clauseSuggestions[clauseKey].concat(this.tableSuggestions); + } + // Columns + if (defaultSuggestionConfig[clauseKey].columns) { + this.clauseSuggestions[clauseKey] = this.clauseSuggestions[clauseKey].concat(this.columnSuggestions); + } + } + } + public updateSuggestions(value: string, cursorPosition: {lineNumber: number, column: number}): void { + const currentClauseResponse = getCursorPositionClause(value, cursorPosition); + this.currentClause = currentClauseResponse.clause.toUpperCase(); + this.currentClauseIndex = currentClauseResponse.index; + this.checkUseClause(value); + + // Check if metadata refresh is needed of catalog/schema/table/column + if (this.currentClause === "SELECT" || this.currentClause === "FROM") { + this.checkMetaDataRefresh(value, cursorPosition); + } + } + + private checkUseClause(value: string): void { + // Check if USE clause is used and update the default catalog/schema if needed + + // Match USE Catalog clause + const patternCatalog = new RegExp("use\\s+catalog\\s+(\\w+)", "i"); + const matchCatalog = patternCatalog.exec(value); + if (matchCatalog) { + const catalog = matchCatalog[1]; + if (this.suggestions.catalogs.includes(catalog)) { + this.currentCatalog = catalog; + this.getSchemas(catalog); + } + } + + // Match USE (SCHEMA/DATABASE) clause, but not USE CATALOG + const patternSchema = new RegExp("use\\s+(?!.*catalog)(schema\\s+|database\\s+)?([\\w\.]+)", "i"); + const matchSchema = patternSchema.exec(value); + if (matchSchema) { + let schema = matchSchema[2]; + if (schema.includes(".")) { + const catalog = schema.split(".")[0]; + schema = schema.split(".")[1]; + if (this.suggestions.catalogs.includes(catalog)) { + this.currentCatalog = catalog; + this.getSchemas(catalog); + } + } + if (this.suggestions.schemas.includes(schema)) { + this.currentSchema = schema; + this.getTables(this.currentCatalog, schema); + } + } + } + + private async catalogSchemaTableInit() { + + await this.dataSource.postResource("defaults", {}).then((defaults: {defaultCatalog: string, defaultSchema: string}) => { + this.currentCatalog = defaults.defaultCatalog; + this.currentSchema = defaults.defaultSchema; + }).catch((error) => { + console.log(error); + }); + + this.dataSource.postResource("catalogs", {}).then((catalogs) => { + this.suggestions.catalogs = catalogs; + this.suggestions.catalogs.forEach((catalog) => { + this.tableSuggestions.push({ + label: catalog, + kind: CodeEditorSuggestionItemKind.Method, + detail: "Catalog", + // @ts-ignore + sortText: "a", + }) + }) + }).catch((error) => { + console.log(error); + }) + this.dataSource.postResource("schemas", {catalog: this.currentCatalog}).then((schemas) => { + this.suggestions.schemas = schemas; + this.suggestions.schemas.forEach((schema) => { + this.tableSuggestions.push({ + label: this.currentCatalog + '.' + schema, + kind: CodeEditorSuggestionItemKind.Method, + detail: "Schema", + // @ts-ignore + sortText: "a", + }) + }) + }).catch((error) => { + console.log(error); + }) + this.dataSource.postResource("tables", {catalog: this.currentCatalog, schema: this.currentSchema}).then((tables) => { + this.suggestions.tables = tables; + this.suggestions.tables.forEach((table) => { + this.tableSuggestions.push({ + label: this.currentCatalog + '.' + this.currentSchema + '.' + table, + kind: CodeEditorSuggestionItemKind.Method, + detail: "Table", + // @ts-ignore + sortText: "a", + }) + }) + }).catch((error) => { + console.log(error); + }) + + this.rebuildClauseSuggestions(); + } + + private async getColumns(table: string): Promise { + if (this.fetchedTableColumns === table) { + return; + } + if (!this.suggestions.tables.includes(table)) { + return; + } + if (this.tableColumnsCache.has(table)) { + this.fetchedTableColumns = table; + this.suggestions.columns = this.tableColumnsCache.get(table) || []; + this.columnSuggestions = this.suggestions.columns.map((column): CodeEditorSuggestionItem => { + return { + label: column.name, + kind: CodeEditorSuggestionItemKind.Field, + detail: column.type, + documentation: 'Column', + // @ts-ignore + sortText: "a", + } + }) + this.rebuildClauseSuggestions(); + return; + } + this.fetchedTableColumns = table; + this.tableColumnsCache.set(table, []); + this.dataSource.postResource("columns", {table: table}).then((columns) => { + this.suggestions.columns = columns; + this.tableColumnsCache.set(table, columns); + this.columnSuggestions = this.suggestions.columns.map((column): CodeEditorSuggestionItem => { + return { + label: column.name, + kind: CodeEditorSuggestionItemKind.Field, + detail: column.type, + documentation: 'Column', + // @ts-ignore + sortText: "a", + } + }) + this.rebuildClauseSuggestions(); + }).catch((error) => { + console.log(error); + }) + } + + public async getSchemas(catalog: string): Promise { + if (this.loadedSchemas.includes(catalog)) { + return; + } + if (!this.suggestions.catalogs.includes(catalog)) { + return; + } + this.loadedSchemas.push(catalog); + this.dataSource.postResource("schemas", {catalog: catalog}).then((schemas) => { + schemas.forEach((schema: string) => { + this.suggestions.schemas.push(catalog + "." + schema); + this.suggestions.schemas.push(schema); + this.tableSuggestions.push({ + label: catalog + "." + schema, + kind: CodeEditorSuggestionItemKind.Method, + detail: "Schema", + // @ts-ignore + sortText: "a", + }); + this.tableSuggestions.push({ + label: schema, + kind: CodeEditorSuggestionItemKind.Method, + detail: "Schema", + // @ts-ignore + sortText: "b", + }); + this.rebuildClauseSuggestions(); + }); + }).catch((error) => { + console.log(error); + }) + }; + + public async getTables(catalog: string, schema: string): Promise { + if (this.loadedTables.includes(catalog + "." + schema)) { + return; + } + if (!this.suggestions.schemas.includes(catalog + "." + schema)) { + return; + } + this.loadedTables.push(catalog + "." + schema); + this.dataSource.postResource("tables", {catalog: catalog, schema: schema}).then((tables) => { + tables.forEach((table: string) => { + this.suggestions.tables.push(catalog + "." + schema + "." + table); + this.suggestions.tables.push(schema + "." + table); + this.suggestions.tables.push(table); + this.tableSuggestions.push({ + label: catalog + "." + schema + "." + table, + kind: CodeEditorSuggestionItemKind.Method, + detail: "Table", + // @ts-ignore + sortText: "a", + }); + this.tableSuggestions.push({ + label: schema + "." + table, + kind: CodeEditorSuggestionItemKind.Method, + detail: "Table", + // @ts-ignore + sortText: "b", + }); + this.tableSuggestions.push({ + label: table, + kind: CodeEditorSuggestionItemKind.Method, + detail: "Table", + // @ts-ignore + sortText: "c", + }); + this.rebuildClauseSuggestions(); + }); + }).catch((error) => { + console.log(error); + }) + } + + public getSuggestions(): CodeEditorSuggestionItem[] { + if (this.currentClause in Clause) { + return this.clauseSuggestions[this.currentClause as Clause]; + } + return []; + } +} diff --git a/src/components/Suggestions/constants.ts b/src/components/Suggestions/constants.ts new file mode 100644 index 0000000..9f37fe5 --- /dev/null +++ b/src/components/Suggestions/constants.ts @@ -0,0 +1,555 @@ +interface SuggestionConfig { + keywords: string[] + templateVariables: boolean + functions: boolean + tables: boolean + columns: boolean +} + +type SuggestionConfigs = { + [clause in Clause]: SuggestionConfig +} + +export enum Clause { + START = "START", + SELECT = "SELECT", + USE = "USE", + FROM = "FROM", + WHERE = "WHERE", + GROUP_BY = "GROUP BY", + ORDER_BY = "ORDER BY", +} + +export const defaultSuggestionConfig: SuggestionConfigs = { + "START": { + keywords: ["SELECT", "USE", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "LIST", "ANALYZE TABLE"], + templateVariables: false, + functions: false, + tables: false, + columns: false + }, + "SELECT": { + keywords: ["FROM"], + templateVariables: true, + functions: true, + tables: false, + columns: true + }, + "USE": { + keywords: ["CATALOG", "SCHEMA"], + templateVariables: false, + functions: false, + tables: true, + columns: false + }, + "FROM": { + keywords: ["WHERE", "GROUP BY", "GROUP BY ALL", "QUALIFY", "HAVING", "WINDOW", "ORDER BY", "CLUSTER BY", "DISTRIBUTE BY", "SORT BY", "LIMIT", "OFFSET", "UNION", "TIMESTAMP AS OF", "VERSION AS OF", "LITERAL VIEW", "CROSS JOIN", "FULL JOIN", "FULL OUTER JOIN", "INNER JOIN", "JOIN", "LEFT JOIN", "LEFT OUTER JOIN", "LEFT SEMI JOIN", "OUTER JOIN", "RIGHT JOIN", "RIGHT OUTER JOIN", "RIGHT SEMI JOIN", "RIGHT ANTI JOIN", "LEFT ANTI JOIN", "ON"], + templateVariables: false, + functions: false, + tables: true, + columns: false + }, + "WHERE": { + keywords: ["AND", "BETWEEN", "IS FALSE", "IS NOT FALSE", "IS NOT NULL", "IS NOT TRUE", "IS NULL", "IS TRUE", "NOT BETWEEN", "OR", "WHERE", "GROUP BY", "GROUP BY ALL", "QUALIFY", "HAVING", "WINDOW", "ORDER BY", "CLUSTER BY", "DISTRIBUTE BY", "SORT BY", "LIMIT", "OFFSET", "UNION", "TIMESTAMP AS OF", "VERSION AS OF", "LITERAL VIEW", "CROSS JOIN", "FULL JOIN", "FULL OUTER JOIN", "INNER JOIN", "JOIN", "LEFT JOIN", "LEFT OUTER JOIN", "LEFT SEMI JOIN", "OUTER JOIN", "RIGHT JOIN", "RIGHT OUTER JOIN", "RIGHT SEMI JOIN", "RIGHT ANTI JOIN", "LEFT ANTI JOIN", "ON"], + templateVariables: true, + functions: false, + tables: false, + columns: false + }, + "GROUP BY": { + keywords: ["ORDER BY", "CLUSTER BY", "DISTRIBUTE BY", "SORT BY", "LIMIT", "OFFSET"], + templateVariables: true, + functions: false, + tables: false, + columns: false + }, + "ORDER BY": { + keywords: ["LIMIT", "OFFSET", "DESC", "ASC"], + templateVariables: true, + functions: false, + tables: false, + columns: false + } +} + +export const templateVariables = [ + "$__timeFrom()", + "$__timeTo()", + "${__from}", + "${__from:date}", + "${__from:date:iso}", + "${__from:date:seconds}", + "${__to}", + "${__to:date}", + "${__to:date:iso}", + "${__to:date:seconds}", + "${__interval}", + "${__interval_ms}" +] +export const functions = [ + "abs", + "acos", + "acosh", + "add_months", + "aes_decrypt", + "aes_encrypt", + "aggregate", + "ai_generate_text", + "ai_query", + "and", + "any", + "any_value", + "approx_count_distinct", + "approx_percentile", + "approx_top_k", + "array", + "array_agg", + "array_append", + "array_compact", + "array_contains", + "array_distinct", + "array_except", + "array_insert", + "array_intersect", + "array_join", + "array_max", + "array_min", + "array_position", + "array_prepend", + "array_remove", + "array_repeat", + "array_size", + "array_sort", + "array_union", + "arrays_overlap", + "arrays_zip", + "ascii", + "asin", + "asinh", + "assert_true", + "atan", + "atan2", + "atanh", + "avg", + "base64", + "between", + "bigint", + "bin", + "binary", + "bit_and", + "bit_count", + "bit_get", + "bit_length", + "bit_or", + "bit_reverse", + "bit_xor", + "bitmap_bit_position", + "bitmap_bucket_number", + "bitmap_construct_agg", + "bitmap_count", + "bitmap_or_agg", + "bool_and", + "bool_or", + "boolean", + "bround", + "btrim", + "cardinality", + "case", + "cast", + "cbrt", + "ceil", + "ceiling", + "char", + "char_length", + "character_length", + "charindex", + "chr", + "cloud_files_state", + "coalesce", + "collect_list", + "collect_set", + "concat", + "concat_ws", + "contains", + "conv", + "convert_timezone", + "corr", + "cos", + "cosh", + "cot", + "count", + "count_if", + "count_min_sketch", + "covar_pop", + "covar_samp", + "crc32", + "csc", + "cube", + "cume_dist", + "curdate", + "current_catalog", + "current_database", + "current_date", + "current_metastore", + "current_schema", + "current_timestamp", + "current_timezone", + "current_user", + "current_version", + "date", + "date_add", + "date_add", + "date_diff", + "date_format", + "date_from_unix_date", + "date_part", + "date_sub", + "date_trunc", + "dateadd", + "dateadd", + "datediff", + "datediff", + "day", + "dayofmonth", + "dayofweek", + "dayofyear", + "decimal", + "decode", + "decode", + "degrees", + "dense_rank", + "double", + "e", + "element_at", + "elt", + "encode", + "endswith", + "equal_null", + "event_log", + "every", + "exists", + "exp", + "explode", + "explode_outer", + "expm1", + "extract", + "factorial", + "filter", + "find_in_set", + "first", + "first_value", + "flatten", + "float", + "floor", + "forall", + "format_number", + "format_string", + "from_csv", + "from_json", + "from_unixtime", + "from_utc_timestamp", + "get", + "get_json_object", + "getbit", + "greatest", + "grouping", + "grouping_id", + "h3_boundaryasgeojson", + "h3_boundaryaswkb", + "h3_boundaryaswkt", + "h3_centerasgeojson", + "h3_centeraswkb", + "h3_centeraswkt", + "h3_compact", + "h3_coverash3", + "h3_coverash3string", + "h3_distance", + "h3_h3tostring", + "h3_hexring", + "h3_ischildof", + "h3_ispentagon", + "h3_isvalid", + "h3_kring", + "h3_kringdistances", + "h3_longlatash3", + "h3_longlatash3string", + "h3_maxchild", + "h3_minchild", + "h3_pointash3", + "h3_pointash3string", + "h3_polyfillash3", + "h3_polyfillash3string", + "h3_resolution", + "h3_stringtoh3", + "h3_tochildren", + "h3_toparent", + "h3_try_polyfillash3", + "h3_try_polyfillash3string", + "h3_try_validate", + "h3_uncompact", + "h3_validate", + "hash", + "hex", + "hll_sketch_agg", + "hll_sketch_estimate", + "hll_union", + "hll_union_agg", + "hour", + "hypot", + "if", + "iff", + "ifnull", + "in", + "initcap", + "inline", + "inline_outer", + "input_file_block_length", + "input_file_block_start", + "input_file_name", + "instr", + "int", + "is_account_group_member", + "is_member", + "isnan", + "isnotnull", + "isnull", + "java_method", + "json_array_length", + "json_object_keys", + "json_tuple", + "kurtosis", + "lag", + "last", + "last_day", + "last_value", + "lcase", + "lead", + "least", + "left", + "len", + "length", + "levenshtein", + "list_secrets", + "ln", + "locate", + "log", + "log10", + "log1p", + "log2", + "lower", + "lpad", + "ltrim", + "luhn_check", + "make_date", + "make_dt_interval", + "make_interval", + "make_timestamp", + "make_ym_interval", + "map", + "map_concat", + "map_contains_key", + "map_entries", + "map_filter", + "map_from_arrays", + "map_from_entries", + "map_keys", + "map_values", + "map_zip_with", + "mask", + "max", + "max_by", + "md5", + "mean", + "median", + "min", + "min_by", + "minute", + "mod", + "mode", + "monotonically_increasing_id", + "month", + "months_between", + "named_struct", + "nanvl", + "negative", + "next_day", + "now", + "nth_value", + "ntile", + "nullif", + "nvl", + "nvl2", + "octet_length", + "overlay", + "parse_url", + "percent_rank", + "percentile", + "percentile_approx", + "percentile_cont", + "percentile_disc", + "pi", + "pmod", + "posexplode", + "posexplode_outer", + "position", + "positive", + "pow", + "power", + "printf", + "quarter", + "radians", + "raise_error", + "rand", + "randn", + "random", + "range", + "rank", + "read_files", + "read_kafka", + "reduce", + "reflect", + "regexp_count", + "regexp_extract", + "regexp_extract_all", + "regexp_instr", + "regexp_like", + "regexp_replace", + "regexp_substr", + "regr_avgx", + "regr_avgy", + "regr_count", + "regr_intercept", + "regr_r2", + "regr_slope", + "regr_sxx", + "regr_sxy", + "regr_syy", + "repeat", + "replace", + "reverse", + "right", + "rint", + "round", + "row_number", + "rpad", + "rtrim", + "schema_of_csv", + "schema_of_json", + "sec", + "second", + "secret", + "sentences", + "sequence", + "session_window", + "sha", + "sha1", + "sha2", + "shiftleft", + "shiftright", + "shiftrightunsigned", + "shuffle", + "sign", + "signum", + "sin", + "sinh", + "size", + "skewness", + "slice", + "smallint", + "some", + "sort_array", + "soundex", + "space", + "spark_partition_id", + "split", + "split_part", + "sql_keywords", + "sqrt", + "stack", + "startswith", + "std", + "stddev", + "stddev_pop", + "stddev_samp", + "str_to_map", + "string", + "struct", + "substr", + "substring", + "substring_index", + "sum", + "table_changes", + "tan", + "tanh", + "timediff", + "timestamp", + "timestamp_micros", + "timestamp_millis", + "timestamp_seconds", + "timestampadd", + "timestampdiff", + "tinyint", + "to_binary", + "to_char", + "to_csv", + "to_date", + "to_json", + "to_number", + "to_timestamp", + "to_unix_timestamp", + "to_utc_timestamp", + "to_varchar", + "transform", + "transform_keys", + "transform_values", + "translate", + "trim", + "trunc", + "try_add", + "try_aes_decrypt", + "try_avg", + "try_cast", + "try_divide", + "try_element_at", + "try_multiply", + "try_subtract", + "try_sum", + "try_to_binary", + "try_to_number", + "try_to_timestamp", + "typeof", + "ucase", + "unbase64", + "unhex", + "unix_date", + "unix_micros", + "unix_millis", + "unix_seconds", + "unix_timestamp", + "upper", + "url_decode", + "url_encode", + "user", + "uuid", + "var_pop", + "var_samp", + "variance", + "version", + "weekday", + "weekofyear", + "width_bucket", + "window", + "window_time", + "xpath", + "xpath_boolean", + "xpath_double", + "xpath_float", + "xpath_int", + "xpath_long", + "xpath_number", + "xpath_short", + "xpath_string", + "xxhash64", + "year", + "zip_with" +] diff --git a/src/components/Suggestions/utils.ts b/src/components/Suggestions/utils.ts new file mode 100644 index 0000000..7998b5f --- /dev/null +++ b/src/components/Suggestions/utils.ts @@ -0,0 +1,43 @@ + + +export function positionToIndex(value: string, position: {lineNumber: number, column: number}): number { + let index = 0; + for (let i = 1; i < position.lineNumber; i++) { + index = value.indexOf('\n', index) + 1; + } + index += position.column - 1; + return index; +} + +export function matchIndexToPosition(value: string, index: number): {lineNumber: number, column: number} { + let lineNumber = 0; + let columnNumber = 0; + for (let i = 0; i < index; i++) { + if (value[i] === '\n') { + lineNumber++; + columnNumber = 0; + } else { + columnNumber++; + } + } + return {lineNumber: lineNumber, column: columnNumber}; +} + +export function getCursorPositionClause(value: string, cursorPosition: {lineNumber: number, column: number}): {clause: string, index: number} { + // Get the current clause based on the cursor position, or START if no clause is found or the cursor is at + // the start of the query (; or ()). The regex matches the last occurrence of a clause keyword before the cursor. + const clausePattern = new RegExp("(select|use|from|where|group by|order by|\\s\\(|;)(?![\\s\\S]*(select|use|from|where|group by|order by|\\s\\(|;))", "i"); + const cursorIndex = positionToIndex(value, cursorPosition); + const match = clausePattern.exec(value.substring(0, cursorIndex)); + if (match !== null) { + const clause = match[1].includes("(") || match[1].includes(";") ? "START" : match[1]; + return { + clause: clause, + index: match.index + }; + } + return { + clause: "START", + index: 0 + }; +} diff --git a/src/datasource.ts b/src/datasource.ts index 724140e..1763b0e 100644 --- a/src/datasource.ts +++ b/src/datasource.ts @@ -3,11 +3,16 @@ import {DataSourceWithBackend, getTemplateSrv} from '@grafana/runtime'; import {MyDataSourceOptions, MyQuery} from './types'; import {map, switchMap} from 'rxjs/operators'; import {firstValueFrom} from 'rxjs'; +import {QuerySuggestions} from "./components/Suggestions/QuerySuggestions"; export class DataSource extends DataSourceWithBackend { + public suggestionProvider: QuerySuggestions; + public autoCompletionEnabled: boolean; constructor(instanceSettings: DataSourceInstanceSettings) { super(instanceSettings); this.annotations = {} + this.suggestionProvider = new QuerySuggestions(this); + this.autoCompletionEnabled = instanceSettings.jsonData.autoCompletion || false; } applyTemplateVariables(query: MyQuery, scopedVars: ScopedVars) { @@ -50,4 +55,5 @@ export class DataSource extends DataSourceWithBackend(DataSource) diff --git a/src/types.ts b/src/types.ts index 29917dd..7bcdb94 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import { DataQuery, DataSourceJsonData } from '@grafana/data'; +import {DataQuery, DataSourceJsonData} from '@grafana/data'; interface QuerySettings { convertLongToWide: boolean @@ -25,6 +25,7 @@ export interface MyDataSourceOptions extends DataSourceJsonData { hostname?: string; port?: string; path?: string; + autoCompletion?: boolean; } /** @@ -38,3 +39,15 @@ export interface MyVariableQuery { namespace: string; rawQuery: string; } + +export interface Column { + name: string + type: string +} + +export interface Suggestions { + catalogs: string[] + schemas: string[] + tables: string[] + columns: Column[] +}