mirror of https://github.com/grafana/grafana
Feat: More robust csv support (#16170)
* stream csv * merged master * merged master * fix test failures * add csv files * update boolean parsing * add toCSV * add toCSV * add toCSV * add streaming datasource * set time range * streaming to a graph * streaming datasource * streaming table * add server to the streaming * remove react streamingpull/16039/head
parent
749c76f2a1
commit
0112bdeb6a
@ -0,0 +1,119 @@ |
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP |
||||
|
||||
exports[`read csv should get X and y 1`] = ` |
||||
Object { |
||||
"fields": Array [ |
||||
Object { |
||||
"name": "Column 1", |
||||
"type": "number", |
||||
}, |
||||
Object { |
||||
"name": "Column 2", |
||||
"type": "number", |
||||
}, |
||||
Object { |
||||
"name": "Column 3", |
||||
"type": "number", |
||||
}, |
||||
Object { |
||||
"name": "Field 4", |
||||
"type": "number", |
||||
}, |
||||
], |
||||
"rows": Array [ |
||||
Array [ |
||||
2, |
||||
3, |
||||
4, |
||||
null, |
||||
], |
||||
Array [ |
||||
5, |
||||
6, |
||||
null, |
||||
null, |
||||
], |
||||
Array [ |
||||
null, |
||||
null, |
||||
null, |
||||
7, |
||||
], |
||||
], |
||||
} |
||||
`; |
||||
|
||||
exports[`read csv should read csv from local file system 1`] = ` |
||||
Object { |
||||
"fields": Array [ |
||||
Object { |
||||
"name": "a", |
||||
"type": "number", |
||||
}, |
||||
Object { |
||||
"name": "b", |
||||
"type": "number", |
||||
}, |
||||
Object { |
||||
"name": "c", |
||||
"type": "number", |
||||
}, |
||||
], |
||||
"rows": Array [ |
||||
Array [ |
||||
10, |
||||
20, |
||||
30, |
||||
], |
||||
Array [ |
||||
40, |
||||
50, |
||||
60, |
||||
], |
||||
], |
||||
} |
||||
`; |
||||
|
||||
exports[`read csv should read csv with headers 1`] = ` |
||||
Object { |
||||
"fields": Array [ |
||||
Object { |
||||
"name": "a", |
||||
"type": "number", |
||||
"unit": "ms", |
||||
}, |
||||
Object { |
||||
"name": "b", |
||||
"type": "string", |
||||
"unit": "lengthm", |
||||
}, |
||||
Object { |
||||
"name": "c", |
||||
"type": "boolean", |
||||
"unit": "s", |
||||
}, |
||||
], |
||||
"rows": Array [ |
||||
Array [ |
||||
10, |
||||
"20", |
||||
true, |
||||
], |
||||
Array [ |
||||
40, |
||||
"50", |
||||
false, |
||||
], |
||||
Array [ |
||||
40, |
||||
"500", |
||||
false, |
||||
], |
||||
Array [ |
||||
40, |
||||
"50", |
||||
true, |
||||
], |
||||
], |
||||
} |
||||
`; |
||||
@ -1,62 +0,0 @@ |
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP |
||||
|
||||
exports[`processSeriesData basic processing should generate a header and fix widths 1`] = ` |
||||
Object { |
||||
"fields": Array [ |
||||
Object { |
||||
"name": "Field 1", |
||||
}, |
||||
Object { |
||||
"name": "Field 2", |
||||
}, |
||||
Object { |
||||
"name": "Field 3", |
||||
}, |
||||
], |
||||
"rows": Array [ |
||||
Array [ |
||||
1, |
||||
null, |
||||
null, |
||||
], |
||||
Array [ |
||||
2, |
||||
3, |
||||
4, |
||||
], |
||||
Array [ |
||||
5, |
||||
6, |
||||
null, |
||||
], |
||||
], |
||||
} |
||||
`; |
||||
|
||||
exports[`processSeriesData basic processing should read header and two rows 1`] = ` |
||||
Object { |
||||
"fields": Array [ |
||||
Object { |
||||
"name": "a", |
||||
}, |
||||
Object { |
||||
"name": "b", |
||||
}, |
||||
Object { |
||||
"name": "c", |
||||
}, |
||||
], |
||||
"rows": Array [ |
||||
Array [ |
||||
1, |
||||
2, |
||||
3, |
||||
], |
||||
Array [ |
||||
4, |
||||
5, |
||||
6, |
||||
], |
||||
], |
||||
} |
||||
`; |
||||
@ -0,0 +1,68 @@ |
||||
import { readCSV, toCSV, CSVHeaderStyle } from './csv'; |
||||
|
||||
// Test with local CSV files
|
||||
const fs = require('fs'); |
||||
|
||||
describe('read csv', () => { |
||||
it('should get X and y', () => { |
||||
const text = ',1\n2,3,4\n5,6\n,,,7'; |
||||
const data = readCSV(text); |
||||
expect(data.length).toBe(1); |
||||
|
||||
const series = data[0]; |
||||
expect(series.fields.length).toBe(4); |
||||
expect(series.rows.length).toBe(3); |
||||
|
||||
// Make sure everythign it padded properly
|
||||
for (const row of series.rows) { |
||||
expect(row.length).toBe(series.fields.length); |
||||
} |
||||
|
||||
expect(series).toMatchSnapshot(); |
||||
}); |
||||
|
||||
it('should read csv from local file system', () => { |
||||
const path = __dirname + '/testdata/simple.csv'; |
||||
expect(fs.existsSync(path)).toBeTruthy(); |
||||
|
||||
const csv = fs.readFileSync(path, 'utf8'); |
||||
const data = readCSV(csv); |
||||
expect(data.length).toBe(1); |
||||
expect(data[0]).toMatchSnapshot(); |
||||
}); |
||||
|
||||
it('should read csv with headers', () => { |
||||
const path = __dirname + '/testdata/withHeaders.csv'; |
||||
expect(fs.existsSync(path)).toBeTruthy(); |
||||
|
||||
const csv = fs.readFileSync(path, 'utf8'); |
||||
const data = readCSV(csv); |
||||
expect(data.length).toBe(1); |
||||
expect(data[0]).toMatchSnapshot(); |
||||
}); |
||||
}); |
||||
|
||||
function norm(csv: string): string { |
||||
return csv.trim().replace(/[\r]/g, ''); |
||||
} |
||||
|
||||
describe('write csv', () => { |
||||
it('should write the same CSV that we read', () => { |
||||
const path = __dirname + '/testdata/roundtrip.csv'; |
||||
const csv = fs.readFileSync(path, 'utf8'); |
||||
const data = readCSV(csv); |
||||
const out = toCSV(data, { headerStyle: CSVHeaderStyle.full }); |
||||
expect(data.length).toBe(1); |
||||
expect(data[0].fields.length).toBe(3); |
||||
expect(norm(out)).toBe(norm(csv)); |
||||
|
||||
// Keep the name even without special formatting
|
||||
const again = readCSV(out); |
||||
const shorter = toCSV(again, { headerStyle: CSVHeaderStyle.name }); |
||||
|
||||
const f = readCSV(shorter); |
||||
const fields = f[0].fields; |
||||
expect(fields.length).toBe(3); |
||||
expect(fields.map(f => f.name).join(',')).toEqual('a,b,c'); // the names
|
||||
}); |
||||
}); |
||||
@ -0,0 +1,365 @@ |
||||
// Libraries
|
||||
import Papa, { ParseResult, ParseConfig, Parser } from 'papaparse'; |
||||
import defaults from 'lodash/defaults'; |
||||
import isNumber from 'lodash/isNumber'; |
||||
|
||||
// Types
|
||||
import { SeriesData, Field, FieldType } from '../types/index'; |
||||
import { guessFieldTypeFromValue } from './processSeriesData'; |
||||
|
||||
export enum CSVHeaderStyle { |
||||
full, |
||||
name, |
||||
none, |
||||
} |
||||
|
||||
// Subset of all parse options
|
||||
export interface CSVConfig { |
||||
delimiter?: string; // default: ","
|
||||
newline?: string; // default: "\r\n"
|
||||
quoteChar?: string; // default: '"'
|
||||
encoding?: string; // default: "",
|
||||
headerStyle?: CSVHeaderStyle; |
||||
} |
||||
|
||||
export interface CSVParseCallbacks { |
||||
/** |
||||
* Get a callback before any rows are processed |
||||
* This can return a modified table to force any |
||||
* Column configurations |
||||
*/ |
||||
onHeader: (table: SeriesData) => void; |
||||
|
||||
// Called after each row is read and
|
||||
onRow: (row: any[]) => void; |
||||
} |
||||
|
||||
export interface CSVOptions { |
||||
config?: CSVConfig; |
||||
callback?: CSVParseCallbacks; |
||||
} |
||||
|
||||
export function readCSV(csv: string, options?: CSVOptions): SeriesData[] { |
||||
return new CSVReader(options).readCSV(csv); |
||||
} |
||||
|
||||
enum ParseState { |
||||
Starting, |
||||
InHeader, |
||||
ReadingRows, |
||||
} |
||||
|
||||
type FieldParser = (value: string) => any; |
||||
|
||||
export class CSVReader { |
||||
config: CSVConfig; |
||||
callback?: CSVParseCallbacks; |
||||
|
||||
field: FieldParser[]; |
||||
series: SeriesData; |
||||
state: ParseState; |
||||
data: SeriesData[]; |
||||
|
||||
constructor(options?: CSVOptions) { |
||||
if (!options) { |
||||
options = {}; |
||||
} |
||||
this.config = options.config || {}; |
||||
this.callback = options.callback; |
||||
|
||||
this.field = []; |
||||
this.state = ParseState.Starting; |
||||
this.series = { |
||||
fields: [], |
||||
rows: [], |
||||
}; |
||||
this.data = []; |
||||
} |
||||
|
||||
// PapaParse callback on each line
|
||||
private step = (results: ParseResult, parser: Parser): void => { |
||||
for (let i = 0; i < results.data.length; i++) { |
||||
const line: string[] = results.data[i]; |
||||
if (line.length < 1) { |
||||
continue; |
||||
} |
||||
const first = line[0]; // null or value, papaparse does not return ''
|
||||
if (first) { |
||||
// Comment or header queue
|
||||
if (first.startsWith('#')) { |
||||
// Look for special header column
|
||||
// #{columkey}#a,b,c
|
||||
const idx = first.indexOf('#', 2); |
||||
if (idx > 0) { |
||||
const k = first.substr(1, idx - 1); |
||||
|
||||
// Simple object used to check if headers match
|
||||
const headerKeys: Field = { |
||||
name: '#', |
||||
type: FieldType.number, |
||||
unit: '#', |
||||
dateFormat: '#', |
||||
}; |
||||
|
||||
// Check if it is a known/supported column
|
||||
if (headerKeys.hasOwnProperty(k)) { |
||||
// Starting a new table after reading rows
|
||||
if (this.state === ParseState.ReadingRows) { |
||||
this.series = { |
||||
fields: [], |
||||
rows: [], |
||||
}; |
||||
this.data.push(this.series); |
||||
} |
||||
|
||||
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]; |
||||
} |
||||
this.state = ParseState.InHeader; |
||||
continue; |
||||
} |
||||
} else if (this.state === ParseState.Starting) { |
||||
this.series.fields = makeFieldsFor(line); |
||||
this.state = ParseState.InHeader; |
||||
continue; |
||||
} |
||||
// Ignore comment lines
|
||||
continue; |
||||
} |
||||
|
||||
if (this.state === ParseState.Starting) { |
||||
const type = guessFieldTypeFromValue(first); |
||||
if (type === FieldType.string) { |
||||
this.series.fields = makeFieldsFor(line); |
||||
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; |
||||
} |
||||
|
||||
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); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
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); |
||||
} |
||||
} |
||||
|
||||
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); |
||||
} |
||||
} |
||||
} |
||||
}; |
||||
|
||||
readCSV(text: string): SeriesData[] { |
||||
this.data = [this.series]; |
||||
|
||||
const papacfg = { |
||||
...this.config, |
||||
dynamicTyping: false, |
||||
skipEmptyLines: true, |
||||
comments: false, // Keep comment lines
|
||||
step: this.step, |
||||
} 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 { |
||||
const str = value.toString(); |
||||
if (str.includes('"')) { |
||||
// Escape the double quote characters
|
||||
return config.quoteChar + str.replace('"', '""') + config.quoteChar; |
||||
} |
||||
if (str.includes('\n') || str.includes(config.delimiter)) { |
||||
return config.quoteChar + str + config.quoteChar; |
||||
} |
||||
return str; |
||||
} |
||||
|
||||
function makeFieldWriter(field: Field, config: CSVConfig): FieldWriter { |
||||
if (field.type) { |
||||
if (field.type === FieldType.boolean) { |
||||
return (value: any) => { |
||||
return value ? 'true' : 'false'; |
||||
}; |
||||
} |
||||
|
||||
if (field.type === FieldType.number) { |
||||
return (value: any) => { |
||||
if (isNumber(value)) { |
||||
return value.toString(); |
||||
} |
||||
return writeValue(value, config); |
||||
}; |
||||
} |
||||
} |
||||
|
||||
return (value: any) => writeValue(value, config); |
||||
} |
||||
|
||||
function getHeaderLine(key: string, fields: Field[], config: CSVConfig): string { |
||||
for (const f of fields) { |
||||
if (f.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]; |
||||
if (v) { |
||||
line = line + writeValue(v, config); |
||||
} |
||||
} |
||||
return line + config.newline; |
||||
} |
||||
} |
||||
return ''; |
||||
} |
||||
|
||||
export function toCSV(data: SeriesData[], config?: CSVConfig): string { |
||||
let csv = ''; |
||||
config = defaults(config, { |
||||
delimiter: ',', |
||||
newline: '\r\n', |
||||
quoteChar: '"', |
||||
encoding: '', |
||||
headerStyle: CSVHeaderStyle.name, |
||||
}); |
||||
|
||||
for (const series of data) { |
||||
const { rows, fields } = series; |
||||
if (config.headerStyle === CSVHeaderStyle.full) { |
||||
csv = |
||||
csv + |
||||
getHeaderLine('name', fields, config) + |
||||
getHeaderLine('type', fields, config) + |
||||
getHeaderLine('unit', fields, config) + |
||||
getHeaderLine('dateFormat', fields, config); |
||||
} else if (config.headerStyle === CSVHeaderStyle.name) { |
||||
for (let i = 0; i < fields.length; i++) { |
||||
if (i > 0) { |
||||
csv += config.delimiter; |
||||
} |
||||
csv += fields[i].name; |
||||
} |
||||
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 v = row[j]; |
||||
if (v !== null) { |
||||
csv = csv + writers[j](v); |
||||
} |
||||
} |
||||
csv = csv + config.newline; |
||||
} |
||||
csv = csv + config.newline; |
||||
} |
||||
|
||||
return csv; |
||||
} |
||||
|
|
|
Loading…
Reference in new issue