mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-24 19:07:47 +00:00
feat: improve suggestions
This commit is contained in:
parent
ff7c398445
commit
a242fd3846
@ -307,6 +307,68 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Context indicator styles
|
||||
.context-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
background-color: #f5f5f5;
|
||||
border-left: 4px solid #1890ff;
|
||||
|
||||
.triplet-info {
|
||||
margin-left: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.query-pair-info {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.1);
|
||||
padding-left: 8px;
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
// Color variations based on context
|
||||
&.context-indicator-key {
|
||||
border-left-color: #1890ff; // blue
|
||||
background-color: rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
&.context-indicator-operator {
|
||||
border-left-color: #722ed1; // purple
|
||||
background-color: rgba(114, 46, 209, 0.1);
|
||||
}
|
||||
|
||||
&.context-indicator-value {
|
||||
border-left-color: #52c41a; // green
|
||||
background-color: rgba(82, 196, 26, 0.1);
|
||||
}
|
||||
|
||||
&.context-indicator-conjunction {
|
||||
border-left-color: #fa8c16; // orange
|
||||
background-color: rgba(250, 140, 22, 0.1);
|
||||
}
|
||||
|
||||
&.context-indicator-function {
|
||||
border-left-color: #13c2c2; // cyan
|
||||
background-color: rgba(19, 194, 194, 0.1);
|
||||
}
|
||||
|
||||
&.context-indicator-parenthesis {
|
||||
border-left-color: #eb2f96; // magenta
|
||||
background-color: rgba(235, 47, 150, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@ -370,5 +432,15 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.context-indicator {
|
||||
background-color: var(--bg-ink-300);
|
||||
color: var(--bg-vanilla-100);
|
||||
|
||||
.query-pair-info {
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,8 +17,8 @@ import {
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { ViewPlugin, ViewUpdate } from '@codemirror/view';
|
||||
import { copilot } from '@uiw/codemirror-theme-copilot';
|
||||
import CodeMirror, { EditorView } from '@uiw/react-codemirror';
|
||||
import { Card, Collapse, Space, Typography } from 'antd';
|
||||
import CodeMirror, { EditorView, Extension } from '@uiw/react-codemirror';
|
||||
import { Card, Collapse, Space, Tag, Typography } from 'antd';
|
||||
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
|
||||
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
@ -28,11 +28,8 @@ import {
|
||||
IValidationResult,
|
||||
} from 'types/antlrQueryTypes';
|
||||
import { QueryKeySuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
import {
|
||||
getQueryContextAtCursor,
|
||||
queryOperatorSuggestions,
|
||||
validateQuery,
|
||||
} from 'utils/antlrQueryUtils';
|
||||
import { queryOperatorSuggestions, validateQuery } from 'utils/antlrQueryUtils';
|
||||
import { getQueryContextAtCursor } from 'utils/queryContextUtils';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { Panel } = Collapse;
|
||||
@ -153,6 +150,18 @@ const contextAwarePlugin = (
|
||||
},
|
||||
);
|
||||
|
||||
const disallowMultipleSpaces: Extension = EditorView.inputHandler.of(
|
||||
(view, from, to, text) => {
|
||||
const currentLine = view.state.doc.lineAt(from);
|
||||
const before = currentLine.text.slice(0, from - currentLine.from);
|
||||
const after = currentLine.text.slice(to - currentLine.from);
|
||||
|
||||
const newText = before + text + after;
|
||||
|
||||
return /\s{2,}/.test(newText);
|
||||
},
|
||||
);
|
||||
|
||||
function CodeMirrorWhereClause(): JSX.Element {
|
||||
const [query, setQuery] = useState<string>('');
|
||||
const [valueSuggestions, setValueSuggestions] = useState<any[]>([
|
||||
@ -178,11 +187,23 @@ function CodeMirrorWhereClause(): JSX.Element {
|
||||
// Reference to the editor view for programmatic autocompletion
|
||||
const editorRef = useRef<EditorView | null>(null);
|
||||
const lastKeyRef = useRef<string>('');
|
||||
const isMountedRef = useRef<boolean>(true);
|
||||
|
||||
const { data: queryKeySuggestions } = useGetQueryKeySuggestions({
|
||||
signal: 'traces',
|
||||
});
|
||||
|
||||
// Add a state for tracking editing mode
|
||||
const [editingMode, setEditingMode] = useState<
|
||||
| 'key'
|
||||
| 'operator'
|
||||
| 'value'
|
||||
| 'conjunction'
|
||||
| 'function'
|
||||
| 'parenthesis'
|
||||
| null
|
||||
>(null);
|
||||
|
||||
// Helper function to wrap string values in quotes if they aren't already quoted
|
||||
const wrapStringValueInQuotes = (value: string): string => {
|
||||
// If value is already quoted (with single quotes), return as is
|
||||
@ -235,48 +256,99 @@ function CodeMirrorWhereClause(): JSX.Element {
|
||||
return value;
|
||||
};
|
||||
|
||||
const analyzeContext = (view: EditorView, pos: number): void => {
|
||||
const word = view.state.wordAt(pos);
|
||||
const token = word ? view.state.sliceDoc(word.from, word.to) : '';
|
||||
const analyzeContext = useCallback((view: EditorView, pos: number): void => {
|
||||
// Skip if component unmounted
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
// Get the query context at the cursor position
|
||||
const queryContext = getQueryContextAtCursor(view.state.doc.toString(), pos);
|
||||
const doc = view.state.doc.toString();
|
||||
|
||||
let contextType = 'Unknown';
|
||||
if (queryContext.isInKey) {
|
||||
contextType = 'Key';
|
||||
} else if (queryContext.isInOperator) {
|
||||
contextType = 'Operator';
|
||||
} else if (queryContext.isInValue) {
|
||||
contextType = 'Value';
|
||||
} else if (queryContext.isInFunction) {
|
||||
contextType = 'Function';
|
||||
} else if (queryContext.isInConjunction) {
|
||||
contextType = 'Conjunction';
|
||||
} else if (queryContext.isInParenthesis) {
|
||||
contextType = 'Parenthesis';
|
||||
}
|
||||
// Check for spaces around the cursor position for debugging
|
||||
const isCursorAtSpace = pos < doc.length && doc[pos] === ' ';
|
||||
const isCursorAfterSpace = pos > 0 && doc[pos - 1] === ' ';
|
||||
const isCursorAfterToken =
|
||||
pos > 0 && doc[pos - 1] !== ' ' && doc[pos - 1] !== undefined;
|
||||
const isCursorBeforeToken =
|
||||
pos < doc.length && doc[pos] !== ' ' && doc[pos] !== undefined;
|
||||
|
||||
console.log(
|
||||
'Cursor is at',
|
||||
pos,
|
||||
'Token under cursor:',
|
||||
token,
|
||||
'Context:',
|
||||
contextType,
|
||||
);
|
||||
};
|
||||
// Check if cursor is at transition point (right after a token at the beginning of a space)
|
||||
const isTransitionPoint = isCursorAtSpace && isCursorAfterToken;
|
||||
|
||||
// Get a slice of the text around cursor for context
|
||||
const sliceStart = Math.max(0, pos - 10);
|
||||
const sliceEnd = Math.min(doc.length, pos + 10);
|
||||
const textSlice = doc.substring(sliceStart, sliceEnd);
|
||||
const cursorPosInSlice = pos - sliceStart;
|
||||
|
||||
// Create a visual cursor indicator
|
||||
const beforeCursor = textSlice.substring(0, cursorPosInSlice);
|
||||
const afterCursor = textSlice.substring(cursorPosInSlice);
|
||||
const visualCursor = `${beforeCursor}|${afterCursor}`;
|
||||
|
||||
const context = getQueryContextAtCursor(doc, pos);
|
||||
|
||||
// Enhanced debug logging with space and pair detection
|
||||
console.log('Context at cursor:', {
|
||||
position: pos,
|
||||
visualCursor,
|
||||
cursorAtSpace: isCursorAtSpace,
|
||||
cursorAfterSpace: isCursorAfterSpace,
|
||||
cursorAfterToken: isCursorAfterToken,
|
||||
cursorBeforeToken: isCursorBeforeToken,
|
||||
isTransitionPoint,
|
||||
contextType: context.isInKey
|
||||
? 'Key'
|
||||
: context.isInOperator
|
||||
? 'Operator'
|
||||
: context.isInValue
|
||||
? 'Value'
|
||||
: context.isInConjunction
|
||||
? 'Conjunction'
|
||||
: context.isInFunction
|
||||
? 'Function'
|
||||
: context.isInParenthesis
|
||||
? 'Parenthesis'
|
||||
: 'Unknown',
|
||||
keyToken: context.keyToken,
|
||||
operatorToken: context.operatorToken,
|
||||
valueToken: context.valueToken,
|
||||
queryPairs: context.queryPairs?.length || 0,
|
||||
currentPair: context.currentPair
|
||||
? {
|
||||
key: context.currentPair.key,
|
||||
operator: context.currentPair.operator,
|
||||
value: context.currentPair.value,
|
||||
isComplete: context.currentPair.isComplete,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Add cleanup effect to prevent component updates after unmount
|
||||
useEffect(
|
||||
(): (() => void) => (): void => {
|
||||
// Mark component as unmounted to prevent state updates
|
||||
isMountedRef.current = false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Use callback to prevent dependency changes on each render
|
||||
const fetchValueSuggestions = useCallback(
|
||||
async (key: string): Promise<void> => {
|
||||
if (!key || (key === activeKey && !isLoadingSuggestions)) return;
|
||||
if (
|
||||
!key ||
|
||||
(key === activeKey && !isLoadingSuggestions) ||
|
||||
!isMountedRef.current
|
||||
)
|
||||
return;
|
||||
|
||||
// Set loading state and store the key we're fetching for
|
||||
setIsLoadingSuggestions(true);
|
||||
lastKeyRef.current = key;
|
||||
setActiveKey(key);
|
||||
|
||||
console.log('fetching suggestions for key:', key);
|
||||
|
||||
// Replace current suggestions with loading indicator
|
||||
setValueSuggestions([
|
||||
{
|
||||
@ -293,9 +365,9 @@ function CodeMirrorWhereClause(): JSX.Element {
|
||||
signal: 'traces',
|
||||
});
|
||||
|
||||
// Verify we're still on the same key (user hasn't moved on)
|
||||
if (lastKeyRef.current !== key) {
|
||||
return; // Skip updating if key has changed
|
||||
// Skip updates if component unmounted or key changed
|
||||
if (!isMountedRef.current || lastKeyRef.current !== key) {
|
||||
return; // Skip updating if key has changed or component unmounted
|
||||
}
|
||||
|
||||
// Process the response data
|
||||
@ -337,7 +409,7 @@ function CodeMirrorWhereClause(): JSX.Element {
|
||||
);
|
||||
|
||||
// Only if we're still on the same key
|
||||
if (lastKeyRef.current === key) {
|
||||
if (lastKeyRef.current === key && isMountedRef.current) {
|
||||
if (allOptions.length > 0) {
|
||||
setValueSuggestions(allOptions);
|
||||
} else {
|
||||
@ -354,14 +426,16 @@ function CodeMirrorWhereClause(): JSX.Element {
|
||||
// Force reopen the completion if editor is available
|
||||
if (editorRef.current) {
|
||||
setTimeout(() => {
|
||||
startCompletion(editorRef.current!);
|
||||
if (isMountedRef.current && editorRef.current) {
|
||||
startCompletion(editorRef.current);
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
setIsLoadingSuggestions(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching suggestions:', error);
|
||||
if (lastKeyRef.current === key) {
|
||||
if (lastKeyRef.current === key && isMountedRef.current) {
|
||||
setValueSuggestions([
|
||||
{
|
||||
label: 'Error loading suggestions',
|
||||
@ -377,29 +451,101 @@ function CodeMirrorWhereClause(): JSX.Element {
|
||||
[activeKey, isLoadingSuggestions],
|
||||
);
|
||||
|
||||
const handleUpdate = (viewUpdate: { view: EditorView }): void => {
|
||||
// Store editor reference
|
||||
if (!editorRef.current) {
|
||||
editorRef.current = viewUpdate.view;
|
||||
}
|
||||
// Enhanced update handler to track context changes
|
||||
const handleUpdate = useCallback(
|
||||
(viewUpdate: { view: EditorView }): void => {
|
||||
// Skip updates if component is unmounted
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
const selection = viewUpdate.view.state.selection.main;
|
||||
const pos = selection.head;
|
||||
// Store editor reference
|
||||
if (!editorRef.current) {
|
||||
editorRef.current = viewUpdate.view;
|
||||
}
|
||||
|
||||
const lineInfo = viewUpdate.view.state.doc.lineAt(pos);
|
||||
const newPos = {
|
||||
line: lineInfo.number,
|
||||
ch: pos - lineInfo.from,
|
||||
};
|
||||
const selection = viewUpdate.view.state.selection.main;
|
||||
const pos = selection.head;
|
||||
const doc = viewUpdate.view.state.doc.toString();
|
||||
|
||||
const lastPos = lastPosRef.current;
|
||||
const lineInfo = viewUpdate.view.state.doc.lineAt(pos);
|
||||
const newPos = {
|
||||
line: lineInfo.number,
|
||||
ch: pos - lineInfo.from,
|
||||
};
|
||||
|
||||
// Only update if cursor position actually changed
|
||||
if (newPos.line !== lastPos.line || newPos.ch !== lastPos.ch) {
|
||||
setCursorPos(newPos);
|
||||
lastPosRef.current = newPos;
|
||||
}
|
||||
};
|
||||
const lastPos = lastPosRef.current;
|
||||
|
||||
// Only update if cursor position actually changed
|
||||
if (newPos.line !== lastPos.line || newPos.ch !== lastPos.ch) {
|
||||
setCursorPos(newPos);
|
||||
lastPosRef.current = newPos;
|
||||
|
||||
// Detect if cursor is at a space or after a token
|
||||
const isAtSpace = pos < doc.length && doc[pos] === ' ';
|
||||
const isAfterToken =
|
||||
pos > 0 && doc[pos - 1] !== ' ' && doc[pos - 1] !== undefined;
|
||||
const isTransitionPoint = isAtSpace && isAfterToken;
|
||||
|
||||
// Get context immediately when cursor position changes
|
||||
if (doc) {
|
||||
const context = getQueryContextAtCursor(doc, pos);
|
||||
|
||||
// Only update context and mode if they've actually changed
|
||||
// This prevents unnecessary re-renders
|
||||
const previousContextType = queryContext?.isInKey
|
||||
? 'key'
|
||||
: queryContext?.isInOperator
|
||||
? 'operator'
|
||||
: queryContext?.isInValue
|
||||
? 'value'
|
||||
: queryContext?.isInConjunction
|
||||
? 'conjunction'
|
||||
: queryContext?.isInFunction
|
||||
? 'function'
|
||||
: queryContext?.isInParenthesis
|
||||
? 'parenthesis'
|
||||
: null;
|
||||
|
||||
const newContextType = context.isInKey
|
||||
? 'key'
|
||||
: context.isInOperator
|
||||
? 'operator'
|
||||
: context.isInValue
|
||||
? 'value'
|
||||
: context.isInConjunction
|
||||
? 'conjunction'
|
||||
: context.isInFunction
|
||||
? 'function'
|
||||
: context.isInParenthesis
|
||||
? 'parenthesis'
|
||||
: null;
|
||||
|
||||
// Log context changes for debugging
|
||||
if (previousContextType !== newContextType) {
|
||||
console.log(
|
||||
`Context changed: ${previousContextType || 'none'} -> ${
|
||||
newContextType || 'none'
|
||||
}`,
|
||||
{
|
||||
position: pos,
|
||||
isAtSpace,
|
||||
isAfterToken,
|
||||
isTransitionPoint,
|
||||
keyToken: context.keyToken,
|
||||
operatorToken: context.operatorToken,
|
||||
valueToken: context.valueToken,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
setQueryContext(context);
|
||||
|
||||
// Update editing mode based on context
|
||||
setEditingMode(newContextType);
|
||||
}
|
||||
}
|
||||
},
|
||||
[queryContext],
|
||||
);
|
||||
|
||||
const handleQueryChange = useCallback(async (newQuery: string) => {
|
||||
setQuery(newQuery);
|
||||
@ -416,13 +562,6 @@ function CodeMirrorWhereClause(): JSX.Element {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (query) {
|
||||
const context = getQueryContextAtCursor(query, cursorPos.ch);
|
||||
setQueryContext(context as IQueryContext);
|
||||
}
|
||||
}, [query, cursorPos]);
|
||||
|
||||
const handleChange = (value: string): void => {
|
||||
setQuery(value);
|
||||
handleQueryChange(value);
|
||||
@ -435,6 +574,29 @@ function CodeMirrorWhereClause(): JSX.Element {
|
||||
handleQueryChange(newQuery);
|
||||
};
|
||||
|
||||
// Helper function to render a badge for the current context mode
|
||||
const renderContextBadge = (): JSX.Element => {
|
||||
if (!editingMode) return <Tag>Unknown</Tag>;
|
||||
|
||||
switch (editingMode) {
|
||||
case 'key':
|
||||
return <Tag color="blue">Key</Tag>;
|
||||
case 'operator':
|
||||
return <Tag color="purple">Operator</Tag>;
|
||||
case 'value':
|
||||
return <Tag color="green">Value</Tag>;
|
||||
case 'conjunction':
|
||||
return <Tag color="orange">Conjunction</Tag>;
|
||||
case 'function':
|
||||
return <Tag color="cyan">Function</Tag>;
|
||||
case 'parenthesis':
|
||||
return <Tag color="magenta">Parenthesis</Tag>;
|
||||
default:
|
||||
return <Tag>Unknown</Tag>;
|
||||
}
|
||||
};
|
||||
|
||||
// Enhanced myCompletions function to better use context including query pairs
|
||||
function myCompletions(context: CompletionContext): CompletionResult | null {
|
||||
const word = context.matchBefore(/[.\w]*/);
|
||||
if (word?.from === word?.to && !context.explicit) return null;
|
||||
@ -442,6 +604,8 @@ function CodeMirrorWhereClause(): JSX.Element {
|
||||
// Get the query context at the cursor position
|
||||
const queryContext = getQueryContextAtCursor(query, cursorPos.ch);
|
||||
|
||||
console.log('queryContext', queryContext);
|
||||
|
||||
// Define autocomplete options based on the context
|
||||
let options: {
|
||||
label: string;
|
||||
@ -449,6 +613,7 @@ function CodeMirrorWhereClause(): JSX.Element {
|
||||
info?: string;
|
||||
apply?: string;
|
||||
detail?: string;
|
||||
boost?: number;
|
||||
}[] = [];
|
||||
|
||||
if (queryContext.isInKey) {
|
||||
@ -458,6 +623,28 @@ function CodeMirrorWhereClause(): JSX.Element {
|
||||
option.label.toLowerCase().includes(searchText),
|
||||
);
|
||||
|
||||
// If we have previous pairs, we can prioritize keys that haven't been used yet
|
||||
if (queryContext.queryPairs && queryContext.queryPairs.length > 0) {
|
||||
const usedKeys = queryContext.queryPairs.map((pair) => pair.key);
|
||||
|
||||
// Add boost to unused keys to prioritize them
|
||||
options = options.map((option) => ({
|
||||
...option,
|
||||
boost: usedKeys.includes(option.label) ? -10 : 10,
|
||||
info: usedKeys.includes(option.label)
|
||||
? `${option.info || ''} (already used in query)`
|
||||
: option.info,
|
||||
}));
|
||||
}
|
||||
|
||||
// Add boost to exact matches
|
||||
options = options.map((option) => ({
|
||||
...option,
|
||||
boost:
|
||||
(option.boost || 0) +
|
||||
(option.label.toLowerCase() === searchText ? 100 : 0),
|
||||
}));
|
||||
|
||||
return {
|
||||
from: word?.from ?? 0,
|
||||
to: word?.to ?? cursorPos.ch,
|
||||
@ -466,8 +653,51 @@ function CodeMirrorWhereClause(): JSX.Element {
|
||||
}
|
||||
|
||||
if (queryContext.isInOperator) {
|
||||
options = [];
|
||||
options = queryOperatorSuggestions;
|
||||
|
||||
// Get key information from context or current pair
|
||||
const keyName = queryContext.keyToken || queryContext.currentPair?.key;
|
||||
|
||||
// If we have a key context, add that info to the operator suggestions
|
||||
if (keyName) {
|
||||
// Find the key details from suggestions
|
||||
const keyDetails = (keySuggestions || []).find((k) => k.label === keyName);
|
||||
const keyType = keyDetails?.type || '';
|
||||
|
||||
// Filter operators based on key type
|
||||
if (keyType) {
|
||||
if (keyType === 'number') {
|
||||
// Prioritize numeric operators
|
||||
options = options.map((op) => ({
|
||||
...op,
|
||||
boost: ['>', '<', '>=', '<=', '=', '!=', 'BETWEEN'].includes(op.label)
|
||||
? 100
|
||||
: 0,
|
||||
}));
|
||||
} else if (keyType === 'string' || keyType === 'keyword') {
|
||||
// Prioritize string operators
|
||||
options = options.map((op) => ({
|
||||
...op,
|
||||
boost: ['=', '!=', 'LIKE', 'ILIKE', 'CONTAINS', 'IN'].includes(op.label)
|
||||
? 100
|
||||
: 0,
|
||||
}));
|
||||
} else if (keyType === 'boolean') {
|
||||
// Prioritize boolean operators
|
||||
options = options.map((op) => ({
|
||||
...op,
|
||||
boost: ['=', '!='].includes(op.label) ? 100 : 0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Add key info to all operators
|
||||
options = options.map((op) => ({
|
||||
...op,
|
||||
info: `${op.info || ''} (for ${keyName})`,
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
from: word?.from ?? 0,
|
||||
to: word?.to ?? cursorPos.ch,
|
||||
@ -476,15 +706,20 @@ function CodeMirrorWhereClause(): JSX.Element {
|
||||
}
|
||||
|
||||
if (queryContext.isInValue) {
|
||||
// Fetch values based on the key - use the keyToken if available
|
||||
const { keyToken, currentToken, operatorToken } = queryContext;
|
||||
const key = keyToken || currentToken;
|
||||
// Fetch values based on the key - use available context
|
||||
const keyName = queryContext.keyToken || queryContext.currentPair?.key || '';
|
||||
const operatorName =
|
||||
queryContext.operatorToken || queryContext.currentPair?.operator || '';
|
||||
|
||||
// Trigger fetch only if key is different from activeKey or if we're still loading
|
||||
if (key && (key !== activeKey || isLoadingSuggestions)) {
|
||||
if (!keyName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Trigger fetch only if needed
|
||||
if (keyName && (keyName !== activeKey || isLoadingSuggestions)) {
|
||||
// Don't trigger a new fetch if we're already loading for this key
|
||||
if (!(isLoadingSuggestions && lastKeyRef.current === key)) {
|
||||
fetchValueSuggestions(key);
|
||||
if (!(isLoadingSuggestions && lastKeyRef.current === keyName)) {
|
||||
fetchValueSuggestions(keyName);
|
||||
}
|
||||
}
|
||||
|
||||
@ -503,22 +738,42 @@ function CodeMirrorWhereClause(): JSX.Element {
|
||||
// String values get quoted
|
||||
processedOption.apply = formatValueForOperator(
|
||||
option.label,
|
||||
operatorToken,
|
||||
operatorName,
|
||||
option.type,
|
||||
);
|
||||
|
||||
// Add context info to the suggestion
|
||||
if (keyName && operatorName) {
|
||||
processedOption.info = `Value for ${keyName} ${operatorName}`;
|
||||
}
|
||||
} else if (option.type === 'number') {
|
||||
// Numbers don't get quoted but may need brackets for IN operators
|
||||
if (isListOperator(operatorToken)) {
|
||||
if (isListOperator(operatorName)) {
|
||||
processedOption.apply = `[${option.label}]`;
|
||||
} else {
|
||||
processedOption.apply = option.label;
|
||||
}
|
||||
|
||||
// Add context info to the suggestion
|
||||
if (keyName && operatorName) {
|
||||
processedOption.info = `Numeric value for ${keyName} ${operatorName}`;
|
||||
}
|
||||
} else if (option.type === 'boolean') {
|
||||
// Boolean values don't get quoted
|
||||
processedOption.apply = option.label;
|
||||
|
||||
// Add context info
|
||||
if (keyName && operatorName) {
|
||||
processedOption.info = `Boolean value for ${keyName} ${operatorName}`;
|
||||
}
|
||||
} else if (option.type === 'array') {
|
||||
// Arrays are already formatted as arrays
|
||||
processedOption.apply = option.label;
|
||||
|
||||
// Add context info
|
||||
if (keyName && operatorName) {
|
||||
processedOption.info = `Array value for ${keyName} ${operatorName}`;
|
||||
}
|
||||
}
|
||||
|
||||
return processedOption;
|
||||
@ -594,9 +849,15 @@ function CodeMirrorWhereClause(): JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
// If no specific context is detected, provide general suggestions
|
||||
return {
|
||||
from: word?.from ?? 0,
|
||||
options: [],
|
||||
options: [
|
||||
...(keySuggestions || []),
|
||||
{ label: 'AND', type: 'conjunction', boost: -10 },
|
||||
{ label: 'OR', type: 'conjunction', boost: -10 },
|
||||
{ label: '(', type: 'parenthesis', info: 'Open group', boost: -20 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@ -620,24 +881,60 @@ function CodeMirrorWhereClause(): JSX.Element {
|
||||
|
||||
// Update state when query context changes to trigger suggestion refresh
|
||||
useEffect(() => {
|
||||
if (queryContext?.isInValue) {
|
||||
const { keyToken, currentToken } = queryContext;
|
||||
const key = keyToken || currentToken;
|
||||
// Skip if we don't have a value context or it hasn't changed
|
||||
if (!queryContext?.isInValue) return;
|
||||
|
||||
if (key && (key !== activeKey || isLoadingSuggestions)) {
|
||||
// Don't trigger a new fetch if we're already loading for this key
|
||||
if (!(isLoadingSuggestions && lastKeyRef.current === key)) {
|
||||
fetchValueSuggestions(key);
|
||||
}
|
||||
}
|
||||
const { keyToken, currentToken } = queryContext;
|
||||
const key = keyToken || currentToken;
|
||||
|
||||
// We're no longer automatically adding quotes here - they will be added
|
||||
// only when a specific value is selected from the dropdown
|
||||
// Only fetch if needed and if we have a valid key
|
||||
if (key && key !== activeKey && !isLoadingSuggestions) {
|
||||
fetchValueSuggestions(key);
|
||||
}
|
||||
}, [queryContext, activeKey, fetchValueSuggestions, isLoadingSuggestions]);
|
||||
// Use only the specific properties of queryContext we need to avoid unnecessary renders
|
||||
}, [queryContext, activeKey, isLoadingSuggestions, fetchValueSuggestions]);
|
||||
|
||||
return (
|
||||
<div className="code-mirror-where-clause">
|
||||
{/* Add a context indicator banner */}
|
||||
{editingMode && (
|
||||
<div className={`context-indicator context-indicator-${editingMode}`}>
|
||||
Currently editing: {renderContextBadge()}
|
||||
{queryContext?.keyToken && (
|
||||
<span className="triplet-info">
|
||||
Key: <Tag>{queryContext.keyToken}</Tag>
|
||||
</span>
|
||||
)}
|
||||
{queryContext?.operatorToken && (
|
||||
<span className="triplet-info">
|
||||
Operator: <Tag>{queryContext.operatorToken}</Tag>
|
||||
</span>
|
||||
)}
|
||||
{queryContext?.valueToken && (
|
||||
<span className="triplet-info">
|
||||
Value: <Tag>{queryContext.valueToken}</Tag>
|
||||
</span>
|
||||
)}
|
||||
{queryContext?.currentPair && (
|
||||
<span className="triplet-info query-pair-info">
|
||||
Current pair: <Tag color="blue">{queryContext.currentPair.key}</Tag>
|
||||
<Tag color="purple">{queryContext.currentPair.operator}</Tag>
|
||||
{queryContext.currentPair.value && (
|
||||
<Tag color="green">{queryContext.currentPair.value}</Tag>
|
||||
)}
|
||||
<Tag color={queryContext.currentPair.isComplete ? 'success' : 'warning'}>
|
||||
{queryContext.currentPair.isComplete ? 'Complete' : 'Incomplete'}
|
||||
</Tag>
|
||||
</span>
|
||||
)}
|
||||
{queryContext?.queryPairs && queryContext.queryPairs.length > 0 && (
|
||||
<span className="triplet-info">
|
||||
Total pairs: <Tag color="blue">{queryContext.queryPairs.length}</Tag>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CodeMirror
|
||||
value={query}
|
||||
theme={copilot}
|
||||
@ -657,6 +954,7 @@ function CodeMirrorWhereClause(): JSX.Element {
|
||||
EditorView.lineWrapping,
|
||||
stopEventsExtension,
|
||||
contextAwarePlugin(analyzeContext),
|
||||
disallowMultipleSpaces,
|
||||
// customTheme,
|
||||
]}
|
||||
basicSetup={{
|
||||
|
||||
@ -12,6 +12,21 @@ export interface IToken {
|
||||
channel?: number;
|
||||
}
|
||||
|
||||
export interface IQueryPair {
|
||||
key: string;
|
||||
operator: string;
|
||||
value?: string;
|
||||
position: {
|
||||
keyStart: number;
|
||||
keyEnd: number;
|
||||
operatorStart: number;
|
||||
operatorEnd: number;
|
||||
valueStart?: number;
|
||||
valueEnd?: number;
|
||||
};
|
||||
isComplete: boolean; // true if the pair has all three components
|
||||
}
|
||||
|
||||
export interface IQueryContext {
|
||||
tokenType: number;
|
||||
text: string;
|
||||
@ -27,6 +42,8 @@ export interface IQueryContext {
|
||||
keyToken?: string;
|
||||
operatorToken?: string;
|
||||
valueToken?: string;
|
||||
queryPairs?: IQueryPair[];
|
||||
currentPair?: IQueryPair | null;
|
||||
}
|
||||
|
||||
export interface IDetailedError {
|
||||
|
||||
@ -185,7 +185,7 @@ export const validateQuery = (query: string): IValidationResult => {
|
||||
};
|
||||
|
||||
// Helper function to find key-operator-value triplets in token stream
|
||||
function findKeyOperatorValueTriplet(
|
||||
export function findKeyOperatorValueTriplet(
|
||||
allTokens: IToken[],
|
||||
currentToken: IToken,
|
||||
isInKey: boolean,
|
||||
|
||||
924
frontend/src/utils/queryContextUtils.ts
Normal file
924
frontend/src/utils/queryContextUtils.ts
Normal file
@ -0,0 +1,924 @@
|
||||
/* eslint-disable */
|
||||
|
||||
import { CharStreams, CommonTokenStream, Token } from 'antlr4';
|
||||
import FilterQueryLexer from 'parser/FilterQueryLexer';
|
||||
import { IQueryContext, IQueryPair, IToken } from 'types/antlrQueryTypes';
|
||||
|
||||
// Function to normalize multiple spaces to single spaces when not in quotes
|
||||
function normalizeSpaces(query: string): string {
|
||||
let result = '';
|
||||
let inQuotes = false;
|
||||
let lastChar = '';
|
||||
|
||||
for (let i = 0; i < query.length; i++) {
|
||||
const char = query[i];
|
||||
|
||||
// Track quote state
|
||||
if (char === "'" && (i === 0 || query[i - 1] !== '\\')) {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
|
||||
// If we're in quotes, always keep the original character
|
||||
if (inQuotes) {
|
||||
result += char;
|
||||
}
|
||||
// Otherwise, collapse multiple spaces to a single space
|
||||
else if (char === ' ' && lastChar === ' ') {
|
||||
// Skip this space (don't add it)
|
||||
} else {
|
||||
result += char;
|
||||
}
|
||||
|
||||
lastChar = char;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Function to create a context object
|
||||
export function createContext(
|
||||
token: Token,
|
||||
isInKey: boolean,
|
||||
isInOperator: boolean,
|
||||
isInValue: boolean,
|
||||
keyToken?: string,
|
||||
operatorToken?: string,
|
||||
valueToken?: string,
|
||||
queryPairs?: IQueryPair[],
|
||||
currentPair?: IQueryPair | null,
|
||||
): IQueryContext {
|
||||
return {
|
||||
tokenType: token.type,
|
||||
text: token.text || '',
|
||||
start: token.start,
|
||||
stop: token.stop,
|
||||
currentToken: token.text || '',
|
||||
isInKey,
|
||||
isInOperator,
|
||||
isInValue,
|
||||
isInFunction: false,
|
||||
isInConjunction: false,
|
||||
isInParenthesis: false,
|
||||
keyToken,
|
||||
operatorToken,
|
||||
valueToken,
|
||||
queryPairs: queryPairs || [],
|
||||
currentPair,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to determine token type for context
|
||||
function determineTokenContext(
|
||||
tokenType: number,
|
||||
): {
|
||||
isInKey: boolean;
|
||||
isInOperator: boolean;
|
||||
isInValue: boolean;
|
||||
isInFunction: boolean;
|
||||
isInConjunction: boolean;
|
||||
isInParenthesis: boolean;
|
||||
} {
|
||||
// Key context
|
||||
const isInKey = tokenType === FilterQueryLexer.KEY;
|
||||
|
||||
// Operator context
|
||||
const isInOperator = [
|
||||
FilterQueryLexer.EQUALS,
|
||||
FilterQueryLexer.NOT_EQUALS,
|
||||
FilterQueryLexer.NEQ,
|
||||
FilterQueryLexer.LT,
|
||||
FilterQueryLexer.LE,
|
||||
FilterQueryLexer.GT,
|
||||
FilterQueryLexer.GE,
|
||||
FilterQueryLexer.LIKE,
|
||||
FilterQueryLexer.NOT_LIKE,
|
||||
FilterQueryLexer.ILIKE,
|
||||
FilterQueryLexer.NOT_ILIKE,
|
||||
FilterQueryLexer.BETWEEN,
|
||||
FilterQueryLexer.EXISTS,
|
||||
FilterQueryLexer.REGEXP,
|
||||
FilterQueryLexer.CONTAINS,
|
||||
FilterQueryLexer.IN,
|
||||
FilterQueryLexer.NOT,
|
||||
].includes(tokenType);
|
||||
|
||||
// Value context
|
||||
const isInValue = [
|
||||
FilterQueryLexer.QUOTED_TEXT,
|
||||
FilterQueryLexer.NUMBER,
|
||||
FilterQueryLexer.BOOL,
|
||||
].includes(tokenType);
|
||||
|
||||
// Function context
|
||||
const isInFunction = [
|
||||
FilterQueryLexer.HAS,
|
||||
FilterQueryLexer.HASANY,
|
||||
FilterQueryLexer.HASALL,
|
||||
FilterQueryLexer.HASNONE,
|
||||
].includes(tokenType);
|
||||
|
||||
// Conjunction context
|
||||
const isInConjunction = [FilterQueryLexer.AND, FilterQueryLexer.OR].includes(
|
||||
tokenType,
|
||||
);
|
||||
|
||||
// Parenthesis context
|
||||
const isInParenthesis = [
|
||||
FilterQueryLexer.LPAREN,
|
||||
FilterQueryLexer.RPAREN,
|
||||
FilterQueryLexer.LBRACK,
|
||||
FilterQueryLexer.RBRACK,
|
||||
].includes(tokenType);
|
||||
|
||||
return {
|
||||
isInKey,
|
||||
isInOperator,
|
||||
isInValue,
|
||||
isInFunction,
|
||||
isInConjunction,
|
||||
isInParenthesis,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current query context at the cursor position
|
||||
* This is useful for determining what kind of suggestions to show
|
||||
*
|
||||
* The function now includes full query pair information:
|
||||
* - queryPairs: All key-operator-value triplets in the query
|
||||
* - currentPair: The pair at or before the current cursor position
|
||||
*
|
||||
* This enables more intelligent context-aware suggestions based on
|
||||
* the current key, operator, and surrounding query structure.
|
||||
*
|
||||
* @param query The query string
|
||||
* @param cursorIndex The position of the cursor in the query
|
||||
* @returns The query context at the cursor position
|
||||
*/
|
||||
export function getQueryContextAtCursor(
|
||||
query: string,
|
||||
cursorIndex: number,
|
||||
): IQueryContext {
|
||||
try {
|
||||
// Guard against infinite recursion by checking call stack
|
||||
const stackTrace = new Error().stack || '';
|
||||
const callCount = (stackTrace.match(/getQueryContextAtCursor/g) || []).length;
|
||||
if (callCount > 3) {
|
||||
console.warn(
|
||||
'Potential infinite recursion detected in getQueryContextAtCursor',
|
||||
);
|
||||
return {
|
||||
tokenType: -1,
|
||||
text: '',
|
||||
start: cursorIndex,
|
||||
stop: cursorIndex,
|
||||
currentToken: '',
|
||||
isInKey: true,
|
||||
isInOperator: false,
|
||||
isInValue: false,
|
||||
isInFunction: false,
|
||||
isInConjunction: false,
|
||||
isInParenthesis: false,
|
||||
queryPairs: [],
|
||||
currentPair: null,
|
||||
};
|
||||
}
|
||||
|
||||
// First check if the cursor is at a token boundary or within a whitespace area
|
||||
// This is critical for context detection
|
||||
const isAtSpace = cursorIndex < query.length && query[cursorIndex] === ' ';
|
||||
const isAfterSpace = cursorIndex > 0 && query[cursorIndex - 1] === ' ';
|
||||
const isAfterToken = cursorIndex > 0 && query[cursorIndex - 1] !== ' ';
|
||||
|
||||
// Check if cursor is right after a token and at the start of a space
|
||||
const isTransitionPoint = isAtSpace && isAfterToken;
|
||||
|
||||
// First normalize the query to handle multiple spaces
|
||||
// We need to adjust cursorIndex based on space normalization
|
||||
let adjustedCursorIndex = cursorIndex;
|
||||
let spaceCount = 0;
|
||||
let inQuotes = false;
|
||||
|
||||
// Count consecutive spaces before the cursor to adjust the cursor position
|
||||
for (let i = 0; i < cursorIndex; i++) {
|
||||
// Track quote state
|
||||
if (query[i] === "'" && (i === 0 || query[i - 1] !== '\\')) {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
|
||||
// Only count spaces when not in quotes
|
||||
if (!inQuotes && query[i] === ' ' && (i === 0 || query[i - 1] === ' ')) {
|
||||
spaceCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust cursor position based on removed spaces
|
||||
adjustedCursorIndex = cursorIndex - spaceCount;
|
||||
|
||||
// Normalize the query by removing extra spaces when not in quotes
|
||||
const normalizedQuery = normalizeSpaces(query);
|
||||
|
||||
// Create input stream and lexer with normalized query
|
||||
const input = normalizedQuery || '';
|
||||
const chars = CharStreams.fromString(input);
|
||||
const lexer = new FilterQueryLexer(chars);
|
||||
|
||||
// Create token stream and force token generation
|
||||
const tokenStream = new CommonTokenStream(lexer);
|
||||
tokenStream.fill();
|
||||
|
||||
// Get all tokens including whitespace
|
||||
const allTokens = tokenStream.tokens as IToken[];
|
||||
|
||||
// Find exact token at cursor, including whitespace
|
||||
let exactToken: IToken | null = null;
|
||||
let previousToken: IToken | null = null;
|
||||
let nextToken: IToken | null = null;
|
||||
|
||||
// Find the real token at or just before the cursor
|
||||
let lastTokenBeforeCursor: IToken | null = null;
|
||||
for (let i = 0; i < allTokens.length; i++) {
|
||||
const token = allTokens[i];
|
||||
if (token.type === FilterQueryLexer.EOF) continue;
|
||||
|
||||
// Store this token if it's before or at the cursor position
|
||||
if (token.stop < cursorIndex) {
|
||||
lastTokenBeforeCursor = token;
|
||||
}
|
||||
|
||||
// If we found a token that starts after the cursor, we're done searching
|
||||
if (token.start > cursorIndex) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Get query pairs information to enhance context
|
||||
const queryPairs = extractQueryPairs(query);
|
||||
|
||||
// Find the current pair without causing a circular dependency
|
||||
let currentPair: IQueryPair | null = null;
|
||||
if (queryPairs.length > 0) {
|
||||
// Look for the rightmost pair whose end position is before or at the cursor
|
||||
let bestMatch: IQueryPair | null = null;
|
||||
|
||||
for (const pair of queryPairs) {
|
||||
const { position } = pair;
|
||||
|
||||
// Find the rightmost position of this pair
|
||||
const pairEnd =
|
||||
position.valueEnd || position.operatorEnd || position.keyEnd;
|
||||
|
||||
// If this pair ends at or before the cursor, and it's further right than our previous best match
|
||||
if (
|
||||
pairEnd <= cursorIndex &&
|
||||
(!bestMatch ||
|
||||
pairEnd >
|
||||
(bestMatch.position.valueEnd ||
|
||||
bestMatch.position.operatorEnd ||
|
||||
bestMatch.position.keyEnd))
|
||||
) {
|
||||
bestMatch = pair;
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a match, use it
|
||||
if (bestMatch) {
|
||||
currentPair = bestMatch;
|
||||
}
|
||||
// If cursor is at the end, use the last pair
|
||||
else if (cursorIndex >= query.length) {
|
||||
currentPair = queryPairs[queryPairs.length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
// Handle cursor at the very end of input
|
||||
if (adjustedCursorIndex >= input.length && allTokens.length > 0) {
|
||||
const lastRealToken = allTokens
|
||||
.filter((t) => t.type !== FilterQueryLexer.EOF)
|
||||
.pop();
|
||||
if (lastRealToken) {
|
||||
exactToken = lastRealToken;
|
||||
previousToken =
|
||||
allTokens.filter((t) => t.stop < lastRealToken.start).pop() || null;
|
||||
}
|
||||
} else {
|
||||
// Normal token search
|
||||
for (let i = 0; i < allTokens.length; i++) {
|
||||
const token = allTokens[i];
|
||||
// Skip EOF token in normal search
|
||||
if (token.type === FilterQueryLexer.EOF) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if cursor is within token bounds (inclusive)
|
||||
if (
|
||||
token.start <= adjustedCursorIndex &&
|
||||
adjustedCursorIndex <= token.stop + 1
|
||||
) {
|
||||
exactToken = token;
|
||||
previousToken = i > 0 ? allTokens[i - 1] : null;
|
||||
nextToken = i < allTokens.length - 1 ? allTokens[i + 1] : null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If cursor is between tokens, find surrounding tokens
|
||||
if (!exactToken) {
|
||||
for (let i = 0; i < allTokens.length - 1; i++) {
|
||||
const current = allTokens[i];
|
||||
const next = allTokens[i + 1];
|
||||
if (
|
||||
current.type === FilterQueryLexer.EOF ||
|
||||
next.type === FilterQueryLexer.EOF
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
current.stop + 1 < adjustedCursorIndex &&
|
||||
adjustedCursorIndex < next.start
|
||||
) {
|
||||
previousToken = current;
|
||||
nextToken = next;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we don't have tokens yet, return default context
|
||||
if (!previousToken && !nextToken && !exactToken && !lastTokenBeforeCursor) {
|
||||
return {
|
||||
tokenType: -1,
|
||||
text: '',
|
||||
start: adjustedCursorIndex,
|
||||
stop: adjustedCursorIndex,
|
||||
currentToken: '',
|
||||
isInKey: true, // Default to key context when input is empty
|
||||
isInOperator: false,
|
||||
isInValue: false,
|
||||
isInFunction: false,
|
||||
isInConjunction: false,
|
||||
isInParenthesis: false,
|
||||
queryPairs: queryPairs, // Add all query pairs to the context
|
||||
currentPair: null, // No current pair when query is empty
|
||||
};
|
||||
}
|
||||
|
||||
// If we have a token and we're at a space after it (transition point),
|
||||
// then we should progress the context
|
||||
if (
|
||||
lastTokenBeforeCursor &&
|
||||
(isAtSpace || isAfterSpace || isTransitionPoint)
|
||||
) {
|
||||
const lastTokenContext = determineTokenContext(lastTokenBeforeCursor.type);
|
||||
|
||||
// Apply the context progression logic: key → operator → value → conjunction → key
|
||||
if (lastTokenContext.isInKey) {
|
||||
// If we just typed a key and then a space, we move to operator context
|
||||
return {
|
||||
tokenType: lastTokenBeforeCursor.type,
|
||||
text: lastTokenBeforeCursor.text,
|
||||
start: adjustedCursorIndex,
|
||||
stop: adjustedCursorIndex,
|
||||
currentToken: lastTokenBeforeCursor.text,
|
||||
isInKey: false,
|
||||
isInOperator: true, // After key + space, should be operator context
|
||||
isInValue: false,
|
||||
isInFunction: false,
|
||||
isInConjunction: false,
|
||||
isInParenthesis: false,
|
||||
keyToken: lastTokenBeforeCursor.text,
|
||||
queryPairs: queryPairs,
|
||||
currentPair: currentPair,
|
||||
};
|
||||
}
|
||||
|
||||
if (lastTokenContext.isInOperator) {
|
||||
// If we just typed an operator and then a space, we move to value context
|
||||
const keyFromPair = currentPair?.key || '';
|
||||
return {
|
||||
tokenType: lastTokenBeforeCursor.type,
|
||||
text: lastTokenBeforeCursor.text,
|
||||
start: adjustedCursorIndex,
|
||||
stop: adjustedCursorIndex,
|
||||
currentToken: lastTokenBeforeCursor.text,
|
||||
isInKey: false,
|
||||
isInOperator: false,
|
||||
isInValue: true, // After operator + space, should be value context
|
||||
isInFunction: false,
|
||||
isInConjunction: false,
|
||||
isInParenthesis: false,
|
||||
operatorToken: lastTokenBeforeCursor.text,
|
||||
keyToken: keyFromPair, // Include key from current pair
|
||||
queryPairs: queryPairs,
|
||||
currentPair: currentPair,
|
||||
};
|
||||
}
|
||||
|
||||
if (lastTokenContext.isInValue) {
|
||||
// If we just typed a value and then a space, we move to conjunction context
|
||||
const keyFromPair = currentPair?.key || '';
|
||||
const operatorFromPair = currentPair?.operator || '';
|
||||
return {
|
||||
tokenType: lastTokenBeforeCursor.type,
|
||||
text: lastTokenBeforeCursor.text,
|
||||
start: adjustedCursorIndex,
|
||||
stop: adjustedCursorIndex,
|
||||
currentToken: lastTokenBeforeCursor.text,
|
||||
isInKey: false,
|
||||
isInOperator: false,
|
||||
isInValue: false,
|
||||
isInFunction: false,
|
||||
isInConjunction: true, // After value + space, should be conjunction context
|
||||
isInParenthesis: false,
|
||||
valueToken: lastTokenBeforeCursor.text,
|
||||
keyToken: keyFromPair, // Include key from current pair
|
||||
operatorToken: operatorFromPair, // Include operator from current pair
|
||||
queryPairs: queryPairs,
|
||||
currentPair: currentPair,
|
||||
};
|
||||
}
|
||||
|
||||
if (lastTokenContext.isInConjunction) {
|
||||
// If we just typed a conjunction and then a space, we move to key context
|
||||
return {
|
||||
tokenType: lastTokenBeforeCursor.type,
|
||||
text: lastTokenBeforeCursor.text,
|
||||
start: adjustedCursorIndex,
|
||||
stop: adjustedCursorIndex,
|
||||
currentToken: lastTokenBeforeCursor.text,
|
||||
isInKey: true, // After conjunction + space, should be key context
|
||||
isInOperator: false,
|
||||
isInValue: false,
|
||||
isInFunction: false,
|
||||
isInConjunction: false,
|
||||
isInParenthesis: false,
|
||||
queryPairs: queryPairs,
|
||||
currentPair: currentPair,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Regular token-based context detection (when cursor is directly on a token)
|
||||
if (exactToken?.channel === 0) {
|
||||
const tokenContext = determineTokenContext(exactToken.type);
|
||||
|
||||
// Get relevant tokens based on current pair
|
||||
const keyFromPair = currentPair?.key || '';
|
||||
const operatorFromPair = currentPair?.operator || '';
|
||||
const valueFromPair = currentPair?.value || '';
|
||||
|
||||
return {
|
||||
tokenType: exactToken.type,
|
||||
text: exactToken.text,
|
||||
start: exactToken.start,
|
||||
stop: exactToken.stop,
|
||||
currentToken: exactToken.text,
|
||||
...tokenContext,
|
||||
keyToken: tokenContext.isInKey
|
||||
? exactToken.text
|
||||
: tokenContext.isInOperator || tokenContext.isInValue
|
||||
? keyFromPair
|
||||
: undefined,
|
||||
operatorToken: tokenContext.isInOperator
|
||||
? exactToken.text
|
||||
: tokenContext.isInValue
|
||||
? operatorFromPair
|
||||
: undefined,
|
||||
valueToken: tokenContext.isInValue ? exactToken.text : undefined,
|
||||
queryPairs: queryPairs,
|
||||
currentPair: currentPair,
|
||||
};
|
||||
}
|
||||
|
||||
// If we're between tokens but not after a space, use previous token to determine context
|
||||
if (previousToken?.channel === 0) {
|
||||
const prevContext = determineTokenContext(previousToken.type);
|
||||
|
||||
// Get relevant tokens based on current pair
|
||||
const keyFromPair = currentPair?.key || '';
|
||||
const operatorFromPair = currentPair?.operator || '';
|
||||
const valueFromPair = currentPair?.value || '';
|
||||
|
||||
// CRITICAL FIX: Check if the last meaningful token is an operator
|
||||
// If so, we're always in the value context regardless of spaces
|
||||
if (prevContext.isInOperator) {
|
||||
// If previous token is operator, we must be in value context
|
||||
return {
|
||||
tokenType: previousToken.type,
|
||||
text: previousToken.text,
|
||||
start: adjustedCursorIndex,
|
||||
stop: adjustedCursorIndex,
|
||||
currentToken: previousToken.text,
|
||||
isInKey: false,
|
||||
isInOperator: false,
|
||||
isInValue: true, // Always in value context after operator
|
||||
isInFunction: false,
|
||||
isInConjunction: false,
|
||||
isInParenthesis: false,
|
||||
operatorToken: previousToken.text,
|
||||
keyToken: keyFromPair, // Include key from current pair
|
||||
queryPairs: queryPairs,
|
||||
currentPair: currentPair,
|
||||
};
|
||||
}
|
||||
|
||||
// Maintain the strict progression key → operator → value → conjunction → key
|
||||
if (prevContext.isInKey) {
|
||||
return {
|
||||
tokenType: previousToken.type,
|
||||
text: previousToken.text,
|
||||
start: adjustedCursorIndex,
|
||||
stop: adjustedCursorIndex,
|
||||
currentToken: previousToken.text,
|
||||
isInKey: false,
|
||||
isInOperator: true, // After key, progress to operator context
|
||||
isInValue: false,
|
||||
isInFunction: false,
|
||||
isInConjunction: false,
|
||||
isInParenthesis: false,
|
||||
keyToken: previousToken.text,
|
||||
queryPairs: queryPairs,
|
||||
currentPair: currentPair,
|
||||
};
|
||||
}
|
||||
|
||||
if (prevContext.isInValue) {
|
||||
return {
|
||||
tokenType: previousToken.type,
|
||||
text: previousToken.text,
|
||||
start: adjustedCursorIndex,
|
||||
stop: adjustedCursorIndex,
|
||||
currentToken: previousToken.text,
|
||||
isInKey: false,
|
||||
isInOperator: false,
|
||||
isInValue: false,
|
||||
isInFunction: false,
|
||||
isInConjunction: true, // After value, progress to conjunction context
|
||||
isInParenthesis: false,
|
||||
valueToken: previousToken.text,
|
||||
keyToken: keyFromPair, // Include key from current pair
|
||||
operatorToken: operatorFromPair, // Include operator from current pair
|
||||
queryPairs: queryPairs,
|
||||
currentPair: currentPair,
|
||||
};
|
||||
}
|
||||
|
||||
if (prevContext.isInConjunction) {
|
||||
return {
|
||||
tokenType: previousToken.type,
|
||||
text: previousToken.text,
|
||||
start: adjustedCursorIndex,
|
||||
stop: adjustedCursorIndex,
|
||||
currentToken: previousToken.text,
|
||||
isInKey: true, // After conjunction, progress back to key context
|
||||
isInOperator: false,
|
||||
isInValue: false,
|
||||
isInFunction: false,
|
||||
isInConjunction: false,
|
||||
isInParenthesis: false,
|
||||
queryPairs: queryPairs,
|
||||
currentPair: currentPair,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Default fallback to key context
|
||||
return {
|
||||
tokenType: -1,
|
||||
text: '',
|
||||
start: adjustedCursorIndex,
|
||||
stop: adjustedCursorIndex,
|
||||
currentToken: '',
|
||||
isInKey: true,
|
||||
isInOperator: false,
|
||||
isInValue: false,
|
||||
isInFunction: false,
|
||||
isInConjunction: false,
|
||||
isInParenthesis: false,
|
||||
queryPairs: queryPairs,
|
||||
currentPair: currentPair,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in getQueryContextAtCursor:', error);
|
||||
return {
|
||||
tokenType: -1,
|
||||
text: '',
|
||||
start: cursorIndex,
|
||||
stop: cursorIndex,
|
||||
currentToken: '',
|
||||
isInValue: false,
|
||||
isInKey: true, // Default to key context on error
|
||||
isInOperator: false,
|
||||
isInFunction: false,
|
||||
isInConjunction: false,
|
||||
isInParenthesis: false,
|
||||
queryPairs: [],
|
||||
currentPair: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all key-operator-value triplets from a query string
|
||||
* This is useful for getting value suggestions based on the current key and operator
|
||||
*
|
||||
* @param query The query string to parse
|
||||
* @returns An array of IQueryPair objects representing the key-operator-value triplets
|
||||
*/
|
||||
export function extractQueryPairs(query: string): IQueryPair[] {
|
||||
try {
|
||||
// Guard against infinite recursion by checking call stack
|
||||
const stackTrace = new Error().stack || '';
|
||||
const callCount = (stackTrace.match(/extractQueryPairs/g) || []).length;
|
||||
if (callCount > 3) {
|
||||
console.warn('Potential infinite recursion detected in extractQueryPairs');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Normalize the query to handle multiple spaces
|
||||
const normalizedQuery = normalizeSpaces(query);
|
||||
|
||||
// Create input stream and lexer with normalized query
|
||||
const input = normalizedQuery || '';
|
||||
const chars = CharStreams.fromString(input);
|
||||
const lexer = new FilterQueryLexer(chars);
|
||||
|
||||
// Create token stream and force token generation
|
||||
const tokenStream = new CommonTokenStream(lexer);
|
||||
tokenStream.fill();
|
||||
|
||||
// Get all tokens including whitespace
|
||||
const allTokens = tokenStream.tokens as IToken[];
|
||||
|
||||
const queryPairs: IQueryPair[] = [];
|
||||
let currentPair: Partial<IQueryPair> | null = null;
|
||||
|
||||
// Process tokens to build triplets
|
||||
for (let i = 0; i < allTokens.length; i++) {
|
||||
const token = allTokens[i];
|
||||
|
||||
// Skip EOF and whitespace tokens
|
||||
if (token.type === FilterQueryLexer.EOF || token.channel !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If token is a KEY, start a new pair
|
||||
if (token.type === FilterQueryLexer.KEY) {
|
||||
// If we have an existing incomplete pair, add it to the result
|
||||
if (currentPair && currentPair.key) {
|
||||
queryPairs.push({
|
||||
key: currentPair.key,
|
||||
operator: currentPair.operator || '',
|
||||
value: currentPair.value,
|
||||
position: {
|
||||
keyStart: currentPair.position?.keyStart || 0,
|
||||
keyEnd: currentPair.position?.keyEnd || 0,
|
||||
operatorStart: currentPair.position?.operatorStart || 0,
|
||||
operatorEnd: currentPair.position?.operatorEnd || 0,
|
||||
valueStart: currentPair.position?.valueStart,
|
||||
valueEnd: currentPair.position?.valueEnd,
|
||||
},
|
||||
isComplete: !!(
|
||||
currentPair.key &&
|
||||
currentPair.operator &&
|
||||
currentPair.value
|
||||
),
|
||||
} as IQueryPair);
|
||||
}
|
||||
|
||||
// Start a new pair
|
||||
currentPair = {
|
||||
key: token.text,
|
||||
position: {
|
||||
keyStart: token.start,
|
||||
keyEnd: token.stop,
|
||||
operatorStart: 0, // Initialize with default values
|
||||
operatorEnd: 0, // Initialize with default values
|
||||
},
|
||||
};
|
||||
}
|
||||
// If token is an operator and we have a key, add the operator
|
||||
else if (
|
||||
isOperatorToken(token.type) &&
|
||||
currentPair &&
|
||||
currentPair.key &&
|
||||
!currentPair.operator
|
||||
) {
|
||||
currentPair.operator = token.text;
|
||||
// Ensure we create a valid position object with all required fields
|
||||
currentPair.position = {
|
||||
keyStart: currentPair.position?.keyStart || 0,
|
||||
keyEnd: currentPair.position?.keyEnd || 0,
|
||||
operatorStart: token.start,
|
||||
operatorEnd: token.stop,
|
||||
valueStart: currentPair.position?.valueStart,
|
||||
valueEnd: currentPair.position?.valueEnd,
|
||||
};
|
||||
}
|
||||
// If token is a value and we have a key and operator, add the value
|
||||
else if (
|
||||
isValueToken(token.type) &&
|
||||
currentPair &&
|
||||
currentPair.key &&
|
||||
currentPair.operator &&
|
||||
!currentPair.value
|
||||
) {
|
||||
currentPair.value = token.text;
|
||||
// Ensure we create a valid position object with all required fields
|
||||
currentPair.position = {
|
||||
keyStart: currentPair.position?.keyStart || 0,
|
||||
keyEnd: currentPair.position?.keyEnd || 0,
|
||||
operatorStart: currentPair.position?.operatorStart || 0,
|
||||
operatorEnd: currentPair.position?.operatorEnd || 0,
|
||||
valueStart: token.start,
|
||||
valueEnd: token.stop,
|
||||
};
|
||||
}
|
||||
// If token is a conjunction (AND/OR), finalize the current pair
|
||||
else if (isConjunctionToken(token.type) && currentPair && currentPair.key) {
|
||||
queryPairs.push({
|
||||
key: currentPair.key,
|
||||
operator: currentPair.operator || '',
|
||||
value: currentPair.value,
|
||||
position: {
|
||||
keyStart: currentPair.position?.keyStart || 0,
|
||||
keyEnd: currentPair.position?.keyEnd || 0,
|
||||
operatorStart: currentPair.position?.operatorStart || 0,
|
||||
operatorEnd: currentPair.position?.operatorEnd || 0,
|
||||
valueStart: currentPair.position?.valueStart,
|
||||
valueEnd: currentPair.position?.valueEnd,
|
||||
},
|
||||
isComplete: !!(
|
||||
currentPair.key &&
|
||||
currentPair.operator &&
|
||||
currentPair.value
|
||||
),
|
||||
} as IQueryPair);
|
||||
|
||||
// Reset for the next pair
|
||||
currentPair = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last pair if not already added
|
||||
if (currentPair && currentPair.key) {
|
||||
queryPairs.push({
|
||||
key: currentPair.key,
|
||||
operator: currentPair.operator || '',
|
||||
value: currentPair.value,
|
||||
position: {
|
||||
keyStart: currentPair.position?.keyStart || 0,
|
||||
keyEnd: currentPair.position?.keyEnd || 0,
|
||||
operatorStart: currentPair.position?.operatorStart || 0,
|
||||
operatorEnd: currentPair.position?.operatorEnd || 0,
|
||||
valueStart: currentPair.position?.valueStart,
|
||||
valueEnd: currentPair.position?.valueEnd,
|
||||
},
|
||||
isComplete: !!(
|
||||
currentPair.key &&
|
||||
currentPair.operator &&
|
||||
currentPair.value
|
||||
),
|
||||
} as IQueryPair);
|
||||
}
|
||||
|
||||
return queryPairs;
|
||||
} catch (error) {
|
||||
console.error('Error in extractQueryPairs:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current query pair at the cursor position
|
||||
* This is useful for getting suggestions based on the current context
|
||||
* The function finds the rightmost complete pair that ends before or at the cursor position
|
||||
*
|
||||
* @param query The query string
|
||||
* @param cursorIndex The position of the cursor in the query
|
||||
* @returns The query pair at the cursor position, or null if not found
|
||||
*/
|
||||
export function getCurrentQueryPair(
|
||||
query: string,
|
||||
cursorIndex: number,
|
||||
): IQueryPair | null {
|
||||
try {
|
||||
const queryPairs = extractQueryPairs(query);
|
||||
// Removed the circular dependency by not calling getQueryContextAtCursor here
|
||||
|
||||
// If we have pairs, try to find the one at the cursor position
|
||||
if (queryPairs.length > 0) {
|
||||
// Look for the rightmost pair whose end position is before or at the cursor
|
||||
let bestMatch: IQueryPair | null = null;
|
||||
|
||||
for (const pair of queryPairs) {
|
||||
const { position } = pair;
|
||||
|
||||
// Find the rightmost position of this pair
|
||||
const pairEnd =
|
||||
position.valueEnd || position.operatorEnd || position.keyEnd;
|
||||
|
||||
// If this pair ends at or before the cursor, and it's further right than our previous best match
|
||||
if (
|
||||
pairEnd <= cursorIndex &&
|
||||
(!bestMatch ||
|
||||
pairEnd >
|
||||
(bestMatch.position.valueEnd ||
|
||||
bestMatch.position.operatorEnd ||
|
||||
bestMatch.position.keyEnd))
|
||||
) {
|
||||
bestMatch = pair;
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a match, return it
|
||||
if (bestMatch) {
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
// If cursor is at the very beginning, before any pairs, return null
|
||||
if (cursorIndex === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If no match found and cursor is at the end, return the last pair
|
||||
if (cursorIndex >= query.length && queryPairs.length > 0) {
|
||||
return queryPairs[queryPairs.length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
// If no valid pair is found, and we cannot infer one from context, return null
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error in getCurrentQueryPair:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if a token is an operator
|
||||
function isOperatorToken(tokenType: number): boolean {
|
||||
return [
|
||||
FilterQueryLexer.EQUALS,
|
||||
FilterQueryLexer.NOT_EQUALS,
|
||||
FilterQueryLexer.NEQ,
|
||||
FilterQueryLexer.LT,
|
||||
FilterQueryLexer.LE,
|
||||
FilterQueryLexer.GT,
|
||||
FilterQueryLexer.GE,
|
||||
FilterQueryLexer.LIKE,
|
||||
FilterQueryLexer.NOT_LIKE,
|
||||
FilterQueryLexer.ILIKE,
|
||||
FilterQueryLexer.NOT_ILIKE,
|
||||
FilterQueryLexer.BETWEEN,
|
||||
FilterQueryLexer.EXISTS,
|
||||
FilterQueryLexer.REGEXP,
|
||||
FilterQueryLexer.CONTAINS,
|
||||
FilterQueryLexer.IN,
|
||||
FilterQueryLexer.NOT,
|
||||
].includes(tokenType);
|
||||
}
|
||||
|
||||
// Helper function to check if a token is a value
|
||||
function isValueToken(tokenType: number): boolean {
|
||||
return [
|
||||
FilterQueryLexer.QUOTED_TEXT,
|
||||
FilterQueryLexer.NUMBER,
|
||||
FilterQueryLexer.BOOL,
|
||||
].includes(tokenType);
|
||||
}
|
||||
|
||||
// Helper function to check if a token is a conjunction
|
||||
function isConjunctionToken(tokenType: number): boolean {
|
||||
return [FilterQueryLexer.AND, FilterQueryLexer.OR].includes(tokenType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage example for query context with pairs:
|
||||
*
|
||||
* ```typescript
|
||||
* // Get context at cursor position
|
||||
* const context = getQueryContextAtCursor(query, cursorPosition);
|
||||
*
|
||||
* // Access all query pairs
|
||||
* const allPairs = context.queryPairs || [];
|
||||
* console.log(`Query contains ${allPairs.length} key-operator-value triplets`);
|
||||
*
|
||||
* // Access the current pair at cursor
|
||||
* if (context.currentPair) {
|
||||
* // Use the current triplet to provide relevant suggestions
|
||||
* const { key, operator, value } = context.currentPair;
|
||||
* console.log(`Current context: ${key} ${operator} ${value || ''}`);
|
||||
*
|
||||
* // Check if this is a complete triplet
|
||||
* if (context.currentPair.isComplete) {
|
||||
* // All parts (key, operator, value) are present
|
||||
* } else {
|
||||
* // Incomplete - might be missing operator or value
|
||||
* }
|
||||
* } else {
|
||||
* // No current pair, likely at the start of a new condition
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
Loading…
x
Reference in New Issue
Block a user