diff --git a/public/app/core/specs/table_model.test.ts b/public/app/core/specs/table_model.test.ts index 990daaaa2da..19b2a7543fb 100644 --- a/public/app/core/specs/table_model.test.ts +++ b/public/app/core/specs/table_model.test.ts @@ -1,4 +1,4 @@ -import TableModel from 'app/core/table_model'; +import TableModel, { mergeTablesIntoModel } from 'app/core/table_model'; describe('when sorting table desc', () => { let table; @@ -79,3 +79,118 @@ describe('when sorting with nulls', () => { expect(values).toEqual([null, null, 'd', 'c', 'b', 'a', '', '']); }); }); + +describe('mergeTables', () => { + const time = new Date().getTime(); + + const singleTable = new TableModel({ + type: 'table', + columns: [{ text: 'Time' }, { text: 'Label Key 1' }, { text: 'Value' }], + rows: [[time, 'Label Value 1', 42]], + }); + + const multipleTablesSameColumns = [ + new TableModel({ + type: 'table', + columns: [{ text: 'Time' }, { text: 'Label Key 1' }, { text: 'Label Key 2' }, { text: 'Value #A' }], + rows: [[time, 'Label Value 1', 'Label Value 2', 42]], + }), + new TableModel({ + type: 'table', + columns: [{ text: 'Time' }, { text: 'Label Key 1' }, { text: 'Label Key 2' }, { text: 'Value #B' }], + rows: [[time, 'Label Value 1', 'Label Value 2', 13]], + }), + new TableModel({ + type: 'table', + columns: [{ text: 'Time' }, { text: 'Label Key 1' }, { text: 'Label Key 2' }, { text: 'Value #C' }], + rows: [[time, 'Label Value 1', 'Label Value 2', 4]], + }), + new TableModel({ + 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]], + }), + ]; + + const multipleTablesDifferentColumns = [ + new TableModel({ + type: 'table', + columns: [{ text: 'Time' }, { text: 'Label Key 1' }, { text: 'Value #A' }], + rows: [[time, 'Label Value 1', 42]], + }), + new TableModel({ + type: 'table', + columns: [{ text: 'Time' }, { text: 'Label Key 2' }, { text: 'Value #B' }], + rows: [[time, 'Label Value 2', 13]], + }), + new TableModel({ + type: 'table', + columns: [{ text: 'Time' }, { text: 'Label Key 1' }, { text: 'Value #C' }], + rows: [[time, 'Label Value 3', 7]], + }), + ]; + + it('should return the single table as is', () => { + const table = mergeTablesIntoModel(new TableModel(), singleTable); + 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 the union of columns for multiple tables', () => { + const table = mergeTablesIntoModel(new TableModel(), ...multipleTablesSameColumns); + 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 table', () => { + const table = mergeTablesIntoModel(new TableModel(), singleTable); + 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 2 rows for a multiple tables with same column values plus one extra row', () => { + const table = mergeTablesIntoModel(new TableModel(), ...multipleTablesSameColumns); + 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 multiple tables with different column values', () => { + const table = mergeTablesIntoModel(new TableModel(), ...multipleTablesDifferentColumns); + 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]).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]).toBe('Label Value 3'); + expect(table.rows[1][2]).toBeUndefined(); + expect(table.rows[1][3]).toBeUndefined(); + expect(table.rows[1][4]).toBeUndefined(); + expect(table.rows[1][5]).toBe(7); + }); +}); diff --git a/public/app/core/table_model.ts b/public/app/core/table_model.ts index f8b96d0537b..99395258ba3 100644 --- a/public/app/core/table_model.ts +++ b/public/app/core/table_model.ts @@ -1,3 +1,5 @@ +import _ from 'lodash'; + interface Column { text: string; title?: string; @@ -14,11 +16,20 @@ export default class TableModel { type: string; columnMap: any; - constructor() { + constructor(table?: any) { this.columns = []; this.columnMap = {}; this.rows = []; this.type = 'table'; + + if (table) { + if (table.columns) { + table.columns.forEach(col => this.addColumn(col)); + } + if (table.rows) { + table.rows.forEach(row => this.addRow(row)); + } + } } sort(options) { @@ -52,3 +63,100 @@ export default class TableModel { this.rows.push(row); } } + +// 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; +} + +export function mergeTablesIntoModel(dst?: TableModel, ...tables: TableModel[]): TableModel { + const model = dst || new TableModel(); + + // Single query returns data columns and rows as is + if (arguments.length === 2) { + model.columns = [...tables[0].columns]; + model.rows = [...tables[0].rows]; + return model; + } + + // Track column indexes of union: name -> index + const columnNames = {}; + + // Union of all non-value columns + const columnsUnion = tables.slice().reduce((acc, series) => { + series.columns.forEach(col => { + const { text } = col; + if (columnNames[text] === undefined) { + columnNames[text] = acc.length; + acc.push(col); + } + }); + return acc; + }, []); + + // 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 = tables.map(series => series.columns.map(col => columnNames[col.text])); + + // Flatten rows of all series and adjust new column indexes + const flattenedRows = tables.reduce((acc, series, seriesIndex) => { + const mapper = columnIndexMapper[seriesIndex]; + series.rows.forEach(row => { + const alteredRow = []; + // Shifting entries according to index mapper + mapper.forEach((to, from) => { + alteredRow[to] = row[from]; + }); + acc.push(alteredRow); + }); + return acc; + }, []); + + // Merge rows that have same values for columns + const mergedRows = {}; + const compactedRows = flattenedRows.reduce((acc, row, rowIndex) => { + if (!mergedRows[rowIndex]) { + // Look from current row onwards + let offset = rowIndex + 1; + // 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 = 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]; + } + } + // Don't visit this row again + mergedRows[match] = matchedRow; + // Keep looking for more rows to merge + offset = match + 1; + } else { + // No match found, stop looking + break; + } + } + acc.push(row); + } + return acc; + }, []); + + model.columns = columnsUnion; + model.rows = compactedRows; + return model; +} diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 4fe67d9d37b..d7326a5bfd1 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -13,6 +13,7 @@ import ResetStyles from 'app/core/components/Picker/ResetStyles'; import PickerOption from 'app/core/components/Picker/PickerOption'; import IndicatorsContainer from 'app/core/components/Picker/IndicatorsContainer'; import NoOptionsMessage from 'app/core/components/Picker/NoOptionsMessage'; +import TableModel, { mergeTablesIntoModel } from 'app/core/table_model'; import ElapsedTime from './ElapsedTime'; import QueryRows from './QueryRows'; @@ -389,8 +390,10 @@ export class Explore extends React.PureComponent { to: parseDate(range.to, true), }; const { interval } = kbn.calculateInterval(absoluteRange, resolution, datasource.interval); - const targets = this.queryExpressions.map(q => ({ + const targets = this.queryExpressions.map((q, i) => ({ ...targetOptions, + // Target identifier is needed for table transformations + refId: i + 1, expr: q, })); return { @@ -437,7 +440,7 @@ export class Explore extends React.PureComponent { }); try { const res = await datasource.query(options); - const tableModel = res.data[0]; + const tableModel = mergeTablesIntoModel(new TableModel(), ...res.data); const latency = Date.now() - now; this.setState({ latency, loading: false, tableResult: tableModel, requestOptions: options }); this.onQuerySuccess(datasource.meta.id, queries); diff --git a/public/app/features/explore/Table.tsx b/public/app/features/explore/Table.tsx index e1c71fa55e4..fdb5d7de93a 100644 --- a/public/app/features/explore/Table.tsx +++ b/public/app/features/explore/Table.tsx @@ -5,6 +5,8 @@ import ReactTable from 'react-table'; import TableModel from 'app/core/table_model'; const EMPTY_TABLE = new TableModel(); +// Identify columns that contain values +const VALUE_REGEX = /^[Vv]alue #\d+/; interface TableProps { data: TableModel; @@ -34,6 +36,7 @@ export default class Table extends PureComponent { const columns = tableModel.columns.map(({ filterable, text }) => ({ Header: text, accessor: text, + className: VALUE_REGEX.test(text) ? 'text-right' : '', show: text !== 'Time', Cell: row => {row.value}, })); diff --git a/public/app/plugins/panel/table/specs/transformers.test.ts b/public/app/plugins/panel/table/specs/transformers.test.ts index 8d581b68842..49926aa00a8 100644 --- a/public/app/plugins/panel/table/specs/transformers.test.ts +++ b/public/app/plugins/panel/table/specs/transformers.test.ts @@ -143,24 +143,6 @@ describe('when transforming time series table', () => { }, ]; - const multipleQueriesDataDifferentLabels = [ - { - type: 'table', - columns: [{ text: 'Time' }, { text: 'Label Key 1' }, { text: 'Value #A' }], - rows: [[time, 'Label Value 1', 42]], - }, - { - type: 'table', - columns: [{ text: 'Time' }, { text: 'Label Key 2' }, { 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]], - }, - ]; - describe('getColumns', () => { it('should return data columns given a single query', () => { const columns = transformers[transform].getColumns(singleQueryData); @@ -177,16 +159,6 @@ describe('when transforming time series table', () => { 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', () => { - const columns = transformers[transform].getColumns(multipleQueriesDataDifferentLabels); - expect(columns[0].text).toBe('Time'); - expect(columns[1].text).toBe('Label Key 1'); - 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'); - }); }); describe('transform', () => { @@ -237,26 +209,6 @@ describe('when transforming time series table', () => { expect(table.rows[1][4]).toBeUndefined(); expect(table.rows[1][5]).toBe(7); }); - - it('should return 2 rows for multiple 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]).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]).toBe('Label Value 3'); - expect(table.rows[1][2]).toBeUndefined(); - expect(table.rows[1][3]).toBeUndefined(); - 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 5a75fa7acf6..c56d385505b 100644 --- a/public/app/plugins/panel/table/transformers.ts +++ b/public/app/plugins/panel/table/transformers.ts @@ -1,7 +1,7 @@ import _ from 'lodash'; -import flatten from '../../../core/utils/flatten'; -import TimeSeries from '../../../core/time_series2'; -import TableModel from '../../../core/table_model'; +import flatten from 'app/core/utils/flatten'; +import TimeSeries from 'app/core/time_series2'; +import TableModel, { mergeTablesIntoModel } from 'app/core/table_model'; const transformers = {}; @@ -168,97 +168,7 @@ transformers['table'] = { }; } - // 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 of union: name -> index - const columnNames = {}; - - // Union of all non-value columns - const columnsUnion = data.reduce((acc, series) => { - series.columns.forEach(col => { - const { text } = col; - if (columnNames[text] === undefined) { - columnNames[text] = acc.length; - acc.push(col); - } - }); - return acc; - }, []); - - // 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])); - - // 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 = []; - // 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 = {}; - const compactedRows = flattenedRows.reduce((acc, row, rowIndex) => { - if (!mergedRows[rowIndex]) { - // Look from current row onwards - let offset = rowIndex + 1; - // 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 = 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]; - } - } - // Don't visit this row again - mergedRows[match] = matchedRow; - // Keep looking for more rows to merge - offset = match + 1; - } else { - // No match found, stop looking - break; - } - } - acc.push(row); - } - return acc; - }, []); - - model.columns = columnsUnion; - model.rows = compactedRows; + mergeTablesIntoModel(model, ...data); }, };