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:
co-authored by
Jack Baldry
parent
aacc83be5c
commit
85c696c4ad
@@ -5,7 +5,7 @@ import { DB, SQLQuery, SQLSelectableValue, ValidationResults } from '../types';
|
||||
import { DatasetSelectorProps } from './DatasetSelector';
|
||||
import { TableSelectorProps } from './TableSelector';
|
||||
|
||||
const buildMockDB = (): DB => ({
|
||||
export const buildMockDB = (): DB => ({
|
||||
datasets: jest.fn(() => Promise.resolve(['dataset1', 'dataset2'])),
|
||||
tables: jest.fn((_ds: string | undefined) => Promise.resolve(['table1', 'table2'])),
|
||||
fields: jest.fn((_query: SQLQuery, _order?: boolean) => Promise.resolve<SQLSelectableValue[]>([])),
|
||||
@@ -13,6 +13,7 @@ const buildMockDB = (): DB => ({
|
||||
Promise.resolve<ValidationResults>({ query: { refId: '123' }, error: '', isError: false, isValid: true })
|
||||
),
|
||||
dsID: jest.fn(() => 1234),
|
||||
functions: jest.fn(() => []),
|
||||
getEditorLanguageDefinition: jest.fn(() => ({ id: '4567' })),
|
||||
toRawSql: (_query: SQLQuery) => '',
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+137
@@ -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',
|
||||
}),
|
||||
};
|
||||
};
|
||||
+167
@@ -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',
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,54 +4,56 @@ import { useCallback } from 'react';
|
||||
|
||||
import { SelectableValue, toOption } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { EditorField, Stack } from '@grafana/experimental';
|
||||
import { Button, Select, useStyles2 } from '@grafana/ui';
|
||||
import { EditorField } from '@grafana/experimental';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Button, Select, Stack, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { QueryEditorExpressionType, QueryEditorFunctionExpression } from '../../expressions';
|
||||
import { SQLExpression, QueryFormat } from '../../types';
|
||||
import { DB, QueryFormat, SQLExpression, SQLQuery } from '../../types';
|
||||
import { createFunctionField } from '../../utils/sql.utils';
|
||||
import { useSqlChange } from '../../utils/useSqlChange';
|
||||
|
||||
import { SelectColumn } from './SelectColumn';
|
||||
import { SelectFunctionParameters } from './SelectFunctionParameters';
|
||||
|
||||
interface SelectRowProps {
|
||||
sql: SQLExpression;
|
||||
format: QueryFormat | undefined;
|
||||
onSqlChange: (sql: SQLExpression) => void;
|
||||
columns?: Array<SelectableValue<string>>;
|
||||
functions?: Array<SelectableValue<string>>;
|
||||
query: SQLQuery;
|
||||
onQueryChange: (sql: SQLQuery) => void;
|
||||
db: DB;
|
||||
columns: Array<SelectableValue<string>>;
|
||||
}
|
||||
|
||||
const asteriskValue = { label: '*', value: '*' };
|
||||
|
||||
export function SelectRow({ sql, format, columns, onSqlChange, functions }: SelectRowProps) {
|
||||
export function SelectRow({ query, onQueryChange, db, columns }: SelectRowProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const columnsWithAsterisk = [asteriskValue, ...(columns || [])];
|
||||
const { onSqlChange } = useSqlChange({ query, onQueryChange, db });
|
||||
const timeSeriesAliasOpts: Array<SelectableValue<string>> = [];
|
||||
|
||||
// Add necessary alias options for time series format
|
||||
// when that format has been selected
|
||||
if (format === QueryFormat.Timeseries) {
|
||||
if (query.format === QueryFormat.Timeseries) {
|
||||
timeSeriesAliasOpts.push({ label: 'time', value: 'time' });
|
||||
timeSeriesAliasOpts.push({ label: 'value', value: 'value' });
|
||||
}
|
||||
|
||||
const onColumnChange = useCallback(
|
||||
(item: QueryEditorFunctionExpression, index: number) => (column: SelectableValue<string>) => {
|
||||
(item: QueryEditorFunctionExpression, index: number) => (column?: string) => {
|
||||
let modifiedItem = { ...item };
|
||||
if (!item.parameters?.length) {
|
||||
modifiedItem.parameters = [{ type: QueryEditorExpressionType.FunctionParameter, name: column.value } as const];
|
||||
modifiedItem.parameters = [{ type: QueryEditorExpressionType.FunctionParameter, name: column } as const];
|
||||
} else {
|
||||
modifiedItem.parameters = item.parameters.map((p) =>
|
||||
p.type === QueryEditorExpressionType.FunctionParameter ? { ...p, name: column.value } : p
|
||||
p.type === QueryEditorExpressionType.FunctionParameter ? { ...p, name: column } : p
|
||||
);
|
||||
}
|
||||
|
||||
const newSql: SQLExpression = {
|
||||
...sql,
|
||||
columns: sql.columns?.map((c, i) => (i === index ? modifiedItem : c)),
|
||||
...query.sql,
|
||||
columns: query.sql?.columns?.map((c, i) => (i === index ? modifiedItem : c)),
|
||||
};
|
||||
|
||||
onSqlChange(newSql);
|
||||
},
|
||||
[onSqlChange, sql]
|
||||
[onSqlChange, query.sql]
|
||||
);
|
||||
|
||||
const onAggregationChange = useCallback(
|
||||
@@ -59,15 +61,18 @@ export function SelectRow({ sql, format, columns, onSqlChange, functions }: Sele
|
||||
const newItem = {
|
||||
...item,
|
||||
name: aggregation?.value,
|
||||
parameters: [
|
||||
{ type: QueryEditorExpressionType.FunctionParameter as const, name: item.parameters?.[0]?.name || '' },
|
||||
],
|
||||
};
|
||||
const newSql: SQLExpression = {
|
||||
...sql,
|
||||
columns: sql.columns?.map((c, i) => (i === index ? newItem : c)),
|
||||
...query.sql,
|
||||
columns: query.sql?.columns?.map((c, i) => (i === index ? newItem : c)),
|
||||
};
|
||||
|
||||
onSqlChange(newSql);
|
||||
},
|
||||
[onSqlChange, sql]
|
||||
[onSqlChange, query.sql]
|
||||
);
|
||||
|
||||
const onAliasChange = useCallback(
|
||||
@@ -81,51 +86,66 @@ export function SelectRow({ sql, format, columns, onSqlChange, functions }: Sele
|
||||
}
|
||||
|
||||
const newSql: SQLExpression = {
|
||||
...sql,
|
||||
columns: sql.columns?.map((c, i) => (i === index ? newItem : c)),
|
||||
...query.sql,
|
||||
columns: query.sql?.columns?.map((c, i) => (i === index ? newItem : c)),
|
||||
};
|
||||
|
||||
onSqlChange(newSql);
|
||||
},
|
||||
[onSqlChange, sql]
|
||||
[onSqlChange, query.sql]
|
||||
);
|
||||
|
||||
const removeColumn = useCallback(
|
||||
(index: number) => () => {
|
||||
const clone = [...sql.columns!];
|
||||
const clone = [...(query.sql?.columns || [])];
|
||||
clone.splice(index, 1);
|
||||
const newSql: SQLExpression = {
|
||||
...sql,
|
||||
...query.sql,
|
||||
columns: clone,
|
||||
};
|
||||
onSqlChange(newSql);
|
||||
},
|
||||
[onSqlChange, sql]
|
||||
[onSqlChange, query.sql]
|
||||
);
|
||||
|
||||
const addColumn = useCallback(() => {
|
||||
const newSql: SQLExpression = { ...sql, columns: [...sql.columns!, createFunctionField()] };
|
||||
const newSql: SQLExpression = { ...query.sql, columns: [...(query.sql?.columns || []), createFunctionField()] };
|
||||
onSqlChange(newSql);
|
||||
}, [onSqlChange, sql]);
|
||||
}, [onSqlChange, query.sql]);
|
||||
|
||||
const aggregateOptions = () => {
|
||||
const options: Array<SelectableValue<string>> = [
|
||||
{ label: 'Aggregations', options: [] },
|
||||
{ label: 'Macros', options: [] },
|
||||
];
|
||||
for (const func of db.functions()) {
|
||||
// Create groups for macros
|
||||
if (func.name.startsWith('$__')) {
|
||||
options[1].options.push({ label: func.name, value: func.name });
|
||||
} else {
|
||||
options[0].options.push({ label: func.name, value: func.name });
|
||||
}
|
||||
}
|
||||
return options;
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={2} wrap direction="column">
|
||||
{sql.columns?.map((item, index) => (
|
||||
<Stack gap={2} wrap="wrap" direction="column">
|
||||
{query.sql?.columns?.map((item, index) => (
|
||||
<div key={index}>
|
||||
<Stack gap={2} alignItems="end">
|
||||
<EditorField label="Column" width={25}>
|
||||
<Select
|
||||
{!config.featureToggles.sqlQuerybuilderFunctionParameters && (
|
||||
<SelectColumn
|
||||
columns={columns}
|
||||
onParameterChange={(v) => onColumnChange(item, index)(v)}
|
||||
value={getColumnValue(item)}
|
||||
data-testid={selectors.components.SQLQueryEditor.selectColumn}
|
||||
options={columnsWithAsterisk}
|
||||
inputId={`select-column-${index}-${uniqueId()}`}
|
||||
menuShouldPortal
|
||||
allowCustomValue
|
||||
onChange={onColumnChange(item, index)}
|
||||
/>
|
||||
</EditorField>
|
||||
|
||||
<EditorField label="Aggregation" optional width={25}>
|
||||
)}
|
||||
<EditorField
|
||||
label={config.featureToggles.sqlQuerybuilderFunctionParameters ? 'Data operations' : 'Aggregation'}
|
||||
optional
|
||||
width={25}
|
||||
>
|
||||
<Select
|
||||
value={item.name ? toOption(item.name) : null}
|
||||
inputId={`select-aggregation-${index}-${uniqueId()}`}
|
||||
@@ -133,10 +153,20 @@ export function SelectRow({ sql, format, columns, onSqlChange, functions }: Sele
|
||||
isClearable
|
||||
menuShouldPortal
|
||||
allowCustomValue
|
||||
options={functions}
|
||||
options={aggregateOptions()}
|
||||
onChange={onAggregationChange(item, index)}
|
||||
/>
|
||||
</EditorField>
|
||||
{config.featureToggles.sqlQuerybuilderFunctionParameters && (
|
||||
<SelectFunctionParameters
|
||||
currentColumnIndex={index}
|
||||
columns={columns}
|
||||
onSqlChange={onSqlChange}
|
||||
query={query}
|
||||
db={db}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EditorField label="Alias" optional width={15}>
|
||||
<Select
|
||||
value={item.alias ? toOption(item.alias) : null}
|
||||
@@ -174,7 +204,14 @@ export function SelectRow({ sql, format, columns, onSqlChange, functions }: Sele
|
||||
}
|
||||
|
||||
const getStyles = () => {
|
||||
return { addButton: css({ alignSelf: 'flex-start' }) };
|
||||
return {
|
||||
addButton: css({ alignSelf: 'flex-start' }),
|
||||
label: css({
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
width: 'unset',
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
function getColumnValue({ parameters }: QueryEditorFunctionExpression): SelectableValue<string> | null {
|
||||
|
||||
@@ -8,8 +8,8 @@ import { QueryToolbox } from '../query-editor-raw/QueryToolbox';
|
||||
import { Preview } from './Preview';
|
||||
import { SQLGroupByRow } from './SQLGroupByRow';
|
||||
import { SQLOrderByRow } from './SQLOrderByRow';
|
||||
import { SQLSelectRow } from './SQLSelectRow';
|
||||
import { SQLWhereRow } from './SQLWhereRow';
|
||||
import { SelectRow } from './SelectRow';
|
||||
|
||||
interface VisualEditorProps extends QueryEditorProps {
|
||||
db: DB;
|
||||
@@ -27,7 +27,7 @@ export const VisualEditor = ({ query, db, queryRowFilter, onChange, onValidate,
|
||||
<>
|
||||
<EditorRows>
|
||||
<EditorRow>
|
||||
<SQLSelectRow fields={state.value || []} query={query} onQueryChange={onChange} db={db} />
|
||||
<SelectRow columns={state.value || []} query={query} onQueryChange={onChange} db={db} />
|
||||
</EditorRow>
|
||||
{queryRowFilter.filter && (
|
||||
<EditorRow>
|
||||
|
||||
@@ -1,4 +1,60 @@
|
||||
export const COMMON_AGGREGATE_FNS = ['AVG', 'COUNT', 'MAX', 'MIN', 'SUM'];
|
||||
import { Func, FuncParameter } from './types';
|
||||
|
||||
export const COMMON_FNS: Func[] = [
|
||||
{ name: 'AVG' },
|
||||
{ name: 'COUNT' },
|
||||
{ name: 'MAX' },
|
||||
{ name: 'MIN' },
|
||||
{ name: 'SUM' },
|
||||
];
|
||||
|
||||
const intervalParam: FuncParameter = {
|
||||
name: 'Interval',
|
||||
required: true,
|
||||
options: () => {
|
||||
return Promise.resolve([{ label: '$__interval', value: '$__interval' }]);
|
||||
},
|
||||
};
|
||||
const fillParam: FuncParameter = {
|
||||
name: 'Fill',
|
||||
required: false,
|
||||
options: () =>
|
||||
Promise.resolve([
|
||||
{ label: '0', value: '0' },
|
||||
{ label: 'NULL', value: 'NULL' },
|
||||
{ label: 'previous', value: 'previous' },
|
||||
]),
|
||||
};
|
||||
|
||||
export const MACRO_FUNCTIONS = (columnParam: FuncParameter) => [
|
||||
{
|
||||
name: '$__timeGroup',
|
||||
description: 'Time grouping function',
|
||||
parameters: [columnParam, intervalParam, fillParam],
|
||||
},
|
||||
{
|
||||
name: '$__timeGroupAlias',
|
||||
description: 'Time grouping function with time as alias',
|
||||
parameters: [columnParam, intervalParam, fillParam],
|
||||
},
|
||||
{
|
||||
name: '$__time',
|
||||
description: 'An expression to rename the column to time',
|
||||
parameters: [columnParam],
|
||||
},
|
||||
{
|
||||
name: '$__timeEpoch',
|
||||
parameters: [columnParam],
|
||||
},
|
||||
{
|
||||
name: '$__unixEpochGroup',
|
||||
parameters: [columnParam, intervalParam, fillParam],
|
||||
},
|
||||
{
|
||||
name: '$__unixEpochGroupAlias',
|
||||
parameters: [columnParam, intervalParam, fillParam],
|
||||
},
|
||||
];
|
||||
|
||||
export const MACRO_NAMES = [
|
||||
'$__time',
|
||||
|
||||
@@ -6,8 +6,11 @@ export type {
|
||||
SQLQuery,
|
||||
SqlQueryModel,
|
||||
SQLSelectableValue,
|
||||
Func,
|
||||
FuncParameter,
|
||||
} from './types';
|
||||
export { QueryFormat } from './types'; // this is an enum, we cannot export-type it
|
||||
export { COMMON_FNS, MACRO_FUNCTIONS } from './constants';
|
||||
export { SqlDatasource } from './datasource/SqlDatasource';
|
||||
export { formatSQL } from './utils/formatSQL';
|
||||
export { ConnectionLimits } from './components/configuration/ConnectionLimits';
|
||||
|
||||
@@ -134,7 +134,18 @@ export interface DB {
|
||||
lookup?: (path?: string) => Promise<Array<{ name: string; completion: string }>>;
|
||||
getEditorLanguageDefinition: () => LanguageDefinition;
|
||||
toRawSql: (query: SQLQuery) => string;
|
||||
functions?: () => string[];
|
||||
functions: () => Func[];
|
||||
}
|
||||
|
||||
export interface FuncParameter {
|
||||
name: string;
|
||||
required?: boolean;
|
||||
options?: (query: SQLQuery) => Promise<SelectableValue[]>;
|
||||
}
|
||||
export interface Func {
|
||||
name: string;
|
||||
parameters?: FuncParameter[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface QueryEditorProps {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { SelectableValue, toOption } from '@grafana/data';
|
||||
|
||||
import {
|
||||
QueryEditorExpressionType,
|
||||
QueryEditorFunctionExpression,
|
||||
QueryEditorFunctionParameterExpression,
|
||||
QueryEditorGroupByExpression,
|
||||
QueryEditorPropertyExpression,
|
||||
QueryEditorPropertyType,
|
||||
@@ -67,3 +70,18 @@ export function createFunctionField(functionName?: string): QueryEditorFunctionE
|
||||
parameters: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the column value from a QueryEditorFunctionParameterExpression object.
|
||||
*
|
||||
* @param column - The QueryEditorFunctionParameterExpression object representing the column.
|
||||
* @returns The column value as a SelectableValue<string> or null if the column is undefined or null.
|
||||
*/
|
||||
export function getColumnValue(
|
||||
column?: QueryEditorFunctionParameterExpression | QueryEditorFunctionExpression
|
||||
): SelectableValue<string> | null {
|
||||
if (column?.name) {
|
||||
return toOption(column.name);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user