From 91e866f14565097d11c348784850fc2b906b95f6 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Fri, 1 Dec 2017 14:27:22 +0100 Subject: [PATCH 1/8] Add multiquery_table table transform The current table transform renders only the first query. This PR adds a new transform to render all query results in a JOIN-ish semantic. * new table transform: Multi-Query table * columns is the union of all non-value fields * one value column per query is added * rows that share all the same label values are merged into one --- .../app/plugins/panel/table/transformers.ts | 120 +++++++++++++++++- 1 file changed, 119 insertions(+), 1 deletion(-) diff --git a/public/app/plugins/panel/table/transformers.ts b/public/app/plugins/panel/table/transformers.ts index 9a94f191646..c6c036161bd 100644 --- a/public/app/plugins/panel/table/transformers.ts +++ b/public/app/plugins/panel/table/transformers.ts @@ -145,12 +145,130 @@ transformers['table'] = { if (data[0].type !== 'table') { throw {message: 'Query result is not in table format, try using another transform.'}; } - model.columns = data[0].columns; model.rows = data[0].rows; } }; +transformers['multiquery_table'] = { + description: 'Multi-Query Table', + getColumns: function(data) { + // Track column indexes: name -> index + const columnNames = {}; + + // Union of all non-value columns + const columns = data.reduce((acc, d, i) => { + d.columns.forEach((col, j) => { + const { text } = col; + if (text !== 'Value') { + if (columnNames[text] === undefined) { + columnNames[text] = acc.length; + acc.push(col); + } + } + }); + return acc; + }, []); + + // Append one value column per data set + data.forEach((_, i) => { + // Value (A), Value (B),... + const text = `Value ${String.fromCharCode(65 + i)}`; + columnNames[text] = columns.length; + columns.push({ text }); + }); + + return columns; + }, + transform: function(data, panel, model) { + if (!data || data.length === 0) { + return; + } + + if (data[0].type !== 'table') { + throw {message: 'Query result is not in table format, try using another transform.'}; + } + + // Track column indexes: name -> index + const columnNames = {}; + const columnIndexes = []; + + // Union of all non-value columns + const columns = data.reduce((acc, d, i) => { + const indexes = []; + d.columns.forEach((col, j) => { + const { text } = col; + if (text !== 'Value') { + if (columnNames[text] === undefined) { + columnNames[text] = acc.length; + acc.push(col); + } + indexes[j] = columnNames[text]; + } + }); + columnIndexes.push(indexes); + return acc; + }, []); + const nonValueColumnCount = columns.length; + + // Append one value column per data set + data.forEach((_, i) => { + // Value (A), Value (B),... + const text = `Value ${String.fromCharCode(65 + i)}`; + columnNames[text] = columns.length; + columns.push({ text }); + columnIndexes[i].push(columnNames[text]); + }); + + model.columns = columns; + + // Adjust rows to new column indexes + let rows = data.reduce((acc, d, i) => { + const indexes = columnIndexes[i]; + d.rows.forEach((r, j) => { + const alteredRow = []; + indexes.forEach((to, from) => { + alteredRow[to] = r[from]; + }); + acc.push(alteredRow); + }); + return acc; + }, []); + + // Merge rows that have same columns + const mergedRows = {}; + rows = rows.reduce((acc, row, i) => { + if (!mergedRows[i]) { + const match = _.findIndex(rows, (other, j) => { + let same = true; + for (let index = 0; index < nonValueColumnCount; index++) { + if (row[index] !== other[index]) { + same = false; + break; + } + } + return same; + }, i + 1); + if (match > -1) { + const matchedRow = rows[match]; + // Merge values into current row + for (let index = nonValueColumnCount; index < columns.length; index++) { + if (row[index] === undefined && matchedRow[index] !== undefined) { + row[index] = matchedRow[index]; + break; + } + } + mergedRows[match] = matchedRow; + } + acc.push(row); + } + return acc; + }, []); + + model.rows = rows; + } +}; + transformers['json'] = { description: 'JSON Data', getColumns: function(data) { From 85eb2aaa805957e06c6846bba4b3060ddd6b1a87 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Fri, 1 Dec 2017 19:03:47 +0100 Subject: [PATCH 2/8] Added basic table transformer test --- .../panel/table/specs/transformers.jest.ts | 57 ++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/public/app/plugins/panel/table/specs/transformers.jest.ts b/public/app/plugins/panel/table/specs/transformers.jest.ts index 5f86266701e..f62078db488 100644 --- a/public/app/plugins/panel/table/specs/transformers.jest.ts +++ b/public/app/plugins/panel/table/specs/transformers.jest.ts @@ -1,6 +1,6 @@ import {transformers, transformDataToTable} from '../transformers'; -describe('when transforming time series table', () => { +describe('when transforming time series table.', () => { var table; describe('given 2 time series', () => { @@ -94,7 +94,60 @@ describe('when transforming time series table', () => { expect(table.columns[2].text).toBe('Min'); }); }); + }); + describe('table data sets', () => { + describe('Table', () => { + var panel = { + transform: 'table', + }; + var time = new Date().getTime(); + var rawData = [ + { + type: 'table', + columns: [ + { text: 'Time' }, + { text: 'Label Key 1' }, + { text: 'Value' }, + ], + rows: [ + [time, 'Label Value 1', 42], + ], + } + ]; + + describe('getColumns', function() { + it('should return data columns', function() { + var columns = transformers['table'].getColumns(rawData); + expect(columns[0].text).toBe('Time'); + expect(columns[1].text).toBe('Label Key 1'); + expect(columns[2].text).toBe('Value'); + }); + }); + + describe('transform', function() { + beforeEach(() => { + table = transformDataToTable(rawData, panel); + }); + + it ('should return 3 columns', () => { + expect(table.columns.length).toBe(3); + expect(table.columns[0].text).toBe('Time'); + expect(table.columns[1].text).toBe('Label Key 1'); + expect(table.columns[2].text).toBe('Value'); + }); + + it ('should return 1 row', () => { + expect(table.rows.length).toBe(1); + expect(table.rows[0][0]).toBe(time); + expect(table.rows[0][1]).toBe('Label Value 1'); + expect(table.rows[0][2]).toBe(42); + }); + }); + }); + }); + + describe('doc data sets', () => { describe('JSON Data', () => { var panel = { transform: 'json', @@ -148,7 +201,9 @@ describe('when transforming time series table', () => { }); }); }); + }); + describe('annotation data', () => { describe('Annnotations', () => { var panel = {transform: 'annotations'}; var rawData = { From b6867891f0160c55c83af96aac9a83be6769c1bd Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Sun, 3 Dec 2017 14:32:02 +0100 Subject: [PATCH 3/8] Tests for multi-query table transform --- .../panel/table/specs/transformers.jest.ts | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/public/app/plugins/panel/table/specs/transformers.jest.ts b/public/app/plugins/panel/table/specs/transformers.jest.ts index f62078db488..16ddfff95aa 100644 --- a/public/app/plugins/panel/table/specs/transformers.jest.ts +++ b/public/app/plugins/panel/table/specs/transformers.jest.ts @@ -145,6 +145,161 @@ describe('when transforming time series table.', () => { }); }); }); + + describe('Multi-Query Table', () => { + const transform = 'multiquery_table'; + var panel = { + transform, + }; + var time = new Date().getTime(); + var singleQueryData = [ + { + type: 'table', + columns: [ + { text: 'Time' }, + { text: 'Label Key 1' }, + { text: 'Value' }, + ], + rows: [ + [time, 'Label Value 1', 42], + ], + } + ]; + + var multipleQueriesDataSameLabels = [ + { + type: 'table', + columns: [ + { text: 'Time' }, + { text: 'Label Key 1' }, + { text: 'Label Key 2' }, + { text: 'Value' }, + ], + rows: [ + [time, 'Label Value 1', 'Label Value 2', 42], + ], + }, + { + type: 'table', + columns: [ + { text: 'Time' }, + { text: 'Label Key 1' }, + { text: 'Label Key 2' }, + { text: 'Value' }, + ], + rows: [ + [time, 'Label Value 1', 'Label Value 2', 13], + ], + } + ]; + + var multipleQueriesDataDifferentLabels = [ + { + type: 'table', + columns: [ + { text: 'Time' }, + { text: 'Label Key 1' }, + { text: 'Value' }, + ], + rows: [ + [time, 'Label Value 1', 42], + ], + }, + { + type: 'table', + columns: [ + { text: 'Time' }, + { text: 'Label Key 2' }, + { text: 'Value' }, + ], + rows: [ + [time, 'Label Value 2', 13], + ], + } + ]; + + describe('getColumns', function() { + it('should return data columns given a single query', function() { + var columns = transformers[transform].getColumns(singleQueryData); + expect(columns[0].text).toBe('Time'); + expect(columns[1].text).toBe('Label Key 1'); + expect(columns[2].text).toBe('Value A'); + }); + + it('should return the union of data columns given a multiple queries', function() { + var columns = transformers[transform].getColumns(multipleQueriesDataSameLabels); + expect(columns[0].text).toBe('Time'); + expect(columns[1].text).toBe('Label Key 1'); + expect(columns[2].text).toBe('Label Key 2'); + expect(columns[3].text).toBe('Value A'); + expect(columns[4].text).toBe('Value B'); + }); + + it('should return the union of data columns given a multiple queries with different labels', function() { + var columns = transformers[transform].getColumns(multipleQueriesDataDifferentLabels); + expect(columns[0].text).toBe('Time'); + expect(columns[1].text).toBe('Label Key 1'); + expect(columns[2].text).toBe('Label Key 2'); + expect(columns[3].text).toBe('Value A'); + expect(columns[4].text).toBe('Value B'); + }); + }); + + describe('transform', function() { + it ('should return 3 columns for single queries', () => { + table = transformDataToTable(singleQueryData, panel); + expect(table.columns.length).toBe(3); + expect(table.columns[0].text).toBe('Time'); + expect(table.columns[1].text).toBe('Label Key 1'); + expect(table.columns[2].text).toBe('Value A'); + }); + + it ('should return the union of columns for multiple queries', () => { + table = transformDataToTable(multipleQueriesDataSameLabels, panel); + expect(table.columns.length).toBe(5); + expect(table.columns[0].text).toBe('Time'); + expect(table.columns[1].text).toBe('Label Key 1'); + expect(table.columns[2].text).toBe('Label Key 2'); + expect(table.columns[3].text).toBe('Value A'); + expect(table.columns[4].text).toBe('Value B'); + }); + + it ('should return 1 row for a single query', () => { + table = transformDataToTable(singleQueryData, panel); + expect(table.rows.length).toBe(1); + expect(table.rows[0][0]).toBe(time); + expect(table.rows[0][1]).toBe('Label Value 1'); + expect(table.rows[0][2]).toBe(42); + }); + + it ('should return 1 row for a mulitple queries with same label values', () => { + table = transformDataToTable(multipleQueriesDataSameLabels, panel); + expect(table.rows.length).toBe(1); + expect(table.rows[0][0]).toBe(time); + expect(table.rows[0][1]).toBe('Label Value 1'); + expect(table.rows[0][2]).toBe('Label Value 2'); + expect(table.rows[0][3]).toBe(42); + expect(table.rows[0][4]).toBe(13); + }); + + it ('should return 2 rows for a mulitple queries with different label values', () => { + table = transformDataToTable(multipleQueriesDataDifferentLabels, panel); + expect(table.rows.length).toBe(2); + + expect(table.rows[0][0]).toBe(time); + expect(table.rows[0][1]).toBe('Label Value 1'); + expect(table.rows[0][2]).toBeUndefined(); + expect(table.rows[0][3]).toBe(42); + expect(table.rows[0][4]).toBeUndefined(); + + expect(table.rows[1][0]).toBe(time); + expect(table.rows[1][1]).toBeUndefined(); + expect(table.rows[1][2]).toBe('Label Value 2'); + expect(table.rows[1][3]).toBeUndefined(); + expect(table.rows[1][4]).toBe(13); + }); + }); + }); }); describe('doc data sets', () => { From 1dd90c8105b72d524aad706a754c344e1fcb4c9d Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Mon, 4 Dec 2017 17:55:00 +0100 Subject: [PATCH 4/8] Making the multi-query table transform the default table transform --- .../panel/table/specs/transformers.jest.ts | 64 +++++-------------- .../app/plugins/panel/table/transformers.ts | 28 ++------ 2 files changed, 23 insertions(+), 69 deletions(-) diff --git a/public/app/plugins/panel/table/specs/transformers.jest.ts b/public/app/plugins/panel/table/specs/transformers.jest.ts index 16ddfff95aa..87b18c00401 100644 --- a/public/app/plugins/panel/table/specs/transformers.jest.ts +++ b/public/app/plugins/panel/table/specs/transformers.jest.ts @@ -98,13 +98,15 @@ describe('when transforming time series table.', () => { describe('table data sets', () => { describe('Table', () => { + const transform = 'table'; var panel = { - transform: 'table', + transform, }; var time = new Date().getTime(); - var rawData = [ + + var nonTableData = [ { - type: 'table', + type: 'foo', columns: [ { text: 'Time' }, { text: 'Label Key 1' }, @@ -116,42 +118,6 @@ describe('when transforming time series table.', () => { } ]; - describe('getColumns', function() { - it('should return data columns', function() { - var columns = transformers['table'].getColumns(rawData); - expect(columns[0].text).toBe('Time'); - expect(columns[1].text).toBe('Label Key 1'); - expect(columns[2].text).toBe('Value'); - }); - }); - - describe('transform', function() { - beforeEach(() => { - table = transformDataToTable(rawData, panel); - }); - - it ('should return 3 columns', () => { - expect(table.columns.length).toBe(3); - expect(table.columns[0].text).toBe('Time'); - expect(table.columns[1].text).toBe('Label Key 1'); - expect(table.columns[2].text).toBe('Value'); - }); - - it ('should return 1 row', () => { - expect(table.rows.length).toBe(1); - expect(table.rows[0][0]).toBe(time); - expect(table.rows[0][1]).toBe('Label Value 1'); - expect(table.rows[0][2]).toBe(42); - }); - }); - }); - - describe('Multi-Query Table', () => { - const transform = 'multiquery_table'; - var panel = { - transform, - }; - var time = new Date().getTime(); var singleQueryData = [ { type: 'table', @@ -223,7 +189,7 @@ describe('when transforming time series table.', () => { var columns = transformers[transform].getColumns(singleQueryData); expect(columns[0].text).toBe('Time'); expect(columns[1].text).toBe('Label Key 1'); - expect(columns[2].text).toBe('Value A'); + expect(columns[2].text).toBe('Value #A'); }); it('should return the union of data columns given a multiple queries', function() { @@ -231,8 +197,8 @@ describe('when transforming time series table.', () => { expect(columns[0].text).toBe('Time'); expect(columns[1].text).toBe('Label Key 1'); expect(columns[2].text).toBe('Label Key 2'); - expect(columns[3].text).toBe('Value A'); - expect(columns[4].text).toBe('Value B'); + expect(columns[3].text).toBe('Value #A'); + expect(columns[4].text).toBe('Value #B'); }); it('should return the union of data columns given a multiple queries with different labels', function() { @@ -240,18 +206,22 @@ describe('when transforming time series table.', () => { expect(columns[0].text).toBe('Time'); expect(columns[1].text).toBe('Label Key 1'); expect(columns[2].text).toBe('Label Key 2'); - expect(columns[3].text).toBe('Value A'); - expect(columns[4].text).toBe('Value B'); + expect(columns[3].text).toBe('Value #A'); + expect(columns[4].text).toBe('Value #B'); }); }); describe('transform', function() { + it ('should throw an error with non-table data', () => { + expect(() => transformDataToTable(nonTableData, panel)).toThrow(); + }); + it ('should return 3 columns for single queries', () => { table = transformDataToTable(singleQueryData, panel); expect(table.columns.length).toBe(3); expect(table.columns[0].text).toBe('Time'); expect(table.columns[1].text).toBe('Label Key 1'); - expect(table.columns[2].text).toBe('Value A'); + expect(table.columns[2].text).toBe('Value #A'); }); it ('should return the union of columns for multiple queries', () => { @@ -260,8 +230,8 @@ describe('when transforming time series table.', () => { expect(table.columns[0].text).toBe('Time'); expect(table.columns[1].text).toBe('Label Key 1'); expect(table.columns[2].text).toBe('Label Key 2'); - expect(table.columns[3].text).toBe('Value A'); - expect(table.columns[4].text).toBe('Value B'); + expect(table.columns[3].text).toBe('Value #A'); + expect(table.columns[4].text).toBe('Value #B'); }); it ('should return 1 row for a single query', () => { diff --git a/public/app/plugins/panel/table/transformers.ts b/public/app/plugins/panel/table/transformers.ts index c6c036161bd..8a7a73493dd 100644 --- a/public/app/plugins/panel/table/transformers.ts +++ b/public/app/plugins/panel/table/transformers.ts @@ -135,24 +135,7 @@ transformers['table'] = { if (!data || data.length === 0) { return []; } - return data[0].columns; - }, - transform: function(data, panel, model) { - if (!data || data.length === 0) { - return; - } - if (data[0].type !== 'table') { - throw {message: 'Query result is not in table format, try using another transform.'}; - } - model.columns = data[0].columns; - model.rows = data[0].rows; - } -}; - -transformers['multiquery_table'] = { - description: 'Multi-Query Table', - getColumns: function(data) { // Track column indexes: name -> index const columnNames = {}; @@ -173,7 +156,7 @@ transformers['multiquery_table'] = { // Append one value column per data set data.forEach((_, i) => { // Value (A), Value (B),... - const text = `Value ${String.fromCharCode(65 + i)}`; + const text = `Value #${String.fromCharCode(65 + i)}`; columnNames[text] = columns.length; columns.push({ text }); }); @@ -185,8 +168,9 @@ transformers['multiquery_table'] = { return; } - if (data[0].type !== 'table') { - throw {message: 'Query result is not in table format, try using another transform.'}; + const noTableIndex = _.findIndex(data, d => d.type !== 'table'); + if (noTableIndex > -1) { + throw {message: `Result of query #${String.fromCharCode(65 + noTableIndex)} is not in table format, try using another transform.`}; } // Track column indexes: name -> index @@ -213,8 +197,8 @@ transformers['multiquery_table'] = { // Append one value column per data set data.forEach((_, i) => { - // Value (A), Value (B),... - const text = `Value ${String.fromCharCode(65 + i)}`; + // Value #A, Value #B,... + const text = `Value #${String.fromCharCode(65 + i)}`; columnNames[text] = columns.length; columns.push({ text }); columnIndexes[i].push(columnNames[text]); From eb31833d521f03901e1d7de53301434fef72d010 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Tue, 5 Dec 2017 11:25:10 +0100 Subject: [PATCH 5/8] Backwards-compat for multi-query table transform * treat single-query table panels like they were before * adjusted test cases --- .../plugins/panel/table/specs/transformers.jest.ts | 6 +++--- public/app/plugins/panel/table/transformers.ts | 14 +++++++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/public/app/plugins/panel/table/specs/transformers.jest.ts b/public/app/plugins/panel/table/specs/transformers.jest.ts index 87b18c00401..f3b58270bfc 100644 --- a/public/app/plugins/panel/table/specs/transformers.jest.ts +++ b/public/app/plugins/panel/table/specs/transformers.jest.ts @@ -1,6 +1,6 @@ import {transformers, transformDataToTable} from '../transformers'; -describe('when transforming time series table.', () => { +describe('when transforming time series table', () => { var table; describe('given 2 time series', () => { @@ -189,7 +189,7 @@ describe('when transforming time series table.', () => { var columns = transformers[transform].getColumns(singleQueryData); expect(columns[0].text).toBe('Time'); expect(columns[1].text).toBe('Label Key 1'); - expect(columns[2].text).toBe('Value #A'); + expect(columns[2].text).toBe('Value'); }); it('should return the union of data columns given a multiple queries', function() { @@ -221,7 +221,7 @@ describe('when transforming time series table.', () => { expect(table.columns.length).toBe(3); expect(table.columns[0].text).toBe('Time'); expect(table.columns[1].text).toBe('Label Key 1'); - expect(table.columns[2].text).toBe('Value #A'); + expect(table.columns[2].text).toBe('Value'); }); it ('should return the union of columns for multiple queries', () => { diff --git a/public/app/plugins/panel/table/transformers.ts b/public/app/plugins/panel/table/transformers.ts index 8a7a73493dd..5caf8f09f8c 100644 --- a/public/app/plugins/panel/table/transformers.ts +++ b/public/app/plugins/panel/table/transformers.ts @@ -136,6 +136,11 @@ transformers['table'] = { return []; } + // Single query returns data columns as is + if (data.length === 1) { + return [...data[0].columns]; + } + // Track column indexes: name -> index const columnNames = {}; @@ -155,7 +160,7 @@ transformers['table'] = { // Append one value column per data set data.forEach((_, i) => { - // Value (A), Value (B),... + // Value #A, Value #B,... const text = `Value #${String.fromCharCode(65 + i)}`; columnNames[text] = columns.length; columns.push({ text }); @@ -173,6 +178,13 @@ transformers['table'] = { throw {message: `Result of query #${String.fromCharCode(65 + noTableIndex)} is not in table format, try using another transform.`}; } + // Single query returns data columns and rows as is + if (data.length === 1) { + model.columns = [...data[0].columns]; + model.rows = [...data[0].rows]; + return; + } + // Track column indexes: name -> index const columnNames = {}; const columnIndexes = []; From 011b2cd1b21f5e7016e7e7eac1f3908e77093fa7 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Wed, 6 Dec 2017 13:12:16 +0100 Subject: [PATCH 6/8] Fix merge issue on multi-query table transforms * after a match has been found the merger should keep looking for more --- .../panel/table/specs/transformers.jest.ts | 16 +++++++- .../app/plugins/panel/table/transformers.ts | 41 +++++++++++-------- 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/public/app/plugins/panel/table/specs/transformers.jest.ts b/public/app/plugins/panel/table/specs/transformers.jest.ts index f3b58270bfc..1c7fc3e7852 100644 --- a/public/app/plugins/panel/table/specs/transformers.jest.ts +++ b/public/app/plugins/panel/table/specs/transformers.jest.ts @@ -156,6 +156,18 @@ describe('when transforming time series table', () => { rows: [ [time, 'Label Value 1', 'Label Value 2', 13], ], + }, + { + type: 'table', + columns: [ + { text: 'Time' }, + { text: 'Label Key 1' }, + { text: 'Label Key 2' }, + { text: 'Value' }, + ], + rows: [ + [time, 'Label Value 1', 'Label Value 2', 4], + ], } ]; @@ -226,12 +238,13 @@ describe('when transforming time series table', () => { it ('should return the union of columns for multiple queries', () => { table = transformDataToTable(multipleQueriesDataSameLabels, panel); - expect(table.columns.length).toBe(5); + expect(table.columns.length).toBe(6); expect(table.columns[0].text).toBe('Time'); expect(table.columns[1].text).toBe('Label Key 1'); expect(table.columns[2].text).toBe('Label Key 2'); expect(table.columns[3].text).toBe('Value #A'); expect(table.columns[4].text).toBe('Value #B'); + expect(table.columns[5].text).toBe('Value #C'); }); it ('should return 1 row for a single query', () => { @@ -250,6 +263,7 @@ describe('when transforming time series table', () => { expect(table.rows[0][2]).toBe('Label Value 2'); expect(table.rows[0][3]).toBe(42); expect(table.rows[0][4]).toBe(13); + expect(table.rows[0][5]).toBe(4); }); it ('should return 2 rows for a mulitple queries with different label values', () => { diff --git a/public/app/plugins/panel/table/transformers.ts b/public/app/plugins/panel/table/transformers.ts index 5caf8f09f8c..5081ab785b6 100644 --- a/public/app/plugins/panel/table/transformers.ts +++ b/public/app/plugins/panel/table/transformers.ts @@ -235,26 +235,33 @@ transformers['table'] = { const mergedRows = {}; rows = rows.reduce((acc, row, i) => { if (!mergedRows[i]) { - const match = _.findIndex(rows, (other, j) => { - let same = true; - for (let index = 0; index < nonValueColumnCount; index++) { - if (row[index] !== other[index]) { - same = false; - break; + let offset = i + 1; + while (offset < rows.length) { + const match = _.findIndex(rows, (other, j) => { + let same = true; + for (let index = 0; index < nonValueColumnCount; index++) { + if (row[index] !== other[index]) { + same = false; + break; + } } - } - return same; - }, i + 1); - if (match > -1) { - const matchedRow = rows[match]; - // Merge values into current row - for (let index = nonValueColumnCount; index < columns.length; index++) { - if (row[index] === undefined && matchedRow[index] !== undefined) { - row[index] = matchedRow[index]; - break; + return same; + }, offset); + if (match > -1) { + const matchedRow = rows[match]; + // Merge values into current row + for (let index = nonValueColumnCount; index < columns.length; index++) { + if (row[index] === undefined && matchedRow[index] !== undefined) { + row[index] = matchedRow[index]; + break; + } } + mergedRows[match] = matchedRow; + // Keep looking for more rows to merge + offset = match + 1; + } else { + break; } - mergedRows[match] = matchedRow; } acc.push(row); } From 8d70f13393c42fbcc3a4e3ca6d861d108eecf1fc Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Mon, 11 Dec 2017 12:42:53 +0100 Subject: [PATCH 7/8] Type-agnostic row merge in table transform for multiple queries * moved unique value naming to datasource (credit: @bergquist) * merge rows based on same column-values and empty values * expanded tests --- .../datasource/prometheus/datasource.ts | 7 +- .../panel/table/specs/transformers.jest.ts | 65 +++++++++++++---- .../app/plugins/panel/table/transformers.ts | 73 ++++++++----------- 3 files changed, 82 insertions(+), 63 deletions(-) diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index ec295760d49..f90dacc34c1 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -118,7 +118,7 @@ export class PrometheusDatasource { } if (activeTargets[index].format === "table") { - result.push(self.transformMetricDataToTable(response.data.data.result)); + result.push(self.transformMetricDataToTable(response.data.data.result, responseList.length, index)); } else { for (let metricData of response.data.data.result) { if (response.data.data.resultType === 'matrix') { @@ -301,7 +301,7 @@ export class PrometheusDatasource { return { target: metricLabel, datapoints: dps }; } - transformMetricDataToTable(md) { + transformMetricDataToTable(md, resultCount: number, resultIndex: number) { var table = new TableModel(); var i, j; var metricLabels = {}; @@ -326,7 +326,8 @@ export class PrometheusDatasource { metricLabels[label] = labelIndex + 1; table.columns.push({text: label}); }); - table.columns.push({text: 'Value'}); + let valueText = resultCount > 1 ? `Value #${String.fromCharCode(65 + resultIndex)}` : 'Value'; + table.columns.push({text: valueText}); // Populate rows, set value to empty string when label not present. _.each(md, function(series) { diff --git a/public/app/plugins/panel/table/specs/transformers.jest.ts b/public/app/plugins/panel/table/specs/transformers.jest.ts index 1c7fc3e7852..18b3cc213d8 100644 --- a/public/app/plugins/panel/table/specs/transformers.jest.ts +++ b/public/app/plugins/panel/table/specs/transformers.jest.ts @@ -139,7 +139,7 @@ describe('when transforming time series table', () => { { text: 'Time' }, { text: 'Label Key 1' }, { text: 'Label Key 2' }, - { text: 'Value' }, + { text: 'Value #A' }, ], rows: [ [time, 'Label Value 1', 'Label Value 2', 42], @@ -151,7 +151,7 @@ describe('when transforming time series table', () => { { text: 'Time' }, { text: 'Label Key 1' }, { text: 'Label Key 2' }, - { text: 'Value' }, + { text: 'Value #B' }, ], rows: [ [time, 'Label Value 1', 'Label Value 2', 13], @@ -163,11 +163,23 @@ describe('when transforming time series table', () => { { text: 'Time' }, { text: 'Label Key 1' }, { text: 'Label Key 2' }, - { text: 'Value' }, + { text: 'Value #C' }, ], rows: [ [time, 'Label Value 1', 'Label Value 2', 4], ], + }, + { + type: 'table', + columns: [ + { text: 'Time' }, + { text: 'Label Key 1' }, + { text: 'Label Key 2' }, + { text: 'Value #C' }, + ], + rows: [ + [time, 'Label Value 1', 'Label Value 2', 7], + ], } ]; @@ -177,7 +189,7 @@ describe('when transforming time series table', () => { columns: [ { text: 'Time' }, { text: 'Label Key 1' }, - { text: 'Value' }, + { text: 'Value #A' }, ], rows: [ [time, 'Label Value 1', 42], @@ -188,11 +200,22 @@ describe('when transforming time series table', () => { columns: [ { text: 'Time' }, { text: 'Label Key 2' }, - { text: 'Value' }, + { text: 'Value #B' }, ], rows: [ [time, 'Label Value 2', 13], ], + }, + { + type: 'table', + columns: [ + { text: 'Time' }, + { text: 'Label Key 1' }, + { text: 'Value #C' }, + ], + rows: [ + [time, 'Label Value 3', 7], + ], } ]; @@ -217,9 +240,10 @@ describe('when transforming time series table', () => { var columns = transformers[transform].getColumns(multipleQueriesDataDifferentLabels); expect(columns[0].text).toBe('Time'); expect(columns[1].text).toBe('Label Key 1'); - expect(columns[2].text).toBe('Label Key 2'); - expect(columns[3].text).toBe('Value #A'); + expect(columns[2].text).toBe('Value #A'); + expect(columns[3].text).toBe('Label Key 2'); expect(columns[4].text).toBe('Value #B'); + expect(columns[5].text).toBe('Value #C'); }); }); @@ -255,32 +279,41 @@ describe('when transforming time series table', () => { expect(table.rows[0][2]).toBe(42); }); - it ('should return 1 row for a mulitple queries with same label values', () => { + it ('should return 2 rows for a mulitple queries with same label values plus one extra row', () => { table = transformDataToTable(multipleQueriesDataSameLabels, panel); - expect(table.rows.length).toBe(1); + expect(table.rows.length).toBe(2); expect(table.rows[0][0]).toBe(time); expect(table.rows[0][1]).toBe('Label Value 1'); expect(table.rows[0][2]).toBe('Label Value 2'); expect(table.rows[0][3]).toBe(42); expect(table.rows[0][4]).toBe(13); expect(table.rows[0][5]).toBe(4); + expect(table.rows[1][0]).toBe(time); + expect(table.rows[1][1]).toBe('Label Value 1'); + expect(table.rows[1][2]).toBe('Label Value 2'); + expect(table.rows[1][3]).toBeUndefined(); + expect(table.rows[1][4]).toBeUndefined(); + expect(table.rows[1][5]).toBe(7); }); - it ('should return 2 rows for a mulitple queries with different label values', () => { + it ('should return 2 rows for mulitple queries with different label values', () => { table = transformDataToTable(multipleQueriesDataDifferentLabels, panel); expect(table.rows.length).toBe(2); + expect(table.columns.length).toBe(6); expect(table.rows[0][0]).toBe(time); expect(table.rows[0][1]).toBe('Label Value 1'); - expect(table.rows[0][2]).toBeUndefined(); - expect(table.rows[0][3]).toBe(42); - expect(table.rows[0][4]).toBeUndefined(); + expect(table.rows[0][2]).toBe(42); + expect(table.rows[0][3]).toBe('Label Value 2'); + expect(table.rows[0][4]).toBe(13); + expect(table.rows[0][5]).toBeUndefined(); expect(table.rows[1][0]).toBe(time); - expect(table.rows[1][1]).toBeUndefined(); - expect(table.rows[1][2]).toBe('Label Value 2'); + expect(table.rows[1][1]).toBe('Label Value 3'); + expect(table.rows[1][2]).toBeUndefined(); expect(table.rows[1][3]).toBeUndefined(); - expect(table.rows[1][4]).toBe(13); + expect(table.rows[1][4]).toBeUndefined(); + expect(table.rows[1][5]).toBe(7); }); }); }); diff --git a/public/app/plugins/panel/table/transformers.ts b/public/app/plugins/panel/table/transformers.ts index 5081ab785b6..08f259bb168 100644 --- a/public/app/plugins/panel/table/transformers.ts +++ b/public/app/plugins/panel/table/transformers.ts @@ -144,28 +144,18 @@ transformers['table'] = { // Track column indexes: name -> index const columnNames = {}; - // Union of all non-value columns + // Union of all columns const columns = data.reduce((acc, d, i) => { d.columns.forEach((col, j) => { const { text } = col; - if (text !== 'Value') { - if (columnNames[text] === undefined) { - columnNames[text] = acc.length; - acc.push(col); - } + if (columnNames[text] === undefined) { + columnNames[text] = acc.length; + acc.push(col); } }); return acc; }, []); - // Append one value column per data set - data.forEach((_, i) => { - // Value #A, Value #B,... - const text = `Value #${String.fromCharCode(65 + i)}`; - columnNames[text] = columns.length; - columns.push({ text }); - }); - return columns; }, transform: function(data, panel, model) { @@ -194,27 +184,15 @@ transformers['table'] = { const indexes = []; d.columns.forEach((col, j) => { const { text } = col; - if (text !== 'Value') { - if (columnNames[text] === undefined) { - columnNames[text] = acc.length; - acc.push(col); - } - indexes[j] = columnNames[text]; + if (columnNames[text] === undefined) { + columnNames[text] = acc.length; + acc.push(col); } + indexes[j] = columnNames[text]; }); columnIndexes.push(indexes); return acc; }, []); - const nonValueColumnCount = columns.length; - - // Append one value column per data set - data.forEach((_, i) => { - // Value #A, Value #B,... - const text = `Value #${String.fromCharCode(65 + i)}`; - columnNames[text] = columns.length; - columns.push({ text }); - columnIndexes[i].push(columnNames[text]); - }); model.columns = columns; @@ -231,29 +209,36 @@ transformers['table'] = { return acc; }, []); - // Merge rows that have same columns + // Merge rows that have same values for columns const mergedRows = {}; - rows = rows.reduce((acc, row, i) => { - if (!mergedRows[i]) { - let offset = i + 1; + rows = rows.reduce((acc, row, rowIndex) => { + if (!mergedRows[rowIndex]) { + let offset = rowIndex + 1; while (offset < rows.length) { - const match = _.findIndex(rows, (other, j) => { - let same = true; - for (let index = 0; index < nonValueColumnCount; index++) { - if (row[index] !== other[index]) { - same = false; + // Find next row that has the same field values unless the respective field is undefined + const match = _.findIndex(rows, (otherRow) => { + let fieldsAreTheSame = true; + let foundFieldToMatch = false; + for (let columnIndex = 0; columnIndex < columns.length; columnIndex++) { + if (row[columnIndex] !== undefined && otherRow[columnIndex] !== undefined) { + if (row[columnIndex] !== otherRow[columnIndex]) { + fieldsAreTheSame = false; + } + } else if (row[columnIndex] === undefined || otherRow[columnIndex] === undefined) { + foundFieldToMatch = true; + } + if (!fieldsAreTheSame) { break; } } - return same; + return fieldsAreTheSame && foundFieldToMatch; }, offset); if (match > -1) { const matchedRow = rows[match]; // Merge values into current row - for (let index = nonValueColumnCount; index < columns.length; index++) { - if (row[index] === undefined && matchedRow[index] !== undefined) { - row[index] = matchedRow[index]; - break; + for (let columnIndex = 0; columnIndex < columns.length; columnIndex++) { + if (row[columnIndex] === undefined && matchedRow[columnIndex] !== undefined) { + row[columnIndex] = matchedRow[columnIndex]; } } mergedRows[match] = matchedRow; From edb2dcf1b0a79a4ffbe8374e48dd0c781ed737e5 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Tue, 12 Dec 2017 13:18:06 +0100 Subject: [PATCH 8/8] Extracted row matching function and added comments --- .../app/plugins/panel/table/transformers.ts | 89 ++++++++++--------- 1 file changed, 49 insertions(+), 40 deletions(-) diff --git a/public/app/plugins/panel/table/transformers.ts b/public/app/plugins/panel/table/transformers.ts index 08f259bb168..a1d49453bcb 100644 --- a/public/app/plugins/panel/table/transformers.ts +++ b/public/app/plugins/panel/table/transformers.ts @@ -145,8 +145,8 @@ transformers['table'] = { const columnNames = {}; // Union of all columns - const columns = data.reduce((acc, d, i) => { - d.columns.forEach((col, j) => { + const columns = data.reduce((acc, series) => { + series.columns.forEach(col => { const { text } = col; if (columnNames[text] === undefined) { columnNames[text] = acc.length; @@ -175,76 +175,84 @@ transformers['table'] = { return; } - // Track column indexes: name -> index + // Track column indexes of union: name -> index const columnNames = {}; - const columnIndexes = []; // Union of all non-value columns - const columns = data.reduce((acc, d, i) => { - const indexes = []; - d.columns.forEach((col, j) => { + const columnsUnion = data.reduce((acc, series) => { + series.columns.forEach(col => { const { text } = col; if (columnNames[text] === undefined) { columnNames[text] = acc.length; acc.push(col); } - indexes[j] = columnNames[text]; }); - columnIndexes.push(indexes); return acc; }, []); - model.columns = columns; + // Map old column index to union index per series, e.g., + // given columnNames {A: 0, B: 1} and + // data [{columns: [{ text: 'A' }]}, {columns: [{ text: 'B' }]}] => [[0], [1]] + const columnIndexMapper = data.map(series => + series.columns.map(col => columnNames[col.text]) + ); - // Adjust rows to new column indexes - let rows = data.reduce((acc, d, i) => { - const indexes = columnIndexes[i]; - d.rows.forEach((r, j) => { + // Flatten rows of all series and adjust new column indexes + const flattenedRows = data.reduce((acc, series, seriesIndex) => { + const mapper = columnIndexMapper[seriesIndex]; + series.rows.forEach(row => { const alteredRow = []; - indexes.forEach((to, from) => { - alteredRow[to] = r[from]; + // Shifting entries according to index mapper + mapper.forEach((to, from) => { + alteredRow[to] = row[from]; }); acc.push(alteredRow); }); return acc; }, []); + // Returns true if both rows have matching non-empty fields as well as matching + // indexes where one field is empty and the other is not + function areRowsMatching(columns, row, otherRow) { + let foundFieldToMatch = false; + for (let columnIndex = 0; columnIndex < columns.length; columnIndex++) { + if (row[columnIndex] !== undefined && otherRow[columnIndex] !== undefined) { + if (row[columnIndex] !== otherRow[columnIndex]) { + return false; + } + } else if (row[columnIndex] === undefined || otherRow[columnIndex] === undefined) { + foundFieldToMatch = true; + } + } + return foundFieldToMatch; + } + // Merge rows that have same values for columns const mergedRows = {}; - rows = rows.reduce((acc, row, rowIndex) => { + const compactedRows = flattenedRows.reduce((acc, row, rowIndex) => { if (!mergedRows[rowIndex]) { + // Look from current row onwards let offset = rowIndex + 1; - while (offset < rows.length) { - // Find next row that has the same field values unless the respective field is undefined - const match = _.findIndex(rows, (otherRow) => { - let fieldsAreTheSame = true; - let foundFieldToMatch = false; - for (let columnIndex = 0; columnIndex < columns.length; columnIndex++) { - if (row[columnIndex] !== undefined && otherRow[columnIndex] !== undefined) { - if (row[columnIndex] !== otherRow[columnIndex]) { - fieldsAreTheSame = false; - } - } else if (row[columnIndex] === undefined || otherRow[columnIndex] === undefined) { - foundFieldToMatch = true; - } - if (!fieldsAreTheSame) { - break; - } - } - return fieldsAreTheSame && foundFieldToMatch; - }, offset); + // More than one row can be merged into current row + while (offset < flattenedRows.length) { + // Find next row that could be merged + const match = _.findIndex(flattenedRows, + otherRow => areRowsMatching(columnsUnion, row, otherRow), + offset); if (match > -1) { - const matchedRow = rows[match]; - // Merge values into current row - for (let columnIndex = 0; columnIndex < columns.length; columnIndex++) { + const matchedRow = flattenedRows[match]; + // Merge values from match into current row if there is a gap in the current row + for (let columnIndex = 0; columnIndex < columnsUnion.length; columnIndex++) { if (row[columnIndex] === undefined && matchedRow[columnIndex] !== undefined) { row[columnIndex] = matchedRow[columnIndex]; } } + // Dont visit this row again mergedRows[match] = matchedRow; // Keep looking for more rows to merge offset = match + 1; } else { + // No match found, stop looking break; } } @@ -253,7 +261,8 @@ transformers['table'] = { return acc; }, []); - model.rows = rows; + model.columns = columnsUnion; + model.rows = compactedRows; } };