Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add type configuration for queries #37

Merged
merged 1 commit into from
Jan 8, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ This section lists the available configuration options for the JSON API data sou
| **Query string** | Overrides the custom query parameters configured by the data source. |
| **Cache Time** | Determines the time in seconds to save the API response. |
| **Query** | Defines the [JSON Path](https://goessner.net/articles/JsonPath/) used to extract the field. |
| **Type** | Defines the type of the values returned by the JSON Path query. |

### Variables

Expand Down
27 changes: 25 additions & 2 deletions src/components/QueryEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import defaults from 'lodash/defaults';
import React from 'react';
import { Icon, InlineFieldRow, InlineField, Segment, Input } from '@grafana/ui';
import { QueryEditorProps } from '@grafana/data';
import { Icon, InlineFieldRow, InlineField, Segment, Input, Select } from '@grafana/ui';
import { QueryEditorProps, SelectableValue, FieldType } from '@grafana/data';
import { DataSource } from '../datasource';
import { JsonApiDataSourceOptions, JsonApiQuery, defaultQuery } from '../types';
import { JsonPathQueryField } from './JsonPathQueryField';
Expand All @@ -16,6 +16,12 @@ export const QueryEditor: React.FC<Props> = ({ onRunQuery, onChange, query }) =>
onChange({ ...query, fields });
};

const onChangeType = (i: number) => (e: SelectableValue<string>) => {
fields[i] = { ...fields[i], type: (e.value === 'auto' ? undefined : e.value) as FieldType };
onChange({ ...query, fields });
onRunQuery();
};

const addField = (i: number) => () => {
if (fields) {
fields.splice(i + 1, 0, { name: '', jsonPath: '' });
Expand Down Expand Up @@ -86,6 +92,23 @@ export const QueryEditor: React.FC<Props> = ({ onRunQuery, onChange, query }) =>
>
<JsonPathQueryField onBlur={onRunQuery} onChange={onChangePath(index)} query={field.jsonPath} />
</InlineField>
<InlineField
label="Type"
tooltip="If Auto is set, the JSON property type is used to detect the field type."
>
<Select
value={field.type ?? 'auto'}
width={12}
onChange={onChangeType(index)}
options={[
{ label: 'Auto', value: 'auto' },
{ label: 'String', value: 'string' },
{ label: 'Number', value: 'number' },
{ label: 'Time', value: 'time' },
{ label: 'Boolean', value: 'boolean' },
]}
/>
</InlineField>
<a className="gf-form-label" onClick={addField(index)}>
<Icon name="plus" />
</a>
Expand Down
94 changes: 73 additions & 21 deletions src/datasource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,13 @@ export class DataSource extends DataSourceApi<JsonApiQuery, JsonApiDataSourceOpt
// Casted to any due to typing issues with JSONPath-Plus
const paths = (JSONPath as any).toPathArray(jsonPathTreated);

const [type, newvals] = detectFieldType(values);
const propertyType = field.type ? field.type : detectFieldType(values);
const typedValues = parseValues(values, propertyType);

return {
name: nameTreated || paths[paths.length - 1],
type: type,
values: newvals,
type: propertyType,
values: typedValues,
};
});

Expand Down Expand Up @@ -147,47 +148,98 @@ export class DataSource extends DataSourceApi<JsonApiQuery, JsonApiDataSourceOpt
}

/**
* Detects the type of the values, and converts values if necessary.
*
* @param values - The field values.
* @returns the detected field type and potentially converted values.
* Detects the field type from an array of values.
*/
export const detectFieldType = (values: any[]): [FieldType, any[]] => {
export const detectFieldType = (values: any[]): FieldType => {
// If all values are null, default to strings.
if (values.every(_ => _ === null)) {
return [FieldType.string, values];
return FieldType.string;
}
// If all values are valid ISO 8601, the assume that it's a time field.

// 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, values.map(_ => (_ !== null ? parseISO(_).valueOf() : null))];
return FieldType.time;
}

const isNumber = values.every(value => typeof value === 'number');
if (isNumber) {
const uniqueLengths = Array.from(new Set(values.map(value => value.toString().length)));
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, values];
return FieldType.time;
}
if (uniqueLengths[0] === 10) {
return [FieldType.time, values.map(_ => _ * 1000.0)];
return FieldType.time;
}
}

return [FieldType.number, values];
return FieldType.number;
}

const isBoolean = values.every(value => typeof value === 'boolean');
if (isBoolean) {
return [FieldType.boolean, values];
if (values.every(value => typeof value === 'boolean')) {
return FieldType.boolean;
}

return [FieldType.string, values];
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.every(value => typeof value === 'string')) {
return values.map(_ => (_ !== null ? parseISO(_).valueOf() : null));
}

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

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

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

throw new Error('Unsupported time property');
case FieldType.string:
return values.every(_ => typeof _ === 'string') ? values : values.map(_ => _.toString());
case FieldType.number:
return values.every(_ => typeof _ === 'number') ? values : values.map(_ => parseFloat(_));
case FieldType.boolean:
return values.every(_ => typeof _ === 'boolean')
? values
: values.map(_ => {
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');
}
});
default:
throw new Error('Unsupported field type');
}
};
38 changes: 14 additions & 24 deletions src/detectFieldType.test.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,36 @@
import { detectFieldType } from './datasource';
import { format } from 'date-fns';

