Compare commits

...

8 Commits

Author SHA1 Message Date
Zoltán Bedi
8555bcba5b Add tests for migrations 2025-12-11 21:36:38 +01:00
Zoltán Bedi
4c601944e1 Add docs 2025-12-11 21:17:36 +01:00
Zoltán Bedi
a632f1f26a Extract labels 2025-12-11 15:50:53 +01:00
Zoltán Bedi
4d82e4295f Enhance SelectRow component with improved alias option handling
- Updated alias options for time series and variable queries to prevent duplicates and ensure correct options are displayed.
2025-12-10 12:14:54 +01:00
Zoltán Bedi
a91f64b9f5 Refactor response parser and tests for backwards compatibility
- Updated `transformMetricFindResponse` to handle cases without `__text` and `__value` fields, ensuring all values are treated as text-only entries.
- Adjusted test cases to reflect the new behavior and maintain backwards compatibility.
- Enhanced property handling to skip reserved field names when applicable.
2025-12-09 14:25:40 +01:00
Zoltán Bedi
b0e1ff8073 Enhance SQL Query Editor with variable query support
- Added `isVariableQuery` prop to `SqlQueryEditor`, `SelectRow`, and `VisualEditor` components to handle variable queries.
- Updated alias options in `SelectRow` to include variable query specific options.
- Modified `VariableQueryEditor` to set `isVariableQuery` to true when passing props to `SqlQueryEditorLazy`.
2025-12-09 13:19:42 +01:00
Zoltán Bedi
5c455ec2bc Refactor response parser to enhance metric transformation logic
- Updated the `transformMetricFindResponse` function to handle multiple fields more effectively, ensuring all values are included in the output.
- Introduced helper functions for better code organization and readability.
- Adjusted tests to reflect changes in the transformation logic, ensuring accurate validation of properties and deduplication behavior.
2025-12-09 12:05:31 +01:00
Zoltán Bedi
d5215a5be2 PostgreSQL: Add variable query editor support
- Introduced a new feature toggle for the PostgreSQL variable query editor `postgresVariableQueryEditor`.
2025-12-08 23:18:00 +01:00
21 changed files with 559 additions and 55 deletions

View File

