Skip to content

Commit

Permalink
Create 'Split by' transform function (#39)
Browse files Browse the repository at this point in the history
  • Loading branch information
mure authored and cameronwaterman committed Nov 12, 2024
1 parent 535ec77 commit a347048
Show file tree
Hide file tree
Showing 7 changed files with 329 additions and 3 deletions.
2 changes: 2 additions & 0 deletions packages/grafana-data/src/transformations/transformers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { renameByRegexTransformer } from './transformers/renameByRegex';
import { seriesToRowsTransformer } from './transformers/seriesToRows';
import { sortByTransformer } from './transformers/sortBy';
import { transposeTransformer } from './transformers/transpose';
import { splitByTransformer } from './transformers/splitBy';

export const standardTransformers = {
noopTransformer,
Expand All @@ -49,6 +50,7 @@ export const standardTransformers = {
ensureColumnsTransformer,
groupByTransformer,
sortByTransformer,
splitByTransformer,
mergeTransformer,
renameByRegexTransformer,
histogramTransformer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export enum DataTransformerID {
noop = 'noop',
ensureColumns = 'ensureColumns',
groupBy = 'groupBy',
splitBy = 'splitBy',
sortBy = 'sortBy',
histogram = 'histogram',
configFromData = 'configFromData',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,15 @@ export const renameByRegexTransformer: DataTransformerInfo<RenameByRegexTransfor
if (!Array.isArray(data) || data.length === 0) {
return data;
}
return data.map(renameFieldsByRegex(options));
return data.map(renameFieldsByRegex(options, data));
})
),
};

const renameFieldsByRegex = (options: RenameByRegexTransformerOptions) => (frame: DataFrame) => {
const renameFieldsByRegex = (options: RenameByRegexTransformerOptions, data: DataFrame[]) => (frame: DataFrame) => {
const regex = stringToJsRegex(options.regex);
const fields = frame.fields.map((field) => {
const displayName = getFieldDisplayName(field, frame);
const displayName = getFieldDisplayName(field, frame, data);
if (!regex.test(displayName)) {
return field;
}
Expand Down
150 changes: 150 additions & 0 deletions packages/grafana-data/src/transformations/transformers/splitBy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { DataTransformerConfig } from '@grafana/data';

import { toDataFrame } from '../../dataframe/processDataFrame';
import { FieldType } from '../../types';
import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry';
import { ArrayVector } from '../../vector';
import { transformDataFrame } from '../transformDataFrame';

import { DataTransformerID } from './ids';
import { splitByTransformer, SplitByTransformerOptions } from './splitBy';

describe('SplitBy transformer', () => {
beforeAll(() => {
mockTransformationsRegistry([splitByTransformer]);
});

it('should not apply transformation when # of frames > 1', async () => {
const testSeries = [
{
name: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: [3000, 4000, 5000] },
{ name: 'group', type: FieldType.string, values: ['one', 'two', 'two'] },
{ name: 'values', type: FieldType.number, values: [1, 2, 2] },
],
},
{
name: 'B',
fields: [
{ name: 'time', type: FieldType.time, values: [6000, 7000, 8000] },
{ name: 'group', type: FieldType.string, values: ['three', 'three', 'three'] },
{ name: 'values', type: FieldType.number, values: [3, 3, 3] },
],
},
].map(toDataFrame);

const cfg: DataTransformerConfig<SplitByTransformerOptions> = {
id: DataTransformerID.splitBy,
options: {
field: 'group',
},
};

await expect(transformDataFrame([cfg], testSeries)).toEmitValuesWith((received) => {
expect(received[0]).toBe(testSeries);
});
});

it('should not apply transformation selected field does not exist', async () => {
const testSeries = toDataFrame({
name: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: [3000, 4000, 5000] },
{ name: 'group', type: FieldType.string, values: ['one', 'two', 'two'] },
{ name: 'values', type: FieldType.number, values: [1, 2, 2] },
],
});

const cfg: DataTransformerConfig<SplitByTransformerOptions> = {
id: DataTransformerID.splitBy,
options: {
field: 'category',
},
};

await expect(transformDataFrame([cfg], [testSeries])).toEmitValuesWith((received) => {
expect(received[0][0]).toBe(testSeries);
});
});

it('should split by group column', async () => {
const testSeries = toDataFrame({
name: 'Series A',
refId: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000, 7000, 8000] },
{ name: 'group', type: FieldType.string, values: ['one', 'two', 'two', 'three', 'three', 'three'] },
{ name: 'values', type: FieldType.number, values: [1, 2, 2, 3, 3, 3], config: { unit: 'm' } },
],
});

const cfg: DataTransformerConfig<SplitByTransformerOptions> = {
id: DataTransformerID.splitBy,
options: {
field: 'group',
},
};

await expect(transformDataFrame([cfg], [testSeries])).toEmitValuesWith((received) => {
expect(received[0]).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'one',
refId: 'A',
fields: [
{
name: 'time',
type: FieldType.time,
values: new ArrayVector([3000]),
config: {},
},
{
name: 'values',
type: FieldType.number,
values: new ArrayVector([1]),
config: { unit: 'm' },
},
],
}),
expect.objectContaining({
name: 'two',
refId: 'A',
fields: [
{
name: 'time',
type: FieldType.time,
values: new ArrayVector([4000, 5000]),
config: {},
},
{
name: 'values',
type: FieldType.number,
values: new ArrayVector([2, 2]),
config: { unit: 'm' },
},
],
}),
expect.objectContaining({
name: 'three',
refId: 'A',
fields: [
{
name: 'time',
type: FieldType.time,
values: new ArrayVector([6000, 7000, 8000]),
config: {},
},
{
name: 'values',
type: FieldType.number,
values: new ArrayVector([3, 3, 3]),
config: { unit: 'm' },
},
],
}),
])
);
});
});
});
67 changes: 67 additions & 0 deletions packages/grafana-data/src/transformations/transformers/splitBy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { without } from 'lodash';
import { map } from 'rxjs/operators';

