From d5215a5be2df149e92c32e8355e570f8bd7e64cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Bedi?= Date: Mon, 8 Dec 2025 23:18:00 +0100 Subject: [PATCH] PostgreSQL: Add variable query editor support - Introduced a new feature toggle for the PostgreSQL variable query editor `postgresVariableQueryEditor`. --- .../src/types/featureToggles.gen.ts | 4 + packages/grafana-sql/package.json | 1 + .../src/components/QueryEditor.tsx | 4 +- .../src/components/QueryHeader.tsx | 59 +++--- .../src/datasource/SqlDatasource.ts | 29 +-- pkg/services/featuremgmt/registry.go | 7 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.json | 13 ++ .../VariableQueryEditor.tsx | 20 ++ .../datasource.ts | 10 +- .../migrations.ts | 20 ++ .../responseParser.test.ts | 177 ++++++++++++++++++ .../responseParser.ts | 62 ++++++ .../grafana-postgresql-datasource/types.ts | 6 +- .../variables.ts | 27 +++ yarn.lock | 1 + 16 files changed, 400 insertions(+), 41 deletions(-) create mode 100644 public/app/plugins/datasource/grafana-postgresql-datasource/VariableQueryEditor.tsx create mode 100644 public/app/plugins/datasource/grafana-postgresql-datasource/migrations.ts create mode 100644 public/app/plugins/datasource/grafana-postgresql-datasource/responseParser.test.ts create mode 100644 public/app/plugins/datasource/grafana-postgresql-datasource/responseParser.ts create mode 100644 public/app/plugins/datasource/grafana-postgresql-datasource/variables.ts diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index eba76d2c198..b56cd5f9ed0 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1161,6 +1161,10 @@ export interface FeatureToggles { */ jaegerEnableGrpcEndpoint?: boolean; /** + * Enable the new variable query editor for the PostgreSQL data source + */ + postgresVariableQueryEditor?: boolean; + /** * Load plugins on store service startup instead of wire provider, and call RegisterFixedRoles after all plugins are loaded * @default false */ diff --git a/packages/grafana-sql/package.json b/packages/grafana-sql/package.json index b184683aff2..be5459efa89 100644 --- a/packages/grafana-sql/package.json +++ b/packages/grafana-sql/package.json @@ -21,6 +21,7 @@ "@grafana/i18n": "12.4.0-pre", "@grafana/plugin-ui": "^0.11.0", "@grafana/runtime": "12.4.0-pre", + "@grafana/schema": "12.4.0-pre", "@grafana/ui": "12.4.0-pre", "@react-awesome-query-builder/ui": "6.6.15", "immutable": "5.1.4", diff --git a/packages/grafana-sql/src/components/QueryEditor.tsx b/packages/grafana-sql/src/components/QueryEditor.tsx index fa7bc199209..f0df374004c 100644 --- a/packages/grafana-sql/src/components/QueryEditor.tsx +++ b/packages/grafana-sql/src/components/QueryEditor.tsx @@ -15,7 +15,7 @@ import { RawEditor } from './query-editor-raw/RawEditor'; import { VisualEditor } from './visual-query-builder/VisualEditor'; export interface SqlQueryEditorProps extends QueryEditorProps { - queryHeaderProps?: Pick; + queryHeaderProps?: Pick; } export default function SqlQueryEditor({ @@ -99,6 +99,8 @@ export default function SqlQueryEditor({ query={queryWithDefaults} isQueryRunnable={isQueryRunnable} dialect={dialect} + hideRunButton={queryHeaderProps?.hideRunButton} + hideFormatSelector={queryHeaderProps?.hideFormatSelector} /> diff --git a/packages/grafana-sql/src/components/QueryHeader.tsx b/packages/grafana-sql/src/components/QueryHeader.tsx index fa0cb184d2a..9bd83eb9648 100644 --- a/packages/grafana-sql/src/components/QueryHeader.tsx +++ b/packages/grafana-sql/src/components/QueryHeader.tsx @@ -25,6 +25,8 @@ export interface QueryHeaderProps { preconfiguredDataset: string; query: QueryWithDefaults; queryRowFilter: QueryRowFilter; + hideRunButton?: boolean; + hideFormatSelector?: boolean; } export function QueryHeader({ @@ -37,6 +39,8 @@ export function QueryHeader({ preconfiguredDataset, query, queryRowFilter, + hideRunButton, + hideFormatSelector, }: QueryHeaderProps) { const { editorMode } = query; const [_, copyToClipboard] = useCopyToClipboard(); @@ -123,14 +127,16 @@ export function QueryHeader({ return ( <> - + {!hideFormatSelector && ( + + )} {editorMode === EditorMode.Builder && ( <> @@ -222,26 +228,27 @@ export function QueryHeader({ - {isQueryRunnable ? ( - - ) : ( - - Your query is invalid. Check below for details.
- However, you can still run this query. - - } - placement="top" - > - -
- )} + ) : ( + + Your query is invalid. Check below for details.
+ However, you can still run this query. + + } + placement="top" + > + +
+ ))} diff --git a/packages/grafana-sql/src/datasource/SqlDatasource.ts b/packages/grafana-sql/src/datasource/SqlDatasource.ts index f3e3f6880ae..e6094c416d9 100644 --- a/packages/grafana-sql/src/datasource/SqlDatasource.ts +++ b/packages/grafana-sql/src/datasource/SqlDatasource.ts @@ -2,20 +2,19 @@ import { lastValueFrom, Observable, throwError } from 'rxjs'; import { map } from 'rxjs/operators'; import { - getDefaultTimeRange, + CoreApp, DataFrame, DataFrameView, - DataQuery, DataQueryRequest, DataQueryResponse, DataSourceInstanceSettings, - MetricFindValue, - ScopedVars, - CoreApp, + getDefaultTimeRange, getSearchFilterScopedVar, LegacyMetricFindQueryOptions, - VariableWithMultiSupport, + MetricFindValue, + ScopedVars, TimeRange, + VariableWithMultiSupport, } from '@grafana/data'; import { EditorMode } from '@grafana/plugin-ui'; import { @@ -24,15 +23,16 @@ import { FetchResponse, getBackendSrv, getTemplateSrv, - toDataQueryResponse, - TemplateSrv, reportInteraction, + TemplateSrv, + toDataQueryResponse, } from '@grafana/runtime'; +import { DataQuery } from '@grafana/schema'; import { ResponseParser } from '../ResponseParser'; import { SqlQueryEditorLazy } from '../components/QueryEditorLazy'; import { MACRO_NAMES } from '../constants'; -import { DB, SQLQuery, SQLOptions, SqlQueryModel, QueryFormat } from '../types'; +import { DB, QueryFormat, SQLOptions, SQLQuery, SqlQueryModel } from '../types'; import migrateAnnotation from '../utils/migration'; export abstract class SqlDatasource extends DataSourceWithBackend { @@ -182,7 +182,7 @@ export abstract class SqlDatasource extends DataSourceWithBackend { + async metricFindQuery(query: SQLQuery | string, options?: LegacyMetricFindQueryOptions): Promise { const range = options?.range; if (range == null) { // i cannot create a scenario where this happens, we handle it just to be sure. @@ -194,12 +194,17 @@ export abstract class SqlDatasource extends DataSourceWithBackend = { + dialect: 'postgres', + hideRunButton: true, + hideFormatSelector: true, +}; + +export function VariableQueryEditor(props: QueryEditorProps) { + const newProps = { + ...props, + query: migrateVariableQuery(props.query), + queryHeaderProps, + }; + return ; +} diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/datasource.ts b/public/app/plugins/datasource/grafana-postgresql-datasource/datasource.ts index 9ea4498db62..0084cda624b 100644 --- a/public/app/plugins/datasource/grafana-postgresql-datasource/datasource.ts +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/datasource.ts @@ -2,7 +2,7 @@ import { v4 as uuidv4 } from 'uuid'; import { DataSourceInstanceSettings, ScopedVars, VariableWithMultiSupport } from '@grafana/data'; import { LanguageDefinition } from '@grafana/plugin-ui'; -import { TemplateSrv } from '@grafana/runtime'; +import { config, TemplateSrv } from '@grafana/runtime'; import { COMMON_FNS, DB, @@ -16,15 +16,23 @@ import { import { PostgresQueryModel } from './PostgresQueryModel'; import { getSchema, getTimescaleDBVersion, getVersion, showTables } from './postgresMetaQuery'; +import { transformMetricFindResponse } from './responseParser'; import { fetchColumns, fetchTables, getSqlCompletionProvider } from './sqlCompletionProvider'; import { getFieldConfig, toRawSql } from './sqlUtil'; import { PostgresOptions } from './types'; +import { SQLVariableSupport } from './variables'; export class PostgresDatasource extends SqlDatasource { sqlLanguageDefinition: LanguageDefinition | undefined = undefined; constructor(instanceSettings: DataSourceInstanceSettings) { super(instanceSettings); + if (config.featureToggles.postgresVariableQueryEditor) { + this.variables = new SQLVariableSupport(this); + this.responseParser = { + transformMetricFindResponse: transformMetricFindResponse, + }; + } } getQueryModel(target?: SQLQuery, templateSrv?: TemplateSrv, scopedVars?: ScopedVars): PostgresQueryModel { diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/migrations.ts b/public/app/plugins/datasource/grafana-postgresql-datasource/migrations.ts new file mode 100644 index 00000000000..71892e742ae --- /dev/null +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/migrations.ts @@ -0,0 +1,20 @@ +import { applyQueryDefaults, type SQLQuery } from '@grafana/sql'; + +import type { VariableQuery } from './types'; + +export function migrateVariableQuery(rawQuery: string | SQLQuery): VariableQuery { + if (typeof rawQuery !== 'string') { + return { + ...rawQuery, + query: rawQuery.rawSql || '', + }; + } + + return { + ...applyQueryDefaults({ + refId: 'SQLVariableQueryEditor-VariableQuery', + rawSql: rawQuery, + }), + query: rawQuery, + }; +} diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/responseParser.test.ts b/public/app/plugins/datasource/grafana-postgresql-datasource/responseParser.test.ts new file mode 100644 index 00000000000..64b4b864029 --- /dev/null +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/responseParser.test.ts @@ -0,0 +1,177 @@ +import { FieldType, DataFrame } from '@grafana/data'; + +import { transformMetricFindResponse } from './responseParser'; + +describe('transformMetricFindResponse function', () => { + it('should handle big arrays', () => { + const stringValues = new Array(150_000).fill('a'); + const numberValues = new Array(150_000).fill(1); + + const frame: DataFrame = { + fields: [ + { name: 'name', type: FieldType.string, config: {}, values: stringValues }, + { name: 'value', type: FieldType.number, config: {}, values: numberValues }, + ], + length: stringValues.length, + }; + + const result = transformMetricFindResponse(frame); + + // With the new logic, first field is text/value and additional fields are properties + // After deduplication, we get 1 unique item since all 'a' values deduplicate + // Fields named 'value' are not added to properties to avoid conflicts + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + text: 'a', + value: 'a', + }); + }); + + it('should use first field as text and value with additional fields as properties', () => { + const frame: DataFrame = { + fields: [ + { name: 'id', type: FieldType.string, config: {}, values: ['user1', 'user2', 'user3'] }, + { + name: 'email', + type: FieldType.string, + config: {}, + values: ['user1@test.com', 'user2@test.com', 'user3@test.com'], + }, + { name: 'role', type: FieldType.string, config: {}, values: ['admin', 'user', 'guest'] }, + ], + length: 3, + }; + + const result = transformMetricFindResponse(frame); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ + text: 'user1', + value: 'user1', + properties: { + email: 'user1@test.com', + role: 'admin', + }, + }); + expect(result[1]).toEqual({ + text: 'user2', + value: 'user2', + properties: { + email: 'user2@test.com', + role: 'user', + }, + }); + expect(result[2]).toEqual({ + text: 'user3', + value: 'user3', + properties: { + email: 'user3@test.com', + role: 'guest', + }, + }); + }); + + it('should handle single field without properties', () => { + const frame: DataFrame = { + fields: [{ name: 'name', type: FieldType.string, config: {}, values: ['value1', 'value2'] }], + length: 2, + }; + + const result = transformMetricFindResponse(frame); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + text: 'value1', + value: 'value1', + }); + expect(result[1]).toEqual({ + text: 'value2', + value: 'value2', + }); + }); + + it('should still handle __text and __value fields', () => { + const frame: DataFrame = { + fields: [ + { name: '__text', type: FieldType.string, config: {}, values: ['Display 1', 'Display 2'] }, + { name: '__value', type: FieldType.string, config: {}, values: ['val1', 'val2'] }, + ], + length: 2, + }; + + const result = transformMetricFindResponse(frame); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + text: 'Display 1', + value: 'val1', + }); + expect(result[1]).toEqual({ + text: 'Display 2', + value: 'val2', + }); + }); + + it('should not add fields named "text" or "value" to properties', () => { + const frame: DataFrame = { + fields: [ + { name: 'id', type: FieldType.string, config: {}, values: ['item1', 'item2'] }, + { name: 'text', type: FieldType.string, config: {}, values: ['Text 1', 'Text 2'] }, + { name: 'value', type: FieldType.string, config: {}, values: ['Value 1', 'Value 2'] }, + { name: 'description', type: FieldType.string, config: {}, values: ['Desc 1', 'Desc 2'] }, + ], + length: 2, + }; + + const result = transformMetricFindResponse(frame); + + expect(result).toHaveLength(2); + // Fields named 'text' and 'value' should not be in properties + expect(result[0]).toEqual({ + text: 'item1', + value: 'item1', + properties: { + description: 'Desc 1', + }, + }); + expect(result[1]).toEqual({ + text: 'item2', + value: 'item2', + properties: { + description: 'Desc 2', + }, + }); + }); + + it('should add additional fields as properties when __text and __value are present', () => { + const frame: DataFrame = { + fields: [ + { name: '__text', type: FieldType.string, config: {}, values: ['Display 1', 'Display 2'] }, + { name: '__value', type: FieldType.string, config: {}, values: ['val1', 'val2'] }, + { name: 'category', type: FieldType.string, config: {}, values: ['cat1', 'cat2'] }, + { name: 'priority', type: FieldType.number, config: {}, values: [1, 2] }, + ], + length: 2, + }; + + const result = transformMetricFindResponse(frame); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + text: 'Display 1', + value: 'val1', + properties: { + category: 'cat1', + priority: '1', + }, + }); + expect(result[1]).toEqual({ + text: 'Display 2', + value: 'val2', + properties: { + category: 'cat2', + priority: '2', + }, + }); + }); +}); diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/responseParser.ts b/public/app/plugins/datasource/grafana-postgresql-datasource/responseParser.ts new file mode 100644 index 00000000000..5a34f3a4de8 --- /dev/null +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/responseParser.ts @@ -0,0 +1,62 @@ +import { uniqBy } from 'lodash'; + +import { DataFrame, Field, MetricFindValue } from '@grafana/data'; + +export function transformMetricFindResponse(frame: DataFrame): MetricFindValue[] { + const textField = frame.fields.find((f) => f.name === '__text'); + const valueField = frame.fields.find((f) => f.name === '__value'); + + let values: MetricFindValue[]; + + if (textField && valueField) { + const additionalFields = frame.fields.filter((f) => f.name !== '__text' && f.name !== '__value'); + values = buildMetricFindValues(textField, valueField, additionalFields); + } else if (frame.fields.length > 0) { + // Support multiple fields by first field as text/value and additional fields as properties + const firstField = frame.fields[0]; + const additionalFields = frame.fields.slice(1); + values = buildMetricFindValues(firstField, firstField, additionalFields); + } else { + values = []; + } + + return uniqBy(values, 'text'); +} + +function buildMetricFindValues(textField: Field, valueField: Field, additionalFields: Field[]): MetricFindValue[] { + const values: MetricFindValue[] = []; + + for (let i = 0; i < textField.values.length; i++) { + const item: MetricFindValue = { + text: '' + textField.values[i], + value: '' + valueField.values[i], + }; + + const properties = buildProperties(additionalFields, i); + if (properties) { + item.properties = properties; + } + + values.push(item); + } + + return values; +} + +function buildProperties(fields: Field[], rowIndex: number): Record | undefined { + if (fields.length === 0) { + return undefined; + } + + const properties: Record = {}; + + for (const field of fields) { + // Skip fields named 'text' or 'value' to avoid conflicts with top-level fields + if (field.name !== 'text' && field.name !== 'value') { + properties[field.name] = '' + field.values[rowIndex]; + } + } + + // Only return properties object if there are actual properties + return Object.keys(properties).length > 0 ? properties : undefined; +} diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/types.ts b/public/app/plugins/datasource/grafana-postgresql-datasource/types.ts index a10a4354ad0..11f30957c6c 100644 --- a/public/app/plugins/datasource/grafana-postgresql-datasource/types.ts +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/types.ts @@ -1,4 +1,4 @@ -import { SQLOptions } from '@grafana/sql'; +import { SQLOptions, SQLQuery } from '@grafana/sql'; export enum PostgresTLSModes { disable = 'disable', @@ -25,3 +25,7 @@ export interface PostgresOptions extends SQLOptions { export interface SecureJsonData { password?: string; } + +export interface VariableQuery extends SQLQuery { + query: string; +} diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/variables.ts b/public/app/plugins/datasource/grafana-postgresql-datasource/variables.ts new file mode 100644 index 00000000000..68dd72ac493 --- /dev/null +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/variables.ts @@ -0,0 +1,27 @@ +import { from, map, Observable } from 'rxjs'; + +import { CustomVariableSupport, DataQueryRequest, MetricFindValue } from '@grafana/data'; +import { applyQueryDefaults, SQLQuery } from '@grafana/sql'; + +import { VariableQueryEditor } from './VariableQueryEditor'; +import { PostgresDatasource } from './datasource'; +import { migrateVariableQuery } from './migrations'; + +export class SQLVariableSupport extends CustomVariableSupport { + constructor(private readonly datasource: PostgresDatasource) { + super(); + } + + editor = VariableQueryEditor; + + query(request: DataQueryRequest): Observable<{ data: MetricFindValue[] }> { + const queryObj = migrateVariableQuery(request.targets[0]); + const result = this.datasource.metricFindQuery(queryObj, { scopedVars: request.scopedVars, range: request.range }); + + return from(result).pipe(map((data) => ({ data }))); + } + + getDefaultQuery(): Partial { + return applyQueryDefaults({ refId: 'SQLVariableQueryEditor-VariableQuery' }); + } +} diff --git a/yarn.lock b/yarn.lock index 057e3613d68..1157ed65807 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3702,6 +3702,7 @@ __metadata: "@grafana/i18n": "npm:12.4.0-pre" "@grafana/plugin-ui": "npm:^0.11.0" "@grafana/runtime": "npm:12.4.0-pre" + "@grafana/schema": "npm:12.4.0-pre" "@grafana/ui": "npm:12.4.0-pre" "@react-awesome-query-builder/ui": "npm:6.6.15" "@testing-library/dom": "npm:10.4.1"