SQL: Add macro support in select case (#88514)

* Feat: timeGroup macro handling in VQB

* Add tests

* Add functions to SQL ds

* Fix lint errors

* Add feature toggle

* Add rendering based on object

* Fix lint

* Fix CI failures

* Fix tests

* Address review comments

* Add docs

* Fix JSX runtime warnings

* Remove docs part that mentions suggest more macros

* Update docs/sources/shared/datasources/sql-query-builder-macros.md

Co-authored-by: Jack Baldry <jack.baldry@grafana.com>

* Add smoke test for this feature

* lint

* Add supported macros to influx

* Add setupTests.ts to include in tsconfig.json

* Import jest-dom instead of setupTests.ts

---------

Co-authored-by: Jack Baldry <jack.baldry@grafana.com>
This commit is contained in:
Zoltán Bedi 2024-11-04 17:13:35 +01:00 committed by GitHub
parent aacc83be5c
commit 85c696c4ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1103 additions and 139 deletions

View File

@ -129,6 +129,8 @@ You can select an optional aggregation function for the column in the **Aggregat
To add more value columns, click the plus (`+`) button to the right of the column's row.
{{< docs/shared source="grafana" lookup="datasources/sql-query-builder-macros.md" version="<GRAFANA_VERSION>" >}}
### Filter data (WHERE)
To add a filter, toggle the **Filter** switch at the top of the editor.
@ -180,8 +182,6 @@ To simplify syntax and to allow for dynamic components, such as date range filte
| `$__unixEpochGroup(dateColumn,'5m', [fillmode])` | Same as `$__timeGroup` but for times stored as Unix timestamp. |
| `$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])` | Same as above but also adds a column alias. |
To suggest more macros, please [open an issue](https://github.com/grafana/grafana) in our GitHub repo.
### View the interpolated query
The query editor also includes a link named **Generated SQL** that appears after running a query while in panel edit mode.

View File

@ -247,6 +247,8 @@ Using the dropdown, select a column to include in the data. You can also specify
Add further value columns by clicking the plus button and another column dropdown appears.
{{< docs/shared source="grafana" lookup="datasources/sql-query-builder-macros.md" version="<GRAFANA_VERSION>" >}}
### Filter data (WHERE)
To add a filter, toggle the **Filter** switch at the top of the editor.
@ -304,10 +306,6 @@ To simplify syntax and to allow for dynamic parts, like date range filters, the
| `$__unixEpochGroup(dateColumn,'5m', [fillmode])` | Same as $\_\_timeGroup but for times stored as Unix timestamp (`fillMode` only works with time series queries). |
| `$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])` | Same as above but also adds a column alias (`fillMode` only works with time series queries). |
We plan to add many more macros. If you have suggestions for what macros you would like to see, please [open an issue](https://github.com/grafana/grafana) in our GitHub repo.
The query editor has a link named `Generated SQL` that shows up after a query has been executed, while in panel edit mode. Click on it and it will expand and show the raw interpolated SQL string that was executed.
## Table queries
If the `Format as` query option is set to `Table` then you can basically do any type of SQL query. The table panel will automatically show the results of whatever columns and rows your query returns.

View File

@ -155,6 +155,8 @@ Using the dropdown, select a column to include in the data. You can also specify
Add further value columns by clicking the plus button and another column dropdown appears.
{{< docs/shared source="grafana" lookup="datasources/sql-query-builder-macros.md" version="<GRAFANA_VERSION>" >}}
### Filter data (WHERE)
To add a filter, toggle the **Filter** switch at the top of the editor.
@ -250,8 +252,6 @@ Macros can be used within a query to simplify syntax and allow for dynamic parts
| `$__unixEpochGroup(dateColumn,'5m', [fillmode])` | Same as $\_\_timeGroup but for times stored as Unix timestamp (`fillMode` only works with time series queries). |
| `$__unixEpochGroupAlias(dateColumn,'5m', [fillmode])` | Same as above but also adds a column alias (`fillMode` only works with time series queries). |
We plan to add many more macros. If you have suggestions for what macros you would like to see, please [open an issue](https://github.com/grafana/grafana) in our GitHub repo.
## Table queries
If the `Format as` query option is set to `Table` then you can basically do any type of SQL query. The table panel will automatically show the results of whatever columns and rows your query returns.

View File

@ -196,6 +196,7 @@ Experimental features might be changed or removed without prior notice.
| `alertingListViewV2` | Enables the new alert list view design |
| `dashboardRestore` | Enables deleted dashboard restore feature |
| `alertingCentralAlertHistory` | Enables the new central alert history. |
| `sqlQuerybuilderFunctionParameters` | Enables SQL query builder function parameters |
| `failWrongDSUID` | Throws an error if a datasource has an invalid UIDs |
| `alertingApiServer` | Register Alerting APIs with the K8s API server |
| `dataplaneAggregator` | Enable grafana dataplane aggregator |

View File

@ -0,0 +1,22 @@
---
headless: true
labels:
products:
- enterprise
- oss
---
#### Macros
You can enable macros support in the select clause to create time-series queries.
{{< docs/experimental product="Macros support in visual query builder" featureFlag="`sqlQuerybuilderFunctionParameters`" >}}
Use the **Data operations** drop-down to select a macro like `$__timeGroup` or `$__timeGroupAlias`.
Select a time column from the **Column** drop-down and a time interval from the **Interval** drop-down to create a time-series query.
{{< figure src="/media/docs/grafana/data-sources/screenshot-sql-builder-time-series-query.png" class="docs-image--no-shadow" caption="SQL query builder time-series query" >}}
You can also add custom value to the **Data operations**.
For example, a function that's not in the drop-down list.
This allows you to add any number of parameters.

View File

@ -21,14 +21,14 @@ export const tablesResponse = {
},
};
export const fieldsResponse = {
export const fieldsResponse = (refId: string) => ({
results: {
fields: {
[refId]: {
status: 200,
frames: [
{
schema: {
refId: 'fields',
refId,
meta: {
executedQueryString:
"SELECT column_name, data_type FROM information_schema.columns WHERE table_schema = 'DataMaker' AND table_name = 'RandomIntsWithTimes' ORDER BY column_name",
@ -48,7 +48,7 @@ export const fieldsResponse = {
],
},
},
};
});
export const datasetResponse = {
results: {

View File

@ -1,29 +1,10 @@
import { selectors } from '@grafana/e2e-selectors';
import { expect, test } from '@grafana/plugin-e2e';
import {
tablesResponse,
fieldsResponse,
datasetResponse,
normalTableName,
tableNameWithSpecialCharacter,
} from './mocks/mysql.mocks';
import { normalTableName, tableNameWithSpecialCharacter } from './mocks/mysql.mocks';
import { mockDataSourceRequest } from './utils';
test.beforeEach(async ({ context, selectors, explorePage }) => {
await explorePage.datasource.set('gdev-mysql');
await context.route(selectors.apis.DataSource.queryPattern, async (route, request) => {
switch (request.postDataJSON().queries[0].refId) {
case 'tables':
return route.fulfill({ json: tablesResponse, status: 200 });
case 'fields':
return route.fulfill({ json: fieldsResponse, status: 200 });
case 'datasets':
return route.fulfill({ json: datasetResponse, status: 200 });
default:
return route.continue();
}
});
});
test.beforeEach(mockDataSourceRequest);
test('code editor autocomplete should handle table name escaping/quoting', async ({ explorePage, selectors, page }) => {
await page.getByLabel('Code').check();

View File

@ -0,0 +1,23 @@
import { PlaywrightTestArgs } from '@playwright/test';
import { PluginFixture } from '@grafana/plugin-e2e';
import { datasetResponse, fieldsResponse, tablesResponse } from './mocks/mysql.mocks';
export async function mockDataSourceRequest({ context, explorePage, selectors }: PlaywrightTestArgs & PluginFixture) {
await explorePage.datasource.set('gdev-mysql');
await context.route(selectors.apis.DataSource.queryPattern, async (route, request) => {
const refId = request.postDataJSON().queries[0].refId;
if (/fields-.*/g.test(refId)) {
return route.fulfill({ json: fieldsResponse(refId), status: 200 });
}
switch (refId) {
case 'tables':
return route.fulfill({ json: tablesResponse, status: 200 });
case 'datasets':
return route.fulfill({ json: datasetResponse, status: 200 });
default:
return route.continue();
}
});
}

View File

@ -0,0 +1,47 @@
import { selectors } from '@grafana/e2e-selectors';
import { test, expect } from '@grafana/plugin-e2e';
import { normalTableName } from './mocks/mysql.mocks';
import { mockDataSourceRequest } from './utils';
test.beforeEach(mockDataSourceRequest);
test.use({ featureToggles: { sqlQuerybuilderFunctionParameters: true } });
test('visual query builder should handle macros', async ({ explorePage, page }) => {
await explorePage.getByGrafanaSelector(selectors.components.SQLQueryEditor.headerTableSelector).click();
await page.getByText(normalTableName, { exact: true }).click();
// Open Data operations
await explorePage.getByGrafanaSelector(selectors.components.SQLQueryEditor.selectAggregation).click();
const select = page.getByLabel('Select options menu');
await select.locator(page.getByText('$__timeGroupAlias')).click();
// Open column selector
await explorePage.getByGrafanaSelector(selectors.components.SQLQueryEditor.selectFunctionParameter('Column')).click();
await select.locator(page.getByText('createdAt')).click();
// Open Interval selector
await explorePage
.getByGrafanaSelector(selectors.components.SQLQueryEditor.selectFunctionParameter('Interval'))
.click();
await select.locator(page.getByText('$__interval')).click();
await page.getByRole('button', { name: 'Add column' }).click();
await explorePage.getByGrafanaSelector(selectors.components.SQLQueryEditor.selectAggregation).nth(1).click();
await select.locator(page.getByText('AVG')).click();
await explorePage
.getByGrafanaSelector(selectors.components.SQLQueryEditor.selectFunctionParameter('Column'))
.nth(1)
.click();
await select.locator(page.getByText('bigint')).click();
// Validate the query
await expect(
explorePage.getByGrafanaSelector(selectors.components.CodeEditor.container).getByRole('textbox')
).toHaveValue(
`SELECT\n $__timeGroupAlias(createdAt, $__interval),\n AVG(\`bigint\`)\nFROM\n DataMaker.normalTable\nLIMIT\n 50`
);
});

View File

@ -190,6 +190,7 @@ export interface FeatureToggles {
preserveDashboardStateWhenNavigating?: boolean;
alertingCentralAlertHistory?: boolean;
pluginProxyPreserveTrailingSlash?: boolean;
sqlQuerybuilderFunctionParameters?: boolean;
azureMonitorPrometheusExemplars?: boolean;
pinNavItems?: boolean;
authZGRPCServer?: boolean;

View File

@ -1191,12 +1191,17 @@ export const versionedComponents = {
selectColumn: {
'11.0.0': 'data-testid select-column',
},
selectColumnInput: { '11.0.0': 'data-testid select-column-input' },
selectFunctionParameter: { '11.0.0': (name: string) => `data-testid select-function-parameter-${name}` },
selectAggregation: {
'11.0.0': 'data-testid select-aggregation',
},
selectAggregationInput: { '11.0.0': 'data-testid select-aggregation-input' },
selectAlias: {
'11.0.0': 'data-testid select-alias',
},
selectAliasInput: { '11.0.0': 'data-testid select-alias-input' },
selectInputParameter: { '11.0.0': 'data-testid select-input-parameter' },
filterConjunction: {
'11.0.0': 'data-testid filter-conjunction',
},

View File

@ -5,7 +5,7 @@ import { DB, SQLQuery, SQLSelectableValue, ValidationResults } from '../types';
import { DatasetSelectorProps } from './DatasetSelector';
import { TableSelectorProps } from './TableSelector';
const buildMockDB = (): DB => ({
export const buildMockDB = (): DB => ({
datasets: jest.fn(() => Promise.resolve(['dataset1', 'dataset2'])),
tables: jest.fn((_ds: string | undefined) => Promise.resolve(['table1', 'table2'])),
fields: jest.fn((_query: SQLQuery, _order?: boolean) => Promise.resolve<SQLSelectableValue[]>([])),
@ -13,6 +13,7 @@ const buildMockDB = (): DB => ({
Promise.resolve<ValidationResults>({ query: { refId: '123' }, error: '', isError: false, isValid: true })
),
dsID: jest.fn(() => 1234),
functions: jest.fn(() => []),
getEditorLanguageDefinition: jest.fn(() => ({ id: '4567' })),
toRawSql: (_query: SQLQuery) => '',
});

View File

@ -1,30 +0,0 @@
import { SelectableValue, toOption } from '@grafana/data';
import { COMMON_AGGREGATE_FNS } from '../../constants';
import { QueryWithDefaults } from '../../defaults';
import { DB, SQLQuery } from '../../types';
import { useSqlChange } from '../../utils/useSqlChange';
import { SelectRow } from './SelectRow';
interface SQLSelectRowProps {
fields: SelectableValue[];
query: QueryWithDefaults;
onQueryChange: (query: SQLQuery) => void;
db: DB;
}
export function SQLSelectRow({ fields, query, onQueryChange, db }: SQLSelectRowProps) {
const { onSqlChange } = useSqlChange({ query, onQueryChange, db });
const functions = [...COMMON_AGGREGATE_FNS, ...(db.functions?.() || [])].map(toOption);
return (
<SelectRow
columns={fields}
sql={query.sql!}
format={query.format}
functions={functions}
onSqlChange={onSqlChange}
/>
);
}

View File

@ -0,0 +1,30 @@
import { useId } from 'react';
import { SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { EditorField } from '@grafana/experimental';
import { Select } from '@grafana/ui';
interface Props {
columns: Array<SelectableValue<string>>;
onParameterChange: (value?: string) => void;
value: SelectableValue<string> | null;
}
export function SelectColumn({ columns, onParameterChange, value }: Props) {
const selectInputId = useId();
return (
<EditorField label="Column" width={25}>
<Select
value={value}
data-testid={selectors.components.SQLQueryEditor.selectColumn}
inputId={selectInputId}
menuShouldPortal
options={[{ label: '*', value: '*' }, ...columns]}
allowCustomValue
onChange={(s) => onParameterChange(s.value)}
/>
</EditorField>
);
}

View File

@ -0,0 +1,137 @@
import { css } from '@emotion/css';
import { useCallback } from 'react';
import { SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Button, InlineLabel, Input, Stack, useStyles2 } from '@grafana/ui';
import { QueryEditorExpressionType } from '../../expressions';
import { SQLExpression, SQLQuery } from '../../types';
import { getColumnValue } from '../../utils/sql.utils';
import { SelectColumn } from './SelectColumn';
interface Props {
columns: Array<SelectableValue<string>>;
query: SQLQuery;
onSqlChange: (sql: SQLExpression) => void;
onParameterChange: (index: number) => (value?: string) => void;
currentColumnIndex: number;
}
export function SelectCustomFunctionParameters({
columns,
query,
onSqlChange,
onParameterChange,
currentColumnIndex,
}: Props) {
const styles = useStyles2(getStyles);
const macroOrFunction = query.sql?.columns?.[currentColumnIndex];
const addParameter = useCallback(
(index: number) => {
const item = query.sql?.columns?.[index];
if (!item) {
return;
}
item.parameters = item.parameters
? [...item.parameters, { type: QueryEditorExpressionType.FunctionParameter, name: '' }]
: [];
const newSql: SQLExpression = {
...query.sql,
columns: query.sql?.columns?.map((c, i) => (i === index ? item : c)),
};
onSqlChange(newSql);
},
[onSqlChange, query.sql]
);
const removeParameter = useCallback(
(columnIndex: number, index: number) => {
const item = query.sql?.columns?.[columnIndex];
if (!item?.parameters) {
return;
}
item.parameters = item.parameters?.filter((_, i) => i !== index);
const newSql: SQLExpression = {
...query.sql,
columns: query.sql?.columns?.map((c, i) => (i === columnIndex ? item : c)),
};
onSqlChange(newSql);
},
[onSqlChange, query.sql]
);
function renderParameters(columnIndex: number) {
if (!macroOrFunction?.parameters || macroOrFunction.parameters.length <= 1) {
return null;
}
const paramComponents = macroOrFunction.parameters.map((param, index) => {
// Skip the first parameter as it is the column name
if (index === 0) {
return null;
}
return (
<Stack key={index} gap={2}>
<InlineLabel className={styles.label}>,</InlineLabel>
<Input
onChange={(e) => onParameterChange(index)(e.currentTarget.value)}
value={param.name}
aria-label={`Parameter ${index} for column ${columnIndex}`}
data-testid={selectors.components.SQLQueryEditor.selectInputParameter}
addonAfter={
<Button
title="Remove parameter"
type="button"
icon="times"
variant="secondary"
size="md"
onClick={() => removeParameter(columnIndex, index)}
/>
}
/>
</Stack>
);
});
return paramComponents;
}
return (
<>
<InlineLabel className={styles.label}>(</InlineLabel>
<SelectColumn
columns={columns}
onParameterChange={(s) => onParameterChange(0)(s)}
value={getColumnValue(macroOrFunction?.parameters?.[0])}
/>
{renderParameters(currentColumnIndex)}
<Button
type="button"
onClick={() => addParameter(currentColumnIndex)}
variant="secondary"
size="md"
icon="plus"
title="Add parameter"
/>
<InlineLabel className={styles.label}>)</InlineLabel>
</>
);
}
const getStyles = () => {
return {
label: css({
padding: 0,
margin: 0,
width: 'unset',
}),
};
};

View File

@ -0,0 +1,167 @@
import { css } from '@emotion/css';
import { useCallback, useEffect, useId, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { EditorField } from '@grafana/experimental';
import { InlineLabel, Input, Select, Stack, useStyles2 } from '@grafana/ui';
import { QueryEditorExpressionType } from '../../expressions';
import { DB, SQLExpression, SQLQuery } from '../../types';
import { getColumnValue } from '../../utils/sql.utils';
import { SelectColumn } from './SelectColumn';
import { SelectCustomFunctionParameters } from './SelectCustomFunctionParameters';
interface Props {
query: SQLQuery;
onSqlChange: (sql: SQLExpression) => void;
currentColumnIndex: number;
db: DB;
columns: Array<SelectableValue<string>>;
}
export function SelectFunctionParameters({ query, onSqlChange, currentColumnIndex, db, columns }: Props) {
const selectInputId = useId();
const macroOrFunction = query.sql?.columns?.[currentColumnIndex];
const styles = useStyles2(getStyles);
const func = db.functions().find((f) => f.name === macroOrFunction?.name);
const [fieldsFromFunction, setFieldsFromFunction] = useState<Array<Array<SelectableValue<string>>>>([]);
useEffect(() => {
const getFieldsFromFunction = async () => {
if (!func) {
return;
}
const options: Array<Array<SelectableValue<string>>> = [];
for (const param of func.parameters ?? []) {
if (param.options) {
options.push(await param.options(query));
} else {
options.push([]);
}
}
setFieldsFromFunction(options);
};
getFieldsFromFunction();
// It is fine to ignore the warning here and omit the query object
// only table property is used in the query object and whenever table changes the component is re-rendered
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [macroOrFunction?.name]);
const onParameterChange = useCallback(
(index: number, keepIndex?: boolean) => (s: string | undefined) => {
const item = query.sql?.columns?.[currentColumnIndex];
if (!item) {
return;
}
if (!item.parameters) {
item.parameters = [];
}
if (item.parameters[index] === undefined) {
item.parameters[index] = { type: QueryEditorExpressionType.FunctionParameter, name: s };
} else if (s == null && keepIndex) {
// Remove value from index
item.parameters = item.parameters.map((p, i) => (i === index ? { ...p, name: '' } : p));
// Remove the last empty parameter
if (item.parameters[item.parameters.length - 1]?.name === '') {
item.parameters = item.parameters.filter((p) => p.name !== '');
}
} else if (s == null) {
item.parameters = item.parameters.filter((_, i) => i !== index);
} else {
item.parameters = item.parameters.map((p, i) => (i === index ? { ...p, name: s } : p));
}
const newSql: SQLExpression = {
...query.sql,
columns: query.sql?.columns?.map((c, i) => (i === currentColumnIndex ? item : c)),
};
onSqlChange(newSql);
},
[currentColumnIndex, onSqlChange, query.sql]
);
function renderParametersWithFunctions() {
if (!func?.parameters) {
return null;
}
return func?.parameters.map((funcParam, index) => {
return (
<Stack key={index} alignItems="flex-end" gap={2}>
<EditorField label={funcParam.name} width={25} optional={!funcParam.required}>
<>
{funcParam.options ? (
<Select
value={getColumnValue(macroOrFunction?.parameters![index])}
options={fieldsFromFunction?.[index]}
data-testid={selectors.components.SQLQueryEditor.selectFunctionParameter(funcParam.name)}
inputId={selectInputId}
menuShouldPortal
allowCustomValue
isClearable
onChange={(s) => onParameterChange(index, true)(s?.value)}
/>
) : (
<Input
onChange={(e) => onParameterChange(index, true)(e.currentTarget.value)}
value={macroOrFunction?.parameters![index]?.name}
data-testid={selectors.components.SQLQueryEditor.selectInputParameter}
/>
)}
</>
</EditorField>
{func.parameters!.length !== index + 1 && <InlineLabel className={styles.label}>,</InlineLabel>}
</Stack>
);
});
}
// This means that no function is selected, we render a column selector
if (macroOrFunction?.name === undefined) {
return (
<SelectColumn
columns={columns}
onParameterChange={(s) => onParameterChange(0)(s)}
value={getColumnValue(macroOrFunction?.parameters?.[0])}
/>
);
}
// If the function is not found, that means that it might be a custom value
// we let the user add any number of parameters
if (!func) {
return (
<SelectCustomFunctionParameters
query={query}
onSqlChange={onSqlChange}
currentColumnIndex={currentColumnIndex}
columns={columns}
onParameterChange={onParameterChange}
/>
);
}
// Else we render the function parameters based on the provided settings
return (
<>
<InlineLabel className={styles.label}>(</InlineLabel>
{renderParametersWithFunctions()}
<InlineLabel className={styles.label}>)</InlineLabel>
</>
);
}
const getStyles = () => {
return {
label: css({
padding: 0,
margin: 0,
width: 'unset',
}),
};
};

View File

@ -0,0 +1,307 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { selectors } from '@grafana/e2e-selectors';
import { QueryEditorExpressionType } from '../../expressions';
import { SQLQuery } from '../../types';
import { buildMockDB } from '../SqlComponents.testHelpers';
import { SelectRow } from './SelectRow';
// Mock featureToggle sqlQuerybuilderFunctionParameters
jest.mock('@grafana/runtime', () => ({
config: {
featureToggles: {
sqlQuerybuilderFunctionParameters: true,
},
},
}));
describe('SelectRow', () => {
const query = Object.freeze<SQLQuery>({
refId: 'A',
rawSql: '',
sql: {
columns: [
{
name: '$__timeGroup',
parameters: [
{ name: 'createdAt', type: QueryEditorExpressionType.FunctionParameter },
{ name: '$__interval', type: QueryEditorExpressionType.FunctionParameter },
],
alias: 'time',
type: QueryEditorExpressionType.Function,
},
],
},
});
it('should show query passed as a prop', () => {
const onQueryChange = jest.fn();
render(<SelectRow onQueryChange={onQueryChange} query={query} columns={[]} db={buildMockDB()} />);
expect(screen.getByTestId(selectors.components.SQLQueryEditor.selectAggregation)).toHaveTextContent('$__timeGroup');
expect(screen.getByTestId(selectors.components.SQLQueryEditor.selectAlias)).toHaveTextContent('time');
expect(screen.getByTestId(selectors.components.SQLQueryEditor.selectColumn)).toHaveTextContent('createdAt');
expect(screen.getByTestId(selectors.components.SQLQueryEditor.selectInputParameter)).toHaveValue('$__interval');
});
describe('should handle multiple columns manipulations', () => {
it('adding column', () => {
const onQueryChange = jest.fn();
render(<SelectRow onQueryChange={onQueryChange} query={query} columns={[]} db={buildMockDB()} />);
screen.getByRole('button', { name: 'Add column' }).click();
expect(onQueryChange).toHaveBeenCalledWith({
...query,
sql: {
columns: [
...query.sql?.columns!,
{
name: undefined,
parameters: [],
type: QueryEditorExpressionType.Function,
},
],
},
});
});
it('show multiple columns when new column added', () => {
const onQueryChange = jest.fn();
render(
<SelectRow
columns={[]}
onQueryChange={onQueryChange}
db={buildMockDB()}
query={{
...query,
sql: {
...query.sql,
columns: [
...query.sql?.columns!,
{ name: undefined, parameters: [], type: QueryEditorExpressionType.Function },
],
},
}}
/>
);
// Check the first column values
expect(screen.getAllByTestId(selectors.components.SQLQueryEditor.selectAggregation)[0]).toHaveTextContent(
'$__timeGroup'
);
expect(screen.getAllByTestId(selectors.components.SQLQueryEditor.selectAlias)[0]).toHaveTextContent('time');
expect(screen.getAllByTestId(selectors.components.SQLQueryEditor.selectColumn)[0]).toHaveTextContent('createdAt');
expect(screen.getAllByTestId(selectors.components.SQLQueryEditor.selectInputParameter)[0]).toHaveValue(
'$__interval'
);
// Check the second column values
expect(
screen.getAllByTestId(selectors.components.SQLQueryEditor.selectAggregationInput)[1]
).toBeEmptyDOMElement();
expect(screen.getAllByTestId(selectors.components.SQLQueryEditor.selectAliasInput)[1]).toBeEmptyDOMElement();
expect(screen.getAllByTestId(selectors.components.SQLQueryEditor.selectColumnInput)[1]).toBeEmptyDOMElement();
expect(screen.queryAllByTestId(selectors.components.SQLQueryEditor.selectInputParameter)[1]).toBeFalsy();
});
it('removing column', () => {
const onQueryChange = jest.fn();
render(
<SelectRow
columns={[]}
db={buildMockDB()}
onQueryChange={onQueryChange}
query={{
...query,
sql: {
columns: [
...query.sql?.columns!,
{
name: undefined,
parameters: [],
type: QueryEditorExpressionType.Function,
},
],
},
}}
/>
);
screen.getAllByRole('button', { name: 'Remove column' })[1].click();
expect(onQueryChange).toHaveBeenCalledWith(query);
});
it('modifying second column aggregation', async () => {
const onQueryChange = jest.fn();
const db = buildMockDB();
db.functions = () => [{ name: 'AVG' }];
const multipleColumns = Object.freeze<SQLQuery>({
...query,
sql: {
columns: [
...query.sql?.columns!,
{
name: '',
parameters: [{ name: 'gaugeValue', type: QueryEditorExpressionType.FunctionParameter }],
type: QueryEditorExpressionType.Function,
},
],
},
});
render(<SelectRow columns={[]} db={db} onQueryChange={onQueryChange} query={multipleColumns} />);
await userEvent.click(screen.getAllByTestId(selectors.components.SQLQueryEditor.selectAggregation)[1]);
await userEvent.click(screen.getByText('AVG'));
expect(onQueryChange).toHaveBeenCalledWith({
...query,
sql: {
columns: [
...query.sql?.columns!,
{
name: 'AVG',
parameters: [{ name: 'gaugeValue', type: QueryEditorExpressionType.FunctionParameter }],
type: QueryEditorExpressionType.Function,
},
],
},
});
});
it('modifying second column name with custom value', async () => {
const onQueryChange = jest.fn();
const db = buildMockDB();
const multipleColumns = Object.freeze<SQLQuery>({
...query,
sql: {
columns: [
...query.sql?.columns!,
{
name: '',
parameters: [{ name: undefined, type: QueryEditorExpressionType.FunctionParameter }],
type: QueryEditorExpressionType.Function,
},
],
},
});
render(
<SelectRow
db={db}
columns={[{ label: 'newColumn', value: 'newColumn' }]}
onQueryChange={onQueryChange}
query={multipleColumns}
/>
);
await userEvent.click(screen.getAllByTestId(selectors.components.SQLQueryEditor.selectColumn)[1]);
await userEvent.type(
screen.getAllByTestId(selectors.components.SQLQueryEditor.selectColumnInput)[1],
'newColumn2{enter}'
);
expect(onQueryChange).toHaveBeenCalledWith({
...query,
sql: {
columns: [
...query.sql?.columns!,
{
name: '',
parameters: [{ name: 'newColumn2', type: QueryEditorExpressionType.FunctionParameter }],
type: QueryEditorExpressionType.Function,
},
],
},
});
});
it('handles second parameter', async () => {
const onQueryChange = jest.fn();
const db = buildMockDB();
const multipleColumns = Object.freeze<SQLQuery>({
...query,
sql: {
columns: [
...query.sql?.columns!,
{
name: '$__timeGroup',
parameters: [{ name: 'gaugeValue', type: QueryEditorExpressionType.FunctionParameter }],
type: QueryEditorExpressionType.Function,
},
],
},
});
render(
<SelectRow
db={db}
columns={[{ label: 'gaugeValue', value: 'gaugeValue' }]}
onQueryChange={onQueryChange}
query={multipleColumns}
/>
);
await userEvent.click(screen.getAllByRole('button', { name: 'Add parameter' })[1]);
expect(onQueryChange).toHaveBeenCalledWith({
...query,
sql: {
columns: [
...query.sql?.columns!,
{
name: '$__timeGroup',
parameters: [
{ name: 'gaugeValue', type: QueryEditorExpressionType.FunctionParameter },
{ name: '', type: QueryEditorExpressionType.FunctionParameter },
],
type: QueryEditorExpressionType.Function,
},
],
},
});
});
it('handles second parameter removal', () => {
const onQueryChange = jest.fn();
const db = buildMockDB();
render(
<SelectRow
onQueryChange={onQueryChange}
db={db}
columns={[]}
query={{
...query,
sql: {
columns: [
...query.sql?.columns!,
{
name: '$__timeGroup',
parameters: [
{ name: 'gaugeValue', type: QueryEditorExpressionType.FunctionParameter },
{ name: 'null', type: QueryEditorExpressionType.FunctionParameter },
],
type: QueryEditorExpressionType.Function,
},
],
},
}}
/>
);
screen.getAllByRole('button', { name: 'Remove parameter' })[1].click();
expect(onQueryChange).toHaveBeenCalledWith({
...query,
sql: {
columns: [
...query.sql?.columns!,
{
name: '$__timeGroup',
parameters: [{ name: 'gaugeValue', type: QueryEditorExpressionType.FunctionParameter }],
type: QueryEditorExpressionType.Function,
},
],
},
});
});
});
});

View File

@ -4,54 +4,56 @@ import { useCallback } from 'react';
import { SelectableValue, toOption } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { EditorField, Stack } from '@grafana/experimental';
import { Button, Select, useStyles2 } from '@grafana/ui';
import { EditorField } from '@grafana/experimental';
import { config } from '@grafana/runtime';
import { Button, Select, Stack, useStyles2 } from '@grafana/ui';
import { QueryEditorExpressionType, QueryEditorFunctionExpression } from '../../expressions';
import { SQLExpression, QueryFormat } from '../../types';
import { DB, QueryFormat, SQLExpression, SQLQuery } from '../../types';
import { createFunctionField } from '../../utils/sql.utils';
import { useSqlChange } from '../../utils/useSqlChange';
import { SelectColumn } from './SelectColumn';
import { SelectFunctionParameters } from './SelectFunctionParameters';
interface SelectRowProps {
sql: SQLExpression;
format: QueryFormat | undefined;
onSqlChange: (sql: SQLExpression) => void;
columns?: Array<SelectableValue<string>>;
functions?: Array<SelectableValue<string>>;
query: SQLQuery;
onQueryChange: (sql: SQLQuery) => void;
db: DB;
columns: Array<SelectableValue<string>>;
}
const asteriskValue = { label: '*', value: '*' };
export function SelectRow({ sql, format, columns, onSqlChange, functions }: SelectRowProps) {
export function SelectRow({ query, onQueryChange, db, columns }: SelectRowProps) {
const styles = useStyles2(getStyles);
const columnsWithAsterisk = [asteriskValue, ...(columns || [])];
const { onSqlChange } = useSqlChange({ query, onQueryChange, db });
const timeSeriesAliasOpts: Array<SelectableValue<string>> = [];
// Add necessary alias options for time series format
// when that format has been selected
if (format === QueryFormat.Timeseries) {
if (query.format === QueryFormat.Timeseries) {
timeSeriesAliasOpts.push({ label: 'time', value: 'time' });
timeSeriesAliasOpts.push({ label: 'value', value: 'value' });
}
const onColumnChange = useCallback(
(item: QueryEditorFunctionExpression, index: number) => (column: SelectableValue<string>) => {
(item: QueryEditorFunctionExpression, index: number) => (column?: string) => {
let modifiedItem = { ...item };
if (!item.parameters?.length) {
modifiedItem.parameters = [{ type: QueryEditorExpressionType.FunctionParameter, name: column.value } as const];
modifiedItem.parameters = [{ type: QueryEditorExpressionType.FunctionParameter, name: column } as const];
} else {
modifiedItem.parameters = item.parameters.map((p) =>
p.type === QueryEditorExpressionType.FunctionParameter ? { ...p, name: column.value } : p
p.type === QueryEditorExpressionType.FunctionParameter ? { ...p, name: column } : p
);
}
const newSql: SQLExpression = {
...sql,
columns: sql.columns?.map((c, i) => (i === index ? modifiedItem : c)),
...query.sql,
columns: query.sql?.columns?.map((c, i) => (i === index ? modifiedItem : c)),
};
onSqlChange(newSql);
},
[onSqlChange, sql]
[onSqlChange, query.sql]
);
const onAggregationChange = useCallback(
@ -59,15 +61,18 @@ export function SelectRow({ sql, format, columns, onSqlChange, functions }: Sele
const newItem = {
...item,
name: aggregation?.value,
parameters: [
{ type: QueryEditorExpressionType.FunctionParameter as const, name: item.parameters?.[0]?.name || '' },
],
};
const newSql: SQLExpression = {
...sql,
columns: sql.columns?.map((c, i) => (i === index ? newItem : c)),
...query.sql,
columns: query.sql?.columns?.map((c, i) => (i === index ? newItem : c)),
};
onSqlChange(newSql);
},
[onSqlChange, sql]
[onSqlChange, query.sql]
);
const onAliasChange = useCallback(
@ -81,51 +86,66 @@ export function SelectRow({ sql, format, columns, onSqlChange, functions }: Sele
}
const newSql: SQLExpression = {
...sql,
columns: sql.columns?.map((c, i) => (i === index ? newItem : c)),
...query.sql,
columns: query.sql?.columns?.map((c, i) => (i === index ? newItem : c)),
};
onSqlChange(newSql);
},
[onSqlChange, sql]
[onSqlChange, query.sql]
);
const removeColumn = useCallback(
(index: number) => () => {
const clone = [...sql.columns!];
const clone = [...(query.sql?.columns || [])];
clone.splice(index, 1);
const newSql: SQLExpression = {
...sql,
...query.sql,
columns: clone,
};
onSqlChange(newSql);
},
[onSqlChange, sql]
[onSqlChange, query.sql]
);
const addColumn = useCallback(() => {
const newSql: SQLExpression = { ...sql, columns: [...sql.columns!, createFunctionField()] };
const newSql: SQLExpression = { ...query.sql, columns: [...(query.sql?.columns || []), createFunctionField()] };
onSqlChange(newSql);
}, [onSqlChange, sql]);
}, [onSqlChange, query.sql]);
const aggregateOptions = () => {
const options: Array<SelectableValue<string>> = [
{ label: 'Aggregations', options: [] },
{ label: 'Macros', options: [] },
];
for (const func of db.functions()) {
// Create groups for macros
if (func.name.startsWith('$__')) {
options[1].options.push({ label: func.name, value: func.name });
} else {
options[0].options.push({ label: func.name, value: func.name });
}
}
return options;
};
return (
<Stack gap={2} wrap direction="column">
{sql.columns?.map((item, index) => (
<Stack gap={2} wrap="wrap" direction="column">
{query.sql?.columns?.map((item, index) => (
<div key={index}>
<Stack gap={2} alignItems="end">
<EditorField label="Column" width={25}>
<Select
{!config.featureToggles.sqlQuerybuilderFunctionParameters && (
<SelectColumn
columns={columns}
onParameterChange={(v) => onColumnChange(item, index)(v)}
value={getColumnValue(item)}
data-testid={selectors.components.SQLQueryEditor.selectColumn}
options={columnsWithAsterisk}
inputId={`select-column-${index}-${uniqueId()}`}
menuShouldPortal
allowCustomValue
onChange={onColumnChange(item, index)}
/>
</EditorField>
<EditorField label="Aggregation" optional width={25}>
)}
<EditorField
label={config.featureToggles.sqlQuerybuilderFunctionParameters ? 'Data operations' : 'Aggregation'}
optional
width={25}
>
<Select
value={item.name ? toOption(item.name) : null}
inputId={`select-aggregation-${index}-${uniqueId()}`}
@ -133,10 +153,20 @@ export function SelectRow({ sql, format, columns, onSqlChange, functions }: Sele
isClearable
menuShouldPortal
allowCustomValue
options={functions}
options={aggregateOptions()}
onChange={onAggregationChange(item, index)}
/>
</EditorField>
{config.featureToggles.sqlQuerybuilderFunctionParameters && (
<SelectFunctionParameters
currentColumnIndex={index}
columns={columns}
onSqlChange={onSqlChange}
query={query}
db={db}
/>
)}
<EditorField label="Alias" optional width={15}>
<Select
value={item.alias ? toOption(item.alias) : null}
@ -174,7 +204,14 @@ export function SelectRow({ sql, format, columns, onSqlChange, functions }: Sele
}
const getStyles = () => {
return { addButton: css({ alignSelf: 'flex-start' }) };
return {
addButton: css({ alignSelf: 'flex-start' }),
label: css({
padding: 0,
margin: 0,
width: 'unset',
}),
};
};
function getColumnValue({ parameters }: QueryEditorFunctionExpression): SelectableValue<string> | null {

View File

@ -8,8 +8,8 @@ import { QueryToolbox } from '../query-editor-raw/QueryToolbox';
import { Preview } from './Preview';
import { SQLGroupByRow } from './SQLGroupByRow';
import { SQLOrderByRow } from './SQLOrderByRow';
import { SQLSelectRow } from './SQLSelectRow';
import { SQLWhereRow } from './SQLWhereRow';
import { SelectRow } from './SelectRow';
interface VisualEditorProps extends QueryEditorProps {
db: DB;
@ -27,7 +27,7 @@ export const VisualEditor = ({ query, db, queryRowFilter, onChange, onValidate,
<>
<EditorRows>
<EditorRow>
<SQLSelectRow fields={state.value || []} query={query} onQueryChange={onChange} db={db} />
<SelectRow columns={state.value || []} query={query} onQueryChange={onChange} db={db} />
</EditorRow>
{queryRowFilter.filter && (
<EditorRow>

View File

@ -1,4 +1,60 @@
export const COMMON_AGGREGATE_FNS = ['AVG', 'COUNT', 'MAX', 'MIN', 'SUM'];
import { Func, FuncParameter } from './types';
export const COMMON_FNS: Func[] = [
{ name: 'AVG' },
{ name: 'COUNT' },
{ name: 'MAX' },
{ name: 'MIN' },
{ name: 'SUM' },
];
const intervalParam: FuncParameter = {
name: 'Interval',
required: true,
options: () => {
return Promise.resolve([{ label: '$__interval', value: '$__interval' }]);
},
};
const fillParam: FuncParameter = {
name: 'Fill',
required: false,
options: () =>
Promise.resolve([
{ label: '0', value: '0' },
{ label: 'NULL', value: 'NULL' },
{ label: 'previous', value: 'previous' },
]),
};
export const MACRO_FUNCTIONS = (columnParam: FuncParameter) => [
{
name: '$__timeGroup',
description: 'Time grouping function',
parameters: [columnParam, intervalParam, fillParam],
},
{
name: '$__timeGroupAlias',
description: 'Time grouping function with time as alias',
parameters: [columnParam, intervalParam, fillParam],
},
{
name: '$__time',
description: 'An expression to rename the column to time',
parameters: [columnParam],
},
{
name: '$__timeEpoch',
parameters: [columnParam],
},
{
name: '$__unixEpochGroup',
parameters: [columnParam, intervalParam, fillParam],
},
{
name: '$__unixEpochGroupAlias',
parameters: [columnParam, intervalParam, fillParam],
},
];
export const MACRO_NAMES = [
'$__time',

View File

@ -6,8 +6,11 @@ export type {
SQLQuery,
SqlQueryModel,
SQLSelectableValue,
Func,
FuncParameter,
} from './types';
export { QueryFormat } from './types'; // this is an enum, we cannot export-type it
export { COMMON_FNS, MACRO_FUNCTIONS } from './constants';
export { SqlDatasource } from './datasource/SqlDatasource';
export { formatSQL } from './utils/formatSQL';
export { ConnectionLimits } from './components/configuration/ConnectionLimits';

View File

@ -134,7 +134,18 @@ export interface DB {
lookup?: (path?: string) => Promise<Array<{ name: string; completion: string }>>;
getEditorLanguageDefinition: () => LanguageDefinition;
toRawSql: (query: SQLQuery) => string;
functions?: () => string[];
functions: () => Func[];
}
export interface FuncParameter {
name: string;
required?: boolean;
options?: (query: SQLQuery) => Promise<SelectableValue[]>;
}
export interface Func {
name: string;
parameters?: FuncParameter[];
description?: string;
}
export interface QueryEditorProps {

View File

@ -1,6 +1,9 @@
import { SelectableValue, toOption } from '@grafana/data';
import {
QueryEditorExpressionType,
QueryEditorFunctionExpression,
QueryEditorFunctionParameterExpression,
QueryEditorGroupByExpression,
QueryEditorPropertyExpression,
QueryEditorPropertyType,
@ -67,3 +70,18 @@ export function createFunctionField(functionName?: string): QueryEditorFunctionE
parameters: [],
};
}
/**
* Retrieves the column value from a QueryEditorFunctionParameterExpression object.
*
* @param column - The QueryEditorFunctionParameterExpression object representing the column.
* @returns The column value as a SelectableValue<string> or null if the column is undefined or null.
*/
export function getColumnValue(
column?: QueryEditorFunctionParameterExpression | QueryEditorFunctionExpression
): SelectableValue<string> | null {
if (column?.name) {
return toOption(column.name);
}
return null;
}

View File

@ -117,7 +117,7 @@ func newAppBuilderGroup(cfg RunnerConfig, provider app.Provider) (appBuilderGrou
func (g *appBuilderGroup) setApp(app app.App) {
g.app = app
for i, _ := range g.builders {
for i := range g.builders {
g.builders[i].setApp(app)
}
}

View File

@ -14,7 +14,7 @@ var typedResources = map[string]TypeInfo{
NewNamespaceResourceIdent(
folderalpha1.FolderResourceInfo.GroupResource().Group,
folderalpha1.FolderResourceInfo.GroupResource().Resource,
): TypeInfo{Type: "folder2"},
): {Type: "folder2"},
}
func GetTypeInfo(group, resource string) (TypeInfo, bool) {

View File

@ -676,9 +676,9 @@ func TestGetParentNames(t *testing.T) {
{UID: "libraryElementUID-1"},
},
expectedParentNames: map[cloudmigration.MigrateDataType][]string{
cloudmigration.DashboardDataType: []string{"", "Folder A", "Folder B"},
cloudmigration.FolderDataType: []string{"Folder A"},
cloudmigration.LibraryElementDataType: []string{"Folder A"},
cloudmigration.DashboardDataType: {"", "Folder A", "Folder B"},
cloudmigration.FolderDataType: {"Folder A"},
cloudmigration.LibraryElementDataType: {"Folder A"},
},
},
}

View File

@ -28,5 +28,6 @@ const (
enterpriseDatasourcesSquad codeowner = "@grafana/enterprise-datasources"
grafanaSharingSquad codeowner = "@grafana/sharing-squad"
grafanaDatabasesFrontend codeowner = "@grafana/databases-frontend"
grafanaOSSBigTent codeowner = "@grafana/oss-big-tent"
growthAndOnboarding codeowner = "@grafana/growth-and-onboarding"
)

View File

@ -1305,6 +1305,13 @@ var (
Owner: grafanaPluginsPlatformSquad,
Expression: "false", // disabled by default
},
{
Name: "sqlQuerybuilderFunctionParameters",
Description: "Enables SQL query builder function parameters",
Stage: FeatureStageExperimental,
Owner: grafanaOSSBigTent,
FrontendOnly: true,
},
{
Name: "azureMonitorPrometheusExemplars",
Description: "Allows configuration of Azure Monitor as a data source that can provide Prometheus exemplars",

View File

@ -171,6 +171,7 @@ alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,fal
preserveDashboardStateWhenNavigating,experimental,@grafana/dashboards-squad,false,false,false
alertingCentralAlertHistory,experimental,@grafana/alerting-squad,false,false,true
pluginProxyPreserveTrailingSlash,GA,@grafana/plugins-platform-backend,false,false,false
sqlQuerybuilderFunctionParameters,experimental,@grafana/oss-big-tent,false,false,true
azureMonitorPrometheusExemplars,preview,@grafana/partner-datasources,false,false,false
pinNavItems,GA,@grafana/grafana-frontend-platform,false,false,false
authZGRPCServer,experimental,@grafana/identity-access-team,false,false,false

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
171 preserveDashboardStateWhenNavigating experimental @grafana/dashboards-squad false false false
172 alertingCentralAlertHistory experimental @grafana/alerting-squad false false true
173 pluginProxyPreserveTrailingSlash GA @grafana/plugins-platform-backend false false false
174 sqlQuerybuilderFunctionParameters experimental @grafana/oss-big-tent false false true
175 azureMonitorPrometheusExemplars preview @grafana/partner-datasources false false false
176 pinNavItems GA @grafana/grafana-frontend-platform false false false
177 authZGRPCServer experimental @grafana/identity-access-team false false false

View File

@ -695,6 +695,10 @@ const (
// Preserve plugin proxy trailing slash.
FlagPluginProxyPreserveTrailingSlash = "pluginProxyPreserveTrailingSlash"
// FlagSqlQuerybuilderFunctionParameters
// Enables SQL query builder function parameters
FlagSqlQuerybuilderFunctionParameters = "sqlQuerybuilderFunctionParameters"
// FlagAzureMonitorPrometheusExemplars
// Allows configuration of Azure Monitor as a data source that can provide Prometheus exemplars
FlagAzureMonitorPrometheusExemplars = "azureMonitorPrometheusExemplars"

View File

@ -2985,6 +2985,19 @@
"codeowner": "@grafana/grafana-app-platform-squad"
}
},
{
"metadata": {
"name": "sqlQuerybuilderFunctionParameters",
"resourceVersion": "1718487716739",
"creationTimestamp": "2024-06-15T21:41:56Z"
},
"spec": {
"description": "Enables SQL query builder function parameters",
"stage": "experimental",
"codeowner": "@grafana/oss-big-tent",
"frontend": true
}
},
{
"metadata": {
"name": "sseGroupByDatasource",

View File

@ -120,7 +120,7 @@ func (i *Index) AddToBatches(ctx context.Context, list *ListResponse) ([]string,
}
tenants := make([]string, 0, len(tenantsWithChanges))
for tenant, _ := range tenantsWithChanges {
for tenant := range tenantsWithChanges {
tenants = append(tenants, tenant)
}

View File

@ -36,6 +36,12 @@ const fakeDataSourceSrv: DataSourceSrv = {
getInstanceSettings: () => ({ id: 8674 }),
} as unknown as DataSourceSrv;
const uid = '0000';
// mock uuidv4 to give back the same value every time
jest.mock('uuid', () => ({
v4: () => uid,
}));
let origBackendSrv: BackendSrv;
let origDataSourceSrv: DataSourceSrv;
beforeAll(() => {
@ -404,8 +410,7 @@ describe('PostgreSQLDatasource', () => {
it('should return a list of fields when fetchFields is called', async () => {
const fetchFieldsResponse = {
results: {
columns: {
refId: 'columns',
[`columns-${uid}`]: {
frames: [
dataFrameToJSON(
createDataFrame({

View File

@ -1,7 +1,18 @@
import { v4 as uuidv4 } from 'uuid';
import { DataSourceInstanceSettings, ScopedVars } from '@grafana/data';
import { LanguageDefinition } from '@grafana/experimental';
import { TemplateSrv } from '@grafana/runtime';
import { SqlDatasource, DB, SQLQuery, SQLSelectableValue, formatSQL } from '@grafana/sql';
import { TemplateSrv, config } from '@grafana/runtime';
import {
COMMON_FNS,
DB,
FuncParameter,
MACRO_FUNCTIONS,
SQLQuery,
SQLSelectableValue,
SqlDatasource,
formatSQL,
} from '@grafana/sql';
import { PostgresQueryModel } from './PostgresQueryModel';
import { getSchema, getTimescaleDBVersion, getVersion, showTables } from './postgresMetaQuery';
@ -70,7 +81,9 @@ export class PostgresDatasource extends SqlDatasource {
// if no table-name, we are not able to query for fields
return [];
}
const schema = await this.runSql<{ column: string; type: string }>(getSchema(table), { refId: 'columns' });
const schema = await this.runSql<{ column: string; type: string }>(getSchema(table), {
refId: `columns-${uuidv4()}`,
});
const result: SQLSelectableValue[] = [];
for (let i = 0; i < schema.length; i++) {
const column = schema.fields.column.values[i];
@ -80,6 +93,20 @@ export class PostgresDatasource extends SqlDatasource {
return result;
}
getFunctions = (): ReturnType<DB['functions']> => {
if (config.featureToggles.sqlQuerybuilderFunctionParameters) {
const columnParam: FuncParameter = {
name: 'Column',
required: true,
options: (query) => this.fetchFields(query),
};
return [...MACRO_FUNCTIONS(columnParam), ...COMMON_FNS.map((fn) => ({ ...fn, parameters: [columnParam] }))];
} else {
return COMMON_FNS;
}
};
getDB(): DB {
if (this.db !== undefined) {
return this.db;
@ -100,6 +127,7 @@ export class PostgresDatasource extends SqlDatasource {
Promise.resolve({ isError: false, isValid: true, query, error: '', rawSql: query.rawSql }),
dsID: () => this.id,
toRawSql,
functions: () => this.getFunctions(),
lookup: async () => {
const tables = await this.fetchTables();
return tables.map((t) => ({ name: t, completion: t }));

View File

@ -1,7 +1,9 @@
import { v4 as uuidv4 } from 'uuid';
import { DataSourceInstanceSettings, TimeRange } from '@grafana/data';
import { CompletionItemKind, LanguageDefinition, TableIdentifier } from '@grafana/experimental';
import { getTemplateSrv, TemplateSrv } from '@grafana/runtime';
import { DB, formatSQL, SqlDatasource, SQLQuery } from '@grafana/sql';
import { TemplateSrv, config, getTemplateSrv } from '@grafana/runtime';
import { COMMON_FNS, DB, FuncParameter, SQLQuery, SqlDatasource, formatSQL } from '@grafana/sql';
import { mapFieldsToTypes } from './fields';
import { buildColumnQuery, buildTableQuery } from './flightsqlMetaQuery';
@ -57,7 +59,7 @@ export class FlightSQLDatasource extends SqlDatasource {
}
const interpolatedTable = this.templateSrv.replace(query.table);
const queryString = buildColumnQuery(interpolatedTable, query.dataset);
const frame = await this.runSql<string[]>(queryString, { refId: 'fields' });
const frame = await this.runSql<string[]>(queryString, { refId: `fields-${uuidv4}` });
const fields = frame.map((f) => ({
name: f[0],
text: f[0],
@ -102,6 +104,40 @@ export class FlightSQLDatasource extends SqlDatasource {
}
}
getFunctions = (): ReturnType<DB['functions']> => {
const fns = [...COMMON_FNS, { name: 'VARIANCE' }, { name: 'STDDEV' }];
if (config.featureToggles.sqlQuerybuilderFunctionParameters) {
const columnParam: FuncParameter = {
name: 'Column',
required: true,
options: (query) => this.fetchFields(query),
};
const intervalParam: FuncParameter = {
name: 'Interval',
required: true,
options: () => {
return Promise.resolve([{ label: '$__interval', value: '$__interval' }]);
},
};
return [
...fns.map((fn) => ({ ...fn, parameters: [columnParam] })),
{
name: '$__timeGroup',
description: 'Time grouping function',
parameters: [columnParam, intervalParam],
},
{
name: '$__timeGroupAlias',
description: 'Time grouping function with time as alias',
parameters: [columnParam, intervalParam],
},
];
} else {
return fns;
}
};
getDB(): DB {
if (this.db !== undefined) {
return this.db;
@ -114,7 +150,7 @@ export class FlightSQLDatasource extends SqlDatasource {
Promise.resolve({ query, error: '', isError: false, isValid: true }),
dsID: () => this.id,
toRawSql,
functions: () => ['VARIANCE', 'STDDEV'],
functions: () => this.getFunctions(),
getEditorLanguageDefinition: () => this.getSqlLanguageDefinition(),
};
}

View File

@ -25,6 +25,11 @@ jest.mock('@grafana/runtime', () => ({
getBackendSrv: () => backendSrv,
}));
// mock uuidv4 to give back the same value every time
jest.mock('uuid', () => ({
v4: () => '0000',
}));
const instanceSettings = {
id: 1,
uid: 'mssql-datasource',
@ -173,8 +178,7 @@ describe('MSSQLDatasource', () => {
it('should return a list of fields when fetchFields is called', async () => {
const fetchFieldsResponse = {
results: {
columns: {
refId: 'columns',
[`columns-0000`]: {
frames: [
dataFrameToJSON(
createDataFrame({

View File

@ -1,9 +1,20 @@
import { v4 as uuidv4 } from 'uuid';
import { DataSourceInstanceSettings, ScopedVars } from '@grafana/data';
import { LanguageDefinition } from '@grafana/experimental';
import { TemplateSrv } from '@grafana/runtime';
import { DB, SQLQuery, SqlDatasource, SQLSelectableValue, formatSQL } from '@grafana/sql';
import { TemplateSrv, config } from '@grafana/runtime';
import {
COMMON_FNS,
DB,
FuncParameter,
MACRO_FUNCTIONS,
SQLQuery,
SQLSelectableValue,
SqlDatasource,
formatSQL,
} from '@grafana/sql';
import { getSchema, showDatabases, getSchemaAndName } from './MSSqlMetaQuery';
import { getSchema, getSchemaAndName, showDatabases } from './MSSqlMetaQuery';
import { MSSqlQueryModel } from './MSSqlQueryModel';
import { fetchColumns, fetchTables, getSqlCompletionProvider } from './sqlCompletionProvider';
import { getIcon, getRAQBType, toRawSql } from './sqlUtil';
@ -36,7 +47,7 @@ export class MssqlDatasource extends SqlDatasource {
}
const [_, table] = query.table.split('.');
const schema = await this.runSql<{ column: string; type: string }>(getSchema(query.dataset, table), {
refId: 'columns',
refId: `columns-${uuidv4()}`,
});
const result: SQLSelectableValue[] = [];
for (let i = 0; i < schema.length; i++) {
@ -63,6 +74,20 @@ export class MssqlDatasource extends SqlDatasource {
return this.sqlLanguageDefinition;
}
getFunctions = (): ReturnType<DB['functions']> => {
if (config.featureToggles.sqlQuerybuilderFunctionParameters) {
const columnParam: FuncParameter = {
name: 'Column',
required: true,
options: (query) => this.fetchFields(query),
};
return [...MACRO_FUNCTIONS(columnParam), ...COMMON_FNS.map((fn) => ({ ...fn, parameters: [columnParam] }))];
} else {
return COMMON_FNS;
}
};
getDB(): DB {
if (this.db !== undefined) {
return this.db;
@ -83,6 +108,7 @@ export class MssqlDatasource extends SqlDatasource {
dsID: () => this.id,
dispose: (_dsID?: string) => {},
toRawSql,
functions: () => this.getFunctions(),
lookup: async (path?: string) => {
if (!path) {
const datasets = await this.fetchDatasets();

View File

@ -1,6 +1,9 @@
import { v4 as uuidv4 } from 'uuid';
import { DataSourceInstanceSettings, TimeRange } from '@grafana/data';
import { CompletionItemKind, LanguageDefinition, TableIdentifier } from '@grafana/experimental';
import { SqlDatasource, DB, SQLQuery, formatSQL } from '@grafana/sql';
import { config } from '@grafana/runtime';
import { COMMON_FNS, DB, FuncParameter, MACRO_FUNCTIONS, SQLQuery, SqlDatasource, formatSQL } from '@grafana/sql';
import { mapFieldsToTypes } from './fields';
import { buildColumnQuery, buildTableQuery, showDatabases } from './mySqlMetaQuery';
@ -52,7 +55,7 @@ export class MySqlDatasource extends SqlDatasource {
return [];
}
const queryString = buildColumnQuery(query.table, query.dataset);
const frame = await this.runSql<string[]>(queryString, { refId: 'fields' });
const frame = await this.runSql<string[]>(queryString, { refId: `fields-${uuidv4()}` });
const fields = frame.map((f) => ({
name: f[0],
text: f[0],
@ -84,6 +87,21 @@ export class MySqlDatasource extends SqlDatasource {
}
}
getFunctions = (): ReturnType<DB['functions']> => {
const fns = [...COMMON_FNS, { name: 'VARIANCE' }, { name: 'STDDEV' }];
if (config.featureToggles.sqlQuerybuilderFunctionParameters) {
const columnParam: FuncParameter = {
name: 'Column',
required: true,
options: (query) => this.fetchFields(query),
};
return [...MACRO_FUNCTIONS(columnParam), ...fns.map((fn) => ({ ...fn, parameters: [columnParam] }))];
} else {
return fns;
}
};
getDB(): DB {
if (this.db !== undefined) {
return this.db;
@ -97,7 +115,7 @@ export class MySqlDatasource extends SqlDatasource {
Promise.resolve({ query, error: '', isError: false, isValid: true }),
dsID: () => this.id,
toRawSql,
functions: () => ['VARIANCE', 'STDDEV'],
functions: () => this.getFunctions(),
getEditorLanguageDefinition: () => this.getSqlLanguageDefinition(),
};
}

View File

@ -21,6 +21,12 @@ jest.mock('@grafana/runtime', () => ({
}),
}));
const uid = '0000';
// mock uuidv4 to give back the same value every time
jest.mock('uuid', () => ({
v4: () => uid,
}));
describe('MySQLDatasource', () => {
const defaultRange = getDefaultTimeRange(); // it does not matter what value this has
const setupTestContext = (response: unknown, templateSrv?: unknown) => {
@ -134,7 +140,7 @@ describe('MySQLDatasource', () => {
it('should return a list of fields when fetchFields is called', async () => {
const fetchFieldsResponse = {
results: {
fields: {
[`fields-${uid}`]: {
refId: 'fields',
frames: [
dataFrameToJSON(