import { DataFrame } from '../../types/dataFrame';
import { SynchronousDataTransformerInfo } from '../../types/transformations';
import { ArrayVector } from '../../vector/ArrayVector';
import { fieldMatchers } from '../matchers';
import { FieldMatcherID } from '../matchers/ids';

import { DataTransformerID } from './ids';

export interface SplitByTransformerOptions {
/**
* The field on which to split the frame
*/
field: string;
}

export const splitByTransformer: SynchronousDataTransformerInfo<SplitByTransformerOptions> = {
id: DataTransformerID.splitBy,
name: 'Split by',
description: "Split a data frame into multiple frames grouped by a field's values",
defaultOptions: {
field: '',
},

operator: (options, ctx) => (source) =>
source.pipe(map((data) => splitByTransformer.transformer(options, ctx)(data))),

transformer: (options: SplitByTransformerOptions) => (data: DataFrame[]) => {
if (!Array.isArray(data) || data.length !== 1) {
return data;
}

const frame = data[0];

const matches = fieldMatchers.get(FieldMatcherID.byName).get(options.field);
const targetField = frame.fields.find(field => matches(field, frame, data));

if (!targetField) {
return data;
}

// Create dictionary of unique groups to their row indexes
const groups: Record<any, number[]> = {};
for (let i = 0; i < frame.length; i++) {
(groups[targetField.values.get(i)] ??= []).push(i);
}

const remainingFields = without(frame.fields, targetField);

const processed: DataFrame[] = Object.keys(groups).map(group => ({
...frame,
name: group,
length: groups[group].length,
fields: remainingFields.map(field => ({
name: field.name,
type: field.type,
config: { ...field.config },
/// @ts-expect-error
values: new ArrayVector(groups[group].map(ix => field.values.get(ix)))
}))
}));

return processed;
},
};
104 changes: 104 additions & 0 deletions public/app/features/transformers/editors/SplitByTransformerEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import React, { useCallback } from 'react';

