Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Column alias tables for simplified querying #862

Merged
merged 6 commits into from
Jun 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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();
SpencerTorres marked this conversation as resolved.
Show resolved Hide resolved
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);
SpencerTorres marked this conversation as resolved.
Show resolved Hide resolved
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
Loading