diff --git a/docs/sources/datasources/mysql/_index.md b/docs/sources/datasources/mysql/_index.md index a14037f49da..bab280da31f 100644 --- a/docs/sources/datasources/mysql/_index.md +++ b/docs/sources/datasources/mysql/_index.md @@ -118,6 +118,8 @@ The response from MySQL can be formatted as either a table or as a time series. ### Dataset and Table selection +> **Note:** If your table or database name contains a reserved word or a [not permitted character](https://dev.mysql.com/doc/refman/8.0/en/identifiers.html) the editor will put quotes around them. For example a table name like `table-name` will be quoted with backticks `` `table-name` ``. + In the dataset dropdown, choose the MySQL database to query. The dropdown is be populated with the databases that the user has access to. When the dataset is selected, the table dropdown is populated with the tables that are available. diff --git a/e2e/sql-suite/datasets-response.json b/e2e/sql-suite/datasets-response.json new file mode 100644 index 00000000000..f9ab5f5f94b --- /dev/null +++ b/e2e/sql-suite/datasets-response.json @@ -0,0 +1,21 @@ +{ + "results": { + "datasets": { + "status": 200, + "frames": [ + { + "schema": { + "refId": "datasets", + "meta": { + "executedQueryString": "SELECT DISTINCT TABLE_SCHEMA from information_schema.TABLES where TABLE_TYPE != 'SYSTEM VIEW' ORDER BY TABLE_SCHEMA" + }, + "fields": [ + { "name": "TABLE_SCHEMA", "type": "string", "typeInfo": { "frame": "string", "nullable": true } } + ] + }, + "data": { "values": [["DataMaker", "mysql", "performance_schema", "sys"]] } + } + ] + } + } +} diff --git a/e2e/sql-suite/fields-response.json b/e2e/sql-suite/fields-response.json new file mode 100644 index 00000000000..3ccf5a0a5b8 --- /dev/null +++ b/e2e/sql-suite/fields-response.json @@ -0,0 +1,27 @@ +{ + "results": { + "fields": { + "status": 200, + "frames": [ + { + "schema": { + "refId": "fields", + "meta": { + "executedQueryString": "SELECT column_name, data_type FROM information_schema.columns WHERE table_schema = 'DataMaker' AND table_name = 'RandomIntsWithTimes' ORDER BY column_name" + }, + "fields": [ + { "name": "COLUMN_NAME", "type": "string", "typeInfo": { "frame": "string", "nullable": true } }, + { "name": "DATA_TYPE", "type": "string", "typeInfo": { "frame": "string", "nullable": true } } + ] + }, + "data": { + "values": [ + ["createdAt", "id", "time", "updatedAt", "bigint"], + ["datetime", "int", "datetime", "datetime", "int"] + ] + } + } + ] + } + } +} diff --git a/e2e/sql-suite/mysql.spec.ts b/e2e/sql-suite/mysql.spec.ts new file mode 100644 index 00000000000..ab535220c98 --- /dev/null +++ b/e2e/sql-suite/mysql.spec.ts @@ -0,0 +1,59 @@ +import { e2e } from '@grafana/e2e'; + +import datasetResponse from './datasets-response.json'; +import fieldsResponse from './fields-response.json'; +import tablesResponse from './tables-response.json'; + +const tableNameWithSpecialCharacter = tablesResponse.results.tables.frames[0].data.values[0][1]; +const normalTableName = tablesResponse.results.tables.frames[0].data.values[0][0]; + +describe('MySQL datasource', () => { + it('code editor autocomplete should handle table name escaping/quoting', () => { + e2e.flows.login('admin', 'admin'); + + e2e().intercept('POST', '**/api/ds/query', (req) => { + if (req.body.queries[0].refId === 'datasets') { + req.alias = 'datasets'; + req.reply({ + body: datasetResponse, + }); + } else if (req.body.queries[0].refId === 'tables') { + req.alias = 'tables'; + req.reply({ + body: tablesResponse, + }); + } else if (req.body.queries[0].refId === 'fields') { + req.alias = 'fields'; + req.reply({ + body: fieldsResponse, + }); + } + }); + + e2e.pages.Explore.visit(); + + e2e.components.DataSourcePicker.container().should('be.visible').type('gdev-mysql{enter}'); + + e2e().get("label[for^='option-code']").should('be.visible').click(); + e2e().get('textarea').type('S{downArrow}{enter}'); + e2e().wait('@tables'); + e2e().get('.suggest-widget').contains(tableNameWithSpecialCharacter).should('be.visible'); + e2e().get('textarea').type('{enter}'); + e2e().get('textarea').should('have.value', `SELECT FROM grafana.\`${tableNameWithSpecialCharacter}\``); + + const deleteTimes = new Array(tableNameWithSpecialCharacter.length + 2).fill( + '{backspace}', + 0, + tableNameWithSpecialCharacter.length + 2 + ); + e2e().get('textarea').type(deleteTimes.join('')); + + e2e().get('textarea').type('{command}i'); + e2e().get('.suggest-widget').contains(tableNameWithSpecialCharacter).should('be.visible'); + e2e().get('textarea').type('S{downArrow}{enter}'); + e2e().get('textarea').should('have.value', `SELECT FROM grafana.${normalTableName}`); + + e2e().get('textarea').type('.'); + e2e().get('.suggest-widget').contains('No suggestions.').should('be.visible'); + }); +}); diff --git a/e2e/sql-suite/tables-response.json b/e2e/sql-suite/tables-response.json new file mode 100644 index 00000000000..6cdda151849 --- /dev/null +++ b/e2e/sql-suite/tables-response.json @@ -0,0 +1,19 @@ +{ + "results": { + "tables": { + "status": 200, + "frames": [ + { + "schema": { + "refId": "tables", + "meta": { + "executedQueryString": "SELECT table_name FROM information_schema.tables WHERE table_schema = 'DataMaker' ORDER BY table_name" + }, + "fields": [{ "name": "TABLE_NAME", "type": "string", "typeInfo": { "frame": "string", "nullable": true } }] + }, + "data": { "values": [["normalTable", "table-name"]] } + } + ] + } + } +} diff --git a/public/app/features/plugins/sql/components/QueryHeader.tsx b/public/app/features/plugins/sql/components/QueryHeader.tsx index 3a18611e9f2..eb14bd251a3 100644 --- a/public/app/features/plugins/sql/components/QueryHeader.tsx +++ b/public/app/features/plugins/sql/components/QueryHeader.tsx @@ -3,15 +3,13 @@ import { useCopyToClipboard } from 'react-use'; import { SelectableValue } from '@grafana/data'; import { EditorField, EditorHeader, EditorMode, EditorRow, FlexItem, InlineSelect, Space } from '@grafana/experimental'; -import { Button, InlineField, InlineSwitch, RadioButtonGroup, Select, Tooltip } from '@grafana/ui'; +import { Button, InlineSwitch, RadioButtonGroup, Tooltip } from '@grafana/ui'; import { QueryWithDefaults } from '../defaults'; import { SQLQuery, QueryFormat, QueryRowFilter, QUERY_FORMAT_OPTIONS, DB } from '../types'; -import { defaultToRawSql } from '../utils/sql.utils'; import { ConfirmModal } from './ConfirmModal'; import { DatasetSelector } from './DatasetSelector'; -import { ErrorBoundary } from './ErrorBoundary'; import { TableSelector } from './TableSelector'; export interface QueryHeaderProps { @@ -43,7 +41,7 @@ export function QueryHeader({ const { editorMode } = query; const [_, copyToClipboard] = useCopyToClipboard(); const [showConfirm, setShowConfirm] = useState(false); - const toRawSql = db.toRawSql || defaultToRawSql; + const toRawSql = db.toRawSql; const onEditorModeChange = useCallback( (newEditorMode: EditorMode) => { @@ -94,28 +92,14 @@ export function QueryHeader({ return ( <> - {/* Backward compatibility check. Inline select uses SelectContainer that was added in 8.3 */} - - void; lookup?: (path?: string) => Promise>; getEditorLanguageDefinition: () => LanguageDefinition; - toRawSql?: (query: SQLQuery) => string; + toRawSql: (query: SQLQuery) => string; functions?: () => string[]; } diff --git a/public/app/features/plugins/sql/utils/sql.utils.ts b/public/app/features/plugins/sql/utils/sql.utils.ts index f5cd52d3334..f58d8dc2cac 100644 --- a/public/app/features/plugins/sql/utils/sql.utils.ts +++ b/public/app/features/plugins/sql/utils/sql.utils.ts @@ -1,5 +1,3 @@ -import { isEmpty } from 'lodash'; - import { QueryEditorExpressionType, QueryEditorFunctionExpression, @@ -7,47 +5,9 @@ import { QueryEditorPropertyExpression, QueryEditorPropertyType, } from '../expressions'; -import { SQLQuery, SQLExpression } from '../types'; - -export function defaultToRawSql({ 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); - - if (dataset && table) { - rawQuery += `FROM ${dataset}.${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} `; - } - - // Altough LIMIT 0 doesn't make sense, it is still possible to have LIMIT 0 - if (sql.limit !== undefined && sql.limit >= 0) { - rawQuery += `LIMIT ${sql.limit} `; - } - return rawQuery; -} +import { SQLExpression } from '../types'; -function createSelectClause(sqlColumns: NonNullable): string { +export function createSelectClause(sqlColumns: NonNullable): string { const columns = sqlColumns.map((c) => { let rawColumn = ''; if (c.name && c.alias) { diff --git a/public/app/features/plugins/sql/utils/useSqlChange.ts b/public/app/features/plugins/sql/utils/useSqlChange.ts index b7e0cbdfd51..f5aba9ce649 100644 --- a/public/app/features/plugins/sql/utils/useSqlChange.ts +++ b/public/app/features/plugins/sql/utils/useSqlChange.ts @@ -2,8 +2,6 @@ import { useCallback } from 'react'; import { DB, SQLExpression, SQLQuery } from '../types'; -import { defaultToRawSql } from './sql.utils'; - interface UseSqlChange { db: DB; query: SQLQuery; @@ -13,7 +11,7 @@ interface UseSqlChange { export function useSqlChange({ query, onQueryChange, db }: UseSqlChange) { const onSqlChange = useCallback( (sql: SQLExpression) => { - const toRawSql = db.toRawSql || defaultToRawSql; + const toRawSql = db.toRawSql; const rawSql = toRawSql({ sql, dataset: query.dataset, table: query.table, refId: query.refId }); const newQuery: SQLQuery = { ...query, sql, rawSql }; onQueryChange(newQuery); diff --git a/public/app/plugins/datasource/mysql/MySqlDatasource.ts b/public/app/plugins/datasource/mysql/MySqlDatasource.ts index 31dabe46f1e..73e491112b1 100644 --- a/public/app/plugins/datasource/mysql/MySqlDatasource.ts +++ b/public/app/plugins/datasource/mysql/MySqlDatasource.ts @@ -1,14 +1,13 @@ -import { DataSourceInstanceSettings, ScopedVars, TimeRange } from '@grafana/data'; +import { DataSourceInstanceSettings, TimeRange } from '@grafana/data'; import { CompletionItemKind, LanguageDefinition, TableIdentifier } from '@grafana/experimental'; -import { TemplateSrv } from '@grafana/runtime'; import { SqlDatasource } from 'app/features/plugins/sql/datasource/SqlDatasource'; import { DB, SQLQuery } from 'app/features/plugins/sql/types'; import { formatSQL } from 'app/features/plugins/sql/utils/formatSQL'; -import MySQLQueryModel from './MySqlQueryModel'; import { mapFieldsToTypes } from './fields'; import { buildColumnQuery, buildTableQuery, showDatabases } from './mySqlMetaQuery'; import { getSqlCompletionProvider } from './sqlCompletionProvider'; +import { quoteIdentifierIfNecessary, quoteLiteral, toRawSql } from './sqlUtil'; import { MySQLOptions } from './types'; export class MySqlDatasource extends SqlDatasource { @@ -18,20 +17,20 @@ export class MySqlDatasource extends SqlDatasource { super(instanceSettings); } - getQueryModel(target?: Partial, templateSrv?: TemplateSrv, scopedVars?: ScopedVars): MySQLQueryModel { - return new MySQLQueryModel(target!, templateSrv, scopedVars); + getQueryModel() { + return { quoteLiteral }; } - getSqlLanguageDefinition(db: DB): LanguageDefinition { + getSqlLanguageDefinition(): LanguageDefinition { if (this.sqlLanguageDefinition !== undefined) { return this.sqlLanguageDefinition; } const args = { - getMeta: { current: (identifier?: TableIdentifier) => this.fetchMeta(identifier) }, + getMeta: (identifier?: TableIdentifier) => this.fetchMeta(identifier), }; this.sqlLanguageDefinition = { - id: 'sql', + id: 'mysql', completionProvider: getSqlCompletionProvider(args), formatter: formatSQL, }; @@ -40,21 +39,27 @@ export class MySqlDatasource extends SqlDatasource { async fetchDatasets(): Promise { const datasets = await this.runSql(showDatabases(), { refId: 'datasets' }); - return datasets.map((t) => t[0]); + return datasets.map((t) => quoteIdentifierIfNecessary(t[0])); } async fetchTables(dataset?: string): Promise { const tables = await this.runSql(buildTableQuery(dataset), { refId: 'tables' }); - return tables.map((t) => t[0]); + return tables.map((t) => quoteIdentifierIfNecessary(t[0])); } async fetchFields(query: Partial) { if (!query.dataset || !query.table) { return []; } - const queryString = buildColumnQuery(this.getQueryModel(query), query.table!); + const queryString = buildColumnQuery(query.table, query.dataset); const frame = await this.runSql(queryString, { refId: 'fields' }); - const fields = frame.map((f) => ({ name: f[0], text: f[0], value: f[0], type: f[1], label: f[0] })); + const fields = frame.map((f) => ({ + name: f[0], + text: f[0], + value: quoteIdentifierIfNecessary(f[0]), + type: f[1], + label: f[0], + })); return mapFieldsToTypes(fields); } @@ -67,12 +72,12 @@ export class MySqlDatasource extends SqlDatasource { const datasets = await this.fetchDatasets(); return datasets.map((d) => ({ name: d, completion: `${d}.`, kind: CompletionItemKind.Module })); } else { - if (!identifier?.table && !defaultDB) { + if (!identifier?.table && (!defaultDB || identifier?.schema)) { const tables = await this.fetchTables(identifier?.schema); return tables.map((t) => ({ name: t, completion: t, kind: CompletionItemKind.Class })); } else if (identifier?.table && identifier.schema) { const fields = await this.fetchFields({ dataset: identifier.schema, table: identifier.table }); - return fields.map((t) => ({ name: t.value, completion: t.value, kind: CompletionItemKind.Field })); + return fields.map((t) => ({ name: t.name, completion: t.value, kind: CompletionItemKind.Field })); } else { return []; } @@ -90,8 +95,9 @@ export class MySqlDatasource extends SqlDatasource { validateQuery: (query: SQLQuery, range?: TimeRange) => Promise.resolve({ query, error: '', isError: false, isValid: true }), dsID: () => this.id, + toRawSql, functions: () => ['VARIANCE', 'STDDEV'], - getEditorLanguageDefinition: () => this.getSqlLanguageDefinition(this.db), + getEditorLanguageDefinition: () => this.getSqlLanguageDefinition(), }; } } diff --git a/public/app/plugins/datasource/mysql/MySqlQueryModel.ts b/public/app/plugins/datasource/mysql/MySqlQueryModel.ts deleted file mode 100644 index c31ef7fcc0a..00000000000 --- a/public/app/plugins/datasource/mysql/MySqlQueryModel.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ScopedVars } from '@grafana/data'; -import { TemplateSrv } from '@grafana/runtime'; - -import { MySQLQuery } from './types'; - -export default class MySQLQueryModel { - target: Partial; - templateSrv?: TemplateSrv; - scopedVars?: ScopedVars; - - constructor(target: Partial, templateSrv?: TemplateSrv, scopedVars?: ScopedVars) { - this.target = target; - this.templateSrv = templateSrv; - this.scopedVars = scopedVars; - } - - // remove identifier quoting from identifier to use in metadata queries - unquoteIdentifier(value: string) { - if (value[0] === '"' && value[value.length - 1] === '"') { - return value.substring(1, value.length - 1).replace(/""/g, '"'); - } else { - return value; - } - } - - quoteIdentifier(value: string) { - return '"' + value.replace(/"/g, '""') + '"'; - } - - quoteLiteral(value: string) { - return "'" + value.replace(/'/g, "''") + "'"; - } - - getDatabase() { - return this.target.dataset; - } -} diff --git a/public/app/plugins/datasource/mysql/mySqlMetaQuery.test.ts b/public/app/plugins/datasource/mysql/mySqlMetaQuery.test.ts new file mode 100644 index 00000000000..e24f74e20a3 --- /dev/null +++ b/public/app/plugins/datasource/mysql/mySqlMetaQuery.test.ts @@ -0,0 +1,9 @@ +import { buildTableQuery } from './mySqlMetaQuery'; + +describe('buildTableQuery', () => { + it('should build table query with parameter `grafana`', () => { + expect(buildTableQuery('`grafana`')).toBe( + `SELECT table_name FROM information_schema.tables WHERE table_schema = 'grafana' ORDER BY table_name` + ); + }); +}); diff --git a/public/app/plugins/datasource/mysql/mySqlMetaQuery.ts b/public/app/plugins/datasource/mysql/mySqlMetaQuery.ts index 25d09bcb8e0..dfd7edc71fb 100644 --- a/public/app/plugins/datasource/mysql/mySqlMetaQuery.ts +++ b/public/app/plugins/datasource/mysql/mySqlMetaQuery.ts @@ -1,7 +1,7 @@ -import MySQLQueryModel from './MySqlQueryModel'; +import { quoteLiteral, unquoteIdentifier } from './sqlUtil'; export function buildTableQuery(dataset?: string) { - const database = dataset !== undefined ? `'${dataset}'` : 'database()'; + const database = dataset !== undefined ? quoteIdentAsLiteral(dataset) : 'database()'; return `SELECT table_name FROM information_schema.tables WHERE table_schema = ${database} ORDER BY table_name`; } @@ -9,52 +9,32 @@ export function showDatabases() { return `SELECT DISTINCT TABLE_SCHEMA from information_schema.TABLES where TABLE_TYPE != 'SYSTEM VIEW' ORDER BY TABLE_SCHEMA`; } -export function buildColumnQuery(queryModel: MySQLQueryModel, table: string, type?: string, timeColumn?: string) { +export function buildColumnQuery(table: string, dbName?: string) { let query = 'SELECT column_name, data_type FROM information_schema.columns WHERE '; - query += buildTableConstraint(queryModel, table); - - switch (type) { - case 'time': { - query += " AND data_type IN ('timestamp','datetime','bigint','int','double','float')"; - break; - } - case 'metric': { - query += " AND data_type IN ('text','tinytext','mediumtext','longtext','varchar','char')"; - break; - } - case 'value': { - query += " AND data_type IN ('bigint','int','smallint','mediumint','tinyint','double','decimal','float')"; - query += ' AND column_name <> ' + quoteIdentAsLiteral(queryModel, timeColumn!); - break; - } - case 'group': { - query += " AND data_type IN ('text','tinytext','mediumtext','longtext','varchar','char')"; - break; - } - } + query += buildTableConstraint(table, dbName); query += ' ORDER BY column_name'; return query; } -export function buildTableConstraint(queryModel: MySQLQueryModel, table: string) { +export function buildTableConstraint(table: string, dbName?: string) { let query = ''; // check for schema qualified table if (table.includes('.')) { const parts = table.split('.'); - query = 'table_schema = ' + quoteIdentAsLiteral(queryModel, parts[0]); - query += ' AND table_name = ' + quoteIdentAsLiteral(queryModel, parts[1]); + query = 'table_schema = ' + quoteIdentAsLiteral(parts[0]); + query += ' AND table_name = ' + quoteIdentAsLiteral(parts[1]); return query; } else { - const database = queryModel.getDatabase() !== undefined ? `'${queryModel.getDatabase()}'` : 'database()'; - query = `table_schema = ${database} AND table_name = ` + quoteIdentAsLiteral(queryModel, table); + const database = dbName !== undefined ? quoteIdentAsLiteral(dbName) : 'database()'; + query = `table_schema = ${database} AND table_name = ` + quoteIdentAsLiteral(table); return query; } } -export function quoteIdentAsLiteral(queryModel: MySQLQueryModel, value: string) { - return queryModel.quoteLiteral(queryModel.unquoteIdentifier(value)); +export function quoteIdentAsLiteral(value: string) { + return quoteLiteral(unquoteIdentifier(value)); } diff --git a/public/app/plugins/datasource/mysql/sqlCompletionProvider.ts b/public/app/plugins/datasource/mysql/sqlCompletionProvider.ts index 8500d59dfa1..18446a98df7 100644 --- a/public/app/plugins/datasource/mysql/sqlCompletionProvider.ts +++ b/public/app/plugins/datasource/mysql/sqlCompletionProvider.ts @@ -1,22 +1,149 @@ import { + CompletionItemKind, + CompletionItemPriority, getStandardSQLCompletionProvider, LanguageCompletionProvider, + LinkedToken, + PositionContext, + StatementPlacementProvider, + SuggestionKind, + SuggestionKindProvider, TableDefinition, TableIdentifier, + TokenType, } from '@grafana/experimental'; interface CompletionProviderGetterArgs { - getMeta: React.MutableRefObject<(t?: TableIdentifier) => Promise>; + getMeta: (t?: TableIdentifier) => Promise; } export const getSqlCompletionProvider: (args: CompletionProviderGetterArgs) => LanguageCompletionProvider = ({ getMeta }) => (monaco, language) => ({ ...(language && getStandardSQLCompletionProvider(monaco, language)), - tables: { - resolve: getMeta.current, - }, - columns: { - resolve: getMeta.current, - }, + customStatementPlacement: customStatementPlacementProvider, + customSuggestionKinds: customSuggestionKinds(getMeta), }); + +const customStatementPlacement = { + afterDatabase: 'afterDatabase', +}; + +const customSuggestionKind = { + tablesWithinDatabase: 'tablesWithinDatabase', +}; + +const FROMKEYWORD = 'FROM'; + +export const customStatementPlacementProvider: StatementPlacementProvider = () => [ + { + id: customStatementPlacement.afterDatabase, + resolve: (currentToken, previousKeyword, previousNonWhiteSpace) => { + return Boolean( + currentToken?.is(TokenType.Delimiter, '.') && + previousKeyword?.value === FROMKEYWORD && + (previousNonWhiteSpace?.is(TokenType.IdentifierQuote) || previousNonWhiteSpace?.isIdentifier()) && + // don't match after table name + currentToken + ?.getPreviousUntil(TokenType.Keyword, [TokenType.IdentifierQuote], FROMKEYWORD) + ?.filter((t) => t.isIdentifier()).length === 1 + ); + }, + }, +]; + +export const customSuggestionKinds: (getMeta: CompletionProviderGetterArgs['getMeta']) => SuggestionKindProvider = + (getMeta) => () => + [ + { + id: SuggestionKind.Tables, + overrideDefault: true, + suggestionsResolver: async (ctx) => { + const databaseName = getDatabaseName(ctx.currentToken); + + const suggestions = await getMeta({ schema: databaseName }); + + return suggestions.map(mapToSuggestion(ctx)); + }, + }, + { + id: SuggestionKind.Columns, + overrideDefault: true, + suggestionsResolver: async (ctx) => { + const databaseToken = getDatabaseToken(ctx.currentToken); + const databaseName = getDatabaseName(databaseToken); + const tableName = getTableName(databaseToken); + + if (!databaseName || !tableName) { + return []; + } + + const suggestions = await getMeta({ schema: databaseName, table: tableName }); + + return suggestions.map(mapToSuggestion(ctx)); + }, + }, + { + id: customSuggestionKind.tablesWithinDatabase, + applyTo: [customStatementPlacement.afterDatabase], + suggestionsResolver: async (ctx) => { + const databaseName = getDatabaseName(ctx.currentToken); + + const suggestions = await getMeta({ schema: databaseName }); + + return suggestions.map(mapToSuggestion(ctx)); + }, + }, + ]; + +function mapToSuggestion(ctx: PositionContext) { + return function (tableDefinition: TableDefinition) { + return { + label: tableDefinition.name, + insertText: tableDefinition.completion ?? tableDefinition.name, + command: { id: 'editor.action.triggerSuggest', title: '' }, + kind: CompletionItemKind.Field, + sortText: CompletionItemPriority.High, + range: { + ...ctx.range, + startColumn: ctx.range.endColumn, + endColumn: ctx.range.endColumn, + }, + }; + }; +} + +function getDatabaseName(token: LinkedToken | null | undefined) { + if (token?.isIdentifier() && token.value[token.value.length - 1] !== '.') { + return token.value; + } + + if (token?.is(TokenType.Delimiter, '.')) { + return token.getPreviousOfType(TokenType.Identifier)?.value; + } + + if (token?.is(TokenType.IdentifierQuote)) { + return token.getPreviousOfType(TokenType.Identifier)?.value || token.getNextOfType(TokenType.Identifier)?.value; + } + return; +} + +function getTableName(token: LinkedToken | null | undefined) { + const identifier = token?.getNextOfType(TokenType.Identifier); + return identifier?.value; +} + +const getFromKeywordToken = (currentToken: LinkedToken | null) => { + const selectToken = currentToken?.getPreviousOfType(TokenType.Keyword, 'SELECT') ?? null; + return selectToken?.getNextOfType(TokenType.Keyword, FROMKEYWORD); +}; + +const getDatabaseToken = (currentToken: LinkedToken | null) => { + const fromToken = getFromKeywordToken(currentToken); + const nextIdentifier = fromToken?.getNextOfType(TokenType.Identifier); + if (nextIdentifier?.isKeyword() && nextIdentifier.next?.is(TokenType.Parenthesis, '(')) { + return null; + } else { + return nextIdentifier; + } +}; diff --git a/public/app/plugins/datasource/mysql/sqlUtil.test.ts b/public/app/plugins/datasource/mysql/sqlUtil.test.ts new file mode 100644 index 00000000000..d0a3f8507f9 --- /dev/null +++ b/public/app/plugins/datasource/mysql/sqlUtil.test.ts @@ -0,0 +1,18 @@ +import { isValidIdentifier } from './sqlUtil'; + +describe('isValidIdentifier', () => { + test.each([ + { value: 'and', expected: false }, // Reserved keyword + { value: '1name', expected: false }, // Starts with value + { value: 'my-sql', expected: false }, // Contains not permitted character + { value: '$id', expected: false }, // $ sign shouldn't be the first character + { value: 'my sql', expected: false }, // Whitespace is not permitted + { value: 'mysql ', expected: false }, // Whitespace is not permitted at the end + { value: ' mysql', expected: false }, // Whitespace is not permitted + { value: 'id$', expected: true }, + { value: 'myIdentifier', expected: true }, + { value: 'table_name', expected: true }, + ])('should return $expected when value is $value', ({ value, expected }) => { + expect(isValidIdentifier(value)).toBe(expected); + }); +}); diff --git a/public/app/plugins/datasource/mysql/sqlUtil.ts b/public/app/plugins/datasource/mysql/sqlUtil.ts new file mode 100644 index 00000000000..9116be17a93 --- /dev/null +++ b/public/app/plugins/datasource/mysql/sqlUtil.ts @@ -0,0 +1,340 @@ +import { isEmpty } from 'lodash'; + +import { SQLQuery } from 'app/features/plugins/sql/types'; +import { createSelectClause, haveColumns } from 'app/features/plugins/sql/utils/sql.utils'; + +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); + + if (dataset && table) { + rawQuery += `FROM ${dataset}.${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} `; + } + + // Altough LIMIT 0 doesn't make sense, it is still possible to have LIMIT 0 + if (sql.limit !== undefined && sql.limit >= 0) { + rawQuery += `LIMIT ${sql.limit} `; + } + return rawQuery; +} + +// Puts backticks (`) around the identifier if it is necessary. +export function quoteIdentifierIfNecessary(value: string) { + return isValidIdentifier(value) ? value : `\`${value}\``; +} + +/** + * Validates the identifier from MySql and returns true if it + * doesn't need to be escaped. + */ +export function isValidIdentifier(identifier: string): boolean { + const isValidName = /^[a-zA-Z_][a-zA-Z0-9_$]*$/g.test(identifier); + const isReservedWord = RESERVED_WORDS.includes(identifier.toUpperCase()); + return !isReservedWord && isValidName; +} + +// remove identifier quoting from identifier to use in metadata queries +export function unquoteIdentifier(value: string) { + if (value[0] === '"' && value[value.length - 1] === '"') { + return value.substring(1, value.length - 1).replace(/""/g, '"'); + } else if (value[0] === '`' && value[value.length - 1] === '`') { + return value.substring(1, value.length - 1); + } else { + return value; + } +} + +export function quoteLiteral(value: string) { + return "'" + value.replace(/'/g, "''") + "'"; +} + +/** + * Copied from MySQL 8.0.31 INFORMATION_SCHEMA.KEYWORDS + */ +const RESERVED_WORDS = [ + 'ACCESSIBLE', + 'ADD', + 'ALL', + 'ALTER', + 'ANALYZE', + 'AND', + 'AS', + 'ASC', + 'ASENSITIVE', + 'BEFORE', + 'BETWEEN', + 'BIGINT', + 'BINARY', + 'BLOB', + 'BOTH', + 'BY', + 'CALL', + 'CASCADE', + 'CASE', + 'CHANGE', + 'CHAR', + 'CHARACTER', + 'CHECK', + 'COLLATE', + 'COLUMN', + 'CONDITION', + 'CONSTRAINT', + 'CONTINUE', + 'CONVERT', + 'CREATE', + 'CROSS', + 'CUBE', + 'CUME_DIST', + 'CURRENT_DATE', + 'CURRENT_TIME', + 'CURRENT_TIMESTAMP', + 'CURRENT_USER', + 'CURSOR', + 'DATABASE', + 'DATABASES', + 'DAY_HOUR', + 'DAY_MICROSECOND', + 'DAY_MINUTE', + 'DAY_SECOND', + 'DEC', + 'DECIMAL', + 'DECLARE', + 'DEFAULT', + 'DELAYED', + 'DELETE', + 'DENSE_RANK', + 'DESC', + 'DESCRIBE', + 'DETERMINISTIC', + 'DISTINCT', + 'DISTINCTROW', + 'DIV', + 'DOUBLE', + 'DROP', + 'DUAL', + 'EACH', + 'ELSE', + 'ELSEIF', + 'EMPTY', + 'ENCLOSED', + 'ESCAPED', + 'EXCEPT', + 'EXISTS', + 'EXIT', + 'EXPLAIN', + 'FALSE', + 'FETCH', + 'FIRST_VALUE', + 'FLOAT', + 'FLOAT4', + 'FLOAT8', + 'FOR', + 'FORCE', + 'FOREIGN', + 'FROM', + 'FULLTEXT', + 'FUNCTION', + 'GENERATED', + 'GET', + 'GRANT', + 'GROUP', + 'GROUPING', + 'GROUPS', + 'HAVING', + 'HIGH_PRIORITY', + 'HOUR_MICROSECOND', + 'HOUR_MINUTE', + 'HOUR_SECOND', + 'IF', + 'IGNORE', + 'IN', + 'INDEX', + 'INFILE', + 'INNER', + 'INOUT', + 'INSENSITIVE', + 'INSERT', + 'INT', + 'INT1', + 'INT2', + 'INT3', + 'INT4', + 'INT8', + 'INTEGER', + 'INTERSECT', + 'INTERVAL', + 'INTO', + 'IO_AFTER_GTIDS', + 'IO_BEFORE_GTIDS', + 'IS', + 'ITERATE', + 'JOIN', + 'JSON_TABLE', + 'KEY', + 'KEYS', + 'KILL', + 'LAG', + 'LAST_VALUE', + 'LATERAL', + 'LEAD', + 'LEADING', + 'LEAVE', + 'LEFT', + 'LIKE', + 'LIMIT', + 'LINEAR', + 'LINES', + 'LOAD', + 'LOCALTIME', + 'LOCALTIMESTAMP', + 'LOCK', + 'LONG', + 'LONGBLOB', + 'LONGTEXT', + 'LOOP', + 'LOW_PRIORITY', + 'MASTER_BIND', + 'MASTER_SSL_VERIFY_SERVER_CERT', + 'MATCH', + 'MAXVALUE', + 'MEDIUMBLOB', + 'MEDIUMINT', + 'MEDIUMTEXT', + 'MIDDLEINT', + 'MINUTE_MICROSECOND', + 'MINUTE_SECOND', + 'MOD', + 'MODIFIES', + 'NATURAL', + 'NOT', + 'NO_WRITE_TO_BINLOG', + 'NTH_VALUE', + 'NTILE', + 'NULL', + 'NUMERIC', + 'OF', + 'ON', + 'OPTIMIZE', + 'OPTIMIZER_COSTS', + 'OPTION', + 'OPTIONALLY', + 'OR', + 'ORDER', + 'OUT', + 'OUTER', + 'OUTFILE', + 'OVER', + 'PARTITION', + 'PERCENT_RANK', + 'PRECISION', + 'PRIMARY', + 'PROCEDURE', + 'PURGE', + 'RANGE', + 'RANK', + 'READ', + 'READS', + 'READ_WRITE', + 'REAL', + 'RECURSIVE', + 'REFERENCES', + 'REGEXP', + 'RELEASE', + 'RENAME', + 'REPEAT', + 'REPLACE', + 'REQUIRE', + 'RESIGNAL', + 'RESTRICT', + 'RETURN', + 'REVOKE', + 'RIGHT', + 'RLIKE', + 'ROW', + 'ROWS', + 'ROW_NUMBER', + 'SCHEMA', + 'SCHEMAS', + 'SECOND_MICROSECOND', + 'SELECT', + 'SENSITIVE', + 'SEPARATOR', + 'SET', + 'SHOW', + 'SIGNAL', + 'SMALLINT', + 'SPATIAL', + 'SPECIFIC', + 'SQL', + 'SQLEXCEPTION', + 'SQLSTATE', + 'SQLWARNING', + 'SQL_BIG_RESULT', + 'SQL_CALC_FOUND_ROWS', + 'SQL_SMALL_RESULT', + 'SSL', + 'STARTING', + 'STORED', + 'STRAIGHT_JOIN', + 'SYSTEM', + 'TABLE', + 'TERMINATED', + 'THEN', + 'TINYBLOB', + 'TINYINT', + 'TINYTEXT', + 'TO', + 'TRAILING', + 'TRIGGER', + 'TRUE', + 'UNDO', + 'UNION', + 'UNIQUE', + 'UNLOCK', + 'UNSIGNED', + 'UPDATE', + 'USAGE', + 'USE', + 'USING', + 'UTC_DATE', + 'UTC_TIME', + 'UTC_TIMESTAMP', + 'VALUES', + 'VARBINARY', + 'VARCHAR', + 'VARCHARACTER', + 'VARYING', + 'VIRTUAL', + 'WHEN', + 'WHERE', + 'WHILE', + 'WINDOW', + 'WITH', + 'WRITE', + 'XOR', + 'YEAR_MONTH', + 'ZEROFILL', +]; diff --git a/public/app/plugins/datasource/postgres/sqlUtil.ts b/public/app/plugins/datasource/postgres/sqlUtil.ts index 81fec6dcf5f..3c652fb2719 100644 --- a/public/app/plugins/datasource/postgres/sqlUtil.ts +++ b/public/app/plugins/datasource/postgres/sqlUtil.ts @@ -1,6 +1,7 @@ import { isEmpty } from 'lodash'; -import { RAQBFieldTypes, SQLExpression, SQLQuery } from 'app/features/plugins/sql/types'; +import { RAQBFieldTypes, SQLQuery } from 'app/features/plugins/sql/types'; +import { createSelectClause, haveColumns } from 'app/features/plugins/sql/utils/sql.utils'; export function getFieldConfig(type: string): { raqbFieldType: RAQBFieldTypes; icon: string } { switch (type) { @@ -82,31 +83,3 @@ export function toRawSql({ sql, table }: SQLQuery): string { } return rawQuery; } - -function createSelectClause(sqlColumns: NonNullable): string { - const columns = sqlColumns.map((c) => { - let rawColumn = ''; - if (c.name && c.alias) { - rawColumn += `${c.name}(${c.parameters?.map((p) => `${p.name}`)}) AS ${c.alias}`; - } else if (c.name) { - rawColumn += `${c.name}(${c.parameters?.map((p) => `${p.name}`)})`; - } else if (c.alias) { - rawColumn += `${c.parameters?.map((p) => `${p.name}`)} AS ${c.alias}`; - } else { - rawColumn += `${c.parameters?.map((p) => `${p.name}`)}`; - } - return rawColumn; - }); - - return `SELECT ${columns.join(', ')} `; -} - -export const haveColumns = (columns: SQLExpression['columns']): columns is NonNullable => { - if (!columns) { - return false; - } - - const haveColumn = columns.some((c) => c.parameters?.length || c.parameters?.some((p) => p.name)); - const haveFunction = columns.some((c) => c.name); - return haveColumn || haveFunction; -};