Tempo: Add support for ad-hoc filters (#102448)

* Add support for ad-hoc filters

* Handle enum tags when using ad hoc filters
pull/102707/head
Piotr Jamróz 3 months ago committed by GitHub
parent f0db0c4f0d
commit 82ea562b75
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 12
      public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.tsx
  2. 29
      public/app/plugins/datasource/tempo/SearchTraceQLEditor/utils.test.ts
  3. 19
      public/app/plugins/datasource/tempo/SearchTraceQLEditor/utils.ts
  4. 11
      public/app/plugins/datasource/tempo/datasource.ts
  5. 233
      public/app/plugins/datasource/tempo/language_provider.test.ts
  6. 46
      public/app/plugins/datasource/tempo/language_provider.ts
  7. 8
      public/app/plugins/datasource/tempo/traceql/QueryEditor.tsx
  8. 2
      public/app/plugins/datasource/tempo/traceql/traceql.ts

@ -64,7 +64,9 @@ const TraceQLSearch = ({ datasource, query, onChange, onClearResults, app, addVa
const templateVariables = getTemplateSrv().getVariables(); const templateVariables = getTemplateSrv().getVariables();
useEffect(() => { useEffect(() => {
setTraceQlQuery(datasource.languageProvider.generateQueryFromFilters(interpolateFilters(query.filters || []))); setTraceQlQuery(
datasource.languageProvider.generateQueryFromFilters({ traceqlFilters: interpolateFilters(query.filters || []) })
);
}, [datasource.languageProvider, query, templateVariables]); }, [datasource.languageProvider, query, templateVariables]);
const findFilter = useCallback((id: string) => query.filters?.find((f) => f.id === id), [query.filters]); const findFilter = useCallback((id: string) => query.filters?.find((f) => f.id === id), [query.filters]);
@ -123,7 +125,9 @@ const TraceQLSearch = ({ datasource, query, onChange, onClearResults, app, addVa
return traceQlQuery; return traceQlQuery;
} }
const filtersAfterRemoval = query.filters?.filter((f) => f.id !== filter.id) || []; const filtersAfterRemoval = query.filters?.filter((f) => f.id !== filter.id) || [];
return datasource.languageProvider.generateQueryFromFilters(interpolateFilters(filtersAfterRemoval || [])); return datasource.languageProvider.generateQueryFromFilters({
traceqlFilters: interpolateFilters(filtersAfterRemoval || []),
});
}; };
return ( return (
@ -260,7 +264,9 @@ const TraceQLSearch = ({ datasource, query, onChange, onClearResults, app, addVa
}); });
onClearResults(); onClearResults();
const traceQlQuery = datasource.languageProvider.generateQueryFromFilters(query.filters || []); const traceQlQuery = datasource.languageProvider.generateQueryFromFilters({
traceqlFilters: query.filters || [],
});
onChange({ onChange({
...query, ...query,
query: traceQlQuery, query: traceQlQuery,

@ -7,7 +7,6 @@ import { intrinsics } from '../traceql/traceql';
import { import {
filterToQuerySection, filterToQuerySection,
generateQueryFromAdHocFilters,
getAllTags, getAllTags,
getFilteredTags, getFilteredTags,
getIntrinsicTags, getIntrinsicTags,
@ -22,34 +21,6 @@ const datasource: TempoDatasource = {
} as unknown as TempoDatasource; } as unknown as TempoDatasource;
const lp = new TempoLanguageProvider(datasource); const lp = new TempoLanguageProvider(datasource);
describe('generateQueryFromAdHocFilters generates the correct query for', () => {
it('an empty array', () => {
expect(generateQueryFromAdHocFilters([], lp)).toBe('{}');
});
it('a filter with values', () => {
expect(generateQueryFromAdHocFilters([{ key: 'footag', operator: '=', value: 'foovalue' }], lp)).toBe(
'{footag="foovalue"}'
);
});
it('two filters with values', () => {
expect(
generateQueryFromAdHocFilters(
[
{ key: 'footag', operator: '=', value: 'foovalue' },
{ key: 'bartag', operator: '=', value: '0' },
],
lp
)
).toBe('{footag="foovalue" && bartag=0}');
});
it('a filter with intrinsic values', () => {
expect(generateQueryFromAdHocFilters([{ key: 'kind', operator: '=', value: 'server' }], lp)).toBe('{kind=server}');
});
});
describe('gets correct tags', () => { describe('gets correct tags', () => {
const datasource: TempoDatasource = { const datasource: TempoDatasource = {
search: { search: {

@ -1,6 +1,6 @@
import { startCase, uniq } from 'lodash'; import { startCase, uniq } from 'lodash';
import { AdHocVariableFilter, ScopedVars, SelectableValue } from '@grafana/data'; import { ScopedVars, SelectableValue } from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime'; import { getTemplateSrv } from '@grafana/runtime';
import { VariableFormatID } from '@grafana/schema'; import { VariableFormatID } from '@grafana/schema';
@ -81,23 +81,6 @@ export const filterToQuerySection = (f: TraceqlFilter, filters: TraceqlFilter[],
return `${scopeHelper(f, lp)}${tagHelper(f, filters)}${f.operator}${valueHelper(f)}`; return `${scopeHelper(f, lp)}${tagHelper(f, filters)}${f.operator}${valueHelper(f)}`;
}; };
export const generateQueryFromAdHocFilters = (filters: AdHocVariableFilter[], lp: TempoLanguageProvider) => {
return `{${filters
.filter((f) => f.key && f.operator && f.value)
.map((f) => `${f.key}${f.operator}${adHocValueHelper(f, lp)}`)
.join(' && ')}}`;
};
const adHocValueHelper = (f: AdHocVariableFilter, lp: TempoLanguageProvider) => {
if (lp.getIntrinsics().find((t) => t === f.key)) {
return f.value;
}
if (parseInt(f.value, 10).toString() === f.value) {
return f.value;
}
return `"${f.value}"`;
};
export const getTagWithoutScope = (tag: string) => { export const getTagWithoutScope = (tag: string) => {
return tag.replace(/^(event|instrumentation|link|resource|span)\./, ''); return tag.replace(/^(event|instrumentation|link|resource|span)\./, '');
}; };

@ -36,7 +36,7 @@ import {
} from '@grafana/runtime'; } from '@grafana/runtime';
import { BarGaugeDisplayMode, TableCellDisplayMode, VariableFormatID } from '@grafana/schema'; import { BarGaugeDisplayMode, TableCellDisplayMode, VariableFormatID } from '@grafana/schema';
import { generateQueryFromAdHocFilters, getTagWithoutScope, interpolateFilters } from './SearchTraceQLEditor/utils'; import { getTagWithoutScope, interpolateFilters } from './SearchTraceQLEditor/utils';
import { TempoVariableQuery, TempoVariableQueryType } from './VariableQueryEditor'; import { TempoVariableQuery, TempoVariableQueryType } from './VariableQueryEditor';
import { PrometheusDatasource, PromQuery } from './_importedDependencies/datasources/prometheus/types'; import { PrometheusDatasource, PromQuery } from './_importedDependencies/datasources/prometheus/types';
import { TagLimitOptions } from './configuration/TagLimitSettings'; import { TagLimitOptions } from './configuration/TagLimitSettings';
@ -239,7 +239,7 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
// Allows to retrieve the list of tag values for ad-hoc filters // Allows to retrieve the list of tag values for ad-hoc filters
getTagValues(options: DataSourceGetTagValuesOptions<TempoQuery>): Promise<Array<{ text: string }>> { getTagValues(options: DataSourceGetTagValuesOptions<TempoQuery>): Promise<Array<{ text: string }>> {
const query = generateQueryFromAdHocFilters(options.filters, this.languageProvider); const query = this.languageProvider.generateQueryFromFilters({ adhocFilters: options.filters });
return this.tagValuesQuery(options.key, query); return this.tagValuesQuery(options.key, query);
} }
@ -418,7 +418,10 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
const traceqlSearchTargets = targets.traceqlSearch; const traceqlSearchTargets = targets.traceqlSearch;
if (traceqlSearchTargets.length > 0) { if (traceqlSearchTargets.length > 0) {
const appliedQuery = this.applyVariables(traceqlSearchTargets[0], options.scopedVars); const appliedQuery = this.applyVariables(traceqlSearchTargets[0], options.scopedVars);
const queryFromFilters = this.languageProvider.generateQueryFromFilters(appliedQuery.filters); const queryFromFilters = this.languageProvider.generateQueryFromFilters({
traceqlFilters: appliedQuery.filters,
adhocFilters: options.filters,
});
reportInteraction('grafana_traces_traceql_search_queried', { reportInteraction('grafana_traces_traceql_search_queried', {
datasourceType: 'tempo', datasourceType: 'tempo',
@ -918,7 +921,7 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
} }
const appliedQuery = this.applyVariables(query, {}); const appliedQuery = this.applyVariables(query, {});
return this.languageProvider.generateQueryFromFilters(appliedQuery.filters); return this.languageProvider.generateQueryFromFilters({ traceqlFilters: appliedQuery.filters });
} }
} }

@ -89,143 +89,228 @@ describe('Language_provider', () => {
}); });
it('an empty array', () => { it('an empty array', () => {
expect(lp.generateQueryFromFilters([])).toBe('{}'); expect(lp.generateQueryFromFilters({ traceqlFilters: [] })).toBe('{}');
}); });
it('a field without value', () => { it('a field without value', () => {
expect(lp.generateQueryFromFilters([{ id: 'foo', tag: 'footag', operator: '=' }])).toBe('{}'); expect(lp.generateQueryFromFilters({ traceqlFilters: [{ id: 'foo', tag: 'footag', operator: '=' }] })).toBe('{}');
}); });
it('a field with value but without tag', () => { it('a field with value but without tag', () => {
expect(lp.generateQueryFromFilters([{ id: 'foo', value: 'foovalue', operator: '=' }])).toBe('{}'); expect(lp.generateQueryFromFilters({ traceqlFilters: [{ id: 'foo', value: 'foovalue', operator: '=' }] })).toBe(
'{}'
);
}); });
it('a field with value and tag but without operator', () => { it('a field with value and tag but without operator', () => {
expect(lp.generateQueryFromFilters([{ id: 'foo', tag: 'footag', value: 'foovalue' }])).toBe('{}'); expect(lp.generateQueryFromFilters({ traceqlFilters: [{ id: 'foo', tag: 'footag', value: 'foovalue' }] })).toBe(
'{}'
);
}); });
describe('generates correct query for duration when duration type', () => { describe('generates correct query for duration when duration type', () => {
it('not set', () => { it('not set', () => {
expect( expect(
lp.generateQueryFromFilters([ lp.generateQueryFromFilters({
{ id: 'min-duration', operator: '>', valueType: 'duration', tag: 'duration', value: '100ms' }, traceqlFilters: [
]) { id: 'min-duration', operator: '>', valueType: 'duration', tag: 'duration', value: '100ms' },
],
})
).toBe('{duration>100ms}'); ).toBe('{duration>100ms}');
}); });
it('set to span', () => { it('set to span', () => {
expect( expect(
lp.generateQueryFromFilters([ lp.generateQueryFromFilters({
{ id: 'min-duration', operator: '>', valueType: 'duration', tag: 'duration', value: '100ms' }, traceqlFilters: [
{ id: 'duration-type', value: 'span' }, { id: 'min-duration', operator: '>', valueType: 'duration', tag: 'duration', value: '100ms' },
]) { id: 'duration-type', value: 'span' },
],
})
).toBe('{duration>100ms}'); ).toBe('{duration>100ms}');
}); });
it('set to trace', () => { it('set to trace', () => {
expect( expect(
lp.generateQueryFromFilters([ lp.generateQueryFromFilters({
{ id: 'min-duration', operator: '>', valueType: 'duration', tag: 'duration', value: '100ms' }, traceqlFilters: [
{ id: 'duration-type', value: 'trace' }, { id: 'min-duration', operator: '>', valueType: 'duration', tag: 'duration', value: '100ms' },
]) { id: 'duration-type', value: 'trace' },
],
})
).toBe('{traceDuration>100ms}'); ).toBe('{traceDuration>100ms}');
}); });
}); });
it('a field with tag, operator and tag', () => { it('a field with tag, operator and tag', () => {
expect(lp.generateQueryFromFilters([{ id: 'foo', tag: 'footag', value: 'foovalue', operator: '=' }])).toBe(
'{.footag=foovalue}'
);
expect( expect(
lp.generateQueryFromFilters([ lp.generateQueryFromFilters({
{ id: 'foo', tag: 'footag', value: 'foovalue', operator: '=', valueType: 'string' }, traceqlFilters: [{ id: 'foo', tag: 'footag', value: 'foovalue', operator: '=' }],
]) })
).toBe('{.footag=foovalue}');
expect(
lp.generateQueryFromFilters({
traceqlFilters: [{ id: 'foo', tag: 'footag', value: 'foovalue', operator: '=', valueType: 'string' }],
})
).toBe('{.footag="foovalue"}'); ).toBe('{.footag="foovalue"}');
}); });
it('a field with valueType as integer', () => { it('a field with valueType as integer', () => {
expect( expect(
lp.generateQueryFromFilters([{ id: 'foo', tag: 'footag', value: '1234', operator: '>', valueType: 'integer' }]) lp.generateQueryFromFilters({
traceqlFilters: [{ id: 'foo', tag: 'footag', value: '1234', operator: '>', valueType: 'integer' }],
})
).toBe('{.footag>1234}'); ).toBe('{.footag>1234}');
}); });
it.each([['=~'], ['!~']])('a field with a regexp operator (%s)', (operator) => { it.each([['=~'], ['!~']])('a field with a regexp operator (%s)', (operator) => {
expect( expect(
lp.generateQueryFromFilters([ lp.generateQueryFromFilters({
{ traceqlFilters: [
id: 'span-name', {
tag: 'name', id: 'span-name',
operator, tag: 'name',
scope: TraceqlSearchScope.Span, operator,
value: ['api/v2/variants/by-upc/(?P<upc>[\\s\\S]*)/$'], scope: TraceqlSearchScope.Span,
valueType: 'string', value: ['api/v2/variants/by-upc/(?P<upc>[\\s\\S]*)/$'],
}, valueType: 'string',
]) },
],
})
).toBe(`{name${operator}"api/v2/variants/by-upc/\\\\(\\\\?P<upc>\\\\[\\\\\\\\s\\\\\\\\S\\\\]\\\\*\\\\)/\\\\$"}`); ).toBe(`{name${operator}"api/v2/variants/by-upc/\\\\(\\\\?P<upc>\\\\[\\\\\\\\s\\\\\\\\S\\\\]\\\\*\\\\)/\\\\$"}`);
}); });
it('two fields with everything filled in', () => { it('two fields with everything filled in', () => {
expect( expect(
lp.generateQueryFromFilters([ lp.generateQueryFromFilters({
{ id: 'foo', tag: 'footag', value: '1234', operator: '>=', valueType: 'integer' }, traceqlFilters: [
{ id: 'bar', tag: 'bartag', value: 'barvalue', operator: '=', valueType: 'string' }, { id: 'foo', tag: 'footag', value: '1234', operator: '>=', valueType: 'integer' },
]) { id: 'bar', tag: 'bartag', value: 'barvalue', operator: '=', valueType: 'string' },
],
})
).toBe('{.footag>=1234 && .bartag="barvalue"}'); ).toBe('{.footag>=1234 && .bartag="barvalue"}');
}); });
it('two fields but one is missing a value', () => { it('two fields but one is missing a value', () => {
expect( expect(
lp.generateQueryFromFilters([ lp.generateQueryFromFilters({
{ id: 'foo', tag: 'footag', value: '1234', operator: '>=', valueType: 'integer' }, traceqlFilters: [
{ id: 'bar', tag: 'bartag', operator: '=', valueType: 'string' }, { id: 'foo', tag: 'footag', value: '1234', operator: '>=', valueType: 'integer' },
]) { id: 'bar', tag: 'bartag', operator: '=', valueType: 'string' },
],
})
).toBe('{.footag>=1234}'); ).toBe('{.footag>=1234}');
}); });
it('two fields but one is missing a value and the other a tag', () => { it('two fields but one is missing a value and the other a tag', () => {
expect( expect(
lp.generateQueryFromFilters([ lp.generateQueryFromFilters({
{ id: 'foo', value: '1234', operator: '>=', valueType: 'integer' }, traceqlFilters: [
{ id: 'bar', tag: 'bartag', operator: '=', valueType: 'string' }, { id: 'foo', value: '1234', operator: '>=', valueType: 'integer' },
]) { id: 'bar', tag: 'bartag', operator: '=', valueType: 'string' },
],
})
).toBe('{}'); ).toBe('{}');
}); });
it('scope is unscoped', () => { it('scope is unscoped', () => {
expect( expect(
lp.generateQueryFromFilters([ lp.generateQueryFromFilters({
{ traceqlFilters: [
id: 'foo', {
tag: 'footag', id: 'foo',
value: '1234', tag: 'footag',
operator: '>=', value: '1234',
scope: TraceqlSearchScope.Unscoped, operator: '>=',
valueType: 'integer', scope: TraceqlSearchScope.Unscoped,
}, valueType: 'integer',
]) },
],
})
).toBe('{.footag>=1234}'); ).toBe('{.footag>=1234}');
}); });
it('scope is span', () => { it('scope is span', () => {
expect( expect(
lp.generateQueryFromFilters([ lp.generateQueryFromFilters({
{ traceqlFilters: [
id: 'foo', {
tag: 'footag', id: 'foo',
value: '1234', tag: 'footag',
operator: '>=', value: '1234',
scope: TraceqlSearchScope.Span, operator: '>=',
valueType: 'integer', scope: TraceqlSearchScope.Span,
}, valueType: 'integer',
]) },
],
})
).toBe('{span.footag>=1234}'); ).toBe('{span.footag>=1234}');
}); });
it('scope is resource', () => { it('scope is resource', () => {
expect( expect(
lp.generateQueryFromFilters([ lp.generateQueryFromFilters({
{ traceqlFilters: [
id: 'foo', {
tag: 'footag', id: 'foo',
value: '1234', tag: 'footag',
operator: '>=', value: '1234',
scope: TraceqlSearchScope.Resource, operator: '>=',
valueType: 'integer', scope: TraceqlSearchScope.Resource,
}, valueType: 'integer',
]) },
],
})
).toBe('{resource.footag>=1234}'); ).toBe('{resource.footag>=1234}');
}); });
describe('adhoc filters', () => {
it('mixes adhoc filters with trace ql', () => {
expect(
lp.generateQueryFromFilters({
traceqlFilters: [
{
id: 'foo',
tag: 'footag',
value: '1234',
operator: '>=',
scope: TraceqlSearchScope.Resource,
valueType: 'integer',
},
],
adhocFilters: [
{
key: 'resource.name',
operator: '=',
value: 'foo',
},
],
})
).toBe('{resource.footag>=1234 && resource.name="foo"}');
});
it('an empty array', () => {
expect(lp.generateQueryFromFilters({ adhocFilters: [] })).toBe('{}');
});
it('a filter with values', () => {
expect(
lp.generateQueryFromFilters({ adhocFilters: [{ key: 'footag', operator: '=', value: 'foovalue' }] })
).toBe('{footag="foovalue"}');
});
it('two filters with values', () => {
expect(
lp.generateQueryFromFilters({
adhocFilters: [
{ key: 'footag', operator: '=', value: 'foovalue' },
{ key: 'bartag', operator: '=', value: '0' },
],
})
).toBe('{footag="foovalue" && bartag=0}');
});
it('a filter with enum intrinsic values', () => {
expect(lp.generateQueryFromFilters({ adhocFilters: [{ key: 'kind', operator: '=', value: 'server' }] })).toBe(
'{kind=server}'
);
});
it('a filter with non-enum intrinsic values', () => {
expect(
lp.generateQueryFromFilters({ adhocFilters: [{ key: 'name', operator: '=', value: 'my-server' }] })
).toBe('{name="my-server"}');
});
});
}); });
const setup = (tagsV1?: string[], tagsV2?: Scope[]) => { const setup = (tagsV1?: string[], tagsV2?: Scope[]) => {

@ -1,4 +1,4 @@
import { LanguageProvider, SelectableValue } from '@grafana/data'; import { AdHocVariableFilter, LanguageProvider, SelectableValue } from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime'; import { getTemplateSrv } from '@grafana/runtime';
import { VariableFormatID } from '@grafana/schema'; import { VariableFormatID } from '@grafana/schema';
@ -11,7 +11,7 @@ import {
} from './SearchTraceQLEditor/utils'; } from './SearchTraceQLEditor/utils';
import { TraceqlFilter, TraceqlSearchScope } from './dataquery.gen'; import { TraceqlFilter, TraceqlSearchScope } from './dataquery.gen';
import { TempoDatasource } from './datasource'; import { TempoDatasource } from './datasource';
import { intrinsicsV1 } from './traceql/traceql'; import { enumIntrinsics, intrinsicsV1 } from './traceql/traceql';
import { Scope } from './types'; import { Scope } from './types';
// Limit maximum tags retrieved from the backend // Limit maximum tags retrieved from the backend
@ -179,14 +179,48 @@ export default class TempoLanguageProvider extends LanguageProvider {
return encodeURIComponent(encodeURIComponent(tag)); return encodeURIComponent(encodeURIComponent(tag));
}; };
generateQueryFromFilters(filters: TraceqlFilter[]) { generateQueryFromFilters({
traceqlFilters,
adhocFilters,
}: {
traceqlFilters?: TraceqlFilter[];
adhocFilters?: AdHocVariableFilter[];
}) {
if (!traceqlFilters && !adhocFilters) {
return '';
}
const allFilters = [
...this.generateQueryFromTraceQlFilters(traceqlFilters || []),
...this.generateQueryFromAdHocFilters(adhocFilters || []),
];
return `{${allFilters.join(' && ')}}`;
}
private generateQueryFromTraceQlFilters(filters: TraceqlFilter[]) {
if (!filters) { if (!filters) {
return ''; return '';
} }
return `{${filters return filters
.filter((f) => f.tag && f.operator && f.value?.length) .filter((f) => f.tag && f.operator && f.value?.length)
.map((f) => filterToQuerySection(f, filters, this)) .map((f) => filterToQuerySection(f, filters, this));
.join(' && ')}}`;
} }
private generateQueryFromAdHocFilters = (filters: AdHocVariableFilter[]) => {
return filters
.filter((f) => f.key && f.operator && f.value)
.map((f) => `${f.key}${f.operator}${this.adHocValueHelper(f)}`);
};
adHocValueHelper = (f: AdHocVariableFilter) => {
if (this.getIntrinsics().find((t) => t === f.key) && enumIntrinsics.includes(f.key)) {
return f.value;
}
if (parseInt(f.value, 10).toString() === f.value) {
return f.value;
}
return `"${f.value}"`;
};
} }

@ -22,7 +22,9 @@ export function QueryEditor(props: Props) {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const query = defaults(props.query, defaultQuery); const query = defaults(props.query, defaultQuery);
const [showCopyFromSearchButton, setShowCopyFromSearchButton] = useState(() => { const [showCopyFromSearchButton, setShowCopyFromSearchButton] = useState(() => {
const genQuery = props.datasource.languageProvider.generateQueryFromFilters(query.filters || []); const genQuery = props.datasource.languageProvider.generateQueryFromFilters({
traceqlFilters: query.filters || [],
});
return genQuery === query.query || genQuery === '{}'; return genQuery === query.query || genQuery === '{}';
}); });
@ -50,7 +52,9 @@ export function QueryEditor(props: Props) {
props.onClearResults(); props.onClearResults();
props.onChange({ props.onChange({
...query, ...query,
query: props.datasource.languageProvider.generateQueryFromFilters(query.filters || []), query: props.datasource.languageProvider.generateQueryFromFilters({
traceqlFilters: query.filters || [],
}),
}); });
setShowCopyFromSearchButton(true); setShowCopyFromSearchButton(true);
}} }}

@ -58,6 +58,8 @@ export const intrinsics = intrinsicsV1.concat([
]); ]);
export const scopes: string[] = ['event', 'instrumentation', 'link', 'resource', 'span']; export const scopes: string[] = ['event', 'instrumentation', 'link', 'resource', 'span'];
export const enumIntrinsics = ['kind', 'span:kind', 'status', 'span:status'];
const aggregatorFunctions = ['avg', 'count', 'max', 'min', 'sum']; const aggregatorFunctions = ['avg', 'count', 'max', 'min', 'sum'];
const functions = aggregatorFunctions.concat([ const functions = aggregatorFunctions.concat([
'by', 'by',

Loading…
Cancel
Save