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
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. 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) ### Filter data (WHERE)
To add a filter, toggle the **Filter** switch at the top of the editor. 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. | | `$__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. | | `$__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 ### 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. 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. 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) ### Filter data (WHERE)
To add a filter, toggle the **Filter** switch at the top of the editor. 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). | | `$__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). | | `$__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 ## 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. 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. 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) ### Filter data (WHERE)
To add a filter, toggle the **Filter** switch at the top of the editor. 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). | | `$__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). | | `$__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 ## 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. 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 | | `alertingListViewV2` | Enables the new alert list view design |
| `dashboardRestore` | Enables deleted dashboard restore feature | | `dashboardRestore` | Enables deleted dashboard restore feature |
| `alertingCentralAlertHistory` | Enables the new central alert history. | | `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 | | `failWrongDSUID` | Throws an error if a datasource has an invalid UIDs |
| `alertingApiServer` | Register Alerting APIs with the K8s API server | | `alertingApiServer` | Register Alerting APIs with the K8s API server |
| `dataplaneAggregator` | Enable grafana dataplane aggregator | | `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: { results: {
fields: { [refId]: {
status: 200, status: 200,
frames: [ frames: [
{ {
schema: { schema: {
refId: 'fields', refId,
meta: { meta: {
executedQueryString: executedQueryString:
"SELECT column_name, data_type FROM information_schema.columns WHERE table_schema = 'DataMaker' AND table_name = 'RandomIntsWithTimes' ORDER BY column_name", "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 = { export const datasetResponse = {
results: { results: {

View File

@ -1,29 +1,10 @@
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { expect, test } from '@grafana/plugin-e2e'; import { expect, test } from '@grafana/plugin-e2e';
import { import { normalTableName, tableNameWithSpecialCharacter } from './mocks/mysql.mocks';
tablesResponse, import { mockDataSourceRequest } from './utils';
fieldsResponse,
datasetResponse,
normalTableName,
tableNameWithSpecialCharacter,
} from './mocks/mysql.mocks';
test.beforeEach(async ({ context, selectors, explorePage }) => { test.beforeEach(mockDataSourceRequest);
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('code editor autocomplete should handle table name escaping/quoting', async ({ explorePage, selectors, page }) => { test('code editor autocomplete should handle table name escaping/quoting', async ({ explorePage, selectors, page }) => {
await page.getByLabel('Code').check(); 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; preserveDashboardStateWhenNavigating?: boolean;
alertingCentralAlertHistory?: boolean; alertingCentralAlertHistory?: boolean;
pluginProxyPreserveTrailingSlash?: boolean; pluginProxyPreserveTrailingSlash?: boolean;
sqlQuerybuilderFunctionParameters?: boolean;
azureMonitorPrometheusExemplars?: boolean; azureMonitorPrometheusExemplars?: boolean;
pinNavItems?: boolean; pinNavItems?: boolean;
authZGRPCServer?: boolean; authZGRPCServer?: boolean;

View File

@ -1191,12 +1191,17 @@ export const versionedComponents = {
selectColumn: { selectColumn: {
'11.0.0': 'data-testid select-column', '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: { selectAggregation: {
'11.0.0': 'data-testid select-aggregation', '11.0.0': 'data-testid select-aggregation',
}, },
selectAggregationInput: { '11.0.0': 'data-testid select-aggregation-input' },
selectAlias: { selectAlias: {
'11.0.0': 'data-testid select-alias', '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: { filterConjunction: {
'11.0.0': 'data-testid filter-conjunction', '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 { DatasetSelectorProps } from './DatasetSelector';
import { TableSelectorProps } from './TableSelector'; import { TableSelectorProps } from './TableSelector';
const buildMockDB = (): DB => ({ export const buildMockDB = (): DB => ({
datasets: jest.fn(() => Promise.resolve(['dataset1', 'dataset2'])), datasets: jest.fn(() => Promise.resolve(['dataset1', 'dataset2'])),
tables: jest.fn((_ds: string | undefined) => Promise.resolve(['table1', 'table2'])), tables: jest.fn((_ds: string | undefined) => Promise.resolve(['table1', 'table2'])),
fields: jest.fn((_query: SQLQuery, _order?: boolean) => Promise.resolve<SQLSelectableValue[]>([])), 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 }) Promise.resolve<ValidationResults>({ query: { refId: '123' }, error: '', isError: false, isValid: true })
), ),
dsID: jest.fn(() => 1234), dsID: jest.fn(() => 1234),
functions: jest.fn(() => []),
getEditorLanguageDefinition: jest.fn(() => ({ id: '4567' })), getEditorLanguageDefinition: jest.fn(() => ({ id: '4567' })),
toRawSql: (_query: SQLQuery) => '', 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 { SelectableValue, toOption } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { EditorField, Stack } from '@grafana/experimental'; import { EditorField } from '@grafana/experimental';
import { Button, Select, useStyles2 } from '@grafana/ui'; import { config } from '@grafana/runtime';
import { Button, Select, Stack, useStyles2 } from '@grafana/ui';
import { QueryEditorExpressionType, QueryEditorFunctionExpression } from '../../expressions'; import { QueryEditorExpressionType, QueryEditorFunctionExpression } from '../../expressions';
import { SQLExpression, QueryFormat } from '../../types'; import { DB, QueryFormat, SQLExpression, SQLQuery } from '../../types';
import { createFunctionField } from '../../utils/sql.utils'; import { createFunctionField } from '../../utils/sql.utils';
import { useSqlChange } from '../../utils/useSqlChange';
import { SelectColumn } from './SelectColumn';
import { SelectFunctionParameters } from './SelectFunctionParameters';
interface SelectRowProps { interface SelectRowProps {
sql: SQLExpression; query: SQLQuery;
format: QueryFormat | undefined; onQueryChange: (sql: SQLQuery) => void;
onSqlChange: (sql: SQLExpression) => void; db: DB;
columns?: Array<SelectableValue<string>>; columns: Array<SelectableValue<string>>;
functions?: Array<SelectableValue<string>>;
} }
const asteriskValue = { label: '*', value: '*' }; export function SelectRow({ query, onQueryChange, db, columns }: SelectRowProps) {
export function SelectRow({ sql, format, columns, onSqlChange, functions }: SelectRowProps) {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const columnsWithAsterisk = [asteriskValue, ...(columns || [])]; const { onSqlChange } = useSqlChange({ query, onQueryChange, db });
const timeSeriesAliasOpts: Array<SelectableValue<string>> = []; const timeSeriesAliasOpts: Array<SelectableValue<string>> = [];
// Add necessary alias options for time series format // Add necessary alias options for time series format
// when that format has been selected // when that format has been selected
if (format === QueryFormat.Timeseries) { if (query.format === QueryFormat.Timeseries) {
timeSeriesAliasOpts.push({ label: 'time', value: 'time' }); timeSeriesAliasOpts.push({ label: 'time', value: 'time' });
timeSeriesAliasOpts.push({ label: 'value', value: 'value' }); timeSeriesAliasOpts.push({ label: 'value', value: 'value' });
} }
const onColumnChange = useCallback( const onColumnChange = useCallback(
(item: QueryEditorFunctionExpression, index: number) => (column: SelectableValue<string>) => { (item: QueryEditorFunctionExpression, index: number) => (column?: string) => {
let modifiedItem = { ...item }; let modifiedItem = { ...item };
if (!item.parameters?.length) { if (!item.parameters?.length) {
modifiedItem.parameters = [{ type: QueryEditorExpressionType.FunctionParameter, name: column.value } as const]; modifiedItem.parameters = [{ type: QueryEditorExpressionType.FunctionParameter, name: column } as const];
} else { } else {
modifiedItem.parameters = item.parameters.map((p) => 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 = { const newSql: SQLExpression = {
...sql, ...query.sql,
columns: sql.columns?.map((c, i) => (i === index ? modifiedItem : c)), columns: query.sql?.columns?.map((c, i) => (i === index ? modifiedItem : c)),
}; };
onSqlChange(newSql); onSqlChange(newSql);
}, },
[onSqlChange, sql] [onSqlChange, query.sql]
); );
const onAggregationChange = useCallback( const onAggregationChange = useCallback(
@ -59,15 +61,18 @@ export function SelectRow({ sql, format, columns, onSqlChange, functions }: Sele
const newItem = { const newItem = {
...item, ...item,
name: aggregation?.value, name: aggregation?.value,
parameters: [
{ type: QueryEditorExpressionType.FunctionParameter as const, name: item.parameters?.[0]?.name || '' },
],
}; };
const newSql: SQLExpression = { const newSql: SQLExpression = {
...sql, ...query.sql,
columns: sql.columns?.map((c, i) => (i === index ? newItem : c)), columns: query.sql?.columns?.map((c, i) => (i === index ? newItem : c)),
}; };
onSqlChange(newSql); onSqlChange(newSql);
}, },
[onSqlChange, sql] [onSqlChange, query.sql]
); );
const onAliasChange = useCallback( const onAliasChange = useCallback(
@ -81,51 +86,66 @@ export function SelectRow({ sql, format, columns, onSqlChange, functions }: Sele
} }
const newSql: SQLExpression = { const newSql: SQLExpression = {
...sql, ...query.sql,
columns: sql.columns?.map((c, i) => (i === index ? newItem : c)), columns: query.sql?.columns?.map((c, i) => (i === index ? newItem : c)),
}; };
onSqlChange(newSql); onSqlChange(newSql);
}, },
[onSqlChange, sql] [onSqlChange, query.sql]
); );
const removeColumn = useCallback( const removeColumn = useCallback(
(index: number) => () => { (index: number) => () => {
const clone = [...sql.columns!]; const clone = [...(query.sql?.columns || [])];
clone.splice(index, 1); clone.splice(index, 1);
const newSql: SQLExpression = { const newSql: SQLExpression = {
...sql, ...query.sql,
columns: clone, columns: clone,
}; };
onSqlChange(newSql); onSqlChange(newSql);
}, },
[onSqlChange, sql] [onSqlChange, query.sql]
); );
const addColumn = useCallback(() => { const addColumn = useCallback(() => {
const newSql: SQLExpression = { ...sql, columns: [...sql.columns!, createFunctionField()] }; const newSql: SQLExpression = { ...query.sql, columns: [...(query.sql?.columns || []), createFunctionField()] };
onSqlChange(newSql); 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 ( return (
<Stack gap={2} wrap direction="column"> <Stack gap={2} wrap="wrap" direction="column">
{sql.columns?.map((item, index) => ( {query.sql?.columns?.map((item, index) => (
<div key={index}> <div key={index}>
<Stack gap={2} alignItems="end"> <Stack gap={2} alignItems="end">
<EditorField label="Column" width={25}> {!config.featureToggles.sqlQuerybuilderFunctionParameters && (
<Select <SelectColumn
columns={columns}
onParameterChange={(v) => onColumnChange(item, index)(v)}
value={getColumnValue(item)} value={getColumnValue(item)}
data-testid={selectors.components.SQLQueryEditor.selectColumn}
options={columnsWithAsterisk}
inputId={`select-column-${index}-${uniqueId()}`}
menuShouldPortal
allowCustomValue
onChange={onColumnChange(item, index)}
/> />
</EditorField> )}
<EditorField
<EditorField label="Aggregation" optional width={25}> label={config.featureToggles.sqlQuerybuilderFunctionParameters ? 'Data operations' : 'Aggregation'}
optional
width={25}
>
<Select <Select
value={item.name ? toOption(item.name) : null} value={item.name ? toOption(item.name) : null}
inputId={`select-aggregation-${index}-${uniqueId()}`} inputId={`select-aggregation-${index}-${uniqueId()}`}
@ -133,10 +153,20 @@ export function SelectRow({ sql, format, columns, onSqlChange, functions }: Sele
isClearable isClearable
menuShouldPortal menuShouldPortal
allowCustomValue allowCustomValue
options={functions} options={aggregateOptions()}
onChange={onAggregationChange(item, index)} onChange={onAggregationChange(item, index)}
/> />
</EditorField> </EditorField>
{config.featureToggles.sqlQuerybuilderFunctionParameters && (
<SelectFunctionParameters
currentColumnIndex={index}
columns={columns}
onSqlChange={onSqlChange}
query={query}
db={db}
/>
)}
<EditorField label="Alias" optional width={15}> <EditorField label="Alias" optional width={15}>
<Select <Select
value={item.alias ? toOption(item.alias) : null} value={item.alias ? toOption(item.alias) : null}
@ -174,7 +204,14 @@ export function SelectRow({ sql, format, columns, onSqlChange, functions }: Sele
} }
const getStyles = () => { 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 { 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 { Preview } from './Preview';
import { SQLGroupByRow } from './SQLGroupByRow'; import { SQLGroupByRow } from './SQLGroupByRow';
import { SQLOrderByRow } from './SQLOrderByRow'; import { SQLOrderByRow } from './SQLOrderByRow';
import { SQLSelectRow } from './SQLSelectRow';
import { SQLWhereRow } from './SQLWhereRow'; import { SQLWhereRow } from './SQLWhereRow';
import { SelectRow } from './SelectRow';
interface VisualEditorProps extends QueryEditorProps { interface VisualEditorProps extends QueryEditorProps {
db: DB; db: DB;
@ -27,7 +27,7 @@ export const VisualEditor = ({ query, db, queryRowFilter, onChange, onValidate,
<> <>
<EditorRows> <EditorRows>
<EditorRow> <EditorRow>
<SQLSelectRow fields={state.value || []} query={query} onQueryChange={onChange} db={db} /> <SelectRow columns={state.value || []} query={query} onQueryChange={onChange} db={db} />
</EditorRow> </EditorRow>
{queryRowFilter.filter && ( {queryRowFilter.filter && (
<EditorRow> <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 = [ export const MACRO_NAMES = [
'$__time', '$__time',

View File

@ -6,8 +6,11 @@ export type {
SQLQuery, SQLQuery,
SqlQueryModel, SqlQueryModel,
SQLSelectableValue, SQLSelectableValue,
Func,
FuncParameter,
} from './types'; } from './types';
export { QueryFormat } from './types'; // this is an enum, we cannot export-type it 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 { SqlDatasource } from './datasource/SqlDatasource';
export { formatSQL } from './utils/formatSQL'; export { formatSQL } from './utils/formatSQL';
export { ConnectionLimits } from './components/configuration/ConnectionLimits'; export { ConnectionLimits } from './components/configuration/ConnectionLimits';

View File

@ -134,7 +134,18 @@ export interface DB {
lookup?: (path?: string) => Promise<Array<{ name: string; completion: string }>>; lookup?: (path?: string) => Promise<Array<{ name: string; completion: string }>>;
getEditorLanguageDefinition: () => LanguageDefinition; getEditorLanguageDefinition: () => LanguageDefinition;
toRawSql: (query: SQLQuery) => string; 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 { export interface QueryEditorProps {

View File

@ -1,6 +1,9 @@
import { SelectableValue, toOption } from '@grafana/data';
import { import {
QueryEditorExpressionType, QueryEditorExpressionType,
QueryEditorFunctionExpression, QueryEditorFunctionExpression,
QueryEditorFunctionParameterExpression,
QueryEditorGroupByExpression, QueryEditorGroupByExpression,
QueryEditorPropertyExpression, QueryEditorPropertyExpression,
QueryEditorPropertyType, QueryEditorPropertyType,
@ -67,3 +70,18 @@ export function createFunctionField(functionName?: string): QueryEditorFunctionE
parameters: [], 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) { func (g *appBuilderGroup) setApp(app app.App) {
g.app = app g.app = app
for i, _ := range g.builders { for i := range g.builders {
g.builders[i].setApp(app) g.builders[i].setApp(app)
} }
} }

View File

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

View File

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

View File

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

View File

@ -1305,6 +1305,13 @@ var (
Owner: grafanaPluginsPlatformSquad, Owner: grafanaPluginsPlatformSquad,
Expression: "false", // disabled by default Expression: "false", // disabled by default
}, },
{
Name: "sqlQuerybuilderFunctionParameters",
Description: "Enables SQL query builder function parameters",
Stage: FeatureStageExperimental,
Owner: grafanaOSSBigTent,
FrontendOnly: true,
},
{ {
Name: "azureMonitorPrometheusExemplars", Name: "azureMonitorPrometheusExemplars",
Description: "Allows configuration of Azure Monitor as a data source that can provide Prometheus exemplars", 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 preserveDashboardStateWhenNavigating,experimental,@grafana/dashboards-squad,false,false,false
alertingCentralAlertHistory,experimental,@grafana/alerting-squad,false,false,true alertingCentralAlertHistory,experimental,@grafana/alerting-squad,false,false,true
pluginProxyPreserveTrailingSlash,GA,@grafana/plugins-platform-backend,false,false,false 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 azureMonitorPrometheusExemplars,preview,@grafana/partner-datasources,false,false,false
pinNavItems,GA,@grafana/grafana-frontend-platform,false,false,false pinNavItems,GA,@grafana/grafana-frontend-platform,false,false,false
authZGRPCServer,experimental,@grafana/identity-access-team,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. // Preserve plugin proxy trailing slash.
FlagPluginProxyPreserveTrailingSlash = "pluginProxyPreserveTrailingSlash" FlagPluginProxyPreserveTrailingSlash = "pluginProxyPreserveTrailingSlash"
// FlagSqlQuerybuilderFunctionParameters
// Enables SQL query builder function parameters
FlagSqlQuerybuilderFunctionParameters = "sqlQuerybuilderFunctionParameters"
// FlagAzureMonitorPrometheusExemplars // FlagAzureMonitorPrometheusExemplars
// Allows configuration of Azure Monitor as a data source that can provide Prometheus exemplars // Allows configuration of Azure Monitor as a data source that can provide Prometheus exemplars
FlagAzureMonitorPrometheusExemplars = "azureMonitorPrometheusExemplars" FlagAzureMonitorPrometheusExemplars = "azureMonitorPrometheusExemplars"

View File

@ -2985,6 +2985,19 @@
"codeowner": "@grafana/grafana-app-platform-squad" "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": { "metadata": {
"name": "sseGroupByDatasource", "name": "sseGroupByDatasource",

View File

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

View File

@ -36,6 +36,12 @@ const fakeDataSourceSrv: DataSourceSrv = {
getInstanceSettings: () => ({ id: 8674 }), getInstanceSettings: () => ({ id: 8674 }),
} as unknown as DataSourceSrv; } 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 origBackendSrv: BackendSrv;
let origDataSourceSrv: DataSourceSrv; let origDataSourceSrv: DataSourceSrv;
beforeAll(() => { beforeAll(() => {
@ -404,8 +410,7 @@ describe('PostgreSQLDatasource', () => {
it('should return a list of fields when fetchFields is called', async () => { it('should return a list of fields when fetchFields is called', async () => {
const fetchFieldsResponse = { const fetchFieldsResponse = {
results: { results: {
columns: { [`columns-${uid}`]: {
refId: 'columns',
frames: [ frames: [
dataFrameToJSON( dataFrameToJSON(
createDataFrame({ createDataFrame({

View File

@ -1,7 +1,18 @@
import { v4 as uuidv4 } from 'uuid';
import { DataSourceInstanceSettings, ScopedVars } from '@grafana/data'; import { DataSourceInstanceSettings, ScopedVars } from '@grafana/data';
import { LanguageDefinition } from '@grafana/experimental'; import { LanguageDefinition } from '@grafana/experimental';
import { TemplateSrv } from '@grafana/runtime'; import { TemplateSrv, config } from '@grafana/runtime';
import { SqlDatasource, DB, SQLQuery, SQLSelectableValue, formatSQL } from '@grafana/sql'; import {
COMMON_FNS,
DB,
FuncParameter,
MACRO_FUNCTIONS,
SQLQuery,
SQLSelectableValue,
SqlDatasource,
formatSQL,
} from '@grafana/sql';
import { PostgresQueryModel } from './PostgresQueryModel'; import { PostgresQueryModel } from './PostgresQueryModel';
import { getSchema, getTimescaleDBVersion, getVersion, showTables } from './postgresMetaQuery'; 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 // if no table-name, we are not able to query for fields
return []; 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[] = []; const result: SQLSelectableValue[] = [];
for (let i = 0; i < schema.length; i++) { for (let i = 0; i < schema.length; i++) {
const column = schema.fields.column.values[i]; const column = schema.fields.column.values[i];
@ -80,6 +93,20 @@ export class PostgresDatasource extends SqlDatasource {
return result; 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 { getDB(): DB {
if (this.db !== undefined) { if (this.db !== undefined) {
return this.db; return this.db;
@ -100,6 +127,7 @@ export class PostgresDatasource extends SqlDatasource {
Promise.resolve({ isError: false, isValid: true, query, error: '', rawSql: query.rawSql }), Promise.resolve({ isError: false, isValid: true, query, error: '', rawSql: query.rawSql }),
dsID: () => this.id, dsID: () => this.id,
toRawSql, toRawSql,
functions: () => this.getFunctions(),
lookup: async () => { lookup: async () => {
const tables = await this.fetchTables(); const tables = await this.fetchTables();
return tables.map((t) => ({ name: t, completion: t })); 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 { DataSourceInstanceSettings, TimeRange } from '@grafana/data';
import { CompletionItemKind, LanguageDefinition, TableIdentifier } from '@grafana/experimental'; import { CompletionItemKind, LanguageDefinition, TableIdentifier } from '@grafana/experimental';
import { getTemplateSrv, TemplateSrv } from '@grafana/runtime'; import { TemplateSrv, config, getTemplateSrv } from '@grafana/runtime';
import { DB, formatSQL, SqlDatasource, SQLQuery } from '@grafana/sql'; import { COMMON_FNS, DB, FuncParameter, SQLQuery, SqlDatasource, formatSQL } from '@grafana/sql';
import { mapFieldsToTypes } from './fields'; import { mapFieldsToTypes } from './fields';
import { buildColumnQuery, buildTableQuery } from './flightsqlMetaQuery'; import { buildColumnQuery, buildTableQuery } from './flightsqlMetaQuery';
@ -57,7 +59,7 @@ export class FlightSQLDatasource extends SqlDatasource {
} }
const interpolatedTable = this.templateSrv.replace(query.table); const interpolatedTable = this.templateSrv.replace(query.table);
const queryString = buildColumnQuery(interpolatedTable, query.dataset); 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) => ({ const fields = frame.map((f) => ({
name: f[0], name: f[0],
text: 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 { getDB(): DB {
if (this.db !== undefined) { if (this.db !== undefined) {
return this.db; return this.db;
@ -114,7 +150,7 @@ export class FlightSQLDatasource extends SqlDatasource {
Promise.resolve({ query, error: '', isError: false, isValid: true }), Promise.resolve({ query, error: '', isError: false, isValid: true }),
dsID: () => this.id, dsID: () => this.id,
toRawSql, toRawSql,
functions: () => ['VARIANCE', 'STDDEV'], functions: () => this.getFunctions(),
getEditorLanguageDefinition: () => this.getSqlLanguageDefinition(), getEditorLanguageDefinition: () => this.getSqlLanguageDefinition(),
}; };
} }

View File

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

View File

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

View File

@ -1,6 +1,9 @@
import { v4 as uuidv4 } from 'uuid';
import { DataSourceInstanceSettings, TimeRange } from '@grafana/data'; import { DataSourceInstanceSettings, TimeRange } from '@grafana/data';
import { CompletionItemKind, LanguageDefinition, TableIdentifier } from '@grafana/experimental'; 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 { mapFieldsToTypes } from './fields';
import { buildColumnQuery, buildTableQuery, showDatabases } from './mySqlMetaQuery'; import { buildColumnQuery, buildTableQuery, showDatabases } from './mySqlMetaQuery';
@ -52,7 +55,7 @@ export class MySqlDatasource extends SqlDatasource {
return []; return [];
} }
const queryString = buildColumnQuery(query.table, query.dataset); 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) => ({ const fields = frame.map((f) => ({
name: f[0], name: f[0],
text: 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 { getDB(): DB {
if (this.db !== undefined) { if (this.db !== undefined) {
return this.db; return this.db;
@ -97,7 +115,7 @@ export class MySqlDatasource extends SqlDatasource {
Promise.resolve({ query, error: '', isError: false, isValid: true }), Promise.resolve({ query, error: '', isError: false, isValid: true }),
dsID: () => this.id, dsID: () => this.id,
toRawSql, toRawSql,
functions: () => ['VARIANCE', 'STDDEV'], functions: () => this.getFunctions(),
getEditorLanguageDefinition: () => this.getSqlLanguageDefinition(), 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', () => { describe('MySQLDatasource', () => {
const defaultRange = getDefaultTimeRange(); // it does not matter what value this has const defaultRange = getDefaultTimeRange(); // it does not matter what value this has
const setupTestContext = (response: unknown, templateSrv?: unknown) => { const setupTestContext = (response: unknown, templateSrv?: unknown) => {
@ -134,7 +140,7 @@ describe('MySQLDatasource', () => {
it('should return a list of fields when fetchFields is called', async () => { it('should return a list of fields when fetchFields is called', async () => {
const fetchFieldsResponse = { const fetchFieldsResponse = {
results: { results: {
fields: { [`fields-${uid}`]: {
refId: 'fields', refId: 'fields',
frames: [ frames: [
dataFrameToJSON( dataFrameToJSON(