Skip to content

Commit bc18e30

Browse files
authored
Add type configuration for queries (#37)
1 parent 9aad631 commit bc18e30

File tree

6 files changed

+157
-48
lines changed

6 files changed

+157
-48
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ This section lists the available configuration options for the JSON API data sou
2424
| **Query string** | Overrides the custom query parameters configured by the data source. |
2525
| **Cache Time** | Determines the time in seconds to save the API response. |
2626
| **Query** | Defines the [JSON Path](https://goessner.net/articles/JsonPath/) used to extract the field. |
27+
| **Type** | Defines the type of the values returned by the JSON Path query. |
2728

2829
### Variables
2930

src/components/QueryEditor.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import defaults from 'lodash/defaults';
22
import React from 'react';
3-
import { Icon, InlineFieldRow, InlineField, Segment, Input } from '@grafana/ui';
4-
import { QueryEditorProps } from '@grafana/data';
3+
import { Icon, InlineFieldRow, InlineField, Segment, Input, Select } from '@grafana/ui';
4+
import { QueryEditorProps, SelectableValue, FieldType } from '@grafana/data';
55
import { DataSource } from '../datasource';
66
import { JsonApiDataSourceOptions, JsonApiQuery, defaultQuery } from '../types';
77
import { JsonPathQueryField } from './JsonPathQueryField';
@@ -16,6 +16,12 @@ export const QueryEditor: React.FC<Props> = ({ onRunQuery, onChange, query }) =>
1616
onChange({ ...query, fields });
1717
};
1818

19+
const onChangeType = (i: number) => (e: SelectableValue<string>) => {
20+
fields[i] = { ...fields[i], type: (e.value === 'auto' ? undefined : e.value) as FieldType };
21+
onChange({ ...query, fields });
22+
onRunQuery();
23+
};
24+
1925
const addField = (i: number) => () => {
2026
if (fields) {
2127
fields.splice(i + 1, 0, { name: '', jsonPath: '' });
@@ -86,6 +92,23 @@ export const QueryEditor: React.FC<Props> = ({ onRunQuery, onChange, query }) =>
8692
>
8793
<JsonPathQueryField onBlur={onRunQuery} onChange={onChangePath(index)} query={field.jsonPath} />
8894
</InlineField>
95+
<InlineField
96+
label="Type"
97+
tooltip="If Auto is set, the JSON property type is used to detect the field type."
98+
>
99+
<Select
100+
value={field.type ?? 'auto'}
101+
width={12}
102+
onChange={onChangeType(index)}
103+
options={[
104+
{ label: 'Auto', value: 'auto' },
105+
{ label: 'String', value: 'string' },
106+
{ label: 'Number', value: 'number' },
107+
{ label: 'Time', value: 'time' },
108+
{ label: 'Boolean', value: 'boolean' },
109+
]}
110+
/>
111+
</InlineField>
89112
<a className="gf-form-label" onClick={addField(index)}>
90113
<Icon name="plus" />
91114
</a>

src/datasource.ts

Lines changed: 73 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,13 @@ export class DataSource extends DataSourceApi<JsonApiQuery, JsonApiDataSourceOpt
5252
// Casted to any due to typing issues with JSONPath-Plus
5353
const paths = (JSONPath as any).toPathArray(jsonPathTreated);
5454

55-
const [type, newvals] = detectFieldType(values);
55+
const propertyType = field.type ? field.type : detectFieldType(values);
56+
const typedValues = parseValues(values, propertyType);
5657

5758
return {
5859
name: nameTreated || paths[paths.length - 1],
59-
type: type,
60-
values: newvals,
60+
type: propertyType,
61+
values: typedValues,
6162
};
6263
});
6364

@@ -147,47 +148,98 @@ export class DataSource extends DataSourceApi<JsonApiQuery, JsonApiDataSourceOpt
147148
}
148149

