mirror of https://github.com/grafana/grafana
sql plugins - angular to react - base sql datasource (#51655)
* base sql datasource and componentspull/51609/head
parent
a14ca8fb62
commit
fa560d96b6
@ -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 }; |
||||
} |
Loading…
Reference in new issue