test('years and months gets parsed as string to reduce false positives', () => {
expect(detectFieldType(['2005', '2006'])).toEqual(['string', ['2005', '2006']]);
expect(detectFieldType(['2005-01', '2006-01'])).toEqual(['string', ['2005-01', '2006-01']]);
expect(detectFieldType(['2005', '2006'])).toStrictEqual('string');
expect(detectFieldType(['2005-01', '2006-01'])).toStrictEqual('string');
});

test('iso8601 date without time zone gets parsed as time', () => {
const input = ['2005-01-02', '2006-01-02'];
const res = detectFieldType(input);

expect(res[0]).toStrictEqual('time');

// Since the input doesn't have a time zone, the resulting timestamps are in
// local time. For now, test that we can parse and format it to input values.
expect(res[1].map(_ => format(_, 'yyyy-MM-dd'))).toStrictEqual(input);
expect(detectFieldType(['2005-01-02', '2006-01-02'])).toStrictEqual('time');
});

test('iso8601 gets parsed as time', () => {
expect(detectFieldType(['2006-01-02T15:06:13Z', '2006-01-02T15:07:13Z'])).toEqual([
'time',
[1136214373000, 1136214433000],
]);
expect(detectFieldType(['2006-01-02T15:06:13Z', '2006-01-02T15:07:13Z'])).toStrictEqual('time');
});

test('nullable iso8601 gets parsed as time', () => {
expect(detectFieldType(['2006-01-02T15:06:13Z', null])).toEqual(['time', [1136214373000, null]]);
expect(detectFieldType(['2006-01-02T15:06:13Z', null])).toStrictEqual('time');
});

test('all zeros gets parsed as number', () => {
expect(detectFieldType([0, 0, 0])).toEqual(['number', [0, 0, 0]]);
expect(detectFieldType([0, 0, 1])).toEqual(['number', [0, 0, 1]]);
test('floating-point numbers with string length 13 get parsed as number', () => {
expect(detectFieldType([12.0000000003, 72.0000000001])).toStrictEqual('number');
});

expect(detectFieldType([false, false, false])).toEqual(['boolean', [false, false, false]]);
expect(detectFieldType([false, false, true])).toEqual(['boolean', [false, false, true]]);
test('all zeros gets parsed as number', () => {
expect(detectFieldType([0, 0, 0])).toStrictEqual('number');
expect(detectFieldType([0, 0, 1])).toStrictEqual('number');
});

test('all false gets parsed as boolean', () => {
expect(detectFieldType([false, false, false])).toEqual(['boolean', [false, false, false]]);
expect(detectFieldType([false, false, true])).toEqual(['boolean', [false, false, true]]);
expect(detectFieldType([false, false, false])).toStrictEqual('boolean');
expect(detectFieldType([false, false, true])).toStrictEqual('boolean');
});

test('all null gets parsed as string', () => {
expect(detectFieldType([null, null])).toEqual(['string', [null, null]]);
expect(detectFieldType([null, null])).toStrictEqual('string');
});
42 changes: 42 additions & 0 deletions src/parseValues.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { FieldType } from '@grafana/data';
import { parseValues } from './datasource';

test('parse numbers', () => {
const values = [2005, 2006];

expect(() => parseValues(values, FieldType.boolean)).toThrow();
expect(parseValues(values, FieldType.number)).toStrictEqual([2005, 2006]);
expect(parseValues(values, FieldType.string)).toStrictEqual(['2005', '2006']);

// Numbers are assumed to be epoch time in seconds.
expect(parseValues(values, FieldType.time)).toStrictEqual([2005000, 2006000]);
});

test('parse numbers from strings', () => {
const values = ['2005', '2006'];

expect(() => parseValues(values, FieldType.boolean)).toThrow();
expect(parseValues(values, FieldType.number)).toStrictEqual([2005, 2006]);
expect(parseValues(values, FieldType.string)).toStrictEqual(['2005', '2006']);

// Values get parsed as ISO 8601 strings.
expect(parseValues(values, FieldType.time)).toStrictEqual([1104534000000, 1136070000000]);
});

test('parse booleans', () => {
const values = [false, true, false];

expect(parseValues(values, FieldType.boolean)).toStrictEqual([false, true, false]);
expect(parseValues(values, FieldType.number)).toStrictEqual([NaN, NaN, NaN]);
expect(parseValues(values, FieldType.string)).toStrictEqual(['false', 'true', 'false']);
expect(() => parseValues(values, FieldType.time)).toThrow();
});

test('parse booleans from strings', () => {
const values = ['false', 'true', 'false'];

expect(parseValues(values, FieldType.boolean)).toStrictEqual([false, true, false]);
expect(parseValues(values, FieldType.number)).toStrictEqual([NaN, NaN, NaN]);
expect(parseValues(values, FieldType.string)).toStrictEqual(['false', 'true', 'false']);
expect(parseValues(values, FieldType.time)).toStrictEqual([NaN, NaN, NaN]);
});
3 changes: 2 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { DataQuery, DataSourceJsonData } from '@grafana/data';
import { DataQuery, DataSourceJsonData, FieldType } from '@grafana/data';

interface JsonField {
name: string;
jsonPath: string;
type?: FieldType;
}

export interface JsonApiQuery extends DataQuery {
Expand Down