add top_hit metric (#9809)

Backports PR #7302

**Commit 1:**
add top_hits aggregation

* Original sha: 22bab6246e
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-05-26T22:02:08Z

**Commit 2:**
support nested fields

* Original sha: 8aef2b8f42
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-06-11T14:57:35Z

**Commit 3:**
added choice of the order and select by default the index pattern associated time field

* Original sha: 9791ac50e4
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-06-14T21:08:00Z

**Commit 4:**
improved parameters name and changed aggregation from latest to top to better reflect what this aggregation may accomplish

* Original sha: 692f41cc6f
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-06-22T20:32:12Z

**Commit 5:**
made variable a constant

* Original sha: 39cddab05c
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-07-01T22:42:13Z

**Commit 6:**
use shorter name for variable

* Original sha: c6494ccc9c
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-07-27T08:50:44Z

**Commit 7:**
prevent to sort terms on top_hits

* Original sha: b365ddc797
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-08-28T09:47:47Z

**Commit 8:**
Added the top_hits agg to line/pie/tile vis. Added support for function in prop_filter to allow more flexibility when filtering the list of values. The _term order is now the default ordering instead of _custom.

* Original sha: be44e905af
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-09-19T14:53:48Z

**Commit 9:**
simplified tests

* Original sha: 43767f0929
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-10-11T09:29:11Z

**Commit 10:**
changed as per code review

* Original sha: 53469d51a6
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-10-11T21:49:36Z

**Commit 11:**
Top Hit should support all field types on the metric vis

* Original sha: 20be95006f
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-10-17T21:18:31Z

**Commit 12:**
renamed metric to top hit

* Original sha: 20cbb12e67
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-10-17T21:28:30Z

**Commit 13:**
check if there is any result from buckets

* Original sha: b99d6c76dd
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-10-18T09:13:19Z

**Commit 14:**
support scripted fields

* Original sha: f79cdc1531
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-10-23T20:58:55Z

**Commit 15:**
Merge branch 'master' of github.com:elastic/kibana into latest-value

* Original sha: 82060ce91f
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-11-01T09:47:19Z

**Commit 16:**
Merge branch 'master' of github.com:elastic/kibana into latest-value

* Original sha: 257729868c
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-11-12T11:30:42Z

**Commit 17:**
- added logic for trying to get the field value from the source or from a
  doc_values field.
- added onlyAggregatable option for a field agg param to decide whether
  or not to retain only aggregatable fields.

* Original sha: 6a2bc0188c
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-11-13T12:34:10Z

**Commit 18:**
improved loops in getValuesAtPath method

* Original sha: 991864cdd3
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-11-16T10:10:28Z

**Commit 19:**
Merge branch 'master' of github.com:elastic/kibana into latest-value

* Original sha: bc1c90ac06
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-11-17T23:51:13Z

**Commit 20:**
do not try to get the doc_values field of an IP field

* Original sha: d697f11ca1
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-11-18T00:49:34Z

**Commit 21:**
improved field tests on the onlyAggregatable option

* Original sha: c2249e525f
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-11-18T01:08:34Z

**Commit 22:**
use indexPattern.formatField to get all the values at a given path

* Original sha: f4200e1bc0
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-11-19T23:02:18Z

**Commit 23:**
do not show the analyzed warning

* Original sha: 2dda53dfb7
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-11-19T23:30:59Z

**Commit 24:**
support to sort on scripted field

* Original sha: eda9610fa2
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-11-19T23:52:15Z

**Commit 25:**
corrected tests and rely on the field's formatter instead of trying to return a nice string

* Original sha: b465438176
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-11-20T14:40:43Z

**Commit 26:**
do not rely on init_default_field_props to set the doc_values property

* Original sha: 19e2104745
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-11-21T23:11:29Z

**Commit 27:**
added test for the doc_values property

* Original sha: fd323f3c2a
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-11-23T10:04:16Z

**Commit 28:**
Merge branch 'master' of github.com:elastic/kibana into latest-value

* Original sha: cfed72ec95
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-12-05T15:47:45Z

**Commit 29:**
- corrected source formatter
- add option to show/hide analyzed warning of a field

* Original sha: 30901820de
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-12-05T22:40:14Z

**Commit 30:**
set the default value of the showAnalyzedWarning in fieldparam

* Original sha: 0d924fc73d
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-12-08T16:29:46Z

**Commit 31:**
added description of the showAnalyzedWarning option

* Original sha: 7f85803f73
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-12-08T16:39:07Z

**Commit 32:**
use aggParam object to access the options of the field parameter

* Original sha: ef95cebf3f
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-12-09T10:54:51Z

**Commit 33:**
flattenHit: support object in array

* Original sha: b64f92a90e
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-12-10T23:36:45Z

**Commit 34:**
added aggregation option to support multivalued fields and top_hits aggregation with a size different than 1

* Original sha: e18a30d292
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-12-17T13:49:17Z

**Commit 35:**
disable concatenate aggregation type for visualizations other than table and metric

* Original sha: 2a2fa67d2a
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-12-17T15:00:08Z

**Commit 36:**
corrected display of arrays in the metric vis

* Original sha: eb70e84a56
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-12-17T15:27:36Z

**Commit 37:**
removed condition on the IP field type when retrieving doc values

* Original sha: d19ba5698c
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-12-17T21:30:47Z

**Commit 38:**
- do not set a default value for the aggregate with property since it is

* Original sha: 66d3b31778
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-12-21T23:04:12Z

**Commit 39:**
filter out incompatble aggregate options and auto select the option if there is only one

* Original sha: 1bd472755d
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-12-28T17:14:21Z

**Commit 40:**
simplified condition

* Original sha: 5babf9a512
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-12-28T23:49:46Z

**Commit 41:**
lint

* Original sha: 7372fced4b
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2016-12-30T19:43:08Z

**Commit 42:**
fix min/max/sum/average aggregate of elements having only null/undefined values

* Original sha: 0791be7914
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2017-01-03T10:40:16Z

**Commit 43:**
added top_hits metric to heatmap visualization

* Original sha: c967a2344d
* Authored by Stéphane Campinas <stephane.campinas@gmail.com> on 2017-01-03T11:16:56Z
This commit is contained in:
Stéphane Campinas 2017-01-11 12:55:09 +00:00 committed by Peter Pisljar
parent 6020d157ee
commit bc61824fd8
31 changed files with 1043 additions and 285 deletions

View file

@ -65,7 +65,7 @@ export default function HeatmapVisType(Private) {
title: 'Value',
min: 1,
max: 1,
aggFilter: ['count', 'avg', 'median', 'sum', 'min', 'max', 'cardinality', 'std_dev'],
aggFilter: ['count', 'avg', 'median', 'sum', 'min', 'max', 'cardinality', 'std_dev', 'top_hits'],
defaults: [
{ schema: 'metric', type: 'count' }
]

View file

@ -70,7 +70,7 @@ export default function HistogramVisType(Private) {
title: 'Dot Size',
min: 0,
max: 1,
aggFilter: ['count', 'avg', 'sum', 'min', 'max', 'cardinality']
aggFilter: ['count', 'avg', 'sum', 'min', 'max', 'cardinality', 'top_hits']
},
{
group: 'buckets',

View file

@ -44,7 +44,7 @@ export default function HistogramVisType(Private) {
title: 'Slice Size',
min: 1,
max: 1,
aggFilter: ['sum', 'count', 'cardinality'],
aggFilter: ['sum', 'count', 'cardinality', 'top_hits'],
defaults: [
{ schema: 'metric', type: 'count' }
]

View file

@ -89,7 +89,7 @@ export default function TileMapVisType(Private, getAppState, courier, config) {
title: 'Value',
min: 1,
max: 1,
aggFilter: ['count', 'avg', 'sum', 'min', 'max', 'cardinality'],
aggFilter: ['count', 'avg', 'sum', 'min', 'max', 'cardinality', 'top_hits'],
defaults: [
{ schema: 'metric', type: 'count' }
]

View file

@ -82,11 +82,18 @@ uiModules
// build collection of agg params html
type.params.forEach(function (param, i) {
let aggParam;
let fields;
// if field param exists, compute allowed fields
if (param.name === 'field') {
fields = $aggParamEditorsScope.indexedFields;
} else if (param.type === 'field') {
fields = $aggParamEditorsScope[`${param.name}Options`] = getIndexedFields(param);
}
if ($aggParamEditorsScope.indexedFields) {
const hasIndexedFields = $aggParamEditorsScope.indexedFields.length > 0;
if (fields) {
const hasIndexedFields = fields.length > 0;
const isExtraParam = i > 0;
if (!hasIndexedFields && isExtraParam) { // don't draw the rest of the options if their are no indexed fields.
if (!hasIndexedFields && isExtraParam) { // don't draw the rest of the options if there are no indexed fields.
return;
}
}
@ -133,6 +140,31 @@ uiModules
.append(param.editor)
.get(0);
}
function getIndexedFields(param) {
let fields = _.filter($scope.agg.vis.indexPattern.fields.raw, 'aggregatable');
const fieldTypes = param.filterFieldTypes;
if (fieldTypes) {
const filter = _.isFunction(fieldTypes) ? fieldTypes.bind(this, $scope.agg.vis) : fieldTypes;
fields = $filter('fieldType')(fields, filter);
fields = $filter('orderBy')(fields, ['type', 'name']);
}
return new IndexedArray({
/**
* @type {Array}
*/
index: ['name'],
/**
* [group description]
* @type {Array}
*/
initialSet: fields
});
}
}
};
});

View file

@ -1,100 +0,0 @@
import initDefaultFieldProps from '../init_default_field_props';
import expect from 'expect.js';
import _ from 'lodash';
let fields;
const testData = [
{
'name': 'ip',
'type': 'ip'
}, {
'name': '@timestamp',
'type': 'date'
}, {
'name': 'agent',
'type': 'string'
}, {
'name': 'bytes',
'type': 'number'
},
{
'name': 'geo.coordinates',
'type': 'geo_point'
}
];
describe('initDefaultFieldProps', function () {
beforeEach(function () {
fields = _.cloneDeep(testData);
});
it('should throw an error if no argument is passed or the argument is not an array', function () {
expect(initDefaultFieldProps).to.throwException(/requires an array argument/);
expect(initDefaultFieldProps).withArgs({}).to.throwException(/requires an array argument/);
});
it('should set the same defaults for everything but strings', function () {
const results = initDefaultFieldProps(fields);
_.forEach(results, function (field) {
if (field.type !== 'string') {
expect(field).to.have.property('indexed', true);
expect(field).to.have.property('analyzed', false);
expect(field).to.have.property('doc_values', true);
expect(field).to.have.property('scripted', false);
expect(field).to.have.property('count', 0);
}
});
});
it('should make string fields analyzed', function () {
const results = initDefaultFieldProps(fields);
_.forEach(results, function (field) {
if (field.type === 'string' && !_.contains(field.name, 'keyword')) {
expect(field).to.have.property('indexed', true);
expect(field).to.have.property('analyzed', true);
expect(field).to.have.property('doc_values', false);
expect(field).to.have.property('scripted', false);
expect(field).to.have.property('count', 0);
}
});
});
it('should create an extra raw non-analyzed field for strings', function () {
const results = initDefaultFieldProps(fields);
const rawField = _.find(results, function (field) {
return _.contains(field.name, 'keyword');
});
expect(rawField).to.have.property('indexed', true);
expect(rawField).to.have.property('analyzed', false);
expect(rawField).to.have.property('doc_values', true);
expect(rawField).to.have.property('scripted', false);
expect(rawField).to.have.property('count', 0);
});
it('should apply some overrides to metafields', function () {
const results = initDefaultFieldProps([{name: '_source'}, {name: '_timestamp'}]);
const expected = [
{
name: '_source',
indexed: false,
analyzed: false,
doc_values: false,
count: 0,
scripted: false,
type: '_source'
},
{
name: '_timestamp',
indexed: true,
analyzed: false,
doc_values: false,
count: 0,
scripted: false,
type: 'date'
}
];
expect(_.isEqual(expected, results)).to.be.ok();
});
});

View file

@ -1,50 +0,0 @@
import _ from 'lodash';
import mappingOverrides from './mapping_overrides';
module.exports = function initDefaultFieldProps(fields) {
if (fields === undefined || !_.isArray(fields)) {
throw new Error('requires an array argument');
}
const results = [];
_.forEach(fields, function (field) {
const newField = _.cloneDeep(field);
results.push(newField);
if (newField.type === 'string') {
_.defaults(newField, {
indexed: true,
analyzed: true,
doc_values: false,
scripted: false,
count: 0
});
results.push({
name: newField.name + '.keyword',
type: 'string',
indexed: true,
analyzed: false,
doc_values: true,
scripted: false,
count: 0
});
}
else {
_.defaults(newField, {
indexed: true,
analyzed: false,
doc_values: true,
scripted: false,
count: 0
});
}
if (mappingOverrides[newField.name]) {
_.assign(newField, mappingOverrides[newField.name]);
}
});
return results;
};

View file

@ -1,38 +0,0 @@
export default {
_source: {
type: '_source',
indexed: false,
analyzed: false,
doc_values: false
},
_index: {
type: 'string',
indexed: false,
analyzed: false,
doc_values: false
},
_type: {
type: 'string',
indexed: false,
analyzed: false,
doc_values: false
},
_id: {
type: 'string',
indexed: false,
analyzed: false,
doc_values: false
},
_timestamp: {
type: 'date',
indexed: true,
analyzed: false,
doc_values: false
},
_score: {
type: 'number',
indexed: false,
analyzed: false,
doc_values: false
}
};

View file

@ -20,15 +20,8 @@ describe('metric vis', function () {
it('should set the metric label and value', function () {
$scope.processTableGroups({
tables: [{
columns: [{title: 'Count'}],
rows: [[4301021]],
aggConfig: function () {
return {
fieldFormatter: function () {
return formatter;
}
};
}
columns: [{ title: 'Count' }],
rows: [[ { toString: () => formatter(4301021) } ]]
}]
});
@ -44,14 +37,7 @@ describe('metric vis', function () {
{title: '1st percentile of bytes'},
{title: '99th percentile of bytes'}
],
rows: [[182, 445842.4634666484]],
aggConfig: function () {
return {
fieldFormatter: function () {
return formatter;
}
};
}
rows: [[ { toString: () => formatter(182) }, { toString: () => formatter(445842.4634666484) } ]]
}]
});

View file

@ -17,14 +17,11 @@ module.controller('KbnMetricVisController', function ($scope, $element, Private)
$scope.processTableGroups = function (tableGroups) {
tableGroups.tables.forEach(function (table) {
table.columns.forEach(function (column, i) {
const fieldFormatter = table.aggConfig(column).fieldFormatter();
let value = table.rows[0][i];
value = isInvalid(value) ? '?' : fieldFormatter(value);
const value = table.rows[0][i];
metrics.push({
label: column.title,
value: value
value: value.toString('html')
});
});
});
@ -32,8 +29,12 @@ module.controller('KbnMetricVisController', function ($scope, $element, Private)
$scope.$watch('esResponse', function (resp) {
if (resp) {
const options = {
asAggConfigResults: true
};
metrics.length = 0;
$scope.processTableGroups(tabifyAggResponse($scope.vis, resp));
$scope.processTableGroups(tabifyAggResponse($scope.vis, resp, options));
$element.trigger('renderComplete');
}
});

View file

@ -5,7 +5,7 @@ function stubbedLogstashFields() {
// | | |aggregatable
// | | | |searchable
// name type | | | | |metadata
['bytes', 'number', true, true, true, true, { count: 10 } ],
['bytes', 'number', true, true, true, true, { count: 10, docValues: true } ],
['ssl', 'boolean', true, true, true, true, { count: 20 } ],
['@timestamp', 'date', true, true, true, true, { count: 30 } ],
['time', 'date', true, true, true, true, { count: 30 } ],
@ -20,6 +20,7 @@ function stubbedLogstashFields() {
['geo.coordinates', 'geo_point', true, true, true, true ],
['extension', 'string', true, true, true, true ],
['machine.os', 'string', true, true, true, true ],
['machine.os.raw', 'string', true, false, true, true, { docValues: true } ],
['geo.src', 'string', true, true, true, true ],
['_id', 'string', false, false, true, true ],
['_type', 'string', false, false, true, true ],
@ -41,6 +42,7 @@ function stubbedLogstashFields() {
] = row;
const {
docValues = false,
count = 0,
script,
lang = script ? 'expression' : undefined,
@ -50,6 +52,7 @@ function stubbedLogstashFields() {
return {
name,
type,
doc_values: docValues,
indexed,
analyzed,
aggregatable,

View file

@ -8,6 +8,7 @@ import getComputedFields from 'ui/index_patterns/_get_computed_fields';
import RegistryFieldFormatsProvider from 'ui/registry/field_formats';
import IndexPatternsFlattenHitProvider from 'ui/index_patterns/_flatten_hit';
import IndexPatternsFieldProvider from 'ui/index_patterns/_field';
export default function (Private) {
let fieldFormats = Private(RegistryFieldFormatsProvider);
let flattenHit = Private(IndexPatternsFlattenHitProvider);

View file

@ -1,13 +0,0 @@
describe('Terms Agg', function () {
describe('order agg editor UI', function () {
it('defaults to the first metric agg');
it('adds "custom metric" option');
it('lists all metric agg responses');
it('lists individual values of a multi-value metric');
it('selects "custom metric" if there are no metric aggs');
it('is emptied if the selected metric is removed');
it('displays a metric editor if "custom metric" is selected');
it('saves the "custom metric" to state and refreshes from it');
it('invalidates the form if the metric agg form is not complete');
});
});

View file

@ -0,0 +1,156 @@
import expect from 'expect.js';
import ngMock from 'ng_mock';
import AggTypesIndexProvider from 'ui/agg_types/index';
describe('Terms Agg', function () {
describe('order agg editor UI', function () {
let $rootScope;
function init({ responseValueAggs = [] }) {
ngMock.module('kibana');
ngMock.inject(function (Private, $controller, _$rootScope_) {
const terms = Private(AggTypesIndexProvider).byName.terms;
const orderAggController = terms.params.byName.orderAgg.controller;
$rootScope = _$rootScope_;
$rootScope.agg = {
id: 'test',
params: {},
type: terms,
vis: {
aggs: []
}
};
$rootScope.responseValueAggs = responseValueAggs;
$controller(orderAggController, { $scope: $rootScope });
$rootScope.$digest();
});
}
it('defaults to the first metric agg', function () {
init({
responseValueAggs: [
{
id: 'agg1',
type: {
name: 'count'
}
},
{
id: 'agg2',
type: {
name: 'count'
}
}
]
});
expect($rootScope.agg.params.orderBy).to.be('agg1');
});
it('defaults to the first metric agg that is compatible with the terms bucket', function () {
init({
responseValueAggs: [
{
id: 'agg1',
type: {
name: 'top_hits'
}
},
{
id: 'agg2',
type: {
name: 'percentiles'
}
},
{
id: 'agg3',
type: {
name: 'median'
}
},
{
id: 'agg4',
type: {
name: 'std_dev'
}
},
{
id: 'agg5',
type: {
name: 'count'
}
}
]
});
expect($rootScope.agg.params.orderBy).to.be('agg5');
});
it('defaults to the _term metric if no agg is compatible', function () {
init({
responseValueAggs: [
{
id: 'agg1',
type: {
name: 'top_hits'
}
}
]
});
expect($rootScope.agg.params.orderBy).to.be('_term');
});
it('selects _term if there are no metric aggs', function () {
init({});
expect($rootScope.agg.params.orderBy).to.be('_term');
});
it('selects _term if the selected metric becomes incompatible', function () {
init({
responseValueAggs: [
{
id: 'agg1',
type: {
name: 'count'
}
}
]
});
expect($rootScope.agg.params.orderBy).to.be('agg1');
$rootScope.responseValueAggs = [
{
id: 'agg1',
type: {
name: 'top_hits'
}
}
];
$rootScope.$digest();
expect($rootScope.agg.params.orderBy).to.be('_term');
});
it('selects _term if the selected metric is removed', function () {
init({
responseValueAggs: [
{
id: 'agg1',
type: {
name: 'count'
}
}
]
});
expect($rootScope.agg.params.orderBy).to.be('agg1');
$rootScope.responseValueAggs = [];
$rootScope.$digest();
expect($rootScope.agg.params.orderBy).to.be('_term');
});
it('adds "custom metric" option');
it('lists all metric agg responses');
it('lists individual values of a multi-value metric');
it('displays a metric editor if "custom metric" is selected');
it('saves the "custom metric" to state and refreshes from it');
it('invalidates the form if the metric agg form is not complete');
});
});

View file

@ -0,0 +1,342 @@
import _ from 'lodash';
import expect from 'expect.js';
import ngMock from 'ng_mock';
import TopHitProvider from 'ui/agg_types/metrics/top_hit';
import VisProvider from 'ui/vis';
import StubbedIndexPattern from 'fixtures/stubbed_logstash_index_pattern';
describe('Top hit metric', function () {
let aggDsl;
let topHitMetric;
let aggConfig;
function init({ field, sortOrder = 'desc', aggregate = 'concat', size = 1 }) {
ngMock.module('kibana');
ngMock.inject(function (Private) {
const Vis = Private(VisProvider);
const indexPattern = Private(StubbedIndexPattern);
topHitMetric = Private(TopHitProvider);
const params = {};
if (field) {
params.field = field;
}
params.sortOrder = {
val: sortOrder
};
params.aggregate = {
val: aggregate
};
params.size = size;
const vis = new Vis(indexPattern, {
title: 'New Visualization',
type: 'metric',
params: {
fontSize: 60,
handleNoResults: true
},
aggs: [
{
id: '1',
type: 'top_hits',
schema: 'metric',
params
}
],
listeners: {}
});
// Grab the aggConfig off the vis (we don't actually use the vis for anything else)
aggConfig = vis.aggs[0];
aggDsl = aggConfig.toDsl();
});
}
it('should return a label prefixed with Last if sorting in descending order', function () {
init({ field: 'bytes' });
expect(topHitMetric.makeLabel(aggConfig)).to.eql('Last bytes');
});
it('should return a label prefixed with First if sorting in ascending order', function () {
init({
field: 'bytes',
sortOrder: 'asc'
});
expect(topHitMetric.makeLabel(aggConfig)).to.eql('First bytes');
});
it('should request the _source field', function () {
init({ field: '_source' });
expect(aggDsl.top_hits._source).to.be(true);
expect(aggDsl.top_hits.docvalue_fields).to.be(undefined);
});
it('should request both for the source and doc_values fields', function () {
init({ field: 'bytes' });
expect(aggDsl.top_hits._source).to.be('bytes');
expect(aggDsl.top_hits.docvalue_fields).to.eql([ 'bytes' ]);
});
it('should only request for the source if the field does not have the doc_values property', function () {
init({ field: 'ssl' });
expect(aggDsl.top_hits._source).to.be('ssl');
expect(aggDsl.top_hits.docvalue_fields).to.be(undefined);
});
describe('try to get the value from the top hit', function () {
it('should return null if there is no hit', function () {
const bucket = {
'1': {
hits: {
hits: []
}
}
};
init({ field: '@tags' });
expect(topHitMetric.getValue(aggConfig, bucket)).to.be(null);
});
it('should return undefined if the field does not appear in the source', function () {
const bucket = {
'1': {
hits: {
hits: [
{
_source: {
bytes: 123
}
}
]
}
}
};
init({ field: '@tags' });
expect(topHitMetric.getValue(aggConfig, bucket)).to.be(undefined);
});
it('should return the field value from the top hit', function () {
const bucket = {
'1': {
hits: {
hits: [
{
_source: {
'@tags': 'aaa'
}
}
]
}
}
};
init({ field: '@tags' });
expect(topHitMetric.getValue(aggConfig, bucket)).to.be('aaa');
});
it('should return the object if the field value is an object', function () {
const bucket = {
'1': {
hits: {
hits: [
{
_source: {
'@tags': {
label: 'aaa'
}
}
}
]
}
}
};
init({ field: '@tags' });
expect(topHitMetric.getValue(aggConfig, bucket)).to.eql({ label: 'aaa' });
});
it('should return an array if the field has more than one values', function () {
const bucket = {
'1': {
hits: {
hits: [
{
_source: {
'@tags': [ 'aaa', 'bbb' ]
}
}
]
}
}
};
init({ field: '@tags' });
expect(topHitMetric.getValue(aggConfig, bucket)).to.eql([ 'aaa', 'bbb' ]);
});
it('should get the value from the doc_values field if the source does not have that field', function () {
const bucket = {
'1': {
hits: {
hits: [
{
_source: {
'machine.os': 'linux'
},
fields: {
'machine.os.raw': [ 'linux' ]
}
}
]
}
}
};
init({ field: 'machine.os.raw' });
expect(topHitMetric.getValue(aggConfig, bucket)).to.be('linux');
});
it('should return undefined if the field is not in the source nor in the doc_values field', function () {
const bucket = {
'1': {
hits: {
hits: [
{
_source: {
bytes: 12345
},
fields: {
bytes: 12345
}
}
]
}
}
};
init({ field: 'machine.os.raw' });
expect(topHitMetric.getValue(aggConfig, bucket)).to.be(undefined);
});
describe('Multivalued field and first/last X docs', function () {
it('should return a label prefixed with Last X docs if sorting in descending order', function () {
init({
field: 'bytes',
size: 2
});
expect(topHitMetric.makeLabel(aggConfig)).to.eql('Last 2 bytes');
});
it('should return a label prefixed with First X docs if sorting in ascending order', function () {
init({
field: 'bytes',
size: 2,
sortOrder: 'asc'
});
expect(topHitMetric.makeLabel(aggConfig)).to.eql('First 2 bytes');
});
[
{
description: 'concat values with a comma',
type: 'concat',
data: [ 1, 2, 3 ],
result: [ 1, 2, 3 ]
},
{
description: 'sum up the values',
type: 'sum',
data: [ 1, 2, 3 ],
result: 6
},
{
description: 'take the minimum value',
type: 'min',
data: [ 1, 2, 3 ],
result: 1
},
{
description: 'take the maximum value',
type: 'max',
data: [ 1, 2, 3 ],
result: 3
},
{
description: 'take the average value',
type: 'average',
data: [ 1, 2, 3 ],
result: 2
},
{
description: 'support null/undefined',
type: 'min',
data: [ undefined, null ],
result: null
},
{
description: 'support null/undefined',
type: 'max',
data: [ undefined, null ],
result: null
},
{
description: 'support null/undefined',
type: 'sum',
data: [ undefined, null ],
result: null
},
{
description: 'support null/undefined',
type: 'average',
data: [ undefined, null ],
result: null
}
]
.forEach(agg => {
it(`should return the result of the ${agg.type} aggregation over the last doc - ${agg.description}`, function () {
const bucket = {
'1': {
hits: {
hits: [
{
_source: {
bytes: agg.data
}
}
]
}
}
};
init({ field: 'bytes', aggregate: agg.type });
expect(topHitMetric.getValue(aggConfig, bucket)).to.eql(agg.result);
});
it(`should return the result of the ${agg.type} aggregation over the last X docs - ${agg.description}`, function () {
const bucket = {
'1': {
hits: {
hits: [
{
_source: {
bytes: _.dropRight(agg.data, 1)
}
},
{
_source: {
bytes: _.last(agg.data)
}
}
]
}
}
};
init({ field: 'bytes', aggregate: agg.type });
expect(topHitMetric.getValue(aggConfig, bucket)).to.eql(agg.result);
});
});
});
});
});

View file

@ -1,18 +1,22 @@
import _ from 'lodash';
import expect from 'expect.js';
import { reject } from 'lodash';
import ngMock from 'ng_mock';
import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern';
import AggTypesParamTypesBaseProvider from 'ui/agg_types/param_types/base';
import AggTypesParamTypesFieldProvider from 'ui/agg_types/param_types/field';
describe('Field', function () {
let BaseAggParam;
let FieldAggParam;
let indexPattern;
beforeEach(ngMock.module('kibana'));
// fetch out deps
beforeEach(ngMock.inject(function (Private) {
BaseAggParam = Private(AggTypesParamTypesBaseProvider);
FieldAggParam = Private(AggTypesParamTypesFieldProvider);
indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider);
}));
describe('constructor', function () {
@ -24,4 +28,34 @@ describe('Field', function () {
expect(aggParam).to.be.a(BaseAggParam);
});
});
describe('getFieldOptions', function () {
it('should return only aggregatable fields by default', function () {
const aggParam = new FieldAggParam({
name: 'field'
});
const fields = aggParam.getFieldOptions({
getIndexPattern: () => indexPattern
});
expect(fields).to.not.have.length(0);
for (const field of fields) {
expect(field.aggregatable).to.be(true);
}
});
it('should return all fields if onlyAggregatable is false', function () {
const aggParam = new FieldAggParam({
name: 'field'
});
aggParam.onlyAggregatable = false;
const fields = aggParam.getFieldOptions({
getIndexPattern: () => indexPattern
});
const nonAggregatableFields = reject(fields, 'aggregatable');
expect(nonAggregatableFields).to.not.be.empty();
});
});
});

View file

@ -16,12 +16,13 @@ export default function TermsAggDefinition(Private) {
let createFilter = Private(AggTypesBucketsCreateFilterTermsProvider);
const routeBasedNotifier = Private(routeBasedNotifierProvider);
const aggFilter = ['!top_hits', '!percentiles', '!median', '!std_dev'];
let orderAggSchema = (new Schemas([
{
group: 'none',
name: 'orderAgg',
title: 'Order Agg',
aggFilter: ['!percentiles', '!median', '!std_dev']
aggFilter: aggFilter
}
])).all[0];
@ -94,9 +95,15 @@ export default function TermsAggDefinition(Private) {
$scope.$watch('responseValueAggs', updateOrderAgg);
$scope.$watch('agg.params.orderBy', updateOrderAgg);
// Returns true if the agg is not compatible with the terms bucket
$scope.rejectAgg = function (agg) {
// aggFilter elements all starts with a '!'
// so the index of agg.type.name in a filter is 1 if it is included
return Boolean(aggFilter.find((filter) => filter.indexOf(agg.type.name) === 1));
};
function updateOrderAgg() {
let agg = $scope.agg;
let aggs = agg.vis.aggs;
let params = agg.params;
let orderBy = params.orderBy;
let paramDef = agg.type.params.byName.orderAgg;
@ -105,7 +112,11 @@ export default function TermsAggDefinition(Private) {
if (!orderBy && prevOrderBy === INIT) {
// abort until we get the responseValueAggs
if (!$scope.responseValueAggs) return;
params.orderBy = (_.first($scope.responseValueAggs) || { id: 'custom' }).id;
let respAgg = _($scope.responseValueAggs).filter((agg) => !$scope.rejectAgg(agg)).first();
if (!respAgg) {
respAgg = { id: '_term' };
}
params.orderBy = respAgg.id;
return;
}
@ -115,15 +126,10 @@ export default function TermsAggDefinition(Private) {
// we aren't creating a custom aggConfig
if (!orderBy || orderBy !== 'custom') {
params.orderAgg = null;
if (orderBy === '_term') {
params.orderBy = '_term';
return;
}
// ensure that orderBy is set to a valid agg
if (!_.find($scope.responseValueAggs, { id: orderBy })) {
params.orderBy = null;
const respAgg = _($scope.responseValueAggs).filter((agg) => !$scope.rejectAgg(agg)).find({ id: orderBy });
if (!respAgg) {
params.orderBy = '_term';
}
return;
}

View file

@ -3,7 +3,7 @@
Field
</label>
<span class="pull-right text-warning hintbox-label" ng-show="agg.params.field.analyzed"
<span class="pull-right text-warning hintbox-label" ng-show="aggParam.showAnalyzedWarning && agg.params.field.analyzed"
ng-click="showAnalyzedFieldWarning = !showAnalyzedFieldWarning">
<i class="fa fa-warning"></i> Analyzed Field
</span>

View file

@ -9,6 +9,7 @@
<option
ng-repeat="respAgg in responseValueAggs track by respAgg.id"
value="{{respAgg.id}}"
ng-disabled="rejectAgg(respAgg)"
ng-selected="agg.params.orderBy === respAgg.id">
metric: {{safeMakeLabel(respAgg)}}
</option>
@ -27,4 +28,4 @@
group-name="'metrics'">
</vis-editor-agg-params>
</div>
</div>
</div>

View file

@ -0,0 +1,37 @@
<div ng-controller="aggParam.controller" class="vis-editor-agg-form-row">
<div class="form-group">
<label>
Aggregate With
<kbn-info
info="Choose a strategy for combining multiple hits or a multi-valued field into a single metric."
placement="right">
</kbn-info>
</label>
<select
required
name="aggregate"
ng-model="agg.params.aggregate"
ng-options="opt as opt.display for opt in options| orderBy: 'display' track by opt.val"
class="form-control"
></select>
</div>
<div class="form-group">
<label>
Size
<kbn-info
info="Request top-K hits. Multiple hits will be combined via 'aggregate with'."
placement="right">
</kbn-info>
</label>
<input
required
name="size"
ng-model="agg.params.size"
class="form-control"
type="number"
min="1"
></input>
</div>
</div>

View file

@ -0,0 +1,29 @@
<div class="form-group">
<label for="sort">
Sort On
</label>
<select
class="form-control"
name="sortField"
required
ng-model="agg.params.sortField"
ng-show="sortFieldOptions.length"
auto-select-if-only-one="sortFieldOptions"
ng-options="field as field.displayName group by field.type for field in sortFieldOptions">
</select>
</div>
<div class="form-group">
<label>
Order
</label>
<select
name="sortOrder"
ng-model="agg.params.sortOrder"
required
ng-options="opt as opt.display for opt in aggParam.options track by opt.val"
class="form-control">
</select>
</div>

View file

@ -6,6 +6,7 @@ import AggTypesMetricsSumProvider from 'ui/agg_types/metrics/sum';
import AggTypesMetricsMedianProvider from 'ui/agg_types/metrics/median';
import AggTypesMetricsMinProvider from 'ui/agg_types/metrics/min';
import AggTypesMetricsMaxProvider from 'ui/agg_types/metrics/max';
import AggTypesMetricsTopHitProvider from 'ui/agg_types/metrics/top_hit';
import AggTypesMetricsStdDeviationProvider from 'ui/agg_types/metrics/std_deviation';
import AggTypesMetricsCardinalityProvider from 'ui/agg_types/metrics/cardinality';
import AggTypesMetricsPercentilesProvider from 'ui/agg_types/metrics/percentiles';
@ -32,7 +33,8 @@ export default function AggTypeService(Private) {
Private(AggTypesMetricsStdDeviationProvider),
Private(AggTypesMetricsCardinalityProvider),
Private(AggTypesMetricsPercentilesProvider),
Private(AggTypesMetricsPercentileRanksProvider)
Private(AggTypesMetricsPercentileRanksProvider),
Private(AggTypesMetricsTopHitProvider)
],
buckets: [
Private(AggTypesBucketsDateHistogramProvider),

View file

@ -0,0 +1,203 @@
import _ from 'lodash';
import MetricAggTypeProvider from 'ui/agg_types/metrics/metric_agg_type';
import RegistryFieldFormatsProvider from 'ui/registry/field_formats';
import topSortEditor from 'ui/agg_types/controls/top_sort.html';
import aggregateAndSizeEditor from 'ui/agg_types/controls/top_aggregate_and_size.html';
export default function AggTypeMetricTopProvider(Private) {
const MetricAggType = Private(MetricAggTypeProvider);
const fieldFormats = Private(RegistryFieldFormatsProvider);
const isNumber = function (type) {
return type === 'number';
};
return new MetricAggType({
name: 'top_hits',
title: 'Top Hit',
makeLabel: function (aggConfig) {
let prefix = aggConfig.params.sortOrder.val === 'desc' ? 'Last' : 'First';
if (aggConfig.params.size !== 1) {
prefix += ` ${aggConfig.params.size}`;
}
return `${prefix} ${aggConfig.params.field.displayName}`;
},
params: [
{
name: 'field',
onlyAggregatable: false,
showAnalyzedWarning: false,
filterFieldTypes: function (vis, value) {
if (vis.type.name === 'table' || vis.type.name === 'metric') {
return true;
}
return value === 'number';
},
write(agg, output) {
const field = agg.params.field;
output.params = {};
if (field.scripted) {
output.params.script_fields = {
[ field.name ]: {
script: {
inline: field.script,
lang: field.lang
}
}
};
} else {
if (field.doc_values) {
output.params.docvalue_fields = [ field.name ];
}
output.params._source = field.name === '_source' ? true : field.name;
}
}
},
{
name: 'aggregate',
type: 'optioned',
editor: aggregateAndSizeEditor,
options: [
{
display: 'Min',
isCompatibleType: isNumber,
isCompatibleVis: _.constant(true),
disabled: true,
val: 'min'
},
{
display: 'Max',
isCompatibleType: isNumber,
isCompatibleVis: _.constant(true),
disabled: true,
val: 'max'
},
{
display: 'Sum',
isCompatibleType: isNumber,
isCompatibleVis: _.constant(true),
disabled: true,
val: 'sum'
},
{
display: 'Average',
isCompatibleType: isNumber,
isCompatibleVis: _.constant(true),
disabled: true,
val: 'average'
},
{
display: 'Concatenate',
isCompatibleType: _.constant(true),
isCompatibleVis: function (name) {
return name === 'metric' || name === 'table';
},
disabled: true,
val: 'concat'
}
],
controller: function ($scope) {
$scope.options = [];
$scope.$watchGroup([ 'agg.vis.type.name', 'agg.params.field.type' ], function ([ visName, fieldType ]) {
if (fieldType && visName) {
$scope.options = _.filter($scope.aggParam.options, option => {
return option.isCompatibleVis(visName) && option.isCompatibleType(fieldType);
});
if ($scope.options.length === 1) {
$scope.agg.params.aggregate = $scope.options[0];
}
}
});
},
write: _.noop
},
{
name: 'size',
editor: null, // size setting is done together with the aggregation setting
default: 1
},
{
name: 'sortField',
type: 'field',
editor: null,
filterFieldTypes: [ 'number', 'date', 'ip', 'string' ],
default: function (agg) {
return agg.vis.indexPattern.timeFieldName;
},
write: _.noop // prevent default write, it is handled below
},
{
name: 'sortOrder',
type: 'optioned',
default: 'desc',
editor: topSortEditor,
options: [
{ display: 'Descending', val: 'desc' },
{ display: 'Ascending', val: 'asc' }
],
write(agg, output) {
const sortField = agg.params.sortField;
const sortOrder = agg.params.sortOrder;
if (sortField.scripted) {
output.params.sort = [
{
_script: {
script: {
inline: sortField.script,
lang: sortField.lang
},
type: sortField.type,
order: sortOrder.val
}
}
];
} else {
output.params.sort = [
{
[ sortField.name ]: {
order: sortOrder.val
}
}
];
}
}
}
],
getValue(agg, bucket) {
const hits = _.get(bucket, `${agg.id}.hits.hits`);
if (!hits || !hits.length) {
return null;
}
const path = agg.params.field.name;
let values = _(hits).map(hit => {
return path === '_source' ? hit._source : agg.vis.indexPattern.flattenHit(hit, true)[path];
})
.flatten()
.value();
if (values.length === 1) {
values = values[0];
}
if (_.isArray(values)) {
if (!_.compact(values).length) {
return null;
}
switch (agg.params.aggregate.val) {
case 'max':
return _.max(values);
case 'min':
return _.min(values);
case 'sum':
return _.sum(values);
case 'average':
return _.sum(values) / values.length;
}
}
return values;
}
});
}

View file

@ -7,7 +7,7 @@ import IndexedArray from 'ui/indexed_array';
import Notifier from 'ui/notify/notifier';
export default function FieldAggParamFactory(Private, $filter) {
let BaseAggParam = Private(AggTypesParamTypesBaseProvider);
const BaseAggParam = Private(AggTypesParamTypesBaseProvider);
const notifier = new Notifier();
_.class(FieldAggParam).inherits(BaseAggParam);
@ -18,6 +18,10 @@ export default function FieldAggParamFactory(Private, $filter) {
FieldAggParam.prototype.editor = editorHtml;
FieldAggParam.prototype.scriptable = true;
FieldAggParam.prototype.filterFieldTypes = '*';
// retain only the fields with the aggregatable property if the onlyAggregatable option is true
FieldAggParam.prototype.onlyAggregatable = true;
// show a warning about the field being analyzed
FieldAggParam.prototype.showAnalyzedWarning = true;
/**
* Called to serialize values for saving an aggConfig object
@ -36,14 +40,20 @@ export default function FieldAggParamFactory(Private, $filter) {
const indexPattern = aggConfig.getIndexPattern();
let fields = indexPattern.fields.raw;
fields = fields.filter(f => f.aggregatable);
if (this.onlyAggregatable) {
fields = fields.filter(f => f.aggregatable);
}
if (!this.scriptable) {
fields = fields.filter(field => !field.scripted);
}
if (this.filterFieldTypes) {
fields = $filter('fieldType')(fields, this.filterFieldTypes);
let filters = this.filterFieldTypes;
if (_.isFunction(this.filterFieldTypes)) {
filters = this.filterFieldTypes.bind(this, aggConfig.vis);
}
fields = $filter('fieldType')(fields, filters);
fields = $filter('orderBy')(fields, ['type', 'name']);
}
@ -88,7 +98,7 @@ export default function FieldAggParamFactory(Private, $filter) {
* @return {undefined}
*/
FieldAggParam.prototype.write = function (aggConfig, output) {
let field = aggConfig.getField();
const field = aggConfig.getField();
if (!field) {
throw new TypeError('"field" is a required parameter');
@ -105,4 +115,4 @@ export default function FieldAggParamFactory(Private, $filter) {
};
return FieldAggParam;
};
}

View file

@ -0,0 +1,58 @@
import expect from 'expect.js';
import propFilter from 'ui/filters/_prop_filter';
describe('prop filter', function () {
let nameFilter;
beforeEach(function () {
nameFilter = propFilter('name');
});
function getObjects(...names) {
const count = new Map();
const objects = [];
for (const name of names) {
if (!count.has(name)) {
count.set(name, 1);
}
objects.push({
name: name,
title: `${name} ${count.get(name)}`
});
count.set(name, count.get(name) + 1);
}
return objects;
}
it('should keep only the tables', function () {
const objects = getObjects('table', 'table', 'pie');
expect(nameFilter(objects, 'table')).to.eql(getObjects('table', 'table'));
});
it('should support comma-separated values', function () {
const objects = getObjects('table', 'line', 'pie');
expect(nameFilter(objects, 'table,line')).to.eql(getObjects('table', 'line'));
});
it('should support an array of values', function () {
const objects = getObjects('table', 'line', 'pie');
expect(nameFilter(objects, [ 'table', 'line' ])).to.eql(getObjects('table', 'line'));
});
it('should return all objects', function () {
const objects = getObjects('table', 'line', 'pie');
expect(nameFilter(objects, '*')).to.eql(objects);
});
it('should allow negation', function () {
const objects = getObjects('table', 'line', 'pie');
expect(nameFilter(objects, [ '!line' ])).to.eql(getObjects('table', 'pie'));
});
it('should support a function for specifying what should be kept', function () {
const objects = getObjects('table', 'line', 'pie');
const line = (value) => value === 'line';
expect(nameFilter(objects, line)).to.eql(getObjects('line'));
});
});

View file

@ -13,13 +13,18 @@ function propFilter(prop) {
* must contain
*
* @param {array} list - array of items to filter
* @param {array|string} filters - the values to match against the list. Can be
* an array, a single value as a string, or a comma
* -seperated list of items
* @param {function|array|string} filters - the values to match against the list
* - if a function, it is expected to take the field property as argument and returns true to keep it.
* - Can be also an array, a single value as a string, or a comma-seperated list of items
* @return {array} - the filtered list
*/
return function (list, filters) {
if (!filters) return filters;
if (_.isFunction(filters)) {
return list.filter((item) => filters(item[prop]));
}
if (!_.isArray(filters)) filters = filters.split(',');
if (_.contains(filters, '*')) return list;

View file

@ -4,18 +4,17 @@ import ngMock from 'ng_mock';
import IndexPatternsFlattenHitProvider from 'ui/index_patterns/_flatten_hit';
describe('IndexPattern#flattenHit()', function () {
let flattenHit;
let config;
let hit;
let flat;
beforeEach(ngMock.module('kibana'));
beforeEach(ngMock.inject(function (Private, $injector) {
let indexPattern = {
fields: {
byName: {
'tags.text': { type: 'string' },
'tags.label': { type: 'string' },
'message': { type: 'string' },
'geo.coordinates': { type: 'geo_point' },
'geo.dest': { type: 'string' },
@ -33,7 +32,12 @@ describe('IndexPattern#flattenHit()', function () {
}
};
flattenHit = Private(IndexPatternsFlattenHitProvider)(indexPattern).uncached;
const cachedFlatten = Private(IndexPatternsFlattenHitProvider)(indexPattern);
flattenHit = function (hit, deep = false) {
delete hit.$$_flattened;
return cachedFlatten(hit, deep);
};
config = $injector.get('config');
hit = {
@ -46,7 +50,10 @@ describe('IndexPattern#flattenHit()', function () {
},
bytes: 10039103,
'@timestamp': (new Date()).toString(),
tags: [{ text: 'foo' }, { text: 'bar' }],
tags: [
{ text: 'foo', label: [ 'FOO1', 'FOO2' ] },
{ text: 'bar', label: 'BAR' }
],
groups: ['loners'],
noMapping: true,
team: [
@ -61,11 +68,11 @@ describe('IndexPattern#flattenHit()', function () {
random: [0.12345]
}
};
flat = flattenHit(hit);
}));
it('flattens keys as far down as the mapping goes', function () {
const flat = flattenHit(hit);
expect(flat).to.have.property('geo.coordinates', hit._source.geo.coordinates);
expect(flat).to.not.have.property('geo.coordinates.lat');
expect(flat).to.not.have.property('geo.coordinates.lon');
@ -77,22 +84,42 @@ describe('IndexPattern#flattenHit()', function () {
});
it('flattens keys not in the mapping', function () {
const flat = flattenHit(hit);
expect(flat).to.have.property('noMapping', true);
expect(flat).to.have.property('groups');
expect(flat.groups).to.eql(['loners']);
});
it('flattens conflicting types in the mapping', function () {
const flat = flattenHit(hit);
expect(flat).to.not.have.property('user');
expect(flat).to.have.property('user.name', hit._source.user.name);
expect(flat).to.have.property('user.id', hit._source.user.id);
});
it('preserves objects in arrays', function () {
it('should preserve objects in arrays if deep argument is false', function () {
const flat = flattenHit(hit);
expect(flat).to.have.property('tags', hit._source.tags);
});
it('should expand objects in arrays if deep argument is true', function () {
const flat = flattenHit(hit, true);
expect(flat['tags.text']).to.be.eql([ 'foo', 'bar' ]);
});
it('should support arrays when expanding objects in arrays if deep argument is true', function () {
const flat = flattenHit(hit, true);
expect(flat['tags.label']).to.be.eql([ 'FOO1', 'FOO2', 'BAR' ]);
});
it('does not enter into nested fields', function () {
const flat = flattenHit(hit);
expect(flat).to.have.property('team', hit._source.team);
expect(flat).to.not.have.property('team.name');
expect(flat).to.not.have.property('team.role');
@ -101,24 +128,28 @@ describe('IndexPattern#flattenHit()', function () {
});
it('unwraps script fields', function () {
const flat = flattenHit(hit);
expect(flat).to.have.property('delta', 42);
});
it('assumes that all fields are "computed fields"', function () {
const flat = flattenHit(hit);
expect(flat).to.have.property('random', 0.12345);
});
it('ignores fields that start with an _ and are not in the metaFields', function () {
config.set('metaFields', ['_metaKey']);
hit.fields._notMetaKey = [100];
flat = flattenHit(hit);
const flat = flattenHit(hit);
expect(flat).to.not.have.property('_notMetaKey');
});
it('includes underscore-prefixed keys that are in the metaFields', function () {
config.set('metaFields', ['_metaKey']);
hit.fields._metaKey = [100];
flat = flattenHit(hit);
const flat = flattenHit(hit);
expect(flat).to.have.property('_metaKey', 100);
});
@ -126,7 +157,7 @@ describe('IndexPattern#flattenHit()', function () {
hit.fields._metaKey = [100];
config.set('metaFields', ['_metaKey']);
flat = flattenHit(hit);
let flat = flattenHit(hit);
expect(flat).to.have.property('_metaKey', 100);
config.set('metaFields', []);
@ -137,7 +168,7 @@ describe('IndexPattern#flattenHit()', function () {
it('handles fields that are not arrays, like _timestamp', function () {
hit.fields._metaKey = 20000;
config.set('metaFields', ['_metaKey']);
flat = flattenHit(hit);
const flat = flattenHit(hit);
expect(flat).to.have.property('_metaKey', 20000);
});
});

View file

@ -1,4 +1,5 @@
import _ from 'lodash';
// Takes a hit, merges it with any stored/scripted fields, and with the metaFields
// returns a flattened version
export default function FlattenHitProvider(config) {
@ -8,23 +9,38 @@ export default function FlattenHitProvider(config) {
metaFields = value;
});
function flattenHit(indexPattern, hit) {
let flat = {};
function flattenHit(indexPattern, hit, deep) {
const flat = {};
// recursively merge _source
let fields = indexPattern.fields.byName;
const fields = indexPattern.fields.byName;
(function flatten(obj, keyPrefix) {
keyPrefix = keyPrefix ? keyPrefix + '.' : '';
_.forOwn(obj, function (val, key) {
key = keyPrefix + key;
if (flat[key] !== void 0) return;
if (deep) {
const isNestedField = fields[key] && fields[key].type === 'nested';
const isArrayOfObjects = _.isArray(val) && _.isPlainObject(_.first(val));
if (isArrayOfObjects && !isNestedField) {
_.each(val, v => flatten(v, key));
return;
}
} else if (flat[key] !== void 0) {
return;
}
let hasValidMapping = (fields[key] && fields[key].type !== 'conflict');
let isValue = !_.isPlainObject(val);
const hasValidMapping = fields[key] && fields[key].type !== 'conflict';
const isValue = !_.isPlainObject(val);
if (hasValidMapping || isValue) {
flat[key] = val;
if (!flat[key]) {
flat[key] = val;
} else if (_.isArray(flat[key])) {
flat[key].push(val);
} else {
flat[key] = [ flat[key], val ];
}
return;
}
@ -48,13 +64,8 @@ export default function FlattenHitProvider(config) {
}
return function flattenHitWrapper(indexPattern) {
function cachedFlatten(hit) {
return hit.$$_flattened || (hit.$$_flattened = flattenHit(indexPattern, hit));
}
cachedFlatten.uncached = _.partial(flattenHit, indexPattern);
return cachedFlatten;
return function cachedFlatten(hit, deep = false) {
return hit.$$_flattened || (hit.$$_flattened = flattenHit(indexPattern, hit, deep));
};
};
};
}

View file

@ -27,6 +27,11 @@ describe('_source formatting', function () {
convertHtml = format.getConverterFor('html');
}));
it('should use the text content type if a field is not passed', function () {
const hit = _.first(hits);
expect(convertHtml(hit._source)).to.be(JSON.stringify(hit._source));
});
it('uses the _source, field, and hit to create a <dl>', function () {
let hit = _.first(hits);
let $dl = $(convertHtml(hit._source, indexPattern.fields.byName._source, hit));

View file

@ -18,7 +18,7 @@ export default function _SourceFormatProvider(Private, shortDotsFilter) {
Source.prototype._convert = {
text: angular.toJson,
html: function sourceToHtml(source, field, hit) {
if (!field) return this.getConverter('text')(source, field, hit);
if (!field) return this.getConverterFor('text')(source, field, hit);
let highlights = (hit && hit.highlight) || {};
let formatted = field.indexPattern.formatHit(hit);

View file

@ -152,7 +152,13 @@ export default function AggConfigFactory(Private, fieldTypeFilter) {
const fieldOptions = this.getFieldOptions();
if (fieldOptions) {
field = fieldOptions.byName[this.fieldName()] || null;
let prevField = fieldOptions.byName[this.fieldName()] || null;
let filters = fieldOptions.filterFieldTypes;
if (_.isFunction(fieldOptions.filterFieldTypes)) {
filters = fieldOptions.filterFieldTypes.bind(this, this.vis);
}
let fieldOpts = fieldTypeFilter(this.vis.indexPattern.fields, filters);
field = _.contains(fieldOpts, prevField) ? prevField : null;
}
return this.fillDefaults({ row: this.params.row, field: field });