Commit 96f26cbd by Marcus Andersson Committed by GitHub

Transform: fixes so we match the field based on the proper name. (#24659)

* fixes so we match the transformer based on name properly.

* changed the signature on the FieldMatcher.

* introduced a names option so you can filter in name specificly.

* changed so the matcher UI uses the new options format.

* moved the exported functions together.

* changing editors a bit.

* made the filter by name work with both regex and name filtering.

* fixed failing tests and make sure we always parse regex the same way.

* removed unused code.

* simplified to make the existing field overrides still working.

* fixed issue reported by hugo.

* added tests for the name matcher.

* added tests for filter by name.

* added more tests.
parent 0e8638ec
......@@ -138,7 +138,7 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra
// Find any matching rules and then override
for (const rule of override) {
if (rule.match(field)) {
if (rule.match(field, frame, options.data!)) {
for (const prop of rule.properties) {
// config.scopedVars is set already here
setDynamicConfigValue(config, prop, context);
......
......@@ -9,3 +9,4 @@ export {
TransformerUIProps,
standardTransformersRegistry,
} from './standardTransformersRegistry';
export { RegexpOrNamesMatcherOptions } from './matchers/nameMatcher';
......@@ -16,7 +16,8 @@ describe('Field Type Matcher', () => {
it('finds numbers', () => {
for (const field of simpleSeriesWithTypes.fields) {
const matches = matcher.get(FieldType.number);
expect(matches(field)).toBe(field.type === FieldType.number);
const didMatch = matches(field, simpleSeriesWithTypes, [simpleSeriesWithTypes]);
expect(didMatch).toBe(field.type === FieldType.number);
}
});
});
import { Field, FieldType } from '../../types/dataFrame';
import { Field, FieldType, DataFrame } from '../../types/dataFrame';
import { FieldMatcherID } from './ids';
import { FieldMatcherInfo } from '../../types/transformations';
......@@ -10,7 +10,7 @@ const fieldTypeMatcher: FieldMatcherInfo<FieldType> = {
defaultOptions: FieldType.number,
get: (type: FieldType) => {
return (field: Field) => {
return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => {
return type === field.type;
};
},
......
......@@ -18,6 +18,9 @@ export enum FieldMatcherID {
// With arguments
byType = 'byType',
byName = 'byName',
byNames = 'byNames',
byRegexp = 'byRegexp',
byRegexpOrNames = 'byRegexpOrNames',
// byIndex = 'byIndex',
// byLabel = 'byLabel',
}
......
......@@ -2,20 +2,20 @@ import { getFieldMatcher } from '../matchers';
import { FieldMatcherID } from './ids';
import { toDataFrame } from '../../dataframe/processDataFrame';
describe('Field Name Matcher', () => {
describe('Field Name by Regexp Matcher', () => {
it('Match all with wildcard regex', () => {
const seriesWithNames = toDataFrame({
fields: [{ name: 'A hello world' }, { name: 'AAA' }, { name: 'C' }],
});
const config = {
id: FieldMatcherID.byName,
id: FieldMatcherID.byRegexp,
options: '/.*/',
};
const matcher = getFieldMatcher(config);
for (const field of seriesWithNames.fields) {
expect(matcher(field)).toBe(true);
expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(true);
}
});
......@@ -24,14 +24,14 @@ describe('Field Name Matcher', () => {
fields: [{ name: '12' }, { name: '112' }, { name: '13' }],
});
const config = {
id: FieldMatcherID.byName,
id: FieldMatcherID.byRegexp,
options: '/^\\d+$/',
};
const matcher = getFieldMatcher(config);
for (const field of seriesWithNames.fields) {
expect(matcher(field)).toBe(true);
expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(true);
}
});
......@@ -40,17 +40,269 @@ describe('Field Name Matcher', () => {
fields: [{ name: 'some.instance.path' }, { name: '112' }, { name: '13' }],
});
const config = {
id: FieldMatcherID.byName,
id: FieldMatcherID.byRegexp,
options: '/\\b(?:\\S+?\\.)+\\S+\\b$/',
};
const matcher = getFieldMatcher(config);
let resultCount = 0;
for (const field of seriesWithNames.fields) {
if (matcher(field)) {
if (matcher(field, seriesWithNames, [seriesWithNames])) {
resultCount++;
}
expect(resultCount).toBe(1);
}
});
});
describe('Field Name Matcher', () => {
it('Match only exact name', () => {
const seriesWithNames = toDataFrame({
fields: [{ name: 'A hello world' }, { name: 'AAA' }, { name: 'C' }],
});
const config = {
id: FieldMatcherID.byName,
options: 'C',
};
const matcher = getFieldMatcher(config);
for (const field of seriesWithNames.fields) {
const didMatch = matcher(field, seriesWithNames, [seriesWithNames]);
expect(didMatch).toBe(field.name === 'C');
}
});
it('Match should respect letter case', () => {
const seriesWithNames = toDataFrame({
fields: [{ name: '12' }, { name: '112' }, { name: '13' }, { name: 'C' }],
});
const config = {
id: FieldMatcherID.byName,
options: 'c',
};
const matcher = getFieldMatcher(config);
for (const field of seriesWithNames.fields) {
expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(false);
}
});
it('Match none of the field names', () => {
const seriesWithNames = toDataFrame({
fields: [{ name: 'some.instance.path' }, { name: '112' }, { name: '13' }],
});
const config = {
id: FieldMatcherID.byName,
options: '',
};
const matcher = getFieldMatcher(config);
for (const field of seriesWithNames.fields) {
expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(false);
}
});
});
describe('Field Multiple Names Matcher', () => {
it('Match only exact name', () => {
const seriesWithNames = toDataFrame({
fields: [{ name: 'A hello world' }, { name: 'AAA' }, { name: 'C' }],
});
const config = {
id: FieldMatcherID.byNames,
options: ['C'],
};
const matcher = getFieldMatcher(config);
for (const field of seriesWithNames.fields) {
const didMatch = matcher(field, seriesWithNames, [seriesWithNames]);
expect(didMatch).toBe(field.name === 'C');
}
});
it('Match should respect letter case', () => {
const seriesWithNames = toDataFrame({
fields: [{ name: '12' }, { name: '112' }, { name: '13' }, { name: 'C' }],
});
const config = {
id: FieldMatcherID.byNames,
options: ['c'],
};
const matcher = getFieldMatcher(config);
for (const field of seriesWithNames.fields) {
expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(false);
}
});
it('Match none of the field names', () => {
const seriesWithNames = toDataFrame({
fields: [{ name: 'some.instance.path' }, { name: '112' }, { name: '13' }],
});
const config = {
id: FieldMatcherID.byNames,
options: [],
};
const matcher = getFieldMatcher(config);
for (const field of seriesWithNames.fields) {
expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(false);
}
});
it('Match all of the field names', () => {
const seriesWithNames = toDataFrame({
fields: [{ name: 'some.instance.path' }, { name: '112' }, { name: '13' }],
});
const config = {
id: FieldMatcherID.byNames,
options: ['some.instance.path', '112', '13'],
};
const matcher = getFieldMatcher(config);
for (const field of seriesWithNames.fields) {
expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(true);
}
});
});
describe('Field Regexp or Names Matcher', () => {
it('Match only exact name by name', () => {
const seriesWithNames = toDataFrame({
fields: [{ name: 'A hello world' }, { name: 'AAA' }, { name: 'C' }],
});
const config = {
id: FieldMatcherID.byRegexpOrNames,
options: {
names: ['C'],
},
};
const matcher = getFieldMatcher(config);
for (const field of seriesWithNames.fields) {
const didMatch = matcher(field, seriesWithNames, [seriesWithNames]);
expect(didMatch).toBe(field.name === 'C');
}
});
it('Match all starting with AA', () => {
const seriesWithNames = toDataFrame({
fields: [{ name: 'A hello world' }, { name: 'AAA' }, { name: 'C' }],
});
const config = {
id: FieldMatcherID.byRegexpOrNames,
options: {
pattern: '/^AA/',
},
};
const matcher = getFieldMatcher(config);
for (const field of seriesWithNames.fields) {
const didMatch = matcher(field, seriesWithNames, [seriesWithNames]);
expect(didMatch).toBe(field.name === 'AAA');
}
});
it('Match all starting with AA and C', () => {
const seriesWithNames = toDataFrame({
fields: [{ name: 'A hello world' }, { name: 'AAA' }, { name: 'C' }],
});
const config = {
id: FieldMatcherID.byRegexpOrNames,
options: {
pattern: '/^AA/',
names: ['C'],
},
};
const matcher = getFieldMatcher(config);
for (const field of seriesWithNames.fields) {
const didMatch = matcher(field, seriesWithNames, [seriesWithNames]);
expect(didMatch).toBe(field.name === 'AAA' || field.name === 'C');
}
});
it('Match should respect letter case by name if not igored in pattern', () => {
const seriesWithNames = toDataFrame({
fields: [{ name: '12' }, { name: '112' }, { name: '13' }, { name: 'C' }],
});
const config = {
id: FieldMatcherID.byRegexpOrNames,
options: {
names: ['c'],
pattern: '/c/i',
},
};
const matcher = getFieldMatcher(config);
for (const field of seriesWithNames.fields) {
const didMatch = matcher(field, seriesWithNames, [seriesWithNames]);
expect(didMatch).toBe(field.name === 'C');
}
});
it('Match none of the field names by name', () => {
const seriesWithNames = toDataFrame({
fields: [{ name: 'some.instance.path' }, { name: '112' }, { name: '13' }],
});
const config = {
id: FieldMatcherID.byRegexpOrNames,
options: {
names: [],
},
};
const matcher = getFieldMatcher(config);
for (const field of seriesWithNames.fields) {
expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(false);
}
});
it('Match all of the field names by name', () => {
const seriesWithNames = toDataFrame({
fields: [{ name: 'some.instance.path' }, { name: '112' }, { name: '13' }],
});
const config = {
id: FieldMatcherID.byRegexpOrNames,
options: {
names: ['some.instance.path', '112', '13'],
},
};
const matcher = getFieldMatcher(config);
for (const field of seriesWithNames.fields) {
expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(true);
}
});
it('Match all of the field names by regexp', () => {
const seriesWithNames = toDataFrame({
fields: [{ name: 'some.instance.path' }, { name: '112' }, { name: '13' }],
});
const config = {
id: FieldMatcherID.byRegexpOrNames,
options: {
pattern: '/.*/',
},
};
const matcher = getFieldMatcher(config);
for (const field of seriesWithNames.fields) {
expect(matcher(field, seriesWithNames, [seriesWithNames])).toBe(true);
}
});
});
import { Field, DataFrame } from '../../types/dataFrame';
import { FieldMatcherID, FrameMatcherID } from './ids';
import { FieldMatcherInfo, FrameMatcherInfo } from '../../types/transformations';
import { FieldMatcherInfo, FrameMatcherInfo, FieldMatcher } from '../../types/transformations';
import { stringToJsRegex } from '../../text/string';
import { getFieldDisplayName } from '../../field/fieldState';
export interface RegexpOrNamesMatcherOptions {
pattern?: string;
names?: string[];
}
// General Field matcher
const fieldNameMacher: FieldMatcherInfo<string> = {
const fieldNameMatcher: FieldMatcherInfo<string> = {
id: FieldMatcherID.byName,
name: 'Field Name',
description: 'match the field name',
defaultOptions: '',
get: (name: string): FieldMatcher => {
return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => {
return getFieldDisplayName(field, frame, allFrames) === name;
};
},
getOptionsDisplayText: (name: string) => {
return `Field name: ${name}`;
},
};
const multipleFieldNamesMatcher: FieldMatcherInfo<string[]> = {
id: FieldMatcherID.byNames,
name: 'Field Names',
description: 'match any of the given the field names',
defaultOptions: [],
get: (names: string[]): FieldMatcher => {
const uniqueNames = new Set<string>(names ?? []);
return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => {
return uniqueNames.has(getFieldDisplayName(field, frame, allFrames));
};
},
getOptionsDisplayText: (names: string[]): string => {
return `Field names: ${names.join(', ')}`;
},
};
const regexpFieldNameMatcher: FieldMatcherInfo<string> = {
id: FieldMatcherID.byRegexp,
name: 'Field Name by Regexp',
description: 'match the field name by a given regexp pattern',
defaultOptions: '/.*/',
get: (pattern: string) => {
let regex = new RegExp('');
try {
regex = stringToJsRegex(pattern);
} catch (e) {
console.error(e);
}
return (field: Field) => {
return regex.test(getFieldDisplayName(field) ?? '');
get: (pattern: string): FieldMatcher => {
const regexp = patternToRegex(pattern);
return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => {
const displayName = getFieldDisplayName(field, frame, allFrames);
return !!regexp && regexp.test(displayName);
};
},
getOptionsDisplayText: (pattern: string) => {
return `Field name: ${pattern}`;
getOptionsDisplayText: (pattern: string): string => {
return `Field name by pattern: ${pattern}`;
},
};
// General Field matcher
const frameNameMacher: FrameMatcherInfo<string> = {
const regexpOrMultipleNamesMatcher: FieldMatcherInfo<RegexpOrNamesMatcherOptions> = {
id: FieldMatcherID.byRegexpOrNames,
name: 'Field Name by Regexp or Names',
description: 'match the field name by a given regexp pattern or given names',
defaultOptions: {
pattern: '/.*/',
names: [],
},
get: (options: RegexpOrNamesMatcherOptions): FieldMatcher => {
const regexpMatcher = regexpFieldNameMatcher.get(options?.pattern || '');
const namesMatcher = multipleFieldNamesMatcher.get(options?.names ?? []);
return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => {
return namesMatcher(field, frame, allFrames) || regexpMatcher(field, frame, allFrames);
};
},
getOptionsDisplayText: (options: RegexpOrNamesMatcherOptions): string => {
const pattern = options?.pattern ?? '';
const names = options?.names?.join(',') ?? '';
return `Field name by pattern: ${pattern} or names: ${names}`;
},
};
const patternToRegex = (pattern?: string): RegExp | undefined => {
if (!pattern) {
return undefined;
}
try {
return stringToJsRegex(pattern);
} catch (error) {
console.log(error);
return undefined;
}
};
// General Frame matcher
const frameNameMatcher: FrameMatcherInfo<string> = {
id: FrameMatcherID.byName,
name: 'Frame Name',
description: 'match the frame name',
......@@ -51,9 +127,9 @@ const frameNameMacher: FrameMatcherInfo<string> = {
* Registry Initalization
*/
export function getFieldNameMatchers(): FieldMatcherInfo[] {
return [fieldNameMacher];
return [fieldNameMatcher, regexpFieldNameMatcher, multipleFieldNamesMatcher, regexpOrMultipleNamesMatcher];
}
export function getFrameNameMatchers(): FrameMatcherInfo[] {
return [frameNameMacher];
return [frameNameMatcher];
}
......@@ -13,26 +13,29 @@ const matchesTimeConfig: MatcherConfig = {
options: FieldType.time,
};
const both = [matchesNumberConfig, matchesTimeConfig];
const allFrames = [simpleSeriesWithTypes];
describe('Check Predicates', () => {
it('can not match both', () => {
const matches = fieldMatchers.get(MatcherID.allMatch).get(both);
for (const field of simpleSeriesWithTypes.fields) {
expect(matches(field)).toBe(false);
expect(matches(field, simpleSeriesWithTypes, allFrames)).toBe(false);
}
});
it('match either time or number', () => {
const matches = fieldMatchers.get(MatcherID.anyMatch).get(both);
for (const field of simpleSeriesWithTypes.fields) {
expect(matches(field)).toBe(field.type === FieldType.number || field.type === FieldType.time);
expect(matches(field, simpleSeriesWithTypes, allFrames)).toBe(
field.type === FieldType.number || field.type === FieldType.time
);
}
});
it('match not time', () => {
const matches = fieldMatchers.get(MatcherID.invertMatch).get(matchesTimeConfig);
for (const field of simpleSeriesWithTypes.fields) {
expect(matches(field)).toBe(field.type !== FieldType.time);
expect(matches(field, simpleSeriesWithTypes, allFrames)).toBe(field.type !== FieldType.time);
}
});
});
......@@ -14,9 +14,9 @@ const anyFieldMatcher: FieldMatcherInfo<MatcherConfig[]> = {
const children = options.map(option => {
return getFieldMatcher(option);
});
return (field: Field) => {
return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => {
for (const child of children) {
if (child(field)) {
if (child(field, frame, allFrames)) {
return true;
}
}
......@@ -82,9 +82,9 @@ const allFieldsMatcher: FieldMatcherInfo<MatcherConfig[]> = {
const children = options.map(option => {
return getFieldMatcher(option);
});
return (field: Field) => {
return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => {
for (const child of children) {
if (!child(field)) {
if (!child(field, frame, allFrames)) {
return false;
}
}
......@@ -147,8 +147,8 @@ const notFieldMatcher: FieldMatcherInfo<MatcherConfig> = {
get: (option: MatcherConfig) => {
const check = getFieldMatcher(option);
return (field: Field) => {
return !check(field);
return (field: Field, frame: DataFrame, allFrames: DataFrame[]) => {
return !check(field, frame, allFrames);
};
},
......
......@@ -10,6 +10,7 @@ import { organizeFieldsTransformer } from './transformers/organize';
import { seriesToColumnsTransformer } from './transformers/seriesToColumns';
import { renameFieldsTransformer } from './transformers/rename';
import { labelsToFieldsTransformer } from './transformers/labelsToFields';
import { ensureColumnsTransformer } from './transformers/ensureColumns';
export const standardTransformers = {
noopTransformer,
......@@ -25,4 +26,5 @@ export const standardTransformers = {
seriesToColumnsTransformer,
renameFieldsTransformer,
labelsToFieldsTransformer,
ensureColumnsTransformer,
};
......@@ -4,7 +4,7 @@ import { FieldType } from '../../types/dataFrame';
import { ReducerID } from '../fieldReducer';
import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry';
import { transformDataFrame } from '../transformDataFrame';
import { calculateFieldTransformer, CalculateFieldMode } from './calculateField';
import { calculateFieldTransformer, CalculateFieldMode, ReduceOptions } from './calculateField';
import { DataFrameView } from '../../dataframe';
import { BinaryOperationID } from '../../utils';
......@@ -96,9 +96,9 @@ describe('calculateField transformer w/ timeseries', () => {
options: {
mode: CalculateFieldMode.ReduceRow,
reduce: {
include: 'B',
include: ['B'],
reducer: ReducerID.mean,
},
} as ReduceOptions,
replaceFields: true,
},
};
......
......@@ -6,10 +6,10 @@ import { FieldMatcherID } from '../matchers/ids';
import { RowVector } from '../../vector/RowVector';
import { ArrayVector, BinaryOperationVector, ConstantVector } from '../../vector';
import { doStandardCalcs } from '../fieldReducer';
import { seriesToColumnsTransformer } from './seriesToColumns';
import { getTimeField } from '../../dataframe/processDataFrame';
import defaults from 'lodash/defaults';
import { BinaryOperationID, binaryOperators } from '../../utils/binaryOperators';
import { ensureColumnsTransformer } from './ensureColumns';
import { getFieldDisplayName } from '../../field';
export enum CalculateFieldMode {
......@@ -18,7 +18,7 @@ export enum CalculateFieldMode {
}
export interface ReduceOptions {
include?: string; // Assume all fields
include?: string[]; // Assume all fields
reducer: ReducerID;
nullValueMode?: NullValueMode;
}
......@@ -69,22 +69,17 @@ export const calculateFieldTransformer: DataTransformerInfo<CalculateFieldTransf
},
},
transformer: options => (data: DataFrame[]) => {
// Assume timeseries should first be joined by time
const timeFieldName = findConsistentTimeFieldName(data);
if (data.length > 1 && timeFieldName && options.timeSeries !== false) {
data = seriesToColumnsTransformer.transformer({
byField: timeFieldName,
})(data);
if (options && options.timeSeries !== false) {
data = ensureColumnsTransformer.transformer(null)(data);
}
const mode = options.mode ?? CalculateFieldMode.ReduceRow;
let creator: ValuesCreator | undefined = undefined;
if (mode === CalculateFieldMode.ReduceRow) {
creator = getReduceRowCreator(defaults(options.reduce, defaultReduceOptions));
creator = getReduceRowCreator(defaults(options.reduce, defaultReduceOptions), data);
} else if (mode === CalculateFieldMode.BinaryOperation) {
creator = getBinaryCreator(defaults(options.binary, defaultBinaryOptions));
creator = getBinaryCreator(defaults(options.binary, defaultBinaryOptions), data);
}
// Nothing configured
......@@ -126,14 +121,14 @@ export const calculateFieldTransformer: DataTransformerInfo<CalculateFieldTransf
},
};
function getReduceRowCreator(options: ReduceOptions): ValuesCreator {
function getReduceRowCreator(options: ReduceOptions, allFrames: DataFrame[]): ValuesCreator {
let matcher = getFieldMatcher({
id: FieldMatcherID.numeric,
});
if (options.include && options.include.length) {
matcher = getFieldMatcher({
id: FieldMatcherID.byName,
id: FieldMatcherID.byNames,
options: options.include,
});
}
......@@ -152,7 +147,7 @@ function getReduceRowCreator(options: ReduceOptions): ValuesCreator {
// Find the columns that should be examined
const columns: Vector[] = [];
for (const field of frame.fields) {
if (matcher(field)) {
if (matcher(field, frame, allFrames)) {
columns.push(field.values);
}
}
......@@ -177,13 +172,13 @@ function getReduceRowCreator(options: ReduceOptions): ValuesCreator {
};
}
function findFieldValuesWithNameOrConstant(frame: DataFrame, name: string): Vector | undefined {
function findFieldValuesWithNameOrConstant(frame: DataFrame, name: string, allFrames: DataFrame[]): Vector | undefined {
if (!name) {
return undefined;
}
for (const f of frame.fields) {
if (name === getFieldDisplayName(f, frame)) {
if (name === getFieldDisplayName(f, frame, allFrames)) {
return f.values;
}
}
......@@ -196,12 +191,12 @@ function findFieldValuesWithNameOrConstant(frame: DataFrame, name: string): Vect
return undefined;
}
function getBinaryCreator(options: BinaryOptions): ValuesCreator {
function getBinaryCreator(options: BinaryOptions, allFrames: DataFrame[]): ValuesCreator {
const operator = binaryOperators.getIfExists(options.operator);
return (frame: DataFrame) => {
const left = findFieldValuesWithNameOrConstant(frame, options.left);
const right = findFieldValuesWithNameOrConstant(frame, options.right);
const left = findFieldValuesWithNameOrConstant(frame, options.left, allFrames);
const right = findFieldValuesWithNameOrConstant(frame, options.right, allFrames);
if (!left || !right || !operator) {
return (undefined as unknown) as Vector;
}
......@@ -210,26 +205,6 @@ function getBinaryCreator(options: BinaryOptions): ValuesCreator {
};
}
/**
* Find the name for the time field used in all frames (if one exists)
*/
function findConsistentTimeFieldName(data: DataFrame[]): string | undefined {
let name: string | undefined = undefined;
for (const frame of data) {
const { timeField } = getTimeField(frame);
if (!timeField) {
return undefined; // Not timeseries
}
if (!name) {
name = timeField.name;
} else if (name !== timeField.name) {
// Second frame has a different time column?!
return undefined;
}
}
return name;
}
export function getNameFromOptions(options: CalculateFieldTransformerOptions) {
if (options.alias?.length) {
return options.alias;
......
import { DataTransformerID } from './ids';
import { toDataFrame } from '../../dataframe/processDataFrame';
import { FieldType } from '../../types/dataFrame';
import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry';
import { transformDataFrame } from '../transformDataFrame';
import { ensureColumnsTransformer } from './ensureColumns';
import { seriesToColumnsTransformer } from './seriesToColumns';
const seriesA = toDataFrame({
fields: [
{ name: 'TheTime', type: FieldType.time, values: [1000, 2000] },
{ name: 'A', type: FieldType.number, values: [1, 100] },
],
});
const seriesBC = toDataFrame({
fields: [
{ name: 'TheTime', type: FieldType.time, values: [1000, 2000] },
{ name: 'B', type: FieldType.number, values: [2, 200] },
{ name: 'C', type: FieldType.number, values: [3, 300] },
{ name: 'D', type: FieldType.string, values: ['first', 'second'] },
],
});
const seriesNoTime = toDataFrame({
fields: [
{ name: 'B', type: FieldType.number, values: [2, 200] },
{ name: 'C', type: FieldType.number, values: [3, 300] },
{ name: 'D', type: FieldType.string, values: ['first', 'second'] },
],
});
describe('ensureColumns transformer', () => {
beforeAll(() => {
mockTransformationsRegistry([ensureColumnsTransformer, seriesToColumnsTransformer]);
});
it('will transform to columns if time field exists and multiple frames', () => {
const cfg = {
id: DataTransformerID.ensureColumns,
options: {},
};
const data = [seriesA, seriesBC];
const filtered = transformDataFrame([cfg], data);
expect(filtered.length).toEqual(1);
expect(filtered[0]).toMatchInlineSnapshot(`
Object {
"fields": Array [
Object {
"config": Object {},
"labels": undefined,
"name": "TheTime",
"type": "time",
"values": Array [
1000,
2000,
],
},
Object {
"config": Object {},
"labels": Object {},
"name": "A",
"type": "number",
"values": Array [
1,
100,
],
},
Object {
"config": Object {},
"labels": Object {},
"name": "B",
"type": "number",
"values": Array [
2,
200,
],
},
Object {
"config": Object {},
"labels": Object {},
"name": "C",
"type": "number",
"values": Array [
3,
300,
],
},
Object {
"config": Object {},
"labels": Object {},
"name": "D",
"type": "string",
"values": Array [
"first",
"second",
],
},
],
"meta": Object {
"transformations": Array [
"ensureColumns",
],
},
"name": undefined,
"refId": undefined,
}
`);
});
it('will not transform to columns if time field is missing for any of the series', () => {
const cfg = {
id: DataTransformerID.ensureColumns,
options: {},
};
const data = [seriesBC, seriesNoTime];
const filtered = transformDataFrame([cfg], data);
expect(filtered).toEqual(data);
});
it('will not transform to columns if only one series', () => {
const cfg = {
id: DataTransformerID.ensureColumns,
options: {},
};
const data = [seriesBC];
const filtered = transformDataFrame([cfg], data);
expect(filtered).toEqual(data);
});
});
import { seriesToColumnsTransformer } from './seriesToColumns';
import { DataFrame } from '../../types/dataFrame';
import { getTimeField } from '../../dataframe/processDataFrame';
import { DataTransformerInfo } from '../../types/transformations';
import { DataTransformerID } from './ids';
export const ensureColumnsTransformer: DataTransformerInfo = {
id: DataTransformerID.ensureColumns,
name: 'Ensure Columns Transformer',
description: 'Will check if current data frames is series or columns. If in series it will convert to columns.',
transformer: () => (data: DataFrame[]) => {
// Assume timeseries should first be joined by time
const timeFieldName = findConsistentTimeFieldName(data);
if (data.length > 1 && timeFieldName) {
return seriesToColumnsTransformer.transformer({
byField: timeFieldName,
})(data);
}
return data;
},
};
/**
* Find the name for the time field used in all frames (if one exists)
*/
function findConsistentTimeFieldName(data: DataFrame[]): string | undefined {
let name: string | undefined = undefined;
for (const frame of data) {
const { timeField } = getTimeField(frame);
if (!timeField) {
return undefined; // Not timeseries
}
if (!name) {
name = timeField.name;
} else if (name !== timeField.name) {
// Second frame has a different time column?!
return undefined;
}
}
return name;
}
......@@ -34,15 +34,16 @@ export const filterFieldsTransformer: DataTransformerInfo<FilterOptions> = {
const fields: Field[] = [];
for (let i = 0; i < series.fields.length; i++) {
const field = series.fields[i];
if (exclude) {
if (exclude(field)) {
if (exclude(field, series, data)) {
continue;
}
if (!include) {
fields.push(field);
}
}
if (include && include(field)) {
if (include && include(field, series, data)) {
fields.push(field);
}
}
......
......@@ -31,11 +31,13 @@ describe('filterByName transformer', () => {
});
describe('respects', () => {
it('inclusion', () => {
it('inclusion by pattern', () => {
const cfg = {
id: DataTransformerID.filterFieldsByName,
options: {
include: ['^(startsWith)'],
include: {
pattern: '/^(startsWith)/',
},
},
};
......@@ -44,11 +46,13 @@ describe('filterByName transformer', () => {
expect(filtered.fields[0].name).toBe('startsWithA');
});
it('exclusion', () => {
it('exclusion by pattern', () => {
const cfg = {
id: DataTransformerID.filterFieldsByName,
options: {
exclude: ['^(startsWith)'],
exclude: {
pattern: '/^(startsWith)/',
},
},
};
......@@ -57,12 +61,102 @@ describe('filterByName transformer', () => {
expect(filtered.fields[0].name).toBe('B');
});
it('inclusion and exclusion', () => {
it('inclusion and exclusion by pattern', () => {
const cfg = {
id: DataTransformerID.filterFieldsByName,
options: {
exclude: ['^(startsWith)'],
include: [`^(B)$`],
exclude: { pattern: '/^(startsWith)/' },
include: { pattern: '/^(B)$/' },
},
};
const filtered = transformDataFrame([cfg], [seriesWithNamesToMatch])[0];
expect(filtered.fields.length).toBe(1);
expect(filtered.fields[0].name).toBe('B');
});
it('inclusion by names', () => {
const cfg = {
id: DataTransformerID.filterFieldsByName,
options: {
include: {
names: ['startsWithA', 'startsWithC'],
},
},
};
const filtered = transformDataFrame([cfg], [seriesWithNamesToMatch])[0];
expect(filtered.fields.length).toBe(2);
expect(filtered.fields[0].name).toBe('startsWithA');
});
it('exclusion by names', () => {
const cfg = {
id: DataTransformerID.filterFieldsByName,
options: {
exclude: {
names: ['startsWithA', 'startsWithC'],
},
},
};
const filtered = transformDataFrame([cfg], [seriesWithNamesToMatch])[0];
expect(filtered.fields.length).toBe(2);
expect(filtered.fields[0].name).toBe('B');
});
it('inclusion and exclusion by names', () => {
const cfg = {
id: DataTransformerID.filterFieldsByName,
options: {
exclude: { names: ['startsWithA', 'startsWithC'] },
include: { names: ['B'] },
},
};
const filtered = transformDataFrame([cfg], [seriesWithNamesToMatch])[0];
expect(filtered.fields.length).toBe(1);
expect(filtered.fields[0].name).toBe('B');
});
it('inclusion by both', () => {
const cfg = {
id: DataTransformerID.filterFieldsByName,
options: {
include: {
pattern: '/^(startsWith)/',
names: ['startsWithA'],
},
},
};
const filtered = transformDataFrame([cfg], [seriesWithNamesToMatch])[0];
expect(filtered.fields.length).toBe(2);
expect(filtered.fields[0].name).toBe('startsWithA');
});
it('exclusion by both', () => {
const cfg = {
id: DataTransformerID.filterFieldsByName,
options: {
exclude: {
pattern: '/^(startsWith)/',
names: ['startsWithA'],
},
},
};
const filtered = transformDataFrame([cfg], [seriesWithNamesToMatch])[0];
expect(filtered.fields.length).toBe(2);
expect(filtered.fields[0].name).toBe('B');
});
it('inclusion and exclusion by both', () => {
const cfg = {
id: DataTransformerID.filterFieldsByName,
options: {
exclude: { names: ['startsWithA', 'startsWithC'] },
include: { pattern: '/^(B)$/' },
},
};
......
import { DataTransformerID } from './ids';
import { filterFieldsTransformer, FilterOptions } from './filter';
import { DataTransformerInfo } from '../../types/transformations';
import { DataTransformerInfo, MatcherConfig } from '../../types/transformations';
import { FieldMatcherID } from '../matchers/ids';
import { FilterOptions, filterFieldsTransformer } from './filter';
import { RegexpOrNamesMatcherOptions } from '../matchers/nameMatcher';
export interface FilterFieldsByNameTransformerOptions {
include?: string[];
exclude?: string[];
include?: RegexpOrNamesMatcherOptions;
exclude?: RegexpOrNamesMatcherOptions;
}
export const filterFieldsByNameTransformer: DataTransformerInfo<FilterFieldsByNameTransformerOptions> = {
......@@ -19,25 +20,33 @@ export const filterFieldsByNameTransformer: DataTransformerInfo<FilterFieldsByNa
* be applied, just return the input series
*/
transformer: (options: FilterFieldsByNameTransformerOptions) => {
const filterOptions: FilterOptions = {};
if (options.include) {
filterOptions.include = {
id: FieldMatcherID.byName,
options: options.include.length > 0 ? buildRegex(options.include) : '',
};
}
if (options.exclude) {
filterOptions.exclude = {
id: FieldMatcherID.byName,
options: options.exclude.length > 0 ? buildRegex(options.exclude) : '',
};
}
const filterOptions: FilterOptions = {
include: getMatcherConfig(options.include),
exclude: getMatcherConfig(options.exclude),
};
return filterFieldsTransformer.transformer(filterOptions);
},
};
const buildRegex = (regexs: string[]) => {
const include = regexs.map(s => `(${s})`).join('|');
return `/${include}/`;
const getMatcherConfig = (options?: RegexpOrNamesMatcherOptions): MatcherConfig | undefined => {
if (!options) {
return undefined;
}
const { names, pattern } = options;
if ((!Array.isArray(names) || names.length === 0) && !pattern) {
return undefined;
}
if (!pattern) {
return { id: FieldMatcherID.byNames, options: names };
}
if (!Array.isArray(names) || names.length === 0) {
return { id: FieldMatcherID.byRegexp, options: pattern };
}
return { id: FieldMatcherID.byRegexpOrNames, options };
};
export enum DataTransformerID {
// join = 'join', // Pick a field and merge all series based on that field
append = 'append', // Merge all series together
append = 'append',
// rotate = 'rotate', // Columns to rows
reduce = 'reduce', // Run calculations on fields
order = 'order', // order fields based on user configuration
organize = 'organize', // order, rename and filter based on user configuration
rename = 'rename', // rename field based on user configuration
calculateField = 'calculateField', // Run a reducer on the row
seriesToColumns = 'seriesToColumns', // former table transform timeseries_to_columns
labelsToFields = 'labelsToFields', // former table transform table
filterFields = 'filterFields', // Pick some fields (keep all frames)
filterFieldsByName = 'filterFieldsByName', // Pick fields with name matching regex (keep all frames)
filterFrames = 'filterFrames', // Pick some frames (keep all fields)
filterByRefId = 'filterByRefId', // Pick some frames by RefId
noop = 'noop', // Does nothing to the dataframe
reduce = 'reduce',
order = 'order',
organize = 'organize',
rename = 'rename',
calculateField = 'calculateField',
seriesToColumns = 'seriesToColumns',
labelsToFields = 'labelsToFields',
filterFields = 'filterFields',
filterFieldsByName = 'filterFieldsByName',
filterFrames = 'filterFrames',
filterByRefId = 'filterByRefId',
noop = 'noop',
ensureColumns = 'ensureColumns',
}
......@@ -29,7 +29,7 @@ export const organizeFieldsTransformer: DataTransformerInfo<OrganizeFieldsTransf
const rename = renameFieldsTransformer.transformer(options);
const order = orderFieldsTransformer.transformer(options);
const filter = filterFieldsByNameTransformer.transformer({
exclude: mapToExcludeArray(options.excludeByName),
exclude: { names: mapToExcludeArray(options.excludeByName) },
});
return (data: DataFrame[]) => rename(order(filter(data)));
......
......@@ -72,7 +72,7 @@ export const reduceTransformer: DataTransformerInfo<ReduceTransformerOptions> =
continue;
}
if (matcher(field)) {
if (matcher(field, series, data)) {
const results = reduceField({
field,
reducers,
......
......@@ -25,7 +25,7 @@ export interface DataTransformerConfig<TOptions = any> {
options: TOptions;
}
export type FieldMatcher = (field: Field) => boolean;
export type FieldMatcher = (field: Field, frame: DataFrame, allFrames: DataFrame[]) => boolean;
export type FrameMatcher = (frame: DataFrame) => boolean;
export interface FieldMatcherInfo<TOptions = any> extends RegistryItemWithOptions<TOptions> {
......
import React from 'react';
import React, { memo, useMemo, useCallback } from 'react';
import { MatcherUIProps, FieldMatcherUIRegistryItem } from './types';
import { FieldMatcherID, fieldMatchers, getFieldDisplayName } from '@grafana/data';
import { FieldMatcherID, fieldMatchers, getFieldDisplayName, SelectableValue, DataFrame } from '@grafana/data';
import { Select } from '../Select/Select';
export class FieldNameMatcherEditor extends React.PureComponent<MatcherUIProps<string>> {
render() {
const { data, options, onChange } = this.props;
const names: Set<string> = new Set();
export const FieldNameMatcherEditor = memo<MatcherUIProps<string>>(props => {
const { data, options } = props;
const names = useFieldDisplayNames(data);
const selectOptions = useSelectOptions(names);
for (const frame of data) {
for (const field of frame.fields) {
names.add(getFieldDisplayName(field, frame, data));
const onChange = useCallback(
(selection: SelectableValue<string>) => {
if (!selection.value || !names.has(selection.value)) {
return;
}
}
if (options) {
names.add(options);
}
const selectOptions = Array.from(names).map(n => ({
value: n,
label: n,
}));
const selectedOption = selectOptions.find(v => v.value === options);
return props.onChange(selection.value);
},
[names, props.onChange]
);
return (
<Select allowCustomValue value={selectedOption} options={selectOptions} onChange={o => onChange(o.value!)} />
);
}
}
const selectedOption = selectOptions.find(v => v.value === options);
return <Select value={selectedOption} options={selectOptions} onChange={onChange} />;
});
export const fieldNameMatcherItem: FieldMatcherUIRegistryItem<string> = {
id: FieldMatcherID.byName,
......@@ -35,3 +29,26 @@ export const fieldNameMatcherItem: FieldMatcherUIRegistryItem<string> = {
name: 'Filter by field',
description: 'Set properties for fields matching the name',
};
const useFieldDisplayNames = (data: DataFrame[]): Set<string> => {
return useMemo(() => {
const names: Set<string> = new Set();
for (const frame of data) {
for (const field of frame.fields) {
names.add(getFieldDisplayName(field, frame, data));
}
}
return names;
}, [data]);
};
const useSelectOptions = (displayNames: Set<string>): Array<SelectableValue<string>> => {
return useMemo(() => {
return Array.from(displayNames).map(n => ({
value: n,
label: n,
}));
}, [displayNames]);
};
......@@ -27,7 +27,7 @@ import defaults from 'lodash/defaults';
interface CalculateFieldTransformerEditorProps extends TransformerUIProps<CalculateFieldTransformerOptions> {}
interface CalculateFieldTransformerEditorState {
include: string;
include: string[];
names: string[];
selected: string[];
}
......@@ -45,7 +45,7 @@ export class CalculateFieldTransformerEditor extends React.PureComponent<
super(props);
this.state = {
include: props.options?.reduce?.include || '',
include: props.options?.reduce?.include || [],
names: [],
selected: [],
};
......@@ -62,9 +62,9 @@ export class CalculateFieldTransformerEditor extends React.PureComponent<
}
private initOptions() {
const { input, options } = this.props;
const include = options?.reduce?.include || '';
const configuredOptions = include.split('|');
const { options } = this.props;
const configuredOptions = options?.reduce?.include || [];
const input = standardTransformers.ensureColumnsTransformer.transformer(null)(this.props.input);
const allNames: string[] = [];
const byName: KeyValue<boolean> = {};
......@@ -156,7 +156,7 @@ export class CalculateFieldTransformerEditor extends React.PureComponent<
const { reduce } = this.props.options;
this.updateReduceOptions({
...reduce!,
include: selected.join('|'),
include: selected,
});
};
......@@ -274,7 +274,6 @@ export class CalculateFieldTransformerEditor extends React.PureComponent<
</div>
<div className="gf-form">
<Select
allowCustomValue
placeholder="Field or number"
options={leftNames}
className="min-width-18 gf-form-spacing"
......@@ -290,7 +289,6 @@ export class CalculateFieldTransformerEditor extends React.PureComponent<
menuPlacement="bottom"
/>
<Select
allowCustomValue
placeholder="Field or number"
className="min-width-10"
options={rightNames}
......
......@@ -6,6 +6,7 @@ import {
TransformerRegistyItem,
TransformerUIProps,
getFieldDisplayName,
stringToJsRegex,
} from '@grafana/data';
import { Field, Input, FilterPill, HorizontalGroup } from '@grafana/ui';
import { css } from 'emotion';
......@@ -32,7 +33,8 @@ export class FilterByNameTransformerEditor extends React.PureComponent<
constructor(props: FilterByNameTransformerEditorProps) {
super(props);
this.state = {
include: props.options.include || [],
include: props.options.include?.names || [],
regex: props.options.include?.pattern,
options: [],
selected: [],
isRegexValid: true,
......@@ -51,7 +53,7 @@ export class FilterByNameTransformerEditor extends React.PureComponent<
private initOptions() {
const { input, options } = this.props;
const configuredOptions = options.include ? options.include : [];
const configuredOptions = Array.from(options.include?.names ?? []);
const allNames: FieldNameInfo[] = [];
const byName: KeyValue<FieldNameInfo> = {};
......@@ -73,28 +75,34 @@ export class FilterByNameTransformerEditor extends React.PureComponent<
}
}
let regexOption;
if (options.include?.pattern) {
try {
const regex = stringToJsRegex(options.include.pattern);
if (configuredOptions.length) {
let selected: FieldNameInfo[] = [];
for (const o of configuredOptions) {
const selectedFields = allNames.filter(n => n.name === o);
if (selectedFields.length > 0) {
selected = selected.concat(selectedFields);
} else {
// there can be only one regex in the options
regexOption = o;
for (const info of allNames) {
if (regex.test(info.name)) {
configuredOptions.push(info.name);
}
}
} catch (error) {
console.log(error);
}
}
if (configuredOptions.length) {
const selected: FieldNameInfo[] = allNames.filter(n => configuredOptions.includes(n.name));
this.setState({
options: allNames,
selected: selected.map(s => s.name),
regex: regexOption,
regex: options.include?.pattern,
});
} else {
this.setState({ options: allNames, selected: allNames.map(n => n.name) });
this.setState({
options: allNames,
selected: allNames.map(n => n.name),
regex: options.include?.pattern,
});
}
}
......@@ -109,44 +117,46 @@ export class FilterByNameTransformerEditor extends React.PureComponent<
onChange = (selected: string[]) => {
const { regex, isRegexValid } = this.state;
let include = selected;
const options: FilterFieldsByNameTransformerOptions = {
...this.props.options,
include: { names: selected },
};
if (regex && isRegexValid) {
include = include.concat([regex]);
options.include = options.include ?? {};
options.include.pattern = regex;
}
this.setState({ selected }, () => {
this.props.onChange({
...this.props.options,
include,
});
this.props.onChange(options);
});
};
onInputBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const { selected, regex } = this.state;
let isRegexValid = true;
try {
if (regex) {
new RegExp(regex);
stringToJsRegex(regex);
}
} catch (e) {
isRegexValid = false;
}
if (isRegexValid) {
this.props.onChange({
...this.props.options,
include: regex ? [...selected, regex] : selected,
include: { pattern: regex },
});
} else {
this.props.onChange({
...this.props.options,
include: selected,
include: { names: selected },
});
}
this.setState({
isRegexValid,
});
this.setState({ isRegexValid });
};
render() {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment