Skip to content

Commit

Permalink
Merge pull request #27 from grafana/adhoc-filters
Browse files Browse the repository at this point in the history
Adhoc filters!!!
  • Loading branch information
bossinc authored Jan 14, 2022
2 parents 5a00c39 + 80d8157 commit d23eabb
Show file tree
Hide file tree
Showing 5 changed files with 259 additions and 21 deletions.
28 changes: 13 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,6 @@ This plugin is currently in Beta development and subject to change.

The **Installation** tab is displayed.

### Verify that the plugin is installed

1. In Grafana Enterprise from the left-hand menu, navigate to **Configuration** > **Data sources**.
2. From the top-right corner, click the **Add data source** button.
3. Search for ClickHouse in the search field, and hover over the ClickHouse search result.
4. Click the **Select** button for ClickHouse.
* If you can click the **Select** button, then it is installed.
* If the button is missing or disabled, then the plugin is not installed. Check to see if your Grafana Enterprise license is valid, and reinstall the plugin. If you still need help, [contact Grafana Labs](https://grafana.com/contact).

## Configure ClickHouse for the data source

Expand Down Expand Up @@ -89,13 +81,13 @@ FROM test_data
WHERE $__timeFilter(date_time)
```

| Macro example | Description |
| -- | --|
| *$__timeFilter(dataRow)* | Will be replaced by a time range filter using the specified name. |
| *$__from* | Will be replaced by the start of the currently active time range filter selection. |
| *$__to* | Will be replaced by the end of the currently active time range filter selection. |
| *$__table* | Will be replaced by the table in use. |
| *$__column* | Will be replaced by the column in use. |
| Macro example | Description |
| ------------------------ | ---------------------------------------------------------------------------------- |
| *$__timeFilter(dataRow)* | Will be replaced by a time range filter using the specified name. |
| *$__from* | Will be replaced by the start of the currently active time range filter selection. |
| *$__to* | Will be replaced by the end of the currently active time range filter selection. |
| *$__table* | Will be replaced by the table in use. |
| *$__column* | Will be replaced by the column in use. |

The plugin also supports notation using braces {}. Use this notation when queries are needed inside parameters.

Expand All @@ -113,6 +105,12 @@ Follow these [instructions](https://grafana.com/docs/grafana/latest/dashboards/e

Imported dashboards can be found in Configuration > Data Sources > select your ClickHouse data source > select the Dashboards tab to see available pre-made dashboards.

### Using Ad-Hoc Filters

A second helper variable must be created in addition to the standard ad-hoc filter type variable of any name. It should be a `constant` type named `clickhouse_adhoc_query` and contain a valid ClickHouse query. The query results will be used to populate your ad-hoc filter's selectable filters. You may choose to hide this variable from view as it serves no further purpose.

If `clickhouse_adhoc_query` is set to `SELECT DISTINCT machine_name FROM mgbench.logs1` you would be able to select which machine names are filtered for in the dashboard.

## Learn more

* Add [Annotations](https://grafana.com/docs/grafana/latest/dashboards/annotations/).
Expand Down
7 changes: 3 additions & 4 deletions src/data/CHDatasource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { mockDatasource } from '__mocks__/datasource';
import { CHQuery } from 'types';

interface InstanceConfig {
adHocQuery?: string;
queryResponse: {} | [];
}

Expand All @@ -14,7 +13,7 @@ jest.mock('@grafana/runtime', () => ({
getTemplateSrv: () => templateSrvMock,
}));

const createInstance = ({ adHocQuery, queryResponse }: Partial<InstanceConfig> = {}) => {
const createInstance = ({ queryResponse }: Partial<InstanceConfig> = {}) => {
const instance = mockDatasource;
jest.spyOn(instance, 'query').mockImplementation((request) => of({ data: [toDataFrame(queryResponse ?? [])] }));
return instance;
Expand All @@ -28,7 +27,7 @@ describe('ClickHouseDatasource', () => {
fields: [{ name: 'field', type: 'number', values: mockedValues }],
};
const expectedValues = mockedValues.map((v) => ({ text: v, value: v }));
const values = await createInstance({ queryResponse }).metricFindQuery({ rawSql: 'mock' } as CHQuery);
const values = await createInstance({ queryResponse }).metricFindQuery('mock');
expect(values).toEqual(expectedValues);
});

Expand All @@ -42,7 +41,7 @@ describe('ClickHouseDatasource', () => {
],
};
const expectedValues = mockedValues.map((v, i) => ({ text: v, value: mockedIds[i] }));
const values = await createInstance({ queryResponse }).metricFindQuery({ rawSql: 'mock' } as CHQuery);
const values = await createInstance({ queryResponse }).metricFindQuery('mock');
expect(values).toEqual(expectedValues);
});
});
Expand Down
49 changes: 47 additions & 2 deletions src/data/CHDatasource.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@
import {
ArrayDataFrame,
DataFrame,
DataQueryRequest,
DataQueryResponse,
DataSourceInstanceSettings,
MetricFindValue,
ScopedVars,
vectorator,
} from '@grafana/data';
import { DataSourceWithBackend, getTemplateSrv } from '@grafana/runtime';
import { DataSourceWithBackend, getTemplateSrv, TemplateSrv } from '@grafana/runtime';
import { CHConfig, CHQuery } from '../types';
import { AdHocFilter } from './adHocFilter';
import { isString } from 'lodash';

export class Datasource extends DataSourceWithBackend<CHQuery, CHConfig> {
// This enables default annotation support for 7.2+
annotations = {};
settings: DataSourceInstanceSettings<CHConfig>;
templateSrv: TemplateSrv;
adHocFilter: AdHocFilter;
skipAdHocFilter = false;

constructor(instanceSettings: DataSourceInstanceSettings<CHConfig>) {
super(instanceSettings);
this.settings = instanceSettings;
this.templateSrv = getTemplateSrv();
this.adHocFilter = new AdHocFilter();
}

async metricFindQuery(query: CHQuery | string) {
Expand All @@ -38,9 +46,15 @@ export class Datasource extends DataSourceWithBackend<CHQuery, CHConfig> {
}

applyTemplateVariables(query: CHQuery, scoped: ScopedVars): CHQuery {
let rawQuery = query.rawSql || '';
// we want to skip applying ad hoc filters when we are getting values for ad hoc filters
if (!this.skipAdHocFilter) {
rawQuery = this.adHocFilter.apply(rawQuery, (this.templateSrv as any)?.getAdhocFilters(this.name));
}
this.skipAdHocFilter = false;
return {
...query,
rawSql: this.replace(query.rawSql || '', scoped) || '',
rawSql: this.replace(rawQuery, scoped) || '',
};
}

Expand Down Expand Up @@ -97,4 +111,35 @@ export class Datasource extends DataSourceWithBackend<CHQuery, CHConfig> {
}
return vectorator(frame?.fields[0]?.values).map((text) => text);
}

async getTagKeys(): Promise<MetricFindValue[]> {
const frame = await this.fetchTags();
return frame.fields.map((f) => ({ text: f.name }));
}

async getTagValues({ key }: any): Promise<MetricFindValue[]> {
const frame = await this.fetchTags();
const field = frame.fields.find((f) => f.name === key);
if (field) {
// Convert to string to avoid https://github.com/grafana/grafana/issues/12209
return vectorator(field.values)
.filter((value) => value !== null)
.map((value) => {
return { text: String(value) };
});
}
return [];
}

async fetchTags(): Promise<DataFrame> {
// @todo https://github.com/grafana/grafana/issues/13109
const rawSql = this.templateSrv.replace('$clickhouse_adhoc_query');
if (rawSql === '$clickhouse_adhoc_query') {
return new ArrayDataFrame([]);
} else {
this.skipAdHocFilter = true;
this.adHocFilter.setTargetTable(rawSql);
return await this.runQuery({ rawSql });
}
}
}
52 changes: 52 additions & 0 deletions src/data/adHocFilter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { AdHocFilter, AdHocVariableFilter } from './adHocFilter';

describe('AdHocManager', () => {
it('apply ad hoc filter with no inner query and existing WHERE', () => {
const ahm = new AdHocFilter();
ahm.setTargetTable('SELECT * FROM table');
const val = ahm.apply('SELECT stuff FROM table WHERE col = test', [
{ key: 'key', operator: '=', value: 'val' },
{ key: 'keyNum', operator: '=', value: '123' },
] as AdHocVariableFilter[]);
expect(val).toEqual(`SELECT stuff FROM table WHERE key = 'val' AND keyNum = 123 AND col = test`);
});
it('apply ad hoc filter with no inner query and no existing WHERE', () => {
const ahm = new AdHocFilter();
ahm.setTargetTable('SELECT * FROM table');
const val = ahm.apply('SELECT stuff FROM table', [
{ key: 'key', operator: '=', value: 'val' },
{ key: 'keyNum', operator: '=', value: '123' },
] as AdHocVariableFilter[]);
expect(val).toEqual(`SELECT stuff FROM table WHERE key = 'val' AND keyNum = 123`);
});
it('apply ad hoc filter with an inner query without existing WHERE', () => {
const ahm = new AdHocFilter();
ahm.setTargetTable('SELECT * FROM table');
const val = ahm.apply(`SELECT stuff FROM (SELECT * FROM table) as r GROUP BY s ORDER BY s`, [
{ key: 'key', operator: '=', value: 'val' },
{ key: 'keyNum', operator: '=', value: '123' },
] as AdHocVariableFilter[]);
expect(val).toEqual(
`SELECT stuff FROM (SELECT * FROM table WHERE key = 'val' AND keyNum = 123 ) as r GROUP BY s ORDER BY s`
);
});
it('apply ad hoc filter with an inner query with existing WHERE', () => {
const ahm = new AdHocFilter();
ahm.setTargetTable('SELECT * FROM table');
const val = ahm.apply(`SELECT stuff FROM (SELECT * FROM table WHERE col = test) as r GROUP BY s ORDER BY s`, [
{ key: 'key', operator: '=', value: 'val' },
{ key: 'keyNum', operator: '=', value: '123' },
] as AdHocVariableFilter[]);
expect(val).toEqual(
`SELECT stuff FROM (SELECT * FROM table WHERE key = 'val' AND keyNum = 123 AND col = test) as r GROUP BY s ORDER BY s`
);
});
it('does not apply ad hoc filter when the target table is not in the query', () => {
const ahm = new AdHocFilter();
ahm.setTargetTable('SELECT * FROM table2');
const val = ahm.apply('select stuff from table', [
{ key: 'key', operator: '=', value: 'val' },
] as AdHocVariableFilter[]);
expect(val).toEqual('SELECT stuff FROM table');
});
});
144 changes: 144 additions & 0 deletions src/data/adHocFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
export class AdHocFilter {
private _targetTable = '';

setTargetTable(query: string) {
const fromSplit = query.split(/\b\FROM\b/i);
if (fromSplit.length === 2) {
this._targetTable = this.getTableName(fromSplit[1]);
}
}

apply(sql: string, adHocFilters: AdHocVariableFilter[]): string {
if (this._targetTable === '' || sql === '' || !adHocFilters || adHocFilters.length === 0) {
return sql;
}
let whereClause = '';
for (let i = 0; i < adHocFilters.length; i++) {
const filter = adHocFilters[i];
const v = isNaN(Number(filter.value)) ? `'${filter.value}'` : Number(filter.value);
whereClause += ` ${filter.key} ${filter.operator} ${v} `;
if (i !== adHocFilters.length - 1) {
whereClause += filter.condition ? filter.condition : 'AND';
}
}
// Semicolons are not required and cause problems when building the SQL
sql = sql.replace(';', '');
const ast = this.sqlToAST(sql);
const filteredAST = this.applyFiltersToAST(ast, whereClause);
return this.clausesToSql(filteredAST);
}

private sqlToAST(sql: string): Map<string, Clause> {
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;
}

private applyFiltersToAST(ast: Map<string, Clause>, whereClause: string): Map<string, Clause> {
if (typeof ast.get('FROM') === 'string') {
const fromPhrase = ast.get('FROM')!.toString().trim();
const tableName = this.getTableName(fromPhrase);
if (tableName !== this._targetTable) {
return ast;
}
// If there is no defined WHERE clause create one
// Else add an ad hoc filter to the existing WHERE clause
if (ast.get('WHERE') === null) {
ast.set('FROM', ` ${tableName} `);
// set where clause to ad hoc filter and add the remaining part of the from phrase to the new WHERE phrase
return ast.set('WHERE', `${whereClause} ${fromPhrase.substring(tableName.length)}`);
}
return ast.set('WHERE', `${whereClause} AND ${ast.get('WHERE')}`);
}
const fromAST = this.applyFiltersToAST(ast.get('FROM')! as Map<string, Clause>, whereClause);
return ast.set('FROM', fromAST);
}

private clausesToSql(ast: Map<string, Clause>): 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 {
return fromPhrase
.trim()
.split(' ')[0]
.replace(/(;|\(|\))/g, '');
}

// Creates a statement with all the keywords to preserve the keyword order
private createStatement() {
let clauses = new Map<string, Clause>();
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 = {
key: string;
operator: string;
value: string;
condition: string;
};

type Clause = string | Map<string, Clause> | null;

0 comments on commit d23eabb

Please sign in to comment.