mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
SQL: Add timeFilter macro to query builder (#74575)
* Add timeFilter macro to query builder * Only render SQLWhereRow when fields are there * Change the default query to timeseries and remove 50 limit * Add timeFilter macro for the first time when timeSeries * Add test for timeFilter macro * Lint fix * Annotation query format should be table * Set order by as default * Revert changes that made time series default * Fix e2e test * Fix e2e test * Make sure to reset the date value when operator is changed * Add docs
This commit is contained in:
parent
375dcc3813
commit
22035565d2
@ -120,6 +120,8 @@ To filter on more columns, click the plus (`+`) button to the right of the condi
|
|||||||
|
|
||||||
To remove a filter, click the `x` button next to that filter's dropdown.
|
To remove a filter, click the `x` button next to that filter's dropdown.
|
||||||
|
|
||||||
|
After selecting a date type column, you can choose Macros from the operators list and select timeFilter which will add the $\_\_timeFilter macro to the query with the selected date column.
|
||||||
|
|
||||||
### Group results
|
### Group results
|
||||||
|
|
||||||
To group results by column, toggle the **Group** switch at the top of the editor.
|
To group results by column, toggle the **Group** switch at the top of the editor.
|
||||||
|
@ -201,10 +201,17 @@ Add further value columns by clicking the plus button and another column dropdow
|
|||||||
|
|
||||||
### Filter data (WHERE)
|
### Filter data (WHERE)
|
||||||
|
|
||||||
To add a filter, flip the switch at the top of the editor.
|
To add a filter, toggle the **Filter** switch at the top of the editor.
|
||||||
Using the first dropdown, select if all the filters need to match (AND) or if only one of the filters needs to match (OR).
|
This reveals a **Filter by column value** section with two dropdown selectors.
|
||||||
|
|
||||||
To add more columns to filter on use the plus button.
|
Use the first dropdown to choose whether all of the filters need to match (`AND`), or if only one of the filters needs to match (`OR`).
|
||||||
|
Use the second dropdown to choose a filter.
|
||||||
|
|
||||||
|
To filter on more columns, click the plus (`+`) button to the right of the condition dropdown.
|
||||||
|
|
||||||
|
To remove a filter, click the `x` button next to that filter's dropdown.
|
||||||
|
|
||||||
|
After selecting a date type column, you can choose Macros from the operators list and select timeFilter which will add the $\_\_timeFilter macro to the query with the selected date column.
|
||||||
|
|
||||||
### Group By
|
### Group By
|
||||||
|
|
||||||
|
@ -114,10 +114,17 @@ Add further value columns by clicking the plus button and another column dropdow
|
|||||||
|
|
||||||
### Filter data (WHERE)
|
### Filter data (WHERE)
|
||||||
|
|
||||||
To add a filter, flip the switch at the top of the editor.
|
To add a filter, toggle the **Filter** switch at the top of the editor.
|
||||||
Using the first dropdown, select if all the filters need to match (AND) or if only one of the filters needs to match (OR).
|
This reveals a **Filter by column value** section with two dropdown selectors.
|
||||||
|
|
||||||
To add more columns to filter on use the plus button.
|
Use the first dropdown to choose whether all of the filters need to match (`AND`), or if only one of the filters needs to match (`OR`).
|
||||||
|
Use the second dropdown to choose a filter.
|
||||||
|
|
||||||
|
To filter on more columns, click the plus (`+`) button to the right of the condition dropdown.
|
||||||
|
|
||||||
|
To remove a filter, click the `x` button next to that filter's dropdown.
|
||||||
|
|
||||||
|
After selecting a date type column, you can choose Macros from the operators list and select timeFilter which will add the $\_\_timeFilter macro to the query with the selected date column.
|
||||||
|
|
||||||
### Group By
|
### Group By
|
||||||
|
|
||||||
|
@ -9,10 +9,6 @@ const normalTableName = tablesResponse.results.tables.frames[0].data.values[0][0
|
|||||||
|
|
||||||
describe('MySQL datasource', () => {
|
describe('MySQL datasource', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('code editor autocomplete should handle table name escaping/quoting', () => {
|
|
||||||
cy.intercept('POST', '/api/ds/query', (req) => {
|
cy.intercept('POST', '/api/ds/query', (req) => {
|
||||||
if (req.body.queries[0].refId === 'datasets') {
|
if (req.body.queries[0].refId === 'datasets') {
|
||||||
req.alias = 'datasets';
|
req.alias = 'datasets';
|
||||||
@ -31,11 +27,14 @@ describe('MySQL datasource', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||||
e2e.pages.Explore.visit();
|
e2e.pages.Explore.visit();
|
||||||
|
|
||||||
e2e.components.DataSourcePicker.container().should('be.visible').type('gdev-mysql{enter}');
|
e2e.components.DataSourcePicker.container().should('be.visible').type('gdev-mysql{enter}');
|
||||||
|
cy.wait('@datasets');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('code editor autocomplete should handle table name escaping/quoting', () => {
|
||||||
cy.get("label[for^='option-code']").should('be.visible').click();
|
cy.get("label[for^='option-code']").should('be.visible').click();
|
||||||
cy.get('textarea').type('S{downArrow}{enter}');
|
cy.get('textarea').type('S{downArrow}{enter}');
|
||||||
cy.wait('@tables');
|
cy.wait('@tables');
|
||||||
@ -60,4 +59,61 @@ describe('MySQL datasource', () => {
|
|||||||
cy.get('textarea').type('.');
|
cy.get('textarea').type('.');
|
||||||
cy.get('.suggest-widget').contains('No suggestions.').should('be.visible');
|
cy.get('.suggest-widget').contains('No suggestions.').should('be.visible');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('visual query builder', () => {
|
||||||
|
it('should be able to add timeFilter macro', () => {
|
||||||
|
cy.get("[aria-label='Table selector']").should('be.visible').click();
|
||||||
|
selectOption(normalTableName);
|
||||||
|
// Open column selector
|
||||||
|
cy.get("[id^='select-column-0']").should('be.visible').click();
|
||||||
|
selectOption('createdAt');
|
||||||
|
|
||||||
|
// Toggle where row
|
||||||
|
cy.get("label[for^='sql-filter']").last().should('be.visible').click();
|
||||||
|
|
||||||
|
// Click add filter button
|
||||||
|
cy.get('button[title="Add filter"]').should('be.visible').click();
|
||||||
|
cy.get('button[title="Add filter"]').should('be.visible').click(); // For some reason we need to click twice
|
||||||
|
|
||||||
|
// Open field selector
|
||||||
|
cy.get("[aria-label='Field']").should('be.visible').click();
|
||||||
|
selectOption('createdAt');
|
||||||
|
|
||||||
|
// Open operator selector
|
||||||
|
cy.get("[aria-label='Operator']").should('be.visible').click();
|
||||||
|
selectOption('Macros');
|
||||||
|
|
||||||
|
// Open macros value selector
|
||||||
|
cy.get("[aria-label='Macros value selector']").should('be.visible').click();
|
||||||
|
selectOption('timeFilter');
|
||||||
|
|
||||||
|
// Validate that the timeFilter macro was added
|
||||||
|
|
||||||
|
e2e.components.CodeEditor.container()
|
||||||
|
.get('textarea')
|
||||||
|
.should(
|
||||||
|
'have.value',
|
||||||
|
`SELECT\n createdAt\nFROM\n DataMaker.normalTable\nWHERE\n $__timeFilter(createdAt)\nLIMIT\n 50`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Validate that the timeFilter macro was removed when changed to equals operator
|
||||||
|
|
||||||
|
// For some reason the input is not visible the second time so we need to force the click
|
||||||
|
cy.get("[aria-label='Operator']").click({ force: true });
|
||||||
|
selectOption('==');
|
||||||
|
|
||||||
|
e2e.components.DateTimePicker.input().should('be.visible').click().blur();
|
||||||
|
|
||||||
|
e2e.components.CodeEditor.container()
|
||||||
|
.get('textarea')
|
||||||
|
.should(
|
||||||
|
'not.have.value',
|
||||||
|
`SELECT\n createdAt\nFROM\n DataMaker.normalTable\nWHERE\n $__timeFilter(createdAt)\nLIMIT\n 50`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function selectOption(option: string) {
|
||||||
|
cy.get("[aria-label='Select option']").contains(option).should('be.visible').click();
|
||||||
|
}
|
||||||
|
@ -32,6 +32,9 @@ export const Components = {
|
|||||||
rolePicker: 'Built-in role picker',
|
rolePicker: 'Built-in role picker',
|
||||||
permissionLevel: 'Permission Level',
|
permissionLevel: 'Permission Level',
|
||||||
},
|
},
|
||||||
|
DateTimePicker: {
|
||||||
|
input: 'data-testid date-time-input',
|
||||||
|
},
|
||||||
DataSource: {
|
DataSource: {
|
||||||
TestData: {
|
TestData: {
|
||||||
QueryTab: {
|
QueryTab: {
|
||||||
|
@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { dateTime } from '@grafana/data';
|
import { dateTime } from '@grafana/data';
|
||||||
|
import { Components } from '@grafana/e2e-selectors';
|
||||||
|
|
||||||
import { DateTimePicker, Props } from './DateTimePicker';
|
import { DateTimePicker, Props } from './DateTimePicker';
|
||||||
|
|
||||||
@ -33,7 +34,7 @@ describe('Date time picker', () => {
|
|||||||
it('should update date onblur', async () => {
|
it('should update date onblur', async () => {
|
||||||
const onChangeInput = jest.fn();
|
const onChangeInput = jest.fn();
|
||||||
render(<DateTimePicker date={dateTime('2021-05-05 12:00:00')} onChange={onChangeInput} />);
|
render(<DateTimePicker date={dateTime('2021-05-05 12:00:00')} onChange={onChangeInput} />);
|
||||||
const dateTimeInput = screen.getByTestId('date-time-input');
|
const dateTimeInput = screen.getByTestId(Components.DateTimePicker.input);
|
||||||
await userEvent.clear(dateTimeInput);
|
await userEvent.clear(dateTimeInput);
|
||||||
await userEvent.type(dateTimeInput, '2021-07-31 12:30:30');
|
await userEvent.type(dateTimeInput, '2021-07-31 12:30:30');
|
||||||
expect(dateTimeInput).toHaveDisplayValue('2021-07-31 12:30:30');
|
expect(dateTimeInput).toHaveDisplayValue('2021-07-31 12:30:30');
|
||||||
@ -44,7 +45,7 @@ describe('Date time picker', () => {
|
|||||||
it('should not update onblur if invalid date', async () => {
|
it('should not update onblur if invalid date', async () => {
|
||||||
const onChangeInput = jest.fn();
|
const onChangeInput = jest.fn();
|
||||||
render(<DateTimePicker date={dateTime('2021-05-05 12:00:00')} onChange={onChangeInput} />);
|
render(<DateTimePicker date={dateTime('2021-05-05 12:00:00')} onChange={onChangeInput} />);
|
||||||
const dateTimeInput = screen.getByTestId('date-time-input');
|
const dateTimeInput = screen.getByTestId(Components.DateTimePicker.input);
|
||||||
await userEvent.clear(dateTimeInput);
|
await userEvent.clear(dateTimeInput);
|
||||||
await userEvent.type(dateTimeInput, '2021:05:05 12-00-00');
|
await userEvent.type(dateTimeInput, '2021:05:05 12-00-00');
|
||||||
expect(dateTimeInput).toHaveDisplayValue('2021:05:05 12-00-00');
|
expect(dateTimeInput).toHaveDisplayValue('2021:05:05 12-00-00');
|
||||||
|
@ -8,6 +8,7 @@ import { usePopper } from 'react-popper';
|
|||||||
import { useMedia } from 'react-use';
|
import { useMedia } from 'react-use';
|
||||||
|
|
||||||
import { dateTimeFormat, DateTime, dateTime, GrafanaTheme2, isDateTime } from '@grafana/data';
|
import { dateTimeFormat, DateTime, dateTime, GrafanaTheme2, isDateTime } from '@grafana/data';
|
||||||
|
import { Components } from '@grafana/e2e-selectors';
|
||||||
|
|
||||||
import { useStyles2, useTheme2 } from '../../../themes';
|
import { useStyles2, useTheme2 } from '../../../themes';
|
||||||
import { Button } from '../../Button/Button';
|
import { Button } from '../../Button/Button';
|
||||||
@ -232,7 +233,7 @@ const DateTimeInput = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
addonAfter={icon}
|
addonAfter={icon}
|
||||||
value={internalDate.value}
|
value={internalDate.value}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
data-testid="date-time-input"
|
data-testid={Components.DateTimePicker.input}
|
||||||
placeholder="Select date/time"
|
placeholder="Select date/time"
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
|
@ -2,7 +2,6 @@ import {
|
|||||||
AnyObject,
|
AnyObject,
|
||||||
BasicConfig,
|
BasicConfig,
|
||||||
Config,
|
Config,
|
||||||
JsonItem,
|
|
||||||
JsonTree,
|
JsonTree,
|
||||||
Operator,
|
Operator,
|
||||||
Settings,
|
Settings,
|
||||||
@ -24,38 +23,14 @@ const buttonLabels = {
|
|||||||
remove: 'Remove',
|
remove: 'Remove',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const emptyInitValue: JsonItem = {
|
|
||||||
id: Utils.uuid(),
|
|
||||||
type: 'group' as const,
|
|
||||||
children1: {
|
|
||||||
[Utils.uuid()]: {
|
|
||||||
type: 'rule',
|
|
||||||
properties: {
|
|
||||||
field: null,
|
|
||||||
operator: null,
|
|
||||||
value: [],
|
|
||||||
valueSrc: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const emptyInitTree: JsonTree = {
|
export const emptyInitTree: JsonTree = {
|
||||||
id: Utils.uuid(),
|
id: Utils.uuid(),
|
||||||
type: 'group' as const,
|
type: 'group',
|
||||||
children1: {
|
|
||||||
[Utils.uuid()]: {
|
|
||||||
type: 'rule',
|
|
||||||
properties: {
|
|
||||||
field: null,
|
|
||||||
operator: null,
|
|
||||||
value: [],
|
|
||||||
valueSrc: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TIME_FILTER = 'timeFilter';
|
||||||
|
const macros = [TIME_FILTER];
|
||||||
|
|
||||||
export const widgets: Widgets = {
|
export const widgets: Widgets = {
|
||||||
...BasicConfig.widgets,
|
...BasicConfig.widgets,
|
||||||
text: {
|
text: {
|
||||||
@ -86,15 +61,47 @@ export const widgets: Widgets = {
|
|||||||
datetime: {
|
datetime: {
|
||||||
...BasicConfig.widgets.datetime,
|
...BasicConfig.widgets.datetime,
|
||||||
factory: function DateTimeInput(props) {
|
factory: function DateTimeInput(props) {
|
||||||
|
if (props?.operator === Op.MACROS) {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
id={props.id}
|
||||||
|
aria-label="Macros value selector"
|
||||||
|
menuShouldPortal
|
||||||
|
options={macros.map(toOption)}
|
||||||
|
value={props?.value}
|
||||||
|
onChange={(val) => props.setValue(val.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const dateValue = dateTime(props?.value).isValid() ? dateTime(props?.value).utc() : undefined;
|
||||||
return (
|
return (
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
props?.setValue(e.format(BasicConfig.widgets.datetime.valueFormat));
|
props?.setValue(e.format(BasicConfig.widgets.datetime.valueFormat));
|
||||||
}}
|
}}
|
||||||
date={dateTime(props?.value).utc()}
|
date={dateValue}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
sqlFormatValue: (val, field, widget, operator, operatorDefinition, rightFieldDef) => {
|
||||||
|
if (operator === Op.MACROS) {
|
||||||
|
if (macros.includes(val)) {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is just satisfying the type checker, this should never happen
|
||||||
|
if (
|
||||||
|
typeof BasicConfig.widgets.datetime.sqlFormatValue === 'string' ||
|
||||||
|
typeof BasicConfig.widgets.datetime.sqlFormatValue === 'object'
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const func = BasicConfig.widgets.datetime.sqlFormatValue;
|
||||||
|
// We need to pass the ctx to this function this way so *this* is correct
|
||||||
|
return func?.call(BasicConfig.ctx, val, field, widget, operator, operatorDefinition, rightFieldDef) || '';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -173,6 +180,7 @@ export const settings: Settings = {
|
|||||||
const enum Op {
|
const enum Op {
|
||||||
IN = 'select_any_in',
|
IN = 'select_any_in',
|
||||||
NOT_IN = 'select_not_any_in',
|
NOT_IN = 'select_not_any_in',
|
||||||
|
MACROS = 'macros',
|
||||||
}
|
}
|
||||||
// eslint-ignore
|
// eslint-ignore
|
||||||
const customOperators = getCustomOperators(BasicConfig) as typeof BasicConfig.operators;
|
const customOperators = getCustomOperators(BasicConfig) as typeof BasicConfig.operators;
|
||||||
@ -192,6 +200,16 @@ const customTypes = {
|
|||||||
text: customTextWidget,
|
text: customTextWidget,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
datetime: {
|
||||||
|
...BasicConfig.types.datetime,
|
||||||
|
widgets: {
|
||||||
|
...BasicConfig.types.datetime.widgets,
|
||||||
|
datetime: {
|
||||||
|
...BasicConfig.types.datetime.widgets.datetime,
|
||||||
|
operators: [Op.MACROS, ...(BasicConfig.types.datetime.widgets.datetime.operators || [])],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const raqbConfig: Config = {
|
export const raqbConfig: Config = {
|
||||||
@ -284,6 +302,15 @@ function getCustomOperators(config: BasicConfig) {
|
|||||||
...supportedOperators[Op.NOT_IN],
|
...supportedOperators[Op.NOT_IN],
|
||||||
sqlFormatOp: customSqlNotInFormatter,
|
sqlFormatOp: customSqlNotInFormatter,
|
||||||
},
|
},
|
||||||
|
[Op.MACROS]: {
|
||||||
|
label: 'Macros',
|
||||||
|
sqlFormatOp: (field: string, _operator: string, value: string | List<string>) => {
|
||||||
|
if (value === TIME_FILTER) {
|
||||||
|
return `$__timeFilter(${field})`;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return customOperators;
|
return customOperators;
|
||||||
|
@ -57,7 +57,7 @@ export abstract class SqlDatasource extends DataSourceWithBackend<SQLQuery, SQLO
|
|||||||
const settingsData = instanceSettings.jsonData || {};
|
const settingsData = instanceSettings.jsonData || {};
|
||||||
this.interval = settingsData.timeInterval || '1m';
|
this.interval = settingsData.timeInterval || '1m';
|
||||||
this.db = this.getDB();
|
this.db = this.getDB();
|
||||||
/*
|
/*
|
||||||
The `settingsData.database` will be defined if a default database has been defined in either
|
The `settingsData.database` will be defined if a default database has been defined in either
|
||||||
1) the ConfigurationEditor.tsx, OR 2) the provisioning config file, either under `jsondata.database`, or simply `database`.
|
1) the ConfigurationEditor.tsx, OR 2) the provisioning config file, either under `jsondata.database`, or simply `database`.
|
||||||
*/
|
*/
|
||||||
@ -167,7 +167,7 @@ export abstract class SqlDatasource extends DataSourceWithBackend<SQLQuery, SQLO
|
|||||||
// No need to check for database change/update issues if the datasource is being used in Explore.
|
// No need to check for database change/update issues if the datasource is being used in Explore.
|
||||||
if (request.app !== CoreApp.Explore) {
|
if (request.app !== CoreApp.Explore) {
|
||||||
/*
|
/*
|
||||||
If a preconfigured datasource database has been added/updated - and the user has built ANY number of queries using a
|
If a preconfigured datasource database has been added/updated - and the user has built ANY number of queries using a
|
||||||
database OTHER than the preconfigured one, return a database issue - since those databases are no longer available.
|
database OTHER than the preconfigured one, return a database issue - since those databases are no longer available.
|
||||||
The user will need to update their queries to use the preconfigured database.
|
The user will need to update their queries to use the preconfigured database.
|
||||||
*/
|
*/
|
||||||
|
Loading…
Reference in New Issue
Block a user