From 04b3c43aecc5ec32d47abd5705b30b4ff1fe0d0d Mon Sep 17 00:00:00 2001 From: javier <javier@formatinternet.com> Date: Thu, 20 Jun 2024 13:43:03 +0200 Subject: [PATCH 01/18] Improved provisioning instructions --- README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3e7d055..a53400a 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,15 @@ Grafana’s provisioning system. To read about how it works, including all the settings that you can set for this data source, refer to [Provisioning Grafana data sources](https://grafana.com/docs/grafana/latest/administration/provisioning/#data-sources). -Here are some provisioning examples for this data source using basic authentication: +Note that the plugin must be previously installed. If you +are using Docker and want to automate installation, you can set the [GF_INSTALL_PLUGINS environment +variable](https://grafana.com/docs/grafana/latest/setup-grafana/configure-docker/#install-plugins-in-the-docker-container) + +```bash +docker run -p 3000:3000 -e GF_INSTALL_PLUGINS=questdb-questdb-datasource grafana/grafana-oss +``` + +This is an example provisioning file for this data source using the default configuration for QuestDB Open Source. ```yaml apiVersion: 1 @@ -69,6 +77,9 @@ datasources: # tlsCACert: <string> ``` +If you are using QuestDB Enterprise and have enabled TLS, you would need to change +`tlsMode: require` in the example above. + ## Building queries The query editor allows you to query QuestDB to return time series or From 4302924baf495d2acf788162706351ae98278417 Mon Sep 17 00:00:00 2001 From: javier <javier@formatinternet.com> Date: Thu, 20 Jun 2024 14:43:47 +0200 Subject: [PATCH 02/18] replacing cloud by enterprise in tooltip --- src/selectors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/selectors.ts b/src/selectors.ts index 66304e0..bcd9e86 100644 --- a/src/selectors.ts +++ b/src/selectors.ts @@ -60,7 +60,7 @@ export const Components = { TlsMode: { label: 'TLS/SSL Mode', tooltip: - 'This option determines whether or with what priority a secure TLS/SSL TCP/IP connection will be negotiated with the server. For QuestDB Cloud, use "require". For self-hosted QuestDB, use "disable".', + 'This option determines whether or with what priority a secure TLS/SSL TCP/IP connection will be negotiated with the server. For QuestDB Enterprise, use "require". For self-hosted QuestDB, use "disable".', placeholder: 'TLS/SSL Mode', }, TlsMethod: { From 73c9f3996ae628cbb6c88f0ef6baad6f7a3f3541 Mon Sep 17 00:00:00 2001 From: Maciej Bodek <maciej.bodek@gmail.com> Date: Fri, 21 Jun 2024 12:41:30 +0200 Subject: [PATCH 03/18] QueryBuilder: sort tables alphabetically --- src/components/queryBuilder/TableSelect.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/queryBuilder/TableSelect.tsx b/src/components/queryBuilder/TableSelect.tsx index 75bc566..54c255f 100644 --- a/src/components/queryBuilder/TableSelect.tsx +++ b/src/components/queryBuilder/TableSelect.tsx @@ -21,7 +21,7 @@ export const TableSelect = (props: Props) => { useEffect(() => { async function fetchTables() { - const tables = await datasource.fetchTables(); + const tables = (await datasource.fetchTables()).sort((a, b) => a.tableName.localeCompare(b.tableName)); const values = tables.map((t) => ({ label: t.tableName, value: t.tableName })); // Add selected value to the list if it does not exist. if (table && !tables.find((x) => x.tableName === table) && props.mode !== BuilderMode.Trend) { From 6085c64c0798dda9eda116ed6887c739c95668f8 Mon Sep 17 00:00:00 2001 From: Maciej Bodek <maciej.bodek@gmail.com> Date: Fri, 21 Jun 2024 12:47:58 +0200 Subject: [PATCH 04/18] QueryBuilder: Remove the default row limit --- src/components/queryBuilder/Limit.tsx | 2 +- src/components/queryBuilder/QueryBuilder.tsx | 2 +- src/types.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/queryBuilder/Limit.tsx b/src/components/queryBuilder/Limit.tsx index 85fa412..7659a85 100644 --- a/src/components/queryBuilder/Limit.tsx +++ b/src/components/queryBuilder/Limit.tsx @@ -8,7 +8,7 @@ interface LimitEditorProps { onLimitChange: (limit: string) => void; } export const LimitEditor = (props: LimitEditorProps) => { - const [limit, setLimit] = useState(props.limit || '100'); + const [limit, setLimit] = useState(props.limit); const { label, tooltip } = selectors.components.QueryEditor.QueryBuilder.LIMIT; return ( diff --git a/src/components/queryBuilder/QueryBuilder.tsx b/src/components/queryBuilder/QueryBuilder.tsx index 588a6aa..75c294f 100644 --- a/src/components/queryBuilder/QueryBuilder.tsx +++ b/src/components/queryBuilder/QueryBuilder.tsx @@ -295,7 +295,7 @@ export const QueryBuilder = (props: QueryBuilderProps) => { fieldsList={getOrderByFields(builder, fieldsList)} /> <EditorRow> - <LimitEditor limit={builder.limit || 100} onLimitChange={onLimitChange} /> + <LimitEditor limit={builder.limit} onLimitChange={onLimitChange} /> </EditorRow> </EditorRows> ) : null; diff --git a/src/types.ts b/src/types.ts index 7cf38f0..c6c253c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -313,7 +313,7 @@ export const defaultBuilderQuery: Omit<QuestDBBuilderQuery, 'refId'> = { builderOptions: { mode: BuilderMode.List, fields: [], - limit: '100', + limit: '', timeField: '', }, format: Format.TABLE, From 822a3c47861cba6cf8a811e7d6c1dcf3b32c0176 Mon Sep 17 00:00:00 2001 From: Maciej Bodek <maciej.bodek@gmail.com> Date: Fri, 21 Jun 2024 13:26:11 +0200 Subject: [PATCH 05/18] QueryBuilder: Escape fields with number at the start --- src/components/queryBuilder/utils.ts | 253 +++++++++++++++------------ 1 file changed, 139 insertions(+), 114 deletions(-) diff --git a/src/components/queryBuilder/utils.ts b/src/components/queryBuilder/utils.ts index 24f6daa..1d05587 100644 --- a/src/components/queryBuilder/utils.ts +++ b/src/components/queryBuilder/utils.ts @@ -1,14 +1,18 @@ import { astVisitor, Expr, - ExprBinary, ExprBool, - ExprCall, ExprCast, + ExprBinary, + ExprBool, + ExprCall, + ExprCast, ExprInteger, - ExprList, ExprNumeric, + ExprList, + ExprNumeric, ExprRef, ExprString, ExprUnary, - FromTable, IAstVisitor, + FromTable, + IAstVisitor, SelectedColumn, } from '@questdb/sql-ast-parser'; import { @@ -26,19 +30,20 @@ import { OrderBy, SampleByAlignToMode, SqlBuilderOptions, - SqlBuilderOptionsAggregate, SqlBuilderOptionsList, + SqlBuilderOptionsAggregate, + SqlBuilderOptionsList, SqlBuilderOptionsTrend, } from 'types'; -import {sqlToStatement} from 'data/ast'; -import {Datasource} from "../../data/QuestDbDatasource"; -import {isString} from "lodash"; +import { sqlToStatement } from 'data/ast'; +import { Datasource } from '../../data/QuestDbDatasource'; +import { isString } from 'lodash'; export const isBooleanType = (type: string): boolean => { return ['boolean'].includes(type?.toLowerCase()); }; export const isGeoHashType = (type: string): boolean => { - return type?.toLowerCase().startsWith("geohash") + return type?.toLowerCase().startsWith('geohash'); }; export const isNumberType = (type: string): boolean => { @@ -84,7 +89,7 @@ export const isDateFilter = (filter: Filter): filter is DateFilter => { }; export const isMultiFilter = (filter: Filter): filter is MultiFilter => { - return FilterOperator.In === filter.operator || FilterOperator.NotIn === filter.operator; + return FilterOperator.In === filter.operator || FilterOperator.NotIn === filter.operator; }; export const isSetFilter = (filter: Filter): filter is MultiFilter => { @@ -96,13 +101,13 @@ const getListQuery = (table = '', fields: string[] = []): string => { return `SELECT ${fields.join(', ')} FROM ${escaped(table)}`; }; -const getLatestOn = (timeField = '', partitionBy: string[] = []): string => { - if ( timeField.length === 0 || partitionBy.length === 0 ){ +const getLatestOn = (timeField = '', partitionBy: string[] = []): string => { + if (timeField.length === 0 || partitionBy.length === 0) { return ''; } return ` LATEST ON ${timeField} PARTITION BY ${partitionBy.join(', ')}`; -} +}; const getAggregationQuery = ( table = '', @@ -154,7 +159,7 @@ const getSampleByQuery = ( return `SELECT ${metricsQuery} FROM ${escaped(table)}`; }; -const getFilters = (filters: Filter[]): {filters: string; hasTimeFilter: boolean} => { +const getFilters = (filters: Filter[]): { filters: string; hasTimeFilter: boolean } => { let hasTsFilter = false; let combinedFilters = filters.reduce((previousValue, currentFilter, currentIndex) => { @@ -170,26 +175,26 @@ const getFilters = (filters: Filter[]): {filters: string; hasTimeFilter: boolean } else if (currentFilter.operator === FilterOperator.OutsideGrafanaTimeRange) { operator = ''; notOperator = true; - field = ` \$__timeFilter(${currentFilter.key})` + field = ` \$__timeFilter(${currentFilter.key})`; hasTsFilter = true; } else if (FilterOperator.WithInGrafanaTimeRange === currentFilter.operator) { operator = ''; - field = ` \$__timeFilter(${currentFilter.key})` + field = ` \$__timeFilter(${currentFilter.key})`; hasTsFilter = true; } else { operator = currentFilter.operator; } - if ( operator.length > 0 ){ + if (operator.length > 0) { filter = ` ${field} ${operator}`; } else { - filter = ` ${field}` + filter = ` ${field}`; } if (isNullFilter(currentFilter)) { // don't add anything } else if (isMultiFilter(currentFilter)) { let values = currentFilter.value; - if (isNumberType(currentFilter.type)){ + if (isNumberType(currentFilter.type)) { filter += ` (${values?.map((v) => v.trim()).join(', ')} )`; } else { filter += ` (${values?.map((v) => formatStringValue(v).trim()).join(', ')} )`; @@ -202,56 +207,58 @@ const getFilters = (filters: Filter[]): {filters: string; hasTimeFilter: boolean if (!isDateFilterWithOutValue(currentFilter)) { switch (currentFilter.value) { case 'GRAFANA_START_TIME': - filter += ` \$__fromTime`; + filter += ` \$__fromTime`; break; case 'GRAFANA_END_TIME': - filter += ` \$__toTime`; + filter += ` \$__toTime`; break; default: filter += ` ${currentFilter.value || 'TODAY'}`; } } } else { - filter += formatStringValue(currentFilter.value || ''); + filter += formatStringValue(currentFilter.value || ''); } if (notOperator) { filter = ` NOT (${filter} )`; } - if ( !filter ){ + if (!filter) { return previousValue; } - if ( previousValue.length > 0 ){ - return `${previousValue} ${prefixCondition}${filter}` + if (previousValue.length > 0) { + return `${previousValue} ${prefixCondition}${filter}`; } else { return filter; } }, ''); - return { filters: combinedFilters, hasTimeFilter: hasTsFilter } + return { filters: combinedFilters, hasTimeFilter: hasTsFilter }; }; const getSampleBy = (sampleByMode: SampleByAlignToMode, sampleByValue?: string, sampleByFill?: string[]): string => { - let fills = ''; - if (sampleByFill !== undefined && sampleByFill.length > 0){ + if (sampleByFill !== undefined && sampleByFill.length > 0) { // remove suffixes - fills = ` FILL ( ${sampleByFill.map((s)=>s.replace(/_[0-9]+$/, '')).join(', ')} )`; + fills = ` FILL ( ${sampleByFill.map((s) => s.replace(/_[0-9]+$/, '')).join(', ')} )`; } let mode = ''; - if (sampleByMode !== undefined ){ + if (sampleByMode !== undefined) { mode = ` ALIGN TO ${sampleByMode}`; } let offsetOrTz = ''; - if ( (sampleByMode === SampleByAlignToMode.CalendarOffset || sampleByMode === SampleByAlignToMode.CalendarTimeZone) && - sampleByValue !== undefined && sampleByValue.length > 0 ){ + if ( + (sampleByMode === SampleByAlignToMode.CalendarOffset || sampleByMode === SampleByAlignToMode.CalendarTimeZone) && + sampleByValue !== undefined && + sampleByValue.length > 0 + ) { offsetOrTz = ` '${sampleByValue}'`; } return ` SAMPLE BY \$__sampleByInterval${fills}${mode}${offsetOrTz}`; -} +}; const getGroupBy = (groupBy: string[] = [], timeField?: string): string => { const clause = groupBy.length > 0 ? ` GROUP BY ${groupBy.join(', ')}` : ''; @@ -281,12 +288,19 @@ const getLimit = (limit?: string): string => { return ` LIMIT ` + (limit || '100'); }; +const escapeFields = (fields: string[]): string[] => { + return fields.map((f) => { + return f.match(/^\d/) ? `"${f}"` : f; + }); +}; + export const getSQLFromQueryOptions = (options: SqlBuilderOptions): 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, options.fields, options.metrics, options.groupBy); + query += getAggregationQuery(options.table, fields, options.metrics, options.groupBy); const aggregateFilters = getFilters(options.filters || []); if (aggregateFilters.filters) { query += ` WHERE${aggregateFilters.filters}`; @@ -294,20 +308,14 @@ export const getSQLFromQueryOptions = (options: SqlBuilderOptions): string => { query += getGroupBy(options.groupBy); break; case BuilderMode.Trend: - query += getSampleByQuery( - options.table, - options.fields, - options.metrics, - options.groupBy, - options.timeField - ); + query += getSampleByQuery(options.table, fields, options.metrics, options.groupBy, options.timeField); const sampleByFilters = getFilters(options.filters || []); - if ( options.timeField || sampleByFilters.filters.length > 0 ){ + if (options.timeField || sampleByFilters.filters.length > 0) { query += ' WHERE'; - if ( options.timeField && !sampleByFilters.hasTimeFilter ){ + if (options.timeField && !sampleByFilters.hasTimeFilter) { query += ` $__timeFilter(${options.timeField})`; - if ( sampleByFilters.filters.length > 0 ){ + if (sampleByFilters.filters.length > 0) { query += ' AND'; } } @@ -319,7 +327,7 @@ export const getSQLFromQueryOptions = (options: SqlBuilderOptions): string => { break; case BuilderMode.List: default: - query += getListQuery(options.table, options.fields); + query += getListQuery(options.table, fields); const filters = getFilters(options.filters || []); if (filters.filters) { query += ` WHERE${filters.filters}`; @@ -333,10 +341,13 @@ export const getSQLFromQueryOptions = (options: SqlBuilderOptions): string => { return query; }; -export async function getQueryOptionsFromSql(sql: string, datasource?: Datasource): Promise<SqlBuilderOptions | string> { +export async function getQueryOptionsFromSql( + sql: string, + datasource?: Datasource +): Promise<SqlBuilderOptions | string> { const ast = sqlToStatement(sql); if (!ast || ast.type !== 'select') { - return 'The query can\'t be parsed.'; + return "The query can't be parsed."; } if (!ast.from || ast.from.length !== 1) { return `The query has too many 'FROM' clauses.`; @@ -349,14 +360,16 @@ export async function getQueryOptionsFromSql(sql: string, datasource?: Datasourc let timeField; let fieldsToTypes = new Map<string, string>(); - if ( fromTable?.name?.name.length > 0 && datasource ){ - const dbFields = await datasource.fetchFields(fromTable?.name?.name); - dbFields.forEach((f)=>{ fieldsToTypes.set(f.name, f.type) }); - timeField = dbFields.find( (f) => f.designated)?.name; + if (fromTable?.name?.name.length > 0 && datasource) { + const dbFields = await datasource.fetchFields(fromTable?.name?.name); + dbFields.forEach((f) => { + fieldsToTypes.set(f.name, f.type); + }); + timeField = dbFields.find((f) => f.designated)?.name; } - if ( timeField === undefined ){ - timeField = ""; + if (timeField === undefined) { + timeField = ''; } const fieldsAndMetrics = getMetricsFromAst(ast.columns ? ast.columns : null); @@ -364,7 +377,7 @@ export async function getQueryOptionsFromSql(sql: string, datasource?: Datasourc let builder = { mode: BuilderMode.List, table: fromTable.name.name, - timeField: timeField + timeField: timeField, } as SqlBuilderOptions; if (fieldsAndMetrics.fields) { @@ -394,9 +407,9 @@ export async function getQueryOptionsFromSql(sql: string, datasource?: Datasourc } builder.limit = undefined; - if (ast.limit){ - if (ast.limit.upperBound && ast.limit.upperBound.type === 'integer'){ - if (ast.limit.lowerBound && ast.limit.lowerBound.type === 'integer'){ + if (ast.limit) { + if (ast.limit.upperBound && ast.limit.upperBound.type === 'integer') { + if (ast.limit.lowerBound && ast.limit.lowerBound.type === 'integer') { builder.limit = `${ast.limit.lowerBound.value}, ${ast.limit.upperBound.value}`; } else { builder.limit = `${ast.limit.upperBound.value}`; @@ -404,32 +417,32 @@ export async function getQueryOptionsFromSql(sql: string, datasource?: Datasourc } } - if (ast.sampleBy){ + if (ast.sampleBy) { builder.mode = BuilderMode.Trend; - if (ast.sampleByAlignTo){ + if (ast.sampleByAlignTo) { (builder as SqlBuilderOptionsTrend).sampleByAlignTo = ast.sampleByAlignTo.alignTo as SampleByAlignToMode; } - if (ast.sampleByFill){ - (builder as SqlBuilderOptionsTrend).sampleByFill = ast.sampleByFill.map( f => { - if ( f.type === 'sampleByKeyword' ){ + if (ast.sampleByFill) { + (builder as SqlBuilderOptionsTrend).sampleByFill = ast.sampleByFill.map((f) => { + if (f.type === 'sampleByKeyword') { return f.keyword; - } else if ( f.type === 'null'){ + } else if (f.type === 'null') { return 'null'; } else { return f.value.toString(); } }); } - if (ast.sampleByAlignTo?.alignValue){ + if (ast.sampleByAlignTo?.alignValue) { (builder as SqlBuilderOptionsTrend).sampleByAlignToValue = ast.sampleByAlignTo?.alignValue; } } - if (ast.latestOn){ + if (ast.latestOn) { builder.mode = BuilderMode.List; - if (ast.partitionBy){ + if (ast.partitionBy) { (builder as SqlBuilderOptionsList).partitionBy = ast.partitionBy.map((p) => { - if (p.table){ + if (p.table) { return p.table.name + '.' + p.name; } else { return p.name; @@ -458,7 +471,7 @@ type MapperState = { filters: Filter[]; notFlag: boolean; condition: 'AND' | 'OR' | null; -} +}; function getFiltersFromAst(expr: Expr, fieldsToTypes: Map<string, string>): Filter[] { let state: MapperState = { currentFilter: null, filters: [], notFlag: false, condition: null } as MapperState; @@ -503,9 +516,10 @@ function getFiltersFromAst(expr: Expr, fieldsToTypes: Map<string, string>): Filt }, })); - try {// don't break conversion + try { + // don't break conversion visitor.expr(expr); - } catch ( error ){ + } catch (error) { console.error(error); } @@ -514,32 +528,32 @@ function getFiltersFromAst(expr: Expr, fieldsToTypes: Map<string, string>): Filt function getRefFilter(e: ExprRef, state: MapperState, fieldsToTypes: Map<string, string>) { let doAdd = false; - if ( state.currentFilter === null){ + if (state.currentFilter === null) { state.currentFilter = {} as Filter; doAdd = true; } - if ( e.name?.toLowerCase() === '$__fromtime'){ - state.currentFilter = { ...state.currentFilter, value: 'GRAFANA_START_TIME', type: 'timestamp' } as Filter; + if (e.name?.toLowerCase() === '$__fromtime') { + state.currentFilter = { ...state.currentFilter, value: 'GRAFANA_START_TIME', type: 'timestamp' } as Filter; return; } - if ( e.name?.toLowerCase() === '$__totime'){ + if (e.name?.toLowerCase() === '$__totime') { state.currentFilter = { ...state.currentFilter, value: 'GRAFANA_END_TIME', type: 'timestamp' } as Filter; return; } let type = fieldsToTypes.get(e.name); - if ( !state.currentFilter.key ) { - state.currentFilter = { ...state.currentFilter, key: e.name} ; - if (type){ + if (!state.currentFilter.key) { + state.currentFilter = { ...state.currentFilter, key: e.name }; + if (type) { state.currentFilter.type = type; } } else { state.currentFilter = { ...state.currentFilter, value: [e.name], type: type || 'string' } as Filter; } - if ( doAdd ){ + if (doAdd) { state.filters.push(state.currentFilter); state.currentFilter = null; } @@ -556,25 +570,27 @@ function getListFilter(e: ExprList, state: MapperState) { } as Filter; } -function getCallString(e: ExprCall){ - let args: string = e.args.map((x) =>{ - switch (x.type){ - case 'string': - return `'${x.value}'`; - case 'boolean': - case 'numeric': - case 'integer': - return x.value; - case 'ref': - return x.name; - case 'null': - return 'null'; - case 'call': - return getCallString(x); - default: - return '' - } - }).join(', '); +function getCallString(e: ExprCall) { + let args: string = e.args + .map((x) => { + switch (x.type) { + case 'string': + return `'${x.value}'`; + case 'boolean': + case 'numeric': + case 'integer': + return x.value; + case 'ref': + return x.name; + case 'null': + return 'null'; + case 'call': + return getCallString(x); + default: + return ''; + } + }) + .join(', '); return `${e.function.name}(${args})`; } @@ -594,21 +610,23 @@ function toString(x: Expr) { case 'call': return getCallString(x); default: - return '' + return ''; } } function getCallFilter(e: ExprCall, state: MapperState) { let doAdd = false; - if ( !state.currentFilter ){ + if (!state.currentFilter) { // map f(x) to true = f(x) so it can be displayed in builder - state.currentFilter = {key: 'true', type: 'boolean'} as Filter; + state.currentFilter = { key: 'true', type: 'boolean' } as Filter; doAdd = true; } - let args = e.args.map((x) =>{ - return toString(x); - }).join(', '); + let args = e.args + .map((x) => { + return toString(x); + }) + .join(', '); const val = `${e.function.name}(${args})`; if (val.startsWith('$__timefilter(')) { @@ -622,8 +640,8 @@ function getCallFilter(e: ExprCall, state: MapperState) { state.currentFilter = { ...state.currentFilter, value: val } as Filter; } - if ( doAdd ){ - if (state.condition){ + if (doAdd) { + if (state.condition) { state.currentFilter.condition = state.condition; state.condition = null; } @@ -642,7 +660,7 @@ function getUnaryFilter(mapper: IAstVisitor, e: ExprUnary, state: MapperState) { } state.currentFilter = { operator: e.op as FilterOperator } as Filter; - if ( state.condition ){ + if (state.condition) { state.currentFilter.condition = state.condition; state.condition = null; } @@ -656,7 +674,11 @@ function getStringFilter(e: ExprString, state: MapperState) { state.currentFilter = { ...state.currentFilter, key: e.value } as Filter; return; } - state.currentFilter = { ...state.currentFilter, value: e.value, type: state.currentFilter?.type || 'string' } as Filter; + state.currentFilter = { + ...state.currentFilter, + value: e.value, + type: state.currentFilter?.type || 'string', + } as Filter; } function getNumericFilter(e: ExprNumeric, state: MapperState) { @@ -679,14 +701,13 @@ function getCastFilter(e: ExprCast, state: MapperState) { let val = `cast( ${toString(e.operand)} as ${e.to.kind === undefined ? e.to.name : ''} )`; if (state.currentFilter != null && !state.currentFilter.key) { - state.currentFilter = {...state.currentFilter, key: val} as Filter; + state.currentFilter = { ...state.currentFilter, key: val } as Filter; return; } else { - state.currentFilter = {...state.currentFilter, value: val, type: state.currentFilter?.type || 'int'} as Filter; + state.currentFilter = { ...state.currentFilter, value: val, type: state.currentFilter?.type || 'int' } as Filter; } } - function getBooleanFilter(e: ExprBool, state: MapperState) { state.currentFilter = { ...state.currentFilter, value: e.value, type: 'boolean' } as Filter; } @@ -700,7 +721,7 @@ function getBinaryFilter(mapper: IAstVisitor, e: ExprBinary, state: MapperState) } else if (Object.values(FilterOperator).find((x) => e.op === x)) { state.currentFilter = {} as Filter; state.currentFilter.operator = e.op as FilterOperator; - if ( state.condition ){ + if (state.condition) { state.currentFilter.condition = state.condition; state.condition = null; } @@ -725,7 +746,11 @@ function selectCallFunc(s: SelectedColumn): BuilderMetricField | string { } return x.name; }); - if ( Object.values(BuilderMetricFieldAggregation).includes( s.expr.function.name.toLowerCase() as BuilderMetricFieldAggregation ) ) { + if ( + Object.values(BuilderMetricFieldAggregation).includes( + s.expr.function.name.toLowerCase() as BuilderMetricFieldAggregation + ) + ) { return { aggregation: s.expr.function.name as BuilderMetricFieldAggregation, field: fields[0], @@ -770,7 +795,7 @@ function getMetricsFromAst(selectClauses: SelectedColumn[] | null): { fields.push(`${s.expr.value}`); break; case 'cast': - fields.push(`cast(${toString(s.expr.operand)} as ${s.expr.to.kind === undefined ? s.expr.to?.name : '' })`) + fields.push(`cast(${toString(s.expr.operand)} as ${s.expr.to.kind === undefined ? s.expr.to?.name : ''})`); break; default: break; @@ -780,7 +805,7 @@ function getMetricsFromAst(selectClauses: SelectedColumn[] | null): { } function formatStringValue(currentFilter: string): string { - if ( Array.isArray(currentFilter) ){ + if (Array.isArray(currentFilter)) { currentFilter = currentFilter[0]; } if (currentFilter.startsWith('$')) { From d171bbac5b5301b265b6205f9b89968862b2cd88 Mon Sep 17 00:00:00 2001 From: Maciej Bodek <maciej.bodek@gmail.com> Date: Fri, 21 Jun 2024 13:41:28 +0200 Subject: [PATCH 06/18] Escape all operators in fields --- src/components/queryBuilder/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/queryBuilder/utils.ts b/src/components/queryBuilder/utils.ts index 1d05587..74cffbc 100644 --- a/src/components/queryBuilder/utils.ts +++ b/src/components/queryBuilder/utils.ts @@ -290,7 +290,7 @@ const getLimit = (limit?: string): string => { const escapeFields = (fields: string[]): string[] => { return fields.map((f) => { - return f.match(/^\d/) ? `"${f}"` : f; + return f.match(/(^\d|\s|\$|\&|\|)/im) ? `"${f}"` : f; }); }; From b106e498e20690f6e426ccf796cc55a8b12fa208 Mon Sep 17 00:00:00 2001 From: Maciej Bodek <maciej.bodek@gmail.com> Date: Fri, 21 Jun 2024 13:47:41 +0200 Subject: [PATCH 07/18] Escape if all non-letter characters are present --- src/components/queryBuilder/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/queryBuilder/utils.ts b/src/components/queryBuilder/utils.ts index 74cffbc..32e7e96 100644 --- a/src/components/queryBuilder/utils.ts +++ b/src/components/queryBuilder/utils.ts @@ -290,7 +290,7 @@ const getLimit = (limit?: string): string => { const escapeFields = (fields: string[]): string[] => { return fields.map((f) => { - return f.match(/(^\d|\s|\$|\&|\|)/im) ? `"${f}"` : f; + return f.match(/(^\d|[^a-zA-Z_])/im) ? `"${f}"` : f; }); }; From 33137027f14622ae715a70bd45a367e0686edf8a Mon Sep 17 00:00:00 2001 From: Maciej Bodek <maciej.bodek@gmail.com> Date: Fri, 21 Jun 2024 14:59:01 +0200 Subject: [PATCH 08/18] Add docs link in Readme --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index a53400a..b4a5568 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ data from within Grafana. For detailed instructions on how to install the plugin on Grafana Cloud or locally, please check out the [Plugin installation docs](https://grafana.com/docs/grafana/latest/plugins/installation/). +Read the guide on QuestDB website: [Third-party Tools - Grafana](https://questdb.io/docs/third-party-tools/grafana/). + ## Configuration ### QuestDB user for the data source @@ -179,3 +181,4 @@ You may choose to hide this variable from view as it serves no further purpose. - Configure and use [Templates and variables](https://grafana.com/docs/grafana/latest/variables/). - Add [Transformations](https://grafana.com/docs/grafana/latest/panels/transformations/). - Set up alerting; refer to [Alerts overview](https://grafana.com/docs/grafana/latest/alerting/). +- Read the [Plugin guide](https://questdb.io/docs/third-party-tools/grafana/) on QuestDB website From 52c583c93e6321048ef00e3102ac8de469946e10 Mon Sep 17 00:00:00 2001 From: Maciej Bodek <maciej.bodek@gmail.com> Date: Mon, 24 Jun 2024 11:52:44 +0200 Subject: [PATCH 09/18] Disable sample by and latest on with no ts --- src/components/queryBuilder/GroupBy.tsx | 4 +++- src/components/queryBuilder/QueryBuilder.tsx | 8 +++++++- src/components/queryBuilder/SampleByFillEditor.tsx | 3 +++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/components/queryBuilder/GroupBy.tsx b/src/components/queryBuilder/GroupBy.tsx index ddec532..71d3db3 100644 --- a/src/components/queryBuilder/GroupBy.tsx +++ b/src/components/queryBuilder/GroupBy.tsx @@ -10,6 +10,7 @@ interface GroupByEditorProps { groupBy: string[]; onGroupByChange: (groupBy: string[]) => void; labelAndTooltip: typeof selectors.components.QueryEditor.QueryBuilder.GROUP_BY; + isDisabled: boolean; } export const GroupByEditor = (props: GroupByEditorProps) => { const columns: SelectableValue[] = (props.fieldsList || []).map((f) => ({ label: f.label, value: f.name })); @@ -34,7 +35,7 @@ export const GroupByEditor = (props: GroupByEditorProps) => { <EditorField tooltip={tooltip} label={label}> <MultiSelect options={columns} - placeholder="Choose" + placeholder={props.isDisabled ? 'Table is missing designated timestamp' : 'Choose'} isOpen={isOpen} onOpenMenu={() => setIsOpen(true)} onCloseMenu={() => setIsOpen(false)} @@ -43,6 +44,7 @@ export const GroupByEditor = (props: GroupByEditorProps) => { value={groupBy} allowCustomValue={true} width={50} + disabled={props.isDisabled} /> </EditorField> ); diff --git a/src/components/queryBuilder/QueryBuilder.tsx b/src/components/queryBuilder/QueryBuilder.tsx index 75c294f..a480919 100644 --- a/src/components/queryBuilder/QueryBuilder.tsx +++ b/src/components/queryBuilder/QueryBuilder.tsx @@ -230,6 +230,7 @@ export const QueryBuilder = (props: QueryBuilderProps) => { onGroupByChange={onGroupByChange} fieldsList={fieldsList} labelAndTooltip={selectors.components.QueryEditor.QueryBuilder.SAMPLE_BY} + isDisabled={builder.timeField.length === 0} /> </EditorRow> )} @@ -264,7 +265,11 @@ export const QueryBuilder = (props: QueryBuilderProps) => { {builder.mode === BuilderMode.Trend && ( <EditorRow> - <SampleByFillEditor fills={builder.sampleByFill || []} onFillsChange={onFillChange} /> + <SampleByFillEditor + fills={builder.sampleByFill || []} + onFillsChange={onFillChange} + isDisabled={builder.timeField.length === 0} + /> </EditorRow> )} @@ -275,6 +280,7 @@ export const QueryBuilder = (props: QueryBuilderProps) => { onGroupByChange={onGroupByChange} fieldsList={fieldsList} labelAndTooltip={selectors.components.QueryEditor.QueryBuilder.GROUP_BY} + isDisabled={builder.timeField.length === 0} /> </EditorRow> )} diff --git a/src/components/queryBuilder/SampleByFillEditor.tsx b/src/components/queryBuilder/SampleByFillEditor.tsx index 45b4d21..6fa02c9 100644 --- a/src/components/queryBuilder/SampleByFillEditor.tsx +++ b/src/components/queryBuilder/SampleByFillEditor.tsx @@ -9,6 +9,7 @@ import { GroupBase, OptionsOrGroups } from 'react-select'; interface FillEditorProps { fills: string[]; onFillsChange: (fills: string[]) => void; + isDisabled: boolean; } const fillModes: SelectableValue[] = []; @@ -85,6 +86,8 @@ export const SampleByFillEditor = (props: FillEditorProps) => { width={50} isClearable={true} hideSelectedOptions={true} + placeholder={props.isDisabled ? 'Table is missing designated timestamp' : 'Choose'} + disabled={props.isDisabled} /> </EditorField> ); From 74aff812c030f12de7c1fcae084f931e9a8d7131 Mon Sep 17 00:00:00 2001 From: Maciej Bodek <maciej.bodek@gmail.com> Date: Mon, 24 Jun 2024 12:08:11 +0200 Subject: [PATCH 10/18] Update tests --- src/components/queryBuilder/GroupBy.test.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/queryBuilder/GroupBy.test.tsx b/src/components/queryBuilder/GroupBy.test.tsx index d040215..bb0363a 100644 --- a/src/components/queryBuilder/GroupBy.test.tsx +++ b/src/components/queryBuilder/GroupBy.test.tsx @@ -1,11 +1,19 @@ import React from 'react'; import { render } from '@testing-library/react'; import { GroupByEditor } from './GroupBy'; -import {selectors} from "../../selectors"; +import { selectors } from '../../selectors'; describe('GroupByEditor', () => { it('renders correctly', () => { - const result = render(<GroupByEditor fieldsList={[]} groupBy={[]} onGroupByChange={() => {}} labelAndTooltip={selectors.components.QueryEditor.QueryBuilder.SAMPLE_BY} />); + const result = render( + <GroupByEditor + fieldsList={[]} + groupBy={[]} + onGroupByChange={() => {}} + isDisabled={false} + labelAndTooltip={selectors.components.QueryEditor.QueryBuilder.SAMPLE_BY} + /> + ); expect(result.container.firstChild).not.toBeNull(); }); }); From 11edc4e41a7dbe2519dc1e7743f7e0f42042462e Mon Sep 17 00:00:00 2001 From: Maciej Bodek <maciej.bodek@gmail.com> Date: Tue, 25 Jun 2024 15:03:44 +0200 Subject: [PATCH 11/18] 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 <maciej.bodek@gmail.com> Date: Wed, 26 Jun 2024 18:06:50 +0200 Subject: [PATCH 12/18] 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<string>(''); + 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<FullField[]> { - if (mockTimeField.length > 0){ - return [{name:mockTimeField, label:mockTimeField, designated: true, type: "timestamp", picklistValues: []}]; - } else { - return []; - } +mockDatasource.fetchFields = async function (table: string): Promise<FullField[]> { + 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<string, FilterOperator>([ 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<Datasource, QuestDBQuery, QuestDBConfig>; 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 ( <> - <QueryHeader query={query} onChange={onChange} onRunQuery={onRunQuery} datasource={props.datasource}/> + <QueryHeader query={query} onChange={onChange} onRunQuery={onRunQuery} datasource={props.datasource} /> <QuestDBEditorByType {...props} /> </> ); From cb39342aaa640e095364d8f9f91119197f72e30d Mon Sep 17 00:00:00 2001 From: Maciej Bodek <maciej.bodek@gmail.com> Date: Wed, 26 Jun 2024 18:22:59 +0200 Subject: [PATCH 13/18] 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 <maciej.bodek@gmail.com> Date: Thu, 27 Jun 2024 10:03:20 +0200 Subject: [PATCH 14/18] 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 <maciej.bodek@gmail.com> Date: Thu, 27 Jun 2024 13:14:44 +0200 Subject: [PATCH 15/18] 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 a6aff138f060fc2a7c17860b003f728a7a5bef20 Mon Sep 17 00:00:00 2001 From: Maciej Bodek <1871646+insmac@users.noreply.github.com> Date: Thu, 11 Jul 2024 13:54:07 +0200 Subject: [PATCH 16/18] Update DEV_GUIDE.md Add artifcact building guide --- DEV_GUIDE.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/DEV_GUIDE.md b/DEV_GUIDE.md index b38d3fc..00baf5f 100644 --- a/DEV_GUIDE.md +++ b/DEV_GUIDE.md @@ -48,3 +48,27 @@ docker run -d -p 9000:9000 -p 8812:8812 --name secure-questdb-server --ulimit no docker exec -it secure-questdb-server bash cp /var/lib/questdb/conf/keys/my-own-ca.crt /usr/local/share/ca-certificates/root.ca.crt update-ca-certificates + +## Building the release artifact + +⚠️ **Important:** The plugin has to be built from the `main` branch, if intended to be released into Grafana as version update. This is because the automated review process task compares the source tree inside the artifact with the current `main` branch of the repo, and fails if they don't match. + +The final plugin artifact has to be signed either by a key using the [@grafana/sign-plugin](https://www.npmjs.com/package/@grafana/sign-plugin) tool. The script needs `GRAFANA_ACCESS_POLICY_TOKEN` ENV variable to be set before hand - it can be obtained in Grafana Cloud's personal account. + +By default, all the assets are built into `dist` directory, which does not match the Grafana's required one, which should match the plugin ID (in this case, `questdb-questdb-datasource`). Therefore, we need to proceed as following: + +```sh +export GRAFANA_ACCESS_POLICY_TOKEN=your_token +nvm use 20 +yarn build +mage -v buildAll +cp -r dist/ questdb-questdb-datasource +npx @grafana/sign-plugin@latest --distDir questdb-questdb-datasource +zip -r questdb-questdb-datasource.zip questdb-questdb-datasource -r +md5 questdb-questdb-datasource.zip +rm -rf questdb-questdb-datasource +``` + +`md5` checksum is needed only during the process of releasing the plugin version update in Grafana Cloud. + +If intended to release into Grafana, the ZIP file has to be uploaded into a publicly available server (i.e. S3 bucket), since the link to it has to be provided during the update process. From d09adb7cf6533fd7579c46d9d5c4447284aef847 Mon Sep 17 00:00:00 2001 From: Maciej Bodek <maciej.bodek@gmail.com> Date: Fri, 19 Jul 2024 16:20:57 +0200 Subject: [PATCH 17/18] 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" From f79f3834e272eee05f04f9e8bcc403783aa0800e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?javier=20ram=C3=ADrez?= <javier@formatinternet.com> Date: Mon, 17 Mar 2025 11:18:21 +0100 Subject: [PATCH 18/18] Update README.md Adding clarification that the plugin can be used for OSS and Enterprise editions of both QuestDB and Grafana --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b4a5568..f0357fc 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,13 @@ # QuestDB data source for Grafana +The QuestDB data source plugin enables querying and visualization of your +QuestDB time series data directly within Grafana. Compatible with all +editions—Grafana OSS, Grafana Enterprise, and Grafana Cloud—it also +fully supports both QuestDB OSS and QuestDB Enterprise. + + <img alt="Sql builder screenshot" src="https://github.com/questdb/grafana-questdb-datasource/blob/main/sql_builder.png?raw=true" width="800" > -The QuestDB data source plugin allows you to query and visualize QuestDB -data from within Grafana. ## Installation