Skip to content

Commit

Permalink
Feature: Column alias tables for simplified querying (#862)
Browse files Browse the repository at this point in the history
SpencerTorres authored Jun 16, 2024
1 parent 00a4c97 commit 5db6b9f
Showing 23 changed files with 500 additions and 43 deletions.
2 changes: 1 addition & 1 deletion .config/tsconfig.json
Original file line number Diff line number Diff line change
@@ -11,7 +11,7 @@
"rootDir": "../src",
"baseUrl": "../src",
"typeRoots": ["../node_modules/@types"],
"resolveJsonModule": true
"resolveJsonModule": true,
},
"ts-node": {
"compilerOptions": {
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions src/__mocks__/datasource.ts
Original file line number Diff line number Diff line change
@@ -19,6 +19,7 @@ export const newMockDatasource = (): Datasource => {
username: 'user',
defaultDatabase: 'foo',
defaultTable: 'bar',
aliasTables: [],
protocol: Protocol.Native,
},
readOnly: true,
120 changes: 120 additions & 0 deletions src/components/configEditor/AliasTableConfig.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<AliasTableConfig aliasTables={[]} onAliasTablesChange={() => {}} />);
expect(result.container.firstChild).not.toBeNull();
});

it('should not call onAliasTablesChange when entry is added', () => {
const onAliasTablesChange = jest.fn();
const result = render(
<AliasTableConfig
aliasTables={[]}
onAliasTablesChange={onAliasTablesChange}
/>
);
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(
<AliasTableConfig
aliasTables={[]}
onAliasTablesChange={onAliasTablesChange}
/>
);
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(
<AliasTableConfig
aliasTables={[
{
targetDatabase: '', targetTable: 'query_log',
aliasDatabase: '', aliasTable: 'query_log_aliases'
},
{
targetDatabase: '', targetTable: 'query_log2',
aliasDatabase: '', aliasTable: 'query_log2_aliases'
},
]}
onAliasTablesChange={onAliasTablesChange}
/>
);
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));
});
});
172 changes: 172 additions & 0 deletions src/components/configEditor/AliasTableConfig.tsx
Original file line number Diff line number Diff line change
@@ -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<AliasTableEntry[]>(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 (
<ConfigSection
title={labels.title}
>
<div>
<span>{labels.descriptionParts[0]}</span>
<code>{labels.descriptionParts[1]}</code>
<span>{labels.descriptionParts[2]}</span>
</div>
<br/>

{entries.map((entry, index) => (
<AliasTableEditor
key={entryToUniqueKey(entry)}
targetDatabase={entry.targetDatabase}
targetTable={entry.targetTable}
aliasDatabase={entry.aliasDatabase}
aliasTable={entry.aliasTable}
onEntryChange={e => updateEntry(index, e)}
onRemove={() => removeEntry(index)}
/>
))}
<Button
data-testid={selectors.addEntryButton}
icon="plus-circle"
variant="secondary"
size="sm"
onClick={addEntry}
className={styles.Common.smallBtn}
>
{labels.addTableLabel}
</Button>
</ConfigSection>
);
}

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<string>(props.targetDatabase);
const [targetTable, setTargetTable] = useState<string>(props.targetTable);
const [aliasDatabase, setAliasDatabase] = useState<string>(props.aliasDatabase);
const [aliasTable, setAliasTable] = useState<string>(props.aliasTable);
const labels = allLabels.components.Config.AliasTableConfig;
const selectors = allSelectors.components.Config.AliasTableConfig;

const onUpdate = () => {
onEntryChange({targetDatabase, targetTable, aliasDatabase, aliasTable});
}

