InfluxDB: Add new truthiness operators (Is and Is Not) to InfluxQL Query Builder (#77923)

* InfluxDB: Add new truthiness operators (`Is` and `Is Not`) to InfluxQL Editor for use with boolean fields

* InfluxDB: Make the front-end aware of the new operators so that translation between raw and builder works

* Chore: Add tests

* feat: identify type of field value to allow other types to work with Is/Is Not

Tags: always quote
Integer: Don't quote
Float: Don't quote
Boolean: Don't quote
String: Quote

* Chore: Add test-cases for type inference

* Update front-end to infer type for operators Is and Is Not

* chore: Add front-end tests

* chore: add additional front-end tests

* chore: fix failing lint test

* chore: fix tests (run prettier)
This commit is contained in:
Ben Tasker 2023-11-15 20:10:37 +00:00 committed by GitHub
parent a94acf4b63
commit f38f657f87
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 291 additions and 5 deletions

View File

@ -67,6 +67,45 @@ func (query *Query) renderTags() []string {
}
}
isOperatorTypeHandler := func(tag *Tag) (string, string) {
// Attempt to identify the type of the supplied value
var lowerValue = strings.ToLower(tag.Value)
var r = regexp.MustCompile(`^(-?)[0-9\.]+$`)
var textValue string
var operator string
// Perform operator replacements
switch tag.Operator {
case "Is":
operator = "="
case "Is Not":
operator = "!="
default:
// This should never happen
operator = "="
}
// Always quote tag values
if strings.HasSuffix(tag.Key, "::tag") {
textValue = fmt.Sprintf("'%s'", strings.ReplaceAll(tag.Value, `\`, `\\`))
return textValue, operator
}
// Try and discern the type of fields
if lowerValue == "true" || lowerValue == "false" {
// boolean, don't quote, but make lowercase
textValue = lowerValue
} else if r.MatchString(tag.Value) {
// Integer or float, don't quote
textValue = tag.Value
} else {
// String (or unknown) - quote
textValue = fmt.Sprintf("'%s'", strings.ReplaceAll(tag.Value, `\`, `\\`))
}
return textValue, operator
}
// quote value unless regex or number
var textValue string
switch tag.Operator {
@ -74,6 +113,8 @@ func (query *Query) renderTags() []string {
textValue = tag.Value
case "<", ">", ">=", "<=":
textValue = tag.Value
case "Is", "Is Not":
textValue, tag.Operator = isOperatorTypeHandler(tag)
default:
textValue = fmt.Sprintf("'%s'", strings.ReplaceAll(tag.Value, `\`, `\\`))
}

View File

@ -239,6 +239,47 @@ func TestInfluxdbQueryBuilder(t *testing.T) {
require.Equal(t, strings.Join(query.renderTags(), ""), `"key" <= 10001`)
})
t.Run("can render boolean equality tags", func(t *testing.T) {
query := &Query{Tags: []*Tag{{Operator: "Is", Value: "false", Key: "key"}}}
require.Equal(t, strings.Join(query.renderTags(), ""), `"key" = false`)
})
t.Run("can render boolean inequality tags", func(t *testing.T) {
query := &Query{Tags: []*Tag{{Operator: "Is Not", Value: "true", Key: "key"}}}
require.Equal(t, strings.Join(query.renderTags(), ""), `"key" != true`)
})
t.Run("can correct case of boolean tags", func(t *testing.T) {
query := &Query{Tags: []*Tag{{Operator: "Is", Value: "False", Key: "key"}}}
require.Equal(t, strings.Join(query.renderTags(), ""), `"key" = false`)
})
t.Run("can use strings with Is", func(t *testing.T) {
query := &Query{Tags: []*Tag{{Operator: "Is", Value: "A string", Key: "key"}}}
require.Equal(t, strings.Join(query.renderTags(), ""), `"key" = 'A string'`)
})
t.Run("can use integers with Is", func(t *testing.T) {
query := &Query{Tags: []*Tag{{Operator: "Is", Value: "123", Key: "key"}}}
require.Equal(t, strings.Join(query.renderTags(), ""), `"key" = 123`)
})
t.Run("can use negative integers with Is", func(t *testing.T) {
query := &Query{Tags: []*Tag{{Operator: "Is", Value: "-123", Key: "key"}}}
require.Equal(t, strings.Join(query.renderTags(), ""), `"key" = -123`)
})
t.Run("can use floats with Is", func(t *testing.T) {
query := &Query{Tags: []*Tag{{Operator: "Is", Value: "1.23", Key: "key"}}}
require.Equal(t, strings.Join(query.renderTags(), ""), `"key" = 1.23`)
})
t.Run("can use negative floats with Is", func(t *testing.T) {
query := &Query{Tags: []*Tag{{Operator: "Is", Value: "-1.23", Key: "key"}}}
require.Equal(t, strings.Join(query.renderTags(), ""), `"key" = -1.23`)
})
t.Run("can render string tags", func(t *testing.T) {
query := &Query{Tags: []*Tag{{Operator: "=", Value: "value", Key: "key"}}}

View File

@ -10,8 +10,8 @@ import { toSelectableValue } from '../utils/toSelectableValue';
import { AddButton } from './AddButton';
import { Seg } from './Seg';
type KnownOperator = '=' | '!=' | '<>' | '<' | '>' | '>=' | '<=' | '=~' | '!~';
const knownOperators: KnownOperator[] = ['=', '!=', '<>', '<', '>', '>=', '<=', '=~', '!~'];
type KnownOperator = '=' | '!=' | '<>' | '<' | '>' | '>=' | '<=' | '=~' | '!~' | 'Is' | 'Is Not';
const knownOperators: KnownOperator[] = ['=', '!=', '<>', '<', '>', '>=', '<=', '=~', '!~', 'Is', 'Is Not'];
type KnownCondition = 'AND' | 'OR';
const knownConditions: KnownCondition[] = ['AND', 'OR'];

View File

@ -205,7 +205,6 @@ describe('InfluxQuery', () => {
expect(queryText).toBe('SELECT mean("value") FROM "autogen"."cpu" WHERE ("value" > 5) AND $timeFilter');
});
});
describe('query with greater-than-or-equal-to condition', () => {
it('should use >=', () => {
const query = new InfluxQueryModel(
@ -244,6 +243,158 @@ describe('InfluxQuery', () => {
});
});
describe('query with Is operator', () => {
it('should use =', () => {
const query = new InfluxQueryModel(
{
refId: 'A',
measurement: 'cpu',
policy: 'autogen',
groupBy: [],
tags: [{ key: 'value', value: 'False', operator: 'Is' }],
},
templateSrv,
{}
);
const queryText = query.render();
expect(queryText).toBe('SELECT mean("value") FROM "autogen"."cpu" WHERE ("value" = false) AND $timeFilter');
});
});
describe('query with Is Not operator', () => {
it('should use !=', () => {
const query = new InfluxQueryModel(
{
refId: 'A',
measurement: 'cpu',
policy: 'autogen',
groupBy: [],
tags: [{ key: 'value', value: 'False', operator: 'Is Not' }],
},
templateSrv,
{}
);
const queryText = query.render();
expect(queryText).toBe('SELECT mean("value") FROM "autogen"."cpu" WHERE ("value" != false) AND $timeFilter');
});
});
describe('query with Is boolean condition', () => {
it('should convert to lowercase and not quote value', () => {
const query = new InfluxQueryModel(
{
refId: 'A',
measurement: 'cpu',
policy: 'autogen',
groupBy: [],
tags: [{ key: 'value', value: 'True', operator: 'Is' }],
},
templateSrv,
{}
);
const queryText = query.render();
expect(queryText).toBe('SELECT mean("value") FROM "autogen"."cpu" WHERE ("value" = true) AND $timeFilter');
});
});
describe('query with Is string condition', () => {
it('should quote value', () => {
const query = new InfluxQueryModel(
{
refId: 'A',
measurement: 'cpu',
policy: 'autogen',
groupBy: [],
tags: [{ key: 'value', value: 'Server2', operator: 'Is' }],
},
templateSrv,
{}
);
const queryText = query.render();
expect(queryText).toBe('SELECT mean("value") FROM "autogen"."cpu" WHERE ("value" = \'Server2\') AND $timeFilter');
});
});
describe('query with Is integer condition', () => {
it('should not quote value', () => {
const query = new InfluxQueryModel(
{
refId: 'A',
measurement: 'cpu',
policy: 'autogen',
groupBy: [],
tags: [{ key: 'value', value: '5', operator: 'Is' }],
},
templateSrv,
{}
);
const queryText = query.render();
expect(queryText).toBe('SELECT mean("value") FROM "autogen"."cpu" WHERE ("value" = 5) AND $timeFilter');
});
});
describe('query with Is float condition', () => {
it('should not quote value', () => {
const query = new InfluxQueryModel(
{
refId: 'A',
measurement: 'cpu',
policy: 'autogen',
groupBy: [],
tags: [{ key: 'value', value: '1.234', operator: 'Is' }],
},
templateSrv,
{}
);
const queryText = query.render();
expect(queryText).toBe('SELECT mean("value") FROM "autogen"."cpu" WHERE ("value" = 1.234) AND $timeFilter');
});
});
describe('query with Is negative float condition', () => {
it('should not quote value', () => {
const query = new InfluxQueryModel(
{
refId: 'A',
measurement: 'cpu',
policy: 'autogen',
groupBy: [],
tags: [{ key: 'value', value: '-1.234', operator: 'Is' }],
},
templateSrv,
{}
);
const queryText = query.render();
expect(queryText).toBe('SELECT mean("value") FROM "autogen"."cpu" WHERE ("value" = -1.234) AND $timeFilter');
});
});
describe('query with Is boolean condition', () => {
it('should convert to lowercase and not quote value', () => {
const query = new InfluxQueryModel(
{
refId: 'A',
measurement: 'cpu',
policy: 'autogen',
groupBy: [],
tags: [{ key: 'value', value: 'True', operator: 'Is' }],
},
templateSrv,
{}
);
const queryText = query.render();
expect(queryText).toBe('SELECT mean("value") FROM "autogen"."cpu" WHERE ("value" = true) AND $timeFilter');
});
});
describe('series with groupByTag', () => {
it('should generate correct query', () => {
const query = new InfluxQueryModel(

View File

@ -141,6 +141,42 @@ export default class InfluxQueryModel {
this.updatePersistedParts();
}
private isOperatorTypeHandler(operator: string, value: string, fieldName: string) {
let textValue;
if (operator === 'Is Not') {
operator = '!=';
} else {
operator = '=';
}
// Tags should always quote
if (fieldName.endsWith('::tag')) {
textValue = "'" + value.replace(/\\/g, '\\\\').replace(/\'/g, "\\'") + "'";
return {
operator: operator,
value: textValue,
};
}
let lowerValue = value.toLowerCase();
// Try and discern type
if (!isNaN(parseFloat(value))) {
// Integer or float, don't quote
textValue = value;
} else if (['true', 'false'].includes(lowerValue)) {
// It's a boolean, don't quite
textValue = lowerValue;
} else {
// String or unrecognised: quote
textValue = "'" + value.replace(/\\/g, '\\\\').replace(/\'/g, "\\'") + "'";
}
return {
operator: operator,
value: textValue,
};
}
private renderTagCondition(tag: InfluxQueryTag, index: number, interpolate?: boolean) {
// FIXME: merge this function with query_builder/renderTagCondition
let str = '';
@ -163,7 +199,12 @@ export default class InfluxQueryModel {
if (interpolate) {
value = this.templateSrv.replace(value, this.scopedVars);
}
if ((!operator.startsWith('>') && !operator.startsWith('<')) || operator === '<>') {
if (operator.startsWith('Is')) {
let r = this.isOperatorTypeHandler(operator, value, tag.key);
operator = r.operator;
value = r.value;
} else if ((!operator.startsWith('>') && !operator.startsWith('<')) || operator === '<>') {
value = "'" + value.replace(/\\/g, '\\\\').replace(/\'/g, "\\'") + "'";
}
} else if (interpolate) {

View File

@ -147,6 +147,18 @@ describe('InfluxDB query utils', () => {
operator: '!~',
value: '/cpu0/',
},
{
condition: 'AND',
key: 'cpu',
operator: 'Is',
value: 'false',
},
{
condition: 'AND',
key: 'cpu',
operator: 'Is Not',
value: 'false',
},
],
groupBy: [],
})
@ -154,7 +166,7 @@ describe('InfluxDB query utils', () => {
`SELECT "value" ` +
`FROM "autogen"."measurement" ` +
`WHERE ("cpu" = 'cpu0' AND "cpu" != 'cpu0' AND "cpu" <> 'cpu0' AND "cpu" < cpu0 AND ` +
`"cpu" > cpu0 AND "cpu" =~ /cpu0/ AND "cpu" !~ /cpu0/) AND $timeFilter`
`"cpu" > cpu0 AND "cpu" =~ /cpu0/ AND "cpu" !~ /cpu0/ AND "cpu" = false AND "cpu" != false) AND $timeFilter`
);
});
it('should handle a complex query', () => {