Compare commits
1 Commits
main
...
v0.91.0-a8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8b62958f0 |
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -47,8 +47,7 @@ func (s *Step) UnmarshalJSON(b []byte) error {
|
||||
}
|
||||
|
||||
func (s Step) MarshalJSON() ([]byte, error) {
|
||||
// Emit human‑friendly string → "30s"
|
||||
return json.Marshal(s.Duration.String())
|
||||
return json.Marshal(s.Duration.Seconds())
|
||||
}
|
||||
|
||||
// FilterOperator is the operator for the filter.
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
533
pkg/variables/variable_replace_visitor.go
Normal file
533
pkg/variables/variable_replace_visitor.go
Normal 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)
|
||||
}
|
||||
}
|
||||
463
pkg/variables/variable_replace_visitor_test.go
Normal file
463
pkg/variables/variable_replace_visitor_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user