Skip to content

Commit

Permalink
Feature: Column alias tables for simplified querying (#862)
Browse files Browse the repository at this point in the history
  • Loading branch information
SpencerTorres authored Jun 16, 2024
1 parent 00a4c97 commit 5db6b9f
Show file tree
Hide file tree
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
Expand Up @@ -11,7 +11,7 @@
"rootDir": "../src",
"baseUrl": "../src",
"typeRoots": ["../node_modules/@types"],
"resolveJsonModule": true
"resolveJsonModule": true,
},
"ts-node": {
"compilerOptions": {
Expand Down
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
Expand Down
1 change: 1 addition & 0 deletions src/__mocks__/datasource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const newMockDatasource = (): Datasource => {
username: 'user',
defaultDatabase: 'foo',
defaultTable: 'bar',
aliasTables: [],
protocol: Protocol.Native,
},
readOnly: true,
Expand Down
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
Expand Up @@ -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 = () => {
Expand Down
16 changes: 11 additions & 5 deletions src/components/queryBuilder/ColumnSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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 : '');
Expand Down
Loading

0 comments on commit 5db6b9f

Please sign in to comment.