From 11edc4e41a7dbe2519dc1e7743f7e0f42042462e Mon Sep 17 00:00:00 2001 From: Maciej Bodek Date: Tue, 25 Jun 2024 15:03:44 +0200 Subject: [PATCH 1/6] Enclose variables in quotes --- src/components/queryBuilder/utils.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/components/queryBuilder/utils.ts b/src/components/queryBuilder/utils.ts index 32e7e96..e2b3413 100644 --- a/src/components/queryBuilder/utils.ts +++ b/src/components/queryBuilder/utils.ts @@ -808,9 +808,6 @@ function formatStringValue(currentFilter: string): string { if (Array.isArray(currentFilter)) { currentFilter = currentFilter[0]; } - if (currentFilter.startsWith('$')) { - return ` ${currentFilter || ''}`; - } return ` '${currentFilter || ''}'`; } From 5da13fe4f57dd8eb4107192bc0a99e52e3d12e85 Mon Sep 17 00:00:00 2001 From: Maciej Bodek Date: Wed, 26 Jun 2024 18:06:50 +0200 Subject: [PATCH 2/6] Escape variables in filters for single and multi values --- src/components/QueryTypeSwitcher.tsx | 15 +- src/components/queryBuilder/utils.spec.ts | 922 ++++++++++++---------- src/components/queryBuilder/utils.ts | 59 +- src/views/QuestDBQueryEditor.tsx | 9 +- 4 files changed, 572 insertions(+), 433 deletions(-) diff --git a/src/components/QueryTypeSwitcher.tsx b/src/components/QueryTypeSwitcher.tsx index 5f5e08b..cbbe99d 100644 --- a/src/components/QueryTypeSwitcher.tsx +++ b/src/components/QueryTypeSwitcher.tsx @@ -1,11 +1,12 @@ import React, { useState } from 'react'; -import { SelectableValue } from '@grafana/data'; +import { SelectableValue, VariableWithMultiSupport } from '@grafana/data'; import { RadioButtonGroup, ConfirmModal } from '@grafana/ui'; import { getQueryOptionsFromSql, getSQLFromQueryOptions } from './queryBuilder/utils'; import { selectors } from './../selectors'; import { QuestDBQuery, QueryType, defaultBuilderQuery, SqlBuilderOptions, QuestDBSQLQuery } from 'types'; import { isString } from 'lodash'; -import {Datasource} from "../data/QuestDbDatasource"; +import { Datasource } from '../data/QuestDbDatasource'; +import { getTemplateSrv } from '@grafana/runtime'; interface QueryTypeSwitcherProps { query: QuestDBQuery; @@ -26,6 +27,8 @@ export const QueryTypeSwitcher = ({ query, onChange, datasource }: QueryTypeSwit { label: queryTypeLabels.QueryBuilder, value: QueryType.Builder }, ]; const [errorMessage, setErrorMessage] = useState(''); + const templateVars = getTemplateSrv().getVariables() as VariableWithMultiSupport[]; + async function onQueryTypeChange(queryType: QueryType, confirm = false) { if (query.queryType === QueryType.SQL && queryType === QueryType.Builder && !confirm) { const queryOptionsFromSql = await getQueryOptionsFromSql(query.rawSql); @@ -43,7 +46,9 @@ export const QueryTypeSwitcher = ({ query, onChange, datasource }: QueryTypeSwit builderOptions = query.builderOptions; break; case QueryType.SQL: - builderOptions = (await getQueryOptionsFromSql(query.rawSql, datasource) as SqlBuilderOptions) || defaultBuilderQuery.builderOptions; + builderOptions = + ((await getQueryOptionsFromSql(query.rawSql, datasource)) as SqlBuilderOptions) || + defaultBuilderQuery.builderOptions; break; default: builderOptions = defaultBuilderQuery.builderOptions; @@ -53,13 +58,13 @@ export const QueryTypeSwitcher = ({ query, onChange, datasource }: QueryTypeSwit onChange({ ...query, queryType, - rawSql: getSQLFromQueryOptions(builderOptions), + rawSql: getSQLFromQueryOptions(builderOptions, templateVars), meta: { builderOptions }, format: query.format, selectedFormat: query.selectedFormat, }); } else if (queryType === QueryType.Builder) { - onChange({ ...query, queryType, rawSql: getSQLFromQueryOptions(builderOptions), builderOptions }); + onChange({ ...query, queryType, rawSql: getSQLFromQueryOptions(builderOptions, templateVars), builderOptions }); } } } diff --git a/src/components/queryBuilder/utils.spec.ts b/src/components/queryBuilder/utils.spec.ts index 3e81466..1b0fa45 100644 --- a/src/components/queryBuilder/utils.spec.ts +++ b/src/components/queryBuilder/utils.spec.ts @@ -1,58 +1,58 @@ import { - BuilderMetricFieldAggregation, - BuilderMode, - FilterOperator, FullField, - OrderByDirection, - SampleByAlignToMode + BuilderMetricFieldAggregation, + BuilderMode, + FilterOperator, + FullField, + OrderByDirection, + SampleByAlignToMode, } from 'types'; -import {getQueryOptionsFromSql, getSQLFromQueryOptions, isDateType, isNumberType, isTimestampType} from './utils'; -import {Datasource} from "../../data/QuestDbDatasource"; -import {PluginType} from "@grafana/data"; +import { getQueryOptionsFromSql, getSQLFromQueryOptions, isDateType, isNumberType, isTimestampType } from './utils'; +import { Datasource } from '../../data/QuestDbDatasource'; +import { PluginType } from '@grafana/data'; -let mockTimeField = ""; +let mockTimeField = ''; const mockDatasource = new Datasource({ - id: 1, - uid: 'questdb_ds', - type: 'questdb-questdb-datasource', + id: 1, + uid: 'questdb_ds', + type: 'questdb-questdb-datasource', + name: 'QuestDB', + jsonData: { + server: 'foo.com', + port: 443, + username: 'user', + }, + readOnly: true, + access: 'direct', + meta: { + id: 'questdb-questdb-datasource', name: 'QuestDB', - jsonData: { - server: 'foo.com', - port: 443, - username: 'user' - }, - readOnly: true, - access: 'direct', - meta: { - id: 'questdb-questdb-datasource', - name: 'QuestDB', - type: PluginType.datasource, - module: '', - baseUrl: '', - info: { - description: '', - screenshots: [], - updated: '', - version: '', - logos: { - small: '', - large: '', - }, - author: { - name: '', - }, - links: [], - }, + type: PluginType.datasource, + module: '', + baseUrl: '', + info: { + description: '', + screenshots: [], + updated: '', + version: '', + logos: { + small: '', + large: '', + }, + author: { + name: '', + }, + links: [], }, + }, }); - -mockDatasource.fetchFields = async function(table: string): Promise { - if (mockTimeField.length > 0){ - return [{name:mockTimeField, label:mockTimeField, designated: true, type: "timestamp", picklistValues: []}]; - } else { - return []; - } +mockDatasource.fetchFields = async function (table: string): Promise { + if (mockTimeField.length > 0) { + return [{ name: mockTimeField, label: mockTimeField, designated: true, type: 'timestamp', picklistValues: [] }]; + } else { + return []; + } }; describe('isDateType', () => { @@ -104,139 +104,175 @@ describe('isNumberType', () => { }); describe('Utils: getSQLFromQueryOptions and getQueryOptionsFromSql', () => { - it( 'handles a table without a database', test( 'SELECT name FROM "tab"', { - mode: BuilderMode.List, - table: 'tab', - fields: ['name'], - timeField: "", - })); - - it('handles a table with a dot', test( 'SELECT name FROM "foo.bar"', { - mode: BuilderMode.List, - table: 'foo.bar', - fields: ['name'], - timeField: "", - })); - - it( 'handles 2 fields', test( 'SELECT field1, field2 FROM "tab"', { - mode: BuilderMode.List, - table: 'tab', - fields: ['field1', 'field2'], - timeField: "", - })); - - it( 'handles a limit wih upper bound', test( 'SELECT field1, field2 FROM "tab" LIMIT 20', { - mode: BuilderMode.List, - table: 'tab', - fields: ['field1', 'field2'], - limit: '20', - timeField: "", - })); - - it( 'handles a limit with lower and upper bound', test( 'SELECT field1, field2 FROM "tab" LIMIT 10, 20', { + it( + 'handles a table without a database', + test('SELECT name FROM "tab"', { + mode: BuilderMode.List, + table: 'tab', + fields: ['name'], + timeField: '', + }) + ); + + it( + 'handles a table with a dot', + test('SELECT name FROM "foo.bar"', { + mode: BuilderMode.List, + table: 'foo.bar', + fields: ['name'], + timeField: '', + }) + ); + + it( + 'handles 2 fields', + test('SELECT field1, field2 FROM "tab"', { mode: BuilderMode.List, table: 'tab', fields: ['field1', 'field2'], - limit: '10, 20', - timeField: "", - })); + timeField: '', + }) + ); - it( 'handles empty orderBy array', test( - 'SELECT field1, field2 FROM "tab" LIMIT 20', - { + it( + 'handles a limit wih upper bound', + test('SELECT field1, field2 FROM "tab" LIMIT 20', { mode: BuilderMode.List, table: 'tab', fields: ['field1', 'field2'], - orderBy: [], - limit: 20, - timeField: "", - }, - false - )); - - it( 'handles order by', test( 'SELECT field1, field2 FROM "tab" ORDER BY field1 ASC LIMIT 20', { - mode: BuilderMode.List, - table: 'tab', - fields: ['field1', 'field2'], - orderBy: [{ name: 'field1', dir: OrderByDirection.ASC }], - limit: '20', - timeField: "", - })); - - it( 'handles no select', test( - 'SELECT FROM "tab"', - { - mode: BuilderMode.Aggregate, + limit: '20', + timeField: '', + }) + ); + + it( + 'handles a limit with lower and upper bound', + test('SELECT field1, field2 FROM "tab" LIMIT 10, 20', { + mode: BuilderMode.List, table: 'tab', - fields: [], - metrics: [], - timeField: "", - }, - false - )); - - it( 'does not escape * field', test( - 'SELECT * FROM "tab"', - { + fields: ['field1', 'field2'], + limit: '10, 20', + timeField: '', + }) + ); + + it( + 'handles empty orderBy array', + test( + 'SELECT field1, field2 FROM "tab" LIMIT 20', + { + mode: BuilderMode.List, + table: 'tab', + fields: ['field1', 'field2'], + orderBy: [], + limit: 20, + timeField: '', + }, + false + ) + ); + + it( + 'handles order by', + test('SELECT field1, field2 FROM "tab" ORDER BY field1 ASC LIMIT 20', { + mode: BuilderMode.List, + table: 'tab', + fields: ['field1', 'field2'], + orderBy: [{ name: 'field1', dir: OrderByDirection.ASC }], + limit: '20', + timeField: '', + }) + ); + + it( + 'handles no select', + test( + 'SELECT FROM "tab"', + { + mode: BuilderMode.Aggregate, + table: 'tab', + fields: [], + metrics: [], + timeField: '', + }, + false + ) + ); + + it( + 'does not escape * field', + test( + 'SELECT * FROM "tab"', + { + mode: BuilderMode.Aggregate, + table: 'tab', + fields: ['*'], + metrics: [], + timeField: '', + }, + false + ) + ); + + it( + 'handles aggregation function', + test('SELECT sum(field1) FROM "tab"', { mode: BuilderMode.Aggregate, table: 'tab', - fields: ['*'], - metrics: [], - timeField: "", - }, - false - )); - - it( 'handles aggregation function', test( 'SELECT sum(field1) FROM "tab"', { - mode: BuilderMode.Aggregate, - table: 'tab', - fields: [], - metrics: [{ field: 'field1', aggregation: BuilderMetricFieldAggregation.Sum }], - timeField: "", - })); - - it( 'handles aggregation with alias', test( 'SELECT sum(field1) total_records FROM "tab"', { - mode: BuilderMode.Aggregate, - table: 'tab', - fields: [], - metrics: [{ field: 'field1', aggregation: BuilderMetricFieldAggregation.Sum, alias: 'total_records' }], - timeField: "", - })); - - it( 'handles 2 aggregations', test( - 'SELECT sum(field1) total_records, count(field2) total_records2 FROM "tab"', - { + fields: [], + metrics: [{ field: 'field1', aggregation: BuilderMetricFieldAggregation.Sum }], + timeField: '', + }) + ); + + it( + 'handles aggregation with alias', + test('SELECT sum(field1) total_records FROM "tab"', { mode: BuilderMode.Aggregate, table: 'tab', fields: [], - metrics: [ - { field: 'field1', aggregation: BuilderMetricFieldAggregation.Sum, alias: 'total_records' }, - { field: 'field2', aggregation: BuilderMetricFieldAggregation.Count, alias: 'total_records2' }, - ], - timeField: "", - } - )); - - it( 'handles aggregation with groupBy', test( - 'SELECT field3, sum(field1) total_records, count(field2) total_records2 FROM "tab" GROUP BY field3', - { + metrics: [{ field: 'field1', aggregation: BuilderMetricFieldAggregation.Sum, alias: 'total_records' }], + timeField: '', + }) + ); + + it( + 'handles 2 aggregations', + test('SELECT sum(field1) total_records, count(field2) total_records2 FROM "tab"', { mode: BuilderMode.Aggregate, table: 'tab', - database: 'db', fields: [], metrics: [ { field: 'field1', aggregation: BuilderMetricFieldAggregation.Sum, alias: 'total_records' }, { field: 'field2', aggregation: BuilderMetricFieldAggregation.Count, alias: 'total_records2' }, ], - groupBy: ['field3'], - timeField: "", - }, - false - )); - - it( 'handles aggregation with groupBy with fields having group by value', test( - 'SELECT field3, sum(field1) total_records, count(field2) total_records2 FROM "tab" GROUP BY field3', - { + timeField: '', + }) + ); + + it( + 'handles aggregation with groupBy', + test( + 'SELECT field3, sum(field1) total_records, count(field2) total_records2 FROM "tab" GROUP BY field3', + { + mode: BuilderMode.Aggregate, + table: 'tab', + database: 'db', + fields: [], + metrics: [ + { field: 'field1', aggregation: BuilderMetricFieldAggregation.Sum, alias: 'total_records' }, + { field: 'field2', aggregation: BuilderMetricFieldAggregation.Count, alias: 'total_records2' }, + ], + groupBy: ['field3'], + timeField: '', + }, + false + ) + ); + + it( + 'handles aggregation with groupBy with fields having group by value', + test('SELECT field3, sum(field1) total_records, count(field2) total_records2 FROM "tab" GROUP BY field3', { mode: BuilderMode.Aggregate, table: 'tab', fields: ['field3'], @@ -245,33 +281,36 @@ describe('Utils: getSQLFromQueryOptions and getQueryOptionsFromSql', () => { { field: 'field2', aggregation: BuilderMetricFieldAggregation.Count, alias: 'total_records2' }, ], groupBy: ['field3'], - timeField: "", - } - )); - - it( 'handles aggregation with group by and order by', test( - 'SELECT StageName, Type, count(Id) count_of, sum(Amount) FROM "tab" GROUP BY StageName, Type ORDER BY count(Id) DESC, StageName ASC', - { - mode: BuilderMode.Aggregate, - table: 'tab', - fields: [], - metrics: [ - { field: 'Id', aggregation: BuilderMetricFieldAggregation.Count, alias: 'count_of' }, - { field: 'Amount', aggregation: BuilderMetricFieldAggregation.Sum }, - ], - groupBy: ['StageName', 'Type'], - orderBy: [ - { name: 'count(Id)', dir: OrderByDirection.DESC }, - { name: 'StageName', dir: OrderByDirection.ASC }, - ], - timeField: "", - }, - false - )); - - it( 'handles aggregation with a IN filter', test( - `SELECT count(id) FROM "tab" WHERE stagename IN ('Deal Won', 'Deal Lost' )`, - { + timeField: '', + }) + ); + + it( + 'handles aggregation with group by and order by', + test( + 'SELECT StageName, Type, count(Id) count_of, sum(Amount) FROM "tab" GROUP BY StageName, Type ORDER BY count(Id) DESC, StageName ASC', + { + mode: BuilderMode.Aggregate, + table: 'tab', + fields: [], + metrics: [ + { field: 'Id', aggregation: BuilderMetricFieldAggregation.Count, alias: 'count_of' }, + { field: 'Amount', aggregation: BuilderMetricFieldAggregation.Sum }, + ], + groupBy: ['StageName', 'Type'], + orderBy: [ + { name: 'count(Id)', dir: OrderByDirection.DESC }, + { name: 'StageName', dir: OrderByDirection.ASC }, + ], + timeField: '', + }, + false + ) + ); + + it( + 'handles aggregation with a IN filter', + test(`SELECT count(id) FROM "tab" WHERE stagename IN ('Deal Won', 'Deal Lost' )`, { mode: BuilderMode.Aggregate, table: 'tab', fields: [], @@ -284,13 +323,13 @@ describe('Utils: getSQLFromQueryOptions and getQueryOptionsFromSql', () => { type: 'string', }, ], - timeField: "", - } - )); + timeField: '', + }) + ); - it( 'handles aggregation with a NOT IN filter', test( - `SELECT count(id) FROM "tab" WHERE stagename NOT IN ('Deal Won', 'Deal Lost' )`, - { + it( + 'handles aggregation with a NOT IN filter', + test(`SELECT count(id) FROM "tab" WHERE stagename NOT IN ('Deal Won', 'Deal Lost' )`, { mode: BuilderMode.Aggregate, table: 'tab', fields: [], @@ -303,27 +342,31 @@ describe('Utils: getSQLFromQueryOptions and getQueryOptionsFromSql', () => { type: 'string', }, ], - timeField: "", - } - )); - - it( 'handles $__fromTime and $__toTime filters', test( - `SELECT id FROM "tab" WHERE tstmp > $__fromTime AND tstmp < $__toTime`, - { + timeField: '', + }) + ); + + it( + 'handles $__fromTime and $__toTime filters', + test( + `SELECT id FROM "tab" WHERE tstmp > $__fromTime AND tstmp < $__toTime`, + { mode: BuilderMode.List, table: 'tab', fields: ['id'], filters: [ - { key: 'tstmp', operator: '>', value: 'GRAFANA_START_TIME', type: 'timestamp', }, - { condition: 'AND', key: 'tstmp', operator: '<', value: 'GRAFANA_END_TIME', type: 'timestamp', }, + { key: 'tstmp', operator: '>', value: 'GRAFANA_START_TIME', type: 'timestamp' }, + { condition: 'AND', key: 'tstmp', operator: '<', value: 'GRAFANA_END_TIME', type: 'timestamp' }, ], - timeField: "", - }, true - )); - - it( 'handles aggregation with $__timeFilter', test( - `SELECT count(id) FROM "tab" WHERE $__timeFilter(createdon)`, - { + timeField: '', + }, + true + ) + ); + + it( + 'handles aggregation with $__timeFilter', + test(`SELECT count(id) FROM "tab" WHERE $__timeFilter(createdon)`, { mode: BuilderMode.Aggregate, table: 'tab', fields: [], @@ -335,260 +378,317 @@ describe('Utils: getSQLFromQueryOptions and getQueryOptionsFromSql', () => { type: 'timestamp', }, ], - timeField: "", - } - )); + timeField: '', + }) + ); - it( 'handles aggregation with negated $__timeFilter', test( - `SELECT count(id) FROM "tab" WHERE NOT ( $__timeFilter(closedate) )`, + it( + 'handles aggregation with negated $__timeFilter', + test(`SELECT count(id) FROM "tab" WHERE NOT ( $__timeFilter(closedate) )`, { + mode: BuilderMode.Aggregate, + table: 'tab', + fields: [], + metrics: [{ field: 'id', aggregation: BuilderMetricFieldAggregation.Count }], + filters: [ + { + key: 'closedate', + operator: FilterOperator.OutsideGrafanaTimeRange, + type: 'timestamp', + }, + ], + timeField: '', + }) + ); + + it( + 'handles latest on one column ', + test( + 'SELECT sym, value FROM "tab" LATEST ON tstmp PARTITION BY sym', { - mode: BuilderMode.Aggregate, - table: 'tab', - fields: [], - metrics: [{field: 'id', aggregation: BuilderMetricFieldAggregation.Count,}], - filters: [ - { - key: 'closedate', - operator: FilterOperator.OutsideGrafanaTimeRange, - type: 'timestamp', - }, - ], - timeField: "", - } - )); - - it( 'handles latest on one column ', test( - 'SELECT sym, value FROM "tab" LATEST ON tstmp PARTITION BY sym', - { mode: BuilderMode.List, table: 'tab', fields: ['sym', 'value'], - timeField: "tstmp", + timeField: 'tstmp', partitionBy: ['sym'], filters: [], - }, - false - )); - - it( 'handles latest on two columns ', test( - 'SELECT s1, s2, value FROM "tab" LATEST ON tstmp PARTITION BY s1, s2 ORDER BY time ASC', - { - mode: BuilderMode.List, - table: 'tab', - fields: ['s1', 's2', 'value'], - timeField: "tstmp", - partitionBy: ['s1', 's2'], - filters: [], - orderBy: [{name: "time", dir: "ASC"}] - }, - false - )); - - it( 'handles sample by align to calendar', test( - 'SELECT tstmp as time, count(*), first(str) FROM "tab" WHERE $__timeFilter(tstmp) SAMPLE BY $__sampleByInterval FILL ( null, 10 ) ALIGN TO CALENDAR', - { + }, + false + ) + ); + + it( + 'handles latest on two columns ', + test( + 'SELECT s1, s2, value FROM "tab" LATEST ON tstmp PARTITION BY s1, s2 ORDER BY time ASC', + { + mode: BuilderMode.List, + table: 'tab', + fields: ['s1', 's2', 'value'], + timeField: 'tstmp', + partitionBy: ['s1', 's2'], + filters: [], + orderBy: [{ name: 'time', dir: 'ASC' }], + }, + false + ) + ); + + it( + 'handles sample by align to calendar', + test( + 'SELECT tstmp as time, count(*), first(str) FROM "tab" WHERE $__timeFilter(tstmp) SAMPLE BY $__sampleByInterval FILL ( null, 10 ) ALIGN TO CALENDAR', + { mode: BuilderMode.Trend, table: 'tab', fields: ['tstmp'], sampleByAlignTo: SampleByAlignToMode.Calendar, - sampleByFill: ["null", "10"], - metrics: [ { field: '*', aggregation: BuilderMetricFieldAggregation.Count }, - { field: 'str', aggregation: BuilderMetricFieldAggregation.First }, - ], - filters: [{ + sampleByFill: ['null', '10'], + metrics: [ + { field: '*', aggregation: BuilderMetricFieldAggregation.Count }, + { field: 'str', aggregation: BuilderMetricFieldAggregation.First }, + ], + filters: [ + { key: 'tstmp', operator: FilterOperator.WithInGrafanaTimeRange, type: 'timestamp', - },], - timeField: "tstmp" - }, - true, "tstmp" - )); - - it( 'handles sample by align to calendar time zone', test( - 'SELECT tstmp as time, count(*), first(str) FROM "tab" WHERE $__timeFilter(tstmp) SAMPLE BY $__sampleByInterval FILL ( null, 10 ) ALIGN TO CALENDAR TIME ZONE \'EST\'', - { - mode: BuilderMode.Trend, - table: 'tab', - fields: ['time'], - sampleByAlignTo: SampleByAlignToMode.CalendarTimeZone, - sampleByAlignToValue: "EST", - sampleByFill: ["null", "10"], - metrics: [ { field: '*', aggregation: BuilderMetricFieldAggregation.Count }, - { field: 'str', aggregation: BuilderMetricFieldAggregation.First }, - ], - filters: [], - timeField: "tstmp" - }, - false - )); - - it( 'handles sample by align to calendar offset', test( - 'SELECT tstmp as time, count(*), first(str) FROM "tab" WHERE $__timeFilter(tstmp) SAMPLE BY $__sampleByInterval FILL ( null, 10 ) ALIGN TO CALENDAR WITH OFFSET \'01:00\'', - { + }, + ], + timeField: 'tstmp', + }, + true, + 'tstmp' + ) + ); + + it( + 'handles sample by align to calendar time zone', + test( + 'SELECT tstmp as time, count(*), first(str) FROM "tab" WHERE $__timeFilter(tstmp) SAMPLE BY $__sampleByInterval FILL ( null, 10 ) ALIGN TO CALENDAR TIME ZONE \'EST\'', + { + mode: BuilderMode.Trend, + table: 'tab', + fields: ['time'], + sampleByAlignTo: SampleByAlignToMode.CalendarTimeZone, + sampleByAlignToValue: 'EST', + sampleByFill: ['null', '10'], + metrics: [ + { field: '*', aggregation: BuilderMetricFieldAggregation.Count }, + { field: 'str', aggregation: BuilderMetricFieldAggregation.First }, + ], + filters: [], + timeField: 'tstmp', + }, + false + ) + ); + + it( + 'handles sample by align to calendar offset', + test( + 'SELECT tstmp as time, count(*), first(str) FROM "tab" WHERE $__timeFilter(tstmp) SAMPLE BY $__sampleByInterval FILL ( null, 10 ) ALIGN TO CALENDAR WITH OFFSET \'01:00\'', + { mode: BuilderMode.Trend, table: 'tab', fields: ['time'], sampleByAlignTo: SampleByAlignToMode.CalendarOffset, - sampleByAlignToValue: "01:00", - sampleByFill: ["null", "10"], - metrics: [ { field: '*', aggregation: BuilderMetricFieldAggregation.Count }, - { field: 'str', aggregation: BuilderMetricFieldAggregation.First }, + sampleByAlignToValue: '01:00', + sampleByFill: ['null', '10'], + metrics: [ + { field: '*', aggregation: BuilderMetricFieldAggregation.Count }, + { field: 'str', aggregation: BuilderMetricFieldAggregation.First }, ], filters: [], - timeField: "tstmp" - }, - false - )); - - it( 'handles sample by align to first observation', test( - 'SELECT tstmp as time, count(*), first(str) FROM "tab" WHERE $__timeFilter(tstmp) SAMPLE BY $__sampleByInterval FILL ( null, 10 ) ALIGN TO FIRST OBSERVATION', - { - mode: BuilderMode.Trend, - table: 'tab', - fields: ['time'], - sampleByAlignTo: SampleByAlignToMode.FirstObservation, - sampleByFill: ["null", "10"], - metrics: [ { field: '*', aggregation: BuilderMetricFieldAggregation.Count }, - { field: 'str', aggregation: BuilderMetricFieldAggregation.First }, - ], - filters: [], - timeField: "tstmp" - }, - false - )); - - it( 'handles __timeFilter macro and sample by', test( - 'SELECT time as time FROM "tab" WHERE $__timeFilter(time) SAMPLE BY $__sampleByInterval ORDER BY time ASC', - { - mode: BuilderMode.Trend, - table: 'tab', - fields: [], - timeField: 'time', - metrics: [], - filters: [], - orderBy: [{name: "time", dir: "ASC"}] - }, - false - )); - - it( 'handles __timeFilter macro and sample by with filters', test( - 'SELECT time as time FROM "tab" WHERE $__timeFilter(time) AND base IS NOT NULL AND time IS NOT NULL SAMPLE BY $__sampleByInterval', - { - mode: BuilderMode.Trend, - table: 'tab', - fields: ['time'], - timeField: 'time', - filters: [ - { key: 'time', operator: FilterOperator.WithInGrafanaTimeRange, type: 'timestamp',}, - { condition: 'AND', key: 'base', operator: 'IS NOT NULL'}, - { condition: 'AND', key: 'time', operator: 'IS NOT NULL', type: 'timestamp'}, - ], - }, - true, "time" - )); - - it( 'handles function filter', test( - 'SELECT tstmp FROM "tab" WHERE tstmp > dateadd(\'M\', -1, now())', - { + timeField: 'tstmp', + }, + false + ) + ); + + it( + 'handles sample by align to first observation', + test( + 'SELECT tstmp as time, count(*), first(str) FROM "tab" WHERE $__timeFilter(tstmp) SAMPLE BY $__sampleByInterval FILL ( null, 10 ) ALIGN TO FIRST OBSERVATION', + { + mode: BuilderMode.Trend, + table: 'tab', + fields: ['time'], + sampleByAlignTo: SampleByAlignToMode.FirstObservation, + sampleByFill: ['null', '10'], + metrics: [ + { field: '*', aggregation: BuilderMetricFieldAggregation.Count }, + { field: 'str', aggregation: BuilderMetricFieldAggregation.First }, + ], + filters: [], + timeField: 'tstmp', + }, + false + ) + ); + + it( + 'handles __timeFilter macro and sample by', + test( + 'SELECT time as time FROM "tab" WHERE $__timeFilter(time) SAMPLE BY $__sampleByInterval ORDER BY time ASC', + { + mode: BuilderMode.Trend, + table: 'tab', + fields: [], + timeField: 'time', + metrics: [], + filters: [], + orderBy: [{ name: 'time', dir: 'ASC' }], + }, + false + ) + ); + + it( + 'handles __timeFilter macro and sample by with filters', + test( + 'SELECT time as time FROM "tab" WHERE $__timeFilter(time) AND base IS NOT NULL AND time IS NOT NULL SAMPLE BY $__sampleByInterval', + { + mode: BuilderMode.Trend, + table: 'tab', + fields: ['time'], + timeField: 'time', + filters: [ + { key: 'time', operator: FilterOperator.WithInGrafanaTimeRange, type: 'timestamp' }, + { condition: 'AND', key: 'base', operator: 'IS NOT NULL' }, + { condition: 'AND', key: 'time', operator: 'IS NOT NULL', type: 'timestamp' }, + ], + }, + true, + 'time' + ) + ); + + it( + 'handles function filter', + test( + 'SELECT tstmp FROM "tab" WHERE tstmp > dateadd(\'M\', -1, now())', + { mode: BuilderMode.List, table: 'tab', - fields: ["tstmp"], + fields: ['tstmp'], timeField: 'tstmp', filters: [ - { - key: 'tstmp', - operator: '>', - type: 'timestamp', - value: 'dateadd(\'M\', -1, now())' - }, + { + key: 'tstmp', + operator: '>', + type: 'timestamp', + value: "dateadd('M', -1, now())", + }, ], - }, - true, "tstmp" - )); - - it( 'handles multiple function filters', test( - 'SELECT tstmp FROM "tab" WHERE tstmp > dateadd(\'M\', -1, now()) AND tstmp = dateadd(\'M\', -1, now())', - { + }, + true, + 'tstmp' + ) + ); + + it( + 'handles multiple function filters', + test( + "SELECT tstmp FROM \"tab\" WHERE tstmp > dateadd('M', -1, now()) AND tstmp = dateadd('M', -1, now())", + { mode: BuilderMode.List, table: 'tab', - fields: ["tstmp"], + fields: ['tstmp'], timeField: 'tstmp', filters: [ - { key: 'tstmp', operator: '>', type: 'timestamp', value: 'dateadd(\'M\', -1, now())' }, - { condition: 'AND', key: 'tstmp', operator: '=', type: 'timestamp', value: 'dateadd(\'M\', -1, now())' }, + { key: 'tstmp', operator: '>', type: 'timestamp', value: "dateadd('M', -1, now())" }, + { condition: 'AND', key: 'tstmp', operator: '=', type: 'timestamp', value: "dateadd('M', -1, now())" }, ], - }, - true, "tstmp" - )); - - it( 'handles boolean column ref filters', test( - 'SELECT tstmp, bool FROM "tab" WHERE bool = true AND tstmp > cast( \'2020-01-01\' as timestamp )', - { + }, + true, + 'tstmp' + ) + ); + + it( + 'handles boolean column ref filters', + test( + 'SELECT tstmp, bool FROM "tab" WHERE bool = true AND tstmp > cast( \'2020-01-01\' as timestamp )', + { mode: BuilderMode.List, table: 'tab', fields: ['tstmp', 'bool'], timeField: 'tstmp', filters: [ - { key: 'bool', operator: '=', type: 'boolean', value: true }, - { condition: 'AND', key: 'tstmp', operator: '>', type: 'timestamp', value: 'cast( \'2020-01-01\' as timestamp )' }, + { key: 'bool', operator: '=', type: 'boolean', value: true }, + { + condition: 'AND', + key: 'tstmp', + operator: '>', + type: 'timestamp', + value: "cast( '2020-01-01' as timestamp )", + }, ], - }, - true, "tstmp" - )); - - it( 'handles numeric filters', test( - 'SELECT tstmp, z FROM "tab" WHERE k = 1 AND j > 1.2', - { + }, + true, + 'tstmp' + ) + ); + + it( + 'handles numeric filters', + test( + 'SELECT tstmp, z FROM "tab" WHERE k = 1 AND j > 1.2', + { mode: BuilderMode.List, table: 'tab', fields: ['tstmp', 'z'], timeField: 'tstmp', filters: [ - { key: 'k', operator: '=', type: 'int', value: 1 }, - { condition: 'AND', key: 'j', operator: '>', type: 'double', value: 1.2 }, + { key: 'k', operator: '=', type: 'int', value: 1 }, + { condition: 'AND', key: 'j', operator: '>', type: 'double', value: 1.2 }, ], - }, - true, "tstmp" - )); + }, + true, + 'tstmp' + ) + ); // builder doesn't support nested conditions, so we flatten them - it( 'flattens condition hierarchy', async () => { - let options = await getQueryOptionsFromSql('SELECT tstmp, z FROM "tab" WHERE k = 1 AND ( j > 1.2 OR p = \'start\' )', mockDatasource); - expect( options).toEqual( { - mode: BuilderMode.List, - table: 'tab', - fields: ['tstmp', 'z'], - timeField: '', - filters: [ - { key: 'k', operator: '=', type: 'int', value: 1 }, - { condition: 'AND', key: 'j', operator: '>', type: 'double', value: 1.2 }, - { condition: 'OR', key: 'p', operator: '=', type: 'string', value: 'start' }, - ], - }); + it('flattens condition hierarchy', async () => { + let options = await getQueryOptionsFromSql( + 'SELECT tstmp, z FROM "tab" WHERE k = 1 AND ( j > 1.2 OR p = \'start\' )', + mockDatasource + ); + expect(options).toEqual({ + mode: BuilderMode.List, + table: 'tab', + fields: ['tstmp', 'z'], + timeField: '', + filters: [ + { key: 'k', operator: '=', type: 'int', value: 1 }, + { condition: 'AND', key: 'j', operator: '>', type: 'double', value: 1.2 }, + { condition: 'OR', key: 'p', operator: '=', type: 'string', value: 'start' }, + ], + }); }); - it( 'handles expressions in select list', async () => { + it('handles expressions in select list', async () => { let options = await getQueryOptionsFromSql('SELECT tstmp, e::timestamp, f(x), g(a,b) FROM "tab"', mockDatasource); - expect( options).toEqual( { - mode: BuilderMode.List, - table: 'tab', - fields: ['tstmp', 'cast(e as timestamp)', 'f(x)', 'g(a, b)'], - timeField: '', + expect(options).toEqual({ + mode: BuilderMode.List, + table: 'tab', + fields: ['tstmp', 'cast(e as timestamp)', 'f(x)', 'g(a, b)'], + timeField: '', }); }); }); function test(sql: string, builder: any, testQueryOptionsFromSql = true, timeField?: string) { - return async () => { - if (timeField){ - mockTimeField = timeField; - } - expect(getSQLFromQueryOptions(builder)).toBe(sql); - if (testQueryOptionsFromSql) { - let options = await getQueryOptionsFromSql(sql, mockDatasource); - expect( options).toEqual(builder); - } - mockTimeField = ""; + return async () => { + if (timeField) { + mockTimeField = timeField; + } + expect(getSQLFromQueryOptions(builder, [])).toBe(sql); + if (testQueryOptionsFromSql) { + let options = await getQueryOptionsFromSql(sql, mockDatasource); + expect(options).toEqual(builder); } + mockTimeField = ''; + }; } diff --git a/src/components/queryBuilder/utils.ts b/src/components/queryBuilder/utils.ts index e2b3413..1752a6b 100644 --- a/src/components/queryBuilder/utils.ts +++ b/src/components/queryBuilder/utils.ts @@ -1,3 +1,4 @@ +import { VariableWithMultiSupport } from '@grafana/data'; import { astVisitor, Expr, @@ -159,7 +160,10 @@ const getSampleByQuery = ( return `SELECT ${metricsQuery} FROM ${escaped(table)}`; }; -const getFilters = (filters: Filter[]): { filters: string; hasTimeFilter: boolean } => { +const getFilters = ( + filters: Filter[], + templateVars: VariableWithMultiSupport[] +): { filters: string; hasTimeFilter: boolean } => { let hasTsFilter = false; let combinedFilters = filters.reduce((previousValue, currentFilter, currentIndex) => { @@ -197,7 +201,15 @@ const getFilters = (filters: Filter[]): { filters: string; hasTimeFilter: boolea if (isNumberType(currentFilter.type)) { filter += ` (${values?.map((v) => v.trim()).join(', ')} )`; } else { - filter += ` (${values?.map((v) => formatStringValue(v).trim()).join(', ')} )`; + filter += ` (${values + ?.map((v) => + formatStringValue( + v, + templateVars, + currentFilter.operator === FilterOperator.In || currentFilter.operator === FilterOperator.NotIn + ).trim() + ) + .join(', ')} )`; } } else if (isBooleanFilter(currentFilter)) { filter += ` ${currentFilter.value}`; @@ -217,7 +229,7 @@ const getFilters = (filters: Filter[]): { filters: string; hasTimeFilter: boolea } } } else { - filter += formatStringValue(currentFilter.value || ''); + filter += formatStringValue(currentFilter.value || '', templateVars); } if (notOperator) { @@ -235,7 +247,7 @@ const getFilters = (filters: Filter[]): { filters: string; hasTimeFilter: boolea } }, ''); - return { filters: combinedFilters, hasTimeFilter: hasTsFilter }; + return { filters: removeQuotesForMultiVariables(combinedFilters, templateVars), hasTimeFilter: hasTsFilter }; }; const getSampleBy = (sampleByMode: SampleByAlignToMode, sampleByValue?: string, sampleByFill?: string[]): string => { @@ -294,14 +306,17 @@ const escapeFields = (fields: string[]): string[] => { }); }; -export const getSQLFromQueryOptions = (options: SqlBuilderOptions): string => { +export const getSQLFromQueryOptions = ( + options: SqlBuilderOptions, + templateVars: VariableWithMultiSupport[] +): string => { const limit = options.limit ? getLimit(options.limit) : ''; const fields = escapeFields(options.fields || []); let query = ``; switch (options.mode) { case BuilderMode.Aggregate: query += getAggregationQuery(options.table, fields, options.metrics, options.groupBy); - const aggregateFilters = getFilters(options.filters || []); + const aggregateFilters = getFilters(options.filters || [], templateVars); if (aggregateFilters.filters) { query += ` WHERE${aggregateFilters.filters}`; } @@ -309,7 +324,7 @@ export const getSQLFromQueryOptions = (options: SqlBuilderOptions): string => { break; case BuilderMode.Trend: query += getSampleByQuery(options.table, fields, options.metrics, options.groupBy, options.timeField); - const sampleByFilters = getFilters(options.filters || []); + const sampleByFilters = getFilters(options.filters || [], templateVars); if (options.timeField || sampleByFilters.filters.length > 0) { query += ' WHERE'; @@ -328,7 +343,7 @@ export const getSQLFromQueryOptions = (options: SqlBuilderOptions): string => { case BuilderMode.List: default: query += getListQuery(options.table, fields); - const filters = getFilters(options.filters || []); + const filters = getFilters(options.filters || [], templateVars); if (filters.filters) { query += ` WHERE${filters.filters}`; } @@ -337,7 +352,6 @@ export const getSQLFromQueryOptions = (options: SqlBuilderOptions): string => { query += getOrderBy(options.orderBy); query += limit; - return query; }; @@ -804,11 +818,16 @@ function getMetricsFromAst(selectClauses: SelectedColumn[] | null): { return { metrics, fields }; } -function formatStringValue(currentFilter: string): string { - if (Array.isArray(currentFilter)) { - currentFilter = currentFilter[0]; - } - return ` '${currentFilter || ''}'`; +function formatStringValue( + currentFilter: string, + templateVars: VariableWithMultiSupport[], + multipleValue?: boolean +): string { + const filter = Array.isArray(currentFilter) ? currentFilter[0] : currentFilter; + const varConfigForFilter = templateVars.find((tv) => tv.name === filter.substring(1)); + return filter.startsWith('$') && (multipleValue || varConfigForFilter?.current.value.length === 1) + ? ` ${filter || ''}` + : ` '${filter || ''}'`; } function escaped(object: string) { @@ -823,3 +842,15 @@ export const operMap = new Map([ export function getOper(v: string): FilterOperator { return operMap.get(v) || FilterOperator.Equals; } + +function removeQuotesForMultiVariables(val: string, templateVars: VariableWithMultiSupport[]): string { + console.log(val); + const multiVariableInWhereString = (tv: VariableWithMultiSupport) => + tv.multi && (val.includes(`\${${tv.name}}`) || val.includes(`$${tv.name}`)); + + if (templateVars.some((tv) => multiVariableInWhereString(tv))) { + val = val.replace(/'\)/g, ')'); + val = val.replace(/\('\)/g, '('); + } + return val; +} diff --git a/src/views/QuestDBQueryEditor.tsx b/src/views/QuestDBQueryEditor.tsx index 09e6774..d603342 100644 --- a/src/views/QuestDBQueryEditor.tsx +++ b/src/views/QuestDBQueryEditor.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { QueryEditorProps } from '@grafana/data'; +import { QueryEditorProps, VariableWithMultiSupport } from '@grafana/data'; import { Datasource } from '../data/QuestDbDatasource'; import { BuilderMode, @@ -17,19 +17,22 @@ import { QueryBuilder } from 'components/queryBuilder/QueryBuilder'; import { Preview } from 'components/queryBuilder/Preview'; import { getFormat } from 'components/editor'; import { QueryHeader } from 'components/QueryHeader'; +import { getTemplateSrv } from '@grafana/runtime'; export type QuestDBQueryEditorProps = QueryEditorProps; const QuestDBEditorByType = (props: QuestDBQueryEditorProps) => { const { query, onChange, app } = props; const onBuilderOptionsChange = (builderOptions: SqlBuilderOptions) => { - const sql = getSQLFromQueryOptions(builderOptions); + const templateVars = getTemplateSrv().getVariables() as VariableWithMultiSupport[]; + const sql = getSQLFromQueryOptions(builderOptions, templateVars); const format = query.selectedFormat === Format.AUTO ? builderOptions.mode === BuilderMode.Trend ? Format.TIMESERIES : Format.TABLE : query.selectedFormat; + onChange({ ...query, queryType: QueryType.Builder, rawSql: sql, builderOptions, format }); }; @@ -87,7 +90,7 @@ export const QuestDBQueryEditor = (props: QuestDBQueryEditorProps) => { return ( <> - + ); From cb39342aaa640e095364d8f9f91119197f72e30d Mon Sep 17 00:00:00 2001 From: Maciej Bodek Date: Wed, 26 Jun 2024 18:22:59 +0200 Subject: [PATCH 3/6] Cleanup --- src/components/queryBuilder/utils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/queryBuilder/utils.ts b/src/components/queryBuilder/utils.ts index 1752a6b..4a0e899 100644 --- a/src/components/queryBuilder/utils.ts +++ b/src/components/queryBuilder/utils.ts @@ -844,7 +844,6 @@ export function getOper(v: string): FilterOperator { } function removeQuotesForMultiVariables(val: string, templateVars: VariableWithMultiSupport[]): string { - console.log(val); const multiVariableInWhereString = (tv: VariableWithMultiSupport) => tv.multi && (val.includes(`\${${tv.name}}`) || val.includes(`$${tv.name}`)); From 3ce9a688cdb6f889316a3b617c69cc182f51ed2b Mon Sep 17 00:00:00 2001 From: Maciej Bodek Date: Thu, 27 Jun 2024 10:03:20 +0200 Subject: [PATCH 4/6] Improve variable name matcher --- src/components/queryBuilder/utils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/queryBuilder/utils.ts b/src/components/queryBuilder/utils.ts index 4a0e899..52f2675 100644 --- a/src/components/queryBuilder/utils.ts +++ b/src/components/queryBuilder/utils.ts @@ -824,7 +824,8 @@ function formatStringValue( multipleValue?: boolean ): string { const filter = Array.isArray(currentFilter) ? currentFilter[0] : currentFilter; - const varConfigForFilter = templateVars.find((tv) => tv.name === filter.substring(1)); + const extractedVariableName = filter.substring(1).replace(/[{}]/g, ''); + const varConfigForFilter = templateVars.find((tv) => tv.name === extractedVariableName); return filter.startsWith('$') && (multipleValue || varConfigForFilter?.current.value.length === 1) ? ` ${filter || ''}` : ` '${filter || ''}'`; From 8372dcbf4eafb838318f86c80dce6812e4dd6c22 Mon Sep 17 00:00:00 2001 From: Maciej Bodek Date: Thu, 27 Jun 2024 13:14:44 +0200 Subject: [PATCH 5/6] Add varchar type, update docker compose --- docker-compose.yml | 4 ++-- src/components/queryBuilder/utils.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index a396d12..9d26d2c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,7 +16,7 @@ services: - grafana questdb: - image: 'questdb/questdb:7.3.9' + image: 'questdb/questdb:8.0.1' container_name: 'grafana-questdb-server' ports: - '8812:8812' @@ -29,4 +29,4 @@ services: - grafana networks: - grafana: + grafana: diff --git a/src/components/queryBuilder/utils.ts b/src/components/queryBuilder/utils.ts index 52f2675..32bf04e 100644 --- a/src/components/queryBuilder/utils.ts +++ b/src/components/queryBuilder/utils.ts @@ -67,7 +67,7 @@ export const isIPv4Type = (type: string): boolean => { }; export const isStringType = (type: string): boolean => { - return ['string', 'symbol', 'char'].includes(type?.toLowerCase()); + return ['string', 'symbol', 'char', 'varchar'].includes(type?.toLowerCase()); }; export const isNullFilter = (filter: Filter): filter is NullFilter => { From d09adb7cf6533fd7579c46d9d5c4447284aef847 Mon Sep 17 00:00:00 2001 From: Maciej Bodek Date: Fri, 19 Jul 2024 16:20:57 +0200 Subject: [PATCH 6/6] bump version --- CHANGELOG.md | 8 ++++++++ docker-compose.yml | 2 +- package.json | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cf82ff..1824608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ and this project adheres to - `Fixed` for any bug fixes. - `Security` in case of vulnerabilities. +## 0.1.4 + +## Changed + +- Enclose variables and column names in quotes in the generated SQL [#107](https://github.com/questdb/grafana-questdb-datasource/pull/107) +- Add VARCHAR type [#107](https://github.com/questdb/grafana-questdb-datasource/pull/107) +- Update docker-compose yaml to use QuestDB 8.0.3 [#107](https://github.com/questdb/grafana-questdb-datasource/pull/107) + ## 0.1.3 ## Changed diff --git a/docker-compose.yml b/docker-compose.yml index 9d26d2c..8a1207e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,7 +16,7 @@ services: - grafana questdb: - image: 'questdb/questdb:8.0.1' + image: 'questdb/questdb:8.0.3' container_name: 'grafana-questdb-server' ports: - '8812:8812' diff --git a/package.json b/package.json index 4919144..18443e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "questdb-questdb-datasource", - "version": "0.1.3", + "version": "0.1.4", "description": "QuestDB Datasource for Grafana", "engines": { "node": ">=18"