mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
bba80b6c7a
commit
62c30dea4d
@ -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.
|
||||
|
||||
|
21
e2e/sql-suite/datasets-response.json
Normal file
21
e2e/sql-suite/datasets-response.json
Normal 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"]] }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
27
e2e/sql-suite/fields-response.json
Normal file
27
e2e/sql-suite/fields-response.json
Normal 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"]
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
59
e2e/sql-suite/mysql.spec.ts
Normal file
59
e2e/sql-suite/mysql.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
19
e2e/sql-suite/tables-response.json
Normal file
19
e2e/sql-suite/tables-response.json
Normal 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"]] }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
@ -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 (
|
||||
<>
|
||||
<EditorHeader>
|
||||
{/* Backward compatibility check. Inline select uses SelectContainer that was added in 8.3 */}
|
||||
<ErrorBoundary
|
||||
fallBackComponent={
|
||||
<InlineField label="Format" labelWidth={15}>
|
||||
<Select
|
||||
placeholder="Select format"
|
||||
value={query.format}
|
||||
onChange={onFormatChange}
|
||||
options={QUERY_FORMAT_OPTIONS}
|
||||
/>
|
||||
</InlineField>
|
||||
}
|
||||
>
|
||||
<InlineSelect
|
||||
label="Format"
|
||||
value={query.format}
|
||||
placeholder="Select format"
|
||||
menuShouldPortal
|
||||
onChange={onFormatChange}
|
||||
options={QUERY_FORMAT_OPTIONS}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
<InlineSelect
|
||||
label="Format"
|
||||
value={query.format}
|
||||
placeholder="Select format"
|
||||
menuShouldPortal
|
||||
onChange={onFormatChange}
|
||||
options={QUERY_FORMAT_OPTIONS}
|
||||
/>
|
||||
|
||||
{editorMode === EditorMode.Builder && (
|
||||
<>
|
||||
|
@ -137,6 +137,7 @@ export function SelectRow({ sql, format, columns, onSqlChange, functions }: Sele
|
||||
<EditorField label="Alias" optional width={15}>
|
||||
<Select
|
||||
value={item.alias ? toOption(item.alias) : null}
|
||||
inputId={`select-alias-${index}-${uniqueId()}`}
|
||||
options={timeSeriesAliasOpts}
|
||||
onChange={onAliasChange(item, index)}
|
||||
isClearable
|
||||
|
@ -132,7 +132,7 @@ export interface DB {
|
||||
dispose?: (dsID?: string) => void;
|
||||
lookup?: (path?: string) => Promise<Array<{ name: string; completion: string }>>;
|
||||
getEditorLanguageDefinition: () => LanguageDefinition;
|
||||
toRawSql?: (query: SQLQuery) => string;
|
||||
toRawSql: (query: SQLQuery) => string;
|
||||
functions?: () => string[];
|
||||
}
|
||||
|
||||
|
@ -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';
|
||||
import { 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;
|
||||
}
|
||||
|
||||
function createSelectClause(sqlColumns: NonNullable<SQLExpression['columns']>): string {
|
||||
export function createSelectClause(sqlColumns: NonNullable<SQLExpression['columns']>): string {
|
||||
const columns = sqlColumns.map((c) => {
|
||||
let rawColumn = '';
|
||||
if (c.name && c.alias) {
|
||||
|
@ -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);
|
||||
|
@ -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<SQLQuery>, 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<string[]> {
|
||||
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[]> {
|
||||
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>) {
|
||||
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<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);
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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`
|
||||
);
|
||||
});
|
||||
});
|
@ -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));
|
||||
}
|
||||
|
@ -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<TableDefinition[]>>;
|
||||
getMeta: (t?: TableIdentifier) => Promise<TableDefinition[]>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
18
public/app/plugins/datasource/mysql/sqlUtil.test.ts
Normal file
18
public/app/plugins/datasource/mysql/sqlUtil.test.ts
Normal 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);
|
||||
});
|
||||
});
|
340
public/app/plugins/datasource/mysql/sqlUtil.ts
Normal file
340
public/app/plugins/datasource/mysql/sqlUtil.ts
Normal 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',
|
||||
];
|
@ -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<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;
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user