return (
<div data-testid={selectors.aliasEditor}>
<HorizontalGroup>
<Field label={labels.targetDatabaseLabel} aria-label={labels.targetDatabaseLabel}>
<Input
data-testid={selectors.targetDatabaseInput}
value={targetDatabase}
placeholder={labels.targetDatabasePlaceholder}
onChange={(e: ChangeEvent<HTMLInputElement>) => setTargetDatabase(e.target.value)}
onBlur={() => onUpdate()}
/>
</Field>
<Field label={labels.targetTableLabel} aria-label={labels.targetTableLabel}>
<Input
data-testid={selectors.targetTableInput}
value={targetTable}
placeholder={labels.targetTableLabel}
onChange={(e: ChangeEvent<HTMLInputElement>) => setTargetTable(e.target.value)}
onBlur={() => onUpdate()}
/>
</Field>
<Field label={labels.aliasDatabaseLabel} aria-label={labels.aliasDatabaseLabel}>
<Input
data-testid={selectors.aliasDatabaseInput}
value={aliasDatabase}
placeholder={labels.aliasDatabasePlaceholder}
onChange={(e: ChangeEvent<HTMLInputElement>) => setAliasDatabase(e.target.value)}
onBlur={() => onUpdate()}
/>
</Field>
<Field label={labels.aliasTableLabel} aria-label={labels.aliasTableLabel}>
<Input
data-testid={selectors.aliasTableInput}
value={aliasTable}
placeholder={labels.aliasTableLabel}
onChange={(e: ChangeEvent<HTMLInputElement>) => setAliasTable(e.target.value)}
onBlur={() => onUpdate()}
/>
</Field>
{onRemove &&
<Button
data-testid={selectors.removeEntryButton}
className={styles.Common.smallBtn}
variant="destructive"
size="sm"
icon="trash-alt"
onClick={onRemove}
/>
}
</HorizontalGroup>
</div>
);
}
2 changes: 1 addition & 1 deletion src/components/queryBuilder/AggregateEditor.tsx
Original file line number Diff line number Diff line change
@@ -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<SelectableValue<string>> = allColumns.map(c => ({ label: c.name, value: c.name }));
const columnOptions: Array<SelectableValue<string>> = allColumns.map(c => ({ label: c.label || c.name, value: c.name }));
columnOptions.push({ label: allColumnName, value: allColumnName });