@@ -279,7 +279,41 @@ Refer to [Templates](ref:templates) for an introduction to creating template var
If you add a `Query` template variable you can write a PostgreSQL query to retrieve items such as measurement names, key names, or key values, which will be displayed in the drop-down menu.
For example, you can use a variable to retrieve all the values from the `hostname` column in a table by creating the following query in the templating variable _Query_ setting.
The PostgreSQL variable query editor supports both **Builder** and **Code** modes, similar to the standard query editor.
#### Builder mode for variables
{{< admonition type="note" >}}
Builder mode for variable queries is currently behind the `postgresVariableQueryEditor` feature toggle.
{{< /admonition >}}
Builder mode provides a visual interface for creating variable queries. When using Builder mode for variable queries, the **Alias** dropdown includes predefined options `__text` and `__value` to easily create key/value variables.
{{< figure src="/static/img/docs/postgresql-variable-query-editor.png" class="docs-image--no-shadow" caption="PostgreSQL variable query editor in Builder mode" >}}
For example, to create a variable that displays hostnames but uses IDs as values:
1. Select your table from the **Table** dropdown.
2. Add a column for the display text (for example, `hostname`) and set its **Alias** to `__text`.
3. Add another column for the value (for example, `id`) and set its **Alias** to `__value`.
This generates a query equivalent to `SELECT hostname AS __text, id AS __value FROM host`.
#### Multiple properties
When you create a key/value variable with `__text` and `__value`, you can also include additional columns to store extra properties. These additional properties can be accessed using dot notation.
For example, if you have a variable named `server` with columns for `hostname` (as `__text`), `id` (as `__value`), and `region`, you can access the region property using `${server.region}`.
To add multiple properties:
1. Set up your `__text` and `__value` columns as described above.
2. Add additional columns for any extra properties you want to include.
3. Access the properties in your queries or panels using `${variableName.propertyName}`.
#### Code mode for variables
In Code mode, you can write PostgreSQL queries directly. For example, you can use a variable to retrieve all the values from the `hostname` column in a table by creating the following query in the templating variable _Query_ setting.
```sql
SELECT hostname FROM host
@@ -297,7 +331,9 @@ To use time range dependent macros like `$__timeFilter(column)` in your query, y
SELECT event_name FROM event_log WHERE $__timeFilter(time_column)
```
Another option is a query that can create a key/value variable. The query should return two columns that are named `__text` and `__value`. The `__text` column must contain unique values (if not, only the first value is used). This allows the drop-down options to display a text-friendly name as the text while using an ID as the value. For example, a query could use `hostname` as the text and `id` as the value:
Another option is a query that can create a key/value variable. The query should return two columns that are named `__text` and `__value`. The `__text` column must contain unique values (if not, only the first value is used). This allows the drop-down options to display a text-friendly name as the text while using an ID as the value.
You can create key/value variables using Builder mode by selecting the predefined `__text` and `__value` alias options, or write the query directly in Code mode. For example, a query could use `hostname` as the text and `id` as the value:
```sql
SELECT hostname AS __text, id AS __value FROM host

View File

@@ -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
*/

View File

@@ -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",

View File

@@ -15,7 +15,8 @@ import { RawEditor } from './query-editor-raw/RawEditor';
import { VisualEditor } from './visual-query-builder/VisualEditor';
export interface SqlQueryEditorProps extends QueryEditorProps<SqlDatasource, SQLQuery, SQLOptions> {
queryHeaderProps?: Pick<QueryHeaderProps, 'dialect'>;
queryHeaderProps?: Pick<QueryHeaderProps, 'dialect' | 'hideRunButton' | 'hideFormatSelector'>;
isVariableQuery?: boolean;
}
export default function SqlQueryEditor({
@@ -25,6 +26,7 @@ export default function SqlQueryEditor({
onRunQuery,
range,
queryHeaderProps,
isVariableQuery = false,
}: SqlQueryEditorProps) {
const [isQueryRunnable, setIsQueryRunnable] = useState(true);
const db = datasource.getDB();
@@ -99,6 +101,8 @@ export default function SqlQueryEditor({
query={queryWithDefaults}
isQueryRunnable={isQueryRunnable}
dialect={dialect}
hideRunButton={queryHeaderProps?.hideRunButton}
hideFormatSelector={queryHeaderProps?.hideFormatSelector}
/>
<Space v={0.5} />
@@ -111,6 +115,7 @@ export default function SqlQueryEditor({
queryRowFilter={queryRowFilter}
onValidate={setIsQueryRunnable}
range={range}
isVariableQuery={isVariableQuery}
/>
)}

View File

@@ -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 (
<>
<EditorHeader>
<InlineSelect
label={t('grafana-sql.components.query-header.label-format', 'Format')}
value={query.format}
placeholder={t('grafana-sql.components.query-header.placeholder-select-format', 'Select format')}
menuShouldPortal
onChange={onFormatChange}
options={QUERY_FORMAT_OPTIONS}
/>
{!hideFormatSelector && (
<InlineSelect
label={t('grafana-sql.components.query-header.label-format', 'Format')}
value={query.format}
placeholder={t('grafana-sql.components.query-header.placeholder-select-format', 'Select format')}
menuShouldPortal
onChange={onFormatChange}
options={QUERY_FORMAT_OPTIONS}
/>
)}
{editorMode === EditorMode.Builder && (
<>
@@ -222,26 +228,27 @@ export function QueryHeader({
<FlexItem grow={1} />
{isQueryRunnable ? (
<Button icon="play" variant="primary" size="sm" onClick={() => onRunQuery()}>
<Trans i18nKey="grafana-sql.components.query-header.run-query">Run query</Trans>
</Button>
) : (
<Tooltip
theme="error"
content={
<Trans i18nKey="grafana-sql.components.query-header.content-invalid-query">
Your query is invalid. Check below for details. <br />
However, you can still run this query.
</Trans>
}
placement="top"
>
<Button icon="exclamation-triangle" variant="secondary" size="sm" onClick={() => onRunQuery()}>
{!hideRunButton &&
(isQueryRunnable ? (
<Button icon="play" variant="primary" size="sm" onClick={() => onRunQuery()}>
<Trans i18nKey="grafana-sql.components.query-header.run-query">Run query</Trans>
</Button>
</Tooltip>
)}
) : (
<Tooltip
theme="error"
content={
<Trans i18nKey="grafana-sql.components.query-header.content-invalid-query">
Your query is invalid. Check below for details. <br />
However, you can still run this query.
</Trans>
}
placement="top"
>
<Button icon="exclamation-triangle" variant="secondary" size="sm" onClick={() => onRunQuery()}>
<Trans i18nKey="grafana-sql.components.query-header.run-query">Run query</Trans>
</Button>
</Tooltip>
))}
<RadioButtonGroup options={editorModes} size="sm" value={editorMode} onChange={onEditorModeChange} />

View File

@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { uniqueId } from 'lodash';
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { SelectableValue, toOption } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
@@ -20,19 +20,56 @@ interface SelectRowProps {
onQueryChange: (sql: SQLQuery) => void;
db: DB;
columns: Array<SelectableValue<string>>;
isVariableQuery?: boolean;
}
export function SelectRow({ query, onQueryChange, db, columns }: SelectRowProps) {
export function SelectRow({ query, onQueryChange, db, columns, isVariableQuery }: SelectRowProps) {
const styles = useStyles2(getStyles);
const { onSqlChange } = useSqlChange({ query, onQueryChange, db });
const timeSeriesAliasOpts: Array<SelectableValue<string>> = [];
// Add necessary alias options for time series format
// when that format has been selected
if (query.format === QueryFormat.Timeseries) {
timeSeriesAliasOpts.push({ label: t('grafana-sql.components.select-row.label.time', 'time'), value: 'time' });
timeSeriesAliasOpts.push({ label: t('grafana-sql.components.select-row.label.value', 'value'), value: 'value' });
}
// Get currently used aliases from all columns
const usedAliases = useMemo(() => {
const aliases = new Set<string>();
query.sql?.columns?.forEach((col) => {
if (col.alias) {
// Remove quotes from alias
const cleanAlias = col.alias.replace(/"/g, '');
aliases.add(cleanAlias);
}
});
return aliases;
}, [query.sql?.columns]);
// Function to get available alias options for a specific column
const getAliasOptions = useCallback(
(currentAlias?: string): Array<SelectableValue<string>> => {
const aliasOpts: Array<SelectableValue<string>> = [];
const cleanCurrentAlias = currentAlias?.replace(/"/g, '');
// Add necessary alias options for time series format
if (query.format === QueryFormat.Timeseries) {
if (!usedAliases.has('time') || cleanCurrentAlias === 'time') {
aliasOpts.push({ label: t('grafana-sql.components.select-row.label.time', 'time'), value: 'time' });
}
if (!usedAliases.has('value') || cleanCurrentAlias === 'value') {
aliasOpts.push({ label: t('grafana-sql.components.select-row.label.value', 'value'), value: 'value' });
}
}
// Add variable query alias options for __text and __value
if (isVariableQuery) {
if (!usedAliases.has('__text') || cleanCurrentAlias === '__text') {
aliasOpts.push({ label: t('grafana-sql.components.select-row.label.__text', '__text'), value: '__text' });
}
if (!usedAliases.has('__value') || cleanCurrentAlias === '__value') {
aliasOpts.push({ label: t('grafana-sql.components.select-row.label.__value', '__value'), value: '__value' });
}
}
return aliasOpts;
},
[query.format, isVariableQuery, usedAliases]
);
const onAggregationChange = useCallback(
(item: QueryEditorFunctionExpression, index: number) => (aggregation: SelectableValue<string>) => {
@@ -145,7 +182,7 @@ export function SelectRow({ query, onQueryChange, db, columns }: SelectRowProps)
value={item.alias ? toOption(item.alias) : null}
inputId={`select-alias-${index}-${uniqueId()}`}
data-testid={selectors.components.SQLQueryEditor.selectAlias}
options={timeSeriesAliasOpts}
options={getAliasOptions(item.alias)}
onChange={onAliasChange(item, index)}
isClearable
menuShouldPortal

View File

@@ -16,9 +16,18 @@ interface VisualEditorProps extends QueryEditorProps {
db: DB;
queryRowFilter: QueryRowFilter;
onValidate: (isValid: boolean) => void;
isVariableQuery?: boolean;
}
export const VisualEditor = ({ query, db, queryRowFilter, onChange, onValidate, range }: VisualEditorProps) => {
export const VisualEditor = ({
query,
db,
queryRowFilter,
onChange,
onValidate,
range,
isVariableQuery,
}: VisualEditorProps) => {
const state = useAsync(async () => {
const fields = await db.fields(query);
return fields;
@@ -28,7 +37,13 @@ export const VisualEditor = ({ query, db, queryRowFilter, onChange, onValidate,
<>
<EditorRows>
<EditorRow>
<SelectRow columns={state.value || []} query={query} onQueryChange={onChange} db={db} />
<SelectRow
columns={state.value || []}
query={query}
onQueryChange={onChange}
db={db}
isVariableQuery={isVariableQuery}
/>
</EditorRow>
{queryRowFilter.filter && (
<EditorRow>

View File

@@ -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<SQLQuery, SQLOptions> {
@@ -182,7 +182,7 @@ export abstract class SqlDatasource extends DataSourceWithBackend<SQLQuery, SQLO
return;
}
async metricFindQuery(query: string, options?: LegacyMetricFindQueryOptions): Promise<MetricFindValue[]> {
async metricFindQuery(query: SQLQuery | string, options?: LegacyMetricFindQueryOptions): Promise<MetricFindValue[]> {
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<SQLQuery, SQLO
refId = options.variable.name;
}
const queryString = typeof query === 'string' ? query : query.rawSql;
if (!queryString) {
return [];
}
const scopedVars = {
...options?.scopedVars,
...getSearchFilterScopedVar({ query, wildcardChar: '%', options }),
...getSearchFilterScopedVar({ query: queryString, wildcardChar: '%', options }),
};
const rawSql = this.templateSrv.replace(query, scopedVars, this.interpolateVariable);
const rawSql = this.templateSrv.replace(queryString, scopedVars, this.interpolateVariable);
const interpolatedQuery: SQLQuery = {
refId: refId,

View File

@@ -107,6 +107,8 @@
}
},
"label": {
"__text": "__text",
"__value": "__value",
"time": "time",
"value": "value"
},

View File

@@ -1913,6 +1913,13 @@ var (
Stage: FeatureStageExperimental,
Owner: grafanaOSSBigTent,
},
{
Name: "postgresVariableQueryEditor",
Description: "Enable the new variable query editor for the PostgreSQL data source",
Stage: FeatureStageExperimental,
Owner: grafanaOSSBigTent,
FrontendOnly: true,
},
{
Name: "pluginStoreServiceLoading",
Description: "Load plugins on store service startup instead of wire provider, and call RegisterFixedRoles after all plugins are loaded",

View File

@@ -260,6 +260,7 @@ newVizSuggestions,preview,@grafana/dataviz-squad,false,false,true
externalVizSuggestions,experimental,@grafana/dataviz-squad,false,false,true
preventPanelChromeOverflow,preview,@grafana/grafana-frontend-platform,false,false,true
jaegerEnableGrpcEndpoint,experimental,@grafana/oss-big-tent,false,false,false
postgresVariableQueryEditor,experimental,@grafana/oss-big-tent,false,false,true
pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false
newPanelPadding,preview,@grafana/dashboards-squad,false,false,true
onlyStoreActionSets,GA,@grafana/identity-access-team,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
260 externalVizSuggestions experimental @grafana/dataviz-squad false false true
261 preventPanelChromeOverflow preview @grafana/grafana-frontend-platform false false true
262 jaegerEnableGrpcEndpoint experimental @grafana/oss-big-tent false false false
263 postgresVariableQueryEditor experimental @grafana/oss-big-tent false false true
264 pluginStoreServiceLoading experimental @grafana/plugins-platform-backend false false false
265 newPanelPadding preview @grafana/dashboards-squad false false true
266 onlyStoreActionSets GA @grafana/identity-access-team false false false

View File

@@ -2692,6 +2692,19 @@
"expression": "false"
}
},
{
"metadata": {
"name": "postgresVariableQueryEditor",
"resourceVersion": "1765231394616",
"creationTimestamp": "2025-12-08T22:03:14Z"
},
"spec": {
"description": "Enable the new variable query editor for the PostgreSQL data source",
"stage": "experimental",
"codeowner": "@grafana/oss-big-tent",
"frontend": true
}
},
{
"metadata": {
"name": "preferLibraryPanelTitle",

View File

@@ -0,0 +1,21 @@
import { QueryEditorProps } from '@grafana/data';
import { QueryHeaderProps, SQLOptions, SQLQuery, SqlQueryEditorLazy } from '@grafana/sql';
import { PostgresDatasource } from './datasource';
import { migrateVariableQuery } from './migrations';
const queryHeaderProps: Pick<QueryHeaderProps, 'dialect' | 'hideRunButton' | 'hideFormatSelector'> = {
dialect: 'postgres',
hideRunButton: true,
hideFormatSelector: true,
};
export function VariableQueryEditor(props: QueryEditorProps<PostgresDatasource, SQLQuery, SQLOptions>) {
const newProps = {
...props,
query: migrateVariableQuery(props.query),
queryHeaderProps,
isVariableQuery: true,
};
return <SqlQueryEditorLazy {...newProps} />;
}

View File

@@ -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<PostgresOptions>) {
super(instanceSettings);
if (config.featureToggles.postgresVariableQueryEditor) {
this.variables = new SQLVariableSupport(this);
this.responseParser = {
transformMetricFindResponse: transformMetricFindResponse,
};
}
}
getQueryModel(target?: SQLQuery, templateSrv?: TemplateSrv, scopedVars?: ScopedVars): PostgresQueryModel {

View File

@@ -0,0 +1,82 @@
import { QueryFormat, SQLQuery } from '@grafana/sql';
import { migrateVariableQuery } from './migrations';
describe('migrateVariableQuery', () => {
describe('when given a string query (legacy format)', () => {
it('should convert to SQLQuery format with rawSql and query fields', () => {
const result = migrateVariableQuery('SELECT hostname FROM hosts');
expect(result.rawSql).toBe('SELECT hostname FROM hosts');
expect(result.query).toBe('SELECT hostname FROM hosts');
expect(result.refId).toBe('SQLVariableQueryEditor-VariableQuery');
});
it('should handle empty string', () => {
const result = migrateVariableQuery('');
expect(result.rawSql).toBe('');
expect(result.query).toBe('');
});
it('should handle complex SQL queries', () => {
const complexQuery = `SELECT hostname AS __text, id AS __value FROM hosts WHERE region = 'us-east-1'`;
const result = migrateVariableQuery(complexQuery);
expect(result.rawSql).toBe(complexQuery);
expect(result.query).toBe(complexQuery);
});
});
describe('when given an SQLQuery object', () => {
it('should preserve the rawSql and add query field', () => {
const sqlQuery = {
refId: 'A',
rawSql: 'SELECT id FROM table',
};
const result = migrateVariableQuery(sqlQuery);
expect(result.rawSql).toBe('SELECT id FROM table');
expect(result.query).toBe('SELECT id FROM table');
expect(result.refId).toBe('A');
});
it('should handle SQLQuery with empty rawSql', () => {
const sqlQuery = {
refId: 'A',
rawSql: '',
};
const result = migrateVariableQuery(sqlQuery);
expect(result.rawSql).toBe('');
expect(result.query).toBe('');
});
it('should handle SQLQuery without rawSql', () => {
const sqlQuery = {
refId: 'A',
};
const result = migrateVariableQuery(sqlQuery);
expect(result.query).toBe('');
});
it('should preserve all existing SQLQuery properties', () => {
const sqlQuery: SQLQuery = {
refId: 'B',
rawSql: 'SELECT * FROM users',
format: QueryFormat.Table,
table: 'users',
dataset: 'mydb',
};
const result = migrateVariableQuery(sqlQuery);
expect(result.refId).toBe('B');
expect(result.rawSql).toBe('SELECT * FROM users');
expect(result.query).toBe('SELECT * FROM users');
expect(result.format).toBe(QueryFormat.Table);
expect(result.table).toBe('users');
expect(result.dataset).toBe('mydb');
});
});
});

View File

@@ -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,
};
}

View File

@@ -0,0 +1,158 @@
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);
// Without __text and __value fields, all values are added as text-only entries
// 150,000 'a' values + 150,000 1 values = 300,000 total
// After deduplication by text, we get 2 unique items ('a' and 1)
expect(result).toHaveLength(2);
const textValues = result.map((r) => r.text);
expect(textValues).toContain('a');
expect(textValues).toContain(1);
});
it('should add all values from multiple fields without __text/__value (backwards compatible)', () => {
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);
// Without __text and __value, all values from all fields are added as text-only entries
expect(result).toHaveLength(9);
// Entries should only have text, no value or properties
const user1Entry = result.find((r) => r.text === 'user1');
expect(user1Entry).toEqual({ text: 'user1' });
const emailEntry = result.find((r) => r.text === 'user1@test.com');
expect(emailEntry).toEqual({ text: 'user1@test.com' });
});
it('should handle single field (backwards compatible)', () => {
const frame: DataFrame = {
fields: [{ name: 'name', type: FieldType.string, config: {}, values: ['value1', 'value2'] }],
length: 2,
};
const result = transformMetricFindResponse(frame);
expect(result).toHaveLength(2);
// Without __text and __value, values are added as text-only entries
expect(result[0]).toEqual({ text: 'value1' });
expect(result[1]).toEqual({ text: '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 skip fields named "text" or "value" in 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: '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: 'Display 1',
value: 'val1',
properties: {
description: 'Desc 1',
},
});
expect(result[1]).toEqual({
text: 'Display 2',
value: 'val2',
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',
},
});
});
});

View File

@@ -0,0 +1,46 @@
import { uniqBy } from 'lodash';
import { DataFrame, Field, MetricFindValue } from '@grafana/data';
const RESERVED_PROPERTY_NAMES = ['text', 'value', '__text', '__value'];
export function transformMetricFindResponse(frame: DataFrame): MetricFindValue[] {
const values: MetricFindValue[] = [];
const textField = frame.fields.find((f) => f.name === '__text');
const valueField = frame.fields.find((f) => f.name === '__value');
if (textField && valueField) {
for (let i = 0; i < textField.values.length; i++) {
values.push({ text: '' + textField.values[i], value: '' + valueField.values[i] });
const properties = buildProperties(frame.fields, i);
if (properties) {
values[i].properties = properties;
}
}
} else {
for (const field of frame.fields) {
for (const value of field.values) {
values.push({ text: value });
}
}
}
return uniqBy(values, 'text');
}
function buildProperties(fields: Field[], rowIndex: number): Record<string, string> | undefined {
if (fields.length === 0) {
return undefined;
}
const properties: Record<string, string> = {};
for (const field of fields) {
if (!RESERVED_PROPERTY_NAMES.includes(field.name)) {
properties[field.name] = '' + field.values[rowIndex];
}
}
return Object.keys(properties).length > 0 ? properties : undefined;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,31 @@
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<PostgresDatasource, SQLQuery> {
constructor(private readonly datasource: PostgresDatasource) {
super();
}
editor = VariableQueryEditor;
query(request: DataQueryRequest<SQLQuery>): Observable<{ data: MetricFindValue[] }> {
if (!request.targets || request.targets.length === 0) {
return from(Promise.resolve([])).pipe(map((data) => ({ data })));
}
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<SQLQuery> {
return applyQueryDefaults({ refId: 'SQLVariableQueryEditor-VariableQuery' });
}
}

View File

@@ -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"