Skip to content

Commit

Permalink
Don't detect time fields from Unix epoch
Browse files Browse the repository at this point in the history
Fixes #82
  • Loading branch information
marcusolsson committed Apr 7, 2021
1 parent c5975d7 commit a35ebfe
Show file tree
Hide file tree
Showing 5 changed files with 102 additions and 105 deletions.
105 changes: 2 additions & 103 deletions src/datasource.ts
@@ -1,5 +1,4 @@
import _ from 'lodash';
import { isValid, parseISO } from 'date-fns';
import { JSONPath } from 'jsonpath-plus';

import {
Expand All @@ -9,13 +8,14 @@ import {
DataSourceInstanceSettings,
toDataFrame,
MetricFindValue,
FieldType,
ScopedVars,
TimeRange,
} from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime';

import API from './api';
import { detectFieldType } from './detectFieldType';
import { parseValues } from './parseValues';
import { JsonApiQuery, JsonApiDataSourceOptions, Pair } from './types';

export class JsonDataSource extends DataSourceApi<JsonApiQuery, JsonApiDataSourceOptions> {
Expand Down Expand Up @@ -176,104 +176,3 @@ const replaceMacros = (str: string, range?: TimeRange) => {
.replace(/\$__unixEpochTo\(\)/g, range.to.unix().toString())
: str;
};

/**
* Detects the field type from an array of values.
*/
export const detectFieldType = (values: any[]): FieldType => {
// If all values are null, default to strings.
if (values.every((_) => _ === null)) {
return FieldType.string;
}

// If all values are valid ISO 8601, then assume that it's a time field.
const isValidISO = values
.filter((value) => value !== null)
.every((value) => value.length >= 10 && isValid(parseISO(value)));
if (isValidISO) {
return FieldType.time;
}

if (values.every((value) => typeof value === 'number')) {
const uniqueLengths = Array.from(new Set(values.map((value) => Math.round(value).toString().length)));
const hasSameLength = uniqueLengths.length === 1;

// If all the values have the same length of either 10 (seconds) or 13
// (milliseconds), assume it's a time field. This is not always true, so we
// might need to add an option to disable detection of time fields.
if (hasSameLength) {
if (uniqueLengths[0] === 13) {
return FieldType.time;
}
if (uniqueLengths[0] === 10) {
return FieldType.time;
}
}

return FieldType.number;
}

if (values.every((value) => typeof value === 'boolean')) {
return FieldType.boolean;
}

return FieldType.string;
};

/**
* parseValues converts values to the given field type.
*/
export const parseValues = (values: any[], type: FieldType): any[] => {
switch (type) {
case FieldType.time:
// For time field, values are expected to be numbers representing a Unix
// epoch in milliseconds.

if (values.filter((_) => _).every((value) => typeof value === 'string')) {
return values.map((_) => (_ !== null ? parseISO(_).valueOf() : _));
}

if (values.filter((_) => _).every((value) => typeof value === 'number')) {
const ms = 1_000_000_000_000;

// If there are no "big" numbers, assume seconds.
if (values.filter((_) => _).every((_) => _ < ms)) {
return values.map((_) => (_ !== null ? _ * 1000.0 : _));
}

// ... otherwise assume milliseconds.
return values;
}

throw new Error('Unsupported time property');
case FieldType.string:
return values.every((_) => typeof _ === 'string') ? values : values.map((_) => (_ !== null ? _.toString() : _));
case FieldType.number:
return values.every((_) => typeof _ === 'number') ? values : values.map((_) => (_ !== null ? parseFloat(_) : _));
case FieldType.boolean:
return values.every((_) => typeof _ === 'boolean')
? values
: values.map((_) => {
if (_ === null) {
return _;
}

switch (_.toString()) {
case '0':
case 'false':
case 'FALSE':
case 'False':
return false;
case '1':
case 'true':
case 'TRUE':
case 'True':
return true;
default:
throw new Error('Found non-boolean values in a field of type boolean: ' + _.toString());
}
});
default:
throw new Error('Unsupported field type');
}
};
10 changes: 9 additions & 1 deletion src/detectFieldType.test.ts
@@ -1,4 +1,4 @@
import { detectFieldType } from './datasource';
import { detectFieldType } from './detectFieldType';

test('years and months gets parsed as string to reduce false positives', () => {
expect(detectFieldType(['2005', '2006'])).toStrictEqual('string');
Expand All @@ -9,6 +9,14 @@ test('iso8601 date without time zone gets parsed as time', () => {
expect(detectFieldType(['2005-01-02', '2006-01-02'])).toStrictEqual('time');
});

test('unix epoch in seconds gets parsed as number', () => {
expect(detectFieldType([1617774880])).toStrictEqual('number');
});

test('unix epoch in milliseconds gets parsed as number', () => {
expect(detectFieldType([1617774880000])).toStrictEqual('number');
});

test('iso8601 gets parsed as time', () => {
expect(detectFieldType(['2006-01-02T15:06:13Z', '2006-01-02T15:07:13Z'])).toStrictEqual('time');
});
Expand Down
30 changes: 30 additions & 0 deletions src/detectFieldType.ts
@@ -0,0 +1,30 @@
import { isValid, parseISO } from 'date-fns';
import { FieldType } from '@grafana/data';

/**
* Detects the field type from an array of values.
*/
export const detectFieldType = (values: any[]): FieldType => {
// If all values are null, default to strings.
if (values.every((_) => _ === null)) {
return FieldType.string;
}

// If all values are valid ISO 8601, then assume that it's a time field.
const isValidISO = values
.filter((value) => value !== null)
.every((value) => value.length >= 10 && isValid(parseISO(value)));
if (isValidISO) {
return FieldType.time;
}

if (values.every((value) => typeof value === 'number')) {
return FieldType.number;
}

if (values.every((value) => typeof value === 'boolean')) {
return FieldType.boolean;
}

return FieldType.string;
};
2 changes: 1 addition & 1 deletion src/parseValues.test.ts
@@ -1,5 +1,5 @@
import { FieldType } from '@grafana/data';
import { parseValues } from './datasource';
import { parseValues } from './parseValues';

test('parse numbers', () => {
const values = [2005, 2006];
Expand Down
60 changes: 60 additions & 0 deletions src/parseValues.ts
@@ -0,0 +1,60 @@
import { parseISO } from 'date-fns';
import { FieldType } from '@grafana/data';

/**
* parseValues converts values to the given field type.
*/
export const parseValues = (values: any[], type: FieldType): any[] => {
switch (type) {
case FieldType.time:
// For time field, values are expected to be numbers representing a Unix
// epoch in milliseconds.

if (values.filter((_) => _).every((value) => typeof value === 'string')) {
return values.map((_) => (_ !== null ? parseISO(_).valueOf() : _));
}

if (values.filter((_) => _).every((value) => typeof value === 'number')) {
const ms = 1_000_000_000_000;

// If there are no "big" numbers, assume seconds.
if (values.filter((_) => _).every((_) => _ < ms)) {
return values.map((_) => (_ !== null ? _ * 1000.0 : _));
}

// ... otherwise assume milliseconds.
return values;
}

throw new Error('Unsupported time property');
case FieldType.string:
return values.every((_) => typeof _ === 'string') ? values : values.map((_) => (_ !== null ? _.toString() : _));
case FieldType.number:
return values.every((_) => typeof _ === 'number') ? values : values.map((_) => (_ !== null ? parseFloat(_) : _));
case FieldType.boolean:
return values.every((_) => typeof _ === 'boolean')
? values
: values.map((_) => {
if (_ === null) {
return _;
}

switch (_.toString()) {
case '0':
case 'false':
case 'FALSE':
case 'False':
return false;
case '1':
case 'true':
case 'TRUE':
case 'True':
return true;
default:
throw new Error('Found non-boolean values in a field of type boolean: ' + _.toString());
}
});
default:
throw new Error('Unsupported field type');
}
};

0 comments on commit a35ebfe

Please sign in to comment.