1import { findIndex } from 'lodash'; 2import { Column, TableData, QueryResultMeta } from '@grafana/data'; 3 4/** 5 * Extends the standard Column class with variables that get 6 * mutated in the angular table panel. 7 */ 8export interface MutableColumn extends Column { 9 title?: string; 10 sort?: boolean; 11 desc?: boolean; 12 type?: string; 13} 14 15export default class TableModel implements TableData { 16 columns: MutableColumn[]; 17 rows: any[]; 18 type: string; 19 columnMap: any; 20 refId?: string; 21 meta?: QueryResultMeta; 22 23 constructor(table?: any) { 24 this.columns = []; 25 this.columnMap = {}; 26 this.rows = []; 27 this.type = 'table'; 28 29 if (table) { 30 if (table.columns) { 31 for (const col of table.columns) { 32 this.addColumn(col); 33 } 34 } 35 if (table.rows) { 36 for (const row of table.rows) { 37 this.addRow(row); 38 } 39 } 40 } 41 } 42 43 sort(options: { col: number; desc: boolean }) { 44 if (options.col === null || this.columns.length <= options.col) { 45 return; 46 } 47 48 this.rows.sort((a, b) => { 49 a = a[options.col]; 50 b = b[options.col]; 51 // Sort null or undefined separately from comparable values 52 return +(a == null) - +(b == null) || +(a > b) || -(a < b); 53 }); 54 55 if (options.desc) { 56 this.rows.reverse(); 57 } 58 59 this.columns[options.col].sort = true; 60 this.columns[options.col].desc = options.desc; 61 } 62 63 addColumn(col: Column) { 64 if (!this.columnMap[col.text]) { 65 this.columns.push(col); 66 this.columnMap[col.text] = col; 67 } 68 } 69 70 addRow(row: any[]) { 71 this.rows.push(row); 72 } 73} 74 75// Returns true if both rows have matching non-empty fields as well as matching 76// indexes where one field is empty and the other is not 77function areRowsMatching(columns: Column[], row: any[], otherRow: any[]) { 78 let foundFieldToMatch = false; 79 for (let columnIndex = 0; columnIndex < columns.length; columnIndex++) { 80 if (row[columnIndex] !== undefined && otherRow[columnIndex] !== undefined) { 81 if (row[columnIndex] !== otherRow[columnIndex]) { 82 return false; 83 } 84 } else if (row[columnIndex] === undefined || otherRow[columnIndex] === undefined) { 85 foundFieldToMatch = true; 86 } 87 } 88 return foundFieldToMatch; 89} 90 91export function mergeTablesIntoModel(dst?: TableModel, ...tables: TableModel[]): TableModel { 92 const model = dst || new TableModel(); 93 94 if (arguments.length === 1) { 95 return model; 96 } 97 // Single query returns data columns and rows as is 98 if (arguments.length === 2) { 99 model.columns = tables[0].hasOwnProperty('columns') ? [...tables[0].columns] : []; 100 model.rows = tables[0].hasOwnProperty('rows') ? [...tables[0].rows] : []; 101 return model; 102 } 103 104 // Filter out any tables that are not of TableData format 105 const tableDataTables = tables.filter((table) => !!table.columns); 106 107 // Track column indexes of union: name -> index 108 const columnNames: { [key: string]: any } = {}; 109 110 // Union of all non-value columns 111 const columnsUnion = tableDataTables.slice().reduce((acc, series) => { 112 series.columns.forEach((col) => { 113 const { text } = col; 114 if (columnNames[text] === undefined) { 115 columnNames[text] = acc.length; 116 acc.push(col); 117 } 118 }); 119 return acc; 120 }, [] as MutableColumn[]); 121 122 // Map old column index to union index per series, e.g., 123 // given columnNames {A: 0, B: 1} and 124 // data [{columns: [{ text: 'A' }]}, {columns: [{ text: 'B' }]}] => [[0], [1]] 125 const columnIndexMapper = tableDataTables.map((series) => series.columns.map((col) => columnNames[col.text])); 126 127 // Flatten rows of all series and adjust new column indexes 128 const flattenedRows = tableDataTables.reduce((acc, series, seriesIndex) => { 129 const mapper = columnIndexMapper[seriesIndex]; 130 series.rows.forEach((row) => { 131 const alteredRow: MutableColumn[] = []; 132 // Shifting entries according to index mapper 133 mapper.forEach((to, from) => { 134 alteredRow[to] = row[from]; 135 }); 136 acc.push(alteredRow); 137 }); 138 return acc; 139 }, [] as MutableColumn[][]); 140 141 // Merge rows that have same values for columns 142 const mergedRows: { [key: string]: any } = {}; 143 144 const compactedRows = flattenedRows.reduce((acc, row, rowIndex) => { 145 if (!mergedRows[rowIndex]) { 146 // Look from current row onwards 147 let offset = rowIndex + 1; 148 // More than one row can be merged into current row 149 while (offset < flattenedRows.length) { 150 // Find next row that could be merged 151 const match = findIndex(flattenedRows, (otherRow) => areRowsMatching(columnsUnion, row, otherRow), offset); 152 if (match > -1) { 153 const matchedRow = flattenedRows[match]; 154 // Merge values from match into current row if there is a gap in the current row 155 for (let columnIndex = 0; columnIndex < columnsUnion.length; columnIndex++) { 156 if (row[columnIndex] === undefined && matchedRow[columnIndex] !== undefined) { 157 row[columnIndex] = matchedRow[columnIndex]; 158 } 159 } 160 // Don't visit this row again 161 mergedRows[match] = matchedRow; 162 // Keep looking for more rows to merge 163 offset = match + 1; 164 } else { 165 // No match found, stop looking 166 break; 167 } 168 } 169 acc.push(row); 170 } 171 return acc; 172 }, [] as MutableColumn[][]); 173 174 model.columns = columnsUnion; 175 model.rows = compactedRows; 176 return model; 177} 178