Compare commits

...

1 Commits

Author SHA1 Message Date
srikanthccv
a8b62958f0 chore: add endpoint to replace varibales 2025-07-31 19:12:31 +05:30
17 changed files with 1137 additions and 29 deletions

View File

@@ -395,6 +395,11 @@ func (p *BaseSeasonalProvider) getAnomalies(ctx context.Context, orgID valuer.UU
continue
}
// no data;
if len(result.Aggregations) == 0 {
continue
}
aggOfInterest := result.Aggregations[0]
for _, series := range aggOfInterest.Series {

View File

@@ -113,6 +113,8 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
// v5
router.HandleFunc("/api/v5/query_range", am.ViewAccess(ah.queryRangeV5)).Methods(http.MethodPost)
router.HandleFunc("/api/v5//substitute_vars", am.ViewAccess(ah.QuerierAPI.ReplaceVariables)).Methods(http.MethodPost)
// Gateway
router.PathPrefix(gateway.RoutePrefix).HandlerFunc(am.EditAccess(ah.ServeGatewayHTTP))

View File

@@ -211,7 +211,8 @@ func (r *AnomalyRule) prepareQueryRangeV5(ctx context.Context, ts time.Time) (*q
},
NoCache: true,
}
copy(r.Condition().CompositeQuery.Queries, req.CompositeQuery.Queries)
req.CompositeQuery.Queries = make([]qbtypes.QueryEnvelope, len(r.Condition().CompositeQuery.Queries))
copy(req.CompositeQuery.Queries, r.Condition().CompositeQuery.Queries)
return req, nil
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/SigNoz/signoz/pkg/types/authtypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/SigNoz/signoz/pkg/variables"
)
type API struct {
@@ -85,6 +86,56 @@ func (a *API) QueryRange(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusOK, queryRangeResponse)
}
// TODO(srikanthccv): everything done here can be done on frontend as well
// For the time being I am adding a helper function
func (a *API) ReplaceVariables(rw http.ResponseWriter, req *http.Request) {
var queryRangeRequest qbtypes.QueryRangeRequest
if err := json.NewDecoder(req.Body).Decode(&queryRangeRequest); err != nil {
render.Error(rw, err)
return
}
errs := []error{}
for idx, item := range queryRangeRequest.CompositeQuery.Queries {
if item.Type == qbtypes.QueryTypeBuilder {
switch spec := item.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
if spec.Filter != nil && spec.Filter.Expression != "" {
replaced, err := variables.ReplaceVariablesInExpression(spec.Filter.Expression, queryRangeRequest.Variables)
if err != nil {
errs = append(errs, err)
}
spec.Filter.Expression = replaced
}
queryRangeRequest.CompositeQuery.Queries[idx].Spec = spec
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
replaced, err := variables.ReplaceVariablesInExpression(spec.Filter.Expression, queryRangeRequest.Variables)
if err != nil {
errs = append(errs, err)
}
spec.Filter.Expression = replaced
queryRangeRequest.CompositeQuery.Queries[idx].Spec = spec
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
replaced, err := variables.ReplaceVariablesInExpression(spec.Filter.Expression, queryRangeRequest.Variables)
if err != nil {
errs = append(errs, err)
}
spec.Filter.Expression = replaced
queryRangeRequest.CompositeQuery.Queries[idx].Spec = spec
}
}
}
if len(errs) != 0 {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, errors.Join(errs...).Error()))
return
}
render.Success(rw, http.StatusOK, queryRangeRequest)
}
func (a *API) logEvent(ctx context.Context, referrer string, event *qbtypes.QBEvent) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {

View File

@@ -194,6 +194,7 @@ func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error)
if err != nil {
return nil, err
}
result.Warnings = stmt.Warnings
return result, nil
}
@@ -297,6 +298,8 @@ func (q *builderQuery[T]) executeWindowList(ctx context.Context) (*qbtypes.Resul
}
}
var warnings []string
for _, r := range buckets {
q.spec.Offset = 0
q.spec.Limit = need
@@ -305,6 +308,7 @@ func (q *builderQuery[T]) executeWindowList(ctx context.Context) (*qbtypes.Resul
if err != nil {
return nil, err
}
warnings = stmt.Warnings
// Execute with proper context for partial value detection
res, err := q.executeWithContext(ctx, stmt.Query, stmt.Args)
@@ -345,6 +349,7 @@ func (q *builderQuery[T]) executeWindowList(ctx context.Context) (*qbtypes.Resul
Rows: rows,
NextCursor: nextCursor,
},
Warnings: warnings,
Stats: qbtypes.ExecStats{
RowsScanned: totalRows,
BytesScanned: totalBytes,

View File

@@ -332,7 +332,7 @@ func readAsScalar(rows driver.Rows, queryName string) (*qbtypes.ScalarData, erro
}, nil
}
func derefValue(v interface{}) interface{} {
func derefValue(v any) any {
if v == nil {
return nil
}

View File

@@ -470,6 +470,7 @@ func (aH *APIHandler) RegisterQueryRangeV4Routes(router *mux.Router, am *middlew
func (aH *APIHandler) RegisterQueryRangeV5Routes(router *mux.Router, am *middleware.AuthZ) {
subRouter := router.PathPrefix("/api/v5").Subrouter()
subRouter.HandleFunc("/query_range", am.ViewAccess(aH.QuerierAPI.QueryRange)).Methods(http.MethodPost)
subRouter.HandleFunc("/substitute_vars", am.ViewAccess(aH.QuerierAPI.ReplaceVariables)).Methods(http.MethodPost)
}
// todo(remove): Implemented at render package (github.com/SigNoz/signoz/pkg/http/render) with the new error structure

View File

@@ -603,7 +603,7 @@ func (c *CompositeQuery) Validate() error {
return fmt.Errorf("composite query is required")
}
if c.BuilderQueries == nil && c.ClickHouseQueries == nil && c.PromQueries == nil {
if c.BuilderQueries == nil && c.ClickHouseQueries == nil && c.PromQueries == nil && len(c.Queries) == 0 {
return fmt.Errorf("composite query must contain at least one query type")
}

View File

@@ -291,7 +291,8 @@ func (r *ThresholdRule) prepareQueryRangeV5(ctx context.Context, ts time.Time) (
},
NoCache: true,
}
copy(r.Condition().CompositeQuery.Queries, req.CompositeQuery.Queries)
req.CompositeQuery.Queries = make([]qbtypes.QueryEnvelope, len(r.Condition().CompositeQuery.Queries))
copy(req.CompositeQuery.Queries, r.Condition().CompositeQuery.Queries)
return req, nil
}

View File

@@ -152,7 +152,7 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
aggFunc, ok := AggreFuncMap[valuer.NewString(name)]
if !ok {
return nil
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unrecognized function: %s", name)
}
var args []chparser.Expr

View File

@@ -126,6 +126,10 @@ func PrepareWhereClause(query string, opts FilterExprVisitorOpts) (*sqlbuilder.W
return nil, nil, combinedErrors.WithAdditional(visitor.errors...)
}
if cond == "" {
cond = "true"
}
whereClause := sqlbuilder.NewWhereClause().AddWhereExpr(visitor.builder.Args, cond)
return whereClause, visitor.warnings, nil
@@ -194,7 +198,13 @@ func (v *filterExpressionVisitor) VisitOrExpression(ctx *grammar.OrExpressionCon
andExpressionConditions := make([]string, len(andExpressions))
for i, expr := range andExpressions {
andExpressionConditions[i] = v.Visit(expr).(string)
if condExpr, ok := v.Visit(expr).(string); ok && condExpr != "" {
andExpressionConditions[i] = condExpr
}
}
if len(andExpressionConditions) == 0 {
return ""
}
if len(andExpressionConditions) == 1 {
@@ -210,7 +220,13 @@ func (v *filterExpressionVisitor) VisitAndExpression(ctx *grammar.AndExpressionC
unaryExpressionConditions := make([]string, len(unaryExpressions))
for i, expr := range unaryExpressions {
unaryExpressionConditions[i] = v.Visit(expr).(string)
if condExpr, ok := v.Visit(expr).(string); ok && condExpr != "" {
unaryExpressionConditions[i] = condExpr
}
}
if len(unaryExpressionConditions) == 0 {
return ""
}
if len(unaryExpressionConditions) == 1 {
@@ -236,7 +252,10 @@ func (v *filterExpressionVisitor) VisitUnaryExpression(ctx *grammar.UnaryExpress
func (v *filterExpressionVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any {
if ctx.OrExpression() != nil {
// This is a parenthesized expression
return fmt.Sprintf("(%s)", v.Visit(ctx.OrExpression()).(string))
if condExpr, ok := v.Visit(ctx.OrExpression()).(string); ok && condExpr != "" {
return fmt.Sprintf("(%s)", v.Visit(ctx.OrExpression()).(string))
}
return ""
} else if ctx.Comparison() != nil {
return v.Visit(ctx.Comparison())
} else if ctx.FunctionCall() != nil {
@@ -248,7 +267,7 @@ func (v *filterExpressionVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any
// Handle standalone key/value as a full text search term
if ctx.GetChildCount() == 1 {
if v.skipFullTextFilter {
return "true"
return ""
}
if v.fullTextColumn == nil {
@@ -297,11 +316,7 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
// if key is missing and can be ignored, the condition is ignored
if len(keys) == 0 && v.ignoreNotFoundKeys {
// Why do we return "true"? to prevent from create a empty tuple
// example, if the condition is (x AND (y OR z))
// if we find ourselves ignoring all, then it creates and invalid
// condition (()) which throws invalid tuples error
return "true"
return ""
}
// this is used to skip the resource filtering on main table if
@@ -315,11 +330,7 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
}
keys = filteredKeys
if len(keys) == 0 {
// Why do we return "true"? to prevent from create a empty tuple
// example, if the condition is (resource.service.name='api' AND (env='prod' OR env='production'))
// if we find ourselves skipping all, then it creates and invalid
// condition (()) which throws invalid tuples error
return "true"
return ""
}
}
@@ -368,7 +379,7 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
var varItem qbtypes.VariableItem
varItem, ok = v.variables[var_]
// if not present, try without `$` prefix
if !ok {
if !ok && len(var_) > 0 {
varItem, ok = v.variables[var_[1:]]
}
@@ -547,7 +558,7 @@ func (v *filterExpressionVisitor) VisitValueList(ctx *grammar.ValueListContext)
func (v *filterExpressionVisitor) VisitFullText(ctx *grammar.FullTextContext) any {
if v.skipFullTextFilter {
return "true"
return ""
}
var text string
@@ -573,7 +584,7 @@ func (v *filterExpressionVisitor) VisitFullText(ctx *grammar.FullTextContext) an
// VisitFunctionCall handles function calls like has(), hasAny(), etc.
func (v *filterExpressionVisitor) VisitFunctionCall(ctx *grammar.FunctionCallContext) any {
if v.skipFunctionCalls {
return "true"
return ""
}
// Get function name based on which token is present
@@ -742,7 +753,7 @@ func (v *filterExpressionVisitor) VisitKey(ctx *grammar.KeyContext) any {
if len(fieldKeysForName) > 1 && !v.keysWithWarnings[keyName] {
// this is warning state, we must have a unambiguous key
v.warnings = append(v.warnings, fmt.Sprintf(
"key `%s` is ambiguous, found %d different combinations of field context and data type: %v",
"key `%s` is ambiguous, found %d different combinations of field context and data type: %v. Learn more [here](https://signoz.io/docs/userguide/search-syntax/#field-context)",
fieldKey.Name,
len(fieldKeysForName),
fieldKeysForName,

View File

@@ -47,8 +47,7 @@ func (s *Step) UnmarshalJSON(b []byte) error {
}
func (s Step) MarshalJSON() ([]byte, error) {
// Emit humanfriendly string → "30s"
return json.Marshal(s.Duration.String())
return json.Marshal(s.Duration.Seconds())
}
// FilterOperator is the operator for the filter.

View File

@@ -37,7 +37,7 @@ type QueryBuilderQuery[T any] struct {
Limit int `json:"limit,omitempty"`
// limitBy fields to limit by
LimitBy LimitBy `json:"limitBy,omitempty"`
LimitBy *LimitBy `json:"limitBy,omitempty"`
// offset the number of rows to skip
// TODO: remove this once we have cursor-based pagination everywhere?

View File

@@ -6,6 +6,7 @@ import (
"math"
"reflect"
"slices"
"strconv"
"strings"
"time"
@@ -168,6 +169,41 @@ type RawRow struct {
Data map[string]*any `json:"data"`
}
func roundToNonZeroDecimals(val float64, n int) float64 {
if val == 0 || math.IsNaN(val) || math.IsInf(val, 0) {
return val
}
absVal := math.Abs(val)
// For numbers >= 1, we want to round to n decimal places total
if absVal >= 1 {
// Round to n decimal places
multiplier := math.Pow(10, float64(n))
rounded := math.Round(val*multiplier) / multiplier
// If the result is a whole number, return it as such
if rounded == math.Trunc(rounded) {
return rounded
}
// Remove trailing zeros by converting to string and back
str := strconv.FormatFloat(rounded, 'f', -1, 64)
result, _ := strconv.ParseFloat(str, 64)
return result
}
// For numbers < 1, count n significant figures after first non-zero digit
order := math.Floor(math.Log10(absVal))
scale := math.Pow(10, -order+float64(n)-1)
rounded := math.Round(val*scale) / scale
// Clean up floating point precision
str := strconv.FormatFloat(rounded, 'f', -1, 64)
result, _ := strconv.ParseFloat(str, 64)
return result
}
func sanitizeValue(v any) any {
if v == nil {
return nil
@@ -181,7 +217,7 @@ func sanitizeValue(v any) any {
} else if math.IsInf(f, -1) {
return "-Inf"
}
return f
return roundToNonZeroDecimals(f, 3)
}
if f, ok := v.(float32); ok {
@@ -193,7 +229,7 @@ func sanitizeValue(v any) any {
} else if math.IsInf(f64, -1) {
return "-Inf"
}
return f
return float32(roundToNonZeroDecimals(f64, 3)) // ADD ROUNDING HERE
}
rv := reflect.ValueOf(v)

View File

@@ -175,7 +175,7 @@ func (rc *RuleCondition) IsValid() bool {
}
if rc.QueryType() == v3.QueryTypePromQL {
if len(rc.CompositeQuery.PromQueries) == 0 {
if len(rc.CompositeQuery.Queries) == 0 {
return false
}
}

View File

@@ -0,0 +1,533 @@
package variables
import (
"fmt"
"strconv"
"strings"
grammar "github.com/SigNoz/signoz/pkg/parser/grammar"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/antlr4-go/antlr/v4"
)
// ErrorListener collects syntax errors during parsing
type ErrorListener struct {
*antlr.DefaultErrorListener
SyntaxErrors []error
}
// NewErrorListener creates a new error listener
func NewErrorListener() *ErrorListener {
return &ErrorListener{
DefaultErrorListener: antlr.NewDefaultErrorListener(),
SyntaxErrors: []error{},
}
}
// SyntaxError is called when a syntax error is encountered
func (e *ErrorListener) SyntaxError(recognizer antlr.Recognizer, offendingSymbol any, line, column int, msg string, ex antlr.RecognitionException) {
e.SyntaxErrors = append(e.SyntaxErrors, fmt.Errorf("line %d:%d %s", line, column, msg))
}
// variableReplacementVisitor implements the visitor interface
// to replace variables in filter expressions with their actual values
type variableReplacementVisitor struct {
variables map[string]qbtypes.VariableItem
errors []string
}
// specialSkipMarker is used to indicate that a condition should be removed
const specialSkipMarker = "__SKIP_CONDITION__"
// ReplaceVariablesInExpression takes a filter expression and returns it with variables replaced
func ReplaceVariablesInExpression(expression string, variables map[string]qbtypes.VariableItem) (string, error) {
// Setup the ANTLR parsing pipeline
input := antlr.NewInputStream(expression)
lexer := grammar.NewFilterQueryLexer(input)
visitor := &variableReplacementVisitor{
variables: variables,
errors: []string{},
}
// Set up error handling
lexerErrorListener := NewErrorListener()
lexer.RemoveErrorListeners()
lexer.AddErrorListener(lexerErrorListener)
tokens := antlr.NewCommonTokenStream(lexer, 0)
parserErrorListener := NewErrorListener()
parser := grammar.NewFilterQueryParser(tokens)
parser.RemoveErrorListeners()
parser.AddErrorListener(parserErrorListener)
// Parse the query
tree := parser.Query()
// Handle syntax errors
if len(parserErrorListener.SyntaxErrors) > 0 {
return "", fmt.Errorf("syntax errors in expression: %v", parserErrorListener.SyntaxErrors)
}
// Visit the parse tree
result := visitor.Visit(tree).(string)
if len(visitor.errors) > 0 {
return "", fmt.Errorf("errors processing expression: %v", visitor.errors)
}
// If the entire expression should be skipped, return empty string
if result == specialSkipMarker {
return "", nil
}
return result, nil
}
// Visit dispatches to the specific visit method based on node type
func (v *variableReplacementVisitor) Visit(tree antlr.ParseTree) any {
if tree == nil {
return ""
}
switch t := tree.(type) {
case *grammar.QueryContext:
return v.VisitQuery(t)
case *grammar.ExpressionContext:
return v.VisitExpression(t)
case *grammar.OrExpressionContext:
return v.VisitOrExpression(t)
case *grammar.AndExpressionContext:
return v.VisitAndExpression(t)
case *grammar.UnaryExpressionContext:
return v.VisitUnaryExpression(t)
case *grammar.PrimaryContext:
return v.VisitPrimary(t)
case *grammar.ComparisonContext:
return v.VisitComparison(t)
case *grammar.InClauseContext:
return v.VisitInClause(t)
case *grammar.NotInClauseContext:
return v.VisitNotInClause(t)
case *grammar.ValueListContext:
return v.VisitValueList(t)
case *grammar.FullTextContext:
return v.VisitFullText(t)
case *grammar.FunctionCallContext:
return v.VisitFunctionCall(t)
case *grammar.FunctionParamListContext:
return v.VisitFunctionParamList(t)
case *grammar.FunctionParamContext:
return v.VisitFunctionParam(t)
case *grammar.ArrayContext:
return v.VisitArray(t)
case *grammar.ValueContext:
return v.VisitValue(t)
case *grammar.KeyContext:
return v.VisitKey(t)
default:
// For unknown types, return the original text
return tree.GetText()
}
}
func (v *variableReplacementVisitor) VisitQuery(ctx *grammar.QueryContext) any {
return v.Visit(ctx.Expression())
}
func (v *variableReplacementVisitor) VisitExpression(ctx *grammar.ExpressionContext) any {
return v.Visit(ctx.OrExpression())
}
func (v *variableReplacementVisitor) VisitOrExpression(ctx *grammar.OrExpressionContext) any {
andExpressions := ctx.AllAndExpression()
parts := make([]string, 0, len(andExpressions))
for _, expr := range andExpressions {
part := v.Visit(expr).(string)
// Skip conditions that should be removed
if part != specialSkipMarker && part != "" {
parts = append(parts, part)
}
}
if len(parts) == 0 {
return specialSkipMarker
}
if len(parts) == 1 {
return parts[0]
}
return strings.Join(parts, " OR ")
}
func (v *variableReplacementVisitor) VisitAndExpression(ctx *grammar.AndExpressionContext) any {
unaryExpressions := ctx.AllUnaryExpression()
parts := make([]string, 0, len(unaryExpressions))
for _, expr := range unaryExpressions {
part := v.Visit(expr).(string)
// Skip conditions that should be removed
if part != specialSkipMarker && part != "" {
parts = append(parts, part)
}
}
if len(parts) == 0 {
return specialSkipMarker
}
if len(parts) == 1 {
return parts[0]
}
return strings.Join(parts, " AND ")
}
func (v *variableReplacementVisitor) VisitUnaryExpression(ctx *grammar.UnaryExpressionContext) any {
result := v.Visit(ctx.Primary()).(string)
// If the condition should be skipped, propagate it up
if result == specialSkipMarker {
return specialSkipMarker
}
if ctx.NOT() != nil {
return "NOT " + result
}
return result
}
func (v *variableReplacementVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any {
if ctx.OrExpression() != nil {
return "(" + v.Visit(ctx.OrExpression()).(string) + ")"
} else if ctx.Comparison() != nil {
return v.Visit(ctx.Comparison())
} else if ctx.FunctionCall() != nil {
return v.Visit(ctx.FunctionCall())
} else if ctx.FullText() != nil {
return v.Visit(ctx.FullText())
}
// Handle standalone key/value
if ctx.GetChildCount() == 1 {
child := ctx.GetChild(0)
if parseTree, ok := child.(antlr.ParseTree); ok {
return v.Visit(parseTree).(string)
}
// Fallback to getting text from the context
return ctx.GetText()
}
return ctx.GetText()
}
func (v *variableReplacementVisitor) VisitComparison(ctx *grammar.ComparisonContext) any {
// First check if any value contains __all__ variable
values := ctx.AllValue()
for _, val := range values {
valueResult := v.Visit(val).(string)
if valueResult == specialSkipMarker {
return specialSkipMarker
}
}
// Also check in IN/NOT IN clauses
if ctx.InClause() != nil {
inResult := v.Visit(ctx.InClause()).(string)
if inResult == specialSkipMarker {
return specialSkipMarker
}
}
if ctx.NotInClause() != nil {
notInResult := v.Visit(ctx.NotInClause()).(string)
if notInResult == specialSkipMarker {
return specialSkipMarker
}
}
var parts []string
// Add key
parts = append(parts, v.Visit(ctx.Key()).(string))
// Handle EXISTS
if ctx.EXISTS() != nil {
if ctx.NOT() != nil {
parts = append(parts, " NOT")
}
parts = append(parts, " EXISTS")
return strings.Join(parts, "")
}
// Handle IN/NOT IN
if ctx.InClause() != nil {
parts = append(parts, " IN ")
parts = append(parts, v.Visit(ctx.InClause()).(string))
return strings.Join(parts, "")
}
if ctx.NotInClause() != nil {
parts = append(parts, " NOT IN ")
parts = append(parts, v.Visit(ctx.NotInClause()).(string))
return strings.Join(parts, "")
}
// Handle BETWEEN
if ctx.BETWEEN() != nil {
if ctx.NOT() != nil {
parts = append(parts, " NOT")
}
parts = append(parts, " BETWEEN ")
values := ctx.AllValue()
parts = append(parts, v.Visit(values[0]).(string))
parts = append(parts, " AND ")
parts = append(parts, v.Visit(values[1]).(string))
return strings.Join(parts, "")
}
// Handle other operators
if ctx.EQUALS() != nil {
parts = append(parts, " = ")
} else if ctx.NOT_EQUALS() != nil {
parts = append(parts, " != ")
} else if ctx.NEQ() != nil {
parts = append(parts, " <> ")
} else if ctx.LT() != nil {
parts = append(parts, " < ")
} else if ctx.LE() != nil {
parts = append(parts, " <= ")
} else if ctx.GT() != nil {
parts = append(parts, " > ")
} else if ctx.GE() != nil {
parts = append(parts, " >= ")
} else if ctx.LIKE() != nil {
parts = append(parts, " LIKE ")
} else if ctx.ILIKE() != nil {
parts = append(parts, " ILIKE ")
} else if ctx.NOT_LIKE() != nil {
parts = append(parts, " NOT LIKE ")
} else if ctx.NOT_ILIKE() != nil {
parts = append(parts, " NOT ILIKE ")
} else if ctx.REGEXP() != nil {
if ctx.NOT() != nil {
parts = append(parts, " NOT")
}
parts = append(parts, " REGEXP ")
} else if ctx.CONTAINS() != nil {
if ctx.NOT() != nil {
parts = append(parts, " NOT")
}
parts = append(parts, " CONTAINS ")
}
// Add value
if len(values) > 0 {
parts = append(parts, v.Visit(values[0]).(string))
}
return strings.Join(parts, "")
}
func (v *variableReplacementVisitor) VisitInClause(ctx *grammar.InClauseContext) any {
if ctx.ValueList() != nil {
result := v.Visit(ctx.ValueList()).(string)
if result == specialSkipMarker {
return specialSkipMarker
}
return result
}
result := v.Visit(ctx.Value()).(string)
if result == specialSkipMarker {
return specialSkipMarker
}
return result
}
func (v *variableReplacementVisitor) VisitNotInClause(ctx *grammar.NotInClauseContext) any {
if ctx.ValueList() != nil {
result := v.Visit(ctx.ValueList()).(string)
if result == specialSkipMarker {
return specialSkipMarker
}
return result
}
result := v.Visit(ctx.Value()).(string)
if result == specialSkipMarker {
return specialSkipMarker
}
return result
}
func (v *variableReplacementVisitor) VisitValueList(ctx *grammar.ValueListContext) any {
values := ctx.AllValue()
// Check if any value is __all__
for _, val := range values {
result := v.Visit(val).(string)
if result == specialSkipMarker {
return specialSkipMarker
}
}
parts := make([]string, 0, len(values))
for i, val := range values {
if i > 0 {
parts = append(parts, ", ")
}
parts = append(parts, v.Visit(val).(string))
}
return "(" + strings.Join(parts, "") + ")"
}
func (v *variableReplacementVisitor) VisitFullText(ctx *grammar.FullTextContext) any {
if ctx.QUOTED_TEXT() != nil {
return ctx.QUOTED_TEXT().GetText()
} else if ctx.FREETEXT() != nil {
return ctx.FREETEXT().GetText()
}
return ""
}
func (v *variableReplacementVisitor) VisitFunctionCall(ctx *grammar.FunctionCallContext) any {
var functionName string
if ctx.HAS() != nil {
functionName = "has"
} else if ctx.HASANY() != nil {
functionName = "hasAny"
} else if ctx.HASALL() != nil {
functionName = "hasAll"
}
params := v.Visit(ctx.FunctionParamList()).(string)
return functionName + "(" + params + ")"
}
func (v *variableReplacementVisitor) VisitFunctionParamList(ctx *grammar.FunctionParamListContext) any {
params := ctx.AllFunctionParam()
parts := make([]string, 0, len(params))
for i, param := range params {
if i > 0 {
parts = append(parts, ", ")
}
parts = append(parts, v.Visit(param).(string))
}
return strings.Join(parts, "")
}
func (v *variableReplacementVisitor) VisitFunctionParam(ctx *grammar.FunctionParamContext) any {
if ctx.Key() != nil {
return v.Visit(ctx.Key())
} else if ctx.Value() != nil {
return v.Visit(ctx.Value())
} else if ctx.Array() != nil {
return v.Visit(ctx.Array())
}
return ""
}
func (v *variableReplacementVisitor) VisitArray(ctx *grammar.ArrayContext) any {
valueList := v.Visit(ctx.ValueList()).(string)
// Don't wrap in brackets if it's already wrapped in parentheses
if strings.HasPrefix(valueList, "(") {
return valueList
}
return "[" + valueList + "]"
}
func (v *variableReplacementVisitor) VisitValue(ctx *grammar.ValueContext) any {
// First get the original value
var originalValue string
if ctx.QUOTED_TEXT() != nil {
originalValue = ctx.QUOTED_TEXT().GetText()
} else if ctx.NUMBER() != nil {
originalValue = ctx.NUMBER().GetText()
} else if ctx.KEY() != nil {
originalValue = ctx.KEY().GetText()
}
// Check if this is a variable (starts with $)
if strings.HasPrefix(originalValue, "$") {
varName := originalValue
// Try with $ prefix first
varItem, ok := v.variables[varName]
if !ok && len(varName) > 1 {
// Try without $ prefix
varItem, ok = v.variables[varName[1:]]
}
if ok {
// Handle dynamic variable with __all__ value
if varItem.Type == qbtypes.DynamicVariableType {
if allVal, ok := varItem.Value.(string); ok && allVal == "__all__" {
// Return special marker to indicate this condition should be removed
return specialSkipMarker
}
}
// Replace variable with its value
return v.formatVariableValue(varItem.Value)
}
}
// Return original value if not a variable or variable not found
return originalValue
}
func (v *variableReplacementVisitor) VisitKey(ctx *grammar.KeyContext) any {
keyText := ctx.GetText()
// Check if this key is actually a variable
if strings.HasPrefix(keyText, "$") {
varName := keyText
// Try with $ prefix first
varItem, ok := v.variables[varName]
if !ok && len(varName) > 1 {
// Try without $ prefix
varItem, ok = v.variables[varName[1:]]
}
if ok {
// Handle dynamic variable with __all__ value
if varItem.Type == qbtypes.DynamicVariableType {
if allVal, ok := varItem.Value.(string); ok && allVal == "__all__" {
return specialSkipMarker
}
}
// Replace variable with its value
return v.formatVariableValue(varItem.Value)
}
}
return keyText
}
// formatVariableValue formats a variable value for inclusion in the expression
func (v *variableReplacementVisitor) formatVariableValue(value any) string {
switch val := value.(type) {
case string:
// Quote string values
return fmt.Sprintf("'%s'", strings.ReplaceAll(val, "'", "\\'"))
case []any:
// Format array values
parts := make([]string, len(val))
for i, item := range val {
parts[i] = v.formatVariableValue(item)
}
return "(" + strings.Join(parts, ", ") + ")"
case int, int32, int64, float32, float64:
return fmt.Sprintf("%v", val)
case bool:
return strconv.FormatBool(val)
default:
return fmt.Sprintf("%v", val)
}
}

View File

@@ -0,0 +1,463 @@
package variables
import (
"testing"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/stretchr/testify/assert"
)
func TestReplaceVariablesInExpression(t *testing.T) {
tests := []struct {
name string
expression string
variables map[string]qbtypes.VariableItem
expected string
wantErr bool
}{
{
name: "simple string variable replacement",
expression: "service.name = $service",
variables: map[string]qbtypes.VariableItem{
"service": {
Type: qbtypes.DynamicVariableType,
Value: "auth-service",
},
},
expected: "service.name = 'auth-service'",
},
{
name: "simple string variable replacement",
expression: "service.name = $service",
variables: map[string]qbtypes.VariableItem{
"service": {
Type: qbtypes.QueryVariableType,
Value: "auth-service",
},
},
expected: "service.name = 'auth-service'",
},
{
name: "simple string variable replacement",
expression: "service.name = $service",
variables: map[string]qbtypes.VariableItem{
"service": {
Type: qbtypes.CustomVariableType,
Value: "auth-service",
},
},
expected: "service.name = 'auth-service'",
},
{
name: "simple string variable replacement",
expression: "service.name = $service",
variables: map[string]qbtypes.VariableItem{
"service": {
Type: qbtypes.TextBoxVariableType,
Value: "auth-service",
},
},
expected: "service.name = 'auth-service'",
},
{
name: "variable with dollar sign prefix in map",
expression: "service.name = $service",
variables: map[string]qbtypes.VariableItem{
"$service": {
Type: qbtypes.DynamicVariableType,
Value: "auth-service",
},
},
expected: "service.name = 'auth-service'",
},
{
name: "numeric variable replacement",
expression: "http.status_code > $threshold",
variables: map[string]qbtypes.VariableItem{
"threshold": {
Type: qbtypes.DynamicVariableType,
Value: 400,
},
},
expected: "http.status_code > 400",
},
{
name: "boolean variable replacement",
expression: "is_error = $error_flag",
variables: map[string]qbtypes.VariableItem{
"error_flag": {
Type: qbtypes.DynamicVariableType,
Value: true,
},
},
expected: "is_error = true",
},
{
name: "array variable in IN clause",
expression: "service.name IN $services",
variables: map[string]qbtypes.VariableItem{
"services": {
Type: qbtypes.DynamicVariableType,
Value: []any{"auth", "api", "web"},
},
},
expected: "service.name IN ('auth', 'api', 'web')",
},
{
name: "array variable with mixed types",
expression: "id IN $ids",
variables: map[string]qbtypes.VariableItem{
"ids": {
Type: qbtypes.DynamicVariableType,
Value: []any{1, 2, "three", 4.5},
},
},
expected: "id IN (1, 2, 'three', 4.5)",
},
{
name: "multiple variables in expression",
expression: "service.name = $service AND env = $environment AND status_code >= $min_code",
variables: map[string]qbtypes.VariableItem{
"service": {
Type: qbtypes.DynamicVariableType,
Value: "auth-service",
},
"environment": {
Type: qbtypes.DynamicVariableType,
Value: "production",
},
"min_code": {
Type: qbtypes.DynamicVariableType,
Value: 400,
},
},
expected: "service.name = 'auth-service' AND env = 'production' AND status_code >= 400",
},
{
name: "variable in complex expression with parentheses",
expression: "(service.name = $service OR service.name = 'default') AND status_code > $threshold",
variables: map[string]qbtypes.VariableItem{
"service": {
Type: qbtypes.DynamicVariableType,
Value: "auth",
},
"threshold": {
Type: qbtypes.DynamicVariableType,
Value: 200,
},
},
expected: "(service.name = 'auth' OR service.name = 'default') AND status_code > 200",
},
{
name: "variable not found - preserved as is",
expression: "service.name = $unknown_service",
variables: map[string]qbtypes.VariableItem{},
expected: "service.name = $unknown_service",
},
{
name: "string with quotes needs escaping",
expression: "message = $msg",
variables: map[string]qbtypes.VariableItem{
"msg": {
Type: qbtypes.DynamicVariableType,
Value: "user's request",
},
},
expected: "message = 'user\\'s request'",
},
{
name: "dynamic variable with __all__ value",
expression: "service.name = $all_services",
variables: map[string]qbtypes.VariableItem{
"all_services": {
Type: qbtypes.DynamicVariableType,
Value: "__all__",
},
},
expected: "", // Special value preserved
},
{
name: "variable in NOT IN clause",
expression: "service.name NOT IN $excluded",
variables: map[string]qbtypes.VariableItem{
"excluded": {
Type: qbtypes.DynamicVariableType,
Value: []any{"test", "debug"},
},
},
expected: "service.name NOT IN ('test', 'debug')",
},
{
name: "variable in BETWEEN clause",
expression: "latency BETWEEN $min AND $max",
variables: map[string]qbtypes.VariableItem{
"min": {
Type: qbtypes.DynamicVariableType,
Value: 100,
},
"max": {
Type: qbtypes.DynamicVariableType,
Value: 500,
},
},
expected: "latency BETWEEN 100 AND 500",
},
{
name: "variable in LIKE expression",
expression: "service.name LIKE $pattern",
variables: map[string]qbtypes.VariableItem{
"pattern": {
Type: qbtypes.DynamicVariableType,
Value: "%auth%",
},
},
expected: "service.name LIKE '%auth%'",
},
{
name: "variable in function call",
expression: "has(tags, $tag)",
variables: map[string]qbtypes.VariableItem{
"tag": {
Type: qbtypes.DynamicVariableType,
Value: "error",
},
},
expected: "has(tags, 'error')",
},
{
name: "variable in hasAny function",
expression: "hasAny(tags, $tags)",
variables: map[string]qbtypes.VariableItem{
"tags": {
Type: qbtypes.DynamicVariableType,
Value: []any{"error", "warning", "info"},
},
},
expected: "hasAny(tags, ('error', 'warning', 'info'))",
},
{
name: "empty array variable",
expression: "service.name IN $services",
variables: map[string]qbtypes.VariableItem{
"services": {
Type: qbtypes.DynamicVariableType,
Value: []any{},
},
},
expected: "service.name IN ()",
},
{
name: "expression with OR and variables",
expression: "env = $env1 OR env = $env2",
variables: map[string]qbtypes.VariableItem{
"env1": {
Type: qbtypes.DynamicVariableType,
Value: "staging",
},
"env2": {
Type: qbtypes.DynamicVariableType,
Value: "production",
},
},
expected: "env = 'staging' OR env = 'production'",
},
{
name: "NOT expression with variable",
expression: "NOT service.name = $service",
variables: map[string]qbtypes.VariableItem{
"service": {
Type: qbtypes.DynamicVariableType,
Value: "test-service",
},
},
expected: "NOT service.name = 'test-service'",
},
{
name: "variable in EXISTS clause",
expression: "tags EXISTS",
variables: map[string]qbtypes.VariableItem{},
expected: "tags EXISTS",
},
{
name: "complex nested expression",
expression: "(service.name IN $services AND env = $env) OR (status_code >= $error_code)",
variables: map[string]qbtypes.VariableItem{
"services": {
Type: qbtypes.DynamicVariableType,
Value: []any{"auth", "api"},
},
"env": {
Type: qbtypes.DynamicVariableType,
Value: "prod",
},
"error_code": {
Type: qbtypes.DynamicVariableType,
Value: 500,
},
},
expected: "(service.name IN ('auth', 'api') AND env = 'prod') OR (status_code >= 500)",
},
{
name: "float variable",
expression: "cpu_usage > $threshold",
variables: map[string]qbtypes.VariableItem{
"threshold": {
Type: qbtypes.DynamicVariableType,
Value: 85.5,
},
},
expected: "cpu_usage > 85.5",
},
{
name: "variable in REGEXP expression",
expression: "message REGEXP $pattern",
variables: map[string]qbtypes.VariableItem{
"pattern": {
Type: qbtypes.DynamicVariableType,
Value: "^ERROR.*",
},
},
expected: "message REGEXP '^ERROR.*'",
},
{
name: "variable in NOT REGEXP expression",
expression: "message NOT REGEXP $pattern",
variables: map[string]qbtypes.VariableItem{
"pattern": {
Type: qbtypes.DynamicVariableType,
Value: "^DEBUG.*",
},
},
expected: "message NOT REGEXP '^DEBUG.*'",
},
{
name: "invalid syntax",
expression: "service.name = = $service",
variables: map[string]qbtypes.VariableItem{},
wantErr: true,
},
{
name: "full text search not affected by variables",
expression: "'error message'",
variables: map[string]qbtypes.VariableItem{},
expected: "'error message'",
},
{
name: "comparison operators",
expression: "a < $v1 AND b <= $v2 AND c > $v3 AND d >= $v4 AND e != $v5 AND f <> $v6",
variables: map[string]qbtypes.VariableItem{
"v1": {Type: qbtypes.DynamicVariableType, Value: 10},
"v2": {Type: qbtypes.DynamicVariableType, Value: 20},
"v3": {Type: qbtypes.DynamicVariableType, Value: 30},
"v4": {Type: qbtypes.DynamicVariableType, Value: 40},
"v5": {Type: qbtypes.DynamicVariableType, Value: "test"},
"v6": {Type: qbtypes.DynamicVariableType, Value: "other"},
},
expected: "a < 10 AND b <= 20 AND c > 30 AND d >= 40 AND e != 'test' AND f <> 'other'",
},
{
name: "CONTAINS operator with variable",
expression: "message CONTAINS $text",
variables: map[string]qbtypes.VariableItem{
"text": {
Type: qbtypes.DynamicVariableType,
Value: "error",
},
},
expected: "message CONTAINS 'error'",
},
{
name: "NOT CONTAINS operator with variable",
expression: "message NOT CONTAINS $text",
variables: map[string]qbtypes.VariableItem{
"text": {
Type: qbtypes.DynamicVariableType,
Value: "debug",
},
},
expected: "message NOT CONTAINS 'debug'",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ReplaceVariablesInExpression(tt.expression, tt.variables)
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}
func TestFormatVariableValue(t *testing.T) {
visitor := &variableReplacementVisitor{}
tests := []struct {
name string
value any
expected string
}{
{
name: "string value",
value: "hello",
expected: "'hello'",
},
{
name: "string with single quote",
value: "user's data",
expected: "'user\\'s data'",
},
{
name: "integer value",
value: 42,
expected: "42",
},
{
name: "float value",
value: 3.14,
expected: "3.14",
},
{
name: "boolean true",
value: true,
expected: "true",
},
{
name: "boolean false",
value: false,
expected: "false",
},
{
name: "array of strings",
value: []any{"a", "b", "c"},
expected: "('a', 'b', 'c')",
},
{
name: "array of mixed types",
value: []any{"string", 123, true, 45.6},
expected: "('string', 123, true, 45.6)",
},
{
name: "empty array",
value: []any{},
expected: "()",
},
{
name: "nil value",
value: nil,
expected: "<nil>",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := visitor.formatVariableValue(tt.value)
assert.Equal(t, tt.expected, result)
})
}
}