mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Co-authored-by: Nitesh Singh <nitesh.singh@gitstart.dev> Co-authored-by: gitstart <gitstart@users.noreply.github.com> Co-authored-by: Rafael Toledo <87545086+Toledodev@users.noreply.github.com> Co-authored-by: Murilo Amaral <87545137+MuriloAmarals@users.noreply.github.com> Co-authored-by: gitstart <gitstart@gitstart.com> Co-authored-by: Matheus Benini Ferreira <88898100+MatheusBeniniF@users.noreply.github.com> Co-authored-by: Matheus Benini <matheus_benini@hotmail.com> Co-authored-by: Rubens Rafael <70234898+RubensRafael@users.noreply.github.com> Co-authored-by: Júlio Piubello da Silva Cabral <julio.piubello@gitstart.dev> Co-authored-by: gitstart <gitstart@users.noreply.github.com> Co-authored-by: Nitesh Singh <nitesh.singh@gitstart.dev> Co-authored-by: Rafael Toledo <87545086+Toledodev@users.noreply.github.com> Co-authored-by: Murilo Amaral <87545137+MuriloAmarals@users.noreply.github.com> Co-authored-by: gitstart <gitstart@gitstart.com> Co-authored-by: Matheus Benini Ferreira <88898100+MatheusBeniniF@users.noreply.github.com> Co-authored-by: Matheus Benini <matheus_benini@hotmail.com> Co-authored-by: Rubens Rafael <70234898+RubensRafael@users.noreply.github.com> Co-authored-by: Júlio Piubello da Silva Cabral <julio.piubello@gitstart.dev>
411 lines
12 KiB
TypeScript
411 lines
12 KiB
TypeScript
import { SyntaxNode } from '@lezer/common';
|
|
import {
|
|
AggregateExpr,
|
|
AggregateModifier,
|
|
AggregateOp,
|
|
BinaryExpr,
|
|
BinModifiers,
|
|
Expr,
|
|
FunctionCall,
|
|
FunctionCallArgs,
|
|
FunctionCallBody,
|
|
FunctionIdentifier,
|
|
GroupingLabel,
|
|
GroupingLabelList,
|
|
GroupingLabels,
|
|
LabelMatcher,
|
|
LabelName,
|
|
MatchOp,
|
|
MetricIdentifier,
|
|
NumberLiteral,
|
|
On,
|
|
OnOrIgnoring,
|
|
ParenExpr,
|
|
parser,
|
|
StringLiteral,
|
|
VectorSelector,
|
|
Without,
|
|
} from '@prometheus-io/lezer-promql';
|
|
|
|
import { binaryScalarOperatorToOperatorName } from './binaryScalarOperations';
|
|
import {
|
|
ErrorId,
|
|
getAllByType,
|
|
getLeftMostChild,
|
|
getString,
|
|
makeBinOp,
|
|
makeError,
|
|
replaceVariables,
|
|
} from './shared/parsingUtils';
|
|
import { QueryBuilderLabelFilter, QueryBuilderOperation } from './shared/types';
|
|
import { PromVisualQuery, PromVisualQueryBinary } from './types';
|
|
|
|
/**
|
|
* Parses a PromQL query into a visual query model.
|
|
*
|
|
* It traverses the tree and uses sort of state machine to update the query model. The query model is modified
|
|
* during the traversal and sent to each handler as context.
|
|
*
|
|
* @param expr
|
|
*/
|
|
export function buildVisualQueryFromString(expr: string): Context {
|
|
const replacedExpr = replaceVariables(expr);
|
|
const tree = parser.parse(replacedExpr);
|
|
const node = tree.topNode;
|
|
|
|
// This will be modified in the handlers.
|
|
const visQuery: PromVisualQuery = {
|
|
metric: '',
|
|
labels: [],
|
|
operations: [],
|
|
};
|
|
const context: Context = {
|
|
query: visQuery,
|
|
errors: [],
|
|
};
|
|
|
|
try {
|
|
handleExpression(replacedExpr, node, context);
|
|
} catch (err) {
|
|
// Not ideal to log it here, but otherwise we would lose the stack trace.
|
|
console.error(err);
|
|
if (err instanceof Error) {
|
|
context.errors.push({
|
|
text: err.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
// If we have empty query, we want to reset errors
|
|
if (isEmptyQuery(context.query)) {
|
|
context.errors = [];
|
|
}
|
|
return context;
|
|
}
|
|
|
|
interface ParsingError {
|
|
text: string;
|
|
from?: number;
|
|
to?: number;
|
|
parentType?: string;
|
|
}
|
|
|
|
interface Context {
|
|
query: PromVisualQuery;
|
|
errors: ParsingError[];
|
|
}
|
|
|
|
/**
|
|
* Handler for default state. It will traverse the tree and call the appropriate handler for each node. The node
|
|
* handled here does not necessarily need to be of type == Expr.
|
|
* @param expr
|
|
* @param node
|
|
* @param context
|
|
*/
|
|
export function handleExpression(expr: string, node: SyntaxNode, context: Context) {
|
|
const visQuery = context.query;
|
|
|
|
switch (node.type.id) {
|
|
case MetricIdentifier: {
|
|
// Expectation is that there is only one of those per query.
|
|
visQuery.metric = getString(expr, node);
|
|
break;
|
|
}
|
|
|
|
case LabelMatcher: {
|
|
// Same as MetricIdentifier should be just one per query.
|
|
visQuery.labels.push(getLabel(expr, node));
|
|
const err = node.getChild(ErrorId);
|
|
if (err) {
|
|
context.errors.push(makeError(expr, err));
|
|
}
|
|
break;
|
|
}
|
|
|
|
case FunctionCall: {
|
|
handleFunction(expr, node, context);
|
|
break;
|
|
}
|
|
|
|
case AggregateExpr: {
|
|
handleAggregation(expr, node, context);
|
|
break;
|
|
}
|
|
|
|
case BinaryExpr: {
|
|
handleBinary(expr, node, context);
|
|
break;
|
|
}
|
|
|
|
case ErrorId: {
|
|
if (isIntervalVariableError(node)) {
|
|
break;
|
|
}
|
|
context.errors.push(makeError(expr, node));
|
|
break;
|
|
}
|
|
|
|
default: {
|
|
if (node.type.id === ParenExpr) {
|
|
// We don't support parenthesis in the query to group expressions. We just report error but go on with the
|
|
// parsing.
|
|
context.errors.push(makeError(expr, node));
|
|
}
|
|
// Any other nodes we just ignore and go to its children. This should be fine as there are lots of wrapper
|
|
// nodes that can be skipped.
|
|
// TODO: there are probably cases where we will just skip nodes we don't support and we should be able to
|
|
// detect those and report back.
|
|
let child = node.firstChild;
|
|
while (child) {
|
|
handleExpression(expr, child, context);
|
|
child = child.nextSibling;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function isIntervalVariableError(node: SyntaxNode) {
|
|
return node.prevSibling?.type.id === Expr && node.prevSibling?.firstChild?.type.id === VectorSelector;
|
|
}
|
|
|
|
function getLabel(expr: string, node: SyntaxNode): QueryBuilderLabelFilter {
|
|
const label = getString(expr, node.getChild(LabelName));
|
|
const op = getString(expr, node.getChild(MatchOp));
|
|
const value = getString(expr, node.getChild(StringLiteral)).replace(/"/g, '');
|
|
return {
|
|
label,
|
|
op,
|
|
value,
|
|
};
|
|
}
|
|
|
|
const rangeFunctions = ['changes', 'rate', 'irate', 'increase', 'delta'];
|
|
|
|
/**
|
|
* Handle function call which is usually and identifier and its body > arguments.
|
|
* @param expr
|
|
* @param node
|
|
* @param context
|
|
*/
|
|
function handleFunction(expr: string, node: SyntaxNode, context: Context) {
|
|
const visQuery = context.query;
|
|
const nameNode = node.getChild(FunctionIdentifier);
|
|
const funcName = getString(expr, nameNode);
|
|
|
|
const body = node.getChild(FunctionCallBody);
|
|
const callArgs = body!.getChild(FunctionCallArgs);
|
|
const params = [];
|
|
let interval = '';
|
|
|
|
// This is a bit of a shortcut to get the interval argument. Reasons are
|
|
// - interval is not part of the function args per promQL grammar but we model it as argument for the function in
|
|
// the query model.
|
|
// - it is easier to handle template variables this way as template variable is an error for the parser
|
|
if (rangeFunctions.includes(funcName) || funcName.endsWith('_over_time')) {
|
|
let match = getString(expr, node).match(/\[(.+)\]/);
|
|
if (match?.[1]) {
|
|
interval = match[1];
|
|
params.push(match[1]);
|
|
}
|
|
}
|
|
|
|
const op = { id: funcName, params };
|
|
// We unshift operations to keep the more natural order that we want to have in the visual query editor.
|
|
visQuery.operations.unshift(op);
|
|
|
|
if (callArgs) {
|
|
if (getString(expr, callArgs) === interval + ']') {
|
|
// This is a special case where we have a function with a single argument and it is the interval.
|
|
// This happens when you start adding operations in query builder and did not set a metric yet.
|
|
return;
|
|
}
|
|
updateFunctionArgs(expr, callArgs, context, op);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle aggregation as they are distinct type from other functions.
|
|
* @param expr
|
|
* @param node
|
|
* @param context
|
|
*/
|
|
function handleAggregation(expr: string, node: SyntaxNode, context: Context) {
|
|
const visQuery = context.query;
|
|
const nameNode = node.getChild(AggregateOp);
|
|
let funcName = getString(expr, nameNode);
|
|
|
|
const modifier = node.getChild(AggregateModifier);
|
|
const labels = [];
|
|
|
|
if (modifier) {
|
|
const byModifier = modifier.getChild(`By`);
|
|
if (byModifier && funcName) {
|
|
funcName = `__${funcName}_by`;
|
|
}
|
|
|
|
const withoutModifier = modifier.getChild(Without);
|
|
if (withoutModifier) {
|
|
funcName = `__${funcName}_without`;
|
|
}
|
|
|
|
labels.push(...getAllByType(expr, modifier, GroupingLabel));
|
|
}
|
|
|
|
const body = node.getChild(FunctionCallBody);
|
|
const callArgs = body!.getChild(FunctionCallArgs);
|
|
|
|
const op: QueryBuilderOperation = { id: funcName, params: [] };
|
|
visQuery.operations.unshift(op);
|
|
updateFunctionArgs(expr, callArgs, context, op);
|
|
// We add labels after params in the visual query editor.
|
|
op.params.push(...labels);
|
|
}
|
|
|
|
/**
|
|
* Handle (probably) all types of arguments that function or aggregation can have.
|
|
*
|
|
* FunctionCallArgs are nested bit weirdly basically its [firstArg, ...rest] where rest is again FunctionCallArgs so
|
|
* we cannot just get all the children and iterate them as arguments we have to again recursively traverse through
|
|
* them.
|
|
*
|
|
* @param expr
|
|
* @param node
|
|
* @param context
|
|
* @param op - We need the operation to add the params to as an additional context.
|
|
*/
|
|
function updateFunctionArgs(expr: string, node: SyntaxNode | null, context: Context, op: QueryBuilderOperation) {
|
|
if (!node) {
|
|
return;
|
|
}
|
|
switch (node.type.id) {
|
|
// In case we have an expression we don't know what kind so we have to look at the child as it can be anything.
|
|
case Expr:
|
|
// FunctionCallArgs are nested bit weirdly as mentioned so we have to go one deeper in this case.
|
|
case FunctionCallArgs: {
|
|
let child = node.firstChild;
|
|
while (child) {
|
|
updateFunctionArgs(expr, child, context, op);
|
|
child = child.nextSibling;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case NumberLiteral: {
|
|
op.params.push(parseFloat(getString(expr, node)));
|
|
break;
|
|
}
|
|
|
|
case StringLiteral: {
|
|
op.params.push(getString(expr, node).replace(/"/g, ''));
|
|
break;
|
|
}
|
|
|
|
default: {
|
|
// Means we get to something that does not seem like simple function arg and is probably nested query so jump
|
|
// back to main context
|
|
handleExpression(expr, node, context);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Right now binary expressions can be represented in 2 way in visual query. As additional operation in case it is
|
|
* just operation with scalar or it creates a binaryQuery when it's 2 queries.
|
|
* @param expr
|
|
* @param node
|
|
* @param context
|
|
*/
|
|
function handleBinary(expr: string, node: SyntaxNode, context: Context) {
|
|
const visQuery = context.query;
|
|
const left = node.firstChild!;
|
|
const op = getString(expr, left.nextSibling);
|
|
const binModifier = getBinaryModifier(expr, node.getChild(BinModifiers));
|
|
|
|
const right = node.lastChild!;
|
|
|
|
const opDef = binaryScalarOperatorToOperatorName[op];
|
|
|
|
const leftNumber = left.getChild(NumberLiteral);
|
|
const rightNumber = right.getChild(NumberLiteral);
|
|
|
|
const rightBinary = right.getChild(BinaryExpr);
|
|
|
|
if (leftNumber) {
|
|
// TODO: this should be already handled in case parent is binary expression as it has to be added to parent
|
|
// if query starts with a number that isn't handled now.
|
|
} else {
|
|
// If this is binary we don't really know if there is a query or just chained scalars. So
|
|
// we have to traverse a bit deeper to know
|
|
handleExpression(expr, left, context);
|
|
}
|
|
|
|
if (rightNumber) {
|
|
visQuery.operations.push(makeBinOp(opDef, expr, right, !!binModifier?.isBool));
|
|
} else if (rightBinary) {
|
|
// Due to the way binary ops are parsed we can get a binary operation on the right that starts with a number which
|
|
// is a factor for a current binary operation. So we have to add it as an operation now.
|
|
const leftMostChild = getLeftMostChild(right);
|
|
if (leftMostChild?.type.id === NumberLiteral) {
|
|
visQuery.operations.push(makeBinOp(opDef, expr, leftMostChild, !!binModifier?.isBool));
|
|
}
|
|
|
|
// If we added the first number literal as operation here we still can continue and handle the rest as the first
|
|
// number will be just skipped.
|
|
handleExpression(expr, right, context);
|
|
} else {
|
|
visQuery.binaryQueries = visQuery.binaryQueries || [];
|
|
const binQuery: PromVisualQueryBinary = {
|
|
operator: op,
|
|
query: {
|
|
metric: '',
|
|
labels: [],
|
|
operations: [],
|
|
},
|
|
};
|
|
if (binModifier?.isMatcher) {
|
|
binQuery.vectorMatchesType = binModifier.matchType;
|
|
binQuery.vectorMatches = binModifier.matches;
|
|
}
|
|
visQuery.binaryQueries.push(binQuery);
|
|
handleExpression(expr, right, {
|
|
query: binQuery.query,
|
|
errors: context.errors,
|
|
});
|
|
}
|
|
}
|
|
|
|
function getBinaryModifier(
|
|
expr: string,
|
|
node: SyntaxNode | null
|
|
):
|
|
| { isBool: true; isMatcher: false }
|
|
| { isBool: false; isMatcher: true; matches: string; matchType: 'ignoring' | 'on' }
|
|
| undefined {
|
|
if (!node) {
|
|
return undefined;
|
|
}
|
|
if (node.getChild('Bool')) {
|
|
return { isBool: true, isMatcher: false };
|
|
} else {
|
|
const matcher = node.getChild(OnOrIgnoring);
|
|
if (!matcher) {
|
|
// Not sure what this could be, maybe should be an error.
|
|
return undefined;
|
|
}
|
|
const labels = getString(expr, matcher.getChild(GroupingLabels)?.getChild(GroupingLabelList));
|
|
return {
|
|
isMatcher: true,
|
|
isBool: false,
|
|
matches: labels,
|
|
matchType: matcher.getChild(On) ? 'on' : 'ignoring',
|
|
};
|
|
}
|
|
}
|
|
|
|
function isEmptyQuery(query: PromVisualQuery) {
|
|
if (query.labels.length === 0 && query.operations.length === 0 && !query.metric) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|