mirror of https://github.com/grafana/grafana.git
sql plugins - angular to react - base sql datasource (#51655)
* base sql datasource and components
This commit is contained in:
parent
a14ca8fb62
commit
fa560d96b6
|
|
@ -6510,6 +6510,20 @@ exports[`better eslint`] = {
|
|||
[173, 23, 47, "Do not use any type assertions.", "3309878203"],
|
||||
[195, 43, 45, "Do not use any type assertions.", "15355460"]
|
||||
],
|
||||
"public/app/features/plugins/sql/components/visual-query-builder/AwesomeQueryBuilder.tsx:760854115": [
|
||||
[175, 24, 63, "Do not use any type assertions.", "2252455532"],
|
||||
[198, 13, 47, "Do not use any type assertions.", "2763495851"],
|
||||
[253, 30, 3, "Unexpected any. Specify a different type.", "193409811"]
|
||||
],
|
||||
"public/app/features/plugins/sql/datasource/SqlDatasource.ts:178321817": [
|
||||
[124, 33, 3, "Unexpected any. Specify a different type.", "193409811"],
|
||||
[159, 56, 3, "Unexpected any. Specify a different type.", "193409811"],
|
||||
[177, 19, 3, "Unexpected any. Specify a different type.", "193409811"],
|
||||
[182, 57, 3, "Unexpected any. Specify a different type.", "193409811"],
|
||||
[184, 18, 135, "Do not use any type assertions.", "3270957200"],
|
||||
[194, 28, 3, "Unexpected any. Specify a different type.", "193409811"],
|
||||
[225, 33, 3, "Unexpected any. Specify a different type.", "193409811"]
|
||||
],
|
||||
"public/app/features/plugins/tests/datasource_srv.test.ts:2399414445": [
|
||||
[10, 19, 3, "Unexpected any. Specify a different type.", "193409811"],
|
||||
[44, 48, 21, "Do not use any type assertions.", "932413114"],
|
||||
|
|
|
|||
|
|
@ -354,6 +354,7 @@
|
|||
"rc-time-picker": "3.7.3",
|
||||
"re-resizable": "6.9.9",
|
||||
"react": "17.0.2",
|
||||
"react-awesome-query-builder": "^5.1.2",
|
||||
"react-beautiful-dnd": "13.1.0",
|
||||
"react-diff-viewer": "^3.1.1",
|
||||
"react-dom": "17.0.2",
|
||||
|
|
|
|||
|
|
@ -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,61 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { Select } from '@grafana/ui';
|
||||
|
||||
import { DB, ResourceSelectorProps, toOption } from '../types';
|
||||
|
||||
interface DatasetSelectorProps extends ResourceSelectorProps {
|
||||
db: DB;
|
||||
value: string | null;
|
||||
applyDefault?: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (v: SelectableValue) => void;
|
||||
}
|
||||
|
||||
export const DatasetSelector: React.FC<DatasetSelectorProps> = ({
|
||||
db,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
className,
|
||||
applyDefault,
|
||||
}) => {
|
||||
const state = useAsync(async () => {
|
||||
const datasets = await db.datasets();
|
||||
return datasets.map(toOption);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!applyDefault) {
|
||||
return;
|
||||
}
|
||||
// Set default dataset when values are fetched
|
||||
if (!value) {
|
||||
if (state.value && state.value[0]) {
|
||||
onChange(state.value[0]);
|
||||
}
|
||||
} else {
|
||||
if (state.value && state.value.find((v) => v.value === value) === 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, value, applyDefault, onChange]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
className={className}
|
||||
aria-label="Dataset selector"
|
||||
value={value}
|
||||
options={state.value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
isLoading={state.loading}
|
||||
menuShouldPortal={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
fallBackComponent?: React.ReactNode;
|
||||
};
|
||||
|
||||
export class ErrorBoundary extends React.Component<Props, { hasError: boolean }> {
|
||||
constructor(props: 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,119 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { QueryEditorProps } from '@grafana/data';
|
||||
import { EditorMode, Space } from '@grafana/experimental';
|
||||
|
||||
import { SqlDatasource } from '../datasource/SqlDatasource';
|
||||
import { applyQueryDefaults } from '../defaults';
|
||||
import { SQLQuery, QueryRowFilter, SQLOptions } from '../types';
|
||||
import { haveColumns } from '../utils/sql.utils';
|
||||
|
||||
import { QueryHeader } from './QueryHeader';
|
||||
import { RawEditor } from './query-editor-raw/RawEditor';
|
||||
import { VisualEditor } from './visual-query-builder/VisualEditor';
|
||||
|
||||
type Props = QueryEditorProps<SqlDatasource, SQLQuery, SQLOptions>;
|
||||
|
||||
export function SqlQueryEditor({ datasource, query, onChange, onRunQuery, range }: Props) {
|
||||
const [isQueryRunnable, setIsQueryRunnable] = useState(true);
|
||||
const db = datasource.getDB();
|
||||
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}
|
||||
onChange={onQueryHeaderChange}
|
||||
onRunQuery={onRunQuery}
|
||||
onQueryRowChange={setQueryRowFilter}
|
||||
queryRowFilter={queryRowFilter}
|
||||
query={queryWithDefaults}
|
||||
isQueryRunnable={isQueryRunnable}
|
||||
/>
|
||||
|
||||
<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,245 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { EditorField, EditorHeader, EditorMode, EditorRow, FlexItem, InlineSelect, Space } from '@grafana/experimental';
|
||||
import { Button, InlineField, InlineSwitch, RadioButtonGroup, Select, Tooltip } from '@grafana/ui';
|
||||
|
||||
import { QueryWithDefaults } from '../defaults';
|
||||
import { SQLQuery, QueryFormat, QueryRowFilter, QUERY_FORMAT_OPTIONS, DB } from '../types';
|
||||
import { defaultToRawSql } from '../utils/sql.utils';
|
||||
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { DatasetSelector } from './DatasetSelector';
|
||||
import { ErrorBoundary } from './ErrorBoundary';
|
||||
import { TableSelector } from './TableSelector';
|
||||
|
||||
interface QueryHeaderProps {
|
||||
db: DB;
|
||||
query: QueryWithDefaults;
|
||||
onChange: (query: SQLQuery) => void;
|
||||
onRunQuery: () => void;
|
||||
onQueryRowChange: (queryRowFilter: QueryRowFilter) => void;
|
||||
queryRowFilter: QueryRowFilter;
|
||||
isQueryRunnable: boolean;
|
||||
}
|
||||
|
||||
const editorModes = [
|
||||
{ label: 'Builder', value: EditorMode.Builder },
|
||||
{ label: 'Code', value: EditorMode.Code },
|
||||
];
|
||||
|
||||
export function QueryHeader({
|
||||
db,
|
||||
query,
|
||||
queryRowFilter,
|
||||
onChange,
|
||||
onRunQuery,
|
||||
onQueryRowChange,
|
||||
isQueryRunnable,
|
||||
}: QueryHeaderProps) {
|
||||
const { editorMode } = query;
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const toRawSql = db.toRawSql || defaultToRawSql;
|
||||
|
||||
const onEditorModeChange = useCallback(
|
||||
(newEditorMode: EditorMode) => {
|
||||
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 };
|
||||
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);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditorHeader>
|
||||
{/* Backward compatibility check. Inline select uses SelectContainer that was added in 8.3 */}
|
||||
<ErrorBoundary
|
||||
fallBackComponent={
|
||||
<InlineField label="Format" labelWidth={15}>
|
||||
<Select
|
||||
placeholder="Select format"
|
||||
value={query.format}
|
||||
onChange={onFormatChange}
|
||||
options={QUERY_FORMAT_OPTIONS}
|
||||
/>
|
||||
</InlineField>
|
||||
}
|
||||
>
|
||||
<InlineSelect
|
||||
label="Format"
|
||||
value={query.format}
|
||||
placeholder="Select format"
|
||||
menuShouldPortal
|
||||
onChange={onFormatChange}
|
||||
options={QUERY_FORMAT_OPTIONS}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
|
||||
{editorMode === EditorMode.Builder && (
|
||||
<>
|
||||
<InlineSwitch
|
||||
id="sql-filter"
|
||||
label="Filter"
|
||||
transparent={true}
|
||||
showLabel={true}
|
||||
value={queryRowFilter.filter}
|
||||
onChange={(ev) =>
|
||||
ev.target instanceof HTMLInputElement &&
|
||||
onQueryRowChange({ ...queryRowFilter, filter: ev.target.checked })
|
||||
}
|
||||
/>
|
||||
|
||||
<InlineSwitch
|
||||
id="sql-group"
|
||||
label="Group"
|
||||
transparent={true}
|
||||
showLabel={true}
|
||||
value={queryRowFilter.group}
|
||||
onChange={(ev) =>
|
||||
ev.target instanceof HTMLInputElement &&
|
||||
onQueryRowChange({ ...queryRowFilter, group: ev.target.checked })
|
||||
}
|
||||
/>
|
||||
|
||||
<InlineSwitch
|
||||
id="sql-order"
|
||||
label="Order"
|
||||
transparent={true}
|
||||
showLabel={true}
|
||||
value={queryRowFilter.order}
|
||||
onChange={(ev) =>
|
||||
ev.target instanceof HTMLInputElement &&
|
||||
onQueryRowChange({ ...queryRowFilter, order: ev.target.checked })
|
||||
}
|
||||
/>
|
||||
|
||||
<InlineSwitch
|
||||
id="sql-preview"
|
||||
label="Preview"
|
||||
transparent={true}
|
||||
showLabel={true}
|
||||
value={queryRowFilter.preview}
|
||||
onChange={(ev) =>
|
||||
ev.target instanceof HTMLInputElement &&
|
||||
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={() => {
|
||||
setShowConfirm(false);
|
||||
copyToClipboard(query.rawSql!);
|
||||
onChange({
|
||||
...query,
|
||||
rawSql: toRawSql(query),
|
||||
editorMode: EditorMode.Builder,
|
||||
});
|
||||
}}
|
||||
onDiscard={() => {
|
||||
setShowConfirm(false);
|
||||
onChange({
|
||||
...query,
|
||||
rawSql: toRawSql(query),
|
||||
editorMode: EditorMode.Builder,
|
||||
});
|
||||
}}
|
||||
onCancel={() => setShowConfirm(false)}
|
||||
/>
|
||||
</EditorHeader>
|
||||
|
||||
{editorMode === EditorMode.Builder && (
|
||||
<>
|
||||
<Space v={0.5} />
|
||||
|
||||
<EditorRow>
|
||||
<EditorField label="Dataset" width={25}>
|
||||
<DatasetSelector
|
||||
db={db}
|
||||
value={query.dataset === undefined ? null : query.dataset}
|
||||
onChange={onDatasetChange}
|
||||
/>
|
||||
</EditorField>
|
||||
|
||||
<EditorField label="Table" width={25}>
|
||||
<TableSelector
|
||||
db={db}
|
||||
query={query}
|
||||
value={query.table === undefined ? null : query.table}
|
||||
onChange={onTableChange}
|
||||
applyDefault
|
||||
/>
|
||||
</EditorField>
|
||||
</EditorRow>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import React from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { SelectableValue, toOption } from '@grafana/data';
|
||||
import { Select } from '@grafana/ui';
|
||||
|
||||
import { QueryWithDefaults } from '../defaults';
|
||||
import { DB, ResourceSelectorProps } from '../types';
|
||||
|
||||
interface TableSelectorProps extends ResourceSelectorProps {
|
||||
db: DB;
|
||||
value: string | null;
|
||||
query: QueryWithDefaults;
|
||||
onChange: (v: SelectableValue) => void;
|
||||
}
|
||||
|
||||
export const TableSelector: React.FC<TableSelectorProps> = ({ db, query, value, className, onChange }) => {
|
||||
const state = useAsync(async () => {
|
||||
if (!query.dataset) {
|
||||
return [];
|
||||
}
|
||||
const tables = await db.tables(query.dataset);
|
||||
return tables.map(toOption);
|
||||
}, [query.dataset]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
className={className}
|
||||
disabled={state.loading}
|
||||
aria-label="Table selector"
|
||||
value={value}
|
||||
options={state.value}
|
||||
onChange={onChange}
|
||||
isLoading={state.loading}
|
||||
menuShouldPortal={true}
|
||||
placeholder={state.loading ? 'Loading tables' : 'Select table'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { LanguageCompletionProvider, SQLEditor } from '@grafana/experimental';
|
||||
|
||||
import { SQLQuery } from '../../types';
|
||||
import { formatSQL } from '../../utils/formatSQL';
|
||||
|
||||
type Props = {
|
||||
query: SQLQuery;
|
||||
onChange: (value: SQLQuery, processQuery: boolean) => void;
|
||||
children?: (props: { formatQuery: () => void }) => React.ReactNode;
|
||||
width?: number;
|
||||
height?: number;
|
||||
completionProvider: LanguageCompletionProvider;
|
||||
};
|
||||
|
||||
export function QueryEditorRaw({ children, onChange, query, width, height, completionProvider }: 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={{ id: 'sql', completionProvider, formatter: formatSQL }}
|
||||
>
|
||||
{children}
|
||||
</SQLEditor>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { css } from '@emotion/css';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
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={onFormatCode} name="brackets-curly" size="xs" tooltip="Format query" />
|
||||
)}
|
||||
{onExpand && (
|
||||
<IconButton
|
||||
onClick={() => 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={12} /> 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,121 @@
|
|||
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 { 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 completionProvider = useMemo(() => db.getSqlCompletionProvider(), [db]);
|
||||
|
||||
const renderQueryEditor = (width?: number, height?: number) => {
|
||||
return (
|
||||
<QueryEditorRaw
|
||||
completionProvider={completionProvider}
|
||||
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={() => {
|
||||
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,259 @@
|
|||
import { List } from 'immutable';
|
||||
import { isString } from 'lodash';
|
||||
import React from 'react';
|
||||
import {
|
||||
BasicConfig,
|
||||
Config,
|
||||
JsonItem,
|
||||
JsonTree,
|
||||
Operator,
|
||||
Settings,
|
||||
SimpleField,
|
||||
Utils,
|
||||
ValueSource,
|
||||
Widgets,
|
||||
} from 'react-awesome-query-builder';
|
||||
|
||||
import { dateTime, toOption } from '@grafana/data';
|
||||
import { Button, DateTimePicker, Input, Select } from '@grafana/ui';
|
||||
|
||||
const buttonLabels = {
|
||||
add: 'Add',
|
||||
remove: 'Remove',
|
||||
};
|
||||
|
||||
export const emptyInitValue: JsonItem = {
|
||||
id: Utils.uuid(),
|
||||
type: 'group' as const,
|
||||
children1: {
|
||||
[Utils.uuid()]: {
|
||||
type: 'rule',
|
||||
properties: {
|
||||
field: null,
|
||||
operator: null,
|
||||
value: [],
|
||||
valueSrc: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const emptyInitTree: JsonTree = {
|
||||
id: Utils.uuid(),
|
||||
type: 'group' as const,
|
||||
children1: {
|
||||
[Utils.uuid()]: {
|
||||
type: 'rule',
|
||||
properties: {
|
||||
field: null,
|
||||
operator: null,
|
||||
value: [],
|
||||
valueSrc: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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) {
|
||||
return (
|
||||
<DateTimePicker
|
||||
onChange={(e) => {
|
||||
props?.setValue(e.format(BasicConfig.widgets.datetime.valueFormat));
|
||||
}}
|
||||
date={dateTime(props?.value).utc()}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const settings: Settings = {
|
||||
...BasicConfig.settings,
|
||||
canRegroup: false,
|
||||
maxNesting: 1,
|
||||
canReorder: false,
|
||||
showNot: false,
|
||||
addRuleLabel: buttonLabels.add,
|
||||
deleteLabel: buttonLabels.remove,
|
||||
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!)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
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!);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
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'}
|
||||
/>
|
||||
);
|
||||
},
|
||||
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',
|
||||
}
|
||||
// eslint-ignore
|
||||
const customOperators = getCustomOperators(BasicConfig) as typeof BasicConfig.operators;
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const raqbConfig: Config = {
|
||||
...BasicConfig,
|
||||
widgets,
|
||||
settings,
|
||||
operators: customOperators as typeof BasicConfig.operators,
|
||||
types: customTypes,
|
||||
};
|
||||
|
||||
export type { Config };
|
||||
|
||||
function getCustomOperators(config: BasicConfig) {
|
||||
const { ...supportedOperators } = config.operators;
|
||||
const noop = () => '';
|
||||
// IN operator expects array, override IN formatter for multi-value variables
|
||||
const sqlFormatInOp = supportedOperators[Op.IN].sqlFormatOp || noop;
|
||||
const customSqlInFormatter = (
|
||||
field: string,
|
||||
op: string,
|
||||
value: string | List<string>,
|
||||
valueSrc: ValueSource,
|
||||
valueType: string,
|
||||
opDef: Operator,
|
||||
operatorOptions: object,
|
||||
fieldDef: SimpleField
|
||||
) => {
|
||||
return sqlFormatInOp(field, op, splitIfString(value), valueSrc, valueType, opDef, operatorOptions, fieldDef);
|
||||
};
|
||||
// NOT IN operator expects array, override NOT IN formatter for multi-value variables
|
||||
const sqlFormatNotInOp = supportedOperators[Op.NOT_IN].sqlFormatOp || noop;
|
||||
const customSqlNotInFormatter = (
|
||||
field: string,
|
||||
op: string,
|
||||
value: string | List<string>,
|
||||
valueSrc: ValueSource,
|
||||
valueType: string,
|
||||
opDef: Operator,
|
||||
operatorOptions: object,
|
||||
fieldDef: SimpleField
|
||||
) => {
|
||||
return sqlFormatNotInOp(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,
|
||||
},
|
||||
};
|
||||
|
||||
return customOperators;
|
||||
}
|
||||
|
||||
// value: string | List<string> but AQB uses a different version of Immutable
|
||||
// eslint-ignore
|
||||
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<QueryEditorGroupByExpression>
|
||||
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, Space } from '@grafana/experimental';
|
||||
import { Input, RadioButtonGroup, Select } 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,46 @@
|
|||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { CodeEditor, Field, IconButton, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { formatSQL } from '../../utils/formatSQL';
|
||||
|
||||
type PreviewProps = {
|
||||
rawSql: string;
|
||||
};
|
||||
|
||||
export function Preview({ rawSql }: PreviewProps) {
|
||||
// TODO: use zero index to give feedback about copy success
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const labelElement = (
|
||||
<div className={styles.labelWrapper}>
|
||||
<label className={styles.label}>Preview</label>
|
||||
<IconButton tooltip="Copy to clipboard" onClick={() => copyToClipboard(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,40 @@
|
|||
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) {
|
||||
columnsWithIndices = [
|
||||
{
|
||||
value: '',
|
||||
label: 'Selected columns',
|
||||
options: query.sql?.columns?.map((c, i) => ({
|
||||
value: i + 1,
|
||||
label: c.name
|
||||
? `${i + 1} - ${c.name}(${c.parameters?.map((p) => `${p.name}`)})`
|
||||
: c.parameters?.map((p) => `${i + 1} - ${p.name}`),
|
||||
})),
|
||||
expanded: true,
|
||||
},
|
||||
...fields,
|
||||
];
|
||||
}
|
||||
|
||||
return <OrderByRow sql={query.sql!} onSqlChange={onSqlChange} columns={columnsWithIndices} />;
|
||||
}
|
||||
|
|
@ -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 { 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 });
|
||||
|
||||
return <SelectRow columns={fields} sql={query.sql!} onSqlChange={onSqlChange} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import React from 'react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
|
||||
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) => {
|
||||
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;
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
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 { AGGREGATE_FNS } from '../../constants';
|
||||
import { QueryEditorExpressionType, QueryEditorFunctionExpression } from '../../expressions';
|
||||
import { SQLExpression } from '../../types';
|
||||
import { createFunctionField } from '../../utils/sql.utils';
|
||||
|
||||
interface SelectRowProps {
|
||||
sql: SQLExpression;
|
||||
onSqlChange: (sql: SQLExpression) => void;
|
||||
columns?: Array<SelectableValue<string>>;
|
||||
}
|
||||
|
||||
const asteriskValue = { label: '*', value: '*' };
|
||||
|
||||
export function SelectRow({ sql, columns, onSqlChange }: SelectRowProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const columnsWithAsterisk = [asteriskValue, ...(columns || [])];
|
||||
|
||||
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 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} alignItems="end" 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={aggregateFnOptions}
|
||||
onChange={onAggregationChange(item, index)}
|
||||
/>
|
||||
</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' }) };
|
||||
};
|
||||
|
||||
const aggregateFnOptions = AGGREGATE_FNS.map((v: { id: string; name: string; description: string }) =>
|
||||
toOption(v.name)
|
||||
);
|
||||
|
||||
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,68 @@
|
|||
import React from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { EditorField, EditorRow, EditorRows } 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: React.FC<VisualEditorProps> = ({
|
||||
query,
|
||||
db,
|
||||
queryRowFilter,
|
||||
onChange,
|
||||
onValidate,
|
||||
range,
|
||||
}) => {
|
||||
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} />
|
||||
</EditorRow>
|
||||
)}
|
||||
</EditorRows>
|
||||
<QueryToolbox db={db} query={query} onValidate={onValidate} range={range} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import { injectGlobal } from '@emotion/css';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Builder, Config, ImmutableTree, Query, Utils } from 'react-awesome-query-builder';
|
||||
|
||||
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,112 @@
|
|||
import { OperatorType } from '@grafana/experimental';
|
||||
|
||||
export const AGGREGATE_FNS = [
|
||||
{
|
||||
id: 'AVG',
|
||||
name: 'AVG',
|
||||
description: `AVG(
|
||||
[DISTINCT]
|
||||
expression
|
||||
)
|
||||
[OVER (...)]
|
||||
|
||||
Returns the average of non-NULL input values, or NaN if the input contains a NaN.`,
|
||||
},
|
||||
{
|
||||
id: 'COUNT',
|
||||
name: 'COUNT',
|
||||
description: `COUNT(*) [OVER (...)]
|
||||
Returns the number of rows in the input.
|
||||
|
||||
COUNT(
|
||||
[DISTINCT]
|
||||
expression
|
||||
)
|
||||
[OVER (...)]
|
||||
|
||||
Returns the number of rows with expression evaluated to any value other than NULL.
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'MAX',
|
||||
name: 'MAX',
|
||||
description: `MAX(
|
||||
expression
|
||||
)
|
||||
[OVER (...)]
|
||||
|
||||
Returns the maximum value of non-NULL expressions. Returns NULL if there are zero input rows or expression evaluates to NULL for all rows. Returns NaN if the input contains a NaN.
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'MIN',
|
||||
name: 'MIN',
|
||||
description: `MIN(
|
||||
expression
|
||||
)
|
||||
[OVER (...)]
|
||||
|
||||
Returns the minimum value of non-NULL expressions. Returns NULL if there are zero input rows or expression evaluates to NULL for all rows. Returns NaN if the input contains a NaN.
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'SUM',
|
||||
name: 'SUM',
|
||||
description: `SUM(
|
||||
[DISTINCT]
|
||||
expression
|
||||
)
|
||||
[OVER (...)]
|
||||
|
||||
Returns the sum of non-null values.
|
||||
|
||||
If the expression is a floating point value, the sum is non-deterministic, which means you might receive a different result each time you use this function.
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
export const OPERATORS = [
|
||||
{ type: OperatorType.Comparison, id: 'LESS_THAN', operator: '<', description: 'Returns TRUE if X is less than Y.' },
|
||||
{
|
||||
type: OperatorType.Comparison,
|
||||
id: 'LESS_THAN_EQUAL',
|
||||
operator: '<=',
|
||||
description: 'Returns TRUE if X is less than or equal to Y.',
|
||||
},
|
||||
{
|
||||
type: OperatorType.Comparison,
|
||||
id: 'GREATER_THAN',
|
||||
operator: '>',
|
||||
description: 'Returns TRUE if X is greater than Y.',
|
||||
},
|
||||
{
|
||||
type: OperatorType.Comparison,
|
||||
id: 'GREATER_THAN_EQUAL',
|
||||
operator: '>=',
|
||||
description: 'Returns TRUE if X is greater than or equal to Y.',
|
||||
},
|
||||
{ type: OperatorType.Comparison, id: 'EQUAL', operator: '=', description: 'Returns TRUE if X is equal to Y.' },
|
||||
{
|
||||
type: OperatorType.Comparison,
|
||||
id: 'NOT_EQUAL',
|
||||
operator: '!=',
|
||||
description: 'Returns TRUE if X is not equal to Y.',
|
||||
},
|
||||
{
|
||||
type: OperatorType.Comparison,
|
||||
id: 'NOT_EQUAL_ALT',
|
||||
operator: '<>',
|
||||
description: 'Returns TRUE if X is not equal to Y.',
|
||||
},
|
||||
{
|
||||
type: OperatorType.Comparison,
|
||||
id: 'LIKE',
|
||||
operator: 'LIKE',
|
||||
description: `Checks if the STRING in the first operand X matches a pattern specified by the second operand Y. Expressions can contain these characters:
|
||||
- A percent sign "%" matches any number of characters or bytes
|
||||
- An underscore "_" matches a single character or byte
|
||||
- You can escape "\", "_", or "%" using two backslashes. For example, "\\%". If you are using raw strings, only a single backslash is required. For example, r"\%".`,
|
||||
},
|
||||
{ type: OperatorType.Logical, id: 'AND', operator: 'AND' },
|
||||
{ type: OperatorType.Logical, id: 'OR', operator: 'OR' },
|
||||
];
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
import { lastValueFrom, of } from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
|
||||
import {
|
||||
AnnotationEvent,
|
||||
DataFrame,
|
||||
DataFrameView,
|
||||
DataQueryRequest,
|
||||
DataQueryResponse,
|
||||
DataSourceInstanceSettings,
|
||||
DataSourceRef,
|
||||
MetricFindValue,
|
||||
ScopedVars,
|
||||
} from '@grafana/data';
|
||||
import {
|
||||
BackendDataSourceResponse,
|
||||
DataSourceWithBackend,
|
||||
FetchResponse,
|
||||
getBackendSrv,
|
||||
getTemplateSrv,
|
||||
TemplateSrv,
|
||||
} from '@grafana/runtime';
|
||||
import { toTestingStatus } from '@grafana/runtime/src/utils/queryResponse';
|
||||
|
||||
import { VariableWithMultiSupport } from '../../../variables/types';
|
||||
import { getSearchFilterScopedVar } from '../../../variables/utils';
|
||||
import {
|
||||
DB,
|
||||
SQLQuery,
|
||||
SQLOptions,
|
||||
SqlQueryForInterpolation,
|
||||
ResponseParser,
|
||||
SqlQueryModel,
|
||||
QueryFormat,
|
||||
} from '../types';
|
||||
|
||||
export abstract class SqlDatasource extends DataSourceWithBackend<SQLQuery, SQLOptions> {
|
||||
id: number;
|
||||
name: string;
|
||||
interval: string;
|
||||
db: DB;
|
||||
|
||||
constructor(
|
||||
instanceSettings: DataSourceInstanceSettings<SQLOptions>,
|
||||
protected readonly templateSrv: TemplateSrv = getTemplateSrv()
|
||||
) {
|
||||
super(instanceSettings);
|
||||
this.name = instanceSettings.name;
|
||||
this.id = instanceSettings.id;
|
||||
const settingsData = instanceSettings.jsonData || {};
|
||||
this.interval = settingsData.timeInterval || '1m';
|
||||
this.db = this.getDB();
|
||||
}
|
||||
|
||||
abstract getDB(dsID?: number): DB;
|
||||
|
||||
abstract getQueryModel(target?: SQLQuery, templateSrv?: TemplateSrv, scopedVars?: ScopedVars): SqlQueryModel;
|
||||
|
||||
abstract getResponseParser(): ResponseParser;
|
||||
|
||||
interpolateVariable = (value: string | string[] | number, variable: VariableWithMultiSupport) => {
|
||||
if (typeof value === 'string') {
|
||||
if (variable.multi || variable.includeAll) {
|
||||
const result = this.getQueryModel().quoteLiteral(value);
|
||||
return result;
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const quotedValues = value.map((v) => this.getQueryModel().quoteLiteral(v));
|
||||
return quotedValues.join(',');
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
interpolateVariablesInQueries(
|
||||
queries: SqlQueryForInterpolation[],
|
||||
scopedVars: ScopedVars
|
||||
): SqlQueryForInterpolation[] {
|
||||
let expandedQueries = queries;
|
||||
if (queries && queries.length > 0) {
|
||||
expandedQueries = queries.map((query) => {
|
||||
const expandedQuery = {
|
||||
...query,
|
||||
datasource: this.getRef(),
|
||||
rawSql: this.templateSrv.replace(query.rawSql, scopedVars, this.interpolateVariable),
|
||||
rawQuery: true,
|
||||
};
|
||||
return expandedQuery;
|
||||
});
|
||||
}
|
||||
return expandedQueries;
|
||||
}
|
||||
|
||||
filterQuery(query: SQLQuery): boolean {
|
||||
return !query.hide;
|
||||
}
|
||||
|
||||
applyTemplateVariables(
|
||||
target: SQLQuery,
|
||||
scopedVars: ScopedVars
|
||||
): Record<string, string | DataSourceRef | SQLQuery['format']> {
|
||||
const queryModel = this.getQueryModel(target, this.templateSrv, scopedVars);
|
||||
const rawSql = this.clean(queryModel.interpolate());
|
||||
return {
|
||||
refId: target.refId,
|
||||
datasource: this.getRef(),
|
||||
rawSql,
|
||||
format: target.format,
|
||||
};
|
||||
}
|
||||
|
||||
clean(value: string) {
|
||||
return value.replace(/''/g, "'");
|
||||
}
|
||||
|
||||
// eslint-ignore @typescript-eslint/no-explicit-any
|
||||
async annotationQuery(options: any): Promise<AnnotationEvent[]> {
|
||||
if (!options.annotation.rawQuery) {
|
||||
return Promise.reject({
|
||||
message: 'Query missing in annotation definition',
|
||||
});
|
||||
}
|
||||
|
||||
const query = {
|
||||
refId: options.annotation.name,
|
||||
datasource: this.getRef(),
|
||||
rawSql: this.templateSrv.replace(options.annotation.rawQuery, options.scopedVars, this.interpolateVariable),
|
||||
format: 'table',
|
||||
};
|
||||
|
||||
return lastValueFrom(
|
||||
getBackendSrv()
|
||||
.fetch<BackendDataSourceResponse>({
|
||||
url: '/api/ds/query',
|
||||
method: 'POST',
|
||||
data: {
|
||||
from: options.range.from.valueOf().toString(),
|
||||
to: options.range.to.valueOf().toString(),
|
||||
queries: [query],
|
||||
},
|
||||
requestId: options.annotation.name,
|
||||
})
|
||||
.pipe(
|
||||
map(
|
||||
async (res: FetchResponse<BackendDataSourceResponse>) =>
|
||||
await this.getResponseParser().transformAnnotationResponse(options, res.data)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async metricFindQuery(query: string, optionalOptions: any): Promise<MetricFindValue[]> {
|
||||
const rawSql = this.templateSrv.replace(
|
||||
query,
|
||||
getSearchFilterScopedVar({ query, wildcardChar: '%', options: optionalOptions }),
|
||||
this.interpolateVariable
|
||||
);
|
||||
|
||||
const interpolatedQuery = {
|
||||
datasourceId: this.id,
|
||||
datasource: this.getRef(),
|
||||
rawSql,
|
||||
format: QueryFormat.Table,
|
||||
};
|
||||
|
||||
const response = await this.runQuery(interpolatedQuery, optionalOptions);
|
||||
return this.getResponseParser().transformMetricFindResponse(response);
|
||||
}
|
||||
|
||||
async runSql<T = any>(query: string) {
|
||||
const frame = await this.runQuery({ rawSql: query, format: QueryFormat.Table }, {});
|
||||
return new DataFrameView<T>(frame);
|
||||
}
|
||||
|
||||
private runQuery(request: Partial<SQLQuery>, options?: any): Promise<DataFrame> {
|
||||
return new Promise((resolve) => {
|
||||
const req = {
|
||||
targets: [{ ...request, refId: String(Math.random()) }],
|
||||
range: options?.range,
|
||||
} as DataQueryRequest<SQLQuery>;
|
||||
this.query(req).subscribe((res: DataQueryResponse) => {
|
||||
resolve(res.data[0] || { fields: [] });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
testDatasource(): Promise<any> {
|
||||
return lastValueFrom(
|
||||
getBackendSrv()
|
||||
.fetch({
|
||||
url: '/api/ds/query',
|
||||
method: 'POST',
|
||||
data: {
|
||||
from: '5m',
|
||||
to: 'now',
|
||||
queries: [
|
||||
{
|
||||
refId: 'A',
|
||||
intervalMs: 1,
|
||||
maxDataPoints: 1,
|
||||
datasource: this.getRef(),
|
||||
datasourceId: this.id,
|
||||
rawSql: 'SELECT 1',
|
||||
format: 'table',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.pipe(
|
||||
map(() => ({ status: 'success', message: 'Database Connection OK' })),
|
||||
catchError((err) => {
|
||||
return of(toTestingStatus(err));
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
targetContainsTemplate(target: any) {
|
||||
return this.templateSrv.containsTemplate(target.rawSql);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { EditorMode } from '@grafana/experimental';
|
||||
|
||||
import { QueryFormat, SQLQuery } from './types';
|
||||
import { createFunctionField, setGroupByField } from './utils/sql.utils';
|
||||
|
||||
export function applyQueryDefaults(q: SQLQuery): SQLQuery {
|
||||
let editorMode = q.editorMode || EditorMode.Builder;
|
||||
|
||||
// Switching to code editor if the query was created before visual query builder was introduced.
|
||||
if (q.editorMode === undefined && q.rawSql !== undefined) {
|
||||
editorMode = EditorMode.Code;
|
||||
}
|
||||
|
||||
const result = {
|
||||
...q,
|
||||
format: q.format !== undefined ? q.format : QueryFormat.Table,
|
||||
rawSql: q.rawSql || '',
|
||||
editorMode,
|
||||
sql: q.sql || {
|
||||
columns: [createFunctionField()],
|
||||
groupBy: [setGroupByField()],
|
||||
limit: 50,
|
||||
},
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export type QueryWithDefaults = ReturnType<typeof applyQueryDefaults>;
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
export enum QueryEditorPropertyType {
|
||||
String = 'string',
|
||||
}
|
||||
|
||||
export interface QueryEditorProperty {
|
||||
type: QueryEditorPropertyType;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export type QueryEditorOperatorType = string | boolean | number;
|
||||
type QueryEditorOperatorValueType = QueryEditorOperatorType | QueryEditorOperatorType[];
|
||||
|
||||
export interface QueryEditorOperator<T extends QueryEditorOperatorValueType> {
|
||||
name?: string;
|
||||
value?: T;
|
||||
}
|
||||
|
||||
export interface QueryEditorOperatorExpression {
|
||||
type: QueryEditorExpressionType.Operator;
|
||||
property: QueryEditorProperty;
|
||||
operator: QueryEditorOperator<QueryEditorOperatorValueType>;
|
||||
}
|
||||
|
||||
export interface QueryEditorArrayExpression {
|
||||
type: QueryEditorExpressionType.And | QueryEditorExpressionType.Or;
|
||||
expressions: QueryEditorExpression[] | QueryEditorArrayExpression[];
|
||||
}
|
||||
|
||||
export interface QueryEditorPropertyExpression {
|
||||
type: QueryEditorExpressionType.Property;
|
||||
property: QueryEditorProperty;
|
||||
}
|
||||
|
||||
export enum QueryEditorExpressionType {
|
||||
Property = 'property',
|
||||
Operator = 'operator',
|
||||
Or = 'or',
|
||||
And = 'and',
|
||||
GroupBy = 'groupBy',
|
||||
Function = 'function',
|
||||
FunctionParameter = 'functionParameter',
|
||||
}
|
||||
|
||||
export type QueryEditorExpression =
|
||||
| QueryEditorArrayExpression
|
||||
| QueryEditorPropertyExpression
|
||||
| QueryEditorGroupByExpression
|
||||
| QueryEditorFunctionExpression
|
||||
| QueryEditorFunctionParameterExpression
|
||||
| QueryEditorOperatorExpression;
|
||||
|
||||
export interface QueryEditorGroupByExpression {
|
||||
type: QueryEditorExpressionType.GroupBy;
|
||||
property: QueryEditorProperty;
|
||||
}
|
||||
|
||||
export interface QueryEditorFunctionExpression {
|
||||
type: QueryEditorExpressionType.Function;
|
||||
name?: string;
|
||||
parameters?: QueryEditorFunctionParameterExpression[];
|
||||
}
|
||||
|
||||
export interface QueryEditorFunctionParameterExpression {
|
||||
type: QueryEditorExpressionType.FunctionParameter;
|
||||
name?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
import { JsonTree } from 'react-awesome-query-builder';
|
||||
|
||||
import {
|
||||
AnnotationEvent,
|
||||
DataFrame,
|
||||
DataQuery,
|
||||
DataSourceJsonData,
|
||||
MetricFindValue,
|
||||
SelectableValue,
|
||||
TimeRange,
|
||||
toOption as toOptionFromData,
|
||||
} from '@grafana/data';
|
||||
import { CompletionItemKind, EditorMode, LanguageCompletionProvider } from '@grafana/experimental';
|
||||
import { BackendDataSourceResponse } from '@grafana/runtime';
|
||||
|
||||
import { QueryWithDefaults } from './defaults';
|
||||
import {
|
||||
QueryEditorFunctionExpression,
|
||||
QueryEditorGroupByExpression,
|
||||
QueryEditorPropertyExpression,
|
||||
} from './expressions';
|
||||
|
||||
export interface SqlQueryForInterpolation {
|
||||
dataset?: string;
|
||||
alias?: string;
|
||||
format?: ResultFormat;
|
||||
rawSql?: string;
|
||||
refId: string;
|
||||
hide?: boolean;
|
||||
}
|
||||
|
||||
export interface SQLOptions extends DataSourceJsonData {
|
||||
timeInterval: string;
|
||||
database: string;
|
||||
}
|
||||
|
||||
export type ResultFormat = 'time_series' | 'table';
|
||||
|
||||
export enum QueryFormat {
|
||||
Timeseries = 'time_series',
|
||||
Table = 'table',
|
||||
}
|
||||
|
||||
export interface SQLQuery extends DataQuery {
|
||||
alias?: string;
|
||||
format?: ResultFormat | QueryFormat | string | undefined;
|
||||
rawSql?: string;
|
||||
dataset?: string;
|
||||
table?: string;
|
||||
sql?: SQLExpression;
|
||||
editorMode?: EditorMode;
|
||||
rawQuery?: boolean;
|
||||
}
|
||||
|
||||
export interface NameValue {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type SQLFilters = NameValue[];
|
||||
|
||||
export interface SQLExpression {
|
||||
columns?: QueryEditorFunctionExpression[];
|
||||
whereJsonTree?: JsonTree;
|
||||
whereString?: string;
|
||||
filters?: SQLFilters;
|
||||
groupBy?: QueryEditorGroupByExpression[];
|
||||
orderBy?: QueryEditorPropertyExpression;
|
||||
orderByDirection?: 'ASC' | 'DESC';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface TableSchema {
|
||||
name?: string;
|
||||
schema?: TableFieldSchema[];
|
||||
}
|
||||
|
||||
export interface TableFieldSchema {
|
||||
name: string;
|
||||
description?: string;
|
||||
type: string;
|
||||
repeated: boolean;
|
||||
schema: TableFieldSchema[];
|
||||
}
|
||||
|
||||
export interface QueryRowFilter {
|
||||
filter: boolean;
|
||||
group: boolean;
|
||||
order: boolean;
|
||||
preview: boolean;
|
||||
}
|
||||
|
||||
export const QUERY_FORMAT_OPTIONS = [
|
||||
{ label: 'Time series', value: QueryFormat.Timeseries },
|
||||
{ label: 'Table', value: QueryFormat.Table },
|
||||
];
|
||||
|
||||
const backWardToOption = (value: string) => ({ label: value, value });
|
||||
|
||||
export const toOption = toOptionFromData ?? backWardToOption;
|
||||
|
||||
export interface ResourceSelectorProps {
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
applyDefault?: boolean;
|
||||
}
|
||||
// React Awesome Query builder field types.
|
||||
// These are responsible for rendering the correct UI for the field.
|
||||
export type RAQBFieldTypes = 'text' | 'number' | 'boolean' | 'datetime' | 'date' | 'time';
|
||||
|
||||
export interface SQLSelectableValue extends SelectableValue {
|
||||
type?: string;
|
||||
raqbFieldType?: RAQBFieldTypes;
|
||||
}
|
||||
export interface DB {
|
||||
init?: (datasourceId?: string) => Promise<boolean>;
|
||||
datasets: () => Promise<string[]>;
|
||||
tables: (dataset?: string) => Promise<string[]>;
|
||||
fields: (query: SQLQuery, order?: boolean) => Promise<SQLSelectableValue[]>;
|
||||
validateQuery: (query: SQLQuery, range?: TimeRange) => Promise<ValidationResults>;
|
||||
dsID: () => string;
|
||||
dispose?: (dsID?: string) => void;
|
||||
lookup: (path?: string) => Promise<Array<{ name: string; completion: string }>>;
|
||||
getSqlCompletionProvider: () => LanguageCompletionProvider;
|
||||
toRawSql?: (query: SQLQuery) => string;
|
||||
}
|
||||
|
||||
export interface QueryEditorProps {
|
||||
db: DB;
|
||||
query: QueryWithDefaults;
|
||||
onChange: (query: SQLQuery) => void;
|
||||
range?: TimeRange;
|
||||
}
|
||||
|
||||
export interface ValidationResults {
|
||||
query: SQLQuery;
|
||||
rawSql: string;
|
||||
error: string;
|
||||
isError: boolean;
|
||||
isValid: boolean;
|
||||
statistics?: {
|
||||
TotalBytesProcessed: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface SqlQueryModel {
|
||||
interpolate: () => string;
|
||||
quoteLiteral: (v: string) => string;
|
||||
}
|
||||
|
||||
export interface ResponseParser {
|
||||
transformAnnotationResponse: (options: object, data: BackendDataSourceResponse) => Promise<AnnotationEvent[]>;
|
||||
transformMetricFindResponse: (frame: DataFrame) => MetricFindValue[];
|
||||
}
|
||||
|
||||
export interface MetaDefinition {
|
||||
name: string;
|
||||
completion?: string;
|
||||
kind: CompletionItemKind;
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// @ts-ignore
|
||||
import sqlFormatter from 'sql-formatter-plus';
|
||||
|
||||
export function formatSQL(q: string) {
|
||||
return sqlFormatter.format(q).replace(/(\$ \{ .* \})|(\$ __)|(\$ \w+)/g, (m: string) => {
|
||||
return m.replace(/\s/g, '');
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import { isEmpty } from 'lodash';
|
||||
|
||||
import {
|
||||
QueryEditorExpressionType,
|
||||
QueryEditorFunctionExpression,
|
||||
QueryEditorGroupByExpression,
|
||||
QueryEditorPropertyExpression,
|
||||
QueryEditorPropertyType,
|
||||
} from '../expressions';
|
||||
import { SQLQuery, SQLExpression } from '../types';
|
||||
|
||||
export function defaultToRawSql({ sql, dataset, table }: SQLQuery): string {
|
||||
let rawQuery = '';
|
||||
|
||||
// Return early with empty string if there is no sql column
|
||||
if (!sql || !haveColumns(sql.columns)) {
|
||||
return rawQuery;
|
||||
}
|
||||
|
||||
rawQuery += createSelectClause(sql.columns);
|
||||
|
||||
if (dataset && table) {
|
||||
rawQuery += `FROM ${dataset}.${table} `;
|
||||
}
|
||||
|
||||
if (sql.whereString) {
|
||||
rawQuery += `WHERE ${sql.whereString} `;
|
||||
}
|
||||
|
||||
if (sql.groupBy?.[0]?.property.name) {
|
||||
const groupBy = sql.groupBy.map((g) => g.property.name).filter((g) => !isEmpty(g));
|
||||
rawQuery += `GROUP BY ${groupBy.join(', ')} `;
|
||||
}
|
||||
|
||||
if (sql.orderBy?.property.name) {
|
||||
rawQuery += `ORDER BY ${sql.orderBy.property.name} `;
|
||||
}
|
||||
|
||||
if (sql.orderBy?.property.name && sql.orderByDirection) {
|
||||
rawQuery += `${sql.orderByDirection} `;
|
||||
}
|
||||
|
||||
// Altough LIMIT 0 doesn't make sense, it is still possible to have LIMIT 0
|
||||
if (sql.limit !== undefined && sql.limit >= 0) {
|
||||
rawQuery += `LIMIT ${sql.limit} `;
|
||||
}
|
||||
return rawQuery;
|
||||
}
|
||||
|
||||
function createSelectClause(sqlColumns: NonNullable<SQLExpression['columns']>): string {
|
||||
const columns = sqlColumns.map((c) => {
|
||||
let rawColumn = '';
|
||||
if (c.name) {
|
||||
rawColumn += `${c.name}(${c.parameters?.map((p) => `${p.name}`)})`;
|
||||
} else {
|
||||
rawColumn += `${c.parameters?.map((p) => `${p.name}`)}`;
|
||||
}
|
||||
return rawColumn;
|
||||
});
|
||||
return `SELECT ${columns.join(', ')} `;
|
||||
}
|
||||
|
||||
export const haveColumns = (columns: SQLExpression['columns']): columns is NonNullable<SQLExpression['columns']> => {
|
||||
if (!columns) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const haveColumn = columns.some((c) => c.parameters?.length || c.parameters?.some((p) => p.name));
|
||||
const haveFunction = columns.some((c) => c.name);
|
||||
return haveColumn || haveFunction;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a GroupByExpression for a specified field
|
||||
*/
|
||||
export function setGroupByField(field?: string): QueryEditorGroupByExpression {
|
||||
return {
|
||||
type: QueryEditorExpressionType.GroupBy,
|
||||
property: {
|
||||
type: QueryEditorPropertyType.String,
|
||||
name: field,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a PropertyExpression for a specified field
|
||||
*/
|
||||
export function setPropertyField(field?: string): QueryEditorPropertyExpression {
|
||||
return {
|
||||
type: QueryEditorExpressionType.Property,
|
||||
property: {
|
||||
type: QueryEditorPropertyType.String,
|
||||
name: field,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createFunctionField(functionName?: string): QueryEditorFunctionExpression {
|
||||
return {
|
||||
type: QueryEditorExpressionType.Function,
|
||||
name: functionName,
|
||||
parameters: [],
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { DB, SQLExpression, SQLQuery } from '../types';
|
||||
|
||||
import { defaultToRawSql } from './sql.utils';
|
||||
|
||||
interface UseSqlChange {
|
||||
db: DB;
|
||||
query: SQLQuery;
|
||||
onQueryChange: (query: SQLQuery) => void;
|
||||
}
|
||||
|
||||
export function useSqlChange({ query, onQueryChange, db }: UseSqlChange) {
|
||||
const onSqlChange = useCallback(
|
||||
(sql: SQLExpression) => {
|
||||
const toRawSql = db.toRawSql || defaultToRawSql;
|
||||
const rawSql = toRawSql({ sql, dataset: query.dataset, table: query.table, refId: db.dsID() });
|
||||
const newQuery: SQLQuery = { ...query, sql, rawSql };
|
||||
onQueryChange(newQuery);
|
||||
},
|
||||
[db, onQueryChange, query]
|
||||
);
|
||||
|
||||
return { onSqlChange };
|
||||
}
|
||||
104
yarn.lock
104
yarn.lock
|
|
@ -4096,6 +4096,24 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@date-io/core@npm:^1.3.13":
|
||||
version: 1.3.13
|
||||
resolution: "@date-io/core@npm:1.3.13"
|
||||
checksum: 5a9e9d1de20f0346a3c7d2d5946190caef4bfb0b64d82ba1f4c566657a9192667c94ebe7f438d11d4286d9c190974daad4fb2159294225cd8af4d9a140239879
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@date-io/moment@npm:^1.3.13":
|
||||
version: 1.3.13
|
||||
resolution: "@date-io/moment@npm:1.3.13"
|
||||
dependencies:
|
||||
"@date-io/core": ^1.3.13
|
||||
peerDependencies:
|
||||
moment: ^2.24.0
|
||||
checksum: c4847f9d1bf09bb22c1acc5806fc50825c0bdb52408c9fec8cc5f9a65f16a8014870b5890a0eeb73b2c53a6487c35acbd2ab2a5c49068ff5a5dccbcb5c9e654b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@daybrush/utils@npm:1.6.0, @daybrush/utils@npm:^1.0.0, @daybrush/utils@npm:^1.1.1, @daybrush/utils@npm:^1.3.1, @daybrush/utils@npm:^1.4.0":
|
||||
version: 1.6.0
|
||||
resolution: "@daybrush/utils@npm:1.6.0"
|
||||
|
|
@ -15607,6 +15625,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"clone@npm:^2.1.2":
|
||||
version: 2.1.2
|
||||
resolution: "clone@npm:2.1.2"
|
||||
checksum: aaf106e9bc025b21333e2f4c12da539b568db4925c0501a1bf4070836c9e848c892fa22c35548ce0d1132b08bbbfa17a00144fe58fccdab6fa900fec4250f67d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"clsx@npm:^1.1.1":
|
||||
version: 1.1.1
|
||||
resolution: "clsx@npm:1.1.1"
|
||||
|
|
@ -21292,6 +21317,7 @@ __metadata:
|
|||
rc-time-picker: 3.7.3
|
||||
re-resizable: 6.9.9
|
||||
react: 17.0.2
|
||||
react-awesome-query-builder: ^5.1.2
|
||||
react-beautiful-dnd: 13.1.0
|
||||
react-diff-viewer: ^3.1.1
|
||||
react-dom: 17.0.2
|
||||
|
|
@ -26765,7 +26791,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"moment@npm:2.29.3, moment@npm:2.x, moment@npm:>= 2.9.0, moment@npm:^2.19.4, moment@npm:^2.20.1":
|
||||
"moment@npm:2.29.3, moment@npm:2.x, moment@npm:>= 2.9.0, moment@npm:^2.19.4, moment@npm:^2.20.1, moment@npm:^2.29.1":
|
||||
version: 2.29.3
|
||||
resolution: "moment@npm:2.29.3"
|
||||
checksum: 2e780e36d9a1823c08a1b6313cbb08bd01ecbb2a9062095820a34f42c878991ccba53abaa6abb103fd5c01e763724f295162a8c50b7e95b4f1c992ef0772d3f0
|
||||
|
|
@ -30856,6 +30882,45 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-awesome-query-builder@npm:^5.1.2":
|
||||
version: 5.1.2
|
||||
resolution: "react-awesome-query-builder@npm:5.1.2"
|
||||
dependencies:
|
||||
"@date-io/moment": ^1.3.13
|
||||
classnames: ^2.3.1
|
||||
clone: ^2.1.2
|
||||
immutable: ^3.8.2
|
||||
lodash: ^4.17.21
|
||||
moment: ^2.29.1
|
||||
prop-types: ^15.7.2
|
||||
react-redux: ^7.2.2
|
||||
redux: ^4.1.0
|
||||
spel2js: ^0.2.8
|
||||
sqlstring: ^2.3.2
|
||||
peerDependencies:
|
||||
"@ant-design/icons": ^4.0.0
|
||||
"@emotion/react": ^11.7.1
|
||||
"@emotion/styled": ^11.6.0
|
||||
"@fortawesome/fontawesome-svg-core": ^1.2.36
|
||||
"@fortawesome/free-solid-svg-icons": ^5.15.4
|
||||
"@fortawesome/react-fontawesome": ^0.1.16
|
||||
"@material-ui/core": ^4.12.3
|
||||
"@material-ui/icons": ^4.0.0
|
||||
"@material-ui/lab": ^4.0.0-alpha.57
|
||||
"@material-ui/pickers": ^3.2.10
|
||||
"@mui/icons-material": ^5.2.4
|
||||
"@mui/lab": ^5.0.0-alpha.60
|
||||
"@mui/material": ^5.2.4
|
||||
antd: ^4.0.0
|
||||
bootstrap: ^5.1.3
|
||||
material-ui-confirm: ^2.0.1 || ^3.0.0
|
||||
react: ^16.8.4 || ^17.0.1
|
||||
react-dom: ^16.8.4 || ^17.0.1
|
||||
reactstrap: ^9.0.0
|
||||
checksum: 6efab0fcbb98d4843fbe3b5036a7831fd097774869fc0e175ca40c4770d7939dd3efcb2c3365c6fe539c8ba6b6bb19ddef55d93f491770a9bfdc3f1355cc5e4e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-beautiful-dnd@npm:13.1.0":
|
||||
version: 13.1.0
|
||||
resolution: "react-beautiful-dnd@npm:13.1.0"
|
||||
|
|
@ -31440,6 +31505,27 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-redux@npm:^7.2.2":
|
||||
version: 7.2.8
|
||||
resolution: "react-redux@npm:7.2.8"
|
||||
dependencies:
|
||||
"@babel/runtime": ^7.15.4
|
||||
"@types/react-redux": ^7.1.20
|
||||
hoist-non-react-statics: ^3.3.2
|
||||
loose-envify: ^1.4.0
|
||||
prop-types: ^15.7.2
|
||||
react-is: ^17.0.2
|
||||
peerDependencies:
|
||||
react: ^16.8.3 || ^17 || ^18
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
react-native:
|
||||
optional: true
|
||||
checksum: ecf1933e91013f2d41bfc781515b536bf81eb1f70ff228607841094c8330fe77d522372b359687e51c0b52b9888dba73db9ac0486aace1896ab9eb9daec102d5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-refresh@npm:0.14.0":
|
||||
version: 0.14.0
|
||||
resolution: "react-refresh@npm:0.14.0"
|
||||
|
|
@ -32064,7 +32150,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"redux@npm:4.2.0":
|
||||
"redux@npm:4.2.0, redux@npm:^4.1.0":
|
||||
version: 4.2.0
|
||||
resolution: "redux@npm:4.2.0"
|
||||
dependencies:
|
||||
|
|
@ -34065,6 +34151,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"spel2js@npm:^0.2.8":
|
||||
version: 0.2.8
|
||||
resolution: "spel2js@npm:0.2.8"
|
||||
checksum: a81f30b90438c6fef27627d8f91e2ce9040cc3743918846e535e44bdbf76be4d404d281e83b1be8287f36c1566a22e20d711532b941838f9ca7673e5caebf75f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"split-on-first@npm:^1.0.0":
|
||||
version: 1.1.0
|
||||
resolution: "split-on-first@npm:1.1.0"
|
||||
|
|
@ -34115,6 +34208,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"sqlstring@npm:^2.3.2":
|
||||
version: 2.3.3
|
||||
resolution: "sqlstring@npm:2.3.3"
|
||||
checksum: 1e7e2d51c38a0cf7372e875408ca100b6e0c9a941ab7773975ea41fb36e5528e404dc787689be855780cf6d0a829ff71027964ae3a05a7446e91dce26672fda7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"sshpk@npm:^1.14.1, sshpk@npm:^1.7.0":
|
||||
version: 1.16.1
|
||||
resolution: "sshpk@npm:1.16.1"
|
||||
|
|
|
|||
Loading…
Reference in New Issue