const addAggregate = () => {
16 changes: 11 additions & 5 deletions src/components/queryBuilder/ColumnSelect.tsx
Original file line number Diff line number Diff line change
@@ -26,12 +26,12 @@ export const ColumnSelect = (props: ColumnSelectProps) => {
const selectedColumnName = selectedColumn?.name;
const columns: Array<SelectableValue<string>> = 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 : '');
9 changes: 5 additions & 4 deletions src/components/queryBuilder/ColumnsEditor.tsx
Original file line number Diff line number Diff line change
@@ -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<Array<SelectableValue<string>>>([]);
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,
});
}
}
15 changes: 10 additions & 5 deletions src/components/queryBuilder/FilterEditor.tsx
Original file line number Diff line number Diff line change
@@ -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 &&
<Select
value={filter.mapKey}
placeholder={labels.components.FilterEditor.mapKeyPlaceholder}
4 changes: 2 additions & 2 deletions src/components/queryBuilder/OrderByEditor.tsx
Original file line number Diff line number Diff line change
@@ -141,7 +141,7 @@ export const getOrderByOptions = (builder: QueryBuilderOptions, allColumns: read

if (isAggregateQuery(builder)) {
builder.columns?.forEach(c => {
allOptions.push({ label: c.name, value: c.name });
allOptions.push({ label: c.alias || c.name, value: c.name });
});

builder.aggregates!.forEach(a => {
@@ -160,7 +160,7 @@ export const getOrderByOptions = (builder: QueryBuilderOptions, allColumns: read
builder.groupBy.forEach(g => allOptions.push({ label: g, value: g }));
}
} else {
allColumns.forEach(c => allOptions.push({ label: c.name, value: c.name }));
allColumns.forEach(c => allOptions.push({ label: c.label || c.name, value: c.name }));
}

// Add selected value to the list if it does not exist.
2 changes: 1 addition & 1 deletion src/components/queryBuilder/QueryBuilder.test.tsx
Original file line number Diff line number Diff line change
@@ -11,7 +11,7 @@ describe('QueryBuilder', () => {
const mockDs = { settings: { jsonData: {} } } as Datasource;
mockDs.fetchDatabases = jest.fn(() => Promise.resolve([]));
mockDs.fetchTables = jest.fn((_db?: string) => Promise.resolve([]));
mockDs.fetchColumnsFull = jest.fn(() => {
mockDs.fetchColumns = jest.fn(() => {
setState();
return Promise.resolve([]);
});
4 changes: 2 additions & 2 deletions src/components/queryBuilder/utils.test.ts
Original file line number Diff line number Diff line change
@@ -414,7 +414,7 @@ describe('getQueryOptionsFromSql', () => {

testCondition(
'handles timeseries function with "timeFieldType: DateType"',
'SELECT $__timeInterval(time) as time FROM "db"."foo" GROUP BY time',
'SELECT $__timeInterval(time) as "time" FROM "db"."foo" GROUP BY time',
{
queryType: QueryType.TimeSeries,
mode: BuilderMode.Trend,
@@ -429,7 +429,7 @@ describe('getQueryOptionsFromSql', () => {

testCondition(
'handles timeseries function with "timeFieldType: DateType" with a filter',
'SELECT $__timeInterval(time) as time FROM "db"."foo" WHERE ( base IS NOT NULL ) GROUP BY time',
'SELECT $__timeInterval(time) as "time" FROM "db"."foo" WHERE ( base IS NOT NULL ) GROUP BY time',
{
queryType: QueryType.TimeSeries,
mode: BuilderMode.Trend,
54 changes: 48 additions & 6 deletions src/data/CHDatasource.test.ts
Original file line number Diff line number Diff line change
@@ -249,12 +249,12 @@ describe('ClickHouseDatasource', () => {
});
});

describe('fetchColumnsFull', () => {
describe('fetchColumnsFromTable', () => {
it('sends a correct query when database and table names are provided', async () => {
const ds = cloneDeep(mockDatasource);
const frame = new ArrayDataFrame([{ name: 'foo', type: 'string', table: 'table' }]);
const spyOnQuery = jest.spyOn(ds, 'query').mockImplementation((_request) => of({ data: [frame] }));
await ds.fetchColumnsFull('db_name', 'table_name');
await ds.fetchColumnsFromTable('db_name', 'table_name');
const expected = { rawSql: 'DESC TABLE "db_name"."table_name"' };

expect(spyOnQuery).toHaveBeenCalledWith(
@@ -266,7 +266,7 @@ describe('ClickHouseDatasource', () => {
const ds = cloneDeep(mockDatasource);
const frame = new ArrayDataFrame([{ name: 'foo', type: 'string', table: 'table' }]);
const spyOnQuery = jest.spyOn(ds, 'query').mockImplementation((_request) => of({ data: [frame] }));
await ds.fetchColumnsFull('', 'table_name');
await ds.fetchColumnsFromTable('', 'table_name');
const expected = { rawSql: 'DESC TABLE "table_name"' };

expect(spyOnQuery).toHaveBeenCalledWith(
@@ -279,7 +279,7 @@ describe('ClickHouseDatasource', () => {
const frame = new ArrayDataFrame([{ name: 'foo', type: 'string', table: 'table' }]);
const spyOnQuery = jest.spyOn(ds, 'query').mockImplementation((_) => of({ data: [frame] }));

await ds.fetchColumnsFull('', 'table.name');
await ds.fetchColumnsFromTable('', 'table.name');
const expected = { rawSql: 'DESC TABLE "table.name"' };

expect(spyOnQuery).toHaveBeenCalledWith(
@@ -288,6 +288,48 @@ describe('ClickHouseDatasource', () => {
});
});

describe('fetchColumnsFromAliasTable', () => {
it('sends a correct query when full table name is provided', async () => {
const ds = cloneDeep(mockDatasource);
const frame = new ArrayDataFrame([{ name: 'foo', type: 'string', table: 'table' }]);
const spyOnQuery = jest.spyOn(ds, 'query').mockImplementation((_request) => of({ data: [frame] }));
await ds.fetchColumnsFromAliasTable('"db_name"."table_name"');
const expected = { rawSql: 'SELECT alias, select, "type" FROM "db_name"."table_name"' };

expect(spyOnQuery).toHaveBeenCalledWith(
expect.objectContaining({ targets: expect.arrayContaining([expect.objectContaining(expected)]) })
);
});
});

describe('getAliasTable', () => {
it('returns the matching table alias', async () => {
const ds = cloneDeep(mockDatasource);
ds.settings.jsonData.aliasTables = [{
targetDatabase: 'db_name',
targetTable: 'table_name',
aliasDatabase: 'alias_db',
aliasTable: 'alias_table'
}];
const result = ds.getAliasTable('db_name', 'table_name');
const expected = '"alias_db"."alias_table"';

expect(result).toBe(expected);
});

it('returns null when no alias matches found', async () => {
const ds = cloneDeep(mockDatasource);
ds.settings.jsonData.aliasTables = [{
targetDatabase: 'db_name',
targetTable: 'table_name',
aliasDatabase: 'alias_db',
aliasTable: 'alias_table'
}];
const result = ds.getAliasTable('other_db', 'other_table');
expect(result).toBeNull();
});
});

describe('query', () => {
it('filters out hidden queries', async () => {
const instance = cloneDeep(mockDatasource);
@@ -421,7 +463,7 @@ describe('ClickHouseDatasource', () => {
} as QueryBuilderOptions,
});
expect(result?.rawSql).toEqual(
'SELECT toStartOfInterval("created_at", INTERVAL 1 DAY) as time, count(*) as logs ' +
'SELECT toStartOfInterval("created_at", INTERVAL 1 DAY) as "time", count(*) as logs ' +
'FROM "default"."logs" ' +
'GROUP BY time ' +
'ORDER BY time ASC'
@@ -434,7 +476,7 @@ describe('ClickHouseDatasource', () => {
.mockReturnValue('toStartOfInterval("created_at", INTERVAL 1 DAY)');
const result = datasource.getSupplementaryLogsVolumeQuery(request, query);
expect(result?.rawSql).toEqual(
`SELECT toStartOfInterval("created_at", INTERVAL 1 DAY) as time, ` +
`SELECT toStartOfInterval("created_at", INTERVAL 1 DAY) as "time", ` +
`sum(toString("level") IN ('critical','fatal','crit','alert','emerg','CRITICAL','FATAL','CRIT','ALERT','EMERG','Critical','Fatal','Crit','Alert','Emerg')) as critical, ` +
`sum(toString("level") IN ('error','err','eror','ERROR','ERR','EROR','Error','Err','Eror')) as error, ` +
`sum(toString("level") IN ('warn','warning','WARN','WARNING','Warn','Warning')) as warn, ` +
52 changes: 51 additions & 1 deletion src/data/CHDatasource.ts
Original file line number Diff line number Diff line change
@@ -496,6 +496,8 @@ export class Datasource
*
* Samples rows to get a unique set of keys for the map.
* May not include ALL keys for a given dataset.
*
* TODO: This query can be slow/expensive
*/
async fetchUniqueMapKeys(mapColumn: string, db: string, table: string): Promise<string[]> {
const rawSql = `SELECT DISTINCT arrayJoin(${mapColumn}.keys) as keys FROM "${db}"."${table}" LIMIT 1000`;
@@ -510,7 +512,10 @@ export class Datasource
return this.fetchData(`DESC TABLE "${database}"."${table}"`);
}

async fetchColumnsFull(database: string | undefined, table: string): Promise<TableColumn[]> {
/**
* Fetches column suggestions from the table schema.
*/
async fetchColumnsFromTable(database: string | undefined, table: string): Promise<TableColumn[]> {
const prefix = Boolean(database) ? `"${database}".` : '';
const rawSql = `DESC TABLE ${prefix}"${table}"`;
const frame = await this.runQuery({ rawSql });
@@ -526,6 +531,51 @@ export class Datasource
}));
}

/**
* Fetches column suggestions from an alias definition table.
*/
async fetchColumnsFromAliasTable(fullTableName: string): Promise<TableColumn[]> {
const rawSql = `SELECT alias, select, "type" FROM ${fullTableName}`;
const frame = await this.runQuery({ rawSql });
if (frame.fields?.length === 0) {
return [];
}
const view = new DataFrameView(frame);
return view.map(item => ({
name: item[1],
type: item[2],
label: item[0],
picklistValues: [],
}));
}

getAliasTable(targetDatabase: string | undefined, targetTable: string): string | null {
const aliasEntries = this.settings?.jsonData?.aliasTables || [];
const matchedEntry = aliasEntries.find(e => {
const matchDatabase = !e.targetDatabase || (e.targetDatabase === targetDatabase);
const matchTable = e.targetTable === targetTable;
return matchDatabase && matchTable;
}) || null;

if (matchedEntry === null) {
return null;
}

const aliasDatabase = matchedEntry.aliasDatabase || targetDatabase || null;
const aliasTable = matchedEntry.aliasTable;
const prefix = Boolean(aliasDatabase) ? `"${aliasDatabase}".` : '';
return `${prefix}"${aliasTable}"`;
}

async fetchColumns(database: string | undefined, table: string): Promise<TableColumn[]> {
const fullAliasTableName = this.getAliasTable(database, table);
if (fullAliasTableName !== null) {
return this.fetchColumnsFromAliasTable(fullAliasTableName);
}

return this.fetchColumnsFromTable(database, table);
}

private async fetchData(rawSql: string) {
const frame = await this.runQuery({ rawSql });
return this.values(frame);
11 changes: 6 additions & 5 deletions src/data/sqlGenerator.test.ts
Original file line number Diff line number Diff line change
@@ -105,7 +105,7 @@ describe('SQL Generator', () => {
};

const expectedSqlParts = [
'SELECT log_ts as timestamp, log_body as body, log_level as level',
'SELECT log_ts as "timestamp", log_body as "body", log_level as "level"',
'FROM "default"."logs"',
'WHERE ( timestamp >= $__fromTime AND timestamp <= $__toTime )',
'AND ( level = \'error\' )',
@@ -139,7 +139,7 @@ describe('SQL Generator', () => {
orderBy: [{ name: '', hint: ColumnHint.Time, dir: OrderByDirection.ASC }]
};
const expectedSqlParts = [
'SELECT time_field as time, number_field',
'SELECT time_field as "time", number_field',
'FROM "default"."time_data" WHERE ( number_field > 0 )',
'ORDER BY time ASC LIMIT 100'
];
@@ -172,7 +172,7 @@ describe('SQL Generator', () => {
orderBy: [{ name: '', hint: ColumnHint.Time, dir: OrderByDirection.ASC }]
};
const expectedSqlParts = [
'SELECT time_field as time, number_field, sum(number_field) as total',
'SELECT time_field as "time", number_field, sum(number_field) as total',
'FROM "default"."time_data" WHERE ( number_field > 0 )',
'GROUP BY time ORDER BY time ASC LIMIT 100'
];
@@ -404,8 +404,9 @@ describe('getColumnIdentifier', () => {
{ input: { name: ' ' }, expected: `" "` },
{ input: { name: 'test' }, expected: `test` },
{ input: { name: 'test with space' }, expected: `"test with space"` },
{ input: { name: 'test with alias', alias: 'a' }, expected: `"test with alias" as a` },
{ input: { name: 'test_with_alias', alias: 'b' }, expected: `test_with_alias as b` },
{ input: { name: 'test with alias', alias: 'a' }, expected: `"test with alias" as "a"` },
{ input: { name: 'test_with_alias', alias: 'b' }, expected: `test_with_alias as "b"` },
{ input: { name: '"test" as a', alias: '' }, expected: `"test" as a` },
];

it.each(cases)('returns correct identifier (case %#)', (c) => {
6 changes: 3 additions & 3 deletions src/data/sqlGenerator.ts
Original file line number Diff line number Diff line change
@@ -524,14 +524,14 @@ const getColumnIdentifier = (col: SelectedColumn): string => {
let colName = col.name;

// allow for functions like count()
if (colName.includes('(') || colName.includes(')') || colName.includes('"') || colName.includes('"')) {
if (colName.includes('(') || colName.includes(')') || colName.includes('"') || colName.includes('"') || colName.includes(' as ')) {
colName = col.name
} else if (colName.includes(' ')) {
colName = escapeIdentifier(col.name);
}

if (col.alias) {
return `${colName} as ${col.alias}`
if (col.alias && (col.alias !== col.name && escapeIdentifier(col.alias) !== colName)) {
return `${colName} as "${col.alias}"`
}

return colName;
6 changes: 3 additions & 3 deletions src/hooks/useColumns.test.ts
Original file line number Diff line number Diff line change
@@ -17,7 +17,7 @@ describe('useColumns', () => {

it('should return empty array if database string is empty', async () => {
const mockDs = {} as Datasource;
mockDs.fetchColumnsFull = jest.fn((db: string, table: string) => Promise.resolve([]));
mockDs.fetchColumns = jest.fn((db: string, table: string) => Promise.resolve([]));
let result: { current: readonly TableColumn[] };
await act(async () => {
const r = renderHook(() => useColumns(mockDs, '', 'table'));
@@ -29,7 +29,7 @@ describe('useColumns', () => {

it('should return empty array if table string is empty', async () => {
const mockDs = {} as Datasource;
mockDs.fetchColumnsFull = jest.fn((db: string, table: string) => Promise.resolve([]));
mockDs.fetchColumns = jest.fn((db: string, table: string) => Promise.resolve([]));
let result: { current: readonly TableColumn[] };
await act(async () => {
const r = renderHook(() => useColumns(mockDs, 'db', ''));
@@ -41,7 +41,7 @@ describe('useColumns', () => {

it('should fetch table columns', async () => {
const mockDs = {} as Datasource;
mockDs.fetchColumnsFull = jest.fn(
mockDs.fetchColumns = jest.fn(
(db: string, table: string) => Promise.resolve([
{ name: 'a', type: 'string', picklistValues: [] },
{ name: 'b', type: 'string', picklistValues: [] },
4 changes: 1 addition & 3 deletions src/hooks/useColumns.ts
Original file line number Diff line number Diff line change
@@ -11,9 +11,7 @@ export default (datasource: Datasource, database: string, table: string): readon
}

let ignore = false;
datasource
.fetchColumnsFull(database, table)
.then(columns => {
datasource.fetchColumns(database, table).then(columns => {
if (ignore) {
return;
}
11 changes: 11 additions & 0 deletions src/labels.ts
Original file line number Diff line number Diff line change
@@ -86,6 +86,17 @@ export default {
tooltip: 'Forward Grafana HTTP Headers to datasource.',
},
},
AliasTableConfig: {
title: 'Column Alias Tables',
descriptionParts: ['Provide alias tables with a', '(`alias` String, `select` String, `type` String)', 'schema to use as a source for column selection.'],
addTableLabel: 'Add Table',
targetDatabaseLabel: 'Target Database',
targetDatabasePlaceholder: '(optional)',
targetTableLabel: 'Target Table',
aliasDatabaseLabel: 'Alias Database',
aliasDatabasePlaceholder: '(optional)',
aliasTableLabel: 'Alias Table',
},

DefaultDatabaseTableConfig: {
title: 'Default DB and table',
9 changes: 9 additions & 0 deletions src/selectors.ts
Original file line number Diff line number Diff line change
@@ -122,6 +122,15 @@ export const Components = {
headerNameInput: 'config__http-header-config__header-name-input',
headerValueInput: 'config__http-header-config__header-value-input',
forwardGrafanaHeadersSwitch: 'config__http-header-config__forward-grafana-headers-switch'
},
AliasTableConfig: {
aliasEditor: 'config__alias-table-config__alias-editor',
addEntryButton: 'config__alias-table-config__add-entry-button',
removeEntryButton: 'config__alias-table-config__remove-entry-button',
targetDatabaseInput: 'config__alias-table-config__target-database-input',
targetTableInput: 'config__alias-table-config__target-table-input',
aliasDatabaseInput: 'config__alias-table-config__alias-database-input',
aliasTableInput: 'config__alias-table-config__alias-table-input',
}
},
QueryBuilder: {
9 changes: 9 additions & 0 deletions src/types/config.ts
Original file line number Diff line number Diff line change
@@ -28,6 +28,8 @@ export interface CHConfig extends DataSourceJsonData {
logs?: CHLogsConfig;
traces?: CHTracesConfig;

aliasTables?: AliasTableEntry[];

httpHeaders?: CHHttpHeader[];
forwardGrafanaHeaders?: boolean;

@@ -87,6 +89,13 @@ export interface CHTracesConfig {
serviceTagsColumn?: string;
}

export interface AliasTableEntry {
targetDatabase: string;
targetTable: string;
aliasDatabase: string;
aliasTable: string;
}

export enum Protocol {
Native = 'native',
Http = 'http',
6 changes: 6 additions & 0 deletions src/types/queryBuilder.ts
Original file line number Diff line number Diff line change
@@ -105,6 +105,7 @@ export interface TableColumn {
name: string;
type: string;
picklistValues: TableColumnPickListItem[];
label?: string;
filterable?: boolean;
sortable?: boolean;
groupable?: boolean;
@@ -227,6 +228,11 @@ export interface CommonFilterProps {
* for the filter to be applied unless key is also provided.
*/
hint?: ColumnHint;

/**
* Display label for filter
*/
label?: string;
}

export interface NullFilter extends CommonFilterProps {
22 changes: 21 additions & 1 deletion src/views/CHConfigEditor.tsx
Original file line number Diff line number Diff line change
@@ -6,7 +6,15 @@ import {
} from '@grafana/data';
import { RadioButtonGroup, Switch, Input, SecretInput, Button, Field, HorizontalGroup } from '@grafana/ui';
import { CertificationKey } from '../components/ui/CertificationKey';
import { CHConfig, CHCustomSetting, CHSecureConfig, CHLogsConfig, Protocol, CHTracesConfig } from 'types/config';
import {
CHConfig,
CHCustomSetting,
CHSecureConfig,
CHLogsConfig,
Protocol,
CHTracesConfig,
AliasTableEntry
} from 'types/config';
import { gte as versionGte } from 'semver';
import { ConfigSection, ConfigSubSection, DataSourceDescription } from '@grafana/experimental';
import { config } from '@grafana/runtime';
@@ -19,6 +27,7 @@ import { TracesConfig } from 'components/configEditor/TracesConfig';
import { HttpHeadersConfig } from 'components/configEditor/HttpHeadersConfig';
import allLabels from 'labels';
import { onHttpHeadersChange, useConfigDefaults } from './CHConfigEditorHooks';
import {AliasTableConfig} from "../components/configEditor/AliasTableConfig";

export interface ConfigEditorProps extends DataSourcePluginOptionsEditorProps<CHConfig, CHSecureConfig> {}

@@ -150,6 +159,15 @@ export const ConfigEditor: React.FC<ConfigEditorProps> = (props) => {
}
});
};
const onAliasTableConfigChange = (aliasTables: AliasTableEntry[]) => {
onOptionsChange({
...options,
jsonData: {
...options.jsonData,
aliasTables
}
});
};

const [customSettings, setCustomSettings] = useState(jsonData.customSettings || []);

@@ -406,6 +424,8 @@ export const ConfigEditor: React.FC<ConfigEditorProps> = (props) => {
onServiceTagsColumnChange={c => onTracesConfigChange('serviceTagsColumn', c)}
/>

<Divider />
<AliasTableConfig aliasTables={jsonData.aliasTables} onAliasTablesChange={onAliasTableConfigChange} />
<Divider />
{config.featureToggles['secureSocksDSProxyEnabled'] && versionGte(config.buildInfo.version, '10.0.0') && (
<Field

0 comments on commit 5db6b9f

Please sign in to comment.