From 5db6b9f986ede72da21ae211fe3e01d62d8bcddf Mon Sep 17 00:00:00 2001 From: Spencer Torres Date: Sun, 16 Jun 2024 11:37:12 -0400 Subject: [PATCH] Feature: Column alias tables for simplified querying (#862) --- .config/tsconfig.json | 2 +- CHANGELOG.md | 6 + src/__mocks__/datasource.ts | 1 + .../configEditor/AliasTableConfig.test.tsx | 120 ++++++++++++ .../configEditor/AliasTableConfig.tsx | 172 ++++++++++++++++++ .../queryBuilder/AggregateEditor.tsx | 2 +- src/components/queryBuilder/ColumnSelect.tsx | 16 +- src/components/queryBuilder/ColumnsEditor.tsx | 9 +- src/components/queryBuilder/FilterEditor.tsx | 15 +- src/components/queryBuilder/OrderByEditor.tsx | 4 +- .../queryBuilder/QueryBuilder.test.tsx | 2 +- src/components/queryBuilder/utils.test.ts | 4 +- src/data/CHDatasource.test.ts | 54 +++++- src/data/CHDatasource.ts | 52 +++++- src/data/sqlGenerator.test.ts | 11 +- src/data/sqlGenerator.ts | 6 +- src/hooks/useColumns.test.ts | 6 +- src/hooks/useColumns.ts | 4 +- src/labels.ts | 11 ++ src/selectors.ts | 9 + src/types/config.ts | 9 + src/types/queryBuilder.ts | 6 + src/views/CHConfigEditor.tsx | 22 ++- 23 files changed, 500 insertions(+), 43 deletions(-) create mode 100644 src/components/configEditor/AliasTableConfig.test.tsx create mode 100644 src/components/configEditor/AliasTableConfig.tsx diff --git a/.config/tsconfig.json b/.config/tsconfig.json index 64b37690..a28a7a7c 100644 --- a/.config/tsconfig.json +++ b/.config/tsconfig.json @@ -11,7 +11,7 @@ "rootDir": "../src", "baseUrl": "../src", "typeRoots": ["../node_modules/@types"], - "resolveJsonModule": true + "resolveJsonModule": true, }, "ts-node": { "compilerOptions": { diff --git a/CHANGELOG.md b/CHANGELOG.md index 14b9038d..9e23eaa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Added the ability to define column alias tables in the config, which simplifies query syntax for tables with a known schema. + ## 4.0.8 ### Fixes diff --git a/src/__mocks__/datasource.ts b/src/__mocks__/datasource.ts index 08840acb..53e6a7ec 100644 --- a/src/__mocks__/datasource.ts +++ b/src/__mocks__/datasource.ts @@ -19,6 +19,7 @@ export const newMockDatasource = (): Datasource => { username: 'user', defaultDatabase: 'foo', defaultTable: 'bar', + aliasTables: [], protocol: Protocol.Native, }, readOnly: true, diff --git a/src/components/configEditor/AliasTableConfig.test.tsx b/src/components/configEditor/AliasTableConfig.test.tsx new file mode 100644 index 00000000..8e0eb849 --- /dev/null +++ b/src/components/configEditor/AliasTableConfig.test.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { AliasTableConfig } from './AliasTableConfig'; +import { selectors as allSelectors } from 'selectors'; +import { AliasTableEntry } from 'types/config'; + +describe('AliasTableConfig', () => { + const selectors = allSelectors.components.Config.AliasTableConfig; + + it('should render', () => { + const result = render( {}} />); + expect(result.container.firstChild).not.toBeNull(); + }); + + it('should not call onAliasTablesChange when entry is added', () => { + const onAliasTablesChange = jest.fn(); + const result = render( + + ); + expect(result.container.firstChild).not.toBeNull(); + + const addEntryButton = result.getByTestId(selectors.addEntryButton); + expect(addEntryButton).toBeInTheDocument(); + fireEvent.click(addEntryButton); + + expect(onAliasTablesChange).toHaveBeenCalledTimes(0); + }); + + it('should call onAliasTablesChange when entry is updated', () => { + const onAliasTablesChange = jest.fn(); + const result = render( + + ); + expect(result.container.firstChild).not.toBeNull(); + + const addEntryButton = result.getByTestId(selectors.addEntryButton); + expect(addEntryButton).toBeInTheDocument(); + fireEvent.click(addEntryButton); + + const aliasEditor = result.getByTestId(selectors.aliasEditor); + expect(aliasEditor).toBeInTheDocument(); + + const targetDatabaseInput = result.getByTestId(selectors.targetDatabaseInput); + expect(targetDatabaseInput).toBeInTheDocument(); + fireEvent.change(targetDatabaseInput, { target: { value: 'default ' } }); // with space in name + fireEvent.blur(targetDatabaseInput); + expect(targetDatabaseInput).toHaveValue('default '); + expect(onAliasTablesChange).toHaveBeenCalledTimes(1); + + const targetTableInput = result.getByTestId(selectors.targetTableInput); + expect(targetTableInput).toBeInTheDocument(); + fireEvent.change(targetTableInput, { target: { value: 'query_log' } }); + fireEvent.blur(targetTableInput); + expect(targetTableInput).toHaveValue('query_log'); + expect(onAliasTablesChange).toHaveBeenCalledTimes(2); + + const aliasDatabaseInput = result.getByTestId(selectors.aliasDatabaseInput); + expect(aliasDatabaseInput).toBeInTheDocument(); + fireEvent.change(aliasDatabaseInput, { target: { value: 'default_aliases ' } }); // with space in name + fireEvent.blur(aliasDatabaseInput); + expect(aliasDatabaseInput).toHaveValue('default_aliases '); + expect(onAliasTablesChange).toHaveBeenCalledTimes(3); + + const aliasTableInput = result.getByTestId(selectors.aliasTableInput); + expect(aliasTableInput).toBeInTheDocument(); + fireEvent.change(aliasTableInput, { target: { value: 'query_log_aliases' } }); + fireEvent.blur(aliasTableInput); + expect(aliasTableInput).toHaveValue('query_log_aliases'); + expect(onAliasTablesChange).toHaveBeenCalledTimes(4); + + const expected: AliasTableEntry[] = [ + { + targetDatabase: 'default', // without space in name + targetTable: 'query_log', + aliasDatabase: 'default_aliases', // without space in name + aliasTable: 'query_log_aliases', + } + ]; + expect(onAliasTablesChange).toHaveBeenCalledWith(expect.objectContaining(expected)); + }); + + it('should call onAliasTablesChange when entry is removed', () => { + const onAliasTablesChange = jest.fn(); + const result = render( + + ); + expect(result.container.firstChild).not.toBeNull(); + + const removeEntryButton = result.getAllByTestId(selectors.removeEntryButton)[0]; // Get 1st + expect(removeEntryButton).toBeInTheDocument(); + fireEvent.click(removeEntryButton); + + const expected: AliasTableEntry[] = [ + { + targetDatabase: '', targetTable: 'query_log2', + aliasDatabase: '', aliasTable: 'query_log2_aliases' + }, + ]; + expect(onAliasTablesChange).toHaveBeenCalledTimes(1); + expect(onAliasTablesChange).toHaveBeenCalledWith(expect.objectContaining(expected)); + }); +}); diff --git a/src/components/configEditor/AliasTableConfig.tsx b/src/components/configEditor/AliasTableConfig.tsx new file mode 100644 index 00000000..d5fdb8ce --- /dev/null +++ b/src/components/configEditor/AliasTableConfig.tsx @@ -0,0 +1,172 @@ +import React, {ChangeEvent, useState} from 'react'; +import {ConfigSection} from '@grafana/experimental'; +import {Input, Field, HorizontalGroup, Button} from '@grafana/ui'; +import {AliasTableEntry} from 'types/config'; +import allLabels from 'labels'; +import {styles} from 'styles'; +import {selectors as allSelectors} from 'selectors'; + +interface AliasTablesConfigProps { + aliasTables?: AliasTableEntry[]; + onAliasTablesChange: (v: AliasTableEntry[]) => void; +} + +export const AliasTableConfig = (props: AliasTablesConfigProps) => { + const {onAliasTablesChange} = props; + const [entries, setEntries] = useState(props.aliasTables || []); + const labels = allLabels.components.Config.AliasTableConfig; + const selectors = allSelectors.components.Config.AliasTableConfig; + + const entryToUniqueKey = (entry: AliasTableEntry) => `"${entry.targetDatabase}"."${entry.targetTable}":"${entry.aliasDatabase}"."${entry.aliasTable}"`; + const removeDuplicateEntries = (entries: AliasTableEntry[]): AliasTableEntry[] => { + const duplicateKeys = new Set(); + return entries.filter(entry => { + const key = entryToUniqueKey(entry); + if (duplicateKeys.has(key)) { + return false; + } + + duplicateKeys.add(key); + return true; + }); + }; + + const addEntry = () => { + setEntries(removeDuplicateEntries([...entries, { + targetDatabase: '', + targetTable: '', + aliasDatabase: '', + aliasTable: '' + }])); + } + const removeEntry = (index: number) => { + let nextEntries: AliasTableEntry[] = entries.slice(); + nextEntries.splice(index, 1); + nextEntries = removeDuplicateEntries(nextEntries); + setEntries(nextEntries); + onAliasTablesChange(nextEntries); + }; + const updateEntry = (index: number, entry: AliasTableEntry) => { + let nextEntries: AliasTableEntry[] = entries.slice(); + entry.targetDatabase = entry.targetDatabase.trim(); + entry.targetTable = entry.targetTable.trim(); + entry.aliasDatabase = entry.aliasDatabase.trim(); + entry.aliasTable = entry.aliasTable.trim(); + nextEntries[index] = entry; + + nextEntries = removeDuplicateEntries(nextEntries); + setEntries(nextEntries); + onAliasTablesChange(nextEntries); + }; + + return ( + +
+ {labels.descriptionParts[0]} + {labels.descriptionParts[1]} + {labels.descriptionParts[2]} +
+
+ + {entries.map((entry, index) => ( + updateEntry(index, e)} + onRemove={() => removeEntry(index)} + /> + ))} + +
+ ); +} + +interface AliasTableEditorProps { + targetDatabase: string; + targetTable: string; + aliasDatabase: string; + aliasTable: string; + onEntryChange: (v: AliasTableEntry) => void; + onRemove?: () => void; +} + +const AliasTableEditor = (props: AliasTableEditorProps) => { + const {onEntryChange, onRemove} = props; + const [targetDatabase, setTargetDatabase] = useState(props.targetDatabase); + const [targetTable, setTargetTable] = useState(props.targetTable); + const [aliasDatabase, setAliasDatabase] = useState(props.aliasDatabase); + const [aliasTable, setAliasTable] = useState(props.aliasTable); + const labels = allLabels.components.Config.AliasTableConfig; + const selectors = allSelectors.components.Config.AliasTableConfig; + + const onUpdate = () => { + onEntryChange({targetDatabase, targetTable, aliasDatabase, aliasTable}); + } + + return ( +
+ + + ) => setTargetDatabase(e.target.value)} + onBlur={() => onUpdate()} + /> + + + ) => setTargetTable(e.target.value)} + onBlur={() => onUpdate()} + /> + + + ) => setAliasDatabase(e.target.value)} + onBlur={() => onUpdate()} + /> + + + ) => setAliasTable(e.target.value)} + onBlur={() => onUpdate()} + /> + + {onRemove && +
+ ); +} diff --git a/src/components/queryBuilder/AggregateEditor.tsx b/src/components/queryBuilder/AggregateEditor.tsx index f0638d39..75454b08 100644 --- a/src/components/queryBuilder/AggregateEditor.tsx +++ b/src/components/queryBuilder/AggregateEditor.tsx @@ -98,7 +98,7 @@ const allColumnName = '*'; export const AggregateEditor = (props: AggregateEditorProps) => { const { allColumns, aggregates, onAggregatesChange } = props; const { label, tooltip, addLabel } = labels.components.AggregatesEditor; - const columnOptions: Array> = allColumns.map(c => ({ label: c.name, value: c.name })); + const columnOptions: Array> = allColumns.map(c => ({ label: c.label || c.name, value: c.name })); columnOptions.push({ label: allColumnName, value: allColumnName }); const addAggregate = () => { diff --git a/src/components/queryBuilder/ColumnSelect.tsx b/src/components/queryBuilder/ColumnSelect.tsx index 03d1bbfd..7ddbb804 100644 --- a/src/components/queryBuilder/ColumnSelect.tsx +++ b/src/components/queryBuilder/ColumnSelect.tsx @@ -26,12 +26,12 @@ export const ColumnSelect = (props: ColumnSelectProps) => { const selectedColumnName = selectedColumn?.name; const columns: Array> = allColumns. filter(columnFilterFn || defaultFilterFn). - map(c => ({ label: c.name, value: c.name })); + map(c => ({ label: c.label || c.name, value: c.name })); // Select component WILL NOT display the value if it isn't present in the options. let staleOption = false; if (selectedColumn && !columns.find(c => c.value === selectedColumn.name)) { - columns.push({ label: selectedColumn.name, value: selectedColumn.name }); + columns.push({ label: selectedColumn.alias || selectedColumn.name, value: selectedColumn.name }); staleOption = true; } @@ -42,11 +42,17 @@ export const ColumnSelect = (props: ColumnSelectProps) => { } const column = allColumns.find(c => c.name === selected!.value)!; - onColumnChange({ + const nextColumn: SelectedColumn = { name: column?.name || selected!.value, type: column?.type, - hint: columnHint - }); + hint: columnHint, + }; + + if (column && column.label !== undefined) { + nextColumn.alias = column.label; + } + + onColumnChange(nextColumn); } const labelStyle = 'query-keyword ' + (inline ? styles.QueryEditor.inlineField : ''); diff --git a/src/components/queryBuilder/ColumnsEditor.tsx b/src/components/queryBuilder/ColumnsEditor.tsx index 66be0e88..0b0c12d2 100644 --- a/src/components/queryBuilder/ColumnsEditor.tsx +++ b/src/components/queryBuilder/ColumnsEditor.tsx @@ -18,7 +18,7 @@ function getCustomColumns(columnNames: string[], allColumns: readonly TableColum const columnNamesSet = new Set(columnNames); return allColumns. filter(c => columnNamesSet.has(c.name)). - map(c => ({ label: c.name, value: c.name })); + map(c => ({ label: c.label || c.name, value: c.name })); } const allColumnName = '*'; @@ -27,11 +27,11 @@ export const ColumnsEditor = (props: ColumnsEditorProps) => { const { allColumns, selectedColumns, onSelectedColumnsChange, disabled, showAllOption } = props; const [customColumns, setCustomColumns] = useState>>([]); const [isOpen, setIsOpen] = useState(false); - const allColumnNames = allColumns.map(c => ({ label: c.name, value: c.name })); + const allColumnNames = allColumns.map(c => ({ label: c.label || c.name, value: c.name })); if (showAllOption) { allColumnNames.push({ label: allColumnName, value: allColumnName }); } - const selectedColumnNames = (selectedColumns || []).map(c => ({ label: c.name, value: c.name })); + const selectedColumnNames = (selectedColumns || []).map(c => ({ label: c.alias || c.name, value: c.name })); const { label, tooltip } = labels.components.ColumnsEditor; const options = [...allColumnNames, ...customColumns]; @@ -71,7 +71,8 @@ export const ColumnsEditor = (props: ColumnsEditorProps) => { nextSelectedColumns.push({ name: columnName, type: tableColumn?.type || 'String', - custom: customColumnNames.has(columnName) + custom: customColumnNames.has(columnName), + alias: tableColumn?.label || columnName, }); } } diff --git a/src/components/queryBuilder/FilterEditor.tsx b/src/components/queryBuilder/FilterEditor.tsx index bb3a6886..8d3a5b18 100644 --- a/src/components/queryBuilder/FilterEditor.tsx +++ b/src/components/queryBuilder/FilterEditor.tsx @@ -206,12 +206,12 @@ export const FilterEditor = (props: { const mapKeys = useUniqueMapKeys(props.datasource, isMapType ? filter.key : '', props.database, props.table); const mapKeyOptions = mapKeys.map(k => ({ label: k, value: k })); if (filter.mapKey && !mapKeys.includes(filter.mapKey)) { - mapKeyOptions.push({ label: filter.mapKey, value: filter.mapKey }); + mapKeyOptions.push({ label: filter.label || filter.mapKey, value: filter.mapKey }); } const getFields = () => { const values = (filter.restrictToFields || fieldsList).map(f => { - let label = f.name; + let label = f.label || f.name; if (f.type.startsWith('Map')) { label += '[]'; } @@ -220,7 +220,7 @@ export const FilterEditor = (props: { }); // Add selected value to the list if it does not exist. if (filter?.key && !values.find((x) => x.value === filter.key)) { - values.push({ label: filter.key!, value: filter.key! }); + values.push({ label: filter.label || filter.key!, value: filter.key! }); } return values; }; @@ -284,7 +284,8 @@ export const FilterEditor = (props: { const matchingField = fieldsList.find(f => f.name === fieldName); const filterData = { key: matchingField?.name || fieldName, - type: matchingField?.type || 'String' + type: matchingField?.type || 'String', + label: matchingField?.label, }; let newFilter: Filter & PredefinedFilter; @@ -297,6 +298,7 @@ export const FilterEditor = (props: { condition: filter.condition || 'AND', operator: FilterOperator.WithInGrafanaTimeRange, restrictToFields: filter.restrictToFields, + label: filterData.label, }; } else if (utils.isBooleanType(filterData.type)) { newFilter = { @@ -306,6 +308,7 @@ export const FilterEditor = (props: { condition: filter.condition || 'AND', operator: FilterOperator.Equals, value: false, + label: filterData.label, }; } else if (utils.isDateType(filterData.type)) { newFilter = { @@ -315,6 +318,7 @@ export const FilterEditor = (props: { condition: filter.condition || 'AND', operator: FilterOperator.Equals, value: 'TODAY', + label: filterData.label, }; } else { newFilter = { @@ -323,6 +327,7 @@ export const FilterEditor = (props: { type: filterData.type, condition: filter.condition || 'AND', operator: FilterOperator.IsNotNull, + label: filterData.label, }; } onFilterChange(index, newFilter); @@ -370,7 +375,7 @@ export const FilterEditor = (props: { allowCustomValue menuPlacement={'bottom'} /> - { isMapType && + { isMapType &&