import {
DataTransformerID,
FieldNamePickerConfigSettings,
PluginState,
StandardEditorsRegistryItem,
standardTransformers,
TransformerRegistryItem,
TransformerUIProps,
} from '@grafana/data';
import { SplitByTransformerOptions } from '@grafana/data/src/transformations/transformers/splitBy';
import { FieldValidationMessage, InlineField } from '@grafana/ui';

import { FieldNamePicker } from '../../../../../packages/grafana-ui/src/components/MatchersUI/FieldNamePicker';

const fieldNamePickerSettings: StandardEditorsRegistryItem<string, FieldNamePickerConfigSettings> = {
settings: { width: 24 },
} as any;

export const SplitByTransformerEditor: React.FC<TransformerUIProps<SplitByTransformerOptions>> = ({
input,
options,
onChange,
}) => {
const onSelectField = useCallback(
(value: string | undefined) => {
onChange({
...options,
field: value ?? '',
});
},
[onChange, options]
);

if (input.length > 1) {
return (
<FieldValidationMessage>
Split by only works with a single frame.
</FieldValidationMessage>
);
}

return (
<InlineField label={'Field'}>
<FieldNamePicker
context={{ data: input }}
value={options.field}
onChange={onSelectField}
item={fieldNamePickerSettings}
/>
</InlineField>
);
};

export const splitByTransformRegistryItem: TransformerRegistryItem<SplitByTransformerOptions> = {
id: DataTransformerID.splitBy,
editor: SplitByTransformerEditor,
transformation: standardTransformers.splitByTransformer,
name: standardTransformers.splitByTransformer.name,
description: standardTransformers.splitByTransformer.description,
state: PluginState.alpha,
help: `
### Use cases
This transforms one frame into many frames by grouping rows on each unique value of a given field.
This is similar to the 'Group by' transform, but instead of calculating aggregates for each group,
it splits apart the rows into separate frames. This can be useful for labeling or coloring parts of
a timeseries.
## Example
Input:
| Time | Value | Group |
|------|-------|--------|
| 1 | 10 | Dog |
| 2 | 20 | Dog |
| 3 | 30 | Cat |
| 4 | 40 | Cat |
| 5 | 30 | Rabbit |
Output:
Series 1: 'Dog'
| Time | Value |
|------|-------|
| 1 | 10 |
| 2 | 20 |
Series 2: 'Cat'
| Time | Value |
|------|-------|
| 3 | 30 |
| 4 | 40 |
Series 3: 'Rabbit'
| Time | Value |
|------|-------|
| 5 | 30 |
There's three unique values for the 'Group' column, so this example data produces three separate frames.
`,
};
2 changes: 2 additions & 0 deletions public/app/features/transformers/standardTransformers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { renameByRegexTransformRegistryItem } from './editors/RenameByRegexTrans
import { seriesToRowsTransformerRegistryItem } from './editors/SeriesToRowsTransformerEditor';
import { sortByTransformRegistryItem } from './editors/SortByTransformerEditor';
import { transposeTransformerRegistryItem } from './editors/TransposeTransformerEditor';
import { splitByTransformRegistryItem } from './editors/SplitByTransformerEditor';
import { extractFieldsTransformRegistryItem } from './extractFields/ExtractFieldsTransformerEditor';
import { joinByLabelsTransformRegistryItem } from './joinByLabels/JoinByLabelsTransformerEditor';
import { fieldLookupTransformRegistryItem } from './lookupGazetteer/FieldLookupTransformerEditor';
Expand All @@ -50,6 +51,7 @@ export const getStandardTransformers = (): Array<TransformerRegistryItem<any>> =
labelsToFieldsTransformerRegistryItem,
groupByTransformRegistryItem,
sortByTransformRegistryItem,
splitByTransformRegistryItem,
mergeTransformerRegistryItem,
histogramTransformRegistryItem,
rowsToFieldsTransformRegistryItem,
Expand Down

0 comments on commit a347048

Please sign in to comment.