From 6282a1baea059287dcb34d33be03ed8b1a2e37f9 Mon Sep 17 00:00:00 2001 From: Scott Lepper Date: Tue, 18 Jan 2022 15:31:35 -0500 Subject: [PATCH] multi line time series (#40) * multi line time series --- CHANGELOG.md | 4 ++ README.md | 15 +++++++ package.json | 2 +- src/components/SQLEditor.tsx | 22 +++++++++- src/data/adHocFilter.ts | 84 ++---------------------------------- src/data/ast.ts | 77 +++++++++++++++++++++++++++++++++ src/types.ts | 5 +++ 7 files changed, 126 insertions(+), 83 deletions(-) create mode 100644 src/data/ast.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6778ef8f..bbf57707 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.9.7 + +Feature - Multi-line time series. + ## 0.9.6 Bug - Change time template variable names. diff --git a/README.md b/README.md index d62e520d..b48f9e1b 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,21 @@ The query editor allows you to query ClickHouse to return time series or tabular Time series visualization options are selectable after adding a `datetime` field type to your query. This field will be used as the timestamp You can select time series visualizations using the visualization options. Grafana interprets timestamp rows without explicit time zone as UTC. Any column except time is treated as a value column. +#### Multi-line time series + +To create multi-line time series, the query must return at least 3 fields. +- field 1: `datetime` field with an alias of `time` +- field 2: value to group by +- field 3+: the metric values + +For example: +```sql +SELECT log_time as time, machine_group, avg(disk_free) as avg_disk_free +from mgbench.logs1 +group by machine_group, log_time +order by log_time +``` + ### Query as table Table visualizations will always be available for any valid ClickHouse query. diff --git a/package.json b/package.json index d5046838..f0e050b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clickhouse-datasource", - "version": "0.9.6", + "version": "0.9.7", "description": "Clickhouse Datasource", "scripts": { "build": "grafana-toolkit plugin:build", diff --git a/src/components/SQLEditor.tsx b/src/components/SQLEditor.tsx index a10488e1..352efd46 100644 --- a/src/components/SQLEditor.tsx +++ b/src/components/SQLEditor.tsx @@ -3,17 +3,35 @@ import { QueryEditorProps } from '@grafana/data'; import { CodeEditor } from '@grafana/ui'; import { Datasource } from '../data/CHDatasource'; import { registerSQL, Range, Fetcher } from './sqlProvider'; -import { CHQuery, CHConfig } from '../types'; +import { CHQuery, CHConfig, Format } from '../types'; import { styles } from '../styles'; import { fetchSuggestions as sugg, Schema } from './suggestions'; import { selectors } from 'selectors'; +import sqlToAST from '../data/ast'; +import { isString } from 'lodash'; type SQLEditorProps = QueryEditorProps; export const SQLEditor = (props: SQLEditorProps) => { const { query, onRunQuery, onChange, datasource } = props; + + const getFormat = (sql: string): Format => { + // convention to format as time series + // first field as "time" alias and requires at least 2 fields (time and metric) + const ast = sqlToAST(sql); + const select = ast.get('SELECT'); + if (isString(select)) { + const fields = select.split(','); + if (fields.length > 1) { + return fields[0].toLowerCase().endsWith('as time') ? Format.TIMESERIES : Format.TABLE; + } + } + return Format.TABLE; + }; + const onSqlChange = (sql: string) => { - onChange({ ...query, rawSql: sql, format: 1 }); + const format = getFormat(sql); + onChange({ ...query, rawSql: sql, format }); onRunQuery(); }; diff --git a/src/data/adHocFilter.ts b/src/data/adHocFilter.ts index f131c54f..84654a70 100644 --- a/src/data/adHocFilter.ts +++ b/src/data/adHocFilter.ts @@ -1,3 +1,5 @@ +import sqlToAST, { clausesToSql, Clause } from './ast'; + export class AdHocFilter { private _targetTable = ''; @@ -23,48 +25,9 @@ export class AdHocFilter { } // Semicolons are not required and cause problems when building the SQL sql = sql.replace(';', ''); - const ast = this.sqlToAST(sql); + const ast = sqlToAST(sql); const filteredAST = this.applyFiltersToAST(ast, whereClause); - return this.clausesToSql(filteredAST); - } - - private sqlToAST(sql: string): Map { - const ast = this.createStatement(); - const re = - /\b(WITH|SELECT|DISTINCT|FROM|SAMPLE|JOIN|PREWHERE|WHERE|GROUP BY|LIMIT BY|HAVING|LIMIT|OFFSET|UNION|INTERSECT|EXCEPT|INTO OUTFILE|FORMAT)\b/gi; - let bracketCount = 0; - let lastBracketCount = 0; - let lastNode = ''; - let bracketPhrase = ''; - let regExpArray: RegExpExecArray | null; - while ((regExpArray = re.exec(sql)) !== null) { - // Sets foundNode to a SQL keyword from the regular expression - const foundNode = regExpArray[0].toUpperCase(); - const phrase = sql.substring(re.lastIndex, sql.length).split(re)[0]; - // If there is a greater number of open brackets than closed, - // add the the bracket phrase that will eventually be added the the last node - if (bracketCount > 0) { - bracketPhrase += foundNode + phrase; - } else { - ast.set(foundNode, phrase); - lastNode = foundNode; - } - bracketCount += (phrase.match(/\(/g) || []).length; - bracketCount -= (phrase.match(/\)/g) || []).length; - if (bracketCount <= 0 && lastBracketCount > 0) { - // The phrase brackets is complete - // If it is a FROM phrase lets make a branch node - // If it is anything else lets make a leaf node - if (lastNode === 'FROM') { - ast.set(lastNode, this.sqlToAST(bracketPhrase)); - } else { - const p = (ast.get(lastNode) as string).concat(bracketPhrase); - ast.set(lastNode, p); - } - } - lastBracketCount = bracketCount; - } - return ast; + return clausesToSql(filteredAST); } private applyFiltersToAST(ast: Map, whereClause: string): Map { @@ -87,19 +50,6 @@ export class AdHocFilter { return ast.set('FROM', fromAST); } - private clausesToSql(ast: Map): string { - let r = ''; - ast.forEach((c: Clause, key: string) => { - if (typeof c === 'string') { - r += `${key} ${c.trim()} `; - } else if (c !== null) { - r += `${key} (${this.clausesToSql(c)} `; - } - }); - // Remove all of the consecutive spaces to make things more readable when debugging - return r.trim().replace(/\s+/g, ' '); - } - // Returns a table name found in the FROM phrase // FROM phrases might contain more than just the table name private getTableName(fromPhrase: string): string { @@ -108,30 +58,6 @@ export class AdHocFilter { .split(' ')[0] .replace(/(;|\(|\))/g, ''); } - - // Creates a statement with all the keywords to preserve the keyword order - private createStatement() { - let clauses = new Map(); - clauses.set('WITH', null); - clauses.set('SELECT', null); - clauses.set('DISTINCT', null); - clauses.set('FROM', null); - clauses.set('SAMPLE', null); - clauses.set('JOIN', null); - clauses.set('PREWHERE', null); - clauses.set('WHERE', null); - clauses.set('GROUP BY', null); - clauses.set('LIMIT BY', null); - clauses.set('HAVING', null); - clauses.set('LIMIT', null); - clauses.set('OFFSET', null); - clauses.set('UNION', null); - clauses.set('INTERSECT', null); - clauses.set('EXCEPT', null); - clauses.set('INTO OUTFILE', null); - clauses.set('FORMAT', null); - return clauses; - } } export type AdHocVariableFilter = { @@ -140,5 +66,3 @@ export type AdHocVariableFilter = { value: string; condition: string; }; - -type Clause = string | Map | null; diff --git a/src/data/ast.ts b/src/data/ast.ts new file mode 100644 index 00000000..d18583e8 --- /dev/null +++ b/src/data/ast.ts @@ -0,0 +1,77 @@ +export type Clause = string | Map | null; + +export default function sqlToAST(sql: string): Map { + const ast = createStatement(); + const re = + /\b(WITH|SELECT|DISTINCT|FROM|SAMPLE|JOIN|PREWHERE|WHERE|GROUP BY|LIMIT BY|HAVING|LIMIT|OFFSET|UNION|INTERSECT|EXCEPT|INTO OUTFILE|FORMAT)\b/gi; + let bracketCount = 0; + let lastBracketCount = 0; + let lastNode = ''; + let bracketPhrase = ''; + let regExpArray: RegExpExecArray | null; + while ((regExpArray = re.exec(sql)) !== null) { + // Sets foundNode to a SQL keyword from the regular expression + const foundNode = regExpArray[0].toUpperCase(); + const phrase = sql.substring(re.lastIndex, sql.length).split(re)[0]; + // If there is a greater number of open brackets than closed, + // add the the bracket phrase that will eventually be added the the last node + if (bracketCount > 0) { + bracketPhrase += foundNode + phrase; + } else { + ast.set(foundNode, phrase); + lastNode = foundNode; + } + bracketCount += (phrase.match(/\(/g) || []).length; + bracketCount -= (phrase.match(/\)/g) || []).length; + if (bracketCount <= 0 && lastBracketCount > 0) { + // The phrase brackets is complete + // If it is a FROM phrase lets make a branch node + // If it is anything else lets make a leaf node + if (lastNode === 'FROM') { + ast.set(lastNode, sqlToAST(bracketPhrase)); + } else { + const p = (ast.get(lastNode) as string).concat(bracketPhrase); + ast.set(lastNode, p); + } + } + lastBracketCount = bracketCount; + } + return ast; +} + +export function clausesToSql(ast: Map): string { + let r = ''; + ast.forEach((c: Clause, key: string) => { + if (typeof c === 'string') { + r += `${key} ${c.trim()} `; + } else if (c !== null) { + r += `${key} (${clausesToSql(c)} `; + } + }); + // Remove all of the consecutive spaces to make things more readable when debugging + return r.trim().replace(/\s+/g, ' '); +} + +// Creates a statement with all the keywords to preserve the keyword order +function createStatement() { + const clauses = new Map(); + clauses.set('WITH', null); + clauses.set('SELECT', null); + clauses.set('DISTINCT', null); + clauses.set('FROM', null); + clauses.set('SAMPLE', null); + clauses.set('JOIN', null); + clauses.set('PREWHERE', null); + clauses.set('WHERE', null); + clauses.set('GROUP BY', null); + clauses.set('LIMIT BY', null); + clauses.set('HAVING', null); + clauses.set('LIMIT', null); + clauses.set('OFFSET', null); + clauses.set('UNION', null); + clauses.set('INTERSECT', null); + clauses.set('EXCEPT', null); + clauses.set('INTO OUTFILE', null); + clauses.set('FORMAT', null); + return clauses; +} diff --git a/src/types.ts b/src/types.ts index 4536e0da..e9697789 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,3 +24,8 @@ export interface CHSecureConfig { tlsClientCert?: string; tlsClientKey?: string; } + +export enum Format { + TABLE = 1, + TIMESERIES = 2, +}