MySQL: Quote identifiers that include special characters (#61135)

* SQL: toRawSQL required and escape table

* Fix autocomplete for MySQL

* Change the way we escape for builder

* Rework escape ident to be smart instead

* Fix A11y for alias

* Add first e2e test

* Add test for code editor

* Add doc

* Review comments

* Move functions to sqlUtil
This commit is contained in:
Zoltán Bedi 2023-01-31 18:16:28 +01:00 committed by GitHub
parent bba80b6c7a
commit 62c30dea4d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 678 additions and 191 deletions

View File

@ -118,6 +118,8 @@ The response from MySQL can be formatted as either a table or as a time series.
### Dataset and Table selection ### 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. 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. When the dataset is selected, the table dropdown is populated with the tables that are available.

View File

@ -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"]] }
}
]
}
}
}

View File

@ -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"]
]
}
}
]
}
}
}

View File

@ -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');
});
});

View File

@ -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"]] }
}
]
}
}
}

View File

@ -3,15 +3,13 @@ import { useCopyToClipboard } from 'react-use';
import { SelectableValue } from '@grafana/data'; import { SelectableValue } from '@grafana/data';
import { EditorField, EditorHeader, EditorMode, EditorRow, FlexItem, InlineSelect, Space } from '@grafana/experimental'; 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 { QueryWithDefaults } from '../defaults';
import { SQLQuery, QueryFormat, QueryRowFilter, QUERY_FORMAT_OPTIONS, DB } from '../types'; import { SQLQuery, QueryFormat, QueryRowFilter, QUERY_FORMAT_OPTIONS, DB } from '../types';
import { defaultToRawSql } from '../utils/sql.utils';
import { ConfirmModal } from './ConfirmModal'; import { ConfirmModal } from './ConfirmModal';
import { DatasetSelector } from './DatasetSelector'; import { DatasetSelector } from './DatasetSelector';
import { ErrorBoundary } from './ErrorBoundary';
import { TableSelector } from './TableSelector'; import { TableSelector } from './TableSelector';
export interface QueryHeaderProps { export interface QueryHeaderProps {
@ -43,7 +41,7 @@ export function QueryHeader({
const { editorMode } = query; const { editorMode } = query;
const [_, copyToClipboard] = useCopyToClipboard(); const [_, copyToClipboard] = useCopyToClipboard();
const [showConfirm, setShowConfirm] = useState(false); const [showConfirm, setShowConfirm] = useState(false);
const toRawSql = db.toRawSql || defaultToRawSql; const toRawSql = db.toRawSql;
const onEditorModeChange = useCallback( const onEditorModeChange = useCallback(
(newEditorMode: EditorMode) => { (newEditorMode: EditorMode) => {
@ -94,28 +92,14 @@ export function QueryHeader({
return ( return (
<> <>
<EditorHeader> <EditorHeader>
{/* Backward compatibility check. Inline select uses SelectContainer that was added in 8.3 */} <InlineSelect
<ErrorBoundary label="Format"
fallBackComponent={ value={query.format}
<InlineField label="Format" labelWidth={15}> placeholder="Select format"
<Select menuShouldPortal
placeholder="Select format" onChange={onFormatChange}
value={query.format} options={QUERY_FORMAT_OPTIONS}
onChange={onFormatChange} />
options={QUERY_FORMAT_OPTIONS}
/>
</InlineField>
}
>
<InlineSelect
label="Format"
value={query.format}
placeholder="Select format"
menuShouldPortal
onChange={onFormatChange}
options={QUERY_FORMAT_OPTIONS}
/>
</ErrorBoundary>
{editorMode === EditorMode.Builder && ( {editorMode === EditorMode.Builder && (
<> <>

View File

@ -137,6 +137,7 @@ export function SelectRow({ sql, format, columns, onSqlChange, functions }: Sele
<EditorField label="Alias" optional width={15}> <EditorField label="Alias" optional width={15}>
<Select <Select
value={item.alias ? toOption(item.alias) : null} value={item.alias ? toOption(item.alias) : null}
inputId={`select-alias-${index}-${uniqueId()}`}
options={timeSeriesAliasOpts} options={timeSeriesAliasOpts}
onChange={onAliasChange(item, index)} onChange={onAliasChange(item, index)}
isClearable isClearable

View File

@ -132,7 +132,7 @@ export interface DB {
dispose?: (dsID?: string) => void; dispose?: (dsID?: string) => void;
lookup?: (path?: string) => Promise<Array<{ name: string; completion: string }>>; lookup?: (path?: string) => Promise<Array<{ name: string; completion: string }>>;
getEditorLanguageDefinition: () => LanguageDefinition; getEditorLanguageDefinition: () => LanguageDefinition;
toRawSql?: (query: SQLQuery) => string; toRawSql: (query: SQLQuery) => string;
functions?: () => string[]; functions?: () => string[];
} }

View File

@ -1,5 +1,3 @@
import { isEmpty } from 'lodash';
import { import {
QueryEditorExpressionType, QueryEditorExpressionType,
QueryEditorFunctionExpression, QueryEditorFunctionExpression,
@ -7,47 +5,9 @@ import {
QueryEditorPropertyExpression, QueryEditorPropertyExpression,
QueryEditorPropertyType, QueryEditorPropertyType,
} from '../expressions'; } from '../expressions';
import { SQLQuery, SQLExpression } from '../types'; import { SQLExpression } from '../types';
export function defaultToRawSql({ sql, dataset, table }: SQLQuery): string { export function createSelectClause(sqlColumns: NonNullable<SQLExpression['columns']>): 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;
}
function createSelectClause(sqlColumns: NonNullable<SQLExpression['columns']>): string {
const columns = sqlColumns.map((c) => { const columns = sqlColumns.map((c) => {
let rawColumn = ''; let rawColumn = '';
if (c.name && c.alias) { if (c.name && c.alias) {

View File

@ -2,8 +2,6 @@ import { useCallback } from 'react';
import { DB, SQLExpression, SQLQuery } from '../types'; import { DB, SQLExpression, SQLQuery } from '../types';
import { defaultToRawSql } from './sql.utils';
interface UseSqlChange { interface UseSqlChange {
db: DB; db: DB;
query: SQLQuery; query: SQLQuery;
@ -13,7 +11,7 @@ interface UseSqlChange {
export function useSqlChange({ query, onQueryChange, db }: UseSqlChange) { export function useSqlChange({ query, onQueryChange, db }: UseSqlChange) {
const onSqlChange = useCallback( const onSqlChange = useCallback(
(sql: SQLExpression) => { (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 rawSql = toRawSql({ sql, dataset: query.dataset, table: query.table, refId: query.refId });
const newQuery: SQLQuery = { ...query, sql, rawSql }; const newQuery: SQLQuery = { ...query, sql, rawSql };
onQueryChange(newQuery); onQueryChange(newQuery);

View File

@ -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 { CompletionItemKind, LanguageDefinition, TableIdentifier } from '@grafana/experimental';
import { TemplateSrv } from '@grafana/runtime';
import { SqlDatasource } from 'app/features/plugins/sql/datasource/SqlDatasource'; import { SqlDatasource } from 'app/features/plugins/sql/datasource/SqlDatasource';
import { DB, SQLQuery } from 'app/features/plugins/sql/types'; import { DB, SQLQuery } from 'app/features/plugins/sql/types';
import { formatSQL } from 'app/features/plugins/sql/utils/formatSQL'; import { formatSQL } from 'app/features/plugins/sql/utils/formatSQL';
import MySQLQueryModel from './MySqlQueryModel';
import { mapFieldsToTypes } from './fields'; import { mapFieldsToTypes } from './fields';
import { buildColumnQuery, buildTableQuery, showDatabases } from './mySqlMetaQuery'; import { buildColumnQuery, buildTableQuery, showDatabases } from './mySqlMetaQuery';
import { getSqlCompletionProvider } from './sqlCompletionProvider'; import { getSqlCompletionProvider } from './sqlCompletionProvider';
import { quoteIdentifierIfNecessary, quoteLiteral, toRawSql } from './sqlUtil';
import { MySQLOptions } from './types'; import { MySQLOptions } from './types';
export class MySqlDatasource extends SqlDatasource { export class MySqlDatasource extends SqlDatasource {
@ -18,20 +17,20 @@ export class MySqlDatasource extends SqlDatasource {
super(instanceSettings); super(instanceSettings);
} }
getQueryModel(target?: Partial<SQLQuery>, templateSrv?: TemplateSrv, scopedVars?: ScopedVars): MySQLQueryModel { getQueryModel() {
return new MySQLQueryModel(target!, templateSrv, scopedVars); return { quoteLiteral };
} }
getSqlLanguageDefinition(db: DB): LanguageDefinition { getSqlLanguageDefinition(): LanguageDefinition {
if (this.sqlLanguageDefinition !== undefined) { if (this.sqlLanguageDefinition !== undefined) {
return this.sqlLanguageDefinition; return this.sqlLanguageDefinition;
} }
const args = { const args = {
getMeta: { current: (identifier?: TableIdentifier) => this.fetchMeta(identifier) }, getMeta: (identifier?: TableIdentifier) => this.fetchMeta(identifier),
}; };
this.sqlLanguageDefinition = { this.sqlLanguageDefinition = {
id: 'sql', id: 'mysql',
completionProvider: getSqlCompletionProvider(args), completionProvider: getSqlCompletionProvider(args),
formatter: formatSQL, formatter: formatSQL,
}; };
@ -40,21 +39,27 @@ export class MySqlDatasource extends SqlDatasource {
async fetchDatasets(): Promise<string[]> { async fetchDatasets(): Promise<string[]> {
const datasets = await this.runSql<string[]>(showDatabases(), { refId: 'datasets' }); const datasets = await this.runSql<string[]>(showDatabases(), { refId: 'datasets' });
return datasets.map((t) => t[0]); return datasets.map((t) => quoteIdentifierIfNecessary(t[0]));
} }
async fetchTables(dataset?: string): Promise<string[]> { async fetchTables(dataset?: string): Promise<string[]> {
const tables = await this.runSql<string[]>(buildTableQuery(dataset), { refId: 'tables' }); const tables = await this.runSql<string[]>(buildTableQuery(dataset), { refId: 'tables' });
return tables.map((t) => t[0]); return tables.map((t) => quoteIdentifierIfNecessary(t[0]));
} }
async fetchFields(query: Partial<SQLQuery>) { async fetchFields(query: Partial<SQLQuery>) {
if (!query.dataset || !query.table) { if (!query.dataset || !query.table) {
return []; return [];
} }
const queryString = buildColumnQuery(this.getQueryModel(query), query.table!); const queryString = buildColumnQuery(query.table, query.dataset);
const frame = await this.runSql<string[]>(queryString, { refId: 'fields' }); const frame = await this.runSql<string[]>(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); return mapFieldsToTypes(fields);
} }
@ -67,12 +72,12 @@ export class MySqlDatasource extends SqlDatasource {
const datasets = await this.fetchDatasets(); const datasets = await this.fetchDatasets();
return datasets.map((d) => ({ name: d, completion: `${d}.`, kind: CompletionItemKind.Module })); return datasets.map((d) => ({ name: d, completion: `${d}.`, kind: CompletionItemKind.Module }));
} else { } else {
if (!identifier?.table && !defaultDB) { if (!identifier?.table && (!defaultDB || identifier?.schema)) {
const tables = await this.fetchTables(identifier?.schema); const tables = await this.fetchTables(identifier?.schema);
return tables.map((t) => ({ name: t, completion: t, kind: CompletionItemKind.Class })); return tables.map((t) => ({ name: t, completion: t, kind: CompletionItemKind.Class }));
} else if (identifier?.table && identifier.schema) { } else if (identifier?.table && identifier.schema) {
const fields = await this.fetchFields({ dataset: identifier.schema, table: identifier.table }); 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 { } else {
return []; return [];
} }
@ -90,8 +95,9 @@ export class MySqlDatasource extends SqlDatasource {
validateQuery: (query: SQLQuery, range?: TimeRange) => validateQuery: (query: SQLQuery, range?: TimeRange) =>
Promise.resolve({ query, error: '', isError: false, isValid: true }), Promise.resolve({ query, error: '', isError: false, isValid: true }),
dsID: () => this.id, dsID: () => this.id,
toRawSql,
functions: () => ['VARIANCE', 'STDDEV'], functions: () => ['VARIANCE', 'STDDEV'],
getEditorLanguageDefinition: () => this.getSqlLanguageDefinition(this.db), getEditorLanguageDefinition: () => this.getSqlLanguageDefinition(),
}; };
} }
} }

View File

@ -1,37 +0,0 @@
import { ScopedVars } from '@grafana/data';
import { TemplateSrv } from '@grafana/runtime';
import { MySQLQuery } from './types';
export default class MySQLQueryModel {
target: Partial<MySQLQuery>;
templateSrv?: TemplateSrv;
scopedVars?: ScopedVars;
constructor(target: Partial<MySQLQuery>, 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;
}
}

View File

@ -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`
);
});
});

View File

@ -1,7 +1,7 @@
import MySQLQueryModel from './MySqlQueryModel'; import { quoteLiteral, unquoteIdentifier } from './sqlUtil';
export function buildTableQuery(dataset?: string) { 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`; 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`; 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 '; let query = 'SELECT column_name, data_type FROM information_schema.columns WHERE ';
query += buildTableConstraint(queryModel, table); query += buildTableConstraint(table, dbName);
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 += ' ORDER BY column_name'; query += ' ORDER BY column_name';
return query; return query;
} }
export function buildTableConstraint(queryModel: MySQLQueryModel, table: string) { export function buildTableConstraint(table: string, dbName?: string) {
let query = ''; let query = '';
// check for schema qualified table // check for schema qualified table
if (table.includes('.')) { if (table.includes('.')) {
const parts = table.split('.'); const parts = table.split('.');
query = 'table_schema = ' + quoteIdentAsLiteral(queryModel, parts[0]); query = 'table_schema = ' + quoteIdentAsLiteral(parts[0]);
query += ' AND table_name = ' + quoteIdentAsLiteral(queryModel, parts[1]); query += ' AND table_name = ' + quoteIdentAsLiteral(parts[1]);
return query; return query;
} else { } else {
const database = queryModel.getDatabase() !== undefined ? `'${queryModel.getDatabase()}'` : 'database()'; const database = dbName !== undefined ? quoteIdentAsLiteral(dbName) : 'database()';
query = `table_schema = ${database} AND table_name = ` + quoteIdentAsLiteral(queryModel, table); query = `table_schema = ${database} AND table_name = ` + quoteIdentAsLiteral(table);
return query; return query;
} }
} }
export function quoteIdentAsLiteral(queryModel: MySQLQueryModel, value: string) { export function quoteIdentAsLiteral(value: string) {
return queryModel.quoteLiteral(queryModel.unquoteIdentifier(value)); return quoteLiteral(unquoteIdentifier(value));
} }

View File

@ -1,22 +1,149 @@
import { import {
CompletionItemKind,
CompletionItemPriority,
getStandardSQLCompletionProvider, getStandardSQLCompletionProvider,
LanguageCompletionProvider, LanguageCompletionProvider,
LinkedToken,
PositionContext,
StatementPlacementProvider,
SuggestionKind,
SuggestionKindProvider,
TableDefinition, TableDefinition,
TableIdentifier, TableIdentifier,
TokenType,
} from '@grafana/experimental'; } from '@grafana/experimental';
interface CompletionProviderGetterArgs { interface CompletionProviderGetterArgs {
getMeta: React.MutableRefObject<(t?: TableIdentifier) => Promise<TableDefinition[]>>; getMeta: (t?: TableIdentifier) => Promise<TableDefinition[]>;
} }
export const getSqlCompletionProvider: (args: CompletionProviderGetterArgs) => LanguageCompletionProvider = export const getSqlCompletionProvider: (args: CompletionProviderGetterArgs) => LanguageCompletionProvider =
({ getMeta }) => ({ getMeta }) =>
(monaco, language) => ({ (monaco, language) => ({
...(language && getStandardSQLCompletionProvider(monaco, language)), ...(language && getStandardSQLCompletionProvider(monaco, language)),
tables: { customStatementPlacement: customStatementPlacementProvider,
resolve: getMeta.current, customSuggestionKinds: customSuggestionKinds(getMeta),
},
columns: {
resolve: getMeta.current,
},
}); });
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;
}
};

View File

@ -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);
});
});

View File

@ -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',
];

View File

@ -1,6 +1,7 @@
import { isEmpty } from 'lodash'; 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 } { export function getFieldConfig(type: string): { raqbFieldType: RAQBFieldTypes; icon: string } {
switch (type) { switch (type) {
@ -82,31 +83,3 @@ export function toRawSql({ sql, table }: SQLQuery): string {
} }
return rawQuery; return rawQuery;
} }
function createSelectClause(sqlColumns: NonNullable<SQLExpression['columns']>): 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<SQLExpression['columns']> => {
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;
};