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