[release-12.3.1] Azure: Improved column handling in logs query builder (#114841)

Azure: Improved column handling in logs query builder (#114667)

* Add parameter type field

* Use parameterType to filter columns for aggregation funcs

* Add tests for aggregate components

(cherry picked from commit ff4228bd58)

Co-authored-by: Andreas Christou <andreas.christou@grafana.com>
This commit is contained in:
grafana-delivery-bot[bot]
2025-12-04 14:01:19 +00:00
committed by GitHub
co-authored by Andreas Christou
parent 6ec45f9d00
commit aaf91dc82c
9 changed files with 556 additions and 13 deletions
@@ -329,8 +329,17 @@ export enum BuilderQueryEditorOrderByOptions {
Desc = 'desc',
}
export enum BuilderQueryEditorReduceParameterTypes {
Generic = 'generic',
Numeric = 'numeric',
}
export interface BuilderQueryEditorProperty {
name: string;
/**
* Optional parameter type for function properties
*/
parameterType?: BuilderQueryEditorReduceParameterTypes;
type: BuilderQueryEditorPropertyType;
}
@@ -223,6 +223,8 @@ func NewBuilderQueryEditorPropertyExpression() *BuilderQueryEditorPropertyExpres
type BuilderQueryEditorProperty struct {
Type BuilderQueryEditorPropertyType `json:"type"`
Name string `json:"name"`
// Optional parameter type for function properties
ParameterType *BuilderQueryEditorReduceParameterTypes `json:"parameterType,omitempty"`
}
// NewBuilderQueryEditorProperty creates a new BuilderQueryEditorProperty object.
@@ -242,6 +244,13 @@ const (
BuilderQueryEditorPropertyTypeInterval BuilderQueryEditorPropertyType = "interval"
)
type BuilderQueryEditorReduceParameterTypes string
const (
BuilderQueryEditorReduceParameterTypesGeneric BuilderQueryEditorReduceParameterTypes = "generic"
BuilderQueryEditorReduceParameterTypesNumeric BuilderQueryEditorReduceParameterTypes = "numeric"
)
type BuilderQueryEditorExpressionType string
const (
@@ -0,0 +1,191 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
BuilderQueryEditorExpressionType,
BuilderQueryEditorPropertyType,
BuilderQueryEditorReduceExpression,
BuilderQueryEditorReduceParameterTypes,
} from '../../dataquery.gen';
import AggregateItem from './AggregateItem';
describe('AggregateItem', () => {
const mockColumns = [
{ label: 'TimeGenerated', value: 'TimeGenerated' },
{ label: 'Level', value: 'Level' },
{ label: 'Message', value: 'Message' },
];
const mockTemplateVariables = { label: '$variable', value: '$variable' };
const defaultAggregate: BuilderQueryEditorReduceExpression = {
reduce: {
name: 'sum',
type: BuilderQueryEditorPropertyType.Function,
parameterType: BuilderQueryEditorReduceParameterTypes.Numeric,
},
property: {
name: 'TimeGenerated',
type: BuilderQueryEditorPropertyType.String,
},
};
const defaultProps = {
aggregate: defaultAggregate,
columns: mockColumns,
onChange: jest.fn(),
onDelete: jest.fn(),
templateVariableOptions: mockTemplateVariables,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders aggregate function select (with column field)', () => {
render(<AggregateItem {...defaultProps} />);
expect(screen.getByLabelText('Aggregate function')).toBeInTheDocument();
expect(screen.getByLabelText('Column')).toBeInTheDocument();
});
it('does not render column select for count aggregates', () => {
const countAggregate = {
...defaultAggregate,
reduce: {
name: 'count',
type: BuilderQueryEditorPropertyType.Function,
parameterType: BuilderQueryEditorReduceParameterTypes.Generic,
},
};
render(<AggregateItem {...defaultProps} aggregate={countAggregate} />);
expect(screen.queryByLabelText('Column')).not.toBeInTheDocument();
});
it('renders percentile input and OF label for percentile aggregate', () => {
const percentileAggregate: BuilderQueryEditorReduceExpression = {
reduce: {
name: 'percentile',
type: BuilderQueryEditorPropertyType.Function,
parameterType: BuilderQueryEditorReduceParameterTypes.Numeric,
},
parameters: [
{
type: BuilderQueryEditorExpressionType.Function_parameter,
fieldType: BuilderQueryEditorPropertyType.Number,
value: '95',
},
{
type: BuilderQueryEditorExpressionType.Function_parameter,
fieldType: BuilderQueryEditorPropertyType.String,
value: 'TimeGenerated',
},
],
property: {
name: 'TimeGenerated',
type: BuilderQueryEditorPropertyType.String,
},
};
render(<AggregateItem {...defaultProps} aggregate={percentileAggregate} />);
expect(screen.getByDisplayValue('95')).toBeInTheDocument();
expect(screen.getByText('OF')).toBeInTheDocument();
});
it('calls onChange when aggregate function changes', async () => {
render(<AggregateItem {...defaultProps} />);
const select = screen.getByLabelText('Aggregate function');
await userEvent.click(select);
const avgOption = await screen.findByText('avg');
await userEvent.click(avgOption);
expect(defaultProps.onChange).toHaveBeenCalledWith(
expect.objectContaining({
reduce: expect.objectContaining({
name: 'avg',
parameterType: BuilderQueryEditorReduceParameterTypes.Numeric,
}),
})
);
});
it('calls onChange when column changes', async () => {
render(<AggregateItem {...defaultProps} />);
const columnSelect = screen.getByLabelText('Column');
await userEvent.click(columnSelect);
const levelOption = await screen.findByText('Level');
await userEvent.click(levelOption);
expect(defaultProps.onChange).toHaveBeenCalledWith(
expect.objectContaining({
property: expect.objectContaining({
name: 'Level',
}),
})
);
});
it('calls onChange when percentile value changes', async () => {
const percentileAggregate: BuilderQueryEditorReduceExpression = {
reduce: {
name: 'percentile',
type: BuilderQueryEditorPropertyType.Function,
parameterType: BuilderQueryEditorReduceParameterTypes.Numeric,
},
parameters: [
{
type: BuilderQueryEditorExpressionType.Function_parameter,
fieldType: BuilderQueryEditorPropertyType.Number,
value: '95',
},
{
type: BuilderQueryEditorExpressionType.Function_parameter,
fieldType: BuilderQueryEditorPropertyType.String,
value: 'TimeGenerated',
},
],
};
render(<AggregateItem {...defaultProps} aggregate={percentileAggregate} />);
const percentileInput = screen.getByDisplayValue('95');
await userEvent.clear(percentileInput);
await userEvent.type(percentileInput, '99');
expect(defaultProps.onChange).toHaveBeenCalledWith(
expect.objectContaining({
parameters: expect.arrayContaining([expect.objectContaining({ value: '99' })]),
})
);
});
it('calls onDelete when delete button clicked', async () => {
render(<AggregateItem {...defaultProps} />);
const deleteButton = screen.getByLabelText('Remove');
await userEvent.click(deleteButton);
expect(defaultProps.onDelete).toHaveBeenCalledTimes(1);
});
it('includes template variables in column options', async () => {
render(<AggregateItem {...defaultProps} />);
const columnSelect = screen.getByLabelText('Column');
await userEvent.click(columnSelect);
expect(await screen.findByText('$variable')).toBeInTheDocument();
});
it('handles array of template variables', async () => {
const arrayTemplateVars = [
{ label: '$var1', value: '$var1' },
{ label: '$var2', value: '$var2' },
];
render(<AggregateItem {...defaultProps} templateVariableOptions={arrayTemplateVars[0]} />);
const columnSelect = screen.getByLabelText('Column');
await userEvent.click(columnSelect);
expect(await screen.findByText('$var1')).toBeInTheDocument();
});
});
@@ -9,6 +9,7 @@ import {
BuilderQueryEditorExpressionType,
BuilderQueryEditorPropertyType,
BuilderQueryEditorReduceExpression,
BuilderQueryEditorReduceParameterTypes,
} from '../../dataquery.gen';
import { aggregateOptions, inputFieldSize } from './utils';
@@ -65,8 +66,16 @@ const AggregateItem: React.FC<AggregateItemProps> = ({
};
const handleAggregateChange = (funcName?: string) => {
const functionParameterType =
aggregateOptions.find((option) => option.value === (funcName || ''))?.parameterType ||
BuilderQueryEditorReduceParameterTypes.Generic;
updateAggregate({
reduce: { name: funcName || '', type: BuilderQueryEditorPropertyType.Function },
reduce: {
name: funcName || '',
type: BuilderQueryEditorPropertyType.Function,
parameterType: functionParameterType,
},
});
};
@@ -0,0 +1,293 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
AzureQueryType,
BuilderQueryEditorExpressionType,
BuilderQueryEditorPropertyType,
BuilderQueryEditorReduceExpression,
BuilderQueryEditorReduceParameterTypes,
} from '../../dataquery.gen';
import { AzureMonitorQuery } from '../../types/query';
import { AggregateSection } from './AggregationSection';
describe('AggregationSection', () => {
const mockAllColumns = [
{ name: 'TimeGenerated', type: 'datetime' },
{ name: 'Level', type: 'string' },
{ name: 'Count', type: 'int' },
{ name: 'Duration', type: 'real' },
];
const mockTemplateVariables = { label: '$variable', value: '$variable' };
const createMockQuery = (reduce?: BuilderQueryEditorReduceExpression[]): AzureMonitorQuery => ({
refId: 'A',
queryType: AzureQueryType.LogAnalytics,
azureLogAnalytics: {
builderQuery: {
from: {
type: BuilderQueryEditorExpressionType.Property,
property: { type: BuilderQueryEditorPropertyType.String, name: 'AppRequests' },
},
columns: {
type: BuilderQueryEditorExpressionType.Property,
columns: [],
},
reduce: {
type: BuilderQueryEditorExpressionType.Reduce,
expressions: reduce || [],
},
where: {
type: BuilderQueryEditorExpressionType.And,
expressions: [],
},
groupBy: {
type: BuilderQueryEditorExpressionType.Group_by,
expressions: [],
},
},
},
});
const defaultProps = {
query: createMockQuery(),
allColumns: mockAllColumns,
templateVariableOptions: mockTemplateVariables,
buildAndUpdateQuery: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders the aggregate section', () => {
render(<AggregateSection {...defaultProps} />);
expect(screen.getByTestId('aggregate-section')).toBeInTheDocument();
});
it('renders empty list when no aggregates exist', () => {
render(<AggregateSection {...defaultProps} />);
const addButton = screen.getByRole('button', { name: /add/i });
expect(addButton).toBeInTheDocument();
});
it('renders existing aggregates', () => {
const existingAggregates: BuilderQueryEditorReduceExpression[] = [
{
reduce: {
name: 'sum',
type: BuilderQueryEditorPropertyType.Function,
parameterType: BuilderQueryEditorReduceParameterTypes.Numeric,
},
property: {
name: 'Count',
type: BuilderQueryEditorPropertyType.String,
},
},
{
reduce: {
name: 'avg',
type: BuilderQueryEditorPropertyType.Function,
parameterType: BuilderQueryEditorReduceParameterTypes.Numeric,
},
property: {
name: 'Duration',
type: BuilderQueryEditorPropertyType.String,
},
},
];
const queryWithAggregates = createMockQuery(existingAggregates);
render(<AggregateSection {...defaultProps} query={queryWithAggregates} />);
expect(screen.getAllByLabelText('Aggregate function')).toHaveLength(2);
});
it('calls buildAndUpdateQuery when aggregate is added', async () => {
render(<AggregateSection {...defaultProps} />);
const addButton = screen.getByRole('button', { name: /add/i });
await userEvent.click(addButton);
expect(defaultProps.buildAndUpdateQuery).toHaveBeenCalledWith({
reduce: expect.arrayContaining([expect.objectContaining({})]),
});
});
it('calls buildAndUpdateQuery when aggregate is deleted', async () => {
const existingAggregates: BuilderQueryEditorReduceExpression[] = [
{
reduce: {
name: 'sum',
type: BuilderQueryEditorPropertyType.Function,
parameterType: BuilderQueryEditorReduceParameterTypes.Numeric,
},
property: {
name: 'Count',
type: BuilderQueryEditorPropertyType.String,
},
},
];
const avgAggregate = {
reduce: {
name: 'avg',
type: BuilderQueryEditorPropertyType.Function,
parameterType: BuilderQueryEditorReduceParameterTypes.Numeric,
},
property: {
name: 'Duration',
type: BuilderQueryEditorPropertyType.String,
},
};
existingAggregates.push(avgAggregate);
const queryWithAggregates = createMockQuery(existingAggregates);
render(<AggregateSection {...defaultProps} query={queryWithAggregates} />);
const deleteButton = (await screen.findAllByLabelText('Remove'))[0];
await userEvent.click(deleteButton);
expect(defaultProps.buildAndUpdateQuery).toHaveBeenCalledWith({
reduce: [avgAggregate],
});
expect(screen.getAllByLabelText('Aggregate function')).toHaveLength(1);
});
it('provides numeric columns for numeric aggregate functions', async () => {
const numericAggregate: BuilderQueryEditorReduceExpression[] = [
{
reduce: {
name: 'sum',
type: BuilderQueryEditorPropertyType.Function,
parameterType: BuilderQueryEditorReduceParameterTypes.Numeric,
},
},
];
const queryWithAggregate = createMockQuery(numericAggregate);
render(<AggregateSection {...defaultProps} query={queryWithAggregate} />);
const columnSelect = screen.getByLabelText('Column');
await userEvent.click(columnSelect);
expect(await screen.getByText('Count')).toBeInTheDocument();
expect(await screen.getByText('Duration')).toBeInTheDocument();
expect(screen.queryByText('Level')).not.toBeInTheDocument();
});
it('provides all columns for generic aggregate functions', async () => {
const genericAggregate: BuilderQueryEditorReduceExpression[] = [
{
reduce: {
name: 'min',
type: BuilderQueryEditorPropertyType.Function,
parameterType: BuilderQueryEditorReduceParameterTypes.Generic,
},
},
];
const queryWithAggregate = createMockQuery(genericAggregate);
render(<AggregateSection {...defaultProps} query={queryWithAggregate} />);
const columnSelect = screen.getByLabelText('Column');
await userEvent.click(columnSelect);
expect(await screen.getByText('TimeGenerated')).toBeInTheDocument();
expect(await screen.getByText('Level')).toBeInTheDocument();
expect(await screen.getByText('Count')).toBeInTheDocument();
expect(await screen.getByText('Duration')).toBeInTheDocument();
});
it('resets aggregates when table changes', () => {
const existingAggregates: BuilderQueryEditorReduceExpression[] = [
{
reduce: {
name: 'sum',
type: BuilderQueryEditorPropertyType.Function,
parameterType: BuilderQueryEditorReduceParameterTypes.Numeric,
},
property: {
name: 'Count',
type: BuilderQueryEditorPropertyType.String,
},
},
];
const queryWithAggregates = createMockQuery(existingAggregates);
const { rerender } = render(<AggregateSection {...defaultProps} query={queryWithAggregates} />);
const newQuery = createMockQuery(existingAggregates);
newQuery.azureLogAnalytics!.builderQuery!.from!.property.name = 'AppEvents';
rerender(<AggregateSection {...defaultProps} query={newQuery} />);
const addButton = screen.getByRole('button', { name: /add/i });
expect(addButton).toBeInTheDocument();
});
it('uses selected columns when available', async () => {
const aggregate: BuilderQueryEditorReduceExpression[] = [
{
reduce: {
name: 'sum',
type: BuilderQueryEditorPropertyType.Function,
parameterType: BuilderQueryEditorReduceParameterTypes.Generic,
},
},
];
const query = createMockQuery(aggregate);
query.azureLogAnalytics!.builderQuery = {
...query.azureLogAnalytics!.builderQuery,
columns: {
columns: ['TimeGenerated', 'Level'],
type: BuilderQueryEditorExpressionType.Property,
},
};
render(<AggregateSection {...defaultProps} query={query} />);
const columnSelect = screen.getByLabelText('Column');
await userEvent.click(columnSelect);
expect(await screen.getByText('TimeGenerated')).toBeInTheDocument();
expect(await screen.getByText('Level')).toBeInTheDocument();
expect(screen.queryByText('Count')).not.toBeInTheDocument();
expect(screen.queryByText('Duration')).not.toBeInTheDocument();
});
it('falls back to all columns when no columns selected', async () => {
const aggregate: BuilderQueryEditorReduceExpression[] = [
{
reduce: {
name: 'sum',
type: BuilderQueryEditorPropertyType.Function,
parameterType: BuilderQueryEditorReduceParameterTypes.Generic,
},
},
];
const query = createMockQuery(aggregate);
query.azureLogAnalytics!.builderQuery = {
...query.azureLogAnalytics!.builderQuery,
columns: {
columns: [],
type: BuilderQueryEditorExpressionType.Property,
},
};
render(<AggregateSection {...defaultProps} query={query} />);
const columnSelect = screen.getByLabelText('Column');
await userEvent.click(columnSelect);
expect(await screen.getByText('TimeGenerated')).toBeInTheDocument();
expect(await screen.getByText('Level')).toBeInTheDocument();
expect(await screen.getByText('Count')).toBeInTheDocument();
expect(await screen.getByText('Duration')).toBeInTheDocument();
});
});
@@ -4,12 +4,12 @@ import { SelectableValue } from '@grafana/data';
import { t } from '@grafana/i18n';
import { EditorField, EditorFieldGroup, EditorList, EditorRow } from '@grafana/plugin-ui';
import { BuilderQueryEditorReduceExpression } from '../../dataquery.gen';
import { BuilderQueryEditorReduceExpression, BuilderQueryEditorReduceParameterTypes } from '../../dataquery.gen';
import { AzureLogAnalyticsMetadataColumn } from '../../types/logAnalyticsMetadata';
import { AzureMonitorQuery } from '../../types/query';
import AggregateItem from './AggregateItem';
import { BuildAndUpdateOptions } from './utils';
import { BuildAndUpdateOptions, isNumericColumn } from './utils';
interface AggregateSectionProps {
query: AzureMonitorQuery;
@@ -43,6 +43,10 @@ export const AggregateSection: React.FC<AggregateSectionProps> = ({
const availableColumns: Array<SelectableValue<string>> = builderQuery?.columns?.columns?.length
? builderQuery.columns.columns.map((col) => ({ label: col, value: col }))
: allColumns.map((col) => ({ label: col.name, value: col.name }));
const numericColumns: Array<SelectableValue<string>> = allColumns.filter(isNumericColumn).map((col) => ({
label: col.name,
value: col.name,
}));
const onChange = (newItems: Array<Partial<BuilderQueryEditorReduceExpression>>) => {
setAggregates(newItems);
@@ -82,7 +86,12 @@ export const AggregateSection: React.FC<AggregateSectionProps> = ({
<EditorList
items={aggregates}
onChange={onChange}
renderItem={makeRenderAggregate(availableColumns, onDeleteAggregate, templateVariableOptions)}
renderItem={makeRenderAggregate(
availableColumns,
numericColumns,
onDeleteAggregate,
templateVariableOptions
)}
/>
</EditorField>
</EditorFieldGroup>
@@ -93,6 +102,7 @@ export const AggregateSection: React.FC<AggregateSectionProps> = ({
function makeRenderAggregate(
availableColumns: Array<SelectableValue<string>>,
numericColumns: Array<SelectableValue<string>>,
onDeleteAggregate: (aggregate: BuilderQueryEditorReduceExpression) => void,
templateVariableOptions: SelectableValue<string>
) {
@@ -105,7 +115,11 @@ function makeRenderAggregate(
aggregate={item}
onChange={onChange}
onDelete={() => onDeleteAggregate(item)}
columns={availableColumns}
columns={
item.reduce?.name && item.reduce.parameterType === BuilderQueryEditorReduceParameterTypes.Numeric
? numericColumns
: availableColumns
}
templateVariableOptions={templateVariableOptions}
/>
);
@@ -9,6 +9,7 @@ import {
BuilderQueryEditorPropertyExpression,
BuilderQueryEditorPropertyType,
BuilderQueryEditorReduceExpression,
BuilderQueryEditorReduceParameterTypes,
BuilderQueryEditorWhereExpression,
BuilderQueryExpression,
} from '../../dataquery.gen';
@@ -93,12 +94,17 @@ export interface BuildAndUpdateOptions {
}
export const aggregateOptions = [
{ label: 'sum', value: 'sum' },
{ label: 'avg', value: 'avg' },
{ label: 'percentile', value: 'percentile' },
{ label: 'count', value: 'count' },
{ label: 'min', value: 'min' },
{ label: 'max', value: 'max' },
{ label: 'dcount', value: 'dcount' },
{ label: 'stdev', value: 'stdev' },
{ label: 'sum', value: 'sum', parameterType: BuilderQueryEditorReduceParameterTypes.Numeric },
{ label: 'avg', value: 'avg', parameterType: BuilderQueryEditorReduceParameterTypes.Numeric },
{ label: 'percentile', value: 'percentile', parameterType: BuilderQueryEditorReduceParameterTypes.Numeric },
{ label: 'stdev', value: 'stdev', parameterType: BuilderQueryEditorReduceParameterTypes.Numeric },
{ label: 'min', value: 'min', parameterType: BuilderQueryEditorReduceParameterTypes.Generic },
{ label: 'max', value: 'max', parameterType: BuilderQueryEditorReduceParameterTypes.Generic },
{ label: 'count', value: 'count', parameterType: BuilderQueryEditorReduceParameterTypes.Generic },
{ label: 'dcount', value: 'dcount', parameterType: BuilderQueryEditorReduceParameterTypes.Generic },
];
export const isNumericColumn = (column: AzureLogAnalyticsMetadataColumn): boolean => {
const numericTypes = ['decimal', 'int', 'long', 'real'];
return numericTypes.includes(column.type);
};
@@ -175,10 +175,13 @@ composableKinds: DataQuery: {
#BuilderQueryEditorExpressionType: "property" | "operator" | "reduce" | "function_parameter" | "group_by" | "or" | "and" | "order_by" @cuetsy(kind="enum", memberNames:"Property|Operator|Reduce|FunctionParameter|GroupBy|Or|And|OrderBy")
#BuilderQueryEditorPropertyType: "number" | "string" | "boolean" | "datetime" | "time_span" | "function" | "interval" @cuetsy(kind="enum", memberNames:"Number|String|Boolean|Datetime|TimeSpan|Function|Interval")
#BuilderQueryEditorOrderByOptions: "asc" | "desc" @cuetsy(kind="enum", memberNames:"Asc|Desc")
#BuilderQueryEditorReduceParameterTypes: "generic" | "numeric" @cuetsy(kind="enum", memberNames:"Asc|Desc")
#BuilderQueryEditorProperty: {
type: #BuilderQueryEditorPropertyType
name: string
// Optional parameter type for function properties
parameterType?: #BuilderQueryEditorReduceParameterTypes
} @cuetsy(kind="interface")
#BuilderQueryEditorPropertyExpression: {
@@ -327,8 +327,17 @@ export enum BuilderQueryEditorOrderByOptions {
Desc = 'desc',
}
export enum BuilderQueryEditorReduceParameterTypes {
Generic = 'generic',
Numeric = 'numeric',
}
export interface BuilderQueryEditorProperty {
name: string;
/**
* Optional parameter type for function properties
*/
parameterType?: BuilderQueryEditorReduceParameterTypes;
type: BuilderQueryEditorPropertyType;
}