DataFrame: convert from row based to a columnar value format (#18391)

This commit is contained in:
Ryan McKinley
2019-08-15 09:18:51 -07:00
committed by GitHub
parent 350b9a9494
commit e59bae55d9
63 changed files with 1856 additions and 995 deletions

View File

@@ -1,6 +1,3 @@
import { Threshold } from './threshold';
import { ValueMapping } from './valueMapping';
export enum LoadingState {
NotStarted = 'NotStarted',
Loading = 'Loading',
@@ -9,14 +6,6 @@ export enum LoadingState {
Error = 'Error',
}
export enum FieldType {
time = 'time', // or date
number = 'number',
string = 'string',
boolean = 'boolean',
other = 'other', // Object, Array, etc
}
export interface QueryResultMeta {
[key: string]: any;
@@ -42,34 +31,10 @@ export interface QueryResultBase {
meta?: QueryResultMeta;
}
export interface Field {
name: string; // The column name
title?: string; // The display value for this field. This supports template variables blank is auto
type?: FieldType;
filterable?: boolean;
unit?: string;
decimals?: number | null; // Significant digits (for display)
min?: number | null;
max?: number | null;
// Convert input values into a display value
mappings?: ValueMapping[];
// Must be sorted by 'value', first value is always -Infinity
thresholds?: Threshold[];
}
export interface Labels {
[key: string]: string;
}
export interface DataFrame extends QueryResultBase {
name?: string;
fields: Field[];
rows: any[][];
labels?: Labels;
}
export interface Column {
text: string; // For a Column, the 'text' is the field name
filterable?: boolean;

View File

@@ -0,0 +1,110 @@
import { Threshold } from './threshold';
import { ValueMapping } from './valueMapping';
import { QueryResultBase, Labels, NullValueMode } from './data';
import { FieldCalcs } from '../utils/index';
import { DisplayProcessor } from './displayValue';
export enum FieldType {
time = 'time', // or date
number = 'number',
string = 'string',
boolean = 'boolean',
other = 'other', // Object, Array, etc
}
/**
* Every property is optional
*
* Plugins may extend this with additional properties. Somethign like series overrides
*/
export interface FieldConfig {
title?: string; // The display value for this field. This supports template variables blank is auto
filterable?: boolean;
// Numeric Options
unit?: string;
decimals?: number | null; // Significant digits (for display)
min?: number | null;
max?: number | null;
// Convert input values into a display string
mappings?: ValueMapping[];
// Must be sorted by 'value', first value is always -Infinity
thresholds?: Threshold[];
// Used when reducing field values
nullValueMode?: NullValueMode;
// Alternative to empty string
noValue?: string;
}
export interface Vector<T = any> {
length: number;
/**
* Access the value by index (Like an array)
*/
get(index: number): T;
/**
* Get the resutls as an array.
*/
toArray(): T[];
/**
* Return the values as a simple array for json serialization
*/
toJSON(): any; // same results as toArray()
}
export interface Field<T = any> {
name: string; // The column name
type: FieldType;
config: FieldConfig;
values: Vector<T>; // `buffer` when JSON
/**
* Cache of reduced values
*/
calcs?: FieldCalcs;
/**
* Convert text to the field value
*/
parse?: (value: any) => T;
/**
* Convert a value for display
*/
display?: DisplayProcessor;
}
export interface DataFrame extends QueryResultBase {
name?: string;
fields: Field[]; // All fields of equal length
labels?: Labels;
// The number of rows
length: number;
}
/**
* Like a field, but properties are optional and values may be a simple array
*/
export interface FieldDTO<T = any> {
name: string; // The column name
type?: FieldType;
config?: FieldConfig;
values?: Vector<T> | T[]; // toJSON will always be T[], input could be either
}
/**
* Like a DataFrame, but fields may be a FieldDTO
*/
export interface DataFrameDTO extends QueryResultBase {
name?: string;
labels?: Labels;
fields: Array<FieldDTO | Field>;
}

View File

@@ -1,3 +1,5 @@
export type DisplayProcessor = (value: any) => DisplayValue;
export interface DisplayValue {
text: string; // Show in the UI
numeric: number; // Use isNaN to check if it is a real number

View File

@@ -1,4 +1,5 @@
export * from './data';
export * from './dataFrame';
export * from './dataLink';
export * from './logs';
export * from './navModel';

View File

@@ -4,42 +4,54 @@ exports[`read csv should get X and y 1`] = `
Object {
"fields": Array [
Object {
"name": "Column 1",
"type": "number",
"config": Object {},
"name": "Field 1",
"type": "string",
"values": Array [
"",
"2",
"5",
"",
],
},
Object {
"name": "Column 2",
"config": Object {},
"name": "Field 2",
"type": "number",
"values": Array [
1,
3,
6,
NaN,
],
},
Object {
"name": "Column 3",
"config": Object {},
"name": "Field 3",
"type": "number",
"values": Array [
null,
4,
NaN,
NaN,
],
},
Object {
"config": Object {},
"name": "Field 4",
"type": "number",
"values": Array [
null,
null,
null,
7,
],
},
],
"rows": Array [
Array [
2,
3,
4,
null,
],
Array [
5,
6,
null,
null,
],
Array [
null,
null,
null,
7,
],
],
"labels": undefined,
"meta": undefined,
"name": undefined,
"refId": undefined,
}
`;
@@ -47,30 +59,37 @@ exports[`read csv should read csv from local file system 1`] = `
Object {
"fields": Array [
Object {
"config": Object {},
"name": "a",
"type": "number",
"values": Array [
10,
40,
],
},
Object {
"config": Object {},
"name": "b",
"type": "number",
"values": Array [
20,
50,
],
},
Object {
"config": Object {},
"name": "c",
"type": "number",
"values": Array [
30,
60,
],
},
],
"rows": Array [
Array [
10,
20,
30,
],
Array [
40,
50,
60,
],
],
"labels": undefined,
"meta": undefined,
"name": undefined,
"refId": undefined,
}
`;
@@ -78,42 +97,48 @@ exports[`read csv should read csv with headers 1`] = `
Object {
"fields": Array [
Object {
"config": Object {
"unit": "ms",
},
"name": "a",
"type": "number",
"unit": "ms",
"values": Array [
10,
40,
40,
40,
],
},
Object {
"config": Object {
"unit": "lengthm",
},
"name": "b",
"type": "string",
"unit": "lengthm",
"type": "number",
"values": Array [
20,
50,
500,
50,
],
},
Object {
"config": Object {
"unit": "s",
},
"name": "c",
"type": "boolean",
"unit": "s",
"values": Array [
true,
false,
false,
true,
],
},
],
"rows": Array [
Array [
10,
"20",
true,
],
Array [
40,
"50",
false,
],
Array [
40,
"500",
false,
],
Array [
40,
"50",
true,
],
],
"labels": undefined,
"meta": undefined,
"name": undefined,
"refId": undefined,
}
`;

View File

@@ -1,7 +1,9 @@
import { readCSV, toCSV, CSVHeaderStyle } from './csv';
import { getDataFrameRow } from './processDataFrame';
// Test with local CSV files
const fs = require('fs');
import fs from 'fs';
import { toDataFrameDTO } from './processDataFrame';
describe('read csv', () => {
it('should get X and y', () => {
@@ -11,14 +13,31 @@ describe('read csv', () => {
const series = data[0];
expect(series.fields.length).toBe(4);
expect(series.rows.length).toBe(3);
const rows = 4;
expect(series.length).toBe(rows);
// Make sure everythign it padded properly
for (const row of series.rows) {
expect(row.length).toBe(series.fields.length);
for (const field of series.fields) {
expect(field.values.length).toBe(rows);
}
expect(series).toMatchSnapshot();
const dto = toDataFrameDTO(series);
expect(dto).toMatchSnapshot();
});
it('should read single string OK', () => {
const text = 'a,b,c';
const data = readCSV(text);
expect(data.length).toBe(1);
const series = data[0];
expect(series.fields.length).toBe(3);
expect(series.length).toBe(0);
expect(series.fields[0].name).toEqual('a');
expect(series.fields[1].name).toEqual('b');
expect(series.fields[2].name).toEqual('c');
});
it('should read csv from local file system', () => {
@@ -28,7 +47,7 @@ describe('read csv', () => {
const csv = fs.readFileSync(path, 'utf8');
const data = readCSV(csv);
expect(data.length).toBe(1);
expect(data[0]).toMatchSnapshot();
expect(toDataFrameDTO(data[0])).toMatchSnapshot();
});
it('should read csv with headers', () => {
@@ -38,7 +57,7 @@ describe('read csv', () => {
const csv = fs.readFileSync(path, 'utf8');
const data = readCSV(csv);
expect(data.length).toBe(1);
expect(data[0]).toMatchSnapshot();
expect(toDataFrameDTO(data[0])).toMatchSnapshot();
});
});
@@ -54,7 +73,7 @@ describe('write csv', () => {
const data = readCSV(csv);
const out = toCSV(data, { headerStyle: CSVHeaderStyle.full });
expect(data.length).toBe(1);
expect(data[0].rows[0]).toEqual(firstRow);
expect(getDataFrameRow(data[0], 0)).toEqual(firstRow);
expect(data[0].fields.length).toBe(3);
expect(norm(out)).toBe(norm(csv));
@@ -65,7 +84,7 @@ describe('write csv', () => {
const f = readCSV(shorter);
const fields = f[0].fields;
expect(fields.length).toBe(3);
expect(f[0].rows[0]).toEqual(firstRow);
expect(getDataFrameRow(f[0], 0)).toEqual(firstRow);
expect(fields.map(f => f.name).join(',')).toEqual('a,b,c'); // the names
});
});

View File

@@ -4,8 +4,9 @@ import defaults from 'lodash/defaults';
import isNumber from 'lodash/isNumber';
// Types
import { DataFrame, Field, FieldType } from '../types';
import { DataFrame, Field, FieldType, FieldConfig } from '../types';
import { guessFieldTypeFromValue } from './processDataFrame';
import { DataFrameHelper } from './dataFrameHelper';
export enum CSVHeaderStyle {
full,
@@ -28,9 +29,9 @@ export interface CSVParseCallbacks {
* This can return a modified table to force any
* Column configurations
*/
onHeader: (table: DataFrame) => void;
onHeader: (fields: Field[]) => void;
// Called after each row is read and
// Called after each row is read
onRow: (row: any[]) => void;
}
@@ -49,16 +50,13 @@ enum ParseState {
ReadingRows,
}
type FieldParser = (value: string) => any;
export class CSVReader {
config: CSVConfig;
callback?: CSVParseCallbacks;
field: FieldParser[];
series: DataFrame;
state: ParseState;
data: DataFrame[];
data: DataFrameHelper[];
current: DataFrameHelper;
constructor(options?: CSVOptions) {
if (!options) {
@@ -67,12 +65,8 @@ export class CSVReader {
this.config = options.config || {};
this.callback = options.callback;
this.field = [];
this.current = new DataFrameHelper({ fields: [] });
this.state = ParseState.Starting;
this.series = {
fields: [],
rows: [],
};
this.data = [];
}
@@ -92,37 +86,42 @@ export class CSVReader {
const idx = first.indexOf('#', 2);
if (idx > 0) {
const k = first.substr(1, idx - 1);
const isName = 'name' === k;
// Simple object used to check if headers match
const headerKeys: Field = {
name: '#',
type: FieldType.number,
const headerKeys: FieldConfig = {
unit: '#',
};
// Check if it is a known/supported column
if (headerKeys.hasOwnProperty(k)) {
if (isName || headerKeys.hasOwnProperty(k)) {
// Starting a new table after reading rows
if (this.state === ParseState.ReadingRows) {
this.series = {
fields: [],
rows: [],
};
this.data.push(this.series);
this.current = new DataFrameHelper({ fields: [] });
this.data.push(this.current);
}
padColumnWidth(this.series.fields, line.length);
const fields: any[] = this.series.fields; // cast to any so we can lookup by key
const v = first.substr(idx + 1);
fields[0][k] = v;
for (let j = 1; j < fields.length; j++) {
fields[j][k] = line[j];
if (isName) {
this.current.addFieldFor(undefined, v);
for (let j = 1; j < line.length; j++) {
this.current.addFieldFor(undefined, line[j]);
}
} else {
const { fields } = this.current;
for (let j = 0; j < fields.length; j++) {
if (!fields[j].config) {
fields[j].config = {};
}
const disp = fields[j].config as any; // any lets name lookup
disp[k] = j === 0 ? v : line[j];
}
}
this.state = ParseState.InHeader;
continue;
}
} else if (this.state === ParseState.Starting) {
this.series.fields = makeFieldsFor(line);
this.state = ParseState.InHeader;
continue;
}
@@ -133,67 +132,48 @@ export class CSVReader {
if (this.state === ParseState.Starting) {
const type = guessFieldTypeFromValue(first);
if (type === FieldType.string) {
this.series.fields = makeFieldsFor(line);
for (const s of line) {
this.current.addFieldFor(undefined, s);
}
this.state = ParseState.InHeader;
continue;
}
this.series.fields = makeFieldsFor(new Array(line.length));
this.series.fields[0].type = type;
this.state = ParseState.InHeader; // fall through to read rows
}
}
if (this.state === ParseState.InHeader) {
padColumnWidth(this.series.fields, line.length);
this.state = ParseState.ReadingRows;
// Add the current results to the data
if (this.state !== ParseState.ReadingRows) {
// anything???
}
if (this.state === ParseState.ReadingRows) {
// Make sure colum structure is valid
if (line.length > this.series.fields.length) {
padColumnWidth(this.series.fields, line.length);
if (this.callback) {
this.callback.onHeader(this.series);
} else {
// Expand all rows with nulls
for (let x = 0; x < this.series.rows.length; x++) {
const row = this.series.rows[x];
while (row.length < line.length) {
row.push(null);
}
}
}
}
this.state = ParseState.ReadingRows;
const row: any[] = [];
for (let j = 0; j < line.length; j++) {
const v = line[j];
if (v) {
if (!this.field[j]) {
this.field[j] = makeFieldParser(v, this.series.fields[j]);
}
row.push(this.field[j](v));
} else {
row.push(null);
}
// Make sure colum structure is valid
if (line.length > this.current.fields.length) {
const { fields } = this.current;
for (let f = fields.length; f < line.length; f++) {
this.current.addFieldFor(line[f]);
}
if (this.callback) {
// Send the header after we guess the type
if (this.series.rows.length === 0) {
this.callback.onHeader(this.series);
this.series.rows.push(row); // Only add the first row
}
this.callback.onRow(row);
} else {
this.series.rows.push(row);
this.callback.onHeader(this.current.fields);
}
}
this.current.appendRow(line);
if (this.callback) {
// // Send the header after we guess the type
// if (this.series.rows.length === 0) {
// this.callback.onHeader(this.series);
// }
this.callback.onRow(line);
}
}
};
readCSV(text: string): DataFrame[] {
this.data = [this.series];
readCSV(text: string): DataFrameHelper[] {
this.current = new DataFrameHelper({ fields: [] });
this.data = [this.current];
const papacfg = {
...this.config,
@@ -204,61 +184,11 @@ export class CSVReader {
} as ParseConfig;
Papa.parse(text, papacfg);
return this.data;
}
}
function makeFieldParser(value: string, field: Field): FieldParser {
if (!field.type) {
if (field.name === 'time' || field.name === 'Time') {
field.type = FieldType.time;
} else {
field.type = guessFieldTypeFromValue(value);
}
}
if (field.type === FieldType.number) {
return (value: string) => {
return parseFloat(value);
};
}
// Will convert anything that starts with "T" to true
if (field.type === FieldType.boolean) {
return (value: string) => {
return !(value[0] === 'F' || value[0] === 'f' || value[0] === '0');
};
}
// Just pass the string back
return (value: string) => value;
}
/**
* Creates a field object for each string in the list
*/
function makeFieldsFor(line: string[]): Field[] {
const fields: Field[] = [];
for (let i = 0; i < line.length; i++) {
const v = line[i] ? line[i] : 'Column ' + (i + 1);
fields.push({ name: v });
}
return fields;
}
/**
* Makes sure the colum has valid entries up the the width
*/
function padColumnWidth(fields: Field[], width: number) {
if (fields.length < width) {
for (let i = fields.length; i < width; i++) {
fields.push({
name: 'Field ' + (i + 1),
});
}
}
}
type FieldWriter = (value: any) => string;
function writeValue(value: any, config: CSVConfig): string {
@@ -295,15 +225,26 @@ function makeFieldWriter(field: Field, config: CSVConfig): FieldWriter {
}
function getHeaderLine(key: string, fields: Field[], config: CSVConfig): string {
const isName = 'name' === key;
const isType = 'type' === key;
for (const f of fields) {
if (f.hasOwnProperty(key)) {
const display = f.config;
if (isName || isType || (display && display.hasOwnProperty(key))) {
let line = '#' + key + '#';
for (let i = 0; i < fields.length; i++) {
if (i > 0) {
line = line + config.delimiter;
}
const v = (fields[i] as any)[key];
let v: any = fields[i].name;
if (isType) {
v = fields[i].type;
} else if (isName) {
// already name
} else {
v = (fields[i].config as any)[key];
}
if (v) {
line = line + writeValue(v, config);
}
@@ -329,7 +270,7 @@ export function toCSV(data: DataFrame[], config?: CSVConfig): string {
});
for (const series of data) {
const { rows, fields } = series;
const { fields } = series;
if (config.headerStyle === CSVHeaderStyle.full) {
csv =
csv +
@@ -346,20 +287,22 @@ export function toCSV(data: DataFrame[], config?: CSVConfig): string {
}
csv += config.newline;
}
const writers = fields.map(field => makeFieldWriter(field, config!));
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
for (let j = 0; j < row.length; j++) {
if (j > 0) {
csv = csv + config.delimiter;
}
const length = fields[0].values.length;
if (length > 0) {
const writers = fields.map(field => makeFieldWriter(field, config!));
for (let i = 0; i < length; i++) {
for (let j = 0; j < fields.length; j++) {
if (j > 0) {
csv = csv + config.delimiter;
}
const v = row[j];
if (v !== null) {
csv = csv + writers[j](v);
const v = fields[j].values.get(i);
if (v !== null) {
csv = csv + writers[j](v);
}
}
csv = csv + config.newline;
}
csv = csv + config.newline;
}
csv = csv + config.newline;
}

View File

@@ -0,0 +1,89 @@
import { FieldType, DataFrameDTO, FieldDTO } from '../types/index';
import { DataFrameHelper } from './dataFrameHelper';
describe('dataFrameHelper', () => {
const frame: DataFrameDTO = {
fields: [
{ name: 'time', type: FieldType.time, values: [100, 200, 300] },
{ name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] },
{ name: 'value', type: FieldType.number, values: [1, 2, 3] },
{ name: 'value', type: FieldType.number, values: [4, 5, 6] },
],
};
const ext = new DataFrameHelper(frame);
it('Should get a valid count for the fields', () => {
expect(ext.length).toEqual(3);
});
it('Should get the first field with a duplicate name', () => {
const field = ext.getFieldByName('value');
expect(field!.name).toEqual('value');
expect(field!.values.toJSON()).toEqual([1, 2, 3]);
});
});
describe('FieldCache', () => {
it('when creating a new FieldCache from fields should be able to query cache', () => {
const fields: FieldDTO[] = [
{ name: 'time', type: FieldType.time },
{ name: 'string', type: FieldType.string },
{ name: 'number', type: FieldType.number },
{ name: 'boolean', type: FieldType.boolean },
{ name: 'other', type: FieldType.other },
{ name: 'undefined' },
];
const fieldCache = new DataFrameHelper({ fields });
const allFields = fieldCache.getFields();
expect(allFields).toHaveLength(6);
const expectedFieldNames = ['time', 'string', 'number', 'boolean', 'other', 'undefined'];
expect(allFields.map(f => f.name)).toEqual(expectedFieldNames);
expect(fieldCache.hasFieldOfType(FieldType.time)).toBeTruthy();
expect(fieldCache.hasFieldOfType(FieldType.string)).toBeTruthy();
expect(fieldCache.hasFieldOfType(FieldType.number)).toBeTruthy();
expect(fieldCache.hasFieldOfType(FieldType.boolean)).toBeTruthy();
expect(fieldCache.hasFieldOfType(FieldType.other)).toBeTruthy();
expect(fieldCache.getFields(FieldType.time).map(f => f.name)).toEqual([expectedFieldNames[0]]);
expect(fieldCache.getFields(FieldType.string).map(f => f.name)).toEqual([expectedFieldNames[1]]);
expect(fieldCache.getFields(FieldType.number).map(f => f.name)).toEqual([expectedFieldNames[2]]);
expect(fieldCache.getFields(FieldType.boolean).map(f => f.name)).toEqual([expectedFieldNames[3]]);
expect(fieldCache.getFields(FieldType.other).map(f => f.name)).toEqual([
expectedFieldNames[4],
expectedFieldNames[5],
]);
expect(fieldCache.fields[0].name).toEqual(expectedFieldNames[0]);
expect(fieldCache.fields[1].name).toEqual(expectedFieldNames[1]);
expect(fieldCache.fields[2].name).toEqual(expectedFieldNames[2]);
expect(fieldCache.fields[3].name).toEqual(expectedFieldNames[3]);
expect(fieldCache.fields[4].name).toEqual(expectedFieldNames[4]);
expect(fieldCache.fields[5].name).toEqual(expectedFieldNames[5]);
expect(fieldCache.fields[6]).toBeUndefined();
expect(fieldCache.getFirstFieldOfType(FieldType.time)!.name).toEqual(expectedFieldNames[0]);
expect(fieldCache.getFirstFieldOfType(FieldType.string)!.name).toEqual(expectedFieldNames[1]);
expect(fieldCache.getFirstFieldOfType(FieldType.number)!.name).toEqual(expectedFieldNames[2]);
expect(fieldCache.getFirstFieldOfType(FieldType.boolean)!.name).toEqual(expectedFieldNames[3]);
expect(fieldCache.getFirstFieldOfType(FieldType.other)!.name).toEqual(expectedFieldNames[4]);
expect(fieldCache.hasFieldNamed('tim')).toBeFalsy();
expect(fieldCache.hasFieldNamed('time')).toBeTruthy();
expect(fieldCache.hasFieldNamed('string')).toBeTruthy();
expect(fieldCache.hasFieldNamed('number')).toBeTruthy();
expect(fieldCache.hasFieldNamed('boolean')).toBeTruthy();
expect(fieldCache.hasFieldNamed('other')).toBeTruthy();
expect(fieldCache.hasFieldNamed('undefined')).toBeTruthy();
expect(fieldCache.getFieldByName('time')!.name).toEqual(expectedFieldNames[0]);
expect(fieldCache.getFieldByName('string')!.name).toEqual(expectedFieldNames[1]);
expect(fieldCache.getFieldByName('number')!.name).toEqual(expectedFieldNames[2]);
expect(fieldCache.getFieldByName('boolean')!.name).toEqual(expectedFieldNames[3]);
expect(fieldCache.getFieldByName('other')!.name).toEqual(expectedFieldNames[4]);
expect(fieldCache.getFieldByName('undefined')!.name).toEqual(expectedFieldNames[5]);
expect(fieldCache.getFieldByName('null')).toBeUndefined();
});
});

View File

@@ -0,0 +1,232 @@
import { Field, FieldType, DataFrame, Vector, FieldDTO, DataFrameDTO } from '../types/dataFrame';
import { Labels, QueryResultMeta } from '../types/data';
import { guessFieldTypeForField, guessFieldTypeFromValue } from './processDataFrame';
import { ArrayVector } from './vector';
import isArray from 'lodash/isArray';
export class DataFrameHelper implements DataFrame {
refId?: string;
meta?: QueryResultMeta;
name?: string;
fields: Field[];
labels?: Labels;
length = 0; // updated so it is the length of all fields
private fieldByName: { [key: string]: Field } = {};
private fieldByType: { [key: string]: Field[] } = {};
constructor(data?: DataFrame | DataFrameDTO) {
if (!data) {
data = { fields: [] }; //
}
this.refId = data.refId;
this.meta = data.meta;
this.name = data.name;
this.labels = data.labels;
this.fields = [];
for (let i = 0; i < data.fields.length; i++) {
this.addField(data.fields[i]);
}
}
addFieldFor(value: any, name?: string): Field {
if (!name) {
name = `Field ${this.fields.length + 1}`;
}
return this.addField({
name,
type: guessFieldTypeFromValue(value),
});
}
/**
* Reverse the direction of all fields
*/
reverse() {
for (const f of this.fields) {
if (isArray(f.values)) {
const arr = f.values as any[];
arr.reverse();
}
}
}
private updateTypeIndex(field: Field) {
// Make sure it has a type
if (field.type === FieldType.other) {
const t = guessFieldTypeForField(field);
if (t) {
field.type = t;
}
}
if (!this.fieldByType[field.type]) {
this.fieldByType[field.type] = [];
}
this.fieldByType[field.type].push(field);
}
addField(f: Field | FieldDTO): Field {
const type = f.type || FieldType.other;
const values =
!f.values || isArray(f.values)
? new ArrayVector(f.values as any[] | undefined) // array or empty
: (f.values as Vector);
// And a name
let name = f.name;
if (!name) {
if (type === FieldType.time) {
name = `Time ${this.fields.length + 1}`;
} else {
name = `Column ${this.fields.length + 1}`;
}
}
const field: Field = {
name,
type,
config: f.config || {},
values,
};
this.updateTypeIndex(field);
if (this.fieldByName[field.name]) {
console.warn('Duplicate field names in DataFrame: ', field.name);
} else {
this.fieldByName[field.name] = field;
}
// Make sure the lengths all match
if (field.values.length !== this.length) {
if (field.values.length > this.length) {
// Add `null` to all other values
const newlen = field.values.length;
for (const fx of this.fields) {
const arr = fx.values as ArrayVector;
while (fx.values.length !== newlen) {
arr.buffer.push(null);
}
}
this.length = field.values.length;
} else {
const arr = field.values as ArrayVector;
while (field.values.length !== this.length) {
arr.buffer.push(null);
}
}
}
this.fields.push(field);
return field;
}
/**
* This will add each value to the corresponding column
*/
appendRow(row: any[]) {
for (let i = this.fields.length; i < row.length; i++) {
this.addFieldFor(row[i]);
}
// The first line may change the field types
if (this.length < 1) {
this.fieldByType = {};
for (let i = 0; i < this.fields.length; i++) {
const f = this.fields[i];
if (!f.type || f.type === FieldType.other) {
f.type = guessFieldTypeFromValue(row[i]);
}
this.updateTypeIndex(f);
}
}
for (let i = 0; i < this.fields.length; i++) {
const f = this.fields[i];
let v = row[i];
if (!f.parse) {
f.parse = makeFieldParser(v, f);
}
v = f.parse(v);
const arr = f.values as ArrayVector;
arr.buffer.push(v); // may be undefined
}
this.length++;
}
/**
* Add any values that match the field names
*/
appendRowFrom(obj: { [key: string]: any }) {
for (const f of this.fields) {
const v = obj[f.name];
if (!f.parse) {
f.parse = makeFieldParser(v, f);
}
const arr = f.values as ArrayVector;
arr.buffer.push(f.parse(v)); // may be undefined
}
this.length++;
}
getFields(type?: FieldType): Field[] {
if (!type) {
return [...this.fields]; // All fields
}
const fields = this.fieldByType[type];
if (fields) {
return [...fields];
}
return [];
}
hasFieldOfType(type: FieldType): boolean {
const types = this.fieldByType[type];
return types && types.length > 0;
}
getFirstFieldOfType(type: FieldType): Field | undefined {
const arr = this.fieldByType[type];
if (arr && arr.length > 0) {
return arr[0];
}
return undefined;
}
hasFieldNamed(name: string): boolean {
return !!this.fieldByName[name];
}
/**
* Returns the first field with the given name.
*/
getFieldByName(name: string): Field | undefined {
return this.fieldByName[name];
}
}
function makeFieldParser(value: string, field: Field): (value: string) => any {
if (!field.type) {
if (field.name === 'time' || field.name === 'Time') {
field.type = FieldType.time;
} else {
field.type = guessFieldTypeFromValue(value);
}
}
if (field.type === FieldType.number) {
return (value: string) => {
return parseFloat(value);
};
}
// Will convert anything that starts with "T" to true
if (field.type === FieldType.boolean) {
return (value: string) => {
return !(value[0] === 'F' || value[0] === 'f' || value[0] === '0');
};
}
// Just pass the string back
return (value: string) => value;
}

View File

@@ -0,0 +1,76 @@
import { FieldType, DataFrameDTO } from '../types/index';
import { DataFrameHelper } from './dataFrameHelper';
import { DataFrameView } from './dataFrameView';
import { DateTime } from './moment_wrapper';
interface MySpecialObject {
time: DateTime;
name: string;
value: number;
more: string; // MISSING
}
describe('dataFrameView', () => {
const frame: DataFrameDTO = {
fields: [
{ name: 'time', type: FieldType.time, values: [100, 200, 300] },
{ name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] },
{ name: 'value', type: FieldType.number, values: [1, 2, 3] },
],
};
const ext = new DataFrameHelper(frame);
const vector = new DataFrameView<MySpecialObject>(ext);
it('Should get a typed vector', () => {
expect(vector.length).toEqual(3);
const first = vector.get(0);
expect(first.time).toEqual(100);
expect(first.name).toEqual('a');
expect(first.value).toEqual(1);
expect(first.more).toBeUndefined();
});
it('Should support the spread operator', () => {
expect(vector.length).toEqual(3);
const first = vector.get(0);
const copy = { ...first };
expect(copy.time).toEqual(100);
expect(copy.name).toEqual('a');
expect(copy.value).toEqual(1);
expect(copy.more).toBeUndefined();
});
it('Should support array indexes', () => {
expect(vector.length).toEqual(3);
const first = vector.get(0) as any;
expect(first[0]).toEqual(100);
expect(first[1]).toEqual('a');
expect(first[2]).toEqual(1);
expect(first[3]).toBeUndefined();
});
it('Should advertise the property names for each field', () => {
expect(vector.length).toEqual(3);
const first = vector.get(0);
const keys = Object.keys(first);
expect(keys).toEqual(['time', 'name', 'value']);
});
it('has a weird side effect that the object values change after interation', () => {
expect(vector.length).toEqual(3);
// Get the first value
const first = vector.get(0);
expect(first.name).toEqual('a');
// Then get the second one
const second = vector.get(1);
// the values for 'first' have changed
expect(first.name).toEqual('b');
expect(first.name).toEqual(second.name);
});
});

View File

@@ -0,0 +1,67 @@
import { DataFrame, Vector } from '../types/index';
/**
* This abstraction will present the contents of a DataFrame as if
* it were a well typed javascript object Vector.
*
* NOTE: The contents of the object returned from `view.get(index)`
* are optimized for use in a loop. All calls return the same object
* but the index has changed.
*
* For example, the three objects:
* const first = view.get(0);
* const second = view.get(1);
* const third = view.get(2);
* will point to the contents at index 2
*
* If you need three different objects, consider something like:
* const first = { ... view.get(0) };
* const second = { ... view.get(1) };
* const third = { ... view.get(2) };
*/
export class DataFrameView<T = any> implements Vector<T> {
private index = 0;
private obj: T;
constructor(private data: DataFrame) {
const obj = ({} as unknown) as T;
for (let i = 0; i < data.fields.length; i++) {
const field = data.fields[i];
const getter = () => {
return field.values.get(this.index);
};
if (!(obj as any).hasOwnProperty(field.name)) {
Object.defineProperty(obj, field.name, {
enumerable: true, // Shows up as enumerable property
get: getter,
});
}
Object.defineProperty(obj, i, {
enumerable: false, // Don't enumerate array index
get: getter,
});
}
this.obj = obj;
}
get length() {
return this.data.length;
}
get(idx: number) {
this.index = idx;
return this.obj;
}
toArray(): T[] {
const arr: T[] = [];
for (let i = 0; i < this.data.length; i++) {
arr.push({ ...this.get(i) });
}
return arr;
}
toJSON(): T[] {
return this.toArray();
}
}

View File

@@ -1,71 +0,0 @@
import { FieldType } from '../types/index';
import { FieldCache } from './fieldCache';
describe('FieldCache', () => {
it('when creating a new FieldCache from fields should be able to query cache', () => {
const fields = [
{ name: 'time', type: FieldType.time },
{ name: 'string', type: FieldType.string },
{ name: 'number', type: FieldType.number },
{ name: 'boolean', type: FieldType.boolean },
{ name: 'other', type: FieldType.other },
{ name: 'undefined' },
];
const fieldCache = new FieldCache(fields);
const allFields = fieldCache.getFields();
expect(allFields).toHaveLength(6);
const expectedFields = [
{ ...fields[0], index: 0 },
{ ...fields[1], index: 1 },
{ ...fields[2], index: 2 },
{ ...fields[3], index: 3 },
{ ...fields[4], index: 4 },
{ ...fields[5], type: FieldType.other, index: 5 },
];
expect(allFields).toMatchObject(expectedFields);
expect(fieldCache.hasFieldOfType(FieldType.time)).toBeTruthy();
expect(fieldCache.hasFieldOfType(FieldType.string)).toBeTruthy();
expect(fieldCache.hasFieldOfType(FieldType.number)).toBeTruthy();
expect(fieldCache.hasFieldOfType(FieldType.boolean)).toBeTruthy();
expect(fieldCache.hasFieldOfType(FieldType.other)).toBeTruthy();
expect(fieldCache.getFields(FieldType.time)).toMatchObject([expectedFields[0]]);
expect(fieldCache.getFields(FieldType.string)).toMatchObject([expectedFields[1]]);
expect(fieldCache.getFields(FieldType.number)).toMatchObject([expectedFields[2]]);
expect(fieldCache.getFields(FieldType.boolean)).toMatchObject([expectedFields[3]]);
expect(fieldCache.getFields(FieldType.other)).toMatchObject([expectedFields[4], expectedFields[5]]);
expect(fieldCache.getFieldByIndex(0)).toMatchObject(expectedFields[0]);
expect(fieldCache.getFieldByIndex(1)).toMatchObject(expectedFields[1]);
expect(fieldCache.getFieldByIndex(2)).toMatchObject(expectedFields[2]);
expect(fieldCache.getFieldByIndex(3)).toMatchObject(expectedFields[3]);
expect(fieldCache.getFieldByIndex(4)).toMatchObject(expectedFields[4]);
expect(fieldCache.getFieldByIndex(5)).toMatchObject(expectedFields[5]);
expect(fieldCache.getFieldByIndex(6)).toBeNull();
expect(fieldCache.getFirstFieldOfType(FieldType.time)).toMatchObject(expectedFields[0]);
expect(fieldCache.getFirstFieldOfType(FieldType.string)).toMatchObject(expectedFields[1]);
expect(fieldCache.getFirstFieldOfType(FieldType.number)).toMatchObject(expectedFields[2]);
expect(fieldCache.getFirstFieldOfType(FieldType.boolean)).toMatchObject(expectedFields[3]);
expect(fieldCache.getFirstFieldOfType(FieldType.other)).toMatchObject(expectedFields[4]);
expect(fieldCache.hasFieldNamed('tim')).toBeFalsy();
expect(fieldCache.hasFieldNamed('time')).toBeTruthy();
expect(fieldCache.hasFieldNamed('string')).toBeTruthy();
expect(fieldCache.hasFieldNamed('number')).toBeTruthy();
expect(fieldCache.hasFieldNamed('boolean')).toBeTruthy();
expect(fieldCache.hasFieldNamed('other')).toBeTruthy();
expect(fieldCache.hasFieldNamed('undefined')).toBeTruthy();
expect(fieldCache.getFieldByName('time')).toMatchObject(expectedFields[0]);
expect(fieldCache.getFieldByName('string')).toMatchObject(expectedFields[1]);
expect(fieldCache.getFieldByName('number')).toMatchObject(expectedFields[2]);
expect(fieldCache.getFieldByName('boolean')).toMatchObject(expectedFields[3]);
expect(fieldCache.getFieldByName('other')).toMatchObject(expectedFields[4]);
expect(fieldCache.getFieldByName('undefined')).toMatchObject(expectedFields[5]);
expect(fieldCache.getFieldByName('null')).toBeNull();
});
});

View File

@@ -1,76 +0,0 @@
import { Field, FieldType } from '../types/index';
export interface IndexedField extends Field {
index: number;
}
export class FieldCache {
private fields: Field[];
private fieldIndexByName: { [key: string]: number };
private fieldIndexByType: { [key: string]: number[] };
constructor(fields?: Field[]) {
this.fields = [];
this.fieldIndexByName = {};
this.fieldIndexByType = {};
this.fieldIndexByType[FieldType.time] = [];
this.fieldIndexByType[FieldType.string] = [];
this.fieldIndexByType[FieldType.number] = [];
this.fieldIndexByType[FieldType.boolean] = [];
this.fieldIndexByType[FieldType.other] = [];
if (fields) {
for (let n = 0; n < fields.length; n++) {
const field = fields[n];
this.addField(field);
}
}
}
addField(field: Field) {
this.fields.push({
type: FieldType.other,
...field,
});
const index = this.fields.length - 1;
this.fieldIndexByName[field.name] = index;
this.fieldIndexByType[field.type || FieldType.other].push(index);
}
hasFieldOfType(type: FieldType): boolean {
return this.fieldIndexByType[type] && this.fieldIndexByType[type].length > 0;
}
getFields(type?: FieldType): IndexedField[] {
const fields: IndexedField[] = [];
for (let index = 0; index < this.fields.length; index++) {
const field = this.fields[index];
if (!type || field.type === type) {
fields.push({ ...field, index });
}
}
return fields;
}
getFieldByIndex(index: number): IndexedField | null {
return this.fields[index] ? { ...this.fields[index], index } : null;
}
getFirstFieldOfType(type: FieldType): IndexedField | null {
return this.hasFieldOfType(type)
? { ...this.fields[this.fieldIndexByType[type][0]], index: this.fieldIndexByType[type][0] }
: null;
}
hasFieldNamed(name: string): boolean {
return this.fieldIndexByName[name] !== undefined;
}
getFieldByName(name: string): IndexedField | null {
return this.hasFieldNamed(name)
? { ...this.fields[this.fieldIndexByName[name]], index: this.fieldIndexByName[name] }
: null;
}
}

View File

@@ -1,20 +1,32 @@
import { fieldReducers, ReducerID, reduceField } from './fieldReducer';
import _ from 'lodash';
import { DataFrame } from '../types/data';
import { Field, FieldType } from '../types/index';
import { DataFrameHelper } from './dataFrameHelper';
import { ArrayVector } from './vector';
import { guessFieldTypeFromValue } from './processDataFrame';
/**
* Run a reducer and get back the value
*/
function reduce(series: DataFrame, fieldIndex: number, id: string): any {
return reduceField({ series, fieldIndex, reducers: [id] })[id];
function reduce(field: Field, id: string): any {
return reduceField({ field, reducers: [id] })[id];
}
function createField<T>(name: string, values?: T[], type?: FieldType): Field<T> {
const arr = new ArrayVector(values);
return {
name,
config: {},
type: type ? type : guessFieldTypeFromValue(arr.get(0)),
values: arr,
};
}
describe('Stats Calculators', () => {
const basicTable = {
fields: [{ name: 'a' }, { name: 'b' }, { name: 'c' }],
rows: [[10, 20, 30], [20, 30, 40]],
};
const basicTable = new DataFrameHelper({
fields: [{ name: 'a', values: [10, 20] }, { name: 'b', values: [20, 30] }, { name: 'c', values: [30, 40] }],
});
it('should load all standard stats', () => {
for (const id of Object.keys(ReducerID)) {
@@ -38,8 +50,7 @@ describe('Stats Calculators', () => {
it('should calculate basic stats', () => {
const stats = reduceField({
series: basicTable,
fieldIndex: 0,
field: basicTable.fields[0],
reducers: ['first', 'last', 'mean'],
});
@@ -54,9 +65,9 @@ describe('Stats Calculators', () => {
});
it('should support a single stat also', () => {
basicTable.fields[0].calcs = undefined; // clear the cache
const stats = reduceField({
series: basicTable,
fieldIndex: 0,
field: basicTable.fields[0],
reducers: ['first'],
});
@@ -67,8 +78,7 @@ describe('Stats Calculators', () => {
it('should get non standard stats', () => {
const stats = reduceField({
series: basicTable,
fieldIndex: 0,
field: basicTable.fields[0],
reducers: [ReducerID.distinctCount, ReducerID.changeCount],
});
@@ -78,8 +88,7 @@ describe('Stats Calculators', () => {
it('should calculate step', () => {
const stats = reduceField({
series: { fields: [{ name: 'A' }], rows: [[100], [200], [300], [400]] },
fieldIndex: 0,
field: createField('x', [100, 200, 300, 400]),
reducers: [ReducerID.step, ReducerID.delta],
});
@@ -88,53 +97,38 @@ describe('Stats Calculators', () => {
});
it('consistenly check allIsNull/allIsZero', () => {
const empty = {
fields: [{ name: 'A' }],
rows: [],
};
const allNull = ({
fields: [{ name: 'A' }],
rows: [null, null, null, null],
} as unknown) as DataFrame;
const allNull2 = {
fields: [{ name: 'A' }],
rows: [[null], [null], [null], [null]],
};
const allZero = {
fields: [{ name: 'A' }],
rows: [[0], [0], [0], [0]],
};
const empty = createField('x');
const allNull = createField('x', [null, null, null, null]);
const allUndefined = createField('x', [undefined, undefined, undefined, undefined]);
const allZero = createField('x', [0, 0, 0, 0]);
expect(reduce(empty, 0, ReducerID.allIsNull)).toEqual(true);
expect(reduce(allNull, 0, ReducerID.allIsNull)).toEqual(true);
expect(reduce(allNull2, 0, ReducerID.allIsNull)).toEqual(true);
expect(reduce(empty, ReducerID.allIsNull)).toEqual(true);
expect(reduce(allNull, ReducerID.allIsNull)).toEqual(true);
expect(reduce(allUndefined, ReducerID.allIsNull)).toEqual(true);
expect(reduce(empty, 0, ReducerID.allIsZero)).toEqual(false);
expect(reduce(allNull, 0, ReducerID.allIsZero)).toEqual(false);
expect(reduce(allNull2, 0, ReducerID.allIsZero)).toEqual(false);
expect(reduce(allZero, 0, ReducerID.allIsZero)).toEqual(true);
expect(reduce(empty, ReducerID.allIsZero)).toEqual(false);
expect(reduce(allNull, ReducerID.allIsZero)).toEqual(false);
expect(reduce(allZero, ReducerID.allIsZero)).toEqual(true);
});
it('consistent results for first/last value with null', () => {
const info = [
{
rows: [[null], [200], [null]], // first/last value is null
data: [null, 200, null], // first/last value is null
result: 200,
},
{
rows: [[null], [null], [null]], // All null
data: [null, null, null], // All null
result: undefined,
},
{
rows: [], // Empty row
data: [undefined, undefined, undefined], // Empty row
result: undefined,
},
];
const fields = [{ name: 'A' }];
const stats = reduceField({
series: { rows: info[0].rows, fields },
fieldIndex: 0,
field: createField('x', info[0].data),
reducers: [ReducerID.first, ReducerID.last, ReducerID.firstNotNull, ReducerID.lastNotNull], // uses standard path
});
expect(stats[ReducerID.first]).toEqual(null);
@@ -146,21 +140,19 @@ describe('Stats Calculators', () => {
for (const input of info) {
for (const reducer of reducers) {
const v1 = reduceField({
series: { rows: input.rows, fields },
fieldIndex: 0,
field: createField('x', input.data),
reducers: [reducer, ReducerID.mean], // uses standard path
})[reducer];
const v2 = reduceField({
series: { rows: input.rows, fields },
fieldIndex: 0,
field: createField('x', input.data),
reducers: [reducer], // uses optimized path
})[reducer];
if (v1 !== v2 || v1 !== input.result) {
const msg =
`Invalid ${reducer} result for: ` +
input.rows.join(', ') +
input.data.join(', ') +
` Expected: ${input.result}` + // configured
` Recieved: Multiple: ${v1}, Single: ${v2}`;
expect(msg).toEqual(null);

View File

@@ -1,7 +1,7 @@
// Libraries
import isNumber from 'lodash/isNumber';
import { DataFrame, NullValueMode } from '../types';
import { NullValueMode, Field } from '../types';
import { Registry, RegistryItem } from './registry';
export enum ReducerID {
@@ -33,7 +33,7 @@ export interface FieldCalcs {
}
// Internal function
type FieldReducer = (data: DataFrame, fieldIndex: number, ignoreNulls: boolean, nullAsZero: boolean) => FieldCalcs;
type FieldReducer = (field: Field, ignoreNulls: boolean, nullAsZero: boolean) => FieldCalcs;
export interface FieldReducerInfo extends RegistryItem {
// Internal details
@@ -43,52 +43,76 @@ export interface FieldReducerInfo extends RegistryItem {
}
interface ReduceFieldOptions {
series: DataFrame;
fieldIndex: number;
field: Field;
reducers: string[]; // The stats to calculate
nullValueMode?: NullValueMode;
}
/**
* @returns an object with a key for each selected stat
*/
export function reduceField(options: ReduceFieldOptions): FieldCalcs {
const { series, fieldIndex, reducers, nullValueMode } = options;
const { field, reducers } = options;
if (!reducers || reducers.length < 1) {
if (!field || !reducers || reducers.length < 1) {
return {};
}
if (field.calcs) {
// Find the values we need to calculate
const missing: string[] = [];
for (const s of reducers) {
if (!field.calcs.hasOwnProperty(s)) {
missing.push(s);
}
}
if (missing.length < 1) {
return {
...field.calcs,
};
}
}
const queue = fieldReducers.list(reducers);
// Return early for empty series
// This lets the concrete implementations assume at least one row
if (!series.rows || series.rows.length < 1) {
const calcs = {} as FieldCalcs;
const data = field.values;
if (data.length < 1) {
const calcs = { ...field.calcs } as FieldCalcs;
for (const reducer of queue) {
calcs[reducer.id] = reducer.emptyInputResult !== null ? reducer.emptyInputResult : null;
}
return calcs;
return (field.calcs = calcs);
}
const { nullValueMode } = field.config;
const ignoreNulls = nullValueMode === NullValueMode.Ignore;
const nullAsZero = nullValueMode === NullValueMode.AsZero;
// Avoid calculating all the standard stats if possible
if (queue.length === 1 && queue[0].reduce) {
return queue[0].reduce(series, fieldIndex, ignoreNulls, nullAsZero);
const values = queue[0].reduce(field, ignoreNulls, nullAsZero);
field.calcs = {
...field.calcs,
...values,
};
return values;
}
// For now everything can use the standard stats
let values = doStandardCalcs(series, fieldIndex, ignoreNulls, nullAsZero);
let values = doStandardCalcs(field, ignoreNulls, nullAsZero);
for (const reducer of queue) {
if (!values.hasOwnProperty(reducer.id) && reducer.reduce) {
values = {
...values,
...reducer.reduce(series, fieldIndex, ignoreNulls, nullAsZero),
...reducer.reduce(field, ignoreNulls, nullAsZero),
};
}
}
field.calcs = {
...field.calcs,
...values,
};
return values;
}
@@ -200,7 +224,7 @@ export const fieldReducers = new Registry<FieldReducerInfo>(() => [
},
]);
function doStandardCalcs(data: DataFrame, fieldIndex: number, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs {
function doStandardCalcs(field: Field, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs {
const calcs = {
sum: 0,
max: -Number.MAX_VALUE,
@@ -223,9 +247,10 @@ function doStandardCalcs(data: DataFrame, fieldIndex: number, ignoreNulls: boole
// Just used for calcutations -- not exposed as a stat
previousDeltaUp: true,
} as FieldCalcs;
const data = field.values;
for (let i = 0; i < data.rows.length; i++) {
let currentValue = data.rows[i] ? data.rows[i][fieldIndex] : null;
for (let i = 0; i < data.length; i++) {
let currentValue = data.get(i);
if (i === 0) {
calcs.first = currentValue;
}
@@ -260,7 +285,7 @@ function doStandardCalcs(data: DataFrame, fieldIndex: number, ignoreNulls: boole
if (calcs.lastNotNull! > currentValue) {
// counter reset
calcs.previousDeltaUp = false;
if (i === data.rows.length - 1) {
if (i === data.length - 1) {
// reset on last
calcs.delta += currentValue;
}
@@ -326,18 +351,14 @@ function doStandardCalcs(data: DataFrame, fieldIndex: number, ignoreNulls: boole
return calcs;
}
function calculateFirst(data: DataFrame, fieldIndex: number, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs {
return { first: data.rows[0][fieldIndex] };
function calculateFirst(field: Field, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs {
return { first: field.values.get(0) };
}
function calculateFirstNotNull(
data: DataFrame,
fieldIndex: number,
ignoreNulls: boolean,
nullAsZero: boolean
): FieldCalcs {
for (let idx = 0; idx < data.rows.length; idx++) {
const v = data.rows[idx][fieldIndex];
function calculateFirstNotNull(field: Field, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs {
const data = field.values;
for (let idx = 0; idx < data.length; idx++) {
const v = data.get(idx);
if (v != null) {
return { firstNotNull: v };
}
@@ -345,19 +366,16 @@ function calculateFirstNotNull(
return { firstNotNull: undefined };
}
function calculateLast(data: DataFrame, fieldIndex: number, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs {
return { last: data.rows[data.rows.length - 1][fieldIndex] };
function calculateLast(field: Field, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs {
const data = field.values;
return { last: data.get(data.length - 1) };
}
function calculateLastNotNull(
data: DataFrame,
fieldIndex: number,
ignoreNulls: boolean,
nullAsZero: boolean
): FieldCalcs {
let idx = data.rows.length - 1;
function calculateLastNotNull(field: Field, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs {
const data = field.values;
let idx = data.length - 1;
while (idx >= 0) {
const v = data.rows[idx--][fieldIndex];
const v = data.get(idx--);
if (v != null) {
return { lastNotNull: v };
}
@@ -365,17 +383,13 @@ function calculateLastNotNull(
return { lastNotNull: undefined };
}
function calculateChangeCount(
data: DataFrame,
fieldIndex: number,
ignoreNulls: boolean,
nullAsZero: boolean
): FieldCalcs {
function calculateChangeCount(field: Field, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs {
const data = field.values;
let count = 0;
let first = true;
let last: any = null;
for (let i = 0; i < data.rows.length; i++) {
let currentValue = data.rows[i][fieldIndex];
for (let i = 0; i < data.length; i++) {
let currentValue = data.get(i);
if (currentValue === null) {
if (ignoreNulls) {
continue;
@@ -394,15 +408,11 @@ function calculateChangeCount(
return { changeCount: count };
}
function calculateDistinctCount(
data: DataFrame,
fieldIndex: number,
ignoreNulls: boolean,
nullAsZero: boolean
): FieldCalcs {
function calculateDistinctCount(field: Field, ignoreNulls: boolean, nullAsZero: boolean): FieldCalcs {
const data = field.values;
const distinct = new Set<any>();
for (let i = 0; i < data.rows.length; i++) {
let currentValue = data.rows[i][fieldIndex];
for (let i = 0; i < data.length; i++) {
let currentValue = data.get(i);
if (currentValue === null) {
if (ignoreNulls) {
continue;

View File

@@ -8,9 +8,11 @@ export * from './logs';
export * from './labels';
export * from './labels';
export * from './object';
export * from './fieldCache';
export * from './moment_wrapper';
export * from './thresholds';
export * from './dataFrameHelper';
export * from './dataFrameView';
export * from './vector';
export { getMappedValue } from './valueMappings';

View File

@@ -1,5 +1,6 @@
import { LogLevel } from '../types/logs';
import { DataFrame, FieldType } from '../types/data';
import { DataFrame, FieldType } from '../types/index';
import { ArrayVector } from './vector';
/**
* Returns the log level of a log line.
@@ -33,12 +34,23 @@ export function getLogLevelFromKey(key: string): LogLevel {
}
export function addLogLevelToSeries(series: DataFrame, lineIndex: number): DataFrame {
const levels = new ArrayVector<LogLevel>();
const lines = series.fields[lineIndex];
for (let i = 0; i < lines.values.length; i++) {
const line = lines.values.get(lineIndex);
levels.buffer.push(getLogLevel(line));
}
return {
...series, // Keeps Tags, RefID etc
fields: [...series.fields, { name: 'LogLevel', type: FieldType.string }],
rows: series.rows.map(row => {
const line = row[lineIndex];
return [...row, getLogLevel(line)];
}),
fields: [
...series.fields,
{
name: 'LogLevel',
type: FieldType.string,
values: levels,
config: {},
},
],
};
}

View File

@@ -5,9 +5,11 @@ import {
toDataFrame,
guessFieldTypes,
guessFieldTypeFromValue,
sortDataFrame,
} from './processDataFrame';
import { FieldType, TimeSeries, DataFrame, TableData } from '../types/data';
import { FieldType, TimeSeries, TableData, DataFrameDTO } from '../types/index';
import { dateTime } from './moment_wrapper';
import { DataFrameHelper } from './dataFrameHelper';
describe('toDataFrame', () => {
it('converts timeseries to series', () => {
@@ -17,7 +19,15 @@ describe('toDataFrame', () => {
};
let series = toDataFrame(input1);
expect(series.fields[0].name).toBe(input1.target);
expect(series.rows).toBe(input1.datapoints);
const v0 = series.fields[0].values;
const v1 = series.fields[1].values;
expect(v0.length).toEqual(2);
expect(v1.length).toEqual(2);
expect(v0.get(0)).toEqual(100);
expect(v0.get(1)).toEqual(200);
expect(v1.get(0)).toEqual(1);
expect(v1.get(1)).toEqual(2);
// Should fill a default name if target is empty
const input2 = {
@@ -39,12 +49,23 @@ describe('toDataFrame', () => {
});
it('keeps dataFrame unchanged', () => {
const input = {
fields: [{ text: 'A' }, { text: 'B' }, { text: 'C' }],
const input = toDataFrame({
datapoints: [[100, 1], [200, 2]],
});
expect(input.length).toEqual(2);
// If the object is alreay a DataFrame, it should not change
const again = toDataFrame(input);
expect(again).toBe(input);
});
it('migrate from 6.3 style rows', () => {
const oldDataFrame = {
fields: [{ name: 'A' }, { name: 'B' }, { name: 'C' }],
rows: [[100, 'A', 1], [200, 'B', 2], [300, 'C', 3]],
};
const series = toDataFrame(input);
expect(series).toBe(input);
const data = toDataFrame(oldDataFrame);
expect(data.length).toBe(oldDataFrame.rows.length);
});
it('Guess Colum Types from value', () => {
@@ -68,14 +89,18 @@ describe('toDataFrame', () => {
});
it('Guess Colum Types from series', () => {
const series = {
fields: [{ name: 'A (number)' }, { name: 'B (strings)' }, { name: 'C (nulls)' }, { name: 'Time' }],
rows: [[123, null, null, '2000'], [null, 'Hello', null, 'XXX']],
};
const series = new DataFrameHelper({
fields: [
{ name: 'A (number)', values: [123, null] },
{ name: 'B (strings)', values: [null, 'Hello'] },
{ name: 'C (nulls)', values: [null, null] },
{ name: 'Time', values: ['2000', 1967] },
],
});
const norm = guessFieldTypes(series);
expect(norm.fields[0].type).toBe(FieldType.number);
expect(norm.fields[1].type).toBe(FieldType.string);
expect(norm.fields[2].type).toBeUndefined();
expect(norm.fields[2].type).toBe(FieldType.other);
expect(norm.fields[3].type).toBe(FieldType.time); // based on name
});
});
@@ -103,6 +128,7 @@ describe('SerisData backwards compatibility', () => {
const series = toDataFrame(table);
expect(isTableData(table)).toBeTruthy();
expect(isDataFrame(series)).toBeTruthy();
expect(series.fields[0].config.unit).toEqual('ms');
const roundtrip = toLegacyResponseData(series) as TimeSeries;
expect(isTableData(roundtrip)).toBeTruthy();
@@ -110,23 +136,46 @@ describe('SerisData backwards compatibility', () => {
});
it('converts DataFrame to TableData to series and back again', () => {
const series: DataFrame = {
const json: DataFrameDTO = {
refId: 'Z',
meta: {
somethign: 8,
},
fields: [
{ name: 'T', type: FieldType.time }, // first
{ name: 'N', type: FieldType.number, filterable: true },
{ name: 'S', type: FieldType.string, filterable: true },
{ name: 'T', type: FieldType.time, values: [1, 2, 3] },
{ name: 'N', type: FieldType.number, config: { filterable: true }, values: [100, 200, 300] },
{ name: 'S', type: FieldType.string, config: { filterable: true }, values: ['1', '2', '3'] },
],
rows: [[1, 100, '1'], [2, 200, '2'], [3, 300, '3']],
};
const series = toDataFrame(json);
const table = toLegacyResponseData(series) as TableData;
expect(table.meta).toBe(series.meta);
expect(table.refId).toBe(series.refId);
expect(table.meta).toEqual(series.meta);
const names = table.columns.map(c => c.text);
expect(names).toEqual(['T', 'N', 'S']);
});
});
describe('sorted DataFrame', () => {
const frame = toDataFrame({
fields: [
{ name: 'fist', type: FieldType.time, values: [1, 2, 3] },
{ name: 'second', type: FieldType.string, values: ['a', 'b', 'c'] },
{ name: 'third', type: FieldType.number, values: [2000, 3000, 1000] },
],
});
it('Should sort numbers', () => {
const sorted = sortDataFrame(frame, 0, true);
expect(sorted.length).toEqual(3);
expect(sorted.fields[0].values.toJSON()).toEqual([3, 2, 1]);
expect(sorted.fields[1].values.toJSON()).toEqual(['c', 'b', 'a']);
});
it('Should sort strings', () => {
const sorted = sortDataFrame(frame, 1, true);
expect(sorted.length).toEqual(3);
expect(sorted.fields[0].values.toJSON()).toEqual([3, 2, 1]);
expect(sorted.fields[1].values.toJSON()).toEqual(['c', 'b', 'a']);
});
});

View File

@@ -4,61 +4,122 @@ import isString from 'lodash/isString';
import isBoolean from 'lodash/isBoolean';
// Types
import { DataFrame, Field, TimeSeries, FieldType, TableData, Column, GraphSeriesXY } from '../types/index';
import {
DataFrame,
Field,
FieldConfig,
TimeSeries,
FieldType,
TableData,
Column,
GraphSeriesXY,
TimeSeriesValue,
FieldDTO,
DataFrameDTO,
} from '../types/index';
import { isDateTime } from './moment_wrapper';
import { ArrayVector, SortedVector } from './vector';
import { DataFrameHelper } from './dataFrameHelper';
function convertTableToDataFrame(table: TableData): DataFrame {
const fields = table.columns.map(c => {
const { text, ...disp } = c;
return {
name: text, // rename 'text' to the 'name' field
config: (disp || {}) as FieldConfig,
values: new ArrayVector(),
type: FieldType.other,
};
});
// Fill in the field values
for (const row of table.rows) {
for (let i = 0; i < fields.length; i++) {
fields[i].values.buffer.push(row[i]);
}
}
for (const f of fields) {
const t = guessFieldTypeForField(f);
if (t) {
f.type = t;
}
}
return {
// rename the 'text' to 'name' field
fields: table.columns.map(c => {
const { text, ...field } = c;
const f = field as Field;
f.name = text;
return f;
}),
rows: table.rows,
fields,
refId: table.refId,
meta: table.meta,
name: table.name,
length: fields[0].values.length,
};
}
function convertTimeSeriesToDataFrame(timeSeries: TimeSeries): DataFrame {
return {
name: timeSeries.target,
fields: [
{
name: timeSeries.target || 'Value',
type: FieldType.number,
const fields = [
{
name: timeSeries.target || 'Value',
type: FieldType.number,
config: {
unit: timeSeries.unit,
},
{
name: 'Time',
type: FieldType.time,
values: new ArrayVector<TimeSeriesValue>(),
},
{
name: 'Time',
type: FieldType.time,
config: {
unit: 'dateTimeAsIso',
},
],
rows: timeSeries.datapoints,
values: new ArrayVector<number>(),
},
];
for (const point of timeSeries.datapoints) {
fields[0].values.buffer.push(point[0]);
fields[1].values.buffer.push(point[1]);
}
return {
name: timeSeries.target,
labels: timeSeries.tags,
refId: timeSeries.refId,
meta: timeSeries.meta,
fields,
length: timeSeries.datapoints.length,
};
}
/**
* This is added temporarily while we convert the LogsModel
* to DataFrame. See: https://github.com/grafana/grafana/issues/18528
*/
function convertGraphSeriesToDataFrame(graphSeries: GraphSeriesXY): DataFrame {
const x = new ArrayVector();
const y = new ArrayVector();
for (let i = 0; i < graphSeries.data.length; i++) {
const row = graphSeries.data[i];
x.buffer.push(row[0]);
y.buffer.push(row[1]);
}
return {
name: graphSeries.label,
fields: [
{
name: graphSeries.label || 'Value',
type: FieldType.number,
config: {},
values: x,
},
{
name: 'Time',
type: FieldType.time,
unit: 'dateTimeAsIso',
config: {
unit: 'dateTimeAsIso',
},
values: y,
},
],
rows: graphSeries.data,
length: x.buffer.length,
};
}
@@ -102,20 +163,18 @@ export function guessFieldTypeFromValue(v: any): FieldType {
/**
* Looks at the data to guess the column type. This ignores any existing setting
*/
export function guessFieldTypeFromSeries(series: DataFrame, index: number): FieldType | undefined {
const column = series.fields[index];
export function guessFieldTypeForField(field: Field): FieldType | undefined {
// 1. Use the column name to guess
if (column.name) {
const name = column.name.toLowerCase();
if (field.name) {
const name = field.name.toLowerCase();
if (name === 'date' || name === 'time') {
return FieldType.time;
}
}
// 2. Check the first non-null value
for (let i = 0; i < series.rows.length; i++) {
const v = series.rows[i][index];
for (let i = 0; i < field.values.length; i++) {
const v = field.values.get(i);
if (v !== null) {
return guessFieldTypeFromValue(v);
}
@@ -135,14 +194,14 @@ export const guessFieldTypes = (series: DataFrame): DataFrame => {
// Somethign is missing a type return a modified copy
return {
...series,
fields: series.fields.map((field, index) => {
if (field.type) {
fields: series.fields.map(field => {
if (field.type && field.type !== FieldType.other) {
return field;
}
// Replace it with a calculated version
// Calculate a reasonable schema value
return {
...field,
type: guessFieldTypeFromSeries(series, index),
type: guessFieldTypeForField(field) || FieldType.other,
};
}),
};
@@ -158,7 +217,22 @@ export const isDataFrame = (data: any): data is DataFrame => data && data.hasOwn
export const toDataFrame = (data: any): DataFrame => {
if (data.hasOwnProperty('fields')) {
return data as DataFrame;
// @deprecated -- remove in 6.5
if (data.hasOwnProperty('rows')) {
const v = new DataFrameHelper(data as DataFrameDTO);
const rows = data.rows as any[][];
for (let i = 0; i < rows.length; i++) {
v.appendRow(rows[i]);
}
// TODO: deprection warning
return v;
}
// DataFrameDTO does not have length
if (data.hasOwnProperty('length')) {
return data as DataFrame;
}
return new DataFrameHelper(data as DataFrameDTO);
}
if (data.hasOwnProperty('datapoints')) {
return convertTimeSeriesToDataFrame(data);
@@ -174,52 +248,129 @@ export const toDataFrame = (data: any): DataFrame => {
throw new Error('Unsupported data format');
};
export const toLegacyResponseData = (series: DataFrame): TimeSeries | TableData => {
const { fields, rows } = series;
export const toLegacyResponseData = (frame: DataFrame): TimeSeries | TableData => {
const { fields } = frame;
const length = fields[0].values.length;
const rows: any[][] = [];
for (let i = 0; i < length; i++) {
const row: any[] = [];
for (let j = 0; j < fields.length; j++) {
row.push(fields[j].values.get(i));
}
rows.push(row);
}
if (fields.length === 2) {
const type = guessFieldTypeFromSeries(series, 1);
let type = fields[1].type;
if (!type) {
type = guessFieldTypeForField(fields[1]) || FieldType.other;
}
if (type === FieldType.time) {
return {
alias: fields[0].name || series.name,
target: fields[0].name || series.name,
alias: fields[0].name || frame.name,
target: fields[0].name || frame.name,
datapoints: rows,
unit: fields[0].unit,
refId: series.refId,
meta: series.meta,
unit: fields[0].config ? fields[0].config.unit : undefined,
refId: frame.refId,
meta: frame.meta,
} as TimeSeries;
}
}
return {
columns: fields.map(f => {
const { name, ...column } = f;
(column as Column).text = name;
return column as Column;
const { name, config } = f;
if (config) {
// keep unit etc
const { ...column } = config;
(column as Column).text = name;
return column as Column;
}
return { text: name };
}),
refId: series.refId,
meta: series.meta,
refId: frame.refId,
meta: frame.meta,
rows,
};
};
export function sortDataFrame(data: DataFrame, sortIndex?: number, reverse = false): DataFrame {
if (isNumber(sortIndex)) {
const copy = {
...data,
rows: [...data.rows].sort((a, b) => {
a = a[sortIndex];
b = b[sortIndex];
// Sort null or undefined separately from comparable values
return +(a == null) - +(b == null) || +(a > b) || -(a < b);
}),
};
if (reverse) {
copy.rows.reverse();
}
return copy;
const field = data.fields[sortIndex!];
if (!field) {
return data;
}
return data;
// Natural order
const index: number[] = [];
for (let i = 0; i < data.length; i++) {
index.push(i);
}
const values = field.values;
// Numeric Comparison
let compare = (a: number, b: number) => {
const vA = values.get(a);
const vB = values.get(b);
return vA - vB; // works for numbers!
};
// String Comparison
if (field.type === FieldType.string) {
compare = (a: number, b: number) => {
const vA: string = values.get(a);
const vB: string = values.get(b);
return vA.localeCompare(vB);
};
}
// Run the sort function
index.sort(compare);
if (reverse) {
index.reverse();
}
// Return a copy that maps sorted values
return {
...data,
fields: data.fields.map(f => {
return {
...f,
values: new SortedVector(f.values, index),
};
}),
};
}
/**
* Wrapper to get an array from each field value
*/
export function getDataFrameRow(data: DataFrame, row: number): any[] {
const values: any[] = [];
for (const field of data.fields) {
values.push(field.values.get(row));
}
return values;
}
/**
* Returns a copy that does not include functions
*/
export function toDataFrameDTO(data: DataFrame): DataFrameDTO {
const fields: FieldDTO[] = data.fields.map(f => {
return {
name: f.name,
type: f.type,
config: f.config,
values: f.values.toJSON(),
};
});
return {
fields,
refId: data.refId,
meta: data.meta,
name: data.name,
labels: data.labels,
};
}

View File

@@ -0,0 +1,43 @@
import { ConstantVector, ScaledVector, ArrayVector, CircularVector } from './vector';
describe('Check Proxy Vector', () => {
it('should support constant values', () => {
const value = 3.5;
const v = new ConstantVector(value, 7);
expect(v.length).toEqual(7);
expect(v.get(0)).toEqual(value);
expect(v.get(1)).toEqual(value);
// Now check all of them
for (let i = 0; i < 10; i++) {
expect(v.get(i)).toEqual(value);
}
});
it('should support multiply operations', () => {
const source = new ArrayVector([1, 2, 3, 4]);
const scale = 2.456;
const v = new ScaledVector(source, scale);
expect(v.length).toEqual(source.length);
// expect(v.push(10)).toEqual(source.length); // not implemented
for (let i = 0; i < 10; i++) {
expect(v.get(i)).toEqual(source.get(i) * scale);
}
});
});
describe('Check Circular Vector', () => {
it('should support constant values', () => {
const buffer = [3, 2, 1, 0];
const v = new CircularVector(buffer);
expect(v.length).toEqual(4);
expect(v.toJSON()).toEqual([3, 2, 1, 0]);
v.append(4);
expect(v.toJSON()).toEqual([4, 3, 2, 1]);
v.append(5);
expect(v.toJSON()).toEqual([5, 4, 3, 2]);
});
});

View File

@@ -0,0 +1,133 @@
import { Vector } from '../types/dataFrame';
export function vectorToArray<T>(v: Vector<T>): T[] {
const arr: T[] = [];
for (let i = 0; i < v.length; i++) {
arr[i] = v.get(i);
}
return arr;
}
export class ArrayVector<T = any> implements Vector<T> {
buffer: T[];
constructor(buffer?: T[]) {
this.buffer = buffer ? buffer : [];
}
get length() {
return this.buffer.length;
}
get(index: number): T {
return this.buffer[index];
}
toArray(): T[] {
return this.buffer;
}
toJSON(): T[] {
return this.buffer;
}
}
export class ConstantVector<T = any> implements Vector<T> {
constructor(private value: T, private len: number) {}
get length() {
return this.len;
}
get(index: number): T {
return this.value;
}
toArray(): T[] {
const arr: T[] = [];
for (let i = 0; i < this.length; i++) {
arr[i] = this.value;
}
return arr;
}
toJSON(): T[] {
return this.toArray();
}
}
export class ScaledVector implements Vector<number> {
constructor(private source: Vector<number>, private scale: number) {}
get length(): number {
return this.source.length;
}
get(index: number): number {
return this.source.get(index) * this.scale;
}
toArray(): number[] {
return vectorToArray(this);
}
toJSON(): number[] {
return vectorToArray(this);
}
}
export class CircularVector<T = any> implements Vector<T> {
buffer: T[];
index: number;
length: number;
constructor(buffer: T[]) {
this.length = buffer.length;
this.buffer = buffer;
this.index = 0;
}
append(value: T) {
let idx = this.index - 1;
if (idx < 0) {
idx = this.length - 1;
}
this.buffer[idx] = value;
this.index = idx;
}
get(index: number): T {
return this.buffer[(index + this.index) % this.length];
}
toArray(): T[] {
return vectorToArray(this);
}
toJSON(): T[] {
return vectorToArray(this);
}
}
/**
* Values are returned in the order defined by the input parameter
*/
export class SortedVector<T = any> implements Vector<T> {
constructor(private source: Vector<T>, private order: number[]) {}
get length(): number {
return this.source.length;
}
get(index: number): T {
return this.source.get(this.order[index]);
}
toArray(): T[] {
return vectorToArray(this);
}
toJSON(): T[] {
return vectorToArray(this);
}
}