sql: extract frontend code into separate package (#81109)

* sql: extract frontend code into separate package

* updated package version
This commit is contained in:
Gábor Farkas
2024-01-26 11:38:29 +01:00
committed by GitHub
parent 2febbec758
commit 29e8a355cb
87 changed files with 187 additions and 104 deletions
@@ -0,0 +1,51 @@
import React, { useRef, useEffect } from 'react';
import { Button, Icon, Modal } from '@grafana/ui';
type ConfirmModalProps = {
isOpen: boolean;
onCancel?: () => void;
onDiscard?: () => void;
onCopy?: () => void;
};
export function ConfirmModal({ isOpen, onCancel, onDiscard, onCopy }: ConfirmModalProps) {
const buttonRef = useRef<HTMLButtonElement>(null);
// Moved from grafana/ui
useEffect(() => {
// for some reason autoFocus property did no work on this button, but this does
if (isOpen) {
buttonRef.current?.focus();
}
}, [isOpen]);
return (
<Modal
title={
<div className="modal-header-title">
<Icon name="exclamation-triangle" size="lg" />
<span className="p-l-1">Warning</span>
</div>
}
onDismiss={onCancel}
isOpen={isOpen}
>
<p>
Builder mode does not display changes made in code. The query builder will display the last changes you made in
builder mode.
</p>
<p>Do you want to copy your code to the clipboard?</p>
<Modal.ButtonRow>
<Button type="button" variant="secondary" onClick={onCancel} fill="outline">
Cancel
</Button>
<Button variant="destructive" type="button" onClick={onDiscard} ref={buttonRef}>
Discard code and switch
</Button>
<Button variant="primary" onClick={onCopy}>
Copy code and switch
</Button>
</Modal.ButtonRow>
</Modal>
);
}
@@ -0,0 +1,79 @@
import React, { useEffect } from 'react';
import { useAsync } from 'react-use';
import { SelectableValue } from '@grafana/data';
import { Select } from '@grafana/ui';
import { DB, ResourceSelectorProps, SQLDialect, toOption } from '../types';
import { isSqlDatasourceDatabaseSelectionFeatureFlagEnabled } from './QueryEditorFeatureFlag.utils';
export interface DatasetSelectorProps extends ResourceSelectorProps {
db: DB;
dataset: string | undefined;
preconfiguredDataset: string;
dialect: SQLDialect;
onChange: (v: SelectableValue) => void;
}
export const DatasetSelector = ({ dataset, db, dialect, onChange, preconfiguredDataset }: DatasetSelectorProps) => {
/*
The behavior of this component - for MSSQL and MySQL datasources - is based on whether the user chose to create a datasource
with or without a default database (preconfiguredDataset). If the user configured a default database, this selector
should only allow that single preconfigured database option to be selected. If the user chose to NOT assign/configure a default database,
then the user should be able to use this component to choose between multiple databases available to the datasource.
*/
// `hasPreconfigCondition` is true if either 1) the sql datasource has a preconfigured default database,
// OR if 2) the datasource is Postgres. In either case the only option available to the user is the preconfigured database.
const hasPreconfigCondition = !!preconfiguredDataset || dialect === 'postgres';
const state = useAsync(async () => {
if (isSqlDatasourceDatabaseSelectionFeatureFlagEnabled()) {
// If a default database is already configured for a MSSQL or MySQL data source, OR the data source is Postgres, no need to fetch other databases.
if (hasPreconfigCondition) {
// Set the current database to the preconfigured database.
onChange(toOption(preconfiguredDataset));
return [toOption(preconfiguredDataset)];
}
}
// If there is no preconfigured database, but there is a selected dataset, set the current database to the selected dataset.
if (dataset) {
onChange(toOption(dataset));
}
// Otherwise, fetch all databases available to the datasource.
const datasets = await db.datasets();
return datasets.map(toOption);
}, []);
useEffect(() => {
if (!isSqlDatasourceDatabaseSelectionFeatureFlagEnabled()) {
// Set default dataset when values are fetched
if (!dataset) {
if (state.value && state.value[0]) {
onChange(state.value[0]);
}
} else {
if (state.value && state.value.find((v) => v.value === dataset) === undefined) {
// if value is set and newly fetched values does not contain selected value
if (state.value.length > 0) {
onChange(state.value[0]);
}
}
}
}
}, [state.value, onChange, dataset]);
return (
<Select
aria-label="Dataset selector"
value={dataset}
options={state.value}
onChange={onChange}
disabled={state.loading}
isLoading={state.loading}
menuShouldPortal={true}
/>
);
};
@@ -0,0 +1,25 @@
import React from 'react';
type Props = {
fallBackComponent?: React.ReactNode;
};
export class ErrorBoundary extends React.Component<React.PropsWithChildren<Props>, { hasError: boolean }> {
constructor(props: React.PropsWithChildren<Props>) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
const FallBack = this.props.fallBackComponent || <div>Error</div>;
return FallBack;
}
return this.props.children;
}
}
@@ -0,0 +1,134 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useAsync } from 'react-use';
import { QueryEditorProps } from '@grafana/data';
import { EditorMode } from '@grafana/experimental';
import { Space } from '@grafana/ui';
import { SqlDatasource } from '../datasource/SqlDatasource';
import { applyQueryDefaults } from '../defaults';
import { SQLQuery, QueryRowFilter, SQLOptions } from '../types';
import { haveColumns } from '../utils/sql.utils';
import { QueryHeader, QueryHeaderProps } from './QueryHeader';
import { RawEditor } from './query-editor-raw/RawEditor';
import { VisualEditor } from './visual-query-builder/VisualEditor';
interface SqlQueryEditorProps extends QueryEditorProps<SqlDatasource, SQLQuery, SQLOptions> {
queryHeaderProps?: Pick<QueryHeaderProps, 'dialect'>;
}
export function SqlQueryEditor({
datasource,
query,
onChange,
onRunQuery,
range,
queryHeaderProps,
}: SqlQueryEditorProps) {
const [isQueryRunnable, setIsQueryRunnable] = useState(true);
const db = datasource.getDB();
const { preconfiguredDatabase } = datasource;
const dialect = queryHeaderProps?.dialect ?? 'other';
const { loading, error } = useAsync(async () => {
return () => {
if (datasource.getDB(datasource.id).init !== undefined) {
datasource.getDB(datasource.id).init!();
}
};
}, [datasource]);
const queryWithDefaults = applyQueryDefaults(query);
const [queryRowFilter, setQueryRowFilter] = useState<QueryRowFilter>({
filter: !!queryWithDefaults.sql?.whereString,
group: !!queryWithDefaults.sql?.groupBy?.[0]?.property.name,
order: !!queryWithDefaults.sql?.orderBy?.property.name,
preview: true,
});
const [queryToValidate, setQueryToValidate] = useState(queryWithDefaults);
useEffect(() => {
return () => {
if (datasource.getDB(datasource.id).dispose !== undefined) {
datasource.getDB(datasource.id).dispose!();
}
};
}, [datasource]);
const processQuery = useCallback(
(q: SQLQuery) => {
if (isQueryValid(q) && onRunQuery) {
onRunQuery();
}
},
[onRunQuery]
);
const onQueryChange = (q: SQLQuery, process = true) => {
setQueryToValidate(q);
onChange(q);
if (haveColumns(q.sql?.columns) && q.sql?.columns.some((c) => c.name) && !queryRowFilter.group) {
setQueryRowFilter({ ...queryRowFilter, group: true });
}
if (process) {
processQuery(q);
}
};
const onQueryHeaderChange = (q: SQLQuery) => {
setQueryToValidate(q);
onChange(q);
};
if (loading || error) {
return null;
}
return (
<>
<QueryHeader
db={db}
preconfiguredDataset={preconfiguredDatabase}
onChange={onQueryHeaderChange}
onRunQuery={onRunQuery}
onQueryRowChange={setQueryRowFilter}
queryRowFilter={queryRowFilter}
query={queryWithDefaults}
isQueryRunnable={isQueryRunnable}
dialect={dialect}
/>
<Space v={0.5} />
{queryWithDefaults.editorMode !== EditorMode.Code && (
<VisualEditor
db={db}
query={queryWithDefaults}
onChange={(q: SQLQuery) => onQueryChange(q, false)}
queryRowFilter={queryRowFilter}
onValidate={setIsQueryRunnable}
range={range}
/>
)}
{queryWithDefaults.editorMode === EditorMode.Code && (
<RawEditor
db={db}
query={queryWithDefaults}
queryToValidate={queryToValidate}
onChange={onQueryChange}
onRunQuery={onRunQuery}
onValidate={setIsQueryRunnable}
range={range}
/>
)}
</>
);
}
const isQueryValid = (q: SQLQuery) => {
return Boolean(q.rawSql);
};
@@ -0,0 +1,5 @@
import { config } from '@grafana/runtime';
export const isSqlDatasourceDatabaseSelectionFeatureFlagEnabled = () => {
return !!config.featureToggles.sqlDatasourceDatabaseSelection;
};
@@ -0,0 +1,315 @@
import React, { useCallback, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { v4 as uuidv4 } from 'uuid';
import { SelectableValue } from '@grafana/data';
import { EditorField, EditorHeader, EditorMode, EditorRow, FlexItem, InlineSelect } from '@grafana/experimental';
import { reportInteraction } from '@grafana/runtime';
import { Button, InlineSwitch, RadioButtonGroup, Tooltip, Space } from '@grafana/ui';
import { QueryWithDefaults } from '../defaults';
import { SQLQuery, QueryFormat, QueryRowFilter, QUERY_FORMAT_OPTIONS, DB, SQLDialect } from '../types';
import { ConfirmModal } from './ConfirmModal';
import { DatasetSelector } from './DatasetSelector';
import { isSqlDatasourceDatabaseSelectionFeatureFlagEnabled } from './QueryEditorFeatureFlag.utils';
import { TableSelector } from './TableSelector';
export interface QueryHeaderProps {
db: DB;
dialect: SQLDialect;
isQueryRunnable: boolean;
onChange: (query: SQLQuery) => void;
onQueryRowChange: (queryRowFilter: QueryRowFilter) => void;
onRunQuery: () => void;
preconfiguredDataset: string;
query: QueryWithDefaults;
queryRowFilter: QueryRowFilter;
}
const editorModes = [
{ label: 'Builder', value: EditorMode.Builder },
{ label: 'Code', value: EditorMode.Code },
];
export function QueryHeader({
db,
dialect,
isQueryRunnable,
onChange,
onQueryRowChange,
onRunQuery,
preconfiguredDataset,
query,
queryRowFilter,
}: QueryHeaderProps) {
const { editorMode } = query;
const [_, copyToClipboard] = useCopyToClipboard();
const [showConfirm, setShowConfirm] = useState(false);
const toRawSql = db.toRawSql;
const onEditorModeChange = useCallback(
(newEditorMode: EditorMode) => {
if (newEditorMode === EditorMode.Code) {
reportInteraction('grafana_sql_editor_mode_changed', {
datasource: query.datasource?.type,
selectedEditorMode: EditorMode.Code,
});
}
if (editorMode === EditorMode.Code) {
setShowConfirm(true);
return;
}
onChange({ ...query, editorMode: newEditorMode });
},
[editorMode, onChange, query]
);
const onFormatChange = (e: SelectableValue) => {
const next = { ...query, format: e.value !== undefined ? e.value : QueryFormat.Table };
reportInteraction('grafana_sql_format_changed', {
datasource: query.datasource?.type,
selectedFormat: next.format,
});
onChange(next);
};
const onDatasetChange = (e: SelectableValue) => {
if (e.value === query.dataset) {
return;
}
const next = {
...query,
dataset: e.value,
table: undefined,
sql: undefined,
rawSql: '',
};
onChange(next);
};
const onTableChange = (e: SelectableValue) => {
if (e.value === query.table) {
return;
}
const next: SQLQuery = {
...query,
table: e.value,
sql: undefined,
rawSql: '',
};
onChange(next);
};
const datasetDropdownIsAvailable = () => {
if (dialect === 'influx') {
return false;
}
// If the feature flag is DISABLED, && the datasource is Postgres (`dialect = 'postgres`),
// we want to hide the dropdown - as per previous behavior.
if (!isSqlDatasourceDatabaseSelectionFeatureFlagEnabled() && dialect === 'postgres') {
return false;
}
return true;
};
return (
<>
<EditorHeader>
<InlineSelect
label="Format"
value={query.format}
placeholder="Select format"
menuShouldPortal
onChange={onFormatChange}
options={QUERY_FORMAT_OPTIONS}
/>
{editorMode === EditorMode.Builder && (
<>
<InlineSwitch
id={`sql-filter-${uuidv4()}}`}
label="Filter"
transparent={true}
showLabel={true}
value={queryRowFilter.filter}
onChange={(ev) => {
if (!(ev.target instanceof HTMLInputElement)) {
return;
}
reportInteraction('grafana_sql_filter_toggled', {
datasource: query.datasource?.type,
displayed: ev.target.checked,
});
onQueryRowChange({ ...queryRowFilter, filter: ev.target.checked });
}}
/>
<InlineSwitch
id={`sql-group-${uuidv4()}}`}
label="Group"
transparent={true}
showLabel={true}
value={queryRowFilter.group}
onChange={(ev) => {
if (!(ev.target instanceof HTMLInputElement)) {
return;
}
reportInteraction('grafana_sql_group_toggled', {
datasource: query.datasource?.type,
displayed: ev.target.checked,
});
onQueryRowChange({ ...queryRowFilter, group: ev.target.checked });
}}
/>
<InlineSwitch
id={`sql-order-${uuidv4()}}`}
label="Order"
transparent={true}
showLabel={true}
value={queryRowFilter.order}
onChange={(ev) => {
if (!(ev.target instanceof HTMLInputElement)) {
return;
}
reportInteraction('grafana_sql_order_toggled', {
datasource: query.datasource?.type,
displayed: ev.target.checked,
});
onQueryRowChange({ ...queryRowFilter, order: ev.target.checked });
}}
/>
<InlineSwitch
id={`sql-preview-${uuidv4()}}`}
label="Preview"
transparent={true}
showLabel={true}
value={queryRowFilter.preview}
onChange={(ev) => {
if (!(ev.target instanceof HTMLInputElement)) {
return;
}
reportInteraction('grafana_sql_preview_toggled', {
datasource: query.datasource?.type,
displayed: ev.target.checked,
});
onQueryRowChange({ ...queryRowFilter, preview: ev.target.checked });
}}
/>
</>
)}
<FlexItem grow={1} />
{isQueryRunnable ? (
<Button icon="play" variant="primary" size="sm" onClick={() => onRunQuery()}>
Run query
</Button>
) : (
<Tooltip
theme="error"
content={
<>
Your query is invalid. Check below for details. <br />
However, you can still run this query.
</>
}
placement="top"
>
<Button icon="exclamation-triangle" variant="secondary" size="sm" onClick={() => onRunQuery()}>
Run query
</Button>
</Tooltip>
)}
<RadioButtonGroup options={editorModes} size="sm" value={editorMode} onChange={onEditorModeChange} />
<ConfirmModal
isOpen={showConfirm}
onCopy={() => {
reportInteraction('grafana_sql_editor_mode_changed', {
datasource: query.datasource?.type,
selectedEditorMode: EditorMode.Builder,
type: 'copy',
});
setShowConfirm(false);
copyToClipboard(query.rawSql!);
onChange({
...query,
rawSql: toRawSql(query),
editorMode: EditorMode.Builder,
});
}}
onDiscard={() => {
reportInteraction('grafana_sql_editor_mode_changed', {
datasource: query.datasource?.type,
selectedEditorMode: EditorMode.Builder,
type: 'discard',
});
setShowConfirm(false);
onChange({
...query,
rawSql: toRawSql(query),
editorMode: EditorMode.Builder,
});
}}
onCancel={() => {
reportInteraction('grafana_sql_editor_mode_changed', {
datasource: query.datasource?.type,
selectedEditorMode: EditorMode.Builder,
type: 'cancel',
});
setShowConfirm(false);
}}
/>
</EditorHeader>
{editorMode === EditorMode.Builder && (
<>
<Space v={0.5} />
<EditorRow>
{datasetDropdownIsAvailable() && (
<EditorField label="Dataset" width={25}>
<DatasetSelector
db={db}
dataset={query.dataset}
dialect={dialect}
preconfiguredDataset={preconfiguredDataset}
onChange={onDatasetChange}
/>
</EditorField>
)}
<EditorField label="Table" width={25}>
<TableSelector
db={db}
dataset={query.dataset || preconfiguredDataset}
table={query.table}
onChange={onTableChange}
/>
</EditorField>
</EditorRow>
</>
)}
</>
);
}
@@ -0,0 +1,116 @@
import { render, waitFor } from '@testing-library/react';
import React from 'react';
import { config } from '@grafana/runtime';
import { SQLExpression } from '../types';
import { makeVariable } from '../utils/testHelpers';
import { DatasetSelector } from './DatasetSelector';
import { buildMockDatasetSelectorProps, buildMockTableSelectorProps } from './SqlComponents.testHelpers';
import { TableSelector } from './TableSelector';
import { removeQuotesForMultiVariables } from './visual-query-builder/SQLWhereRow';
beforeEach(() => {
config.featureToggles.sqlDatasourceDatabaseSelection = true;
});
afterEach(() => {
config.featureToggles.sqlDatasourceDatabaseSelection = false;
});
describe('DatasetSelector', () => {
it('should only query the database when needed', async () => {
const mockProps = buildMockDatasetSelectorProps();
render(<DatasetSelector {...mockProps} />);
await waitFor(() => {
expect(mockProps.db.datasets).toHaveBeenCalled();
});
});
it('should not query the database if Postgres instance, and no preconfigured database', async () => {
const mockProps = buildMockDatasetSelectorProps({ dialect: 'postgres' });
render(<DatasetSelector {...mockProps} />);
await waitFor(() => {
expect(mockProps.db.datasets).not.toHaveBeenCalled();
});
});
it('should not query the database if preconfigured', async () => {
const mockProps = buildMockDatasetSelectorProps({ preconfiguredDataset: 'database 1' });
render(<DatasetSelector {...mockProps} />);
await waitFor(() => {
expect(mockProps.db.datasets).not.toHaveBeenCalled();
});
});
});
describe('TableSelector', () => {
it('should only query the database when needed', async () => {
const mockProps = buildMockTableSelectorProps({ dataset: 'database 1' });
render(<TableSelector {...mockProps} />);
await waitFor(() => {
expect(mockProps.db.tables).toHaveBeenCalled();
});
});
it('should not query the database if no dataset is passed as a prop', async () => {
const mockProps = buildMockTableSelectorProps();
render(<TableSelector {...mockProps} />);
await waitFor(() => {
expect(mockProps.db.tables).not.toHaveBeenCalled();
});
});
});
describe('SQLWhereRow', () => {
it('should remove quotes in a where clause including multi-value variable', () => {
const exp: SQLExpression = {
whereString: "hostname IN ('${multiHost}')",
};
const multiVar = makeVariable('multiVar', 'multiHost', { multi: true });
const nonMultiVar = makeVariable('nonMultiVar', 'host', { multi: false });
const variables = [multiVar, nonMultiVar];
removeQuotesForMultiVariables(exp, variables);
expect(exp.whereString).toBe('hostname IN (${multiHost})');
});
it('should not remove quotes in a where clause including a non-multi variable', () => {
const exp: SQLExpression = {
whereString: "hostname IN ('${host}')",
};
const multiVar = makeVariable('multiVar', 'multiHost', { multi: true });
const nonMultiVar = makeVariable('nonMultiVar', 'host', { multi: false });
const variables = [multiVar, nonMultiVar];
removeQuotesForMultiVariables(exp, variables);
expect(exp.whereString).toBe("hostname IN ('${host}')");
});
it('should not remove quotes in a where clause not including any known variables', () => {
const exp: SQLExpression = {
whereString: "hostname IN ('${nonMultiHost}')",
};
const multiVar = makeVariable('multiVar', 'multiHost', { multi: true });
const nonMultiVar = makeVariable('nonMultiVar', 'host', { multi: false });
const variables = [multiVar, nonMultiVar];
removeQuotesForMultiVariables(exp, variables);
expect(exp.whereString).toBe("hostname IN ('${nonMultiHost}')");
});
});
@@ -0,0 +1,94 @@
import { TimeRange, PluginType } from '@grafana/data';
import { DB, SQLQuery, SQLSelectableValue, ValidationResults } from '../types';
import { DatasetSelectorProps } from './DatasetSelector';
import { TableSelectorProps } from './TableSelector';
const buildMockDB = (): DB => ({
datasets: jest.fn(() => Promise.resolve(['dataset1', 'dataset2'])),
tables: jest.fn((_ds: string | undefined) => Promise.resolve(['table1', 'table2'])),
fields: jest.fn((_query: SQLQuery, _order?: boolean) => Promise.resolve<SQLSelectableValue[]>([])),
validateQuery: jest.fn((_query: SQLQuery, _range?: TimeRange) =>
Promise.resolve<ValidationResults>({ query: { refId: '123' }, error: '', isError: false, isValid: true })
),
dsID: jest.fn(() => 1234),
getEditorLanguageDefinition: jest.fn(() => ({ id: '4567' })),
toRawSql: (_query: SQLQuery) => '',
});
// This data is of type `SqlDatasource`
export const buildMockDatasource = (hasDefaultDatabaseConfigured?: boolean) => {
return {
id: Infinity,
type: '',
name: '',
uid: '',
responseParser: { transformMetricFindResponse: jest.fn() },
interval: '',
db: buildMockDB(),
preconfiguredDatabase: hasDefaultDatabaseConfigured ? 'default database' : '',
getDB: () => buildMockDB(),
getQueryModel: jest.fn(),
getResponseParser: jest.fn(),
interpolateVariable: jest.fn(),
interpolateVariablesInQueries: jest.fn(),
filterQuery: jest.fn(),
applyTemplateVariables: jest.fn(),
metricFindQuery: jest.fn(),
templateSrv: {
getVariables: jest.fn(),
replace: jest.fn(),
containsTemplate: jest.fn(),
updateTimeRange: jest.fn(),
},
runSql: jest.fn(),
runMetaQuery: jest.fn(),
targetContainsTemplate: jest.fn(),
query: jest.fn(),
getRequestHeaders: jest.fn(),
streamOptionsProvider: jest.fn(),
getResource: jest.fn(),
postResource: jest.fn(),
callHealthCheck: jest.fn(),
testDatasource: jest.fn(),
getRef: jest.fn(),
meta: {
id: '',
name: '',
type: PluginType.panel,
info: {
author: { name: '' },
description: '',
links: [],
logos: { large: '', small: '' },
screenshots: [],
updated: '',
version: '',
},
module: '',
baseUrl: '',
},
};
};
export function buildMockDatasetSelectorProps(overrides?: Partial<DatasetSelectorProps>): DatasetSelectorProps {
return {
db: buildMockDB(),
dataset: '',
dialect: 'other',
onChange: jest.fn(),
preconfiguredDataset: '',
...overrides,
};
}
export function buildMockTableSelectorProps(overrides?: Partial<TableSelectorProps>): TableSelectorProps {
return {
db: buildMockDB(),
dataset: '',
table: '',
onChange: jest.fn(),
...overrides,
};
}
@@ -0,0 +1,40 @@
import React from 'react';
import { useAsync } from 'react-use';
import { SelectableValue, toOption } from '@grafana/data';
import { Select } from '@grafana/ui';
import { DB, ResourceSelectorProps } from '../types';
export interface TableSelectorProps extends ResourceSelectorProps {
db: DB;
table: string | undefined;
dataset: string | undefined;
onChange: (v: SelectableValue) => void;
}
export const TableSelector = ({ db, dataset, table, className, onChange }: TableSelectorProps) => {
const state = useAsync(async () => {
// No need to attempt to fetch tables for an unknown dataset.
if (!dataset) {
return [];
}
const tables = await db.tables(dataset);
return tables.map(toOption);
}, [dataset]);
return (
<Select
className={className}
disabled={state.loading}
aria-label="Table selector"
value={table}
options={state.value}
onChange={onChange}
isLoading={state.loading}
menuShouldPortal={true}
placeholder={state.loading ? 'Loading tables' : 'Select table'}
/>
);
};
@@ -0,0 +1,229 @@
import React from 'react';
import { DataSourceSettings } from '@grafana/data';
import { ConfigSubSection, Stack } from '@grafana/experimental';
import { config } from '@grafana/runtime';
import { Field, Icon, InlineLabel, Input, Label, Switch, Tooltip } from '@grafana/ui';
import { SQLConnectionLimits, SQLOptions } from '../../types';
interface Props<T> {
onOptionsChange: Function;
options: DataSourceSettings<SQLOptions>;
}
function toNumber(text: string): number {
if (text.trim() === '') {
// calling `Number('')` returns zero,
// so we have to handle this case
return NaN;
}
return Number(text);
}
export const ConnectionLimits = <T extends SQLConnectionLimits>(props: Props<T>) => {
const { onOptionsChange, options } = props;
const jsonData = options.jsonData;
const autoIdle = jsonData.maxIdleConnsAuto !== undefined ? jsonData.maxIdleConnsAuto : false;
// Update JSON data with new values
const updateJsonData = (values: {}) => {
const newOpts = {
...options,
jsonData: {
...jsonData,
...values,
},
};
return onOptionsChange(newOpts);
};
// For the case of idle connections and connection lifetime
// use a shared function to update respective properties
const onJSONDataNumberChanged = (property: keyof SQLConnectionLimits) => {
return (number?: number) => {
updateJsonData({ [property]: number });
};
};
// When the maximum number of connections is changed
// see if we have the automatic idle option enabled
const onMaxConnectionsChanged = (number?: number) => {
if (autoIdle && number) {
updateJsonData({
maxOpenConns: number,
maxIdleConns: number,
});
} else {
updateJsonData({
maxOpenConns: number,
});
}
};
// Update auto idle setting when control is toggled
// and set minimum idle connections if automatic
// is selected
const onConnectionIdleAutoChanged = () => {
let idleConns = undefined;
let maxConns = undefined;
// If the maximum number of open connections is undefined
// and we're setting auto idle then set the default amount
// otherwise take the numeric amount and get the value from that
if (!autoIdle) {
if (jsonData.maxOpenConns !== undefined) {
maxConns = jsonData.maxOpenConns;
idleConns = jsonData.maxOpenConns;
}
} else {
maxConns = jsonData.maxOpenConns;
idleConns = jsonData.maxIdleConns;
}
updateJsonData({
maxIdleConnsAuto: !autoIdle,
maxIdleConns: idleConns,
maxOpenConns: maxConns,
});
};
const labelWidth = 40;
return (
<ConfigSubSection title="Connection limits">
<Field
label={
<Label>
<Stack gap={0.5}>
<span>Max open</span>
<Tooltip
content={
<span>
The maximum number of open connections to the database. If <i>Max idle connections</i> is greater
than 0 and the <i>Max open connections</i> is less than <i>Max idle connections</i>, then
<i>Max idle connections</i> will be reduced to match the <i>Max open connections</i> limit. If set
to 0, there is no limit on the number of open connections.
</span>
}
>
<Icon name="info-circle" size="sm" />
</Tooltip>
</Stack>
</Label>
}
>
<Input
type="number"
placeholder="unlimited"
defaultValue={jsonData.maxOpenConns}
onChange={(e) => {
const newVal = toNumber(e.currentTarget.value);
if (!Number.isNaN(newVal)) {
onMaxConnectionsChanged(newVal);
}
}}
width={labelWidth}
/>
</Field>
<Field
label={
<Label>
<Stack gap={0.5}>
<span>Auto Max Idle</span>
<Tooltip
content={
<span>
If enabled, automatically set the number of <i>Maximum idle connections</i> to the same value as
<i> Max open connections</i>. If the number of maximum open connections is not set it will be set to
the default ({config.sqlConnectionLimits.maxIdleConns}).
</span>
}
>
<Icon name="info-circle" size="sm" />
</Tooltip>
</Stack>
</Label>
}
>
<Switch value={autoIdle} onChange={onConnectionIdleAutoChanged} />
</Field>
<Field
label={
<Label>
<Stack gap={0.5}>
<span>Max idle</span>
<Tooltip
content={
<span>
The maximum number of connections in the idle connection pool.If <i>Max open connections</i> is
greater than 0 but less than the <i>Max idle connections</i>, then the <i>Max idle connections</i>{' '}
will be reduced to match the <i>Max open connections</i> limit. If set to 0, no idle connections are
retained.
</span>
}
>
<Icon name="info-circle" size="sm" />
</Tooltip>
</Stack>
</Label>
}
>
{autoIdle ? (
<InlineLabel width={labelWidth}>{options.jsonData.maxIdleConns}</InlineLabel>
) : (
<Input
type="number"
placeholder="2"
defaultValue={jsonData.maxIdleConns}
onChange={(e) => {
const newVal = toNumber(e.currentTarget.value);
if (!Number.isNaN(newVal)) {
onJSONDataNumberChanged('maxIdleConns')(newVal);
}
}}
width={labelWidth}
disabled={autoIdle}
/>
)}
</Field>
<Field
label={
<Label>
<Stack gap={0.5}>
<span>Max lifetime</span>
<Tooltip
content={
<span>
The maximum amount of time in seconds a connection may be reused. If set to 0, connections are
reused forever.
</span>
}
>
<Icon name="info-circle" size="sm" />
</Tooltip>
</Stack>
</Label>
}
>
<Input
type="number"
placeholder="14400"
defaultValue={jsonData.connMaxLifetime}
onChange={(e) => {
const newVal = toNumber(e.currentTarget.value);
if (!Number.isNaN(newVal)) {
onJSONDataNumberChanged('connMaxLifetime')(newVal);
}
}}
width={labelWidth}
/>
</Field>
</ConfigSubSection>
);
};
@@ -0,0 +1,21 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
// this custom component is necessary because the Grafana UI <Divider /> component is not backwards compatible with Grafana < 10.1.0
export const Divider = () => {
const styles = useStyles2(getStyles);
return <hr className={styles.horizontalDivider} />;
};
const getStyles = (theme: GrafanaTheme2) => {
return {
horizontalDivider: css({
borderTop: `1px solid ${theme.colors.border.weak}`,
margin: theme.spacing(2, 0),
width: '100%',
}),
};
};
@@ -0,0 +1,114 @@
import React from 'react';
import {
DataSourceJsonData,
DataSourcePluginOptionsEditorProps,
KeyValue,
onUpdateDatasourceSecureJsonDataOption,
updateDatasourcePluginResetOption,
} from '@grafana/data';
import { Field, Icon, Label, SecretTextArea, Tooltip, Stack } from '@grafana/ui';
export interface Props<T extends DataSourceJsonData, S> {
editorProps: DataSourcePluginOptionsEditorProps<T, S>;
showCACert?: boolean;
showKeyPair?: boolean;
secureJsonFields?: KeyValue<Boolean>;
labelWidth?: number;
}
export const TLSSecretsConfig = <T extends DataSourceJsonData, S extends {} = {}>(props: Props<T, S>) => {
const { editorProps, showCACert, showKeyPair = true } = props;
const { secureJsonFields } = editorProps.options;
return (
<>
{showKeyPair ? (
<Field
label={
<Label>
<Stack gap={0.5}>
<span>TLS/SSL Client Certificate</span>
<Tooltip
content={
<span>
To authenticate with an TLS/SSL client certificate, provide the client certificate here.
</span>
}
>
<Icon name="info-circle" size="sm" />
</Tooltip>
</Stack>
</Label>
}
>
<SecretTextArea
placeholder="-----BEGIN CERTIFICATE-----"
cols={45}
rows={7}
isConfigured={secureJsonFields && secureJsonFields.tlsClientCert}
onChange={onUpdateDatasourceSecureJsonDataOption(editorProps, 'tlsClientCert')}
onReset={() => {
updateDatasourcePluginResetOption(editorProps, 'tlsClientCert');
}}
/>
</Field>
) : null}
{showCACert ? (
<Field
label={
<Label>
<Stack gap={0.5}>
<span>TLS/SSL Root Certificate</span>
<Tooltip
content={
<span>If the selected TLS/SSL mode requires a server root certificate, provide it here.</span>
}
>
<Icon name="info-circle" size="sm" />
</Tooltip>
</Stack>
</Label>
}
>
<SecretTextArea
placeholder="-----BEGIN CERTIFICATE-----"
cols={45}
rows={7}
isConfigured={secureJsonFields && secureJsonFields.tlsCACert}
onChange={onUpdateDatasourceSecureJsonDataOption(editorProps, 'tlsCACert')}
onReset={() => {
updateDatasourcePluginResetOption(editorProps, 'tlsCACert');
}}
/>
</Field>
) : null}
{showKeyPair ? (
<Field
label={
<Label>
<Stack gap={0.5}>
<span>TLS/SSL Client Key</span>
<Tooltip
content={<span>To authenticate with a client TLS/SSL certificate, provide the key here.</span>}
>
<Icon name="info-circle" size="sm" />
</Tooltip>
</Stack>
</Label>
}
>
<SecretTextArea
placeholder="-----BEGIN RSA PRIVATE KEY-----"
cols={45}
rows={7}
isConfigured={secureJsonFields && secureJsonFields.tlsClientKey}
onChange={onUpdateDatasourceSecureJsonDataOption(editorProps, 'tlsClientKey')}
onReset={() => {
updateDatasourcePluginResetOption(editorProps, 'tlsClientKey');
}}
/>
</Field>
) : null}
</>
);
};
@@ -0,0 +1,84 @@
import { renderHook } from '@testing-library/react-hooks';
import { DataSourceSettings } from '@grafana/data';
import { SQLOptions } from '../../types';
import { useMigrateDatabaseFields } from './useMigrateDatabaseFields';
jest.mock('@grafana/runtime', () => {
return {
config: {
sqlConnectionLimits: {
maxOpenConns: 10,
maxIdleConns: 11,
connMaxLifetime: 12,
},
},
logDebug: jest.fn(),
};
});
describe('Database Field Migration', () => {
let defaultProps = {
options: {
database: 'testDatabase',
id: 1,
uid: 'unique-id',
orgId: 1,
name: 'Datasource Name',
type: 'postgres',
typeName: 'Postgres',
typeLogoUrl: 'http://example.com/logo.png',
access: 'access',
url: 'http://example.com',
user: 'user',
basicAuth: true,
basicAuthUser: 'user',
isDefault: false,
secureJsonFields: {},
readOnly: false,
withCredentials: false,
jsonData: {
tlsAuth: false,
tlsAuthWithCACert: false,
timezone: 'America/Chicago',
tlsSkipVerify: false,
user: 'user',
},
},
};
it('should migrate the old database field to be included in jsonData', () => {
const props = {
...defaultProps,
onOptionsChange: (options: DataSourceSettings) => {
const jsonData = options.jsonData as SQLOptions;
expect(options.database).toBe('');
expect(jsonData.database).toBe('testDatabase');
},
};
// @ts-ignore Ignore this line as it's expected that
// the database object will not be in necessary (most current) state
const { rerender, result } = renderHook(() => useMigrateDatabaseFields(props));
rerender();
});
it('adds default max connection, max idle connection, and auto idle values when not detected', () => {
const props = {
...defaultProps,
onOptionsChange: (options: DataSourceSettings) => {
const jsonData = options.jsonData as SQLOptions;
expect(jsonData.maxOpenConns).toBe(10);
expect(jsonData.maxIdleConns).toBe(11);
expect(jsonData.connMaxLifetime).toBe(12);
expect(jsonData.maxIdleConnsAuto).toBe(true);
},
};
// @ts-ignore Ignore this line as it's expected that
// the database object will not be in the expected (most current) state
const { rerender, result } = renderHook(() => useMigrateDatabaseFields(props));
rerender();
});
});
@@ -0,0 +1,75 @@
import { useEffect } from 'react';
import { DataSourcePluginOptionsEditorProps } from '@grafana/data';
import { logDebug, config } from '@grafana/runtime';
import { SQLOptions } from '../../types';
/**
* 1. Moves the database field from the options object to jsonData.database and empties the database field.
* 2. If max open connections, max idle connections, and auto idle are all undefined set these to default values.
*/
export function useMigrateDatabaseFields<T extends SQLOptions, S = {}>({
onOptionsChange,
options,
}: DataSourcePluginOptionsEditorProps<T, S>) {
useEffect(() => {
const jsonData = options.jsonData;
let newOptions = { ...options };
let optionsUpdated = false;
// Migrate the database field from the column into the jsonData object
if (options.database) {
logDebug(`Migrating from options.database with value ${options.database} for ${options.name}`);
newOptions.database = '';
newOptions.jsonData = { ...jsonData, database: options.database };
optionsUpdated = true;
}
// Set default values for max open connections, max idle connection,
// and auto idle if they're all undefined
if (
jsonData.maxOpenConns === undefined &&
jsonData.maxIdleConns === undefined &&
jsonData.maxIdleConnsAuto === undefined
) {
const { maxOpenConns, maxIdleConns } = config.sqlConnectionLimits;
logDebug(
`Setting default max open connections to ${maxOpenConns} and setting max idle connection to ${maxIdleConns}`
);
// Spread from the jsonData in new options in case
// the database field was migrated as well
newOptions.jsonData = {
...newOptions.jsonData,
maxOpenConns: maxOpenConns,
maxIdleConns: maxIdleConns,
maxIdleConnsAuto: true,
};
// Make sure we issue an update if options changed
optionsUpdated = true;
}
// If the maximum connection lifetime hasn't been
// otherwise set fill in with the default from configuration
if (jsonData.connMaxLifetime === undefined) {
const { connMaxLifetime } = config.sqlConnectionLimits;
// Spread new options and add our value
newOptions.jsonData = {
...newOptions.jsonData,
connMaxLifetime: connMaxLifetime,
};
// Note that we've updated the options
optionsUpdated = true;
}
// Only issue an update if we changed options
if (optionsUpdated) {
onOptionsChange(newOptions);
}
}, [onOptionsChange, options]);
}
@@ -0,0 +1 @@
export * from './visual-query-builder';
@@ -0,0 +1,46 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { LanguageDefinition, SQLEditor } from '@grafana/experimental';
import { SQLQuery } from '../../types';
type Props = {
query: SQLQuery;
onChange: (value: SQLQuery, processQuery: boolean) => void;
children?: (props: { formatQuery: () => void }) => React.ReactNode;
width?: number;
height?: number;
editorLanguageDefinition: LanguageDefinition;
};
export function QueryEditorRaw({ children, onChange, query, width, height, editorLanguageDefinition }: Props) {
// We need to pass query via ref to SQLEditor as onChange is executed via monacoEditor.onDidChangeModelContent callback, not onChange property
const queryRef = useRef<SQLQuery>(query);
useEffect(() => {
queryRef.current = query;
}, [query]);
const onRawQueryChange = useCallback(
(rawSql: string, processQuery: boolean) => {
const newQuery = {
...queryRef.current,
rawQuery: true,
rawSql,
};
onChange(newQuery, processQuery);
},
[onChange]
);
return (
<SQLEditor
width={width}
height={height}
query={query.rawSql!}
onChange={onRawQueryChange}
language={editorLanguageDefinition}
>
{children}
</SQLEditor>
);
}
@@ -0,0 +1,109 @@
import { css } from '@emotion/css';
import React, { useMemo, useState } from 'react';
import { reportInteraction } from '@grafana/runtime';
import { HorizontalGroup, Icon, IconButton, Tooltip, useTheme2 } from '@grafana/ui';
import { QueryValidator, QueryValidatorProps } from './QueryValidator';
interface QueryToolboxProps extends Omit<QueryValidatorProps, 'onValidate'> {
showTools?: boolean;
isExpanded?: boolean;
onFormatCode?: () => void;
onExpand?: (expand: boolean) => void;
onValidate?: (isValid: boolean) => void;
}
export function QueryToolbox({ showTools, onFormatCode, onExpand, isExpanded, ...validatorProps }: QueryToolboxProps) {
const theme = useTheme2();
const [validationResult, setValidationResult] = useState<boolean>();
const styles = useMemo(() => {
return {
container: css`
border: 1px solid ${theme.colors.border.medium};
border-top: none;
padding: ${theme.spacing(0.5, 0.5, 0.5, 0.5)};
display: flex;
flex-grow: 1;
justify-content: space-between;
font-size: ${theme.typography.bodySmall.fontSize};
`,
error: css`
color: ${theme.colors.error.text};
font-size: ${theme.typography.bodySmall.fontSize};
font-family: ${theme.typography.fontFamilyMonospace};
`,
valid: css`
color: ${theme.colors.success.text};
`,
info: css`
color: ${theme.colors.text.secondary};
`,
hint: css`
color: ${theme.colors.text.disabled};
white-space: nowrap;
cursor: help;
`,
};
}, [theme]);
let style = {};
if (!showTools && validationResult === undefined) {
style = { height: 0, padding: 0, visibility: 'hidden' };
}
return (
<div className={styles.container} style={style}>
<div>
{validatorProps.onValidate && (
<QueryValidator
{...validatorProps}
onValidate={(result: boolean) => {
setValidationResult(result);
validatorProps.onValidate!(result);
}}
/>
)}
</div>
{showTools && (
<div>
<HorizontalGroup spacing="sm">
{onFormatCode && (
<IconButton
onClick={() => {
reportInteraction('grafana_sql_query_formatted', {
datasource: validatorProps.query.datasource?.type,
});
onFormatCode();
}}
name="brackets-curly"
size="xs"
tooltip="Format query"
/>
)}
{onExpand && (
<IconButton
onClick={() => {
reportInteraction('grafana_sql_editor_expand', {
datasource: validatorProps.query.datasource?.type,
expanded: !isExpanded,
});
onExpand(!isExpanded);
}}
name={isExpanded ? 'angle-up' : 'angle-down'}
size="xs"
tooltip={isExpanded ? 'Collapse editor' : 'Expand editor'}
/>
)}
<Tooltip content="Hit CTRL/CMD+Return to run query">
<Icon className={styles.hint} name="keyboard" />
</Tooltip>
</HorizontalGroup>
</div>
)}
</div>
);
}
@@ -0,0 +1,110 @@
import { css } from '@emotion/css';
import React, { useState, useMemo, useEffect } from 'react';
import { useAsyncFn } from 'react-use';
import useDebounce from 'react-use/lib/useDebounce';
import { formattedValueToString, getValueFormat, TimeRange } from '@grafana/data';
import { Icon, Spinner, useTheme2 } from '@grafana/ui';
import { DB, SQLQuery, ValidationResults } from '../../types';
export interface QueryValidatorProps {
db: DB;
query: SQLQuery;
range?: TimeRange;
onValidate: (isValid: boolean) => void;
}
export function QueryValidator({ db, query, onValidate, range }: QueryValidatorProps) {
const [validationResult, setValidationResult] = useState<ValidationResults | null>();
const theme = useTheme2();
const valueFormatter = useMemo(() => getValueFormat('bytes'), []);
const styles = useMemo(() => {
return {
error: css`
color: ${theme.colors.error.text};
font-size: ${theme.typography.bodySmall.fontSize};
font-family: ${theme.typography.fontFamilyMonospace};
`,
valid: css`
color: ${theme.colors.success.text};
`,
info: css`
color: ${theme.colors.text.secondary};
`,
};
}, [theme]);
const [state, validateQuery] = useAsyncFn(
async (q: SQLQuery) => {
if (q.rawSql?.trim() === '') {
return null;
}
return await db.validateQuery(q, range);
},
[db]
);
const [,] = useDebounce(
async () => {
const result = await validateQuery(query);
if (result) {
setValidationResult(result);
}
return null;
},
1000,
[query, validateQuery]
);
useEffect(() => {
if (validationResult?.isError) {
onValidate(false);
}
if (validationResult?.isValid) {
onValidate(true);
}
}, [validationResult, onValidate]);
if (!state.value && !state.loading) {
return null;
}
const error = state.value?.error ? processErrorMessage(state.value.error) : '';
return (
<>
{state.loading && (
<div className={styles.info}>
<Spinner inline={true} size="xs" /> Validating query...
</div>
)}
{!state.loading && state.value && (
<>
<>
{state.value.isValid && state.value.statistics && (
<div className={styles.valid}>
<Icon name="check" /> This query will process{' '}
<strong>{formattedValueToString(valueFormatter(state.value.statistics.TotalBytesProcessed))}</strong>{' '}
when run.
</div>
)}
</>
<>{state.value.isError && <div className={styles.error}>{error}</div>}</>
</>
)}
</>
);
}
function processErrorMessage(error: string) {
const splat = error.split(':');
if (splat.length > 2) {
return splat.slice(2).join(':');
}
return error;
}
@@ -0,0 +1,135 @@
## SQLEditor
### Core concepts
- `SuggestionKind` - a descriptive string representing a type of a suggestion, i.e. `SelectKeyword`, `Tables`, `LogicalOperators` etc.
- `LinkedToken` - linked list element representing each individual token with a query. Allows traversing the query back and forth. Used by `StatementPositionResolver`(see below)
- `StatementPosition` - a desctiptive string representing cursor/token position within the query. Each statement position is defined together with `StatementPositionResolver` that, given some position context, returns a boolean value indicating whether or not we are in a given `StatementPosition` position.
```ts
export type StatementPositionResolver = (
currentToken: LinkedToken | null,
previousKeyword: LinkedToken | null,
previousNonWhiteSpace: LinkedToken | null,
previousIsSlash: Boolean // To be removed as it's CloudWatch specific
) => Boolean;
```
- `SuggestionKind` and `StatementPosition` are glued together via suggestions kind registry (language specific!). This registry contains items of `SuggestionKindRegistyItem` type of the following interface:
```ts
export interface SuggestionKindRegistyItem extends RegistryItem {
id: StatementPosition;
kind: SuggestionKind[];
}
```
This item defines what kinds of suggestions should be provided in a given statement position
- Registries. There are couple of different registries used that drive the autocomplete mechanism.
- **Language specific**: functions registry, operators registry, suggestion kinds registries and statement position resolvers registires. Those registires contain SQL defaults as well as allow extension per language type.
- **Instance specific**: Registry of `SuggestionsRegistyItem` items that glue particular `SuggestionKind` with an async function that provides completion items for it.
```ts
export interface SuggestionsRegistyItem extends RegistryItem {
id: SuggestionKind;
suggestions: (position: PositionContext, m: typeof monacoTypes) => Promise<CustomSuggestion[]>;
}
```
Think about instance-specific registry as having i.e. mixed data source with multiple query editors for the same type of data source and you wish to provide only table suggestions that are valid for particular query row.
### SQLEditor component
Goals
- [ ] Allow providing suggestions for standard-ish SQL syntax (THIS PR)
- [ ] Allow providing custom SQL dialects and suggestions for them (TODO - CloudWatch implementation sets a good base for how to provide custom dialect definition)
`SQLEditor` component builds on top of `CodeEditor` component, but we may want to base it on `ReactMonacoEditor` component instead to be less prone to `CodeEditor` API changes and have full control over the Monaco API. For now the `CodeEditor` is good enough for a simplification.
`SQLEditor` API:
```ts
interface SQLEditorProps {
query: string;
onChange: (q: string) => void;
language?: LanguageDefinition;
}
```
The important part is the `LanguageDefinition` interface which provides way to customize the completion both on a language and instance level:
```ts
interface LanguageDefinition extends monacoTypes.languages.ILanguageExtensionPoint {
// TODO: Will allow providing a custom language definition.
loadLanguage?: (module: any) => Promise<void>;
// Provides API for customizing the autocomplete
completionProvider?: (m: Monaco) => SQLCompletionItemProvider;
}
```
The `completionProvider` function is the core of the autocomplete customization. `SQLEditor` comes with standard SQL completion items, but this function allows:
- providing dynamic suggestions: tables, columns
- providing custom `StatementPositionResolvers` that are specific for a given dialect or not implemented yet for standard SQL
- providing custom `SuggestionKind` and resolvers for this kind of suggestions.
```ts
export interface SQLCompletionItemProvider
extends Omit<monacoTypes.languages.CompletionItemProvider, 'provideCompletionItems'> {
/**
* Allows dialect specific functions to be added to the completion list.
* @alpha
*/
supportedFunctions?: () => Array<{
id: string;
name: string;
}>;
/**
* Allows dialect specific operators to be added to the completion list.
* @alpha
*/
supportedOperators?: () => Array<{
id: string;
operator: string;
type: OperatorType;
}>;
/**
* Allows adding macros that are available in the dialect datasource.
* @alpha
*/
supportedMacros?: () => Array<{
id: string;
name: string;
type: MacroType;
args: Array<string>;
}>;
/**
* Allows custom suggestion kinds to be defined and correlate them with <Custom>StatementPosition.
* @alpha
*/
customSuggestionKinds?: () => CustomSuggestionKind[];
/**
* Allows custom statement placement definition.
* @alpha
*/
customStatementPlacement?: () => CustomStatementPlacement[];
/**
* Allows providing a custom function for resolving db tables.
* It's up to the consumer to decide whether the columns are resolved via API calls or preloaded in the query editor(i.e. full db schema is preloades loaded).
* @alpha
*/
tables?: {
resolve: () => Promise<TableDefinition[]>;
// Allows providing a custom function for calculating the table name from the query. If not specified a default implemnentation is used. I.e. BigQuery requires the table name to be fully qualified name: <project>.<dataset>.<table>
parseName?: (t: LinkedToken) => string;
};
/**
* Allows providing a custom function for resolving table.
* It's up to the consumer to decide whether the columns are resolved via API calls or preloaded in the query editor(i.e. full db schema is preloades loaded).
* @alpha
*/
columns?: {
resolve: (table: string) => Promise<ColumnDefinition[]>;
};
}
```
@@ -0,0 +1,126 @@
import { css } from '@emotion/css';
import React, { useMemo, useState } from 'react';
import { useMeasure } from 'react-use';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme2 } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { Modal, useStyles2, useTheme2 } from '@grafana/ui';
import { SQLQuery, QueryEditorProps } from '../../types';
import { QueryEditorRaw } from './QueryEditorRaw';
import { QueryToolbox } from './QueryToolbox';
interface RawEditorProps extends Omit<QueryEditorProps, 'onChange'> {
onRunQuery: () => void;
onChange: (q: SQLQuery, processQuery: boolean) => void;
onValidate: (isValid: boolean) => void;
queryToValidate: SQLQuery;
}
export function RawEditor({ db, query, onChange, onRunQuery, onValidate, queryToValidate, range }: RawEditorProps) {
const theme = useTheme2();
const styles = useStyles2(getStyles);
const [isExpanded, setIsExpanded] = useState(false);
const [toolboxRef, toolboxMeasure] = useMeasure<HTMLDivElement>();
const [editorRef, editorMeasure] = useMeasure<HTMLDivElement>();
const editorLanguageDefinition = useMemo(() => db.getEditorLanguageDefinition(), [db]);
const renderQueryEditor = (width?: number, height?: number) => {
return (
<QueryEditorRaw
editorLanguageDefinition={editorLanguageDefinition}
query={query}
width={width}
height={height ? height - toolboxMeasure.height : undefined}
onChange={onChange}
>
{({ formatQuery }) => {
return (
<div ref={toolboxRef}>
<QueryToolbox
db={db}
query={queryToValidate}
onValidate={onValidate}
onFormatCode={formatQuery}
showTools
range={range}
onExpand={setIsExpanded}
isExpanded={isExpanded}
/>
</div>
);
}}
</QueryEditorRaw>
);
};
const renderEditor = (standalone = false) => {
return standalone ? (
<AutoSizer>
{({ width, height }) => {
return renderQueryEditor(width, height);
}}
</AutoSizer>
) : (
<div ref={editorRef}>{renderQueryEditor()}</div>
);
};
const renderPlaceholder = () => {
return (
<div
style={{
width: editorMeasure.width,
height: editorMeasure.height,
background: theme.colors.background.primary,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
Editing in expanded code editor
</div>
);
};
return (
<>
{isExpanded ? renderPlaceholder() : renderEditor()}
{isExpanded && (
<Modal
title={`Query ${query.refId}`}
closeOnBackdropClick={false}
closeOnEscape={false}
className={styles.modal}
contentClassName={styles.modalContent}
isOpen={isExpanded}
onDismiss={() => {
reportInteraction('grafana_sql_editor_expand', {
datasource: query.datasource?.type,
expanded: false,
});
setIsExpanded(false);
}}
>
{renderEditor(true)}
</Modal>
)}
</>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
modal: css`
width: 95vw;
height: 95vh;
`,
modalContent: css`
height: 100%;
padding-top: 0;
`,
};
}
@@ -0,0 +1,336 @@
import {
AnyObject,
BasicConfig,
Config,
JsonTree,
Operator,
Settings,
SimpleField,
SqlFormatOperator,
Utils,
ValueSource,
Widgets,
} from '@react-awesome-query-builder/ui';
import { List } from 'immutable';
import { isString } from 'lodash';
import React from 'react';
import { dateTime, toOption } from '@grafana/data';
import { Button, DateTimePicker, Input, Select } from '@grafana/ui';
const buttonLabels = {
add: 'Add',
remove: 'Remove',
};
export const emptyInitTree: JsonTree = {
id: Utils.uuid(),
type: 'group',
};
const TIME_FILTER = 'timeFilter';
const macros = [TIME_FILTER];
// Widgets are the components rendered for each field type see the docs for more info
// https://github.com/ukrbublik/react-awesome-query-builder/blob/master/CONFIG.adoc#configwidgets
export const widgets: Widgets = {
...BasicConfig.widgets,
text: {
...BasicConfig.widgets.text,
factory: function TextInput(props) {
return (
<Input
value={props?.value || ''}
placeholder={props?.placeholder}
onChange={(e) => props?.setValue(e.currentTarget.value)}
/>
);
},
},
number: {
...BasicConfig.widgets.number,
factory: function NumberInput(props) {
return (
<Input
value={props?.value}
placeholder={props?.placeholder}
type="number"
onChange={(e) => props?.setValue(Number.parseInt(e.currentTarget.value, 10))}
/>
);
},
},
datetime: {
...BasicConfig.widgets.datetime,
factory: function DateTimeInput(props) {
if (props?.operator === Op.MACROS) {
return (
<Select
id={props.id}
aria-label="Macros value selector"
menuShouldPortal
options={macros.map(toOption)}
value={props?.value}
onChange={(val) => props.setValue(val.value)}
/>
);
}
const dateValue = dateTime(props?.value).isValid() ? dateTime(props?.value).utc() : undefined;
return (
<DateTimePicker
onChange={(e) => {
props?.setValue(e.format(BasicConfig.widgets.datetime.valueFormat));
}}
date={dateValue}
/>
);
},
// Function for formatting widgets value in SQL WHERE query.
sqlFormatValue: (val, field, widget, operator, operatorDefinition, rightFieldDef) => {
if (operator === Op.MACROS) {
if (macros.includes(val)) {
return val;
}
return undefined;
}
// This is just satisfying the type checker, this should never happen
if (
typeof BasicConfig.widgets.datetime.sqlFormatValue === 'string' ||
typeof BasicConfig.widgets.datetime.sqlFormatValue === 'object'
) {
return undefined;
}
const func = BasicConfig.widgets.datetime.sqlFormatValue;
// We need to pass the ctx to this function this way so *this* is correct
return func?.call(BasicConfig.ctx, val, field, widget, operator, operatorDefinition, rightFieldDef) || '';
},
},
};
// Settings are the configuration options for the query builder see the docs for more info
// https://github.com/ukrbublik/react-awesome-query-builder/blob/master/CONFIG.adoc#configsettings
export const settings: Settings = {
...BasicConfig.settings,
canRegroup: false,
maxNesting: 1,
canReorder: false,
showNot: false,
addRuleLabel: buttonLabels.add,
deleteLabel: buttonLabels.remove,
// This is the component that renders conjunctions (logical operators)
renderConjs: function Conjunctions(conjProps) {
return (
<Select
id={conjProps?.id}
aria-label="Conjunction"
menuShouldPortal
options={conjProps?.conjunctionOptions ? Object.keys(conjProps?.conjunctionOptions).map(toOption) : undefined}
value={conjProps?.selectedConjunction}
onChange={(val) => conjProps?.setConjunction(val.value!)}
/>
);
},
// This is the component that renders fields
renderField: function Field(fieldProps) {
const fields = fieldProps?.config?.fields || {};
return (
<Select
id={fieldProps?.id}
width={25}
aria-label="Field"
menuShouldPortal
options={fieldProps?.items.map((f) => {
// @ts-ignore
const icon = fields[f.key].mainWidgetProps?.customProps?.icon;
return {
label: f.label,
value: f.key,
icon,
};
})}
value={fieldProps?.selectedKey}
onChange={(val) => {
fieldProps?.setField(val.label!);
}}
/>
);
},
// This is the component used for the Add/Remove buttons
renderButton: function RAQBButton(buttonProps) {
return (
<Button
type="button"
title={`${buttonProps?.label} filter`}
onClick={buttonProps?.onClick}
variant="secondary"
size="md"
icon={buttonProps?.label === buttonLabels.add ? 'plus' : 'times'}
/>
);
},
// This is the component used for the fields operator selector
renderOperator: function Operator(operatorProps) {
return (
<Select
options={operatorProps?.items.map((op) => ({ label: op.label, value: op.key }))}
aria-label="Operator"
menuShouldPortal
value={operatorProps?.selectedKey}
onChange={(val) => {
operatorProps?.setField(val.value || '');
}}
/>
);
},
};
// add IN / NOT IN operators to text to support multi-value variables
const enum Op {
IN = 'select_any_in',
NOT_IN = 'select_not_any_in',
MACROS = 'macros',
}
const customOperators = getCustomOperators(BasicConfig);
const textWidget = BasicConfig.types.text.widgets.text;
const opers = [...(textWidget.operators || []), Op.IN, Op.NOT_IN];
const customTextWidget = {
...textWidget,
operators: opers,
};
const customTypes = {
...BasicConfig.types,
text: {
...BasicConfig.types.text,
widgets: {
...BasicConfig.types.text.widgets,
text: customTextWidget,
},
},
datetime: {
...BasicConfig.types.datetime,
widgets: {
...BasicConfig.types.datetime.widgets,
datetime: {
...BasicConfig.types.datetime.widgets.datetime,
operators: [Op.MACROS, ...(BasicConfig.types.datetime.widgets.datetime.operators || [])],
},
},
},
};
// This is the configuration for the query builder that doesn't include the fields but all the other configuration for the UI
// Fields should be added dynamically based on returned data
// See the doc for more info https://github.com/ukrbublik/react-awesome-query-builder/blob/master/CONFIG.adoc
export const raqbConfig: Config = {
...BasicConfig,
widgets,
settings,
operators: customOperators,
types: customTypes,
};
export type { Config };
const noop = () => '';
const isSqlFormatOp = (func: unknown): func is SqlFormatOperator => {
return typeof func === 'function';
};
function getCustomOperators(config: BasicConfig) {
const { ...supportedOperators } = config.operators;
// IN operator expects array, override IN formatter for multi-value variables
const sqlFormatInOpOrNoop = () => {
const sqlFormatOp = supportedOperators[Op.IN].sqlFormatOp;
if (isSqlFormatOp(sqlFormatOp)) {
return sqlFormatOp;
}
return noop;
};
const customSqlInFormatter = (
field: string,
op: string,
value: string | List<string>,
valueSrc: ValueSource,
valueType: string,
opDef: Operator,
operatorOptions: AnyObject,
fieldDef: SimpleField
) => {
return sqlFormatInOpOrNoop()(
field,
op,
splitIfString(value),
valueSrc,
valueType,
opDef,
operatorOptions,
fieldDef
);
};
// NOT IN operator expects array, override NOT IN formatter for multi-value variables
const sqlFormatNotInOpOrNoop = () => {
const sqlFormatOp = supportedOperators[Op.NOT_IN].sqlFormatOp;
if (isSqlFormatOp(sqlFormatOp)) {
return sqlFormatOp;
}
return noop;
};
const customSqlNotInFormatter = (
field: string,
op: string,
value: string | List<string>,
valueSrc: ValueSource,
valueType: string,
opDef: Operator,
operatorOptions: AnyObject,
fieldDef: SimpleField
) => {
return sqlFormatNotInOpOrNoop()(
field,
op,
splitIfString(value),
valueSrc,
valueType,
opDef,
operatorOptions,
fieldDef
);
};
const customOperators = {
...supportedOperators,
[Op.IN]: {
...supportedOperators[Op.IN],
sqlFormatOp: customSqlInFormatter,
},
[Op.NOT_IN]: {
...supportedOperators[Op.NOT_IN],
sqlFormatOp: customSqlNotInFormatter,
},
[Op.MACROS]: {
label: 'Macros',
sqlFormatOp: (field: string, _operator: string, value: string | List<string>) => {
if (value === TIME_FILTER) {
return `$__timeFilter(${field})`;
}
return value;
},
},
};
return customOperators;
}
// value: string | List<string> but AQB uses a different version of Immutable
function splitIfString(value: any) {
if (isString(value)) {
return value.split(',');
}
return value;
}
@@ -0,0 +1,59 @@
import React, { useCallback } from 'react';
import { SelectableValue, toOption } from '@grafana/data';
import { AccessoryButton, EditorList, InputGroup } from '@grafana/experimental';
import { Select } from '@grafana/ui';
import { QueryEditorGroupByExpression } from '../../expressions';
import { SQLExpression } from '../../types';
import { setGroupByField } from '../../utils/sql.utils';
interface GroupByRowProps {
sql: SQLExpression;
onSqlChange: (sql: SQLExpression) => void;
columns?: Array<SelectableValue<string>>;
}
export function GroupByRow({ sql, columns, onSqlChange }: GroupByRowProps) {
const onGroupByChange = useCallback(
(item: Array<Partial<QueryEditorGroupByExpression>>) => {
// As new (empty object) items come in, we need to make sure they have the correct type
const cleaned = item.map((v) => setGroupByField(v.property?.name));
const newSql = { ...sql, groupBy: cleaned };
onSqlChange(newSql);
},
[onSqlChange, sql]
);
return (
<EditorList
items={sql.groupBy!}
onChange={onGroupByChange}
renderItem={makeRenderColumn({
options: columns,
})}
/>
);
}
function makeRenderColumn({ options }: { options?: Array<SelectableValue<string>> }) {
const renderColumn = function (
item: Partial<QueryEditorGroupByExpression>,
onChangeItem: (item: QueryEditorGroupByExpression) => void,
onDeleteItem: () => void
) {
return (
<InputGroup>
<Select
value={item.property?.name ? toOption(item.property.name) : null}
aria-label="Group by"
options={options}
menuShouldPortal
onChange={({ value }) => value && onChangeItem(setGroupByField(value))}
/>
<AccessoryButton aria-label="Remove group by column" icon="times" variant="secondary" onClick={onDeleteItem} />
</InputGroup>
);
};
return renderColumn;
}
@@ -0,0 +1,92 @@
import { uniqueId } from 'lodash';
import React, { useCallback } from 'react';
import { SelectableValue, toOption } from '@grafana/data';
import { EditorField, InputGroup } from '@grafana/experimental';
import { Input, RadioButtonGroup, Select, Space } from '@grafana/ui';
import { SQLExpression } from '../../types';
import { setPropertyField } from '../../utils/sql.utils';
type OrderByRowProps = {
sql: SQLExpression;
onSqlChange: (sql: SQLExpression) => void;
columns?: Array<SelectableValue<string>>;
showOffset?: boolean;
};
const sortOrderOptions = [
{ description: 'Sort by ascending', value: 'ASC', icon: 'sort-amount-up' } as const,
{ description: 'Sort by descending', value: 'DESC', icon: 'sort-amount-down' } as const,
];
export function OrderByRow({ sql, onSqlChange, columns, showOffset }: OrderByRowProps) {
const onSortOrderChange = useCallback(
(item: 'ASC' | 'DESC') => {
const newSql: SQLExpression = { ...sql, orderByDirection: item };
onSqlChange(newSql);
},
[onSqlChange, sql]
);
const onLimitChange = useCallback(
(event: React.FormEvent<HTMLInputElement>) => {
const newSql: SQLExpression = { ...sql, limit: Number.parseInt(event.currentTarget.value, 10) };
onSqlChange(newSql);
},
[onSqlChange, sql]
);
const onOffsetChange = useCallback(
(event: React.FormEvent<HTMLInputElement>) => {
const newSql: SQLExpression = { ...sql, offset: Number.parseInt(event.currentTarget.value, 10) };
onSqlChange(newSql);
},
[onSqlChange, sql]
);
const onOrderByChange = useCallback(
(item: SelectableValue<string>) => {
const newSql: SQLExpression = { ...sql, orderBy: setPropertyField(item?.value) };
if (item === null) {
newSql.orderByDirection = undefined;
}
onSqlChange(newSql);
},
[onSqlChange, sql]
);
return (
<>
<EditorField label="Order by" width={25}>
<InputGroup>
<Select
aria-label="Order by"
options={columns}
value={sql.orderBy?.property.name ? toOption(sql.orderBy.property.name) : null}
isClearable
menuShouldPortal
onChange={onOrderByChange}
/>
<Space h={1.5} />
<RadioButtonGroup
options={sortOrderOptions}
disabled={!sql?.orderBy?.property.name}
value={sql.orderByDirection}
onChange={onSortOrderChange}
/>
</InputGroup>
</EditorField>
<EditorField label="Limit" optional width={25}>
<Input type="number" min={0} id={uniqueId('limit-')} value={sql.limit || ''} onChange={onLimitChange} />
</EditorField>
{showOffset && (
<EditorField label="Offset" optional width={25}>
<Input type="number" id={uniqueId('offset-')} value={sql.offset || ''} onChange={onOffsetChange} />
</EditorField>
)}
</>
);
}
@@ -0,0 +1,55 @@
import { css } from '@emotion/css';
import React from 'react';
import { useCopyToClipboard } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { CodeEditor, Field, IconButton, useStyles2 } from '@grafana/ui';
import { formatSQL } from '../../utils/formatSQL';
type PreviewProps = {
rawSql: string;
datasourceType?: string;
};
export function Preview({ rawSql, datasourceType }: PreviewProps) {
// TODO: use zero index to give feedback about copy success
const [_, copyToClipboard] = useCopyToClipboard();
const styles = useStyles2(getStyles);
const copyPreview = (rawSql: string) => {
copyToClipboard(rawSql);
reportInteraction('grafana_sql_preview_copied', {
datasource: datasourceType,
});
};
const labelElement = (
<div className={styles.labelWrapper}>
<span className={styles.label}>Preview</span>
<IconButton tooltip="Copy to clipboard" onClick={() => copyPreview(rawSql)} name="copy" />
</div>
);
return (
<Field label={labelElement} className={styles.grow}>
<CodeEditor
language="sql"
height={80}
value={formatSQL(rawSql)}
monacoOptions={{ scrollbar: { vertical: 'hidden' }, scrollBeyondLastLine: false }}
readOnly={true}
showMiniMap={false}
/>
</Field>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
grow: css({ flexGrow: 1 }),
label: css({ fontSize: 12, fontWeight: theme.typography.fontWeightMedium }),
labelWrapper: css({ display: 'flex', justifyContent: 'space-between', paddingBottom: theme.spacing(0.5) }),
};
}
@@ -0,0 +1,22 @@
import React from 'react';
import { SelectableValue } from '@grafana/data';
import { QueryWithDefaults } from '../../defaults';
import { DB, SQLQuery } from '../../types';
import { useSqlChange } from '../../utils/useSqlChange';
import { GroupByRow } from './GroupByRow';
interface SQLGroupByRowProps {
fields: SelectableValue[];
query: QueryWithDefaults;
onQueryChange: (query: SQLQuery) => void;
db: DB;
}
export function SQLGroupByRow({ fields, query, onQueryChange, db }: SQLGroupByRowProps) {
const { onSqlChange } = useSqlChange({ query, onQueryChange, db });
return <GroupByRow columns={fields} sql={query.sql!} onSqlChange={onSqlChange} />;
}
@@ -0,0 +1,42 @@
import React from 'react';
import { SelectableValue } from '@grafana/data';
import { QueryWithDefaults } from '../../defaults';
import { DB, SQLQuery } from '../../types';
import { useSqlChange } from '../../utils/useSqlChange';
import { OrderByRow } from './OrderByRow';
type SQLOrderByRowProps = {
fields: SelectableValue[];
query: QueryWithDefaults;
onQueryChange: (query: SQLQuery) => void;
db: DB;
};
export function SQLOrderByRow({ fields, query, onQueryChange, db }: SQLOrderByRowProps) {
const { onSqlChange } = useSqlChange({ query, onQueryChange, db });
let columnsWithIndices: SelectableValue[] = [];
if (fields) {
const options = query.sql?.columns?.map((c, i) => {
const value = c.name ? `${c.name}(${c.parameters?.map((p) => p.name)})` : c.parameters?.map((p) => p.name);
return {
value,
label: `${i + 1} - ${value}`,
};
});
columnsWithIndices = [
{
value: '',
label: 'Selected columns',
options,
expanded: true,
},
...fields,
];
}
return <OrderByRow sql={query.sql!} onSqlChange={onSqlChange} columns={columnsWithIndices} />;
}
@@ -0,0 +1,32 @@
import React from 'react';
import { SelectableValue, toOption } from '@grafana/data';
import { COMMON_AGGREGATE_FNS } from '../../constants';
import { QueryWithDefaults } from '../../defaults';
import { DB, SQLQuery } from '../../types';
import { useSqlChange } from '../../utils/useSqlChange';
import { SelectRow } from './SelectRow';
interface SQLSelectRowProps {
fields: SelectableValue[];
query: QueryWithDefaults;
onQueryChange: (query: SQLQuery) => void;
db: DB;
}
export function SQLSelectRow({ fields, query, onQueryChange, db }: SQLSelectRowProps) {
const { onSqlChange } = useSqlChange({ query, onQueryChange, db });
const functions = [...COMMON_AGGREGATE_FNS, ...(db.functions?.() || [])].map(toOption);
return (
<SelectRow
columns={fields}
sql={query.sql!}
format={query.format}
functions={functions}
onSqlChange={onSqlChange}
/>
);
}
@@ -0,0 +1,65 @@
import React from 'react';
import useAsync from 'react-use/lib/useAsync';
import { SelectableValue, VariableWithMultiSupport } from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime';
import { QueryWithDefaults } from '../../defaults';
import { DB, SQLExpression, SQLQuery, SQLSelectableValue } from '../../types';
import { useSqlChange } from '../../utils/useSqlChange';
import { Config } from './AwesomeQueryBuilder';
import { WhereRow } from './WhereRow';
interface WhereRowProps {
query: QueryWithDefaults;
fields: SelectableValue[];
onQueryChange: (query: SQLQuery) => void;
db: DB;
}
export function SQLWhereRow({ query, fields, onQueryChange, db }: WhereRowProps) {
const state = useAsync(async () => {
return mapFieldsToTypes(fields);
}, [fields]);
const { onSqlChange } = useSqlChange({ query, onQueryChange, db });
return (
<WhereRow
// TODO: fix key that's used to force clean render or SQLWhereRow - otherwise it doesn't render operators correctly
key={JSON.stringify(state.value)}
config={{ fields: state.value || {} }}
sql={query.sql!}
onSqlChange={(val: SQLExpression) => {
const templateVars = getTemplateSrv().getVariables() as VariableWithMultiSupport[];
removeQuotesForMultiVariables(val, templateVars);
onSqlChange(val);
}}
/>
);
}
// needed for awesome query builder
function mapFieldsToTypes(columns: SQLSelectableValue[]) {
const fields: Config['fields'] = {};
for (const col of columns) {
fields[col.value] = {
type: col.raqbFieldType || 'text',
valueSources: ['value'],
mainWidgetProps: { customProps: { icon: col.icon } },
};
}
return fields;
}
export function removeQuotesForMultiVariables(val: SQLExpression, templateVars: VariableWithMultiSupport[]) {
const multiVariableInWhereString = (tv: VariableWithMultiSupport) =>
tv.multi && (val.whereString?.includes(`\${${tv.name}}`) || val.whereString?.includes(`$${tv.name}`));
if (templateVars.some((tv) => multiVariableInWhereString(tv))) {
val.whereString = val.whereString?.replaceAll("')", ')');
val.whereString = val.whereString?.replaceAll("('", '(');
}
}
@@ -0,0 +1,182 @@
import { css } from '@emotion/css';
import { uniqueId } from 'lodash';
import React, { useCallback } from 'react';
import { SelectableValue, toOption } from '@grafana/data';
import { EditorField, Stack } from '@grafana/experimental';
import { Button, Select, useStyles2 } from '@grafana/ui';
import { QueryEditorExpressionType, QueryEditorFunctionExpression } from '../../expressions';
import { SQLExpression, QueryFormat } from '../../types';
import { createFunctionField } from '../../utils/sql.utils';
interface SelectRowProps {
sql: SQLExpression;
format: QueryFormat | undefined;
onSqlChange: (sql: SQLExpression) => void;
columns?: Array<SelectableValue<string>>;
functions?: Array<SelectableValue<string>>;
}
const asteriskValue = { label: '*', value: '*' };
export function SelectRow({ sql, format, columns, onSqlChange, functions }: SelectRowProps) {
const styles = useStyles2(getStyles);
const columnsWithAsterisk = [asteriskValue, ...(columns || [])];
const timeSeriesAliasOpts: Array<SelectableValue<string>> = [];
// Add necessary alias options for time series format
// when that format has been selected
if (format === QueryFormat.Timeseries) {
timeSeriesAliasOpts.push({ label: 'time', value: 'time' });
timeSeriesAliasOpts.push({ label: 'value', value: 'value' });
}
const onColumnChange = useCallback(
(item: QueryEditorFunctionExpression, index: number) => (column: SelectableValue<string>) => {
let modifiedItem = { ...item };
if (!item.parameters?.length) {
modifiedItem.parameters = [{ type: QueryEditorExpressionType.FunctionParameter, name: column.value } as const];
} else {
modifiedItem.parameters = item.parameters.map((p) =>
p.type === QueryEditorExpressionType.FunctionParameter ? { ...p, name: column.value } : p
);
}
const newSql: SQLExpression = {
...sql,
columns: sql.columns?.map((c, i) => (i === index ? modifiedItem : c)),
};
onSqlChange(newSql);
},
[onSqlChange, sql]
);
const onAggregationChange = useCallback(
(item: QueryEditorFunctionExpression, index: number) => (aggregation: SelectableValue<string>) => {
const newItem = {
...item,
name: aggregation?.value,
};
const newSql: SQLExpression = {
...sql,
columns: sql.columns?.map((c, i) => (i === index ? newItem : c)),
};
onSqlChange(newSql);
},
[onSqlChange, sql]
);
const onAliasChange = useCallback(
(item: QueryEditorFunctionExpression, index: number) => (alias: SelectableValue<string>) => {
let newItem = { ...item };
if (alias !== null) {
newItem = { ...item, alias: `"${alias?.value?.trim()}"` };
} else {
delete newItem.alias;
}
const newSql: SQLExpression = {
...sql,
columns: sql.columns?.map((c, i) => (i === index ? newItem : c)),
};
onSqlChange(newSql);
},
[onSqlChange, sql]
);
const removeColumn = useCallback(
(index: number) => () => {
const clone = [...sql.columns!];
clone.splice(index, 1);
const newSql: SQLExpression = {
...sql,
columns: clone,
};
onSqlChange(newSql);
},
[onSqlChange, sql]
);
const addColumn = useCallback(() => {
const newSql: SQLExpression = { ...sql, columns: [...sql.columns!, createFunctionField()] };
onSqlChange(newSql);
}, [onSqlChange, sql]);
return (
<Stack gap={2} wrap direction="column">
{sql.columns?.map((item, index) => (
<div key={index}>
<Stack gap={2} alignItems="end">
<EditorField label="Column" width={25}>
<Select
value={getColumnValue(item)}
options={columnsWithAsterisk}
inputId={`select-column-${index}-${uniqueId()}`}
menuShouldPortal
allowCustomValue
onChange={onColumnChange(item, index)}
/>
</EditorField>
<EditorField label="Aggregation" optional width={25}>
<Select
value={item.name ? toOption(item.name) : null}
inputId={`select-aggregation-${index}-${uniqueId()}`}
isClearable
menuShouldPortal
allowCustomValue
options={functions}
onChange={onAggregationChange(item, index)}
/>
</EditorField>
<EditorField label="Alias" optional width={15}>
<Select
value={item.alias ? toOption(item.alias) : null}
inputId={`select-alias-${index}-${uniqueId()}`}
options={timeSeriesAliasOpts}
onChange={onAliasChange(item, index)}
isClearable
menuShouldPortal
allowCustomValue
/>
</EditorField>
<Button
aria-label="Remove"
type="button"
icon="trash-alt"
variant="secondary"
size="md"
onClick={removeColumn(index)}
/>
</Stack>
</div>
))}
<Button
type="button"
onClick={addColumn}
variant="secondary"
size="md"
icon="plus"
aria-label="Add"
className={styles.addButton}
/>
</Stack>
);
}
const getStyles = () => {
return { addButton: css({ alignSelf: 'flex-start' }) };
};
function getColumnValue({ parameters }: QueryEditorFunctionExpression): SelectableValue<string> | null {
const column = parameters?.find((p) => p.type === QueryEditorExpressionType.FunctionParameter);
if (column?.name) {
return toOption(column.name);
}
return null;
}
@@ -0,0 +1,61 @@
import React from 'react';
import { useAsync } from 'react-use';
import { EditorRows, EditorRow, EditorField } from '@grafana/experimental';
import { DB, QueryEditorProps, QueryRowFilter } from '../../types';
import { QueryToolbox } from '../query-editor-raw/QueryToolbox';
import { Preview } from './Preview';
import { SQLGroupByRow } from './SQLGroupByRow';
import { SQLOrderByRow } from './SQLOrderByRow';
import { SQLSelectRow } from './SQLSelectRow';
import { SQLWhereRow } from './SQLWhereRow';
interface VisualEditorProps extends QueryEditorProps {
db: DB;
queryRowFilter: QueryRowFilter;
onValidate: (isValid: boolean) => void;
}
export const VisualEditor = ({ query, db, queryRowFilter, onChange, onValidate, range }: VisualEditorProps) => {
const state = useAsync(async () => {
const fields = await db.fields(query);
return fields;
}, [db, query.dataset, query.table]);
return (
<>
<EditorRows>
<EditorRow>
<SQLSelectRow fields={state.value || []} query={query} onQueryChange={onChange} db={db} />
</EditorRow>
{queryRowFilter.filter && (
<EditorRow>
<EditorField label="Filter by column value" optional>
<SQLWhereRow fields={state.value || []} query={query} onQueryChange={onChange} db={db} />
</EditorField>
</EditorRow>
)}
{queryRowFilter.group && (
<EditorRow>
<EditorField label="Group by column">
<SQLGroupByRow fields={state.value || []} query={query} onQueryChange={onChange} db={db} />
</EditorField>
</EditorRow>
)}
{queryRowFilter.order && (
<EditorRow>
<SQLOrderByRow fields={state.value || []} query={query} onQueryChange={onChange} db={db} />
</EditorRow>
)}
{queryRowFilter.preview && query.rawSql && (
<EditorRow>
<Preview rawSql={query.rawSql} datasourceType={query.datasource?.type} />
</EditorRow>
)}
</EditorRows>
<QueryToolbox db={db} query={query} onValidate={onValidate} range={range} />
</>
);
};
@@ -0,0 +1,92 @@
import { injectGlobal } from '@emotion/css';
import { Builder, Config, ImmutableTree, Query, Utils } from '@react-awesome-query-builder/ui';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { SQLExpression } from '../../types';
import { emptyInitTree, raqbConfig } from './AwesomeQueryBuilder';
interface SQLBuilderWhereRowProps {
sql: SQLExpression;
onSqlChange: (sql: SQLExpression) => void;
config?: Partial<Config>;
}
export function WhereRow({ sql, config, onSqlChange }: SQLBuilderWhereRowProps) {
const [tree, setTree] = useState<ImmutableTree>();
const configWithDefaults = useMemo(() => ({ ...raqbConfig, ...config }), [config]);
useEffect(() => {
// Set the initial tree
if (!tree) {
const initTree = Utils.checkTree(Utils.loadTree(sql.whereJsonTree ?? emptyInitTree), configWithDefaults);
setTree(initTree);
}
}, [configWithDefaults, sql.whereJsonTree, tree]);
useEffect(() => {
if (!sql.whereJsonTree) {
setTree(Utils.checkTree(Utils.loadTree(emptyInitTree), configWithDefaults));
}
}, [configWithDefaults, sql.whereJsonTree]);
const onTreeChange = useCallback(
(changedTree: ImmutableTree, config: Config) => {
setTree(changedTree);
const newSql = {
...sql,
whereJsonTree: Utils.getTree(changedTree),
whereString: Utils.sqlFormat(changedTree, config),
};
onSqlChange(newSql);
},
[onSqlChange, sql]
);
if (!tree) {
return null;
}
return (
<Query
{...configWithDefaults}
value={tree}
onChange={onTreeChange}
renderBuilder={(props) => <Builder {...props} />}
/>
);
}
function flex(direction: string) {
return `
display: flex;
gap: 8px;
flex-direction: ${direction};`;
}
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
injectGlobal`
.group--header {
${flex('row')}
}
.group-or-rule {
${flex('column')}
.rule {
flex-direction: row;
}
}
.rule--body {
${flex('row')}
}
.group--children {
${flex('column')}
}
.group--conjunctions:empty {
display: none;
}
`;
@@ -0,0 +1 @@
export { GroupByRow } from './GroupByRow';