414 lines
13 KiB
TypeScript
Raw Normal View History

2025-05-14 01:56:25 +05:30
/* eslint-disable import/no-extraneous-dependencies */
/* eslint-disable no-cond-assign */
/* eslint-disable no-restricted-syntax */
/* eslint-disable class-methods-use-this */
/* eslint-disable react/no-this-in-sfc */
/* eslint-disable sonarjs/cognitive-complexity */
import './QueryAggregation.styles.scss';
import {
autocompletion,
closeCompletion,
Completion,
CompletionContext,
completionKeymap,
CompletionResult,
} from '@codemirror/autocomplete';
import { javascript } from '@codemirror/lang-javascript';
2025-05-14 01:56:25 +05:30
import { RangeSetBuilder } from '@codemirror/state';
import { copilot } from '@uiw/codemirror-theme-copilot';
2025-05-14 01:56:25 +05:30
import CodeMirror, {
Decoration,
EditorView,
keymap,
2025-05-14 01:56:25 +05:30
ViewPlugin,
ViewUpdate,
} from '@uiw/react-codemirror';
import { getAggregateAttribute } from 'api/queryBuilder/getAggregateAttribute';
import { QueryBuilderKeys } from 'constants/queryBuilder';
import { tracesAggregateOperatorOptions } from 'constants/queryBuilderOperators';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
2025-05-14 13:58:56 +05:30
import { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from 'react-query';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TracesAggregatorOperator } from 'types/common/queryBuilder';
import { useQueryBuilderV2Context } from '../../QueryBuilderV2Context';
2025-05-14 16:43:28 +05:30
2025-05-14 01:56:25 +05:30
const chipDecoration = Decoration.mark({
class: 'chip-decorator',
});
const operatorArgMeta: Record<
string,
{ acceptsArgs: boolean; multiple: boolean }
> = {
[TracesAggregatorOperator.NOOP]: { acceptsArgs: false, multiple: false },
[TracesAggregatorOperator.COUNT]: { acceptsArgs: false, multiple: false },
[TracesAggregatorOperator.COUNT_DISTINCT]: {
acceptsArgs: true,
multiple: true,
},
[TracesAggregatorOperator.SUM]: { acceptsArgs: true, multiple: false },
[TracesAggregatorOperator.AVG]: { acceptsArgs: true, multiple: false },
[TracesAggregatorOperator.MAX]: { acceptsArgs: true, multiple: false },
[TracesAggregatorOperator.MIN]: { acceptsArgs: true, multiple: false },
[TracesAggregatorOperator.P05]: { acceptsArgs: true, multiple: false },
[TracesAggregatorOperator.P10]: { acceptsArgs: true, multiple: false },
[TracesAggregatorOperator.P20]: { acceptsArgs: true, multiple: false },
[TracesAggregatorOperator.P25]: { acceptsArgs: true, multiple: false },
[TracesAggregatorOperator.P50]: { acceptsArgs: true, multiple: false },
[TracesAggregatorOperator.P75]: { acceptsArgs: true, multiple: false },
[TracesAggregatorOperator.P90]: { acceptsArgs: true, multiple: false },
[TracesAggregatorOperator.P95]: { acceptsArgs: true, multiple: false },
[TracesAggregatorOperator.P99]: { acceptsArgs: true, multiple: false },
[TracesAggregatorOperator.RATE]: { acceptsArgs: true, multiple: false },
[TracesAggregatorOperator.RATE_SUM]: { acceptsArgs: true, multiple: false },
[TracesAggregatorOperator.RATE_AVG]: { acceptsArgs: true, multiple: false },
[TracesAggregatorOperator.RATE_MIN]: { acceptsArgs: true, multiple: false },
[TracesAggregatorOperator.RATE_MAX]: { acceptsArgs: true, multiple: false },
};
function getFunctionContextAtCursor(
text: string,
cursorPos: number,
): string | null {
// Find the nearest function name to the left of the nearest unmatched '('
let openParenIndex = -1;
let funcName: string | null = null;
let parenStack = 0;
for (let i = cursorPos - 1; i >= 0; i--) {
if (text[i] === ')') parenStack++;
else if (text[i] === '(') {
if (parenStack === 0) {
openParenIndex = i;
const before = text.slice(0, i);
const match = before.match(/(\w+)\s*$/);
if (match) funcName = match[1].toLowerCase();
break;
}
parenStack--;
}
}
if (openParenIndex === -1 || !funcName) return null;
// Scan forwards to find the matching closing parenthesis
let closeParenIndex = -1;
let depth = 1;
for (let j = openParenIndex + 1; j < text.length; j++) {
if (text[j] === '(') depth++;
else if (text[j] === ')') depth--;
if (depth === 0) {
closeParenIndex = j;
break;
}
}
if (
cursorPos > openParenIndex &&
(closeParenIndex === -1 || cursorPos <= closeParenIndex)
) {
return funcName;
}
return null;
}
2025-05-14 01:56:25 +05:30
// eslint-disable-next-line react/no-this-in-sfc
2025-05-14 16:43:28 +05:30
function QueryAggregationSelect(): JSX.Element {
const { currentQuery } = useQueryBuilder();
2025-05-14 16:43:28 +05:30
const { setAggregationOptions } = useQueryBuilderV2Context();
const queryData = currentQuery.builder.queryData[0];
const [input, setInput] = useState('');
const [cursorPos, setCursorPos] = useState(0);
2025-05-14 13:58:56 +05:30
const [functionArgPairs, setFunctionArgPairs] = useState<
{ func: string; arg: string }[]
>([]);
const editorRef = useRef<EditorView | null>(null);
// Update cursor position on every editor update
const handleUpdate = (update: { view: EditorView }): void => {
const pos = update.view.state.selection.main.from;
setCursorPos(pos);
};
2025-05-14 13:58:56 +05:30
// Extract all valid function-argument pairs from the input
useEffect(() => {
const pairs: { func: string; arg: string }[] = [];
const regex = /([a-zA-Z_][\w]*)\s*\(([^)]*)\)/g;
let match;
while ((match = regex.exec(input)) !== null) {
const func = match[1].toLowerCase();
const args = match[2]
.split(',')
.map((arg) => arg.trim())
.filter((arg) => arg.length > 0);
args.forEach((arg) => {
pairs.push({ func, arg });
});
}
setFunctionArgPairs(pairs);
2025-05-14 16:43:28 +05:30
setAggregationOptions(pairs);
2025-05-14 16:16:34 +05:30
// eslint-disable-next-line react-hooks/exhaustive-deps
2025-05-14 13:58:56 +05:30
}, [input]);
// Find function context for fetching suggestions
const functionContextForFetch = getFunctionContextAtCursor(input, cursorPos);
const { data: aggregateAttributeData, isLoading: isLoadingFields } = useQuery(
[
QueryBuilderKeys.GET_AGGREGATE_ATTRIBUTE,
functionContextForFetch,
queryData.dataSource,
],
() =>
getAggregateAttribute({
searchText: '',
aggregateOperator: functionContextForFetch as string,
dataSource: queryData.dataSource,
}),
{
enabled:
!!functionContextForFetch &&
!!operatorArgMeta[functionContextForFetch]?.acceptsArgs,
},
);
2025-05-14 01:56:25 +05:30
// Get valid function names (lowercase)
const validFunctions = useMemo(
() => tracesAggregateOperatorOptions.map((op) => op.value.toLowerCase()),
[],
);
// Memoized chipPlugin that highlights valid function calls like count(), max(arg), min(arg)
const chipPlugin = useMemo(
() =>
ViewPlugin.fromClass(
class {
decorations: import('@codemirror/view').DecorationSet;
constructor(view: EditorView) {
this.decorations = this.buildDecorations(view);
}
update(update: ViewUpdate): void {
if (update.docChanged || update.viewportChanged) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(
view: EditorView,
): import('@codemirror/view').DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
for (const { from, to } of view.visibleRanges) {
const text = view.state.doc.sliceString(from, to);
const regex = /\b([a-zA-Z_][\w]*)\s*\(([^)]*)\)/g;
let match;
while ((match = regex.exec(text)) !== null) {
const func = match[1].toLowerCase();
if (validFunctions.includes(func)) {
const start = from + match.index;
const end = start + match[0].length;
builder.add(start, end, chipDecoration);
}
}
}
return builder.finish();
}
},
{
decorations: (v: any): import('@codemirror/view').DecorationSet =>
v.decorations,
},
),
[validFunctions],
) as any;
const operatorCompletions: Completion[] = tracesAggregateOperatorOptions.map(
(op) => ({
label: op.value,
type: 'function',
info: op.label,
2025-05-14 01:56:25 +05:30
apply: (
view: EditorView,
completion: Completion,
from: number,
to: number,
): void => {
2025-05-14 13:11:06 +05:30
const isCount = op.value === TracesAggregatorOperator.COUNT;
const insertText = isCount ? `${op.value}() ` : `${op.value}(`;
const cursorPos = isCount
? from + op.value.length + 3 // after 'count() '
: from + op.value.length + 1; // after 'operator('
view.dispatch({
2025-05-14 01:56:25 +05:30
changes: { from, to, insert: insertText },
selection: { anchor: cursorPos },
});
},
}),
);
2025-05-14 13:11:06 +05:30
// Memoize field suggestions from API (no filtering here)
const fieldSuggestions = useMemo(
() =>
aggregateAttributeData?.payload?.attributeKeys?.map(
(attributeKey: BaseAutocompleteData) => ({
label: attributeKey.key,
type: 'variable',
info: attributeKey.dataType,
2025-05-14 01:56:25 +05:30
apply: (
view: EditorView,
completion: Completion,
from: number,
to: number,
): void => {
view.dispatch({
2025-05-14 13:11:06 +05:30
changes: { from, to, insert: completion.label },
selection: { anchor: from + completion.label.length },
});
},
}),
) || [],
[aggregateAttributeData],
);
const aggregatorAutocomplete = useMemo(
() =>
autocompletion({
override: [
(context: CompletionContext): CompletionResult | null => {
const text = context.state.sliceDoc(0, context.state.doc.length);
const cursorPos = context.pos;
const funcName = getFunctionContextAtCursor(text, cursorPos);
2025-05-14 01:56:25 +05:30
// Do not show suggestions if inside count()
if (
funcName === TracesAggregatorOperator.COUNT &&
cursorPos > 0 &&
text[cursorPos - 1] !== ')'
) {
return null;
}
// If inside a function that accepts args, show field suggestions
if (funcName && operatorArgMeta[funcName]?.acceptsArgs) {
if (isLoadingFields) {
return {
from: cursorPos,
options: [
{
label: 'Loading suggestions...',
type: 'text',
apply: (): void => {},
},
],
};
}
2025-05-14 13:11:06 +05:30
const doc = context.state.sliceDoc(0, cursorPos);
const lastOpenParen = doc.lastIndexOf('(');
const lastComma = doc.lastIndexOf(',', cursorPos - 1);
const startOfArg =
lastComma > lastOpenParen ? lastComma + 1 : lastOpenParen + 1;
const inputText = doc.slice(startOfArg, cursorPos).trim();
// Parse arguments already present in the function call (before the cursor)
const usedArgs = new Set<string>();
if (lastOpenParen !== -1) {
const argsString = doc.slice(lastOpenParen + 1, cursorPos);
argsString.split(',').forEach((arg) => {
const trimmed = arg.trim();
if (trimmed) usedArgs.add(trimmed);
});
}
2025-05-14 13:58:56 +05:30
// Exclude arguments already paired with this function elsewhere in the input
const globalUsedArgs = new Set(
functionArgPairs
.filter((pair) => pair.func === funcName)
.map((pair) => pair.arg),
);
2025-05-14 13:11:06 +05:30
const availableSuggestions = fieldSuggestions.filter(
2025-05-14 13:58:56 +05:30
(suggestion) =>
!usedArgs.has(suggestion.label) &&
!globalUsedArgs.has(suggestion.label),
2025-05-14 13:11:06 +05:30
);
const filteredSuggestions =
inputText === ''
? availableSuggestions
: availableSuggestions.filter((suggestion) =>
suggestion.label.toLowerCase().includes(inputText.toLowerCase()),
);
return {
2025-05-14 13:11:06 +05:30
from: startOfArg,
options: filteredSuggestions,
};
}
2025-05-14 13:58:56 +05:30
// Before returning operatorCompletions, filter out 'count' if already present in the input (case-insensitive, direct text check)
if (!funcName || !operatorArgMeta[funcName]?.acceptsArgs) {
// Check if 'count(' is present in the current input (case-insensitive)
const hasCount = text.toLowerCase().includes('count(');
const availableOperators = hasCount
? operatorCompletions.filter((op) => op.label.toLowerCase() !== 'count')
: operatorCompletions;
const word = context.matchBefore(/[\w\d_]+/);
if (!word && !context.explicit) {
return null;
}
return {
from: word ? word.from : context.pos,
options: availableOperators,
};
}
2025-05-14 13:58:56 +05:30
return null;
},
],
defaultKeymap: true,
closeOnBlur: false,
maxRenderedOptions: 50,
activateOnTyping: true,
}),
2025-05-14 13:58:56 +05:30
[operatorCompletions, isLoadingFields, fieldSuggestions, functionArgPairs],
);
return (
<div className="query-aggregation-select-container">
<CodeMirror
value={input}
onChange={setInput}
className="query-aggregation-select-editor"
width="100%"
theme={copilot}
extensions={[
2025-05-14 01:56:25 +05:30
chipPlugin,
aggregatorAutocomplete,
javascript({ jsx: false, typescript: false }),
keymap.of([
...completionKeymap,
{
key: 'Escape',
run: closeCompletion,
},
]),
]}
placeholder="Type aggregator functions like sum(), count_distinct(...), etc."
basicSetup={{
lineNumbers: false,
autocompletion: true,
completionKeymap: true,
}}
onUpdate={handleUpdate}
ref={editorRef}
/>
</div>
);
}
export default QueryAggregationSelect;