149150
/**
150-
* Detects the type of the values, and converts values if necessary.
151-
*
152-
* @param values - The field values.
153-
* @returns the detected field type and potentially converted values.
151+
* Detects the field type from an array of values.
154152
*/
155-
export const detectFieldType = (values: any[]): [FieldType, any[]] => {
153+
export const detectFieldType = (values: any[]): FieldType => {
154+
// If all values are null, default to strings.
156155
if (values.every(_ => _ === null)) {
157-
return [FieldType.string, values];
156+
return FieldType.string;
158157
}
159-
// If all values are valid ISO 8601, the assume that it's a time field.
158+
159+
// If all values are valid ISO 8601, then assume that it's a time field.
160160
const isValidISO = values
161161
.filter(value => value !== null)
162162
.every(value => value.length >= 10 && isValid(parseISO(value)));
163163
if (isValidISO) {
164-
return [FieldType.time, values.map(_ => (_ !== null ? parseISO(_).valueOf() : null))];
164+
return FieldType.time;
165165
}
166166

167-
const isNumber = values.every(value => typeof value === 'number');
168-
if (isNumber) {
169-
const uniqueLengths = Array.from(new Set(values.map(value => value.toString().length)));
167+
if (values.every(value => typeof value === 'number')) {
168+
const uniqueLengths = Array.from(new Set(values.map(value => Math.round(value).toString().length)));
170169
const hasSameLength = uniqueLengths.length === 1;
171170

172171
// If all the values have the same length of either 10 (seconds) or 13
173172
// (milliseconds), assume it's a time field. This is not always true, so we
174173
// might need to add an option to disable detection of time fields.
175174
if (hasSameLength) {
176175
if (uniqueLengths[0] === 13) {
177-
return [FieldType.time, values];
176+
return FieldType.time;
178177
}
179178
if (uniqueLengths[0] === 10) {
180-
return [FieldType.time, values.map(_ => _ * 1000.0)];
179+
return FieldType.time;
181180
}
182181
}
183182

184-
return [FieldType.number, values];
183+
return FieldType.number;
185184
}
186185

187-
const isBoolean = values.every(value => typeof value === 'boolean');
188-
if (isBoolean) {
189-
return [FieldType.boolean, values];
186+
if (values.every(value => typeof value === 'boolean')) {
187+
return FieldType.boolean;
190188
}
191189

192-
return [FieldType.string, values];
190+
return FieldType.string;
191+
};
192+
193+
/**
194+
* parseValues converts values to the given field type.
195+
*/
196+
export const parseValues = (values: any[], type: FieldType): any[] => {
197+
switch (type) {
198+
case FieldType.time:
199+
// For time field, values are expected to be numbers representing a Unix
200+
// epoch in milliseconds.
201+
202+
if (values.every(value => typeof value === 'string')) {
203+
return values.map(_ => (_ !== null ? parseISO(_).valueOf() : null));
204+
}
205+
206+
if (values.every(value => typeof value === 'number')) {
207+
const ms = 1_000_000_000_000;
208+
209+
// If there are no "big" numbers, assume seconds.
210+
if (values.every(_ => _ < ms)) {
211+
return values.map(_ => _ * 1000.0);
212+
}
213+
214+
// ... otherwise assume milliseconds.
215+
return values;
216+
}
217+
218+
throw new Error('Unsupported time property');
219+
case FieldType.string:
220+
return values.every(_ => typeof _ === 'string') ? values : values.map(_ => _.toString());
221+
case FieldType.number:
222+
return values.every(_ => typeof _ === 'number') ? values : values.map(_ => parseFloat(_));
223+
case FieldType.boolean:
224+
return values.every(_ => typeof _ === 'boolean')
225+
? values
226+
: values.map(_ => {
227+
switch (_.toString()) {
228+
case '0':
229+
case 'false':
230+
case 'FALSE':
231+
case 'False':
232+
return false;
233+
case '1':
234+
case 'true':
235+
case 'TRUE':
236+
case 'True':
237+
return true;
238+
default:
239+
throw new Error('Found non-boolean values in a field of type boolean');
240+
}
241+
});
242+
default:
243+
throw new Error('Unsupported field type');
244+
}
193245
};

src/detectFieldType.test.ts

Lines changed: 14 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,36 @@
11
import { detectFieldType } from './datasource';
2-
import { format } from 'date-fns';
32

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

98
test('iso8601 date without time zone gets parsed as time', () => {
10-
const input = ['2005-01-02', '2006-01-02'];
11-
const res = detectFieldType(input);
12-
13-
expect(res[0]).toStrictEqual('time');
14-
15-
// Since the input doesn't have a time zone, the resulting timestamps are in
16-
// local time. For now, test that we can parse and format it to input values.
17-
expect(res[1].map(_ => format(_, 'yyyy-MM-dd'))).toStrictEqual(input);
9+
expect(detectFieldType(['2005-01-02', '2006-01-02'])).toStrictEqual('time');
1810
});
1911

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

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

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

35-
expect(detectFieldType([false, false, false])).toEqual(['boolean', [false, false, false]]);
36-
expect(detectFieldType([false, false, true])).toEqual(['boolean', [false, false, true]]);
24+
test('all zeros gets parsed as number', () => {
25+
expect(detectFieldType([0, 0, 0])).toStrictEqual('number');
26+
expect(detectFieldType([0, 0, 1])).toStrictEqual('number');
3727
});
3828

3929
test('all false gets parsed as boolean', () => {
40-
expect(detectFieldType([false, false, false])).toEqual(['boolean', [false, false, false]]);
41-
expect(detectFieldType([false, false, true])).toEqual(['boolean', [false, false, true]]);
30+
expect(detectFieldType([false, false, false])).toStrictEqual('boolean');
31+
expect(detectFieldType([false, false, true])).toStrictEqual('boolean');
4232
});
4333

4434
test('all null gets parsed as string', () => {
45-
expect(detectFieldType([null, null])).toEqual(['string', [null, null]]);
35+
expect(detectFieldType([null, null])).toStrictEqual('string');
4636
});

src/parseValues.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { FieldType } from '@grafana/data';
2+
import { parseValues } from './datasource';
3+
4+
test('parse numbers', () => {
5+
const values = [2005, 2006];
6+
7+
expect(() => parseValues(values, FieldType.boolean)).toThrow();
8+
expect(parseValues(values, FieldType.number)).toStrictEqual([2005, 2006]);
9+
expect(parseValues(values, FieldType.string)).toStrictEqual(['2005', '2006']);
10+
11+
// Numbers are assumed to be epoch time in seconds.
12+
expect(parseValues(values, FieldType.time)).toStrictEqual([2005000, 2006000]);
13+
});
14+
15+
test('parse numbers from strings', () => {
16+
const values = ['2005', '2006'];
17+
18+
expect(() => parseValues(values, FieldType.boolean)).toThrow();
19+
expect(parseValues(values, FieldType.number)).toStrictEqual([2005, 2006]);
20+
expect(parseValues(values, FieldType.string)).toStrictEqual(['2005', '2006']);
21+
22+
// Values get parsed as ISO 8601 strings.
23+
expect(parseValues(values, FieldType.time)).toStrictEqual([1104534000000, 1136070000000]);
24+
});
25+
26+
test('parse booleans', () => {
27+
const values = [false, true, false];
28+
29+
expect(parseValues(values, FieldType.boolean)).toStrictEqual([false, true, false]);
30+
expect(parseValues(values, FieldType.number)).toStrictEqual([NaN, NaN, NaN]);
31+
expect(parseValues(values, FieldType.string)).toStrictEqual(['false', 'true', 'false']);
32+
expect(() => parseValues(values, FieldType.time)).toThrow();
33+
});
34+
35+
test('parse booleans from strings', () => {
36+
const values = ['false', 'true', 'false'];
37+
38+
expect(parseValues(values, FieldType.boolean)).toStrictEqual([false, true, false]);
39+
expect(parseValues(values, FieldType.number)).toStrictEqual([NaN, NaN, NaN]);
40+
expect(parseValues(values, FieldType.string)).toStrictEqual(['false', 'true', 'false']);
41+
expect(parseValues(values, FieldType.time)).toStrictEqual([NaN, NaN, NaN]);
42+
});

src/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { DataQuery, DataSourceJsonData } from '@grafana/data';
1+
import { DataQuery, DataSourceJsonData, FieldType } from '@grafana/data';
22

33
interface JsonField {
44
name: string;
55
jsonPath: string;
6+
type?: FieldType;
67
}
78

89
export interface JsonApiQuery extends DataQuery {

0 commit comments

Comments
 (0)