diff --git a/src/components/configEditor/QuerySettingsConfig.test.tsx b/src/components/configEditor/QuerySettingsConfig.test.tsx index 8024153..46b271e 100644 --- a/src/components/configEditor/QuerySettingsConfig.test.tsx +++ b/src/components/configEditor/QuerySettingsConfig.test.tsx @@ -1,157 +1,16 @@ import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; +import { render } from '@testing-library/react'; import { QuerySettingsConfig } from './QuerySettingsConfig'; -import allLabels from 'labels'; describe('QuerySettingsConfig', () => { it('should render', () => { const result = render( {}} - onConnMaxLifetimeChange={() => {}} - onConnMaxOpenConnsChange={() => {}} - onDialTimeoutChange={() => {}} - onQueryTimeoutChange={() => {}} - onValidateSqlChange={() => {}} + filterValidationEnabled={true} + onFilterValidationEnabledChange={() => {}} /> ); expect(result.container.firstChild).not.toBeNull(); }); - it('should call onDialTimeout when changed', () => { - const onDialTimeout = jest.fn(); - const result = render( - {}} - onConnMaxLifetimeChange={() => {}} - onConnMaxOpenConnsChange={() => {}} - onDialTimeoutChange={onDialTimeout} - onQueryTimeoutChange={() => {}} - onValidateSqlChange={() => {}} - /> - ); - expect(result.container.firstChild).not.toBeNull(); - - const input = result.getByPlaceholderText(allLabels.components.Config.QuerySettingsConfig.dialTimeout.placeholder); - expect(input).toBeInTheDocument(); - fireEvent.change(input, { target: { value: '10' } }); - fireEvent.blur(input); - expect(onDialTimeout).toHaveBeenCalledTimes(1); - expect(onDialTimeout).toHaveBeenCalledWith(expect.any(Object)); - }); - - it('should call onQueryTimeout when changed', () => { - const onQueryTimeout = jest.fn(); - const result = render( - {}} - onConnMaxLifetimeChange={() => {}} - onConnMaxOpenConnsChange={() => {}} - onDialTimeoutChange={() => {}} - onQueryTimeoutChange={onQueryTimeout} - onValidateSqlChange={() => {}} - /> - ); - expect(result.container.firstChild).not.toBeNull(); - - const input = result.getByPlaceholderText(allLabels.components.Config.QuerySettingsConfig.queryTimeout.placeholder); - expect(input).toBeInTheDocument(); - fireEvent.change(input, { target: { value: '10' } }); - fireEvent.blur(input); - expect(onQueryTimeout).toHaveBeenCalledTimes(1); - expect(onQueryTimeout).toHaveBeenCalledWith(expect.any(Object)); - }); - - it('should call onValidateSqlChange when changed', () => { - const onValidateSqlChange = jest.fn(); - const result = render( - {}} - onConnMaxLifetimeChange={() => {}} - onConnMaxOpenConnsChange={() => {}} - onDialTimeoutChange={() => {}} - onQueryTimeoutChange={() => {}} - onValidateSqlChange={onValidateSqlChange} - /> - ); - expect(result.container.firstChild).not.toBeNull(); - - const input = result.getByRole('checkbox'); - expect(input).toBeInTheDocument(); - fireEvent.click(input); - expect(onValidateSqlChange).toHaveBeenCalledTimes(1); - expect(onValidateSqlChange).toHaveBeenCalledWith(expect.any(Object)); - }); - - it('should call onConnMaxIdleConnsChange when changed', () => { - const onConnMaxIdleConnsChange = jest.fn(); - const result = render( - {}} - onConnMaxOpenConnsChange={() => {}} - onDialTimeoutChange={() => {}} - onQueryTimeoutChange={() => {}} - onValidateSqlChange={() => {}} - /> - ); - expect(result.container.firstChild).not.toBeNull(); - - const input = result.getByPlaceholderText(allLabels.components.Config.QuerySettingsConfig.maxIdleConns.placeholder); - expect(input).toBeInTheDocument(); - fireEvent.change(input, { target: { value: '10' } }); - fireEvent.blur(input); - expect(onConnMaxIdleConnsChange).toHaveBeenCalledTimes(1); - expect(onConnMaxIdleConnsChange).toHaveBeenCalledWith(expect.any(Object)); - }); - - it('should call onConnMaxLifetimeChange when changed', () => { - const onConnMaxLifetimeChange = jest.fn(); - const result = render( - {}} - onConnMaxLifetimeChange={onConnMaxLifetimeChange} - onConnMaxOpenConnsChange={() => {}} - onDialTimeoutChange={() => {}} - onQueryTimeoutChange={() => {}} - onValidateSqlChange={() => {}} - /> - ); - expect(result.container.firstChild).not.toBeNull(); - - const input = result.getByPlaceholderText(allLabels.components.Config.QuerySettingsConfig.connMaxLifetime.placeholder); - expect(input).toBeInTheDocument(); - fireEvent.change(input, { target: { value: '10' } }); - fireEvent.blur(input); - expect(onConnMaxLifetimeChange).toHaveBeenCalledTimes(1); - expect(onConnMaxLifetimeChange).toHaveBeenCalledWith(expect.any(Object)); - }); - - it('should call onConnMaxOpenConnsChange when changed', () => { - const onConnMaxOpenConnsChange = jest.fn(); - const result = render( - {}} - onConnMaxLifetimeChange={() => {}} - onConnMaxOpenConnsChange={onConnMaxOpenConnsChange} - onDialTimeoutChange={() => {}} - onQueryTimeoutChange={() => {}} - onValidateSqlChange={() => {}} - /> - ); - expect(result.container.firstChild).not.toBeNull(); - - const input = result.getByPlaceholderText(allLabels.components.Config.QuerySettingsConfig.maxOpenConns.placeholder); - expect(input).toBeInTheDocument(); - fireEvent.change(input, { target: { value: '10' } }); - fireEvent.blur(input); - expect(onConnMaxOpenConnsChange).toHaveBeenCalledTimes(1); - expect(onConnMaxOpenConnsChange).toHaveBeenCalledWith(expect.any(Object)); - }); }); diff --git a/src/components/configEditor/QuerySettingsConfig.tsx b/src/components/configEditor/QuerySettingsConfig.tsx index e6a14df..5958ad7 100644 --- a/src/components/configEditor/QuerySettingsConfig.tsx +++ b/src/components/configEditor/QuerySettingsConfig.tsx @@ -1,44 +1,38 @@ import React, { FormEvent } from 'react'; -import { Switch, Input, Field } from '@grafana/ui'; +import { Switch, Field } from '@grafana/ui'; import { ConfigSection } from 'components/experimental/ConfigSection'; import allLabels from 'labels'; interface QuerySettingsConfigProps { - connMaxLifetime?: string; - dialTimeout?: string; - maxIdleConns?: string; - maxOpenConns?: string; - queryTimeout?: string; - validateSql?: boolean; - onConnMaxIdleConnsChange: (e: FormEvent) => void; - onConnMaxLifetimeChange: (e: FormEvent) => void; - onConnMaxOpenConnsChange: (e: FormEvent) => void; - onDialTimeoutChange: (e: FormEvent) => void; - onQueryTimeoutChange: (e: FormEvent) => void; - onValidateSqlChange: (e: FormEvent) => void; + + filterValidationEnabled?: boolean; + + onFilterValidationEnabledChange: (e: FormEvent) => void; } export const QuerySettingsConfig = (props: QuerySettingsConfigProps) => { const { - connMaxLifetime, - dialTimeout, - maxIdleConns, - maxOpenConns, - queryTimeout, - validateSql, - onConnMaxIdleConnsChange, - onConnMaxLifetimeChange, - onConnMaxOpenConnsChange, - onDialTimeoutChange, - onQueryTimeoutChange, - onValidateSqlChange, + // connMaxLifetime, + // dialTimeout, + // maxIdleConns, + // maxOpenConns, + // queryTimeout, + // validateSql, + filterValidationEnabled, + // onConnMaxIdleConnsChange, + // onConnMaxLifetimeChange, + // onConnMaxOpenConnsChange, + // onDialTimeoutChange, + // onQueryTimeoutChange, + // onValidateSqlChange, + onFilterValidationEnabledChange, } = props; const labels = allLabels.components.Config.QuerySettingsConfig; return ( - + {/* { + */} + + + ); diff --git a/src/components/queryBuilder/FilterEditor.tsx b/src/components/queryBuilder/FilterEditor.tsx index 90fe907..05ab28c 100644 --- a/src/components/queryBuilder/FilterEditor.tsx +++ b/src/components/queryBuilder/FilterEditor.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { SelectableValue } from '@grafana/data'; import { Button, HorizontalGroup, InlineFormLabel, Input, MultiSelect, RadioButtonGroup, Select } from '@grafana/ui'; import { Filter, FilterOperator, TableColumn, NullFilter } from 'types/queryBuilder'; @@ -7,6 +7,7 @@ import labels from 'labels'; import { styles } from 'styles'; import { Datasource } from 'data/CHDatasource'; import useUniqueMapKeys from 'hooks/useUniqueMapKeys'; +import { validateUserFilters } from './filterValidator'; const boolValues: Array> = [ { value: true, label: 'True' }, @@ -418,20 +419,38 @@ export const FiltersEditor = (props: { }) => { const { filters = [], onFiltersChange, allColumns: fieldsList = [], datasource, database, table } = props; const { label, tooltip, addLabel } = labels.components.FilterEditor; + + // Add validation state + const [validationError, setValidationError] = useState(); + const isFilterValidationEnabled = datasource.settings.jsonData.filterValidationEnabled || false; const addFilter = () => { onFiltersChange([...filters, { ...defaultNewFilter }]); }; + const removeFilter = (index: number) => { const newFilters = [...filters]; newFilters.splice(index, 1); onFiltersChange(newFilters); }; + const onFilterChange = (index: number, filter: Filter) => { const newFilters = [...filters]; newFilters[index] = filter; onFiltersChange(newFilters); }; + useEffect(() => { + if (!isFilterValidationEnabled) { + return; + } + const validation = validateUserFilters(filters); + if (validation.isValid) { + setValidationError(undefined); + } else { + setValidationError(validation.error); + } + }, [filters, isFilterValidationEnabled]); + return ( <> {filters.length === 0 && ( @@ -451,6 +470,9 @@ export const FiltersEditor = (props: { )} + + + {filters.map((filter, index) => { return (
@@ -489,6 +511,13 @@ export const FiltersEditor = (props: {
)} + {/* Display validation error */} + {validationError && ( +
+
+
⚠️ {validationError}
+
+ )} ); }; diff --git a/src/components/queryBuilder/filterValidator.ts b/src/components/queryBuilder/filterValidator.ts new file mode 100644 index 0000000..efc4f89 --- /dev/null +++ b/src/components/queryBuilder/filterValidator.ts @@ -0,0 +1,52 @@ +import { Filter, FilterOperator, ColumnHint } from 'types/queryBuilder'; + +export interface FilterValidationResult { + isValid: boolean; + error?: string; +} + +/** + * Validates if there's at least one user-added filter (excluding default time range filters) + */ +export const validateUserFilters = (filters: Filter[]): FilterValidationResult => { + // Count user-added filters (excluding default time range filters) + let hasDefaultTimeRangeFilter = false; + const userFilters = filters.filter(filter => { + if (!filter.key && !filter.hint) { + return false; + } + + // Exclude default time range filters that are automatically added + const isDefaultTimeRange = filter.hint === ColumnHint.Time + if (!hasDefaultTimeRangeFilter && isDefaultTimeRange) { + return false + } + hasDefaultTimeRangeFilter = true; + return true; + }); + + if (userFilters.length === 0) { + return { + isValid: false, + error: 'At least one non-default time range condition is required' + }; + } + + return { isValid: true }; +}; + +/** + * Checks if a specific filter is a default time range filter + */ +export const isDefaultTimeRangeFilter = (filter: Filter): boolean => { + return filter.hint === ColumnHint.Time && + filter.operator === FilterOperator.WithInGrafanaTimeRange && + filter.id === 'timeRange'; +}; + +/** + * Gets only user-added filters (excluding default time range filters) + */ +export const getUserFilters = (filters: Filter[]): Filter[] => { + return filters.filter(filter => !isDefaultTimeRangeFilter(filter)); +}; diff --git a/src/data/sqlGenerator.ts b/src/data/sqlGenerator.ts index 581f681..6a77cc0 100644 --- a/src/data/sqlGenerator.ts +++ b/src/data/sqlGenerator.ts @@ -377,7 +377,7 @@ const generateSimpleTimeSeriesQuery = (_options: QueryBuilderOptions): string => } if ((options.groupBy?.length || 0) > 0) { - const groupByTime = timeColumn !== undefined ? `, ${timeColumn.name}` : ''; + const groupByTime = timeColumn !== undefined ? `, ${timeColumn.columnName || timeColumn.name}` : ''; queryParts.push(`${options.groupBy!.join(', ')}${groupByTime}`); } else if (hasAggregates && timeColumn) { queryParts.push(timeColumn.name!); @@ -411,8 +411,9 @@ const generateAggregateTimeSeriesQuery = (_options: QueryBuilderOptions): string const timeColumn = getColumnByHint(options, ColumnHint.Time); if (timeColumn !== undefined) { // timeColumn.name = `$__timeInterval(${timeColumn.name})`; + timeColumn.columnName = timeColumn.name; timeColumn.name = `date_trunc('minute', ${timeColumn.name})` - // timeColumn.alias = 'time'; + timeColumn.alias = 'time'; selectParts.push(getColumnIdentifier(timeColumn)); } @@ -442,7 +443,7 @@ const generateAggregateTimeSeriesQuery = (_options: QueryBuilderOptions): string const groupByTime = timeColumn !== undefined ? `, ${timeColumn.name}` : ''; queryParts.push(`${options.groupBy!.join(', ')}${groupByTime}`); } else if (timeColumn) { - queryParts.push(timeColumn.name!); + queryParts.push(timeColumn.alias!); } const orderBy = getOrderBy(options); @@ -691,7 +692,7 @@ const getFilters = (options: QueryBuilderOptions): string => { let type = filter.type; const hintedColumn = filter.hint && getColumnByHint(options, filter.hint); if (hintedColumn) { - column = hintedColumn.name; + column = hintedColumn.columnName || hintedColumn.name; type = hintedColumn.type || type; } diff --git a/src/types/config.ts b/src/types/config.ts index 180d72b..99392b2 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -28,6 +28,11 @@ export interface CHConfig extends DataSourceJsonData { queryTimeout?: string; validateSql?: boolean; + /** + * Enable filter validation to require at least one user-added filter + */ + filterValidationEnabled?: boolean; + logs?: CHLogsConfig; traces?: CHTracesConfig; diff --git a/src/views/CHConfigEditor.tsx b/src/views/CHConfigEditor.tsx index 7088f4a..4c6a73d 100644 --- a/src/views/CHConfigEditor.tsx +++ b/src/views/CHConfigEditor.tsx @@ -22,6 +22,7 @@ import { TimeUnit } from 'types/queryBuilder'; import { DefaultDatabaseTableConfig } from 'components/configEditor/DefaultDatabaseTableConfig'; import { LogsConfig } from 'components/configEditor/LogsConfig'; import { TracesConfig } from 'components/configEditor/TracesConfig'; +import { QuerySettingsConfig } from 'components/configEditor/QuerySettingsConfig'; import allLabels from 'labels'; import { useConfigDefaults } from './CHConfigEditorHooks'; import {AliasTableConfig} from "../components/configEditor/AliasTableConfig"; @@ -35,7 +36,7 @@ export const ConfigEditor: React.FC = (props) => { useConfigDefaults(options, onOptionsChange); const onSwitchToggle = ( - key: keyof Pick, + key: keyof Pick, value: boolean ) => { onOptionsChange({ @@ -231,6 +232,13 @@ export const ConfigEditor: React.FC = (props) => { onDefaultTableChange={onUpdateDatasourceJsonDataOption(props, 'defaultTable')} /> + + onSwitchToggle('filterValidationEnabled', e.currentTarget.checked)} + />