diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/fsql/FSQLEditor.tsx b/public/app/plugins/datasource/influxdb/components/editor/query/fsql/FSQLEditor.tsx index 6d4a092fafb..cc79bdc0e44 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/fsql/FSQLEditor.tsx +++ b/public/app/plugins/datasource/influxdb/components/editor/query/fsql/FSQLEditor.tsx @@ -8,10 +8,9 @@ import { SQLQuery } from '../../../../../../../features/plugins/sql'; import { SqlQueryEditor } from '../../../../../../../features/plugins/sql/components/QueryEditor'; import { applyQueryDefaults } from '../../../../../../../features/plugins/sql/defaults'; import InfluxDatasource from '../../../../datasource'; +import { FlightSQLDatasource } from '../../../../fsql/datasource.flightsql'; import { InfluxQuery } from '../../../../types'; -import { FlightSQLDatasource } from './FlightSQLDatasource'; - interface Props extends Themeable2 { onChange: (query: InfluxQuery) => void; onRunQuery: () => void; diff --git a/public/app/plugins/datasource/influxdb/components/editor/query/fsql/FlightSQLDatasource.ts b/public/app/plugins/datasource/influxdb/fsql/datasource.flightsql.ts similarity index 88% rename from public/app/plugins/datasource/influxdb/components/editor/query/fsql/FlightSQLDatasource.ts rename to public/app/plugins/datasource/influxdb/fsql/datasource.flightsql.ts index 3a5217879c4..ec47c71f0b7 100644 --- a/public/app/plugins/datasource/influxdb/components/editor/query/fsql/FlightSQLDatasource.ts +++ b/public/app/plugins/datasource/influxdb/fsql/datasource.flightsql.ts @@ -1,20 +1,19 @@ -import { DataSourceInstanceSettings, TimeRange } from '@grafana/data'; +import { DataSourceInstanceSettings, TimeRange } from '@grafana/data/src'; import { CompletionItemKind, LanguageDefinition, TableIdentifier } from '@grafana/experimental'; 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'; -// @todo These are being imported for PoC, but should probably be reimplemented within the influx datasource? -import { mapFieldsToTypes } from '../../../../../mysql/fields'; -import { buildColumnQuery, buildTableQuery, showDatabases } from '../../../../../mysql/mySqlMetaQuery'; -import { getSqlCompletionProvider } from '../../../../../mysql/sqlCompletionProvider'; -import { quoteIdentifierIfNecessary, quoteLiteral, toRawSql } from '../../../../../mysql/sqlUtil'; -import { MySQLOptions } from '../../../../../mysql/types'; +import { mapFieldsToTypes } from './fields'; +import { buildColumnQuery, buildTableQuery, showDatabases } from './flightsqlMetaQuery'; +import { getSqlCompletionProvider } from './sqlCompletionProvider'; +import { quoteLiteral, quoteIdentifierIfNecessary, toRawSql } from './sqlUtil'; +import { FlightSQLOptions } from './types'; export class FlightSQLDatasource extends SqlDatasource { sqlLanguageDefinition: LanguageDefinition | undefined; - constructor(private instanceSettings: DataSourceInstanceSettings) { + constructor(private instanceSettings: DataSourceInstanceSettings) { super(instanceSettings); } @@ -31,7 +30,7 @@ export class FlightSQLDatasource extends SqlDatasource { getMeta: (identifier?: TableIdentifier) => this.fetchMeta(identifier), }; this.sqlLanguageDefinition = { - id: 'mysql', + id: 'flightsql', completionProvider: getSqlCompletionProvider(args), formatter: formatSQL, }; diff --git a/public/app/plugins/datasource/influxdb/fsql/fields.ts b/public/app/plugins/datasource/influxdb/fsql/fields.ts new file mode 100644 index 00000000000..96eb122c35e --- /dev/null +++ b/public/app/plugins/datasource/influxdb/fsql/fields.ts @@ -0,0 +1,91 @@ +import { RAQBFieldTypes, SQLSelectableValue } from 'app/features/plugins/sql/types'; + +export function mapFieldsToTypes(columns: SQLSelectableValue[]) { + const fields: SQLSelectableValue[] = []; + for (const col of columns) { + let type: RAQBFieldTypes = 'text'; + switch (col.type?.toUpperCase()) { + case 'BOOLEAN': + case 'BOOL': { + type = 'boolean'; + break; + } + case 'BYTES': + case 'VARCHAR': { + type = 'text'; + break; + } + case 'FLOAT': + case 'FLOAT64': + case 'INT': + case 'INTEGER': + case 'INT64': + case 'NUMERIC': + case 'BIGNUMERIC': { + type = 'number'; + break; + } + case 'DATE': { + type = 'date'; + break; + } + case 'DATETIME': { + type = 'datetime'; + break; + } + case 'TIME': { + type = 'time'; + break; + } + case 'TIMESTAMP': { + type = 'datetime'; + break; + } + case 'GEOGRAPHY': { + type = 'text'; + break; + } + default: + break; + } + + fields.push({ ...col, raqbFieldType: type, icon: mapColumnTypeToIcon(col.type!.toUpperCase()) }); + } + return fields; +} + +export function mapColumnTypeToIcon(type: string) { + switch (type) { + case 'TIME': + case 'DATETIME': + case 'TIMESTAMP': + return 'clock-nine'; + case 'BOOLEAN': + return 'toggle-off'; + case 'INTEGER': + case 'FLOAT': + case 'FLOAT64': + case 'INT': + case 'SMALLINT': + case 'BIGINT': + case 'TINYINT': + case 'BYTEINT': + case 'INT64': + case 'NUMERIC': + case 'DECIMAL': + return 'calculator-alt'; + case 'CHAR': + case 'VARCHAR': + case 'STRING': + case 'BYTES': + case 'TEXT': + case 'TINYTEXT': + case 'MEDIUMTEXT': + case 'LONGTEXT': + return 'text'; + case 'GEOGRAPHY': + return 'map'; + default: + return undefined; + } +} diff --git a/public/app/plugins/datasource/influxdb/fsql/flightsqlMetaQuery.ts b/public/app/plugins/datasource/influxdb/fsql/flightsqlMetaQuery.ts new file mode 100644 index 00000000000..dfd7edc71fb --- /dev/null +++ b/public/app/plugins/datasource/influxdb/fsql/flightsqlMetaQuery.ts @@ -0,0 +1,40 @@ +import { quoteLiteral, unquoteIdentifier } from './sqlUtil'; + +export function buildTableQuery(dataset?: string) { + const database = dataset !== undefined ? quoteIdentAsLiteral(dataset) : 'database()'; + return `SELECT table_name FROM information_schema.tables WHERE table_schema = ${database} ORDER BY table_name`; +} + +export function showDatabases() { + return `SELECT DISTINCT TABLE_SCHEMA from information_schema.TABLES where TABLE_TYPE != 'SYSTEM VIEW' ORDER BY TABLE_SCHEMA`; +} + +export function buildColumnQuery(table: string, dbName?: string) { + let query = 'SELECT column_name, data_type FROM information_schema.columns WHERE '; + query += buildTableConstraint(table, dbName); + + query += ' ORDER BY column_name'; + + return query; +} + +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(parts[0]); + query += ' AND table_name = ' + quoteIdentAsLiteral(parts[1]); + return query; + } else { + const database = dbName !== undefined ? quoteIdentAsLiteral(dbName) : 'database()'; + query = `table_schema = ${database} AND table_name = ` + quoteIdentAsLiteral(table); + + return query; + } +} + +export function quoteIdentAsLiteral(value: string) { + return quoteLiteral(unquoteIdentifier(value)); +} diff --git a/public/app/plugins/datasource/influxdb/fsql/sqlCompletionProvider.ts b/public/app/plugins/datasource/influxdb/fsql/sqlCompletionProvider.ts new file mode 100644 index 00000000000..b44b2475027 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/fsql/sqlCompletionProvider.ts @@ -0,0 +1,148 @@ +import { + CompletionItemKind, + CompletionItemPriority, + getStandardSQLCompletionProvider, + LanguageCompletionProvider, + LinkedToken, + PositionContext, + StatementPlacementProvider, + SuggestionKind, + SuggestionKindProvider, + TableDefinition, + TableIdentifier, + TokenType, +} from '@grafana/experimental'; + +interface CompletionProviderGetterArgs { + getMeta: (t?: TableIdentifier) => Promise; +} + +export const getSqlCompletionProvider: (args: CompletionProviderGetterArgs) => LanguageCompletionProvider = + ({ getMeta }) => + (monaco, language) => ({ + ...(language && getStandardSQLCompletionProvider(monaco, language)), + 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/influxdb/fsql/sqlUtil.ts b/public/app/plugins/datasource/influxdb/fsql/sqlUtil.ts index 3b750cad827..2064345fab3 100644 --- a/public/app/plugins/datasource/influxdb/fsql/sqlUtil.ts +++ b/public/app/plugins/datasource/influxdb/fsql/sqlUtil.ts @@ -3,6 +3,21 @@ import { isEmpty } from 'lodash'; import { SQLQuery } from 'app/features/plugins/sql/types'; import { createSelectClause, haveColumns } from 'app/features/plugins/sql/utils/sql.utils'; +// 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, "''") + "'"; +} + export function toRawSql({ sql, dataset, table }: SQLQuery): string { let rawQuery = ''; @@ -43,3 +58,283 @@ export function toRawSql({ sql, dataset, table }: SQLQuery): string { } const isLimit = (limit: number | undefined): boolean => limit !== undefined && limit >= 0; + +// Puts double quotes (") 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; +} + +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/influxdb/fsql/types.ts b/public/app/plugins/datasource/influxdb/fsql/types.ts new file mode 100644 index 00000000000..805fa085eb3 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/fsql/types.ts @@ -0,0 +1,7 @@ +import { SQLOptions, SQLQuery } from 'app/features/plugins/sql/types'; + +export interface FlightSQLOptions extends SQLOptions { + allowCleartextPasswords?: boolean; +} + +export interface FlightSQLQuery extends SQLQuery {}