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