diff --git a/docs/sources/datasources/mssql/query-editor/index.md b/docs/sources/datasources/mssql/query-editor/index.md index 7a6f87ca73b..d582f7be4b3 100644 --- a/docs/sources/datasources/mssql/query-editor/index.md +++ b/docs/sources/datasources/mssql/query-editor/index.md @@ -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="" >}} + ### 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. diff --git a/docs/sources/datasources/mysql/_index.md b/docs/sources/datasources/mysql/_index.md index 8b5e548c2ab..72923a252ae 100644 --- a/docs/sources/datasources/mysql/_index.md +++ b/docs/sources/datasources/mysql/_index.md @@ -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="" >}} + ### 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. diff --git a/docs/sources/datasources/postgres/_index.md b/docs/sources/datasources/postgres/_index.md index ab9cadb35ae..223c47868ed 100644 --- a/docs/sources/datasources/postgres/_index.md +++ b/docs/sources/datasources/postgres/_index.md @@ -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="" >}} + ### 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. diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 32846859797..ab9db38b18d 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -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 | diff --git a/docs/sources/shared/datasources/sql-query-builder-macros.md b/docs/sources/shared/datasources/sql-query-builder-macros.md new file mode 100644 index 00000000000..10425a73ab4 --- /dev/null +++ b/docs/sources/shared/datasources/sql-query-builder-macros.md @@ -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. diff --git a/e2e/plugin-e2e/mysql/mocks/mysql.mocks.ts b/e2e/plugin-e2e/mysql/mocks/mysql.mocks.ts index eb213f0aafc..a72c83903f3 100644 --- a/e2e/plugin-e2e/mysql/mocks/mysql.mocks.ts +++ b/e2e/plugin-e2e/mysql/mocks/mysql.mocks.ts @@ -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: { diff --git a/e2e/plugin-e2e/mysql/mysql.spec.ts b/e2e/plugin-e2e/mysql/mysql.spec.ts index 8adfd456fbc..8d56d882f27 100644 --- a/e2e/plugin-e2e/mysql/mysql.spec.ts +++ b/e2e/plugin-e2e/mysql/mysql.spec.ts @@ -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(); diff --git a/e2e/plugin-e2e/mysql/utils.ts b/e2e/plugin-e2e/mysql/utils.ts new file mode 100644 index 00000000000..6d585a5d1b9 --- /dev/null +++ b/e2e/plugin-e2e/mysql/utils.ts @@ -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(); + } + }); +} diff --git a/e2e/plugin-e2e/mysql/visual-query-builder.spec.ts b/e2e/plugin-e2e/mysql/visual-query-builder.spec.ts new file mode 100644 index 00000000000..706364df9d8 --- /dev/null +++ b/e2e/plugin-e2e/mysql/visual-query-builder.spec.ts @@ -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` + ); +}); diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index d12c8339c54..e1dc98f8c4d 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -190,6 +190,7 @@ export interface FeatureToggles { preserveDashboardStateWhenNavigating?: boolean; alertingCentralAlertHistory?: boolean; pluginProxyPreserveTrailingSlash?: boolean; + sqlQuerybuilderFunctionParameters?: boolean; azureMonitorPrometheusExemplars?: boolean; pinNavItems?: boolean; authZGRPCServer?: boolean; diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 0be814024b2..b52ee83e585 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -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', }, diff --git a/packages/grafana-sql/src/components/SqlComponents.testHelpers.ts b/packages/grafana-sql/src/components/SqlComponents.testHelpers.ts index df20f537a2d..917769b50a1 100644 --- a/packages/grafana-sql/src/components/SqlComponents.testHelpers.ts +++ b/packages/grafana-sql/src/components/SqlComponents.testHelpers.ts @@ -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([])), @@ -13,6 +13,7 @@ const buildMockDB = (): DB => ({ Promise.resolve({ query: { refId: '123' }, error: '', isError: false, isValid: true }) ), dsID: jest.fn(() => 1234), + functions: jest.fn(() => []), getEditorLanguageDefinition: jest.fn(() => ({ id: '4567' })), toRawSql: (_query: SQLQuery) => '', }); diff --git a/packages/grafana-sql/src/components/visual-query-builder/SQLSelectRow.tsx b/packages/grafana-sql/src/components/visual-query-builder/SQLSelectRow.tsx deleted file mode 100644 index b0febbeb747..00000000000 --- a/packages/grafana-sql/src/components/visual-query-builder/SQLSelectRow.tsx +++ /dev/null @@ -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 ( - - ); -} diff --git a/packages/grafana-sql/src/components/visual-query-builder/SelectColumn.tsx b/packages/grafana-sql/src/components/visual-query-builder/SelectColumn.tsx new file mode 100644 index 00000000000..774ef7501f8 --- /dev/null +++ b/packages/grafana-sql/src/components/visual-query-builder/SelectColumn.tsx @@ -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>; + onParameterChange: (value?: string) => void; + value: SelectableValue | null; +} + +export function SelectColumn({ columns, onParameterChange, value }: Props) { + const selectInputId = useId(); + + return ( + + onParameterChange(index)(e.currentTarget.value)} + value={param.name} + aria-label={`Parameter ${index} for column ${columnIndex}`} + data-testid={selectors.components.SQLQueryEditor.selectInputParameter} + addonAfter={ +